这张图,把 vue3 的源码讲清楚了!

Hello,大家好,我是 Sunday。

最近一位同学在学习 vue3 源码的时候,把 vue 3 的大部分核心逻辑都整理到了脑图之中:

vue3.png

整理的内容非常详细。应该会对所有还在学习 vue3 源码的同学都有所帮助。所以分享给大家!

那么今天,咱们就借助这位同学的脑图作为契机,来为大家捋一捋 【Vue3 框架设计原理】(看完设计原理之后,再看脑图收获会更大哦~)

01:前言

在了解 Vue3 框架设计之前,我们需要做两件事情,而这两件事情也是今天的主要内容。

  1. 我们需要同步并明确一些词汇的概念,比如:声明式、命令式、运行时、编译时...。这些词汇将会在后面的框架设计中被经常涉及到。
  2. 我们需要了解一些关于 前端框架 的一些基础的概念。框架的设计原则,开发者开发体验原则。以此来帮助大家解决一些固有的疑惑,从而揭开 vue 神秘的面纱。

那么准备好了?

我们开始吧!

02:编程范式之命令式编程

针对于目前的前端开发而言,主要存在两种 编程范式

  1. 命令式编程
  2. 声明式编程

这两种 范式 一般是相对来去说的。

命令式

那么首先我们先来说什么叫做 命令式

具体例子:

张三的妈妈让张三去买酱油。

那么张三怎么做的呢?

  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

那么通过以上例子,相信大家可以对命令式的概念有了一个基础的认识。

最后做一个总结,什么叫做命令式呢?

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

03:编程范式之声明式编程

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

针对于声明式而言,大家其实都是非常熟悉的了。

比如以下代码,就是一个典型的 声明式

<div>{{ msg }}</div>

对于这个代码,大家是不是感觉有些熟悉?

没错,这就是 Vue 中非常常见的双大括号语法。所以当我们在写 Vue 模板语法 的时候,其实一直写的就是 声明式 编程。

那么声明式编程具体指的是什么意思呢?

还是以刚才的例子为例:

张三的妈妈让张三去买酱油。

那么张三怎么做的呢?

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

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

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

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

同样,如果我们通过代码来进行表示的话,以下例子:

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

将会得出如下代码:

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

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

最后做一个总结,什么叫做声明式呢?

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

04:命令式 VS 声明式

那么在我们讲解完成 命令式声明式 之后,很多同学肯定会对这两种编程范式进行一个对比。

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

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

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

  1. 性能
  2. 可维护性

那么接下来我们就通过这两个方面,来分析一下命令式和声明式。

性能

性能一直是我们在进行项目开发时特别关注的方向,那么我们通常如何来表述一个功能的性能好坏呢?

我们来看一个例子:

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

那么针对于这个需求而言,最简单的代码就是:

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

你应该找不到比这个更简单的代码实现了。

