likes
comments
collection
share

精读 《Vuejs设计与实现》第 7 章(渲染器的设计)

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

在第三章,我们已经对虚拟 DOM 和渲染器的基本工作原理进行了探讨,并制作了一个简单的渲染器。现在,我们将深入渲染器的实现细节。你将发现,渲染器在 Vue.js 中起着至关重要的作用,很多功能,如 Transition、Teleport、Suspense 组件,以及 template ref 和自定义指令等,都依赖于渲染器实现。渲染器也是框架性能的关键所在,其实现方式直接决定了框架的性能。Vue.js 3 的渲染器不仅包含了传统的 Diff 算法,还创新地引入了快速路径的更新方式,借助编译器提供的信息,极大地提升了更新性能。虽然渲染器的代码量较大,需要合理的架构设计以保持可维护性,但其实现原理并不复杂。下面,我们将从如何将渲染器与响应系统相结合的讨论开始,逐步构建一个完整的渲染器。

7.1 渲染器与响应系统的整合

渲染器的主要任务是执行渲染,比如在浏览器环境中渲染真实的 DOM 元素。然而,渲染器的功能不仅限于此,它也是框架跨平台能力的关键。因此在设计时,必须考虑其可定制性。在本节,我们会将渲染器的应用场景限定在 DOM 平台,渲染器的核心任务是渲染真实的 DOM 元素,下面是一个基本的渲染器:

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

我们可以这样使用它:

renderer('<h1>Hello</h1>', document.getElementById('app'))

如果页面中存在 id 为 app 的 DOM 元素,那么上述代码会在此 DOM 元素内插入 <h1>Hello</h1>。我们不仅可以渲染静态的字符串,还可以渲染动态拼接的 HTML 内容:

let count = 1
renderer(`<h1>${count}</h1>`, document.getElementById('app'))

此时,渲染出的内容将会是 <h1>1</h1>。如果变量 count 是一个响应式数据,情况又会如何呢?这引出了副作用函数和响应式数据的应用。通过响应系统,我们可以实现整个渲染过程的自动化:

const count = ref(1)

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

count.value++

上述代码,我们首先定义了一个响应式数据 count,接着在副作用函数中调用 renderer 函数执行渲染。一旦我们修改 count.value 的值,副作用函数会重新执行,完成重新渲染,最终渲染到页面的内容是 <h1>2</h1>。这就是响应系统和渲染器之间的关系。我们利用响应系统自动调用渲染器,完成页面的渲染和更新。这个过程与渲染器的具体实现无关,只要渲染器能够修改元素的 innerHTML 内容即可。

在接下来的内容中,我们将使用 @vue/reactivity 提供的响应式 API。关于 @vue/reactivity 的实现原理,第二篇已有讲解。@vue/reactivity 提供了 IIFE 模块格式,因此我们可以直接通过 <script> 标签引用到页面中使用。

<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>

这个模块暴露的全局 API 名叫 VueReactivity,以下是完整代码:

const { effect, ref } = VueReactivity

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

const count = ref(1)

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

count.value++

上述代码,当我们修改 count.value 的值,副作用函数会重新执行,从而实现重新渲染。这段代码执行完后,最终渲染到页面的内容是 <h1>2</h1>

7.2 渲染器的基础概念

理解写渲染器的基础概念:

  • 渲染器(Renderer):英文为"renderer",务必区分于"render"(渲染),名词和动词的区别。渲染器的职责是将虚拟 DOM 转换为特定平台上的实际元素。例如,在浏览器上,渲染器将虚拟 DOM 转换为实际的 DOM 元素。
  • 虚拟DOM(Virtual DOM):通常简写为 vdom,结构与真实 DOM 相同,都是由树形结构的节点组成。我们常听到“虚拟节点”(或 vnode),这个词指的是虚拟 DOM 树中的任何一个节点,因此 vnode 和 vdom 有时可互换使用,为避免混淆,本书将统一使用 vnode。
  • 挂载(Mount):挂载是指将虚拟 DOM 节点渲染为真实 DOM 节点的过程。比如,在 Vue.js 中,mounted 钩子会在挂载完成时触发,然后就可以在其钩子中访问到真实 DOM 元素了。
  • 容器(Container):渲染器需要一个挂载点(也就是一个 DOM 元素)来确定应该将真实 DOM 挂载到哪里,我们通常用 "container" 来表示这个容器。

让我们通过一个代码示例来理解这些概念:

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

  return render;
}

const renderer = createRenderer();
renderer.render(vnode, document.querySelector('#app'));

上述代码,createRenderer 函数创建一个渲染器,该渲染器通过 render 函数将 vnode 渲染到指定的 container。你可能会问,为何需要 createRenderer 函数?我们不能直接定义 render 函数吗?实际上,渲染器和渲染是不同的。渲染器是一个更广泛的概念,它包括渲染。渲染器不仅可以渲染,还可以激活已有的 DOM 元素,这通常在同构渲染时发生,例如:

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

