什麼是響應式
在 Vue 開發中,我們修改了數據,所有用到這份數據的視圖都會更新。
響應式概括來説就是數據驅動視圖的自動更新
舉個例子,本文也將以下面這段代碼來講解與實現響應式
HTML
<div id="app">
{{ obj.message }}
</div>
JS
let data = {
obj: {
message: 'Hello Vue!',
},
}
new Vue({
el: '#app',
data,
})
setTimeout(() => {
data.obj = {
message: 'Obj have changed!',
}
}, 1000)
setTimeout(() => {
data.obj.message = 'Message have changed!'
}, 2000)
Vue 會將 “ Hello Vue! ” 渲染在頁面中,在一秒後修改了 data.obj,頁面也隨之更新為 “ Obj have changed! ”,隨後又過一秒 data.obj.message 被修改,頁面顯示為 “ Message have changed! ”
如何實現響應式
為了實現響應式,要解決2個問題
- 數據什麼時候變化了 --- 監視數據
- 哪些地方用到了數據 --- 解析模板
監視數據
Vue 使用了 3 個類,實現了數據的攔截、更新的訂閲與發佈
Observer,監視者類,監視數據的變化,在數據變化時告訴通知者,在這個類中將數據的所有屬性用Object.defineProperty重新定義一遍,綁定了存取器(getter/setter)Dep,通知者類,通知訂閲者更新視圖,因為一個數據可能被多處使用,所以一個通知者會存儲多位訂閲者Watcher,訂閲者類,用於存儲數據變化後要執行的更新函數,調用更新函數可以使用新的數據更新視圖
下面我們來詳述 Vue 是如何操作我們傳入的 data 數據的
- Vue 拿到了
data這個對象,創建一個監視者,綁定到對象的__ob__屬性上 - 創建監視者會將對象身上的所有屬性用
Object.defineProperty重新一遍,在定義的同時,為每一個數據創建它的通知者。通知者會在閉包環境中創建,只有該數據的存取器能夠訪問到。 - 如果對象的值中還有對象,會遞歸上面的過程
- 處理完
data後,將其所有屬性映射到Vue實例身上(允許vm.xxx直接訪問)
以本文舉例,監視數據完成時,創建了兩個監視者,分別監視 data 與 obj。創建了四個通知者,分別屬於數據 obj 與 message 和兩個監視者,為了方便之後的講解,我們為這些通知者編號
解析模板
數據監視完畢後,Vue 會解析模板
模板解析的內容較為複雜,這一過程會創建虛擬節點 vnode,匹配到 {{ }} v- 等響應式寫法,會根據當前節點的類型、響應式語法等建立該節點的更新函數 patch ,將數據與視圖綁定
本文主要是幫助理解響應式更新的邏輯,模板解析語法並不是本文的重點,所以在之後的講解與實現中使用的還是 DOM 節點
接下來繼續以本文的例子來講解這一流程
- Vue 會根據
el: '#app'配置項獲取根 DOM 元素,遍歷其所有子節點 - 發現一個文本節點的內容是
{{ obj.message }},檢測到特定的響應式寫法,建立更新函數並提取數據表達式(字符串'obj.message') -
因為要操作的是隻是文本節點的內容,所以更新函數較為簡單(下方代碼)
const patch = (value) => { node.textContent = value } - 創建訂閲者,保存更新函數與數據表達式,並將此訂閲者存入一個全局變量中(表示進入依賴收集階段),然後執行這個更新函數
- 執行函數會時會根據表達式訪問數據,觸發了監視者的綁定在其身上的 getter,getter 從全局變量獲取訂閲者,存入其綁定的通知者中
- 每個數據訪問結束時,表示本輪的依賴收集完成,清除全局變量中的訂閲者
- 然後針對模板中用到的每一個響應式數據,都會重複以上的過程。對於多層嵌套的數據,也是轉換成字符串一層層訪問,監視者會為一路上所有數據的通知者添加本輪的訂閲者
模板解析完成時,只創建了一個訂閲者但被添加到了四個通知者中
如果細想會發現 Dep2 與 Dep3 的內容完全一致,其實 Dep2 主要是給 vue 其他 api 用的
更新數據
以後修改數據就會觸發監視者的 setter,setter 就能告訴通知者,通知其內部的訂閲者執行更新函數修改視圖,實現了數據的響應式
如果修改的數據值是一個對象,會先為其創建監視者,再告訴通知者發佈訂閲,執行更新函數訪問這些數據時,所有子數據的新通知者又存儲了之前解析模板時創建的訂閲者
還是根據例子來講解更新流程
- 第一次修改的是
data.obj,是一個對象,原對象的監視者、Dep2、Dep4被移除 - 觸發 obj 的 setter,新值是一個對象,為其創建監視者,同時創建了空的 Dep2、Dep4
- 告訴 Dep3,通知其中的訂閲者執行更新函數
- 更新函數執行訪問到了
obj和obj.message,將此訂閲者又添加進 Dep2 和 Dep4 中 - 第二次修改的是
data.obj.message - 觸發 message 的 setter,告訴 Dep4,通知其中的訂閲者更新視圖
這就是完整的更新流程了
通知者是用集合存儲訂閲者的,所以多次訪問也只會添加一個訂閲者
代碼實現
Vue 的源碼非常複雜,本文只提取了一小部分,以下代碼只實現了響應式更新文本節點的功能
// 公開的Vue類
class Vue {
constructor(options) {
// 保存數據
this._data = options.data
// 創建監視者
observe(this._data)
// 將數據都映射到實例上
this._initData()
// 模板解析
compile(options.el, this)
}
// 遍歷數據,映射到實例上
_initData() {
for (const key of Object.keys(this._data)) {
Object.defineProperty(this, key, {
get() {
return this._data[key]
},
set(newVal) {
this._data[key] = newVal
},
})
}
}
}
// 創建監視者並返回
function observe(obj) {
// 如果不是對象,不需要創建監視者
if (typeof obj != 'object') return null
let ob
if (typeof obj.__ob__ !== 'undefined') {
ob = obj.__ob__
} else {
// 創建監視着,傳入對象
ob = new Observer(obj)
}
return ob
}
// 監視者
class Observer {
constructor(obj) {
// 創建通知者
this.dep = new Dep()
// 將監視者添加到對象的身上
Object.defineProperty(obj, '__ob__', {
value: this,
})
// 遍歷對象數據,定義存取器
for (let k of Object.keys(obj)) {
defineReactive(obj, k)
}
}
}
// 通知者
class Dep {
constructor() {
// 用集合存儲自己的訂閲者
this.subs = new Set()
}
// 添加訂閲者
addSub(watcher) {
// 存儲訂閲者
this.subs.add(watcher)
}
//發佈訂閲
notify() {
// 依次執行更新函數
// 淺克隆是為了避免在訂閲者中修改同一數據,無限更新
for (const sub of [...this.subs]) {
sub.update()
}
}
// 標誌,表示是否處於依賴收集階段,值為 Wather
static target = null
}
// 定義存取攔截器,創建閉包環境
function defineReactive(data, key) {
// 為數據創建通知者
const dep = new Dep()
// 在閉包環境中用局部變量保存數據
let val = data[key]
// 子數據如果是對象,也創建監視者
let childOb = observe(val)
// 定義存取器
Object.defineProperty(data, key, {
// getter
get() {
// 如果處於依賴收集階段
if (Dep.target != null) {
// 添加訂閲
dep.addSub(Dep.target)
// 監視者的通知者也要添加訂閲
if (childOb != null) {
childOb.dep.addSub(Dep.target)
}
}
// 從局部變量獲取值
return val
},
// setter
set(newValue) {
if (val === newValue) {
return
}
// 更新局部變量
val = newValue
// 新值也需要嘗試創建監視着
childOb = observe(newValue)
// 告訴通知者發佈訂閲
dep.notify()
},
})
}
// 訂閲者
class Watcher {
constructor(vue, expression, callback) {
this.target = vue
this.expression = expression
this.callback = callback
this.value = this.get()
}
update() {
// 獲取新值,如果不相等,則執行更新函數
const value = this.get()
if (value !== this.value) {
this.value = value
this.callback.call(this.target, value)
}
}
get() {
// 進入依賴收集階段,讓全局的Dep.target設置成Watcher本身
Dep.target = this
// 沿着路徑一致尋找
let val = getObjVal(this.target, this.expression)
// 依賴收集結束
Dep.target = null
return val
}
}
// 根據字符串表達式獲取值
function getObjVal(obj, exp) {
let val = obj
exp = exp.split('.')
exp.forEach((k) => {
val = val[k]
})
return val
}
// 編譯模板
function compile(el, vue) {
// 獲取掛載節點
const $el = document.querySelector(el)
// 創建片段,存儲dom節點
let fragment = document.createDocumentFragment()
// 將所有dom節點都放入片段中
let child
while ((child = $el.firstChild)) {
fragment.appendChild(child)
}
// 匹配響應式寫法
const reg = /\{\{(.*)\}\}/
// 遍歷子節點
// 簡便起見,直解析文本節點
for (const node of fragment.childNodes) {
const text = node.textContent
if (node.nodeType == 3 && reg.test(text)) {
// 獲取字符串表達式
let name = text.match(reg)[1].trim()
// 從vue中獲取數據賦值
node.textContent = getObjVal(vue, name)
// 創建訂閲者,綁定表達式與更新函數
new Watcher(vue, name, (value) => {
node.textContent = value
})
}
}
// 上樹
$el.appendChild(fragment)
}
總結
Vue2 實現響應式的三個類非常繞,希望讀者仔細思考,理清其中關係
最後再強調一遍三個類的功能
- 觀察者會在每一個對象身上創建,為其所有屬性添加存取器,用於操作通知者發佈訂閲
- 每一個數據(包括對象)都會獨有一個通知者,通知者內使用一個集合存儲所有依賴這個數據的訂閲者
- 數據在模板中的每一次使用,都會創建一個訂閲者,存儲更新函數與數據訪問表達式
結語
如果文中有不理解或不嚴謹的地方,歡迎評論提問。
如果喜歡或有所幫助,希望能點贊關注,鼓勵一下作者。