Vue.js 设计与实现 笔记4 渲染器

69 阅读6分钟

渲染器的设计

渲染器用来渲染真实DOM

function renderer(domString, container) {
  container.innerHTML = domString
}

下面这段代码中,定义了一个响应式数据count,并且在副作用函数中调用rederer函数执行渲染,副作用函数执行完毕后,会与响应式数据建立响应联系,修改count的值会触发重新渲染:

const count = ref(1)

effect(() => {
  rederer(`<h1>{{ count }}</h1>`, document.getElementById('app'))
})

count.value = 2

渲染器的基本概念

渲染器的作用是把虚拟DOM渲染为特定平台的真实元素。

虚拟DOM,简称vdom,和真实DOM的结构一样,是由一个个节点组成的树形结构; 虚拟Node(vnode),虚拟DOM中任何一个vnode节点都可以是一棵子树,所以vnode和vdom有时可以替换使用,以下统一使用vnode描述。

function createRenderer() {
  function render(vnode, container) {
    // ...
  }

  function hydrate(vnode, container) {
    // ...
  }

  return {
    render,
    hydrate
  }
}
const renderer = createRender()
// 首次渲染
renderer.render(oldVNode, document.querySelector('#app'))
// 第二次渲染
renderer.render(newVNode, document.querySelector('#app'))

再次渲染时,比较oldVNode和newVNode,试图找到并更新变更点(这个过程叫patch

// 补充一下render
function createRenderer() {
  function render(vnode, container) {
    if(vnode) {
      // 新 vnode 存在,将新旧vnode一起传递给patch函数,进行打补丁
      patch(container._vnode, vnode, container)
    } else {
      if(container._vode) {
        // 旧vnode存在,且新vnode不存在,就是unmount操作
        // 情况DOM即可
        container.innerHTML = ''
      }
    }
    // 把vnode存储到container._vnode下,即后续渲染中的旧vnode
    container._vnode = vnode
  }

  return {
    render
  }
}

// n1:旧vnode; n2: 新vnode
function patch(n1, n2, container) {
  // ...
}

自定义渲染器

function createRenderer() {
  function patch(n1, n2, container) {
    // 渲染逻辑
    // 如果n1不存在,那就是挂载操作
    if(!n1) {
      mountElement(n2, container)
    } else {
      // n1存在,就是打补丁
      // 具体情况看后面
    }
  }
}

目前问题:渲染器依赖浏览器API 要把浏览器API抽离出来 **解决方案:**将操作DOM的API作为配置项,配置项作为createRenderer的参数

// 在创建renderer时候传入配置项
const renderer = createRenderer({
  // 创建元素
  createElement(tag) {
    return document.createElement(tag)
  }
  // 设置元素的文本节点
  setElementText((el, text) {
    el.textContent = text
  }
  // 给指定的节点添加元素
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor)
  }
})

function createRenderer(options) {
  // 通过options获取操作DOM的API
  const {
    createElement,
    insert,
    setElementText
  } = options
  
  // 在这个作用域内定义的函数都可以访问那些API
  function mountElement(vnode, container) {
    const el = createElement(vnode.type)
    if(typeof vnode.childrenn === 'string') {
      // 调用 setElementText 设置元素的文本节点
      setElementText(el, vnode.children)
    }
    // 调用insert函数将元素插入到容器内
    insert(el, container)
  }
}

image.png

挂载和更新

挂载节点和元素的属性

// 虚拟dom树
const vnode = {
  type: 'div'
  children [
    {
      type: 'p',
      children: 'hello'
    }
  ]
}
function mountElement(vnode, container) {
  const el = createElement(vnode.type)
  if(typeof vnode.children === 'string') {
    setElement(el, vnode.children)
  } else if(Array.isArray(vnode.children)) {
    // 如果children是数组,则遍历每一个子节点,并调用patch函数挂载他们
    vnode.children.forEach((child) => {
      patch(null, child, el)
    })
  }
  insert(el, container)
}

为了描述虚拟DOM属性,使用props描述一个元素的属性

const vnode = {
  type: 'div',
  props: {
    id: 'foo'
  },
  children: [
    {
      type: 'p',
      children: 'hello'
    }
  ]
}

所以在mountElement中,要将属性设置到元素上

function mountElement(vnode, container) {
  const el = createElement(vnode.type)
  // 省略对children的处理
  if(vnode.props) {
    // 遍历 vnode.props
    for(const key in vnode.props) {
      // 调用 setAttribute 将属性设置到元素上
      el.setAttribute(key, vnode.props[key])
      // 或直接设置
      // el[key] = vnode.props[key]
    }
  }
  insert(el, container)
}

HTML Attributes 与 DOM Properties

  • HTML Attribute是用来设置与之对应的DOM Properties的初始值的,当值改变后,DOM Properties存储着当前值,而getAttribute函数得到的仍是初始值。
  • 但是有些值是受限制的,如果通过HTML Attribute提供的值不合法,那么浏览器会使用对应DOM Properties对应的默认值。

正确的设置元素属性

无论是通过setAttributes还是直接设置DOM Properties,都有缺陷 **解决方案:**优先设置元素的DOM Properties,但当值为空串的时候,手动将值矫正为true

class的处理

class的值的类型:

  • 字符串class: "foo"
  • 对象class: {foo: true, bar: false}
  • 两种类型的数组class: [ 'foo bar', { baz: true } ]

**解决方案:**封装一个normalizeClass函数,用它来将不同类型的class值正常化为字符串

卸载操作

  • 容器的内容可能是多个组件渲染的,当卸载操作发生时,应该正确的调用这些组件的beforeUnmount, unmounted等生命周期函数。
  • 即使内容不是组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确执行对应的指令钩子
  • 使用innerHTML清空的缺陷还有:不能移除绑定在DOM元素上的事件处理函数

**解决方案:**根据vnode对象获取与其相关联的真实DOM,然后用原生DOM操作方法将其移除

// 修改 mountElement ,让vnode.el 引用真实 DOM
function mountElement(vnode, container) {
  const el = vnode.el = createElement(vnode.type)
  // ...其他操作
}
// 卸载操作
function unmount(vnode) {
  const parent = vnode.el.parent
  if(parent) {
    parent.removeChild(vnode.el)
  }
}

区分vnode的类型

function patch(n1, n2, container) {
  if(!n1) {
    mountElement(n2, container)
  } else {
    // 更新
  }
}

如果新旧vnode所描述的内容不同,比如旧的是一个<p>,新的是<input>,则应该先卸载旧的再挂载新的。

function patch(n1, n2, patch) {
  // n1存在,则对比n1,n2的类型
  if(n1 && n1.type != n2.type) {
    unmounted(n1)
    n1 = null
  }
  // 其他逻辑
}

但是vnode也可以描述组件,所以如果n2.type是字符串,则为普通标签,如果n2.type是object,则为组件。

事件的处理

约定在vnode.props中,凡是以字符串on开头的属性,都视作事件。例如onClick 那么只需要在patchProps中调用addEventListener函数来绑定事件即可。

patchProps(el, key, preValue, nextValue) {
  // 匹配以on开头的属性,视其为事件
  if(/^on/.test(key)) {
    // 根据属性名称得到对应的事件名称,例如onClick --> click
    const name = key.slice(2).toLowerCase()
    // 移除上一次绑定的事件
    prevValue && el.removeEventListener(name, prevValue)
    // 绑定事件
    el.addEventListener(name, nextValue)
  } else if(key === 'class') {
    // ...
  } else if(shouldSetAsProps(el, key, nextValue)) {
    // ...
  } else {
    // ...
  }
}

性能优化: 我们可以绑定一个伪造的事件处理函数invoker, 然后把真正的事件处理函数设置为invoker.value的属性值,并存到el._vel中。

事件冒泡与更新时机问题

解决方案:利用事件处理函数绑定到DOM元素的时间,与事件触发的差异,我们需要屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行

更新子节点

vnode.children的三种类型

  • 字符串类型:代表元素具有文本子节点
  • 数组类型:代表元素有一组子节点
  • null:没有子节点

文本节点和注释节点

我们利用了symbol类型值的唯一性,为文本节点和注释节点分别创建唯一标识,并将其作为vnode.type属性的值。

Fragment

渲染器渲染Fragment的方式类似于渲染普通标签,但是Fragment本身并不会渲染任何DOM元素,只需要渲染一个Fragment的所有子节点即可。

Vue2的template不能存在多个根节点,Vue3可以,原因就是Fragment

具体的:

<template>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</template>

// 对应的vnode
const vnode = {
  type: Fragment,
  children: [
    { type: 'li', children: '1' },
    { type: 'li', children: '2' },
    { type: 'li', children: '3' },
  ]
}