时间:2021-04-16 08:26:36 | 栏目:vue | 点击:次
本文主要是讲述 Vue.js 3.0 中一个组件是如何转变为页面中真实 DOM 节点的。对于任何一个基于 Vue.js 的应用来说,一切的故事都要从应用初始化「根组件(通常会命名为 APP)挂载到 HTML 页面 DOM 节点(根组件容器)上」说起。所以,我们可以从应用的根组件为切入点。
主线思路:聚焦于一个组件是如何转变为 DOM 的。
辅助思路:
应用初始化
在 Vue.js 3.0 中,初始化一个应用的方式和 Vue.js 2.x 有差别但是差别不大(本质上都是把 App 组件挂载到 id 为 app 的 DOM 节点上),在 Vue.js 3.0 中用法如下:
import { createApp } from 'vue' import App from './app' const app = createApp(App) app.mount('#app')
createApp 简化版源码
// packages/runtime-dom/src/index.ts // 创建应用 const createApp = ((...args) => { // 1. 创建 app 对象 const app = ensureRenderer().createApp(...args) const { mount } = app // 2. 重写 mount 方法 app.mount = (containerOrSelector) => { // ... } return app })
createApp 方法中主要做了两件事:
接下来会分别看一下这两个过程都做了什么事情。
创建 app 对象
从 ensureRenderer() 着手。在 Vue.js 3.0 中有一个「渲染器」的概念,我们先对渲染器有一个初步的印象:**渲染器可以用于跨平台渲染,是一个包含了平台渲染核心逻辑的 JavaScript 对象。**接下来,我们通过简化版源码来验证这个结论:
// packages/runtime-dom/src/index.ts // 定义渲染器变量 let renderer // 创建一个渲染器对象 // 惰性创建渲染器(当用户只依赖响应式包的时候可以通过 tree-shaking 的方式移除核心渲染逻辑相关的代码) function ensureRenderer() { return renderer || (renderer = createRenderer(rendererOptions)) } // packages/runtime-core/src/renderer.ts export function createRenderer(options) { return baseCreateRenderer(options) } // 创建不同平台渲染器的函数,在其内部都会调用 baseCreateRenderer function baseCreateRenderer(options, createHydrationFns) { // 一系列内部函数 const render = (vnode, container) => { // 组件渲染的核心逻辑 } // 返回渲染器对象 return { render, hydrate, createApp: createAppAPI(render, hydrate) } }
可以看出渲染器最终由 baseCreateRenderer 函数生成,是一个包含 render 和createApp 函数的 JS 对象。其中 createApp 函数是由 createAppAPI 函数返回的。那 createApp 接收的参数有哪些呢?为了寻求答案,我们需要看一下 createAppAPI 做了什么事情。
// packages/runtime-core/src/apiCreateApp.ts // 接收一个渲染器 render 作为参数,接收一个可选参数 hydrate,返回一个用于创建 app 的函数 export function createAppAPI(render, hydrate) { // createApp 接收两个参数:根组件对象和根组件的prop return function createApp(rootComponent, rootProps = null) { const context = createAppContext() const app: App = (context.app = { _uid: uid++, _component: rootComponent, _props: rootProps, _container: null, _context: context, version, get config() {}, set config(v) {}, use(plugin: Plugin, ...options: any[]) {}, mixin(mixin: ComponentOptions) {}, component(name: string, component?: Component): any {}, directive(name: string, directive?: Directive) {}, mount(rootContainer: HostElement, isHydrate?: boolean): any { // 创建根组件的 vnode const vnode = createVNode(rootComponent, rootProps) // 利用函数参数传入的渲染器渲染 vnode render(vnode, rootContainer) app._container = rootContainer return vnode.component.proxy }, unmount() {}, provide(key, value) {} } return app } }
渲染器对象的 createApp 方法接收两个参数:根组件对象和根组件的prop。这和应用初始化 demo 中 createApp(App) 的使用方式是吻合的。还可以看到的是:createApp 返回的 app 对象在最初定义时包含了 _uid 、 use 、 mixin 、 component 、mount 等属性。
此时,我们可以得出结论:在应用层调用的 createApp 方法内部,首先会生成一个渲染器,然后调用渲染器的 createApp 方法创建 app 对象。app 对象中具有一系列我们在日常开发应用时已经很熟悉的属性。
在应用层调用的 createApp 方法内部创建好 app 对象后,接下来便是对 app.mount 方法重写。
重写 app.mount 方法
先看一下简化版的 app.mount 源码:
// packages/runtime-dom/src/index.ts const { mount } = app app.mount = (containerOrSelector): any => { // 1. 标准化容器(将传入的 DOM 对象或者节点选择器统一为 DOM 对象) const container = normalizeContainer(containerOrSelector) if (!container) return const component = app._component // 2. 标准化组件(如果根组件不是函数,并且没有 render 函数和 template 模板,则把根组件 innerHTML 作为 template) if (!isFunction(component) && !component.render && !component.template) { component.template = container.innerHTML } // 3. 挂载前清空容器的内容 container.innerHTML = '' // 4. 执行渲染器创建 app 对象时定义的 mount 方法(在后文中称之为「标准 mount 函数」)来渲染根组件 const proxy = mount(container) return proxy }
浏览器平台 app.mount 方法重写主要做了 4 件事情:
此时可能会有人思考一个问题:为什么要重写app.mount 呢?答案是因为 Vue.js 需要支持跨平台渲染。
支持跨平台渲染的思路:不同的平台具有不同的渲染器,不同的渲染器中会调用标准的 baseCreateRenderer 来保证核心(标准)的渲染流程是一致的。
以浏览器端和服务端渲染的代码实现为例:
createApp 流程图
在分别了解了 创建 app 对象和重写 app.mount 过程后,我们来以整体的视角看一下 createApp 函数的实现:
目前为止,只是对应用的初始化有了一个初步的印象,但是还没有涉及到具体的组件渲染过程。可以看到根组件的渲染是在标准 mount 函数中进行的。所以接下来需要去深入了解标准 mount 函数。
标准 mount 函数
简化版源码
// packages/runtime-core/src/apiCreateApp.ts // createAppAPI 函数内部返回的 createApp 函数中定义了 app 对象,mount 函数是 app 对象的方法之一 mount(rootContainer, isHydrate) { // 1. 创建根组件的 vnode const vnode = createVNode(rootComponent, rootProps) // 2. 利用函数参数传入的渲染器渲染 vnode render(vnode, rootContainer) app._container = rootContainer return vnode.component.proxy },
createVNode 方法做了两件事:
vnode 大致可以理解为 Virtual DOM(虚拟 DOM)概念的一个具体实现,是用普通的 JS 对象来描述 DOM 对象。因为不是真实的 DOM 对象,所以叫做 Virtual DOM。
我们来一起看一下创建 vnode 和渲染 vnode 的具体过程。
创建 vnode:createVNode(rootComponent, rootProps)
简化版源码(已经把分支逻辑拿掉)
// packages/runtime-core/src/vnode.ts function _createVNode(type, props, children, patchFlag, dynamicProps, isBlockNode = false) { // 1. 对 VNodeTypes 或 ClassComponent 类型的 type 进行各种标准化处理:规范化 vnode、规范化 component、规范化 CSS 类和样式 // 2. 将 vnode 类型信息编码为位图 const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : __FEATURE_SUSPENSE__ && isSuspense(type) ? ShapeFlags.SUSPENSE : isTeleport(type) ? ShapeFlags.TELEPORT : isObject(type) ? ShapeFlags.STATEFUL_COMPONENT : isFunction(type) ? ShapeFlags.FUNCTIONAL_COMPONENT : 0 // 3. 创建 vnode 对象 const vnode = { __v_isVNode: true, [ReactiveFlags.SKIP]: true, type, // 把函数入参 type 赋值给 vnode props, children: null, component: null, staticCount: 0, shapeFlag, // 把 vnode 类型信息赋值给 vnode // 还有很多属性 } // 4. 标准化子节点 children normalizeChildren(vnode, children) return vnode }
createVNode 做了 4 件事
细心的同学会发现:在标准 mount 函数中执行 createVNode(rootComponent, rootProps) 时,参数是根组件 rootComponent 和根组件属性 rootProps,但是在 _createVNode 在定义时函数签名的前两个参数确实 type 和 props。rootComponent 与 type 的关系是什么呢?函数名为什么差了一个 _ 呢?
首先函数名的差异,是由于在定义函数时,基于代码运行环境做了一个判断:
export const createVNode = (__DEV__ ? createVNodeWithArgsTransform : _createVNode) as typeof _createVNode
其次,rootComponent 与 type 的关系我们可以从 type 的类型定义中得到答案:
function _createVNode( type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT, props: (Data & VNodeProps) | null = null ): VNode { }
当 createVNode把这 4 件事情做好后,会返回已经创建好 vnode,接下来做的事情是渲染 vnode。
渲染 vnode:render(vnode, rootContainer)
即使不看具体源码实现,我们其实大致可以用一句话总结出渲染 vnode 过程做了什么事情:把 vnode 转化为真实 DOM。
前文我们提过,**渲染器是一个包含了平台渲染核心逻辑的 JavaScript 对象。**渲染 vnode 正是通过调用渲染器的 render 方法做的。
// 返回渲染器对象 return { render, hydrate, createApp: createAppAPI(render, hydrate) }
我们来看一下 render 函数的定义(简化版源码):**
// packages/runtime-core/src/renderer.ts const render = (vnode, container) => { if (vnode == null) { // 如果 vnode 为 null,但是容器中有 vnode,则销毁组件 if (container._vnode) { unmount(container._vnode, null, null, true) } } else { // 创建或更新组件 patch(container._vnode || null, vnode, container) } // packages/runtime-core/src/scheduler.ts flushPostFlushCbs() // 缓存 vnode 节点(标识该 vnode 已经完成渲染) container._vnode = vnode }
抽象来看, render 做的事情是:如果传入的 vnode 为空,则销毁组件,否则就创建或者更新组件。其中有两个关键函数:patch 和 unmount(patch、unmount 和 render 都是在baseCreateRenderer函数内部的方法)。
可以从 patch 着手,看一下是如何将 vnode 转化为 DOM 的。
patch
// packages/runtime-core/src/renderer.ts const patch = ( n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false ) => { // 1. 如果是更新 vnode 并且新旧 vnode 类型不一致,则销毁旧的 vnode if (n1 && !isSameVNodeType(n1, n2)) { anchor = getNextHostNode(n1) unmount(n1, parentComponent, parentSuspense, true) n1 = null } // 2. 处理不同类型节点的渲染 const { type, ref, shapeFlag } = n2 switch (type) { case Text: // 处理文本节点 processText(n1, n2, container, anchor) break case Comment: // 处理注释节点 break case Static: // 处理静态节点 break case Fragment: // 处理 Fragment 元素(https://v3.vuejs.org/guide/migration/fragments.html#fragments) break default: if (shapeFlag & ShapeFlags.ELEMENT) { // 处理普通 DOM 元素 } else if (shapeFlag & ShapeFlags.COMPONENT) { // 处理组件 } else if (shapeFlag & ShapeFlags.TELEPORT) { // 处理 TELEPORT } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { // 处理 SUSPENSE } else if (__DEV__) { warn('Invalid VNode type:', type, `(${typeof type})`) } } }
patch 函数做了 2 件事情:
在 patch 函数的多个参数中,我们优先关注前 3 个参数:
以新建文本 DOM 节点为例,此时 n1 为 null,n2 类型为 Text,所以会走分支逻辑:processText(n1, n2, container, anchor)。processText 内部会去调用 hostCreateText 和 hostSetText。
hostCreateText 和 hostSetText 是从 baseCreateRenderer 函数入参 options 中解析出来的方法:
// packages/runtime-core/src/renderer.ts const { insert: hostInsert, remove: hostRemove, patchProp: hostPatchProp, forcePatchProp: hostForcePatchProp, createElement: hostCreateElement, createText: hostCreateText, createComment: hostCreateComment, setText: hostSetText, setElementText: hostSetElementText, parentNode: hostParentNode, nextSibling: hostNextSibling, setScopeId: hostSetScopeId = NOOP, cloneNode: hostCloneNode, insertStaticContent: hostInsertStaticContent } = options
来看看 options 是怎么来的:
// packages/runtime-core/src/renderer.ts // 在调用 baseCreateRenderer 时,传入了渲染参数 function baseCreateRenderer(options: RendererOptions) { }
还记得前文提到的我们在哪里调用了 baseCreateRenderer 吗?
// packages/runtime-dom/src/index.ts // 创建应用 const createApp = ((...args) => { // 1. 创建 app 对象 const app = ensureRenderer().createApp(...args) return app }) // packages/runtime-dom/src/index.ts const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps) function ensureRenderer() { return renderer || (renderer = createRenderer<Node, Element>(rendererOptions)) } // packages/runtime-core/src/renderer.ts export function createRenderer< HostNode = RendererNode, HostElement = RendererElement >(options: RendererOptions<HostNode, HostElement>) { return baseCreateRenderer<HostNode, HostElement>(options) }
可以看到在创建渲染器时,我们调用了 baseCreateRenderer 并传入了 rendererOptions。rendererOptions 的值为extend({ patchProp, forcePatchProp }, nodeOps)。
我们如果知道了 nodeOps 中的 createText、setText 等方法做了什么事情,就清楚了某一个确定类型的 vnode 是如何转变为 DOM 的。先看一下 nodeOps 的定义:
// packages/runtime-dom/src/nodeOps.ts export const nodeOps = { createText: text => doc.createTextNode(text), setText: (node, text) => {}, // 其他方法 }
此时已经非常接近问题的答案了,关键是看一下 doc 变量是什么:
const doc = (typeof document !== 'undefined' ? document : null) as Document
至此,我们知道了答案:先把组件转化为 vnode,针对特定类型的 vnode 执行不同的渲染逻辑,最终调用 document 上的方法将 vnode 渲染成 DOM。**抽象一下,从组件到渲染生成 DOM 需要经历 3 个过程:创建 vnode - 渲染 vnode - 生成 DOM。
在渲染 vnode 部分,我们以一个简单的 Text 类型的 vnode 为例来找到了答案。其实在 baseCreateRenderer 中有 30+ 个函数来处理不同类型的 vnode 的渲染。 比如:用来处理组件类型的 processComponent 函数、用来处理普通 DOM 元素类型的processElement 函数等。由于 vnode 是一个树形数据结构,在处理过程中还应用到了递归思想。建议感兴趣的同学自行查看。
总结
最后,我们来做个总结:
附录
Vue.js 中使用了哪些 DOM 的方法: