likes
comments
collection
share

手动实现Vue3 & 原理解析(三) —— renderer渲染器 && render渲染 && patch对比更新

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

前言

本篇解析参阅 vue3源码、崔大的mini-vue、霍春阳大佬的《Vuejs设计与实现》尽可能记录我的Vue3源码阅读学习过程。我会结合自己的思考,提出问题,找到答案,附在每一篇的底部。希望大家能在我的文章中也能一起学习,一起进步,有 get 到东西的可以给作者一个小小的赞作为鼓励吗?谢谢大家!

手写简易vue3 renderer渲染器 && render渲染 && patch对比更新

以 render 和 renderer 的差别先开个头吧

很多人会把这两者混淆,我们也顺便将这一节会讲到的一些名词来做一个提前声明

  • render: 渲染 是一个动词,渲染什么。
  • renderer: 渲染器 是一个名词,它的作用就是把虚拟 DOM 渲染为特定平台的真实元素(在浏览器上就是渲染为真实 DOM 元素)。
  • virtual DOM: 虚拟 DOM,简写成vdom,由一个个节点(vnode)组成的树型结构。
  • virtual node: 虚拟节点,简写成vnode,组成树型结构的基本单位,注意任意一个vnode都可以是一棵子树。
  • mount:挂载,渲染器把虚拟 DOM 节点渲染成真实 DOM 节点的过程就叫做挂载,Vue中也提供了一个 mounted 钩子在这个挂载完成时触发,可以让我们拿到真实的 DOM 节点。
  • container: 容器,渲染器挂载需要提供一个容器给它,这样它才知道挂载在哪个位置,我们会提供一个 DOM 元素来作为这个容器。
  • patch:比较更新,调用 render 函数时,如果已经有旧的节点(old vnode)了,那就需要走 patch 来做比较,找到并更新变更的位置,是渲染逻辑关键入口。patch 除了比较更新也能用来执行挂载(首次渲染,没有old vnode)。

我们通过代码来描述一下他们的一个大致关系:

function createRenderer() {
  function render(vnode, container) {
    if (vnode) {
      // 新的 vnode 存在,和旧的 vnode 一起传递给 patch函数进行更新
      patch(container._vnode, vnode, container)
    } else {
      // ...
    }
    // 新的 vnode 赋值给 container 的 _vnode 属性
    container._vnode = vnode
  }
  return render
}

上述的代码就是用 createRenderer 函数来创建一个渲染器,调用这个函数就会得到一个 render 函数,render 函数以 container 作为挂载点将 vnode 渲染为真实 DOM添加到挂载点下触发 mounted,render 函数的内部有一个 patch 函数,它能比较更新新老节点,找到变更位置并更新,也能实现首次的挂载

你可能会疑惑为啥要多一个 createRenderer 来包装一层呢 我干嘛不直接定义 render函数呢?

那就带着疑问接着往下看吧

renderer渲染器

实际上 renderer 的作用不仅仅是返回一个 render 函数这么简单,它还包含了一些 patch(比较新旧节点,并更新)、hydrate(服务端渲染用到的) 功能

在 Vue3 中,createRenderer 函数最终除了返回上边提到的 render 以及 hydrate 以外,还会返回一个叫 createAPP 的函数

return {
  render,
  hydrate,
  createApp: createAppAPI(render, hydrate)
}

实际上我们看到这个 createApp 实际上是一个 createAppAPI 的东西,必须传入 render,里边实际上就是包装了一层函数叫做 mount,它创建一个 vnode 节点来调用这个 render 函数实现挂载。hydrate是可选的我们这就不说它了,后边如果有服务端渲染篇的话我们再好好聊这个。

接着呢,我们再强调一下 renderer 渲染器的作用是 把虚拟 DOM 渲染为特定平台的真实元素,也就是说他是支持个性化配置能力来实现跨平台的。

在代码里,createRenderer 是接收一个 options 参数的,然后它运用解构来拿到对应的操作

