玩转Vue3全家桶

2021/12/28 极客时间

源链接:https://time.geekbang.org/column/intro/100094401 (opens new window)

# 1. 开篇词

  • 前端工程师进阶困难的痛点就是,没有体系化的学习。
  • 由兴趣和爱好驱动。
  • 学习的不仅仅是表面的增删改查,而是底层的工程化、框架,还有海底的计算机知识体系;以一个渐进的方式去进阶前端开发。
  • 在目前的前端开发中,流行的框架相信你并不陌生。它们的目标都是为了帮助开发者高效地开发 Web 应用,只不过走的路线略显不同,比如 React 注重数据不可变、虚拟 DOM 和运行时;而 Svelte 运行时都非常轻量级,侧重在于编译时的优化;Angular 则在抽象这个维度又走向一个极致,生来就是为了复杂项目。而相比之下,Vue 就简单多了,简单到大部分前端开发者都能学得会。Vue 在每个维度之间,做了非常好的权衡和取舍,算是一个非常中庸且优雅的框架,兼顾响应式、虚拟 DOM、运行时和编译优化。

# 2. 课程导读篇

# 01 | 宏观视角:从前端框架发展史聊聊为什么要学 Vue 3

  • 【1】石器时代:1990 年,第一个 Web 浏览器诞生了。这是前端这个技术的起点,代表这一年它出生了。后面的时间里,前端圈有很多里程碑事件。----》1994 年,网景公司发布第一个商业浏览器 Navigator。----》1995 年,网景工程师 Brendan Eich 用 10 天时间设计了 JavaScript,同年微软发布了 IE 浏览器,进而掀起了浏览器大战。----》2002 年,IE 在浏览器大战中赢得胜利,IE6 占有率超过 96% 。----》直到 2004 年,Google 发布了 Gmail,用户可以在不刷新页面的情况下进行复杂的交互,之后,Ajax 逐渐成为网页开发的技术标准,也不断地被应用于各种网站。Ajax 这个技术让我们可以异步的获取数据并且刷新页面,从此前端不再受限于后端的模板,这也宣告了 Web2.0 时代正式到来。至此,前端工程师也正式作为一个独立工种出现。
  • 【2】铁器时代:在 Gmail 诞生后,虽然依然有浏览器的混战和兼容性问题,比如绑定事件不同的浏览器就要写不同的代码,但大家意识到前端也可以做出复杂应用。----》而 jQuery 的出现迅速风靡全球,一个 $ 走天下,学会 jQuery 就等同于学会了前端,算是前端车同轴的时代。----》那个时候写代码,就是找到某个元素,进行 DOM 操作。----》随着前端项目规模的逐渐提升,前端也需要规模化的时候,在 2009 年 AngularJS 和 Node.js 的诞生,也宣告前端工业革命的到来。
  • 【3】工业时代:AngularJS 的诞生,引领了前端 MVVM 模式的潮流;Node.js 的诞生,让前端有了入侵后端的能力,也加速了前端工程化的诞生。----》现在前端三大框架 Angular、React、Vue 的发展主线,也就是从这里开始的。----》所谓 MVVM,就是在前端的场景下,把 Controller 变成了 View-Model 层,作为 Model 和 View 的桥梁,Model 数据层和 View 视图层交给 View-Model 来同步。
  • 前端三大框架:在前端 MVVM 模式下,不同框架的目标都是一致的,就是利用数据驱动页面,但是怎么处理数据的变化,各个框架走出了不同的路线。(这些框架要回答的核心问题就是,数据发生变化后,我们怎么去通知页面更新)。浏览器操作 DOM 一直都是性能杀手,而虚拟 DOM 的 Diff 的逻辑,又能够确保尽可能少的操作 DOM,这也是虚拟 DOM 驱动的框架性能一直比较优秀的原因之一。
  • 在 Vue 框架下,如果数据变了,那框架会主动告诉你修改了哪些数据;而 React 的数据变化后,我们只能通过新老数据的计算 Diff 来得知数据的变化。
  • React 为了突破性能瓶颈,借鉴了操作系统时间分片的概念,引入了 Fiber 架构。通俗来说,就是把整个虚拟 DOM 树微观化,变成链表,然后我们利用浏览器的空闲时间计算 Diff。一旦浏览器有需求,我们可以把没计算完的任务放在一旁,把主进程控制权还给浏览器,等待浏览器下次空闲。
  • 响应式数据是主动推送变化,虚拟 DOM 是被动计算数据的 Diff,一个推一个拉,它们看起来是两个方向的技术,但被 Vue 2 很好地融合在一起,采用的方式就是组件级别的划分。对于 Vue 2 来说,组件之间的变化,可以通过响应式来通知更新。组件内部的数据变化,则通过虚拟 DOM 去更新页面。这样就把响应式的监听器,控制在了组件级别,而虚拟 DOM 的量级,也控制在了组件的大小。(组件内部是没有 Watcher 监听器的,而是通过虚拟 DOM 来更新,每个组件对应一个监听器,大大减小了监听器的数量)。Vue 3 很优秀的一个点,就是在虚拟 DOM 的静态标记上做到了极致,让静态的部分越过虚拟 DOM 的计算,真正做到了按需更新,很好的提高了性能。
  • 浏览器的诞生让我们可以方便地显示文本和图片的内容和样式;JavaScript 的出现让网页动了起来;Gmail 的发布,宣告前端也可以使用 Ajax 异步加载技术,来进行复杂网页的开发,前端工程师这个工种也正式出现了。jQuery 框架的出现统一了写法,解决了那个时代最棘手的前端问题:兼容性,极大提高了开发者的效率。

# 02 | 上手:一个清单应用帮你入门 Vue.js

  • 学习 Vue.js,首先就要进行思想的升级,也就是说,不要再思考页面的元素怎么操作,而是要思考数据是怎么变化的。
  • 全选框的功能:可以利用 computed 的 get 和 set 函数。
  • 持久化方案: 一个是借助后端让数据入库,还有就是 localStorage 这种浏览器本地持久化。

# 03 | 新特性:初探 Vue 3 新特性

  • Vue 3 的优势是什么,以及 Vue 3 到底有哪些新特性值得我们学习。
  • Vue 2 的核心模块:Vue 2 是一个响应式驱动的、内置虚拟 DOM、组件化、用在浏览器开发,并且有一个运行时把这些模块很好地管理起来的框架。
  • Vue 2 的历史遗留问题:【1】首先从开发维护的角度看,Vue 2 是使用 Flow.js 来做类型校验。但现在 Flow.js 已经停止维护了,整个社区都在全面使用 TypeScript 来构建基础库,Vue 团队也不例外。【2】然后从社区的二次开发难度来说,Vue 2 内部运行时,是直接执行浏览器 API 的。但这样就会在 Vue 2 的跨端方案中带来问题,要么直接进入 Vue 源码中,和 Vue 一起维护,比如 Vue 2 中你就能见到 Weex 的文件夹。要么是要直接改为复制一份全部 Vue 的代码,把浏览器 API 换成客户端或者小程序的。比如 mpvue 就是这么做的,但是 Vue 后续的更新就很难享受到。【3】最后从普通开发者的角度来说,Vue 2 响应式并不是真正意义上的代理,而是基于 Object.defineProperty() 实现的。这个 API 并不是代理,而是对某个属性进行拦截,所以有很多缺陷,比如:删除数据就无法监听。【4】Option API 在组织代码较多组件的时候不易维护。
  • 从七个方面了解 Vue 3 新特性:【1】RFC 机制:Vue 3 的第一个新特性和代码无关,而是 Vue 团队开发的工作方式。关于 Vue 的新语法或者新功能的讨论,都会先在 GitHub 上公开征求意见,邀请社区所有的人一起讨论。Github (opens new window)。这个改变让 Vue 社区更加有活力。【2】响应式系统:Vue 2 的响应式机制是基于 Object.defineProperty() 这个 API 实现的,此外,Vue 还使用了 Proxy,这两者看起来都像是对数据的读写进行拦截,但是 defineProperty 是拦截具体某个属性,Proxy 才是真正的 “代理”。Proxy 存在一些兼容性问题,不兼容 IE11 以下的浏览器。前端框架利用浏览器的新特性来完善自己,才会让前端这个生态更繁荣,抛弃旧的浏览器是早晚的事。【3】自定义渲染器:Vue 2 内部所有的模块都是揉在一起的,这样做会导致不好扩展的问题;Vue 3 利用拆包,使用最近流行的 monorepo 管理方式,响应式、编译和运行时全部独立。在 Vue 3 的组织架构中,响应式独立了出来。而 Vue 2 的响应式只服务于 Vue,Vue 3 的响应式就和 Vue 解耦了,你甚至可以在 Node.js 和 React 中使用响应式。渲染的逻辑也拆成了平台无关渲染逻辑和浏览器渲染 API 两部分 。【4】全部模块使用 TypeScript 重构:类型系统带来了更方便的提示,并且让我们的代码能够更健壮。Vue 2 选 Flow.js 没问题,但是现在 Flow.js 被抛弃了。【5】Composition API 组合语法:所有 API 都是 import 引入的,对 Tree-shaking 很友好;不再上下反复横跳,可以把一个功能模块的 methods、data 都放在一起书写,维护更轻松;代码方便复用,可以把一个功能所有的 methods、data 封装在一个独立的函数里,复用代码非常容易。【6】新的组件:Vue 3 还内置了 Fragment、Teleport 和 Suspense 三个新组件。Fragment: Vue 3 组件不再要求有一个唯一的根节点,清除了很多无用的占位 div。Teleport: 允许组件渲染在别的元素内,主要开发弹窗组件的时候特别有用。Suspense: 异步组件,更方便开发有异步请求的组件。【7】新一代工程化工具 Vite:Vite 不在 Vue 3 的代码包内,和 Vue 也不是强绑定,Vite 的竞品是 Webpack。Vite 主要提升的是开发的体验。现代浏览器已经默认支持了 ES6 的 import 语法,Vite 就是基于这个原理来实现的。具体来说,在调试环境下,我们不需要全部预打包,只是把你首页依赖的文件,依次通过网络请求去获取,整个开发体验得到巨大提升,做到了复杂项目的秒级调试和热更新。
  • 特征回顾:新的 RFC 机制也让我们所有人都可以参与 Vue 新语法的讨论。工程化工具 Vite 带来了更丝滑的调试体验。对于产品的最终效果来看,Vue 3 性能更高,体积更小。对于普通开发者来说,Composition API 组合语法带来了更好的组织代码的形式。全新的响应式系统基于 Proxy,也可以独立使用。Vue 3 内置了新的 Fragment、Teleport 和 Suspense 等组件。对于 Vue 的二次开发来说,自定义渲染器让我们开发跨端应用时更加得心应手。对于 Vue 的源码维护者,全部的模块使用 TypeScript 重构,能够带来更好的可维护性。

# 04 | 升级:Vue 2 项目如何升级到 Vue 3

  • Vue 3 由于新的响应式系统用了 Proxy,会存在兼容性问题。其实官方原来是有计划在 Vue 3 中支持 IE11,但后来由于复杂度和优先级的问题,这个计划就搁置了下来。Vue 官方在重新思考后,决定让 Vue 3 全面拥抱未来,把原来准备投入到 Vue 3 上支持 IE11 的精力转投给 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 来组合代码。
  • 详细的兼容性变更,官方有一个迁移指南 (opens new window)
  • 使用自动化升级工具进行 Vue 的升级:【1】手动:首先是在 Vue 3 的项目里,有一个 @vue/compat 的库,这是一个 Vue 3 的构建版本,提供了兼容 Vue 2 的行为。这个版本默认运行在 Vue 2 下,它的大部分 API 和 Vue 2 保持了一致。当使用那些在 Vue 3 中发生变化或者废弃的特性时,这个版本会提出警告,从而避免兼容性问题的发生,帮助你很好地迁移项目。@vue/compat 还可以很好地帮助你学习版本之间的差异。【2】自动:比较好用的就是“阿里妈妈”出品的 gogocode,官方文档 (opens new window)。【3】自动化替换工具的原理很简单,和 Vue 的 Compiler 优化的原理是一样的,也就是利用编译原理做代码替换。利用 babel 分析 Vue 2 的源码,解析成 AST,然后根据 Vue 3 的写法对 AST 进行转换,最后生成新的 Vue 3 代码。

img

  • @vue/compat:
// 首先我们把项目依赖的 Vue 版本换成 Vue 3,并且引入了 @vue/compat
"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 作为入口
// vue.config.js
module.exports = {
  chainWebpack: config => {
    config.resolve.alias.set('vue', '@vue/compat')
    ......
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 全面拥抱 Vue 3 也算是一次离开舒适圈的挑战。

# 3. 基础入门篇 (5讲)

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

  • 环境准备:VS Code、Chrome、Vite、Node.js。
  • 工程化的雏形:从下往上看这个架构,我们所有工程化体系都是基于 Node.js 生态;我们使用 VS Code+Volar 编辑器 + 语法提示工具作为上层开发工具;使用 Vite 作为工程化工具;使用 Chrome 进行调试,这些都是 Vue 3 工程化体系的必备工具。
  • 规范:src 目录的组织结构
├── src
│   ├── api            数据请求
│   ├── assets         静态资源
│   ├── components     组件
│   ├── pages          页面
│   ├── router         路由配置
│   ├── store          vuex数据
│   └── utils          工具函数
1
2
3
4
5
6
7
8

img

  • 项目生态完善(插件、工具):【1】Vuex 的数据本地持久化插件;【2】接口数据的 mock, json-server;【3】 埋点的 sdk;【4】@vueuse 库,封装常用的 hooks;【5】Vite 相关的项目:Github (opens new window);【6】WindiCSS:Github (opens new window);【7】SSR 的适用场景是首屏 or SEO

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

  • Composition API 可以让我们更好地组织代码结构,而让你感到好奇的 <script setup> 本质上是以一种更精简的方式来书写 Composition API 。
  • 单文件组件——也就是 .vue 文件。
  • ref 包裹的响应式数据,注意要修改响应式数据的 value 属性。
  • 在 Composition API 的语法中,计算属性和生命周期等功能,都可以脱离 Vue 的组件机制单独使用 。
  • 在使用 Composition API 拆分功能时,也就是执行 useTodos 的时候,ref、computed 等功能都是从 Vue 中单独引入,而不是依赖 this 上下文。其实你可以把组件内部的任何一段代码,从组件文件里抽离出一个独立的文件进行维护。
  • style 样式的特性:可以通过 v-bind 函数,直接在 CSS 中使用 JavaScript 中的变量。
<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>>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

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

  • 响应式原理:【1】Vue 2 的 defineProperty API,MDN 介绍文档 (opens new window)。【2】Vue 3 的响应式机制是基于 Proxy 实现的。【3】利用对象的 get 和 set 函数来进行监听,这种响应式的实现方式,只能拦截某一个属性的修改,这也是 Vue 3 中 ref 这个 API 的实现。

img

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,有种自动变化的感觉
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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) 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  • watchEffect 这个函数让我们在数据变化之后可以执行指定的函数。数据变化之后会把数据同步到 localStorage 之上,这样就实现了 todolist 和本地存储的同步。
  • 基于响应式的开发模式,我们还可以按照类似的原理,把我们需要修改的数据,都变成响应式。比如,我们可以在 loading 状态下,去修改浏览器的小图标 favicon。和本地存储类似,修改 favicon 时,我们需要找到 head 中有 icon 属性的标签。
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}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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

<script setup>
 import useFavicon from './utils/favicon'
 let {favicon}  = useFavicon()
 function loading(){
   favicon.value = '/geek.png'
 }
</script>

<template>
  <button @click="loading">123</button>
</template>
1
2
3
4
5
6
7
8
9
10
11
  • Vueuse 工具包:【1】VueUse 插件的安装:npm install @vueuse/core。【2】官网 (opens new window)。【3】VueUse 中包含了很多我们常用的工具函数,我们可以把网络状态、异步请求的数据、动画和事件等功能,都看成是响应式的数据去管理。

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

  • 浏览器自带的组件;在 Vue 中我们自定义组件。
  • 通用型组件就是各大组件库的组件风格,包括按钮、表单、弹窗等通用功能。业务型组件包含业务的交互逻辑,包括购物车、登录注册等,会和我们不同的业务强绑定。
  • 渲染评级分数:只需要传入评分值 rate。
"★★★★★☆☆☆☆☆".slice(5 - rate, 10 - rate)
1
<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>
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
  • 组件事件:在 Vue 中,我们使用 emit 来对外传递事件。
  • 组件的 v-model:对于自定义组件来说,v-model 是传递属性和接收组件事件两个写法的简写。
  • 插槽。

# 09 | 动画:Vue 中如何实现动画效果

  • css3:transition 和 animation 可以用非常简单的方式实现动画。
  • Vue 3 中提供了一些动画的封装,使用内置的 transition 组件来控制组件的动画。(v-enter-from)
<transition name="fade">
  <h1 v-if="showTitle">你好 Vue 3</h1>
</transition>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s linear;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 列表动画:对于 v-for 渲染的列表元素,怎么实现列表项依次动画出现的效果。把这种需求称之为列表过渡。因为 transition 组件会把子元素作为一个整体同时去过渡,所以我们需要一个新的内置组件 transition-group。使用 transition-group 组件去包裹元素,通过 tag 属性去指定渲染一个元素。(tag 的目的是给 li 渲染一个 ul 父元素,倒是不会影响实际功能,不过会让 html 更语义化一些)(v-move)
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  • 页面切换动画:现在默认是在 vue-router 的模式下,我们使用 router-view 组件进行动态的组件渲染。在路由发生变化的时候,我们计算出对应匹配的组件去填充 router-view。
<router-view v-slot="{ Component }">
  <transition  name="route" mode="out-in">
    <component :is="Component" />
  </transition>
</router-view>
1
2
3
4
5
  • 实现一个图标飞到废纸篓的动画:实现的思路也很简单,我们放一个单独存在的动画元素并且藏起来,当点击删除图标的时候,我们把这个动画元素移动到鼠标的位置,再飞到废纸篓里藏起来就可以了。在 Vue 的 transition 组件里,我们可以分别设置 before-enter,enter 和 after-enter 三个函数来更精确地控制动画。在 beforeEnter 函数中,通过 getBoundingClientRect 函数获取鼠标的点击位置,让动画元素通过 translate 属性移动到鼠标所在位置;并且在 enter 钩子中,把动画元素移动到初始位置,在 afterEnter 中,也就是动画结束后,把动画元素再隐藏起来。
<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>
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
  • 手动触发一次重绘,算是启动动画:document.body.offsetHeight。
  • 最后一条 todo 删除的时候会产生问题,可以加上定时器或者 nextTick()。

# 4. 全家桶实战篇 (12讲)

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

  • Vuex,有了这个神兵利器,复杂项目设计也会变得条理更清晰。集中式存储管理应用的所有组件的状态。
  • 前端数据管理:现代 Web 应用都是由三大件构成,分别是:组件、数据和路由。需要用 ref 和 reactive 去把数据包裹成响应式数据,并且提供统一的操作方法,这其实就是数据管理框架 Vuex 的雏形了。对于一个数据,如果只是组件内部使用就是用 ref 管理;如果我们需要跨组件,跨页面共享的时候,我们就需要把数据从 Vue 的组件内部抽离出来,放在 Vuex 中去管理。
  • 手写迷你 Vuex:在 Vue 中有 provide/inject 这两个函数专门用来做数据共享,provide 注册了数据后,所有的子组件都可以通过 inject 获取数据。(借助 vue 的插件机制和 reactive 响应式功能)
import { inject, reactive } from 'vue'

const STORE_KEY = '__store__'
function useStore() {
  return inject(STORE_KEY)
}
function createStore(options) {
  return new Store(options)
}
class Store {
  constructor(options) {
    this.$options = options
    this._state = reactive({
      data: options.state
    })
    this._mutations = options.mutations
  }
  get state() {
    return this._state.data
  }
  commit = (type, payload) => {
    const entry = this._mutations[type]
    entry && entry(this.state, payload)
  }
  install(app) {
    app.provide(STORE_KEY, this)
  }
}

export { createStore, useStore }
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
  • action 并不是直接修改数据,而是通过 mutations 去修改,这是需要注意的。actions 的调用方式是使用 store.dispatch。
  • 下一代 Vuex:Vuex 由于在 API 的设计上,对 TypeScript 的类型推导的支持比较复杂,用起来很是痛苦。为了解决 Vuex 的这个问题,Vuex 的作者最近发布了一个新的作品叫 Pinia。Pinia 的 API 的设计非常接近 Vuex5 的提案。
  • 变量命名:【1】name是普通变量;【2】_name是内部变量;【3】$name是第三方插件 or 我们自己注册的公用数据。

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

  • 所有路由都渲染一个前端入口文件的方式,是单页面应用程序(SPA,single page application)应用的雏形。
  • 前端路由的实现原理:通过 URL 区分路由的机制上,有两种实现方式,一种是 hash 模式,通过 URL 中 # 后面的内容做区分,我们称之为 hash-router;另外一个方式就是 history 模式,在这种方式下,路由看起来和正常的 URL 完全一致。这两个不同的原理,在 vue-router 中对应两个函数,分别是 createWebHashHistory 和 createWebHistory。

img

  • hash 模式:在进行页面跳转的操作时,hash 值的变化并不会导致浏览器页面的刷新,只是会触发 hashchange 事件。在下面的代码中,通过对 hashchange 事件的监听,可以在 fn 函数内部进行动态地页面切换。
// http://www.xxx.com/#/login

window.addEventListener('hashchange',fn)
1
2
3
  • history 模式:2014 年之后,因为 HTML5 标准发布,浏览器多了两个 API:pushState 和 replaceState。通过这两个 API ,我们可以改变 URL 地址,并且浏览器不会向后端发送请求,我们就能用另外一种方式实现前端路由 **。在下面的代码中,我们监听了 popstate 事件,可以监听到通过 pushState 修改路由的变化。并且在 fn 函数中,我们实现了页面的更新操作。(调用 pushState replaceState 并不会触发 popstate 事件,监听通常需要 hack 这两个 api。参考链接 (opens new window)
window.addEventListener('popstate', fn)
1
  • 手写迷你 vue-router:createWebHashHistory。
import {ref,inject} from '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,
        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(()=>{
            this.current.value = window.location.hash.slice(1)
        })
    }
    install(app){
        app.provide(ROUTER_KEY,this)
    }
}

export {createRouter,createWebHashHistory,useRouter}
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

下一步,我们需要注册两个内置组件 router-view 和 router-link。

router-view 组件的功能,就是 current 发生变化的时候,去匹配 current 地址对应的组件,然后动态渲染到 router-view 就可以了。

<template>
    <component :is="comp"></component>
</template>
<script setup>

import {computed } from 'vue'
import { useRouter } from '../grouter/index'

let router = useRouter()

const comp = computed(()=>{
    const route = router.routes.find(
        (route) => route.path === router.current.value
    )
    return route?route.component : null
})
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

有了 RouterView 组件后,我们再来实现 router-link 组件。代码中的 template 依然是渲染一个 a 标签,只是把 a 标签的 href 属性前面加了个一个 #, 就实现了 hash 的修改。

<template>
    <a :href="'#'+props.to">
        <slot />
    </a>
</template>

<script setup>
import {defineProps} from 'vue'
let props = defineProps({
    to:{type:String,required:true}
})

</script>
1
2
3
4
5
6
7
8
9
10
11
12
13

添加到手写 vue-router 代码中:

import {ref,inject} from 'vue'
import RouterLink from './RouterLink.vue'
import RouterView from './RouterView.vue'

class Router{
    ....
    install(app){
        app.provide(ROUTER_KEY,this)
        app.component("router-link",RouterLink)
        app.component("router-view",RouterView)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
  • vue-router 实战要点:【1】首先是在路由匹配的语法上,vue-router 支持动态路由。冒号开头的 id 就是路由的动态部分。官方文档的路由匹配语法部分 (opens new window)。【2】然后是在实战中,对于有些页面来说,只有管理员才可以访问,普通用户访问时,会提示没有权限。这时就需要用到 vue-router 的导航守卫功能了,也就是在访问路由页面之前进行权限认证,这样可以做到对页面的控制,也就是只允许某些用户可以访问。【3】此外,在项目庞大之后,如果首屏加载文件太大,那么就可能会影响到性能。这个时候,我们可以使用 vue-router 的动态导入功能,把不常用的路由组件单独打包,当访问到这个路由的时候再进行加载,这也是 vue 项目中常见的优化方式。

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

  • Vue Devtools 就是 Vue 官方开发的一个基于 Chrome 浏览器的插件。
  • Chrome 的开发者工具中自带的选项:Elements 页面可以帮助我们调试页面的 HTML 和 CSS;Console 页面是我们用得最多的页面,它可以帮助我们调试 JavaScript;Source 页面可以帮助我们调试开发中的源码;Application 页面可以帮助我们调试本地存储和一些浏览器服务,比如 Cookie、Localstorage、通知等等。Network 页面在我们开发前后端交互接口的时候,可以让我们看到每个网络请求的状态和参数;Performance 页面则用来调试网页性能。Lighthouse 是 Google 官方开发的插件,用来获取网页性能报告。
  • 在日志信息中直接复制报错内容中的链接,去 Stack Overflow 中寻找答案:
window.onerror = function(e){
    console.log(['https://stackoverflow.com/search?q=[js]+'+e])
}
1
2
3
  • 统计极客时间官网一共有多少种 HTML 标签:
new Set([...document.querySelectorAll('*')].map(n=>n.nodeName)).size
1
  • 断点调试:Chrome 的调试窗口会识别代码中的 debugger 关键字,并中断代码的执行。
  • 性能相关的调试:Performance(在调试窗口中点击 Performance 页面中的录制按钮,然后重复你卡顿的操作后,点击结束,就可以清晰看到你在和页面进行交互操作时,浏览器中性能的变化);lighthouse 插件;关于 Chrome 性能页面更多的使用方法,可以到 Chrome 官方文档 (opens new window)上去查看。
  • 统计极客时间首页出现次数最多的 3 种 HTML 标签:
Object.entries([...document.querySelectorAll("*")].map(n=>n.tagName).reduce((pre, cur)=>{
  pre[cur] = (pre[cur] || 0) + 1;
  return pre;
}, {})).sort((a, b)=>b[1]-a[1]).slice(0, 3)
1
2
3
4
  • vue3 项目通过 vite 打包后,如何支持 dev-tools:新增这个命令 "build:dev": "vue-tsc --noEmit && vite build --mode=development",这样就打包了一个支持devtools的打包版。

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

  • Vue 中不仅有 JSX,而且 Vue 还借助 JSX 发挥了 Javascript 动态化的优势。此外,Vue 中的 JSX 在组件库、路由库这类开发场景中,也发挥着重要的作用。
  • 在 Vue 3 的项目开发中,template 是 Vue 3 默认的写法。虽然 template 长得很像 HTML,但 Vue 其实会把 template 解析为 render 函数,之后,组件运行的时候通过 render 函数去返回虚拟 DOM。
  • h 函数:这里的 setup 函数返回值是一个函数,就是我们所说的 render 函数。render 函数返回 h 函数的执行结果。手写的 h 函数,可以处理动态性更高的场景。但是如果是复杂的场景,h 函数写起来就显得非常繁琐,需要自己把所有的属性都转变成对象。因为 h 函数也是返回虚拟 DOM 的,所以有没有更方便的方式去写 h 函数呢?答案是肯定的,这个方式就是 JSX。
// 普通写法 .vue
<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>

// h 函数 .jsx
import { defineComponent, h } from 'vue'
export default defineComponent({
  props: {
    level: {
      type: Number,
      required: true
    }
  },
  setup(props, { slots }) {
    return () => h(
      'h' + props.level, // 标签名
      {}, // prop 或 attribute
      slots.default() // 子节点
    )
  }
})
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
  • JSX 是什么:JSX 来源自 React 框架。这种在 JavaScript 里面写 HTML 的语法,就叫做 JSX,算是对 JavaScript 语法的一个扩展。下面的代码直接在 JavaScript 环境中运行时,会报错。JSX 的本质就是语法糖,h 函数内部也是调用 createVnode 来返回虚拟 DOM(React 就是这样依赖于虚拟 DOM)。在之后的课程中,对于那些创建虚拟 DOM 的函数,我们统一称为 h 函数。
const element = <h1 id="app">Hello, Geekbang!</h1>

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

// 在从 JSX 到 createVNode 函数的转化过程中,我们需要安装一个 JSX 插件
npm install @vitejs/plugin-vue-jsx -D

// 插件安装完成后,进入根目录下,打开 vite.config.js 文件去修改 vite 配置
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx';

export default defineConfig({
  plugins: [vue(), vueJsx()]
})

// 前面的代码就可以修改为下面这样
setup(props, { slots }) {
  const tag = 'h' + props.level
  return () => <tag>{ slots.default() }</tag>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 使用 JSX 实现一个简单版本的清单应用:使用 vModel 取代 v-model、使用单个大括号包裹的形式传入变量 title.value、使用 onClick 取代 @click、循环渲染清单的时候使用.map 映射取代 v-for、使用三元表达式取代 v-if。
import { defineComponent, ref } from 'vue'

export default defineComponent({
  setup (props) {
    let title = ref('')
    let todos = ref([{ title: "学习 Vue 3", done: true },{ title: "睡觉", done: false }]);
    function addTodo(){
        todos.value.push({
            title: title.value
        })
        title.value = ''
    }
    return () => <div>
        <input type="text" vModel={title.value} />
        <button onClick={addTodo}>click</button>
        <ul>
            {
                todos.value.length ? todos.value.map(todo=>{
                    return <li>{todo.title}</li>
                }): <li>no data</li>
            }
        </ul>
    </div>
  }
})
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
  • 像在 TimeLine 组件的源码中,有一个 reverse 的属性来决定是否倒序渲染,类似这种动态性要求很高的场景,template 是较难实现的。
export const Timeline = (props)=>{
    const timeline = [
        <div class="start">8.21 开始自由职业</div>,
        <div class="online">10.18 专栏上线</div>
    ]
    if(props.reverse){
        timeline.reverse()
    }
    return <div>{timeline}</div>
}
1
2
3
4
5
6
7
8
9
10
  • JSX 和 Template:我们接受一些操作上的限制,但同时也会获得一些系统优化的收益。【1】template 的语法是固定的,只有 v-if、v-for 等等语法。我们按照这种固定格式的语法书写,这样 Vue 在编译层面就可以很方便地去做静态标记的优化。【2】而 JSX 只是 h 函数的一个语法糖,本质就是 JavaScript,想实现条件渲染可以用 if else,也可以用三元表达式,还可以用任意合法的 JavaScript 语法。也就是说,JSX 可以支持更动态的需求。而 template 则因为语法限制原因,不能够像 JSX 那样可以支持更动态的需求。这是 JSX 相比于 template 的一个优势。【3】JSX 相比于 template 还有一个优势,是可以在一个文件内返回多个组件。
  • 在 template 和 JSX 这两者的选择问题上,只是选择框架时角度不同而已。我们实现业务需求的时候,也是优先使用 template,动态性要求较高的组件使用 JSX 实现,尽可能地利用 Vue 本身的性能优化。

# 14 | TypeScript:Vue 3 中如何使用 TypeScript

  • 在语言层面上,提高代码可维护性和调试效率的强类型语言——TypeScript。
  • 什么是 TypeScript:TypeScript 是微软开发的 JavaScript 的超集,这里说的超集,意思就是 TypeScript 在语法上完全包含 JavaScript。TypeScript 的主要作用是给 JavaScript 赋予强类型的语言环境。TypeScript 相当于在 JavaScript 外面包裹了一层类型系统,这样可以帮助我们开发更健壮的前端应用。
  • TypeScript 的变量后面有一个冒号用来设置好变量的数据类型;使用 interface 去定义一个复杂的类型接口;TypeScript 能够智能地去报错和提示。
  • TypeScript 中的一些进阶用法:很多时候,看不懂开源库 TypeScript 的原因,也是出在对这些进阶用法的生疏上。【1】首先要讲到的进阶用法是泛型,泛型就是指有些函数的参数,你在定义的时候是不确定的类型,而返回值类型需要根据参数来确定。泛型让我们拥有了根据输入的类型去实现函数的能力,这里你也能感受到 TypeScript 类型可以进行动态设置。【2】关于 TypeScript 的更多类型的使用文档,你可以在官网文档 (opens new window)上找到很详细的教程和介绍。而且 TypeScript 的类型其实是可以编程的,可以根据类型去组合推导新的类型,甚至可以使用 extends 去实现递归类型。
// 泛型
function test<T> (args: T): T{
    return args
}

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]
}
1
2
3
4
5
6
7
8
9
10
11
  • Vue 3 中的 TypeScript:【1】由于 TypeScript 中的每个变量都需要把类型定义好,因而对代码书写的要求也会提高。Vue 2 中全部属性都挂载在 this 之上,而 this 可以说是一个黑盒子,我们完全没办法预先知道 this 上会有什么数据,这也是为什么 Vue 2 对 TypeScript 的支持一直不太好的原因。【2】Vue 3 全面拥抱 Composition API 之后,没有了 this 这个黑盒,对 TypeScript 的支持也比 Vue2 要好很多。首先我们需要在 script 标签上加一个配置 lang=“ts”,来标记当前组件使用了 TypeScript,然后代码内部使用 defineComponent 定义组件即可。【3】针对 ref 或者 reactive 进行类型推导,TypeScript 就可以预先判断并且进行报错提示。
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
  // 已启用类型推断
})
</script>

