Vue 3.2 源码系列:01-不看肯定后悔的《入门 vue 框架设计》

2,470 阅读14分钟

序

博客代码已上传至github

点击这里 即可访问

另提供:完整代码(ts+rollup)和视频教程

image.png

本篇博客要说明的问题

本篇博客是 Vue 3.2 源码系列的第一篇,目的是为了:为了让大家可以掌握学习 Vue 源码的一些基础知识。

那么为了达到这个目的,我们将从两个大的方面来去进行:

  1. 名词概念的同步
  2. 框架设计的原则

名词概念同步

在这一块,我们需要对前端框架中的一些名词以及对应的概念进行基础的同步,需要同步的名词主要有:

  1. 命令式 与 声明式
  2. 运行时 与 编译器
  3. 副作用

命令式 与 声明式

命令式与声明式的概念在前端框架的设计中经常会出现,那么究竟什么是 命令式、什么是 声明式 呢?这一咖,我们就主要来明确这两个概念:

命令式

想要明确命令式,我们从一个日常生活中的例子入手:

现在,张三的妈妈让张三去买酱油。

那么张三怎么做的呢?

  1. 张三拿起钱
  2. 打开门
  3. 下了楼
  4. 到商店
  5. 拿钱买酱油
  6. 回到家

在上面的例子中,我们详细的描述张三买酱油的过程,那么这种:详细描述做事过程的方式 就可以被叫做 命令式。

只看这样例子,可能很多小伙伴依然难以理解。

那么下面,我们就从具体代码的例子来进行描述。

我们来看下面这个需求:

在指定的 div 中展示 “hello world”

这是一个在日常开发中,非常常见的需求,那么想要完成这件事情,我们通过命令式的方式如何进行实现呢?

我们知道命令式的核心在于:关注过程

所以,以上需求通过 命令式 的方式,可以得出如下代码逻辑:

// 1. 获取到指定的 div
const divEle = document.querySelector('#app')
// 2. 为该 div 设置 innerHTML 为 hello world
divEle.innerHTML = 'hello world' 

该代码虽然只有两步,但是它清楚的描述了:完成这件事情,所需要经历的过程

那么假如我们所做的事情,变得更加复杂了,则整个过程也会变得更加复杂。

比如:

为指定的 div 的子元素 div 的子元素 p 标签,展示变量 msg

那么通过命令式完成以上功能,则会得出如下逻辑与代码:

// 1. 获取到第一层的 div
const divEle = document.querySelector('#app')
// 2. 获取到它的子 div
const subDivEle = divEle.querySelector('div')
// 3. 获取第三层的 p
const subPEle = subDivEle.querySelector('p')
// 4. 定义变量 msg
const msg = 'hello world'
// 5. 为该 p 元素设置 innerHTML 为 hello world
subPEle.innerHTML = msg

通过以上两个例子,那么小伙伴们应该已经对命令式有了一个基本的了解。

那么最后,我们对命令式进行一个总结,明确命令式的概念。

所谓命令式指的是:

关注过程 的一种编程方式。他描述了完成一个功能的 详细逻辑与步骤。

声明式

当了解完命令式之后,那么接下来我们就来看 声明式 编程。

所谓 声明式 指的是:不关注过程,只关注结果 的一种编程方式。

那么具体指的是什么意思呢?

我们还是通过刚才那个例子,来进行明确:

现在,张三的妈妈让张三去买酱油。

那么张三怎么做的呢?

  1. 张三拿起钱
  2. 打开门
  3. 下了楼
  4. 到商店
  5. 拿钱买酱油
  6. 回到家

在这个例子中,我们知道:张三所做的事情是命令式,那么张三的妈妈所做的事情就是声明式!

在这样一个事情中,张三妈妈只是发布了一个声明,她并不关心张三如何去买的酱油,只关心最后的结果。

所以说,所谓声明式指的是:不关注过程,只关注结果 的方式。

同样,如果我们通过需求与代码(vue)来进行表示的话,那么同样的需求:

为指定的 div 的子元素 div 的子元素 p 标签,展示变量 msg

将得到如下代码:

<div id="app">
  <div>
    <p>{{ msg }}</p>
  </div>
</div>

在这样的代码中,我们完全不关心 msg 是如何被渲染到 p 标签中的,我们所关心的只是:在 p 标签中,渲染了指定文本而已。

那么最后,我们同样对声明式进行一个总结:

所谓声明式指的是:

关注结果 的一种编程方式。它 并不关心 完成一个功能的 详细逻辑与步骤。(注意:这并不意味着声明式不需要过程!声明式只是把过程进行了隐藏而已!)

命令式 VS 声明式

那么在我们明确了 命令式 和 声明式 的概念之后,很多小伙伴肯定会对这两种编程范式进行一个对比。

