深入理解vue響應式原理
原創【51CTO.com原創稿件】前言
Vue 最獨特的特性之一,是其非侵入性的響應式系統。數據模型僅僅是普通的 JavaScript 對象。而當你修改它們時,視圖會進行更新。這使得狀態管理非常簡單直接,不過理解其工作原理同樣重要,這樣你可以避開一些常見的問題。----官方文檔 本文將針對響應式原理做一個詳細介紹,并且帶你實現一個基礎版的響應式系統。本文的代碼請猛戳Github博客
什么是響應式
我們先來看個例子:
- <div id="app">
- <div>Price :¥{{ price }}</div>
- <div>Total:¥{{ price * quantity }}</div>
- <div>Taxes: ¥{{ totalPriceWithTax }}</div>
- <button @click="changePrice">改變價格</button>
- </div>
- var app = new Vue({
- el: '#app',
- data() {
- return {
- price: 5.0,
- quantity: 2
- };
- },
- computed: {
- totalPriceWithTax() {
- return this.price * this.quantity * 1.03;
- }
- },
- methods: {
- changePrice() {
- this.price = 10;
- }
- }
- })
上例中當price 發生變化的時候,Vue就知道自己需要做三件事情:
- 更新頁面上price的值
- 計算表達式 price*quantity 的值,更新頁面
- 調用totalPriceWithTax 函數,更新頁面
發生變化后,會重新對頁面渲染,這就是Vue響應式,那么這一切是怎么做到的呢?
想完成這個過程,我們需要:
- 偵測數據的變化
- 收集視圖依賴了哪些數據
- 數據變化時,自動“通知”需要更新的視圖部分,并進行更新
對應專業俗語分別是:
- 數據劫持 / 數據代理
- 依賴收集
- 發布訂閱模式
如何偵測數據的變化
首先有個問題,在Javascript中,如何偵測一個對象的變化? 其實有兩種辦法可以偵測到變化:使用Object.defineProperty和ES6的Proxy,這就是進行數據劫持或數據代理。這部分代碼主要參考珠峰架構課。
方法1.Object.defineProperty實現
Vue通過設定對象屬性的 setter/getter 方法來監聽數據的變化,通過getter進行依賴收集,而每個setter方法就是一個觀察者,在數據變更的時候通知訂閱者更新視圖。
- function render () {
- console.log('模擬視圖渲染')
- }
- let data = {
- name: '浪里行舟',
- location: { x: 100, y: 100 }
- }
- observe(data)
- function observe (obj) {
- // 判斷類型
- if (!obj || typeof obj !== 'object') {
- return
- }
- Object.keys(obj).forEach(key => {
- defineReactive(obj, key, obj[key])
- })
- function defineReactive (obj, key, value) {
- // 遞歸子屬性
- observe(value)
- Object.defineProperty(obj, key, {
- enumerable: true, //可枚舉(可以遍歷)
- configurable: true, //可配置(比如可以刪除)
- get: function reactiveGetter () {
- console.log('get', value) // 監聽
- return value
- },
- set: function reactiveSetter (newVal) {
- observe(newVal) //如果賦值是一個對象,也要遞歸子屬性
- if (newVal !== value) {
- console.log('set', newVal) // 監聽
- render()
- value = newVal
- }
- }
- })
- }
- }
- data.location = {
- x: 1000,
- y: 1000
- } //set {x: 1000,y: 1000} 模擬視圖渲染
- data.name // get 浪里行舟
幾個注意點補充說明:
- 這種方式無法檢測到對象屬性的添加或刪除(如data.location.a=1)。
這是因為 Vue 通過Object.defineProperty來將對象的key轉換成getter/setter的形式來追蹤變化,但getter/setter只能追蹤一個數據是否被修改,無法追蹤新增屬性和刪除屬性。如果是刪除屬性,我們可以用vm.$delete實現,那如果是新增屬性,該怎么辦呢? 1)可以使用 Vue.set(location, a, 1) 方法向嵌套對象添加響應式屬性; 2)也可以給這個對象重新賦值,比如data.location = {...data.location,a:1}
- Object.defineProperty 不能監聽數組的變化,需要進行數組方法的重寫
- function render() {
- console.log('模擬視圖渲染')
- }
- let obj = [1, 2, 3]
- let methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push']
- // 先獲取到原來的原型上的方法
- let arrayProto = Array.prototype
- // 創建一個自己的原型 并且重寫methods這些方法
- let proto = Object.create(arrayProto)
- methods.forEach(method => {
- proto[method] = function() {
- // AOP
- arrayProto[method].call(this, ...arguments)
- render()
- }
- })
- function observer(obj) {
- // 把所有的屬性定義成set/get的方式
- if (Array.isArray(obj)) {
- obj.__proto__ = proto
- return
- }
- if (typeof obj == 'object') {
- for (let key in obj) {
- defineReactive(obj, key, obj[key])
- }
- }
- }
- function defineReactive(data, key, value) {
- observer(value)
- Object.defineProperty(data, key, {
- get() {
- return value
- },
- set(newValue) {
- observer(newValue)
- if (newValue !== value) {
- render()
- value = newValue
- }
- }
- })
- }
- observer(obj)
- function $set(data, key, value) {
- defineReactive(data, key, value)
- }
- obj.push(123, 55)
- console.log(obj) //[1, 2, 3, 123, 55]
這種方法將數組的常用方法進行重寫,進而覆蓋掉原生的數組方法,重寫之后的數組方法需要能夠被攔截。但有些數組操作Vue時攔截不到的,當然也就沒辦法響應,比如:
- obj.length-- // 不支持數組的長度變化
- obj[0]=1 // 修改數組中***個元素,也無法偵測數組的變化
ES6提供了元編程的能力,所以有能力攔截,Vue3.0可能會用ES6中Proxy 作為實現數據代理的主要方式。
方法2.Proxy實現
Proxy 是 JavaScript 2015 的一個新特性。Proxy 的代理是針對整個對象的,而不是對象的某個屬性,因此不同于 Object.defineProperty 的必須遍歷對象每個屬性,Proxy 只需要做一層代理就可以監聽同級結構下的所有屬性變化,當然對于深層結構,遞歸還是需要進行的。此外**Proxy支持代理數組的變化。**
- function render() {
- console.log('模擬視圖的更新')
- }
- let obj = {
- name: '前端工匠',
- age: { age: 100 },
- arr: [1, 2, 3]
- }
- let handler = {
- get(target, key) {
- // 如果取的值是對象就在對這個對象進行數據劫持
- if (typeof target[key] == 'object' && target[key] !== null) {
- return new Proxy(target[key], handler)
- }
- return Reflect.get(target, key)
- },
- set(target, key, value) {
- if (key === 'length') return true
- render()
- return Reflect.set(target, key, value)
- }
- }
- let proxy = new Proxy(obj, handler)
- proxy.age.name = '浪里行舟' // 支持新增屬性
- console.log(proxy.age.name) // 模擬視圖的更新 浪里行舟
- proxy.arr[0] = '浪里行舟' //支持數組的內容發生變化
- console.log(proxy.arr) // 模擬視圖的更新 ['浪里行舟', 2, 3 ]
- proxy.arr.length-- // 無效
以上代碼不僅精簡,而且還是實現一套代碼對對象和數組的偵測都適用。不過Proxy兼容性不太好!
我們之所以要觀察數據,其目的在于當數據的屬性發生變化時,可以通知那些曾經使用了該數據的地方。比如***例子中,模板中使用了price 數據,當它發生變化時,要向使用了它的地方發送通知。那如何收集依賴呢?
收集依賴與發布訂閱模式
如何收集依賴,總結起來就一句話,在getter中收集依賴,在setter中觸發依賴 我們先來實現一個 Dep 類,用于解耦屬性的依賴收集和派發更新操作。
- // 通過 Dep 解耦屬性的依賴和更新操作
- class Dep {
- constructor() {
- this.subs = []
- }
- // 添加依賴
- addSub(sub) {
- this.subs.push(sub)
- }
- // 更新
- notify() {
- this.subs.forEach(sub => {
- sub.update()
- })
- }
- }
- // 全局屬性,通過該屬性配置 Watcher
- Dep.target = null
當需要依賴收集的時候調用 addSub,當需要派發更新的時候調用 notify。具體如何調用呢?
- let dp = new Dep()
- dp.addSub(() => {
- console.log('emit here')
- })
- dp.notify()
這就是一個簡單實現的“事件發布訂閱模式”,當然代碼只是啟發思路,真實應用還比較“粗糙”,沒有進行事件名設置,APIs 也并不豐富,但完全能夠說明問題了。
接下來我們先來簡單的了解下 Vue 組件掛載時添加響應式的過程。在組件掛載時,會先對所有需要的屬性調用 Object.defineProperty(),然后實例化 Watcher,傳入組件更新的回調。在實例化過程中,會對模板中的屬性進行求值,觸發依賴收集。我們可以把Watcher理解成一個中介的角色,數據發生變化時通知它,然后它再通知其他地方。
***需要對 defineReactive 函數進行改造,在自定義函數中添加依賴收集和派發更新相關的代碼。
- function render () {
- console.log('模擬視圖渲染')
- }
- let data = {
- name: '浪里行舟',
- location: { x: 100, y: 100 }
- }
- observe(data)
- let dp = new Dep()
- function observe (obj) {
- // 判斷類型
- if (!obj || typeof obj !== 'object') {
- return
- }
- Object.keys(obj).forEach(key => {
- defineReactive(obj, key, obj[key])
- })
- function defineReactive (obj, key, value) {
- // 遞歸子屬性
- observe(value)
- Object.defineProperty(obj, key, {
- enumerable: true, //可枚舉(可以遍歷)
- configurable: true, //可配置(比如可以刪除)
- get: function reactiveGetter () {
- console.log('get', value) // 監聽
- // 將 Watcher 添加到訂閱
- if (Dep.target) {
- dp.addSub(Dep.target)
- }
- return value
- },
- set: function reactiveSetter (newVal) {
- observe(newVal) //如果賦值是一個對象,也要遞歸子屬性
- if (newVal !== value) {
- console.log('set', newVal) // 監聽
- render()
- value = newVal
- // 執行 watcher 的 update 方法
- dp.notify()
- }
- }
- })
- }
- }
以上所有代碼實現了一個簡易的數據響應式,核心思路就是手動觸發一次屬性的 getter 來實現依賴收集。
總結
我們再來回顧下整個過程:
- 在 Vue 中模板編譯過程中的指令或者數據綁定都會實例化一個 Watcher 實例,實例化過程中會觸發 get() 將自身指向 Dep.target;
- data在 Observer 時執行 getter 會觸發 dep.depend() 進行依賴收集;依賴收集的結果:
- data在 Observer 時閉包的dep實例的subs添加觀察它的 Watcher 實例;
- Watcher 的deps中添加觀察對象 Observer 時的閉包dep;
- 當data中被 Observer 的某個對象值變化后,觸發subs中觀察它的watcher執行 update() 方法,***實際上是調用watcher的回調函數cb,進而更新視圖。
參考文章和書籍
作者介紹
浪里行舟:碩士研究生,專注于前端。個人公眾號:「前端工匠」,致力于打造適合初中級工程師能夠快速吸收的一系列優質文章!
【51CTO原創稿件,合作站點轉載請注明原文作者和出處為51CTO.com】