Vuejs设计与实现 —— 渲染器

基本概念

了解渲染器所涉及的基本概念,有助于更好的理解框架 API 的设计。

渲染器 & 渲染

通常使用名词 renderer 来表示 "渲染器",使用动词 render 来表示 "渲染"渲染器 的作用是把虚拟 DOM 渲染 为特定平台上的真实元素,例如,在浏览器平台上,渲染器会把 虚拟 DOM 渲染为 真实 DOM 元素。

虚拟 DOM & 虚拟节点

虚拟 DOM 通常使用英文 virtual DOM 表示,简写为 vdom虚拟 DOM真实 DOM 结构是一样的,都是由一个个节点组成的树形结构,而 虚拟节点 使用 virtual node 来表示,简写为 vnode虚拟 DOM 是树形结构,其中的任何一个节点 vnode 都可以代表一颗子树,因此 vnodevdom 是可以替换使用的。

挂载

渲染器虚拟 DOM 节点渲染为 真实 DOM 节点的过程叫作 挂载,英文表示为 mount,例如 在 Vue.js 组件中的 mounted 钩子就会在挂载完成时触发,这就意味着可以在这个钩子中访问到 真实 DOM 元素。

通过一下代码来辅助理解:

  function createRenderer(){
       fucntion render(vnode, container){
        ...
       }
  
       fucntion hydrate(){
        ...
       }
  
      // 返回渲染函数和 createApp
      return {
        render,
        hydrate,
        createApp: createAppAPI(render, hydrate)
      }
 }
复制代码

其中 createRenderer 函数用来创建一个渲染器,调用 createRenderer 函数后会得到一个 render 函数,这个 render 函数会以 container 为挂载点,将 vnode 渲染为真实 DOM 并进行挂载。

为什么需要 createRenderer 函数?

渲染器渲染 是不同的,渲染器 是更加宽泛的概念,它包含了 渲染,渲染器不仅可以用来渲染,还可以用来激活已有的 DOM 元素,这通常发生在 同构渲染 的情况下。可以看到,当创建渲染器时,渲染器除了包含 render 函数外,还包含了 hydrate 函数,专门用于处理服务端渲染。

// 创建对应平台的渲染器
cosnt renderer = createRenderer()

// 首次渲染,进行挂载
renderer.render(vnode, document.querySelector('#app'))

// 后续渲染,进行更新
renderer.render(newVnode, document.querySelector('#app'))
复制代码

render 函数的实现思路

为了便于理解,先看下面的 render 部分的伪代码:

function createRenderer() {
  function render(vnode, container) {
    if (vnode) {
      // 新的 vnode 存在,将其与旧的 vnode 一起传递给 patch 函数,进行补丁(挂载 或 更新)
      patch(container._vnode, vnode, container)
    } else {
      if(container._vnode){
        // 新的 vnode 不存在,旧的 vnode 存在,说明当前属于 unmount 操作
        // 这里简单的通过 container.innerHTML 将 container 的内容清空
        container.innerHTML = ''
      }
    }

    // 将新的 vnode 存储到 container._vnode 中,即后续渲染中旧的 vnode
    container._vnode = vnode
  }

  return {
    render,
  }
}
复制代码

假设连续使用三次 renderer.render 函数执行渲染,如下:

// 容器元素
const app = document.querySelector("#app");

// 创建渲染器
const renderer = createRenderer();

// 首次渲染
renderer.render(vnode1, app);
// 第二次渲染
renderer.render(vnode2, app);
// 第三次渲染
renderer.render(null, app);
复制代码
  • 首次渲染时,会将 vnode1 渲染为 真实 DOM,渲染完成后,vnode1 会被存储到 container._vnode 中,作为后续渲染中的 旧 vnode 使用
  • 第二次渲染时,旧 vnode 存在,此时会把 vnode2 作为 新 vndoe,并将 新旧 vnode 传递给 patch 函数进行补丁
  • 第三次渲染时,新 vnode 节点为 null,即什么都不渲染,但此时容器中渲染的是 vnode2 的内容,所以渲染器需要清空容器,当然直接通过 innerHTML = '' 清空的方式是有问题的,这里只是用于表示达到清空的目的

上面的三次渲染分别对应着:挂载、更新、卸载 的过程,patch 函数是整个渲染器的核心入口,它包含了重要的渲染逻辑,其中 patch 函数的各个参数:

function patch(n1, n2, container){...}
复制代码
  • n1 代表 旧 vnode 节点
  • n2 代表 新 vnode 节点
  • container 代表真实的容器元素

自定义渲染器

渲染器不仅应该能够把 虚拟 DOM 渲染为浏览器平台上的 真实 DOM,也应该能实现在渲染到任意平台上,这就意味需要将渲染器中浏览器特定的 API 进行抽象,这样就可以使得渲染器的核心不依赖于浏览器。在此基础上,再为那些抽离 API 提供可配置的接口,既可实现渲染器跨平台的能力

抽离和平台强相关的 API

首先针对 patch 函数进行一个简单的实现,并且通过 mountElement 完成挂载操作,如下:

// 渲染器
function createRenderer() {
  // mountElement
  function mountElement(vnode, container) {
    // 创建 dom 元素
    const el = document.createElement(vnode.type)
    
    // 若子节点是字符串,则代表是文本内容
    if (typeof vnode.children === 'string') {
        el.textContext = el.children;
    }
    // 将子元素添加到容器中
    container.appendChild(el)
  }

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

  // 渲染函数
  function render(vnode, container) {
    if (vnode) {
      patch(container._vnode, vnode, container)
    } else {
      if (container._vnode) {
        container.innerHTML = ''
      }
    }
    container._vnode = vnode
  }

  return {
    render,
  }
}
复制代码

通过上述内容,我们的目标是设计一个不依赖于浏览器平台的通用渲染器,但是在 mountElement 函数内调用了大量依赖于浏览器的 API(如:createElement、appendChild、textContext),因此第一步就是将这些依赖于浏览器的 API 进行抽离。

可以在创建渲染器时通过传入对应的配置项,如下:

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

于是在渲染器内就可以通过配置项 options 对获取对应操作 DOM 的 API 了:

// 渲染器
function createRenderer(options) {
  // 通过配置项获取操作 DOM 的 API
  const {
    createElement,
    setElementText,
    insert
  } = options;

  // mountElement
  function mountElement(vnode, container) {
    // 创建 dom 元素
    const el = createElement(vnode.type)

    // 若子节点是字符串,则代表是文本内容
    if (typeof vnode.children === 'string') {
      setElementText(el.children);
    }
    // 将子元素添加到容器中
    insert(el, container)
  }

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

  // 渲染函数
  function render(vnode, container) {
    if (vnode) {
      patch(container._vnode, vnode, container)
    } else {
      if (container._vnode) {
        container.innerHTML = ''
      }
    }
    container._vnode = vnode
  }

  return {
    render,
  }
}
复制代码

重构后的代码,已经不再直接依赖于浏览器特有的 API 了,并且通过传入不同的配置项,就能够完成非浏览器环境下的渲染工作。

最后

有了对渲染器最基本的了解,在结合 Vue.js 源码的学习会有更深刻的理解,为什么 vue.js 要如此设计其 API

猜你喜欢

转载自juejin.im/post/7102374154921312269