🤯vue3核心源码剖析(六)

277 阅读7分钟

🚀vue3 从 template 到真实 DOM 的渲染(一)

前言

本文将会实现一个简单的 vue 运行时 runtime,通过 rollup 打包 runtime,并确保能在 html 中渲染出元素。

本文的函数命名全部采用 vue3 的函数名称,降低对 vue3 源码的学习成本。在巩固自己对 vue3 的理解的同时,把知识分享给大家是我的乐趣所在。

🤣 如有错漏,请多指教 ❤

简述一下大致的过程

  1. template 编译成 render 函数
  2. 根据编译后的结果生成 vnode
  3. patch 函数对 vnode 进行拆箱操作
  4. 通过 createElement 把 vnode 转成真实的 DOM
  5. 挂载 dom 到指定的 目标 容器上

预览本 demo 的使用方法

本demo暂时只实现组件和HTML元素挂载DOM功能。实现组件节点更新功能在后续章节。

万事开头,有一个最终结果往往能让我们朝着一个方向前进。

<style>
  .red {
    width: 100px;
    color: red;
    background-color: aqua;
  }
  .blue {
    width: 100px;
    color: blue;
    background-color: aqua;
  }
  .flex {
    display: flex;
  }
  .container-r {
    flex-flow: row nowrap;
    justify-content: space-between;
    align-items: center;
  }
</style>
<body>
  <div id="app"></div>
  <script src="./main.js" type="module"></script>
</body>
// In main.js

const rootContainer = document.querySelector('#app')

createApp(App).mount(rootContainer)
// In app.js
// 这里就是template经过编译后,得到的根组件组合对象(如果用户使用optionsAPI,测绘得到根组件选项对象),里面会包含一个render()函数
export default {
  render() {
    return h('div', {
      id: 'root',
      class: ['flex', 'container-r']
    }, [
      h('p', {class: 'red'}, 'red'),
      h('p', {class: 'blue'}, 'blue')
    ])
  },
  setup() {
    // 返回对象或者h()渲染函数
    return {
      name: 'hi my app'
    }
  }
}

编译 template 成 render 函数

这里关于template怎么编译成具有render函数的特殊对象,具体代码实现我们暂时忽略。它涉及AST的知识。

流程大致分为三大点:

  1. parse

    • 解析template生成AST节点
  2. transform

    • 针对AST进行一些转换
  3. codegen

    • 根据不同的AST节点调用代码生成函数输出代码字符串,进而生成render函数

生成虚拟节点 vnode

为什么要有vnode这个东西?

vnode是真实dom的抽象存在,当我们业务越来越复杂,这时候大量操作dom显然会消耗大量的性能,而有了vnode 的存在,我们可以预先在vnode进行操作,操作完成之后,在统一把真实想要的dom的样子渲染处理,不必在dom每次操作一次渲染一次。

那什么时候生成vnode呢?

创建vue上下文的时候,createApp接受根组件选项对象或者根组件组合对象,并返回一系列的应用API(component、mount、config、use等等),其中mount功能把传入的根组件选项对象或者根组件组合对象挂载到指定的根节点中。在这个挂载之前必须把根组件对象转换成vnode,这时候就要调用createVNode

我们可以简单实现以下createApp

export function createApp(rootComponent: any) {

  // mount函数可接受dom实例对象或者dom的ID属性(string)
  const mount = (rootContainer: any) => {
    const vnode = createVNode(rootComponent)
    // 根组件的vnode
    render(vnode, rootContainer)
  }
  return {
    mount,
  }
}

type是组件对象或者元素,props是组件的内联属性,children是组件的子组件或者子元素。


// 由于只是实现简单的渲染vnode功能,所以目前只需要返回vnode对象
export function createVNode(type: any, props?: any, children?: any) {
  const vnode = {
    type,
    props,
    children,
  }
  return vnode
}

按照官方文档来说h()函数的实现也是createVNode(),返回一个虚拟节点。

export function h(type: any, props?: any, children?: any) {
  return createVNode(type, props, children)
}

