Vue 基础体系 · 第 1/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付

Vue 3 的学习不能只停留在模板语法。一个能维护、能测试、能上线的 Vue 应用,至少要同时理解五层问题:

  1. JavaScript、TypeScript 与浏览器基础:模块、异步、对象引用、DOM、HTTP、Cookie 和构建产物。
  2. Vue 核心运行时:响应式系统、模板编译、组件实例、生命周期和更新调度。
  3. 组件化应用:Props、Emits、v-model、插槽、透传属性、依赖注入以及状态边界。
  4. 应用工程化:Vite、路由、状态管理、类型检查、测试、代码分割和错误监控。
  5. 服务端渲染与生产交付:SSR、Hydration、环境配置、静态资源、缓存、灰度发布和回滚。

这几层存在明确的因果关系:响应式系统决定数据变化后如何更新视图;组件契约决定数据如何跨边界流动;构建工具决定代码如何被浏览器加载;SSR 决定首屏 HTML 如何生成及如何接管;生产交付则决定用户最终拿到哪个版本、哪些资源以及发生故障时如何恢复。


一、学习 Vue 前必须补齐的前置知识

1. JavaScript:先理解引用、闭包和异步

Vue 的响应式并不是魔法,它建立在 JavaScript 对象、函数和事件循环之上。

const state = { count: 0 }
const alias = state

alias.count++
console.log(state.count) // 1

aliasstate 指向同一个对象。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()
}

如果用户先后选择了 AB,而 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 的关键步骤通常是:

  1. TypeScript 类型检查;
  2. Vue 单文件组件转换;
  3. 模块依赖分析;
  4. 代码压缩与分包;
  5. 生成 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> 是编译期语法。顶层声明的变量、函数和导入会自动暴露给模板。它不是运行时函数,也不应该被理解为每次渲染都会重新执行的普通脚本。

执行过程可以简化为:

  1. 组件实例创建;
  2. setup() 逻辑建立响应式状态;
  3. 模板被编译为渲染函数;
  4. 渲染函数读取状态并生成虚拟 DOM;
  5. 首次渲染得到真实 DOM;
  6. 状态变化后,Vue 调度组件重新渲染;
  7. 新旧虚拟 DOM 比较后,只提交必要的 DOM 修改。

这里的“重新渲染”不等于“整个页面重新创建”。Vue 会重新执行渲染函数,但 DOM 提交阶段会尽量复用已有节点。


三、响应式原理:从读取追踪到修改触发

1. 响应式的形式化条件

设状态为 SS,渲染函数为:

V=R(S)V = R(S)

其中:

  • SS 是响应式状态;
  • RR 是渲染或计算函数;
  • VV 是生成的视图结果。

当状态从 S0S_0 变为 S1S_1 时,如果:

R(S0)R(S1)R(S_0) \neq R(S_1)

则相关视图需要重新计算。

但 Vue 不能每次任意属性变化都重新执行所有组件。它需要记录:

D(R)={p1,p2,...,pn}D(R) = \{p_1, p_2, ..., p_n\}

其中 D(R)D(R) 表示函数 RR 实际读取过的属性集合。只有当修改的属性 pp 属于 D(R)D(R) 时,才需要触发 RR

因此响应式系统包含两个方向:

  • 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 的值由 pricequantity 决定:

total=price×quantitytotal = price \times 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 })
})

这个例子处理了请求竞态:

  1. userId 变为 u1,开始请求;
  2. userId 很快变为 u2
  3. 监听器清理 u1 请求;
  4. u2 请求继续;
  5. 被取消的请求不会再覆盖最新状态。

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 和属性边界

组件是一个拥有输入、输出和内部状态的边界。可以把组件写成:

UI=F(props,state)UI = F(props, state)

其中:

  • 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"
/>

事件参数应该表达事实或意图,而不是直接暴露子组件内部实现。例如 savesetInternalInputValue 更稳定。父组件负责决定保存到哪里、是否显示错误以及是否关闭弹窗。

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:firstNamelastName / update:lastName。具体宏参数和修饰符能力具有版本敏感性,应以当前 Vue API 文档和项目版本为准。

