likes
comments
collection
share

从零开始学习Vue3源码 ——— (三)Vue3渲染原理

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

前言

这篇文章开始,我们就要继续学习Vue3中其它包的作用了,在前文也提到过,Vue3的组成,是有编译时运行时的概念。

  • 编译时:其实就是将模板转化为函数的过程,举个例子,就是将我们写的模板代码,如<template>{{ msg }}</template>转化为函数。之所以用模板的方式来写,纯粹是为了减少开发的心智负担,能够根据语义化进行代码书写,而不必用各种函数调用的方式来生成。
  • 运行时:运行时又分为两个部分,那么运行时的核心,也就是runtime-core是不依赖任何平台的,

那么模块之间的依赖就是runtime-dom提供了浏览器运行环境中的DOM API,而runtime-core提供了虚拟dom的核心逻辑,通过runtime-dom提供的API,从而生成真实DOM,而runtime-core中又会引入reactivity包中的内容,所以整体的流程是Vue -> runtime-dom -> runtime-core -> reactivity后者均是前者的子级,由前者导入使用。我们本篇文章主要讲运行时相关的内容。

runtime-core包中,提供了一个方法createRenderer,看着虽然陌生,但是在我们项目中的createApp(在runtime-dom包中实现),其实底层调用的就是这个方法,那么我们便从这个方法开始,一步步学习runtime-domruntime-core这两个包吧!

runtime-dom的实现

首先,我们依旧是要创建文件夹,和之前的套路一样,先看下示例效果,再进行代码书写。和reactivity包位置相同,我们创建runtime-dom文件夹和package.json文件,并且在runtime-dom文件夹下边创建src/index.ts作为入口;创建dist/index.html作为效果展示示例页面。同样,我们把node_modules文件夹中,Vue官方打包好的compiler-dom.esm-browser.js文件,复制进dist目录下,和我们之前reactivity的操作一模一样,先看看人家官方的方法实现效果,再自己实现一遍。最后,别忘了将script/dev.js中的target改为runtime-dom,这样,我们就是从runtime-dom/src/index.ts作为入口进行打包了。万事具备,我们写一下测试代码,看看有没有跑通吧:

// runtime-dom/src/index.ts
export const testName = '测试runtime-dom'

然后执行npm run dev,对我们runtime-dom模块的代码进行打包,之后修改dist/index.html文件内容,执行npx serve dist,在浏览器控制台观测结果,成功打印了testName,便说明我们已经调通了。

// runtime-dom/dist/index.html
<script type="module">
import { testName } from './runtime-dom.js'
console.log(testName)
</script>

我们首先看下两个方法:createRendererh的用法。

<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'
</script>

那么这两个方法,其实是runtime-core中提供的,前文也说过,其实runtime-dom提供的主要是浏览器相关的API,作为参数传入createRenderer中。什么意思呢?我们一步一步来看。

相信h方法,大家都有所耳闻,可以生成一个虚拟DOM,那么调用createRenderer就可以将虚拟DOM,通过我们传入的API,在页面中生成真实的DOM,我们再次修改示例代码,然后查看控制台结果。

<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'
const renderer = createRenderer()
// 将h1渲染到页面上
renderer.render(h('h1', 'hello world'), document.getElementById('app'))
</script>

发现控制台竟然报错了:

从零开始学习Vue3源码 ——— (三)Vue3渲染原理

代码非常简单,就是想要将h1标签渲染到页面上,但是为啥报错了呢?我们查看报错的内容,可以发现,提示我们缺少insert方法,这是啥意思呢?没错,前文提到了runtime-dom这个包中,提供了DOM操作的API,将这些API配置项等传入createRenderer,才能够正常的执行代码,所以我们此时要传入一个insert方法,告诉runtime-core在将虚拟DOM转化为真实DOM,进行插入操作,要用我们传入的这个insert方法:

<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'

const renderOptions = {
  // 我们自己提供一个insert方法,当做api来调用
  insert (el, container, anchor = null) {
    container.insertBefore(el, anchor)
  }
}
// 传入配置项,里边包含各种操作的api
const renderer = createRenderer(renderOptions)
// 将h1渲染到页面上
renderer.render(h('h1', 'hello world'), document.getElementById('app'))
</script>

