vue源代码学习-组件

我记得我之前在vue官网看见这么一句介绍:组件是Vue.js最强大的功能之一,怎么最近上去看的时候就没了?囧。不管怎么样,lets do it。

组件也是Vue的实例

组件其实也是一个Vue的实例,最大的区别可能就是不需要传入el这样的Vue根实例才需要的属性。顺着Vue.component方法来看看组件是如何被创建的吧。

Vue.component在core/global-api/assets.js中, 当传入组件定义时,实际就是调用了Vue.extend方法

还记得Vue的构造函数吧?

1
2
3
4
5
6
7
8
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}

刚开始说组件是一个Vue的实例,更具体一点,其实组件是继承自Vue的,我们只要看看Vue.extend的实现就知道了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// core/global-api/extend.js

Vue.extend = function (extendOptions: Object): Function { // 组件继承VUE
......

const Sub = function VueComponent (options) { // 构造函数其实是一样一样的
this._init(options)
}
Sub.prototype = Object.create(Super.prototype) // 继承自VUE
Sub.prototype.constructor = Sub // prototype的constructor指向自身构造函数
Sub.cid = cid++
Sub.options = mergeOptions(
Super.options,
extendOptions
)

......

return Sub

}

这样就很明显的看出来了,组件继承自Vue,this._init其实就是Vue.prototype上的方法。所以,组件在创建实例的时候,其实也是和根节点Vue实例创建是一样的,当然其中会有根据实例是组件的很多特殊处理。

那么子组件是在什么时候创建的呢? 是在父节点的Vue render函数执行的时候创建的。注意这里说的是父节点,因为毕竟组件也会嵌套嘛。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div id = "app">
<component-ul>
<component-li v-for = "row in rows" :content = "row" :key = "row"></component-li>
</component-ul>
</div>
<script>
Vue.component('component-ul', {
template: '<ul><slot></slot></ul>'
})

Vue.component('component-li', {
props: ['content'],
template: '<li>{{content}}</li>'
})

new Vue({
el: '#app',
data: {
rows: ['row1', 'row2']
}
})
</script>

上面这个例子中,首先,创建#app的根节点Vue实例,在第一次执行render函数的过程中, 创建component-ul的实例;然后在执行component-ul的render函数时,再去创建component-li的实例并执行其render函数。

模板作用域

看Vue组件教程的时候一定看见过这么一句话: 父组件模板的所有东西都会在父级作用域内编译;子组件模板的所有东西都会在子级作用域内编译。

这一个设计其实是很自然和合理的。当我们使用嵌套组件、插槽slot时,甚至于其实我们在使用组件的时候就已经嵌套了(因为还有一个根实例的vue对象),那么我们不可能在子组件的模板里依然去使用父组件的数据,这样过于耦合。在vue中, 父组件通过prop向子组件传递数据;子组件通过事件向父组件发送消息。

如何来理解各个组件有自己的作用域呢?我们在之前已经知道了,每个组件都有自己的render函数,这个render函数在执行时其实就可以理解为组件自己的作用域了。

以上一节为例,其中涉及到一个根节点vue和两个组件,它们的render函数分别如下:

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

// 根节点
(function anonymous() {
with (this) {
return _c('div', {
attrs: {
"id": "app"
}
}, [_c('component-ul', _l((rows), function(row) {
return _c('component-li', {
key: row,
attrs: {
"content": row
}
})
})), _v(" "), _c('component-ul')], 1)
}
}
)

// component-ul
(function anonymous() {
with (this) {
return _c('ul', [_t("default")], 2)
}
}
)

// component-li
(function anonymous() {
with (this) {
return _c('li', [_v(_s(content))])
}
}
)

这样就很清楚了,每次render函数在执行时,都是将自身实例作为this,所以template模板里的变量也是相对应的。

Vue自带的组件

下面我们来看看Vue自己实现的组件吧

keep-alive

keep-alive主要用来实现保存动态组件,会缓存不活动的组件实例,而不是销毁它们。它的实现在core/components/keep-alive.js中。实现不复杂,每个keep-alive组件自身会从其自身的$slot.default中的第一个子组件作为保存的组件,保存下该组件的实例和key,并且为其vnode设置标志位keepAlive=true。那么在之后比如is或者v-if等本来该导致该子组件销毁的情况下,将不会初始化组件或者调用组件的$destroy,取而代之去执行activated或者deactivated钩子。