// 在 <script setup> 语法中
const props = defineProps<{
  title: string
  value?: number
}>()
const emit = defineEmits<{ 
  (e: 'update', value: number): void
}>()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 了解一下和 vue-router 的优化相关的工作:vue-router 提供了 Router 和 RouteRecordRaw 这两个路由的类型。
import { createRouter, createWebHashHistory, Router, RouteRecordRaw } from 'vue-router'

const routes: Array<RouteRecordRaw> = [
  ...
]

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

export default router
1
2
3
4
5
6
7
8
9
10
11
12
  • TypeScript 和 JavaScript 的平衡:【1】TypeScript 引入的强类型系统带来了可维护性较好、调试较为方便的好处。并且 TypeScript 在社区的热度也越来越高,也有人开始提问:“到底是学 TypeScript 还是 JavaScript?”。【2】但是,这个提问忽略了这一点:TypeScript 是 JavaScript 的一个超集,这两者并不是完全对立的关系。所以,学习 TypeScript 和学习 JavaScript 不是二选一的关系,你需要做的,是打好坚实的 JavaScript 的基础,在维护复杂项目和基础库的时候选择 TypeScript。【3】TypeScript 能发展至今,得益于微软,而 JavaScript 的语法则是由 TC39 协会制定的。由于 JavaScript 的发展速度问题,有一些语法的实现细节在 TC39 协会还在讨论的时候,TypeScript 就已经实现了。比较典型的就是装饰器 Decorator 的语法,因为 TC39 在 Decorator 的实现思路上,和 Typescript 不同,未来 TypeScript 的 Decorator 可能会和 JavaScript 的 Decorator 发生冲突。【4】TypeScript 最终还是要编译成为 JavaScript,并在浏览器里执行。对于浏览器厂商来说,引入类型系统的收益并不太高,毕竟编译需要时间。而过多的编译时间,会影响运行时的性能,所以未来 TypeScript 很难成为浏览器的语言标准。【5】所以我们的核心还是要掌握 JavaScript,在这个基础之上,无论是框架,还是 TypeScript 类型系统,我们都将其作为额外的工具使用,才是我们最佳的选择。

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

  • 组件库:首先需要一个组件库帮助我们快速搭建项目,组件库提供了各式各样的封装完备的组件。我们选择 Element3 来搭建项目,首先我们来到项目目录下,执行下面的代码安装 Element3。然后,我们在 src/main.js 中使用一下 Element3,引入了 Element3 和主体对应的 CSS,并使用 use(Element3) 加载组件库。这样,项目的入口页面就注册好了 Element3 内置的组件。
npm install element3 --save
1
import { createApp } from 'vue'
import Element3 from 'element3'
import 'element3/lib/theme-chalk/index.css'
import store from './store/index'
import App from './App.vue'
import router from './router/index'
const app = createApp(App)
app.use(store)
    .use(router)
    .use(Element3)
    .mount('#app')
1
2
3
4
5
6
7
8
9
10
11
  • 工具库:完成页面基本结构的搭建后,在我们获取后端数据时,需要使用 axios 发起网络请求。axios 跟 Vue 版本没有直接关系,安装最新即可。在项目开发中,业务逻辑有很多配置需要进行统一设置,所以安装完 axios 之后,我们需要做的就是封装项目中的业务逻辑。【1】首先,在项目在登录成功之后,后端会返回一个 token,用来存储用户的加密信息,我们把 token 放在每一次的 http 请求的 header 中,后端在收到请求之后,会对请求 header 中的 token 进行认证,然后解密出用户的信息,过期时间,并且查询用户的权限后,校验完毕才会返回对应的数据。【2】所以我们要对所有的 http 请求进行统一拦截,确保在请求发出之前,从本地存储中获取 token,这样就不需要在每个发起请求的组件内去读取本地存储。后端数据如果出错的话,接口还要进行统一拦截,比如接口返回的错误是登录状态过期,那么就需要提示用户跳转到登录页面重新登录。
npm i axios --save
1
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
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
  • 然后,我们在项目里集成 CSS 预编译器,CSS 预编译器可以帮我们更快、更高效地管理和编写 CSS 代码。在这里,我们选择 Sass 作为 CSS 预处理语言,然后我们就进入项目根目录下执行下面代码安装 Sass。Sass 让我们在 CSS 的世界里也拥有了编程的概念,在实际项目中可以使用变量和函数等概念优化 CSS 代码。
npm install -D sass
1
<style lang="scss" scoped>
$padding:10px;
$white:#fff;
ul {
  width:500px;
  margin:0 auto;
  padding: 0;
  li {
    &:hover {
      cursor: pointer;
    }
    list-style-type: none;
    margin-bottom: $padding;
    padding: $padding;
    background: $white;
    box-shadow: 1px 3px 5px rgba(0, 0, 0, 0.1);
  }
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  • 代码规范和提交规范:ESLint 就是专门用来做规范代码的一个库。ESLint 安装成功后,在项目根目录下执行 npx eslint --init,然后按照终端操作的提示完成一系列设置来创建配置文件。(使用 husky 管理 git 的钩子函数,在每次代码提交至 git 之前去执行 ESLint,只有 ESLint 的校验通过,commit 才能执行成功)
npm i eslint -D
1

img

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

  • 在项目中,权限系统的控制需要前后端配合完成,而且权限系统也是后端管理系统中常见的一个难点。下面,我们先从登录权限谈起,因为登录权限对于一个项目来说是必备的功能模块。完成了登录选项的设置后,下一步需要做的是管理项目中的页面权限,而角色权限在这一过程中则可以帮助我们精细化地去控制页面权限。
  • 登录权限:通常来说管理系统的内部页面都需要登录之后才可以访问,比如个人中心、订单页面等等。首先,我们来设计一个这样的权限限制功能,它能保证某些页面在登录之后才能访问。(token 就算是一个钥匙,对于那些需要权限才能读取到的页面数据,前端需要带上这个钥匙才能读取到数据,否则访问那些页面的时候就会显示没有权限)我们回到前端页面,登录成功后,首先需要做的事情,就是把这个 token 存储在本地存储里面,留着后续发送数据。这一步的实现比较简单,直接把 token 存储到 localStorage 中就可以了。【1】通过下面的操作,我们完成了前端网络请求的 token 限制。但是还有一个需求没有实现,就是用户没有登录某个受限页面的时候,或者说没有 token 的时候,如果直接访问受限页面,比如个人中心,那么就需要让 vue-router 拦截这次页面的跳转。【2】与 vue-router 拦截页面的跳转,并显示无权限的报错信息相比,直接跳转登录页是现在更流行的交互方式。但这种方式需要在 vue-router 上加一层限制,这层限制就是说,在路由跳转的时候做权限认证,我们把 vue-router 的这个功能称作导航守卫 (opens new window)。在 router.beforeEach 函数中设置一个全局的守卫。【3】在路由守卫的函数内,只要是页面跳转时想实现的操作,都可以放在这个函数内部实现,比如一些常见的交互效果,就像给项目的主页面顶部设置一个页面跳转的进度条、设置和修改页面标题等等。【4】后端设置 cookie:登录的时候,后端只需要设置 setCookie 这个 header,之后浏览器会自动把 cookie 写入到我们的浏览器存起来,然后当前域名在发送请求的时候都会自动带上这个 cookie。这是浏览器自动管理和发送的,也算是权限认证的最佳方案之一。【5】但是,在现在这种前后端分离的场景下,通常前后端项目都会部署在不同的机器和服务器之上,Cookie 在跨域上有诸多的限制。所以在这种场景下,我们更愿意手动地去管理权限,于是就诞生了现在流行的基于 token 的权限解决方案,你也可以把 token 理解为我们手动管理的 cookie。
// router.js
import Login from '../components/Login.vue'
const routes = [
    ...
    {
        path: '/login',
        component: Login,
        hidden: true,
    }
]

// Login.vue
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
    }
  })
}
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
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)
  }
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 如果 token 不存在的话直接跳转登录页面,否则返回 true,页面正常跳转
router.beforeEach(async (to, from,next) => {
  // canUserAccess() 返回 `true` 或 `false`
  let token = getToken()
  if(!token){
     next('/login')
  }
  return true
})
1
2
3
4
5
6
7
8
9
  • 角色权限:对系统内部的权限进行分级,每个级别都对应着可以访问的不同页面。我们通常使用的权限解决方案就是 RBAC 权限管理机制。简单来说,就是在下图所示的这个模型里,除了用户和页面之外,我们需要一个新的概念,就是角色。每个用户有不同的角色,每个角色对应不同的页面权限,这个数据结构的关系设计主要是由后端来实现。【1】这样有一部分页面是写在代码的 src/router/index.js 中,另外一部分页面我们通过 axios 获取数据后,通过调用 vue-router 的 addRoute 方法动态添加 (opens new window)进项目整体的路由配置中。【2】我们需要把动态路由的状态存储在本地存储里,否则刷新页面之后,动态的路由部分就会被清空,页面就会显示 404 报错。我们需要在 localStorage 中把静态路由和动态路由分开对待,在页面刷新的时候,通过 src/router/index.js 入口文件中的 routes 配置,从 localStorage 中获取完整的路由信息,并且新增到 vue-router 中,才能加载完整的路由。【3】权限系统中还有一个常见的问题,就是登录是有时间限制的。在常见的登录状态下,token 有效期只能保持 24 小时或者 72 小时,过了这个期限,token 会自动失效。即使我们依然存在 token,刷新页面后也会跳转到登录页。(首先,token 的过期时间认证是由后端来实现和完成的。如果登录状态过期,那么会有一个单独的报错信息,我们需要在接口拦截函数中,统一对接口的响应结果进行拦截。如果报错信息显示的是登录过期,我们需要清理所有的 token 和页面权限数据,并且跳转到登录页面)【4】动态路由:vue-router 提供的 addRoute 和 removeRoute 这两个函数,可以很好地帮助我们实现这一功能。

img

  • 如果在任意一个页面里,我们想实现按钮级别的权限认证,那我们应该如何扩展我们的权限系统:大部分场景封装一个v-auth指令。【实现按钮级别的权限认证:1. 维护页面下需要控制权限的按钮权限标识,后台保存;2. 登录后,获取权限数据,将该用户的按钮权限数组存放到对应页面的路由信息里;3. 可编写v-auth的自定义指令(可以拿当前按钮标识去当前页面路由信息的按钮权限数组里去找,存在则显示,否则隐藏)】

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

  • 独立的第三方库:有的第三方工具框架跟 Vue 耦合性不高,而有的需要做适配。(和 Vue 耦合性不高,直接引入使用即可;否则需要按需引入)
  • axios:发送和获取网络接口数据。在 Vue、React 框架下,axios 可以用来获取后端数据;甚至在 Node.js 环境下,也可以用 axios 去作为网络接口工具去实现爬虫。在项目中,我们之后还会依赖很多和 NProgress 类似的库,比如处理 Excel 的 xlsx 库,处理剪切板的 clipboard 库等等。
// npm install nprogress -D
// 进行 import 操作,导入 NProgress 库之后,就不需要使用 Vue3 的插件机制进行注册
import NProgress from 'nprogress' // progress bar
router.beforeEach(async (to, from, next) => {
  // start progress bar
  NProgress.start()
})

router.afterEach(() => {
  // finish progress bar
  NProgress.done()
})
1
2
3
4
5
6
7
8
9
10
11
12
  • 组件的封装:下面我们以可视化组件为例,来分析复杂组件的封装。首先,你需要完成图表库的配置,并且填入图表数据,然后把这个数据渲染在一个 DOM 上就可以了。
<!-- echarts.init 初始化一个 DOM 标签 -->
<!-- 在 options 中配置了图表的结构 -->
<!-- 通过 series 配置了页面的销量数据 -->
<!-- 使用 myChart.setOption 的方式渲染图表 -->
<!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>
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 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>
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
  • 指令的封装:指令增强型组件的封装。比如我们常见的图片懒加载的需求,这一需求的实现方式就是在 img 的标签之上,再加上一个 v-lazy 的属性。像图片懒加载这种库和 DOM 绑定,但是又没有单独的组件渲染逻辑的情况,通常在 Vue 中以指令的形式存在。在 Vue 中注册指令和组件略有不同。(我们通过 lazy 指令获取到当前图片的标签,并且计算图片的位置信息,判断图片是否在首页显示。如果不在首页的话,图片就加载一个默认的占位符就可以了,并且在页面发生变化的时候,重新进行计算,这样就实现了页面图片的懒加载)
// 注册实现了 v-focus 指令,然后在 input 标签中加上 v-focus 指令,在指令加载完毕后,鼠标会自动聚焦到输入框上,这个实现在登录注册窗口中很常见。
// 注册一个全局自定义指令 `v-focus`
app.directive('focus', {
  // 当被绑定的元素挂载到 DOM 中时……
  mounted(el) {
    // 聚焦元素
    el.focus()
  }
})
1
2
3
4
5
6
7
8
9
  • 指令的生命周期和组件类似,首先我们要让指令能够支持 Vue 的插件机制,所以我们需要在 install 函数内注册 lazy 指令。在 install 方法的内部去注册 lazy 指令,并且实现了 mounted、updated、unmounted 三个钩子函数。
const lazyPlugin = {
  install (app, options) {
    app.directive('lazy', {
      mounted: ...,
      updated: ...,
      unmounted: ...
    })
  }
}
1
2
3
4
5
6
7
8
9
  • 与懒加载类似的,还有我们组件库中常用的 v-loading 指令,它用来显示组件内部的加载状态。通过 v-loading 的值来对显示效果进行切换,实现了组件内部的 loading 状态。动态切换的 Loading 组件能够显示一个 circle 的 div 标签,通过 v-loading 指令的注册,在后续表格、表单等组件的提交状态中,加载状态就可以很方便地使用 v-loading 来实现。
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)
  }
}
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
  • 引入第三方库的注意事项:我们封装第三方库的目的是实现第三方框架和 Vue 框架的融合,提高开发效率。【1】首先,无论是引用第三方库还是你自己封装的底层库,在使用它们之初就要考虑到项目的长期可维护性。【2】其次,尽可能不要因为排期等问题,一股脑地把第三方库堆在一起,虽然这样做可以让项目在早期研发进度上走得很快,但这样会导致项目中后期的维护成本远远大于重写一遍代码 xxx 的成本。【3】然后是 Vue 中的 mixin,extends 机制能不用就不用,这两个 API 算是从 Vue 2 时代继承下来的产物,都是扩展和丰富 Vue 2 中 this 关键字,在项目复杂了之后,mixin 和 extends 隐式添加的 API 无从溯源,一旦多个 mixin 有了命名冲突,调试起来难度倍增。【4】项目中的全局属性也尽可能少用,全局变量是最原始的共享数据的方法,Vue 3 中我们使用 app.config.globalProperties.x 注册全局变量,要少用它的主要原因也是项目中的全局变量会极大的提高维护成本。有些监控场景必须要用到,就要把所有注册的全局变量放在一个独立的文件去管理。【5】最后,我们引入第三方框架和库的时候一定要注意按需使用。

  • 项目中第三方框架:【1】print.js, 打印。【2】pdfjs,生成pdf。【3】html2canvas,生成海报。

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

  • 先从 Vue 项目在整体上的执行流程谈起,然后详细介绍性能优化的两个重要方面:网络请求优化代码效率优化。不过,在性能优化之外,用户体验才是性能优化的目的。最后,会通过性能监测报告,指引出性能优化的方向。

  • 用户输入 URL 到页面显示的过程:简单来说,就是用户在输入 URL 并且敲击回车之后,浏览器会去查询当前域名对应的 IP 地址。对于 IP 地址来说,它就相当于域名后面的服务器在互联网世界的门牌号。然后,浏览器会向服务器发起一个网络请求,服务器会把浏览器请求的 HTML 代码返回给浏览器。之后,浏览器会解析这段 HTML 代码,并且加载 HTML 代码中需要加载的 CSS 和 JavaScript,然后开始执行 JavaScript 代码。进入到项目的代码逻辑中,可以看到 Vue 中通过 vue-router 计算出当前路由匹配的组件,并且把这些组件显示到页面中,这样我们的页面就完全显示出来了。而我们性能优化的主要目的,就是让页面显示过程的时间再缩短一些。

img

  • 性能优化:从用户输入 URL 到页面显示的过程这个问题,包含着项目页面的执行流程。这个问题之所以重要,是因为我们只有知道了在这个过程中,每一步都发生了什么,之后才能针对每一步去做网络请求的优化,这也是性能优化必备的基础知识。
  • 网络请求优化:【1】在首页的标签中,使用标签去通知浏览器对页面中出现的其他域名去做 DNS 的预解析,比如页面中的图片通常都是放置在独立的 CDN 域名下,这样页面加载首页的时候就能预先解析域名并把结果缓存起来 。dns-prefetch 标签,这样首页再出现 img.alicdn.com 这个域名请求的时候,浏览器就可以从缓存中直接获取对应的 IP 地址。【2】项目在整体流程中,会通过 HTTP 请求加载很多的 CSS、JavaScript,以及图片等静态资源。为了让这些文件在网络加载中更快,我们可以从后面这几方面入手进行优化。【3】首先,浏览器在获取网络文件时,需要通过 HTTP 请求,HTTP 协议底层的 TCP 协议每次创建链接的时候,都需要三次握手,而三次握手会造成额外的网络损耗。如果浏览器需要获取的文件较多,那就会因为三次握手次数过多,而带来过多网络损耗的问题。所以,首先我们需要的是让文件尽可能地少,这就诞生出一些常见的优化策略,比如先给文件打包,之后再上线;使用 CSS 雪碧图来进行图片打包等等。文件打包这条策略在 HTTP2 全面普及之前还是有效的,但是在 HTTP2 普及之后,多路复用可以优化三次握手带来的网络损耗。【4】其次,除了让文件尽可能少,我们还可以想办法让这些文件尽可能地小一些。比如 CSS 和 JavaScript 代码会在上线之前进行压缩;在图片格式的选择上,对于大部分图片来说,需要使用 JPG 格式,精细度要求高的图片才使用 PNG 格式;优先使用 WebP 等等。也就是说,尽可能在同等像素下,选择体积更小的图片格式。【5】在性能优化中,懒加载的方式也被广泛使用。图片懒加载,我们可以动态计算图片的位置,只需要正常加载首屏出现的图片,其他暂时没出现的图片只显示一个占位符,等到页面滚动到对应图片位置的时候,再去加载完整图片。路由懒加载,现在项目打包后,所有路由的代码都在首页一起加载。但是,我们也可以把不常用的路由单独打包,在用户访问到这个路由的时候再去加载代码。【6】按需加载

img

  • 在项目打包的时候,使用可视化的插件来查看包大小的分布:安装插件 rollup-plugin-visualizer。执行 npm run build 命令后,项目就把项目代码打包在根目录的 dist 目录下,并且根目录下多了一个文件 stat.html
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
  plugins: [vue(), vueJsx(), visualizer()],
})
1
2
3
4
5
  • 那么这些文件如何才能高效复用呢?我们需要做的,就是尽可能高效地利用浏览器的缓存机制,在文件内容没有发生变化的时候,做到一次加载多次使用,项目中如果成功复用一个几百 KB 的文件,对于性能优化来说是一个巨大的提升。浏览器的缓存机制有好几个 Headers 可以实现,Expires、Cache-control,last-modify、etag 这些缓存相关的 Header 可以让浏览器高效地利用文件缓存。我们需要做的是,只有当文件的内容修改了,我们才会重新加载文件。这也是为什么我们的项目执行 npm run build 命令之后,静态资源都会带上一串 Hash 值,因为这样确保了只有文件内容发生变化的时候,文件名才会发生变化,其他情况都会复用缓存。

  • 代码效率优化:在浏览器加载网络请求结束后,页面开始执行 JavaScript,因为 Vue 已经对项目做了很多内部的优化,所以在代码层面,我们需要做的优化并不多。很多 Vue 2 中的性能优化策略,在 Vue 3 时代已经不需要了,我们需要做的就是遵循 Vue 官方的最佳实践,其余的交给 Vue 自身来优化就可以了。比如 computed 内置有缓存机制,比使用 watch 函数好一些;组件里也优先使用 template 去激活 Vue 内置的静态标记,也就是能够对代码执行效率进行优化;v-for 循环渲染一定要有 key,从而能够在虚拟 DOM 计算 Diff 的时候更高效复用标签等等。

  • 性能优化另外一个重要原则,那就是不要过度优化。

// 使用简单的递归算法实现斐波那契数列
function fib(n){
  if(n<=1) return 1
  return fib(n-1)+fib(n-2)
}
let count = fib(38)

// 优化
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]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 用户体验优化:性能优化的主要目的,还是为了能让用户在浏览网页的时候感觉更舒服。必要的时候,我们可以损失一些性能去换取交互体验的提升。【1】比如用户加载大量图片的同时,如果本身图片清晰度较高,那直接加载的话,页面会有很多图一直是白框。所以我们也可以预先解析出图片的一个模糊版本作为占位符,然后再去加载清晰的版本。【2】比如用户上传文件的时候,如果文件过大,那么上传可能就会很耗时。而且一旦上传的过程中发生了网络中断,那上传就前功尽弃了。我们可以选择断点续传,也就是把文件切分成小块后,挨个上传。【3】还有很多组件库也会提供骨架图的组件,能够在页面还没有解析完成之前,先渲染一个页面的骨架和 loading 的状态,这样用户在页面加载的等待期就不至于一直白屏。

  • 性能监测报告:使用 Chrome 的性能监测工具 Lighthouse,查看评测报告。为了方便理解,在这里解释一下 FCP、TTI 和 LCP 这几个关键指标的含义。【1】首先是 First Contentful Paint,通常简写为 FCP,它表示的是页面上呈现第一个 DOM 元素的时间。在此之前,页面都是白屏的状态;【2】然后是 Time to interactive,通常简写为 TTI,也就是页面可以开始交互的时间;【3】还有和用户体验相关的 Largest Contentful Paint,通常简写为 LCP,这是页面视口上最大的图片或者文本块渲染的时间,在这个时间,用户能看到渲染基本完成后的首页,这也是用户体验里非常重要的一个指标。

  • 通过代码中的 performance 对象去动态获取性能指标数据,并且统一发送给后端,实现网页性能的监控。性能监控也是大型项目必备的监控系统之一,可以获取到用户电脑上项目运行的状态。下图展示了 performance 中所有的性能指标,我们可以通过这些指标计算出需要统计的性能结果。(通过 Performance API 获取了 DNS 解析、网络、渲染和可交互的时间消耗)

img

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 加餐01|什么是好的项目?

  • 很多同学面试的时候都会被问到:你做过什么项目?看起来很简单的一个问题,却难住了无数面试者,因为面试官想听到的并不是你的项目流水账,而是你项目中的亮点
  • 足够好的项目:你不用怀疑的一点是,我们在日常工作中的项目,都是足够好的项目。但是对于 “足够好的项目” 的标准,其实是相对而言的。为什么这么说呢?因为好项目的标准,根据你所在的工作环境和你的岗位级别不同,都会发生变化。如果你本身就在大厂里,项目有大量的流量、效率的挑战,那只完成项目的基本功能是远远不够的。如果你还能再考虑提升一下项目运行的效率发布部署的效率等等,那对你来说,这就是一个足够好的项目,并且已经亮点颇多了。
  • 项目中的亮点:【1】可以从手里负责的项目开始考虑,所以我们可以从项目的结构说起。在开发的代码中,我们使用组件 + 数据 + 路由的方式实现了项目需求,然后项目打包后部署到服务器之上。用户访问到的都是线上的代码,有增删改查的操作就会调用后端的接口实现。对于项目功能的实现来说,这种结构没有问题,但这种结构是没有亮点的。【2】当你开始考虑每一个环节的优化项,当你开始思考左侧的组件如何能在多个项目复用?整体项目的性能如何优化?项目打包上线的过程如何更稳定?如何提前发现项目中的报错等等问题的时候,亮点也就随之诞生了。【3】我们能看到,对于一个项目来说,有很多值得优化的点。但是,这并不意味着你需要一个人去承包所有的待优化项,我们可以根据你在项目开发中的角色来分别做讨论。

img

  • 给项目普通开发者的优化建议:如果你现在是团队内的开发者之一,那你能做的,主要还是从开发者的角度去思考现在手里负责的需求如何能够更进一步做优化,首先是需求中的数据量比变大之后如何优化,我在这里给你举两个常见的场景,相信会带给你不少启发。
  • 【1】文件上传的场景:我们直接使用 axios.post 就可以实现这个需求了,文件的体积就是这个场景下的数据量,那么文件变得很大之后,该如何处理呢?中途一旦出现网络卡顿,就需要重新上传这个视频文件。所以在这种数据量极大的场景下,我们需要采用断点续传的解决方案。在文件上传之前,我们需要在前端计算出一个文件的 Hash 值作为唯一标识,用来向后端询问切片的列表。但是比如我们上传切片的时候,所有的文件切片一起使用 Promise.all 发起几十个 HTTP 请求,也会导致卡顿,所以我们就需要手动管理上传任务的并发数量。由于切片上传速度跟当前网速相关,所以在对上传任务的并发数量进行管理时,我们需要确定切片的大小。那该如何确定切片的大小呢?我们可以借鉴 TCP 协议的慢启动逻辑,去让切片的大小和当前网速匹配,这样,我们就可以通过网速确定切片的大小。演示代码 (opens new window)
// 对于一个 2GB 大小的文件来说,即使是使用 MD5 算法来计算 Hash 值,也会造成浏览器的卡顿
// 对于卡顿问题,我们可以通过 web-workder 去解决
// 这里的 hash.js,就相当于浏览器主进程的分身,用分身就可以去计算 Hash 值,不耽误主进程的任务
async calculateHashWorker(chunks) {
    return new Promise(resolve => {
        // web-worker 防止卡顿主线程
        this.worker = new Worker("/hash.js")
        this.worker.postMessage({ chunks })
        this.worker.onmessage = e => {
            const { progress, hash } = e.data
            this.hashProgress = Number(progress.toFixed(2))
            if (hash) {
                resolve(hash)
            }
        };
    });
}

