Vue3 学习 04——全局状态,属性透传,插槽,生命周期

这里学习 Vue 关于全局状态的实现——使用两种方式——直接定义全局的 ref、reactive 对象;使用依赖注入(虽然它不是典型的全局状态啦)。然后以及学习插槽以学习更多父子组件的相关东西。学完这个后再学习路由,然后就可以开始做实战了。

即使只是一个多星期没接触,感觉就很多地方有点忘了……Vue 的缺点就在这里,概念太多,如果是 React 就没那么容易忘。

全局状态

全局 ref、reactive

Vue 全局状态的实现比 React 简单很多——React 还需要用 Provider 啊 Consumer 一堆玩意儿,还需要在组件树里体现出来,Vue 因为其设计,则更简单——我直接在一个单独的 js 文件里定义 ref 和 reactive 变量,然后大家都来引用它就行了

1
2
3
// GlobalState.ts
import { ref } from "vue";
export const globalCounter = ref(0)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- App.vue -->
<script setup lang="ts">
import Counter from './Counter.vue';
</script>
<template>
<Counter />
</template>

<!-- Counter.vue -->
<script setup lang="ts">
import { globalCounter } from './GlobalState';
</script>
<template>
<div>
<input type="number" v-model="globalCounter" />
</div>
</template>

这样操作缺点是显然的——没法跟踪谁访问、谁更改了我的对象,debug 会很困难。但是也有方式——像 react 的 reducer 一样,暴露只读对象出来,然后通过方法去设置值,这样便能更好地跟踪。实际上 pinia 就是这个思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { readonly, ref } from "vue";

const globalCounter = ref(0)

export default {
// 返回一个只读的 Ref
counter: readonly(globalCounter),
inc() {
globalCounter.value++
},
desc() {
globalCounter.value--
}
}

当然,这里也可以不定义成真正的全局,而是和下面的依赖注入结合,把响应式状态和子孙节点共享。

依赖注入

依赖注入严格来说不是为了全局状态,而是为了让特定属性能直接透传到子孙节点而不需要显式给定。

依赖注入使用一个字符串或数字或 symbol 去唯一标识,最佳实践是使用 symbol 去标识依赖,这样就能够避免任何可能的重名问题(倘若重名,子节点的 provide 的会覆盖父节点 provide 的)。使用 symbol 实际上也允许更好地类型推导

依赖注入不会自动地解包 ref,也不会操作 reactive,所以可以把响应式状态作为依赖。

依赖注入使用 provide,inject 方法,父节点调用 provide,子节点调用 inject。同时,Vue 官方文档建议在同一个文件中导出依赖的 Key,下面是一个例子。

1
2
3
4
5
6
7
// keys.ts
import type { InjectionKey, Reactive } from "vue";

export const GlobalCounter = Symbol() as InjectionKey<Reactive<{
counterA: number,
counterB: number,
}>>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!-- App.vue -->
<script setup lang="ts">
import { provide, reactive } from 'vue';
import Counter from './Counter.vue';
import { GlobalCounter } from './keys';

const someCounter = reactive({
counterA: 0,
counterB: 0,
})
provide(GlobalCounter, someCounter)

</script>
<template>
<Counter />
</template>

<!-- Counter.vue -->
<script setup lang="ts">
import { inject } from 'vue';
import { GlobalCounter } from './keys';

// 这里必须给默认值,否则可以是 undefined,这里图简单直接!
const res = inject(GlobalCounter)!
</script>
<template>
<input type="number" v-model="res.counterA" />
<input type="number" v-model="res.counterB" />
</template>

此外,可以在整个应用层提供依赖,即createApp的返回值,它能够调用 provide,供整个应用使用,这也是一种全局状态了。

这里本来想去学学 pinia 的,但是使用 pinia 并不能带给我什么更多的东西…我个人的小项目没有那种复杂程度。所以就这样了。

attribute 透传

https://cn.vuejs.org/guide/components/attrs.html#fallthrough-attributes

这个倒不是概念,是机制,定义组件时可能需要注意。如果传递给组件的 attribute 没有被组件声明为 props 或 emits,则 Vue 会自动将该 attribute 往下传递,这个称为透传,最常见的透传就是 class,id 和 style。实际上对于这三个属性,Vue 会自动地进行合并。

此外,对于 v-on,Vue 也会自动合并,即先调用子节点的监听器,再调用父节点的,这个并不是走的 html 的冒泡逻辑。

如果一个组件以单个元素为根元素,则透传的 attribute 会自动添加到根元素上。如果一个组件顶层有多个根节点,则没有这种自动添加的机制,此时需要手动添加,方法是在子节点的特定元素的模板中写v-bind="$attrs"

1
2
3
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

只有一个元素为根元素,但想要禁止透传(比如我要以其它元素去接受),可以:

1
2
3
defineOptions({
inheritAttrs: false
}) // 3.3 以上的 Vue 才能这样,否则得另开一个 script 去写选项式然后期待 Vue 去合并

最后,在模板中使用透传的属性时,使用$attrs['foo-bar']的形式,注意这里使用的是 html 中使用的命名方式(中划线分割),而非 js 中的小驼峰

在 setup 中想要访问透传 Attribute,则使用useAttrs这个方法返回的值是非响应式的

插槽

组件如何接受模板内容?React 中这是简单的,直接取 children 字段,而 Vue 使用 slot 标签表示子节点要插入到的地方

children 是插槽内容,而 slot 是插槽出口。下面是一个例子,注意<slot>Nothing is given</slot>,这里的Nothing is given是默认值,如果父组件没有给插槽任何内容,它将渲染这个默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- App.vue -->
<script setup lang="ts">
import DecorateMe from './DecorateMe.vue';

</script>
<template>
<DecorateMe>
<p>hello</p>
<p>world</p>
</DecorateMe>
</template>

<!-- DecorateMe.vue -->
<template>
<p>start</p>
<slot><p>Nothing is given</p></slot>
<p>end</p>
</template>

条件插槽,具名插槽

此外,插槽是可以塞到 if 中的,在 html 中通过$slots去访问插槽(注意这里的 s,证明可以有多个插槽),其中$slots.default访问默认插槽,具名插槽后面提。

上面的DecorateMe.vue因此也可以这么实现:

1
2
<slot v-if="$slots.default"></slot>
<p v-else>Nothing is Given</p>

条件插槽的一个用法是需要给插槽内的内容加一个 wrapper 来添加样式,但是在没有插槽的时候不想添加样式:

1
2
3
<div v-if="$slots.default" class="someStyle">
<slot />
</div>

如何有多个插槽呢?使用具名插槽,即在 slot 中标注name属性,这时候,父组件在插槽内容中通过<template v-slot:someSlotName>去插值到特定插槽;此时插槽内容的顶层的内容会插值到默认插槽v-slot简写作#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- Article.vue -->
<template>
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer" />
</footer>
</template>

<!-- App.vue -->
<Article>
<template #header>
header content
</template>
main content
<template v-slot:footer>
footer content
</template>
</Article>

在某种程度上,可以认为具名插槽就是那种需要在属性中传递组件实例的需求的实现,在 React 中这个操作是很容易的——创建组件实例和传递组件实例都很方便,而在 vue 中这个不太显然。

作用域插槽

插槽内容是父组件定义的,所以父组件可以往里面塞任何东西,而插槽内容无法访问子组件的内容,这是显然的。但是,子组件能够给插槽提供内容让父组件在插槽中使用。这就能做到一件事情——父组件提供模板,利用子组件提供的数据去渲染,这样,就给父组件足够的灵活性去进行渲染了,同时父组件不需要维护这些状态(这好也不好)

作用域插槽的写法也容易——子组件在 slot 中把要传递的属性作为 props 传递,然后:

  1. 如果子组件没有使用具名插槽(即只有默认插槽),在子组件上写v-slot="slotProps",这时候会在插槽内容作用域中绑定 slotProps 对象代表子组件传入的属性
  2. 如果子组件使用了具名插槽,此时不能在子组件顶层写v-slot,而是在每个 template 中写v-slot:XXX="slotProps",这时候可以使用那个简写#XXX="slotProps"

这里实际上有个缺憾——不能同时给所有插槽传同样的内容,每个插槽要各传各的,比较蛋疼。

子组件写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<header>
<slot name="header" :title="title"/>
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer" :content="content" />
</footer>
</template>

<script lang="ts">
import { ref } from 'vue';

const title = ref('')
const content = ref('')
</script>

父组件写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 单插槽时 -->
<SomeComponent v-slot="slotProps"> <!-- 其实可以解构 -->
some prop: {{ slotProps.propA }}
</SomeComponent>

<!-- 多插槽时 -->
<SomeComponent>
<template #header="{title}">
title: {{ title }}
</template>
<template #default="{content}">
content: {{ content }}
</template>
</SomeComponent>

下面同样是一个例子,假设子组件是一个不受管组件,它代表一篇文章内容,父组件只管传入文章的 ID,子组件自己负责文章的获取和渲染,因此子组件持有文章内容。这样是完全背离了前端的设计方式,但做了几天游戏后我反而觉得这样其实也挺好的…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!-- Article.vue -->
<template>
<header>
<slot name="header" :title="title"/>
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer" :content="content" />
</footer>
</template>

<script lang="ts" setup>
import { ref } from 'vue';

const props = defineProps<{
articleId: string
}>()

const title = ref('')
const content = ref('')
</script>

<!-- App.vue -->
<script setup lang="ts">
import Article from './Article.vue';

</script>
<template>
<Article article-id="123123">
<template #header="{title}">
title: {{ title }}
</template>
<template #default="content">
content: {{ content }}
</template>
</Article>
</template>

如果上面这个例子不够显然,下面是一个更显然但没啥意义的例子——它就像游戏开发中使用的方式一样——利用组合去引入功能:定义一个 MouseTracker,把当前的鼠标位置通过 slot 返回出来供父组件使用:

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

这种操作会让人想到 React 的高阶组件。这种组件称作无渲染组件。但同样的功能完全没必要这么操作,直接用组合式 API 就行了,这个会引入一层嵌套。

生命周期

观察这张图,知道:mounted,beforeUpdate,updated,beforeUnmount,在这些生命周期时,组件在文档树中,能够访问自己的子组件,这里只学常用的。后面看看要不要学更细节的。

onMounted

组件挂载完成后执行,此时所有同步子组件已挂载,DOM 也已创建和插入到父容器。这一阶段通常要对 DOM 树进行副作用,比如用一个 div 去创建地图、图表等、或者拉取起始数据,设置一些状态等。

onUpdated

每次因为响应式状态变更而更新 DOM 之后调用,其中父组件在子组件更新之后调用。所以它能够访问更新后的当前组件和子组件。

onBeforeUpdate

在 Vue 更新 DOM 之前执行,可以访问 DOM 的状态,也可以安全地变更状态

onUnmounted

所有子组件卸载后,以及所有响应式作用停止后执行。所以卸载是先卸载子组件再卸载父组件。

onBeforeUnmount

在被卸载之前调用,这时候组件实例仍旧可用。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!