Vue 基础体系 · 第 1/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
Vue 3 的学习不能只停留在模板语法。一个能维护、能测试、能上线的 Vue 应用,至少要同时理解五层问题:
- JavaScript、TypeScript 与浏览器基础:模块、异步、对象引用、DOM、HTTP、Cookie 和构建产物。
- Vue 核心运行时:响应式系统、模板编译、组件实例、生命周期和更新调度。
- 组件化应用:Props、Emits、
v-model、插槽、透传属性、依赖注入以及状态边界。 - 应用工程化:Vite、路由、状态管理、类型检查、测试、代码分割和错误监控。
- 服务端渲染与生产交付:SSR、Hydration、环境配置、静态资源、缓存、灰度发布和回滚。
这几层存在明确的因果关系:响应式系统决定数据变化后如何更新视图;组件契约决定数据如何跨边界流动;构建工具决定代码如何被浏览器加载;SSR 决定首屏 HTML 如何生成及如何接管;生产交付则决定用户最终拿到哪个版本、哪些资源以及发生故障时如何恢复。
一、学习 Vue 前必须补齐的前置知识
1. JavaScript:先理解引用、闭包和异步
Vue 的响应式并不是魔法,它建立在 JavaScript 对象、函数和事件循环之上。
const state = { count: 0 }
const alias = state
alias.count++
console.log(state.count) // 1
alias 和 state 指向同一个对象。Vue 需要知道的不是“某个变量被赋值了”,而是“某个响应式对象的哪个属性被读取或修改了”。
闭包同样重要:
function createCounter() {
let count = 0
return {
get value() {
return count
},
increment() {
count++
},
}
}
const counter = createCounter()
counter.increment()
console.log(counter.value) // 1
count 被闭包保留,这说明函数可以携带状态。Vue 的 ref 也可以理解为一种受控的状态容器,但它还额外接入了依赖追踪。
异步代码则直接影响组件生命周期和竞态处理:
let requestId = 0
async function loadUser(id: string) {
const currentId = ++requestId
const response = await fetch(`/api/users/${id}`)
if (currentId !== requestId) {
return
}
return response.json()
}
如果用户先后选择了 A 和 B,而 A 的请求后返回,单纯地把响应写入状态会让旧数据覆盖新数据。Vue 只负责触发更新,不会自动替你解决请求竞态。
2. TypeScript:类型描述边界,不替代运行时校验
TypeScript 在编译阶段检查代码,但接口返回值来自网络,运行时仍然可能不符合类型声明:
interface User {
id: string
name: string
}
const user = (await response.json()) as User
as User 只是告诉 TypeScript“相信我”,并没有检查数据。对关键边界应使用运行时校验库或手动校验:
function isUser(value: unknown): value is User {
if (!value || typeof value !== 'object') return false
const item = value as Record<string, unknown>
return typeof item.id === 'string' &&
typeof item.name === 'string'
}
Vue 项目中常见的类型边界包括 Props、Emits、路由参数、接口响应和环境变量。类型越靠近边界,越应该明确;组件内部的临时推导则不必过度注解。
3. Vite:开发服务器不是生产服务器
Vite 开发阶段通常使用原生 ESM 和按需转换,生产阶段则通过 Rollup 体系构建静态产物。两者行为不同:
npm create vite@latest vue-route-demo -- --template vue-ts
cd vue-route-demo
npm install
npm run dev
典型脚本如下:
{
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
}
}
npm run build 的关键步骤通常是:
- TypeScript 类型检查;
- Vue 单文件组件转换;
- 模块依赖分析;
- 代码压缩与分包;
- 生成
dist静态文件。
vite preview 用于本地预览构建结果,不等同于生产级静态服务器。生产环境还需要处理 HTTPS、压缩、缓存头、回源、日志和回滚。
二、Vue 的基本执行模型:模板、组件和更新
Vue 单文件组件通常由三部分组成:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button type="button" @click="count++">
点击次数:{{ count }}
</button>
</template>
<style scoped>
button {
padding: 0.5rem 1rem;
}
</style>
<script setup> 是编译期语法。顶层声明的变量、函数和导入会自动暴露给模板。它不是运行时函数,也不应该被理解为每次渲染都会重新执行的普通脚本。
执行过程可以简化为:
- 组件实例创建;
setup()逻辑建立响应式状态;- 模板被编译为渲染函数;
- 渲染函数读取状态并生成虚拟 DOM;
- 首次渲染得到真实 DOM;
- 状态变化后,Vue 调度组件重新渲染;
- 新旧虚拟 DOM 比较后,只提交必要的 DOM 修改。
这里的“重新渲染”不等于“整个页面重新创建”。Vue 会重新执行渲染函数,但 DOM 提交阶段会尽量复用已有节点。
三、响应式原理:从读取追踪到修改触发
1. 响应式的形式化条件
设状态为 ,渲染函数为:
其中:
- 是响应式状态;
- 是渲染或计算函数;
- 是生成的视图结果。
当状态从 变为 时,如果:
则相关视图需要重新计算。
但 Vue 不能每次任意属性变化都重新执行所有组件。它需要记录:
其中 表示函数 实际读取过的属性集合。只有当修改的属性 属于 时,才需要触发 。
因此响应式系统包含两个方向:
- track:读取属性时,记录“当前副作用依赖了这个属性”;
- trigger:修改属性时,找到依赖该属性的副作用并调度执行。
2. ref:包装一个值
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
ref 返回一个带有 .value 属性的对象。模板中会自动解包:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>{{ count }}</p>
</template>
在 JavaScript 中不能省略 .value,因为模板编译器可以识别模板上下文,而普通 TypeScript 代码没有这个特殊处理。
ref 适合:
- 基本类型;
- 需要整体替换的值;
- 明确表达“这是一个可变引用”的状态。
const user = ref<User | null>(null)
user.value = {
id: 'u1',
name: 'Ada',
}
3. reactive:代理对象和集合
import { reactive } from 'vue'
const form = reactive({
email: '',
password: '',
})
form.email = 'user@example.com'
reactive 使用 Proxy 代理对象。它适合多个字段组成的对象状态,但不能直接代理原始值:
const count = reactive(0) // 错误用法:原始数字不能被 reactive 代理
常见的解构陷阱如下:
const state = reactive({ count: 0 })
const { count } = state
state.count++
console.log(count) // 仍然是旧的普通值
解构后,count 不再经过 state 的代理访问。需要使用 toRefs 保持响应式:
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state)
state.count++
console.log(count.value) // 1
另一个边界是替换整个对象:
let state = reactive({ count: 0 })
// state = { count: 1 } // 这会丢失原来的响应式代理
如果对象需要整体替换,使用 ref 更合适:
const state = ref({ count: 0 })
state.value = { count: 1 }
4. 依赖追踪的数据结构
常见实现可以抽象为:
WeakMap<
target,
Map<
key,
Set<effect>
>
>
它表示:
- 一个被代理的对象
target; - 对象上的属性
key; - 依赖该属性的副作用集合
Set<effect>。
概念性的伪代码如下:
let activeEffect: (() => void) | undefined
const bucket = new WeakMap<object, Map<PropertyKey, Set<Function>>>()
function effect(fn: () => void) {
const wrapped = () => {
activeEffect = wrapped
fn()
activeEffect = undefined
}
wrapped()
return wrapped
}
function track(target: object, key: PropertyKey) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
depsMap = new Map()
bucket.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
deps.add(activeEffect)
}
function trigger(target: object, key: PropertyKey) {
const depsMap = bucket.get(target)
const deps = depsMap?.get(key)
deps?.forEach(fn => fn())
}
Proxy 负责拦截访问和修改:
function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key)
return result
},
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver)
const changed = !Object.is(oldValue, value)
const result = Reflect.set(target, key, value, receiver)
if (changed) {
trigger(target, key)
}
return result
},
})
}
这只是说明机制的简化模型。真实 Vue 还需要处理嵌套代理、数组索引、数组长度、Map/Set、依赖清理、递归触发、调度器和组件更新队列。
5. computed:带缓存的派生状态
import { computed, ref } from 'vue'
const price = ref(100)
const quantity = ref(2)
const total = computed(() => price.value * quantity.value)
total 的值由 price 和 quantity 决定:
第一次读取 total.value 时,计算函数执行并记录依赖。之后再次读取,如果依赖没有变化,返回缓存结果。依赖变化时,computed 被标记为“脏”,下一次读取才重新计算。
因此 computed 适合纯派生值:
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
不应在 computed 中执行副作用:
const bad = computed(() => {
localStorage.setItem('name', name.value)
return name.value
})
写入存储、发送请求、修改其他状态都属于副作用,应使用 watch 或明确的事件处理函数。
可写计算属性需要同时提供 getter 和 setter:
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set(value: string) {
const [first = '', last = ''] = value.split(' ')
firstName.value = first
lastName.value = last
},
})
6. watch:观察变化并执行副作用
import { ref, watch } from 'vue'
const userId = ref('u1')
watch(userId, async (newId, oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
const response = await fetch(`/api/users/${newId}`, {
signal: controller.signal,
})
if (response.ok) {
console.log(await response.json())
}
console.log({ newId, oldId })
})
这个例子处理了请求竞态:
userId变为u1,开始请求;userId很快变为u2;- 监听器清理
u1请求; u2请求继续;- 被取消的请求不会再覆盖最新状态。
watch 的数据源可以是 ref、响应式对象的 getter 或多个来源:
watch(
() => route.params.id,
id => {
// id 变化时执行
},
)
watch([page, pageSize], ([newPage, newSize]) => {
// 任一值变化都会执行
})
watchEffect 会立即执行,并自动追踪执行过程中读取的依赖:
watchEffect(() => {
document.title = `当前页:${page.value}`
})
区别是:
watch明确指定观察源,并提供新旧值;watchEffect自动收集依赖,适合简单同步副作用;- 复杂异步场景通常更适合
watch,因为依赖和清理边界更明确。
监听时机也会影响 DOM 是否已经更新。默认监听回调通常在组件 DOM 更新前调度;如果需要读取更新后的 DOM,可以使用:
watch(source, callback, { flush: 'post' })
flush: 'sync' 会同步执行,可能破坏批量更新并造成递归或性能问题,应只在明确理解触发频率时使用。
四、组件契约:Props、Emits、v-model 和属性边界
组件是一个拥有输入、输出和内部状态的边界。可以把组件写成:
其中:
props是父组件输入;state是组件内部状态;UI是当前视图;emits是组件向外报告事件的通道。
1. Props:父传子,单向数据流
子组件:
<script setup lang="ts">
interface Props {
title: string
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
</script>
<template>
<section>
<h2>{{ props.title }}</h2>
<button :disabled="props.disabled">
操作
</button>
</section>
</template>
父组件:
<ChildCard title="账户信息" :disabled="isSubmitting" />
Props 是只读的。下面的写法违反组件边界:
const props = defineProps<{ count: number }>()
props.count++ // 不应修改父组件传入的 prop
如果子组件需要基于 prop 计算值,应使用 computed:
const doubled = computed(() => props.count * 2)
如果子组件需要编辑初始值,应将其复制为本地状态,并明确后续同步策略:
const props = defineProps<{ initialName: string }>()
const name = ref(props.initialName)
这表示“初始值”,而不是持续双向同步。若需求是持续同步,应通过 v-model 或监听 prop 变化实现。
2. Emits:子传父,事件表达意图
子组件:
<script setup lang="ts">
const emit = defineEmits<{
save: [name: string]
cancel: []
}>()
function submit() {
emit('save', 'Ada')
}
</script>
<template>
<button type="button" @click="submit">保存</button>
<button type="button" @click="emit('cancel')">取消</button>
</template>
父组件:
<Editor
@save="handleSave"
@cancel="closeEditor"
/>
事件参数应该表达事实或意图,而不是直接暴露子组件内部实现。例如 save 比 setInternalInputValue 更稳定。父组件负责决定保存到哪里、是否显示错误以及是否关闭弹窗。
3. v-model:约定化的受控组件协议
在 Vue 3 中,组件上的默认 v-model 等价于:
<CustomInput
:model-value="value"
@update:model-value="value = $event"
/>
子组件可以这样声明:
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
<template>
<input v-model="model" />
</template>
defineModel 是 Vue 3.4 引入的 <script setup> 宏。若项目版本较旧,可以显式声明:
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<input
:value="modelValue"
@input="emit(
'update:modelValue',
($event.target as HTMLInputElement).value
)"
/>
</template>
多模型绑定可以使用参数:
<UserName
v-model:first-name="firstName"
v-model:last-name="lastName"
/>
对应的组件契约是 firstName / update:firstName 和 lastName / update:lastName。具体宏参数和修饰符能力具有版本敏感性,应以当前 Vue API 文档和项目版本为准。
4. 透传属性:未声明的属性不等于 Props
如果组件没有声明某个属性或事件监听器,Vue 会将其作为“透传属性”处理。单根组件通常会把它们放到根元素:
<MyButton class="primary" id="submit-button" />
如果 MyButton 的根元素是 <button>,class 和 id 通常会落到该按钮上。
多根节点组件需要显式绑定:
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>
<template>
<label class="field-label">
<span>邮箱</span>
<input v-bind="$attrs" />
</label>
</template>
如果不关闭自动继承,多根模板中的属性不会自动知道应该落到哪个节点。$attrs 还包含未声明的事件监听器,因此声明 Props 和 Emits 会改变哪些属性被视为“已消费”。
5. 插槽:由父组件控制内容结构
<!-- BasePanel.vue -->
<template>
<section class="panel">
<header v-if="$slots.header">
<slot name="header" />
</header>
<main>
<slot />
</main>
</section>
</template>
使用时:
<BasePanel>
<template #header>
<h2>订单详情</h2>
</template>
<p>这里是订单内容。</p>
</BasePanel>
插槽内容在父组件作用域中编译,因此不能直接访问子组件的局部变量。作用域插槽可以由子组件传值给父组件:
<!-- List.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item" />
</li>
</ul>
</template>
<List :items="items">
<template #item="{ item }">
{{ item.name }}
</template>
</List>
6. 依赖注入:跨层级共享,但要保持边界清晰
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> =
Symbol('theme')
提供方:
const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)
使用方:
const theme = inject(ThemeKey)
if (!theme) {
throw new Error('Theme provider is missing')
}
provide/inject 适合主题、表单上下文、组件树级别的服务。它不应该成为无约束的全局状态替代品,否则数据来源会难以追踪。
五、生命周期与异步组件状态
1. 生命周期的因果顺序
常用生命周期包括:
onBeforeMount:首次挂载前;onMounted:组件 DOM 已挂载;onBeforeUpdate:响应式更新导致 DOM 更新前;onUpdated:DOM 更新后;onBeforeUnmount:卸载前;onUnmounted:卸载完成后。
DOM 相关操作应放在 onMounted 之后:
import { onMounted, onUnmounted } from 'vue'
function handleResize() {
console.log(window.innerWidth)
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
如果没有清理事件监听器,组件反复挂载后会积累多个监听器,表现为一次操作触发多次回调和内存持续增长。
2. 异步 UI 必须建模为状态机
请求状态不应只用一个 loading 布尔值表示。至少可以建模为:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string }
const state = ref<RequestState<User>>({ status: 'idle' })
async function loadUser(id: string) {
state.value = { status: 'loading' }
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data: unknown = await response.json()
if (!isUser(data)) {
throw new Error('接口返回格式无效')
}
state.value = { status: 'success', data }
} catch (error) {
state.value = {
status: 'error',
message: error instanceof Error ? error.message : '未知错误',
}
}
}
模板可以据此精确表达界面:
<template>
<p v-if="state.status === 'loading'">加载中……</p>
<p v-else-if="state.status === 'error'">
{{ state.message }}
</p>
<pre v-else-if="state.status === 'success'">
{{ state.data }}
</pre>
</template>
这种写法避免了不一致状态,例如 loading = false 但 data = null、error 仍然存在。
六、应用级能力:路由、状态和数据获取
1. 路由是 URL 到组件状态的映射
使用 Vue Router:
npm install vue-router
// router.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('./views/HomeView.vue'),
},
{
path: '/users/:id',
component: () => import('./views/UserView.vue'),
},
],
})
export default router
入口挂载:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')
动态导入产生代码分割边界。访问 /users/:id 时,相关页面代码可以按需加载,而不是首屏全部下载。
路由参数属于外部输入:
const route = useRoute()
const id = computed(() => String(route.params.id))
不要假设参数一定存在或一定符合后端接口要求。需要时在路由守卫或页面逻辑中校验,并处理 404、无权限和请求失败。
2. 导航守卫不等于权限安全
router.beforeEach(async to => {
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return {
path: '/login',
query: { redirect: to.fullPath },
}
}
})
这只能控制前端导航。真正的权限必须由服务端根据身份和资源授权判断。攻击者可以直接调用 API,绕过任何前端守卫。
3. 全局状态应该有明确归属
组件局部状态适合输入框、弹窗开关和临时请求状态。多个不相邻组件共享且具有业务意义的状态,可以使用 Pinia:
npm install pinia
// stores/cart.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref<{ id: string; price: number; quantity: number }[]>([])
const total = computed(() =>
items.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
),
)
function add(item: { id: string; price: number }) {
const existing = items.value.find(x => x.id === item.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...item, quantity: 1 })
}
}
return { items, total, add }
})
状态库解决的是共享状态组织,不会自动解决服务端缓存、请求去重、离线同步或持久化。那些问题应单独建模,否则一个 store 会演变为所有业务的垃圾桶。
七、工程化:从源码到可验证产物
1. 配置环境变量时区分公开配置和秘密
Vite 中以 VITE_ 开头的变量会暴露给客户端:
# .env.development
VITE_API_BASE_URL=http://localhost:3000
# .env.production
VITE_API_BASE_URL=https://api.example.com
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
API 地址、功能开关等可以公开;数据库密码、私钥、第三方服务端密钥绝不能放在 VITE_ 变量中。因为它们会被编译进浏览器可以下载的 JavaScript。
环境文件的优先级和模式行为属于 Vite 版本与配置范围内的工具能力,应通过 import.meta.env.MODE、构建日志和产物检查确认实际值,不要仅凭文件名推断部署结果。
2. TypeScript 类型检查和构建检查是两件事
Vite 主要负责转换和打包,通常不会替代完整的 TypeScript 类型检查。因此应显式执行:
npx vue-tsc --noEmit
npm run build
如果项目使用 ESLint 和单元测试,还应将它们加入 CI:
npm run lint
npm run test
npm run build
CI 的价值不只是阻止语法错误,更重要的是在合并前验证组件契约、类型边界和构建产物。
3. 测试要覆盖状态转换,而非只测快照
一个计数器的测试应验证行为:
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
test('点击后计数增加', async () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('0')
await wrapper.get('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
组件测试应优先验证:
- 输入 Props 后显示什么;
- 点击或输入后发出什么事件;
- loading、success、error 状态如何转换;
- 异步请求失败时用户能否恢复。
端到端测试则验证真实路由、浏览器行为和关键业务流程。快照可以发现结构变化,但不能证明交互正确。
4. 性能优化必须先确定瓶颈
常见的有效手段包括:
- 路由级动态导入;
- 使用稳定且唯一的
:key; - 避免在模板中反复执行昂贵函数;
- 对大列表使用虚拟滚动;
- 使用
computed缓存派生结果; - 检查不必要的深度监听;
- 将非首屏代码延迟加载。
错误的优化包括:
<li v-for="item in items" :key="index">
当列表插入、删除或排序时,索引不是稳定身份,可能导致输入框状态被错误复用。应使用后端或业务层稳定 ID:
<li v-for="item in items" :key="item.id">
八、SSR:服务端生成 HTML,再由客户端接管
1. SSR 解决什么问题
客户端渲染通常是:
- 浏览器下载 HTML;
- 下载 JavaScript;
- 执行 JavaScript;
- Vue 创建组件并生成页面。
SSR 则先由服务器执行组件渲染,返回包含内容的 HTML。浏览器可以更早看到页面内容,随后客户端 JavaScript 进行 Hydration,即把事件监听和响应式逻辑绑定到已有 HTML 上。
SSR 不是天然更快。它增加了服务器渲染时间、部署复杂度和 Hydration 约束。是否使用 SSR,需要结合首屏内容、SEO、服务器成本和数据访问方式决定。
2. 一个最小 SSR 结构
安装:
npm install vue @vue/server-renderer
服务端:
// server-entry.ts
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from './App.vue'
export async function render() {
const app = createSSRApp(App)
return await renderToString(app)
}
客户端入口:
// client-entry.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
createSSRApp(App).mount('#app')
服务端输出应把结果放进容器:
<div id="app"><!-- 服务端生成的 Vue HTML --></div>
<script type="module" src="/src/client-entry.ts"></script>
客户端必须使用 createSSRApp,而不是普通的 createApp,这样 Vue 才会按照 Hydration 逻辑复用服务端 DOM。
实际项目通常使用 Vite SSR 配置或 Nuxt 等上层框架处理入口分离、HTML 模板、路由、数据预取、构建和部署。SSR 能力和相关工具接口具有版本敏感性,不能把某个框架的约定当成 Vue 核心 API。
3. Hydration 不匹配的原因
服务端和客户端首次渲染必须得到等价结构。下面的代码存在问题:
<template>
<p>{{ Math.random() }}</p>
</template>
服务端和客户端会得到不同随机数,导致 Hydration mismatch。类似问题还包括:
- 直接在渲染阶段读取
window、document; - 依赖本地时区或当前时间;
- 服务端和客户端使用不同初始数据;
- 根据浏览器宽度直接决定模板结构;
- 服务端排序和客户端排序规则不同。
浏览器专属逻辑应放在客户端挂载后:
import { onMounted, ref } from 'vue'
const width = ref<number | null>(null)
onMounted(() => {
width.value = window.innerWidth
})
但这会让服务端初始 HTML 与客户端后续状态发生变化,因此模板应允许 null 的初始状态,而不是强行假设浏览器 API 在服务端存在。
4. SSR 数据获取必须避免重复和泄漏
服务端请求得到的数据通常需要序列化到 HTML 或通过专门的数据传递机制交给客户端,否则客户端 Hydration 后可能再次请求,导致重复访问。
更危险的是把用户私密数据放入公共缓存或错误地序列化进页面。SSR 应明确区分:
- 可公开缓存的页面数据;
- 与用户身份绑定的数据;
- 只能保留在服务端的凭据;
- 可安全下发给浏览器的脱敏数据。
SSR 服务通常还应避免模块级可变状态:
// 有风险:所有请求共享这个对象
const session = reactive({ user: null })
服务器进程会处理多个请求。如果把请求相关状态放在模块级变量中,一个用户的数据可能被另一个请求读取。请求级状态必须在每次请求内创建。
九、生产交付:构建、资源、缓存和回滚
1. 先验证产物,再部署
典型流程:
npm ci
npm run lint
npx vue-tsc --noEmit
npm run test
npm run build
npm ci 根据锁文件安装依赖,适合 CI。构建完成后检查:
find dist -maxdepth 2 -type f
应重点确认:
index.html是否存在;- JavaScript、CSS 和字体是否生成;
- 静态资源路径是否正确;
- 生产环境 API 地址是否正确;
- 是否错误地把密钥打进产物;
- 产物是否能在目标子路径下访问。
若应用部署在 /console/,需要同时配置 Vite 的 base 和路由 history 的基础路径。否则 HTML 可能能打开,但脚本会请求 /assets/... 而不是 /console/assets/...。
2. 静态资源缓存必须与文件命名策略匹配
带内容哈希的资源,例如:
assets/app.8f31a2.js
assets/style.14c9d0.css
内容改变时文件名改变,因此可以使用较长缓存:
Cache-Control: public, max-age=31536000, immutable
但 index.html 通常不应长时间 immutable,因为它负责引用最新资源:
Cache-Control: no-cache
这里的 no-cache 不是“不允许缓存”,而是要求使用前向服务器重新验证。若 HTML 和资源缓存策略不匹配,常见故障是:
- 新 HTML 引用了旧资源,通常还能工作;
- 旧 HTML 引用了已被删除的新资源,出现 JavaScript 404;
- HTML 被 CDN 长时间缓存,用户迟迟拿不到新版本。
因此发布时不能只上传新文件后立刻删除旧文件。至少应保留旧版本资源一段时间,或者使用版本目录和原子切换。
3. history 路由需要服务器回退规则
使用 createWebHistory() 时,直接访问:
/users/u1
服务器必须将未知路径回退到 index.html,再由 Vue Router 接管。Nginx 示例:
location / {
try_files $uri $uri/ /index.html;
}
静态资源应优先返回 404,而不是也回退到 HTML,否则浏览器请求 .js 失败时收到 HTML,控制台会出现 MIME 类型错误:
location /assets/ {
try_files $uri =404;
}
4. 发布版本必须可识别
前端页面出现问题时,需要知道用户运行的版本。可以在构建时写入公开版本号:
const appVersion = import.meta.env.VITE_APP_VERSION
console.info('app version:', appVersion)
版本号可以来自 Git commit、构建编号或发布标签,但不要把它当作安全凭据。它的作用是关联日志、错误监控和发布记录。
5. 灰度发布的核心是请求路由,而不是前端判断
灰度意味着只有一部分流量访问新版本。可靠的灰度通常由网关、负载均衡或 CDN 按以下条件路由:
- 用户 ID;
- Cookie;
- 请求头;
- 地域;
- 随机比例。
前端代码里写:
if (Math.random() < 0.1) {
// 新功能
}
不能完成真正的灰度,因为所有用户仍下载同一个构建,且刷新后分组可能变化。前端功能开关可以做 UI 级实验,但版本级灰度应由交付层控制。
灰度验证至少需要观察:
- JavaScript 加载失败率;
- API 5xx;
- 路由错误;
- Hydration mismatch;
- 核心业务转化;
- 资源 404;
- 客户端异常堆栈。
6. 回滚必须是可执行动作
如果版本 v2 出现错误,回滚不是“重新运行一次构建”,而应切换到已验证的 v1 产物:
/releases/
v1/
v2/
current -> v2
发布时让 current 指向新版本,回滚时切回旧版本。若采用对象存储和 CDN,还需要考虑:
- 旧版本资源是否仍然存在;
- CDN 是否需要失效;
- HTML 是否被缓存;
- 浏览器是否保留旧 Service Worker;
- 前端是否兼容旧 API。
7. 前后端兼容是回滚成功的必要条件
假设旧前端发送:
{
"name": "Ada"
}
新后端突然要求:
{
"displayName": "Ada"
}
此时回滚前端可能仍无法调用新后端。安全发布通常要求后端先兼容旧字段,再发布新前端,确认流量切换完成后才移除旧协议。这称为向后兼容的渐进式变更。
十、常见失败路径与诊断方法
1. 数据变了但视图没变
优先检查:
- 是否错误地解构了
reactive; - 是否忘记在 JavaScript 中访问
ref.value; - 是否修改了原始对象而不是响应式代理;
- 是否使用了不稳定的
key; - 是否在
computed中读取了非响应式数据。
可以在 watch 或模板附近打印引用和状态,确认修改前后是否是同一个代理对象。
2. 页面显示旧数据
常见原因不是 Vue 更新失败,而是异步竞态:
- 请求 A 开始;
- 请求 B 开始;
- B 先返回并写入数据;
- A 后返回并覆盖数据。
解决方式包括 AbortController、请求序列号、缓存库的查询键或服务端数据版本。必须让“最新请求”成为明确规则。
3. SSR 控制台出现 Hydration mismatch
诊断顺序应是:
- 查找服务端和客户端首次渲染中的随机数、时间和浏览器 API;
- 比较服务端返回 HTML 与客户端初始状态;
- 检查服务端是否共享了请求级状态;
- 检查列表排序和条件渲染;
- 确认服务端和客户端依赖版本一致。
不要通过随意加 key 或关闭警告掩盖不匹配,因为真实 DOM 可能已经处于不可预测状态。
4. 生产环境白屏
可以按资源加载链路排查:
HTML 是否返回
-> JS URL 是否正确
-> JS 是否 HTTP 200
-> Content-Type 是否正确
-> JS 执行是否抛异常
-> API 是否可访问
-> 路由回退是否正确
浏览器 Network 面板可以确认 URL、状态码、缓存状态和响应类型;Console 可以确认运行时异常。服务器日志则用于区分 CDN、网关和源站问题。
5. 内存不断增长
重点检查:
addEventListener是否在卸载时移除;- 定时器是否
clearInterval; - WebSocket 是否关闭;
- 订阅是否取消;
watch是否在合适的生命周期创建和清理;- 是否将大量历史数据长期保存在全局 store。
Vue 会管理自身的组件副作用,但第三方库创建的资源仍需要应用代码负责释放。
十一、推荐的完整学习顺序
阶段一:JavaScript、TypeScript 和工具链
掌握模块、Promise、async/await、闭包、对象引用、DOM 事件、HTTP、类型收窄、泛型、联合类型以及 npm。随后能独立创建 Vite Vue TypeScript 项目,并理解开发构建与生产构建的差异。
阶段二:Vue 基础与响应式
依次学习:
- 模板表达式与指令;
ref与reactive;computed;watch与watchEffect;- 组件生命周期;
- 条件渲染、列表渲染和稳定
key; - 事件与表单绑定。
这一阶段的验收标准不是能背 API,而是能够解释一次状态修改如何经过依赖追踪、调度和 DOM 提交最终显示出来。
阶段三:组件设计
掌握 Props、Emits、插槽、v-model、透传属性和 provide/inject。为一个表单组件写出完整契约,包括输入类型、事件参数、错误状态、键盘操作和禁用行为。
阶段四:应用组织
学习 Vue Router、Pinia、组合式函数、请求层、错误边界思路、异步状态建模、代码分割和权限边界。组合式函数应围绕一个可复用的业务能力组织,而不是把所有逻辑集中在单个巨型组件中。
阶段五:质量工程
加入 ESLint、vue-tsc、组件测试、端到端测试和构建检查。让 CI 在合并前发现类型、交互、路由和产物问题。
阶段六:SSR 与生产交付
最后学习 SSR 的服务端入口、客户端 Hydration、请求级状态、数据序列化和浏览器 API 边界,再进入环境配置、资源缓存、CDN、发布、灰度、监控和回滚。
十二、用一个小项目验证学习成果
可以实现一个“用户目录”应用来串联全部能力:
- 使用 Vue 3、
<script setup lang="ts">和 Vite 创建项目; - 用
ref保存搜索词,用computed生成过滤结果; - 用
watch根据搜索词请求接口,并通过AbortController取消旧请求; - 用 Props 和 Emits 拆出
UserTable、UserEditor; - 用
v-model实现受控表单; - 用 Vue Router 实现
/users与/users/:id; - 用 Pinia 保存登录用户和收藏状态;
- 用测试覆盖加载、空数据、错误和保存成功;
- 为页面加入动态导入;
- 构建后部署到带 history 回退和正确缓存头的静态服务器;
- 若使用 SSR,确保服务端和客户端初始数据一致;
- 发布时保留上一版本并验证一键回滚。
当这个项目能够解释“每个状态由谁拥有、谁可以修改、修改后如何传播、失败后如何恢复、构建后如何被浏览器加载”时,才算真正掌握了 Vue 的完整工程链路。
Vue 官方指南适合按概念学习,API Reference 适合核对具体签名、生命周期和版本行为:https://vuejs.org/guide/introduction.html、https://vuejs.org/api/。遇到 defineModel、SSR 工具链或构建插件等版本敏感能力时,应以项目实际安装版本和对应文档为准。
完整学习目录
一、核心基础
- Vue 项目与 Vite 工具链:创建、环境变量、构建、代理和依赖治理
- Vue 模板与渲染:指令、表达式、列表、条件和 Virtual DOM
- Vue 响应式原理:ref、reactive、computed、watch 与依赖追踪
- Vue Composition API:setup、生命周期、作用域和逻辑组织
二、组件与交互
- Vue 组件契约:Props、Emits、v-model、透传属性与边界
- Vue Slots 与动态组件:内容分发、KeepAlive、Teleport 和异步组件
- Vue 表单工程:受控输入、校验、异步提交、错误与可访问性
- Vue Composable 设计:复用状态、清理副作用、参数契约和测试
三、应用状态与数据
- Vue Router 完整指南:路由匹配、守卫、懒加载和滚动行为
- Pinia 状态管理:Store、Getter、Action、持久化和 SSR 边界
- Vue 异步数据与请求状态:取消、竞态、缓存、分页和错误恢复
四、工程质量
- Vue 与 TypeScript:组件类型、泛型、模板检查和 API 契约
- Vue 测试体系:Vitest、Vue Test Utils、组件测试和端到端测试
- Vue 性能优化:更新边界、长列表、异步组件、Bundle 和指标
- Vue 可访问性与交互质量:语义、键盘、焦点、ARIA 和动效
- Vue 前端安全:XSS、URL、Token、CSRF、依赖和富文本边界
- Vue 错误处理与可观测性:Error Boundary、日志、性能和发布诊断
五、架构与交付
- Vue 组件库与设计系统:Token、主题、无障碍、文档和版本治理
- Vue SSR 与 Nuxt:水合、数据获取、缓存、SEO 和部署边界
- Vue 国际化工程:消息目录、Locale、日期数字、路由和回退
- Vue 大型应用架构:模块边界、依赖方向、权限和微前端取舍
- Vue 生产交付:环境配置、静态资源、缓存、灰度和回滚
一、核心基础
- Vue SFC 与编译器:template、script setup、style 和编译产物
- Vue 组件生命周期:挂载、更新、卸载、副作用和父子顺序
- Vue 模板引用:元素、组件实例、defineExpose 和生命周期边界
- Vue 调度器与 nextTick:批量更新、Flush 时机和 DOM 可见性
- Vue 渲染函数与 JSX:VNode、h、Slots、事件和适用边界
- Vue 编译宏:defineProps、defineEmits、defineModel 与泛型组件
- Vue 自定义指令:生命周期、DOM 行为、清理和组件替代边界
- Vue Plugin 与 Provide/Inject:依赖注入、类型和作用域
二、组件与交互
- Vue Transition 与 TransitionGroup:进入离开、列表动画和性能
- Vue Suspense 与异步组件:加载、错误、超时和嵌套边界
- Vue KeepAlive 深入:缓存键、include、生命周期和内存治理
- Vue Teleport:层级、事件、SSR、可访问性和弹窗架构
- Vue 与 Web Components:Custom Element、属性事件、样式和互操作
- Vue 文件上传:选择、拖拽、分片、进度、取消和重试
- Vue 数据表格工程:列模型、排序、筛选、选择、编辑和可访问性
- Vue 长列表虚拟化:可视区、动态高度、滚动锚点和性能
- Vue 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
- Vue 封装浏览器 API:ResizeObserver、IntersectionObserver 和剪贴板
- Vue 样式工程:Scoped CSS、CSS Modules、变量、主题和覆盖边界
三、应用状态与数据
- Vue Router 深入:动态路由、元数据、数据预取和失败处理
- Vue 登录与权限:路由、按钮、Token 刷新、403 和状态恢复
- Pinia 生产模式:Store 拆分、订阅、持久化、重置和测试
- Vue Query 数据状态:缓存键、失效、乐观更新、分页和 SSR
- Vue HTTP 客户端封装:Axios、拦截器、取消、重试和错误模型
- Vue 实时数据:WebSocket、SSE、重连、心跳和状态同步
四、工程质量
- Vue 单元测试:Vitest、Composable、时间、网络和稳定断言
- Vue 组件测试:挂载、用户交互、异步更新和契约验证
- Vue 端到端测试:Playwright、登录态、网络、并行和失败证据
- Vue 视觉回归测试:截图基线、字体、动画、阈值和审阅
- Vue Storybook:Story、交互测试、文档、主题和组件评审
- Vue DevTools 调试:组件、状态、时间线、性能和生产诊断
- Vue Bundle 分析:依赖图、Tree Shaking、分包、预加载和预算
- Vue Source Map 与发布诊断:生成、上传、隐私和版本映射
- Vue PWA:Service Worker、缓存更新、离线、安装和回退
五、架构与交付
- Vue Monorepo:Workspace、共享包、构建图、版本和边界
- Vue 组件库发布:构建、类型、样式、按需加载和语义化版本
- Vite 插件开发:Hook、虚拟模块、转换、HMR 和调试
- Vue 环境与运行时配置:构建变量、注入、校验和 Secret 边界
- Vue 微前端:路由、状态、样式、依赖隔离和迁移取舍
- Vue CI 质量流水线:类型、Lint、测试、构建、预览和制品
- Vue 静态站点交付:Nginx、CDN、History 回退、缓存和压缩
- Nuxt 路由与布局:文件约定、中间件、错误页和导航
- Nuxt Server Routes:Nitro、API、运行时配置、缓存和部署
- Vue SSR 水合诊断:不一致来源、客户端边界和调试方法
- Vue 与 Nuxt SEO:Meta、结构化数据、Canonical、站点地图和渲染
- Vue SSR 缓存:页面、数据、边缘缓存、个性化和失效
- Vue Islands 与渐进式水合:交互边界、成本和适用场景
系列导航与关联阅读
- 下一篇:Vue 项目与 Vite 工具链:创建、环境变量、构建、代理和依赖治理
- 延伸:Vue 响应式原理:ref、reactive、computed、watch 与依赖追踪
- 延伸:Vue 组件契约:Props、Emits、v-model、透传属性与边界
- 延伸:Vue 生产交付:环境配置、静态资源、缓存、灰度和回滚
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论