提到“響應式”三個字,大家立刻想到啥?響應式佈局?響應式程式設計?
從字面意思可以看出,具有“響應式”特徵的事物會根據條件變化,使得目標自動作出對應變化。比如在“響應式佈局”中,頁面根據不同裝置尺寸自動顯示不同樣式。
Vue.js 中的響應式也是一樣,當資料發生變化後,使用到該資料的檢視也會相應進行自動更新。
接下來我根據個人理解,和大家一起探索下 Vue.js 中的響應式原理,如有錯誤,歡迎指點?~~
一、Vue.js 響應式的使用
現在有個很簡單的需求,點選頁面中 “leo” 文字後,文字內容修改為“你好,前端自習課”。
我們可以直接操作 DOM,來完成這個需求:
<span id="name">leo</span>
const node = document.querySelector('#name')
node.innerText = '你好,前端自習課';
實現起來比較簡單,當我們需要修改的資料有很多時(比如相同資料被多處引用),這樣的操作將變得複雜。
既然說到 Vue.js,我們就來看看 Vue.js 怎麼實現上面需求:
<template>
<div id="app">
<span @click="setName">{{ name }}</span>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
name: "leo",
};
},
methods: {
setName() {
this.name = "你好,前端自習課";
},
},
};
</script>
觀察上面程式碼,我們通過改變資料,來自動更新檢視。當我們有多個地方引用這個 name
時,檢視都會自動更新。
<template>
<div id="app">
<span @click="setName">{{ name }}</span>
<span>{{ name }}</span>
<span>{{ name }}</span>
<span>{{ name }}</span>
</div>
</template>
當我們使用目前主流的前端框架 Vue.js 和 React 開發業務時,只需關注頁面資料如何變化,因為資料變化後,檢視也會自動更新,這讓我們從繁雜的 DOM 操作中解脫出來,提高開發效率。
二、回顧觀察者模式
前面反覆提到“通過改變資料,來自動更新檢視”,換個說法就是“資料改變後,使用該資料的地方被動發生響應,更新檢視”。
是不是有種熟悉的感覺?資料無需關注自身被多少物件引用,只需在資料變化時,通知到引用的物件即可,引用的物件作出響應。恩,有種觀察者模式的味道?
關於觀察者模式,可閱讀我之前寫的《圖解設計模式之觀察者模式(TypeScript)》。
1. 觀察者模式流程
觀察者模式表示一種“一對多”的關係,n 個觀察者關注 1 個被觀察者,被觀察者可以主動通知所有觀察者。接下圖:
在這張圖中,粉絲想及時收到“前端自習課”最新文章,只需關注即可,“前端自習課”有新文章,會主動推送給每個粉絲。該過程中,“前端自習課”是被觀察者,每位“粉絲”是觀察者。
2. 觀察者模式核心
觀察者模式核心組成包括:n 個觀察者和 1 個被觀察者。這裡實現一個簡單觀察者模式:
2.1 定義介面
// 觀察目標介面
interface ISubject {
addObserver: (observer: Observer) => void; // 新增觀察者
removeObserver: (observer: Observer) => void; // 移除觀察者
notify: () => void; // 通知觀察者
}
// 觀察者介面
interface IObserver {
update: () => void;
}
2.2 實現被觀察者類
// 實現被觀察者類
class Subject implements ISubject {
private observers: IObserver[] = [];
public addObserver(observer: IObserver): void {
this.observers.push(observer);
}
public removeObserver(observer: IObserver): void {
const idx: number = this.observers.indexOf(observer);
~idx && this.observers.splice(idx, 1);
}
public notify(): void {
this.observers.forEach(observer => {
observer.update();
});
}
}
2.3 實現觀察者類
// 實現觀察者類
class Observer implements IObserver {
constructor(private name: string) { }
update(): void {
console.log(`${this.name} has been notified.`);
}
}
2.4 測試程式碼
function useObserver(){
const subject: ISubject = new Subject();
const Leo = new Observer("Leo");
const Robin = new Observer("Robin");
const Pual = new Observer("Pual");
subject.addObserver(Leo);
subject.addObserver(Robin);
subject.addObserver(Pual);
subject.notify();
subject.removeObserver(Pual);
subject.notify();
}
useObserver();
// [LOG]: "Leo has been notified."
// [LOG]: "Robin has been notified."
// [LOG]: "Pual has been notified."
// [LOG]: "Leo has been notified."
// [LOG]: "Robin has been notified."
三、回顧 Object.defineProperty()
Vue.js 的資料響應式原理是基於 JS 標準內建物件方法 Object.defineProperty()
方法來實現,該方法不相容 IE8 和 FF22 及以下版本瀏覽器,這也是為什麼 Vue.js 只能在這些版本之上的瀏覽器中才能執行的原因。
理解 Object.defineProperty()
對我們理解 Vue.js 響應式原理非常重要。
Vue.js 3 使用proxy
方法實現響應式,兩者類似,我們只需搞懂Object.defineProperty()
,proxy
也就差不多理解了。
1. 概念介紹
Object.defineProperty()
方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性,並返回此物件。
語法如下:
Object.defineProperty(obj, prop, descriptor)
- 入參說明:
obj
:要定義屬性的源物件;prop
:要定義或修改的屬性名稱或 Symbol;descriptor
:要定義或修改的屬性描述符,包括 configurable
、enumerable
、value
、writable
、get
、set
,具體的可以去參閱文件;
- 出參說明:
修改後的源物件。
舉個簡單?例子:
const leo = {};
Object.defineProperty(leo, 'age', {
value: 18,
writable: true
})
console.log(leo.age); // 18
leo.age = 22;
console.log(leo.age); // 22
2. 實現 getter/setter
我們知道 Object.defineProperty()
方法第三個引數是屬性描述符(descriptor
),支援設定 get
和 set
描述符:
get
描述符:當訪問該屬性時,會呼叫此函式,預設值為undefined
;set
描述符:當修改該屬性時,會呼叫此函式,預設值為undefined
。
一旦物件擁有了 getter/setter 方法,我們可以簡單將該物件稱為響應式物件。
這兩個操作符為我們提供攔截資料進行操作的可能性,修改前面示例,新增 getter/setter 方法:
let leo = {}, age = 18;
Object.defineProperty(leo, 'age', {
get(){
// to do something
console.log('監聽到請求資料');
return age;
},
set(newAge){
// to do something
console.log('監聽到修改資料');
age = newAge > age ? age : newAge
}
})
leo.age = 20; // 監聽到修改資料
console.log(leo.age); // 監聽到請求資料 // 18
leo.age = 10; // 監聽到修改資料
console.log(leo.age); // 監聽到請求資料 // 10
訪問 leo
物件的 age
屬性,會通過 get
描述符處理,而修改 age
屬性,則會通過 set
描述符處理。
四、實現簡單的資料響應式
通過前面兩個小節,我們複習了“觀察者模式”和“Object.defineProperty()
” 方法,這兩個知識點在 Vue.js 響應式原理中非常重要。
接下來我們來實現一個很簡單的資料響應式變化,需求如下:點選“更新資料”按鈕,文字更新。
接下來我們將實現三個類:
Dep
被觀察者類,用來生成被觀察者;Watcher
觀察者類,用來生成觀察者;Observer
類,將普通資料轉換為響應式資料,從而實現響應式物件。
用一張圖來描述三者之間關係,現在看不懂沒關係,這小節看完可以再回顧這張圖:
1. 實現精簡觀察者模式
這裡參照前面複習“觀察者模式”的示例,做下精簡:
// 實現被觀察者類
class Dep {
constructor() {
this.subs = [];
}
addSub(watcher) {
this.subs.push(watcher);
}
notify(data) {
this.subs.forEach(sub => sub.update(data));
}
}
// 實現觀察者類
class Watcher {
constructor(cb) {
this.cb = cb;
}
update(data) {
this.cb(data);
}
}
Vue.js 響應式原理中,觀察者模式起到非常重要的作用。其中:
Dep
被觀察者類,提供用來收集觀察者(addSub
)方法和通知觀察者(notify
)方法;Watcher
觀察者類,例項化時支援傳入回撥(cb
)方法,並提供更新(update
)方法;
2. 實現生成響應式的類
這一步需要實現 Observer
類,核心是通過 Object.defineProperty()
方法為物件的每個屬性設定 getter/setter,目的是將普通資料轉換為響應式資料,從而實現響應式物件。
這裡以最簡單的單層物件為例(下一節會介紹深層物件),如:
let initData = {
text: '你好,前端自習課',
desc: '每日清晨,享受一篇前端優秀文章。'
};
接下來實現 Observer
類:
// 實現響應式類(最簡單單層的物件,暫不考慮深層物件)
class Observer {
constructor (node, data) {
this.defineReactive(node, data)
}
// 實現資料劫持(核心方法)
// 遍歷 data 中所有的資料,都新增上 getter 和 setter 方法
defineReactive(vm, obj) {
//每一個屬性都重新定義get、set
for(let key in obj){
let value = obj[key], dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 建立觀察者
let watcher = new Watcher(v => vm.innerText = v);
dep.addSub(watcher);
return value;
},
set(newValue) {
value = newValue;
// 通知所有觀察者
dep.notify(newValue);
}
})
}
}
}
上面程式碼的核心是 defineReactive
方法,它遍歷原始物件中每個屬性,為每個屬性例項化一個被觀察者(Dep
),然後分別呼叫 Object.defineProperty()
方法,為每個屬性新增 getter/setter。
- 訪問資料時,getter 執行依賴收集(即新增觀察者),通過例項化
Watcher
建立一個觀察者,並執行被觀察者的addSub()
方法新增一個觀察者; - 修改資料時,setter 執行派發更新(即通知觀察者),通過呼叫被觀察者的
notify()
方法通知所有觀察者,執行觀察者update()
方法。
3. 測試程式碼
為了方便觀察資料變化,我們為“更新資料”按鈕繫結點選事件來修改資料:
<div id="app"></div>
<button id="update">更新資料</button>
測試程式碼如下:
// 初始化測試資料
let initData = {
text: '你好,前端自習課',
desc: '每日清晨,享受一篇前端優秀文章。'
};
const app = document.querySelector('#app');
// 步驟1:為測試資料轉換為響應式物件
new Observer(app, initData);
// 步驟2:初始化頁面文字內容
app.innerText = initData.text;
// 步驟3:繫結按鈕事件,點選觸發測試
document.querySelector('#update').addEventListener('click', function(){
initData.text = `我們必須經常保持舊的記憶和新的希望。`;
console.log(`當前時間:${new Date().toLocaleString()}`)
})
測試程式碼中,核心在於通過例項化 Observer
,將測試資料轉換為響應式資料,然後模擬資料變化,來觀察檢視變化。
每次點選“更新資料”按鈕,在控制檯中都能看到“資料發生變化!”的提示,說明我們已經能通過 setter 觀察到資料的變化情況。
當然,你還可以在控制檯手動修改 initData
物件中的 text
屬性,來體驗響應式變化~~
到這裡,我們實現了非常簡單的資料響應式變化,當然 Vue.js 肯定沒有這麼簡單,這個先理解,下一節看 Vue.js 響應式原理,思路就會清晰很多。
這部分程式碼,我已經放到我的 Github,地址:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/Basics-Reactive-Demo.js
可以再回顧下這張圖,對整個過程會更清晰:
五、Vue.js 響應式實現
本節程式碼:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/
這裡大家可以再回顧下下面這張官網經典的圖,思考下前面講的示例。
(圖片來自:https://cn.vuejs.org/v2/guide/reactivity.html)
上一節實現了簡單的資料響應式,接下來繼續通過完善該示例,實現一個簡單的 Vue.js 響應式,測試程式碼如下:
// index.js
const vm = new Vue({
el: '#app',
data(){
return {
text: '你好,前端自習課',
desc: '每日清晨,享受一篇前端優秀文章。'
}
}
});
是不是很有內味了,下面是我們最終實現後專案目錄:
- mini-reactive
/ index.html // 入口 HTML 檔案
/ index.js // 入口 JS 檔案
/ observer.js // 實現響應式,將資料轉換為響應式物件
/ watcher.js // 實現觀察者和被觀察者(依賴收集者)
/ vue.js // 實現 Vue 類作為主入口類
/ compile.js // 實現編譯模版功能
知道每一個檔案功能以後,接下來將每一步串聯起來。
1. 實現入口檔案
我們首先實現入口檔案,包括 index.html
/ index.js
2 個簡單檔案,用來方便接下來的測試。
1.1 index.html
<!DOCTYPE html>
<html lang="en">
<head>
<script src="./vue.js"></script>
<script src="./observer.js"></script>
<script src="./compile.js"></script>
<script src="./watcher.js"></script>
</head>
<body>
<div id="app">{{text}}</div>
<button id="update">更新資料</button>
<script src="./index.js"></script>
</body>
</html>
1.2 index.js
"use strict";
const vm = new Vue({
el: '#app',
data(){
return {
text: '你好,前端自習課',
desc: '每日清晨,享受一篇前端優秀文章。'
}
}
});
console.log(vm.$data.text)
vm.$data.text = '頁面資料更新成功!'; // 模擬資料變化
console.log(vm.$data.text)
2. 實現核心入口 vue.js
vue.js
檔案是我們實現的整個響應式的入口檔案,暴露一個 Vue
類,並掛載全域性。
class Vue {
constructor (options = {}) {
this.$el = options.el;
this.$data = options.data();
this.$methods = options.methods;
// [核心流程]將普通 data 物件轉換為響應式物件
new Observer(this.$data);
if (this.$el) {
// [核心流程]將解析模板的內容
new Compile(this.$el, this)
}
}
}
window.Vue = Vue;
Vue
類入參為一個配置項 option
,使用起來跟 Vue.js 一樣,包括 $el
掛載點、 $data
資料物件和 $methods
方法列表(本文不詳細介紹)。
通過例項化 Oberser
類,將普通 data 物件轉換為響應式物件,然後判斷是否傳入 el
引數,存在時,則例項化 Compile
類,解析模版內容。
總結下 Vue
這個類工作流程 :
3. 實現 observer.js
observer.js 檔案實現了 Observer
類,用來將普通物件轉換為響應式物件:
class Observer {
constructor (data) {
this.data = data;
this.walk(data);
}
// [核心方法]將 data 物件轉換為響應式物件,為每個 data 屬性設定 getter 和 setter 方法
walk (data) {
if (typeof data !== 'object') return data;
Object.keys(data).forEach( key => {
this.defineReactive(data, key, data[key])
})
}
// [核心方法]實現資料劫持
defineReactive (obj, key, value) {
this.walk(value); // [核心過程]遍歷 walk 方法,處理深層物件。
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
console.log('[getter]方法執行')
Dep.target && dep.addSub(Dep.target);
return value
},
set (newValue) {
console.log('[setter]方法執行')
if (value === newValue) return;
// [核心過程]當設定的新值 newValue 為物件,則繼續通過 walk 方法將其轉換為響應式物件
if (typeof newValue === 'object') this.walk(newValue);
value = newValue;
dep.notify(); // [核心過程]執行被觀察者通知方法,通知所有觀察者執行 update 更新
}
})
}
}
相比較第四節實現的 Observer
類,這裡做了調整:
- 增加
walk
核心方法,用來遍歷物件每個屬性,分別呼叫資料劫持方法(defineReactive()
); - 在
defineReactive()
的 getter 中,判斷Dep.target
存在才新增觀察者,下一節會詳細介紹Dep.target
; - 在
defineReactive()
的 setter 中,判斷當前新值(newValue
)是否為物件,如果是,則直接呼叫this.walk()
方法將當前物件再次轉為響應式物件,處理深層物件。
通過改善後的 Observer
類,我們就可以實現將單層或深層巢狀的普通物件轉換為響應式物件。
4. 實現 watcher.js
這裡實現了 Dep
被觀察者類(依賴收集者)和 Watcher
觀察者類。
class Dep {
constructor() {
this.subs = [];
}
addSub(watcher) {
this.subs.push(watcher);
}
notify(data) {
this.subs.forEach(sub => sub.update(data));
}
}
class Watcher {
constructor (vm, key, cb) {
this.vm = vm; // vm:表示當前例項
this.key = key; // key:表示當前操作的資料名稱
this.cb = cb; // cb:表示資料發生改變之後的回撥
Dep.target = this; // 全域性唯一
this.oldValue = this.vm.$data[key]; // 儲存變化的資料作為舊值,後續作判斷是否更新
Dep.target = null;
}
update () {
console.log(`資料發生變化!`);
let oldValue = this.oldValue;
let newValue = this.vm.$data[this.key];
if (oldValue != newValue) { // 比較新舊值,發生變化才執行回撥
this.cb(newValue, oldValue);
};
}
}
相比較第四節實現的 Watcher
類,這裡做了調整:
- 在建構函式中,增加
Dep.target
值操作; - 在建構函式中,增加
oldValue
變數,儲存變化的資料作為舊值,後續作為判斷是否更新的依據; - 在
update()
方法中,增加當前操作物件key
對應值的新舊值比較,如果不同,才執行回撥。
Dep.target
是當前全域性唯一的訂閱者,因為同一時間只允許一個訂閱者被處理。target
指當前正在處理的目標訂閱者,當前訂閱者處理完就賦值為 null
。這裡 Dep.target
會在 defineReactive()
的 getter 中使用到。
通過改善後的 Watcher
類,我們操作當前操作物件 key
對應值的時候,可以在資料有變化的情況才執行回撥,減少資源浪費。
4. 實現 compile.js
compile.js 實現了 Vue.js 的模版編譯,如將 HTML 中的 {{text}}
模版轉換為具體變數的值。
compile.js 介紹內容較多,考慮到篇幅問題,並且本文核心介紹響應式原理,所以這裡就暫時不介紹 compile.js 的實現,在學習的朋友可以到我 Github 上下載該檔案直接下載使用即可,地址:
https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/compile.js
5. 測試程式碼
到這裡,我們已經將第四節的 demo 改造成簡易版 Vue.js 響應式,接下來開啟 index.html 看看效果:
當 index.js 中執行到:
vm.$data.text = '我們必須經常保持舊的記憶和新的希望。';
頁面便發生更新,頁面顯示的文字內容從“你好,前端自習課”更新成“我們必須經常保持舊的記憶和新的希望。”。
到這裡,我們的簡易版 Vue.js 響應式原理實現好了,能跟著文章看到這裡的朋友,給你點個大大的贊?
六、總結
本文首先通過回顧觀察者模式和 Object.defineProperty()
方法,介紹 Vue.js 響應式原理的核心知識點,然後帶大家通過一個簡單示例實現簡單響應式,最後通過改造這個簡單響應式的示例,實現一個簡單 Vue.js 響應式原理的示例。
相信看完本文的朋友,對 Vue.js 的響應式原理的理解會更深刻,希望大家理清思路,再好好回味下~