阅读资源推荐
【读vue 源码】溯源 import Vue from 'vue' 到底做了什么?
前言
Vue.js 一个核心思想是数据驱动。也就是说视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。当交互复杂的时候,只关心数据的修改会让代码的逻辑变的非常清晰,因为 DOM 变成了数据的映射,我们所有的逻辑都是对数据的修改,而不用碰触 DOM,这样的代码非常利于维护。
在 Vue.js 中我们可以采用简洁的模板语法来声明式的将数据渲染为 DOM:
<div id="app">
{{ msg }}
</div>
var app = new Vue({
el: '#app',
data: {
msg: 'Hello world!'
}
})
结果页面上会展示出Hello world!
。这是入门vue.js的时候就知道的知识。那么现在要问vue.js的源码到底做了什么,才能让模版和数据最终被渲染成了DOM???
从 new Vue()
开始
在写vue 项目的时候,会在项目的入口文件 main.js
文件里实例化一个vue 。
如下:
var app = new Vue({
el: '#app',
data: {
msg: 'Hello world!'
},
})
由上一篇文章最后的结论可知,Vue 就是一个用 Function 实现的类。源码如下:在src/core/instance/index.js
中
// _init 方法所在的位置
import { initMixin } from './init'
// Vue就是一个用 Function 实现的类,所以才通过 new Vue 去实例化它。
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)
}
当我们在项目中 new Vue({})
传入一个对象的时候,其实就是执行的上面的方法,并传入参数为 options
,然后调用了this._init(options)
方法。该方法在src/core/instance/init.js
文件中。代码如下:
import { initState } from './state'
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// 定义了uid
vm._uid = uid++
let startTag, endTag
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
startTag = `vue-perf-start:${vm._uid}`
endTag = `vue-perf-end:${vm._uid}`
mark(startTag)
}
vm._isVue = true
// 合并options
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
// 这里将传入的options全部合并在$options上。
// 因此我们可以通过$el访问到 vue 项目中new Vue 中的el
// 通过$options.data 访问到 vue 项目中new Vue 中的data
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = vm
}
// 初始化函数
vm._self = vm
initLifecycle(vm) // 生命周期函数
initEvents(vm) // 初始化事件链
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
vm._name = formatComponentName(vm, false)
mark(endTag)
measure(`vue ${vm._name} init`, startTag, endTag)
}
// 判断当前的$options.el是否有el 也就是说是否传入挂载的DOM对象
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
由以上代码可知 this._init(options)
主要是合并配置,初始化生命周期,初始化事件中心,初始化渲染,初始化 data、props、computed、watcher 等等。重要的部分在代码里做里注释。
那么接下来依然从其中一个功能为例进行分析:以initState(vm)
为例:
为什么在钩子函数里可以访问到 data 里定义的数据?
vue 项目中,当定义了 data 就可以在组件的钩子函数 或者 在 methods 函数里都可以访问到data 里定义的属性。这是为什么??
var app = new Vue({
el: '#app',
data:(){
return{
msg: 'Hello world!'
}
},
mounted(){
console.log(this.msg) // logs 'Hello world!'
},
分析源码:可以看到this._init(options)
方法,在初始化函数部分有一个 initState(vm)
函数。该方法实在./state.js
中:具体代码如下:
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
// 如果定义了 props 就初始化props;
if (opts.props) initProps(vm, opts.props)
// 如果定义了methods 就初始化methods;
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
// 如果定义了data,就初始化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)
}
}
在initState
方法中判断:如果定义了data,就初始化data;继续看初始化data 的函数:initData(vm)
。代码如下:
function initData (vm: Component) {
/*
这个data 就是 我们vue 项目中定义的data。也就是上面例子中的
data(){
return {
msg: 'Hello world!'
}
}
*/
let data = vm.$options.data
// 拿到data 后,做了判断,判断它是不是一个function
data = vm._data = typeof data === 'function'
? getData(data, vm) // 如果是 执行了getData()方法 ,这个方法就是返回data
: data || {}
// 如果不是一个对象则在开发环境报出一个警告
if (!isPlainObject(data)) {
data = {}
process.env.NODE_ENV !== 'production' && warn(
'data functions should return an object:\n' +
'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
vm
)
}
// 拿到data 定义的属性
const keys = Object.keys(data)
// 拿到props
const props = vm.$options.props
// 拿到 methods
const methods = vm.$options.methods
let i = keys.length
// 做了一个循环对比,如果在data 上定义的属性,就不能在props与methods在定义该属性。因为不管是data里定义的,在props里定义的,还是在medthods里定义的,最终都挂载在vm实例上了。见proxy(vm, `_data`, key)
while (i--) {
const key = keys[i]
if (process.env.NODE_ENV !== 'production') {
if (methods && hasOwn(methods, key)) {
warn(
`Method "${key}" has already been defined as a data property.`,
vm
)
}
}
if (props && hasOwn(props, key)) {
process.env.NODE_ENV !== 'production' && warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) {
proxy(vm, `_data`, key) // 代理 定义了Getter 和 Setter
}
}
// observe data
observe(data, true /* asRootData */)
}
// proxy 代理
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
}
export function proxy (target: Object, sourceKey: string, key: string) {
// 通过对象 sharedPropertyDefinition 定义了Getter 和 Setter
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
// 当访问vm.key 的时候其实访问的是 vm[sourceKey][key]
// 以上述开始的问题,当访问this.msg 实际是访问 this._data.msg
}
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val
}
// 对vm 的 key 做了一次Getter 和 Setter
Object.defineProperty(target, key, sharedPropertyDefinition)
}
综上:初始化 data 实在./state.js
文件里。执行initState()
方法,该方法判断如果定义了data,就初始化data。
如果data 是一个function,就执行了getData()
方法return data.call(vm, vm)
。然后对 vm 上的 data 里定义的属性、vm上的 props 、vm上的methods里的属性进行循环比对,如果在data 上定义的属性,就不能在props与methods在定义该属性。因为不管是data里定义的,在props里定义的,还是在medthods里定义的,最终都挂载在vm实例上了。见proxy(vm, _data
, key)。
然后通过proxy 方法给vm 上的属性做了Getter 和 Setter 方法的绑定。回到上述的问题,当访问this.msg 实际是访问 vm._data.msg。因此在钩子函数里确实可以访问到 data 里定义的数据了。
不得不在说一遍,Vue 的初始化逻辑写的非常清楚,把不同的功能逻辑拆成一些单独的函数执行,让主线逻辑一目了然,这样的编程思想是非常值得借鉴和学习的。
其它初始化的内容大家可以自己补充,接下来看挂载vm。在初始化的最后,检测到如果有 el 属性,则调用 vm.$mount
方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM,那么接下来探究 Vue 的挂载过程吧
Vue 实例挂载的实现
Vue 中我们是通过 $mount
实例方法去挂载 vm 的。接下来要探究执行$mount('#app')
的时候,源码都干了什么???
new Vue({
render: h => h(App),
}).$mount('#app')
$mount
方法在多个文件中都有定义,如 src/platform/web/entry-runtime-with-compiler.js
、src/platform/web/runtime/index.js
、src/platform/weex/runtime/index.js
。因为 $mount
这个方法的实现是和平台、构建方式都有关系。
就选取 compiler 版本的 $mount
分析吧,文件地址在src/platform/web/entry-runtime-with-compiler.js
,代码如下:
// 获取vue 原型上的 $mount 方法, 存在变量 mount 上。
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// query 定义在 './util/index'文件中
// 调用原生的DOM api querySelector() 方法。最后将el转化为一个DOM 对象。
el = el && query(el)
...
return mount.call(this, el, hydrating)
}
读代码可知,代码首先获取了 vue 原型上的 $mount
方法,将其存在变量mount中,然后重新定义了该方法。该方法对传入的el做了处理,el 可以是个字符串,也可以是DOM 对象。然后调用了 query()
方法,该方法在./util/index
文件中。主要是调用原生的DOM api querySelector() 方法。最后将el转化为一个DOM 对象返回。上述只贴出了主要的代码部分。
源码了还对el进行了判断,判断传入的el 是否为body 或者 html ,如果是,就会在开发环境报一个警告。vue 不可以直接挂载到body 和html上 ,因为会被覆盖,当覆盖了 html 或 body 整个文档就会报错。
源码还获取到 $options 判断是否定义render方法。如果没有定义 render 方法,则会把 el 或者 template 字符串最终将编译为render()
函数。
最后 return mount.call(this, el, hydrating)
。此处的mount是vue 原型上的 $mount
方法。在文件./runtime/index
。代码如下:
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
其中参数 el 表示挂载的元素,它可以是字符串,也可以是一个DOM 对象。如果是字符串在浏览器环境下会调用 query()
方法转换成 DOM 对象。第二个参数是和服务端渲染相关,在浏览器环境下我们不需要传第二个参数。最后return 的时候调用了mountComponent()
方法。该方法定义在src/core/instance/lifecycle.js
,代码如下:
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
...
let updateComponent
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
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)
}
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, 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
}
读代码可知,该方法首先实例化一个渲染Watcher
,在它的回调函数中会调用 updateComponent
方法,在此方法中调用 vm._render()
方法先生成虚拟DOM节点,最终调用 vm._update
更新 DOM。
最后判断为根节点的时候设置 vm._isMounted
为 true
, 表示这个实例已经挂载了,同时执行 mounted
钩子函数。 vm.$vnode
表示 Vue 实例的父虚拟节点,所以它为 Null 则表示当前是根 Vue 的实例。
那么vm._render()
是怎样生成虚拟DOM节点的呢?
_render()
渲染虚拟DOM 节点
在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要 render()
。Vue 的 _render()
是实例的一个私有方法,它用来把实例渲染成一个虚拟DOM节点。它的定义在 src/core/instance/render.js
文件中,代码如下:
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
...
let vnode
try {
currentRenderingInstance = vm
vnode = render.call(vm._renderProxy, vm.$createElement)
}
}
上述代码 从vue实例的 $options 上获取到 render 函数。通过call()
调用了_renderProxy
和 createElement()
方法,先来探索createElement()
方法。
createElement()
createElement()
是在initRender()
中。如下:
// 该函数是在 _init() 过程中执行 initRender()
// 见 './init.js' 文件中的 initRender(vm) 传入vm。就执行到下面的方法。
export function initRender (vm: Component) {
// 被编译后生成的render函数
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// 手写render函数 创建 vnode 的方法。
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
}
initRender()
是在 _init过程中执行了initRender()
见 ./init.js
文件中的 initRender(vm)
传入vm。
在 vue 项目实际开发中,手写 render 函数 案例如下:
new Vue({
render(createElement){
return createElement('div',{
style:{color:'red'}
},this.msg)
},
data(){
return{
msg:"hello world"
}
}
}).$mount('#app')
因为是手写的render函数省去了将 template 编译为 render函数的过程,因此性能更好。
接下来看_renderProxy
方法:
_renderProxy
_renderProxy
方法,也是在 init 过程中执行的。见文件./init.js
中,代码如下:
import { initProxy } from './proxy'
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = vm
}
如果当前环境为生产环境 就将 vm 直接赋值给 vm._renderProxy
;
如果当前环境为开发环境,则执行initProxy()
。
该函数在./proxy.js
文件中,代码如下:
initProxy = function initProxy (vm) {
// 判断浏览器是否支持 proxy 。
if (hasProxy) {
// determine which proxy handler to use
const options = vm.$options
const handlers = options.render && options.render._withStripped
? getHandler
: hasHandler
vm._renderProxy = new Proxy(vm, handlers)
} else {
vm._renderProxy = vm
}
}
首先判断浏览器是否支持 proxy
。它是ES6 新增的,用于给目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
如果浏览器不支持 proxy
, 就将 vm 直接赋值给 vm._renderProxy
;
如果浏览器支持 proxy
,就执行new Proxy()
。
综上所述:vm._render
是通过执行 createElement
方法并返回虚拟的DOM 节点。那么什么是虚拟的DOM呢???
虚拟的DOM
在探究vue 的虚拟DOM 之前,先推荐一个虚拟DOM开源库。有时间,有兴趣的朋友可以去深入了解。它是用一个函数去表示一个应用程序的视图层。view.js 是借鉴它实现了虚拟DOM。从而大大的提升了程序的性能。接下来我们就来看vue.js是怎么做的。
vnode 的定义在 src/core/vdom/vnode.js
文件中,如下:
export default class VNode {
tag: string | void;
data: VNodeData | void;
children: ?Array<VNode>;
text: string | void;
elm: Node | void;
...
}
虚拟DOM 是个js对象,是对真实DOM 的一种抽象描述,比如标签名、数据、子节点名等。因为虚拟DOM只是用来映射真实DOM的渲染,所以不包含操作DOM的方法操作DOM的方法。因此更加的轻量,更加的简单。因为虚拟DOM 的创建是通过createElement
方法,那这个环节又是如何实现的呢???
createElement
Vue.js 利用 createElement
方法创建 DOM节点,它定义在 src/core/vdom/create-elemenet.js
文件中,代码如下:
export function createElement (
context: Component, // vm 实例
tag: any, // 标签
data: any, // 数据
children: any,// 子节点 可以构造DOM 树
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
}
// 处理好参数,则调用 _createElement() 去真正的创建节点。
return _createElement(context, tag, data, children, normalizationType)
}
createElement
方法是对 _createElement
方法的封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 DOM 节点的函数_createElement
,代码如下:
export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
...
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
...
}
_createElement
方法提供 5 个参数如下:
-
context
表示DOM节点的上下文环境,它是 Component 类型; -
tag
表示标签,它可以是一个字符串,也可以是一个 Component; -
data
表示 DOM节点上的数据,它是一个 VNodeData 类型,可以在flow/vnode.js
中找到它的定义; -
children
表示当前DOM节点的子节点,它是任意类型的,它接下来需要被规范为标准的 VNode 数组; -
normalizationType
表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考 render 函数是编译生成的还是手写的 render 函数。
createElement 函数的流程略微有点多,本文将重点探究 children 的规范化以及 VNode 的创建。
children 的规范化
虚拟DOM(Virtual DOM)实际上是一个树状结构,每一个DOM 节点都可能会有若干个子节点,这些子节点应该也是 VNode 的类型。
_createElement
接收的第 4 个参数 children
是任意类型的,因此我们需要把它们规范成 VNode 类型。
它是根据 normalizationType
的不同,调用了 normalizeChildren(children)
和 simpleNormalizeChildren(children)
方法,它们的定义都在 src/core/vdom/helpers/normalzie-children.js
文件 中,代码如下:
// render 函数是编译生成的时候调用
// 拍平数组为一维数组
export function simpleNormalizeChildren (children: any) {
for (let i = 0; i < children.length; i++) {
if (Array.isArray(children[i])) {
return Array.prototype.concat.apply([], children)
}
}
return children
}
// 返回一维数组
export function normalizeChildren (children: any): ?Array<VNode> {
return isPrimitive(children)
? [createTextVNode(children)]
: Array.isArray(children)
? normalizeArrayChildren(children)
: undefined
}
simpleNormalizeChildren
方法调用场景是 render 函数是编译生成的。但是当子节点为一个组件的时候,函数式组件返回的是一个数组而不是一个根节点,所以会通过 Array.prototype.concat
方法把整个 children
数组拍平,让它的深度只有一层。
normalizeChildren
方法的调用场景有 2 种,一个场景是手写 render 函数,当 children
只有一个节点的时候,Vue.js 从接口层面允许用户把 children
写成基础类型用来创建单个简单的文本节点,这种情况会调用 createTextVNode
创建一个文本节点的DOM 节点;另一个场景是当编译 slot
、v-for
的时候会产生嵌套数组的情况,会调用 normalizeArrayChildren
方法,代码如下:
function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
const res = []
let i, c, lastIndex, last
for (i = 0; i < children.length; i++) {
c = children[i]
if (isUndef(c) || typeof c === 'boolean') continue
lastIndex = res.length - 1
last = res[lastIndex]
// nested
if (Array.isArray(c)) {
if (c.length > 0) {
c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
// merge adjacent text nodes
if (isTextNode(c[0]) && isTextNode(last)) {
res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
c.shift()
}
res.push.apply(res, c)
}
} else if (isPrimitive(c)) {
if (isTextNode(last)) {
res[lastIndex] = createTextVNode(last.text + c)
} else if (c !== '') {
res.push(createTextVNode(c))
}
} else {
// 如果两个节点都为文本节点,则合并他们。
if (isTextNode(c) && isTextNode(last)) {
res[lastIndex] = createTextVNode(last.text + c.text)
} else {
if (isTrue(children._isVList) &&
isDef(c.tag) &&
isUndef(c.key) &&
isDef(nestedIndex)) {
c.key = `__vlist${nestedIndex}_${i}__`
}
res.push(c)
}
}
}
return res
}
normalizeArrayChildren
接收 2 个参数。
-
children
表示要规范的子节点; -
nestedIndex
表示嵌套的索引;
因为单个child
可能是一个数组类型。normalizeArrayChildren
主要是遍历children
,获得单个节点c
,然后对c
的类型判断,如果是一个数组类型,则递归调用normalizeArrayChildren
; 如果是基础类型,则通过createTextVNode
方法转换成 VNode 类型;否则就已经是 VNode 类型了,如果children
是一个列表并且列表还存在嵌套的情况,则根据nestedIndex
去更新它的key
。
在遍历的过程中,对这 3 种情况都做了如下处理:如果存在两个连续的 text
节点,会把它们合并成一个 text
节点。
到此,children 变成了一个类型为 VNode 的 Array。这就是children 的规范化。
虚拟的DOM节点的创建
回到 createElement
函数,规范化 children
后,接下来就要创建一个DOM实例,代码如下:
let vnode, ns
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
// platform built-in elements
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
} else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// component
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// 不认识的节点的处理
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
// direct component options / constructor
vnode = createComponent(tag, data, context, children)
}
这里先对 tag
做判断,如果是 string
类型,则接着判断如果是内置的一些节点,则直接创建一个普通 VNode,如果是为已注册的组件名,则通过 createComponent
创建一个组件类型的 VNode,否则创建一个未知的标签的 VNode。 如果 tag
是一个 Component
类型,则直接调用 createComponent
创建一个组件类型的 VNode 节点。
到这一步,createElement
方法就创建好了一个虚拟DOM树的实例,它用来描述了真实DOM 树,那么如何渲染为真实的DOM 树呢???其实它是由 vm._update
完成的。
update把虚拟DOM 渲染为真实DOM
_update
方法是如何把虚拟DOM 渲染为真实DOM 的。这部分代码在 src/core/instance/lifecycle.js
文件中,代码如下:
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
if (!prevVnode) {
// 数据的首次渲染时候执行
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
}
...
}
读代码可知,当数据首次渲染的时候,调用了vm.__patch__()
的方法,他接收了四个参数,结合我们实际vue项目的开发过程。vm.$el
就是 id 为 app 的 DOM 对象,即:<div id="app"></div>
;vnode
对应的是调用 render 函数的返回值;hydrating
在非服务端渲染情况下为 false
,removeOnly
为 false。
vm.__patch__
方法在不同的平台的定义是不一样的,对 web 平台的定义在 src/platforms/web/runtime/index.js
中,代码如下:
// 是否在浏览器环境
Vue.prototype.__patch__ = inBrowser ? patch : noop
在 web 平台上,是否是服务端渲染也会对这个方法产生影响。因为在服务端渲染中,没有真实的浏览器 DOM 环境,所以不需要把 VNode 最终转换成 DOM,因此是一个空函数,而在浏览器端渲染中,它指向了 patch 方法,它的定义在 src/platforms/web/runtime/patch.js
文件中,代码如下:
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'
const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })
读代码可知 createPatchFunction
方法的返回值被传入了一个对象,其中,
-
nodeOps
封装了一系列 DOM 操作的方法; -
modules
定义了模块的钩子函数的实现;
createPatchFunction
方法的定义在src/core/vdom/patch.js
文件中,代码如下:
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
export function createPatchFunction (backend) {
let i, j
const cbs = {}
const { modules, nodeOps } = backend
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
if (isDef(modules[j][hooks[i]])) {
cbs[hooks[i]].push(modules[j][hooks[i]])
}
}
}
// ...
// 定义了一些辅助函数
// 当调用 vm.__dispatch__时,其实就是调用下面的 patch 方法
// 这块应用了函数柯理化的技巧
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// ...
return vnode.elm
}
}
createPatchFunction
内部定义了一系列的辅助方法,最终返回了一个 patch
方法,这个方法就赋值给了 vm._update
函数里调用的 vm.__patch__
。也就是说当调用 vm.__dispatch__
时,其实就是调用patch (oldVnode, vnode, hydrating, removeOnly)
方法,这块其实是应用了函数柯理化的技巧。
patch
方法接收 4个参数,如下:
-
oldVnode
表示旧的 VNode 节点,它也可以不存在或者是一个 DOM 对象; -
vnode
表示执行 _render 后返回的 VNode 的节点; -
hydrating
表示是否是服务端渲染; -
removeOnly
是给 transition-group 用的。
分析patch
方法,因为传入的oldVnode
实际上是一个 DOM container,所以 isRealElement
为 true,然后调用 emptyNodeAt
方法把 oldVnode
转换成 虚拟DOM节点(一个js对象),然后再调用 createElm
方法。代码如下:
if (isRealElement) {
oldVnode = emptyNodeAt(oldVnode)
}
function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
if (isDef(vnode.elm) && isDef(ownerArray)) {
vnode = ownerArray[index] = cloneVNode(vnode)
}
vnode.isRootInsert = !nested // for transition enter check
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
// 接下来判断 vnode 是否包含 tag,
// 如果包含,先对tag的合法性在非生产环境下做校验,看是否是一个合法标签;
// 然后再去调用平台 DOM 的操作去创建一个占位符元素。
if (isDef(tag)) {
if (process.env.NODE_ENV !== 'production') {
if (data && data.pre) {
creatingElmInVPre++
}
if (isUnknownElement(vnode, creatingElmInVPre)) {
warn(
'Unknown custom element: <' + tag + '> - did you ' +
'register the component correctly? For recursive components, ' +
'make sure to provide the "name" option.',
vnode.context
)
}
}
// 调用 createChildren 方法去创建子元素:
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode)
/* istanbul ignore if */
if (__WEEX__) {
// ...
} else {
// 调用 createChildren 方法去创建子元素
// 用 createChildren 方法遍历子虚拟节点,递归调用 createElm
// 在遍历过程中会把 vnode.elm 作为父容器的 DOM 节点占位符传入。
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
insert(parentElm, vnode.elm, refElm)
}
if (process.env.NODE_ENV !== 'production' && data && data.pre) {
creatingElmInVPre--
}
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}
createElm
方法的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中。判断 vnode 是否包含 tag,如果包含,先对 tag 的合法性在非生产环境下做验证,看是否是一个合法标签;然后再去调用平台 DOM 的操作去创建一个占位符元素。然后调用 createChildren
方法去创建子元素,createChildren
方法代码如下:
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)))
}
}
createChildren
方法遍历子虚拟节点,递归调用 createElm
,在遍历过程中会把 vnode.elm 作为父容器的 DOM 节点占位符传入。然后调用 invokeCreateHooks
方法执行所有的 create 的钩子并把 vnode push 到 insertedVnodeQueue
中。最后调用 insert
方法把 DOM 插入到父节点中,因为是递归调用,子元素会优先调用 insert
,所以整个 vnode 树节点的插入顺序是先子后父。insert
方法定义在 src/core/vdom/patch.js
文件中,代码如下:
insert(parentElm, vnode.elm, refElm)
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (ref.parentNode === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}
读代码可知,insert
方法调用一些辅助方法把子节点插入到父节点中(其实就是调用原生 DOM 的 API 进行 DOM 操作),这些辅助方法定义在 src/platforms/web/runtime/node-ops.js
文件中。到此,Vue 动态创建的 DOM节点就完成了。emm~~ 回头在看看这个图。
结束
最近一段时间都会认真的去看vue.js的源码。【读vue 源码】会按照一个系列去更新。分享自己学习的同时,也希望与更多的同行交流所得,如此而已。
第一篇:【读vue 源码】溯源 import Vue from 'vue' 到底做了什么?
第二篇:【读vue源码】探究模版和数据是如何被渲染成DOM的? 【当前在读】