Vue学习记录20
单文件组件
介绍
Vue的单文件组件(即 *.vue 文件,英文 Single-File Component,简称SFC)是一种特殊的文件格式,使我们能够将一个 Vue 组件的模板、逻辑与样式封装在单个文件中。下面是一个单文件组件的示例:
<script setup>
import { ref } from 'vue'
const greeting = ref('Hello World!')
</script>
<template>
<p class="greeting">{{ greeting }}</p>
</template>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
如你所见,Vue的单文件组件是网页开发中 HTML、CSS 和 JavaScript 三种语言经典组合的自然延伸。 <template>、<script> 和 <style> 三个块在同一个文件中封装、组合了组件的视图、逻辑和样式。
为什么使用单文件组件
使用单文件组件必须使用构建工具,但作为汇报带来了以下优点:
- 使用熟悉的HTML、CSS 和 JavaScript 语法编写模块化的组件
- 让本来就强相关的关注点自然内聚
- 预编译模板,避免运行时的编译开销
- 组件作用域的CSS
- 在使用组合式API时语法更简单
- 通过交叉分析模板和逻辑代码能进行更多编译时优化
- 更好的IDE支持,提供自动补全和对模块中表达式的类型检查
- 开箱即用的模块热更新(HMR)支持
单文件组件是Vue框架提供的一个功能,并且在下列场景中都是官方推荐的项目组织方式:
- 单页面应用(SPA)
- 静态站点生成(SSG)
- 任何值得引入构建步骤以获得更好的开发体验(DX)的项目
当然,在一些轻量级场景下使用单文件组件会显得有些杀鸡用牛刀。因此Vue同样也可以在无构建步骤的情况下以纯 JavaScript 方式使用。如果你的用例只需要给静态 HTML 添加一些简单的交互,可以使用 petite-vue,它是一个6kB左右,预优化过的Vue子集,更适合渐进式增强的需求。
单文件组件是如何工作的
Vue单文件组件是一个框架指定的文件格式,因此必须交由 @vue/compiler-sfc 编译为标准的 JavaScript 和 CSS,一个编译后的单文件组件是一个标准的 JavaScript(ES)模块,这也意味着在构建配置正确的前提下,可以像导入其他ES模块导入单文件组件:
import MyComponent from './MyComponent.vue'
export default {
components: {
MyComponent
}
}
单文件组件中的 <style> 标签一般会在开发时注入成原生的 <style> 标签以支持热更新,而生产环境下它们会被抽取,合并成单独的 CSS 文件。
在实际项目中,一般会使用集成了单文件组件编译器的构建工具,比如 Vite 或者 Vue CLI(基于 webpack),Vue 官方也提供了脚手架工具来帮助你尽可能快速地上手开发单文件组件。
工具链
vite
Vite 是一个轻量级的、速度极快的构建工具,对Vue单文件组件提供第一优先级支持。
要使用 Vite 来创建一个 Vue 项目,非常简单:
npm create vue@latest
这个命令会安装和执行 create-vue ,它是Vue提供的官方脚手架工具。跟随命令行的提示继续操作即可。
Vue CLI
Vue CLI 是官方提供的基于 Webpack的 Vue 工具链,它现在处于维护模式。建议使用 Vite 开始新的项目,除非你依赖特定的 Webpack 的特性。在大多数情况下,Vite 将提供更优秀的开发体验。
浏览器内模板编译注意事项
当以无构建步骤方式使用 Vue 时,组件模板要么是写在页面的 HTML 中,要么是内联的 JavaScript 字符串。 在这些场景中,为了执行动态模板编译,Vue 需要将模板编译器运行在浏览器中。相对的,如果使用了构建步骤,由于提前编译了模板,那么就无须在浏览器中运行。为了减小打包出的客户端代码体积,Vue 提供了多种格式的“构建文件”以适配不同场景下的优化需求。
- 前缀为 vue.runtime.* 的文件是只包含运行时的版本:不包含编译器,当使用这个版本时,所有的模板都必须由构建步骤预先编译。
- 名称中不包含.runtime 的文件则是完全版:即包含了编译器,并支持在浏览器中直接编译模板。然而,体积也会因此增长大约 14kb。
默认的工具链中都会使用仅含运行时的版本,因为所有单文件组件中的模板都已经被预编译了。如果因为某些原因,在有构建步骤时,你仍然需要浏览器内的模板编译,你可以更改构建工具配置,将vue改为相应的版本 vue/dist/vue.esm-bundler.js。
IDE支持
推荐使用的 IDE 是 VS Code,配合 Vue-Offical扩展。该插件提供了语法高亮、TypeScript支持,以及模块内表达式与组件 props 的智能提示。
WebStorm 同样也为Vue的单文件提供了很好的内置支持。
其他语言服务协议(LSP)的IDE 也可以通过LSP 享受到 Volar 所提供的核心功能:
-
Sublime Text 通过 LSP-Volar 支持。
-
vim / Neovim 通过 coc-volar 支持。
-
emacs 通过 lsp-mode 支持。
浏览器开发者插件
Vue 的浏览器开发者插件使我们可以浏览一个 Vue 应用的组件树,查看各个组件的状态,追踪管理的事件,还可以进行组件性能分析。
TypeScript
- Vue-Offical 扩展能够为 <script lang="ts"> 块提供类型检查,也能对模块内表达式和组件之间 props 提供自动补全和类型验证。
- 使用 vue-tsc 可以在命令行中执行相同的类型检查,通常用来生成单文件组件的 d.ts 文件。
测试
- Cypress 推荐用于 E2E 测试。 也可以通过 Cypress 组件测试运行器来给 Vue 单文件组件作单文件组件测试。
- Vitest 是一个追求更快运行速度的测试运行器,由 Vue/ Vite 团队成员开发。 主要针对基于 Vite 的应用设计,可以为组件提供即时响应的测试反馈。
- Jest 可以通过 vite-jest 配合 Vite 使用。不过只推荐在你已经由一套基于Jest 的测试集、且想要迁移到基于 Vite 的开发配置时使用,因为 Vitest 也能够提供类似的功能,且后者与 Vite 的集成更方便高效。
代码规范
Vue 团队维护者 eslint-plugin-vue 项目,它是一个 ESLint插件,会提供单文件组件相关规则的定义。
- 使用 Vue CLI 的开发者可能习惯于通过 webpack loader 来配置规范检查器。然而,若基于 Vite 构建,一般推荐:
- npm install -D eslint eslint-plugin-vue, 然后遵照 eslint-plugin-vue 的指引进行配置。
- 启用 ESlint IDE 插件,比如 ESLint for VS Code,然后你就可以在开发时获得规范检查器的反馈。这同时也避免了启动开发服务器时不必要的规范检查。
- 将ESLint 格式检查作为一个生产构建的步骤,保证你可以在最终打包时获得完整的规范检查反馈。
- (可选)启用类似 lint-staged 一类的工具在 git commit 提交时自动执行规范检查。
格式化
- Vue - Official VS Code 插件为Vue单文件组件提供了开箱即用的格式化功能。
- 除此之外,Prettier 也提供了内置的 Vue 单文件组件格式化支持。
单文件自定义块集成
自定义块被编译成导入到同一 Vue 文件的不同请求查询。这取决于底层构建工具如何处理这类导入请求。
-
如果使用 Vite,需使用一个自定义 Vite 插件将自定义块转换为可执行的 JavaScript 代码。
-
如果使用 Vue CLI 或只是 webpack,需要使用一个 loader 来配置如何转换匹配到的自定义块。
底层库
@vue/compiler-sfc
这个包是Vue核心 monorepo 的一部分,并始终和 vue 主包版本号保持一致。它已经成为 vue 主包的一个依赖并代理到了 vue/compiler-sfc 目录下,因此你无需单独安装它。
这个包本身提供了处理Vue 单文件组件的底层的功能,并只适用于需要支持 Vue 单文件组件相关工具链的开发者。
Tip
请始终选择通过 vue/compiler-sfc 的深度导入来使用这个包,因为这样可以确保其与Vue运行时版本同步。
@vitejs/plugin-vue
为Vite 提供 Vue 单文件组件支持的官方插件。
vue-loader
为 webpack 提供 Vue 单文件组件支持的官方 loader。
路由
客户端 vs. 服务端路由
服务端路由指的是服务器根据用户访问的 URL 路径返回不同的响应结果。当我们在一个传统的服务端渲染的 web 应用中点击一个链接时,浏览器会从服务端获得全新的 HTML,然后重新加载整个界面。
然而,在单页面应用中,客户端的 JavaScript 可以拦截页面的跳转请求,动态获取新的数据,然后在无需重新加载的情况下更新当前页面。这样同样可以带来更顺滑的用户体验,尤其是在更偏向“应用”的场景下,因为这类场景下用户通常会在很长的一段时间中做出多次交互。
在这类单页应用中,“路由”是在客户端执行的。一个客户端路由器的职责就是利用诸如 History API 或是 hashchange 事件这样的浏览器来管理应用当前渲染的视图。
官方路由
Vue 很适合用来构建单页面应用。 对于大多数此类应用,都推荐使用官方支持的路由库。
从头开始实现一个简单的路由
如果你只需要一个简单的页面路由,而不想为此引入一整个路由库,你可以通过动态组件的方式,监听浏览器 hashchange 事件或使用 History API 来更新当前组件。
下面是一个简单的例子:
<script setup>
import { ref, computed } from 'vue'
import Home from './Home.vue'
import About from './About.vue'
import NotFound from './NotFound.vue'
const routes = {
'/': Home,
'/about': About
}
const currentPath = ref(window.location.hash)
window.addEventListener('hashchange', () => {
currentPath.value = window.location.hash
})
const currentView = computed(() => {
return routes[currentPath.value.slice(1) || '/'] || NotFound
})
</script>
<template>
<a href="#/">Home</a> |
<a href="#/about">About</a> |
<a href="#/non-existent-path">Broken Link</a>
<component :is="currentView" />
</template>
状态管理
什么是状态管理?
理论上来说,每一个 Vue 组件实例都已经在 “管理”它自己的响应式状态了。以一个简单的计数器组件为例:
<script setup>
import { ref } from 'vue'
// 状态
const count = ref(0)
// 动作
function increment() {
count.value++
}
</script>
<!-- 视图 -->
<template>{{ count }}</template>
它是一个独立的单元,由以下几个部分组成:
- 状态:驱动整个应用的数据源;
- 视图:对状态的一种声明式映射;
- 交互:状态根据用户在视图中的输入而作出相应变更的可能方式。
下面是“单向数据流”这一概念的简单图示:
然而:当我们有多个组件共享一个共同的状态时,就没有这么简单了:
- 多个视图可能都依赖于同一份状态。
- 来自不同视图的交互也可能需要更改同一份状态。
对于情景1,一个可行的办法是将共享状态“提升”到共同的祖先组件上去,再通过 props 传递下来。然而在深层次的组件树结构中这么做的话,很快就会使得代码变得繁琐冗长。 这会导致另一个问题:Prop 逐级透传问题。
对于情景2,我们经常发现自己会直接通过模板引用获取父/子实例,或者通过触发的事件尝试改变和同步多个状态的副本。但这些模式的健壮性都不甚理想,很容易就会导致代码难以维护。
一个更简单直接的解决方案是抽取出组件间的共享状态,放在一个全局单例中来管理。这样我们的组件树就变成了一个大的“视图”,而任何位置上的组件都可以访问其中的状态或触发动作。
用响应式 API 做简单状态管理
如果你有一部分状态需要在多个组件实例间共享,你可以使用 reactive() 来创建一个响应式对象,并将它导入到多个组件中:
// store.js
import { reactive } from 'vue'
export const store = reactive({
count: 0
})
<!-- ComponentA.vue -->
<script setup>
import { store } from './store.js'
</script>
<template>From A: {{ store.count }}</template>
<!-- ComponentB.vue -->
<script setup>
import { store } from './store.js'
</script>
<template>From B: {{ store.count }}</template>
现在每当 store对象被更改时,<ComponentA> 与 <ComponentB> 都会自动更新它们的视图。现在我们有了单一的数据源。
然而,这也意味着任意一个导入了 store 的组件都可以随意修改它的状态:
<template>
<button @click="store.count++">
From B: {{ store.count }}
</button>
</template>
虽然这在简单的情况下是可行的,但从长远来看,可以被任意组件任意改变的全局状态是不太容易维护的。为了确保改变状态的逻辑像状态本身一样集中,建议在 store 上定义方法,方法的名称应该要能表达出行动的意图:
// store.js
import { reactive } from 'vue'
export const store = reactive({
count: 0,
increment() {
this.count++
}
})
<template>
<button @click="store.increment()">
From B: {{ store.count }}
</button>
</template>
Tip
请注意这里点击的处理函数使用了 store.increment(),带上了圆括号作为内联表达式调用,因为它并不是组件的方法,并且必须要以正确的 this 上下文来调用。
除了我们这里用到的单个响应式对象作为一个 store 之外,你还可以使用其他 响应式 API 例如 ref() 或是 computed(),或是甚至通过一个组合式函数来返回一个全局状态:
import { ref } from 'vue'
// 全局状态,创建在模块作用域下
const globalCount = ref(1)
export function useCount() {
// 局部状态,每个组件都会创建
const localCount = ref(1)
return {
globalCount,
localCount
}
}
事实上,Vue的响应性系统与组件层是解耦的,这使得它非常灵活。
SSR 相关细节
如果你正在构建一个需要利用服务端渲染(SSR)的应用,由于store是跨多个请求共享的单例,上述模式可能会导致问题。
Pinia
虽然我们的手动状态管理方案在简单的场景中已经足够了,但是在大规模的生产应用中还有其他事项需要考虑:
- 更强的团队协作约定
- 与Vue DevTools集成,包括时间轴、组件内部审查和时间旅行调试
- 模块热更新(HMR)
- 服务端渲染支持
Pinia 就是一个实现了上述需求的状态管理库,由Vue核心团队维护,对Vue2 和 Vue3 都可用。
测试
为什么需要测试
自动化测试能够预防无意引入的bug,并鼓励开发者将应用分解为可测试、可维护的函数、模块、类和组件。这能够帮助你和你的团队更快速、自信地构建复杂的Vue应用。与任何应用一样,新的Vue 应用可能会以多种方式崩溃,因此,在发布前发现并解决这些问题就变得十分重要。
何时测试
越早越好! 建议尽快编写测试。拖得越久,应用就会有越多的依赖和复杂性,想要开始添加测试也就越困难。
测试的类型
当设计你的 Vue 应用的测试策略时,你应该利用以下几种测试类型:
- 单元测试:检查给定函数、类或组合式函数的输入是否产生预期的输出或副作用。
- 组件测试:检查你的组件是否正常挂载和渲染、是否可以与之互动,以及表现是否符合预期。这些测试比单元测试导入了更多的代码,更复杂,需要更多时间来执行。
- 端到端测试:检查跨越多个页面的功能,并对生产构建的 Vue 应用进行实际的网络请求。这些测试通常涉及到建立一个数据库或其他后端。
每种测试类型在你的应用的测试策略中都发挥着作用,保护你免受不同类型的问题的影响。
单元测试
编写单元测试是为了验证小的、独立的代码单元是否按照预期工作,一个单元测试通常覆盖一个哈数、类、组合式函数或模块。单元测试侧重于逻辑的正确性,只关注应用整体功能的一小部分。他们可能会模拟你的应用环境的很大一部分(如初始状态、复杂的类、第三方模块和网络请求)。
一般来说,单元测试将捕获函数的业务逻辑和逻辑正确性的问题。
以这个 increment 函数为例:
// helpers.js
export function increment(current, max = 10) {
if (current < max) {
return current + 1
}
return current
}
因为它很独立,可以很容易地调用 increment 函数并断言它是否返回了所期望的内容,所以我们将编写一个单元测试。
如果任何一条断言失败了,那么问题一定是出在 increment 函数上。
// helpers.spec.js
import { increment } from './helpers'
describe('increment', () => {
test('increments the current number by 1', () => {
expect(increment(0, 10)).toBe(1)
})
test('does not increment the current number over the max', () => {
expect(increment(10, 10)).toBe(10)
})
test('has a default max of 10', () => {
expect(increment(10)).toBe(10)
})
})
如前所述,单元测试通常适用于独立的业务逻辑、组件、类、模块或函数,不涉及UI 渲染、网络请求或其他环境问题。
这些通常是与 Vue 无关的纯 JavaScript/TypeScript 模块。一般来说,在Vue应用中为业务逻辑编写单元测试与使用其他框架的应用没有明显区别。
但有两种情况,你必须对Vue的特定功能进行单元测试:
- 组合式函数
- 组件
组合式函数
有一类 Vue 应用中特有的函数被称为 组合式函数,在测试过程中可能需要特殊处理。
组建的单元测试
一个组件可以通过两种方式测试:
1.白盒:单元测试
白盒测试知晓一个组件的实现细节和依赖关系。它们更专注于将组件进行更独立的测试。这些测试通常会涉及到模拟一些组件的部分子组件,以及设置插件的状态和依赖性(例如 Pinia)。
2.黑盒:组件测试
黑盒测试不知晓一个组件的实现细节。这些测试尽可能少地模拟,以测试组件在整个系统中的集成情况。它们通常会渲染所有子组件,因而会被认为更像一种“集成测试”。
推荐方案
Vitest
因为由 create-vue 创建的官方项目配置是基于 Vite 的,所以推荐你使用一个可以利用同一套 Vite 配置和转换管道的单元测试框架。 Vitest 正是一个针对此目标设计的单元测试框架,它由 Vue / Vite 团队成员开发和维护。 在 Vite 的项目集成它会非常简单,而且速度非常快。
其他选择
Jest 是一个广受欢迎的单元测试框架。不过只推荐你在已有一套 Jest 测试配置、且需要迁移到基于 Vite 的项目时使用它,因为 Vitest 提供了更无缝的集成和更好的性能。
组件测试
在Vue应用中,主要用组件来构建用户界面。因此,当验证应用的行为时,组件是一个很自然的独立单元。从粒度的角度来看,组件测试位于单元测试之上,可以被认为是集成测试的一种形式。你的 Vue 应用中大部分内容都应该由组件测试来覆盖,建议每个Vue组件都应有自己的组件测试文件。
组件测试应该捕捉组件中的 prop、事件、提供的插槽、样式、CSS class名、生命周期钩子,和其他相关的问题。
组件测试主要需要关心组件的公开接口而不是内部实现细节。对于大部分的组件来说,公开接口包括触发的事件、prop 和 插槽。当进行测试时,请记住,测试这个组件做了什么,而不是测试它是怎么做到的。
在下面的例子中,展示了一个步进器(Stepper)组件,它拥有一个标记为 increment 的可点击的 DOM 元素。 还传入了一个名为 max 的 prop 放置步进器增长超过 2,因此如果点击了按钮3次,视图将仍然显式2。
我们不了解这个步进器的实现细节,只知道 “输入” 是这个 max prop,“输出” 是这个组件状态所呈现出的视图。
const valueSelector = '[data-testid=stepper-value]'
const buttonSelector = '[data-testid=increment]'
const wrapper = mount(Stepper, {
props: {
max: 1
}
})
expect(wrapper.find(valueSelector).text()).toContain('0')
await wrapper.find(buttonSelector).trigger('click')
expect(wrapper.find(valueSelector).text()).toContain('1')
应避免的做法
- 不要区断言一个组件实例的私有状态或测试一个组件的私有方法。测试实现细节会使测试代码太脆弱,因为当实现发生变化时,它们更有可能失败并需要更新重写。
- 组件的最终工作是渲染正确的DOM输出,所以专注于DOM输出的额测试提供了足够的正确性保证(如果你不需要更多其他方面测试的话),同时更加健壮、需要的改动更少。
- 不要完全依赖快照测试。断言 HTML 字符串并不能完全说明正确性。应当编写有意图的测试。
- 如果一个方法需要测试,把它提取到一个独立的实用函数中,并为它写一个专门的单元测试。如果它不能被直截了当地抽离出来,那么对它地调用应该作为交互测试地一部分。
推荐方案
- Vitest 对于组件和组合式函数都采用无头渲染地方式(例如VueUse 中地 useFavicon 函数)。组件和 DOM 都可以通过 @vue/test-utils 来测试。
- Cypress 组件测试 会预期其准确地渲染样式或者触发原生DOM事件。它可以搭配 @testing-library/cypress 这个库一同进行测试。
Vitest 和基于浏览器地运行器之间地主要区别是速度和执行上下文。简而言之,基于浏览器地运行器,如 Cypress,可以捕捉到基于 Node的运行器(如 Vitest)所不能捕捉的问题(比如样式问题、原生DOM事件、Cookies、本地存储和网络故障),但基于浏览器的运行器比 Vitest 慢几个数量级,因为它们要执行打开浏览器,编译样式表以及其他步骤。Cypress 是一个基于浏览器的运行器,支持组件测试。
组件挂载库
组件测试通常涉及到单独挂载被测试的组件,触发模拟的用户输入事件,并对渲染的DOM输出进行断言。有一些专门的工具库可以使这些任务变得更简单。
- @vue/test-utils 是官方的底层组件测试库,用来提供给用户访问 Vue 特有的API。@testing-library/vue 也是基于此库构建的。
- @testing-library/vue 是一个专注于测试组件而不依赖于实现细节的Vue测试库。它的指导原则是:测试越是类似于软件的使用方式,它们就能提供越多的信心。
推荐在应用中使用 @vue/test-utils 测试组件。 @testing-library/vue 在测试带有 Suspense 的异步组件时存在问题,在使用时需要谨慎。
其他选择
Nightwatch 是一个端到端测试运行器,支持Vue的组件测试。
WebdriverIO 用于跨浏览器组件测试,该测试依赖于基本标准自动化的原生用户交互。它也可以与测试库一起使用。
端到端(E2E)测试
虽然单元测试为所写的代码提供了一定程度的验证,但单元测试和组件测试在部署到生产时,对应用整体覆盖的能力有限。因此,端到端测试针对的可以说是应用最重要的方面:当用户实际使用你的应用时发生了什么。
端到端测试的重点是多页面的应用表现,针对你的应用在生产环境下进行网络请求。他们通常需要建立一个数据库或其他形式的后端,甚至可能针对一个预备上线的环境运行。
端到端测试通常会捕捉到路由、状态管理库、顶级组件(常见为 App 或 Layout)、公共资源或任何请求处理方面的问题。如上所述,它们可以捕捉到单元测试或组件测试无法捕获的关键问题。
端到端测试验证了你的应用中的许多层。可以在你的本地构建的应用中,甚至是一个预上线的环境中运行。针对预上线环境的测试不仅包括你的前端代码和静态服务器,还包括所有相关的后端服务和基础设施。
通过测试用户操作如何影响你的应用,端到端的测试通常是提高应用能否正常运行的置信度的关键。
添加 Vitest 到项目中
在一个基于 Vite 的 Vue 项目中,运行如下命令:
> npm install -D vitest happy-dom @testing-library/vue
接着,更新你的 Vite 配置,添加上 test 选项:
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
// ...
test: {
// 启用类似 jest 的全局测试 API
globals: true,
// 使用 happy-dom 模拟 DOM
// 这需要你安装 happy-dom 作为对等依赖(peer dependency)
environment: 'happy-dom'
}
})
Tip
如果使用 TypeScript,请将 vitest/globals 添加到 tsconfig.json 的types 字段当中。
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
接着,在你的项目中创建名字以 *.test.js 结尾的文件。你可以把所有的测试文件放在项目根目录下的 test 目录中,或者放在源文件旁边的 test 目录中。 Vitest 会使用命令规则自动搜索它们。
// MyComponent.test.js
import { render } from '@testing-library/vue'
import MyComponent from './MyComponent.vue'
test('it should work', () => {
const { getByText } = render(MyComponent, {
props: {
/* ... */
}
})
// 断言输出
getByText('...')
})
最后,在 package.json 之中添加测试命令,然后运行它:
{
// ...
"scripts": {
"test": "vitest"
}
}
> npm test