Skip to content

渲染器

渲染器是用来执行渲染任务。vue 渲染器在浏览器平台上,会把虚拟 DOM 渲染为真实 DOM 元素。

基本实现

要渲染一个 <p>张三</p>,可以使用以下虚拟 DOM 来表示:

js
const vNode = { type: 'p', children: '张三' }

渲染时接收两个参数 vNodecontainer,将 vNode 渲染成真实 dom 并挂载在容器节点上。

js
function renderer(vNode, container) {
  const el = document.createElement(vNode.type)
  const text = document.createTextNode(vNode.children)
  el.appendChild(text)
  container.appendChild(el)
}

renderer(vNode, document.body)

以上代码能通过虚拟 DOM 生成 dom 节点并添加到了 body 下,最终页面展示出 张三

但一个基本渲染器需要有挂载、更新和卸载功能,我们优化一下代码,部分代码后续补齐,得出结构如下:

js
function createRenderer() {
  // 渲染
  function render(vNode, container) {
    if (vNode) {
      // 新节点存在,则打补丁
      patch(container._vNode, vNode, container)
    } else {
      // 新节点不存在,则卸载
      unmountElement(container)
    }

    // 记录上次渲染虚拟 DOM
    container._vNode = vNode
  }

  // 补丁
  function patch(oldV, newV, container) {
    if (!oldV) {
      // 挂载
      mountElement(newV, container)
    } else {
      // 更新
    }
  }

  // 挂载
  function mountElement(vNode, container) {
    const el = document.createElement(vNode.type)
    const text = document.createTextNode(vNode.children)
    el.appendChild(text)
    container.appendChild(el)
  }

  // 卸载
  function unmountElement(container) {
    // 临时这样操作,以清除子节点
    container.innerHTML = ''
  }

  return { render }
}

const renderer = createRenderer()

挂载子节点

现在我们需要渲染包含子节点的虚拟 DOM,注意看结构,一个虚拟节点可能是字符串或对象,子节点可能是字符串或数组,子节点为数组的时候需要递归的去渲染挂载。

js
const vNode = { type: 'p', children: ['张三', { type: 'span', children: ',你好!' }] }
renderer.render(vNode, document.body)

现在我们更改 mountElement 函数,来递归渲染子节点。

js
// 挂载
function mountElement(vNode, container) {
  if (typeof vNode === 'string') {
    // 虚拟 DOM 为 字符串
    const text = document.createTextNode(vNode)
    return container.appendChild(text)
  }
  const el = document.createElement(vNode.type)
  if (typeof vNode.children === 'string') {
    // 子节点 为 字符串
    const text = document.createTextNode(vNode.children)
    el.appendChild(text)
  } else if (Array.isArray(vNode.children)) {
    // 子节点 是 数组,递归挂载
    vNode.children.forEach(child => patch(null, child, el))
  }
  container.appendChild(el)
}

HTML Attributes 与 DOM Properties

HTML Attributes 指的就是定义在 HTML 标签上的属性。

html
<input id="my-input" type="text" value="foo" />

DOM Properties 就是 dom 对象上的属性。

js
const el = document.createElement('div')
el.id
el.type
el.value

很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties。

  • id="my-input" 对应 el.id
  • type="text" 对应 el.type
  • value="foo" 对应 el.value

但 DOM Properties 与 HTML Attributes 的名字不总是一模一样的,比如:class="box"el.className

实际上,HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。

挂载属性

在了解 HTML Attributes 与 DOM Properties 之后,我们新增一个名为 patchProps 函数处理属性问题,在设置属性的时候优先设置 DOM Properties。

js
function patchProps(el, key, oldV, newV) {
  if (key in el) {
    // 优先设置 DOM Properties
    el[key] = newV
  } else {
    // 设置 HTML Attributes
    el.setAttribute(key, newV)
  }
}

这时需要渲染一个被禁用的按钮。

js
const vNode = { type: 'button', props: { disabled: true }, children: '按钮' }

接下来我们只需要在挂载的时候调用该函数就行,在 mountElement 函数中添代码。

