[浅析] vue3.0中的自定义渲染器

Posted 小平果118

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[浅析] 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

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

接下来我们就着手将一个普通渲染器修改为拥有自定义能力的渲染器,在之前的讲解中,我们将渲染器的代码存放在了 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)  /* ... */ 

观察如上代码结构,可以发现一个渲染器由两部分组成: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)  /* ... */ 

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('http://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('http://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('http://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('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)
    
  
)

当然了,如上代码所创建的渲染器只能够完成 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('http://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';刚刚创建的元素还不能确定其父节点,因此 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
    
  
)

文本元素的 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)
    
  
)

如上高亮代码所示,追加节点时我们要做的就是建立节点间正确的父子关系,在 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以上是关于[浅析] vue3.0中的自定义渲染器的主要内容,如果未能解决你的问题,请参考以下文章

[浅析] vue3.0中的自定义渲染器

UWP xamarin 中的自定义渲染器

.net maui 中的自定义控件和渲染器会发生啥?

Xamarin.Forms 中 Picker 的自定义渲染器

来自 xaml 的自定义渲染器的 Xamarin 访问属性

android中的自定义pdf查看器