Vue 用了两年,业务需求都能做,但被问到「组件为什么会更新」「computed 和 watch 到底该选哪个」「Vuex 的 state 是怎么变成响应式的」,脑子里能浮现的只有 API 文档的样子,讲不出个所以然。我自己有一阵就卡在这个状态,会用但不成体系。这篇是我把 Vue 2 的核心概念从头串一遍的笔记,从组件的属性、事件、插槽三要素起步,一路走到 Vuex 的简化实现和路由的底层原理。每一节都给了可以直接跑的代码和它的使用边界,不是 API 罗列。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 组件的三个核心概念,属性 props 的四种声明写法、事件的触发与冒泡、插槽的新旧两套语法
- 「大属性」这种把插槽当属性传的写法,什么时候值得用
- 双向绑定和单向数据流为什么不冲突,
v-model展开之后是什么 - 触发组件更新的条件是什么,为什么有时候改了数据视图不动
- computed 和 watch 各自的适用面,以及为什么优先用 computed
- 生命周期每个钩子的实际用途,函数式组件省掉了什么
- 内置指令和自定义指令的五个钩子
- template 和 JSX 的取舍,同一组组件用 JSX 重写一遍是什么样
- Vuex 的运行机制、核心概念、最佳实践,以及一个三十行的简化实现
- vue-router 的使用方式、路由类型和底层原理
# 一、组件的核心概念-属性props几种写法
我们的开发都是围绕着 options 来的。Vue 2 的组件说到底就是一个配置对象,props、data、computed、methods、生命周期钩子,全是这个对象上的字段。先把这张全景图放在这里:

组件之间要协作,靠的是三个东西:属性往下传数据,事件往上报变化,插槽往里塞内容。属性是其中最基础的一环。

下面这个组件把 props 的几种写法都用上了,类型约束、自定义校验、默认值、函数型 prop 各一个:
<template>
<div>
name: {{ name }}
<br />
type: {{ type }}
<br />
list: {{ list }}
<br />
isVisible: {{ isVisible }}
<br />
<button @click="handleClick">change type</button>
</div>
</template>
<script>
export default {
name: "PropsDemo",
// inheritAttrs: false,
// 这种写法不利于后期维护
// props: ['name', 'type', 'list', 'isVisible'],
props: {
name: String,
type: {
validator: function(value) {
// 这个值必须匹配下列字符串中的一个
return ["success", "warning", "danger"].includes(value);
}
},
list: {
type: Array,
// 对象或数组默认值必须从一个工厂函数获取
default: () => []
},
isVisible: {
type: Boolean,
default: false
},
onChange: {
type: Function,
default: () => {}
}
},
methods: {
handleClick() {
// 不要这么做、不要这么做、不要这么做
// this.type = "warning";
// 可以,还可以更好
this.onChange(this.type === "success" ? "warning" : "success");
}
}
};
</script>
这段代码里有四个点值得停一下。
props: ['name', 'type', 'list', 'isVisible'] 这种数组写法被我注释掉了,因为它不利于后期维护。数组写法只声明了名字,没有类型、没有默认值、没有校验,半年后接手的人得去读调用方的代码才知道该传什么。
type 用的是 validator 自定义校验,只允许 success、warning、danger 三个值。传错了 Vue 会在控制台打警告,但组件照常渲染,它是开发期的提示不是运行时的拦截。
list 的默认值写成了 default: () => [],这里必须是工厂函数。对象或数组默认值必须从一个工厂函数获取,因为组件可能被复用多次,写成 default: [] 的话所有实例共享同一个数组引用,一个实例往里 push,别的实例全跟着变。
onChange 是一个函数型的 prop。handleClick 里注释掉的那行 this.type = "warning" 是明确不该做的事,props 是单向的,子组件直接改父组件传下来的值,父组件下次重新渲染就会把你的修改覆盖掉,而且 Vue 会警告。正确做法是调用父组件传下来的 onChange,让父组件自己去改。
用法这边混着写了 props 和原生属性:
// 用法
<Props
name="Hello Vue!" // 原生属性
:type="type"
:is-visible="false"
:on-change="handlePropChange"
title="属性Demo" // 原生属性
class="test1" // 原生属性
:class="['test2']"
:style="{ marginTop: '20px' }"
style="margin-top: 10px" // 原生属性
/>
没有在 props 里声明的属性,比如这里的 title,会落到组件根元素上,同时能通过 $attrs 拿到。不想让它自动落到根元素上,就打开被注释掉的那行 inheritAttrs: false。
class 和 style 是两个特例,它们不走 $attrs,而是和组件内部根元素上写的 class、style 做合并,不是覆盖。所以上面同时写 class="test1" 和 :class="['test2']" 两个都会生效,style 同理。
还有个细节,模板里写 :is-visible 是短横线命名,声明的时候是 isVisible 驼峰。HTML 属性名大小写不敏感,所以模板里推荐用短横线,Vue 会自动转换。用 JSX 或者 render 函数的时候没有这个限制,直接写驼峰。
# 二、组件的核心概念-事件
属性把数据从父传到子,事件负责把变化从子报回父。

