vue3.0中的自定义渲染器(转载)

Vue3.0中支持 自定义渲染器 (Renderer):这个 API 可以用来自定义渲染逻辑。它可以将 Virtual DOM 渲染为 Web 平台的真实 DOM。(在以往像weex和mpvue,需要通过fork源码的方式进行扩展)。

1.自定义渲染器的原理

渲染器是围绕 Virtual DOM 而存在的,在 Web 平台下它能够把 Virtual DOM 渲染为浏览器中的真实 DOM 对象,通过前面几章的讲解,相信你已经能够认识到渲染器的实现原理,为了能够将 Virtual DOM 渲染为真实 DOM,渲染器内部需要调用浏览器提供的 DOM 编程接口,下面罗列了在出上一章中我们曾经使用到的那些浏览器为我们提供的 DOM 编程接口:

  • document.createElement / createElementNS:创建标签元素。
  • document.createTextNode:创建文本元素。
  • el.nodeValue:修改文本元素的内容。
  • el.removeChild:移除 DOM 元素。
  • el.insertBefore:插入 DOM 元素。
  • el.appendChild:追加 DOM 元素。
  • el.parentNode:获取父元素。
  • el.nextSibling:获取下一个兄弟元素。
  • document.querySelector:挂载 Portal 类型的 VNode 时,用它查找挂载点。

这些 DOM 编程接口完成了 Web 平台(或者说浏览器)下对 DOM 的增加、删除、查找的工作,它是 Web 平台独有的,所以如果渲染器自身强依赖于这些方法(函数),那么这个渲染器也只能够运行在浏览器中,它不具备跨平台的能力。换句话说,如果想要实现一个平台无关的渲染器,那么渲染器自身必须不能强依赖于任何一个平台下特有的接口,而是应该提供一个抽象层,将 “DOM” 的增加、删除、查找等操作使用抽象接口实现,具体到某个平台下时,由开发者决定如何使用该平台下的接口实现这个抽象层,这就是自定义渲染器的本质。

渲染器除了负责对元素的增加、删除、查找之外,它还负责修改某个特定元素自身的属性/特性,例如 Web 平台中元素具有 idhref 等属性/特性。在上一章中,我们使用 patchData 函数来完成元素自身属性/特性的更新,如下代码用于修改一个元素的类名列表(class):

// patchData.js
case 'class':
  el.className = nextValue
  break

 
 
 
 
  • 1
  • 2
  • 3
  • 4

这段代码同样也只能运行在浏览器中,为了渲染器能够跨平台,那么修改一个元素自身的属性/特性的工作也应该作为可自定义的一部分才行,因此,一个跨平台的渲染器应该至少包含两个可自定义的部分:可自定义元素的增加、删除、查找等操作可自定义元素自身属性/特性的修改操作。这样对于任何一个元素来说,它的增删改查都已经变成了可自定义的部分,我们只需要“告知”渲染器在对元素进行增删改查时应该做哪些具体的操作即可。

接下来我们就着手将一个普通渲染器修改为拥有自定义能力的渲染器,在之前的讲解中,我们将渲染器的代码存放在了 render.js 文件中,如下是整个 render.js 文件的核心代码:

// 导出渲染器
export default 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) { /* … */ }

// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function lis(arr) { /* … */ }

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47

观察如上代码结构,可以发现一个渲染器由两部分组成:mountpatch。在 mountpatch 中都会调用浏览器提供的 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 }
}

// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function lis(arr) { /* … */ }

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50

createRenderer 函数的返回值就是之前的 render 函数,也就是说调用 createRenderer 函数可以创建一个渲染器。createRenderer 函数接收一个参数 options,该参数的作用是为了允许外界有能力将操作元素的具体实现以选项的方式传递进来。

那么 options 参数中应该包含哪些选项呢?其实前面我们已经分析过了,只要是需要自定义的部分就应该作为选项传递进来,所以参数 options 中至少要包含两部分:一部分是元素的增加、删除、查找;另外一部分是元素的修改,即 patchData 函数。如下代码所示:

const { render } = createRenderer({
  // nodeOps 是一个对象,该对象包含了所有用于操作节点的方法
  nodeOps: {
    createElement() { /* ... */ },
    createText() { /* ... */ }
    // more...
  },
  patchData
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

基于此,在 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 }
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

如上代码所示,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('http://www.w3.org/2000/svg', vnode.tag)
    : document.createElement(vnode.tag)
  // 省略...
}

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