render内部用于执行patch()(什么是patch,见下方经过 patch 拆箱

export function render(vnode: any, container: any) {
  // 做patch算法
  patch(vnode, container)
}

经过 patch 拆箱

patch会判断当前传入参数的vnode.type属性是什么类型。

  • 如果vnode.type是string类型,说明这个vnode是普通元素标签,patch内部会调用processElement进行对普通元素的vnode继续处理,processElement内部又用了mountElement()把vnode.type用createElement创建出dom元素。

  • 如果vnode.type是object类型,说明这个vnode是一个组件。再次调用vnode.type.render()可以得到子元素的vnode,用子元素的vnode再次调用patch()进行拆箱操作,直到vnode.type是普通元素标签为止。

// 传入vnode,递归对一个组件或者普通元素进行拆箱,在内部对vnode的type判断执行不同的处理函数
function patch(vnode: any, container: any) {
  // 检查是什么类型的vnode
  console.log('vnode', vnode.type)
  if(typeof vnode.type === 'string'){
    // 是一个普通元素?处理vnode是普通标签的情况
    processElement(vnode, container)
  }else if(isObject(vnode.type)){
    // 是一个组件?处理vnode是组件的情况
    processComponent(vnode, container)
  }
}

processComponent 处理组件

处理组件的事情大致分为三件事:

  1. 创建组件实例instance
  2. 把setup的返回值setupState挂载在组件实例instance上。
  3. 把render函数挂载在组件实例instance上,以便对render返回的vnode做patch()拆箱处理。

这些事情都在mountComponent里面完成


function processComponent(vnode: any, container: any) {
  mountComponent(vnode, container)
}
function mountComponent(vnode: any, container: any) {
  const instance = createComponentInstance(vnode)
  // 安装组件
  setupComponent(instance)

  //
  setupRenderEffect(instance, container)
}
// 创建组件实例
export function createComponentInstance(vnode: any) {
  const type = vnode.type
  const instance = {
    vnode,
    type,
  }
  return instance
}
function setupRenderEffect(instance: any, container: any) {
  console.log(instance)
  // 这个render()已经在finishComponentSetup处理过了,就是 instance.type.render() 特殊对象的render()
  const subTree = instance.render()
  // 对子树进行拆箱操作
  patch(subTree, container)
}
//
export function setupComponent(instance: any) {
  // initProps()
  // initSlots()
  setupStatefulComponent(instance)
}
// 初始化组件的状态
function setupStatefulComponent(instance: any) {
  const Component = instance.type
  const { setup } = Component
  // 有时候用户并没有使用setup()
  if (setup) {
    // 处理setup的返回值,如果返回的是对象,那么把对象里面的值注入到template上下文中
    // 如果是一个函数h(),那么直接render

    const setupResult = setup()

    handleSetupResult(instance, setupResult)
  }
  finishComponentSetup(instance)
}
// 处理组件的setup的返回值
function handleSetupResult(instance: any, setupResult: any) {
  if (isFunction(setupResult)) {
  // TODO handle function
  } else if (isObject(setupResult)) {
    // 把setup返回的对象挂载到setupState上
    instance.setupState = setupResult
  }
}
// 结束组件的安装
function finishComponentSetup(instance: any) {
  const Component = instance.type // 遇到h('div',{}, this.name)  这里Component将为'div'

  if (instance) {
    instance.render = Component.render
  }
}

processElement 处理元素

处理元素的事情大致分为三件事:

  1. 根据vnode.type创建HTML元素。
  2. 根据vnode.children的类型判断是string还是array,如果是string,那么说明children是文本节点,如果是array,我们并不知道每个元素到底是HTML元素还是组件,这点同样通过patch处理。
  3. 根据vnode.props对象,遍历来设置HTML元素的属性。
// 此时的vnode.type是一个string类型的HTML元素
function processElement(vnode: any, container: any) {
  mountElement(vnode, container)
}

生成真实 DOM 节点

生成真实的DOM节点,其实逻辑就在mountElement()里面。

mountElement()函数传入两个参数:vnodecontainer

因为此时调用mountElement的vnode.type已经被认定为是普通的HTMLElement,那么就能用document.createElement(vnode.type)创建dom节点,注意:这里的vnode.type是string类型。

除此之外,我们还必须处理vnode.props属性,它是包含着这个HTML元素的所有内联属性的对象,比如idclassstyle等等。如果class有多个类名,通过数组表示。处理props属性我们联想到了遍历props对象然后调用setAttribute()函数,把属性添加到dom节点上。

我们还得处理dom节点的子节点,这里我们分两种情况:

  1. vnode.children是一个string类型,说明子节点是文本节点。
  2. vnode.children是一个array类型,不清楚里面的子元素是文本节点还是组件,这时我们可以通过patch()做拆箱处理。

定义一个mountChildren()

里面的实现大致就是:通过遍历vnode.children数组,让每个子元素都执行patch()

function mountElement(vnode: any, container: any) {
  const el = document.createElement(vnode.type) as HTMLElement
  let { children, props } = vnode
  if (isString(children)) {
    el.textContent = children
  } else if (Array.isArray(children)) {
    mountChildren(vnode, el)
  }
  // 对vnode的props进行处理,把虚拟属性添加到el
  for (let key of Object.getOwnPropertyNames(props).values()) {
    if(Array.isArray(props[key])){
      el.setAttribute(key, props[key].join(' '))
    }else{
      el.setAttribute(key, props[key])
    }
  }
  container.append(el)
}

// 处理子节点
function mountChildren(vnode: any, container: any){
  vnode.children.forEach((vnode: any) => {
    patch(vnode, container)
  });
}

使用rollup打包代码

  1. 安装rollup

    终端输入命令pnpm i rollup @rollup/plugin-typescript typescript tslib -D

    没有pnpm的可以使用npm

  2. 在根目录新建rollup.config.js

     import typescript from '@rollup/plugin-typescript'
     export default {
       input: './src/index.ts',
       output: [
         {
           file: './lib/guide-toy-vue3.cjs.js',
           format: 'cjs'
         },
         {
           file: './lib/guide-toy-vue3.esm.js',
           format: 'es'
         }
       ],
       plugins: [typescript()]
     }
    
  3. package.json添加执行脚本

"scripts": {
  "build": "rollup -c rollup.config.js"
},

最后@感谢阅读

当学习成为一种习惯,知识就变成了常识。

本阶段的完整源码

本代码已经挂在stackblitz上了,大家进去之后,他会自动帮我们安装依赖。

  • 查看效果example/index.html,只需要在终端输入npm start
  • 打包runtime-core的代码可以在终端输入npm run build

完整源码