是命令式好呢?还是声明式好呢?

那么想要弄清楚这个问题,那么我们首先就需要先搞清楚:评价一种编程方式好还是不好的标准是什么?

通常情况下,我们评价一个编程方式通常会从两个方面入手:

  1. 性能
  2. 可维护性

性能

我们通过一个同样的需求来去分析命令式与声明式在性能方面的表现:

需求:为指定 div 设置文本为 “hello world”

针对以上需求,我们通过命令式的方式来进行代码实现,得出代码为:

div.innerText = "hello world" // 耗时为:1

这个代码是实现此功能的最简代码,我们把它的耗时比作为:1(注意:耗时越少,性能越强)。

然后我们来看声明式的代码实现:

<div>{{ msg }}</div>  <!-- 耗时为:1 + n -->
<!-- 将 msg 修改为 hello world -->

那么:已知实现该需求最简单的方式是 div.innerText = "hello world" 。

所以说无论声明式的代码是如何实现的文本切换,那么它的耗时一定是 > 1 的,所以我们把它的耗时比作 1 + n。

所以,由以上举例可知:命令式的性能 > 声明式的性能

可维护性

分析完了性能的对比之后,接下来我们来分析可维护性的对比。

可维护性代表的维度非常多,但是通常情况下,所谓的可维护性指的是:对代码可以方便的 阅读、修改、删除、增加 。

那么同步了这个认知之后,对于可维护性的衡量维度就非常简单了。

所谓的可维护性的衡量维度,说白了就是:代码的逻辑要足够简单,让人一看就懂。

那么明确了这个概念,我们来看下命令式和声明式在同一段业务下的代码逻辑:

为指定的 div 的子元素 div 的子元素 p 标签,展示变量 msg

// 命令式实现

// 1. 获取到第一层的 div
const divEle = document.querySelector('#app')
// 2. 获取到它的子 div
const subDivEle = divEle.querySelector('div')
// 3. 获取第三层的 p
const subPEle = subDivEle.querySelector('p')
// 4. 定义变量 msg
const msg = 'hello world'
// 5. 为该 p 元素设置 innerHTML 为 hello world
subPEle.innerHTML = msg
// 声明式实现

<div id="app">
  <div>
    <p>{{ msg }}</p>
  </div>
</div>

对于以上代码而言:声明式 的代码明显更加利于阅读,所以也更加利于维护。

所以,由以上举例可知:命令式的可维护性 < 声明式的可维护性

命令式VS声明式总结

由以上分析可知,无论是声明式也好、命令式也好,他们从不同的维度去进行分析时,得出的结论也会不相同:

  1. 针对性能维度:命令式的性能 > 声明式的性能
  2. 针对可维护性维度:命令式的可维护性 < 声明式的可维护性

所以针对于这两种编程方式而言,本身就没有好坏之分。我们所需要做的是:根据需求的场景,来进行对应的取舍。

那么这种取舍具体应该怎么去做呢?咱们等到框架设计原则时,再去明确!

命令式与声明式总结

命令式:关注过程 的一种编程方式。他描述了完成一个功能的 详细逻辑与步骤。

声明式:关注结果 的一种编程方式。它 并不关心 完成一个功能的 详细逻辑与步骤。

同时,小伙伴们也需要知道:所以针对于这两种编程方式而言,本身就没有好坏之分。我们所需要做的是:根据需求的场景,来进行对应的取舍。

运行时 与 编译器

运行时 与 编译器 是前端框架中经常被提到的两个名词。如果我们不了解这两个名词,那么在框架的学习中,将会 "举步维艰"。

所以,我们在这一咖中,需要明确这两个名词,所代表的的含义:

运行时

在 Vue 3 的 源代码 中存在一个 runtime-core 的文件夹,该文件夹内存放的就是 运行时 的核心代码逻辑。

runtime-core 中对外暴露了一个函数,叫做 渲染函数 render

我们可以通过 render 代替 template 来完成 DOM 的渲染:

有些同学可能看不懂以下代码是什么意思,没有关系,这不重要,后面我们会详细说明:


<head>
  <meta charset="UTF-8">
  <title>Document</title>
  <script src="https://unpkg.com/vue@3.2.36/dist/vue.global.js"></script>
</head>

<body>
  <div id="app"></div>
</body>

<script>
  const { render, h } = Vue
  // 生成 VNode
  const vnode = h('div', {
    class: 'test'
  }, 'hello render')

  // 承载的容器
  const container = document.querySelector('#app')

  // 渲染函数
  render(vnode, container)
</script>

运行以上代码,浏览器中会成功被渲染出hello render 的文本。

但是 我们知道,在 Vue 的项目中,我们是需要通过 tempalte 渲染 DOM 节点的,如下:

<template>
  <div class="test">hello render</div>
</template>

但是,对于 render 的例子而言,我们并没有使用 template,而是通过了一个名字叫做 render 的函数,返回了一个 vnode 对象,为什么也可以渲染出 DOM 呢?

带着这样的问题,我们来看:

我们知道在上面的代码中,存在一个核心函数:渲染函数 render。

那么这个 render 在这里到底做了什么事情呢?

我们通过一段代码实例来去看下:

假设有一天你们领导跟你说:

我希望根据如下数据:

{
	type: 'div',
	props: {
		class: test
	},
	children: 'hello render'
}

渲染出这样一个 div:

<div class="test">hello render</div>

你 “冥思苦想” 之后,得出如下代码,你把它叫做 render:

  const VNode = {
    type: 'div',
    props: {
      class: 'test'
    },
    children: 'hello render'
  }
  // 创建 render 渲染函数
  function render(vnode) {
    // 根据 type 生成 element
    const ele = document.createElement(vnode.type)
    // 把 props 中的 class 赋值给 ele 的 className
    ele.className = vnode.props.class
    // 把 children 赋值给 ele 的 innerText
    ele.innerText = vnode.children
    // 把 ele 作为子节点插入 body 中
    document.body.appendChild(ele)
  }

  render(VNode)

在这样的一个代码中,我们成功的通过一个 render 函数渲染出了对应的 DOM,和前面的 render 示例 类似,它们都是渲染了一个 vnode,你觉得这样的代码真是 “妙极了”!

但是你的领导用了一段时间你的 render 之后,却说:天天这样写也太麻烦了,每次都得写一个复杂的 vnode,能不能让我直接写 HTML 标签结构的方式 你来进行渲染呢?

你想了想之后,说:如果这样的话,那就不是以上 运行时 的代码可以解决的了!

没错!我们刚刚所编写的这样的一个 render,就是 运行时 的代码框架。

那么最后,我们做一个总结:

运行时指的是:可以利用 render 把 vnode 渲染成真实 dom 节点。 的代码逻辑。

编译器

现在我们知道,如果只靠运行时,那么是没有办法通过 HTML 标签结构 的方式来进行渲染解析的。

那么想要实现HTML 结构的标签解析,就需要使用到编译器 了!

编译器 的代码主要存在于 compiler-core 模块下。

我们来看如下代码:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Document</title>
  <script src="https://unpkg.com/vue@3.2.36/dist/vue.global.js"></script>
</head>

<body>
  <div id="app"></div>
</body>

<script>
  const { compile, createApp } = Vue

  // 1. 创建一个 html 结构
  const html = `
    <div class="test">hello compiler</div>
  `
  // 2. 利用 compile 函数,生成 render 函数
  const renderFn = compile(html)

  // 3. 创建实例
  const app = createApp({
    // 利用 render 函数进行渲染
    render: renderFn
  })
  // 4. 挂载
  app.mount('#app')
</script>

</html>

对于编译器而言,它的主要作用就是:把 template 中的 html 编译成 render 函数,然后再利用 运行时 通过 render 挂载对应的 DOM。

那么最后,我们做一个总结,所谓编译器指的是:可以把 html 的节点,编译成 render 函数 的函数。

运行时与编译器总结

运行时指的是:可以利用 render 把 vnode 渲染成真实 dom 节点。 的代码逻辑。

编译器指的是:可以把 html 的节点,编译成 render 函数 的函数。

同时小伙伴们应该也知道运行时和编译器是可以一起工作的 (参考编译器模块代码)。

副作用

在 vue 的源码中,会大量的涉及到一个概念,那就副作用。

所以我们需要在这一咖中明确所谓的副作用指的是什么意思。

这里我们先抛出副作用的定义。

副作用指的是:当我们 对数据进行 setter 或 getter 操作时,所产生的一系列后果。

那么具体是什么意思呢?我们分别来说一下:

setter

setter 表示:变量的赋值操作。

比如,当我们执行如下代码时:

msg = '你好,世界'

这里 msg 就触发了一次setter 的行为。

那么假如说,msg 是一个响应性数据,那么这样的一次数据改变,就会影响到对应的视图改变。

所以对于当前的setter 行为,可以表示为:msg 的 setter 行为,触发了一次副作用, 导致视图跟随发生了变化。

getter

getter 表示:变量的取值操作。

比如说,当我们执行如下代码时:

element.innerText = msg

此时对于变量 msg 而言,就触发了一次 getter 操作。

那么这样的一次取值操作,同样会导致 element 的 innerText 发生改变(产生了副作用)。

所以对于当前的getter 行为,可以表示为:msg 的 getter 行为触发了一次副作用, 导致 element 的 innterText 发生了变化。

副作用总结

这一咖,我们明确了副作用的概念。