现在我们应该使用 platformCreateElement 函数替代 document.createElement(NS)

function mountElement(vnode, container, isSVG, refNode) {
  isSVG = isSVG || vnode.flags & VNodeFlags.ELEMENT_SVG
  const el = platformCreateElement(vnode.tag, isSVG)
  // 省略...
}

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5

类似的,其他所有涉及 DOM 操作的地方都应该使用这些通过解构得到的抽象接口替代。当这部分工作完成之后,接下来要做的就是对这些用于操作节点的抽象方法进行实现,如下代码所示,我们实现了 Web 平台下创建 DOM 节点的方法:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag, isSVG) {
      return isSVG
        ? document.createElementNS('http://www.w3.org/2000/svg', tag)
        : document.createElement(tag)
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

再举一个例子,下面这条语句是我们之前实现的渲染器中用于移除旧 children 中节点的代码:

container.removeChild(prevChildren.el)

 
 
 
 
  • 1

现在我们将之替换为 platformRemoveChild 函数:

platformRemoveChild(container, prevVNode.el)

 
 
 
 
  • 1

为了让这段代码在 Web 平台正常工作,我们需要在创建渲染器时实现 nodeOps.removeChild 函数:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag, isSVG) {
      return isSVG
        ? document.createElementNS('http://www.w3.org/2000/svg', tag)
        : document.createElement(tag)
    },
    removeChild(parent, child) {
      parent.removeChild(child)
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

也许你已经想到了,当我们实现了所有 nodeOps 下的规定的抽象接口之后,实际上就完成了一个面向 Web 平台的渲染器,如下代码所示:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag, isSVG) {
      return isSVG
        ? document.createElementNS('http://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)
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

当然了,如上代码所创建的渲染器只能够完成 Web 平台中对 DOM 的增加、删除和查找的功能,为了能够修改 DOM 元素自身的属性和特性,我们还需要在创建渲染器时将 patchData 函数作为选项传递过去,好在我们之前已经封装了 patchData 函数,现在直接拿过来用即可:

import { patchData } from './patchData'
const { render } = createRenderer({
  nodeOps: {
    // 省略...
  },
  patchData
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

完整代码&在线体验地址: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('http://www.w3.org/2000/svg', tag)
        : document.createElement(tag)
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

其中 nodeOps.createElement 函数会返回一个真实的 DOM 对象,在其内部调用的是浏览器为我们提供的 document.createElement/NS 函数。实际上 nodeOps.createElement 函数的真正意图是:创建一个元素,然而并没有规定这个元素应该由谁来创建,或这个元素应该具有什么样的特征,这就是自定义的核心所在。因此,我们完全使 nodeOps.createElement 函数返回一个普通对象来代指一个元素,后续的所有操作都是基于我们所规定的元素而进行,如下代码所示:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {
        type: 'ELEMENT',
        tag
      }
      return customElement
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

在这段代码中,我们自行规定了 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 存储着文本内容
}

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

现在 customElement 就是一个能完全代替真实 DOM 对象的模拟实现了,我们用它修改之前的代码:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {
        type: 'ELEMENT',
        tag,
        parentNode: null,
        children: [],
        props: {},
        eventListeners: {},
        text: null
      }
      return customElement
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

如上代码所示,由于 nodeOps.createElement 函数用于创建元素节点,因此 type 属性的值为 'ELEMENT';刚刚创建的元素还不能确定其父节点,因此 parentNodenull;用于存储子节点的 children 属性被初始化为一个数组,props 属性和 eventListeners 被初始化为空对象;最后的 textnull,因为它不是一个文本节点。

现在创建元素节点的功能已经实现,那么创建文本节点呢?如下:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {/* 省略... */}
      return customElement
    },
    createText(text) {
      const customElement = {
        type: 'TEXT',
        parentNode: null,
        text: text
      }
      return customElement
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

文本元素的 type 类型值为 'TEXT'parentNode 同样被初始化为 unlltext 属性存储着文本节点的内容。由于文本元素没有子节点、属性/特性、事件等信息,因此不需要其他描述信息。

文本节点与元素节点的创建都已经实现,接下来我们看看当元素被追加时应该如何处理,即 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)
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

如上高亮代码所示,追加节点时我们要做的就是建立节点间正确的父子关系,在 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
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

如上高亮代码所示,在移除节点时,思路也很简单,首先需要在父节点的 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
    }
  }
})

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

