likes
comments
collection

Vue3源码学习-3 | 设计思路

作者站长头像
站长
· 阅读数 14
Vue3源码学习-3 | 设计思路

Vue3源码学习-3 | 设计思路

在第一节中,我们阐述了框架设计是权衡的艺术;在第二节中,详细讨论了框架设计的几个核心要素。而框架设计讲究全局视角的把控,存在着一条核心思路,并围绕核心展开。在这一节中:

  • 从全局视觉了解Vue3的设计思路工作机制及其重要的组成部分
  • 当我们把组成部分看作是独立的功能模块,看看他们之间是如何相互配合

3.1 虚拟 DOM

引出虚拟DOM

  • UI 可以简单的理解成页面
  • 想了解 UI 请去往第一节

从第一节中我们知道Vue3是一个声明式的 UI 框架,也就是使用声明式的代码来编写页面。

我们先来了解编写前端页面涉及到的内容:

  • DOM元素 例如 div 标签。
  • 属性 如id、class等通用属性。
  • 事件 如click、keydown等。
  • 元素的层级结构 DOM树的层级结构,既有子节点,又有父节点

现在有一个问题:

如果让你设计一个声明式的UI框架,你会怎么设计?让一步来说,如何用声明式的代码来描述上述的内容?

这里我就不卖关子了,直接给出 Vue3 中相应的解决方案:

  • 使用与 HTML 标签一致的方式来描述 DOM 元素,例如使用 <div></div>来描述一个 div 标签。
  • 使用与 HTML 标签一致的方式来描述属性,例如<div id='app'></div>
  • 使用:v-bind来描述动态绑定的属性,例如<div :id='Tshe'></div>
  • 使用@v-on来描述事件,例如点击事件<div @click="e"></div>
  • 使用与HTML标签一致的方式来描述层级结构,例如一个具有span子节点的div标签 <div><span></span></div>

可以看出在Vue3中,用户不需要手写任何命令式代码,这就是所谓的声明式地描述 UI。

看到这里 你可能会说:小标题不是虚拟DOM吗?你在这里讲啥声明式?接着看!

除了上面这种声明式地描述UI之外,还可以使用js对象来描述,上代码!

const title = {
    // 标签名称
    tag:'h1',
    // 标签属性
    props:{
        onClick:handler
    },
    // 子节点
    children:[
        {tag:'span'}
    ]
   }

对应的Vue3模板就是:

<h1 @click="handler"><span></span></div>

看到这里,又有一个问题在你脑海中浮现:

使用Vue3模板和用js对象描述UI有何不同呢?

这里先不给予答案,你先看下面的代码:举个例子,分别用Vue3模板和js对象来分别描述h1~h6

  • js对象 描述:
// h 标签的级别
let level = 3
const title = {
    tag:`h${level}`,// h3 标签
}

可以看出,只需要一个变量来代表h标签即可,只要控制level的值,对应的标签也会在h1和h6之间变化。

  • 使用Vue3模板来描述:
// 不得不穷举
<h1 v-if="level === 1"><h1>
<h1 v-else-if="level === 2"><h1>
<h1 v-else-if="level === 3"><h1>
<h1 v-else-if="level === 4"><h1>
<h1 v-else-if="level === 5"><h1>
<h1 v-else-if="level === 6"><h1>

是不是略显笨重。看好我接下来要说的话!

而!使用js对象来描述UI的方式,其实就是所谓的 虚拟DOM。现在大家是不是觉得 虚拟DOM 并没有想象中的那么神秘。

虚拟DOM 的运用

正是因为虚拟DOM的这种灵活性,Vue3除了支持使用模板描述UI外,还支持使用虚拟DOM描述UI。

在Vue组件中手写渲染函数 就是使用 虚拟DOM 来描述 UI,代码如下:

import { h } from 'vue'

export default {
    render(){
        return h('h1',{ onClick:handler }) // 虚拟DOM
    }
}

