一、如何优化DOM树解析过程?
(一) 根本原则:理解关键渲染路径(CRP)
优化 DOM 解析的核心在于缩短关键渲染路径。即最小化、优化或避免阻塞 DOM 和 CSSOM 构建的因素。
DOM(文档对象模型):HTML 被解析后生成的树状结构。CSSOM(CSS对象模型):CSS 被解析后生成的树状结构。渲染树(Render Tree):DOM 和 CSSOM 结合后生成,用于计算布局和绘制。
阻塞关系:
HTML 解析 -> 构建 DOM:默认过程。
会阻塞 DOM 构建:因为 JS 可能会修改 DOM 结构,所以浏览器会停止 HTML 解析,先下载(如果是外部脚本)并执行 JS,然后再继续。CSS 会阻塞 JS 的执行:因为 JS 可能会请求 CSS 信息,所以浏览器会确保所有之前的 CSS(特别是
<script>
标签之前的CSS)都已被下载并解析为 CSSOM 后,才执行 JS。CSS 不会阻塞 DOM 解析,但会阻塞渲染:浏览器会等 CSSOM 构建完成后才进行渲染(避免“无样式内容闪烁”)。
<script>
(二) HTML 层面的优化(减少DOM的“量”和“复杂度”)
这是最直接优化解析过程的方法。解析一个更小、更简单的树总是更快。
最小化 DOM 深度和节点数量:
原因:更少的节点意味着更少的内存占用、更快的样式计算、更快的布局重排(Reflow)。做法:
避免不必要的包装
或
<div>
。现代 CSS(如 Flexbox、Grid)可以减少用于布局的冗余标签。使用语义化标签(如
<span>
,
<article>
,
<section>
)而不是一堆
<nav>
,它们在结构上更清晰。定期审查代码,移除僵尸节点或注释掉的代码块。
<div>
移除空白和注释:
原因:文本节点(包括空白符)也是 DOM 节点。大量的空白和注释会增加不必要的节点数量。做法:在生产环境中使用构建工具(如 Webpack, Gulp, Vite)对 HTML 进行压缩(Minify),移除所有不必要的字符。
使用 HTML 惰性加载属性:
:对
loading="lazy"
和
<img>
使用此属性。它不会加快初始 DOM 解析,但会推迟这些非关键资源的加载,减少主线程的初始压力,让浏览器更专注于解析和渲染核心内容。
<iframe>
<img src="image.jpg" loading="lazy" alt="...">
<iframe src="content.html" loading="lazy"></iframe>
(三) CSS 层面的优化(减少对CSSOM的阻塞)
精简和压缩 CSS:
使用工具(如 cssnano、PurgeCSS)移除未使用的 CSS 规则和空白字符。更少的 CSS 规则意味着更快的 CSSOM 构建。
避免使用
:
@import
原因:
会在 CSS 文件中发起一个新的 HTTP 请求,并且是同步的。它会阻止浏览器并行下载其他资源,直到该
@import
的资源被下载和解析。做法:始终使用
@import
标签在 HTML 中引入 CSS,这样可以并行下载。
<link>
将 CSS 放在头部(
):
<head>
原因:让浏览器尽早发现 CSS 并开始构建 CSSOM。由于 CSS 不会阻塞 DOM 解析,但会阻塞渲染,尽早加载可以保证浏览器在有 DOM 和 CSSOM 后能立即渲染,减少白屏时间。
使用媒体查询(Media Queries):
对非立即需要的 CSS(如打印样式、特定屏幕尺寸的样式)使用
属性。浏览器会以低优先级下载它们,避免它们阻塞关键渲染路径。
media
<!-- 关键CSS,阻塞渲染 -->
<link rel="stylesheet" href="styles.css">
<!-- 非关键CSS,不阻塞渲染 -->
<link rel="stylesheet" href="print.css" media="print">
<link rel="stylesheet" href="large-screen.css" media="(min-width: 1200px)">
(四) JavaScript 层面的优化(减少对DOM构建的阻塞)
这是优化 DOM 解析的重中之重,因为 JS 是主要的阻塞源。
将 JavaScript 放在底部(
之前):
</body>
经典方法。将所有的
标签放在页面内容之后。这样 DOM 的解析基本完成,不会被 JS 所阻塞。
<script>
使用
和
async
属性:
defer
(异步):
async
脚本的下载不会阻塞 HTML 解析。脚本下载完成后会立即执行,此时会阻塞 HTML 解析。适用于独立且不依赖DOM或其他脚本的第三方脚本(如 analytics)。
(延迟):
defer
脚本的下载不会阻塞 HTML 解析。脚本会等到 HTML 解析完全完成后(
事件之前),按顺序执行。适用于需要操作DOM但又不急的脚本,或者有依赖关系的脚本。
DOMContentLoaded
<!-- 可能会在中间执行,阻塞解析 -->
<script async src="script.js"></script>
<!-- 保证在最后执行,不阻塞解析 -->
<script defer src="script.js"></script>
避免使用
:
document.write()
在现代浏览器中,尤其是在异步或延迟的脚本中,
会破坏 DOM 结构,并可能导致浏览器执行完整的页面重解析,性能极差。
document.write()
使用
或
requestAnimationFrame
拆分长任务:
setTimeout
如果必须执行大量的 DOM 操作,不要一次性做完。这会造成主线程长时间被占用,导致页面卡顿。可以将任务拆分成小块,在浏览器的空闲时间执行。
使用虚拟DOM(Virtual DOM)库:
像 React、Vue 这样的框架使用 Virtual DOM。它们先在内存中的 JavaScript 对象(虚拟DOM)上进行更改,然后通过高效的 Diff 算法计算出最小化的变更集,最后再一次性应用到真实 DOM 上。这大大减少了直接操作真实 DOM 的次数,而 DOM 操作是昂贵的。
(五) 其他高级优化
预加载和预连接:
使用
告诉浏览器以高优先级下载关键资源(如关键CSS、Web字体)。使用
<link rel="preload">
或
<link rel="preconnect">
提前与第三方源建立连接,减少 DNS 查询和 TCP 握手时间。
<link rel="dns-prefetch">
<link rel="preload" href="critical.css" as="style">
<link rel="preconnect" href="https://fonts.gstatic.com">
服务器端渲染(SSR):
原理:在服务器上生成页面的初始 HTML。用户收到时已经有一个完整的 DOM 结构,JavaScript 再随后激活(Hydrate)交互功能。优势:极大改善首屏加载时间和可交互时间,因为浏览器无需等待所有 JS 下载和执行完就能显示内容。
二、DNS预解析是什么?怎么实现?
(一) 什么是 DNS 预解析?
DNS 预解析(DNS Prefetching) 是一种前端性能优化手段,它允许浏览器在后台提前执行第三方域名的 DNS 解析,从而减少后续实际请求资源时的延迟。
(二) 为什么需要它?—— 解决什么问题
要理解它的价值,我们需要先看一个网络请求的生命周期。当浏览器需要从另一个域名(例如,从
去请求
https://www.example.com
)获取资源时,大致需要以下步骤:
https://cdn.example-network.com/image.jpg
DNS 解析:浏览器需要找出
这个域名对应的真实服务器 IP 地址。这个过程就是 DNS 查询。TCP 握手:浏览器拿到 IP 后,会与服务器建立 TCP 连接(通常是三次握手)。TLS 协商(如果是 HTTPS):如果使用 HTTPS,还需要进行 TLS 握手以建立安全连接。发送请求 & 接收响应:连接建立后,浏览器才真正发送 HTTP 请求并等待服务器返回资源。
cdn.example-network.com
DNS 解析是第一步,但它是一个潜在的瓶颈:
它通常需要花费 20-120 ms。它必须在一个域名的第一次请求时发生。如果页面中存在多个来自同一新域名的资源,每个资源都需要等待 DNS 解析完成,就会造成排队和延迟(虽然浏览器有缓存,但第一次无法避免)。
DNS 预解析的作用就是提前完成第一步。它告诉浏览器:“我稍后会需要从这个域名加载资源,你现在有空的时候先帮我把 DNS 解析了吧。” 这样,当浏览器真正需要请求该域名的资源时,DNS 解析这一步已经完成,可以直接建立连接,节省了宝贵的时间,显著提升了页面加载性能,特别是对于使用了大量第三方资源(字体、分析脚本、广告、CDN 资源等)的网站。
(三) 如何实现 DNS 预解析?
实现 DNS 预解析非常简单,主要通过在你的 HTML 文档的
部分添加特定的
<head>
标签来实现。
<link>
1. 手动添加 Link 标签(最常用)
这是最直接、兼容性最好的方法。
语法:
<link rel="dns-prefetch" href="https://需要预解析的域名">
:明确指示浏览器这是一个 DNS 预解析指令。
rel="dns-prefetch"
:指定需要预解析的域名。注意:这里只需要指定协议和域名,不需要指定具体的路径。协议(
href
)最好写上,但即使只写
https:
也可以。
//domain.com
示例:
假设你的页面将要从以下第三方服务加载资源:
Google Fonts(字体):
Google Analytics(分析):
https://fonts.googleapis.com
你自己的 CDN:
https://www.google-analytics.com
https://cdn.yourdomain.com
你可以在 HTML 的
中添加:
<head>
<head>
...
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://www.google-analytics.com">
<link rel="dns-prefetch" href="https://cdn.yourdomain.com">
...
</head>
2. 通过 HTTP 响应头实现
除了在 HTML 中写入,服务器还可以通过在 HTTP 响应头中返回
字段来指示浏览器进行预解析。这对于动态页面或者无法直接修改 HTML 模板的情况非常有用。
Link
语法(在服务器的响应配置中设置):
Link: <https://fonts.googleapis.com>; rel=dns-prefetch
示例(在 Nginx 配置文件中):
server {
listen 80;
server_name yourdomain.com;
location / {
...
add_header Link "<https://fonts.googleapis.com>; rel=dns-prefetch";
...
}
}
(注意:添加多个域名需要多条
指令,但需注意 Nginx 中
add_header
的继承规则)
add_header
3. 浏览器自动解析
现代浏览器(如 Chrome、Firefox)已经非常智能。它们会解析在 HTML 中遇到的各类资源链接(如
,
<a>
,
<img>
,
<link>
,
<script>
标签的
<style>
或
href
属性),并自动为这些域名进行 DNS 预解析,甚至不需要开发者手动添加
src
指令。
dns-prefetch
但是,手动添加仍然在以下情况下至关重要:
非标准或隐藏的资源:通过 JavaScript 动态加载的资源、CSS 中的
规则里引用的字体文件、通过
@font-face
引用的背景图片等。浏览器在初始解析 HTML 时无法发现这些资源,因此不会自动预解析它们的域名。重定向域名:如果你知道一个 URL 最终会重定向到另一个域名,可以预解析最终的目标域名。确保高优先级:手动添加可以确保浏览器更早地注意到这些域名并优先处理。
url()
(四) 最佳实践和注意事项
仅对跨域域名使用:对于你网站自身的域名,DNS 解析通常已经在初始请求页面时完成了,无需再预解析。应专注于第三方域名。不要过度使用:每个预解析请求虽然消耗极小的带宽和 CPU,但如果一次性预解析几十个域名,可能会与关键资源争夺网络带宽和 CPU 资源。只预解析最关键、最可能用到的第三方域名。与 Preconnect 结合使用:
只完成了 DNS 解析。还有一个更“强大”的兄弟指令叫
dns-prefetch
。
preconnect
不仅会提前进行 DNS 解析,还会提前进行 TCP 握手和 TLS 协商。它建立了完整的连接,成本更高,但收益也更大。
preconnect
建议:对极其关键的核心第三方源使用
,对其他的使用
preconnect
dns-prefetch
<!-- 建立完整连接,用于最关键的资源 -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<!-- 仅DNS解析,用于其他重要资源 -->
<link rel="dns-prefetch" href="https://www.google-analytics.com">
(注意:使用
时,通常建议加上
preconnect
属性)
crossorigin
兼容性:
得到了所有主流浏览器的广泛支持,可以放心使用。
dns-prefetch
的兼容性也非常好,但略新于
preconnect
。
dns-prefetch
三、如何防止CSS阻塞渲染?
(一) 核心结论先行
CSS 默认会阻塞渲染。目标是只让关键CSS阻塞渲染,而对非关键CSS采用异步加载策略,从而让页面内容尽快呈现给用户。
(二) 为什么CSS会阻塞渲染?
浏览器渲染页面的“关键渲染路径”如下:
构建 DOM:解析 HTML 生成 DOM 树。构建 CSSOM:解析 CSS 生成 CSSOM 树。合并:将 DOM 和 CSSOM 合并成渲染树(Render Tree)。布局:计算每个节点的位置和大小(Layout)。绘制:将像素绘制到屏幕上(Paint)。
浏览器之所以会阻塞渲染,是因为它要避免 FOUC(Flash of Unstyled Content),即“无样式内容闪烁”。如果浏览器在 CSS 加载完之前就显示了已解析的 HTML,用户会先看到丑陋的无样式页面,然后突然样式被应用,页面发生跳动。这是一种很差的用户体验。
因此,浏览器的规则是:CSSOM 构建完成之前,浏览器不会渲染页面。
(三) 如何防止CSS阻塞渲染?(实战策略)
以下方法按推荐度和实用性排序。
1. 策略一:使用
media
属性进行条件加载(最简单、最有效)
media
这是最符合Web标准且高效的方法。你可以通过
属性告诉浏览器某些CSS资源只在特定条件下使用,浏览器会据此调整加载优先级。
media
核心思想:将CSS分为关键CSS和非关键CSS。关键CSS:用于首屏、Above-the-fold(折叠上方)内容的样式。必须同步加载,阻塞渲染。非关键CSS:打印样式、特定屏幕尺寸的样式、折叠下方的样式等。可以异步加载。
实现方法:
在
标签上使用
<link>
属性。
media
<!-- 关键CSS:始终阻塞渲染 -->
<link rel="stylesheet" href="core.css">
<!-- 非关键CSS:指定媒体查询 -->
<link rel="stylesheet" href="print.css" media="print"> <!-- 仅用于打印,不阻塞渲染 -->
<link rel="stylesheet" href="large-screen.css" media="(min-width: 1200px)"> <!-- 大屏才用,不阻塞渲染 -->
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)"> <!-- 小屏才用,不阻塞渲染 -->
浏览器行为:
浏览器会下载所有CSS文件(优先级不同)。但对于标记了
的CSS,浏览器会以最低优先级(Lowest Priority)异步下载它们,不会阻塞渲染和
media
事件。下载完成后,浏览器会检查
DOMContentLoaded
条件。如果条件满足(如屏幕宽度变化),该CSS才会被应用,并可能触发重绘。
media
2. 策略二:异步加载CSS(更主动的控制)
如果你有一些必须加载但又不希望阻塞渲染的CSS(比如首屏非关键CSS),可以使用JavaScript技巧来异步加载。
使用
和
preload
(现代、推荐)
onload
告诉浏览器以高优先级获取资源,但不确定如何执行。结合
preload
事件,我们可以在加载完成后将其转换为样式表。
onload
html
<link rel="preload" href="non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="non-critical.css"></noscript>
:高速浏览器以高优先级去获取这个样式文件,但不会应用它。
rel="preload" as="style"
:当资源加载完成后,将
onload="..."
属性改为
rel
,浏览器就会应用这些样式。
stylesheet
:为禁用JavaScript的用户提供兜底方案。
<noscript>
动态创建
标签
<link>
使用JavaScript动态创建并插入一个
标签。通过JS加载的资源默认是异步的。
<link>
<script>
// 在<head>底部添加一段脚本
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'non-critical.css';
// 插入到document中开始加载,但不会阻塞渲染
document.head.appendChild(link);
</script>
3. 策略三:内联关键CSS并异步加载其余部分(终极优化)
这是最高效的混合方案,常与“首屏优化”结合。
提取关键CSS:使用工具(如
、
critical
)将用于首屏内容渲染的CSS提取出来。内联到
penthouse
:将提取出的关键CSS直接内嵌到HTML的
<head>
标签中。这消除了一个网络请求,保证了首屏样式立即可用。异步加载完整CSS:然后使用上述策略二的方法异步加载完整的CSS文件。
<style>
html
<head>
<style>
/* 这里内联提取出的关键CSS */
body { font-family: sans-serif; }
.hero { color: #333; }
...
</style>
<script>
// 异步加载完整的CSS
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'full-styles.css';
link.media = 'print'; // trick:先设置为print,加载完成后改回all
link.onload = function() {
link.media = 'all';
};
document.head.appendChild(link);
</script>
</head>
4. 策略四:HTTP/2 Server Push(服务器推送)
如果你使用HTTP/2,服务器可以在响应HTML请求时,主动将关键的CSS文件“推送”给浏览器, before the browser even parses the HTML and asks for it. 这可以省去一个网络往返(RTT)时间。
这需要在服务器端进行配置(如Nginx、Node.js)。这是一种高级优化手段,通常与其他策略结合使用。
# Nginx 配置示例
location / {
http2_push /styles/core.css;
...
}
四、如果一个列表有100000个数据,这个该怎么进行展示?
(一) 终极解决方案:虚拟列表 (Virtual List)
这是处理大规模列表渲染的标准答案和唯一推荐的最佳实践。
1. 核心原理
只渲染可视区域 (Viewport):计算当前滚动位置,只渲染用户能看到的那部分列表项(比如20-30条)。模拟滚动内容:用一个足够高的容器(通过CSS高度或一个填充元素)来模拟整个长列表的滚动条。动态渲染:监听滚动事件,当用户滚动时,动态计算新的可视区域,销毁移出视口的DOM节点,并创建新进入视口的DOM节点。
2. 实现虚拟列表的关键步骤
HTML 结构:
<div class="viewport">
<div class="list-container">
<!-- 这里只动态放置当前可视区域的 item -->
<div class="list-item">Item {{startIndex}}</div>
<div class="list-item">Item {{startIndex + 1}}</div>
...
<div class="list-item">Item {{endIndex}}</div>
</div>
</div>
:固定高度的视口,负责产生滚动条。
viewport
:其高度设置为
list-container
,用于模拟总滚动范围。
(总数据量 * 每项高度)
:只有可视区域内的项才会被真实创建和渲染。
.list-item
JavaScript 逻辑:
计算总高度:
计算可视区域数量:
containerHeight = totalCount * itemHeight
(Buffer是缓冲区,多渲染几条防止滚动白屏)监听滚动事件:监听
visibleCount = Math.ceil(viewportHeight / itemHeight) + buffer
的
viewport
事件(务必使用节流!)。计算起始索引:
scroll
计算结束索引:
startIndex = Math.floor(scrollTop / itemHeight)
计算偏移量:为了保持滚动条正确,需要将可视列表向下偏移
endIndex = startIndex + visibleCount
像素。渲染子集:从
startIndex * itemHeight
中切片取出
allListData
范围的数据进行渲染。
[startIndex, endIndex]
3. 为什么不自己造轮子?—— 使用现成的库
虚拟列表自己实现起来细节非常多(动态高度、滚动节流、平滑滚动等),强烈推荐使用成熟的库:
React:
react-window (轻量级,作者是React核心团队)react-virtualized (功能更全面,但体积更大)
Vue:
vue-virtual-scroller
Angular:
@angular/cdk/scrolling
示例(使用 react-window):
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Row {index} (数据: {data[index]})</div>
);
const Example = () => (
<List
height={500} // 视口高度
itemCount={100000} // 总条数
itemSize={35} // 每项高度
width={'100%'} // 宽度
>
{Row}
</List>
);
几行代码就能完美解决10万条数据渲染问题。
五、怎么统计页面的性能指标?
(一) 核心 Web 指标 (Core Web Vitals)
这是Google提出的、以用户为中心的关键性能指标,现已直接影响搜索引擎排名。
指标 |
描述 |
优化目标 |
测量方法 |
LCP (Largest Contentful Paint) |
测量加载性能。可视区域内最大内容元素(如图片、视频、大文本块)渲染完成的时间。 |
< 2.5s (良好) |
|
FID (First Input Delay) |
测量交互性。从用户第一次与页面交互(点击链接、按钮等)到浏览器实际能够响应该交互的时间。 |
< 100ms (良好) |
|
CLS (Cumulative Layout Shift) |
测量视觉稳定性。衡量页面在整个生命周期中发生的所有意外布局偏移的分数。 |
< 0.1 (良好) |
|
如何测量Core Web Vitals:
使用 field tools(字段工具 – 真实用户数据):
Chrome UX Report (CrUX):提供来自真实Chrome用户的匿名性能数据。Google Search Console:直接报告你网站在核心Web指标上的表现。
使用
库(官方推荐):这是最准确、最简单的方式,它帮你处理了所有兼容性和复杂逻辑。
web-vitals
npm install web-vitals
import {getLCP, getFID, getCLS} from 'web-vitals';
getLCP(console.log);
getFID(console.log);
getCLS(console.log);
// 或发送到你的分析平台
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
使用 lab tools(实验室工具 – 模拟环境):
Lighthouse:集成在Chrome DevTools中,或通过命令行、CI运行,提供模拟环境的性能评估和优化建议。PageSpeed Insights:结合了Lab Data(Lighthouse)和Field Data(CrUX),给出全面报告。
(二) 传统性能指标 (Navigation Timing API)
(已废弃) 和
performance.timing
API 提供了页面加载生命周期的详细时间点。
PerformanceNavigationTiming
如何获取这些指标:
// 获取最新的 navigation 条目
const [navigationEntry] = performance.getEntriesByType('navigation');
// 计算关键时间点
const metrics = {
// DNS 查询时间
dnsLookupTime: navigationEntry.domainLookupEnd - navigationEntry.domainLookupStart,
// TCP 连接时间
tcpConnectTime: navigationEntry.connectEnd - navigationEntry.connectStart,
// 请求响应时间(TTFB)
timeToFirstByte: navigationEntry.responseStart - navigationEntry.requestStart,
// 内容加载时间
domContentLoadedTime: navigationEntry.domContentLoadedEventEnd - navigationEntry.domContentLoadedEventStart,
// 页面完全加载时间
fullLoadTime: navigationEntry.loadEventEnd - navigationEntry.loadEventStart,
// 白屏时间 (粗略计算)
whiteScreenTime: navigationEntry.responseStart - navigationEntry.navigationStart,
};
console.table(metrics);
(三) 使用
PerformanceObserver
API(现代标准方法)
PerformanceObserver
这是监听各种性能条目的推荐方式,可以异步获取指标,不会阻塞主线程。
示例:监听 LCP
javascript
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry);
// 发送到你的监控系统
// sendToAnalytics(entry);
}
});
// 订阅 'largest-contentful-paint' 类型的条目
observer.observe({entryTypes: ['largest-contentful-paint']});
其他可监听的条目类型:
:获取
'paint'
(FP) 和
first-paint
(FCP)。
first-contentful-paint
:监听布局偏移,用于计算 CLS。
'layout-shift'
:监听长任务(耗时超过50ms的任务),判断是否存在阻塞主线程的任务。
'longtask'
:监听所有资源(图片、脚本、样式等)的加载性能。
'resource'
(四) 使用
console.time()
和
console.timeEnd()
console.time()
console.timeEnd()
对于测量代码块或自定义功能的执行时间非常有用。
console.time('myFunction');
myExpensiveFunction(); // 你要测量的函数
console.timeEnd('myFunction'); // 控制台输出: myFunction: 125.5ms
(五) 实践:构建一个简单的性能监控器
你可以将上述方法组合起来,构建一个轻量级的性能监控脚本。
// perf-monitor.js
(function monitorPerf() {
// 1. 监听 Core Web Vitals (使用 web-vitals 库是更好的选择)
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.entryType === 'largest-contentful-paint') {
console.log('LCP:', entry.startTime);
sendToAnalytics('LCP', entry.startTime);
}
if (entry.entryType === 'layout-shift') {
// 需要累积计算 CLS
console.log('Layout Shift:', entry);
}
});
});
observer.observe({entryTypes: ['largest-contentful-paint', 'layout-shift']});
// 2. 监听首次输入延迟 (FID)
let firstInputReceived = false;
const onFirstInput = (entry) => {
if (!firstInputReceived) {
firstInputReceived = true;
const fid = entry.processingStart - entry.startTime;
console.log('FID:', fid);
sendToAnalytics('FID', fid);
// 移除事件监听器
['mousedown', 'keydown', 'touchstart'].forEach(event => {
document.removeEventListener(event, onFirstInput, true);
});
}
};
['mousedown', 'keydown', 'touchstart'].forEach(event => {
document.addEventListener(event, onFirstInput, { capture: true, once: true });
});
// 3. 获取 Navigation Timing 数据
setTimeout(() => { // 确保在 onload 后执行
const [navigationEntry] = performance.getEntriesByType('navigation');
if (navigationEntry) {
const TTFB = navigationEntry.responseStart - navigationEntry.requestStart;
console.log('TTFB:', TTFB);
sendToAnalytics('TTFB', TTFB);
}
}, 0);
function sendToAnalytics(metricName, value) {
// 这里实现你的数据发送逻辑,例如:
// navigator.sendBeacon('https://your-analytics-endpoint.com', `${metricName}=${value}`);
console.log(`Sending ${metricName}: ${value}`);
}
})();
六、虚拟DOM一定更快吗?
不一定。虚拟DOM并不总是更快,它的主要优势在于为开发者提供了更好的开发体验和可维护性,并在大多数常见场景下提供了“足够好”的性能。
虚拟DOM更像是一种 “性能妥协” 或 “可维护性换性能” 的策略,而不是一种纯粹的性能优化黑魔法。
(一) 虚拟DOM更快的场景(优势)
虚拟DOM在以下情况下通常比直接操作真实DOM更有优势:
复杂的、频繁的UI更新:
场景:一个大型表格的排序、过滤,或者一个具有复杂状态的交互式应用。原因:直接操作DOM需要精确计算哪些节点需要更新、添加、删除。当逻辑变得复杂时,很容易出错或产生冗余操作。虚拟DOM的Diff算法可以自动帮你计算出最小变更集,虽然多了一次Diff的计算开销,但避免了人工计算可能带来的大量不必要的DOM操作。减少并批量化DOM操作带来的收益,远大于虚拟DOM本身Diff计算的开销。
声明式编程带来的开发效率提升:
场景:任何规模的现代Web应用。原因:开发者不需要关心“如何更新DOM”,只需要关心“数据是什么”(状态 => UI)。这极大地降低了代码的复杂度和心智负担,减少了Bug。虽然这本身不是速度优势,但开发效率的提升是巨大的。
跨平台渲染:
场景:React Native、SSR(服务端渲染)、小程序等。原因:虚拟DOM是一个普通的JavaScript对象,它不依赖于浏览器环境。你可以在服务器端根据虚拟DOM生成HTML字符串(SSR),也可以在原生移动端将其渲染为原生组件(React Native)。这是直接操作真实DOM绝对无法做到的。
(二) 虚拟DOM更慢的场景(劣势)
虚拟DOM在以下情况下可能比直接操作真实DOM更慢:
极致的性能优化场景:
场景:需要每秒60帧的高频动画、对延迟极其敏感的拖拽操作、大型数据可视化项目(如Canvas、WebGL)。原因:虚拟DOM的Diff和协调(Reconciliation)过程发生在JavaScript层面,这会占用主线程时间。对于需要极致性能的场景,手动精确控制每一帧的DOM更新,避免任何不必要的JS计算,才是最快的方案。这也是为什么Three.js、D3.js等库通常不采用虚拟DOM的原因。
简单的、一次性的静态页面:
场景:一个简单的宣传页、一个表单提交页面。原因:如果页面几乎没有交互,或者交互非常简单,引入整个虚拟DOM库(如React、Vue)的运行时开销是完全不必要的。直接使用原生JS或轻量级工具会更高效。
手动优化到极致的DOM操作:
场景:一个顶级前端专家为一个特定功能编写了高度优化的原生JS代码。原因:理论上,任何算法都无法比“预先知道 exactly 要做什么”的手动操作更快。虚拟DOM的Diff是“猜测”哪里需要变化,而专家可以直接“命中”目标。但这种情况在大型项目中很少见,且维护成本极高。
(三) 核心:虚拟DOM的价值到底是什么?
让我们用一个简单的比喻来理解:
直接操作DOM:像是用汇编语言编程。你可以写出性能极高的代码,但非常繁琐、容易出错、难以维护和协作。使用虚拟DOM:像是用高级语言(如C++/Java) 编程。编译器(虚拟DOM的Diff算法)会帮你生成最终的机器码(DOM操作)。虽然生成的代码可能不是最优的,但它保证了可接受的平均性能和极高的开发效率与可维护性。
虚拟DOM的真正价值在于:
提供了性能的下限保障:它通过Diff算法避免了最蠢的DOM操作方式(比如每次更新都重置整个
),为开发者提供了一个“还不错”的性能基线。即使是一个新手,用React写出来的应用性能通常也不会太差。解放了开发者:让开发者从手动、繁琐的DOM操作中解脱出来,专注于业务逻辑和状态管理。实现了声明式UI:
innerHTML
是这个时代最成功的UI开发范式,而虚拟DOM是实现这一范式的高效手段。
UI = f(state)
七、导致页面加载白屏时间长的原因有哪些,怎么进行优化?
白屏通常发生在页面内容被渲染出来之前的阶段。其核心原因是浏览器正在忙于加载资源、解析、编译和执行,无暇进行渲染。
(一) 白屏时间的阶段分析
要理解原因,首先要知道从输入URL到看到页面内容经历了什么:
DNS 查询 -> 2. TCP 连接 -> 3. SSL 握手 (HTTPS) -> 4. 发送请求 -> 5. 等待服务器响应 (TTFB) -> 6. 下载 HTML -> 7. 解析 HTML,构建 DOM -> 8. 遇到
和
<link>
,加载并解析 CSS、JS -> 9. 构建 CSSOM,形成渲染树 -> 10. 布局与绘制
<script>
白屏就发生在第1步到第9步完成之前。 任何一步的延迟都会导致白屏时间变长。
(二) 导致白屏时间过长的具体原因及优化方案
我们将原因分为网络层面和浏览器渲染层面。
1. 网络层面原因(资源加载慢)
资源体积过大(HTML、JS、CSS)
原因:巨大的文件需要更长的下载时间。优化:
压缩:使用
或
Gzip
压缩文本资源。代码拆分:使用 Webpack 等工具的
Brotli
语法进行动态导入,实现按需加载。Tree Shaking:移除 JavaScript 和 CSS 中未使用的代码。优化图片:使用 WebP/AVIF 格式,适当压缩,使用响应式图片(
import()
)。
srcset
网络往返次数多(RTT)
原因:DNS查询、TCP/SSL握手、重定向、多个小资源请求都会增加RTT。优化:
减少重定向:避免不必要的
重定向。预连接:使用
3xx
或
<link rel="preconnect">
提前与第三方源建立连接。HTTP/2:启用 HTTP/2,利用多路复用特性,减少多个请求的开销。CDN:使用CDN将资源分发到离用户更近的节点。
<link rel="dns-prefetch">
关键资源加载慢
原因:阻塞渲染的CSS和JS(特别是放在
中的)下载慢。优化:
<head>
预加载:使用
高优先级加载关键CSS、字体、Logo图片等。缓存:设置合理的
<link rel="preload">
,
Cache-Control
等缓存策略,减少重复请求。
ETag
2. 浏览器渲染层面原因(解析和执行慢)
CSS 阻塞渲染
原因:浏览器会等待CSSOM构建完成后再进行渲染(避免FOUC)。
优化:
内联关键CSS:将首屏内容所需的关键CSS直接内嵌到HTML的
标签中,消除一次网络请求。异步加载非关键CSS:对非首屏CSS使用
<style>
或
preload
属性异步加载。
media
<link rel="preload" href="non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<link rel="stylesheet" href="print.css" media="print"> <!-- 不阻塞渲染 -->
JavaScript 阻塞解析
原因:
标签会阻塞HTML解析,除非声明为异步。优化:
<script>
延迟/异步加载:使用
或
defer
属性,避免JS阻塞文档解析。
async
:脚本异步加载,在
defer
事件前按顺序执行。
DOMContentLoaded
:脚本异步加载,下载完成后立即执行(执行顺序不定)。
async
将非关键JS放在底部:将
标签放在
<script>
之前。避免同步XHR:绝对不要使用同步的
</body>
。
XMLHttpRequest
大量的或复杂的前端框架初始化
原因:React、Vue等框架需要先加载、解析、执行JS,然后才能开始渲染组件。优化:
服务端渲染 (SSR):这是解决首屏白屏问题的终极武器。在服务器端生成完整的HTML页面下发,浏览器可以直接渲染,然后再由客户端JS“激活”(Hydrate)交互功能。代码分割与懒加载:结合路由进行懒加载,只加载当前页面需要的JS代码。
长时间运行的主线程任务(Long Tasks)
原因:复杂的JavaScript计算(如处理大数据)会长时间占用主线程,导致浏览器无法进行渲染。优化:
任务拆分:使用
、
setTimeout
或
requestAnimationFrame
将长任务拆分成多个小块执行,让主线程有机会进行渲染。
Web Worker
(三) 优化实战 checklist
你可以按照以下步骤系统地优化白屏时间:
测量与分析(首先做!)
使用 Chrome DevTools 的 Performance 和 Network 面板录制加载过程,找到瓶颈。使用 Lighthouse 或 PageSpeed Insights 获取权威评分和优化建议。关注 FCP (First Contentful Paint) 指标。
网络优化
开启 Gzip/Brotli 压缩。配置强缓存 (
) 和协商缓存 (
Cache-Control
)。使用 HTTP/2 和 CDN。对关键资源使用
ETag
,对第三方源使用
preload
/
preconnect
。
dns-prefetch
资源优化
压缩和优化图片,使用现代格式。对 JS/CSS 进行代码拆分、Tree Shaking、压缩。内联关键CSS。移除未使用的CSS(PurgeCSS)。
JavaScript 优化
为所有非关键
添加
<script>
或
defer
。将脚本放在 body 底部。考虑使用 SSR 框架(如 Next.js, Nuxt.js)。
async
渲染优化
确保没有复杂的同步代码阻塞主线程。避免使用
引入CSS(它阻止并行加载)。
@import
八、如果某个页面有几百个函数需要执行,可以怎么优化页面的性能?
当页面有几百个函数需要执行时,最大的风险是长时间阻塞主线程(Main Thread),导致页面无法响应(卡顿)、交互延迟,甚至崩溃。
优化的核心思想是:避免一次性同步执行所有函数,将任务合理地拆分、延迟或转移到其他线程,最大限度地保持主线程的流畅。
(一) 根本问题:为什么几百个函数会带来性能问题?
浏览器的主线程是单线程的,它负责:
解析 HTML/CSS执行 JavaScript布局(Layout)绘制(Paint)
如果一个庞大的JS任务长时间占用主线程(超过 50ms 的任务被称为 Long Task/长任务),就会阻塞上述所有其他工作,用户会感觉到页面“卡住了”。
(二) 优化策略详解(从易到难)
1. 策略一:任务分片(Time Slicing)
这是最常用且有效的策略。将一个大任务拆分成无数个小任务,在每个小任务执行后,让出主线程给浏览器进行渲染和其他工作,然后再继续执行下一个分片。
实现技术:
/
setTimeout
、
setInterval
、
requestAnimationFrame
queueMicrotask
示例(使用
):
requestAnimationFrame
会在浏览器下一次重绘之前执行回调,非常适合做分片,能保证流畅性。
requestAnimationFrame
const tasks = [fn1, fn2, fn3, ..., fn500]; // 几百个待执行函数
function processTaskInFrames() {
let taskIndex = 0;
function doNextChunk() {
// 记录开始时间
const startTime = performance.now();
// 在一个 "帧"(约16.7ms)内尽可能多地执行任务
// 但必须预留几毫秒给浏览器进行布局和绘制
while (taskIndex < tasks.length && performance.now() - startTime < 4) {
tasks[taskIndex](); // 执行函数
taskIndex++;
}
if (taskIndex < tasks.length) {
// 如果还有任务,下一帧继续
requestAnimationFrame(doNextChunk);
}
}
// 启动任务分片
requestAnimationFrame(doNextChunk);
}
processTaskInFrames();
2. 策略二:使用 Web Workers
这是终极解决方案,适用于计算密集型任务(如数据处理、图像操作、复杂算法)。
原理:Web Worker 允许你在后台线程中运行脚本,与主线程完全并行。它不会阻塞主线程的UI渲染和交互。限制:
Worker 中无法操作 DOM(
,
window
对象不可用)。与主线程通信通过
document
和
postMessage
事件,数据是拷贝而不是共享的(除了
onmessage
等可使用 Transferable Objects 转移)。
ArrayBuffer
示例:
主线程 (main.js):
// 创建 Worker
const myWorker = new Worker('worker.js');
// 准备大量数据
const bigData = [...]; // 你的数据
// 发送数据到 Worker
myWorker.postMessage(bigData);
// 接收来自 Worker 的结果
myWorker.onmessage = function(e) {
const result = e.data;
console.log('Worker 执行完毕,结果是:', result);
// 用结果更新UI
};
Worker 线程 (worker.js):
// 在 Worker 内部监听消息
self.onmessage = function(e) {
const data = e.data;
const results = [];
// 在这里安全地、无阻塞地执行几百个函数
for (let i = 0; i < data.length; i++) {
// 假设 processItem 是你的一个计算函数
results.push(processItem(data[i]));
}
// 将结果发送回主线程
self.postMessage(results);
};
function processItem(item) {
// 这里是你的计算逻辑
return item * 2; // 示例
}
3. 策略三:优先级调度与空闲执行
如果你的某些函数优先级不高,可以等到浏览器空闲时再执行。
实现技术:
requestIdleCallback
会在浏览器空闲时期(一帧的末尾、没有输入事件处理等)被调用。它提供了一个
requestIdleCallback
对象,告诉你当前帧还剩余多少空闲时间。
IdleDeadline
javascript
const lowPriorityTasks = [lowFn1, lowFn2, ..., lowFn100];
function runLowPriorityTasks(deadline) {
// 当还有空闲时间且还有任务时
while (deadline.timeRemaining() > 0 && lowPriorityTasks.length > 0) {
const task = lowPriorityTasks.shift();
task();
}
// 如果还有剩余任务,下次空闲时继续
if (lowPriorityTasks.length > 0) {
requestIdleCallback(runLowPriorityTasks);
}
}
// 启动低优先级任务
requestIdleCallback(runLowPriorityTasks);
4. 策略四:函数本身的优化(治本)
审视你的几百个函数,看看是否可以从根源上优化。
算法优化:检查是否有时间复杂度高的函数,能否用更优的算法(如用
代替
Map
)?避免重复计算:使用缓存(Memoization),对于相同输入的函数,直接返回缓存的结果。
Array.find
function expensiveOperation(input) {
if (!expensiveOperation.cache) {
expensiveOperation.cache = {};
}
if (expensiveOperation.cache[input] !== undefined) {
return expensiveOperation.cache[input];
}
// ... 复杂的计算 ...
const result = ...;
expensiveOperation.cache[input] = result;
return result;
}
延迟执行:有些函数是否真的需要在页面初始化时执行?能否在用户交互时再触发?(按需执行)
(三) 决策流程图:我该用哪种策略?
你可以根据以下流程图来决定最适合的优化方案:
九、React.memo() 和useMemo() 的用法是什么,有哪些区别?
和
React.memo
都是用于优化性能的工具,但它们应用的对象和时机完全不同。
useMemo
(一) 核心区别:一句话概括
:是一个高阶组件(HOC),用于包装一个函数式组件本身,防止组件不必要的重新渲染。
React.memo
:是一个 React Hook,用在函数式组件内部,用于缓存一个昂贵的计算结果,防止计算在每次渲染时重复执行。
useMemo
它们解决的是不同层面的性能问题。
(二) 深入解析与代码示例
1.
React.memo
– 优化组件重渲染
React.memo
场景:父组件状态变化导致重渲染,但其传递给子组件的Props实际上并没有改变。
问题代码:
jsx
// 子组件:每次父组件渲染,它都会跟着渲染,即使name没变!
function ChildComponent({ name }) {
console.log('子组件被渲染了!');
return <div>{name}</div>;
}
// 父组件
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Mike');
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* 点击button,count改变,父组件重渲染。虽然name没变,但ChildComponent也会重渲染! */}
<ChildComponent name={name} />
</div>
);
}
使用
优化:
React.memo
// 用React.memo包裹子组件
const ChildComponent = React.memo(function ChildComponent({ name }) {
console.log('子组件被渲染了!'); // 现在只有name真正改变时,这里才会打印
return <div>{name}</div>;
});
// 父组件保持不变...
现在,点击按钮更新
时,
count
的Props(
ChildComponent
)经浅比较发现没有变化,它就会跳过这次渲染,直接复用上一次的渲染结果。
name
自定义比较函数:
默认是浅比较,你可以提供第二个参数来自定义比较逻辑。
const ChildComponent = React.memo(
function ChildComponent({ user }) {
return <div>{user.name}</div>;
},
// 自定义比较函数:返回true表示跳过渲染,返回false表示需要渲染
(prevProps, nextProps) => {
// 只有当user的id改变时,才重新渲染
return prevProps.user.id === nextProps.user.id;
}
);
2.
useMemo
– 优化昂贵计算
useMemo
场景:组件内部有一个需要大量计算才能得到的值,这个值在每次渲染时都被重复计算,即使依赖项没变。
问题代码:
jsx
function ExpensiveComponent({ list }) {
// 每次渲染,无论list是否改变,都会重新执行这个昂贵的计算
const sortedList = expensiveSortingFunction(list); // ⚠️ 性能瓶颈!
return <div>{sortedList.map(item => <div key={item.id}>{item.name}</div>)}</div>;
}
使用
优化:
useMemo
function ExpensiveComponent({ list }) {
// 使用useMemo缓存计算结果,只有当[list]改变时,才会重新计算
const sortedList = useMemo(() => {
return expensiveSortingFunction(list);
}, [list]); // ✅ 依赖项数组
return <div>{sortedList.map(item => <div key={item.id}>{item.name}</div>)}</div>;
}
现在,
这个昂贵的计算只会在
expensiveSortingFunction
属性发生变化时才会执行。如果组件因其他状态(如
list
)重渲染而
count
没变,
list
会直接返回上一次缓存的值。
sortedList
(三) 详细用法与区别
为了更清晰地理解,我们通过一个对比表格和具体示例来深入分析。
对比表格
特性 |
|
|
是什么 |
高阶组件 (HOC) |
React Hook |
作用对象 |
整个函数式组件 |
组件内部的某个值(计算结果) |
解决什么问题 |
避免组件不必要的重渲染(当Props未变时) |
避免昂贵的计算在每次渲染时重复执行 |
依赖项 |
自动对Props进行浅比较,也可自定义比较函数 |
显式声明依赖项数组 |
返回值 |
返回一个被记忆的(Memoized)组件 |
返回一个被记忆的(Memoized)值 |
(四) 四、如何选择?决策流程图
你应该根据你的具体需求来决定使用哪一个,下图清晰地展示了决策过程:
十、SPA首屏加载速度慢的怎么解决?
SPA(单页应用)首屏加载慢是一个极其常见且影响用户体验的问题。其根本原因是:浏览器必须首先下载、解析和执行整个应用的JavaScript包,然后才能渲染出页面。
解决这个问题的核心思路是:化整为零,按需加载,预加载关键资源,并优化传输过程。
(一) 根本原因分析
在优化之前,先理解为什么慢:
巨大的JavaScript体积:现代前端框架(React, Vue, Angular)及其依赖的运行时库、第三方包,打包后往往是一个巨大的
文件。串行加载链:浏览器必须:
bundle.js
下载HTML -> 解析HTML -> 发现JS和CSS链接 -> 下载JS -> 下载CSS -> 执行JS -> 框架初始化 -> 发起API请求 -> 渲染页面。
所有代码一次性加载:即使用户只看首页,也会加载整个应用的所有代码(关于产品、设置等页面的代码)。
(二) 系统性优化方案(从易到难)
以下方案可以组合使用,效果最佳。
1. 代码分割与懒加载 (最核心的优化)
这是解决SPA首屏问题的第一要务。原理是:将巨大的JS包拆分成多个小块(chunks),只加载当前页面(路由)所需的代码。
基于路由的代码分割:这是最自然的分割点。每个路由对应的页面组件打包成一个独立的chunk。
React (使用
+
React.lazy
)
Suspense
import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./routes/Home')); // 动态导入
const About = lazy(() => import('./routes/About'));
function App() {
return (
<Router>
<Suspense fallback={<div>Loading...</div>}> {/* 加载中的占位组件 */}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
</Router>
);
}
Vue (使用动态
)
import
const Home = () => import('./views/Home.vue');
const About = () => import('./views/About.vue');
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About }
];
Webpack:上述语法 (
) 是Webpack实现代码分割的标准方式。
import()
组件级懒加载:对于模态框、弹窗、折叠内容等非首屏立即看到的组件,也可以进行懒加载。
2. 打包分析与体积优化
“无法衡量就无法优化”。首先要知道你的bundle里是什么东西占了大部分空间。
使用打包分析工具:
# 1. 安装分析插件
npm install --save-dev webpack-bundle-analyzer
# 2. 构建时生成分析报告
npx webpack-bundle-analyzer dist/main.js
这个工具会生成一个可视化图表,清晰地展示每个依赖包的大小。
优化策略:
Tree Shaking:确保启用(Webpack默认在生产模式开启),移除未使用的代码。压缩代码:使用
压缩JS,
TerserWebpackPlugin
压缩CSS。移除重复依赖:分析工具可能会显示同一个库有两个版本,需要统一版本号。按需引入第三方库:例如,不要引入整个
CssMinimizerWebpackPlugin
,而是只引入用到的函数
lodash
。
import debounce from 'lodash/debounce'
、
antd
等组件库也支持按需引入。
element-ui
3. 资源传输优化
开启Gzip / Brotli压缩:在服务器上开启,可以将文本资源(JS, CSS, HTML)压缩到原来的 20%-30%。
Nginx配置Gzip示例:
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
使用CDN:将静态资源(JS, CSS, 图片、字体)部署到CDN上,利用其全球分布的边缘节点,使用户能从最近的服务器获取资源,大幅减少网络延迟。
4. 预加载与预连接
使用资源提示(Resource Hints)来优化加载顺序。
:高优先级获取首屏关键资源(如关键CSS、Web字体、首屏图片)。
<link rel="preload">
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="critical.js" as="script">
/
<link rel="preconnect">
:提前与第三方源建立连接。如果你用了Google Fonts、统计分析等外部服务,这个优化效果立竿见影。
<link rel="dns-prefetch">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://www.google-analytics.com">
5. 服务端渲染 (SSR) – 终极方案
如果以上优化仍无法达到要求(特别是对SEO和首屏时间极其敏感的应用),SSR是最终的解决方案。
原理:在服务器端将组件渲染成完整的HTML字符串,直接发送给浏览器。浏览器能立即解析和显示内容,无需等待JS下载和执行。然后JS再“接管”页面,使其成为可交互的SPA。好处:
极大改善FCP (首次内容绘制) 和 LCP (最大内容绘制)。利于SEO:搜索引擎爬虫直接看到的是完整的页面内容。
实现:
Next.js (React)Nuxt.js (Vue)Angular Universal (Angular)
SSR带来了更好的性能和SEO,但也增加了服务器的复杂度和成本。
6. 其他优化手段
优化图片:使用现代格式(WebP/AVIF)、响应式图片(
)、懒加载(
srcset
)。利用浏览器缓存:设置合理的
loading="lazy"
和
Cache-Control
头部,让浏览器缓存静态资源,避免重复下载。减少重定向:避免不必要的重定向,增加网络往返次数。
ETag
十一、在React中可以做哪些性能优化?
(一) 核心优化策略(避免不必要的渲染)
这是 React 性能优化中最重要、最常见的一部分。核心思想是:只有当组件依赖的数据真正发生变化时,才触发其重新渲染。
1. 使用
React.memo
进行组件记忆
React.memo
是什么:
是一个高阶组件(HOC),用于包装函数组件。作用:它对组件的
React.memo
进行浅比较(shallow comparison),如果
props
没有变化,就会跳过该组件的渲染,直接复用上一次的渲染结果。适用场景:父组件频繁重新渲染,但其传递给子组件的
props
并未改变。例如:列表中的子项、纯展示型组件。
props
示例:
const MyComponent = React.memo(function MyComponent(props) {
/* 只在 props 改变时重新渲染 */
});
// 或者与 useCallback 联用处理函数 props
const Child = React.memo(({ onClick, value }) => {
console.log('Child rendered');
return <button onClick={onClick}>{value}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [value, setValue] = useState('Hello');
// 使用 useCallback 记忆函数,避免每次渲染都生成新的 onClick 引用
const onClick = useCallback(() => {
console.log('Clicked');
}, []); // 依赖项为空数组,表示该函数永远不会改变
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child onClick={onClick} value={value} />
</div>
);
}
2. 使用
useCallback
记忆函数
useCallback
问题:在函数组件中,每次渲染都会重新创建内部函数。如果一个函数被作为
传递给子组件(尤其是被
prop
包装的子组件),每次父组件渲染都会导致子组件收到一个新的函数引用,从而触发不必要的重渲染。解决方案:
React.memo
会返回一个记忆化的回调函数,它只在依赖项发生变化时才会更新。示例:见上例中的
useCallback
。
onClick
3. 使用
useMemo
记忆计算结果
useMemo
是什么:
返回一个记忆化的值。作用:避免在每次渲染时都进行昂贵的计算(如排序、过滤、大规模数据转换)。它只在依赖项发生变化时重新计算值。适用场景:计算成本高的派生数据。
useMemo
示例:
function ExpensiveComponent({ list, filterTerm }) {
// 只有当 list 或 filterTerm 变化时,才会重新执行昂贵的过滤计算
const filteredList = useMemo(() => {
console.log('Filtering...');
return list.filter(item => item.name.includes(filterTerm));
}, [list, filterTerm]);
return <div>{filteredList.map(item => <div key={item.id}>{item.name}</div>)}</div>;
}
注意:
和
useMemo
都应谨慎使用,并非所有函数和值都需要记忆,因为它们本身也有性能开销。只在优化收益明显大于开销时使用。
useCallback
(二) 状态管理优化
1. 精细化的状态划分(State Colocation)
原则:将状态放到尽可能靠近它被使用的地方,而不是盲目地提升到很高的层级。好处:避免因为一个局部状态的更新,导致整个大树分支的重新渲染。示例:如果一个表单的状态只在一个子组件中使用,那就应该将它保存在那个子组件内部,而不是提升到父组件。
2. 使用不可变数据(Immutability)
为什么重要:React 的浅比较(
,
React.memo
的依赖数组)依赖于不可变数据。如果你直接修改状态对象或数组,浅比较会认为引用没变,从而无法检测到数据变化,导致渲染错误或副作用不触发。
useEffect
正确做法:始终使用展开运算符(
)、
...
、
concat
、
slice
、
map
等返回新数组/对象的方法来更新状态。
filter
// ✅ 正确:创建新数组
setTodos(prevTodos => [...prevTodos, newTodo]);
setUser(prevUser => ({ ...prevUser, name: 'New Name' }));
// ❌ 错误:直接修改原状态
todos.push(newTodo);
user.name = 'New Name';
辅助工具:对于复杂状态,可以使用 Immer 库来简化不可变更新,它让你可以用“可变”的语法写出不可变的逻辑。
(三) 上下文(Context)优化
Context 很容易导致性能问题,因为只要 Context 的值发生变化,所有消费该 Context 的组件都会重新渲染,即使它们只使用了该值中未发生变化的部分。
1. 拆分 Context
不要将所有全局状态都放在一个巨大的 Context 里。根据逻辑关注点(如
,
AuthContext
,
ThemeContext
)拆分成多个,这样状态的更新只会影响相关的消费者。
UserContext
2. 优化 Context Value
传递给 Context Provider 的
必须被记忆化,否则每次父组件渲染都会提供一个新的对象,导致所有消费者无条件重新渲染。
value
function App() {
const [currentUser, setCurrentUser] = useState(null);
const [theme, setTheme] = useState('dark');
// ❌ 错误:每次 App 渲染都创建一个新对象,导致所有消费者重新渲染
// const value = { currentUser, setCurrentUser, theme, setTheme };
// ✅ 正确:使用 useMemo 记忆化 value
const userValue = useMemo(() => ({ currentUser, setCurrentUser }), [currentUser]);
const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<AuthContext.Provider value={userValue}>
<ThemeContext.Provider value={themeValue}>
<MainPage />
</ThemeContext.Provider>
</AuthContext.Provider>
);
}
3. 对 Context 消费者使用
memo
memo
即使 Context 值变了,你也可以让某些消费组件不渲染,前提是它的其他
没变。
props
const ThemedButton = React.memo(({ onClick, label }) => {
const theme = useContext(ThemeContext); // 这里读取 Context
// 即使 ThemeContext 变了,如果 onClick 和 label props 没变,memo 会阻止渲染
return <Button style={theme} onClick={onClick}>{label}</Button>;
});
(四) 列表渲染优化
1. 使用唯一的、稳定的
key
key
为列表中的每一项提供一个唯一且在其列表中保持稳定的
(最好是
key
,而不是数组索引
id
)。作用:帮助 React 准确地识别哪些项被更改、添加或删除,从而高效地更新虚拟 DOM,避免不必要的树操作和组件状态错乱。
index
2. 虚拟化长列表(Windowing)
问题:渲染包含成千上万行的长列表会非常慢,因为会创建大量的 DOM 节点。解决方案:只渲染可见部分(视口)的内容。当用户滚动时,再动态渲染即将进入视口的元素,并移除离开视口的元素。
常用库:
或
react-window
。
react-virtualized
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Row {index}</div>
);
const LongList = () => (
<List
height={500}
itemCount={10000}
itemSize={35}
width={'100%'}
>
{Row}
</List>
);
(五) 打包与交付优化(与构建工具相关)
1. 代码分割(Code Splitting)
是什么:将代码拆分成多个包,然后按需加载或并行加载。实现:
使用
和
React.lazy
:用于路由级或组件级的懒加载。
Suspense
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}
使用 Webpack 的动态
语法:
import()
和 Next.js 等框架已内置支持。
create-react-app
2. 依赖优化
使用
等工具分析打包体积,找出过大的依赖。考虑替换体积更小的替代库(如用
bundle-analyzer
替代
date-fns
)。确保依赖被正确标记为
moment.js
(如果使用 CDN)。
external
3. 图片和静态资源优化
压缩图片(WebP, AVIF 等现代格式)。使用懒加载(
)。
loading="lazy"
(六) 其他优化技巧
依赖数组优化:确保
,
useEffect
,
useCallback
的依赖数组中只包含真正需要依赖的变量,避免不必要的重新执行。避免内联对象和函数:在作为
useMemo
传递时,内联对象
prop
和函数
style={{ color: 'red' }}
每次都会创建新的引用,会破坏
onClick={() => {}}
的优化。尽量将它们移出渲染作用域或使用
React.memo
/
useMemo
。使用 Profiler API:React DevTools 中的 Profiler 可以帮你找出应用的性能瓶颈,测量组件渲染的次数和耗时,从而有针对性地进行优化
useCallback
十二、Service Worker 是如何缓存 http请求资源的?
Service Worker 缓存 HTTP 请求资源是其最核心、最强大的功能之一。它通过在浏览器和网络之间充当代理服务器来实现这一点。
Service Worker 运行在一个独立的线程上,可以拦截、处理并响应您网站发起的所有 HTTP 请求。通过这个能力,您可以完全控制如何处理这些请求:是从网络获取,还是从缓存中返回,或是两者结合。
(一) 缓存机制详解
1. 前提:安装和激活
首先,Service Worker 需要被注册、安装并激活。这个过程通常发生在用户的首次访问或 Service Worker 更新时。
注册 (Register): 网站的主 JavaScript 文件注册 Service Worker 脚本。安装 (Install): 这是预缓存关键资源的理想时机。在
事件中,您可以打开一个缓存仓库(Cache Storage),并添加需要离线可用的核心静态文件(如 HTML, CSS, JS, 图片)。
install
// 在 service-worker.js 中
const CACHE_NAME = 'v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
];
self.addEventListener('install', event => {
// 执行安装步骤:打开缓存并添加文件
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('已打开缓存');
return cache.addAll(urlsToCache); // 缓存文件数组
})
);
});
确保 Service Worker 在缓存完成之前不会进入下一阶段。
event.waitUntil()
激活 (Activate): 这是清理旧缓存的好时机。当您更新 Service Worker 时,新的版本安装后,旧的缓存可能就不再需要了,可以在
事件中删除它们。
activate
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cache => {
if (cache !== CACHE_NAME) {
console.log('删除旧缓存');
return caches.delete(cache);
}
})
);
})
);
});
2. 核心:拦截和响应请求 (Fetch 事件)
这是实现动态缓存的关键。Service Worker 可以监听
事件,该事件会在页面请求任何资源时触发。
fetch
self.addEventListener('fetch', event => {
event.respondWith(
// 在这里决定如何响应这个请求
// 策略一:缓存优先
caches.match(event.request)
.then(response => {
// 如果缓存中有,直接返回缓存响应
if (response) {
return response;
}
// 如果没有,则从网络获取
return fetch(event.request);
})
);
});
(二) 常见的缓存策略
上面只是一个简单示例,实际应用中您可以根据资源类型选择不同的策略:
缓存优先,网络回退 (Cache Falling Back to Network)
适用场景: 对实时性要求不高的静态资源(如 logos, 字体, 不常变的 CSS/JS)。流程: 先检查缓存,有则返回。没有或失败,再请求网络,并可选地将新请求加入缓存。
网络优先,缓存回退 (Network Falling Back to Cache)
适用场景: 需要尽可能获取最新内容,但在离线时也能展示(如文章列表、API 数据)。流程: 先尝试网络请求,成功则返回新数据(并更新缓存)。如果网络失败(如用户离线),则返回缓存的旧版本。
缓存和网络竞速 (Cache then Network)
适用场景: 在 Service Worker 中较难实现,通常与网页主线程配合。旨在快速从缓存显示内容,同时用网络数据更新它(常见于 PWA 应用)。
仅缓存 / 仅网络
仅缓存: 对于肯定不需要更新的资源(如版本化的静态文件)。仅网络: 对于绝对不能使用旧数据的请求(如账单支付 API)。
(三) 更新缓存
缓存不是一次性的,更新策略非常重要:
** stale-while-revalidate**: 一种常用策略。立即返回缓存中的内容(即使已过期),同时在后台发起网络请求,用新响应更新缓存,供下次使用。这保证了速度也兼顾了新鲜度。
self.addEventListener('fetch', event => {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
const fetchedResponse = fetch(event.request).then(networkResponse => {
// 用新响应更新缓存
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// 返回缓存响应(如果存在),否则等待网络响应
return cachedResponse || fetchedResponse;
});
})
);
});
(四) 注意事项
缓存污染 (Cache Poisoning): 只缓存安全可靠的响应,特别是对第三方资源的请求。确保源站点的 HTTPS 证书有效。存储限制 (Storage Quota): 浏览器对缓存总量有限制(通常与源站共享)。需要管理缓存大小,定期清理不必要的内容。缓存版本管理: 使用不同的缓存名称(如
)来管理不同版本的资源,并在激活阶段清理旧缓存。范围 (Scope): Service Worker 只能拦截其所在路径及其子路径下的请求。例如,位于
my-site-cache-v2
目录下的 SW 无法拦截根路径
/sw/
的请求。
/
十三、什么是CSS Sprites?
CSS Sprites 是一种将多个小图片、图标合并到一张大图中的网页图片应用处理技术。 网页通过 CSS 背景定位(
)属性,在这张合成的大图上“裁剪”出需要显示的小图标。
background-position
您可以把它想象成一张邮票纸,上面有很多枚不同的邮票。CSS 的作用就是用一个刚好一枚邮票大小的框(HTML元素),在这张邮票纸上移动,只露出框下的那一枚邮票,而隐藏其他部分。
十四、浏览器缓存中 Memory Cache 和 Disk Cache,有什么区别?
浏览器的 Memory Cache(内存缓存) 和 Disk Cache(磁盘缓存) 是浏览器缓存机制中两个关键的存储位置,它们在速度、容量、生命周期和行为上有着显著的区别。
简单来说,Memory Cache 是“快但是短”,而 Disk Cache 是“慢但是持久”。
下面是一个详细的对比表格,清晰地展示了两者的区别:
特性 |
Memory Cache (内存缓存) |
Disk Cache (磁盘缓存) |
存储介质 |
计算机的RAM(内存) |
计算机的硬盘(HDD/SSD) |
读写速度 |
极快(纳秒级别,与CPU直接交互) |
相对较慢(毫秒级别,涉及I/O操作) |
存储容量 |
非常有限(取决于浏览器分配和当前标签页/系统内存压力) |
非常大(可达数百MB甚至GB级别,取决于浏览器设置和硬盘空间) |
生命周期 |
短暂 |
持久 |
存储内容 |
通常是当前会话中获取过的资源: |
几乎是所有符合缓存策略的资源: , 等HTTP头控制)。 |
开发者控制 |
几乎无法直接控制。由浏览器自动管理,开发者无法通过代码指定一个资源必须存入内存缓存。 |
可以精确控制。通过配置服务器端的HTTP缓存头(如 , , )来管理资源的缓存行为。 |
在DevTools中的表现 |
在 Network 面板中, 列会显示 。传输大小和时间为 和 。 |
在 Network 面板中, 列会显示 。传输大小和时间为 和 。 |
(一) 深入理解与工作流程
1. 浏览器如何决定使用哪个缓存?
浏览器有一套复杂的启发式算法,但大致逻辑如下:
检查 Memory Cache: 当发起一个资源请求时,浏览器首先会飞速地检查内存中是否有完全匹配的请求。如果有(并且有效),就直接从内存中读取,这是最快的方式。检查 Disk Cache: 如果 Memory Cache 中没有,浏览器就会去检查磁盘缓存。如果找到有效且未过期的缓存,浏览器会将其内容加载到内存中并使用(同时它也留在磁盘上),然后返回给页面。发送网络请求: 如果两级缓存都没有找到可用的资源,浏览器才会真正发起网络请求。获取到响应后,它会根据HTTP缓存头的指示,决定是否以及如何将资源存入 Disk Cache。同时,这个资源也很可能被放入 Memory Cache 以供当前会话快速访问。
2. 一个典型的场景
假设你第一次访问一个网站
:
example.com
浏览器请求
,服务器返回了这个文件,并附带了响应头:
style.css
(缓存1小时)。浏览器将
Cache-Control: max-age=3600
存入 Disk Cache(因为指令明确),同时也放了一份到 Memory Cache(为了当前页面的快速重用时使用)。你在页面内跳转或刷新页面。再次需要
style.css
时,浏览器优先从 Memory Cache 中读取,速度极快。你关闭了标签页。Memory Cache 被释放,但
style.css
仍然安全地保存在 Disk Cache 中。一小时内,你重新打开浏览器访问
style.css
。浏览器发现 Disk Cache 中有
example.com
且未过期,于是直接使用它。在加载的同时,它又会把这个文件放入新的 Memory Cache 中,供这次会话使用。
style.css
3. 为什么两者需要共存?
这是一种典型的性能权衡(Trade-off),借鉴了计算机系统中常见的“内存-磁盘”分层设计:
Memory Cache 利用了内存的极致速度,极大地提升了当前会话内的页面导航、刷新等操作的体验,实现了“瞬时加载”。Disk Cache 利用了硬盘的大容量和持久性,实现了跨会话的缓存,减少了重复的网络请求,节省了带宽,并为后续的访问提供了加速基础。
十五、怎么进行站点内的图片性能优化?
站点内图片性能优化是一个系统工程,涵盖了从选择、制作、交付到加载的整个流程。做好它能极大提升用户体验、SEO排名和转化率。
(一) 核心优化思路
减少不必要的字节:用更小的文件体积提供视觉上清晰的图片。减少不必要的请求:尽可能合并图片或使用新技术替代。尽快呈现内容:优先加载关键图片,延迟加载非关键图片。
(二) 具体优化手段与技术
1. 选择合适的图片格式 (Choose the Right Format)
这是最重要的决策,直接决定文件体积。
格式 |
适用场景 |
优点 |
缺点 |
JPEG |
颜色丰富的照片、渐变场景 |
压缩率高,体积小 |
有损压缩,不支持透明 |
PNG |
需要透明底、线条/图标、颜色数较少且要求高质量的图片 |
支持无损压缩、支持透明 |
体积通常比JPEG大 |
GIF |
简单的动图 |
支持动画 |
颜色支持差(仅256色),体积大 |
WebP |
JPEG和PNG的现代替代品(强烈推荐) |
比JPEG小25-35%,支持透明和动画 |
兼容性并非100%(但已很好),需要备选方案 |
AVIF |
下一代格式,比WebP更先进 |
比WebP压缩率更高,功能更全面 |
兼容性目前较差(但正在快速发展) |
SVG |
图标、Logo、简单图形 |
矢量格式,无限缩放不失真,体积小,可通过CSS控制样式 |
不适合复杂图像(如照片) |
建议:
优先使用 WebP,并为不支持的浏览器(如老版本Safari)提供JPEG/PNG备选(如下文“使用现代格式”部分所示)。Logo、图标等尽量使用SVG。
2. 压缩与优化 (Compression & Optimization)
即使选对格式,原始文件也可能包含多余的元数据,可以通过工具进一步压缩。
工具推荐:
命令行工具: imagemin(可集成到构建流程中)图形界面工具: Squoosh(谷歌出品,可在线对比效果)、ImageOptim(Mac)构建工具插件: 对于 Webpack,可以使用
。
image-minimizer-webpack-plugin
建议: 将图片优化作为项目构建流程的一部分,自动化完成。
3. 响应式图片 (Responsive Images)
为不同尺寸和分辨率的屏幕提供不同大小的图片,避免在小屏幕手机上加载巨大的桌面端图片。
使用
和
<picture>
属性:
srcset
<!-- 基于屏幕宽度提供不同尺寸 -->
<img
srcset="image-320w.jpg 320w,
image-640w.jpg 640w,
image-1024w.jpg 1024w"
sizes="(max-width: 480px) 100vw, (max-width: 900px) 50vw, 800px"
src="image-640w.jpg"
alt="描述性文字"
>
<!-- 基于格式支持提供现代格式(WebP)和备选格式 -->
<picture>
<source type="image/webp" srcset="image.webp">
<source type="image/jpeg" srcset="image.jpg">
<img src="image.jpg" alt="描述性文字">
</picture>
4. 懒加载 (Lazy Loading)
只加载用户当前视口(viewport)内或即将看到的图片,其他图片等用户滚动到附近时再加载。
原生懒加载(最简单):
<img src="image.jpg" loading="lazy" alt="..." />
(现代浏览器普遍支持,对于旧浏览器需要polyfill)
JavaScript库: 如
等,提供更多高级功能。
lozad.js
5. 使用 CDN (Content Delivery Network) 和图像优化服务
CDN: 将你的图片分发到全球各地的服务器,用户可以从离他们最近的节点加载,大幅降低延迟。专业图像CDN/服务: 如 Cloudinary, Imgix, Akamai Image Manager。它们不仅能加速,还能实时处理图片(调整大小、裁剪、转换格式、优化),你只需要通过URL参数即可实现。
例如:
https://your-cdn.com/image.jpg?width=400&format=webp&quality=80
6. 缓存策略 (Caching)
为图片设置正确的 HTTP 缓存头,让浏览器将其存储在本地的 Disk Cache 中,后续访问时直接从本地加载。
服务器配置示例(如
):
.htaccess
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
ExpiresByType image/svg+xml "access plus 1 month"
</IfModule>
对于带哈希指纹的文件名(如
),可以设置非常长的缓存时间(如一年)。
logo.a1b2c3.png
7. 其他重要技巧
为图片设置正确的尺寸(宽高): 避免浏览器进行布局重排(Reflow)。最好在HTML中显式设置
和
width
属性(或通过CSS设置),这有助于稳定布局,减少累积布局偏移(CLS)。使用 CSS 效果替代图片: 简单的渐变、阴影、形状效果,用CSS实现远比用图片高效。考虑使用 CSS Sprites: 将多个小图标合并到一张大图中,通过
height
来显示指定部分。虽然HTTP/2多路复用后其优势减小,但仍能减少请求数。
background-position
关键图片预加载: 对于非常重要的Above-the-fold图片(首屏立即看到的),可以使用
来提示浏览器优先加载。
<link rel="preload">
<link rel="preload" as="image" href="important-hero-image.webp">
十六、什么是内存泄漏?什么原因会导致呢?
(一) 什么是内存泄漏?
内存泄漏(Memory Leak) 是指程序中已动态分配的堆内存由于某种原因未能被释放或者无法被释放,造成系统内存的浪费。
通俗的比喻:
就像你从图书馆借了一批书(申请内存),看完后却忘了还回去(释放内存)。如果你一直借一直忘还,图书馆的书架最终会被占满,导致其他人无书可借(程序内存不足,性能下降,甚至崩溃)。
在JavaScript的上下文(如浏览器、Node.js)中,引擎的垃圾回收机制(Garbage Collection, GC)会自动管理内存。内存泄漏的本质是:你认为不再需要的变量或数据,意外地被垃圾回收器判断为“仍然需要”,因而无法被回收。
(二) 垃圾回收(GC)的基本原理:引用计数与标记清除
要理解原因,先要简单了解GC如何工作。现代浏览器主要使用标记-清除(Mark-and-Sweep) 算法:
标记:从根对象(全局对象
、当前函数调用栈)出发,遍历所有能访问(引用)到的对象,并标记为“可达”。清除:将所有未被标记为“可达”的对象视为垃圾,回收它们占用的内存。
window
内存泄漏的发生,就是因为“不该被引用到的对象”被意外地标记为了“可达”。
(三) 导致内存泄漏的常见原因(以前端JavaScript为例)
以下是几种最典型的情景:
1. 意外的全局变量
在非严格模式下,给未声明的变量赋值会创建一个全局变量。而全局变量只有在页面关闭时才会被销毁。
javascript
function foo() {
bar = '这是一个意外的全局变量'; // 错误!没有用 var/let/const 声明
this.anotherBar = '这是另一个全局变量'; // 在函数中,this 可能指向 window
}
foo(); // 执行后,window.bar 和 window.anotherBar 就永久存在了
解决方法: 使用
模式,它会阻止创建意外的全局变量。
'use strict'
2. 被遗忘的定时器(Timers)或回调函数(Callbacks)
、
setInterval
以及事件监听器如果不再需要,但未被清除,它们所引用的对象就无法被回收。
setTimeout
// 例子1:被遗忘的 Interval
let someData = getHugeData();
setInterval(() => {
const node = document.getElementById('Node');
if (node) {
node.innerHTML = JSON.stringify(someData); // someData 一直被 interval 引用,无法释放
}
}, 1000);
// 即使从DOM中移除了 #Node,由于 interval 仍在执行,someData 依然占用内存。
// 例子2:被遗忘的事件监听器
const element = document.getElementById('button');
element.addEventListener('click', onClick); // 添加了监听器
// 如果后来这个 element 被从DOM中移除了...
element.remove(); // ...但事件监听器没有被移除,onClick 函数和其闭包作用域依然被引用着
解决方法: 在不需要时,使用
、
clearInterval()
和
clearTimeout()
进行清理。
removeEventListener()
3. 脱离DOM的引用
当你将DOM元素存到一个变量或数据结构中,即使后来这个元素被从页面上移除了,由于它仍然被JavaScript引用着,它也无法被GC回收。
javascript
// 在 JavaScript 中缓存一个DOM元素的引用
const elements = {
button: document.getElementById('myBigButton'),
};
// 后来从DOM树中移除了该按钮
document.body.removeChild(document.getElementById('myBigButton'));
// 然而,由于 elements.button 仍然引用着这个DOM节点,它及其事件监听器占用的内存都无法被释放!
解决方法: 确保在移除DOM元素后,也清除对它的所有JavaScript引用(如
)。
elements.button = null
4. 闭包(Closures)
闭包是函数和其声明时的词法作用域的组合。如果闭包持有对一个大型数据结构的引用,即使外部函数已经执行完毕,这个大型数据结构也会一直留在内存中。
javascript
function outer() {
const hugeArray = new Array(1000000).fill('*'); // 一个大数组
return function inner() { // 内部函数(闭包)
// 即使 inner 函数没有显式使用 hugeArray,只要它被定义在 outer 的作用域里,
// 整个 outer 的作用域(包括 hugeArray)都会被保留!
console.log('Hello from inner!');
};
}
const closureFn = outer(); // hugeArray 不会被释放,因为 closureFn 隐式地引用了它
解决方法: 谨慎使用闭包。如果闭包不需要某个大变量,确保在不需要时将其置为
。
null
5. 在框架中)未销毁的组件和事件总线
在现代前端框架(如 Vue、React)中,如果在组件挂载时订阅了全局事件总线(Event Bus)或第三方库的监听器,但在组件销毁(
/
unmounted
)时没有取消订阅,就会导致内存泄漏。因为销毁的组件实例仍然被事件总线引用着。
componentWillUnmount
// Vue 2 选项式API示例
export default {
mounted() {
// 在全局事件总线上添加监听器
EventBus.$on('some-event', this.handleEvent);
},
beforeUnmount() { // 在Vue 3中是 beforeUnmount
// 如果忘记移除,每次组件创建都会增加一个监听器,且旧组件的实例不会被释放!
EventBus.$off('some-event', this.handleEvent); // 正确的做法:在卸载前移除
}
}
(四) 如何发现内存泄漏?
浏览器开发者工具是强大的武器:
Performance 面板:录制一段时间内的性能,观察 JS Heap 内存线是否持续上升,而不回落。Memory 面板:
Heap Snapshot:拍摄堆内存快照,对比操作前后的快照,查看哪些对象在增加。Allocation instrumentation on timeline:实时查看内存分配的时间线,定位分配内存的函数。
十七、如何用webpack和vite来优化前端性能?
Webpack 和 Vite 是两种不同时代的构建工具,它们的优化思路和具体手段既有重叠也有差异。下面我将分别阐述,并进行对比。
(一) 核心哲学差异
Webpack: 基于 Bundle(打包) 的理念。它的优化核心在于如何更智能、更高效地将你的代码打包成数量更少、体积更优的 bundle 文件。Vite: 基于 ESM(原生 ES 模块) 和 原生语言(Go/Rust)。它的优化核心在于极致的启动速度和热更新(HMR),通过利用浏览器原生支持 ESM 的能力,在开发环境免于打包,在生产环境则使用高度优化的 Rollup 进行构建。
(二) Webpack 性能优化方案
Webpack 的优化主要围绕 Bundle Analyzer、Split Chunks、Tree Shaking 和 Caching。
1. 分析打包结果 (Analysis)
“没有测量,就没有优化”
: 生成一个可视化的图表,直观展示每个 bundle 由哪些模块构成、体积大小,从而定位优化目标。
webpack-bundle-analyzer
2. 减少打包体积 (Reduce Bundle Size)
Tree Shaking:
条件:使用 ES2015 模块语法(
/
import
)、在生产模式(
export
)下默认开启。确保:在
mode: 'production'
中设置
package.json
,或指定有副作用的文件数组(如 CSS)。
"sideEffects": false
代码分割 (Code Splitting):
入口点:
。动态导入 (Dynamic Import):利用
entry: { app: './src/app.js', admin: './src/admin.js' }
语法实现路由级或组件级的懒加载。
import()
const LazyComponent = () => import('./LazyComponent.vue');
SplitChunksPlugin:配置
来自动分离公共依赖和第三方库。
optimization.splitChunks
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\/]node_modules[\/]/,
name: 'vendors',
},
},
},
}
压缩 (Minification):
使用
压缩 JS。使用
TerserWebpackPlugin
压缩 CSS。
CssMinimizerWebpackPlugin
3. 优化构建速度 (Improve Build Speed)
缓存 (Caching):
(Webpack 5): 将构建结果缓存到硬盘,极大提升二次构建速度。
cache: { type: 'filesystem' }
减少 Loader 处理范围:
在
中使用
rules
和
exclude
。
include
{
test: /.js$/,
exclude: /node_modules/, // 排除 node_modules
include: path.resolve(__dirname, 'src'), // 只处理 src 目录
use: 'babel-loader'
}
使用更快的 Loader/Plugin:
例如用
替代
esbuild-loader
或
babel-loader
进行转译和压缩,速度提升惊人。
Terser
{
test: /.js$/,
use: 'esbuild-loader'
}
4. 优化运行时性能 (Runtime Performance)
Bundle 哈希:使用
。只有内容变化的文件哈希才会改变,利用浏览器缓存。预加载/预获取 (Preload/Prefetch):使用
output.filename: '[name].[contenthash].js'
提示浏览器优先加载关键资源。
import(/* webpackPreload: true */ '...')
(三) Vite 性能优化方案
Vite 的优化分为开发环境和生产环境。
1. 开发环境 (Development)
Vite 的开发环境本身就是一个巨大的优化,无需额外配置已极快。
原理:基于浏览器原生 ESM,服务器按需编译返回源文件,完全免去了打包开销。热更新 (HMR):基于 ESM,HMR 是在原生 ESM 上执行的。当某个模块更新时,Vite 只需要精确地使该模块及其依赖链失效,速度极快。
2. 生产环境 (Production)
Vite 使用 Rollup 进行打包,因此许多 Rollup 优化手段同样适用。
内置优化:
代码分割:自动为异步导入的模块生成单独的 chunk。CSS 代码分割:为每个入口点提取并生成单独的 CSS 文件。资源处理:小资源自动转换为 base64 URL,减少 HTTP 请求。
手动配置优化 (在
中):
vite.config.js
依赖预构建:Vite 会使用
将复杂的 CommonJS 依赖转换为 ESM,这个步骤本身已经极快,通常无需干预。Rollup 配置透传:你可以直接配置
esbuild
。
build.rollupOptions
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: { // 手动分包策略
vendor: ['react', 'react-dom'],
utils: ['lodash-es', 'axios']
}
}
}
}
}
高级优化:
使用
:为传统浏览器生成兼容性包。SSR 优化:Vite 提供了一流的 SSR 支持。
@vitejs/plugin-legacy
(四) Webpack vs Vite 优化对比总结
优化维度 |
Webpack |
Vite |
开发启动速度 |
慢。需要先打包整个应用。 |
极快。无需打包,按需编译。 |
开发热更新 (HMR) |
快。但项目越大,更新速度下降越明显。 |
极快。基于 ESM,边界更细,影响范围更小。 |
生产构建 |
成熟、稳定、高度可配置。优化依赖复杂的插件配置。 |
使用 Rollup,构建输出高度优化。配置更简洁。 |
代码分割 |
功能强大,但配置稍复杂( )。 |
自动且智能,配置更简单(Rollup 逻辑)。 |
Tree Shaking |
优秀,生产模式默认开启。 |
优秀,由 Rollup 处理,效果非常好。 |
缓存优化 |
需要配置 选项。 |
开发环境依赖浏览器缓存,生产环境构建缓存开箱即用。 |
学习成本 |
高。需要理解其打包理念和众多插件。 |
低。配置更简单,符合现代开发习惯。 |
(五) 如何选择?
选择 Webpack:
项目非常庞大且历史悠久的,迁移成本高。需要极其特殊、自定义的构建流程(Webpack 的插件生态无可比拟)。团队对 Webpack 配置非常熟悉。
选择 Vite:
新项目首选,尤其是 Vue、React、Preact、Lit 等现代框架。追求极致的开发体验和启动速度。希望配置更简单,开箱即用。
总而言之,Webpack 的优化是“精雕细琢的工匠模式”,你需要手动配置各种插件来达到最佳效果;而 Vite 的优化是“现代高效的自动驾驶模式”,它在开发环境提供了颠覆性的速度体验,在生产环境也通过成熟的 Rollup 给出了近乎最优的打包方案。
十八、为何大多数都是canvas,而很少用 svg 的?
Canvas 和 SVG 是两种完全不同理念的技术,它们的优势和劣势决定了各自的应用场景。Canvas 的“全能性”和“高性能”使其在复杂、动态的图形应用中成为首选,而 SVG 在需要交互、保真和可扩展性的特定场景中无可替代。
为什么你感觉 Canvas 用得多?因为它覆盖了大量当前互联网的热门领域:游戏、数据可视化、特效、图像处理等。而 SVG 更像一个“幕后英雄”,大量应用于图标、Logo、插图和数据图表的基础构建,但你不会总是注意到它。
(一) 核心差异:位图 vs 矢量图
Canvas:是一个位图画布。你通过 JavaScript API 在像素级别上绘制图形。一旦绘制完成,浏览器就不再记得这个图形是什么(圆、矩形等),它只知道那一片像素的颜色。就像用画笔在画布上作画,画完后你只能修改像素,而不能移动一个已经画好的圆。SVG:是一种基于 XML 的矢量图形格式。它用数学公式(点、线、路径)来描述图形。浏览器会解析这些代码并实时渲染出图形。因为它是“对象”,所以每个图形元素都是独立的 DOM 节点,可以被 JavaScript 和 CSS 操作。
这个根本区别引出了所有其他的优劣对比。
(二) 对比表格:Canvas vs SVG
特性 |
Canvas |
SVG |
图像类型 |
位图(像素) |
矢量图(数学描述) |
输出缩放 |
放大后会失真、模糊 |
无限放大不失真 |
DOM 结构 |
单元素 ( ),内部无结构 |
多元素,每个图形都是DOM节点,结构清晰 |
交互能力 |
弱。需手动计算坐标实现交互,非常复杂 |
强。每个图形自带事件(click, mouseover等) |
性能特点 |
像素操作性能极高。适合大量元素、高频重绘 |
DOM操作性能开销大。大量元素时性能下降严重 |
适用场景 |
游戏、动态图表、图像处理、视频特效、像素操作 |
图标、地图、高保真插图、可交互的数据图表 |
SEO/可访问性 |
差。画布内内容对搜索引擎和屏幕阅读器不可见 |
好。文本内容可被读取,支持ARIA属性 |
(三) 为什么 Canvas 感觉更普遍?
基于以上差异,Canvas 在以下热门领域具有不可替代的优势,而这些领域恰好非常吸引眼球:
游戏开发 (Games)
需求:每秒60帧的重绘、大量的精灵(sprite)移动、碰撞检测。Canvas:直接操作像素,性能可以做到极致。WebGL(基于Canvas的3D上下文)更是开启了浏览器端3A级游戏的可能。SVG:成千上万的DOM节点会彻底拖垮性能,完全无法胜任。
复杂的数据可视化 (Data Visualization)
需求:绘制成千上万个数据点(如股票K线图、实时传感器网络)。Canvas:使用库如 ECharts、AntV G2 等,可以在一个Canvas上高效渲染数万个点。SVG:如果为每个点创建一个DOM节点,浏览器会直接卡死。虽然 D3.js 常操作SVG,但在数据量极大时也会转向Canvas模拟或混合模式。
图像与视频处理 (Image/Video Processing)
需求:实现滤镜、美颜、抠图、合成等。Canvas:可以直接获取、操作每个像素点的RGBA值,这是其天然优势。SVG:无法进行像素级操作。
复杂的动画与特效 (Effects & Animations)
需求:粒子效果、光影、流体模拟等。Canvas:可以自由地绘制每一帧,实现任何天马行空的效果。SVG:虽然也能做动画(SMIL、CSS),但复杂度和性能远不如Canvas。
(四) 为什么 SVG 依然重要且无处不在?
尽管Canvas在很多“炫技”的场景下出场率更高,但SVG在基础领域是绝对的主流和最佳实践:
图标系统 (Icons)
现状:几乎所有的现代UI库和网站(如Ant Design, Material-UI)都使用SVG图标。它比 iconfont 更易控制(颜色、大小、部分颜色)、无障碍支持更好、渲染更清晰。原因:矢量缩放、CSS可控、易于交互。
地图与地理信息系统 (Maps)
现状:Leaflet、OpenLayers 等地图库大量使用SVG来绘制路径、标记、区域。原因:无限缩放、每个区域可单独交互(点击显示省份信息等)。
高质量的插图和Logo
现状:设计师通常使用 Illustrator 或 Sketch 导出SVG,用于网页。原因:在任何屏幕上都能完美显示,文件体积小。
现状:D3.js 这个强大的可视化库的核心就是操作SVG来生成高度可定制和可交互的图表。原因:为每个图表元素(轴、线、点)绑定数据和时间,实现复杂的交互逻辑非常方便。
(五) 总结与选型建议
Use Canvas if… |
Use SVG if… |
|
……你需要处理 |
像素、大量对象、高频重绘 |
可交互的图形、可扩展的UI元素 |
……你的场景是 |
游戏、复杂动画、图像处理 |
图标、地图、数据图表(交互复杂但数据量不大) |
……你更关注 |
性能、帧率 |
可访问性、可维护性、清晰度 |
结论:
并不是“大多数都是Canvas,而很少用SVG”,而是 “在那些高调、炫酷、性能要求极高的领域,Canvas是唯一的选择,因此更容易被用户感知到”。而在构建网站的基础UI层(图标、Logo、交互式图表),SVG是沉默但强大的基石,无处不在。
很多时候,混合使用才是最佳方案。例如,一个数据仪表盘可能用 SVG 绘制交互性强的图例和控件,而用 Canvas 来渲染中央庞大的、不断流动的数据图。
十九、浏览器为什么要请求并发数限制?
浏览器对同一域名下的请求进行并发数限制,主要是基于性能、公平性和安全等多方面的综合考虑。
简单来说,如果不加限制,允许单个网页无限制地并发请求服务器资源,会对服务器、网络和浏览器自身造成巨大的压力,最终导致整体性能下降,用户体验反而变差。
下面我们从几个关键角度来详细拆解这个原因:
(一) 对服务器端的保护 (Server Protection)
这是最主要的原因。
防止拒绝服务攻击 (DoS):如果一个恶意网页可以瞬间向同一个服务器发起成千上万个并发请求,就极容易耗尽服务器的连接、带宽或计算资源,导致服务器无法响应其他正常用户的请求,形成一种简单的 DoS 攻击。限制并发数相当于给每个客户端(浏览器)设置了“流量闸口”,保护了服务器的稳定性。公平性 (Fairness):网络服务器的资源(CPU、内存、连接数)是有限的。限制并发数确保了来自不同用户和不同标签页的请求能够被公平地处理,避免单个用户的单个标签页独占大量服务器资源,从而让所有用户都能获得相对稳定的服务。
(二) 客户端(浏览器)自身的性能优化 (Client-Side Performance)
浏览器本身也需要管理资源。
TCP 连接开销:每个 HTTP 请求(特别是 HTTPS)都需要建立和维护一个 TCP 连接。建立连接需要“三次握手”,TLS 加密还需要额外的握手过程。创建和管理大量并发连接会显著消耗客户端的 CPU 和内存资源。限制连接数可以减轻浏览器自身的负担。操作系统限制:操作系统对每个客户端程序能打开的端口和连接数也有限制。浏览器必须在这个框架内运作。
(三) 网络拥堵控制 (Network Congestion Control)
避免网络过载:如果无数个客户端都可以无限制地发起并发请求,会极大地增加网络路由和交换设备的负载,容易造成局部网络拥堵,影响所有用户的网络体验。限制并发数有助于维持整体网络的健康状态。
(四) 符合 HTTP/1.1 协议的设计 (HTTP/1.1 Design)
这个限制在 HTTP/1.1 时代尤为明显和必要。
队头阻塞 (Head-of-Line Blocking):在 HTTP/1.1 中,虽然一个 TCP 连接可以处理多个请求,但这些请求必须是串行的(Pipelineing 并不理想)。即,服务器必须按顺序返回响应。如果第一个请求的响应很慢,它会阻塞后面所有请求的返回。为了绕过队头阻塞:浏览器不得不为同一个域名打开多个 TCP 连接(通常是 6个),将请求分发到不同的连接上并行发送,以此来提升加载速度。这个“6个”就是经典的 HTTP/1.1 并发限制。但连接数不能无限多,否则上述1、2、3点的问题就会出现。
(五) 并发数限制的具体规则
HTTP/1.1: 现代浏览器通常对同一个域名允许 6~8 个并发 TCP 连接。不同浏览器略有差异(Chrome 是6个)。HTTP/2: 这是一个巨大的改进。HTTP/2 引入了多路复用 (Multiplexing) 特性,它允许在单个 TCP 连接上同时交错传输多个请求和响应。请求之间互不阻塞。
因此,HTTP/2 下,浏览器对同一个域名通常只建立 1个 TCP 连接,但在这个连接上可以发起几乎无限制的并发请求(实际会有流控制等限制,但数量级远高于6个)。这极大地减轻了并发限制带来的影响,提升了页面加载效率。
(六) 开发者如何应对这个限制?
了解这个机制后,开发者可以采取以下优化策略:
域名分片 (Domain Sharding) [主要针对 HTTP/1.1]:
做法:将资源分布在多个子域名下(如
,
static1.example.com
)。原理:每个子域名都享有 6~8 个的并发限制,这样总体的并发数就变成了
static2.example.com
。注意:在 HTTP/2 下,域名分片是反模式!因为多个域名意味着需要建立多个 TCP 连接,失去了 HTTP/2 单连接多路复用的优势,额外的连接建立和TLS握手开销可能反而使性能变差。
6 * 子域名数
升级到 HTTP/2:
这是根本性的解决方案。HTTP/2 的多路复用特性完美解决了 HTTP/1.1 的队头阻塞和并发限制问题。
优化资源:
减少请求数:使用 CSS Sprites、合并JS/CSS文件、内联小资源(如小图片转base64)。优化加载顺序:使用
、
preload
等资源提示来优先级化关键资源的加载。
prefetch
(七) 总结
浏览器请求并发数限制是一个经典的 “权衡” 设计:
角度 |
如果不限制(坏处) |
限制之后(好处) |
服务器 |
易被拖垮,拒绝服务 |
得到保护,稳定性高 |
客户端 |
CPU/内存消耗巨大 |
资源消耗可控 |
网络 |
容易造成局部拥堵 |
网络流量更平稳 |
HTTP/1.1 |
队头阻塞严重 |
通过多连接提升效率 |
公平性 |
单个客户端可独占资源 |
所有用户请求被公平处理 |
这个限制是Web生态长期发展中形成的一种自我保护机制。而随着 HTTP/2 的普及,这个限制所带来的性能痛点正在被逐渐化解,但其保护性的核心目的依然存在。
二十、dom渲染能使用 GPU加速吗?
能,而且现代浏览器一直在积极地使用 GPU 来加速 DOM 和页面的渲染。
但这并不是“直接”将 DOM 交给 GPU 处理。DOM 本身是逻辑对象,GPU 擅长的是处理像素。因此,浏览器的策略是:将需要渲染的 DOM 元素提升为一个独立的层(Layer),然后将这个层的纹理(Texture)上传到 GPU,由 GPU 来完成最终的合成(Composition)和显示。 这个过程被称为 “硬件加速” 或 “GPU 加速”。
(一) 核心原理:从 DOM 到屏幕的渲染流程
要理解 GPU 加速,首先要知道浏览器如何将 DOM 变成像素:
解析:解析 HTML 构建 DOM 树,解析 CSS 构建 CSSOM 树。组合:将 DOM 和 CSSOM 组合成渲染树(Render Tree),包含所有可见元素及其样式。布局:计算每个元素在视窗内的确切位置和大小(Layout/Reflow)。绘制:将元素的每个部分(文本、颜色、边框、阴影等)绘制到多个层中的多个“图块”上(Paint)。此时还是软件操作。合成:浏览器将所有层(包括常规文档流层和提升的层)交给 GPU。GPU 将这些层作为纹理来处理,根据 CSS 的变形、透明度、混合模式等属性,将它们合成为最终的屏幕图像(Composite)。
GPU 加速的关键就在于第 5 步——“合成”。 浏览器通过将某些元素分离成独立的层,让 GPU 来负责这些层的变换和最终合成,从而极大提升性能。
(二) 浏览器何时会创建独立的层(GPU 加速)?
浏览器会根据 CSS 规则自动将一些元素提升为独立的层,但开发者也可以显式地触发。
1. 浏览器自动创建层的情况:
根元素(
)具有 3D 或透视变换的 CSS 属性(
<html>
,
transform: translateZ(0)
,
translate3d()
,
perspective
)使用
3D-transform
属性做动画的元素具有
opacity
属性(如模糊、饱和度)的元素
filter
,
<video>
,
<canvas>
元素位置为
<iframe>
或
fixed
的元素
sticky
2. 开发者显式触发层提升(强制 GPU 加速):
有时,即使浏览器没有自动提升,我们也可以通过特定的 CSS 技巧来“欺骗”浏览器,将一个元素提升到自己的层上。最经典和常用的方法是使用 3D 变形:
.element-to-accelerate {
transform: translateZ(0); /* 或 translate3d(0, 0, 0) */
/* 这并不会产生真实的3D效果,但会提示浏览器启动GPU加速 */
}
或者使用
属性(更现代、更推荐的方式)来提前告知浏览器你将要改变什么属性:
will-change
.element-to-animate {
will-change: transform, opacity; /* 告诉浏览器我准备改变transform和opacity */
}
(三) GPU 加速的优势与代价
1. 优势(为什么我们要这么做):
平滑的动画:GPU 专为大规模并行计算设计,处理像素和几何变换(如移动、缩放、旋转、透明度变化)的速度极快。将动画(尤其是
和
transform
动画)交给 GPU,可以稳定达到 60 FPS,非常流畅。减少重绘:当一个元素在自己的层上时,它的变换不会影响其他层。比如一个按钮的动画只需要GPU合成一下即可,浏览器无需重新计算布局(Reflow)或重新绘制(Repaint)页面的其他部分,性能开销极小。
opacity
2. 代价(不能滥用):
内存占用:每个层都需要额外的内存来存储其纹理(位图图像)。纹理需要上传到 GPU,而 GPU 内存是有限的。过多的层会导致 “层爆炸”,消耗大量内存,特别是在移动设备上,可能反而导致性能下降甚至崩溃。传输开销:创建新层或层的内容发生变化时,需要将纹理从 CPU 主内存传输到 GPU 显存,这个过程有一定开销。如果层的内容频繁变化(如每秒都在改变大量文本),传输开销可能会抵消加速带来的好处。
(四) 实践建议:如何正确使用 GPU 加速
仅对需要高性能动画的元素使用:不要滥用
或
translateZ(0)
。只对那些确实需要做复杂动画、滚动效果或会引起大量重绘的元素使用。使用
will-change
要谨慎:
will-change
应被视为最后的手段,用于优化已知的性能问题。不要预先应用于太多元素,且最好在动画开始前添加、动画结束后移除。优先使用
will-change
和
transform
属性做动画:这两个属性在合成阶段由 GPU 处理,效率最高。避免使用
opacity
,
left
,
top
等会触发 Layout 和 Paint 的属性做动画。用开发者工具分析: Chrome DevTools 中的 Layers 面板可以查看页面有哪些层、层的原因以及层的内存占用。Performance 面板可以记录渲染过程,查看是否因过多的重绘或布局而导致掉帧。
margin
(五) 总结
问题 |
答案 |
DOM 渲染能使用 GPU 吗? |
能,但不是直接处理 DOM,而是处理由 DOM 元素提升而成的层的纹理。 |
核心机制是什么? |
分层 和 合成。浏览器将元素提升为独立的层,GPU 负责这些层的变换和最终合成。 |
如何触发? |
使用特定的 CSS 属性(如 )或 。 |
优点? |
极其平滑的动画,避免不必要的重排和重绘,提升渲染性能。 |
缺点? |
增加内存消耗,滥用会导致“层爆炸”,反而降低性能。 |
因此,GPU 加速是现代浏览器渲染引擎的一个强大功能,但需要开发者理解和正确使用,才能成为性能优化的利器,而不是负担。
二十一、以用户为中心的前端性能指标有哪些?
现代前端性能监控已经从传统的、以技术为中心的指标(如 DOMContentLoaded 时间),全面转向了 以用户为中心的性能指标。
这些指标的核心思想是:衡量性能的好坏,不是看代码跑得多快,而是看用户感受到的体验有多好。
以下是目前最重要的、以用户为中心的前端性能指标,可以分为几个关键的体验阶段:
(一) 加载体验:页面内容是否在快速呈现?
这些指标衡量用户感觉页面“加载”的速度。
LCP – 最大内容绘制
它是什么:测量视口内最大的文本块或图片元素变为可见的时间。为什么重要:它很好地衡量了用户感知到页面“主要内容”已加载的时间点。用户会觉得“哦,页面已经准备好了”。良好标准:最好在 2.5 秒 内。
FCP – 首次内容绘制
它是什么:测量页面从开始加载到任何一部分内容(如文本、图像、SVG)首次渲染完成的时间。为什么重要:它让用户确信“事情正在发生”,打破了白屏,提供了加载的反馈。良好标准:最好在 1.8 秒 内。
(二) 交互体验:页面是否流畅、可响应?
这些指标衡量页面加载后,与用户交互时的体验。
INP – 交互下次绘
它是什么:测量页面所有点击、敲击和键盘交互的延迟,并报告最差的一个(或某个高百分位值)。它将于 2024年3月取代 FID。为什么重要:它反映了页面在整个会话期间的整体响应度。延迟低的交互会让用户觉得应用很快、很流畅。良好标准:低于 200 毫秒。
FID – 首次输入延迟
它是什么:测量用户第一次与页面交互(点击链接、点击按钮)到浏览器实际能够开始处理事件处理程序之间的时间。为什么重要:它量化了用户首次尝试与页面交互时的“卡顿”感。主线程被JS执行阻塞是导致高FID的主要原因。良好标准:低于 100 毫秒。注意:正在被 INP 取代,因为 INP 能衡量整个生命周期的交互,而不仅仅是第一次。
(三) 视觉稳定性:页面内容会意外移动吗?
这个指标衡量页面加载期间内容的视觉稳定性,对用户体验影响极大。
CLS – 累积布局偏移
它是什么:测量整个页面生命周期内发生的所有意外布局偏移的总分数。为什么重要:突然移动的内容(如图片加载后推挤下方文字、动态插入的广告、未指定尺寸的字体或媒体)非常令人讨厌,可能导致误点击和较差的阅读体验。良好标准:低于 0.1。
(四) 核心 Web 指标
Google 将上述指标中的 LCP、INP(取代FID)、CLS 这三项合并称为 核心 Web 指标,它们是评估页面用户体验的最重要指标,并直接影响网站在 Google 搜索中的排名。
(五) 其他重要的以用户为中心的指标
除了核心三大项,以下指标也从不同角度衡量用户体验:
TTI – 可交互时间
它是什么:页面看起来已经渲染完成(FCP),并且能够可靠地响应用户输入的时间点(主线程有足够长的空闲时间)。为什么重要:它标志着“加载”体验的结束和“可交互”体验的开始。用户希望在此之后他们的点击能得到即时响应。
TBT – 总阻塞时间
它是什么:测量 FCP 和 TTI 之间,主线程被阻塞(无法响应用户输入)的总时间。任何超过50毫秒的任务都被认为是“阻塞”的。为什么重要:它量化了页面变得可用之前,用户可能经历的非交互期有多长。优化长任务可以降低TBT,从而改善INP/FID。
(六) 总结与实践
指标 |
英文全称 |
衡量阶段 |
核心问题 |
良好标准 |
LCP |
Largest Contentful Paint |
加载 |
主要内容加载了吗? |
≤ 2.5s |
INP |
Interaction to Next Paint |
交互 |
页面响应快吗? |
≤ 200ms |
CLS |
Cumulative Layout Shift |
视觉稳定 |
页面内容会跳动吗? |
≤ 0.1 |
FCP |
First Contentful Paint |
加载 |
有东西加载出来了吗? |
≤ 1.8s |
FID |
First Input Delay |
交互 |
第一次交互卡顿吗? |
≤ 100ms |
如何测量这些指标?
实验室工具:使用 Lighthouse(集成在Chrome DevTools中)、PageSpeed Insights 在可控环境中模拟和测试。真实用户监控:使用 Chrome User Experience Report 数据或部署像 Google Analytics、Web Vitals JS库 这样的代码来收集真实用户的数据。Field Tools:在Chrome DevTools的 Performance 面板中录制页面加载过程,可以查看FCP、LCP等时间的详细时间线。
优化的最终目标:就是不断优化这些指标,让用户感觉你的网站加载飞快、响应灵敏、稳定可靠,从而提升用户满意度、参与度和转化率。
二十二、有些框架不用虚拟dom,但是他们的性能也不错是为什么?
像 Svelte、Solid.js 甚至更早的 Angular(使用 Ivy 编译器后)等框架,它们在性能上表现卓越,但并不严重依赖 Virtual DOM(虚拟 DOM)。这背后的原因在于它们采用了截然不同的性能优化策略。
要理解这一点,我们首先要明白 Virtual DOM 解决的是什么问题,以及它带来的代价。
(一) Virtual DOM 的核心与代价
它解决了什么?
Virtual DOM 的核心价值是提供了一个声明式和可预测的UI编程模型,同时避免了直接操作昂贵的真实 DOM。它通过“diffing”算法比较新旧虚拟DOM树的差异,然后批量、高效地更新真实DOM。这在大多数情况下比手动操作DOM性能更好,且极大地解放了开发者。它带来了什么代价?(性能开销)
运行时开销:Diffing 算法本身不是免费的。框架必须始终分配内存来创建整个UI的虚拟树(即使只有一小部分数据变了),然后执行一个O(n)复杂度(React的优化算法)的比较过程。这对于复杂的UI来说可能很耗时。“过度渲染”:Virtal DOM 的工作模式通常是:
。即使只有一个文本节点变化,也可能导致整个组件子树被重新“渲染”(执行render函数生成VNode),尽管diff后可能只改一个DOM节点。
状态变化 -> 重新渲染整个组件子树(生成新的VNode树)-> Diff -> 更新DOM
(二) 非Virtual DOM框架的优化策略
那些不用Virtual DOM的框架,通过以下一种或多种策略,绕过了上述开销,从而实现了卓越的性能:
1. 编译时优化 (AOT Compilation) – 代表:Svelte
这是最核心的策略。Svelte 是一个编译器。
原理:在构建时(compile time),Svelte 会分析你的组件代码。它精确地知道状态(变量)和DOM之间的依赖关系。结果:它不会生成运行时的diffing代码,而是生成极尽优化的、命令式的原生JavaScript代码。这些代码直接在状态改变时,执行最小量的、精确的DOM操作。
例子对比:
假设有一个计数器
。
count
React (Virtual DOM):
变化 -> 调用组件的render函数 -> 生成新的VNode树 -> diff算法比较新旧树 -> 发现只有
count
的textContent变了 -> 执行
span
。
span.textContent = newCount
Svelte (Compiler): 在编译时,Svelte 就知道
只影响那个
count
的文本。它会直接生成类似下面的代码:
span
javascript
// 伪代码,Svelte编译后的输出
function increment() {
count++; // 更新状态
span.textContent = count; // 直接、精确地更新DOM!
}
优势:零运行时diff开销。更新速度极快,因为浏览器直接执行最少的必要命令。生成的代码体积也更小,因为不需要打包庞大的运行时库(ReactDOM)。
2. 细粒度的响应式更新 (Fine-Grained Reactivity) – 代表:Solid.js
这种策略的核心是精确跟踪状态与UI的依赖关系。
原理:使用响应式系统(如
)。每个响应式状态(signal)都知道哪些UI部分(计算或Effects)依赖自己。结果:当状态改变时,响应式系统会直接通知并更新那些依赖于此状态的、具体的DOM节点。它完全跳过了“渲染组件”和“生成虚拟树”的步骤。
createSignal
例子对比:
同样是一个计数器
。
count
React: 状态变 -> 重新执行组件函数 -> diff。Solid.js: 你通过
改变状态 -> 响应式系统触发 -> 它知道只有一个
setCount
的textContent依赖于这个
span
-> 直接运行
count
。优势:更新是“靶向”的。没有组件“渲染”的概念,也就没有过度渲染。性能与需要更新的DOM节点数量直接相关,而不是组件树的规模。
span.textContent = newCount
3. 增量DOM (Incremental DOM) – 代表:Angular (Ivy)
这是Google的一种技术,与Virtual DOM思路不同。
原理:它不维护两棵完整的虚拟树。 Instead, it uses instructions.
在编译时,Angular将模板编译成一系列指令。这些指令描述了如何创建和更新DOM节点。当变化检测运行时,它会遍历这些指令,并直接在与当前组件关联的DOM树上应用变更。
优势:内存效率高。因为它不需要在内存中保存整棵虚拟DOM树,它只需要为当前活动的组件分配内存。它的更新过程也更直接,避免了创建完整新树的开销。
(三) 总结对比
特性 |
Virtual DOM (e.g., React) |
编译时优化 (e.g., Svelte) |
细粒度响应式 (e.g., Solid.js) |
核心机制 |
运行时Diff/Patch |
编译时分析生成命令式代码 |
运行时响应式依赖跟踪 |
更新粒度 |
组件级(可能过度渲染) |
DOM节点级(最精确) |
DOM节点级(最精确) |
运行时开销 |
有(Diffing算法) |
极低(几乎无框架运行时) |
低(只有响应式系统开销) |
代码体积 |
较大(需包含运行时库) |
很小(编译器优化,无运行时) |
中等(需包含响应式运行时) |
心智模型 |
简单(UI是状态的函数) |
简单 |
需要理解响应式原理 |
(四) 结论
这些不用 Virtual DOM 的框架性能优异,是因为它们从根本上避免了 Virtual DOM 带来的运行时比较(diffing)开销。
它们通过编译时分析 或 精密的响应式系统,实现了 “状态到UI”的直接、精确的映射。当状态变化时,它们能够绕过“重新渲染”和“比较”的步骤,直接对真实DOM执行最小量的必要操作。
这就像是从一个“先画整个新草图,再和旧草图对比修改”的模式(Virtual DOM),进化到了“有一张蓝图,知道哪个螺丝钉动了就直接去拧”的模式(编译/响应式),后者无疑更加高效和直接。
然而,Virtual DOM 的成功在于其声明式的编程模型和强大的生态系统,其带来的开发者体验和生产力提升,在很多场景下远比那微小的性能开销更重要。这也是为什么它至今仍是主流的原因。选择哪种范式,始终是性能、开发体验和项目需求的权衡。
二十三、如何优化大规模dom操作的场景?
DOM 操作本身是昂贵的,因为它会触发浏览器的重排(Reflow)和重绘(Repaint)。大规模操作时,性能瓶颈会非常明显。
以下是优化大规模 DOM 操作的完整策略和具体方法,从理念到实践,由主到次:
(一) 核心优化原则 (The Golden Rule)
最大限度地减少对真实 DOM 的直接操作次数,并将操作批量处理。
(二) 根本方法:使用文档片段(DocumentFragment)和离线 DOM
当你需要向 DOM 中动态添加大量节点(如一个很长的列表)时,最直接有效的优化是使用
。
DocumentFragment
原理:
是一个轻量级的文档对象,它存在于内存中,不属于主 DOM 树。你可以在其中进行多次 DOM 操作,而不会触发浏览器的重排。最后一次性将其附加到真实 DOM 中,只触发一次重排。
DocumentFragment
示例:
// 糟糕的做法:每次循环都直接操作DOM
const list = document.getElementById('my-list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li); // 触发1000次重排!
}
// 优化的做法:使用 DocumentFragment
const list = document.getElementById('my-list');
const fragment = document.createDocumentFragment(); // 创建文档片段
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // 在内存中操作,0次重排
}
list.appendChild(fragment); // 一次性插入,只触发1次重排
类似技巧:先将目标父节点
,进行所有操作后再显示出来。这也是一种“离线操作”的思路。
display: none
(三) 终极武器:虚拟 DOM (Virtual DOM) 与现代化框架
如果你正在开发一个复杂的、需要频繁更新视图的应用(如 React, Vue, Angular, Svelte),直接使用这些框架就是最好的优化。
原理:
抽象:框架会在内存中维护一个与真实 DOM 结构对应的虚拟 DOM(一个普通的 JavaScript 对象)。差异计算 (Diffing):当状态发生变化时,框架会在内存中生成一个新的虚拟 DOM 树,并与旧的树进行对比(Diff),精确找出需要改变的最小单位。批量更新 (Patching):最后,框架通过高效的算法,批量地将所有这些变化一次性更新到真实 DOM 上。
优势:
自动化:你无需手动优化,框架替你处理了最复杂的部分。声明式编程:你只需要关心“数据状态是什么”,UI 会自动与之同步,而不是一步步命令式地操作 DOM。性能卓越:差异算法(如 React 的 Reconciliation,Vue 的双端 Diff)非常高效,能最大程度减少 DOM 操作。
结论:对于大规模动态应用,采用基于虚拟 DOM 的框架是最高效、最可维护的方案。
(四) 关键技巧:减少重排和重绘
即使不使用框架,理解重排和重绘也能极大帮助你写出高性能的代码。
集中改变样式:
坏:逐行修改样式,每条都会触发重排。
el.style.width = '100px';
el.style.height = '200px';
el.style.margin = '10px';
好:使用
一次性修改,或添加一个 CSS 类来批量修改样式。
cssText
// 方法一:cssText
el.style.cssText = 'width: 100px; height: 200px; margin: 10px;';
// 方法二:添加类名 (最佳实践)
el.classList.add('my-new-style');
.my-new-style {
width: 100px;
height: 200px;
margin: 10px;
}
批量布局读取和写入(避免布局抖动):
问题:交替进行“读取”和“写入”操作会强制浏览器为了给你提供准确的几何信息而频繁进行重排,导致布局抖动(Layout Thrashing)。解决:遵循 “先读后写” 的原则。将所有读取操作批量完成,然后再进行所有的写入操作。
示例:
// 糟糕的写法:读写交替,引发多次重排
const width = element1.offsetWidth; // READ (触发重排以计算)
element1.style.width = width + 10 + 'px'; // WRITE
const height = element2.offsetHeight; // READ (再次触发重排!)
element2.style.height = height + 10 + 'px'; // WRITE
// 优化的写法:先批量读,再批量写
const width = element1.offsetWidth; // READ
const height = element2.offsetHeight; // READ
element1.style.width = width + 10 + 'px'; // WRITE
element2.style.height = height + 10 + 'px'; // WRITE
现代浏览器有自动优化,但手动控制仍是好习惯。可以使用
这样的库来强制管理读写操作。
FastDom
优化 CSS 选择器:
过于复杂的选择器(如
)会增加浏览器计算样式的时间。尽量使用简单的类选择器。
div > ul li a .classname
(五) 其他重要策略
使用
:
requestAnimationFrame
对于视觉动画类的连续 DOM 变化,使用
而不是
requestAnimationFrame
或
setTimeout
。它能保证你的代码在浏览器每一次重绘前执行,避免掉帧,动画更流畅。事件委托(Event Delegation):
setInterval
如果你需要为大量子元素(如列表项)绑定事件(如
),不要为每个子元素单独绑定。
click
原理:利用事件冒泡,只在它们的父元素上绑定一个事件监听器。然后在事件处理函数中通过
来判断是哪个子元素被点击了。优势:极大减少了内存中的事件监听器数量,性能更高,尤其适用于动态添加的元素。
event.target
// 好的做法:事件委托
document.getElementById('my-list').addEventListener('click', function(event) {
if (event.target.tagName === 'LI') {
// 处理li的点击事件
console.log('You clicked on item:', event.target.textContent);
}
});
延迟渲染(Debouncing & Throttling):
对于由频繁事件(如
,
scroll
,
resize
)触发的大量 DOM 操作,必须使用防抖或节流。
input
节流 (Throttling):确保函数在指定时间间隔内只执行一次(如每 100ms 一次)。防抖 (Debouncing):在事件停止触发后一段时间再执行函数(如用户停止输入 300ms 后)。
这可以避免在极短时间内执行大量不必要的计算和 DOM 更新。
(六) 总结与决策树
面对大规模 DOM 操作时,可以按以下思路选择策略:
构建全新大型应用? -> 直接采用现代化框架(React/Vue/Svelte等),利用其虚拟DOM和差异化更新机制。在传统项目或小型模块中需要一次性插入大量节点? -> 使用
或隐藏父元素进行离线操作。需要频繁修改样式? -> 通过切换类名或
DocumentFragment
来批量处理样式更改。在循环中涉及几何属性读写? -> 严格遵守“先读后写” 原则,避免布局抖动。需要为大量元素绑定事件? -> 使用事件委托,在父节点上统一管理。操作由高频事件触发? -> 必须加上防抖或节流 来控制执行频率。
cssText
记住,性能分析永远是第一步。使用 Chrome DevTools 的 Performance 面板来录制和分析你的网站,找到真正的性能瓶颈所在,再针对性地应用上述优化策略。
二十四、谈谈对 window.requestAnimationFrame的理解?
(一) 核心定义:它是什么?
是一个由浏览器提供的、专门用于制作高性能、流畅动画的 Web API。它告诉浏览器你希望执行一个动画,并要求浏览器在下次重绘之前调用指定的函数来更新动画。
window.requestAnimationFrame
你可以把它理解为:
的高性能、专为动画优化的版本。
setTimeout(callback, 0)
(二) 核心工作原理:为什么它更好?
要理解 rAF 的优势,关键在于理解浏览器渲染页面的过程(像素管道)以及它如何与这个过程协同工作。
1. 与浏览器的刷新率同步(最关键的特性)
大多数现代显示器的刷新率是 60Hz,即每秒钟刷新 60 次。这意味着每过大约 16.7ms,浏览器就会尝试绘制一帧新的画面。
或
setTimeout
是独立于浏览器渲染周期的。你可能会写出
setInterval
的代码,但它无法保证精确 timing。它可能在浏览器正在绘制的中途执行,导致丢帧(jank),或者在不必要的时候执行,浪费资源。rAF 完全不同:它会把你的回调函数排队,浏览器保证在下一次页面重绘(即下一帧)之前执行这个回调。这意味着你的动画更新逻辑永远会和浏览器的绘制节奏保持同步,从而实现最大程度的流畅性。
setTimeout(callback, 16.7)
2. 自动节能和后台暂停
当页面被切换到另一个标签页(即不可见或最小化)时,
和
setTimeout
会继续在后台执行,毫无意义地消耗 CPU 资源和电池电量。rAF 非常智能:当页面处于非活动状态时,浏览器会自动暂停 rAF 的回调执行,直到页面再次可见。这能显著降低 CPU、GPU 的占用和电池消耗。
setInterval
3. 批量更新与布局抖动优化
由于 rAF 将所有动画更新都集中在一帧开始前的那一刻执行,这本身就是一种批量处理。这有助于避免布局抖动(Layout Thrashing)。如果你在 rAF 回调中进行样式读取(如
)和写入(如
offsetWidth
),浏览器通常会将这些操作批量处理,比在随机时刻分散地进行这些操作要高效得多。
style.width
(三) 如何使用?
它的用法非常简单,类似于
,但不需要指定时间间隔。
setTimeout
基本语法:
const requestId = window.requestAnimationFrame(callbackFunction);
:浏览器在下次重绘前要调用的函数。该回调函数会自动被传入一个高精度时间戳
callbackFunction
(类似于
DOMHighResTimeStamp
的返回值),代表 rAF 开始排队的时间点。这个时间戳对于制作与时间相关的动画非常有用。
performance.now()
:返回一个 long integer,是回调在队列中的唯一ID。你可以传这个值给
requestId
来取消尚未执行的回调。
window.cancelAnimationFrame(requestId)
递归调用以实现动画循环:
rAF 只会执行一次回调。要创建连续的动画,你必须在回调函数中再次调用它自己。
function animate(timestamp) {
// 1. 在这里更新动画状态 (基于 timestamp 计算)
// 例如:element.style.left = (value) + 'px';
// 2. 递归调用,为下一帧做准备
animationId = requestAnimationFrame(animate);
}
// 启动动画
let animationId = requestAnimationFrame(animate);
// 停止动画
// cancelAnimationFrame(animationId);
(四) 最佳实践与常见用法
1. 基于时间戳的动画
使用回调函数提供的时间戳来计算动画进度,而不是依赖固定的步长。这可以确保动画速度保持一致,不受帧率波动的影响。
let startTime;
function animate(timestamp) {
if (!startTime) startTime = timestamp; // 记录开始时间
const elapsedTime = timestamp - startTime; // 计算已过去的时间
const progress = Math.min(elapsedTime / 1000, 1); // 假设动画时长1秒
// 根据 progress (0到1) 更新元素位置
element.style.transform = `translateX(${progress * 200}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
2. 性能考量:避免在 rAF 中做太多事情
虽然 rAF 很高效,但你的回调函数执行时间必须小于 16.7ms。如果你在回调中进行了非常复杂的计算或大量的 DOM 操作,导致执行时间过长,浏览器依然无法在下一帧前完成绘制,还是会掉帧。对于复杂计算,可以考虑使用 Web Worker。
3. 降级策略
对于不支持 rAF 的旧浏览器(如 IE9及以下),提供一个回退方案(polyfill)是良好的实践。
// 简单的兼容性处理
window.requestAnimationFrame = window.requestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function(callback) {
return setTimeout(callback, 1000 / 60);
};
window.cancelAnimationFrame = window.cancelAnimationFrame ||
window.mozCancelAnimationFrame ||
function(id) {
clearTimeout(id);
};
(五) 与相关技术的对比
特性 |
|
/ |
** timing** |
与浏览器刷新率同步 (如 16.7ms/帧) |
由开发者指定,与渲染不同步 |
准确性 |
高,保证在渲染前执行 |
低,可能被其他任务阻塞导致延迟 |
性能/功耗 |
高效节能,后台页面自动暂停 |
低效耗电,后台仍继续执行 |
设计目的 |
专为平滑动画和高性能渲染优化 |
通用目的的时间延迟/循环 |
(六) 总结
是现代浏览器中实现流畅动画的黄金标准。 它的核心价值在于:
window.requestAnimationFrame
同步:与浏览器渲染节奏完美同步,避免丢帧。高效:智能管理资源,不可见时自动暂停。精准:提供高精度时间戳,便于制作时间驱动动画。
任何需要更新视觉表现(动画、Canvas绘制、WebGL渲染、滚动效果等)的连续操作,都应该优先选择
,而不是
requestAnimationFrame
或
setTimeout
。
setInterval
二十五、CSR、SSR、SSG、NSR、ESR、ISR 都是什么?
(一) 核心概念:渲染发生的地点
客户端渲染 (CSR): 在用户的浏览器中渲染。服务端渲染 (SSR): 在服务器上渲染。静态站点生成 (SSG): 在构建时在服务器上渲染。
(二) CSR – 客户端渲染 (Client-Side Rendering)
是什么? 服务器只发送一个几乎为空的 HTML 外壳和一个 JavaScript 文件。浏览器下载并执行 JS 后,由 JS 动态地创建和渲染页面内容。工作流程:
用户请求网站。服务器返回一个空的
和
index.html
。浏览器显示空白页面或加载动画。浏览器下载并执行
app.js
(通常是 React, Vue, Angular 等框架)。JS 框架开始运行,调用 API 获取数据。数据返回后,JS 动态生成 HTML 并更新 DOM,最终展示完整页面。
app.js
优点:
后续页面切换速度快,体验流畅(SPA 体验)。服务器压力小,前后端分离清晰。
缺点:
首屏加载慢 (FCP 慢):用户需要等待所有 JS 加载执行完毕才能看到内容。SEO 不友好:搜索引擎爬虫可能无法等待 JS 执行完毕,导致无法索引完整内容。
代表: 传统的 React、Vue、Angular SPA 应用。
(三) SSR – 服务端渲染 (Server-Side Rendering)
是什么? 对于每个用户请求,服务器都在其内存中执行 JavaScript,生成完整的 HTML 页面,然后发送给浏览器。浏览器立即可以显示内容。工作流程:
用户请求网站。服务器运行 JS 代码(如 React 组件),调用 API 获取所需数据。服务器将数据和组件渲染成完整的 HTML 字符串。服务器将这个完整的 HTML 发送给浏览器。浏览器立即解析并显示内容(首屏速度快)。浏览器再下载 JS,并“接管”已有的 HTML,使其变得可交互(这个过程叫 Hydration/注水)。
优点:
首屏性能极佳,用户能快速看到内容。SEO 友好,搜索引擎直接获取完整 HTML。
缺点:
服务器压力大,每次请求都需要服务器进行渲染。TTI (可交互时间) 可能较慢:虽然内容很快可见,但要等到 JS 下载并完成 Hydration 后用户才能点击操作。
代表: Next.js, Nuxt.js, SvelteKit 等框架的 SSR 模式。
(四) SSG – 静态站点生成 (Static Site Generation)
是什么? 在项目构建(Build)阶段,就提前将页面预渲染成静态的 HTML 文件。这些文件可以被部署到 CDN 上,用户访问时直接返回现成的 HTML。工作流程:
开发者运行
。框架获取所有需要的数据,并为每个路由路径生成对应的
npm run build
文件。将生成的
.html
或
dist/
文件夹部署到服务器或 CDN。用户请求时,CDN 直接返回预先生成的 HTML 文件,速度极快。
out/
优点:
性能极致:CDN 分发,无需服务器实时计算。安全性高:没有服务器和数据库的动态交互。SEO 友好。
缺点:
只适用于内容变化不频繁的页面(如博客、文档、宣传页)。数据更新需要重新构建和部署整个站点。
代表: Gatsby, Next.js, Nuxt.js, Hugo, Jekyll, VitePress 的静态生成模式。
(五) ISR – 增量静态再生 (Incremental Static Regeneration)
是什么? SSG 的增强版,由 Next.js 首创。它允许你在不重新构建整个站点的情况下,更新或生成静态页面。如何工作?
你可以为每个页面设置一个
时间(例如 60 秒)。在构建时生成静态页面。用户请求时,始终返回已缓存的静态页面。在
revalidate
时间过后,下一个请求会触发后台的页面再生。服务器会重新生成这个页面(用新数据),并更新缓存。当前用户看到的仍是旧页面,下一个用户将会看到新生成的页面。
revalidate
优点:
拥有 SSG 的绝大部分优点(快、安全)。可以更新内容,而无需全站重建,非常适合百万级别的页面站点。
缺点:
用户可能看到稍旧的数据,不是绝对的“实时”。
代表: Next.js 的核心功能。
(六) NSR – 原生服务端渲染 (Native Server Rendering)
是什么? 这是一个相对小众的概念,通常指不依赖大型 JS 框架(如 React, Vue),而是使用原生 Web 平台技术(如 Web Components)配合服务器运行时进行渲染的方案。理念: 追求更小的 JS 体积和更快的 Hydration,甚至无 Hydration。例如,使用 Lit 等库在服务端渲染 Web Components。代表: Lit SSR, 一些边缘计算渲染方案。
(七) ESR – 边缘侧渲染 (Edge Side Rendering)
是什么? 将 SSR 的工作从中心化的源服务器转移到分布在全球各地的 CDN 边缘节点上执行。如何工作?
用户请求到达离他最近的 CDN 边缘节点。该边缘节点运行 JS 代码,渲染出页面 HTML。将渲染好的 HTML 返回给用户。
优点:
极低的 TTFB (首字节时间):因为服务器离用户非常近,网络延迟极低。减轻源服务器压力。
缺点:
边缘节点的计算能力和资源可能有限。
代表: Next.js 配合 Vercel Edge Functions, Cloudflare Workers, Deno Deploy 等边缘计算平台。
(八) 总结与比喻
想象一下开一家餐馆:
CSR: 顾客(浏览器)点了一份“宫保鸡丁”(网页)。你给他端上一盘生鸡肉、一堆花生和调料(JS Bundle),并给他一本菜谱(JS 逻辑)。顾客需要自己在桌子上炒菜(在客户端渲染)。等待时间很长。SSR: 顾客点餐后,你(服务器)在厨房把“宫保鸡丁”完全做好,然后端上去。顾客马上就能吃(首屏快),但筷子(交互性)要过一会儿才给他。SSG: 你在每天开业前(构建时),就把最受欢迎的 100 道菜各做了 50 份,放在保温柜里(CDN)。顾客点餐,你直接从柜子里拿出来给他,速度飞快。但如果有顾客点了一道新菜(动态内容),你就抓瞎了。ISR: 你依然提前做好菜放在保温柜。但你设置了一个规则:宫保鸡丁每10分钟检查一次,如果卖完了,就立刻再做10份补上。保证了供应也保证了新鲜度。ESR: 你在城市的每个区都开了一个分店(边缘节点)。顾客点餐,由最近的分店现场制作,而不是所有人都跑到市中心的总店(源服务器)去,大大缩短了送餐时间。
(九) 如何选择?
策略 |
适用场景 |
CSR |
后台管理系统、高度交互的 Web App、对 SEO 无要求的应用 |
SSR |
社交媒体、新闻网站、电商列表页——需要良好 SEO 和首屏速度的公开页面 |
SSG |
博客、文档、公司官网、营销登陆页——内容几乎不变 |
ISR |
大型电商网站、博客平台——有大量页面且需要定期更新 |
ESR |
全球用户分布广的应用,对延迟极度敏感的场景 |
现代框架如 Next.js、Nuxt.js 和 SvelteKit 的强大之处在于,它们允许你在同一个应用中混合使用多种策略(SSG、SSR、CSR),为每个页面选择最合适的渲染方式。
二十六、讲一下png8、png16、png32的区别,并简单讲讲 png的压缩原理
(一) PNG-8、PNG-24、PNG-32 的区别
这三者的核心区别在于 色彩深度(Color Depth) 和 透明度支持(Alpha Transparency)。
特性 |
PNG-8 |
PNG-24 |
PNG-32 (通常指 PNG-24 + Alpha) |
色彩深度 |
8位 |
24位 |
32位 |
颜色数量 |
最多 256 色 |
约 1677 万色 |
约 1677 万色 |
透明度 |
1位透明度(全透明或不透明)或索引色透明度(类似GIF) |
不支持透明度 |
8位Alpha通道(256级透明度,半透明) |
文件大小 |
小 |
大 |
最大 |
适用场景 |
颜色简单的Logo、图标、几何图形 |
颜色丰富的照片、截图、复杂图像 |
需要平滑半透明效果(如阴影、模糊、投影)的图像 |
详细解释:
PNG-8
工作原理:它使用一个调色板(Palette),这个调色板最多只能存储 256 种颜色(2⁸ = 256)。图像中的每个像素不存储具体的颜色值,而是存储一个指向这个调色板的索引编号。透明度:
1位透明度(Binary Transparency):类似于GIF,某个颜色可以被标记为完全透明或不透明。无法实现半透明。索引色透明度(Index Transparency):可以在调色板中为某种颜色指定一个透明度值,但这仍然是针对整个颜色块,而非每个像素。
优点:文件体积非常小。缺点:如果图像颜色超过256种,会发生色彩抖动(Dithering) 或颜色丢失,导致质量下降。不适合照片。
PNG-24 (Truecolor PNG)
工作原理:它为每个像素存储完整的颜色信息,使用 RGB 三个通道,每个通道8位(8bit × 3 = 24bit)。因此可以表示 2²⁴ ≈ 1677 万种颜色。透明度:不支持任何形式的透明度。如果一个 PNG-24 图像有透明区域,这些区域通常会被填充为白色或其他背景色。优点:色彩丰富,无损,适合保存高质量图片。缺点:文件体积比 PNG-8 大很多。
PNG-32
注意:这是一个非官方但很流行的叫法。从技术上讲,它仍然是 PNG-24 格式,但增加了一个额外的 8位Alpha通道(8bit R + 8bit G + 8bit B + 8bit Alpha = 32bit per pixel)。工作原理:在 RGB 三个通道的基础上,增加了一个 Alpha 通道来存储每个像素的不透明度信息(从 0 完全透明 到 255 完全不透明)。透明度:支持 256级平滑的半透明效果。这是它与 PNG-8 在透明度上的本质区别。优点:完美还原带有阴影、羽化、光晕等复杂透明效果的图像。缺点:文件体积是四种类型中最大的。
简单总结如何选择:
Logo、图标,颜色少且无需半透明 -> PNG-8照片、截图,颜色丰富且无需透明 -> PNG-24需要平滑的半透明效果 -> PNG-32 (带Alpha通道的PNG)
(二) PNG 的压缩原理
PNG 压缩是一个无损压缩过程,意味着压缩前后图像数据完全一致,没有任何质量损失。它主要分为两个阶段:
1. 第一阶段:过滤(Filtering)- 关键步骤
这是在压缩之前对图像数据进行的一种预处理,目的是让数据更容易被压缩。
是什么? PNG 图像会按行(Scanline)进行扫描。对于每一行像素,编码器会尝试应用一种“过滤器”,来预测当前像素的颜色值,并只存储预测值与实际值的差值(Delta)。为什么有效? 相邻像素的颜色通常是相似的(例如,一片蓝色天空)。直接存储原始数据,会得到很多变化不大的数值。而存储“差值”后,这些差值会更小,并且会出现大量的 0 和重复的数字,这为后续的压缩创造了极好的条件。常用过滤器类型:
None: 不进行预测,直接存储原始值。Sub: 用左边像素的值来预测当前像素。
Up: 用上面(上一行)像素的值来预测。
差值 = 当前像素 - 左边像素
Average: 用左边和上面像素的平均值来预测。
差值 = 当前像素 - 上面像素
Paeth: 一种更复杂的预测器,根据左、上、左上三个像素计算出一个预测值。
差值 = 当前像素 - (左边像素 + 上面像素)/2
编码器会为每一行选择一种能产生最小差值的过滤器,从而最大化压缩潜力。
2. 第二阶段:压缩(Compression)
经过过滤阶段后,得到的“差值”数据流充满了冗余信息。PNG 使用著名的 DEFLATE 压缩算法(也是 ZIP 压缩包使用的算法)对其进行最终压缩。
DEFLATE 的工作原理:它结合了 LZ77 算法和 霍夫曼编码(Huffman Coding)。
LZ77 (字典编码):在数据流中寻找重复的字符串(Patterns)。当发现一个重复字符串时,它会用一个(距离,长度)对来代替它,意思是“向前回溯X个字节,复制Y个字节长度的数据”。这消除了数据的冗余性。
例如,字符串
可以被压缩为
ABCABC
,意思是“输出ABC,然后回溯3个字节,复制3个字节”。
ABC(3,3)
霍夫曼编码(熵编码):将出现频率高的符号(如差值0)用更短的二进制码表示,而出现频率低的符号用较长的二进制码表示。这进一步减少了数据的体积。
二十七、script预加载方式有哪些,这些加载方式有何区别?
标签的预加载方式主要围绕着两个核心问题:
<script>
何时加载? (下载脚本的时机)何时执行? (执行脚本的时机和行为)
不同的属性组合决定了不同的答案。下面我们来详细讲解各种方式及其区别。
(一) 核心加载方式与区别
为了更直观地理解,我们先看一张总结图,它描绘了脚本在不同加载方式下的时间线:
上图揭示了每种模式对页面解析的阻塞效应,下面是每种模式的详细说明:
1. 默认情况 (无
async
或
defer
)
async
defer
行为:
立即停止 HTML 解析。开始同步下载脚本(会阻塞解析)。下载完成后,立即执行脚本。脚本执行完毕后,再继续解析 HTML。
示例:
使用场景:极少情况。通常是需要立即执行并严格依赖 DOM 的脚本(但通常也会有更好的方式)。
<script src="example.js"></script>
2.
async
(异步)
async
行为:
脚本的下载过程是异步的,不会阻塞 HTML 解析。脚本下载完成后,会立即执行。执行过程会阻塞 HTML 解析。多个
脚本的执行顺序是不确定的(谁先下载完谁先执行)。
async
示例:
使用场景:完全独立的第三方脚本,如 analytics 分析脚本(Google Analytics)、广告脚本、性能监控脚本等。这些脚本不依赖你的其他脚本,也不被你的其他脚本依赖,其执行顺序无关紧要。
<script async src="example.js"></script>
3.
defer
(延迟)
defer
行为:
脚本的下载过程是异步的,不会阻塞 HTML 解析。脚本的执行会延迟到整个 HTML 文档解析完成之后,但在
事件触发之前。多个
DOMContentLoaded
脚本会严格按照它们在 HTML 中出现的顺序执行,无论谁先下载完。
defer
示例:
使用场景:需要操作 DOM 或依赖其他脚本的脚本。这是将脚本放在
<script defer src="example.js"></script>
中同时又避免阻塞的标准最佳实践。它保证了执行顺序,就像把它们放在
<head>
之前一样,但更早地开始了下载。
</body>
4.
module
与
module async
module
module async
行为:
默认具有
<script type="module">
的行为:异步下载,延迟到解析后按序执行。你也可以给 ES6 模块加上
defer
属性(
async
),此时它的行为就变成了
<script type="module">
模式:执行顺序不再保证。
async
示例:
(类似
<script type="module" src="main.js"></script>
)使用场景:现代浏览器中使用 ES6 模块的场合。
defer
(二) 预加载技术 (Preload/Prefetch)
上面的
和
async
是执行控制,而
defer
和
preload
是资源优先级提示,它们通常与
prefetch
标签一起使用,用于更精细地控制资源的加载。
<link>
1.
preload
preload
是什么:以 高优先级 指示浏览器立即下载指定的资源。用于当前导航中肯定很快就会用到的关键资源。语法:
特点:
<link rel="preload">
浏览器会优先下载,但只是下载,并不执行。你需要另外再写一个
标签来实际执行它(浏览器有缓存,不会重复下载)。必须指定
<script>
属性来明确资源类型,否则优先级会很低。
as
使用场景:预加载关键脚本、字体、CSS 等。例如,在应用入口(如
)中动态导入的组件(Component),可以使用
app.js
提前下载,减少动态导入的等待时间。
preload
2.
prefetch
prefetch
是什么:以 低优先级 指示浏览器在空闲时下载指定的资源。用于未来可能需要的资源(如下一个页面的资源)。语法:
特点:
<link rel="prefetch" as="script" href="next-page.js">
下载优先级非常低,不会影响当前页面的关键资源加载。下载后会存储在磁盘缓存中。
使用场景:优化下一个页面的加载速度。例如,在用户浏览当前页面时,提前悄悄加载下一个页面所需的脚本。
(三) 总结与对比表格
方式 |
加载是否阻塞解析 |
执行时机 |
执行是否阻塞解析 |
顺序保证 |
使用场景 |
默认 |
阻塞 |
下载完后立即执行 |
阻塞 |
是 |
已过时,不推荐 |
|
不阻塞 |
下载完后立即执行 |
阻塞 |
否 |
独立的第三方脚本 |
|
不阻塞 |
HTML 解析完成后执行 |
不阻塞 |
是 |
大多数需要操作DOM的脚本 |
|
不阻塞 |
同 (默认) |
不阻塞 |
是 |
ES6 模块 |
|
(不阻塞,高优先级下载) |
不自动执行,需另设标签 |
– |
– |
关键资源提前加载 |
|
(不阻塞,低优先级下载) |
不自动执行 |
– |
– |
预加载未来页面的资源 |
(四) 最佳实践建议
首选
:将你所有的主要脚本用
defer
引入,放在
<script defer>
里。这能最大程度实现异步下载、不阻塞解析、按序执行的理想效果。第三方用
<head>
:对于不关心执行顺序的第三方脚本,使用
async
。关键资源用
<script async>
:对于隐藏在CSS/JS中、浏览器发现较晚但又至关重要的资源(如关键字体、首屏渲染必需的组件代码),使用
preload
。未来导航用
<link rel="preload">
:如果你能预测用户的下一个操作,可以用
prefetch
来提前加载下一个页面所需的资源,实现“瞬时切换”的效果。
<link rel="prefetch">
二十八、说说 vue3和vue2 中的响应式设计原理?
(一) 核心目标
共同目标:在数据(State)发生变化时,自动更新依赖于这些数据的视图(View)。
(二) Vue 2 的响应式原理:
Object.defineProperty
Object.defineProperty
Vue 2 的响应式系统是基于
API 实现的。
Object.defineProperty
1. 工作流程:
初始化/数据观测:
当你把一个普通的 JavaScript 对象传入 Vue 实例的
选项时,Vue 会遍历此对象的所有属性。对于每个属性,Vue 会使用
data
将其转换为 getter 和 setter。
Object.defineProperty
const data = { count: 1 };
let internalValue = data.count; // 内部存储实际值
Object.defineProperty(data, 'count', {
get() {
console.log('读取 count');
return internalValue; // 返回内部存储的值
},
set(newValue) {
console.log('修改 count');
internalValue = newValue;
// 通知依赖此属性的视图进行更新
dep.notify();
}
});
依赖收集:
在 getter 中进行的操作。当视图中的渲染函数(Render Function) 或计算属性(Computed) 执行时,会读取这些属性,从而触发对应的 getter。Vue 会在一个全局的“靶子”(
类)上记录当前正在执行的组件或函数(称为
Dep
)。这个过程就是依赖收集,建立了“数据属性”和“依赖它的观察者”之间的关系。
Watcher
派发更新:
当属性被修改时,会触发 setter。在 setter 中,会通知之前收集的所有依赖(
),告诉它们:“我变了!”接收到通知的
Watcher
会重新执行(例如,组件会重新渲染),从而更新视图。
Watcher
2. Vue 2 的局限性:
无法检测对象属性的添加或删除:
只能拦截已存在的属性的读取和修改。对于新增的属性(
Object.defineProperty
),Vue 2 无法自动将其变为响应式的,必须使用
obj.newProperty = 'hi'
方法。同样,删除属性(
Vue.set(obj, 'newProperty', 'hi')
)也无法被检测,必须使用
delete obj.property
。
Vue.delete(obj, 'property')
数组响应式的黑客实现:
直接通过索引设置数组项(
)或修改数组长度(
arr[index] = newValue
)不会被检测到。Vue 2 通过重写数组的 7 个变更方法(
arr.length = newLength
,
push
,
pop
,
shift
,
unshift
,
splice
,
sort
)来实现响应式。调用这些方法时,Vue 能够被通知到。
reverse
性能开销:
递归遍历整个对象、一次性转换所有属性的 getter/setter 在初始化时会有一定的性能负担。
(三) Vue 3 的响应式原理:
Proxy
Proxy
Vue 3 使用 ES6 的
来重写响应式系统,彻底解决了 Vue 2 的局限性。
Proxy
1. 工作流程:
初始化/创建代理:
Vue 3 使用
来包装目标对象。
new Proxy(target, handler)
就像一个代理器,可以拦截对目标对象的各种操作,包括读取(
Proxy
)、写入(
get
)、删除(
set
)等共13种,而不仅仅是
deleteProperty
和
get
。
set
const data = { count: 1 };
const reactiveData = new Proxy(data, {
get(target, key, receiver) {
console.log(`读取了 ${key} 属性`);
track(target, key); // 依赖收集
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log(`修改了 ${key} 属性`);
const result = Reflect.set(target, key, value, receiver);
trigger(target, key); // 派发更新
return result;
},
deleteProperty(target, key) {
console.log(`删除了 ${key} 属性`);
const result = Reflect.deleteProperty(target, key);
trigger(target, key); // 派发更新
return result;
}
});
依赖收集:
在
拦截器中,Vue 3 会执行
handler.get
函数来追踪当前正在运行的效应(Effect),建立依赖关系。Vue 3 的依赖收集比 Vue 2 更精细,它基于
track(target, key)
和
target
来建立依赖关系图。
key
派发更新:
在
或
handler.set
等拦截器中,Vue 3 会执行
handler.deleteProperty
函数。这个函数会根据之前收集的依赖图,找到所有依赖于这个
trigger(target, key)
的效应(Effect),并重新执行它们。
target[key]
2. Vue 3 的优势:
全面拦截:
是代理整个对象,而不是对象的某个属性。因此,它可以检测到所有属性的增、删、改、查,包括动态新增的属性,彻底告别
Proxy
和
Vue.set
。
Vue.delete
更好的数组支持:
直接通过索引设置值(
)或修改长度(
arr[i] = x
)都能被完美拦截。对
arr.length = n
,
Map
,
Set
,
WeakMap
等集合类型也提供了原生支持。
WeakSet
性能提升:
只在真正访问到某个属性时才会递归创建代理(惰性转换),减少了初始化的开销。依赖收集的粒度更细,在大型组件树中更新时的性能更好。
Proxy
更丰富的拦截能力:
除了
和
get
,还能拦截
set
(
has
操作符)、
in
(
ownKeys
)等操作,提供了更强大的元编程能力。
Object.keys()
二十九、Vue 模板是如何编译的?
Vue 模板编译是将类 HTML 的模板字符串转换为渲染函数的过程。下面我将通过一个可视化示例来展示这个过程的各个阶段。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue 模板编译过程</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h1 {
color: #3498db;
margin-bottom: 10px;
}
.subtitle {
color: #7f8c8d;
font-size: 1.2rem;
}
.process-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.process-step {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
}
.process-step:hover {
transform: translateY(-5px);
}
.step-header {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.step-number {
background: #3498db;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
font-weight: bold;
}
.step-title {
font-size: 1.2rem;
color: #2c3e50;
}
.step-content {
color: #34495e;
font-size: 0.95rem;
}
.code-block {
background: #2c3e50;
color: #ecf0f1;
padding: 15px;
border-radius: 5px;
margin-top: 10px;
overflow-x: auto;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
}
.demo-section {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-top: 30px;
}
.demo-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.template-input, .compiled-output {
flex: 1;
min-width: 300px;
}
textarea {
width: 100%;
height: 150px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-family: 'Courier New', Courier, monospace;
resize: vertical;
}
button {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
margin-top: 10px;
transition: background 0.3s ease;
}
button:hover {
background: #2980b9;
}
.output {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 5px;
min-height: 150px;
font-family: 'Courier New', Courier, monospace;
white-space: pre-wrap;
}
footer {
text-align: center;
margin-top: 40px;
color: #7f8c8d;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.process-container {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Vue 模板编译过程</h1>
<p class="subtitle">从模板字符串到渲染函数的完整流程解析</p>
</header>
<div class="process-container">
<div class="process-step">
<div class="step-header">
<div class="step-number">1</div>
<h3 class="step-title">解析 (Parse)</h3>
</div>
<div class="step-content">
<p>将模板字符串解析成抽象语法树 (AST)。解析器会识别模板中的标签、属性、表达式和文本内容,并构建一棵树状结构。</p>
<div class="code-block">
// 示例模板<br>
<div id="app"><br>
<p>{{ message }}</p><br>
</div><br><br>
// 解析后的 AST 结构<br>
{<br>
type: 1,<br>
tag: "div",<br>
attrs: [{ name: "id", value: "app" }],<br>
children: [...]<br>
}
</div>
</div>
</div>
<div class="process-step">
<div class="step-header">
<div class="step-number">2</div>
<h3 class="step-title">优化 (Optimize)</h3>
</div>
<div class="step-content">
<p>遍历 AST,标记静态节点和静态根节点。这些节点在重新渲染时会被跳过,从而优化运行时性能。</p>
<div class="code-block">
// 静态节点标记<br>
{<br>
type: 1,<br>
tag: "p",<br>
static: false, // 包含动态绑定<br>
children: [{<br>
type: 2,<br>
text: "{{ message }}",<br>
static: false<br>
}]<br>
}
</div>
</div>
</div>
<div class="process-step">
<div class="step-header">
<div class="step-number">3</div>
<h3 class="step-title">生成 (Generate)</h3>
</div>
<div class="step-content">
<p>将优化后的 AST 转换为渲染函数代码字符串。这个函数执行后会返回虚拟 DOM 节点。</p>
<div class="code-block">
// 生成的渲染函数<br>
function render() {<br>
with(this) {<br>
return _c('div', {<br>
attrs: {"id": "app"}<br>
}, [_c('p', [_v(_s(message))])])<br>
}<br>
}
</div>
</div>
</div>
</div>
<div class="demo-section">
<h2>模板编译演示</h2>
<div class="demo-container">
<div class="template-input">
<h3>输入模板</h3>
<textarea id="templateInput"><div id="app">
<p>{{ message }}</p>
<button @click="increment">点击次数: {{ count }}</button>
</div></textarea>
<button onclick="compileTemplate()">编译模板</button>
</div>
<div class="compiled-output">
<h3>编译结果</h3>
<div class="output" id="output">编译结果将显示在这里...</div>
</div>
</div>
</div>
<footer>
<p>Vue 模板编译过程详解 © 2023</p>
</footer>
</div>
<script>
function compileTemplate() {
const template = document.getElementById('templateInput').value;
// 模拟编译过程
const outputElement = document.getElementById('output');
outputElement.innerHTML = '正在编译...';
setTimeout(() => {
// 模拟编译结果
const compiledCode = `// 生成的渲染函数代码
function render() {
with(this) {
return _c('div', {
attrs: {"id": "app"}
}, [
_c('p', [_v(_s(message))]),
_c('button', {
on: {"click": increment}
}, [_v("点击次数: " + _s(count))])
])
}
}
// 静态渲染函数(优化后)
function staticRender() {
with(this) {
return _c('div', {
attrs: {"id": "app"}
}, [
_c('p', [_v(_s(message))]),
_c('button', {
on: {"click": increment}
}, [_v("点击次数: " + _s(count))])
])
}
}`;
outputElement.textContent = compiledCode;
}, 1000);
}
// 初始化示例
window.onload = function() {
// 初始编译示例模板
compileTemplate();
};
</script>
</body>
</html>
Vue 模板编译过程主要分为三个步骤:
解析 (Parse):将模板字符串解析成抽象语法树 (AST)
使用大量正则表达式对模板进行词法分析识别标签、属性、文本和表达式构建具有父子关系的树形结构
优化 (Optimize):遍历 AST 标记静态节点
标记纯静态节点(永远不会改变的节点)标记静态根节点(子节点全是静态的节点)在重新渲染时跳过这些静态节点,提高性能
生成 (Generate):将 AST 转换为渲染函数代码
递归遍历 AST 树为每个节点生成对应的渲染函数调用最终生成可执行的渲染函数字符串
这个HTML页面提供了一个交互式演示,您可以在左侧输入Vue模板,点击编译按钮后,右侧会显示编译后的渲染函数代码。
三十、Vue 项目中,做过哪些性能优化?
在Vue项目开发中,我通常会从以下几个方面进行性能优化:
(一) 组件层面优化
1、合理使用 v-if 和 v-show
v-if : 适用于条件很少改变的场景,因为它有更高的切换开销v-show : 适用于需要频繁切换的场景,因为它只是简单地切换CSS的display属性根据实际业务场景选择合适的条件渲染方式
2、列表渲染优化
始终为 v-for 提供唯一的 key 值,避免使用数组索引对于大量数据的列表,使用虚拟滚动技术(如 vue-virtual-scroller)合理使用 v-memo 指令缓存子树(Vue 3.2+)
3、组件懒加载
路由级别的懒加载: const Home = () => import('./views/Home.vue')组件级别的懒加载:使用 defineAsyncComponent第三方库的按需引入,避免全量导入
(二) 数据响应式优化
1、避免不必要的响应式数据
使用 Object.freeze() 冻结不需要响应式的大型数据对象在Vue 3中,使用 shallowRef 和 shallowReactive 进行浅层响应式对于只读数据,使用 readonly() 包装
2、计算属性和侦听器优化
优先使用计算属性而不是方法,利用其缓存特性避免在计算属性中进行异步操作或产生副作用合理使用 watchEffect 和 watch 的懒执行选项
(三) 渲染性能优化
1、减少重新渲染
使用 v-once 指令渲染一次性内容合理拆分组件,避免大组件的频繁更新使用 keep-alive 缓存动态组件
2、模板编译优化
避免在模板中使用复杂的表达式将复杂逻辑移到计算属性或方法中使用静态提升(Vue 3自动优化)
(四) 网络请求优化
1、API调用优化
实现请求防抖和节流使用请求缓存机制,避免重复请求合理使用 Promise.all() 并行处理多个请求实现请求取消机制,避免组件卸载后的无效请求
2、数据预加载
在路由切换前预加载关键数据使用 Intersection Observer API 实现图片懒加载合理使用浏览器缓存策略
(五) 构建和打包优化
1、代码分割
路由级别的代码分割第三方库的独立打包使用 Webpack 的 SplitChunksPlugin 优化chunk分割
2、资源优化
图片压缩和格式优化(WebP、AVIF)使用CDN加速静态资源加载启用Gzip或Brotli压缩Tree Shaking 去除未使用的代码
(六) 状态管理优化
1、Vuex/Pinia 优化
合理设计store结构,避免过深的嵌套使用模块化管理大型应用的状态避免在store中存储不必要的数据使用 mapState 、 mapGetters 等辅助函数
2、本地状态优先
优先使用组件本地状态,避免过度使用全局状态合理使用 provide/inject 进行跨层级通信
(七) 内存管理
1、避免内存泄漏
及时清理定时器和事件监听器正确使用 $off 移除事件监听避免闭包中的循环引用合理使用 WeakMap 和 WeakSet
2、组件销毁处理
在 beforeUnmount / destroyed 钩子中清理资源取消未完成的异步操作清理第三方库的实例
(八) 开发工具和监控
1、性能分析
使用 Vue DevTools 分析组件性能利用浏览器的 Performance 面板分析渲染性能使用 Lighthouse 进行综合性能评估
2、监控和告警
集成性能监控工具(如 Sentry)设置关键性能指标的监控告警定期进行性能回归测试
(九) 用户体验优化
1、加载体验
实现骨架屏提升加载体验使用 Loading 状态提示用户合理使用 Suspense 组件(Vue 3)
2、交互优化
实现乐观更新提升交互响应速度使用防抖节流优化用户输入处理合理使用过渡动画提升用户体验
(十) 实际项目中的优化案例
1、长列表优化
使用虚拟滚动处理万级数据展示实现分页或无限滚动减少初始加载时间使用 v-memo 缓存列表项渲染结果
2、表单优化
大型表单的分步骤提交表单验证的防抖处理使用 v-model.lazy 减少不必要的更新
3、图表组件优化
图表数据的增量更新使用 Canvas 替代 SVG 处理大量数据点合理使用图表库的性能配置选项
通过以上这些优化策略的综合运用,通常能够显著提升Vue应用的性能表现。在实际项目中,我会根据具体的业务场景和性能瓶颈,有针对性地选择和实施相应的优化方案。同时,性能优化是一个持续的过程,需要结合监控数据和用户反馈不断调整和改进。
三十一、说下Vite的原理?
Vite 的核心思想是:利用浏览器原生支持 ES 模块(ESM)的能力,将开发环境下的模块编译和打包工作“下放”给浏览器,从而实现极速的启动和热更新。
它可以分为两个关键部分:
开发环境:基于原生 ESM,提供丰富的内置功能。生产环境:使用 Rollup 进行打包,提供最佳性能。
下面我们重点剖析其开发环境的工作原理,这也是 Vite 最精妙的地方。
(一) 开发环境的工作原理
1. 极速的服务启动 – 无需打包(No-Bundle)
传统的打包器(如 Webpack)在启动开发服务器前,必须递归地构建整个应用的依赖图,并将所有模块打包成一个或多个
。这个过程非常耗时,而且随着项目规模的增长呈指数级上升。
bundle
Vite 的做法完全不同:
按需提供源码:Vite 直接将你的源代码分成两类:依赖 和 源码。
依赖:指的是从
导入的第三方包(如
node_modules
,
vue
,
react
)。这些包大多是纯 JavaScript,不会经常变动。Vite 使用 esbuild 对这些依赖进行 预构建。esbuild 是用 Go 编写的,构建速度比 JavaScript 打包器快 10-100 倍。源码:通常是一些需要转换的模块(如 JSX, TS, Vue 组件,或者 CSS)。Vite 会 按需 转换并提供这些源码。即只有当浏览器请求它们时,Vite 才会进行转换并提供。
lodash
原生 ESM 是基石:Vite 通过一个开发服务器,将你的所有文件都作为一个原生 ES 模块来提供服务。
2. 工作流程详解
假设我们有一个简单的
文件:
main.js
javascript
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
启动服务器:当你运行
时,服务器会立刻启动,几乎没有任何延迟,因为它不需要做任何打包工作。浏览器请求:浏览器首先请求根目录下的
vite
。遇到导入语句:浏览器解析
index.html
中的
index.html
,并向服务器请求
<script type="module" src="/src/main.js">
。Vite 进行转换(按需):
/src/main.js
服务器收到对
的请求。它读取文件,发现里面有
/src/main.js
、
import { createApp } from 'vue'
和
import './style.css'
。对于
import App from './App.vue'
:Vite 会识别出这是一个“裸模块”导入(Bare Module Import),它会将其解析为预构建后的依赖文件(位于
vue
目录下),并重写导入路径为
node_modules/.vite/deps
。这样浏览器就能正确发出请求了。对于
/node_modules/.vite/deps/vue.js
:Vite 知道这是一个 Vue 单文件组件(SFC)。它会调用相应的编译插件,将
./App.vue
文件拆解、编译成多个独立的请求(比如
.vue
,
App.vue?type=template
)。对于
App.vue?type=script
:Vite 会将其内容转换为一个 JS 模块,该模块通过
./style.css
将 CSS 插入到页面中。
document.createElement('style')
浏览器处理转换后的模块:浏览器收到转换后的
文件,并看到里面新的导入语句(如
main.js
),然后继续向服务器发起这些新的请求。这个过程会持续直到所有依赖都被加载。
import '/node_modules/.vite/deps/vue.js'
总结这个流程: Vite 将打包的压力从服务器转移给了浏览器。服务器只做“按需编译”,浏览器则通过原生 ESM 的导入机制自然地完成“依赖图构建”。这带来了革命性的启动速度体验。
3. 极速的热模块更新(HMR)
热更新之所以快,也是因为利用了 ESM。
精确的依赖关系:Vite 的每个模块都有明确的依赖关系,因为浏览器已经通过原生 ESM 加载了它们。基于 ESM 的 HMR API:当某个文件被修改时,Vite 只需要精确地让与之相关的链失效。它只需要向浏览器推送一条消息,告诉它哪个模块发生了变化。浏览器直接处理:浏览器接收到这个消息后,会直接通过 ESM 的
重新请求被修改的模块。由于是原生请求,速度极快。Vite 只需要转换这一个文件,而传统的打包器往往需要重新构建变动的模块及其受影响的部分,甚至重新打包整个 bundle。
import
(二) 依赖预构建(Dependency Pre-Bundling)
虽然我们说“无需打包”,但 Vite 确实对依赖做了一个预构建步骤。这是为了解决两个核心问题:
CommonJS / UMD 兼容性:很多旧的 npm 包仍然是 CommonJS 或 UMD 格式的。浏览器无法直接识别这些格式。Vite 使用 esbuild 将它们转换为 ESM 格式。性能优化:将许多内部模块的 ESM 依赖关系转换为单个模块,以提高加载性能。
例如:
有超过 600 个内部模块。如果浏览器直接请求它们,会发出 600+ 个 HTTP 请求!这会造成网络拥塞。预构建会将
lodash-es
打包成一个文件,这样浏览器就只需要一个请求。
lodash-es
预构建只发生在第一次启动服务器时,或者当你安装了新的依赖时。之后的启动会直接使用缓存,速度极快。
(三) 生产环境构建
开发环境不打包是为了速度,但为了生产环境的性能(如代码压缩、代码分割、tree-shaking 等),打包仍然是必要的。
在生产环境中(
),Vite 切换到了 Rollup。
vite build
为什么是 Rollup? Rollup 是一个成熟且高效的打包器,对 ESM 有非常好的支持,并且能生成非常高效的打包代码。一致性:Rollup 的插件 API 与 Vite 的开发服务器插件 API 非常相似(兼容),这保证了开发和生产环境行为的一致性。
当然,你也可以通过配置使用
来为不支持 ESM 的旧浏览器提供回退方案。
@vitejs/plugin-legacy
(四) 总结:Vite 的工作原理
特性 |
传统打包器(如 Webpack) |
Vite |
开发启动 |
打包驱动:先打包整个应用,然后启动服务器。 |
按需编译:先启动服务器,然后按浏览器请求编译提供文件。 |
速度 |
慢,随项目规模增长而变慢。 |
极快,与项目规模关系不大。 |
HMR |
需要重新构建受影响的部分 bundle。 |
精确的 HMR,仅使已更新模块的链失效。 |
生产构建 |
使用自身进行打包。 |
使用 Rollup 进行高效打包。 |
核心机制 |
自己实现模块解析和打包。 |
利用浏览器原生 ESM。 |
简单比喻:
传统打包器:像一家预制菜工厂。客人点餐前,必须把所有可能的菜都先做好、打包好(打包),客人点了才能立刻上菜。准备所有菜很慢,改一道菜也很麻烦。Vite:像一家现代化厨房。客人来了先就座(服务器启动)。客人点了菜单上的菜(浏览器请求),厨师(Vite)立刻用准备好的高级食材(预构建的依赖)和新鲜食材(源码)现场制作(按需编译),迅速上菜。改一道菜只需要重做那一道即可。
正是这种“按需服务”的理念,结合原生 ESM 和现代工具链(esbuild, Rollup),使得 Vite 在开发体验上实现了质的飞跃。
三十二、vue 中怎么实现样式隔离?
(一) Scoped CSS (最常用、Vue 原生支持)
这是 Vue 单文件组件(SFC)中默认推荐的样式隔离方式。通过在
标签上添加
<style>
属性,Vue 会自动为该组件中的所有 CSS 选择器添加一个唯一的属性(如
scoped
),从而实现样式的私有化。
data-v-f3f3eg9
实现方式:
<template>
<div class="my-component">
<p>This is a scoped style paragraph.</p>
</div>
</template>
<style scoped> /* 关键是这个 scoped 属性 */
.my-component {
color: red;
}
p {
background-color: blue;
}
/* 编译后,选择器会变成 .my-component[data-v-f3f3eg9] 和 p[data-v-f3f3eg9] */
</style>
工作原理:
编译阶段,Vue 会为当前组件的所有 DOM 元素添加一个唯一的
属性(例如
data
)。同时,
data-v-f3f3eg9
中的所有 CSS 选择器末尾也会被加上同样的属性选择器(如
<style scoped>
变成
.my-component
)。
.my-component[data-v-f3f3eg9]
优点:
官方支持:开箱即用,无需额外配置。简单高效:几乎满足了绝大多数组件的样式隔离需求。穿透支持:可以使用
伪类来影响子组件样式。
:deep()
缺点与注意事项:
权重更高:由于添加了属性选择器,CSS 选择器的权重会更高,有时会带来优先级问题。无法隔离全局样式:它只防止组件内的样式向外泄露影响其他组件,但不防止全局样式(无
)影响自身。如果全局有一个
scoped
,它依然会影响到这个组件。子组件根元素:使用
p { color: green; }
后,父组件的样式会渗透到子组件的根元素上,这是设计使然,以便父组件可以设置子组件根元素的布局样式。
scoped
(二) CSS Modules (更严格的隔离)
CSS Modules 是一种流行的 CSS 模块化方案,Vue 也提供了原生支持。它会在编译过程中将类名编译成一个唯一的、哈希化的字符串,从根本上杜绝类名冲突。
实现方式:
<template>
<div :class="$style.myComponent"> <!-- 绑定计算后的类名 -->
<p :class="$style.text">This is a CSS Modules paragraph.</p>
</div>
</template>
<style module> /* 使用 module 属性替代 scoped */
.myComponent {
border: 1px solid #ccc;
}
.text {
color: green;
}
/* 编译后,.myComponent 和 .text 会变成类似 ._1yoj8how 和 ._2yslzhand 的哈希字符串 */
</style>
你也可以为模块命名:
<style module="classes"> /* 命名为 classes */
/* ... */
</style>
<template>
<div :class="classes.myComponent"> <!-- 通过你定义的名称来访问 -->
</template>
优点:
绝对隔离:类名被编译成哈希值,不可能发生冲突。显式依赖:样式和类名的绑定关系非常清晰,在模板中一目了然。与 JS 交互能力强:可以通过 JavaScript 动态地操作类名(
是一个对象)。
$style
缺点:
写法稍显繁琐:需要在模板中通过
来绑定。无法使用选择器嵌套:由于类名是动态生成的,像
:class="$style.className"
这种嵌套选择器会失效,必须全部使用扁平化的类名。
p { .my-class } { ... }
(三) 利用 CSS-in-JS (如
vue-emotion
)
vue-emotion
CSS-in-JS 是将 CSS 样式写入 JavaScript 中的一种技术。它在 React 生态中非常流行,在 Vue 中也可以通过一些库来实现。
实现方式(以
为例):
vue-emotion
首先需要安装库:
npm install @egoist/vue-emotion
<template>
<div :class="container">
<p :class="text">This is a CSS-in-JS paragraph.</p>
</div>
</template>
<script>
import { css } from '@egoist/vue-emotion'
export default {
computed: {
container() {
return css`
border: 1px solid #ccc;
padding: 1rem;
`
},
text() {
return css`
color: hotpink;
font-size: 16px;
`
}
}
}
</script>
优点:
极致灵活:可以利用 JavaScript 的全部能力来动态生成样式。真正的隔离:样式完全定义在组件内部。自动添加供应商前缀等好处。
缺点:
运行时开销:样式通常在运行时生成和注入,有一定性能成本。破坏性:不符合传统的 CSS 编写习惯,破坏了 Vue 单文件组件的结构。生态系统:在 Vue 中的流行度和支持度不如 React。
(四) Shadow DOM (最彻底的隔离)
Shadow DOM 是 Web 标准的一部分,它能够将一个隐藏的、独立的 DOM 附加到一个元素上,其样式完全与外部隔离。这通常是编写 Web Components 时使用的方法。
Vue 3.2+ 支持将组件挂载到 Shadow DOM 中。
实现方式:
<!-- MyWebComponent.vue -->
<template>
<div>
<p>This is inside Shadow DOM. Style me!</p>
</div>
</template>
<style>
/* 这里的样式只会作用在这个组件的 Shadow DOM 内部 */
p {
color: purple; /* 外部的样式规则无法覆盖这个颜色 */
}
</style>
在挂载时:
// main.js
import { createApp } from 'vue'
import MyWebComponent from './MyWebComponent.vue'
// 创建一个 Shadow DOM 根节点
const shadowRoot = document.getElementById('app').attachShadow({ mode: 'open' })
createApp(MyWebComponent).mount(shadowRoot)
优点:
最强隔离:样式和 DOM 都被完全封装,外部 CSS 完全无法影响内部,内部的 CSS 也完全不会泄露。Web 标准:是浏览器原生支持的特性。
缺点:
重量级:过于彻底,导致外部很难覆盖其样式(需要使用 CSS 自定义变量或
伪元素等特定方法)。兼容性考虑:虽然现代浏览器支持良好,但在一些旧环境中可能需要 polyfill。工具链支持:一些第三方库的样式可能无法正常注入到 Shadow DOM 中。
::part()
(五) BEM 等命名约定 (人工隔离)
这是一种纯靠人工约定和 discipline 的方法,不属于技术解决方案,但在大型项目中非常有效。
实现方式:
<template>
<div class="my-component">
<p class="my-component__text">This is a BEM paragraph.</p>
<button class="my-component__button my-component__button--primary">Click</button>
</div>
</template>
<style> /* 没有 scoped,全靠类名长且唯一 */
.my-component { ... }
.my-component__text { ... }
.my-component__button { ... }
.my-component__button--primary { ... }
</style>
优点:
无任何技术限制:在任何地方都能用。样式可预测:看类名就知道属于哪个组件。
缺点:
依赖人工:完全靠开发者自觉遵守约定,容易出错。繁琐:类名会很长。
(六) 总结与选择建议
方法 |
适用场景 |
隔离强度 |
推荐度 |
Scoped CSS |
绝大多数 Vue 组件 |
高 |
⭐⭐⭐⭐⭐ (首选) |
CSS Modules |
需要极致类名隔离的复杂组件或项目 |
极高 |
⭐⭐⭐⭐ |
CSS-in-JS |
高度动态样式的组件,喜欢 JS 操作样式的开发者 |
极高 |
⭐⭐ (Vue 中小众) |
Shadow DOM |
需要构建完全隔离的可复用 Web Components |
最高(彻底) |
⭐ (特殊场景) |
BEM 约定 |
大型项目,作为其他方法的补充 |
低(靠约定) |
⭐⭐⭐ (作为辅助) |
一般建议:
默认使用
,它能解决 95% 的样式隔离问题。如果项目非常庞大且复杂,担心即使加了
<style scoped>
也可能有冲突,可以考虑使用 CSS Modules。如果需要开发一套给任何技术栈使用的纯组件库,可以考虑使用 Shadow DOM 实现最强隔离。在任何项目中,都推荐结合 BEM 这类命名约定,即使使用了
scoped
,也能让类名更具可读性和可维护性。
scoped