在创建渲染器时我们需要实现 patchData 函数的功能,它的功能是用来更新元素自身的属性/特性的,在之前的讲解中我们实现了 Web 平台中 patchData 函数,然而在这个模拟实现中,我们要做的事情就少了很多。只需要把元素的属性添加到元素的 props 对象中即可,同时如果是事件的话,我们也只需要将其添加到元素的 eventListeners 对象中就可以了。

实际上,本节我们所实现的自定义渲染器,就能够满足我们对组件测试的需求,我们可以利用它来测试组件所渲染内容的正确性。如果你想要进一步提升该自定义渲染器的能力,例如希望该渲染器有能力在控制台中打印出操作元素的信息,也很简单,我们以创建元素为例,如下代码所示:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {
        type: 'ELEMENT',
        tag,
        parentNode: null,
        children: [],
        props: {},
        eventListeners: {},
        text: null
      }
  console<span class="token punctuation">.</span><span class="token function">table</span><span class="token punctuation">(</span><span class="token punctuation">{<!-- --></span>
    type<span class="token punctuation">:</span> <span class="token string">'CREATE ELEMENT'</span><span class="token punctuation">,</span>
    targetNode<span class="token punctuation">:</span> customElement
  <span class="token punctuation">}</span><span class="token punctuation">)</span>

  <span class="token keyword">return</span> customElement
<span class="token punctuation">}</span>

}
})

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

只需要在 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<span class="token punctuation">.</span><span class="token function">table</span><span class="token punctuation">(</span><span class="token punctuation">{<!-- --></span>
    type<span class="token punctuation">:</span> <span class="token string">'APPEND'</span><span class="token punctuation">,</span>
    targetNode<span class="token punctuation">:</span> child<span class="token punctuation">,</span>
    parentNode<span class="token punctuation">:</span> parent
  <span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>

}
})

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

怎么样,是不是很简单。当然了这只是自定义渲染器的应用之一,对于自定义渲染器来说,它可发挥的空间还是非常大的,举几个例子:

  • 渲染到 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

<template>
 <piechart @click="handleClick" :data="state.data" :x="200" :y="200" :r="200"></piechart>
</template>
<script>
import { reactive, ref } from "vue";
export default {
 setup() {
   const state = reactive({
     data: [
      { name: "大专", count: 200, color: "brown" },
      { name: "本科", count: 300, color: "yellow" },
      { name: "硕士", count: 100, color: "pink" },
      { name: "博士", count: 50, color: "skyblue" }
    ]
  });
   function handleClick() {
     state.data.push({ name: "其他", count: 30, color: "orange" });
  }
   return {
     state,
     handleClick
  };
}
};
</script>

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

下面我们创建自定义渲染器,main.js.借助Vue响应式的特性,实现图形渲染。

import { createRenderer } from '@vue/runtime-dom';
let renderer = createRenderer(nodeOps);
let ctx;
let canvas;
function createApp(App) {
    const app = renderer.createApp(App);
    return {
        mount(selector) {
            canvas = document.createElement('canvas');
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            document.querySelector(selector).appendChild(canvas);
            ctx = canvas.getContext('2d');
            app.mount(canvas);
        }
    }
}
createApp(App).mount('#app')

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

重写mount方法,生成cavans并进行挂载操作,这里的nodeOps是需要提供的api ,Vue在渲染时会调用用户提供的方法,从而达到自定义渲染器的目的!

3.1 自定义渲染逻辑

const nodeOps = {
    insert: (child, parent, anchor) => {
        child.parent = parent;
        if (!parent.childs) { // 格式化父子关系
            parent.childs = [child]
        } else {
            parent.childs.push(child);
        }
        if (parent.nodeType == 1) {
            draw(child); // 开始绘图
            if (child.onClick) {
                ctx.canvas.addEventListener('click', () => {
                    child.onClick();
                    setTimeout(() => {
                        draw(child)
                    }, 0);
                }, false)
            }
        }
    },
    remove: child => {},
    createElement: (tag, isSVG, is) => {
        return {tag}
    },
    createText: text => {},
    createComment: text => {},
    setText: (node, text) => {},
    setElementText: (el, text) => {},
    parentNode: node => {},
    nextSibling: node => {},
    querySelector: selector => {},
    setScopeId(el, id) {},
    cloneNode(el) {},
    insertStaticContent(content, parent, anchor, isSVG) {},
    patchProp(el, key, prevValue, nextValue) {
        el[key] = nextValue;
    },
};

 
 
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

