手寫Vue源碼之數據劫持示例詳解
源代碼: 傳送門
Vue會對我們在data中傳入的數據進行攔截:
- 對象:遞歸的為對象的每個屬性都設置get/set方法
- 數組:修改數組的原型方法,對於會修改原數組的方法進行瞭重寫
在用戶為data中的對象設置值、修改值以及調用修改原數組的方法時,都可以添加一些邏輯來進行處理,實現數據更新頁面也同時更新。
Vue中的響應式(reactive): 對對象屬性或數組方法進行瞭攔截,在屬性或數組更新時可以同時自動地更新視圖。在代碼中被觀測過的數據具有響應性
創建Vue實例
我們先讓代碼實現下面的功能:
<body> <script> const vm = new Vue({ el: '#app', data () { return { age: 18 }; } }); // 會觸發age屬性對應的set方法 vm.age = 20; // 會觸發age屬性對應的get方法 console.log(vm.age); </script> </body>
在src/index.js中,定義Vue的構造函數。用戶用到的Vue就是在這裡導出的Vue:
import initMixin from './init'; function Vue (options) { this._init(options); } // 進行原型方法擴展 initMixin(Vue); export default Vue;
在init中,會定義原型上的_init方法,並進行狀態的初始化:
import initState from './state'; function initMixin (Vue) { Vue.prototype._init = function (options) { const vm = this; // 將用戶傳入的選項放到vm.$options上,之後可以很方便的通過實例vm來訪問所有實例化時傳入的選項 vm.$options = options; initState(vm); }; } export default initMixin;
在_init方法中,所有的options被放到瞭vm.$options中,這不僅讓之後代碼中可以更方便的來獲取用戶傳入的配置項,也可以讓用戶通過這個api來獲取實例化時傳入的一些自定義選選項。比如在Vuex 和Vue-Router中,實例化時傳入的router和store屬性便可以通過$options獲取到。
除瞭設置vm.$options,_init中還執行瞭initState方法。該方法中會判斷選項中傳入的屬性,來分別進行props、methods、data、watch、computed 等配置項的初始化操作,這裡我們主要處理data選項:
import { observe } from './observer'; import { proxy } from './shared/utils'; function initState (vm) { const options = vm.$options; if (options.props) { initProps(vm); } if (options.methods) { initMethods(vm); } if (options.data) { initData(vm); } if (options.computed) { initComputed(vm) } if (options.watch) { initWatch(vm) } } function initData (vm) { let data = vm.$options.data; vm._data = data = typeof data === 'function' ? data.call(vm) : data; // 對data中的數據進行攔截 observe(data); // 將data中的屬性代理到vm上 for (const key in data) { if (data.hasOwnProperty(key)) { // 為vm代理所有data中的屬性,可以直接通過vm.xxx來進行獲取 proxy(vm, key, data); } } } export default initState;
在initData中進行瞭如下操作:
- data可能是對象或函數,這裡將data統一處理為對象
- 觀測data中的數據,為所有對象屬性添加set/get方法,重寫數組的原型鏈方法
- 將data中的屬性代理到vm上,方便用戶直接通過實例vm來訪問對應的值,而不是通過vm._data來訪問
新建src/observer/index.js,在這裡書寫observe函數的邏輯:
function observe (data) { // 如果是對象,會遍歷對象中的每一個元素 if (typeof data === 'object' && data !== null) { // 已經觀測過的值不再處理 if (data.__ob__) { return; } new Observer(data); } } export { observe };
observe函數中會過濾data中的數據,隻對對象和數組進行處理,真正的處理邏輯在Observer中:
/** * 為data中的所有對象設置`set/get`方法 */ class Observer { constructor (value) { this.value = value; // 為data中的每一個對象和數組都添加__ob__屬性,方便直接可以通過data中的屬性來直接調用Observer實例上的屬性和方法 defineProperty(this.value, '__ob__', this); // 這裡會對數組和對象進行單獨處理,因為為數組中的每一個索引都設置get/set方法性能消耗比較大 if (Array.isArray(value)) { Object.setPrototypeOf(value, arrayProtoCopy); this.observeArray(value); } else { this.walk(); } } walk () { for (const key in this.value) { if (this.value.hasOwnProperty(key)) { defineReactive(this.value, key); } } } observeArray (value) { for (let i = 0; i < value.length; i++) { observe(value[i]); } } }
需要註意的是,__ob__屬性要設置為不可枚舉,否則之後在對象遍歷時可能會引發死循環
Observer類中會為對象和數組都添加__ob__屬性,之後便可以直接通過data中的對象和數組vm.value.__ob__來獲取到Observer實例。
當傳入的value為數組時,由於觀測數組的每一個索引會耗費比較大的性能,並且在實際使用中,我們可能隻會操作數組的第一項和最後一項,即arr[0],arr[arr.length-1],很少會寫出arr[23] = xxx的代碼。
所以我們選擇對數組的方法進行重寫,將數組的原型指向繼承Array.prototype新創建的對象arrayProtoCopy,對數組中的每一項繼續進行觀測。
創建data中數組原型的邏輯在src/observer/array.js中:
// if (Array.isArray(value)) { // Object.setPrototypeOf(value, arrayProtoCopy); // this.observeArray(); // } const arrayProto = Array.prototype; export const arrayProtoCopy = Object.create(arrayProto); const methods = ['push', 'pop', 'unshift', 'shift', 'splice', 'reverse', 'sort']; methods.forEach(method => { arrayProtoCopy[method] = function (...args) { const result = arrayProto[method].apply(this, args); console.log('change array value'); // data中的數組會調用這裡定義的方法,this指向該數組 const ob = this.__ob__; let inserted; switch (method) { case 'push': case 'unshift': inserted = args; break; case 'splice': // splice(index,deleteCount,item1,item2) inserted = args.slice(2); break; } if (inserted) {ob.observeArray(inserted);} return result; }; });
通過Object.create方法,可以創建一個原型為Array.prototype的新對象arrayProtoCopy。修改原數組的7個方法會設置為新對象的私有屬性,並且在執行時會調用arrayProto 上對應的方法。
在這樣處理之後,便可以在arrayProto中的方法執行前後添加自己的邏輯,而除瞭這7個方法外的其它方法,會根據原型鏈,使用arrayProto上的對應方法,並不會有任何額外的處理。
在修改原數組的方法中,添加瞭如下的額外邏輯:
const ob = this.__ob__; let inserted; switch (method) { case 'push': case 'unshift': inserted = args; break; case 'splice': // splice(index,deleteCount,item1,item2) inserted = args.slice(2); break; } if (inserted) {ob.observeArray(inserted);}
push、unshift、splice會為數組新增元素,對於新增的元素,也要對其進行觀測。這裡利用到瞭Observer中為數組添加的__ob__屬性,來直接調用ob.observeArray ,對數組中新增的元素繼續進行觀測。
對於對象,要遍歷對象的每一個屬性,來為其添加set/get方法。如果對象的屬性依舊是對象,會對其進行遞歸處理
function defineReactive (target, key) { let value = target[key]; // 繼續對value進行監聽,如果value還是對象的話,會繼續new Observer,執行defineProperty來為其設置get/set方法 // 否則會在observe方法中什麼都不做 observe(value); Object.defineProperty(target, key, { get () { console.log('get value'); return value; }, set (newValue) { if (newValue !== value) { // 新加的元素也可能是對象,繼續為新加對象的屬性設置get/set方法 observe(newValue); // 這樣寫會新將value指向一個新的值,而不會影響target[key] console.log('set value'); value = newValue; } } }); } class Observer { constructor (value) { // some code ... if (Array.isArray(value)) { // some code ... } else { this.walk(); } } walk () { for (const key in this.value) { if (this.value.hasOwnProperty(key)) { defineReactive(this.value, key); } } } // some code ... }
數據觀測存在的問題
檢測變化的註意事項
我們先創建一個簡單的例子:
const mv = new Vue({ data () { return { arr: [1, 2, 3], person: { name: 'zs', age: 20 } } } })
對於對象,我們隻是攔截瞭它的取值和賦值操作,添加值和刪除值並不會進行攔截:
vm.person.school = '北大' delete vm.person.age
而對於數組,用索引修改值以及修改數組長度不會被觀測到:
vm.arr[0] = 0 vm.arr.length--
為瞭能處理上述的情況,Vue為用戶提供瞭$set和$delete方法:
- $set: 為響應式對象添加一個屬性,確保新屬性也是響應式的,因此會觸發視圖更新
- $delete: 刪除對象上的一個屬性。如果對象是響應式的,確保刪除觸發視圖更新。
結語
通過實現Vue的數據劫持,將會對Vue的數據初始化和響應式有更深的認識。
在工作中,我們可能總是會疑惑,為什麼我更新瞭值,但是頁面沒有發生變化?現在我們可以從源碼的角度進行理解,從而更清楚的知道代碼中存在的問題以及如何解決和避免這些問題。
代碼的目錄結構是參考瞭源碼的,所以看完文章的小夥伴,也可以從源碼中找出對應的代碼進行閱讀,相信你會有不一樣的理解!
到此這篇關於手寫Vue源碼之數據劫持的文章就介紹到這瞭,更多相關Vue源碼之數據劫持內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!