那么此时我们把这个操作的 耗时 比作 :1 。(PS:耗时越少,性能越强

然后我们来看声明式,声明式的代码为:

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

那么:已知修改text最简单的方式是innerText  ,所以说无论声明式的代码是如何实现的文本切换,那么它的耗时一定是 > 1 的,我们把它比作 1 + n(对比的性能消耗)

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

可维护性

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

那么想要达到这个目的,说白了就是:代码的逻辑要足够简单,让人一看就懂。

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

// 命令式
// 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>

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

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

小结一下

由以上分析可知两点内容:

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

那么双方各有优劣,我们在日常开发中应该使用哪种范式呢?

想要搞明白这点,那么我们还需要搞明白更多的知识。

05:企业应用的开发与设计原则

企业应用的设计原则,想要描述起来比较复杂,为什么呢?

因为对于 不同的企业类型(大厂、中小厂、人员外包、项目外包),不同的项目类型(前台、中台、后台)来说,对应的企业应用设计原则上可能会存在一些差异。

所以我们这里所做的描述,会抛弃一些细微的差异,仅抓住核心的重点来进行阐述。

无论什么类型的企业,也无论它们在开发什么类型的项目,那么最关注的点无非就是两个:

  1. 项目成本
  2. 开发体验

项目成本

项目成本非常好理解,它决定了一个公司完成“这件事”所付出的代价,从而直接决定了这个项目是否是可以盈利的(大厂的烧钱项目例外)。

那么既然项目成本如此重要,大家可以思考一下,决定项目成本的又是什么?


没错!就是你的 开发周期

开发周期越长,所付出的人员成本就会越高,从而导致项目成本变得越高。

通过我们前面的分析可知,声明式的开发范式在 可维护性 上,是 大于 命令式的。

而可维护性从一定程度上就决定了,它会使项目的:开发周期变短、升级变得更容易 从而大量节约开发成本。

所以这也是为什么 Vue 会变得越来越受欢迎的原因。

开发体验

决定开发者开发体验的核心要素,主要是在开发时和阅读时的难度,这个被叫做:心智负担

心智负担可以作为衡量开发难易度的一个标准,心智负担高则证明开发的难度较高,心智负担低则表示开发的难度较低,开发更加舒服。

那么根据我们之前所说,声明式的开发难度明显低于命令式的开发难度。

所以对于开发体验而言,声明式的开发体验更好,也就是 心智负担更低

06:为什么说框架的设计过程其实是一个不断取舍的过程?

Vue 作者尤雨溪在一次演讲中说道:框架的设计过程其实是一个不断取舍的过程

这代表的是什么意思呢?

想要搞明白这个,那么再来明确一下之前说过的概念:

  1. 命令式的性能 > 声明式的性能
  2. 命令式的可维护性 < 声明式的可维护性
  3. 声明式的框架本质上是由命令式的代码来去实现的
  4. 企业项目开发时,大多使用声明式框架

当我们明确好了这样的一个问题之后,那么我们接下来来思考一个问题:框架的开发与设计原则是什么呢?

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

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

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

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

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

以这个例子为例。

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

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

所以对于 Vue 而言,它的设计原则就是:在保证可维护性的基础上,尽可能的减少性能的损耗

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

答案也就呼之欲出了,因为:

我们需要在可维护性和性能之间,找到一个平衡点。在保证可维护性的基础上,尽可能的减少性能的损耗。

所以框架的设计过程其实是一个不断在 可维护性和性能 之间进行取舍的过程

07:什么是运行时?

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>

我们知道,在 Vue 的项目中,我们可以通过 tempalte 渲染 DOM 节点,如下:

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

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

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

我们知道在上面的代码中,存在一个核心函数:渲染函数 render,那么这个 render 在这里到底做了什么事情呢?

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

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

我希望根据如下数据:

渲染出这样一个 div:

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

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

那么针对这样的一个需求你会如何进行实现呢?大家可以在这里先思考一下,尝试进行一下实现,然后我们再继续往下看..........

那么接下来我们根据这个需求来实现以下代码:

 
<script>
  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)
</script>

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

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

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

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

那么最后,我们做一个总结:运行时可以利用rendervnode 渲染成真实 dom 节点。

08:什么是编译时?

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

那么想要实现这一点,我们就需要借助另外一个东西,也就是 编译时

Vue 中的编译时,更准确的说法应该是 编译器 的意思。它的代码主要存在于 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

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

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

</html>

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

那么最后,我们做一个总结:编译时可以把html 的节点,编译成 render函数

09:运行时 + 编译时

前面两小节我们已经分别了解了 运行时 和 编译时,同时我们也知道了:vue 是一个 运行时+编译时 的框架!

vue 通过 compiler 解析 html 模板,生成 render 函数,然后通过 runtime 解析 render,从而挂载真实 dom

那么看到这里可能有些同学就会有疑惑了,既然 compiler 可以直接解析 html 模板,那么为什么还要生成 render 函数,然后再去进行渲染呢?为什么不直接利用 compiler 进行渲染呢?

即:为什么 vue 要设计成一个 运行时+编译时的框架呢?

那么想要理清楚这个问题,我们就需要知道 dom 渲染是如何进行的。

对于 dom  渲染而言,可以被分为两部分:

  1. 初次渲染 ,我们可以把它叫做 挂载
  2. 更新渲染 ,我们可以把它叫做 打补丁

初次渲染

那么什么是初次渲染呢?

当初始 divinnerHTML 为空时,

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

我们在该 div 中渲染如下节点:

<ul>
	<li>1</li>
	<li>2</li>
	<li>3</li>
</ul>

li - 3 上升到了第一位,那么此时大家可以想一下:我们期望浏览器如何来更新这次渲染呢?

浏览器更新这次渲染无非有两种方式:

  1. 删除原有的所有节点,重新渲染新的节点
  2. 删除原位置的 li - 3,在新位置插入 li - 3

那么大家觉得这两种方式哪一种方式更好呢?那么我们来分析一下:

  1. 首先对于第一种方式而言:它的好处在于不需要进行任何的比对,需要执行 6 次(删除 3 次,重新渲染 3 次)dom 处理即可。
  2. 对于第二种方式而言:在逻辑上相对比较复杂。他需要分成两步来做:
    1. 对比 旧节点新节点 之间的差异
    2. 根据差异,删除一个 旧节点,增加一个 新节点

那么根据以上分析,我们知道了:

  1. 第一种方式:会涉及到更多的 dom 操作
  2. 第二种方式:会涉及到 js 计算 + 少量的 dom 操作