transition

transition主要用来提供在插入、更新、移除dom时,实现过渡效果。组件的实现在platforms/web/runtime/components/transition.js中。在执行transition的render函数时主要做了以下两件事:

  1. vue会找出执行过渡的vnode节点,并且提取出transition相关参数以供后面patch过程中执行钩子的时候调用,然后来实现过渡特效。
  2. 在配置了out-in、in-out的模式时,会为vnode对应的增加钩子函数。比如out-in模式,本来新vnode会显示,但是这里vue会先返回空vnode节点,然后在旧节点消失时的钩子函数afterLeave里,去执行$forceUpdate再次渲染出新节点,实现out-in的效果。

所以其实过渡状态主要的逻辑其实是在patch过程中,vue调用vnode的enter、leave模块的钩子函数的。这部分代码主要在platforms/web/runtime/modules/transition.js中。以enter的过程为例,我们看看主要的一段实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

const cb = el._enterCb = once(() => {
if (expectsCSS) {
removeTransitionClass(el, toClass)
removeTransitionClass(el, activeClass)
}
// ...省略
})

// ...省略

addTransitionClass(el, startClass)
addTransitionClass(el, activeClass)
nextFrame(() => {
addTransitionClass(el, toClass)
removeTransitionClass(el, startClass)
if (!cb.cancelled && !userWantsControl) {
if (isValidDuration(explicitEnterDuration)) {
setTimeout(cb, explicitEnterDuration)
} else {
whenTransitionEnds(el, type, cb)
}
}
})

可以看到, activeClass贯穿始终,在动画结束后才会移除, startClass在nextFrame后移除, toClass在nextFrame后加入。 Vue nextFrame的实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
const raf = inBrowser
? window.requestAnimationFrame
? window.requestAnimationFrame.bind(window)
: setTimeout
: /* istanbul ignore next */ fn => fn()

export function nextFrame (fn: Function) {
raf(() => {
raf(fn)
})
}

文档写的是插入(移除)之后的下一帧,我理解应该是指要确保在enter class生效后(即经过一帧),然后在下一帧之前加入enter-to对应的class。所以这里会调用两次raf函数,先加入enter class(一闪而过,毕竟只有一帧的时间), 过了一帧后,再加入enter-to class。

transition-group

transition-group用来实现多个元素的过渡。不同于transition, 它不是一个抽象的组件,而是会以真实元素呈现,默认为spantransition-group为了实现更为流畅的列表切换动画,采用了FLIP这种技术。在此基础上,为了实现增删过程中列表元素的移动动画、准确的记录各种操作导致的列表中元素的位置变化,所以vue将transition-group的render过程分为了两步: 先删除节点后patch,然后再patch最终的节点。vue中利用了beforeUpdate钩子来实现分步的patch, 整体过程如下:

vue中源码如下(已添加注释):

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168

import { warn, extend } from 'core/util/index'
import { addClass, removeClass } from '../class-util'
import { transitionProps, extractTransitionData } from './transition'

import {
hasTransition,
getTransitionInfo,
transitionEndEvent,
addTransitionClass,
removeTransitionClass
} from '../transition-util'

const props = extend({
tag: String,
moveClass: String
}, transitionProps)

delete props.mode

export default {
props,

render (h: Function) {
const tag: string = this.tag || this.$vnode.data.tag || 'span' // 默认span tag
const map: Object = Object.create(null)
const prevChildren: Array<VNode> = this.prevChildren = this.children // 上一次的children
const rawChildren: Array<VNode> = this.$slots.default || [] // 本次render的children
const children: Array<VNode> = this.children = [] // 用来保存要展示的节点
const transitionData: Object = extractTransitionData(this) // 获取transition数据

for (let i = 0; i < rawChildren.length; i++) {
const c: VNode = rawChildren[i]
if (c.tag) {
if (c.key != null && String(c.key).indexOf('__vlist') !== 0) { // 有自定义key的节点
children.push(c) // 保存要展示的节点
map[c.key] = c // 缓存,用于判断上一次的节点是否被保留
;(c.data || (c.data = {})).transition = transitionData // 保存transition数据用于patch时调用enter、leave钩子
} else if (process.env.NODE_ENV !== 'production') {
const opts: ?VNodeComponentOptions = c.componentOptions
const name: string = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tag
warn(`<transition-group> children must be keyed: <${name}>`)
}
}
}

if (prevChildren) {
const kept: Array<VNode> = [] // 被保留的节点
const removed: Array<VNode> = [] // 被删除的节点
for (let i = 0; i < prevChildren.length; i++) {
const c: VNode = prevChildren[i]
c.data.transition = transitionData
c.data.pos = c.elm.getBoundingClientRect() // 得到节点位置
if (map[c.key]) {
kept.push(c) // 本次render依然被保留
} else {
removed.push(c) // 本次render被删除
}
}
this.kept = h(tag, null, kept) // 渲染保留的节点,用于之后执行第一步(即先删除节点patch)
this.removed = removed
}

return h(tag, null, children) // 返回本次render节点
},

beforeUpdate () { // 执行第一步删除节点的patch(注意这个钩子在组件本身patch之前触发)
// force removing pass
this.__patch__(
this._vnode, // 旧的节点
this.kept, // 本次render保留的节点(不包含新增的节点)
false, // hydrating
true // removeOnly (!important, avoids unnecessary moves)
)
this._vnode = this.kept
},

updated () { // patch已触发,此时若有新增节点也已经patch
const children: Array<VNode> = this.prevChildren
const moveClass: string = this.moveClass || ((this.name || 'v') + '-move')
if (!children.length || !this.hasMove(children[0].elm, moveClass)) { // 用户有设置move class
return
}

// we divide the work into three loops to avoid mixing DOM reads and writes
// in each iteration - which helps prevent layout thrashing.
children.forEach(callPendingCbs) // 回调
children.forEach(recordPosition) // 记录新位置
children.forEach(applyTranslation) // 执行FLIP概念中的FL,让move的节点前往F(初始)状态

// force reflow to put everything in position
// assign to this to avoid being removed in tree-shaking
// $flow-disable-line
this._reflow = document.body.offsetHeight // 触发reflow

children.forEach((c: VNode) => {
if (c.data.moved) { // 节点相比于render前位置有变动
var el: any = c.elm
var s: any = el.style
addTransitionClass(el, moveClass) // 添加moveclass
s.transform = s.WebkitTransform = s.transitionDuration = '' // 把节点从F(初始状态) 设置为L(最终状态)
el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) { // 监听transition end
if (!e || /transform$/.test(e.propertyName)) {
el.removeEventListener(transitionEndEvent, cb)
el._moveCb = null
removeTransitionClass(el, moveClass)
}
})
}
})
},

methods: {
hasMove (el: any, moveClass: string): boolean { // 是否有move class
/* istanbul ignore if */
if (!hasTransition) {
return false
}
/* istanbul ignore if */
if (this._hasMove) {
return this._hasMove
}
// Detect whether an element with the move class applied has
// CSS transitions. Since the element may be inside an entering
// transition at this very moment, we make a clone of it and remove
// all other transition classes applied to ensure only the move class
// is applied.
const clone: HTMLElement = el.cloneNode() // 用克隆的节点
if (el._transitionClasses) { // 本身有transition class
el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })
}
addClass(clone, moveClass)
clone.style.display = 'none'
this.$el.appendChild(clone)
const info: Object = getTransitionInfo(clone)
this.$el.removeChild(clone)
return (this._hasMove = info.hasTransform)
}
}
}

function callPendingCbs (c: VNode) {
/* istanbul ignore if */
if (c.elm._moveCb) {
c.elm._moveCb()
}
/* istanbul ignore if */
if (c.elm._enterCb) {
c.elm._enterCb()
}
}

function recordPosition (c: VNode) {
c.data.newPos = c.elm.getBoundingClientRect() // 组件patch后新的位置
}

function applyTranslation (c: VNode) {
const oldPos = c.data.pos
const newPos = c.data.newPos
const dx = oldPos.left - newPos.left
const dy = oldPos.top - newPos.top
if (dx || dy) { // 若位置有变动
c.data.moved = true
const s = c.elm.style
s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)` // 将节点移动回初始位置
s.transitionDuration = '0s'
}
}

总结

三个组件的实现都很巧妙,尤其是transition-group。Vue自带的三个组件和Vue自身的耦合还是比较重的,尤其是过渡组件,所以难怪Vue会提供一些基础的组件,毕竟这些组件你不看源码、不增加额外的内部功能是无法完成的。