下面这个例子既演示了 $emit 上报,也演示了一个很容易踩的坑,原生事件的冒泡阻止:
<template>
<div>
name: {{ name || "--" }}
<br />
<input :value="name" @change="handleChange" />
<br />
<br />
<div @click="handleDivClick">
<button @click="handleClick">重置成功</button>
<button @click.stop="handleClick">重置失败</button>
</div>
</div>
</template>
<script>
export default {
name: "EventDemo",
props: {
name: String
},
methods: {
handleChange(e) {
this.$emit("change", e.target.value);
},
handleDivClick() {
this.$emit("change", "");
},
handleClick(e) {
// 都会失败
//e.stopPropagation();
}
}
};
</script>
handleChange 里的 this.$emit("change", e.target.value) 是标准写法,事件名用小写短横线风格更安全,因为 HTML 里事件名同样大小写不敏感。
真正有意思的是那两个按钮。外层 div 上绑了 handleDivClick,里层按钮上绑了 handleClick。点「重置成功」这个按钮,事件冒泡到外层 div,handleDivClick 会执行,$emit("change", "") 把名字清空,所以它「成功」了。点「重置失败」那个按钮,模板上写了 @click.stop,冒泡被 Vue 的修饰符拦住了,外层的处理函数不执行。
handleClick 里被注释掉的 e.stopPropagation() 标着「都会失败」,这句注释指的是:你想在这个方法里手动阻止冒泡,效果和用 .stop 修饰符不一样。修饰符生成的代码是在事件处理函数最外层先调 stopPropagation,而你写在函数体里的时机没那么靠前,遇到多个处理函数或者更复杂的绑定顺序时行为不可控。
结论就是能用修饰符就用修饰符。.stop、.prevent、.capture、.self、.once、.passive 这一套是 Vue 给你的声明式出口,逻辑意图写在模板上一眼可见,比藏在方法体里清楚得多。
# 三、组件的核心概念-插槽
属性传数据,事件传变化,插槽传的是「内容」,也就是一段 DOM 结构。

Vue 2.6 换过一次插槽语法,新旧两套都能用,下面这段把它们放在一起对比:
<a-tab-pane key="slot" tab="插槽">
<h2>2.6 新语法</h2>
<SlotDemo>
<p>default slot</p>
<template v-slot:title>
<p>title slot1</p>
<p>title slot2</p>
</template>
<template v-slot:item="props">
<p>item slot-scope {{ props }}</p>
</template>
</SlotDemo>
<br />
<h2>老语法</h2>
<SlotDemo>
<p>default slot</p>
<p slot="title">title slot1</p>
<p slot="title">title slot2</p>
<p slot="item" slot-scope="props">item slot-scope {{ props }}</p>
</SlotDemo>
</a-tab-pane>
<script>
import Slot from "./Slot";
export default {
components: {
SlotDemo: Slot
},
data: () => {
return {
name: "",
type: "success",
bigPropsName: "Hello world!"
};
},
};
</script>
新语法统一用 v-slot,具名插槽写 v-slot:title,作用域插槽写 v-slot:item="props",一个指令覆盖两种场景。老语法是 slot="title" 和 slot-scope="props" 两个不同的特性,而且 slot 是写在普通元素上的,看不出它和插槽的关系。
新语法还有个硬性限制:v-slot 只能用在 <template> 标签或者组件标签上,不能像老的 slot 那样随便挂在一个 <p> 上。这个限制不是找麻烦,它让插槽的边界在模板里变得明确。
对应的子组件是这么定义插槽出口的: