文章

前端开发整理(三): Vue框架的脚手架配置与单组件编写

前端开发整理(三): Vue框架的脚手架配置与单组件编写

Vue3的脚手架构建

Vue的安装与模板创建

vue的安装与简单的模板创建方式如下:

1
2
3
4
5
6
## 安装vue
npm i vue
## 全局安装脚手架
npm install -g create-preset
## 使用 `vue3-ts-vite` 模板创建一个名为 `hello-vue3` 的项目
preset init hello-vue3 --template vue3-ts-vite

使用Vite创建项目

创建Vite项目的方式包括Create Vite、Create Vue、Create Preset三种:

1
2
3
4
5
6
7
8
9
10
11
## Create Vite:按命令行的提示操作(选择 vue 技术栈进入),即可创建一个基于 Vite 的基础空项目
npm create vite
## Create Vue:创建基于 Vite 的 Vue 基础模板
npm init vue@3
## Create Preset:选 vue3-ts-vite 创建一个基于 Vite + Vue 3 + TypeScript 的项目启动模板
npm create preset
## 不过第三种方法更推荐全局安装
npm install -g create-preset
preset -v
## 然后通过 --template 选项直接指定模板创建项目,用 vue3-ts-vite 模板创建 hello-vue3 项目
preset init hello-vue3 --template vue3-ts-vite

不论使用上方哪种方式创建项目,在项目的根目录下都会有一个名为 vite.config.jsvite.config.ts 的项目配置文件(其扩展名由项目使用 JavaScript 还是 TypeScript 决定)。里面会有一些预设好的配置,可以在 Vite 官网的配置文档 查阅更多的可配置选项。

使用 @vue/cli 创建项目

这里不做详细介绍,具体可见参考教材的该部分内容

调整TS Config

如果在 Vite 的配置文件 vite.config.ts ,或者是在 Vue CLI 的配置文件 vue.config.js 里设置了 alias 的话,因为 TypeScript 不认识里面配置的 alias 别名,所以需要再对 tsconfig.json 做一点调整,增加对应的 paths ,否则在 VSCode 里可能会路径报红,提示找不到模块或其相应的类型声明。

假设在 vite.config.ts 里配置了这些 alias :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default defineConfig({
  // ...
  resolve: {
    alias: {
      '@': resolve('src'), // 源码根目录
      '@img': resolve('src/assets/img'), // 图片
      '@less': resolve('src/assets/less'), // 预处理器
      '@libs': resolve('src/libs'), // 本地库
      '@plugins': resolve('src/plugins'), // 本地插件
      '@cp': resolve('src/components'), // 公共组件
      '@views': resolve('src/views'), // 路由组件
    },
  },
  // ...
})
1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"],
      "@img/*": ["src/assets/img/*"],
      "@less/*": ["src/assets/less/*"],
      "@libs/*": ["src/libs/*"],
      "@plugins/*": ["src/plugins/*"],
      "@cp/*": ["src/components/*"],
      "@views/*": ["src/views/*"]
    },
  },
}

添加协作规范

Editor Config

在项目根目录下再增加一个名为 .editorconfig 的文件,作用是强制编辑器以该配置来进行编码,比如缩进统一为空格而不是 Tab ,每次缩进都是 2 个空格而不是 4 个等等。文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
## http://editorconfig.org
root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true

[*.md]
max_line_length = 0
trim_trailing_whitespace = false

具体的参数说明可参考:项目代码风格统一神器 editorconfig 的作用与配置说明

Prettier

Prettier 是目前最流行的代码格式化工具,可以约束的代码风格不会乱七八糟,通过脚手架创建的项目很多都内置了 Prettier 功能集成。如果需要手动增加功能支持,请在项目根目录下创建一个名为 .prettierrc 的文件,写入以下内容:

1
2
3
4
{
  "semi": false,
  "singleQuote": true
}

这代表 JavaScript / TypeScript 代码一般情况下不需要加 ; 分号结尾,然后使用 '' 单引号来定义字符串等变量。这里只需要写入与默认配置不同的选项即可,如果和默认配置一致,可以省略,完整的配置选项以及默认值可以在 Prettier 官网的 Options Docs 查看。配合 VSCode 的 VSCode Prettier 扩展,可以在编辑器里使用该规则格式化文件(此时无需在项目下安装 Prettier 依赖)。

如果开启了 ESLint ,配合 ESLint 的代码提示,可以更方便的体验格式化排版,详见 ESLint 一节的说明。

ESLint

ESLint 是一个查找 JavaScript / TypeScript 代码问题并提供修复建议的工具,换句话说就是可以约束的代码不会写出一堆 BUG ,它是代码健壮性的重要保障。

通过脚手架创建的项目通常都会帮开发者配置好 ESLint 规则,如果有一些项目一开始没有,后面想增加 ESLint 检查,也可以手动配置具体规则。