看到上面的代码,你是不是觉得我在骗你:“这里是h函数调用哇!哪里来的js对象”。嗷!其实h函数的返回值就是一个对象,这是Vue3内部编写的,提高用户的效率!

将上面h函数调用的代码改成js对象,那么内容就是:

export default {
    render() {
        return {
            tag:'h1',
            props:{ onClick:handler }
        }
    }
  }

所以h函数就是一个辅助创建虚拟DOM的工具函数。

好!咱接着写流水账!这个时候!是不是就会想到如何渲染!也就是说什么是组件的渲染函数,就是上面的render函数。

有请收看下面的内容!

3.2 初始渲染器

正片

渲染器的作用:就是把虚拟DOM渲染为真实DOM。

想必!大家现在已经了解什么是虚拟DOM了吧,它其实就是用 js对象 来描述真实的DOM节点。

那么:

虚拟DOM是如何变成真实DOM并渲染到浏览器页面中的呢?

渲染器! 闪亮登场! 来先给个

Vue3源码学习-3 | 设计思路

这里初步认识渲染器,以便更加地理解Vue.js的工作原理。

下面看看,渲染器是如何工作的吧!

有如下虚拟DOM:

const vnode = {
    tag:'div',
    props:{
        onClick:() => alert('hello')
    },
    children:'click me'
}

简单解释一下上面的这段代码:

  • tag 用来描述标签名称
  • `props`` 对象,用来描述
    标签的属性、事件等内容
  • children 用来描述标签的子节点

说明:上面的vnode本身只是一个js对象,并没有特殊含义。

编写一个渲染器,把上面的这段虚拟DOM渲染为真实DOM:

function renderer(vnode,container) {
    // 使用 vnode.tag 作为标签名称创建 DOM 元素
    const el = document.createElement(vnode.tag)
    // 遍历 vnode.props,将属性、事件添加到 DOM 元素
    for(const key in vnode.props) {
        if(/^on/.test(key)) {
            // 如果 key 以 on 开头,说明它是事件
            el.addEventListener(
                key.substr(2).toLowerCase(),// 事件名称 onClick ---> click
                vnode.props[key] // 事件处理函数
            )
        }
    }
    
    // 处理 children
    if(typeof vnode.children === 'string') {
        // 如果 children 是字符串,说明它是元素的文本子节点 
        el.appendChild(document.createTextNode(vnode.children))
    } else if(Array.isArray(vnode.children)) {
        // 递归地调用,renderer 函数渲染子节点,使用当前元素 el 作为挂载点
        vnode.children.forEach(child => renderer(child,el))
    }
    
    // 将元素添加到挂载点下
    container.appendChild(el)
}

上面 renderer 函数接收个参数:

  • vnode 虚拟DOM对象
  • container 一个真实DOM元素,作为挂载点,渲染器会把虚拟DOM渲染到该挂载点下

接下来,调用 renderer 函数即可:

renderer(vnode,document.body) // body 作为挂载点

分析渲染器renderer的实现思路,分三步:

  • 创建元素
  • 为元素添加属性和事件
  • `处理children

看到这里,是不是觉得 渲染器 并没有我们想象中的那么神秘。

其实不然,别忘了我们这里现在所做的还仅仅只是创建节点,渲染器的精髓都在于更新节点阶段(敲黑板)。但无论如何,渲染器的工作原理其实很简单,归根到底,都是使用一些我们熟悉的DOM操作API来完成渲染工作

3.3 组件的本质

来到这里,我们已经初步了解了虚拟DOM渲染器。在有的时候,我们或多或少有听到一个词:渲染组件

那么问题来了:

  • 组件又是什么呢?
  • 组件和虚拟DOM有什么关系?
  • 渲染器如何渲染组件?

大家请记住一句话:组件就是一组DOM元素的封装

这组DOM元素就是组件要渲染的内容,因此可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:

const MyComponent = function() {
    return {
        tag:'div',
        props: {
            onClick: () => alert('hello')
        },
        children:'click me'
    }
}

可以看到,组件的返回值也是虚拟DOM,它代表组件要渲染的内容。搞清楚了组件的本质,我们就可以定义用虚拟DOM来描述组件了。很简单,可以让虚拟DOM对象中的tag属性来存储组件函数:

const vnode = {
    tag:MyComponent
}

就像tag:'div'用来描述 div 标签一样,tag:MyComponent用来描述组件,只不过此时的tag属性不是标签名称,而是组件函数。为了能够渲染组件,需要渲染器的支持。修改前面提到的renderer函数,如下所示:

function renderer(vnode,container) {
   if(typeof vnode.tag === 'string') {
        // 说明 vnode 描述的是标签元素
        mountElement(vnode,container)
    }else if (typeof vnode.tag === 'function') {
        //说明 vnode 描述的是组件
        mountComponent(vnode,container)
    }
}

可以看到,上面的代码中出现了mountElementmountComponent

  • 其中mountComponent的代码与上文中的 renderer 函数一致:
function mountComponent(vnode,container) {
    // 使用 vnode.tag 作为标签名称创建 DOM 元素
    const el = document.createElement(vnode.tag)
    // 遍历 vnode.props,将属性、事件添加到 DOM 元素
    for(const key in vnode.props) {
        if(/^on/.test(key)) {
            // 如果 key 以 on 开头,说明它是事件
            el.addEventListener(
                key.substr(2).toLowerCase(),// 事件名称 onClick ---> click
                vnode.props[key] // 事件处理函数
            )
        }
    }
    
    // 处理 children
    if(typeof vnode.children === 'string') {
        // 如果 children 是字符串,说明它是元素的文本子节点 
        el.appendChild(document.createTextNode(vnode.children))
    } else if(Array.isArray(vnode.children)) {
        // 递归地调用,renderer 函数渲染子节点,使用当前元素 el 作为挂载点
        vnode.children.forEach(child => renderer(child,el))
    }
    
    // 将元素添加到挂载点下
    container.appendChild(el)
}
  • 再来看 mountComponent 函数是如何实现的:
function mountComponent(vnode,container) {
    // 调用组件函数,获取组件要渲染的内容(虚拟DOM)
    const subtree = vnode.tag()
    // 递归地调用 renderer 渲染 subtree
    renderer(subtree,container)
}

上面的代码中。首先调用vnode.tag函数,它其实就是组件函数,其返回值是虚拟DOM,即组件要渲染的内容,这里我们称之为subtree。即subtree就是虚拟DOM,好!直接 调用 renderer 函数完成渲染即可

番外:如果 组件 变心,成了 “对象”

这里希望大家能够举一反三,例如组件一定得是函数吗?当然不是,它完全可以雨露均沾,变成一个js对象来表达组件:

// MyComponent 是一个对象
const MyComponent = {
    render() {
        return {
            tag:'div',
            props: {
                onClick: () => alert('hello')
            },
            children:'click me'
        }
    }
}

为了完成对象组件的渲染,我们需要修改renderer渲染器以及mountComponent函数

  • 修改渲染器的判断条件
function renderer(vnode,container) {
   if(typeof vnode.tag === 'string') {
        // 说明 vnode 描述的是标签元素
        mountElement(vnode,container)
    }else if (typeof vnode.tag === 'object') { // 如果是对象,说明 vnode 描述的是组件
        //说明 vnode 描述的是组件
        mountComponent(vnode,container)
    }
}
  • 修改mountComponent函数:
function mountComponent(vnode,container) {
    // vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟DOM)
    const subtree = vnode.tag.render()
    // 递归地调用 renderer 渲染 subtree
    renderer(subtree,container)
}

在上述代码中,vnode.tag是表达组件的对象,调用该对象的 renderer 函数得到组件要渲染的内容,也就是 虚拟DOM。

其实Vue.js中的有状态组件就是使用对象结构来表达的

