function patchElement(prevVNode, nextVNode, container) { /* ... */ }
function patchChildren( prevChildFlags, nextChildFlags, prevChildren, nextChildren, container ) { /* ... */ }
function patchText(prevVNode, nextVNode) { /* ... */ }
function patchFragment(prevVNode, nextVNode, container) { /* ... */ }
function patchPortal(prevVNode, nextVNode) { /* ... */ }
function patchComponent(prevVNode, nextVNode, container) { /* ... */ }
// en.wikipedia.org/wiki/Longes… function lis(arr) { /* ... */ }
观察如上代码结构,可以发现一个渲染器由两部分组成:`mount` 和 `patch`。在 `mount` 和 `patch` 中都会调用浏览器提供的 DOM 编程接口来完成真正的渲染工作。为了将浏览器提供的 DOM 编程接口与渲染器的代码分离,我们可以将如上代码封装到一个叫做 `createRenderer` 的函数中,如下代码所示:
export default function createRenderer(options) { function render(vnode, container) { /* ... */ }
// ========== 挂载 ==========
function mount(vnode, container, isSVG, refNode) { /* ... */ }
function mountElement(vnode, container, isSVG, refNode) { /* ... */ }
function mountText(vnode, container) { /* ... */ }
function mountFragment(vnode, container, isSVG) { /* ... */ }
function mountPortal(vnode, container) { /* ... */ }
function mountComponent(vnode, container, isSVG) { /* ... */ }
function mountStatefulComponent(vnode, container, isSVG) { /* ... */ }
function mountFunctionalComponent(vnode, container, isSVG) { /* ... */ }
// ========== patch ==========
function patch(prevVNode, nextVNode, container) { /* ... */ }
function replaceVNode(prevVNode, nextVNode, container) { /* ... */ }
function patchElement(prevVNode, nextVNode, container) { /* ... */ }
function patchChildren( prevChildFlags, nextChildFlags, prevChildren, nextChildren, container ) { /* ... */ }
function patchText(prevVNode, nextVNode) { /* ... */ }
function patchFragment(prevVNode, nextVNode, container) { /* ... */ }
function patchPortal(prevVNode, nextVNode) { /* ... */ }
function patchComponent(prevVNode, nextVNode, container) { /* ... */ }
return { render } }
// en.wikipedia.org/wiki/Longes… function lis(arr) { /* ... */ }
`createRenderer` 函数的返回值就是之前的 `render` 函数,也就是说调用 `createRenderer` 函数可以创建一个渲染器。`createRenderer` 函数接收一个参数 `options`,该参数的作用是为了允许外界有能力将操作元素的具体实现以选项的方式传递进来。
那么 `options` 参数中应该包含哪些选项呢?其实前面我们已经分析过了,只要是需要自定义的部分就应该作为选项传递进来,所以参数 `options` 中至少要包含两部分:一部分是元素的增加、删除、查找;另外一部分是元素的修改,即 `patchData` 函数。如下代码所示:
const { render } = createRenderer({ // nodeOps 是一个对象,该对象包含了所有用于操作节点的方法 nodeOps: { createElement() { /* ... */ }, createText() { /* ... */ } // more... }, patchData })
基于此,在 `createRenderer` 函数内部我们就可以通过解构的方式从 `options` 参数中得到具体的方法:
export default function createRenderer(options) { // options.nodeOps 选项中包含了本章开头罗列的所有操作 DOM 的方法 // options.patchData 选项就是 patchData 函数 const { nodeOps: { createElement: platformCreateElement, createText: platformCreateText, setText: platformSetText, // 等价于 Web 平台的 el.nodeValue appendChild: platformAppendChild, insertBefore: platformInsertBefore, removeChild: platformRemoveChild, parentNode: platformParentNode, nextSibling: platformNextSibling, querySelector: platformQuerySelector }, patchData: platformPatchData } = options
function render(vnode, container) { /* ... */ }
// ========== 挂载 ========== // 省略...
// ========== patch ========== // 省略...
return { render } }
如上代码所示,`options.nodeOps` 选项是一个对象,它包含了所有用于对元素进行增、删、查的操作,`options.patchData` 选项是一个函数,用于处理某个特定元素上的属性/特定,这些内容都是在创建渲染器时由外界来决定的。
接下来我们要做的就是将渲染器中原本使用了 Web 平台进行 DOM 操作的地方修改成使用通过解构得到的函数进行替代,例如在创建 DOM 元素时,原来的实现如下:
function mountElement(vnode, container, isSVG, refNode) { isSVG = isSVG || vnode.flags & VNodeFlags.ELEMENT_SVG const el = isSVG ? document.createElementNS('www.w3.org/2000/svg', vnode.tag) : document.createElement(vnode.tag) // 省略... }
现在我们应该使用 `platformCreateElement` 函数替代 `document.createElement(NS)`:
function mountElement(vnode, container, isSVG, refNode) { isSVG = isSVG || vnode.flags & VNodeFlags.ELEMENT_SVG const el = platformCreateElement(vnode.tag, isSVG) // 省略... }
类似的,其他所有涉及 DOM 操作的地方都应该使用这些通过解构得到的抽象接口替代。当这部分工作完成之后,接下来要做的就是对这些用于操作节点的抽象方法进行实现,如下代码所示,我们实现了 Web 平台下创建 DOM 节点的方法:
const { render } = createRenderer({ nodeOps: { createElement(tag, isSVG) { return isSVG ? document.createElementNS('www.w3.org/2000/svg', tag) : document.createElement(tag) } } })
再举一个例子,下面这条语句是我们之前实现的渲染器中用于移除旧 `children` 中节点的代码:
container.removeChild(prevChildren.el)
现在我们将之替换为 `platformRemoveChild` 函数:
platformRemoveChild(container, prevVNode.el)
为了让这段代码在 Web 平台正常工作,我们需要在创建渲染器时实现 `nodeOps.removeChild` 函数:
const { render } = createRenderer({ nodeOps: { createElement(tag, isSVG) { return isSVG ? document.createElementNS('www.w3.org/2000/svg', tag) : document.createElement(tag) }, removeChild(parent, child) { parent.removeChild(child) } } })
也许你已经想到了,当我们实现了所有 `nodeOps` 下的规定的抽象接口之后,实际上就完成了一个面向 Web 平台的渲染器,如下代码所示:
const { render } = createRenderer({ nodeOps: { createElement(tag, isSVG) { return isSVG ? document.createElementNS('www.w3.org/2000/svg', tag) : document.createElement(tag) }, removeChild(parent, child) { parent.removeChild(child) }, createText(text) { return document.createTextNode(text) }, setText(node, text) { node.nodeValue = text }, appendChild(parent, child) { parent.appendChild(child) }, insertBefore(parent, child, ref) { parent.insertBefore(child, ref) }, parentNode(node) { return node.parentNode }, nextSibling(node) { return node.nextSibling }, querySelector(selector) { return document.querySelector(selector) } } })
当然了,如上代码所创建的渲染器只能够完成 Web 平台中对 DOM 的增加、删除和查找的功能,为了能够修改 DOM 元素自身的属性和特性,我们还需要在创建渲染器时将 `patchData` 函数作为选项传递过去,好在我们之前已经封装了 `patchData` 函数,现在直接拿过来用即可:
import { patchData } from './patchData' const { render } = createRenderer({ nodeOps: { // 省略... }, patchData })
完整代码&在线体验地址:<https://codesandbox.io/s/web-render-6m73k>
以上我们就完成了对渲染器的抽象,使它成为一个平台无关的工具。并基于此实现了一个 Web 平台的渲染器,专门用于浏览器环境。
### 2.自定义渲染器的应用
`Vue3` 提供了一个叫做 `@vue/runtime-test` 的包,其作用是方便开发者在无 DOM 环境时有能力对组件的渲染内容进行测试,这实际上就是对自定义渲染器的应用。本节我们尝试来实现与 `@vue/runtime-test` 具有相同功能的渲染器。
原理其实很简单,如下代码所示,这是用于 Web 平台下创建真实 DOM 元素的代码:
const { render } = createRenderer({ nodeOps: { createElement(tag, isSVG) { return isSVG ? document.createElementNS('www.w3.org/2000/svg', tag) : document.createElement(tag) } } })
其中 `nodeOps.createElement` 函数会返回一个真实的 DOM 对象,在其内部调用的是浏览器为我们提供的 `document.createElement/NS` 函数。实际上 `nodeOps.createElement` 函数的真正意图是:**创建一个元素**,然而并没有规定这个元素应该由谁来创建,或这个元素应该具有什么样的特征,这就是自定义的核心所在。因此,我们完全使 `nodeOps.createElement` 函数返回一个普通对象来代指一个元素,后续的所有操作都是基于我们所规定的元素而进行,如下代码所示:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = { type: 'ELEMENT', tag } return customElement } } })
在这段代码中,我们自行规定了 `nodeOps.createElement` 函数所返回的元素的格式,即 `customElement` 对象,它包含两个属性,分别是 用来代表元素类型的 `type` 属性以及用来代表元素名称的 `tag` 属性。虽然看上去很奇怪,但这确实是一个完全符合要求的实现。这么做的结果就是:**`nodeOps.createElement` 函数所创建的元素不来自于浏览器的 DOM 编程接口,更不来自于任何其他平台的 API**,因此,如上代码所创建的渲染器也将是一个平台无关的渲染器。这就是为什么 `@vue/runtime-test` 可以运行在 `NodeJs` 中的原因。
当然了,如上代码中 `customElement` 只有两个属性,实际上这并不能满足需求,即使元素的格式由我们自行定义,但还是要有一定的限制,例如元素会有子节点,子节点也需要保存对父节点的引用,元素自身也会有属性/特性等等。一个最小且完整的元素定义应该包含以下属性:
const customElement = { type, // 元素的类型:ELEMENT ---> 标签元素;TEXT ---> 文本 tag, // 当 type === 'ELEMENT' 时,tag 属性为标签名字 parentNode, // 对父节点的引用 children, // 子节点 props, // 当 type === 'ELEMENT' 时,props 中存储着元素的属性/特性 eventListeners, // 当 type === 'ELEMENT' 时,eventListeners 中存储着元素的事件信息 text // 当 type === 'TEXT' 时,text 存储着文本内容 }
现在 `customElement` 就是一个能完全代替真实 DOM 对象的模拟实现了,我们用它修改之前的代码:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = { type: 'ELEMENT', tag, parentNode: null, children: [], props: {}, eventListeners: {}, text: null } return customElement } } })
如上代码所示,由于 `nodeOps.createElement` 函数用于创建元素节点,因此 `type` 属性的值为 `'ELEMENT'`;刚刚创建的元素还不能确定其父节点,因此 `parentNode` 为 `null`;用于存储子节点的 `children` 属性被初始化为一个数组,`props` 属性和 `eventListeners` 被初始化为空对象;最后的 `text` 为 `null`,因为它不是一个文本节点。
现在创建元素节点的功能已经实现,那么创建文本节点呢?如下:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = {/* 省略... */} return customElement }, createText(text) { const customElement = { type: 'TEXT', parentNode: null, text: text } return customElement } } })
文本元素的 `type` 类型值为 `'TEXT'`,`parentNode` 同样被初始化为 `unll`,`text` 属性存储着文本节点的内容。由于文本元素没有子节点、属性/特性、事件等信息,因此不需要其他描述信息。
文本节点与元素节点的创建都已经实现,接下来我们看看当元素被追加时应该如何处理,即 `nodeOps.appendChild` 函数的实现:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = {/* 省略... */} return customElement }, createText(text) { const customElement = {/* 省略... */} return customElement }, appendChild(parent, child) { // 简历父子关系 child.parentNode = parent parent.children.push(child) } } })
如上高亮代码所示,追加节点时我们要做的就是建立节点间正确的父子关系,在 Web 平台下,当我们调用 `el.appendChild` 函数时,父子关系是由浏览器负责建立的,但在模拟实现中,这个关系需要我们自己来维护。不过好在这很简单,让子元素的 `parentNode` 指向父元素,同时将子元素添加到父元素的 `children` 数组中即可。
类似的,如下是 `nodeOps.removeChild` 函数的实现:
const { render } = createRenderer({ nodeOps: { createElement(tag) {/* 省略... */}, createText(text) {/* 省略... */}, appendChild(parent, child) { // 简历父子关系 child.parentNode = parent parent.children.push(child) }, removeChild(parent, child) { // 找到将要移除的元素 child 在父元素的 children 中的位置 const i = parent.children.indexOf(child) if (i > -1) { // 如果找到了,则将其删除 parent.children.splice(i, 1) } else { // 没找到,说明渲染器出了问题,例如没有在 nodeOps.appendChild 函数中维护正确的父子关系等 // 这时需要打印错误信息,以提示开发者 console.error('target: ', child) console.error('parent: ', parent) throw Error('target 不是 parent 的子节点') } // 清空父子链 child.parentNode = null } } })
如上高亮代码所示,在移除节点时,思路也很简单,首先需要在父节点的 `children` 属性中查找即将要被移除的节点的位置索引,如果找到了,那么就直接将其从父节点的 `children` 数组中移除即可。如果没有找到则说明渲染器出问题了,例如在你实现自定义渲染器时没有在 `nodeOps.appendChild` 函数或 `nodeOps.insertBefore` 函数中维护正确的父子关系,这时我们需要打印错误信息以提示开发者。最后不要忘记清空父子链。
通过如上的讲解,你可能已经领会到了,我们所做的其实就是在模拟 Web 平台在操作元素时的行为,并且这个模拟的思路也及其简单。实际上,当我们实现了所有 `nodeOps` 下的抽象函数之后,那么这个类似于 `@vue/runtime-test` 的自定义渲染器就基本完成了。当然,不要忘记的是我们还需要实现 `patchData` 函数,这可能比你想象的要简单的多,如下高亮代码所示:
const { render } = createRenderer({ nodeOps: { createElement(tag) {/* 省略... */}, createText(text) {/* 省略... */}, appendChild(parent, child) {/* 省略... */}, removeChild(parent, child) {/* 省略... */} // 其他 nodeOps 函数的实现 }, patchData( el, key, prevValue, nextValue ) { // 将属性添加到元素的 props 对象下 el.props[key] = nextValue // 我们将属性名字中前两个字符是 'o' 和 'n' 的属性认为是事件绑定 if (key[0] === 'o' && key[1] === 'n') { // 如果是事件,则将事件添加到元素的 eventListeners 对象下 const event = key.slice(2).toLowerCase() ;(el.eventListeners || (el.eventListeners = {}))[event] = nextValue } } })
在创建渲染器时我们需要实现 `patchData` 函数的功能,它的功能是用来更新元素自身的属性/特性的,在之前的讲解中我们实现了 Web 平台中 `patchData` 函数,然而在这个模拟实现中,我们要做的事情就少了很多。只需要把元素的属性添加到元素的 `props` 对象中即可,同时如果是事件的话,我们也只需要将其添加到元素的 `eventListeners` 对象中就可以了。
实际上,本节我们所实现的自定义渲染器,就能够满足我们对组件测试的需求,我们可以利用它来测试组件所渲染内容的正确性。如果你想要进一步提升该自定义渲染器的能力,例如希望该渲染器有能力在控制台中打印出操作元素的信息,也很简单,我们以创建元素为例,如下代码所示:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = { type: 'ELEMENT', tag, parentNode: null, children: [], props: {}, eventListeners: {}, text: null }
console.table({
type: 'CREATE ELEMENT',
targetNode: customElement
})
return customElement
}
} })
只需要在 `nodeOps.createElement` 函数中调用 `console.table` 进行打印你想要的信息即可,例如我们打印了一个对象,该对象包含 `type` 属性用于指示当前操作元素的类型,所以对于创建元素来说,我们为 `type` 属性赋值了字符串 `'CREATE ELEMENT'`,同时将目标节点也打印了出来(即 `targetNode`)。类似的,追加节点可以打印如下信息:
const { render } = createRenderer({ nodeOps: { createElement(tag) {/* 省略... */}, appendChild(parent, child) { // 简历父子关系 child.parentNode = parent parent.children.push(child)
console.table({
type: 'APPEND',
targetNode: child,
parentNode: parent
})
}
} })
怎么样,是不是很简单。当然了这只是自定义渲染器的应用之一,对于自定义渲染器来说,它可发挥的空间还是非常大的,举几个例子:
* 渲染到 `PDF`,我们可以实现一个自定义渲染器如 `vue-pdf-renderer`,它能够将 `Vue` 组件渲染为 `PDF` 文件。
* 渲染到文件系统,我们可以实现一个 `vue-file-renderer`,它可以根据 `VNode` 的结构在本地渲染与该结构相同的文件目录。
* `canvas` 渲染器,我们可以实现一个 `vue-canvas-renderer`,它可以从渲染器的层面渲染 `canvas`,而非组件层面。
以上仅仅是简单的列了几个小想法,实际上由于自定义渲染器本身就是平台无关的,很多事情需要看特定平台的能力,渲染器为你提供的就是在组件层面的抽象能力以及虚拟 DOM 的更新算法,剩下的就靠社区的想象力和实现能力了。
完整代码&在线体验地址:<https://codesandbox.io/s/vue3-custom-render-tbv1e>
### 3.自定义canvas渲染
这里我们来自定义一个canvas渲染器,可以渲染常见的饼图,点击后增加一个其他。

首先创建一个组件描述要渲染的数据,我们想要渲染一个叫做piechart的组件,我们不需要单独声明该组件,因为我们只是想把它携带的数据绘制到canvas上。创建CanvasApp.vue
### 最后
你要问前端开发难不难,我就得说计算机领域里常说的一句话,这句话就是『**难的不会,会的不难**』,对于不熟悉某领域技术的人来说,因为不了解所以产生神秘感,神秘感就会让人感觉很难,也就是『难的不会』;当学会这项技术之后,知道什么什么技术能做到什么做不到,只是做起来花多少时间的问题而已,没啥难的,所以就是『会的不难』。
**我特地针对初学者整理一套前端学习资料**


**开源分享:https://docs.qq.com/doc/DSmRnRGxvUkxTREhO**