JS
function mountElement(vNode, container) {
  if (typeof vNode === 'string') {
    // 虚拟 DOM 为 字符串
    const text = document.createTextNode(vNode)
    return container.appendChild(text)
  }
  const el = document.createElement(vNode.type)

  if (vNode.props) { 
    for (const key in vNode.props ) { 
      patchProps(el, key, null, vNode.props[key]) 
    } 
  } 

  if (typeof vNode.children === 'string') {
    // 子节点 为 字符串
    const text = document.createTextNode(vNode.children)
    el.appendChild(text)
  } else if (Array.isArray(vNode.children)) {
    // 子节点 是 数组,递归挂载
    vNode.children.forEach(child => patch(null, child, el))
  }
  container.appendChild(el)
}

其中一些属性比较特殊,比如表单元素 form 属性,只能通过 setAttribute 设置,针对这些属性只要在 patchProps 中特殊处理就行。

vue 对 class 和 style 做了增强,可以传入对象、数组、字符串,在设置元素 class 或 style 之前将值归一化为统一的字符串形式,再把该字符串作为元素的 class 或 style 值去设置。

js
function normalizeClass(clas) {
  if (typeof clas === 'string') return clas

  if (Object.prototype.toString.call(clas) === '[object Object]') {
    let classText = ''
    for (const key in clas) {
      clas[key] && (classText += key + ' ')
    }
    return classText.trim()
  }

  if (Array.isArray(clas)) {
    return clas.reduce((pre, cur) => pre + ' ' + normalizeClass(cur), '').trim()
  }
}

卸载节点

当需要卸载节点时,只需要在渲染时第一个参数穿 null 即可,以下代码会清空 body 节点。

js
renderer.render(null, document.body)

在之前的代码中,我们操作 innerHTML 来清空子节点,这样做有很多弊端,比如无法清除事件,是嵌套结构的组件,还应该递归的卸载。

修改 mountElement 函数,将创建的节点信息记录在虚拟 DOM 上。

js
function mountElement(vNode, container) {
  // ...

  const el = document.createElement(vNode.type)
  vNode._el = el 

  // ...
}

修改 unmountElement 函数接收一个虚拟 DOM,获取其对应的真实 DOM,调用原生方法进行卸载,在此函数中,我们可以调用卸载的生命周期函数,清除事件等。

js
function unmountElement(vNode) {
  const parentNode = vNode._el.parentNode
  parentNode && parentNode.removeChild(vNode._el)
}

在 render 函数中调用 unmountElement 并传入上次渲染虚拟 DOM。

js
function render(vNode, container) {
  if (vNode) {
    // 新节点存在,则打补丁
    patch(container._vNode, vNode, container)
  } else {
    // 新节点不存在,则卸载
    unmountElement(container) 
    container._vNode && unmountElement(container._vNode) 
  }

  // 记录上次渲染虚拟 DOM
  container._vNode = vNode
}

绑定事件

对于事件处理是用 addEventListenerremoveEventListener 来完成,以下虚拟 DOM 描述了一个按钮和按钮的事件。

js
const vNode = {
  type: 'button',
  props: { onClick: () => console.log(111) },
  children: '按钮'
}

patchProps 新增一个处理事件逻辑,并调用 patchEvent 方法。

JS
function patchProps(el, key, oldV, newV) {
  // 以 on 开头的属性被认为是事件
  if (/^on/.test(key)) { 
    patchEvent(el, key, oldV, newV) 
  } 

  // ...
}

patchEvent 函数中对元素事件进绑定和卸载。

js
function patchEvent(el, key, oldV, newV) {
  const eventName = key.slice(2).toLowerCase()
  oldV && el.removeEventListener(eventName, newV)
  el.addEventListener(eventName, newV)
}

以上代码能实现功能,但是不够好,因为频繁的调用 addEventListenerremoveEventListener 其实会有性能问题。

可以使用代理模式来优化,实际元素只绑定一个事件,在该事件内部处理原来应该绑定在元素的事件。