// 借鉴 React 的 Fiber 解决方案,使用浏览器的空闲时间去计算 Hash
// 使用 requestIdleCallback 启动空闲时间的计算任务
let count = 0
const workLoop = async deadline => {
  // 计算,并且当前帧还没结束
  while (count < chunks.length && deadline.timeRemaining() > 1) {
    await appendToSpark(chunks[count].file)
    count++
    // 没有了 计算完毕
    if (count < chunks.length) {
      // 计算中
      this.hashProgress = Number(
        ((100 * count) / chunks.length).toFixed(2)
      )
      // console.log(this.hashProgress)
    } else {
      // 计算完毕
      this.hashProgress = 100
      resolve(spark.end())
    }
  }
  window.requestIdleCallback(workLoop)
}
window.requestIdleCallback(workLoop)
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
  • 【2】列表渲染的场景:使用虚拟列表来应对这个场景,我们只需要渲染视图中可见的 DOM 元素,就可以实现性能优化了。看下面的示意图,我们只渲染窗口中的绿色元素,然后浏览器滚动的过程中我们维护这些 DOM,就可以避免因为页面中 DOM 元素过多,而引起的卡顿问题。

img

  • 给项目骨干开发者的优化建议:更需要从项目的整体出发,去思考如何提高项目的研发效率和稳定性。【1】首先你会发现,一旦团队项目里多个项目之间的配置或者规范不同步,那么每个项目的配置都需要手动修改,而这很浪费时间。所以,你可以发起了一个团队的脚手架项目,把项目中的代码规范、Vite 配置,log 等等都集成在脚手架内部。【2】然后,很多时候,公司多个项目之间会有代码复用和组件复用的需求。这时,你就可以再发起一个基础组件库的项目,做出一个类似 Element3 的基础组件库,并且发布在公司的 npm 服务之上,提供给全公司前端使用。为了让大家用这个组件库的时候能放心,你可以给组件库实现完备的文档系统以及超过 90% 的单测覆盖率。【3】前端项目的上线需要和后端服务器打交道,为了提高发布和部署的效率,你可以发起了一个 CI/CD 的项目,利用 GitHub 的 action 机制,可以把整个发布过程自动化,并且还可以一键回滚。【4】复盘你现在负责的业务类型,如果你负责营销组,那么面对繁多的营销页面时,你可以搭建一个 Low Code 系统,让运营同学和产品同学自己通过拖拽的方式配置出营销页面。在这个过程中,你需要解决搭建系统时的一系列问题,比如:如何设计物料系统、如何实现跨端搭建系统等等。【5】实时监控客户端的性能,除了常见的性能优化策略之外,我们还可以分析用户访问日志,提前预测用户可能访问的页面,从而做路由级别的预加载等等。
  • 尝试使用 STAR 原则去描述项目:所谓 STAR 原则,即 Situation(情景)、Task(任务)、Action(行动)和 Result(结果),结果中最好还能带上数字展示,这样你的项目的描述就会很饱满。以下是对比:【1】原:2020-2021 在极客时间负责官网开发和后台管理系统。【2】改:2020-2021 在极客时间带领 3 个同事开发和维护极客时间官网的前端项目,作为核心开发者,参与了组件库的设计,XX 个组件测试覆盖率达到 80%,性能优化了 XX%。【3】改:2021 年 6 月至今,在极客时间负责开发极客时间后台管理系统,作为团队负责人,负责代码开发和 5 人团队的搭建,项目由 XX 和 XX 核心模块构成,通过引入 XX,提高了 XX% 的性能。

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

  • 代码部署难点:【1】在 jQuery 时代之前,前端项目中所有的内容都是一些简单的静态资源,网站还没有部署的概念。网站上线前,我们直接把开发完的项目打包发给运维,再由运维把代码直接上传到服务器的网站根目录下解压缩,这样就完成了项目的部署。【2】后来的 jQuery 时代,项目的入口页面被后端管理,模板部署到了后端,CSS、JavaScript 和图片等静态资源依然是打包到后端之后,再解压处理。【3】现在前端所处的时代,我们主要会面临后面这些代码部署难点:首先是,如何高效地利用项目中的文件缓存;然后是,如何能够让整个项目的上线部署过程自动化,尽可能避免人力的介入,从而提高上线的稳定性;最后,项目上线之后,如果发现有重大 Bug,我们就要考虑如何尽快回滚代码
  • 项目上线前的自动化部署:首先,我们需要一台独立的机器去进行打包和构建的操作,这台机器需要独立于所有开发环境,这样做是为了保证打包环境的稳定;之后,在部署任务启动的时候,我们需要拉取远程的代码,并且切换到需要部署的分支,然后锁定 Node 版本进行依赖安装、单元测试、ESLint 等代码检查工作;最后,在这台机器上,执行经过编译产出的打包后的代码,并打包上传代码到 CDN 和静态服务器。当然了,完成这些操作之后,还要能通过脚本自动通过内部沟通软件通知团队项目构建的结果。
  • 但是在项目部署的过程中,迎面而来的可能是下面这些问题:在什么操作系统环境中执行项目的构建?由谁触发构建?如何管理前面所述的把代码上传 CDN 时,CDN 账户的权限?如何自动化执行部署的全过程,如果每次都由人工执行,就得消耗一个人力守着编译打包了,而且较为容易引发问题,比如测试的步骤遗漏或部署顺序出错。
  • 为了解决上面这些问题,业界提出了一些解决方案:比如,采用能保证环境一致性的 Docker;自动化构建触发可以通过 GitHub Actions;GitHub 的 actions 功能相当于给我们提供了一个免费的服务器,可以很方便地监控代码的推送、安装依赖、代码编译自动上传到服务器。

img

  • 项目上线后的自动化部署:前端项目的自动化部署完成后,我们可以保证上线的稳定性,但是后续的持续上线怎么办?直接发到生产环境,会面临极大的风险。但如果不直接发布到生产环境,我们就不能在本地和测试的前端环境去连接生产环境的数据库。所以我们需要一个预发布的(Pre)环境,这个环境只能让测试和开发人员访问,除了访问地址的环节不同,其他所有环节都和生产环境保持一致,从而提供最真实的回归测试环境。这个时候,我们会遇见下面这些问题,【1】首先,如果我们确定项目下个版本在下周一零点发布,那我们就只能晚上 12 点准时守在电脑前,等待结果吗?如果 npm 安装依赖失败,或者上线后发现了重大 Bug,那就只能迎接用户的吐槽吗?【2】其次,随着 node_modules 的体积越来越大,构建时间会越来越长。即使 Bug 是在项目刚上线时就发现的,并且你也秒级响应,并修复了 Bug,但在重新部署项目时,我们也需要等服务器慢慢编译。
  • 为了解决上面说到的这些问题,我们需要一种机制,能够让我们在发现问题之后,尽快地将版本进行回滚,并且在回滚的操作过程中,尽可能不需要人力的介入。所以,我们需要静态资源的版本管理,具体来说,就是让每个历史版本的资源都能保留下来,并且有一个唯一的版本号,如果发生了故障,能够瞬间切换版本。这个过程由具体的代码实现之后,我们只需要点击回滚的版本号,系统就会自动恢复到上线前的版本。在这种机制下,如果你的业务流量特别大,每秒都有大量用户访问和使用,那么直接全量上线的操作就会被禁止。为了减少上线时,部署操作对用户造成的影响,我们需要先选择一部分用户去做灰度测试,也就是说,上线后的项目的访问权限,暂时只对这些用户开放。或者,你也可以做一些 AB 测试,比如给北京的同学推送 Vue 课,给上海的同学推荐 React 课等等。我们需要做的,就是把不同版本的代码分开打包,互不干涉。之后,我们再设计部署的机器和机房去适配不同的用户。
  • 在 Gtihub 中,我们可以使用 actions 去配置打包的功能,下面的代码是 actions 的配置文件。在这个配置文件中,我们使用 Ubuntu 作为服务器的打包环境,然后拉取 GitHub 中最新的 master 分支代码,并且把 Node 版本固定为 14.7.6,执行 npm install 安装代码所需依赖后,再执行 npm run build 进行代码打包压缩。然后,我们需要配置上线服务器和 GitHub Actions 服务器的信任关系,通过 SSH 密钥可以实现免登录直接部署。我们直接把 build 之后的代码打包压缩,通过 SSH 直接上传到服务器上,并且要进行代码文件版本的管理,就完成了代码的部署。最后一步,就是部署成功后的结果通知了。现在办公软件钉钉和飞书都提供了相关的推送结果,我们可以随时通过群机器人接口把消息推送到群内。
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 加餐02|深入TypeScript

  • 一边阅读一边敲代码的话,学习效果更好。在线链接 (opens new window)
  • TypeScript 入门:对于 TypeScript,你首先要了解的是,TypeScript 可以在 JavaScript 的基础上,对变量的数据类型加以限制。TypeScript 中最基本的数据类型包括布尔、数字、字符串、null、undefined,这些都很好理解。【1】当你不确定某个变量是什么类型时,你可以使用 any 作为这个变量的类型。你可以用 any 标记任何属性,可以修改任何数据,访问任何方法也不会报错。也就是说,在 TypeScript 中,当你把变量的类型标记为 any 后,这个变量的使用就和 JavaScript 没啥区别了,错误只会在浏览器里运行的时候才会提示。【2】然后我们可以使用 enum 去定义枚举类型,这样可以把类型限制在指定的场景之内。【3】然后我们可以通过学到的这些基础类型,通过组合的方式组合出新的类型,最常见的组合方式就是使用 | 实现类型联合。还可以用来限制变量只能赋值为几个字符串的一个。【4】通过 interface 接口可以定义对象的类型限制
let courseName: string = '玩转Vue 3全家桶'
let price: number = 129
price = '89' //类型报错
let isOnline: boolean = true
let courseSales: undefined
let timer: null = null
let me: [string,number] = ["大圣",18]
me[0] = 1 //类型报错
1
2
3
4
5
6
7
8
let anyThing
let anyCourse: any = 1
anyCourse = 'xx'
console.log(anyCourse.a.b.c)
1
2
3
4
enum 课程评分 {,非常好,嘎嘎好}
console.log(课程评分['好']===0)
console.log(课程评分[0]==='好')
let scores = [课程评分['好'], 课程评分['嘎嘎好'], 课程评分['非常好']]
1
2
3
4
let course1: string|number = '玩转vue 3'
course1 = 1
course1 = true // 报错

type courseScore = '好' | '非常好' | '嘎嘎好'
let score1: courseScore = '好'
let score2: courseScore = '一般好' // 报错
1
2
3
4
5
6
7
interface 极客时间课程 {
    课程名字:string,
    价格:number[],
    受众:string,
    讲师头像?:string|boolean,
    readonly 课程地址:string
}
let vueCourse: 极客时间课程 = {
    课程名字:'玩转Vue 3全家桶',
    价格:[59,'139'],
    讲师头像:false,
    课程地址:"time.geekbang.org"
}
vueCourse.课程地址 = 'e3.shengxinjing.cn' // 报错
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 然后学一下函数的类型限制。其实函数的定义,参数和返回值本质上也是变量的概念,都可以进行类型的定义。定义好参数和返回值类型,函数的类型自然也就确定了。也可以使用变量的方式去定义函数。
function 函数名(参数:参数类型):返回值类型{} //大致语法
function add(x: number, y: number): number {
    return x + y;
}
add(1, 2);
1
2
3
4
5
let add1:(a:number,b:number)=>number = function(x: number, y: number): number {
    return x + y;
}

type addType = (a:number,b:number)=>number
let add2:addType  = function(x: number, y: number): number {
    return x + y;
}