那么这两种方式,哪一种更快呢?我们来实验一下:

  const length = 10000
  // 增加一万个dom节点,耗时 3.992919921875 ms
  console.time('element')
  for (let i = 0; i < length; i++) {
    const newEle = document.createElement('div')
    document.body.appendChild(newEle)
  }
  console.timeEnd('element')

  // 增加一万个 js 对象,耗时 0.402099609375 ms
  console.time('js')
  const divList = []
  for (let i = 0; i < length; i++) {
    const newEle = {
      type: 'div'
    }
    divList.push(newEle)
  }
  console.timeEnd('js')

从结果可以看出,dom 的操作要比 js 的操作耗时多得多,即:dom** 操作比 js 更加耗费性能**。

那么根据这样的一个结论,回到我们刚才所说的场景中:

  1. 首先对于第一种方式而言:它的好处在于不需要进行任何的比对,仅需要执行 6 次(删除 3 次,重新渲染 3 次)dom 处理即可。
  2. 对于第二种方式而言:在逻辑上相对比较复杂。他需要分成两步来做:
    1. 对比 旧节点新节点 之间的差异
    2. 根据差异,删除一个 旧节点,增加一个 新节点

根据结论可知:方式一会比方式二更加消耗性能(即:性能更差)。

那么得出这样的结论之后,我们回过头去再来看最初的问题:为什么 vue 要设计成一个 运行时+编译时的框架呢?

答:

  1. 针对于 纯运行时 而言:因为不存在编译器,所以我们只能够提供一个复杂的 JS 对象。
  2. 针对于 纯编译时 而言:因为缺少运行时,所以它只能把分析差异的操作,放到 编译时 进行,同样因为省略了运行时,所以速度可能会更快。但是这种方式这将损失灵活性(具体可查看第六章虚拟 DOM ,或可点击 这里 查看官方示例)。比如 svelte ,它就是一个纯编译时的框架,但是它的实际运行速度可能达不到理论上的速度。
  3. 运行时 + 编译时:比如 vuereact 都是通过这种方式来进行构建的,使其可以在保持灵活性的基础上,尽量的进行性能的优化,从而达到一种平衡。

10:什么是副作用

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

所以我们需要先了解一下副作用代表的是什么意思。

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

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

setter

setter 所表示的是 赋值 操作,比如说,当我们执行如下代码时 :

msg = '你好,世界'

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

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

那么我们就可以说:msgsetter 行为,触发了一次副作用,导致视图跟随发生了变化。

getter

getter 所表示的是 取值 操作,比如说,当我们执行如下代码时:

element.innerText = msg

此时对于变量 msg 而言,就触发了一次 getter 操作,那么这样的一次取值操作,同样会导致 elementinnerText 发生改变。

所以我们可以说:msggetter 行为触发了一次副作用,导致 elementinnterText 发生了变化。

副作用会有多个吗?

那么明确好了副作用的基本概念之后,那么大家想一想:副作用可能会有多个吗?

答案是:可以的。

举个简单的例子:

<template>
  <div>
    <p>姓名:{{ obj.name }}</p>
    <p>年龄:{{ obj.age }}</p>
  </div>
</template>

<script>
	const obj = ref({
    name: '张三',
    age: 30
  })
  obj.value = {
    name: '李四',
    age: 18
  }
</script>

在这样的一个代码中 obj.value 触发了一次 setter 行为,但是会导致两个 p 标签的内容发生改变,也就是产生了两次副作用。

小节一下

根据本小节我们知道了:

  1. 副作用指的是:对数据进行 settergetter 操作时,所产生的一系列后果
  2. 副作用可能是会有多个的。

11:Vue 3 框架设计概述

根据前面的学习我们已经知道了:

  1. 什么是声明式
  2. 什么是命令式
  3. 什么是运行时
  4. 什么是编译时
  5. 什么是运行时+编译时
  6. 同时也知道了 框架的设计过程本身是一个不断取舍的过程

那么了解了这些内容之后,下来 vue3 的一个基本框架设计:

对于 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 (现在很多同学可能还不了解什么是 proxy ,没有关系后面我们会详细介绍它,现在只需要有个印象即可)的 被代理对象(target
    3. 返回一个 Proxy 类型的 代理对象(proxyTarget
    4. proxyTarget 触发 settergetter 行为时,会产生对应的副作用
  2. 然后,我们在 tempalte 标签中,写入了一个 div。我们知道这里所写入的 html 并不是真实的 html,我们可以把它叫做 模板,该模板的内容会被 编译器( compiler 进行编译,从而生成一个 render 函数
  3. 最后,vue 会利用 运行时(runtime 来执行 render 函数,从而渲染出真实 dom

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

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

原文:https://juejin.cn/post/7387205769906733090
 

  • 36
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值