js
function patchEvent(el, key, oldV, newV) {
  // 事件名
  const eventName = key.slice(2).toLowerCase()
  // 代理事件对象
  const proxyEvents = el._proxyEvents || (el._proxyEvents = {})
  // 代理事件
  let proxyEvent = proxyEvents[key]

  if (newV) {
    if (proxyEvent) {
      // 在这里修改每次更新的事件
      proxyEvent.value = newV
    } else {
      proxyEvent = proxyEvents[key] = function (e) {
        // 触发原来事件
        proxyEvent.value(e)
      }
      proxyEvent.value = newV
      // 绑定代理事件
      el.addEventListener(eventName, proxyEvent)
    }
  } else if (proxyEvent) {
    // 移除代理事件
    el.removeEventListener(eventName, proxyEvent)
  }
}

当绑定多个事件时 onClick 为数组,只需要在执行时判断一下即可。

JS
function patchEvent(el, key, oldV, newV) {
  // ...

  proxyEvent = proxyEvents[key] = function (e) {
    // 触发原来事件
    proxyEvent.value(e) 
    if (Array.isArray(proxyEvent.value)) { 
      proxyEvent.value.forEach(fn => fn(e)) 
    } else { 
      proxyEvent.value(e) 
    } 
  }

  // ...
}

更新节点

在更新时需要确认新旧虚拟 DOM 类型是否一致,不一致直接卸载旧 DOM,否则进行打补丁,节点补丁操作放在 patchElement 中处理。

JS
function patch(oldV, newV, container) {
  if (oldV && oldV.type !== newV.type) { 
    // 当旧节点 与 新节点 类型不一致时,直接卸载旧节点
    unmountElement(oldV) 
    oldV = null
  }

  if (!oldV) {
    // 挂载
    mountElement(newV, container)
  } else {
    // 更新
    patchElement(oldV, newV) 
  }
}

patchElement 中第一步更新属性 props,第二步更新子节点 children,更新子节点放在 pathChildren 中处理。

js
function patchElement(oldV, newV) {
  const el = (newV._el = oldV._el)
  const oldProps = oldV.props
  const newProps = newV.props

  // 第一步:更新 props
  // 更新属性
  for (const key in newProps) {
    if (oldProps[key] === newProps[key]) continue
    patchProps(el, key, oldV.props[key], newV.props[key])
  }

  // 删除上次存在本次不存在的属性
  for (const key in oldProps) {
    if (key in newProps) continue
    patchProps(el, key, oldV.props[key], null)
  }

  // 第二步:更新 children
  patchChildren(oldV, newV, el)
}

一个虚拟 DOM 的子节点可能没有,或者是一个子节点,或者是一组节点,新旧节点三种情况进行对比来更新节点。其中二者都为一组节点的情况下会进行 diff 操作,有关 diff 另作说明,这里先用最基础的办法,旧节点全部卸载后挂载新节点。

js
function patchChildren(oldV, newV, container) {
  if (typeof newV.children === 'string') {
    /* 新节点 为 字符串 */
    if (Array.isArray(oldV.children)) {
      // 循环卸载旧节点
      oldV.children.forEach(i => unmountElement(i))
    }
    container.textContent = newV.children
  } else if (Array.isArray(newV.children)) {
    /* 新节点 为 数组 */
    if (Array.isArray(oldV.children)) {
      // 这里应该是核心 diff,暂时全部卸载再挂载
      oldV.children.forEach(i => unmountElement(i))
      newV.children.forEach(i => patch(null, i, container))
    } else {
      container.textContent = ''
      newV.children.forEach(i => patch(null, i, container))
    }
  } else {
    /* 新节点 为 不存在 */
    if (Array.isArray(oldV.children)) {
      // 循环卸载旧节点
      oldV.children.forEach(i => unmountElement(i))
    } else if (typeof oldV.children === 'string') {
      container.textContent = ''
    }
  }
}

与响应式系统结合

利用响应式系统,在副作用函数中调用渲染器渲染页面,数据发生变化时会自动重新渲染。vue 中 render 函数就是这样的。

js
const obj = reactive({ name: '张三' })

effect(() => {
  renderer.render({ tag: 'p', children: obj.name }, document.body)
})

提示

事件属于动态绑定时,会有冒泡问题,需要特殊处理,这里暂时留个坑。