此时我们再刷新页面,发现又有了新的报错,很明显,有了前边的经验,我们很容易能明白,原来还缺少一个创建元素的方法,runtime-core不知道用哪个API来进行元素的创建,于是我们又补充了一下代码:

从零开始学习Vue3源码 ——— (三)Vue3渲染原理
<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'

const renderOptions = {
  // 我们自己提供一个insert方法,当做api来调用
  insert (el, container, anchor = null) {
    container.insertBefore(el, anchor)
  },
  // 注意,报错中的hostCreateElement是对我们配置中的命名做了个映射,所以我们配置这里命名为createElement
  createElement (element) {
    return document.createElement(element)
  }
}
// 传入配置项,里边包含各种操作的api
const renderer = createRenderer(renderOptions)
// 将h1渲染到页面上
renderer.render(h('h1', 'hello world'), document.getElementById('app'))
</script>

我们再运行代码,发现又有了一个报错,还真是没完没了- -,我们不难分析出来,还需要提供一个设置元素值的方法,于是我们再次修改了配置项

从零开始学习Vue3源码 ——— (三)Vue3渲染原理
<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'

const renderOptions = {
  // 我们自己提供一个insert方法,当做api来调用
  insert (el, container, anchor = null) {
    container.insertBefore(el, anchor)
  },
  // 注意,报错中的hostCreateElement是对我们配置中的命名做了个映射,所以我们配置这里命名为createElement
  createElement (element) {
    return document.createElement(element)
  },
  // 将文字内容赋值给元素
  setElementText (el, text) {
    el.innerHTML = text
  }
}
// 传入配置项,里边包含各种操作的api
const renderer = createRenderer(renderOptions)
// 将h1渲染到页面上
renderer.render(h('h1', 'hello world'), document.getElementById('app'))
</script>

这次我们再刷新页面,可以发现,页面上终于打印出了hello world,也就是说,至少要提供创建元素、插入元素、设置元素内容这3个API,才能够在页面上正常显示一个基本的元素。

说了这么多,大家应该知道runtime-dom的大致作用了吧?没错,就是提供了上述的这些个renderOptionsDOM相关的API。所以,我们有了大致的思路,便开始实现一下吧!

目录结构如下:

runtime-dom
    src
      module // 存放模块文件
      index.ts // 入口文件
      nodeOps.ts // 操作节点相关的api
      patchProp.ts // 属性相关的api   
// src/index.ts
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'

// 将渲染时所需要的属性做整理
export const renderOptions = Object.assign({ patchProp }, nodeOps)

nodeOps.ts文件中,我们存放了和节点相关的操作,不止上文中提到的三个,常见的还有如下一些API

// src/nodeOps.ts文件
export const nodeOps = {
  insert: (child, parent, anchor) => { // 添加节点
      parent.insertBefore(child, anchor || null);
  },
  remove: child => { // 节点删除
      const parent = child.parentNode;
      if (parent) {
          parent.removeChild(child);
      }
  },
  createElement: (tag) => document.createElement(tag),// 创建节点
  createText: text => document.createTextNode(text),// 创建文本
  setText: (node, text) => node.nodeValue = text, //  设置文本节点内容
  setElementText: (el, text) => el.textContent = text, // 设置文本元素中的内容
  parentNode: node => node.parentNode, // 父节点
  nextSibling: node => node.nextSibling, // 下一个节点
  querySelector: selector => document.querySelector(selector) // 查找元素
}

除了节点操作,还涉及到了对比属性的方法,比如处理类,样式的替换,事件的绑定解绑,这些都写在src/patchProp.ts文件中:

// src/patchProp.ts文件

import { patchClass } from './module/class'
import { patchStyle } from './module/style'
import { patchEvent } from './module/event'
import { patchAttr } from './module/attr'

// 比对属性的方法
export const patchProp = (el, key, prevValue, nextValue) => {
  if (key === 'class') {
    patchClass(el, nextValue)
  } else if (key === 'style') {
    patchStyle(el, prevValue, nextValue);
  } else if (/^on[^a-z]/.test(key)) {
    patchEvent(el, key, nextValue)
  } else {
    patchAttr(el, key, nextValue)
  }
}

针对不同情况的处理,把这些文件单独放在module文件夹下

