vue和react是現在前端框架的雙子星。vue以其簡單好用而聞名。vue以資料驅動檢視,資料響應系統是vue的核心。這篇文章主要是結合原始碼分析vue響應式系統的原理和實現。
代理
下面這段程式碼是vue使用的典型方式:
<div id="app-5">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">逆轉訊息</button>
</div>
複製程式碼
var app5 = new Vue({
el: '#app-5',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
複製程式碼
我們可以看到當我們給this.message
賦值時,檢視會自動更新。這就是vue資料響應系統做的事情,當然vue做的事情遠比這個複雜地多,但這是核心理念。原理也很簡單,vue幫我們代理了資料的賦值操作,在資料賦值時進行了DOM的更新,這些對於vue使用者是不可見的,也是無需考慮的。
實現資料代理在js中有兩種方式,一個是ES5的getter
和setter
方法;一個是ES6的proxy
api。vue2.5及以下采用的是getter
和setter
方法;即將出來的vue3.0全部改為proxy
方式來實現。
getter和setter
Object提供一個方法definePropery可以讓我們給一個物件的屬性定義getter
和setter
,從而代理物件屬性的取值和賦值操作,用法如下:
var o = {};
Object.defineProperty(o, "b", {
get: function(){
alert('我在取值');
},
set: function(newValue){
alert('我在賦值');
},
enumerable : true,
configurable : true
});
var a = o.b; // 彈出“我在取值”
o.b=5; // 彈出“我在賦值”
複製程式碼
有了這個特性,我們就可以實現最簡單的雙向資料繫結。比如vue中的v-model效果。
<input type="text" id="name"></input>
複製程式碼
var data = {};
var nameDom = document.querySelector('#name');
Object.defineProperty(data, 'value', {
get: function(){
return nameDom.value;
},
set: function(value){
nameDom.value = value;
}
})
複製程式碼
由此我們便實現了最簡單的雙向資料繫結。當然,vue實現響應式資料的思路和上面不一樣,要複雜的多,但是基本理念就是通過劫持資料的賦值和取值操作來完成的。
觀察者模式
我們要想實現vue的響應式資料,就要給vue初始化物件的data和props的所有屬性設定setter和getter函式。vue原始碼src/core/instance/state.js
的initData
函式有如下程式碼,其中observe
都是迴圈遍歷data物件,給每個屬性都設定setter和getter。
// src/core/instance/state.js
// observe data
observe(data, true /* asRootData */)
複製程式碼
我們可以知道的是setter函式中的操作肯定是要更新DOM的,那每個資料繫結的DOM不同,繫結的屬性也不同,如果像我們上面那樣把每個響應式資料和具體DOM的屬性繫結起來,就太麻煩和複雜了。Vue採取的策略是虛擬DOM
,每次資料setter操作,都會根據你寫的template
或者render
生成虛擬DOM
,然後和之前的虛擬DOM
進行比較,如果有不同,則進行DOM更新,而且只更新有變化的部分;如果相同就不做操作。這種方式可以解決我們的問題,效能也沒有問題。在vue例項mount的過程中會執行以下程式碼:
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
複製程式碼
這句就是用來執行DOM的更新渲染的,我們在響應式資料的setter中應該執行updateComponent
就能達到我們的目的了。看起來很簡單是吧,但是現在有兩個問題:
- 我們在data和props中宣告的屬性不一定都繫結到Dom上了,如果是沒有繫結到Dom上的資料,在進行setter的時候,也要DOM更新操作,雖然不會引起真正的DOM更新,但也是很浪費效能。
- 我們資料setter可能會不止有Dom更新的任務,比如watch了一個屬性,那麼這個屬性就有Dom更新和watch繫結的回撥兩個任務。
資料setter繫結不同的任務,在資料改變時,執行所有繫結的任務,這個不就是觀察者模式嘛。。。
觀察者模式一個典型的例子就是DOM元素的事件繫結
var btnDom = document.getElementById('btn');
btnDom.addEventListener('click', function(){
console.log('click事件發生了,做點啥...');
});
複製程式碼
我們給btnDom繫結click
事件,就相當於在觀察
btnDom,當btnDom被點選,就會呼叫我們繫結的事件。現在我們的響應式資料(data和props)就是我們觀察的物件,我們把需要執行的任務放入響應式資料的setter裡,在響應式資料被賦值的時候,執行這些任務。觀察者模式這個名字是和現實的一個類比,有的有觀察者
例項,有的直接註冊回撥函式
,其實本質是一樣的,這些觀察者例項
或者回撥函式
都在觀察的動作中被放入被觀察者
的例項中的,在被觀察者
發生改變時,執行註冊在自己身上的回撥函式
或者通知觀察者
。下面是一段典型的觀察者模式實現:
//被觀察者
class Subject{
constructor(){
this.observerList = [];
}
addObserver(observer){
this.observerList.push(observer);
}
removeObserver(observer){
const index = this.observerList.findIndex(item => item === observer);
if(index !== -1){
this.observerList.splice(index, 1);
}
}
notify(context){
const length = this.observerList.length;
for(let i = 0; i<length; i++){
const observer = this.observerList[i];
observer.notify(context);
}
}
}
//觀察者
class Observer{
constructor(){
this.notify = function(){
// ...
};
}
}
複製程式碼
Subject例項通過addObserver
和removeObserver
來新增和刪除觀察者,然後在合適的時機通過notify
方法通知所有的觀察者。vue也是類似的實現機制,不過Vue的設計比較巧妙,實現形式有所不同。vue有3個類是用來處理響應式資料的觀察者模式:Observer
、Watcher
、Dep
。
- Observer, 該類主要作用是用來定義屬性的getter和setter方法
- Watcher, 觀察者類,且同時用於$watch例項方法和watch指令
- Dep, 觀察者容器,每一個響應式資料的屬性都擁有一個自己獨立的Dep例項,盛放自己的觀察者
我們上面寫的觀察者模式或者是事件繫結,需要我們主動去新增觀察者
,那麼在響應式資料這個模式當中我們應該在何時去收集屬性自己的觀察者那?答案是在響應式資料的getter
中收集。因為被觀察的資料在求值的時候肯定會觸發getter
函式,這是一個很好的時機,而且也能避免沒有參與DOM更新的屬性被繫結DOM更新的觀察者
。所以,vue採用的方式是在getter
中收集觀察者
,在setter
中通知觀察者
。
Observer
型別中有一個defineReactive
函式,這個函式主要是用來定義屬性的getter和setter方法,下面是一個簡化版的defineReactive
函式,去掉了一個邊界情況的資料,只考慮物件這種響應式資料。
/**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
shallow?: boolean
) {
const dep = new Dep()
val = obj[key];
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
if (Dep.target) {
dep.depend()
}
return val
},
set: function reactiveSetter (newVal) {
/* eslint-disable no-self-compare */
if (newVal === val || (newVal !== newVal && val !== val)) {
return
}
val = newVal
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
複製程式碼
上面程式碼還是很清楚簡單的,在getter
中的dep.depend
就是收集觀察者
;在setter
中dep.notify
就是通知觀察者
。每一個響應式屬性都擁有自己的閉包Dep
例項,這個Dep
例項中裝載這所有該屬性的觀察者
。那麼Dep.target
是什麼東西那?它就是我們所要收集的觀察者。這裡Dep.target
可能會有點迷糊,我們先來考慮一下vue的$watch例項方法或者watch指令是怎麼用的:
vm.$watch(expOrFn, function(){...});
複製程式碼
當expOrFn
的值傳送變化時,執行回撥函式。而$watch
方法就是新建了一個Watcher
例項:
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) { // 用於處理watch指令cb是一個物件,帶有immediate或者deep引數的情況,createWatcher中是整理引數,建立Watcher例項
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
cb.call(vm, watcher.value)
}
return function unwatchFn () {
watcher.teardown()
}
}
複製程式碼
現在我們需要明確的一點就是,其實通過改變資料來更新DOM這個操作,其實就是建立了一個渲染函式的Watcher
例項,跟我們使用$watch
方法去觀察一個資料是一樣的,只不過這個操作是Vue主動做的,只不過它觀察的是所有<template>
或者render
中的資料。下面這段程式碼就是建立渲染函式的Watcher
例項:
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
複製程式碼
updateComponent
函式上面已經說過,是用來更新DOM操作的。我們先來看一下Watcher類建構函式的引數,Watcher(vm, expOrFn, cb, options)
一共有4個:
vm
, Vue例項expOrFn
, 被觀察的表示式或者函式,用來求值同時觸發被觀察資料的getter
函式,用於收集該觀察者,所以我們上面說的Dep.target
就是在expOrFn
求值之前被賦值為該觀察者例項的cd
,回撥函式,被觀察資料改變時,執行的回撥函式options
, 一些引數設定isRenderWatcher
,是否為渲染函式的觀察者
那我們看這個渲染函式
的觀察者例項的構造引數就有些奇怪,因為它的回撥是noop
,noop
是定義了一個空函式。這不是很奇怪嗎,在資料變化的時候什麼都不做,怎麼更新DOM?實際上在資料發生變化的時候,Watcher例項都要對expOrFn
重新求一遍值,這樣才能知道expOrFn
的值有沒有變化,進而決定是否要執行cb
;而updateComponent
這個更新DOM的函式同時滿足觸發getter
和更新DOM的需求,所以在這裡就不需要設定cb
了,同樣如果我們在編碼時,有這樣同時滿足收集依賴和滿足回撥的函式,也可以這樣用。
避免重複收集觀察者
那每次資料發生變化的時候,觀察者
都對expOrFn
求值,豈不是每次都會觸發getter
函式,造成依賴重複收集?的確會,而且即便在一次求值過程中,也可能觸發同一個資料多次(比如同一個屬性出現在模板多個地方),不過Vue已經實現了避免收集重複依賴的處理:
class Watcher{
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
}
class Dep{
addSub (sub: Watcher) {
this.subs.push(sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
}
複製程式碼
上述程式碼就是Vue用來避免收集重複依賴的,我們知道,在響應式資料的getter
中,我們會呼叫該屬性所擁有的Dep
例項的depend
方法來收集觀察者
。我們可以看到在depend
方法中呼叫了觀察者例項方法addDep
,而在addDep
方法中我們可以看到又呼叫了Dep
的例項方法addSub
給Dep
這個容器加入觀察者
。有點繞,兩個類來回撥用,目的只有一個,就是避免收集重複的觀察者
。
我們可以看到addDep
方法中有3個值,決定了是否新增觀察者
,我們先來說明一下這3個值的作用:
- newDepIds,本次求值所收集的
Dep
例項Id列表,用來避免本次求值的重複收集,在每次求值完成之後都會被賦值給depIds,然後被清空 - depIds,上次求值所收集的
Dep
例項Id列表,用來避免兩次求值之間的重複收集以及去除廢棄的觀察者
- newDeps, 本次求值所收集的
Dep
例項列表, 在每次求值完成之後都會被賦值給deps,然後被清空
這樣看程式碼的邏輯就很清楚了,先判斷在本次求值中是否已經收集了該Dep
例項,如果沒有,則將該Dep
的id新增到newDepIds
,然後再判斷,該Dep
例項是否存在於上次求值的Dep
例項Id列表,如果沒有,則將該觀察者
放入Dep
例項中,作為該Dep
例項所屬的響應式資料所擁有的觀察者
。
在每次求值之後,都會執行cleanupDeps
,用於給newDepIds
賦值給depIds
,清空newDepIds
, 並且去除廢棄的觀察者
,下面這段程式碼就是去除廢棄的觀察者
:
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
// 省略...
}
複製程式碼
凡是在上次求值過程中存在的Dep
例項,在本次求值中不存在了,說明該Dep
例項已經被廢棄了,更直白的說法就是該Dep
例項所屬的響應式資料已經不在本次求值過程中了,需要把該觀察者
在Dep
例項中去除。
非同步更新佇列
在同一個js任務佇列
中,我們可能改變多個模板中的響應式資料,這樣會造成多次觸發渲染函式的觀察者
,造成多次重複地渲染DOM,造成效能浪費。解決這個問題的辦法在於我們要有一個合適的時機統一處理一個js任務佇列
中所有被觸發的觀察者
,對於重複的觀察者
只執行一次。這個合適的時機就是微任務
,在js任務佇列
執行完之後,會立即執行在本次任務佇列
中產生的所有微任務。且在兩次js任務佇列
之間會穿插著DOM更新,所以在微任務
中把所有相關的資料更新,是最優的。下面queueWatcher
是非同步模式下觀察者
被通知時執行的操作,queue
存放在本次任務佇列
中所有被通知的觀察者
,nextTick
是Vue實現的微任務
機制(在不支援微任務的情況下,回退到巨集任務
),flushSchedulerQueue
則是用來執行queue
中的觀察者
,並清空queue
:
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
queue.push(watcher)
// queue the flush
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}
複製程式碼