深入理解vue響應(yīng)式原理
原創(chuàng)【51CTO.com原創(chuàng)稿件】前言
Vue 最獨(dú)特的特性之一,是其非侵入性的響應(yīng)式系統(tǒng)。數(shù)據(jù)模型僅僅是普通的 JavaScript 對(duì)象。而當(dāng)你修改它們時(shí),視圖會(huì)進(jìn)行更新。這使得狀態(tài)管理非常簡(jiǎn)單直接,不過(guò)理解其工作原理同樣重要,這樣你可以避開(kāi)一些常見(jiàn)的問(wèn)題。----官方文檔 本文將針對(duì)響應(yīng)式原理做一個(gè)詳細(xì)介紹,并且?guī)銓?shí)現(xiàn)一個(gè)基礎(chǔ)版的響應(yīng)式系統(tǒng)。本文的代碼請(qǐng)猛戳Github博客
什么是響應(yīng)式
我們先來(lái)看個(gè)例子:
- <div id="app">
- <div>Price :¥{{ price }}</div>
- <div>Total:¥{{ price * quantity }}</div>
- <div>Taxes: ¥{{ totalPriceWithTax }}</div>
- <button @click="changePrice">改變價(jià)格</button>
- </div>
- var app = new Vue({
- el: '#app',
- data() {
- return {
- price: 5.0,
- quantity: 2
- };
- },
- computed: {
- totalPriceWithTax() {
- return this.price * this.quantity * 1.03;
- }
- },
- methods: {
- changePrice() {
- this.price = 10;
- }
- }
- })
上例中當(dāng)price 發(fā)生變化的時(shí)候,Vue就知道自己需要做三件事情:
- 更新頁(yè)面上price的值
- 計(jì)算表達(dá)式 price*quantity 的值,更新頁(yè)面
- 調(diào)用totalPriceWithTax 函數(shù),更新頁(yè)面
發(fā)生變化后,會(huì)重新對(duì)頁(yè)面渲染,這就是Vue響應(yīng)式,那么這一切是怎么做到的呢?
想完成這個(gè)過(guò)程,我們需要:
- 偵測(cè)數(shù)據(jù)的變化
- 收集視圖依賴了哪些數(shù)據(jù)
- 數(shù)據(jù)變化時(shí),自動(dòng)“通知”需要更新的視圖部分,并進(jìn)行更新
對(duì)應(yīng)專(zhuān)業(yè)俗語(yǔ)分別是:
- 數(shù)據(jù)劫持 / 數(shù)據(jù)代理
- 依賴收集
- 發(fā)布訂閱模式
如何偵測(cè)數(shù)據(jù)的變化
首先有個(gè)問(wèn)題,在Javascript中,如何偵測(cè)一個(gè)對(duì)象的變化? 其實(shí)有兩種辦法可以偵測(cè)到變化:使用Object.defineProperty和ES6的Proxy,這就是進(jìn)行數(shù)據(jù)劫持或數(shù)據(jù)代理。這部分代碼主要參考珠峰架構(gòu)課。
方法1.Object.defineProperty實(shí)現(xiàn)
Vue通過(guò)設(shè)定對(duì)象屬性的 setter/getter 方法來(lái)監(jiān)聽(tīng)數(shù)據(jù)的變化,通過(guò)getter進(jìn)行依賴收集,而每個(gè)setter方法就是一個(gè)觀察者,在數(shù)據(jù)變更的時(shí)候通知訂閱者更新視圖。
- function render () {
- console.log('模擬視圖渲染')
- }
- let data = {
- name: '浪里行舟',
- location: { x: 100, y: 100 }
- }
- observe(data)
- function observe (obj) {
- // 判斷類(lèi)型
- if (!obj || typeof obj !== 'object') {
- return
- }
- Object.keys(obj).forEach(key => {
- defineReactive(obj, key, obj[key])
- })
- function defineReactive (obj, key, value) {
- // 遞歸子屬性
- observe(value)
- Object.defineProperty(obj, key, {
- enumerable: true, //可枚舉(可以遍歷)
- configurable: true, //可配置(比如可以刪除)
- get: function reactiveGetter () {
- console.log('get', value) // 監(jiān)聽(tīng)
- return value
- },
- set: function reactiveSetter (newVal) {
- observe(newVal) //如果賦值是一個(gè)對(duì)象,也要遞歸子屬性
- if (newVal !== value) {
- console.log('set', newVal) // 監(jiān)聽(tīng)
- render()
- value = newVal
- }
- }
- })
- }
- }
- data.location = {
- x: 1000,
- y: 1000
- } //set {x: 1000,y: 1000} 模擬視圖渲染
- data.name // get 浪里行舟
幾個(gè)注意點(diǎn)補(bǔ)充說(shuō)明:
- 這種方式無(wú)法檢測(cè)到對(duì)象屬性的添加或刪除(如data.location.a=1)。
這是因?yàn)?Vue 通過(guò)Object.defineProperty來(lái)將對(duì)象的key轉(zhuǎn)換成getter/setter的形式來(lái)追蹤變化,但getter/setter只能追蹤一個(gè)數(shù)據(jù)是否被修改,無(wú)法追蹤新增屬性和刪除屬性。如果是刪除屬性,我們可以用vm.$delete實(shí)現(xiàn),那如果是新增屬性,該怎么辦呢? 1)可以使用 Vue.set(location, a, 1) 方法向嵌套對(duì)象添加響應(yīng)式屬性; 2)也可以給這個(gè)對(duì)象重新賦值,比如data.location = {...data.location,a:1}
- Object.defineProperty 不能監(jiān)聽(tīng)數(shù)組的變化,需要進(jìn)行數(shù)組方法的重寫(xiě)
- function render() {
- console.log('模擬視圖渲染')
- }
- let obj = [1, 2, 3]
- let methods = ['pop', 'shift', 'unshift', 'sort', 'reverse', 'splice', 'push']
- // 先獲取到原來(lái)的原型上的方法
- let arrayProto = Array.prototype
- // 創(chuàng)建一個(gè)自己的原型 并且重寫(xiě)methods這些方法
- let proto = Object.create(arrayProto)
- methods.forEach(method => {
- proto[method] = function() {
- // AOP
- arrayProto[method].call(this, ...arguments)
- render()
- }
- })
- function observer(obj) {
- // 把所有的屬性定義成set/get的方式
- if (Array.isArray(obj)) {
- obj.__proto__ = proto
- return
- }
- if (typeof obj == 'object') {
- for (let key in obj) {
- defineReactive(obj, key, obj[key])
- }
- }
- }
- function defineReactive(data, key, value) {
- observer(value)
- Object.defineProperty(data, key, {
- get() {
- return value
- },
- set(newValue) {
- observer(newValue)
- if (newValue !== value) {
- render()
- value = newValue
- }
- }
- })
- }
- observer(obj)
- function $set(data, key, value) {
- defineReactive(data, key, value)
- }
- obj.push(123, 55)
- console.log(obj) //[1, 2, 3, 123, 55]
這種方法將數(shù)組的常用方法進(jìn)行重寫(xiě),進(jìn)而覆蓋掉原生的數(shù)組方法,重寫(xiě)之后的數(shù)組方法需要能夠被攔截。但有些數(shù)組操作Vue時(shí)攔截不到的,當(dāng)然也就沒(méi)辦法響應(yīng),比如:
- obj.length-- // 不支持?jǐn)?shù)組的長(zhǎng)度變化
- obj[0]=1 // 修改數(shù)組中***個(gè)元素,也無(wú)法偵測(cè)數(shù)組的變化
ES6提供了元編程的能力,所以有能力攔截,Vue3.0可能會(huì)用ES6中Proxy 作為實(shí)現(xiàn)數(shù)據(jù)代理的主要方式。
方法2.Proxy實(shí)現(xiàn)
Proxy 是 JavaScript 2015 的一個(gè)新特性。Proxy 的代理是針對(duì)整個(gè)對(duì)象的,而不是對(duì)象的某個(gè)屬性,因此不同于 Object.defineProperty 的必須遍歷對(duì)象每個(gè)屬性,Proxy 只需要做一層代理就可以監(jiān)聽(tīng)同級(jí)結(jié)構(gòu)下的所有屬性變化,當(dāng)然對(duì)于深層結(jié)構(gòu),遞歸還是需要進(jìn)行的。此外**Proxy支持代理數(shù)組的變化。**
- function render() {
- console.log('模擬視圖的更新')
- }
- let obj = {
- name: '前端工匠',
- age: { age: 100 },
- arr: [1, 2, 3]
- }
- let handler = {
- get(target, key) {
- // 如果取的值是對(duì)象就在對(duì)這個(gè)對(duì)象進(jìn)行數(shù)據(jù)劫持
- if (typeof target[key] == 'object' && target[key] !== null) {
- return new Proxy(target[key], handler)
- }
- return Reflect.get(target, key)
- },
- set(target, key, value) {
- if (key === 'length') return true
- render()
- return Reflect.set(target, key, value)
- }
- }
- let proxy = new Proxy(obj, handler)
- proxy.age.name = '浪里行舟' // 支持新增屬性
- console.log(proxy.age.name) // 模擬視圖的更新 浪里行舟
- proxy.arr[0] = '浪里行舟' //支持?jǐn)?shù)組的內(nèi)容發(fā)生變化
- console.log(proxy.arr) // 模擬視圖的更新 ['浪里行舟', 2, 3 ]
- proxy.arr.length-- // 無(wú)效
以上代碼不僅精簡(jiǎn),而且還是實(shí)現(xiàn)一套代碼對(duì)對(duì)象和數(shù)組的偵測(cè)都適用。不過(guò)Proxy兼容性不太好!
我們之所以要觀察數(shù)據(jù),其目的在于當(dāng)數(shù)據(jù)的屬性發(fā)生變化時(shí),可以通知那些曾經(jīng)使用了該數(shù)據(jù)的地方。比如***例子中,模板中使用了price 數(shù)據(jù),當(dāng)它發(fā)生變化時(shí),要向使用了它的地方發(fā)送通知。那如何收集依賴呢?
收集依賴與發(fā)布訂閱模式
如何收集依賴,總結(jié)起來(lái)就一句話,在getter中收集依賴,在setter中觸發(fā)依賴 我們先來(lái)實(shí)現(xiàn)一個(gè) Dep 類(lèi),用于解耦屬性的依賴收集和派發(fā)更新操作。
- // 通過(guò) Dep 解耦屬性的依賴和更新操作
- class Dep {
- constructor() {
- this.subs = []
- }
- // 添加依賴
- addSub(sub) {
- this.subs.push(sub)
- }
- // 更新
- notify() {
- this.subs.forEach(sub => {
- sub.update()
- })
- }
- }
- // 全局屬性,通過(guò)該屬性配置 Watcher
- Dep.target = null
當(dāng)需要依賴收集的時(shí)候調(diào)用 addSub,當(dāng)需要派發(fā)更新的時(shí)候調(diào)用 notify。具體如何調(diào)用呢?
- let dp = new Dep()
- dp.addSub(() => {
- console.log('emit here')
- })
- dp.notify()
這就是一個(gè)簡(jiǎn)單實(shí)現(xiàn)的“事件發(fā)布訂閱模式”,當(dāng)然代碼只是啟發(fā)思路,真實(shí)應(yīng)用還比較“粗糙”,沒(méi)有進(jìn)行事件名設(shè)置,APIs 也并不豐富,但完全能夠說(shuō)明問(wèn)題了。
接下來(lái)我們先來(lái)簡(jiǎn)單的了解下 Vue 組件掛載時(shí)添加響應(yīng)式的過(guò)程。在組件掛載時(shí),會(huì)先對(duì)所有需要的屬性調(diào)用 Object.defineProperty(),然后實(shí)例化 Watcher,傳入組件更新的回調(diào)。在實(shí)例化過(guò)程中,會(huì)對(duì)模板中的屬性進(jìn)行求值,觸發(fā)依賴收集。我們可以把Watcher理解成一個(gè)中介的角色,數(shù)據(jù)發(fā)生變化時(shí)通知它,然后它再通知其他地方。
***需要對(duì) defineReactive 函數(shù)進(jìn)行改造,在自定義函數(shù)中添加依賴收集和派發(fā)更新相關(guān)的代碼。
- function render () {
- console.log('模擬視圖渲染')
- }
- let data = {
- name: '浪里行舟',
- location: { x: 100, y: 100 }
- }
- observe(data)
- let dp = new Dep()
- function observe (obj) {
- // 判斷類(lèi)型
- if (!obj || typeof obj !== 'object') {
- return
- }
- Object.keys(obj).forEach(key => {
- defineReactive(obj, key, obj[key])
- })
- function defineReactive (obj, key, value) {
- // 遞歸子屬性
- observe(value)
- Object.defineProperty(obj, key, {
- enumerable: true, //可枚舉(可以遍歷)
- configurable: true, //可配置(比如可以刪除)
- get: function reactiveGetter () {
- console.log('get', value) // 監(jiān)聽(tīng)
- // 將 Watcher 添加到訂閱
- if (Dep.target) {
- dp.addSub(Dep.target)
- }
- return value
- },
- set: function reactiveSetter (newVal) {
- observe(newVal) //如果賦值是一個(gè)對(duì)象,也要遞歸子屬性
- if (newVal !== value) {
- console.log('set', newVal) // 監(jiān)聽(tīng)
- render()
- value = newVal
- // 執(zhí)行 watcher 的 update 方法
- dp.notify()
- }
- }
- })
- }
- }
以上所有代碼實(shí)現(xiàn)了一個(gè)簡(jiǎn)易的數(shù)據(jù)響應(yīng)式,核心思路就是手動(dòng)觸發(fā)一次屬性的 getter 來(lái)實(shí)現(xiàn)依賴收集。
總結(jié)
我們?cè)賮?lái)回顧下整個(gè)過(guò)程:
- 在 Vue 中模板編譯過(guò)程中的指令或者數(shù)據(jù)綁定都會(huì)實(shí)例化一個(gè) Watcher 實(shí)例,實(shí)例化過(guò)程中會(huì)觸發(fā) get() 將自身指向 Dep.target;
- data在 Observer 時(shí)執(zhí)行 getter 會(huì)觸發(fā) dep.depend() 進(jìn)行依賴收集;依賴收集的結(jié)果:
- data在 Observer 時(shí)閉包的dep實(shí)例的subs添加觀察它的 Watcher 實(shí)例;
- Watcher 的deps中添加觀察對(duì)象 Observer 時(shí)的閉包dep;
- 當(dāng)data中被 Observer 的某個(gè)對(duì)象值變化后,觸發(fā)subs中觀察它的watcher執(zhí)行 update() 方法,***實(shí)際上是調(diào)用watcher的回調(diào)函數(shù)cb,進(jìn)而更新視圖。
參考文章和書(shū)籍
- 珠峰架構(gòu)課(強(qiáng)烈推薦)
- 剖析 Vue.js 內(nèi)部運(yùn)行機(jī)制
- 深入淺出Vue.js
- Vue官方文檔
- 前端面試之道
- 前端開(kāi)發(fā)核心知識(shí)進(jìn)階
- Javascript響應(yīng)式的最通俗易懂的解釋(譯)
作者介紹
浪里行舟:碩士研究生,專(zhuān)注于前端。個(gè)人公眾號(hào):「前端工匠」,致力于打造適合初中級(jí)工程師能夠快速吸收的一系列優(yōu)質(zhì)文章!
【51CTO原創(chuàng)稿件,合作站點(diǎn)轉(zhuǎn)載請(qǐng)注明原文作者和出處為51CTO.com】