// src/module/attr.ts 文件
export function patchAttr(el, key, value) { // 更新属性
  if (value == null) {
    el.removeAttribute(key);
  } else {
    el.setAttribute(key, value);
  }
}
// src/module/class.ts 文件
export function patchClass(el, value) { // 根据最新值设置类名
  if (value == null) {
    el.removeAttribute('class');
  } else {
    el.className = value;
  }
}
// src/module/event.ts 文件

function createInvoker(initialValue) {
  const invoker = (e) => invoker.value(e)
  // 真实的方法,是绑定在.value上的
  invoker.value = initialValue
  return invoker;
}
export function patchEvent(el, rawName, nextValue) {
  const invokers = el._vei || (el._vei = {})
  const exisitingInvoker = invokers[rawName] // 是否缓存过

  if (nextValue && exisitingInvoker) {
    // 有新值并且绑定过事件,需要进行换绑操作
    exisitingInvoker.value = nextValue;
  } else {
    // 获取注册事件的名称
    const name = rawName.slice(2).toLowerCase()
    if (nextValue) {// 缓存函数
      const invoker = (invokers[rawName]) = createInvoker(nextValue)
      el.addEventListener(name, invoker);
    } else if (exisitingInvoker) {
      el.removeEventListener(name, exisitingInvoker);
      invokers[rawName] = undefined
    }
  }
}
// src/module/style.ts 文件
export function patchStyle(el, prev, next) { // 更新style
  const style = el.style;
  for (const key in next) { // 用最新的直接覆盖
    style[key] = next[key]
  }
  if (prev) {
    for (const key in prev) {// 老的有新的没有删除
      if (next[key] == null) {
        style[key] = null
      }
    }
  }
}

那么有了这些个APIruntime-core就知道,应该用哪些方法将虚拟DOM转化为真实DOM了。之后,我们引入自己的renderOptions看看能不能正常渲染:

<script type="module">
import { createRenderer, h } from './runtime-dom.esm-browser.js'
import { renderOptions } from './runtime-dom.js'
const renderer = createRenderer(renderOptions)
renderer.render(h('h1', 'hello'), app)
</script>

页面正常渲染了!那么针对上文这种方式,适合针对某个平台(跨平台),自己定义一套渲染API,可以随意进行定制化,如果在浏览器环境下,其实正如上文所说,API都已经在runtime-dom中了,所以在内部又提供了一个方法(render),默认把这一坨renderOptions自动传进去了,不用我们再手动传入:

<script type="module">
import { createRenderer, h, render } from './runtime-dom.esm-browser.js'
// import { renderOptions } from './runtime-dom.js'
//const renderer = createRenderer(renderOptions)
//renderer.render(h('h1', 'hello'), app)
render(h('h1', 'hello'), app)
</script>

再次运行代码,发现结果没变,还是能正常运行,说明这两种方式都可行,使用render的话,相当于默认传入浏览器环境下的API,使用createRenderer可以自定义传入API,比较灵活,所以,我们最后还需要改一下入口文件的内容:

// src/index.ts
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'
import { createRenderer as renderer } from '@vue/runtime-core'

// 将渲染时所需要的属性做整理
export const renderOptions = Object.assign({ patchProp }, nodeOps)

export function createRenderer (options) {
  // 提供了渲染的api,但实际调用的是runtime-core中的方法
  return renderer(options)
}

// 专门给浏览器环境中使用
export function render (vnode, container) {
  const renderer = createRenderer(renderOptions)
  return renderer.render(vnode, container)
}
// 将runtime-core中的方法都进行导出
export * from '@vue/runtime-core'

诶,这样就没啥问题了,既然从runtime-core包中引入了渲染的方法,那么接下来我们需要的就是来实现runtime-core的核心逻辑了。

runtime-core的实现

老规矩,首先还是创建相应文件夹:

runtime-core
      src
        index.ts // 入口文件
        createVNode.ts // 创建虚拟DOM
        renderer.ts // 创建真实DOM进行渲染
        h.ts // 封装createVNode,形成h方法

先在入口文件进行导出操作,防止后边忘记掉,我们不难发现,正如我们之前所说,runtime-domDOM相关API传给runtime-coreruntime-core中又使用了reactivity模块,至此,三个模块便互相串通了起来。

// index.ts 入口文件
export * from './renderer'
export * from './createVNode'
export * from './h'
export * from '@vue/reactivity'

我们还是先紧跟着runtime-dom的逻辑,先写下renderer.ts的大概逻辑,那么这个就是我们runtime-dom中使用的createRenderer方法,实际上调用的还是runtime-core中的方法。

// renderer.ts文件
export function createRenderer(renderOptions) {
  // 从renderOptions中解构api,并重命名
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    querySelector: hostQuerySelector
  } = renderOptions
  const render = (vnode, container) => {
    console.log('render')
  }
  return {
    render
  }
}

接下来我们写一下createVNode.ts中的逻辑。所谓虚拟DOM,就是用对象的形式,来形容一个节点,标注了各种信息,为了之后转化成真实DOM

import { ShapeFlags } from '@vue/shared'
// 判断是不是一个虚拟节点
export function isVNode(value) {
  return value ? value.__v_isVNode === true : false
}
export function createVNode(type, props, children = null) {
  const shapeFlag = typeof type === 'string' ? ShapeFlags.ELEMENT : 0
  // 虚拟节点包含的信息
  const vnode = {
    __v_isVNode: true, // 判断对象是不是虚拟节点
    type,
    props,
    key: props && props['key'], // 虚拟节点的key,主要用于diff算法
    el: null, // 虚拟节点对应的真实节点
    children,
    shapeFlag
  }
  if (children) {
    let type = 0;
    if (Array.isArray(children)) {
      type = ShapeFlags.ARRAY_CHILDREN;
    } else {
      children = String(children);
      type = ShapeFlags.TEXT_CHILDREN
    }
    vnode.shapeFlag |= type
    // 如果shapeFlag结果为9 说明元素中包含一个文本
    // 如果shapeFlag结果为17 说明元素中有多个子节点
  }
  // 返回的虚拟节点并且标注了虚拟节点的类型,之后生成真实DOM时,根据shapFlag调用不同的方法。
  return vnode
}

我们在@vue/shared包中补充下ShapFlags,并且来详细解释一下,这到底是个什么东西。

// shared/src/index.ts
export const enum ShapeFlags { // Vue3提供的标识
  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, // keep-alive相关
  COMPONENT_KEPT_ALIVE = 1 << 9, // keep-alive相关
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 按位或操作,相当于包含两种类型
}

很多朋友可能对<< | &移位、按位或、按位与很陌生,就算知道其定义,也不知道有哪些个使用场景。其实在Vue3中,就有很好的例子。比如这个ShapFlags通过名称我们便能大致猜出来,是描述形状的标志,比如一个普通的元素,就用1来代表,函数式组件就用1向左移1位来表示,普通的状态组件,就用1向左移2位来表示。为啥要用移位操作呢?搞几个普通的枚举值不行么,其实,之所以用移位来进行标识,是为了后续进行按位与,按位或操作提供了极大的便利。我们举个例子,我们有如下的3种权限:

测试:1,二进制为001
开发者:1 << 1,二进制为010
超级管理员:1 << 2,二进制为100

那么当A,既是开发者,又是超级管理员的时候,那么只需要将开发者超级管理员的权限进行按位或操作,也就是010100进行按位或操作,得到的结果为110,大于0。那么判断A有没有测试权限,只需要将刚才的结果和测试的权限进行按位与操作,即110001进行按位与操作,得到的结果为000,等于0。从而我们可以发现,在使用移位符操作的枚举值,进行|操作后,相当于权限相加的操作,进行&操作后,如果结果大于0,说明包含相关权限,如果结果等于0,则说明不包括相关权限。

那么再回到我们之前的实例,我们改动下index.html中的代码,来调试下代码有没有生效,先调试下createVNode方法:

<script>
import { createVNode } from './runtime-dom.js'
console.log(createVNode('div',null, ['hello', 'world']))
</script>

打印结果可以看到,虚拟节点成功的被创建了:

从零开始学习Vue3源码 ——— (三)Vue3渲染原理

但是createVNode这个方法,写法是固定的,比如传参的顺序和类型,都不能变,并不灵活,(特别注意,createVNode的第三个参数,只能传字符串和数组类型的数据),所以,我们可以基于createVNode进行封装,那么这个方法就是我们熟悉的h方法了,首先我们先看下h方法能怎么传参:

  • 只传1个参数,就是标签;
  • 传2个参数,可能是传标签和属性:h('div', { style: { color: 'red' } }),也可能是传标签和子元素:h('div', h('span', null, 'hello')) h('div', [h('span', null, 'hello')]),还可能是传标签和内容:h('div', 'hello')
  • 传3个参数,那就是和createVNode的传参一样了,即h('div', { style: {color: 'red'} }, 'hello')
  • 传3个以上的参数,第二个参数必须是属性,之后的参数都作为内容:h('div', null, 'hello', 'world', '!')

