细读vue3文档,总有一些细节是你之前不知道的(二)
前言
书接上回,这次补充一点之前没有写完的知识点,这次没有很多点了,但是会重点记录vue中的effect、effectScope这2个方法以及相关知识点的延伸拓展。
列举
19 effect、effectScope是什么,和effectWatch有什么区别
其实effectScope和effect2个函数都是vue子包@vue/reactivity 的工具函数,一般不会在vue业务层直接使用,大多数业务场景在工具包中使用。
effectScope
先说effectScope是什么
官方描述:
创建一个 effect 作用域,可以捕获其中所创建的响应式副作用 (即计算属性和侦听器),这样捕获到的副作用可以一起处理。对于该 API 的使用细节,请查阅对应的 RFC
ts类型
function effectScope(detached?: boolean): EffectScope
interface EffectScope {
run<T>(fn: () => T): T | undefined // 如果作用域不活跃就为 undefined
stop(): void
}
可以看到这个effectScope返回的实例上有2个方法,一个是run,一个是stop,run方法传入一个副作用函数,而stop方法用于停止这个作用域响应式。
那么这个方法到底有什么用的?
在普通的vue组件中,我们会在setup中写一些钩子和响应式数据,这些数据以来被收集并绑定到当前实例,当实例被卸载时,依赖将自动释放,这个没有问题。然而,当我们在组件之外或作为独立包使用它们时,事情就没那么简单了,比如下面这个例子
const disposables = []
const counter = ref(0)
const doubled = computed(() => counter.value * 2)
disposables.push(() => stop(doubled.effect))
const stopWatch1 = watchEffect(() => {
console.log(`counter: ${counter.value}`)
})
disposables.push(stopWatch1)
const stopWatch2 = watch(doubled, () => {
console.log(doubled.value)
})
disposables.push(stopWatch2)
在这个文件中,我使用了三组响应式依赖,并把他们的停止函数收集在了一个数组之中,当我需要停止监听他们的时候,需要手动执行如下方法
disposables.forEach((f) => f())
disposables = []
这还只是简单例子,当我的业务逻辑复杂后,甚至出现作用域嵌套,也很容易忘记收集它们,此时使用这种方式就比较麻烦了,这个时候effectScope就派上用场了。
// effect, computed, watch, watchEffect created inside the scope will be collected
const scope = effectScope()
scope.run(() => {
const doubled = computed(() => counter.value * 2)
watch(doubled, () => console.log(doubled.value))
watchEffect(() => console.log('Count: ', doubled.value))
})
// to dispose all effects in the scope
scope.stop()
当我不需要这部分响应式数据的时候,直接执行scope中的stop方法就可以了
effectScope优势
当处理更复杂的组件逻辑时,effectScope
可以提供一些优势,尤其是在以下情况下:
- 作用域嵌套:在一个组件中,你可能有多个嵌套的作用域,每个作用域都有自己的响应式数据和副作用函数。使用
effectScope
可以确保每个作用域内部的副作用函数只会在对应的数据变化时触发,避免不必要的更新。 - 性能优化:当你有多个副作用函数,但只想在特定数据变化时触发其中一部分,
effectScope
可以帮助你避免在无关数据变化时的重复运行副作用函数,从而提高性能。 - 可维护性:在复杂的组件中,很容易出现副作用函数的混乱和重复。使用
effectScope
可以将相关的副作用函数组织到一个作用域中,使代码更易于维护和理解。 - 动态加载和销毁:在一些场景下,你可能需要在动态加载或销毁组件时管理副作用。
effectScope
可以帮助你在正确的时机创建和销毁副作用作用域,以防止内存泄漏和不必要的触发。 - 测试和调试:将副作用函数限定在特定的作用域中,可以使测试和调试变得更加可控。你可以通过创建不同的作用域来模拟不同的数据变化情况,以便更容易进行单元测试和调试。
综上所述,effectScope
在处理复杂的组件逻辑时可以提供更好的代码组织、性能优化和可维护性。然而,对于简单的组件,使用 effectScope
可能会增加代码的复杂性,因此在选择是否使用时需要根据实际情况权衡利弊。
作用域嵌套时的场景
const counter = ref(0);
function handle() {
counter.value++;
}
let nestedScope;
const parentScope = effectScope();
parentScope.run(() => {
const doubled = computed(() => counter.value * 2);
// with the detected flag,
// the scope will not be collected and disposed by the outer scope
nestedScope = effectScope(true /* detached */);
nestedScope.run(() => {
watchEffect(() => console.log("nestedScope", counter.value));
});
watchEffect(() => console.log("Count: ", doubled.value));
});
// disposes all effects, but not `nestedScope`
parentScope.stop();
// stop the nested scope only when appropriate
// nestedScope.stop();
正常情况下嵌套作用域也应该由其父作用域收集。当父作用域被释放时,其所有后代作用域也将被停止。但是最开始的ts类型中可以看到effectScope方法是可以传入一个布尔值detached参数的,这个参数的作用是:接受要在“分离”模式下创建的参数,分离的作用域不会被其父作用域收集。
比如上述案例中,nestedScope的作用域不会被他的父级所收集,所以当父级销毁的时候,nestedScope作用域仍然生效。
全局状态共享,模拟pinia
在vue3使用hooks的时候,因为很多状态是存储在hook函数内部,所以每次执行hook函数时拿到的都是一个独立的,和之前没有联系的值,那么如何实现这个值全局共享呢?
有一种做法是在这个hook函数的外部声明变量,然后导出,在不同的文件中使用,这样就可以实现全局状态了。但是如果导出了很多个,一些情况要销毁或者重置状态,就会很麻烦。所以这里就可以使用effectScope,它可以创建一个effect作用域,可以在这个作用域中创建响应式的数据,当作用域销毁时,响应式数据也会销毁。这样就可以实现全局状态了。
// useGlobalState
import { effectScope } from "vue";
export default (run: any) => {
let isChange = false;
let state: any;
const scope = effectScope(true);
return () => {
// 防止重复触发
if (!isChange) {
state = scope.run(run);
isChange = true;
}
return state;
};
};
// store.js
import { computed, ref } from "vue";
import useGlobalState from "./useGlobalState";
export default useGlobalState(() => {
// state
const count = ref(0);
// getters
const doubleCount = computed(() => count.value * 2);
// actions
function increment() {
count.value++;
}
return { count, doubleCount, increment };
});
// A.vue
import useStore from "@/hooks/useStore";
const { count, doubleCount, increment } = useStore();
// B.vue
import useStore from "@/hooks/useStore";
const { count, doubleCount, increment } = useStore();
此时在不同的组件中,拿到的数据都是同一份。
effect
看到effect可能会觉的陌生,但是看到watchEffect就很熟悉了,
watchEffect 并不是对 effect 的一层简单应用封装,它们虽然在某些方面相似,但在实现和用法上有一些区别,或者说可以将 watchEffect 视为对 effect 的一种高级应用
watchEffect 是专门为自动追踪响应式数据变化而设计的 API,你不需要手动指定依赖,副作用函数会自动追踪内部使用的响应式数据,并在数据变化时触发重新执行。
effect 是一个通用的 API,也可以自动追踪响应式数据的变化,但它还可以手动指定依赖,以及通过参数控制副作用函数的执行时机。
// 2者效果一样,都能被执行
watchEffect(() => {
console.log("watchEffect",num.value);
})
effect(() => {
console.log("watchEffect", num.value);
});
从源码角度去看
effect 创建了一个副作用函数,ReactiveEffect,在这个函数中对一个全局变量activeEffect进行了赋值,在track 依赖收集的时候,会需要使用到这个全局变量。或者说用effetc包裹下,生成了一个环境,只有在这个环境中才能被依赖收集。
全局变量activeEffect的作用是什么
建立数据与副作用函数之间的关联: 在副作用函数内部访问响应式数据时,会触发数据的依赖追踪,将数据与当前的 activeEffect 关联起来。(塞带depsMap中的dep里去)这样,当数据发生变化时,就可以通过 activeEffect 来找到需要重新运行的副作用函数。
effect
源码分析:
effect
函数用于创建具有响应性的副作用函数,它接受一个副作用函数fn
和一些配置选项options
。- 在
effect
内部,首先判断传入的fn
是否已经是一个具有effect
属性的函数,如果是,就取出其原始的副作用函数部分。 - 创建一个
ReactiveEffect
实例_effect
,该实例管理着副作用函数的运行、停止以及依赖追踪等。 - 根据传入的配置选项,可能会将一些配置信息合并到
_effect
实例中。 - 如果
options
不是懒执行的,就立即执行_effect
实例的run
方法,即运行副作用函数。 - 创建一个
runner
函数,它实际上是_effect.run
方法的别名,可以被外部调用,用于手动触发副作用函数的运行。 - 将
_effect
实例赋值给runner
的effect
属性,并返回runner
函数。
- watchEffect源码中实际在调用dowatch函数
doWatch
源码分析:
doWatch
函数用于创建一个响应式的观察者。它接受一个source
、一个回调函数cb
,以及一些配置选项options
。- 首先,对传入的
source
进行类型判断,以确定它的类型,并根据类型创建不同的获取数据的getter
函数。 - 如果传入的
source
是一个函数,且提供了回调函数cb
,则将getter
函数替换为调用源函数并捕获错误的函数。 - 创建一个
ReactiveEffect
实例effect
,用于追踪副作用函数的依赖,并提供了onTrack
和onTrigger
钩子。 - 在不同情况下,
getter
可能会进行进一步的处理,如深度观察、数组变异等。 - 根据传入的配置选项,将
effect
实例的run
方法与不同的调度器函数关联,用于控制副作用函数的执行时机。 - 调用
effect.run
方法来运行副作用函数,如果提供了回调函数cb
,则在运行后根据数据变化情况执行回调。 - 返回一个函数
unwatch
,用于停止观察器的运行,即停止对数据的追踪。
export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions
): ReactiveEffectRunner
export interface ReactiveEffectOptions extends DebuggerOptions {
lazy?: boolean
scheduler?: EffectScheduler
scope?: EffectScope
allowRecurse?: boolean
onStop?: () => void
}
interface WatchEffectOptions {
flush?: 'pre' | 'post' | 'sync' // 默认:'pre'
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
}
从watchEffect的类型就可以看出,这个函数在应用层做了一些其他的扩展功能,比如增加了一些调试钩子
@vue/reactivity
既然前面提到了effectScope和effece是@vue/reactivity中的2个工具函数,是为一些工具包服务的,不太在业务中使用,那有什么实际使用的案例吗 我司之前有一些3D项目,有一个工具包,里面都是threejs和webgl的一些封装方法,在应用层页面应用的时候需要调用到了,为了数据传递的时候高效,工具包中利用@vue/reactivity实现了一套模型数据响应式数据处理的实现。例如从工具包中取到的模型位置,材质反射亮度等数据,都是响应数据,可以直接v-model绑定到页面中,实现便捷调整。
这里是另外一个建议demo,实现浏览器localStorage的响应式,花样可以做很多,主要是利用vue响应式的原理思路,你可以实现任意你想实现的响应式功能。
const useEffect = () => {
const data = reactive({
version: "v1.0.0",
});
const scope = effectScope();
const fn = () => {
const localVersion = localStorage.getItem("version_xx");
if (data.version !== localVersion) {
localStorage.setItem("version_xx", data.version);
}
};
scope.run(() => {
effect(fn);
});
function changeVersion() {
data.version = Math.random().toString(36).substr(2);
}
function scopeDestroy() {
scope.stop();
}
return {
changeVersion,
version: computed(() => data.version),
scopeDestroy,
};
};
20 setup中异步声明watch的问题
这个问题是我最近写业务的时候发现的,场景是这样的:我在一个hook中写了watch方法,但是我在组件中使用这个hook的时候用了异步方法,在nextTick中去使用了hook方法,此时我发现当当前组件实例被销毁的时候,watch监听不会被销毁,所以当我重新生成组件的时候,watch监听又监听了一遍,导致监听的回调会重复触发,例如我打开关闭这个组件4次,那么此时我的回调就会执行4次。
后来我发现这个问题在vue文档中写明了,我之前读文档的时候还是没有仔细看,疏忽掉了😂,但这是很重要的一点。
(文档原文)是这样写的:
在 `setup()` 或 `<script setup>` 中用同步语句创建的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,你无需关心怎么停止一个侦听器。
一个关键点是,侦听器必须用**同步**语句创建:如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏。如下方这个例子:
```
<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})
// ...这个则不会!
setTimeout(() => {
watchEffect(() => {})
}, 100)
</script>
```
注意,需要异步创建侦听器的情况很少,请尽可能选择同步创建。如果需要等待一些异步数据,你可以使用条件式的侦听逻辑
```
// 需要异步请求得到的数据
const data = ref(null)
watchEffect(() => {
if (data.value) {
// 数据加载后执行某些操作...
}
})
```
所有说,官方是不推荐异步创建侦听器的,那如果我硬要异步创建,有没有办法解决? 肯定是有的,一种就是用到我们上面所讲的effectScope,另一种就是抛出unwatch方法,其实两种思路是一样的,都是手动去取消监听,在组件卸载的时候去执行取消监听的方法 ,用effectScope的话就是抛出scope的close方法。
最后说一下原因,为什么异步创建侦听器的时候会产生这样的原因,个人的理解是生命周期调用栈的同步性问题,因为我们是些在setup的生命周期里面的,Vue 会自动将回调函数注册到当前正被初始化的组件实例上,这意味着这些钩子应当在组件初始化时被同步注册,而这个侦听器是异步注册的时候,当前的实例组件已经不是代码写的位置的那个组件了,所以这个响应式数据依赖项无法被组件实例给收集进去,没有被添加到deps中去,所以当组件被销毁的时候,也无法销毁这个依赖。(个人理解,有错欢迎指正)
21 使用key实现强制替换一个元素/组件而不是复用它
key
这个特殊的 attribute 主要作为 Vue 的虚拟 DOM 算法提示,在比较新旧节点列表时用于识别 vnode
在没有 key 的情况下,Vue 将使用一种最小化元素移动的算法,并尽可能地就地更新/复用相同类型的元素。如果传了 key,则将根据 key 的变化顺序来重新排列元素,并且将始终移除/销毁 key 已经不存在的元素。
上面加粗的这点是很重要的一点,因为我们可以利用这个规则做一些事情,用于强制替换一个元素/组件而不是复用它
比如:
- 在适当的时候触发组件的生命周期钩子
- 触发过渡
<transition>
<span :key="text">{{ text }}</span>
<Ab :key="text"></Ab>
</transition>
当 text
变化时,<span>
总是会被替换而不是更新,因此 transition 将会被触发。
而组件Ab,他的生命周期就会被重复触发
从原理上来讲,加key和不加key对于结果没有影响,只是在数据量大的列表等情况如果有key可以减少diff比对的时间,和性能开销。因为如果没有key,需要进入子节点,再深入比较里面的节点以至到文本节点,而如果有key,可以最大程度复用节点,进行dom层面位置的移动,而不需要销毁创建新的node
22 v-bind style
单文件组件的 <style>
标签支持使用 v-bind
CSS 函数将 CSS 的值链接到动态的组件状态:
<script setup>
const theme = {
color: 'red'
}
</script>
<template>
<p>hello</p>
</template>
<style scoped>
p {
color: v-bind('theme.color');
}
</style>
实际的值会被编译成哈希化的 CSS 自定义属性,因此 CSS 本身仍然是静态的。自定义属性会通过内联样式的方式应用到组件的根元素上,并且在源值变更的时候响应式地更新。
比如上述代码,实际被转化的样子在控制台可以看到
p {
color: var(--2be5509c-theme\.color);
}
style属性 {
--2be5509c-theme\.color: #b7b0d2;
}
当响应式变量color更高的时候, ‘--2be5509c-theme.color’这个css自定义属性的值也会相应的变更
在js层面可以通过这个方法去变更css变量的值
document.body.style.setProperty('--main-color', 'green');
结尾
这个系列内容结束了,其实这个内容对于刚学习vue3的新人不是那么友好的,因为我是站着主观视角去记录的,有些我认为很基础的常识性的东西都没有写,记录的都是很细节性的东西,甚至有点“偏”,但是有时候确实是能派上用处的,希望能有所帮助,说的不对的,也欢迎指正🙏
转载自:https://juejin.cn/post/7269022955470979083