上述代码 createRenderer 函数创建了一个渲染器,它包含了 render 和 hydrate 两个函数。hydrate 函数将在服务端渲染时详细讨论。渲染器的功能非常广泛,将虚拟节点(vnode)渲染为真实DOM的 render 函数只是其中一部分。例如,在Vue.js 3中,创建应用的 createApp 函数也是渲染器的一部分。

一旦有了渲染器,我们就可以使用它来执行渲染任务:

const renderer = createRenderer()
// 首次渲染
renderer.render(vnode, document.querySelector('#app'))

上述代码,我们首先创建了一个渲染器,然后调用其 render 函数来进行渲染。在首次渲染时,只需创建新的 DOM 元素即可,这个过程涉及的是挂载。

如果我们在同一容器上多次渲染,渲染器将执行更新操作:

const renderer = createRenderer();
// 首次渲染
renderer.render(oldVNode, document.querySelector('#app'));
// 第二次渲染
renderer.render(newVNode, document.querySelector('#app'));

上述代码,渲染器会将新的 vnode 和之前渲染的 oldVNode 进行比较,寻找并更新变化的部分。这个过程被称为“打补丁”或“更新”,用"patch"表示。实际上,挂载操作本身也可以看作是一种特殊的打补丁,只不过在挂载操作中,旧的 vnode 是不存在的。因此,我们不必过分区分“挂载”和“打补丁”这两个概念。我们来实现下基本的 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 内的 DOM 清空即可
        container.innerHTML = ''
      }
    }
    // 把 vnode 存储到 container._vnode 下,即后续渲染中的旧 vnode
    container._vnode = vnode
  }

  return {
    render
  }
}

我们通过以下示例来理解其执行流程:

const renderer = createRenderer()

// 首次渲染
renderer.render(vnode1, document.querySelector('#app'))
// 第二次渲染
renderer.render(vnode2, document.querySelector('#app'))
// 第三次渲染
renderer.render(null, document.querySelector('#app'))
  • 首次渲染时,渲染器会将 vnode1 渲染为真实 DOM。渲染完成后,vnode1 会被存储到容器元素的 container._vnode 属性中,用作后续渲染的旧 vnode。
  • 在第二次渲染时,旧 vnode 存在,此时渲染器会将 vnode2 作为新 vnode,并将新旧 vnode 一同传递给 patch 函数进行打补丁。
  • 在第三次渲染时,新 vnode 值为 null,此时渲染器需要清空容器。我们使用 container.innerHTML = '' 来清空容器。请注意,这种清空容器的方法可能会存在问题,但在这里我们暂时使用它来达到目的。

另外,在上面给出的代码中,我们注意到 patch 函数的签名如下:

patch(container._vnode, vnode, container);
  • 第一个参数 n1:旧 vnode。
  • 第二个参数 n2:新 vnode。
  • 第三个参数 container:容器。

在首次渲染时,容器元素的 container._vnode 属性是不存在的,即为 undefined。这意味着,在首次渲染时传递给 patch 函数的第一个参数 n1 也是 undefined。在这种情况下,patch 函数会执行挂载操作,忽略 n1,直接将 n2 所描述的内容直接渲染到容器中。由此可见,patch 函数不仅能完成打补丁的任务,也能执行挂载操作。

7.3 自定义渲染器

渲染器的设计理念在于通用性,我们的目标是创建一个不受平台限制,可以渲染到任何目标平台的渲染器。让我们以浏览器为目标平台,我们将编写一个渲染器,并探究其可抽象的部分,看如何通过抽象来独立出特定于浏览器的API,从而实现渲染器的核心不再依赖于特定平台。首先,以一个简单的 <h1> 标签为例,其对应的 vnode 可以如下描述:

const vnode = {
  type: 'h1',
  children: 'hello'
}

在这个 vnode 对象中,我们使用 type 属性来标识 vnode 的类型。当 type 是一个字符串时,我们可以认为它描述的是一个普通的标签,其值即为标签名称。接下来,我们可以用一个渲染器的 render 函数来渲染这个 vnode:

const vnode = {
  type: 'h1',
  children: 'hello'
}

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

// 调用 render 函数渲染该 vnode
renderer.render(vnode, document.querySelector('#app'))

为了完成渲染,我们需要补充 patch 函数:

function createRenderer() {
  function patch(n1, n2, container) {
    // 在这里编写渲染逻辑
  }

  function render(vnode, container) {
    if (vnode) {
      patch(container._vnode, vnode, container)
    } else {
      if (container._vnode) {
        container.innerHTML = ''
      }
    }
    container._vnode = vnode
  }

  return {
    render
  }
}

我们将 patch 函数也编写在 createRenderer 函数内,后续没有特殊声明,我们编写的函数都定义在 createRenderer 函数内。patch 函数的代码如下:

function patch(n1, n2, container) {
  // 如果 n1 不存在,即为挂载,调用 mountElement 函数完成挂载
  if (!n1) {
    mountElement(n2, container)
  } else {
    // n1 存在,即为打补丁,暂时省略
  }
}

