原理深度解析Vue的響應式更新比React快
前言
我們都知道 Vue 對於響應式屬性的更新,只會精確更新依賴收集的當前元件,而不會遞迴的去更新子元件,這也是它效能強大的原因之一。
例子
舉例來說 這樣的一個元件:
<template> <div> {{ msg }} <ChildComponent /> </div> </template>
我們在觸發 this.msg = 'Hello,Changed~'的時候,會觸發元件的更新,檢視的重新渲染。
但是 <ChildComponent /> 這個元件其實是不會重新渲染的,這是 Vue 刻意而為之的。
在以前的一段時間裡,我曾經認為因為元件是一棵樹,所以它的更新就是理所當然的深度遍歷這棵樹,進行遞迴更新。本篇就從原始碼的角度帶你一起分析,Vue 是怎麼做到精確更新的。
React的更新粒度
而 React 在類似的場景下是自頂向下的進行遞迴更新的,也就是說,React 中假如 ChildComponent 裡還有十層巢狀子元素,那麼所有層次都會遞迴的重新render(在不進行手動優化的情況下),這是效能上的災難。(因此,React 創造了Fiber,創造了非同步渲染,其實本質上是彌補被自己搞砸了的效能)。
他們能用收集依賴的這套體系嗎?不能,因為他們遵從Immutable的設計思想,永遠不在原物件上修改屬性,那麼基於Object.defineProperty 或 Proxy 的響應式依賴收集機制就無從下手了(你永遠返回一個新的物件,我哪知道你修改了舊物件的哪部分?)
同時,由於沒有響應式的收集依賴,React 只能遞迴的把所有子元件都重新 render一遍(除了memo和shouldComponentUpdate這些優化手段),然後再通過 diff演算法 決定要更新哪部分的檢視,這個遞迴的過程叫做 reconciler,聽起來很酷,但是效能很災難。
Vue的更新粒度
那麼,Vue 這種精確的更新是怎麼做的呢?其實每個元件都有自己的渲染 watcher,它掌管了當前元件的檢視更新,但是並不會掌管 ChildComponent 的更新。
具體到原始碼中,是怎麼樣實現的呢?
在 patch 的過程中,當元件更新到ChildComponent的時候,會走到patchVnode,那麼這個方法大致做了哪些事情呢?
patchVnode
執行 vnode 的 prepatch 鉤子。
注意,只有 元件vnode 才會有 prepatch 這個生命週期,
這裡會走到updateChildComponent方法,這個 child 具體指什麼呢?
prepatch (oldVnode: MountedComponentVNode,vnode: MountedComponentVNode) { const options = vnode.componentOptions // 注意 這個child就是ChildComponent元件的 vm 例項,也就是咱們平常用的 this const child = vnode.componentInstance = oldVnode.componentInstance updateChildComponent( child,options.propsData,// updated props options.listeners,// updated listeners vnode,// new parent vnode options.children // new children ) },
其實看傳入的引數也能猜到大概了,就是做了:
- 更新props(後續詳細講)
- 更新繫結事件
- 對於slot做一些更新(後續詳細講)
如果有子節點的話,對子節點進行 diff。
比如這樣的場景:
<ul> <li>1</li> <li>2</li> <li>3</li> <ul>
要對於 ul 中的三個 li 子節點 vnode 利用 diff 演算法來更新,本篇略過。
然後到此為止,patchVnode 就結束了,並沒有像常規思維中的那樣去遞迴的更新子元件樹。
這也就說明了,Vue 的元件更新確實是精確到元件本身的。
如果是子元件呢?
假設列表是這樣的:
<ul> <component>1</component> <component>2</component> <component>3</component> <ul>
那麼在diff的過程中,只會對 component 上宣告的 props、listeners等屬性進行更新,而不會深入到元件內部進行更新。
注意:不會深入到元件內部進行更新!(劃重點,這也是本文所說的更新粒度的關鍵)
props的更新如何觸發重渲染?
那麼有同學可能要問了,如果不會遞迴的去對子元件更新,如果我們把 msg 這個響應式元素通過props傳給 ChildComponent,此時它怎麼更新呢?
首先,在元件初始化 props的時候,會走到 initProps 方法。
const props = vm._props = {} for (const key in propsOptions) { // 經過一系列驗證props合法性的流程後 const value = validateProp(key,propsOptions,propsData,vm) // props中的欄位也被定義成響應式了 defineReactive(props,key,value) }
至此為止,是實現了對於 _props 上欄位變更的劫持。也就是變成了響應式資料,後面我們做類似於 _props.msg = 'Changed' 的操作時(當然我們不會這樣做,Vue內部會做),就會觸發檢視更新。
其實,msg 在傳給子元件的時候,會被儲存在子元件例項的 _props 上,並且被定義成了響應式屬性,而子元件的模板中對於 msg 的訪問其實是被代理到 _props.msg 上去的,所以自然也能精確的收集到依賴,只要 ChildComponent 在模板裡也讀取了這個屬性。
這裡要注意一個細節,其實父元件發生重渲染的時候,是會重新計運算元元件的 props 的,具體是在 updateChildComponent 中的:
// update props if (propsData && vm.$options.props) { toggleObserving(false) // 注意props被指向了 _props const props = vm._props const propKeys = vm.$options._propKeys || [] for (let i = 0; i < propKeys.length; i++) { const key = propKeys[i] const propOptions: any = vm.$options.props // wtf flow? // 就是這句話,觸發了對於 _props.msg 的依賴更新。 props[key] = validateProp(key,propOptions,vm) } toggleObserving(true) // keep a copy of raw propsData vm.$options.propsData = propsData }
那麼,由於上面註釋標明的那段程式碼,msg 的變化通過 _props 的響應式能力,也讓子元件重新渲染了,到目前為止,都只有真的用到了 msg 的元件被重新渲染了。
正如官網 api 文件中所說:
vm.$forceUpdate:迫使 Vue 例項重新渲染。注意它僅僅影響例項本身和插入插槽內容的子元件,而不是所有子元件。
—— vm-forceUpdate文件
我們需要知道一個小知識點,vm.$forceUpdate 本質上就是觸發了渲染watcher的重新執行,和你去修改一個響應式的屬性觸發更新的原理是一模一樣的,它只是幫你呼叫了 vm._watcher.update()(只是提供給你了一個便捷的api,在設計模式中叫做門面模式)
slot是怎麼更新的?
注意這裡也提到了一個細節,也就是 插入插槽內容的子元件:
舉例來說
假設我們有父元件parent-comp:
<div> <slot-comp> <span>{{ msg }}</span> </slot-comp> </div>
子元件 slot-comp:
<div> <slot></slot> </div>
元件中含有 slot的更新 ,是屬於比較特殊的場景。
這裡的 msg 屬性在進行依賴收集的時候,收集到的是 parent-comp 的`渲染watcher。(至於為什麼,你看一下它所在的渲染上下文就懂了。)
那麼我們想象 msg 此時更新了,
<div> <slot-comp> <span>{{ msg }}</span> </slot-comp> </div>
這個元件在更新的時候,遇到了一個子元件 slot-comp,按照 Vue 的精確更新策略來說,子元件是不會重新渲染的。
但是在原始碼內部,它做了一個判斷,在執行 slot-comp 的 prepatch 這個hook的時候,會執行 updateChildComponent 邏輯,在這個函式內部會發現它有 slot 元素。
prepatch (oldVnode: MountedComponentVNode,vnode: MountedComponentVNode) { const options = vnode.componentOptions // 注意 這個child就是 slot-comp 元件的 vm 例項,也就是咱們平常用的 this const child = vnode.componentInstance = oldVnode.componentInstance updateChildComponent( child,
在 updateChildComponent 內部
const hasChildren = !!( // 這玩意就是 slot 元素 renderChildren || // has new static slots vm.$options._renderChildren || // has old static slots parentVnode.data.scopedSlots || // has new scoped slots vm.$scopedSlots !== emptyObject // has old scoped slots )
然後下面走一個判斷
if (hasChildren) { vm.$slots = resolveSlots(renderChildren,parentVnode.context) vm.$forceUpdate() }
這裡呼叫了 slot-comp 元件vm例項上的 $forceUpdate,那麼它所觸發的渲染watcher就是屬於slot-comp的渲染watcher了。
總結來說,這次 msg 的更新不光觸發了 parent-comp 的重渲染,也進一步的觸發了擁有slot的子元件 slot-comp 的重渲染。
它也只是觸發了兩層渲染,如果 slot-comp 內部又渲染了其他元件 slot-child,那麼此時它是不會進行遞迴更新的。(只要 slot-child 元件不要再有 slot 了)。
比起 React 的遞迴更新,是不是還是好上很多呢?
贈禮 一個小issue
有人給 Vue 2.4.2 版本提了一個issue,在下面的場景下會出現 bug。
let Child = { name: "child",template: '<div><span>{{ localMsg }}</span><button @click="change">click</button></div>',data: function() { return { localMsg: this.msg }; },props: { msg: String },methods: { change() { this.$emit("update:msg","world"); } } }; new Vue({ el: "#app",template: '<child :msg.sync="msg"><child>',beforeUpdate() { alert("update twice"); },data() { return { msg: "hello" }; },components: { Child } });
具體的表現是點選 click按鈕,會 alert 出兩次 update twice。 這是由於子元件在執行 data 這個函式初始化元件的資料時,會錯誤的再收集一遍 Dep.target (也就是渲染watcher)。
由於資料初始化的時機是 beforeCreated -> created 之間,此時由於還沒有進入子元件的渲染階段, Dep.target 還是父元件的渲染watcher。
這就導致重複收集依賴,重複觸發同樣的更新
怎麼解決的呢?很簡單,在執行 data 函式的前後,把 Dep.target 先設定為 null 即可,在 finally 中再恢復,這樣響應式資料就沒辦法收集到依賴了。
export function getData (data: Function,vm: Component): any { const prevTarget = Dep.target + Dep.target = null try { return data.call(vm,vm) } catch (e) { handleError(e,vm,`data()`) return {} + } finally { + Dep.target = prevTarget } }
後記
如果你對於 Dep.target、 渲染watcher等概念還不太理解,可以看我寫的一篇最簡實現 Vue 響應式的文章,歡迎閱讀:
手把手帶你實現一個最精簡的響應式系統來學習Vue的data、computed、watch原始碼
本文也存放在我的Github部落格倉庫中,歡迎訂閱和star。
到此這篇關於原理深度解析Vue的響應式更新比React快的文章就介紹到這了,更多相關Vue的響應式更新比React快內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!