Skip to content

Vue 关键知识

新建项目

1️⃣ 基于 create-vue 创建

create-vue 是 Vue 官方的项目脚手架工具。你将会看到一些诸如 TypeScript 和测试支持之类的可选功能提示:

sh
pnpm create vue@latest
create-vue.png

2️⃣ 基于 create-vite 创建

create-vite 是 Vite 官方的项目脚手架工具。使用 Vite 创建项目时可以选择多种项目模板,包括 Vue、React、SSR 等等类型

sh
## 执行创建命令
pnpm create vite
create-vite.png

选择某个框架之后,还可以自定义模板,比如这里选择 Vue 框架,还可以通过 create-vue 来自定义模板,也可以选择 Nuxt 模板等

create-vite-2.png

模板语法

同名简写

  • 仅支持 3.4 版本及以上

如果 attribute 的名称与绑定的 JavaScript 值的名称相同,那么可以进一步简化语法,省略 attribute 值:

html
<!-- 与 :id="id" 相同 -->
<div :id></div>

<!-- 这也同样有效 -->
<div v-bind:id></div>

布尔型 Attribute

布尔型 attribute  依据 true / false 值来决定 attribute 是否应该存在于该元素上。disabled  就是最常见的例子之一。

v-bind  在这种场景下的行为略有不同:

html
<button :disabled="isButtonDisabled">Button</button>

当  isButtonDisabled  为真值或一个空字符串 (即  <button disabled="">) 时,元素会包含这个  disabled attribute。而当其为其他假值时 attribute 将被忽略。

⏰  注意:当布尔型 Attribute 的值为空字符串时,元素会包含这个 attribute,相当于这个 attribute 为 true;这和 JS 中的认知不同,这是 HTML5 中推荐的规范

动态绑定多个值

如果你有像这样的一个包含多个 attribute 的 JavaScript 对象:

js
const objectOfAttrs = {
  id: "container",
  class: "wrapper",
  style: "background-color:green",
};

通过不带参数的  v-bind,你可以将它们绑定到单个元素上:

html
<div v-bind="objectOfAttrs"></div>

指令 Directives

指令是带有  v-  前缀的特殊 attribute。Vue 提供了许多内置指令,包括上面我们所介绍的  v-bind  和  v-html

指令 attribute 的期望值为一个 JavaScript 表达式 (除了少数几个例外,即之后要讨论到的  v-forv-on  和  v-slot)。一个指令的任务是在其表达式的值变化时响应式地更新 DOM。以  v-if  为例:

html
<p v-if="seen">Now you see me</p>

这里,v-if  指令会基于表达式  seen  的值的真假来移除/插入该  <p>  元素。

参数 Arguments

某些指令会需要一个“参数”,在指令名后通过一个冒号隔开做标识。例如用  v-bind  指令来响应式地更新一个 HTML attribute:

html
<a v-bind:href="url"> ... </a>

<!-- 简写 -->
<a :href="url"> ... </a>

这里  href  就是一个参数,它告诉  v-bind  指令将表达式  url  的值绑定到元素的  href attribute 上。在简写中,参数前的一切 (例如  v-bind:) 都会被缩略为一个  :  字符。

另一个例子是  v-on  指令,它将监听 DOM 事件:

html
<a v-on:click="doSomething"> ... </a>

<!-- 简写 -->
<a @click="doSomething"> ... </a>

这里的参数是要监听的事件名称:clickv-on  有一个相应的缩写,即  @  字符。

⏰  注意:要搞清楚 attribute 和 arguments 直接两个定义

动态参数

同样在指令参数上也可以使用一个 JavaScript 表达式,需要包含在一对方括号内

html
<a v-bind:[attributeName]="url"> ... </a>

<a v-on:[eventName]="doSomething"> ... </a>

这里的 attributeNameeventName 可以是表达式,动态值

修饰符 Modifiers

修饰符是以点开头的特殊后缀,表明指令需要以一些特殊的方式被绑定。例如  .prevent  修饰符会告知  v-on  指令对触发的事件调用  event.preventDefault()

html
<form @submit.prevent="onSubmit">...</form>

指令语法:

  • 指令名 v-on
  • 参数 submit
  • 修饰符 .prevent
  • onSubmit
directive.png

CSS 中的 v-bind()

单文件组件的 <style> 标签支持使用 v-bind CSS 函数将 CSS 的值链接到动态的组件状态:

vue
<template>
  <div class="text">hello</div>
</template>

<script>
export default {
  data() {
    return {
      color: "red",
    };
  },
};
</script>

<style>
.text {
  color: v-bind(color);
}
</style>

这个语法同样也适用于 <script setup>,且支持 JavaScript 表达式 (需要用引号包裹起来):

vue
<script setup>
import { ref } from "vue";
const theme = ref({
  color: "red",
});
</script>

<template>
  <p>hello</p>
</template>

<style scoped>
p {
  color: v-bind("theme.color");
}
</style>

响应式基础

有状态的方法 (选项式)

在某些情况下,我们可能需要动态地创建一个方法函数,比如创建一个预置防抖的事件处理器:

js
import { debounce } from "lodash-es";

export default {
  methods: {
    // 使用 Lodash 的防抖函数
    click: debounce(function () {
      // ... 对点击的响应 ...
    }, 500),
  },
};

不过这种方法对于被重用的组件来说是有问题的,因为这个预置防抖的函数是有状态的:它在运行时维护着一个内部状态。如果多个组件实例都共享这同一个预置防抖的函数,那么它们之间将会互相影响。

要保持每个组件实例的防抖函数都彼此独立,我们可以改为在  created  生命周期钩子中创建这个预置防抖的函数:

js
export default {
  created() {
    // 每个实例都有了自己的预置防抖的处理函数
    this.debouncedClick = _.debounce(this.click, 500);
  },
  unmounted() {
    // 最好是在组件卸载时
    // 清除掉防抖计时器
    this.debouncedClick.cancel();
  },
  methods: {
    click() {
      // ... 对点击的响应 ...
    },
  },
};

reactive() 的局限性

  1. 有限的值类型:它只能用于对象类型 (对象、数组和如  MapSet  这样的集合类型)。它不能持有如  stringnumber  或  boolean  这样的原始类型
  2. 不能替换整个对象:由于 Vue 的响应式跟踪是通过属性访问实现的,因此我们必须始终保持对响应式对象的相同引用。这意味着我们不能轻易地“替换”响应式对象,因为这样的话与第一个引用的响应性连接将丢失:
js
let state = reactive({ count: 0 });

// 上面的 ({ count: 0 }) 引用将不再被追踪
// (响应性连接已丢失!)
state = reactive({ count: 1 });
  1. 对解构操作不友好:当我们将响应式对象的原始类型属性解构为本地变量时,或者将该属性传递给函数时,我们将丢失响应性连接:
js
const state = reactive({ count: 0 });

// 当解构时,count 已经与 state.count 断开连接
let { count } = state;
// 不会影响原始的 state
count++;

// 该函数接收到的是一个普通的数字
// 并且无法追踪 state.count 的变化
// 我们必须传入整个对象以保持响应性
callSomeFunction(state.count);

由于这些限制,我们建议使用  ref()  作为声明响应式状态的主要 API。

额外的 ref 解包细节

一个 ref 会在作为响应式对象的属性被访问或修改时自动解包。换句话说,它的行为就像一个普通的属性:

js
const count = ref(0);
const state = reactive({
  count,
});

console.log(state.count); // 0

state.count = 1;
console.log(count.value); // 1

如果将一个新的 ref 赋值给一个关联了已有 ref 的属性,那么它会替换掉旧的 ref:

js
const otherCount = ref(2);

state.count = otherCount;
console.log(state.count); // 2
// 原始 ref 现在已经和 state.count 失去联系
console.log(count.value); // 1

只有当嵌套在一个深层响应式对象内时,才会发生 ref 解包。当其作为浅层响应式对象的属性被访问时不会解包。

参考 https://cn.vuejs.org/guide/essentials/reactivity-fundamentals.html#additional-ref-unwrapping-details

计算属性

计算属性缓存 vs 方法

我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的,然而,不同之处在于计算属性值会基于其响应式依赖被缓存。相比之下,方法调用总是会在重渲染发生时再次执行函数。

可写计算属性

计算属性默认是只读的。当你尝试修改一个计算属性时,你会收到一个运行时警告。只在某些特殊场景中你可能才需要用到“可写”的属性,你可以通过同时提供 getter 和 setter 来创建。

js
import { ref, computed } from "vue";

const firstName = ref("John");
const lastName = ref("Doe");

const fullName = computed({
  // getter
  get() {
    return firstName.value + " " + lastName.value;
  },
  // setter
  set(newValue) {
    // 注意:我们这里使用的是解构赋值语法
    [firstName.value, lastName.value] = newValue.split(" ");
  },
});

类和样式绑定

数据绑定的一个常见需求场景是操纵元素的 CSS class 列表和内联样式。因为  class  和  style  都是 attribute,我们可以和其他 attribute 一样使用  v-bind  将它们和动态的字符串绑定。但是,在处理比较复杂的绑定时,通过拼接生成字符串是麻烦且易出错的。因此,Vue 专门为  class  和  style  的  v-bind  用法提供了特殊的功能增强。除了字符串外,表达式的值也可以是对象或数组

绑定 HTML class

errorClass  会一直存在,但  activeClass  只会在  isActive  为真时才存在。

html
<!-- 方案 1 -->
<div :class="[isActive ? activeClass : '', errorClass]"></div>

<!-- 方案 2:数组中嵌套对象 -->
<div :class="[{ [activeClass]: isActive }, errorClass]"></div>

在组件中使用

对于只有一个根元素的组件,当你使用了  class attribute 时,这些 class 会被添加到根元素上并与该元素上已有的 class 合并。

举例来说,如果你声明了一个组件名叫  MyComponent,模板如下:

html
<!-- 子组件模板 -->
<p class="foo bar">Hi!</p>

在使用时添加一些 class:

html
<!-- 在使用组件时 -->
<MyComponent class="baz boo" />

渲染出的 HTML 为:

html
<p class="foo bar baz boo">Hi!</p>

条件渲染

v-if

一个  v-else  元素必须跟在一个  v-if  或者  v-else-if  元素后面,否则它将不会被识别

因为  v-if  是一个指令,他必须依附于某个元素。但如果我们想要切换不止一个元素呢?在这种情况下我们可以在一个  <template>  元素上使用  v-if,这只是一个不可见的包装器元素,最后渲染的结果并不会包含这个  <template>  元素。

v-show

v-show  会在 DOM 渲染中保留该元素;v-show  仅切换了该元素上名为  display  的 CSS 属性。 v-show  不支持在  <template>  元素上使用,也不能和  v-else  搭配使用。

列表渲染

使用  of  或  in 作为分隔符均可,效果一样

遍历数组

html
<li v-for="(item, index) in items" :key="index">{{ index }} - {{ item }}</li>

第一个参数 item 是遍历数组的项,第二个参数 index 是当前项的位置索引

遍历对象

html
<li v-for="(value, key, index) in myObject">{{ index }}. {{ key }}: {{ value }}</li>

第一个参数 value 是遍历对象的属性值,第二个参数 key 表示属性名,第三个参数 index 表示位置索引

使用范围值

v-for  可以直接接受一个整数值。在这种用例中,会将该模板基于  1...n  的取值范围重复多次。

html
<span v-for="n in 10">{{ n }}</span>

注意此处  n  的初值是从  1  开始而非  0

组件上使用 v-for

我们可以直接在组件上使用  v-for,和在一般的元素上使用没有区别,但是,这不会自动将任何数据传递给组件,因为组件有自己独立的作用域。为了将迭代后的数据传递到组件中,我们还需要传递 props:

html
<MyComponent v-for="(item, index) in items" :item="item" :index="index" :key="item.id" />

v-for 与 v-if

同时使用 v-if 和 v-for 是不推荐的,因为这样二者的优先级不明显。

当它们同时存在于一个节点上时,v-if 比 v-for 的优先级更高。这意味着 v-if 的条件将无法访问到 v-for 作用域内定义的变量别名:

html
<!--
 这会抛出一个错误,因为属性 todo 此时
 没有在该实例上定义
-->
<li v-for="todo in todos" v-if="!todo.isComplete">{{ todo.name }}</li>

在外先包装一层 <template> 再在其上使用 v-for 可以解决这个问题 (这也更加明显易读):

html
<template v-for="todo in todos">
  <li v-if="!todo.isComplete">{{ todo.name }}</li>
</template>

事件处理

内联事件处理器

html
<button @click="count++">Add 1</button>

foo()  和  count++  () ⇒ {} 会被视为内联事件处理器

在内联处理器中调用方法

html
<button @click="say('hello')">Say hello</button> <button @click="say('bye')">Say bye</button>
js
methods: {
  say(message) {
    alert(message)
  }
}

在内联处理器中访问事件参数

html
<!-- 使用特殊的 $event 变量 -->
<button @click="warn('Form cannot be submitted yet.', $event)">Submit</button>

<!-- 使用内联箭头函数 -->
<button @click="(event) => warn('Form cannot be submitted yet.', event)">Submit</button>
js
methods: {
  warn(message, event) {
    // 这里可以访问 DOM 原生事件
    if (event) {
      event.preventDefault()
    }
    alert(message)
  }
}

方法事件处理器

html
<!-- `greet` 是上面定义过的方法名 -->
<button @click="greet">Greet</button>

foofoo.bar  和  foo['bar']  会被视为方法事件处理器

方法事件处理器会自动接收原生 DOM 事件并触发执行。在上面的例子中,我们能够通过被触发事件的  event.target  访问到该 DOM 元素。

js
methods: {
  greet(event) {
    // 方法中的 `this` 指向当前活跃的组件实例
    alert(`Hello ${this.name}!`)
    // `event` 是 DOM 原生事件
    if (event) {
      alert(event.target.tagName)
    }
  }
}

表单输入绑定

也就是我们常说的双向绑定,v-model

1️⃣ 手动连接值绑定和更改事件监听器

html
<input :value="text" @input="event => text = event.target.value" />

2️⃣ v-model 指令

html
<input v-model="text" />

v-model  会忽略任何表单元素上初始的  valuechecked  或  selected attribute。它将始终将当前绑定的 JavaScript 状态视为数据的正确来源。

多行文本

html
<span>Multiline message is:</span>
<p style="white-space: pre-line;">{{ message }}</p>
<textarea v-model="message" placeholder="add multiple lines"></textarea>

white-space: pre-line 的意思是:

  • 保留换行符:元素内的换行符会被保留,即文本会按照源代码中的换行位置进行换行。
  • 合并连续空格:连续的空格会被合并为一个空格。

textarea

在  <textarea>  中是不支持插值表达式的。请使用  v-model  来替代

html
<!-- 错误 -->
<textarea>{{ text }}</textarea>

<!-- 正确 -->
<textarea v-model="text"></textarea>

原生的 <textarea>  标签体中的内容是默认输入内容,不支持 value 属性

生命周期

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。

lifecycle.png

侦听器 watch

计算属性允许我们声明性地计算衍生值。然而在有些情况下,我们需要在状态变化时执行一些“副作用”:例如更改 DOM,或是根据异步操作的结果去修改另一处的状态。

watch 的第一个参数可以是不同形式的“数据源”:它可以是:

  • 一个 ref (包括计算属性)
  • 一个响应式对象 reactive
  • 一个 getter 函数 () => value
  • 以上多个数据源组成的数组

警告

注意,你不能直接侦听响应式对象的属性值,例如:

js
const obj = reactive({ count: 0 });

// 错误,因为 watch() 得到的参数是一个 number
watch(obj.count, (count) => {
  console.log(`Count is: ${count}`);
});

这里需要用一个返回该属性的 getter 函数:

js
// 提供一个 getter 函数
watch(
  () => obj.count,
  (count) => {
    console.log(`Count is: ${count}`);
  }
);

回调的出发时机

默认情况下,侦听器回调会在父组件更新 (如有) 之后、所属组件的 DOM 更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的 DOM,那么 DOM 将处于更新前的状态。

使用命令式创建侦听器

js
created() {
    this.$watch('question', (newQuestion) => {
      // ...
    })
  }

停止侦听器

js
const unwatch = this.$watch("foo", callback);

// ...当该侦听器不再需要时
unwatch();

V3 侦听数据源类型

  • 一个 ref 对象
  • 一个 reactive 对象
  • 一个 getter 函数
  • 以上多个数据源组成的数组

模板引用

ref  是一个特殊的 attribute,它允许我们在一个特定的 DOM 元素或子组件实例被挂载后,获得对它的直接引用,挂载结束后引用都会被暴露在  this.$refs  之上。

当在  v-for  中使用模板引用时,相应的引用中包含的值是一个数组,ref 数组并不保证与源数组相同的顺序

html
<ul>
  <li v-for="item in list" ref="items">{{ item }}</li>
</ul>
  • ref 使用在 DOM 元素上时,获得是真实 DOM 元素
  • ref 使用在 组件标签上时,获得是组件实例

组合式 API 中使用模板引用

  • Vue 3.5 以前
vue
<script setup>
import { ref, onMounted } from "vue";

// 声明一个 ref 来存放该元素的引用;必须和模板里的 ref 同名(这里就将模板引用和响应式变量混用了,不太好)
const input = ref(null);

onMounted(() => {
  input.value.focus();
});
</script>

<template>
  <input ref="input" />
</template>
  • Vue 3.5 以后
vue
<script setup>
import { useTemplateRef, onMounted } from "vue";

// 第一个参数必须与模板中的 ref 值匹配
const input = useTemplateRef("my-input");

onMounted(() => {
  input.value.focus();
});
</script>

<template>
  <input ref="my-input" />
</template>

使用了  <script setup>  的组件是默认私有的:一个父组件无法访问到一个使用了  <script setup>  的子组件中的任何东西,除非子组件在其中通过  defineExpose  宏显式暴露

html
<script setup>
  import { ref } from "vue";

  const a = 1;
  const b = ref(2);

  // 像 defineExpose 这样的编译器宏不需要导入
  defineExpose({
    a,
    b,
  });
</script>

类型标注

为了获取导入组件的实例类型,我们需要先通过  typeof  获取其类型,然后使用 TypeScript 的内置  InstanceType  工具提取其实例类型:

vue
<!-- App.vue -->
<script setup lang="ts">
import { useTemplateRef } from "vue";
import Foo from "./Foo.vue";
import Bar from "./Bar.vue";

type FooType = InstanceType<typeof Foo>;
type BarType = InstanceType<typeof Bar>;

const compRef = useTemplateRef<FooType | BarType>("comp");
</script>

<template>
  <component :is="Math.random() > 0.5 ? Foo : Bar" ref="comp" />
</template>

组件基础

动态组件

html
<!-- currentTab 改变时组件也改变 -->
<component :is="currentTab"></component>

被传给  :is  的值可以是以下几种:

  • 被注册的组件名
  • 导入的组件对象
  • 异步组件
  • 内置的 HTML 标签
html
<!-- 组件名称 -->
<template>
  <component :is="currentComponent"></component>
</template>

<script>
  import MyComponentA from "./MyComponentA.vue";
  import MyComponentB from "./MyComponentB.vue";

  export default {
    components: {
      MyComponentA,
      MyComponentB,
    },
    data() {
      return {
        currentComponent: "MyComponentA", // 动态切换到 MyComponentA 或 MyComponentB
      };
    },
  };
</script>

<!-- 组件对象 -->
<template>
  <component :is="currentComponent"></component>
</template>

<script>
  import MyComponentA from "./MyComponentA.vue";
  import MyComponentB from "./MyComponentB.vue";

  export default {
    data() {
      return {
        currentComponent: MyComponentA, // 动态切换组件
      };
    },
  };
</script>

<!-- 异步组件 -->
<template>
  <component :is="currentComponent"></component>
</template>

<script>
  export default {
    data() {
      return {
        currentComponent: () => import("./MyComponentA.vue"), // 异步加载组件
      };
    },
  };
</script>

<!-- 内置的 HTML 标签 -->
<template>
  <component :is="currentTag">Hello World</component>
</template>

<script>
  export default {
    data() {
      return {
        currentTag: "div", // 动态渲染 div 或其他 HTML 标签
      };
    },
  };
</script>

当使用  <component :is="...">  来在多个组件间作切换时,被切换掉的组件会被卸载。我们可以通过  <KeepAlive>  组件强制被切换掉的组件仍然保持“存活”的状态。

组件注册

局部注册和全局注册

全局注册时没有被使用的组件无法在生产打包时被自动移除 (也叫“tree-shaking”)

使用 Vue.component()app.component() 全局注册组件

js
import MyComponent from "./App.vue";

app.component("MyComponent", MyComponent);

在使用 <script setup> 的单文件组件中,导入的组件可以直接在模板中使用,无需注册:

vue
<script setup>
import ComponentA from "./ComponentA.vue";
</script>

<template>
  <ComponentA />
</template>

如果没有使用 <script setup>,则需要使用 components 选项来显式注册:

js
import ComponentA from "./ComponentA.js";

export default {
  components: {
    ComponentA,
  },
  setup() {
    // ...
  },
};

Props

使用一个对象绑定多个 prop

如果你想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的  v-bind,即只使用  v-bind  而非  :prop-name

js
export default {
  data() {
    return {
      post: {
        id: 1,
        title: "My Journey with Vue",
      },
    };
  },
};
js
<BlogPost v-bind="post" />
// 等价于
<BlogPost :id="post.id" :title="post.title" />

组合式 API 中使用 Props

  • 使用  <script setup>  的单文件组件
js
// 字符串数组
const props = defineProps(['foo'])

// 对象
const props = defineProps({
  foo: String // 这里的 String 是构造函数,不是类型
})

// 实践中通常使用函数泛型的写法,可单独将泛型定义为 interface
<script setup lang="ts">
defineProps<{
  title?: string
  likes?: number
}>()
</script>

// 指定默认值
const props = withDefaults(defineProps<{ list: PersonList }>(), {
   list: () => [{ id: 'abcd01', name: '小猪佩奇', age: 18 }]
  })
  • 没有使用  <script setup>  的组件
js
export default {
  props: ["foo"],
  setup(props) {
    // setup() 接收 props 作为第一个参数
    console.log(props.foo);
  },
};

响应式 Props 解构

js
const { foo } = defineProps(["foo"]);

watchEffect(() => {
  // 在 3.5 之前只运行一次
  // 在 3.5+ 中在 "foo" prop 变化时重新执行
  console.log(foo);
});

在 3.4 及以下版本,foo  是一个实际的常量,永远不会改变。在 3.5 及以上版本,当在同一个  <script setup>  代码块中访问由  defineProps  解构的变量时,Vue 编译器会自动在前面添加  props.

js
const props = defineProps(["foo"]);

watchEffect(() => {
  // `foo` 由编译器转换为 `props.foo`
  console.log(props.foo);
});

此外,你可以使用 JavaScript 原生的默认值语法声明 props 默认值。

js
const { foo = 'hello' } = defineProps<{ foo?: string }>()

将解构的 props 传递到函数中

当我们将解构的 prop 传递到函数中时,例如:

js
const { foo } = defineProps(["foo"]);

watch(foo /* ... */);

这并不会按预期工作,因为它等价于 watch(props.foo, ...)——我们给 watch 传递的是一个值而不是响应式数据源。实际上,Vue 的编译器会捕捉这种情况并发出警告。

与使用 watch(() => props.foo, ...) 来侦听普通 prop 类似,我们也可以通过将其包装在 getter 中来侦听解构的 prop:

js
watch(() => foo /* ... */);

Props 解构默认值

当使用基于类型的声明时,我们失去了为 props 声明默认值的能力。可以通过使用响应式 Props 解构解决这个问题。 (3.5+)

typescript
interface Props {
  msg?: string;
  labels?: string[];
}

const { msg = "hello", labels = ["one", "two"] } = defineProps<Props>();

在 3.4 及更低版本,响应式 Props 解构不会被默认启用。另一种选择是使用 withDefaults 编译器宏:

typescript
interface Props {
  msg?: string;
  labels?: string[];
}

const props = withDefaults(defineProps<Props>(), {
  msg: "hello",
  labels: () => ["one", "two"],
});

事件

声明触发的事件

  • 选项式
js
export default {
  emits: ["inFocus", "submit"],
};
  • 组合式

我们在  <template>  中使用的  $emit  方法不能在组件的  <script setup>  部分中使用,但  defineEmits()  会返回一个相同作用的函数供我们使用

vue
<script setup>
const emit = defineEmits(["inFocus", "submit"]);
function buttonClick() {
  emit("submit");
}
</script>
js
export default {
  emits: ["inFocus", "submit"],
  setup(props, ctx) {
    ctx.emit("submit");
  },
};

事件校验

要为事件添加校验,那么事件可以被赋值为一个函数,接受的参数就是抛出事件时传入  emit  的内容,返回一个布尔值来表明事件是否合法。

js
export default {
  emits: {
    // 没有校验
    click: null,

    // 校验 submit 事件
    submit: ({ email, password }) => {
      if (email && password) {
        return true;
      } else {
        console.warn("Invalid submit event payload!");
        return false;
      }
    },
  },
  methods: {
    submitForm(email, password) {
      this.$emit("submit", { email, password });
    },
  },
};

组件 v-model

在原生元素上使用

html
<input v-model="searchText" />
<!-- 相当于 -->
<input :value="searchText" @input="searchText = $event.target.value" />

Vue 3.4 之后

html
<script setup>
  const model = defineModel();
</script>

<template>
  <input v-model="model" />
</template>

在组件上使用 v-model

下面两种实现方式均可

html
<!-- CustomInput.vue -->
<script>
  export default {
    props: ["modelValue"],
    emits: ["update:modelValue"],
  };
</script>

<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>
html
<!-- CustomInput.vue -->
<script>
  export default {
    props: ["modelValue"],
    emits: ["update:modelValue"],
    computed: {
      value: {
        get() {
          return this.modelValue;
        },
        set(value) {
          this.$emit("update:modelValue", value);
        },
      },
    },
  };
</script>

<template>
  <input v-model="value" />
</template>

组合式 API 中的 v-model

从 Vue 3.4 开始,推荐的实现方式是使用  defineModel()  宏:

html
<!-- Child.vue -->
<script setup>
  const model = defineModel();

  function update() {
    model.value++;
  }
</script>

<template>
  <div>Parent bound v-model is: {{ model }}</div>
  <button @click="update">Increment</button>
</template>
html
<!-- Parent.vue -->
<Child v-model="countModel" />
js
// 使 v-model 必填
const model = defineModel({ required: true });

// 提供一个默认值
const model = defineModel({ default: 0 });

透传 Attributes

“透传 attribute”指的是传递给一个组件,却没有被该组件声明为  props  或  emits  的 attribute 或者  v-on  事件监听器。最常见的例子就是  classstyle  和  id

禁用 Attributes 继承

如果你不想要一个组件自动地继承 attribute,你可以在组件选项中设置  inheritAttrs: false

从 3.3 开始你也可以直接在  <script setup>  中使用  defineOptions

defineOptions 这个宏可以用来直接在  <script setup>  中声明组件选项,而不必使用单独的  <script>  块

js
<script setup>
defineOptions({
  name: "ComponentName",
  inheritAttrs: false
})
// ...setup 逻辑
</script>

多根节点的继承

和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。如果  $attrs  没有被显式绑定,将会抛出一个运行时警告。

在 JS 中访问透传属性

  • 选项式
js
export default {
  created() {
    console.log(this.$attrs);
  },
};
  • 组合式
vue
<!-- 使用 setup 语法糖 -->
<script setup>
import { useAttrs } from "vue";
const attrs = useAttrs();
</script>
js
// 不使用 setup 语法糖
export default {
  setup(props, ctx) {
    // 透传 attribute 被暴露为 ctx.attrs
    console.log(ctx.attrs);
  },
};

插槽

原理

插槽可以理解为父组件传递对象子组件调用函数

😀 父组件中写在子组件标签体中的内容是一个对象,对象里面有插槽属性,属性值是一个函数

vue
<!-- 父组件 -->
<template>
  <ChildComp>
    <p>default slot</p>

    <template v-slot:slot1>
      <p>slot1</p>
    </template>

    <template v-slot:slot2="{ msg }">
      <p>slot2: {{ msg }}</p>
    </template>
  </ChildComp>
</template>
js
// 抽象
const obj = {
  // 默认插槽
  default: function () {
    return "<p>default slot</p>";
  },
  // 具名插槽
  slot1: function () {
    return "<p>slot1</p>";
  },
  // 作用域插槽
  slot2: function ({ msg }) {
    return "<p>slot2: {{ msg }}</p>";
  },
};

子组件中通过书写 <slot> 标签调用相应的函数,返回对应的虚拟节点

vue
<!-- 子组件 -->
<template>
  <div>
    <!-- 调用 default 函数 -->
    <slot></slot>

    <!-- 调用 slot1 函数 -->
    <slot name="slot1"></slot>

    <!-- 调用 slot2 函数,参数为 "hello world" -->
    <slot name="slot2" msg="hello world"></slot>
  </div>
</template>

信息

  • Props 使得组件可以接收任意 JavaScript 值
  • 插槽 Slot 使得组件可以接收模板内容
html
<FancyButton>
  <!-- 插槽内容 -->
  Click me!
</FancyButton>
html
<!-- FancyButton.vue -->
<button class="fancy-btn">
  <!-- 插槽出口 -->
  <slot></slot>
</button>

<slot>  元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。

slots.png

示例

js
// 子组件 Comp
import { createElementVNode } from "vue";

export default {
  setup(props, { slots }) {
    const _default = slots.default();
    const slot1 = slots.slot1();
    const slot2 = slots.slot2({ msg: "hello world" });

    return () => {
      return createElementVNode("div", null, [..._default, ...slot1, ...slot2]);
    };
  },
};
vue
<!-- 父组件 -->
<template>
  <Comp>
    <!-- 传递 default 函数 -->
    <p>default slot</p>
    <!-- 传递 slot1 函数 -->
    <template v-slot:slot1>
      <p>slot1</p>
    </template>
    <!-- 传递 slot2 函数,形参 msg -->
    <template v-slot:slot2="{ msg }">
      <p>slot2: {{ msg }}</p>
    </template>
  </Comp>
</template>

插槽默认内容

html
<button type="submit">
  <slot>
    <!-- 默认内容 -->
    Submit
  </slot>
</button>

具名插槽

html
<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

这类带  name  的插槽被称为具名插槽 (named slots)。没有提供 name  的  <slot>  出口会隐式地命名为 default

要为具名插槽传入内容,我们需要使用一个含  v-slot  指令的  <template>  元素,并将目标插槽的名字传给该指令:

html
<BaseLayout>
  <template v-slot:header>
    <!-- header 插槽的内容放这里 -->
  </template>
</BaseLayout>

<BaseLayout>
  <template #header>
    <!-- header 插槽的内容放这里 -->
  </template>
</BaseLayout>

v-slot  有对应的简写  #,因此  <template v-slot:header>  可以简写为  <template #header>。其意思就是“将这部分模板片段传入子组件的 header 插槽中”。

named-slots.png

条件插槽

根据插槽是否存在来渲染某些内容

vue
<template>
  <div class="card">
    <div v-if="$slots.header" class="card-header">
      <slot name="header" />
    </div>

    <div v-if="$slots.default" class="card-content">
      <slot />
    </div>

    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>

作用域插槽

我们需要一种方法来让子组件在渲染时将一部分数据提供给插槽,可以像对组件传递 props 那样,向一个插槽的出口上传递 attributes

html
<!-- <MyComponent> 的模板 -->
<div>
  <slot :text="greetingMessage" :count="1"></slot>
</div>

当需要接收插槽 props 时,通过子组件标签上的 v-slot 指令,直接接收到了一个插槽 props 对象

html
<MyComponent v-slot="slotProps">
  <div>{{ slotProps.text }}</div>
  <div>{{ slotProps.count }}</div>
</MyComponent>
scoped-slots.svg

应用

vue
<script setup lang="ts">
/**
 * 异步数据包装组件
 * 在这里先获取数据,再传递给插槽函数
 * 将异步逻辑集中在 AsyncWrapper 中,可以让父组件的代码更加简洁,关注点也更单一。
 * 父组件只负责渲染布局,而异步数据的加载和传递则完全交由子组件处理。
 * AsyncWrapper 将异步数据加载逻辑与页面渲染逻辑解耦,使得这部分异步逻辑更容易单独测试和优化。
 */
const AsyncWrapper = defineComponent({
  name: "AsyncWrapper",
  async setup(_, ctx) {
    // 获取热门电影列表
    const list = await listMedia(type.value, queries.value[0].query, 1);
    // 获取第一个电影的详情
    const item = await getMedia(type.value, list.results[0].id);
    // 调用插槽函数,并传入实参 item
    return () => ctx.slots?.default?.({ item });
  },
});
</script>

<template>
  <AsyncWrapper v-slot="{ item }">
    <!-- 传递 default 插槽函数,形参 item -->
    <NuxtLink :to="`/${type}/${item.id}`">
      <MediaHero :item="item" />
    </NuxtLink>
  </AsyncWrapper>
</template>

依赖注入

选项式

提供数据

js
import { computed } from "vue";

export default {
  data() {
    return {
      message: "hello!",
    };
  },
  provide() {
    return {
      // 显式提供一个计算属性,保持响应式
      message: computed(() => this.message),
    };
  },
};

除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖

js
import { createApp } from "vue";

const app = createApp({});

app.provide(/* 注入名 */ "message", /* 值 */ "hello!");

接收数据

注入会在组件自身的状态之前被解析,因此你可以在  data()  中访问到注入的属性:

js
export default {
  inject: ["message"],
  data() {
    return {
      // 基于注入值的初始数据
      fullMessage: this.message,
    };
  },
};

注入默认值

js
export default {
  // 当声明注入的默认值时
  // 必须使用对象形式
  inject: {
    message: {
      from: "message", // 当与原注入名同名时,这个属性是可选的
      default: "default value",
    },
    user: {
      // 对于非基础类型数据,如果创建开销比较大,或是需要确保每个组件实例
      // 需要独立数据的,请使用工厂函数
      default: () => ({ name: "John" }),
    },
  },
};

组合式 API

提供数据

vue
<script setup>
import { provide } from "vue";

provide(/* 注入名 */ "message", /* 值 */ "hello!");
</script>

接收数据

vue
<script setup>
import { inject } from "vue";

const message = inject("message");
</script>

注入默认值

js
// 如果没有祖先组件提供 "message"
// `value` 会是 "这是默认值"
const value = inject("message", "这是默认值");

异步组件

从服务器获取组件

js
import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // ...从服务器获取组件
    resolve(/* 获取到的组件 */);
  });
});
// ... 像使用其他一般组件一样使用 `AsyncComp`

ES 模块动态导入组件

js
import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => import("./components/MyComponent.vue"));

组合式函数

在 Vue 应用的概念中,“组合式函数”(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。

鼠标跟踪器示例

js
// useMouse.js
import { ref, onMounted, onUnmounted } from "vue";

// 按照惯例,组合式函数名以“use”开头
export function useMouse() {
  // 被组合式函数封装和管理的状态
  const x = ref(0);
  const y = ref(0);

  // 组合式函数可以随时更改其状态。
  function update(event) {
    x.value = event.pageX;
    y.value = event.pageY;
  }

  // 一个组合式函数也可以挂靠在所属组件的生命周期上
  // 来启动和卸载副作用
  onMounted(() => window.addEventListener("mousemove", update));
  onUnmounted(() => window.removeEventListener("mousemove", update));

  // 通过返回值暴露所管理的状态
  return { x, y };
}

下面是它在组件中使用的方式:

html
<script setup>
  import { useMouse } from "./mouse.js";

  const { x, y } = useMouse();
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>

进一步的,我们可以将添加和清除 DOM 事件监听器的逻辑也封装进一个组合式函数中:

js
// useEvent.js
import { onMounted, onUnmounted } from "vue";

export function useEventListener(target, event, callback) {
  // 如果你想的话,
  // 也可以用字符串形式的 CSS 选择器来寻找目标 DOM 元素
  onMounted(() => target.addEventListener(event, callback));
  onUnmounted(() => target.removeEventListener(event, callback));
}
js
// useMouse.js
import { ref } from "vue";
import { useEventListener } from "./event";

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  useEventListener(window, "mousemove", (event) => {
    x.value = event.pageX;
    y.value = event.pageY;
  });

  return { x, y };
}

useFetch 实践

js
// useFetch.js
import { ref, watchEffect, toValue } from "vue";

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);

  const fetchData = () => {
    // reset state before fetching..
    data.value = null;
    error.value = null;

    fetch(toValue(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err));
  };

  watchEffect(() => {
    fetchData();
  });

  return { data, error };
}

使用 useFetch()

vue
<script setup>
import { useFetch } from "./fetch.js";

const { data, error } = useFetch("...");
</script>

toValue()  是一个在 3.3 版本中新增的 API。它的设计目的是将 ref 或 getter 规范化为值。如果参数是 ref,它会返回 ref 的值;如果参数是函数,它会调用函数并返回其返回值。否则,它会原样返回参数。它的工作方式类似于  unref(),但对函数有特殊处理。

toValue()

js
import { toValue } from "vue";

function useFeature(maybeRefOrGetter) {
  // 如果 maybeRefOrGetter 是一个 ref 或 getter,
  // 将返回它的规范化值。
  // 否则原样返回。
  const value = toValue(maybeRefOrGetter);
}

我们推荐在纯逻辑复用时使用组合式函数,在需要同时复用逻辑和视图布局时使用无渲染组件。

无渲染组件

vue
<MouseTracker v-slot="{ x, y }">
  Mouse is at: {{ x }}, {{ y }}
</MouseTracker>

自定义指令

重用代码的方式

  • 组件:重用主要的构建模块
  • 组合式函数:重用有状态的逻辑
  • 自定义指令:重用涉及普通元素的底层 DOM 访问的逻辑

Vue 2

vue
<template>
  <h2>放大10倍后的 n 值是:<span v-big="n"></span></h2>
  <input type="text" v-fbind:value="n" />
</template>

<script>
// 定义全局指令(对象式)
Vue.directive("fbind", {
  bind(element, binding) {
    // 指令与元素成功绑定时(一上来)被调用
    element.value = binding.value;
  },
  inserted(element, binding) {
    // 指令所在元素被插入页面时被调用
    element.focus();
  },
  update(element, binding) {
    // 指令所在的模板被重新解析时被调用
    element.value = binding.value;
  },
});
new Vue({
  // 定义局部指令(函数式简写)
  directives: {
    big(element, binding) {
      // 第一个形参:真实 DOM 元素;第二个形参:绑定元素对象
      element.innerText = binding.value * 10; // 原生 DOM 操作
    },
  },
});
</script>

组合式

<script setup> 中,任何以 v 开头的驼峰式命名的变量都可以被用作一个自定义指令。在上面的例子中,vFocus 即可以在模板中以 v-focus 的形式使用。

vue
<script setup>
// 在模板中启用 v-focus
const vFocus = {
  mounted: (el) => el.focus(),
};
</script>

<template>
  <input v-focus />
</template>

选项式

一个自定义指令由一个包含类似组件生命周期钩子的对象来定义。钩子函数会接收到指令所绑定元素作为其参数。下面是一个自定义指令的例子,当一个 input 元素被 Vue 插入到 DOM 中后,它会被自动聚焦:

在没有使用 <script setup> 的情况下,自定义指令需要通过 directives 选项注册:

js
// 自定义指令对象
const focus = {
  mounted: (el) => el.focus(),
};

export default {
  directives: {
    // 在模板中启用 v-focus
    focus,
  },
};

全局自定义指令

js
const app = createApp({});

// 使 v-focus 在所有组件中都可用
app.directive("focus", {
  /* ... */
});

指令钩子

js
const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode) {
    // 下面会介绍各个参数的细节
  },
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode) {},
  // 绑定元素的父组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都更新后调用
  updated(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode) {},
  // 绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode) {},
};

钩子参数

指令的钩子会传递以下几种参数:

  • el:指令绑定到的元素。这可以用于直接操作 DOM。
  • binding:一个对象,包含以下属性。
    • value:传递给指令的值。例如在  v-my-directive="1 + 1"  中,值是  2
    • oldValue:之前的值,仅在  beforeUpdate  和  updated  中可用。无论值是否更改,它都可用。
    • arg:传递给指令的参数 (如果有的话)。例如在  v-my-directive:foo  中,参数是  "foo"
    • modifiers:一个包含修饰符的对象 (如果有的话)。例如在  v-my-directive.foo.bar  中,修饰符对象是  { foo: true, bar: true }
    • instance:使用该指令的组件实例。
    • dir:指令的定义对象。
  • vnode:代表绑定元素的底层 VNode。
  • prevVnode:代表之前的渲染中指令所绑定元素的 VNode。仅在  beforeUpdate  和  updated  钩子中可用。

警告

不推荐在组件上使用自定义指令。当组件具有多个根节点时可能会出现预期外的行为。

插件

插件 (Plugins) 是一种能为 Vue 添加全局功能的工具代码。

js
import { createApp } from "vue";

const app = createApp({});

app.use(myPlugin, {
  /* 可选的选项 */
});

一个插件可以是一个拥有  install()  方法的对象,也可以直接是一个安装函数本身。安装函数会接收到安装它的应用实例和传递给  app.use()  的额外选项作为参数:

js
const myPlugin = {
  install(app, options) {
    // 配置此应用
  },
};

编写一个插件

js
// plugins/i18n.js
export default {
  install: (app, options) => {
    // 在这里编写插件代码
  },
};

我们希望有一个翻译函数,这个函数接收一个以  .  作为分隔符的  key  字符串,用来在用户提供的翻译字典中查找对应语言的文本。期望的使用方式如下:

js
<h1>{{ $translate('greetings.hello') }}</h1>

这个函数应当能够在任意模板中被全局调用。这一点可以通过在插件中将它添加到  app.config.globalProperties  上来实现:

js
// plugins/i18n.js
export default {
  install: (app, options) => {
    // 注入一个全局可用的 $translate() 方法
    app.config.globalProperties.$translate = (key) => {
      // 获取 `options` 对象的深层属性
      // 使用 `key` 作为索引
      return key.split(".").reduce((o, i) => {
        if (o) return o[i];
      }, options);
    };
  },
};

我们的  $translate  函数会接收一个例如  greetings.hello  的字符串,在用户提供的翻译字典中查找,并返回翻译得到的值。

用于查找的翻译字典对象则应当在插件被安装时作为  app.use()  的额外参数被传入:

js
import i18nPlugin from "./plugins/i18n";

app.use(i18nPlugin, {
  greetings: {
    hello: "Bonjour!",
  },
});

Transition 组件

<Transition> 仅支持单个元素或组件作为其插槽内容。如果内容是一个组件,这个组件必须仅有一个根元素。

vue
<template>
  <Transition name="slide-fade">
    <p v-if="show">hello</p>
  </Transition>
</template>
css
/*
  进入和离开动画可以使用不同
  持续时间和速度曲线。
*/
.slide-fade-enter-active {
  transition: all 0.3s ease-out;
}

.slide-fade-leave-active {
  transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
}

.slide-fade-enter-from,
.slide-fade-leave-to {
  transform: translateX(20px);
  opacity: 0;
}

<TransitionGroup>  是一个内置组件,用于对  v-for  列表中的元素或组件的插入、移除和顺序改变添加动画效果。

KeepAlive 组件

<KeepAlive> 是一个内置组件,它的功能是在多个组件间动态切换时缓存被移除的组件实例。

默认情况下,一个组件实例在被替换掉后会被销毁。这会导致它丢失其中所有已变化的状态——当这个组件再一次被显示时,会创建一个只带有初始状态的新实例。

当我们的确想要组件能在被“切走”的时候保留它们的状态。要解决这个问题,我们可以用 <KeepAlive> 内置组件将这些动态组件包装起来:

vue
<template>
  <!-- 非活跃的组件将会被缓存! -->
  <KeepAlive>
    <component :is="activeComponent" />
  </KeepAlive>
</template>

Teleport

<Teleport> 是一个内置组件,它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。

有时我们可能会遇到这样的场景:一个组件模板的一部分在逻辑上从属于该组件,但从整个应用视图的角度来看,它在 DOM 中应该被渲染在整个 Vue 应用外部的其他地方。

这类场景最常见的例子就是全屏的模态框。理想情况下,我们希望触发模态框的按钮和模态框本身是在同一个组件中,因为它们都与组件的开关状态有关。但这意味着该模态框将与按钮一起渲染在应用 DOM 结构里很深的地方。这会导致该模态框的 CSS 布局代码很难写。

vue
<script setup>
import { ref } from "vue";

const open = ref(false);
</script>

<template>
  <button @click="open = true">Open Modal</button>

  <Teleport to="body">
    <div v-if="open" class="modal">
      <p>Hello from the modal!</p>
      <button @click="open = false">Close</button>
    </div>
  </Teleport>
</template>

<Teleport> 接收一个 to prop 来指定传送的目标。to 的值可以是一个 CSS 选择器字符串,也可以是一个 DOM 元素对象。这段代码的作用就是告诉 Vue“把以下模板片段传送到 body 标签下”。

路由

如果你只需要一个简单的页面路由,而不想为此引入一整个路由库,你可以通过动态组件的方式,监听浏览器 hashchange 事件或使用 History API 来更新当前组件。

vue
<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>

状态管理

用响应式 API 做简单状态管理

如果你有一部分状态需要在多个组件实例间共享,你可以使用 reactive() 来创建一个响应式对象,并将它导入到多个组件中:

js
// store.js
import { reactive } from "vue";

export const store = reactive({
  count: 0,
  increment() {
    this.count++;
  },
});
vue
<!-- ComponentA.vue -->
<script setup>
import { store } from "./store.js";
</script>

<template @click="store.increment()">From A: {{ store.count }}</template>
vue
<!-- ComponentB.vue -->
<script setup>
import { store } from "./store.js";
</script>

<template>From B: {{ store.count }}</template>

服务端渲染 SSR

Vue.js 是一个用于构建客户端应用的框架。默认情况下,Vue 组件的职责是在浏览器中生成和操作 DOM。然而,Vue 也支持将组件在服务端直接渲染成 HTML 字符串,作为服务端响应返回给浏览器,最后在浏览器端将静态的 HTML“激活”(hydrate) 为能够交互的客户端应用。

一个由服务端渲染的 Vue.js 应用也可以被认为是“同构的”(Isomorphic) 或“通用的”(Universal),因为应用的大部分代码同时运行在服务端和客户端。

在服务器和客户端共享的通用代码 app.js

js
// app.js (在服务器和客户端之间共享)
// 该文件及其依赖项在服务器和客户端之间共享——我们称它们为通用代码
import { createSSRApp } from "vue";

export function createApp() {
  return createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`,
  });
}

客户端代码 client.js

js
// 客户端 client.js
// 在客户端入口导入通用代码,创建应用并执行挂载
import { createApp } from "./app.js";

createApp().mount("#app");

服务端代码 server.js

js
import express from "express";
import { renderToString } from "vue/server-renderer";
import { createApp } from "./app.js";

const server = express();

server.get("/", (req, res) => {
  const app = createApp();

  renderToString(app).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Example</title>
        <script type="importmap">
          {
            "imports": {
              "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
            }
          }
        </script>
        <script type="module" src="/client.js"></script> 加载客户端入口文件
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
    `);
  });
});

// 1. 使用中间件托管客户端文件
server.use(express.static("."));

server.listen(3000, () => {
  console.log("ready");
});

renderToString()  接收一个 Vue 应用实例作为参数,返回一个 Promise,当 Promise resolve 时得到应用渲染的 HTML。

然后我们可以把 Vue SSR 的代码移动到一个服务器请求处理函数里,它将应用的 HTML 片段包装为完整的页面 HTML。接下来的几步我们将会使用  express

客户端激活

如果你点击该按钮,你会发现数字并没有改变。这段 HTML 在客户端是完全静态的,因为我们没有在浏览器中加载 Vue。

为了使客户端的应用可交互,Vue 需要执行一个激活步骤。在激活过程中,Vue 会创建一个与服务端完全相同的应用实例,然后将每个组件与它应该控制的 DOM 节点相匹配,并添加 DOM 事件监听器。

为了在激活模式下挂载应用,我们应该使用  createSSRApp()  而不是  createApp()

跨请求状态污染

在 SSR 环境下,应用模块通常只在服务器启动时初始化一次。同一个应用模块会在多个服务器请求之间被复用,而我们的单例状态对象也一样。如果我们用单个用户特定的数据对共享的单例状态进行修改,那么这个状态可能会意外地泄露给另一个用户的请求。我们把这种情况称为跨请求状态污染

js
// app.js (在服务端和客户端间共享)
import { createSSRApp } from "vue";
import { createStore } from "./store.js";

// 每次请求时调用
export function createApp() {
  const app = createSSRApp(/* ... */);
  // 对每个请求都创建新的 store 实例
  const store = createStore(/* ... */);
  // 提供应用级别的 store
  app.provide("store", store);
  // 也为激活过程暴露出 store
  return { app, store };
}

推荐的解决方案是在每个请求中为整个应用创建一个全新的实例,包括 router 和全局 store。然后,我们使用应用层级的 provide 方法来提供共享状态,并将其注入到需要它的组件中,而不是直接在组件中将其导入

激活不匹配

如果预渲染的 HTML 的 DOM 结构不符合客户端应用的期望,就会出现激活不匹配。

深入响应式系统

https://cn.vuejs.org/guide/extras/reactivity-in-depth.html

渲染机制

Vue 是如何将一份模板转换为真实的 DOM 节点的,又是如何高效地更新这些节点的呢?我们接下来就将尝试通过深入研究 Vue 的内部渲染机制来解释这些问题。

虚拟 DOM

虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构“虚拟”地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。这个概念是由 React 率先开拓,随后被许多不同的框架采用,当然也包括 Vue。

js
const vnode = {
  type: "div",
  props: {
    id: "hello",
  },
  children: [
    /* 更多 vnode */
  ],
};

一个运行时渲染器将会遍历整个虚拟 DOM 树,并据此构建真实的 DOM 树。这个过程被称为挂载 (mount)。

如果我们有两份虚拟 DOM 树,渲染器将会有比较地遍历它们,找出它们之间的区别,并应用这其中的变化到真实的 DOM 上。这个过程被称为更新 (patch),又被称为“比对”(diffing) 或“协调”(reconciliation)。

render-pipeline.png