export function createRenderer(options) {
  const {
    // 创建 element
    createElement: hostCreateElement,
    // 对比元素新老属性
    patchProp: hostPatchProp,
    // 插入 element
    insert: hostInsert,
    // 删除 element
    remove: hostRemove,
    // 设置 text
    setElementText: hostSetElementText
  } = options
}

createRenderer 实际上就是包含了诸多的处理函数,具体的我们看一下以下图片

render函数

render 函数在最初的时候我们也看到了其实它就是调用 patch,由 patch 来根据是否是首次渲染来判断直接挂载到真实 DOM 或是对比新旧节点找到变更点实现更新。

另外 render 函数 还负责了 当传入的 vnode 是空且当前的挂载点存在 vnode 的时候,意味着需要执行卸载操作

简单的,我们可以认为 render 函数的实现如下:

function render(vnode, container) {
  if (vnode) {
    // 新的 vnode 存在,和旧的 vnode 一起传递给 patch函数进行更新
    patch(container._vnode || null, vnode, container, null, null)
  } else {
    if (container._vnode) {
      // 卸载,清空容器
      container.innerHTML = ""
    }
  }
  // 新的 vnode 赋值给 container 的 _vnode 属性
  container._vnode = vnode
}

总的来说,在 Vue3 中,render函数更复杂的逻辑其实是交给了 patch,它只是作为一个中间函数,去调用 patch。 注意,render 是被返回出去了,也就是说我们可以通过

const { render } = createRenderer()
render(vnode, container)
// or
const renderer = createRenderer()
renderer.render(vnode, container)

来直接调用它

它也在 createAppAPI -> mount -> createVnode + render() 中被使用

关于 render 的关联关系如下图: 手动实现Vue3 & 原理解析(三) —— renderer渲染器 && render渲染 && patch对比更新

关于 patch 的具体接收参数

另外,上边卸载容器的方式使用了 container.innerHTML = "" ,这是不严谨的,因为:

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

正确的操作应该是根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生 DOM 操作方法将该 DOM 元素移除。所以我们在 mountElement 中给 vnode.el 引用了真实的 DOM 元素,让 vnode 与真实 DOM 建立起了联系,卸载的时候就通过 vnode.el.parentNode 来执行 removeChild 方法实现卸载

我们用代码来解释这一段话:

// 省略其它代码,这里 vnode 是 null,但是 挂载点存在 _vnode,说明需要执行卸载操作
if (container._vnode) {
  // 根据 vnode 获取要卸载的真实 DOM 元素
  const el = container._vnode.el
  // 获取 el 的父元素
  const parent = el.parentNode
  // 调用 removeChild 移除元素
  if (parent) parent.removeChild(el)
}

自定义渲染器

上面我们提到了 renderer 渲染器是支持个性化配置能力来实现跨平台的,也就是说别想当然的理解成只能在浏览器里去做渲染。

事实上我们可以这么使用 renderer 渲染器来实现我们自己的自定义渲染,这里我们演示一个打印渲染器操作流程的自定义渲染器:

  1. 创建渲染器:
const renderer = createRenderer({
  createElement(tag) {
    console.log(`创建了元素 ${tag}`)
    return { tag }
  },
  setElementText(el, text) {
    console.log(`设置 ${JSON.stringify(el)} 的文本内容: ${text}`)
  },
  insert(el, parent, anchor = null) {
    console.log(`将 ${JSON>stringify(el)} 添加到 ${JSON.stringify(parent)} 下`)
    parent.children = el
  }
})
  1. 验证这个渲染器的代码:
const vnode = {
  type: "h1",
  shapeFlag: 9, // 标识当前节点是 element 且 children 也是 element
  children: "Hello Btrya"
}
// 使用一个对象模拟挂载点
const container = { type: "app" }
renderer.render(vnode, container)

然后我们就能在浏览器看到我们的提示出现了: 手动实现Vue3 & 原理解析(三) —— renderer渲染器 && render渲染 && patch对比更新

在 codesandbox 中尝试

这里的 shapeFlag 的机制在 Vue3 中特别有意思,我们后边会讲到。

patch函数

这是本章的重点函数,patch函数,再次强调一下它的作用:

  1. 首次渲染,执行挂载
  2. 新旧节点比较变更内容并更新

其主要接收参数如下:

/**
 * @param n1  old vnode
 * @param n2  new vnode
 * @param container   挂载点
 * @param parentComponent   
 * @param anchor
 */ 
patch(n1, n2, container, parentComponent, anchor)

patchProps 处理元素 props 对象

特性:

  • patchProps 支持用户通过 createRenderer 的时候通过 options 传入自定义的 pathProps
  • 主要处理传入节点的属性,包括 HTML Attributes、 DOM Properties、类名、事件

我们先来看一下对于一个 vnode 的定义是怎样的:

const vnode = {
  type: "p",
  props: {
    id: "foo", // 元素属性
    onClick: () => { // 使用 onXxx 描述事件
      alert('clicked')
    },
    class: 'foo, bar'
  },
  children: 'text'
}
属性

在元素上的属性实际上还要区分是 HTML Attribute 还是 DOM Properties

  • HTML Attribute: 比如<input id="foo" type="text" value="foo"/>,那这里的 id="foo" type="text" value="foo" 指的就是定义在 HTML 标签上的属性。
  • DOM Properties: 当我们利用 js 获取到 dom 元素,比如document.getElementById("foo"). 我们能看到这个 dom 元素上有很多的属性方法,也有一些是和 HTML 标签属性同名的属性。

对于这两种情况我们要考虑是直接给元素设置属性比如: el.disabled = false 还是利用 setAttribute 函数来设置属性,我们需要做一些处理。

// 针对一些只读 DOM Properties 处理
function shouldSetAsProps(el, key, value) {
  if (key === 'form' && el.tagName === 'INPUT') return false
  // 兜底
  return key in el
}
class 的处理

Vue.js 对 class 属性做了一些增强,比如对于一个 props,它的 class 可以有多种很灵活的设置,比如:

class: 'foo bar'

class: { foo: true, bar: false }

class: [
  'foo bar',
  { baz: true }
]

对于最后一种,其实 vue 提供了一个叫做 normalizeClass 函数,可以转换成 'foo bar baz' 的形式,方便更简单的处理。

对于类名的设置,其实有几种:

  • el.className
  • el.setAttribute
  • el.classList

那么经过多次的性能比较,className 的性能是最优秀的,然后是 classList,最后才是 setAttribute,所以,最终在代码里针对 class 的处理就是以 className 赋值的形式处理的。

事件

我们可以看到对于事件的描述,采用 onXxx 这种形式来描述,所以在我们 patchProps 函数中对于事件属性我们可以这么处理:

  1. 匹配以 on 开头的属性,认为它是事件,处理这个属性名,得到对应的事件名称:如 onClick ---> click
  2. 如果之前已经绑定了事件,那么就是更新事件,我们需要把上一次的事件移除
  3. 绑定事件,使用 addEventListenerel 绑定事件

最终 patchProps 的代码如下:

function patchProps(el, key, preValue, nextValue) {
  // 事件处理 匹配以 on 开头的属性
  if (/^on/.test(key)) {
    // 拿到对应的事件名
    const name = key.slice(2).toLowerCase()
    // preValue 有值说明是更新事件,我们需要把上一次的事件移除
    preValue && el.removeEventListener(name, preValue)
    // 绑定事件, nextValue 就是新的 vnode 事件 name 对应的处理函数
    el.addEventListener(name, nextValue)
  } else if (key === 'class') { // 类名处理
    el.className = nextValue || ''
  } else if (shouldSetAsProps(el, key, nextValue)) { // dom特性处理
    const type = typeof el[key]
    // 针对如 disabled 属性的处理
    if (type === 'boolean' && nextValue === '') {
      el[key] = true
    } else {
      el[key] = nextValue
    }
  } else {
    el.setAttribute(key, nextValue) // html属性
  }
}