那么知道了以上的用法,我们便可以按照传参数量的不同,分别处理相应逻辑,来编写h方法了:

// h.ts 文件
import { isArray, isObject } from '@vue/shared'
import { createVNode, isVNode } from './createVNode'

export function h(type, propsOrChildren?, children?) {
  const l = arguments.length
  if (l === 2) {
    // 只有属性,或者只有一个生成的虚拟元素的时候
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // 区分第二个参数是属性还是生成的虚拟元素,比如h('div',h('span'))
      if (isVNode(propsOrChildren)) {
        // 如果是虚拟元素,根据createVNode的传参要求,就要用数组包起来
        return createVNode(type, null, [propsOrChildren])
      }
      // 如果是h('div',{style:{color:'red'}}),则进行如下传参
      return createVNode(type, propsOrChildren)
    } else {
      // 传递儿子列表h('div',null,[h('span'),h('span')])或者h('div', 'hello')的情况
      return createVNode(type, null, propsOrChildren)
    }
  } else {
    // 除了前2个,后边的都是子元素
    if (l > 3) {
      children = Array.prototype.slice.call(arguments, 2)
    } else if (l === 3 && isVNode(children)) {
      // 第三个参数传入的是生成的虚拟元素
      children = [children]
    }
    return createVNode(type, propsOrChildren, children)
  }
}

到此,我们h方法便写好了,是不是没有想象中那么困难呢?

接下来,就该完善createRenderer方法,也就是渲染方法了,之后二者一结合,就能够在页面中,将虚拟DOM渲染成真实DOM了,刚才我们写到了render方法,那我们继续完善吧!

// renderer.ts 文件
export function createRenderer(renderOptions) {
  // 从renderOptions中解构api,并重命名
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    querySelector: hostQuerySelector
  } = renderOptions
  const render = (vnode, container) => {
    // 虚拟节点渲染成真实DOM,挂载到页面上
    // 卸载操作 render(null, container)
    // 初始化和更新虚拟DOM
    if (vnode == null) {
      // 卸载逻辑
    }else {
      // 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
      patch(container._vnode || null, vnode, container)
    }
    container._vnode = vnode
  }
  return {
    render
  }
}
const mountElement = (vnode, container) => {
  // 将虚拟节点转化为真实DOM
}

// 虚拟节点对比逻辑
const patch = (n1, n2, container) => {
  if(n1 == n2) {
    return
  }
  if(n1 == null) {
    // 初始化情况
    mountElement(n2, container)
  }else {
    // n1, n2不相等,diff算法逻辑
  }
}

那么整个流程的架子我们已经搭好了,接下来就一个个来实现具体的方法,我们先实现将虚拟DOM转化为真实DOMmountElement方法:

// renderer.ts 文件
export function createRenderer(renderOptions) {
  // 从renderOptions中解构api,并重命名
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    querySelector: hostQuerySelector
  } = renderOptions
  const mountChildren = (children, container) => {
    for(let i = 0; i < children.length; i++) {
      // 递归调用patch方法
      patch(null,children[i],container)
    }
  }
  const mountElement = (vnode, container) => {
    // 将虚拟节点转化为真实DOM
    const { type, props, shapeFlag, children } = vnode
    // 创建真实元素,挂载到虚拟节点上
    let el = vnode.el = hostCreateElement(type)
    // 如果有props,则处理属性
    if (props) {
      for (const key in props) {
        hostPatchProp(el, key, null, props[key])
      }
    }
    if(children) {
      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // 说明是文本
        hostSetElementText(el, vnode.children)
      }else if(shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 说明有多个儿子
        mountChildren(vnode.children, el)
      }
    }
    hostInsert(el,container); // 插入到容器中
  }
  // 虚拟节点对比逻辑
  const patch = (n1, n2, container) => {
    if(n1 == n2) {
      return
    }
    if(n1 == null) {
      // 初始化情况
      mountElement(n2, container)
    }else {
      // n1, n2不相等,diff算法逻辑
    }
  }
  const render = (vnode, container) => {
    // 虚拟节点渲染成真实DOM,挂载到页面上
    // 卸载操作 render(null, container)
    // 初始化和更新虚拟DOM
    if (vnode == null) {
      // 卸载逻辑
    }else {
      // 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
      patch(container._vnode || null, vnode, container)
    }
    container._vnode = vnode
  }
  return {
    render
  }
}

我们可以清楚的看到,就是用了runtime-dom中的API,来递归生成真实的DOM元素,我们来验证一下,代码是否有问题吧:

<script type="module">
import { h, render } from './runtime-dom.js'
render(h('h1',{style: {color: 'red'}}, 'hello'), app)
</script>

在浏览器中运行完代码,发现,hello已经成功被渲染到页面上了。那么初始化阶段的渲染的逻辑,便写完了!那么初始化逻辑写完后,我们再写一下卸载的逻辑,什么是卸载的逻辑呢?可以理解为render(null, app),也就是传入了null的时候,要把页面中元素清除掉,我们之前已经预留出来卸载逻辑的位置,那我们现在便可以来完善了:

// renderer.ts 文件
export function createRenderer(renderOptions) {
  // 从renderOptions中解构api,并重命名
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    querySelector: hostQuerySelector
  } = renderOptions
  const mountChildren = (children, container) => {
    for(let i = 0; i < children.length; i++) {
      // 递归调用patch方法
      patch(null,children[i],container)
    }
  }
  const mountElement = (vnode, container) => {
    // 将虚拟节点转化为真实DOM
    const { type, props, shapeFlag, children } = vnode
    // 创建真实元素,挂载到虚拟节点上
    let el = vnode.el = hostCreateElement(type)
    // 如果有props,则处理属性
    if (props) {
      for (const key in props) {
        hostPatchProp(el, key, null, props[key])
      }
    }
    if(children) {
      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // 说明是文本
        hostSetElementText(el, vnode.children)
      }else if(shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 说明有多个儿子
        mountChildren(vnode.children, el)
      }
    }
    hostInsert(el,container); // 插入到容器中
  }
  // 虚拟节点对比逻辑
  const patch = (n1, n2, container) => {
    if(n1 == n2) {
      return
    }
    if(n1 == null) {
      // 初始化情况
      mountElement(n2, container)
    }else {
      // n1, n2不相等,diff算法逻辑
    }
  }
  // 卸载元素的方法
  const unmount = vnode => {
    const { shapeFlag } = vnode
    if(shapeFlag & ShapeFlags.ELEMENT) {
      // 如果是一个元素,那么直接删除DOM即可
      hostRemove(vnode.el)
    }
  }
  const render = (vnode, container) => {
    // 虚拟节点渲染成真实DOM,挂载到页面上
    // 卸载操作 render(null, container)
    // 初始化和更新虚拟DOM
    if (vnode == null) {
      // 卸载逻辑
      if(container._vnode) {
        // 找到对应的真实节点,将其卸载
        unmount(container._vnode)
      }
    }else {
      // 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
      patch(container._vnode || null, vnode, container)
    }
    container._vnode = vnode
  }
  return {
    render
  }
}

到这里,就只剩下元素更新的逻辑了,那么元素更新的逻辑,涉及的内容又非常多,我们先讲一些关键性的点,从而为后续文章做好铺垫。我们用几个不同的例子,来表明什么时候触发更新,也就是说,怎么判断两个虚拟节点相同,可以复用:

<script type="module">
    import { h, render } from './runtime-dom.js'
    // 1、可以看到,标签名不相同,所以就不能够进行DOM复用
    render(h('h1',{style: {color: 'red'}}, 'hello'), app)
    render(h('div',{style: {color: 'blue'}}, 'world'), app)
    // 2、那么如果标签名相同,又不想复用DOM,那么这时候就需要提供key,来进行区分了
    render(h('div',{style: {color: 'blue'}, key: 1}, 'world'), app)
    render(h('div',{style: {color: 'blue'}, key: 2}, 'world'), app)
    // 3、如果标签名相同,也没有key,那么就进行复用
    render(h('h1',{style: {color: 'red'}}, 'hello'), app)
    render(h('h1',{style: {color: 'red'}}, 'hello'), app)
</script>

