# Vue如何實現響應式(Reactivity) ## 前言 前端框架Vue的基本思路和Angular、React並無二致,其核心就在於: 當數據變化時,自動去刷新頁面DOM,這使得我們能從繁瑣的DOM操作中解放出來,從而專心地去處理業務邏輯。 ## 何謂響應式 當數據變化後會自動更新某個函示 映射到組件的實現就是,當數據變化後,會自動觸發組件重新渲染 響應式式vue.js組件化更新渲染的一個核心機制 #### 命令式編程實現響應式 ``` let price = 5 let quantity = 2 let total = price * qunatity console.log(total) // 10 quantity = 3 total = price * qunatity console.log(total) // 15 ``` 很顯然這樣很笨,每次改quantity都要重新執行 total = price * qunatity #### 用函數實現響應式 ``` let product = { price: 5, quantity: 2 } let total = 0 let dep = new Set() // to store our effects let effect = () => { total = product.price * product.quantity } function track(){ dep.add(effect) } function trigger() { dep.forEach(effect => effect()) } track() // 收集響應 effect() // 執行響應,否則一開始total還是0 console.log(total) // 10 product.quantity = 3 trigger() // 重新執行effect計算total console.log(total) // 15 ``` 雖然每次修改quantity還是要執行一次trigger()才會重算total 不過至少接下來只要思考如何解決如何自動執行trigger這件事就好 假如若想要改變quantity就重新觸發trigger,限制一定要呼叫這個setState,就可以達到改值自動響應 ``` let product = { price: 5, quantity: 2 } let total = 0 let dep = new Set() // to store our effects let effect = () => { total = product.price * product.quantity } function track(){ dep.add(effect) } function trigger() { dep.forEach(effect => effect()) } track(); effect(); console.log(total) // 10 function setState(param) { product = { ...product, ...param, }; trigger(); } setState({ quantity: 3 }); console.log(total); // 15 ``` 有沒有一種react的既視感,其實react就是差不多是這種概念來實現數據響應式 但vue是怎麼做到改變值(eg: product.quantity = 3 then trigger)就可以做到響應式的呢? ### vue 2 使用defineProprty 實現數據劫持 [MDN defineProperty文件](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) 靜態方法 Object.defineProperty() 會直接對一個物件定義、或是修改現有的屬性。執行後會回傳定義完的物件。 ``` var aValue = 1 var o = {a: aValue}; Object.defineProperty(o, "a", { get: function () { console.log('trigger o.a getter!') return aValue; }, set(value){ aValue = value console.log('set o.a to '+ value) } }); o.a = 2 // "set o.a to 2" o.b = 3 // 未對o.b definProperty實作setter,不會trigger setter console.log(o.a) // "trigger o.a getter!" console.log(o.b) // 未對o.b definProperty實作setter,不會trigger getter ``` 缺點: 1. 無法對新增/刪除的屬性作監聽(vue 2 會為此提供api: $set/ $delete) 2. 初始化階段遞迴執行Object.defineProperty帶來的性能負擔 [code sanbox](https://jsfiddle.net/yamapi0103/5byrzfan/35/) 範例: 對nested Object/array 實作getter/setter, vue 源碼也是類似這樣去做攔截 ``` function isPlainObject(obj) { return Object.prototype.toString.call(obj) === '[object Object]'; } const isArray = Array.isArray; function defineReactive(data, key, value) { observe(value); // 递归观察嵌套对象和数组 Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function defineGet() { console.log(`get key: ${key} value: ${value}`); return value; }, set: function defineSet(newVal) { console.log(`set key: ${key} value: ${newVal}`); value = newVal; }, }); } class Observer { constructor(data) { if (isArray(data)) { for (let i = 0; i < data.length; i++) { defineReactive(data, i, data[i]); } } else if (isPlainObject(data)) { this.walk(data); } } walk(data) { // 循環對象 對屬性依次劫持 Object.keys(data).forEach(function (key) { defineReactive(data, key, data[key]); }); } } function observe(data) { if (typeof data !== 'object' || data == null) return; // 只對對象劫持 return new Observer(data); } let nestedObj = { a: { b: 2, }, c: [], }; observe(nestedObj); nestedObj.a.b; // "get key: b value: 2" nestedObj.c.push({ d: 1 }); nestedObj.c[0].d; // getter not work ``` 由於Object.defineProperty劫持的是對象的屬性,所以新增屬性時,需要重新遍歷對象,對其新增屬性再使用Object.defineProperty進行劫持。(要再執行一次observe(data)) 所以,Object.defineProperty 只能監控現有變化的能力,所以vue2.x放棄了這個特性。 (使用vue為data中的數據新增屬性時,需要使用vm.$set才能確保新增的屬性也是響應式的。) #### [補充]那 vue 2 如何用defineProperty 在新增數據時實現數據劫持? 定義在core/observer/array.js中, 下面是這部分源碼的分析。 vue 改寫了原生Array.prototype的方法(methodsToPatch 這7種),所以要用這幾種方法操作陣列才能實現響應式 [官網有提到](https://v2.vuejs.org/v2/guide/list#Mutation-Methods ): ![](https://hackmd.io/_uploads/Byo7zDbzT.png) arrayMethods 是對數組的方法進行重寫(包裝) [code sandbox](https://jsfiddle.net/yamapi0103/e0j4vqh5/29/) ``` let oldArrayProto = Array.prototype; // newArrayProto.__proto__ = oldArrayProto let newArrayProto = Object.create(oldArrayProto); // 添加自己的數組方法,同時保留原有的方法 let methods = ['push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice']; // concat/ slice 不會改變原術組 methods.forEach((method) => { newArrayProto[method] = function (...args) { const result = oldArrayProto[method].apply(this, args); // 調用原來的方法 // 對數組新增的數據再次進行劫持 let inserted; let ob = this.__ob__; switch (method) { case 'push': case 'unshift': inserted = args; break; case 'splice': inserted = args.slice(2); break; default: break; } if (inserted) { // 對新增內容再次進行觀測 ob.observeArray(inserted); } return result; }; }); function isPlainObject(obj) { return Object.prototype.toString.call(obj) === '[object Object]'; } const isArray = Array.isArray; function defineReactive(data, key, value) { /* if (isPlainObject(value) || isArray(value)) { observe(value); // 递归观察嵌套对象和数组 } */ observe(value); // 递归观察嵌套对象和数组 Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function defineGet() { console.log(`get key: ${key} value: ${value}`); return value; }, set: function defineSet(newVal) { console.log(`set key: ${key} value: ${newVal}`); value = newVal; }, }); } class Observer { constructor(data) { // 如果數據上有__ob__,說明這個屬性被觀測過了 Object.defineProperty(data, '__ob__', { value: this, enumerable: false, }); if (isArray(data)) { /* for (let i = 0; i < data.length; i++) { defineReactive(data, i, data[i]) } */ // 重寫數組7個mutable的方法 data.__proto__ = newArrayProto; this.observeArray(data); } else { this.walk(data); } } walk(data) { // 循環對象 對屬性依次劫持 Object.keys(data).forEach(function (key) { defineReactive(data, key, data[key]); }); } observeArray(data) { data.forEach((item) => observe(item)); } } function observe(data) { if (typeof data !== 'object' || data == null) return; // 只對對象劫持 if (data.__ob__ instanceof Observer) { // 說明這個對象已被代理過了 return data.__ob__; } return new Observer(data); } let nestedArr = [1, 2, [2, 3], { d: 333 }]; let nestedObj = { a: [], }; observe(nestedObj); nestedObj.a.push({ b: 'bbb' }); // 新增的屬性後續皆有被觸發 nestedObj.a[0].b; // "get key: a value: bbb" nestedObj.a[0].b = 'ababab'; // "set key: b value: ababab" ``` vue 2 中偵測陣列變化並沒有使用defineProperty,因為修改索引情況不多,且會浪費性能,採用重寫陣列方法的變異方法實現 initData -> observe -> 對我們傳入的陣列進行原型鏈修改,後續調用的方法都是重寫後的方法 -> 對陣列中每個對象也再次進行代理 - arr[1] = 100 (X) // 不會進行畫面渲染 - arr[0].a =100 (O) // 陣列內部若還是物件,會進行畫面渲染,因為陣列中的對象會被observe ### [補充]vue 3 使用 Proxy 實現數據劫持 [MDN Proxy說明文件](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy) 1. Proxy 可以直接監聽整個物件 優點: 可以檢測到對象的任何修改,彌補defineProperty的不足 ``` // 創建一个空的陣列 const myArray = []; // 創建代理對象來監聽陣列 const arrayProxy = new Proxy(myArray, { get(target, property, receiver) { // 返回陣列原生方法 return target[property] }, set(target, property, value, receiver) { // 在這裡log 陣列變化 console.log(`Array property '${property}' was set to: ${value}`) target[property] = value return true; } }); // 添加元素到陣列 arrayProxy.push(1); // "Array property '0' was set to: 1 arrayProxy.push(2); // "Array property '1' was set to: 2" // 輸出陣列 console.log(arrayProxy); // [1, 2] ``` Proxy則是直接代理物件,是可以攔截到物件的新增屬性的。 ### 用觀察者模式實現 其核心思路是對傳入的數據,深度遍歷每個屬性並使用defineProperty實作get和set方法,使其具有響應式 做法: - 調用getter時進行依賴收集(訂閱) =>dep.depend() - 調用setter 時通知依賴更新(發布) => dep.notify() 每次頁面渲染就會重新收集 ![](https://hackmd.io/_uploads/r1dlItbzT.png) [源碼解析影片](https://www.bilibili.com/video/BV1P8411M7dM?p=8&vd_source=b7b95c88e0694fa186b56e535ce87b9d) target可想成一個組件上的data的每個屬性都有各自的trigger函式,只要屬性變動會去執行對應的effect ![](https://hackmd.io/_uploads/H1JK5t-za.png) class Dep 收集依賴 [code sandbox](https://jsfiddle.net/yamapi0103/0n7cg2s6/18/) ``` class Dep { constructor(value) { this.subs = new Set(); } depend() { if (activeEffect) { console.log('before add subs'); this.subs.add(activeEffect); } } notify() { this.subs.forEach((effect) => effect()); } } var activeEffct = null; function watchEffect(effect) { activeEffect = effect; effect(); activeEffect = null; } function isPlainObject(obj) { return Object.prototype.toString.call(obj) === '[object Object]'; } const isArray = Array.isArray; function defineReactive(data, key, value) { observe(value); // 递归观察嵌套对象和数组 const dep = new Dep(); Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function defineGet() { dep.depend(); console.log(`get key: ${key} value: ${value}`); return value; }, set: function defineSet(newVal) { dep.notify(); console.log(`set key: ${key} value: ${newVal}`); value = newVal; }, }); } class Observer { constructor(data) { if (isArray(data)) { for (let i = 0; i < data.length; i++) { defineReactive(data, i, data[i]); } } else if (isPlainObject(data)) { this.walk(data); } } walk(data) { // 循環對象 對屬性依次劫持 Object.keys(data).forEach(function (key) { defineReactive(data, key, data[key]); }); } } function observe(data) { if (typeof data !== 'object' || data == null) return; // 只對對象劫持 return new Observer(data); } let nestedObj = { a: 1, c: [], }; observe(nestedObj); watchEffect(() => { console.log(nestedObj.a); console.log('數據變了 我要去更新畫面了!'); }); nestedObj.a = 2; // 執行watchEffect() ``` [code sandbox](https://codesandbox.io/s/vue-reactivity-test-cxz63x?file=/src/App.vue) 參考來源 - https://vue-course-doc.vercel.app/#_1-%E8%AF%BE%E7%A8%8B%E7%AE%80%E4%BB%8B - https://front-chef.coderbridge.io/2021/02/27/vue2-vue3-reactivity/ - [defineProperty vs Proxy](https://juejin.cn/post/6844903965180575751) - [解析vue源碼 上](https://juejin.cn/post/7105337345649901605#1) - [解析vue源碼 下](https://juejin.cn/post/7104871637710798885) https://www.gushiciku.cn/pl/g8v2/zh-tw