我们知道所谓副作用指的是:对数据进行 setter 或 getter 操作时,所产生的一系列后果

名词概念同步总结

根据以上内容,我们明确了以下名词对应的概念:

  1. 命令式:关注过程 的一种编程方式。他描述了完成一个功能的 详细逻辑与步骤。
  2. 声明式:关注结果 的一种编程方式。它 并不关心 完成一个功能的 详细逻辑与步骤。
  3. 运行时:可以利用 render 把 vnode 渲染成真实 dom 节点。 的代码逻辑。
  4. 编译器:可以把 html 的节点,编译成 render 函数 的函数。
  5. 副作用:对数据进行 setter 或 getter 操作时,所产生的一系列后果

框架设计原则

想要更加清楚的明确 vue 的源码设计:那么了解尤大的设计思想,明确框架的设计原则是绕不过的一个话题。

所以针对于这一大咖,我们主要就通过以下两个方面来进行明确:

  1. 尤大的设计思想
  2. 框架的设计逻辑

尤大的设计思想:框架设计就是不断地舍取

尤大在一次访谈中提到:框架的设计过程,其实就是一个不断取舍的过程。

那么尤大为什么会这么去说呢?

对于 Vue 而言,当我们使用它的时,是通过 声明式 的方式进行使用,但是 Vue 内部而言,是通过 命令式 来进行的实现。

所以我们可以理解为:Vue 封装了命令式的逻辑,而对外暴露出了声明式的接口。

那么既然如此,我们明知 命令式的性能 > 声明式的性能 , 为什么 Vue 还要选择声明式的方案呢?

其实原因非常的简单,那就是因为:命令式的可维护性 < 声明式的可维护性 。

对于开发者而言,不需要关注实现过程,只需要关注最终的结果即可。

所以对于 Vue 而言,他所需要做的就是:封装命令式逻辑,同时 尽可能的减少性能的损耗! 它需要在 性能 与 可维护性 之间,找到一个平衡。从而找到一个 可维护性更好,性能相对更优 的一个点。

那么回到我们的标题:为什么说框架的设计过程其实是一个不断取舍的过程?

答案也就呼之欲出了,因为:我们需要在可维护性和性能之间,找到一个平衡点。在保证可维护性的基础上,尽可能的减少性能的损耗。 所以框架的设计过程其实是一个不断在 可维护性和性能 之间进行取舍的过程

vue 3 框架设计逻辑

对于 vue3 而言,它的框架设计大致可以分为三大模块:

  1. 响应性:reactivity
  2. 运行时:runtime
  3. 编译器:compiler

我们可以通过以下基本结构来描述一下三者之间的基本关系:

<template>
    <div>{{ proxyTarget.name }}</div>
</template>

<script>
import { reactive } from 'vue'
export default {
    setup() {
        const target = {
            name: '张三'
        }
        const proxyTarget = reactive(target)
        return {
            proxyTarget
        }
    }
}
</script>

为了方便大家进行理解,我们通过三大步,把以上代码进行下解析:

  1. 首先,我们通过 reactive 方法,声明了一个响应式数据。
    1. 该方法是 reactivity 模块对外暴露的一个方法
    2. 可以接收一个复杂数据类型,作为 Proxy 的 被代理对象(target)
    3. 返回一个 Proxy 类型的 代理对象(proxyTarget)
    4. 当 proxyTarget 触发 setter 或 getter 行为时,会产生对应的副作用
  2. 然后,我们在 tempalte 标签中,写入了一个 div。我们知道这里所写入的 html 并不是真实的 html,我们可以把它叫做 模板,该模板的内容会被 编译器( compiler ) 进行编译,从而生成一个 render 函数
  3. 最后,vue 会利用 运行时(runtime) 来执行 render 函数,从而渲染出真实 dom

以上就是 reactivity、runtime、compiler 三者之间的运行关系。

当然除了这三者之外, vue 还提供了很多其他的模块,比如:SSR ,咱们这里只是 概述了基本的运行逻辑。

框架设计原则总结

  1. 尤大的设计思想:框架的设计过程,其实就是一个不断取舍的过程
  2. 框架的设计逻辑:reactivity、runtime、compiler 三者构建了 vue 运行的核心逻辑

总结

本篇文章,我们主要明确了两件事情:

  1. 名词概念的同步
  2. 框架设计的原则

在名词概念中,我们主要明确了:

  1. 命令式
  2. 声明式
  3. 运行时
  4. 编译器
  5. 副作用

这五个基本名词。

在框架设计的原则中,我们分别从:

  1. 尤大的设计思想
  2. 框架的设计逻辑

两个方面来进行了明确。

只要大家掌握了以上内容,那么就进入了 阅读 vue 3.2 源码 的基本门槛了!

点击访问下一篇