interface addType1 {
    (a:number,b:number):number
}
let add3:addType1  = function(x: number, y: number): number {
    return x + y;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 函数重载:我们的要求是如果参数是数字,返回值也要是数字,参数是字符串返回值也只能是字符串。
function reverse(x: number): number
function reverse(x: string): string
function reverse(x: number | string): number | string | void {
    if (typeof x === 'number') {
        return Number(x.toString().split('').reverse().join(''));
    } else if (typeof x === 'string') {
        return x.split('').reverse().join('');
    }
}
1
2
3
4
5
6
7
8
9
  • 日常开发中有很多浏览器上的变量和属性,这些怎么限制类型呢?关于宿主环境里的类型,TypeScript 全部都给我们提供了,我们可以直接在代码中书写:Window 是 window 的类型,HTMLElement 是 dom 元素类型,NodeList 是节点列表类型,MouseEvent 是鼠标点击事件的类型……关于更多 TypeScript 的内置类型,你可以在 TypeScript 的源码 (opens new window)中看到。
let w:Window = window
let ele:HTMLElement = document.createElement('div')
let allDiv: NodeList = document.querySelectorAll('div')
ele.addEventListener('click', function(e:MouseEvent){
    const args:IArguments = arguments
    w.alert(1)
    console.log(args)
},false)
1
2
3
4
5
6
7
8
  • 泛型:TypeScript 可以进行类型编程,这会极大提高 TypeScript 在复杂场景下的应用场景。【1】需要返回值的类型和参数一致,所以我们在函数名之后使用 <> 定一个泛型 T,你可以理解这个 T 的意思就是给函数参数定义了一个类型变量,会在后面使用,相当于【type T = arg 的类型】。【2】有了泛型之后,我们就有了把函数参数定义成类型的功能,我们就可以实现类似高阶函数的类型函数。keyof 可以帮助我们拆解已有类型,下一步我们需要使用 extends 来实现类型系统中的条件判断。【3】extends 相当于 TypeScript 世界中的条件语句,然后 in 关键字可以理解为 TypeScript 世界中的遍历。【4】然后讲解最后一个关键字 infer。 让我们拥有了给函数的参数定义类型变量的能力,infer 则是可以在 extends 之后的变量设置类型变量,更加细致地控制类型。【5】[K in keyof T]?: T[K]K extends keyof T
function identity0(arg: any): any {
    return arg
}
// 相当于type T = arg的类型
function identity<T>(arg: T): T {
    return arg
}
identity<string>('玩转vue 3全家桶') // 这个T就是string,所以返回值必须得是string
identity<number>(1)
1
2
3
4
5
6
7
8
9
// 使用 keyof 语法获得已知类型 VueCourse5 的属性列表,相当于 ‘name’|‘price’
interface VueCourse5 {
    name:string,
    price:number
}
type CourseProps = keyof VueCourse5 // 只能是 name 和 price 选一个
let k:CourseProps = 'name'
let k1:CourseProps = 'p' // 改成price

// T extends U ? X : Y 类型三元表达式
type ExtendsType<T> = T extends boolean ? "重学前端" : "玩转Vue 3"
type ExtendsType1 = ExtendsType<boolean> // type ExtendsType1='重学前端'
type ExtendsType2 = ExtendsType<string> // type ExtendsType2='玩转Vue 3'

// in
type Courses = '玩转Vue 3'|'重学前端'
type CourseObj = {
    [k in Courses]:number // 遍历Courses类型作为key
}
// 上面的代码等于下面的定义
// type CourseObj = {
//     玩转Vue 3: number;
//     重学前端: number;
// }

// infer
type Foo = () => CourseObj
// 如果T是一个函数,并且函数返回类型是P就返回P
type ReturnType1<T> = T extends ()=>infer P ?P:never 
type Foo1 = ReturnType1<Foo>
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

# 5. Vue 3 进阶开发篇 (8讲)

# 20|组件库:如何设计你自己的通用组件库

npm init vite@latest
1
  • husky:这个库可以很方便地帮助我们设置 Git 的钩子函数,可以允许我们在代码提交之前进行代码质量的监测。【1】下面的代码中,husky 会在我们执行 git commit 提交代码的时候执行 node scripts/verifyCommit 命令来校验 commit 信息格式。【2】然后我们来到项目目录下的 verifyCommit 文件。在下面的代码中,我们先去 .git/COMMIT_EDITMSG 文件中读取了 commit 提交的信息,然后使用了正则去校验提交信息的格式。如果 commit 的信息不符合要求,会直接报错并且终止代码的提交。【3】commit-msg 是代码执行提交的时候执行的,我们还可以使用代码执行之前的钩子 pre-commit 去执行 ESLint 代码格式。
npm install -D husky # 安装husky
npx husky install    # 初始化husky
# 新增commit msg钩子
npx husky add .husky/commit-msg "node scripts/verifyCommit.js" 
# pre-commit
npx husky add .husky/pre-commit "npm run lint"
1
2
3
4
5
6
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信息校验通过')
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 布局组件:我们可以参考 Element3 组件列表页面 (opens new window),这里的组件分成了基础组件、表单组件、数据组件、通知组件、导航组件和其他组件几个类型,这些类型基本覆盖了组件库的适用场景,项目中的业务组件也是由这些类型组件拼接而来的。

  • 各个组件的负责范围:【1】首先我们需要设计基础的组件,也就是整个项目中都会用到的组件规范,包括布局、色彩,字体、图标等等。这些组件基本没有 JavaScript 的参与,实现起来也很简单,负责的就是项目整体的布局和色彩设计。【2】表单组件则负责用户的输入数据管理,包括我们常见的输入框、滑块、评分等等,总结来说,需要用户输入的地方就是表单组件的应用场景,其中对用户的输入校验是比较重要的功能点。【3】数据组件负责显示后台的数据,最重要的就是表格和树形组件。【4】通知组件负责通知用户操作的状态,包括警告和弹窗,如何用函数动态渲染组件是警告组件的重要功能点。

  • 有两个 script 标签:开发组件库的时候,我们要确保每个组件都有自己的名字,script setup 中没法返回组件的名字,所以我们需要一个单独的标签,使用 options 的语法设置组件的 name 属性。

  • 组件注册:组件库最后会有很多组件对外暴露,用户每次都 import 的话确实太辛苦了,所以我们还需要使用插件机制对外暴露安装的接口。实际的组件库开发过程中,每个组件都会提供一个 install 方法 (opens new window),可以很方便地根据项目的需求按需加载。

// container/index.ts
import {App} from 'vue'
import ElContainer from './Container.vue'
import ElHeader from './Header.vue'
import ElFooter from './Footer.vue'
import ElAside from './Aside.vue'
import ElMain from './Main.vue'

export default {
  install(app:App){
    app.component(ElContainer.name,ElContainer)
    app.component(ElHeader.name,ElHeader)
    app.component(ElFooter.name,ElFooter)
    app.component(ElAside.name,ElAside)
    app.component(ElMain.name,ElMain)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/main.ts
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')
1
2
3
4
5
6
7
8
9
feat: 新功能
fix: 修改 bug
docs: 文档
perf: 性能相关
refactor: 代码重构(就是不影响使用,内部结构的调整)
test: 测试用例
style: 样式修改
workflow: 工作流
build: 项目打包构建相关的配置修改
ci: 持续集成相关
revert: 恢复上一次提交(回滚)
wip: work in progress 工作中 还没完成
chore: 其他修改(不在上述类型中的修改)
release: 发版
deps: 依赖相关的修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 21 | 单元测试:如何使用 TDD 开发一个组件

  • TypeScript 和 Jest 都为我们的代码质量和研发效率保驾护航。

  • 单元测试:大幅提升组件库代码可维护性的手段。如何使用测试驱动开发的方式实现一个组件,也就是社区里很火的 TDD 开发模式。单元测试(Unit Testing),是指对软件中的最小可测试单元进行检查和验证,这是百度百科对单元测试的定义。而我的理解是,在我们日常代码开发中,会经常写 Console 来确认代码执行效果是否符合预期,这其实就算是测试的雏形了,我们把代码中的某个函数或者功能,传入参数后,校验输出是否符合预期。

  • 组件库引入 Jest:我们选择 Facebook 出品的 Jest 作为我们组件库的测试代码,Jest 是现在做测试的最佳选择了,因为它内置了断言、测试覆盖率等功能。【1】不过,因为我们组件库使用 TypeScript 开发,所以需要安装一些插件,通过命令行执行下面的命令,vue-jest 和 @vue/test-utils 是测试 Vue 组件必备的库,然后安装 babel 相关的库,最后安装 Jest 适配 TypeScript 的库。代码如下:【2】安装完毕后,我们要在根目录下新建.babel.config.js。下面的配置目的是让 babel 解析到 Node 和 TypeScript 环境下。【3】然后,我们还需要新建 jest.config.js,用来配置 jest 的测试行为。不同格式的文件需要使用不同命令来配置,对于.vue 文件我们使用 vue-jest,对于.js 或者.jsx 结果的文件,我们就要使用 babel-jest,而对于.ts 结尾的文件我们使用 ts-jest,然后匹配文件名是 xx.spect.js。这里请注意,Jest 只会执行.spec.js 结尾的文件。【4】然后配置 package.json,在 scrips 配置下面新增 test 命令,即可启动 Jest。

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
1
2
3
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    '@babel/preset-typescript',
  ],
}
1
2
3
4
5
6
module.exports = {
  transform: {
    // .vue文件用 vue-jest 处理
    '^.+\\.vue$': 'vue-jest',
    // .js或者.jsx用 babel-jest处理
    '^.+\\.jsx?$': 'babel-jest', 
    //.ts文件用ts-jest处理
    '^.+\\.tsx?$': 'ts-jest'
  },
  testMatch: ['**/?(*.)+(spec).[jt]s?(x)'],
  collectCoverage: true,
  coverageReporters: ['json', 'html']
}
1
2
3
4
5
6
7
8
9
10
11
12
13
"scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "serve": "vite preview",
    "lint": "eslint --fix --ext .js,vue src/",
    "test": "jest",
}
1
2
3
4
5
6
7
  • TDD 开发组件:根据行为去书写测试案例。我们首先要从 @vue/test-utils 库中导入 mount 函数,这个函数可以在命令行里模拟 Vue 的组件渲染。我们实现功能的过程就像小时候写作业,而测试代码就像批改作业的老师。TDD 的优势就相当于有一位老师,在我们旁边不停做批改,哪怕一开始所有题都做错了,只要我们不断写代码,把所有题都回答正确,也能最后确保全部功能的正确。
// 全局配置
const app = createApp(App)
app.config.globalProperties.$AILEMENTE = {
  size:'large'
}
app.use(ElContainer)
  .use(ElButton)
  .mount('#app')

// util.ts,vue 提供的 getCurrentInstance 获取当前的实例
// 由于很多组件都需要读取全局配置,所以我们封装了 useGlobalConfig 函数
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 || {}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 22|表单:如何设计一个表单组件

  • 表单组件:表单组件在组件库中作用,就是收集和获取用户的输入值,并且提供用户的输入校验,比如输入的长度、邮箱格式等,符合校验规则后,就可以获取用户输入的内容,并提交给后端。现在主流组件库使用的都是 async-validator 这个库,详细的校验规则你可以访问 async-validator 的官网 (opens new window)查看。
  • 表单组件实现:【1】到 src/components 目录下新建 Form.vue 去实现 el-form 组件,该组件是整个表单组件的容器,负责管理每一个 el-form-item 组件的校验方法,并且自身还提供一个检查所有输入项的 validate 方法。在下面的代码中,我们注册了传递的属性的格式,并且注册了 validate 方法使其对外暴露使用。【2】那么在 el-form 组件中如何管理 el-form-item 组件呢?新建 FormItem.vue 文件,这个组件加载完毕之后去通知 el-form 组件自己加载完毕了,这样在 el-form 中我们就可以很方便地使用数组来管理所有内部的 form-item 组件。【3】然后 el-form-item 还要负责管理内部的 input 输入标签,并且从 form 组件中获得配置的 rules,通过 rules 的逻辑,来判断用户的输入值是否合法。另外,el-form 还要管理当前输入框的 label,看看输入状态是否报错,以及报错的信息显示,这是一个承上启下的组件。
interface Props {
  label?: string
  prop?: string
}
const props = withDefaults(defineProps<Props>(), { 
  label: "", 
  prop: "" 
})

const formData = inject(key)

const o: FormItem = {
  validate,
}

defineExpose(o)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { emitter } from "../../emitter"
const items = ref<FormItem[]>([])

emitter.on("addFormItem", (item) => {
  items.value.push(item)
})
1
2
3
4
5
6
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 = ""
    }
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  • form、form-item 和 input 这三个组件之间是嵌套使用的关系:form 提供了所有的数据对象和配置规则;input 负责具体的输入交互;form-item 负责中间的数据和规则管理,以及显示具体的报错信息。
  • 这就需要一个强有力的组件通信机制,在 Vue 中组件之间的通信机制有这么几种。【1】首先是父子组件通信,通过 propsemits 来通信。并且还可以通过 defineDepose 的方式暴露给父元素方法,可以让父元素调用自己的方法。【2】祖先元素和后代元素如何通信,中间可能嵌套了很多层的关系,Vue 则提供了 provideinject 两个 API 来实现这个功能。
  • 在组件中我们可以使用 provide 函数向所有子组件提供数据,子组件内部通过 inject 函数注入使用。注意这里 provide 提供的只是普通的数据,并没有做响应式的处理,如果子组件内部需要响应式的数据,那么需要在 provide 函数内部使用 ref 或者 reative 包裹才可以。关于 prvide 和 inject 的类型系统,我们可以使用 Vue 提供的 InjectiveKey 来声明。我们在 form 目录下新建 type.ts 专门管理表单组件用到的相关类型,在下面的代码中,我们定义了表单 form 和表单管理 form-item 的上下文,并且通过 InjectionKey 管理提供的类型。
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")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 通过 provide 向所有子元素提供 form 组件的上下文
// 子组件内部通过 inject 获取
provide(key, {
  model: props.model,
  rules?: props.rules,
})

// 子组件
const formData = inject(key)
1
2
3
4
5
6
7
8
9
  • 组件设计我们需要考虑的就是内部交互的逻辑,对子组件提供什么数据,对父组件提供什么方法,需不需要通过 provide 或者 inject 来进行跨组件通信等等。

# 23 | 弹窗:如何设计一个弹窗组件

  • 组件需求分析:在设计一个新的组件的时候,先把组件所有的功能都罗列出来,分析清楚需求再具体实现。首先无论是对话框 Dialog,还是消息弹窗 Notification,它们都由一个弹窗的标题,以及具体的弹窗的内容组成的。我们希望弹窗有一个关闭的按钮,点击之后就可以关闭弹窗,弹窗关闭之后还可以设置回调函数。
  • 这类组件实现起来和表单类组件区别不是特别大,我们首先需要做的就是控制好组件的数据传递,并且使用 Teleport 渲染到页面顶层的 body 标签。像 Dialog 和 Notification 类的组件,我们只是单纯想显示一个提示或者报错信息,过几秒就删除,如果在每个组件内部都使用 v-if 绑定变量的方式控制显示就会显得很冗余。所以,这里就要用到一种调用 Vue 组件的新方式:我们可以使用 JavaScript 的 API 动态地创建和渲染 Vue 的组件
  • 弹窗组件实现:弹窗类的组件都需要直接渲染在 body 标签下面,弹窗类组件由于布局都是绝对定位,如果在组件内部渲染,组件的 css 属性(比如 Transform)会影响弹窗组件的渲染样式,为了避免这种问题重复出现,弹窗组件 Dialog、Notification 都需要渲染在 body 内部。【1】Dialog 组件可以直接使用 Vue3 自带的 Teleport,很方便地渲染到 body 之上。用 teleport 组件把 dialog 组件包裹之后,通过 to 属性把 dialog 渲染到 body 标签内部。【2】但是 Notification 组件并不会在当前组件以组件的形式直接调用,我们需要像 Element3 一样,能够使用 js 函数动态创建 Notification 组件,给 Vue 的组件提供 Javascript 的动态渲染方法,这是弹窗类组件的特殊需求。
  • 组件渲染优化:template 的本质就是使用 h 函数创建虚拟 Dom。【1】在下面的代码中我们使用 Notification 函数去执行 createComponent 函数,使用 h 函数动态创建组件,实现了动态组件的创建。【2】创建组件后,由于 Notification 组件同时可能会出现多个弹窗,所以我们需要使用数组来管理通知组件的每一个实例,每一个弹窗的实例都存储在数组中进行管理。Notification 函数最终会暴露给用户使用,在 Notification 函数内部我们通过 createComponent 函数创建渲染的容器,然后通过 createNotification 创建弹窗组件的实例,并且维护在 instanceList 中。
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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)
  })
}
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

# 24|树:如何设计一个树形组件

  • 组件功能分析:树形组件的主要特点是可以无限层级、这种需求在日常工作和生活中其实很常见,比如后台管理系统的菜单管理、文件夹管理、生物分类、思维导图等等。首先,树形组件的节点可以无限展开,父节点可以展开和收起节点,并且每一个节点有一个复选框,可以切换当前节点和所有子节点的选择状态。另外,同一级所有节点选中的时候,父节点也能自动选中。
  • 递归组件:这里父节点和子节点的样式操作完全一致,并且可以无限嵌套,这种需求需要组件递归来实现,也就是组件内部渲染自己。想要搞定递归组件,我们需要先明确什么是递归。浏览器渲染的页面是 Dom 树,我们内部管理的是虚拟 Dom 树,树形结构是一种天然适合递归的数据结构。【1】做一个算法题感受一下,我们来到 leetcode 第 226 题反转二叉树 (opens new window):递归的时候,我们首先需要思考递归的核心逻辑如何实现,这里就是两个节点如何交换,然后就是递归的终止条件,否则递归函数就会进入死循环。【2】反转可以用解构赋值实现[ left, right ] = [ right, left ]

