自我介绍:大家好,我是吉帅振的网络日志;微信公众号:吉帅振的网络日志;前端开发工程师,工作4年,去过上海、北京,经历创业公司,进过大厂,现在郑州敲代码。
Vue.js 3.0源码专栏
【Vue.js 3.0源码】一文看懂Vue.js 3.0的优化
【Vue.js 3.0源码】直击Vue核心的实现之组件渲染vnode到真实DOM
一、前言
相信作为一个 Vue.js 的开发者,最熟悉的应该就是组件了,我们开发 Vue.js 的项目,大部分时间都是在写组件,组件系统是 Vue.js 的一个重要概念,它是一种对 DOM 结构的抽象,我们可以使用小型、独立和通常可复用的组件构建大型应用。仔细想想,几乎任意类型的应用界面都可以抽象为一个组件树,如下:

组件化也是 Vue.js 的核心思想之一,它允许我们用模板加对象描述的方式去创建一个组件,再加上我们给组件注入不同的数据,就可以完整地渲染出组件。当数据更新后,组件可以自动重新渲染,因此用户只需要专注于数据逻辑的处理,而无须关心 DOM 的操作,无论是开发体验和开发效率都得到了很大的提升。
短短几行代码,就可以构建庞大的组件结构,这一切都是 Vue.js 框架的功劳。那它究竟是怎么做到的呢?
二、组件渲染 vnode 到真实 DOM
const app = ensureRenderer().createApp(...args)
其中 ensureRenderer() 用来创建一个渲染器对象,它的内部代码是这样的:
// 渲染相关的一些配置,比如更新属性的方法,操作 DOM 的方法
const rendererOptions = {
patchProp,
...nodeOps
}
let renderer
// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
function ensureRenderer() {
return renderer || (renderer = createRenderer(rendererOptions))
}
function createRenderer(options) {
return baseCreateRenderer(options)
}
function baseCreateRenderer(options) {
function render(vnode, container) {
// 组件渲染的核心逻辑
}
return {
render,
createApp: createAppAPI(render)
}
}
function createAppAPI(render) {
// createApp createApp 方法接受的两个参数:根组件的对象和 prop
return function createApp(rootComponent, rootProps = null) {
const app = {
_component: rootComponent,
_props: rootProps,
mount(rootContainer) {
// 创建根组件的 vnode
const vnode = createVNode(rootComponent, rootProps)
// 利用渲染器渲染 vnode
render(vnode, rootContainer)
app._container = rootContainer
return vnode.component.proxy
}
}
return app
}
}
可以看到,这里先用 ensureRenderer() 来延时创建渲染器,这样做的好处是当用户只依赖响应式包的时候,就不会创建渲染器,因此可以通过 tree-shaking 的方式移除核心渲染逻辑相关的代码。这里涉及了渲染器的概念,它是为跨平台渲染做准备的,之后我会在自定义渲染器的相关内容中详细说明。在这里,你可以简单地把渲染器理解为包含平台渲染核心逻辑的 JavaScript 对象。
我们结合上面的代码继续深入,在 Vue.js 3.0 内部通过 createRenderer 创建一个渲染器,这个渲染器内部会有一个 createApp 方法,它是执行 createAppAPI 方法返回的函数,接受了 rootComponent 和 rootProps 两个参数,我们在应用层面执行 createApp(App) 方法时,会把 App 组件对象作为根组件传递给 rootComponent。这样,createApp 内部就创建了一个 app 对象,它会提供 mount 方法,这个方法是用来挂载组件的。
在整个 app 对象创建过程中,Vue.js 利用闭包和函数柯里化的技巧,很好地实现了参数保留。比如,在执行 app.mount 的时候,并不需要传入渲染器 render,这是因为在执行 createAppAPI 的时候渲染器 render 参数已经被保留下来了。
根据前面的分析,我们知道 createApp 返回的 app 对象已经拥有了 mount 方法了,但在入口函数中,接下来的逻辑却是对 app.mount 方法的重写。先思考一下,为什么要重写这个方法,而不把相关逻辑放在 app 对象的 mount 方法内部来实现呢?因为 Vue.js 不仅仅是为 Web 平台服务,它的目标是支持跨平台渲染,而 createApp 函数内部的 app.mount 方法是一个标准的可跨平台的组件渲染流程:
mount(rootContainer) {
// 创建根组件的 vnode
const vnode = createVNode(rootComponent, rootProps)
// 利用渲染器渲染 vnode
render(vnode, rootContainer)
app._container = rootContainer
return vnode.component.proxy
}
标准的跨平台渲染流程是先创建 vnode,再渲染 vnode。此外参数 rootContainer 也可以是不同类型的值,比如,在 Web 平台它是一个 DOM 对象,而在其他平台(比如 Weex 和小程序)中可以是其他类型的值。所以这里面的代码不应该包含任何特定平台相关的逻辑,也就是说这些代码的执行逻辑都是与平台无关的。因此我们需要在外部重写这个方法,来完善 Web 平台下的渲染逻辑。
接下来,我们再来看 app.mount 重写都做了哪些事情:
app.mount = (containerOrSelector) => {
// 标准化容器
const container = normalizeContainer(containerOrSelector)
if (!container)
return
const component = app._component
// 如组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容
if (!isFunction(component) && !component.render && !component.template) {
component.template = container.innerHTML
}
// 挂载前清空容器内容
container.innerHTML = ''
// 真正的挂载
return mount(container)
}
首先是通过 normalizeContainer 标准化容器(这里可以传字符串选择器或者 DOM 对象,但如果是字符串选择器,就需要把它转成 DOM 对象,作为最终挂载的容器),然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容;接着在挂载前清空容器内容,最终再调用 app.mount 的方法走标准的组件渲染流程。
三、总结
在这里,重写的逻辑都是和 Web 平台相关的,所以要放在外部实现。此外,这么做的目的是既能让用户在使用 API 时可以更加灵活,也兼容了 Vue.js 2.x 的写法,比如 app.mount 的第一个参数就同时支持选择器字符串和 DOM 对象两种类型。