Skip to content

渲染模块

在我们开始学习如何编写 Vue 渲染模块代码之前,我们需要先熟悉一个概念 —— Virtual DOM。

Virtual DOM

Virtual DOM(为了方便,后续简写为 VDOM)是指使用 JS 对象去表示真实的 DOM 对象结构和层级关系。VDOM 中的节点称作 VNode。

优势

  • 与环境无关 —— 由于使用的是 JS 对象而不是真实的 DOM 对象,所以可以在浏览器之外的环境使用。例如 SSR、canvas/WebGL 以及原生移动端渲染
  • 性能更好 —— 通过对 JS 对象进行操作而不是对真实的 DOM 对象进行操作,可以有效减少 DOM API 的调用,减少浏览器重绘、重排的次数

我们的渲染模块就是对 VDOM 进行处理。接下来,我们开始编写代码。

h

首先我们实现 Vue 中用于创建 VNode 的 h 函数。

js
function h(tag, props, children) {
  return {
    tag,
    props,
    children,
  }
}

可以看到该函数很简单,只是把参数放进对象中然后返回。

mount

接下来我们实现 mount 函数,该函数用于将 VDOM 挂载到真实的 DOM 节点上。

首先我们要做的是根据 VNode 的类型创建真实的元素,然后设置元素的 attrs,最后把元素插入到容器中。

js
export function mount(vnode, container) {
  const el = document.createElement(vnode.tag)
  // props
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key]
      el.setAttribute(key, value)
    }
  }
  // children
  if (vnode.children) {
    if (typeof vnode.children === 'string') {
      el.textContent = vnode.children
    } else {
      vnode.children.forEach((e) => {
        mount(e, el)
      })
    }
  }
  container.appendChild(el)
}

patch

最后,我们需要实现更新 DOM 的函数 patch

patch 函数接受两个参数,分别为旧节点和新节点,然后对这两个节点进行比较,只需更新需要更新的部分。

在实现 patch 之前,我们需要修改一下 mount 函数,让它在创建元素时把元素存储到 VNode 本身,这样我们就可以在 patch 中访问到旧节点的真实 DOM 树。

js
export function mount(vnode, container) {
  // const el = document.createElement(vnode.tag)
  const el = (vnode.el = document.createElement(vnode.tag)) 
  // props
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key]
      el.setAttribute(key, value)
    }
  }
  // children
  if (vnode.children) {
    if (typeof vnode.children === 'string') {
      el.textContent = vnode.children
    } else {
      vnode.children.forEach((e) => {
        mount(e, el)
      })
    }
  }
  container.appendChild(el)
}
js
import { mount } from '../mount/mount2'

export function patch(n1, n2) {
  if (n1.tag === n2.tag) {
    const el = (n2.el = n1.el)

    // props
    const oldProps = n1.props || {}
    const newProps = n2.props || {}
    for (const key in newProps) {
      const oldValue = oldProps[key]
      const newValue = newProps[key]
      if (newValue !== oldValue) {
        el.setAttribute(key, newValue)
      }
    }
    for (const key in oldProps) {
      if (!(key in newProps)) {
        el.removeAttribute(key)
      }
    }

    // children
    const oldChildren = n1.children
    const newChildren = n2.children
    if (typeof newChildren === 'string') {
      if (typeof oldChildren === 'string') {
        if (newChildren !== oldChildren) {
          el.textContent = newChildren
        }
      } else {
        el.textContent = newChildren
      }
    } else {
      if (typeof oldChildren === 'string') {
        el.innerHTML = ''
        newChildren.forEach((e) => {
          mount(e, el)
        })
      } else {
        /**
         * 对于数组的比较, Vue 中有两种模式
         * 一: 键模式
         * 给组件或元素提供一个 key, 该 key 用作节点
         * 位置的提示
         *
         * 二: 不带 key
         * 按索引位置比较, 不同就直接替换
         *
         * 下面只实现第二种, 第一种看源码
         */
        const commonLength = Math.min(oldChildren.length, newChildren.length)
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChildren[i])
        }
        if (newChildren.length > oldChildren.length) {
          newChildren.slice(oldChildren.length).forEach((e) => {
            mount(e, el)
          })
        } else if (newChildren.length < oldChildren.length) {
          oldChildren.slice(newChildren.length).forEach((e) => {
            el.removeChild(e.el)
          })
        }
      }
    }
  } else {
    // replace
  }
}

完整的代码示例

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Renderer Example</title>
    <style>
      .red {
        color: red;
      }

      .green {
        color: green;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="./main.ts"></script>
  </body>
</html>
ts
import { h, mount, patch } from './renderer'

const vdom = h('div', { class: 'green' }, [
  h('span', undefined, ['Hello ']),
  'Vue',
])

mount(vdom, document.getElementById('app')!)

const vdom2 = h('div', { class: 'red' }, [
  h('span', undefined, ['Hello ']),
  'Vue2222',
])

setTimeout(() => {
  patch(vdom, vdom2)
}, 1000)
ts
export interface VNode {
  // el 的类型与平台无关, 但这里示例方便假定为 Element
  el: Element | null
  tag: string
  props?: Record<string, any>
  children?: (VNode | string)[] | string
}

export function h(
  tag: string,
  props?: Record<string, any>,
  children?: (VNode | string)[] | string
): VNode {
  return {
    el: null,
    tag,
    props,
    children,
  }
}

export function mountChildren(
  container: Element | string,
  children: (VNode | string)[]
) {
  const el =
    typeof container === 'string'
      ? document.querySelector(container)!
      : container

  children.forEach((e) => {
    if (typeof e === 'string') {
      el.appendChild(document.createTextNode(e))
    } else {
      mount(e, el)
    }
  })
}

export type Container = Element | string

export function mount(vnode: VNode, container: Container) {
  const el = (vnode.el = document.createElement(vnode.tag))
  const root =
    typeof container === 'string'
      ? document.querySelector(container)!
      : container
  const { props, children } = vnode

  if (props) {
    /**
     * 此处简化代码只用于示例
     * 实际还要考虑是设置对象的 props 还是元素的 attrs
     * 以及还要考虑 event listener 的处理
     */
    for (const key in props) {
      if (key.startsWith('on')) {
        // event listener
        el.addEventListener(key.slice(2).toLowerCase(), props[key])
      } else {
        // attrs
        el.setAttribute(key, props[key])
      }
    }
  }

  if (typeof children === 'string') {
    el.textContent = children
  } else {
    mountChildren(el, children || [])
  }

  root.appendChild(el)
}

export function patch(oldNode: VNode, newNode: VNode) {
  if (oldNode.tag === newNode.tag) {
    // props
    const el = (newNode.el = oldNode.el!)
    const { props: oldProps = {}, children: oldChildren } = oldNode
    const { props: newProps = {}, children: newChildren } = newNode

    /**
     * 此处简化代码只用于示例
     * 实际上 Compiler 生成的优化过的 Render Fn
     * 提供了一些 hints 以优化 props 遍历
     */
    for (const key in newProps) {
      const oldValue = oldProps[key]
      const newValue = newProps[key]
      if (newValue !== oldValue) {
        el.setAttribute(key, newValue)
      }
    }
    for (const key in oldProps) {
      if (!(key in newProps)) {
        el.removeAttribute(key)
      }
    }
    // children
    if (!newChildren) {
      el.innerHTML = ''
    } else if (typeof newChildren === 'string') {
      if (
        (typeof oldChildren === 'string' && newChildren !== oldChildren) ||
        Array.isArray(oldChildren)
      ) {
        el.textContent = newChildren
      }
    } else {
      if (!oldChildren) {
        mountChildren(el, newChildren)
      } else if (typeof oldChildren === 'string') {
        el.innerHTML = ''
        mountChildren(el, newChildren)
      } else {
        /**
         * 对于数组的比较, Vue 中有两种模式
         * 一: 键模式
         * 给组件或元素提供一个 key, 该 key 用作节点
         * 位置的提示
         *
         * 二: 不带 key
         * 按索引位置比较, 不同就直接替换
         *
         * 下面只实现第二种, 第一种看源码
         */
        const commonLength = Math.min(oldChildren.length, newChildren.length)
        for (let i = 0; i < commonLength; i++) {
          const oldValue = oldChildren[i]
          const newValue = newChildren[i]
          if (typeof newValue === 'string') {
            if (typeof oldValue === 'string' && newValue === oldValue) continue
            el.replaceChild(document.createTextNode(newValue), el.childNodes[i])
          } else {
            if (typeof oldValue === 'string') {
              // replace
            } else {
              patch(oldValue, newValue)
            }
          }
        }
        if (newChildren.length > oldChildren.length) {
          mountChildren(el, newChildren.slice(oldChildren.length))
        } else if (newChildren.length < oldChildren.length) {
          oldChildren
            .slice(newChildren.length)
            .map((e, i) => el.childNodes[i])
            .forEach((e) => {
              el.removeChild(e)
            })
        }
      }
    }
  } else {
    // replace
  }
}