这里我们改写 patchProinsertcreateElement方法。

  • patchProp 每次更新属性会调用此方法
  • createElement 创建元素会调用此方法
  • insert 元素插入到页面中会调用此方法

3.2 提供绘制逻辑

这里就是普通的canvas操作~

const draw = (el,noClear) => {
 if (!noClear) {
   ctx.clearRect(0, 0, canvas.width, canvas.height)
}
 if (el.tag == 'piechart') {
   let { data, r, x, y } = el;
   let total = data.reduce((memo, current) => memo + current.count, 0);
   let start = 0,
       end = 0;
   data.forEach(item => {
     end += item.count / total * 360;
     drawPieChart(start, end, item.color, x, y, r);
     drawPieChartText(item.name, (start + end) / 2, x, y, r);
     start = end;
  });
}
 el.childs && el.childs.forEach(child => draw(child,true));
}

const d2a = (n) => {
return n Math.PI / 180;
}
const drawPieChart = (start, end, color, cx, cy, r) => {
let x = cx + Math.cos(d2a(start)) r;
let y = cy + Math.sin(d2a(start)) r;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(x, y);
ctx.arc(cx, cy, r, d2a(start), d2a(end), false);
ctx.fillStyle = color;
ctx.fill();
ctx.stroke();
ctx.closePath();
}
const drawPieChartText = (val, position, cx, cy, r) => {
ctx.beginPath();
let x = cx + Math.cos(d2a(position)) r/1.25 - 20;
let y = cy + Math.sin(d2a(position)) * r/1.25;
ctx.fillStyle = ‘#000’;
ctx.font = ‘20px 微软雅黑’;
ctx.fillText(val,x,y);
ctx.closePath();
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 在 ant-design-vue3.0 ,我们可以通过自定义表单校验错误显示来实现我们想要的效果。首先,我们需要在表单项定义校验规则,并给每个表单项添加一个唯一的 `name` 属性。例如: ```html <a-form-item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}> <a-input v-model:value="formData.username"></a-input> </a-form-item> ``` 接下来,我们可以在表单的 `validateTrigger` 属性指定触发校验的时机。默认情况下,它的值为 `change` 和 `blur`,即在表单项失去焦点或值改变时触发校验。我们可以将它修改为 `submit`,表示只有在提交表单的时候才触发校验。 ```html <a-form ref="formRef" :model="formData" :validateTrigger="'submit'"> <!-- 表单项 --> </a-form> ``` 当表单校验失败时,默认情况下会在表单项下方显示错误信息。我们可以通过 `validateStatus` 和 `help` 属性来自定义错误显示。`validateStatus` 可以接受三种值:`'success'` 表示校验通过,`'error'` 表示校验失败,`'validating'` 表示校验。我们可以根据需要设置不同的显示效果。 ```html <a-form-item label="用户名" name="username" :validateStatus="validateStatus('username')" :help="errorMessages['username']"> <a-input v-model:value="formData.username"></a-input> </a-form-item> ``` 其,`validateStatus` 是一个方法,用于根据校验结果返回对应的状态值。`errorMessages` 是一个对象,保存所有的错误信息。 ```javascript data() { return { formData: { username: '' }, errorMessages: {} // 错误信息 } }, methods: { validateStatus(name) { if (this.errorMessages[name]) { return 'error'; } return ''; } } ``` 最后,我们需要在提交表单时手动触发校验,并捕获校验结果,并将错误信息保存到 `errorMessages` 。 ```javascript methods: { submitForm() { this.$refs.formRef.validate((valid) => { if (valid) { // 校验通过,提交表单 } else { // 校验失败,显示错误信息 this.errorMessages = this.$refs.formRef.getFieldsError().reduce((acc, {name, errors}) => { if (errors.length > 0) { acc[name] = errors[0].message; } return acc; }, {}); } }); } } ``` 通过以上步骤,我们就可以实现自定义表单校验错误显示。在校验失败时,表单项下方会显示我们设置的错误信息,而不是默认的错误提示。 ### 回答2: 在 ant-design-vue 3.0 ,可以通过使用自定义校验规则和错误信息来自定义表单校验错误显示。 首先,创建一个自定义的校验规则函数。校验规则函数需要接受两个参数:rule 和 value。rule 代表当前校验规则的配置,而 value 代表当前表单字段的值。在校验函数,可以根据具体的校验逻辑进行判断并返回一个校验结果。校验结果可以返回一个布尔值或一个 Promise 对象。 接着,在需要进行校验的表单控件,可以使用 rules 属性来指定校验规则函数或校验规则数组。如果是多个校验规则,可以将它们放入数组。可以通过 message 属性来设置错误提示信息。 例如,我们需要自定义一个校验规则,判断输入的字符串是否包含特定字符。可以创建一个函数 validateSpecialChar,接受 rule 和 value 作为参数,根据具体需求进行判断,并返回一个校验结果。 ```javascript const validateSpecialChar = (rule, value) => { if (/[@#$%^&*]/.test(value)) { return Promise.reject(new Error('不能包含特殊字符')); } return Promise.resolve(); }; ``` 然后,在表单控件,可以指定 rules 属性来使用这个校验规则,并设置错误提示信息。 ```html <a-form-item label="用户名" required> <a-input v-decorator="['username', { rules: [{ validator: validateSpecialChar, message: '不能包含特殊字符' }] }]"></a-input> </a-form-item> ``` 当用户输入的字符串包含特殊字符时,会显示错误提示信息"不能包含特殊字符"。如果输入合法,则不显示错误提示信息。 通过这种方式,我们可以方便地自定义表单校验错误的显示方式。根据具体需求,可以自定义不同的校验规则函数和错误提示信息来满足不同的校验需求。 ### 回答3: 在Ant Design Vue 3.0,默认的表单校验错误显示是使用Tooltip组件,在表单控件的右上方显示错误提示信息。如果需要自定义表单校验错误显示,可以通过以下步骤实现: 1. 创建自定义的错误提示组件:可以使用Message组件或者其他任意组件作为错误提示的样式,根据具体需求进行定制。 ```vue <template> <div class="custom-error"> <span class="error-message">{{ errorMessage }}</span> </div> </template> <script> export default { name: 'CustomError', props: { errorMessage: { type: String, required: true } } } </script> <style> .custom-error { /* 自定义样式 */ } .error-message { /* 自定义样式 */ } </style> ``` 2. 在表单组件使用自定义的错误提示组件。在需要进行校验的表单控件外部包裹一层`FormItem`组件,并通过`rules`属性设置校验规则,在`validateStatus`属性根据校验结果设置表单控件的状态,如果校验失败,将错误信息通过`error-message`属性传给自定义错误提示组件。 ```vue <template> <a-form :form="form"> <a-form-item label="用户名" :validateStatus="validateStatus('username')" :help="helpMessage('username')"> <a-input v-model="username" /> </a-form-item> </a-form> </template> <script> import CustomError from '@/components/CustomError.vue'; export default { name: 'MyForm', components: { CustomError }, data() { return { form: this.$form.createForm(this), username: '' }; }, methods: { validateStatus(fieldName) { const { isFieldTouched, getFieldError } = this.form; return isFieldTouched(fieldName) && getFieldError(fieldName) ? 'error' : ''; }, helpMessage(fieldName) { const { isFieldTouched, getFieldError } = this.form; return isFieldTouched(fieldName) && getFieldError(fieldName) ? getFieldError(fieldName)[0] : ''; } } } </script> ``` 3. 构建校验规则:根据具体需求,使用`rules`属性定义校验规则,可以使用Ant Design Vue提供的校验规则或者自定义校验规则。 ```vue <script> export default { // ... methods: { validateStatus(fieldName) { // ... }, helpMessage(fieldName) { // ... } }, created() { this.form.setFields({ username: { rules: [ { required: true, message: '请输入用户名' }, { pattern: /^[a-zA-Z0-9_]{5,16}$/, message: '用户名由字母、数字或下划线组成,长度为5-16位' } ] } }); } } </script> ``` 通过以上步骤,我们可以实现自定义表单校验错误显示。可以根据自己的需求,使用不同的组件和样式来进行自定义

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值