Vue2.6.0源码阅读(五):挂载及编译部分
初始化结束后,如果存在el
属性,那么最后会进行挂载操作:
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
$mount
方法是个区分平台的方法,web
平台的如下:
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el,
hydrating
) {
el = el && query(el)
const options = this.$options
// 解析 template/el,编译成渲染函数
if (!options.render) {
// 分情况获取模板
let template = options.template
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
return this
}
} else if (el) {
template = getOuterHTML(el)
}
if (template) {
// 编译模板为渲染函数
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
}
}
return mount.call(this, el, hydrating)
}
Vue
的模板是需要编译成渲染函数的,所以$mount
的主要逻辑就是判断是否存在字符串模板,存在的话才会调用编译方法,否则如果已经是渲染函数了,那么直接调用mount
方法:
Vue.prototype.$mount = function (
el,
hydrating
) {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
接下来先看一下比较简单的mountComponent
方法。
挂载组件mountComponent
export function mountComponent (
vm,
el,
hydrating
) {
vm.$el = el
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
}
callHook(vm, 'beforeMount')
// 更新组件的方法
let updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 我们把Watcher实例设置为vm._watcher属性值这个逻辑放在watcher的构造函数中,因为watcher的初始补丁可能调用$forceUpdate(例如,在子组件的挂载钩子中),这需要依赖于vm._watcher先被定义
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
}
render
渲染函数其实就是一个返回虚拟DOM
的函数,不存在的话会默认定义为一个返回空虚拟DOM
的函数。接下来定义了一个updateComponent
方法,这个方法是真正的挂载方法,会在初始化和更新的时候调用。接着创建了一个Watcher
实例,Watcher
实例化时会执行取值函数,也就是会执行上述的updateComponent
方法,这个方法很明显先执行渲染函数生成VNode
,然后通过diff
算法进行打补丁更新页面,这样组件就渲染完成了,当后续模板中依赖的数据变化了,那么会通知该watcher
,该watcher
会重新调用取值函数,也就是会再次调用updateComponent
方法,达到页面更新。
编译渲染函数
接下来看看模板编译为渲染函数的过程,首先声明,这个过程是非常复杂的,所以我们不会太深入细节(因为我看!不!懂!),只会大概看一下流程是怎么样的。
首先执行的是compileToFunctions
方法:
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,// 是否会编码换行符,IE在属性值内会编码换行符,而其他浏览器不编码
shouldDecodeNewlinesForHref,// 是否会在href属性中编码换行符
delimiters: options.delimiters,// 改变纯文本插入分隔符,默认为['{{', '}}'],你可以改成比如['${', '}']
comments: options.comments// 当设为 true 时,将会保留且渲染模板中的 HTML 注释。默认行为是舍弃它们
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
传入了模板和配置项。
import { baseOptions } from './options'
import { createCompiler } from '../../../compiler/index'
const { compile, compileToFunctions } = createCompiler(baseOptions)
export { compile, compileToFunctions }
可以看到compileToFunctions
方法是createCompiler
函数返回的,它接受一个配置对象:
export const createCompiler = createCompilerCreator(function baseCompile () { })
createCompiler
方法又是执行createCompilerCreator
函数返回的:
export function createCompilerCreator (baseCompile) {
return function createCompiler (baseOptions) {
function compile (template, options) {}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
而真正的compileToFunctions
又是通过createCompileToFunctionFn
函数返回的,怎么样,头有没有晕?让我们画图看一下:
Vue
设计的这么复杂肯定是有原因的,不妨来模拟一下。
模拟设计思路
最开始我们只写了一个complie
方法,接收模板和选项,返回编译结果,结果是一个代码字符串,类似这样的:
with(this){return _c('ul',_l((list),function(item){return _c('li',[_v(_s(item))])}),0)}
只生成字符串还不够,我们还需要将它转换成可执行的函数,于是我们又写了一个createCompileToFunctionFn
方法,它接收compile
作为参数,返回一个compileToFunctions
函数。现在是这样的:
const compile = (template, options) => {}
const compileToFunctions = createCompileToFunctionFn(compile)
compileToFunctions(template, options)
一切正常,然而有一天我们发现有一些基础选项baseOptions
也需要传给compile
函数,而且这些基础选项也不是固定的,可能不同的平台需要传不同的,我们肯定不能写死在compile
函数里,通过参数传递的话那么每个使用compile
的地方都得引入这个参数并传给它,显然不行,那么我们可以通过偏函数
的方式来接收这个参数并返回compile
:
const createCompiler = (baseOptions) => {
const compile = (template, options) => {
console.log(baseOptions)
}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
const { compileToFunctions } = createCompiler(baseOptions)
compileToFunctions(template, options)
喜大普奔,可是万万没想到又有一天我们发现核心的编译功能也要区分平台,而且和baseOptions
还不太一样,同一个编译器可以接收不同的选项,怎么办,一不做二不休,我们给createCompiler
再包一层:
const createCompilerCreator = (baseCompile) => {
return const createCompiler = (baseOptions) => {
const compile = (template, options) => {
console.log(baseCompile, baseOptions)
}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
const createCompiler = createCompilerCreator(baseCompile)
// 选项1
const { compileToFunctions } = createCompiler(baseOptions)
// 选项2
const { compileToFunctions } = createCompiler(baseOptions2)
compileToFunctions(template, options)
终于完美了,既具有扩展性,又消灭了重复,到这里,你应该就理解为什么Vue
要这么设计了。
详解编译流程
compileToFunctions
函数是createCompileToFunctionFn
函数生成的,简化后的代码如下:
export function createCompileToFunctionFn (compile) {
const cache = Object.create(null)
return function compileToFunctions (
template,
options,
vm
) {
options = extend({}, options
// 检查缓存
const key = options.delimiters
? String(options.delimiters) + template
: template
if (cache[key]) {
return cache[key]
}
// 编译
const compiled = compile(template, options)
// 将代码转换为函数
const res = {}
res.render = createFunction(compiled.render)
res.staticRenderFns = compiled.staticRenderFns.map(code => {
return createFunction(code)
})
return (cache[key] = res)
}
}
Vue
的缓存使用真是无处不在,这个函数的主要功能就是调用compile
函数进行编译,返回代码字符串,然后通过createFunction
转换成函数:
function createFunction (code) {
try {
return new Function(code)
} catch (err) {
return noop
}
}
很简单,就是使用了new Function
。
接下来看compile
的实现:
function compile (
template,
options
) {
const finalOptions = Object.create(baseOptions)
// 错误信息
const errors = []
// 提示信息
const tips = []
let warn = (msg, range, tip) => {
(tip ? tips : errors).push(msg)
}
if (options) {
// merge custom modules
// 合并自定义模块
if (options.modules) {
finalOptions.modules =
(baseOptions.modules || []).concat(options.modules)
}
// merge custom directives
// 合并自定义指令
if (options.directives) {
finalOptions.directives = extend(
Object.create(baseOptions.directives || null),
options.directives
)
}
// copy other options
// 复制其他选项
for (const key in options) {
if (key !== 'modules' && key !== 'directives') {
finalOptions[key] = options[key]
}
}
}
finalOptions.warn = warn
// 调用基础编译器进行编译
const compiled = baseCompile(template.trim(), finalOptions)
compiled.errors = errors
compiled.tips = tips
return compiled
}
这个函数的实现也很清晰,先处理及合并参数,然后调用baseCompile
进行编译。
function baseCompile (
template,
options
) {
// 解析成ast
const ast = parse(template.trim(), options)
// 优化
if (options.optimize !== false) {
optimize(ast, options)
}
// 生成代码
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
可以看到Vue
的模板编译的核心操作就是三步:把模板解析为抽象语法树、优化、生成代码字符串,这和普通的编译操作是一致的。
总结一下,模板编译首先会把模板字符串编译为抽象语法树,然后进行优化,生成代码字符串,最后再将代码字符串转换成可执行的函数,这些函数的产出就是VNode
。
parse
的过程太复杂了,完全看不懂,跳过,我们来看看优化的过程:
export function optimize (root, options) {
if (!root) return
isStaticKey = genStaticKeys(options.staticKeys || '')
// 判断是否是保留标签,比如html、svg标签
isPlatformReservedTag = options.isReservedTag || no
// 第一遍处理:标记所有非静态节点
markStatic(root)
// 第二遍:标记静态根节点
markStaticRoots(root, false)
}
优化的目的是检测出AST
中的静态子树,这样在每次重新渲染时就可以不用再重新创建新节点,在打补丁的时候也可以完全跳过。
genStaticKeys
方法会返回一个函数,用来检测某个key
是否是静态的key
:
function genStaticKeys (keys) {
return makeMap(
'type,tag,attrsList,attrsMap,plain,parent,children,attrs,start,end,rawAttrsMap' +
(keys ? ',' + keys : '')
)
}
AST
树root
的结构大致如下:
第一次遍历会标记出所有的静态节点:
function markStatic (node) {
node.static = isStatic(node)
if (node.type === 1) {
// 不要将组件插槽内容设置为静态,用来避免:
// 1.组件无法改变插槽节点
// 2.静态插槽内容无法进行热更新加载
if (
!isPlatformReservedTag(node.tag) &&
node.tag !== 'slot' &&
node.attrsMap['inline-template'] == null
) {
return
}
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i]
markStatic(child)
// 有一个子节点不是静态节点,那么该节点就不是静态节点
if (!child.static) {
node.static = false
}
}
// 存在v-if
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
const block = node.ifConditions[i].block
markStatic(block)
if (!block.static) {
node.static = false
}
}
}
}
}
静态节点的判断依据还是挺复杂的,直接看代码:
function isStatic (node) {
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 && // 没有 v-if 、 v-for 、 v-else
!isBuiltInTag(node.tag) && // not a built-in 不是内置标签
isPlatformReservedTag(node.tag) && // not a component 不是组件
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey)
))
}
第二遍会找出静态根:
function markStaticRoots (node, isInFor) {
if (node.type === 1) {
if (node.static || node.once) {
node.staticInFor = isInFor
}
// 使节点符合静态根的条件,它应该有不仅仅是静态文本的子级,
// 否则,花费的成本将超过收益,还不如直接刷新渲染。
if (node.static && node.children.length && !(
node.children.length === 1 &&
node.children[0].type === 3
)) {
node.staticRoot = true
return
} else {
node.staticRoot = false
}
if (node.children) {
for (let i = 0, l = node.children.length; i < l; i++) {
markStaticRoots(node.children[i], isInFor || !!node.for)
}
}
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
markStaticRoots(node.ifConditions[i].block, isInFor)
}
}
}
}
可以看到如果一个节点是静态节点、存在子节点,且子节点不能只有一个静态文本,那么就可以把它视为是静态根。这部分优化凑活看吧,反正笔者看的云里雾里。
生成代码generate
也是一个很复杂的过程,同样也跳过。
将模板编译为渲染函数后,就会调用前面介绍的mount
方法进行挂载。
挂载详解
挂载前面介绍过会通过渲染watcher
来触发,也就是执行下面这个函数:
let updateComponent = () => {
vm._update(vm._render(), hydrating)
}
很明显会先执行_render
方法,来看一下:
Vue.prototype._render = function () {
const vm = this
const { render, _parentVnode } = vm.$options
// 存在父VNode
if (_parentVnode) {
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots,
vm.$slots
)
}
// 设置父vnode。这允许渲染函数访问占位符节点上的数据。
vm.$vnode = _parentVnode
// 执行render方法生成虚拟DOM
let vnode = render.call(vm._renderProxy, vm.$createElement)
// 如果返回的数组只包含一个节点,请允许它
if (Array.isArray(vnode) && vnode.length === 1) {
vnode = vnode[0]
}
// 设置父节点
vnode.parent = _parentVnode
return vnode
}
这个函数的核心就是执行了render
函数,也就是上一步模板编译生成的,其他一些细节目前还不太好理解,后续如果遇到了对应的场景再说。
生成了VNode
后会执行_update
方法:
Vue.prototype._update = function (vnode, hydrating) {
const vm = this
const prevEl = vm.$el
// 旧的vnode
const prevVnode = vm._vnode
// 将该vm标记为当前活跃的实例
const restoreActiveInstance = setActiveInstance(vm)
// 新生成的vnode
vm._vnode = vnode
// Vue.prototype.__patch__ 是在入口处根据所使用的平台来注入的
if (!prevVnode) {
// 第一次渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 后续更新
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// 更新 __vue__ 引用
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// 如果父项是HOC(高阶组件),则也更新其$el
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
// 调度程序调用更新的钩子,以确保在父级的更新钩子中更新子级。
}
这个函数的核心是调用了__patch__
方法来打补丁,也就是第一次渲染时根据vnode
来生成dom
,后续通过vnode
的diff
操作来更新dom
,这部分的详细内容后面会单独进行介绍。
总结一下本文的内容,当第一次创建一个Vue
实例时,如果存在模板,那么会进行编译操作,编译操作核心就是三步:将模板编译为AST
、优化AST
、根据AST
生成代码,最后会编译为渲染函数,然后会给该实例创建一个渲染watcher
,初始化时会调用更新函数,也就是先调用render
方法生成VNode
,然后调用__patch__
方法来生成实际的DOM
。
当然,本文只介绍了基本的流程,具体的细节都跳过了,有能力的朋友可以深入了解一下。
- 点赞
- 收藏
- 关注作者
评论(0)