本文是系列文章的一部分:框架现场指南 - 基础知识
虽然您可以使用 React、Angular 和 Vue 构建静态网站,但这些框架在构建交互式应用程序时最为出色。
这些应用程序具有多种形状、形式和功能,但都有一个共同点:它们从用户那里获取实时输入并将信息显示给用户。
这是静态网站和交互式网站之间的主要区别;静态网站显示其内容,然后允许用户浏览网站,而不会对显示的内容进行重大更改;同时,交互式网站会根据用户输入大幅改变其显示的信息。
这种差异也体现在应用程序的构建方式上。静态网站可能会通过服务器端渲染 (SSR) 或静态网站生成 (SSG)预先加载 HTML 编译,从而优先处理初始加载。而交互式应用则更专注于处理传递给它的信息,以定制您的体验。
由于交互式应用程序严重依赖于基于用户输入的信息处理,因此 React、Angular 和 Vue 都提供了内置的交互、拦截和以其他方式获取此类信息的方式。
虽然这些框架处理输入的方式略有不同,但其基本概念是相同的:所有用户输入和输出都会产生需要处理的“副作用”。
这引发的问题比它回答的还多:
- 什么是副作用?
- 这些框架如何处理副作用?
- 浏览器如何通知框架副作用?
- 您如何确保消除副作用?
- 您如何处理组件内的副作用?
让我们逐一回答这些问题,首先是:
什么是副作用?
副作用是指一段代码改变或依赖于其本地环境之外的状态。如果一段代码不包含副作用,则被认为是“纯粹的”。
例如,假设我们有以下代码:
function pureFn() { let data = 0; data++; return data;}
这种逻辑被认为是“纯粹的”,因为它不依赖于外部数据源。但是,如果我们将data变量移出本地环境并在其他地方进行变异:
let data;function increment() { data++;}function setupData() { data = 0; increment(); return data;}
increment将被视为改变其自身环境之外的变量的“副作用”。
这在生产应用中何时发挥作用?
这个问题问得真好!浏览器中的window和documentAPI 就是一个很好的例子。
假设我们想要存储在应用程序多个部分使用的全局计数器;我们可能会将其存储在window。
window.shoppingCartItems = 0;function addToShoppingCart() { window.shoppingCartItems++;}addToShoppingCart();addToShoppingCart();addToShoppingCart(); // window.shoppingCartItems is now `3`
由于window是一个全局变量,因此在函数内部改变其中的值是一种“副作用”,因为该window变量未在function的范围内声明。
注意我们的addToShoppingCart方法没有返回任何东西;相反,它window通过副作用修改变量来更新全局值。如果我们尝试在addToShoppingCart不引入新变量的情况下消除副作用,我们会得到以下结果:
window.shoppingCartItems = 0;function addToShoppingCart() { // Nothing is happening here. // No side effects? Yay. // No functionality? Boo.}addToShoppingCart();addToShoppingCart();addToShoppingCart(); // window.shoppingCartItems is still `0`
注意addToShoppingCart现在什么都不做。为了消除副作用,同时仍然保留增加值的功能,我们必须同时做到以下两点:
- 传递输入
- 返回值
经过这些改变,它可能看起来像这样:
function addToShoppingCart(val) { return val + 1;}let shoppingCartItems = 0;shoppingCartItems = addToShoppingCart(shoppingCartItems);shoppingCartItems = addToShoppingCart(shoppingCartItems);shoppingCartItems = addToShoppingCart(shoppingCartItems);// shoppingCartItems is now `3`
由于副作用的固有性质,这表明所有不返回新值的函数要么不执行任何操作,要么在其中产生副作用。
此外,由于应用程序的输入和输出(合起来通常称为“ I/O”)来自用户而非函数本身,因此所有 I/O 操作都被视为“副作用” 。这意味着除了无返回值的函数外,以下所有内容都被视为“副作用”:
- 用户正在输入某些内容
- 用户点击某个东西
- 保存文件
- 加载文件
- 发出网络请求
- 使用打印机打印内容
- 将值记录到
console
框架如何处理副作用?
如前所述,副作用对于 React、Angular 和 Vue 等专门构建的应用程序而言至关重要。为了简化副作用处理,每个框架都有自己的方法,用于在组件中发生特定事件时代表开发人员运行代码。
框架可能监听的组件事件包括:
- 用户输入事件
- 组件首次渲染
- 作为条件渲染的一部分,组件从屏幕上被移除
- 组件的数据或传递的属性发生变化
这些组件事件中的第一个通常对大多数开发人员来说是完全透明的:用户输入绑定。
例如:
<!-- Comp.vue --><script setup>const sayHi = () => alert("Hi!");</script><template> <button @click="sayHi()">Say hello</button></template>
该组件处理一个click事件(即用户输入——副作用)并向alert用户输出一个结果(输出,另一个副作用)。
看到了吗?使用这些框架时,事件通常对用户隐藏。
让我们回顾一下最常见的四种组件副作用起源点类型:
- 用户输入事件
- 组件首次渲染
- 作为条件渲染的一部分,组件从屏幕上被移除
- 组件的数据或传递的属性发生变化
虽然第一个问题很容易解决,但从开发人员经验的角度来看,最后三个组件事件通常更难解决。
通常,框架会实现一个与开发者定义的函数(由框架运行)对应的 API,并将其与组件生命周期内发生的事件一一对应。当框架将函数与生命周期事件进行一对一映射时,这种映射会创建一系列称为“生命周期方法”**的 API。Angular 和 Vue 都将生命周期方法作为其核心 API 的一部分。
另一方面,一些框架选择不使用生命周期方法来实现副作用处理。React 就是一个典型的例子,但 Vue 也提供了一种非生命周期方法来管理组件中的副作用。
为了探索这些副作用处理程序可以做什么,让我们看一个在组件初始渲染期间运行的处理程序的示例。
初始渲染副作用
在介绍组件时,我们提到了“渲染”的概念。渲染发生在组件被绘制到屏幕上时,无论是用户首次加载页面时,还是使用条件渲染显示或隐藏时。
假设我们有以下代码:
<!-- Child.vue --><template> <p>I am the child</p></template>
<!-- Parent.vue --><script setup>import { ref } from "vue";import Child from "./Child.vue";const showChild = ref(true);function setShowChild() { showChild.value = !showChild.value;}</script><template> <div> <button @click="setShowChild()">Toggle Child</button> <Child v-if="showChild" /> </div></template>
这里,Child每次点击时,都会从 DOM 中添加和移除setShowChild。假设我们想添加一种方法,console.log每次Child在屏幕上显示 时调用 。
请记住,会将数据
console.log输出给用户(尽管是在 DevTools 面板中)。因此,从技术上讲,调用 是一种副作用console.log。
虽然我们可以将此日志添加到 内部setShowChild,但当我们不可避免地重构组件代码时,它更容易崩溃Parent。因此,我们可以添加一个副作用处理程序,在渲染console.log时调用它。Child
Angular 严格使用生命周期方法,而 React 使用useEffect钩子来处理副作用,而 Vue 则两者兼而有之。
我们先来看看 Vue 处理副作用的生命周期方法。
Vue 的onMounted生命周期方法
<!-- Child.vue --><script setup>import { onMounted } from "vue";onMounted(() => { console.log("I am initialized");});</script><template> <p>I am the child</p></template>
这里,我们onMounted从vueimport 中导入了生命周期处理程序。Vue 的生命周期方法在组件on内部使用时都以前缀开头<script setup>。
Vue 的watchEffectHook
就像 React 有一个非生命周期的运行副作用方法一样,Vue 也一样。这是通过 Vue 的 API 实现的watchEffect。watch让我们从一个简单的例子开始:
<!-- Child.vue --><script setup>import { watchEffect } from "vue";watchEffect(() => { console.log("I am initialized");});</script><template> <p>I am the child</p></template>
在这里,我们使用在组件渲染后立即watchEffect运行。console.log``Child
相反,watchEffect它通常与响应式值(例如变量)一起使用ref。我们将在本章后面讨论它的工作原理。
如前所述,当内部事件发生时,框架本身会代表您调用这些方法;在本例中,即Child渲染时。
尝试反复单击切换按钮,您会看到console.log每次Child组件再次呈现时都会发生这种情况。
在生产中使用副作用
除了提供一个可以改变以存储值的全局变量之外,还window可以document公开许多在应用程序中有用的 API。
假设在我们的组件内部,我们想要显示窗口大小:
<!-- WindowSize.vue --><script setup>const height = window.innerHeight;const width = window.innerWidth;</script><template> <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
这可以在初始渲染时显示窗口大小,但是当用户调整浏览器大小时会发生什么?
因为我们没有监听窗口大小的变化,所以我们永远无法获得具有新屏幕尺寸的更新渲染!
让我们通过处理事件来解决这个问题window.addEventListener——resize当用户改变窗口大小时发出。
<!-- WindowSize.vue --><script setup>import { ref, onMounted } from "vue";const height = ref(window.innerHeight);const width = ref(window.innerWidth);function resizeHandler() { height.value = window.innerHeight; width.value = window.innerWidth;}onMounted(() => { // This code will cause a memory leak, more on that soon window.addEventListener("resize", resizeHandler);});</script><template> <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
现在,当我们调整浏览器大小时,屏幕上的值也应该更新。
抛开事件冒泡
在我们对组件的介绍中,我们演示了组件可以监听 HTML 事件。
如果我们改变上面的代码来监听事件resize以避开这种情况会怎样addEventListener?
<!-- WindowSize.vue --><script setup>import { ref } from "vue";const height = ref(window.innerHeight);const width = ref(window.innerWidth);function resizeHandler() { height.value = window.innerHeight; width.value = window.innerWidth;}</script><template> <!-- This code doesn't work, we'll explain why soon --> <div @resize="resizeHandler()"> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
如果我们运行此代码,它将按初始屏幕尺寸按预期渲染,但后续重新渲染将不会更新屏幕上的值。这是因为该resize事件仅在window对象(与标签关联<html>)上触发,而不会向下渗透到其他元素。
可以看到,默认情况下,事件总是会从其触发位置在 DOM 树中向上“冒泡”。因此,如果我们点击一个div,click事件将从 开始div,一直冒泡到html标签。
我们可以在我们的框架内证明这一点。
<div @click="logMessage()"> <p> <span style="color: red">Click me</span> or even <span style="background: green; color: white;">me</span>! </p></div>
如果点击span,click事件将从 开始span,向上冒泡到p标签,最后向上冒泡到div。因为我们在 上添加了事件监听器,所以即使点击div,它也会运行。logMessage``span
这就是为什么我们不简单地使用事件绑定来处理resize事件:它只能直接从html节点发出。由于这种行为,如果我们想在组件resize内部访问事件WindowSize,我们需要使用addEventListener。
您可以从 Mozilla 开发者网络了解有关事件冒泡的更多信息、它的工作原理以及如何在特定实例中覆盖它。
清除副作用
我们先暂时放下代码,用一个比喻来谈谈副作用。
假设您正在电视上观看电视节目,该电视没有快退或快进的功能,但有暂停的功能。
这听起来可能很奇怪,但请听我说完。
正当演出进行到最高潮时,烟雾报警器突然响了。
“哦不!”你的爆米花在微波炉里烧焦了。
您有两个选择:
- 暂停节目,然后停止微波炉。
- 不要暂停节目;立即关闭微波炉。
虽然第二种选择可能是最自然的反应,但你会发现自己遇到了一个问题:你刚刚错过了节目中的重大公告,而当你回到电视机前时,你仍然感到困惑。
由于您的电视缺乏倒带功能,您将停留在原来的位置而无法重新开始观看该剧集。
然而,如果你暂停了节目,你就可以关掉微波炉后取消暂停并看到重大揭秘。
当然,这个比喻与前端开发关系不大,对吧?
啊,但确实如此!
瞧,把电视想象成你应用中一个有副作用的组件。我们以这个时钟组件为例:
<!-- Clock.vue --><script setup>import { ref, onMounted } from "vue";const time = ref(formatDate(new Date()));onMounted(() => { setInterval(() => { console.log("I am updating the time"); time.value = formatDate(new Date()); }, 1000);});function formatDate(date) { return ( prefixZero(date.getHours()) + ":" + prefixZero(date.getMinutes()) + ":" + prefixZero(date.getSeconds()) );}function prefixZero(number) { if (number < 10) { return "0" + number.toString(); } return number.toString();}</script><template> <p role="timer">Time is: {{ time }}</p></template>
在这个例子中,我们调用setInterval每秒运行一次的函数。这个函数做了两件事:
- 更新
time以在其字符串中包含当前Date的时针、分针和秒针 console.log一条消息
由于每个框架的副作用处理程序,此setInterval调用在每个组件渲染时都会发生。Clock
现在让我们Clock在条件块内渲染这个组件:
<!-- App.vue --><script setup>import Clock from "./Clock.vue";import { ref } from "vue";const showClock = ref(true);function setShowClock(val) { showClock.value = val;}</script><template> <div> <button @click="setShowClock(!showClock)">Toggle clock</button> <Clock v-if="showClock" /> </div></template>
在 中App,我们默认showClock为true。这意味着我们的Clock组件将在App的第一次渲染时进行渲染。
我们可以直观地看到我们的时钟每秒都在更新,但真正有趣的部分是console.log。如果我们打开浏览器的开发者工具,我们可以看到它每次在屏幕上更新时也会记录下来。
但是,让我们Clock通过单击按钮来切换组件几次。
当我们每次切换时钟停止渲染时,它不会停止console.log运行。但是,当我们重新渲染时Clock,它会创建一个新的间隔console.logs。这意味着,如果我们切换Clock组件三次,console.log那么每次屏幕时间更新时,它都会运行三次。
这确实是一种糟糕的行为。这不仅意味着我们的计算机在后台运行了超出所需数量的代码,还意味着传递给setInterval调用的函数无法被浏览器清理。这意味着你的setInterval函数(以及其中的所有变量)会停留在内存中,如果这种情况发生得太频繁,最终可能会导致内存不足崩溃。
此外,这还会直接影响应用程序的功能。让我们来看看这是如何发生的:
损坏的生产代码
假设你正在开发一个闹钟应用。你希望实现以下功能:
- 显示闹钟剩余时间
- 显示“唤醒”屏幕
- “暂停”闹钟 5 分钟(暂时将计时器的倒计时重置为 5 分钟)
- 完全禁用警报
此外,我们还添加了自动暂停已响10分钟的闹钟的功能。毕竟,深度睡眠的人更容易被音量变化而不是重复的巨响吵醒。
现在让我们构建该功能,但将“分钟”减少为“秒”以便于测试:
<!-- AlarmScreen.vue --><script setup>import { onMounted } from "vue";const props = defineProps(["snooze", "disable"]);onMounted(() => { setTimeout(() => { // Automatically snooze the alarm // after 10 seconds of inactivity // In production, this would be 10 minutes props.snooze(); }, 10 * 1000);});</script><template> <div> <p>Time to wake up!</p> <button @click="props.snooze()">Snooze for 5 seconds</button> <button @click="props.disable()">Turn off alarm</button> </div></template>
<!-- App.vue --><script setup>import AlarmScreen from "./AlarmScreen.vue";import { ref, onMounted } from "vue";const secondsLeft = ref(5);const timerEnabled = ref(true);onMounted(() => { setInterval(() => { if (secondsLeft.value === 0) return; secondsLeft.value = secondsLeft.value - 1; }, 1000);});const snooze = () => { // In production, this would add 5 minutes, not 5 seconds secondsLeft.value = secondsLeft.value + 5;};const disable = () => { timerEnabled.value = false;};</script><template> <p v-if="!timerEnabled">There is no timer</p> <AlarmScreen v-else-if="secondsLeft === 0" :snooze="snooze" :disable="disable" /> <p v-else>{{ secondsLeft }} seconds left in timer</p></template>
你会注意到,我们在 Vue 代码示例中没有使用事件,而是选择传递一个函数。这是因为,虽然其他框架会继续监听来自已卸载组件的事件,但 Vue 不会。
但这并不意味着 Vue 能帮我们解决这个问题。虽然在 Vue 中传递函数不如使用输出常见,但这并非唯一的处理方式。此外,虽然使用 Vue 的输出来处理
snooze和disable处理功能从用户的角度解决了这个问题,但它并不能解决你因不清理而造成的内存泄漏setTimeout。为了更好地理解这一点,本章有一节对此进行了解释。
是的!它能渲染倒计时秒数,然后显示结果,AlarmScreen一切正常。就连我们的“自动贪睡”功能也正常工作了。
让我们测试一下手动“贪睡”按钮,看看它是否能按预期工作......
等等,计时器屏幕是不是从 4 秒变成了 9 秒?倒计时不是这样运作的!
果然,如果您恰好在自动贪睡功能关闭之前单击手动“贪睡”按钮,它将在您现有的倒计时上额外增加 5 秒。
发生这种情况是因为我们从未告诉AlarmScreen停止setTimeout运行,即使AlarmScreen不再渲染。
// AlarmScreen componentsetTimeout(() => { snooze();}, 10 * 1000);
当上述代码运行时,它将通过的方法向snooze变量添加 4 秒。secondsLeft``App``snooze
为了解决这个问题,我们只需要告诉组件在不再渲染时AlarmScreen取消它即可。让我们看看如何使用副作用处理程序来实现这一点。setTimeout
卸载
在我们之前的代码示例中,我们展示了未清理的已安装副作用会导致我们的应用程序出现错误并给用户带来性能问题。
让我们使用在组件卸载期间运行的处理程序来清理这些副作用。为此,我们将使用 JavaScriptclearTimeout删除所有setTimeout未运行的 s :
const timeout = setTimeout(() => { }, 1000);// This stops a timeout from running if unran.// Otherwise, it does nothing.clearTimeout(timeout);
类似地,当使用时setInterval,我们可以使用一种clearInterval方法进行清理:
const interval = setInterval(() => { }, 1000);// This stops an interval from runningclearInterval(interval);
与我们导入的方式类似onMounted,我们可以onUnmounted在 Vue 中导入来运行相关的生命周期方法。
<!-- Cleanup.vue --><script setup>import { onUnmounted } from "vue";onUnmounted(() => { alert("I am cleaning up");});</script><template> <p>Unmount me to see an alert</p></template>
让我们将这个新的生命周期方法应用到我们之前的代码示例中:
<!-- AlarmScreen.vue --><script setup>import { ref, onMounted, onUnmounted } from "vue";const emit = defineEmits(["snooze", "disable"]);// We don't need to wrap this in `ref`, since it won't be used in `template`let timeout;onMounted(() => { timeout = setTimeout(() => { // Automatically snooze the alarm // after 10 seconds of inactivity // In production, this would be 10 minutes emit("snooze"); }, 10 * 1000);});onUnmounted(() => { clearTimeout(timeout);});</script><template> <!-- ... --></template>
<!-- App.vue --><script setup>import AlarmScreen from "./AlarmScreen.vue";import { ref, onMounted, onUnmounted } from "vue";// We don't need to wrap this in `ref`, since it won't be used in `template`let interval;onMounted(() => { interval = setInterval(() => { if (secondsLeft.value === 0) return; secondsLeft.value = secondsLeft.value - 1; }, 1000);});onUnmounted(() => { clearInterval(interval);});// ...</script><template> <!-- ... --></template>
Vue 的watchEffect清理
如前所述,Vue 有两种处理副作用的方法:生命周期方法和watchEffect。幸运的是,watchEffect它还能够清除之前创建的副作用。
为了清理效果,watchEffect需要向内部watchEffect函数提供一个参数,我们将其命名为onCleanup。此属性本身就是一个函数,我们会使用清理逻辑来调用它:
// onCleanup is a property passed to the inner `watchEffect`
function watchEffect((onCleanup) => { const interval = setInterval(() => { console.log("Hello!"); }, 1000); // We then call `onCleanup` with the expected cleaning behavior onCleanup(() => { clearInterval(interval); });});
让我们重写前面的代码示例以使用watchEffect:
<!-- App.vue --><script setup>import AlarmScreen from "./AlarmScreen.vue";import { ref, watchEffect } from "vue";const secondsLeft = ref(5);const timerEnabled = ref(true);watchEffect((onCleanup) => { const interval = setInterval(() => { if (secondsLeft.value === 0) return; secondsLeft.value = secondsLeft.value - 1; }, 1000); onCleanup(() => { clearInterval(interval); });});const snooze = () => { secondsLeft.value = secondsLeft.value + 5;};const disable = () => { timerEnabled.value = false;};</script><template> <p v-if="!timerEnabled">There is no timer</p> <AlarmScreen v-else-if="secondsLeft === 0" @snooze="snooze()" @disable="disable()" /> <p v-else>{{ secondsLeft }} seconds left in timer</p></template>
<!-- AlarmScreen.vue --><script setup>import { ref, watchEffect } from "vue";const emit = defineEmits(["snooze", "disable"]);watchEffect((onCleanup) => { const timeout = setTimeout(() => { // Automatically snooze the alarm // after 10 seconds of inactivity // In production, this would be 10 minutes emit("snooze"); }, 10 * 1000); onCleanup(() => clearTimeout(timeout));});</script><template> <div> <p>Time to wake up!</p> <button @click="emit('snooze')">Snooze for 5 seconds</button> <button @click="emit('disable')">Turn off alarm</button> </div></template>
隐藏的内存泄漏
虽然我们快速浏览了每个框架如何使用在组件卸载时运行的副作用处理程序来清理其副作用,但我们还没有看到这对组件的其他方面有何影响。
例如,我们在第一章中提到过,可以从子组件向父组件发送事件。让我们构建一个组件,该组件使用事件在其内容显示一秒后触发父函数。
我们先不介绍清理,您马上就会明白为什么。
<!-- Alert.vue --><script setup>import { onMounted } from "vue";const emit = defineEmits(["alert"]);onMounted(() => { setTimeout(() => { emit("alert"); }, 1000);});</script><template> <p>Showing alert...</p></template>
<!-- App.vue --><script setup>import { ref } from "vue";import Alert from "./Alert.vue";const show = ref(false);const toggle = () => (show.value = !show.value);const alertUser = () => alert("I am an alert!");</script><template> <div> <!-- Try clicking and unclicking quickly --> <button @click="toggle()">Toggle</button> <!-- Binding to an event --> <Alert v-if="show" @alert="alertUser()" /> </div></template>
现在,如果我们运行其中一个代码示例,并快速反复地点击一个按钮,我们会得到……什么都没有!只有最后一个按钮alert会显示出来,通知您我们的Alert组件已渲染完成。
这很奇怪...我以为我们的
setTimeout仍然会触发,因为我们还没有使用来清理它clearTimeout。
你说得对!setTimeout 确实触发了!你可以更新我们的代码,在里面setTimeout添加一个来验证这一点。console.log
那为什么我们的
alert存在没有被触发呢?
好吧,当您思考其中一个框架中的组件如何工作时,您可能会将它们想象成这样:
在这里,我们可以看到尽管有一个组件声明:
<!-- Comp.vue --><script setup>const obj = {};</script><template> <p>Hello, world</p></template>
每次我们使用这个组件时:
<!-- App.vue --><script setup>import Comp from "./Comp.vue";</script><template> <div> <!-- One: --> <Comp /> <!-- Two: --> <Comp /> </div></template>
每个单独的Comp用法都会生成一个组件实例。这些实例有各自独立的内存使用,这使得你可以彼此独立地控制它们的状态。
此外,由于每个组件实例都有自己与框架根实例的连接,因此当一个实例分离时,它可以对事件监听器进行一些清理,而不会影响其他实例:
那么,如果框架事件已经为我们清理了,为什么我们还需要手动处理和清理副作用呢?
正如我们所展示的,setTimeout示例中的 our 并没有被清理,即使emit父级的输出被清理了。这意味着,如果你有长期存在的代码(比如, auseInterval而不是 a setTimeout),你将无法将这部分内存释放回 JavaScript 的内部清理机制。
这就是内存泄漏的定义:不允许语言清理旧代码。最终,随着时间的推移,你甚至可能会遇到“内存不足”的错误,你的电脑如果不重置就无法运行你的应用或网站。
是不是感觉漏掉了什么?本节内容需要你预先了解计算机内存的硬件工作原理以及JavaScript 底层内存处理机制。建议你先阅读这些资源,如果感到困惑,可以再回来阅读。
发现隐藏的内存泄漏
我们提到的内存泄漏不仅在使用事件时对用户隐藏,而且还有可能意外地将它们暴露给用户。
最简单的方法是从事件处理程序切换到从父级传递给子级的函数:
<!-- Alert.vue --><script setup>import { onMounted } from "vue";const props = defineProps(["alert"]);onMounted(() => { setTimeout(() => { props.alert?.(); }, 1000);});</script><template> <p>Showing alert...</p></template>
<!-- App.vue --><script setup>import { ref } from "vue";import Alert from "./Alert.vue";const show = ref(false);const toggle = () => (show.value = !show.value);const alertUser = () => alert("I am an alert!");</script><template> <!-- Try clicking and unclicking quickly --> <button @click="toggle()">Toggle</button> <!-- Passing a function --> <Alert v-if="show" :alert="alertUser" /></template>
现在,如果我们快速点击“切换”按钮,你会看到多个alert连续的 s ,次数与你切换Alert组件的次数相同。你隐藏的内存泄漏现在对用户可见了!😱
为什么这种行为与监听父级事件不同?
好吧,让我们再次直观地看一下我们的旧示例:
你可能会注意到,我们现在不再是双向绑定,而是将一个函数引用从组件实例传递给框架和 DOM。由于这种传递(而不是监听),我们选择的框架不再能够强制使此引用无效。毕竟,JavaScript 中的函数只是一个可以像其他值一样传递的值。
我们处理父组件数据的方式(绑定 vs. 获取引用)的这种转变意味着,在清理时,传递给子组件的函数不会失效。相反,它会一直保留在内存中,直到子组件不再需要它并被 JavaScript 内部清理掉为止:
这就是为什么清理内存如此重要——很容易让内存泄漏并意外改变用户的预期行为。
清理事件监听器
本章前面我们有一个依赖于 的代码示例addEventListener来获取窗口大小。你可能已经猜到了,这个代码示例存在内存泄漏,因为我们从未清理过这个事件监听器。
要清理事件监听器,我们必须window通过以下方式从对象中删除它的引用removeEventListener:
const fn = () => console.log("a");window.addEventListener("resize", fn);window.removeEventListener("resize", fn);
需要记住的
removeEventListener是,它需要与第二个参数传递的函数相同,才能将其从侦听器中删除。这意味着内联箭头函数如下所示:
window.addEventListener("resize", () => console.log("a"));window.removeEventListener("resize", () => console.log("a"));不会起作用,但下面的方法可以:
const fn = () => console.log("a");window.addEventListener("resize", fn);window.removeEventListener("resize", fn);
让WindowSize我们利用现在掌握的知识,通过清理事件监听器的副作用来修复之前的组件。
使用生命周期,这将是:
<!-- WindowSize.vue --><script setup>import { ref, onMounted, onUnmounted } from "vue";const height = ref(window.innerHeight);const width = ref(window.innerWidth);function resizeHandler() { height.value = window.innerHeight; width.value = window.innerWidth;}onMounted(() => { window.addEventListener("resize", resizeHandler);});onUnmounted(() => { window.removeEventListener("resize", resizeHandler);});</script><template> <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
或者,使用watchEffect,这是:
<!-- WindowSize.vue --><script setup>import { ref, watchEffect } from "vue";const height = ref(window.innerHeight);const width = ref(window.innerWidth);function resizeHandler() { height.value = window.innerHeight; width.value = window.innerWidth;}watchEffect((onCleanup) => { window.addEventListener("resize", resizeHandler); onCleanup(() => window.removeEventListener("resize", resizeHandler));});</script><template> <div> <p>Height: {{ height }}</p> <p>Width: {{ width }}</p> </div></template>
确保副作用清除
一些框架已经采取了额外的措施来确保你的副作用始终被清除。
Vue 没有任何特殊行为onMounted或watchEffect强制组件清理的功能。然而,React 却有。
重新渲染
虽然渲染和取消渲染是您可能想要添加副作用处理程序的最频繁时间,但它们并不是您能够这样做的唯一时间。
虽然我们之前展示的许多示例在各个框架之间相对一致,但这些框架往往在这一点上存在分歧。之所以出现这种差异,是因为每个框架公开的 API 通常取决于其内部功能;这允许您对组件进行更细粒度的控制。
虽然我们将在本系列的第三本书中介绍框架的内部结构,但现在,让我们先来看看大多数框架之间相对一致的另一个组件 API:重新渲染。
重新渲染听起来就是这样;虽然初始“渲染”使我们能够看到屏幕上绘制的第一个内容,但后续更新(例如实时更新的值)是在“重新渲染”期间绘制的。
重新渲染可能由于多种原因而发生:
- 道具正在更新
- 状态正在改变
- 通过其他方式明确调用重新渲染
让我们看一下每个框架如何向您展示重新渲染。
<!-- ReRenderListener.vue --><script setup>import { ref, onUpdated } from "vue";const count = ref(0);const add = () => { count.value++;};onUpdated(() => { console.log("Component was painted");});</script><template> <button @click="add()">{{ count }}</button></template>
每次ReRenderListener组件使用新的更改更新 DOM 时,该onUpdated方法就会运行。
组件内属性的副作用
到目前为止,我们已经研究了组件范围的事件,例如“渲染组件”和“取消渲染组件”。
尽管这些事件无疑有助于解决这一问题,但大多数用户输入并不会引起如此剧烈的变化。
例如,假设我们想要在选择新文档时更新浏览器选项卡的标题:
<!-- App.vue --><script setup>import { ref, watchEffect } from "vue";const title = ref("Movies");</script><template> <div> <button @click="title = 'Movies'">Movies</button> <button @click="title = 'Music'">Music</button> <button @click="title = 'Documents'">Documents</button> <p>{{ title }}</p> </div></template>
这里,title是一个正在更新的变量,它会触发重新渲染。由于框架知道如何根据响应式更改触发重新渲染,因此它还能够在值更改时触发副作用。
让我们看看如何在 React、Angular 和 Vue 中做到这一点:
我必须坦白一些事情:watchEffect它主要不是用来在组件的第一次渲染上运行效果,因为我们一直在使用它。
相反,它做了一些非常神奇的事情:每当“跟踪”更新watchEffect时,它就会重新运行内部函数:ref
<!-- App.vue --><script setup>import { ref, watchEffect } from "vue";const title = ref("Movies");// watchEffect will re-run whenever `title.value` is updatedwatchEffect(() => { document.title = title.value;});</script><template> <div> <button @click="title = 'Movies'">Movies</button> <button @click="title = 'Music'">Music</button> <button @click="title = 'Documents'">Documents</button> <p>{{ title }}</p> </div></template>
如何watchEffect知道需要监视哪些 ref?长话短说,需要深入研究 Vue 的源代码,目前阶段介绍起来比较困难。
简短的回答是“Vue 的ref代码包含一些逻辑来注册到最近的watchEffect,但只有当ref在同步操作中时才有效。”
这意味着如果我们有以下代码:
const title = ref("Movies");const count = ref(0);// watchEffect will re-run whenever `title.value` or `count.value` is updatedwatchEffect(() => { console.log(count.value); document.title = "Title is " + title.value + " and count is " + count.value;});
watchEffect每当title或count更新时, 都会运行。但是,如果我们执行以下操作:
const title = ref("Movies");const count = ref(0);// watchEffect will re-run only when `count.value` is updatedwatchEffect(() => { console.log(count.value); // This is an async operation, inner `ref` usage won't be tracked setTimeout(() => { document.title = "Title is " + title.value + " and count is " + count.value; }, 0);});
它将仅跟踪的变化count,因为title.value使用是在异步操作中。
手动跟踪更改watch
watchEffect是不是有点神奇?或许它对你的需求来说有点过分,或许你需要更明确地说明哪些需要跟踪,哪些不需要。这时,Vue 的watch功能就派上用场了。
虽然watchEffect看似神奇地检测出要监听哪些变量,但watch需要你明确说明要监听哪些属性的变化:
import { ref, watch } from "vue";const title = ref("Movies");watch( // Only listen for changes to `title`, despite what's in the function body title, () => { document.title = title.value; }, { immediate: true },);
您可能注意到我们正在将其
{immediate: true}作为选项传递watch;这是在做什么?嗯,默认情况下,它只在第一次渲染后
watch运行。这意味着,否则的初始状态将不会触发更新。**title``document.title例如:
watch(title, () => { document.title = title.value;});当发生变化时仍会运行内部函数
title,但仅在初始渲染之后。
watch多个项目
为了观察多个项目,我们可以传递一个反应变量数组:
const title = ref("Movies");const count = ref(0);// This will run when `title` and `count` are updated, despite async usagewatch( [title, count], () => { setTimeout(() => { document.title = "Title is " + title.value + " and count is " + count.value; }, 0); }, { immediate: true },);
watch还支持传递onCleanup方法来清理所监视的副作用,就像watchEffectAPI 一样:
const title = ref("Movies");const count = ref(0);// This will run when `title` and `count` are updated, despite async usagewatch([title, count], (currentValue, previousValue, onCleanup) => { const timeout = setTimeout(() => { document.title = "Title is " + title.value + " and count is " + count.value; }, 1000); onCleanup(() => clearTimeout(timeout));});
渲染、提交、绘画
虽然我们可能将“渲染”的定义归结为“在屏幕上显示新内容”,但这只是部分正确。虽然 Angular 在某种程度上遵循了这一定义,但 React 和 Vue 却没有。
相反,React 和 Vue 都各有一套绝招:虚拟 DOM(VDOM)。虽然解释 VDOM 有点复杂,但以下是一些基础知识:
-
该框架镜像DOM 树中的节点,以便它可以在任何给定时刻重新创建整个应用程序的 UI。
-
当您告诉框架更新屏幕上的值时,它会尝试找出要渲染的屏幕特定部分,仅此而已。
-
在框架明确决定要使用新内容重新渲染哪些元素后,它将:
- 创建一组需要运行以更新 DOM 的指令
- 在 VDOM 上运行这些指令
- 将 VDOM 中所做的更改反映到实际的浏览器 DOM 上
- 然后,浏览器获取对 DOM 所做的更新并将其显示给用户
让我们先回顾一下上一点提到的四个步骤。决定框架需要更新哪些元素的过程称为“协调”。此协调步骤包含三个部分,分别命名为:
- 差异
- 预先承诺
- 承诺
- 绘画
请记住,此“协调”过程是渲染的一部分。您的组件 可能会由于响应式状态变化而渲染,但如果它检测到此阶段没有任何变化,则可能不会触发整个协调过程
diffing。
React 和 Vue 都提供了通过自己的 API 访问协调内部阶段的方法。
在本章中,我们一直在使用watch和,主要作为监听某种全球事件的一种手段。watchEffect
但是,如果我们想将副作用本地化到屏幕上的元素作为组件子元素的一部分,该怎么办?让我们尝试一下:
<!-- App.vue --><script setup>import { ref, watch } from "vue";const title = ref("Movies");watch( title, () => { const el = document.querySelector("#title-paragraph"); alert(el?.innerText); }, { immediate: true },);</script><template> <div> <button @click="title = 'Movies'">Movies</button> <button @click="title = 'Music'">Music</button> <button @click="title = 'Documents'">Documents</button> <p id="title-paragraph">{{ title }}</p> </div></template>
在这里,当我们点击任意按钮触发更改时title,您可能会注意到它显示的是元素的先前innerText值。例如,当我们按下“音乐”时,它会显示innerText,Movies也就是 的先前值title。
这似乎不符合我们所寻求的行为!
还有另一个迹象表明事情并没有按预期进行;在第一次运行watch
为什么会发生这种情况?
默认情况下,在从 Vue 的 VDOM 提交 DOM 内容之前watch,同时watchEffect运行这两个操作。**
为了改变这种行为,我们可以在DOM 提交阶段之后{flush: 'post'}添加watch或watchEffect运行观察者的功能。**
watch( title, () => { const el = document.querySelector("#title-paragraph"); alert(el?.innerText); }, { immediate: true, flush: "post" },);
现在,当我们单击某个项目时,它将打印出title该元素的当前版本innerText。
虽然在开始构建应用程序时很少使用这种级别的内部知识,但它可以为您提供优化和改进应用程序的强大能力;将这些信息视为您的开发人员超能力。
与任何其他超能力一样,您应该谨慎使用最后几个 API,因为它们可能会使您的应用程序变得更糟而不是更好;优秀的 API 伴随着巨大的责任。
无需渲染即可更改数据
有时,每次想要设置变量的状态时触发重新渲染并不理想。
例如,让我们回到之前的document.title例子。假设我们不想立即更新title和document.title,而是想使用来延迟更新两者setTimeout:
<!-- TitleChanger.vue --><script setup>import { ref } from "vue";const title = ref("Movies");function updateTitle(val) { setTimeout(() => { title.value = val; document.title = val; }, 5000);}</script><template> <div> <button @click="updateTitle('Movies')">Movies</button> <button @click="updateTitle('Music')">Music</button> <button @click="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div></template>
如果我们单击其中一个按钮并取消渲染App组件,我们的setTimeout操作仍会执行,因为我们从未告诉过该组件取消超时。
我们可以使用状态变量来解决这个问题:
<!-- TitleChanger.vue --><script setup>import { ref, onUnmounted } from "vue";const title = ref("Movies");const timeoutExpire = ref(null);function updateTitle(val) { clearTimeout(timeoutExpire.value); timeoutExpire.value = setTimeout(() => { title.value = val; document.title = val; }, 5000);}onUnmounted(() => clearTimeout(timeoutExpire.value));</script><template> <div> <button @click="updateTitle('Movies')">Movies</button> <button @click="updateTitle('Music')">Music</button> <button @click="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div></template>
App当我们运行时,这将触发 的重新渲染updateTitle。由于我们的属性未在 DOM 中使用,因此此重新渲染不会显示任何新的更改,timeoutExpire但根据App组件的大小,计算成本可能会很高。
对我们来说幸运的是,每个框架都能够避开渲染,同时将值保留在组件的状态中。
因为 Vue 使用script在每次组件运行时仅执行一次的 ,所以我们可以依靠使用变量关键字来let改变变量的值。
<!-- TitleChanger.vue --><script setup>import { watch, ref, onUnmounted } from "vue";const title = ref("Movies");let timeoutExpire = null;function updateTitle(val) { clearTimeout(timeoutExpire); timeoutExpire = setTimeout(() => { title.value = val; document.title = val; }, 5000);}onUnmounted(() => clearTimeout(timeoutExpire));</script><template> <div> <button @click="updateTitle('Movies')">Movies</button> <button @click="updateTitle('Music')">Music</button> <button @click="updateTitle('Documents')">Documents</button> <p>{{ title }}</p> </div></template>
API图表
我们来直观的看一下各个框架是如何调用我们今天接触到的相关API的:
因为 Vue 有两个不同的 API,所以我为它们制作了两个图表。
Vue 生命周期方法
Vue 观察者
挑战
从本书开始,我们就一直在开发一款存储应用。目前为止,我们的原型一直使用浅色模式(出于可访问性的考虑,浅色模式是所有应用的重要默认设置),现在让我们为应用添加深色模式:
这可以通过结合使用多种技术来实现:
-
手动主题切换有三个选项:
- 灯光模式
- 从操作系统继承模式
- 黑暗模式
-
浏览器的
matchMediaAPI检测操作系统的主题。 -
保留用户的主题选择
localstorage
让我们首先使用各自的框架构建主题切换。
<!-- DarkModeToggle.vue --><script setup>import { ref } from "vue";const explicitTheme = ref("inherit");</script><template> <div style="display: flex; gap: 1rem"> <label style="display: inline-flex; flex-direction: column"> <div>Light</div> <input name="theme" type="radio" :checked="explicitTheme === 'light'" @change="explicitTheme = 'light'" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Inherit</div> <input name="theme" type="radio" :checked="explicitTheme === 'inherit'" @change="explicitTheme = 'inherit'" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Dark</div> <input name="theme" type="radio" :checked="explicitTheme === 'dark'" @change="explicitTheme = 'dark'" /> </label> </div></template>
现在我们有了这个主题切换,让我们dark通过使用一些 CSS 和副作用处理程序来监听更改,从而使模式工作value:
<!-- DarkModeToggle.vue --><script setup>import { ref, watch } from "vue";const explicitTheme = ref("inherit");watch(explicitTheme, () => { document.documentElement.className = explicitTheme.value;});</script><template> <!-- ... --></template>
<!-- App.vue --><script setup>import DarkModeToggle from "./DarkModeToggle.vue";</script><template> <div> <DarkModeToggle /> <p style="color: var(--primary)">This text is blue</p> </div></template><style>:root { --primary: #1a42e5;}.dark { background: #121926; color: #d6e4ff; --primary: #6694ff;}</style>
现在,我们可以使用matchMediaAPI 来添加检查,以检查用户的操作系统是否已更改其主题。
瞧,我们可以通过在 JavaScript 中执行以下操作来检测用户喜欢的颜色主题:
// If true, the user prefers dark mode
window.matchMedia("(prefers-color-scheme: dark)").matches;
我们甚至可以通过执行以下操作来添加一个监听器,用于监听用户实时更改此偏好设置的情况:
window.matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", (e) => { // If true, the user prefers dark mode e.matches; });
现在我们知道了 JavaScript API,让我们将其与我们的应用程序集成:
<!-- DarkModeToggle.vue --><script setup>import { ref, watch, onMounted, onUnmounted } from "vue";const explicitTheme = ref("inherit");watch(explicitTheme, () => { if (explicitTheme.value === "implicit") { document.documentElement.className = explicitTheme.value; return; } document.documentElement.className = explicitTheme.value;});const isOSDark = window.matchMedia("(prefers-color-scheme: dark)");const changeOSTheme = () => { explicitTheme.value = isOSDark.matches ? "dark" : "light";};onMounted(() => { isOSDark.addEventListener("change", changeOSTheme);});onUnmounted(() => { isOSDark.removeEventListener("change", changeOSTheme);});</script><template> <!-- ... --></template>
现在,当用户更改其操作系统的暗模式设置时,它将通过我们的网站反映出来。
最后,让我们使用浏览器的localstorageAPI来记住用户在手动切换中选择的首选项。此 API 的工作方式类似于 JSON 对象,可在多个浏览器会话中持久保存。这意味着我们可以在 JavaScript 中执行以下操作:
// Will be `null` if nothing is defined
const car = localStorage.getItem("car");if (!car) localStorage.setItem("car", "Hatchback");
让我们将其集成到我们的DarkModeToggle组件中:
<!-- DarkModeToggle.vue --><script setup>import { ref, watch, onMounted, onUnmounted } from "vue";const explicitTheme = ref(localStorage.getItem("theme") || "inherit");watch(explicitTheme, () => { localStorage.setItem("theme", explicitTheme);});watch(explicitTheme, () => { if (explicitTheme.value === "implicit") { document.documentElement.className = explicitTheme.value; return; } document.documentElement.className = explicitTheme.value;});const isOSDark = window.matchMedia("(prefers-color-scheme: dark)");const changeOSTheme = () => { explicitTheme.value = isOSDark.matches ? "dark" : "light";};onMounted(() => { isOSDark.addEventListener("change", changeOSTheme);});onUnmounted(() => { isOSDark.removeEventListener("change", changeOSTheme);});</script><template> <div style="display: flex; gap: 1rem"> <label style="display: inline-flex; flex-direction: column"> <div>Light</div> <input name="theme" type="radio" :checked="explicitTheme === 'light'" @change="explicitTheme = 'light'" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Inherit</div> <input name="theme" type="radio" :checked="explicitTheme === 'inherit'" @change="explicitTheme = 'inherit'" /> </label> <label style="display: inline-flex; flex-direction: column"> <div>Dark</div> <input name="theme" type="radio" :checked="explicitTheme === 'dark'" @change="explicitTheme = 'dark'" /> </label> </div></template>
<!-- App.vue --><script setup>import DarkModeToggle from "./DarkModeToggle.vue";</script><template> <div> <DarkModeToggle /> <p style="color: var(--primary)">This text is blue</p> </div></template><style>:root { --primary: #1a42e5;}.dark { background: #121926; color: #d6e4ff; --primary: #6694ff;}</style>