Vue
现在已经迭代到 3+ 版本,阅读官方文档的过程中发现作者的一些理念和思路很合我口味,很多概念与方案都是基于解决实际问题提出并实现的,且在权衡利弊后勇于打破常规,比如如何看待关注点分离?。可见,Vue 之所以流行,不单单因为作者是国人,更应该是由于 Vue 作为新一代的解决方案提升了前端编程的体验与效率。
本文介绍几个核心概念。
选项式 vs 组合式#
Vue 提供两种代码的书写风格——选项式
和组合式
。可简单理解为:前者面向对象编程;后者函数式编程。
选项式
:如果你有微信小程序的开发经验,就知道选项式是什么样子,其实就是将组件的逻辑封装到一个对象中,这个对象预定义多个字段和方法(如 data、methods 和 mounted),开发人员需要在适当的地方组织代码。对于有面向对象语言背景的用户来说,这通常与基于类的心智模型更为一致,同时,响应性相关的细节由框架本身处理,对初学者而言更为友好。
组合式
:传统的自由无约束的编码风格,顶层就是各个成员变量和 functions,及一些钩子函数。似乎回到了 js 最初的模样,在对象、类、prototype 这些概念普及以前,大多数代码就是一坨变量加一坨 function,然后 onclick 调用。但是 Vue 的组合式风格依托其底层的依赖注入系统,及完善的响应式 API,使得情况不像看上去那么简单,而是呈现出一种螺旋向上的味道,耐人寻味。
官方文档对这两种风格有一些比较,个人比较倾向于组合式,所以本文 Vue 代码都是组合式的。
响应式基础#
所谓响应式,就是视图会随着 JS 对象状态的改变而自动改变(也就是MVVM
模式),有这种效果的对象就叫作响应式对象
(其实就是 JavaScript Proxy)。在组合式 API 中,我们需要显式声明响应式对象,有两种方式——reactive()
和ref()
。
reactive()#
该 API 返回的对象,是传入对象的代理对象,其所有属性及深层的子属性,都是响应式的。响应式对象的内嵌对象也是响应式对象,就算给它赋值普通对象,如:
const proxy = reactive({})
const raw = {}
proxy.nested = raw // proxy.nested 自动就是响应式对象
console.log(proxy.nested === raw) // false,代理对象和原始对象不是全等的
reactive() 的注意事项和原理#
reactive() 有一定的局限:它仅对对象类型有效(对象、数组和 Map、Set 这样的集合类型),而对 string、number 和 boolean 这样的基础类型无效;需要尽量避免对一个响应式变量重新赋值,除非我们有办法将新对象和视图重新建立连接;且当我们将响应式对象的属性(基础类型)赋值或解构至本地变量时,或是将该属性传入一个函数时,我们会失去响应性,如:
let state = reactive({ count: 0 })
// 官方文档这里的表述不是很准确,下面是我的表述:
// 表面上看是重新赋值的 state 状态变化没有引起视图的变化,似乎响应连接丢失了,
// 其实原对象上的响应式连接还在,但是原对象在此处已无法继续访问,所以响应式连接在不在不重要了,
// 重建响应就需要建立视图和新对象的连接。
state = reactive({ count: 1 })
let n = state.count // 基础类型赋值,失去响应性连接
n++ // 不影响 state
let { count } = state
count++ // 同上
// state.count 值传递给基础类型形参,也失去响应性连接了
callSomeFunction(state.count)
对于这些情况,有后端经验的同学如果将 reactive() 得到的响应式对象类比成引用类型对象就很好理解,这就是引用类型和值类型在使用过程中需要注意的一些点——变量赋值,如果是引用类型的话,那么指向的是对象的内存地址(新旧对象的内存地址自然是不一样的);如果是值类型,虽然代码看上去都是指向 state.count,其实是拷贝源值到自己的内存块,拷贝完了之后就和源没有关系了。
对于引用类型的“问题”,只要注意点就好了,但是在响应式的场景下,值类型的“拷贝”特性确实让人有点闹心。有没有类似于后端的装箱
操作呢?
ref()#
该 API 返回的也是响应式对象,它用于将值类型(基础类型)封装成引用类型(对象类型)。也就是说,我们将上一小节代码改造一下,就能保持基础类型数据在各个变量间传递后的响应性,如下:
const state = reactive({
count: ref(0)
})
let n = state.count // 现在 state.count 是引用类型,所以它和 n 指向的是同一个对象
n.value++ // 需要用 value 操作值
// 注意 value 也是响应式的,也就是传递给它的普通对象会自动转为响应式对象,和 reactive() 那边的情况一样
// 同时要注意直接替换掉整个对象会导致出现响应连接丢失的问题(上面提到过)
n.value = { name: 'Tony' }
简言之,我们可以将 ref 对象就看作引用类型对象,就能很快理解它的特性了。唯一要注意的是访问和操作它的值需要 .value,但是在某些时候框架也会帮我们自动解包(不需要使用 .value),可以参看官方文档。
组合式函数#
是利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。说白了,就是业务逻辑封装,它表现形式不是对象,但是有状态,状态作为响应式对象对外暴露使用(如果有的话)。推测是为了和对象形式区分,才称之为组合式函数(就像选项式风格和组合式风格的区别)。
Vue 2 的用户可能会对mixins
选项比较熟悉。它也让我们能够把组件逻辑提取到可复用的单元里,然而 mixins 没有自我范围的约束,就像页面里使用引入的 js 文件,容易和其它 js 文件产生命名冲突,对象来源也不清晰,编码时不注意的话也容易产生模块和模块之间隐性的依赖。
其它几个 API#
nextTick()
:DOM 更新是有间隔时间的,在间隔时间内每个组件发生的所有状态改变汇总后一次更新。可以给该函数传递一个回调,在最近的一次 DOM 更新后执行。类似于 HTML5 新增的 window.requestAnimationFrame()
。
watchEffect(callback)
:callback 中涉及到的响应式对象状态的变更会触发 callback 执行,如下:
const count = ref(0)
watchEffect(() => console.log(count.value)) // 马上执行一次,-> 输出 0
count.value++ // -> 输出 1
watch()
:同 watchEffect() 不同在于,watch() 需要显式地给它传递要监听的响应式对象。
构建工具 Vite#
伴随 Vue 3 一起出来的还有新的构建工具Vite
。下面会简单介绍 Vite 涉及到的关键技术和工具,以及同其它构建工具的比较。
ESM#
不同于之前的CJS,AMD,CMD等,ESM
是 ECMA 标准化模块系统,也就是说我们可以直接在浏览器中去执行 import,动态引入模块。作为 ECMA 标准,目前 ESM 已经得到 92% 以上浏览器的支持。
ESM 的执行可以分为三个步骤:
- 构建: 确定模板依赖关系,下载并将所有的文件解析为模块记录;
- 实例化: 将模块记录转换为一个模块实例,为所有的模块分配内存空间,依照导出、导入语句把模块指向对应的内存地址;
- 运行:运行代码,填充内存空间。
ESM 使用引用模式指向模块,也就是说如果引用的模块已经存在,那么直接返回模块的内存地址。而 CJS 采用的是拷贝模式,即所有导出模块都是独立的实例。可见前者比后者的效率要高。
基于 ESM,还能做到按需加载模块(碰到 import 再去请求加载文件)。但是我们一般只在开发环境下使用这个特性(不需要每次改动都导致整个 bundle 模块全量打包编译),原因如下段所述。
尽管原生ESM
现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码预先进行Tree Shaking
(移除那些没被使用的代码)、懒加载和 chunk 分割(以获得更好的缓存)。
Rollup#
Rollup
就是基于 ESM 模块的打包工具,比Webpack
和Browserify
使用的 CommonJS 模块机制更高效。Rollup 能针对源码进行 Tree Shaking,以及 Scope Hoisting 以减小输出文件大小提升运行性能。
Esbuild#
Esbuild
提供了与Webpack
、Rollup
等工具相似的资源打包能力,但其打包速度却是其他工具的 10~100 倍,原因有二:
- 大多数前端打包工具都是基于 JavaScript 实现的,边运行边解释。而 Esbuild 则选择使用 Go 语言编写,编译为机器语言,在启动的时候直接执行,性能更高;
- JavaScript 本质上是一门单线程语言,直到引入
Web Worker
后才有可能在浏览器、Node 中实现多线程操作,目前大部分打包工具未必有使用 Web Worker 提供的多线程能力。而 GO 则没这方面的“缺陷”,更不用说还有成熟的协程特性。
但是,虽然Esbuild
快得惊人,并且已经是一个在构建库方面比较出色的工具,但一些重要功能仍然还在持续开发中——特别是代码分割和 CSS 处理方面(ESM 小节提到的加载性能)。就目前来说,Rollup 在应用打包方面更加成熟和灵活。所以,我们一般在开发时,使用Esbuild
进行构建,而在生产环境,则是使用 Rollup 进行打包。
HMR 热更新#
Webpack ——重新编译,请求变更后模块的代码,客户端重新加载。
Vite ——请求变更的模块,再重新加载。
Vite 通过chokidar
监听文件系统的变更,使相关模块与其临近的 HMR 边界连接失效,只对发生变更的模块重新加载,这样 HMR 更新速度就不会因为应用体积的增加而变慢而 Webpack 还要经历一次打包构建。所以 HMR 场景下,Vite 表现也要好于 Webpack。
关于构建,需要注意的是,如果使用传统 方式引入 Vue 的话,那么就不会涉及到构建步骤,但同时将无法使用单文件组件 (SFC) 语法。传统方式一般用在对现有项目进行局部 Vue 改造的场景下。