注: 路人读者请移步 => Huang Yi 老师的Vue源码阅读点这里, 我写这一篇笔记更倾向于以自问自答的形式增加一些自己的理解 , 内容包含面试题范围但超出更多.
自己提出的问题自己解决:
-
core/vdom/patch.js setScope
如何做到// set scope id attribute for scoped CSS.
?
目前看到了它调用了nodeOps.setStyleScope(vnode.elm, i)
,即vnode.elm.setAttribute(i, ' ')
1 Vue.util
以Vue.util.extend
这个函数为例, 查找顺序为:
-
src/platforms/web/entry-runtime-with-compiler.js
import Vue from './runtime/index'
-
src/platforms/web/runtime/index.js
import Vue from 'core/index'
import Vue from './instance/index'
initGlobalAPI(Vue)
fromimport { initGlobalAPI } from './global-api/index'
Vue.util = { warn, extend, mergeOptions, defineReactive }
from
import { warn, extend, nextTick, mergeOptions, defineReactive } from '../util/index'
export * from 'shared/util'
-
// in shared/util
/**
* Mix properties into target object.
*/
export function extend (to: Object, _from: ?Object): Object {
for (const key in _from) {
to[key] = _from[key]
}
return to
}
可以看出这个 extend 函数只支持2个参数, 这也印证了源码中提到的"不要依赖 Vue 的 util 函数因为不稳定" , 实测:
2 Vue 数据绑定
//调用例子: proxy(vm,`_data`,"message")
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)
}
实现的效果就是访问this.message
实际上通过get
访问到了vm._data.message
,而访问this.message = 'msg'
则是通过set
访问了this.message.set('msg')
即this._data.message = 'msg'
而初始值是在initMixin
的initData
方法中通过
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
来赋予初值
2.2 vm.$mount
入口: initMixin
中结尾的时候
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
取得 el 对应的 dom
el = el && query(el)
//&& 和 || :
//进行布尔值的且和或的运算。当运算到某一个变量就得出最终结果之后,就返回哪个变量。所以上式能返回 dom.
//再举个例子: 1 && undefined === undefined , 1 && {1:2} === {1:2}
为什么 Vue 不允许挂载在html | body
上?
因为它是替换对应的节点,如果 html 或 body 被替换的话整个文档就出错了
template
$options 中有 template 的话
- 优先选用
<template></>
的innerHTML
来作为模板, - 或者选用
template:#id
对应的query('#id')
的innerHTML
, - 最后才是会选用
getOuterHTML(el)
来作为模板
最后,创建好render
函数并挂载到vm
上等待执行.
2.3 vm._render link
Vue 的 _render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。它的定义在 src/core/instance/render.js
文件中
2.3.1 ES6 Proxy link
2.4 Virtual DOM
Virtual DOM 是简化的 DOM 形式的结构, 以下面例子为例
<body>
<div id="app">
<span>{{message}}</span>
</div>
</body>
<script src="./vue.js"></script>
<script>
var app = new Vue({
el: "#app",
data() {
return {
message: 'test'
}
}
})
</script>
断点位置:执行 mountComponent时,vm._update(vm._render(),hydrating)
这里, render
函数会返回如下的 VNODE
(简化版):
{
tag:"div",
children:{
tag:"span",
children:{
tag:undefined,
text:"test"
}
}
}
Vnode 其它属性中, 原 dom 的 attr 存在了 data
中, 还有更多的属性不逐个列举了
data : {
attrs: {data-brackets-id: "149", id: "app", editable: ""}
class: "test"
staticClass: "origin"
staticStyle: {color: "red"}
__proto__: Object
}
提问: 从app
的哪个属性可以访问到vnode
?
答: app.$vnode
不可以, 但是app._vnode
可以.
一般情况下的约定中, 以$
开头的属性名是 Vue 暴露出来的接口(例如this.$store.state
), _
开头的是私有属性不建议访问, 而普通的(例如app.message
)则是从 data 代理过来的数据.
2.4.1 children 规范化(normalize)
_createElement
接收的第 4 个参数 children 是任意类型的,因此我们需要把它们规范成 VNode 类型。
正常的由 template 得到的 VNode 是不需要序列化的, 触发序列化的有如下几种情况:
-
functional component
函数式组件返回的是一个数组而不是一个根节点时, 会调用simpleNormalizeChildren
, 通过Array.prototype.concat
方法把整个 children 数组打平,让它的深度只有一层 -
render 函数
是用户手写时,当 children 由基础类型组成时,Vue会调用normalizeChildren
中的createTextVNode
创建一个文本节点的 VNode; - 当编译 slot、v-for 的时候会产生嵌套数组的情况(测试发现 template中有简单嵌套v-for 的时候并不触发该条规则,可能强制要求手写 render 或 复杂component),会调用
normalizeArrayChildren
方法, 递归 调用自己来处理 Vnode , 同时如果遇到VList
则用nestedIndex
维护一下它的key
学习一下手写 render 函数: link
Vue.component('anchored-heading', {
render: function(h) {
return h('h' + this.level, this.$slots.default )
},
props: {
level: {
type: Number,
required: true
}
}
})
2.5 vm._update(vnode:VNode,hydrating?:boolean)
_update
方法的作用是把 VNode 渲染成真实的 DOM,hydrating
表示是否是服务端渲染 , 它的定义在 src/core/instance/lifecycle.js
中
vm.$el = vm.__patch__(
vm.$el, vnode, hydrating, false /* removeOnly */,
vm.$options._parentElm,
vm.$options._refElm
)
调用__patch__
来xx
//可以看出__patch__是用来用第二个参数去替换第一个参数,
//而第一个参数可以是旧的 Vnode 也可以是原生 DOM
vm.$el = vm.__patch__(prevVnode, vnode)
__patch__
的定义查找顺序,platform/web/runtime/index.js => patch.js => 取出后端的 node-ops.js 中各种方法,传递给 cor/vdom/patch 的 createPatchFunction()
第一次 update 时return function patch
中的关键语句就是:
oldVnode = emptyNodeAt(oldVnode)
{ 该花括号用于指示分析文字的作用域
先创建一个空的根节点 Vnode(只有 tag的那种)
createElm(...)
根据 Vnode 创建实际的 DOM 并插入到原 DOM. 其中递归调用了createChildren
createChildren 我get到的理解
createChildren(vnode, children, insertedVnodeQueue)
function createChildren (vnode, children, insertedVnodeQueue) {
if (Array.isArray(children)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(children)
}
for (let i = 0; i < children.length; ++i) {
createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
}
} else if (isPrimitive(vnode.text)) {
nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
}
}
在这里要注意如果原 template 中一个节点只有1个子节点, 那么该 vnode 的 children 属性将为一个长度为1的 Array, 所以仍会进入第一个 if 分支. 也就是说children 要么为一个长度至少为1的 Array,要么就是 undefined
createChildren
执行完之后 this._vnode.elm 就是构建完成的原生 DOM 了,接下来执行insert(parentElm, vnode.elm, refElm);
来把它插入到合适的位置(此时还未删除原节点,如下图)
} 至此 createElm 终于结束了
// destroy old node
if (isDef(parentElm$1)) {
removeVnodes(parentElm$1, [oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode);
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
return vnode.elm
在__patch__
的最后, 根据之前保存的parentElm$1
(在例子中是 body
)和oldVnode
来移除之前的原生 DOM 节点, 调用invokeInsertHook
(毕竟它插入新节点和删除旧节点都完成了嘛,是时候向上级报告啦!),至此__patch__
全部完成, 返回值用于更新vm.$el
,
接下来做了约10行的收尾工作(这一章不涉及组件的话vm.$vnode
= undefined
也看不出什么来)
至此, _update
函数全部完成!
2.6 第二章数据绑定总结:
3 组件化
3.1 createComponent
这一小节主要讲了把一个组件构建为 vnode 的过程
测试时使用的例子:
<body>
<div id="app" class="origin" :class="message" style="color:red" editable ref="a1">
<span>{{message}}</span>
<cc></cc>
</div>
</body>
<script src="./vue.js"></script>
<script>
Vue.component('cc',{
template:'<strong>I am component</strong>'
})
var app = new Vue({
el: "#app",
data() {
return {
message: 'test'
}
}
})
</script>
小细节: 组件在创建 Vnode 时, children, text, elm
为空,但componentOptions
属性包含了所需要的内容
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
最终我们的例子的组件会返回如下 vnode(不写的属性默认为 undefined):
vnode:{
tag: "vue-component-1-cc",
test: undefined,
children: undefined,
data: {
attrs: { },
hook: { destroy, init, insert ,prepatch, on }
},
context: Vue,
componentOptions: {
Ctor:{
extendOptions: { name:"cc",template:"<strong>I am component</strong>"},
options:{ components, _Ctor, _base, name:"cc",template:"<strong>I am component</strong>"}
},
tag: "cc"
}
}
3.1 patch - 从组件 vnode 构建组件 DOM
了解 patch 的整体流程和插入顺序
- activeInstance
- $vnode
- _vnode
-
patch
的整体流程:createComponent
=>子组件初始化
=>子组件 render
=>子组件 patch
-
activeInstance
为当前激活的 vm 实例;vm.$vnode
为组件的占位符 vnode
;vm._vnode
为组件的渲染 vnode
3.2 mergeOptions 合并配置
入口core/instance/index.js
其中//...
是暂时略去无关代码的意思
function Vue (options) {
//...
this._init(options)
}
接着来到core/instance/init.js
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
//...
// merge options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
//...
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
执行new Vue(options)
时resolveConstructorOptions
返回的就是大Vue
本身, 接着继续看mergeOptions
(在src/core/util/options.js
里)
/**
* Merge two option objects into a new one.
* Core utility used in both instantiation and inheritance.
*/
export function mergeOptions (
parent: Object,
child: Object,
vm?: Component
): Object {
//...
const options = {}
let key
for (key in parent) {
mergeField(key)
}
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key)
}
}
function mergeField (key) {
const strat = strats[key] || defaultStrat
options[key] = strat(parent[key], child[key], vm, key)
}
return options
}
3.2.1 默认策略
const defaultStrat = function (parentVal: any, childVal: any): any {
return childVal === undefined ? parentVal : childVal
}
3.2.2 strats.el
使用默认策略(要求 vm 必须实例化)
3.3.3 strats.data
若 vm 为空则要求传入的 data必须是个函数, 然后返回mergeDataOrFn
. 简言之, 就是尝试去获取 ___Val.call(vm,vm)
或 Val 本身(取决于它是不是函数)来得到数据, 然后调用一个无依赖的函数mergeData
进行深拷贝形式的 merge
export function mergeDataOrFn (
parentVal: any,
childVal: any,
vm?: Component
): ?Function {
if (!vm) {
//...
} else {
return function mergedInstanceDataFn () {
// instance merge
const instanceData = typeof childVal === 'function'
? childVal.call(vm, vm)
: childVal
const defaultData = typeof parentVal === 'function'
? parentVal.call(vm, vm)
: parentVal
if (instanceData) {
return mergeData(instanceData, defaultData)
} else {
return defaultData
}
}
}
}
3.3.4 props methods inject computed
先断言 childVal 是 Object , 然后使用简单的 extend函数将二者合并
strats.props =
strats.methods =
strats.inject =
strats.computed = function (
parentVal: ?Object,
childVal: ?Object,
vm?: Component,
key: string
): ?Object {
if (childVal && process.env.NODE_ENV !== 'production') {
assertObjectType(key, childVal, vm)
}
if (!parentVal) return childVal
const ret = Object.create(null)
extend(ret, parentVal)
if (childVal) extend(ret, childVal)
return ret
}
3.3.5 生命周期钩子, 例如 created
主要有这些钩子:
export const LIFECYCLE_HOOKS = [
'beforeCreate',
'created',
'beforeMount',
'mounted',
'beforeUpdate',
'updated',
'beforeDestroy',
'destroyed',
'activated',
'deactivated',
'errorCaptured'
]
生命周期钩子的合并策略 strats[hook]都被赋值为 mergeHook, 具体过程是把不同的 created 函数串成一串(即存入一个 array 中), 形式是[created1,created2 ,... ]
function mergeHook (
parentVal: ?Array<Function>,
childVal: ?Function | ?Array<Function>
): ?Array<Function> {
return childVal
? parentVal
? parentVal.concat(childVal)
: Array.isArray(childVal)
? childVal
: [childVal]
: parentVal
}
LIFECYCLE_HOOKS.forEach(hook => {
strats[hook] = mergeHook
})
3.3.6 ASSET , 例如components
在这里, parent 的KeepAlive Transition TransitionGroup
被传入的 child 的 components 所替代
export const ASSET_TYPES = [
'component',
'directive',
'filter'
]
function mergeAssets (
parentVal: ?Object,
childVal: ?Object,
vm?: Component,
key: string
): Object {
const res = Object.create(parentVal || null)
if (childVal) {
process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)
return extend(res, childVal)
} else {
return res
}
}
ASSET_TYPES.forEach(function (type) {
strats[type + 's'] = mergeAssets
})
3.4 生命周期
callHook
调用不同钩子时, 我们的 vm 对象都有哪些参数呢?
3.4.1 beforeCreated
这个时候已经执行了Init Events & Lifecycle & render
, 可以看到 vm 对象的$options
已经执行完毕合并配置. 但这时 $data
为空
3.4.1.1 未执行任意一个 init 时
可以看到已经执行过了mergeOption
合并配置
3.4.1.2 执行完initLifecycle
多了$children, $parent, $refs, $root
等
3.4.1.3 执行完initEvents
多了_events , _hasHookEvent
3.4.1.4 执行完initRender
多了_vnode, _staticTrees, $slots, $scopedSlots, $_c, $createElement
, 还未真的执行渲染. 至此, vm 这个对象已经初始化完成, 调用beforeCreated
的 hook.
3.4.2 created
执行了
-
initInjections
, 暂不明 -
initState
把 props data methods computed watch 挂载上了,可以访问 this.message 了 , 但此时不能访问 this.el -
initProvide
, 暂不明
3.4.3 beforeMount
入口是vm.$mount(vm.$options.el)
可以看到此时 el 进入了我们的视野,可以访问 vm.el 是一个原生HTMLElement
根据 el 来生成 render 函数
在控制台中看不到 vm._render, 但是可以执行 vm._render(), 应该和 vm._renderProxy 有关
beforeMount 钩子的执行顺序先父后子
3.4.4 mounted
子组件的 mounted 优先于父组件.
vm._update(vm._render(), ...)
先执行_render(), 再把它更新到 DOM 上. 如果检测父 vnode(vm.$vnode
)为空,说明自己就是 root , 则可以调用 mount 的 hook.
后续进入等待状态, 等待数据更新带来的beforeUpdate
/ update
3.4.5 beforeDestroy
destroy
前者先父后子, 后者先子后父, 同 mount 类似
3.4.6 总结
在created
钩子中可以访问到数据, 在mounted
钩子中可以访问到 DOM, 在destroyed
钩子中可以做一些定时器的销毁工作.
3.5 组件注册
3.5.1 全局注册
推荐用-
分割组件名
全局注册的行为必须在根 Vue 实例 (通过 new Vue) 创建之前发生
Vue.component('component-a', { /* ... */ })
Vue.component('component-b', { /* ... */ })
Vue.component('component-c', { /* ... */ })
new Vue({ el: '#app' })
//index.html
<div id="app">
<component-a></component-a>
<component-b></component-b>
<component-c></component-c>
</div>
全局注册的组件在各自内部也都可以相互使用
3.5.2 局部注册
全局注册往往是不够理想的。比如,如果你使用一个像 webpack 这样的构建系统,全局注册所有的组件意味着即便你已经不再使用一个组件了,它仍然会被包含在你最终的构建结果中。这造成了用户下载的 JavaScript 的无谓的增加
局部注册的方法有:
1.js 形式
new Vue({
el: '#app',
components: {
'component-a': ComponentA,
'component-b': ComponentB
}
})
- 模块系统中
import ComponentA from './ComponentA'
export default {
components: {
ComponentA,
},
// ...
}
而特别常用的局部组件应该做全局化处理, 参考官方文档
3.5.2 全局注册的源码
src/core/global-api/assets.js
export function initAssetRegisters (Vue: GlobalAPI) {
ASSET_TYPES.forEach(type => {
Vue[type] = function (
id: string,
definition: Function | Object
): Function | Object | void {
if (!definition) {
return this.options[type + 's'][id]
} else {
//... 组件名校验 √
if (type === 'component' && isPlainObject(definition)) {
definition.name = definition.name || id
definition = this.options._base.extend(definition)
}
if (type === 'directive' && typeof definition === 'function') {
definition = { bind: definition, update: definition }
}
this.options[type + 's'][id] = definition
return definition
}
}
})
}
核心this.options[type + 's'][id] = this.options._base.extend(definition)
即扩展了 this.$options.components.cc ( cc 只是一个组件名 )
提问: 为什么这样就可以用了呢?在模板中遇到<cc></cc>会如何解析呢?
答: 原来在_createElement
的时候会对 tagName 进行判断, 如果是原生 tagName 就创建原生DOM 对应的 vnode , 否则执行vnode = createComponent(...)
过程细节:
-
resolveAssets
拼了老命地尝试去查找组件名, 先找分割线,不行就驼峰, 再不行就首字母大写试试, 还不行就去prototype 中找(这里面有KeepAlive,Transition,TransitionGroup
), 实在找不到就返回 undefined( 进而在下面判断 Array.isArray(vnode)的时候进入分支createEmptyVNode()
-
createComponent
之前的注册相当于只是记录了 component 的信息, 到这一步才是真的创建, 在这一步里, 会new VNode , 然后处理好它的data, elm, tag
等等, 同时还对 slot 做了处理
提问: vm.$options.components
什么时候拿到的呢?
答: 在合并配置时, mergeField 时, 就把 Vue['components'] 和传入参数 merge 在一起赋值给 vm.$options 了
3.5.4 局部注册的源码
过程同全局注册类似, mergeOption挂到 vm.$options.components
, 这样 resolveAssets 就可以拿到了.
要注意此时并没有注册到 Vue.components 对象上
3.5.5 异步组件
还没看,暂时略过
4 深入响应式原理
4.0 什么时候收集依赖和清空依赖
收集依赖:
执行数据的 getter 时会收集依赖, 一般为[初次渲染 DOM
, 执行计算属性的 getter
] 等情况, 前者将 RederWatcher 添加入数据的__ob__
的deps[]
中. 后者不仅将用户 computed watcher 添加入数据的deps[]
中, 还通过computed watcher.depend()
来将 RenderWatcher 添加入数据的deps[]
中
清空依赖 watcher.cleanupDeps()
- RenderWatcher 执行完自己的 getter(内部执行
_update(_render())
, 返回销毁函数), 会执行一次清空依赖 - 计算属性在执行完 getter 以及
popTarget()
后, 会执行一次清空依赖.- 清空前可能是
name
属性依赖[ useless, firstName, lastName ]
, 此时存放在name watcher
的newDeps
和newDepIds
中, 同时三个 data 的__ob__.deps : []
中也存储了name watcher
- 清空的过程就是查找
watcher.dep[]
(oldDep)中有但是newDep[]
中没有的对象, 即可以想象为
- 清空前可能是
name 对 firstName 说: "以前我依赖你, 但我重新审视了一下自身, 我现在已经不依赖你了, 我们断绝关系吧!"
/**
* Clean up for dependency collection.
*/
Watcher.prototype.cleanupDeps = function cleanupDeps () {
var this$1 = this;
var i = this.deps.length;
while (i--) {
var dep = this$1.deps[i];
if (!this$1.newDepIds.has(dep.id)) {
dep.removeSub(this$1);
}
}
var tmp = this.depIds;
this.depIds = this.newDepIds;
this.newDepIds = tmp;
this.newDepIds.clear();
tmp = this.deps;
this.deps = this.newDeps;
this.newDeps = tmp;
this.newDeps.length = 0;
};
4.1 Vue.set 为什么不允许设置根 data ?
例如 Vue.set(app,'msg','value')
, 这样app.msg
是拿不到app.$data.msg
的, 缺少了一层代理(在defineReactive
函数中没有为它增加到$data
的代理). 而如果是Vue.set(app.msg,'msg2','value')
, 则是可以通过代理拿到app.$data.msg.msg2
的
简单来说, Vue.set(app,'msg','value')
实际上就是执行defineReactive(app,'msg','value')
, 而这个函数内部是没有写proxy
的
如果重新调整代码结构, 把 proxy 放入 defineReactive 函数中中执行, 那就 ok . 不过干脆期待 vue3.0的 es6 proxy 代理比较好, 比 Object.defineProperty 的功能更强大.
4.2 用户手动添加 watcher 导致 update loop 时的循环流程
和 watcher 相关的全局变量有has = { } , waiting = false, flushing = false, index //queue
watcher的 queue 加入顺序, 实例流程解析如下
var app = new Vue({
data : { msg : 1, count: 0 },
watch : { msg(){ this.count++<100 && this.msg = Math.random() }
})
1. 首次渲染后, 第一次改变 msg 的数值时, watcher 的队列内容如下
queue = [ { id:1, expression:"msg"}, { id:2, expression:" ... vm._update(vm._render())" } ]
has = { 1: true , 2: true }
2. 接着, 若此时 waiting 为 false, 则置 waiting 为 true, 置 flushing 为 true,注册nextTick(flushSchedulerQueue)
3. 执行flushSchedulerQueue时, 首先取出 queue[0], 设置 has[1] = null , 执行该 watcher 的 .run 函数时,
发现处理好新旧 value 后, 在调用 callback(cb)的过程中, 碰到了用户代码的 this.msg = Math.random() 语句,
则再次触发一轮新的 代理 setter 过程
4. 在新的一轮代理 setter 过程中, 订阅者仍然是["msg", "... vm._update(vm._render())"]两人, 此时
has = [ 1: null, 2: true ], 所以前者可以以插队形式(正好插在{id:2}的前面)加入 queue, 后者的插队被拒绝(因为 has[2] 为 true).
循环若干次后, queue 的状态会变为:
queue = [
{ id:1, expression : "msg" },
{ id:1, expression : "msg" },
{ id:1, expression : "msg" },
...
{ id:2, expression : "... vm._update(vm._render())" },
]
5.最终, 达到100次后(若超过100次则会抛出"infinite update loop"异常), 不再向 queue 中添加新的内容,
index 终于可以如愿执行至{ id:2 }的 watcher, DOM 被更新
总结: 可以看到, 得益于nextTick 的异步机制, msg 这个数据执行了100次(数量取决于用户代码)setter
后才刷新一次 DOM, 性能表现很好.
4.3 this.$nextTick( ) 异步更新
Vue 内部检测到数据变化后会将 watcher 添加入 queue, 而 DOM 刷新是放在了nextTick(flushSchedulerQueue)
中, 也就是说 DOM 的更新是个异步过程, 同时用户自定义 watch 函数
也是异步的, 作为验证, 可以测试如下代码, watch 内的函数只执行了一次
var app = new Vue({
// ...
data : { msg : 1 },
methods : {
change(){
[2,3,4,5].forEach(x => this.msg = x)
}
},
watch: {
msg() {
console.log(arguments) //该函数只会触发一次
}
}
}
如果要获取修改后的 DOM, 可以调用 this.$nextTick
或Vue.nextTick
, 二者完全一致
-
this.$nextTick(fn)
会将 fn 加入 callbacks 队列 -
this.$nextTick()
会生成一个状态为resolved
的Promise 对象
, 并将该存储于函数闭包中的_resolve
加入 callbacks 队列 - 若闭包中的变量
pending
为 false, nextTick 函数会执行macroTimerFunc()
或microTimerFunc()
来异步执行 flushCallbacks.-
macroTimerFunc
实质上等于messageChannel
触发onmessage
事件,该事件的回调是flushCallbacks -
microTimerFunc
实质上等于()=>Promise.resolve().then(flushCallbacks)
-
var app = new Vue({
// ...
data : { msg : 1 },
methods : {
async change(){
this.msg = 2
console.log('sync:', this.$refs.msg.innerText) // sync: 1
/* 下面三种写法效果一致, 都会输出 2 */
this.$nextTick(()=>{
console.log('nextTick:', this.$refs.msg.innerText)
})
this.$nextTick().then(()=>{
console.log('nextTick with promise:', this.$refs.msg.innerText)
})
await this.$nextTick()
console.log('sync:', this.$refs.msg.innerText)
}
},
有所区别的是, 下面的代码会输出1
async change(){
this.msg = 2
Promise.resolve().then(()=>console.log('promise:', this.$refs.msg.innerText)) //1
}
因为该函数里的 Promise 是在这一轮 event-loop 的末尾执行的, 而 nextTick 的回调是在下一轮event-loop 的开头执行的
关于这点, 单步调试可发现 nextTick 中在
macro
和micro
二选一时选择了macro
更新: vue-2.6中作者权衡利弊后又把 nextTick 全部改为了 microTask, 参见2.6 Internal Change: Reverting nextTick to Always Use Microtask
4.4 Vue 如何实现 computed 计算属性 ?
示例代码:
var app= new Vue({
//...
computed:{
name(){
return this.useless > 0? this.firstName+ ', ' +this.lastName : 'please click change'
}
},
})
4.4.1 计算属性注册:
- 在
initState
函数中发现$options
中有 computed 属性, 则调用 initComputed 函数 - 在 vm 上挂载
vm._computedWatchers
属性,初始化为{ }
, 该属性是为计算属性专属, 如果用户options
中没有计算属性, 则它不会出现 - 遍历
computed
, 例如本例中只会遍历一次name
-
获取
computed[name]
对应的 getter:
var getter = typeof userDef === 'function' ? userDef : userDef.get;
-
新建一个 watcher
watchers[name] = new Watcher(vm, getter, noop, { lazy :true });
注意该 watcher 的lazy
属性和dirty
属性都为 true, 这里做了一个缓存机制 Object.defineProperty(vm, key /* name */, sharedPropertyDefinition);
其中sharedPropertyDefinition
的 getter 是由 createComputedGetter 函数来生成的, 放到下面取值的过程讲. setter 我们暂时忽略
4.4.2 计算属性的 getter
function createComputedGetter (key) {
return function computedGetter () {
var watcher = this._computedWatchers && this._computedWatchers[key];
if (watcher) {
if (watcher.dirty) {
watcher.evaluate();
}
if (Dep.target) {
watcher.depend();
}
return watcher.value
}
}
}
当渲染 DOM 需要用到name
时, 检测dirty
标志, 判断该计算属性是否被修改过,
- 若无, 则返回之前的缓存值
watcher.value
- 若有, 则置
dirty
为 false , 执行watcher.evaluate()
获取新的值- 在该函数中, 会调用
this.get()
, 而此处会判断数值是否变动来决定下一步操作, 例如'firstName' + 'lastName' === 'firstNam'+'elastName'
, 此时两个响应式属性的变动会将 name 的 watcher 添加到队列中并执行, 但 name的 watcher 执行了自己的this.get()
后发现自己没变化, 就不需要把渲染 watcher 再添加到队列了.
- 在该函数中, 会调用
- 在渲染 watcher 的上下文环境中要做依赖收集
//若是控制台无聊输出 vm.name 则不需要
接下来看看this.firstName
的变动是如何让name
的dirty
变化的fase => true
4.4.3 data 变动引起 computed 值变动的过程
- 首先,
this.useless
会变为true
, 会 dep.notify( )name
watcher 和DOM
watcher
- update
name
watcher 的过程就是设置它的this.dirty
为 true - 将 DOM 渲染 watcher 放入队列
提问: 为什么 useless 会同时拥有两个 watcher?为什么不是 useless 通知 name 更新, name 再通知 dom 更新?
答: 因为代码中有这样一部分, 计算属性取值时watcher.evaluate()
后, 又执行了watcher.depend()
, 该方法中会执行this.deps[i].depend()
, 于是就把 dom 渲染 watcher 也给 useless 和 firstName 等每人依赖了一份. 相对的, 在数据变动而 notify 它的 watcher 更新时, 不会把这两个 watcher 都放入队列, 而是只把计算书行的 dirty 设置为 true, 把 dom渲染 watcher 放入队列.
- nextTick 清空 callBacks 队列 => 清空 flushSchedulerQueue 队列
在这个过程中, dom 渲染 watcher.run()时, 会重新收集依赖.
4.4.4 如果 data 变了但是 computed 不变会怎么办?
关于这件事的详细讨论可以参考
- github 博客 深入浅出 - vue变化侦测原理
vue-2.5.17-beta0
中引入的PR 尤大写的 PR 事实上该 beta 版本未被合入2.5.17正式版中
考虑如下的代码
示例代码1
computed:{
name() { return this.a + this.b }
},
methods:{
change() { this.a++; this.b-- }
}
示例代码2
computed:{
name() { return this.a > 0 ? this.b : 0 }
},
methods:{
change() { this.a++ }
}
在2.5.17~2.6.10
的版本中
会在同步代码执行完毕后, 判断新旧 vnode 相同的部分来达到不重绘dom 的目的, 但是生成新的 vnode 时还是用了不少时间.
在2.5.17-beta.0
的版本中
作者尝试使用watcher.getAndInvoke
函数来实现计算属性不变则不重绘
的目的, 结果:
- 对
示例代码2
表现效果非常好 - 但对
示例代码1
会发现事与愿违, 由于event-loop
的关系, 上述代码反而会让name()
发现自己被改变了2次, 进而触发两次创建新 vnode
, 进而触发2次重绘 dom.
那么理想的解决办法是什么呢?
理想的执行顺序为:
change()改变 data
(同步) => name()求值
(异步) => 根据需要重绘 dom
目前在2.5.17
版本中, 重绘 dom
的异步是在 macrotask(messageChannel)中实现的
而在^2.6.10
版本中, 重绘 dom
的异步全部使用 microTask(Promise)
那么, 如果想要让 computed 的求值异步任务放在重绘 DOM 之前, 就要构造一个优先级比 Promise 更高的 microtask. 我很期待 Vue 3.0 给我们带来的改变!
5. 编译
关于简单的 HTMLParser 请移步我的另一篇文章 HTMLParser 的实现和使用, 下面主要纪录 Vue 的start end
等钩子函数中做了什么.
5.1 AST Node 的分类
- type:1 普通 tag , 例如
{ type:1, tag:'div ,attrs}
- type:2 模板语法字符串, 例如
{ type:2, text:'{{msg}}', expression:'_s(msg)', tokens }
- type:3 纯文本字符串, 例如
{ type:3, text:'hello world' }
- type:3 注释字符串,
{ type:3, text, isComment:true }
5.2 钩子函数
5.2.1 comment
function comment (text: string) {
currentParent.children.push({
type: 3,
text,
isComment: true
})//纯文本注释
}
5.2.2 chars
function chars (text: string) {
const children = currentParent.children
//对于一般</ul>闭合前的若干空格, text.trim()会变成长度为0的"",此时一般保留1个空格
//我也不知道为什么,明明下面 end()中又把这个空格 pop 掉了
text = text.trim() ? isTextTag(currentParent) ? text : decodeHTMLCached(text)
// only preserve whitespace if its not right after a starting tag
: preserveWhitespace && children.length ? ' ' : ''
if (text) {
let res
if (text !== ' ' && (res = parseText(text, delimiters))) {
children.push({
type: 2,
expression: res.expression,
tokens: res.tokens,
text
})//模板字符串,由 "{{msg}}" 转为 "_s(msg)"
} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
children.push({
type: 3,
text
})//纯文本节点
}
}
}
5.2.3 start
function start(tag, attrs, unary) {
/* @type{ type:1, tag, parent, children, attrsList, attrsMap } */
let element = createASTElement(tag, attrs, currentParent)
// apply pre-transforms 如果 tag 为 input 的话处理 v-model 相关
for (let i = 0; i < preTransforms.length; i++) {
element = preTransforms[i](element, options) || element
}
if (!element.processed) {
processFor(element) /* 处理 v-for, 例如对 v-for="(item,index) in data"处理为
Object.assign(element,{ alias:"item", for:"data", iterator1:"index" }) */
processIf(element) /* 处理 v-if, 例如对 v-if="isShow" 处理为 element.if="isShow",
同时设置 elment.ifConditions = [{ exp:"isShow", block:element }] */
/* 另外,遇到attrs 含有 v-else 节点时,标记 { else:true }, 然后在下面 processIfConditions 处理*/
processOnce(element) //处理 v-once
processElement(element, options)/* 处理 ref slot component,
* transform[0] : 处理 staticClass, classBinding
* transform[1] : 处理 staticStyle, styleBinding
*/
}
// tree management
if (!root) {
root = element
}
if (currentParent) {
if (element.elseif || element.else) {
processIfConditions(element, currentParent) /* 对 v-else 节点vel,在当前父亲下寻找前面的 v-if 节点vif,并设置
vif.ifConditions.push({
exp:vel.elseif,
block:vel
}) */
} else if (element.slotScope) { // scoped slot
currentParent.plain = false
const name = element.slotTarget || '"default"'
; (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
} else {
currentParent.children.push(element)
element.parent = currentParent
}
}
if (!unary) {
currentParent = element
stack.push(element)
} else {
closeElement(element)
}
}
5.2.4 end
function end() {
//去除尾部空白字符,例如
/* <ul>
<li></li>
</ul> //此时 ul 会有2个 child,一个是 li,一个是 li 后面的空格,所以要去除空格
*/
var element = stack[stack.length - 1];
var lastNode = element.children[element.children.length - 1];
if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
element.children.pop();
}
// pop stack
stack.length -= 1;
currentParent = stack[stack.length - 1];
}
5.3 optimize 优化
- optimize 的目标是通过标记静态根的方式, 优化重新渲染过程中对静态节点的处理逻辑
- optimize 的过程就是深度遍历这个 AST 树,先标记静态节点, 在标记静态根
- 静态节点: 例如
<p>123</p>
, 即子节点都要是静态节点, 且自己能通过isStatic
- 静态根:
node.type
必须为1 且node.static==1
且node.children
必须有>=1个非纯文本孩子, 称之为静态根-
<div><p>111</p></div>
是静态根 -
<ul><li>1</li><li>2</li><li>3</li>
是静态根 -
<li>1</li>
不是静态根, 虽然它是静态节点
-
单个静态节点的判定:
function isStatic (node: ASTNode): boolean {
if (node.type === 2) { // expression
return false
}
if (node.type === 3) { // text
return true
}
return !!(node.pre || (
!node.hasBindings && // no dynamic bindings
!node.if && !node.for && // not v-if or v-for or v-else
!isBuiltInTag(node.tag) && // not a built-in
isPlatformReservedTag(node.tag) && // not a component
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey)
))
}
5.4 codegen 代码生成
5.4.1 codegen 的输入和输出
下面的示例代码
<ul :class="bindCls" class="list" v-if="isShow">
<li v-for="(item,index) in data" @click="clickItem(index)">{{item}}:{{index}}</li>
</ul>
会被编译成如下渲染函数
function anonymous(
) {
with(this){
return (isShow) ?
_c('ul', {
staticClass: "list",
class: bindCls
},
_l((data), function(item, index) {
return _c('li', {
on: {
"click": function($event) {
clickItem(index)
}
}
},
[_v(_s(item) + ":" + _s(index))])
})
) : _e()
}
}
可以在渲染函数-模板编译测试一下
其中用到的_c
这些下划线函数可以在src/core/instance/render-helpers/index.js
中找到
export function installRenderHelpers (target: any) {
target._o = markOnce
target._n = toNumber
target._s = toString
target._l = renderList
target._t = renderSlot
target._q = looseEqual /* Check if two values are loosely equal - that is,
if they are plain objects, do they have the same shape? */
target._i = looseIndexOf // 判断是否相等时使用上面的函数的数组 indexOf
target._m = renderStatic
target._f = resolveFilter
target._k = checkKeyCodes
target._b = bindObjectProps
target._v = createTextVNode
target._e = createEmptyVNode
target._u = resolveScopedSlots
target._g = bindObjectListeners
}
5.4.2 我自己尝试编写的简单的 codegen 逻辑
其中 js 字符串排版使用了beautify.js
其中输入的 ast 是optimize 优化过的 ast 结构
function generate(node) {
var res = "function anonymous(){with(this){return "
res += gen(node);
return res + "}}"
function gen(el) {
if (el.type == 1) {
debugger
if (el.for && !el.forProcessed) {
el.forProcessed = true
return genFor(el)
} else if (el.if && !el.ifProcessed) {
el.ifProcessed = true
return genIf(el)
} else {
var data = el.plain ? undefined : JSON.stringify(el.attrsMap)
var children = ""
if (el.children.length === 1 && el.children[0].for) {
children = gen(el.children[0])
}
else {
children = '[' + el.children.map(x => gen(x)).join(',') + ']'
}
code = `_c('${el.tag}'${data ? ("," + data) : ''}${children ? ("," + children) : ''})`;
return code
}
} else if (el.type == 2) {
return `_v(${el.expression})`
} else if (el.type == 3 && el.text.trim()) {
return `_v(${el.text})`
} else {
return ''
}
function genIf(el) {
return (function genIfConditions(conditions) {
var leftCondition = conditions.shift()
if (leftCondition && leftCondition.exp) {
return '(' + leftCondition.exp + ')?' + gen(leftCondition.block) + ':' + genIfConditions(conditions)
}
else {
return "_e()"
}
})(el.ifConditions.slice())
}
function genFor(el) {
return `_l((${el.for}),function(${el.alias},${el.iterator1}){ return ${gen(el, false)}})`
}
}
}
整体思路比较简单, 对于文本节点使用_v
,对于if
和for
做了特殊的处理,下面看一下对同一段 DOM 的测试结果:
//测试
js_beautify(generate(ast),{ indent_size: 2, space_in_empty_paren: true })
//测试结果
function anonymous() {
with(this) {
return (isShow) ? _c('ul', {
":class": "bindCls",
"class": "list",
"v-if": "isShow"
}, _l((data), function(item, index) {
return _c('li', {
"v-for": "(item,index) in data",
"@click": "clickItem(index)"
}, [_v(_s(item) + ":" + _s(index))])
})) : _e()
}
}
可以看到, 我写的简单 generate 和 vue 的, 对同一段简单 DOM 生成的 render 函数基本一致, 所以原理基本搞清楚了, 但有些细微差别:
我没处理
{ on: { "click": function($event) { clickItem(index) } }
这种结构我没处理
node.staticRoot
这些静态根节点我没处理组件
-
对于
v-for
和v-if
我的处理和 vue 一致, 其中包括:-
v-for
且 children 数组长度为1时, 不生成[ gen(el) ]
而是直接生成gen(el)
, 即将此种孩子提升了一级. 实际上_c
是能接收_l
产生的结果的 -
v-if
中良好的处理了v-if v-elseif v-elseif v-else
这种多级结构
-
5.4.3 Vue对 staticRoot 节点的处理
Vue 会将这类 node 渲染为一个新的function anonymous(){ with(this) //... }
, 并 push 入staticRenderFns
中, 然后它的 codegen 就是返回该函数的序号, 例如_m(0)
.
此外, Vue 还以简单的 cached[template]
的形式对模板生成的 render 函数和staticRenderFns 进行了缓存
6 扩展
6.1 Vue 和 React 事件绑定的this 对比
="handler" | ="handler()" | ="()=>handler()" | |
---|---|---|---|
Vue | 编译为with(this){ .... handler() } 成功绑定回调和this |
编译为with(this){ .... function($event){ handler() } 成功绑定回调和 this |
with(this){ .... 'click':()=>handler() } 在Render时绑定this到箭头函数 |
React | 能触发事件,但是直接执行handler() 未绑定 this |
在 Render 时会触发一次 handler(), 然后将 handler()的返回值传入addEventListener, 可能为 undefined 而导致事件没有回调 | 成功绑定回调, 在 Render 时绑定 this 到箭头函数 |
6.2 为什么@click="alert(1)" 或者 @click="console.log(1)"会报错
这个问题出现在 vue2.5.17-2.6.10 的非 production 版本上, 如果使用 production 版本则问题消失. 检查源码可以发现在开发版的 vue 生命周期中有一个
initProxy
函数, 为 vm 挂载了vm._renderProxy
属性, 此时, 在执行 render 函数访问其中属性时, 会优先访问代理属性. 即, 访问console.log(1)
, 会优先访问this.console
vue 的 render 函数大概长这样:
function anonymous() {
with (this) {
return _c('div', [_c('p', {
on: {
"click": function($event) {
return console.log(1)
}
}
})])
}
}
本来, 在 with(this) 的函数中, 访问 this.console 如果是 undefined, 会再次访问上级作用域来寻找 console 值. 但是 开发版的 vue 做了代理,
const hasHandler = {
has (target, key) {
const has = key in target
const isAllowed = allowedGlobals(key) ||
(typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data))
if (!has && !isAllowed) {
if (key in target.$data) warnReservedPrefix(target, key)
else warnNonPresent(target, key)
}
return has || !isAllowed
}
}
这个代理导致系统在判断 vm.console 是否存在时, has
值为false
, isAllowed
为false
, 因此系统觉得 vm.console 存在, 就不去访问 window 了, 于是出错
总之, 还是不推荐在@click=""
里直接写alert console window
这些全局作用域里有的东西, 只写 vm 的作用域里有的东西比较好. 或者把alert console window
转移至methods
中去
6.3 语法糖 v-model
对普通元素的 v-model 有下面的等价关系
<input v-model="message">
<input v-bind:value="message"
v-on:input="if($event.target.composing) return; message=$event.target.value">
对组件的 v-model, 语法糖等价关系变为了
//子组件
let Child = {
template: '<div>'
+ '<input :value="value" @input="updateValue" placeholder="edit me">' +
'</div>',
props: ['value'],
methods: {
updateValue(e) {
this.$emit('input', e.target.value)
}
}
}
// 父组件 v-model 写法
let vm = new Vue({
el: '#app',
template: '<div>' +
'<child v-model="message"></child>' +
'<p>Message is: {{ message }}</p>' +
'</div>',
data() {
return {
message: ''
}
},
components: {
Child
}
})
//父组件语法糖写法
let vm = new Vue({
el: '#app',
template: '<div>' +
'<child :value="message" @input="message=arguments[0]"></child>' +
'<p>Message is: {{ message }}</p>' +
'</div>',
data() {
return {
message: ''
}
},
components: {
Child
}
})