4. 透传属性:未声明的属性不等于 Props

如果组件没有声明某个属性或事件监听器,Vue 会将其作为“透传属性”处理。单根组件通常会把它们放到根元素:

<MyButton class="primary" id="submit-button" />

如果 MyButton 的根元素是 <button>classid 通常会落到该按钮上。

多根节点组件需要显式绑定:

<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 布尔值表示。至少可以建模为:

State{idle,loading,success,error}State \in \{idle, loading, success, error\}

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 = falsedata = nullerror 仍然存在。


六、应用级能力:路由、状态和数据获取

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 解决什么问题

客户端渲染通常是:

  1. 浏览器下载 HTML;
  2. 下载 JavaScript;
  3. 执行 JavaScript;
  4. 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。类似问题还包括:

  • 直接在渲染阶段读取 windowdocument
  • 依赖本地时区或当前时间;
  • 服务端和客户端使用不同初始数据;
  • 根据浏览器宽度直接决定模板结构;
  • 服务端排序和客户端排序规则不同。

浏览器专属逻辑应放在客户端挂载后:

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,还需要考虑:

  1. 旧版本资源是否仍然存在;
  2. CDN 是否需要失效;
  3. HTML 是否被缓存;
  4. 浏览器是否保留旧 Service Worker;
  5. 前端是否兼容旧 API。

7. 前后端兼容是回滚成功的必要条件

假设旧前端发送:

{
  "name": "Ada"
}

新后端突然要求:

{
  "displayName": "Ada"
}

此时回滚前端可能仍无法调用新后端。安全发布通常要求后端先兼容旧字段,再发布新前端,确认流量切换完成后才移除旧协议。这称为向后兼容的渐进式变更。


十、常见失败路径与诊断方法

1. 数据变了但视图没变

优先检查:

  • 是否错误地解构了 reactive
  • 是否忘记在 JavaScript 中访问 ref.value
  • 是否修改了原始对象而不是响应式代理;
  • 是否使用了不稳定的 key
  • 是否在 computed 中读取了非响应式数据。

可以在 watch 或模板附近打印引用和状态,确认修改前后是否是同一个代理对象。

2. 页面显示旧数据

常见原因不是 Vue 更新失败,而是异步竞态:

  1. 请求 A 开始;
  2. 请求 B 开始;
  3. B 先返回并写入数据;
  4. A 后返回并覆盖数据。

解决方式包括 AbortController、请求序列号、缓存库的查询键或服务端数据版本。必须让“最新请求”成为明确规则。

3. SSR 控制台出现 Hydration mismatch

诊断顺序应是:

  1. 查找服务端和客户端首次渲染中的随机数、时间和浏览器 API;
  2. 比较服务端返回 HTML 与客户端初始状态;
  3. 检查服务端是否共享了请求级状态;
  4. 检查列表排序和条件渲染;
  5. 确认服务端和客户端依赖版本一致。

不要通过随意加 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 基础与响应式

依次学习:

  • 模板表达式与指令;
  • refreactive
  • computed
  • watchwatchEffect
  • 组件生命周期;
  • 条件渲染、列表渲染和稳定 key
  • 事件与表单绑定。

这一阶段的验收标准不是能背 API,而是能够解释一次状态修改如何经过依赖追踪、调度和 DOM 提交最终显示出来。

阶段三:组件设计

掌握 Props、Emits、插槽、v-model、透传属性和 provide/inject。为一个表单组件写出完整契约,包括输入类型、事件参数、错误状态、键盘操作和禁用行为。

阶段四:应用组织

学习 Vue Router、Pinia、组合式函数、请求层、错误边界思路、异步状态建模、代码分割和权限边界。组合式函数应围绕一个可复用的业务能力组织,而不是把所有逻辑集中在单个巨型组件中。

阶段五:质量工程

加入 ESLint、vue-tsc、组件测试、端到端测试和构建检查。让 CI 在合并前发现类型、交互、路由和产物问题。

阶段六:SSR 与生产交付

最后学习 SSR 的服务端入口、客户端 Hydration、请求级状态、数据序列化和浏览器 API 边界,再进入环境配置、资源缓存、CDN、发布、灰度、监控和回滚。


十二、用一个小项目验证学习成果

可以实现一个“用户目录”应用来串联全部能力:

  1. 使用 Vue 3、<script setup lang="ts"> 和 Vite 创建项目;
  2. ref 保存搜索词,用 computed 生成过滤结果;
  3. watch 根据搜索词请求接口,并通过 AbortController 取消旧请求;
  4. 用 Props 和 Emits 拆出 UserTableUserEditor
  5. v-model 实现受控表单;
  6. 用 Vue Router 实现 /users/users/:id
  7. 用 Pinia 保存登录用户和收藏状态;
  8. 用测试覆盖加载、空数据、错误和保存成功;
  9. 为页面加入动态导入;
  10. 构建后部署到带 history 回退和正确缓存头的静态服务器;
  11. 若使用 SSR,确保服务端和客户端初始数据一致;
  12. 发布时保留上一版本并验证一键回滚。

当这个项目能够解释“每个状态由谁拥有、谁可以修改、修改后如何传播、失败后如何恢复、构建后如何被浏览器加载”时,才算真正掌握了 Vue 的完整工程链路。

Vue 官方指南适合按概念学习,API Reference 适合核对具体签名、生命周期和版本行为:https://vuejs.org/guide/introduction.htmlhttps://vuejs.org/api/。遇到 defineModel、SSR 工具链或构建插件等版本敏感能力时,应以项目实际安装版本和对应文档为准。

完整学习目录

一、核心基础

  1. Vue 项目与 Vite 工具链:创建、环境变量、构建、代理和依赖治理
  2. Vue 模板与渲染:指令、表达式、列表、条件和 Virtual DOM
  3. Vue 响应式原理:ref、reactive、computed、watch 与依赖追踪
  4. Vue Composition API:setup、生命周期、作用域和逻辑组织

二、组件与交互

  1. Vue 组件契约:Props、Emits、v-model、透传属性与边界
  2. Vue Slots 与动态组件:内容分发、KeepAlive、Teleport 和异步组件
  3. Vue 表单工程:受控输入、校验、异步提交、错误与可访问性
  4. Vue Composable 设计:复用状态、清理副作用、参数契约和测试

三、应用状态与数据

  1. Vue Router 完整指南:路由匹配、守卫、懒加载和滚动行为
  2. Pinia 状态管理:Store、Getter、Action、持久化和 SSR 边界
  3. Vue 异步数据与请求状态:取消、竞态、缓存、分页和错误恢复

四、工程质量

  1. Vue 与 TypeScript:组件类型、泛型、模板检查和 API 契约
  2. Vue 测试体系:Vitest、Vue Test Utils、组件测试和端到端测试
  3. Vue 性能优化:更新边界、长列表、异步组件、Bundle 和指标
  4. Vue 可访问性与交互质量:语义、键盘、焦点、ARIA 和动效
  5. Vue 前端安全:XSS、URL、Token、CSRF、依赖和富文本边界
  6. Vue 错误处理与可观测性:Error Boundary、日志、性能和发布诊断

五、架构与交付

  1. Vue 组件库与设计系统:Token、主题、无障碍、文档和版本治理
  2. Vue SSR 与 Nuxt:水合、数据获取、缓存、SEO 和部署边界
  3. Vue 国际化工程:消息目录、Locale、日期数字、路由和回退
  4. Vue 大型应用架构:模块边界、依赖方向、权限和微前端取舍
  5. Vue 生产交付:环境配置、静态资源、缓存、灰度和回滚

一、核心基础

  1. Vue SFC 与编译器:template、script setup、style 和编译产物
  2. Vue 组件生命周期:挂载、更新、卸载、副作用和父子顺序
  3. Vue 模板引用:元素、组件实例、defineExpose 和生命周期边界
  4. Vue 调度器与 nextTick:批量更新、Flush 时机和 DOM 可见性
  5. Vue 渲染函数与 JSX:VNode、h、Slots、事件和适用边界
  6. Vue 编译宏:defineProps、defineEmits、defineModel 与泛型组件
  7. Vue 自定义指令:生命周期、DOM 行为、清理和组件替代边界
  8. Vue Plugin 与 Provide/Inject:依赖注入、类型和作用域

二、组件与交互

  1. Vue Transition 与 TransitionGroup:进入离开、列表动画和性能
  2. Vue Suspense 与异步组件:加载、错误、超时和嵌套边界
  3. Vue KeepAlive 深入:缓存键、include、生命周期和内存治理
  4. Vue Teleport:层级、事件、SSR、可访问性和弹窗架构
  5. Vue 与 Web Components:Custom Element、属性事件、样式和互操作
  6. Vue 文件上传:选择、拖拽、分片、进度、取消和重试
  7. Vue 数据表格工程:列模型、排序、筛选、选择、编辑和可访问性
  8. Vue 长列表虚拟化:可视区、动态高度、滚动锚点和性能
  9. Vue 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
  10. Vue 封装浏览器 API:ResizeObserver、IntersectionObserver 和剪贴板
  11. Vue 样式工程:Scoped CSS、CSS Modules、变量、主题和覆盖边界

三、应用状态与数据

  1. Vue Router 深入:动态路由、元数据、数据预取和失败处理
  2. Vue 登录与权限:路由、按钮、Token 刷新、403 和状态恢复
  3. Pinia 生产模式:Store 拆分、订阅、持久化、重置和测试
  4. Vue Query 数据状态:缓存键、失效、乐观更新、分页和 SSR
  5. Vue HTTP 客户端封装:Axios、拦截器、取消、重试和错误模型
  6. Vue 实时数据:WebSocket、SSE、重连、心跳和状态同步

四、工程质量

  1. Vue 单元测试:Vitest、Composable、时间、网络和稳定断言
  2. Vue 组件测试:挂载、用户交互、异步更新和契约验证
  3. Vue 端到端测试:Playwright、登录态、网络、并行和失败证据
  4. Vue 视觉回归测试:截图基线、字体、动画、阈值和审阅
  5. Vue Storybook:Story、交互测试、文档、主题和组件评审
  6. Vue DevTools 调试:组件、状态、时间线、性能和生产诊断
  7. Vue Bundle 分析:依赖图、Tree Shaking、分包、预加载和预算
  8. Vue Source Map 与发布诊断:生成、上传、隐私和版本映射
  9. Vue PWA:Service Worker、缓存更新、离线、安装和回退

五、架构与交付

  1. Vue Monorepo:Workspace、共享包、构建图、版本和边界
  2. Vue 组件库发布:构建、类型、样式、按需加载和语义化版本
  3. Vite 插件开发:Hook、虚拟模块、转换、HMR 和调试
  4. Vue 环境与运行时配置:构建变量、注入、校验和 Secret 边界
  5. Vue 微前端:路由、状态、样式、依赖隔离和迁移取舍
  6. Vue CI 质量流水线:类型、Lint、测试、构建、预览和制品
  7. Vue 静态站点交付:Nginx、CDN、History 回退、缓存和压缩
  8. Nuxt 路由与布局:文件约定、中间件、错误页和导航
  9. Nuxt Server Routes:Nitro、API、运行时配置、缓存和部署
  10. Vue SSR 水合诊断:不一致来源、客户端边界和调试方法
  11. Vue 与 Nuxt SEO:Meta、结构化数据、Canonical、站点地图和渲染
  12. Vue SSR 缓存:页面、数据、边缘缓存、个性化和失效
  13. Vue Islands 与渐进式水合:交互边界、成本和适用场景

系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。