自我介绍:大家好,我是吉帅振的网络日志;微信公众号:吉帅振的网络日志;前端开发工程师,工作4年,去过上海、北京,经历创业公司,进过大厂,现在郑州敲代码。
一、前言:
相信作为一个 Vue.js 的开发者,最熟悉的应该就是组件了,我们开发 Vue.js 的项目,大部分时间都是在写组件,组件系统是 Vue.js 的一个重要概念,它是一种对 DOM 结构的抽象,我们可以使用小型、独立和通常可复用的组件构建大型应用。仔细想想,几乎任意类型的应用界面都可以抽象为一个组件树,如下:
组件化也是 Vue.js 的核心思想之一,它允许我们用模板加对象描述的方式去创建一个组件,再加上我们给组件注入不同的数据,就可以完整地渲染出组件。当数据更新后,组件可以自动重新渲染,因此用户只需要专注于数据逻辑的处理,而无须关心 DOM 的操作,无论是开发体验和开发效率都得到了很大的提升。
短短几行代码,就可以构建庞大的组件结构,这一切都是 Vue.js 框架的功劳。那它究竟是怎么做到的呢?
二、组件渲染 vnode 到真实 DOM
- 创建 app 对象 首先,我们使用 ensureRenderer().createApp() 来创建 app 对象 :
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 参数已经被保留下来了。
- 重写 app.mount 方法
根据前面的分析,我们知道 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 对象两种类型。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。