前言
關於vue響應式的文章其實已經挺多了,不過大多都在淺嘗輒止,基本就是簡單介紹一下Object.defineProperty
,覆蓋一下setter
做個小demo
就算解決,好一點的會幫你引入observe、watcher、dep
的概念,以及加入對Array
的特殊處理,所以本篇除了上述以外,更多的重心將放在setter
引發render
的機制與流程上,然后結合這個這個響應式機制解析vue
中的watch
和computed
語法實現
文章分為兩部分,第一部分會簡單介紹vue實例構建流程,第二部分則深入探究響應式實現。
建議對照源碼閱讀文章,因為很多本文很多地方會直接指出文件路徑,同時將省略部分代碼而直述功能
版本信息:
- vue: 2.6.12
一、尋找vue
直入主題
真正的vue
實例在core/instance/index
中可以找到
function Vue (options) {
....
this._init(options) // 這個方法在initMixin中定義
}
initMixin(Vue) // 掛載_init()
stateMixin(Vue) // 掛載狀態處理方法(掛載data,methods等)
eventsMixin(Vue) // 掛載 事件 的方法($on,$off等)
lifecycleMixin(Vue) // 掛載 生命周期方法(update,destory)
renderMixin(Vue) // 掛載與渲染有關的方法($nextTick,_render)
每個方法可以按照代碼邏輯來看,實現對應功能,這里拿initMixin
舉例
篇幅有限,所有此處僅解釋initMixin邏輯,剩余幾個方法大家可以自己探索哦
initMixin
initMixin
中僅僅掛載了_init()
方法,在_init
中,初始化了整個vue
的狀態:
function _init(option) {
...
vm._uid = uid++ // 即component id
...
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm)
callHook(vm, 'created')
...
if (vm.$options.el) {
vm.$mount(vm.$options.el) // 開始掛載
}
}
這里我們可以看到幾個beforeCreate
,created
和Mount
關鍵字,大概就能夠猜到vu
e實例的部分生命周期方法就是在這里進行了掛載,再結合 vue官方文檔的圖示
關於初始化整個vue
的狀態,可以舉例來說,例如initLifecycle
中就賦值了parent,children
,以及一些isMounted,isDestroy
的標識符。initRender
中就將attrs,listeners
響應化,等等,諸如此類。
從initMixin=>initState=>initData
,便可以看到掛載props,methods,data,computed,watch
了,
可以看到,此處先掛載了
props,methods
,然后是data
的順序,其實再往下探究邏輯就可以知道,如果存在變量重名,優先級是props>methods>data
的,這也就解釋了為什么初始化的順序是這樣安排的
在initData
中,先是獲取了data
數據,判斷props,methods
變量重名問題,然后是走了一個代理,將變量名代理到vue
實例上,這樣的話你的vue
實例中,使用this.x
指向就可以訪問到this.data.x,
這類代理也用在了props
和methods
中
在
initData
獲取數據中可以看到一個判斷typeof data === 'function' ? getData(data, vm) : data || {}
, 支持兩種方式獲取,實際上如果是自己寫這樣一個邏輯是會藏有隱患的,如果你的data是直接使用對象,而js的復雜數據類型是地址引用,這意味着,你實例化了兩個vue
對象,實際上他們的data引用地址是同一個地址,對其中一個vue data
的修改會觸發另一個vue數據的變動,帶來的問題是巨大的
export function proxy (target: Object, sourceKey: string, key: string) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
}
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
這個邏輯處理的設計也是非常巧妙,他覆蓋了實例中對該key的訪問,使用setter
和getter
將實際訪問指向了this.data[key]
。
這里可以說一下computed的邏輯,實際上也是取巧使用了原本用於data的響應式邏輯,其實看到上面貼出來的proxy代碼,大概就能猜到,既然proxy能夠改變一個變量讀取的指向,那么他也能創造一個虛假變量的指向,這個創造出來的這個變量實際上就是computed所使用的變量,將每次computed函數賦給getter,再加上響應式處理,就完全實現了computed,
走到最后,就是observe(data)
,也就是開始處理vue數據的雙向綁定
二、雙向綁定
不同於react
的單向數據流,vue
使用的雙向綁定,單向數據流可以理解為當源頭的數據發生變動則觸發行為,當然這個變動是主動的,即你需要setState
才能觸發,而雙向綁定則可以抽象為,每一個數據旁邊都有一個監護人(一種處理邏輯),當數據發生變化,這個監護人就會響應行為,這個流程是被動發生的,只要該數據發生變動,就會通過監護人觸發行為。
如果你之前有過了解,大概就會知道,js每個數據的變動都是通過Object
原型鏈中的setter
去改變值,而如果你在他改變值之前,去通知監護人,就能夠實現上述的邏輯,這一點很多博客文章都寫的非常清楚了。
接着第一部分的initData
知道最后observe(data)
,這里開始正式處理響應式。
2.1 前置條件
前面一直提到,通過Object
的原型鏈改變對象的默認行為:getter
和setter
,首先我們需要知道,在js
中,讀取一個對象的值並不是直接讀取,而是通過Object的原型鏈上的默認行為getter拿到對應的值,而改變這種行為實際上是通過Object.defineProperty
,來重新定義一個對象的getter
和setter
,在/src/core/observer/index.js
中我們可以看一個defineReactive
方法,他就是vue
用來實現這種行為的方法,也是這個響應式的核心
function defineReactive(obj, key, val, ... ) {
// 此處需要保留getter、setter是因為,開發者可能自己基於defineProperty已經做過一層覆蓋,
// 而響應式又會覆蓋一次,所以為了保留開發者自己的行為,此處需要兼容原有的getter、setter
const getter = property && property.get // 拿到默認的getter、setter行為
const setter = property && property.set
Object.defineProperty(obj, key, {
enumerable: true, // 是否可以被枚舉出來(例如Object.keys(),for in)
configurable: true, // 是否可以被配置,是否可以被刪除
get: function() {
const value = getter ? getter.call(obj) : val
...
return value
}
set: function(newVal) {
...
setter.call(obj, newVal)
}
})
}
2. 2響應式
首先,我們猜想一下,雙向綁定的行為,數據能夠響應行為的變化,而行為又能夠操作數據的改變,雖然有部分教程會讓你站在數據的角度去理解這種行為,實際上,我們站在行為的角度上去理解是更加方便的。
我們將一種行為定義為一個Watcher
,他有可能是一個vue
文件的template
中的dom
節點渲染行為,也有可能是computed
的計算值行為,總之,我們從行為的角度出發,一個行為的發生,會伴隨着對變量的讀取(回想一下我們在vue
文件中的template
寫html
標簽時,總是會使用{{obj.xxx}}
來讀取某個變量並渲染),我們想要實現,變量的改變也會帶動這個行為的重新渲染,是不是我們只需要在首次行為發生的周期內,在讀取某個變量時,在這個變量內記錄這個Watcher
,這樣的話,下次變量的改變時,我只要觸發我之前記錄過的Watcher
就行了。所以,我們只需要在一個Watcher
發生時,將其掛載到一個公共變量上,這樣在讀取一個值的時候,記錄這個公共變量,就能夠實現上述操作。
這里先不解釋Dep的作用,可以將其抽象理解為一個被掛載在數據上的數組,每次這個數據被一個watch讀取時,就會將這個watch記錄下來
2.2.1 Watcher
既然說到將一種行為定義為一個watcher
,那么可以在/src/core/observer/watcher.js
中看到Watcher
的實體類,而我們之前一直所說的“行為”,實際上就是構造器的第二個參數expOrFn
,可以有表達式或者函數讀取的兩種模式
class Watcher {
constructor ( vm: Component, // vue實例
expOrFn: string | Function, // 行為
cb: Function, // 為watch服務
options?: ?Object,
isRenderWatcher?: boolean // 判斷是否為渲染watcher, )
}
接着來看一種最典型的watcher行為,在/src/core/instance/lifecycle.js
中的moundComponent
方法中,可以看到一個實例化watcher
的方法
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
可以看到,他將updateComponent
(可以抽象為渲染行為)傳給Watcher
,而在Watcher
的實例化中,將會執行此方法,當然在執行之前,pushTarget(this)
,將這個watcher掛載到公共變量上而后開始執行渲染行為,
class Watch {
constructor(...) {
....
if (typeof expOrFn === 'function') {
this.getter = expOrFn
}
this.get();
}
get() {
pushTarget(this) // 掛載行為至公共Target
value = this.getter.call(vm, vm) // 開始執行行為,之所以會有返回值是為了computed服務
popTarget() // 取消掛載,避免下次讀取變量時又會綁定此行為
}
}
此時,如果此行為讀取了某個響應式變量,那么該變量的getter
將會存儲公共變量target
,當行為完成后就會取消行為的掛載,這個時候我們再回過頭來看前面的defineReactive
的邏輯
function defineReactive(obj, key) {
const dep = new Dep(); // 每個數據都有一個自己的存儲列表
const getter = property && property.get
const setter = property && property.set
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) { // 判斷公共變量中是否掛載了行為(watcher)
dep.depend() // 將行為(watcher)加入dep(即此變量的存儲行為列表)
...
}
return value
},
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val
if (newVal === value || (newVal !== newVal && value !== value)) {
return // 判斷變量沒有變化,則直接返回(后兩者判斷則是因為NaN!==NaN的特性)
}
if (setter) {
setter.call(obj, newVal) // 開始
} else {
val = newVal
}
dep.notify() // 通知自己這個數據的存儲列表,數據發生改變,需要重新執行行為(watcher)
}
});
}
這個時候就很清晰明了了,這就是很多博客文章所說的依賴收集,變量在get時通過公共變量Target
收集依賴(也就是本文所說的行為),在set
時,即變量數據發生改變時,觸發更新notify
;
2.2.2 Computed
前文有大致介紹computed
的實現,實際上在介紹完Wacher之后就可以來詳細介紹了,計算屬性computed
並沒有實際的變量,他通過原型鏈覆蓋創造了一個變量指向(src/core/instance/state.js
的initComputed
),回憶一下computed的兩種寫法
'fullName': function() {
return this.firstName + this.secondeName;
}
'fullName': {
get: function () {...},
set: function() {...},
}
我們再來看一下initComputed
function initComputed (vm: Component, computed: Object) {
const watchers = vm._computedWatchers = Object.create(null)
for (const key in computed) {
const userDef = computed[key]
// 對照着computed的兩種寫法,就能理解為什么這里有這樣的判斷,
const getter = typeof userDef === 'function' ? userDef : userDef.get
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
{ lazy: true }
)
defineComputed(vm, key, userDef) // 通過defineProperty來創造一個掛載在vm上key(fullName)的指向
}
}
可以看到,他將computed
的getter
方法,作為Watcher
的行為傳遞了進去,這樣在執行getter
時,可以將此行為綁定至過程中所讀取到的變量(firstName
),如此,再下次firstName發生改變時,就會觸發此Watcher
,重新運行getter方法,得到一個新的fullName
的值(還記得前文class Watch
中的value = this.getter.call(vm, vm)
嗎?這個返回值就是computed
的返回值),這樣就實現了computed
的邏輯
2.2.3 Watch
watch
的用法,是監聽某個變量,當該變量發生變化時,執行特定的邏輯,
上文提到的兩種Watcher
行為都是函數行為,但是Watcher
的行為是支持函數或者表達式的(expOrFn
),所以此處的exp(expression)
這里就是可以提現到的,我們只需要在變量發生變化時,執行watch
定義的邏輯即可,
還記得前文代碼defineReactive
中 set
方法通知依賴更新(dep.notify()
),雖然前文一直為了方便理解,將Dep描述為一種抽象的列表結構,僅用於依賴收集,但實際上他是一個單獨的數據結構,
let uid = 0;
class Dep {
constructor() {
this.id = uid ++;
this.subs = []; // 真正用於收集依賴的數據
}
depend () { // 依賴收集
if (Dep.target) {
Dep.target.addDep(this)
}
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
notify() { // 變量值發生變化,通知更新
// 遍歷所有收集的依賴,注意觸發更新,
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
...
}
Dep.target = null; // 這就是一直說的,用於掛載Watcher行為的公共變量
function pushTarget(target){ Dep.target = target };
function popTarget() { Dep.target = null };
實際上這里的靜態變量
target
以及pushTarget、popTarget
是經過簡化的,因為渲染並不是一個單一的行為,他是層層嵌套的行為,所以在綁定響應式時,也是需要區分該變量到底是要綁定至哪個行為(否則每個變量都綁定最頂層的行為,一個變量的變化,將會引發整個頁面的update
),因此真正的target是還有一個stack
棧結構,用於掛載多個嵌套的行為
可以看到,每次變量更新,都會觸發watcher.update
,那么對於watch
監聽的回調,就可以放到在update
中調用
class Watch {
constructor(vm, expOrFn, cb, ...) {
this.cb = cb // 這個cb就是watch監聽的回調
}
update() {
this.run()
}
run() {
...
this.cb.call(this.vm, ...)
}
}
至此,關於watch
監聽的實現邏輯大致就是如此
關於依賴收集,實際上並不是在get變量時,直接將
watcher
綁定至Dep
中,可以看到Dep.depend()
,他先通知行為(watcher
),叫他先綁定自己,然后watcher綁定完dep之后,才會回過頭,告知Dep
要addSub()
,這里的邏輯像是一個圈
所以現在我們回過頭來看,前文說了,每個數據都有一個“監護人”,來記錄此數據所綁定的行為,那么這個“監護人”到底在哪里呢? 可以看到/src/core/observer/index.js
的class Observer
中,
class Observer {
constructor(val) {
...
def(value, '__ob__', this) // 對value定義__ob__屬性,掛載此object
...
}
}
對於每一份需要響應式處理的數據,都會掛載一個
Observer
實例,其內subs就是用於記錄綁定此數據的Watcher
,同時也可以看到,這份數據的get、set
方法已經是被重寫過了,也就是前文的defineReactive
中的覆蓋行為。
2.2.4 其他
其實對於Array
的響應式是需要特殊處理的,因為他除了set、get
之外,還會對數組進行增減操作(splice
等),而這些操作是set無法捕捉的,所以覆蓋get、set
顯然無法實現數組的響應式,而vue
中采用的是直接覆蓋數組的原型鏈中會對數據本身改變的方法(push、shift、splice
等),/src/core/observer/array.js
整個文件就是對數據的特殊處理 最新的vue3
中,使用了ES6
的proxy
特性來替代這種覆蓋set、get
實現響應式行為,這種模式同時也能夠處理Array
。
三、結尾
vue
的源碼當然沒有如此簡單,很多東西文章都沒有涉及到,譬如說,通過上面的邏輯其實你可以發現,dep
和watcher
其實是互相引用的,而js
的垃圾回收是檢測變量引用的機制,所以如果是簡單的復制上文的邏輯,最終的這部分的內存其實是無法被回收的,需要你手動清除,當然vue
中也做了這樣的處理(每個vm
下其實有一個watcherList
,用於記錄這個示例中所有使用到的watcher
,再vm.destroy
時,通過遍歷watcherList
,再銷毀每一個watcher
,而watcher
中又會自己銷毀Dep
),但是限於篇幅原因無法詳細介紹了。
最后
為了幫助大家更好溫習重點知識、更高效的准備面試,特別整理了《95頁前端學習筆記》電子稿文件。
主要內容包括html,css,html5,css3,JavaScript,正則表達式,函數,BOM,DOM,jQuery,AJAX,vue 等等。
👉點擊這里免費獲取👈
html5/css3
-
HTML5 的優勢
-
HTML5 廢棄元素
-
HTML5 新增元素
-
HTML5 表單相關元素和屬性
-
CSS3 新增選擇器
-
CSS3 新增屬性
-
新增變形動畫屬性
-
3D變形屬性
-
CSS3 的過渡屬性
-
CSS3 的動畫屬性
-
CSS3 新增多列屬性
-
CSS3新增單位
-
彈性盒模型
JavaScript
-
JavaScript基礎
-
JavaScript數據類型
-
算術運算
-
強制轉換
-
賦值運算
-
關系運算
-
邏輯運算
-
三元運算
-
分支循環
-
switch
-
while
-
do-while
-
for
-
break和continue
-
數組
-
數組方法
-
二維數組
-
字符串
正則表達式
-
創建正則表達式
-
元字符
-
模式修飾符
-
正則方法
-
支持正則的 String方法
js對象
-
定義對象
-
對象的數據訪問
-
JSON
-
內置對象
-
Math 方法
-
Date 方法
面向對象是一種編程思想
- 定義對象
- 原型和原型鏈
- 原型鏈
- 原型
常用的JavaScript設計模式
-
單體模式
-
工廠模式
-
例模式
函數
-
函數的定義
-
局部變量和全局變量
-
返回值
-
匿名函數
-
自運行函數
-
閉包
BOM
-
BOM概述
-
window方法
-
frames [ ] 框架集
-
history 歷史記錄
-
location 定位
-
navigator 導航
-
screen 屏幕
-
document 文檔
DOM
- DOM對象方法
- 操作DOM間的關系
- DOM節點屬性
事件
-
事件分類
-
事件對象
-
事件流
-
事件目標
-
事件委派(delegate)
-
事件監聽
jQuery
-
jQuery 選擇器
-
屬性選擇器
-
位置選擇器
-
后代選擇器
-
子代選擇器
-
選擇器對象
-
子元素
-
DOM操作
-
JQuery 事件
-
容器適應
-
標簽樣式操作
-
滑動
-
自定義動畫
AJAX
- 工作原理
- XMLHttpRequest對象
- XML和HTML的區別
- get() 和post()
HTTP
-
HTTP消息結構
-
url請求過程
性能優化
- JavaScript代碼優化
- 提升文件加載速度
webpack
-
webpack的特點
-
webpack的缺點
-
安裝
-
webpack基本應用
-
配置文件入門
vue
-
MVC模式
-
MVVM模式
-
基礎語法
-
實例屬性/方法
-
生命周期
-
計算屬性
-
數組的更新檢查
-
事件對象
-
Vue組件
-
路由使用
-
路由導航
-
嵌套路由
-
命名視圖