这里以一个 Vite + TypeScript + Prettier 的 Vue 3 项目为例,在项目根目录下创建一个名为 .eslintrc.js 文件,写入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
module.exports = {
  root: true,
  env: {
    node: true,
    browser: true,
  },
  extends: ['plugin:vue/vue3-essential', 'eslint:recommended', 'prettier'],
  parser: 'vue-eslint-parser',
  parserOptions: {
    parser: '@typescript-eslint/parser',
    ecmaVersion: 2020,
    sourceType: 'module',
  },
  plugins: ['@typescript-eslint', 'prettier'],
  rules: {
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'prettier/prettier': 'warn',
    'vue/multi-word-component-names': 'off',
  },
  globals: {
    defineProps: 'readonly',
    defineEmits: 'readonly',
    defineExpose: 'readonly',
    withDefaults: 'readonly',
  },
}

然后安装对应的依赖(记得添加 -D 参数添加到 devDependencies ,因为都是开发环境下使用的);这样就可以在项目中生效了(如果 VSCode 未能立即生效,重启编辑器即可),一旦代码有问题, ESLint 就会帮检查出来并反馈具体的报错原因,久而久之的代码就会越写越规范。如果有一些文件需要排除检查,可以再创建一个 .eslintignore 文件在项目根目录下,里面添加要排除的文件或者文件夹名称。

VSCode插件

Volar

Vue 官方推荐的 VSCode 扩展,用以代替 Vue 2 时代的 Vetur ,提供了 Vue 3 的语言支持、 TypeScript 支持、基于 vue-tsc 的类型检查等功能。

Vue VSCode Snippets

从实际使用 Vue 的角度提供 Vue 代码片段的生成,可以通过简单的命令,在 .vue 文件里实现大篇幅的代码片段生成,例如:

  1. 输入 ts 可以快速创建一个包含了 template + script + style 的 Vue 组件模板(可选 2.x 、3.x 以及 class 风格的模板)
  2. 也可以通过输入带有 v3 开头的指令来快速生成 Vue 3 的 API 。

Auto Close Tag

可以快速完成 HTML 标签的闭合,除非通过 .jsx / .tsx 文件编写 Vue 组件,否则在 .vue 文件里写 template 的时候肯定用得上。

Auto Rename Tag

假如要把 div 修改为 section,不需要再把 <div> 然后找到代码尾部的 </div> 才能修改,只需要选中前面或后面的半个标签直接修改,插件会自动把闭合部分也同步修改,对于篇幅比较长的代码调整非常有帮助。

EditorConfig for VSCode

一个可以让编辑器遵守协作规范的插件,详见 添加协作规范 。

Prettier for VSCode

这是 Prettier 在 VSCode 的一个扩展,不论项目有没有安装 Prettier 依赖,安装该扩展之后,单纯在 VSCode 也可以使用 Prettier 进行代码格式化。

ESLint for VSCode

这是 ESLint 在 VSCode 的一个扩展, TypeScript 项目基本都开了 ESLint ,编辑器也建议安装该扩展支持以便获得更好的代码提示。

项目初始化

入口文件

项目的初始化都是在入口文件集中处理,Vue 3 的目录结构对比 Vue 2 没有变化,入口文件依然还是 main.ts 这个文件。

但是 Vue 3 在初始化的时候,做了不少的调整,代码写法和 Vue 2 完全不同。对于这次大改动,笔者认为是好的,因为统一了相关生态的启用方式,不再像 Vue 2 时期那样多方式共存,显得比较杂乱。

在 Vue 3 ,使用 createApp 执行 Vue 的初始化,另外不管是 Vue 生态里的东西,还是外部插件、 UI 框架,统一都是由 use() 进行激活,非常统一和简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import pluginA from 'pluginA'
import pluginB from 'pluginB'
import pluginC from 'pluginC'

createApp(App)
  .use(store)
  .use(router)
  .use(pluginA)
  .use(pluginB)
  .use(pluginC)
  .mount('#app')

Vue Devtools

Vue Devtools 是一个浏览器扩展,支持 Chrome 、 Firefox 等浏览器,需要先安装才能使用。

当在 Vue 项目通过 npm run dev 等命令启动开发环境服务后,访问本地页面,在页面上按 F12 唤起浏览器的控制台,会发现多了一个名为 vue 的面板。

面板的顶部有一个菜单可以切换不同的选项卡,菜单数量会根据不同项目有所不同,例如没有安装 Pinia 则不会出现 Pinia 选项卡,这里以其中一部分选项卡作为举例。

Components 是以结构化的方式显示组件的调试信息,可以查看组件的父子关系,并检查组件的各种内部状态;Routes 可以查看当前所在路由的配置信息;Timeline 是以时间线的方式追踪不同类型的数据,例如事件;Pinia 是可以查看当前组件引入的全局状态情况。

