本文主要内容摘抄自黄轶老师的慕课网课程Vue.js 源码全方位深入解析 全面深入理解Vue实现原理,主要用于个人学习和复习,不用作其他用途。
数据驱动
Vue.js 一个核心思想是数据驱动。所谓数据驱动,是指视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。
它相比我们传统的前端开发,如使用 jQuery 等前端库直接修改 DOM,大大简化了代码量。特别是当交互复杂的时候,只关心数据的修改会让代码的逻辑变的非常清晰,因为 DOM 变成了数据的映射,我们所有的逻辑都是对数据的修改,而不用碰触 DOM,这样的代码非常利于维护。
在 Vue.js 中我们可以采用简洁的模板语法来声明式的将数据渲染为 DOM:
<div id="app">
{{ message }}
</div>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
最终它会在页面上渲染出 Hello Vue,接下来,我们会从源码角度来分析 Vue 是如何实现的。
new Vue 发生了什么
首先来看一下Vue的函数类,源码在src/core/instance/index.js 中。
function Vue (options) {
this._init(options)
}
可以看到 Vue 只能通过 new 关键字初始化,然后会调用 this._init 方法, 该方法在 src/core/instance/init.js 中定义。
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
...
// 合并配置,并在vm上挂载$options
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
...
// 进行一系列的初始化
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm)
// 把data变成响应式
initState(vm)
initProvide(vm)
callHook(vm, 'created')
...
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
这里重点分析下initState和initRender做了什么事情:
initState
export function initState(vm: Component) {
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe((vm._data = {}), true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
initData代码如下:
function initData(vm: Component) {
let data = vm.$options.data
data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}
// 因为data,props和methods都会挂载在vm上,所以需要判断他们不能重名
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
let i = keys.length
while (i--) {
const key = keys[i]
...
// 代理,访问this.message 就是访问this._data.message
proxy(vm, `_data`, key)
...
}
// 把data变为响应式data
observe(data, true /* asRootData */)
}
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)
}
我们把 data 挂载在 vm._data 上,但是每次访问data里面的属性,就会这样这访问this._data.message,这样比较麻烦,而且下划线开头说明是一个私有属性,不应该被外面所访问,所以后面利用proxy做了一层代理,当你访问this.message其实就访问了this._data.message。
initRender
export function initRender (vm: Component) {
...
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
...
}
这个方法的功能是在vm上挂载了_c和$createElement两个方法,这两个方法的作用创建vnode。
从上面可以看出,初始化就是往实例上挂载各种属性和方法。比如在vm上挂载了$options、_data,以及方法$createElement等等。
Vue 的初始化逻辑写的非常清楚,把不同的功能逻辑拆成一些单独的函数执行,让主线逻辑一目了然,这样的编程思想是非常值得借鉴和学习的。
在初始化的最后,检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM,那么接下来我们来分析 Vue 的挂载过程。
Vue 实例挂载的实现
Vue 中我们是通过 $mount 实例方法去挂载 vm 的,$mount 方法在多个文件中都有定义,如 src/platform/web/entry-runtime-with-compiler.js、src/platform/web/runtime/index.js、src/platform/weex/runtime/index.js。
因为 $mount 这个方法的实现是和平台、构建方式都相关的。接下来重点分析带 compiler 版本的 $mount 实现,因为抛开 webpack 的 vue-loader,我们在纯前端浏览器环境分析 Vue 的工作原理,有助于我们对原理理解的深入。
compiler 版本的 $mount 实现非常有意思,先来看一下 src/platform/web/entry-runtime-with-compiler.js 文件中定义:
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 传入一个id,通过id获取的dom对象,记住是真实dom query:document.querySelector(el)
el = el && query(el)
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
// 如果options中没有render函数
if (!options.render) {
let template = options.template
if (template) {
...
// 获取模板字符串
template = template.innerHTML
...
} else if (el) {
// 如果没有template属性,那么就从el中获取dom的字符串
template = getOuterHTML(el)
}
if (template) {
// 把模板字符串转化为render函数
const { render, staticRenderFns } = compileToFunctions(template...)
// 把render函数放在options对象上
options.render = render
options.staticRenderFns = staticRenderFns
}
}
return mount.call(this, el, hydrating)
}
这段代码首先缓存了原型上的 $mount 方法,再重新定义该方法,我们先来分析这段代码。
-
首先,它对
el做了限制,Vue 不能挂载在body、html这样的根节点上。 -
接下来的是很关键的逻辑,如果没有定义
render方法,则会把el或者template字符串转换成render方法。这里我们要牢记,在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要render方法,无论我们是用单文件 .vue 方式开发组件,还是写了el或者template属性,最终都会转换成render方法,那么这个过程是 Vue 的一个在线编译的过程,它是调用compileToFunctions方法实现的,编译过程我们之后会介绍。 -
最后,调用原先原型上的
$mount方法挂载。
原先原型上的 $mount 方法在 src/platform/web/runtime/index.js 中定义,之所以这么设计完全是为了复用,因为它是可以被 runtime only 版本的 Vue 直接使用的。
Vue.prototype.$mount = function (el?: string | Element, hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
$mount 方法实际上会去调用 mountComponent 方法,这个方法定义在 src/core/instance/lifecycle.js 文件中:
export function mountComponent (vm: Component, el: ?Element): Component {
vm.$el = el
...
callHook(vm, 'beforeMount')
...
let updateComponent
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 这一块后面会重点介绍
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
...
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
从上面的代码可以看到,mountComponent 核心就是先实例化一个渲染Watcher,在它的实例化中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM。
Watcher 在这里起到两个作用,一个是初始化的时候会执行回调函数,另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数,这块儿会在之后的章节中介绍。
函数最后判断为根节点的时候设置 vm._isMounted 为 true, 表示这个实例已经挂载了,同时执行 mounted 钩子函数。 这里注意 vm.$vnode 表示 Vue 实例的父虚拟 Node,所以它为 Null 则表示当前是根 Vue 的实例。
render 生成虚拟dom
Virtual DOM
Virtual DOM 这个概念相信大部分人都不会陌生,它产生的前提是浏览器中的 DOM 是很"昂贵"的,为了更直观的感受,我们可以简单的把一个简单的 div 元素的属性都打印出来,如图所示:
可以看到,真正的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂。当我们频繁的去做 DOM 更新,会产生一定的性能问题。
而 Virtual DOM 就是用一个原生的 JS 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多。在 Vue.js 中,Virtual DOM 是用 VNode 这么一个 Class 去描述,它是定义在 src/core/vdom/vnode.js 中的。
export default class VNode {
constructor (tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component ...
) {
this.tag = tag
this.data = data // 标签属性
this.children = children
this.text = text
this.elm = elm // 虚拟dom所对应的真实dom节点
this.ns = undefined
this.context = context
this.fnContext = undefined
...
}
get child (): Component | void {
return this.componentInstance
}
}
其实 VNode 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是用来扩展 VNode 的灵活性以及实现一些特殊 feature 的。由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的。
Virtual DOM 除了它的数据结构的定义,映射到真实的 DOM 实际上要经历 VNode 的 create、diff、patch 等过程。那么在 Vue.js 中,VNode 的 create 是通过之前提到的 vm._render 方法创建的,我们接下来分析这部分的实现。
render
Vue 的 _render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。它的定义在 src/core/instance/render.js 文件中:
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
...
let vnode
try {
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
...
}
...
return vnode
}
这段代码最关键的是 render 方法的调用,我们在平时的开发工作中手写 render 方法的场景比较少,而写的比较多的是 template 模板,在之前的 mounted 方法的实现中,会把 template 编译成 render 方法。
下面是我们手写的一个render方法,在render方法中传入了一个参数函数createElement,这个参数就是对应上面的vm.$createElement函数。
new Vue({
el: '#app',
data() {
return {
message: 'hello vue'
}
},
render(createElement) {
return createElement(
'div',
{
attrs: {
id: 'app1'
}
},
this.message
)
}
})
vm.$createElement函数代码如下:
vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };
Vue.js 利用 createElement 方法创建 VNode,它定义在 src/core/vdom/create-element.js 中:
export function createElement (
context: Component,
tag: any,
data: any,
children: any,
normalizationType: any,
alwaysNormalize: boolean
): VNode | Array<VNode> {
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
}
if (isTrue(alwaysNormalize)) {
normalizationType = ALWAYS_NORMALIZE
}
return _createElement(context, tag, data, children, normalizationType)
}
createElement 方法实际上是对 _createElement 方法的封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 VNode 的函数 _createElement。
因为我们可以不传createElement的data(标签属性,一个对象),所以需要对参数做一个处理,比如如果data不传,那么data就是children。
export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
...
// 如果没有传tag,则创建一个空vnode
if (!tag) {
return createEmptyVNode();
}
// `normalizationType` 表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考 `render` 函数是编译生成的还是用户手写的。
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
let vnode, ns
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
// 如果是平台保留的标签名,比如浏览器环境下的div标签
if (config.isReservedTag(tag)) {
// 生成vnode
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
} else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// 判断是否是一个组件,如果是则创建一个组件
vnode = createComponent(Ctor, data, context, children, tag)
} else {
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
// 如果tag不是字符串,说明是一个组件
vnode = createComponent(tag, data, context, children)
}
if (Array.isArray(vnode)) {
return vnode
} else if (isDef(vnode)) {
if (isDef(ns)) applyNS(vnode, ns)
if (isDef(data)) registerDeepBindings(data)
return vnode
} else {
return createEmptyVNode()
}
}
我们以如下代码来分析_createElement的运行过程,下面代码中的children有两个子节点,一个是文本节点,一个是继续调用_createElement生成一个vnode。
new Vue({
el: '#app',
data() {
return {
message: 'hello vue'
}
},
render(createElement) {
return createElement(
'div',
{
attrs: {
id: 'app1'
}
},
[
createElement('div', 'pengchangjun'),
this.message
]
)
}
})
当执行这个代码的时候,首先调用children里面的createElement,然后执行normalizeChildren函数:
export function normalizeChildren (children: any): ?Array<VNode> {
return isPrimitive(children)
? [createTextVNode(children)]
: Array.isArray(children)
? normalizeArrayChildren(children)
: undefined
}
如果children是一个原始类型(字符串),则生成一个文本vnode的数组,此时的children为:
{
tag: undefined,
data: undefined,
children: undefined,
text: 'pengchangjun',
...
}
如果children是一个数组,那么调用normalizeArrayChildren,此时children为:
[
{
// 这是一个VNode对象
tag: 'div',
data: undefined,
text: undefined,
children: [
{
// 这也是一个vnode对象
tag: undefined,
data: undefined,
children: undefined,
text: 'pengchangjun',
...
}
]
},
// 这是一个文本
'hello vue'
]
把这个children传入到normalizeArrayChildren执行
function normalizeArrayChildren (children, nestedIndex) {
var res = [];
var i, c, lastIndex, last;
for (i = 0; i < children.length; i++) {
c = children[i];
...
// nested 嵌套数组
if (Array.isArray(c)) {
if (c.length > 0) {
c = normalizeArrayChildren(c, ((nestedIndex || '') + "_" + i));
...
res.push.apply(res, c);
}
} else if (isPrimitive(c)) {
// 如果是原始类型则生成一个文本vnode
res.push(createTextVNode(c));
} else {
// 如果已经是vnode类型,则直接push
...
res.push(c);
}
}
return res
}
返回的res为一个vnode的数组:
[
{
// 这是一个VNode对象
tag: 'div',
data: undefined,
text: undefined,
children: [
{
// 这是一个vnode对象
tag: undefined,
data: undefined,
children: undefined,
text: 'pengchangjun',
...
}
]
},
// 这是一个文本vnode
{
tag: undefined,
data: undefined,
children: undefined,
text: 'hello vue',
...
}
]
生成 vnode 的 children 之后,继续执行如下逻辑:
let vnode, ns
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
} else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
vnode = createComponent(Ctor, data, context, children, tag)
} else {
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
vnode = createComponent(tag, data, context, children)
}
这里先对 tag 做判断,如果是 string 类型,则接着判断如果是内置的一些节点,则直接创建一个普通 VNode,如果是为已注册的组件名,则通过 createComponent 创建一个组件类型的 VNode,否则创建一个未知的标签的 VNode。 如果是 tag 一个 Component 类型,则直接调用 createComponent 创建一个组件类型的 VNode 节点。对于 createComponent 创建组件类型的 VNode 的过程,之后会去介绍,本质上它还是返回了一个 VNode。
通过以上逻辑就生成了一个vnode的树状结构,结构如下:
{
tag: 'div',
data: {
attrs: {
id: 'app1'
}
},
children: [
{
// 这是一个VNode对象
tag: 'div',
data: undefined,
text: undefined,
children: [
{
// 这是一个vnode对象
tag: undefined,
data: undefined,
children: undefined,
text: 'pengchangjun',
...
}
]
},
// 这是一个文本vnode
{
tag: undefined,
data: undefined,
children: undefined,
text: 'hello vue',
...
}
]
text: undefined,
elm: undefined
}
那么至此,我们大致了解了 createElement 创建 VNode 的过程,每个 VNode 有 children,children 每个元素也是一个 VNode,这样就形成了一个 VNode Tree,它很好的描述了我们的 DOM Tree。
回到 mountComponent 函数的过程,我们已经知道 vm._render 是如何创建了一个 VNode,接下来就是要把这个 VNode 渲染成一个真实的 DOM 并渲染出来,这个过程是通过 vm._update 完成的,接下来的文章中分析一下这个过程。