# 25|表格:如何设计一个表格组件

  • 组件库中最复杂的表格组件,核心的难点除了数据的嵌套渲染复杂的交互之外,复杂的 DOM 节点也是表格的特点之一。关于表单的具体交互形式和复杂程度,你可以访问 ElementPlus (opens new window)NaiveUi (opens new window)AntDesignVue (opens new window) 这三个主流组件库中的表格组件去体验,并且社区还提供了单独的复杂表格组件 (opens new window)
  • 表格组件:【1】我们先研究一下 html 的 table 标签。table 标签负责表格的容器,thead 负责表头信息的容器,tbody 负责表格的主体,tr 标签负责表格的每一行,th 和 td 分别负责表头和主体的单元格。其实标准的表格系列标签,跟 div+css 实现是有很大区别的。另外,它们的渲染原理上也有一定的区别,每一列的宽度会保持一致。【2】表格组件的使用风格,从设计上说也分为了两个方向。一个方向是完全由数据驱动,参考 Naive Ui 的使用方式,通过 data 属性传递数据,通过 columns 传递表格的表头配置;还有一种是 Element3 现在使用的风格,配置数据之后,具体数据的展现形式交给子元素来决定,把 columns 当成组件去使用。
  • 表格组件的扩展:复杂的表格组件需要对表格的显示和操作进行扩展。对于表格的操作来说,首先要和树组件一样,每一样支持复选框进行选中,方便进行批量的操作。另外,表头还需要支持点击事件,点击后对当前这一列实现排序的效果,同时每一列还可能会有详情数据的展开,甚至表格内部还会有树形组件的嵌套、底部的数据显示等等。【1】table-store 进行表格内部的状态管理。每当 table 中的 table-store 被修改后,table-header、table-body 都需要重新渲染。【2】表格组件除了显示的效果非常复杂、交互非常复杂之外,还有一个非常棘手的性能问题。一旦数据量庞大之后,表格就成了最容易导致性能瓶颈的组件,那这种场景如何去做优化呢?虚拟列表解决方案(虚拟滚动)
  • 性能优化主要的思路就是如何能够减少计算量。

# 26|文档:如何给你的组件库设计一个可交互式文档

  • 这个文档页面主要包含组件的描述,组件 Demo 示例的展示、描述和代码,并且每个组件都应该有详细的参数文档。我们需要用最简洁的语法来写页面,还需要用最简洁的语法来展示 Demo + 源代码 + 示例描述。那么从语法上来说,首选就是 Markdown 无疑了,因为它既简洁又强大。
  • VuePress:基于 Markdown 构建文档的工具。VuePress 内置了 Markdown 的扩展,写文档的时候就是用 Markdown 语法进行渲染的。最让人省心的是,它可以直接在 Markdown 里面使用 Vue 组件,这就意味着我们可以直接在 Markdown 中写上一个个的组件库的使用代码,就可以直接展示运行效果了。【1】新建 docs 目录作为文档目录,新建 docs/README.md 文件作为文档的首页。除了 Markdown 之外,我们可以直接使用 VuePress 的语法扩展对组件进行渲染。【2】然后进入 docs/.vuepress/ 目录下,新建文件 config.js,这是这个网站的配置页面。【3】然后创建 docs/install.md 文件,点击顶部导航之后,就会显示 install.md 的信息。【4】然后需要在这个文档系统中支持 Element3,首先执行下面的代码安装 Element3。在项目根目录下的 docs/.vuepress 文件夹中新建文件 clientAppEnhance.js,这是 VuerPress 的客户端扩展文件。
# 安装 VuePress 的最新版本
yarn add -D vuepress@next

# 安装 Element3
npm i element3 -D
1
2
3
4
5
---
home: true
heroImage: /theme.png
title: 网站快速成型工具
tagline: 一套为开发者、设计师和产品经理准备的基于 Vue 3 的桌面端组件库
heroText: 网站快速成型工具
actions:
  - text: 快速上手
    link: /install
    type: primary
  - text: 项目简介
    link: /button
    type: secondary
features:
  - title: 简洁至上
    details: 以 Markdown 为中心的项目结构,以最少的配置帮助你专注于写作。
  - title: Vue 驱动
    details: 享受 Vue 的开发体验,可以在 Markdown 中使用 Vue 组件,又可以使用 Vue 来开发自定义主题。
  - title: 高性能
    details: VuePress 会为每个页面预渲染生成静态的 HTML,同时,每个页面被加载的时候,将作为 SPA 运行。
footer: powdered by vuepress and me
---
# 额外的信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// config.js
module.exports = {
  themeConfig:{
    title:"Element3",
    description:"vuepress搭建的Element3文档",
    logo:"/element3.svg",
    navbar:[
      {
        link:"/",
        text:"首页"
      },{
        link:"/install",
        text:"安装"
      }
    ]
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// clientAppEnhance.js
import { defineClientAppEnhance } from '@vuepress/client'
import element3 from 'element3'
export default defineClientAppEnhance(({ app, router, siteData }) => {
  app.use(element3)
})
1
2
3
4
5
6
  • 解析 Markdown:实现一个 Markdown-loader,对 Markdown 语法进行扩展。Element3 中使用 Markdown-it (opens new window) 进行 Markdown 语法的解析和扩展。Markdown-it 导出一个函数,这个函数可以把 Markdown 语法解析为 HTML 标签。解析出 Markdown 中的 demo 语法,渲染其中的 Vue 组件,并且同时能把源码也显示在组件下方,这样就完成了扩展任务。

# 27|自定义渲染器:如何实现 Vue 的跨端渲染

  • 什么是渲染器:Vue 内部的组件是以虚拟 dom 形式存在的。相比 dom 标签相比,这种形式可以让整个 Vue 项目脱离浏览器的限制,更方便地实现 Vuejs 的跨端。渲染器是围绕虚拟 Dom 存在的。在浏览器中,我们把虚拟 Dom 渲染成真实的 Dom 对象,Vue 源码内部把一个框架里所有和平台相关的操作,抽离成了独立的方法。
{
  tag: 'div',
  props: {
    id: 'app'
  },
  chidren: [
    {
      tag: Container,
      props: {
        className: 'el-container'
      },
      chidren: [
        '哈喽小老弟!!!'
      ]
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 所以,我们只需要实现下面这些方法,就可以实现 Vue 3 在一个平台的渲染。【1】首先用 createElement 创建标签,还有用 createText 创建文本。创建之后就需要用 insert 新增元素,通过 remote 删除元素,通过 setText 更新文本和 patchProps 修改属性。然后再实现 parentNode、nextSibling 等方法实现节点的查找关系。完成这些工作,理论上就可以在一个平台内实现一个应用了。【2】在 Vue 3 中的 runtime-core (opens new window) 模块,就对外暴露了这些接口,runtime-core 内部基于这些函数实现了整个 Vue 内部的所有操作,然后在 runtime-dom 中传入以上所有方法。【3】如果一个框架想要实现实现跨端的功能,那么渲染器本身不能依赖任何平台下特有的接口。【4】了解了 Vue 中自定义渲染器的实现方式后,我们还可以基于 Vue 3 的 runtime-core 包封装其他平台的渲染器,让其他平台也能使用 Vue 内部的响应式和组件化等优秀的特性。
// Vue 代码提供浏览器端操作的函数
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
  }
  //...其他操作函数
}
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
  • 自定义渲染:自定义渲染器让 Vue 脱离了浏览器的限制,我们只需要实现平台内部的增删改查函数后,就可以直接对接 Vue 3。比方说,我们可以把 Vue 渲染到小程序平台,实现 Vue 3-minipp;也可以渲染到 Canvas,实现 vue 3-canvas,把虚拟 dom 渲染成 Canvas;甚至还可以尝试把 Vue 3 渲染到 three.js 中,在 3D 世界使用响应式开发。
  • 实现一个 Canvas 的渲染器:具体操作是,我们在项目的 src 目录下新建 renderer.js,通过这个文件实现一个简易的 Canvas 渲染逻辑。Canvas 平台中操作的方式相对简单,没有太多节点的概念,我们可以把整个 Canvas 维护成一个对象,每次操作的时候直接把 Canvas 重绘一下就可以了。【1】实现了 draw 函数,用 Canvas 的操作方法递归地把 Canvas 对象渲染到 Canvas 标签内部。【2】由于我们主体需要维护的逻辑就是对于对象的操作,所以创建和更新操作直接操作对象即可。新增 insert 需要维护 parent 和 child 元素。另外,插入的时候也需要调用 draw 函数,并且需要监听 onclick 事件。
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) {
  },
});
1
2
3
4
5
6
7
8
9
10
11
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)
  })
}


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
  },
});
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
  • 自定义渲染器的原理,就是把所有的增删改查操作暴露出去,使用的时候不需要知道内部的实现细节,我们只需要针对每个平台使用不同的 API 即可。自定义渲染器也代表着适配器设计模式的一个实践。
  • vue 操作的是 v-dom, 渲染器 ”生成“ v-dom,不同平台提供不同 options ”供“ 渲染器生成 v-dom。
  • three.js 和 vue 的结合:https://github.com/troisjs/trois (opens new window)

# 6. Vue 3 生态源码篇

# 28|响应式:万能的面试题,怎么手写响应式系统

  • 手写一个迷你的 Vue 框架,实现 Vue3 的主要渲染和更新逻辑,可以在 GitHub (opens new window) 上看到所有的核心代码。
  • 响应式:Vue3 的组件之间是通过响应式机制来通知的,响应式机制可以自动收集系统中数据的依赖,并且在修改数据之后自动执行更新,极大提高开发的效率。根据响应式组件通知效果可以知道,响应式机制的主要功能就是,可以把普通的 JavaScript 对象封装成为响应式对象,拦截数据的获取和修改操作,实现依赖数据的自动化更新。所以,一个最简单的响应式模型,我们可以通过 reactive 或者 ref 函数,把数据包裹成响应式对象,并且通过 effect 函数注册回调函数,然后在数据修改之后,响应式地通知 effect 去执行回调函数即可。

img

  • Vue 的响应式是可以独立在其他平台使用的。
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)
1
2
3
4
5
6
7
8
9
10
11

img

  • reactive:reactive 是通过 ES6 中的 Proxy 特性实现的属性拦截。
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 中对象的代理设置
const proxy = new Proxy(target, mutableHandlers)
1
2
3
4
5
6
7
8
9
10
11
12
13
  • mutableHandles:它要做的事就是配置 Proxy 的拦截函数,这里我们只拦截 get 和 set 操作。mutableHandles 就是配置了 set 和 get 的对象返回。【1】get 中直接返回读取的数据,这里的 Reflect.get 和 target[key]实现的结果是一致的;并且返回值是对象的话,还会嵌套执行 reactive,并且调用 track 函数收集依赖。【2】set 中调用 trigger 函数,执行 track 收集的依赖。
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,
};
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
  • track:先看 get 的关键部分,track 函数是怎么完成依赖收集的,依赖收集和执行的原理看下面的示意图:在 track 函数中,我们可以使用一个巨大的 tragetMap 去存储依赖关系。map 的 key 是我们要代理的 target 对象,值还是一个 depsMap,存储这每一个 key 依赖的函数,每一个 key 都可以依赖多个 effect。

img

// 依赖地图的格式
targetMap = {
 target:{
   key1: [回调函数1,回调函数2],
   key2: [回调函数3,回调函数4],
 },
 target1: {
   key3: [回调函数5]
 }  
}
1
2
3
4
5
6
7
8
9
10
  • trigger:有了上面 targetMap 的实现机制,trigger 函数实现的思路就是从 targetMap 中,根据 target 和 key 找到对应的依赖函数集合 deps,然后遍历 deps 执行依赖函数。
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)
}

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()
    }
  })
}
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
  • effect:effect 传递的函数,比如可以通过传递 lazy 和 scheduler 来控制函数执行的时机,默认是同步执行。
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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 另一个选择 ref 函数:ref 函数实现的相对简单很多,只是利用面向对象的 getter 和 setter 拦截了 value 属性的读写,这也是为什么我们需要操作 ref 对象的 value 属性的原因。值得一提的是,ref 也可以包裹复杂的数据结构,内部会直接调用 reactive 来实现,这也解决了大部分同学对 ref 和 reactive 使用时机的疑惑,现在你可以全部都用 ref 函数,ref 内部会帮你调用 reactive。
  • 相比于 Vue2 使用的 Object.defineProperty,Vue3 不需要提前递归收集依赖,初始化的速度更快; Vue2 收集依赖的过程中会产生很多的 Dep 对象,Vue3 可以节省这部分的内存开销; Vue2 无法监听数组、对象的动态添加、删除,需要通过 $set、$delete,增加学习成本; Vue2 无法监听 Set、Map,只能处理普通对象。

# 29|运行时:Vue 在浏览器里是怎么跑起来的

  • 前端框架需要处理的最核心的两个流程,就是首次渲染和数据更新后的渲染。
  • 首次渲染:想要启动一个 Vue 项目,只需要从 Vue 中引入 createApp,传入 App 组件,并且调用 createApp 返回的 App 实例的 mount 方法,就实现了项目的启动。createApp 就是项目的初始化渲染入口。首次执行 Vue 项目的时候,通过 patch 实现组件的渲染,patch 函数内部根据节点的不同类型,去分别执行 processElement、processComponent、processText 等方法去递归处理不同类型的节点,最终通过 setupComponent 执行组件的 setup 函数,setupRenderEffect 中使用响应式的 effect 函数监听数据的变化。整个 createApp 函数的执行逻辑如下图所示:

img

# 30|虚拟 DOM(上):如何通过虚拟 DOM 更新页面

  • Vue 中组件更新的方式使用了响应式 + 虚拟 DOM 的方式
  • Vue 虚拟 DOM 执行流程:在 Vue 中,我们使用虚拟 DOM 来描述页面的组件,比如 template 虽然格式和 HTML 很像,但是在 Vue 的内部会解析成 JavaScript 函数,这个函数就是用来返回虚拟 DOM。
  • DOM 的创建:使用 createVNode 函数创建项目的虚拟 DOM,Vue 内部的虚拟 DOM,也就是 vnode,就是一个对象,通过 type、props、children 等属性描述整个节点。Vue 源码中的实现首次渲染和更新的逻辑都写在一起,我们在递归的时候如果对一个标签实现更新和渲染,就可以用一个函数实现。effect 函数,也是 Vue 组件更新的入口函数。
  • patch 函数:数据更新之后就会执行 patch 函数,在 patch 函数中,会针对不同的组件类型执行不同的函数,组件我们会执行 processComponent,HTML 标签我们会执行 processElement。下图就是 patch 函数执行的逻辑图:

img

  • patchElement 函数:在函数 patchElement 中我们主要就做两件事,更新节点自己的属性和更新子元素。
1、节点自身属性的更新:
先看自身属性的更新,这里就能体现出 Vue 3 中性能优化的思想,通过 patchFlag 可以做到按需更新:
- 如果标记了 FULL_PROPS,就直接调用 patchProps。
- 如果标记了 CLASS,说明节点只有 class 属性是动态的,其他的 style 等属性都不需要进行判断和 DOM 操作。

2、子元素的更新:
子元素的更新是 patchChildren 函数负责的,这个函数也是虚拟 DOM 中难度最高的一个函数,搞懂它还需要一些算法知识。主要的实现思路:首先我们把子元素分成了文本、数组和空三个状态,新老子元素分别是这三种状态的一个,构成了不同的执行逻辑。这样 patchChildren 内部大致有五种情况需要处理:
- 如果新的子元素是空,老的子元素不为空,直接卸载 unmount 即可。
- 如果新的子元素不为空,老的子元素是空,直接创建加载即可。
- 如果新的子元素是文本,老的子元素如果是数组就需要全部 unmount,是文本的话就需要执行 hostSetElementText。
- 如果新的子元素是数组,比如是使用 v-for 渲染出来的列表,老的子元素如果是空或者文本,直接 unmout 后,渲染新的数组即可。
- 最复杂的情况就是新的子元素和老的子元素都是数组。最朴实无华的思路就是把老的子元素全部 unmount,新的子元素全部 mount。我们需要判断出可以复用的 DOM 元素,如果一个虚拟 DOM 没有改动或者属性变了,不需要完全销毁重建,而是更新一下属性,最大化减少 DOM 的操作,这个任务就会交给 patchKeyedChildren 函数去完成。它做的事情就是尽可能高效地把老的子元素更新成新的子元素,如何高效复用老的子元素中的 DOM 元素是 patchKeyedChildren 函数的难点。
1
2
3
4
5
6
7
8
9
10
11
12
  • patchChildren 函数:是各类虚拟 DOM 框架中最难实现的函数,我们需要实现一个高效的更新算法,能够使用尽可能少的更新次数,来实现从老的子元素到新的子元素的更新。

img

  • 总结:Vue 响应式驱动了组件之间的数据通信机制,数据更新之后,组件会执行 intance.update 方法,update 方法内部执行 patch 方法进行新老子树的 diff 计算。现在 Vue 执行逻辑全景图变成了下面的样子,新增了组件更新的逻辑:

img

# 31|虚拟 DOM(下):想看懂虚拟 DOM 算法,先刷个算法题

  • 如何使用位运算来实现 Vue 中的按需更新,让静态的节点可以越过虚拟 DOM 的计算逻辑,并且使用计算最长递增子序列的方式,来实现队伍的高效排序。
  • 位运算:每个节点 diff 的时候会做什么,主要就是通过虚拟 DOM 节点的 patchFlag 树形判断是否需要更新节点。方法就是使用 & 操作符来判断操作的类型,比如 patchFlag & PatchFlags.CLASS 来判断当前元素的 class 是否需要计算 diff;shapeFlag & ShapeFlags.ELEMENT 来判断当前虚拟 DOM 是 HTML 元素还是 Component 组件。
  • 最长递增子序列:Vue 3 借鉴了 infero 的算法逻辑,就像操场上需要按照个头从低到高站好一样。Vue 3 中用到的算法:贪心 + 二分。
  • 总结:Vue 执行全景图更新:

img

# 32|编译原理(上):手写一个迷你 Vue 3 Compiler 的入门原理

  • 在 Vue 中,组件都是以虚拟 DOM 的形式存在,加载完毕之后注册 effect 函数。这样组件内部的数据变化之后,用 Vue 的响应式机制做到了通知组件更新,内部则使用 patch 函数实现了虚拟 DOM 的更新,中间我们也学习了位运算、最长递增子序列等算法。还有一个疑问,那就是虚拟 DOM 是从哪来的?-- Compiler,目的就是实现 template 到 render 函数的转变。

img

  • 整体流程:首先,代码会被解析成一个对象,这个对象有点像虚拟 DOM 的概念,用来描述 template 的代码关系,这个对象就是抽象语法树(简称 AST)。然后通过 transform 模块对代码进行优化,比如识别 Vue 中的语法,静态标记、最后通过 generate 模块生成最终的 render 函数。其中 parse 函数负责生成抽象语法树 AST,transform 函数负责语义转换,generate 函数负责最终的代码生成。
function compiler (template) {
    const ast = parse(template);
    transform(ast);
    const code = generate(ast);
    return code;
}
1
2
3
4
5
6
  • tokenizer 的迷你实现:对 template 进行词法分析,把模板中的 <div>、@click、{{}} 等语法识别出来,转换成一个个的 token。可以理解为把 template 的语法进行了分类,这一步我们叫 tokenizer。
  • 生成抽象语法树:分别用 tagstart、props、tagend 和 text 标记,用它们标记了全部内容。然后下一步我们需要把这个数组按照标签的嵌套关系转换成树形结构。通过 type 和 children 描述整个 template 的结构。
  • 语义分析和优化:有了抽象语法树之后,我们还要进行语义的分析和优化,也就是说,我们要在这个阶段理解语句要做的事。使用 traverse 函数递归整个 AST,去优化 AST 的结构,并且在这一步实现简单的静态标记。通过在 compiler 阶段的标记,让 template 产出的虚拟 DOM 有了更精确的状态,可以越过大部分的虚拟 DOM 的 diff 计算,极大提高 Vue 的运行时效率。
  • 目标代码:最后,基于优化之后的 AST 生成目标代码,也就是 generate 函数要做的事:遍历整个 AST,拼接成最后要执行的函数字符串。
  • 整个 Vue compiler 工作的主体流程:

img

# 33 | 编译原理(中):Vue Compiler 模块全解析

  • Vue compiler 入口分析:Vue 3 内部有 4 个和 compiler 相关的包。compiler-dom 和 compiler-core 负责实现浏览器端的编译,这两个包是我们需要深入研究的,compiler-ssr 负责服务器端渲染,我们后面讲 ssr 的时候再研究,compiler-sfc 是编译.vue 单文件组件的,有兴趣的同学可以自行探索。GitHub 代码 (opens new window)
  • 总结:Vue 中的 compiler 执行全流程:

img

  • Vue 执行全景图更新:

img

# 34 | 编译原理(下):编译原理给我们带来了什么

  • 编译原理作为计算机世界的一个重要的学科,除了探究原理和源码之外,我们工作中也有很多地方可以用到。从宏观视角来看,编译原理实现的功能就是代码之间的转换。
  • Vite 插件:auto-imput (opens new window) 插件的实现。
  • Babel:Babel 官网 (opens new window)进行深入学习。Babel 提供了完整的编译代码的功能后函数,包括 AST 的解析、语义分析、代码生成等,我们可以通过下面的函数去实现自己的插件。
@babel/parser 提供了代码解析的能力,能够把 js 代码解析成 AST,代码就从字符串变成了树形结构,方便我们进行操作;
@babel/traverse 提供了遍历 AST 的能力,我们可以从 travser 中获取每一个节点的信息后去修改它;
@babe/types 提供了类型判断的函数,我们可以很方便的判断每个节点的类型;
@babel/core 提供了代码转化的能力。

有了 Babel 提供的能力之后,我们可以只关注于代码中需要转换的逻辑,比如我们可以使用 Babel 实现国际化,把每种语言在编译的时候自动替换语言,打包成独立的项目;也可以实现页面的自动化监控,在一些操作函数里面加入监控的代码逻辑。
1
2
3
4
5
6

# 35|Vite 原理:写一个迷你的 Vite

  • 现在工程化的痛点:现在前端开发项目的时候,工程化工具已经成为了标准配置,webpack 是现在使用率最高的工程化框架,它可以很好地帮助我们完成从代码调试到打包的全过程,但是随着项目规模的爆炸式增长,webpack 也带来了一些痛点问题。
1、最早 webpack 可以帮助我们在 JavaScript 文件中使用 require 导入其他 JavaScript、CSS、image 等文件,并且提供了 dev-server 启动测试服务器,极大地提高了我们开发项目的效率。
2、webpack 的核心原理就是通过分析 JavaScript 中的 require 语句,分析出当前 JavaScript 文件所有的依赖文件,然后递归分析之后,就得到了整个项目的一个依赖图。对图中不同格式的文件执行不同的 loader,比如会把 CSS 文件解析成加载 CSS 标签的 JavaScript 代码,最后基于这个依赖图获取所有的文件。
3、进行打包处理之后,放在内存中提供给浏览器使用,然后 dev-server 会启动一个测试服务器打开页面,并且在代码文件修改之后可以通过 WebSocket 通知前端自动更新页面,也就是熟悉的热更新功能。
4、由于 webpack 在项目调试之前,要把所有文件的依赖关系收集完,打包处理后才能启动测试,很多大项目我们执行调试命令后需要等 1 分钟以上才能开始调试。
5、热更新也需要等几秒钟才能生效,极大地影响了我们开发的效率。所以针对 webpack 这种打包 bundle 的思路,社区就诞生了 bundless 的框架,Vite 就是其中的佼佼者。

前端的项目之所以需要 webpack 打包,是因为浏览器里的 JavaScript 没有很好的方式去引入其他文件。webpack 提供的打包功能可以帮助我们更好地组织开发代码,但是现在大部分浏览器都支持了 ES6 的 module 功能,我们在浏览器内使用 type="module"标记一个 script 后,在 src/main.js 中就可以直接使用 import 语法去引入一个新的 JavaScript 文件。这样我们其实可以不依赖 webpack 的打包功能,利用浏览器的 module 功能就可以重新组织我们的代码。
<script type="module" src="/src/main.js"></script>
1
2
3
4
5
6
7
8
  • Vite 可以直接启动服务,通过浏览器运行时的请求拦截,实现首页文件的按需加载,这样开发服务器启动的时间就和整个项目的复杂度解耦。Vite 的主要目的就是提供一个调试服务器

# 36|数据流原理:Vuex & Pinia 源码剖析

  • Vuex5 提案:由于 Vuex 有模块化 namespace 的功能,所以模块 user 中的 mutation add 方法,我们需要使用 commit('user/add') 来触发。这样虽然可以让 Vuex 支持更复杂的项目,但是这种字符串类型的拼接功能,在 TypeScript4 之前的类型推导中就很难实现。然后就有了 Vuex5 相关提案的讨论 (opens new window)。可以在 Github 的提案 RFCs 中看到 Vuex5 的设计文稿 (opens new window),而 Pinia 正是基于 Vuex5 设计的框架
Vuex5 的提案相比 Vuex4 有很大的改进,解决了一些 Vuex4 中的缺点:
- Vuex5 能够同时支持 Composition API 和 Option API,并且去掉了 namespace 模式;
- 使用组合 store 的方式更好地支持了 TypeScript 的类型推导;
- 去掉了容易混淆的 Mutation 和 Action 概念,只保留了 Action;
- 支持自动的代码分割。
1
2
3
4
5
  • Pinia:安装 Pinia 的最新版本 -- npm install pinia@next

# 37|前端路由原理:vue-router源码剖析

  • vue-router 入口分析:vue-router 提供了 createRouter 方法 (opens new window)来创建路由配置,我们传入每个路由地址对应的组件后,使用 app.use 在 Vue 中加载 vue-router 插件,并且给 Vue 注册了两个内置组件,router-view 负责渲染当前路由匹配的组件,router-link 负责页面的跳转。
  • Vue 中 app.use 实际上执行的就是 router 对象内部的 install 方法。
  • createWebHistory 返回的是 HTML5 的 history 模式路由对象,createWebHashHistory 是 Hash 模式的路由对象。createWebHashHistory 和 createWebHistory 的实现,内部都是通过 useHistoryListeners 实现路由的监听,通过 useHistoryStateNavigation 实现路由的切换。useHistoryStateNavigation 会返回 push 或者 replace 方法来更新路由,这两个函数你可以在 GitHub (opens new window) 上自行学习。
  • navigate 函数负责执行路由守卫的功能

# 38|服务端渲染原理:Vue 3 中的 SSR 是如何实现的

  • SSR 是什么:SSR(Server Side Rendering),也就是服务端渲染。要想搞清楚 SSR 是什么?需要先理解这个方案是为解决什么问题而产生的。在现在 MVVM 盛行的时代,无论是 Vue 还是 React 的全家桶,都有路由框架的身影。这种架构下,所有的路由和页面都是在客户端进行解析和渲染的,我们称之为 Client Side Rendering,简写为 CSR,也就是客户端渲染。交互体验确实提升了,但同时也带来了两个小问题:项目中执行 npm run build命令后,生成 dist 文件夹,其中有 index.html 文件,这就是项目部署上线之后的入口文件,body 内部就是一个空的 div 标签,用户访问这个页面后,页面的首屏需要等待 JavaScript 加载和执行完毕才能看到,这样白屏时间肯定比 body 内部写页面标签的要长一些;其次,搜索引擎的爬虫抓取到你的页面数据后,发现 body 是空的,也会认为你这个页面是空的,这对于 SEO 是很不利的。即使现在基于 Google 的搜索引擎爬虫已经能够支持 JavaScript 的执行,但是爬虫不会等待页面的网络数据请求,何况国内主要的搜索引擎还是百度。
  • 如果你的项目对白屏时间和搜索引擎有要求,我们就需要在用户访问页面的时候,能够把首屏渲染的 HTML 内容写入到 body 内部,也就是说我们需要在服务器端实现组件的渲染,这就是 SSR 的用武之地。
  • 怎么做 SSR:Vue 提供了 @vue/server-renderer 这个专门做服务端解析的库。Vue 中的最成熟的 SSR 框架就是 nuxt
  • 同构应用和其他渲染方式:既需要提供服务器渲染的首屏内容,又需要 CSR 带来的优秀交互体验,这个时候我们就需要使用同构的方式来构建 Vue 的应用。在服务端实现用户首次访问页面的时候通过服务器端入口进入,显示服务器渲染的结果,然后用户在后续的操作中由客户端接管,通过 vue-router 来提高页面跳转的交互体验,这就是同构应用的概念。

img

  • SSR + 同构的问题:SSR 和同构带来了很好的首屏速度和 SEO 友好度,但是也让我们的项目多了一个 Node 服务器模块。首先,部署的难度会提高,之前的静态资源直接上传到服务器的 Nginx 目录下,做好版本管理即可,现在还需要在服务器上部署一个 Node 环境;其次,SSR 和同构的架构,实际上,是把客户端渲染组件的计算逻辑移到了服务器端执行,在并发量大的场景中,会加大服务器的负载。针对 SSR 架构的问题,我们也可以使用静态网站生成(Static Site Generation,SSG)的方式来解决,针对页面中变动频率不高的页面,直接渲染成静态页面来展示
  • 每个技术架构的出现都是为了解决一些特定的问题,但是它们的出现也必然会带来新的问题

# 7. 用户故事|有了手感,才能真正把知识学透

# 8. 结束语

# Vue 3生态源码到底给我们带来了什么

  • 任何框架,我们都可以通过深入底层,在理解框架底层用到的计算机世界最佳实践的基础上,去构建自己的知识体系。
  • 如果说编程是一个武侠世界的话,框架和最佳实践是武功和招式,算法和数据结构、设计模式和网络协议等底层知识就是内力,如果我们只沉迷于武功和招式,注定很难成为高手,真正的高手需要不断地修炼内力和不断地实战。
  • 前端知识体系的全景图:

img

Last Updated: 2023/04/22, 23:39:26
彩虹
周杰伦