使用Vue进行单组件编写

全新的 setup 函数

setup函数的含义

Vue 3 的 Composition API 系列里,推出了一个全新的 setup 函数,它是一个组件选项,在创建组件之前执行,一旦 props 被解析,便作为组合式 API 的入口点。基本语法如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 这是一个基于 TypeScript 的 Vue 组件
import { defineComponent } from 'vue'

export default defineComponent({
  setup(props, context) {
    // 在这里声明数据,或者编写函数并在这里执行它

    return {
      // 需要给 `<template />` 用的数据或函数,在这里 `return` 出去
    }
  },
})

setup 的参数使用

setup 函数包含了两个入参:

参数 类型 含义 是否必传
props object 由父组件传递下来的数据
context object 组件的执行上下文
  • 第一个参数 props :是响应式的,当父组件传入新的数据时,它将被更新。不要解构它,这样会让数据失去响应性,一旦父组件发生数据变化,解构后的变量将无法同步更新为最新的值。

  • 第二个参数 contextcontext 只是一个普通的对象,它暴露三个组件的 Property;因为 context 只是一个普通对象,所以可以直接使用 ES6 解构。

    属性 类型 作用
    attrs 非响应式对象 未在 Props 里定义的属性都将变成 Attrs
    slots 非响应式对象 组件插槽,用于接收父组件传递进来的模板内容
    emit 方法 触发父组件绑定下来的事件

defineComponent 的作用

defineComponent 是 Vue 3 推出的一个全新 API ,可用于对 TypeScript 代码的类型推导,帮助开发者简化掉很多编码过程中的类型声明,使得代码量瞬间大幅度减少,只要是 Vue 本身的 API , defineComponent 都可以自动推导其类型,这样开发者在编写组件的过程中,只需要维护自己定义的数据类型就可以了,可专注于业务。

组件的生命周期

只有理解并记住组件的生命周期,才能够灵活地把控好每一处代码的执行,使程序的运行结果可以达到预期。

Vue 3的生命周期写法名称是Composition API(组合式API),且Vue 3组件默认支持 Options API。

使用 3.x 的生命周期

在 Vue 3 的 Composition API 写法里,每个生命周期函数都要先导入才可以使用,并且所有生命周期函数统一放在 setup 里运行。如果需要达到 Vue 2 的 beforeCreatecreated 生命周期的执行时机,直接在 setup 里执行函数即可。

Vue 2 生命周期 Vue 3 生命周期 执行时间说明
beforeCreate setup 组件创建前执行
created setup 组件创建后执行
beforeMount onBeforeMount 组件挂载到节点上之前执行
mounted onMounted 组件挂载完成后执行
beforeUpdate onBeforeUpdate 组件更新之前执行
updated onUpdated 组件更新完成之后执行
beforeDestroy onBeforeUnmount 组件卸载之前执行
destroyed onUnmounted 组件卸载完成后执行
errorCaptured onErrorCaptured 当捕获一个来自子孙组件的异常时激活钩子函数
activated onActivated 被激活时执行
deactivated onDeactivated 切换组件后,原组件消失前执行

以下是几个生命周期的执行顺序对比:

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

export default defineComponent({
  setup() {
    console.log(1)

    onBeforeMount(() => {
      console.log(2)
    })

    onMounted(() => {
      console.log(3)
    })

    console.log(4)
  },
})

最终将按照生命周期的顺序输出:

1
2
3
4
// 1
// 4
// 2
// 3

组件的基本写法

Vue 3 在保留对 Class Component 支持的同时,推出了全新的 Function-based Component ,更贴合 JavaScript 的函数式编程风格,这也是接下来要讲解并贯穿全文使用的 Composition API 新写法。

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 3 的组件也是 <template /> + <script /> + <style /> 的三段式组合,上手非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!-- Template 代码和 Vue 2 一样 -->
<template>
  <p class="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>

其中 Template 沿用了 Vue 2 时期类似 HTML 风格的模板写法, Style 则是使用原生 CSS 语法或者 Less 等 CSS 预处理器编写。
需要注意的是,在 Vue 3 的 Composition API 写法里,数据或函数如果需要在 <template /> 中使用,就必须在 setup 里将其 return 出去,而仅在 <script /> 里被调用的函数或变量,不需要渲染到模板则无需 return

响应式方法

响应式数据

响应式数据是 MVVM 数据驱动编程的特色, Vue 的设计也是受 MVVM 模型的启发,相信大部分开发者选择 MVVM 框架都是因为数据驱动编程比传统的事件驱动编程要来得方便,而选择 Vue ,则是方便中的方便。

响应式API之ref

ref 是最常用的一个响应式 API,它可以用来定义所有类型的数据,包括 Node 节点和组件。

类型声明

开始使用 API 之前,需要先了解在 TypeScript 中如何声明 Ref 变量的类型。