所以我们可以得到结论,当两个虚拟节点的标签类型不同时候,或者两个虚拟节点标签类型相同,但是key不同,那么就不会进行复用;如果两个虚拟节点的标签类型相同,并且不传入key,或者key相同,那么就进行复用。

所以,我们需要继续改进下更新下patch方法中的代码:

// renderer.ts 文件
export function createRenderer(renderOptions) {
  // 从renderOptions中解构api,并重命名
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    querySelector: hostQuerySelector
  } = renderOptions
  const mountChildren = (children, container) => {
    for(let i = 0; i < children.length; i++) {
      // 递归调用patch方法
      patch(null,children[i],container)
    }
  }
  const mountElement = (vnode, container) => {
    // 将虚拟节点转化为真实DOM
    const { type, props, shapeFlag, children } = vnode
    // 创建真实元素,挂载到虚拟节点上
    let el = vnode.el = hostCreateElement(type)
    // 如果有props,则处理属性
    if (props) {
      for (const key in props) {
        hostPatchProp(el, key, null, props[key])
      }
    }
    if(children) {
      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // 说明是文本
        hostSetElementText(el, vnode.children)
      }else if(shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 说明有多个儿子
        mountChildren(vnode.children, el)
      }
    }
    hostInsert(el,container); // 插入到容器中
  }
  
  // 判断是不是相同的虚拟节点
  const isSameVNode = (n1, n2) => {
    return n1.type === n2.type && n1.key === n2.key
  }
  // 处理元素
  const processElement = (n1, n2, container) => {
    if (n1 == null) {
      // 初始化情况
      mountElement(n2, container)
    } else {
      // 元素相同,属性更新了,可以进行复用,进行diff的逻辑
      console.log(n1, n2);
    }
  }
  // 虚拟节点对比逻辑
  const patch = (n1, n2, container) => {
    if(n1 == n2) {
      return
    }
    if(n1 && !isSameVNode(n1, n2)) {
      unmount(n1)
      n1 = null
    }
    // 处理元素
    processElement(n1, n2, container)
  }
  // 卸载元素的方法
  const unmount = vnode => {
    const { shapeFlag } = vnode
    if(shapeFlag & ShapeFlags.ELEMENT) {
      // 如果是一个元素,那么直接删除DOM即可
      hostRemove(vnode.el)
    }
  }
  const render = (vnode, container) => {
    // 虚拟节点渲染成真实DOM,挂载到页面上
    // 卸载操作 render(null, container)
    // 初始化和更新虚拟DOM
    if (vnode == null) {
      // 卸载逻辑
      if(container._vnode) {
        // 找到对应的真实节点,将其卸载
        unmount(container._vnode)
      }
    }else {
      // 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
      patch(container._vnode || null, vnode, container)
    }
    container._vnode = vnode
  }
  return {
    render
  }
}

我们又将当两个虚拟节点不相同时的更新逻辑写完了,我们改下调试代码,在页面上看效果,发现,过了1秒钟后,成功的渲染了新的虚拟节点:

 <script type="module">
    import { h, render } from './runtime-dom.js'
    render(h('h1', { style: { color: 'red' } }, 'hello'), app)
    setTimeout(() => {
      render(h('div', { style: { color: 'blue' } }, 'world'), app)
    }, 2000)
  </script>

所以,当两个虚拟节点可以复用时的逻辑,我们就放到后续文章中,进行详细的讲解,因为会涉及到我们耳熟能详的diff算法。

结语

我又又又烧起来了,不知道是因为最近开始健身着凉了,还是因为流感,从去年10月份的急性化脓性扁桃体发炎的高烧,到12月份集体小阳人,再到今年3月初的发烧,短短半年,已经烧了3次了。真不知道再来几次,会不会嘎了,只有生病的时候,才知道,健康是多么的宝贵,别无他想。也可能是这半年以来,心理压力一直非常大,内心不够强大,所以也影响了抵抗力。很多时候,都是过分的预支了对未来的焦虑,对一些没发生的事情忧心忡忡,再加上自媒体的各种鼓吹贩卖焦虑,每天爆炸式接收到这种负面消息,对未来的期望和自己的信息被一点点蚕食掉。修炼内心,确实非常的难,慢慢调整状态吧。写完了前三篇文章,感觉收获也是满满,当我们从头进行手写代码之后,很多常见的原理性面试题,都能够从根源上明白是如何实现的,为什么这样实现,一点点进步吧!

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