likes
comments
collection
share

【拆解Vue3】渲染器是如何实现的(下篇)?

作者站长头像
站长
· 阅读数 39

本篇内容基于【拆解Vue3】渲染器是如何实现的(上篇)?实现。

节点的更新与删除

在前面的文章中,我们已经能够对简单的vnode进行渲染。但前面我们实习的渲染是一次性的,实际开发中,我们需要重新渲染复杂节点的能力。为了实现这一点,我们考虑对前面实现的代码进行重构。先从创建渲染器的代码出发。

const renderer = createRender(DOMOptions)
renderer.render(vnode, document.querySelector('#app')) // (1)
renderer.render(newVNode, document.querySelector('#app')) // (2)
renderer.render(null, document.querySelector('#app')) // (3)

在上面这段示例中,我们定义了一个构造器,(1)处传入vnode节点进行了初次渲染,(2)处给出了一个新的newVNode节点进行了更新,(3)处传入null卸载已经渲染的节点。

这里需要着重说明的是更新。最简单暴力的更新方式是删除旧的DOM,重新渲染新的DOM。但是不要忘了,虚拟DOM在性能上不如直接操作DOM来的快,之所以使用虚拟DOM,关键就在于虚拟DOM仅会进行必要的更新,通过减少更新的次数和规模,来间接提高前端项目的整体性能。Vue中使用diff算法来进行必要的更新,进行diff的前提是,我们需要保留旧vnode,与新vnode进行对比

function createRender(options) {
  const { createElement, setElementText, insert } = options
  function patch(oldNode, newNode, container) { // (4)
    if(!oldNode) {
      mountElement(newNode, container)
    }
  }
  function render(vnode, container) { 
    if(vnode) { // (5)
      patch(container._vnode, vnode, container)
    }
    container._vnode = vnode // (6)
  }
  function mountElement(vnode, container) {
    const el = createElement(vnode.type)
    if(typeof vnode.children === 'string') {
      setElementText(el, vnode.children)
    }
    insert(el, container)
  }
  return {
    render
  }
}

按照上面的思路,在(4)处新增了一个patch函数,并在render中调用该函数。在patch中,我们需要提供新DOM与旧DOM进行对比,在(6)处我们特地利用container保留了挂载在container上的vnode。重构后,我们就可以利用patch的参数来判断当前是要进行挂载,更新还是删除。

  • 挂载:oldNode为空,newNode不为空;
  • 更新:oldNode不为空,newNode不为空;
  • 删除:oldNode不为空,newNode为空;

挂载我们已经实现了,更新由于涉及复杂的diff算法,我们会在下一篇文章中详细分析。这里先来实现删除。我们已经保存了container容器的vnode,在初次挂载时,我们创建了vnode对应的真实DOM。而DOM是以树的结构进行存储的,想要删掉这个DOM节点,就需要知道这个节点的父节点,才能进行删除。思路捋清楚了,让我来动手尝试着写一下。

function unmount(vnode) { // (7)
  const parent = vnode.el.parentNode
  if(parent) {
    parent.removeChild(vnode.el)
  }
}

function createRender(options) {
  const { createElement, setElementText, insert } = options
  function patch(oldNode, newNode, container) {
    if(!oldNode) {
      mountElement(newNode, container)
    } 
  }
  function render(vnode, container) { 
    if(vnode) {
      patch(container._vnode, vnode, container)
    } else {
      if(container._vnode) { // (8)
        unmount(container._vnode)
      }
    }
    container._vnode = vnode
  }
  function mountElement(vnode, container) {
    const el = vnode.el = createElement(vnode.type) // (9)
    if(typeof vnode.children === 'string') {
      setElementText(el, vnode.children)
    }
    insert(el, container)
  }
  return {
    render
  }
}

在(9)处,创建元素时,我们就把创建的元素用vnode.el保存下来,以便进行删除。调用render函数进行渲染时,给vnode赋值为null执行删除逻辑,(8)处判断,若该container容器中存在vnode,则说明该container容器不为空,可以进行删除操作。在(7)处,我们封装了一个函数unmount用于删除DOM,思路与前面提到的一样,找到要删除节点的父节点后remove掉。

聊完了删除,这里可以拓展一下,是否存在更新时不需要diff的情况呢?如果更新时不需要diff,我们就可以复用删除的逻辑,删掉再重建DOM了。仔细思考后不难发现,如果newNodeoldNodetype已经不同,那就没有diff的必要了。

function createRender(options) {
  const { createElement, setElementText, insert } = options
  function patch(oldNode, newNode, container) {
    if(oldNode && oldNode.type !== newNode.type) { // (10)
      unmount(oldNode)
      oldNode = null
    }
    if(!oldNode) {
      mountElement(newNode, container)
    } 
  }
  function render(vnode, container) { 
    if(vnode) {
      patch(container._vnode, vnode, container)
    } else {
      if(container._vnode) {
        unmount(container._vnode)
      }
    }
    container._vnode = vnode
  }
  function mountElement(vnode, container) {
    const el = vnode.el = createElement(vnode.type)
    if(typeof vnode.children === 'string') {
      setElementText(el, vnode.children)
    }
    insert(el, container)
  }
  return {
    render
  }
}

在(10)处,我们判断新旧节点的type是否一致,若不一致直接删掉旧节点对应的DOM,并在后续的逻辑中挂载新节点。

渲染多个子节点

实际开发中,我们要渲染的节点可能不会像前面的那么简单。让我们一点点来扩充我们的渲染器,让它变得可用。首先,我们尝试对包含多个子节点的vnode进行渲染。

const vnode = {
  type: 'div',
  children: [
    {
      type: 'p',
      children: 'hello'
    }, {
      type: 'p',
      children: 'Vue'
    }
  ]
}

在这个例子中,我们将多个子节点用数组包裹,并尝试渲染这个vnode。我们在前面的实现中,进考虑了单个子节点的情况,处理由数组包裹的多个子节点,遍历渲染这个数组就能完成。

  function mountElement(vnode, container) {
    const el = vnode.el = createElement(vnode.type)
    if(typeof vnode.children === 'string') {
      setElementText(el, vnode.children)
    } else if(Array.isArray(vnode.children)) { // (11)
      vnode.children.forEach(child => {
        patch(null, child, el)
      })
    }
    insert(el, container)
  }

(11)处,我们追加了一个判断,如果vnode.children是数组,就调用patch函数去循环渲染数组里的元素。这里需要注意,调用patch函数进行渲染,需要指定渲染的容器container。这里,我们的容器就是我们创建的el元素。

【拆解Vue3】渲染器是如何实现的(下篇)?

处理节点属性

除了节点的类型和子节点,在vnode中我们还需要描述DOM元素的属性。这里我们以在Vue中较为常用,且使用较复杂的class为例,看看如何将vnode上的class定义设置到对应的DOM元素上。先来看看我们在Vue中是如何使用class的。

  1. 像HTML一样去设置class,此时class的值是字符串
  2. 使用Vue绑定语法设置class,此时class的值是对象或数组
const vnode = {
  type: 'div',
  props: {
    class: [
      'foo bar',
      { 
        baz: true,
        ops: false
      }
    ]
  },
  children: 'hello, Vue'
}

最终我们渲染到HTML上的属性应该是一个字符串,每个类名间用空格隔开,所以这里我们也需要对class进行标准化。

function normalizeClass(content) {
  let str = ''
  if(typeof content === 'string') str = content + ' '
  else if(Array.isArray(content)) {
    for(const item of content) {
      str = str + normalizeClass(item)
    }
  } else {
    for(const key in content) {
      if(content[key]) {
        str = str + key + ' '
      }
    }
  }
  return str
}

这里我们简单实现了一个标准化函数,有了这个函数,我们就可以直接生成可用的属性名。JavaScript有多种方式可以给DOM元素设置class,这里我们使用性能最好的el.className进行设置。这里不要忘了,我们的渲染器应该是不依赖具体平台进行渲染的,所以我们需要对DOM元素的属性设置操作进行封装。

const DOMOptions = {
  createElement(tag) {
    return document.createElement(tag)
  },
  setElementText(el, text) {
    el.textContent = text
  },
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor)
  },
  patchProps(el, key, value) {
    if(key === 'class') {
      el.className = normalizeClass(value) || '' // (12)
    }
  }
}

(12)处我们将DOM元素的属性操作封装为函数patchProps。渲染器只需要调用DOMOptions,就能拿到需要的操作。

function createRender(options) {
  const { createElement, setElementText, insert, patchProps } = options // (13)
  // 省略无关代码...
  function mountElement(vnode, container) {
    const el = vnode.el = createElement(vnode.type)
    if(typeof vnode.children === 'string') {
      setElementText(el, vnode.children)
    } else if(Array.isArray(vnode.children)) {
      vnode.children.forEach(child => {
        patch(null, child, el)
      })
    }
    if(vnode.props) { // (14)
      for(const key in vnode.props) {
        patchProps(el, key, vnode.props[key])
      }
    }
    insert(el, container)
  }
  return {
    render
  }
}

(13)处我们将元素属性操作patchProps导入,(14)处判断,当vnode存在props时,利用patchProps去设置属性。

【拆解Vue3】渲染器是如何实现的(下篇)?

处理事件

除了设置属性以外,在Vue中,我们还会给DOM元素设置事件。先来看看在vnode中,我们是如何描述事件的,这里我们以最常见的click事件为例。

const vnode = {
  type: 'div',
  props: {
    onClick: () => {
      console.log('click!')
    }
  },
  children: 'hello, Vue'
}

从上面的例子可以看出,在Vue中,事件实际上就是一种特殊的属性。我们规定,事件名均采用on开头,并使用驼峰规范进行命名,具体到本例中onClick表示的就是click。明确了规范,我们的思路也就确定了。我们只需要把on开头的属性去掉开头的on,再把字符串转为小写,就能得到真正的事件名了。既然把事件作为特殊的属性,我们就仍在patchProps函数里完成这个逻辑。

  patchProps(el, key, value) {
    if(key === 'class') {
      el.className = normalizeClass(value) || ''
    } else if(/^on/.test(key)) { // (15)
      const eventName = key.slice(2).toLowerCase()
      el.addEventListener(eventName, value)
    }
  }

(15)处,我们增加了对事件这种特殊属性的支持。按前面所讲的思路得到事件名后,此时的value就是事件对应要触发的函数,我们给DOM元素el添加事件监听器。

【拆解Vue3】渲染器是如何实现的(下篇)?

参考资料

转载自:https://juejin.cn/post/7138740444514484255
评论
请登录