先看 API 本身,ref API 是一个函数,通过接受一个泛型入参,返回一个响应式对象,所有的值都通过 .value 属性获取,这是 API 本身的 TS 类型:

1
2
3
4
5
6
7
// `ref` API 的 TS 类型
function ref<T>(value: T): Ref<UnwrapRef<T>>

// `ref` API 的返回值的 TS 类型
interface Ref<T> {
  value: T
}

因此在声明变量时,是使用尖括号 <> 包裹其 TS 类型,紧跟在 ref API 之后:

1
2
// 显式指定 `msg.value` 是 `string` 类型
const msg = ref<string>('Hello World!')

在声明变量时,使用尖括号 <> 包裹其 TS 类型,紧跟在 ref API 之后:

1
2
// 显式指定 `msg.value` 是 `string` 类型
const msg = ref<string>('Hello World!')

看该 API 本身的类型,其中使用了 T 泛型,这表示在传入函数的入参时,可以不需要手动指定其 TS 类型, TypeScript 会根据这个 API 所返回的响应式对象的 · 属性的类型,确定当前变量的类型。

因此也可以省略显式的类型指定,像下面这样声明变量,其类型交给 TypeScript 去自动推导:

1
2
// TypeScript 会推导 `msg.value` 是 `string` 类型
const msg = ref('Hello World')

ref API 类型里面还标注了一个返回值的 TS 类型:

1
2
3
interface Ref<T> {
  value: T
}

它是代表整个 Ref 变量的完整类型:

  • 上文声明 Ref 变量时,提到的 string 类型都是指 msg.value 这个 .value 属性的类型
  • 而 msg 这个响应式变量,其本身是 Ref<string> 类型

如果在开发过程中需要在函数里返回一个 Ref 变量,那么其 TypeScript 类型就可以这样写(请留意 Calculator 里的 num 变量的类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 导入 `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

变量定义

不同类型的值之间还是有少许差异和注意事项,具体可以参考下文的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 字符串
const msg = ref<string>('Hello World!')

// 数值
const count = ref<number>(1)

// 布尔值
const isVip = ref<boolean>(false)

// 先声明对象的格式
interface Member {
  id: number
  name: string
}

// 在定义对象时指定该类型
const userInfo = ref<Member>({
  id: 1,
  name: 'Tom',
})

// 数值数组
const uids = ref<number[]>([1, 2, 3])

// 字符串数组
const names = ref<string[]>(['Tom', 'Petter', 'Andy'])

// 声明对象的格式
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 和子组件的 TS 类型声明,可参考以下规则:

节点类型 声明类型 参考文档
DOM 元素 使用 HTML 元素接口 HTML 元素接口
子组件 使用 InstanceType 配合 typeof 获取子组件的类型 typeof 操作符

变量读取与赋值

任何 Ref 对象的值都必须通过 xxx.value 才可以正确获取!

响应式API之reactive

reactive 是继 ref 之后最常用的一个响应式 API 了,相对于 ref ,它的局限性在于只适合对象、数组。

1
function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

reactive 变量的声明方式没有 ref 的变化那么大,基本上和普通变量一样;没有像 ref API 一样有 .value 的心智负担;但是 Reactive 数组和普通数组会有一些区别,普通数组在 “重置” 或者 “修改值” 时都可以直接操作,如果使用 reactive 定义数组,则不能这么处理,必须只使用那些不会改变引用地址的操作。

响应式 API 之 toRef 与 toRefs

这两个 API 在拼写上非常接近,顾名思义,一个是只转换一个字段,一个是转换所有字段,转换后将得到新的变量,并且新变量和原来的变量可以保持同步更新。

API 作用
toRef 创建一个新的 Ref 变量,转换 Reactive 对象的某个字段为 Ref 变量
toRefs 创建一个新的对象,它的每个字段都是 Reactive 对象各个字段的 Ref 变量

toRef的使用

toRef API 的 TS 类型如下:

1
2
3
4
5
6
7
8
9
// `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 变量,在适当的时候也可以传递第三个参数,为该变量设置默认值。

如果 Reactive 对象上有一个属性本身没有初始值,也可以传递第三个参数进行设置(默认值仅对 Ref 变量有效);数组也是同理,对于可能不存在的下标,可以传入默认值避免项目的逻辑代码出现问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//一般变量
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

//数组变量
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

toRefs的使用

toRefs的API类型如下,只接收了一个 reactive 变量,转换后的 Reactive 对象或数组支持 ES6 的解构,并且不会失去响应性,因为解构后的每一个变量都具备响应性:

1
2
3
4
5
6
7
function toRefs<T extends object>(
  object: T,
): {
  [K in keyof T]: ToRef<T[K]>
}

type ToRef = T extends Ref ? T : Ref<T>

这个功能在使用 Hooks 函数非常好用(在 Vue 3 里也叫可组合函数, Composable Functions ),还是以一个计算器函数为例,这一次将其修改为内部有一个 Reactive 的数据状态中心,在函数返回时解构为多个 Ref 变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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,
  }
}

