分析vue是如何實現資料改變更新檢視的.
前記
三個月前看了vue原始碼來分析如何做到響應式資料的, 文章名字叫vue原始碼之響應式資料, 最後分析到, 資料變化後會呼叫Watcher
的update()
方法. 那麼時隔三月讓我們繼續看看update()
做了什麼. (這三個月用react-native做了個專案, 也無心總結了, 因為好像太簡單了).
本文敘事方式為樹藤摸瓜, 順著看原始碼的邏輯走一遍, 檢視的vue的版本為2.5.2. 我fork了一份原始碼用來記錄註釋.
目的
明確調查方向才能直至目標, 先說一下目標行為: 資料變化以後執行了什麼方法來更新檢視的. 那麼準備開始以這個方向為目標從vue原始碼的入口開始找答案.
從之前的結論開始
先來複習一下之前的結論:
- vue構造的時候會在data(和一些別的欄位)上建立Observer物件, getter和setter被做了攔截, getter觸發依賴收集, setter觸發notify.
- 另一個物件是Watcher, 註冊watch的時候會呼叫一次watch的物件, 這樣觸發了watch物件的getter, 把依賴收集到當前Watcher的deps裡, 當任何dep的setter被觸發就會notify當前Watcher來呼叫Watcher的
update()
方法.
那麼這裡就從註冊渲染相關的Watcher開始.
找到了檔案在src/core/instance/lifecycle.js
中.
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
mountComponent
渲染相關的Watcher是在mountComponent()
這個方法中呼叫的, 那麼我們搜一下這個方法是在哪裡呼叫的. 只有2處, 分別是src/platforms/web/runtime/index.js
和src/platforms/weex/runtime/index.js
, 以web為例:
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
原來如此, 是$mount()
方法呼叫了mountComponent()
, (或者在vue構造時指定el
欄位也會自動呼叫$mount()
方法), 因為web和weex(什麼是weex?之前別的文章介紹過)渲染的標的物不同, 所以在釋出的時候應該引入了不同的檔案最後發不成不同的dist(這個問題留給之後來研究vue的整個流程).
下面是mountComponent方法:
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el // 放一份el到自己的屬性裡
if (!vm.$options.render) { // render應該經過處理了, 因為我們經常都是用template或者vue檔案
// 判斷是否存在render函式, 如果沒有就把render函式寫成空VNode來避免紅錯, 並報出黃錯
vm.$options.render = createEmptyVNode
if (process.env.NODE_ENV !== `production`) {
/* istanbul ignore if */
if ((vm.$options.template && vm.$options.template.charAt(0) !== `#`) ||
vm.$options.el || el) {
warn(
`You are using the runtime-only build of Vue where the template ` +
`compiler is not available. Either pre-compile the templates into ` +
`render functions, or use the compiler-included build.`,
vm
)
} else {
warn(
`Failed to mount component: template or render function not defined.`,
vm
)
}
}
}
callHook(vm, `beforeMount`)
let updateComponent
/* istanbul ignore if */
if (process.env.NODE_ENV !== `production` && config.performance && mark) {
// 不看這裡的程式碼了, 直接看else裡的, 行為是一樣的
updateComponent = () => {
const name = vm._name
const id = vm._uid
const startTag = `vue-perf-start:${id}`
const endTag = `vue-perf-end:${id}`
mark(startTag)
const vnode = vm._render()
mark(endTag)
measure(`vue ${name} render`, startTag, endTag)
mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
// we set this to vm._watcher inside the watcher`s constructor
// since the watcher`s initial patch may call $forceUpdate (e.g. inside child
// component`s mounted hook), which relies on vm._watcher being already defined
// 註冊一個Watcher
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
hydrating = false
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, `mounted`)
}
return vm
}
這段程式碼其實只做了3件事:
- 呼叫beforeMount鉤子
- 建立Watcher
- 呼叫mounted鉤子
(哈哈哈)那麼其實核心就是建立Watcher了.
看一下Watcher的引數: vm是this, updateComponent是一個函式, noop是空, null是空, true代表是RenderWatcher.
在Watcher裡看了isRenderWatcher
:
if (isRenderWatcher) {
vm._watcher = this
}
是的, 只是複製了一份用來在watcher第一次patch的時候判斷一些東西(從註釋裡看的, 我現在還不知道是幹嘛的).
那麼只有一個問題沒解決就是updateComponent
是個什麼東西.
updateComponent
在Watcher的建構函式的第二個引數傳了function, 那麼這個函式就成了watcher的getter. 聰明的你應該已經猜到, 在這個updateComponent
裡一定呼叫了檢視中所有的資料的getter, 才能在watcher中建立依賴從而讓檢視響應資料的變化.
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
那麼就去找vm._update()
和vm._render()
.
在src/core/instance/render.js
找到了._render()
方法.
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options // todo: render和_parentVnode的由來
// reset _rendered flag on slots for duplicate slot check
if (process.env.NODE_ENV !== `production`) {
for (const key in vm.$slots) {
// $flow-disable-line
vm.$slots[key]._rendered = false
}
}
if (_parentVnode) {
vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject
}
// set parent vnode. this allows render functions to have access
// to the data on the placeholder node.
vm.$vnode = _parentVnode
// render self
let vnode
try {
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
// catch其實不需要看了, 都是做異常處理, _vnode是在vm._update的時候儲存的, 也就是上次的狀態或是null(init的時候給的)
handleError(e, vm, `render`)
// return error render result,
// or previous vnode to prevent render error causing blank component
/* istanbul ignore else */
if (process.env.NODE_ENV !== `production`) {
if (vm.$options.renderError) {
try {
vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
} catch (e) {
handleError(e, vm, `renderError`)
vnode = vm._vnode
}
} else {
vnode = vm._vnode
}
} else {
vnode = vm._vnode
}
}
// return empty vnode in case the render function errored out
if (!(vnode instanceof VNode)) {
if (process.env.NODE_ENV !== `production` && Array.isArray(vnode)) {
warn(
`Multiple root nodes returned from render function. Render function ` +
`should return a single root node.`,
vm
)
}
vnode = createEmptyVNode()
}
// set parent
vnode.parent = _parentVnode
return vnode
}
}
這個方法做了:
- 根據當前vm的render方法來生成VNode. (render方法可能是根據template或vue檔案編譯而來, 所以推論直接寫render方法效率最高)
- 如果render方法有問題, 那麼首先呼叫renderError方法, 再不行就讀取上次的vnode或是null.
- 如果有父節點就放到自己的
.parent
屬性裡. - 最後返回VNode
所以核心是這句:
vnode = render.call(vm._renderProxy, vm.$createElement)
其中的render()
, vm._renderProxy
, vm.$createElement
都不知道是什麼.
先看vm._renderProxy
: 是initMixin()
的時候設定的, 在生產環境返回vm, 開發環境返回代理, 那麼我們認為他是一個可以debug的vm(就是vm), 細節之後再看.
vm.$createElement
的程式碼在vdom資料夾下, 看了下是一個方法, 返回值一個VNode.
render有點複雜, 能不能以後研究, 總之就是把template或者vue單檔案和mount目標parse成render函式.
小總結: vm._render()的返回值是VNode, 根據當前vm的render函式
接下來看vm._update()
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
if (vm._isMounted) {
callHook(vm, `beforeUpdate`)
}
// 記錄update之前的狀態
const prevEl = vm.$el
const prevVnode = vm._vnode
const prevActiveInstance = activeInstance
activeInstance = vm
vm._vnode = vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) { // 初次載入, 只有_update方法更新vm._vnode, 初始化是null
// initial render
vm.$el = vm.__patch__( // patch建立新dom
vm.$el, vnode, hydrating, false /* removeOnly */,
vm.$options._parentElm,
vm.$options._refElm
)
// no need for the ref nodes after initial patch
// this prevents keeping a detached DOM tree in memory (#5851)
vm.$options._parentElm = vm.$options._refElm = null
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode) // patch更新dom
}
activeInstance = prevActiveInstance
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// if parent is an HOC, update its $el as well
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent`s updated hook.
}
我們關心的部分其實就是__patch()
的部分, __patch()
做了對dom的操作, 在_update()
裡判斷了是否是初次呼叫, 如果是的話建立新dom, 不是的話傳入新舊node進行比較再操作.
vue的入口檔案
現在render()
方法和__patch__()
方法都不在core
資料夾中被定義, 那麼現在來一起看看我們最終引用的vue
物件的整體.
以webpack的vue專案為例, 用的是vue.esm.js
, package.json的main欄位不是他, 於是看build命令:
node scripts/build.js
是用rollup把配置中的所有欄位都對應地編譯, 配置如下:
const builds = {
// Runtime only (CommonJS). Used by bundlers e.g. Webpack & Browserify
`web-runtime-cjs`: {
entry: resolve(`web/entry-runtime.js`),
dest: resolve(`dist/vue.runtime.common.js`),
format: `cjs`,
banner
},
// Runtime+compiler CommonJS build (CommonJS)
`web-full-cjs`: {
entry: resolve(`web/entry-runtime-with-compiler.js`),
dest: resolve(`dist/vue.common.js`),
format: `cjs`,
alias: { he: `./entity-decoder` },
banner
},
// Runtime only (ES Modules). Used by bundlers that support ES Modules,
// e.g. Rollup & Webpack 2
`web-runtime-esm`: {
entry: resolve(`web/entry-runtime.js`),
dest: resolve(`dist/vue.runtime.esm.js`),
format: `es`,
banner
},
// Runtime+compiler CommonJS build (ES Modules)
`web-full-esm`: {
entry: resolve(`web/entry-runtime-with-compiler.js`),
dest: resolve(`dist/vue.esm.js`),
format: `es`,
alias: { he: `./entity-decoder` },
banner
},
... // 以下省略, 還有很多...
}
我們找的檔案vue.esm.js
的入口檔案找到啦, 是web/entry-runtime-with-compiler.js
.
而在web/entry-runtime-with-compiler.js
中, 又從./runtime/index
引入了Vue, 最後才從core/index
中引入Vue.
所以Vue的平臺無關的內容放在core
中, 最後打成dist的時候根據不同的釋出平臺(web, weex), 釋出模式(browser, es-module)來給核心Vue物件掛載更多的方法和屬性, 那麼我們現在來看看web/es-module這條路新增了些什麼~
從runtime/index
開始:
// runtime/index.js 部分程式碼
// install platform specific utils
Vue.config.mustUseProp = mustUseProp
Vue.config.isReservedTag = isReservedTag
Vue.config.isReservedAttr = isReservedAttr
Vue.config.getTagNamespace = getTagNamespace
Vue.config.isUnknownElement = isUnknownElement
// install platform runtime directives & components
extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop
// public mount method
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
掛載了一些常量和平臺專屬directive和component. 我們關心的__patch__()
方法是在這裡被掛上的, $mount()
方法也是這個時候掛上的, 正是呼叫了mountComponent()
.
然後看web/entry-runtime-with-compiler.js
:
// web/entry-runtime-with-compiler.js 部分程式碼
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
/* istanbul ignore if */
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== `production` && warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}
const options = this.$options
// resolve template/el and convert to render function
if (!options.render) { // 如果沒有render方法就嘗試把別的欄位編譯成render方法
let template = options.template
if (template) { // 嘗試template欄位, 沒有的話就獲取el欄位並編譯成template
if (typeof template === `string`) {
if (template.charAt(0) === `#`) {
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== `production` && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
if (process.env.NODE_ENV !== `production`) {
warn(`invalid template option:` + template, this)
}
return this
}
} else if (el) {
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== `production` && config.performance && mark) {
mark(`compile`)
}
// 把template編譯成render函式
const { render, staticRenderFns } = compileToFunctions(template, {
shouldDecodeNewlines, // 檢測瀏覽器的行為, 是否會把一些東西url-encode
shouldDecodeNewlinesForHref,
delimiters: options.delimiters, // 預設是雙花括號 `{{` `}}`, 用來編譯模板的
comments: options.comments // 預設是false, 如果true就不丟棄註釋
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
if (process.env.NODE_ENV !== `production` && config.performance && mark) {
mark(`compile end`)
measure(`vue ${this._name} compile`, `compile`, `compile end`)
}
}
// 如果所有if都沒走到, 那麼就沒有render方法, 異常將在$mount的時候丟擲. 這裡沒有做處理
}
return mount.call(this, el, hydrating)
}
註釋都貼在上面的程式碼裡了, 在這個檔案裡在$mount()
方法裡插入render()
方法的註冊, 總結為:
- 如果有
render()
函式, 就用render()
函式. - 如果沒有, 就用
template
屬性編譯成render()
函式. - 如果沒有
template
屬性, 就用找el
屬性所指的dom, 並把他編譯成template
. - 最後用
template
(原來的template
或是el
編譯成的)編譯出render()
函式. - 如果是三無產品(
render()
,template
,el
都沒有). 那麼什麼都不做, 這個Vue例項就沒有render()
函式, 但沒有報錯, 因為在mountComponent()
的時候會報錯.
結論
- vue的檢視渲染是一種特殊的Watcher, watch的內容是一個函式, 函式執行的過程呼叫了render函式, render又是由template或者el的dom編譯成的(template中含有一些被observe的資料). 所以template中被observe的資料有變化觸發Watcher的update()方法就會重新渲染檢視.
- Vue的平臺無關的內容在
core
中, 最後打成dist的時候根據不同的釋出平臺(web, weex), 釋出模式(browser, es-module)來給核心Vue物件掛載更多的方法和屬性(程式碼在platforms中).render()
和__patch__()
是在platforms裡掛上的.
遺留
- template編譯成render的實現
-
__patch__
和VNode的分析