简单的对于事件属性的处理大概就是这样了,当然在 vue3 实际的源码中,还对这个过程做了一些优化:

  • 利用一个包装函数 invoker ,内部根据 invoker.value 是否是数组遍历或直接调用 invoker.value() 方法(由于事件绑定存在对一个事件绑定多个事件处理函数的情况,所以代码执行到 invoker.value 的时候需要判断它是否是数组);
  • 如果是第一次,那么创建这个 invokerel.addEventListener(name, invoker)
  • 后续每次有新的事件函数,就去修改 invoker.value,这样就不需要每次都去 removeEventListener
  • 解决 绑定事件处理函数发生在事件冒泡之前 的问题,每次绑定事件给 invoker.attached 属性 赋值当前的精度时间,利用 el.timeStampinvoker.attached 比较,如果事件发生时间早于 invoker.attached 则不触发处理函数

简单来说,invoker 帮助解决了每次需要 removeEventListener 的问题,还解决了事件冒泡和事件更新之间相互影响的问题

而对于 element 来说,他是通过 el._vei 来保存 一个 invokers(注意有个 s)对象,这个对象的 key 就是不同的事件名,值就是对应事件名的 invoker 函数,依靠这个对象来使单个元素可以保存多种事件。

shapeFlag的机制

shapeFlags 是 Vue3 用于判断当前虚拟节点的一个类型

文件的位置在 package/shared/shapeFlags.ts

详情如下:

export const enum ShapeFlags {
  ELEMENT = 1,
  FUNCTIONAL_COMPONENT = 1 << 1,
  STATEFUL_COMPONENT = 1 << 2,
  TEXT_CHILDREN = 1 << 3,
  ARRAY_CHILDREN = 1 << 4,
  SLOTS_CHILDREN = 1 << 5,
  TELEPORT = 1 << 6,
  SUSPENSE = 1 << 7,
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
  COMPONENT_KEPT_ALIVE = 1 << 9,
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

我们能看到实际上是使用了一个枚举来标识不同的类型,利用了位运算符 << 来让 1 向左位移 n 位。

其中 ELEMENT 表示的就是元素,它的值是 1 我们还能看到 TEXT_CHILDREN 表示的其实就是 子元素是 文本类型, 它的值是 1 << 3 = 1000(2进制) = 8

那么一个元素它的子元素是文本类型它就可以被表示为 1001(2进制) = 9

在判断的时候使用位运算符 & 就可以知道当前的元素是否具备对应的类型了,比如:

// 判断当前的节点是否有儿子节点是文本的情况
9 & ShapeFlags.TEXT_CHILDREN // 8 
16 & ShapeFlags.TEXT_CHILDREN // 0

在 Vue3 中就是利用这样的判断来知道这个虚拟节点是否具备一个或多个类型。

那么一个 vnode 是怎么计算它的 shapeFlag 的呢?

问题:vnode 是怎么计算它的 shapeFlag 的呢?

在 createVnode 的时候,其实会有一个初始化的操作,判断当前的这个 vnode 的一个基本类型,具体如下:

const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : __FEATURE_SUSPENSE__ && isSuspense(type)
    ? ShapeFlags.SUSPENSE
    : isTeleport(type)
    ? ShapeFlags.TELEPORT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : isFunction(type)
    ? ShapeFlags.FUNCTIONAL_COMPONENT
    : 0

那么分别就可以得到 ELEMENT、SUSPENSE、TELEPORT、STATEFUL_COMPONENT、FUNCTIONAL_COMPONENT 这五种基本类型中的其中一种

接着就会根据当前 vnode 的 children 进一步判断 children 的类型,通过位运算符来 |= 进行合并,比如:

vnode.shapeFlag |= isString(children)
      ? ShapeFlags.TEXT_CHILDREN
      : ShapeFlags.ARRAY_CHILDREN

比如现在的 children 是 TEXT_CHILDREN,vnode 的基本类型是 ELEMENT,那么根据枚举我们可以知道它的 shapeFlag 最终就是 1001 = 9

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