在业务中的具体运用

使用 userInfo 来当案例,以一个用户信息表的小 demo 做个演示。

在 <script /> 部分:

  1. 先用 reactive 定义一个源数据,所有的数据更新,都是修改这个对象对应的值,按照对象的写法维护数据
  2. 再通过 toRefs 定义一个给 <template /> 使用的对象,这样可以得到一个每个字段都是 Ref 变量的新对象
  3. 在 return 的时候,对步骤 2 里的 toRefs 对象进行解构,这样导出去就是各个字段对应的 Ref 变量,而不是一整个对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
  <ul class="user-info">
    <li class="item">
      <span class="key">ID:</span>
      <span class="value"></span>
    </li>

    <li class="item">
      <span class="key">name:</span>
      <span class="value"></span>
    </li>

    <li class="item">
      <span class="key">age:</span>
      <span class="value"></span>
    </li>

    <li class="item">
      <span class="key">gender:</span>
      <span class="value"></span>
    </li>
  </ul>
</template>

需要注意的问题
请注意是否有相同命名的变量存在,比如上面在 return 给 <template /> 使用时,在解构 userInfoRefs 的时候已经包含了一个 name 字段,此时如果还有一个单独的变量也叫 name ,就会出现渲染上的数据显示问题。
此时它们在 <template /> 里哪个会生效,取决于谁排在后面,因为 return 出去的其实是一个对象,在对象里,如果存在相同的 key ,则后面的会覆盖前面的。

函数声明和应用

在 Vue 3,可以使用普通函数、 Class 类、箭头函数、匿名函数等等进行声明,可以将其写在 setup 里直接使用,也可以抽离在独立的 .js / .ts 文件里再导入使用。

需要在组件创建时自动执行的函数,其执行时机需要遵循 Vue 3 的生命周期,需要在模板里通过 @click@change 等行为触发,和变量一样,需要把函数名在 setup 里进行 return 出去。

案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<template>
  <p></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>

数据的侦听

侦听数据变化也是组件里的一项重要工作,比如侦听路由变化、侦听参数变化等等。

watch

在 Vue 3 的组合式 API 写法, watch 是一个可以接受 3 个参数的函数(保留了 Vue 2 的 this.$watch 这种用法),在使用层面上简单了很多。

1
2
3
4
5
6
7
8
import { watch } from 'vue'

// 一个用法走天下
watch(
  source, // 必传,要侦听的数据源
  callback, // 必传,侦听到变化后要执行的回调函数
  // options // 可选,一些侦听选项
)

在了解用法之前,先对它的 TS 类型声明做一个简单的了解, watch 作为组合式 API ,根据使用方式有两种类型声明:

  1. 基础用法的 TS 类型

    1
    2
    3
    4
    5
    6
    7
    8
    
     // 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
     // ...
    
  2. 批量侦听的 TS 类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
     // 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 都是接受三个入参,并返回一个可以用来停止侦听的函数:

参数 是否可选 含义
source 必传 数据源
callback 必传 侦听到变化后要执行的回调函数
options 可选 一些侦听选项

要侦听的数据源

watch API 的第 1 个参数 source 是要侦听的数据源,它的 TS 类型如下:

1
export declare type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)

能够用于侦听的数据,是通过响应式API定义的变量(Ref<T>),或者是一个计算数据(ComputedRef<T>),或者是一个 getter 函数(() => T)。所以要想定义的 watch 能够做出预期的行为,数据源必须具备响应性或者是一个 getter ,如果只是通过 let 定义一个普通变量,然后去改变这个变量的值,这样是无法侦听的。

侦听后的回调函数

watch API 的第 2 个参数 callback 是侦听到数据变化时要做出的行为,它的 TS 类型如下:

1
2
3
4
5
6
7
8
// watch 第 2 个入参的 TS 类型
// ...
export declare type WatchCallback<V = any, OV = any> = (
  value: V,
  oldValue: OV,
  onCleanup: OnCleanup,
) => any
// ...

这些参数不是自己定义的,而是 watch API 传递的,所以不管用或者不用,它们都在那里:

参数 作用
value 变化后的新值,类型和数据源保持一致
oldValue 变化前的旧值,类型和数据源保持一致
onCleanup 注册一个清理函数

默认情况下,watch 是惰性的,也就是只有当被侦听的数据源发生变化时才执行回调。

基础用法

只需要先关注前 2 个必传的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 不要忘了导入要用的 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 })
      },
    )
  },
})

批量侦听

