玩转Vue3全家桶

为什么要学Vue 3

AngularJS 的诞生,引领了前端 MVVM 模式的潮流;Node.js 的诞生,让前端有了入侵后端的能力,也加速了前端工程化的诞生。现在前端三大框架 Angular、React、Vue 的发展主线,也就是从这里开始的。

所谓 MVVM,就是在前端的场景下,把 Controller 变成了 View-Model 层,作为 Model 和View 的桥梁,Model 数据层和 View 视图层交给 View-Model 来同步,

前端三大框架

在前端 MVVM 模式下,不同框架的目标都是一致的,就是利用数据驱动页面,但是怎么处理数据的变化,各个框架走出了不同的路线。

image-20231008213353845

这些框架要回答的核心问题就是,数据发生变化后,我们怎么去通知页面更新。各大框架在这个步骤上,各显神通:

Angular

Angular 1 就是最老套的脏检查。所谓的脏检查,指的是 Angular 1 在对数据变化的检查上,遵循每次用户交互时都检查一次数据是否变化,有变化就去更新 DOM 这一方法。这个方法看似简单粗暴,但算是数据驱动页面早期的实现,所以一经推出,就迅速占领了 MVVM 市场。

后面 Angular 团队自断双臂,完全抛弃 Angular 1,搞了一个全新的框架还叫 Angular,引入了 TypeScriptRxJS 等新内容,虽然这些设计很优秀,但是不支持向前兼容,抛弃了老用户。这样做也伤了一大批 Angular 1 用户的心,包括我。这也是 Angular 这个优秀的框架现在在国内没有大面积推广的原因。

Vue

Vue 1 的解决方案,就是使用响应式,初始化的时候,Watcher 监听了数据的每个属性,这样数据发生变化的时候,我们就能精确地知道数据的哪个 key 变了,去针对性修改对应的DOM 即可,这一过程可以按如下方式解构:

image-20231008213619969

在上图中,左边是实际的网页内容,我们在网页中使用{{}}渲染一个变量,Vue 1 就会在内容里保存一个监听器监控这个变量,我们称之为 Watcher,数据有变化,watcher 会收到通知去更新网页。

此外,FacebookReact 团队提出了不同于上面的 AngularVue 的的解决方案,他们设计了 React 框架,在页面初始化的时候,在浏览器 DOM 之上,搞了一个叫虚拟 DOM 的东西,也就是用一个 JavaScript 对象来描述整个 DOM 树。我们可以很方便的通过虚拟 DOM计算出变化的数据,去进行精确的修改。

我们先看 React 中的一段代码

1
2
3
4
<div id = "app">
<p class = "item">Item1</p>
<div class = "item">Item2</div>
</div>

在 React 中,这样一段 HTML 会被映射成一个 JavaScript 的对象进行描述。这个对象就像数据和实际 DOM 的一个缓存层,通过管理这个对象的变化,来减少对实际 DOM 的操作。

这种形式不仅让性能有个很好的保障,我们还多了一个用 JSON 来描述网页的工具,并且让虚拟 DOM 这个技术脱离了 Web 的限制。因为积累了这么多优势,虚拟 DOM 在小程序,客户端等跨端领域大放异彩。

虚拟 DOM 在运行的时候就是这么一个对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
tag: "div",
attrs: {
id: "app"
},
children: [
{
tag: "p",
attrs: { className: "item" },
children: ["Item1"]
},
{
tag: "div",
attrs: { className: "item" },
children: ["Item2"]
}
]
}

这个对象完整地描述了 DOM 的树形结构,这样数据有变化的时候,我们生成一份新的虚拟DOM 数据,然后再对之前的虚拟 DOM 进行计算,算出需要修改的 DOM,再去页面进行操作。

浏览器操作 DOM 一直都是性能杀手,而虚拟 DOMDiff 的逻辑,又能够确保尽可能少的操作 DOM,这也是虚拟 DOM 驱动的框架性能一直比较优秀的原因之一

image-20231008214107104

Vue 与 React 框架的对比

通过上面对前端三大框架的介绍,我们不难发现 Vue 和 React 在数据发生变化后,在通知页面更新的方式上有明显的不同,通俗的来说,就是:在 Vue 框架下,如果数据变了,那框架会主动告诉你修改了哪些数据;而 React 的数据变化后,我们只能通过新老数据的计算 Diff来得知数据的变化

这两个解决方案都解决了数据变化后,如何通知页面更新的问题,并且迅速地获得了很高的占有率,但是他们都碰到了性能的瓶颈

  • 对于 Vue 来说,它的一个核心就是“响应式”,也就是数据变化后,会主动通知我们。响应式数据新建 Watcher 监听,本身就比较损耗性能,项目大了之后每个数据都有一个watcher 会影响性能。

  • 对于 React虚拟 DOMDiff 计算逻辑来说,如果虚拟 DOM 树过于庞大,使得计算时间大于 16.6ms,那么就可能会造成性能的卡顿。

为了解决这种性能瓶颈, Vue 和 React 走了不同的道路。

React解决方案

React 为了突破性能瓶颈,借鉴了操作系统时间分片的概念,引入了 Fiber 架构。通俗来说,就是把整个虚拟 DOM 树微观化,变成链表,然后我们利用浏览器的空闲时间计算 Diff。一旦浏览器有需求,我们可以把没计算完的任务放在一旁,把主进程控制权还给浏览器,等待浏览器下次空闲。

image-20231008214533408

在上图中,左侧是一个树形结构,树形结构的 Diff 很难中断;右侧是把树形结构改造成了链表,遍历严格地按照子元素 -> 兄弟元素 -> 父元素的逻辑,随时可以中断和恢复 Diff 的计算过程。

为了方便你对计算 Diff 的理解,我们来看下面这张图:

image-20231008214618563

这个图里两个虚线之间是浏览器的一帧,高性能的动画要求是 60fps,也就是 1 秒要渲染 60次,每一帧的时间就是 16.6 毫秒,在这 16.6 毫秒里,浏览器自己的渲染更新任务执行后,会有一部分的空闲时间,这段时间我们就用来计算 Diff。

等到下一帧任务来了,我们就把控制权还给浏览器,让它继续去更新和渲染,等待空闲时间再继续计算,这样就不会导致卡顿。

Vue解决方案

Vue 1 的问题在于响应式数据过多,这样会带来内存占用过多的问题。所以 Vue 2 大胆引入虚拟 DOM 来解决响应式数据过多的问题

这个解决方案使用虚拟 DOM 解决了响应式数据过多的内存占用问题,又良好地规避了 React虚拟 DOM 的问题, 还通过虚拟 DOM Vue带来了跨端的能力。

响应式数据是主动推送变化,虚拟 DOM 是被动计算数据的 Diff,一个推一个拉,它们看起来是两个方向的技术,但被 Vue 2 很好地融合在一起,采用的方式就是组件级别的划分。

对于Vue 2来说,组件之间的变化,可以通过响应式来通知更新。组件内部的数据变化,则通过虚拟 DOM 去更新页面。这样就把响应式的监听器,控制在了组件级别,而虚拟 DOM的量级,也控制在了组件的大小。

下图左边就是一个个的组件,组件内部是没有 Watcher 监听器的,而是通过虚拟 DOM 来更新,每个组件对应一个监听器,大大减小了监听器的数量。

image-20231008215252260

除了响应式和虚拟 DOM 这个维度,Vue 和 React 还有一些理念和路线的不同,在模板的书写上,也走出了 template 和 JSX 两个路线。

image-20231008215404821

React 的世界里只有 JSX,最终 JSX 都会在 Compiler 那一层,也就是工程化那里编译成 JS来执行,所以 React 最终拥有了全部 JS 的动态性,这也导致了 React 的 API 一直很少,只有 state、hooks、Component 几个概念,主要都是 JavaScript 本身的语法和特性。

Vue 的世界默认是 template,也就是语法是限定死的,比如 v-ifv-for 等语法。有了这些写法的规矩后,我们可以在上线前做很多优化。Vue 3 很优秀的一个点,就是虚拟 DOM的静态标记上做到了极致,让静态的部分越过虚拟 DOM 的计算,真正做到了按需更新,很好的提高了性能。

image-20231008215627051

在模板的书写上,除了 Vue React 走出的templateJSX 两个路线,还出现了 Svelte 这种框架,没有虚拟 DOM 的库,直接把模板编译成原生 DOM,几乎没有 Runtime,所有的逻辑都在 Compiler 层优化,算是另外一个极致。

image-20231008215752957

Vue 需不需要 React 的 Fiber 呢?

最早Vue3的提案其实是包含时间切片方案的,最后废弃的主要原因,是时间切片解决的的问题,Vue3基本碰不到

  1. Vue3虚拟Dom控制在组件级别,组件之间使用响应式,这就让Vue3虚拟Dom不会过于庞大

  2. Vue3虚拟Dom的静态标记和自动缓存功能,让静态的节点和属性可以直接绕过Diff逻辑,也大大减少了虚拟Dom的Diff事件

  3. 时间切片也会带来额外的系统复杂性

所以引入时间切片对于Vue3来说投入产出比不太理想,在后来的讨论中,Vue3的时间切片方案就被废弃了

Vue 3新特性

Vue 2 的核心模块和历史遗留问题

先看一看 Vue 2。从下图你能看到,Vue 2 是一个响应式驱动的、内置虚拟 DOM、组件化、用在浏览器开发,并且有一个运行时把这些模块很好地管理起来的框架。

image-20231008221042397

Vue 2 能把上面所说的这些模块很好地管理起来,看起来已经足够好了。不过事实真的如此么?聪明的你估计已经猜到了,Vue 2 还是有缺陷的,所以后面才会升级迭代。

Vue 2 常见的缺陷

首先从开发维护的角度看,Vue 2 是使用 Flow.js 来做类型校验。但现在 Flow.js 已经停止维护了,整个社区都在全面使用 TypeScript 来构建基础库,Vue 团队也不例外

然后从社区的二次开发难度来说,Vue 2 内部运行时,是直接执行浏览器 API 的。但这样就会在Vue 2的跨端方案中带来问题,要么直接进入 Vue 源码中,和 Vue 一起维护,比如Vue 2 中你就能见到 Weex 的文件夹。

要么是要直接改为复制一份全部 Vue 的代码,把浏览器 API 换成客户端或者小程序的。比如mpvue 就是这么做的,但是 Vue 后续的更新就很难享受到。

最后从我们普通开发者的角度来说,Vue 2 响应式并不是真正意义上的代理,而是基于Object.defineProperty() 实现的。对于 Object.defineProperty() 这个 API 的细节,我们在后面讲源码时会讲到,现在你只需要知道这个 API 并不是代理,而是对某个属性进行拦截,所以有很多缺陷,比如:删除数据就无法监听,需要 $delete 等 API 辅助才能监听到。

并且,Option API 在组织代码较多组件的时候不易维护。对于 Option API 来说,所有的methodscomputed 都在一个对象里配置,这对小应用来说还好。但代码超过 300 行的时候,新增或者修改一个功能,就需要不停地在 datamethods 里跳转写代码,我称之为上下反复横跳。

七个方面了解 Vue 3 新特性

Vue 3 就是继承了Vue 2 具有的响应式、虚拟 DOM,组件化等所有优秀的特点,并且全部重新设计,解决了这些历史包袱的新框架,是一个拥抱未来的前端框架。

接下来我们就来具体看看 Vue 3 新特性,我将分成 7 个具体方面向你展开介绍。其中,响应式系统Composition API 组合语法新的组件Vite 是你需要重视的;自定义渲染器这方面的知识,你想用 Vue 开发跨端应用时会用到;如果你想对 Vue 源码作出贡献,RFC 机制你也需要好好研究,并且得对 TypeScript 重构有很好的经验。

RFC 机制

Vue 3 的第一个新特性和代码无关,而是 Vue 团队开发的工作方式。

关于 Vue 的新语法或者新功能的讨论,都会先在 GitHub 上公开征求意见,邀请社区所有的人一起讨论, 你随时可以打开这个项目,我把链接放在这里Vue 3 正在讨论中的新需求,任何人都可以围观、参与讨论和尝试实现。

这个改变让 Vue 社区更加有活力,不管是课程后面会提到的 <script setup>,还是 Vue 3 引入的 ref API,你都可以在这个项目中看到每个需求从诞生到最终被 Vue 采纳的来龙去脉,这能帮助我们更好地了解 Vue 的发展。

Vue 很长一段时间都是尤雨溪一个人维护,感慨尤雨溪战斗力的同时,社区也有很多人对Vue 的稳定性提出质疑。后来尤雨溪吸纳了社区的人,并成立了 Core Team。Vue 3 在此基础之上更进一步,全面拥抱社区,任何对 Vue 感兴趣的人都可以参与新特性的讨论。

RFC 的引入,让 Vue 生态更加开放,在开发方式的新特性之外,我们搞技术的还是要回归代码,下面我来说说 Vue 3 在代码层面所做的具体优化。

响应式系统

Vue 2 的响应式机制是基于 Object.defineProperty() 这个 API 实现的,此外,Vue 还使用了Proxy,这两者看起来都像是对数据的读写进行拦截,但是 defineProperty 是拦截具体某个属性,**Proxy 才是真正的“代理”**。

怎么理解这两者的区别呢?我们首先看 defineProperty 这个 API,defineProperty 的使用,要明确地写在代码里,下面是示例代码:

1
2
3
4
Object.defineProperty(obj, 'title', {
get() {},
set() {},
})

当项目里“读取 obj.title”和“修改 obj.title”的时候被 defineProperty 拦截,但**defineProperty 对不存在的属性无法拦截**,所以 Vue 2 中所有数据必须要在 data 里声明。

而且,如果 title 是一个数组的时候,对数组的操作,并不会改变 obj.title 的指向,虽然我们可以通过拦截.push 等操作实现部分功能,但是对数组的长度的修改等操作还是无法实现拦截,所以还需要额外的 $set 等 API。

Proxy 这个 API 就是真正的代理了,我们先看它的用法:

1
2
3
4
new Proxy(obj, {
get() { },
set() { },
})

需要注意的是,虽然 Proxy 拦截 obj 这个数据,但 obj 具体是什么属性,Proxy 则不关心,统一都拦截了。**而且 Proxy 还可以监听更多的数据格式,比如 SetMap**,这是 Vue 2 做不到的。

当然,Proxy 存在一些兼容性问题,这也是为什么 Vue 3 不兼容 IE11 以下的浏览器的原因,还好现在 IE 用的人不多了。

更重要的是,我觉得 Proxy 代表一种方向,就是框架会越来越多的拥抱浏览器的新特性。

Proxy 普及之前,我们是没有办法完整的监听一个 JavaScript 对象的变化,只能使用Object.defineProperty() 去实现一部分功能。

自定义渲染器

Vue 2 内部所有的模块都是揉在一起的,这样做会导致不好扩展的问题,刚才我也提到了这一点。Vue 3 是怎么解决这个问题的呢?那就是拆包,使用最近流行的 monorepo 管理方式,响应式、编译和运行时全部独立了,变成下图所示的模样:

image-20231009161157964

我们能看到,在 Vue 3 的组织架构中,响应式独立了出来。Vue 2 的响应式只服务于VueVue 3 的响应式就和 Vue 解耦了,你甚至可以在 Node.js React 中使用响应式。

渲染的逻辑也拆成了平台无关渲染逻辑浏览器渲染 API 两部分 。

在这个架构下,Node 的一些库,甚至 React 都可以依赖响应式。在任何时候,如果你希望数据被修改了之后能通知你,你都可以单独依赖 Vue 3 的响应式。

那么,在你想使用 Vue 3 开发小程序、开发 canvas 小游戏以及开发客户端的时候,就不用全部 fork Vue 的代码,只需要实现平台的渲染逻辑就可以。

image-20231009161431752

全部模块使用 TypeScript 重构

所以大部分开源的框架都会引入类型系统,来对 JavaScript 进行限制。这样做的原因,就是我们前面提到的两点:第一点是,类型系统带来了更方便的提示;第二点是,类型系统让代码更健壮

Vue 2 那个时代基本只有两个技术选型,Facebook 家的 Flow.js 和微软家的 TypeScriptVue 2Flow.js 没问题,但是现在 Flow.js 被抛弃了。Vue 3 选择了 TypeScriptTypeScript 官方也对使用 TypeScript 开发 Vue 3 项目的团队也更加友好。

Composition API 组合语法

Composition APIVue 3 中我最喜欢的一个特性,我们也叫它组合 API

先举个 Vue 2 中的简单例子,一个累加器,并且还有一个计算属性显示累加器乘以 2 的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<div id="app">
<h1 @click="add">{{count}} * 2 = {{double}}</h1>
</div>
<script src="https://unpkg.com/vue@next"></script>
<script>
let App = {
data(){
return {
count:1
}
},
methods:{
add(){
this.count++
}
},
computed:{
double(){
return this.count*2
}
}
}
Vue.createApp(App).mount('#app')
</script>

在 Vue 3 中,除了上面这种这个写法,我们还可以采用下方的写法,新增一个 setup 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div id="app">
<h1 @click="add">{{state.count}} * 2 = {{double}}</h1>
</div>
<script src="https://unpkg.com/vue@next"></script>
<script>
const {reactive,computed} = Vue
let App = {
setup(){
const state = reactive({
count:1
})
function add(){
state.count++
}
const double = computed(()=>state.count*2)
return {state,add,double}
}
}
Vue.createApp(App).mount('#app')
</script>

使用Composition API后,代码看起来很烦琐,没有 Vue 2Options API 的写法简单好懂,但 Options API 的写法也有几个很严重的问题:

  • 由于所有数据都挂载在 this 之上,因而Options API的写法TypeScript 的类型推导很不友好,并且这样也不好做 Tree-shaking 清理代码

  • 新增功能基本都得修改 data、method 等配置,并且代码上 300 行之后,会经常上下反复横跳,开发很痛苦。

  • 代码不好复用Vue 2 的组件很难抽离通用逻辑,只能使用 mixin,还会带来命名冲突的问题。

我们使用 Composition API 后,虽然看起来烦琐了一些,但是带来了诸多好处:

  • 所有 API 都是 import 引入的(现在我们的例子还没有工程化,后续会加入)。用到的功能都 import 进来,对 Tree-shaking 很友好,我的例子里没用到功能,打包的时候会被清理掉 ,减小包的大小。

  • 不再上下反复横跳,我们可以把一个功能模块的 methods、data 都放在一起书写,维护更轻松。

  • 代码方便复用,可以把一个功能所有的 methods、data 封装在一个独立的函数里,复用代码非常容易。

  • Composotion API 新增的 return 等语句,在实际项目中使用 <script setup> 特性可以清除, 我们后续项目中都会用到这样的操作

Composition API 对我们开发 Vue 项目起到了巨大的帮助。下面这个示例图很好地说明了问题:每一个功能模块的代码颜色一样,左边是 Options API,一个功能的代码零散的分布在data,methods 等配置内,维护起来很麻烦,而右边的 Compositon API 就不一样了,每个功能模块都在一起维护。

image-20231009162849519

其实还可以更进一步,如果每个颜色块代码,我们都拆分出去一个函数,我们就会写出类似上面右侧风格的代码,每个数据来源都清晰可见,而且每个功能函数都可以在各个地方复用。

新的组件

Vue 3 还内置了 FragmentTeleport Suspense三个新组件。这个倒不难,项目中用到的时候会详细剖析,现在你只需要这仨是啥就行,以及它们的用途即可:

  • Fragment: Vue 3 组件不再要求有一个唯一的根节点,清除了很多无用的占位 div。

  • Teleport: 允许组件渲染在别的元素内,主要开发弹窗组件的时候特别有用。

  • Suspense: 异步组件,更方便开发有异步请求的组件。

新一代工程化工具 Vite

Vite 不在 Vue 3 的代码包内,和 Vue 也不是强绑定,Vite 的竞品是 Webpack,而且按照现在的趋势看,使用率超过 Webpack 也是早晚的事。

Vite 主要提升的是开发的体验,Webpack 等工程化工具的原理,就是根据你的 import 依赖逻辑,形成一个依赖图,然后调用对应的处理工具,把整个项目打包后,放在内存里再启动调试。

由于要预打包,所以复杂项目的开发,启动调试环境需要 3 分钟都很常见,Vite 就是为了解决这个时间资源的消耗问题出现的。

你可能不知道,现代浏览器已经默认支持了 ES6import 语法,Vite 就是基于这个原理来实现的。具体来说,在调试环境下,我们不需要全部预打包,只是把你首页依赖的文件,依次通过网络请求去获取,整个开发体验得到巨大提升,做到了复杂项目的秒级调试和热更新。

下图展示了 Webpack 的工作原理,Webpack 要把所有路由的依赖打包后,才能开始调试。

image-20231009163415128

而下图所示的是 Vite 的工作原理,一开始就可以准备联调,然后根据首页的依赖模块,再去按需加载,这样启动调试所需要的资源会大大减少。

image-20231009163503398

总结

image-20231009163650507

Vue 2项目如何升级到Vue 3?

应不应该从 Vue 2 升级到 Vue 3

应不应该升级?这个问题不能一概而论。

首先,如果你要开启一个新项目,那直接使用Vue 3是最佳选择。后面课程里,我也会带你使用Vue 3的新特性和新语法开发一个项目。

以前我独立使用 Vue 2 开发应用的时候,不管我怎么去组织代码,我总是无法避免在 datatemplatemethods 中上下反复横跳,这种弊端在项目规模上来之后会更加明显。而且由于vue-cli 是基于 Webpack 开发的,当项目规模上来后,每执行一下,调试环境就要 1 分钟时间,这也是大部分复杂项目的痛点之一。

而 Vue 3 的 Composition API 带来的代码组织方式更利于封装代码,维护起来也不会上下横跳。Vite 则带来了更丝滑的调试体验,一步步跟着专栏完成你的第一个 Vue 3 项目,你会感受到 Vue 3 的魅力。

Vue 3 的正式版已经发布有一年了,无论是辅助工具,还是周边库都已经非常完善了,足以胜任大型的项目开发。并且,现在也有越来越多的公司正在尝试和体验 Vue 3。所以新项目可以直接拥抱 Vue 3 的生态,这也是现在很多团队在做的尝试。

而且对于 Vue 2,官方还会再维护两年,但两年后的问题和需求,官方就不承诺修复和提供解答了,现在继续用 Vue 2 其实是有这个隐患的。

Vue 2 的终止支持时间是 2023 年 12 月 31 日

Vue 3 也不是没有问题,由于新的响应式系统用了 Proxy,会存在兼容性问题。也就是说,如果你的应用被要求兼容 IE11,就应该选择 Vue 2。而且,Vue 团队也已经放弃 Vue 3IE11 浏览器的支持。

其实,官方原来是有计划在 Vue 3 中支持 IE11,但后来由于复杂度和优先级的问题,这个计划就搁置了下来。

不过,站在 2021 看待现在前端的世界,你能发现浏览器和 JavaScript 本身已经有了巨大的发展。大部分的前端项目都在直接使用现代的语言特性,而且微软本身也在抛弃 IE,转而推广Edge。所以 Vue 官方在重新思考后,决定让 Vue 3 全面拥抱未来,把原来准备投入到 Vue 3上支持 IE11 的精力转投给 Vue 2.7

那么 Vue 2.7 会带来什么内容呢?

Vue 2.7 会移植 Vue 3 的一些新特性,让你在 Vue 2 的生态中,也能享受 Vue 3 的部分新特性。在 Vue 3 发布之前,Vue 2 项目中就可以基于 @vue/composition-api 插件,使用Composition API 语法,Vue 2 会直接内置这个插件,在 Vue 2 中默认也可以用Compositon 来组合代码。

image-20231009200507879

Vue 3 不兼容的那些写法

通过前面的分析,在选择 Vue 2 还是 Vue 3 这个问题上,相信你现在已经有了自己的取舍。如果最后你依然决定要升级 Vue 3,那我就先带你了解一下 Vue 3 不支持的那些写法、之后为你讲解它的生态现状,最后,我们再进入到实操升级的环节。

了解一下 Vue 3 不兼容的那些具体语法,除了可以帮你在升级项目后,避免写的代码无法使用,还会让你更好地适应 Vue 3。详细的兼容性变更,官方有一个迁移指南

首先,我们来看一下 Vue 2Vue 3 在项目在启动上的不同之处。在 Vue 2 中,我们使用new Vue() 来新建应用,有一些全局的配置我们会直接挂在 Vue 上,比如我们通过 Vue.use来使用插件,通过 Vue.component 来注册全局组件,如下面代码所示:

1
2
3
4
5
6
7
8
Vue.component('el-counter', {
data(){
return {count: 1}
},
template: '<button @click="count++">Clicked {{ count }} times.</button>'
})
let VueRouter = require('vue-router')
Vue.use(VueRouter)

在上面的代码里,我们注册了一个 el-counter 组件,这个组件是全局可用的,它直接渲染一个按钮,并且在点击按钮的时候,按钮内的数字会累加。

然后我们需要注册路由插件,这也是 Vue 2 中我们使用 vue-router 的方式。这种形式虽然很直接,但是由于全局的 Vue 只有一个,所以当我们在一个页面的多个应用中独立使用 Vue 就会非常困难

看下面这段代码,我们在 Vue 上先注册了一个组件 el-counter,然后创建了两个 Vue 的实例。这两个实例都自动都拥有了 el-couter 这个组件,但这样做很容易造成混淆。

1
2
3
Vue.component('el-counter',...)
new Vue({el:'#app1'})
new Vue({el:'#app2'})

为了解决这个问题,Vue 3 引入一个新的 API ,createApp,来解决这个问题,也就是新增了App 的概念。全局的组件、插件都独立地注册在这个 App 内部,很好的解决了上面提到的两个实例容易造成混淆的问题。下面的代码是使用 createApp 的简单示例:

1
2
3
4
5
6
7
8
const { createApp } = Vue
const app = createApp({})
app.component(...)
app.use(...)
app.mount('#app1')

const app2 = createApp({})
app2.mount('#app2')

createApp 还移除了很多我们常见的写法,比如在 createApp 中,就不再支持 filter、$on、$off、$set、$delete 等 API。不过这都不用担心,后面我会告诉你怎么去实现类似这些 API的功能。

Vue 3 中,v-model 的用法也有更改。在后面讲到组件化,也就是我们需要深度使用 v-model 的时候,我会再细讲。 其实 Vue 3 还有很多小细节的更新,比如 slotslot-scope两者实现了合并,而 directive 注册指令的 API 等也有变化。你现在记不住这些也不要紧,我们会在后面的实战项目里逐渐掌握这些内容。

Vue 3 生态现状介绍

在 Vue 生态中,现在所有官方库的工具都全面支持 Vue 3 了,但仍然有一些生态库还处于候选或者刚发布的状态。所以,升级 Vue 3 的过程中,除了 Vue 3 本身的语法变化,生态也要****注意选择。有一些周边的生态库可能还存在不稳定的情况,开发项目的时候我们时刻关注项目的 GitHub 即可。

Vue-cli4 已经提供内置选项,你当然可以选择它支持的 Vue 2。如果你对Vite不放心的话,Vue-cli4 也全面支持 Vue 3,这还是很贴心的。

vue-router 是复杂项目必不可少的路由库,它也包含一些写法上的变化,比如从 new Router变成 createRouter;使用方式上,也全面拥抱 Composition API 风格,提供了useRouter和 useRoute等方法。

Vuex 4.0 也支持 Vue 3,不过变化不大。有趣的是官方成员还发布了一个 Pinia,**Pinia的 API 非常接近 Vuex5 的设计,并且对 Composition API 特别友好**,更优雅一些。在课程后续的项目里,我们会使用更成熟的 Vuex4

其他生态诸如 Nuxt、组件库 Ant-design-vueElement 等等,都有 Vue 3 的版本发布。我开发维护的 Element3 是一个教育项目,我们在组件化章节会详细介绍。除此之外,我们项目中也会使用 Element3 来作为组件库。并且在进阶开发篇,我们会自己设计一个类似风格的组件库。

使用自动化升级工具进行 Vue 的升级

小项目不用多说,从 Vue 2 升级到 Vue 3 之后,对于语法的改变之处,我们挨个替换写法就可以。但对于复杂项目,我们需要借助几个自动化工具来帮我们过渡。

首先是在 Vue 3 的项目里,有一个 @vue/compat 的库,这是一个 Vue 3 的构建版本,提供了兼容 Vue 2 的行为。这个版本默认运行在 Vue 2 下,它的大部分 API 和 Vue 2 保持了一致。当使用那些在 Vue 3 中发生变化或者废弃的特性时,这个版本会提出警告,从而避免兼容性问题的发生,帮助你很好地迁移项目。并且通过升级的提示信息,@vue/compat 还可以很好地帮助你学习版本之间的差异。

在下面的代码中,首先我们把项目依赖的 Vue 版本换成 Vue 3,并且引入了 @vue/compat

1
2
3
4
5
6
7
8
9
10
"dependencies": {
- "vue": "^2.6.12",
+ "vue": "^3.2.19"
+ "@vue/compat": "^3.2.19"
...
},
"devDependencies": {
- "vue-template-compiler": "^2.6.12"
+ "@vue/compiler-sfc": "^3.2.19"
}

然后给 vue 设置别名 @vue/compat,也就是以 compat 作为入口,代码如下:

1
2
3
4
5
6
7
// vue.config.js
module.exports = {
chainWebpack: config => {
config.resolve.alias.set('vue', '@vue/compat')
......
}
}

这时你就会在控制台看到很多警告,以及很多优化的建议。我们参照建议,挨个去做优化就可以了。

@vue/compat提供了很多建议后,我们自己还是要慢慢做修改。但从另一个角度看,“偷懒”是优秀程序员的标志,社区就有能够做自动化替换的工具,比较好用的就是“阿里妈妈”出品的 gogocode,官方文档也写得很详细,就不在这里赘述了。

自动化替换工具的原理很简单,和 Vue 的 Compiler 优化的原理是一样的,也就是利用编译原理做代码替换。如下图所示,我们利用 babel 分析左边 Vue 2 的源码,解析成 AST,然后根据 Vue 3 的写法对 AST 进行转换,最后生成新的 Vue 3 代码。

image-20231009203249726

关于 AST 的细节,在课程后面的Vue 3生态源码篇中,我会带你手写一个迷你版的 Vue 3Compiler,那时你会对 AST 和它背后的编译原理有一个更深的认识

总结

首先,我带你明确了什么时候该升级 Vue 3,什么时候该继续使用 Vue 2 的兼容版本。现在,Vue 3 的官方生态在整体都比较稳定,新的项目完全可以直接选择 Vue 3。并且,对于那些需要长期维护的项目,其实也很有必要进行升级。不过,Vue 2 很快会停止更新,如果你的项目需要兼容 IE11,那就需要继续使用 Vue 2.7。这样,在保持好项目的兼容性的前提下,还可以体验到 Composition API 带来的便利。

然后,在升级 Vue 的过程中,我们可以利用官方和社区的工具,帮助我们高效地升级。我们可以使用 compat 来给出提醒,项目中设置 @vue/compat 作为 vue 的别名,这样内部就会把所有和 Vue 2 的语法相关的升级信息提示出来,逐个替换即可,或者直接使用 gogocode进行自动化批量替换。

最后,我想说的是,全面拥抱 Vue 3 也算是一次离开舒适圈的挑战,这带来的不只是新框架的体验,同时也可能是更好的潜力与更好的待遇

项目启动:搭建Vue 3工程化项目第一步

对于 Vue 2,官方推荐用 Vue-cli 创建项目;而对于 Vue 3,我建议你使用 Vite 创建项目,因为 vite 能够提供更好更快的调试体验。在使用 Vite 之前,我们要先安装 Node.js 。

在正式开发之前,我推荐使用VS Code 的官方扩展插件 Volar,这个插件给 Vue 3 提供了全面的开发支持。我们访问 Volar的地址,直接点击 Install,就会启动 VS Code 并且安装。然后使用 Chrome 访问 Vue 调试插件的地址 ,可以帮助我们在浏览器里高效的调试页面。

1
npm init @vitejs/app

我们开发的项目是多页面的,所以 vue-routerVuex 也成为了必选项,就像一个团队需要人员配比,Vue 负责核心,Vuex 负责管理数据,vue-router 负责管理路由。我们在 geekadmin 目录中使用下面这段代码安装 Vuex 和 vue-router。

1
npm install vue-router@next vuex@next

在使用npm安装Vue.js的相关库时,”@next”表示你正在安装这些库的下一个(即最新的)版本。通常,库的开发者会将它们的不稳定、开发中的版本标记为”next”,以区分它们与稳定版本。

规范

无规矩不成方圆,团队项目中的规范尤其重要。我们先对几个文件夹的分层进行规定,便于管理,下面是 src 目录的组织结构。

1
2
3
4
5
6
7
8
├── src
│ ├── api 数据请求
│ ├── assets 静态资源
│ ├── components 组件
│ ├── pages 页面
│ ├── router 路由配置
│ ├── store vuex数据
│ └── utils 工具函数

我们的页面需要引入路由系统,我们进入到 router 文件夹中,新建 index.js,写入下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {
createRouter,
createWebHashHistory,
} from 'vue-router'
import Home from '../pages/home.vue'
import About from '../pages/about.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
component: About
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router

上面的代码中,我们首先引入了 createRoutercreateWebHashHistory 两个函数。

createRouter 用来新建路由实例,createWebHashHistory 用来配置我们内部使用 hash 模式的路由,也就是 url 上会通过 # 来区分。

main.ts

1
2
3
4
5
6
import { createApp } from 'vue'
import App from './App.vue'
import router from './router/index'
createApp(App)
.use(router)
.mount('#app')

App.vue

1
2
3
4
5
6
7
<template>
<div>
<router-link to="/">首页</router-link> |
<router-link to="/about">关于</router-link>
</div>
<router-view></router-view>
</template>

代码中的 router-link router-view 就是由 vue-router 注册的全局组件,router-link 负责跳转不同的页面,相当于 Vue 世界中的超链接 a 标签; router-view 负责渲染路由匹配的组件,我们可以通过把 router-view 放在不同的地方,实现复杂项目的页面布局。

我们在实际项目开发中还会有各种工具的集成,比如写 CSS 代码时,我们需要预处理工具 stylus 或者 sass;组件库开发中,我们需要 Element3 作为组件库;网络请求后端数据的时候,我们需要 Axios

对于团队维护的项目,工具集成完毕后,还要有严格的代码规范。我们需要 EslintPrettier来规范代码的格式,Eslint 和 Prettier 可以规范项目中 JavaScript 代码的可读性和一致性。

代码的管理还需要使用 Git,我们默认使用 GitHub 来托管我们的代码。此外,我们还会使用commitizen 来规范 Git 的日志信息

对于我们项目的基础组件,我们还会提供单元测试来确保代码质量和可维护性,最后我们还会配置 GitHub Action 来实现自动化的部署

最后这个项目的架构大概是下面这样,这就是一个足以应对复杂项目开发的架构了:

image-20231009212031844

新的代码组织方式:Composition API + <script setup>到底好在哪里?

image-20231009212349324

Composition API 可以让我们更好地组织代码结构,而让你感到好奇的 <script setup> 本质上是以一种更精简的方式来书写 Composition API

Composition API **<script setup>** 上手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>
<h1 @click="add">{{count}}</h1>
</div>
</template>

<script setup>
import { ref } from "vue";
let count = ref(1)
function add(){
count.value++
}
</script>

<style>
h1 {
color: red;
}
</style>
1
2
3
4
5
6
7
8
<template>
<h1>这是首页</h1>
<TodoList />
</template>

<script setup>
import TodoList from '../components/TodoList.vue'
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div>
<input type="text" v-model="title" @keydown.enter="addTodo" />
<ul v-if="todos.length">
<li v-for="todo in todos">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }"> {{ todo.title }}</span>
</li>
</ul>
</div>
</template>

<script setup>
import { ref } from "vue";
let title = ref("");
let todos = ref([{title:'学习Vue',done:false}])

function addTodo() {
todos.value.push({
title: title.value,
done: false,
});
title.value = "";
}
</script>

计算属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
<div>
<input type="text" v-model="title" @keydown.enter="addTodo" />
<button v-if="active < all" @click="clear">清理</button>
<ul v-if="todos.length">
<li v-for="todo in todos">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }"> {{ todo.title }}</span>
</li>
</ul>
<div v-else>暂无数据</div>
<div>
全选<input type="checkbox" v-model="allDone" />
<span> {{ active }} / {{ all }} </span>
</div>
</div>
</template>

<script setup>
import { ref,computed } from "vue";
let title = ref("");
let todos = ref([{title:'学习Vue',done:false}])

function addTodo() {
...
}
function clear() {
todos.value = todos.value.filter((v) => !v.done);
}
let active = computed(() => {
return todos.value.filter((v) => !v.done).length;
});
let all = computed(() => todos.value.length);
let allDone = computed({
get: function () {
return active.value === 0;
},
set: function (value) {
todos.value.forEach((todo) => {
todo.done = value;
});
},
});
</script>

Composition API 拆分代码

讲到这里,可能你就会意识到,之前的累加器和清单,虽然功能都很简单,但也属于两个功能模块。如果在一个页面里有这两个功能,那就需要在 data 和 methods 里分别进行配置。但这样的话,数据和方法相关的代码会写在一起,在组件代码行数多了以后就不好维护。所以,我们需要使用 Composition API 的逻辑来拆分代码,把一个功能相关的数据和方法都维护在一起。

但是,所有功能代码都写在一起的话,也会带来一些问题:随着功能越来越复杂,script 内部的代码也会越来越多。因此,我们可以进一步对代码进行拆分,把功能独立的模块封装成一个独立的函数,真正做到按需拆分。在下面,我们新建了一个函数 useTodos:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function useTodos() {
let title = ref("");
let todos = ref([{ title: "学习Vue", done: false }]);
function addTodo() {
todos.value.push({
title: title.value,
done: false,
});
title.value = "";
}
function clear() {
todos.value = todos.value.filter((v) => !v.done);
}
let active = computed(() => {
return todos.value.filter((v) => !v.done).length;
});
let all = computed(() => todos.value.length);
let allDone = computed({
get: function () {
return active.value === 0;
},
set: function (value) {
todos.value.forEach((todo) => {
todo.done = value;
});
},
});
return { title, todos, addTodo, clear, active, all, allDone };
}

这个函数就是把那些和清单相关的所有数据和方法,都放在函数内部定义并且返回,这样这个函数就可以放在任意的地方来维护。

而我们的组件入口,也就是 <script setup> 中的代码,就可以变得非常简单和清爽了。在下面的代码中,我们只需要调用 useTodos,并且获取所需要的变量即可,具体的实现逻辑可以去 useTodos 内部维护,代码可维护性大大增强。

1
2
3
4
5
6
7
8
9
10
<script setup>
import { ref, computed } from "vue";

let count = ref(1)
function add(){
count.value++
}

let { title, todos, addTodo, clear, active, all, allDone } = useTodos();
</script>

我们在使用 Composition API 拆分功能时,也就是执行 useTodos 的时候,ref、computed等功能都是从 Vue 中单独引入,而不是依赖 this 上下文。其实你可以把组件内部的任何一段代码,从组件文件里抽离出一个独立的文件进行维护。

现在,我们引入追踪鼠标位置的需求进行讲解,比如我们项目中可能有很多地方需要显示鼠标的坐标位置,那我们就可以在项目的 src/utils 文件夹下面新建一个 mouse.js。我们先从 Vue中引入所需要的 ref 函数,然后暴露一个函数,函数内部和上面封装的 useTodos 类似,不过这次独立成了文件,放在 utils 文件下独立维护,提供给项目的所有组件使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {ref, onMounted,onUnmounted} from 'vue'

export function useMouse(){
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}

完成了上面的鼠标事件封装这一步之后,我们在组件的入口就可以和普通函数一样使用useMouse 函数。在下面的代码中,上面的代码返回的 x 和 y 的值可以在模板任意地方使用,也会随着鼠标的移动而改变数值。

1
2
import {useMouse} from '../utils/mouse'
let {x,y} = useMouse()

相信到这里,你一定能体会到 Composition API 对代码组织方式的好处。简单来看,因为ref 和 computed 等功能都可以从 Vue 中全局引入,所以我们就可以把组件进行任意颗粒度的拆分和组合,这样就大大提高了代码的可维护性和复用性。

<script setup> 好用的功能

Composition API 带来的好处你已经掌握了,而 <script setup> 是为了提高我们使用Composition API 的效率而存在的。我们还用累加器来举例,如果没有 <script setup>,那么我们需要写出下面这样的代码来实现累加器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script >
import { ref } from "vue";
export default {
setup() {
let count = ref(1)
function add() {
count.value++
}
return {
count,
add
}
}
}
</script>

在上面的代码中,我们要在 <script> 中导出一个对象。我们在 setup 配置函数中写代码时,和 Options 的写法比,也多了两层嵌套。并且,我们还要在 setup 函数中,返回所有需要在模板中使用的变量和方法。上面的代码中,setup 函数就返回了 count 和 add。

使用 <script setup> 可以让代码变得更加精简,这也是现在开发 Vue 3 项目必备的写法。除了我们上面介绍的功能,<script setup> 还有其它一些很好用的功能,比如能够使用顶层的 await 去请求后端的数据等等,我们会在后面的项目中看到这种使用方法

style 样式的特性

除了 script 相关的配置,我也有必要给你介绍一下 style 样式的配置。比如,在 style 标签上,当我们加上 scoped 这个属性的时候,我们定义的 CSS 就只会应用到当前组件的元素上,这样就很好地避免了一些样式冲突的问题

我们项目中的样式也可以加上如下标签:

1
2
3
4
5
<style scoped>
h1 {
color: red;
}
</style>

这样,组件就会解析成下面代码的样子。标签和样式的属性上,新增了 data- 的前缀,确保只在当前组件生效。

1
2
3
4
5
6
<h1 data-v-3de47834="">1</h1>
<style scoped>
h1[data-v-3de47834] {
color: red;
}
</style>

如果在 scoped 内部,你还想写全局的样式,那么你可以用:global 来标记,这样能确保你可以很灵活地组合你的样式代码(后面项目中用到的话,我还会结合实战进行讲解)。而且我们甚至可以通过 v-bind 函数,直接在 CSS 中使用 JavaScript 中的变量。

  • 样式穿透

    想在定义了scoped的样式中,来定义全局作用的样式。使用>>>来突破作用域,是指全局可见。

1
2
3
4
5
6
7
8
9
<style lang="stylus" scoped>
.wrapper >>> .swiper-pagination-bullet-active
background: #fff !important
</style>
// 注:样式穿透是vue-loader的特性
// 当lang = less or sass 时 解析不了">>>"符号,则使用 /deep/
.a /deep/ .b {
  /*样式*/
}
  • :global
1
2
3
4
5
<style scoped>
:global(.red) {
color: red;
}
</style>

在下面这段代码中, 我在 script 里定义了一个响应式的 color 变量,并且在累加的时候,将变量随机修改为红或者蓝。在 style 内部,我们使用 v-bind 函数绑定 color 的值,就可以动态地通过 JavaScript 的变量实现 CSS 的样式修改,点击累加器的时候文本颜色会随机切换为红或者蓝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>
<h1 @click="add">{{ count }}</h1>
</div>
</template>
<script setup>
import { ref } from "vue";
let count = ref(1)
let color = ref('red')
function add() {
count.value++
color.value = Math.random()>0.5? "blue":"red"
}
</script>
<style scoped>
h1 {
color:v-bind(color);
}
</style>>

巧妙的响应式:深入理解Vue 3的响应式机制

什么是响应式

响应式一直都是 Vue 的特色功能之一。与之相比,JavaScript 里面的变量,是没有响应式这个概念的。你在学习 JavaScript 的时候首先被灌输的概念,就是代码是自上而下执行的。我们看下面的代码,代码在执行后,打印输出的两次 double 的结果也都是 2。即使我们修改了代码中的 count 的值后,double 的值也不会有任何改变

1
2
3
4
5
let count = 1
let double = count * 2
console.log(double)
count = 2
console.log(double)

double 的值是根据 count 的值乘以二计算而得到的,如果现在我们想让 doube 能够跟着count 的变化而变化,那么我们就需要在每次 count 的值修改后,重新计算 double。

比如,在下面的代码,我们先把计算 doube 的逻辑封装成函数,然后在修改完 count 之后,再执行一遍,你就会得到最新的 double 值。

1
2
3
4
5
6
7
8
9
let count = 1
// 计算过程封装成函数
let getDouble = n=>n*2 //箭头函数
let double = getDouble(count)
console.log(double)
count = 2
// 重新计算double,这里我们不能自动执行对double的计算
double = getDouble(count)
console.log(double)

实际开发中的计算逻辑会比计算 doube 复杂的多,但是都可以封装成一个函数去执行。下一步,我们要考虑的是,如何让 double 的值得到自动计算。

如果我们能让 getDouble 函数自动执行,也就是如下图所示,我们使用 JavaScript 的某种机制,把 count 包裹一层,每当对 count 进行修改时,就去同步更新 double 的值,那么就有一种 double 自动跟着 count 的变化而变化的感觉,这就算是响应式的雏形了。

image-20231010160158650

响应式原理

响应式原理是什么呢?Vue 中用过三种响应式解决方案,分别是definePropertyProxyvalue setter。我们首先来看 Vue 2 的 defineProperty API,这个函数详细的 API 介绍你可以直接访问MDN 介绍文档来了解。

这里我结合一个例子来说明,在下面的代码中,我们定义个一个对象 obj,使用defineProperty 代理了 count 属性。这样我们就对 obj 对象的 value 属性实现了拦截,读取count 属性的时候执行 get 函数,修改 count 属性的时候执行 set 函数,并在 set 函数内部重新计算了 double。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let getDouble = n=>n*2
let obj = {}
let count = 1
let double = getDouble(count)
Object.defineProperty(obj,'count',{
get(){
return count
},
set(val){
count = val
double = getDouble(val)
}
})
console.log(double) // 打印2
obj.count = 2
console.log(double) // 打印4 有种自动变化的感觉

这样我们就实现了简易的响应式功能,在课程的第四部分,我还会带着你写一个更完善的响应式系统。

defineProperty API 作为 Vue 2 实现响应式的原理,它的语法中也有一些缺陷。比如在下面代码中,我们删除 obj.count 属性,set 函数就不会执行,double 还是之前的数值。这也是为什么在 Vue 2 中,我们需要 $delete 一个专门的函数去删除数据。

1
2
delete obj.count
console.log(double) // doube还是4

Vue 3 的响应式机制是基于 Proxy 实现的。就 Proxy 这个名字来说,你也能看出来这是代理的意思,Proxy 的重要意义在于它解决了 Vue 2 响应式的缺陷。我们看下面的代码,在其中我们通过 new Proxy 代理了 obj 这个对象,然后通过 getsetdeleteProperty 函数代理了对象的读取、修改和删除操作,从而实现了响应式的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let proxy = new Proxy(obj,{
get : function (target,prop) {
return target[prop]
},
set : function (target,prop,value) {
target[prop] = value;
if(prop==='count'){
double = getDouble(value)
}
},
deleteProperty(target,prop){
delete target[prop]
if(prop==='count'){
double = NaN
}
}
})
console.log(obj.count,double)
proxy.count = 2
console.log(obj.count,double)
delete proxy.count
// 删除属性后,我们打印log时,输出的结果就会是 undefined NaN
console.log(obj.count,double)

我们从这里可以看出 Proxy 实现的功能和 Vue 2 的 definePropery 类似,它们都能够在用户修改数据的时候触发 set 函数,从而实现自动更新 double 的功能。而且 Proxy 还完善了几个 definePropery 的缺陷,比如说可以监听到属性的删除。

Proxy 是针对对象来监听,而不是针对某个具体属性,所以不仅可以代理那些定义时不存在的属性,还可以代理更丰富的数据结构,比如 Map、Set 等,并且我们也能通过deleteProperty 实现对删除操作的代理。

当然,为了帮助你理解 Proxy,我们还可以把 double 相关的代码都写在 set 和deleteProperty 函数里进行实现,在课程的后半程我会带你做好更完善的封装。比如下面代码中,Vue 3 的 reactive 函数可以把一个对象变成响应式数据,而 reactive 就是基于 Proxy实现的。我们还可以通过 watchEffect,在 obj.count 修改之后,执行数据的打印。

1
2
3
4
5
6
7
8
9
10
11
import {reactive,computed,watchEffect} from 'vue'

let obj = reactive({
count:1
})
let double = computed(()=>obj.count*2)
obj.count = 2

watchEffect(()=>{
console.log('数据被修改了',obj.count,double.value)
})

有了 Proxy 后,响应式机制就比较完备了。但是在 Vue 3 中还有另一个响应式实现的逻辑,就是利用对象的 get 和 set 函数来进行监听,这种响应式的实现方式,只能拦截某一个属性的修改,这也是 Vue 3 中 ref 这个 API 的实现。在下面的代码中,我们拦截了 count 的 value属性,并且拦截了 set 操作,也能实现类似的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let getDouble = n => n * 2
let _value = 1
double = getDouble(_value)

let count = {
get value() {
return _value
},
set value(val) {
_value = val
double = getDouble(_value)
}
}
console.log(count.value,double)
count.value = 2
console.log(count.value,double)

三种实现原理的对比表格如下,帮助你理解三种响应式的区别。

image-20231010162230098

定制响应式数据

简单入门响应式的原理后,接下来我们学习一下响应式数据在使用的时候的进阶方式。在前面第二讲做清单应用的时候,我给你留过一个思考题,就是让你想办法解决所有的操作状态在刷新后就都没了这个问题。

解决这个问题所需要的,就是让 todolist 和本地存储能够同步。我们首先可以选择的就是在代码中,显式地声明同步的逻辑,而 watchEffect 这个函数让我们在数据变化之后可以执行指定的函数。

我们看下使用 <script setup> 重构之后的 todolist 的代码。这段代码使用 watchEffect,数据变化之后会把数据同步到 localStorage 之上,这样我们就实现了 todolist 和本地存储的同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { ref, watchEffect, computed } from "vue";

let title = ref("");
let todos = ref(JSON.parse(localStorage.getItem('todos')||'[]'));
watchEffect(()=>{
localStorage.setItem('todos',JSON.stringify(todos.value))
})
function addTodo() {
todos.value.push({
title: title.value,
done: false,
});
title.value = "";
}

更进一步,我们可以直接抽离一个 useStorage 函数,在响应式的基础之上,把任意数据响应式的变化同步到本地存储。我们先看下面的这段代码,ref 从本地存储中获取数据,封装成响应式并且返回,watchEffect 中做本地存储的同步,useStorage 这个函数可以抽离成一个文件,放在工具函数文件夹中。

1
2
3
4
5
6
function useStorage(name, value=[]){
let data = ref(JSON.parse(localStorage.getItem(name)|| value))
watchEffect(()=>{ localStorage.setItem(name,JSON.stringify(data.value))
})
return data
}

在项目中我们使用下面代码的写法,把ref变成useStorage,这也是Composition API 最大的优点,也就是可以任意拆分出独立的功能。

1
2
3
4
5
let todos = useStorage('todos',[])

function addTodo() {
...code
}

现在,你应该已经学会了在 Vue 内部进阶地使用响应式机制,去封装独立的函数。社区也有非常优秀的 Vueuse 工具库,包含了大量类似 useStorage 的工具函数库。在后续的实战应用中,我们也会经常对通用功能进行封装。

如下图所示,我们可以把日常开发中用到的数据,无论是浏览器的本地存储,还是网络数据,都封装成响应式数据,统一使用响应式数据开发的模式。这样,我们开发项目的时候,只需要修改对应的数据就可以了。

image-20231010163134874

基于响应式的开发模式,我们还可以按照类似的原理,把我们需要修改的数据,都变成响应式。比如,我们可以在 loading 状态下,去修改浏览器的小图标 favicon。和本地存储类似,修改 favicon 时,我们需要找到 head 中有 icon 属性的标签。

在下面的代码中,我们把对图标的对应修改的操作封装成了 useFavicon 函数,并且通过 ref和 watch 的包裹,我们还把小图标变成了响应式数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {ref,watch} from 'vue'
export default function useFavicon( newIcon ) {
const favicon = ref(newIcon)

const updateIcon = (icon) => {
document.head
.querySelectorAll(`link[rel*="icon"]`)
.forEach(el => el.href = `${icon}`)
}
const reset = ()=>favicon.value = '/favicon.ico'

watch( favicon,
(i) => {
updateIcon(i)
}
)
return {favicon,reset}
}

这样在组件中,我们就可以通过响应式的方式去修改和使用小图标,通过对 faivcon.value 的修改就可以随时更换网站小图标。下面的代码,就实现了在点击按钮之后,修改了网页的图标为 geek.png 的操作。

1
2
3
4
5
6
7
8
9
10
11
 <script setup>
import useFavicon from './utils/favicon'
let {favicon} = useFavicon()
function loading(){
favicon.value = '/geek.png'
}
</script>

<template>
<button @click="loading">123</button>
</template>

Vueuse 工具包

我们自己封装的 useStorage,算是把 localStorage 简单地变成了响应式对象,实现数据的更新和 localStorage 的同步。同理,我们还可以封装更多的类似 useStorage 函数的其他 use类型的函数,把实际开发中你用到的任何数据或者浏览器属性,都封装成响应式数据,这样就可以极大地提高我们的开发效率。

Vue 社区中其实已经有一个类似的工具集合,也就是 VueUse,它把开发中常见的属性都封装成为响应式函数。

VueUse 趁着这一波 Vue 3 的更新,跟上了响应式 API 的潮流。VueUse 的官方的介绍说这是一个 Composition API 的工具集合,适用于 Vue 2.x 或者 Vue 3.x,用起来和 React Hooks 还挺像的。

在项目目录下打开命令行里,我们输入如下命令,来进行 VueUse 插件的安装:

1
npm install @vueuse/core

然后,我们就先来使用一下 VueUse。在下面这段代码中,我们使用 useFullscreen 来返回全屏的状态和切换全屏的函数。这样,我们就不需要考虑浏览器全屏的 API,而是直接使用VueUse 响应式数据和函数就可以很轻松地在项目中实现全屏功能。

1
2
3
4
5
6
7
<template>
<h1 @click="toggle">click</h1>
</template>
<script setup>
import { useFullscreen } from '@vueuse/core'
const { isFullscreen, enter, exit, toggle } = useFullscreen()
</script>

useFullscreen的封装逻辑和useStorage类似,都是屏蔽了浏览器的操作,把所有我们需要用到的状态和数据都用响应式的方式统一管理,VueUse中包含了很多我们常用的工具函数,我们可以把网络状态、异步请求的数据、动画和事件等功能,都看成是响应式的数据去管理。

组件化:如何像搭积木一样开发网页?

image-20231011152256598

什么是组件化开发

除了浏览器自带的组件外,Vue 还允许我们自定义组件,把一个功能的模板(template)封装在一个.vue 文件中。例如在下图中,我们把每个组件的逻辑和样式,也就是把 JavaScriptCSS 封装在一起,方便在项目中复用整个组件的代码。

image-20231011152356298

我们实际开发的项目中有导航、侧边栏、表格、弹窗等组件,并且也会引入 Element3 这个组件库进行开发。此外,我们也会定制业务相关的组件,最终通过这些组件,搭积木式地把页面搭建起来。

为了帮助你理解设计组件的要点,我先选择一个简单的组件展开讲解。小圣在继续开发项目的时候,有一个评级的需求,简单来说,就是在前端页面上,能够让商品显示1 到 5 的评分。

渲染评级分数

其实,对于简单的评级需求,我们就可以使用组件。这样,只需要一行代码就可以实现评级需求。比如下面的代码,rate 是 1 到 5 的整数,通过 slice 方法,我们直接渲染出对应数量的星星即可。

1
"★★★★★☆☆☆☆☆".slice(5 - rate, 10 - rate)

想要查看上面这行代码的效果,你只需要传入评分值 rate。这行代码的运行效果如下图所示,其中的星星代表着评价的等级,由 rate 的值来决定。

image-20231011153203502

我们在这里写的这个组件就是根据 rate 的值,来渲染出不同数量的星星。我们进入到src/components 目录,新建 Rate.vue,然后写出下面的代码。在下面的代码中,我们使用defineProps 来规范传递数据的格式,这里规定了组件会接收外部传来的 value 属性,并且只能是数字,然后根据 value 的值计算出评分的星星。

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div>
{{rate}}
</div>
</template>
<script setup>
import { defineProps,computed } from 'vue';
let props = defineProps({
value: Number
})
let rate = computed(()=>"★★★★★☆☆☆☆☆".slice(5 - props.value, 10 - props.value)
</script>

使用组件的方式就是使用:value 的方式,通过属性把 score 传递给 Rate 组件,就能够在页面上根据 score 的值,显示出三颗实心的星星。下面的代码展示了如何使用 Rate 组件来显示 3颗星星。

1
2
3
4
5
6
7
8
<template>
<Rate :value="score"></Rate>
</template>
<script setup>
import {ref} from 'vue'
import Rate from './components/Rate1.vue'
let score = ref(3)
</script>

根据传递的 score 值显示的不同的内容,我们也可以更进一步,回到 Rate.vue 代码里,加入如下的代码,比如在组件中内置一些主题颜色,加入 CSS 的内容。如下面代码,Rate 组件新接收一个属性 theme,默认值是 orange。我们在 Rate 组件中内置了几个主题颜色,根据传递的 theme 计算出颜色,并且使用 :style 渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<div :style="fontstyle">
{{rate}}
</div>
</template>
<script setup>
import { defineProps,computed, } from 'vue';
let props = defineProps({
value: Number,
theme:{type:String,default:'orange'}
})
console.log(props)
let rate = computed(()=>"★★★★★☆☆☆☆☆".slice(5 - props.value, 10 - props.value)
const themeObj = {
'black': '#00',
'white': '#fff',
'red': '#f5222d',
'orange': '#fa541c',
'yellow': '#fadb14',
'green': '#73d13d',
'blue': '#40a9ff',
}
const fontstyle = computed(()=> {
return `color:${themeObj[props.theme]};`
})
</script>

在完成了上面代码所示的这一过程,也就是通过 theme 渲染星星颜色这一步,我们就可以使用下面的代码,传递 value 和 theme 两个属性,并且可以很方便地复用组件。

1
2
3
<Rate :value="3" ></Rate>
<Rate :value="4" theme="red"></Rate>
<Rate :value="1" theme="green"></Rate>

在下图中,也可以看到上面三个组件渲染的结果:

image-20231011154912472

组件事件

使用这个 Rate 组件后,虽然前端页面显示评级的需求是完成了,但是评级组件还需要具备修改评分的功能,所以我们需要让组件的星星可点击,并且让点击后的评分值能够传递到父组件。

在 Vue 中,我们使用 emit 来对外传递事件,这样父元素就可以监听 Rate 组件内部的变化。

现在我们对 Rate 组件进行改造,首先由于我们的星星都是普通的文本,没有办法单独绑定click 事件。所以我们要对模板进行改造,每个星星都用 span 包裹,并且我们可以用 width属性控制宽度,支持小数的评分显示。

我们回到 Rate.vue 组件,添加下面的代码,我们把★和☆用 span 包裹,并绑定鼠标的mouseover 事件。然后通过:style,我们可以设置实心五角星★的宽度,实现一样的评级效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<template>
<div :style="fontstyle">
<div class='rate' @mouseout="mouseOut">
<span @mouseover="mouseOver(num)" v-for='num in 5' :key="num">☆</span>
<span class='hollow' :style="fontwidth">
<span @mouseover="mouseOver(num)" v-for='num in 5' :key="num">★</span>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps } from 'vue'
const props = defineProps(['value'])
// 评分宽度
const width = ref(props.value)
function mouseOver (i: number) {
width.value = i
}
function mouseOut () {
width.value = props.value
}
const fontwidth = computed(() => `width:${width.value}em;`)
</script>
<style scoped>
.rate{
position:relative;
display: inline-block;
}
.rate > span.hollow {
position:absolute;
display: inline-block;
top:0;
left:0;
width:0;
overflow:hidden;
}
</style>

mouseOvermouseout事件是鼠标事件中的两种类型,区别如下:

  1. 触发条件不同mouseOver事件是当鼠标指针从元素外部移到元素内部时触发;mouseout事件是当鼠标指针从元素内部移到元素外部时触发。
  2. 冒泡不同mouseOver事件冒泡,即子元素的mouseover事件默认会触发父元素上的mouseover事件;mouseout事件不冒泡,即子元素的mouseout事件默认不会触发父元素上的mouseout事件。

因为现在是通过宽度显示星星,所以我们还可以支持 3.5 分的小数评级,并且支持鼠标滑过的时候选择不同的评分。用下面的代码,我们可以使用 Rate 组件。

1
<Rate :value="3.5" ></Rate>

image-20231011163413482

然后我们需要做的,就是在点击五角星选择评分的时候,把当前评分传递给父组件即可。在Vue 3 中,我们使用 defineEmit 来定义对外“发射”的数据,在点击评分的时候触发即可。

下面的 defineEmit 代码就展示了点击评分后,向父元素“发射”评分数据 num。

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
省略代码
<span @click="onRate(num)" @mouseover="mouseOver(num)" v-for='num in 5' :key="num">★</span>

</template>
<script setup>
import { defineProps, defineEmits,computed, ref} from 'vue';

let emits = defineEmits('update-rate')
function onRate(num){
emits('update-rate',num)
}
</script>

在下面的代码中,我们使用@update-rate 接收Rate组件emit的数据,并且修改score的值,这样就完成了数据修改后的更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>

<h1>你的评分是 {{score}}</h1>
<Rate :value="score" @update-rate="update"></Rate>

</template>

<script setup>
import {ref} from 'vue'
import Rate from './components/Rate1.vue'
let score = ref(3.5)
function update(num){
score.value = num
}

</script>

现在组件的示意图如下,我们通过 definePr 海量资源:666java.com ops 定义了传递数据的格式,通过 defineEmits定义了监听的函数,最终实现了组件和外部数据之间的同步。

image-20231011202317071

组件的 v-model

上面 Rate 组件中数据双向同步的需求在表单领域非常常见,例如第二讲中,我们在 input标签上使用 v-model 这个属性就实现了这个需求。在自定义组件上我们也可以用 v-model,对于自定义组件来说,v-model 是传递属性和接收组件事件两个写法的简写。

在下面的代码中,首先我们把属性名修改成 modelValue,然后如果我们想在前端页面进行点击评级的操作,我们只需要通过 update:modelValue 这个 emit 事件发出通知即可。

1
2
3
4
5
let props = defineProps({
modelValue: Number,
theme:{type:String,default:'orange'}
})
let emits = defineEmits(['update:modelValue'])

然后我们就可以按如下代码中的方式,使用 Rate 这个组件,也就是直接使用 v-model 绑定score 变量。这样,就可以实现 value 和 onRate 两个属性的效果。

1
2
3
4
<template>
<h1>你的评分是 {{score}}</h1>
<Rate v-model="score"></Rate>
</template>

动画:Vue中如何实现动画效果?

前端过渡和动效

在讲 Vue 中的动效和过渡之前,我想先跟你聊一下前端的过渡和动效的实现方式。举个例子,假设我现在有这样一个需求:在页面上要有一个 div 标签,以及一个按钮,点击页面的按钮后,能够让 div 标签的宽度得到增加。

在下面的代码中,我们可以实现上面所说的这个效果。这段代码里,首先是一个 div 标签,我们使用 width 控制宽度。我们想要的前端效果是,每次点击按钮的时候,div 标签的宽度都增加 100px。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="box" :style="{width:width+'px'}"></div>
<button @click="change">click</button>
</template>
<script setup>
import {ref} from 'vue'
let width= ref(100)
function change(){
width.value += 100
}
</script>
<style>
.box{
background:red;
height:100px;
}
</style>

这个功能实现的效果图如下,小圣虽然实现了需求中提到的功能,但是现在的显示效果很生硬,这点你从下面的动态效果图中也能看出来。

image-20231011203111412

为了优化显示的效果,首先我们可以通过一个 CSS 的属性 transition 来实现过渡,实现方式非常简单,直接在 div 的样式里加上一个 transition 配置就可以了。下面是具体的实现,其中我们给 transition 配置了三个参数,简单解释呢,就是 div 的 width 属性需要过渡,过渡时间是 1 秒,并且过渡方式是线性过渡。

1
2
3
4
5
6
7
<style>
.box{
background:#d88986;
height:100px;
transition: width 1s linear;
}
</style>

添加上述 transition 配置后,前端页面会显示如下的过渡效果,是不是流畅了一些呢?实际上,日常项目开发中类似的过渡效果是很常见的。

现在你能看到,我们可以通过 transition 来控制一个元素的属性的值,缓慢地变成另外一个值,这种操作就称之为过渡。除了 transition,我们还可以通过animation keyframe 的组合实现动画。

在下面的代码中,我们指定标签的 antimation 配置,给标签设置 move 动画,持续时间为两秒,线性变化并且无限循环。然后使用 @keyframes 定制 move 动画,内部定义了动画0%、50% 和 100% 的位置,最终实现了一个方块循环移动的效果。

1
2
3
4
5
6
7
8
9
10
11
12
.box1{
width:30px;
height:30px;
position: relative;
background:#d88986;
animation: move 2s linear infinite;
}
@keyframes move {
0% {left:0px}
50% {left:200px}
100% {left:0}
}

Vue 3 动画入门

通常我们实现的动画,会给 Web 应用带来额外的价值。动画和过渡可以增加用户体验的舒适度,让变化更加自然,并且可以吸引用户的注意力,突出重点。transitionanimation 让我们可以用非常简单的方式实现动画。那么在 Vue 3 中,我们到底该如何使用动画呢?

Vue 3 中提供了一些动画的封装,使用内置的 transition 组件来控制组件的动画。为了让你先有一个感性的认识,这里我们先来举一个最简单的例子:我们可以使用一个按钮控制标题文字的显示和隐藏,具体的代码如下,通过点击按钮,就可以控制 h1 标签的显示和隐藏。

1
2
3
4
5
6
7
8
9
10
11
<template>
<button @click="toggle">click</button>
<h1 v-if="showTitle">你好 Vue 3</h1>
</template>
<script setup>
import {ref} from 'vue'
let showTitle = ref(true)
function toggle(){
showTitle.value = !showTitle.value
}
</script>

在 Vue 中,如果我们想要在显示和隐藏标题文字的时候,加入动效进行过渡,那么我们直接使用 transition 组件包裹住需要动画的元素就可以了。

在下面代码中,我们使用 transition 包裹 h1 标签,并且设置了 name 为 fade,Vue 会在h1 标签显示和隐藏的过程中去设置标签的 class,我们可以根据这些 class 去实现想要的动效。

1
2
3
<transition name="fade">
<h1 v-if="showTitle">你好 Vue 3</h1>
</transition>

具体 class 的名字,Vue 的官网有一个图给出了很好的解释,图里的 v-enter-from 中的 v,就是我们设置的 name 属性。所以在我们现在这个案例中,标签在进入和离开的时候,会有fade-enter-active fade-leave-active 的 class,进入的开始和结束会有 fade-enter-fromface-enter-to 两个 class。

image-20231011203929374

根据上图所示的原理,我们在 style 标签中新增如下代码,通过 fade-enter-activefade-leave-active 两个 class,去控制动画全程的过渡属性。设置 opacity 有 0.5 秒的过渡时间,并且在元素进入前和离开后设置 opacity 为 0。

1
2
3
4
5
6
7
8
9
10
11
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s linear;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
  1. v-enter-from:进入动画的起始状态。在元素插入之前添加,在元素插入完成后的下一帧移除。
  2. v-enter-active:进入动画的生效状态。应用于整个进入动画阶段。在元素被插入之前添加,在过渡或动画完成之后移除。这个 class 可以被用来定义进入动画的持续时间、延迟与速度曲线类型。
  3. v-enter-to:进入动画的结束状态。在元素插入完成后的下一帧被添加 (也就是 v-enter-from 被移除的同时),在过渡或动画完成之后移除。
  4. v-leave-from:离开动画的起始状态。在离开过渡效果被触发时立即添加,在一帧后被移除。
  5. v-leave-active:离开动画的生效状态。应用于整个离开动画阶段。在离开过渡效果被触发时立即添加,在过渡或动画完成之后移除。这个 class 可以被用来定义离开动画的持续时间、延迟与速度曲线类型。
  6. v-leave-to:离开动画的结束状态。在一个离开动画被触发后的下一帧被添加 (也就是 v-leave-from 被移除的同时),在过渡或动画完成之后移除。

清单应用优化

现在,我们通过学到的动画原理,去优化一下第二讲的清单应用。我们先来了解一下操作的场景,在原先清单应用已有的交互下,有一个交互的优化,我们想对交互再增加一个优化项。具体来说,就是当输入框为空的时候,敲击回车需要弹出一个错误的提示。

小圣同学对 Composition API 已经非常熟悉了,很快速地写下了下面的代码。小圣在代码的template 中新增了一个显示错误消息的 div,设置为绝对定位,通过 showModal 变量控制显示和隐藏。并且在 addTodo 函数中,如果 title.value 为空,也就是用户输入为空的时候,就设置 showModal 为 true。这时,如果用户敲击回车,就会显示弹窗,并且定时关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<template>
...清单代码
<div class="info-wrapper" v-if="showModal">
<div class="info">
哥,你啥也没输入!
</div>
</div>
</template>

<script setup>
...清单功能代码
let showModal = ref(false)

function addTodo() {
if(!title.value){
showModal.value = true
setTimeout(()=>{
showModal.value = false
},1500)
return
}
todos.value.push({
title: title.value,
done: false,
});
title.value = "";
}
</script>
<style>
.info-wrapper {
position: fixed;
top: 20px;
width:200px;
}
.info {
padding: 20px;
color: white;
background: #d88986;
}
</style>

新增交互后的前端显示效果如下,敲击回车后,如果输入为空,就会显示错误信息的弹窗。

功能虽然实现了,但是我们想进一步提高弹窗的交互效果,也就是弹窗的显示需要新增动画。对于这个需求,我们在直接使用 transition 包裹弹窗之后,设置对应进入和离开的 CSS 样式就可以了。首先,我们给 transition 动画设置 name 为 modal,在 style 中通过对 model 对应的 CSS 设置过渡效果后,就给弹窗增加了弹窗的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<transition name="modal">
<div class="info-wrapper" v-if="showModal">
<div class="info">
哥,你啥也没输入!
</div>
</div>
</transition>

<style>
.modal-enter-from {
opacity: 0;
transform: translateY(-60px);
}
.modal-enter-active {
transition: all 0.3s ease;
}
.modal-leave-to {
opacity: 0;
transform: translateY(-60px);
}
.modal-leave-active {
transition: all 0.3s ease;
}
</style>

通过上面的代码,我们可以进行过渡效果的优化。优化后,前端页面的显示效果如下,可以看到弹窗有一个明显的滑入和划出的过渡效果。

列表动画

学了 transition 组件后,小圣兴致勃勃地把清单应用的列表也做了动画显示,但是现在清单列表并不是一个单独的标签,而是 v-for 渲染的列表元素,所以小圣就来找我求助,问我怎么实现列表项依次动画出现的效果。

在 Vue 中,我们把这种需求称之为列表过渡。因为 transition 组件会把子元素作为一个整体同时去过渡,所以我们需要一个新的内置组件 transition-group。在 v-for 渲染列表的场景之下,我们使用 transition-group 组件去包裹元素,通过 tag 属性去指定渲染一个元素。

此外,transition-group 组件还有一个特殊之处,就是不仅可以进入和离开动画,还可以改变定位。就和之前的类名一样,这个功能新增了 v-move 类,在下面的代码中,使用transition-group 包裹渲染的 li 元素,并且设置动画的 name 属性为 flip-list。然后我们根据v-move 的命名规范,设置 .flip-list-move 的过渡属性,就实现了列表依次出现的效果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 <ul v-if="todos.length">
<transition-group name="flip-list" tag="ul">
<li v-for="todo in todos" :key="todo.title">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }"> {{ todo.title }}</span>
</li>
</transition-group>

</ul>
<style>
.flip-list-move {
transition: transform 0.8s ease;
}
.flip-list-enter-active,
.flip-list-leave-active {
transition: all 1s ease;
}
.flip-list-enter-from,
.flip-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

页面切换动画

对于一般的前端页面应用来说,还有一个常见的动画切换的场景,就是在页面切换这个场景时的动画。这个动画切换场景的核心原理和弹窗动画是一样的,都是通过 transition 标签控制页面进入和离开的 class

现在默认是在 vue-router 的模式下,我们使用 router-view 组件进行动态的组件渲染。在路由发生变化的时候,我们计算出对应匹配的组件去填充 router-view。

如果要在路由组件上使用转场,并且对导航进行动画处理,你就需要使用 v-slot API。我们来到 src/App.vue 组件中,因为之前 router-view 没有子元素,所以我们要对代码进行修改。

在下面的代码中,router-view 通过 v-slot 获取渲染的组件并且赋值给 Component,然后使用 transition 包裹需要渲染的组件,并且通过内置组件 component 的 is 属性动态渲染组件。这里 vue-router 的动画切换效果算是抛砖引玉,关于 vue-router 进阶的适用内容,全家桶实战篇后面的几讲还会继续深入剖析。

1
2
3
4
5
<router-view v-slot="{ Component }">
<transition name="route" mode="out-in">
<component :is="Component" />
</transition>
</router-view>

JavaScript 动画

在前端的大部分交互场景中,动画的主要目的是提高交互体验,CSS 动画足以应对大部分场景。但如果碰见比较复杂的动画场景,就需要用 JavaScript 来实现,比如购物车、地图等场景。

在下面的代码中,我们首先在清单应用中加上一个删除事项的功能,当点击删除图标来删除清单的时候,可以直接删除一行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
...清单应用其他代码
<transition-group name="flip-list" tag="ul">
<li v-for="(todo,i) in todos" :key="todo.title">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }"> {{ todo.title }}</span>
<span class="remove-btn" @click="removeTodo($event,i)">

</span>
</li>
</transition-group>
</template>
<script>
function removeTodo(e,i){
todos.value.splice(i,1)
}
</script>

image-20231011213533405

如果我们想在删除的时候,实现一个图标飞到废纸篓的动画,那么在这个场景下,使用单纯的CSS 动画就不好实现了,我们需要引入 JavaScript 来实现动画。实现的思路也很简单,我们放一个单独存在的动画元素并且藏起来,当点击删除图标的时候,我们把这个动画元素移动到鼠标的位置,再飞到废纸篓里藏起来就可以了。

具体怎么做呢? 在 Vue 的 transition 组件里,我们可以分别设置 before-enter,enter 和after-enter 三个函数来更精确地控制动画。

在下面的代码中,我们首先定义了 animate 响应式对象来控制动画元素的显示和隐藏,并且用 transition 标签包裹动画元素。在 beforeEnter 函数中,通过 getBoundingClientRect 函数获取鼠标的点击位置,让动画元素通过 translate 属性移动到鼠标所在位置;并且在 enter钩子中,把动画元素移动到初始位置,在 afterEnter 中,也就是动画结束后,把动画元素再隐藏起来,这样就实现了类似购物车的飞入效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<template>
<span class="dustbin">
🗑
</span>
<div class="animate-wrap">
<transition @before-enter="beforeEnter" @enter="enter" @after-enter="afterEnter">
<div class="animate" v-show="animate.show">
📋
</div>
</transition>
</div>
</template>

<script setup>

let animate = reactive({
show:false,
el:null
})
function beforeEnter(el){
let dom = animate.el
let rect = dom.getBoundingClientRect()
let x = window.innerWidth - rect.left - 60
let y = rect.top - 10
el.style.transform = `translate(-${x}px, ${y}px)`
}
function enter(el,done){
document.body.offsetHeight
el.style.transform = `translate(0,0)`
el.addEventListener('transitionend', done)
}
function afterEnter(el){
animate.show = false
el.style.display = 'none'
}
function removeTodo(e,i){
animate.el = e.target
animate.show = true
todos.value.splice(i,1)
}
</script>
<style>
.animate-wrap .animate{
position :fixed;
right :10px;
top :10px;
z-index: 100;
transition: all 0.5s linear;
}
</style>

上面代码的显示效果如下,我们点击删除后,除了列表本身的动画移出效果,还多了一个飞入废纸篓的效果。你能看到,在引入JavaScript后,我们可以实现更多定制的动画效果。

数据流:如何使用Vuex设计你的数据流

在全家桶实战篇,我们将一同学习 Vue 3 的生态,包括 Vuexvue-routerVue Devtools等生态库,以及实战开发中需要用到的库。这⼀模块学完,你就能全副武装,应对复杂的项目开发也会慢慢得心应手。

今天,我先来带你认识一下 Vue 全家桶必备的工具:Vuex,有了这个神兵利器,复杂项目设计也会变得条理更清晰。接下来,让我们先从 Vuex 解决了什么问题说起。

前端数据管理

首先,我们需要掌握前端的数据怎么管理,现代 Web 应用都是由三大件构成,分别是:组件、数据和路由。关于组件化开发,在前面的第 8 讲中,已经有详细的讲解了。这一讲我们思考一个这样的场景,就是有一些数据组件之间需要共享的时候,应该如何实现?

解决这个问题的最常见的一种思路就是:专门定义一个全局变量,任何组件需要数据的时候都去这个全局变量中获取。一些通用的数据,比如用户登录信息,以及一个跨层级的组件通信都可以通过这个全局变量很好地实现。在下面的代码中我们使用 _store 这个全局变量存储数据。

1
window._store = {}

数据存储的结构图大致如下,任何组件内部都可以通过 window._store 获取数据并且修改。

image-20231013123524811

但这样就会产生一个问题,**window._store 并不是响应式的,如果在 Vue 项目中直接使用,那么就无法自动更新页面。所以我们需要refreactive 去把数据包裹成响应式数据**,并且提供统一的操作方法,这其实就是数据管理框架 Vuex 的雏形了。

Vuex 是什么

你现在肯定跟小圣有同样的困惑,那就是感觉 Vue 已经够用了,这个 Vuex 又是做什么的?

其实,Vuex 存在的意义,就是管理我们项目的数据

我们是使用组件化机制来搭建整个项目,每个组件内部有自己的数据和模板。但是总有些数据是需要共享的,比如当前登录的用户名、权限等数据,如果都在组件内部传递,会变得非常混乱。

如果把开发的项目比作公司的话,我们项目中的各种数据就非常像办公用品。很多小公司在初创时期不需要管理太多,大家随便拿办公用品就行。但是公司大了之后,就需要一个专门的办公用品申报的流程,对数据做统一地申请和发放,这样才能方便做资产管理。Vuex 就相当于我们项目中的大管家,集中式存储管理应用的所有组件的状态

下面,我们先来上手使用一下 Vuex。我们项目结构中的 src/store 目录,就是专门留给 Vuex的,在项目的目录下,我们执行下面这个命令,进行 Vuex 的安装工作。

1
npm install vuex@next

安装完成后,我们在 src/store 中先新建 index.js,在下面的代码中,我们使用 createStore来创建一个数据存储,我们称之为 store。

vuex

store 内部除了数据,还需要一个 mutation 配置去修改数据,你可以把这个 mutation 理解为数据更新的申请单,mutation 内部的函数会把 state 作为参数,我们直接操作 state.count就可以完成数据的修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createStore } from 'vuex'
const store = createStore({
state () {
return {
count: 666
}
},
mutations: {
add (state) {
state.count++
}
}
})

现在你会发现,我们的代码里,在 Vue 的组件系统之外,多了一个数据源,里面只有一个变量 count,并且有一个方法可以累加这个 count。然后,我们在 Vue 中注册这个数据源,在项目入口文件 src/main.js 中,使用 app.use(store) 进行注册,这样 Vue 和 Vuex 就连接上了。

1
2
3
4
const app = createApp(App)
app.use(store)
.use(router)
.mount('#app')

之后,我们在 src/components 文件夹下新建一个 Count.vue 组件,在下面的代码中,template 中的代码我们很熟悉了,就是一个 div 渲染了 count 变量,并且点击的时候触发add 方法。在 script 中,我们使用 useStore 去获取数据源,初始化值和修改的函数有两个变化:

count 不是使用 ref 直接定义,而是使用计算属性返回了store.state.count,也就是刚才在 src/store/index.js 中定义的 count。add 函数是用来修改数据,这里我们不能直接去操作 store.state.count +=1,因为这个数据属于 Vuex 统一管理,所以我们要使用 store.commit(‘add’) 去触发 Vuex 中的mutation 去修改数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div @click="add">
{{count}}
</div>
</template>
<script setup>
import { computed } from 'vue'
import {useStore} from 'vuex'
let store = useStore()
let count = computed(()=>store.state.count)
function add(){
store.commit('add')
}
</script>

在浏览器中打开项目页面,我们就会有一个累加器的效果。相比起来之前用 ref 的方式,真的很简单,这时候小圣就问了我一个问题:什么时候的数据用 Vuex 管理,什么时候数据要放在组件内部使用 ref 管理呢?

答案就是,对于一个数据,如果只是组件内部使用就是用 ref 管理;如果我们需要跨组件,跨页面共享的时候,我们就需要把数据从 Vue 的组件内部抽离出来,放在 Vuex 中去管理

我再结合例子具体说说:比如项目中的登录用户名,页面的右上角需要显示,有些信息弹窗也需要显示。这样的数据就需要放在 Vuex 中统一管理,每当需要抽离这样的数据的时候,我们都需要思考这个数据的初始化和更新逻辑。

就像下图中,项目初始化的时候没有登录状态,我们是在用户登录成功之后,才能获取用户名这个信息,去修改 Vuex 的数据,再通过 Vuex 派发到所有的组件中。

image-20231013143446937

手写迷你 Vuex

知道了 Vuex 是什么,接下来我们不妨动手实现一个迷你的 Vuex,这能让你看到 Vuex 的大致原理。

首先,我们需要创建一个变量 store 用来存储数据。下一步就是把这个 store 的数据包转成响应式的数据,并且提供给 Vue 组件使用。在 Vue 中有 provide/inject 这两个函数专门用来做数据共享,provide 注册了数据后,所有的子组件都可以通过 inject 获取数据,这两个函数官方文档介绍得比较详细,我在这里就不过多解释了。

完成刚才的数据转换之后,我们直接进入到 src/store 文件夹下,新建 myVuex.js。下面的代码中,我们使用一个 Store 类来管理数据,类的内部使用 _state 存储数据,使用 mutations 来存储数据修改的函数,注意这里的 state 已经使用 reactive 包裹成响应式数据了。

src/store/myVuex.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import { inject, reactive, computed } from 'vue'

// 规定了提供全局数据的key, provide和inject使用
const STORE_KEY = '__store__'

function useStore () {
// 任意子组件中获取provide提供的数据
return inject(STORE_KEY)
// return inject(STORE_KEY) 这里在子组件调用useStore时,通过STORE_KEY拿到key对应的value
}
/**
* 作用是实例化Store对象,把用户写的vuex的几个属性(state,actions,
mutations,getters)传给Store对象
* @param {*} options
* @returns Store
*/
function createStore (options) {
return new Store(options)
}

class Store {
constructor (options) {
this.$options = options
// 用户传入的state对象变成响应式数据,并保存到内部变量_state上面
this._state = reactive({
data: options.state()
})
// 直接保存用户的mutations
this._mutations = options.mutations
this._actions = options.actions
this.getters = {}

Object.keys(options.getters).forEach(name => {
const fn = options.getters[name]
this.getters[name] = computed(() => fn(this.state))
})
}

// state方法返回保存在_state上面的用户的state, 使用get state(){}格式,
// 是为了访问this.state时直接拿到返回值,而不是this.state() 才能拿到返回值
get state () {
return this._state.data
}

/**
* commit方法是在用户调用commit时一般会传入要触发的mutions函数名称,和新数据
commit函数在接收到用户传入的参数时,先判断了_mutations对象上面有用户要触发的函
数,之后进行触发,传入state,和新数据提供给用户的mutations函数使用
* @param {*} type
* @param {*} payload
*/
commit = (type, payload) => {
const entry = this._mutations[type]
entry && entry(this.state, payload)
}

dispatch (type, payload) {
const entry = this._actions[type]
return entry && entry(this, payload)
}

/**
* 在Vue.use(store)时,会执行install函数,这时调用了app上面的provide,此时全局最顶层的ap
p组件会提供一个key为STORE_KEY(__store__)的对象,值为this(Store对象)供任意子组
件通过inject去获取顶层组件提供的store数据
* @param {*} app
*/
install (app) {
// 全局提供数据
app.provide(STORE_KEY, this)
}
}

export { createStore, useStore }

下面的代码中,我们使用 createStore 创建了一个 store 实例,并且实例内部使用 state 定义了 count 变量和修改 count 值的 add 函数。

src/store/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { createStore } from './myVuex'

const store = createStore({
state () {
return {
count: 1
}
},
getters: {
double (state) {
return state.count * 2
}
},
mutations: {
add (state) {
state.count++
}
},
actions: {
asyncAdd ({ commit }) {
setTimeout(() => {
commit('add')
}, 1000)
}
}

})

export default store

src/main.ts

app.use() https://github.com/vuejs/core/blob/main/packages/runtime-core/src/apiCreateApp.ts#L219

image-20231013164112361

1
2
3
4
5
6
7
8
9
10
import { createApp } from 'vue'
import App from './App.vue'
//@ts-ignore
import store from './store/index.js'

const app = createApp(App)

app.use(store)

app.mount('#app')

src/components/MyVuexDemo.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
<h1>{{count}}</h1>
<div>{{ count }} *2 = {{ double }}</div>
<button @click="add">点击加加</button>
<button @click="asyncAdd">异步加加</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useStore } from '../store/myVuex'
const store = useStore()
const count = computed(() => store.state.count)
const double = computed(() => store.getters.double)
function add () {
store.commit('add')
}
function asyncAdd () {
store.dispatch('asyncAdd')
}
</script>

image-20231013154841224

在项目入口文件 src/main.js 中使用 app.use(store) 注册。为了让 useStore 能正常工作,下面的代码中,我们需要给 store 新增一个 install 方法,这个方法会在 app.use 函数内部执行。我们通过 app.provide 函数注册 store 给全局的组件使用。

1
2
3
4
5
6
class Store {
// main.js入口处app.use(store)的时候,会执行这个函数
install(app) {
app.provide(STORE_KEY, this)
}
}

Vuex 实战

从上面的例子你可以立即看出,Vuex 就是一个公用版本的 ref,提供响应式数据给整个项目使用。现在的功能还比较简单,项目大部分情况都是像之前的清单应用一样,除了简单的数据修改,还会有一些异步任务的触发,这些场景 Vuex 都有专门的处理方式。

在 Vuex 中,你可以使用 getters 配置,来实现 computed 的功能,比如我们想显示累加器数字乘以 2 之后的值,那么我们就需要引入 getters 配置。下面的代码中,我们实现了计算累加器数字乘以 2 以后的值。我们在 Vuex 中新增了 getters配置,其实 getters 配置和 Vue 中的 computed 是一样的写法和功能。我们配置了 doubule函数,用于显示 count 乘以 2 的计算结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { createStore } from 'vuex'
const store = createStore({
state () {
return {
count: 666
}
},
getters:{
double(state){
return state.count*2
}
},
mutations: {
add (state) {
state.count++
}
}
})

export default store

然后,我们可以很方便地在组件中使用 getters,把 double 处理和计算的逻辑交给 Vuex。

1
let double = computed(()=>store.getters.double)

实际项目开发中,有很多数据我们都是从网络请求中获取到的。在 Vuex 中,mutation 的设计就是用来实现同步地修改数据。如果数据是异步修改的,我们需要一个新的配置 action。现在我们模拟一个异步的场景,就是点击按钮之后的 1 秒,再去做数据的修改。

面对这种异步的修改需求,在 Vuex 中你需要新增 action 的配置,在 action 中你可以做任意的异步处理。这里我们使用 setTimeout 来模拟延时,然后在 action 内部调用 mutation 就可以了。

听起来是不是很绕?不过你不用担心,下面的代码就很清晰地演示了这个过程。

首先,我们在 createStore 的配置中,新增了 actions 配置,这个配置中所有的函数,可以通过解构获得 commit 函数。内部的异步任务完成后,就随时可以调用commit来执行mutations 去更新数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const store = createStore({
state () {
return {
count: 666
}
},
...
actions:{
asyncAdd({commit}){
setTimeout(()=>{
commit('add')
},1000)
}
}
})

action 并不是直接修改数据,而是通过 mutations 去修改,这是我提醒你需要注意的

actions 的调用方式是使用 store.dispatch,在下面的代码中你可以看到这样的变化效果:页面中新增了一个 asyncAdd 的按钮,点击后会延迟一秒做累加。

1
2
3
function asyncAdd(){
store.dispatch('asyncAdd')
}

代码执行的效果如下:

image-20231013160820284

Vuex 在整体上的逻辑如下图所示,从宏观来说,Vue 的组件负责渲染页面,组件中用到跨页面的数据,就是用 state 来存储,但是 Vue 不能直接修改 state,而是要通过actions/mutations 去做数据的修改。

image-20231013160919183

下面这个图也是 Vuex 官方的结构图,很好地拆解了 Vuex 在 Vue 全家桶中的定位,我们项目中也会用 Vuex 来管理所有的跨组件的数据,并且我们也会在 Vuex 内部根据功能模块去做拆分,会把用户、权限等不同模块的组件分开去管理。

image-20231013161012101

由于 Vuex 所有的数据修改都是通过 mutations 来完成的,因而我们可以很方便地监控到数据的动态变化,后面我们可以借助官方的调试工具,非常方便地去调试项目中的数据变化。

回到正在做的这个项目中,有大量的数据交互需求、用户的登录状态、登录的有效期、布局的设置,不同用户还会有不同的菜单权限等。

不过面对眼花缭乱的交互需求,你不能自乱阵脚。总体来说,我们在决定一个数据是否用Vuex 来管理的时候,核心就是要思考清楚,这个数据是否有共享给其他页面或者是其他组件的需要。如果需要,就放置在 Vuex 中管理;如果不需要,就应该放在组件内部使用 ref 或者reactive 去管理。

下一代 Vuex

Vuex 由于在 API 的设计上,对 TypeScript 的类型推导的支持比较复杂,用起来很是痛苦。因为我们的项目一直用的都是 JavaScript,你可能感触并不深,但对于使用 TypeScript 的用户来说,Vuex 的这种问题是很明显的。

为了解决 Vuex 的这个问题,Vuex 的作者最近发布了一个新的作品叫 Pinia,并将其称之为下一代的 Vuex。Pinia 的 API 的设计非常接近 Vuex5 的提案,首先,Pinia 不需要 Vuex 自定义复杂的类型去支持 TypeScript,天生对类型推断就非常友好,并且对 Vue Devtool 的支持也非常好,是一个很有潜力的状态管理框架。

总结

简单来说,Vuex 是一个状态和数据管理的框架,负责管理项目中多个组件和多个页面共享的数据。在开发项目的时候,我们就会把数据分成两个部分,一种数据是在某个组件内部使用,我们使用 ref 或者 reactive 定义即可,另外一种数据需要跨页面共享,就需要使用 Vuex 来进行管理。

之后,我们还讲到了 Vuex 带来了几个新的概念,我们使用 state 定义数据,使用 mutation定义修改数据的逻辑,并且在组件中使用 commit 去调用 mutations。在此基础之上,还可以用 getters 去实现 Vuex 世界的计算属性,使用 action 来去定义异步任务,并且在内部调用 mutation 去同步数据。

路由:新一代vue-router带来什么变化

其实项目中除了数据管理,路由系统也是非常核心的模块。所以在这一讲中,我会先带你了解一下前端开发方式的演变,让你明白前端路由因何而来,之后再讲解前端路由的实现原理。最后,我会再带你手写一个 vue-router,并在这个过程中为你补充相关的实战要点,让你对如何用好 vue-router 有一个直观体验。

前后端开发模式的演变

在 jQuery 时代,对于大部分 Web 项目而言,前端都是不能控制路由的,而是需要依赖后端项目的路由系统。通常,前端项目也会部署在后端项目的模板里,整个项目执行的示意图如下:

image-20231013164415872

jQuery 那个时代的前端工程师,都要学会在后端的模板,比如 JSP,Smatry 等里面写一些代码。但是在这个时代,前端工程师并不需要了解路由的概念。对于每次的页面跳转,都由后端开发人员来负责重新渲染模板。

前端依赖后端,并且前端不需要负责路由的这种开发方式,有很多的优点,比如开发速度会很快、后端也可以承担部分前端任务等,所以到现在还有很多公司的内部管理系统是这样的架构。当然,这种开发方式也有很多缺点,比如前后端项目无法分离、页面跳转由于需要重新刷新整个页面、等待时间较长等等,所以也会让交互体验下降。

为了提高页面的交互体验,很多前端工程师做了不同的尝试。在这个过程中,前端的开发模式发生了变化,项目的结构也发生了变化。下图所示的,是在目前的前端开发中,用户访问页面后代码执行的过程。

image-20231013164534755

从上面的示意图中,我们可以看到:用户访问路由后,无论是什么 URL 地址,都直接渲染一个前端的入口文件 index.html,然后就会在 index.html 文件中加载 JS 和 CSS。之后,JavaScript 获取当前的页面地址,以及当前路由匹配的组件,再去动态渲染当前页面即可。用户在页面上进行点击操作时,也不需要刷新页面,而是直接通过 JS 重新计算出匹配的路由渲染即可。

在前后两个示意图中,绿色的部分表示的就是前端负责的内容。而在后面这个架构下,前端获得了路由的控制权,在 JavaScript 中控制路由系统。也因此,页面跳转的时候就不需要刷新页面,网页的浏览体验也得到了提高。这种所有路由都渲染一个前端入口文件的方式,是单页面应用程序(SPA,single page application)应用的雏形。

通过 JavaScript 动态控制数据去提高用户体验的方式并不新奇,Ajax 让数据的获取不需要刷新页面,SPA 应用让路由跳转也不需要刷新页面。这种开发的模式在 jQuery 时代就出来了,浏览器路由的变化可以通过 pushState 来操作,这种纯前端开发应用的方式,以前称之为Pjax (pushState+ Ajax)。之后,这种开发模式在 MVVM 框架的时代大放异彩,现在大部分使用 Vue/React/Angular 的应用都是这种架构。

SPA 应用相比于模板的开发方式,对前端更加友好,比如:前端对项目的控制权更大了、交互体验也更加丝滑,更重要的是,前端项目终于可以独立出来单独部署了。

前端路由的实现原理

现在,通过 URL 区分路由的机制上,有两种实现方式,一种是 hash 模式,通过 URL 中 # 后面的内容做区分,我们称之为 hash-router另外一个方式就是 history 模式,在这种方式下,路由看起来和正常的 URL 完全一致。

这两个不同的原理,在 vue-router 中对应两个函数,分别是 createWebHashHistorycreateWebHistory

image-20231013165059086

hash 模式

单页应用在页面交互、页面跳转上都是无刷新的,这极大地提高了用户访问网页的体验。为了实现单页应用,前端路由的需求也变得重要了起来。

类似于服务端路由,前端路由实现起来其实也很简单,就是匹配不同的 URL 路径,进行解析,然后动态地渲染出区域 HTML 内容。但是这样存在一个问题,就是 URL 每次变化的时候,都会造成页面的刷新。解决这一问题的思路便是在改变 URL 的情况下,保证页面的不刷新。

在 2014 年之前,大家是通过 hash 来实现前端路由,URL hash 中的 # 就是类似于下面代码中的这种 # :

1
http://www.xxx.com/#/login

之后,在进行页面跳转的操作时,hash 值的变化并不会导致浏览器页面的刷新,只是会触发hashchange 事件。在下面的代码中,通过对 hashchange 事件的监听,我们就可以在 fn 函数内部进行动态地页面切换。

1
window.addEventListener('hashchange',fn)

history 模式

2014 年之后,因为 HTML5 标准发布,浏览器多了两个 API:pushStatereplaceState。通过这两个 API ,我们可以改变 URL 地址,并且浏览器不会向后端发送请求,我们就能用另外一种方式实现前端路由 。

在下面的代码中,我们监听了 popstate 事件,可以监听到通过 pushState 修改路由的变化。并且在 fn 函数中,我们实现了页面的更新操作。

1
window.addEventListener('popstate', fn)

手写迷你 vue-router

明白了前端路由实现原理还不够,接下来我们一起写代码直观感受一下。这里我们准备设计一个使用 hash 模式的迷你 vue-router。

现在,我们在 src/router 目录下新建一个 grouter 文件夹,并且在 grouter 文件夹内部新建index.js。有了上一讲手写 Vuex 的基础,我们就可以在 index.js 中写入下面的代码。

在代码中,我们首先实现了用 Router 类去管理路由,并且,我们使用createWebHashHistory 来返回 hash 模式相关的监听代码,以及返回当前 URL 和监听hashchange 事件的方法;然后,我们通过 Router 类的install方法注册了 Router 的实例,并对外暴露 createRouter 方法去创建 Router 实例;最后,我们还暴露了 useRouter 方法,去获取路由实例。

下一步,我们需要注册两个内置组件 router-viewrouter-link。在 createRouter 创建的Router 实例上,current 返回当前的路由地址,并且使用 ref 包裹成响应式的数据。router-view 组件的功能,就是 current 发生变化的时候,去匹配 current 地址对应的组件,然后动态渲染到 router-view 就可以了。

src/router/myRouter.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { ref, inject } from 'vue'
import RouterLink from './RouterLink.vue'
import RouterView from './RouterView.vue'

const ROUTER_KEY = '__router__'

function createRouter (options) {
return new Router(options)
}

function useRouter () {
return inject(ROUTER_KEY)
}

function createWebHashHistory () {
function bindEvents (fn) {
window.addEventListener('hashchange', fn)
}
return {
bindEvents,
// window.location.hash.slice(1)如#/home,去掉#号,得到/home
url: window.location.hash.slice(1) || '/'
}
}

class Router {
constructor (options) {
this.history = options.history
this.routes = options.routes
this.current = ref(this.history.url)

this.history.bindEvents(() => {
// window.location.hash.slice(1)如#/home,去掉#号,得到/home
this.current.value = window.location.hash.slice(1)
})
}

install (app) {
app.provide(ROUTER_KEY, this)
app.component('router-link', RouterLink)
app.component('router-view', RouterView)
}
}

export { createRouter, useRouter, createWebHashHistory }

src/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { createRouter, createWebHashHistory } from './myRouter'

import Home from '../views/home.vue'
import About from '../views/about.vue'

const routes = [
{
path: '/home',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
component: About
}
]

const router = createRouter({
history: createWebHashHistory(),
routes
})

export default router

src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createApp } from 'vue'
import App from './App.vue'
//@ts-ignore
import store from './store/index.js'
//@ts-ignore
import router from './router/index.js'

const app = createApp(App)

app.use(store)
app.use(router)

app.mount('#app')

我们在 src/router/下新建 RouterView.vue,写出下面的代码。在代码中,我们首先使用 useRouter 获取当前路由的实例;然后通过当前的路由,也就是 router.current.value的值,在用户路由配置 route 中计算出匹配的组件;最后通过计算属性返回 comp 变量,在template 内部使用 component 组件动态渲染。

src/router/RouterView.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<component :is="component"></component>
</template>

<script setup>
import { computed } from 'vue'
import { useRouter } from './myRouter'

const router = useRouter()

const component = computed(() => {
const route = router.routes.find(
(route) => route.path === router.current.value
)
return route ? route.component : null
})
</script>

在上面的代码中,我们的目的是介绍 vue-router 的大致原理。之后,在课程的源码篇中,我们会在《前端路由原理:vue-router 源码剖析》这一讲完善这个函数的路由匹配逻辑,并让这个函数支持正则匹配。

有了 RouterView 组件后,我们再来实现 router-link 组件。我们在 grouter 下面新建文件RouterLink.vue,并写入下面的代码。代码中的 template 依然是渲染一个 a 标签,只是把a 标签的 href 属性前面加了个一个 #, 就实现了 hash 的修改。

src/router/RouterLink.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<a :href="'#' + props.to">
<slot/>
</a>
</template>
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
to: {
type: String,
require: true
}
})
</script>

实际上,vue-router 还需要处理很多额外的任务,比如路由懒加载、路由的正则匹配等等。在今天了解了 vue-router 原理之后,等到课程最后一部分剖析 vue-router 源码的那一讲时,你就可以真正感受到“玩具版”的 router 和实战开发中的 router 的区别。

vue-router 实战要点

了解了 vue-router 的原理后,我们再来介绍一下 vue-router 在实战中的几个常见功能。

首先是在路由匹配的语法上,vue-router 支持动态路由。例如我们有一个用户页面,这个页面使用的是 User 组件,但是每个用户的信息都不一样,需要给每一个用户配置单独的路由入口,这时就可以按下面代码中的样式来配置路由。

在下面的代码中,冒号开头的 id 就是路由的动态部分,会同时匹配 /user/dasheng 和/user/geektime, 这一部分的详细内容你可以参考官方文档的路由匹配语法部分

1
2
3
const routes = [
{ path: '/users/:id', component: User },
]

然后是在实战中,对于有些页面来说,只有管理员才可以访问,普通用户访问时,会提示没有权限。这时就需要用到 vue-router导航守卫功能了,也就是在访问路由页面之前进行权限认证,这样可以做到对页面的控制,也就是只允许某些用户可以访问。

此外,在项目庞大之后,如果首屏加载文件太大,那么就可能会影响到性能。这个时候,我们可以使用 vue-router动态导入功能把不常用的路由组件单独打包,当访问到这个路由的时候再进行加载,这也是 vue 项目中常见的优化方式。

调试:提高开发效率必备的Vue Devtools

在项目开发中,我们会碰到各种各样的问题,有样式错误、有不符合预期的代码报错、有前后端联调失败等问题。也因此,一个能全盘帮我们监控项目的方方面面,甚至在监控时,能精确到源码每一行的运行状态的调试工具,就显得非常有必要了。

Chrome 的开发者工具 Devtools,就是 Vue 的调试工具中最好的选择。由于 Chrome 也公开了 Devtools 开发的规范,因而各大框架比如 Vue 和 React,都会在 Chrome Devtools的基础之上,开发自己框架的调试插件,这样就可以更方便地调试框架内部的代码。VueDevtools 就是 Vue 官方开发的一个基于 Chrome 浏览器的插件,作为调试工具,它可以帮助我们更好地调试 Vuejs 代码。

这节课,我会先为你讲解如何借助 ChromeVS Code 搭建高效的开发环境,然后再教你使用 Vue 的官方调试插件 Vue Devtools 来进行项目调试工作。

Chrome 调试工具

首先,我们来了解一下 Chrome 的调试工具,也就是 Chrcom 的开发者工具 ChromeDevTools。在 Chrome 浏览器中,我们打开任意一个页面,点击鼠标右键,再点击审查元素(检查),或者直接点击 F12 就可以看到调试窗口了。

image-20231013214047213

我们看下截图中的调试窗口,里面有几个页面是我们经常用到的:Elements 页面可以帮助我们调试页面的 HTML 和 CSS;Console 页面是我们用得最多的页面,它可以帮助我们调试JavaScript;Source 页面可以帮助我们调试开发中的源码Application 页面可以帮助我们调试本地存储和一些浏览器服务,比如 CookieLocalstorage、通知等等。

Network 页面在我们开发前后端交互接口的时候,可以让我们看到每个网络请求的状态和参数;**Performance 页面则用来调试网页性能Lighthouse 是 Google 官方开发的插件,用来获取网页性能报告**,今天我也会教你用 lighthouse 评测一下极客时间官网首页的性能。

以上说的这些调试窗口中的页面,都是 Chrome 的开发者工具中自带的选项,而调试窗口最后面的 Vue 页面就是需要额外安装的 Vue Devtools,也就是调试 Vue 必备的工具。

image-20231013214601695

上图所示的是项目开发中用到最多的页面,而在调试窗口右侧的工具栏中,你还可以选中More tools 来开启更多自带的插件。如下图所示,More tools 中的 Animations 用于调试动画,Security 用于调试安全特性等等。

image-20231013214643468

下面,我们重点介绍一下调试窗口中的 Elements 页面和 Console 页面。这两个页面用来调试页面中的 HTML+CSS+JavaScript,是使用频率最高的两个页面。

由于经常使用,这里不做详细介绍

参考我们提到的国外程序员的做法,我们在 src/main.js 里加入下面这段代码 ,这样就可以在日志信息中直接复制报错内容中的链接,去 Stack Overflow 中寻找答案。

1
2
3
window.onerror = function (e) {
console.log(['https://stackoverflow.com/search?q=[js]+' + e])
}

其实 Console 页面的用法非常多,当我们在代码里使用 cosole.log 打印信息时,console 页面里就会显示 log 传递的参数,这也是程序员用得最多的调试方法。

除了 console.log,还有 console.infoconsole.error 等方法可以显示不同级别的报错信息。而在 log 之外,我们还可以使用 console.table 更便捷地打印数组消息。在 MDN 的Console 页面中,有对 Console 的全部 API 的介绍,你也可以去参考一下。

关于 Console,后续的课程中还会持续地用到,我在这里还可以分享一个我喜欢用的前端面试题,题目来自贺老的面试题。那就是我会把电脑给面试者,让他在 Console 页面里写代码,统计极客时间官网一共有多少种 HTML 标签。

1
new Set([...document.querySelectorAll('*')].map(n => n.nodeName)).size

image-20231013220942187

Vue Devtools

Vue Devtools 的官网上有详细的安装教程,这里就不过多讲解了。安装完毕后,如果调试的前端页面中有 Vue 相关的代码,就会激活这个 tab。进入到 Vue 这个调试页面后,你就会看到下面的示意图。

image-20231014160150020

从上面的图中你可以看到,Vue Devtools 可以算是一个 Elements 页面的 Vue 定制版本,调试页面左侧的显示内容并不是 HTML,而是 Vue 的组件嵌套关系。我们可以从中清晰地看到整个项目中最外层的 App 组件,也能看到 App 组件内部的 RouterView 下面的 Todo 组件。

并且,在调试页面的左侧中,当我们点击组件的时候,我们所调试的前端页面中也会高亮清单组件的覆盖范围。调试页面的右侧则显示着 todo 组件内部所有的数据和方法。我们可以清晰地看到 setup 配置下,有 todos、animate、active 等诸多变量,并且这些变量也是和页面实时同步的数据,我们在页面中输入新的清单后,可以看到 active 和 all 的数据也随之发生了变化。

同时,我们也可以直接修改调试窗口里面的数据,这样,正在调试的前端页面也会同步数据的显示效果。有了 Vue 的调试页面,当我们碰到页面中的数据和标签不同步的情况时,就可以很轻松地定位出是哪里出现了问题。

然后在 Component 的下拉框那里,我们还可以选择 Vuex 和 Router 页面,分别用来调试Vuexvue-router

image-20231014161030389

我们先来点击 Vuex 页面试一下,这个页面里的操作可以帮助我们把 Vuex 的执行过程从黑盒变成一个白盒。简单来说,我们可以在调试窗口的右侧看到 Vuex 内部所有的数据变化,包括state、getters 等。

image-20231014161128217

我们点击 Vuex 下拉框里的 Routes 页面,这个页面里显示了整个应用路由的配置、匹配状态、参数等,这里就不做过多的解释了。相信有了 Vue Devtools 后,你能够更快地调试 Vue项目的内部运行状态,从而极大地提高开发效率。

image-20231014161424676

这里还有一个小技巧,你可以了解一下:在 Components 页面下,你选中一个组件后,调试窗口的右侧就会出现 4 个小工具。

如下图所示,在我用红框标记的四个工具中,最右边的那个工具可以让你直接在编辑器里打开这个代码。这样,调试组件的时候就不用根据路径再去 VS Code 里搜索代码文件了,这算是一个非常好用的小功能。

image-20231014161509253

断点调试

正常情况下,我们用好 Elements、Console 和 Vue 这三个页面就可以处理大部分的调试结果了。不过太多的 Console 信息会让页面显得非常臃肿,所以还出现过专门去掉 Console 代码的 webpack 插件

如果代码逻辑比较复杂,过多的 Console 信息也会让我们难以调试。这种情况就需要使用断点调试的功能,Chrome 的调试窗口会识别代码中的 debugger 关键字,并中断代码的执行。

1
2
3
4
5
created () {
debugger
console.log('111')
console.log('2222')
}

image-20231014162316706

性能相关的调试

了解了页面代码的调试方法后,我们再来分享一下页面的性能调试方法。比如,在你遇到页面交互略有卡顿的时候,你可以在调试窗口中点击 Performance 页面中的录制按钮,然后重复你卡顿的操作后,点击结束,就可以清晰看到你在和页面进行交互操作时,浏览器中性能的变化。

以极客时间的官网页面作为具体的例子,我们在调试窗口中点击 Performance 页面中的录制按钮,然后进行刷新页面的操作,并点击首页轮播图,之后我们可以看到如下的效果:

image-20231014162922211

我们可以滑动鼠标,这样就能很清晰地看到极客时间页面加载的过程。然后,我们重点看下首屏加载中的性能指标,通过下方的饼图,你可以看到整个刷新过程中耗时的比例,其中 JS 代码 391ms,整体 624ms。

image-20231014163131081

在 Performace 页面中,我们还可以详细地看到每个函数的执行时间。我们录制一下清单应用新增清单的操作之后,就会显示下面的示意图,从中可以清晰地看到键盘 keydown 事件之后执行的函数,在图中可以找到我们写的 addTodo 方法,以及 mountElement 等 Vue 源码里的函数。关于 Chrome 性能页面更多的使用方法 ,你可以到Chrome 官方文档上去查看。

image-20231014163428847

如果你觉得上面手动录制页面的性能报告的方法过于繁琐,还可以直接使用 lighthouse 插件。我们进入到 lighthouse 页面,选择 desktop 桌面版后,点击生成报告。lighthouse 在浏览器上模拟刷新的操作后,给出一个网页评分。这里我们可以看到,极客时间网站首页的评分是 72 分,在合格的标准线上。

应用商店安装lighthouse

image-20231014163814281

点击生成报告

image-20231014163922168

image-20231014163958396

此外,根据性能、可访问性、最佳实践、SEO 和 PWA 五个维度的评分,我们可以看出,在前面四个维度中,极客时间都是及格的,第五个指标置灰,说明极客时间首页还没有支持PWA。

我们先看下性能指标,下图中详细地给出了 FCP、TTI、LCP 等常见性能指标,并且还很贴心地给出了建议,包括字体、图标宽高、DOM 操作等等,其实我们按照这些建议依次修改,就可以实现对网页的性能优化了。并且网页优化后,性能分数的提升还可以很好地量化优化的结果。

image-20231014164123987

文章推荐阅读

据说 99% 的人不知道 vue-devtools 还能直接打开对应组件文件?本文原理揭秘

JSX:如何利用JSX应对更灵活的开发场景?

今天,我们来聊一个相对独立的话题,就是 Vue 中的 JSX。你肯定会有这样的疑惑,JSX 不是 React 的知识点吗?怎么 Vue 里也有?

实际上,Vue 中不仅有 JSX,而且 Vue 还借助 JSX 发挥了 Javascript 动态化的优势。此外,Vue 中的 JSX 在组件库、路由库这类开发场景中,也发挥着重要的作用。对你来说,学习JSX,可以让你实现更灵活的开发需求,这一讲我们重点关注一下 Vue 中的 JSX

h 函数

在聊 JSX 之前,我需要先给你简单介绍一下 h 函数,因为理解了 h 函数之后,你才能更好地理解 JSX 是什么。下面,我会通过一个小圣要实现的需求作为引入,来给你讲一下 h 函数。

在 Vue 3 的项目开发中,template 是 Vue 3 默认的写法。虽然 template 长得很像 HTML,但 Vue 其实会把 template 解析为 render 函数,之后,组件运行的时候通过 render 函数去返回虚拟 DOM,你可以在 Vue Devtools 中看到组件编译之后的结果。

点击<>查看编译后的结果

image-20231014170434649

所以除了 template 之外,在某些场景下,我们可以直接写render 函数来实现组件。

先举个小例子,我给小圣模拟了这样一个需求:我们需要通过一个值的范围在数字 1 到 6 之间的变量,去渲染标题组件 h1~h6,并根据传递的 props 去渲染标签名。对于这个需求,小圣有点拿不准了,不知道怎么实现会更合适,于是小圣按照之前学习的 template 语法,写了很多的 v-if

1
2
3
4
5
6
<h1 v-if="num==1">{{title}}</h1>
<h2 v-if="num==2">{{title}}</h2>
<h3 v-if="num==3">{{title}}</h3>
<h4 v-if="num==4">{{title}}</h4>
<h5 v-if="num==5">{{title}}</h5>
<h6 v-if="num==6">{{title}}</h6>

从上面的代码中,你应该能感觉到,小圣这样的实现看起来太冗余。所以这里我教你一个新的实现方法,那就是 Vue 3 中的h 函数

由于render函数可以直接返回虚拟DOM,因而我们就不再需要template。我们在src/components目录下新建一个文件Heading.jsx ,要注意的是,这里Heading的结尾从.vue变成了jsx。

在下面的代码中, 我们使用 defineComponent 定义一个组件,组件内部配置了 propssetup。这里的 setup 函数返回值是一个函数,就是我们所说的 render 函数。render 函数返回 h 函数的执行结果,h 函数的第一个参数就是标签名,我们可以很方便地使用字符串拼接的方式,实现和上面代码一样的需求。像这种连标签名都需要动态处理的场景,就需要通过手写h 函数来实现。

src/components/jsx/Heading.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { defineComponent, h } from 'vue'

export default defineComponent({
props: {
level: {
type: Number,
required: true
}
},
setup (props, { slots }) {
console.log(slots)
return () => h(
'h' + props.level, // 标签名
{}, // prop 或 attribute
slots.default() // 子节点
)
}
})

然后,在文件 src/About.vue 中,我们使用下面代码中的 import 语法来引入 Heading,之后使用 level 传递标签的级别。这样,之后在浏览器里访问

src/views/about.vue

1
2
3
4
5
6
7
8
9
<template>
<h1>关于</h1>
<hr>
<Heading :level="1">h1标签</Heading>
</template>
<script setup>
import Heading from '../components/jsx/Heading.jsx'
</script>
<style lang="scss" scoped></style>

image-20231014203347052

手写的 h 函数,可以处理动态性更高的场景。但是如果是复杂的场景,h 函数写起来就显得非常繁琐,需要自己把所有的属性都转变成对象。并且组件嵌套的时候,对象也会变得非常复杂。不过,因为 h 函数也是返回虚拟 DOM 的,所以有没有更方便的方式去写 h 函数呢?答案是肯定的,这个方式就是 JSX

JSX 是什么

我们先来了解一下 JSX 是什么,JSX 来源自 React 框架,下面这段代码就是 JSX 的语法,我们给变量 title 赋值了一个 h1 标签。

1
const element = <h1 id="app">Hello, Geekbang!</h1>

这种在 JavaScript 里面写 HTML 的语法,就叫做 JSX,算是对 JavaScript 语法的一个扩展。上面的代码直接在 JavaScript 环境中运行时,会报错。JSX 的本质就是下面代码的语法糖,**h 函数内部也是调用 createVnode 来返回虚拟 DOM**。在之后的课程中,对于那些创建虚拟 DOM 的函数,我们统一称为 h 函数

1
const element = createVnode('h1',{id:"app"}, 'hello Geekbakg')

在从 JSX 到 createVNode 函数的转化过程中,我们需要安装一个 JSX 插件。在项目的根目录下,打开命令行,执行下面的代码来安装插件:

1
npm install @vitejs/plugin-vue-jsx -D

插件安装完成后,我们进入根目录下,打开 vite.config.js 文件去修改 vite 配置。在vite.config.js 文件中,我们加入下面的代码。这样,在加载 JSX 插件后 ,现在的页面中就可以支持 JSX 插件了。

1
2
3
4
5
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx';
export default defineConfig({
plugins: [vue(),vueJsx()]
})

由于xue3项目是由vueCli创建的所以按下面这个步骤来:

1
npm i -D @vue/babel-plugin-jsx

在babel.config.js 内配置插件

1
2
3
4
5
6
7
8
module.exports = {
// 这是原来的预设,cli 搭建项目就有的
presets: [
'@vue/cli-plugin-babel/preset'
],
// 需要配置的插件
plugins: ['@vue/babel-plugin-jsx']
}

然后,我们进入 src/componentns/jsx/Heading.jsx 中,把 setup 函数的返回函数改成下面代码中所示的内容,这里我们使用变量 tag 计算出标签类型,直接使用渲染,使用一个大括号把默认插槽包起来就可以了

1
2
3
4
setup(props, { slots }) {
const tag = 'h'+props.level
return () => <tag>{slots.default()}</tag>
}

我们再来聊一下 JSX 的语法在实战中的要点,详细的要点其实在GitHub 文档中也有全面的介绍,我在这里主要针对之前的清单应用讲解一下。

我们进入到 src/components/jsx 下面新建文件 Todo.jsx,在下面的代码中,我们使用 JSX 实现了一个简单版本的清单应用。我们首先使用 defineComponent 的方式来定义组件,在setup 返回的 JSX 中,使用 vModel 取代 v-model,并且使用单个大括号包裹的形式传入变量 title.value ,然后使用 onClick 取代 @click。循环渲染清单的时候,使用.map 映射取代v-for,使用三元表达式取代 v-if。

src/components/jsx/Todo.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { defineComponent, ref } from 'vue'

export default defineComponent({
setup (props) {
const title = ref('')
const todos = ref([
{ title: '学习Vue3', done: true },
{ title: '睡觉', done: false }
])

function addTodo () {
todos.value.push({
title: title.value
})
}

return () => <div>
<input type="text" vModel={title.value}/>
<button onClick={addTodo}>添加</button>
<ul>
{
todos.value.length
? todos.value.map((todo) => {
return <li>{todo.title}</li>
})
: <li>no data</li>
}
</ul>
</div>
}
})

src/views/home.vue中使用

1
2
3
4
5
6
7
8
9
<template>
<h1>首页</h1>
<hr>
<Todo></Todo>
</template>
<script setup>
import Todo from '../components/jsx/Todo.jsx'
</script>
<style lang="scss" scoped></style>

image-20231014210623860

通过这个例子,你应该能够认识到,使用 JSX 的本质,还是在写 JavaScript。在 Element3组件库设计中,我们也有很多组件需要用到 JSX,比如时间轴 Timeline、分页 Pagination、表格 Table 等等。

就像在 TimeLine 组件的源码中,有一个 reverse 的属性来决定是否倒序渲染,我们在下面写出了类似的代码。代码中的 Timeline 是一个数组,数组中的两个元素都是 JSX,我们可以通过数组的 reverse 方法直接进行数组反转,实现逆序渲染。类似这种动态性要求很高的场景,template 是较难实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { defineComponent } from 'vue'
export default defineComponent({
props: {
reverse: {
type: Boolean,
default: true
}
},
setup (props) {
console.log('TimeLine执行了')
const timeline = [
<div class="start">8.21 开始自由职业</div>,
<div class="online">10.18 专栏上线</div>
]
if (props.reverse) {
timeline.reverse()
}
return () => <div>{timeline}</div>
}
})

image-20231014212217508

JSX 和 Template

看到这里,你一定会有一个疑惑:我们该怎么选择 JSXtemplate 呢?接下来,我就和你聊聊 template 和 JSX 的区别,这样你在加深对 template 的理解的同时,也能让你逐步了解到JSX 的重要性。

先举个例子,我们在极客时间官网购买课程的时候,就如下图所示的样子,页面顶部有搜索框、页面左侧有课程的一些类别。我们按照极客时间对课程的分类,比如前端、后端、AI、运维等分类,可以很轻松地筛选出我们所需类别的课程。

试想一下,如果没有这些条件限制,而是直接显示课程列表,那你就需要自己在几百门的课程列表里搜索到自己需要的内容。也就是说,接受了固定分类的限制,就能降低选择课程的成本。这就告诉我们一个朴实无华的道理:我们接受一些操作上的限制,但同时也会获得一些系统优化的收益。

image-20231014212645759

在 Vue 的世界中也是如此,template 的语法是固定的,只有 v-ifv-for 等等语法。Vue的官网中也列举得很详细,也就是说,template 遇见条件渲染就是要固定的选择用 v-if。这就像极客时间官网上课程的分类是有限的,我们需要在某一个类别中选择课程一样。我们按照这种固定格式的语法书写,这样 Vue 在编译层面就可以很方便地去做静态标记的优化。

而 JSX 只是 h 函数的一个语法糖,本质就是 JavaScript,想实现条件渲染可以用 if else,也

可以用三元表达式,还可以用任意合法的 JavaScript 语法。也就是说,**JSX 可以支持更动态的需求。而 template 则因为语法限制原因,不能够像 JSX 那样可以支持更动态的需求**。这是 JSX 相比于 template 的一个优势。

JSX 相比于 template 还有一个优势,是可以在一个文件内返回多个组件,我们可以像下面的代码一样,在一个文件内返回 Button、Input、Timeline 等多个组件。

1
2
3
4
5
export const Button = (props,{slots})=><button {...props}>slots.default()</button
export const Input = (props)=><input {...props} />
export const Timeline = (props)=>{
...
}

在上面,我们谈到了 JSX 相比于 template 的优势,那么 template 有什么优势呢?你可以先看下面的截图,这是使用 Vue 官方的 template 解析的一个 demo

image-20231014213443776

在 demo 页面左侧的 template 代码中,你可以看到代码中的三个标签。页面右侧是template 代码编译的结果,我们可以看到,相比于我们自己去写 h 函数,在 template 解析的结果中,有以下几个性能优化的方面。

image-20231014214000092

在demo页面左侧的template代码中,你可以看到代码中的三个标签。页面右侧是template代码编译的结果,我们可以看到,相比于我们自己去写h函数,在template解析的结果中,有以下几个性能优化的方面。

首先,静态的标签和属性会放在_hoisted变量中,并且放在render函数之外。这样,重复执行render的时候,代码里的h1这个纯静态的标签,就不需要进行额外地计算,并且静态标签在虚拟DOM计算的时候,会直接越过Diff过程。

然后是@click函数增加了一个cache缓存层,这样实现出来的效果也是和静态提升类似,尽可能高效地利用缓存。最后是,由于在下面代码中的属性里,那些带冒号的属性是动态属性,因而存在使用一个数字去标记标签的动态情况。

比如在p标签上,使用8这个数字标记当前标签时,只有props是动态的。而在虚拟DOM计算Diff的过程中,可以忽略掉class和文本的计算,这也是Vue 3的虚拟DOM能够比Vue 2快的一个重要原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("h1", null, "技术摸鱼", -1 /* HOISTED */)
const _hoisted_3 = ["id"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("div", {
onClick: _cache[0] || (_cache[0] = ()=>_ctx.console.log(_ctx.xx)),
name: "hello"
}, _toDisplayString(_ctx.name), 1 /* TEXT */),
_hoisted_2,
_createElementVNode("p", {
id: _ctx.name,
class: "app"
}, "极客时间", 8 /* PROPS */, _hoisted_3)
]))
}

// Check the console for the AST

在template和JSX这两者的选择问题上,只是选择框架时角度不同而已。 我们实现业务需求的时候,也是优先使用template,动态性要求较高的组件使用JSX实现,尽可能地利用Vue本身的性能优化。

总结

在课程最后的生态源码篇中,我们还会聊到框架的设计思路,那时你就会发现除了template和JSX之外,一个框架的诞生还需要很多维度的考量,比如是重编译还是重运行时等等,学到那里的时候,你会对Vue有一个更加深刻的理解。

好,今天这一讲的主要内容就讲完了,我们来简单总结一下今天学到了什么吧。今天我主要带你学习了Vue 3中的JSX。首先我们学习了h函数,简单来说,h函数内部执行createVNode,并返回虚拟DOM,而JSX最终也是解析为createVnode执行。而在一些动态性要求很高的场景下,很难用template优雅地实现,所以我们需要JSX实现。

因为render函数内部都是JavaScript代码,所以render函数相比于template会更加灵活,但是h函数手写起来非常的痛苦,有太多的配置,所以我们就需要JSX去方便快捷地书写render函数。

JSX的语法来源于React,在Vue 3中会直接解析成h函数执行,所以JSX就拥有了JS全部的动态性。

最后,我们对比了JSX和template的优缺点,template由于语法固定,可以在编译层面做的优化较多,比如静态标记就真正做到了按需更新;而JSX由于动态性太强,只能在有限的场景下做优化,虽然性能不如template好,但在某些动态性要求较高的场景下,JSX成了标配,这也是诸多组件库会使用JSX的主要原因。

渲染函数 & JSX

https://cn.vuejs.org/guide/extras/render-function.html

TypeScript:Vue 3中如何使用TypeScript?

什么是 TypeScript

TypeScript 是微软开发的 JavaScript 的超集,这里说的超集,意思就是 TypeScript 在语法上完全包含 JavaScript。TypeScript 的主要作用是给 JavaScript 赋予强类型的语言环境。现在大部分的开源项目都是用 TypeScript 构建的,并且 Vue 3 本身 TS 的覆盖率也超过了95%。

image-20231014214827285

image-20231014214844946

TypeScript 能够智能地去报错和提示,也是 Vue 3 的代码阅读起来比较顺畅的主要原因。点击这里的 Vue 3 源码链接,如下图所示,在这个源码文件内部的 interface App 中,定义好了 Vue 实例所需要的所有方法后,我们可以看到熟悉的 use、component、mount 等方法。并且每个方法的参数类型和返回值都已经定义好了,阅读和调试代码的难度也降低了很多

image-20231014215053508

接下来,我来跟你聊一下 TypeScript 中的一些进阶用法。很多时候,你看不懂开源库TypeScript 的原因,也是出在对这些进阶用法的生疏上。

首先要讲到的进阶用法是泛型,泛型就是指有些函数的参数,你在定义的时候是不确定的类型,而返回值类型需要根据参数来确定。在下面的代码中,我们想规定 test 函数的返回类型和参数传递类型保持一致,这个时候就没有办法用 number 或者 string 预先定义好参数 args的类型,为了解决这一问题,泛型机制就派上了用场。

我们在函数名的后面用尖括号包裹一个类型占位符,常见的写法是,这里为了帮助你理解,我用 < 某种类型 > 替代这种写法。调用方式可以直接使用 test(1), 也可以使用 test <number>

(1) 。泛型让我们拥有了根据输入的类型去实现函数的能力,这里你也能感受到 TypeScript 类型可以进行动态设置。

1
2
3
function test<某种类型>(args:某种类型):某种类型{
return args
}

接下来,我再给你介绍一下 TypeScript 中泛型的使用方法。在下面的代码中,我们实现一个函数 getProperty,它能够动态地返回对象的属性。函数的逻辑是很好实现的,那怎么使用TypeScript 限制 getProperty 的类型呢?

1
getProperty(vueCourse, '课程名字') // 返回 ['玩转Vue3全家桶']

因为 getProperty 的返回值是由输入类型决定的,所以一定会用到泛型。但是返回值是vueCourse 的一个 value 值,那如何定义返回值的类型呢?首先我们要学习的是 keyof 关键字,下面代码中我们使用 type 课程属性列表 = keyof 极客时间课程 ,就可以根据获取到的极客时间课程这个对象的属性列表,使用 extends 来限制属性只能从极客时间的课程里获取。

1
2
3
4
5
6
function getProperty<某种类型, 某种属性 extends keyof 某种类型>(o: 某种类型, name: 某种
return o[name]
}
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
return o[name]
}

Vue 3 中的 TypeScript

由于 TypeScript 中的每个变量都需要把类型定义好,因而对代码书写的要求也会提高。Vue2 中全部属性都挂载在 this 之上,而 this 可以说是一个黑盒子,我们完全没办法预先知道this 上会有什么数据,这也是为什么 Vue 2 对 TypeScript 的支持一直不太好的原因

Vue 3 全面拥抱 Composition API 之后,没有了 this 这个黑盒,对 TypeScript 的支持也比Vue2 要好很多。在下面的代码中,首先我们需要在 script 标签上加一个配置 lang=“ts”,来标记当前组件使用了 TypeScript,然后代码内部使用 defineComponent 定义组件即可。

1
2
3
4
5
6
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
// 已启用类型推断
})
</script>

<script setup> 的内部,需要调整写法的内容不多。下面的代码使用 Composition API的过程中,可以针对 ref 或者 reactive 进行类型推导。如果 ref 包裹的是数字,那么在对count.value 进行 split 函数操作的时候,TypeScript 就可以预先判断 count.value 是一个数字,并且进行报错提示。

1
2
const count = ref(1)
count.value.split('') // => Property 'split' does not exist on type 'number'

我们也可以显式地去规定 refreactivecomputed 输入的属性,下面代码中我们分别演示了 ref、reactive 和 computed 限制类型的写法,每个函数都可以使用默认的参数推导,也可以显式地通过泛型去限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import { computed, reactive, ref } from '@vue/runtime-core';
interface 极客时间课程 {
name:string,
price:number
}
const msg = ref('') // 根据输入参数推导字符串类型
const msg1 = ref<string>('') // 可以通过范型显示约束
const obj = reactive({})
const course = reactive<极客时间课程>({name: '玩转Vue3全家桶', price: 129})
const msg2 = computed(() => '') // 默认参数推导
const course2 = computed<极客时间课程>(() => {
return {name: '玩转Vue3全家桶', price: 129}
})
</script>

在 Vue 中,除了组件内部数据的类型限制,还需要对传递的属性 Props 声明类型。而在<script setup> 语法中,只需要在 definePropsdefineEmits 声明参数类型就可以了。下面的代码中,我们声明了 title 属性必须是 string,而 value 的可选属性是 number 类型。

1
2
3
4
5
6
7
const props = defineProps<{
title: string
value?: number
}>()
const emit = defineEmits<{
(e: 'update', value: number): void
}>()

完成了上面的操作后,我们再来了解一下和 vue-router 的优化相关的工作。vue-router 提供了 RouterRouteRecordRaw 这两个路由的类型。在下面的代码中,用户路由的配置使用RouteRecordRaw 来定义,返回的 router 实例使用类型 Router 来定义,这两个类型都是vue-router 内置的。通过查看这两个类型的定义,我们也可以很方便地学习和了解 vue-router 路由的写法。

1
2
3
4
5
6
7
8
9
import { createRouter, createWebHashHistory, Router, RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = [
...
]
const router: Router = createRouter({
history: createWebHashHistory(),
routes
})
export default router

我们打开项目目录下的 node_modules/vue-router/dist/vue-router.d.ts 文件,下面的代码中你可以看到 vue-router 是一个组合类型,在这个类型的限制下,你在注册路由的时候,如果参数有漏写或者格式不对的情况,那就会在调试窗口里直接看到报错信息。如果没有TypeScript 的话,我们需要先启动 dev,之后在浏览器的调试页面里看到错误页面,回来之后才能定位问题。

1
2
3
4
5
6
7
8
9
10
11
12
export declare type RouteRecordRaw = RouteRecordSingleView | RouteRecordMultipleV
declare interface RouteRecordSingleView extends _RouteRecordBase {
/**
* Component to display when the URL matches this route.
*/
component: RawRouteComponent;
components?: never;
/**
* Allow passing down params as props to the component rendered by `router-vi
*/
props?: _RouteRecordProps;
}

TypeScript 和 JavaScript 的平衡

TypeScript 是 JavaScript 的一个超集,这两者并不是完全对立的关系。所以,学习 TypeScript 和学习 JavaScript 不是二选一的关系,你需要做的,是打好坚实的 JavaScript 的基础,在维护复杂项目和基础库的时候选择 TypeScript

实战痛点1:复杂Vue项目的规范和基础库封装

组件库

在项目开发中,我们首先需要一个组件库帮助我们快速搭建项目,组件库提供了各式各样的封装完备的组件。现在社区可选择的组件库有 element-plus、antd-vue,Naive-UI、Element3 等,我们选择 Element3 来搭建项目,首先我们来到项目目录下,执行下面的代码安装 Element3。

1
npm install element3 --save

然后,我们在 src/main.js 中使用一下 Element3。看下面的代码,我们在其中引入了Element3 和主体对应的 CSS,并使用 use(Element3) 加载组件库。

工具库

1
npm i axios --save

首先,在项目在登录成功之后,后端会返回一个 token,用来存储用户的加密信息,我们把token 放在每一次的 http 请求的 header 中,后端在收到请求之后,会对请求 header 中的token 进行认证,然后解密出用户的信息,过期时间,并且查询用户的权限后,校验完毕才会返回对应的数据。

所以我们要对所有的 http 请求进行统一拦截,确保在请求发出之前,从本地存储中获取token,这样就不需要在每个发起请求的组件内去读取本地存储。后端数据如果出错的话,接口还要进行统一拦截,比如接口返回的错误是登录状态过期,那么就需要提示用户跳转到登录页面重新登录。

这样,我们就把网络接口中需要统一处理的内容都放在了拦截器中统一处理了。在下面的代码中,所有接口在请求发出之前,都会使用 getToken 获取 token,然后放在 header 中。在接口返回报错信息的时候,会在调试窗口统一打印报错信息。在项目的组件中,我们只需要直接使用封装好的 axios 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import axios from 'axios'
import { useMsgbox, Message } from 'element3'
import store from '@/store'
import { getToken } from '@/utils/auth'

const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
timeout: 5000, // request timeout
})

service.interceptors.request.use(
config => {
if (store.getters.token) {
config.headers['X-Token'] = getToken()
}
return config
},
error => {
console.log(error) // for debug
return Promise.reject(error)
},
)

service.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 20000) {
console.log('接口信息报错',res.message)
return Promise.reject(new Error(res.message || 'Error'))
} else {
return res
}
},
error => {
console.log('接口信息报错' + error)
return Promise.reject(error)
},
)

export default service

代码规范和提交规范

由于个人习惯的不同,每个人写代码的风格也略有不同。比如在写 JavaScript 代码中,有些人习惯在每行代码之后都写分号,有些人习惯不写分号。但是团队产出的项目就需要有一致的风格,这样代码在团队之间阅读起来时,也会更加流畅。ESLint 就是专门用来做规范团队代码的一个库。

首先我们安装 ESLint,进入到项目文件夹,使用下面的命令,我们就可以在全局或者本地安装 ESLint 了。

1
npm i eslint -D

ESLint 安装成功后,在项目根目录下执行 npx eslint –init,然后按照终端操作的提示完成一系列设置来创建配置文件。你可以按照下图所示的选择来始化 ESLint。

image-20231015201939322

前面我们已经统一了代码规范,并且在提交代码时进行强约束来保证仓库代码的质量。多人协作的项目中,在提交代码这个环节,也存在一种情况:不能保证每个人对提交信息的准确描述,因此会出现提交信息紊乱、风格不一致的情况。

对于这种情况,一种比较好的解决方案是,在执行 git commit 命令的时候,同时执行ESLint。我们使用 husky 管理 git 的钩子函数,在每次代码提交至 git 之前去执行 ESLint,只有 ESLint 的校验通过,commit 才能执行成功。后面的进阶开发篇中,单元测试也会放在git 的钩子函数中执行,确保提交到 git 中的代码都是测试通过的。

项目代码符合规范后,我们就可以把代码提交到代码仓库中,git 允许我们在每次提交时,附带一个提交信息作为说明。我们在项目根目录执行下面的命令,提交了一个附带信息是commit 的代码。

1
2
git add .
git commit -m 'init commit'

然后我们需要再定义一下 git 的提交规范,描述信息精准的 git 提交日志,会让我们在后期维护和 处理 Bug 时有据可查。在项目开发周期内,我们还可以根据规范的提交信息,快速生成开发日志,从而方便我们追踪项目和把控进度。 如下图所示,我们可以看到 Vue 3 的代码提交日志。

image-20231015202240370

看了 Vue 3 代码日志提交的格式,初次接触的你可能会觉得复杂。其实不然,Vue 3 在代码日志中,使用的是【类别: 信息】的格式,我们可以通过类别清晰地知道这次提交是代码修复,还是功能开发 feat。冒号后面的信息是用来解释此次提交的内容,在修复 bug 时,还会带上 issue 中的编号。在现在的项目开发中,我们也会强制要求使用和 Vue 3 一样的 git 日志格式。

总结

当然,复杂的 Vue 项目更需要良好的规范,毕竟没有规矩不成方圆,为此,我们进一步规范了代码格式,使用 ESLint 统一 JavaScript 的代码风格,husky 管理 git 的钩子函数,并且规定了 git 的提交日志格式,确保代码的可维护性。

实战痛点2:项目开发中的权限系统

下面,我们先从登录权限谈起,因为登录权限对于一个项目来说是必备的功能模块。完成了登录选项的设置后,下一步需要做的是管理项目中的页面权限,而角色权限在这一过程中则可以帮助我们精细化地去控制页面权限。

登录权限

继续上一讲我们搭建起来的项目,你可以看到现在所有的页面都可以直接访问了,通常来说管理系统的内部页面都需要登录之后才可以访问,比如个人中心、订单页面等等。首先,我们来设计一个这样的权限限制功能,它能保证某些页面在登录之后才能访问。

为了实现这个功能,我们首先需要模拟登录的接口和页面。我们先新增路由页面,进入到项目目录下,在 router.js 中新增路由配置。下面的代码中,routes 数组新增 /login 路由访问。

1
2
3
4
5
6
7
8
9
import Login from '../components/Login.vue'
const routes = [
...
{
path: '/login',
component: Login,
hidden: true,
}
]

然后,我们进入到 src/components/Login.vue 组件中,组件的代码如下所示。在代码中,我们能看到,用户在输入用户名和密码之后,把用户名和密码传递给后端,然后就可以实现登录认证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
handleLogin() {
formRef.value.validate(async valid => {
if (valid) {
loading.value = true
const {code, message} = await useStore.login(loginForm)
loading.value = false
if(code===0){
router.replace( toPath || '/')
}else{
message({
message: '登录失败',
type: 'error'
})
}
} else {
console.log('error submit!!')
return false
}
})
}

由于我们的项目是一个前端项目,所以我们需要在Vite内部做数据结构的模拟。我们在src目录下面新建mock目录,用来放置假数据的结构。我们写死一个用户名dasheng,使用用户名dasheng登录成功之后,我们把用户名、过期日期等重要信息进行加密,生成一个token返回给前端。

这个token就算是一个钥匙,对于那些需要权限才能读取到的页面数据,前端需要带上这个钥匙才能读取到数据,否则访问那些页面的时候就会显示没有权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
url: '/geek-admin/user/login',
type: 'post',
response: config => {
const { username } = config.body
const token = tokens[username]

// mock error
if (user!=='dasheng') {
return {
code: 60204,
message: 'Account and password are incorrect.'
}
}

return {
code: 20000,
data: token
}
}
}

我们回到前端页面,登录成功后,首先需要做的事情,就是把这个token存储在本地存储里面,留着后续发送数据。这一步的实现比较简单,直接把token存储到localStorage中就可以了。我们拿到这个token后,为了进行接口权限认证,要把token放在HTTP请求的header内部。

我们看下面的代码,在axios的请求发出之前,我们在配置中使用getToken从localStorage中读取token,放在请求的header里发送。由于我们使用了请求拦截的方式,所以所有的后端数据发送的时候,都会带上这个token,完成受限数据的请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
service.interceptors.request.use(
config => {
const token = getToken()
// do something before request is sent
if (token) {
config.headers.gtoken = token
}
return config
},
error => {
// do something with request error
console.log(error) // for debug
return Promise.reject(error)
}
)

通过上面的操作,我们完成了前端网络请求的token限制。但是还有一个需求没有实现,就是用户没有登录某个受限页面的时候,或者说没有token的时候,如果直接访问受限页面,比如个人中心,那么就需要让vue-router拦截这次页面的跳转。

与vue-router拦截页面的跳转,并显示无权限的报错信息相比,直接跳转登录页是现在更流行的交互方式。但这种方式需要在vue-router上加一层限制,这层限制就是说,在路由跳转的时候做权限认证,我们把vue-router的这个功能称作导航守卫。

关于导航守卫的API,你可以从Vue Router的官网看到很详细的 介绍。这里我们实际应用下,下面的代码中,我们在router.beforeEach函数中设置一个全局的守卫。

每次页面跳转的时候,vue-router会自动执行守卫函数,如果函数返回false的话,页面跳转就会失败。不过,我们也可以手动地跳转到其他页面。现在我们设置的路由很简单,如果token不存在的话直接跳转登录页面,否则返回true,页面正常跳转。

1
2
3
4
5
6
7
8
router.beforeEach(async (to, from,next) => {
// canUserAccess() 返回 `true` 或 `false`
let token = getToken()
if(!token){
next('/login')
}
return true
})

当然,在路由守卫的函数内,只要是页面跳转时想实现的操作,都可以放在这个函数内部实现,比如一些常见的交互效果,就像给项目的主页面顶部设置一个页面跳转的进度条、设置和修改页面标题等等。和我们现在对全部页面进行一次性的粗略拦截相比,后面还会在路由守卫那里进行更精确的路由拦截。

到这里你可能会有疑问:之前开发项目的时候,和登录注册相关的配置,不需要自己管理token,都是后端直接设置cookie。那么这里用到的token和之前项目开发时,交给后端设置的cookie到底有什么区别呢?

这是个非常好的问题,我们在第一讲聊前端发展史的时候,提到了jQuery时代的前端项目是作为后端项目的模块部署的。

那时候前后端不分家,整个应用的入口是后端控制模板的渲染。在模板渲染前,后端会直接判断路由的权限来决定是否跳转。登录的时候,后端只需要设置setCookie这个header,之后浏览器会自动把cookie写入到我们的浏览器存起来,然后当前域名在发送请求的时候都会自动带上这个cookie

在Chrome浏览器中,我们先进入极客时间的官网,然后打开调试窗口页面,再选择Network页面。之后,我们在页面中点击Fetch/XHR,然后在Name这一栏中,我们可以任选一个接口点开。这样,我们就可以看到这个接口请求的所有细节了。

在下图中,我们点击list请求,也就是极客时间的推荐接口时,HTTP的Request Headers里就有Cookie这个数据,这是浏览器自动管理和发送的,也算是权限认证的最佳方案之一。

image-20231015205358265

但是,在现在这种前后端分离的场景下,通常前后端项目都会部署在不同的机器和服务器之上,Cookie 在跨域上有诸多的限制。所以在这种场景下,我们更愿意手动地去管理权限,于是就诞生了现在流行的基于 token 的权限解决方案,你也可以把 token 理解为我们手动管理的 cookie

角色权限

实现登录权限验证之后,我们就可以针对项目中的页面进行登录的保护。但现在,我们只能通过登录状态去判断页面的显示与否,而这远远达不到我们实际开发的需求。

比如,在我们的管理系统开发中,订单页面是所有人都可以看到的,但是像账单的查询页面,以及其他一些权限更高的页面,我们需要管理员权限才能看到。这时候,我们就需要对系统内部的权限进行分级,每个级别都对应着可以访问的不同页面。

我们通常使用的权限解决方案就是RBAC权限管理机制。简单来说,就是在下图所示的这个模型里,除了用户和页面之外, 我们需要一个新的概念,就是角色 每个用户有不同的角色,每个角色对应不同的页面权限,这个数据结构的关系设计主要是由后端来实现。

根据下图这个结构,在用户登录完成之后我们会获取页面的权限数据,也就是说后端会返回给我们当前页面的动态权限部分。

image-20231015205819816

这样有一部分页面是写在代码的src/router/index.js中,另外一部分页面我们通过axios获取数据后,通过调用vue-router的addRoute方法动态添加进项目整体的路由配置中。

关于这部分动态路由的内容,官网的文档中有详细的 API介绍。在下面的代码中,我们在Vuex中注册addRoute这个action,通过后端返回的权限页面数据,调用router.addRoute新增路由。

1
2
3
4
5
6
7
8
9
addRoutes({ commit }, accessRoutes) {
// 添加动态路由,同时保存移除函数,将来如果需要重置路由可以用到它们
const removeRoutes = []
accessRoutes.forEach(route => {
const removeRoute = router.addRoute(route)
removeRoutes.push(removeRoute)
})
commit('SET_REMOVE_ROUTES', removeRoutes)
},

与新增路由对应,在页面重新设置权限的时候,我们需要用router.removeRoute来删除注册的路由,这也是上面的代码中我们还有一个remoteRoutes来管理动态路由的原因。

然后,我们需要把动态路由的状态存储在本地存储里,否则刷新页面之后,动态的路由部分就会被清空,页面就会显示404报错。我们需要在localStorage中把静态路由和动态路由分开对待,在页面刷新的时候,通过src/router/index.js入口文件中的routes配置,从localStorage中获取完整的路由信息,并且新增到vue-router中,才能加载完整的路由。

权限系统中还有一个常见的问题,就是登录是有时间限制的。在常见的登录状态下,token有效期只能保持24小时或者72小时,过了这个期限,token会自动失效。即使我们依然存在token,刷新页面后也会跳转到登录页。所以,对token有效期的判断这个需求该如何实现呢?

首先,token的过期时间认证是由后端来实现和完成的。如果登录状态过期,那么会有一个单独的报错信息,我们需要在接口拦截函数中,统一对接口的响应结果进行拦截。如果报错信息显示的是登录过期,我们需要清理所有的token和页面权限数据,并且跳转到登录页面。

下面的代码中,50008和50012都代表着状态过期,我们可以直接使用Element3的messageBox组件显示一个错误信息,提示用户需要重新登录,然后直接跳转到登录页面就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;
if (res.code === 50008 || res.code === 50012) {
// to re-login
Msgbox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', {
confirmButtonText: 'Re-Login',
cancelButtonText: 'Cancel',
type: 'warning',
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
}
return Promise.reject(new Error(res.message || 'Error'))

实战痛点3:Vue 3中如何集成第三方框架

独立的第三方库

首先我们要介绍的第三方框架是 axios,这是一个完全独立于 Vue 的框架,我们可以使用axios 发送和获取网络接口数据。在 Vue、React 框架下,axios 可以用来获取后端数据。甚至在 Node.js 环境下,也可以用 axios 去作为网络接口工具去实现爬虫。

axios 这种相对独立的工具对于我们项目来说,引入的难度非常低。通常来说,使用这种独立的框架需要以下两步。

以页面进度条工具 NProgress 为例,第一步是,我们先进入到项目根目录下,使用下面的命令去安装 NProgress。

1
npm install nprogress -D

第二步,就是在需要使用 NProgress 的地方进行 import 的相关操作,比如在页面跳转的时候,我们就需要使用 NProgress 作为进度条。导入 NProgress 库之后,我们就不需要使用Vue3 的插件机制进行注册,只需要通过 router.beforeEach 来显示进度条,通过 afterEach来结束进度条就可以了。

1
2
3
4
5
6
7
8
9
10
import NProgress from 'nprogress' // progress bar
router.beforeEach(async (to, from, next) => {
// start progress bar
NProgress.start()
})

router.afterEach(() => {
// finish progress bar
NProgress.done()
})

在项目中,我们之后还会依赖很多和NProgress类似的库,比如处理Excel的xlsx库,处理剪切板的clipboard库等等。

组件的封装

下面我们以可视化组件为例,来分析复杂组件的封装。之所以选择可视化组件为示例,是因为管理系统中的统计数据、销售额数据等等,都喜欢用饼图或柱状图的方式来展示。

虽然可视化本身和Vue没有太大关系,但我们需要在页面中以组件的形式显示可视化图表。对此,我们的选择是用可视化框架ECharts去封装Vue的组件,来实现可视化组件。

我们再简单介绍一下可视化框架的使用方式,不管你选择用百度的ECharts,还是蚂蚁的G2等框架,在框架的使用方法上,都是类似的。首先,你需要完成图表库的配置,并且填入图表数据,然后把这个数据渲染在一个DOM上就可以了。

下面的代码展示了一个ECharts的入门案例,代码中我们首先使用echarts.init初始化一个DOM标签;然后在options中配置了图表的结构,包括标题、x轴等;并且我们还通过series配置了页面的销量数据;最后使用myChart.setOption的方式渲染图表就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>ECharts</title>
<!-- 引入刚刚下载的 ECharts 文件 -->
<script src="echarts.js"></script>
</head>
<body>
<!-- 为 ECharts 准备一个定义了宽高的 DOM -->
<div id="main" style="width: 600px;height:400px;"></div>
<script type="text/javascript">
// 基于准备好的dom,初始化echarts实例
var myChart = echarts.init(document.getElementById('main'));
// 指定图表的配置项和数据
var option = {
title: {
text: 'ECharts 入门示例'
},
tooltip: {},
legend: {
data: ['销量']
},
xAxis: {
data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子']
},
yAxis: {},
series: [
{
name: '销量',
type: 'bar',
data: [5, 20, 36, 10, 10, 20]
}
]
};
// 使用刚指定的配置项和数据显示图表。
myChart.setOption(option);
</script>
</body>
</html>

看上面的代码,我们先配置好图表需要的数据,然后使用setOption初始化图表,之后在浏览器中打开项目主页面,就可以看到下图所示的这种可视化结果。在你理解了ECharts的使用方法后,下一个要解决的问题是,我们该如何在Vue 3中集成这个框架呢?答案就是我们自己实现与ECharts对应的Vue组件即可。

image-20231015213432586

在Vue 3中集成ECharts的最简单的方式,就是封装一个Chart组件,把上面代码中的option配置以参数的形式传递给Chart组件,然后组件内部进行渲染即可。

我们还是结合代码直观体验一下。在下面的代码中,template设置了一个普通的div作为容器,通过mount和onUnmounted生命周期内部去初始化图表,实现ECharts框架中图表的渲染和清理,然后initChart内部使用echart的API进行渲染,这样就实现了图表的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<div ref="chartRef" class="chart"></div>
</template>

<script setup>
import * as echarts from 'echarts'
import {ref,onMounted,onUnmounted} from 'vue'
// 通过ref获得DOM
let chartRef = ref()
let myChart
onUnmounted(()=>{
myChart.dispose()
myChart = null
})
onMounted(()=>{
myChart = echarts.init(chartRef.value)
const option = {
tooltip: {
trigger: 'item'
},
color: ['#ffd666', '#ffa39e', '#409EFF'],
// 饼图数据配置
series: [
{
name: '前端课程',
type: 'pie',
radius: '70%',
data: [
{value: 43340, name: '重学前端'},
{value: 7003, name: 'Javascript核心原理解析'},
{value: 4314, name: '玩转Vue3全家桶'}
]
}
]
}
myChart.setOption(option)
})
</script>

// 通过ref获得DOM

let chartRef = ref() // 这里的名称 必须与 ref=’chartRef’ 一致

在上面,我们虽然实现了可视化组件的封装,但因为逻辑并不复杂,所以我们的实现还比较简略。

我们当然可以尝试去实现一下更详细的可视化组件封装,但因为ECharts是一个非常复杂的可视化框架,有饼图,地图等不同的图表类型,如果引入ECharts全部代码的话,项目的体积会变得非常臃肿。所以,如果我们能按照不同的图表类型按需引入ECharts,那么除了能够让组件使用起来更方便之外,整体项目的包的大小也会优化很多。

指令的封装

接下来,我们再介绍一下指令增强型组件的封装。

比如我们常见的图片懒加载的需求,这一需求的实现方式就是在img的标签之上,再加上一个v-lazy的属性。而图片懒加载和指令增强型组件的封装的关系在于,v-lazy指令的使用方式是在HTML标签上新增一个属性。Vue内置的指令我们已经很熟悉了,包括v-if、v-model等等。像图片懒加载这种库和DOM绑定,但是又没有单独的组件渲染逻辑的情况,通常在Vue中以指令的形式存在。

在Vue中注册指令和组件略有不同,下面的代码中我们注册实现了v-focus指令,然后在input标签中加上v-focus指令,在指令加载完毕后,鼠标会自动聚焦到输入框上,这个实现在登录注册窗口中很常见。

1
2
3
4
5
6
7
8
// 注册一个全局自定义指令 `v-focus`
app.directive('focus', {
// 当被绑定的元素挂载到 DOM 中时……
mounted(el) {
// 聚焦元素
el.focus()
}
})

指令的生命周期和组件类似,首先我们要让指令能够支持Vue的插件机制,所以我们需要在install函数内注册lazy指令。这种实现Vue插件的方式,在vuex和vue-router两讲中已经带你学习过了,这里的代码里我们使用install方法,在install方法的内部去注册lazy指令,并且实现了mounted、updated、unmounted三个钩子函数。

1
2
3
4
5
6
7
8
9
const lazyPlugin = {
install (app, options) {
app.directive('lazy', {
mounted: ...,
updated: ...,
unmounted: ...
})
}
}

我们通过lazy指令获取到当前图片的标签,并且计算图片的位置信息,判断图片是否在首页显示。如果不在首页的话,图片就加载一个默认的占位符就可以了,并且在页面发生变化的时候,重新进行计算,这样就实现了页面图片的懒加载。

与懒加载类似的,还有我们组件库中常用的v-loading指令,它用来显示组件内部的加载状态,我们在Element3中。也有类似的指令效果,下面的代码中,我们注册了loadingDirective指令,并且注册了mounted、updated、unmounted三个钩子函数,通过v-loading的值来对显示效果进行切换,实现了组件内部的loading状态。

动态切换的Loading组件能够显示一个circle的div标签,通过v-loading指令的注册,在后续表格、表单等组件的提交状态中,加载状态就可以很方便地使用v-loading来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const loadingDirective = {
mounted: function (el, binding, vnode) {
const mask = createComponent(Loading, {
...options,
onAfterLeave() {
el.domVisible = false
const target =
binding.modifiers.fullscreen || binding.modifiers.body
? document.body
: el
removeClass(target, 'el-loading-parent--relative')
removeClass(target, 'el-loading-parent--hidden')
}
})
el.options = options
el.instance = mask.proxy
el.mask = mask.proxy.$el
el.maskStyle = {}

binding.value && toggleLoading(el, binding)
},

updated: function (el, binding) {
el.instance.setText(el.getAttribute('element-loading-text'))
if (binding.oldValue !== binding.value) {
toggleLoading(el, binding)
}
},

unmounted: function () {
el.instance && el.instance.close()
}
}

export default {
install(app) {
// if (Vue.prototype.$isServer) return
app.directive('loading', loadingDirective)
}
}

引入第三方库的注意事项

我们封装第三方库的目的是实现第三方框架和Vue框架的融合,提高开发效率。这里我跟你聊几个和引入第三方库相关的注意事项。

首先,无论是引用第三方库还是你自己封装的底层库,在使用它们之初就要考虑到项目的长期可维护性;其次,尽可能不要因为排期等问题,一股脑地把第三方库堆在一起,虽然这样做可以让项目在早期研发进度上走得很快,但这样会导致项目中后期的维护成本远远大于重写一遍代码xxx的成本。

然后是Vue中的mixin,extends机制能不用就不用,这两个API算是从Vue 2时代继承下来的产物,都是扩展和丰富Vue 2中this关键字,在项目复杂了之后,mixin和extends隐式添加的API无从溯源,一旦多个mixin有了命名冲突,调试起来难度倍增。

项目中的全局属性也尽可能少用,全局变量是最原始的共享数据的方法,Vue 3中我们使用app.config.globalProperties.x注册全局变量,要少用它的主要原因也是项目中的全局变量会极大的提高维护成本。有些监控场景必须要用到,就要把所有注册的全局变量放在一个独立的文件去管理。

最后,我们引入第三方框架和库的时候一定要注意按需使用,比如我们只用到了ECharts中的某几种类型的图,也只用到了Element3中的部分组件。现在引入全部代码的方式会让项目体积越来越大,关于代码体积优化的内容,我们在18讲谈性能优化时也会详细介绍。

总结

从项目开始之初就要考虑到长期维护的成本,不要一股脑地堆砌代码,要学会全面使用Composition API 组织代码、少用全局变量,以及不要引入第三方库全部代码,这些都是很值得你注意的地方

实战痛点4:Vue 3项目中的性能优化

那么在 Vue 项目中,我们应该如何做性能优化呢?下面,我们会先从 Vue 项目在整体上的执行流程谈起,然后详细介绍性能优化的两个重要方面:网络请求优化和代码效率优化。不过,在性能优化之外,用户体验才是性能优化的目的,所以我也会简单谈一下用户体验方面的优化项。最后,我还会通过性能监测报告,为你指引出性能优化的方向。

用户输入 URL 到页面显示的过程

参考文章:https://juejin.cn/post/6844903832435032072?searchId=20231015215422C41E633D3B21C5943442

我们先来聊一个常见的面试题,那就是用户从输入 URL,然后点击回车,到页面完全显示出来,这一过程中到底发生了什么?

通过下图,我们可以从前端的视角看到从输入 URL 到页面显示的大致过程:

image-20231015214912195

简单来说,就是用户在输入 URL 并且敲击回车之后,浏览器会去查询当前域名对应的 IP 地址。对于 IP 地址来说,它就相当于域名后面的服务器在互联网世界的门牌号。然后,浏览器会向服务器发起一个网络请求,服务器会把浏览器请求的 HTML 代码返回给浏览器。

之后,浏览器会解析这段 HTML 代码,并且加载 HTML 代码中需要加载的 CSS 和JavaScript,然后开始执行 JavaScript 代码。进入到项目的代码逻辑中,可以看到 Vue 中通过 vue-router 计算出当前路由匹配的组件,并且把这些组件显示到页面中,这样我们的页面就完全显示出来了。而我们性能优化的主要目的,就是让页面显示过程的时间再缩短一些。

性能优化

从用户输入 URL 到页面显示的过程这个问题,包含着项目页面的执行流程。这个问题之所以重要,是因为我们只有知道了在这个过程中,每一步都发生了什么,之后才能针对每一步去做网络请求的优化,这也是性能优化必备的基础知识。

网络请求优化

对于前端来说,可以优化的点,首先就是在首页的标签中,使用标签去通知浏览器对页面中出现的其他域名去做 DNS 的预解析,比如页面中的图片通常都是放置在独立的 CDN 域名下,这样页面加载首页的时候就能预先解析域名并把结果缓存起来 。

因为极客时间首页没做这个优化,所以我们以淘宝网的首页为例进行分析。你可以在 淘宝的首页源码 中看到下图所示的一列dns-prefetch标签,这样首页再出现img.alicdn.com这个域名请求的时候,浏览器就可以从缓存中直接获取对应的IP地址。

image-20231016162330198

项目在整体流程中,会通过HTTP请求加载很多的CSS、JavaScript,以及图片等静态资源。为了让这些文件在网络加载中更快,我们可以从后面这几方面入手进行优化。

首先,浏览器在获取网络文件时,需要通过HTTP请求,HTTP协议底层的TCP协议每次创建链接的时候,都需要三次握手,而三次握手会造成额外的网络损耗。如果浏览器需要获取的文件较多,那就会因为三次握手次数过多,而带来过多网络损耗的问题

所以,首先我们需要的是让文件尽可能地少,这就诞生出一些常见的优化策略,比如先给文件打包,之后再上线;使用CSS雪碧图来进行图片打包等等。文件打包这条策略在HTTP2全面普及之前还是有效的,但是在HTTP2普及之后,多路复用可以优化三次握手带来的网络损耗。关于HTTP2的更多内容,你可以去搜索相关文章自行学习。

其次, 除了让文件尽可能少,我们还可以想办法让这些文件尽可能地小一些,因为如果能减少文件的体积,那文件的加载速度自然也就会变快。这一环节也诞生出一些性能优化策略,比如CSS和JavaScript代码会在上线之前进行压缩;在图片格式的选择上,对于大部分图片来说,需要使用JPG格式,精细度要求高的图片才使用PNG格式;优先使用WebP等等。也就是说,尽可能在同等像素下,选择体积更小的图片格式。

在性能优化中,懒加载的方式也被广泛使用。图片懒加载的意思是,我们可以动态计算图片的位置,只需要正常加载首屏出现的图片,其他暂时没出现的图片只显示一个占位符,等到页面滚动到对应图片位置的时候,再去加载完整图片。

除了图片,项目中也会做路由懒加载,现在项目打包后,所有路由的代码都在首页一起加载。但是,我们也可以把不常用的路由单独打包,在用户访问到这个路由的时候再去加载代码。下面的代码中,vue-router也提供了懒加载的使用方式,只有用户访问了/course/:id这个页面后,对应页面的代码才会加载执行。

1
2
3
4
{
path: '/course/:id',
component: () => import('../pages/courseInfo'),
}

在文件大小的问题上,Lighthouse已经给了我们比较详细的优化方法,比如控制图片大小、减少冗余代码等等,我们可以在项目打包的时候,使用可视化的插件来查看包大小的分布。

我们来到项目根目录下,通过执行npm install操作来安装插件rollup-plugin-visualizer。使用这个插件后,我们就可以获取到代码文件大小的报告了。之后,进入到vite.config.js这个文件中,新增下列代码,就可以在Vite中加载可视化分析插件。

1
2
3
4
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [vue(),vueJsx(), visualizer()],
})

然后,我们在项目的根目录下执行 npm run build命令后,项目就把项目代码打包在根目录的dist目录下,并且根目录下多了一个文件stat.html。

我们用浏览器打开这个stat文件,就能看到下面的示意图。项目中的ECharts和Element3的体积远远大于项目代码的体积,这时候我们就需要用懒加载和按需加载的方式,去优化项目整体的体积。

image-20231016170723260

由于项目是使用vue-cli创建的

1
npm install webpack-bundle-analyzer --save-dev

vue.config.js

1
2
3
4
5
6
7
8
9
10
11
12
const { defineConfig } = require('@vue/cli-service')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = defineConfig({
transpileDependencies: true,
lintOnSave: false, // 关闭eslint语法检查
configureWebpack: {
plugins: [
// 开启 BundleAnalyzerPlugin
new BundleAnalyzerPlugin()
]
}
})

那么这些文件如何才能高效复用呢? 我们需要做的,就是尽可能高效地利用浏览器的缓存机制,在文件内容没有发生变化的时候,做到一次加载多次使用,项目中如果成功复用一个几百KB的文件,对于性能优化来说是一个巨大的提升。

浏览器的缓存机制有好几个Headers可以实现,ExpiresCache-controllast-modifyetag这些缓存相关的Header可以让浏览器高效地利用文件缓存。我们需要做的是,只有当文件的内容修改了,我们才会重新加载文件。这也是为什么我们的项目执行npm run build命令之后,静态资源都会带上一串Hash值,因为这样确保了只有文件内容发生变化的时候,文件名才会发生变化,其他情况都会复用缓存。

代码效率优化

在浏览器加载网络请求结束后,页面开始执行JavaScript,因为Vue已经对项目做了很多内部的优化,所以在代码层面,我们需要做的优化并不多。很多Vue 2中的性能优化策略,在Vue 3时代已经不需要了,我们需要做的就是 遵循Vue官方的最佳实践,其余的交给Vue自身来优化就可以了。

比如computed内置有缓存机制,比使用watch函数好一些;组件里也优先使用template去激活Vue内置的静态标记,也就是能够对代码执行效率进行优化;v-for循环渲染一定要有key,从而能够在虚拟DOM计算Diff的时候更高效复用标签等等。然后就是JavaScript本身的性能优化,或者说某些实现场景算法的选择了,这里需要具体问题具体分析,在通过性能监测工具发现代码运行的瓶颈后,我们依次对耗时过长的函数进行优化即可。

我们来到src/App.vue文件中,看下面的代码,我们实现了一个斐波那契数列,也就是说,在我们实现的这个数列中,每一个数的值是前面两个数的值之和。我们使用简单的递归算法实现斐波那契数列后,在页面显示计算结果。

1
2
3
4
5
function fib(n){
if(n<=1) return 1
return fib(n-1)+fib(n-2)
}
let count = ref(fib(38))

上面的代码在功能上,虽然实现了斐波那契数列的要求,但是我们能够感觉到页面有些卡顿,所以我们来对页面的性能做一下检测。

我们打开调试窗口中的Performance面板,使用录制功能后,便可得到下面的火焰图。通过这个火焰图,我们可以清晰地定位出这个项目中,整体而言耗时最长的fib函数,并且我们能看到这个函数被递归执行了无数次。到这里,我们不难意识到这段代码有性能问题。不过,定位到问题出现的地方之后,代码性能的优化就变得方向明确了。

image-20231016190322659

下面的代码中,我们使用递推的方式优化了斐波那契数列的计算过程,页面也变得流畅起来,这样优化就算完成了。其实对于斐波那契数列的计算而言,得到最好性能的方式是使用数学公式+矩阵来计算。不过在项目瓶颈到来之前,我们采用下面的算法已经足够了, 这也是性能优化另外一个重要原则,那就是不要过度优化

1
2
3
4
5
6
7
8
9
function fib(n){
let arr = [1,1]
let i = 2
while(i<=n){
arr[i] = arr[i-1]+arr[i-2]
i++
}
return arr[n]
}

用户体验优化

性能优化的主要目的,还是为了能让用户在浏览网页的时候感觉更舒服,所有有些场景我们不能只考虑单纯的性能指标,还要结合用户的交互体验进行设计, 必要的时候,我们可以损失一些性能去换取交互体验的提升。

比如用户加载大量图片的同时,如果本身图片清晰度较高,那直接加载的话,页面会有很多图一直是白框。所以我们也可以预先解析出图片的一个模糊版本,加载图片的时候,先加载这个模糊的图作为占位符,然后再去加载清晰的版本。虽然额外加载了图片文件,但是用户在体验上得到了提升。

类似的场景还有很多,比如用户上传文件的时候,如果文件过大,那么上传可能就会很耗时。而且一旦上传的过程中发生了网络中断,那上传就前功尽弃了。

为了提高用户的体验,我们可以选择断点续传,也就是把文件切分成小块后,挨个上传。这样即使中间上传中断,但下次再上传时,只上传缺失的那些部分就可以了。可以看到,断点上传虽然在性能上,会造成网络请求变多的问题,但也极大地提高了用户上传的体验

还有很多组件库也会提供骨架图的组件,能够在页面还没有解析完成之前,先渲染一个页面的骨架和loading的状态,这样用户在页面加载的等待期就不至于一直白屏,下图所示就是antd-vue组件库骨架图渲染的结果。

image-20231016195539560

性能监测报告

第12讲 学习Vue Devtools的时候,我们已经使用Chrome的性能监测工具Lighthouse对极客时间的官网做了一次性能的评估,我们可以在这里看到 评测报告。并且,我们也对如何在调试窗口的Performance页面中进行性能监控,给出了演示。为了方便你理解,我们在这里也解释一下FCP、TTI和LCP这几个关键指标的含义。

首先是First Contentful Paint,通常简写为FCP,它表示的是页面上呈现第一个DOM元素的时间。在此之前,页面都是白屏的状态;然后是Time to interactive,通常简写为TTI,也就是页面可以开始交互的时间;还有和用户体验相关的Largest Contentful Paint,通常简写为LCP,这是页面视口上最大的图片或者文本块渲染的时间,在这个时间,用户能看到渲染基本完成后的首页,这也是用户体验里非常重要的一个指标。

我们还可以通过代码中的**performance对象去动态获取性能指标数据**,并且统一发送给后端,实现网页性能的监控。性能监控也是大型项目必备的监控系统之一,可以获取到用户电脑上项目运行的状态。

下图展示了performance中所有的性能指标,我们可以通过这些指标计算出需要统计的性能结果。

image-20231016200444773

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let timing = window.performance && window.performance.timing
let navigation = window.performance && window.performance.navigation

DNS 解析:
let dns = timing.domainLookupEnd - timing.domainLookupStart

总体网络交互耗时:
let network = timing.responseEnd - timing.navigationStart

渲染处理:
let processing = (timing.domComplete || timing.domLoading) - timing.domLoading

可交互:
let active = timing.domInteractive - timing.navigationStart

image-20231016200938600

在上面的代码中,我们通过Performance API获取了DNS解析、网络、渲染和可交互的时间消耗。有了这些指标后,我们可以随时对用户端的性能进行检测,做到提前发现问题,提高项目的稳定性。

总结

首先我们了解了用户从输入URL到页面显示的这一过程发生了什么,这里面的每个流程都有值得优化的地方,比如网络请求、页面渲染等。在对这些流程优化后,网页运行时整体的性能都会得到提升。

之后,在网络请求优化这一部分,我们首先谈到,对于DNS,我们可以通过dns-prefetch预先获取,这对性能优化来说,会减少页面中其他域名请求的DNS解析时间;因为TCP协议每次链接时,都需要三次握手,而这会带来额外的网络消耗的问题,为了解决这一问题,我们的优化策略是让文件尽可能少一些,并且也小一些。

比如,我们可以通过文件打包的形式减少HTTP请求数量,这样对于文件的大小来说,可以减小文件体积。我们也可以压缩代码,以及选择更合适的图片格式,这些都可以让我们加载更小的文件。图片的懒加载和路由的懒加载可以让首页加载更少的文件,从而实现页面整体性能的优化。

在讲完网络请求优化后,我们又研究了代码效率优化这个问题,其实代码层面要做的优化并不多,主要还是遵守Vue 3最佳实践。我们还以斐波那契数列的计算为例,通过在Performance面板中进行性能监控,明确了代码优化的方向。在通过递归的方式优化斐波那契数列之后,我们能明白这样一点: 性能优化的一个重要原则,是不要过度优化

之后,在用户体验优化这一部分,我们的关注点是在交互体验的优化上。有些场景我们可以损失部分性能去换取体验的提升,比如通过骨架图,我们可以在页面加载之前,通过对图片预先加载出模糊版本,可以让用户获得更好的体验。

最后,在性能监测报告这一部分,我讲到选择合适的工具,可以帮助我们实时地监测项目的性能。我们通过Lighthouse性能报告和Performace监测工具,可以精确地定位到项目瓶颈所在,有针对地去进行性能优化。

实战痛点5:如何打包发布你的Vue 3应用?

对于这个问题,你可能脱口而出:“使用npm run build就好了呀”。这样做只是在本地把代码打包,如果想要在线上也可以访问这些代码,那么还需要加上部署的过程。所以在下面,我先给你介绍一下当前这个时代的前端代码在部署的时候,有哪些难点和问题需要处理。

代码部署难点

在jQuery时代之前,前端项目中所有的内容都是一些简单的静态资源。那个时候,网站还没有部署的概念,网站上线前,我们直接把开发完的项目打包发给运维,再由运维把代码直接上传到服务器的网站根目录下解压缩,这样就完成了项目的部署。

后来的jQuery时代,项目的入口页面被后端管理,模板部署到了后端,CSS、JavaScript和图片等静态资源依然是打包到后端之后,再解压处理。但现在,我们对前端的性能和稳定性的要求也越来越高,jQuery时代的那种简单的部署模式就不足以应对性能优化、持续部署等一系列的情境。

现在前端所处的时代,我们主要会面临后面这些代码部署难点:首先是,如何高效地利用项目中的文件缓存;然后是,如何能够让整个项目的上线部署过程自动化,尽可能避免人力的介入,从而提高上线的稳定性;最后,项目上线之后,如果发现有重大Bug,我们就要考虑如何尽快回滚代码

当我们面对这些代码部署上的难点,特别是在团队协作的项目中遇到时,我们就可以考虑对项目进行自动化部署了,这样代码部署的速度和稳定性会给项目研发效率带来很好的提升。

项目上线前的自动化部署

下图所示的,是大部分团队部署项目时的逻辑 。实际上,大部分前端开发者都会认为,完成图示中的打包压缩这一步,也就是开发完项目之后,代码推送到GitHub后,就算完成任务了。但是,打包代码之后,把代码上传服务器也是这一步,对于前端开发者来说,是很少能接触到,但却是很重要的一步。

image-20231016201800183

所以,对于如何把打包好的代码上传到服务器这个问题,就值得我们去好好探究,琢磨出一个好的解决方案。

首先,我们需要一台独立的机器去进行打包和构建的操作,这台机器需要独立于所有开发环境,这样做是为了保证打包环境的稳定;之后,在部署任务启动的时候,我们需要拉取远程的代码,并且切换到需要部署的分支,然后锁定Node版本进行依赖安装、单元测试、ESLint等代码检查工作;最后,在这台机器上,执行经过编译产出的打包后的代码,并打包上传代码到CDN和静态服务器。当然了,完成这些操作之后,还要能通过脚本自动通过内部沟通软件通知团队项目构建的结果。

但是在项目部署的过程中,迎面而来的可能是下面这些问题:在什么操作系统环境中执行项目的构建?由谁触发构建?如何管理前面所述的把代码上传CDN时,CDN账户的权限?如何自动化执行部署的全过程,如果每次都由人工执行,就得消耗一个人力守着编译打包了,而且较为容易引发问题,比如测试的步骤遗漏或部署顺序出错。那么如何提升构建速率,就成了部署功能中需要解决的重要问题。

为了解决上面这些问题,业界提出了一些解决方案:比如,采用能保证环境一致性的Docker自动化构建触发可以通过GitHub ActionsGitHub的actions功能相当于给我们提供了一个免费的服务器,可以很方便地监控代码的推送、安装依赖、代码编译自动上传到服务器。

image-20231016202312040

上图所展示的,就是我们使用了GitHub Actions部署项目之后的项目开发流程。现在静态资源管理已经完成,也实现了自动化部署。提交代码之后,我们的项目就可以自动推送到服务器,这样,网站的第一次上线也就算成功了。

项目上线后的自动化部署

前端项目的自动化部署完成后,我们可以保证上线的稳定性,但是后续的持续上线怎么办?直接发到生产环境,会面临极大的风险。但如果不直接发布到生产环境,我们就不能在本地和测试的前端环境去连接生产环境的数据库。

所以我们需要一个 预发布的(Pre)环境,这个环境只能让测试和开发人员访问,除了访问地址的环节不同,其他所有环节都和生产环境保持一致,从而提供最真实的回归测试环境。

这个时候,我们会遇见下面这些问题,首先,如果我们确定项目下个版本在下周一零点发布,那我们就只能晚上12点准时守在电脑前,等待结果吗?如果npm安装依赖失败,或者上线后发现了重大Bug,那就只能迎接用户的吐槽吗?

其次, 随着node_modules的体积越来越大,构建时间会越来越长。如果每次构建都需要30分钟甚至更长时间的话,那么,即使Bug是在项目刚上线时就发现的,并且你也秒级响应,并修复了Bug,但在重新部署项目时,我们也需要等服务器慢慢编译。这个时候,时间就是金钱,如果你在修复Bug和重新部署项目上,耗费了过多的时间,那么就会导致项目故障时间过长的问题。

为了解决上面说到的这些问题,我们需要一种机制,能够让我们在发现问题之后,尽快地将版本进行回滚,并且在回滚的操作过程中,尽可能不需要人力的介入。所以,我们需要静态资源的版本管理,具体来说,就是让每个历史版本的资源都能保留下来,并且有一个唯一的版本号,如果发生了故障,能够瞬间切换版本。这个过程由具体的代码实现之后,我们只需要点击回滚的版本号,系统就会自动恢复到上线前的版本。

在这种机制下,如果你的业务流量特别大,每秒都有大量用户访问和使用,那么直接全量上线的操作就会被禁止。为了减少上线时,部署操作对用户造成的影响,我们需要先选择一部分用户去做灰度测试,也就是说,上线后的项目的访问权限,暂时只对这些用户开放。或者,你也可以做一些AB测试,比如给北京的同学推送Vue课,给上海的同学推荐React课等等。我们需要做的,就是把不同版本的代码分开打包,互不干涉。之后,我们再设计部署的机器和机房去适配不同的用户。

在Gtihub中,我们可以使用actions去配置打包的功能,下面的代码是actions的配置文件。在这个配置文件中,我们使用Ubuntu作为服务器的打包环境,然后拉取GitHub中最新的master分支代码,并且把Node版本固定为14.7.6,执行npm install安装代码所需依赖后,再执行npm run build进行代码打包压缩。在下面的代码中,我们就通过GitHub Actions自动化打包了一份准备上线的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
name: 打包应用的actions
on:
  push: # 监听代码时间
    branches:
      - master # master分支代码推送的时候激活当前action
jobs:
  build:
    # runs-on 操作系统
    runs-on: ubuntu-latest
    steps:
      - name: 迁出代码
        uses: actions/checkout@master
      # 安装Node
      - name: 安装Node
        uses: actions/setup-node@v1
        with:
          node-version: 14.7.6
      # 安装依赖
      - name: 安装依赖
        run: npm install
      # 打包
      - name: 打包
        run: npm run build

然后,我们需要配置上线服务器和GitHub Actions服务器的信任关系,通过SSH密钥可以实现免登录直接部署。我们直接把build之后的代码打包压缩,通过SSH直接上传到服务器上,并且要进行代码文件版本的管理,就完成了代码的部署。

最后一步,就是部署成功后的结果通知了。现在办公软件钉钉和飞书都提供了相关的推送结果,我们可以随时通过群机器人接口把消息推送到群内,关于钉钉机器人的适用文档,你直接看官方的 开发文档 就可以了,我们需要做的是把版本号、部署日期、发起人等信息推送到对应接口,这样就完成了自动化部署的操作。

这一过程涉及服务器、钉钉开发文档、GitHub Actions,浏览器和本地代码环境多个场景的转换,这一讲我们先重点学习整体部署需要的思路和注意事项,实际的部署操作过程,你可以看这个视频的实操演示:

视频内容归为以下步骤

自动化部署流程

选择仓库的Actions

image-20231016204031927

点击 new workflow

里面已经内置了很多工作流

image-20231016204123532

我们选择自己配置,点击set up a workflow yourself

image-20231016204251946

复制这段内容到main.yml里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# This is a basic workflow to help you get started with Actions

name: deploy

# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the main branch
push: # push就会触发下面的任务
branches: [ main ] # 哪个分支
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: install Node.js # 任务的名字,可任意
uses: actions/setup-node@v2.5.0 # 可以在右边搜
with:
node-version: "14.X" # 版本
- name: install dep
run: npm install # 任务运行的命令
- name: build app
run: npm run build
- name: copy file via ssh password #上传打包后的文件到服务器
uses: appleboy/scp-action@master
with:
host: ${{ secrets.REMOTE_HOST }} # 服务器ip
username: ${{ secrets.REMOTE_USER }} # 服务器账号
password: ${{ secrets.REMOTE_PASS }} # 服务器密码
port: 22 # 端口号
source: "docs/" # 打包后的文件夹
target: ${{ secrets.REMOTE_TARGET }} # 要上传到的服务器的对应的文件夹

users在这里查找,尽量选择星多的

image-20231016205310050

这些变量可以在这里定义

选择:Settings

image-20231016205453896

点击左侧的Secrets and variables下的Actions

image-20231016205532787

image-20231016205716008

常见一个测试一下

image-20231016205825710

之后每次push代码就会自动化部署了

组件库:如何设计你自己的通用组件库?

学习路径大致是这样的,首先我会给你拆解一下Element3组件库的代码,其次带你剖析组件库中一些经典的组件,比如表单、表格、弹窗等组件的实现细节,整体使用Vite+TypeScript+Sass的技术栈来实现。而业务中繁多的页面也是由一个个组件拼接而成的,所以我们可以先学习一下不同类型的组件是如何去设计的,借此举一反三。

环境搭建

下面我们直奔主题,开始搭建环境。这个章节的代码我已经推送到了 Github 上,由于组件库是模仿Element实现的,所以我为其取名为ailemente。

接下来我们就一步步实现这个组件库吧。首先和开发项目一样,我们要在命令行里使用下面的命令创建Vite项目,模板选择vue-ts,这样我们就拥有了一个Vite+TypeScript的开发环境。

1
npm init vite@latest

image-20231016211640276

关于ESLintSass的相关配置,全家桶实战篇我们已经详细配置了,这里只补充一下husky的内容。husky这个库可以很方便地帮助我们设置Git的钩子函数,可以允许我们在代码提交之前进行代码质量的监测。

在这之前,我们要先创建仓库

下面的代码中,我们首先安装和初始化了husky,然后我们使用 npx husky add命令新增了commit-msg钩子,husky会在我们执行git commit提交代码的时候执行 node scripts/verifyCommit命令来校验commit信息格式。

1
2
3
4
npm install -D husky # 安装husky
npx husky install # 初始化husky
# 新增commit msg钩子
npx husky add .husky/commit-msg "node scripts/verifyCommit.js"

然后我们来到项目目录下的verifyCommit文件。在下面的代码中,我们先去 .git/COMMIT\_EDITMSG文件中读取了commit提交的信息,然后使用了正则去校验提交信息的格式。如果commit的信息不符合要求,会直接报错并且终止代码的提交。

scripts/verifyCommit.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// import fs from 'fs';
// const msg = fs.readFileSync('.git/COMMIT_EDITMSG', 'utf-8')

const msg = require('fs')
.readFileSync('.git/COMMIT_EDITMSG', 'utf-8')
.trim()

const commitRE = /^(revert: )?(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip|release)(\(.+\))?: .{1,50}/
const mergeRe = /^(Merge pull request|Merge branch)/
if (!commitRE.test(msg)) {
if(!mergeRe.test(msg)){
console.log('git commit信息校验不通过')

console.error(`git commit的信息格式不对, 需要使用 title(scope): desc的格式
比如 fix: xxbug
feat(test): add new
具体校验逻辑看 scripts/verifyCommit.js
`)
process.exit(1)
}

}else{
console.log('git commit信息校验通过')
}

这样就确保在GitHub中的提交日志都符合type(scope): message 的格式。你可以看下Vue 3的 代码提交记录,每个提交涉及的模块,类型和信息都清晰可见,能够很好地帮助我们管理版本日志,校验正则的逻辑。如下图,feat代表新功能,docs代表文档,perf代表性能。下面的提交日志就能告诉我们这次提交的是组件相关的新功能,代码中新增了Button.vue。

1
feat(component): add Button.vue

image-20231016221435197

image-20231016221407907

commit-msg是代码执行提交的时候执行的,我们还可以使用代码执行之前的钩子pre-commit去执行ESLint代码格式。这样我们在执行git commit的同时,就会首先进行ESLint校验,然后执行commit的log信息格式检查,全部通过后代码才能提交至Git,这也是现在业界通用的解决方案,学完你就快去优化一下手里的项目吧!

1
npx husky add .husky/pre-commit "npm run lint"

布局组件

好,现在环境我们就搭建好了,接着看看怎么布局组件。

我们可以参考 Element3组件列表页面,这里的组件分成了基础组件、表单组件、数据组件、通知组件、导航组件和其他组件几个类型,这些类型基本覆盖了组件库的适用场景,项目中的业务组件也是由这些类型组件拼接而来的。

我们还可以参考项目模块的规范搭建组件库的模板,包括Sass、ESLint等,组件库会在这些规范之上加入单元测试来进一步确保代码的可维护性。

接下来我们逐一讲解下各个组件的负责范围。

首先我们需要设计基础的组件,也就是整个项目中都会用到的组件规范,包括布局、色彩,字体、图标等等。这些组件基本没有JavaScript的参与,实现起来也很简单,负责的就是项目整体的布局和色彩设计。

而表单组件则负责用户的输入数据管理,包括我们常见的输入框、滑块、评分等等,总结来说, 需要用户输入的地方就是表单组件的应用场景,其中对用户的输入校验是比较重要的功能点。

数据组件负责显示后台的数据,最重要的就是表格和树形组件。

通知组件负责通知用户操作的状态,包括警告和弹窗,如何用函数动态渲染组件是警告组件的重要功能点。

接下来我们就动手设计一个基础的布局组件,这个组件相对是比较简单的。你可以访问 Element3布局容器页面,这里一共有containerheaderfooterasidemain五个组件,这个组合可以很方便地实现常见的页面布局。

  • el-container组件负责外层容器,当子元素中包含 <el-header> 或 <el-footer> 时,全部子元素会垂直上下排列,否则会水平左右排列。
  • el-headerel-asideel-mainel-footer 组件分别负责顶部和侧边栏,页面主体和底部容器组件。这个功能比较简单,只是渲染页面的布局。我们可以在src/components目录下新建文件夹container,新建Container.vue,布局组件没有交互逻辑,只需要通过flex布局就可以实现。

这几个组件只是提供了不同的class,这里就涉及到CSS的设计内容。在Element3中所有的样式前缀都是el开头,每次都重复书写维护太困难,所以我们设计之初就需要涉及Sass的Mixin来提高书写CSS的代码效率。

接着,我们在src/styles下面新建mixin.scss。在下面的代码中,我们定义了namespace变量为el,使用Mixin注册一个可以重复使用的模块b,可以通过传进来的block生成新的变量$B,并且变量会渲染在class上,并且注册了when可以新增class选择器,实现多个class的样式。

src/components/styles/mixin.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$namespace: 'el';
@mixin b($block) {
$B: $namespace + '-' + $block !global;
.#{$B} {
@content;
}
}

// 添加ben后缀啥的
@mixin when($state) {
@at-root {
&.#{$state-prefix + $state} {
@content;
}
}
}

BEM是一种用于组织和命名CSS类的命名约定,旨在提高代码的可维护性和可扩展性。BEM将页面分解为块(blocks)、元素(elements)和修饰符(modifiers),每个都有自己的命名规则。在Sass代码中,你可以看到一种将BEM类应用于HTML元素的方法。

让我解释一下这段代码的关键部分:

  1. @mixin b($block):这是一个Sass mixin的定义,它接受一个参数 $block,用于指定BEM块的名称。
  2. $B: $namespace + '-' + $block !global;:在这一行中,代码创建一个新的Sass变量 $B,它是一个BEM类的名称,由 $namespace$block 组成。$namespace$block 可能是在其他地方定义的Sass变量,它们用于生成BEM类名。!global 表示将这个变量声明为全局变量,以便在整个Sass文件中使用。
  3. .#{$B}:这是生成的BEM类名的Sass选择器,它会根据前面生成的变量 $B 来创建相应的CSS类选择器。
  4. @content:这表示 mixin 会包含嵌套在这个选择器内的任何Sass代码

代码看着有些抽象,不要急,我们再在 container.vue中写上下面的代码。使用@import导入mixin.scss后,就可以用include语法去使用Mixin注册的代码块。

  1. @mixin when($state):这是一个Sass mixin 的定义,它接受一个参数 $state,这个参数用于表示一个状态。
  2. @at-root:这是一个Sass指令,它告诉Sass生成的CSS规则要跳出当前的嵌套结构,直接放在根级别(全局级别)。
  3. &.#{$state-prefix + $state}:这行代码生成了一个CSS选择器,其中 $state-prefix 是一个变量,它可能在其他地方定义,而 $state 则是 mixin 的参数。这个选择器的作用是匹配具有特定状态的HTML元素,其中 & 表示当前选择器的父级选择器,#{$state-prefix + $state} 是动态生成的状态类名。
  4. @content:这表示 mixin 会包含嵌套在这个选择器内的任何Sass代码。

总结来说,这个 mixin 的作用是在生成CSS时,将一组具有特定状态的样式规则放在全局级别,以便可以在不同地方应用这些状态。通常,这用于创建可复用的状态类,这些状态类可以用于修改元素的样式,而不需要在每个元素的选择器中重复定义这些状态。

例如,你可以使用这个 mixin 创建一个状态类,如下所示:

1
2
3
4
5
6
sassCopy code$state-prefix: "is-"; // 设置状态类的前缀

@include when("active") {
background-color: red;
color: white;
}

在这种情况下,生成的CSS将包括一个全局的 .is-active 类,你可以将它应用于任何HTML元素以添加特定的激活状态样式。

src/components/container/Container.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
contsiner页面
</div>
</template>
<script setup lang="ts"></script>
<style lang="scss" scoped>
@import '../styles/mixin';
@include b(container) {
display: flex;
flex-direction: row;
flex: 1;
flex-basis: auto;
box-sizing: border-box;
min-width: 0;
@include when(vertical) {
flex-direction: column;
}
}
</style>

在上面的代码中,我们使用b(container)生成.el-container样式类,在内部使用when(vertical)生成.el-container.is-vertical样式类,去修改flex的布局方向。

1
2
3
4
5
6
7
8
9
10
11
.el-container {
  display: flex;
  flex-direction: row;
  flex: 1;
  flex-basis: auto;
  box-sizing: border-box;
  min-width: 0;
}
.el-container.is-vertical {
  flex-direction: column;
}

container组件如果内部没有header或者footer组件,就是横向布局,否则就是垂直布局。根据上面的CSS代码,我们可以知道,只需要新增is-vertical这个class,就可以实现垂直布局。

我们在Container.vue中写下下面的代码,template中使用el-container容器包裹,通过:class来实现样式控制即可。然后你肯定会疑惑,为什么会有两个script标签?

因为开发组件库的时候,我们要确保每个组件都有自己的名字,**script setup中没法返回组件的名字,所以我们需要一个单独的标签,使用options的语法设置组件的name属性**。

然后在<script setup>标签中,添加lang=”ts”来声明语言是TypeScript。Typescript实现组件的时候,我们只需要使用interface去定义传递的属性类型即可。使用defineProps()实现参数类型校验后,我们再使用computed去判断container的方向是否为垂直,手动指定direction和子元素中有el-header或者el-footer的时候是垂直布局,其他情况是水平布局。

src/components/Container.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<template>
<section
class="el-container"
:class="{ 'is-vertical': isVertical }"
>
<slot />
</section>
</template>
<script lang="ts">
export default{
name:'ElContainer'
}
</script>
<script setup lang="ts">

import {useSlots,computed,VNode,Component} from 'vue'

interface Props {
direction?:string
}
const props = defineProps<Props>()

const slots = useSlots()

const isVertical = computed(() => {
if (slots && slots.default) {
return slots.default().some((vn:VNode) => {
const tag = (vn.type as Component).name
return tag === 'ElHeader' || tag === 'ElFooter'
})
} else {
return props.direction === 'vertical'
}
})
</script>

<style lang="scss">
@import '../styles/mixin';
@include b(header) {
padding: $--header-padding;
box-sizing: border-box;
flex-shrink: 0;
}

</style>

这样我们的container组件就实现了,其他四个组件实现起来大同小异。我们以header组件举例,在container目录下新建Header.vue。

在下面的代码中,template中渲染el-header样式类,通过defineProps定义传入的属性height,并且通过withDefaults设置height的默认值为60px,通过b(header)的方式实现样式,用到的变量都在style/mixin中注册,方便多个组件之间的变量共享。

src/components/container/Header.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<template>
<header
class="el-header"
:style="{ height }"
>
<slot />
</header>
</template>

<script lang="ts">
export default{
name:'ElHeader'
}
</script>
<script setup lang="ts">
import {withDefaults} from 'vue'

interface Props {
height?:string
}
withDefaults(defineProps<Props>(),{
height:"60px"
})

</script>

<style lang="scss">
@import '../styles/mixin';
@include b(header) {
padding: $--header-padding;
box-sizing: border-box;
flex-shrink: 0;
}
</style>

src/components/container/Aside.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
<aside
class="el-aside"
:style="{ width }"
>
<slot />
</aside>
</template>
<script lang="ts">
export default{
name:'ElAside'
}
</script>
<script setup lang="ts">
import {withDefaults} from 'vue'

type PropValues = {
width:string
}
withDefaults(defineProps<PropValues>(),{
width:"300px"
})
</script>

<style lang="scss">
@import '../styles/mixin';
@include b(aside) {
overflow: auto;
box-sizing: border-box;
flex-shrink: 0;
}
</style>

src/components/container/Footer.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<footer
class="el-footer"
:style="{ height }"
>
<slot />
</footer>
</template>

<script lang="ts">
export default{
name:'ElFooter'
}
</script>

<script setup lang="ts">
import { withDefaults } from 'vue'

interface Props {
height?:string
}
withDefaults(defineProps<Props>(),{
height:"60px"
})

</script>

<style lang="scss">
@import '../styles/mixin';
@include b(input) {
padding: $--footer-padding;
box-sizing: border-box;
flex-shrink: 0;
@include m(inner){
width:300px;
}
}
</style>

src/components/container/Main.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<main class="el-main">
<slot />
</main>
</template>

<script lang="ts">
export default{
name:'ElMain'
}
</script>

<script setup lang="ts">
</script>
<style lang="scss">
@import '../styles/mixin';
@include b(main) {
display: block;
flex: 1;
flex-basis: auto;
overflow: auto;
box-sizing: border-box;
padding: $--main-padding;
}
</style>

withDefaults说明

当使用基于类型的声明时,我们失去了为 props 声明默认值的能力。这可以通过 withDefaults 编译器宏解决

组件注册

aside、footer和main组件代码和header组件基本一致,你可以在 这次提交中 看到组件的变更。

组件注册完毕之后,我们在src/App.vue中使用import语法导入后就可以直接使用了。但是这里有一个小问题,我们的组件库最后会有很多组件对外暴露,用户每次都import的话确实太辛苦了,所以我们还需要使用插件机制对外暴露安装的接口,我们在container目录下新建index.ts。在下面的代码中,我们对外暴露了一个对象,对象的install方法中,我们使用app.component注册这五个组件。

然后我们来到src/main.ts文件中,下面的代码中我们使用app.use(ElContainer)的方式注册全部布局组件,这样在项目内部就可以全局使用contain、header等五个组件。实际的组件库开发过程中, 每个组件都会提供一个install方法,可以很方便地根据项目的需求按需加载。

1
2
3
4
5
6
7
8
import { createApp } from 'vue'
import App from './App.vue'
import ElContainer from './components/container'

const app = createApp(App)
app.use(ElContainer)
.use(ElButton)
.mount('#app')

组件使用

在src/App.vue的代码中,我们使用组件嵌套的方式就可以实现下面的页面布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<template>
<el-container>
<el-header>Header</el-header>
<el-main>Main</el-main>
<el-footer>Footer</el-footer>
</el-container>
<hr>

<el-container>
<el-header>Header</el-header>
<el-container>
<el-aside width="200px">Aside</el-aside>
<el-main>Main</el-main>
</el-container>
</el-container>
<hr>
<el-container>
<el-aside width="200px">Aside</el-aside>
<el-container>
<el-header>Header</el-header>
<el-main>Main</el-main>
<el-footer>Footer</el-footer>
</el-container>
</el-container>
</template>

<script setup lang="ts">
</script>

<style scoped>
body{
width:1000px;
margin:10px auto;
}
.el-header,
.el-footer {
background-color: #b3c0d1;
color: #333;
text-align: center;
line-height: 60px;
}

.el-aside {
background-color: #d3dce6;
color: #333;
text-align: center;
line-height: 200px;
}

.el-main {
background-color: #e9eef3;
color: #333;
text-align: center;
line-height: 160px;
}

body > .el-container {
margin-bottom: 40px;
}

.el-container:nth-child(5) .el-aside,
.el-container:nth-child(6) .el-aside {
line-height: 260px;
}

.el-container:nth-child(7) .el-aside {
line-height: 320px;
}
</style>

单元测试:如何使用 TDD 开发一个组件?

今天我们来聊另外一个大幅提升组件库代码可维护性的手段:单元测试。在理解单元测试来龙去脉的基础上,我还会给你演示,如何使用测试驱动开发的方式实现一个组件,也就是社区里很火的TDD开发模式。

单元测试

单元测试(Unit Testing),是指对软件中的最小可测试单元进行检查和验证,这是百度百科对单元测试的定义。而我的理解是,在我们日常代码开发中,会经常写Console来确认代码执行效果是否符合预期,这其实就算是测试的雏形了,我们把代码中的某个函数或者功能,传入参数后,校验输出是否符合预期。

下面的代码中我们实现了一个简单的add函数, 并且使用打印3和add(1,2)的结果来判断函数输出。

add函数虽然看起来很简单,但实际使用时可能会遇到很多情况。比如说x如果是字符串,或者对象等数据类型的时候,add结果是否还可以符合预期?而且add函数还有可能被你的同事不小心加了其他逻辑,这都会干扰add函数的行为。

1
2
3
4
5
function add(x,y){
return x+y
}

console.log(3 == add(1,2))

为了让add函数的行为符合预期,你希望能添加很多Console的判断逻辑,并且让这些代码自动化执行。

我们来到src目录下,新建一个add.js。下面的代码中,我们定义了函数test执行测试函数,可以给每个测试起个名字,方便调试的时候查找,expect可以判断传入的值和预期是否相符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function add(x,y){
return x+y
}

function expect(ret){
return {
toBe(arg){
if(ret!==arg){
throw Error(`预计和实际不符,预期是${arg},实际是${ret}`)
}
}
}
}
function test(title, fn){
try{
fn()
console.log(title,'测试通过')
}catch(e){
console.log(e)
console.error(title,'测试失败')
}
}
test('测试数字相加',()=>{
expect(add(1,2)).toBe(3)
})

命令行执行node add.js以后,我们就可以看到下面的结果。如果每次Git提交代码之前,我们都能执行一遍add.js去检查add函数的逻辑,add函数相当于有了个自动检查员,这样就可以很好地提高add函数的可维护性。

1
2
➜  ailemente git:(main) ✗ node add.js
测试数字相加 测试通过

下一步,我们如果想让add函数支持更多的数据类型,比如我们想支持数字字符串的相加,又要怎么处理呢?我们可以先写好测试代码,在下面的代码中,我们希望数字1和字符串2也能以数字的形式相加。

1
2
3
test('测试数字和字符串数字相加',()=>{
expect(add(1,'2')).toBe(3)
})

我们在命令行里执行node add.js之后,就会提示下面的报错信息,这说明现在代码还没有符合新的需求,我们需要进一步丰富add函数的逻辑。

image-20231017200602302

我们把add函数改成下面的代码,再执行add.js后,就会提示你两个测试都通过了,这样我们就确保新增逻辑的时候,也没有影响到之前的代码逻辑。

1
2
3
4
5
6
function add(x,y){
if(Number(x)==x && Number(y)==y){
return Number(x) + Number(y)
}
return x+y
}

这是一个非常简单的场景演示,但这个例子能够帮助你快速了解什么是单元测试。下一步,我们要在Vue中给我们的组件加上测试。

组件库引入Jest

我们选择Facebook出品的Jest作为我们组件库的测试代码,Jest是现在做测试的最佳选择了,因为它内置了断言、测试覆盖率等功能。

不过,因为我们组件库使用TypeScript开发,所以需要安装一些插件,通过命令行执行下面的命令,vue-jest和@vue/test-utils是测试Vue组件必备的库,然后安装babel相关的库,最后安装Jest适配TypeScript的库。代码如下:

1
2
3
npm install -D jest@26 vue-jest@next @vue/test-utils@next
npm install -D babel-jest@26 @babel/core @babel/preset-env
npm install -D ts-jest@26 @babel/preset-typescript @types/jest

安装完毕后,我们要在根目录下新建.babel.config.js。下面的配置目的是让babel解析到Node和TypeScript环境下。

.babel.config.js

1
2
3
4
5
6
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
}

然后,我们还需要新建jest.config.js,用来配置jest的测试行为。不同格式的文件需要使用不同命令来配置,对于.vue文件我们使用vue-jest,对于.js或者.jsx结果的文件,我们就要使用babel-jest,而对于.ts结尾的文件我们使用ts-jest,然后匹配文件名是xx.spect.js。这里请注意, Jest只会执行.spec.js结尾的文件

jest.config.js

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
transform: {
// .vue文件用 vue-jest 处理
'^.+\\.vue$': 'vue-jest',
// .js或者.jsx用 babel-jest处理
'^.+\\.jsx?$': 'babel-jest',
//.ts文件用ts-jest处理
'^.+\\.ts$': 'ts-jest'
},
testMatch: ['**/?(*.)+(spec).[jt]s?(x)']
}

然后配置package.json,在scrips配置下面新增test命令,即可启动Jest。

package.json

1
2
3
4
5
6
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test": "jest",
},

完成上面的操作之后,配置工作就告一段落了,可以开始输入代码做测试了。

我们可以在目录下新增test.spec.js,再输入下面代码来进行测试。在这段代码中,我们使用expect().toBe()来判断值是否相等,使用toHavaBeenCalled来判断函数是否执行。更多的断言函数你可以去 官网 查看,这些函数可以覆盖我们测试场景的方方面面。

编写测试用例

test.spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
function sayHello(name,fn){
if(name=='aaa'){
fn()
}
}
test('测试加法',()=>{
expect(1+2).toBe(3)
})
test('测试函数',()=>{
const fn = jest.fn()
sayHello('aaa',fn)
expect(fn).toHaveBeenCalled()
})

image-20231017214732827

运行npm run test会报下面的错误

解决方案:去掉package.json中的”type”: “module”,

image-20231017214531096

TDD开发组件

好,通过之前的讲解,我们已经学会如何使用Jest去测试函数。下一步我们来测试Vue3的组件,其实,Vue的组件核心逻辑也是函数。

这里我们借助Vue官方推荐的 @vue/test-utils 库来测试组件的渲染,我们新建src/components/button文件夹,新建Button.spec.ts。

src/components/button/Button.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
<template>
<button
class="el-button"
:class="[
buttonSize ? `el-button--${buttonSize}` : '',
type ? `el-button--${type}` : ''
]"
>
<slot />
</button>
</template>

<script lang="ts">
export default{
name:'ElButton'
}
</script>

<script setup lang="ts">

import {computed, withDefaults} from 'vue'
import { useGlobalConfig } from '../../utils'

interface Props {
size?:""|'small'|'medium'|'large',
type?:""|'primary'|'success'|'danger'
}
const props = withDefaults(defineProps<Props>(),{
size:"",
type:""
})
const globalConfig = useGlobalConfig()
const buttonSize = computed(()=>{
console.log(props.size,globalConfig)
return props.size||globalConfig.size
})
</script>

<style lang="scss">
@import '../styles/mixin';

@include b(button){
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
background: $--button-default-background-color;
color: $--button-default-font-color;
-webkit-appearance: none;
text-align: center;
border: $--border-base;
border-color: $--button-default-border-color;
box-sizing: border-box;
outline: none;
margin: 0;
font-weight: $--button-font-weight;
& + & {
margin-left: 10px;
}
@include button-size(
$--button-padding-vertical,
$--button-padding-horizontal,
$--button-font-size,
$--button-border-radius
);
&:hover,
&:focus {
color: $--color-primary;
border-color: mix($--color-white,$--color-primary,70%);
background-color: mix($--color-white,$--color-primary,90%);
}
@include m(medium) {
@include button-size(
$--button-medium-padding-vertical,
$--button-medium-padding-horizontal,
$--button-medium-font-size,
$--button-medium-border-radius
);
}
@include m(small) {
@include button-size(
$--button-small-padding-vertical,
$--button-small-padding-horizontal,
$--button-small-font-size,
$--button-small-border-radius
);

}
@include m(large) {
@include button-size(
$--button-large-padding-vertical,
$--button-large-padding-horizontal,
$--button-large-font-size,
$--button-large-border-radius
);
}
@include m(primary) {
@include button-variant(
$--button-primary-font-color,
$--button-primary-background-color,
$--button-primary-border-color
);
}
@include m(success) {
@include button-variant(
$--button-success-font-color,
$--button-success-background-color,
$--button-success-border-color
);
}
@include m(danger) {
@include button-variant(
$--button-danger-font-color,
$--button-danger-background-color,
$--button-danger-border-color
);
}
}
</style>

参考 Element3的button组件,el-button组件可以通过传递size来配置按钮的大小。现在我们先根据需求去写测试代码,因为现在Button.vue还不存在,所以我们可以先根据Button的行为去书写测试案例。

src/components/button/Button.spec.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Button from './Button.vue'
import { mount } from '@vue/test-utils'
describe('按钮测试', () => {
it('按钮能够显示文本', () => {
const content = '测试内容'
const wrapper = mount(Button, {
slots: {
default: content
}
})
expect(wrapper.text()).toBe(content)
})
it('通过size属性控制大小', () => {
const size = 'small'
const wrapper = mount(Button, {
props: {
size
}
})
// size内部通过class控制
expect(wrapper.classes()).toContain('el-button--small')
})
})

我们首先要从@vue/test-utils库中导入mount函数,这个函数可以在命令行里模拟Vue的组件渲染。在Button的slot传递了文本之后,wrapper.text()就能获取到文本内容,然后对Button渲染结果进行判断。之后,我们利用size参数,即可通过渲染不同的class来实现按钮的大小,这部分内容我们很熟悉了,在 第20讲 里的Container组件中就已经实现过了。

然后我们在命令行执行npm run test来执行所有的测试代码。

image-20231017221120645

前面的代码中通过b(button)渲染el-button的样式,内部使用变量都可以在mixin中找到。通过b和button-size的嵌套,就能实现按钮大小的控制。button渲染的结果,你可以参考下方的截图。

image-20231017221638829

然后我们接着往下进行,想要设置按钮的大小,除了通过props传递,还可以通过全局配置的方式设置默认大小。我们进入到代码文件src/main.ts中,设置全局变量$AILEMENTE中的size为large,并且还可以通过type=”primary”或者type=”success”的方式,设置按钮的主体颜色,代码如下:

1
2
3
app.config.globalProperties.$AILEMENTE = {
size:'large'
}

首先我们要支持全局的size配置,在src目录下新建util.ts,写入下面的代码。我们通过vue提供的getCurrentInstance获取当前的实例,然后返回全局配置的$AILEMENTE。这里请注意,由于很多组件都需要读取全局配置,所以我们封装了useGlobalConfig函数。

src/utils.ts

1
2
3
4
5
6
7
8
9
10
11
import { getCurrentInstance,ComponentInternalInstance } from 'vue'

export function useGlobalConfig(){
const instance:ComponentInternalInstance|null =getCurrentInstance()
if(!instance){
console.log('useGlobalConfig 必须得在setup里面整')
return
}
return instance.appContext.config.globalProperties.$AILEMENTE || {}

}

getCurrentInstance 用于获取当前组件实例,而 ComponentInternalInstance 是组件实例的类型。

const instance:ComponentInternalInstance|null = getCurrentInstance():这行代码使用 getCurrentInstance 函数来获取当前组件实例

instance.appContext.config.globalProperties.$AILEMENTE从组件实例的上下文中获取全局配置对象

组件对象:getCurrentInstance

组件参数:instance.appContext.config

image-20231018150434901

这时我们再回到Button.vue中,通过computed返回计算后的按钮的size。如果props.size没传值,就使用全局的globalConfig.size;如果全局设置中也没有size配置,按钮就使用Sass中的默认大小。

src/components/button/index.ts

1
2
3
4
5
6
7
8
import {App} from 'vue'
import ElButton from './Button.vue'

export default {
install(app:App){
app.component(ElButton.name,ElButton)
}
}

在main.ts中引入,并使用use注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElContainer from './components/container'
import ElButton from './components/button'

const app= createApp(App)


app.config.globalProperties.$AILEMENTE = {
size:'large'
}

app.use(ElContainer)
.use(ElButton)
.mount('#app')

我们来到src/App.vue中,就可以直接使用el-button来显示不同样式的按钮了。

1
2
3
4
5
6
7
8
9
10
<el-button type="primary">
按钮
</el-button>
<el-button type="success">
按钮
</el-button>
<el-button>按钮</el-button>
<el-button size="small">
按钮
</el-button>

image-20231017222343889

然后我们进入jest.config.js中,新增下面的配置,collectCoverage标记的意思是我们需要收集代码测试覆盖率。

然后在执行npm run test后,项目的根目录下就会出现一个coverage目录。

我们打开下面的index.html后,就可以在浏览器中看到测试覆盖率的报告。对照下图我们可以看到,button组件的测试覆盖率100%,util下面有两行代码飘红,也就是没有测试的逻辑。

在一定程度上,测试覆盖率也能够体现出代码的可维护性,希望你可以用好这个指标。

image-20231018151549061

image-20231018151622719

image-20231018151745924

最后,我们进入.husky/pre-commit 文件,新增 npm run test 命令,这么做的目的是,确保测试通过的代码才能进入 git 管理代码,这会进一步提高代码的规范和可维护性。

1
2
3
4
5
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run lint
npm run test

总结

首先,我们学习了什么是自动化测试,我们实现了 test 和 expect 函数,通过它们来测试add 函数。

然后,我们通过 jest 框架配置了 Vue 的自动化测试环境。通过安装 babel、@vue/test-utils、babel-vue、ts-babel 等插件,我们配置了 TypeScript 环境下的 Jest+Vue 3 的单测环境,并且匹配项目中.spect 结束的 js 和 vue 文件执行测试。

在 Jest 中,我们通过 describe 函数给测试分组,通过 it 执行测试,再利用 expect 语法去执行断言。我们还发现,借助 @vue/test-utils 库可以很方便地对 Vue 组件进行测试。

最后,我们一起体验了 TDD 测试驱动开发的开发模式。我们先根据功能需求,去写出测试案例,这个时候测试窗口就会报错,然后我们才开始实现功能,最终让测试代码全部通过,用这样的方式来检验开发的结果。TDD 的优势就在于可以随时检验代码的逻辑,能极大提高代码的可维护性

现在我们有了 TypeScript,有了 Jest,下一讲我们将实现一个比较复杂的表单组件,它会包含组件的通信、方法传递等难点,敬请期待。

表单:如何设计一个表单组件?

表单组件

Element表单组件 的页面里,我们能看到表单种类的组件类型有很多,我们常见的输入框、单选框和评分组件等都算是表单组件系列的。

下面这段代码是Element3官方演示表单的Template,整体表单页面分三层:

  • el-form组件负责最外层的表单容器;
  • el-form-item组件负责每一个输入项的label和校验管理;
  • 内部的el-input或者el-switch负责具体的输入组件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<el-form
:model="ruleForm"
:rules="rules"
ref="form"
label-width="100px"
class="demo-ruleForm"
>
<el-form-item label="活动名称" prop="name">
<el-input v-model="ruleForm.name"></el-input>
</el-form-item>
<el-form-item label="活动区域" prop="region">
<el-select v-model="ruleForm.region" placeholder="请选择活动区域">
<el-option label="区域一" value="shanghai"></el-option>
<el-option label="区域二" value="beijing"></el-option>
</el-select>
</el-form-item>
<el-form-item label="即时配送" prop="delivery">
<el-switch v-model="ruleForm.delivery"></el-switch>
</el-form-item>
<el-form-item label="活动性质" prop="type">
<el-checkbox-group v-model="ruleForm.type">
<el-checkbox label="美食/餐厅线上活动" name="type"></el-checkbox>
<el-checkbox label="地推活动" name="type"></el-checkbox>
<el-checkbox label="线下主题活动" name="type"></el-checkbox>
<el-checkbox label="单纯品牌曝光" name="type"></el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="特殊资源" prop="resource">
<el-radio-group v-model="ruleForm.resource">
<el-radio label="线上品牌商赞助"></el-radio>
<el-radio label="线下场地免费"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="活动形式" prop="desc">
<el-input type="textarea" v-model="ruleForm.desc"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')"
>立即创建</el-button
>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>

现在我们把上面的代码简化为最简单的形式,只留下el-input作为输入项,就可以清晰地看到表单组件工作的模式:el-form组件使用:model提供数据绑定;使用rules提供输入校验规则,可以规范用户的输入内容;使用el-form-item作为输入项的容器,对输入进行校验,显示错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
<el-form :model="ruleForm" :rules="rules" ref="form">
<el-form-item label="用户名" prop="username">
<el-input v-model="ruleForm.username"></el-input>
<!-- <el-input :model-value="" @update:model-value=""></el-input> -->
</el-form-item>
<el-form-item label="密码" prop="passwd">
<el-input type="textarea" v-model="ruleForm.passwd"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm()">登录</el-button>
</el-form-item>
</el-form>

然后我们看下rules和model是如何工作的。

这里使用reactive返回用户输入的数据,username和passwd输入项对应,然后rules使用reactive包裹用户输入项校验的配置。

具体的校验规则,现在主流组件库使用的都是async-validator这个库,详细的校验规则你可以访问 async-validator的官网 查看。而表单Ref上我们额外新增了一个validate方法,这个方法会执行所有的校验逻辑来显示用户的报错信息,下图就是用户输入不符合rules配置后,页面的报错提示效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const ruleForm = reactive<UserForm>({
username:"",
passwd:""
})
const rules = reactive({
rules: {
username: { required: true,min: 1, max: 20, message: '长度在 1 到 20 个字符', trigger: 'blur' },
passwd: [{ required: true, message: '密码', trigger: 'blur' }]
}
})
function submitForm() {
form.value.validate((valid) => {
if (valid) {
alert('submit!')
} else {
console.log('error submit!!')
return false
}
})
}

image-20231018155627446

表单组件实现

那么接下来我们就要实现组件了。我们进入到 src/components 目录下新建 Form.vue 去实现 el-form 组件,该组件是整个表单组件的容器,负责管理每一个 el-form-item 组件的校验方法,并且自身还提供一个检查所有输入项的 validate 方法。

在下面的代码中,我们注册了传递的属性的格式,并且注册了validate 方法使其对外暴露使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Props {
label?: string
prop?: string
}
const props = withDefaults(defineProps<Props>(), {
label: "",
prop: ""
})

const formData = inject(key)

const o: FormItem = {
validate,
}

defineExpose(o)

那么在 el-form 组件中如何管理el-form-item组件呢?我们先要新建FormItem.vue文件,这个组件加载完毕之后去通知el-form组件自己加载完毕了,这样在el-form中我们就可以很方便地使用数组来管理所有内部的form-item组件。

1
2
3
4
5
6
import { emitter } from "../../emitter"
const items = ref<FormItem[]>([])

emitter.on("addFormItem", (item) => {
items.value.push(item)
})

然后el-form-item还要负责管理内部的input输入标签,并且从form组件中获得配置的rules,通过rules的逻辑,来判断用户的输入值是否合法。另外,el-form还要管理当前输入框的label,看看输入状态是否报错,以及报错的信息显示,这是一个承上启下的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
onMounted(() => {
if (props.prop) {
emitter.on("validate", () => {
validate()
})
emitter.emit("addFormItem", o)
}
})
function validate() {
if (formData?.rules === undefined) {
return Promise.resolve({ result: true })
}
const rules = formData.rules[props.prop]
const value = formData.model[props.prop]
const schema = new Schema({ [props.prop]: rules })
return schema.validate({ [props.prop]: value }, (errors) => {
if (errors) {
error.value = errors[0].message || "校验错误"
} else {
error.value = ""
}
})
}

这里我们可以看到,form、form-item和input这三个组件之间是 嵌套使用 的关系:

  • form提供了所有的数据对象和配置规则;

  • input负责具体的输入交互;

  • form-item负责中间的数据和规则管理,以及显示具体的报错信息。

    这就需要一个强有力的组件通信机制,在Vue中组件之间的通信机制有这么几种。

首先是父子组件通信,通过propsemits来通信。这个我们在全家桶实战篇和评级组件那一讲都有讲过,父元素通过props把需要的数据传递给子元素,子元素通过emits通知父元素内部的变化,并且还可以通过defineDepose的方式暴露给父元素方法,可以让父元素调用自己的方法。

那么form和input组件如何通信呢?这种祖先元素和后代元素,中间可能嵌套了很多层的关系,Vue则提供了provideinject两个API来实现这个功能。

在组件中我们可以使用provide函数向所有子组件提供数据,子组件内部通过inject函数注入使用。注意这里provide提供的只是普通的数据,并没有做响应式的处理,如果子组件内部需要响应式的数据,那么需要在provide函数内部使用ref或者reative包裹才可以。

关于prvide和inject的类型系统,我们可以使用Vue提供的InjectiveKey来声明。我们在form目录下新建type.ts专门管理表单组件用到的相关类型,在下面的代码中,我们定义了表单form和表单管理form-item的上下文,并且通过InjectionKey管理提供的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { InjectionKey } from "vue"
import { Rules, Values } from "async-validator"

export type FormData = {
model: Record<string, unknown>
rules?: Rules
}

export type FormItem = {
validate: () => Promise<Values>
}

export type FormType = {
validate: (cb: (isValid: boolean) => void) => void
}

export const key: InjectionKey<FormData> = Symbol("form-data")

而下面的代码,我们则通过provide向所有子元素提供form组件的上下文。子组件内部通过inject获取,很多组件都是嵌套成对出现的,provideinject这种通信机制后面我们还会不停地用到,做好准备。

1
2
3
4
5
6
7
provide(key, {
model: props.model,
rules?: props.rules,
})

# 子组件
const formData = inject(key)

然后就是具体的input实现逻辑,在下面的代码中,input 的核心逻辑就是对v-model的支持,这个内容我们在评级组件那一讲已经实现过了。

v-mode其实是:mode-value="x"@update:modelValute两个写法的简写,组件内部获取对应的属性和modelValue方法即可。这里需要关注的代码是我们输入完成之后的事件,输入的结果校验是由父组件el-form-item来实现的,我们只需要通过emit对外广播出去即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<template>
<div
class="el-form-item"
>
<label
v-if="label"
>{{ label }}</label>
<slot />
<p
v-if="error"
class="error"
>
{{ error }}
</p>
</div>
</template>
<script lang="ts">
export default{
name:'ElFormItem'
}
</script>

<script setup lang="ts">
import Schema from "async-validator"
import { onMounted, ref, inject } from "vue"
import { FormItem, key } from "./type"
import { emitter } from "../../emitter"

interface Props {
label?: string
prop?: string
}
const props = withDefaults(defineProps<Props>(), { label: "", prop: "" })
// 错误
const error = ref("")

const formData = inject(key)

const o: FormItem = {
validate,
}

defineExpose(o)

onMounted(() => {
if (props.prop) {
emitter.on("validate", () => {
validate()
})
emitter.emit("addFormItem", o)
}
})

function validate() {
if (formData?.rules === undefined) {
return Promise.resolve({ result: true })
}
const rules = formData.rules[props.prop]
const value = formData.model[props.prop]
const schema = new Schema({ [props.prop]: rules })
return schema.validate({ [props.prop]: value }, (errors) => {
if (errors) {
error.value = errors[0].message || "校验错误"
} else {
error.value = ""
}
})
}
</script>

<style lang="scss">
@import '../styles/mixin';
@include b(form-item) {
margin-bottom: 22px;
label{
line-height:1.2;
margin-bottom:5px;
display: inline-block;
}
& .el-form-item {
margin-bottom: 0;
}
}
.error{
color:red;
}
</style>

最后我们点击按钮的时候,在最外层的form标签内部会对所有的输入项进行校验。由于我们管理着所有的form-item,只需要遍历所有的form-item,依次执行即可。

下面的代码就是表单注册的validate方法,我们遍历全部的表单输入项,调用表单输入项的validate方法,有任何一个输入项有报错信息,整体的校验就会是失败状态。

1
2
3
4
5
6
function validate(cb: (isValid: boolean) => void) {
const tasks = items.value.map((item) => item.validate())
Promise.all(tasks)
.then(() => { cb(true) })
.catch(() => { cb(false) })
}

上面代码实际执行的是每个表单输入项内部的validate方法,这里我们使用的就是async-validate的校验函数。在validate函数内部,我们会获取表单所有的ruls,并且过滤出当前输入项匹配的输入校验规则,然后通过AsyncValidator对输入项进行校验,把所有的校验结果放在model对象中。如果errors[0].message非空,就说明校验失败,需要显示对应的错误消息,页面输入框显示红色状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Schema from "async-validator"

function validate() {
if (formData?.rules === undefined) {
return Promise.resolve({ result: true })
}
const rules = formData.rules[props.prop]
const value = formData.model[props.prop]
const schema = new Schema({ [props.prop]: rules })
return schema.validate({ [props.prop]: value }, (errors) => {
if (errors) {
error.value = errors[0].message || "校验错误"
} else {
error.value = ""
}
})
}

完整代码实现

安装

1
npm install async-validator mitt
  1. async-validator:这是一个用于在异步 JavaScript 应用程序中执行验证的库。它通常与表单验证和数据校验相关。async-validator 可以帮助你定义验证规则,并在数据发生变化时对数据进行验证。这对于确保用户提供的数据符合特定要求非常有用,例如在提交表单之前验证用户输入的数据。

    这是一个基于 Promise 的库,可以用于浏览器和 Node.js 环境。你可以定义验证规则,然后使用 async-validator 来验证数据是否符合这些规则,根据验证结果采取相应的操作。这对于构建强大的表单验证逻辑非常有用。

  2. mitt:这是一个用于实现事件发布和订阅模式的小型事件总线库。它允许你在 JavaScript 应用程序中轻松创建自定义事件,以便在不同部分的代码之间进行通信。

    你可以使用 mitt 来创建自定义事件,然后在应用程序的不同模块之间订阅这些事件,以便在特定事件发生时执行相关操作。这对于解耦代码、提高代码的可维护性和扩展性非常有用,特别是在复杂的应用程序中。

src/components/form/type.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { InjectionKey } from "vue"
import { Rules, Values } from "async-validator"

export type FormData = {
model: Record<string, unknown>
rules?: Rules
}

export type FormItem = {
validate: () => Promise<Values>
}

export type FormType = {
validate: (cb: (isValid: boolean) => void) => void
}

export const key: InjectionKey<FormData> = Symbol("form-data")

src/components/form/emitter.ts

1
2
3
4
5
6
7
import mitt from "mitt"
import { FormItem } from "./components/form/type"
export type Events = {
validate: undefined
addFormItem: FormItem
}
export const emitter = mitt<Events>()

src/components/form/Form.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<template>
<div class="el-form">
<slot />
</div>
</template>
<script lang="ts">
export default{
name:'ElForm'
}

</script>

<script setup lang="ts">
import { PropType, provide } from "vue"
import { Rules } from "async-validator"
import { ref } from "vue"
import { emitter } from "../../emitter"
import { FormItem, key } from "./type"

const props = defineProps({
model: { type: Object, required: true },
rules: { type: Object as PropType<Rules> },
})

provide(key, {
model: props.model,
rules: props.rules,
})

const items = ref<FormItem[]>([])

emitter.on("addFormItem", (item) => {
items.value.push(item)
})

function validate(cb: (isValid: boolean) => void) {
const tasks = items.value.map((item) => item.validate())
Promise.all(tasks)
.then(() => { cb(true) })
.catch(() => { cb(false) })
}

defineExpose({
validate,
})
</script>

<style lang="scss">
@import '../styles/mixin';
@include b(form) {
margin-top:20px;
box-sizing: border-box;
flex-shrink: 0;
width:300px;
}

</style>

defineExpose是vue3新增的一个api,放在<scipt setup>下使用的,目的是把属性和方法暴露出去,可以用于父子组件通信,子组件把属性暴露出去, 父组件用ref获取子组件DOM,子组件暴露的方法或属性可以用dom获取。

src/components/form/Input.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<template>
<div>
<input
:value="modelValue"
class="el-input--inner"
@input="onInput"
>
</div>
</template>
<script lang="ts">
export default{
name:'ElInput'
}
</script>
<script setup lang="ts">
import { emitter } from "../../emitter"

defineProps<{
modelValue:string
}>()

const emit = defineEmits<{
(e: "update:model-value", value: string): void
}>()

function onInput(e: Event) {
const input = e.target as HTMLInputElement
emit("update:model-value", input.value)
emitter.emit("validate")
}
</script>
<style lang="scss">
@import '../styles/mixin';
@include b(input) {

@include m(inner){
-webkit-appearance: none;
background-color: #fff;
background-image: none;
border-radius: 4px;
border: 1px solid #dcdfe6;
box-sizing: border-box;
color: #606266;
display: inline-block;
font-size: inherit;
height: 40px;
line-height: 40px;
outline: 0;
padding: 0 15px;
transition: border-color .2s cubic-bezier(.645,.045,.355,1);
width: 100%;
}

}

</style>

src/components/form/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
import {App} from 'vue'
import ElForm from './Form.vue'
import ElFormItem from './FormItem.vue'
import ElInput from './Input.vue'

export default {
install(app:App){
app.component(ElForm.name,ElForm)
app.component(ElFormItem.name,ElFormItem)
app.component(ElInput.name,ElInput)
}
}

总结

今天我们设计和实现了一个比较复杂的组件类型——表单组件。表单组件在组件库中作用,就是收集和获取用户的输入值,并且提供用户的输入校验,比如输入的长度、邮箱格式等,符合校验规则后,就可以获取用户输入的内容,并提交给后端。

这一过程中我们要实现三类组件:

  • el-form提供表单的容器组件,负责全局的输入对象model和校验规则rules的配置,并且在用户点击提交的时候,可以执行全部输入项的校验规则;

  • 其次是input类组件,我们日常输入内容的输入框、下拉框、滑块等都属于这一类组件,这类组件主要负责显示对应的交互组件,并且监听所有的输入项,用户在交互的同时通知执行校验;

  • 然后就是介于form和input中间的form-item组件,这个组件负责每一个具体输入的管理,从form组件中获取校验规则,从input中获取用户输入的内容,通过async-validator校验输入是否合法后显示对应的输入状态,并且还能把校验方法提供给form组件,form可以很方便地管理所有form-item。

至此,form组件设计完毕,相信你对组件通信、输入类组件的实现已经得心应手了,并且对组件设计中如何使用TypeScript也有了自己的心得。 组件设计我们需要考虑的就是内部交互的逻辑,对子组件提供什么数据,对父组件提供什么方法,需不需要通过provide或者inject来进行跨组件通信等等。相信实践过后,你会有更加深刻的理解和认识。

弹窗:如何设计一个弹窗组件?

不过,用户在交互完成之后,还需要知道交互的结果状态,这就需要我们提供专门用来反馈操作状态的组件。这类组件根据反馈的级别不同,也分成了很多种类型,比如全屏灰色遮罩、居中显示的对话框Dialog,在交互按钮侧面显示、用来做简单提示的tooltip,以及右上角显示信息的通知组件Notification等,这类组件的交互体验你都可以在 Element3官网 感受。

今天的代码也会用Element3的Dialog组件和Notification进行举例,在动手写代码实现之前,我们先从这个弹窗组件的需求开始说起。

组件需求分析

我们先来设计一下要做的组件,通过这部分内容,还可以帮你继续加深一下对单元测试Jest框架的使用熟练度。我建议你在设计一个新的组件的时候,也试试采用这种方式,先把组件所有的功能都罗列出来,分析清楚需求再具体实现,这样能够让你后面的工作事半功倍。

首先无论是对话框Dialog,还是消息弹窗Notification,它们都由一个弹窗的标题,以及具体的弹窗的内容组成的。我们希望弹窗有一个关闭的按钮,点击之后就可以关闭弹窗,弹窗关闭之后还可以设置回调函数。

下面这段代码演示了dialog组件的使用方法,通过title显示标题,通过slot显示文本内容和交互按钮,而通过v-model就能控制显示状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<el-dialog
title="提示"
:visible.sync="dialogVisible"
width="30%"
v-model:visible="dialogVisible"
>
<span>这是一段信息</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="dialogVisible = false">确 定</el-button>
</span>
</template>
</el-dialog>

这类组件实现起来和表单类组件区别不是特别大,我们首先需要做的就是 控制好组件的数据传递,并且使用Teleport渲染到页面顶层的body标签。

DialogNotification类的组件,我们只是单纯想显示一个提示或者报错信息,过几秒就删除,如果在每个组件内部都需要写一个<Dialog v-if>,并且使用v-if绑定变量的方式控制显示就会显得很冗余。

所以,这里就要用到一种调用Vue组件的新方式:我们可以使用JavaScript的API动态地创建和渲染Vue的组件。具体如何实现呢?我们以Notification组件为例一起看一下。

下面的代码是Element3的Notification演示代码。组件内部只有两个button,我们不需要书写额外的组件标签,只需要在<script setup>中使用Notification.success函数,就会在页面动态创建Notification组件,并且显示在页面右上角。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<el-button plain @click="open1"> 成功 </el-button>
<el-button plain @click="open2"> 警告 </el-button>
</template>
<script setup>
import { Notification } from 'element3'

function open1() {
Notification.success({
title: '成功',
message: '这是一条成功的提示消息',
type: 'success'
})
}
function open2() {
Notification.warning({
title: '警告',
message: '这是一条警告的提示消息',
type: 'warning'
})
}

</script>

弹窗组件实现

分析完需求之后,我们借助单元测试的方法来实现这个弹窗组件(单元测试的内容如果记不清了,你可以回顾 第20讲)。

我们依次来分析Notification的代码,相比于写Demo逻辑的代码,这次我们体验一下实际的组件和演示组件的区别。我们来到element3下面的src/components/Notification/notifucation.vue代码中,下面的代码构成了组件的主体框架,我们不去直接写组件的逻辑,而是先从测试代码来梳理组件的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<template>
<div class="el-notification" :style="positionStyle" @click="onClickHandler">
<div class="el-notification__title">
{{ title }}
</div>

<div class="el-notification__message">
{{ message }}
</div>

<button
v-if="showClose"
class="el-notification__close-button"
@click="onCloseHandler"
></button>
</div>
</template>
<script setup>
const instance = getCurrentInstance()
const visible = ref(true)
const verticalOffsetVal = ref(props.verticalOffset)

const typeClass = computed(() => {
return props.type ? `el-icon-${props.type}` : ''
})

const horizontalClass = computed(() => {
return props.position.endsWith('right') ? 'right' : 'left'
})

const verticalProperty = computed(() => {
return props.position.startsWith('top') ? 'top' : 'bottom'
})

const positionStyle = computed(() => {
return {
[verticalProperty.value]: `${verticalOffsetVal.value}px`
}
})
</script>

<style lang="scss">
.el-notification {
position: fixed;
right: 10px;
top: 50px;
width: 330px;
padding: 14px 26px 14px 13px;
border-radius: 8px;
border: 1px solid #ebeef5;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
}
</style>

结合下面的代码可以看到,我们进入到了内部文件Notification.spec.js中。下面的测试代码中,我们期待Notification组件能够渲染el-notification样式类,并且内部能够通过属性title渲染标题;message属性用来渲染消息主体;position用来渲染组件的位置,让我们的弹窗组件可以显示在浏览器四个角。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import Notification from "./Notification.vue"
import { mount } from "@vue/test-utils"

describe("Notification", () => {

it('渲染标题title', () => {
const title = 'this is a title'
const wrapper = mount(Notification, {
props: {
title
}
})
expect(wrapper.get('.el-notification__title').text()).toContain(title)
})

it('信息message渲染', () => {
const message = 'this is a message'
const wrapper = mount(Notification, {
props: {
message
}
})
expect(wrapper.get('.el-notification__content').text()).toContain(message)
})

it('位置渲染', () => {
const position = 'bottom-right'
const wrapper = mount(Notification, {
props: {
position
}
})
expect(wrapper.find('.el-notification').classes()).toContain('right')
expect(wrapper.vm.verticalProperty).toBe('bottom')
expect(wrapper.find('.el-notification').element.style.bottom).toBe('0px')
})

it('位置偏移', () => {
const verticalOffset = 50
const wrapper = mount(Notification, {
props: {
verticalOffset
}
})
expect(wrapper.vm.verticalProperty).toBe('top')
expect(wrapper.find('.el-notification').element.style.top).toBe(
`${verticalOffset}px`
)
})

})

到这里,Notification组件测试的主体逻辑就实现完毕了,我们拥有了一个能够显示在右上角的组件,具体效果你可以参考后面这张截图。

image-20231019145251416

进行到这里,距离完成整体设计我们还差两个步骤。

首先,弹窗类的组件都需要直接渲染在body标签下面,弹窗类组件由于布局都是绝对定位,如果在组件内部渲染,组件的css属性(比如Transform)会影响弹窗组件的渲染样式,为了避免这种问题重复出现,弹窗组件Dialog、Notification都需要渲染在body内部。

Dialog组件可以直接使用Vue3自带的Teleport,很方便地渲染到body之上。在下面的代码中, 我们用teleport组件把dialog组件包裹之后,通过to属性把dialog渲染到body标签内部。

1
2
3
4
5
6
7
8
9
10
<teleport
:disabled="!appendToBody"
to="body"
>
<div class="el-dialog">
<div class="el-dialog__content">
<slot />
</div>
</div>
</teleport>

image-20231019150342817

但是Notification组件并不会在当前组件以组件的形式直接调用,我们需要像Element3一样,能够使用js函数动态创建Notification组件, 给Vue的组件提供Javascript的动态渲染方法,这是弹窗类组件的特殊需求

组件渲染优化

我们先把测试代码写好,具体如下。代码中分别测试函数创建组件,以及不同配置和样式的通知组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
it('函数会创建组件', () => {
const instanceProxy = Notification('foo')
expect(instanceProxy.close).toBeTruthy()
})

it('默认配置 ', () => {
const instanceProxy = Notification('foo')

expect(instanceProxy.$props.position).toBe('top-right')
expect(instanceProxy.$props.message).toBe('foo')
expect(instanceProxy.$props.duration).toBe(4500)
expect(instanceProxy.$props.verticalOffset).toBe(16)
})
test('字符串信息', () => {
const instanceProxy = Notification.info('foo')

expect(instanceProxy.$props.type).toBe('info')
expect(instanceProxy.$props.message).toBe('foo')
})
test('成功信息', () => {
const instanceProxy = Notification.success('foo')

expect(instanceProxy.$props.type).toBe('success')
expect(instanceProxy.$props.message).toBe('foo')
})

现在测试写完后还是会报错,因为现在Notification函数还没有定义,我们要能通过Notification函数动态地创建Vue的组件,而不是在template中使用组件。

JSX那一讲 中我们讲过,template的本质就是使用h函数创建虚拟Dom,如果我们自己想动态创建组件时,使用相同的方式即可。

在下面的代码中我们使用Notification函数去执行createComponent函数,使用h函数动态创建组件,实现了动态组件的创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function createComponent(Component, props, children) {
const vnode = h(Component, { ...props, ref: MOUNT_COMPONENT_REF }, children)
const container = document.createElement('div')
vnode[COMPONENT_CONTAINER_SYMBOL] = container
render(vnode, container)
return vnode.component
}
export function Notification(options) {
return createNotification(mergeProps(options))
}

function createNotification(options) {
const instance = createNotificationByOpts(options)
setZIndex(instance)
addToBody(instance)
return instance.proxy
}

创建组件后,由于Notification组件同时可能会出现多个弹窗,所以我们需要使用数组来管理通知组件的每一个实例,每一个弹窗的实例都存储在数组中进行管理。

下面的代码里,我演示了怎样用数组管理弹窗的实例。Notification函数最终会暴露给用户使用,在Notification函数内部我们通过createComponent函数创建渲染的容器,然后通过createNotification创建弹窗组件的实例,并且维护在instanceList中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const instanceList = []
function createNotification(options) {
...
addInstance(instance)
return instance.proxy
}
function addInstance(instance) {
instanceList.push(instance)
}
;['success', 'warning', 'info', 'error'].forEach((type) => {
Notification[type] = (options) => {
if (typeof options === 'string' || isVNode(options)) {
options = {
message: options
}
}
options.type = type
return Notification(options)
}
})

// 有了instanceList, 可以很方便的关闭所有信息弹窗
Notification.closeAll = () => {
instanceList.forEach((instance) => {
instance.proxy.close()
removeInstance(instance)
})
}

最后,我带你简单回顾下我们都做了什么。在正式动手实现弹窗组件前,我们分析了弹窗类组件的风格。弹窗类组件主要负责用户交互的反馈。根据显示的级别不同,它可以划分成不同的种类:既有覆盖全屏的弹窗Dialog,也有负责提示消息的Notification

这些组件除了负责渲染传递的数据和方法之外,还需要能够脱离当前组件进行渲染, 防止当前组件的css样式影响布局。因此Notification组件需要渲染到body标签内部,而Vue提供了Teleport组件来完成这个任务,我们通过Teleport组件就能把内部的组件渲染到指定的dom标签。

之后,我们需要给组件提供JavaScript调用的方法。我们可以使用Notification()的方式动态创建组件,利用createNotification即可动态创建Vue组件的实例。

对于弹窗组件来说可以这样操作:首先通过createNotification函数创建弹窗的实例,并且给每个弹窗设置好唯一的id属性,然后存储在数组中进行管理。接着,我们通过对createNotification函数返回值的管理,即可实现弹窗动态的渲染、更新和删除功能。

树:如何设计一个树形组件?

组件功能分析

我们进入 Element3的Tree组件文档页面,现在我们对Vue的组件如何设计和实现已经很熟悉了,我重点挑跟之前组件设计不同的地方为你讲解。

在设计新组件的时候,我们需要重点考虑的就是树形组件和之前我们之前的Container、Button、Notification有什么区别。树形组件的主要特点是可以无限层级、这种需求在日常工作和生活中其实很常见,比如后台管理系统的菜单管理、文件夹管理、生物分类、思维导图等等。

image-20231019155104627

根据上图所示,我们可以先拆解出树形组件的功能需求。

首先,树形组件的节点可以无限展开,父节点可以展开和收起节点,并且每一个节点有一个复选框,可以切换当前节点和所有子节点的选择状态。另外,同一级所有节点选中的时候,父节点也能自动选中。

下面的代码是Element3的Tree组件使用方式,所有的节点配置都是一个data对象实现的。每个节点里的label用来显示文本;expaned显示是否展开;checked用来决定复选框选中列表,data数据内部的children属性用来配置子节点数组,子节点的数据结构和父节点相同,可以递归实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<el-tree
:data="data"
show-checkbox
v-model:expanded="expandedList"
v-model:checked="checkedList"
:defaultNodeKey="defaultNodeKey"
>
</el-tree>
<script>
export default {
data() {
return {
expandedList: [4, 5],
checkedList: [5],
data: [
{
id: 1,
label: '一级 1',
children: [
{
id: 4,
label: '二级 1-1',
children: [
{
id: 9,
label: '三级 1-1-1'
},
{
id: 10,
label: '三级 1-1-2'
}
]
}
]
},
{
id: 2,
label: '一级 2',
children: [
{
id: 5,
label: '二级 2-1'
},
{
id: 6,
label: '二级 2-2'
}
]
}
],
defaultNodeKey: {
childNodes: 'children',
label: 'label'
}
}
}
}

</script>

递归组件

这里父节点和子节点的样式操作完全一致,并且可以无限嵌套,这种需求需要组件递归来实现,也就是组件内部渲染自己渲染自己。

想要搞定递归组件,我们需要先明确什么是递归,递归的概念也是我们前端进阶过程中必须要掌握的知识点。

前端的场景中,树这个数据结构出现的频率非常高,浏览器渲染的页面是Dom树,我们内部管理的是虚拟Dom树, 树形结构是一种天然适合递归的数据结构

我们先来做一个算法题感受一下,我们来到 leetcode第226题反转二叉树,题目的描述很简单,就是把属性结构反转,下面是题目的描述:

每一个节点的val属性代表显示的数字,left指向左节点,right指向右节点,如何实现invertTree去反转这一个二叉树,也就是所有节点的left和right互换位置呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
输入
4
/ \
2 7
/ \ / \
1 3 6 9
输出
4
/ \
7 2
/ \ / \
9 6 3 1
节点的构造函数
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/

输入的左右位置正好相反,而且每个节点的结构都相同,这就是非常适合递归的场景。递归的时候,我们首先需要思考递归的核心逻辑如何实现,这里就是两个节点如何交换,然后就是递归的终止条件,否则递归函数就会进入死循环。

下面的代码中,设置invertTree函数的终止条件是root是null的时候,也就是如果节点不存在的时候不需要反转。这里我们只用了一行解构赋值的代码就实现了,值得注意的是右边的代码中我们递归调用了inverTree去递归执行,最终实现了整棵树的反转。

1
2
3
4
5
6
7
8
9
var invertTree = function(root) {
// 递归 终止条件
if(root==null) {
return root
}
// 递归的逻辑
[root.left, root.right] = [invertTree(root.right), invertTree(root.left)]
return root
}

树形组件的数据结构内部的children可以无限嵌套,处理这种数据结构,就需要使用递归的算法思想。有了上面这个算法题的基础后,我们后面再学习树形组件如何实现就能更加顺畅了。

组件实现

首先我们进入到Element3的tree文件夹内部,然后找到tree.vue文件。tree.vue 是组件的入口容器,用于接收和处理数据,并将数据传递给 TreeNode.vue;TreeNode.vue 负责渲染树形组件的选择框、标题和递归渲染子元素。

在下面的代码中,我们提供了el-tree的容器,还导入了el-tree-node进行渲染。tree.vue通过provide向所有子元素提供tree的数据,通过useExpand判断树形结构的展开状态,并且用到了watchEffect去向组件外部通知update:expanded事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<template>
<div class="el-tree">
<el-tree-node v-for="child in tree.root.childNodes" :node="child" :key="child.id"></el-tree-node>
</div>
</template>

<script>
import ElTreeNode from './TreeNode.vue'
const instance = getCurrentInstance()
const tree = new Tree(props.data, props.defaultNodeKey, {
asyncLoadFn: props.asyncLoadFn,
isAsync: props.async
})
const state = reactive({
tree
})
provide('elTree', instance)
useTab()
useExpand(props, state)

function useExpand(props, state) {
const instance = getCurrentInstance()
const { emit } = instance

if (props.defaultExpandAll) {
state.tree.expandAll()
}

watchEffect(() => {
emit('update:expanded', state.tree.expanded)
})

watchEffect(() => {
state.tree.setExpandedByIdList(props.expanded, true)
})

onMounted(() => {
state.tree.root.expand(true)
})
}


</script>

表格:如何设计一个表格组件?

文档:如何给你的组件库设计一个可交互式文档?

自定义渲染器:如何实现Vue的跨端渲染 ?

这一讲我们来学习一个叫Vue 3的进阶知识点:自定义渲染器,这个功能可以自定义Vue渲染的逻辑。

在给你讲清楚原理之后,我还会带你一起实现一个Canvas的渲染器实际上手体验一下。

什么是渲染器

我们都知道,Vue内部的组件是以虚拟dom形式存在的。下面的代码就是一个很常见的虚拟Dom,用对象的方式去描述一个项目。相比dom标签相比,这种形式可以让整个Vue项目脱离浏览器的限制,更方便地实现Vuejs的跨端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
tag: 'div',
props: {
id: 'app'
},
chidren: [
{
tag: Container,
props: {
className: 'el-container'
},
chidren: [
'哈喽小老弟!!!'
]
}
]
}

渲染器是围绕虚拟Dom存在的。在浏览器中,我们把虚拟Dom渲染成真实的Dom对象,Vue源码内部把一个框架里所有和平台相关的操作,抽离成了独立的方法。所以,我们只需要实现下面这些方法,就可以实现Vue 3在一个平台的渲染。

首先用createElement创建标签,还有用createText创建文本。创建之后就需要用insert新增元素,通过remote删除元素,通过setText更新文本和patchProps修改属性。然后再实现parentNodenextSibling等方法实现节点的查找关系。完成这些工作,理论上就可以在一个平台内实现一个应用了。

在Vue 3中的runtime-core模块,就对外暴露了这些接口,runtime-core内部基于这些函数实现了整个Vue内部的所有操作,然后在runtime-dom中传入以上所有方法。

下面的代码就是Vue代码提供浏览器端操作的函数,这些DOM编程接口完成了浏览器端增加、添加和删除操作,这些API都是浏览器端独有的,如果一个框架强依赖于这些函数,那就只能在浏览器端运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
//插入元素
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
// 删除元素
remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
},
// 创建元素
createElement: (tag, isSVG, is, props): Element => {
const el = isSVG
? doc.createElementNS(svgNS, tag)
: doc.createElement(tag, is ? { is } : undefined)

if (tag === 'select' && props && props.multiple != null) {
;(el as HTMLSelectElement).setAttribute('multiple', props.multiple)
}

return el
}
//...其他操作函数
}

如果一个框架想要实现实现跨端的功能,那么渲染器本身不能依赖任何平台下特有的接口。

在后面的代码中,我们通过createRenderer函数区创建了一个渲染器。通过参数options获取增删改查所有的函数以后,在内部的rendermountpatch等函数中,需要去渲染一个元素的时候,就可以通过option.createElementoption.insert来实现。

这段代码给你展现的是核心逻辑,完整版本你可以看一下 Vue 3的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
export default function createRenderer(options) {
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
createComment: hostCreateComment,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
setScopeId: hostSetScopeId = NOOP,
cloneNode: hostCloneNode,
insertStaticContent: hostInsertStaticContent
} = options

  function render(vnode, container) { }

  function mount(vnode, container, isSVG, refNode) { }

  function mountElement(vnode, container, isSVG, refNode) { }

  function mountText(vnode, container) { }

  function patch(prevVNode, nextVNode, container) { }

  function replaceVNode(prevVNode, nextVNode, container) { }
  function patchElement(prevVNode, nextVNode, container) { }
  function patchChildren(
    prevChildFlags,
    nextChildFlags,
    prevChildren,
    nextChildren,
    container
  ) { }

  function patchText(prevVNode, nextVNode) { }
  function patchComponent(prevVNode, nextVNode, container) { }

  return { render }
}

在每个函数实现的内部,比如mountElemnt,我们之前的实现方式是调用浏览器的API创建。

1
2
3
4
5
function mountElement(vnode, container, isSVG, refNode) {
  const el = isSVG
    ? document.createElementNS(....)
    : document.createElement(vnode.tag)
}

对比一下,经过渲染器抽离之后,内部的mountElmenet就会把所有document的操作全部换成options传递进来的hostCreate函数。

1
2
3
function mountElement(vnode, container, isSVG, refNode) {
  const el = hostCreateElement(vnode.tag, isSVG)
}

然后,我们使用后面的代码创建一个具体平台的渲染器,这也是Vue 3中的runtime-dom包主要做的事。了解了Vue中自定义渲染器的实现方式后,我们还可以基于Vue 3的runtime-core包封装其他平台的渲染器,让其他平台也能使用Vue内部的响应式和组件化等优秀的特性。

1
2
3
4
5
6
7
8
const { render } = createRenderer({
  nodeOps: {
    createElement() { },
    createText() { }
    // more...
  },
  patchData
})

自定义渲染

说完了渲染器创建,我们再来看看自定义渲染。

自定义渲染器让Vue脱离了浏览器的限制,我们只需要实现平台内部的增删改查函数后,就可以直接对接Vue 3。比方说,我们可以把Vue渲染到小程序平台,实现Vue 3-minipp;也可以渲染到Canvas,实现vue 3-canvas,把虚拟dom渲染成Canvas;甚至还可以尝试把Vue 3渲染到threee.js中,在3D世界使用响应式开发。

接下来,我们一起尝试实现一个Canvas的渲染器。具体操作是这样的,我们在项目的src目录下新建renderer.js,通过这个文件实现一个简易的Canvas渲染逻辑。Canvas平台中操作的方式相对简单,没有太多节点的概念,我们可以把整个Canvas维护成一个对象,每次操作的时候直接把Canvas重绘一下就可以了。

1
2
3
4
5
6
7
8
9
10
11
import { createRenderer } from '@vue/runtime-core'
const { createApp: originCa } = createRenderer({
insert: (child, parent, anchor) => {
},
createElement(type, isSVG, isCustom) {
},
setElementText(node, text) {
},
patchProp(el, key, prev, next) {
},
});

下面的代码中我们实现了draw函数,这里我们就是用Canvas的操作方法 递归 地把Canvas对象渲染到Canvas标签内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let ctx
function draw(ele, isChild) {
if (!isChild) {
ctx.clearRect(0, 0, 500, 500)
}

ctx.fillStyle = ele.fill || 'white'
ctx.fillRect(...ele.pos)
if (ele.text) {
ctx.fillStyle = ele.color || 'white'
ele.fontSize = ele.type == "h1" ? 20 : 12
ctx.font = (ele.fontSize || 18) + 'px serif'
ctx.fillText(ele.text, ele.pos[0] + 10, ele.pos[1] + ele.fontSize)
}
ele.child && ele.child.forEach(c => {
console.log('child:::', c)
draw(c, true)
})

}

由于我们主体需要维护的逻辑就是对于对象的操作,所以创建和更新操作直接操作对象即可。新增insert需要维护parent和child元素。另外,插入的时候也需要调用draw函数,并且需要监听onclick事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const { createApp: originCa } = createRenderer({
insert: (child, parent, anchor) => {
if (typeof child == 'string') {
parent.text = child
} else {
child.parent = parent
if (!parent.child) {
parent.child = [child]
} else {
parent.child.push(child)
}
}
if (parent.nodeName) {
draw(child)
if (child.onClick) {
ctx.canvas.addEventListener('click', () => {
child.onClick()
setTimeout(() => {
draw(child)
})
}, false)
}
}
},
createElement(type, isSVG, isCustom) {
return {
type
}
},
setElementText(node, text) {
node.text = text
},
patchProp(el, key, prev, next) {
el[key] = next
},

});

现在我们来到src/main.js中,这时候就不能直接从vue中引入createApp了,而是需要从runtime-core中导入createRenderer。

接下来,通过createRenderer用我们自已定义的renderer去创建createApp,并且重写mount函数。在Canvas的mount中,我们需要创建Canvas标签并且挂载到App上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createRenderer } from '@vue/runtime-core'
const { createApp: originCa } = createRenderer({

})
function createApp(...args) {
const app = originCa(...args)
return {
mount(selector) {
const canvas = document.createElement('canvas')
canvas.width = window.innerWidth
canvas.height = window.innerHeight
document.querySelector(selector).appendChild(canvas)
ctx = canvas.getContext('2d')
app.mount(canvas)
}
}
}

下一步进入src/App.vue中,我们就可以在Vue组件中使用ref等响应式的写法了。我们实现了通过ref返回的响应式对象,渲染Canvas内部的文字和高度,并且点击的时候还可以修改文字。完成上面的操作,我们就实现了Canvas平台的基本渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div @click="setName('vue3真棒')" :pos="[10,10,300,300]" fill="#eee">
<h1 :pos="[20,20,200,100]" fill="red" color="#000">累加器{{count}}</h1>
<span :pos="pos" fill="black" >哈喽{{name}}</span>
</div>

</template>

<script setup>

import {ref} from 'vue'
const name = ref('vue3入门')
const pos = ref([20,120,200,100])
const count = ref(1)
const setName = (n)=>{
  name.value = n
  pos.value[1]+=20
  count.value+=2
}
</script>

上面的代码在浏览器里就会有下图的显示效果。我们点击Canvas后,文案就会显示为“哈喽vue3真棒”,并且黑色方块和红色方块的距离也会变大。

image-20231019202534773

基于这个原理,我们其实可以做很多有意思的尝试,社区也也有越来越多开源的Vue 3的自定义渲染器,比如小程序跨端框架uni-app,Vugel可以使用Vue渲染Webgl等,你也可以动手多多体验。

比如下面的代码中,我们对three.js进行一个渲染的尝试。它的实现逻辑和Canvas比较类似,通过对于对象的维护和draw函数实现最终的绘制。在draw函数内部,我们调用three.js的操作方法去创建camera,sence,geometry等概念,最后对外暴露three.js的createApp函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { createRenderer } from '@vue/runtime-core'
import * as THREE from 'three'
import {nextTick} from '@vue/runtime-core'

let renderer

function draw(obj) {
const {camera,cameraPos, scene, geometry,geometryArg,material,mesh,meshY,meshX} = obj
if([camera,cameraPos, scene, geometry,geometryArg,material,mesh,meshY,meshX].filter(v=>v).length<9){
return
}
let cameraObj = new THREE[camera]( 40, window.innerWidth / window.innerHeight, 0.1, 10 )
Object.assign(cameraObj.position,cameraPos)

let sceneObj = new THREE[scene]()

let geometryObj = new THREE[geometry]( ...geometryArg)
let materialObj = new THREE[material]()

let meshObj = new THREE[mesh]( geometryObj, materialObj )
meshObj.rotation.x = meshX
meshObj.rotation.y = meshY
sceneObj.add( meshObj )
renderer.render( sceneObj, cameraObj );

}

const { createApp: originCa } = createRenderer({
insert: (child, parent, anchor) => {
if(parent.domElement){
draw(child)
}
},
createElement(type, isSVG, isCustom) {
return {
type
}
},
setElementText(node, text) {
},
patchProp(el, key, prev, next) {
el[key] = next
draw(el)
},
parentNode: node => node,
nextSibling: node => node,
createText: text => text,
remove:node=>node

});
function createApp(...args) {
const app = originCa(...args)
return {
mount(selector) {
renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
app.mount(renderer)
}
}
}
export { createApp }

然后我们在App.vue中,使用下面的代码渲染出一个立方体,并且通过ref响应式对象控制立方体偏移的监督,再通过setInterval实现立方体的动画,实现下图的反转效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
    <div
        camera="PerspectiveCamera"
        :cameraPos={z:1}
        scene="Scene"
        geometry="BoxGeometry"
        :geometryArg="[0.2,0.2,0.2]"
        material="MeshNormalMaterial"
        mesh="Mesh"
        :meshY="y"
        :meshX="x"
    >
    </div>

</template>

<script>
import {ref} from 'vue'
export default {
    setup(){
        const y = ref(0.3)
        const x = ref(0.3)
        setInterval(()=>{
            y.value+=0.3
            x.value+=0.5
        },100)
        return {y,x}
    }
}
</script>

image-20231019202723125

我们还可以在Canvas的封装上更进一步,并且实现对一些Canvas已有框架Pixi.js的封装,这样就可以通过Vue 3的响应式的开发方式,快速开发一个小游戏。

下面的代码中就是针对Pixi.js实现的封装函数,你可以看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import {Graphics} from "PIXI.js";

export const getNodeOps = (app) => {
return {
insert: (child, parent, anchor) => {
parent.addChild(child);
},

remove: (child) => {
const parent = child.parentNode;
if (parent) {
parent.removeChild(child);
}
},

createElement: (tag, isSVG, is) => {
let element;
if (tag === "Rectangle") {
// 创建一个矩形
element = new window.PIXI.Graphics();
element.lineStyle(4, 0xff3300, 1);
element.beginFill(0x66ccff);
element.drawRect(0, 0, 64, 64);
element.endFill();
element.x = 0;
element.y = 0;
// Opt-in to interactivity
element.interactive = true;

// Shows hand cursor
element.buttonMode = true;
} else if (tag === "Sprite") {
element = new window.PIXI.Sprite();
element.x = 0;
element.y = 0;
} else if (tag === "Container") {
element = new window.PIXI.Container();
element.x = 0;
element.y = 0;
}

return element;
},

createText: (text) => doc.createTextNode(text),

createComment: (text) => {
// console.log(text);
},

setText: (node, text) => {
node.nodeValue = text;
},

setElementText: (el, text) => {
el.textContent = text;
},

parentNode: (node) => node.parentNode,

nextSibling: (node) => node.nextSibling,

querySelector: (selector) => doc.querySelector(selector),

setScopeId(el, id) {
el.setAttribute(id, "");
},

cloneNode(el) {
return el.cloneNode(true);
},
};
};

Pixi中的属性修改可以使用下面的代码,判断x、y、width和on属性不同的操作,就是用响应式包裹了Pixi的对象。关于Vue 3和Pixi实现的代码效果,你可以在 GitHub 看到全部的源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export const patchProp = (
el,
key,
prevValue,
nextValue,
isSVG = false,
) => {
switch (key) {
case "x":
case "y":
case "width":
case "height":
el[key] = nextValue;
break;
case "on":
Object.keys(nextValue).forEach((eventName) => {
const callback = nextValue[eventName];
el.on(eventName, callback);
});
break;
case "texture":
let texture = PIXI.Texture.from(nextValue);
el.texture = texture;
break;
}
};

响应式:万能的面试题,怎么手写响应式系统

我们将手写一个迷你的Vue框架,实现Vue3的主要渲染和更新逻辑,项目就叫weiyouyi,你可以在 GitHub上 看到所有的核心代码。

响应式

在第三讲的Vue3新特性中,我们剖析了Vue3的功能结构,就是下图所示的Vue核心模块,可以看到,Vue3的组件之间是通过响应式机制来通知的,响应式机制可以自动收集系统中数据的依赖,并且在修改数据之后自动执行更新,极大提高开发的效率。

我们今天就要自己做一个迷你的响应式原型,希望你能通过自己手写,搞清楚响应式的实现原理。

image-20231019203239578

根据响应式组件通知效果可以知道, 响应式机制的主要功能就是,可以把普通的JavaScript对象封装成为响应式对象,拦截数据的获取和修改操作,实现依赖数据的自动化更新

所以,一个最简单的响应式模型,我们可以通过reactive或者ref函数,把数据包裹成响应式对象,并且通过effect函数注册回调函数,然后在数据修改之后,响应式地通知effect去执行回调函数即可。

整个流程这么概括地说,你估计不太理解,我们先通过一个简单的小例子直观感受一下响应式的效果。

Vue的响应式是可以独立在其他平台使用的。比如你可以新建test.js,使用下面的代码在node环境中使用Vue响应。以reactive为例,我们使用reactive包裹JavaScript对象之后,每一次对响应式对象counter的修改,都会执行effect内部注册的函数:

1
2
3
4
5
6
7
8
9
10
11
const {effect, reactive} = require('@vue/reactivity')

let dummy
const counter = reactive({ num1: 1, num2: 2 })
effect(() => {
dummy = counter.num1 + counter.num2
console.log(dummy)// 每次counter.num1修改都会打印日志
})
setInterval(()=>{
counter.num1++
},1000)

执行node test.js之后,你就可以看到effect内部的函数会一直调用,每次count.value修改之后都会执行。

看到这个API估计你有点疑惑,effect内部的函数式如何知道count已经变化了呢?

我们先来看一下响应式整体的流程图,上面的代码中我们使用reactive把普通的JavaScript对象包裹成响应式数据了。

所以,在effect中获取counter.num1和counter.num2的时候,就会触发counter的get拦截函数; get函数,会把当前的effect函数注册到一个全局的依赖地图中去。这样counter.num1在修改的时候, 就会触发set拦截函数,去依赖地图中找到注册的effect函数,然后执行

image-20231019203938447

具体是怎么实现的呢?我们从第一步把数据包裹成响应式对象开始。先看reactive的实现。

reactive

我们进入到src/reactivity目录中,新建reactive.spec.js,使用下面代码测试reactive的功能,能够在响应式数据ret更新之后,执行effect中注册的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { effect } from '../effect'
import { reactive } from '../reactive'

describe('测试响应式', () => {
test('reactive基本使用', () => {
const ret = reactive({ num: 0 })
let val
effect(() => {
val = ret.num
})
expect(val).toBe(0)
ret.num++
expect(val).toBe(1)
ret.num = 10
expect(val).toBe(10)
})
})

之前讲过在Vue3中,reactive是通过ES6中的Proxy特性实现的属性拦截,所以,在reactive函数中我们直接返回newProxy即可:

1
2
3
4
5
6
7
8
export function reactive(target) {
if (typeof target!=='object') {
console.warn(`reactive ${target} 必须是一个对象`);
return target
}

return new Proxy(target, mutableHandlers);
}

可以看到, **下一步我们需要实现的就是Proxy中的处理方法mutableHandles**。

这里会把Proxy的代理配置抽离出来单独维护,是因为,其实Vue3中除了reactive还有很多别的函数需要实现,比如只读的响应式数据、浅层代理的响应式数据等,并且reactive中针对ES6的代理也需要单独的处理。

这里我们只处理js中对象的代理设置:

1
const proxy = new Proxy(target, mutableHandlers)

mutableHandles

好,看回来,我们剖析mutableHandles。它要做的事就是配置Proxy的拦截函数,这里我们只拦截get和set操作,进入到baseHandlers.js文件中。

我们使用createGetter和createSetters来创建set和get函数,mutableHandles就是配置了set和get的对象返回。

  • get中直接返回读取的数据,这里的Reflect.get和target[key]实现的结果是一致的;并且返回值是对象的话,还会嵌套执行reactive,并且调用track函数收集依赖。
  • set中调用trigger函数,执行track收集的依赖。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const get = createGetter();
const set = createSetter();

function createGetter(shallow = false) {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
track(target, "get", key)
if (isObject(res)) {
// 值也是对象的话,需要嵌套调用reactive
// res就是target[key]
// 浅层代理,不需要嵌套
return shallow ? res : reactive(res)
}
return res
}
}

function createSetter() {
return function set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
// 在触发 set 的时候进行触发依赖
trigger(target, "set", key)
return result
}
}
export const mutableHandles = {
get,
set,
};

我们先看get的关键部分,track函数是怎么完成依赖收集的。

track

具体写代码之前,把依赖收集和执行的原理我们梳理清楚,看下面的示意图:

image-20231019205324607

在track函数中,我们可以使用一个巨大的tragetMap去存储依赖关系。 map的key是我们要代理的target对象,值还是一个depsMap,存储这每一个key依赖的函数,每一个key都可以依赖多个effect。上面的代码执行完成,depsMap中就有了num1和num2两个依赖。

而依赖地图的格式,用代码描述如下:

1
2
3
4
5
6
7
8
9
targetMap = {
target: {
key1: [回调函数1,回调函数2],
key2: [回调函数3,回调函数4],
} ,
target1: {
key3: [回调函数5]
}
}

好,有了大的设计思路,我们来进行具体的实现,在reactive下新建effect.js。

由于target是对象,所以必须得用map才可以把target作为key来管理数据,每次操作之前需要做非空的判断。最终把activeEffect存储在集合之中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const targetMap = new WeakMap()

export function track(target, type, key) {

// console.log(`触发 track -> target: ${target} type:${type} key:${key}`)

// 1. 先基于 target 找到对应的 dep
// 如果是第一次的话,那么就需要初始化
// {
// target1: {//depsmap
// key:[effect1,effect2]
// }
// }
let depsMap = targetMap.get(target)
if (!depsMap) {
// 初始化 depsMap 的逻辑
// depsMap = new Map()
// targetMap.set(target, depsMap)
// 上面两行可以简写成下面的
targetMap.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
deps = new Set()
}
if (!deps.has(activeEffect) && activeEffect) {
// 防止重复注册
deps.add(activeEffect)
}
depsMap.set(key, deps)
}

get中关键的收集依赖的track函数我们已经讲完了,继续看set中关键的trigger函数。

trigger

有了上面targetMap的实现机制, trigger函数实现的思路就是从targetMap中,根据target和key找到对应的依赖函数集合deps,然后遍历deps执行依赖函数

看实现的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export function trigger(target, type, key) {
// console.log(`触发 trigger -> target: type:${type} key:${key}`)
// 从targetMap中找到触发的函数,执行他
const depsMap = targetMap.get(target)
if (!depsMap) {
// 没找到依赖
return
}
const deps = depsMap.get(key)
if (!deps) {
return
}
deps.forEach((effectFn) => {

if (effectFn.scheduler) {
effectFn.scheduler()
} else {
effectFn()
}
})

}

可以看到执行的是effect的scheduler或者run函数,这是因为我们需要在effect函数中把依赖函数进行包装,并对依赖函数的执行时机进行控制,这是一个小的设计点。

effect

然后我们来实现effect函数。

下面的代码中,我们把传递进来的fn函数通过effectFn函数包裹执行,在effectFn函数内部,把函数赋值给全局变量activeEffect;然后执行fn()的时候,就会触发响应式对象的get函数,get函数内部就会把activeEffect存储到依赖地图中,完成依赖的收集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function effect(fn, options = {}) {
// effect嵌套,通过队列管理
const effectFn = () => {
try {
activeEffect = effectFn
//fn执行的时候,内部读取响应式数据的时候,就能在get配置里读取到activeEffect
return fn()
} finally {
activeEffect = null
}
}
if (!options.lazy) {
//没有配置lazy 直接执行
effectFn()
}
effectFn.scheduler = options.scheduler // 调度时机 watchEffect回用到
return effectFn

}

effect传递的函数,比如可以通过传递lazy和scheduler来控制函数执行的时机,默认是同步执行。

scheduler存在的意义就是我们可以手动控制函数执行的时机,方便应对一些性能优化的场景,比如数据在一次交互中可能会被修改很多次,我们不想每次修改都重新执行依次effect函数,而是合并最终的状态之后,最后统一修改一次。

scheduler怎么用你可以看下面的代码,我们使用数组管理传递的执行任务,最后使用Promise.resolve只执行最后一次,这也是Vue中watchEffect函数的大致原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const obj = reactive({ count: 1 })
effect(() => {
console.log(obj.count)
}, {
// 指定调度器为 queueJob
scheduler: queueJob
})
// 调度器实现
const queue: Function[] = []
let isFlushing = false
function queueJob(job: () => void) {
if (!isFlushing) {
isFlushing = true
Promise.resolve().then(() => {
let fn
while(fn = queue.shift()) {
fn()
}
})
}
}

好了,绕了这么一大圈终于执行完了函数,估计你也看出来了封装了很多层。

之所以封装这么多层就是因为,Vue的响应式本身有很多的横向扩展,除了响应式的封装,还有只读的拦截、浅层数据的拦截等等,这样,响应式系统本身也变得更加灵活和易于扩展,我们自己在设计公用函数的时候也可以借鉴类似的思路。

另一个选择ref函数

有了track和trigger的逻辑之后,我们用ref函数实现就变得非常简单了。

ref的执行逻辑要比reactive要简单一些,不需要使用Proxy代理语法,直接使用对象语法的getter和setter配置,监听value属性即可。

看下面的实现,在ref函数返回的对象中,对象的get value方法,使用track函数去收集依赖,set value方法中使用trigger函数去触发函数的执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export function ref(val) {
if (isRef(val)) {
return val
}
return new RefImpl(val)
}
export function isRef(val) {
return !!(val && val.__isRef)
}

// ref就是利用面向对象的getter和setters进行track和trigget
class RefImpl {
constructor(val) {
this.__isRef = true
this._val = convert(val)
}
get value() {
track(this, 'value')
return this._val
}

set value(val) {
if (val !== this._val) {
this._val = convert(val)
trigger(this, 'value')
}
}
}

// ref也可以支持复杂数据结构
function convert(val) {
return isObject(val) ? reactive(val) : val
}

你能很直观地看到,ref函数实现的相对简单很多,只是利用面向对象的getter和setter拦截了value属性的读写,这也是为什么我们需要操作ref对象的value属性的原因。

值得一提的是,ref也可以包裹复杂的数据结构,内部会直接调用reactive来实现,这也解决了大部分同学对ref和reactive使用时机的疑惑,现在你可以全部都用ref函数,ref内部会帮你调用reactive。

computed

Vue中的computed计算属性也是一种特殊的effect函数,我们可以新建computed.spec.js来测试computed函数的功能, computed可以传递一个函数或者对象,实现计算属性的读取和修改。比如说可以这么用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import {  ref } from '../ref'
import { reactive } from '../reactive'
import { computed } from '../computed'

describe('computed测试',()=>{
it('computed基本使用',()=>{
const ret = reactive({ count: 1 })
const num = ref(2)
const sum = computed(() => num.value + ret.count)
expect(sum.value).toBe(3)

ret.count++
expect(sum.value).toBe(4)
num.value = 10
expect(sum.value).toBe(12)
})
it('computed属性修改',()=>{
const author = ref('大圣')
const course = ref('玩转Vue3')
const title = computed({
get(){
return author.value+":"+course.value
},
set(val){
[author.value,course.value] = val.split(':')
}
})
expect(title.value).toBe('大圣:玩转Vue3')

author.value="winter"
course.value="重学前端"
expect(title.value).toBe('winter:重学前端')
//计算属性赋值
title.value = '王争:数据结构与算法之美'
expect(author.value).toBe('王争')
expect(course.value).toBe('数据结构与算法之美')

})
})

怎么实现呢?我们新建computed函数,看下面的代码,我们拦截computed的value属性,并且定制了effect的lazy和scheduler配置,computed注册的函数就不会直接执行,而是要通过scheduler函数中对_dirty属性决定是否执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
export function computed(getterOrOptions) {
// getterOrOptions可以是函数,也可以是一个对象,支持get和set
// 还记得清单应用里的全选checkbox就是一个对象配置的computed
let getter, setter
if (typeof getterOrOptions === 'function') {
getter = getterOrOptions
setter = () => {
console.warn('计算属性不能修改')
}
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
return new ComputedRefImpl(getter, setter)
}
class ComputedRefImpl {
constructor(getter, setter) {
this._setter = setter
this._val = undefined
this._dirty = true
// computed就是一个特殊的effect,设置lazy和执行时机
this.effect = effect(getter, {
lazy: true,
scheduler: () => {
if (!this._dirty) {
this._dirty = true
trigger(this, 'value')
}
},
})
}
get value() {
track(this, 'value')
if (this._dirty) {
this._dirty = false
this._val = this.effect()
}
return this._val
}
set value(val) {
this._setter(val)
}
}

总结

响应式的主要功能就是可以把普通的JavaScript对象封装成为响应式对象, 在读取数据的时候通过track收集函数的依赖关系,把整个对象和effect注册函数的依赖关系全部存储在一个依赖图中

定义的dependsMap是一个巨大的Map数据,effect函数内部读取的数据都会存储在dependsMap中,数据在修改的时候,通过查询dependsMap,获得需要执行的函数,再去执行即可。

dependsMap中存储的也不是直接存储effect中传递的函数,而是包装了一层对象对这个函数的执行实际进行管理,内部可以通过active管理执行状态,还可以通过全局变量shouldTrack控制监听状态,并且执行的方式也是判断scheduler和run方法,实现了对性能的提升。

我们在日常项目开发中也可以 借鉴响应式的处理思路,使用通知的机制,来调用具体数据的操作和更新逻辑,灵活使用effect、ref、reactive等函数把常见的操作全部变成响应式数据处理,会极大的提高我们开发的体验和效率。

运行时:Vue在浏览器里是怎么跑起来的?

那今天我就跟你聊一下Vue在浏览器里是如何运行的,照例我们还是对着Vue 3的源码来学习,不过源码复杂,为了帮助你理解主要逻辑,我会直接把源码简化再演示,当然怎么简化源码的一些小技巧也会顺便分享给你。

好了废话不多说,我们马上开始。前端框架需要处理的最核心的两个流程,就是首次渲染和数据更新后的渲染。先来看首次渲染的源码。演示代码会用Vue 3的实际代码,你也可以在 weiyouyi 项目中看到我们课程的mini版本代码。

首次渲染

我们知道,想要启动一个Vue项目,只需要从Vue中引入createApp,传入App组件,并且调用createApp返回的App实例的mount方法,就实现了项目的启动。这个时候Vue也完成了首次渲染,代码逻辑如下:

image-20231022154631908

所以 createApp 就是项目的初始化渲染入口。

但是这段简单的代码是怎么完成初始化渲染的呢?我们可以在Vue中的runtime-dom中看到createApp的定义,你可以打开 GitHub链接 查看。

这里就有一个看代码的小技巧,分享给你,我们首次查看源码的时候,可以先把一些无用的信息删除,方便自己梳理主体的逻辑。看Vue代码,和今天主题无关的无用信息有哪些,__COMPAT__代码是用来兼容Vue 2的,__DEV__代码是用来调试的,我们可以把这些代码删除之后,得到下面的简化版createApp源码。

再看思路就比较清晰了。我们使用ensureRenderer返回的对象去创建app,并且重写了app.mount方法;在mount方法内部,我们查找mount传递的DOM元素,并且调用ensureRenderer返回的mount方法,进行初始化渲染。如下图所示:

image-20231022203941300

之前我们讲过要会TypeScript,这时你就能感受到TypeScript的好处了,现在即使我们不知道app.mount是什么逻辑,也能知道这个函数的参数只能是Element、ShadowRoot或者string三者之一,也就很好理解内部的normalizeContainer就是把你传递的参数统一变为浏览器的DOM元素,Typescript类型带来的好处,我们在读源码的时候会一直感受得到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
const { mount } = app
// 重写mount
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
const container = normalizeContainer(containerOrSelector)
if (!container) return

const component = app._component
if (!isFunction(component) && !component.render && !component.template) {
component.template = container.innerHTML
}
container.innerHTML = ''
const proxy = mount(container, false, container instanceof SVGElement)
if (container instanceof Element) {
container.removeAttribute('v-cloak')
container.setAttribute('data-v-app', '')
}
return proxy
}
return app
})
function normalizeContainer(container){
if (isString(container)) {
const res = document.querySelector(container)
}
return container
}

我们继续深入了解ensureRenderer方法,以及ensureRenderer方法返回的createApp方法。

这里ensureRenderer函数,内部通过createRenderer函数,创建了一个浏览器的渲染器,并且缓存了渲染器renderer,这种使用闭包做缓存的方式,你在日常开发中也可以借鉴这种思路。

createRenderer函数,我们在自定义渲染器那一讲里学到过,传递的rendererOptions就是浏览器里面标签的增删改查API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 浏览器dom操作
import { nodeOps } from './nodeOps'
// 浏览器dom属性更新
import { patchProp } from './patchProp'
import { createRenderer } from '@vue/runtime-core'
const rendererOptions = extend({ patchProp }, nodeOps)

let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer

function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
}

可以看到,createRenderer函数传递的参数是nodeOps和patchProp的合并对象。

我们继续进入nodeOps和pathProp也可以看到下面的代码,写了很多方法。通过ensureRenderer存储这些操作方法后,createApp内部就可以脱离具体的渲染平台了,这也是Vue 3实现跨端的核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
},
createElement: (tag, isSVG, is, props): Element => {
const el = isSVG
? doc.createElementNS(svgNS, tag)
: doc.createElement(tag, is ? { is } : undefined)

if (tag === 'select' && props && props.multiple != null) {
;(el as HTMLSelectElement).setAttribute('multiple', props.multiple)
}
return el
},

createText: text => doc.createTextNode(text),

createComment: text => doc.createComment(text),

setText: (node, text) => {
node.nodeValue = text
},

setElementText: (el, text) => {
el.textContent = text
},
parentNode: node => node.parentNode as Element | null,
nextSibling: node => node.nextSibling,
querySelector: selector => doc.querySelector(selector),
...
}

然后我们就需要进入到rumtime-core模块去看下createRenderer是如何工作的。你可以在这个 GitHub链接 内看到createRenderer的代码逻辑。当然源码比较复杂,我们照样需要简化一下。

createRenderer是调用baseCreateRenderer创建的,baseCreateRenderer函数内部有十几个函数,代码行数合计2000行左右,这也是我们学习Vue源码最复杂的一个函数了。按前面简化源码的思路,先把工具函数的实现折叠起来,精简之后代码主要逻辑其实很简单。

我们一起来看。

首先获取了平台上所有的insert、remove函数,这些函数都是nodeOps传递进来的,然后定义了一些列patch、mount、unmount函数,通过名字我们不难猜出,这就是Vue中更新、渲染组件的工具函数,比如mountElement就是渲染DOM元素、mountComponent就是渲染组件updateComponent就是更新组件。这部分的简化代码,你也可以在 weiyouyi 项目中查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
return baseCreateRenderer<HostNode, HostElement>(options)
}

function baseCreateRenderer(){
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
createComment: hostCreateComment,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
setScopeId: hostSetScopeId = NOOP,
cloneNode: hostCloneNode,
insertStaticContent: hostInsertStaticContent
} = options
const patch = ()=>... //一个函数
const processText = ()=>...
const processCommentNode = ()=>...
const processElement = ()=>...
const mountElement = ()=>...
const mountChildren = ()=>...
const patchElement = ()=>...
const patchBlockChildren = ()=>...
const patchProps = ()=>...
const processComponent = ()=>...
const mountComponent = ()=>...
const updateComponent = ()=>...
const setupRenderEffect = ()=>...
const patchChildren = ()=>...
const patchKeyedChildren = ()=>...
const unmount = ()=>...
const unmountComponent = ()=>...
const unmountComponent = ()=>...
const unmountComponent = ()=>...
const unmountComponent = ()=>...
const render: RootRenderFunction = (vnode, container, isSVG) => {
if (vnode == null) {
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
patch(container._vnode || null, vnode, container, null, null, null, isSVG)
}
flushPostFlushCbs()
container._vnode = vnode
}
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
}
}

整个createApp函数的执行逻辑如下图所示:

img

最后返回的createApp方法,实际上是createAPI的返回值,并且给createAPI传递了render方法。render方法内部很简单,就是判断container容器上有没有_vnode属性,如果有的话就执行unmout方法,没有的话就执行patch方法,最后把vnode信息存储在container._vnode上。

那createAppAPI又做了什么呢?我们继续进入createAppAPI源码,看下面的代码。内部创建了一个app对象,app上注册了我们熟悉的use、component和mount等方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
export function createAppAPI<HostElement>(
render: RootRenderFunction,
hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
const context = createAppContext()
let isMounted = false

const app: App = (context.app = {
_context: context,
_instance: null,
use(plugin: Plugin, ...options: any[]) ,
component(name: string, component?: Component): any {
if (!component) {
return context.components[name]
}
context.components[name] = component
return app
},
directive(name: string, directive?: Directive)
mount(
rootContainer: HostElement,
isHydrate?: boolean,
isSVG?: boolean
): any {
if (!isMounted) {
const vnode = createVNode(
rootComponent as ConcreteComponent,
rootProps
)
vnode.appContext = context
// 核心的逻辑
if (isHydrate && hydrate) {
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
} else {
render(vnode, rootContainer, isSVG)
}
return getExposeProxy(vnode.component!) || vnode.component!.proxy
}
},

provide(key, value) {
context.provides[key as string] = value
return app
}
})

return app
}
}

可以看到mount内部执行的是传递进来的render方法,也就是上面的render方法。container 就是我们app.mount中传递的DOM元素,对DOM元素进行处理之后,执行patch函数实现整个应用的加载。

所以我们的下一个任务就是需要搞清楚patch函数的执行逻辑。

patch 函数

patch传递的是container._vnode,也就是上一次渲染缓存的vnode、本次渲染组件的vnode,以及容器container。

下面就是patch函数的代码,核心代码我添加了注释。其中n1是上次渲染的虚拟DOM,n2是下次要渲染的虚拟DOM。

首先可以把n1和n2做一次判断,如果虚拟DOM的节点类型不同,就直接unmount之前的节点。因为比如之前是Button组件,现在要渲染Container组件,就没有计算diff的必要,直接把Button组件销毁再渲染Container即可。

如果n1和n2类型相同,比如都是Button组件或者都是div标签,我们需要判断具体的类型再去执行不同的函数,比如processText、processFragment、processElement以及processComponent等函数。

看第55行,这里的ShapeFlags用到了位运算的知识,我们后面会通过刷算法题的方式介绍,暂时我们只需要知道,ShapeFlags可以帮助我们快速判断需要操作的类型就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
// 两次虚拟dom完全一样 啥也不用干
if (n1 === n2) {
return
}
// 虚拟dom节点类型不一样, unmount老的虚拟dom,并且n1赋值null
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
// n2是要渲染的虚拟dom,我们获取type,ref和shapeFlag
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
// 文本
processText(n1, n2, container, anchor)
break
case Comment:
// 注释
processCommentNode(n1, n2, container, anchor)
break
case Static:
// 静态节点
if (n1 == null) {
mountStaticNode(n2, container, anchor, isSVG)
} else if (__DEV__) {
patchStaticNode(n1, n2, container, isSVG)
}
break
case Fragment:
processFragment(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
break
default:
// 运运算判断操作类型
if (shapeFlag & ShapeFlags.ELEMENT) {
// html标签
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 组件
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
;(type as typeof TeleportImpl).process(
n1 as TeleportVNode,
n2 as TeleportVNode,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
internals
)
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
;(type as typeof SuspenseImpl).process(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
internals
)
} else if (__DEV__) {
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}

// set ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
}

代码的整体执行逻辑如下图所示:

image-20231022204054033

我们首次渲染的App是一个组件,所以要执行的就是processComponent方法。

processComponent方法

那我们继续进入到processComponent代码内部,看下面的代码。首次渲染的时候,n1就是null,所以会执行mountComponent;如果是更新组件的时候,n1就是上次渲染的vdom,需要执行updateComponent。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
n2.slotScopeIds = slotScopeIds
if (n1 == null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).activate(
n2,
container,
anchor,
isSVG,
optimized
)
} else {
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
}
} else {
updateComponent(n1, n2, optimized)
}
}

updateComponent是虚拟DOM的逻辑,我们会在下一讲详细剖析,这一讲主要讲首次渲染的过程。

所以我们进入mountComponent函数中,可以看到mountComponent函数内部会对组件的类型进行一系列的判断,还有一些对Vue 2的兼容代码,核心的渲染逻辑就是setupComponent函数和setupRenderEffect函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import {setupComponent} from './component'
const mountComponent: MountComponentFn = (
) => {
// 2.x compat may pre-creaate the component instance before actually
// mounting
const compatMountInstance =
__COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
const instance: ComponentInternalInstance =
compatMountInstance ||
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense
))

// resolve props and slots for setup context
if (!(__COMPAT__ && compatMountInstance)) {

setupComponent(instance)

}
(
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
)

if (__DEV__) {
popWarningContext()
endMeasure(instance, `mount`)
}
}

setupComponent和setupRenderEffect,它俩又做了点什么呢?可以参考下面的示意图这两个实现组件首次渲染的函数:

image-20231022204124325

setupComponent

首先看setupComponent,要完成的就是执行我们写的setup函数。

可以看到,内部先初始化了props和slots,并且执行setupStatefulComponent创建组件,而这个函数内部从component中获取setup属性,也就是script setup内部实现的函数,就进入到我们组件内部的reactive、ref等函数实现的逻辑了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false
) {
isInSSRComponentSetup = isSSR

const { props, children } = instance.vnode
const isStateful = isStatefulComponent(instance)
initProps(instance, props, isStateful, isSSR)
initSlots(instance, children)

const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
isInSSRComponentSetup = false
return setupResult
}

function setupStatefulComponent(
instance: ComponentInternalInstance,
isSSR: boolean
) {
const Component = instance.type as ComponentOptions
// 执行setup
const { setup } = Component
if (setup) {
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)

setCurrentInstance(instance)
pauseTracking()
const setupResult = callWithErrorHandling(
setup,
instance,
ErrorCodes.SETUP_FUNCTION,
[instance.props, setupContext]
)
if (isPromise(setupResult)) {
setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
} else {
handleSetupResult(instance, setupResult, isSSR)
}
} else {
finishComponentSetup(instance, isSSR)
}
}

export function callWithErrorHandling(
fn: Function,
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[]
) {
let res
try {
res = args ? fn(...args) : fn()
} catch (err) {
handleError(err, instance, type)
}
return res
}

setupRenderEffect

另一个setupRenderEffect函数,就是为了后续数据修改注册的函数,我们先梳理一下核心的实现逻辑。

组件首次加载会调用patch函数去初始化子组件,注意setupRenderEffect本身就是在patch函数内部执行的,所以这里就会递归整个虚拟DOM树,然后触发生命周期mounted,完成这个组件的初始化。

页面首次更新结束后,setupRenderEffect不仅实现了组件的递归渲染,还注册了组件的更新机制。

在下面的核心代码中,我们通过ReactiveEffect创建了effect函数,这个概念上一讲我们手写过,然后执行instance.update赋值为effect.run方法,这样结合setup内部的ref和reactive绑定的数据,数据修改之后,就会触发update方法的执行,内部就会componentUpdateFn,内部进行递归的patch调用执行每个组件内部的update方法实现组件的更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (!instance.isMounted) {
patch(
null,
subTree,
container,
anchor,
instance,
parentSuspense,
isSVG
)
}else{
// updateComponent
}
// create reactive effect for rendering
const effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(instance.update),
instance.scope // track it in component's effect scope
)

const update = (instance.update = effect.run.bind(effect) as SchedulerJob)
update.id = instance.uid

update()

总结

img

Vue通过createApp创建应用,并且执行返回的mount方法实现在浏览器中的挂载,在createApp中,通过传递浏览器平台的操作方法nodeOps创建了浏览器的渲染器renderer。

首次执行Vue项目的时候,通过patch实现组件的渲染,patch函数内部根据节点的不同类型,去分别执行processElement、processComponent、processText等方法去递归处理不同类型的节点,最终通过setupComponent执行组件的setup函数,setupRenderEffect中使用响应式的effect函数监听数据的变化。

你可以先看我们实现的迷你版本项目weiyouyi,然后再去看Vue 3中实际的代码,可以学习代码中很多优秀的设计思路,比如createRenderer中使用闭包作为缓存、使用位运算来提高组件类型的判断效率等。学习优秀框架中的代码设计,这对我们日常开发项目的代码质量也有很好的提高作用。

虚拟DOM(上):如何通过虚拟DOM更新页面?

上一讲我们主要介绍了Vue项目的首次渲染流程,在mountComponent中注册了effect函数,这样,在组件数据有更新的时候,就会通知到组件的update方法进行更新。

Vue中组件更新的方式也是使用了响应式+虚拟DOM的方式,这个我们在第一讲中有介绍过Vue 1、Vue 2和Vue 3中更新方式的变化,今天我们就来详细剖析一下Vue组件内部如何通过虚拟DOM更新页面的代码细节。

Vue虚拟DOM执行流程

我们从虚拟DOM在Vue的执行流程开始讲起。在Vue中,我们使用虚拟DOM来描述页面的组件,比如下面的template虽然格式和HTML很像,但是在Vue的内部会解析成JavaScript函数,这个函数就是用来返回虚拟DOM:

1
2
3
4
<div id="app">
<p>hello world</p>
<Rate :value="4"></Rate>
</div>

上面的template会解析成下面的函数,最终返回一个JavaScript的对象能够描述这段HTML:

1
2
3
4
5
6
function render(){
return h('div',{id:"app"},children:[
h('p',{},'hello world'),
h(Rate,{value:4}),
])
}

知道虚拟DOM是什么之后,那么它是怎么创建的呢?

DOM的创建

我们简单回忆上一讲介绍的 mount函数,在代码中,我们使用createVNode函数创建项目的虚拟DOM,可以看到 Vue内部的虚拟DOM,也就是vnode,就是一个对象,通过type、props、children等属性描述整个节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const vnode = createVNode(
rootComponent as ConcreteComponent,
rootProps
)
function _createVNode() {

// 处理属性和class
if (props) {
...
}

// 标记vnode信息
const shapeFlag = isString(type)
? ShapeFlags.ELEMENT
: __FEATURE_SUSPENSE__ && isSuspense(type)
? ShapeFlags.SUSPENSE
: isTeleport(type)
? ShapeFlags.TELEPORT
: isObject(type)
? ShapeFlags.STATEFUL_COMPONENT
: isFunction(type)
? ShapeFlags.FUNCTIONAL_COMPONENT
: 0

return createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
isBlockNode,
true
)
}

function createBaseVNode(type,props,children,...){
const vnode = {
type,
props,
key: props && normalizeKey(props),
ref: props && normalizeRef(props),
children,
shapeFlag,
patchFlag,
dynamicProps,
...
} as VNode
// 标准化子节点
if (needFullChildrenNormalization) {
normalizeChildren(vnode, children)
} else if (children) {
vnode.shapeFlag |= isString(children)
? ShapeFlags.TEXT_CHILDREN
: ShapeFlags.ARRAY_CHILDREN
}
return vnode
}componentUpdateFn

createVNode负责创建Vue中的虚拟DOM,而上一讲中我们讲过mount函数的核心逻辑就是使用setupComponent执行我们写的<script setup>,使用setupRenderEffect监听组件的数据变化。所以我们来到setupRenderEffect函数中,去完整地剖析Vue中虚拟DOM的更新逻辑。

我们给组件注册了update方法,这个方法使用effect包裹后,当组件内的refreactive包裹的响应式数据变化的时候就会执行update方法,触发组件内部的更新机制。

看下面的代码,在setupRenderEffect内部的componentUpdateFn中,updateComponentPreRenderer更新了属性和slots,并且调用renderComponentRoot函数创建新的子树对象nextTree,然后内部依然是调用patch函数。

可以看到, Vue源码中的实现首次渲染和更新的逻辑都写在一起,我们在递归的时候如果对一个标签实现更新和渲染,就可以用一个函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const componentUpdateFn = ()=>{
if (!instance.isMounted) {
//首次渲染
instance,
parentSuspense,
isSVG
)
。。。
}else{
let { next, bu, u, parent, vnode } = instance
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
const nextTree = renderComponentRoot(instance)
patch(
prevTree,
nextTree,
// parent may have changed if it's in a teleport
hostParentNode(prevTree.el!)!,
// anchor may have changed if it's in a fragment
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
)
}
}

// 注册effect函数
const effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(instance.update),
instance.scope // track it in component's effect scope
)
const update = (instance.update = effect.run.bind(effect) as S chedulerJob)
update()

const updateComponentPreRender = (
instance: ComponentInternalInstance,
nextVNode: VNode,
optimized: boolean
) => {
nextVNode.component = instance
const prevProps = instance.vnode.props
instance.vnode = nextVNode
instance.next = null
updateProps(instance, nextVNode.props, prevProps, optimized)
updateSlots(instance, nextVNode.children, optimized)

pauseTracking()
// props update may have triggered pre-flush watchers.
// flush them before the render update.
flushPreFlushCbs(undefined, instance.update)
resetTracking()
}

比较关键的就是上面代码中32-39行的 effect函数,负责注册组件,这个函数也是Vue组件更新的入口函数。

patch函数

数据更新之后就会执行patch函数,下图就是patch函数执行的逻辑图:

image-20231022204124325

在patch函数中,会针对不同的组件类型执行不同的函数,组件我们会执行processComponent,HTML标签我们会执行processElement

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function path(n1, n2, container){
const { type, shapeFlag } = n2
switch (type) {
case Text:
processText(n1, n2, container)
break
// 还有注释,fragment之类的可以处理,这里忽略
default:
// 通过shapeFlag判断类型
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor)
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container)
}
}

}

function processComponent(n1, n2, container) {
// 老规矩,么有n1就是mount
if (!n1) {
// 初始化 component
mountComponent(n2, container)
} else {
updateComponent(n1, n2, container)
}
}

由于更新之后不是首次渲染了,patch函数内部会执行updateComponent,看下面的updateComponent函数内部,shouldUpdateComponent会判断组件是否需要更新,实际执行的是instance.update

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const instance = (n2.component = n1.component)!
if (shouldUpdateComponent(n1, n2, optimized)) {

// normal update
instance.next = n2
// in case the child component is also queued, remove it to avoid
// double updating the same child component in the same flush.
invalidateJob(instance.update)
// instance.update is the reactive effect.
instance.update()

} else {
// no update needed. just copy over properties
n2.component = n1.component
n2.el = n1.el
instance.vnode = n2
}

组件的子元素是由HTML标签和组件构成,组件内部的递归处理最终也是对HTML标签的处理,所以,最后组件的更新都会进入到processElement内部的patchElement函数中。

patchElement函数

在函数patchElement中我们主要就做两件事,更新节点自己的属性和更新子元素。

节点自身属性的更新

先看自身属性的更新,这里就能体现出 Vue 3中性能优化的思想,通过patchFlag可以做到按需更新

  • 如果标记了FULL_PROPS,就直接调用patchProps。
  • 如果标记了CLASS,说明节点只有class属性是动态的,其他的style等属性都不需要进行判断和DOM操作。

这样就极大的优化了属性操作的性能。

内部执行hostPatchProp进行实际的DOM操作,你还记得上一讲中hostPatchProp是从nodeOps中定义的吗,其他动态属性STYLE、TEXT等等也都是一样的逻辑。Vue 3的虚拟DOM真正做到了按需更新,这也是相比于React的一个优势。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
const el = (n2.el = n1.el!)
let { patchFlag, dynamicChildren, dirs } = n2
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS

const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ

// full diff
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
)

if (patchFlag > 0) {

if (patchFlag & PatchFlags.FULL_PROPS) {
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
)
} else {
// class是动态的
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, isSVG)
}
}

// style样式是动态的
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
}

// 属性需要diff
if (patchFlag & PatchFlags.PROPS) {
//
const propsToUpdate = n2.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
// #1471 force patch value
if (next !== prev || key === 'value') {
hostPatchProp(
el,
key,
prev,
next,
isSVG,
n1.children as VNode[],
parentComponent,
parentSuspense,
unmountChildren
)
}
}
}
}
//文本是动态的
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
}
}

子元素的更新

而子元素的更新是patchChildren 函数负责的,这个函数也是虚拟DOM中难度最高的一个函数,搞懂它还需要我们下一讲中介绍的算法知识,今天我们就先理解它主要的实现思路。

首先我们把子元素分成了文本、数组和空三个状态,新老子元素分别是这三种状态的一个,构成了不同的执行逻辑。这样patchChildren内部大致有五种情况需要处理:

  • 如果新的子元素是空, 老的子元素不为空,直接卸载unmount即可。
  • 如果新的子元素不为空,老的子元素是空,直接创建加载即可。
  • 如果新的子元素是文本,老的子元素如果是数组就需要全部unmount,是文本的话就需要执行hostSetElementText。
  • 如果新的子元素是数组,比如是使用v-for渲染出来的列表,老的子元素如果是空或者文本,直接unmout后,渲染新的数组即可。

最复杂的情况就是新的子元素和老的子元素都是数组。

最朴实无华的思路就是把老的子元素全部unmount,新的子元素全部mount,这样虽然可以实现功能,但是没法复用已经存在的DOM元素,比如我们只是在数组中间新增了一个数据,全部DOM都销毁就有点太可惜了。

所以,我们需要判断出可以复用的DOM元素,如果一个虚拟DOM没有改动或者属性变了,不需要完全销毁重建,而是更新一下属性,最大化减少DOM的操作,这个任务就会交给patchKeyedChildren函数去完成。

patchKeyedChildren函数,做的事情就是尽可能高效地把老的子元素更新成新的子元素,如何高效复用老的子元素中的DOM元素是patchKeyedChildren函数的难点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
const patchChildren: PatchChildrenFn = (
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized = false
) => {
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children

const { patchFlag, shapeFlag } = n2
// fast path
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// this could be either fully-keyed or mixed (some keyed some not)
// presence of patchFlag means children are guaranteed to be arrays
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// unkeyed
patchUnkeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
return
}
}

// children has 3 possibilities: text, array or no children.
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// text children fast path
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
}
if (c2 !== c1) {
hostSetElementText(container, c2 as string)
}
} else {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// prev children was array
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// two arrays, cannot assume anything, do full diff
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// no new children, just unmount old
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
}
} else {
// prev children was text OR null
// new children is array OR null
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(container, '')
}
// mount new if array
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
}
}
}

上面的代码执行逻辑如下图所示,根据flags判断子元素的类型后,执行不同的操作函数:

image-20231022212510444

patchChildren

最后就剩下patchChildren的实现了,这也是各类虚拟DOM框架中最难实现的函数,我们需要实现 一个高效的更新算法,能够使用尽可能少的更新次数,来实现从老的子元素到新的子元素的更新

举个例子,类似体育课站队的时候,大家一开始站一排,但是顺序是乱的,我们需要尽快把队伍按照个头左低右高排列。

在React中,这种场景的处理逻辑是先进行循环,使用的是单侧插入的算法,我们在排队的时候挨个对比,如果你站我右边,并且个头比我高一点,说明咱俩的相对位置和最终队伍的位置是一致的,暂时不需要变化,如果你比我个头矮,就需要去我左边找到一个正确的位置插队进去。

由于都只向单侧插入,最后我们就会把所有的节点移动到正确的位置之上,这就是React15框架内虚拟节点diff的逻辑,初步实现了DOM的复用;而Vue 2借鉴了snabbdom的算法,在此基础上做了第一层双端对比的优化。

首先Web场景之下对一个数组元素的操作,很少有直接全部替换的, 比如我们操作一个表格,大概率是更关心表格某一行的一个字段、新增一行、删除一行,或者是对表格某个字段进行排序,所以我们可以从纯算法的场景之中加入实际应用的场景

如果我们只是在表格里新增一行,那么可以不要一开始就开始循环,而是可以先进行节点的预判。

比如,在下面的例子中,新的节点就是在老的节点中新增和删除了几个元素,我们在循环之前,先进行头部元素的判断。在这个例子里,可以预判出头部元素的a、b、c、d是一样的节点,说明节点不需要重新创建,我们只需要进行属性的更新,然后进行队尾元素的预判,可以判断出g和元素也是一样的:

1
2
3
a b c d e f g h
a b c d i f j g h

这样我们虚拟DOM diff的逻辑就变成了下面的结构, 现在只需要比较ef和ifg的区别:

1
2
3
(a b c d) e f (g h)
(a b c) d) i f j (g h)

相比于之前的对比场景,我们需要遍历的运算量就大大减小了。

而且,有很多场景比如新增一行或者删除一行的简单场景,预判完毕之后,新老元素有一个处于没有元素的状态,我们就可以直接执行mount或者unmout完成对比的全过程,不需要再进行复杂的遍历:

1
2
3
4
5
6
(a b c d)
(a b c d) e

(a b c) d
(a b c

双端对比的原理大致就是这样。最后双端对比之后的执行逻辑这一部分需要一些算法知识,我们下一讲会详细介绍,这里你只需要掌握大概的思路。

想让一个队伍尽快按照个头排好序,如果能够计算出,在队伍中,个头从低到高依次递增的最多的队列,让这些人站在原地不动,其余人穿插到他们中间,就可以最大化减少人员的移动,这就是一个最长递增子序列的算法问题,我们下一讲详细剖析。

总结

今天的内容就讲完了,来总结一下吧,我们学习了Vue中的更新逻辑。现在Vue执行逻辑全景图变成了下面的样子,新增了组件更新的逻辑:

img

Vue响应式驱动了组件之间的数据通信机制,数据更新之后,组件会执行intance.update方法,update方法内部执行patch方法进行新老子树的diff计算。

在更新函数中,主要做了两件事,pathProps更新节点自身的属性,这里面使用了pathFlags做到了按需更新;patchChildren执行子元素的更新。其中patch函数内部会只对节点内部的动态属性做更新,这种按需更新的机制是Vue性能优秀的一个原因。

函数内部针对新老子元素不同的状态,执行不同的逻辑。根据子元素是否为空或者数组,以及新元素是否为空或者数组,分别执行对应的删除或者mount逻辑,其中最复杂的就是新的子元素和老的子元素都是数组。

为了最大化减少DOM操作,patchKeyedChildren使用了最长递增子序列来实现,并且相比于React的虚拟DOM diff,新增了双端的预先判断+最长递增子序列算法来实现,这也是Vue性能比较优秀的另外一个原因。

虚拟DOM(下):想看懂虚拟DOM算法,先刷个算法题

今天我们将讲到如何使用位运算来实现Vue中的按需更新,让静态的节点可以越过虚拟DOM的计算逻辑,并且使用计算最长递增子序列的方式,来实现队伍的高效排序。我们会剖析Vue框架源码,结合对应的LeetCode题,帮助你掌握算法的核心原理和实现。

位运算

前面也复习了,在执行diff之前,要根据需要判断每个虚拟DOM节点有哪些属性需要计算,因为无论响应式数据怎么变化,静态的属性和节点都不会发生变化。

所以我们看每个节点diff的时候会做什么,在renderer.ts代码文件中就可以看到代码,主要就是通过虚拟DOM节点的patchFlag树形判断是否需要更新节点。

方法就是使用&操作符来判断操作的类型,比如patchFlag & PatchFlags.CLASS来判断当前元素的class是否需要计算diff;shapeFlag & ShapeFlags.ELEMENT来判断当前虚拟DOM是HTML元素还是Component组件。这个“&”其实就是位运算的按位与。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// class
// this flag is matched when the element has dynamic class bindings.
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, isSVG)
}
}

// style
// this flag is matched when the element has dynamic style bindings
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
}
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}

上面的代码中 & 就是按位与的操作符,这其实是二进制上的计算符号,所以我们首先要了解一下什么是二进制。

我们日常使用的数字都是十进制数字,比如数字13就是 1*10+3 的运算结果,每个位置都是代表10的n次方。13也可以使用二进制表达,因为二进制每个位置只能是0和1两个数字,每个位置代表的是2的n次方,13在二进制里是1101,就是1*8+1*4+0*2+1*1。

而在JavaScript中我们可以很方便地使用toString(2)的方式,把十进制数字转换成二进制。运算的概念很简单,就是在二进制上的“与”和“或”运算:

1
2
3
4
5
6
7
8
9
10
11
12
13
(13).toString(2) // 1101

0 & 0 // 0
0 & 1 // 0
1 & 0 // 0
1 & 1 // 1

0 | 0 // 0
0 | 1 // 1
1 | 0 // 1
1 | 1 // 1

1 << 2 // 1左移动两位,就是100 就是1*2平方 = 4

由于 这些都是在二进制上的计算,运算的性能通常会比字符串和数字的计算性能要好,这也是很多框架内部使用位运算的原因。

这么说估计你不是很理解,我们结合一个LeetCode题看看为什么说二进制的位运算性能更好。

为什么位运算性能更好

我们来做一下LeetCode231题,题目描述很简单,判断数字n是不是2的幂次方,也就是说,判断数字n是不是2的整次方,比如2、4、8。我们可以很轻松地写出JavaScript的解答,n一直除以2,如果有余数就是false,否则就是true:

1
2
3
4
5
6
7
8
9
var isPowerOfTwo = function(n) {
if(n === 1) return true
while( n > 2 ){
n = n / 2
if(n % 2 !== 0) return false
}
return n===2

};

不过上面的解答我们可以用位运算来优化。

先来分析一下2的幂次方的特点。

2的幂次方就是数字1左移动若干次,其余位置全部都是0,所以n-1就是最高位变成0,其余位置都变成1,就像十进制里的10000-1 = 9999。这样, n和n-1每个二进制位的数字都不一样,我们可以很轻松地用按位“与”来判断这个题的答案,如果n&n-1是0的话,数字n就符合2的整次幂的特点:

1
2
3
4
5
6
7
8
9
16
10000
16-1 = 15
01111
16&15 == 0

var isPowerOfTwo = function(n) {
return n>0 && (n & (n - 1)) === 0
};

所以我们使用位运算提高了代码的整体性能。

如何运用位运算

好,搞清楚为什么用位运算,我们回来看diff判断,如何根据位运算的特点,设计出权限的组合认证方案。

比如Vue中的动态属性,有文本、class、style、props几个属性,我们可以使用二进制中的一个位置来表示权限,看下面的代码, 我们使用左移的方式分别在四个二进制上标记了1,代表四种不同的权限,使用按位或的方式去实现权限授予

比如,一个节点如果TEXT和STYLE都需要修改,我们只需要使用 | 运算符就可以得到flag1的权限表示,这就是为什么Vue 3 中针对虚拟DOM类型以及虚拟DOM需要动态计算diff的树形都做了标记,你可以在 Vue 3的源码 中看到下面的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
const PatchFlags = {
TEXT:1, // 0001
CLASS: 1<<1, // 0010
STYLE:1<<2, // 0100
PROPS:1<<3 // 1000
}

const flag1 = PatchFlags.TEXT | PatchFlags.STYLE // 0101

// 权限校验

flag1 & PatchFlags.TEXT // 有权限,结果大于1
flag1 & PatchFlags.CLASS //没有权限 是0

最长递增子系列

然后就到了今天的重点:我们虚拟DOM计算diff中的算法了。

上一讲我们详细介绍了在虚拟diff计算中,如果新老子元素都是数组的时候,需要先做首尾的预判,如果新的子元素和老的子元素在预判完毕后,未处理的元素依然是数组,那么就需要对两个数组计算diff,最终找到最短的操作路径,能够让老的子元素通过尽可能少的操作,更新成为新的子元素。

Vue 3借鉴了infero的算法逻辑,就像操场上需要按照个头从低到高站好一样,我们采用的思路是先寻找一个现有队列中由低到高的队列,让这个队列尽可能的长,它们的相对位置不需要变化,而其他元素进行插入和移动位置,这样就可以做到尽可能少的操作DOM。

所以如何寻找这个最长递增的序列呢?这就是今天的重点算法知识了,我们看 LeetCode第300题,题目描述如下, 需要在数组中找到最长底层的自序列长度:

1
2
3
4
5
6
7
8
9
给你一个整数数组 nums,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。
例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

=
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4

首先我们可以使用动态规划的思路,通过每一步的递推,使用dp数组,记录出每一步操作的最优解,最后得到全局最优解。

在这个例子中,我们可以把dp[i]定义成nums[0]到nums[i]这个区间内,数组的最长递增子序列的长度,并且dp数组的初始值设为1。

从左边向右递推,如果nums[i+1]>nums[i],dp[i+1]就等于dp[i]+1;如果nums[i+1]<nums[i],就什么都不需要干,这样我们在遍历的过程中,就能根据数组当前位置之前的最长递增子序列长度推导出i+1位置的最长递增子序列长度。

所以可以得到如下解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @param {number[]} nums
* @return {number}
*/
const lengthOfLIS = function(nums) {
let n = nums.length;
if (n == 0) {
return 0;
}
let dp = new Array(n).fill(1);
for (let i = 0; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
return Math.max(...dp)
}

由于我们需要两层循环,所以这个解法的时间复杂度是n的平方,这个解法其实已经不错了,但是还有更优秀的解法,也就是Vue 3中用到的算法:贪心+二分。

贪心+二分

我们再看一下这个题,贪心的思路就是在寻找最长递增的序列,所以,[1,3]要比[1,5]好,也就是说,在这个上升的序列中,我们要让上升速度尽可能变得慢,这样才有可能让后面的元素尽可能也递增。

我们可以创建一个arr数组,用来保存这种策略下的最长递增子序列。

如果当前遍历的nums[i]大于arr的最后一个元素,也就是大于arr的最大值时,我们把nums[i]追加到后面即可,否则我们就在arr中 寻找一个第一个大于num[i]的数字并替换它。因为是arr是递增的数列,所以在寻找插入位置的时候,我们可以使用二分查找的方式,把整个算法的复杂度变成O(nlgn)。

下面的代码就是贪心+二分的解法,我们可以得到正确的最长递增子序列的长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* @param {number[]} nums
* @return {number}
*/
const lengthOfLIS = function(nums) {
let len = nums.length
if (len <= 1) {
return len
}
let arr = [nums[0]]
for (let i = 0; i < len; i++) {
// nums[i] 大于 arr 尾元素时,直接追加到后面,递增序列长度+1
if (nums[i] > arr[arr.length - 1]) {
arr.push(nums[i])
} else {
// 否则,查找递增子序列中第一个大于numsp[i]的元素 替换它
// 递增序列,可以使用二分查找
let left = 0
let right = arr.length - 1
while (left < right) {
let mid = (left + right) >> 1
if (arr[mid] < nums[i]) {
left = mid + 1
} else {
right = mid
}
}
arr[left] = nums[i]
}
}
return arr.length
};

但是贪心+二分的这种解法,现在只能得到最长递增子序列的长度,但是最后得到的arr并不一定是最长递增子序列,因为我们移动的num[i]位置可能会不正确,只是得到的数组长度是正确的,所以我们需要对这个算法改造一下,把整个数组复制一份之后,最后也能得到正确的最长递增子序列。

具体代码怎么写呢?我们来到Vue 3的renderer.ts文件中,函数 getSquenece 就是用来生成最长递增子序列,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function getSequence(arr: number[]): number[] {
const p = arr.slice() //赋值一份arr
const result = [0]
let i, j, u, v, c
const len = arr.length
for (i = 0; i < len; i++) {
const arrI = arr[i]
if (arrI !== 0) {
j = result[result.length - 1]
if (arr[j] < arrI) {
p[i] = j // 存储在result最后一个索引的值
result.push(i)
continue
}
u = 0
v = result.length - 1
// 二分查找,查找比arrI小的节点,更新result的值
while (u < v) {
c = (u + v) >> 1
if (arr[result[c]] < arrI) {
u = c + 1
} else {
v = c
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1]
}
result[u] = i
}
}
}
u = result.length
v = result[u - 1]
// 查找数组p 找到最终的索引
while (u-- > 0) {
result[u] = v
v = p[v]
}
return result
}

这段代码就是Vue 3里的实现,result存储的就是长度是i的递增子序列最小末位置的索引,最后计算出最长递增子序列。

我们得到increasingNewIndexSequence队列后,再去遍历数组进行patch操作就可以实现完整的diff流程了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}

上面代码的思路,我们用下图演示。做完双端对比之后,a和g已经计算出可以直接复用DOM,剩下的队列中我们需要把hbfdc更新成bcdef。

首先我们需要 使用keyToNewIndexMap存储新节点中每个key对应的索引,比如下图中key是c的元素的索引就是2; 然后计算出newIndexOldIndexMap存储这个key在老的子元素中的位置,我们可以根据c的索引是2,在newIndexOldIndexMap中查询到在老的子元素的位置是6, 关于newIndexOldIndexMap的具体逻辑你可以在上面的代码中看到:

image-20231023202803240

总结

首先我们分析了Vue 3中虚拟DOM diff中的静态标记功能,标记后通过位运算,可以快速判断出一个节点的类型是HTML标签还是Vue组件,然后去执行不同的操作方法;在节点更新的流程中,也可以通过位运算的方式确定需要更新的范围。

位运算就是通过二进制上的与和或运算,能够高效地进行权限的判断,我们在工作中如果涉及权限的判断,也可以借鉴类似的思路,Linux中的读写权限也是通过位运算的方式来实现的。

然后我们剖析了Vue的虚拟DOM中最为复杂的最长递增子序列算法,通过对LeetCode第300的题分析掌握了动态规划和贪心+二分的解法。

掌握算法思想之后,我们再回到Vue3的源码中分析代码的实现逻辑,patchKeyedChildren的核心逻辑就是在进行双端对比后,对无法预判的序列计算出最长递增子序列之后,我们通过编译数组,对其余的元素进行patch或者move的操作,完整实现了虚拟DOM 的diff。

学到这里相信你已经完全搞懂了虚拟DOM的执行,以及关键的diff操作思路,可以体会到Vue中极致的优化理念,使用位运算对Vue中的动态属性和节点进行标记,实现高效判断;对于两个数组的diff计算使用了最长递增子序列算法实现,优化了diff的时间复杂度。这也是为什么我一直建议刚入行的前端工程师要好好学习算法的主要原因。

推荐阅读

说说 vue2 和 vue3 核心diff算法

编译原理(上):手写一个迷你Vue 3 Compiler的入门原理

前面我们用了四讲,学习了Vue在浏览器中是如何执行的,你可以参考上一讲结尾的Vue执行全景图来回顾一下。在Vue中,组件都是以虚拟DOM的形式存在,加载完毕之后注册effect函数。这样组件内部的数据变化之后,用Vue的响应式机制做到了通知组件更新,内部则使用patch函数实现了虚拟DOM的更新,中间我们也学习了位运算、最长递增子序列等算法。

这时候你肯定还有一个疑问,那就是虚拟DOM是从哪来的?我们明明写的是template和JSX,这也是吃透Vue源码最后一个难点:Vue中的Compiler。

下图就是Vue核心模块依赖关系图,reactivity和runtime我们已经剖析完毕,迷你版本的代码你可以在 GitHub 中看到。今天开始我将用三讲的内容,给你详细讲解一下Vue在编译的过程中做了什么。

image-20231023211209223

编译原理也属于计算机中的一个重要学科,Vue 的 compiler 是在 Vue 场景下的实现,目的就是实现 template 到 render 函数的转变。

我们第一步需要先掌握编译原理的基本概念。Vue官方提供了模板编译的 在线演示。下图左侧代码是我们写的template,右侧代码就是compiler模块解析城的render函数,我们今天的任务就是能够实现一个迷你的compiler。

image-20231023211434533

整体流程

上述转化的过程可以分为下面的示意图几步来实现。

首先,代码会被解析成一个对象,这个对象有点像虚拟DOM的概念,用来描述template的代码关系,这个对象就是抽象语法树(简称AST,后面我们细讲)。然后通过transform模块对代码进行优化,比如识别Vue中的语法,静态标记、最后通过generate模块生成最终的render函数。

image-20231023211731213

理清了流程,我们动手完成具体代码实现。用下面的代码就能实现上述的流程图里的内容。其中parse函数负责生成抽象语法树ASTtransform函数负责语义转换generate函数负责最终的代码生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function compiler(template) {
const ast = parse(template);
transform(ast)
const code = generate(ast)
return code
}

let template = `<div id="app">
<div @click="()=>console.log(xx)" :id="name">{{name}}</div>
<h1 :name="title">玩转vue3</h1>
<p >编译原理</p>
</div>
`

const renderFunction = compiler(template)
console.log(renderFunction)

我们先来看下parse函数如何实现。template转成render函数是两种语法的转换,这种代码转换的需求其实计算机的世界中非常常见。比如我们常用的Babel,就是把ES6的语法转成低版本浏览器可以执行的代码。

tokenizer的迷你实现

首先,我们要对template进行词法分析,把模板中的<div>, @click, {{}}等语法识别出来,转换成一个个的token。你可以理解为把template的语法进行了分类,这一步我们叫tokenizer

下面的代码就是tokenizer的迷你实现。我们使用tokens数组存储解析的结果,然后对模板字符串进行循环,在template中,< > / 和空格都是关键的分隔符,如果碰见<字符,我们需要判断下一个字符的状态。如果是字符串我们就标记tagstart;如果是/,我们就知道是结束标签,标记为tagend,最终通过push方法把分割之后的token存储在数组tokens中返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function tokenizer(input) {
let tokens = []
let type = ''
let val = ''
// 粗暴循环
for (let i = 0; i < input.length; i++) {
let ch = input[i]
if (ch === '<') {
push()
if (input[i + 1] === '/') {
type = 'tagend'
} else {
type = 'tagstart'
}
} if (ch === '>') {
if(input[i-1]=='='){
//箭头函数
}else{
push()
type = "text"
continue
}
} else if (/[\s]/.test(ch)) { // 碰见空格截断一下
push()
type = 'props'
continue
}
val += ch
}
return tokens

function push() {
if (val) {
if (type === "tagstart") val = val.slice(1) // <div => div
if (type === "tagend") val = val.slice(2) // </div => div
tokens.push({
type,
val
})
val = ''
}
}
}

实现了上面的代码,我们就得到了解析之后的token数组。

生成抽象语法树

下面的数组中,我们分别用tagstartprops tagendtext标记,用它们标记了全部内容。然后下一步我们需要把这个数组按照标签的嵌套关系转换成树形结构,这样才能完整地描述template标签的关系。

image-20231023224111246

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[
  { type: 'tagstart', val: 'div' },
  { type: 'props', val: 'id="app"' },
  { type: 'tagstart', val: 'div' },
  { type: 'props', val: '@click="()=console.log(xx)"' },
  { type: 'props', val: ':id="name"' },
  { type: 'text', val: '{{name}}' },
  { type: 'tagend', val: 'div' },
  { type: 'tagstart', val: 'h1' },
  { type: 'props', val: ':name="title"' },
  { type: 'text', val: '玩转vue3' },
  { type: 'tagend', val: 'h1' },
  { type: 'tagstart', val: 'p' },
  { type: 'text', val: '编译原理' },
  { type: 'tagend', val: 'p' },
  { type: 'tagend', val: 'div' }
]

然后我们分析token数组,看看它是如何转化成一个体现语法规则的树形结构的。

就像我们用虚拟DOM描述页面DOM结构一样,我们使用树形结构描述template的语法,这个树我们称之为抽象语法树,简称AST。

下面的代码中我们用parse函数实现AST的解析。过程是这样的,首先我们使用一个AST对象作为根节点。然后通过walk函数遍历整个tokens数组,根据token的类型不同,生成不同的node对象。最后根据tagend的状态来决定walk的递归逻辑,最终实现了整棵树的构建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function parse(template) {
const tokens = tokenizer(template)
let cur = 0
let ast = {
type: 'root',
props:[],
children: []
}
while (cur < tokens.length) {
ast.children.push(walk())
}
return ast

function walk() {
let token = tokens[cur]
if (token.type == 'tagstart') {
let node = {
type: 'element',
tag: token.val,
props: [],
children: []
}
token = tokens[++cur]
while (token.type !== 'tagend') {
if (token.type == 'props') {
node.props.push(walk())
} else {
node.children.push(walk())
}
token = tokens[cur]
}
cur++
return node
}
if (token.type === 'tagend') {
cur++
// return token
}
if (token.type == "text") {
cur++
return token
}
if (token.type === "props") {
cur++
const [key, val] = token.val.replace('=','~').split('~')
return {
key,
val
}
}
}
}

上面的代码会生成抽象语法树AST,这个树的结构如下面代码所示,通过type和children描述整个template的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
{
  "type": "root",
  "props": [],
  "children": [
    {
      "type": "element",
      "tag": "div",
      "props": [
        {
          "key": "id",
          "val": "\"app\""
        }
      ],
      "children": [
        {
          "type": "element",
          "tag": "div",
          "props": [
            {
              "key": "@click",
              "val": "\"()"
            },
            {
              "key": ":id",
              "val": "\"name\""
            }
          ],
          "children": [
            {
              "type": "text",
              "val": "{{name}}"
            }
          ]
        },
        {
          "type": "element",
          "tag": "h1",
          "props": [
            {
              "key": ":name",
              "val": "\"title\""
            }
          ],
          "children": [
            {
              "type": "text",
              "val": "玩转vue3"
            }
          ]
        },
        {
          "type": "element",
          "tag": "p",
          "props": [],
          "children": [
            {
              "type": "text",
              "val": "编译原理"
            }
          ]
        }
      ]
    }
  ]
}

语义分析和优化

有了抽象语法树之后,我们还要进行语义的分析和优化,也就是说,我们要在这个阶段理解语句要做的事。咱们结合例子来理解会更容易。

在template这个场景下,两个大括号包裹的字符串就是变量,@click就是事件监听。

下面的代码中我们使用transform函数实现这个功能,这一步主要是理解template中Vue的语法,并且为最后生成的代码做准备。我们使用context对象存储AST所需要的上下文,如果我们用到了变量{{}},就需要引入toDisplayString函数,上下文中的helpers存储的就是我们用到的工具函数。

1
2
3
4
5
6
7
8
9
function transform(ast) {
// 优化一下ast
let context = {
// import { toDisplayString , createVNode , openBlock , createBlock } from "vue"
helpers:new Set(['openBlock','createVnode']), // 用到的工具函数
}
traverse(ast, context)
ast.helpers = context.helpers
}

然后我们使用traverse函数递归整个AST,去优化AST的结构,并且在这一步实现简单的静态标记

当节点标记为element的时候,我们递归调用整个AST,内部挨个遍历AST所有的属性,我们默认使用ast.flag标记节点的动态状态。如果属性是@开头的,我们就认为它是Vue中的事件绑定,使用arg.flag|= PatchFlags.EVENT 标记当前节点的事件是动态的,需要计算diff,这部分位运算的知识点我们在上一讲已经学习过了。

然后冒号开头的就是动态的属性传递,并且把class和style标记了不同的flag。如果都没有命中的话,就使用static:true,标记当前节点位是静态节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function traverse(ast, context){
switch(ast.type){
case "root":
context.helpers.add('createBlock')
// log(ast)
case "element":
ast.children.forEach(node=>{
traverse(node,context)
})
ast.flag = 0
ast.props = ast.props.map(prop=>{
const {key,val} = prop
if(key[0]=='@'){
ast.flag |= PatchFlags.EVENT // 标记event需要更新
return {
key:'on'+key[1].toUpperCase()+key.slice(2),
val
}
}
if(key[0]==':'){
const k = key.slice(1)
if(k=="class"){
ast.flag |= PatchFlags.CLASS // 标记class需要更新

}else if(k=='style'){
ast.flag |= PatchFlags.STYLE // 标记style需要更新
}else{
ast.flag |= PatchFlags.PROPS // 标记props需要更新
}
return{
key:key.slice(1),
val
}
}
if(key.startsWith('v-')){
// pass such as v-model
}
//标记static是true 静态节点
return {...prop,static:true}
})
break
case "text":
// trnsformText
let re = /\{\{(.*)\}\}/g
if(re.test(ast.val)){
//有{{
ast.flag |= PatchFlags.TEXT // 标记props需要更新
context.helpers.add('toDisplayString')
ast.val = ast.val.replace(/\{\{(.*)\}\}/g,function(s0,s1){
return s1
})
}else{
ast.static = true
}
}
}

经过上面的代码标记优化之后,项目在数据更新之后,浏览器计算虚拟dom diff运算的时候,就会执行类似下面的代码逻辑。

我们通过在compiler阶段的标记,让template产出的虚拟DOM有了更精确的状态,可以越过大部分的虚拟DOM的diff计算,极大提高Vue的运行时效率,这个思想我们日常开发中也可以借鉴学习。

1
2
3
4
5
6
7
8
9
10
if(vnode.static){
return
}
if(vnode.flag & patchFlag.CLASS){
遍历class 计算diff
}else if(vnode.flag & patchFlag.STYLE){
计算style的diff
}else if(vnode.flag & patchFlag.TEXT){
计算文本的diff
}

接下来,我们基于优化之后的AST生成目标代码,也就是generate函数要做的事:遍历整个AST,拼接成最后要执行的函数字符串。

下面的代码中,我们首先把helpers拼接成import语句,并且使用walk函数遍历整个AST,在遍历的过程中收集helper集合的依赖。最后,在createVnode的最后一个参数带上ast.flag进行状态的标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function generate(ast) {
const {helpers} = ast

let code = `
import {${[...helpers].map(v=>v+' as _'+v).join(',')}} from 'vue'\n
export function render(_ctx, _cache, $props){
return(_openBlock(), ${ast.children.map(node=>walk(node))})}`

function walk(node){
switch(node.type){
case 'element':
let {flag} = node // 编译的标记
let props = '{'+node.props.reduce((ret,p)=>{
if(flag.props){
//动态属性
ret.push(p.key +':_ctx.'+p.val.replace(/['"]/g,'') )
}else{
ret.push(p.key +':'+p.val )
}

return ret
},[]).join(',')+'}'
return `_createVnode("${node.tag}",${props}),[
${node.children.map(n=>walk(n))}
],${JSON.stringify(flag)}`
break
case 'text':
if(node.static){
return '"'+node.val+'"'
}else{
return `_toDisplayString(_ctx.${node.val})`
}
break
}
}
return code
}

最终实现效果

最后我们执行一下代码,看下效果输出的代码。可以看到,它已经和Vue输出的代码很接近了,到此为止,我们也实现了一个非常迷你的Vue compiler,这个产出的render函数最终会和组件的setup函数一起组成运行时的组件对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function compiler(template) {
const ast = parse(template);
transform(ast)

const code = generate(ast)
return code
}

let template = `<div id="app">
<div @click="()=>console.log(xx)" :id="name">{{name}}</div>
<h1 :name="title">玩转vue3</h1>
<p >编译原理</p>
</div>
`

const renderFunction = compiler(template)
console.log(renderFunction)

// 下面是输出结果
import { openBlock as _openBlock, createVnode as _createVnode, createBlock as _createBlock, toDisplayString as _toDisplayString } from 'vue'

export function render(_ctx, _cache, $props) {
return (_openBlock(), _createVnode("div", { id: "app" }), [
_createVnode("div", { onClick: "()=>console.log(xx)", id: "name" }), [
_toDisplayString(_ctx.name)
], 24, _createVnode("h1", { name: "title" }), [
"玩转vue3"
], 8, _createVnode("p", {}), [
"编译原理"
], 0
], 0)
}

总结

image-20231023215530747

通过这个迷你的compiler,我们学习了编译原理的入门知识:包括parser的实现、AST是什么,AST的语义化优化和代码生成generate模块,这给我们下一讲弄清楚Vue的compiler的核心逻辑打下了良好的理论基础。

我想提醒你注意一个优化方法,我们实现的迷你compiler也实现了属性的静态标记,通过在编译期间的标记方式,让虚拟DOM在运行时有更多的状态,从而能够精确地控制更新。这种编译时的优化也能够对我们项目开发有很多指引作用,我会在剖析完Vue的compiler之后,在第34讲那里跟你分享一下实战中如何使用编译优化的思想。

编译原理(中):Vue Compiler模块全解析

上一讲我带你手写了一个迷你的Vue compiler,还学习了编译原理的基础知识。通过实现这个迷你Vue compiler,我们知道了tokenizer可以用来做语句分析,而parse负责生成抽象语法树AST。然后我们一起分析AST中的Vue语法,最后通过generate函数生成最终的代码

今天我就带你深入Vue的compiler源码之中,看看Vue内部到底是怎么实现的。有了上一讲编译原理的入门基础,你会对Compiler执行全流程有更深的理解。

Vue compiler入口分析

Vue 3内部有4个和compiler相关的包。compiler-domcompiler-core负责实现浏览器端的编译,这两个包是我们需要深入研究的,compiler-ssr负责服务器端渲染,我们后面讲ssr的时候再研究,compiler-sfc编译.vue单文件组件的,有兴趣的同学可以自行探索。

首先我们进入到vue-next/packages/compiler-dom/index.ts文件下,在 GitHub 上你可以找到下面这段代码。

compiler函数有两个参数,第一个参数template,它是我们项目中的模板字符串;第二个参数options是编译的配置,内部调用了baseCompile函数。我们可以看到,这里的调用关系和runtime-domruntime-core的关系类似,compiler-dom负责传入浏览器Dom相关的API,实际编译的baseCompile是由compiler-core提供的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export function compile(
template: string,
options: CompilerOptions = {}
): CodegenResult {
return baseCompile(
template,
extend({}, parserOptions, options, {
nodeTransforms: [
// ignore <script> and <tag>
// this is not put inside DOMNodeTransforms because that list is used
// by compiler-ssr to generate vnode fallback branches
ignoreSideEffectTags,
...DOMNodeTransforms,
...(options.nodeTransforms || [])
],
directiveTransforms: extend(
{},
DOMDirectiveTransforms,
options.directiveTransforms || {}
),
transformHoist: __BROWSER__ ? null : stringifyStatic
})
)
}

我们先来看看compiler-dom做了哪些额外的配置。

首先,parserOption传入了parse的配置,通过parserOption传递的isNativeTag来区分elementcomponent。这里的实现也非常简单,把所有html的标签名存储在一个对象中,然后就可以很轻松地判断出div是浏览器自带的element。

baseCompile传递的其他参数nodeTransforms和directiveTransforms,它们做的也是和上面代码类似的事。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export const parserOptions: ParserOptions = {
isVoidTag,
isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag),
isPreTag: tag => tag === 'pre',
decodeEntities: __BROWSER__ ? decodeHtmlBrowser : decodeHtml,

isBuiltInComponent: (tag: string): symbol | undefined => {
if (isBuiltInType(tag, `Transition`)) {
return TRANSITION
} else if (isBuiltInType(tag, `TransitionGroup`)) {
return TRANSITION_GROUP
}
},
...
}
const HTML_TAGS =
'html,body,base,head,link,meta,style,title,address,article,aside,footer,' +
'header,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,' +
'figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,' +
'data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,' +
'time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,' +
'canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,' +
'th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,' +
'option,output,progress,select,textarea,details,dialog,menu,' +
'summary,template,blockquote,iframe,tfoot'
export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)

Vue浏览器端编译的核心流程

然后,我们进入到baseCompile函数中,这就是Vue浏览器端编译的核心流程。

下面的代码中可以很清楚地看到,我们先通过baseParse把传递的template解析成AST,然后通过transform函数对AST进行语义化分析,最后通过generate函数生成代码。

这个主要逻辑和我们写的迷你compiler基本一致,这些函数大概要做的事你也心中有数了。这里你也能体验到,亲手实现一个迷你版本对我们阅读源码很有帮助。

接下来,我们就进入到这几个函数之中去,看一下跟迷你compiler里的实现相比,我们到底做了哪些优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export function baseCompile(
template: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
const ast = isString(template) ? baseParse(template, options) : template
const [nodeTransforms, directiveTransforms] =
getBaseTransformPreset(prefixIdentifiers)

transform(
ast,
extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []) // user transforms
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {} // user transforms
)
})
)
return generate(
ast,
extend({}, options, {
prefixIdentifiers
})
)
}

上一讲中我们体验了Vue的在线模板编译环境,可以在console中看到Vue解析得到的AST。

如下图所示,可以看到这个AST比迷你版多了很多额外的属性。 loc用来描述节点对应代码的信息,component和directive用来记录代码中出现的组件和指令等等

image-20231024152527585

然后我们进入到baseParse函数中, 这里的createParserContextcreateRoot用来生成上下文,其实就是创建了一个对象,保存当前parse函数中需要共享的数据和变量,最后调用parseChildren。

children内部开始判断<开头的标识符,判断开始还是闭合标签后,接着会生成一个nodes数组。其中,advanceBy函数负责更新context中的source用来向前遍历代码,最终对不同的场景执行不同的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
export function baseParse(
content: string,
options: ParserOptions = {}
): RootNode {
const context = createParserContext(content, options)
const start = getCursor(context)
return createRoot(
parseChildren(context, TextModes.DATA, []),
getSelection(context, start)
)
}
function parseChildren(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[]
): TemplateChildNode[] {
const parent = last(ancestors)
// 依次生成node
const nodes: TemplateChildNode[] = []
// 如果遍历没结束
while (!isEnd(context, mode, ancestors)) {

const s = context.source
let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined

if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
// 处理vue的变量标识符,两个大括号 '{{'
node = parseInterpolation(context, mode)
} else if (mode === TextModes.DATA && s[0] === '<') {
// 处理<开头的代码,可能是<div>也有可能是</div> 或者<!的注释
if (s.length === 1) {
// 长度是1,只有一个< 有问题 报错
emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 1)
} else if (s[1] === '!') {
// html注释
if (startsWith(s, '<!--')) {
node = parseComment(context)
} else if (startsWith(s, '<!DOCTYPE')) {

// DOCTYPE
node = parseBogusComment(context)
}
} else if (s[1] === '/') {
//</ 开头的标签,结束标签
// https://html.spec.whatwg.org/multipage/parsing.html#end-tag-open-state
if (/[a-z]/i.test(s[2])) {
emitError(context, ErrorCodes.X_INVALID_END_TAG)
parseTag(context, TagType.End, parent)
continue
}
} else if (/[a-z]/i.test(s[1])) {
// 解析节点
node = parseElement(context, ancestors)
// 2.x <template> with no directive compat
node = node.children
}
}
}
}
if (!node) {
// 文本
node = parseText(context, mode)
}
// node树数组,遍历puish
if (isArray(node)) {
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
} else {
pushNode(nodes, node)
}
}

return removedWhitespace ? nodes.filter(Boolean) : nodes
}

parseInterpolationparseText函数的逻辑比较简单。parseInterpolation负责识别变量的分隔符 ,然后通过parseTextData获取变量的值,并且通过innerStart和innerEnd去记录插值的位置;parseText负责处理模板中的普通文本,主要是把文本包裹成AST对象。

接着我们看看处理节点的parseElement函数都做了什么。首先要判断prev-pre标签,然后通过isVoidTag判断标签是否是自闭合标签,这个函数是从compiler-dom中传来的,之后会递归调用parseChildren,接着再解析开始标签、解析子节点,最后解析结束标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
const VOID_TAGS =
'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'

export const isVoidTag = /*#__PURE__*/ makeMap(VOID_TAGS)
function parseElement(
context: ParserContext,
ancestors: ElementNode[]
): ElementNode | undefined {
// Start tag.
// 是不是pre标签和v-pre标签
const wasInPre = context.inPre
const wasInVPre = context.inVPre
const parent = last(ancestors)
// 解析标签节点
const element = parseTag(context, TagType.Start, parent)
const isPreBoundary = context.inPre && !wasInPre
const isVPreBoundary = context.inVPre && !wasInVPre

if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
// #4030 self-closing <pre> tag
if (isPreBoundary) {
context.inPre = false
}
if (isVPreBoundary) {
context.inVPre = false
}
return element
}

// Children.
ancestors.push(element)
const mode = context.options.getTextMode(element, parent)
const children = parseChildren(context, mode, ancestors)
ancestors.pop()
element.children = children

// End tag.
if (startsWithEndTagOpen(context.source, element.tag)) {
parseTag(context, TagType.End, parent)
} else {
emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
const first = children[0]
if (first && startsWith(first.loc.source, '<!--')) {
emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
}
}
}

element.loc = getSelection(context, element.loc.start)

if (isPreBoundary) {
context.inPre = false
}
if (isVPreBoundary) {
context.inVPre = false
}
return element
}

最后,我们来看下解析节点的parseTag函数的逻辑,匹配文本标签结束的位置后,先通过parseAttributes函数处理属性,然后对pre和v-pre标签进行检查,最后通过isComponent函数判断是否为组件。

isComponent内部会通过compiler-dom传递的isNativeTag来辅助判断结果,最终返回一个描述节点的对象,包含当前节点所有解析之后的信息,tag表示标签名,children表示子节点的数组,具体代码我放在了后面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
function parseTag(
context: ParserContext,
type: TagType,
parent: ElementNode | undefined
): ElementNode | undefined {

// Tag open.
const start = getCursor(context)
//匹配标签结束的位置
const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
const tag = match[1]
const ns = context.options.getNamespace(tag, parent)
// 向前遍历代码
advanceBy(context, match[0].length)
advanceSpaces(context)

// save current state in case we need to re-parse attributes with v-pre
const cursor = getCursor(context)
const currentSource = context.source

// check <pre> tag
if (context.options.isPreTag(tag)) {
context.inPre = true
}
// Attributes.
// 解析属性
let props = parseAttributes(context, type)
// check v-pre
if (){...}
// Tag close.
let isSelfClosing = false
if (type === TagType.End) {
return
}

let tagType = ElementTypes.ELEMENT
if (!context.inVPre) {
if (tag === 'slot') {
tagType = ElementTypes.SLOT
} else if (tag === 'template') {
if (
props.some(
p =>
p.type === NodeTypes.DIRECTIVE && isSpecialTemplateDirective(p.name)
)
) {
tagType = ElementTypes.TEMPLATE
}
} else if (isComponent(tag, props, context)) {
tagType = ElementTypes.COMPONENT
}
}

return {
type: NodeTypes.ELEMENT,
ns,
tag,
tagType,
props,
isSelfClosing,
children: [],
loc: getSelection(context, start),
codegenNode: undefined // to be created during transform phase
}
}

parse函数生成AST之后,我们就有了一个完整描述template的对象,它包含了template中所有的信息。

AST的语义化分析

下一步我们要对AST进行语义化的分析。transform函数的执行流程分支很多, 核心的逻辑就是识别一个个的Vue的语法,并且进行编译器的优化,我们经常提到的静态标记就是这一步完成的

我们进入到transform函数中,可以看到,内部通过createTransformContext创建上下文对象,这个对象包含当前分析的属性配置,包括是否ssr,是否静态提升还有工具函数等等,这个对象的属性你可以在 GitHub 上看到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export function transform(root: RootNode, options: TransformOptions) {
const context = createTransformContext(root, options)
traverseNode(root, context)
if (options.hoistStatic) {
hoistStatic(root, context)
}
if (!options.ssr) {
createRootCodegen(root, context)
}
// finalize meta information
root.helpers = [...context.helpers.keys()]
root.components = [...context.components]
root.directives = [...context.directives]
root.imports = context.imports
root.hoists = context.hoists
root.temps = context.temps
root.cached = context.cached

if (__COMPAT__) {
root.filters = [...context.filters!]
}
}

然后通过traverseNode即可编译AST所有的节点。核心的转换流程是在遍历中实现,内部使用switch判断node.type执行不同的处理逻辑。比如如果是Interpolation,就需要在helper中导入toDisplayString工具函数,这个迷你版本中我们也实现过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
export function traverseNode(
node: RootNode | TemplateChildNode,
context: TransformContext
) {
context.currentNode = node
// apply transform plugins
const { nodeTransforms } = context
const exitFns = []
for (let i = 0; i < nodeTransforms.length; i++) {
// 处理exitFns
}
swtch (node.type) {
case NodeTypes.COMMENT:
if (!context.ssr) {
context.helper(CREATE_COMMENT)
}
break
case NodeTypes.INTERPOLATION:
if (!context.ssr) {
context.helper(TO_DISPLAY_STRING)
}
break
case NodeTypes.IF:
for (let i = 0; i < node.branches.length; i++) {
traverseNode(node.branches[i], context)
}
break
case NodeTypes.IF_BRANCH:
case NodeTypes.FOR:
case NodeTypes.ELEMENT:
case NodeTypes.ROOT:
traverseChildren(node, context)
break
}

// exit transforms
context.currentNode = node
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}

transform中还会调用transformElement来转换节点,用来处理props和children的静态标记,transformText用来转换文本,这里的代码比较简单, 你可以自行在 Github 上查阅。

transform函数参数中的nodeTransformsdirectiveTransforms传递了Vue中template语法的配置,这个两个函数由getBaseTransformPreset返回。

下面的代码中,transformIftransformFor函数式解析Vue中v-if和v-for的语法转换,transformOn和transformModel是解析v-on和v-model的语法解析,这里我们只关注v-开头的语法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export function getBaseTransformPreset(
prefixIdentifiers?: boolean
): TransformPreset {
return [
[
transformOnce,
transformIf,
transformMemo,
transformFor,
...(__COMPAT__ ? [transformFilter] : []),
...(!__BROWSER__ && prefixIdentifiers
? [
// order is important
trackVForSlotScopes,
transformExpression
]
: __BROWSER__ && __DEV__
? [transformExpression]
: []),
transformSlotOutlet,
transformElement,
trackSlotScopes,
transformText
],
{
on: transformOn,
bind: transformBind,
model: transformModel
}
]
}

然后我们再来看看transformIf的函数实现。首先判断v-if、v-else和v-else-if属性,内部通过createCodegenNodeForBranch来创建条件分支,在AST中标记当前v-if的处理逻辑。这段逻辑标记结束后,在generate中就会把v-if标签和后面的v-else标签解析成三元表达式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export const transformIf = createStructuralDirectiveTransform(
/^(if|else|else-if)$/,
(node, dir, context) => {
return processIf(node, dir, context, (ifNode, branch, isRoot) => {
const siblings = context.parent!.children
let i = siblings.indexOf(ifNode)
let key = 0
while (i-- >= 0) {
const sibling = siblings[i]
if (sibling && sibling.type === NodeTypes.IF) {
key += sibling.branches.length
}
}
return () => {
if (isRoot) {
ifNode.codegenNode = createCodegenNodeForBranch(
branch,
key,
context
) as IfConditionalExpression
} else {
// attach this branch's codegen node to the v-if root.
const parentCondition = getParentCondition(ifNode.codegenNode!)
parentCondition.alternate = createCodegenNodeForBranch(
branch,
key + ifNode.branches.length - 1,
context
)
}
}
})
}
)

transform对AST分析结束之后,我们就得到了一个优化后的AST对象,最后我们需要调用generate函数最终生成render函数。

template到render函数的转化

结合下面的代码我们可以看到,generate首先通过createCodegenContext创建上下文对象,然后通过genModulePreamble生成预先定义好的代码模板,然后生成render函数,最后生成创建虚拟DOM的表达式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
export function generate(
ast,
options
): CodegenResult {
const context = createCodegenContext(ast, options)
const {
mode,
push,
prefixIdentifiers,
indent,
deindent,
newline,
scopeId,
ssr
} = context

if (!__BROWSER__ && mode === 'module') {
// 预设代码,module风格 就是import语句
genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
} else {
// 预设代码,函数风格 就是import语句
genFunctionPreamble(ast, preambleContext)
}
// render还是ssrRender
const functionName = ssr ? `ssrRender` : `render`
const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache']
if (!__BROWSER__ && options.bindingMetadata && !options.inline) {
// binding optimization args
args.push('$props', '$setup', '$data', '$options')
}
const signature =
!__BROWSER__ && options.isTS
? args.map(arg => `${arg}: any`).join(',')
: args.join(', ')

if (isSetupInlined) {
push(`(${signature}) => {`)
} else {
push(`function ${functionName}(${signature}) {`)
}
indent()

// 组件,指令声明代码
if (ast.components.length) {
genAssets(ast.components, 'component', context)
if (ast.directives.length || ast.temps > 0) {
newline()
}
}
if (ast.components.length || ast.directives.length || ast.temps) {
push(`\n`)
newline()
}

if (ast.codegenNode) {
genNode(ast.codegenNode, context)
} else {
push(`null`)
}

if (useWithBlock) {
deindent()
push(`}`)
}

deindent()
push(`}`)

return {
ast,
code: context.code,
preamble: isSetupInlined ? preambleContext.code : ``,
// SourceMapGenerator does have toJSON() method but it's not in the types
map: context.map ? (context.map as any).toJSON() : undefined
}
}

我们来看下关键的步骤,genModulePreamble函数生成import风格的代码,这也是我们迷你版本中的功能:通过遍历helpers,生成import字符串,这对应了代码的第二行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 生成这个
// import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

function genModulePreamble(
ast: RootNode,
context: CodegenContext,
genScopeId: boolean,
inline?: boolean
) {

if (genScopeId && ast.hoists.length) {
ast.helpers.push(PUSH_SCOPE_ID, POP_SCOPE_ID)
}
// generate import statements for helpers
if (ast.helpers.length) {
push(
`import { ${ast.helpers
.map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
.join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`
)
}
}
...
}

接下来的步骤就是生成渲染函数rendercomponent的代码,最后通过genNode生成创建虚拟的代码,执行switch语句生成不同的代码,一共有十几种情况,这里就不一一赘述了。我们可以回顾上一讲中迷你代码的逻辑,总之针对变量,标签,v-if和v-for都有不同的代码生成逻辑,最终才实现了template到render函数的转化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
function genNode(node: CodegenNode | symbol | string, context: CodegenContext) {
if (isString(node)) {
context.push(node)
return
}
if (isSymbol(node)) {
context.push(context.helper(node))
return
}
switch (node.type) {
case NodeTypes.ELEMENT:
case NodeTypes.IF:
case NodeTypes.FOR:
genNode(node.codegenNode!, context)
break
case NodeTypes.TEXT:
genText(node, context)
break
case NodeTypes.SIMPLE_EXPRESSION:
genExpression(node, context)
break
case NodeTypes.INTERPOLATION:
genInterpolation(node, context)
break
case NodeTypes.TEXT_CALL:
genNode(node.codegenNode, context)
break
case NodeTypes.COMPOUND_EXPRESSION:
genCompoundExpression(node, context)
break
case NodeTypes.COMMENT:
genComment(node, context)
break
case NodeTypes.VNODE_CALL:
genVNodeCall(node, context)
break

case NodeTypes.JS_CALL_EXPRESSION:
genCallExpression(node, context)
break
case NodeTypes.JS_OBJECT_EXPRESSION:
genObjectExpression(node, context)
break
case NodeTypes.JS_ARRAY_EXPRESSION:
genArrayExpression(node, context)
break
case NodeTypes.JS_FUNCTION_EXPRESSION:
genFunctionExpression(node, context)
break
case NodeTypes.JS_CONDITIONAL_EXPRESSION:
genConditionalExpression(node, context)
break
case NodeTypes.JS_CACHE_EXPRESSION:
genCacheExpression(node, context)
break
case NodeTypes.JS_BLOCK_STATEMENT:
genNodeList(node.body, context, true, false)
break

/* istanbul ignore next */
case NodeTypes.IF_BRANCH:
// noop
break

}
}

总结

今天我们一起分析了Vue中的compiler执行全流程,有了上一讲编译入门知识的基础之后,今天的parsetransformgenerate模块就是在上一讲的基础之上,更加全面地实现代码的编译和转化。

image-20231024161352850

上面的流程图中,我们代码中的template是通过compiler函数进行编译转换,compiler内部调用了compiler-core中的baseCompile函数,并且传递了浏览器平台的转换逻辑。

比如isNativeTag等函数,baseCompie函数中首先通过baseParse函数把template处理成为AST,并且由transform函数进行标记优化,transfom内部的transformIf,transformOn等函数会对Vue中的语法进行标记,这样在generate函数中就可以使用优化后的AST去生成最终的render函数。

最终,render函数会和我们写的setup函数一起组成组件对象,交给页面进行渲染。后面我特意为你绘制了一幅Vue全流程的架构图,你可以保存下来随时查阅。

img

编译原理(下):编译原理给我们带来了什么?

上一讲我们深入研究了 Vue 里的 compiler-dom 和 compiler-core 的流程,相信学完之后,你已经对编译原理的基础知识很熟悉了。

这时候你肯定会有一个疑问,AST、transform、generate这些概念以前工作中也没遇见过,难道学了这个就只能面试用吗? 当然不是,编译原理作为计算机世界的一个重要的学科,除了探究原理和源码之外,我们工作中也有很多地方可以用到。

从宏观视角来看,编译原理实现的功能就是代码之间的转换。哪怕我们只是掌握了入门知识,也能可以实现Vue中 template到render函数转化这样的功能。

现在的前端发展,很大程度上离不开编译原理在前端圈的落地实践,只要是我们想做自动化代码转化的地方,都可以看到编译的身影。

举个例子,Babel把ES6中的新语法转换成低版本浏览器支持的语法,我们才能在项目中愉快地使用箭头函数等特性,把浏览器的兼容性交给Babel来处理,甚至现在社区内还出现了gogocode这种把Vue 2代码转换成Vue 3代码的工具。

在工作中我们可以借助Babel和vite提供给我们的能力,parse,transform,generate等代码都不需要我们自己实现,只需要考虑代码转换的逻辑就可以了,下面我给你举几个小例子。

vite 插件

首先我们在项目中使用了script setup来组织我们的代码,虽然组件引入之后有了自动注册的功能,但是每一个组件内部都肯定要用到ref、computed等Vue提供的API。我们还想要多一步,项目大了只引入ref的语句就写了几百行,就会非常地繁琐,这时候就可以使用编译的思想来解决这个问题。

首先ref、computed、watch等Vue提供的API,我们在后面的代码调用可以通过正则匹配的方式,完全可以分析出来当前组件依赖的API有哪些。这样,我们就可以在组件执行之前自动导入这些API。

我们在weiyouyi项目中使用vite插件的形式来完成这个工作。社区内已经有可用的 auto-imput 插件了,不过这里为了加深对技术的理解,咱们还是自己来实现一个。

首先我们进入到根目录下的vite.config.js文件中,导入autoPlugin插件后,配置在vite的plugins插件中。

1
2
3
4
5
import vue from '@vitejs/plugin-vue'
import autoPlgin from './src/auto-import'
export default defineConfig({
plugins: [vue(),autoPlgin()]
})

然后我们来实现autoPlugin函数,vite的插件开发文档你可以在 官网中 查询,这里就不赘述了。

我们直接看代码,我们先定义了Vue 3提供的API数组,有ref、computed等等。然后,autoImportPlugin函数对外导出一个对象,transform函数就是核心要实现的逻辑。

这里的helper和我们在32讲中的工具函数实现逻辑一致,通过new Regexp创建每个函数匹配的正则。如果匹配到对应的API,就把API的名字加入到helper集合中,最后在script setup的最上方加入一行import语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const vue3 = [
'ref',
'computed',
'reactive',
'onMounted',
'watchEffect',
'watch'
] // 还有很多....

export default function autoImportPlugin() {
return {
name: 'vite-plugin-auto-import', // 必须的,将会在 warning 和 error 中显示
enforce:'pre',
transform(code,id){
vueReg = /\.vue$/
if(vueReg.test(id)){
const helpers = new Set()
vue3.forEach(api=>{
const reg = new RegExp(api+"(.*)")
if(reg.test(code)){
helpers.add(api)
}
})
return code.replace('<script setup>',`<script setup>

import {${[...helpers].join(',')}} from 'vue' //俺是自动导入的
`)
}
return code
}
}
}

接着,我们在项目的src目录下新建App.vue。下面的代码实现了一个简易的累加器,并且还会在onMount之后打印一条信息,这里的ref、computedonMounted都是没有导入的。我们在浏览器就能看到页面可以正常显示,这时我们在浏览器调试窗口的sources页面中,就可以看到App.vue的代码已经自动加上了import语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div @click="add">
{{num}} * 2 = {{double}}
</div>
</template>

<script setup>
let num = ref(1)
let double = computed(()=>num.value*2)

function add(){
num.value++
}
onMounted(()=>{
console.log('mounted')
})

</script>

image-20231024164345134

这里的代码都是硬编码实现的,逻辑也比较简单。不过,实际场景中判断ref等API调用的正则和导入import的方式,都不会这么简单。如果我们自己每次都写一个parse模块比较麻烦,所以我们实际开发中会借助现有的工具对代码进行解析,而代码转换的场景下最成熟的工具就是Babel。

Babel

我们在项目中异步的任务有很多,经常使用async+ await的语法执行异步任务,比如网络数据的获取。但 await是异步任务,如果报错,我们需要使用try catch语句进行错误处理,每个catch语句都是一个打印语句会让代码变得冗余,但我们有了代码转化的思路后,这一步就能用编译的思路自动来完成。

首先我们在根目录的src/main.js中新增下面代码,我们使用delyError函数模拟异步的任务报错,在代码中使用await来模拟异步任务。

这里我们希望每个await都能跟着一个try代码,在catch中能够打印错误消息提示的同时,还能够使用调用错误监控的函数,把当前错误信息发给后端服务器进行报警,当然也可以打印一个自动去stackoverflow查询的链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function delyError(message){
return new Promise((resolve,reject)=>{
setTimeout(()=>{
reject({message})
},1000)
})
}
async function test(){
await delyError('ref is not defined')
}
// 我们期望的代码
async function test(){
try{
await delyError('ref is not defined')
}catche(e){
console.error(e.message)
_errorTrack(e.message,location.pathname)
console.log('https://stackoverflow.com/search?q=[js]+'+encodeURI(e.message))
}

}
test()

页面中await语句变多了之后,手动替换的成本就比较高,我们可以继续使用vite的插件来实现。这次我们就是用Babel提供好的代码解析能力对代码进行转换。Babel都提供了哪些API,你可以在 Babel的官网 进行深入学习。

Babel提供了完整的编译代码的功能后函数,包括AST的解析、语义分析、代码生成等,我们可以通过下面的函数去实现自己的插件。

  • @babel/parser提供了代码解析的能力,能够把js代码解析成AST,代码就从字符串变成了树形结构,方便我们进行操作;
  • @babel/traverse提供了遍历AST的能力,我们可以从travser中获取每一个节点的信息后去修改它;
  • @babe/types提供了类型判断的函数,我们可以很方便的判断每个节点的类型;
  • @babel/core提供了代码转化的能力。

下面的代码中我们实现了vite-plugin-auto-try插件,由babel/parer解析成为AST,通过travser遍历整个AST节点,配置的AwaitExpression会识别出AST中的await调用语句,再用isTryStatement判断await外层是否已经包裹了try语句。如果没有try语句的话,就使用tryStatement函数生成新的AST节点。

这个AST包裹当前的节点,并且我们在内部加上了stackoverflow链接的打印。最后,使用babel/core提供的transformFromAstSync函数,把优化后的AST生成新的JavaScript代码,自动新增try代码的插件就实现了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import {
isTryStatement,
tryStatement,
isBlockStatement,
catchClause,
identifier,
blockStatement,
} from '@babel/types'
import { transformFromAstSync } from '@babel/core'

const catchStatement = parse(`
console.error(err)
console.log('https://stackoverflow.com/search?q=[js]+'+encodeURI(err.message))
`).program.body

export default function autoImportPlugin() {
return {
name: 'vite-plugin-auto-try', // 必须的,将会在 warning 和 error 中显示
enforce:'pre',
transform(code,id){
fileReg = /\.js$/
if(fileReg.test(id)){
const ast = parse(code, {
sourceType: 'module'
})
traverse(ast, {
AwaitExpression(path){
console.log(path)
if (path.findParent((path) => isTryStatement(path.node))) {
// 已经有try了
return
}
// isBlockStatement 是否函数体
const blockParentPath = path.findParent((path) => isBlockStatement(path.node))
const tryCatchAst = tryStatement(
blockParentPath.node,
// ast中新增try的ast
catchClause(
identifier('err'),
blockStatement(catchStatement),
)
)
// 使用有try的ast替换之前的ast
blockParentPath.replaceWithMultiple([tryCatchAst])

}
})
// 生成代码,generate
code = transformFromAstSync(ast,"",{
configFile:false
}).code

return code
}
return code
}
}
}

然后,我们在根目录下的src/main.js中写入下面的代码。两个await语句一个使用try包裹,一个没有使用try包裹。

接着我们启动项目后,就来到了浏览器的调试窗口中的source页面,可以看到下图中解析后的main.js代码,现在没有try的await语句已经自动加上了try语句。

你看, 这次我们基于babel来实现,就省去了我们写正则的开发成本。Babel提供了一整套关于JavaScirpt中语句的转化函数,有兴趣的同学可以去Babel官网了解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { createApp } from "vue";
import App from './App.vue'

createApp(App)
.mount('#app')

async function test(){
await delyError('ref is not defined')
}

async function test2(){
try{
await delyError('reactive is not defined')
}catch(e){
console.error(e)
}
}
test()
function delyError(message){
return new Promise((resolve,reject)=>{
setTimeout(()=>{
reject({message})
},1000)
})
}

image-20231024165824260

有了Babel提供的能力之后,我们可以只关注于代码中需要转换的逻辑,比如我们可以使用Babel实现国际化,把每种语言在编译的时候自动替换语言,打包成独立的项目;也可以实现页面的自动化监控,在一些操作函数里面加入监控的代码逻辑。你可以自行发挥想象力,使用编译的思想来提高日常的开发效率。

最后我们回顾一下Vue中的compiler。Vue中的compiler-dom提供了compile函数,具体的compile逻辑我们在上一讲中已经详细学习了。其实我们也可以手动导入compiler-dom包之后,自己实现对vue template的解析。另外,Vue中还提供了@vue/compiler-sfc包,用来实现单文件组件.vue的解析,还有@vue/compiler-ssr包,它实现了服务端渲染的解析。

下一讲我们一起来手写vite的代码内容,我们就需要在nodejs中实现对Vue单文件组件的解析工作,实现浏览器中直接导入单文件组件的功能,敬请期待。

总结

我们把Vue内部的compiler原理融会贯通之后,今天尝试把template到render转化过程的思想应用到实际项目中。Vue中的compiler在转化的过程中还做了静态标记的优化,我们在实际开发中可以借鉴编译的思路,提高开发的效率。

我们一起回顾一下代码自动导入的操作思路。首先我们可以实现页面中ref、computed的API的自动化导入,在vite插件的transform函数中获取到待转换的代码,通过对代码的内容进行正则匹配,实现如果出现了ref,computed等函数的调用,我们可以把这些依赖的函数收集在helper中。最终在script setup标签之前新增import语句来导入依赖的API,最终就可以实现代码的自动导入。

实际开发中,我们可以把使用到的组件库Element3,工具函数vueuse等框架都进行语法的解析,实现函数和组件的自动化导入和按需加载。这样能在提高开发效率的同时,也提高我们书写vite插件的能力

Vite原理:写一个迷你的Vite

上一讲学完了Vue的编译原理后,我们就把Vue的整体流程梳理完毕了,但是我们在使用Vue的时候,还会用到很多Vue生态的库。所以从今天开始,我会带你了解几个Vue生态中重要成员的原理和源码,今天我先带你剖析一下我们项目中用的工程化工具Vite的原理。

现在工程化的痛点

现在前端开发项目的时候,工程化工具已经成为了标准配置,webpack是现在使用率最高的工程化框架,它可以很好地帮助我们完成从代码调试到打包的全过程,但是随着项目规模的爆炸式增长, webpack也带来了一些痛点问题

最早webpack可以帮助我们在JavaScript文件中使用require导入其他JavaScript、CSS、image等文件,并且提供了dev-server启动测试服务器,极大地提高了我们开发项目的效率。

webpack的核心原理就是通过分析JavaScript中的require语句,分析出当前JavaScript文件所有的依赖文件,然后递归分析之后,就得到了整个项目的一个依赖图。对图中不同格式的文件执行不同的loader,比如会把CSS文件解析成加载CSS标签的JavaScript代码,最后基于这个依赖图获取所有的文件。进行打包处理之后,放在内存中提供给浏览器使用,然后dev-server会启动一个测试服务器打开页面,并且在代码文件修改之后可以通过WebSocket通知前端自动更新页面, 也就是我们熟悉的热更新功能

由于webpack在项目调试之前,要把所有文件的依赖关系收集完,打包处理后才能启动测试,很多大项目我们执行调试命令后需要等1分钟以上才能开始调试。这对于开发者来说,这段时间除了摸鱼什么都干不了,而且热更新也需要等几秒钟才能生效,极大地影响了我们开发的效率。所以针对webpack这种打包bundle的思路,社区就诞生了bundless的框架,Vite就是其中的佼佼者。

前端的项目之所以需要webpack打包,是因为 浏览器里的JavaScript没有很好的方式去引入其他文件。webpack提供的打包功能可以帮助我们更好地组织开发代码,但是现在大部分浏览器都支持了ES6的module功能,我们在浏览器内使用type=”module”标记一个script后,在src/main.js中就可以直接使用import语法去引入一个新的JavaScript文件。这样我们其实可以不依赖webpack的打包功能,利用浏览器的module功能就可以重新组织我们的代码。

1
<script type="module" src="/src/main.js"></script>

Vite原理

了解了script的使用方式之后,我们来实现一个 迷你的 Vite 来讲解其大致的原理。

首先,浏览器的module功能有一些限制需要额外处理。浏览器识别出JavaScript中的import语句后,会发起一个新的网络请求去获取新的文件,所以只支持/./…/开头的路径

而在下面的Vue项目启动代码中,首先浏览器并不知道Vue是从哪来,我们第一个要做的,就是分析文件中的import语句。**如果路径不是一个相对路径或者绝对路径,那就说明这个模块是来自node\_modules**,我们需要去node_modules查找这个文件的入口文件后返回浏览器。然后 ./App.vue是相对路径,可以找到文件,但是浏览器不支持 .vue文件的解析,并且index.css也不是一个合法的JavaScript文件。

我们需要解决以上三个问题,才能让Vue项目很好地在浏览器里跑起来。

1
2
3
4
5
6
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

const app = createApp(App)
app.mount('#app')

怎么做呢?首先我们需要使用Koa搭建一个server,用来拦截浏览器发出的所有网络请求,才能实现上述功能。在下面代码中,我们使用Koa启动了一个服务器,并且访问首页内容读取index.html的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const app = new Koa()

app.use(async ctx=>{
const {request:{url,query} } = ctx
if(url=='/'){
ctx.type="text/html"
let content = fs.readFileSync('./index.html','utf-8')

ctx.body = content
}
})
app.listen(24678, ()=>{
console.log('快来快来数一数,端口24678')
})

下面就是首页index.html的内容,一个div作为Vue启动的容器,并且通过script引入src.main.js。我们访问首页之后,就会看到浏览器内显示的geektime文本,并且发起了一个main.js的HTTP请求, 然后我们来解决页面中的报错问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<h1>geek time</h1>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

首先import {createApp} from Vue这一步由于浏览器无法识别Vue的路径,就会直接抛出错误,所以我们要在Koa中把Vue的路径重写。为了方便演示,我们可以直接使用replace语句,把Vue改成/@modules/vue,使用@module开头的地址来告诉Koa这是一个需要去node_modules查询的模块。

在下面的代码中,我们判断如果请求地址是js结尾,就去读取对应的文件内容,使用rewriteImport函数处理后再返回文件内容。在rewriteImport中我们实现了路径的替换,把Vue变成了 @modules/vue, 现在浏览器就会发起一个 http://localhost:24678/@modules/vue 的请求,下一步我们要在Koa中拦截这个请求,并且返回Vue的代码内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const app = new Koa()

function rewriteImport(content){
return content.replace(/ from ['|"]([^'"]+)['|"]/g, function(s0,s1){
// . ../ /开头的,都是相对路径
if(s1[0]!=='.'&& s1[1]!=='/'){
return ` from '/@modules/${s1}'`
}else{
return s0
}
})
}

app.use(async ctx=>{
const {request:{url,query} } = ctx
if(url=='/'){
ctx.type="text/html"
let content = fs.readFileSync('./index.html','utf-8')

ctx.body = content
}else if(url.endsWith('.js')){
// js文件
const p = path.resolve(__dirname,url.slice(1))
ctx.type = 'application/javascript'
const content = fs.readFileSync(p,'utf-8')
ctx.body = rewriteImport(content)
}
})
app.listen(24678, ()=>{
console.log('快来快来说一书,端口24678')
})

image-20231024172127740

然后我们在Koa中判断请求地址,如果是@module的地址,就把后面的Vue解析出来,去node_modules中查询。然后拼接出目标路径 ./node_modules/vue/package.json去读取Vue项目中package.json的module字段,这个字段的地址就是 ES6 规范的入口文件。在我们读取到文件后,再使用rewriteImport处理后返回即可。

这里还要使用rewriteImport的原因是,Vue文件内部也会使用import的语法去加载其他模块。然后我们就可以看到浏览器网络请求列表中多了好几个Vue的请求。

1
2
3
4
5
6
7
8
9
else if(url.startsWith('/@modules/')){
// 这是一个node_module里的东西
const prefix = path.resolve(__dirname,'node_modules',url.replace('/@modules/',''))
const module = require(prefix+'/package.json').module
const p = path.resolve(prefix,module)
const ret = fs.readFileSync(p,'utf-8')
ctx.type = 'application/javascript'
ctx.body = rewriteImport(ret)
}

image-20231024172823812

这样我们就实现了node_modules模块的解析,然后我们来处理浏览器无法识别 .vue文件的错误。

.vue文件是Vue中特有的文件格式,我们上一节课提过Vue内部通过@vue/compiler-sfc来解析单文件组件,把组件分成template、style、script三个部分,我们要做的就是在Node环境下,把template的内容解析成render函数,并且和script的内容组成组件对象,再返回即可。

其中,compiler-dom解析template的流程我们学习过,今天我们来看下如何使用。

在下面的代码中,我们判断 .vue的文件请求后,通过compilerSFC.parse方法解析Vue组件,通过返回的descriptor.script获取JavaScript代码,并且发起一个type=template的方法去获取render函数。在query.type是template的时候,调用compilerDom.compile解析template内容,直接返回render函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const compilerSfc = require('@vue/compiler-sfc') // .vue
const compilerDom = require('@vue/compiler-dom') // 模板

if(url.indexOf('.vue')>-1){
// vue单文件组件
const p = path.resolve(__dirname, url.split('?')[0].slice(1))
const {descriptor} = compilerSfc.parse(fs.readFileSync(p,'utf-8'))

if(!query.type){
ctx.type = 'application/javascript'
// 借用vue自导的compile框架 解析单文件组件,其实相当于vue-loader做的事情
ctx.body = `
${rewriteImport(descriptor.script.content.replace('export default ','const __script = '))}
import { render as __render } from "${url}?type=template"
__script.render = __render
export default __script
`
}else if(query.type==='template'){
// 模板内容
const template = descriptor.template
// 要在server端吧compiler做了
const render = compilerDom.compile(template.content, {mode:"module"}).code
ctx.type = 'application/javascript'

ctx.body = rewriteImport(render)
}

上面的代码实现之后,我们就可以在浏览器中看到App.vue组件解析的结果。App.vue会额外发起一个App.vue?type=template的请求,最终完成了整个App组件的解析。

image-20231024173425939

image-20231024173447131

接下来我们再来实现对CSS文件的支持。 下面的代码中,如果url是CSS结尾,我们就返回一段JavaScript代码。这段JavaScript代码会在浏览器里创建一个style标签,标签内部放入我们读取的CSS文件代码。这种对CSS文件的处理方式,让CSS以JavaScript的形式返回,这样我们就实现了在Node中对Vue组件的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if(url.endsWith('.css')){
const p = path.resolve(__dirname,url.slice(1))
const file = fs.readFileSync(p,'utf-8')
const content = `
const css = "${file.replace(/\n/g,'')}"
let link = document.createElement('style')
link.setAttribute('type', 'text/css')
document.head.appendChild(link)
link.innerHTML = css
export default css
`
ctx.type = 'application/javascript'
ctx.body = content
}

image-20231024173706837

Vite的热更新

最后我们再来看一下热更新如何实现。热更新的目的就是在我们修改代码之后, 浏览器能够自动渲染更新的内容,所以我们要在客户端注入一个额外的JavaScript文件,这个文件用来和后端实现WebSocket通信。然后后端启动WebSocket服务,通过chokidar库监听文件夹的变化后,再通过WebSocket去通知浏览器即可。

下面的代码中,我们通过chokidar.watch实现了文件夹变更的监听,并且通过handleHMRUpdate通知客户端文件更新的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export function watch() {
const watcher = chokidar.watch(appRoot, {
ignored: ['**/node_modules/**', '**/.git/**'],
ignoreInitial: true,
ignorePermissionErrors: true,
disableGlobbing: true,
});
watcher;

return watcher;
}
export function handleHMRUpdate(opts: { file: string; ws: any }) {
const { file, ws } = opts;
const shortFile = getShortName(file, appRoot);
const timestamp = Date.now();

console.log(`[file change] ${chalk.dim(shortFile)}`);
let updates;
if (shortFile.endsWith('.css')) {
updates = [
{
type: 'js-update',
timestamp,
path: `/${shortFile}`,
acceptedPath: `/${shortFile}`,
},
];
}

ws.send({
type: 'update',
updates,
});
}

然后客户端注入一段额外的JavaScript代码,判断后端传递的类型是js-update还是css-update去执行不同的函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function handleMessage(payload: any) {
switch (payload.type) {
case 'connected':
console.log(`[vite] connected.`);

setInterval(() => socket.send('ping'), 30000);
break;

case 'update':
payload.updates.forEach((update: Update) => {
if (update.type === 'js-update') {
fetchUpdate(update);
}
});
break;
}
}

总结

首先,我们通过了解webpack的大致原理,知道了现在webpack在开发体验上的痛点。除了用户体验UX之外,开发者的体验DX也是项目质量的重要因素。

webpack启动服务器之前需要进行项目的打包,而Vite则是可以直接启动服务,通过浏览器运行时的请求拦截,实现首页文件的按需加载,这样开发服务器启动的时间就和整个项目的复杂度解耦。任何时候我们启动Vite的调试服务器,基本都可以在一秒以内响应,这极大地提升了开发者的体验,这也是Vite的使用率越来越高的原因。

并且我们可以看到,Vite的主要目的就是提供一个调试服务器。Vite也可以和Vue解耦,实现对任何框架的支持,如果使用Vite支持React,只需要解析React中的JSX就可以实现。这也是Vite项目的现状,我们只需要使用框架对应的Vite插件就可以支持任意框架。

Vite能够做到这么快的原因,还有一部分是因为使用了esbuild去解析JavaScript文件。esbuild是一个用Go语言实现的JavaScript打包器,支持JavaScript和TypeScript语法,现在前端工程化领域的工具也越来越多地使用Go和Rust等更高效的语言书写,这也是性能优化的一个方向。

数据流原理:Vuex & Pinia源码剖析

其实在之前的课程中,我们已经实现过一个迷你的Vuex,整体代码逻辑比较简单,基于Vue提供的响应式函数reactive和computed的能力,我们封装了一个独立的共享数据的store,并且对外暴露了commit和dispatch方法修改和更新数据,这些原理就不赘述了。

今天我们探讨一下下一代Vuex5的提案,并且看一下实际的代码是如何实现的,你学完之后可以对比之前gvuex mini版本,感受一下两者的区别。

Vuex5提案

由于Vuex有模块化namespace的功能,所以模块user中的mutation add方法,我们需要使用 commit('user/add') 来触发。这样虽然可以让Vuex支持更复杂的项目,但是 这种字符串类型的拼接功能,在TypeScript4之前的类型推导中就很难实现。然后就有了Vuex5相关提案的讨论,整个讨论过程都是在GitHub的issue里推进的,你可以访问 GitHub链接 去围观。

Vuex5的提案相比Vuex4有很大的改进,解决了一些Vuex4中的缺点。Vuex5能够同时支持Composition API和Option API,并且去掉了namespace模式,使用组合store的方式更好地支持了TypeScript的类型推导,还去掉了容易混淆的Mutation和Action概念,只保留了Action,并且 支持自动的代码分割

我们也可以通过对这个提案的研究,来体验一下在一个框架中如何讨论新的语法设计和实现,以及如何通过API的设计去解决开发方式的痛点。你可以在Github的提案RFCs中看到 Vuex5的设计文稿,而Pinia正是基于Vuex5设计的框架。

现在Pinia已经正式合并到Vue组织下,成为了Vue的官方项目,尤雨溪也在多次分享中表示 Pinia就是未来的Vuex,接下来我们就好好学习一下Pinia的使用方式和实现的原理。

Pinia

下图是Pinia官网的介绍,可以看到类型安全、Vue 的Devtools支持、易扩展、只有1KB的体积等优点。快来看下Pinia如何使用吧。

image-20231025152626877

首先我们在项目根目录下执行下面的命令去 安装Pinia的最新版本

1
npm install pinia@next

然后在src/main.js中,我们导入createPinia方法,通过createPinia方法创建Pinia的实例后,再通过app.use方法注册Pinia。

1
2
3
4
5
6
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia).mount('#app')

然后我们可以在store文件夹中创建一个count.js。下面的代码中我们通过Pinia的defineStore方法定义了一个store,store内部通过state返回一个对象,并且通过Actions配置修改数据的方法add。这里使用的语法和Vuex比较类似,只是删除了Mutation的概念, 统一使用Actions来配置

1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('count', {
id:'count',
  state: () => {
    return { count: 1 }
  },
  actions: {
    add() {
      this.count++
    },
  },
})

然后我们可以使用Composition的方式在代码中使用store。注意上面的store返回的其实就是一个Composition风格的函数,使用useCounterStore返回count后,可以在add方法中直接使用count.add触发Actions,实现数据的修改。

1
2
3
4
5
6
import { useCounterStore } from '../stores/count'

const count = useCounterStore()
function add(){
count.add()
}

我们也可以使用Composition风格的语法,去创建一个store。 使用ref或者reactive包裹后,通过defineStore返回,这样store就非常接近我们自己分装的Composition语法了,也去除了很多Vuex中特有的概念,学习起来更加简单。

1
2
3
4
5
6
7
8
export const useCounterStore = defineStore('count', () => {
  const count = ref(0)
  function increment() {
    count.value++
  }

  return { count, increment }
})

Pinna源码

然后我们通过阅读Pinia的源码,来看下Pinia是如何实现的。

首先我们进入到Pinia的GitHub中,我们可以在packages/pinia/src/createPinia.ts中看到createPinia函数的实现。

下面的代码中,我们通过effectScope创建一个作用域对象,并且通过ref创建了响应式的数据对象state。然后通过install方法支持了app.use的注册,内部通过provide的语法和全局的$pinia变量配置Pinia对象,并且通过use方法和toBeInstalled数组实现了Pinia的插件机制。 最后还通过pinia.use(devtoolsPlugin) 实现了对VueDevtools的支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
export function createPinia(): Pinia {
const scope = effectScope(true)
// NOTE: here we could check the window object for a state and directly set it
// if there is anything like it with Vue 3 SSR
const state = scope.run(() => ref<Record<string, StateTree>>({}))!

let _p: Pinia['_p'] = []
// plugins added before calling app.use(pinia)
let toBeInstalled: PiniaPlugin[] = []

const pinia: Pinia = markRaw({
install(app: App) {
// this allows calling useStore() outside of a component setup after
// installing pinia's plugin
setActivePinia(pinia)
if (!isVue2) {
pinia._a = app
app.provide(piniaSymbol, pinia)
app.config.globalProperties.$pinia = pinia
toBeInstalled.forEach((plugin) => _p.push(plugin))
toBeInstalled = []
}
},

use(plugin) {
if (!this._a && !isVue2) {
toBeInstalled.push(plugin)
} else {
_p.push(plugin)
}
return this
},

_p,
_a: null,
_e: scope,
_s: new Map<string, StoreGeneric>(),
state,
})
if (__DEV__ && IS_CLIENT) {
pinia.use(devtoolsPlugin)
}

return pinia
}

通过上面的代码,我们可以看到Pinia实例就是 ref({}) 包裹的响应式对象,项目中用到的state都会挂载到Pinia这个响应式对象内部。

然后我们去看下创建store的defineStore方法, defineStore内部通过useStore方法去定义store,并且每个store都会标记唯一的ID。

首先通过getCurrentInstance获取当前组件的实例,如果useStore参数没有Pinia的话,就使用inject去获取Pinia实例, 这里inject的数据就是createPinia函数中install方法提供的

然后设置activePinia,项目中可能会存在很多Pinia的实例,设置activePinia就是设置当前活跃的Pinia实例。这个函数的实现方式和Vue中的componentInstance很像,每次创建组件的时候都设置当前的组件实例,这样就可以在组件的内部通过getCurrentInstance获取,最后通过createSetupStore或者createOptionsStore创建组件。

这就是上面代码中我们使用Composition和Option两种语法创建store的不同执行逻辑,最后通过pinia._s缓存创建后的store,_s就是在createPinia的时候创建的一个Map对象,防止store多次重复创建。 到这store创建流程就结束了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
export function defineStore(
// TODO: add proper types from above
idOrOptions: any,
setup?: any,
setupOptions?: any
): StoreDefinition {
let id: string
let options:...
const isSetupStore = typeof setup === 'function'
if (typeof idOrOptions === 'string') {
id = idOrOptions
// the option store setup will contain the actual options in this case
options = isSetupStore ? setupOptions : setup
} else {
options = idOrOptions
id = idOrOptions.id
}

function useStore(pinia?: Pinia | null, hot?: StoreGeneric): StoreGeneric {
const currentInstance = getCurrentInstance()
pinia =
// in test mode, ignore the argument provided as we can always retrieve a
// pinia instance with getActivePinia()
(__TEST__ && activePinia && activePinia._testing ? null : pinia) ||
(currentInstance && inject(piniaSymbol))
if (pinia) setActivePinia(pinia)

pinia = activePinia!

if (!pinia._s.has(id)) {
// creating the store registers it in `pinia._s`
if (isSetupStore) {
createSetupStore(id, setup, options, pinia)
} else {
createOptionsStore(id, options as any, pinia)
}

/* istanbul ignore else */
if (__DEV__) {
// @ts-expect-error: not the right inferred type
useStore._pinia = pinia
}
}

const store: StoreGeneric = pinia._s.get(id)!

// save stores in instances to access them devtools
if (
__DEV__ &&
IS_CLIENT &&
currentInstance &&
currentInstance.proxy &&
// avoid adding stores that are just built for hot module replacement
!hot
) {
const vm = currentInstance.proxy
const cache = '_pStores' in vm ? vm._pStores! : (vm._pStores = {})
cache[id] = store
}

// StoreGeneric cannot be casted towards Store
return store as any
}

useStore.$id = id

return useStore
}

在Pinia中createOptionsStore内部也是调用了createSetupStore来创建store对象。下面的代码中,我们通过assign方法实现了setup函数,这里可以看到computed的实现,内部就是通过pinia._s缓存获取store对象,调用store的getters方法来模拟,最后依然通过createSetupStore创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function createOptionsStore<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A extends _ActionsTree
>(
id: Id,
options: DefineStoreOptions<Id, S, G, A>,
pinia: Pinia,
hot?: boolean
): Store<Id, S, G, A> {
const { state, actions, getters } = options

const initialState: StateTree | undefined = pinia.state.value[id]

let store: Store<Id, S, G, A>

function setup() {

pinia.state.value[id] = state ? state() : {}
return assign(
localState,
actions,
Object.keys(getters || {}).reduce((computedGetters, name) => {
computedGetters[name] = markRaw(
computed(() => {
setActivePinia(pinia)
// it was created just before
const store = pinia._s.get(id)!
return getters![name].call(store, store)
})
)
return computedGetters
}, {} as Record<string, ComputedRef>)
)
}

store = createSetupStore(id, setup, options, pinia, hot)

return store as any
}

最后我们来看一下createSetupStore函数的实现。这个函数也是Pinia中最复杂的函数实现,内部的$patch函数可以实现数据的更新。如果传递的参数partialStateOrMutator是函数,则直接执行,否则就通过mergeReactiveObjects方法合并到state中,最后生成subscriptionMutation对象, 通过triggerSubscriptions方法触发数据的更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function $patch(
partialStateOrMutator:
| _DeepPartial<UnwrapRef<S>>
| ((state: UnwrapRef<S>) => void)
): void {
let subscriptionMutation: SubscriptionCallbackMutation<S>
isListening = isSyncListening = false
// reset the debugger events since patches are sync
/* istanbul ignore else */
if (__DEV__) {
debuggerEvents = []
}
if (typeof partialStateOrMutator === 'function') {
partialStateOrMutator(pinia.state.value[$id] as UnwrapRef<S>)
subscriptionMutation = {
type: MutationType.patchFunction,
storeId: $id,
events: debuggerEvents as DebuggerEvent[],
}
} else {
mergeReactiveObjects(pinia.state.value[$id], partialStateOrMutator)
subscriptionMutation = {
type: MutationType.patchObject,
payload: partialStateOrMutator,
storeId: $id,
events: debuggerEvents as DebuggerEvent[],
}
}
nextTick().then(() => {
isListening = true
})
isSyncListening = true
// because we paused the watcher, we need to manually call the subscriptions
triggerSubscriptions(
subscriptions,
subscriptionMutation,
pinia.state.value[$id] as UnwrapRef<S>
)
}

然后定义partialStore对象去存储ID、$patch、Pinia实例,并且新增了subscribe方法。再调用reactive函数把partialStore包裹成响应式对象,通过pinia._s.set的方法实现store的挂载。

最后我们通过pinia._s.get获取的就是partialStore对象,defineStore返回的方法useStore就可以通过useStore去获取缓存的Pinia对象,实现对数据的更新和读取。

这里我们也可以看到,除了直接执行Action方法,还可以通过调用内部的 count.$patch({count:count+1}) 的方式来实现数字的累加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const partialStore = {
_p: pinia,
// _s: scope,
$id,
$onAction: addSubscription.bind(null, actionSubscriptions),
$patch,
$reset,
$subscribe(callback, options = {}) {
const removeSubscription = addSubscription(
subscriptions,
callback,
options.detached,
() => stopWatcher()
)
const stopWatcher = scope.run(() =>
watch(
() => pinia.state.value[$id] as UnwrapRef<S>,
(state) => {
if (options.flush === 'sync' ? isSyncListening : isListening) {
callback(
{
storeId: $id,
type: MutationType.direct,
events: debuggerEvents as DebuggerEvent,
},
state
)
}
},
assign({}, $subscribeOptions, options)
)
)!

return removeSubscription
}


const store: Store<Id, S, G, A> = reactive(
assign({}, partialStore )
)

// store the partial store now so the setup of stores can instantiate each other before they are finished without
// creating infinite loops.
pinia._s.set($id, store)

我们可以看出一个简单的store功能,真正需要支持生产环境的时候,也需要很多逻辑的封装。

代码内部除了__dev__调试环境中对Devtools支持的语法,还有很多适配Vue 2的语法,并且同时支持Optipn风格和Composition风格去创建store。createSetupStore等方法内部也会通过Map的方式实现缓存,并且setActivePinia方法可以在多个Pinia实例的时候获取当前的实例。

这些思路在Vue、vue-router源码中都能看到类似的实现方式,这种性能优化的思路和手段也值得我们学习,在项目开发中也可以借鉴。

总结

Vuex5针对Vuex4中的几个痛点,去掉了容易混淆的概念Mutation,并且去掉了对TypeScript不友好的namespace功能,使用组合store的方式让Vuex对TypeScript更加友好。

Pinia就是Vuex5提案产出的框架,现在已经是Vue官方的框架了,也就是Vuex5的实现。在Pinia的代码中,我们通过createPinia创建Pinia实例,并且可以通过Option和Composition两种风格的API去创建store,返回 useStore 函数获取Pinia的实例后,就可以进行数据的修改和读取。

前端路由原理:vue-router源码剖析

课程中我们也实现过一个迷你的router,我们通过监听路由的变化,把路由数据包裹成响应式对象后,一旦路由发生变化,我们就去定义好的路由数据中查询当前路由对应的组件,在router-view中渲染即可。今天我们就进入到vue-router源码的内部,看一下实际的vue-router和我们实现的迷你版本有什么区别。

vue-router入口分析

vue-router提供了createRouter方法来创建路由配置,我们传入每个路由地址对应的组件后,使用app.use在Vue中加载vue-router插件,并且给Vue注册了两个内置组件,router-view负责渲染当前路由匹配的组件,router-link负责页面的跳转。

我们先来看下createRouter如何实现,完整的代码你可以在 GitHub 上看到。这个函数比较长,还好我们有TypeScript,我们先看下createRouter的参数。

在下面的代码中,参数RouterOptions是规范我们配置的路由对象,主要包含history、routes等数据。routes就是我们需要配置的路由对象,类型是RouteRecordRaw组成的数组,并且RouteRecordRaw的类型是三个类型的合并。然后返回值的类型Router就是包含了addRoute、push、beforeEnter、install方法的一个对象, 并且维护了currentRoute和options两个属性

并且每个类型方法还有详细的注释,这也极大降低了阅读源码的门槛,可以帮助我们在看到函数的类型时就知道函数大概的功能。我们知道Vue中app.use实际上执行的就是router对象内部的install方法,我们先进入到install方法看下是如何安装的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// createRouter传递参数的类型
export interface RouterOptions extends PathParserOptions {
history: RouterHistory
routes: RouteRecordRaw[]
scrollBehavior?: RouterScrollBehavior
...
}
// 每个路由配置的类型
export type RouteRecordRaw =
| RouteRecordSingleView
| RouteRecordMultipleViews
| RouteRecordRedirect

//... other config
// Router接口的全部方法和属性
export interface Router {
readonly currentRoute: Ref<RouteLocationNormalizedLoaded>
readonly options: RouterOptions

addRoute(parentName: RouteRecordName, route: RouteRecordRaw): () => void
addRoute(route: RouteRecordRaw): () => void
Route(name: RouteRecordName): void
hasRoute(name: RouteRecordName): boolean

getRoutes(): RouteRecord[]
resolve(
to: RouteLocationRaw,
currentLocation?: RouteLocationNormalizedLoaded
): RouteLocation & { href: string }
push(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>
replace(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>
back(): ReturnType<Router['go']>
forward(): ReturnType<Router['go']>
go(delta: number): void
beforeEach(guard: NavigationGuardWithThis<undefined>): () => void
beforeResolve(guard: NavigationGuardWithThis<undefined>): () => void
afterEach(guard: NavigationHookAfter): () => void
onError(handler: _ErrorHandler): () => void
isReady(): Promise<void>
install(app: App): void
}

export function createRouter(options: RouterOptions): Router {

}

路由安装

从下面的代码中我们可以看到,在createRouter的最后,创建了包含addRoute、push等方法的对象,并且install方法内部注册了RouterLink和RouterView两个组件。所以我们可以在任何组件内部直接使用<router-view><router-link>组件,然后注册全局变量$router和$route,其中$router就是我们通过createRouter返回的路由对象,包含addRoute、push等方法,$route使用defineProperty的形式返回currentRoute的值,可以做到和currentRoute值同步。

然后使用computed把路由变成响应式对象,存储在reactiveRoute对象中,再通过app.provide给全局注册了route和reactive包裹后的reactiveRoute对象。我们之前介绍provide函数的时候也介绍了,provide提供的数据并没有做响应式的封装, 需要响应式的时候需要自己使用ref或者reactive封装为响应式对象,最后注册unmount方法实现vue-router的安装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
export function createRouter(options: RouterOptions): Router {
....
let started: boolean | undefined
const installedApps = new Set<App>()
// 路由对象
const router: Router = {
currentRoute,

addRoute,
removeRoute,
hasRoute,
getRoutes,
resolve,
options,

push,
replace,
go,
back: () => go(-1),
forward: () => go(1),

beforeEach: beforeGuards.add,
beforeResolve: beforeResolveGuards.add,
afterEach: afterGuards.add,

onError: errorHandlers.add,
isReady,
// 插件按章
install(app: App) {
const router = this
// 注册全局组件 router-link和router-view
app.component('RouterLink', RouterLink)
app.component('RouterView', RouterView)

app.config.globalProperties.$router = router
Object.defineProperty(app.config.globalProperties, '$route', {
enumerable: true,
get: () => unref(currentRoute),
})
if (
isBrowser &&
!started &&
currentRoute.value === START_LOCATION_NORMALIZED
) {
// see above
started = true
push(routerHistory.location).catch(err => {
if (__DEV__) warn('Unexpected error when starting the router:', err)
})
}

const reactiveRoute = {} as {
[k in keyof RouteLocationNormalizedLoaded]: ComputedRef<
RouteLocationNormalizedLoaded[k]
>
}
for (const key in START_LOCATION_NORMALIZED) {
// @ts-expect-error: the key matches
reactiveRoute[key] = computed(() => currentRoute.value[key])
}
// 提供全局配置
app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))
app.provide(routerViewLocationKey, currentRoute)

const unmountApp = app.unmount
installedApps.add(app)
app.unmount = function () {
installedApps.delete(app)
// ...
unmountApp()
}

if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && isBrowser) {
addDevtools(app, router, matcher)
}
},
}

return router
}

路由对象创建和安装之后,我们 下一步需要了解的就是router-link和router-view两个组件的实现方式

通过下面的代码我们可以看到,RouterView的setup函数返回了一个函数,这个函数就是RouterView组件的render函数。大部分我们使用的方式就是一个<router-view />组件,没有slot情况下返回的就是component变量。component使用h函数返回ViewComponent的虚拟DOM,而ViewComponent是根据matchedRoute.components[props.name]计算而来。

matchedRoute依赖的matchedRouteRef的计算逻辑在如下代码的第12~15行,数据来源injectedRoute就是上面我们注入的currentRoute对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
export const RouterViewImpl = /*#__PURE__*/ defineComponent({
name: 'RouterView',
props: {
name: {
type: String as PropType<string>,
default: 'default',
},
route: Object as PropType<RouteLocationNormalizedLoaded>,
},
// router-view组件源码
setup(props, { attrs, slots }) {
// 全局的reactiveRoute对象注入
const injectedRoute = inject(routerViewLocationKey)!

const routeToDisplay = computed(() => props.route || injectedRoute.value)
const depth = inject(viewDepthKey, 0)
const matchedRouteRef = computed<RouteLocationMatched | undefined>(
() => routeToDisplay.value.matched[depth]
)
// 嵌套层级
provide(viewDepthKey, depth + 1)
// 匹配的router对象
provide(matchedRouteKey, matchedRouteRef)
provide(routerViewLocationKey, routeToDisplay)

const viewRef = ref<ComponentPublicInstance>()
// 返回的render函数
return () => {
const route = routeToDisplay.value
const matchedRoute = matchedRouteRef.value
const ViewComponent = matchedRoute && matchedRoute.components[props.name]
const currentName = props.name

if (!ViewComponent) {
return normalizeSlot(slots.default, { Component: ViewComponent, route })
}

// props from route configuration
const routePropsOption = matchedRoute!.props[props.name]
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null

const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => {
// remove the instance reference to prevent leak
if (vnode.component!.isUnmounted) {
matchedRoute!.instances[currentName] = null
}
}
// 创建需要渲染组件的虚拟dom
const component = h(
ViewComponent,
assign({}, routeProps, attrs, {
onVnodeUnmounted,
ref: viewRef,
})
)

return (
// pass the vnode to the slot as a prop.
// h and <component :is="..."> both accept vnodes
normalizeSlot(slots.default, { Component: component, route }) ||
component
)
}
},
})

路由更新

到这我们可以看出,RouterView渲染的组件是由当前匹配的路由变量matchedRoute决定的。接下来我们回到createRouter函数中,可以看到matcher对象是由createRouterMatcher创建,createRouterMatcher函数传入routes配置的路由数组,并且返回创建的RouterMatcher对象,内部遍历routes数组,通过addRoute挨个处理路由配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export function createRouter(options: RouterOptions): Router {
const matcher = createRouterMatcher(options.routes, options)
///....
}
export function createRouterMatcher(
routes: RouteRecordRaw[],
globalOptions: PathParserOptions
): RouterMatcher {
// matchers数组
const matchers: RouteRecordMatcher[] = []
// matcher对象
const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
globalOptions = mergeOptions(
{ strict: false, end: true, sensitive: false } as PathParserOptions,
globalOptions
)
function addRoute(){}
function remoteRoute(){}
function getRoutes(){
return matchers
}
function insertMatcher(){}
function resolve(){}
// add initial routes
routes.forEach(route => addRoute(route))

return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
}

在下面的代码中我们可以看到,addRoute函数内部通过createRouteRecordMatcher创建扩展之后的matcher对象,包括了record、parent、children等树形,可以很好地描述路由之间的嵌套父子关系。这样整个路由对象就已经创建完毕,那我们如何在路由切换的时候寻找到正确的路由对象呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function addRoute(
record: RouteRecordRaw,
parent?: RouteRecordMatcher,
originalRecord?: RouteRecordMatcher
){
if ('alias' in record) {
// 标准化alias
}
for (const normalizedRecord of normalizedRecords) {
// ...
matcher = createRouteRecordMatcher(normalizedRecord, parent, options)
insertMatcher(matcher)

}
return originalMatcher
? () => {
// since other matchers are aliases, they should be removed by the original matcher
removeRoute(originalMatcher!)
}
: noop

}

export function createRouteRecordMatcher(
record: Readonly<RouteRecord>,
parent: RouteRecordMatcher | undefined,
options?: PathParserOptions
): RouteRecordMatcher {
const parser = tokensToParser(tokenizePath(record.path), options)
const matcher: RouteRecordMatcher = assign(parser, {
record,
parent,
// these needs to be populated by the parent
children: [],
alias: [],
})

if (parent) {
if (!matcher.record.aliasOf === !parent.record.aliasOf)
parent.children.push(matcher)
}

return matcher
}

在vue-router中,路由更新可以通过router-link渲染的链接实现,也可以使用router对象的push等方法实现。下面的代码中,router-link组件内部也是渲染一个a标签,并且注册了a标签的onClick函数,内部也是通过router.replace或者router.push来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
export const RouterLinkImpl = /*#__PURE__*/ defineComponent({
name: 'RouterLink',
props: {
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
...
},
// router-link源码
setup(props, { slots }) {
const link = reactive(useLink(props))
const { options } = inject(routerKey)!

const elClass = computed(() => ({
...
}))

return () => {
const children = slots.default && slots.default(link)
return props.custom
? children
: h(
'a',
{
href: link.href,
onClick: link.navigate,
class: elClass.value,
},
children
)
}
},
})
// 跳转
function navigate(
e: MouseEvent = {} as MouseEvent
): Promise<void | NavigationFailure> {
if (guardEvent(e)) {
return router[unref(props.replace) ? 'replace' : 'push'](
unref(props.to)
// avoid uncaught errors are they are logged anyway
).catch(noop)
}
return Promise.resolve()
}

现在我们回到createRouter函数中,可以看到push函数直接调用了pushWithRedirect函数来实现,内部通过resolve(to)生成targetLocation变量。这个变量会赋值给toLocation,然后执行navigate(toLocation)函数。而 这个函数内部会执行一系列的导航守卫函数,最后会执行finalizeNavigation函数完成导航。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function push(to: RouteLocationRaw | RouteLocation) {
return pushWithRedirect(to)
}

function replace(to: RouteLocationRaw | RouteLocationNormalized) {
return push(assign(locationAsObject(to), { replace: true }))
}
// 路由跳转函数
function pushWithRedirect(
to: RouteLocationRaw | RouteLocation,
redirectedFrom?: RouteLocation
): Promise<NavigationFailure | void | undefined> {
const targetLocation: RouteLocation = (pendingLocation = resolve(to))
const from = currentRoute.value
const data: HistoryState | undefined = (to as RouteLocationOptions).state
const force: boolean | undefined = (to as RouteLocationOptions).force
// to could be a string where `replace` is a function
const replace = (to as RouteLocationOptions).replace === true

const toLocation = targetLocation as RouteLocationNormalized


return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
.catch((error: NavigationFailure | NavigationRedirectError) =>
isNavigationFailure(error)
? error
: // reject any unknown error
triggerError(error, toLocation, from)
)
.then((failure: NavigationFailure | NavigationRedirectError | void) => {

failure = finalizeNavigation(
toLocation as RouteLocationNormalizedLoaded,
from,
true,
replace,
data
)

triggerAfterEach(
toLocation as RouteLocationNormalizedLoaded,
from,
failure
)
return failure
})
}

在下面的代码中我们可以看到,finalizeNavigation函数内部通过routerHistory.push或者replace实现路由跳转,并且更新currentRoute.value。

currentRoute就是我们在install方法中注册的全局变量$route,每次页面跳转currentRoute都会更新为toLocation,在任意组件中都可以通过$route变量来获取当前路由的数据, 最后在handleScroll设置滚动行为

routerHistory在createRouter中通过option.history获取,就是我们创建vue-router应用时通过createWebHistory或者createWebHashHistory创建的对象。createWebHistory返回的是HTML5的history模式路由对象,createWebHashHistory是Hash模式的路由对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function finalizeNavigation(
toLocation: RouteLocationNormalizedLoaded,
from: RouteLocationNormalizedLoaded,
isPush: boolean,
replace?: boolean,
data?: HistoryState
): NavigationFailure | void {

const isFirstNavigation = from === START_LOCATION_NORMALIZED
const state = !isBrowser ? {} : history.state

if (isPush) {

if (replace || isFirstNavigation)
routerHistory.replace(
toLocation.fullPath
)
else routerHistory.push(toLocation.fullPath, data)
}

// accept current navigation
currentRoute.value = toLocation
handleScroll(toLocation, from, isPush, isFirstNavigation)

markAsReady()
}

function markAsReady(err?: any): void {
if (ready) return
ready = true
setupListeners()
readyHandlers
.list()
.forEach(([resolve, reject]) => (err ? reject(err) : resolve()))
readyHandlers.reset()
}

下面的代码中我们可以看到,createWebHashHistory和createWebHistory的实现,内部都是通过useHistoryListeners实现路由的监听,通过useHistoryStateNavigation实现路由的切换。useHistoryStateNavigation会返回push或者replace方法来更新路由,这两个函数你可以在 GitHub 上自行学习。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export function createWebHashHistory(base?: string): RouterHistory {
base = location.host ? base || location.pathname + location.search : ''
// allow the user to provide a `#` in the middle: `/base/#/app`
if (!base.includes('#')) base += '#'
return createWebHistory(base)
}

export function createWebHistory(base?: string): RouterHistory {
base = normalizeBase(base)

const historyNavigation = useHistoryStateNavigation(base)
const historyListeners = useHistoryListeners(
base,
historyNavigation.state,
historyNavigation.location,
historyNavigation.replace
)
function go(delta: number, triggerListeners = true) {
if (!triggerListeners) historyListeners.pauseListeners()
history.go(delta)
}

const routerHistory: RouterHistory = assign(
{
// it's overridden right after
location: '',
base,
go,
createHref: createHref.bind(null, base),
},

historyNavigation,
historyListeners
)

Object.defineProperty(routerHistory, 'location', {
enumerable: true,
get: () => historyNavigation.location.value,
})

Object.defineProperty(routerHistory, 'state', {
enumerable: true,
get: () => historyNavigation.state.value,
})

return routerHistory
}

总结

这节课我们进入到vue-router的源码中分析了vue-router内部的执行逻辑,其实我们之前课上已经实现了迷你的vue-router,在掌握了前端路由实现的原理后,再来看实际的vue-router源码难度会下降不少。

首先我们分析了createRouter函数入口函数,createRouter函数返回了router对象,router对象提供了addRoute、push等方法,并且在install方法中实现了路由,注册了组件router-link和router-view。

然后通过createRouterMatcher创建路由匹配对象,并且在路由变化的时候维护currentRoute,让你可以在每个组件内部$router和$route获取路由匹配的数据,并且动态渲染当前路由匹配的组件到router-view组件内部,实现了前端的路由系统。

这一讲我们也能感受到,一个玩具的router和实际的vue-router的距离,也能体会到TypeScript在我们阅读代码时的好处。我们阅读源码的目的之一,就是要学习和模仿优秀框架内部的设计思路,然后去优化自己项目中的代码,学会模仿也是一个优秀程序员的优秀品质。

推荐阅读

史上最全 vue-router 讲解 !!!

服务端渲染原理:Vue 3中的SSR是如何实现的?

SSR是什么

要想搞清楚SSR是什么?我们需要先理解这个方案是为解决什么问题而产生的。

在现在MVVM盛行的时代,无论是Vue还是React的全家桶,都有路由框架的身影,所以,页面的渲染流程也全部都是浏览器加载完JavaScript文件后,由JavaScript获取当前的路由地址,再决定渲染哪个页面。

这种架构下, 所有的路由和页面都是在客户端进行解析和渲染的,我们称之为Client Side Rendering,简写为CSR,也就是客户端渲染

交互体验确实提升了,但同时也带来了两个小问题。

首先,如果采用CSR,我们在ailemente项目中执行 npm run build 命令后,可以在项目根目录下看到多了一个dist文件夹,打开其中的index.html文件,看到下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<script type="module" crossorigin src="/assets/index.c305634d.js"></script>
<link rel="modulepreload" href="/assets/vendor.9419ee42.js">
<link rel="stylesheet" href="/assets/index.1826a359.css">
</head>
<body>
<div id="app"></div>

</body>
</html>

这就是项目部署上线之后的入口文件,body内部就是一个空的div标签,用户访问这个页面后,页面的首屏需要等待JavaScript加载和执行完毕才能看到,这样白屏时间肯定比body内部写页面标签的要长一些,尤其在客户端网络环境差的情况下,等待JavaScript下载和执行的白屏时间是很伤害用户体验的。

其次,搜索引擎的爬虫抓取到你的页面数据后,发现body是空的,也会认为你这个页面是空的,这对于SEO是很不利的。即使现在基于Google的搜索引擎爬虫已经能够支持JavaScript的执行,但是爬虫不会等待页面的网络数据请求,何况国内主要的搜索引擎还是百度。

所以如果你的项目对白屏时间和搜索引擎有要求, 我们就需要在用户访问页面的时候,能够把首屏渲染的HTML内容写入到body内部,也就是说我们需要在服务器端实现组件的渲染,这就是SSR的用武之地。

怎么做SSR

那怎么在服务器端实现组件渲染呢?Vue提供了@vue/server-renderer这个专门做服务端解析的库,我们来尝试使用一下。

首先创建一个新的文件夹vue-ssr,执行下面命令来安装server-renderer、vue和express:

1
2
npm init -y
npm install @vue/server-renderer vue@next express --save

然后新建server.js,核心就是要实现在服务器端解析Vue的组件,直接把渲染结果返回给浏览器。

下面的代码中我们使用express启动了一个服务器,监听9093端口,在用户访问首页的时候,通过createSSRApp创建一个Vue的实例,并且通过@vue/compiler-ssr对模板的template进行编译,返回的函数配置在vueapp的ssrRender属性上,最后通过@vue/server-renderer的renderToString方法渲染Vue的实例,把renderToString返回的字符串通过res.send返回给客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 引入express
const express = require('express')
const app = express()
const Vue = require('vue') // vue@next
const renderer3 = require('@vue/server-renderer')
const vue3Compile= require('@vue/compiler-ssr')

// 一个vue的组件
const vueapp = {
template: `<div>
<h1 @click="add">{{num}}</h1>
<ul >
<li v-for="(todo,n) in todos" >{{n+1}}--{{todo}}</li>
</ul>
</div>`,
data(){
return {
num:1,
todos:['吃饭','睡觉','学习Vue']
}
},
methods:{
add(){
this.num++
}
}
}
// 使用@vue/compiler-ssr解析template
vueapp.ssrRender = new Function('require',vue3Compile.compile(vueapp.template).code)(require)
// 路由首页返回结果
app.get('/',async function(req,res){
let vapp = Vue.createSSRApp(vueapp)
let html = await renderer3.renderToString(vapp)
const title = "Vue SSR"
let ret = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
</head>
<body>
<div id="app">
${html}
</div>
</body>
</html>`
res.send(ret)
})

app.listen(9093,()=>{
console.log('listen 9093')
})

现在我们访问页面后,点击右键查看网页源代码,会出现下图所示的页面:

image-20231025195529042

可以看到,首屏的body标签内部就出现了vue组件中v-for渲染后的标签结果,我们的第一步就完成了。

但具体SSR是怎么实现的呢?我们一起来看源码。

Vue SSR源码剖析

在CSR环境下,template解析的render函数用来返回组件的虚拟DOM,而SSR环境下template解析的ssrRender函数,函数内部是通过_push对字符串进行拼接,最终生成组件渲染的结果的。你可以在官方的 模板渲染演示页面 选择ssr设置后,看到渲染的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate, ssrRenderList: _ssrRenderList } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
const _cssVars = { style: { color: _ctx.color }}
_push(`<div${_ssrRenderAttrs(_mergeProps(_attrs, _cssVars))}><ul><!--[-->`)
_ssrRenderList(_ctx.todos, (todo, n) => {
_push(`<li>${
_ssrInterpolate(n+1)
}--${
_ssrInterpolate(todo)
}</li>`)
})
_push(`<!--]--></ul></div>`)
}

可以看到ssrRender函数内部通过传递的_push函数拼接组件渲染的结果后,直接返回renderToString函数的执行结果。

那renderToString是如何工作的呢?

现在你已经拥有了源码阅读的技巧,我们进入到vue-next/packages/server-renderer文件中,打开 renderToString文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export async function renderToString(
input: App | VNode,
context: SSRContext = {}
): Promise<string> {
if (isVNode(input)) {
// raw vnode, wrap with app (for context)
return renderToString(createApp({ render: () => input }), context)
}
const vnode = createVNode(input._component, input._props)
vnode.appContext = input._context
// provide the ssr context to the tree
input.provide(ssrContextKey, context)
const buffer = await renderComponentVNode(vnode)

await resolveTeleports(context)

return unrollBuffer(buffer as SSRBuffer)
}

这段代码可以看到,我们通过renderComponentVNode函数对创建的Vnode进行渲染,生成一个buffer变量,最后通过unrollBuffer返回字符串。

我们先继续看 renderComponentVNode函数,它内部通过renderComponentSubTree进行虚拟DOM的子树渲染,而renderComponentSubTree内部调用组件内部的ssrRender函数,这个函数就是我们代码中通过@vue/compiler-ssr解析之后的ssrRender函数,传递的push参数是通过createBuffer传递的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export function renderComponentVNode(
vnode: VNode,
parentComponent: ComponentInternalInstance | null = null,
slotScopeId?: string
): SSRBuffer | Promise<SSRBuffer> {
const instance = createComponentInstance(vnode, parentComponent, null)
const res = setupComponent(instance, true /* isSSR */)
if (hasAsyncSetup || prefetches) {
....
return p.then(() => renderComponentSubTree(instance, slotScopeId))
} else {
return renderComponentSubTree(instance, slotScopeId)
}
}
function renderComponentSubTree(instance,slotScopeId){
const { getBuffer, push } = createBuffer()
const ssrRender = instance.ssrRender || comp.ssrRender
if (ssrRender) {
ssrRender(
instance.proxy,
push,
instance,
attrs,
// compiler-optimized bindings
instance.props,
instance.setupState,
instance.data,
instance.ctx
)
}
}

createBuffer的实现 也很简单,buffer是一个数组,push函数就是不停地在数组最后新增数据,如果item是字符串,就在数组最后一个数据上直接拼接字符串,否则就在数组尾部新增一个元素,这种提前合并字符串的做法,也算是一个小优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export function createBuffer() {
let appendable = false
const buffer: SSRBuffer = []
return {
getBuffer(): SSRBuffer {
// Return static buffer and await on items during unroll stage
return buffer
},
push(item: SSRBufferItem) {
const isStringItem = isString(item)
if (appendable && isStringItem) {
buffer[buffer.length - 1] += item as string
} else {
buffer.push(item)
}
appendable = isStringItem
if (isPromise(item) || (isArray(item) && item.hasAsync)) {
// promise, or child buffer with async, mark as async.
// this allows skipping unnecessary await ticks during unroll stage
buffer.hasAsync = true
}
}
}
}

最后我们看下返回字符串的 unrollBuffer函数,由于buffer数组中可能会有异步的组件,服务器返回渲染内容之前,我们要把组件依赖的异步任务使用await,等待执行完毕后,进行字符串的拼接,最后返回给浏览器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function unrollBuffer(buffer: SSRBuffer): Promise<string> {
if (buffer.hasAsync) {
let ret = ''
for (let i = 0; i < buffer.length; i++) {
let item = buffer[i]
if (isPromise(item)) {
item = await item
}
if (isString(item)) {
ret += item
} else {
ret += await unrollBuffer(item)
}
}
return ret
} else {
// sync buffer can be more efficiently unrolled without unnecessary await
// ticks
return unrollBufferSync(buffer)
}
}

至此我们就把Vue中SSR的渲染流程梳理完毕了,通过compiler-ssr模块把template解析成ssrRender函数后,整个组件通过renderToString把组件渲染成字符串返回给浏览器。

SSR最终实现了通过服务器端解析Vue组件的方式,提高首屏的响应时间和页面的SEO友好度。

同构应用和其他渲染方式

现在服务器渲染SSR的逻辑我们已经掌握了,但是现在页面中没有JavaScript的加入,我们既需要提供服务器渲染的首屏内容,又需要CSR带来的优秀交互体验,这个时候我们就需要使用同构的方式来构建Vue的应用。

什么是同构应用呢?看来自于Vue官网的同构应用的经典架构图:

image-20231025201253798

左边是我们的源码,无论项目有多么复杂,都可以拆分为component + store + router三大模块。这一部分的源码,设置了两个入口,分别是客户端入口 client entry 和服务器端入口 server entry。打包的过程中也有两个打包的配置文件,分别客户端的配置和服务器端的配置。

最终在服务端实现用户首次访问页面的时候通过服务器端入口进入,显示服务器渲染的结果,然后用户在后续的操作中由客户端接管,通过vue-router来提高页面跳转的交互体验,这就是 同构应用 的概念。

SSR+同构的问题

当然,没有任何一个技术架构是完美的,SSR和同构带来了很好的首屏速度和SEO友好度,但是也让我们的项目多了一个Node服务器模块。

首先,我们部署的难度会提高。之前的静态资源直接上传到服务器的Nginx目录下,做好版本管理即可,现在还需要在服务器上部署一个Node环境,额外带来了部署和监控的成本,工作量提升了。

其次,SSR和同构的架构,实际上,是把客户端渲染组件的计算逻辑移到了服务器端执行,在并发量大的场景中,会加大服务器的负载。所以,所有的同构应用下还需要有降级渲染的逻辑,在服务器负载过高或者服务器有异常报错的情况下,让页面恢复为客户端渲染。

总的来说,同构解决问题的同时,也带来了额外的系统复杂度。 每个技术架构的出现都是为了解决一些特定的问题,但是它们的出现也必然会带来新的问题

针对同构出现的问题目前也有一些解决方案来应对。

解决方案

针对SSR架构的问题,我们也可以使用 静态网站生成(Static Site Generation,SSG) 的方式来解决,针对页面中变动频率不高的页面,直接渲染成静态页面来展示。

比如极客时间的首页变化频率比较高,每次我们都需要对每个课程的销量和评分进行排序,这部分的每次访问都需要从后端读取数据;但是每个课程内部的页面,比如文章详情页,变化频率其实是很低的,虽然课程的文本是存储在数据库里,但是每次上线前,我们可以把课程详情页生成静态的HTML页面再上线。

Vue的SSR框架nuxt就提供了很好的SSG功能,由于这一部分页面变化频率低,我们静态化之后还可以通过部署到CDN来进行页面加速,每次新文章发布或者修改的时候,重新生成一遍即可。

当然SSG也不是完全没有问题,比如极客时间如果有一万门课了,每门课几十篇文章,每次部署都全量静态生成一遍,耗时是非常惊人的,所以也不断有新的解决方案出现。

如果你的页面是内嵌在客户端内部的,可以借助客户端的运算能力,把SSR的逻辑移动到客户端进行,使用 客户端渲染(Native Side Rendering,NSR) 的方式降低服务端的负载,同时也能提高首屏的响应时间。

针对SSG全量生成的性能问题,我们可以采用 增量渲染(Incremental Site Rendering,ISR) 的方式,每次只生成核心重点的页面,比如每个课程的开篇词,其他的页面访问的时候先通过CSR的方式渲染,然后把渲染结果存储在CDN中。

现在还有解决方案 边缘渲染(Edge Side Rendering,ESR),把静态内容和动态的内容都以流的方式返回给用户,在CDN节点上返回给用户缓存静态资源,同时在CDN上负责发起动态内容的请求。

今年还出现了在浏览器里跑node的 webcontainer 技术,如果这个技术成熟后,我们甚至可以把Express、Egg.js等后端应用也部署到CDN节点上,在浏览器端实现服务器应用的ESR,一起期待webcontainer技术的发展。

总结

今天我们学习了Vue中服务器渲染的原理,Vue通过@vue/compiler-ssr库把template解析成ssrRender函数,并且用@vue/server-renderer库提供了在服务器端渲染组件的能力,让用户访问首屏页面的时候,能够有更快的首屏渲染结果,并且对SEO也是友好的,server-renderer通过提供renderToString函数,内部通过管理buffer数组实现组件的渲染。

然后我们学习了SSR之后的同构、静态网站生成SSG、增量渲染ISR和边缘渲染ESR等内容,Vue中的最成熟的SSR框架就是nuxt了,最新的nuxt3还没有正式发版,内部对于SSG和ESR都支持,等nuxt3发版后你可以自行学习。

每一个技术选型都是为了解决问题存在的,无论学习什么技术,我们都不要单纯地把它当做八股文,这样才能真正掌握好一个技术。


玩转Vue3全家桶
http://example.com/2023/10/08/01.前端/06.Vue/02.Vue3/02.玩转Vue3全家桶/
作者
Deng ErPu
发布于
2023年10月8日
许可协议