父子组件通信是指,B 组件引入到 A 组件里渲染,此时 A 是 B 的父级;B 组件的一些数据需要从 A 组件拿,B 组件有时也要告知 A 组件一些数据变化情况。
另外官方文档推荐对 camelCase 风格(小驼峰)命名的 props ,在绑定时使用和 HTML attribute 一样的 kebab-case 风格(短横线),例如使用 user-name
代替 userName
传递,详见官网的 传递 prop 的细节 一节。
这种情况虽然无法从 props 里拿到对应的数据,但也不意味着不能传递任何未定义的属性数据,在父组件,除了可以给子组件绑定 props ,还可以根据实际需要去绑定一些特殊的属性。
刚接触 Vue 的开发者可能容易混淆这两者,确实是非常接近,都是由父组件传递,由子组件接收,支持传递的数据类型也一样,但为什么一部分是在 props 获取,一部分在 attrs 获取呢?笔者给出一个比较容易记忆的方式,不一定特别准确,但相信可以帮助开发者加深两者的区别理解。
根据它们的缩写,其实是可以知道 Prop 是指 Property ,而 Attr 是指 Attribute ,虽然都是 “属性” ,但 Property 更接近于事物本身的属性,因此需要在组件里声明,而 Attribute 更偏向于赋予的属性,因此用于指代父组件传递的其他未被声明为 Property 的属性。
如果父组件 Father.vue 需要获取子组件 Child.vue 的数据更新情况,可以由子组件通过 emits 进行通知,下面这个更新用户年龄的例子可以学习如何给子组件绑定 emit 事件。
看到这里应该能明白了,一个 v-model 其实就是一个 prop ,它支持的数据类型和 prop 是一样的,所以子组件在接收数据的时候,完全按照 props 去定义就可以了。
可以看到 Grandson.vue 并非直接挂载在 Grandfather.vue 下面,他们之间还隔着至少一个 Son.vue (在实际业务中可能存在更多层级),如果使用 props ,只能一级组件一级组件传递下去,就太繁琐了。
`,158),C=a('另外还有一个特殊情况需要注意,当 Grandson.vue 的父级、爷级组件都 provide 了相同名字的数据下来,那么在 inject 的时候,会优先选择离它更近的组件的数据。
EventBus 通常被称之为 “全局事件总线” ,是用在全局范围内通信的一个常用方案,在 Vue 2 时期该方案非常流行,其特点就是 “简单” 、 “灵活” 、 “轻量级” 。
在 Vue 2 ,使用 EventBus 无需导入第三方插件,可以在项目下的 libs 文件夹里,创建一个名为 eventBus.ts 的文件,导出一个新的 Vue 实例即可。
然后在 src/libs 文件夹下,创建一个名为 eventBus.ts 的文件,文件内容和 Vue 2 的写法其实是一样的,只不过是把 Vue 实例换成了 mitt 实例。
意思是 Pinia 已经成为 Vue 生态最新的官方状态管理库,不仅适用于 Vue 3 ,也支持 Vue 2 ,而 Vuex 将进入维护状态,不再增加新功能, Vue 官方强烈建议在新项目中使用 Pinia 。
一般情况下一个 index.ts 足矣,它是 Vuex 的入口文件,如果的项目比较庞大,可以在 store 目录下创建一个命名为 modules 的文件夹,使用 Vuex Modules 的方式导入到 index.ts 里去注册。
由于现在在 Vue 3 里已经更推荐使用 Pinia , Vuex 已处于维护状态,因此关于 Vuex 的使用将不展开更多的介绍,有需要的开发者可以查看 Vuex 官网的 使用指南 了解更多。
+ * homepage: https://vue3.chengpeiquan.com
+ */
+import{_ as e,v as t,b as c,t as s,O as r,R as a,M as n}from"./chunks/framework.0d8bea05.js";const m=JSON.parse('{"title":"单组件的编写","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"component.md","filePath":"component.md"}'),y={name:"component.md"},F=a('单组件的编写 项目搭好了,第一个需要了解的是 Vue 组件的变化,由于这部分篇幅会非常大,所以会分成很多个小节,一部分一部分按照开发顺序来逐步了解。
因为 Vue 3 对 TypeScript 的支持真的是太完善了,并且 TypeScript 的发展趋势和市场需求度越来越高,所以接下来都将直接使用 TypeScript 进行编程。
TIP
对 TypeScript 不太熟悉的开发者,建议先阅读 快速上手 TypeScript 一章,有了一定的语言基础之后,再一边写代码一边加深印象。
',4),D=a(`全新的 setup 函数 ~new 在开始编写 Vue 组件之前,需要了解两个全新的前置知识点:
全新的 setup
函数,关系到组件的生命周期和渲染等问题 写 TypeScript 组件离不开的 defineComponent
API setup 的含义 Vue 3 的 Composition API 系列里,推出了一个全新的 setup
函数,它是一个组件选项,在创建组件之前执行,一旦 props 被解析,并作为组合式 API 的入口点。
TIP
说的通俗一点,就是在使用 Vue 3 生命周期的情况下,整个组件相关的业务代码,都可以放在 setup
里执行。
因为在 setup
之后,其他的生命周期才会被启用(点击了解:组件的生命周期 )。
基本语法:
ts // 这是一个基于 TypeScript 的 Vue 组件
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup ( props , context ) {
+ // 在这里声明数据,或者编写函数并在这里执行它
+
+ return {
+ // 需要给 \`<template />\` 用的数据或函数,在这里 \`return\` 出去
+ }
+ },
+} )
可以发现在这段代码里还导入了一个 defineComponent
API ,也是 Vue 3 带来的新功能,下文的 defineComponent 的作用 将介绍其用法。
在使用 setup
的情况下,请牢记一点:不能再用 this
来获取 Vue 实例,也就是无法和 Vue 2 一样,通过 this.foo
、 this.bar()
这样来获取实例上的数据,或者执行实例上的方法。
关于全新的 Vue 3 组件编写,笔者将在下文一步步说明。
setup 的参数使用 setup
函数包含了两个入参:
参数 类型 含义 是否必传 props object 由父组件传递下来的数据 否 context object 组件的执行上下文 否
第一个参数 props
:
它是响应式的,当父组件传入新的数据时,它将被更新。
TIP
请不要解构它,这样会让数据失去响应性,一旦父组件发生数据变化,解构后的变量将无法同步更新为最新的值。
可以使用 Vue 3 全新的响应式 API toRef / toRefs 进行响应式数据转换,下文将会介绍全新的响应式 API 的用法。
第二个参数 context
:
context
只是一个普通的对象,它暴露三个组件的 Property :
属性 类型 作用 attrs 非响应式对象 未在 Props 里定义的属性都将变成 Attrs slots 非响应式对象 组件插槽,用于接收父组件传递进来的模板内容 emit 方法 触发父组件绑定下来的事件
因为 context
只是一个普通对象,所以可以直接使用 ES6 解构。
平时使用可以通过直接传入 { emit }
,即可用 emit('xxx')
来代替使用 context.emit('xxx')
,另外两个功能也是如此。
但是 attrs
和 slots
请保持 attrs.xxx
、slots.xxx
的方式来使用其数据,不要进行解构,虽然这两个属性不是响应式对象,但对应的数据会随组件本身的更新而更新。
两个参数的具体使用,可查阅 组件之间的通信 一章详细了解。
defineComponent 的作用 defineComponent
是 Vue 3 推出的一个全新 API ,可用于对 TypeScript 代码的类型推导,帮助开发者简化掉很多编码过程中的类型声明。
比如,原本需要这样才可以使用 setup
函数:
ts import { Slots } from ' vue '
+
+// 声明 \`props\` 和 \`return\` 的数据类型
+interface Data {
+ [ key : string ] : unknown
+}
+
+// 声明 \`context\` 的类型
+interface SetupContext {
+ attrs : Data
+ slots : Slots
+ emit : ( event : string , ... args : unknown [] ) => void
+}
+
+// 使用的时候入参要加上声明, \`return\` 也要加上声明
+export default {
+ setup ( props : Data , context : SetupContext ): Data {
+ // ...
+
+ return {
+ // ...
+ }
+ },
+}
每个组件都这样进行类型声明,会非常繁琐,如果使用了 defineComponent
,就可以省略这些类型声明:
ts import { defineComponent } from ' vue '
+
+// 使用 \`defineComponent\` 包裹组件的内部逻辑
+export default defineComponent ( {
+ setup ( props , context ) {
+ // ...
+
+ return {
+ // ...
+ }
+ },
+} )
代码量瞬间大幅度减少,只要是 Vue 本身的 API , defineComponent
都可以自动推导其类型,这样开发者在编写组件的过程中,只需要维护自己定义的数据类型就可以了,可专注于业务。
组件的生命周期 ~new 在了解了 Vue 3 组件的两个前置知识点后,不着急写组件,还需要先了解组件的生命周期,这个知识点非常重要,只有理解并记住组件的生命周期,才能够灵活的把控好每一处代码的执行,使程序的运行结果可以达到预期。
升级变化 从 Vue 2 升级到 Vue 3 ,在保留对 Vue 2 的生命周期支持的同时,Vue 3 也带来了一定的调整。
Vue 2 的生命周期写法名称是 Options API (选项式 API ), Vue 3 新的生命周期写法名称是 Composition API (组合式 API )。
Vue 3 组件默认支持 Options API ,而 Vue 2 可以通过 @vue/composition-api 插件获得 Composition API 的功能支持(其中 Vue 2.7 版本内置了该插件, 2.6 及以下的版本需要单独安装)。
为了减少理解成本,笔者将从读者的使用习惯上,使用 “ Vue 2 的生命周期” 代指 Options API 写法,用 “ Vue 3 的生命周期” 代指 Composition API 写法。
关于 Vue 生命周期的变化,可以从下表直观地了解:
Vue 2 生命周期 Vue 3 生命周期 执行时间说明 beforeCreate setup 组件创建前执行 created setup 组件创建后执行 beforeMount onBeforeMount 组件挂载到节点上之前执行 mounted onMounted 组件挂载完成后执行 beforeUpdate onBeforeUpdate 组件更新之前执行 updated onUpdated 组件更新完成之后执行 beforeDestroy onBeforeUnmount 组件卸载之前执行 destroyed onUnmounted 组件卸载完成后执行 errorCaptured onErrorCaptured 当捕获一个来自子孙组件的异常时激活钩子函数
可以看到 Vue 2 生命周期里的 beforeCreate
和 created
,在 Vue 3 里已被 setup
替代。
熟悉 Vue 2 的开发者应该都知道 Vue 有一个全局组件 <KeepAlive />
,用于在多个组件间动态切换时缓存被移除的组件实例,当组件被包含在 <KeepAlive />
组件里时,会多出两个生命周期钩子函数:
Vue 2 生命周期 Vue 3 生命周期 执行时间说明 activated onActivated 被激活时执行 deactivated onDeactivated 切换组件后,原组件消失前执行
TIP
虽然 Vue 3 依然支持 Vue 2 的生命周期,但是不建议混搭使用,前期可以继续使用 Vue 2 的生命周期作为过度阶段慢慢适应,但还是建议尽快熟悉并完全使用 Vue 3 的生命周期编写组件。
使用 3.x 的生命周期 在 Vue 3 的 Composition API 写法里,每个生命周期函数都要先导入才可以使用 ,并且所有生命周期函数统一放在 setup
里运行。
如果需要达到 Vue 2 的 beforeCreate
和 created
生命周期的执行时机,直接在 setup
里执行函数即可。
以下是几个生命周期的执行顺序对比:
ts import { defineComponent , onBeforeMount , onMounted } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ console . log ( 1 )
+
+ onBeforeMount ( () => {
+ console . log ( 2 )
+ } )
+
+ onMounted ( () => {
+ console . log ( 3 )
+ } )
+
+ console . log ( 4 )
+ },
+} )
最终将按照生命周期的顺序输出:
组件的基本写法 如果想在 Vue 2 里使用 TypeScript 编写组件,需要通过 Options API 的 Vue.extend 语法,或者是另外一种风格 Class Component 的语法声明组件,其中为了更好的进行类型推导, Class Component 语法更受开发者欢迎。
但是 Class Component 语法和默认的组件语法相差较大,带来了一定的学习成本,对于平时编写 JavaScript 代码很少使用 Class 的开发者,适应时间应该也会比较长。
因此 Vue 3 在保留对 Class Component 支持的同时,推出了全新的 Function-based Component ,更贴合 JavaScript 的函数式编程风格,这也是接下来要讲解并贯穿全文使用的 Composition API 新写法。
Composition API 虽然也是一个步伐迈得比较大的改动,但其组件结构并没有特别大的变化,区别比较大的地方在于组件生命周期和响应式 API 的使用,只要掌握了这些核心功能,上手 Vue 3 非常容易!
看到这里可能有开发者心里在想:
“这几种组件写法,加上视图部分又有 Template 和 TSX 的写法之分,生命周期方面 Vue 3 对 Vue 2 的写法又保持了兼容,在 Vue 里写 TypeScript 的组合方式一只手数不过来,在入门时选择合适的编程风格就遇到了困难,可怎么办?”
不用担心!笔者将九种常见的组合方式以表格的形式进行对比, Vue 3 组件最好的写法一目了然!
回顾 Vue 2 在 Vue 2 ,常用以下三种写法声明 TypeScript 组件:
适用版本 基本写法 视图写法 Vue 2 Vue.extend Template Vue 2 Class Component Template Vue 2 Class Component TSX
其中最接近 Options API 的写法是使用 Vue.extend API 声明组件:
ts // 这是一段摘选自 Vue 2 官网的代码演示
+import Vue from ' vue '
+
+// 推荐使用 Vue.extend 声明组件
+const Component = Vue . extend ( {
+ // 类型推断已启用
+} )
+
+// 不推荐这种方式声明
+const Component = {
+ // 这里不会有类型推断,
+ // 因为 TypeScript 不能确认这是 Vue 组件的选项
+}
而为了更好地获得 TypeScript 类型推导支持,通常使用 Class Component 的写法,这是 Vue 官方推出的一个装饰器插件(需要单独安装):
ts // 这是一段摘选自 Vue 2 官网的代码演示
+import Vue from ' vue '
+import Component from ' vue-class-component '
+
+// @Component 修饰符注明了此类为一个 Vue 组件
+@ Component ( {
+ // 所有的组件选项都可以放在这里
+ template : ' <button @click="onClick">Click!</button> ' ,
+} )
+
+// 使用 Class 声明一个组件
+export default class MyComponent extends Vue {
+ // 初始数据可以直接声明为实例的 property
+ message : string = ' Hello! '
+
+ // 组件方法也可以直接声明为实例的方法
+ onClick (): void {
+ window . alert ( this. message )
+ }
+}
可在 Vue 2 官网的 TypeScript 支持 一章了解更多配置说明。
了解 Vue 3 ~new Vue 3 从设计初期就考虑了 TypeScript 的支持,其中 defineComponent
这个 API 就是为了解决 Vue 2 对 TypeScript 类型推导不完善等问题而推出的。
在 Vue 3 ,至少有以下六种写法可以声明 TypeScript 组件:
适用版本 基本写法 视图写法 生命周期版本 官方是否推荐 Vue 3 Class Component Template Vue 2 × Vue 3 defineComponent Template Vue 2 × Vue 3 defineComponent Template Vue 3 √ Vue 3 Class Component TSX Vue 2 × Vue 3 defineComponent TSX Vue 2 × Vue 3 defineComponent TSX Vue 3 √
其中 defineComponent + Composition API + Template 的组合是 Vue 官方最为推荐的组件声明方式,本书接下来的内容都会以这种写法作为示范案例,也推荐开发者在学习的过程中,使用该组合进行入门。
下面看看如何使用 Composition API 编写一个最简单的 Hello World 组件:
vue <!-- Template 代码和 Vue 2 一样 -->
+< template >
+ < p class = " msg " > {{ msg }} </ p >
+</ template >
+
+<!-- Script 代码需要使用 Vue 3 的新写法-->
+< script lang = " ts " >
+// Vue 3 的 API 需要导入才能使用
+import { defineComponent } from ' vue '
+
+// 使用 \`defineComponent\` 包裹组件代码
+// 即可获得完善的 TypeScript 类型推导支持
+export default defineComponent ( {
+ setup () {
+ // 在 \`setup\` 方法里声明变量
+ const msg = ' Hello World! '
+
+ // 将需要在 \`<template />\` 里使用的变量 \`return\` 出去
+ return {
+ msg ,
+ }
+ },
+} )
+</ script >
+
+<!-- CSS 代码和 Vue 2 一样 -->
+< style scoped >
+. msg {
+ font-size : 14px ;
+}
+</ style >
可以看到 Vue 3 的组件也是 <template />
+ <script />
+ <style />
的三段式组合,上手非常简单。
其中 Template 沿用了 Vue 2 时期类似 HTML 风格的模板写法, Style 则是使用原生 CSS 语法或者 Less 等 CSS 预处理器编写。
但需要注意的是,在 Vue 3 的 Composition API 写法里,数据或函数如果需要在 <template />
中使用,就必须在 setup
里将其 return
出去,而仅在 <script />
里被调用的函数或变量,不需要渲染到模板则无需 return
。
响应式数据的变化 ~new 响应式数据是 MVVM 数据驱动编程的特色, Vue 的设计也是受 MVVM 模型的启发,相信大部分开发者选择 MVVM 框架都是因为数据驱动编程比传统的事件驱动编程要来得方便,而选择 Vue ,则是方便中的方便。
TIP
Model-View-ViewModel (简称 MVVM ) 是一种软件架构模式,将视图 UI 和业务逻辑分开,通过对逻辑数据的修改即可驱动视图 UI 的更新,因此常将这种编程方式称为 “数据驱动” ,与之对应的需要操作 DOM 才能完成视图更新的编程方式则称为 “事件驱动” 。
设计上的变化 作为最重要的一个亮点, Vue 3 的响应式数据在设计上和 Vue 2 有着很大的不同。
回顾 Vue 2 Vue 2 是使用了 Object.defineProperty
API 的 getter/setter
来实现数据的响应性,这个方法的具体用法可以参考 MDN 的文档: Object.defineProperty - MDN 。
下面使用 Object.defineProperty
实现一个简单的双向绑定 demo ,亲自敲代码试一下可以有更多的理解:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > DefineProperty Demo </ title >
+ </ head >
+ < body >
+ <!-- 输入框和按钮 -->
+ < div >
+ < input type = " text " id = " input " />
+ < button onclick = " vm . text = ' Hello World '" > 设置为 Hello World </ button >
+ </ div >
+ <!-- 输入框和按钮 -->
+
+ <!-- 文本展示 -->
+ < div id = " output " ></ div >
+ <!-- 文本展示 -->
+
+ < script >
+ // 声明一个响应式数据
+ const vm = {}
+ Object . defineProperty (vm , ' text ' , {
+ set ( value ) {
+ document . querySelector ( ' #input ' ) . value = value
+ document . querySelector ( ' #output ' ) . innerText = value
+ },
+ } )
+
+ // 处理输入行为
+ document . querySelector ( ' #input ' ) . oninput = function ( e ) {
+ vm . text = e . target . value
+ }
+ </ script >
+ </ body >
+</ html >
这个小 demo 实现了这两个功能:
输入框的输入行为只修改 vm.text
的数据,但会同时更新 output 标签的文本内容 点击按钮修改 vm.text
的数据,也会触发输入框和 output 文本的更新 当然 Vue 做了非常多的工作,而非只是简单的调用了 Object.defineProperty
,可以在官网 深入 Vue 2 的响应式原理 一章了解更多 Vue 2 的响应式原理。
了解 Vue 3 Vue 3 是使用了 Proxy
API 的 getter/setter
来实现数据的响应性,这个方法的具体用法可以参考 MDN 的文档: Proxy - MDN 。
同样的,也来实现一个简单的双向绑定 demo ,这次使用 Proxy
来实现:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > Proxy Demo </ title >
+ </ head >
+ < body >
+ <!-- 输入框和按钮 -->
+ < div >
+ < input type = " text " id = " input " />
+ < button onclick = " vm . text = ' Hello World '" > 设置为 Hello World </ button >
+ </ div >
+ <!-- 输入框和按钮 -->
+
+ <!-- 文本展示 -->
+ < div id = " output " ></ div >
+ <!-- 文本展示 -->
+
+ < script >
+ // 声明一个响应式数据
+ const vm = new Proxy (
+ {},
+ {
+ set ( obj , key , value ) {
+ document . querySelector ( ' #input ' ) . value = value
+ document . querySelector ( ' #output ' ) . innerText = value
+ },
+ }
+ )
+
+ // 处理输入行为
+ document . querySelector ( ' #input ' ) . oninput = function ( e ) {
+ vm . text = e . target . value
+ }
+ </ script >
+ </ body >
+</ html >
这个 demo 实现的功能和使用 Object.defineProperty
的 demo 是完全一样的,也都是基于 setter
的行为完成数据更新的实现,那么为什么 Vue 3 要舍弃 Object.defineProperty
,换成 Proxy
呢?
主要原因在于 Object.defineProperty
有以下的不足:
无法侦听数组下标的变化,通过 arr[i] = newValue
这样的操作无法实时响应 无法侦听数组长度的变化,例如通过 arr.length = 10
去修改数组长度,无法响应 只能侦听对象的属性,对于整个对象需要遍历,特别是多级对象更是要通过嵌套来深度侦听 使用 Object.assign()
等方法给对象添加新属性时,也不会触发更新 更多细节上的问题 … 这也是为什么 Vue 2 要提供一个 Vue.set API 的原因,可以在官网 Vue 2 中检测变化的注意事项 一章了解更多说明。
而这些问题在 Proxy
都可以得到解决,可以在官网 深入 Vue 3 的响应式原理 一章了解更多这部分的内容。
用法上的变化 本书只使用 Composition API 编写组件,这是使用 Vue 3 的最大优势。
TIP
虽然官方文档在各个 API 的使用上都做了一定的举例,但在实际使用过程中可能会遇到一些问题,常见的情况就是有些数据用着用着就失去了响应,或者是在 TypeScript 里出现类型不匹配的报错等等。
当然,一般遇到这种情况并不一定是框架的 BUG ,而可能是使用方式不对,本章节将结合笔者最初入门 Vue 3 时遇到的问题和解决问题的心得,复盘这些响应式 API 的使用。
相对于 Vue 2 在 data
里声明后即可通过 this.xxx
调用响应式数据,在 Vue 3 的生命周期里没有了 Vue 实例的 this
指向,需要导入 ref
、reactive
等响应式 API 才能声明并使用响应式数据。
ts // 这里导入的 \`ref\` 是一个响应式 API
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 通过响应式 API 创建的变量具备了响应性
+ const msg = ref < string > ( ' Hello World! ' )
+ },
+} )
由于 Vue 3 新的 API 非常多,但有些 API 的使用场景却不多,因此本书当前只对常用的部分 API 的使用和常见问题进行说明,更多的 API 可以在官方文档的 响应性 API 一章查阅。
响应式 API 之 ref ~new ref
是最常用的一个响应式 API,它可以用来定义所有类型的数据,包括 Node 节点和组件。
没错,在 Vue 2 常用的 this.$refs.xxx
来取代 document.querySelector('.xxx')
获取 Node 节点的方式,也是使用这个 API 来取代。
类型声明 在开始使用 API 之前,需要先了解在 TypeScript 中如何声明 Ref 变量的类型。
API 本身的类型 先看 API 本身, ref
API 是一个函数,通过接受一个泛型入参,返回一个响应式对象,所有的值都通过 .value
属性获取,这是 API 本身的 TS 类型:
ts // \`ref\` API 的 TS 类型
+function ref < T >( value : T ): Ref < UnwrapRef < T >>
+
+// \`ref\` API 的返回值的 TS 类型
+interface Ref < T > {
+ value : T
+}
因此在声明变量时,是使用尖括号 <>
包裹其 TS 类型,紧跟在 ref
API 之后:
ts // 显式指定 \`msg.value\` 是 \`string\` 类型
+const msg = ref < string > ( ' Hello World! ' )
再回看该 API 本身的类型,其中使用了 T
泛型,这表示在传入函数的入参时,可以不需要手动指定其 TS 类型, TypeScript 会根据这个 API 所返回的响应式对象的 .value
属性的类型,确定当前变量的类型。
因此也可以省略显式的类型指定,像下面这样声明变量,其类型交给 TypeScript 去自动推导:
ts // TypeScript 会推导 \`msg.value\` 是 \`string\` 类型
+const msg = ref ( ' Hello World ' )
对于声明时会赋予初始值,并且在使用过程中不会改变其类型的变量,是可以省略类型的显式指定的。
而如果有显式的指定的类型,那么在一些特殊情况下,初始化时可以不必赋值,这样 TypeScript 会自动添加 undefined
类型:
ts const msg = ref < string > ()
+console . log (msg . value) // undefined
+
+msg . value = ' Hello World! '
+console . log (msg . value) // Hello World!
因为入参留空时,虽然指定了 string
类型,但实际上此时的值是 undefined
,因此实际上这个时候的 msg.value
是一个 string | undefined
的联合类型。
对于声明时不知道是什么值,在某种条件下才进行初始化的情况,就可以省略其初始值,但是切记在调用该变量的时候对 .value
值进行有效性判断。
而如果既不显式指定类型,也不赋予初始值,那么会被默认为 any
类型,除非真的无法确认类型,否则不建议这么做。
API 返回值的类型 细心的开发者还会留意到 ref
API 类型里面还标注了一个返回值的 TS 类型:
ts interface Ref < T > {
+ value : T
+}
它是代表整个 Ref 变量的完整类型:
上文声明 Ref 变量时,提到的 string
类型都是指 msg.value
这个 .value
属性的类型 而 msg
这个响应式变量,其本身是 Ref<string>
类型 如果在开发过程中需要在函数里返回一个 Ref 变量,那么其 TypeScript 类型就可以这样写(请留意 Calculator
里的 num
变量的类型):
ts // 导入 \`ref\` API
+import { ref } from ' vue '
+// 导入 \`ref\` API 的返回值类型
+import type { Ref } from ' vue '
+
+// 声明 \`useCalculator\` 函数的返回值类型
+interface Calculator {
+ // 这里包含了一个 Ref 变量
+ num : Ref < number >
+ add : () => void
+}
+
+// 声明一个 “使用计算器” 的函数
+function useCalculator (): Calculator {
+ const num = ref < number > ( 0 )
+
+ function add () {
+ num . value ++
+ }
+
+ return {
+ num ,
+ add ,
+ }
+}
+
+// 在执行使用计算器函数时,可以获取到一个 Ref 变量和其他方法
+const { num , add } = useCalculator ()
+add ()
+console . log (num . value) // 1
上面这个简单的例子演示了如何手动指定 Ref 变量的类型,对于逻辑复用时的函数代码抽离、插件开发等场景非常有用!当然大部分情况下可以交给 TypeScript 自动推导,但掌握其用法,在必要的时候就派得上用场了!
变量的定义 在了解了如何对 Ref 变量进行类型声明之后,面对不同的数据类型,相信都得心应手了!但不同类型的值之间还是有少许差异和注意事项,例如上文提及到该 API 可以用来定义所有类型的数据,包括 Node 节点和组件,具体可以参考下文的示例。
基本类型 对字符串、布尔值等基本类型的定义方式,比较简单:
ts // 字符串
+const msg = ref < string > ( ' Hello World! ' )
+
+// 数值
+const count = ref < number > ( 1 )
+
+// 布尔值
+const isVip = ref < boolean > ( false )
引用类型 对于对象、数组等引用类型也适用,比如要定义一个对象:
ts // 先声明对象的格式
+interface Member {
+ id : number
+ name : string
+}
+
+// 在定义对象时指定该类型
+const userInfo = ref < Member > ( {
+ id : 1 ,
+ name : ' Tom ' ,
+} )
定义一个普通数组:
ts // 数值数组
+const uids = ref < number [] > ([ 1 , 2 , 3 ])
+
+// 字符串数组
+const names = ref < string [] > ([ ' Tom ' , ' Petter ' , ' Andy ' ])
定义一个对象数组:
ts // 声明对象的格式
+interface Member {
+ id : number
+ name : string
+}
+
+// 定义一个对象数组
+const memberList = ref < Member [] > ([
+ {
+ id : 1 ,
+ name : ' Tom ' ,
+ },
+ {
+ id : 2 ,
+ name : ' Petter ' ,
+ },
+])
DOM 元素与子组件 除了可以定义数据,ref
也有熟悉的用途,就是用来挂载节点,也可以挂在子组件上,也就是对应在 Vue 2 时常用的 this.$refs.xxx
获取 DOM 元素信息的作用。
模板部分依然是熟悉的用法,在要引用的 DOM 上添加一个 ref
属性:
vue < template >
+ <!-- 给 DOM 元素添加 \`ref\` 属性 -->
+ < p ref = " msg " > 请留意该节点,有一个 ref 属性 </ p >
+
+ <!-- 子组件也是同样的方式添加 -->
+ < Child ref = " child " />
+</ template >
在 <script />
部分有三个最基本的注意事项:
在 <template />
代码里添加的 ref
属性的值,是对应 <script />
里使用 ref
API 声明的变量的名称;
请保证视图渲染完毕后再执行 DOM 或组件的相关操作(需要放到生命周期的 onMounted
或者 nextTick
函数里,这一点在 Vue 2 也是一样);
该 Ref 变量必须 return
出去才可以给到 <template />
使用,这一点是 Vue 3 生命周期的硬性要求,子组件的数据和方法如果要给父组件操作,也要 return
出来才可以。
配合上面的 <template />
,来看看 <script />
部分的具体例子:
ts import { defineComponent , onMounted , ref } from ' vue '
+import Child from ' @cp/Child.vue '
+
+export default defineComponent ( {
+ components : {
+ Child ,
+ },
+ setup () {
+ // 定义挂载节点,声明的类型详见下方附表
+ const msg = ref < HTMLElement > ()
+ const child = ref < InstanceType <typeof Child >> ()
+
+ // 请保证视图渲染完毕后再执行节点操作 e.g. \`onMounted\` / \`nextTick\`
+ onMounted ( () => {
+ // 比如获取 DOM 的文本
+ console . log ( msg . value . innerText )
+
+ // 或者操作子组件里的数据
+ child . value . isShowDialog = true
+ } )
+
+ // 必须 \`return\` 出去才可以给到 \`<template />\` 使用
+ return {
+ msg ,
+ child ,
+ }
+ },
+} )
关于 DOM 和子组件的 TS 类型声明,可参考以下规则:
TIP
单纯使用 typeof Child
虽然可以获得 Child.vue 组件的 Props 和方法等提示,但在 VSCode 的类型推导还不够智能,缺乏更有效的代码补全支持。
上文使用的 InstanceType<T>
是 TypeScript 提供的一个工具类型,可以获取构造函数类型的实例类型,因此将组件的类型声明为 InstanceType<typeof Child>
,不仅可以得到更完善的类型提示,在编程过程中还可以让编辑器提供更完善的代码补全功能。
另外,关于这一小节,有一个可能会引起 TS 编译报错的情况是,一些脚手架创建出来的项目会默认启用 --strictNullChecks
选项,会导致案例中的代码无法正常编译,出现如下报错:
bash ❯ npm run build
+
+> hello-vue3@0.0.0 build
+> vue-tsc --noEmit && vite build
+
+src/views/home.vue:27:7 - error TS2532: Object is possibly ' undefined ' .
+
+27 child.value.isShowDialog = true
+ ~~~~~~~~~~~
+
+
+Found 1 error in src/views/home.vue:27
这是因为在默认情况下 null
和 undefined
是所有类型的子类型,但开启了 strictNullChecks
选项之后,会使 null
和 undefined
只能赋值给 void
和它们各自,这是一个更为严谨的选项,可以保障程序代码的健壮性,但对于刚接触 TypeScript 不久的开发者可能不太友好。
有以下几种解决方案可以参考:
在涉及到相关操作的时候,对节点变量增加一个判断: ts // 添加 \`if\` 分支,判断 \`.value\` 存在时才执行相关代码
+if (child . value) {
+ // 读取子组件的数据
+ console . log ( child . value . num )
+
+ // 执行子组件的方法
+ child . value . sayHi ( ' Use \`if\` in \`onMounted\` API. ' )
+}
通过 TS 的可选符 ?
将目标设置为可选,避免出现错误(这个方式不能直接修改子组件数据的值): ts // 读取子组件的数据(留意 \`.num\` 前面有一个 \`?\` 问号)
+console . log (child . value ?. num)
+
+// 执行子组件的方法(留意 \`.sayHi\` 前面有一个 \`?\` 问号)
+child . value ?. sayHi ( ' use ? in onMounted ' )
在项目根目录下的 tsconfig.json
文件里,显式的关闭 strictNullChecks
选项,关闭后,需要开发者在写代码的时候,自行把控好是否需要对 null
和 undefined
进行判断: json {
+ " compilerOptions " : {
+ // ...
+ " strictNullChecks " : false
+ }
+ // ...
+}
使用 any 类型代替,但是写 TypeScript 还是尽量不要使用 any ,满屏的 AnyScript 不如直接使用 JavaScript 变量的读取与赋值 前面在介绍 API 类型的时候已经了解,通过 ref
声明的变量会全部变成对象,不管定义的是什么类型的值,都会转化为一个 Ref 对象,其中 Ref 对象具有指向内部值的单个 Property .value
。
也就是说,任何 Ref 对象的值都必须通过 xxx.value
才可以正确获取。
请牢记上面这句话,初拥 Vue 3 的开发者很多 BUG 都是由于这个问题引起的(包括笔者刚开始使用 Vue 3 的那段时间,嘿嘿)。
读取变量 平时对于普通变量的值,读取的时候都是直接调用其变量名即可:
ts // 读取一个字符串
+const msg : string = ' Hello World! '
+console . log (msg)
+
+// 读取一个数组
+const uids : number [] = [ 1 , 2 , 3 ]
+console . log (uids[ 1 ])
而 Ref 对象的值的读取,切记!必须通过 .value
!
ts // 读取一个字符串
+const msg = ref < string > ( ' Hello World! ' )
+console . log (msg . value)
+
+// 读取一个数组
+const uids = ref < number [] > ([ 1 , 2 , 3 ])
+console . log (uids . value[ 1 ])
为变量赋值 普通变量需要使用 let
声明才可以修改其值,由于 Ref 对象是个引用类型,所以可以使用 const
声明,直接通过 .value
修改。
ts // 声明一个字符串变量
+const msg = ref < string > ( ' Hi! ' )
+
+// 等待 1s 后修改它的值
+setTimeout ( () => {
+ msg . value = ' Hello! '
+}, 1000 )
因此日常业务中,像在对接服务端 API 的接口数据时,可以自由的使用 forEach
、map
、filter
等方法操作 Ref 数组,或者直接重置它,而不必担心数据失去响应性。
ts const data = ref < string [] > ([])
+
+// 提取接口的数据
+data . value = api . data . map ( ( item : any ) => item . text)
+
+// 重置数组
+data . value = []
为什么突然要说这个呢?因为涉及到下一部分的知识,关于 reactive
API 在使用上的注意事项。
响应式 API 之 reactive ~new reactive
是继 ref
之后最常用的一个响应式 API 了,相对于 ref
,它的局限性在于只适合对象、数组。
TIP
使用 reactive
的好处就是写法跟平时的对象、数组几乎一模一样,但它也带来了一些特殊注意点,请留意赋值部分的特殊说明。
类型声明与定义 reactive
变量的声明方式没有 ref
的变化那么大,基本上和普通变量一样,它的 TS 类型如下:
ts function reactive < T extends object >( target : T ): UnwrapNestedRefs < T >
可以看到其用法还是比较简单的,下面是一个 Reactive 对象的声明方式:
ts // 声明对象的类型
+interface Member {
+ id : number
+ name : string
+}
+
+// 定义一个对象
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Tom ' ,
+} )
下面是 Reactive 数组的声明方式:
ts const uids : number [] = reactive ([ 1 , 2 , 3 ])
还可以声明一个 Reactive 对象数组:
ts // 对象数组也是先声明其中的对象类型
+interface Member {
+ id : number
+ name : string
+}
+
+// 再定义一个为对象数组
+const userList : Member [] = reactive ([
+ {
+ id : 1 ,
+ name : ' Tom ' ,
+ },
+ {
+ id : 2 ,
+ name : ' Petter ' ,
+ },
+ {
+ id : 3 ,
+ name : ' Andy ' ,
+ },
+])
变量的读取与赋值 虽然 reactive
API 在使用上没有像 ref
API 一样有 .value
的心智负担,但也有一些注意事项要留意。
处理对象 Reactive 对象在读取字段的值,或者修改值的时候,与普通对象是一样的,这部分没有太多问题。
ts // 声明对象的类型
+interface Member {
+ id : number
+ name : string
+}
+
+// 定义一个对象
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Tom ' ,
+} )
+
+// 读取用户名
+console . log (userInfo . name)
+
+// 修改用户名
+userInfo . name = ' Petter '
处理数组 但是对于 Reactive 数组,和普通数组会有一些区别。
普通数组在 “重置” 或者 “修改值” 时都是可以直接操作:
ts // 定义一个普通数组
+let uids : number [] = [ 1 , 2 , 3 ]
+
+// 从另外一个对象数组里提取数据过来
+uids = api . data . map ( ( item : any ) => item . id)
+
+// 合并另外一个数组
+let newUids : number [] = [ 4 , 5 , 6 ]
+uids = [ ... uids , ... newUids]
+
+// 重置数组
+uids = []
Vue 2 在操作数组的时候,也可以和普通数组这样处理数据的变化,依然能够保持响应性,但在 Vue 3 ,如果使用 reactive
定义数组,则不能这么处理,必须只使用那些不会改变引用地址的操作。
笔者刚开始接触时,按照原来的思维去处理 reactive
数组,于是遇到了 “数据变了,但模板不会更新的问题” ,如果开发者在学习的过程中也遇到了类似的情况,可以从这里去入手排查问题所在。
举个例子,比如要从服务端 API 接口获取翻页数据时,通常要先重置数组,再异步添加数据,如果使用常规的重置,会导致这个变量失去响应性:
ts let uids : number [] = reactive ([ 1 , 2 , 3 ])
+
+/**
+ * 不推荐使用这种方式,会丢失响应性
+ * 异步添加数据后,模板不会响应更新
+ */
+uids = []
+
+// 异步获取数据后,模板依然是空数组
+setTimeout ( () => {
+ uids . push ( 1 )
+}, 1000 )
要让数据依然保持响应性,则必须在关键操作时,不破坏响应性 API ,以下是推荐的操作方式,通过重置数组的 length
长度来实现数据的重置:
ts const uids : number [] = reactive ([ 1 , 2 , 3 ])
+
+/**
+ * 推荐使用这种方式,不会破坏响应性
+ */
+uids . length = 0
+
+// 异步获取数据后,模板可以正确的展示
+setTimeout ( () => {
+ uids . push ( 1 )
+}, 1000 )
特别注意 不要对 Reactive 数据进行 ES6 的解构 操作,因为解构后得到的变量会失去响应性。
比如这些情况,在 2s 后都得不到新的 name 信息:
ts import { defineComponent , reactive } from ' vue '
+
+interface Member {
+ id : number
+ name : string
+}
+
+export default defineComponent ( {
+ setup () {
+ // 定义一个带有响应性的对象
+ const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+ } )
+
+ // 在 2s 后更新 \`userInfo\`
+ setTimeout ( () => {
+ userInfo . name = ' Tom '
+ }, 2000 )
+
+ // 这个变量在 2s 后不会同步更新
+ const newUserInfo : Member = { ... userInfo }
+
+ // 这个变量在 2s 后不会再同步更新
+ const { name } = userInfo
+
+ // 这样 \`return\` 出去给模板用,在 2s 后也不会同步更新
+ return {
+ ... userInfo ,
+ }
+ },
+} )
响应式 API 之 toRef 与 toRefs ~new 相信各位开发者看到这里时,应该已经对 ref
和 reactive
API 都有所了解了,为了方便开发者使用, Vue 3 还推出了两个与之相关的 API : toRef
和 toRefs
,都是用于 reactive
向 ref
转换。
各自的作用 这两个 API 在拼写上非常接近,顾名思义,一个是只转换一个字段,一个是转换所有字段,转换后将得到新的变量,并且新变量和原来的变量可以保持同步更新。
API 作用 toRef 创建一个新的 Ref 变量,转换 Reactive 对象的某个字段为 Ref 变量 toRefs 创建一个新的对象,它的每个字段都是 Reactive 对象各个字段的 Ref 变量
光看概念可能不容易理解,来看下面的例子,先声明一个 reactive
变量:
ts interface Member {
+ id : number
+ name : string
+}
+
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+} )
然后分别看看这两个 API 应该怎么使用。
使用 toRef 先看这个转换单个字段的 toRef
API ,了解了它的用法之后,再去看 toRefs
就很容易理解了。
API 类型和基本用法 toRef
API 的 TS 类型如下:
ts // \`toRef\` API 的 TS 类型
+function toRef < T extends object , K extends keyof T >(
+ object : T ,
+ key : K ,
+ defaultValue ?: T [ K ]
+): ToRef < T [ K ] >
+
+// \`toRef\` API 的返回值的 TS 类型
+type ToRef < T > = T extends Ref ? T : Ref < T >
通过接收两个必传的参数(第一个是 reactive
对象, 第二个是要转换的 key
),返回一个 Ref 变量,在适当的时候也可以传递第三个参数,为该变量设置默认值。
以上文声明好的 userInfo
为例,如果想转换 name
这个字段为 Ref 变量,只需要这样操作:
ts const name = toRef (userInfo , ' name ' )
+console . log (name . value) // Petter
等号左侧的 name
变量此时是一个 Ref 变量,这里因为 TypeScript 可以对其自动推导,因此声明时可以省略 TS 类型的显式指定,实际上该变量的类型是 Ref<string>
。
所以之后在读取和赋值时,就需要使用 name.value
来操作,在重新赋值时会同时更新 name
和 userInfo.name
的值:
ts // 修改前先查看初始值
+const name = toRef (userInfo , ' name ' )
+console . log (name . value) // Petter
+console . log (userInfo . name) // Petter
+
+// 修改 Ref 变量的值,两者同步更新
+name . value = ' Tom '
+console . log (name . value) // Tom
+console . log (userInfo . name) // Tom
+
+// 修改 Reactive 对象上该属性的值,两者也是同步更新
+userInfo . name = ' Jerry '
+console . log (name . value) // Jerry
+console . log (userInfo . name) // Jerry
这个 API 也可以接收一个 Reactive 数组,此时第二个参数应该传入数组的下标:
ts // 这一次声明的是数组
+const words = reactive ([ ' a ' , ' b ' , ' c ' ])
+
+// 通过下标 \`0\` 转换第一个 item
+const a = toRef (words , 0 )
+console . log (a . value) // a
+console . log (words[ 0 ]) // a
+
+// 通过下标 \`2\` 转换第三个 item
+const c = toRef (words , 2 )
+console . log (c . value) // c
+console . log (words[ 2 ]) // c
设置默认值 如果 Reactive 对象上有一个属性本身没有初始值,也可以传递第三个参数进行设置(默认值仅对 Ref 变量有效):
ts interface Member {
+ id : number
+ name : string
+ // 类型上新增一个属性,因为是可选的,因此默认值会是 \`undefined\`
+ age ?: number
+}
+
+// 声明变量时省略 \`age\` 属性
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+} )
+
+// 此时为了避免程序运行错误,可以指定一个初始值
+// 但初始值仅对 Ref 变量有效,不会影响 Reactive 字段的值
+const age = toRef (userInfo , ' age ' , 18 )
+console . log (age . value) // 18
+console . log (userInfo . age) // undefined
+
+// 除非重新赋值,才会使两者同时更新
+age . value = 25
+console . log (age . value) // 25
+console . log (userInfo . age) // 25
数组也是同理,对于可能不存在的下标,可以传入默认值避免项目的逻辑代码出现问题:
ts const words = reactive ([ ' a ' , ' b ' , ' c ' ])
+
+// 当下标对应的值不存在时,也是返回 \`undefined\`
+const d = toRef (words , 3 )
+console . log (d . value) // undefined
+console . log (words[ 3 ]) // undefined
+
+// 设置了默认值之后,就会对 Ref 变量使用默认值, Reactive 数组此时不影响
+const e = toRef (words , 4 , ' e ' )
+console . log (e . value) // e
+console . log (words[ 4 ]) // undefined
其他用法 这个 API 还有一个特殊用法,但不建议在 TypeScript 里使用。
在 toRef
的过程中,如果使用了原对象上面不存在的 key
,那么定义出来的 Ref 变量的 .value
值将会是 undefined
。
ts // 众所周知, Petter 是没有女朋友的
+const girlfriend = toRef (userInfo , ' girlfriend ' )
+console . log (girlfriend . value) // undefined
+console . log (userInfo . girlfriend) // undefined
+
+// 此时 Reactive 对象上只有两个 Key
+console . log (Object . keys (userInfo)) // ['id', 'name']
如果对这个不存在的 key
的 Ref 变量进行赋值,那么原来的 Reactive 对象也会同步增加这个 key
,其值也会同步更新。
ts // 赋值后,不仅 Ref 变量得到了 \`Marry\` , Reactive 对象也得到了 \`Marry\`
+girlfriend . value = ' Marry '
+console . log (girlfriend . value) // 'Marry'
+console . log (userInfo . girlfriend) // 'Marry'
+
+// 此时 Reactive 对象上有了三个 Key
+console . log (Object . keys (userInfo)) // ['id', 'name', 'girlfriend']
为什么强调不要在 TypeScript 里使用呢?因为在编译时,无法通过 TypeScript 的类型检查:
bash ❯ npm run build
+
+> hello-vue3@0.0.0 build
+> vue-tsc --noEmit && vite build
+
+src/views/home.vue:37:40 - error TS2345: Argument of type ' "girlfriend" '
+is not assignable to parameter of type ' keyof Member ' .
+
+37 const girlfriend = toRef ( userInfo, ' girlfriend ' )
+ ~~~~~~~~~~~~
+
+src/views/home.vue:39:26 - error TS2339: Property ' girlfriend ' does not exist
+on type ' Member ' .
+
+39 console.log ( userInfo.girlfriend ) // undefined
+ ~~~~~~~~~~
+
+src/views/home.vue:45:26 - error TS2339: Property ' girlfriend ' does not exist
+on type ' Member ' .
+
+45 console.log ( userInfo.girlfriend ) // ' Marry '
+ ~~~~~~~~~~
+
+
+Found 3 errors in the same file, starting at: src/views/home.vue:37
如果不得不使用这种情况,可以考虑使用 any 类型:
ts // 将该类型直接指定为 \`any\`
+type Member = any
+// 当然一般都是 \`const userInfo: any\`
+
+// 或者保持接口类型的情况下,允许任意键值
+interface Member {
+ [ key : string ] : any
+}
+
+// 使用 \`Record\` 也是同理
+type Member = Record < string , any >
但笔者还是更推荐保持良好的类型声明习惯,尽量避免这种用法。
使用 toRefs 在了解了 toRef
API 之后,来看看 toRefs
的用法。
API 类型和基本用法 先看看它的 TS 类型:
ts function toRefs < T extends object >(
+ object : T
+): {
+ [ K in keyof T ] : ToRef < T [ K ] >
+}
+
+type ToRef = T extends Ref ? T : Ref < T >
与 toRef
不同, toRefs
只接收了一个参数,是一个 reactive
变量。
ts interface Member {
+ id : number
+ name : string
+}
+
+// 声明一个 Reactive 变量
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+} )
+
+// 传给 \`toRefs\` 作为入参
+const userInfoRefs = toRefs (userInfo)
此时这个新的 userInfoRefs
变量,它的 TS 类型就不再是 Member
了,而应该是:
ts // 导入 \`toRefs\` API 的类型
+import type { ToRefs } from ' vue '
+
+// 上下文代码省略...
+
+// 将原来的类型传给 API 的类型
+const userInfoRefs : ToRefs < Member > = toRefs (userInfo)
也可以重新编写一个新的类型来指定它,因为每个字段都是与原来关联的 Ref 变量,所以也可以这样声明:
ts // 导入 \`ref\` API 的类型
+import type { Ref } from ' vue '
+
+// 上下文代码省略...
+
+// 新声明的类型每个字段都是一个 Ref 变量的类型
+interface MemberRefs {
+ id : Ref < number >
+ name : Ref < string >
+}
+
+// 使用新的类型进行声明
+const userInfoRefs : MemberRefs = toRefs (userInfo)
当然实际上日常使用时并不需要手动指定其类型, TypeScript 会自动推导,可以节约非常多的开发工作量。
和 toRef
API 一样,这个 API 也是可以对数组进行转换:
ts const words = reactive ([ ' a ' , ' b ' , ' c ' ])
+const wordsRefs = toRefs (words)
此时新数组的类型是 Ref<string>[]
,不再是原来的 string[]
类型。
解构与赋值 转换后的 Reactive 对象或数组支持 ES6 的解构,并且不会失去响应性,因为解构后的每一个变量都具备响应性。
ts // 为了提高开发效率,可以直接将 Ref 变量直接解构出来使用
+const { name } = toRefs (userInfo)
+console . log (name . value) // Petter
+
+// 此时对解构出来的变量重新赋值,原来的变量也可以同步更新
+name . value = ' Tom '
+console . log (name . value) // Tom
+console . log (userInfo . name) // Tom
这一点和直接解构 Reactive 变量有非常大的不同,直接解构 Reactive 变量,得到的是一个普通的变量,不再具备响应性。
这个功能在使用 Hooks 函数非常好用(在 Vue 3 里也叫可组合函数, Composable Functions ),还是以一个计算器函数为例,这一次将其修改为内部有一个 Reactive 的数据状态中心,在函数返回时解构为多个 Ref 变量:
ts import { reactive , toRefs } from ' vue '
+
+// 声明 \`useCalculator\` 数据状态类型
+interface CalculatorState {
+ // 这是要用来计算操作的数据
+ num : number
+ // 这是每次计算时要增加的幅度
+ step : number
+}
+
+// 声明一个 “使用计算器” 的函数
+function useCalculator () {
+ // 通过数据状态中心的形式,集中管理内部变量
+ const state : CalculatorState = reactive ( {
+ num : 0 ,
+ step : 10 ,
+ } )
+
+ // 功能函数也是通过数据中心变量去调用
+ function add () {
+ state . num += state . step
+ }
+
+ return {
+ ... toRefs ( state ) ,
+ add ,
+ }
+}
这样在调用 useCalculator
函数时,可以通过解构直接获取到 Ref 变量,不需要再进行额外的转换工作。
ts // 解构出来的 \`num\` 和 \`step\` 都是 Ref 变量
+const { num , step , add } = useCalculator ()
+console . log (num . value) // 0
+console . log (step . value) // 10
+
+// 调用计算器的方法,数据也是会得到响应式更新
+add ()
+console . log (num . value) // 10
为什么要进行转换 关于为什么要出这么两个 API ,官方文档没有特别说明,不过经过笔者在业务中的一些实际使用感受,以及在写上一节 reactive
的 特别注意 ,可能知道一些使用理由。
关于 ref
和 reactive
这两个 API 的好处就不重复了,但是在使用的过程中,各自都有不方便的地方:
ref
API 虽然在 <template />
里使用起来方便,但是在 <script />
里进行读取 / 赋值的时候,要一直记得加上 .value
,否则 BUG 就来了。
reactive
API 虽然在使用的时候,因为知道它本身是一个对象,所以不会忘记通过 foo.bar
这样的格式去操作,但是在 <template />
渲染的时候,又因此不得不每次都使用 foo.bar
的格式去渲染。
那么有没有办法,既可以在编写 <script />
的时候不容易出错,在写 <template />
的时候又比较简单呢?
于是, toRef
和 toRefs
因此诞生。
什么场景下比较适合使用它们 从便利性和可维护性来说,最好只在功能单一、代码量少的组件里使用,比如一个表单组件,通常表单的数据都放在一个对象里。
当然也可以把所有的数据都定义到一个 data
里,再去 data
里面取值,但是没有必要为了转换而转换,否则不如使用 Options API 风格。
在业务中的具体运用 继续使用上文一直在使用的 userInfo
来当案例,以一个用户信息表的小 demo 做个演示。
在 <script />
部分:
先用 reactive
定义一个源数据,所有的数据更新,都是修改这个对象对应的值,按照对象的写法维护数据
再通过 toRefs
定义一个给 <template />
使用的对象,这样可以得到一个每个字段都是 Ref 变量的新对象
在 return
的时候,对步骤 2 里的 toRefs
对象进行解构,这样导出去就是各个字段对应的 Ref 变量,而不是一整个对象
ts import { defineComponent , reactive , toRefs } from ' vue '
+
+interface Member {
+ id : number
+ name : string
+ age : number
+ gender : string
+}
+
+export default defineComponent ( {
+ setup () {
+ // 定义一个 reactive 对象
+ const userInfo = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+ age : 18 ,
+ gender : ' male ' ,
+ } )
+
+ // 定义一个新的对象,它本身不具备响应性,但是它的字段全部是 Ref 变量
+ const userInfoRefs = toRefs ( userInfo )
+
+ // 在 2s 后更新 \`userInfo\`
+ setTimeout ( () => {
+ userInfo . id = 2
+ userInfo . name = ' Tom '
+ userInfo . age = 20
+ }, 2000 )
+
+ // 在这里解构 \`toRefs\` 对象才能继续保持响应性
+ return {
+ ... userInfoRefs ,
+ }
+ },
+} )
在 <template />
部分:
由于 return
出来的都是 Ref 变量,所以在模板里可以直接使用 userInfo
各个字段的 key
,不再需要写很长的 userInfo.name
了。
vue < template >
+ < ul class = " user-info " >
+ < li class = " item " >
+ < span class = " key " > ID: </ span >
+ < span class = " value " > {{ id }} </ span >
+ </ li >
+
+ < li class = " item " >
+ < span class = " key " > name: </ span >
+ < span class = " value " > {{ name }} </ span >
+ </ li >
+
+ < li class = " item " >
+ < span class = " key " > age: </ span >
+ < span class = " value " > {{ age }} </ span >
+ </ li >
+
+ < li class = " item " >
+ < span class = " key " > gender: </ span >
+ < span class = " value " > {{ gender }} </ span >
+ </ li >
+ </ ul >
+</ template >
需要注意的问题 请注意是否有相同命名的变量存在,比如上面在 return
给 <template />
使用时,在解构 userInfoRefs
的时候已经包含了一个 name
字段,此时如果还有一个单独的变量也叫 name
,就会出现渲染上的数据显示问题。
此时它们在 <template />
里哪个会生效,取决于谁排在后面,因为 return
出去的其实是一个对象,在对象里,如果存在相同的 key
,则后面的会覆盖前面的。
下面这种情况,会以单独的 name
为渲染数据:
ts return {
+ ... userInfoRefs ,
+ name ,
+}
而下面这种情况,则是以 userInfoRefs
里的 name
为渲染数据:
ts return {
+ name ,
+ ... userInfoRefs ,
+}
所以当决定使用 toRef
和 toRefs
API 的时候,请注意这个特殊情况!
函数的声明和使用 ~new 在了解了响应式数据如何使用之后,接下来就要开始了解函数了。
在 Vue 2 ,函数通常是作为当前组件实例上的方法在 methods
里声明,然后再在 mounted
等生命周期里调用,或者是在模板里通过 Click 等行为触发,由于组件内部经常需要使用 this
获取组件实例,因此不能使用箭头函数。
js export default {
+ data : () => {
+ return {
+ num : 0 ,
+ }
+ },
+ mounted : function () {
+ this. add ()
+ },
+ methods : {
+ // 不可以使用 \`add: () => this.num++\`
+ add : function () {
+ this. num ++
+ },
+ },
+}
在 Vue 3 则灵活了很多,可以使用普通函数、 Class 类、箭头函数、匿名函数等等进行声明,可以将其写在 setup
里直接使用,也可以抽离在独立的 .js
/ .ts
文件里再导入使用。
需要在组件创建时自动执行的函数,其执行时机需要遵循 Vue 3 的生命周期,需要在模板里通过 @click
、@change
等行为触发,和变量一样,需要把函数名在 setup
里进行 return
出去。
下面是一个简单的例子,方便开发者更直观地了解:
vue < template >
+ < p > {{ msg }} </ p >
+
+ <!-- 在这里点击执行 \`return\` 出来的方法 -->
+ < button @click = " updateMsg " > 修改MSG </ button >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , onMounted , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const msg = ref < string > ( ' Hello World! ' )
+
+ // 这个要暴露给模板使用,必须 \`return\` 才可以使用
+ function updateMsg () {
+ msg . value = ' Hi World! '
+ }
+
+ // 这个要在页面载入时执行,无需 \`return\` 出去
+ const init = () => {
+ console . log ( ' init ' )
+ }
+
+ onMounted ( () => {
+ init ()
+ } )
+
+ return {
+ msg ,
+ updateMsg ,
+ }
+ },
+} )
+</ script >
数据的侦听 ~new 侦听数据变化也是组件里的一项重要工作,比如侦听路由变化、侦听参数变化等等。
Vue 3 在保留原来的 watch
功能之外,还新增了一个 watchEffect
帮助更简单的进行侦听。
watch 在 Vue 3 ,新版的 watch
和 Vue 2 的旧版写法对比,在使用方式上变化非常大!
回顾 Vue 2 在 Vue 2 是这样用的,和 data
、 methods
都在同级配置:
ts export default {
+ data () {
+ return {
+ // ...
+ }
+ },
+ // 注意这里,放在 \`data\` 、 \`methods\` 同个级别
+ watch : {
+ // ...
+ },
+ methods : {
+ // ...
+ },
+}
并且类型繁多,选项式 API 的类型如下:
ts watch : { [ key : string ]: string | Function | Object | Array }
联合类型过多,意味着用法复杂,下面是个很好的例子,虽然出自 官网 的用法介绍,但过于繁多的用法也反映出来对初学者不太友好,初次接触可能会觉得一头雾水:
ts export default {
+ data () {
+ return {
+ a : 1 ,
+ b : 2 ,
+ c : {
+ d : 4 ,
+ },
+ e : 5 ,
+ f : 6 ,
+ }
+ },
+ watch : {
+ // 侦听顶级 Property
+ a ( val , oldVal ) {
+ console . log ( \` new: \${ val } , old: \${ oldVal }\` )
+ },
+ // 字符串方法名
+ b : ' someMethod ' ,
+ // 该回调会在任何被侦听的对象的 Property 改变时被调用,不论其被嵌套多深
+ c : {
+ handler ( val , oldVal ) {
+ console . log ( ' c changed ' )
+ },
+ deep : true ,
+ },
+ // 侦听单个嵌套 Property
+ ' c.d ' : function ( val , oldVal ) {
+ // do something
+ },
+ // 该回调将会在侦听开始之后被立即调用
+ e : {
+ handler ( val , oldVal ) {
+ console . log ( ' e changed ' )
+ },
+ immediate : true ,
+ },
+ // 可以传入回调数组,它们会被逐一调用
+ f : [
+ ' handle1 ' ,
+ function handle2 ( val , oldVal ) {
+ console . log ( ' handle2 triggered ' )
+ },
+ {
+ handler : function handle3 ( val , oldVal ) {
+ console . log ( ' handle3 triggered ' )
+ },
+ /* ... */
+ },
+ ] ,
+ },
+ methods : {
+ someMethod () {
+ console . log ( ' b changed ' )
+ },
+ handle1 () {
+ console . log ( ' handle 1 triggered ' )
+ },
+ },
+}
当然肯定也会有开发者会觉得这样选择多是个好事,选择适合自己的就好,但笔者还是认为这种写法对于初学者来说不是那么友好,有些过于复杂化,如果一个用法可以适应各种各样的场景,岂不是更妙?
TIP
另外需要注意的是,不能使用箭头函数来定义 Watcher 函数 (例如 searchQuery: newValue => this.updateAutocomplete(newValue)
)。
因为箭头函数绑定了父级作用域的上下文,所以 this
将不会按照期望指向组件实例, this.updateAutocomplete
将是 undefined
。
Vue 2 也可以通过 this.$watch()
这个 API 的用法来实现对某个数据的侦听,它接受三个参数: source
、 callback
和 options
。
ts export default {
+ data () {
+ return {
+ a : 1 ,
+ }
+ },
+ // 生命周期钩子
+ mounted () {
+ this. $watch ( ' a ' , ( newVal , oldVal ) => {
+ // ...
+ } )
+ },
+}
由于 this.$watch
的用法和 Vue 3 比较接近,所以这里不做过多的回顾,请直接看 了解 Vue 3 部分。
了解 Vue 3 在 Vue 3 的组合式 API 写法, watch
是一个可以接受 3 个参数的函数(保留了 Vue 2 的 this.$watch
这种用法),在使用层面上简单了很多。
ts import { watch } from ' vue '
+
+// 一个用法走天下
+watch (
+ source , // 必传,要侦听的数据源
+ callback // 必传,侦听到变化后要执行的回调函数
+ // options // 可选,一些侦听选项
+)
下面的内容都基于 Vue 3 的组合式 API 用法展开讲解。
API 的 TS 类型 在了解用法之前,先对它的 TS 类型声明做一个简单的了解, watch 作为组合式 API ,根据使用方式有两种类型声明:
基础用法的 TS 类型,详见 基础用法 部分 ts // watch 部分的 TS 类型
+// ...
+export declare function watch < T , Immediate extends Readonly < boolean > = false >(
+ source : WatchSource < T >,
+ cb : WatchCallback < T , Immediate extends true ? T | undefined : T >,
+ options ?: WatchOptions < Immediate >
+): WatchStopHandle
+// ...
批量侦听的 TS 类型,详见 批量侦听 部分 ts // watch 部分的 TS 类型
+// ...
+export declare function watch <
+ T extends MultiWatchSources ,
+ Immediate extends Readonly < boolean > = false
+>(
+ sources : [ ... T ] ,
+ cb : WatchCallback < MapSources < T , false >, MapSources < T , Immediate >>,
+ options ?: WatchOptions < Immediate >
+): WatchStopHandle
+
+// MultiWatchSources 是一个数组
+declare type MultiWatchSources = ( WatchSource < unknown > | object )[]
+// ...
但是不管是基础用法还是批量侦听,可以看到这个 API 都是接受三个入参:
并返回一个可以用来停止侦听的函数(详见:停止侦听 )。
要侦听的数据源 在上面 API 的 TS 类型 已经对 watch
API 的组成有一定的了解了,这里先对数据源的类型和使用限制做下说明。
TIP
如果不提前了解,在使用的过程中可能会遇到 “侦听了但没有反应” 的情况出现。
另外,这部分内容会先围绕基础用法展开说明,批量侦听会在 批量侦听 部分单独说明。
watch
API 的第 1 个参数 source
是要侦听的数据源,它的 TS 类型如下:
ts // watch 第 1 个入参的 TS 类型
+// ...
+export declare type WatchSource < T = any > = Ref < T > | ComputedRef < T > | ( () => T )
+// ...
可以看到能够用于侦听的数据,是通过 响应式 API 定义的变量( Ref<T>
),或者是一个 计算数据 ( ComputedRef<T>
),或者是一个 getter 函数 ( () => T
)。
所以要想定义的 watch 能够做出预期的行为,数据源必须具备响应性或者是一个 getter ,如果只是通过 let
定义一个普通变量,然后去改变这个变量的值,这样是无法侦听的。
TIP
如果要侦听响应式对象里面的某个值(这种情况下对象本身是响应式,但它的 property 不是),需要写成 getter 函数,简单的说就是需要写成有返回值的函数,这个函数 return 要侦听的数据, e.g. () => foo.bar
,可以结合下方 基础用法 的例子一起理解。
侦听后的回调函数 在上面 API 的 TS 类型 介绍了 watch API 的组成,和数据源一样,先了解一下回调函数的定义。
TIP
和数据源部分一样,回调函数的内容也是会先围绕基础用法展开说明,批量侦听会在 批量侦听 部分单独说明。
watch API 的第 2 个参数 callback
是侦听到数据变化时要做出的行为,它的 TS 类型如下:
ts // watch 第 2 个入参的 TS 类型
+// ...
+export declare type WatchCallback < V = any , OV = any > = (
+ value : V ,
+ oldValue : OV ,
+ onCleanup : OnCleanup
+) => any
+// ...
乍一看它有三个参数,但实际上这些参数不是自己定义的,而是 watch API 传给的,所以不管用或者不用,它们都在那里:
参数 作用 value 变化后的新值,类型和数据源保持一致 oldValue 变化前的旧值,类型和数据源保持一致 onCleanup 注册一个清理函数,详见 侦听效果清理 部分
注意:第一个参数是新值,第二个才是原来的旧值!
如同其他 JS 函数,在使用 watch 的回调函数时,可以对这三个参数任意命名,比如把 value
命名为觉得更容易理解的 newValue
。
TIP
如果侦听的数据源是一个 引用类型 时( e.g. Object
、 Array
、 Date
… ), value
和 oldValue
是完全相同的,因为指向同一个对象。
另外,默认情况下,watch
是惰性的,也就是只有当被侦听的数据源发生变化时才执行回调。
基础用法 来到这里,对 2 个必传的参数都有一定的了解了,先看看基础的用法,也就是日常最常编写的方案,只需要先关注前 2 个必传的参数。
ts // 不要忘了导入要用的 API
+import { defineComponent , reactive , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 定义一个响应式数据
+ const userInfo = reactive ( {
+ name : ' Petter ' ,
+ age : 18 ,
+ } )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ userInfo . name = ' Tom '
+ }, 2000 )
+
+ /**
+ * 可以直接侦听这个响应式对象
+ * callback 的参数如果不用可以不写
+ */
+ watch ( userInfo , () => {
+ console . log ( ' 侦听整个 userInfo ' , userInfo . name )
+ } )
+
+ /**
+ * 也可以侦听对象里面的某个值
+ * 此时数据源需要写成 getter 函数
+ */
+ watch (
+ // 数据源,getter 形式
+ () => userInfo . name ,
+ // 回调函数 callback
+ ( newValue , oldValue ) => {
+ console . log ( ' 只侦听 name 的变化 ' , userInfo . name )
+ console . log ( ' 打印变化前后的值 ' , { oldValue , newValue } )
+ }
+ )
+ },
+} )
一般的业务场景,基础用法足以面对。
如果有多个数据源要侦听,并且侦听到变化后要执行的行为一样,那么可以使用 批量侦听 。
特殊的情况下,可以搭配 侦听的选项 做一些特殊的用法,详见下面部分的内容。
批量侦听 如果有多个数据源要侦听,并且侦听到变化后要执行的行为一样,第一反应可能是这样来写:
抽离相同的处理行为为公共函数 然后定义多个侦听操作,传入这个公共函数 ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const message = ref < string > ( '' )
+ const index = ref < number > ( 0 )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ // 来到这里才会触发 watch 的回调
+ message . value = ' Hello World! '
+ index . value ++
+ }, 2000 )
+
+ // 抽离相同的处理行为为公共函数
+ const handleWatch = (
+ newValue : string | number ,
+ oldValue : string | number
+ ) : void => {
+ console . log ( { newValue , oldValue } )
+ }
+
+ // 然后定义多个侦听操作,传入这个公共函数
+ watch ( message , handleWatch )
+ watch ( index , handleWatch )
+ },
+} )
这样写其实没什么问题,不过除了抽离公共代码的写法之外, watch API 还提供了一个批量侦听的用法,和 基础用法 的区别在于,数据源和回调参数都变成了数组的形式。
数据源:以数组的形式传入,里面每一项都是一个响应式数据。
回调参数:原来的 value
和 newValue
也都变成了数组,每个数组里面的顺序和数据源数组排序一致。
可以看下面的这个例子更为直观:
ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 定义多个数据源
+ const message = ref < string > ( '' )
+ const index = ref < number > ( 0 )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ message . value = ' Hello World! '
+ index . value ++
+ }, 2000 )
+
+ watch (
+ // 数据源改成了数组
+ [ message , index ] ,
+ // 回调的入参也变成了数组,每个数组里面的顺序和数据源数组排序一致
+ ([ newMessage , newIndex ], [ oldMessage , oldIndex ]) => {
+ console . log ( ' message 的变化 ' , { newMessage , oldMessage } )
+ console . log ( ' index 的变化 ' , { newIndex , oldIndex } )
+ }
+ )
+ },
+} )
什么情况下可能会用到批量侦听呢?比如一个子组件有多个 props ,当有任意一个 prop 发生变化时,都需要执行初始化函数重置组件的状态,那么这个时候就可以用上这个功能啦!
侦听的选项 在 API 的 TS 类型 里提到, watch API 还接受第 3 个参数 options
,可选的一些侦听选项。
它的 TS 类型如下:
ts // watch 第 3 个入参的 TS 类型
+// ...
+export declare interface WatchOptions < Immediate = boolean >
+ extends WatchOptionsBase {
+ immediate ?: Immediate
+ deep ?: boolean
+}
+// ...
+
+// 继承的 base 类型
+export declare interface WatchOptionsBase extends DebuggerOptions {
+ flush ?: ' pre ' | ' post ' | ' sync '
+}
+// ...
+
+// 继承的 debugger 选项类型
+export declare interface DebuggerOptions {
+ onTrack ?: ( event : DebuggerEvent ) => void
+ onTrigger ?: ( event : DebuggerEvent ) => void
+}
+// ...
options
是一个对象的形式传入,有以下几个选项:
选项 类型 默认值 可选值 作用 deep boolean false true | false 是否进行深度侦听 immediate boolean false true | false 是否立即执行侦听回调 flush string 'pre' 'pre' | 'post' | 'sync' 控制侦听回调的调用时机 onTrack (e) => void 在数据源被追踪时调用 onTrigger (e) => void 在侦听回调被触发时调用
其中 onTrack
和 onTrigger
的 e
是 debugger 事件,建议在回调内放置一个 debugger 语句 以调试依赖,这两个选项仅在开发模式下生效。
TIP
deep 默认是 false
,但是在侦听 reactive 对象或数组时,会默认为 true
,详见 侦听选项之 deep 。
侦听选项之 deep deep
选项接受一个布尔值,可以设置为 true
开启深度侦听,或者是 false
关闭深度侦听,默认情况下这个选项是 false
关闭深度侦听的,但也存在特例。
设置为 false
的情况下,如果直接侦听一个响应式的 引用类型 数据(e.g. Object
、 Array
… ),虽然它的属性的值有变化,但对其本身来说是不变的,所以不会触发 watch 的 callback 。
下面是一个关闭了深度侦听的例子:
ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 定义一个响应式数据,注意用的是 ref 来定义
+ const nums = ref < number [] > ([])
+
+ // 2s后给这个数组添加项目
+ setTimeout ( () => {
+ nums . value . push ( 1 )
+
+ // 可以打印一下,确保数据确实变化了
+ console . log ( ' 修改后 ' , nums . value )
+ }, 2000 )
+
+ // 但是这个 watch 不会按预期执行
+ watch (
+ nums ,
+ // 这里的 callback 不会被触发
+ () => {
+ console . log ( ' 触发侦听 ' , nums . value )
+ },
+ // 因为关闭了 deep
+ {
+ deep : false ,
+ }
+ )
+ },
+} )
类似这种情况,需要把 deep
设置为 true
才可以触发侦听。
可以看到上面的例子特地用了 ref API ,这是因为通过 reactive API 定义的对象无法将 deep
成功设置为 false
(这一点在目前的官网文档未找到说明,最终是在 watch API 的源码 上找到了答案)。
ts // ...
+if ( isReactive (source)) {
+ getter = () => source
+ deep = true // 被强制开启了
+}
+// ...
这个情况就是上面所说的 “特例” ,可以通过 isReactive
API 来判断是否需要手动开启深度侦听。
ts // 导入 isReactive API
+import { defineComponent , isReactive , reactive , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 侦听这个数据时,会默认开启深度侦听
+ const foo = reactive ( {
+ name : ' Petter ' ,
+ age : 18 ,
+ } )
+ console . log ( isReactive ( foo )) // true
+
+ // 侦听这个数据时,不会默认开启深度侦听
+ const bar = ref ( {
+ name : ' Petter ' ,
+ age : 18 ,
+ } )
+ console . log ( isReactive ( bar )) // false
+ },
+} )
在 侦听后的回调函数 部分有了解过, watch 默认是惰性的,也就是只有当被侦听的数据源发生变化时才执行回调。
这句话是什么意思呢?来看一下这段代码,为了减少 deep 选项的干扰,换一个类型,换成 string
数据来演示,请留意注释:
ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 这个时候不会触发 watch 的回调
+ const message = ref < string > ( '' )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ // 来到这里才会触发 watch 的回调
+ message . value = ' Hello World! '
+ }, 2000 )
+
+ watch ( message , () => {
+ console . log ( ' 触发侦听 ' , message . value )
+ } )
+ },
+} )
可以看到,数据在初始化的时候并不会触发侦听回调,如果有需要的话,通过 immediate
选项来让它直接触发。
immediate
选项接受一个布尔值,默认是 false
,可以设置为 true
让回调立即执行。
改成这样,请留意高亮的代码部分和新的注释:
ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 这一次在这里可以会触发 watch 的回调了
+ const message = ref < string > ( '' )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ // 这一次,这里是第二次触发 watch 的回调,不再是第一次
+ message . value = ' Hello World! '
+ }, 2000 )
+
+ watch (
+ message ,
+ () => {
+ console . log ( ' 触发侦听 ' , message . value )
+ },
+ // 设置 immediate 选项
+ {
+ immediate : true ,
+ }
+ )
+ },
+} )
注意,在带有 immediate 选项时,不能在第一次回调时取消该数据源的侦听,详见 停止侦听 部分。
侦听选项之 flush flush
选项是用来控制 侦听回调 的调用时机,接受指定的字符串,可选值如下,默认是 'pre'
。
可选值 回调的调用时机 使用场景 'pre' 将在渲染前被调用 允许回调在模板运行前更新了其他值 'sync' 在渲染时被同步调用 目前来说没什么好处,可以了解但不建议用… 'post' 被推迟到渲染之后调用 如果要通过 ref 操作 DOM 元素与子组件 ,需要使用这个值来启用该选项,以达到预期的执行效果
对于 'pre'
和 'post'
,回调使用队列进行缓冲。回调只被添加到队列中一次。
即使观察值变化了多次,值的中间变化将被跳过,不会传递给回调,这样做不仅可以提高性能,还有助于保证数据的一致性。
更多关于 flush 的信息,请参阅 回调的触发时机 。
停止侦听 如果在 setup 或者 script-setup 里使用 watch 的话, 组件被卸载 的时候也会一起被停止,一般情况下不太需要关心如何停止侦听。
不过有时候可能想要手动取消, Vue 3 也提供了方法。
TIP
随着组件被卸载一起停止的前提是,侦听器必须是 同步语句 创建的,这种情况下侦听器会绑定在当前组件上。
如果放在 setTimeout
等 异步函数 里面创建,则不会绑定到当前组件,因此组件卸载的时候不会一起停止该侦听器,这种时候就需要手动停止侦听。
在 API 的 TS 类型 有提到,当在定义一个 watch 行为的时候,它会返回一个用来停止侦听的函数。
这个函数的 TS 类型如下:
ts export declare type WatchStopHandle = () => void
用法很简单,做一下简单了解即可:
ts // 定义一个取消观察的变量,它是一个函数
+const unwatch = watch (message , () => {
+ // ...
+} )
+
+// 在合适的时期调用它,可以取消这个侦听
+unwatch ()
但是也有一点需要注意的是,如果启用了 immediate 选项 ,不能在第一次触发侦听回调时执行它。
ts // 注意:这是一段错误的代码,运行会报错
+const unwatch = watch (
+ message ,
+ // 侦听的回调
+ () => {
+ // ...
+ // 在这里调用会有问题 ❌
+ unwatch ()
+ },
+ // 启用 immediate 选项
+ {
+ immediate : true ,
+ }
+)
会收获一段报错,告诉 unwatch
这个变量在初始化前无法被访问:
bash Uncaught ReferenceError: Cannot access ' unwatch ' before initialization
目前有两种方案可以让实现这个操作:
方案一:使用 var
并判断变量类型,利用 var 的变量提升 来实现目的。
ts // 这里改成 var ,不要用 const 或 let
+var unwatch = watch (
+ message ,
+ // 侦听回调
+ () => {
+ // 这里加一个判断,是函数才执行它
+ if ( typeof unwatch === ' function ' ) {
+ unwatch ()
+ }
+ },
+ // 侦听选项
+ {
+ immediate : true ,
+ }
+)
不过 var
已经属于过时的语句了,建议用方案二的 let
。
方案二:使用 let
并判断变量类型。
ts // 如果不想用 any ,可以导入 TS 类型
+import type { WatchStopHandle } from ' vue '
+
+// 这里改成 let ,但是要另起一行,先定义,再赋值
+let unwatch : WatchStopHandle
+unwatch = watch (
+ message ,
+ // 侦听回调
+ () => {
+ // 这里加一个判断,是函数才执行它
+ if ( typeof unwatch === ' function ' ) {
+ unwatch ()
+ }
+ },
+ // 侦听选项
+ {
+ immediate : true ,
+ }
+)
侦听效果清理 在 侦听后的回调函数 部分提及到一个参数 onCleanup
,它可以帮注册一个清理函数。
有时 watch 的回调会执行异步操作,当 watch 到数据变更的时候,需要取消这些操作,这个函数的作用就用于此,会在以下情况调用这个清理函数:
watcher 即将重新运行的时候 watcher 被停止(组件被卸载或者被手动 停止侦听 ) TS 类型:
ts declare type OnCleanup = ( cleanupFn : () => void ) => void
用法方面比较简单,传入一个回调函数运行即可,不过需要注意的是,需要在停止侦听之前注册好清理行为,否则不会生效。
在 停止侦听 里的最后一个 immediate 例子的基础上继续添加代码,请注意注册的时机:
ts let unwatch : WatchStopHandle
+unwatch = watch (
+ message ,
+ ( newValue , oldValue , onCleanup ) => {
+ // 需要在停止侦听之前注册好清理行为
+ onCleanup ( () => {
+ console . log ( ' 侦听清理ing ' )
+ // 根据实际的业务情况定义一些清理操作 ...
+ } )
+ // 然后再停止侦听
+ if ( typeof unwatch === ' function ' ) {
+ unwatch ()
+ }
+ },
+ {
+ immediate : true ,
+ }
+)
watchEffect 如果一个函数里包含了多个需要侦听的数据,一个一个数据去侦听太麻烦了,在 Vue 3 ,可以直接使用 watchEffect API 来简化的操作。
API 的 TS 类型 这个 API 的类型如下,使用的时候需要传入一个副作用函数(相当于 watch 的 侦听后的回调函数 ),也可以根据的实际情况传入一些可选的 侦听选项 。
和 watch API 一样,它也会返回一个用于 停止侦听 的函数。
ts // watchEffect 部分的 TS 类型
+// ...
+export declare type WatchEffect = ( onCleanup : OnCleanup ) => void
+
+export declare function watchEffect (
+ effect : WatchEffect ,
+ options ?: WatchOptionsBase
+): WatchStopHandle
+// ...
副作用函数也会传入一个清理回调作为参数,和 watch 的 侦听效果清理 一样的用法。
可以理解为它是一个简化版的 watch ,具体简化在哪里呢?请看下面的用法示例。
用法示例 它立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。
ts import { defineComponent , ref , watchEffect } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 单独定义两个数据,后面用来分开改变数值
+ const name = ref < string > ( ' Petter ' )
+ const age = ref < number > ( 18 )
+
+ // 定义一个调用这两个数据的函数
+ const getUserInfo = (): void => {
+ console . log ( {
+ name : name . value ,
+ age : age . value ,
+ } )
+ }
+
+ // 2s后改变第一个数据
+ setTimeout ( () => {
+ name . value = ' Tom '
+ }, 2000 )
+
+ // 4s后改变第二个数据
+ setTimeout ( () => {
+ age . value = 20
+ }, 4000 )
+
+ // 直接侦听调用函数,在每个数据产生变化的时候,它都会自动执行
+ watchEffect ( getUserInfo )
+ },
+} )
和 watch 的区别 虽然理论上 watchEffect
是 watch
的一个简化操作,可以用来代替 批量侦听 ,但它们也有一定的区别:
watch
可以访问侦听状态变化前后的值,而 watchEffect
没有。
watch
是在属性改变的时候才执行,而 watchEffect
则默认会执行一次,然后在属性改变的时候也会执行。
第二点的意思,看下面这段代码可以有更直观的理解:
使用 watch :
ts export default defineComponent ( {
+ setup () {
+ const foo = ref < string > ( '' )
+
+ setTimeout ( () => {
+ foo . value = ' Hello World! '
+ }, 2000 )
+
+ function bar () {
+ console . log ( foo . value )
+ }
+
+ // 使用 watch 需要先手动执行一次
+ bar ()
+
+ // 然后当 foo 有变动时,才会通过 watch 来执行 bar()
+ watch ( foo , bar )
+ },
+} )
使用 watchEffect :
ts export default defineComponent ( {
+ setup () {
+ const foo = ref < string > ( '' )
+
+ setTimeout ( () => {
+ foo . value = ' Hello World! '
+ }, 2000 )
+
+ function bar () {
+ console . log ( foo . value )
+ }
+
+ // 可以通过 watchEffect 实现 bar() + watch(foo, bar) 的效果
+ watchEffect ( bar )
+ },
+} )
可用的侦听选项 虽然用法和 watch 类似,但也简化了一些选项,它的侦听选项 TS 类型如下:
ts // 只支持 base 类型
+export declare interface WatchOptionsBase extends DebuggerOptions {
+ flush ?: ' pre ' | ' post ' | ' sync '
+}
+// ...
+
+// 继承的 debugger 选项类型
+export declare interface DebuggerOptions {
+ onTrack ?: ( event : DebuggerEvent ) => void
+ onTrigger ?: ( event : DebuggerEvent ) => void
+}
+// ...
对比 watch API ,它不支持 deep 和 immediate ,请记住这一点,其他的用法是一样的。
flush
选项的使用详见 侦听选项之 flush ,onTrack
和 onTrigger
详见 侦听的选项 部分内容。
watchPostEffect watchEffect API 使用 flush: 'post'
选项时的别名,具体区别详见 侦听选项之 flush 部分。
TIP
Vue v3.2.0 及以上版本才支持该 API 。
watchSyncEffect watchEffect API 使用 flush: 'sync'
选项时的别名,具体区别详见 侦听选项之 flush 部分。
TIP
Vue v3.2.0 及以上版本才支持该 API 。
数据的计算 ~new 和 Vue 2.0 一样,数据的计算也是使用 computed
API ,它可以通过现有的响应式数据,去通过计算得到新的响应式变量,用过 Vue 2.0 的开发者应该不会太陌生,但是在 Vue 3.0 ,在使用方式上也是变化非常大!
TIP
这里的响应式数据,可以简单理解为通过 ref API 、 reactive API 定义出来的数据,当然 Vuex 、Vue Router 等 Vue 数据也都具备响应式,可以在 响应式数据的变化 了解。
用法变化 先从一个简单的用例来看看在 Vue 新旧版本的用法区别:
假设定义了两个分开的数据 firstName
名字和 lastName
姓氏,但是在 template 展示时,需要展示完整的姓名,那么就可以通过 computed
来计算一个新的数据:
回顾 Vue 2 在 Vue 2.0 ,computed
和 data
在同级配置,并且不可以和 data
里的数据同名重复定义:
ts // 在 Vue 2 的写法:
+export default {
+ data () {
+ return {
+ firstName : ' Bill ' ,
+ lastName : ' Gates ' ,
+ }
+ },
+ // 注意这里定义的变量,都要通过函数的形式来返回它的值
+ computed : {
+ // 普通函数可以直接通过熟悉的 this 来拿到 data 里的数据
+ fullName () {
+ return \`\${ this. firstName } \${ this. lastName }\`
+ },
+ // 箭头函数则需要通过参数来拿到实例上的数据
+ fullName2 : ( vm ) => \`\${ vm . firstName } \${ vm . lastName }\` ,
+ },
+}
这样在需要用到全名的地方,只需要通过 this.fullName
就可以得到 Bill Gates
。
了解 Vue 3 在 Vue 3.0 ,跟其他 API 的用法一样,需要先导入 computed
才能使用:
ts // 在 Vue 3 的写法:
+import { defineComponent , ref , computed } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 定义基本的数据
+ const firstName = ref < string > ( ' Bill ' )
+ const lastName = ref < string > ( ' Gates ' )
+
+ // 定义需要计算拼接结果的数据
+ const fullName = computed ( () => \`\${ firstName . value } \${ lastName . value }\` )
+
+ // 2s 后改变某个数据的值
+ setTimeout ( () => {
+ firstName . value = ' Petter '
+ }, 2000 )
+
+ // template 那边在 2s 后也会显示为 Petter Gates
+ return {
+ fullName ,
+ }
+ },
+} )
可以把这个用法简单的理解为,传入一个回调函数,并 return
一个值,对,它需要有明确的返回值。
TIP
需要注意的是:
定义出来的 computed
变量,和 Ref 变量的用法一样,也是需要通过 .value
才能拿到它的值
但是区别在于,默认情况下 computed
的 value
是只读的
原因详见下方的 类型声明 。
类型声明 之前说过,在 defineComponent 里,会自动帮推导 Vue API 的类型,所以一般情况下,是不需要显式的去定义 computed
出来的变量类型的。
在确实需要手动指定的情况下,也可以导入它的类型然后定义:
ts import { computed } from ' vue '
+import type { ComputedRef } from ' vue '
+
+// 注意这里添加了类型声明
+const fullName : ComputedRef < string > = computed (
+ () => \`\${ firstName . value } \${ lastName . value }\`
+)
要返回一个字符串,就写 ComputedRef<string>
;返回布尔值,就写 ComputedRef<boolean>
;返回一些复杂对象信息,可以先定义好的类型,再诸如 ComputedRef<UserInfo>
去写。
ts // 这是 ComputedRef 的类型声明:
+export declare interface ComputedRef < T = any > extends WritableComputedRef < T > {
+ readonly value : T
+ [ComoutedRefSymbol] : true
+}
优势对比和注意事项 在继续往下看之前,先来了解一下这个 API 的一些优势和注意事项(如果在 Vue 2 已经有接触过的话,可以跳过这一段,因为优势和需要注意的东西比较一致)。
优势对比 看到这里,相信刚接触的开发者可能会有疑问,既然 computed
也是通过一个函数来返回值,那么和普通的 function
有什么区别,或者说优势?
性能优势 这一点在 官网文档 其实是有提到的:
数据的计算是基于它们的响应依赖关系缓存的,只在相关响应式依赖发生改变时它们才会重新求值。
也就是说,只要原始数据没有发生改变,多次访问 computed
,都是会立即返回之前的计算结果,而不是再次执行函数;而普通的 function
调用多少次就执行多少次,每调用一次就计算一次。
至于为何要如此设计,官网文档也给出了原因:
为什么需要缓存?假设有一个性能开销比较大的计算数据 list,它需要遍历一个巨大的数组并做大量的计算。然后可能有其他的计算数据依赖于 list。如果没有缓存,将不可避免的多次执行 list 的 getter!如果不希望有缓存,请用 function 来替代。
书写统一 假定 foo1 是 Ref 变量, foo2 是 computed
变量, foo3 是普通函数返回值
看到这里的开发者应该都已经清楚 Vue 3 的 Ref 变量是通过 foo1.value
来拿到值的,而 computed
也是通过 foo2.value
,并且在 template 里都可以省略 .value
,在读取方面,他们是有一致的风格和简洁性。
而 foo3 不管是在 script 还是 template ,都需要通过 foo3()
才能拿到结果,相对来说会有那么一丢丢别扭。
当然,关于这一点,如果涉及到的数据不是响应式数据,那么还是老老实实的用函数返回值吧,原因请见下面的 注意事项 。
注意事项 有优势当然也就有一定的 “劣势” ,当然这也是 Vue 框架的有意为之,所以在使用上也需要注意一些问题:
只会更新响应式数据的计算 假设要获取当前的时间信息,因为不是响应式数据,所以这种情况下就需要用普通的函数去获取返回值,才能拿到最新的时间。
ts const nowTime = computed ( () => new Date ())
+console . log (nowTime . value)
+// 输出 Sun Nov 14 2021 21:07:00 GMT+0800 (GMT+08:00)
+
+// 2s 后依然是跟上面一样的结果
+setTimeout ( () => {
+ console . log ( nowTime . value )
+ // 还是输出 Sun Nov 14 2021 21:07:00 GMT+0800 (GMT+08:00)
+}, 2000 )
数据是只读的 通过 computed 定义的数据,它是只读的,这一点在 类型声明 已经有所了解。
如果直接赋值,不仅无法变更数据,而且会收获一个报错。
bash TS2540: Cannot assign to ' value ' because it is a read-only property.
虽然无法直接赋值,但是在必要的情况下,依然可以通过 computed
的 setter
来更新数据。
点击了解:computed 的 setter 用法
setter 的使用 通过 computed 定义的变量默认都是只读的形式(只有一个 getter ),但是在必要的情况下,也可以使用其 setter 属性来更新数据。
基本格式 当需要用到 setter 的时候, computed
就不再是一个传入 callback 的形式了,而是传入一个带有 2 个方法的对象。
ts // 注意这里computed接收的入参已经不再是函数
+const foo = computed ( {
+ // 这里需要明确的返回一个值
+ get () {
+ // ...
+ },
+ // 这里接收一个参数,代表修改 foo 时,赋值下来的新值
+ set ( newValue ) {
+ // ...
+ },
+} )
这里的 get
就是 computed
的 getter ,跟原来传入 callback 的形式一样,是用于 foo.value
的读取,所以这里必须有明确的返回值。
这里的 set
就是 computed
的 setter ,它会接收一个参数,代表新的值,当通过 foo.value = xxx
赋值的时候,赋入的这个值,就会通过这个入参来传递进来,可以根据的业务需要,把这个值,赋给相关的数据源。
TIP
请注意,必须使用 get
和 set
这 2 个方法名,也只接受这 2 个方法。
在了解了基本格式后,可以查看下面的例子来了解具体的用法。
使用示范 官网的 例子 是一个 Options API 的案例,这里改成 Composition API 的写法来演示:
ts // 还是这2个数据源
+const firstName = ref < string > ( ' Bill ' )
+const lastName = ref < string > ( ' Gates ' )
+
+// 这里配合setter的需要,改成了另外一种写法
+const fullName = computed ( {
+ // getter还是返回一个拼接起来的全名
+ get () {
+ return \`\${ firstName . value } \${ lastName . value }\`
+ },
+ // setter这里改成只更新firstName,注意参数也定义TS类型
+ set ( newFirstName : string ) {
+ firstName . value = newFirstName
+ },
+} )
+console . log (fullName . value) // 输出 Bill Gates
+
+// 2s后更新一下数据
+setTimeout ( () => {
+ // 对fullName的赋值,其实更新的是firstName
+ fullName . value = ' Petter '
+
+ // 此时firstName已经得到了更新
+ console . log ( firstName . value ) // 会输出 Petter
+
+ // 当然,由于firstName变化了,所以fullName的getter也会得到更新
+ console . log ( fullName . value ) // 会输出 Petter Gates
+}, 2000 )
应用场景 计算 API 的作用,官网文档只举了一个非常简单的例子,那么在实际项目中,什么情况下用它会让更方便呢?
简单举几个比较常见的例子吧,加深一下对 computed
的理解。
数据的拼接和计算 如上面的案例,与其每个用到的地方都要用到 firstName + ' ' + lastName
这样的多变量拼接,不如用一个 fullName
来的简单。
当然,不止是字符串拼接,数据的求和等操作更是合适,比如说做一个购物车,购物车里有商品列表,同时还要显示购物车内的商品总金额,这种情况就非常适合用计算数据。
复用组件的动态数据 在一个项目里,很多时候组件会涉及到复用,比如说:“首页的文章列表 vs 列表页的文章列表 vs 作者详情页的文章列表” ,特别常见于新闻网站等内容资讯站点,这种情况下,往往并不需要每次都重新写 UI 、数据渲染等代码,仅仅是接口 URL 的区别。
这种情况就可以通过路由名称来动态获取要调用哪个列表接口:
ts const route = useRoute ()
+
+// 定义一个根据路由名称来获取接口URL的计算数据
+const apiUrl = computed ( () => {
+ switch ( route . name ) {
+ // 首页
+ case ' home ' :
+ return ' /api/list1 '
+ // 列表页
+ case ' list ' :
+ return ' /api/list2 '
+ // 作者页
+ case ' author ' :
+ return ' /api/list3 '
+ // 默认是随机列表
+ default :
+ return ' /api/random '
+ }
+} )
+
+// 请求列表
+const getArticleList = async (): Promise < void > => {
+ // ...
+ articleList . value = await axios ( {
+ method : ' get ' ,
+ url : apiUrl . value ,
+ // ...
+ } )
+ // ...
+}
当然,这种情况也可以在父组件通过 props
传递接口 URL ,如果已经学到了 组件通讯 一章的话。
获取多级对象的值 应该很经常的遇到要在 template 显示一些多级对象的字段,但是有时候又可能存在某些字段不一定有,需要做一些判断的情况,虽然有 v-if
,但是嵌套层级一多,模板代码会难以维护。
如果把这些工作量转移给计算数据,结合 try / catch
,这样就无需在 template 里处理很多判断了。
ts // 例子比较极端,但在 Vuex 这种大型数据树上,也不是完全不可能存在
+const foo = computed ( () => {
+ // 正常情况下返回需要的数据
+ try {
+ return store . state . foo3 . foo2 . foo1 . foo
+ } catch ( e ) {
+ // 处理失败则返回一个默认值
+ return ''
+ }
+} )
这样在 template 里要拿到 foo 的值,完全不需要关心中间一级又一级的字段是否存在,只需要区分是不是默认值。
不同类型的数据转换 有时候会遇到一些需求类似于,让用户在输入框里,按一定的格式填写文本,比如用英文逗号 ,
隔开每个词,然后保存的时候,是用数组的格式提交给接口。
这个时候 computed
的 setter 就可以妙用了,只需要一个简单的 computed
,就可以代替 input
的 change
事件或者 watch
侦听,可以减少很多业务代码的编写。
vue < template >
+ < input
+ type = " text "
+ v-model = " tagsStr "
+ placeholder = " 请输入标签,多个标签用英文逗号隔开 "
+ />
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , computed , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 这个是最终要用到的数组
+ const tags = ref < string [] > ([])
+
+ // 因为input必须绑定一个字符串
+ const tagsStr = computed ( {
+ // 所以通过getter来转成字符串
+ get () {
+ return tags . value . join ( ' , ' )
+ },
+ // 然后在用户输入的时候,切割字符串转换回数组
+ set ( newValue : string ) {
+ tags . value = newValue . split ( ' , ' )
+ },
+ } )
+
+ return {
+ tagsStr ,
+ }
+ },
+} )
+</ script >
所以在实际业务开发中,开发者可以多考虑一下是否可以使用 computed
代替 watch
,避免过多的数组侦听带来项目性能的下降。
指令 指令是 Vue 模板语法里的特殊标记,在使用上和 HTML 的 data-* 属性十分相似,统一以 v-
开头( e.g. v-html
)。
它以简单的方式实现了常用的 JavaScript 表达式功能,当表达式的值改变的时候,响应式地作用到 DOM 上。
内置指令 Vue 提供了一些内置指令可以直接使用,例如:
vue < template >
+ <!-- 渲染一段文本 -->
+ < span v-text = " msg " ></ span >
+
+ <!-- 渲染一段 HTML -->
+ < div v-html = " html " ></ div >
+
+ <!-- 循环创建一个列表 -->
+ < ul v-if = " items.length " >
+ < li v-for = " (item, index) in items " :key = " index " >
+ < span > {{ item }} </ span >
+ </ li >
+ </ ul >
+
+ <!-- 一些事件( \`@\` 等价于 \`v-on\` ) -->
+ < button @click = " hello " > Hello </ button >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const msg = ref < string > ( ' Hello World! ' )
+ const html = ref < string > ( ' <p>Hello World!</p> ' )
+ const items = ref < string [] > ([ ' a ' , ' b ' , ' c ' , ' d ' ])
+
+ function hello () {
+ console . log ( msg . value )
+ }
+
+ return {
+ msg ,
+ html ,
+ items ,
+ hello ,
+ }
+ },
+} )
+</ script >
内置指令在使用上都非常的简单,可以在官方文档的 内置指令 一章查询完整的指令列表和用法,在模板上使用时,请了解 指令的模板语法 。
TIP
有两个指令可以使用别名:
v-on
的别名是 @
,使用 @click
等价于 v-on:click
v-bind
的别名是 :
,使用 :src
等价于 v-bind:src
自定义指令 ~new 如果 Vue 的内置指令不能满足业务需求,还可以开发自定义指令。
相关的 TS 类型 在开始编写代码之前,先了解一下自定义指令相关的 TypeScript 类型。
自定义指令有两种实现形式,一种是作为一个对象,其中的写法比较接近于 Vue 组件,除了 getSSRProps 和 deep 选项 外,其他的每一个属性都是一个 钩子函数 ,下一小节会介绍钩子函数的内容。
ts // 对象式写法的 TS 类型
+// ...
+export declare interface ObjectDirective < T = any , V = any > {
+ created ?: DirectiveHook < T , null , V >
+ beforeMount ?: DirectiveHook < T , null , V >
+ mounted ?: DirectiveHook < T , null , V >
+ beforeUpdate ?: DirectiveHook < T , VNode < any , T >, V >
+ updated ?: DirectiveHook < T , VNode < any , T >, V >
+ beforeUnmount ?: DirectiveHook < T , null , V >
+ unmounted ?: DirectiveHook < T , null , V >
+ getSSRProps ?: SSRDirectiveHook
+ deep ?: boolean
+}
+// ...
另外一种是函数式写法,只需要定义成一个函数,但这种写法只在 mounted
和 updated
这两个钩子生效,并且触发一样的行为。
ts // 函数式写法的 TS 类型
+// ...
+export declare type FunctionDirective < T = any , V = any > = DirectiveHook <
+ T ,
+ any ,
+ V
+>
+// ...
这是每个钩子函数对应的类型,它有 4 个入参:
ts // 钩子函数的 TS 类型
+// ...
+export declare type DirectiveHook <
+ T = any ,
+ Prev = VNode < any , T > | null ,
+ V = any
+> = (
+ el : T ,
+ binding : DirectiveBinding < V >,
+ vnode : VNode < any , T >,
+ prevVNode : Prev
+) => void
+// ...
钩子函数第二个参数的类型:
ts // 钩子函数第二个参数的 TS 类型
+// ...
+export declare interface DirectiveBinding < V = any > {
+ instance : ComponentPublicInstance | null
+ value : V
+ oldValue : V | null
+ arg ?: string
+ modifiers : DirectiveModifiers
+ dir : ObjectDirective < any , V >
+}
+// ...
可以看到自定义指令最核心的就是 “钩子函数” 了,接下来来了解这部分的知识点。
钩子函数 和 组件的生命周期 类似,自定义指令里的逻辑代码也有一些特殊的调用时机,在这里称之为钩子函数:
钩子函数 调用时机 created 在绑定元素的 attribute 或事件侦听器被应用之前调用 beforeMount 当指令第一次绑定到元素并且在挂载父组件之前调用 mounted 在绑定元素的父组件被挂载后调用 beforeUpdate 在更新包含组件的 VNode 之前调用 updated 在包含组件的 VNode 及其子组件的 VNode 更新后调用 beforeUnmount 在卸载绑定元素的父组件之前调用 unmounted 当指令与元素解除绑定且父组件已卸载时,只调用一次
TIP
因为自定义指令的默认写法是一个对象,所以在代码风格上是遵循 Options API 的生命周期命名,而非 Vue 3 的 Composition API 风格。
钩子函数在用法上就是这样子:
ts const myDirective = {
+ created ( el , binding , vnode , prevVnode ) {
+ // ...
+ },
+ mounted ( el , binding , vnode , prevVnode ) {
+ // ...
+ },
+ // 其他钩子...
+}
在 相关的 TS 类型 已了解,每个钩子函数都有 4 个入参:
参数 作用 el 指令绑定的 DOM 元素,可以直接操作它 binding 一个对象数据,见下方的单独说明 vnode el 对应在 Vue 里的虚拟节点信息 prevVNode Update 时的上一个虚拟节点信息,仅在 beforeUpdate
和 updated
可用
其中用的最多是 el
和 binding
了。
属性 作用 value 传递给指令的值,例如 v-foo="bar"
里的 bar
,支持任意有效的 JS 表达式 oldValue 指令的上一个值,仅对 beforeUpdate
和 updated
可用 arg 传给指令的参数,例如 v-foo:bar
里的 bar
modifiers 传给指令的修饰符,例如 v-foo.bar
里的 bar
instance 使用指令的组件实例 dir 指令定义的对象(就是上面的 const myDirective = { /* ... */ }
这个对象)
在了解了指令的写法和参数作用之后,来看看如何注册一个自定义指令。
局部注册 自定义指令可以在单个组件内定义并使用,通过和 setup 函数 同级别的 directives
选项进行定义,可以参考下面的例子和注释:
vue < template >
+ <!-- 这个使用默认值 \`unset\` -->
+ < div v-highlight > {{ msg }} </ div >
+
+ <!-- 这个使用传进去的黄色 -->
+ < div v-highlight = " \`yellow\` " > {{ msg }} </ div >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ // 自定义指令在这里编写,和 \`setup\` 同级别
+ directives : {
+ // \`directives\` 下的每个字段名就是指令名称
+ highlight : {
+ // 钩子函数
+ mounted ( el , binding ) {
+ el . style . backgroundColor =
+ typeof binding . value === ' string ' ? binding . value : ' unset '
+ },
+ },
+ },
+ setup () {
+ const msg = ref < string > ( ' Hello World! ' )
+
+ return {
+ msg ,
+ }
+ },
+} )
+</ script >
上面是对象式的写法,也可以写成函数式:
ts export default defineComponent ( {
+ directives : {
+ highlight ( el , binding ) {
+ el . style . backgroundColor =
+ typeof binding . value === ' string ' ? binding . value : ' unset '
+ },
+ },
+} )
TIP
局部注册的自定义指令,默认在子组件内生效,子组件内无需重新注册即可使用父组件的自定义指令。
全局注册 自定义指令也可以注册成全局,这样就无需在每个组件里定义了,只要在入口文件 main.ts
里启用它,任意组件里都可以使用自定义指令。
请查看 开发本地 Vue 专属插件 一节的内容了解如何注册一个全局的自定义指令插件。
deep 选项 除了 钩子函数 ,在 相关的 TS 类型 里还可以看到有一个 deep 选项,它是一个布尔值,作用是:
如果自定义指令用于一个有嵌套属性的对象,并且需要在嵌套属性更新的时候触发 beforeUpdate
和 updated
钩子,那么需要将这个选项设置为 true
才能够生效。
vue < template >
+ < div v-foo = " foo " ></ div >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , reactive } from ' vue '
+
+export default defineComponent ( {
+ directives : {
+ foo : {
+ beforeUpdate ( el , binding ) {
+ console . log ( ' beforeUpdate ' , binding )
+ },
+ updated ( el , binding ) {
+ console . log ( ' updated ' , binding )
+ },
+ mounted ( el , binding ) {
+ console . log ( ' mounted ' , binding )
+ },
+ // 需要设置为 \`true\` ,如果是 \`false\` 则不会触发
+ deep : true ,
+ },
+ },
+ setup () {
+ // 定义一个有嵌套属性的对象
+ const foo = reactive ( {
+ bar : {
+ baz : 1 ,
+ },
+ } )
+
+ // 2s 后修改其中一个值,会触发 \`beforeUpdate\` 和 \`updated\`
+ setTimeout ( () => {
+ foo . bar . baz = 2
+ console . log ( foo )
+ }, 2000 )
+
+ return {
+ foo ,
+ }
+ },
+} )
+</ script >
插槽 Vue 在使用子组件的时候,子组件在 template 里类似一个 HTML 标签,可以在这个子组件标签里传入任意模板代码以及 HTML 代码,这个功能就叫做 “插槽” 。
默认插槽 默认情况下,子组件使用 <slot />
标签即可渲染父组件传下来的插槽内容,例如:
在父组件这边:
vue < template >
+ < Child >
+ <!-- 注意这里,子组件标签里面传入了 HTML 代码 -->
+ < p > 这是插槽内容 </ p >
+ </ Child >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+import Child from ' @cp/Child.vue '
+
+export default defineComponent ( {
+ components : {
+ Child ,
+ },
+} )
+</ script >
在子组件这边:
vue < template >
+ < slot />
+</ template >
默认插槽非常简单,一个 <slot />
就可以了。
具名插槽 有时候可能需要指定多个插槽,例如一个子组件里有 “标题” 、 “作者”、 “内容” 等预留区域可以显示对应的内容,这时候就需要用到具名插槽来指定不同的插槽位。
子组件通过 name
属性来指定插槽名称:
vue < template >
+ <!-- 显示标题的插槽内容 -->
+ < div class = " title " >
+ < slot name = " title " />
+ </ div >
+
+ <!-- 显示作者的插槽内容 -->
+ < div class = " author " >
+ < slot name = " author " />
+ </ div >
+
+ <!-- 其他插槽内容放到这里 -->
+ < div class = " content " >
+ < slot />
+ </ div >
+</ template >
父组件通过 template
标签绑定 v-slot:name
格式的属性,来指定传入哪个插槽里:
vue < template >
+ < Child >
+ <!-- 传给标题插槽 -->
+ < template v-slot : title >
+ < h1 > 这是标题 </ h1 >
+ </ template >
+
+ <!-- 传给作者插槽 -->
+ < template v-slot : author >
+ < h1 > 这是作者信息 </ h1 >
+ </ template >
+
+ <!-- 传给默认插槽 -->
+ < p > 这是插槽内容 </ p >
+ </ Child >
+</ template >
v-slot:name
有一个别名 #name
语法,上面父组件的代码也相当于:
vue < template >
+ < Child >
+ <!-- 传给标题插槽 -->
+ < template # title >
+ < h1 > 这是标题 </ h1 >
+ </ template >
+
+ <!-- 传给作者插槽 -->
+ < template # author >
+ < h1 > 这是作者信息 </ h1 >
+ </ template >
+
+ <!-- 传给默认插槽 -->
+ < p > 这是插槽内容 </ p >
+ </ Child >
+</ template >
TIP
在使用具名插槽的时候,子组件如果不指定默认插槽,那么在具名插槽之外的内容将不会被渲染。
默认内容 可以给 slot
标签添加内容,例如 <slot>默认内容</slot>
,当父组件没有传入插槽内容时,会使用默认内容来显示,默认插槽和具名插槽均支持该功能。
注意事项 有一条规则需要记住:
父组件里的所有内容都是在父级作用域中编译的 子组件里的所有内容都是在子作用域中编译的 CSS 样式与预处理器 Vue 组件的 CSS 样式部分,Vue 3 保留着和 Vue 2 完全一样的写法。
编写组件样式表 最基础的写法,就是在 .vue
文件里添加一个 <style />
标签,即可在里面写 CSS 代码了。
vue < template >
+ < div >
+ <!-- HTML 代码 -->
+ </ div >
+</ template >
+
+< script lang = " ts " >
+ // TypeScript 代码
+</ script >
+
+< style >
+/* CSS 代码 */
+. msg {
+ width : 100% ;
+}
+. msg p {
+ color : # 333 ;
+ font-size : 14px ;
+}
+</ style >
动态绑定 CSS 动态绑定 CSS ,在 Vue 2 就已经存在了,在此之前常用的是 :class
和 :style
,现在在 Vue 3 ,还可以通过 v-bind
来动态修改了。
使用 :class 动态修改样式名 它是绑定在 DOM 元素上面的一个属性,跟 class="class-name"
这样的属性同级别,它非常灵活!
TIP
使用 :class
是用来动态修改样式名,也就意味着必须提前把样式名对应的样式表先写好!
假设已经提前定义好了这几个变量:
vue < script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const activeClass = ' active-class '
+ const activeClass1 = ' active-class1 '
+ const activeClass2 = ' active-class2 '
+ const isActive = true
+
+ return {
+ activeClass ,
+ activeClass1 ,
+ activeClass2 ,
+ isActive ,
+ }
+ },
+} )
+</ script >
如果只想绑定一个单独的动态样式,可以传入一个字符串:
vue < template >
+ < p :class = " activeClass " > Hello World! </ p >
+</ template >
如果有多个动态样式,也可以传入一个数组:
vue < template >
+ < p :class = " [activeClass1, activeClass2] " > Hello World! </ p >
+</ template >
还可以对动态样式做一些判断,这个时候传入一个对象:
vue < template >
+ < p :class = " { 'active-class': isActive } " > Hello World! </ p >
+</ template >
多个判断的情况下,记得也用数组套起来:
vue < template >
+ < p :class = " [{ activeClass1: isActive }, { activeClass2: !isActive }] " >
+ Hello World!
+ </ p >
+</ template >
那么什么情况下会用到 :class
呢?
最常见的场景,应该就是导航、选项卡了,比如要给一个当前选中的选项卡做一个突出高亮的状态,那么就可以使用 :class
来动态绑定一个样式。
vue < template >
+ < ul class = " list " >
+ < li
+ class = " item "
+ :class = " { cur: index === curIndex } "
+ v-for = " (item, index) in 5 "
+ :key = " index "
+ @click = " curIndex = index "
+ >
+ {{ item }}
+ </ li >
+ </ ul >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const curIndex = ref < number > ( 0 )
+
+ return {
+ curIndex ,
+ }
+ },
+} )
+</ script >
+
+< style scoped >
+. cur {
+ color : red ;
+}
+</ style >
这样就简单实现了一个点击切换选项卡高亮的功能。
使用 :style 动态修改内联样式 如果觉得使用 :class
需要提前先写样式,再去绑定样式名有点繁琐,有时候只想简简单单的修改几个样式,那么可以通过 :style
来处理。
默认的情况下,都是传入一个对象去绑定:
key
是符合 CSS 属性名的 “小驼峰式” 写法,或者套上引号的短横线分隔写法(原写法),例如在 CSS 里,定义字号是 font-size
,那么需要写成 fontSize
或者 'font-size'
作为它的键。
value
是 CSS 属性对应的 “合法值”,比如要修改字号大小,可以传入 13px
、0.4rem
这种带合法单位字符串值,但不可以是 13
这样的缺少单位的值,无效的 CSS 值会被过滤不渲染。
vue < template >
+ < p
+ :style = " {
+ fontSize: '13px',
+ 'line-height': 2,
+ color: '#ff0000',
+ textAlign: 'center',
+ } "
+ >
+ Hello World!
+ </ p >
+</ template >
如果有些特殊场景需要绑定多套 style
,需要在 script
先定义好各自的样式变量(也是符合上面说到的那几个要求的对象),然后通过数组来传入:
vue < template >
+ < p :style = " [style1, style2] " > Hello World! </ p >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const style1 = {
+ fontSize : ' 13px ' ,
+ ' line-height ' : 2 ,
+ }
+ const style2 = {
+ color : ' #ff0000 ' ,
+ textAlign : ' center ' ,
+ }
+
+ return {
+ style1 ,
+ style2 ,
+ }
+ },
+} )
+</ script >
使用 v-bind 动态修改 style ~new 当然,以上两种形式都是关于 <script />
和 <template />
部分的操作,如果觉得会给模板带来一定的维护成本的话,不妨考虑这个新方案,将变量绑定到 <style />
部分去。
TIP
请注意这是一个在 3.2.0
版本之后才被归入正式队列的新功能!如果需要使用它,请确保的 vue
的版本号在 3.2.0
以上,最好是保持最新版本。
先来看看基本的用法:
vue < template >
+ < p class = " msg " > Hello World! </ p >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const fontColor = ref < string > ( ' #ff0000 ' )
+
+ return {
+ fontColor ,
+ }
+ },
+} )
+</ script >
+
+< style scoped >
+. msg {
+ color : v-bind(fontColor) ;
+}
+</ style >
如上面的代码,将渲染出一句红色文本的 Hello World!
这其实是利用了现代浏览器支持的 CSS 变量来实现的一个功能(所以如果打算用它的话,需要提前注意一下兼容性噢,点击查看:CSS Variables 兼容情况 )。
它渲染到 DOM 上,其实也是通过绑定 style
来实现,可以看到渲染出来的样式是:
html < p class = " msg " data-v-7eb2bc79 = "" style = " --7eb2bc79-fontColor:#ff0000; " >
+ Hello World!
+</ p >
对应的 CSS 变成了:
css . msg [ data-v-7eb2bc79 ] {
+ color : var ( --7eb2bc79-fontColor );
+}
理论上 v-bind
函数可以在 Vue 内部支持任意的 JavaScript 表达式,但由于可能包含在 CSS 标识符中无效的字符,因此官方是建议在大多数情况下,用引号括起来,如:
css . text {
+ font-size : v-bind( ' theme.font.size ' ) ;
+}
由于 CSS 变量的特性,因此对 CSS 响应式属性的更改不会触发模板的重新渲染(这也是和 :class
与 :style
的最大不同)。
TIP
不管有没有开启 <style scoped> ,使用 v-bind
渲染出来的 CSS 变量,都会带上 scoped
的随机 hash 前缀,避免样式污染(永远不会意外泄漏到子组件中),所以请放心使用!
如果对 CSS 变量的使用还不是很了解的话,可以先阅读一下相关的基础知识点。
相关阅读:使用 CSS 自定义属性(变量) - MDN
样式表的组件作用域 CSS 不像 JS ,是没有作用域的概念的,一旦写了某个样式,直接就是全局污染。所以 BEM 命名法 等规范才应运而生。
但在 Vue 组件里,有两种方案可以避免出现这种污染问题:一个是 Vue 2 就有的 <style scoped>
,一个是 Vue 3 新推出的 <style module>
。
Style Scoped Vue 组件在设计的时候,就想到了一个很优秀的解决方案,通过 scoped
来支持创建一个 CSS 作用域,使这部分代码只运行在这个组件渲染出来的虚拟 DOM 上。
使用方式很简单,只需要在 <style />
上添加 scoped
属性:
vue <!-- 注意这里多了一个 \`scoped\` -->
+< style scoped >
+. msg {
+ width : 100% ;
+}
+. msg p {
+ color : # 333 ;
+ font-size : 14px ;
+}
+</ style >
编译后,虚拟 DOM 都会带有一个 data-v-xxxxx
这样的属性,其中 xxxxx
是一个随机生成的 Hash ,同一个组件的 Hash 是相同并且唯一的:
html < div class = " msg " data-v-7eb2bc79 >
+ < p data-v-7eb2bc79 > Hello World! </ p >
+</ div >
而 CSS 则也会带上与 HTML 相同的属性,从而达到样式作用域的目的。
css . msg [ data-v-7eb2bc79 ] {
+ width : 100% ;
+}
+. msg p [ data-v-7eb2bc79 ] {
+ color : # 333 ;
+ font-size : 14px ;
+}
使用 scoped
可以有效的避免全局样式污染,可以在不同的组件里面都使用相同的 className,而不必担心会相互覆盖,不必再定义很长很长的样式名来防止冲突了。
TIP
添加 scoped
生成的样式,只作用于当前组件中的元素,并且权重高于全局 CSS ,可以覆盖全局样式
Style Module ~new 这是在 Vue 3 才推出的一个新方案,和 <style scoped>
不同,scoped 是通过给 DOM 元素添加自定义属性的方式来避免冲突,而 <style module>
则更为激进,将会编译成 CSS Modules 。
对于 CSS Modules 的处理方式,也可以通过一个小例子来更直观的了解它:
css /* 案例来自阮一峰老师的博文《CSS Modules 用法教程》 */
+/* https://www.ruanyifeng.com/blog/2016/06/css_modules.html */
+
+/* 编译前 */
+. title {
+ color : red ;
+}
+
+/* 编译后 */
+. _3zyde4l1yATCOkgn-DBWEL {
+ color : red ;
+}
可以看出,是通过比较 “暴力” 的方式,把编写的 “好看的” 样式名,直接改写成一个随机 Hash 样式名,来避免样式互相污染。
所以回到 Vue 这边,看看 <style module>
是怎么操作的。
vue < template >
+ < p :class = " $style.msg " > Hello World! </ p >
+</ template >
+
+< style module >
+. msg {
+ color : # ff0000 ;
+}
+</ style >
于是,将渲染出一句红色文本的 Hello World!
。
TIP
使用这个方案,需要了解如何 使用 :class 动态修改样式名
如果单纯只使用 <style module>
,那么在绑定样式的时候,是默认使用 $style
对象来操作的
必须显示的指定绑定到某个样式,比如 $style.msg
,才能生效
如果单纯的绑定 $style
,并不能得到 “把全部样式名直接绑定” 的期望结果
如果指定的 className 是短横杆命名,比如 .user-name
,那么需要通过 $style['user-name']
去绑定
也可以给 module
进行命名,然后就可以通过命名的 “变量名” 来操作:
vue < template >
+ < p :class = " classes.msg " > Hello World! </ p >
+</ template >
+
+< style module = " classes " >
+. msg {
+ color : # ff0000 ;
+}
+</ style >
TIP
需要注意的一点是,一旦开启 <style module>
,那么在 <style module>
里所编写的样式都必须手动绑定才能生效,没有被绑定的样式虽然也会被编译,但不会主动生效到 DOM 上。
原因是编译出来的样式名已经变化,而原来的 DOM 未指定对应的样式名,或者指定的是编译前的命名,所以并不能匹配到正确的样式。
useCssModule ~new 这是一个全新的 API ,面向在 script 部分操作 CSS Modules 。
在上面的 CSS Modules 部分可以知道,可以在 style
定义好样式,然后在 template
部分通过变量名来绑定样式。
那么如果有一天有个需求,需要通过 v-html
来渲染 HTML 代码,那这里的样式岂不是凉凉了?当然不会!
Vue 3 提供了一个 Composition API useCssModule
来帮助在 setup
函数里操作的 CSS Modules (对,只能在 setup 或者 script setup 里使用)。
基本用法:
绑定多几个样式,再来操作:
vue < template >
+ < p :class = " $style.msg " >
+ < span :class = " $style.text " > Hello World! </ span >
+ </ p >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , useCssModule } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const style = useCssModule ()
+ console . log ( style )
+ },
+} )
+</ script >
+
+< style module >
+. msg {
+ color : # ff0000 ;
+}
+. text {
+ font-size : 14px ;
+}
+</ style >
可以看到打印出来的 style
是一个对象:
js {
+ msg : ' home_msg_37Xmr ' ,
+ text : ' home_text_2woQJ '
+}
所以来配合 模板字符串 的使用,看看刚刚说的,要通过 v-html
渲染出来的内容应该如何绑定样式:
vue < template >
+ < div v-html = " content " ></ div >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , useCssModule } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 获取样式
+ const style = useCssModule ()
+
+ // 编写模板内容
+ const content = \` <p class=" \${ style . msg } ">
+ <span class=" \${ style . text } ">Hello World! —— from v-html</span>
+ </p> \`
+
+ return {
+ content ,
+ }
+ },
+} )
+</ script >
+
+< style module >
+. msg {
+ color : # ff0000 ;
+}
+. text {
+ font-size : 14px ;
+}
+</ style >
是不是也非常简单?可能刚开始不太习惯,但写多几次其实也蛮好玩的这个功能!
另外,需要注意的是,如果是指定了 modules 的名称,那么必须传入对应的名称作为入参才可以正确拿到这些样式:
比如指定了一个 classes 作为名称:
vue < style module = " classes " >
+/* ... */
+</ style >
那么需要通过传入 classes 这个名称才能拿到样式,否则会是一个空对象:
ts const style = useCssModule ( ' classes ' )
TIP
在 const style = useCssModule()
的时候,命名是随意的,跟在 <style module="classes">
这里指定的命名没有关系。
深度操作符 ~new 在 样式表的组件作用域 部分了解到,使用 scoped 后,父组件的样式将不会渗透到子组件中,但也不能直接修改子组件的样式。
如果确实需要进行修改子组件的样式,必须通过 ::v-deep
(完整写法) 或者 :deep
(快捷写法) 操作符来实现。
TIP
旧版的深度操作符是 >>>
、 /deep/
和 ::v-deep
,现在 >>>
和 /deep/
已进入弃用阶段(虽然暂时还没完全移除)
同时需要注意的是,旧版 ::v-deep
的写法是作为组合器的方式,写在样式或者元素前面,如:::v-deep .class-name { /* ... */ }
,现在这种写法也废弃了。
现在不论是 ::v-deep
还是 :deep
,使用方法非常统一,来假设 .b 是子组件的样式名:
vue < style scoped >
+. a :deep( . b ) {
+ /* ... */
+}
+</ style >
编译后:
css . a [ data-v-f3f3eg9 ] . b {
+ /* ... */
+}
TIP
可以看到,新的 deep 写法是作为一个类似 JS “函数” 那样去使用,需要深度操作的样式或者元素名,作为 “入参” 去传入。
同理,如果使用 Less 或者 Stylus 这种支持嵌套写法的预处理器,也是可以这样去深度操作的:
less . a {
+ : deep( . b ) {
+ /* ... */
+ }
+}
另外,除了操作子组件的样式,那些通过 v-html
创建的 DOM 内容,也不受作用域内的样式影响,也可以通过深度操作符来实现样式修改。
使用 CSS 预处理器 在工程化的现在,可以说前端都几乎不写 CSS 了,都是通过 sass
、less
、stylus
等 CSS 预处理器来完成样式的编写。
为什么要用 CSS 预处理器?放一篇关于三大预处理器的点评,新开发者可以做个简单了解,具体的用法在对应的官网上有非常详细的说明。
可以查看了解:浅谈 CSS 预处理器,Sass、Less 和 Stylus
在 Vue 组件里使用预处理器非常简单,像 Vite 已内置了对预处理器文件的支持(可处理 .less
、 .scss
之类的预处理器扩展名文件),因此只需要安装对应的依赖到项目里。
这里以 Less 为例,先安装该预处理器:
bash # 因为是在开发阶段使用,所以添加到 \`devDependencies\`
+npm i -D less
接下来在 Vue 组件里,只需要在 <style />
标签上,通过 lang="less"
属性指定使用哪个预处理器,即可直接编写对应的代码:
vue < style lang = " less " scoped >
+// 定义颜色变量
+@ color-black : #333 ;
+@ color-red : #ff0000 ;
+
+// 父级标签
+. msg {
+ width : 100% ;
+ // 其子标签可以使用嵌套写法
+ p {
+ color : @ color-black ;
+ font-size : 14px ;
+ // 支持多级嵌套
+ span {
+ color : @ color-red ;
+ }
+ }
+}
+</ style >
编译后的 css 代码:
css . msg {
+ width : 100% ;
+}
+. msg p {
+ color : # 333333 ;
+ font-size : 14px ;
+}
+. msg p span {
+ color : # ff0000 ;
+}
预处理器也支持 scoped
,用法请查阅 样式表的组件作用域 部分。
`,729);function C(i,A,d,u,g,h){const l=n("ReadingTips"),p=n("GitalkComment"),o=n("ClientOnly");return t(),c("div",null,[F,s(l),D,s(o,null,{default:r(()=>[s(p,{issueId:46})]),_:1})])}const E=e(y,[["render",C]]);export{m as __pageData,E as default};
diff --git a/assets/component.md.b59f8845.lean.js b/assets/component.md.b59f8845.lean.js
new file mode 100644
index 00000000..2d260347
--- /dev/null
+++ b/assets/component.md.b59f8845.lean.js
@@ -0,0 +1,1949 @@
+/**
+ * name: learning-vue3
+ * version: v2.0.0
+ * description: A starting learning tutorial on Vue 3.0 + TypeScript, suitable for complete Vue novices and Vue 2.0 veterans, incorporating some of my own practical experience on the basis of official documents.
+ * author: chengpeiquan
+ * homepage: https://vue3.chengpeiquan.com
+ */
+import{_ as e,v as t,b as c,t as s,O as r,R as a,M as n}from"./chunks/framework.0d8bea05.js";const m=JSON.parse('{"title":"单组件的编写","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"component.md","filePath":"component.md"}'),y={name:"component.md"},F=a('单组件的编写 项目搭好了,第一个需要了解的是 Vue 组件的变化,由于这部分篇幅会非常大,所以会分成很多个小节,一部分一部分按照开发顺序来逐步了解。
因为 Vue 3 对 TypeScript 的支持真的是太完善了,并且 TypeScript 的发展趋势和市场需求度越来越高,所以接下来都将直接使用 TypeScript 进行编程。
TIP
对 TypeScript 不太熟悉的开发者,建议先阅读 快速上手 TypeScript 一章,有了一定的语言基础之后,再一边写代码一边加深印象。
',4),D=a(`全新的 setup 函数 ~new 在开始编写 Vue 组件之前,需要了解两个全新的前置知识点:
全新的 setup
函数,关系到组件的生命周期和渲染等问题 写 TypeScript 组件离不开的 defineComponent
API setup 的含义 Vue 3 的 Composition API 系列里,推出了一个全新的 setup
函数,它是一个组件选项,在创建组件之前执行,一旦 props 被解析,并作为组合式 API 的入口点。
TIP
说的通俗一点,就是在使用 Vue 3 生命周期的情况下,整个组件相关的业务代码,都可以放在 setup
里执行。
因为在 setup
之后,其他的生命周期才会被启用(点击了解:组件的生命周期 )。
基本语法:
ts // 这是一个基于 TypeScript 的 Vue 组件
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup ( props , context ) {
+ // 在这里声明数据,或者编写函数并在这里执行它
+
+ return {
+ // 需要给 \`<template />\` 用的数据或函数,在这里 \`return\` 出去
+ }
+ },
+} )
可以发现在这段代码里还导入了一个 defineComponent
API ,也是 Vue 3 带来的新功能,下文的 defineComponent 的作用 将介绍其用法。
在使用 setup
的情况下,请牢记一点:不能再用 this
来获取 Vue 实例,也就是无法和 Vue 2 一样,通过 this.foo
、 this.bar()
这样来获取实例上的数据,或者执行实例上的方法。
关于全新的 Vue 3 组件编写,笔者将在下文一步步说明。
setup 的参数使用 setup
函数包含了两个入参:
参数 类型 含义 是否必传 props object 由父组件传递下来的数据 否 context object 组件的执行上下文 否
第一个参数 props
:
它是响应式的,当父组件传入新的数据时,它将被更新。
TIP
请不要解构它,这样会让数据失去响应性,一旦父组件发生数据变化,解构后的变量将无法同步更新为最新的值。
可以使用 Vue 3 全新的响应式 API toRef / toRefs 进行响应式数据转换,下文将会介绍全新的响应式 API 的用法。
第二个参数 context
:
context
只是一个普通的对象,它暴露三个组件的 Property :
属性 类型 作用 attrs 非响应式对象 未在 Props 里定义的属性都将变成 Attrs slots 非响应式对象 组件插槽,用于接收父组件传递进来的模板内容 emit 方法 触发父组件绑定下来的事件
因为 context
只是一个普通对象,所以可以直接使用 ES6 解构。
平时使用可以通过直接传入 { emit }
,即可用 emit('xxx')
来代替使用 context.emit('xxx')
,另外两个功能也是如此。
但是 attrs
和 slots
请保持 attrs.xxx
、slots.xxx
的方式来使用其数据,不要进行解构,虽然这两个属性不是响应式对象,但对应的数据会随组件本身的更新而更新。
两个参数的具体使用,可查阅 组件之间的通信 一章详细了解。
defineComponent 的作用 defineComponent
是 Vue 3 推出的一个全新 API ,可用于对 TypeScript 代码的类型推导,帮助开发者简化掉很多编码过程中的类型声明。
比如,原本需要这样才可以使用 setup
函数:
ts import { Slots } from ' vue '
+
+// 声明 \`props\` 和 \`return\` 的数据类型
+interface Data {
+ [ key : string ] : unknown
+}
+
+// 声明 \`context\` 的类型
+interface SetupContext {
+ attrs : Data
+ slots : Slots
+ emit : ( event : string , ... args : unknown [] ) => void
+}
+
+// 使用的时候入参要加上声明, \`return\` 也要加上声明
+export default {
+ setup ( props : Data , context : SetupContext ): Data {
+ // ...
+
+ return {
+ // ...
+ }
+ },
+}
每个组件都这样进行类型声明,会非常繁琐,如果使用了 defineComponent
,就可以省略这些类型声明:
ts import { defineComponent } from ' vue '
+
+// 使用 \`defineComponent\` 包裹组件的内部逻辑
+export default defineComponent ( {
+ setup ( props , context ) {
+ // ...
+
+ return {
+ // ...
+ }
+ },
+} )
代码量瞬间大幅度减少,只要是 Vue 本身的 API , defineComponent
都可以自动推导其类型,这样开发者在编写组件的过程中,只需要维护自己定义的数据类型就可以了,可专注于业务。
组件的生命周期 ~new 在了解了 Vue 3 组件的两个前置知识点后,不着急写组件,还需要先了解组件的生命周期,这个知识点非常重要,只有理解并记住组件的生命周期,才能够灵活的把控好每一处代码的执行,使程序的运行结果可以达到预期。
升级变化 从 Vue 2 升级到 Vue 3 ,在保留对 Vue 2 的生命周期支持的同时,Vue 3 也带来了一定的调整。
Vue 2 的生命周期写法名称是 Options API (选项式 API ), Vue 3 新的生命周期写法名称是 Composition API (组合式 API )。
Vue 3 组件默认支持 Options API ,而 Vue 2 可以通过 @vue/composition-api 插件获得 Composition API 的功能支持(其中 Vue 2.7 版本内置了该插件, 2.6 及以下的版本需要单独安装)。
为了减少理解成本,笔者将从读者的使用习惯上,使用 “ Vue 2 的生命周期” 代指 Options API 写法,用 “ Vue 3 的生命周期” 代指 Composition API 写法。
关于 Vue 生命周期的变化,可以从下表直观地了解:
Vue 2 生命周期 Vue 3 生命周期 执行时间说明 beforeCreate setup 组件创建前执行 created setup 组件创建后执行 beforeMount onBeforeMount 组件挂载到节点上之前执行 mounted onMounted 组件挂载完成后执行 beforeUpdate onBeforeUpdate 组件更新之前执行 updated onUpdated 组件更新完成之后执行 beforeDestroy onBeforeUnmount 组件卸载之前执行 destroyed onUnmounted 组件卸载完成后执行 errorCaptured onErrorCaptured 当捕获一个来自子孙组件的异常时激活钩子函数
可以看到 Vue 2 生命周期里的 beforeCreate
和 created
,在 Vue 3 里已被 setup
替代。
熟悉 Vue 2 的开发者应该都知道 Vue 有一个全局组件 <KeepAlive />
,用于在多个组件间动态切换时缓存被移除的组件实例,当组件被包含在 <KeepAlive />
组件里时,会多出两个生命周期钩子函数:
Vue 2 生命周期 Vue 3 生命周期 执行时间说明 activated onActivated 被激活时执行 deactivated onDeactivated 切换组件后,原组件消失前执行
TIP
虽然 Vue 3 依然支持 Vue 2 的生命周期,但是不建议混搭使用,前期可以继续使用 Vue 2 的生命周期作为过度阶段慢慢适应,但还是建议尽快熟悉并完全使用 Vue 3 的生命周期编写组件。
使用 3.x 的生命周期 在 Vue 3 的 Composition API 写法里,每个生命周期函数都要先导入才可以使用 ,并且所有生命周期函数统一放在 setup
里运行。
如果需要达到 Vue 2 的 beforeCreate
和 created
生命周期的执行时机,直接在 setup
里执行函数即可。
以下是几个生命周期的执行顺序对比:
ts import { defineComponent , onBeforeMount , onMounted } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ console . log ( 1 )
+
+ onBeforeMount ( () => {
+ console . log ( 2 )
+ } )
+
+ onMounted ( () => {
+ console . log ( 3 )
+ } )
+
+ console . log ( 4 )
+ },
+} )
最终将按照生命周期的顺序输出:
组件的基本写法 如果想在 Vue 2 里使用 TypeScript 编写组件,需要通过 Options API 的 Vue.extend 语法,或者是另外一种风格 Class Component 的语法声明组件,其中为了更好的进行类型推导, Class Component 语法更受开发者欢迎。
但是 Class Component 语法和默认的组件语法相差较大,带来了一定的学习成本,对于平时编写 JavaScript 代码很少使用 Class 的开发者,适应时间应该也会比较长。
因此 Vue 3 在保留对 Class Component 支持的同时,推出了全新的 Function-based Component ,更贴合 JavaScript 的函数式编程风格,这也是接下来要讲解并贯穿全文使用的 Composition API 新写法。
Composition API 虽然也是一个步伐迈得比较大的改动,但其组件结构并没有特别大的变化,区别比较大的地方在于组件生命周期和响应式 API 的使用,只要掌握了这些核心功能,上手 Vue 3 非常容易!
看到这里可能有开发者心里在想:
“这几种组件写法,加上视图部分又有 Template 和 TSX 的写法之分,生命周期方面 Vue 3 对 Vue 2 的写法又保持了兼容,在 Vue 里写 TypeScript 的组合方式一只手数不过来,在入门时选择合适的编程风格就遇到了困难,可怎么办?”
不用担心!笔者将九种常见的组合方式以表格的形式进行对比, Vue 3 组件最好的写法一目了然!
回顾 Vue 2 在 Vue 2 ,常用以下三种写法声明 TypeScript 组件:
适用版本 基本写法 视图写法 Vue 2 Vue.extend Template Vue 2 Class Component Template Vue 2 Class Component TSX
其中最接近 Options API 的写法是使用 Vue.extend API 声明组件:
ts // 这是一段摘选自 Vue 2 官网的代码演示
+import Vue from ' vue '
+
+// 推荐使用 Vue.extend 声明组件
+const Component = Vue . extend ( {
+ // 类型推断已启用
+} )
+
+// 不推荐这种方式声明
+const Component = {
+ // 这里不会有类型推断,
+ // 因为 TypeScript 不能确认这是 Vue 组件的选项
+}
而为了更好地获得 TypeScript 类型推导支持,通常使用 Class Component 的写法,这是 Vue 官方推出的一个装饰器插件(需要单独安装):
ts // 这是一段摘选自 Vue 2 官网的代码演示
+import Vue from ' vue '
+import Component from ' vue-class-component '
+
+// @Component 修饰符注明了此类为一个 Vue 组件
+@ Component ( {
+ // 所有的组件选项都可以放在这里
+ template : ' <button @click="onClick">Click!</button> ' ,
+} )
+
+// 使用 Class 声明一个组件
+export default class MyComponent extends Vue {
+ // 初始数据可以直接声明为实例的 property
+ message : string = ' Hello! '
+
+ // 组件方法也可以直接声明为实例的方法
+ onClick (): void {
+ window . alert ( this. message )
+ }
+}
可在 Vue 2 官网的 TypeScript 支持 一章了解更多配置说明。
了解 Vue 3 ~new Vue 3 从设计初期就考虑了 TypeScript 的支持,其中 defineComponent
这个 API 就是为了解决 Vue 2 对 TypeScript 类型推导不完善等问题而推出的。
在 Vue 3 ,至少有以下六种写法可以声明 TypeScript 组件:
适用版本 基本写法 视图写法 生命周期版本 官方是否推荐 Vue 3 Class Component Template Vue 2 × Vue 3 defineComponent Template Vue 2 × Vue 3 defineComponent Template Vue 3 √ Vue 3 Class Component TSX Vue 2 × Vue 3 defineComponent TSX Vue 2 × Vue 3 defineComponent TSX Vue 3 √
其中 defineComponent + Composition API + Template 的组合是 Vue 官方最为推荐的组件声明方式,本书接下来的内容都会以这种写法作为示范案例,也推荐开发者在学习的过程中,使用该组合进行入门。
下面看看如何使用 Composition API 编写一个最简单的 Hello World 组件:
vue <!-- Template 代码和 Vue 2 一样 -->
+< template >
+ < p class = " msg " > {{ msg }} </ p >
+</ template >
+
+<!-- Script 代码需要使用 Vue 3 的新写法-->
+< script lang = " ts " >
+// Vue 3 的 API 需要导入才能使用
+import { defineComponent } from ' vue '
+
+// 使用 \`defineComponent\` 包裹组件代码
+// 即可获得完善的 TypeScript 类型推导支持
+export default defineComponent ( {
+ setup () {
+ // 在 \`setup\` 方法里声明变量
+ const msg = ' Hello World! '
+
+ // 将需要在 \`<template />\` 里使用的变量 \`return\` 出去
+ return {
+ msg ,
+ }
+ },
+} )
+</ script >
+
+<!-- CSS 代码和 Vue 2 一样 -->
+< style scoped >
+. msg {
+ font-size : 14px ;
+}
+</ style >
可以看到 Vue 3 的组件也是 <template />
+ <script />
+ <style />
的三段式组合,上手非常简单。
其中 Template 沿用了 Vue 2 时期类似 HTML 风格的模板写法, Style 则是使用原生 CSS 语法或者 Less 等 CSS 预处理器编写。
但需要注意的是,在 Vue 3 的 Composition API 写法里,数据或函数如果需要在 <template />
中使用,就必须在 setup
里将其 return
出去,而仅在 <script />
里被调用的函数或变量,不需要渲染到模板则无需 return
。
响应式数据的变化 ~new 响应式数据是 MVVM 数据驱动编程的特色, Vue 的设计也是受 MVVM 模型的启发,相信大部分开发者选择 MVVM 框架都是因为数据驱动编程比传统的事件驱动编程要来得方便,而选择 Vue ,则是方便中的方便。
TIP
Model-View-ViewModel (简称 MVVM ) 是一种软件架构模式,将视图 UI 和业务逻辑分开,通过对逻辑数据的修改即可驱动视图 UI 的更新,因此常将这种编程方式称为 “数据驱动” ,与之对应的需要操作 DOM 才能完成视图更新的编程方式则称为 “事件驱动” 。
设计上的变化 作为最重要的一个亮点, Vue 3 的响应式数据在设计上和 Vue 2 有着很大的不同。
回顾 Vue 2 Vue 2 是使用了 Object.defineProperty
API 的 getter/setter
来实现数据的响应性,这个方法的具体用法可以参考 MDN 的文档: Object.defineProperty - MDN 。
下面使用 Object.defineProperty
实现一个简单的双向绑定 demo ,亲自敲代码试一下可以有更多的理解:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > DefineProperty Demo </ title >
+ </ head >
+ < body >
+ <!-- 输入框和按钮 -->
+ < div >
+ < input type = " text " id = " input " />
+ < button onclick = " vm . text = ' Hello World '" > 设置为 Hello World </ button >
+ </ div >
+ <!-- 输入框和按钮 -->
+
+ <!-- 文本展示 -->
+ < div id = " output " ></ div >
+ <!-- 文本展示 -->
+
+ < script >
+ // 声明一个响应式数据
+ const vm = {}
+ Object . defineProperty (vm , ' text ' , {
+ set ( value ) {
+ document . querySelector ( ' #input ' ) . value = value
+ document . querySelector ( ' #output ' ) . innerText = value
+ },
+ } )
+
+ // 处理输入行为
+ document . querySelector ( ' #input ' ) . oninput = function ( e ) {
+ vm . text = e . target . value
+ }
+ </ script >
+ </ body >
+</ html >
这个小 demo 实现了这两个功能:
输入框的输入行为只修改 vm.text
的数据,但会同时更新 output 标签的文本内容 点击按钮修改 vm.text
的数据,也会触发输入框和 output 文本的更新 当然 Vue 做了非常多的工作,而非只是简单的调用了 Object.defineProperty
,可以在官网 深入 Vue 2 的响应式原理 一章了解更多 Vue 2 的响应式原理。
了解 Vue 3 Vue 3 是使用了 Proxy
API 的 getter/setter
来实现数据的响应性,这个方法的具体用法可以参考 MDN 的文档: Proxy - MDN 。
同样的,也来实现一个简单的双向绑定 demo ,这次使用 Proxy
来实现:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > Proxy Demo </ title >
+ </ head >
+ < body >
+ <!-- 输入框和按钮 -->
+ < div >
+ < input type = " text " id = " input " />
+ < button onclick = " vm . text = ' Hello World '" > 设置为 Hello World </ button >
+ </ div >
+ <!-- 输入框和按钮 -->
+
+ <!-- 文本展示 -->
+ < div id = " output " ></ div >
+ <!-- 文本展示 -->
+
+ < script >
+ // 声明一个响应式数据
+ const vm = new Proxy (
+ {},
+ {
+ set ( obj , key , value ) {
+ document . querySelector ( ' #input ' ) . value = value
+ document . querySelector ( ' #output ' ) . innerText = value
+ },
+ }
+ )
+
+ // 处理输入行为
+ document . querySelector ( ' #input ' ) . oninput = function ( e ) {
+ vm . text = e . target . value
+ }
+ </ script >
+ </ body >
+</ html >
这个 demo 实现的功能和使用 Object.defineProperty
的 demo 是完全一样的,也都是基于 setter
的行为完成数据更新的实现,那么为什么 Vue 3 要舍弃 Object.defineProperty
,换成 Proxy
呢?
主要原因在于 Object.defineProperty
有以下的不足:
无法侦听数组下标的变化,通过 arr[i] = newValue
这样的操作无法实时响应 无法侦听数组长度的变化,例如通过 arr.length = 10
去修改数组长度,无法响应 只能侦听对象的属性,对于整个对象需要遍历,特别是多级对象更是要通过嵌套来深度侦听 使用 Object.assign()
等方法给对象添加新属性时,也不会触发更新 更多细节上的问题 … 这也是为什么 Vue 2 要提供一个 Vue.set API 的原因,可以在官网 Vue 2 中检测变化的注意事项 一章了解更多说明。
而这些问题在 Proxy
都可以得到解决,可以在官网 深入 Vue 3 的响应式原理 一章了解更多这部分的内容。
用法上的变化 本书只使用 Composition API 编写组件,这是使用 Vue 3 的最大优势。
TIP
虽然官方文档在各个 API 的使用上都做了一定的举例,但在实际使用过程中可能会遇到一些问题,常见的情况就是有些数据用着用着就失去了响应,或者是在 TypeScript 里出现类型不匹配的报错等等。
当然,一般遇到这种情况并不一定是框架的 BUG ,而可能是使用方式不对,本章节将结合笔者最初入门 Vue 3 时遇到的问题和解决问题的心得,复盘这些响应式 API 的使用。
相对于 Vue 2 在 data
里声明后即可通过 this.xxx
调用响应式数据,在 Vue 3 的生命周期里没有了 Vue 实例的 this
指向,需要导入 ref
、reactive
等响应式 API 才能声明并使用响应式数据。
ts // 这里导入的 \`ref\` 是一个响应式 API
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 通过响应式 API 创建的变量具备了响应性
+ const msg = ref < string > ( ' Hello World! ' )
+ },
+} )
由于 Vue 3 新的 API 非常多,但有些 API 的使用场景却不多,因此本书当前只对常用的部分 API 的使用和常见问题进行说明,更多的 API 可以在官方文档的 响应性 API 一章查阅。
响应式 API 之 ref ~new ref
是最常用的一个响应式 API,它可以用来定义所有类型的数据,包括 Node 节点和组件。
没错,在 Vue 2 常用的 this.$refs.xxx
来取代 document.querySelector('.xxx')
获取 Node 节点的方式,也是使用这个 API 来取代。
类型声明 在开始使用 API 之前,需要先了解在 TypeScript 中如何声明 Ref 变量的类型。
API 本身的类型 先看 API 本身, ref
API 是一个函数,通过接受一个泛型入参,返回一个响应式对象,所有的值都通过 .value
属性获取,这是 API 本身的 TS 类型:
ts // \`ref\` API 的 TS 类型
+function ref < T >( value : T ): Ref < UnwrapRef < T >>
+
+// \`ref\` API 的返回值的 TS 类型
+interface Ref < T > {
+ value : T
+}
因此在声明变量时,是使用尖括号 <>
包裹其 TS 类型,紧跟在 ref
API 之后:
ts // 显式指定 \`msg.value\` 是 \`string\` 类型
+const msg = ref < string > ( ' Hello World! ' )
再回看该 API 本身的类型,其中使用了 T
泛型,这表示在传入函数的入参时,可以不需要手动指定其 TS 类型, TypeScript 会根据这个 API 所返回的响应式对象的 .value
属性的类型,确定当前变量的类型。
因此也可以省略显式的类型指定,像下面这样声明变量,其类型交给 TypeScript 去自动推导:
ts // TypeScript 会推导 \`msg.value\` 是 \`string\` 类型
+const msg = ref ( ' Hello World ' )
对于声明时会赋予初始值,并且在使用过程中不会改变其类型的变量,是可以省略类型的显式指定的。
而如果有显式的指定的类型,那么在一些特殊情况下,初始化时可以不必赋值,这样 TypeScript 会自动添加 undefined
类型:
ts const msg = ref < string > ()
+console . log (msg . value) // undefined
+
+msg . value = ' Hello World! '
+console . log (msg . value) // Hello World!
因为入参留空时,虽然指定了 string
类型,但实际上此时的值是 undefined
,因此实际上这个时候的 msg.value
是一个 string | undefined
的联合类型。
对于声明时不知道是什么值,在某种条件下才进行初始化的情况,就可以省略其初始值,但是切记在调用该变量的时候对 .value
值进行有效性判断。
而如果既不显式指定类型,也不赋予初始值,那么会被默认为 any
类型,除非真的无法确认类型,否则不建议这么做。
API 返回值的类型 细心的开发者还会留意到 ref
API 类型里面还标注了一个返回值的 TS 类型:
ts interface Ref < T > {
+ value : T
+}
它是代表整个 Ref 变量的完整类型:
上文声明 Ref 变量时,提到的 string
类型都是指 msg.value
这个 .value
属性的类型 而 msg
这个响应式变量,其本身是 Ref<string>
类型 如果在开发过程中需要在函数里返回一个 Ref 变量,那么其 TypeScript 类型就可以这样写(请留意 Calculator
里的 num
变量的类型):
ts // 导入 \`ref\` API
+import { ref } from ' vue '
+// 导入 \`ref\` API 的返回值类型
+import type { Ref } from ' vue '
+
+// 声明 \`useCalculator\` 函数的返回值类型
+interface Calculator {
+ // 这里包含了一个 Ref 变量
+ num : Ref < number >
+ add : () => void
+}
+
+// 声明一个 “使用计算器” 的函数
+function useCalculator (): Calculator {
+ const num = ref < number > ( 0 )
+
+ function add () {
+ num . value ++
+ }
+
+ return {
+ num ,
+ add ,
+ }
+}
+
+// 在执行使用计算器函数时,可以获取到一个 Ref 变量和其他方法
+const { num , add } = useCalculator ()
+add ()
+console . log (num . value) // 1
上面这个简单的例子演示了如何手动指定 Ref 变量的类型,对于逻辑复用时的函数代码抽离、插件开发等场景非常有用!当然大部分情况下可以交给 TypeScript 自动推导,但掌握其用法,在必要的时候就派得上用场了!
变量的定义 在了解了如何对 Ref 变量进行类型声明之后,面对不同的数据类型,相信都得心应手了!但不同类型的值之间还是有少许差异和注意事项,例如上文提及到该 API 可以用来定义所有类型的数据,包括 Node 节点和组件,具体可以参考下文的示例。
基本类型 对字符串、布尔值等基本类型的定义方式,比较简单:
ts // 字符串
+const msg = ref < string > ( ' Hello World! ' )
+
+// 数值
+const count = ref < number > ( 1 )
+
+// 布尔值
+const isVip = ref < boolean > ( false )
引用类型 对于对象、数组等引用类型也适用,比如要定义一个对象:
ts // 先声明对象的格式
+interface Member {
+ id : number
+ name : string
+}
+
+// 在定义对象时指定该类型
+const userInfo = ref < Member > ( {
+ id : 1 ,
+ name : ' Tom ' ,
+} )
定义一个普通数组:
ts // 数值数组
+const uids = ref < number [] > ([ 1 , 2 , 3 ])
+
+// 字符串数组
+const names = ref < string [] > ([ ' Tom ' , ' Petter ' , ' Andy ' ])
定义一个对象数组:
ts // 声明对象的格式
+interface Member {
+ id : number
+ name : string
+}
+
+// 定义一个对象数组
+const memberList = ref < Member [] > ([
+ {
+ id : 1 ,
+ name : ' Tom ' ,
+ },
+ {
+ id : 2 ,
+ name : ' Petter ' ,
+ },
+])
DOM 元素与子组件 除了可以定义数据,ref
也有熟悉的用途,就是用来挂载节点,也可以挂在子组件上,也就是对应在 Vue 2 时常用的 this.$refs.xxx
获取 DOM 元素信息的作用。
模板部分依然是熟悉的用法,在要引用的 DOM 上添加一个 ref
属性:
vue < template >
+ <!-- 给 DOM 元素添加 \`ref\` 属性 -->
+ < p ref = " msg " > 请留意该节点,有一个 ref 属性 </ p >
+
+ <!-- 子组件也是同样的方式添加 -->
+ < Child ref = " child " />
+</ template >
在 <script />
部分有三个最基本的注意事项:
在 <template />
代码里添加的 ref
属性的值,是对应 <script />
里使用 ref
API 声明的变量的名称;
请保证视图渲染完毕后再执行 DOM 或组件的相关操作(需要放到生命周期的 onMounted
或者 nextTick
函数里,这一点在 Vue 2 也是一样);
该 Ref 变量必须 return
出去才可以给到 <template />
使用,这一点是 Vue 3 生命周期的硬性要求,子组件的数据和方法如果要给父组件操作,也要 return
出来才可以。
配合上面的 <template />
,来看看 <script />
部分的具体例子:
ts import { defineComponent , onMounted , ref } from ' vue '
+import Child from ' @cp/Child.vue '
+
+export default defineComponent ( {
+ components : {
+ Child ,
+ },
+ setup () {
+ // 定义挂载节点,声明的类型详见下方附表
+ const msg = ref < HTMLElement > ()
+ const child = ref < InstanceType <typeof Child >> ()
+
+ // 请保证视图渲染完毕后再执行节点操作 e.g. \`onMounted\` / \`nextTick\`
+ onMounted ( () => {
+ // 比如获取 DOM 的文本
+ console . log ( msg . value . innerText )
+
+ // 或者操作子组件里的数据
+ child . value . isShowDialog = true
+ } )
+
+ // 必须 \`return\` 出去才可以给到 \`<template />\` 使用
+ return {
+ msg ,
+ child ,
+ }
+ },
+} )
关于 DOM 和子组件的 TS 类型声明,可参考以下规则:
TIP
单纯使用 typeof Child
虽然可以获得 Child.vue 组件的 Props 和方法等提示,但在 VSCode 的类型推导还不够智能,缺乏更有效的代码补全支持。
上文使用的 InstanceType<T>
是 TypeScript 提供的一个工具类型,可以获取构造函数类型的实例类型,因此将组件的类型声明为 InstanceType<typeof Child>
,不仅可以得到更完善的类型提示,在编程过程中还可以让编辑器提供更完善的代码补全功能。
另外,关于这一小节,有一个可能会引起 TS 编译报错的情况是,一些脚手架创建出来的项目会默认启用 --strictNullChecks
选项,会导致案例中的代码无法正常编译,出现如下报错:
bash ❯ npm run build
+
+> hello-vue3@0.0.0 build
+> vue-tsc --noEmit && vite build
+
+src/views/home.vue:27:7 - error TS2532: Object is possibly ' undefined ' .
+
+27 child.value.isShowDialog = true
+ ~~~~~~~~~~~
+
+
+Found 1 error in src/views/home.vue:27
这是因为在默认情况下 null
和 undefined
是所有类型的子类型,但开启了 strictNullChecks
选项之后,会使 null
和 undefined
只能赋值给 void
和它们各自,这是一个更为严谨的选项,可以保障程序代码的健壮性,但对于刚接触 TypeScript 不久的开发者可能不太友好。
有以下几种解决方案可以参考:
在涉及到相关操作的时候,对节点变量增加一个判断: ts // 添加 \`if\` 分支,判断 \`.value\` 存在时才执行相关代码
+if (child . value) {
+ // 读取子组件的数据
+ console . log ( child . value . num )
+
+ // 执行子组件的方法
+ child . value . sayHi ( ' Use \`if\` in \`onMounted\` API. ' )
+}
通过 TS 的可选符 ?
将目标设置为可选,避免出现错误(这个方式不能直接修改子组件数据的值): ts // 读取子组件的数据(留意 \`.num\` 前面有一个 \`?\` 问号)
+console . log (child . value ?. num)
+
+// 执行子组件的方法(留意 \`.sayHi\` 前面有一个 \`?\` 问号)
+child . value ?. sayHi ( ' use ? in onMounted ' )
在项目根目录下的 tsconfig.json
文件里,显式的关闭 strictNullChecks
选项,关闭后,需要开发者在写代码的时候,自行把控好是否需要对 null
和 undefined
进行判断: json {
+ " compilerOptions " : {
+ // ...
+ " strictNullChecks " : false
+ }
+ // ...
+}
使用 any 类型代替,但是写 TypeScript 还是尽量不要使用 any ,满屏的 AnyScript 不如直接使用 JavaScript 变量的读取与赋值 前面在介绍 API 类型的时候已经了解,通过 ref
声明的变量会全部变成对象,不管定义的是什么类型的值,都会转化为一个 Ref 对象,其中 Ref 对象具有指向内部值的单个 Property .value
。
也就是说,任何 Ref 对象的值都必须通过 xxx.value
才可以正确获取。
请牢记上面这句话,初拥 Vue 3 的开发者很多 BUG 都是由于这个问题引起的(包括笔者刚开始使用 Vue 3 的那段时间,嘿嘿)。
读取变量 平时对于普通变量的值,读取的时候都是直接调用其变量名即可:
ts // 读取一个字符串
+const msg : string = ' Hello World! '
+console . log (msg)
+
+// 读取一个数组
+const uids : number [] = [ 1 , 2 , 3 ]
+console . log (uids[ 1 ])
而 Ref 对象的值的读取,切记!必须通过 .value
!
ts // 读取一个字符串
+const msg = ref < string > ( ' Hello World! ' )
+console . log (msg . value)
+
+// 读取一个数组
+const uids = ref < number [] > ([ 1 , 2 , 3 ])
+console . log (uids . value[ 1 ])
为变量赋值 普通变量需要使用 let
声明才可以修改其值,由于 Ref 对象是个引用类型,所以可以使用 const
声明,直接通过 .value
修改。
ts // 声明一个字符串变量
+const msg = ref < string > ( ' Hi! ' )
+
+// 等待 1s 后修改它的值
+setTimeout ( () => {
+ msg . value = ' Hello! '
+}, 1000 )
因此日常业务中,像在对接服务端 API 的接口数据时,可以自由的使用 forEach
、map
、filter
等方法操作 Ref 数组,或者直接重置它,而不必担心数据失去响应性。
ts const data = ref < string [] > ([])
+
+// 提取接口的数据
+data . value = api . data . map ( ( item : any ) => item . text)
+
+// 重置数组
+data . value = []
为什么突然要说这个呢?因为涉及到下一部分的知识,关于 reactive
API 在使用上的注意事项。
响应式 API 之 reactive ~new reactive
是继 ref
之后最常用的一个响应式 API 了,相对于 ref
,它的局限性在于只适合对象、数组。
TIP
使用 reactive
的好处就是写法跟平时的对象、数组几乎一模一样,但它也带来了一些特殊注意点,请留意赋值部分的特殊说明。
类型声明与定义 reactive
变量的声明方式没有 ref
的变化那么大,基本上和普通变量一样,它的 TS 类型如下:
ts function reactive < T extends object >( target : T ): UnwrapNestedRefs < T >
可以看到其用法还是比较简单的,下面是一个 Reactive 对象的声明方式:
ts // 声明对象的类型
+interface Member {
+ id : number
+ name : string
+}
+
+// 定义一个对象
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Tom ' ,
+} )
下面是 Reactive 数组的声明方式:
ts const uids : number [] = reactive ([ 1 , 2 , 3 ])
还可以声明一个 Reactive 对象数组:
ts // 对象数组也是先声明其中的对象类型
+interface Member {
+ id : number
+ name : string
+}
+
+// 再定义一个为对象数组
+const userList : Member [] = reactive ([
+ {
+ id : 1 ,
+ name : ' Tom ' ,
+ },
+ {
+ id : 2 ,
+ name : ' Petter ' ,
+ },
+ {
+ id : 3 ,
+ name : ' Andy ' ,
+ },
+])
变量的读取与赋值 虽然 reactive
API 在使用上没有像 ref
API 一样有 .value
的心智负担,但也有一些注意事项要留意。
处理对象 Reactive 对象在读取字段的值,或者修改值的时候,与普通对象是一样的,这部分没有太多问题。
ts // 声明对象的类型
+interface Member {
+ id : number
+ name : string
+}
+
+// 定义一个对象
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Tom ' ,
+} )
+
+// 读取用户名
+console . log (userInfo . name)
+
+// 修改用户名
+userInfo . name = ' Petter '
处理数组 但是对于 Reactive 数组,和普通数组会有一些区别。
普通数组在 “重置” 或者 “修改值” 时都是可以直接操作:
ts // 定义一个普通数组
+let uids : number [] = [ 1 , 2 , 3 ]
+
+// 从另外一个对象数组里提取数据过来
+uids = api . data . map ( ( item : any ) => item . id)
+
+// 合并另外一个数组
+let newUids : number [] = [ 4 , 5 , 6 ]
+uids = [ ... uids , ... newUids]
+
+// 重置数组
+uids = []
Vue 2 在操作数组的时候,也可以和普通数组这样处理数据的变化,依然能够保持响应性,但在 Vue 3 ,如果使用 reactive
定义数组,则不能这么处理,必须只使用那些不会改变引用地址的操作。
笔者刚开始接触时,按照原来的思维去处理 reactive
数组,于是遇到了 “数据变了,但模板不会更新的问题” ,如果开发者在学习的过程中也遇到了类似的情况,可以从这里去入手排查问题所在。
举个例子,比如要从服务端 API 接口获取翻页数据时,通常要先重置数组,再异步添加数据,如果使用常规的重置,会导致这个变量失去响应性:
ts let uids : number [] = reactive ([ 1 , 2 , 3 ])
+
+/**
+ * 不推荐使用这种方式,会丢失响应性
+ * 异步添加数据后,模板不会响应更新
+ */
+uids = []
+
+// 异步获取数据后,模板依然是空数组
+setTimeout ( () => {
+ uids . push ( 1 )
+}, 1000 )
要让数据依然保持响应性,则必须在关键操作时,不破坏响应性 API ,以下是推荐的操作方式,通过重置数组的 length
长度来实现数据的重置:
ts const uids : number [] = reactive ([ 1 , 2 , 3 ])
+
+/**
+ * 推荐使用这种方式,不会破坏响应性
+ */
+uids . length = 0
+
+// 异步获取数据后,模板可以正确的展示
+setTimeout ( () => {
+ uids . push ( 1 )
+}, 1000 )
特别注意 不要对 Reactive 数据进行 ES6 的解构 操作,因为解构后得到的变量会失去响应性。
比如这些情况,在 2s 后都得不到新的 name 信息:
ts import { defineComponent , reactive } from ' vue '
+
+interface Member {
+ id : number
+ name : string
+}
+
+export default defineComponent ( {
+ setup () {
+ // 定义一个带有响应性的对象
+ const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+ } )
+
+ // 在 2s 后更新 \`userInfo\`
+ setTimeout ( () => {
+ userInfo . name = ' Tom '
+ }, 2000 )
+
+ // 这个变量在 2s 后不会同步更新
+ const newUserInfo : Member = { ... userInfo }
+
+ // 这个变量在 2s 后不会再同步更新
+ const { name } = userInfo
+
+ // 这样 \`return\` 出去给模板用,在 2s 后也不会同步更新
+ return {
+ ... userInfo ,
+ }
+ },
+} )
响应式 API 之 toRef 与 toRefs ~new 相信各位开发者看到这里时,应该已经对 ref
和 reactive
API 都有所了解了,为了方便开发者使用, Vue 3 还推出了两个与之相关的 API : toRef
和 toRefs
,都是用于 reactive
向 ref
转换。
各自的作用 这两个 API 在拼写上非常接近,顾名思义,一个是只转换一个字段,一个是转换所有字段,转换后将得到新的变量,并且新变量和原来的变量可以保持同步更新。
API 作用 toRef 创建一个新的 Ref 变量,转换 Reactive 对象的某个字段为 Ref 变量 toRefs 创建一个新的对象,它的每个字段都是 Reactive 对象各个字段的 Ref 变量
光看概念可能不容易理解,来看下面的例子,先声明一个 reactive
变量:
ts interface Member {
+ id : number
+ name : string
+}
+
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+} )
然后分别看看这两个 API 应该怎么使用。
使用 toRef 先看这个转换单个字段的 toRef
API ,了解了它的用法之后,再去看 toRefs
就很容易理解了。
API 类型和基本用法 toRef
API 的 TS 类型如下:
ts // \`toRef\` API 的 TS 类型
+function toRef < T extends object , K extends keyof T >(
+ object : T ,
+ key : K ,
+ defaultValue ?: T [ K ]
+): ToRef < T [ K ] >
+
+// \`toRef\` API 的返回值的 TS 类型
+type ToRef < T > = T extends Ref ? T : Ref < T >
通过接收两个必传的参数(第一个是 reactive
对象, 第二个是要转换的 key
),返回一个 Ref 变量,在适当的时候也可以传递第三个参数,为该变量设置默认值。
以上文声明好的 userInfo
为例,如果想转换 name
这个字段为 Ref 变量,只需要这样操作:
ts const name = toRef (userInfo , ' name ' )
+console . log (name . value) // Petter
等号左侧的 name
变量此时是一个 Ref 变量,这里因为 TypeScript 可以对其自动推导,因此声明时可以省略 TS 类型的显式指定,实际上该变量的类型是 Ref<string>
。
所以之后在读取和赋值时,就需要使用 name.value
来操作,在重新赋值时会同时更新 name
和 userInfo.name
的值:
ts // 修改前先查看初始值
+const name = toRef (userInfo , ' name ' )
+console . log (name . value) // Petter
+console . log (userInfo . name) // Petter
+
+// 修改 Ref 变量的值,两者同步更新
+name . value = ' Tom '
+console . log (name . value) // Tom
+console . log (userInfo . name) // Tom
+
+// 修改 Reactive 对象上该属性的值,两者也是同步更新
+userInfo . name = ' Jerry '
+console . log (name . value) // Jerry
+console . log (userInfo . name) // Jerry
这个 API 也可以接收一个 Reactive 数组,此时第二个参数应该传入数组的下标:
ts // 这一次声明的是数组
+const words = reactive ([ ' a ' , ' b ' , ' c ' ])
+
+// 通过下标 \`0\` 转换第一个 item
+const a = toRef (words , 0 )
+console . log (a . value) // a
+console . log (words[ 0 ]) // a
+
+// 通过下标 \`2\` 转换第三个 item
+const c = toRef (words , 2 )
+console . log (c . value) // c
+console . log (words[ 2 ]) // c
设置默认值 如果 Reactive 对象上有一个属性本身没有初始值,也可以传递第三个参数进行设置(默认值仅对 Ref 变量有效):
ts interface Member {
+ id : number
+ name : string
+ // 类型上新增一个属性,因为是可选的,因此默认值会是 \`undefined\`
+ age ?: number
+}
+
+// 声明变量时省略 \`age\` 属性
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+} )
+
+// 此时为了避免程序运行错误,可以指定一个初始值
+// 但初始值仅对 Ref 变量有效,不会影响 Reactive 字段的值
+const age = toRef (userInfo , ' age ' , 18 )
+console . log (age . value) // 18
+console . log (userInfo . age) // undefined
+
+// 除非重新赋值,才会使两者同时更新
+age . value = 25
+console . log (age . value) // 25
+console . log (userInfo . age) // 25
数组也是同理,对于可能不存在的下标,可以传入默认值避免项目的逻辑代码出现问题:
ts const words = reactive ([ ' a ' , ' b ' , ' c ' ])
+
+// 当下标对应的值不存在时,也是返回 \`undefined\`
+const d = toRef (words , 3 )
+console . log (d . value) // undefined
+console . log (words[ 3 ]) // undefined
+
+// 设置了默认值之后,就会对 Ref 变量使用默认值, Reactive 数组此时不影响
+const e = toRef (words , 4 , ' e ' )
+console . log (e . value) // e
+console . log (words[ 4 ]) // undefined
其他用法 这个 API 还有一个特殊用法,但不建议在 TypeScript 里使用。
在 toRef
的过程中,如果使用了原对象上面不存在的 key
,那么定义出来的 Ref 变量的 .value
值将会是 undefined
。
ts // 众所周知, Petter 是没有女朋友的
+const girlfriend = toRef (userInfo , ' girlfriend ' )
+console . log (girlfriend . value) // undefined
+console . log (userInfo . girlfriend) // undefined
+
+// 此时 Reactive 对象上只有两个 Key
+console . log (Object . keys (userInfo)) // ['id', 'name']
如果对这个不存在的 key
的 Ref 变量进行赋值,那么原来的 Reactive 对象也会同步增加这个 key
,其值也会同步更新。
ts // 赋值后,不仅 Ref 变量得到了 \`Marry\` , Reactive 对象也得到了 \`Marry\`
+girlfriend . value = ' Marry '
+console . log (girlfriend . value) // 'Marry'
+console . log (userInfo . girlfriend) // 'Marry'
+
+// 此时 Reactive 对象上有了三个 Key
+console . log (Object . keys (userInfo)) // ['id', 'name', 'girlfriend']
为什么强调不要在 TypeScript 里使用呢?因为在编译时,无法通过 TypeScript 的类型检查:
bash ❯ npm run build
+
+> hello-vue3@0.0.0 build
+> vue-tsc --noEmit && vite build
+
+src/views/home.vue:37:40 - error TS2345: Argument of type ' "girlfriend" '
+is not assignable to parameter of type ' keyof Member ' .
+
+37 const girlfriend = toRef ( userInfo, ' girlfriend ' )
+ ~~~~~~~~~~~~
+
+src/views/home.vue:39:26 - error TS2339: Property ' girlfriend ' does not exist
+on type ' Member ' .
+
+39 console.log ( userInfo.girlfriend ) // undefined
+ ~~~~~~~~~~
+
+src/views/home.vue:45:26 - error TS2339: Property ' girlfriend ' does not exist
+on type ' Member ' .
+
+45 console.log ( userInfo.girlfriend ) // ' Marry '
+ ~~~~~~~~~~
+
+
+Found 3 errors in the same file, starting at: src/views/home.vue:37
如果不得不使用这种情况,可以考虑使用 any 类型:
ts // 将该类型直接指定为 \`any\`
+type Member = any
+// 当然一般都是 \`const userInfo: any\`
+
+// 或者保持接口类型的情况下,允许任意键值
+interface Member {
+ [ key : string ] : any
+}
+
+// 使用 \`Record\` 也是同理
+type Member = Record < string , any >
但笔者还是更推荐保持良好的类型声明习惯,尽量避免这种用法。
使用 toRefs 在了解了 toRef
API 之后,来看看 toRefs
的用法。
API 类型和基本用法 先看看它的 TS 类型:
ts function toRefs < T extends object >(
+ object : T
+): {
+ [ K in keyof T ] : ToRef < T [ K ] >
+}
+
+type ToRef = T extends Ref ? T : Ref < T >
与 toRef
不同, toRefs
只接收了一个参数,是一个 reactive
变量。
ts interface Member {
+ id : number
+ name : string
+}
+
+// 声明一个 Reactive 变量
+const userInfo : Member = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+} )
+
+// 传给 \`toRefs\` 作为入参
+const userInfoRefs = toRefs (userInfo)
此时这个新的 userInfoRefs
变量,它的 TS 类型就不再是 Member
了,而应该是:
ts // 导入 \`toRefs\` API 的类型
+import type { ToRefs } from ' vue '
+
+// 上下文代码省略...
+
+// 将原来的类型传给 API 的类型
+const userInfoRefs : ToRefs < Member > = toRefs (userInfo)
也可以重新编写一个新的类型来指定它,因为每个字段都是与原来关联的 Ref 变量,所以也可以这样声明:
ts // 导入 \`ref\` API 的类型
+import type { Ref } from ' vue '
+
+// 上下文代码省略...
+
+// 新声明的类型每个字段都是一个 Ref 变量的类型
+interface MemberRefs {
+ id : Ref < number >
+ name : Ref < string >
+}
+
+// 使用新的类型进行声明
+const userInfoRefs : MemberRefs = toRefs (userInfo)
当然实际上日常使用时并不需要手动指定其类型, TypeScript 会自动推导,可以节约非常多的开发工作量。
和 toRef
API 一样,这个 API 也是可以对数组进行转换:
ts const words = reactive ([ ' a ' , ' b ' , ' c ' ])
+const wordsRefs = toRefs (words)
此时新数组的类型是 Ref<string>[]
,不再是原来的 string[]
类型。
解构与赋值 转换后的 Reactive 对象或数组支持 ES6 的解构,并且不会失去响应性,因为解构后的每一个变量都具备响应性。
ts // 为了提高开发效率,可以直接将 Ref 变量直接解构出来使用
+const { name } = toRefs (userInfo)
+console . log (name . value) // Petter
+
+// 此时对解构出来的变量重新赋值,原来的变量也可以同步更新
+name . value = ' Tom '
+console . log (name . value) // Tom
+console . log (userInfo . name) // Tom
这一点和直接解构 Reactive 变量有非常大的不同,直接解构 Reactive 变量,得到的是一个普通的变量,不再具备响应性。
这个功能在使用 Hooks 函数非常好用(在 Vue 3 里也叫可组合函数, Composable Functions ),还是以一个计算器函数为例,这一次将其修改为内部有一个 Reactive 的数据状态中心,在函数返回时解构为多个 Ref 变量:
ts import { reactive , toRefs } from ' vue '
+
+// 声明 \`useCalculator\` 数据状态类型
+interface CalculatorState {
+ // 这是要用来计算操作的数据
+ num : number
+ // 这是每次计算时要增加的幅度
+ step : number
+}
+
+// 声明一个 “使用计算器” 的函数
+function useCalculator () {
+ // 通过数据状态中心的形式,集中管理内部变量
+ const state : CalculatorState = reactive ( {
+ num : 0 ,
+ step : 10 ,
+ } )
+
+ // 功能函数也是通过数据中心变量去调用
+ function add () {
+ state . num += state . step
+ }
+
+ return {
+ ... toRefs ( state ) ,
+ add ,
+ }
+}
这样在调用 useCalculator
函数时,可以通过解构直接获取到 Ref 变量,不需要再进行额外的转换工作。
ts // 解构出来的 \`num\` 和 \`step\` 都是 Ref 变量
+const { num , step , add } = useCalculator ()
+console . log (num . value) // 0
+console . log (step . value) // 10
+
+// 调用计算器的方法,数据也是会得到响应式更新
+add ()
+console . log (num . value) // 10
为什么要进行转换 关于为什么要出这么两个 API ,官方文档没有特别说明,不过经过笔者在业务中的一些实际使用感受,以及在写上一节 reactive
的 特别注意 ,可能知道一些使用理由。
关于 ref
和 reactive
这两个 API 的好处就不重复了,但是在使用的过程中,各自都有不方便的地方:
ref
API 虽然在 <template />
里使用起来方便,但是在 <script />
里进行读取 / 赋值的时候,要一直记得加上 .value
,否则 BUG 就来了。
reactive
API 虽然在使用的时候,因为知道它本身是一个对象,所以不会忘记通过 foo.bar
这样的格式去操作,但是在 <template />
渲染的时候,又因此不得不每次都使用 foo.bar
的格式去渲染。
那么有没有办法,既可以在编写 <script />
的时候不容易出错,在写 <template />
的时候又比较简单呢?
于是, toRef
和 toRefs
因此诞生。
什么场景下比较适合使用它们 从便利性和可维护性来说,最好只在功能单一、代码量少的组件里使用,比如一个表单组件,通常表单的数据都放在一个对象里。
当然也可以把所有的数据都定义到一个 data
里,再去 data
里面取值,但是没有必要为了转换而转换,否则不如使用 Options API 风格。
在业务中的具体运用 继续使用上文一直在使用的 userInfo
来当案例,以一个用户信息表的小 demo 做个演示。
在 <script />
部分:
先用 reactive
定义一个源数据,所有的数据更新,都是修改这个对象对应的值,按照对象的写法维护数据
再通过 toRefs
定义一个给 <template />
使用的对象,这样可以得到一个每个字段都是 Ref 变量的新对象
在 return
的时候,对步骤 2 里的 toRefs
对象进行解构,这样导出去就是各个字段对应的 Ref 变量,而不是一整个对象
ts import { defineComponent , reactive , toRefs } from ' vue '
+
+interface Member {
+ id : number
+ name : string
+ age : number
+ gender : string
+}
+
+export default defineComponent ( {
+ setup () {
+ // 定义一个 reactive 对象
+ const userInfo = reactive ( {
+ id : 1 ,
+ name : ' Petter ' ,
+ age : 18 ,
+ gender : ' male ' ,
+ } )
+
+ // 定义一个新的对象,它本身不具备响应性,但是它的字段全部是 Ref 变量
+ const userInfoRefs = toRefs ( userInfo )
+
+ // 在 2s 后更新 \`userInfo\`
+ setTimeout ( () => {
+ userInfo . id = 2
+ userInfo . name = ' Tom '
+ userInfo . age = 20
+ }, 2000 )
+
+ // 在这里解构 \`toRefs\` 对象才能继续保持响应性
+ return {
+ ... userInfoRefs ,
+ }
+ },
+} )
在 <template />
部分:
由于 return
出来的都是 Ref 变量,所以在模板里可以直接使用 userInfo
各个字段的 key
,不再需要写很长的 userInfo.name
了。
vue < template >
+ < ul class = " user-info " >
+ < li class = " item " >
+ < span class = " key " > ID: </ span >
+ < span class = " value " > {{ id }} </ span >
+ </ li >
+
+ < li class = " item " >
+ < span class = " key " > name: </ span >
+ < span class = " value " > {{ name }} </ span >
+ </ li >
+
+ < li class = " item " >
+ < span class = " key " > age: </ span >
+ < span class = " value " > {{ age }} </ span >
+ </ li >
+
+ < li class = " item " >
+ < span class = " key " > gender: </ span >
+ < span class = " value " > {{ gender }} </ span >
+ </ li >
+ </ ul >
+</ template >
需要注意的问题 请注意是否有相同命名的变量存在,比如上面在 return
给 <template />
使用时,在解构 userInfoRefs
的时候已经包含了一个 name
字段,此时如果还有一个单独的变量也叫 name
,就会出现渲染上的数据显示问题。
此时它们在 <template />
里哪个会生效,取决于谁排在后面,因为 return
出去的其实是一个对象,在对象里,如果存在相同的 key
,则后面的会覆盖前面的。
下面这种情况,会以单独的 name
为渲染数据:
ts return {
+ ... userInfoRefs ,
+ name ,
+}
而下面这种情况,则是以 userInfoRefs
里的 name
为渲染数据:
ts return {
+ name ,
+ ... userInfoRefs ,
+}
所以当决定使用 toRef
和 toRefs
API 的时候,请注意这个特殊情况!
函数的声明和使用 ~new 在了解了响应式数据如何使用之后,接下来就要开始了解函数了。
在 Vue 2 ,函数通常是作为当前组件实例上的方法在 methods
里声明,然后再在 mounted
等生命周期里调用,或者是在模板里通过 Click 等行为触发,由于组件内部经常需要使用 this
获取组件实例,因此不能使用箭头函数。
js export default {
+ data : () => {
+ return {
+ num : 0 ,
+ }
+ },
+ mounted : function () {
+ this. add ()
+ },
+ methods : {
+ // 不可以使用 \`add: () => this.num++\`
+ add : function () {
+ this. num ++
+ },
+ },
+}
在 Vue 3 则灵活了很多,可以使用普通函数、 Class 类、箭头函数、匿名函数等等进行声明,可以将其写在 setup
里直接使用,也可以抽离在独立的 .js
/ .ts
文件里再导入使用。
需要在组件创建时自动执行的函数,其执行时机需要遵循 Vue 3 的生命周期,需要在模板里通过 @click
、@change
等行为触发,和变量一样,需要把函数名在 setup
里进行 return
出去。
下面是一个简单的例子,方便开发者更直观地了解:
vue < template >
+ < p > {{ msg }} </ p >
+
+ <!-- 在这里点击执行 \`return\` 出来的方法 -->
+ < button @click = " updateMsg " > 修改MSG </ button >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , onMounted , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const msg = ref < string > ( ' Hello World! ' )
+
+ // 这个要暴露给模板使用,必须 \`return\` 才可以使用
+ function updateMsg () {
+ msg . value = ' Hi World! '
+ }
+
+ // 这个要在页面载入时执行,无需 \`return\` 出去
+ const init = () => {
+ console . log ( ' init ' )
+ }
+
+ onMounted ( () => {
+ init ()
+ } )
+
+ return {
+ msg ,
+ updateMsg ,
+ }
+ },
+} )
+</ script >
数据的侦听 ~new 侦听数据变化也是组件里的一项重要工作,比如侦听路由变化、侦听参数变化等等。
Vue 3 在保留原来的 watch
功能之外,还新增了一个 watchEffect
帮助更简单的进行侦听。
watch 在 Vue 3 ,新版的 watch
和 Vue 2 的旧版写法对比,在使用方式上变化非常大!
回顾 Vue 2 在 Vue 2 是这样用的,和 data
、 methods
都在同级配置:
ts export default {
+ data () {
+ return {
+ // ...
+ }
+ },
+ // 注意这里,放在 \`data\` 、 \`methods\` 同个级别
+ watch : {
+ // ...
+ },
+ methods : {
+ // ...
+ },
+}
并且类型繁多,选项式 API 的类型如下:
ts watch : { [ key : string ]: string | Function | Object | Array }
联合类型过多,意味着用法复杂,下面是个很好的例子,虽然出自 官网 的用法介绍,但过于繁多的用法也反映出来对初学者不太友好,初次接触可能会觉得一头雾水:
ts export default {
+ data () {
+ return {
+ a : 1 ,
+ b : 2 ,
+ c : {
+ d : 4 ,
+ },
+ e : 5 ,
+ f : 6 ,
+ }
+ },
+ watch : {
+ // 侦听顶级 Property
+ a ( val , oldVal ) {
+ console . log ( \` new: \${ val } , old: \${ oldVal }\` )
+ },
+ // 字符串方法名
+ b : ' someMethod ' ,
+ // 该回调会在任何被侦听的对象的 Property 改变时被调用,不论其被嵌套多深
+ c : {
+ handler ( val , oldVal ) {
+ console . log ( ' c changed ' )
+ },
+ deep : true ,
+ },
+ // 侦听单个嵌套 Property
+ ' c.d ' : function ( val , oldVal ) {
+ // do something
+ },
+ // 该回调将会在侦听开始之后被立即调用
+ e : {
+ handler ( val , oldVal ) {
+ console . log ( ' e changed ' )
+ },
+ immediate : true ,
+ },
+ // 可以传入回调数组,它们会被逐一调用
+ f : [
+ ' handle1 ' ,
+ function handle2 ( val , oldVal ) {
+ console . log ( ' handle2 triggered ' )
+ },
+ {
+ handler : function handle3 ( val , oldVal ) {
+ console . log ( ' handle3 triggered ' )
+ },
+ /* ... */
+ },
+ ] ,
+ },
+ methods : {
+ someMethod () {
+ console . log ( ' b changed ' )
+ },
+ handle1 () {
+ console . log ( ' handle 1 triggered ' )
+ },
+ },
+}
当然肯定也会有开发者会觉得这样选择多是个好事,选择适合自己的就好,但笔者还是认为这种写法对于初学者来说不是那么友好,有些过于复杂化,如果一个用法可以适应各种各样的场景,岂不是更妙?
TIP
另外需要注意的是,不能使用箭头函数来定义 Watcher 函数 (例如 searchQuery: newValue => this.updateAutocomplete(newValue)
)。
因为箭头函数绑定了父级作用域的上下文,所以 this
将不会按照期望指向组件实例, this.updateAutocomplete
将是 undefined
。
Vue 2 也可以通过 this.$watch()
这个 API 的用法来实现对某个数据的侦听,它接受三个参数: source
、 callback
和 options
。
ts export default {
+ data () {
+ return {
+ a : 1 ,
+ }
+ },
+ // 生命周期钩子
+ mounted () {
+ this. $watch ( ' a ' , ( newVal , oldVal ) => {
+ // ...
+ } )
+ },
+}
由于 this.$watch
的用法和 Vue 3 比较接近,所以这里不做过多的回顾,请直接看 了解 Vue 3 部分。
了解 Vue 3 在 Vue 3 的组合式 API 写法, watch
是一个可以接受 3 个参数的函数(保留了 Vue 2 的 this.$watch
这种用法),在使用层面上简单了很多。
ts import { watch } from ' vue '
+
+// 一个用法走天下
+watch (
+ source , // 必传,要侦听的数据源
+ callback // 必传,侦听到变化后要执行的回调函数
+ // options // 可选,一些侦听选项
+)
下面的内容都基于 Vue 3 的组合式 API 用法展开讲解。
API 的 TS 类型 在了解用法之前,先对它的 TS 类型声明做一个简单的了解, watch 作为组合式 API ,根据使用方式有两种类型声明:
基础用法的 TS 类型,详见 基础用法 部分 ts // watch 部分的 TS 类型
+// ...
+export declare function watch < T , Immediate extends Readonly < boolean > = false >(
+ source : WatchSource < T >,
+ cb : WatchCallback < T , Immediate extends true ? T | undefined : T >,
+ options ?: WatchOptions < Immediate >
+): WatchStopHandle
+// ...
批量侦听的 TS 类型,详见 批量侦听 部分 ts // watch 部分的 TS 类型
+// ...
+export declare function watch <
+ T extends MultiWatchSources ,
+ Immediate extends Readonly < boolean > = false
+>(
+ sources : [ ... T ] ,
+ cb : WatchCallback < MapSources < T , false >, MapSources < T , Immediate >>,
+ options ?: WatchOptions < Immediate >
+): WatchStopHandle
+
+// MultiWatchSources 是一个数组
+declare type MultiWatchSources = ( WatchSource < unknown > | object )[]
+// ...
但是不管是基础用法还是批量侦听,可以看到这个 API 都是接受三个入参:
并返回一个可以用来停止侦听的函数(详见:停止侦听 )。
要侦听的数据源 在上面 API 的 TS 类型 已经对 watch
API 的组成有一定的了解了,这里先对数据源的类型和使用限制做下说明。
TIP
如果不提前了解,在使用的过程中可能会遇到 “侦听了但没有反应” 的情况出现。
另外,这部分内容会先围绕基础用法展开说明,批量侦听会在 批量侦听 部分单独说明。
watch
API 的第 1 个参数 source
是要侦听的数据源,它的 TS 类型如下:
ts // watch 第 1 个入参的 TS 类型
+// ...
+export declare type WatchSource < T = any > = Ref < T > | ComputedRef < T > | ( () => T )
+// ...
可以看到能够用于侦听的数据,是通过 响应式 API 定义的变量( Ref<T>
),或者是一个 计算数据 ( ComputedRef<T>
),或者是一个 getter 函数 ( () => T
)。
所以要想定义的 watch 能够做出预期的行为,数据源必须具备响应性或者是一个 getter ,如果只是通过 let
定义一个普通变量,然后去改变这个变量的值,这样是无法侦听的。
TIP
如果要侦听响应式对象里面的某个值(这种情况下对象本身是响应式,但它的 property 不是),需要写成 getter 函数,简单的说就是需要写成有返回值的函数,这个函数 return 要侦听的数据, e.g. () => foo.bar
,可以结合下方 基础用法 的例子一起理解。
侦听后的回调函数 在上面 API 的 TS 类型 介绍了 watch API 的组成,和数据源一样,先了解一下回调函数的定义。
TIP
和数据源部分一样,回调函数的内容也是会先围绕基础用法展开说明,批量侦听会在 批量侦听 部分单独说明。
watch API 的第 2 个参数 callback
是侦听到数据变化时要做出的行为,它的 TS 类型如下:
ts // watch 第 2 个入参的 TS 类型
+// ...
+export declare type WatchCallback < V = any , OV = any > = (
+ value : V ,
+ oldValue : OV ,
+ onCleanup : OnCleanup
+) => any
+// ...
乍一看它有三个参数,但实际上这些参数不是自己定义的,而是 watch API 传给的,所以不管用或者不用,它们都在那里:
参数 作用 value 变化后的新值,类型和数据源保持一致 oldValue 变化前的旧值,类型和数据源保持一致 onCleanup 注册一个清理函数,详见 侦听效果清理 部分
注意:第一个参数是新值,第二个才是原来的旧值!
如同其他 JS 函数,在使用 watch 的回调函数时,可以对这三个参数任意命名,比如把 value
命名为觉得更容易理解的 newValue
。
TIP
如果侦听的数据源是一个 引用类型 时( e.g. Object
、 Array
、 Date
… ), value
和 oldValue
是完全相同的,因为指向同一个对象。
另外,默认情况下,watch
是惰性的,也就是只有当被侦听的数据源发生变化时才执行回调。
基础用法 来到这里,对 2 个必传的参数都有一定的了解了,先看看基础的用法,也就是日常最常编写的方案,只需要先关注前 2 个必传的参数。
ts // 不要忘了导入要用的 API
+import { defineComponent , reactive , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 定义一个响应式数据
+ const userInfo = reactive ( {
+ name : ' Petter ' ,
+ age : 18 ,
+ } )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ userInfo . name = ' Tom '
+ }, 2000 )
+
+ /**
+ * 可以直接侦听这个响应式对象
+ * callback 的参数如果不用可以不写
+ */
+ watch ( userInfo , () => {
+ console . log ( ' 侦听整个 userInfo ' , userInfo . name )
+ } )
+
+ /**
+ * 也可以侦听对象里面的某个值
+ * 此时数据源需要写成 getter 函数
+ */
+ watch (
+ // 数据源,getter 形式
+ () => userInfo . name ,
+ // 回调函数 callback
+ ( newValue , oldValue ) => {
+ console . log ( ' 只侦听 name 的变化 ' , userInfo . name )
+ console . log ( ' 打印变化前后的值 ' , { oldValue , newValue } )
+ }
+ )
+ },
+} )
一般的业务场景,基础用法足以面对。
如果有多个数据源要侦听,并且侦听到变化后要执行的行为一样,那么可以使用 批量侦听 。
特殊的情况下,可以搭配 侦听的选项 做一些特殊的用法,详见下面部分的内容。
批量侦听 如果有多个数据源要侦听,并且侦听到变化后要执行的行为一样,第一反应可能是这样来写:
抽离相同的处理行为为公共函数 然后定义多个侦听操作,传入这个公共函数 ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const message = ref < string > ( '' )
+ const index = ref < number > ( 0 )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ // 来到这里才会触发 watch 的回调
+ message . value = ' Hello World! '
+ index . value ++
+ }, 2000 )
+
+ // 抽离相同的处理行为为公共函数
+ const handleWatch = (
+ newValue : string | number ,
+ oldValue : string | number
+ ) : void => {
+ console . log ( { newValue , oldValue } )
+ }
+
+ // 然后定义多个侦听操作,传入这个公共函数
+ watch ( message , handleWatch )
+ watch ( index , handleWatch )
+ },
+} )
这样写其实没什么问题,不过除了抽离公共代码的写法之外, watch API 还提供了一个批量侦听的用法,和 基础用法 的区别在于,数据源和回调参数都变成了数组的形式。
数据源:以数组的形式传入,里面每一项都是一个响应式数据。
回调参数:原来的 value
和 newValue
也都变成了数组,每个数组里面的顺序和数据源数组排序一致。
可以看下面的这个例子更为直观:
ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 定义多个数据源
+ const message = ref < string > ( '' )
+ const index = ref < number > ( 0 )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ message . value = ' Hello World! '
+ index . value ++
+ }, 2000 )
+
+ watch (
+ // 数据源改成了数组
+ [ message , index ] ,
+ // 回调的入参也变成了数组,每个数组里面的顺序和数据源数组排序一致
+ ([ newMessage , newIndex ], [ oldMessage , oldIndex ]) => {
+ console . log ( ' message 的变化 ' , { newMessage , oldMessage } )
+ console . log ( ' index 的变化 ' , { newIndex , oldIndex } )
+ }
+ )
+ },
+} )
什么情况下可能会用到批量侦听呢?比如一个子组件有多个 props ,当有任意一个 prop 发生变化时,都需要执行初始化函数重置组件的状态,那么这个时候就可以用上这个功能啦!
侦听的选项 在 API 的 TS 类型 里提到, watch API 还接受第 3 个参数 options
,可选的一些侦听选项。
它的 TS 类型如下:
ts // watch 第 3 个入参的 TS 类型
+// ...
+export declare interface WatchOptions < Immediate = boolean >
+ extends WatchOptionsBase {
+ immediate ?: Immediate
+ deep ?: boolean
+}
+// ...
+
+// 继承的 base 类型
+export declare interface WatchOptionsBase extends DebuggerOptions {
+ flush ?: ' pre ' | ' post ' | ' sync '
+}
+// ...
+
+// 继承的 debugger 选项类型
+export declare interface DebuggerOptions {
+ onTrack ?: ( event : DebuggerEvent ) => void
+ onTrigger ?: ( event : DebuggerEvent ) => void
+}
+// ...
options
是一个对象的形式传入,有以下几个选项:
选项 类型 默认值 可选值 作用 deep boolean false true | false 是否进行深度侦听 immediate boolean false true | false 是否立即执行侦听回调 flush string 'pre' 'pre' | 'post' | 'sync' 控制侦听回调的调用时机 onTrack (e) => void 在数据源被追踪时调用 onTrigger (e) => void 在侦听回调被触发时调用
其中 onTrack
和 onTrigger
的 e
是 debugger 事件,建议在回调内放置一个 debugger 语句 以调试依赖,这两个选项仅在开发模式下生效。
TIP
deep 默认是 false
,但是在侦听 reactive 对象或数组时,会默认为 true
,详见 侦听选项之 deep 。
侦听选项之 deep deep
选项接受一个布尔值,可以设置为 true
开启深度侦听,或者是 false
关闭深度侦听,默认情况下这个选项是 false
关闭深度侦听的,但也存在特例。
设置为 false
的情况下,如果直接侦听一个响应式的 引用类型 数据(e.g. Object
、 Array
… ),虽然它的属性的值有变化,但对其本身来说是不变的,所以不会触发 watch 的 callback 。
下面是一个关闭了深度侦听的例子:
ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 定义一个响应式数据,注意用的是 ref 来定义
+ const nums = ref < number [] > ([])
+
+ // 2s后给这个数组添加项目
+ setTimeout ( () => {
+ nums . value . push ( 1 )
+
+ // 可以打印一下,确保数据确实变化了
+ console . log ( ' 修改后 ' , nums . value )
+ }, 2000 )
+
+ // 但是这个 watch 不会按预期执行
+ watch (
+ nums ,
+ // 这里的 callback 不会被触发
+ () => {
+ console . log ( ' 触发侦听 ' , nums . value )
+ },
+ // 因为关闭了 deep
+ {
+ deep : false ,
+ }
+ )
+ },
+} )
类似这种情况,需要把 deep
设置为 true
才可以触发侦听。
可以看到上面的例子特地用了 ref API ,这是因为通过 reactive API 定义的对象无法将 deep
成功设置为 false
(这一点在目前的官网文档未找到说明,最终是在 watch API 的源码 上找到了答案)。
ts // ...
+if ( isReactive (source)) {
+ getter = () => source
+ deep = true // 被强制开启了
+}
+// ...
这个情况就是上面所说的 “特例” ,可以通过 isReactive
API 来判断是否需要手动开启深度侦听。
ts // 导入 isReactive API
+import { defineComponent , isReactive , reactive , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 侦听这个数据时,会默认开启深度侦听
+ const foo = reactive ( {
+ name : ' Petter ' ,
+ age : 18 ,
+ } )
+ console . log ( isReactive ( foo )) // true
+
+ // 侦听这个数据时,不会默认开启深度侦听
+ const bar = ref ( {
+ name : ' Petter ' ,
+ age : 18 ,
+ } )
+ console . log ( isReactive ( bar )) // false
+ },
+} )
在 侦听后的回调函数 部分有了解过, watch 默认是惰性的,也就是只有当被侦听的数据源发生变化时才执行回调。
这句话是什么意思呢?来看一下这段代码,为了减少 deep 选项的干扰,换一个类型,换成 string
数据来演示,请留意注释:
ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 这个时候不会触发 watch 的回调
+ const message = ref < string > ( '' )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ // 来到这里才会触发 watch 的回调
+ message . value = ' Hello World! '
+ }, 2000 )
+
+ watch ( message , () => {
+ console . log ( ' 触发侦听 ' , message . value )
+ } )
+ },
+} )
可以看到,数据在初始化的时候并不会触发侦听回调,如果有需要的话,通过 immediate
选项来让它直接触发。
immediate
选项接受一个布尔值,默认是 false
,可以设置为 true
让回调立即执行。
改成这样,请留意高亮的代码部分和新的注释:
ts import { defineComponent , ref , watch } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 这一次在这里可以会触发 watch 的回调了
+ const message = ref < string > ( '' )
+
+ // 2s后改变数据
+ setTimeout ( () => {
+ // 这一次,这里是第二次触发 watch 的回调,不再是第一次
+ message . value = ' Hello World! '
+ }, 2000 )
+
+ watch (
+ message ,
+ () => {
+ console . log ( ' 触发侦听 ' , message . value )
+ },
+ // 设置 immediate 选项
+ {
+ immediate : true ,
+ }
+ )
+ },
+} )
注意,在带有 immediate 选项时,不能在第一次回调时取消该数据源的侦听,详见 停止侦听 部分。
侦听选项之 flush flush
选项是用来控制 侦听回调 的调用时机,接受指定的字符串,可选值如下,默认是 'pre'
。
可选值 回调的调用时机 使用场景 'pre' 将在渲染前被调用 允许回调在模板运行前更新了其他值 'sync' 在渲染时被同步调用 目前来说没什么好处,可以了解但不建议用… 'post' 被推迟到渲染之后调用 如果要通过 ref 操作 DOM 元素与子组件 ,需要使用这个值来启用该选项,以达到预期的执行效果
对于 'pre'
和 'post'
,回调使用队列进行缓冲。回调只被添加到队列中一次。
即使观察值变化了多次,值的中间变化将被跳过,不会传递给回调,这样做不仅可以提高性能,还有助于保证数据的一致性。
更多关于 flush 的信息,请参阅 回调的触发时机 。
停止侦听 如果在 setup 或者 script-setup 里使用 watch 的话, 组件被卸载 的时候也会一起被停止,一般情况下不太需要关心如何停止侦听。
不过有时候可能想要手动取消, Vue 3 也提供了方法。
TIP
随着组件被卸载一起停止的前提是,侦听器必须是 同步语句 创建的,这种情况下侦听器会绑定在当前组件上。
如果放在 setTimeout
等 异步函数 里面创建,则不会绑定到当前组件,因此组件卸载的时候不会一起停止该侦听器,这种时候就需要手动停止侦听。
在 API 的 TS 类型 有提到,当在定义一个 watch 行为的时候,它会返回一个用来停止侦听的函数。
这个函数的 TS 类型如下:
ts export declare type WatchStopHandle = () => void
用法很简单,做一下简单了解即可:
ts // 定义一个取消观察的变量,它是一个函数
+const unwatch = watch (message , () => {
+ // ...
+} )
+
+// 在合适的时期调用它,可以取消这个侦听
+unwatch ()
但是也有一点需要注意的是,如果启用了 immediate 选项 ,不能在第一次触发侦听回调时执行它。
ts // 注意:这是一段错误的代码,运行会报错
+const unwatch = watch (
+ message ,
+ // 侦听的回调
+ () => {
+ // ...
+ // 在这里调用会有问题 ❌
+ unwatch ()
+ },
+ // 启用 immediate 选项
+ {
+ immediate : true ,
+ }
+)
会收获一段报错,告诉 unwatch
这个变量在初始化前无法被访问:
bash Uncaught ReferenceError: Cannot access ' unwatch ' before initialization
目前有两种方案可以让实现这个操作:
方案一:使用 var
并判断变量类型,利用 var 的变量提升 来实现目的。
ts // 这里改成 var ,不要用 const 或 let
+var unwatch = watch (
+ message ,
+ // 侦听回调
+ () => {
+ // 这里加一个判断,是函数才执行它
+ if ( typeof unwatch === ' function ' ) {
+ unwatch ()
+ }
+ },
+ // 侦听选项
+ {
+ immediate : true ,
+ }
+)
不过 var
已经属于过时的语句了,建议用方案二的 let
。
方案二:使用 let
并判断变量类型。
ts // 如果不想用 any ,可以导入 TS 类型
+import type { WatchStopHandle } from ' vue '
+
+// 这里改成 let ,但是要另起一行,先定义,再赋值
+let unwatch : WatchStopHandle
+unwatch = watch (
+ message ,
+ // 侦听回调
+ () => {
+ // 这里加一个判断,是函数才执行它
+ if ( typeof unwatch === ' function ' ) {
+ unwatch ()
+ }
+ },
+ // 侦听选项
+ {
+ immediate : true ,
+ }
+)
侦听效果清理 在 侦听后的回调函数 部分提及到一个参数 onCleanup
,它可以帮注册一个清理函数。
有时 watch 的回调会执行异步操作,当 watch 到数据变更的时候,需要取消这些操作,这个函数的作用就用于此,会在以下情况调用这个清理函数:
watcher 即将重新运行的时候 watcher 被停止(组件被卸载或者被手动 停止侦听 ) TS 类型:
ts declare type OnCleanup = ( cleanupFn : () => void ) => void
用法方面比较简单,传入一个回调函数运行即可,不过需要注意的是,需要在停止侦听之前注册好清理行为,否则不会生效。
在 停止侦听 里的最后一个 immediate 例子的基础上继续添加代码,请注意注册的时机:
ts let unwatch : WatchStopHandle
+unwatch = watch (
+ message ,
+ ( newValue , oldValue , onCleanup ) => {
+ // 需要在停止侦听之前注册好清理行为
+ onCleanup ( () => {
+ console . log ( ' 侦听清理ing ' )
+ // 根据实际的业务情况定义一些清理操作 ...
+ } )
+ // 然后再停止侦听
+ if ( typeof unwatch === ' function ' ) {
+ unwatch ()
+ }
+ },
+ {
+ immediate : true ,
+ }
+)
watchEffect 如果一个函数里包含了多个需要侦听的数据,一个一个数据去侦听太麻烦了,在 Vue 3 ,可以直接使用 watchEffect API 来简化的操作。
API 的 TS 类型 这个 API 的类型如下,使用的时候需要传入一个副作用函数(相当于 watch 的 侦听后的回调函数 ),也可以根据的实际情况传入一些可选的 侦听选项 。
和 watch API 一样,它也会返回一个用于 停止侦听 的函数。
ts // watchEffect 部分的 TS 类型
+// ...
+export declare type WatchEffect = ( onCleanup : OnCleanup ) => void
+
+export declare function watchEffect (
+ effect : WatchEffect ,
+ options ?: WatchOptionsBase
+): WatchStopHandle
+// ...
副作用函数也会传入一个清理回调作为参数,和 watch 的 侦听效果清理 一样的用法。
可以理解为它是一个简化版的 watch ,具体简化在哪里呢?请看下面的用法示例。
用法示例 它立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。
ts import { defineComponent , ref , watchEffect } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 单独定义两个数据,后面用来分开改变数值
+ const name = ref < string > ( ' Petter ' )
+ const age = ref < number > ( 18 )
+
+ // 定义一个调用这两个数据的函数
+ const getUserInfo = (): void => {
+ console . log ( {
+ name : name . value ,
+ age : age . value ,
+ } )
+ }
+
+ // 2s后改变第一个数据
+ setTimeout ( () => {
+ name . value = ' Tom '
+ }, 2000 )
+
+ // 4s后改变第二个数据
+ setTimeout ( () => {
+ age . value = 20
+ }, 4000 )
+
+ // 直接侦听调用函数,在每个数据产生变化的时候,它都会自动执行
+ watchEffect ( getUserInfo )
+ },
+} )
和 watch 的区别 虽然理论上 watchEffect
是 watch
的一个简化操作,可以用来代替 批量侦听 ,但它们也有一定的区别:
watch
可以访问侦听状态变化前后的值,而 watchEffect
没有。
watch
是在属性改变的时候才执行,而 watchEffect
则默认会执行一次,然后在属性改变的时候也会执行。
第二点的意思,看下面这段代码可以有更直观的理解:
使用 watch :
ts export default defineComponent ( {
+ setup () {
+ const foo = ref < string > ( '' )
+
+ setTimeout ( () => {
+ foo . value = ' Hello World! '
+ }, 2000 )
+
+ function bar () {
+ console . log ( foo . value )
+ }
+
+ // 使用 watch 需要先手动执行一次
+ bar ()
+
+ // 然后当 foo 有变动时,才会通过 watch 来执行 bar()
+ watch ( foo , bar )
+ },
+} )
使用 watchEffect :
ts export default defineComponent ( {
+ setup () {
+ const foo = ref < string > ( '' )
+
+ setTimeout ( () => {
+ foo . value = ' Hello World! '
+ }, 2000 )
+
+ function bar () {
+ console . log ( foo . value )
+ }
+
+ // 可以通过 watchEffect 实现 bar() + watch(foo, bar) 的效果
+ watchEffect ( bar )
+ },
+} )
可用的侦听选项 虽然用法和 watch 类似,但也简化了一些选项,它的侦听选项 TS 类型如下:
ts // 只支持 base 类型
+export declare interface WatchOptionsBase extends DebuggerOptions {
+ flush ?: ' pre ' | ' post ' | ' sync '
+}
+// ...
+
+// 继承的 debugger 选项类型
+export declare interface DebuggerOptions {
+ onTrack ?: ( event : DebuggerEvent ) => void
+ onTrigger ?: ( event : DebuggerEvent ) => void
+}
+// ...
对比 watch API ,它不支持 deep 和 immediate ,请记住这一点,其他的用法是一样的。
flush
选项的使用详见 侦听选项之 flush ,onTrack
和 onTrigger
详见 侦听的选项 部分内容。
watchPostEffect watchEffect API 使用 flush: 'post'
选项时的别名,具体区别详见 侦听选项之 flush 部分。
TIP
Vue v3.2.0 及以上版本才支持该 API 。
watchSyncEffect watchEffect API 使用 flush: 'sync'
选项时的别名,具体区别详见 侦听选项之 flush 部分。
TIP
Vue v3.2.0 及以上版本才支持该 API 。
数据的计算 ~new 和 Vue 2.0 一样,数据的计算也是使用 computed
API ,它可以通过现有的响应式数据,去通过计算得到新的响应式变量,用过 Vue 2.0 的开发者应该不会太陌生,但是在 Vue 3.0 ,在使用方式上也是变化非常大!
TIP
这里的响应式数据,可以简单理解为通过 ref API 、 reactive API 定义出来的数据,当然 Vuex 、Vue Router 等 Vue 数据也都具备响应式,可以在 响应式数据的变化 了解。
用法变化 先从一个简单的用例来看看在 Vue 新旧版本的用法区别:
假设定义了两个分开的数据 firstName
名字和 lastName
姓氏,但是在 template 展示时,需要展示完整的姓名,那么就可以通过 computed
来计算一个新的数据:
回顾 Vue 2 在 Vue 2.0 ,computed
和 data
在同级配置,并且不可以和 data
里的数据同名重复定义:
ts // 在 Vue 2 的写法:
+export default {
+ data () {
+ return {
+ firstName : ' Bill ' ,
+ lastName : ' Gates ' ,
+ }
+ },
+ // 注意这里定义的变量,都要通过函数的形式来返回它的值
+ computed : {
+ // 普通函数可以直接通过熟悉的 this 来拿到 data 里的数据
+ fullName () {
+ return \`\${ this. firstName } \${ this. lastName }\`
+ },
+ // 箭头函数则需要通过参数来拿到实例上的数据
+ fullName2 : ( vm ) => \`\${ vm . firstName } \${ vm . lastName }\` ,
+ },
+}
这样在需要用到全名的地方,只需要通过 this.fullName
就可以得到 Bill Gates
。
了解 Vue 3 在 Vue 3.0 ,跟其他 API 的用法一样,需要先导入 computed
才能使用:
ts // 在 Vue 3 的写法:
+import { defineComponent , ref , computed } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 定义基本的数据
+ const firstName = ref < string > ( ' Bill ' )
+ const lastName = ref < string > ( ' Gates ' )
+
+ // 定义需要计算拼接结果的数据
+ const fullName = computed ( () => \`\${ firstName . value } \${ lastName . value }\` )
+
+ // 2s 后改变某个数据的值
+ setTimeout ( () => {
+ firstName . value = ' Petter '
+ }, 2000 )
+
+ // template 那边在 2s 后也会显示为 Petter Gates
+ return {
+ fullName ,
+ }
+ },
+} )
可以把这个用法简单的理解为,传入一个回调函数,并 return
一个值,对,它需要有明确的返回值。
TIP
需要注意的是:
定义出来的 computed
变量,和 Ref 变量的用法一样,也是需要通过 .value
才能拿到它的值
但是区别在于,默认情况下 computed
的 value
是只读的
原因详见下方的 类型声明 。
类型声明 之前说过,在 defineComponent 里,会自动帮推导 Vue API 的类型,所以一般情况下,是不需要显式的去定义 computed
出来的变量类型的。
在确实需要手动指定的情况下,也可以导入它的类型然后定义:
ts import { computed } from ' vue '
+import type { ComputedRef } from ' vue '
+
+// 注意这里添加了类型声明
+const fullName : ComputedRef < string > = computed (
+ () => \`\${ firstName . value } \${ lastName . value }\`
+)
要返回一个字符串,就写 ComputedRef<string>
;返回布尔值,就写 ComputedRef<boolean>
;返回一些复杂对象信息,可以先定义好的类型,再诸如 ComputedRef<UserInfo>
去写。
ts // 这是 ComputedRef 的类型声明:
+export declare interface ComputedRef < T = any > extends WritableComputedRef < T > {
+ readonly value : T
+ [ComoutedRefSymbol] : true
+}
优势对比和注意事项 在继续往下看之前,先来了解一下这个 API 的一些优势和注意事项(如果在 Vue 2 已经有接触过的话,可以跳过这一段,因为优势和需要注意的东西比较一致)。
优势对比 看到这里,相信刚接触的开发者可能会有疑问,既然 computed
也是通过一个函数来返回值,那么和普通的 function
有什么区别,或者说优势?
性能优势 这一点在 官网文档 其实是有提到的:
数据的计算是基于它们的响应依赖关系缓存的,只在相关响应式依赖发生改变时它们才会重新求值。
也就是说,只要原始数据没有发生改变,多次访问 computed
,都是会立即返回之前的计算结果,而不是再次执行函数;而普通的 function
调用多少次就执行多少次,每调用一次就计算一次。
至于为何要如此设计,官网文档也给出了原因:
为什么需要缓存?假设有一个性能开销比较大的计算数据 list,它需要遍历一个巨大的数组并做大量的计算。然后可能有其他的计算数据依赖于 list。如果没有缓存,将不可避免的多次执行 list 的 getter!如果不希望有缓存,请用 function 来替代。
书写统一 假定 foo1 是 Ref 变量, foo2 是 computed
变量, foo3 是普通函数返回值
看到这里的开发者应该都已经清楚 Vue 3 的 Ref 变量是通过 foo1.value
来拿到值的,而 computed
也是通过 foo2.value
,并且在 template 里都可以省略 .value
,在读取方面,他们是有一致的风格和简洁性。
而 foo3 不管是在 script 还是 template ,都需要通过 foo3()
才能拿到结果,相对来说会有那么一丢丢别扭。
当然,关于这一点,如果涉及到的数据不是响应式数据,那么还是老老实实的用函数返回值吧,原因请见下面的 注意事项 。
注意事项 有优势当然也就有一定的 “劣势” ,当然这也是 Vue 框架的有意为之,所以在使用上也需要注意一些问题:
只会更新响应式数据的计算 假设要获取当前的时间信息,因为不是响应式数据,所以这种情况下就需要用普通的函数去获取返回值,才能拿到最新的时间。
ts const nowTime = computed ( () => new Date ())
+console . log (nowTime . value)
+// 输出 Sun Nov 14 2021 21:07:00 GMT+0800 (GMT+08:00)
+
+// 2s 后依然是跟上面一样的结果
+setTimeout ( () => {
+ console . log ( nowTime . value )
+ // 还是输出 Sun Nov 14 2021 21:07:00 GMT+0800 (GMT+08:00)
+}, 2000 )
数据是只读的 通过 computed 定义的数据,它是只读的,这一点在 类型声明 已经有所了解。
如果直接赋值,不仅无法变更数据,而且会收获一个报错。
bash TS2540: Cannot assign to ' value ' because it is a read-only property.
虽然无法直接赋值,但是在必要的情况下,依然可以通过 computed
的 setter
来更新数据。
点击了解:computed 的 setter 用法
setter 的使用 通过 computed 定义的变量默认都是只读的形式(只有一个 getter ),但是在必要的情况下,也可以使用其 setter 属性来更新数据。
基本格式 当需要用到 setter 的时候, computed
就不再是一个传入 callback 的形式了,而是传入一个带有 2 个方法的对象。
ts // 注意这里computed接收的入参已经不再是函数
+const foo = computed ( {
+ // 这里需要明确的返回一个值
+ get () {
+ // ...
+ },
+ // 这里接收一个参数,代表修改 foo 时,赋值下来的新值
+ set ( newValue ) {
+ // ...
+ },
+} )
这里的 get
就是 computed
的 getter ,跟原来传入 callback 的形式一样,是用于 foo.value
的读取,所以这里必须有明确的返回值。
这里的 set
就是 computed
的 setter ,它会接收一个参数,代表新的值,当通过 foo.value = xxx
赋值的时候,赋入的这个值,就会通过这个入参来传递进来,可以根据的业务需要,把这个值,赋给相关的数据源。
TIP
请注意,必须使用 get
和 set
这 2 个方法名,也只接受这 2 个方法。
在了解了基本格式后,可以查看下面的例子来了解具体的用法。
使用示范 官网的 例子 是一个 Options API 的案例,这里改成 Composition API 的写法来演示:
ts // 还是这2个数据源
+const firstName = ref < string > ( ' Bill ' )
+const lastName = ref < string > ( ' Gates ' )
+
+// 这里配合setter的需要,改成了另外一种写法
+const fullName = computed ( {
+ // getter还是返回一个拼接起来的全名
+ get () {
+ return \`\${ firstName . value } \${ lastName . value }\`
+ },
+ // setter这里改成只更新firstName,注意参数也定义TS类型
+ set ( newFirstName : string ) {
+ firstName . value = newFirstName
+ },
+} )
+console . log (fullName . value) // 输出 Bill Gates
+
+// 2s后更新一下数据
+setTimeout ( () => {
+ // 对fullName的赋值,其实更新的是firstName
+ fullName . value = ' Petter '
+
+ // 此时firstName已经得到了更新
+ console . log ( firstName . value ) // 会输出 Petter
+
+ // 当然,由于firstName变化了,所以fullName的getter也会得到更新
+ console . log ( fullName . value ) // 会输出 Petter Gates
+}, 2000 )
应用场景 计算 API 的作用,官网文档只举了一个非常简单的例子,那么在实际项目中,什么情况下用它会让更方便呢?
简单举几个比较常见的例子吧,加深一下对 computed
的理解。
数据的拼接和计算 如上面的案例,与其每个用到的地方都要用到 firstName + ' ' + lastName
这样的多变量拼接,不如用一个 fullName
来的简单。
当然,不止是字符串拼接,数据的求和等操作更是合适,比如说做一个购物车,购物车里有商品列表,同时还要显示购物车内的商品总金额,这种情况就非常适合用计算数据。
复用组件的动态数据 在一个项目里,很多时候组件会涉及到复用,比如说:“首页的文章列表 vs 列表页的文章列表 vs 作者详情页的文章列表” ,特别常见于新闻网站等内容资讯站点,这种情况下,往往并不需要每次都重新写 UI 、数据渲染等代码,仅仅是接口 URL 的区别。
这种情况就可以通过路由名称来动态获取要调用哪个列表接口:
ts const route = useRoute ()
+
+// 定义一个根据路由名称来获取接口URL的计算数据
+const apiUrl = computed ( () => {
+ switch ( route . name ) {
+ // 首页
+ case ' home ' :
+ return ' /api/list1 '
+ // 列表页
+ case ' list ' :
+ return ' /api/list2 '
+ // 作者页
+ case ' author ' :
+ return ' /api/list3 '
+ // 默认是随机列表
+ default :
+ return ' /api/random '
+ }
+} )
+
+// 请求列表
+const getArticleList = async (): Promise < void > => {
+ // ...
+ articleList . value = await axios ( {
+ method : ' get ' ,
+ url : apiUrl . value ,
+ // ...
+ } )
+ // ...
+}
当然,这种情况也可以在父组件通过 props
传递接口 URL ,如果已经学到了 组件通讯 一章的话。
获取多级对象的值 应该很经常的遇到要在 template 显示一些多级对象的字段,但是有时候又可能存在某些字段不一定有,需要做一些判断的情况,虽然有 v-if
,但是嵌套层级一多,模板代码会难以维护。
如果把这些工作量转移给计算数据,结合 try / catch
,这样就无需在 template 里处理很多判断了。
ts // 例子比较极端,但在 Vuex 这种大型数据树上,也不是完全不可能存在
+const foo = computed ( () => {
+ // 正常情况下返回需要的数据
+ try {
+ return store . state . foo3 . foo2 . foo1 . foo
+ } catch ( e ) {
+ // 处理失败则返回一个默认值
+ return ''
+ }
+} )
这样在 template 里要拿到 foo 的值,完全不需要关心中间一级又一级的字段是否存在,只需要区分是不是默认值。
不同类型的数据转换 有时候会遇到一些需求类似于,让用户在输入框里,按一定的格式填写文本,比如用英文逗号 ,
隔开每个词,然后保存的时候,是用数组的格式提交给接口。
这个时候 computed
的 setter 就可以妙用了,只需要一个简单的 computed
,就可以代替 input
的 change
事件或者 watch
侦听,可以减少很多业务代码的编写。
vue < template >
+ < input
+ type = " text "
+ v-model = " tagsStr "
+ placeholder = " 请输入标签,多个标签用英文逗号隔开 "
+ />
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , computed , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 这个是最终要用到的数组
+ const tags = ref < string [] > ([])
+
+ // 因为input必须绑定一个字符串
+ const tagsStr = computed ( {
+ // 所以通过getter来转成字符串
+ get () {
+ return tags . value . join ( ' , ' )
+ },
+ // 然后在用户输入的时候,切割字符串转换回数组
+ set ( newValue : string ) {
+ tags . value = newValue . split ( ' , ' )
+ },
+ } )
+
+ return {
+ tagsStr ,
+ }
+ },
+} )
+</ script >
所以在实际业务开发中,开发者可以多考虑一下是否可以使用 computed
代替 watch
,避免过多的数组侦听带来项目性能的下降。
指令 指令是 Vue 模板语法里的特殊标记,在使用上和 HTML 的 data-* 属性十分相似,统一以 v-
开头( e.g. v-html
)。
它以简单的方式实现了常用的 JavaScript 表达式功能,当表达式的值改变的时候,响应式地作用到 DOM 上。
内置指令 Vue 提供了一些内置指令可以直接使用,例如:
vue < template >
+ <!-- 渲染一段文本 -->
+ < span v-text = " msg " ></ span >
+
+ <!-- 渲染一段 HTML -->
+ < div v-html = " html " ></ div >
+
+ <!-- 循环创建一个列表 -->
+ < ul v-if = " items.length " >
+ < li v-for = " (item, index) in items " :key = " index " >
+ < span > {{ item }} </ span >
+ </ li >
+ </ ul >
+
+ <!-- 一些事件( \`@\` 等价于 \`v-on\` ) -->
+ < button @click = " hello " > Hello </ button >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const msg = ref < string > ( ' Hello World! ' )
+ const html = ref < string > ( ' <p>Hello World!</p> ' )
+ const items = ref < string [] > ([ ' a ' , ' b ' , ' c ' , ' d ' ])
+
+ function hello () {
+ console . log ( msg . value )
+ }
+
+ return {
+ msg ,
+ html ,
+ items ,
+ hello ,
+ }
+ },
+} )
+</ script >
内置指令在使用上都非常的简单,可以在官方文档的 内置指令 一章查询完整的指令列表和用法,在模板上使用时,请了解 指令的模板语法 。
TIP
有两个指令可以使用别名:
v-on
的别名是 @
,使用 @click
等价于 v-on:click
v-bind
的别名是 :
,使用 :src
等价于 v-bind:src
自定义指令 ~new 如果 Vue 的内置指令不能满足业务需求,还可以开发自定义指令。
相关的 TS 类型 在开始编写代码之前,先了解一下自定义指令相关的 TypeScript 类型。
自定义指令有两种实现形式,一种是作为一个对象,其中的写法比较接近于 Vue 组件,除了 getSSRProps 和 deep 选项 外,其他的每一个属性都是一个 钩子函数 ,下一小节会介绍钩子函数的内容。
ts // 对象式写法的 TS 类型
+// ...
+export declare interface ObjectDirective < T = any , V = any > {
+ created ?: DirectiveHook < T , null , V >
+ beforeMount ?: DirectiveHook < T , null , V >
+ mounted ?: DirectiveHook < T , null , V >
+ beforeUpdate ?: DirectiveHook < T , VNode < any , T >, V >
+ updated ?: DirectiveHook < T , VNode < any , T >, V >
+ beforeUnmount ?: DirectiveHook < T , null , V >
+ unmounted ?: DirectiveHook < T , null , V >
+ getSSRProps ?: SSRDirectiveHook
+ deep ?: boolean
+}
+// ...
另外一种是函数式写法,只需要定义成一个函数,但这种写法只在 mounted
和 updated
这两个钩子生效,并且触发一样的行为。
ts // 函数式写法的 TS 类型
+// ...
+export declare type FunctionDirective < T = any , V = any > = DirectiveHook <
+ T ,
+ any ,
+ V
+>
+// ...
这是每个钩子函数对应的类型,它有 4 个入参:
ts // 钩子函数的 TS 类型
+// ...
+export declare type DirectiveHook <
+ T = any ,
+ Prev = VNode < any , T > | null ,
+ V = any
+> = (
+ el : T ,
+ binding : DirectiveBinding < V >,
+ vnode : VNode < any , T >,
+ prevVNode : Prev
+) => void
+// ...
钩子函数第二个参数的类型:
ts // 钩子函数第二个参数的 TS 类型
+// ...
+export declare interface DirectiveBinding < V = any > {
+ instance : ComponentPublicInstance | null
+ value : V
+ oldValue : V | null
+ arg ?: string
+ modifiers : DirectiveModifiers
+ dir : ObjectDirective < any , V >
+}
+// ...
可以看到自定义指令最核心的就是 “钩子函数” 了,接下来来了解这部分的知识点。
钩子函数 和 组件的生命周期 类似,自定义指令里的逻辑代码也有一些特殊的调用时机,在这里称之为钩子函数:
钩子函数 调用时机 created 在绑定元素的 attribute 或事件侦听器被应用之前调用 beforeMount 当指令第一次绑定到元素并且在挂载父组件之前调用 mounted 在绑定元素的父组件被挂载后调用 beforeUpdate 在更新包含组件的 VNode 之前调用 updated 在包含组件的 VNode 及其子组件的 VNode 更新后调用 beforeUnmount 在卸载绑定元素的父组件之前调用 unmounted 当指令与元素解除绑定且父组件已卸载时,只调用一次
TIP
因为自定义指令的默认写法是一个对象,所以在代码风格上是遵循 Options API 的生命周期命名,而非 Vue 3 的 Composition API 风格。
钩子函数在用法上就是这样子:
ts const myDirective = {
+ created ( el , binding , vnode , prevVnode ) {
+ // ...
+ },
+ mounted ( el , binding , vnode , prevVnode ) {
+ // ...
+ },
+ // 其他钩子...
+}
在 相关的 TS 类型 已了解,每个钩子函数都有 4 个入参:
参数 作用 el 指令绑定的 DOM 元素,可以直接操作它 binding 一个对象数据,见下方的单独说明 vnode el 对应在 Vue 里的虚拟节点信息 prevVNode Update 时的上一个虚拟节点信息,仅在 beforeUpdate
和 updated
可用
其中用的最多是 el
和 binding
了。
属性 作用 value 传递给指令的值,例如 v-foo="bar"
里的 bar
,支持任意有效的 JS 表达式 oldValue 指令的上一个值,仅对 beforeUpdate
和 updated
可用 arg 传给指令的参数,例如 v-foo:bar
里的 bar
modifiers 传给指令的修饰符,例如 v-foo.bar
里的 bar
instance 使用指令的组件实例 dir 指令定义的对象(就是上面的 const myDirective = { /* ... */ }
这个对象)
在了解了指令的写法和参数作用之后,来看看如何注册一个自定义指令。
局部注册 自定义指令可以在单个组件内定义并使用,通过和 setup 函数 同级别的 directives
选项进行定义,可以参考下面的例子和注释:
vue < template >
+ <!-- 这个使用默认值 \`unset\` -->
+ < div v-highlight > {{ msg }} </ div >
+
+ <!-- 这个使用传进去的黄色 -->
+ < div v-highlight = " \`yellow\` " > {{ msg }} </ div >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ // 自定义指令在这里编写,和 \`setup\` 同级别
+ directives : {
+ // \`directives\` 下的每个字段名就是指令名称
+ highlight : {
+ // 钩子函数
+ mounted ( el , binding ) {
+ el . style . backgroundColor =
+ typeof binding . value === ' string ' ? binding . value : ' unset '
+ },
+ },
+ },
+ setup () {
+ const msg = ref < string > ( ' Hello World! ' )
+
+ return {
+ msg ,
+ }
+ },
+} )
+</ script >
上面是对象式的写法,也可以写成函数式:
ts export default defineComponent ( {
+ directives : {
+ highlight ( el , binding ) {
+ el . style . backgroundColor =
+ typeof binding . value === ' string ' ? binding . value : ' unset '
+ },
+ },
+} )
TIP
局部注册的自定义指令,默认在子组件内生效,子组件内无需重新注册即可使用父组件的自定义指令。
全局注册 自定义指令也可以注册成全局,这样就无需在每个组件里定义了,只要在入口文件 main.ts
里启用它,任意组件里都可以使用自定义指令。
请查看 开发本地 Vue 专属插件 一节的内容了解如何注册一个全局的自定义指令插件。
deep 选项 除了 钩子函数 ,在 相关的 TS 类型 里还可以看到有一个 deep 选项,它是一个布尔值,作用是:
如果自定义指令用于一个有嵌套属性的对象,并且需要在嵌套属性更新的时候触发 beforeUpdate
和 updated
钩子,那么需要将这个选项设置为 true
才能够生效。
vue < template >
+ < div v-foo = " foo " ></ div >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , reactive } from ' vue '
+
+export default defineComponent ( {
+ directives : {
+ foo : {
+ beforeUpdate ( el , binding ) {
+ console . log ( ' beforeUpdate ' , binding )
+ },
+ updated ( el , binding ) {
+ console . log ( ' updated ' , binding )
+ },
+ mounted ( el , binding ) {
+ console . log ( ' mounted ' , binding )
+ },
+ // 需要设置为 \`true\` ,如果是 \`false\` 则不会触发
+ deep : true ,
+ },
+ },
+ setup () {
+ // 定义一个有嵌套属性的对象
+ const foo = reactive ( {
+ bar : {
+ baz : 1 ,
+ },
+ } )
+
+ // 2s 后修改其中一个值,会触发 \`beforeUpdate\` 和 \`updated\`
+ setTimeout ( () => {
+ foo . bar . baz = 2
+ console . log ( foo )
+ }, 2000 )
+
+ return {
+ foo ,
+ }
+ },
+} )
+</ script >
插槽 Vue 在使用子组件的时候,子组件在 template 里类似一个 HTML 标签,可以在这个子组件标签里传入任意模板代码以及 HTML 代码,这个功能就叫做 “插槽” 。
默认插槽 默认情况下,子组件使用 <slot />
标签即可渲染父组件传下来的插槽内容,例如:
在父组件这边:
vue < template >
+ < Child >
+ <!-- 注意这里,子组件标签里面传入了 HTML 代码 -->
+ < p > 这是插槽内容 </ p >
+ </ Child >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+import Child from ' @cp/Child.vue '
+
+export default defineComponent ( {
+ components : {
+ Child ,
+ },
+} )
+</ script >
在子组件这边:
vue < template >
+ < slot />
+</ template >
默认插槽非常简单,一个 <slot />
就可以了。
具名插槽 有时候可能需要指定多个插槽,例如一个子组件里有 “标题” 、 “作者”、 “内容” 等预留区域可以显示对应的内容,这时候就需要用到具名插槽来指定不同的插槽位。
子组件通过 name
属性来指定插槽名称:
vue < template >
+ <!-- 显示标题的插槽内容 -->
+ < div class = " title " >
+ < slot name = " title " />
+ </ div >
+
+ <!-- 显示作者的插槽内容 -->
+ < div class = " author " >
+ < slot name = " author " />
+ </ div >
+
+ <!-- 其他插槽内容放到这里 -->
+ < div class = " content " >
+ < slot />
+ </ div >
+</ template >
父组件通过 template
标签绑定 v-slot:name
格式的属性,来指定传入哪个插槽里:
vue < template >
+ < Child >
+ <!-- 传给标题插槽 -->
+ < template v-slot : title >
+ < h1 > 这是标题 </ h1 >
+ </ template >
+
+ <!-- 传给作者插槽 -->
+ < template v-slot : author >
+ < h1 > 这是作者信息 </ h1 >
+ </ template >
+
+ <!-- 传给默认插槽 -->
+ < p > 这是插槽内容 </ p >
+ </ Child >
+</ template >
v-slot:name
有一个别名 #name
语法,上面父组件的代码也相当于:
vue < template >
+ < Child >
+ <!-- 传给标题插槽 -->
+ < template # title >
+ < h1 > 这是标题 </ h1 >
+ </ template >
+
+ <!-- 传给作者插槽 -->
+ < template # author >
+ < h1 > 这是作者信息 </ h1 >
+ </ template >
+
+ <!-- 传给默认插槽 -->
+ < p > 这是插槽内容 </ p >
+ </ Child >
+</ template >
TIP
在使用具名插槽的时候,子组件如果不指定默认插槽,那么在具名插槽之外的内容将不会被渲染。
默认内容 可以给 slot
标签添加内容,例如 <slot>默认内容</slot>
,当父组件没有传入插槽内容时,会使用默认内容来显示,默认插槽和具名插槽均支持该功能。
注意事项 有一条规则需要记住:
父组件里的所有内容都是在父级作用域中编译的 子组件里的所有内容都是在子作用域中编译的 CSS 样式与预处理器 Vue 组件的 CSS 样式部分,Vue 3 保留着和 Vue 2 完全一样的写法。
编写组件样式表 最基础的写法,就是在 .vue
文件里添加一个 <style />
标签,即可在里面写 CSS 代码了。
vue < template >
+ < div >
+ <!-- HTML 代码 -->
+ </ div >
+</ template >
+
+< script lang = " ts " >
+ // TypeScript 代码
+</ script >
+
+< style >
+/* CSS 代码 */
+. msg {
+ width : 100% ;
+}
+. msg p {
+ color : # 333 ;
+ font-size : 14px ;
+}
+</ style >
动态绑定 CSS 动态绑定 CSS ,在 Vue 2 就已经存在了,在此之前常用的是 :class
和 :style
,现在在 Vue 3 ,还可以通过 v-bind
来动态修改了。
使用 :class 动态修改样式名 它是绑定在 DOM 元素上面的一个属性,跟 class="class-name"
这样的属性同级别,它非常灵活!
TIP
使用 :class
是用来动态修改样式名,也就意味着必须提前把样式名对应的样式表先写好!
假设已经提前定义好了这几个变量:
vue < script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const activeClass = ' active-class '
+ const activeClass1 = ' active-class1 '
+ const activeClass2 = ' active-class2 '
+ const isActive = true
+
+ return {
+ activeClass ,
+ activeClass1 ,
+ activeClass2 ,
+ isActive ,
+ }
+ },
+} )
+</ script >
如果只想绑定一个单独的动态样式,可以传入一个字符串:
vue < template >
+ < p :class = " activeClass " > Hello World! </ p >
+</ template >
如果有多个动态样式,也可以传入一个数组:
vue < template >
+ < p :class = " [activeClass1, activeClass2] " > Hello World! </ p >
+</ template >
还可以对动态样式做一些判断,这个时候传入一个对象:
vue < template >
+ < p :class = " { 'active-class': isActive } " > Hello World! </ p >
+</ template >
多个判断的情况下,记得也用数组套起来:
vue < template >
+ < p :class = " [{ activeClass1: isActive }, { activeClass2: !isActive }] " >
+ Hello World!
+ </ p >
+</ template >
那么什么情况下会用到 :class
呢?
最常见的场景,应该就是导航、选项卡了,比如要给一个当前选中的选项卡做一个突出高亮的状态,那么就可以使用 :class
来动态绑定一个样式。
vue < template >
+ < ul class = " list " >
+ < li
+ class = " item "
+ :class = " { cur: index === curIndex } "
+ v-for = " (item, index) in 5 "
+ :key = " index "
+ @click = " curIndex = index "
+ >
+ {{ item }}
+ </ li >
+ </ ul >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const curIndex = ref < number > ( 0 )
+
+ return {
+ curIndex ,
+ }
+ },
+} )
+</ script >
+
+< style scoped >
+. cur {
+ color : red ;
+}
+</ style >
这样就简单实现了一个点击切换选项卡高亮的功能。
使用 :style 动态修改内联样式 如果觉得使用 :class
需要提前先写样式,再去绑定样式名有点繁琐,有时候只想简简单单的修改几个样式,那么可以通过 :style
来处理。
默认的情况下,都是传入一个对象去绑定:
key
是符合 CSS 属性名的 “小驼峰式” 写法,或者套上引号的短横线分隔写法(原写法),例如在 CSS 里,定义字号是 font-size
,那么需要写成 fontSize
或者 'font-size'
作为它的键。
value
是 CSS 属性对应的 “合法值”,比如要修改字号大小,可以传入 13px
、0.4rem
这种带合法单位字符串值,但不可以是 13
这样的缺少单位的值,无效的 CSS 值会被过滤不渲染。
vue < template >
+ < p
+ :style = " {
+ fontSize: '13px',
+ 'line-height': 2,
+ color: '#ff0000',
+ textAlign: 'center',
+ } "
+ >
+ Hello World!
+ </ p >
+</ template >
如果有些特殊场景需要绑定多套 style
,需要在 script
先定义好各自的样式变量(也是符合上面说到的那几个要求的对象),然后通过数组来传入:
vue < template >
+ < p :style = " [style1, style2] " > Hello World! </ p >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const style1 = {
+ fontSize : ' 13px ' ,
+ ' line-height ' : 2 ,
+ }
+ const style2 = {
+ color : ' #ff0000 ' ,
+ textAlign : ' center ' ,
+ }
+
+ return {
+ style1 ,
+ style2 ,
+ }
+ },
+} )
+</ script >
使用 v-bind 动态修改 style ~new 当然,以上两种形式都是关于 <script />
和 <template />
部分的操作,如果觉得会给模板带来一定的维护成本的话,不妨考虑这个新方案,将变量绑定到 <style />
部分去。
TIP
请注意这是一个在 3.2.0
版本之后才被归入正式队列的新功能!如果需要使用它,请确保的 vue
的版本号在 3.2.0
以上,最好是保持最新版本。
先来看看基本的用法:
vue < template >
+ < p class = " msg " > Hello World! </ p >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , ref } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const fontColor = ref < string > ( ' #ff0000 ' )
+
+ return {
+ fontColor ,
+ }
+ },
+} )
+</ script >
+
+< style scoped >
+. msg {
+ color : v-bind(fontColor) ;
+}
+</ style >
如上面的代码,将渲染出一句红色文本的 Hello World!
这其实是利用了现代浏览器支持的 CSS 变量来实现的一个功能(所以如果打算用它的话,需要提前注意一下兼容性噢,点击查看:CSS Variables 兼容情况 )。
它渲染到 DOM 上,其实也是通过绑定 style
来实现,可以看到渲染出来的样式是:
html < p class = " msg " data-v-7eb2bc79 = "" style = " --7eb2bc79-fontColor:#ff0000; " >
+ Hello World!
+</ p >
对应的 CSS 变成了:
css . msg [ data-v-7eb2bc79 ] {
+ color : var ( --7eb2bc79-fontColor );
+}
理论上 v-bind
函数可以在 Vue 内部支持任意的 JavaScript 表达式,但由于可能包含在 CSS 标识符中无效的字符,因此官方是建议在大多数情况下,用引号括起来,如:
css . text {
+ font-size : v-bind( ' theme.font.size ' ) ;
+}
由于 CSS 变量的特性,因此对 CSS 响应式属性的更改不会触发模板的重新渲染(这也是和 :class
与 :style
的最大不同)。
TIP
不管有没有开启 <style scoped> ,使用 v-bind
渲染出来的 CSS 变量,都会带上 scoped
的随机 hash 前缀,避免样式污染(永远不会意外泄漏到子组件中),所以请放心使用!
如果对 CSS 变量的使用还不是很了解的话,可以先阅读一下相关的基础知识点。
相关阅读:使用 CSS 自定义属性(变量) - MDN
样式表的组件作用域 CSS 不像 JS ,是没有作用域的概念的,一旦写了某个样式,直接就是全局污染。所以 BEM 命名法 等规范才应运而生。
但在 Vue 组件里,有两种方案可以避免出现这种污染问题:一个是 Vue 2 就有的 <style scoped>
,一个是 Vue 3 新推出的 <style module>
。
Style Scoped Vue 组件在设计的时候,就想到了一个很优秀的解决方案,通过 scoped
来支持创建一个 CSS 作用域,使这部分代码只运行在这个组件渲染出来的虚拟 DOM 上。
使用方式很简单,只需要在 <style />
上添加 scoped
属性:
vue <!-- 注意这里多了一个 \`scoped\` -->
+< style scoped >
+. msg {
+ width : 100% ;
+}
+. msg p {
+ color : # 333 ;
+ font-size : 14px ;
+}
+</ style >
编译后,虚拟 DOM 都会带有一个 data-v-xxxxx
这样的属性,其中 xxxxx
是一个随机生成的 Hash ,同一个组件的 Hash 是相同并且唯一的:
html < div class = " msg " data-v-7eb2bc79 >
+ < p data-v-7eb2bc79 > Hello World! </ p >
+</ div >
而 CSS 则也会带上与 HTML 相同的属性,从而达到样式作用域的目的。
css . msg [ data-v-7eb2bc79 ] {
+ width : 100% ;
+}
+. msg p [ data-v-7eb2bc79 ] {
+ color : # 333 ;
+ font-size : 14px ;
+}
使用 scoped
可以有效的避免全局样式污染,可以在不同的组件里面都使用相同的 className,而不必担心会相互覆盖,不必再定义很长很长的样式名来防止冲突了。
TIP
添加 scoped
生成的样式,只作用于当前组件中的元素,并且权重高于全局 CSS ,可以覆盖全局样式
Style Module ~new 这是在 Vue 3 才推出的一个新方案,和 <style scoped>
不同,scoped 是通过给 DOM 元素添加自定义属性的方式来避免冲突,而 <style module>
则更为激进,将会编译成 CSS Modules 。
对于 CSS Modules 的处理方式,也可以通过一个小例子来更直观的了解它:
css /* 案例来自阮一峰老师的博文《CSS Modules 用法教程》 */
+/* https://www.ruanyifeng.com/blog/2016/06/css_modules.html */
+
+/* 编译前 */
+. title {
+ color : red ;
+}
+
+/* 编译后 */
+. _3zyde4l1yATCOkgn-DBWEL {
+ color : red ;
+}
可以看出,是通过比较 “暴力” 的方式,把编写的 “好看的” 样式名,直接改写成一个随机 Hash 样式名,来避免样式互相污染。
所以回到 Vue 这边,看看 <style module>
是怎么操作的。
vue < template >
+ < p :class = " $style.msg " > Hello World! </ p >
+</ template >
+
+< style module >
+. msg {
+ color : # ff0000 ;
+}
+</ style >
于是,将渲染出一句红色文本的 Hello World!
。
TIP
使用这个方案,需要了解如何 使用 :class 动态修改样式名
如果单纯只使用 <style module>
,那么在绑定样式的时候,是默认使用 $style
对象来操作的
必须显示的指定绑定到某个样式,比如 $style.msg
,才能生效
如果单纯的绑定 $style
,并不能得到 “把全部样式名直接绑定” 的期望结果
如果指定的 className 是短横杆命名,比如 .user-name
,那么需要通过 $style['user-name']
去绑定
也可以给 module
进行命名,然后就可以通过命名的 “变量名” 来操作:
vue < template >
+ < p :class = " classes.msg " > Hello World! </ p >
+</ template >
+
+< style module = " classes " >
+. msg {
+ color : # ff0000 ;
+}
+</ style >
TIP
需要注意的一点是,一旦开启 <style module>
,那么在 <style module>
里所编写的样式都必须手动绑定才能生效,没有被绑定的样式虽然也会被编译,但不会主动生效到 DOM 上。
原因是编译出来的样式名已经变化,而原来的 DOM 未指定对应的样式名,或者指定的是编译前的命名,所以并不能匹配到正确的样式。
useCssModule ~new 这是一个全新的 API ,面向在 script 部分操作 CSS Modules 。
在上面的 CSS Modules 部分可以知道,可以在 style
定义好样式,然后在 template
部分通过变量名来绑定样式。
那么如果有一天有个需求,需要通过 v-html
来渲染 HTML 代码,那这里的样式岂不是凉凉了?当然不会!
Vue 3 提供了一个 Composition API useCssModule
来帮助在 setup
函数里操作的 CSS Modules (对,只能在 setup 或者 script setup 里使用)。
基本用法:
绑定多几个样式,再来操作:
vue < template >
+ < p :class = " $style.msg " >
+ < span :class = " $style.text " > Hello World! </ span >
+ </ p >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , useCssModule } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const style = useCssModule ()
+ console . log ( style )
+ },
+} )
+</ script >
+
+< style module >
+. msg {
+ color : # ff0000 ;
+}
+. text {
+ font-size : 14px ;
+}
+</ style >
可以看到打印出来的 style
是一个对象:
js {
+ msg : ' home_msg_37Xmr ' ,
+ text : ' home_text_2woQJ '
+}
所以来配合 模板字符串 的使用,看看刚刚说的,要通过 v-html
渲染出来的内容应该如何绑定样式:
vue < template >
+ < div v-html = " content " ></ div >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent , useCssModule } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 获取样式
+ const style = useCssModule ()
+
+ // 编写模板内容
+ const content = \` <p class=" \${ style . msg } ">
+ <span class=" \${ style . text } ">Hello World! —— from v-html</span>
+ </p> \`
+
+ return {
+ content ,
+ }
+ },
+} )
+</ script >
+
+< style module >
+. msg {
+ color : # ff0000 ;
+}
+. text {
+ font-size : 14px ;
+}
+</ style >
是不是也非常简单?可能刚开始不太习惯,但写多几次其实也蛮好玩的这个功能!
另外,需要注意的是,如果是指定了 modules 的名称,那么必须传入对应的名称作为入参才可以正确拿到这些样式:
比如指定了一个 classes 作为名称:
vue < style module = " classes " >
+/* ... */
+</ style >
那么需要通过传入 classes 这个名称才能拿到样式,否则会是一个空对象:
ts const style = useCssModule ( ' classes ' )
TIP
在 const style = useCssModule()
的时候,命名是随意的,跟在 <style module="classes">
这里指定的命名没有关系。
深度操作符 ~new 在 样式表的组件作用域 部分了解到,使用 scoped 后,父组件的样式将不会渗透到子组件中,但也不能直接修改子组件的样式。
如果确实需要进行修改子组件的样式,必须通过 ::v-deep
(完整写法) 或者 :deep
(快捷写法) 操作符来实现。
TIP
旧版的深度操作符是 >>>
、 /deep/
和 ::v-deep
,现在 >>>
和 /deep/
已进入弃用阶段(虽然暂时还没完全移除)
同时需要注意的是,旧版 ::v-deep
的写法是作为组合器的方式,写在样式或者元素前面,如:::v-deep .class-name { /* ... */ }
,现在这种写法也废弃了。
现在不论是 ::v-deep
还是 :deep
,使用方法非常统一,来假设 .b 是子组件的样式名:
vue < style scoped >
+. a :deep( . b ) {
+ /* ... */
+}
+</ style >
编译后:
css . a [ data-v-f3f3eg9 ] . b {
+ /* ... */
+}
TIP
可以看到,新的 deep 写法是作为一个类似 JS “函数” 那样去使用,需要深度操作的样式或者元素名,作为 “入参” 去传入。
同理,如果使用 Less 或者 Stylus 这种支持嵌套写法的预处理器,也是可以这样去深度操作的:
less . a {
+ : deep( . b ) {
+ /* ... */
+ }
+}
另外,除了操作子组件的样式,那些通过 v-html
创建的 DOM 内容,也不受作用域内的样式影响,也可以通过深度操作符来实现样式修改。
使用 CSS 预处理器 在工程化的现在,可以说前端都几乎不写 CSS 了,都是通过 sass
、less
、stylus
等 CSS 预处理器来完成样式的编写。
为什么要用 CSS 预处理器?放一篇关于三大预处理器的点评,新开发者可以做个简单了解,具体的用法在对应的官网上有非常详细的说明。
可以查看了解:浅谈 CSS 预处理器,Sass、Less 和 Stylus
在 Vue 组件里使用预处理器非常简单,像 Vite 已内置了对预处理器文件的支持(可处理 .less
、 .scss
之类的预处理器扩展名文件),因此只需要安装对应的依赖到项目里。
这里以 Less 为例,先安装该预处理器:
bash # 因为是在开发阶段使用,所以添加到 \`devDependencies\`
+npm i -D less
接下来在 Vue 组件里,只需要在 <style />
标签上,通过 lang="less"
属性指定使用哪个预处理器,即可直接编写对应的代码:
vue < style lang = " less " scoped >
+// 定义颜色变量
+@ color-black : #333 ;
+@ color-red : #ff0000 ;
+
+// 父级标签
+. msg {
+ width : 100% ;
+ // 其子标签可以使用嵌套写法
+ p {
+ color : @ color-black ;
+ font-size : 14px ;
+ // 支持多级嵌套
+ span {
+ color : @ color-red ;
+ }
+ }
+}
+</ style >
编译后的 css 代码:
css . msg {
+ width : 100% ;
+}
+. msg p {
+ color : # 333333 ;
+ font-size : 14px ;
+}
+. msg p span {
+ color : # ff0000 ;
+}
预处理器也支持 scoped
,用法请查阅 样式表的组件作用域 部分。
`,729);function C(i,A,d,u,g,h){const l=n("ReadingTips"),p=n("GitalkComment"),o=n("ClientOnly");return t(),c("div",null,[F,s(l),D,s(o,null,{default:r(()=>[s(p,{issueId:46})]),_:1})])}const E=e(y,[["render",C]]);export{m as __pageData,E as default};
diff --git a/assets/efficient.md.a41d0892.js b/assets/efficient.md.a41d0892.js
new file mode 100644
index 00000000..cac3dc46
--- /dev/null
+++ b/assets/efficient.md.a41d0892.js
@@ -0,0 +1,552 @@
+/**
+ * name: learning-vue3
+ * version: v2.0.0
+ * description: A starting learning tutorial on Vue 3.0 + TypeScript, suitable for complete Vue novices and Vue 2.0 veterans, incorporating some of my own practical experience on the basis of official documents.
+ * author: chengpeiquan
+ * homepage: https://vue3.chengpeiquan.com
+ */
+import{_ as p,v as o,b as e,t as s,O as t,R as c,M as n}from"./chunks/framework.0d8bea05.js";const g=JSON.parse('{"title":"高效开发","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"efficient.md","filePath":"efficient.md"}'),r={name:"efficient.md"},y=c(`高效开发 可能很多开发者(包括笔者)刚上手 Vue 3 的那段时间,都会觉得开发过程似乎变得更繁琐了, Vue 官方团队当然不会无视群众的呼声,如果基于脚手架和 .vue
文件开发,那么可以享受到更高效率的开发体验。
在阅读这一章之前,需要对 Vue 3 的单组件开发有一定的了解,如果还处于完全没有接触过的阶段,请先抽点时间阅读 单组件的编写 一章。
TIP
要体验以下新特性,请确保项目下 package.json 里的 vue 版本在 3.2.0
以上,最好同步 npm 上当前最新的 @latest 版本,否则可能出现 API 未定义等问题。
script-setup ~new script-setup 是 Vue 3 组件的一个语法糖,旨在帮助开发者降低 setup 函数需要 return 的心智负担。
Vue 的 3.1.2
版本是针对 script-setup 的一个分水岭版本,自 3.1.4
开始 script-setup 进入定稿状态,部分旧的 API 已被舍弃,本章节内容将以最新的 API 为准进行整理说明,如果需要查阅旧版 API 的使用,请参阅笔者的 这篇开荒博文 。
script-setup 方案已在 Vue 3.2.0-beta.1
版本中脱离实验状态,正式进入 Vue 3 的队伍,此后所有的新版本均可以作为一个官方标准的开发方案使用。
新特性的产生背景 在了解 script-setup 怎么用之前,可以先了解一下推出该语法糖的一些开发背景,通过对比开发体验上的异同点,了解为什么会有这个新模式。
在 Vue 3 的组件标准写法里,如果数据和方法需要在 <template />
里使用,都需要在 <script />
的 setup 函数里 return 出来。
如果使用的是 TypeScript ,还需要借助 defineComponent 对 API 类型进行自动推导。
vue <!-- 标准组件格式 -->
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // ...
+
+ return {
+ // ...
+ }
+ },
+} )
+</ script >
关于标准 setup 和 defineComponent 的说明和用法,可以查阅 全新的 setup 函数 一节。
script-setup 的推出是为了让熟悉 Vue 3 的开发者可以更高效率地开发组件,减少编码过程中的心智负担,只需要给 <script />
标签添加一个 setup
属性,那么整个 <script />
就直接会变成 setup 函数,所有顶级变量、函数,均会自动暴露给模板使用(无需再一个个 return 了)。
Vue 会通过单组件编译器,在编译的时候将其处理回标准组件,所以目前这个方案只适合用 .vue
文件写的工程化项目。
vue <!-- 使用 script-setup 格式 -->
+< script setup lang = " ts " >
+// ...
+</ script >
对,就是这样,代码量瞬间大幅度减少!
因为 script-setup 的大部分功能在书写上和标准版是一致的,因此下面的内容只提及有差异的写法。
全局编译器宏 在 script-setup 模式下,新增了 4 个全局编译器宏,他们无需 import 就可以直接使用。
但是默认的情况下直接使用,如果项目开启了 ESLint ,可能会提示 API 没有导入,但导入 API 后,控制台的 Vue 编译助手又会提示不需要导入,就很尴尬… 不过不用着急!可以通过配置 Lint 规则解决这个问题!
将这几个编译助手写进全局规则里,这样不导入也不会报错了。
js // 项目根目录下的 .eslintrc.js
+module.exports = {
+ // ...
+ // 在原来的 Lint 规则后面,补充下面的 \`globals\` 选项
+ globals : {
+ defineProps : ' readonly ' ,
+ defineEmits : ' readonly ' ,
+ defineExpose : ' readonly ' ,
+ withDefaults : ' readonly ' ,
+ },
+}
关于几个宏的说明都在下面的文档部分有说明,也可以从这里导航过去直接查看。
下面继续了解 script-setup 的变化。
template 操作简化 如果使用 JSX / TSX 写法,这一点没有太大影响,但对于习惯使用 <template />
的开发者来说,这是一个非常爽的体验。
主要体现在这两点:
变量无需进行 return 子组件无需手动注册 变量无需进行 return 标准组件模式下,变量和方法都需要在 setup 函数里 return 出去,才可以在 <template />
部分拿到。
vue <!-- 标准组件格式 -->
+< template >
+ < p > {{ msg }} </ p >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const msg = ' Hello World! '
+
+ // 要给 \`<template />\` 用的数据需要 \`return\` 出去才可以
+ return {
+ msg ,
+ }
+ },
+} )
+</ script >
在 script-setup 模式下,定义了就可以直接使用。
vue <!-- 使用 script-setup 格式 -->
+< template >
+ < p > {{ msg }} </ p >
+</ template >
+
+< script setup lang = " ts " >
+const msg = ' Hello World! '
+</ script >
子组件无需手动注册 子组件的挂载,在标准组件里的写法是需要 import 后再放到 components 里才能够启用:
vue <!-- 标准组件格式 -->
+< template >
+ < Child />
+</ template >
+
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+
+// 导入子组件
+import Child from ' @cp/Child.vue '
+
+export default defineComponent ( {
+ // 需要启用子组件作为模板
+ components : {
+ Child ,
+ },
+
+ // 组件里的业务代码
+ setup () {
+ // ...
+ },
+} )
+</ script >
在 script-setup 模式下,只需要导入组件即可,编译器会自动识别并启用。
vue <!-- 使用 script-setup 格式 -->
+< template >
+ < Child />
+</ template >
+
+< script setup lang = " ts " >
+import Child from ' @cp/Child.vue '
+</ script >
props 的接收方式变化 由于整个 script 都变成了一个大的 setup 函数,没有了组件选项,也没有了 setup 的入参,所以没办法和标准写法一样去接收 props 了。
这里需要使用一个全新的 API :defineProps 。
defineProps 是一个方法,内部返回一个对象,也就是挂载到这个组件上的所有 props ,它和普通的 props 用法一样,如果不指定为 props ,则传下来的属性会被放到 attrs 那边去。
defineProps 的基础用法 如果只是单纯在 <template />
里使用,那么这么简单定义就可以了:
ts defineProps ([ ' name ' , ' userInfo ' , ' tags ' ])
使用 string[]
数组作为入参,把 prop 的名称作为数组的 item 传给 defineProps 就可以了。
如果 <script />
里的方法要拿到 props 的值,也可以使用字面量定义:
ts const props = defineProps ([ ' name ' , ' userInfo ' , ' tags ' ])
+console . log (props . name)
但作为 Vue 的老玩家,都清楚不显性的指定 props 的类型很容易在协作中引起程序报错,那么应该如何对每个 prop 进行类型检查呢?
有两种方式来处理类型定义:
通过构造函数检查 prop 使用类型注解检查 prop 通过构造函数检查 prop 这是第一种方式:使用 JavaScript 原生构造函数进行类型规定,也就是跟平时定义 prop 类型时一样, Vue 会通过 instanceof
来进行 类型检查 。
使用这种方法,需要通过一个 “对象” 入参来传递给 defineProps ,比如:
ts defineProps ( {
+ name : String ,
+ userInfo : Object ,
+ tags : Array ,
+} )
所有原来 props 具备的校验机制,都可以适用,比如除了要限制类型外,还想指定 name
是可选,并且带有一个默认值:
ts defineProps ( {
+ name : {
+ type : String ,
+ required : false ,
+ default : ' Petter ' ,
+ },
+ userInfo : Object ,
+ tags : Array ,
+} )
更多的 props 校验机制,可以点击 带有类型限制的 props 和 可选以及带有默认值的 props 了解更多。
使用类型注解检查 prop 这是第二种方式:使用 TypeScript 的类型注解,和 ref 等 API 的用法一样,defineProps 也是可以使用尖括号 <> 来包裹类型定义,紧跟在 API 后面。
另外,由于 defineProps 返回的是一个对象(因为 props 本身是一个对象),所以尖括号里面的类型还要用大括号包裹,通过 key: value
的键值对形式表示,如:
ts defineProps <{ name : string }> ()
注意到了吗?这里使用的类型,和第一种方法提到的指定类型是不一样的。这里不再使用构造函数校验,而是需要遵循使用 TypeScript 的类型,比如字符串是 string
,而不是 String
。
如果有多个 prop ,就跟写 interface 一样:
ts defineProps <{
+ name : string
+ phoneNumber : number
+ userInfo : object
+ tags : string []
+}> ()
其中,举例里的 userInfo
是一个对象,可以简单的指定为 object,也可以先定义好它对应的类型,再进行指定:
ts interface UserInfo {
+ id : number
+ age : number
+}
+
+defineProps <{
+ name : string
+ userInfo : UserInfo
+}> ()
如果想对某个数据设置为可选,也是遵循 TS 规范,通过英文问号 ?
来允许可选:
ts // name 是可选
+defineProps <{
+ name ?: string
+ tags : string []
+}> ()
如果想设置可选参数的默认值,需要借助 withDefaults API。
WARNING
需要强调的一点是:在 构造函数 和 类型注解 这两种校验方式只能二选一,不能同时使用,否则会引起程序报错。
withDefaults 的基础用法 这个新的 withDefaults API 可以让在使用 TS 类型系统时,也可以指定 props 的默认值。
它接收两个入参:
参数 含义 props 通过 defineProps 传入的 props defaultValues 根据 props 的 key 传入默认值
光看描述可能不容易理解,看看下面这段演示代码会更直观:
ts withDefaults (
+ // 这是第一个参数,声明 props
+ defineProps <{
+ size ?: number
+ labels ?: string []
+ }> () ,
+ // 这是第二个参数,设置默认值
+ {
+ size : 3 ,
+ labels : () => [ ' default label ' ] ,
+ }
+)
也可以通过字面量获取 props :
ts // 上面的写法可能比较复杂,存在阅读成本
+// 也可以跟平时一样先通过 interface 声明其类型
+interface Props {
+ size ?: number
+ labels ?: string []
+}
+
+// 再作为 \`defineProps\` 的类型传入
+// 代码风格上会简洁很多
+const props = withDefaults ( defineProps < Props > () , {
+ size : 3 ,
+ labels : () => [ ' default label ' ] ,
+} )
+
+// 这样就可以通过 \`props\` 变量拿到需要的值
+console . log (props . size)
emits 的接收方式变化 和 props 一样,emits 的接收也是需要使用一个全新的 API 来操作,这个 API 就是 defineEmits 。
和 defineProps 一样, defineEmits 也是一个方法,它接受的入参格式和标准组件的要求是一致的。
defineEmits 的基础用法 需要通过字面量来定义 emits ,最基础的用法也是传递一个 string[]
数组进来,把每个 emit 的名称作为数组的 item 。
ts // 获取 emit
+const emit = defineEmits ([ ' update-name ' ])
+
+// 调用 emit
+emit ( ' update-name ' , ' Tom ' )
由于 defineEmits 的用法和原来的 emits 选项差别不大,这里也不重复说明更多的诸如校验之类的用法了,可以查看 接收 emits 一节了解更多。
attrs 的接收方式变化 attrs 和 props 很相似,也是基于父子通信的数据,如果父组件绑定下来的数据没有被指定为 props ,那么就会被 attrs 接收。
在标准组件里, attrs 的数据是通过 setup 的第二个入参 context 里的 attrs API 获取的。
ts // 标准组件的写法
+export default defineComponent ( {
+ setup ( props , { attrs }) {
+ // attrs 是个对象,每个 Attribute 都是它的 key
+ console . log ( attrs . class )
+
+ // 如果传下来的 Attribute 带有短横线,需要通过这种方式获取
+ console . log ( attrs [ ' data-hash ' ])
+ },
+} )
但和 props 一样,由于没有了 context 参数,需要使用一个新的 API 来拿到 attrs 数据,这个 API 就是 useAttrs 。
useAttrs 的基础用法 顾名思义, useAttrs 可以是用来获取 attrs 数据的,它的用法非常简单:
ts import { useAttrs } from ' vue '
+
+// 获取 attrs
+const attrs = useAttrs ()
+
+// attrs 是个对象,和 props 一样,需要通过 \`key\` 来得到对应的单个 attr
+console . log (attrs . msg)
对 attrs 不太了解的话,可以查阅 获取非 Prop 的 Attribute
slots 的接收方式变化 slots 是 Vue 组件的插槽数据,也是在父子通信里的一个重要成员。
对于使用 <template />
的开发者来说,在 script-setup 里获取插槽数据并不困难,因为跟标准组件的写法是完全一样的,可以直接在 <template />
里使用 <slot />
标签渲染。
vue < template >
+ < div >
+ <!-- 插槽数据 -->
+ < slot />
+ <!-- 插槽数据 -->
+ </ div >
+</ template >
但对使用 JSX / TSX 的开发者来说,就影响比较大了,在标准组件里,想在 script 里获取插槽数据,也是需要在 setup 的第二个入参里拿到 slots API 。
ts // 标准组件的写法
+export default defineComponent ( {
+ // 这里的 slots 就是插槽
+ setup ( props , { slots }) {
+ // ...
+ },
+} )
新版本的 Vue 也提供了一个全新的 useSlots API 来帮助 script-setup 用户获取插槽。
useSlots 的基础用法 先来看看父组件,父组件先为子组件传入插槽数据,支持 “默认插槽” 和 “命名插槽” :
vue < template >
+ <!-- 子组件 -->
+ < ChildTSX >
+ <!-- 默认插槽 -->
+ < p > Default slot for TSX. </ p >
+ <!-- 默认插槽 -->
+
+ <!-- 命名插槽 -->
+ < template # msg >
+ < p > Named slot for TSX. </ p >
+ </ template >
+ <!-- 命名插槽 -->
+ </ ChildTSX >
+ <!-- 子组件 -->
+</ template >
+
+< script setup lang = " ts " >
+// 实际上是导入 ChildTSX.tsx 文件,扩展名默认可以省略
+import ChildTSX from ' @cp/ChildTSX '
+</ script >
在使用 JSX / TSX 编写的子组件里,就可以通过 useSlots 来获取父组件传进来的 slots 数据进行渲染:
tsx // src/components/ChildTSX.tsx
+import { defineComponent , useSlots } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 获取插槽数据
+ const slots = useSlots ()
+
+ // 渲染组件
+ return () => (
+ < div >
+ { /* 渲染默认插槽 */ }
+ < p >{ slots . default ? slots . default () : '' }</ p >
+
+ { /* 渲染命名插槽 */ }
+ < p >{ slots . msg ? slots . msg () : '' }</ p >
+ </ div >
+ )
+ },
+} )
请注意,这里的 TSX 组件代码需要使用 .tsx
作为文件扩展名,并且构建工具可能默认没有对 JSX / TSX 作支持,以 Vite 为例,需要安装官方提供的 JSX / TSX 支持插件才可以正常使用。
bash # 该插件支持使用 JSX 或 TSX 作为 Vue 组件
+npm i -D @vitejs/plugin-vue-jsx
并在 vite.config.ts 里启用插件,添加对 JSX 和 TSX 的支持。
ts // vite.config.ts
+import { defineConfig } from ' vite '
+import vueJsx from ' @vitejs/plugin-vue-jsx '
+
+export default defineConfig ( {
+ // ...
+ plugins : [
+ // ...
+ // 启用插件
+ vueJsx () ,
+ ] ,
+} )
如果还存在报错的情况,可以检查项目下的 tsconfig.json 文件里,编译选项 jsx
是否设置为 preserve
:
json {
+ " compilerOptions " : {
+ " jsx " : " preserve "
+ }
+}
ref 的通信方式变化 在标准组件写法里,子组件的数据和方法可以通过在 setup 里 return 出来给父组件调用,也就是父组件可以通过 childComponent.value.foo
这样的方式直接操作子组件的数据(参见:DOM 元素与子组件 - 响应式 API 之 ref )。
但在 script-setup 模式下,所有数据只是默认隐式 return 给 <template />
使用,不会暴露到组件外,所以父组件是无法直接通过挂载 ref 变量获取子组件的数据。
在 script-setup 模式下,如果要调用子组件的数据,需要先在子组件显式的暴露出来,才能够正确的拿到,这个操作,就是由 defineExpose API 来完成。
defineExpose 的基础用法 defineExpose 的用法非常简单,它本身是一个函数,可以接受一个对象参数。
在子组件里,像这样把需要暴露出去的数据通过 key: value
的形式作为入参(下面的例子是用到了 ES6 的 属性的简洁表示法 ):
vue < script setup lang = " ts " >
+const msg = ' Hello World! '
+
+// 通过该 API 显式暴露的数据,才可以在父组件拿到
+defineExpose ( {
+ msg ,
+} )
+</ script >
然后在父组件就可以通过挂载在子组件上的 ref 变量,去拿到暴露出来的数据了。
顶级 await 的支持 在 script-setup 模式下,不必再配合 async 就可以直接使用 await 了,这种情况下,组件的 setup 会自动变成 async setup 。
vue < script setup lang = " ts " >
+const res = await fetch ( \` https://example.com/api/foo \` )
+const json = await res . json ()
+console . log (json)
+</ script >
它转换成标准组件的写法就是:
vue < script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ async setup () {
+ const res = await fetch ( \` https://example.com/api/foo \` )
+ const json = await res . json ()
+ console . log ( json )
+
+ return {
+ json ,
+ }
+ },
+} )
+</ script >
命名技巧 对于接触编程不久的开发者,在个人练习 demo 或者简单的代码片段里可能会经常看到 var a
、 var b
这样的命名,因为本身是一段练习代码,因此都是 “能跑就行”,问题不大。
但在工作中,很多开发团队都会有语义化命名的规范要求,严格的团队会有 Code Review 环节,使用这种无意义命名的代码将无法通过审查,在这种背景下,开发者可能会在命名上花费很多时间,在这里也分享笔者的一些常用技巧,希望能够帮助开发者节约在命名上的时间开销。
文件命名技巧 在开始讲变量命名之前,先说说文件的命名,因为代码都是保存在文件里,并且可能会互相引用,如果后期再修改文件名或者保存位置而忘记更新代码里的引用路径,那么就会影响程序编译和运行。
Vue 组件 在 Vue 项目里,会有放在 views 下的路由组件,有放在 components 目录下的公共组件,虽然都是以 .vue
为扩展名的 Vue 组件文件,但根据用途,它们其实并不相同,因此命名上也有不同的技巧。
路由组件 路由组件组件通常存放在 src/views 目录下,在命名上容易困惑的点应该是风格问题,开发者容易陷入是使用 camelCase 小驼峰还是 kebab-case 短横线风格,或者是 snake_case 下划线风格的选择困难。
一般情况下路由组件都是以单个名词或动词进行命名,例如个人资料页使用 profile
命名路由,路由的访问路径使用 /profile
,对应的路由组件使用 profile.vue
命名,下面是几个常见的例子。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 首页
+ // e.g. \`https://example.com/\`
+ {
+ path : ' / ' ,
+ name : ' home ' ,
+ component : () => import ( ' @views/home.vue ' ) ,
+ },
+ // 个人资料页
+ // e.g. \`https://example.com/profile\`
+ {
+ path : ' /profile ' ,
+ name : ' profile ' ,
+ component : () => import ( ' @views/profile.vue ' ) ,
+ },
+ // 登录页
+ // e.g. \`https://example.com/login\`
+ {
+ path : ' /login ' ,
+ name : ' login ' ,
+ component : () => import ( ' @views/login.vue ' ) ,
+ },
+]
+
+export default routes
如果是一些数据列表类的页面,使用名词复数,或者名词单数加上 -list
结尾的 kebab-case 短横线风格写法,推荐短横线风格是因为在 URL 的风格设计里更为常见。
像文章列表可以使用 articles
或者 article-list
,但同一个项目建议只使用其中一种方式,以保持整个项目的风格统一,下面是几个常见的例子。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 文章列表页
+ // 翻页逻辑是改变页码进行跳转,因此需要添加动态参数 \`:page\`
+ // 可以在组件内使用路由实例 \`route.params.page\` 拿到页码
+ // e.g. \`https://example.com/articles/1\`
+ {
+ path : ' /articles/:page ' ,
+ name : ' articles ' ,
+ component : () => import ( ' @views/articles.vue ' ) ,
+ },
+ // 通知列表页
+ // 翻页逻辑使用 AJAX 无刷翻页,这种情况则可以不配置页码参数
+ // e.g. \`https://example.com/notifications\`
+ {
+ path : ' /notifications ' ,
+ name : ' notifications ' ,
+ component : () => import ( ' @views/notifications.vue ' ) ,
+ },
+]
+
+export default routes
列表里的资源详情页,因为访问的时候通常会带上具体的 ID 以通过接口查询详情数据,这种情况下资源就继续使用单数,例如下面这个例子。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 文章详情页
+ // 可以在组件内使用路由实例 \`route.params.id\` 拿到文章 ID
+ // e.g. \`https://example.com/article/1\`
+ {
+ path : ' /article/:id ' ,
+ name : ' article ' ,
+ component : () => import ( ' @views/article.vue ' ) ,
+ },
+]
+
+export default routes
如果项目路由比较多,通常会对同一业务的路由增加文件夹归类,因此上面的文章列表和文章详情页,可以统一放到 article 目录下,使用 list
和 detail
区分是列表还是详情。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 文章相关的路由统一放在这里管理
+ {
+ path : ' /article ' ,
+ name : ' article ' ,
+ // 这是一个配置了 \`<router-view />\` 标签的路由中转站组件
+ // 目的是使其可以渲染子路由
+ component : () => import ( ' @cp/TransferStation.vue ' ) ,
+ // 由于父级路由没有内容,所以重定向至列表的第 1 页
+ // e.g. \`https://example.com/article\`
+ redirect : {
+ name : ' article-list ' ,
+ params : {
+ page : 1 ,
+ },
+ },
+ children : [
+ // 文章列表页
+ // e.g. \`https://example.com/article/list/1\`
+ {
+ path : ' list/:page ' ,
+ name : ' article-list ' ,
+ component : () => import ( ' @views/article/list.vue ' ) ,
+ },
+ // 文章详情页
+ // e.g. \`https://example.com/article/detail/1\`
+ {
+ path : ' detail/:id ' ,
+ name : ' article-detail ' ,
+ component : () => import ( ' @views/article/detail.vue ' ) ,
+ },
+ ] ,
+ },
+]
+
+export default routes
对于一些需要用多个单词才能描述的资源,可以使用 kebab-case 短横线风格命名,例如很常见的 “策划面对面” 这种栏目,在设置路由时,比较难用一个单词在 URL 里体现其含义,就需要使用这种多个单词的连接。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 面对面栏目
+ {
+ path : ' /face-to-face ' ,
+ name : ' face-to-face ' ,
+ component : () => import ( ' @views/face-to-face.vue ' ) ,
+ },
+]
+
+export default routes
这种情况如果需要使用文件夹管理多个路由,同样建议使用 kebab-case 短横线风格命名,例如上面这个 “策划面对面” 栏目,可能会归属于 “开发计划” 这个业务下,那么其父级文件夹就可以使用 development-plan
这样的短横线命名。
公共组件 公共组件组件通常存放在 src/components 目录下,也可以视不同的使用情况,在路由文件夹下创建属于当前路由的 components 目录,作为一个小范围共享的公共组件目录来管理,而 src/components 则只存放全局性质的公共组件。
本节最开始提到了路由组件和公共组件并不相同,虽然都是组件,但路由组件代表的是整个页面,而公共组件更多是作为一个页面上的某个可复用的部件,如果开发者写过 Flutter ,应该能够更深刻的理解到这里的公共组件更接近于 Widget 性质的小部件。
公共组件通常使用 PascalCase 帕斯卡命名法,也就是大驼峰,为什么不用小驼峰呢?
这是源于 Vue 官网的一个 组件名格式 命名推荐:
使用 PascalCase 作为组件名的注册格式,这是因为: PascalCase 是合法的 JavaScript 标识符。这使得在 JavaScript 中导入和注册组件都很容易,同时 IDE 也能提供较好的自动补全。 ><PascalCase />
在模板中更明显地表明了这是一个 Vue 组件,而不是原生 HTML 元素。同时也能够将 Vue 组件和自定义元素( web components )区分开来。
而且实际使用 PascalCase 风格的编码过程中,在 VSCode 里可以得到不同颜色的高亮效果,这与 kebab-case 风格的 HTML 标签可以快速区分。
vue < template >
+ <!-- 普通的 HTML 标签 -->
+ <!-- 在笔者的 VSCode 风格里呈现为桃红色 -->
+ < div ></ div >
+
+ <!-- 大驼峰组件 -->
+ <!-- 在笔者的 VSCode 风格里呈现为绿色 -->
+ < PascalCase />
+</ template >
养成这种习惯还有一个好处,就是使用 UI 框架的时候,例如 Ant Design Vue 的 Select 组件 ,在其文档上演示的是全局安装的写法:
vue < template >
+ < a-select >
+ < a-select-option value = " Hello " > Hello </ a-select-option >
+ </ a-select >
+</ template >
而实际使用时,为了更好的配合构建工具进行 Tree Shaking 移除没有用到的组件,都是按需引入 UI 框架的组件,因此如果平时有养成习惯使用 PascalCase 命名,就可以很轻松的知道上面的 <a-select-option />
组件应该对应的是 <SelectOption />
,因此是这样按需导入:
ts import { Select , SelectOption } from ' ant-design-vue '
可以说, PascalCase 这个命名方式也是目前流行 UI 框架都在使用的命名规范。
TypeScript 文件 在 Vue 项目里,虽然 TypeScript 代码可以写在组件里,但由于很多功能实现是可以解耦并复用,所以经常会有专门的目录管理公共方法,这样做也可以避免在一个组件里写出一两千行代码从而导致维护成本提高。
libs 文件 笔者习惯将这些方法统一放到 src/libs 目录下,按照业务模块或者功能的相似度,以一个名词或者动词作为文件命名。
例如常用的正则表达式,可以归类到 regexp.ts 里。
ts // src/libs/regexp.ts
+
+// 校验手机号格式
+export function isMob ( phoneNumber : number | string ) {
+ // ...
+}
+
+// 校验电子邮箱格式
+export function isEmail ( email : string ) {
+ // ...
+}
+
+// 校验网址格式
+export function isUrl ( url : string ) {
+ // ...
+}
+
+// 校验身份证号码格式
+export function isIdCard ( idCardNumber : string ) {
+ // ...
+}
+
+// 校验银行卡号码格式
+export function isBankCard ( bankCardNumber : string ) {
+ // ...
+}
统一使用命名导出,这样一个 TS 文件就像一个 npm 包一样,在使用的时候就可以从这个 “包” 里面导出各种要用到的方法直接使用,无需在组件里重复编写判断逻辑。
ts import { isMob , isEmail } from ' @libs/regexp '
其他诸如常用到的短信验证 sms.ts 、登录逻辑 login.ts 、数据格式转换 format.ts 都可以像这样单独抽出来封装,这种与业务解耦的封装方式非常灵活,以后不同项目如果也有类似的需求,就可以直接拿过去复用了!
types 文件 对于经常用到的 TypeScript 类型,也可以抽离成公共文件,笔者习惯在 src/types 目录管理公共类型,统一使用 .ts
作为扩展名并在里面导出 TS 类型,而不使用 .d.ts
这个类型声明文件。
这样做的好处是在使用到相应类型时,可以通过 import type
显式导入,在后期的项目维护过程中,可以很明确的知道类型来自于哪里,并且更接近从 npm 包里导入类型使用的开发方式。
例如上文配置路由的例子里,就是从 Vue Router 里导入了路由的类型:
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // ...
+]
+
+export default routes
在 types 目录下,可以按照业务模块创建多个模块文件分别维护不同的 TS 类型,并统一在 index.ts 里导出:
bash src
+└─types
+ │ # 入口文件
+ ├─index.ts
+ │ # 管理不同业务的公共类型
+ ├─user.ts
+ ├─game.ts
+ └─news.ts
例如 game.ts 可以维护经常用到的游戏业务相关类型,其中为了避免和其他模块命名冲突,以及一眼可以看出是来自哪个业务的类型,可以统一使用业务模块的名称作为前缀。
ts // src/types/game.ts
+
+// 游戏公司信息
+export interface GameCompany {
+ // ...
+}
+
+// 游戏信息
+export interface GameInfo {
+ id : number
+ name : string
+ gameCompany : GameCompany
+ // ...
+}
将该模块的所有类型在 index.ts 里全部导出:
ts // src/types/index.ts
+export * from ' ./game '
在组件里就可以这样使用该类型:
ts // 可以从 \`types\` 里统一导入,而不必明确到 \`types/game\`
+import type { GameInfo } from ' @/types '
+
+const game : GameInfo = {
+ id : 1 ,
+ name : ' Contra ' ,
+ gameCompany : {},
+}
+console . log (game)
TS 类型都遵循 PascalCase 命名风格,方便和声明的变量作为区分,大部分情况下一看到 GameInfo
就知道是类型,而 gameInfo
则是一个变量。
代码命名技巧 在编写 JavaScript / TypeScript 时,为变量和函数的命名也是新手开发者容易花费比较多时间的一件事情,笔者也分享自己常用的命名套路,可以大幅度降低命名的思考时间,而且可以体现一定的语义化。
变量的命名 首先笔者遵循变量只使用 camelCase 小驼峰风格的基本原则,并且根据不同的类型,搭配不同的命名前缀或后缀。
对于 string
字符串类型,使用相关的名词命名即可。
ts import { ref } from ' vue '
+
+// 用户名
+const username = ref < string > ( ' Petter ' )
+
+// 职业
+const profession = ref < string > ( ' Front-end Engineer ' )
对于 number
数值类型,除了一些本身可以代表数字的名词,例如年龄 age
、秒数 seconds
,其他的情况可以搭配后缀命名,常用的后缀有 Count
、 Number
、 Size
、 Amount
等和单位有关的名词。
ts import { ref } from ' vue '
+
+// 最大数量
+const maxCount = ref < number > ( 100 )
+
+// 页码
+const pageNumber = ref < number > ( 1 )
+
+// 每页条数
+const pageSize = ref < number > ( 10 )
+
+// 折扣金额
+const discountAmount = ref < number > ( 50 )
对于 boolean
布尔值类型,可搭配 is
、 has
等 Be 动词或判断类的动词作为前缀命名,并视情况搭配行为动词和目标名词,或者直接使用一些状态形容词。
ts import { ref } from ' vue '
+
+// 是否显示弹窗
+const isShowDialog = ref < boolean > ( false )
+
+// 用户是否为 VIP 会员
+const isVIP = ref < boolean > ( true )
+
+// 用户是否有头像
+const hasAvatar = ref < boolean > ( true )
+
+// 是否被禁用
+const disabled = ref < boolean > ( true )
+
+// 是否可见
+const visible = ref < boolean > ( true )
之所以要搭配 is
开头,是为了和函数区分,例如 showDialog()
是显示弹窗的方法,而 isShowDialog
才是一个布尔值用于逻辑判断。
对于数组,通常使用名词的复数形式,或者名词加上 List
结尾作为命名,数组通常会有原始数据类型的数组,也有 JSON 对象数组,笔者习惯对前者使用名词复数,对后者使用 List
结尾。
ts import { ref } from ' vue '
+
+// 每个 Item 都是字符串
+const tags = ref < string > ([ ' 食物 ' , ' 粤菜 ' , ' 卤水 ' ])
+
+// 每个 Item 都是数值
+const tagIds = ref < number > ([ 1 , 2 , 3 ])
+
+// 每个 Item 都是 JSON 对象
+const memberList = ref < Member [] > ([
+ {
+ id : 1 ,
+ name : ' Petter ' ,
+ },
+ {
+ id : 2 ,
+ name : ' Marry ' ,
+ },
+])
如果是作为函数的入参,通常也遵循变量的命名规则。
除非是一些代码量很少的操作,可以使用 i
、 j
等单个字母的变量名,例如提交接口参数时,经常只需要提交一个 ID 数组,从 JSON 数组里提取 ID 数组时就可以使用这种简短命名。
ts // \`map\` 的参数命名就可以使用 \`i\` 这种简短命名
+const ids = dataList . map ( ( i ) => i . id)
函数的命名 函数的命名也是只使用 camelCase 小驼峰风格,通常根据该函数是同步操作还是异步操作,使用不同的动词前缀。
获取数据的函数,通常使用 get
、 query
、 read
等代表会返回数据的动词作为前缀,如果还是觉得很难确定使用哪一个,可以统一使用 get
,也可以根据函数的操作性质来决定:
如果是同步操作,不涉及接口请求,使用 get
作为前缀 如果是需要从 API 接口查询数据的异步操作,使用 query
作为前缀 如果是 Node.js 程序这种需要进行文件内容读取的场景,就使用 read
ts // 从本地存储读取数据
+// 因为是同步操作,所以使用 \`get\` 前缀
+function getLoginInfo () {
+ try {
+ const info = localStorage . getItem ( ' loginInfo ' )
+ return info ? JSON . parse ( info ) : null
+ } catch ( e ) {
+ return null
+ }
+}
+
+// 从接口查询数据
+// 因为是异步操作,需要去数据库查数据,所以使用 \`query\` 前缀
+async function queryMemberInfo ( id : number ) {
+ try {
+ const res = await fetch ( \` https://example.com/api/member/ \${ id }\` )
+ const json = await res . json ()
+ return json
+ } catch ( e ) {
+ return null
+ }
+}
修改数据的函数,通常使用 save
、 update
、 delete
等会变更数据的动词作为前缀,一般情况下:
数据存储可以统一使用 save
如果要区分新建或者更新操作,可以对新建操作使用 create
,对更新操作使用 update
删除使用 delete
或 remove
如果是 Node.js 程序需要对文件写入内容,使用 write
表单验证合法性等场景,可以使用 verify
或 check
切换可见性可以用 show
和 hide
,如果是写在一个函数里,可以使用 toggle
发送验证码、发送邮件等等可以使用 send
打开路由、打开外部 URL 可以使用 open
当然以上只是一些常用到的命名技巧建议,对于简单的业务,例如一个 H5 活动页面,也可以在同步操作时使用 set
表示可以直接设置,在异步操作时使用 save
表示需要提交保存。
ts // 将数据保存至本地存储
+// 因为是同步操作,所以使用 \`set\` 前缀
+function setLoginInfo ( info : LoginInfo ) {
+ try {
+ localStorage . setItem ( ' loginInfo ' , JSON . stringify ( info ))
+ return true
+ } catch ( e ) {
+ return false
+ }
+}
+
+// 将数据通过接口保存到数据库
+// 因为是异步操作,所以使用 \`save\` 前缀
+async function saveMemberInfo ( id : number , data : MemberDTO ) {
+ try {
+ const res = await fetch ( \` https://example.com/api/member/ \${ id }\` , {
+ method : ' POST ' ,
+ body : JSON . stringify ( data ) ,
+ } )
+ const json = await res . json ()
+ return json . code === 200
+ } catch ( e ) {
+ return false
+ }
+}
Class 类上的方法和函数命名规则一样,但 Class 本身使用 PascalCase 命名法,代表这是一个类,在调用的时候需要 new
。
ts // 类使用 PascalCase 命名法
+class Hello {
+ name : string
+
+ constructor ( name : string ) {
+ this. name = name
+ }
+
+ say () {
+ console . log ( \` Hello \${ this. name }\` )
+ }
+}
+
+const hello = new Hello ( ' World ' )
+hello . say () // Hello World
希望曾经在命名上有过困扰的开发者,不再有此烦恼,编写代码更加高效率!
`,217);function D(F,i,C,A,d,u){const a=n("GitalkComment"),l=n("ClientOnly");return o(),e("div",null,[y,s(l,null,{default:t(()=>[s(a,{issueId:118})]),_:1})])}const h=p(r,[["render",D]]);export{g as __pageData,h as default};
diff --git a/assets/efficient.md.a41d0892.lean.js b/assets/efficient.md.a41d0892.lean.js
new file mode 100644
index 00000000..cac3dc46
--- /dev/null
+++ b/assets/efficient.md.a41d0892.lean.js
@@ -0,0 +1,552 @@
+/**
+ * name: learning-vue3
+ * version: v2.0.0
+ * description: A starting learning tutorial on Vue 3.0 + TypeScript, suitable for complete Vue novices and Vue 2.0 veterans, incorporating some of my own practical experience on the basis of official documents.
+ * author: chengpeiquan
+ * homepage: https://vue3.chengpeiquan.com
+ */
+import{_ as p,v as o,b as e,t as s,O as t,R as c,M as n}from"./chunks/framework.0d8bea05.js";const g=JSON.parse('{"title":"高效开发","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"efficient.md","filePath":"efficient.md"}'),r={name:"efficient.md"},y=c(`高效开发 可能很多开发者(包括笔者)刚上手 Vue 3 的那段时间,都会觉得开发过程似乎变得更繁琐了, Vue 官方团队当然不会无视群众的呼声,如果基于脚手架和 .vue
文件开发,那么可以享受到更高效率的开发体验。
在阅读这一章之前,需要对 Vue 3 的单组件开发有一定的了解,如果还处于完全没有接触过的阶段,请先抽点时间阅读 单组件的编写 一章。
TIP
要体验以下新特性,请确保项目下 package.json 里的 vue 版本在 3.2.0
以上,最好同步 npm 上当前最新的 @latest 版本,否则可能出现 API 未定义等问题。
script-setup ~new script-setup 是 Vue 3 组件的一个语法糖,旨在帮助开发者降低 setup 函数需要 return 的心智负担。
Vue 的 3.1.2
版本是针对 script-setup 的一个分水岭版本,自 3.1.4
开始 script-setup 进入定稿状态,部分旧的 API 已被舍弃,本章节内容将以最新的 API 为准进行整理说明,如果需要查阅旧版 API 的使用,请参阅笔者的 这篇开荒博文 。
script-setup 方案已在 Vue 3.2.0-beta.1
版本中脱离实验状态,正式进入 Vue 3 的队伍,此后所有的新版本均可以作为一个官方标准的开发方案使用。
新特性的产生背景 在了解 script-setup 怎么用之前,可以先了解一下推出该语法糖的一些开发背景,通过对比开发体验上的异同点,了解为什么会有这个新模式。
在 Vue 3 的组件标准写法里,如果数据和方法需要在 <template />
里使用,都需要在 <script />
的 setup 函数里 return 出来。
如果使用的是 TypeScript ,还需要借助 defineComponent 对 API 类型进行自动推导。
vue <!-- 标准组件格式 -->
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // ...
+
+ return {
+ // ...
+ }
+ },
+} )
+</ script >
关于标准 setup 和 defineComponent 的说明和用法,可以查阅 全新的 setup 函数 一节。
script-setup 的推出是为了让熟悉 Vue 3 的开发者可以更高效率地开发组件,减少编码过程中的心智负担,只需要给 <script />
标签添加一个 setup
属性,那么整个 <script />
就直接会变成 setup 函数,所有顶级变量、函数,均会自动暴露给模板使用(无需再一个个 return 了)。
Vue 会通过单组件编译器,在编译的时候将其处理回标准组件,所以目前这个方案只适合用 .vue
文件写的工程化项目。
vue <!-- 使用 script-setup 格式 -->
+< script setup lang = " ts " >
+// ...
+</ script >
对,就是这样,代码量瞬间大幅度减少!
因为 script-setup 的大部分功能在书写上和标准版是一致的,因此下面的内容只提及有差异的写法。
全局编译器宏 在 script-setup 模式下,新增了 4 个全局编译器宏,他们无需 import 就可以直接使用。
但是默认的情况下直接使用,如果项目开启了 ESLint ,可能会提示 API 没有导入,但导入 API 后,控制台的 Vue 编译助手又会提示不需要导入,就很尴尬… 不过不用着急!可以通过配置 Lint 规则解决这个问题!
将这几个编译助手写进全局规则里,这样不导入也不会报错了。
js // 项目根目录下的 .eslintrc.js
+module.exports = {
+ // ...
+ // 在原来的 Lint 规则后面,补充下面的 \`globals\` 选项
+ globals : {
+ defineProps : ' readonly ' ,
+ defineEmits : ' readonly ' ,
+ defineExpose : ' readonly ' ,
+ withDefaults : ' readonly ' ,
+ },
+}
关于几个宏的说明都在下面的文档部分有说明,也可以从这里导航过去直接查看。
下面继续了解 script-setup 的变化。
template 操作简化 如果使用 JSX / TSX 写法,这一点没有太大影响,但对于习惯使用 <template />
的开发者来说,这是一个非常爽的体验。
主要体现在这两点:
变量无需进行 return 子组件无需手动注册 变量无需进行 return 标准组件模式下,变量和方法都需要在 setup 函数里 return 出去,才可以在 <template />
部分拿到。
vue <!-- 标准组件格式 -->
+< template >
+ < p > {{ msg }} </ p >
+</ template >
+
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ const msg = ' Hello World! '
+
+ // 要给 \`<template />\` 用的数据需要 \`return\` 出去才可以
+ return {
+ msg ,
+ }
+ },
+} )
+</ script >
在 script-setup 模式下,定义了就可以直接使用。
vue <!-- 使用 script-setup 格式 -->
+< template >
+ < p > {{ msg }} </ p >
+</ template >
+
+< script setup lang = " ts " >
+const msg = ' Hello World! '
+</ script >
子组件无需手动注册 子组件的挂载,在标准组件里的写法是需要 import 后再放到 components 里才能够启用:
vue <!-- 标准组件格式 -->
+< template >
+ < Child />
+</ template >
+
+< script lang = " ts " >
+import { defineComponent } from ' vue '
+
+// 导入子组件
+import Child from ' @cp/Child.vue '
+
+export default defineComponent ( {
+ // 需要启用子组件作为模板
+ components : {
+ Child ,
+ },
+
+ // 组件里的业务代码
+ setup () {
+ // ...
+ },
+} )
+</ script >
在 script-setup 模式下,只需要导入组件即可,编译器会自动识别并启用。
vue <!-- 使用 script-setup 格式 -->
+< template >
+ < Child />
+</ template >
+
+< script setup lang = " ts " >
+import Child from ' @cp/Child.vue '
+</ script >
props 的接收方式变化 由于整个 script 都变成了一个大的 setup 函数,没有了组件选项,也没有了 setup 的入参,所以没办法和标准写法一样去接收 props 了。
这里需要使用一个全新的 API :defineProps 。
defineProps 是一个方法,内部返回一个对象,也就是挂载到这个组件上的所有 props ,它和普通的 props 用法一样,如果不指定为 props ,则传下来的属性会被放到 attrs 那边去。
defineProps 的基础用法 如果只是单纯在 <template />
里使用,那么这么简单定义就可以了:
ts defineProps ([ ' name ' , ' userInfo ' , ' tags ' ])
使用 string[]
数组作为入参,把 prop 的名称作为数组的 item 传给 defineProps 就可以了。
如果 <script />
里的方法要拿到 props 的值,也可以使用字面量定义:
ts const props = defineProps ([ ' name ' , ' userInfo ' , ' tags ' ])
+console . log (props . name)
但作为 Vue 的老玩家,都清楚不显性的指定 props 的类型很容易在协作中引起程序报错,那么应该如何对每个 prop 进行类型检查呢?
有两种方式来处理类型定义:
通过构造函数检查 prop 使用类型注解检查 prop 通过构造函数检查 prop 这是第一种方式:使用 JavaScript 原生构造函数进行类型规定,也就是跟平时定义 prop 类型时一样, Vue 会通过 instanceof
来进行 类型检查 。
使用这种方法,需要通过一个 “对象” 入参来传递给 defineProps ,比如:
ts defineProps ( {
+ name : String ,
+ userInfo : Object ,
+ tags : Array ,
+} )
所有原来 props 具备的校验机制,都可以适用,比如除了要限制类型外,还想指定 name
是可选,并且带有一个默认值:
ts defineProps ( {
+ name : {
+ type : String ,
+ required : false ,
+ default : ' Petter ' ,
+ },
+ userInfo : Object ,
+ tags : Array ,
+} )
更多的 props 校验机制,可以点击 带有类型限制的 props 和 可选以及带有默认值的 props 了解更多。
使用类型注解检查 prop 这是第二种方式:使用 TypeScript 的类型注解,和 ref 等 API 的用法一样,defineProps 也是可以使用尖括号 <> 来包裹类型定义,紧跟在 API 后面。
另外,由于 defineProps 返回的是一个对象(因为 props 本身是一个对象),所以尖括号里面的类型还要用大括号包裹,通过 key: value
的键值对形式表示,如:
ts defineProps <{ name : string }> ()
注意到了吗?这里使用的类型,和第一种方法提到的指定类型是不一样的。这里不再使用构造函数校验,而是需要遵循使用 TypeScript 的类型,比如字符串是 string
,而不是 String
。
如果有多个 prop ,就跟写 interface 一样:
ts defineProps <{
+ name : string
+ phoneNumber : number
+ userInfo : object
+ tags : string []
+}> ()
其中,举例里的 userInfo
是一个对象,可以简单的指定为 object,也可以先定义好它对应的类型,再进行指定:
ts interface UserInfo {
+ id : number
+ age : number
+}
+
+defineProps <{
+ name : string
+ userInfo : UserInfo
+}> ()
如果想对某个数据设置为可选,也是遵循 TS 规范,通过英文问号 ?
来允许可选:
ts // name 是可选
+defineProps <{
+ name ?: string
+ tags : string []
+}> ()
如果想设置可选参数的默认值,需要借助 withDefaults API。
WARNING
需要强调的一点是:在 构造函数 和 类型注解 这两种校验方式只能二选一,不能同时使用,否则会引起程序报错。
withDefaults 的基础用法 这个新的 withDefaults API 可以让在使用 TS 类型系统时,也可以指定 props 的默认值。
它接收两个入参:
参数 含义 props 通过 defineProps 传入的 props defaultValues 根据 props 的 key 传入默认值
光看描述可能不容易理解,看看下面这段演示代码会更直观:
ts withDefaults (
+ // 这是第一个参数,声明 props
+ defineProps <{
+ size ?: number
+ labels ?: string []
+ }> () ,
+ // 这是第二个参数,设置默认值
+ {
+ size : 3 ,
+ labels : () => [ ' default label ' ] ,
+ }
+)
也可以通过字面量获取 props :
ts // 上面的写法可能比较复杂,存在阅读成本
+// 也可以跟平时一样先通过 interface 声明其类型
+interface Props {
+ size ?: number
+ labels ?: string []
+}
+
+// 再作为 \`defineProps\` 的类型传入
+// 代码风格上会简洁很多
+const props = withDefaults ( defineProps < Props > () , {
+ size : 3 ,
+ labels : () => [ ' default label ' ] ,
+} )
+
+// 这样就可以通过 \`props\` 变量拿到需要的值
+console . log (props . size)
emits 的接收方式变化 和 props 一样,emits 的接收也是需要使用一个全新的 API 来操作,这个 API 就是 defineEmits 。
和 defineProps 一样, defineEmits 也是一个方法,它接受的入参格式和标准组件的要求是一致的。
defineEmits 的基础用法 需要通过字面量来定义 emits ,最基础的用法也是传递一个 string[]
数组进来,把每个 emit 的名称作为数组的 item 。
ts // 获取 emit
+const emit = defineEmits ([ ' update-name ' ])
+
+// 调用 emit
+emit ( ' update-name ' , ' Tom ' )
由于 defineEmits 的用法和原来的 emits 选项差别不大,这里也不重复说明更多的诸如校验之类的用法了,可以查看 接收 emits 一节了解更多。
attrs 的接收方式变化 attrs 和 props 很相似,也是基于父子通信的数据,如果父组件绑定下来的数据没有被指定为 props ,那么就会被 attrs 接收。
在标准组件里, attrs 的数据是通过 setup 的第二个入参 context 里的 attrs API 获取的。
ts // 标准组件的写法
+export default defineComponent ( {
+ setup ( props , { attrs }) {
+ // attrs 是个对象,每个 Attribute 都是它的 key
+ console . log ( attrs . class )
+
+ // 如果传下来的 Attribute 带有短横线,需要通过这种方式获取
+ console . log ( attrs [ ' data-hash ' ])
+ },
+} )
但和 props 一样,由于没有了 context 参数,需要使用一个新的 API 来拿到 attrs 数据,这个 API 就是 useAttrs 。
useAttrs 的基础用法 顾名思义, useAttrs 可以是用来获取 attrs 数据的,它的用法非常简单:
ts import { useAttrs } from ' vue '
+
+// 获取 attrs
+const attrs = useAttrs ()
+
+// attrs 是个对象,和 props 一样,需要通过 \`key\` 来得到对应的单个 attr
+console . log (attrs . msg)
对 attrs 不太了解的话,可以查阅 获取非 Prop 的 Attribute
slots 的接收方式变化 slots 是 Vue 组件的插槽数据,也是在父子通信里的一个重要成员。
对于使用 <template />
的开发者来说,在 script-setup 里获取插槽数据并不困难,因为跟标准组件的写法是完全一样的,可以直接在 <template />
里使用 <slot />
标签渲染。
vue < template >
+ < div >
+ <!-- 插槽数据 -->
+ < slot />
+ <!-- 插槽数据 -->
+ </ div >
+</ template >
但对使用 JSX / TSX 的开发者来说,就影响比较大了,在标准组件里,想在 script 里获取插槽数据,也是需要在 setup 的第二个入参里拿到 slots API 。
ts // 标准组件的写法
+export default defineComponent ( {
+ // 这里的 slots 就是插槽
+ setup ( props , { slots }) {
+ // ...
+ },
+} )
新版本的 Vue 也提供了一个全新的 useSlots API 来帮助 script-setup 用户获取插槽。
useSlots 的基础用法 先来看看父组件,父组件先为子组件传入插槽数据,支持 “默认插槽” 和 “命名插槽” :
vue < template >
+ <!-- 子组件 -->
+ < ChildTSX >
+ <!-- 默认插槽 -->
+ < p > Default slot for TSX. </ p >
+ <!-- 默认插槽 -->
+
+ <!-- 命名插槽 -->
+ < template # msg >
+ < p > Named slot for TSX. </ p >
+ </ template >
+ <!-- 命名插槽 -->
+ </ ChildTSX >
+ <!-- 子组件 -->
+</ template >
+
+< script setup lang = " ts " >
+// 实际上是导入 ChildTSX.tsx 文件,扩展名默认可以省略
+import ChildTSX from ' @cp/ChildTSX '
+</ script >
在使用 JSX / TSX 编写的子组件里,就可以通过 useSlots 来获取父组件传进来的 slots 数据进行渲染:
tsx // src/components/ChildTSX.tsx
+import { defineComponent , useSlots } from ' vue '
+
+export default defineComponent ( {
+ setup () {
+ // 获取插槽数据
+ const slots = useSlots ()
+
+ // 渲染组件
+ return () => (
+ < div >
+ { /* 渲染默认插槽 */ }
+ < p >{ slots . default ? slots . default () : '' }</ p >
+
+ { /* 渲染命名插槽 */ }
+ < p >{ slots . msg ? slots . msg () : '' }</ p >
+ </ div >
+ )
+ },
+} )
请注意,这里的 TSX 组件代码需要使用 .tsx
作为文件扩展名,并且构建工具可能默认没有对 JSX / TSX 作支持,以 Vite 为例,需要安装官方提供的 JSX / TSX 支持插件才可以正常使用。
bash # 该插件支持使用 JSX 或 TSX 作为 Vue 组件
+npm i -D @vitejs/plugin-vue-jsx
并在 vite.config.ts 里启用插件,添加对 JSX 和 TSX 的支持。
ts // vite.config.ts
+import { defineConfig } from ' vite '
+import vueJsx from ' @vitejs/plugin-vue-jsx '
+
+export default defineConfig ( {
+ // ...
+ plugins : [
+ // ...
+ // 启用插件
+ vueJsx () ,
+ ] ,
+} )
如果还存在报错的情况,可以检查项目下的 tsconfig.json 文件里,编译选项 jsx
是否设置为 preserve
:
json {
+ " compilerOptions " : {
+ " jsx " : " preserve "
+ }
+}
ref 的通信方式变化 在标准组件写法里,子组件的数据和方法可以通过在 setup 里 return 出来给父组件调用,也就是父组件可以通过 childComponent.value.foo
这样的方式直接操作子组件的数据(参见:DOM 元素与子组件 - 响应式 API 之 ref )。
但在 script-setup 模式下,所有数据只是默认隐式 return 给 <template />
使用,不会暴露到组件外,所以父组件是无法直接通过挂载 ref 变量获取子组件的数据。
在 script-setup 模式下,如果要调用子组件的数据,需要先在子组件显式的暴露出来,才能够正确的拿到,这个操作,就是由 defineExpose API 来完成。
defineExpose 的基础用法 defineExpose 的用法非常简单,它本身是一个函数,可以接受一个对象参数。
在子组件里,像这样把需要暴露出去的数据通过 key: value
的形式作为入参(下面的例子是用到了 ES6 的 属性的简洁表示法 ):
vue < script setup lang = " ts " >
+const msg = ' Hello World! '
+
+// 通过该 API 显式暴露的数据,才可以在父组件拿到
+defineExpose ( {
+ msg ,
+} )
+</ script >
然后在父组件就可以通过挂载在子组件上的 ref 变量,去拿到暴露出来的数据了。
顶级 await 的支持 在 script-setup 模式下,不必再配合 async 就可以直接使用 await 了,这种情况下,组件的 setup 会自动变成 async setup 。
vue < script setup lang = " ts " >
+const res = await fetch ( \` https://example.com/api/foo \` )
+const json = await res . json ()
+console . log (json)
+</ script >
它转换成标准组件的写法就是:
vue < script lang = " ts " >
+import { defineComponent } from ' vue '
+
+export default defineComponent ( {
+ async setup () {
+ const res = await fetch ( \` https://example.com/api/foo \` )
+ const json = await res . json ()
+ console . log ( json )
+
+ return {
+ json ,
+ }
+ },
+} )
+</ script >
命名技巧 对于接触编程不久的开发者,在个人练习 demo 或者简单的代码片段里可能会经常看到 var a
、 var b
这样的命名,因为本身是一段练习代码,因此都是 “能跑就行”,问题不大。
但在工作中,很多开发团队都会有语义化命名的规范要求,严格的团队会有 Code Review 环节,使用这种无意义命名的代码将无法通过审查,在这种背景下,开发者可能会在命名上花费很多时间,在这里也分享笔者的一些常用技巧,希望能够帮助开发者节约在命名上的时间开销。
文件命名技巧 在开始讲变量命名之前,先说说文件的命名,因为代码都是保存在文件里,并且可能会互相引用,如果后期再修改文件名或者保存位置而忘记更新代码里的引用路径,那么就会影响程序编译和运行。
Vue 组件 在 Vue 项目里,会有放在 views 下的路由组件,有放在 components 目录下的公共组件,虽然都是以 .vue
为扩展名的 Vue 组件文件,但根据用途,它们其实并不相同,因此命名上也有不同的技巧。
路由组件 路由组件组件通常存放在 src/views 目录下,在命名上容易困惑的点应该是风格问题,开发者容易陷入是使用 camelCase 小驼峰还是 kebab-case 短横线风格,或者是 snake_case 下划线风格的选择困难。
一般情况下路由组件都是以单个名词或动词进行命名,例如个人资料页使用 profile
命名路由,路由的访问路径使用 /profile
,对应的路由组件使用 profile.vue
命名,下面是几个常见的例子。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 首页
+ // e.g. \`https://example.com/\`
+ {
+ path : ' / ' ,
+ name : ' home ' ,
+ component : () => import ( ' @views/home.vue ' ) ,
+ },
+ // 个人资料页
+ // e.g. \`https://example.com/profile\`
+ {
+ path : ' /profile ' ,
+ name : ' profile ' ,
+ component : () => import ( ' @views/profile.vue ' ) ,
+ },
+ // 登录页
+ // e.g. \`https://example.com/login\`
+ {
+ path : ' /login ' ,
+ name : ' login ' ,
+ component : () => import ( ' @views/login.vue ' ) ,
+ },
+]
+
+export default routes
如果是一些数据列表类的页面,使用名词复数,或者名词单数加上 -list
结尾的 kebab-case 短横线风格写法,推荐短横线风格是因为在 URL 的风格设计里更为常见。
像文章列表可以使用 articles
或者 article-list
,但同一个项目建议只使用其中一种方式,以保持整个项目的风格统一,下面是几个常见的例子。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 文章列表页
+ // 翻页逻辑是改变页码进行跳转,因此需要添加动态参数 \`:page\`
+ // 可以在组件内使用路由实例 \`route.params.page\` 拿到页码
+ // e.g. \`https://example.com/articles/1\`
+ {
+ path : ' /articles/:page ' ,
+ name : ' articles ' ,
+ component : () => import ( ' @views/articles.vue ' ) ,
+ },
+ // 通知列表页
+ // 翻页逻辑使用 AJAX 无刷翻页,这种情况则可以不配置页码参数
+ // e.g. \`https://example.com/notifications\`
+ {
+ path : ' /notifications ' ,
+ name : ' notifications ' ,
+ component : () => import ( ' @views/notifications.vue ' ) ,
+ },
+]
+
+export default routes
列表里的资源详情页,因为访问的时候通常会带上具体的 ID 以通过接口查询详情数据,这种情况下资源就继续使用单数,例如下面这个例子。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 文章详情页
+ // 可以在组件内使用路由实例 \`route.params.id\` 拿到文章 ID
+ // e.g. \`https://example.com/article/1\`
+ {
+ path : ' /article/:id ' ,
+ name : ' article ' ,
+ component : () => import ( ' @views/article.vue ' ) ,
+ },
+]
+
+export default routes
如果项目路由比较多,通常会对同一业务的路由增加文件夹归类,因此上面的文章列表和文章详情页,可以统一放到 article 目录下,使用 list
和 detail
区分是列表还是详情。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 文章相关的路由统一放在这里管理
+ {
+ path : ' /article ' ,
+ name : ' article ' ,
+ // 这是一个配置了 \`<router-view />\` 标签的路由中转站组件
+ // 目的是使其可以渲染子路由
+ component : () => import ( ' @cp/TransferStation.vue ' ) ,
+ // 由于父级路由没有内容,所以重定向至列表的第 1 页
+ // e.g. \`https://example.com/article\`
+ redirect : {
+ name : ' article-list ' ,
+ params : {
+ page : 1 ,
+ },
+ },
+ children : [
+ // 文章列表页
+ // e.g. \`https://example.com/article/list/1\`
+ {
+ path : ' list/:page ' ,
+ name : ' article-list ' ,
+ component : () => import ( ' @views/article/list.vue ' ) ,
+ },
+ // 文章详情页
+ // e.g. \`https://example.com/article/detail/1\`
+ {
+ path : ' detail/:id ' ,
+ name : ' article-detail ' ,
+ component : () => import ( ' @views/article/detail.vue ' ) ,
+ },
+ ] ,
+ },
+]
+
+export default routes
对于一些需要用多个单词才能描述的资源,可以使用 kebab-case 短横线风格命名,例如很常见的 “策划面对面” 这种栏目,在设置路由时,比较难用一个单词在 URL 里体现其含义,就需要使用这种多个单词的连接。
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // 面对面栏目
+ {
+ path : ' /face-to-face ' ,
+ name : ' face-to-face ' ,
+ component : () => import ( ' @views/face-to-face.vue ' ) ,
+ },
+]
+
+export default routes
这种情况如果需要使用文件夹管理多个路由,同样建议使用 kebab-case 短横线风格命名,例如上面这个 “策划面对面” 栏目,可能会归属于 “开发计划” 这个业务下,那么其父级文件夹就可以使用 development-plan
这样的短横线命名。
公共组件 公共组件组件通常存放在 src/components 目录下,也可以视不同的使用情况,在路由文件夹下创建属于当前路由的 components 目录,作为一个小范围共享的公共组件目录来管理,而 src/components 则只存放全局性质的公共组件。
本节最开始提到了路由组件和公共组件并不相同,虽然都是组件,但路由组件代表的是整个页面,而公共组件更多是作为一个页面上的某个可复用的部件,如果开发者写过 Flutter ,应该能够更深刻的理解到这里的公共组件更接近于 Widget 性质的小部件。
公共组件通常使用 PascalCase 帕斯卡命名法,也就是大驼峰,为什么不用小驼峰呢?
这是源于 Vue 官网的一个 组件名格式 命名推荐:
使用 PascalCase 作为组件名的注册格式,这是因为: PascalCase 是合法的 JavaScript 标识符。这使得在 JavaScript 中导入和注册组件都很容易,同时 IDE 也能提供较好的自动补全。 ><PascalCase />
在模板中更明显地表明了这是一个 Vue 组件,而不是原生 HTML 元素。同时也能够将 Vue 组件和自定义元素( web components )区分开来。
而且实际使用 PascalCase 风格的编码过程中,在 VSCode 里可以得到不同颜色的高亮效果,这与 kebab-case 风格的 HTML 标签可以快速区分。
vue < template >
+ <!-- 普通的 HTML 标签 -->
+ <!-- 在笔者的 VSCode 风格里呈现为桃红色 -->
+ < div ></ div >
+
+ <!-- 大驼峰组件 -->
+ <!-- 在笔者的 VSCode 风格里呈现为绿色 -->
+ < PascalCase />
+</ template >
养成这种习惯还有一个好处,就是使用 UI 框架的时候,例如 Ant Design Vue 的 Select 组件 ,在其文档上演示的是全局安装的写法:
vue < template >
+ < a-select >
+ < a-select-option value = " Hello " > Hello </ a-select-option >
+ </ a-select >
+</ template >
而实际使用时,为了更好的配合构建工具进行 Tree Shaking 移除没有用到的组件,都是按需引入 UI 框架的组件,因此如果平时有养成习惯使用 PascalCase 命名,就可以很轻松的知道上面的 <a-select-option />
组件应该对应的是 <SelectOption />
,因此是这样按需导入:
ts import { Select , SelectOption } from ' ant-design-vue '
可以说, PascalCase 这个命名方式也是目前流行 UI 框架都在使用的命名规范。
TypeScript 文件 在 Vue 项目里,虽然 TypeScript 代码可以写在组件里,但由于很多功能实现是可以解耦并复用,所以经常会有专门的目录管理公共方法,这样做也可以避免在一个组件里写出一两千行代码从而导致维护成本提高。
libs 文件 笔者习惯将这些方法统一放到 src/libs 目录下,按照业务模块或者功能的相似度,以一个名词或者动词作为文件命名。
例如常用的正则表达式,可以归类到 regexp.ts 里。
ts // src/libs/regexp.ts
+
+// 校验手机号格式
+export function isMob ( phoneNumber : number | string ) {
+ // ...
+}
+
+// 校验电子邮箱格式
+export function isEmail ( email : string ) {
+ // ...
+}
+
+// 校验网址格式
+export function isUrl ( url : string ) {
+ // ...
+}
+
+// 校验身份证号码格式
+export function isIdCard ( idCardNumber : string ) {
+ // ...
+}
+
+// 校验银行卡号码格式
+export function isBankCard ( bankCardNumber : string ) {
+ // ...
+}
统一使用命名导出,这样一个 TS 文件就像一个 npm 包一样,在使用的时候就可以从这个 “包” 里面导出各种要用到的方法直接使用,无需在组件里重复编写判断逻辑。
ts import { isMob , isEmail } from ' @libs/regexp '
其他诸如常用到的短信验证 sms.ts 、登录逻辑 login.ts 、数据格式转换 format.ts 都可以像这样单独抽出来封装,这种与业务解耦的封装方式非常灵活,以后不同项目如果也有类似的需求,就可以直接拿过去复用了!
types 文件 对于经常用到的 TypeScript 类型,也可以抽离成公共文件,笔者习惯在 src/types 目录管理公共类型,统一使用 .ts
作为扩展名并在里面导出 TS 类型,而不使用 .d.ts
这个类型声明文件。
这样做的好处是在使用到相应类型时,可以通过 import type
显式导入,在后期的项目维护过程中,可以很明确的知道类型来自于哪里,并且更接近从 npm 包里导入类型使用的开发方式。
例如上文配置路由的例子里,就是从 Vue Router 里导入了路由的类型:
ts // src/router/routes.ts
+import type { RouteRecordRaw } from ' vue-router '
+
+const routes : RouteRecordRaw [] = [
+ // ...
+]
+
+export default routes
在 types 目录下,可以按照业务模块创建多个模块文件分别维护不同的 TS 类型,并统一在 index.ts 里导出:
bash src
+└─types
+ │ # 入口文件
+ ├─index.ts
+ │ # 管理不同业务的公共类型
+ ├─user.ts
+ ├─game.ts
+ └─news.ts
例如 game.ts 可以维护经常用到的游戏业务相关类型,其中为了避免和其他模块命名冲突,以及一眼可以看出是来自哪个业务的类型,可以统一使用业务模块的名称作为前缀。
ts // src/types/game.ts
+
+// 游戏公司信息
+export interface GameCompany {
+ // ...
+}
+
+// 游戏信息
+export interface GameInfo {
+ id : number
+ name : string
+ gameCompany : GameCompany
+ // ...
+}
将该模块的所有类型在 index.ts 里全部导出:
ts // src/types/index.ts
+export * from ' ./game '
在组件里就可以这样使用该类型:
ts // 可以从 \`types\` 里统一导入,而不必明确到 \`types/game\`
+import type { GameInfo } from ' @/types '
+
+const game : GameInfo = {
+ id : 1 ,
+ name : ' Contra ' ,
+ gameCompany : {},
+}
+console . log (game)
TS 类型都遵循 PascalCase 命名风格,方便和声明的变量作为区分,大部分情况下一看到 GameInfo
就知道是类型,而 gameInfo
则是一个变量。
代码命名技巧 在编写 JavaScript / TypeScript 时,为变量和函数的命名也是新手开发者容易花费比较多时间的一件事情,笔者也分享自己常用的命名套路,可以大幅度降低命名的思考时间,而且可以体现一定的语义化。
变量的命名 首先笔者遵循变量只使用 camelCase 小驼峰风格的基本原则,并且根据不同的类型,搭配不同的命名前缀或后缀。
对于 string
字符串类型,使用相关的名词命名即可。
ts import { ref } from ' vue '
+
+// 用户名
+const username = ref < string > ( ' Petter ' )
+
+// 职业
+const profession = ref < string > ( ' Front-end Engineer ' )
对于 number
数值类型,除了一些本身可以代表数字的名词,例如年龄 age
、秒数 seconds
,其他的情况可以搭配后缀命名,常用的后缀有 Count
、 Number
、 Size
、 Amount
等和单位有关的名词。
ts import { ref } from ' vue '
+
+// 最大数量
+const maxCount = ref < number > ( 100 )
+
+// 页码
+const pageNumber = ref < number > ( 1 )
+
+// 每页条数
+const pageSize = ref < number > ( 10 )
+
+// 折扣金额
+const discountAmount = ref < number > ( 50 )
对于 boolean
布尔值类型,可搭配 is
、 has
等 Be 动词或判断类的动词作为前缀命名,并视情况搭配行为动词和目标名词,或者直接使用一些状态形容词。
ts import { ref } from ' vue '
+
+// 是否显示弹窗
+const isShowDialog = ref < boolean > ( false )
+
+// 用户是否为 VIP 会员
+const isVIP = ref < boolean > ( true )
+
+// 用户是否有头像
+const hasAvatar = ref < boolean > ( true )
+
+// 是否被禁用
+const disabled = ref < boolean > ( true )
+
+// 是否可见
+const visible = ref < boolean > ( true )
之所以要搭配 is
开头,是为了和函数区分,例如 showDialog()
是显示弹窗的方法,而 isShowDialog
才是一个布尔值用于逻辑判断。
对于数组,通常使用名词的复数形式,或者名词加上 List
结尾作为命名,数组通常会有原始数据类型的数组,也有 JSON 对象数组,笔者习惯对前者使用名词复数,对后者使用 List
结尾。
ts import { ref } from ' vue '
+
+// 每个 Item 都是字符串
+const tags = ref < string > ([ ' 食物 ' , ' 粤菜 ' , ' 卤水 ' ])
+
+// 每个 Item 都是数值
+const tagIds = ref < number > ([ 1 , 2 , 3 ])
+
+// 每个 Item 都是 JSON 对象
+const memberList = ref < Member [] > ([
+ {
+ id : 1 ,
+ name : ' Petter ' ,
+ },
+ {
+ id : 2 ,
+ name : ' Marry ' ,
+ },
+])
如果是作为函数的入参,通常也遵循变量的命名规则。
除非是一些代码量很少的操作,可以使用 i
、 j
等单个字母的变量名,例如提交接口参数时,经常只需要提交一个 ID 数组,从 JSON 数组里提取 ID 数组时就可以使用这种简短命名。
ts // \`map\` 的参数命名就可以使用 \`i\` 这种简短命名
+const ids = dataList . map ( ( i ) => i . id)
函数的命名 函数的命名也是只使用 camelCase 小驼峰风格,通常根据该函数是同步操作还是异步操作,使用不同的动词前缀。
获取数据的函数,通常使用 get
、 query
、 read
等代表会返回数据的动词作为前缀,如果还是觉得很难确定使用哪一个,可以统一使用 get
,也可以根据函数的操作性质来决定:
如果是同步操作,不涉及接口请求,使用 get
作为前缀 如果是需要从 API 接口查询数据的异步操作,使用 query
作为前缀 如果是 Node.js 程序这种需要进行文件内容读取的场景,就使用 read
ts // 从本地存储读取数据
+// 因为是同步操作,所以使用 \`get\` 前缀
+function getLoginInfo () {
+ try {
+ const info = localStorage . getItem ( ' loginInfo ' )
+ return info ? JSON . parse ( info ) : null
+ } catch ( e ) {
+ return null
+ }
+}
+
+// 从接口查询数据
+// 因为是异步操作,需要去数据库查数据,所以使用 \`query\` 前缀
+async function queryMemberInfo ( id : number ) {
+ try {
+ const res = await fetch ( \` https://example.com/api/member/ \${ id }\` )
+ const json = await res . json ()
+ return json
+ } catch ( e ) {
+ return null
+ }
+}
修改数据的函数,通常使用 save
、 update
、 delete
等会变更数据的动词作为前缀,一般情况下:
数据存储可以统一使用 save
如果要区分新建或者更新操作,可以对新建操作使用 create
,对更新操作使用 update
删除使用 delete
或 remove
如果是 Node.js 程序需要对文件写入内容,使用 write
表单验证合法性等场景,可以使用 verify
或 check
切换可见性可以用 show
和 hide
,如果是写在一个函数里,可以使用 toggle
发送验证码、发送邮件等等可以使用 send
打开路由、打开外部 URL 可以使用 open
当然以上只是一些常用到的命名技巧建议,对于简单的业务,例如一个 H5 活动页面,也可以在同步操作时使用 set
表示可以直接设置,在异步操作时使用 save
表示需要提交保存。
ts // 将数据保存至本地存储
+// 因为是同步操作,所以使用 \`set\` 前缀
+function setLoginInfo ( info : LoginInfo ) {
+ try {
+ localStorage . setItem ( ' loginInfo ' , JSON . stringify ( info ))
+ return true
+ } catch ( e ) {
+ return false
+ }
+}
+
+// 将数据通过接口保存到数据库
+// 因为是异步操作,所以使用 \`save\` 前缀
+async function saveMemberInfo ( id : number , data : MemberDTO ) {
+ try {
+ const res = await fetch ( \` https://example.com/api/member/ \${ id }\` , {
+ method : ' POST ' ,
+ body : JSON . stringify ( data ) ,
+ } )
+ const json = await res . json ()
+ return json . code === 200
+ } catch ( e ) {
+ return false
+ }
+}
Class 类上的方法和函数命名规则一样,但 Class 本身使用 PascalCase 命名法,代表这是一个类,在调用的时候需要 new
。
ts // 类使用 PascalCase 命名法
+class Hello {
+ name : string
+
+ constructor ( name : string ) {
+ this. name = name
+ }
+
+ say () {
+ console . log ( \` Hello \${ this. name }\` )
+ }
+}
+
+const hello = new Hello ( ' World ' )
+hello . say () // Hello World
希望曾经在命名上有过困扰的开发者,不再有此烦恼,编写代码更加高效率!
`,217);function D(F,i,C,A,d,u){const a=n("GitalkComment"),l=n("ClientOnly");return o(),e("div",null,[y,s(l,null,{default:t(()=>[s(a,{issueId:118})]),_:1})])}const h=p(r,[["render",D]]);export{g as __pageData,h as default};
diff --git a/assets/engineering.md.43ce842b.js b/assets/engineering.md.43ce842b.js
new file mode 100644
index 00000000..288e259c
--- /dev/null
+++ b/assets/engineering.md.43ce842b.js
@@ -0,0 +1,291 @@
+/**
+ * name: learning-vue3
+ * version: v2.0.0
+ * description: A starting learning tutorial on Vue 3.0 + TypeScript, suitable for complete Vue novices and Vue 2.0 veterans, incorporating some of my own practical experience on the basis of official documents.
+ * author: chengpeiquan
+ * homepage: https://vue3.chengpeiquan.com
+ */
+import{v as y,b as D,t as s,O as n,F as o,L as e,X as F,R as p,M as t}from"./chunks/framework.0d8bea05.js";const i=p(`了解前端工程化 现在前端的工作与以前的前端开发已经完全不同了。
刚接触前端的时候,做一个页面,是先创建 HTML 页面文件写页面结构,再在里面写 CSS 代码美化页面,再根据需要写一些 JavaScript 代码增加交互功能,需要几个页面就创建几个页面,相信大家的前端起步都是从这个模式开始的。
而实际上的前端开发工作,早已进入了前端工程化开发的时代,已经充满了各种现代化框架、预处理器、代码编译…
最终的产物也不再单纯是多个 HTML 页面,经常能看到 SPA / SSR / SSG 等词汇的身影。
传统开发的弊端 在了解什么是前端工程化之前,先回顾一下传统开发存在的一些弊端,这样更能知道为什么需要它。
在传统的前端开发模式下,前端工程师大部分只需要单纯地写写页面,都是在 HTML 文件里直接编写代码,所需要的 JavaScript 代码是通过 script
标签以内联或者文件引用的形式放到 HTML 代码里的,当然 CSS 代码也是一样的处理方式。
例如这样:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > Document </ title >
+ </ head >
+ < body >
+ <!-- 引入 JS 文件 -->
+ < script src = " ./js/lib-1.js " ></ script >
+ < script src = " ./js/lib-2.js " ></ script >
+ <!-- 引入 JS 文件 -->
+ </ body >
+</ html >
如演示代码,虽然可以把代码分成多个文件来维护,这样可以有效降低代码维护成本,但在实际开发过程中,还是会存在代码运行时的一些问题。
一个常见的案例 继续用上面的演示代码,来看一个最简单的一个例子。
先在 lib-1.js
文件里,声明一个变量:
再在 lib-2.js
文件里,也声明一个变量(没错,也是 foo
):
然后在 HTML 代码里追加一个 script
,打印这个值:
html <! DOCTYPE html >
+< html lang = " en " >
+< head >
+ < meta charset = " UTF-8 " >
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " >
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " >
+ < title > Document </ title >
+</ head >
+< body >
+
+ <!-- 引入 JS 文件 -->
+ < script src = " ./js/lib-1.js " ></ script >
+ < script src = " ./js/lib-2.js " ></ script >
+ <!-- 引入 JS 文件 -->
+
+ <!-- 假设这里是实际的业务代码 -->
+ < script >
+ console . log (foo)
+ </ script >
+ <!-- 假设这里是实际的业务代码 -->
+
+</ body >
+</ html >
先猜猜会输出什么? —— 答案是 2
。
如果在开发的过程中,不知道在 lib-2.js
文件里也声明了一个 foo
变量,一旦在后面的代码里预期了 foo + 2 === 3
,那么这样就得不到想要的结果(因为 lib-1.js
里的 foo
是 1
, 1 + 2
等于 3
) 。
原因是 JavaScript 的加载顺序是从上到下,当使用 var
声明变量时,如果命名有重复,那么后加载的变量会覆盖掉先加载的变量。
这是使用 var
声明的情况,它允许使用相同的名称来重复声明,那么换成 let
或者 const
呢?
虽然不会出现重复声明的情况,但同样会收到一段报错:
bash Uncaught SyntaxError: Identifier ' foo ' has already been declared (at lib-2.js:1:1 )
这次程序直接崩溃了,因为 let
和 const
无法重复声明,从而抛出这个错误,程序依然无法正确运行。
更多问题 以上只是一个最简单的案例,就暴露出了传统开发很大的弊端,然而并不止于此,实际上,存在诸如以下这些问题:
如本案例,可能存在同名的变量声明,引起变量冲突 引入多个资源文件时,比如有多个 JS 文件,在其中一个 JS 文件里面使用了在别处声明的变量,无法快速找到是在哪里声明的,大型项目难以维护 类似第 1 、 2 点提到的问题无法轻松预先感知,很依赖开发人员人工定位原因 大部分代码缺乏分割,比如一个工具函数库,很多时候需要整包引入到 HTML 里,文件很大,然而实际上只需要用到其中一两个方法 由第 4 点大文件延伸出的问题, script
的加载从上到下,容易阻塞页面渲染 不同页面的资源引用都需要手动管理,容易造成依赖混乱,难以维护 如果要压缩 CSS 、混淆 JS 代码,也是要人力操作使用工具去一个个处理后替换,容易出错 当然,实际上还会有更多的问题会遇到。
工程化带来的优势 为了解决传统开发的弊端,前端也开始引入工程化开发的概念,借助工具来解决人工层面的烦琐事情。
开发层面的优势 在 传统开发的弊端 里,主要列举的是开发层面的问题,工程化首要解决的当然也是在开发层面遇到的问题。
在开发层面,前端工程化有以下这些好处:
引入了模块化和包的概念,作用域隔离,解决了代码冲突的问题 按需导出和导入机制,让编码过程更容易定位问题 自动化的代码检测流程,有问题的代码在开发过程中就可以被发现 编译打包机制可以让使用开发效率更高的编码方式,比如 Vue 组件、 CSS 的各种预处理器 引入了代码兼容处理的方案( e.g. Babel ),可以让开发者自由使用更先进的 JavaScript 语句,而无需顾忌浏览器兼容性,因为最终会转换为浏览器兼容的实现版本 引入了 Tree Shaking 机制,清理没有用到的代码,减少项目构建后的体积 还有非常多的体验提升,列举不完。而对应的工具,根据用途也会有非常多的选择,在后面的学习过程中,会一步一步体验到工程化带来的好处。
团队协作的优势 除了对开发者有更好的开发体验和效率提升,对于团队协作,前端工程化也带来了更多的便利,例如下面这些场景:
统一的项目结构 以前的项目结构比较看写代码的人的喜好,虽然一般在研发部门里都有 “团队规范” 这种东西,但靠自觉性去配合的事情,还是比较难做到统一,特别是项目很赶的时候。
工程化后的项目结构非常清晰和统一,以 Vue 项目来说,通过脚手架创建一个新项目之后,它除了提供能直接运行 Hello World 的基础代码之外,还具备了如下的统一目录结构:
src
是源码目录src/main.ts
是入口文件src/views
是路由组件目录src/components
是子组件目录src/router
是路由目录虽然也可以自行调整成别的结构,但根据笔者在多年的工作实际接触下来,以及从很多开源项目的代码里看到的,都是沿用脚手架创建的项目结构(不同脚手架创建的结构会有所不同,但基于同一技术栈的项目基本上都具备相同的结构)。
统一的代码风格 不管是接手其他人的代码或者是修改自己不同时期的代码,可能都会遇到这样的情况,例如一个模板语句,上面包含了很多属性,有的人喜欢写成一行,属性多了维护起来很麻烦,需要花费较多时间辨认:
vue < template >
+ < div class = " list " >
+ <!-- 这个循环模板有很多属性 -->
+ < div class = " item " :class = " { \`top-\${index + 1}\`: index < 3 } " v-for = " (item, index)
+ in list " :key = " item.id " @click = " handleClick(item.id) " >
+ < span > {{ item.text }} </ span >
+ </ div >
+ <!-- 这个循环模板有很多属性 -->
+ </ div >
+</ template >
而工程化配合统一的代码格式化规范,可以让不同人维护的代码,最终提交到 Git 上的时候,风格都保持一致,并且类似这种很多属性的地方,都会自动帮格式化为一个属性一行,维护起来就很方便:
vue < template >
+ < div class = " list " >
+ <!-- 这个循环模板有很多属性 -->
+ < div
+ class = " item "
+ :class = " { \`top-\${index + 1}\`: index < 3 } "
+ v-for = " (item, index) in list "
+ :key = " item.id "
+ @click = " handleClick(item.id) "
+ >
+ < span > {{ item.text }} </ span >
+ </ div >
+ <!-- 这个循环模板有很多属性 -->
+ </ div >
+</ template >
同样的,写 JavaScript 时也会有诸如字符串用双引号还是单引号,缩进是 Tab 还是空格,如果用空格到底是要 4 个空格还是 2 个空格等一堆 “没有什么实际意义” 、但是不统一的话协作起来又很难受的问题……
在工程化项目这些问题都可以交给程序去处理,在书写代码的时候,开发者可以先按照自己的习惯书写,然后再执行命令进行格式化,或者是在提交代码的时候配合 Git Hooks 自动格式化,都可以做到统一风格。
TIP
在 添加协作规范 一节可以学习如何给项目添加统一的协作规范。
可复用的模块和组件 传统项目比较容易被复用的只有 JavaScript 代码和 CSS 代码,会抽离公共函数文件上传到 CDN ,然后在 HTML 页面里引入这些远程资源, HTML 代码部分通常只有由 JS 创建的比较小段的 DOM 结构。
并且通过 CDN 引入的资源,很多时候都是完整引入,可能有时候只需要用到里面的一两个功能,却要把很大的完整文件都引用进来。
这种情况下,在前端工程化里,就可以抽离成一个开箱即用的 npm 组件包,并且很多包都提供了模块化导出,配合构建工具的 Tree Shaking ,可以抽离用到的代码,没有用到的其他功能都会被抛弃,不会一起发布到生产环境。
TIP
在 依赖包和插件 一节可以学习如何查找和使用开箱即用的 npm 包。
代码健壮性有保障 传统的开发模式里,只能够写 JavaScript ,而在工程项目里,可以在开发环境编写带有类型系统的 TypeScript ,然后再编译为浏览器能认识的 JavaScript 。
在开发过程中,编译器会检查代码是否有问题,比如在 TypeScript 里声明了一个布尔值的变量,然后不小心将它赋值为数值:
ts // 声明一个布尔值变量
+let bool : boolean = true
+
+// 在 TypeScript ,不允许随意改变类型,这里会报错
+bool = 3
编译器检测到这个行为的时候就会抛出错误:
bash # ...
+return new TSError ( diagnosticText, diagnosticCodes );
+ ^
+TSError: ⨯ Unable to compile TypeScript:
+src/index.ts:2:1 - error TS2322: Type ' number ' is not assignable to type ' boolean ' .
+
+2 bool = 3
+ ~~~~
+# ...
从而得以及时发现问题并修复,减少线上事故的发生。
团队开发效率高 在前后端合作环节,可以提前 Mock 接口与后端工程师同步开发,如果遇到跨域等安全限制,也可以进行本地代理,不受跨域困扰。
前端工程在开发过程中,还有很多可以交给程序处理的环节,像前面提到的代码格式化、代码检查,还有在部署上线的时候也可以配合 CI/CD 完成自动化流水线,不像以前改个字都要找服务端工程师去更新,可以把非常多的人力操作剥离出来交给程序。
求职竞争上的优势 近几年前端开发领域的相关岗位,都会在招聘详情里出现类似的描述:
熟悉 Vue / React 等主流框架,对前端组件化和模块化有深入的理解和实践 熟悉面向组件的开发模式,熟悉 Webpack / Vite 等构建工具 熟练掌握微信小程序开发,熟悉 Taro 框架或 uni-app 框架优先 熟悉 Scss / Less / Stylus 等预处理器的使用 熟练掌握 TypeScript 者优先 有良好的代码风格,结构设计与程序架构者优先 了解或熟悉后端开发者优先(如 Java / Go / Node.js )
知名企业对 1-3 年工作经验的初中级工程师,更是明确要求具备前端工程化开发的能力:
`,73),C=p('组件化开发、模块化开发、 Webpack / Vite 构建工具、 Node.js 开发… 这些技能都属于前端工程化开发的知识范畴,不仅在面试的时候会提问,入职后新人接触的项目通常也是直接指派前端工程化项目,如果能够提前掌握相关的知识点,对求职也是非常有帮助的!
Vue.js 与工程化 在上一节提到了前端工程化 在求职竞争上的优势 ,里面列出的招聘要求例子都提及到了 Vue 和 React 这些主流的前端框架,前端框架是前端工程化开发里面不可或缺的成员。
框架能够充分的利用前端工程化相关的领先技术,不仅在开发层面降低开发者的上手难度、提升项目开发效率,在构建出来的项目成果上也有着远比传统开发更优秀的用户体验。
本书结合 Vue.js 框架 3.0 系列的全新版本,将从项目开发的角度,在帮助开发者入门前端工程化的同时,更快速的掌握一个流行框架的学习和使用。
了解 Vue.js 与全新的 3.0 版本 Vue.js(发音为 /vjuː/
,类似 view
)是一个易学易用,性能出色,适用场景丰富的 Web 前端框架,从 2015 年发布 1.0 版本以来,受到了全世界范围的前端开发者喜爱,已成为当下最受欢迎的前端框架之一。
',7),d=p(`Vue 一直紧跟广大开发者的需求迭代发展,保持着它活跃的生命力。
2020 年 9 月 18 日, Vue.js 发布了 3.0 正式版,在大量开发者长达约一年半的使用和功能改进反馈之后, Vue 又于 2022 年 2 月 7 日发布了 3.2 版本,同一天, Vue 3 成为 Vue.js 框架全新的默认版本 (在此之前,通过 npm install vue
的默认版本还是 Vue 2 )。
也就是在未来的日子里, Vue 3 将随着时间的推移,逐步成为 Vue 生态的主流版本,是时候学习 Vue 3 了!
如果还没有体验过 Vue ,可以把以下代码复制到的代码编辑器,保存成一个 HTML 文件(例如: hello.html
),并在浏览器里打开访问,同时请唤起浏览器的控制台面板(例如 Chrome 浏览器是按 F12
或者鼠标右键点 “检查” ),在 Console 面板查看 Log 的打印。
html <!-- 这是使用 Vue 实现的 demo -->
+<! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > Hello Vue </ title >
+ < script src = " https://unpkg.com/vue@3 " ></ script >
+ </ head >
+ < body >
+ < div id = " app " >
+ <!-- 通过 \`{{ 变量名 }}\` 语法渲染响应式变量 -->
+ < p > Hello {{ name }}! </ p >
+
+ <!-- 通过 \`v-model\` 双向绑定响应式变量 -->
+ <!-- 通过 \`@input\` 给输入框绑定输入事件 -->
+ < input
+ type = " text "
+ v-model = " name "
+ placeholder = " 输入名称打招呼 "
+ @input = " printLog "
+ />
+
+ <!-- 通过 \`@click\` 给按钮绑定点击事件 -->
+ < button @click = " reset " > 重置 </ button >
+ </ div >
+
+ < script >
+ const { createApp , ref } = Vue
+ createApp ( {
+ // \`setup\` 是一个生命周期钩子
+ setup () {
+ // 默认值
+ const DEFAULT_NAME = ' World '
+
+ // 用于双向绑定的响应式变量
+ const name = ref ( DEFAULT_NAME )
+
+ // 打印响应式变量的值到控制台
+ function printLog () {
+ // \`ref\` 变量需要通过 \`.value\` 操作值
+ console . log ( name . value )
+ }
+
+ // 重置响应式变量为默认值
+ function reset () {
+ name . value = DEFAULT_NAME
+ printLog ()
+ }
+
+ // 需要 \`return\` 出去才可以被模板使用
+ return { name , printLog , reset }
+ },
+ } ) . mount ( ' #app ' )
+ </ script >
+ </ body >
+</ html >
这是一个基于 Vue 3 组合式 API 语法的 demo ,它包含了两个主要功能:
可以在输入框修改输入内容,上方的 Hello World!
以及浏览器控制台的 Log 输出,都会随着输入框内容的变更而变化 可以点击 “重置” 按钮,响应式变量被重新赋值的时候,输入框的内容也会一起变化为新的值 这是 Vue 的特色之一:数据的双向绑定。
对比普通的 HTML 文件需要通过输入框的 oninput
事件手动编写视图的更新逻辑, Vue 的双向绑定功能大幅度减少了开发过程的编码量。
在未接触 Vue 这种编程方式之前,相信大部分人首先想到的是直接操作 DOM 来实现需求,为了更好的进行对比,接下来用原生 JavaScript 实现一次相同的功能:
html <!-- 这是使用原生 JavaScript 实现的 demo -->
+<! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > Hello World </ title >
+ </ head >
+ < body >
+ < div id = " app " >
+ <!-- 通过一个 \`span\` 标签来指定要渲染数据的位置 -->
+ < p > Hello < span id = " name " ></ span > ! </ p >
+
+ <!-- 通过 \`oninput\` 给输入框绑定输入事件 -->
+ < input
+ id = " input "
+ type = " text "
+ placeholder = " 输入名称打招呼 "
+ oninput = " handleInput () "
+ />
+
+ <!-- 通过 \`onclick\` 给按钮绑定点击事件 -->
+ < button onclick = " reset () " > 重置 </ button >
+ </ div >
+
+ < script >
+ // 默认值
+ const DEFAULT_NAME = ' World '
+
+ // 要操作的 DOM 元素
+ const nameElement = document . querySelector ( ' #name ' )
+ const inputElement = document . querySelector ( ' #input ' )
+
+ // 处理输入
+ function handleInput () {
+ const name = inputElement . value
+ nameElement . innerText = name
+ printLog ()
+ }
+
+ // 打印输入框的值到控制台
+ function printLog () {
+ const name = inputElement . value
+ console . log ( name )
+ }
+
+ // 重置 DOM 元素的文本和输入框的值
+ function reset () {
+ nameElement . innerText = DEFAULT_NAME
+ inputElement . value = DEFAULT_NAME
+ printLog ()
+ }
+
+ // 执行一次初始化,赋予 DOM 元素默认文本和输入框的默认值
+ window . addEventListener ( ' load ' , reset)
+ </ script >
+ </ body >
+</ html >
虽然两个方案总的代码量相差不大,但可以看到两者的明显区别:
Vue 只需要对一个 name
变量的进行赋值操作,就可以轻松实现视图的同步更新 使用原生 JavaScript 则需要频繁的操作 DOM 才能达到输入内容即时体现在文本 DOM 上面,并且还要考虑 DOM 是否已渲染完毕,否则操作会出错 Vue 的这种编程方式,称之为 “数据驱动” 编程。
如果在一个页面上频繁且大量的操作真实 DOM ,频繁的触发浏览器回流( Reflow )与重绘( Repaint ),会带来很大的性能开销,从而造成页面卡顿,在大型项目的性能上很是致命。
而 Vue 则是通过操作虚拟 DOM ( Virtual DOM ,简称 VDOM ),每一次数据更新都通过 Diff 算法找出需要更新的节点,只更新对应的虚拟 DOM ,再去映射到真实 DOM 上面渲染,以此避免频繁或大量的操作真实 DOM 。
TIP
虚拟 DOM 是一种编程概念,是指将原本应该是真实 DOM 元素的 UI 界面,用数据结构来组织起完整的 DOM 结构,再同步给真实 DOM 渲染,减少浏览器的回流与重绘。
在 JavaScript 里,虚拟 DOM 的表现是一个 Object 对象,其中需要包含指定的属性(例如 Vue 的虚拟 DOM 需要用 type
来指定当前标签是一个 <div />
还是 <span />
),然后框架会根据对象的属性去转换为 DOM 结构并最终完成内容的显示。
更多关于 Vue 虚拟 DOM 和性能优化可以查看官网的 渲染机制 一章进行了解。
Vue 3.0 版本还引入了组合式 API 的概念,更符合软件工程 “高内聚,低耦合” 的思想,让开发者可以更灵活的管理自己的逻辑代码,更方便的进行抽离封装再复用,不管是大型项目还是流水线业务,开箱即用的逻辑代码都是提升开发效率的利器。
Vue 与工程化之间的关联 在已经对 Vue 做了初步了解之后,可能有读者会问:“既然 Vue 的使用方式也非常简单,可以像 jQuery 这些经典类库一样在 HTML 引入使用,那么 Vue 和工程化有什么关联呢?”
Vue.js 是一个框架,框架除了简化编码过程中的复杂度之外,面对不同的业务需求还提供了通用的解决方案,而这些解决方案,通常是将前端工程化里的很多种技术栈组合起来串成一条条技术链,一环扣一环,串起来就是一个完整的工程化项目。
举一个常见的例子,比如上一节内容 了解 Vue.js 与全新的 3.0 版本 里的 demo 是一个简单的 HTML 页面,如果业务稍微复杂一点,比如区分了 “首页” 、 “列表页” 、 “内容页” 这样涉及到多个页面,传统的开发方案是通过 A 标签跳转到另外一个页面,在跳转期间会产生 “新页面需要重新加载资源、会有短暂白屏” 等情况,用户体验不太好。
Vue 提供了 Vue Router 实现路由功能,利用 History API 实现单页面模式(可在 现代化的开发概念 部分了解区别),在一个 HTML 页面里也可以体验 “页面跳转” 这样的体验,但如果页面很多,所有代码都堆积在一个 HTML 页面里,就很难维护。
借助前端工程化的构建工具,开发者可以编写 .vue
单组件文件,将多个页面的代码根据其功能模块进行划分,可拆分到多个单组件文件里维护并进行合理复用,最终通过构建工具编译再合并,最终生成浏览器能访问的 HTML / CSS / JS 文件,这样的开发过程,用户体验没有影响,但开发体验大大提升。
类似这样一个个业务场景会积少成多,把 Vue 和工程化结合起来,处理问题更高效更简单。
选择 Vue 入门工程化的理由 虽然前端的流行框架有主流的 Angular 、 React 和 Vue ,也有新兴的 Svelte 等等,每一个框架都有自己的特色,那为什么建议选择 Vue 来入门工程化呢?
最主要的两个原因是:
职场对 Vue 技术栈的需求量大,容易找工作 上手门槛低,会一些基础的 HTML / CSS / JavaScript 语法知识,就能够轻松上手 Vue 的组件开发 第一个原因在 求职竞争上的优势 已有进行过说明,掌握一门流行框架已经是前端岗位必备的技能,几乎所有公司在招聘前端工程师的时候都要求会 Vue 。
这里主要讲讲第二个原因,在 Vue 与工程化之间的关联 里提到了开发者可以编写 .vue
文件,这是一个 Vue 专属的文件扩展名,官方名称是 Single-File Component ,简称 SFC ,也就是单文件组件。
.vue
文件最大的特色就是支持像编写 .html
文件一样,在文件里写 HTML / CSS / JS 代码,不仅结构相似,在代码书写上,两者的语法也是十分接近:
.vue
文件.html
文件<template />
部分HTML 代码 <style />
部分CSS 代码 <script />
部分JavaScript 代码
下面就是一个最基础的 Vue 组件结构,可以看到和 HTML 文件是非常的相似:
vue <!-- \`template\` 对应 HTML 代码 -->
+< template >
+ < div >
+ <!-- 一些 HTML -->
+ </ div >
+</ template >
+
+<!-- \`script\` 部分对应 JavaScript 代码 -->
+<!-- 还支持其他语言,例如 \`lang="ts"\` 代表当前使用 TypeScript 编写 -->
+< script >
+export default {
+ // 这里是变量、函数等逻辑代码
+}
+</ script >
+
+<!-- \`style\` 部分对应 CSS 代码 -->
+<!-- 还支持开启 \`scoped\` 标识,让 CSS 代码仅对当前组件生效,不会全局污染 -->
+< style scoped >
+/* 一些 CSS 代码 */
+</ style >
Vue 组件不仅支持这些语言的所有基础用法,还增加了非常多更高效的功能,在后面 Vue3 教程的 单组件的编写 一章会有详细的介绍。
现代化的开发概念 在本章最开始的时候提到了 SPA / SSR / SSG 等词汇,这些词汇是一些现代前端工程化开发的概念名词缩写,代表着不同的开发模式和用户体验。
当下主流的前端框架都提供了这些开发模式的支持,因此在学习前端工程化和 Vue 开发的过程中,会不定期的看到这一类词汇,在实际工作业务的技术选型时,面对不同的业务场景也要考虑好需要使用什么样的开发模式,提前了解这些概念,对以后的工作也会很有帮助。
MPA 与 SPA 首先来看 MPA 与 SPA ,这代表着两个完全相反的开发模式和用户体验,它们的全称和中文含义如下:
名词 全称 中文 MPA Multi-Page Application 多页面应用 SPA Single-Page Application 单页面应用
多页面应用 MPA 多页面应用是最传统的网站体验,当一个网站有多个页面时,会对应有多个实际存在的 HTML 文件,访问每一个页面都需要经历一次完整的页面请求过程:
bash # 传统的页面跳转过程
+
+从用户点击跳转开始:
+---> 浏览器打开新的页面
+---> 请求【所有】资源
+---> 加载 HTML 、CSS 、 JS 、 图片等资源
+---> 完成新页面的渲染
MPA 的优点 作为最传统也是最被广泛运用的模式,自然有它的优势存在:
因为 MPA 的页面源码都是实实在在的写在 HTML 文件里,所以当 HTML 文件被访问成功,内容也就随即呈现(在不考虑额外的 CSS 、 图片加载速度的情况下,这种模式的内容呈现速度是最快的)。
如果读者有稍微了解过一些 SEO 知识,会知道除了网页的 TKD 三要素之外,网页的内容也影响收录的关键因素,传统的多页面应用,网页的内容都是直接位于 HTML 文件内,例如下面这个有很多内容的网页:
`,51),A=o("p",null,[e("右键查看该网页的源代码,可以看到网页内容对应的 HTML 结构也是包含在 "),o("code",null,".html"),e(" 文件里。")],-1),h=p(`TIP
网页的 TKD 三要素是指一个网页的三个关键信息,含义如下:
T ,指 Title ,网站的标题,即网页的 <title>网站的标题</title>
标签。
K ,指 Keywords ,网站的关键词,即网页的 <meta name="Keywords" content="关键词1,关键词2,关键词3" />
标签。
D ,指 Description ,网站的描述,即网页的 <meta name="description" content="网站的描述" />
标签。
这三个要素标签都位于 HTML 文件的 <head />
标签内。
由于传统的页面都是由服务端直出,所以可以使用 PHP 、 JSP 、 ASP 、 Python 等非前端语言或技术栈来编写页面模板,最终输出 HTML 页面到浏览器访问。
MPA 的缺点 说完 MPA 的优点,再来看看它的缺点,正因为有这些缺点的存在,才会催生出其他更优秀的开发模式出现。
正如它的访问流程,每一次页面访问都需要完整的经历一次渲染过程,哪怕从详情页 A 的 “相关阅读” 跳转到详情页 B ,这种网页结构一样,只有内容不同的两个页面,也需要经历这样的过程。
如果网页上的资源较多或者网速不好,这个过程就会有明显的卡顿或者布局错乱,影响用户体验。
传统的多页面模式缺少前端工程化的很多优秀技术栈支持,前端开发者在刀耕火种的开发过程中效率低下。如果是基于 PHP 等非前端语言开发,工作量通常更是压在一名开发者身上,无法做到前后端分离来利用好跨岗位协作。
TIP
此处列举的多页面应用问题均指传统开发模式下的多页面,之所以特地说明,是因为后文还会有新的技术栈来实现多页面应用,但实现原理和体验并不一样。
单页面应用 正因为传统的多页面应用存在了很多无法解决的开发问题和用户体验问题,催生了现代化的 SPA 单页面应用技术的诞生。
SPA 单页面应用是现代化的网站体验,与 MPA 相反,不论站点内有多少个页面,在 SPA 项目实际上只有一个 HTML 文件,也就是 index.html
首页文件。
它只有第一次访问的时候才需要经历一次完整的页面请求过程,之后的每个内部跳转或者数据更新操作,都是通过 AJAX 技术来获取需要呈现的内容并只更新指定的网页位置。
TIP
AJAX 技术( Asynchronous JavaScript and XML )是指在不离开页面的情况下,通过 JavaScript 发出 HTTP 请求,让网页通过增量更新的方式呈现给用户界面,而不需要刷新整个页面来重新加载,是一种 “无刷体验” 。
SPA 在页面跳转的时候,地址栏也会发生变化,主要有以下两种方式:
通过修改 Location:hash 修改 URL 的 Hash 值(也就是 #
号后面部分),例如从 https://example.com/#/foo
变成 https://example.com/#/bar
通过 History API 的 pushState 方法更新 URL ,例如从 https://example.com/foo
变成 https://example.com/bar
这两个方式的共同特点是更新地址栏 URL 的时候,均不会刷新页面,只是单纯的变更地址栏的访问地址,而网页的内容则通过 AJAX 更新,配合起来就形成了一种网页的 “前进 / 后退” 等行为效果。
TIP
Vue Router 默认提供了这两种 URL 改变方式的支持,分别是 createWebHashHistory
的 Hash 模式和 createWebHistory
对应的 History 模式,在 路由的使用 一章可以学习更多 Vue 路由的使用。
理解了实现原理之后,可以把 SPA 的请求过程简化为如下步骤:
bash # SPA 页面跳转过程
+
+从用户点击跳转开始:
+---> 浏览器通过 \` pushState \` 等方法更新 URL
+---> 请求接口数据(如果有涉及到前后端交互)
+---> 通过 JavaScript 处理数据,拼接 HTML 片段
+---> 把 HTML 片段渲染到指定位置,完成页面的 “刷新”
SPA 的优点 从上面的实现原理已经能总结出它的优势了:
只有一次完全请求的等待时间(首屏加载) 用户体验好,内部跳转的时候可以实现 “无刷切换” 因为不需要重新请求整个页面,所以切换页面的时候速度更快 因为没有脱离当前页面,所以 “页” 与 “页” 之间在切换过程中支持动画效果 脱离了页面跳页面的框架,让整个网站形成一个 Web App ,更接近原生 App 的访问体验 开发效率高,前后端分离,后端负责 API 接口,前端负责界面和联调,同步进行缩短工期 这也是为什么短短几年时间, SPA 的体验模式成为前端领域的主流。
SPA 的缺点 虽然 SPA 应用在使用过程中的用户体验非常好,但也有自身的缺点存在:
由于 SPA 应用的路由是由前端控制, SPA 在打开首页后,还要根据当前的路由再执行一次内容渲染,相对于 MPA 应用从服务端直出 HTML ,首屏渲染所花费的时间会更长。
由于 SPA 应用全程是由 JavaScript 控制内容的渲染,因此唯一的一个 HTML 页面 index.html
通常是一个空的页面,只有最基础的 HTML 结构,不仅无法设置每个路由页面的 TDK ,页面内容也无法呈现在 HTML 代码里,因此对搜索引擎来说,网站的内容再丰富,依然只是一个 “空壳” ,无法让搜索引擎进行内容爬取。
`,33),u=p('为了减少用户等待过程中的焦虑感,可以通过增加 Loading 过程,或者 Skeleton 骨架屏等优化方案,但其实也是治标不治本,因此为了结合 SPA 和 MPA 的优点,又进一步催生出了更多实用的技术方案以适配更多的业务场景,在后面的小节将逐一介绍。
CSR 与 SSR 在了解了 MPA 与 SPA 之后,先了解另外两个有相关联的名词: CSR 与 SSR ,同样的,这一对也是代表着相反的开发模式和用户体验,它们的全称和中文含义如下:
名词 全称 中文 CSR Client-Side Rendering 客户端渲染 SSR Server-Side Rendering 服务端渲染
正如它们的名称,这两者代表的是渲染网页过程中使用到的技术栈。
客户端渲染 在 MPA 多页面应用与 SPA 单页面应用 部分的介绍过的 SPA 单页面应用,正是基于 CSR 客户端渲染实现的(因此大部分情况下, CSR 等同于 SPA ,包括实现原理和优势),这是一种利用 AJAX 技术,把渲染工作从服务端转移到客户端完成,不仅客户端的用户体验更好,前后端分离的开发模式更加高效。
但随之而来的首屏加载较慢、不利于 SEO 优化等缺点,而 SPA 的这几个缺点,却是传统 MPA 多页面应用所具备的优势,但同样的, MPA 也有着自己开发成本高、用户体验差等问题。
既然原来的技术方案无法完美满足项目需求,因此在结合 MPA 的优点和 SPA 的优点之后,一种新的技术随之诞生,这就是 SSR 服务端渲染。
服务端渲染 和传统的 MPA 使用 PHP / JSP 等技术栈做服务端渲染不同,现代前端工程化里的 SSR 通常是指使用 Node.js 作为服务端技术栈。
TIP
在 工程化神器 Node.js 一节会介绍 Node ,以及它对前端工程化带来的重大变化,现代前端工程化发展离不开它的存在。
传统的服务端渲染通常由后端开发者一起维护前后端代码,需要写后端语言支持的模板、 JavaScript 代码维护成本也比较高;而 SSR 服务端渲染则是交给前端开发者来维护,利用 Node 提供的能力进行同构渲染,由于本身前后端都使用 JavaScript 编写,维护成本也大大的降低。
SSR 技术利用的同构渲染方案( Isomorphic Rendering ),指的是一套代码不仅可以在客户端运行,也可以在服务端运行,在一些合适的时机先由服务端完成渲染( Server-Side Rendering )再直出给客户端激活( Client-Side Hydration ),这种开发模式带来了:
更好的 SEO 支持,解决了 SPA 单页面应用的痛点 更快的首屏加载速度,保持了 MPA 多页面应用的优点 和 SPA 一样支持前后端分离,开发效率依然很高 有更好的客户端体验,当用户完全打开页面后,本地访问过程中也可以保持 SPA 单页面应用的体验 统一的心智模型,由于支持同构,因此没有额外的心智负担 那么,使用 Vue 开发项目时,应该如何实现 SSR 呢?
Vue 的 SSR 支持非常好, Vue 官方不仅提供了一个 Vue.js 服务器端渲染指南 介绍了基于 Vue 的 SSR 入门实践,还有基于 Vue 的 Nuxt.js 、 Quasar 框架帮助开发者更简单地落地 SSR 开发,构建工具 Vite 也有内置的 Vue SSR 支持。
Pre-Rendering 与 SSG 在介绍了 SSR 服务端渲染技术后,读者可能会想到一个问题,就是 SSR 的开发成本总归比较高,如果本身项目比较简单,例如一个静态博客,或者静态官网、落地页等内容不多,仅需要简单的 SEO 支持的项目需求,是否有更简便的方案呢?
以下两种方案正是用于满足这类需求的技术:
名词 全称 中文 Pre-Rendering Pre-Rendering 预渲染 SSG Static-Site Generation 静态站点生成
预渲染 预渲染也是一种可以让 SPA 单页面应用 解决 SEO 问题的技术手段。
预渲染的原理是在构建的时候启动无头浏览器( Headless Browser ),加载页面的路由并将访问结果按照路由的路径保存到静态 HTML 文件里,这样部署到服务端的页面,不再是一个空的 HTML 页面,而是有真实内容的存在,但由于只在构建时运行,因此用户每次访问的时候 HTML 里的内容不会产生变化,直到下一次构建。
TIP
无头浏览器( Headless Browser ),指没有 GUI 界面的浏览器,使用代码通过编程接口来控制浏览器的行为,常用于网络爬虫、自动化测试等场景,预渲染也使用它来完成页面的渲染,以获取渲染后的代码来填充 HTML 文件。
预渲染和 服务端渲染 最大的区别在于,预渲染只在构建的时候就完成了页面内容的输出(发生在用户请求前),因此构建后不论用户何时访问, HTML 文件里的内容都是构建的时候的那份内容,所以预渲染适合一些简单的、有一定的 SEO 要求但对内容更新频率没有太高要求、内容多为静态展示的页面。
例如企业用于宣传的官网页面、营销活动的推广落地页都非常适合使用预渲染技术,现代的构建工具都提供了预渲染的内置实现,例如这个教程: 用 Vite 更简单的解决 Vue3 项目的预渲染问题 ,就是通过 Vite 的内置功能来实现预渲染,最终也运用到了公司的业务上。
静态站点生成 SSG 静态站点生成是基于预渲染技术,通过开放简单的 API 和配置文件,就让开发者可以实现一个预渲染静态站点的技术方案。
它可以让开发者定制站点的个性化渲染方案,但更多情况下,通常是作为一些开箱即用的技术产品来简化开发过程中的繁琐步骤,这一类技术产品通常称之为静态站点生成器( Static-Site Generator ,也是简称 SSG )。
常见的 SSG 静态站点生成器有:基于 Vue 技术的 VuePress 和 VitePress ,自带了 Vue 组件的支持,还有基于 React 的 Docusaurus ,以及很多各有特色的生成器,例如 Jekyll 、 Hugo 等等。
如果有写技术文档或者博客等内容创作需求,使用静态站点生成器是一个非常方便的选择,通常这一类产品还有非常多的个性化主题可以使用。
ISR 与 DPR 在现代化的开发概念这一节,从 MPA 多页面应用到 SPA 单页面应用 ,再到 CSR 客户端渲染和 SSR 服务端渲染 ,以及 Pre-Rendering 预渲染与 SSG 静态站点生成 ,似乎已经把所有常见的开发场景覆盖完了。
那接下来要讲的 ISR 和 DPR 又是什么用途的技术方案呢?先看看它们的全称和中文含义:
名词 全称 中文 ISR Incremental Site Rendering 增量式的网站渲染 DPR Distributed Persistent Rendering 分布式的持续渲染
当网站的内容体量达到一定程度的时候,从头开始构建进行预渲染所花费的时间会非常久,而实际上并不是所有页面的内容都需要更新,这两项技术的推出是为了提升大型项目的渲染效率。
ISR 增量式的网站渲染,通过区分 “关键页面” 和 “非关键页面” 进行构建,优先预渲染 “关键页面” 以保证内容的最新和正确,同时缓存到 CDN ,而 “非关键页面” 则交给用户访问的时候再执行 CSR 客户端渲染,并触发异步的预渲染缓存到 CDN 。
这样做的好处是,大幅度的提升了每次构建的时间,但由于只保证部分 “关键页面” 的构建和内容正确,所以访问 “非关键页面” 的时候,有可能先看到旧的内容,再由 CSR 刷新为新的内容,会丢失一部分用户体验。
更多 ISR 技术细节可以阅读 Netlify 的开发者体验总监 Cassidy Williams 的一篇文章: Incremental Static Regeneration: Its Benefits and Its Flaws 。
DPR 分布式的持续渲染则是为了解决 ISR 方案下可能访问到旧内容的问题,这也是由 Cassidy Williams 发起的一个提案,详情可在 GitHub 查看:Distributed Persistent Rendering (DPR) 。
由于目前这两项技术还在发展初期,能够支持的框架和服务还比较少,在这里建议作为一种技术知识储备提前了解,在未来的某一天有业务需要的时候,也可以知道有这样的方案可以解决问题。
工程化不止于前端 在 现代化的开发概念 部分所讲述的都是关于网页开发的变化,当然,前端这个岗位本身就是从页面开发发展起来的,自然还是离不开网页这个老本行。
但随着前端工程化的发展,前端越来越不止于写前端,已经有很多前端工程师利用前端工程化带来的优势,不仅仅只是做一个 Web 前端,开始逐步发展为一个全栈工程师,在企业内部承担起了更多的岗位职责,包括作者笔者也是。
之所以能做这么多事情,得益于 Node.js 在前端开发带来的翻天覆地的变化,可以在保持原有的 JavaScript 和 TypeScript 基础上,几乎没有过多的学习成本就可以过度到其他端的开发。
在了解 Node.js 之前,先来看看现在的前端开发工程师除了写 Web 前端,还可以做到哪些岗位的工作。
服务端开发 在传统的认知里,如果一个前端工程师想自己搭建一个服务端项目,需要学习 Java 、 PHP 、 Go 等后端语言,还需要学习 Nginx 、 Apache 等 Web Server 程序的使用,并使用这些技术来开发并部署一个项目的服务端。
现在的前端工程师可以利用 Node.js ,单纯使用 JavaScript 或者 TypeScript 来开发一个基于 Node 的服务端项目。
Node 本身是一个 JavaScript 的运行时,还提供了 HTTP 模块 可以启动一个本地 HTTP 服务,如果把 Node 项目部署到服务器上,就可以运行一个可对外访问的公网服务。
但 Node 的原生服务端开发成本比较高,因此在 GitHub 开源社区也诞生了很多更方便的、开箱即用、功能全面的服务端框架,根据它们的特点,可以简单归类如下:
以 Express 、 Koa 、 Fastify 为代表的轻量级服务端框架,这一类框架的特点是 “短平快” ,对于服务端需求不高,只是跑一些小项目的话,开箱即用非常地方便,比如 Build 了一个 Vue 项目,然后提供一个读取静态目录的服务来访问它。
但是 “短平快” 框架带来了一些团队协作上的弊端,如果缺少一些架构设计的能力,很容易把一个服务端搭的很乱以至于难以维护,比如项目的目录结构、代码的分层设计等等,每个创建项目的人都有自己的想法和个人喜好,就很难做到统一管理。
因此在这些框架的基础上,又诞生了以 Nest (底层基于 Express ,可切换为 Fastify )、 Egg (基于 Koa )为代表的基于 MVC 架构的企业级服务端框架,这一类框架的特点是基于底层服务进行了更进一步的架构设计并实现了代码分层,还自带了很多开箱即用的 Building Blocks ,例如 TypeORM 、WebSockets 、Swagger 等等,同样也是开箱即用,对大型项目的开发更加友好。
TIP
当然, Node.js 所做的事情是解决服务端程序部分的工作,如果涉及到数据存储的需求,学习 MySQL 和 Redis 的技术知识还是必不可少的!
App 开发 常规的 Native App 原生开发需要配备两条技术线的支持:使用 Java / Kotlin 语言开发 Android 版本,使用 Objective-C / Swift 语言开发 iOS 版本,这对于创业团队或者个人开发者来说都是一个比较高的开发成本。
前端开发者在项目组里对 App 的作用通常是做一些活动页面、工具页面内嵌到 App 的 WebView 里,如果是在一些产品比较少的团队里,例如只有一个 App 产品,那么前端的存在感会比较低。
而 Hybrid App 的出现,使得前端开发者也可以使用 JavaScript / TypeScript 来编写混合 App ,只需要了解简单的打包知识,就可以参与到一个 App 的开发工作中。
开发 Hybrid App 的过程通常称为混合开发,最大的特色就是一套代码可以运行到多个平台,这是因为整个 App 只有一个基座,里面的 App 页面都是使用 UI WebView 来渲染的 Web 界面,因此混合开发的开发成本相对于原生开发是非常低的,通常只需要一个人 / 一个小团队就可以输出双平台的 App ,并且整个 App 的开发周期也会更短。
在用户体验方面, Hybrid App 相对于 Native App ,一样可以做到:
双平台的体验一致性 支持热更新,无需用户重新下载整个 App 内置的 WebView 在交互体验上也可以做到和系统交互,比如读取 / 存储照片、通讯录,获取定位等等 支持 App Push 系统通知推送 还有很多 Native App 具备的功能 基本上 Native App 的常见功能,在 Hybrid App 都能满足。
而且大部分情况下,在构建 Hybrid App 的时候还可以顺带输出一个 Web App 版本,也就是让这个 App 在被用户下载前,也有一模一样的网页版可以体验,这对于吸引新用户是非常有用的。
在混合开发的过程中,通常是由前端开发者来负责 App 项目从 “开发” 到 “打包” 再到 “发版” 的整个流程,在开发的过程中是使用常见的前端技术栈,例如目前主流的有基于 Vue 的 uni-app 、基于 React 的 React Native 等等,这些 Hybrid 框架都具备了 “学习成本低、开发成本低、一套代码编译多个平台” 的特点。
在 App 开发完毕后,使用 Hybrid 框架提供的 CLI 工具编译出 App 资源包,再根据框架提供的原生基座打包教程去完成 Android / iOS 的安装包构建,这个环节会涉及到原生开发的知识,例如 Android 包的构建会使用到 Android Studio ,但整个过程使用到原生开发的环节非常少,几乎没有太高的学习门槛。
桌面程序开发 放在以前要开发一个 Windows 桌面程序,需要用上 QT / WPF / WinForm 等技术栈,还要学习 C++ / C# 之类的语言,对于只想在业余写几个小工具的开发者来说,上手难度和学习成本都很高,但在前端工程化的时代里,使用 JavaScript 或 TypeScript 也可以满足程序开发的需要。
这得益于 Electron / Tauri 等技术栈的出现,其中 Electron 的成熟度最高、生态最完善、最被广泛使用,除了可以构建 Windows 平台支持的 .exe
文件之外,对 macOS 和 Linux 平台也提供了对应的文件构建支持。
广大前端开发者每天都在使用的 Visual Studio Code 以及知名的 HTTP 网络测试工具 Postman 都是使用 Electron 开发的。
',71),g=p('笔者也通过 Electron 构建了多个给公司内部使用的界面化工具客户端,这一类技术栈对于前端开发者来说,真的非常方便!在这里以 Electron 为例,简单讲解下它的工作原理,以了解为什么程序开发可以如此简单。
Electron 的底层是基于 Chromium 和 Node.js ,它提供了两个进程供开发者使用:
主进程:它是整个应用的入口点,主进程运行在 Node 环境中,可以使用所有的 Node API ,程序也因此具备了和系统进行交互的能力,例如文件的读写操作。
渲染进程:负责与用户交互的 GUI 界面,基于 Chromium 运行,所以开发者得以使用 HTML / CSS / JavaScript 像编写网页一样来编写程序的 GUI 界面。
一个程序应用只会有一个主进程,而渲染进程则可以根据实际需求创建多个,渲染进程如果需要和系统交互,则必须与主进程通信,借助主进程的能力来实现。
在构建的时候, Electron 会把 Node 和 Chromium 一起打包为一个诸如 .exe
这样的安装文件(或者是包含了两者的免安装版本),这样用户不需要 Node 环境也可以运行桌面程序。
应用脚本开发 在 桌面程序开发 部分讲的是构建一种拥有可视化 GUI 界面的程序,但有时候并不需要复杂的 GUI ,可能只想提供一个双击运行的脚本类程序给用户,现在的前端工程化也支持使用 JavaScript 构建一个无界面的应用脚本。
假如某一天公司的运营小姐姐希望能做一个自动化的脚本减轻她们的机械操作,或者是自己工作过程中发现一些日常工作可以交付给脚本解决的情况,就可以使用这种方式来输出一个脚本程序,使用的时候双击运行非常方便。
笔者之前为了让团队的工程师减少写日报的心智负担,也是使用了这个方式编写了一个 git-commit-analytics 工具,部门里的工程师可以通过规范化 commit 来生成每天的工作日报,每天双击一下就可以生成一份报告,很受团队的喜欢。
',9),b=p(`在这里推荐一个工具 Pkg ,它可以把 Node 项目打包为一个可执行文件,支持 Windows 、 macOS 、 Linux 等多个平台,它的打包机制和 Electron 打包的思路类似,也是通过把 Node 一起打包,让用户可以在不安装 Node 环境的情况下也可以直接运行脚本程序。
实践工程化的流程 基于 Vue 3 的项目,最主流的工程化组合拳有以下两种:
常用方案 Runtime 构建工具 前端框架 方案一 Node Webpack Vue 方案二 Node Vite Vue
方案一是比较传统并且过去项目使用最多的方案组合,但从 2021 年初随着 Vite 2.0 的发布,伴随着更快的开发体验和日渐丰富的社区生态,新项目很多都开始迁移到方案二,因此本书秉着面向当下与未来的原则,会侧重 Vite 的使用来开展讲解,包括一些 demo 的创建等等。
当技术成熟的时候,还可以选择更喜欢的方案自行组合,例如用 Deno 来代替 Node ,但前期还是按照主流的方案来进入工程化的学习。
下面的内容将根据 Vue 3 的工程化开发,逐一讲解涉及到常用的工具,了解它们的用途和用法。
工程化神器 Node.js 只要在近几年有接触过前端开发,哪怕没有实际使用过,也应该有听说过 Node.js ,那么它是一个什么样的存在?
什么是 Node.js Node.js (简称 Node ) 是一个基于 Chrome V8 引擎构建的 JS 运行时( JavaScript Runtime )。
它让 JavaScript 代码不再局限于网页上,还可以跑在客户端、服务端等场景,极大的推动了前端开发的发展,现代的前端开发几乎都离不开 Node 。
什么是 Runtime Runtime ,可以叫它 “运行时” 或者 “运行时环境” ,这个概念是指,项目的代码在哪里运行,哪里就是运行时。
传统的 JavaScript 只能跑在浏览器上,每个浏览器都为 JS 提供了一个运行时环境,可以简单地把浏览器当成一个 Runtime ,明白了这一点,相信就能明白什么是 Node 。
Node 就是一个让 JS 可以脱离浏览器运行的环境,当然,这里并不是说 Node 就是浏览器。
Node 和浏览器的区别 虽然 Node 也是基于 Chrome V8 引擎构建,但它并不是一个浏览器,它提供了一个完全不一样的运行时环境,没有 Window 、没有 Document 、没有 DOM 、没有 Web API ,没有 UI 界面…
但它提供了很多浏览器做不到的能力,比如和操作系统的交互,例如 “文件读写” 这样的操作在浏览器有诸多的限制,而在 Node 则轻轻松松。
对于前端开发者来说, Node 的巨大优势在于,使用一种语言就可以编写所有东西(前端和后端),不再花费很多精力去学习各种各样的开发语言。
哪怕仅仅只做 Web 开发,也不再需要顾虑新的语言特性在浏览器上的兼容性( e.g. ES6 、 ES7 、 ES8 、 ES9 …), Node 配合构建工具,以及诸如 Babel 这样的代码编译器,可以帮转换为浏览器兼容性最高的 ES5 。
当然还有很多工程化方面的好处,总之一句话,使用 Node 的开发体验会非常好。
在 工程化的入门准备 一章中,会对 Node 开发做进一步的讲解,下面先继续顺着 Node 的工具链,了解与日常开发息息相关的前端构建工具。
工程化的构建工具 在前端开发领域,构建工具已经成为现在必不可少的开发工具了,很多刚接触前端工程化的开发者可能会有疑惑,为什么以前的前端页面直接编写代码就可以在浏览器访问,现在却还要进行构建编译,是否 “多此一举” ?
要消除这些困惑,就需要了解一下为什么要使用构建工具,知道构建工具在开发上能够给带来什么好处。
为什么要使用构建工具 目前已经有很多流行的构建工具,例如: Grunt 、 Gulp 、 Webpack 、 Snowpack 、 Parcel 、 Rollup 、 Vite … 每一个工具都有自己的特色。
如上面列举的构建工具,虽然具体到某一个工具的时候,是 “一个” 工具,但实际上可以理解为是 “一套” 工具链、工具集,构建工具通常集 “语言转换 / 编译” 、 “资源解析” 、 “代码分析” 、 “错误检查” 、 “任务队列” 等非常多的功能于一身。
构建工具可以帮解决很多问题,先看看最基础的一个功能支持: “语言转换 / 编译” 。
且不说构建工具让可以自由自在的在项目里使用 TypeScript 这些新兴的语言,单纯看历史悠久的 JavaScript ,从 2015 年开始,每年也都会有新的版本发布(例如 ES6 对应 ES2015 、 ES7 对应 ES2016 、 ES8 对应 ES2017 等等)。
虽然新版本的 JS API 更便捷更好用,但浏览器可能还没有完全支持,这种情况下可以通过构建工具去转换成兼容度更高的低版本 JS 代码。
举个很常用到的例子,现在判断一个数组是否包含某个值,通常会这么写:
js // 声明一个数组
+const arr = [ ' foo ' , ' bar ' , ' baz ' ]
+
+// 当数组包含 foo 这个值时,处理一些逻辑
+if (arr . includes ( ' foo ' )) {
+ // do something…
+}
通过 Array.prototype.includes()
这个实例方法返回的布尔值,判断数组是否包含目标值,而这个方法是从 ES6 开始支持的,对于不支持 ES6 的古董浏览器,只能使用其他更早期的方法代替( e.g. indexOf
),或者手动引入它的 Polyfill 来保证这个方法可用。
TIP
Polyfill 是在浏览器不支持的情况下实现某个功能的代码,可以在概念发明者 Remy Sharp 的博文里了解到它的由来,是一个挺有意思的命名。
点击阅读: What is a Polyfill?
以下是摘选自 MDN 网站上关于 Array.prototype.includes() 的 Polyfill 实现:
js // https://tc39.github.io/ecma262/#sec-array.prototype.includes
+if ( ! Array . prototype . includes) {
+ Object . defineProperty ( Array . prototype , ' includes ' , {
+ value : function ( valueToFind , fromIndex ) {
+ if ( this == null ) {
+ throw new TypeError ( ' "this" is null or not defined ' )
+ }
+
+ // 1. Let O be ? ToObject(this value).
+ var o = Object ( this )
+
+ // 2. Let len be ? ToLength(? Get(O, "length")).
+ var len = o . length >>> 0
+
+ // 3. If len is 0, return false.
+ if ( len === 0 ) {
+ return false
+ }
+
+ // 4. Let n be ? ToInteger(fromIndex).
+ // (If fromIndex is undefined, this step produces the value 0.)
+ var n = fromIndex | 0
+
+ // 5. If n ≥ 0, then
+ // a. Let k be n.
+ // 6. Else n < 0,
+ // a. Let k be len + n.
+ // b. If k < 0, let k be 0.
+ var k = Math . max ( n >= 0 ? n : len - Math . abs ( n ) , 0 )
+
+ function sameValueZero ( x , y ) {
+ return (
+ x === y ||
+ ( typeof x === ' number ' &&
+ typeof y === ' number ' &&
+ isNaN ( x ) &&
+ isNaN ( y ))
+ )
+ }
+
+ // 7. Repeat, while k < len
+ while ( k < len ) {
+ // a. Let elementK be the result of ? Get(O, ! ToString(k)).
+ // b. If SameValueZero(valueToFind, elementK) is true, return true.
+ if ( sameValueZero ( o [ k ] , valueToFind )) {
+ return true
+ }
+ // c. Increase k by 1.
+ k ++
+ }
+
+ // 8. Return false
+ return false
+ },
+ } )
+}
由于 JavaScript 允许更改 prototype ,所以 Polyfill 的原理就是先检查浏览器是否支持某个方法,当浏览器不支持的时候,会借助已经被广泛支持的方法来实现相同的功能,达到在旧浏览器上也可以使用新方法的目的。
下面是一个简单的 includes
方法实现,也借用浏览器支持的 indexOf
方法,让不支持 includes
的浏览器也可以使用 includes
:
js // 借助 indexOf 来实现一个简单的 includes
+if ( ! Array . prototype . includes) {
+ Array . prototype . includes = function ( v ) {
+ return this. indexOf ( v ) > - 1
+ }
+}
WARNING
请注意,上面这个实现方案很粗糙,没有 Polyfill 的方案考虑的足够周到,只是在这里做一个简单的实现演示。
Polyfill 会考虑到多种异常情况,最大幅度保证浏览器的兼容支持,当然一些复杂的方法实现起来会比较臃肿,全靠人工维护 Polyfill 很不现实。
而且实际的项目里,要用到的 JavaScript 原生方法非常多,不可能手动去维护每一个方法的兼容性,所以这部分工作,通常会让构建工具来自动化完成,常见的方案就有 Babel 。
除了 “语言转换 / 编译” 这个好处之外,在实际的开发中,构建工具可以更好地提高开发效率、提供自动化的代码检查、规避上线后的生产风险,例如:
项目好多代码可以复用,可以直接抽离成 模块 、 组件 ,交给构建工具去合并打包 TypeScript 的类型系统和代码检查真好用,也可以放心写,交给构建工具去编译CSS 写起来很慢,可以使用 Sass 、 Less 等 CSS 预处理器 ,利用它们的变量支持、混合继承等功能提高开发效率,最终交给构建工具去编译回 CSS 代码 海量的 npm 包 开箱即用,剩下的工作交给构建工具去按需抽离与合并 项目上线前代码要混淆,人工处理太费劲,交给构建工具自动化处理 还有很多列举不完的其他场景… 下面基于接下来要学习的 Vue3 技术栈,介绍两个流行且强相关的构建工具: Webpack 和 Vite 。
Webpack Webpack 是一个老牌的构建工具,前些年可以说几乎所有的项目都是基于 Webpack 构建的,生态最庞大,各种各样的插件最全面,对旧版本的浏览器支持程度也最全面。
点击访问:Webpack 官网
在升级与配置一章里的 使用 @vue/cli 创建项目 会指导如何使用 Vue CLI 创建一个基于 Webpack 的 Vue 项目。
Vite Vite 的作者也是熟悉的 Vue 作者尤雨溪,它是一个基于 ESM 实现的构建工具,主打更轻、更快的开发体验,主要面向现代浏览器,于 2021 年推出 2.x 版本之后,进入了一个飞速发展的时代,目前市场上的 npm 包基本都对 Vite 做了支持,用来做业务已经没有问题了。
毫秒级的开发服务启动和热重载,对 TypeScript 、 CSS 预处理器等常用开发工具都提供了开箱即用的支持,也兼容海量的 npm 包,如果是先用 Webpack 再用的 Vite ,会很快就喜欢上它!
点击访问:Vite 官网
在升级与配置一章里的 使用 Vite 创建项目 会指导如何使用流行脚手架创建一个基于 Vite 的 Vue 项目。
两者的区别 在开发流程上, Webpack 会先打包,再启动开发服务器,访问开发服务器时,会把打包好的结果直接给过去,下面是 Webpack 使用的 bundler 机制的工作流程。
`,58),m=o("p",null,"Vite 是基于浏览器原生的 ES Module ,所以不需要预先打包,而是直接启动开发服务器,请求到对应的模块的时候再进行编译,下面是 Vite 使用的 ESM 机制的工作流程。",-1),f=p('所以当项目体积越大的时候,在开发启动速度上, Vite 和 Webpack 的差距会越来越大。
可以点击 Vite 官网的这篇文章: 为什么选 Vite 了解更多的技术细节。
构建方面,为了更好的加载体验,以及 Tree Shaking 按需打包 、懒加载和 Chunk 分割利于缓存,两者都需要进行打包;但由于 Vite 是面向现代浏览器,所以如果项目有兼容低版本浏览器的需求的话,建议还是用 Webpack 来打包,否则, Vite 是目前的更优解。
开发环境和生产环境 在使用构建工具的时候,需要了解一下 “环境” 的概念,对构建工具而言,会有 “开发环境( development )” 和 “生产环境( production )” 之分。
TIP
需要注意的是,这和业务上的 “测试 -> 预发 -> 生产” 那几个环境的概念是不一样的,业务上线流程的这几个环境,对于项目来说,都属于 “生产环境” ,因为需要打包部署。
开发环境 前面在编写 Hello TypeScript 这个 demo 的时候,使用了 npm run dev:ts
这样的命令来测试 TypeScript 代码的可运行性,可以把这个阶段认为是一个 “测试环境” ,这个时候代码不管怎么写,它都是 TypeScript 代码,不是最终要编译出来的 JavaScript 。
如果基于 Webpack 或者 Vite 这样的构建工具,测试环境提供了更多的功能,例如:
可以使用 TypeScript 、 CSS 预处理器之类的需要编译的语言提高开发效率 提供了热重载( Hot Module Replacement , 简称 HMR ),当修改了代码之后,无需重新运行或者刷新页面,构建工具会检测的修改自动帮更新 代码不会压缩,并有 Source Mapping 源码映射,方便 BUG 调试 默认提供局域网服务,无需自己做本地部署 更多 … 生产环境 在 Hello TypeScript demo 最后配置的一个 npm run build
命令,将 TypeScript 代码编译成了 JavaScript ,这个时候 dist 文件夹下的代码文件就处于 “生产环境” 了,因为之后不论源代码怎么修改,都不会直接影响到它们,直到再次执行 build 编译。
可以看出生产环境和开发环境最大的区别就是稳定!除非再次打包发布,否则不会影响到已部署的代码。
代码会编译为浏览器最兼容的版本,一些不兼容的新语法会进行 Polyfill 稳定,除非重新发布,否则不会影响到已部署的代码 打包的时候代码会进行压缩混淆,缩小项目的体积,也降低源码被直接曝光的风险 环境判断 ',15),E=p('在 Vite ,还可以通过判断 import.meta.env.DEV
为 true
时是开发环境,判断 import.meta.env.PROD
为 true
时是生产环境(这两个值永远相反)。
有关环境变量的问题可以查阅以下文档:
',3),x=JSON.parse('{"title":"了解前端工程化","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"engineering.md","filePath":"engineering.md"}'),S={name:"engineering.md"},V=Object.assign(S,{setup(k){const c="process.env.NODE_ENV";return(v,q)=>{const l=t("ImgWrap"),a=t("ClientOnly"),r=t("GitalkComment");return y(),D("div",null,[i,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/job-details.png",dark:"/assets/img/job-details-dark.png",alt:"知名企业对 1-3 年经验的前端工程师招聘要求"})]),_:1}),C,s(a,null,{default:n(()=>[s(l,{src:"/logo.png",alt:"Vue.js Logo",maxWidth:240})]),_:1}),d,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/seo-page.jpg",dark:"/assets/img/seo-page-dark.jpg",alt:"网页呈现的内容"})]),_:1}),A,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/seo-page-code.jpg",dark:"/assets/img/seo-page-code-dark.jpg",alt:"网页内容对应的 HTML 源码"})]),_:1}),h,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/seo-spa-page-code.jpg",dark:"/assets/img/seo-spa-page-code-dark.jpg",alt:"单页面应用的网页内容只有一个空的 HTML 结构"})]),_:1}),u,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/screenshot-vscode.jpg",dark:"/assets/img/screenshot-vscode-dark.jpg",alt:"Visual Studio Code 界面截图"})]),_:1}),s(a,null,{default:n(()=>[s(l,{src:"/assets/img/screenshot-postman.jpg",dark:"/assets/img/screenshot-postman-dark.jpg",alt:"Postman 界面截图"})]),_:1}),g,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/screenshot-pkg.jpg",dark:"/assets/img/screenshot-pkg-dark.jpg",alt:"使用 Pkg 构建后的程序运行截图"})]),_:1}),b,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/bundler.png",alt:"Webpack 的工作原理(摘自 Vite 官网)"})]),_:1}),m,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/esm.png",alt:"Vite 的工作原理(摘自 Vite 官网)"})]),_:1}),f,o("p",null,[e("在 Webpack ,可以使用 "),o("code",null,F(c)),e(" 来区分开发环境( development )还是生产环境( production ),它会返回当前所处环境的名称。")]),E,s(a,null,{default:n(()=>[s(r,{issueId:194})]),_:1})])}}});export{x as __pageData,V as default};
diff --git a/assets/engineering.md.43ce842b.lean.js b/assets/engineering.md.43ce842b.lean.js
new file mode 100644
index 00000000..288e259c
--- /dev/null
+++ b/assets/engineering.md.43ce842b.lean.js
@@ -0,0 +1,291 @@
+/**
+ * name: learning-vue3
+ * version: v2.0.0
+ * description: A starting learning tutorial on Vue 3.0 + TypeScript, suitable for complete Vue novices and Vue 2.0 veterans, incorporating some of my own practical experience on the basis of official documents.
+ * author: chengpeiquan
+ * homepage: https://vue3.chengpeiquan.com
+ */
+import{v as y,b as D,t as s,O as n,F as o,L as e,X as F,R as p,M as t}from"./chunks/framework.0d8bea05.js";const i=p(`了解前端工程化 现在前端的工作与以前的前端开发已经完全不同了。
刚接触前端的时候,做一个页面,是先创建 HTML 页面文件写页面结构,再在里面写 CSS 代码美化页面,再根据需要写一些 JavaScript 代码增加交互功能,需要几个页面就创建几个页面,相信大家的前端起步都是从这个模式开始的。
而实际上的前端开发工作,早已进入了前端工程化开发的时代,已经充满了各种现代化框架、预处理器、代码编译…
最终的产物也不再单纯是多个 HTML 页面,经常能看到 SPA / SSR / SSG 等词汇的身影。
传统开发的弊端 在了解什么是前端工程化之前,先回顾一下传统开发存在的一些弊端,这样更能知道为什么需要它。
在传统的前端开发模式下,前端工程师大部分只需要单纯地写写页面,都是在 HTML 文件里直接编写代码,所需要的 JavaScript 代码是通过 script
标签以内联或者文件引用的形式放到 HTML 代码里的,当然 CSS 代码也是一样的处理方式。
例如这样:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > Document </ title >
+ </ head >
+ < body >
+ <!-- 引入 JS 文件 -->
+ < script src = " ./js/lib-1.js " ></ script >
+ < script src = " ./js/lib-2.js " ></ script >
+ <!-- 引入 JS 文件 -->
+ </ body >
+</ html >
如演示代码,虽然可以把代码分成多个文件来维护,这样可以有效降低代码维护成本,但在实际开发过程中,还是会存在代码运行时的一些问题。
一个常见的案例 继续用上面的演示代码,来看一个最简单的一个例子。
先在 lib-1.js
文件里,声明一个变量:
再在 lib-2.js
文件里,也声明一个变量(没错,也是 foo
):
然后在 HTML 代码里追加一个 script
,打印这个值:
html <! DOCTYPE html >
+< html lang = " en " >
+< head >
+ < meta charset = " UTF-8 " >
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " >
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " >
+ < title > Document </ title >
+</ head >
+< body >
+
+ <!-- 引入 JS 文件 -->
+ < script src = " ./js/lib-1.js " ></ script >
+ < script src = " ./js/lib-2.js " ></ script >
+ <!-- 引入 JS 文件 -->
+
+ <!-- 假设这里是实际的业务代码 -->
+ < script >
+ console . log (foo)
+ </ script >
+ <!-- 假设这里是实际的业务代码 -->
+
+</ body >
+</ html >
先猜猜会输出什么? —— 答案是 2
。
如果在开发的过程中,不知道在 lib-2.js
文件里也声明了一个 foo
变量,一旦在后面的代码里预期了 foo + 2 === 3
,那么这样就得不到想要的结果(因为 lib-1.js
里的 foo
是 1
, 1 + 2
等于 3
) 。
原因是 JavaScript 的加载顺序是从上到下,当使用 var
声明变量时,如果命名有重复,那么后加载的变量会覆盖掉先加载的变量。
这是使用 var
声明的情况,它允许使用相同的名称来重复声明,那么换成 let
或者 const
呢?
虽然不会出现重复声明的情况,但同样会收到一段报错:
bash Uncaught SyntaxError: Identifier ' foo ' has already been declared (at lib-2.js:1:1 )
这次程序直接崩溃了,因为 let
和 const
无法重复声明,从而抛出这个错误,程序依然无法正确运行。
更多问题 以上只是一个最简单的案例,就暴露出了传统开发很大的弊端,然而并不止于此,实际上,存在诸如以下这些问题:
如本案例,可能存在同名的变量声明,引起变量冲突 引入多个资源文件时,比如有多个 JS 文件,在其中一个 JS 文件里面使用了在别处声明的变量,无法快速找到是在哪里声明的,大型项目难以维护 类似第 1 、 2 点提到的问题无法轻松预先感知,很依赖开发人员人工定位原因 大部分代码缺乏分割,比如一个工具函数库,很多时候需要整包引入到 HTML 里,文件很大,然而实际上只需要用到其中一两个方法 由第 4 点大文件延伸出的问题, script
的加载从上到下,容易阻塞页面渲染 不同页面的资源引用都需要手动管理,容易造成依赖混乱,难以维护 如果要压缩 CSS 、混淆 JS 代码,也是要人力操作使用工具去一个个处理后替换,容易出错 当然,实际上还会有更多的问题会遇到。
工程化带来的优势 为了解决传统开发的弊端,前端也开始引入工程化开发的概念,借助工具来解决人工层面的烦琐事情。
开发层面的优势 在 传统开发的弊端 里,主要列举的是开发层面的问题,工程化首要解决的当然也是在开发层面遇到的问题。
在开发层面,前端工程化有以下这些好处:
引入了模块化和包的概念,作用域隔离,解决了代码冲突的问题 按需导出和导入机制,让编码过程更容易定位问题 自动化的代码检测流程,有问题的代码在开发过程中就可以被发现 编译打包机制可以让使用开发效率更高的编码方式,比如 Vue 组件、 CSS 的各种预处理器 引入了代码兼容处理的方案( e.g. Babel ),可以让开发者自由使用更先进的 JavaScript 语句,而无需顾忌浏览器兼容性,因为最终会转换为浏览器兼容的实现版本 引入了 Tree Shaking 机制,清理没有用到的代码,减少项目构建后的体积 还有非常多的体验提升,列举不完。而对应的工具,根据用途也会有非常多的选择,在后面的学习过程中,会一步一步体验到工程化带来的好处。
团队协作的优势 除了对开发者有更好的开发体验和效率提升,对于团队协作,前端工程化也带来了更多的便利,例如下面这些场景:
统一的项目结构 以前的项目结构比较看写代码的人的喜好,虽然一般在研发部门里都有 “团队规范” 这种东西,但靠自觉性去配合的事情,还是比较难做到统一,特别是项目很赶的时候。
工程化后的项目结构非常清晰和统一,以 Vue 项目来说,通过脚手架创建一个新项目之后,它除了提供能直接运行 Hello World 的基础代码之外,还具备了如下的统一目录结构:
src
是源码目录src/main.ts
是入口文件src/views
是路由组件目录src/components
是子组件目录src/router
是路由目录虽然也可以自行调整成别的结构,但根据笔者在多年的工作实际接触下来,以及从很多开源项目的代码里看到的,都是沿用脚手架创建的项目结构(不同脚手架创建的结构会有所不同,但基于同一技术栈的项目基本上都具备相同的结构)。
统一的代码风格 不管是接手其他人的代码或者是修改自己不同时期的代码,可能都会遇到这样的情况,例如一个模板语句,上面包含了很多属性,有的人喜欢写成一行,属性多了维护起来很麻烦,需要花费较多时间辨认:
vue < template >
+ < div class = " list " >
+ <!-- 这个循环模板有很多属性 -->
+ < div class = " item " :class = " { \`top-\${index + 1}\`: index < 3 } " v-for = " (item, index)
+ in list " :key = " item.id " @click = " handleClick(item.id) " >
+ < span > {{ item.text }} </ span >
+ </ div >
+ <!-- 这个循环模板有很多属性 -->
+ </ div >
+</ template >
而工程化配合统一的代码格式化规范,可以让不同人维护的代码,最终提交到 Git 上的时候,风格都保持一致,并且类似这种很多属性的地方,都会自动帮格式化为一个属性一行,维护起来就很方便:
vue < template >
+ < div class = " list " >
+ <!-- 这个循环模板有很多属性 -->
+ < div
+ class = " item "
+ :class = " { \`top-\${index + 1}\`: index < 3 } "
+ v-for = " (item, index) in list "
+ :key = " item.id "
+ @click = " handleClick(item.id) "
+ >
+ < span > {{ item.text }} </ span >
+ </ div >
+ <!-- 这个循环模板有很多属性 -->
+ </ div >
+</ template >
同样的,写 JavaScript 时也会有诸如字符串用双引号还是单引号,缩进是 Tab 还是空格,如果用空格到底是要 4 个空格还是 2 个空格等一堆 “没有什么实际意义” 、但是不统一的话协作起来又很难受的问题……
在工程化项目这些问题都可以交给程序去处理,在书写代码的时候,开发者可以先按照自己的习惯书写,然后再执行命令进行格式化,或者是在提交代码的时候配合 Git Hooks 自动格式化,都可以做到统一风格。
TIP
在 添加协作规范 一节可以学习如何给项目添加统一的协作规范。
可复用的模块和组件 传统项目比较容易被复用的只有 JavaScript 代码和 CSS 代码,会抽离公共函数文件上传到 CDN ,然后在 HTML 页面里引入这些远程资源, HTML 代码部分通常只有由 JS 创建的比较小段的 DOM 结构。
并且通过 CDN 引入的资源,很多时候都是完整引入,可能有时候只需要用到里面的一两个功能,却要把很大的完整文件都引用进来。
这种情况下,在前端工程化里,就可以抽离成一个开箱即用的 npm 组件包,并且很多包都提供了模块化导出,配合构建工具的 Tree Shaking ,可以抽离用到的代码,没有用到的其他功能都会被抛弃,不会一起发布到生产环境。
TIP
在 依赖包和插件 一节可以学习如何查找和使用开箱即用的 npm 包。
代码健壮性有保障 传统的开发模式里,只能够写 JavaScript ,而在工程项目里,可以在开发环境编写带有类型系统的 TypeScript ,然后再编译为浏览器能认识的 JavaScript 。
在开发过程中,编译器会检查代码是否有问题,比如在 TypeScript 里声明了一个布尔值的变量,然后不小心将它赋值为数值:
ts // 声明一个布尔值变量
+let bool : boolean = true
+
+// 在 TypeScript ,不允许随意改变类型,这里会报错
+bool = 3
编译器检测到这个行为的时候就会抛出错误:
bash # ...
+return new TSError ( diagnosticText, diagnosticCodes );
+ ^
+TSError: ⨯ Unable to compile TypeScript:
+src/index.ts:2:1 - error TS2322: Type ' number ' is not assignable to type ' boolean ' .
+
+2 bool = 3
+ ~~~~
+# ...
从而得以及时发现问题并修复,减少线上事故的发生。
团队开发效率高 在前后端合作环节,可以提前 Mock 接口与后端工程师同步开发,如果遇到跨域等安全限制,也可以进行本地代理,不受跨域困扰。
前端工程在开发过程中,还有很多可以交给程序处理的环节,像前面提到的代码格式化、代码检查,还有在部署上线的时候也可以配合 CI/CD 完成自动化流水线,不像以前改个字都要找服务端工程师去更新,可以把非常多的人力操作剥离出来交给程序。
求职竞争上的优势 近几年前端开发领域的相关岗位,都会在招聘详情里出现类似的描述:
熟悉 Vue / React 等主流框架,对前端组件化和模块化有深入的理解和实践 熟悉面向组件的开发模式,熟悉 Webpack / Vite 等构建工具 熟练掌握微信小程序开发,熟悉 Taro 框架或 uni-app 框架优先 熟悉 Scss / Less / Stylus 等预处理器的使用 熟练掌握 TypeScript 者优先 有良好的代码风格,结构设计与程序架构者优先 了解或熟悉后端开发者优先(如 Java / Go / Node.js )
知名企业对 1-3 年工作经验的初中级工程师,更是明确要求具备前端工程化开发的能力:
`,73),C=p('组件化开发、模块化开发、 Webpack / Vite 构建工具、 Node.js 开发… 这些技能都属于前端工程化开发的知识范畴,不仅在面试的时候会提问,入职后新人接触的项目通常也是直接指派前端工程化项目,如果能够提前掌握相关的知识点,对求职也是非常有帮助的!
Vue.js 与工程化 在上一节提到了前端工程化 在求职竞争上的优势 ,里面列出的招聘要求例子都提及到了 Vue 和 React 这些主流的前端框架,前端框架是前端工程化开发里面不可或缺的成员。
框架能够充分的利用前端工程化相关的领先技术,不仅在开发层面降低开发者的上手难度、提升项目开发效率,在构建出来的项目成果上也有着远比传统开发更优秀的用户体验。
本书结合 Vue.js 框架 3.0 系列的全新版本,将从项目开发的角度,在帮助开发者入门前端工程化的同时,更快速的掌握一个流行框架的学习和使用。
了解 Vue.js 与全新的 3.0 版本 Vue.js(发音为 /vjuː/
,类似 view
)是一个易学易用,性能出色,适用场景丰富的 Web 前端框架,从 2015 年发布 1.0 版本以来,受到了全世界范围的前端开发者喜爱,已成为当下最受欢迎的前端框架之一。
',7),d=p(`Vue 一直紧跟广大开发者的需求迭代发展,保持着它活跃的生命力。
2020 年 9 月 18 日, Vue.js 发布了 3.0 正式版,在大量开发者长达约一年半的使用和功能改进反馈之后, Vue 又于 2022 年 2 月 7 日发布了 3.2 版本,同一天, Vue 3 成为 Vue.js 框架全新的默认版本 (在此之前,通过 npm install vue
的默认版本还是 Vue 2 )。
也就是在未来的日子里, Vue 3 将随着时间的推移,逐步成为 Vue 生态的主流版本,是时候学习 Vue 3 了!
如果还没有体验过 Vue ,可以把以下代码复制到的代码编辑器,保存成一个 HTML 文件(例如: hello.html
),并在浏览器里打开访问,同时请唤起浏览器的控制台面板(例如 Chrome 浏览器是按 F12
或者鼠标右键点 “检查” ),在 Console 面板查看 Log 的打印。
html <!-- 这是使用 Vue 实现的 demo -->
+<! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > Hello Vue </ title >
+ < script src = " https://unpkg.com/vue@3 " ></ script >
+ </ head >
+ < body >
+ < div id = " app " >
+ <!-- 通过 \`{{ 变量名 }}\` 语法渲染响应式变量 -->
+ < p > Hello {{ name }}! </ p >
+
+ <!-- 通过 \`v-model\` 双向绑定响应式变量 -->
+ <!-- 通过 \`@input\` 给输入框绑定输入事件 -->
+ < input
+ type = " text "
+ v-model = " name "
+ placeholder = " 输入名称打招呼 "
+ @input = " printLog "
+ />
+
+ <!-- 通过 \`@click\` 给按钮绑定点击事件 -->
+ < button @click = " reset " > 重置 </ button >
+ </ div >
+
+ < script >
+ const { createApp , ref } = Vue
+ createApp ( {
+ // \`setup\` 是一个生命周期钩子
+ setup () {
+ // 默认值
+ const DEFAULT_NAME = ' World '
+
+ // 用于双向绑定的响应式变量
+ const name = ref ( DEFAULT_NAME )
+
+ // 打印响应式变量的值到控制台
+ function printLog () {
+ // \`ref\` 变量需要通过 \`.value\` 操作值
+ console . log ( name . value )
+ }
+
+ // 重置响应式变量为默认值
+ function reset () {
+ name . value = DEFAULT_NAME
+ printLog ()
+ }
+
+ // 需要 \`return\` 出去才可以被模板使用
+ return { name , printLog , reset }
+ },
+ } ) . mount ( ' #app ' )
+ </ script >
+ </ body >
+</ html >
这是一个基于 Vue 3 组合式 API 语法的 demo ,它包含了两个主要功能:
可以在输入框修改输入内容,上方的 Hello World!
以及浏览器控制台的 Log 输出,都会随着输入框内容的变更而变化 可以点击 “重置” 按钮,响应式变量被重新赋值的时候,输入框的内容也会一起变化为新的值 这是 Vue 的特色之一:数据的双向绑定。
对比普通的 HTML 文件需要通过输入框的 oninput
事件手动编写视图的更新逻辑, Vue 的双向绑定功能大幅度减少了开发过程的编码量。
在未接触 Vue 这种编程方式之前,相信大部分人首先想到的是直接操作 DOM 来实现需求,为了更好的进行对比,接下来用原生 JavaScript 实现一次相同的功能:
html <!-- 这是使用原生 JavaScript 实现的 demo -->
+<! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > Hello World </ title >
+ </ head >
+ < body >
+ < div id = " app " >
+ <!-- 通过一个 \`span\` 标签来指定要渲染数据的位置 -->
+ < p > Hello < span id = " name " ></ span > ! </ p >
+
+ <!-- 通过 \`oninput\` 给输入框绑定输入事件 -->
+ < input
+ id = " input "
+ type = " text "
+ placeholder = " 输入名称打招呼 "
+ oninput = " handleInput () "
+ />
+
+ <!-- 通过 \`onclick\` 给按钮绑定点击事件 -->
+ < button onclick = " reset () " > 重置 </ button >
+ </ div >
+
+ < script >
+ // 默认值
+ const DEFAULT_NAME = ' World '
+
+ // 要操作的 DOM 元素
+ const nameElement = document . querySelector ( ' #name ' )
+ const inputElement = document . querySelector ( ' #input ' )
+
+ // 处理输入
+ function handleInput () {
+ const name = inputElement . value
+ nameElement . innerText = name
+ printLog ()
+ }
+
+ // 打印输入框的值到控制台
+ function printLog () {
+ const name = inputElement . value
+ console . log ( name )
+ }
+
+ // 重置 DOM 元素的文本和输入框的值
+ function reset () {
+ nameElement . innerText = DEFAULT_NAME
+ inputElement . value = DEFAULT_NAME
+ printLog ()
+ }
+
+ // 执行一次初始化,赋予 DOM 元素默认文本和输入框的默认值
+ window . addEventListener ( ' load ' , reset)
+ </ script >
+ </ body >
+</ html >
虽然两个方案总的代码量相差不大,但可以看到两者的明显区别:
Vue 只需要对一个 name
变量的进行赋值操作,就可以轻松实现视图的同步更新 使用原生 JavaScript 则需要频繁的操作 DOM 才能达到输入内容即时体现在文本 DOM 上面,并且还要考虑 DOM 是否已渲染完毕,否则操作会出错 Vue 的这种编程方式,称之为 “数据驱动” 编程。
如果在一个页面上频繁且大量的操作真实 DOM ,频繁的触发浏览器回流( Reflow )与重绘( Repaint ),会带来很大的性能开销,从而造成页面卡顿,在大型项目的性能上很是致命。
而 Vue 则是通过操作虚拟 DOM ( Virtual DOM ,简称 VDOM ),每一次数据更新都通过 Diff 算法找出需要更新的节点,只更新对应的虚拟 DOM ,再去映射到真实 DOM 上面渲染,以此避免频繁或大量的操作真实 DOM 。
TIP
虚拟 DOM 是一种编程概念,是指将原本应该是真实 DOM 元素的 UI 界面,用数据结构来组织起完整的 DOM 结构,再同步给真实 DOM 渲染,减少浏览器的回流与重绘。
在 JavaScript 里,虚拟 DOM 的表现是一个 Object 对象,其中需要包含指定的属性(例如 Vue 的虚拟 DOM 需要用 type
来指定当前标签是一个 <div />
还是 <span />
),然后框架会根据对象的属性去转换为 DOM 结构并最终完成内容的显示。
更多关于 Vue 虚拟 DOM 和性能优化可以查看官网的 渲染机制 一章进行了解。
Vue 3.0 版本还引入了组合式 API 的概念,更符合软件工程 “高内聚,低耦合” 的思想,让开发者可以更灵活的管理自己的逻辑代码,更方便的进行抽离封装再复用,不管是大型项目还是流水线业务,开箱即用的逻辑代码都是提升开发效率的利器。
Vue 与工程化之间的关联 在已经对 Vue 做了初步了解之后,可能有读者会问:“既然 Vue 的使用方式也非常简单,可以像 jQuery 这些经典类库一样在 HTML 引入使用,那么 Vue 和工程化有什么关联呢?”
Vue.js 是一个框架,框架除了简化编码过程中的复杂度之外,面对不同的业务需求还提供了通用的解决方案,而这些解决方案,通常是将前端工程化里的很多种技术栈组合起来串成一条条技术链,一环扣一环,串起来就是一个完整的工程化项目。
举一个常见的例子,比如上一节内容 了解 Vue.js 与全新的 3.0 版本 里的 demo 是一个简单的 HTML 页面,如果业务稍微复杂一点,比如区分了 “首页” 、 “列表页” 、 “内容页” 这样涉及到多个页面,传统的开发方案是通过 A 标签跳转到另外一个页面,在跳转期间会产生 “新页面需要重新加载资源、会有短暂白屏” 等情况,用户体验不太好。
Vue 提供了 Vue Router 实现路由功能,利用 History API 实现单页面模式(可在 现代化的开发概念 部分了解区别),在一个 HTML 页面里也可以体验 “页面跳转” 这样的体验,但如果页面很多,所有代码都堆积在一个 HTML 页面里,就很难维护。
借助前端工程化的构建工具,开发者可以编写 .vue
单组件文件,将多个页面的代码根据其功能模块进行划分,可拆分到多个单组件文件里维护并进行合理复用,最终通过构建工具编译再合并,最终生成浏览器能访问的 HTML / CSS / JS 文件,这样的开发过程,用户体验没有影响,但开发体验大大提升。
类似这样一个个业务场景会积少成多,把 Vue 和工程化结合起来,处理问题更高效更简单。
选择 Vue 入门工程化的理由 虽然前端的流行框架有主流的 Angular 、 React 和 Vue ,也有新兴的 Svelte 等等,每一个框架都有自己的特色,那为什么建议选择 Vue 来入门工程化呢?
最主要的两个原因是:
职场对 Vue 技术栈的需求量大,容易找工作 上手门槛低,会一些基础的 HTML / CSS / JavaScript 语法知识,就能够轻松上手 Vue 的组件开发 第一个原因在 求职竞争上的优势 已有进行过说明,掌握一门流行框架已经是前端岗位必备的技能,几乎所有公司在招聘前端工程师的时候都要求会 Vue 。
这里主要讲讲第二个原因,在 Vue 与工程化之间的关联 里提到了开发者可以编写 .vue
文件,这是一个 Vue 专属的文件扩展名,官方名称是 Single-File Component ,简称 SFC ,也就是单文件组件。
.vue
文件最大的特色就是支持像编写 .html
文件一样,在文件里写 HTML / CSS / JS 代码,不仅结构相似,在代码书写上,两者的语法也是十分接近:
.vue
文件.html
文件<template />
部分HTML 代码 <style />
部分CSS 代码 <script />
部分JavaScript 代码
下面就是一个最基础的 Vue 组件结构,可以看到和 HTML 文件是非常的相似:
vue <!-- \`template\` 对应 HTML 代码 -->
+< template >
+ < div >
+ <!-- 一些 HTML -->
+ </ div >
+</ template >
+
+<!-- \`script\` 部分对应 JavaScript 代码 -->
+<!-- 还支持其他语言,例如 \`lang="ts"\` 代表当前使用 TypeScript 编写 -->
+< script >
+export default {
+ // 这里是变量、函数等逻辑代码
+}
+</ script >
+
+<!-- \`style\` 部分对应 CSS 代码 -->
+<!-- 还支持开启 \`scoped\` 标识,让 CSS 代码仅对当前组件生效,不会全局污染 -->
+< style scoped >
+/* 一些 CSS 代码 */
+</ style >
Vue 组件不仅支持这些语言的所有基础用法,还增加了非常多更高效的功能,在后面 Vue3 教程的 单组件的编写 一章会有详细的介绍。
现代化的开发概念 在本章最开始的时候提到了 SPA / SSR / SSG 等词汇,这些词汇是一些现代前端工程化开发的概念名词缩写,代表着不同的开发模式和用户体验。
当下主流的前端框架都提供了这些开发模式的支持,因此在学习前端工程化和 Vue 开发的过程中,会不定期的看到这一类词汇,在实际工作业务的技术选型时,面对不同的业务场景也要考虑好需要使用什么样的开发模式,提前了解这些概念,对以后的工作也会很有帮助。
MPA 与 SPA 首先来看 MPA 与 SPA ,这代表着两个完全相反的开发模式和用户体验,它们的全称和中文含义如下:
名词 全称 中文 MPA Multi-Page Application 多页面应用 SPA Single-Page Application 单页面应用
多页面应用 MPA 多页面应用是最传统的网站体验,当一个网站有多个页面时,会对应有多个实际存在的 HTML 文件,访问每一个页面都需要经历一次完整的页面请求过程:
bash # 传统的页面跳转过程
+
+从用户点击跳转开始:
+---> 浏览器打开新的页面
+---> 请求【所有】资源
+---> 加载 HTML 、CSS 、 JS 、 图片等资源
+---> 完成新页面的渲染
MPA 的优点 作为最传统也是最被广泛运用的模式,自然有它的优势存在:
因为 MPA 的页面源码都是实实在在的写在 HTML 文件里,所以当 HTML 文件被访问成功,内容也就随即呈现(在不考虑额外的 CSS 、 图片加载速度的情况下,这种模式的内容呈现速度是最快的)。
如果读者有稍微了解过一些 SEO 知识,会知道除了网页的 TKD 三要素之外,网页的内容也影响收录的关键因素,传统的多页面应用,网页的内容都是直接位于 HTML 文件内,例如下面这个有很多内容的网页:
`,51),A=o("p",null,[e("右键查看该网页的源代码,可以看到网页内容对应的 HTML 结构也是包含在 "),o("code",null,".html"),e(" 文件里。")],-1),h=p(`TIP
网页的 TKD 三要素是指一个网页的三个关键信息,含义如下:
T ,指 Title ,网站的标题,即网页的 <title>网站的标题</title>
标签。
K ,指 Keywords ,网站的关键词,即网页的 <meta name="Keywords" content="关键词1,关键词2,关键词3" />
标签。
D ,指 Description ,网站的描述,即网页的 <meta name="description" content="网站的描述" />
标签。
这三个要素标签都位于 HTML 文件的 <head />
标签内。
由于传统的页面都是由服务端直出,所以可以使用 PHP 、 JSP 、 ASP 、 Python 等非前端语言或技术栈来编写页面模板,最终输出 HTML 页面到浏览器访问。
MPA 的缺点 说完 MPA 的优点,再来看看它的缺点,正因为有这些缺点的存在,才会催生出其他更优秀的开发模式出现。
正如它的访问流程,每一次页面访问都需要完整的经历一次渲染过程,哪怕从详情页 A 的 “相关阅读” 跳转到详情页 B ,这种网页结构一样,只有内容不同的两个页面,也需要经历这样的过程。
如果网页上的资源较多或者网速不好,这个过程就会有明显的卡顿或者布局错乱,影响用户体验。
传统的多页面模式缺少前端工程化的很多优秀技术栈支持,前端开发者在刀耕火种的开发过程中效率低下。如果是基于 PHP 等非前端语言开发,工作量通常更是压在一名开发者身上,无法做到前后端分离来利用好跨岗位协作。
TIP
此处列举的多页面应用问题均指传统开发模式下的多页面,之所以特地说明,是因为后文还会有新的技术栈来实现多页面应用,但实现原理和体验并不一样。
单页面应用 正因为传统的多页面应用存在了很多无法解决的开发问题和用户体验问题,催生了现代化的 SPA 单页面应用技术的诞生。
SPA 单页面应用是现代化的网站体验,与 MPA 相反,不论站点内有多少个页面,在 SPA 项目实际上只有一个 HTML 文件,也就是 index.html
首页文件。
它只有第一次访问的时候才需要经历一次完整的页面请求过程,之后的每个内部跳转或者数据更新操作,都是通过 AJAX 技术来获取需要呈现的内容并只更新指定的网页位置。
TIP
AJAX 技术( Asynchronous JavaScript and XML )是指在不离开页面的情况下,通过 JavaScript 发出 HTTP 请求,让网页通过增量更新的方式呈现给用户界面,而不需要刷新整个页面来重新加载,是一种 “无刷体验” 。
SPA 在页面跳转的时候,地址栏也会发生变化,主要有以下两种方式:
通过修改 Location:hash 修改 URL 的 Hash 值(也就是 #
号后面部分),例如从 https://example.com/#/foo
变成 https://example.com/#/bar
通过 History API 的 pushState 方法更新 URL ,例如从 https://example.com/foo
变成 https://example.com/bar
这两个方式的共同特点是更新地址栏 URL 的时候,均不会刷新页面,只是单纯的变更地址栏的访问地址,而网页的内容则通过 AJAX 更新,配合起来就形成了一种网页的 “前进 / 后退” 等行为效果。
TIP
Vue Router 默认提供了这两种 URL 改变方式的支持,分别是 createWebHashHistory
的 Hash 模式和 createWebHistory
对应的 History 模式,在 路由的使用 一章可以学习更多 Vue 路由的使用。
理解了实现原理之后,可以把 SPA 的请求过程简化为如下步骤:
bash # SPA 页面跳转过程
+
+从用户点击跳转开始:
+---> 浏览器通过 \` pushState \` 等方法更新 URL
+---> 请求接口数据(如果有涉及到前后端交互)
+---> 通过 JavaScript 处理数据,拼接 HTML 片段
+---> 把 HTML 片段渲染到指定位置,完成页面的 “刷新”
SPA 的优点 从上面的实现原理已经能总结出它的优势了:
只有一次完全请求的等待时间(首屏加载) 用户体验好,内部跳转的时候可以实现 “无刷切换” 因为不需要重新请求整个页面,所以切换页面的时候速度更快 因为没有脱离当前页面,所以 “页” 与 “页” 之间在切换过程中支持动画效果 脱离了页面跳页面的框架,让整个网站形成一个 Web App ,更接近原生 App 的访问体验 开发效率高,前后端分离,后端负责 API 接口,前端负责界面和联调,同步进行缩短工期 这也是为什么短短几年时间, SPA 的体验模式成为前端领域的主流。
SPA 的缺点 虽然 SPA 应用在使用过程中的用户体验非常好,但也有自身的缺点存在:
由于 SPA 应用的路由是由前端控制, SPA 在打开首页后,还要根据当前的路由再执行一次内容渲染,相对于 MPA 应用从服务端直出 HTML ,首屏渲染所花费的时间会更长。
由于 SPA 应用全程是由 JavaScript 控制内容的渲染,因此唯一的一个 HTML 页面 index.html
通常是一个空的页面,只有最基础的 HTML 结构,不仅无法设置每个路由页面的 TDK ,页面内容也无法呈现在 HTML 代码里,因此对搜索引擎来说,网站的内容再丰富,依然只是一个 “空壳” ,无法让搜索引擎进行内容爬取。
`,33),u=p('为了减少用户等待过程中的焦虑感,可以通过增加 Loading 过程,或者 Skeleton 骨架屏等优化方案,但其实也是治标不治本,因此为了结合 SPA 和 MPA 的优点,又进一步催生出了更多实用的技术方案以适配更多的业务场景,在后面的小节将逐一介绍。
CSR 与 SSR 在了解了 MPA 与 SPA 之后,先了解另外两个有相关联的名词: CSR 与 SSR ,同样的,这一对也是代表着相反的开发模式和用户体验,它们的全称和中文含义如下:
名词 全称 中文 CSR Client-Side Rendering 客户端渲染 SSR Server-Side Rendering 服务端渲染
正如它们的名称,这两者代表的是渲染网页过程中使用到的技术栈。
客户端渲染 在 MPA 多页面应用与 SPA 单页面应用 部分的介绍过的 SPA 单页面应用,正是基于 CSR 客户端渲染实现的(因此大部分情况下, CSR 等同于 SPA ,包括实现原理和优势),这是一种利用 AJAX 技术,把渲染工作从服务端转移到客户端完成,不仅客户端的用户体验更好,前后端分离的开发模式更加高效。
但随之而来的首屏加载较慢、不利于 SEO 优化等缺点,而 SPA 的这几个缺点,却是传统 MPA 多页面应用所具备的优势,但同样的, MPA 也有着自己开发成本高、用户体验差等问题。
既然原来的技术方案无法完美满足项目需求,因此在结合 MPA 的优点和 SPA 的优点之后,一种新的技术随之诞生,这就是 SSR 服务端渲染。
服务端渲染 和传统的 MPA 使用 PHP / JSP 等技术栈做服务端渲染不同,现代前端工程化里的 SSR 通常是指使用 Node.js 作为服务端技术栈。
TIP
在 工程化神器 Node.js 一节会介绍 Node ,以及它对前端工程化带来的重大变化,现代前端工程化发展离不开它的存在。
传统的服务端渲染通常由后端开发者一起维护前后端代码,需要写后端语言支持的模板、 JavaScript 代码维护成本也比较高;而 SSR 服务端渲染则是交给前端开发者来维护,利用 Node 提供的能力进行同构渲染,由于本身前后端都使用 JavaScript 编写,维护成本也大大的降低。
SSR 技术利用的同构渲染方案( Isomorphic Rendering ),指的是一套代码不仅可以在客户端运行,也可以在服务端运行,在一些合适的时机先由服务端完成渲染( Server-Side Rendering )再直出给客户端激活( Client-Side Hydration ),这种开发模式带来了:
更好的 SEO 支持,解决了 SPA 单页面应用的痛点 更快的首屏加载速度,保持了 MPA 多页面应用的优点 和 SPA 一样支持前后端分离,开发效率依然很高 有更好的客户端体验,当用户完全打开页面后,本地访问过程中也可以保持 SPA 单页面应用的体验 统一的心智模型,由于支持同构,因此没有额外的心智负担 那么,使用 Vue 开发项目时,应该如何实现 SSR 呢?
Vue 的 SSR 支持非常好, Vue 官方不仅提供了一个 Vue.js 服务器端渲染指南 介绍了基于 Vue 的 SSR 入门实践,还有基于 Vue 的 Nuxt.js 、 Quasar 框架帮助开发者更简单地落地 SSR 开发,构建工具 Vite 也有内置的 Vue SSR 支持。
Pre-Rendering 与 SSG 在介绍了 SSR 服务端渲染技术后,读者可能会想到一个问题,就是 SSR 的开发成本总归比较高,如果本身项目比较简单,例如一个静态博客,或者静态官网、落地页等内容不多,仅需要简单的 SEO 支持的项目需求,是否有更简便的方案呢?
以下两种方案正是用于满足这类需求的技术:
名词 全称 中文 Pre-Rendering Pre-Rendering 预渲染 SSG Static-Site Generation 静态站点生成
预渲染 预渲染也是一种可以让 SPA 单页面应用 解决 SEO 问题的技术手段。
预渲染的原理是在构建的时候启动无头浏览器( Headless Browser ),加载页面的路由并将访问结果按照路由的路径保存到静态 HTML 文件里,这样部署到服务端的页面,不再是一个空的 HTML 页面,而是有真实内容的存在,但由于只在构建时运行,因此用户每次访问的时候 HTML 里的内容不会产生变化,直到下一次构建。
TIP
无头浏览器( Headless Browser ),指没有 GUI 界面的浏览器,使用代码通过编程接口来控制浏览器的行为,常用于网络爬虫、自动化测试等场景,预渲染也使用它来完成页面的渲染,以获取渲染后的代码来填充 HTML 文件。
预渲染和 服务端渲染 最大的区别在于,预渲染只在构建的时候就完成了页面内容的输出(发生在用户请求前),因此构建后不论用户何时访问, HTML 文件里的内容都是构建的时候的那份内容,所以预渲染适合一些简单的、有一定的 SEO 要求但对内容更新频率没有太高要求、内容多为静态展示的页面。
例如企业用于宣传的官网页面、营销活动的推广落地页都非常适合使用预渲染技术,现代的构建工具都提供了预渲染的内置实现,例如这个教程: 用 Vite 更简单的解决 Vue3 项目的预渲染问题 ,就是通过 Vite 的内置功能来实现预渲染,最终也运用到了公司的业务上。
静态站点生成 SSG 静态站点生成是基于预渲染技术,通过开放简单的 API 和配置文件,就让开发者可以实现一个预渲染静态站点的技术方案。
它可以让开发者定制站点的个性化渲染方案,但更多情况下,通常是作为一些开箱即用的技术产品来简化开发过程中的繁琐步骤,这一类技术产品通常称之为静态站点生成器( Static-Site Generator ,也是简称 SSG )。
常见的 SSG 静态站点生成器有:基于 Vue 技术的 VuePress 和 VitePress ,自带了 Vue 组件的支持,还有基于 React 的 Docusaurus ,以及很多各有特色的生成器,例如 Jekyll 、 Hugo 等等。
如果有写技术文档或者博客等内容创作需求,使用静态站点生成器是一个非常方便的选择,通常这一类产品还有非常多的个性化主题可以使用。
ISR 与 DPR 在现代化的开发概念这一节,从 MPA 多页面应用到 SPA 单页面应用 ,再到 CSR 客户端渲染和 SSR 服务端渲染 ,以及 Pre-Rendering 预渲染与 SSG 静态站点生成 ,似乎已经把所有常见的开发场景覆盖完了。
那接下来要讲的 ISR 和 DPR 又是什么用途的技术方案呢?先看看它们的全称和中文含义:
名词 全称 中文 ISR Incremental Site Rendering 增量式的网站渲染 DPR Distributed Persistent Rendering 分布式的持续渲染
当网站的内容体量达到一定程度的时候,从头开始构建进行预渲染所花费的时间会非常久,而实际上并不是所有页面的内容都需要更新,这两项技术的推出是为了提升大型项目的渲染效率。
ISR 增量式的网站渲染,通过区分 “关键页面” 和 “非关键页面” 进行构建,优先预渲染 “关键页面” 以保证内容的最新和正确,同时缓存到 CDN ,而 “非关键页面” 则交给用户访问的时候再执行 CSR 客户端渲染,并触发异步的预渲染缓存到 CDN 。
这样做的好处是,大幅度的提升了每次构建的时间,但由于只保证部分 “关键页面” 的构建和内容正确,所以访问 “非关键页面” 的时候,有可能先看到旧的内容,再由 CSR 刷新为新的内容,会丢失一部分用户体验。
更多 ISR 技术细节可以阅读 Netlify 的开发者体验总监 Cassidy Williams 的一篇文章: Incremental Static Regeneration: Its Benefits and Its Flaws 。
DPR 分布式的持续渲染则是为了解决 ISR 方案下可能访问到旧内容的问题,这也是由 Cassidy Williams 发起的一个提案,详情可在 GitHub 查看:Distributed Persistent Rendering (DPR) 。
由于目前这两项技术还在发展初期,能够支持的框架和服务还比较少,在这里建议作为一种技术知识储备提前了解,在未来的某一天有业务需要的时候,也可以知道有这样的方案可以解决问题。
工程化不止于前端 在 现代化的开发概念 部分所讲述的都是关于网页开发的变化,当然,前端这个岗位本身就是从页面开发发展起来的,自然还是离不开网页这个老本行。
但随着前端工程化的发展,前端越来越不止于写前端,已经有很多前端工程师利用前端工程化带来的优势,不仅仅只是做一个 Web 前端,开始逐步发展为一个全栈工程师,在企业内部承担起了更多的岗位职责,包括作者笔者也是。
之所以能做这么多事情,得益于 Node.js 在前端开发带来的翻天覆地的变化,可以在保持原有的 JavaScript 和 TypeScript 基础上,几乎没有过多的学习成本就可以过度到其他端的开发。
在了解 Node.js 之前,先来看看现在的前端开发工程师除了写 Web 前端,还可以做到哪些岗位的工作。
服务端开发 在传统的认知里,如果一个前端工程师想自己搭建一个服务端项目,需要学习 Java 、 PHP 、 Go 等后端语言,还需要学习 Nginx 、 Apache 等 Web Server 程序的使用,并使用这些技术来开发并部署一个项目的服务端。
现在的前端工程师可以利用 Node.js ,单纯使用 JavaScript 或者 TypeScript 来开发一个基于 Node 的服务端项目。
Node 本身是一个 JavaScript 的运行时,还提供了 HTTP 模块 可以启动一个本地 HTTP 服务,如果把 Node 项目部署到服务器上,就可以运行一个可对外访问的公网服务。
但 Node 的原生服务端开发成本比较高,因此在 GitHub 开源社区也诞生了很多更方便的、开箱即用、功能全面的服务端框架,根据它们的特点,可以简单归类如下:
以 Express 、 Koa 、 Fastify 为代表的轻量级服务端框架,这一类框架的特点是 “短平快” ,对于服务端需求不高,只是跑一些小项目的话,开箱即用非常地方便,比如 Build 了一个 Vue 项目,然后提供一个读取静态目录的服务来访问它。
但是 “短平快” 框架带来了一些团队协作上的弊端,如果缺少一些架构设计的能力,很容易把一个服务端搭的很乱以至于难以维护,比如项目的目录结构、代码的分层设计等等,每个创建项目的人都有自己的想法和个人喜好,就很难做到统一管理。
因此在这些框架的基础上,又诞生了以 Nest (底层基于 Express ,可切换为 Fastify )、 Egg (基于 Koa )为代表的基于 MVC 架构的企业级服务端框架,这一类框架的特点是基于底层服务进行了更进一步的架构设计并实现了代码分层,还自带了很多开箱即用的 Building Blocks ,例如 TypeORM 、WebSockets 、Swagger 等等,同样也是开箱即用,对大型项目的开发更加友好。
TIP
当然, Node.js 所做的事情是解决服务端程序部分的工作,如果涉及到数据存储的需求,学习 MySQL 和 Redis 的技术知识还是必不可少的!
App 开发 常规的 Native App 原生开发需要配备两条技术线的支持:使用 Java / Kotlin 语言开发 Android 版本,使用 Objective-C / Swift 语言开发 iOS 版本,这对于创业团队或者个人开发者来说都是一个比较高的开发成本。
前端开发者在项目组里对 App 的作用通常是做一些活动页面、工具页面内嵌到 App 的 WebView 里,如果是在一些产品比较少的团队里,例如只有一个 App 产品,那么前端的存在感会比较低。
而 Hybrid App 的出现,使得前端开发者也可以使用 JavaScript / TypeScript 来编写混合 App ,只需要了解简单的打包知识,就可以参与到一个 App 的开发工作中。
开发 Hybrid App 的过程通常称为混合开发,最大的特色就是一套代码可以运行到多个平台,这是因为整个 App 只有一个基座,里面的 App 页面都是使用 UI WebView 来渲染的 Web 界面,因此混合开发的开发成本相对于原生开发是非常低的,通常只需要一个人 / 一个小团队就可以输出双平台的 App ,并且整个 App 的开发周期也会更短。
在用户体验方面, Hybrid App 相对于 Native App ,一样可以做到:
双平台的体验一致性 支持热更新,无需用户重新下载整个 App 内置的 WebView 在交互体验上也可以做到和系统交互,比如读取 / 存储照片、通讯录,获取定位等等 支持 App Push 系统通知推送 还有很多 Native App 具备的功能 基本上 Native App 的常见功能,在 Hybrid App 都能满足。
而且大部分情况下,在构建 Hybrid App 的时候还可以顺带输出一个 Web App 版本,也就是让这个 App 在被用户下载前,也有一模一样的网页版可以体验,这对于吸引新用户是非常有用的。
在混合开发的过程中,通常是由前端开发者来负责 App 项目从 “开发” 到 “打包” 再到 “发版” 的整个流程,在开发的过程中是使用常见的前端技术栈,例如目前主流的有基于 Vue 的 uni-app 、基于 React 的 React Native 等等,这些 Hybrid 框架都具备了 “学习成本低、开发成本低、一套代码编译多个平台” 的特点。
在 App 开发完毕后,使用 Hybrid 框架提供的 CLI 工具编译出 App 资源包,再根据框架提供的原生基座打包教程去完成 Android / iOS 的安装包构建,这个环节会涉及到原生开发的知识,例如 Android 包的构建会使用到 Android Studio ,但整个过程使用到原生开发的环节非常少,几乎没有太高的学习门槛。
桌面程序开发 放在以前要开发一个 Windows 桌面程序,需要用上 QT / WPF / WinForm 等技术栈,还要学习 C++ / C# 之类的语言,对于只想在业余写几个小工具的开发者来说,上手难度和学习成本都很高,但在前端工程化的时代里,使用 JavaScript 或 TypeScript 也可以满足程序开发的需要。
这得益于 Electron / Tauri 等技术栈的出现,其中 Electron 的成熟度最高、生态最完善、最被广泛使用,除了可以构建 Windows 平台支持的 .exe
文件之外,对 macOS 和 Linux 平台也提供了对应的文件构建支持。
广大前端开发者每天都在使用的 Visual Studio Code 以及知名的 HTTP 网络测试工具 Postman 都是使用 Electron 开发的。
',71),g=p('笔者也通过 Electron 构建了多个给公司内部使用的界面化工具客户端,这一类技术栈对于前端开发者来说,真的非常方便!在这里以 Electron 为例,简单讲解下它的工作原理,以了解为什么程序开发可以如此简单。
Electron 的底层是基于 Chromium 和 Node.js ,它提供了两个进程供开发者使用:
主进程:它是整个应用的入口点,主进程运行在 Node 环境中,可以使用所有的 Node API ,程序也因此具备了和系统进行交互的能力,例如文件的读写操作。
渲染进程:负责与用户交互的 GUI 界面,基于 Chromium 运行,所以开发者得以使用 HTML / CSS / JavaScript 像编写网页一样来编写程序的 GUI 界面。
一个程序应用只会有一个主进程,而渲染进程则可以根据实际需求创建多个,渲染进程如果需要和系统交互,则必须与主进程通信,借助主进程的能力来实现。
在构建的时候, Electron 会把 Node 和 Chromium 一起打包为一个诸如 .exe
这样的安装文件(或者是包含了两者的免安装版本),这样用户不需要 Node 环境也可以运行桌面程序。
应用脚本开发 在 桌面程序开发 部分讲的是构建一种拥有可视化 GUI 界面的程序,但有时候并不需要复杂的 GUI ,可能只想提供一个双击运行的脚本类程序给用户,现在的前端工程化也支持使用 JavaScript 构建一个无界面的应用脚本。
假如某一天公司的运营小姐姐希望能做一个自动化的脚本减轻她们的机械操作,或者是自己工作过程中发现一些日常工作可以交付给脚本解决的情况,就可以使用这种方式来输出一个脚本程序,使用的时候双击运行非常方便。
笔者之前为了让团队的工程师减少写日报的心智负担,也是使用了这个方式编写了一个 git-commit-analytics 工具,部门里的工程师可以通过规范化 commit 来生成每天的工作日报,每天双击一下就可以生成一份报告,很受团队的喜欢。
',9),b=p(`在这里推荐一个工具 Pkg ,它可以把 Node 项目打包为一个可执行文件,支持 Windows 、 macOS 、 Linux 等多个平台,它的打包机制和 Electron 打包的思路类似,也是通过把 Node 一起打包,让用户可以在不安装 Node 环境的情况下也可以直接运行脚本程序。
实践工程化的流程 基于 Vue 3 的项目,最主流的工程化组合拳有以下两种:
常用方案 Runtime 构建工具 前端框架 方案一 Node Webpack Vue 方案二 Node Vite Vue
方案一是比较传统并且过去项目使用最多的方案组合,但从 2021 年初随着 Vite 2.0 的发布,伴随着更快的开发体验和日渐丰富的社区生态,新项目很多都开始迁移到方案二,因此本书秉着面向当下与未来的原则,会侧重 Vite 的使用来开展讲解,包括一些 demo 的创建等等。
当技术成熟的时候,还可以选择更喜欢的方案自行组合,例如用 Deno 来代替 Node ,但前期还是按照主流的方案来进入工程化的学习。
下面的内容将根据 Vue 3 的工程化开发,逐一讲解涉及到常用的工具,了解它们的用途和用法。
工程化神器 Node.js 只要在近几年有接触过前端开发,哪怕没有实际使用过,也应该有听说过 Node.js ,那么它是一个什么样的存在?
什么是 Node.js Node.js (简称 Node ) 是一个基于 Chrome V8 引擎构建的 JS 运行时( JavaScript Runtime )。
它让 JavaScript 代码不再局限于网页上,还可以跑在客户端、服务端等场景,极大的推动了前端开发的发展,现代的前端开发几乎都离不开 Node 。
什么是 Runtime Runtime ,可以叫它 “运行时” 或者 “运行时环境” ,这个概念是指,项目的代码在哪里运行,哪里就是运行时。
传统的 JavaScript 只能跑在浏览器上,每个浏览器都为 JS 提供了一个运行时环境,可以简单地把浏览器当成一个 Runtime ,明白了这一点,相信就能明白什么是 Node 。
Node 就是一个让 JS 可以脱离浏览器运行的环境,当然,这里并不是说 Node 就是浏览器。
Node 和浏览器的区别 虽然 Node 也是基于 Chrome V8 引擎构建,但它并不是一个浏览器,它提供了一个完全不一样的运行时环境,没有 Window 、没有 Document 、没有 DOM 、没有 Web API ,没有 UI 界面…
但它提供了很多浏览器做不到的能力,比如和操作系统的交互,例如 “文件读写” 这样的操作在浏览器有诸多的限制,而在 Node 则轻轻松松。
对于前端开发者来说, Node 的巨大优势在于,使用一种语言就可以编写所有东西(前端和后端),不再花费很多精力去学习各种各样的开发语言。
哪怕仅仅只做 Web 开发,也不再需要顾虑新的语言特性在浏览器上的兼容性( e.g. ES6 、 ES7 、 ES8 、 ES9 …), Node 配合构建工具,以及诸如 Babel 这样的代码编译器,可以帮转换为浏览器兼容性最高的 ES5 。
当然还有很多工程化方面的好处,总之一句话,使用 Node 的开发体验会非常好。
在 工程化的入门准备 一章中,会对 Node 开发做进一步的讲解,下面先继续顺着 Node 的工具链,了解与日常开发息息相关的前端构建工具。
工程化的构建工具 在前端开发领域,构建工具已经成为现在必不可少的开发工具了,很多刚接触前端工程化的开发者可能会有疑惑,为什么以前的前端页面直接编写代码就可以在浏览器访问,现在却还要进行构建编译,是否 “多此一举” ?
要消除这些困惑,就需要了解一下为什么要使用构建工具,知道构建工具在开发上能够给带来什么好处。
为什么要使用构建工具 目前已经有很多流行的构建工具,例如: Grunt 、 Gulp 、 Webpack 、 Snowpack 、 Parcel 、 Rollup 、 Vite … 每一个工具都有自己的特色。
如上面列举的构建工具,虽然具体到某一个工具的时候,是 “一个” 工具,但实际上可以理解为是 “一套” 工具链、工具集,构建工具通常集 “语言转换 / 编译” 、 “资源解析” 、 “代码分析” 、 “错误检查” 、 “任务队列” 等非常多的功能于一身。
构建工具可以帮解决很多问题,先看看最基础的一个功能支持: “语言转换 / 编译” 。
且不说构建工具让可以自由自在的在项目里使用 TypeScript 这些新兴的语言,单纯看历史悠久的 JavaScript ,从 2015 年开始,每年也都会有新的版本发布(例如 ES6 对应 ES2015 、 ES7 对应 ES2016 、 ES8 对应 ES2017 等等)。
虽然新版本的 JS API 更便捷更好用,但浏览器可能还没有完全支持,这种情况下可以通过构建工具去转换成兼容度更高的低版本 JS 代码。
举个很常用到的例子,现在判断一个数组是否包含某个值,通常会这么写:
js // 声明一个数组
+const arr = [ ' foo ' , ' bar ' , ' baz ' ]
+
+// 当数组包含 foo 这个值时,处理一些逻辑
+if (arr . includes ( ' foo ' )) {
+ // do something…
+}
通过 Array.prototype.includes()
这个实例方法返回的布尔值,判断数组是否包含目标值,而这个方法是从 ES6 开始支持的,对于不支持 ES6 的古董浏览器,只能使用其他更早期的方法代替( e.g. indexOf
),或者手动引入它的 Polyfill 来保证这个方法可用。
TIP
Polyfill 是在浏览器不支持的情况下实现某个功能的代码,可以在概念发明者 Remy Sharp 的博文里了解到它的由来,是一个挺有意思的命名。
点击阅读: What is a Polyfill?
以下是摘选自 MDN 网站上关于 Array.prototype.includes() 的 Polyfill 实现:
js // https://tc39.github.io/ecma262/#sec-array.prototype.includes
+if ( ! Array . prototype . includes) {
+ Object . defineProperty ( Array . prototype , ' includes ' , {
+ value : function ( valueToFind , fromIndex ) {
+ if ( this == null ) {
+ throw new TypeError ( ' "this" is null or not defined ' )
+ }
+
+ // 1. Let O be ? ToObject(this value).
+ var o = Object ( this )
+
+ // 2. Let len be ? ToLength(? Get(O, "length")).
+ var len = o . length >>> 0
+
+ // 3. If len is 0, return false.
+ if ( len === 0 ) {
+ return false
+ }
+
+ // 4. Let n be ? ToInteger(fromIndex).
+ // (If fromIndex is undefined, this step produces the value 0.)
+ var n = fromIndex | 0
+
+ // 5. If n ≥ 0, then
+ // a. Let k be n.
+ // 6. Else n < 0,
+ // a. Let k be len + n.
+ // b. If k < 0, let k be 0.
+ var k = Math . max ( n >= 0 ? n : len - Math . abs ( n ) , 0 )
+
+ function sameValueZero ( x , y ) {
+ return (
+ x === y ||
+ ( typeof x === ' number ' &&
+ typeof y === ' number ' &&
+ isNaN ( x ) &&
+ isNaN ( y ))
+ )
+ }
+
+ // 7. Repeat, while k < len
+ while ( k < len ) {
+ // a. Let elementK be the result of ? Get(O, ! ToString(k)).
+ // b. If SameValueZero(valueToFind, elementK) is true, return true.
+ if ( sameValueZero ( o [ k ] , valueToFind )) {
+ return true
+ }
+ // c. Increase k by 1.
+ k ++
+ }
+
+ // 8. Return false
+ return false
+ },
+ } )
+}
由于 JavaScript 允许更改 prototype ,所以 Polyfill 的原理就是先检查浏览器是否支持某个方法,当浏览器不支持的时候,会借助已经被广泛支持的方法来实现相同的功能,达到在旧浏览器上也可以使用新方法的目的。
下面是一个简单的 includes
方法实现,也借用浏览器支持的 indexOf
方法,让不支持 includes
的浏览器也可以使用 includes
:
js // 借助 indexOf 来实现一个简单的 includes
+if ( ! Array . prototype . includes) {
+ Array . prototype . includes = function ( v ) {
+ return this. indexOf ( v ) > - 1
+ }
+}
WARNING
请注意,上面这个实现方案很粗糙,没有 Polyfill 的方案考虑的足够周到,只是在这里做一个简单的实现演示。
Polyfill 会考虑到多种异常情况,最大幅度保证浏览器的兼容支持,当然一些复杂的方法实现起来会比较臃肿,全靠人工维护 Polyfill 很不现实。
而且实际的项目里,要用到的 JavaScript 原生方法非常多,不可能手动去维护每一个方法的兼容性,所以这部分工作,通常会让构建工具来自动化完成,常见的方案就有 Babel 。
除了 “语言转换 / 编译” 这个好处之外,在实际的开发中,构建工具可以更好地提高开发效率、提供自动化的代码检查、规避上线后的生产风险,例如:
项目好多代码可以复用,可以直接抽离成 模块 、 组件 ,交给构建工具去合并打包 TypeScript 的类型系统和代码检查真好用,也可以放心写,交给构建工具去编译CSS 写起来很慢,可以使用 Sass 、 Less 等 CSS 预处理器 ,利用它们的变量支持、混合继承等功能提高开发效率,最终交给构建工具去编译回 CSS 代码 海量的 npm 包 开箱即用,剩下的工作交给构建工具去按需抽离与合并 项目上线前代码要混淆,人工处理太费劲,交给构建工具自动化处理 还有很多列举不完的其他场景… 下面基于接下来要学习的 Vue3 技术栈,介绍两个流行且强相关的构建工具: Webpack 和 Vite 。
Webpack Webpack 是一个老牌的构建工具,前些年可以说几乎所有的项目都是基于 Webpack 构建的,生态最庞大,各种各样的插件最全面,对旧版本的浏览器支持程度也最全面。
点击访问:Webpack 官网
在升级与配置一章里的 使用 @vue/cli 创建项目 会指导如何使用 Vue CLI 创建一个基于 Webpack 的 Vue 项目。
Vite Vite 的作者也是熟悉的 Vue 作者尤雨溪,它是一个基于 ESM 实现的构建工具,主打更轻、更快的开发体验,主要面向现代浏览器,于 2021 年推出 2.x 版本之后,进入了一个飞速发展的时代,目前市场上的 npm 包基本都对 Vite 做了支持,用来做业务已经没有问题了。
毫秒级的开发服务启动和热重载,对 TypeScript 、 CSS 预处理器等常用开发工具都提供了开箱即用的支持,也兼容海量的 npm 包,如果是先用 Webpack 再用的 Vite ,会很快就喜欢上它!
点击访问:Vite 官网
在升级与配置一章里的 使用 Vite 创建项目 会指导如何使用流行脚手架创建一个基于 Vite 的 Vue 项目。
两者的区别 在开发流程上, Webpack 会先打包,再启动开发服务器,访问开发服务器时,会把打包好的结果直接给过去,下面是 Webpack 使用的 bundler 机制的工作流程。
`,58),m=o("p",null,"Vite 是基于浏览器原生的 ES Module ,所以不需要预先打包,而是直接启动开发服务器,请求到对应的模块的时候再进行编译,下面是 Vite 使用的 ESM 机制的工作流程。",-1),f=p('所以当项目体积越大的时候,在开发启动速度上, Vite 和 Webpack 的差距会越来越大。
可以点击 Vite 官网的这篇文章: 为什么选 Vite 了解更多的技术细节。
构建方面,为了更好的加载体验,以及 Tree Shaking 按需打包 、懒加载和 Chunk 分割利于缓存,两者都需要进行打包;但由于 Vite 是面向现代浏览器,所以如果项目有兼容低版本浏览器的需求的话,建议还是用 Webpack 来打包,否则, Vite 是目前的更优解。
开发环境和生产环境 在使用构建工具的时候,需要了解一下 “环境” 的概念,对构建工具而言,会有 “开发环境( development )” 和 “生产环境( production )” 之分。
TIP
需要注意的是,这和业务上的 “测试 -> 预发 -> 生产” 那几个环境的概念是不一样的,业务上线流程的这几个环境,对于项目来说,都属于 “生产环境” ,因为需要打包部署。
开发环境 前面在编写 Hello TypeScript 这个 demo 的时候,使用了 npm run dev:ts
这样的命令来测试 TypeScript 代码的可运行性,可以把这个阶段认为是一个 “测试环境” ,这个时候代码不管怎么写,它都是 TypeScript 代码,不是最终要编译出来的 JavaScript 。
如果基于 Webpack 或者 Vite 这样的构建工具,测试环境提供了更多的功能,例如:
可以使用 TypeScript 、 CSS 预处理器之类的需要编译的语言提高开发效率 提供了热重载( Hot Module Replacement , 简称 HMR ),当修改了代码之后,无需重新运行或者刷新页面,构建工具会检测的修改自动帮更新 代码不会压缩,并有 Source Mapping 源码映射,方便 BUG 调试 默认提供局域网服务,无需自己做本地部署 更多 … 生产环境 在 Hello TypeScript demo 最后配置的一个 npm run build
命令,将 TypeScript 代码编译成了 JavaScript ,这个时候 dist 文件夹下的代码文件就处于 “生产环境” 了,因为之后不论源代码怎么修改,都不会直接影响到它们,直到再次执行 build 编译。
可以看出生产环境和开发环境最大的区别就是稳定!除非再次打包发布,否则不会影响到已部署的代码。
代码会编译为浏览器最兼容的版本,一些不兼容的新语法会进行 Polyfill 稳定,除非重新发布,否则不会影响到已部署的代码 打包的时候代码会进行压缩混淆,缩小项目的体积,也降低源码被直接曝光的风险 环境判断 ',15),E=p('在 Vite ,还可以通过判断 import.meta.env.DEV
为 true
时是开发环境,判断 import.meta.env.PROD
为 true
时是生产环境(这两个值永远相反)。
有关环境变量的问题可以查阅以下文档:
',3),x=JSON.parse('{"title":"了解前端工程化","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"engineering.md","filePath":"engineering.md"}'),S={name:"engineering.md"},V=Object.assign(S,{setup(k){const c="process.env.NODE_ENV";return(v,q)=>{const l=t("ImgWrap"),a=t("ClientOnly"),r=t("GitalkComment");return y(),D("div",null,[i,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/job-details.png",dark:"/assets/img/job-details-dark.png",alt:"知名企业对 1-3 年经验的前端工程师招聘要求"})]),_:1}),C,s(a,null,{default:n(()=>[s(l,{src:"/logo.png",alt:"Vue.js Logo",maxWidth:240})]),_:1}),d,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/seo-page.jpg",dark:"/assets/img/seo-page-dark.jpg",alt:"网页呈现的内容"})]),_:1}),A,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/seo-page-code.jpg",dark:"/assets/img/seo-page-code-dark.jpg",alt:"网页内容对应的 HTML 源码"})]),_:1}),h,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/seo-spa-page-code.jpg",dark:"/assets/img/seo-spa-page-code-dark.jpg",alt:"单页面应用的网页内容只有一个空的 HTML 结构"})]),_:1}),u,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/screenshot-vscode.jpg",dark:"/assets/img/screenshot-vscode-dark.jpg",alt:"Visual Studio Code 界面截图"})]),_:1}),s(a,null,{default:n(()=>[s(l,{src:"/assets/img/screenshot-postman.jpg",dark:"/assets/img/screenshot-postman-dark.jpg",alt:"Postman 界面截图"})]),_:1}),g,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/screenshot-pkg.jpg",dark:"/assets/img/screenshot-pkg-dark.jpg",alt:"使用 Pkg 构建后的程序运行截图"})]),_:1}),b,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/bundler.png",alt:"Webpack 的工作原理(摘自 Vite 官网)"})]),_:1}),m,s(a,null,{default:n(()=>[s(l,{src:"/assets/img/esm.png",alt:"Vite 的工作原理(摘自 Vite 官网)"})]),_:1}),f,o("p",null,[e("在 Webpack ,可以使用 "),o("code",null,F(c)),e(" 来区分开发环境( development )还是生产环境( production ),它会返回当前所处环境的名称。")]),E,s(a,null,{default:n(()=>[s(r,{issueId:194})]),_:1})])}}});export{x as __pageData,V as default};
diff --git a/assets/guide.md.82bb6597.js b/assets/guide.md.82bb6597.js
new file mode 100644
index 00000000..94be6db3
--- /dev/null
+++ b/assets/guide.md.82bb6597.js
@@ -0,0 +1,650 @@
+/**
+ * name: learning-vue3
+ * version: v2.0.0
+ * description: A starting learning tutorial on Vue 3.0 + TypeScript, suitable for complete Vue novices and Vue 2.0 veterans, incorporating some of my own practical experience on the basis of official documents.
+ * author: chengpeiquan
+ * homepage: https://vue3.chengpeiquan.com
+ */
+import{_ as t,v as c,b as r,t as s,O as n,R as l,M as p}from"./chunks/framework.0d8bea05.js";const q=JSON.parse('{"title":"工程化的前期准备","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"guide.md","filePath":"guide.md"}'),y={name:"guide.md"},D=l(`工程化的前期准备 对于刚刚迈入前端工程化、或者还没有接触过前端工程化的开发者,从传统的用 HTML + CSS + JS 手写页面的认知阶段走到工程化的世界,会面对翻天覆地的变化,需要先学习一些入门准备知识。
这一章会介绍一些前置的知识点科普,方便开始学习 Vue3 的时候,不会对一些基本的认知和操作存在太多疑惑。
TIP
本指南需要具备一定的 HTML 、 CSS 和 JavaScript 基础,如果完全不懂,请先对这三个知识点进行一些入门的学习。
命令行工具 在前端工程化开发过程中,已经离不开各种命令行操作,例如:管理项目依赖、本地服务启动、打包构建,还有拉取代码 / 提交代码这些 Git 操作等等。
命令行界面( Command-line Interface ,缩写 CLI ),是一种通过命令行来实现人机交互的工具,需要提前准备好命令行界面工具。
如果有所留意,会发现很多工具都可以实现命令行操作,比如:命令行界面( CLI )、终端( Terminal )、 Shell 、控制台( Console )等等。
从完整功能看,它们之间确实有许多区别,不过对于前端开发者来说,日常的命令行交互需要用到的功能不会特别多,所以后面会统一一些名词,减少理解上的偏差。
交互行为 统一代替名词 代替名词解释 输入 命令行 需要输入命令的时候,会统一用 “命令行” 来指代。 输出 控制台 鉴于前端开发者更多接触的是浏览器的 Console 控制台, 所以也是会用 “控制台” 来指代。
Windows 在 Windows 平台,可以使用自带的 CMD 或者 Windows PowerShell 工具。
但为了更好的开发体验,推荐使用以下工具(需要下载安装),可以根据自己的喜好选择其一:
笔者在 Windows 台式机上是使用 Windows Terminal 比较多,在此之前是用 CMDer ,两者的设计和体验都非常优秀,当然,还有颜值。
macOS 如果使用的是 Mac 系统,可以直接使用系统自带的 “终端” 工具,笔者在 MacBook 上是使用自带的终端进行开发。
TIP
其实只要能正常使用命令行,对于前端工程师来说就可以满足日常需求,但选择更喜欢的工具,可以让自己的开发过程更为身心愉悦!
安装 Node.js 环境 安装好命令行工具之后,来安装 Node 的开发环境。
下载和安装 Node 在 Node.js 官网提供了安装包的下载,不论是使用 Windows 系统还是 MacOS 系统, Node 都提供了对应的安装包,直接下载安装包并运行即可安装到的电脑里,就可以用来开发的项目了。
点击访问:Node.js 官网下载
安装后,打开的 命令行工具 ,输入以下命令即可查看是否安装成功:
如果已成功安装,会在控制台输出当前的 Node 版本号。
版本之间的区别 可以看到官网标注了 LTS 和 Current 两个系列,并且对应了不同的版本号。
Current 版本 Current 是最新发布版本,或者叫 “尝鲜版” ,可以在这个系列体验到最新的功能,但也可能会有一些意想不到的问题和兼容性要处理。
每六个月会发布一次 Current 大版本,新的偶数版本( e.g. v16.x.x )会在每年的 4 月份发布,奇数版本( e.g. v17.x.x )会在每年的 10 月份发布。
也就是说,所有版本都会有 Current 版本阶段,这个阶段会持续 6 个月的时间,期间会被活跃的维护和变更,在发布满 6 个月后,奇偶数版本会有不同的结果:
大版本号是奇数的,将变为不支持状态,不会进入 LTS 版本。 大版本号是偶数的,会按照发布节点进入 LTS ,并且作为活跃状态投入使用。 TIP
除非是狂热的 Node 开发探索者,否则不应该选择 Current 系列(特别是在生产环境),应该选择未被 EOL 的 LTS 系列作为的项目运行环境,详见下方的 LTS 版本 说明。
LTS 版本 LTS ,全称 Long Time Support ,长期维护版本,这个系列代表着稳定,建议首次下载以及后续的每次升级都选择 LTS 版本,减少开发过程中的未知问题出现。
每个 LTS 版本的大版本号都是偶数,并且会有 3 个阶段的生命周期:
生命周期 含义 说明 Active 活跃阶段 每个从 Current 进入 LTS 的偶数版本,都会有 18 个月的时间被积极维护和升级。 Maintenance 维护阶段 活跃阶段达到 18 个月后,会进入为期 12 个月的维护阶段,期间只会进行错误修复和安全补丁。 End of Life 结束阶段 简称 EOL ,在维护阶段达到期限之后,该版本进入 EOL 阶段,将不再维护,也就是说,每个 LTS 版本最长会有 30 个月的维护时间,之后将不再进行维护。
是否需要经常更新版本 不论是 LTS 还是 Current ,每个系列下面都还有不同的大版本和小版本,是不是每次都必须及时更新到最新版呢?
当然不是,完全可以依照的项目技术栈依赖的最低 Node 版本去决定是否需要升级,不过如果条件允许,还是建议至少要把大版本升级到最新的 LTS 版本。
TIP
关于 Node.js 的版本发布时间表可以在官方 GitHub 的 Release 仓库 查看。
基础的 Node 项目 在安装和配置完 Node.js 之后,接下来了解 Node 项目的一些基础组成,这有助于开启前端工程化开发大门。
TIP
当前文档所演示的 hello-node 项目已托管至 learning-vue3/hello-node 仓库,可使用 Git 克隆命令拉取至本地:
bash # 从 GitHub 克隆
+git clone https://github.com/learning-vue3/hello-node.git
+
+# 如果 GitHub 访问失败,可以从 Gitee 克隆
+git clone https://gitee.com/learning-vue3/hello-node.git
成品项目可作为学习过程中的代码参考,但更建议按照教程的讲解步骤,从零开始亲手搭建一个新项目并完成 node 开发的体验,可以更有效的提升学习效果。
初始化一个项目 如果想让一个项目成为 Node 项目,只需要在命令行 cd
到项目所在的目录,执行初始化命令:
之后命令行会输出一些提示,以及一些问题,可以根据的实际情况填写项目信息,例如:
bash package name: (demo) hello-node
以上面这个问题为例:
冒号左边的 package name
是问题的题干,会询问要输入什么内容。
冒号右边的括号内容 (demo)
是 Node 为推荐的答案(不一定会出现这个推荐值),如果觉得 OK ,可以直接按回车确认,进入下一道题。
冒号右边的 hello-node
是输入的答案(如果选择了推荐的答案,则这里为空),这个答案会写入到项目信息文件里。
当回答完所有问题之后,会把填写的信息输出到控制台,确认无误后,回车完成初始化的工作。
bash {
+ "name" : " hello-node " ,
+ "version" : " 1.0.0 " ,
+ "description" : " A demo about Node.js. " ,
+ "main" : " index.js " ,
+ "scripts" : {
+ "test" : " echo \\" Error: no test specified \\" && exit 1 "
+ } ,
+ "author" : " chengpeiquan " ,
+ "license" : " MIT "
+}
+
+
+Is this OK? (yes)
如果觉得问题太多,太繁琐了,可以直接加上 -y
参数,这样会以 Node 推荐的答案帮快速生成项目信息。
了解 package.json 在完成 项目的初始化 之后,会发现在项目的根目录下出现了一个名为 package.json
的 JSON 文件。
这是 Node 项目的清单,里面记录了这个项目的基础信息、依赖信息、开发过程的脚本行为、发布相关的信息等等,未来将在很多项目里看到它的身影。
TIP
它必须是 JSON 文件,不可以是存储了 JavaScript 对象字面量的 JS 文件。
如果是按照上面初始化一节的操作得到的这个文件,打开它之后,会发现里面存储了在初始化过程中,根据问题确认下来的那些答案,例如:
json {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " description " : " A demo about Node.js. " ,
+ " main " : " index.js " ,
+ " scripts " : {
+ " test " : " echo \\" Error: no test specified \\" && exit 1 "
+ },
+ " author " : " chengpeiquan " ,
+ " license " : " MIT "
+}
package.json 的字段并非全部必填,唯一的要求就是,必须是一个 JSON 文件,所以也可以仅仅写入以下内容:
但在实际的项目中,往往需要填写更完善的项目信息,除了手动维护这些信息之外,在安装 npm 包等操作时, Node 也会帮写入数据到这个文件里,来了解一些常用字段的含义:
字段名 含义 name 项目名称,如果打算发布成 npm 包,它将作为包的名称 version 项目版本号,如果打算发布成 npm 包,这个字段是必须的,遵循 语义化版本号 的要求 description 项目的描述 keywords 关键词,用于在 npm 网站上进行搜索 homepage 项目的官网 URL main 项目的入口文件 scripts 指定运行脚本的命令缩写,常见的如 npm run build
等命令就在这里配置,详见 脚本命令的配置 author 作者信息 license 许可证信息,可以选择适当的许可证进行开源 dependencies 记录当前项目的生产依赖,安装 npm 包时会自动生成,详见:依赖包和插件 devDependencies 记录当前项目的开发依赖,安装 npm 包时会自动生成,详见:依赖包和插件 type 配置 Node 对 CJS 和 ESM 的支持
其中最后的 type 字段是涉及到模块规范的支持,它有两个可选值: commonjs
和 module
,其默认值为 commonjs
。
当不设置或者设置为 commonjs
时,扩展名为 .js
和 .cjs
的文件都是 CommonJS 规范的模块,如果要使用 ES Module 规范,需要使用 .mjs
扩展名 当设置为 module
时,扩展名为 .js
和 .mjs
的文件都是 ES Module 规范的模块,如果要使用 CommonJS 规范,需要使用 .cjs
扩展名 关于模块规范可以在 学习模块化设计 一节了解更多。
关于 package.json 的完整的选项可以在 npm Docs 上查阅。
项目名称规则 如果打算发布成 npm 包,它将作为包的名称,可以是普通包名,也可以是范围包的包名。
类型 释义 例子 范围包 具备 @scope/project-name
格式,一般有一系列相关的开发依赖之间会以相同的 scope 进行命名 如 @vue/cli
、 @vue/cli-service
就是一系列相关的范围包 普通包 其他命名都属于普通包 如 vue
、 vue-router
包名有一定的书写规则:
名称必须保持在 1 ~ 214 个字符之间(包括范围包的 @scope/
部分) 只允许使用小写字母、下划线、短横线、数字、小数点(并且只有范围包可以以点或下划线开头) 包名最终成为 URL 、命令行参数或者文件夹名称的一部分,所以名称不能包含任何非 URL 安全字符 TIP
了解这一点有助于在后续工作中,在需要查找技术栈相关包的时候,可以知道如何在 npmjs 上找到它们。
如果打算发布 npm 包,可以通过 npm view <package-name>
命令查询包名是否已存在,如果存在就会返回该包的相关信息。
比如查询 vue
这个包名,会返回它的版本号、许可证、描述等信息:
bash npm view vue
+
+vue@3.2.33 | MIT | deps: 5 | versions: 372
+The progressive JavaScript framework for building modern web UI.
+https://github.com/vuejs/core/tree/main/packages/vue#readme
+
+keywords: vue
+
+# 后面太多信息这里就省略...
如果查询一个不存在的包名,则会返回 404 信息:
bash npm view vue123456
+npm ERR! code E404
+npm ERR! 404 Not Found - GET https://registry.npmjs.org/vue123456 - Not found
+npm ERR! 404
+npm ERR! 404 ' vue123456@latest ' is not in this registry.
+npm ERR! 404 You should bug the author to publish it (or use the name yourself! )
+npm ERR! 404
+npm ERR! 404 Note that you can also install from a
+npm ERR! 404 tarball, folder, http url, or git url.
+
+# 后面太多信息这里就省略...
语义化版本号管理 Node 项目遵循 语义化版本号 的规则,例如 1.0.0
、 1.0.1
、 1.1.0
这样的版本号,本教材的主角 Vue 也是遵循了语义化版本号的发布规则。
建议开发者在入门前端工程化的时候就应该熟悉这套规则,后续的项目开发中,会使用到很多外部依赖,它们也是使用版本号控制来管理代码的发布,每个版本之间可能会有一些兼容性问题,如果不了解版本号的通用规则,很容易在开发中带来困扰。
TIP
现在有很多 CI/CD 流水线作业具备了根据 Git 的 Commit 记录来自动升级版本号,它们也是遵循了语义化版本号规则,版本号的语义化在前端工程里有重大的意义。
基本格式与升级规则 版本号的格式为: Major.Minor.Patch
(简称 X.Y.Z
),它们的含义和升级规则如下:
英文 中文 含义 Major 主版本号 当项目作了大量的变更,与旧版本存在一定的不兼容问题 Minor 次版本号 做了向下兼容的功能改动或者少量功能更新 Patch 修订号 修复上一个版本的少量 BUG
一般情况下,三者均为正整数,并且从 0
开始,遵循这三条注意事项:
当主版本号升级时,次版本号和修订号归零 当次版本号升级时,修订号归零,主版本号保持不变 当修订号升级时,主版本号和次版本号保持不变 下面以一些常见的例子帮助快速理解版本号的升级规则:
如果不打算发布,可以默认为 0.0.0
,代表它并不是一个进入发布状态的包 在正式发布之前,可以将其设置为 0.1.0
发布第一个测试版本,自此,代表已进入发布状态,但还处于初期开发阶段,这个阶段可能经常改变 API ,但不需要频繁地更新主版本号 在 0.1.0
发布后,修复了 BUG ,下一个版本号将设置为 0.1.1
,即更新了一个修订号 在 0.1.1
发布后,有新的功能发布,下一个版本号可以升级为 0.2.0
,即更新了一个次版本号 当觉得这个项目已经功能稳定、没有什么 BUG 了,决定正式发布并给用户使用时,那么就可以进入 1.0.0
正式版了 版本标识符 以上是一些常规的版本号升级规则,也可以通过添加 “标识符” 来修饰的版本更新:
格式为: Major.Minor.Patch-Identifier.1
,其中的 Identifier
代表 “标识符” ,它和版本号之间使用 -
短横线来连接,后面的 .1
代表当前标识符的第几个版本,每发布一次,这个数字 +1 。
标识符 含义 alpha 内部版本,代表当前可能有很大的变动 beta 测试版本,代表版本已开始稳定,但可能会有比较多的问题需要测试和修复 rc 即将作为正式版本发布,只需做最后的验证即可发布正式版
脚本命令的配置 在工作中,会频繁接触到 npm run dev
启动开发环境、 npm run build
构建打包等操作,这些操作其实是对命令行的一种别名。
它在 package.json 里是存放于 scripts
字段,以 [key: string]: string
为格式的键值对存放数据( key: value
)。
json {
+ " scripts " : {
+ // ...
+ }
+}
其中:
以 Vue CLI 创建的项目为例,它的项目 package.json 文件里就会包括了这样的命令:
json {
+ " scripts " : {
+ " serve " : " vue-cli-service serve " ,
+ " build " : " vue-cli-service build "
+ }
+}
这里的名字是可以自定义的,比如可以把 serve
改成更喜欢的 dev
:
json {
+ " scripts " : {
+ " dev " : " vue-cli-service serve " ,
+ " build " : " vue-cli-service build "
+ }
+}
这样运行 npm run dev
也可以相当于运行了 vue-cli-service serve
。
据笔者所了解,有不少开发者曾经对不同的 Vue CLI 版本提供的 npm run serve
和 npm run dev
有什么区别有过疑问,看到这里应该都明白了吧,可以说没有区别,因为这取决于它对应的命令,而不是取决于它起什么名称。
TIP
如果 value
部分包含了双引号 "
,必须使用转义符 \\
来避免格式问题,例如: \\"
。
可以阅读 npm 关于 scripts 的 完整文档 了解更多用法。
Hello Node 看到这里,对于 Node 项目的基本创建流程和关键信息都有所了解了吧!来写一个 demo ,实际体验一下如何从初始化项目到打印一个 Hello World
到控制台的过程。
请先启动的命令行工具,然后创建一个项目文件夹,这里使用 mkdir
命令:
bash # 语法是 mkdir <dir-name>
+mkdir hello-node
使用 cd
命令进入刚刚创建好的项目目录:
bash # 语法是 cd <dir-path>
+cd hello-node
执行项目初始化,可以回答问题,也可以添加 -y
参数来使用默认配置:
来到这里就得到了一个具有 package.json 的 Node 项目了。
在项目下创建一个 index.js
的 JS 文件,可以像平时一样书写 JavaScript ,输入以下内容并保存:
js console . log ( ' Hello World ' )
然后打开 package.json 文件,修改 scripts 部分如下,也就是配置了一个 "dev": "node index"
命令:
json {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " description " : "" ,
+ " main " : " index.js " ,
+ " scripts " : {
+ " dev " : " node index "
+ },
+ " keywords " : [],
+ " author " : "" ,
+ " license " : " ISC "
+}
在命令行执行 npm run dev
,可以看到控制台打印出了 Hello World
:
bash npm run dev
+
+> demo@1.0.0 dev
+> node index
+
+Hello World
这等价于直接在命令行执行 node index.js
命令,其中 node
是 Node.js 运行文件的命令, index
是文件名,相当于 index.js
,因为 JS 文件名后缀可以省略。
学习模块化设计 在了解 Node 项目之后,就要开始通过编码来加强对 Node.js 的熟悉程度了,但在开始使用之前,还需要了解一些概念。
在未来的日子里(不限于本教程,与前端工程化相关的工作内容息息相关),会频繁的接触到两个词:模块( Module )和包( Package )。
模块和包是 Node 开发最重要的组成部分,不管是全部自己实现一个项目,还是依赖各种第三方轮子来协助开发,项目的构成都离不开这两者。
模块化解决了什么问题 在软件工程的设计原则里,有一个原则叫 “单一职责” 。
假设一个代码块负责了多个职责的功能支持,在后续的迭代过程中,维护成本会极大的增加,虽然只需要修改这个代码块,但需要兼顾职责 1 、职责 2 、职责 3 … 等多个职责的兼容性,稍不注意就会引起工程运行的崩溃。
“单一职责” 的目的就是减少功能维护带来的风险,把代码块的职责单一化,让代码的可维护性更高。
一个完整业务的内部实现,不应该把各种代码都耦合在一起,而应该按照职责去划分好代码块,再进行组合,形成一个 “高内聚,低耦合” 的工程设计。
模块化就是由此而来,在前端工程里,每个单一职责的代码块,就叫做模块( Module ) ,模块有自己的作用域,功能与业务解耦,非常方便复用和移植。
TIP
模块化还可以解决本章开头所讲述的 传统开发的弊端 里提到的大部分问题,随着下面内容一步步深入,将一步步的理解它。
如何实现模块化 在前端工程的发展过程中,不同时期诞生了很多不同的模块化机制,最为主流的有以下几种:
模块化方案 全称 适用范围 CJS CommonJS Node 端 AMD Async Module Definition 浏览器 CMD Common Module Definition 浏览器 UMD Universal Module Definition Node 端和浏览器 ESM ES Module Node 端和浏览器
其中 AMD 、CMD 、 UMD 都已经属于偏过去式的模块化方案,在新的业务里,结合各种编译工具,可以直接用最新的 ESM 方案来实现模块化,所以可以在后续有接触的时候再了解。
ESM ( ES Module ) 是 JavaScript 在 ES6( ECMAScript 2015 )版本推出的模块化标准,旨在成为浏览器和服务端通用的模块解决方案。
CJS ( CommonJS ) 原本是服务端的模块化标准(设计之初也叫 ServerJS ),是为 JavaScript 设计的用于浏览器之外的一个模块化方案, Node 默认支持了该规范,在 Node 12 之前也只支持 CJS ,但从 Node 12 开始,已经同时支持 ES Module 的使用。
至此,不论是 Node 端还是浏览器端, ES Module 是统一的模块化标准了!
但由于历史原因, CJS 在 Node 端依然是非常主流的模块化写法,所以还是值得进行了解,因此下面的内容将主要介绍 CJS 和 ESM 这两种模块化规范是如何实际运用。
TIP
在开始体验模块化的编写之前,请先在电脑里 安装好 Node.js ,然后打开 命令行工具 ,通过 cd
命令进入平时管理项目的目录路径, 初始化一个 Node 项目 。
另外,在 CJS 和 ESM ,一个独立的文件就是一个模块,该文件内部的变量必须通过导出才能被外部访问到,而外部文件想访问这些变量,需要导入对应的模块才能生效。
用 CommonJS 设计模块 虽然现在推荐使用 ES Module 作为模块化标准,但是日后在实际工作的过程中,还是不免会遇到要维护一些老项目,因此了解 CommonJS 还是非常有必要的。
以下简称 CJS 代指 CommonJS 规范。
准备工作 延续在 Hello Node 部分创建的 Node.js demo 项目,先调整一下目录结构:
删掉 index.js
文件 创建一个 src
文件夹,在里面再创建一个 cjs
文件夹 在 cjs
文件夹里面创建两个文件: index.cjs
和 module.cjs
TIP
请注意这里使用了 .cjs
文件扩展名,其实它也是 JS 文件,但这个扩展名是 Node 专门为 CommonJS 规范设计的,可以在 了解 package.json 部分的内容了解更多。
此时目录结构应该如下:
bash hello-node
+│ # 源码文件夹
+├─src
+│ │ # 业务文件夹
+│ └─cjs
+│ │ # 入口文件
+│ ├─index.cjs
+│ │ # 模块文件
+│ └─module.cjs
+│ # 项目清单
+└─package.json
这是一个常见的 Node 项目目录结构,通常源代码都会放在 src
文件夹里面统一管理。
接下来再修改一下 package.json 里面的 scripts 部分,改成如下:
json {
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs "
+ }
+}
后面在命令行执行 npm run dev:cjs
命令,就可以测试刚刚添加的 CJS 模块了。
基本语法 CJS 使用 module.exports
语法导出模块,可以导出任意合法的 JavaScript 类型,例如:字符串、布尔值、对象、数组、函数等等。
使用 require
导入模块,在导入的时候,当文件扩展名是 .js
时,可以只写文件名,而此时使用的是 .cjs
扩展名,所以需要完整的书写。
默认导出和导入 默认导出的意思是,一个模块只包含一个值;而导入默认值则意味着,导入时声明的变量名就是对应模块的值。
在 src/cjs/module.cjs
文件里,写入以下代码,导出一句 Hello World
信息:
js // src/cjs/module.cjs
+module.exports = ' Hello World '
TIP
自己在写入代码的时候,不需要包含文件路径那句注释,这句注释只是为了方便阅读时能够区分代码属于哪个文件,以下代码均如此。
在 src/cjs/index.cjs
文件里,写入以下代码,导入刚刚编写的模块。
js // src/cjs/index.cjs
+const m = require ( ' ./module.cjs ' )
+console . log (m)
在命令行输入 npm run dev:cjs
,可以看到成功输出了 Hello World
信息:
bash npm run dev:cjs
+
+> demo@1.0.0 dev:cjs
+> node src/cjs/index.cjs
+
+Hello World
可以看到,在导入模块时,声明的 m
变量拿到的值,就是整个模块的内容,可以直接使用,此例子中它是一个字符串。
再改动一下,把 src/cjs/module.cjs
改成如下,这次导出一个函数:
js // src/cjs/module.cjs
+module.exports = function foo () {
+ console . log ( ' Hello World ' )
+}
相应的,这次变成了导入一个函数,所以可以执行它:
js // src/cjs/index.cjs
+const m = require ( ' ./module.cjs ' )
+m ()
得到的结果也是打印一句 Hello World
,不同的是,这一次的打印行为是在模块里定义的,入口文件只是执行模块里的函数。
bash npm run dev:cjs
+
+> demo@1.0.0 dev:cjs
+> node src/cjs/index.cjs
+
+Hello World
命名导出和导入 默认导出的时候,一个模块只包含一个值,有时候如果想把很多相同分类的函数进行模块化集中管理,例如想做一些 utils 类的工具函数文件、或者是维护项目的配置文件,全部使用默认导出的话,会有非常多的文件要维护。
那么就可以用到命名导出,这样既可以导出多个数据,又可以统一在一个文件里维护管理,命名导出是先声明多个变量,然后通过 {}
对象的形式导出。
再来修改一下 src/cjs/module.cjs
文件,这次改成如下:
js // src/cjs/module.cjs
+function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+const bar = ' Hello World from bar. '
+
+module.exports = {
+ foo ,
+ bar ,
+}
这个时候通过原来的方式去拿模块的值,会发现无法直接获取到函数体或者字符串的值,因为打印出来的也是一个对象。
js // src/cjs/index.cjs
+const m = require ( ' ./module.cjs ' )
+console . log (m)
控制台输出:
bash npm run dev:cjs
+
+> demo@1.0.0 dev:cjs
+> node src/cjs/index.cjs
+
+{ foo: [Function: foo], bar: ' Hello World from bar. ' }
需要通过 m.foo()
、 m.bar
的形式才可以拿到值。
此时可以用一种更方便的方式,利用 ES6 的对象解构来直接拿到变量:
js // src/cjs/index.cjs
+const { foo , bar } = require ( ' ./module.cjs ' )
+foo ()
+console . log (bar)
这样子才可以直接调用变量拿到对应的值。
导入时重命名 以上都是基于非常理想的情况下使用模块,有时候不同的模块之间也会存在相同命名导出的情况,来看看模块化是如何解决这个问题的。
src/cjs/module.cjs
文件保持不变,依然导出这两个变量:
js // src/cjs/module.cjs
+function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+const bar = ' Hello World from bar. '
+
+module.exports = {
+ foo ,
+ bar ,
+}
这次在入口文件里也声明一个 foo
变量,在导入的时候对模块里的 foo
进行了重命名操作。
js // src/cjs/index.cjs
+const {
+ foo : foo2 , // 这里进行了重命名
+ bar ,
+} = require ( ' ./module.cjs ' )
+
+// 就不会造成变量冲突
+const foo = 1
+console . log (foo)
+
+// 用新的命名来调用模块里的方法
+foo2 ()
+
+// 这个不冲突就可以不必处理
+console . log (bar)
再次运行 npm run dev:cjs
,可以看到打印出来的结果完全符合预期:
bash npm run dev:cjs
+
+> demo@1.0.0 dev:cjs
+> node src/cjs/index.cjs
+
+1
+Hello World from foo.
+Hello World from bar.
这是利用了 ES6 解构对象的 给新的变量名赋值 技巧。
以上是针对命名导出时的重命名方案,如果是默认导出,那么在导入的时候用一个不冲突的变量名来声明就可以了。
用 ES Module 设计模块 ES Module 是新一代的模块化标准,它是在 ES6( ECMAScript 2015 )版本推出的,是原生 JavaScript 的一部分。
不过因为历史原因,如果要直接在浏览器里使用该方案,在不同的浏览器里会有一定的兼容问题,需要通过 Babel 等方案进行代码的版本转换(可在 控制编译代码的兼容性 一节了解如何使用 Babel )。
因此一般情况下都需要借助构建工具进行开发,工具通常会提供开箱即用的本地服务器用于开发调试,并且最终打包的时候还可以抹平不同浏览器之间的差异。
随着 ESM 的流行,很多新推出的构建工具都默认只支持该方案( e.g. Vite 、 Rollup ),如果需要兼容 CJS 反而需要另外引入插件单独配置。除了构建工具,很多语言也是默认支持 ESM ,例如 TypeScript ,因此了解 ESM 非常重要。
以下简称 ESM 代指 ES Module 规范。
准备工作 继续使用在 用 CommonJS 设计模块 时使用的 hello-node 项目作为 demo ,当然也可以重新创建一个新的。
一样的,先调整一下目录结构:
在 src
文件夹里面创建一个 esm
文件夹 在 esm
文件夹里面创建两个 MJS 文件: index.mjs
和 module.mjs
TIP
注意这里使用了 .mjs
文件扩展名,因为默认情况下, Node 需要使用该扩展名才会支持 ES Module 规范。
也可以在 package.json 里增加一个 "type": "module"
的字段来使 .js
文件支持 ESM ,但对应的,原来使用 CommonJS 规范的文件需要从 .js
扩展名改为 .cjs
才可以继续使用 CJS 。
为了减少理解上的门槛,这里选择了使用 .mjs
新扩展名便于入门,可以在 了解 package.json 部分的内容了解更多。
此时目录结构应该如下:
bash hello-node
+│ # 源码文件夹
+├─src
+│ │ # 上次用来测试 CommonJS 的相关文件
+│ ├─cjs
+│ │ ├─index.cjs
+│ │ └─module.cjs
+│ │
+│ │ # 这次要用的 ES Module 测试文件
+│ └─esm
+│ │ # 入口文件
+│ ├─index.mjs
+│ │ # 模块文件
+│ └─module.mjs
+│
+│ # 项目清单
+└─package.json
同样的,源代码放在 src
文件夹里面管理。
然后再修改一下 package.json 里面的 scripts 部分,参照上次配置 CJS 的格式,增加一个 ESM 版本的 script ,改成如下:
json {
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs " ,
+ " dev:esm " : " node src/esm/index.mjs "
+ }
+}
后面在命令行执行 npm run dev:esm
就可以测试的 ESM 模块了。
TIP
注意, script 里的 .mjs
扩展名不能省略。
另外,在实际项目中,可能不需要做这些处理,因为很多工作脚手架已经帮处理过了,比如 Vue3 项目。
基本语法 ESM 使用 export default
(默认导出)和 export
(命名导出)这两个语法导出模块,和 CJS 一样, ESM 也可以导出任意合法的 JavaScript 类型,例如:字符串、布尔值、对象、数组、函数等等。
使用 import ... from ...
导入模块,在导入的时候,如果文件扩展名是 .js
则可以省略文件名后缀,否则需要把扩展名也完整写出来。
默认导出和导入 ESM 的默认导出也是一个模块只包含一个值,导入时声明的变量名,它对应的数据就是对应模块的值。
在 src/esm/module.mjs
文件里,写入以下代码,导出一句 Hello World
信息:
js // src/esm/module.mjs
+export default ' Hello World '
在 src/esm/index.mjs
文件里,写入以下代码,导入刚刚编写的模块。
js // src/esm/index.mjs
+import m from ' ./module.mjs '
+console . log (m)
在命令行输入 npm run dev:esm
,可以看到成功输出了 Hello World
信息:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+Hello World
可以看到,在导入模块时,声明的 m
变量拿到的值,就是整个模块的内容,可以直接使用,此例子中它是一个字符串。
像在 CJS 的例子里一样,也来再改动一下,把 src/esm/module.mjs
改成导出一个函数:
js // src/esm/module.mjs
+export default function foo () {
+ console . log ( ' Hello World ' )
+}
同样的,这次也是变成了导入一个函数,可以执行它:
js // src/esm/index.mjs
+import m from ' ./module.mjs '
+m ()
一样可以从模块里的函数得到一句 Hello World
的打印信息。
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+Hello World
TIP
可以看到, CJS 和 ESM 的默认导出是非常相似的,在未来如果有老项目需要从 CJS 往 ESM 迁移,大部分情况下只需要把 module.exports
改成 export default
即可。
命名导出和导入 虽然默认导出的时候, CJS 和 ESM 的写法非常相似,但命名导出却完全不同!
在 CJS 里,使用命名导出后的模块数据默认是一个对象,可以导入模块后通过 m.foo
这样的方式去调用对象的属性,或者在导入的时候直接解构拿到对象上的某个属性:
js // CJS 支持导入的时候直接解构
+const { foo } = require ( ' ./module.cjs ' )
但 ES Module 的默认导出不能这样做,例如下面这个例子,虽然默认导出了一个对象:
js // 在 ESM ,通过这样导出的数据也是属于默认导出
+export default {
+ foo : 1 ,
+}
但是无法和 CJS 一样通过大括号的方式导入其中的某个属性:
js // ESM 无法通过这种方式对默认导出的数据进行 “解构”
+import { foo } from ' ./module.mjs '
这样操作在运行过程中,控制台会抛出错误信息:
bash import { foo } from ' ./module.mjs '
+ ^^^
+SyntaxError:
+The requested module ' ./module.mjs ' does not provide an export named ' foo '
正确的方式应该是通过 export
对数据进行命名导出,先将 src/esm/module.mjs
文件修改成如下代码,请留意 export
关键字的使用:
js // src/esm/module.mjs
+export function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+export const bar = ' Hello World from bar. '
通过 export
命名导出的方式,现在才可以使用大括号将它们进行命名导入:
js // src/esm/index.mjs
+import { foo , bar } from ' ./module.mjs '
+
+foo ()
+console . log (bar)
这一次程序可以顺利运行了:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+Hello World from foo.
+Hello World from bar.
那么有没有办法像 CJS 一样使用 m.foo
调用对象属性的方式一样,去使用这些命名导出的模块呢?
答案是肯定的!命名导出支持使用 * as 变量名称
的方式将其所有命名挂在某个变量上,该变量是一个对象,每一个导出的命名都是其属性:
ts // src/esm/index.mjs
+// 注意这里使用了另外一种方式,将所有的命名导出都挂在了 \`m\` 变量上
+import * as m from ' ./module.mjs '
+
+console . log ( typeof m)
+console . log (Object . keys (m))
+
+m . foo ()
+console . log (m . bar)
运行 npm run dev:esm
,将输出:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+object
+[ ' bar ' , ' foo ' ]
+Hello World from foo.
+Hello World from bar.
导入时重命名 接下来看看 ESM 是如何处理相同命名导出的问题,项目下的模块文件依然保持不变,还是导出两个变量:
js // src/esm/module.mjs
+export function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+export const bar = ' Hello World from bar. '
入口文件里面,也声明一个 foo
变量,然后导入的时候对模块里的 foo
进行重命名操作:
js // src/esm/index.mjs
+import {
+ foo as foo2 , // 这里进行了重命名
+ bar
+} from ' ./module.mjs '
+
+// 就不会造成变量冲突
+const foo = 1
+console . log (foo)
+
+// 用新的命名来调用模块里的方法
+foo2 ()
+
+// 这个不冲突就可以不必处理
+console . log (bar)
可以看到,在 ESM 的重命名方式和 CJS 是完全不同的,它是使用 as
关键字来操作,语法为 <old-name> as <new-name>
。
现在再次运行 npm run dev:esm
,可以看到打印出来的结果也是完全符合预期了:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+1
+Hello World from foo.
+Hello World from bar.
以上是针对命名导出时的重命名方案,如果是默认导出,和 CJS 一样,在导入的时候用一个不冲突的变量名来声明就可以了。
在浏览器里访问 ESM ES Module 除了支持在 Node 环境使用,还可以和普通的 JavaScript 代码一样在浏览器里运行。
要在浏览器里体验 ESM ,需要使用现代的主流浏览器(如 Chrome ),并注意其访问限制,例如本地开发不能直接通过 file://
协议在浏览器里访问本地 HTML 内引用的 JS 文件,这是因为浏览器对 JavaScript 的安全性要求,会触发 CORS 错误,因此需要启动本地服务并通过 http://
协议访问。
TIP
CORS (全称 Cross-Origin Resource Sharing )是指跨源资源共享,可以决定浏览器是否需要阻止 JavaScript 获取跨域请求的响应。
现代浏览器默认使用 “同源安全策略” ,这里的 “源” 指 URL 的 origin
部分,例如网页可以通过 window.location.origin
获取到如 https://example.com
这样格式的数据,就是网页的 origin
。
默认情况下,非同源的请求会被浏览器拦截,最常见的场景是通过 XHR 或者 Fetch 请求 API 接口,需要网页和接口都部署在同一个域名才可以请求成功,否则就会触发跨域限制。
如果网页和接口不在同一个域名,例如网页部署在 https://web.example.com
,接口部署在 https://api.example.com
,此时需要在 https://api.example.com
的 API 服务端程序里,配置 Access-Control-Allow-Origin: *
允许跨域请求( *
代表允许任意外域访问,也可以指定具体的域名作为白名单列表)。
添加服务端程序 接下来搭建一个简单的本地服务,并通过 HTML 文件来引入 ESM 模块文件,体验浏览器端如何使用 ESM 模块。
在 hello-node 项目的根目录下创建名为 server 的文件夹(与 src 目录同级),并添加 index.js 文件,敲入以下代码:
js // server/index.js
+const { readFileSync } = require ( ' fs ' )
+const { resolve } = require ( ' path ' )
+const { createServer } = require ( ' http ' )
+
+/**
+ * 判断是否 ESM 文件
+ */
+function isESM ( url ) {
+ return String ( url ) . endsWith ( ' mjs ' )
+}
+
+/**
+ * 获取 MIME Type 信息
+ * @ tips \`.mjs\` 和 \`.js\` 一样,都使用 JavaScript 的 MIME Type
+ */
+function mimeType ( url ) {
+ return isESM ( url ) ? ' application/javascript ' : ' text/html '
+}
+
+/**
+ * 获取入口文件
+ * @ returns 存放在本地的文件路径
+ */
+function entryFile ( url ) {
+ const file = isESM ( url ) ? \` ../src/esm \${ url }\` : ' ./index.html '
+ return resolve ( __dirname , file )
+}
+
+/**
+ * 创建 HTTP 服务
+ */
+const app = createServer ( ( request , response ) => {
+ // 获取请求时的相对路径,如网页路径、网页里的 JS 文件路径等
+ const { url } = request
+
+ // 转换成对应的本地文件路径并读取其内容
+ const entry = entryFile ( url )
+ const data = readFileSync ( entry , ' utf-8 ' )
+
+ // 需要设置正确的响应头信息,浏览器才可以正确响应
+ response . writeHead ( 200 , { ' Content-Type ' : mimeType ( url ) } )
+ response . end ( data )
+} )
+
+/**
+ * 在指定的端口号启动本地服务
+ */
+const port = 8080
+app . listen (port , ' 0.0.0.0 ' , () => {
+ console . log ( \` Server running at: \` )
+ console . log ()
+ console . log ( \` ➜ Local: http://localhost: \${ port } / \` )
+ console . log ()
+} )
这是一个基础的 Node.js 服务端程序,利用了 HTTP 模块启动本地服务,期间利用 FS 模块的 I/O 能力对本地文件进行读取,而 PATH 模块则简化了文件操作过程中的路径处理和兼容问题(例如众所周知的 Windows 与 macOS 的路径斜杆问题)。
TIP
在这段服务端程序代码里,请留意 mimeType
方法,要让浏览器能够正确解析 .mjs
文件,需要在服务端响应文件内容时,将其 MIME Type 设置为 和 JavaScript 文件一样,这一点非常重要。
并且需要注意传递给 readFileSync
API 的文件路径是否与真实存在的文件路径匹配,如果启动服务时,在 Node 控制台报了 no such file or directory
的错误,请检查是否因为笔误写错了文件名称,或者文件路径多了空格等情况。
添加入口页面 继续在 server 目录下添加一个 index.html 并写入以下 HTML 代码,它将作为网站的首页文件:
TIP
可以在 VSCode 先新建一个空文件,文件语言设置为 HTML ,并写入英文感叹号 !
,再按 Tab 键(或者鼠标选择第一个代码片段提示),可快速生成基础的 HTML 结构。
html <!-- server/index.html -->
+<! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > ESM run in browser </ title >
+ </ head >
+ < body >
+ < script type = " module " src = " ./index.mjs " ></ script >
+ </ body >
+</ html >
请注意在 <script />
标签这一句代码上,比平时多了一个 type="module"
属性,这代表这个 script 是使用了 ESM 模块,而 src
属性则对应指向了上文在 src/esm 目录下的入口文件名。
之所以无需使用 ../src/esm/index.mjs
显式的指向真实目录,是因为在 添加服务端程序 时,已通过服务端代码里的 entryFile
方法重新指向了文件所在的真实路径,所以在 HTML 文件里可以使用 ./
简化文件路径。
启动服务并访问 打开 package.json 文件,在 scripts
字段追加一个 serve
命令如下:
json {
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs " ,
+ " dev:esm " : " node src/esm/index.mjs " ,
+ " serve " : " node server/index.js "
+ }
+}
在命令行运行 npm run serve
即可启动本地服务:
bash ❯ npm run serve
+
+> demo@1.0.0 serve
+> node server/index.js
+
+Server running at:
+
+ ➜ Local: http://localhost:8080/
根据命令行提示,在浏览器访问 http://localhost:8080/
地址,即可访问本地服务。
TIP
如遭遇端口号冲突,可在 server/index.js 的 const port = 8080
代码处修改为其他端口号。
因为在编写 HTML 文件时没有写入内容,只引入了 ESM 模块文件,因此需要按 F12 唤起浏览器的控制台查看 Log ,可以看到控制台根据模块的文件内容,输出了这三句 Log (如果没有 Log ,可在控制台唤起的情况下按 F5 重新载入页面):
bash 1 index.mjs:8
+Hello World from foo. module.mjs:2
+Hello World from bar. index.mjs:14
分别来自 src/esm/index.mjs 本身的 console.log
语句,以及 import
进来的 module.mjs 里的 console.log
语句。
如果未能出现这三句 Log ,请留意 .mjs
文件内容是否为上一小节最后的内容:
src/esm/index.mjs 文件内容为:
js // src/esm/index.mjs
+import {
+ foo as foo2 , // 这里进行了重命名
+ bar ,
+} from ' ./module.mjs '
+
+// 就不会造成变量冲突
+const foo = 1
+console . log (foo)
+
+// 用新的命名来调用模块里的方法
+foo2 ()
+
+// 这个不冲突就可以不必处理
+console . log (bar)
src/esm/module.mjs 文件内容为:
js // src/esm/module.mjs
+export function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+export const bar = ' Hello World from bar. '
内联的 ESM 代码 到目前为止, server/index.html 文件里始终是通过文件的形式引入 ESM 模块,其实 <script type="module" />
也支持编写内联代码,和普通的 <script />
标签用法相同:
html < script type = " module " >
+ // ESM 模块的 JavaScript 代码
+</ script >
请移除 <script />
标签的 src
属性,并在标签内写入 src/esm/index.mjs 文件里的代码,现在该 HTML 文件的完整代码如下:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > ESM run in browser </ title >
+ </ head >
+ < body >
+ <!-- 标签内的代码就是 src/esm/index.mjs 的代码 -->
+ < script type = " module " >
+ import {
+ foo as foo2 , // 这里进行了重命名
+ bar ,
+ } from ' ./module.mjs '
+
+ // 就不会造成变量冲突
+ const foo = 1
+ console . log (foo)
+
+ // 用新的命名来调用模块里的方法
+ foo2 ()
+
+ // 这个不冲突就可以不必处理
+ console . log (bar)
+ </ script >
+ </ body >
+</ html >
回到浏览器刷新 http://localhost:8080/
,可以看到浏览器控制台依然输出了和引入 src="./index.mjs"
时一样的 Log 信息:
bash 1 (index):21
+Hello World from foo. module.mjs:2
+Hello World from bar. (index):27
了解模块导入限制 虽然以上例子可以完美地在浏览器里引用现成的 ESM 模块代码并运行,但不代表工程化项目下所有的 ES Module 模块化方式都适合浏览器。
先做一个小尝试,将 src/esm/index.mjs 文件内容修改如下,导入项目已安装的 md5 工具包:
js // src/esm/index.mjs
+import md5 from ' md5 '
+console . log ( md5 ( ' Hello World ' ))
回到浏览器刷新 http://localhost:8080/
,观察控制台,可以发现出现了一个红色的错误信息:
bash Uncaught TypeError: Failed to resolve module specifier " md5 " .
+Relative references must start with either " / " , " ./ " , or " ../ " .
这是因为不论是通过 <script type="module" />
标签还是通过 import
语句导入,模块的路径都必须是以 /
、 ./
或者是 ../
开头,因此无法直接通过 npm 包名进行导入。
这种情况下需要借助另外一个 script 类型: importmap
,在 server/index.html 里追加 <script type="importmap" />
这一段代码:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > ESM run in browser </ title >
+ </ head >
+ < body >
+ <!-- 注意需要先通过 \`importmap\` 引入 npm 包的 CDN -->
+ < script type = " importmap " >
+ {
+ "imports": {
+ "md5": "https://esm.run/md5"
+ }
+ }
+ </ script >
+
+ <!-- 然后才能在 \`module\` 里 \`import xx from 'xx'\` -->
+ < script type = " module " src = " ./index.mjs " ></ script >
+ </ body >
+</ html >
再次刷新页面,可以看到控制台成功输出了 b10a8db164e0754105b7a99be72e3fe5
这个字符串,也就是 Hello World
被 MD5 处理后的结果。
可以看到 importmap
的声明方式和 package.json 的 dependencies 字段非常相似, JSON 的 key 是包名称, value 则是支持 ESM 的远程地址。
TIP
Import Maps 的运行机制是通过 import 映射来控制模块说明符的解析,类似于构建工具常用的 alias
别名机制。
这是一个现代浏览器才能支持的新特性,建议使用 Chrome 最新版本体验完整功能,可以在其 GitHub 仓库 查看更多用法。
上方例子里, md5 对应的远程地址是使用了来自 esm.run
网站的 URL ,而不是 npm 包同步到 jsDelivr CDN 或者 UNPKG CDN 的地址,这是因为 md5 这个包本身不支持 ES Module ,需要通过 esm.run
这个网站进行在线转换才可以在 <script type="module" />
上使用。
`,323),F=l(`该网站的服务是 jsDelivr CDN 所属的服务商提供,因此也可以通过 jsDelivr CDN 的 URL 添加 /+esm
参数来达到转换效果,以 md5 包为例:
bash # 默认是一个 CJS 包
+https://cdn.jsdelivr.net/npm/md5
+
+# 可添加 \`/+esm\` 参数变成 ESM 包
+https://cdn.jsdelivr.net/npm/md5/+esm
总的来说,现阶段在浏览器使用 ES Module 并不是一个很好的选择,建议开发者还是使用构建工具来开发,工具可以抹平这些浏览器差异化问题,降低开发成本。
认识组件化设计 学习完模块化设计之后,未来在 Vue 的工程化开发过程中,还会遇到一个新的概念,那就是 “组件” 。
什么是组件化 模块化属于 JavaScript 的概念,但作为一个页面,都知道它是由 HTML + CSS + JS 三部分组成的,既然 JS 代码可以按照不同的功能、需求划分成模块,那么页面是否也可以呢?
答案是肯定的!组件化就是由此而来。
在前端工程项目里,页面可以理解为一个积木作品,组件则是用来搭建这个作品的一块又一块积木。
`,9),i=l(`解决了什么问题 模块化属于 JavaScript 的概念,把代码块的职责单一化,一个函数、一个类都可以独立成一个模块。
但这只解决了逻辑部分的问题,一个页面除了逻辑,还有骨架( HTML )和样式( CSS ),组件就是把一些可复用的 HTML 结构和 CSS 样式再做一层抽离,然后再放置到需要展示的位置。
常见的组件有:页头、页脚、导航栏、侧边栏… 甚至小到一个用户头像也可以抽离成组件,因为头像可能只是尺寸、圆角不同而已。
每个组件都有自己的 “作用域” , JavaScript 部分利用 模块化 来实现作用域隔离, HTML 和 CSS 代码则借助 Style Scoped 来生成独有的 hash ,避免全局污染,这些方案组合起来,使得组件与组件之间的代码不会互相影响。
如何实现组件化 在 Vue ,是通过 Single-File Component (简称 SFC , .vue
单文件组件)来实现组件化开发。
一个 Vue 组件是由三部分组成的:
vue < template >
+ <!-- HTML 代码 -->
+</ template >
+
+< script >
+// JavaScript 代码
+</ script >
+
+< style scoped >
+/* CSS 代码 */
+</ style >
在后面的 单组件的编写 一章中,会详细介绍如何编写一个 Vue 组件。
依赖包和插件 在实际业务中,经常会用到各种各样的插件,插件在 Node 项目里的体现是一个又一个的依赖包。
虽然也可以把插件的代码文件手动放到的源码文件夹里引入,但并不是一个最佳的选择,本节内容将带了解 Node 的依赖包。
什么是包 在 Node 项目里,包可以简单理解为模块的集合,一个包可以只提供一个模块的功能,也可以作为多个模块的集合集中管理。
包通常是发布在官方的包管理平台 npmjs 上面,开发者需要使用的时候,可以通过包管理器安装到项目里,并在的代码里引入,开箱即用(详见: 依赖包的管理 )。
使用 npm 包可以减少在项目中重复造轮子,提高项目的开发效率,也可以极大的缩小项目源码的体积(详见:什么是 node_modules )。
包管理平台官网:https://www.npmjs.com
什么是 node_modules node_modules 是 Node 项目下用于存放已安装的依赖包的目录,如果不存在,会自动创建。
如果是本地依赖,会存在于项目根目录下,如果是全局依赖,会存在于环境变量关联的路径下,详见下方的管理依赖部分内容的讲解。
TIP
一般在提交项目代码到 Git 仓库或者的服务器上时,都需要排除 node_modules 文件夹的提交,因为它非常大。
如果托管在 Git 仓库,可以在 .gitignore 文件里添加 node_modules
作为要排除的文件夹名称。
什么是包管理器 包管理器( Package Manager )是用来管理依赖包的工具,比如:发布、安装、更新、卸载等等。
Node 默认提供了一个包管理器 npm
,在安装 Node.js 的时候,默认会一起安装 npm 包管理器,可以通过以下命令查看它是否正常。
如果正常,将会输出相应的版本号。
依赖包的管理 接下来会以 npm 作为默认的包管理器,来了解如何在项目里管理依赖包。
配置镜像源 在国内,直接使用 npm 会比较慢,可以通过绑定 npm Mirror 中国镜像站 的镜像源来提升依赖包的下载速度。
可以先在命令行输入以下命令查看当前的 npm 配置:
bash npm config get registry
+# https://registry.npmjs.org/
默认情况下,会输出 npm 官方的资源注册表地址,接下来在命令行上输入以下命令,进行镜像源的绑定:
bash npm config set registry https://registry.npmmirror.com
可以再次运行查询命令来查看是否设置成功:
bash npm config get registry
+# https://registry.npmmirror.com/
可以看到已经成功更换为中国镜像站的地址了,之后在安装 npm 包的时候,速度会有很大的提升!
如果需要删除自己配置的镜像源,可以输入以下命令进行移除,移除后会恢复默认设置:
bash npm config rm registry
TIP
如果之前已经绑定过 npm.taobao
系列域名,也请记得更换成 npmmirror
这个新的域名!
随着新的域名已经正式启用,老 npm.taobao.org
和 registry.npm.taobao.org
域名在 2022 年 05 月 31 日零时后不再提供服务。
详见:【望周知】淘宝 npm 镜像站喊你切换新域名啦
本地安装 项目的依赖建议优先选择本地安装,这是因为本地安装可以把依赖列表记录到 package.json 里,多人协作的时候可以减少很多问题出现,特别是当本地依赖与全局依赖版本号不一致的时候。
生产依赖 执行 npm install
的时候,添加 --save
或者 -S
选项可以将依赖安装到本地,并列为生产依赖。
TIP
需要提前在命令行 cd
到的项目目录下再执行安装。
另外, --save
或者 -S
选项在实际使用的时候可以省略,因为它是默认选项。
bash npm install --save < package-nam e >
可以在项目的 package.json
文件里的 dependencies
字段查看是否已安装成功,例如:
json // package.json
+{
+ // 会安装到这里
+ " dependencies " : {
+ // 以 "包名":"版本号" 的格式写入
+ " vue-router " : " ^4.0.14 "
+ }
+}
生产依赖包会被安装到项目根目录下的 node_modules
目录里。
项目在上线后仍需用到的包,就需要安装到生产依赖里,比如 Vue 的路由 vue-router
就需要以这个方式安装。
开发依赖 执行 npm install
的时候,如果添加 --save-dev
或者 -D
选项,可以将依赖安装到本地,并写入开发依赖里。
TIP
需要提前在命令行 cd
到的项目目录下再执行安装。
bash npm install --save-dev < package-nam e >
可以在项目的 package.json
文件里的 devDependencies
字段查看是否已安装成功,例如:
json // package.json
+{
+ // 会安装到这里
+ " devDependencies " : {
+ // 以 "包名":"版本号" 的格式写入
+ " eslint " : " ^8.6.0 "
+ }
+}
开发依赖包也是会被安装到项目根目录下的 node_modules
目录里。
和生产依赖包不同的点在于,只在开发环境生效,构建部署到生产环境时可能会被抛弃,一些只在开发环境下使用的包,就可以安装到开发依赖里,比如检查代码是否正确的 ESLint
就可以用这个方式安装。
全局安装 执行 npm install
的时候,如果添加 --global
或者 -g
选项,可以将依赖安装到全局,它们将被安装在 配置环境变量 里配置的全局资源路径里。
bash npm install --global < package-nam e >
TIP
Mac 用户需要使用 sudo
来提权才可以完成全局安装。
另外,可以通过 npm root -g
查看全局包的安装路径。
一般情况下,类似于 @vue/cli
之类的脚手架会提供全局安装的服务,安装后,就可以使用 vue create xxx
等命令直接创建 Vue 项目了。
但不是每个 npm 包在全局安装后都可以正常使用,请阅读 npm 包的主页介绍和使用说明。
版本控制 有时候一些包的新版本不一定适合的老项目,因此 npm 也提供了版本控制功能,支持通过指定的版本号或者 Tag 安装。
语法如下,在包名后面紧跟 @
符号,再紧跟版本号或者 Tag 名称。
bash npm install < package-nam e > @ < version | tag>
例如:
现阶段 Vue 默认为 3.x 的版本了,如果想安装 Vue 2 ,可以通过指定版本号的方式安装:
bash npm install vue@2.6.14
或者通过对应的 Tag 安装:
bash npm install vue@legacy
TIP
版本号或者 Tag 名称可以在 npmjs 网站上的包详情页查询。
版本升级 一般来说,直接重新安装依赖包可以达到更新的目的,但也可以通过 npm update
命令来更新。
语法如下,可以更新全部的包:
也可以更新指定的包:
bash npm update < package-nam e >
npm 会检查是否有满足版本限制的更新版本。
卸载 可以通过 npm uninstall
命令来卸载指定的包,和安装一样,卸载也区分了卸载本地依赖包和卸载全局包,不过只有在卸载全局包的时候才需要添加选项,默认只卸载当前项目下的本地包。
本地卸载:
bash npm uninstall < package-nam e >
全局卸载:
bash npm uninstall --global < package-nam e >
TIP
Mac 用户需要使用 sudo
来提权才可以完成全局卸载。
如何使用包 在了解了 npm 包的常规操作之后,通过一个简单的例子来了解如何在项目里使用 npm 包。
继续使用的 Hello Node demo ,或者也可以重新创建一个 demo 。
首先在 命令行工具 通过 cd
命令进入项目所在的目录,用本地安装的方式来把 md5 包 添加到生产依赖,这是一个为提供开箱即用的哈希算法的包,在未来的实际工作中,可能也会用到它,在这里使用它是因为足够简单。
输入以下命令并回车执行:
可以看到控制台提示一共安装了 4 个包,这是因为 md5 这个 npm 包还引用了其他的包作为依赖,需要同时安装才可以正常工作。
bash # 这是安装 md5 之后控制台的信息返回
+added 4 packages, and audited 5 packages in 2 s
+
+found 0 vulnerabilities
此时项目目录下会出现一个 node_modules 文件夹和一个 package-lock.json 文件:
bash hello-node
+│ # 依赖文件夹
+├─node_modules
+│ # 源码文件夹
+├─src
+│ # 锁定安装依赖的版本号
+├─package-lock.json
+│ # 项目清单
+└─package.json
先打开 package.json ,可以看到已经多出了一个 dependencies
字段,这里记录了刚刚安装的 md5 包信息。
json {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " description " : "" ,
+ " main " : " index.js " ,
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs " ,
+ " dev:esm " : " node src/esm/index.mjs " ,
+ " serve " : " node server/index.js "
+ },
+ " keywords " : [],
+ " author " : "" ,
+ " license " : " ISC " ,
+ " dependencies " : {
+ " md5 " : " ^2.3.0 "
+ }
+}
来到这里可能会有一连串的疑问:
为什么只安装了一个 md5 ,但控制台提示安装了 4 个包? 为什么 package.json 又只记录了 1 个 md5 包信息? 为什么提示审核了 5 个包,哪里来的第 5 个包? 不要着急,请先打开 package-lock.json 文件,这个文件是记录了锁定安装依赖的版本号信息(由于篇幅原因,这里的展示省略了一些包的细节):
json {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " lockfileVersion " : 2 ,
+ " requires " : true,
+ " packages " : {
+ "" : {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " license " : " ISC " ,
+ " dependencies " : {
+ " md5 " : " ^2.3.0 "
+ }
+ },
+ " node_modules/charenc " : {
+ " version " : " 0.0.2 "
+ // ...
+ },
+ " node_modules/crypt " : {
+ " version " : " 0.0.2 "
+ // ...
+ },
+ " node_modules/is-buffer " : {
+ " version " : " 1.1.6 "
+ // ...
+ },
+ " node_modules/md5 " : {
+ " version " : " 2.3.0 "
+ // ...
+ }
+ },
+ " dependencies " : {
+ " charenc " : {
+ " version " : " 0.0.2 "
+ // ...
+ },
+ " crypt " : {
+ " version " : " 0.0.2 "
+ // ...
+ },
+ " is-buffer " : {
+ " version " : " 1.1.6 "
+ // ...
+ },
+ " md5 " : {
+ " version " : " 2.3.0 " ,
+ // ...
+ " requires " : {
+ " charenc " : " 0.0.2 " ,
+ " crypt " : " 0.0.2 " ,
+ " is-buffer " : " ~1.1.6 "
+ }
+ }
+ }
+}
可以看到这个文件的 dependencies
字段除了 md5 之外,还有另外 3 个包信息,它们就是 md5 包所依赖的另外 3 个 npm 包了,这就解答了为什么一共安装了 4 个 npm 包。
在 node_modules 文件夹下也可以看到以这 4 个包名为命名的文件夹,这些文件夹存放的就是各个包项目发布在 npmjs 平台上的文件。
再看 packages
字段,这里除了罗列出 4 个 npm 包的信息之外,还把项目的信息也列了进来,这就是为什么是提示审核了 5 个包,原因是除了 4 个依赖包,该项目本身也是一个包。
TIP
package-lock.json 文件并不是一成不变的,假如以后 md5 又引用了更多的包,这里记录的信息也会随之增加。
并且不同的包管理器,它的 lock 文件也会不同,如果是使用 yarn 作为包管理器的话,它是生成一个 yarn.lock 文件,而不是 package-lock.json ,有关更多的包管理器,详见 插件的使用 一章。
现在已经安装好 md5 包了,接下来看看具体如何使用它。
通常在包的 npmjs 主页上会有 API 和用法的说明,只需要根据说明操作,打开 src/esm/index.mjs
文件,首先需要导入这个包。
包的导入和在 学习模块化设计 一节了解到的模块导入用法是一样的,只是把 from
后面的文件路径换成了包名。
js // src/esm/index.mjs
+import md5 from ' md5 '
然后根据 md5 的用法,来编写一个小例子,先声明一个原始字符串变量,然后再声明一个使用 md5 加密过的字符串变量,并打印它们:
js // src/esm/index.mjs
+import md5 from ' md5 '
+
+const before = ' Hello World '
+const after = md5 (before)
+console . log ( { before , after } )
在命令行输入 npm run dev:esm
,可以在控制台看到输出了这些内容,成功获得了转换后的结果:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+{ before: ' Hello World ' , after: ' b10a8db164e0754105b7a99be72e3fe5 ' }
是不是非常简单,其实包的用法和在导入模块的用法可以说是完全一样的,区别主要在于,包是需要安装了才能用,而模块是需要自己编写。
控制编译代码的兼容性 作为一名前端工程师,了解如何控制代码的兼容性是非常重要的能力。
在 “了解前端工程化” 的 为什么要使用构建工具 一节里,已简单介绍过 Polyfill 的作用,以及介绍了构建工具可以通过 Babel 等方案自动化处理代码的兼容问题,这一小节将讲解 Babel 的配置和使用,亲自体验如何控制代码的兼容性转换。
如何查询兼容性 在开始学习使用 Babel 之前,需要先掌握一个小技能:了解如何查询代码在不同浏览器上的兼容性。
说起浏览器兼容性,前端工程师应该都不陌生,特别是初学者很容易会遇到在自己的浏览器上布局正确、功能正常,而在其他人的电脑或者手机上访问就会有布局错位或者运行报错的问题出现,最常见的场景就是开发者使用的是功能强大的 Chrome 浏览器,而产品用户使用了 IE 浏览器。
这是因为网页开发使用的 HTML / CSS / JavaScript 每年都在更新新版本,推出更好用的新 API ,或者废弃部分过时的旧 API ,不同的浏览器在版本更新过程中,对这些新 API 的支持程度并不一致,如果使用了新 API 而没有做好兼容支持,很容易就会在低版本浏览器上出现问题。
为了保证程序可以正确的在不同版本浏览器之间运行,就需要根据产品要支持的目标浏览器范围,去选择兼容性最好的编程方案。
在 Web 开发有一个网站非常知名:Can I use ,只要搜索 API 的名称,它会以图表的形式展示该 API 在不同浏览器的不同版本之间的支持情况,支持 HTML 标签、 CSS 属性、 JavaScript API 等内容的查询。
以 JavaScript ES6 的 class
新特性为例:
`,128),C=l(`可以看到在 Chrome 浏览器需要在 49 版本开始才被完全支持,而 IE 浏览器则全面不支持,如果不做特殊处理(例如引入 Polyfill 方案),那么就需要考虑在编程过程中,是否需要可以直接使用 class
来实现功能,还是寻找其他替代方案。
在工作中,工程师无需关注每一个 API 的具体支持范围,这些工作可以交给工具来处理,下面将介绍 Babel 的使用入门。
Babel 的使用和配置 Babel 是一个 JavaScript 编译器,它可以让开发者仅需维护一份简单的 JSON 配置文件,即可调动一系列工具链将源代码编译为目标浏览器指定版本所支持的语法。
安装 Babel 请打开 hello-node 项目,安装以下几个 Babel 依赖:
bash npm i -D @babel/core @babel/cli @babel/preset-env
此时在 package.json 的 devDependencies
可以看到有了如下三个依赖:
json {
+ " devDependencies " : {
+ " @babel/cli " : " ^7.19.3 " ,
+ " @babel/core " : " ^7.19.3 " ,
+ " @babel/preset-env " : " ^7.19.3 "
+ }
+}
它们的作用分别如下:
依赖 作用 文档 @babel/cli 安装后可以从命令行使用 Babel 编译文件 查看文档 @babel/core Babel 的核心功能包 查看文档 @babel/preset-env 智能预设,可以通过它的选项控制代码要转换的支持版本 查看文档
TIP
在使用 Babel 时,建议在项目下进行本地安装,尽量不选择全局安装,这是因为不同项目可能依赖于不同版本的 Babel ,全局依赖和可能会出现使用上的异常。
添加 Babel 配置 接下来在 hello-node 的根目录下创建一个名为 babel.config.json 的文件,这是 Babel 的配置文件,写入以下内容:
json {
+ " presets " : [
+ [
+ " @babel/preset-env " ,
+ {
+ " targets " : {
+ " chrome " : " 41 "
+ },
+ " modules " : false,
+ " useBuiltIns " : " usage " ,
+ " corejs " : " 3.6.5 "
+ }
+ ]
+ ]
+}
这份配置将以 Chrome 浏览器作为目标浏览器,编译结果将保留 ES Module 规范,可以在 配置文件文档 查看更多配置选项。
这里的 targets.chrome
字段代表编译后要支持的目标浏览器版本号,在 caniuse 查询可知 ES6 的 class 语法 在 Chrome 49 版本之后才被完全支持,而 Chrome 41 或更低的版本是完全不支持该语法,因此先将其目标版本号设置为 41 ,下一步将开始测试 Babel 的编译结果。
使用 Babel 编译代码 在 hello-node 的 src 目录下添加一个 babel 文件夹,并在该文件夹下创建一个 index.js 文件,写入以下代码:
js // src/babel/index.js
+export class Hello {
+ constructor ( name ) {
+ this. name = name
+ }
+
+ say () {
+ return \` Hello \${ this. name }\`
+ }
+}
根据上一步的 Babel 配置,在这里使用 class
语法作为测试代码。
接下来再打开 package.json 文件,添加一个 compile
script 如下:
json {
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs " ,
+ " dev:esm " : " node src/esm/index.mjs " ,
+ " compile " : " babel src/babel --out-dir compiled " ,
+ " serve " : " node server/index.js "
+ }
+}
这条命令的含义是:使用 Babel 处理 src/babel 目录下的文件,并输出到根目录下的 compiled 文件夹。
在命令行运行以下命令:
可以看到 hello-node 的根目录下多了一个 compiled 文件夹,里面有一个和源码相同命名的 index.js 文件,它的文件内容如下:
js // compiled/index.js
+function _classCallCheck ( instance , Constructor ) {
+ if ( ! ( instance instanceof Constructor )) {
+ throw new TypeError ( ' Cannot call a class as a function ' )
+ }
+}
+
+function _defineProperties ( target , props ) {
+ for ( var i = 0 ; i < props . length ; i ++ ) {
+ var descriptor = props [ i ]
+ descriptor . enumerable = descriptor . enumerable || false
+ descriptor . configurable = true
+ if ( ' value ' in descriptor ) descriptor . writable = true
+ Object . defineProperty ( target , descriptor . key , descriptor )
+ }
+}
+
+function _createClass ( Constructor , protoProps , staticProps ) {
+ if ( protoProps ) _defineProperties ( Constructor . prototype , protoProps )
+ if ( staticProps ) _defineProperties ( Constructor , staticProps )
+ Object . defineProperty ( Constructor , ' prototype ' , { writable : false } )
+ return Constructor
+}
+
+export var Hello = /*#__PURE__*/ ( function () {
+ function Hello ( name ) {
+ _classCallCheck ( this, Hello )
+
+ this. name = name
+ }
+
+ _createClass ( Hello , [
+ {
+ key : ' say ' ,
+ value : function say () {
+ return \` Hello \${ this. name }\`
+ },
+ },
+ ])
+
+ return Hello
+} )()
由于 Chrome 41 版本不支持 class
语法,因此 Babel 做了大量的工作对其进行转换兼容。
再次打开 babel.config.json ,将 targets.chrome
的版本号调整为支持 class
语法的 Chrome 49 版本:
diff {
+ "presets": [
+ [
+ "@babel/preset-env",
+ {
+ "targets": {
+- "chrome": "41"
++ "chrome": "49"
+ },
+ "modules": false,
+ "useBuiltIns": "usage",
+ "corejs": "3.6.5"
+ }
+ ]
+ ]
+}
再次执行编译,这一次编译后的代码和编译前完全一样:
js // compiled/index.js
+export class Hello {
+ constructor ( name ) {
+ this. name = name
+ }
+
+ say () {
+ return \` Hello \${ this. name }\`
+ }
+}
因为此时配置文件指定的目标浏览器版本已支持该语法,无需转换。
Babel 的使用其实非常简单,了解了这部分知识点之后,如果某一天需要自己控制代码的兼容性,只需要配合官方文档调整 Babel 的配置,处理起来就得心应手了!
`,35);function A(d,u,h,m,g,E){const o=p("ImgWrap"),a=p("ClientOnly"),e=p("GitalkComment");return c(),r("div",null,[D,s(a,null,{default:n(()=>[s(o,{src:"/assets/img/esm-run.jpg",alt:"esm.run 网站上的包转换操作界面"})]),_:1}),F,s(a,null,{default:n(()=>[s(o,{src:"/assets/img/components.png",alt:"把页面拆分成多个组件,降低维护成本(摘自 Vue 官网)"})]),_:1}),i,s(a,null,{default:n(()=>[s(o,{src:"/assets/img/caniuse-es6-classes.jpg",alt:"在 caniuse 网站上查询 ES6 `class` 的兼容情况"})]),_:1}),C,s(a,null,{default:n(()=>[s(e,{issueId:163})]),_:1})])}const f=t(y,[["render",A]]);export{q as __pageData,f as default};
diff --git a/assets/guide.md.82bb6597.lean.js b/assets/guide.md.82bb6597.lean.js
new file mode 100644
index 00000000..94be6db3
--- /dev/null
+++ b/assets/guide.md.82bb6597.lean.js
@@ -0,0 +1,650 @@
+/**
+ * name: learning-vue3
+ * version: v2.0.0
+ * description: A starting learning tutorial on Vue 3.0 + TypeScript, suitable for complete Vue novices and Vue 2.0 veterans, incorporating some of my own practical experience on the basis of official documents.
+ * author: chengpeiquan
+ * homepage: https://vue3.chengpeiquan.com
+ */
+import{_ as t,v as c,b as r,t as s,O as n,R as l,M as p}from"./chunks/framework.0d8bea05.js";const q=JSON.parse('{"title":"工程化的前期准备","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"guide.md","filePath":"guide.md"}'),y={name:"guide.md"},D=l(`工程化的前期准备 对于刚刚迈入前端工程化、或者还没有接触过前端工程化的开发者,从传统的用 HTML + CSS + JS 手写页面的认知阶段走到工程化的世界,会面对翻天覆地的变化,需要先学习一些入门准备知识。
这一章会介绍一些前置的知识点科普,方便开始学习 Vue3 的时候,不会对一些基本的认知和操作存在太多疑惑。
TIP
本指南需要具备一定的 HTML 、 CSS 和 JavaScript 基础,如果完全不懂,请先对这三个知识点进行一些入门的学习。
命令行工具 在前端工程化开发过程中,已经离不开各种命令行操作,例如:管理项目依赖、本地服务启动、打包构建,还有拉取代码 / 提交代码这些 Git 操作等等。
命令行界面( Command-line Interface ,缩写 CLI ),是一种通过命令行来实现人机交互的工具,需要提前准备好命令行界面工具。
如果有所留意,会发现很多工具都可以实现命令行操作,比如:命令行界面( CLI )、终端( Terminal )、 Shell 、控制台( Console )等等。
从完整功能看,它们之间确实有许多区别,不过对于前端开发者来说,日常的命令行交互需要用到的功能不会特别多,所以后面会统一一些名词,减少理解上的偏差。
交互行为 统一代替名词 代替名词解释 输入 命令行 需要输入命令的时候,会统一用 “命令行” 来指代。 输出 控制台 鉴于前端开发者更多接触的是浏览器的 Console 控制台, 所以也是会用 “控制台” 来指代。
Windows 在 Windows 平台,可以使用自带的 CMD 或者 Windows PowerShell 工具。
但为了更好的开发体验,推荐使用以下工具(需要下载安装),可以根据自己的喜好选择其一:
笔者在 Windows 台式机上是使用 Windows Terminal 比较多,在此之前是用 CMDer ,两者的设计和体验都非常优秀,当然,还有颜值。
macOS 如果使用的是 Mac 系统,可以直接使用系统自带的 “终端” 工具,笔者在 MacBook 上是使用自带的终端进行开发。
TIP
其实只要能正常使用命令行,对于前端工程师来说就可以满足日常需求,但选择更喜欢的工具,可以让自己的开发过程更为身心愉悦!
安装 Node.js 环境 安装好命令行工具之后,来安装 Node 的开发环境。
下载和安装 Node 在 Node.js 官网提供了安装包的下载,不论是使用 Windows 系统还是 MacOS 系统, Node 都提供了对应的安装包,直接下载安装包并运行即可安装到的电脑里,就可以用来开发的项目了。
点击访问:Node.js 官网下载
安装后,打开的 命令行工具 ,输入以下命令即可查看是否安装成功:
如果已成功安装,会在控制台输出当前的 Node 版本号。
版本之间的区别 可以看到官网标注了 LTS 和 Current 两个系列,并且对应了不同的版本号。
Current 版本 Current 是最新发布版本,或者叫 “尝鲜版” ,可以在这个系列体验到最新的功能,但也可能会有一些意想不到的问题和兼容性要处理。
每六个月会发布一次 Current 大版本,新的偶数版本( e.g. v16.x.x )会在每年的 4 月份发布,奇数版本( e.g. v17.x.x )会在每年的 10 月份发布。
也就是说,所有版本都会有 Current 版本阶段,这个阶段会持续 6 个月的时间,期间会被活跃的维护和变更,在发布满 6 个月后,奇偶数版本会有不同的结果:
大版本号是奇数的,将变为不支持状态,不会进入 LTS 版本。 大版本号是偶数的,会按照发布节点进入 LTS ,并且作为活跃状态投入使用。 TIP
除非是狂热的 Node 开发探索者,否则不应该选择 Current 系列(特别是在生产环境),应该选择未被 EOL 的 LTS 系列作为的项目运行环境,详见下方的 LTS 版本 说明。
LTS 版本 LTS ,全称 Long Time Support ,长期维护版本,这个系列代表着稳定,建议首次下载以及后续的每次升级都选择 LTS 版本,减少开发过程中的未知问题出现。
每个 LTS 版本的大版本号都是偶数,并且会有 3 个阶段的生命周期:
生命周期 含义 说明 Active 活跃阶段 每个从 Current 进入 LTS 的偶数版本,都会有 18 个月的时间被积极维护和升级。 Maintenance 维护阶段 活跃阶段达到 18 个月后,会进入为期 12 个月的维护阶段,期间只会进行错误修复和安全补丁。 End of Life 结束阶段 简称 EOL ,在维护阶段达到期限之后,该版本进入 EOL 阶段,将不再维护,也就是说,每个 LTS 版本最长会有 30 个月的维护时间,之后将不再进行维护。
是否需要经常更新版本 不论是 LTS 还是 Current ,每个系列下面都还有不同的大版本和小版本,是不是每次都必须及时更新到最新版呢?
当然不是,完全可以依照的项目技术栈依赖的最低 Node 版本去决定是否需要升级,不过如果条件允许,还是建议至少要把大版本升级到最新的 LTS 版本。
TIP
关于 Node.js 的版本发布时间表可以在官方 GitHub 的 Release 仓库 查看。
基础的 Node 项目 在安装和配置完 Node.js 之后,接下来了解 Node 项目的一些基础组成,这有助于开启前端工程化开发大门。
TIP
当前文档所演示的 hello-node 项目已托管至 learning-vue3/hello-node 仓库,可使用 Git 克隆命令拉取至本地:
bash # 从 GitHub 克隆
+git clone https://github.com/learning-vue3/hello-node.git
+
+# 如果 GitHub 访问失败,可以从 Gitee 克隆
+git clone https://gitee.com/learning-vue3/hello-node.git
成品项目可作为学习过程中的代码参考,但更建议按照教程的讲解步骤,从零开始亲手搭建一个新项目并完成 node 开发的体验,可以更有效的提升学习效果。
初始化一个项目 如果想让一个项目成为 Node 项目,只需要在命令行 cd
到项目所在的目录,执行初始化命令:
之后命令行会输出一些提示,以及一些问题,可以根据的实际情况填写项目信息,例如:
bash package name: (demo) hello-node
以上面这个问题为例:
冒号左边的 package name
是问题的题干,会询问要输入什么内容。
冒号右边的括号内容 (demo)
是 Node 为推荐的答案(不一定会出现这个推荐值),如果觉得 OK ,可以直接按回车确认,进入下一道题。
冒号右边的 hello-node
是输入的答案(如果选择了推荐的答案,则这里为空),这个答案会写入到项目信息文件里。
当回答完所有问题之后,会把填写的信息输出到控制台,确认无误后,回车完成初始化的工作。
bash {
+ "name" : " hello-node " ,
+ "version" : " 1.0.0 " ,
+ "description" : " A demo about Node.js. " ,
+ "main" : " index.js " ,
+ "scripts" : {
+ "test" : " echo \\" Error: no test specified \\" && exit 1 "
+ } ,
+ "author" : " chengpeiquan " ,
+ "license" : " MIT "
+}
+
+
+Is this OK? (yes)
如果觉得问题太多,太繁琐了,可以直接加上 -y
参数,这样会以 Node 推荐的答案帮快速生成项目信息。
了解 package.json 在完成 项目的初始化 之后,会发现在项目的根目录下出现了一个名为 package.json
的 JSON 文件。
这是 Node 项目的清单,里面记录了这个项目的基础信息、依赖信息、开发过程的脚本行为、发布相关的信息等等,未来将在很多项目里看到它的身影。
TIP
它必须是 JSON 文件,不可以是存储了 JavaScript 对象字面量的 JS 文件。
如果是按照上面初始化一节的操作得到的这个文件,打开它之后,会发现里面存储了在初始化过程中,根据问题确认下来的那些答案,例如:
json {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " description " : " A demo about Node.js. " ,
+ " main " : " index.js " ,
+ " scripts " : {
+ " test " : " echo \\" Error: no test specified \\" && exit 1 "
+ },
+ " author " : " chengpeiquan " ,
+ " license " : " MIT "
+}
package.json 的字段并非全部必填,唯一的要求就是,必须是一个 JSON 文件,所以也可以仅仅写入以下内容:
但在实际的项目中,往往需要填写更完善的项目信息,除了手动维护这些信息之外,在安装 npm 包等操作时, Node 也会帮写入数据到这个文件里,来了解一些常用字段的含义:
字段名 含义 name 项目名称,如果打算发布成 npm 包,它将作为包的名称 version 项目版本号,如果打算发布成 npm 包,这个字段是必须的,遵循 语义化版本号 的要求 description 项目的描述 keywords 关键词,用于在 npm 网站上进行搜索 homepage 项目的官网 URL main 项目的入口文件 scripts 指定运行脚本的命令缩写,常见的如 npm run build
等命令就在这里配置,详见 脚本命令的配置 author 作者信息 license 许可证信息,可以选择适当的许可证进行开源 dependencies 记录当前项目的生产依赖,安装 npm 包时会自动生成,详见:依赖包和插件 devDependencies 记录当前项目的开发依赖,安装 npm 包时会自动生成,详见:依赖包和插件 type 配置 Node 对 CJS 和 ESM 的支持
其中最后的 type 字段是涉及到模块规范的支持,它有两个可选值: commonjs
和 module
,其默认值为 commonjs
。
当不设置或者设置为 commonjs
时,扩展名为 .js
和 .cjs
的文件都是 CommonJS 规范的模块,如果要使用 ES Module 规范,需要使用 .mjs
扩展名 当设置为 module
时,扩展名为 .js
和 .mjs
的文件都是 ES Module 规范的模块,如果要使用 CommonJS 规范,需要使用 .cjs
扩展名 关于模块规范可以在 学习模块化设计 一节了解更多。
关于 package.json 的完整的选项可以在 npm Docs 上查阅。
项目名称规则 如果打算发布成 npm 包,它将作为包的名称,可以是普通包名,也可以是范围包的包名。
类型 释义 例子 范围包 具备 @scope/project-name
格式,一般有一系列相关的开发依赖之间会以相同的 scope 进行命名 如 @vue/cli
、 @vue/cli-service
就是一系列相关的范围包 普通包 其他命名都属于普通包 如 vue
、 vue-router
包名有一定的书写规则:
名称必须保持在 1 ~ 214 个字符之间(包括范围包的 @scope/
部分) 只允许使用小写字母、下划线、短横线、数字、小数点(并且只有范围包可以以点或下划线开头) 包名最终成为 URL 、命令行参数或者文件夹名称的一部分,所以名称不能包含任何非 URL 安全字符 TIP
了解这一点有助于在后续工作中,在需要查找技术栈相关包的时候,可以知道如何在 npmjs 上找到它们。
如果打算发布 npm 包,可以通过 npm view <package-name>
命令查询包名是否已存在,如果存在就会返回该包的相关信息。
比如查询 vue
这个包名,会返回它的版本号、许可证、描述等信息:
bash npm view vue
+
+vue@3.2.33 | MIT | deps: 5 | versions: 372
+The progressive JavaScript framework for building modern web UI.
+https://github.com/vuejs/core/tree/main/packages/vue#readme
+
+keywords: vue
+
+# 后面太多信息这里就省略...
如果查询一个不存在的包名,则会返回 404 信息:
bash npm view vue123456
+npm ERR! code E404
+npm ERR! 404 Not Found - GET https://registry.npmjs.org/vue123456 - Not found
+npm ERR! 404
+npm ERR! 404 ' vue123456@latest ' is not in this registry.
+npm ERR! 404 You should bug the author to publish it (or use the name yourself! )
+npm ERR! 404
+npm ERR! 404 Note that you can also install from a
+npm ERR! 404 tarball, folder, http url, or git url.
+
+# 后面太多信息这里就省略...
语义化版本号管理 Node 项目遵循 语义化版本号 的规则,例如 1.0.0
、 1.0.1
、 1.1.0
这样的版本号,本教材的主角 Vue 也是遵循了语义化版本号的发布规则。
建议开发者在入门前端工程化的时候就应该熟悉这套规则,后续的项目开发中,会使用到很多外部依赖,它们也是使用版本号控制来管理代码的发布,每个版本之间可能会有一些兼容性问题,如果不了解版本号的通用规则,很容易在开发中带来困扰。
TIP
现在有很多 CI/CD 流水线作业具备了根据 Git 的 Commit 记录来自动升级版本号,它们也是遵循了语义化版本号规则,版本号的语义化在前端工程里有重大的意义。
基本格式与升级规则 版本号的格式为: Major.Minor.Patch
(简称 X.Y.Z
),它们的含义和升级规则如下:
英文 中文 含义 Major 主版本号 当项目作了大量的变更,与旧版本存在一定的不兼容问题 Minor 次版本号 做了向下兼容的功能改动或者少量功能更新 Patch 修订号 修复上一个版本的少量 BUG
一般情况下,三者均为正整数,并且从 0
开始,遵循这三条注意事项:
当主版本号升级时,次版本号和修订号归零 当次版本号升级时,修订号归零,主版本号保持不变 当修订号升级时,主版本号和次版本号保持不变 下面以一些常见的例子帮助快速理解版本号的升级规则:
如果不打算发布,可以默认为 0.0.0
,代表它并不是一个进入发布状态的包 在正式发布之前,可以将其设置为 0.1.0
发布第一个测试版本,自此,代表已进入发布状态,但还处于初期开发阶段,这个阶段可能经常改变 API ,但不需要频繁地更新主版本号 在 0.1.0
发布后,修复了 BUG ,下一个版本号将设置为 0.1.1
,即更新了一个修订号 在 0.1.1
发布后,有新的功能发布,下一个版本号可以升级为 0.2.0
,即更新了一个次版本号 当觉得这个项目已经功能稳定、没有什么 BUG 了,决定正式发布并给用户使用时,那么就可以进入 1.0.0
正式版了 版本标识符 以上是一些常规的版本号升级规则,也可以通过添加 “标识符” 来修饰的版本更新:
格式为: Major.Minor.Patch-Identifier.1
,其中的 Identifier
代表 “标识符” ,它和版本号之间使用 -
短横线来连接,后面的 .1
代表当前标识符的第几个版本,每发布一次,这个数字 +1 。
标识符 含义 alpha 内部版本,代表当前可能有很大的变动 beta 测试版本,代表版本已开始稳定,但可能会有比较多的问题需要测试和修复 rc 即将作为正式版本发布,只需做最后的验证即可发布正式版
脚本命令的配置 在工作中,会频繁接触到 npm run dev
启动开发环境、 npm run build
构建打包等操作,这些操作其实是对命令行的一种别名。
它在 package.json 里是存放于 scripts
字段,以 [key: string]: string
为格式的键值对存放数据( key: value
)。
json {
+ " scripts " : {
+ // ...
+ }
+}
其中:
以 Vue CLI 创建的项目为例,它的项目 package.json 文件里就会包括了这样的命令:
json {
+ " scripts " : {
+ " serve " : " vue-cli-service serve " ,
+ " build " : " vue-cli-service build "
+ }
+}
这里的名字是可以自定义的,比如可以把 serve
改成更喜欢的 dev
:
json {
+ " scripts " : {
+ " dev " : " vue-cli-service serve " ,
+ " build " : " vue-cli-service build "
+ }
+}
这样运行 npm run dev
也可以相当于运行了 vue-cli-service serve
。
据笔者所了解,有不少开发者曾经对不同的 Vue CLI 版本提供的 npm run serve
和 npm run dev
有什么区别有过疑问,看到这里应该都明白了吧,可以说没有区别,因为这取决于它对应的命令,而不是取决于它起什么名称。
TIP
如果 value
部分包含了双引号 "
,必须使用转义符 \\
来避免格式问题,例如: \\"
。
可以阅读 npm 关于 scripts 的 完整文档 了解更多用法。
Hello Node 看到这里,对于 Node 项目的基本创建流程和关键信息都有所了解了吧!来写一个 demo ,实际体验一下如何从初始化项目到打印一个 Hello World
到控制台的过程。
请先启动的命令行工具,然后创建一个项目文件夹,这里使用 mkdir
命令:
bash # 语法是 mkdir <dir-name>
+mkdir hello-node
使用 cd
命令进入刚刚创建好的项目目录:
bash # 语法是 cd <dir-path>
+cd hello-node
执行项目初始化,可以回答问题,也可以添加 -y
参数来使用默认配置:
来到这里就得到了一个具有 package.json 的 Node 项目了。
在项目下创建一个 index.js
的 JS 文件,可以像平时一样书写 JavaScript ,输入以下内容并保存:
js console . log ( ' Hello World ' )
然后打开 package.json 文件,修改 scripts 部分如下,也就是配置了一个 "dev": "node index"
命令:
json {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " description " : "" ,
+ " main " : " index.js " ,
+ " scripts " : {
+ " dev " : " node index "
+ },
+ " keywords " : [],
+ " author " : "" ,
+ " license " : " ISC "
+}
在命令行执行 npm run dev
,可以看到控制台打印出了 Hello World
:
bash npm run dev
+
+> demo@1.0.0 dev
+> node index
+
+Hello World
这等价于直接在命令行执行 node index.js
命令,其中 node
是 Node.js 运行文件的命令, index
是文件名,相当于 index.js
,因为 JS 文件名后缀可以省略。
学习模块化设计 在了解 Node 项目之后,就要开始通过编码来加强对 Node.js 的熟悉程度了,但在开始使用之前,还需要了解一些概念。
在未来的日子里(不限于本教程,与前端工程化相关的工作内容息息相关),会频繁的接触到两个词:模块( Module )和包( Package )。
模块和包是 Node 开发最重要的组成部分,不管是全部自己实现一个项目,还是依赖各种第三方轮子来协助开发,项目的构成都离不开这两者。
模块化解决了什么问题 在软件工程的设计原则里,有一个原则叫 “单一职责” 。
假设一个代码块负责了多个职责的功能支持,在后续的迭代过程中,维护成本会极大的增加,虽然只需要修改这个代码块,但需要兼顾职责 1 、职责 2 、职责 3 … 等多个职责的兼容性,稍不注意就会引起工程运行的崩溃。
“单一职责” 的目的就是减少功能维护带来的风险,把代码块的职责单一化,让代码的可维护性更高。
一个完整业务的内部实现,不应该把各种代码都耦合在一起,而应该按照职责去划分好代码块,再进行组合,形成一个 “高内聚,低耦合” 的工程设计。
模块化就是由此而来,在前端工程里,每个单一职责的代码块,就叫做模块( Module ) ,模块有自己的作用域,功能与业务解耦,非常方便复用和移植。
TIP
模块化还可以解决本章开头所讲述的 传统开发的弊端 里提到的大部分问题,随着下面内容一步步深入,将一步步的理解它。
如何实现模块化 在前端工程的发展过程中,不同时期诞生了很多不同的模块化机制,最为主流的有以下几种:
模块化方案 全称 适用范围 CJS CommonJS Node 端 AMD Async Module Definition 浏览器 CMD Common Module Definition 浏览器 UMD Universal Module Definition Node 端和浏览器 ESM ES Module Node 端和浏览器
其中 AMD 、CMD 、 UMD 都已经属于偏过去式的模块化方案,在新的业务里,结合各种编译工具,可以直接用最新的 ESM 方案来实现模块化,所以可以在后续有接触的时候再了解。
ESM ( ES Module ) 是 JavaScript 在 ES6( ECMAScript 2015 )版本推出的模块化标准,旨在成为浏览器和服务端通用的模块解决方案。
CJS ( CommonJS ) 原本是服务端的模块化标准(设计之初也叫 ServerJS ),是为 JavaScript 设计的用于浏览器之外的一个模块化方案, Node 默认支持了该规范,在 Node 12 之前也只支持 CJS ,但从 Node 12 开始,已经同时支持 ES Module 的使用。
至此,不论是 Node 端还是浏览器端, ES Module 是统一的模块化标准了!
但由于历史原因, CJS 在 Node 端依然是非常主流的模块化写法,所以还是值得进行了解,因此下面的内容将主要介绍 CJS 和 ESM 这两种模块化规范是如何实际运用。
TIP
在开始体验模块化的编写之前,请先在电脑里 安装好 Node.js ,然后打开 命令行工具 ,通过 cd
命令进入平时管理项目的目录路径, 初始化一个 Node 项目 。
另外,在 CJS 和 ESM ,一个独立的文件就是一个模块,该文件内部的变量必须通过导出才能被外部访问到,而外部文件想访问这些变量,需要导入对应的模块才能生效。
用 CommonJS 设计模块 虽然现在推荐使用 ES Module 作为模块化标准,但是日后在实际工作的过程中,还是不免会遇到要维护一些老项目,因此了解 CommonJS 还是非常有必要的。
以下简称 CJS 代指 CommonJS 规范。
准备工作 延续在 Hello Node 部分创建的 Node.js demo 项目,先调整一下目录结构:
删掉 index.js
文件 创建一个 src
文件夹,在里面再创建一个 cjs
文件夹 在 cjs
文件夹里面创建两个文件: index.cjs
和 module.cjs
TIP
请注意这里使用了 .cjs
文件扩展名,其实它也是 JS 文件,但这个扩展名是 Node 专门为 CommonJS 规范设计的,可以在 了解 package.json 部分的内容了解更多。
此时目录结构应该如下:
bash hello-node
+│ # 源码文件夹
+├─src
+│ │ # 业务文件夹
+│ └─cjs
+│ │ # 入口文件
+│ ├─index.cjs
+│ │ # 模块文件
+│ └─module.cjs
+│ # 项目清单
+└─package.json
这是一个常见的 Node 项目目录结构,通常源代码都会放在 src
文件夹里面统一管理。
接下来再修改一下 package.json 里面的 scripts 部分,改成如下:
json {
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs "
+ }
+}
后面在命令行执行 npm run dev:cjs
命令,就可以测试刚刚添加的 CJS 模块了。
基本语法 CJS 使用 module.exports
语法导出模块,可以导出任意合法的 JavaScript 类型,例如:字符串、布尔值、对象、数组、函数等等。
使用 require
导入模块,在导入的时候,当文件扩展名是 .js
时,可以只写文件名,而此时使用的是 .cjs
扩展名,所以需要完整的书写。
默认导出和导入 默认导出的意思是,一个模块只包含一个值;而导入默认值则意味着,导入时声明的变量名就是对应模块的值。
在 src/cjs/module.cjs
文件里,写入以下代码,导出一句 Hello World
信息:
js // src/cjs/module.cjs
+module.exports = ' Hello World '
TIP
自己在写入代码的时候,不需要包含文件路径那句注释,这句注释只是为了方便阅读时能够区分代码属于哪个文件,以下代码均如此。
在 src/cjs/index.cjs
文件里,写入以下代码,导入刚刚编写的模块。
js // src/cjs/index.cjs
+const m = require ( ' ./module.cjs ' )
+console . log (m)
在命令行输入 npm run dev:cjs
,可以看到成功输出了 Hello World
信息:
bash npm run dev:cjs
+
+> demo@1.0.0 dev:cjs
+> node src/cjs/index.cjs
+
+Hello World
可以看到,在导入模块时,声明的 m
变量拿到的值,就是整个模块的内容,可以直接使用,此例子中它是一个字符串。
再改动一下,把 src/cjs/module.cjs
改成如下,这次导出一个函数:
js // src/cjs/module.cjs
+module.exports = function foo () {
+ console . log ( ' Hello World ' )
+}
相应的,这次变成了导入一个函数,所以可以执行它:
js // src/cjs/index.cjs
+const m = require ( ' ./module.cjs ' )
+m ()
得到的结果也是打印一句 Hello World
,不同的是,这一次的打印行为是在模块里定义的,入口文件只是执行模块里的函数。
bash npm run dev:cjs
+
+> demo@1.0.0 dev:cjs
+> node src/cjs/index.cjs
+
+Hello World
命名导出和导入 默认导出的时候,一个模块只包含一个值,有时候如果想把很多相同分类的函数进行模块化集中管理,例如想做一些 utils 类的工具函数文件、或者是维护项目的配置文件,全部使用默认导出的话,会有非常多的文件要维护。
那么就可以用到命名导出,这样既可以导出多个数据,又可以统一在一个文件里维护管理,命名导出是先声明多个变量,然后通过 {}
对象的形式导出。
再来修改一下 src/cjs/module.cjs
文件,这次改成如下:
js // src/cjs/module.cjs
+function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+const bar = ' Hello World from bar. '
+
+module.exports = {
+ foo ,
+ bar ,
+}
这个时候通过原来的方式去拿模块的值,会发现无法直接获取到函数体或者字符串的值,因为打印出来的也是一个对象。
js // src/cjs/index.cjs
+const m = require ( ' ./module.cjs ' )
+console . log (m)
控制台输出:
bash npm run dev:cjs
+
+> demo@1.0.0 dev:cjs
+> node src/cjs/index.cjs
+
+{ foo: [Function: foo], bar: ' Hello World from bar. ' }
需要通过 m.foo()
、 m.bar
的形式才可以拿到值。
此时可以用一种更方便的方式,利用 ES6 的对象解构来直接拿到变量:
js // src/cjs/index.cjs
+const { foo , bar } = require ( ' ./module.cjs ' )
+foo ()
+console . log (bar)
这样子才可以直接调用变量拿到对应的值。
导入时重命名 以上都是基于非常理想的情况下使用模块,有时候不同的模块之间也会存在相同命名导出的情况,来看看模块化是如何解决这个问题的。
src/cjs/module.cjs
文件保持不变,依然导出这两个变量:
js // src/cjs/module.cjs
+function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+const bar = ' Hello World from bar. '
+
+module.exports = {
+ foo ,
+ bar ,
+}
这次在入口文件里也声明一个 foo
变量,在导入的时候对模块里的 foo
进行了重命名操作。
js // src/cjs/index.cjs
+const {
+ foo : foo2 , // 这里进行了重命名
+ bar ,
+} = require ( ' ./module.cjs ' )
+
+// 就不会造成变量冲突
+const foo = 1
+console . log (foo)
+
+// 用新的命名来调用模块里的方法
+foo2 ()
+
+// 这个不冲突就可以不必处理
+console . log (bar)
再次运行 npm run dev:cjs
,可以看到打印出来的结果完全符合预期:
bash npm run dev:cjs
+
+> demo@1.0.0 dev:cjs
+> node src/cjs/index.cjs
+
+1
+Hello World from foo.
+Hello World from bar.
这是利用了 ES6 解构对象的 给新的变量名赋值 技巧。
以上是针对命名导出时的重命名方案,如果是默认导出,那么在导入的时候用一个不冲突的变量名来声明就可以了。
用 ES Module 设计模块 ES Module 是新一代的模块化标准,它是在 ES6( ECMAScript 2015 )版本推出的,是原生 JavaScript 的一部分。
不过因为历史原因,如果要直接在浏览器里使用该方案,在不同的浏览器里会有一定的兼容问题,需要通过 Babel 等方案进行代码的版本转换(可在 控制编译代码的兼容性 一节了解如何使用 Babel )。
因此一般情况下都需要借助构建工具进行开发,工具通常会提供开箱即用的本地服务器用于开发调试,并且最终打包的时候还可以抹平不同浏览器之间的差异。
随着 ESM 的流行,很多新推出的构建工具都默认只支持该方案( e.g. Vite 、 Rollup ),如果需要兼容 CJS 反而需要另外引入插件单独配置。除了构建工具,很多语言也是默认支持 ESM ,例如 TypeScript ,因此了解 ESM 非常重要。
以下简称 ESM 代指 ES Module 规范。
准备工作 继续使用在 用 CommonJS 设计模块 时使用的 hello-node 项目作为 demo ,当然也可以重新创建一个新的。
一样的,先调整一下目录结构:
在 src
文件夹里面创建一个 esm
文件夹 在 esm
文件夹里面创建两个 MJS 文件: index.mjs
和 module.mjs
TIP
注意这里使用了 .mjs
文件扩展名,因为默认情况下, Node 需要使用该扩展名才会支持 ES Module 规范。
也可以在 package.json 里增加一个 "type": "module"
的字段来使 .js
文件支持 ESM ,但对应的,原来使用 CommonJS 规范的文件需要从 .js
扩展名改为 .cjs
才可以继续使用 CJS 。
为了减少理解上的门槛,这里选择了使用 .mjs
新扩展名便于入门,可以在 了解 package.json 部分的内容了解更多。
此时目录结构应该如下:
bash hello-node
+│ # 源码文件夹
+├─src
+│ │ # 上次用来测试 CommonJS 的相关文件
+│ ├─cjs
+│ │ ├─index.cjs
+│ │ └─module.cjs
+│ │
+│ │ # 这次要用的 ES Module 测试文件
+│ └─esm
+│ │ # 入口文件
+│ ├─index.mjs
+│ │ # 模块文件
+│ └─module.mjs
+│
+│ # 项目清单
+└─package.json
同样的,源代码放在 src
文件夹里面管理。
然后再修改一下 package.json 里面的 scripts 部分,参照上次配置 CJS 的格式,增加一个 ESM 版本的 script ,改成如下:
json {
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs " ,
+ " dev:esm " : " node src/esm/index.mjs "
+ }
+}
后面在命令行执行 npm run dev:esm
就可以测试的 ESM 模块了。
TIP
注意, script 里的 .mjs
扩展名不能省略。
另外,在实际项目中,可能不需要做这些处理,因为很多工作脚手架已经帮处理过了,比如 Vue3 项目。
基本语法 ESM 使用 export default
(默认导出)和 export
(命名导出)这两个语法导出模块,和 CJS 一样, ESM 也可以导出任意合法的 JavaScript 类型,例如:字符串、布尔值、对象、数组、函数等等。
使用 import ... from ...
导入模块,在导入的时候,如果文件扩展名是 .js
则可以省略文件名后缀,否则需要把扩展名也完整写出来。
默认导出和导入 ESM 的默认导出也是一个模块只包含一个值,导入时声明的变量名,它对应的数据就是对应模块的值。
在 src/esm/module.mjs
文件里,写入以下代码,导出一句 Hello World
信息:
js // src/esm/module.mjs
+export default ' Hello World '
在 src/esm/index.mjs
文件里,写入以下代码,导入刚刚编写的模块。
js // src/esm/index.mjs
+import m from ' ./module.mjs '
+console . log (m)
在命令行输入 npm run dev:esm
,可以看到成功输出了 Hello World
信息:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+Hello World
可以看到,在导入模块时,声明的 m
变量拿到的值,就是整个模块的内容,可以直接使用,此例子中它是一个字符串。
像在 CJS 的例子里一样,也来再改动一下,把 src/esm/module.mjs
改成导出一个函数:
js // src/esm/module.mjs
+export default function foo () {
+ console . log ( ' Hello World ' )
+}
同样的,这次也是变成了导入一个函数,可以执行它:
js // src/esm/index.mjs
+import m from ' ./module.mjs '
+m ()
一样可以从模块里的函数得到一句 Hello World
的打印信息。
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+Hello World
TIP
可以看到, CJS 和 ESM 的默认导出是非常相似的,在未来如果有老项目需要从 CJS 往 ESM 迁移,大部分情况下只需要把 module.exports
改成 export default
即可。
命名导出和导入 虽然默认导出的时候, CJS 和 ESM 的写法非常相似,但命名导出却完全不同!
在 CJS 里,使用命名导出后的模块数据默认是一个对象,可以导入模块后通过 m.foo
这样的方式去调用对象的属性,或者在导入的时候直接解构拿到对象上的某个属性:
js // CJS 支持导入的时候直接解构
+const { foo } = require ( ' ./module.cjs ' )
但 ES Module 的默认导出不能这样做,例如下面这个例子,虽然默认导出了一个对象:
js // 在 ESM ,通过这样导出的数据也是属于默认导出
+export default {
+ foo : 1 ,
+}
但是无法和 CJS 一样通过大括号的方式导入其中的某个属性:
js // ESM 无法通过这种方式对默认导出的数据进行 “解构”
+import { foo } from ' ./module.mjs '
这样操作在运行过程中,控制台会抛出错误信息:
bash import { foo } from ' ./module.mjs '
+ ^^^
+SyntaxError:
+The requested module ' ./module.mjs ' does not provide an export named ' foo '
正确的方式应该是通过 export
对数据进行命名导出,先将 src/esm/module.mjs
文件修改成如下代码,请留意 export
关键字的使用:
js // src/esm/module.mjs
+export function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+export const bar = ' Hello World from bar. '
通过 export
命名导出的方式,现在才可以使用大括号将它们进行命名导入:
js // src/esm/index.mjs
+import { foo , bar } from ' ./module.mjs '
+
+foo ()
+console . log (bar)
这一次程序可以顺利运行了:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+Hello World from foo.
+Hello World from bar.
那么有没有办法像 CJS 一样使用 m.foo
调用对象属性的方式一样,去使用这些命名导出的模块呢?
答案是肯定的!命名导出支持使用 * as 变量名称
的方式将其所有命名挂在某个变量上,该变量是一个对象,每一个导出的命名都是其属性:
ts // src/esm/index.mjs
+// 注意这里使用了另外一种方式,将所有的命名导出都挂在了 \`m\` 变量上
+import * as m from ' ./module.mjs '
+
+console . log ( typeof m)
+console . log (Object . keys (m))
+
+m . foo ()
+console . log (m . bar)
运行 npm run dev:esm
,将输出:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+object
+[ ' bar ' , ' foo ' ]
+Hello World from foo.
+Hello World from bar.
导入时重命名 接下来看看 ESM 是如何处理相同命名导出的问题,项目下的模块文件依然保持不变,还是导出两个变量:
js // src/esm/module.mjs
+export function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+export const bar = ' Hello World from bar. '
入口文件里面,也声明一个 foo
变量,然后导入的时候对模块里的 foo
进行重命名操作:
js // src/esm/index.mjs
+import {
+ foo as foo2 , // 这里进行了重命名
+ bar
+} from ' ./module.mjs '
+
+// 就不会造成变量冲突
+const foo = 1
+console . log (foo)
+
+// 用新的命名来调用模块里的方法
+foo2 ()
+
+// 这个不冲突就可以不必处理
+console . log (bar)
可以看到,在 ESM 的重命名方式和 CJS 是完全不同的,它是使用 as
关键字来操作,语法为 <old-name> as <new-name>
。
现在再次运行 npm run dev:esm
,可以看到打印出来的结果也是完全符合预期了:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+1
+Hello World from foo.
+Hello World from bar.
以上是针对命名导出时的重命名方案,如果是默认导出,和 CJS 一样,在导入的时候用一个不冲突的变量名来声明就可以了。
在浏览器里访问 ESM ES Module 除了支持在 Node 环境使用,还可以和普通的 JavaScript 代码一样在浏览器里运行。
要在浏览器里体验 ESM ,需要使用现代的主流浏览器(如 Chrome ),并注意其访问限制,例如本地开发不能直接通过 file://
协议在浏览器里访问本地 HTML 内引用的 JS 文件,这是因为浏览器对 JavaScript 的安全性要求,会触发 CORS 错误,因此需要启动本地服务并通过 http://
协议访问。
TIP
CORS (全称 Cross-Origin Resource Sharing )是指跨源资源共享,可以决定浏览器是否需要阻止 JavaScript 获取跨域请求的响应。
现代浏览器默认使用 “同源安全策略” ,这里的 “源” 指 URL 的 origin
部分,例如网页可以通过 window.location.origin
获取到如 https://example.com
这样格式的数据,就是网页的 origin
。
默认情况下,非同源的请求会被浏览器拦截,最常见的场景是通过 XHR 或者 Fetch 请求 API 接口,需要网页和接口都部署在同一个域名才可以请求成功,否则就会触发跨域限制。
如果网页和接口不在同一个域名,例如网页部署在 https://web.example.com
,接口部署在 https://api.example.com
,此时需要在 https://api.example.com
的 API 服务端程序里,配置 Access-Control-Allow-Origin: *
允许跨域请求( *
代表允许任意外域访问,也可以指定具体的域名作为白名单列表)。
添加服务端程序 接下来搭建一个简单的本地服务,并通过 HTML 文件来引入 ESM 模块文件,体验浏览器端如何使用 ESM 模块。
在 hello-node 项目的根目录下创建名为 server 的文件夹(与 src 目录同级),并添加 index.js 文件,敲入以下代码:
js // server/index.js
+const { readFileSync } = require ( ' fs ' )
+const { resolve } = require ( ' path ' )
+const { createServer } = require ( ' http ' )
+
+/**
+ * 判断是否 ESM 文件
+ */
+function isESM ( url ) {
+ return String ( url ) . endsWith ( ' mjs ' )
+}
+
+/**
+ * 获取 MIME Type 信息
+ * @ tips \`.mjs\` 和 \`.js\` 一样,都使用 JavaScript 的 MIME Type
+ */
+function mimeType ( url ) {
+ return isESM ( url ) ? ' application/javascript ' : ' text/html '
+}
+
+/**
+ * 获取入口文件
+ * @ returns 存放在本地的文件路径
+ */
+function entryFile ( url ) {
+ const file = isESM ( url ) ? \` ../src/esm \${ url }\` : ' ./index.html '
+ return resolve ( __dirname , file )
+}
+
+/**
+ * 创建 HTTP 服务
+ */
+const app = createServer ( ( request , response ) => {
+ // 获取请求时的相对路径,如网页路径、网页里的 JS 文件路径等
+ const { url } = request
+
+ // 转换成对应的本地文件路径并读取其内容
+ const entry = entryFile ( url )
+ const data = readFileSync ( entry , ' utf-8 ' )
+
+ // 需要设置正确的响应头信息,浏览器才可以正确响应
+ response . writeHead ( 200 , { ' Content-Type ' : mimeType ( url ) } )
+ response . end ( data )
+} )
+
+/**
+ * 在指定的端口号启动本地服务
+ */
+const port = 8080
+app . listen (port , ' 0.0.0.0 ' , () => {
+ console . log ( \` Server running at: \` )
+ console . log ()
+ console . log ( \` ➜ Local: http://localhost: \${ port } / \` )
+ console . log ()
+} )
这是一个基础的 Node.js 服务端程序,利用了 HTTP 模块启动本地服务,期间利用 FS 模块的 I/O 能力对本地文件进行读取,而 PATH 模块则简化了文件操作过程中的路径处理和兼容问题(例如众所周知的 Windows 与 macOS 的路径斜杆问题)。
TIP
在这段服务端程序代码里,请留意 mimeType
方法,要让浏览器能够正确解析 .mjs
文件,需要在服务端响应文件内容时,将其 MIME Type 设置为 和 JavaScript 文件一样,这一点非常重要。
并且需要注意传递给 readFileSync
API 的文件路径是否与真实存在的文件路径匹配,如果启动服务时,在 Node 控制台报了 no such file or directory
的错误,请检查是否因为笔误写错了文件名称,或者文件路径多了空格等情况。
添加入口页面 继续在 server 目录下添加一个 index.html 并写入以下 HTML 代码,它将作为网站的首页文件:
TIP
可以在 VSCode 先新建一个空文件,文件语言设置为 HTML ,并写入英文感叹号 !
,再按 Tab 键(或者鼠标选择第一个代码片段提示),可快速生成基础的 HTML 结构。
html <!-- server/index.html -->
+<! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > ESM run in browser </ title >
+ </ head >
+ < body >
+ < script type = " module " src = " ./index.mjs " ></ script >
+ </ body >
+</ html >
请注意在 <script />
标签这一句代码上,比平时多了一个 type="module"
属性,这代表这个 script 是使用了 ESM 模块,而 src
属性则对应指向了上文在 src/esm 目录下的入口文件名。
之所以无需使用 ../src/esm/index.mjs
显式的指向真实目录,是因为在 添加服务端程序 时,已通过服务端代码里的 entryFile
方法重新指向了文件所在的真实路径,所以在 HTML 文件里可以使用 ./
简化文件路径。
启动服务并访问 打开 package.json 文件,在 scripts
字段追加一个 serve
命令如下:
json {
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs " ,
+ " dev:esm " : " node src/esm/index.mjs " ,
+ " serve " : " node server/index.js "
+ }
+}
在命令行运行 npm run serve
即可启动本地服务:
bash ❯ npm run serve
+
+> demo@1.0.0 serve
+> node server/index.js
+
+Server running at:
+
+ ➜ Local: http://localhost:8080/
根据命令行提示,在浏览器访问 http://localhost:8080/
地址,即可访问本地服务。
TIP
如遭遇端口号冲突,可在 server/index.js 的 const port = 8080
代码处修改为其他端口号。
因为在编写 HTML 文件时没有写入内容,只引入了 ESM 模块文件,因此需要按 F12 唤起浏览器的控制台查看 Log ,可以看到控制台根据模块的文件内容,输出了这三句 Log (如果没有 Log ,可在控制台唤起的情况下按 F5 重新载入页面):
bash 1 index.mjs:8
+Hello World from foo. module.mjs:2
+Hello World from bar. index.mjs:14
分别来自 src/esm/index.mjs 本身的 console.log
语句,以及 import
进来的 module.mjs 里的 console.log
语句。
如果未能出现这三句 Log ,请留意 .mjs
文件内容是否为上一小节最后的内容:
src/esm/index.mjs 文件内容为:
js // src/esm/index.mjs
+import {
+ foo as foo2 , // 这里进行了重命名
+ bar ,
+} from ' ./module.mjs '
+
+// 就不会造成变量冲突
+const foo = 1
+console . log (foo)
+
+// 用新的命名来调用模块里的方法
+foo2 ()
+
+// 这个不冲突就可以不必处理
+console . log (bar)
src/esm/module.mjs 文件内容为:
js // src/esm/module.mjs
+export function foo () {
+ console . log ( ' Hello World from foo. ' )
+}
+
+export const bar = ' Hello World from bar. '
内联的 ESM 代码 到目前为止, server/index.html 文件里始终是通过文件的形式引入 ESM 模块,其实 <script type="module" />
也支持编写内联代码,和普通的 <script />
标签用法相同:
html < script type = " module " >
+ // ESM 模块的 JavaScript 代码
+</ script >
请移除 <script />
标签的 src
属性,并在标签内写入 src/esm/index.mjs 文件里的代码,现在该 HTML 文件的完整代码如下:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > ESM run in browser </ title >
+ </ head >
+ < body >
+ <!-- 标签内的代码就是 src/esm/index.mjs 的代码 -->
+ < script type = " module " >
+ import {
+ foo as foo2 , // 这里进行了重命名
+ bar ,
+ } from ' ./module.mjs '
+
+ // 就不会造成变量冲突
+ const foo = 1
+ console . log (foo)
+
+ // 用新的命名来调用模块里的方法
+ foo2 ()
+
+ // 这个不冲突就可以不必处理
+ console . log (bar)
+ </ script >
+ </ body >
+</ html >
回到浏览器刷新 http://localhost:8080/
,可以看到浏览器控制台依然输出了和引入 src="./index.mjs"
时一样的 Log 信息:
bash 1 (index):21
+Hello World from foo. module.mjs:2
+Hello World from bar. (index):27
了解模块导入限制 虽然以上例子可以完美地在浏览器里引用现成的 ESM 模块代码并运行,但不代表工程化项目下所有的 ES Module 模块化方式都适合浏览器。
先做一个小尝试,将 src/esm/index.mjs 文件内容修改如下,导入项目已安装的 md5 工具包:
js // src/esm/index.mjs
+import md5 from ' md5 '
+console . log ( md5 ( ' Hello World ' ))
回到浏览器刷新 http://localhost:8080/
,观察控制台,可以发现出现了一个红色的错误信息:
bash Uncaught TypeError: Failed to resolve module specifier " md5 " .
+Relative references must start with either " / " , " ./ " , or " ../ " .
这是因为不论是通过 <script type="module" />
标签还是通过 import
语句导入,模块的路径都必须是以 /
、 ./
或者是 ../
开头,因此无法直接通过 npm 包名进行导入。
这种情况下需要借助另外一个 script 类型: importmap
,在 server/index.html 里追加 <script type="importmap" />
这一段代码:
html <! DOCTYPE html >
+< html lang = " en " >
+ < head >
+ < meta charset = " UTF-8 " />
+ < meta http-equiv = " X-UA-Compatible " content = " IE=edge " />
+ < meta name = " viewport " content = " width=device-width, initial-scale=1.0 " />
+ < title > ESM run in browser </ title >
+ </ head >
+ < body >
+ <!-- 注意需要先通过 \`importmap\` 引入 npm 包的 CDN -->
+ < script type = " importmap " >
+ {
+ "imports": {
+ "md5": "https://esm.run/md5"
+ }
+ }
+ </ script >
+
+ <!-- 然后才能在 \`module\` 里 \`import xx from 'xx'\` -->
+ < script type = " module " src = " ./index.mjs " ></ script >
+ </ body >
+</ html >
再次刷新页面,可以看到控制台成功输出了 b10a8db164e0754105b7a99be72e3fe5
这个字符串,也就是 Hello World
被 MD5 处理后的结果。
可以看到 importmap
的声明方式和 package.json 的 dependencies 字段非常相似, JSON 的 key 是包名称, value 则是支持 ESM 的远程地址。
TIP
Import Maps 的运行机制是通过 import 映射来控制模块说明符的解析,类似于构建工具常用的 alias
别名机制。
这是一个现代浏览器才能支持的新特性,建议使用 Chrome 最新版本体验完整功能,可以在其 GitHub 仓库 查看更多用法。
上方例子里, md5 对应的远程地址是使用了来自 esm.run
网站的 URL ,而不是 npm 包同步到 jsDelivr CDN 或者 UNPKG CDN 的地址,这是因为 md5 这个包本身不支持 ES Module ,需要通过 esm.run
这个网站进行在线转换才可以在 <script type="module" />
上使用。
`,323),F=l(`该网站的服务是 jsDelivr CDN 所属的服务商提供,因此也可以通过 jsDelivr CDN 的 URL 添加 /+esm
参数来达到转换效果,以 md5 包为例:
bash # 默认是一个 CJS 包
+https://cdn.jsdelivr.net/npm/md5
+
+# 可添加 \`/+esm\` 参数变成 ESM 包
+https://cdn.jsdelivr.net/npm/md5/+esm
总的来说,现阶段在浏览器使用 ES Module 并不是一个很好的选择,建议开发者还是使用构建工具来开发,工具可以抹平这些浏览器差异化问题,降低开发成本。
认识组件化设计 学习完模块化设计之后,未来在 Vue 的工程化开发过程中,还会遇到一个新的概念,那就是 “组件” 。
什么是组件化 模块化属于 JavaScript 的概念,但作为一个页面,都知道它是由 HTML + CSS + JS 三部分组成的,既然 JS 代码可以按照不同的功能、需求划分成模块,那么页面是否也可以呢?
答案是肯定的!组件化就是由此而来。
在前端工程项目里,页面可以理解为一个积木作品,组件则是用来搭建这个作品的一块又一块积木。
`,9),i=l(`解决了什么问题 模块化属于 JavaScript 的概念,把代码块的职责单一化,一个函数、一个类都可以独立成一个模块。
但这只解决了逻辑部分的问题,一个页面除了逻辑,还有骨架( HTML )和样式( CSS ),组件就是把一些可复用的 HTML 结构和 CSS 样式再做一层抽离,然后再放置到需要展示的位置。
常见的组件有:页头、页脚、导航栏、侧边栏… 甚至小到一个用户头像也可以抽离成组件,因为头像可能只是尺寸、圆角不同而已。
每个组件都有自己的 “作用域” , JavaScript 部分利用 模块化 来实现作用域隔离, HTML 和 CSS 代码则借助 Style Scoped 来生成独有的 hash ,避免全局污染,这些方案组合起来,使得组件与组件之间的代码不会互相影响。
如何实现组件化 在 Vue ,是通过 Single-File Component (简称 SFC , .vue
单文件组件)来实现组件化开发。
一个 Vue 组件是由三部分组成的:
vue < template >
+ <!-- HTML 代码 -->
+</ template >
+
+< script >
+// JavaScript 代码
+</ script >
+
+< style scoped >
+/* CSS 代码 */
+</ style >
在后面的 单组件的编写 一章中,会详细介绍如何编写一个 Vue 组件。
依赖包和插件 在实际业务中,经常会用到各种各样的插件,插件在 Node 项目里的体现是一个又一个的依赖包。
虽然也可以把插件的代码文件手动放到的源码文件夹里引入,但并不是一个最佳的选择,本节内容将带了解 Node 的依赖包。
什么是包 在 Node 项目里,包可以简单理解为模块的集合,一个包可以只提供一个模块的功能,也可以作为多个模块的集合集中管理。
包通常是发布在官方的包管理平台 npmjs 上面,开发者需要使用的时候,可以通过包管理器安装到项目里,并在的代码里引入,开箱即用(详见: 依赖包的管理 )。
使用 npm 包可以减少在项目中重复造轮子,提高项目的开发效率,也可以极大的缩小项目源码的体积(详见:什么是 node_modules )。
包管理平台官网:https://www.npmjs.com
什么是 node_modules node_modules 是 Node 项目下用于存放已安装的依赖包的目录,如果不存在,会自动创建。
如果是本地依赖,会存在于项目根目录下,如果是全局依赖,会存在于环境变量关联的路径下,详见下方的管理依赖部分内容的讲解。
TIP
一般在提交项目代码到 Git 仓库或者的服务器上时,都需要排除 node_modules 文件夹的提交,因为它非常大。
如果托管在 Git 仓库,可以在 .gitignore 文件里添加 node_modules
作为要排除的文件夹名称。
什么是包管理器 包管理器( Package Manager )是用来管理依赖包的工具,比如:发布、安装、更新、卸载等等。
Node 默认提供了一个包管理器 npm
,在安装 Node.js 的时候,默认会一起安装 npm 包管理器,可以通过以下命令查看它是否正常。
如果正常,将会输出相应的版本号。
依赖包的管理 接下来会以 npm 作为默认的包管理器,来了解如何在项目里管理依赖包。
配置镜像源 在国内,直接使用 npm 会比较慢,可以通过绑定 npm Mirror 中国镜像站 的镜像源来提升依赖包的下载速度。
可以先在命令行输入以下命令查看当前的 npm 配置:
bash npm config get registry
+# https://registry.npmjs.org/
默认情况下,会输出 npm 官方的资源注册表地址,接下来在命令行上输入以下命令,进行镜像源的绑定:
bash npm config set registry https://registry.npmmirror.com
可以再次运行查询命令来查看是否设置成功:
bash npm config get registry
+# https://registry.npmmirror.com/
可以看到已经成功更换为中国镜像站的地址了,之后在安装 npm 包的时候,速度会有很大的提升!
如果需要删除自己配置的镜像源,可以输入以下命令进行移除,移除后会恢复默认设置:
bash npm config rm registry
TIP
如果之前已经绑定过 npm.taobao
系列域名,也请记得更换成 npmmirror
这个新的域名!
随着新的域名已经正式启用,老 npm.taobao.org
和 registry.npm.taobao.org
域名在 2022 年 05 月 31 日零时后不再提供服务。
详见:【望周知】淘宝 npm 镜像站喊你切换新域名啦
本地安装 项目的依赖建议优先选择本地安装,这是因为本地安装可以把依赖列表记录到 package.json 里,多人协作的时候可以减少很多问题出现,特别是当本地依赖与全局依赖版本号不一致的时候。
生产依赖 执行 npm install
的时候,添加 --save
或者 -S
选项可以将依赖安装到本地,并列为生产依赖。
TIP
需要提前在命令行 cd
到的项目目录下再执行安装。
另外, --save
或者 -S
选项在实际使用的时候可以省略,因为它是默认选项。
bash npm install --save < package-nam e >
可以在项目的 package.json
文件里的 dependencies
字段查看是否已安装成功,例如:
json // package.json
+{
+ // 会安装到这里
+ " dependencies " : {
+ // 以 "包名":"版本号" 的格式写入
+ " vue-router " : " ^4.0.14 "
+ }
+}
生产依赖包会被安装到项目根目录下的 node_modules
目录里。
项目在上线后仍需用到的包,就需要安装到生产依赖里,比如 Vue 的路由 vue-router
就需要以这个方式安装。
开发依赖 执行 npm install
的时候,如果添加 --save-dev
或者 -D
选项,可以将依赖安装到本地,并写入开发依赖里。
TIP
需要提前在命令行 cd
到的项目目录下再执行安装。
bash npm install --save-dev < package-nam e >
可以在项目的 package.json
文件里的 devDependencies
字段查看是否已安装成功,例如:
json // package.json
+{
+ // 会安装到这里
+ " devDependencies " : {
+ // 以 "包名":"版本号" 的格式写入
+ " eslint " : " ^8.6.0 "
+ }
+}
开发依赖包也是会被安装到项目根目录下的 node_modules
目录里。
和生产依赖包不同的点在于,只在开发环境生效,构建部署到生产环境时可能会被抛弃,一些只在开发环境下使用的包,就可以安装到开发依赖里,比如检查代码是否正确的 ESLint
就可以用这个方式安装。
全局安装 执行 npm install
的时候,如果添加 --global
或者 -g
选项,可以将依赖安装到全局,它们将被安装在 配置环境变量 里配置的全局资源路径里。
bash npm install --global < package-nam e >
TIP
Mac 用户需要使用 sudo
来提权才可以完成全局安装。
另外,可以通过 npm root -g
查看全局包的安装路径。
一般情况下,类似于 @vue/cli
之类的脚手架会提供全局安装的服务,安装后,就可以使用 vue create xxx
等命令直接创建 Vue 项目了。
但不是每个 npm 包在全局安装后都可以正常使用,请阅读 npm 包的主页介绍和使用说明。
版本控制 有时候一些包的新版本不一定适合的老项目,因此 npm 也提供了版本控制功能,支持通过指定的版本号或者 Tag 安装。
语法如下,在包名后面紧跟 @
符号,再紧跟版本号或者 Tag 名称。
bash npm install < package-nam e > @ < version | tag>
例如:
现阶段 Vue 默认为 3.x 的版本了,如果想安装 Vue 2 ,可以通过指定版本号的方式安装:
bash npm install vue@2.6.14
或者通过对应的 Tag 安装:
bash npm install vue@legacy
TIP
版本号或者 Tag 名称可以在 npmjs 网站上的包详情页查询。
版本升级 一般来说,直接重新安装依赖包可以达到更新的目的,但也可以通过 npm update
命令来更新。
语法如下,可以更新全部的包:
也可以更新指定的包:
bash npm update < package-nam e >
npm 会检查是否有满足版本限制的更新版本。
卸载 可以通过 npm uninstall
命令来卸载指定的包,和安装一样,卸载也区分了卸载本地依赖包和卸载全局包,不过只有在卸载全局包的时候才需要添加选项,默认只卸载当前项目下的本地包。
本地卸载:
bash npm uninstall < package-nam e >
全局卸载:
bash npm uninstall --global < package-nam e >
TIP
Mac 用户需要使用 sudo
来提权才可以完成全局卸载。
如何使用包 在了解了 npm 包的常规操作之后,通过一个简单的例子来了解如何在项目里使用 npm 包。
继续使用的 Hello Node demo ,或者也可以重新创建一个 demo 。
首先在 命令行工具 通过 cd
命令进入项目所在的目录,用本地安装的方式来把 md5 包 添加到生产依赖,这是一个为提供开箱即用的哈希算法的包,在未来的实际工作中,可能也会用到它,在这里使用它是因为足够简单。
输入以下命令并回车执行:
可以看到控制台提示一共安装了 4 个包,这是因为 md5 这个 npm 包还引用了其他的包作为依赖,需要同时安装才可以正常工作。
bash # 这是安装 md5 之后控制台的信息返回
+added 4 packages, and audited 5 packages in 2 s
+
+found 0 vulnerabilities
此时项目目录下会出现一个 node_modules 文件夹和一个 package-lock.json 文件:
bash hello-node
+│ # 依赖文件夹
+├─node_modules
+│ # 源码文件夹
+├─src
+│ # 锁定安装依赖的版本号
+├─package-lock.json
+│ # 项目清单
+└─package.json
先打开 package.json ,可以看到已经多出了一个 dependencies
字段,这里记录了刚刚安装的 md5 包信息。
json {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " description " : "" ,
+ " main " : " index.js " ,
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs " ,
+ " dev:esm " : " node src/esm/index.mjs " ,
+ " serve " : " node server/index.js "
+ },
+ " keywords " : [],
+ " author " : "" ,
+ " license " : " ISC " ,
+ " dependencies " : {
+ " md5 " : " ^2.3.0 "
+ }
+}
来到这里可能会有一连串的疑问:
为什么只安装了一个 md5 ,但控制台提示安装了 4 个包? 为什么 package.json 又只记录了 1 个 md5 包信息? 为什么提示审核了 5 个包,哪里来的第 5 个包? 不要着急,请先打开 package-lock.json 文件,这个文件是记录了锁定安装依赖的版本号信息(由于篇幅原因,这里的展示省略了一些包的细节):
json {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " lockfileVersion " : 2 ,
+ " requires " : true,
+ " packages " : {
+ "" : {
+ " name " : " hello-node " ,
+ " version " : " 1.0.0 " ,
+ " license " : " ISC " ,
+ " dependencies " : {
+ " md5 " : " ^2.3.0 "
+ }
+ },
+ " node_modules/charenc " : {
+ " version " : " 0.0.2 "
+ // ...
+ },
+ " node_modules/crypt " : {
+ " version " : " 0.0.2 "
+ // ...
+ },
+ " node_modules/is-buffer " : {
+ " version " : " 1.1.6 "
+ // ...
+ },
+ " node_modules/md5 " : {
+ " version " : " 2.3.0 "
+ // ...
+ }
+ },
+ " dependencies " : {
+ " charenc " : {
+ " version " : " 0.0.2 "
+ // ...
+ },
+ " crypt " : {
+ " version " : " 0.0.2 "
+ // ...
+ },
+ " is-buffer " : {
+ " version " : " 1.1.6 "
+ // ...
+ },
+ " md5 " : {
+ " version " : " 2.3.0 " ,
+ // ...
+ " requires " : {
+ " charenc " : " 0.0.2 " ,
+ " crypt " : " 0.0.2 " ,
+ " is-buffer " : " ~1.1.6 "
+ }
+ }
+ }
+}
可以看到这个文件的 dependencies
字段除了 md5 之外,还有另外 3 个包信息,它们就是 md5 包所依赖的另外 3 个 npm 包了,这就解答了为什么一共安装了 4 个 npm 包。
在 node_modules 文件夹下也可以看到以这 4 个包名为命名的文件夹,这些文件夹存放的就是各个包项目发布在 npmjs 平台上的文件。
再看 packages
字段,这里除了罗列出 4 个 npm 包的信息之外,还把项目的信息也列了进来,这就是为什么是提示审核了 5 个包,原因是除了 4 个依赖包,该项目本身也是一个包。
TIP
package-lock.json 文件并不是一成不变的,假如以后 md5 又引用了更多的包,这里记录的信息也会随之增加。
并且不同的包管理器,它的 lock 文件也会不同,如果是使用 yarn 作为包管理器的话,它是生成一个 yarn.lock 文件,而不是 package-lock.json ,有关更多的包管理器,详见 插件的使用 一章。
现在已经安装好 md5 包了,接下来看看具体如何使用它。
通常在包的 npmjs 主页上会有 API 和用法的说明,只需要根据说明操作,打开 src/esm/index.mjs
文件,首先需要导入这个包。
包的导入和在 学习模块化设计 一节了解到的模块导入用法是一样的,只是把 from
后面的文件路径换成了包名。
js // src/esm/index.mjs
+import md5 from ' md5 '
然后根据 md5 的用法,来编写一个小例子,先声明一个原始字符串变量,然后再声明一个使用 md5 加密过的字符串变量,并打印它们:
js // src/esm/index.mjs
+import md5 from ' md5 '
+
+const before = ' Hello World '
+const after = md5 (before)
+console . log ( { before , after } )
在命令行输入 npm run dev:esm
,可以在控制台看到输出了这些内容,成功获得了转换后的结果:
bash npm run dev:esm
+
+> demo@1.0.0 dev:esm
+> node src/esm/index.mjs
+
+{ before: ' Hello World ' , after: ' b10a8db164e0754105b7a99be72e3fe5 ' }
是不是非常简单,其实包的用法和在导入模块的用法可以说是完全一样的,区别主要在于,包是需要安装了才能用,而模块是需要自己编写。
控制编译代码的兼容性 作为一名前端工程师,了解如何控制代码的兼容性是非常重要的能力。
在 “了解前端工程化” 的 为什么要使用构建工具 一节里,已简单介绍过 Polyfill 的作用,以及介绍了构建工具可以通过 Babel 等方案自动化处理代码的兼容问题,这一小节将讲解 Babel 的配置和使用,亲自体验如何控制代码的兼容性转换。
如何查询兼容性 在开始学习使用 Babel 之前,需要先掌握一个小技能:了解如何查询代码在不同浏览器上的兼容性。
说起浏览器兼容性,前端工程师应该都不陌生,特别是初学者很容易会遇到在自己的浏览器上布局正确、功能正常,而在其他人的电脑或者手机上访问就会有布局错位或者运行报错的问题出现,最常见的场景就是开发者使用的是功能强大的 Chrome 浏览器,而产品用户使用了 IE 浏览器。
这是因为网页开发使用的 HTML / CSS / JavaScript 每年都在更新新版本,推出更好用的新 API ,或者废弃部分过时的旧 API ,不同的浏览器在版本更新过程中,对这些新 API 的支持程度并不一致,如果使用了新 API 而没有做好兼容支持,很容易就会在低版本浏览器上出现问题。
为了保证程序可以正确的在不同版本浏览器之间运行,就需要根据产品要支持的目标浏览器范围,去选择兼容性最好的编程方案。
在 Web 开发有一个网站非常知名:Can I use ,只要搜索 API 的名称,它会以图表的形式展示该 API 在不同浏览器的不同版本之间的支持情况,支持 HTML 标签、 CSS 属性、 JavaScript API 等内容的查询。
以 JavaScript ES6 的 class
新特性为例:
`,128),C=l(`可以看到在 Chrome 浏览器需要在 49 版本开始才被完全支持,而 IE 浏览器则全面不支持,如果不做特殊处理(例如引入 Polyfill 方案),那么就需要考虑在编程过程中,是否需要可以直接使用 class
来实现功能,还是寻找其他替代方案。
在工作中,工程师无需关注每一个 API 的具体支持范围,这些工作可以交给工具来处理,下面将介绍 Babel 的使用入门。
Babel 的使用和配置 Babel 是一个 JavaScript 编译器,它可以让开发者仅需维护一份简单的 JSON 配置文件,即可调动一系列工具链将源代码编译为目标浏览器指定版本所支持的语法。
安装 Babel 请打开 hello-node 项目,安装以下几个 Babel 依赖:
bash npm i -D @babel/core @babel/cli @babel/preset-env
此时在 package.json 的 devDependencies
可以看到有了如下三个依赖:
json {
+ " devDependencies " : {
+ " @babel/cli " : " ^7.19.3 " ,
+ " @babel/core " : " ^7.19.3 " ,
+ " @babel/preset-env " : " ^7.19.3 "
+ }
+}
它们的作用分别如下:
依赖 作用 文档 @babel/cli 安装后可以从命令行使用 Babel 编译文件 查看文档 @babel/core Babel 的核心功能包 查看文档 @babel/preset-env 智能预设,可以通过它的选项控制代码要转换的支持版本 查看文档
TIP
在使用 Babel 时,建议在项目下进行本地安装,尽量不选择全局安装,这是因为不同项目可能依赖于不同版本的 Babel ,全局依赖和可能会出现使用上的异常。
添加 Babel 配置 接下来在 hello-node 的根目录下创建一个名为 babel.config.json 的文件,这是 Babel 的配置文件,写入以下内容:
json {
+ " presets " : [
+ [
+ " @babel/preset-env " ,
+ {
+ " targets " : {
+ " chrome " : " 41 "
+ },
+ " modules " : false,
+ " useBuiltIns " : " usage " ,
+ " corejs " : " 3.6.5 "
+ }
+ ]
+ ]
+}
这份配置将以 Chrome 浏览器作为目标浏览器,编译结果将保留 ES Module 规范,可以在 配置文件文档 查看更多配置选项。
这里的 targets.chrome
字段代表编译后要支持的目标浏览器版本号,在 caniuse 查询可知 ES6 的 class 语法 在 Chrome 49 版本之后才被完全支持,而 Chrome 41 或更低的版本是完全不支持该语法,因此先将其目标版本号设置为 41 ,下一步将开始测试 Babel 的编译结果。
使用 Babel 编译代码 在 hello-node 的 src 目录下添加一个 babel 文件夹,并在该文件夹下创建一个 index.js 文件,写入以下代码:
js // src/babel/index.js
+export class Hello {
+ constructor ( name ) {
+ this. name = name
+ }
+
+ say () {
+ return \` Hello \${ this. name }\`
+ }
+}
根据上一步的 Babel 配置,在这里使用 class
语法作为测试代码。
接下来再打开 package.json 文件,添加一个 compile
script 如下:
json {
+ " scripts " : {
+ " dev:cjs " : " node src/cjs/index.cjs " ,
+ " dev:esm " : " node src/esm/index.mjs " ,
+ " compile " : " babel src/babel --out-dir compiled " ,
+ " serve " : " node server/index.js "
+ }
+}
这条命令的含义是:使用 Babel 处理 src/babel 目录下的文件,并输出到根目录下的 compiled 文件夹。
在命令行运行以下命令:
可以看到 hello-node 的根目录下多了一个 compiled 文件夹,里面有一个和源码相同命名的 index.js 文件,它的文件内容如下:
js // compiled/index.js
+function _classCallCheck ( instance , Constructor ) {
+ if ( ! ( instance instanceof Constructor )) {
+ throw new TypeError ( ' Cannot call a class as a function ' )
+ }
+}
+
+function _defineProperties ( target , props ) {
+ for ( var i = 0 ; i < props . length ; i ++ ) {
+ var descriptor = props [ i ]
+ descriptor . enumerable = descriptor . enumerable || false
+ descriptor . configurable = true
+ if ( ' value ' in descriptor ) descriptor . writable = true
+ Object . defineProperty ( target , descriptor . key , descriptor )
+ }
+}
+
+function _createClass ( Constructor , protoProps , staticProps ) {
+ if ( protoProps ) _defineProperties ( Constructor . prototype , protoProps )
+ if ( staticProps ) _defineProperties ( Constructor , staticProps )
+ Object . defineProperty ( Constructor , ' prototype ' , { writable : false } )
+ return Constructor
+}
+
+export var Hello = /*#__PURE__*/ ( function () {
+ function Hello ( name ) {
+ _classCallCheck ( this, Hello )
+
+ this. name = name
+ }
+
+ _createClass ( Hello , [
+ {
+ key : ' say ' ,
+ value : function say () {
+ return \` Hello \${ this. name }\`
+ },
+ },
+ ])
+
+ return Hello
+} )()
由于 Chrome 41 版本不支持 class
语法,因此 Babel 做了大量的工作对其进行转换兼容。
再次打开 babel.config.json ,将 targets.chrome
的版本号调整为支持 class
语法的 Chrome 49 版本:
diff {
+ "presets": [
+ [
+ "@babel/preset-env",
+ {
+ "targets": {
+- "chrome": "41"
++ "chrome": "49"
+ },
+ "modules": false,
+ "useBuiltIns": "usage",
+ "corejs": "3.6.5"
+ }
+ ]
+ ]
+}
再次执行编译,这一次编译后的代码和编译前完全一样:
js // compiled/index.js
+export class Hello {
+ constructor ( name ) {
+ this. name = name
+ }
+
+ say () {
+ return \` Hello \${ this. name }\`
+ }
+}
因为此时配置文件指定的目标浏览器版本已支持该语法,无需转换。
Babel 的使用其实非常简单,了解了这部分知识点之后,如果某一天需要自己控制代码的兼容性,只需要配合官方文档调整 Babel 的配置,处理起来就得心应手了!
`,35);function A(d,u,h,m,g,E){const o=p("ImgWrap"),a=p("ClientOnly"),e=p("GitalkComment");return c(),r("div",null,[D,s(a,null,{default:n(()=>[s(o,{src:"/assets/img/esm-run.jpg",alt:"esm.run 网站上的包转换操作界面"})]),_:1}),F,s(a,null,{default:n(()=>[s(o,{src:"/assets/img/components.png",alt:"把页面拆分成多个组件,降低维护成本(摘自 Vue 官网)"})]),_:1}),i,s(a,null,{default:n(()=>[s(o,{src:"/assets/img/caniuse-es6-classes.jpg",alt:"在 caniuse 网站上查询 ES6 `class` 的兼容情况"})]),_:1}),C,s(a,null,{default:n(()=>[s(e,{issueId:163})]),_:1})])}const f=t(y,[["render",A]]);export{q as __pageData,f as default};
diff --git a/assets/img/book.png b/assets/img/book.png
new file mode 100644
index 0000000000000000000000000000000000000000..bb7473db4320b2fb5efdd4d145c932b25aa2b104
GIT binary patch
literal 54144
zcmV)PK()V#P)00Mgm0{{R3A_kKv00090P)t-s0000M
z7Z>>Y`A||)TwPv6Mn<~3yJ~N6goTAOPE(qin!CHZp`)Yf>gu+(w#3B5yuH2f@bKc|
z;>gL#($dn^)z#zVgovo{^|y;y#D*5`26$u
z`|JJuzWw=<^z)1e2nqf7c*DfR{`;)J<*EGd0S*oh78e!s`Q?V9sQCA&{rQRZ^_u+m
zZ9YCeOiWD1$jDPvRQ~#!`}cwT^+qZ5^ze6hd61Bh?d|RS^-|c`+2Z2jgoTBEet)av?8xWW#^%cM@oUxb
z_Rs6$GH(q;A?Gdo}Zt&<>Z>0n&j}t
z-R!!Ytgqqn=E35-PsYyB=)jSut51)bM2wiU-LanF=sJm)=k1H5qomsJ-L&4s%;T}-
z^xCVete)E0r>Ll>v9sRmnfvlHwc3oX;NIozdeG&c<>xT1-qMrX=!@9lEQghqm6dav
zqS@-F*6Xve+Mkov&Z61K!Qh(I-Z*ELpu^ctpwy?Q*|%=c-CL8MlhV7{=!YJKl%&&?
zeb(mY>SoR3kK5%{$KQUp*mj`Kbdb)au)@W$(_q%+abUv0$lhs#%&~OFvx3gZOLBOV
z%Zy&j;%~~ze8Q7}u()ZypEgrioVmX%L`Yb$<*(!q2`v3p{8+1}mQveJ6MSLYG
zTXKAaQJmwlxs>bP$I9KvdZ3WW=^>+U007VzNkl*+M%{JP1a*nR0!|6X>y=E$#tEnu~o@
zRaIHOYF=J~fO8&(zV8c-Pye@kO-$ommvhFyhBU&H@6YG&UpI$qHf`I;H_dLBZ#7X;
zm5C+{1HidIGWQeFe;{UB0)=+2Sox9*fk*J=d_J9i{QTCc!xT!@m$XOYiY%2yp64-0
zq9`)MBJ3GVF*zwnRzjf1LeJkJXy=Ld8Roi?T#J5e)Zxa|n^a~ROp6@EyP`@AWf}Hh
zdl=Z%1~Kw}43B=>`X3Jwb}hLT{nMzMmVhhLR7#n>!4%jdab+k8Z4C}Y5BcKu;Q~B)
zNV*~`O5O`jAlh1=kNvcCS4--6zO5a
zs>C@xGUiIo`2KY9lB3<5{)-*^*G(IU12`@bi6}}_scO{@#tFs<25;S}49ysp4rF9P
zq7v$GApaRN8+FAJ^1>64wF^?8!5fWO=qvaM`a56U!*cB(WH37E_cbtO`{Z|bzL(d$
zNoqE0Ez$H%($)JUzE-?~{|Y!+oF}EL1sphe0D>oki|nzjoc}>~EC1Jm(5vZY>Nv>@
z>bti*ewhV*fW)iF8j^iYkSRMHkj3lAIS1x_XtPi{xoNBTne5
zhNB1Y$HEBUExa6ga42x5xUZ0==p_RwuWbZ7`kI!Hw+WHz3sIJ
z-L7rh9gV6!932xs9R2&qw5;WzXARnhWkSZ%voxymaP&fP0ltO~M4t^b$elR89~wi?
zWuexX)HJI4aCCWiOWA>X2js$hekgj_gP+dBaH8vdYwSPv$F?1|G^+Y=bU`lXoJ+@B
zA0jFu<}vo@nS@?_
zIQkAIJUiua6vgvh6m#yQ(QDr(OIY1a&vo4q3kOR=KT$Zki2DM0w0+*JBR=VAhoc+%
z^%z$7rOy}}jvIDJ2f6CQ(K9=F6vxr#8QQ)O-T!Pq=y%2w^St4`ueE1{ev)u>IplxX
zw|m~SfiMo>QohDzXv^f4uN)uCL6)~(|I;qY7(viy9{i4%O162KW_8*~y3eRSyVH>oN<
zANIO8-FDVY&|`=;y0`MBq@Vl_=2SAyxbFixc>gB)5l;0memkDn%d0%RqLHA-5IyvO
z-1op5*L)ynoCkaQy*bfGA7k?)uAWcQFzwc+33?3CL0<*t%!kvMn(W{v1#{M6=%rqy3Dz5de%P@U|-zMlWL=)Yk)LVJ7CNX9d%sGRZA7}?dAH6)n(<_WGZ+bbH
zwsAAr(_@Gp`ifAxTDhD9x|Cop?>uPl9@#`2y;w1dZ!kV@%~0CT9O08r68@M`Y_hG%i-D%=%IYEyh`sn_NG%)u$Bcg^y
zu%}1rdMiDhI$7G#-6y@Bj~L^|1h820DW`SiMgcBZW%
zGP~_^a`qlWw9(0lFaK)JIE-ET+9mmU7M
zXrQwu#ehyc&r?h(A)CT=!f2vrc92uSsQb>G|`K_R5n}5qwna88A?x4k{F_e?pTsh+L4x^0PeViGAS(3
z-V9CjokreH<$IM}?mGPlEthD~MoD6b20EB4p*ozxHok7_N>OgX^F|#Wn(e*cZX>h1
zm|nH6+m(DC<>x3#4ADY&rBsTz0kDwN;f~9ky0hknIF>GYrk{iBQE`+j4oAWK54>s1
zMCI<@ZdgBijFSA2ZRij}iR%Kol1y0^h{Gc=Uk}JXbU5?kNmQR6hUNa*)2QA>Nw%Xq
zKrSRSb|M|uVUES&$-0jdH~O=Ki1Fa!IIQ6py#@%ue*Xv8Q5F#qd&jc
zk0wtCr%|VVauKP&KMTuOVfpMVT=j=x*WW*USiOEbx?+GXsgysj@_I00K?n8YX!7{%
z>~T0bj?{P0-bZEF%U^^IU~jM=RtM-IWgEI>Nuj<9CY-wa6M0}>uiuvc4*K5VQxxUk
z;v4zqkJ~m3h4F)E@uES3WI_lk5w0goRts3#5*Y;|`T+5wr3n`SL9}-9RAjG*4n9M@
z^#TQqX6>3gl#buy9~-cisz8({!}%UbF~g7FBRM$J%$Z7%MimGuiP}+XAx%&v8LCJQ
z3<9hF(GGp{^fZ}leu>2NxQPGhbay1UsUTIQ;mH(fLEQnRAK`~`IFYsvq}9FL!Exyu
zxYf5n-;)+57jQSG$q$d_^zLH}?uN*;Lz@+8D`KXo`>Ht`p}NR%8nvsBEsZ>
zp}&N_lOt;J1%En)?l^iz(kj%_@pYu1;Ly}Yg^z1F1D~QwXCQ1zIFtrY0Dqq^@-5Ie
z`YwL>9JmAZj-iLu%&4{(m2P&gN&}T#OQ-_1q#ori4yb*p9Gp*DJq^B~vFZfA##L(6_}JLlW6j?I=Rnmc>x`kIk~NP(4Rd^1{+DD1Vov*drfY|80yksT1$4(X
zQa7m*SMN^iP|Ip2J8jAdYDL}V6qMT5eeAVU#2;lKLKIdr2Qlc`?`zLOp8#^eenNaD
zzWzP*Sw1$gd!szoEA6<>0-3ms`yh?79@g0GO4fvzpr&ka5Pl9*IXQvhDbCRpAa3sZ
zMZFEWviT|Sj-cD)^cZ_+ZtaBSLzNBdSn9Gaq?Cjmj*UJ?|H}f;rXUh2BAlTCadmfP
z^(k7Q!_mzQ0N#PqF~Sqh#80}wy+qc
z9>L7^5;yhRN2^cK3jH)z_=xBXx@8NuM+SC6Ykh0?xn}l~H;CG%YkX}X*Fw1kelBZZ
z7>iN(I3X;mIDxl{0>u42h)B>6RM_n)SBro!
zwFH_q`g*=aY~>cZ37US+=gJnus<$T?MD%S4~VzHiM%VEjb6jp|hx4#VNzgxmP
zSGQ0{F2DC5x(-}$-u?OT@%h{C_X?vsr(-y@rk>s~Vlh7j
z`pkD|;D~D9^$!m-i*G-Hel!uVVM#K63QF2~aAs>O2|)ahDjk+90y8YT_Mn(cvwU$P
zaOQPPbyFBJL>&)fO*Q0&>QWU~aLb9b(4(9<7Wref7r1hGJG1yU13J3(_{+WcAb2Y0Q95ZCkLfY5VWR^Vl{o4yTw0Q2L*>{K;EICp*b=UZjk
zG;>p_Ae~}&I)2*3^?eLy`y62WKB#jaf800dW^@6$Fh}D!+dE#wWSI6y4QF!C{s-v7
z_Ai$N^7V~Td^;JP(^f|DcsQWP9HEcp-SNyx>ZAe}fc;eA%oqLu50@Xhpq^}viSGo@hx@_0c2BJ9!OLp$QO-i5Y(nx5q^|0FW0(BX4>*#0tf1>}3Yi77SGDOAcp3G58-3f2&)BG^N-w-zxVPYDKo>Sjf{iUr*rSoQ@^+$hJmw8ufOb63&8p0>
zxNoS~=QcuD!8UlrWMB_k(kto(MY~^?-lt&$QdjlfuS<9RH7)AbK4PQ3y^B{}-difA
zQ^OYTh#v0J#_BZmU}6^leXdsZLJOqja&8q6+dPs8C2TC?QJ02{L0kF)G;ToqE~jbG
zAXG@|xzLne)ps1v=-8-3`oija3-?jFUw|$+k8Y1j-$JLy{L8yC)1Sg#7F`^$7py^x
z>aN}%K=v&Jg+Whx)+PG&L0#SmjoVz7k1S~8b2A3^0uAic@5_z)5yFEsMs_aoDZz_Pg-8E#2MZqUgL-{5h^f60b6p)Wd
z|Lh}8eiwP>&ECSb@iU-9>Dr|oS>Cl^<2&eYFZ?a&w4B>Il6n-`gjlfbQ4wH0@He_W
zQ3)h<=@s=>SEl8GTmS$+%c;&|_hwtt>F>9tfB2DJ?R5qH$zJ;J7@5VeS
z()LH%>dl>h2)e*z3)Cp=`Z(5UxZQ>B?QWZBw`pP5+azuS)Z3ACfN#@bPL+;rO;0)l
zy#aRWH}K}_ks6xz6uQZm1v-^?c|3Dxbp9aN)JnlO&`;iE_%AVBtLJw`6
z{ppGHtxqynT910t^~j;Xb5}agk*?j6u0bcU*FI4DuJ0=7U%5M*-ZZW#3`_nSmth7O
zFa~ufz&tgPusL~G!I&G|NE-Q=q
z{2UGEdWVO0Q99fD>GP+i#ykhlPnjvfg~nmI*BSY?*AjE>bq!%k{#XNi1SWe0w~D1%
z>%p0Ix#rW`GM~RNE4f6R!vOl-yJAFa2=qeKr8Q8Q?~)0AN-;w&zwjoXVsTNppB)LFdZ>bd4&$
z5wVM*!;s$3H>WX|H_x}5?oe+){FyzV6ZV5wr|X&=W{iv!{<>kr5Iq^c1e5VIz8Sy7
zjR1A?iz{+kKYhwC$%fvFtm6-4ipb|V?3#_(L<-)5jxv8;jEMa|rJvtWypMZ_O**o>
zx4-ZhV#k*~(z~>&-05jfr2&L*$S#?d^UJ}EVd#i83wK!)ed_D-R(NXgOH%O%ui!nh
zoS%`UTs$IRd#igK6A5?=IywYaKls*dVQ(9*bZ*X^-#d)-=Fc~j?Q_!oJZrs4U-p4c
zRx^wo7!#(lU)}&yWq%EPwTz!HWr5ujlUd&bJ7EXkVFj_Mmdf+)mI@(-|3^M=`^nBihih
z6nV^F1{AIA=Sze>^_+hS7qY^8O357A9v)KXU#lV%WPbnk5r^Uj&~Np(@xQVAS!Kha
z|Iti^i0&UZm7RUXd$>kxHXp%m0(sqL(1kWrHp)7W)(wc8MV@6@mye3L&K*?{GDk7N
ziJmX~s=)m*gC80E-%bzUS7hZbOSyDgz#hHO5cChAuly#YeR5sc+=fGMI-<)rdMRqh
zJ=szeuc9pXdQ#|Qc7sl)XN+B{zWnGY8(WvAJCcy~*jld50$T^wu?HS|R8gYsW7o
zZks|!O;;+*nY>ae#SjTUXY-^4jVr*f@I_JxP-ic7Z@?d%!b_=Nk&C@n9$r1!MUYQW
z4LWiwcRBD*jm>R1^k({8xv{hGa2gLsZoAyBr5moENxa^4+7LQ%$(2eK6@%ql9q!*KMkF7{r}L{v9LikZG*MpvOu)mlw|x0@NjM
zc$Mw|WcwQ4%ai-M;emf8cnr_^9qk!TBdIj`!7kg~6?i+=8&3W{zO%TjK<-f&cuRBG6gp8XCQGbtJ$VHDP&q>O40QEsbqu#aRfX1(+oza%jPHq@
z0gz>A*mDY)YPm+9>vyX?BiC&G{dCQOe!G7@(f3z{J#85Do@-i}eZ01~@Nh!`{LSO}
zC_!)9A39l#u_skbOZM!{@EKk
z<4(pzAA77?En}lZtp`y>xr05{O*bJQ1*OCEgv4GX-Wo_p#w@Yx
zXH^~3JDTW39mAqE#n+CI_qn25=h8jP^;)X{UueOP$<0+AonAmU>$hJ_=$>zFd*J9W
z=!d?ja?LfTY)`DsDzJ@dWpi|tF5y7m#JgIJqv&SH3Gx`nWl`6s9;5dU1RvwRohfw;
zcR9cBp^CT4_bEW{D}8Wg$)|LNjZJMB^roY^?2X=39Gf6McT?G#l}g8#v)OdnAG#1@
zDVf$&-AD{MzDua%-F85T)Ei>2cdG<3(j7x0AhamiH
zHV^jn21@_Z`k?FQ{p^0(ZcDeJ(3`$RWpR1=!J4wT|tR!jqX@D{z*tWZ|`P48eka>-YDMv%q1L
zcFXg_q0cWU8`thcXqizQTqOby;+?yTN6&msG=f0Dx0pM=e9ICR(JohW5?dU9sX
zySAX*@Ot;%!0nuLI!6dPS-Ra#sX~V*;6zx9yh(G4hvN}b4^XbJ3Zk5}9B26$M(T^O
z0Qw+#9d+P`(B+B^r&?H>Zr^_{Y-Yot*PEgH$JBgZm{XSSDG&E)wf=a*Nywc|=OlfP
z5_DjP&ePp45zcoaH0)4zkF$G|H>pRul<=z&EZ-~W*~H8Ro0hBy?6Hwk8)5h)C-&TX
z(5++0u71M-%XMKly9m15388PzdX+mb1DncX^zrcn$I{CQN1b>xcIQYbolJ`pc0t%_
z!5wIK(n4#&wL3u(?6ftJhTuV6?Iz5*y>g@x*pi@2$bau-k9?jd^=t*cmeYhar}s~Z
z_guI1{o@b9ZZ-sZ-M#OnC#B{uU0PCZPF!-;UH2cj>P`mUbT}tROzE(AyOGzlRHqe$
z&f!`r$v!XCHYM#i4Vb#l`Tk6m30^H#r1JRy5}DtvE+1@-{kn_(!R^(@=e@-a_pkTnrR~loz&jy>Ct_x{6Xja;HUkjVq#n7opug{Mk*C)qA
zmqM;54`-*TjIRT_W#c0Wov|0RV55^J;th?^?}`EukH0Gj^<6`VMn|JG5D~fCyN5znSVjQVm9Qio6tY$XZNq|^JhLUgzg~d1m5Qk`Ht6Smld{~
zPTwTUoNRQV1N&;2)Z7TDt#`X3Otj-9_&}#2)DEaOG!Y^4DO$m)AfLdB4N?#OwJWz?
ztV%97Bh1e_06LnlgX5{b`cX*p{A8bv)*;YQr8}PO%-!*tXUJ_n^SLDPQHM@yZJdMy
z;YKU4+h_!WCno`|aS{}blK^zi-z|{uQc+JWS`ENcZB>XDe+u4Owby}-$dy7$+wBFy9iOuf?*DwcEI6iGb+
zsS6?AKC(Xgkd2Y60lN8&+%5UA)Ti=1y1`!o`rJ;v*o&b%$$Zhdm*W#!T~TJoT_F8Y
zLH~!mGwE#_3gWoL*O(@8NKAmRsA{3mA^{>Sfr41VV!~paQcz1+3b@E^6-02r1r>U+
z#MV?GA8HE;!3m2)ZX6IGrHB(BhMD*5nB8z8RWeo6_S&Cj`@ebf5W6gEMcXcGt_iBE
zfh-Hfc@?aA2^u`
z9w+GOL@Jd`q%I(IOy{i!9R$U3blp+QrcC4o)h*{-6AZUZ)FrIx$Ecgb)_ru2PUrw~
zv{$TX0l*ZSrlg*Y&%OJezKR#`o&F$2pv8(Snt#w54m}!=V+G&B&=I>{vsKep7K%xnO-k=*mkEUDB)5$QZ+iFc!YPROo%BGuB
z>Y7wn$=8*6NstXjyD$e(HTW{<;+(*fI=|Vpd0d1BbZkC(}JHMH2>3t?LFPta38
zLnnbi8_1lIx81&J#9D!?m}>5HJr60hl|*c9bm
zpyg8Cz@ayeTbBDXGr>E)yPKcU{~3Bbn_n8e+7XYoAasDTTC+`4wM_+GzOEy6p>D>I
zx+y~mtNEAY?lG_|zIkS>KuP@|sqZ3RO2DBv+Vs`^3~?|4-?ozA^;(M>_Y!mU;W-3;{UMd_oB1)D
z4}IA>MtQ9l%X|DXF4GGO!NQ?qMDK|AEv7#G!r6VK&g~d!QBNn+OovK3RdrO;(XfB7
z+2GnS*9Oy}V^tz|C3!`~r!=Exb~
zEt{{+@H&p7yO+o_{oq+=S0^;fLZKtpWZ&%hz21S##yvZ)%w&@-a^Uhiiz%jyeY;_r
zx@_pOGwdE%VfZ_u1ifU;zOaQ6G=KoKd^V=LNuEiNg?yZ
zKs4Uko=%oC9J#6*rlG33t*9naFW1XTJ*T*WCdVX~mvlG;Jv`mbHMzs%-Taizf7fj#
z74HcuymvUuyDzYy)r672w;W^3j@S_Xb8Rl}&39Yv|fY#T_XQ@~Zhu<*V^jK%k(nAF#d
zZ|0LTKJ+eD$BEvQ-16Nkfm?r*<@fMHt|JjmXD_EuUB*pA+_vZ21UlgS+`E?sajz;$
zRdb4}oO5*v#X2`uuIHqeMVgoFp6+JDkpea9ooq&K%OODPN7|Iq`8-8euL#W69&7Hf
z++Ve9`H>FXGPHAoQu~$J1MLJIV#fS@)neQgOL2;(iJ4t@!K?xLe8qxcbalGd^kR_8
zcYhjo3kh#xKe@KA(1`qY6M1|WG}pqR$7cujZlt{7&)nPrKxFZWQA&wWAUB5(au%swrAs(p?&!nz|>5)0?qV7im!_zv056
zFUt^Sf80I8*i#MPsNB~+Yl;#d?&<053qyFmvi
zzW+iozEYtWU#v>DT6RIT>ET^mKzsKFO-qsbPWRJ2Pv6o5^vTeQ{a*U+ZM5i@GSDOo
zho0KQ+O*m9%;c@@Op!e>+jgE#R`~GYl~FC<=`s;=O_kW2qL}&aQq10
zM8TnVA#T5KTkVx`YcKhZ-rK}xJ>1jJ-oj>WVr1zMzvFWL_Vad-VSixKcsIs*1Ti3@SL7bF2WLS{l@+K
z4xB>zS1(@O(brzpdtjvxpI;$$#vS|ivMgB!?jRYSh8uTrFcL(pU+32lbVSzyRw+J2OiiM8N%!8?Fk(gp|ckXmCBe=vPzbsSEZN2
zD7iWYcM+fVy?KWA$Z7JUJ8c4}1OjO<{DjQm9~nFCuqs>RR{o
z6Y+&`_^G%YM<@&^!AH`Yz_j8T=apfo6q&Rh@GCe`g+E$R!d(ksaG3XO}F!a6Wkf
zp-=ALxGA1(U+4g%pWYL7W{Od)#;zTF@)-McYSYJ0GH*Y$)q^hftOT!`qY~aL-Gku1
zp$P&_D0G^F-4xx94xWh54D9u;Ecdmsr=v~1{q$&bbabpTW{p|JN(C3pJsw>10^ips
z;3c9?j>dg{j!;eW9U{8n;emU*$H`(h^!*3~
zxmXl0d>0g+7;%$LpgL}&+=}XHMvsRu=tEm`mkrC_tDw2V{_11L%XVzPt+fe{HeHEl
z+5R8~KYZ_z6aApP%D
zNH>^C;aUn)f`;r|G5hYalL9!wNJ@u|Nk?Yuc2wSTtnOjR%T!fXR#pFR-VrU&e{mXE
z2ik{E^bZYoq%E@zoj%OXNJb)w%%_`CyRE41>=r>*8%yEz;)KCg%In59M|w^5+4}PO
z-m~Rk)h?6l?4BYi`c&&uk6zMfpux9IO-2tTUCUD8r
zk=rW&yJ^^;WBiNvo*EqJ2ja)l-J_O3&>thQs8Qndl9or(k#8eCw``DLSnJj1T`QoO
zD{(xxsX(q`$FcJAo2r{m?JB>e3HMu?uJ~h3n*_4{^7wfF)QQ0pGef~qsC4rc@!0&@lj+15FS5>S?x~k*G2&y|HoyI9VZPH{oppkS-H;i{M(@E?2>#3tKVw4vOAd
zSq}v>a7_JSB>mV`WflLEz8>Qc&Apr+n3@`y33OyEnW&gCMf8FKYc#sOiW*eA+G-H)ijqa*@K;Ckdt_G<7pY(OO=HgSQ)8m6vLqoyNaA!1Ql89{O
zBqDk^7R-r+-~?N$1J>mdb*aj%J#2pf_P7y9qM?$nX;##`WQ3q9DbRuWYT{(OACc%ngmV*2BG0
zb-nw`>&vc0(yfx@vyf;u<_|6eg45|1P3_F-7uv@TY7PxQ{lw$9-*Lx7FSZ@L_tEo@
zwYQK<&~WkM^z?XoG1%GZi^_f_EXsNhXE1X%2hk(Pihu)U+nV4A!VoX|aC?bwrr3CE
zwXC;wPx*nm>K!$Zk-P2aJ@ugPSJLfLoG6ERVv0n<(M&8h86|-E*Eizwr7P%C-+a
z=s|hcj~}%49O@Z3QJ18?ORAgl&AtL-%T4gi9iO;CtYi_Nhlu%eB)N
zMn(dGBsC7x7tc*xSR7di2w^#D2s;d-lh}-c#3AWng<~bM^q3REmY@PP-bqS~&68eP
z(|Xgc@&o&J-&(D^B69EUxNMLVmmB8}d(uY;p9mAnlD}ce0xykBk5lvA{&qh>+<5=(
zDBA9A$40GF(5q`^VrYJz<1C#qAL01g3$IT
z?zqh(8E6|NliivP6Ao{Iu_P6Cl6-Xu$ByZAS_7fnGXn$dV8|bT@z6uG@#53xhY6zZ
z`5}(b`=5i!0EGOUUI@JQ+Wg{l+BdrZ!8>TX!K`-^N#Mb>OQL$WoWS;7Y!MuP&bi4M
zca|h4x2gSfUD>IJD`_B4aXGj1Kvw0DKse)EiV#|uDG!laenNMG;Gm27%0LhRE~~
zgzqQduxdAX0=jm=$>f7iJbm!cqdjQd2Vo5lsGXk=1bKDK{M9#JU71g=1OqFwC>v1O
zJsc)vO`N1>=NZ)$JXn5$Fs(bIZiw3jQW9*kMy{!;Q0abIcWdHpcAK7Exh~ixiD%bB
zM6)zQ#@QxEI8b;W*D(*dnHf}eZVLT598!hOtHAhOH)2S4ZyRZcn0~5HOSD6a^P>b|
zl^X&X-^v>+^GnOC(b!6dLzGN5!KpX6fpK0ku0Q)g5|sM&DLKwwy6v`&Nk?FNwkv>i
zKJF5gNXm?JI~d*waZyE!EYqJF$!sT~7#(GDVhBf2^)@OWx}VBJ=bvfchTU5<4b$@-
ztZ9$7b9uJYPl9i}F+Z^sTk`qmGlEUDi3Y3aG@*qzGM8?Z2*8lwQLMX{KnRtzQ{&UvEk7_q
zYDX4Ez-t2*o?8KXpMO?PJR1|O27}ec+l;cv0qJ2G(=%a$iv*D%lwvuo16+IQ@E;+a
zb9=z9(;GmnP9Hc_JGHCX7@^=NRTU4Y?2jx`p`DmQp?1h8nkVi;(Qk
zz2frh%Vu{OY!R=Uuuh0_LU0HKpG^7)g@1{ucI`rtN^ZV`${AJFA4Qtg9@^NY39b9!
z&>-kJxb>CA2AG&6UJG%25=c)j#OzB^aXBX%^lr{3iZW63bk*i7oOeNyQk^*G!X$hV
zdd
z#&8QGRA{G}s!#P%p;@!`fC{#{3Oy35F?v%6i~@dya2Lg9o+D7b6}1;%iGDr;wh
zD2mIqG*jd5M_N!Ee6)uO*jYuit4_TG1((E6;FftydLf;$b-eY~YBE1J8I_i$x%{%j
z6WS{!6q9TsioejuNs(Yc?juBn$sTubq$JoJ=}kv=cinvarjvK?zwYpk%cW_))uAT_
zCrtEUU%{I*g!550v1C=H@0{hR463o*$SC`{s_K1HRNTw#`|Y<=F+7Z;HqDYZfgp~&
zEjlsUfJwgD=T@_^ytJCiEQK7&q$q?GL&zl)Bc^9)ET?DX@7x;ahLF8vlyBC&lOy%V
z?>>3c$?J|EZMr<_-feN4*F$gFH#&GC7t2(9c_W)dvlcd)4KTUL*ydEW(`hQk`ZOq1
z``-5WLHdjt{TW7jCncRz!dik+gkV`-T3yXgO3(V`oXwig3JzH-+CnCy!Vy0bPq26f
zMS`gClyarMDbj)E>fHzKZf-t#WY^)|vzLXYi(uG|&b>CGBCL9IWSwV|z$8;`%dDTu
z25Q)gs@^@_f*HP{DIGR>RJrN4<^#9vKY8+|16^H>I^AW^Kb>|+GI>)G
z6JZcjK6REUEYo61COc=DRP%6}MI^tNc|U<#U5wu!zl*+wIrWx_78L#fhr+j!d1Jt1y1mnJ=N7IjES6m&gS$i6PISF{VAOwv4Dak-xTRhHC;CxKX6Osk>10*_8mX_u$c2|L2$@i?7M8^G;$
zn*p-J;V>YqR;vgQ5PAwDgNcIY&330#u!&lZqlZdddct6o1a+2C>9-^;Opc;dJ7NQu
zYMJZc7>=4@7Pvo7?fab%F{iGkXDI2sk5xc93BqkA#JHTzE~g^PhFDIYnRMv$uw?UD
zJTmcz%%JEZ!5~g16wx1Ioy+;1xn)2eF}a+}GQ_gWlZe^m?CNY1(Ydq~0CaXPyb1;Dog=~d(IB8>
zbh-n@&~yi&VX)8A(9mEyKg>hrYwbflTx~5GYX{o2n2$p+(gjAk8Ev(kOL^mFuiJgC
zS?~4o_BprUF*_hlARrvjo`GlI`%#H|#&zc-`T%mAb=cD*s7M
z*Iu&$=^F!he-^$`{2a@L$#c)aXQCZ=D;z=C$z)&%Ji;wO22^1z#)AtqiuE=$}~WzoUO(;EsjWu{+0lD8{n+
zz@2vHw>C~y_^8L+w4m3m0&3~HoxzHTL-ZgB7_?(J0
z$}i!a@eSjz;*IBkY5e{rF2dJq7k`QOzCgf;_>he#K79M_4=LV($2%W>^v*{gy~FV4
zM+kh%5Rtf`!%%BvINrfW5NFpWyCS%!LHZ#ePUye;?0ZUNJG`UK#yvpzv
zJSbL}09>NosP9NHjEHsIe}Ugfy2`yuIvd|c#(`xJKrzycUm)J39OG~aKK~pZ>+#vo
zaP>O8Nd6=?PrB~RJ$1cTZNKfR-nx5kEz|wur0dC_gV|=a+dKlHSRcdjl24t5V;q|#
z1B3&R5s;Ff&;gtCmK2c6B8B~XC4{qfV1b1JTo*hG?IQ{VkDhTkB_G(
z0N0F9jE|3Bb735CddAlxqdi}lEUx$AXRR4kf58h$Aj^w|&
zo_NHMKQhuk|5eK2$&KVMU~|ctGnH3W)f_vre-HHS*#kE~Z&A{X5EJq4IlHOIdU0*e
z=VR*3H_2Gn7B^FaR5UEsqQHDQqOTjr?shGTwl*B^{OTZn?k*a|_Y4rkkOfE4ild1Y
zFgt7n;mJAdAzOU!oXr~YI8u2fmdb~u$i_{8wN4CfJ#BAx);NbzGSNUx|W>ngtv`gq)M)A1+4xNlWW7woKaMo28al2Qt+
zXA7*y)I?mvQU{Z-Hc&abNX5)_EoE~8M=i^|_dJGltBIMm^Eg817WyIjJEA^Dx`4vK
zNO$J)@wm&AvMG*8*6xxN!K#D=2Qdf+tKO-1&bi%%DtBjGB