各位开发者,下午好!
今天,我们将深入探讨一个在现代前端应用中至关重要的性能优化话题:延迟计算 (Lazy Computation)。尤其是在构建复杂、数据密集型或包含大量动态内容的单页应用 (SPA) 时,我们经常会遇到这样的场景:某个组件的渲染或数据处理成本非常高,但它并非总是在用户的即时视线之内。如果我们在组件挂载时就无差别地执行所有昂贵的计算,可能会导致页面加载缓慢、交互卡顿,从而严重损害用户体验。
想象一下一个长列表,每个列表项可能包含一个复杂的图表、一个计算密集型的子组件,或者需要从服务器加载大量数据。如果用户只看到前几项,而我们却在后台默默地计算并渲染了成百上千项,这无疑是一种巨大的资源浪费。
这就是我们今天要解决的核心问题:如何确保昂贵的计算只在组件真正进入用户的视口时才执行?
我们将通过手写实现一个名为 useLazyValue 的自定义 React Hook 来回答这个问题。这个 Hook 将结合 React 的响应式能力和 Web API IntersectionObserver 的强大功能,为您提供一个优雅、高效的解决方案。
1. 延迟计算的必要性与核心思想
在前端开发中,"延迟" 是一种强大的优化策略。它的核心思想是:只在真正需要时才做某事。
1.1 为什么需要延迟计算?
- 性能提升: 避免不必要的计算和渲染,减少 CPU 和内存占用,加快页面加载速度和响应时间。
- 资源节约: 如果计算涉及网络请求,延迟加载可以减少带宽消耗。
- 改善用户体验: 用户在滚动到相关内容时,内容能够快速呈现,而不是等待整个页面加载完毕。
- 电量消耗: 对于移动设备而言,减少不必要的计算也能延长电池续航。
1.2 useLazyValue 的核心思想
useLazyValue 的目标是封装以下行为:
- 挂载时不计算: 组件首次挂载时,如果不在视口内,不立即执行昂贵的计算函数。
- 视口触发计算: 只有当组件(或其关联的 DOM 元素)首次进入视口时,才触发一次计算。
- 依赖更新后重计算 (可选但推荐): 如果计算函数依赖的数据发生变化,且组件当前在视口内(或已经进入过视口),则重新执行计算。
- 提供一个可观察的引用: Hook 应该返回一个
ref,供使用者将其附加到需要被观察的 DOM 元素上。
为了实现这些,我们将依赖两个关键技术:
- React Hooks (
useState,useEffect,useRef,useCallback,useMemo): 用于管理组件的生命周期、状态和副作用。 IntersectionObserverWeb API: 用于高效地检测元素是否进入或离开视口。
2. IntersectionObserver 深度解析
在深入 useLazyValue 的实现之前,我们必须彻底理解 IntersectionObserver。它是现代 Web 开发中实现高性能滚动优化(如图片懒加载、无限滚动、广告可见性检测等)的基石。
2.1 IntersectionObserver 是什么?
IntersectionObserver 是一个异步观察目标元素与祖先元素或文档视口交叉状态的 API。它提供了一种比传统滚动事件监听更高效、性能更好的方式来检测元素可见性。
2.2 为什么比滚动事件更好?
- 性能优越:
IntersectionObserver是异步的,并且不运行在主线程上。这意味着它不会阻塞用户界面的渲染或交互。而滚动事件通常在主线程上频繁触发,容易导致性能问题(“jank”)。 - 精确度高: 它可以精确地报告目标元素与根元素的交叉比例。
- 浏览器优化: 浏览器可以对
IntersectionObserver的实现进行高度优化,利用硬件加速等技术。
2.3 IntersectionObserver 的基本用法
创建一个 IntersectionObserver 实例需要一个回调函数和一个可选的配置对象。
const observer = new IntersectionObserver(callback, options);
callback(回调函数):
当目标元素的可见性发生变化时,此函数会被调用。它接收一个entries数组作为参数,每个entry对象代表一个被观察元素的交叉状态变化。options(配置对象):
允许您配置观察者如何检测交叉。
callback 参数 entries
entries 是一个 IntersectionObserverEntry 对象的数组。每个 IntersectionObserverEntry 对象包含以下关键属性:
| 属性名称 | 类型 | 描述 |
|---|---|---|
boundingClientRect |
DOMRectReadOnly |
目标元素的边界矩形信息(相对于视口)。 |
intersectionRatio |
Number |
目标元素在根元素中可见部分的比例。取值范围 0 到 1。0 表示完全不可见,1 表示完全可见。 |
intersectionRect |
DOMRectReadOnly |
目标元素与根元素交叉部分的矩形信息。 |
isIntersecting |
Boolean |
最重要的属性。如果目标元素与根元素至少有 1 像素的交叉,则为 true。这是我们判断元素是否进入视口的主要依据。 |
rootBounds |
DOMRectReadOnly |
根元素的边界矩形信息。如果未指定 root,则为视口的边界。 |
target |
Element |
被观察的目标 DOM 元素。 |
time |
Number |
发生交叉变化的时间戳。 |
options 配置对象
| 属性名称 | 类型 | 默认值 | 描述 |
|---|---|---|---|
root |
Element |
null (视口) |
指定观察者的根元素。如果为 null,则使用文档的视口作为根元素。被观察的目标元素必须是根元素的子孙元素。 |
rootMargin |
String |
"0px 0px 0px 0px" |
根元素的边距。类似于 CSS 的 margin 属性。可以扩大或缩小根元素的判定区域。例如,"100px" 表示在根元素所有方向上增加 100px 的边距,这意味着目标元素在进入视口前 100px 就会被视为进入。这对于提前加载内容非常有用。 |
threshold |
Number | Array<Number> |
0 |
一个数值或数值数组,表示目标元素可见性达到多少比例时触发回调。0 表示目标元素哪怕只有 1 像素进入视口也会触发;1 表示目标元素完全可见时才触发。[0, 0.25, 0.5, 0.75, 1] 会在目标元素可见性达到这几个比例时分别触发回调。 |
2.4 观察和停止观察
observer.observe(targetElement): 开始观察目标元素。observer.unobserve(targetElement): 停止观察特定目标元素。observer.disconnect(): 停止观察所有目标元素。
在 React 的 useEffect 中,我们通常会在组件挂载时 observe,在组件卸载时 disconnect,以避免内存泄漏。
3. 实现 useLazyValue 自定义 Hook
现在,我们已经掌握了必要的理论知识,可以开始构建 useLazyValue 了。
3.1 核心组件和状态
我们的 Hook 需要管理以下几项:
value: 存储昂贵计算的结果。初始时应为undefined或一个加载占位符。hasIntersected: 一个布尔值,指示组件是否已经进入过视口。elementRef: 一个ref对象,用于将IntersectionObserver附加到 DOM 元素。这个ref将被 Hook 返回,由使用者传递给其组件的 DOM 元素。observerRef: 一个ref对象,用于存储IntersectionObserver实例本身,防止在组件重新渲染时重复创建。
// useLazyValue.js
import { useRef, useState, useEffect, useCallback } from 'react';
/**
* 一个自定义 Hook,用于延迟执行昂贵的计算,直到关联的 DOM 元素进入视口。
*
* @param {Function} computeFn - 一个返回昂贵计算结果的函数。此函数应该被 useCallback 包装以确保稳定性。
* @param {Array<any>} deps - computeFn 的依赖项数组。当这些依赖项变化时,如果组件已进入视口,会重新计算。
* @param {Object} [observerOptions] - IntersectionObserver 的可选配置。
* @param {Element|null} [observerOptions.root=null] - 根元素,默认为浏览器视口。
* @param {string} [observerOptions.rootMargin="0px"] - 根元素的边距。
* @param {number|number[]} [observerOptions.threshold=0] - 交叉比例,达到此比例时触发回调。
* @returns {[any, React.MutableRefObject<HTMLElement | null>, boolean]} - 包含计算结果、用于附加到 DOM 元素的 ref,以及元素是否已进入视口的状态。
*/
function useLazyValue(
computeFn,
deps = [],
observerOptions = { root: null, rootMargin: '0px', threshold: 0 }
) {
// 存储计算结果,初始为 undefined
const [value, setValue] = useState(undefined);
// 标记元素是否已进入视口
const [hasIntersected, setHasIntersected] = useState(false);
// 用于关联到需要观察的 DOM 元素的 ref
const elementRef = useRef(null);
// 用于存储 IntersectionObserver 实例的 ref
const observerRef = useRef(null);
// ... 接下来我们将逐步添加 useEffect 逻辑
return [value, elementRef, hasIntersected];
}
export default useLazyValue;
3.2 建立 IntersectionObserver
第一个 useEffect 负责创建、配置和清理 IntersectionObserver。这个 Hook 应该只运行一次,当 elementRef.current 被设置时,或者在组件卸载时清理。
// ... (之前的代码)
function useLazyValue(
computeFn,
deps = [],
observerOptions = { root: null, rootMargin: '0px', threshold: 0 }
) {
// ... (state 和 ref 声明)
useEffect(() => {
// 确保在浏览器环境中运行,避免 SSR 错误
if (typeof window === 'undefined' || !window.IntersectionObserver) {
// 如果不在浏览器环境或不支持 IntersectionObserver,则直接标记为已相交
// 这样在 SSR 环境下,计算会在客户端 hydration 后立即执行
setHasIntersected(true);
return;
}
const currentElement = elementRef.current;
// 如果没有目标元素,则不创建观察者
if (!currentElement) {
return;
}
// 创建 IntersectionObserver 实例
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
// 更新 hasIntersected 状态
setHasIntersected(entry.isIntersecting);
},
observerOptions // 传入用户自定义的配置
);
// 将 observer 实例存入 ref,方便在清理时访问
observerRef.current = observer;
// 开始观察目标元素
observer.observe(currentElement);
// 清理函数:在组件卸载时断开观察者
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, [observerOptions]); // 当 observerOptions 改变时,重新创建 observer (不常见,但提供灵活性)
// ... (接下来的计算逻辑)
return [value, elementRef, hasIntersected];
}
关于 useEffect 依赖 observerOptions 的说明:
通常情况下,observerOptions 在应用中是静态的,因此可以将其从依赖数组中移除,使 useEffect 仅在挂载时运行一次。但为了提供最大的灵活性,我们将其包含在依赖数组中。如果 observerOptions 确实是动态变化的,那么当它变化时,重新创建 IntersectionObserver 是正确的行为。在大多数实际场景中,使用者会使用 useMemo 来稳定 observerOptions 对象。
3.3 触发昂贵计算
第二个 useEffect 的任务是根据 hasIntersected 状态和 computeFn 的依赖项来触发计算。
// ... (之前的代码)
function useLazyValue(
computeFn,
deps = [],
observerOptions = { root: null, rootMargin: '0px', threshold: 0 }
) {
// ... (state 和 ref 声明)
// ... (第一个 useEffect: IntersectionObserver setup)
useEffect(() => {
// 只有当元素进入视口后,才执行计算
// 并且如果 computeFn 或其依赖项发生变化,也会重新计算
if (hasIntersected) {
// 在这里执行昂贵的计算
const result = computeFn();
setValue(result);
}
}, [hasIntersected, computeFn, ...deps]); // 依赖项:hasIntersected 状态、computeFn 函数本身及其依赖项
return [value, elementRef, hasIntersected];
}
关于 computeFn 和 deps 的说明:
computeFn必须是稳定的。如果computeFn在每次渲染时都重新创建(例如,直接在组件内部定义一个匿名函数),那么即使deps数组没有变化,useEffect也会因为computeFn的引用变化而频繁执行。因此,使用者应该用useCallback包装computeFn。deps数组是computeFn的内部依赖项。当这些依赖项变化时,如果hasIntersected为true,useLazyValue就会重新执行computeFn。
3.4 完整的 useLazyValue 实现
将上述所有部分整合,我们得到了一个完整的 useLazyValue Hook:
// useLazyValue.js
import { useRef, useState, useEffect, useCallback } from 'react';
/**
* 一个自定义 Hook,用于延迟执行昂贵的计算,直到关联的 DOM 元素进入视口。
*
* @param {Function} computeFn - 一个返回昂贵计算结果的函数。此函数应该被 useCallback 包装以确保稳定性。
* @param {Array<any>} deps - computeFn 的依赖项数组。当这些依赖项变化时,如果组件已进入视口,会重新计算。
* @param {Object} [observerOptions] - IntersectionObserver 的可选配置。
* @param {Element|null} [observerOptions.root=null] - 根元素,默认为浏览器视口。
* @param {string} [observerOptions.rootMargin="0px"] - 根元素的边距。
* @param {number|number[]} [observerOptions.threshold=0] - 交叉比例,达到此比例时触发回调。
* @returns {[any, React.MutableRefObject<HTMLElement | null>, boolean]} - 包含计算结果、用于附加到 DOM 元素的 ref,以及元素是否已进入视口的状态。
*/
function useLazyValue(
computeFn,
deps = [],
observerOptions = { root: null, rootMargin: '0px', threshold: 0 }
) {
// 存储计算结果,初始为 undefined
const [value, setValue] = useState(undefined);
// 标记元素是否已进入视口。
// 如果在 SSR 环境或不支持 IntersectionObserver,我们默认它为 true,
// 这样在客户端 hydration 后,计算可以立即进行。
const [hasIntersected, setHasIntersected] = useState(
typeof window === 'undefined' || !window.IntersectionObserver
);
// 用于关联到需要观察的 DOM 元素的 ref
const elementRef = useRef(null);
// 用于存储 IntersectionObserver 实例的 ref
const observerRef = useRef(null);
// Effect 1: 设置和清理 IntersectionObserver
useEffect(() => {
// 如果已经确定为已相交(例如在 SSR 或不支持的浏览器中),则无需设置观察者
if (hasIntersected) {
return;
}
const currentElement = elementRef.current;
// 如果没有目标元素,则不创建观察者
if (!currentElement) {
return;
}
// 创建 IntersectionObserver 实例
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
// 更新 hasIntersected 状态
setHasIntersected(entry.isIntersecting);
},
observerOptions
);
observerRef.current = observer;
observer.observe(currentElement);
// 清理函数:在组件卸载时断开观察者
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, [hasIntersected, observerOptions]); // 依赖 hasIntersected 是为了在 SSR 场景下,一旦变为 true,就停止观察者设置
// Effect 2: 触发昂贵的计算
useEffect(() => {
// 只有当元素进入视口后,才执行计算
// 并且如果 computeFn 或其依赖项发生变化,会重新计算
if (hasIntersected) {
const result = computeFn();
setValue(result);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasIntersected, computeFn, ...deps]); // 依赖项:hasIntersected 状态、computeFn 函数本身及其依赖项
return [value, elementRef, hasIntersected];
}
export default useLazyValue;
关于 hasIntersected 初始值和 useEffect 依赖的调整:
- 我们将
hasIntersected的初始值设置为true,如果window不存在或IntersectionObserver不受支持。这样确保在非浏览器环境下(如 SSR)或旧浏览器中,计算会在客户端 hydration 后立即执行,而不是永远不执行。 - 在
useEffectfor observer 中,添加hasIntersected作为依赖。一旦hasIntersected变为true(无论是通过实际交叉还是初始值),我们就不再需要设置或重新设置观察者了(因为计算条件已经满足)。这优化了在某些情况下的观察者生命周期。
3.5 Hook 返回值的增强
除了计算结果和 ref,我们还返回了 hasIntersected 状态。这允许使用者根据元素是否进入视口来渲染不同的加载占位符或骨架屏。
| 返回值名称 | 类型 | 描述 |
|---|---|---|
value |
any |
昂贵计算的结果。在计算完成前为 undefined。 |
elementRef |
React.MutableRefObject<HTMLElement | null> |
一个 ref 对象,必须将其附加到需要被观察的 DOM 元素上。 |
hasIntersected |
boolean |
如果元素已进入视口(或在不支持 IntersectionObserver 的环境中),则为 true。 |
4. useLazyValue 的使用示例
现在我们来看看如何在 React 组件中使用这个 useLazyValue Hook。
4.1 模拟昂贵计算
首先,我们创建一个模拟的昂贵计算函数。
// utils.js
export function simulateExpensiveCalculation(data, delay = 2000) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Performing expensive calculation for data: ${data}`);
// 假设这里执行了复杂的数组处理、图形渲染等
const result = `Processed: ${data.toUpperCase()} - ${new Date().toLocaleTimeString()}`;
resolve(result);
}, delay);
});
}
4.2 在组件中使用 useLazyValue
// ExpensiveCard.js
import React, { useState, useCallback, useMemo } from 'react';
import useLazyValue from './useLazyValue'; // 假设useLazyValue.js在同级目录
import { simulateExpensiveCalculation } from './utils'; // 假设utils.js在同级目录
function ExpensiveCard({ id, initialData }) {
const [data, setData] = useState(initialData);
// 1. 使用 useCallback 包装昂贵的计算函数,确保其稳定性
// 只有当 data 或 id 变化时,这个函数才会重新创建
const computeExpensiveValue = useCallback(async () => {
// 模拟异步和耗时操作
const result = await simulateExpensiveCalculation(`${data}-${id}`);
return result;
}, [data, id]); // computeExpensiveValue 的依赖项
// 2. 使用 useLazyValue Hook
// 注意:我们将 computeExpensiveValue 的依赖项 (data, id) 也传递给 useLazyValue
const [computedResult, cardRef, hasIntersected] = useLazyValue(
computeExpensiveValue,
[data, id], // Hook 的 deps 数组与 computeExpensiveValue 的 deps 保持一致
{
rootMargin: '0px', // 元素进入视口即触发
threshold: 0.1, // 元素有 10% 可见时触发
}
);
const handleUpdateData = () => {
setData(prev => prev + ' Updated');
};
return (
<div
ref={cardRef} // 3. 将 Hook 返回的 ref 附加到需要观察的 DOM 元素上
style={{
border: '1px solid #ccc',
padding: '20px',
margin: '20px auto',
width: '80%',
minHeight: '200px', // 确保有足够的高度来滚动
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: hasIntersected ? '#f0f8ff' : '#f8f8f8',
transition: 'background-color 0.3s ease',
}}
>
<h3>卡片 ID: {id}</h3>
<p>初始数据: {initialData}</p>
<p>当前数据: {data}</p>
{/* 4. 根据 hasIntersected 和 computedResult 状态渲染不同内容 */}
{!hasIntersected ? (
<p style={{ color: 'gray' }}>
滚动到视口内以加载昂贵内容...
</p>
) : computedResult === undefined ? (
<p style={{ color: 'blue' }}>
加载中... (模拟昂贵计算)
</p>
) : (
<div style={{ marginTop: '15px', padding: '10px', border: '1px dashed #007bff', borderRadius: '5px', backgroundColor: '#e9f7ff' }}>
<h4>昂贵计算结果:</h4>
<p>{computedResult}</p>
</div>
)}
<button
onClick={handleUpdateData}
disabled={!hasIntersected} // 只有进入视口后才允许更新数据并触发重计算
style={{ marginTop: '15px', padding: '8px 15px', cursor: hasIntersected ? 'pointer' : 'not-allowed' }}
>
{hasIntersected ? '更新数据并重计算' : '请先滚动到视口'}
</button>
</div>
);
}
export default ExpensiveCard;
// App.js (主应用文件)
import React from 'react';
import ExpensiveCard from './ExpensiveCard';
import './App.css'; // 假设有一些基础样式,或者直接内联
function App() {
// 简单模拟长页面以测试滚动
const cardData = useMemo(() => {
return Array.from({ length: 10 }).map((_, i) => ({
id: i + 1,
initialData: `Item ${i + 1} Original`,
}));
}, []);
return (
<div className="App">
<header style={{ textAlign: 'center', padding: '20px', backgroundColor: '#282c34', color: 'white' }}>
<h1>useLazyValue 示例</h1>
<p>向下滚动以观察延迟计算的效果</p>
</header>
<div style={{ height: '500px', background: '#f0f0f0', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<p>页面顶部内容区域</p>
</div>
{cardData.map((data) => (
<ExpensiveCard key={data.id} {...data} />
))}
<div style={{ height: '500px', background: '#f0f0f0', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<p>页面底部内容区域</p>
</div>
</div>
);
}
export default App;
在上述示例中,您会观察到:
- 页面加载时,只有屏幕内的
ExpensiveCard会开始显示“加载中…”并最终显示计算结果。 - 滚动页面,当新的
ExpensiveCard进入视口时,它们才会开始计算。 - 如果一个
ExpensiveCard已经计算完成,并且其内部数据 (data) 发生变化(通过点击“更新数据并重计算”按钮),它会立即重新执行computeExpensiveValue,因为hasIntersected为true且deps变化了。
5. 优化与注意事项
5.1 稳定 computeFn 和 observerOptions
如前所述,computeFn 应该被 useCallback 包裹,并且其依赖项应准确地传递给 useLazyValue。同样,observerOptions 如果是动态的,也应使用 useMemo 进行稳定,否则 IntersectionObserver 可能会不必要地重新创建。
// 示例:稳定 computeFn 和 observerOptions
const myComputeFn = useCallback(() => {
// ... 昂贵计算
}, [dep1, dep2]);
const myObserverOptions = useMemo(() => ({
rootMargin: '100px',
threshold: 0.5,
}), []); // 空数组表示 options 不会改变
const [value, ref] = useLazyValue(myComputeFn, [dep1, dep2], myObserverOptions);
5.2 服务器端渲染 (SSR) 兼容性
IntersectionObserver 是一个浏览器 API。在服务器端渲染时,window 对象不可用。我们的 Hook 已经通过 typeof window === 'undefined' 判断来处理了这种情况:在 SSR 环境中,hasIntersected 默认设置为 true,这意味着计算会在客户端 hydration 后立即执行。这是一种合理的降级策略,因为在服务器上我们无法得知元素是否在视口中,最好的做法是假设它最终会可见。
5.3 初始加载状态
在 computeFn 异步执行期间,value 将是 undefined。您可以使用 computedResult === undefined && hasIntersected 来判断是否处于加载状态,从而显示骨架屏或加载指示器。
5.4 严格模式下的行为
在 React 的严格模式下,useEffect 的清理函数可能会在开发模式下被调用两次(用于模拟组件卸载和重新挂载)。我们的 observer.disconnect() 逻辑是幂等的,因此不会引起问题。
5.5 性能考虑:阈值 (Threshold) 和根边距 (Root Margin)
threshold:0(默认): 元素只要有 1 像素进入视口就触发。1: 元素完全进入视口才触发。[0, 0.25, 0.5, 0.75, 1]: 会在元素可见性达到这些比例时多次触发回调。对于useLazyValue而言,我们通常只关心第一次进入视口,所以0或一个小数值通常是最好的选择。
rootMargin: 允许在元素进入视口前就触发观察者。例如,rootMargin: '200px'会让元素在距离视口还有 200px 时就被视为“进入”,从而提前开始计算。这对于平滑的用户体验(避免用户滚动到空白区域再等待内容加载)非常有用。
5.6 多个 useLazyValue 实例
每个 useLazyValue 实例都会创建自己的 IntersectionObserver。对于少量的延迟加载组件,这没有问题。但如果页面上同时有成百上千个独立的 IntersectionObserver 实例,可能会引入一些开销。在极端情况下,可以考虑使用一个共享的 IntersectionObserver 实例来观察多个目标,但这种优化通常只有在遇到实际性能瓶颈时才需要。对于 useLazyValue 的设计目的而言,这种单实例的实现已经足够高效。
5.7 可访问性 (Accessibility)
延迟加载内容通常对可访问性没有负面影响,因为屏幕阅读器等辅助技术通常会等待 DOM 准备就绪。然而,如果加载状态持续时间很长,确保有适当的 ARIA 属性(如 aria-busy)来指示内容正在加载会更好。
6. 结语
通过 useLazyValue,我们成功地将 React 的声明式 UI 与 Web API 的强大能力结合起来,创建了一个既实用又高效的自定义 Hook。它解决了在大型应用中处理昂贵计算时的性能瓶颈问题,极大地提升了用户体验。
理解 IntersectionObserver 的工作原理及其与 React Hooks 的结合方式,是现代前端工程师工具箱中不可或缺的一部分。掌握这种延迟加载策略,将使您能够构建更流畅、更响应迅速的 Web 应用程序。希望今天的探讨能对您的日常开发工作有所启发和帮助。
感谢大家的聆听!