3.4 模板的工作原理

无论是手写 虚拟DOM(渲染函数)还是使用 模板,都属于声明式地描述 UI,并且Vue.js同时支持这两种UI的方式

模板

使用过 Vue 的小伙伴都敲过"模板"吧:

<div @click="handler">
     click me 
</div>

上文我们说了虚拟DOM是如何渲染成真实DOM的,那么现在让我们想想 模板 是如何工作的?

回答:这就要提到 Vue 框架中另外一个重要的组成部分:编译器

编译器

对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数

// 上面的模板代码转换而成的
render() {
    return h('div',{onClick:handler},'click me')
 }

实例: .vue 文件为例

代码:

<template>
    <div @click="handler">
        click me
    </div>
</template>

<script setup>
   const handler = () => {...}
</script>

其中<template>标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到<script>标签快的组件对象上,所以最终在浏览器里运行的代码就是:

export default {
    const handler = () => {...}
    render(){
    return h('div',{onClick:handler},'click me')
    }
}

所以,无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的。然后渲染器再把渲染函数返回的虚拟DOM渲染为真实DOM,这就是模板的工作原理,也是Vue.js渲染页面的流程

特别说明:这里只说明了编辑器的作用及角色即可。

3.5 Vue.js是各个模块组成的有机整体

如上面所说的,组件的实现依赖于渲染器,模板的编译依赖于编译器,并且编译后产生的代码是根据渲染器虚拟DOM的设计决定的,因此Vue.js的各个模块之间是相互关联、相互制约的,共同构成了一个有机整体。 因此,我们在学习Vue原理的时候,应该把各个模块结合到一起去看,才能明白到底是怎么回事

渲染器编译器为例

  • 如下模板
<div id="foo" :class="cls"></div>
  • 编译器会把上面模板编译成渲染函数
render(){
    // 为了效果更加直观,这里没有使用 h 函数,而直接采用虚拟DOM对象
    // 下面的代码等价于:
    // return h('div',{id:"foo",class:cls})
    return {
        tag:'div',
        props:{
            id:'id',
            class:'cls'
        }
    }
}

可以发现,在这段代码中。cls是一个变量,它可能会发生变化。我们知道渲染器的作用之一就是寻找并且只更新变化的内容,所以当变量cls发生变化时,渲染器会自行寻找变更点。对于渲染器来说,这个寻找的过程需要花费一些力气。

那么!从编译器的角度来看,它能否知道哪些内容会发生变化呢?如果编译器有能力分析动态内容,并在编译阶段把这些信息提取出来,然后再交给渲染器,这样渲染器不就不需要花费大力气去寻找变更点了吗?这个好想法并且能够实现。

Vue.js的模板是有特点的,拿上面的模板来说,我们一眼就能看到其中id='foo'是永远不会变化的,而:class=""cls是一个v-bind绑定,它是可能变化的。所以编译器能够识别出哪些是静态属性,哪些是动态组件,在生成代码的时候完全可以附带这些信息:

render() {
    return {
        tag:'div',
        props: {
            id:'foo',
            class:'cls'
        },
        patchFlags:1 // 假设数字1代表class是动态的
     }
}

如上面的代码所示,在生成的虚拟DOM对象中多出了一个patchFlags属性,我们假设数字1代表‘calss是动态的’,这样渲染器看到这个标志时就知道:“哦豁!只要class属性会发生变化。”对于渲染器来说,就相当于省去了寻找变更点的工作量,性能自然就提升了。

我们知道编译器和渲染器之间存在信息交流,它们互相配合使得性能进一步提升,而它们之间交流的媒介就是虚拟DOM对象

总结

  • 先说出 虚拟DOM
  • 然后通过虚拟DOM,引出 渲染器
  • 通过Vue模板拿出 编译器
  • 最后说明 渲染器编译器是如何配合的,体现出Vue中各个模块相互作用的优点和重要性!!!