Lucas Liao's Blog

Vue 2.x数据驱动源码分析

前言

最近看源码看得有点上头,趁着周末有空就记录下。之前和同事谈论过,他们对源码都不以为意,停留在一种框架停留在能用就行了,看看官方文档,能解决覆盖80%,甚至更多的业务场景了。对于我来说,使用vue已经很长一段时间了,如何对内部实现还是不清楚的话,我自己本身也说不过去的,关键是有时候遇到一些问题,只有理解内部是如何进行的,才能很好地找到合理的解决方案,而不是老是黑人问号“为什么会这样?”

概述

众所周知,vue内部遍历data对象,对数据进行每个劫持,重新定义了get, set方法。set方法很好理解,就是改变值时候触发,那get方法是什么时候触发的呢?

引用官方文档的概述,如下:

当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在属性被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装 vue-devtools 来获取对检查数据更加友好的用户界面。
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

mouted

简单分析一波源码~~那就从入口说起吧。

现在vue2.x版本一般是通过官方脚手架进行搭建,在src/main.js目录下,会看到这样的代码:

1
2
3
4
5
6
new Vue({
router: router,
store,
i18n,
render: h => h(App)
}).$mount('#app');
1
2
3
4
5
6
7
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}

至于传入的参数是怎样处理的,我们暂时放到一边,一目了然,new了一个vue实例,并且调用了$mount方法。

new Vue()的时候,调用this._init方法,其中这里对data对象中所有属性利用Object.defineProperty, 进行geter/seter处理。

vue对web/weex不同平台运行环境进行了差异化处理,以web平台分析,我们看到$mount的赋值如下,所有在入口其实是调用了mountComponent()方法,我们继续深入探究mountComponent()内部是怎样实现的。

进入mountComponent,将组件的element元素赋值给$el,触发了beforeMount生命周期,创建了一个Watch实例,最后触发了mounted生命周期,返回vm component对象,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
}

callHook(vm, 'beforeMount')

let updateComponent
updateComponent = () => {
vm._update(vm._render(), hydrating)
}

new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false

if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}

watcher

我们把焦点放到new Watcher,这段代码发生了什么?转到Watcher类的定义,首先在constructor将watcher的实例的this,push到vm._watchers数组里面,然后对一序列的参数进行赋值初始化,最后调用了里面的this.get()方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
export default class Watcher {
vm: Component;
cb: Function;
id: number;
deep: boolean;
user: boolean;
lazy: boolean;
sync: boolean;
dirty: boolean;
active: boolean;
deps: Array<Dep>;
newDeps: Array<Dep>;
depIds: SimpleSet;
newDepIds: SimpleSet;
before: ?Function;
getter: Function;
value: any;
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
vm._watchers.push(this)
// options
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync
this.before = options.before
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
? expOrFn.toString()
: ''
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
this.value = this.lazy
? undefined
: this.get()
}

依赖收集

在get()处理如下逻辑,先喵一下主流程代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.
Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}

export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}

pushTarget(this)

将当前watcher放到targetStack队列, 同一时间只有一个watcher处于收集状态

this.getter.call(vm, vm)

回顾mountComponent()方法的传参,其实watchergetter方法就是调用:

1
2
3
4
let updateComponent
updateComponent = () => {
vm._update(vm._render(), hydrating)
}

_update的核心是触发__patch__,根据虚拟vnode,生成真实的Dom元素, 下面是_update的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// 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.
}

如何进行依赖收集呢

回到get/seter的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})

我们发现在get里面,触发了dep.depend():

1
2
3
4
5
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}

上面说到,在实例化watcher的时候,pushTarget(this),即 target => watcher, 因此在get的时候,就是触发了watcher里面的addDep, 同一个watcher通过newDepIds维护,根据id避免
收集重复的依赖~~

1
2
3
4
5
6
7
8
9
10
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}

在vnode转化成真实Dom元素的时候,会触发数据绑定中的get, 进行依赖收集,收集完成调用popTarget(),this.cleanupDeps(), 避免造成对下一次依赖收集的污染。

所有依赖塞进一个数组队列subs维护,当数据发生变化,在set方法触发notify(), 遍历subs里面的元素(其实就是watcher对象),调用watcher对象的update()方法, 触run()方法进行派发更新,最终还是回到了vm._update(vm._render(), hydrating),更新渲染。

需要注意的是, 内部根据组件id进行排序,保证更新顺序是由父组件到子组件。

1
queue.sort((a, b) => a.id - b.id)

为了提高性能vue实行异步更新watcher队列, 就是将watcher.run()放到任务队列,如:

  • Promise.resolve(flushCallbacks)
  • setTimeout(flushCallbacks, 0)
  • const observer = new MutationObserver(flushCallbacks)

贴出官方文档的解释:

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。
例如,当你设置 vm.someData = ‘new value’,该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用

总结

对vue 2.x的源码分析至此over, 在debug的过程中,曾经怀疑过是否值得花这么多时间去做这个事情,但最终还是走过来了,深入debug源码我觉得有两点是非常重要的:

  • 了解框架内部实现
  • 学习编程设计模式

我对自己对要求是,是否吃透了JavaScript,是否达到为所欲为的程度?很显然,离这个水平还有很长的路要走,那就继续努力吧。接下来转战vue 3.0了,我可能会再去撸一遍vue 3.0的源码,大概就是想学好typescript😬