上述代码,第一个参数 n1 代表旧 vnode,第二个参数 n2 代表新 vnode。当 n1 不存在时,即没有旧 vnode,此时只需要执行挂载即可。我们调用 mountElement 完成挂载,它的实现如下:

function mountElement(vnode, container) {
  // 创建 DOM 元素
  const el = document.createElement(vnode.type)
  // 处理子节点,如果子节点是字符串,代表元素具有文本节点
  if (typeof vnode.children === 'string') {
    // 因此只需设置元素的 textContent 属性即可
    el.textContent = vnode.children
  }
  // 将元素添加到容器中
  container.appendChild(el)
}

我们已经完成了挂载普通标签元素的工作。现在,我们来分析一下代码的问题。我们的目标是设计一个不依赖于浏览器平台的通用渲染器、但是,mountElement 函数内调用了大量依赖于浏览器的 API,例如 document.createElement、el.textContent 以及 appendChild 等。要设计通用渲染器,首要步骤就是剥离这些浏览器特有的 API。我们可以将这些操作 DOM 的 API 设为配置项,作为 createRenderer 函数的参数:

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

如此,我们可以通过配置项在 mountElement 等函数内取得操作 DOM 的 API:

function createRenderer(options) {
  // 通过 options 得到操作 DOM 的 API
  const { createElement, insert, setElementText } = options

  // 在这个作用域内定义的函数都可以访问那些 API
  function mountElement(vnode, container) {
    // ...
  }

  function patch(n1, n2, container) {
    // ...
  }

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

  return {
    render
  }
}

然后,我们就可以使用从配置项中获取的 API 重新实现 mountElement 函数:

function mountElement(vnode, container) {
  // 调用 createElement 函数创建元素
  const el = createElement(vnode.type)
  if (typeof vnode.children === 'string') {
    // 调用 setElementText 设置元素的文本节点
    setElementText(el, vnode.children)
  }
  // 调用 insert 函数将元素插入到容器内
  insert(el, container)
}

上述代码,重构后的 mountElement 函数功能上并无任何变化。不同的是,它不再直接依赖于浏览器的特有 API 了。这意味着,只要传入不同的配置项,就能够完成非浏览器环境下的渲染工作。

为了展示这一点,我们可以实现一个用来打印渲染器操作流程的自定义渲染器:

const renderer = createRenderer({
  createElement(tag) {
    console.log(`创建元素 ${tag}`)
    return { tag }
  },
  setElementText(el, text) {
    console.log(`设置 ${JSON.stringify(el)} 的文本内容:${text}`)
    el.textContent = text
  },
  insert(el, parent, anchor = null) {
    console.log(`将 ${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)} 下`)
    parent.children = el
  }
})

上述代码中,我们在调用 createRenderer 函数创建渲染器时,传入了不同的配置项。在 createElement 内,我们不再调用浏览器的 API,而是仅仅返回一个对象 { tag },并将其作为创建出来的 “DOM 元素”。同样,在 setElementText 以及 insert 函数内,我们也没有调用浏览器相关的 API,而是自定义了一些逻辑,并打印信息到控制台。这样,我们就实现了一个自定义渲染器。我们可以使用下面这段代码来检测它的功能:

const vnode = {
  type: 'h1',
  children: 'hello'
}
// 使用一个对象模拟挂载点
const container = { type: 'root' }
renderer2.render(vnode, container)

上述实现的自定义渲染器不依赖浏览器特有的 API,所以这段代码不仅可以在浏览器中运行,还可以在 Node.js 中运行: 精读 《Vuejs设计与实现》第 7 章(渲染器的设计)所以说自定义渲染器并不是“黑魔法”,它只是通过抽象的手段,让核心代码不再依赖平台特有的 API,再通过支持个性化配置的能力来实现跨平台。

7.4 小结

在这一章,我们探讨了渲染器与响应系统的交互,阐明了如何通过响应系统的能力,在响应式数据变化时自动触发页面的更新(或重渲染),而这个过程与渲染器的具体实现无关。我们以一个极简的渲染器为例,只需用到 innerHTML 属性将 HTML 字符串内容填充至容器。我们接着研究了渲染器相关的基本术语与概念。渲染器(renderer)的职责是将虚拟DOM(virtual DOM,简写为 vdom 或 vnode)转换为特定平台的实体元素。在处理新元素时,渲染器将其挂载至容器;在处理新旧 vnode 同时存在的情况时,渲染器则执行打补丁操作,即比对新旧 vnode,只更新有所变动的部分。最后,我们讨论了自定义渲染器的实现。在浏览器环境,渲染器能够利用 DOM API 完成 DOM 元素的创建、修改和删除。为了使渲染器不直接依赖于浏览器特定的 API,我们将这些操作抽象成可配置的对象。这允许用户在调用 createRenderer 函数创建渲染器时,指定自定义的配置对象,从而实现自定义的行为。我们还设计了一个打印渲染器操作流程的自定义渲染器,它既能在浏览器中运行,也能在 Node.js 环境下工作。

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