批量侦听的数据源和回调参数都变成了数组的形式。

  1. 数据源:以数组的形式传入,里面每一项都是一个响应式数据。
  2. 回调参数:原来的 value 和 newValue 也都变成了数组,每个数组里面的顺序和数据源数组排序一致。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { 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 发生变化时,都需要执行初始化函数重置组件的状态,这个时候则可以用上批量侦听。

侦听的选项

watch API 还接受第 3 个参数 options ,可选的一些侦听选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 语句以调试依赖,这两个选项仅在开发模式下生效。

watchEffect

如果一个函数里包含了多个需要侦听的数据,一个一个数据去侦听太麻烦了,在 Vue 3 ,可以直接使用 watchEffect API 来简化的操作。这个 API 的类型如下,使用的时候需要传入一个副作用函数(相当于 watch 的 侦听后的回调函数 ),也可以根据的实际情况传入一些可选的 侦听选项 。和 watch API 一样,它也会返回一个用于 停止侦听 的函数:

1
2
3
4
5
6
7
8
9
// watchEffect 部分的 TS 类型
// ...
export declare type WatchEffect = (onCleanup: OnCleanup) => void

export declare function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase,
): WatchStopHandle
// ...

虽然理论上 watchEffect 是 watch 的一个简化操作,可以用来代替批量侦听,但它们也有一定的区别:

  1. watch 可以访问侦听状态变化前后的值,而 watchEffect 没有。
  2. watch 是在属性改变的时候才执行,而 watchEffect 则默认会执行一次,然后在属性改变的时候也会执行。

在侦听选项方面,对比 watch API ,它不支持 deep 和 immediate,其他的用法是一样的。

数据的计算与计算API

Vue 3 数据的计算也是使用 computed API ,它可以通过现有的响应式数据,去通过计算得到新的响应式变量。在 Vue 3.0 ,数据的计算跟其他 API 的用法一样,需要先导入 computed 才能使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 在 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 一个值,对,它需要有明确的返回值。

defineComponent 里,会自动帮推导 Vue API 的类型,所以一般情况下,是不需要显式的去定义 computed 出来的变量类型的。在确实需要手动指定的情况下,也可以导入它的类型然后定义:

1
2
3
4
5
6
7
import { computed } from 'vue'
import type { ComputedRef } from 'vue'

// 注意这里添加了类型声明
const fullName: ComputedRef<string> = computed(
  () => `${firstName.value} ${lastName.value}`,
)

要返回一个字符串,就写 ComputedRef<string> ;返回布尔值,就写 ComputedRef<boolean> ;返回一些复杂对象信息,可以先定义好的类型,再诸如 ComputedRef<UserInfo> 去写:

1
2
3
4
5
// 这是 ComputedRef 的类型声明:
export declare interface ComputedRef<T = any> extends WritableComputedRef<T> {
  readonly value: T
  [ComoutedRefSymbol]: true
}

优势

  • 性能优势:数据的计算是基于它们的响应依赖关系缓存的,只在相关响应式依赖发生改变时它们才会重新求值。只要原始数据没有发生改变,多次访问 computed ,都是会立即返回之前的计算结果,而不是再次执行函数;而普通的 function 调用多少次就执行多少次,每调用一次就计算一次。当个遇到较大的计算数据时,可以以此提升性能
  • 书写统一:Vue 3 的 Ref 变量是通过 foo1.value 来拿到值的,而 computed 也是通过 foo2.value ,并且在 template 里都可以省略 .value ,在读取方面,他们是有一致的风格和简洁性。

缺陷

  • 只会更新响应式数据的计算:假设要获取当前的时间信息,因为不是响应式数据,所以这种情况下就需要用普通的函数去获取返回值,才能拿到最新的时间。
  • 数据是只读的:通过 computed 定义的数据,它是只读的。如果直接赋值,不仅无法变更数据,而且会收获一个报错。虽然无法直接赋值,但是在必要的情况下,依然可以通过 computedsetter 来更新数据。

setter的使用

通过 computed 定义的变量默认都是只读的形式(只有一个 getter ),但是在必要的情况下,也可以使用其 setter 属性来更新数据。当需要用到 setter 的时候, computed 就不再是一个传入 callback 的形式了,而是传入一个带有 2 个方法的对象:

1
2
3
4
5
6
7
8
9
10
11
// 注意这里computed接收的入参已经不再是函数
const foo = computed({
  // 这里需要明确的返回一个值
  get() {
    // ...
  },
  // 这里接收一个参数,代表修改 foo 时,赋值下来的新值
  set(newValue) {
    // ...
  },
})

应用场景

数据的拼接和计算

字符串拼接、数据的求和等操作都可以利用计算API,比如说做一个购物车,购物车里有商品列表,同时还要显示购物车内的商品总金额,这种情况就非常适合用计算数据。

复用组件的动态数据

