用 Vue 2 写了两三年,第一次开 Vue 3 的项目,最先卡住的往往不是组合式 API,而是一堆细碎的变化。脚手架到底该用哪个,生命周期钩子改了几个名字,mixin 还能不能继续用,模态框那个被父级 overflow 裁掉的老问题在 Vue 3 里有没有官方解法。这篇是我把 Vue 3 基础这块重新过一遍的笔记,按「建项目 → 父子传值 → 生命周期 → 逻辑复用 → Teleport」这条线走完,每个点都说清楚它解决什么、什么时候用、边界在哪。读完你至少能独立起一个 Vue 3 项目,并且知道哪些 Vue 2 的肌肉记忆需要改掉。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Vue CLI 和 Vite 两套脚手架怎么选,各自的命令和适用场景
- 父组件给子组件传值、props 验证的七种写法、单向数据流为什么不能破
$refs和$parent这两条「越级」通道的正确用法和代价- Vue 2 到 Vue 3 生命周期钩子的完整对照表,以及
setup为什么吃掉了前两个钩子 keep-alive缓存组件状态、this.$nextTick拿渲染后的 DOM- mixin 的三条合并规则,以及它为什么在 Vue 3 里被劝退
- 用 Teleport 实现一个模态对话框组件,顺手解决层级和裁切问题
# 一、先把项目跑起来 Vue CLI 和 Vite 怎么选
动手之前电脑上得先有 Node.js,装稳定版就行。Vue 官方那会儿同时提供了两个脚手架,Vue CLI 和 Vite,两者不是替代关系,是两个时代。
Vue CLI 基于 webpack,生态成熟、插件多、配置项全,老项目和需要复杂构建定制的项目还在用它。安装是全局装一个命令行工具:
yarn global add @vue/cli
# OR
npm install -g @vue/cli
# OR
cnpm install -g @vue/cli
如果电脑上面没有安装过 cnpm,可以通过下面命令安装:
npm install -g cnpm --registry=https://registry.npm.taobao.org
这条命令在 2021 年是标准写法,现在淘宝源域名已经迁移到 registry.npmmirror.com,老域名的证书早就过期了,照抄会直接报错。现在更常见的做法是不装 cnpm,直接给 npm 配镜像源,或者用 pnpm 自带的 registry 配置。
如果电脑上面没有安装过 yarn,可以通过下面命令安装:
npm i -g yarn
装完就能创建项目了,这一步会有一个交互式的选项列表,问你要不要 Router、Vuex、TypeScript、ESLint:
vue create hello-vue3
yarn serve
另一条路是 Vite。它开发阶段不打包,直接用浏览器原生的 ES Module 按需编译,改一行代码热更新几乎是瞬时的,项目越大差距越明显。用 npm 创建:
npm init vite-app <project-name>
cd <project-name>
npm install
npm run dev
用 yarn 创建:
yarn create vite-app <project-name>
cd <project-name>
yarn
yarn dev
这里有个坑要注意,vite-app 这个包名是 Vite 1.x 时期的产物,后来官方模板改名了,现在通行的是 npm create vite@latest。如果你要的是一个开箱带 Router 和 Pinia 的 Vue 项目,Vue 官方现在推荐的入口是 npm create vue@latest,它内部就是 Vite 驱动的。Vue CLI 目前处于维护状态,新项目基本不用再考虑它了,具体版本策略以官方文档为准。
先说结论,除非你要接手的是一个已经跑在 webpack 上的老仓库,否则新项目直接上 Vite。
# 目录结构
创建完的项目长这样,src 下面是源码,public 放不参与打包的静态资源,入口在 main.js:

# 开发工具以及插件配置
编辑器这块我用 VS Code。这里有个特别容易踩的点,Vue 2 时代大家装的是 Vetur,但 Vetur 不认 Vue 3 的类型推导,装了反而会满屏飘红。Vue 3 项目要装的是 Volar,它后来改名叫 Vue - Official,两个插件同时开启会互相打架,装新的记得把旧的禁用掉。

# 二、父组件给子组件传值 props验证与单向数据流
# 父子组件是怎么串起来的
组件树的关系图先放在这,父组件通过标签属性把数据往下传,子组件通过事件把消息往上抛,这是最基础的一组通道:

# 父组件调用子组件的时候传值
父组件这边,把要传的数据写在 data 里,在标签上用 :title 绑上去:
<template>
<v-header :title="title"></v-header>
</template>
<script>
import Header from './Header'
export default {
data() {
return {
title: "首页组件title"
}
},
components: {
"v-header": Header
}
}
</script>
# 子组件接收父组件传值
子组件用 props 声明自己要接收哪些字段,声明完就能像自己的数据一样在模板里用:
<template>
<header>{{title}}</header>
</template>
<script>
export default {
props: ["title"]
}
</script>
数组形式的 props 写起来快,但它什么都不校验。传错类型、少传必填项,全都要等到页面白屏了才发现。
# Props 验证
我们可以为组件的 prop 指定验证要求。如果有一个要求没被满足,Vue 会在浏览器控制台里警告你。这在开发一个会被别人用到的组件时尤其有帮助,相当于给组件写了一份可执行的接口文档。
下面这段把常见的七种写法都列全了,从最简单的类型检查到自定义验证函数:
props: {
// 基础的类型检查 (`null` 和 `undefined` 会通过任何类型验证)
propA: Number,
// 多个可能的类型
propB: [String, Number],
// 必填的字符串
propC: {
type: String,
required: true
},
// 带有默认值的数字
propD: {
type: Number,
default: 100
},
// 带有默认值的对象
propE: {
type: Object,
// 对象或数组默认值必须从一个工厂函数获取
default: function() {
return { message: 'hello' }
}
},
// 自定义验证函数
propF: {
validator: function(value) {
// 这个值必须匹配下列字符串中的一个
return ['success', 'warning', 'danger'].indexOf(value) !== -1
}
},
// 具有默认值的函数
propG: {
type: Function,
// 和对象、数组的默认值不同,这里的函数不是工厂函数,它本身就是默认值
default: function() {
return 'Default function'
}
}
}
propE 和 propG 这两条最容易记混。对象和数组的 default 必须写成工厂函数,因为引用类型如果直接写字面量,所有组件实例会共享同一个对象,一个改了大家全跟着变。而 Function 类型的 default 就是那个函数本身,不需要再包一层。
# 单向数据流
所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。这样能防止子组件意外改掉父组件的状态,避免数据流向变得难以追踪。
另外,每次父级组件发生变更时,子组件中所有的 prop 都会刷新为最新的值。所以你不应该在子组件内部直接改 prop,改了 Vue 会在控制台发警告,而且下一次父组件更新时你的修改会被覆盖掉。
那真的需要在子组件里改怎么办?两种做法。一种是把 prop 当初始值,在 data 里复制一份自己维护;另一种是用 computed 基于 prop 派生出新值。要是父组件也需要知道这个变化,那就别绕了,老老实实 $emit 一个事件让父组件自己改。
# 父组件主动获取子组件的数据和执行子组件方法
props 和事件是常规通道,但有时候你就是想在父组件里直接调子组件的一个方法,比如让表单子组件执行一次校验。这时候用 ref。
调用子组件的时候定义一个 ref:
<v-header ref="header"></v-header>
父组件主动获取子组件数据:
this.$refs.header.属性
父组件主动执行子组件方法:
this.$refs.header.方法
# 子组件主动获取父组件的数据和执行父组件方法
反过来,子组件也能通过 $parent 摸到父组件实例。
子组件主动获取父组件的数据:
this.$parent.数据
子组件主动执行父组件方法:
this.$parent.方法
这两条通道我自己的感受是能少用就少用。$refs 还算可控,父组件本来就知道自己挂了哪些子组件;但 $parent 相当于让子组件对它的使用环境做了假设,一旦这个组件被挪到别的地方复用,this.$parent.xxx 立刻就是 undefined。真要跨层级通信,后面 provide/inject 或者状态管理才是正路,这块我在 Vue组件化实践详解 里把九种方式的取舍都排了一遍。
还有个 Vue 3 的变化得提一句。用 script setup 写的组件默认是封闭的,父组件通过 ref 拿到的实例上什么都没有,你得在子组件里用 defineExpose 显式暴露想给外面用的属性和方法。这个设计我一开始觉得麻烦,用久了反而觉得舒服,组件的对外接口一眼就能看全。
# 三、生命周期函数 从Vue2到Vue3的钩子对照
# 生命周期函数
先看这张图,一个组件从创建到销毁会经过创建、挂载、更新、卸载四个阶段,每个阶段前后各埋一个钩子:

Vue 3 里这些钩子在组合式 API 中都换了名字,规律是加 on 前缀改成驼峰:
| Vue2 | Vue3 |
|---|---|
| beforeCreate | ❌setup(替代) |
| created | ❌setup(替代) |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeUpdate | onBeforeUpdate |
| updated | onUpdated |
| beforeDestroy | onBeforeUnmount |
| destroyed | onUnmounted |
| errorCaptured | onErrorCaptured |
| - | 🎉onRenderTracked |
| - | 🎉onRenderTriggered |
表里有三处值得单独拎出来说。