Hello,大家好,我是 Sunday。
最近一位同学在学习 vue3 源码的时候,把 vue 3 的大部分核心逻辑都整理到了脑图之中:
整理的内容非常详细。应该会对所有还在学习 vue3 源码的同学都有所帮助。所以分享给大家!
那么今天,咱们就借助这位同学的脑图作为契机,来为大家捋一捋 【Vue3 框架设计原理】(看完设计原理之后,再看脑图收获会更大哦~)
01:前言
在了解 Vue3 框架设计之前,我们需要做两件事情,而这两件事情也是今天的主要内容。
- 我们需要同步并明确一些词汇的概念,比如:声明式、命令式、运行时、编译时...。这些词汇将会在后面的框架设计中被经常涉及到。
- 我们需要了解一些关于 前端框架 的一些基础的概念。框架的设计原则,开发者开发体验原则。以此来帮助大家解决一些固有的疑惑,从而揭开 vue 神秘的面纱。
那么准备好了?
我们开始吧!
02:编程范式之命令式编程
针对于目前的前端开发而言,主要存在两种 编程范式:
- 命令式编程
- 声明式编程
这两种 范式 一般是相对来去说的。
命令式
那么首先我们先来说什么叫做 命令式。
具体例子:
张三的妈妈让张三去买酱油。
那么张三怎么做的呢?
- 张三拿起钱
- 打开门
- 下了楼
- 到商店
- 拿钱买酱油
- 回到家
以上的流程详细的描述了,张三在买酱油的过程中,每一步都做了什么。那么这样一种:详细描述做事过程 的方式就可以被叫做 命令式。
那么如果把这样的方式放到具体的代码实现之中,又应该怎么做呢?
我们来看以下这样的一个事情:
在指定的 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 模板语法 的时候,其实一直写的就是 声明式 编程。
那么声明式编程具体指的是什么意思呢?
还是以刚才的例子为例:
张三的妈妈让张三去买酱油。
那么张三怎么做的呢?
- 张三拿起钱
- 打开门
- 下了楼
- 到商店
- 拿钱买酱油
- 回到家
在这个例子中,我们说:张三所做的事情就是命令式。那么张三妈妈所做的事情就是 声明式。
在这样一个事情中,张三妈妈只是发布了一个声明,她并不关心张三如何去买的酱油,只关心最后的结果。
所以说,所谓声明式指的是:不关注过程,只关注结果 的范式。
同样,如果我们通过代码来进行表示的话,以下例子:
为指定的 div 的子元素 div 的子元素 p 标签,展示变量 msg
将会得出如下代码:
<div id="app">
<div>
<p>{{ msg }}</p>
</div>
</div>
在这样的代码中,我们完全不关心 msg 是怎么被渲染到 p 标签中的,我们所关心的只是:在 p 标签中,渲染指定文本而已。
最后做一个总结,什么叫做声明式呢?
声明式是:关注结果 的一种编程范式,他 并不关心 完成一个功能的 详细逻辑与步骤。(注意:这并不意味着声明式不需要过程!声明式只是把过程进行了隐藏而已!)
04:命令式 VS 声明式
那么在我们讲解完成 命令式 和 声明式 之后,很多同学肯定会对这两种编程范式进行一个对比。
是命令式好呢?还是声明式好呢?
那么想要弄清楚这个问题,那么我们首先就需要先搞清楚,评价一种编程范式好还是不好的标准是什么?
通常情况下,我们评价一个编程范式通常会从两个方面入手:
- 性能
- 可维护性
那么接下来我们就通过这两个方面,来分析一下命令式和声明式。
性能
性能一直是我们在进行项目开发时特别关注的方向,那么我们通常如何来表述一个功能的性能好坏呢?
我们来看一个例子:
为指定 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>
对于以上代码而言,声明式 的代码明显更加利于阅读,所以也更加利于维护。
所以,由以上举例可知:**命令式的可维护性 < 声明式的可维护性 **
小结一下
由以上分析可知两点内容:
- 命令式的性能 > 声明式的性能
- 命令式的可维护性 < 声明式的可维护性
那么双方各有优劣,我们在日常开发中应该使用哪种范式呢?
想要搞明白这点,那么我们还需要搞明白更多的知识。
05:企业应用的开发与设计原则
企业应用的设计原则,想要描述起来比较复杂,为什么呢?
因为对于 不同的企业类型(大厂、中小厂、人员外包、项目外包),不同的项目类型(前台、中台、后台)来说,对应的企业应用设计原则上可能会存在一些差异。
所以我们这里所做的描述,会抛弃一些细微的差异,仅抓住核心的重点来进行阐述。
无论什么类型的企业,也无论它们在开发什么类型的项目,那么最关注的点无非就是两个:
- 项目成本
- 开发体验
项目成本
项目成本非常好理解,它决定了一个公司完成“这件事”所付出的代价,从而直接决定了这个项目是否是可以盈利的(大厂的烧钱项目例外)。
那么既然项目成本如此重要,大家可以思考一下,决定项目成本的又是什么?
没错!就是你的 开发周期。
开发周期越长,所付出的人员成本就会越高,从而导致项目成本变得越高。
通过我们前面的分析可知,声明式的开发范式在 可维护性 上,是 大于 命令式的。
而可维护性从一定程度上就决定了,它会使项目的:开发周期变短、升级变得更容易 从而大量节约开发成本。
所以这也是为什么 Vue 会变得越来越受欢迎的原因。
开发体验
决定开发者开发体验的核心要素,主要是在开发时和阅读时的难度,这个被叫做:心智负担。
心智负担可以作为衡量开发难易度的一个标准,心智负担高则证明开发的难度较高,心智负担低则表示开发的难度较低,开发更加舒服。
那么根据我们之前所说,声明式的开发难度明显低于命令式的开发难度。
所以对于开发体验而言,声明式的开发体验更好,也就是 心智负担更低。
06:为什么说框架的设计过程其实是一个不断取舍的过程?
Vue 作者尤雨溪在一次演讲中说道:框架的设计过程其实是一个不断取舍的过程 。
这代表的是什么意思呢?
想要搞明白这个,那么再来明确一下之前说过的概念:
- 命令式的性能 > 声明式的性能
- 命令式的可维护性 < 声明式的可维护性
- 声明式的框架本质上是由命令式的代码来去实现的
- 企业项目开发时,大多使用声明式框架
当我们明确好了这样的一个问题之后,那么我们接下来来思考一个问题:框架的开发与设计原则是什么呢?
我们知道对于 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 标签结构的方式 你来进行渲染呢?
你想了想之后,说:如果是这样的话,那就不是以上 运行时 的代码可以解决的了!
没错!我们刚刚所编写的这样的一个“框架”,就是 运行时 的代码框架。
那么最后,我们做一个总结:运行时可以利用render 把 vnode 渲染成真实 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 渲染而言,可以被分为两部分:
- 初次渲染 ,我们可以把它叫做 挂载
- 更新渲染 ,我们可以把它叫做 打补丁
初次渲染
那么什么是初次渲染呢?
当初始 div 的 innerHTML 为空时,
<div id="app"></div>
我们在该 div 中渲染如下节点:
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
那么这样的一次渲染,就是 初始渲染。在这样的一次渲染中,我们会生成一个 ul 标签,同时生成三个 li 标签,并且把他们挂载到 div 中。
更新渲染
那么此时如果 ul 标签的内容发生了变化:
<ul>
<li>3</li>
<li>1</li>
<li>2</li>
</ul>
li - 3 上升到了第一位,那么此时大家可以想一下:我们期望浏览器如何来更新这次渲染呢?
浏览器更新这次渲染无非有两种方式:
- 删除原有的所有节点,重新渲染新的节点
- 删除原位置的 li - 3,在新位置插入 li - 3
那么大家觉得这两种方式哪一种方式更好呢?那么我们来分析一下:
- 首先对于第一种方式而言:它的好处在于不需要进行任何的比对,需要执行 6 次(删除 3 次,重新渲染 3 次)dom 处理即可。
- 对于第二种方式而言:在逻辑上相对比较复杂。他需要分成两步来做:
- 对比 旧节点 和 新节点 之间的差异
- 根据差异,删除一个 旧节点,增加一个 新节点
那么根据以上分析,我们知道了:
- 第一种方式:会涉及到更多的 dom 操作
- 第二种方式:会涉及到 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 更加耗费性能**。
那么根据这样的一个结论,回到我们刚才所说的场景中:
- 首先对于第一种方式而言:它的好处在于不需要进行任何的比对,仅需要执行 6 次(删除 3 次,重新渲染 3 次)dom 处理即可。
- 对于第二种方式而言:在逻辑上相对比较复杂。他需要分成两步来做:
对比 旧节点 和 新节点 之间的差异
根据差异,删除一个 旧节点,增加一个 新节点
根据结论可知:方式一会比方式二更加消耗性能(即:性能更差)。
那么得出这样的结论之后,我们回过头去再来看最初的问题:为什么 vue 要设计成一个 运行时+编译时的框架呢?
答:
- 针对于 纯运行时 而言:因为不存在编译器,所以我们只能够提供一个复杂的 JS 对象。
- 针对于 纯编译时 而言:因为缺少运行时,所以它只能把分析差异的操作,放到 编译时 进行,同样因为省略了运行时,所以速度可能会更快。但是这种方式这将损失灵活性(具体可查看第六章虚拟 DOM ,或可点击 这里 查看官方示例)。比如 svelte ,它就是一个纯编译时的框架,但是它的实际运行速度可能达不到理论上的速度。
- 运行时 + 编译时:比如 vue 或 react 都是通过这种方式来进行构建的,使其可以在保持灵活性的基础上,尽量的进行性能的优化,从而达到一种平衡。
10:什么是副作用
在 vue 的源码中,会大量的涉及到一个概念,那就 副作用。
所以我们需要先了解一下副作用代表的是什么意思。
副作用指的是:当我们 对数据进行 setter 或 getter 操作时,所产生的一系列后果。
那么具体是什么意思呢?我们分别来说一下:
setter
setter 所表示的是 赋值 操作,比如说,当我们执行如下代码时 :
msg = '你好,世界'
这时 msg 就触发了一次 setter 的行为。
那么假如说,msg 是一个响应性数据,那么这样的一次数据改变,就会影响到对应的视图改变。
那么我们就可以说:msg 的 setter 行为,触发了一次副作用,导致视图跟随发生了变化。
getter
getter 所表示的是 取值 操作,比如说,当我们执行如下代码时:
element.innerText = msg
此时对于变量 msg 而言,就触发了一次 getter 操作,那么这样的一次取值操作,同样会导致 element 的 innerText 发生改变。
所以我们可以说:msg 的 getter 行为触发了一次副作用,导致 element 的 innterText 发生了变化。
副作用会有多个吗?
那么明确好了副作用的基本概念之后,那么大家想一想:副作用可能会有多个吗?
答案是:可以的。
举个简单的例子:
<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 标签的内容发生改变,也就是产生了两次副作用。
小节一下
根据本小节我们知道了:
- 副作用指的是:对数据进行 setter 或 getter 操作时,所产生的一系列后果
- 副作用可能是会有多个的。
11:Vue 3 框架设计概述
根据前面的学习我们已经知道了:
- 什么是声明式
- 什么是命令式
- 什么是运行时
- 什么是编译时
- 什么是运行时+编译时
- 同时也知道了 框架的设计过程本身是一个不断取舍的过程
那么了解了这些内容之后,下来 vue3 的一个基本框架设计:
对于 vue3 而言,核心大致可以分为三大模块:
- 响应性:reactivity
- 运行时:runtime
- 编译器: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>
在以上代码中:
- 首先,我们通过 reactive 方法,声明了一个响应式数据。
该方法是 reactivity 模块对外暴露的一个方法
可以接收一个复杂数据类型,作为 Proxy (现在很多同学可能还不了解什么是 proxy ,没有关系后面我们会详细介绍它,现在只需要有个印象即可)的 被代理对象(target)
返回一个 Proxy 类型的 代理对象(proxyTarget)
当 proxyTarget 触发 setter 或 getter 行为时,会产生对应的副作用
- 然后,我们在 tempalte 标签中,写入了一个 div。我们知道这里所写入的 html 并不是真实的 html,我们可以把它叫做 模板,该模板的内容会被 编译器( compiler ) 进行编译,从而生成一个 render 函数
- 最后,vue 会利用 运行时(runtime) 来执行 render 函数,从而渲染出真实 dom
以上就是 reactivity、runtime、compiler 三者之间的运行关系。
当然除了这三者之外, vue 还提供了很多其他的模块,比如:SSR ,我们这里只是 概述了基本的运行逻辑。