在一个项目里,很多时候组件会涉及到复用,这种情况下,往往并不需要每次都重新写 UI 、数据渲染等代码,仅仅是接口 URL 的区别。这种情况就可以通过路由名称来动态获取要调用哪个列表接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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,
    // ...
  })
  // ...
}

获取多级对象的值

经常遇到这样的情况:要在 template 显示一些多级对象的字段,而某些字段不一定有,需要做一些判断。虽然有 v-if ,但是嵌套层级一多,模板代码会难以维护。

如果把这些工作量转移给计算数据,结合 try / catch ,就无需在 template 里处理很多判断:

1
2
3
4
5
6
7
8
9
10
// 例子比较极端,但在 Vuex 这种大型数据树上,也不是完全不可能存在
const foo = computed(() => {
  // 正常情况下返回需要的数据
  try {
    return store.state.foo3.foo2.foo1.foo
  } catch (e) {
    // 处理失败则返回一个默认值
    return ''
  }
})

不同类型的数据转换

有时候会遇到一些需求,类似于:让用户在输入框里按一定的格式填写文本,比如用英文逗号 , 隔开每个词,然后保存时用数组的格式提交给接口。

这个时候 computed 的 setter 就发挥妙用了,只需要一个简单的 computed ,就可以代替 input 的 change 事件或者 watch 侦听,可以减少很多业务代码的编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<template>
  <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>

指令

指令是 Vue 模板语法里的特殊标记,在使用上和 HTML 的 data-* 属性十分相似,统一以 v- 开头( e.g. v-html )。

它以简单的方式实现了常用的 JavaScript 表达式功能,当表达式的值改变的时候,响应式地作用到 DOM 上。Vue 提供了一些内置指令可以直接使用;如果 Vue 的内置指令不能满足业务需求,也可以开发自定义指令。

相关TS类型

自定义指令有两种实现形式,一种是作为一个对象,其中的写法比较接近于 Vue 组件,其他的每一个属性都是一个钩子函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 对象式写法的 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 这两个钩子生效,并且触发一样的行为:

1
2
3
4
5
6
7
8
// 函数式写法的 TS 类型
// ...
export declare type FunctionDirective<T = any, V = any> = DirectiveHook<
  T,
  any,
  V
>
// ...

自定义指令最核心的就是 “钩子函数”。

钩子函数

和组件的生命周期类似,自定义指令里的逻辑代码也有一些特殊的调用时机,在这里称之为钩子函数:

钩子函数 调用时机
created 在绑定元素的 attribute 或事件侦听器被应用之前调用
beforeMount 当指令第一次绑定到元素并且在挂载父组件之前调用
mounted 在绑定元素的父组件被挂载后调用
beforeUpdate 在更新包含组件的 VNode 之前调用
updated 在包含组件的 VNode 及其子组件的 VNode 更新后调用
beforeUnmount 在卸载绑定元素的父组件之前调用
unmounted 当指令与元素解除绑定且父组件已卸载时,只调用一次

每个钩子函数都有 4 个入参:

参数 作用
el 指令绑定的 DOM 元素,可以直接操作它
binding 一个对象数据,见下方的单独说明
vnode el 对应在 Vue 里的虚拟节点信息
prevVNode Update 时的上一个虚拟节点信息,仅在 beforeUpdate 和 updated 可用

其中用的最多是 el 和 binding 。

  • el 的值就是通过 document.querySelector 拿到的那个 DOM 元素。
  • binding 是一个对象,里面包含了以下属性:
属性 作用
value 传递给指令的值,例如 v-foo="bar" 里的 bar ,支持任意有效的 JS 表达式
oldValue 指令的上一个值,仅对 beforeUpdate 和 updated 可用
arg 传给指令的参数,例如 v-foo:bar 里的 bar
modifiers 传给指令的修饰符,例如 v-foo.bar 里的 bar
instance 使用指令的组件实例
dir 指令定义的对象(就是上面的 const myDirective = { /* ... */ } 这个对象)

指令可以通过局部注册或全局注册的方式进行,使用后者就无需在每个组件里定义,只需在入口文件main.ts中启用。

除了钩子函数,在相关的TS类型里还可以看到有一个 deep 选项,它是一个布尔值,作用是:

如果自定义指令用于一个有嵌套属性的对象,并且需要在嵌套属性更新的时候触发 beforeUpdate 和 updated 钩子,那么需要将这个选项设置为 true 才能够生效。

插槽

Vue 在使用子组件的时候,子组件在 template 里类似一个 HTML 标签,可以在这个子组件标签里传入任意模板代码以及 HTML 代码,这个功能就叫做 “插槽” 。

默认插槽

默认情况下,子组件使用 标签即可渲染父组件传下来的插槽内容。

具名插槽

有时候可能需要指定多个插槽,例如一个子组件里有 “标题” 、 “作者”、 “内容” 等预留区域可以显示对应的内容,这时候就需要用到具名插槽来指定不同的插槽位。

子组件通过 name 属性来指定插槽名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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 格式的属性,来指定传入哪个插槽里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
  <Child>
    <!-- 传给标题插槽 -->
    <template v-slot:title>
      <h1>这是标题</h1>
    </template>

    <!-- 传给作者插槽 -->
    <template v-slot:author>
      <h1>这是作者信息</h1>
    </template>

    <!-- 传给默认插槽 -->
    <p>这是插槽内容</p>
  </Child>
</template>

在使用具名插槽的时候,子组件如果不指定默认插槽,那么在具名插槽之外的内容将不会被渲染。

可以给 slot 标签添加内容,例如 <slot>默认内容</slot> ,当父组件没有传入插槽内容时,会使用默认内容来显示,默认插槽和具名插槽均支持该功能。

有一条规则需要记住

  • 父组件里的所有内容都是在父级作用域中编译的
  • 子组件里的所有内容都是在子作用域中编译的

CSS 样式与预处理器

编写组件样式表

最基础的写法,就是在 .vue 文件里添加一个 <style /> 标签,即可在里面写 CSS 代码了。

动态绑定 CSS

动态绑定 CSS ,在 Vue 2 就已经存在了,在此之前常用的是 :class:style ,现在在 Vue 3 ,还可以通过 v-bind 来动态修改了。

使用 :class 动态修改样式名

它是绑定在 DOM 元素上面的一个属性,跟 class="class-name" 这样的属性同级别,它非常灵活!如果只想绑定一个单独的动态样式,可以传入一个字符串;如果有多个动态样式,也可以传入一个数组;还可以对动态样式做一些判断,这个时候传入一个对象;多个判断的情况下,记得也用数组套起来。

最常见用到 :class 的场景就是导航、选项卡了,比如要给一个当前选中的选项卡做一个突出高亮的状态,那么就可以使用 :class 来动态绑定一个样式。这样就简单实现了一个点击切换选项卡高亮的功能。

使用 :style 动态修改内联样式

如果觉得使用 :class 需要提前先写样式,再去绑定样式名有点繁琐,有时候只想简简单单的修改几个样式,那么可以通过 :style 来处理。

默认的情况下,都是传入一个对象去绑定:

  • key 是符合 CSS 属性名的 “小驼峰式” 写法,或者套上引号的短横线分隔写法(原写法),例如在 CSS 里,定义字号是 font-size ,那么需要写成 fontSize 或者 'font-size' 作为它的键。
  • value 是 CSS 属性对应的 “合法值”,比如要修改字号大小,可以传入 13px 、0.4rem 这种带合法单位字符串值,但不可以是 13 这样的缺少单位的值,无效的 CSS 值会被过滤不渲染。

如果有些特殊场景需要绑定多套 style,需要在 script 先定义好各自的样式变量(也是符合上面说到的那几个要求的对象),然后通过数组来传入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
  <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 /> 部分去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<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 变量来实现的一个功能,它渲染到 DOM 上,其实也是通过绑定 style 来实现。理论上 v-bind 函数可以在 Vue 内部支持任意的 JavaScript 表达式,但由于可能包含在 CSS 标识符中无效的字符,因此官方是建议在大多数情况下,用引号括起来。由于 CSS 变量的特性,因此对 CSS 响应式属性的更改不会触发模板的重新渲染(这也是和 :class:style 的最大不同)。

样式表的组件作用域

CSS是没有作用域的概念的,一旦写了某个样式,直接就是全局污染。在 Vue 组件里,有两种方案可以避免出现这种污染问题:一个是 Vue 2 就有的 <style scoped> ,一个是 Vue 3 新推出的 <style module> 。使用 scoped 后,父组件的样式将不会渗透到子组件中,也不能直接修改子组件的样式。

如果确实需要修改子组件的样式,必须通过 ::v-deep(完整写法) 或者 :deep(快捷写法) 操作符来实现。

使用 CSS 预处理器

在工程化的现在,可以说前端都几乎不写 CSS 了,都是通过 sasslessstylus 等 CSS 预处理器来完成样式的编写。

在 Vue 组件里使用预处理器非常简单,像 Vite 已内置了对预处理器文件的支持(可处理 .less.scss 之类的预处理器扩展名文件),因此只需要安装对应的依赖到项目里。

这里以 Less 为例,先安装该预处理器:

1
2
# 因为是在开发阶段使用,所以添加到 `devDependencies`
npm i -D less

接下来在 Vue 组件里,只需要在 <style /> 标签上,通过 lang="less" 属性指定使用哪个预处理器,即可直接编写对应的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<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 代码:

1
2
3
4
5
6
7
8
9
10
.msg {
  width: 100%;
}
.msg p {
  color: #333333;
  font-size: 14px;
}
.msg p span {
  color: #ff0000;
}
本文由作者按照 CC BY 4.0 进行授权