前端面试
好的,我将逐一回答这些前端面试题。
1. 如何判断用户设备
判断用户设备通常通过以下几种方式:
navigator.userAgent(用户代理字符串): 这是最常见的方式。userAgent字符串包含了浏览器、操作系统、设备类型等信息。- 优点: 简单易用。
- 缺点: 容易被伪造,且字符串格式复杂,需要正则匹配或关键词判断,维护成本高。随着设备类型多样化,判断可能不准确。
- 示例:
1const userAgent = navigator.userAgent; 2let device = 'Unknown'; 3 4if (/Android/i.test(userAgent)) { 5 device = 'Android'; 6} else if (/iPhone|iPad|iPod/i.test(userAgent)) { 7 device = 'iOS'; 8} else if (/Windows Phone/i.test(userAgent)) { 9 device = 'Windows Phone'; 10} else if (/Macintosh/i.test(userAgent)) { 11 device = 'Mac'; 12} else if (/Windows/i.test(userAgent)) { 13 device = 'Windows'; 14} else if (/Linux/i.test(userAgent)) { 15 device = 'Linux'; // 可能是桌面版或移动版 16} 17 18console.log(`User device: ${device}`); 19 20// 判断是否是移动端 21const isMobile = /Mobi|Android|iPhone|iPad|iPod|BlackBerry|Opera Mini|Windows Phone|IEMobile|webOS|NokiaBrowser|UCBrowser/i.test(userAgent); 22console.log(`Is mobile: ${isMobile}`);
- 媒体查询 (Media Queries): 主要用于判断屏幕宽度、高度、DPR 等特性,从而实现响应式布局,间接判断设备类型。
- 优点: 真正关心设备的特性而不是其名称,更可靠,与设备无关。
- 缺点: 无法直接区分是 iOS 还是 Android,或 Windows 还是 Mac。
- 示例:
1if (window.matchMedia("(max-width: 768px)").matches) { 2 console.log("屏幕宽度小于或等于768px,可能是移动设备。"); 3}
navigator.platform: 返回浏览器所在的操作系统平台。- 优点: 相对直接。
- 缺点: 无法区分移动端和桌面端,例如 "MacIntel" 表示 macOS,"Win32" 表示 Windows。
- 示例:
1console.log(navigator.platform); // e.g., "Win32", "MacIntel", "Linux armv7l"
- 触摸事件支持: 通过判断是否支持
ontouchstart或maxTouchPoints > 0来大致判断是否是触摸设备。- 优点: 有助于区分是否有触摸功能。
- 缺点: 笔记本电脑也可能有触摸屏,所以不能完全等同于移动设备。
- 示例:
1const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; 2console.log(`Is touch device: ${isTouchDevice}`);
最佳实践:
通常,结合媒体查询和 userAgent 辅助判断是比较稳妥的方式。媒体查询用于布局适配,userAgent 用于一些特定设备的逻辑(如微信内置浏览器、特定APP环境)。
2. 将多次提交压缩成一次提交
在 Git 中,可以使用 git rebase -i (交互式变基) 命令将多次提交压缩成一次提交。
步骤:
-
确定要压缩的提交范围:
假设你有以下提交历史:commit_A (最旧的提交) commit_B commit_C commit_D (最新的提交)你想将
B,C,D压缩成一个提交。你需要指定A的前一个提交,或者比B更旧的提交作为rebase的起点。
如果你想压缩最近的 N 次提交,可以使用HEAD~N。例如,压缩最近 3 次提交:1git rebase -i HEAD~3 -
编辑交互式变基界面:
执行命令后,会弹出一个文本编辑器,显示类似以下内容:pick <commit-hash-B> commit_B message pick <commit-hash-C> commit_C message pick <commit-hash-D> commit_D message # Rebase <commit-hash-A>..<commit-hash-D> onto <commit-hash-A> (3 commands) # # Commands: # p, pick <commit> = use commit # r, reword <commit> = use commit, but edit the commit message # e, edit <commit> = use commit, but stop for amending # s, squash <commit> = use commit, but meld into previous commit # f, fixup <commit> = like "squash", but discard this commit's log message # x, exec <command> = run command (the rest of the line) for each commit # b, break = stop here (continue rebase later with 'git rebase --continue') # d, drop <commit> = remove commit # l, label <label> = label current HEAD with a name # t, reset <label> = reset HEAD to a label # m, merge [-C | -c] <label> [<strategymodifiers>...] # . create a merge commit using the original merge commit's # . message (or the oneline from the original merge commit). # . Use -c <commit> to reword the commit message. # # These lines can be re-ordered; they are executed from top to bottom. # # If you remove a line here THAT COMMIT WILL BE LOST. # # However, if you remove everything, the rebase will be aborted. # # Note that empty commits are commented outpick表示保留该提交。要压缩,你需要将后续提交的pick命令改为squash或fixup。squash:将该提交合并到前一个提交,并让你编辑新的提交信息。fixup:将该提交合并到前一个提交,但丢弃该提交自己的提交信息,只保留前一个提交的信息。
例如,要将
B,C,D压缩成一个,并将B作为新的提交的主体:pick <commit-hash-B> commit_B message squash <commit-hash-C> commit_C message fixup <commit-hash-D> commit_D message # 也可以是 squash保存并关闭编辑器。
-
编辑新的提交信息:
如果使用了squash,Git 会再次打开编辑器,让你组合或编写新的提交信息。如果所有被压缩的提交都使用fixup,则会直接使用第一个pick提交的提交信息。 -
完成压缩:
保存并关闭提交信息编辑器后,Git 会完成变基操作,将多个提交合并为一个新的提交。
注意事项:
- 不要变基已推送到远程仓库的提交 (尤其是在多人协作的分支上): 变基会重写历史,如果强制推送到远程仓库 (
git push -f),会给其他协作者带来麻烦。只在本地分支或个人分支上使用。 - 确保在执行前保存工作: 虽然 Git 比较安全,但最好确保当前工作区是干净的,并且对操作有所了解。
3. 介绍下navigator.sendBeacon方法
navigator.sendBeacon() 方法用于在浏览器卸载文档 (unloading the document) 之前,以异步和非阻塞的方式发送少量数据到 Web 服务器。
主要特点:
- 异步且非阻塞: 与传统的
XMLHttpRequest或fetch在beforeunload或unload事件中发送数据不同,sendBeacon()不会延迟页面的卸载或导航。它在后台悄悄地发送数据,不影响用户体验。 - 可靠性高: 浏览器保证在页面卸载前,会尽力发送这个请求,即使页面已经关闭或用户已经导航到其他页面。这使得它非常适合发送分析数据、日志或状态更新。
- 少量数据: 主要设计用于发送少量数据,如短字符串、
FormData或Blob。不适合发送大文件。 - 只支持 POST 请求: 数据通过 HTTP POST 请求发送,但不会返回响应。
- 无跨域问题 (通常): 通常用于发送数据到当前域的服务器,但也可以发送到其他域,只要服务器设置了正确的 CORS (Cross-Origin Resource Sharing) 头部。
使用场景:
- 埋点和统计: 在用户离开页面前,发送页面的 PV、停留时间、用户行为等数据,确保数据不丢失。
- 会话结束: 记录用户会话结束前的状态。
- 日志上报: 客户端错误日志、性能数据等。
基本语法:
1navigator.sendBeacon(url, data);
url: 接收数据的目标 URL。data: 要发送的数据。可以是ArrayBuffer,ArrayBufferView,Blob,DOMString,FormData,URLSearchParams。
示例:
1// 在页面卸载前发送统计数据 2window.addEventListener('unload', () => { 3 const data = { 4 page: window.location.pathname, 5 stayTime: Date.now() - performance.navigation.startTime, 6 // ...其他统计数据 7 }; 8 const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); 9 navigator.sendBeacon('/api/log/page_unload', blob); 10}); 11 12// 或者在其他需要确保数据发送的场景 13function sendClickEvent(elementId) { 14 const data = { 15 eventType: 'click', 16 element: elementId, 17 timestamp: Date.now() 18 }; 19 const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); 20 // 即使在用户点击后立即跳转,这个数据也能发送 21 navigator.sendBeacon('/api/log/click_event', blob); 22} 23 24// 假设有一个按钮 25document.getElementById('myButton').addEventListener('click', () => { 26 sendClickEvent('myButton'); 27 // 立即跳转到另一个页面,sendBeacon 仍会尝试发送数据 28 // window.location.href = 'https://example.com/next-page'; 29});
与传统方法的比较:
XMLHttpRequest/fetch在unload事件中: 容易因为请求未完成而导致数据丢失,因为浏览器可能会在请求完成前终止连接。sendBeacon(): 浏览器会将请求放入队列,并在后台发送,即使页面已经关闭。
因此,对于非关键的、统计类的数据发送,sendBeacon() 是一个非常好的选择。
4. 混动跟随导航(电梯导航)该如何实现
混动跟随导航(通常也称为“电梯导航”或“吸顶导航”、“Sticky Header/Sidebar”)是指在页面滚动时,导航栏或某个元素从正常流中脱离,固定在屏幕的某个位置(通常是顶部或侧边),当滚动回其原始位置时又恢复正常。
实现方式主要有两种:CSS 和 JavaScript。
1. CSS position: sticky (推荐)
这是最现代、最简洁、性能最好的方式。
- 原理: 元素在跨越特定阈值(例如,
top: 0)之前是相对定位(position: relative),一旦达到阈值,它就变为固定定位(position: fixed),直到父容器的边缘。 - 优点: 纯 CSS 实现,性能最佳,浏览器原生支持,无需 JavaScript 监听滚动事件。
- 兼容性: 现代浏览器基本都支持。IE 不支持。
示例:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Sticky Navigation</title> 7 <style> 8 body { 9 margin: 0; 10 font-family: sans-serif; 11 min-height: 200vh; /* 制造滚动条 */ 12 background-color: #f0f0f0; 13 } 14 15 .header { 16 background-color: #333; 17 color: white; 18 padding: 20px; 19 text-align: center; 20 } 21 22 .main-content { 23 padding: 20px; 24 } 25 26 .sticky-nav { 27 background-color: #4CAF50; 28 color: white; 29 padding: 10px 20px; 30 text-align: center; 31 /* 关键样式 */ 32 position: sticky; 33 top: 0; /* 当元素距离视口顶部为0时,开始吸顶 */ 34 z-index: 1000; /* 确保在其他内容之上 */ 35 box-shadow: 0 2px 5px rgba(0,0,0,0.2); 36 } 37 38 /* 模拟内容区域 */ 39 .section { 40 height: 500px; 41 background-color: #fff; 42 margin-bottom: 20px; 43 padding: 20px; 44 border-radius: 8px; 45 box-shadow: 0 1px 3px rgba(0,0,0,0.1); 46 } 47 </style> 48</head> 49<body> 50 <div class="header"> 51 <h1>网站标题</h1> 52 <p>页面顶部内容</p> 53 </div> 54 55 <nav class="sticky-nav"> 56 <a href="#section1">章节 1</a> | 57 <a href="#section2">章节 2</a> | 58 <a href="#section3">章节 3</a> 59 </nav> 60 61 <div class="main-content"> 62 <div id="section1" class="section"> 63 <h2>章节 1</h2> 64 <p>这是章节1的内容。向下滚动,导航栏会吸顶。</p> 65 <p>...</p> 66 </div> 67 <div id="section2" class="section"> 68 <h2>章节 2</h2> 69 <p>这是章节2的内容。</p> 70 <p>...</p> 71 </div> 72 <div id="section3" class="section"> 73 <h2>章节 3</h2> 74 <p>这是章节3的内容。继续向下滚动,导航栏会一直吸顶。</p> 75 <p>...</p> 76 </div> 77 <div class="section"> 78 <h2>页面底部内容</h2> 79 <p>继续向下滚动</p> 80 </div> 81 </div> 82</body> 83</html>
2. JavaScript (Scroll Event Listener 或 Intersection Observer)
当需要更复杂的行为(例如,导航栏在吸顶后改变样式、高度,或者有多个吸顶元素联动)时,可能需要 JavaScript。
a. 使用 scroll 事件监听器 (性能较差,需节流/防抖)
- 原理: 监听
window的scroll事件,在回调中判断元素是否滚动到特定位置。 - 优点: 兼容性好,可以实现复杂逻辑。
- 缺点: 频繁触发
scroll事件可能导致性能问题,需要进行节流(throttle)或防抖(debounce)优化。
示例:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Sticky Navigation with JS</title> 7 <style> 8 body { 9 margin: 0; 10 font-family: sans-serif; 11 min-height: 200vh; 12 background-color: #f0f0f0; 13 } 14 15 .header { 16 background-color: #333; 17 color: white; 18 padding: 20px; 19 text-align: center; 20 } 21 22 .main-content { 23 padding: 20px; 24 } 25 26 .js-sticky-nav { 27 background-color: #4CAF50; 28 color: white; 29 padding: 10px 20px; 30 text-align: center; 31 z-index: 1000; 32 transition: all 0.3s ease-in-out; /* 添加过渡效果 */ 33 } 34 35 .js-sticky-nav.fixed { 36 position: fixed; 37 top: 0; 38 left: 0; 39 width: 100%; 40 box-shadow: 0 2px 5px rgba(0,0,0,0.2); 41 } 42 43 /* 模拟内容区域 */ 44 .section { 45 height: 500px; 46 background-color: #fff; 47 margin-bottom: 20px; 48 padding: 20px; 49 border-radius: 8px; 50 box-shadow: 0 1px 3px rgba(0,0,0,0.1); 51 } 52 </style> 53</head> 54<body> 55 <div class="header"> 56 <h1>网站标题</h1> 57 <p>页面顶部内容</p> 58 </div> 59 60 <nav class="js-sticky-nav"> 61 <a href="#section1">章节 1</a> | 62 <a href="#section2">章节 2</a> | 63 <a href="#section3">章节 3</a> 64 </nav> 65 66 <div class="main-content"> 67 <div id="section1" class="section"> 68 <h2>章节 1</h2> 69 <p>这是章节1的内容。向下滚动,导航栏会吸顶。</p> 70 <p>...</p> 71 </div> 72 <div id="section2" class="section"> 73 <h2>章节 2</h2> 74 <p>这是章节2的内容。</p> 75 <p>...</p> 76 </div> 77 <div id="section3" class="section"> 78 <h2>章节 3</h2> 79 <p>这是章节3的内容。继续向下滚动,导航栏会一直吸顶。</p> 80 <p>...</p> 81 </div> 82 <div class="section"> 83 <h2>页面底部内容</h2> 84 <p>继续向下滚动</p> 85 </div> 86 </div> 87 88 <script> 89 const nav = document.querySelector('.js-sticky-nav'); 90 const navOffsetTop = nav.offsetTop; // 获取导航栏距离文档顶部的初始距离 91 92 function handleScroll() { 93 // 当页面滚动距离大于导航栏的初始距离时,添加 fixed 类 94 if (window.pageYOffset >= navOffsetTop) { 95 nav.classList.add('fixed'); 96 // 解决 fixed 导致元素脱离文档流,下面内容上移的问题 97 document.body.style.paddingTop = nav.offsetHeight + 'px'; 98 } else { 99 nav.classList.remove('fixed'); 100 document.body.style.paddingTop = '0'; 101 } 102 } 103 104 // 节流函数 (简单实现) 105 function throttle(func, delay) { 106 let timeout = null; 107 let lastArgs = null; 108 let lastThis = null; 109 110 return function(...args) { 111 lastArgs = args; 112 lastThis = this; 113 if (!timeout) { 114 timeout = setTimeout(() => { 115 func.apply(lastThis, lastArgs); 116 timeout = null; 117 lastArgs = null; 118 lastThis = null; 119 }, delay); 120 } 121 }; 122 } 123 124 window.addEventListener('scroll', throttle(handleScroll, 100)); // 100ms 节流 125 // 首次加载时也检查一次,以防页面初始滚动位置就达到吸顶条件 126 handleScroll(); 127 </script> 128</body> 129</html>
b. 使用 Intersection Observer API (性能更优)
- 原理:
Intersection Observer允许您配置一个回调函数,当目标元素(target element)与根元素(root element)的交叉状态发生变化时,该函数就会被执行。这比监听scroll事件更高效,因为它不依赖于滚动事件的频繁触发。 - 优点: 性能好,非主线程工作,不需要节流/防抖。
- 缺点: 兼容性不如
scroll事件(IE 不支持)。
示例: (需要一个辅助元素来作为观察目标,因为导航栏本身会 fixed 导致脱离文档流)
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Sticky Navigation with Intersection Observer</title> 7 <style> 8 body { 9 margin: 0; 10 font-family: sans-serif; 11 min-height: 200vh; 12 background-color: #f0f0f0; 13 } 14 15 .header { 16 background-color: #333; 17 color: white; 18 padding: 20px; 19 text-align: center; 20 } 21 22 /* 辅助元素,用于观察 */ 23 .observer-target { 24 height: 1px; /* 几乎不可见 */ 25 background-color: transparent; /* 透明 */ 26 margin-top: -1px; /* 放置在导航栏的上方 */ 27 } 28 29 .io-sticky-nav { 30 background-color: #4CAF50; 31 color: white; 32 padding: 10px 20px; 33 text-align: center; 34 z-index: 1000; 35 transition: all 0.3s ease-in-out; 36 } 37 38 .io-sticky-nav.fixed { 39 position: fixed; 40 top: 0; 41 left: 0; 42 width: 100%; 43 box-shadow: 0 2px 5px rgba(0,0,0,0.2); 44 } 45 46 /* 模拟内容区域 */ 47 .section { 48 height: 500px; 49 background-color: #fff; 50 margin-bottom: 20px; 51 padding: 20px; 52 border-radius: 8px; 53 box-shadow: 0 1px 3px rgba(0,0,0,0.1); 54 } 55 </style> 56</head> 57<body> 58 <div class="header"> 59 <h1>网站标题</h1> 60 <p>页面顶部内容</p> 61 </div> 62 63 <!-- 观察目标,放置在导航栏的上方 --> 64 <div class="observer-target"></div> 65 66 <nav class="io-sticky-nav"> 67 <a href="#section1">章节 1</a> | 68 <a href="#section2">章节 2</a> | 69 <a href="#section3">章节 3</a> 70 </nav> 71 72 <div class="main-content"> 73 <div id="section1" class="section"> 74 <h2>章节 1</h2> 75 <p>这是章节1的内容。向下滚动,导航栏会吸顶。</p> 76 <p>...</p> 77 </div> 78 <div id="section2" class="section"> 79 <h2>章节 2</h2> 80 <p>这是章节2的内容。</p> 81 <p>...</p> 82 </div> 83 <div id="section3" class="section"> 84 <h2>章节 3</h2> 85 <p>这是章节3的内容。继续向下滚动,导航栏会一直吸顶。</p> 86 <p>...</p> 87 </div> 88 <div class="section"> 89 <h2>页面底部内容</h2> 90 <p>继续向下滚动</p> 91 </div> 92 </div> 93 94 <script> 95 const nav = document.querySelector('.io-sticky-nav'); 96 const observerTarget = document.querySelector('.observer-target'); 97 98 // 创建 Intersection Observer 实例 99 const observer = new IntersectionObserver( 100 (entries) => { 101 // entries 是一个 IntersectionObserverEntry 对象的数组 102 const [entry] = entries; // 通常只有一个目标元素 103 if (entry.isIntersecting) { 104 // 目标元素进入视口,说明导航栏还未吸顶 105 nav.classList.remove('fixed'); 106 document.body.style.paddingTop = '0'; 107 } else { 108 // 目标元素离开视口,说明导航栏应该吸顶 109 nav.classList.add('fixed'); 110 // 解决 fixed 导致元素脱离文档流,下面内容上移的问题 111 document.body.style.paddingTop = nav.offsetHeight + 'px'; 112 } 113 }, 114 { 115 root: null, // 根元素是视口 116 rootMargin: '0px', // 根元素的边距,默认为0 117 threshold: 0 // 目标元素 0% 进入或离开时触发 118 } 119 ); 120 121 // 开始观察辅助元素 122 observer.observe(observerTarget); 123 </script> 124</body> 125</html>
总结:
- 对于简单的吸顶效果,
position: sticky是首选。 - 如果需要复杂的行为或者更好的兼容性(需要支持旧版 IE),可以使用 JavaScript 监听
scroll事件(并进行节流/防抖)。 - 如果兼容性要求不是特别严格,并且需要比
scroll事件更优的性能,Intersection Observer是一个很好的 JavaScript 替代方案。
5. 退出浏览器之前,发送积压的埋点数据请求,该如何做?
在用户退出浏览器(关闭页签、刷新、导航到其他页面)之前,发送积压的埋点数据请求是一个常见的需求,以确保数据不丢失。实现这一目标的主要方法是使用 navigator.sendBeacon(),并结合 beforeunload 或 unload 事件。
-
navigator.sendBeacon()(推荐)- 优点: 专门为此场景设计。它能保证请求在页面卸载前发送,并且不会阻塞页面卸载,提供更好的用户体验。浏览器会在后台完成发送,即使页面已经关闭。
- 缺点: 只能发送 POST 请求,且不处理响应,主要用于发送数据而不是接收。数据量不能太大。
- 实现:
1// 假设你有一个数组存储了所有待发送的埋点数据 2let analyticsQueue = []; 3 4function collectAnalyticData(data) { 5 analyticsQueue.push(data); 6 // 可以设置一个定时器,每隔一段时间发送一次,避免数据积压过多 7 // 或者在特定关键行为时立即发送 8} 9 10// 在页面卸载时发送所有积压的数据 11window.addEventListener('unload', () => { 12 if (analyticsQueue.length > 0) { 13 const payload = new Blob([JSON.stringify(analyticsQueue)], { type: 'application/json' }); 14 navigator.sendBeacon('/api/analytics/batch', payload); 15 // 发送后清空队列,避免重复发送(如果页面在后台长时间存在的话) 16 analyticsQueue = []; 17 } 18}); 19 20// 示例:收集一些数据 21collectAnalyticData({ event: 'page_view', path: window.location.pathname }); 22collectAnalyticData({ event: 'click', elementId: 'some-button' });
-
beforeunload事件 (配合少量数据发送)- 当用户尝试离开页面时(关闭、刷新、导航),会触发此事件。可以在此事件中执行一些操作。
- 特点: 如果在此事件处理程序中返回一个字符串,浏览器会弹出一个确认框,询问用户是否离开页面。
- 限制: 在此事件中执行同步任务(如
XMLHttpRequest)会阻塞页面卸载,导致用户体验差。发送异步请求(如fetch)也可能因页面过早关闭而失败。 - 适用于: 提醒用户保存数据,或配合
sendBeacon使用。
1window.addEventListener('beforeunload', (event) => { 2 if (unsavedChangesExist()) { // 假设存在未保存的修改 3 event.preventDefault(); // 阻止默认行为(弹窗) 4 event.returnValue = '你有未保存的更改,确定要离开吗?'; // 提示消息 5 } 6 7 // 仍然建议使用 sendBeacon 来发送埋点数据,因为它不会阻塞 8 if (analyticsQueue.length > 0) { 9 const payload = new Blob([JSON.stringify(analyticsQueue)], { type: 'application/json' }); 10 navigator.sendBeacon('/api/analytics/batch', payload); 11 analyticsQueue = []; 12 } 13}); -
unload事件 (已废弃或不推荐用于发送请求)- 在文档即将完全卸载时触发,比
beforeunload更晚。 - 问题: 现代浏览器(尤其是移动端浏览器)为了性能优化,会限制甚至禁止在
unload事件中执行网络请求。请求往往会失败。 - 不推荐用于发送数据。 更多用于清除定时器、取消动画等不涉及网络的操作。
- 在文档即将完全卸载时触发,比
总结:
- 最佳实践是使用
navigator.sendBeacon()结合unload或beforeunload事件。 确保在事件中将所有积压的数据一次性发送出去。 - 避免在
beforeunload或unload中使用传统的XMLHttpRequest或fetch进行网络请求,因为它们可能会阻塞页面或导致请求失败。 - 对于大量数据,考虑在用户正常交互过程中定期发送(例如,每隔 X 分钟发送一次),而不是全部堆积到页面卸载时。
6. 如何统计页面的long task(长任务)
在前端性能优化中,长任务(Long Tasks)是导致页面响应慢、卡顿的重要原因。一个长任务是指执行时间超过 50 毫秒 (ms) 的 JavaScript 任务。统计它们通常使用 PerformanceObserver API。
PerformanceObserver 提供了对性能时间线的异步、非阻塞观察机制。
步骤:
- 创建
PerformanceObserver实例: 传入一个回调函数,当观察到的性能事件发生时,该函数会被调用。 - 指定要观察的
entryType: 对于长任务,entryType是'longtask'。 - 开始观察: 调用
observer.observe()方法。
示例代码:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Long Task Monitoring</title> 7 <style> 8 body { font-family: sans-serif; } 9 button { padding: 10px 20px; font-size: 16px; margin-top: 20px; } 10 </style> 11</head> 12<body> 13 <h1>长任务统计示例</h1> 14 <p>点击下方按钮或执行一些计算密集型操作来模拟长任务。</p> 15 <button id="runLongTask">执行一个模拟长任务</button> 16 <div id="output"></div> 17 18 <script> 19 const outputDiv = document.getElementById('output'); 20 let longTaskCount = 0; 21 let totalLongTaskDuration = 0; 22 23 // 1. 创建 PerformanceObserver 实例 24 const observer = new PerformanceObserver((list) => { 25 // list.getEntries() 返回所有观察到的性能事件列表 26 for (const entry of list.getEntries()) { 27 if (entry.entryType === 'longtask') { 28 longTaskCount++; 29 totalLongTaskDuration += entry.duration; 30 31 console.log('检测到长任务:', entry); 32 outputDiv.innerHTML += `39`; 40 outputDiv.scrollTop = outputDiv.scrollHeight; // 滚动到底部 41 } 42 } 43 console.log(`当前长任务总数: ${longTaskCount}, 总时长: ${totalLongTaskDuration.toFixed(2)} ms`); 44 }); 45 46 // 2. 指定要观察的 entryType 47 // { buffered: true } 表示获取在 observer 注册之前发生的事件 48 observer.observe({ entryTypes: ['longtask'], buffered: true }); 49 50 console.log('PerformanceObserver for longtask started.'); 51 52 // 模拟一个长任务 53 document.getElementById('runLongTask').addEventListener('click', () => { 54 console.log('开始执行模拟长任务...'); 55 const startTime = performance.now(); 56 // 模拟一个耗时的同步计算 57 let sum = 0; 58 for (let i = 0; i < 500000000; i++) { // 足够大的循环来制造长任务 59 sum += Math.sqrt(i); 60 } 61 const endTime = performance.now(); 62 console.log(`模拟长任务完成,耗时: ${(endTime - startTime).toFixed(2)} ms. Sum: ${sum}`); 63 }); 64 65 // 模拟一个短任务 66 setTimeout(() => { 67 console.log('执行一个短任务...'); 68 }, 10); 69 70 // 如果想在页面卸载时报告总数 71 window.addEventListener('beforeunload', () => { 72 console.log(`页面卸载前最终报告:长任务总数: ${longTaskCount}, 总时长: ${totalLongTaskDuration.toFixed(2)} ms`); 73 // 这里可以使用 navigator.sendBeacon 发送最终统计数据 74 // navigator.sendBeacon('/api/longtask-metrics', JSON.stringify({ count: longTaskCount, duration: totalLongTaskDuration })); 75 }); 76 </script> 77</body> 78</html>
PerformanceEntry 属性说明:
对于 longtask 类型的 PerformanceEntry,它包含以下重要属性:
entryType:'longtask'。name: 通常是'self'(表示长任务发生在主线程自身)或'script'(如果是由脚本引起的)。startTime: 长任务开始的时间(相对于performance.timing.navigationStart)。duration: 长任务的持续时间(毫秒)。如果超过 50ms,则被认为是长任务。attribution: 一个数组,提供了关于长任务来源的归因信息。这对于调试非常有用,可以帮助你定位到导致长任务的具体脚本、文件、行号和列号。例如:name: 通常是'script'。url: 导致长任务的脚本文件 URL。lineNumber: 脚本中的行号。columnNumber: 脚本中的列号。containerType: 如果是iframe,则会显示iframe。
通过收集这些数据,可以分析页面在用户交互或加载过程中是否频繁出现长任务,从而定位性能瓶颈并进行优化。
7. PerformanceObserver如何测量页面性能
PerformanceObserver 是一个强大的 Web API,用于异步、非阻塞地收集性能指标。它允许你订阅浏览器发送的各种性能事件,而无需在主线程中频繁查询或轮询。这比传统的使用 performance.getEntries() 或 performance.timing 更加高效和灵活。
核心思想:
PerformanceObserver 充当一个观察者,当特定类型的性能事件发生时,它会触发一个回调函数。这些事件包括:
- 资源加载 (
resource): 测量每个资源(图片、CSS、JS、字体、Fetch/XHR 请求等)的加载时间。 - 导航 (
navigation): 测量页面加载的各个阶段(DNS 查询、TCP 连接、请求发送、响应接收、DOM 解析等)。 - 长任务 (
longtask): 检测主线程上超过 50ms 的长时间运行的 JavaScript 任务(参见上一题)。 - 元素计时 (
element): 测量特定 DOM 元素的渲染时间。 - 事件计时 (
event): 测量事件处理程序的执行时间。 - Paint Timing (
paint): 测量首次内容绘制 (FCP) 和首次绘制 (FP)。 - Largest Contentful Paint (LCP):测量视口中最大可见元素(通常是图片或文本块)的渲染时间。
- First Input Delay (FID):测量用户首次交互(点击、按键)到浏览器实际响应事件处理程序之间的时间。
- Cumulative Layout Shift (CLS):测量页面布局的视觉稳定性。
工作原理:
- 创建实例: 你创建一个
PerformanceObserver实例,并传入一个回调函数。1const observer = new PerformanceObserver((list, observer) => { 2 // 当性能事件发生时,这个回调函数会被调用 3 // list.getEntries() 获取所有观察到的性能事件(PerformanceEntry 对象数组) 4}); - 指定观察类型: 调用
observer.observe()方法,告诉它你对哪种entryType感兴趣。1observer.observe({ entryTypes: ['paint', 'longtask', 'resource'], buffered: true }); 2// buffered: true 表示在 observer 注册之前发生的事件也会被收集。这对于 LCP、FID 等在页面生命周期早期发生的指标非常重要。 - 处理事件: 在回调函数中,你可以遍历
list.getEntries()返回的PerformanceEntry对象,获取详细的性能数据。
如何测量页面性能:
以下是一些使用 PerformanceObserver 测量关键页面性能指标的例子:
1. 首次内容绘制 (FCP) 和首次绘制 (FP):
1const observer = new PerformanceObserver((list) => { 2 for (const entry of list.getEntriesByName('first-contentful-paint')) { 3 console.log('FCP:', entry.startTime); 4 } 5 for (const entry of list.getEntriesByName('first-paint')) { 6 console.log('FP:', entry.startTime); 7 } 8 // FP 和 FCP 通常只发生一次,观察到后可以断开连接 9 observer.disconnect(); 10}); 11observer.observe({ entryTypes: ['paint'] });
2. 最大内容绘制 (LCP):
LCP 可能会在页面加载过程中多次更新,直到页面最终稳定。
1let lcpValue = 0; 2const observer = new PerformanceObserver((list) => { 3 const entries = list.getEntries(); 4 // LCP 取最后一次报告的值 5 const lastEntry = entries[entries.length - 1]; 6 lcpValue = lastEntry.startTime; 7 console.log('Largest Contentful Paint (LCP):', lcpValue, 'ms'); 8 // 注意:LCP 可能会在页面生命周期中多次更新,通常取最后一次稳定后的值。 9 // 在实际应用中,你可能会在页面unload时发送这个最终值。 10}); 11observer.observe({ entryTypes: ['largest-contentful-paint'], buffered: true });
3. 首次输入延迟 (FID):
FID 测量用户首次交互(点击、按键)到浏览器响应事件处理程序的时间。
1const observer = new PerformanceObserver((list) => { 2 for (const entry of list.getEntries()) { 3 const fid = entry.processingStart - entry.startTime; 4 console.log('First Input Delay (FID):', fid, 'ms', entry); 5 observer.disconnect(); // FID 通常只发生一次 6 } 7}); 8observer.observe({ entryTypes: ['first-input'], buffered: true });
4. 累计布局偏移 (CLS):
CLS 衡量页面整个生命周期内的布局稳定性。
1let clsValue = 0; 2const observer = new PerformanceObserver((list) => { 3 for (const entry of list.getEntries()) { 4 if (!entry.hadRecentInput) { // 排除用户交互引起的布局偏移 5 clsValue += entry.value; 6 console.log('Cumulative Layout Shift (CLS) increment:', entry.value, 'Current CLS:', clsValue); 7 } 8 } 9}); 10observer.observe({ entryTypes: ['layout-shift'], buffered: true }); 11 12// 在页面卸载时发送最终的 CLS 值 13window.addEventListener('beforeunload', () => { 14 console.log('Final CLS value:', clsValue); 15 // navigator.sendBeacon('/api/web-vitals', JSON.stringify({ cls: clsValue })); 16});
5. 统计所有资源加载耗时:
1const observer = new PerformanceObserver((list) => { 2 for (const entry of list.getEntries()) { 3 console.log(`资源加载: ${entry.name} - 类型: ${entry.initiatorType} - 耗时: ${entry.duration.toFixed(2)}ms`); 4 // 可以进一步分析: 5 // console.log(` DNS: ${entry.domainLookupEnd - entry.domainLookupStart}ms`); 6 // console.log(` TCP: ${entry.connectEnd - entry.connectStart}ms`); 7 // console.log(` TTFB: ${entry.responseStart - entry.requestStart}ms`); 8 // console.log(` Content Download: ${entry.responseEnd - entry.responseStart}ms`); 9 } 10}); 11observer.observe({ entryTypes: ['resource'] });
优点:
- 异步和非阻塞: 不会阻塞主线程,对页面性能影响小。
- 精准: 提供精确的时间戳和持续时间,以及事件的详细归因信息。
- 灵活: 可以观察多种性能事件类型,并可以根据需求进行过滤。
- 回调机制: 只在有新事件时才触发回调,比轮询更高效。
总结:
PerformanceObserver 是现代前端性能监控和优化的基石,尤其是对于测量 Web Vitals (LCP, FID, CLS) 等关键指标。通过它,开发者可以实时、高效地收集页面性能数据,并将其上报到监控系统进行分析,从而持续优化用户体验。
8. 移动端如何实现下拉滚动加载(顶部加载)
“下拉滚动加载(顶部加载)”通常指的是两种场景:
- “下拉刷新” (Pull-to-Refresh): 在列表顶部下拉,释放后刷新页面内容或加载最新数据。
- “滚动到顶部加载更多”: 例如在聊天应用中,滚动到聊天记录顶部时加载更早的历史消息。
我将主要介绍这两种场景的实现方式。
场景一:下拉刷新 (Pull-to-Refresh)
这是一种常见的移动端手势,用于获取最新内容。实现它通常涉及监听触摸事件 (touchstart, touchmove, touchend)。
基本原理:
touchstart: 记录触摸开始时的 Y 坐标。touchmove:- 检查当前滚动位置是否在顶部 (
scrollTop === 0)。 - 如果用户正在向下滑动且处于顶部,计算下拉距离。
- 根据下拉距离,动态改变一个指示器(例如,一个显示“下拉刷新”或“释放刷新”的元素)的样式。
- 阻止默认的滚动行为(
event.preventDefault()),以确保平滑的下拉体验。
- 检查当前滚动位置是否在顶部 (
touchend:- 如果下拉距离达到阈值,触发刷新操作。
- 显示加载动画。
- 发送数据请求。
- 请求完成后,隐藏加载动画,更新列表,并重置指示器位置。
- 如果未达到阈值,则直接重置指示器。
示例代码 (简化版):
这是一个概念性的实现,实际项目中可能需要更复杂的动画和状态管理。
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> 6 <title>Pull to Refresh</title> 7 <style> 8 body { 9 margin: 0; 10 font-family: sans-serif; 11 background-color: #f0f0f0; 12 overflow-x: hidden; /* 防止水平滚动 */ 13 -webkit-overflow-scrolling: touch; /* 提高iOS滚动流畅性 */ 14 } 15 16 #app { 17 min-height: 100vh; 18 display: flex; 19 flex-direction: column; 20 } 21 22 .refresh-indicator { 23 height: 0; /* 初始高度为0 */ 24 overflow: hidden; 25 background-color: #e0e0e0; 26 text-align: center; 27 line-height: 50px; 28 color: #666; 29 transition: height 0.3s ease-out; /* 高度过渡动画 */ 30 font-size: 14px; 31 } 32 33 .refresh-indicator.pulling { 34 height: 50px; /* 下拉时显示高度 */ 35 } 36 37 .refresh-indicator.loading { 38 height: 50px; 39 background-color: #4CAF50; 40 color: white; 41 } 42 43 .refresh-indicator.loading::before { 44 content: '加载中...'; 45 display: inline-block; 46 margin-right: 10px; 47 } 48 49 .content-list { 50 flex-grow: 1; /* 填充剩余空间 */ 51 padding: 10px; 52 list-style: none; 53 margin: 0; 54 padding: 0; 55 } 56 57 .content-list li { 58 background-color: #fff; 59 margin-bottom: 8px; 60 padding: 15px; 61 border-radius: 5px; 62 box-shadow: 0 1px 3px rgba(0,0,0,0.1); 63 } 64 </style> 65</head> 66<body> 67 <div id="app"> 68 <div class="refresh-indicator" id="refreshIndicator">下拉刷新</div> 69 <ul class="content-list" id="contentList"> 70 <li>初始内容 1</li> 71 <li>初始内容 2</li> 72 <li>初始内容 3</li> 73 <li>初始内容 4</li> 74 <li>初始内容 5</li> 75 <li>初始内容 6</li> 76 <li>初始内容 7</li> 77 <li>初始内容 8</li> 78 <li>初始内容 9</li> 79 <li>初始内容 10</li> 80 </ul> 81 </div> 82 83 <script> 84 const app = document.getElementById('app'); 85 const refreshIndicator = document.getElementById('refreshIndicator'); 86 const contentList = document.getElementById('contentList'); 87 88 let startY = 0; 89 let deltaY = 0; 90 let isRefreshing = false; 91 const REFRESH_THRESHOLD = 80; // 下拉刷新阈值 92 93 app.addEventListener('touchstart', (e) => { 94 if (isRefreshing || app.scrollTop > 0) return; // 如果正在刷新或未在顶部,则不处理 95 startY = e.touches[0].clientY; 96 app.style.transition = ''; // 移除过渡,让拖拽更平滑 97 }); 98 99 app.addEventListener('touchmove', (e) => { 100 if (isRefreshing || app.scrollTop > 0) return; 101 102 deltaY = e.touches[0].clientY - startY; 103 104 if (deltaY > 0) { // 正在向下滑动 105 e.preventDefault(); // 阻止默认的滚动行为 106 const pullDistance = Math.min(deltaY, REFRESH_THRESHOLD * 1.5); // 限制最大下拉距离 107 refreshIndicator.style.height = `${pullDistance}px`; 108 if (pullDistance >= REFRESH_THRESHOLD) { 109 refreshIndicator.textContent = '释放刷新'; 110 } else { 111 refreshIndicator.textContent = '下拉刷新'; 112 } 113 } 114 }, { passive: false }); // 设置 passive: false 以允许 preventDefault 115 116 app.addEventListener('touchend', () => { 117 if (isRefreshing) return; 118 119 if (deltaY > REFRESH_THRESHOLD) { 120 isRefreshing = true; 121 refreshIndicator.textContent = '加载中...'; 122 refreshIndicator.classList.add('loading'); 123 refreshIndicator.style.height = '50px'; // 保持加载高度 124 console.log('触发刷新!'); 125 126 // 模拟数据请求 127 setTimeout(() => { 128 const newItem = `新加载内容 ${Date.now()}`; 129 const li = document.createElement('li'); 130 li.textContent = newItem; 131 contentList.prepend(li); // 加载到顶部 132 133 isRefreshing = false; 134 refreshIndicator.classList.remove('loading'); 135 refreshIndicator.style.height = '0'; 136 refreshIndicator.textContent = '下拉刷新'; // 重置文本 137 deltaY = 0; 138 console.log('刷新完成'); 139 }, 1500); // 模拟网络延迟 140 } else { 141 refreshIndicator.style.height = '0'; 142 deltaY = 0; 143 refreshIndicator.textContent = '下拉刷新'; // 重置文本 144 } 145 }); 146 </script> 147</body> 148</html>
注意: 实际生产中会使用专门的库,如 better-scroll、iScroll 或者各种 UI 框架自带的组件(如 Vant 的 PullRefresh),它们处理了触摸事件的各种复杂情况,以及动画和性能优化。
场景二:滚动到顶部加载更多 (如聊天记录)
这种场景是在滚动条到达顶部时,触发加载更早的数据。
基本原理:
- 监听
scroll事件: 在可滚动容器上监听滚动事件。 - 判断滚动位置: 检查
scrollTop是否接近或等于 0。 - 节流/防抖: 优化滚动事件的触发频率。
- 加载数据: 当条件满足时,触发数据加载,并显示加载状态。加载完成后,将新数据插入到列表的顶部。
- 保持滚动位置: 加载新数据后,需要调整滚动条位置,以避免用户界面跳动。
示例代码:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Scroll to Top Load More</title> 7 <style> 8 body { 9 margin: 0; 10 font-family: sans-serif; 11 background-color: #f0f0f0; 12 } 13 14 .chat-container { 15 width: 90%; 16 max-width: 600px; 17 height: 400px; /* 固定高度,使其可滚动 */ 18 margin: 20px auto; 19 border: 1px solid #ccc; 20 border-radius: 8px; 21 overflow-y: auto; /* 允许垂直滚动 */ 22 display: flex; 23 flex-direction: column-reverse; /* 新消息在底部,旧消息在顶部 */ 24 background-color: #fff; 25 box-shadow: 0 2px 5px rgba(0,0,0,0.1); 26 padding: 10px; 27 } 28 29 .message { 30 background-color: #e0e0e0; 31 padding: 8px 12px; 32 margin-bottom: 8px; 33 border-radius: 15px; 34 align-self: flex-start; /* 默认左对齐 */ 35 max-width: 80%; 36 word-wrap: break-word; 37 } 38 39 .message.self { 40 background-color: #dcf8c6; 41 align-self: flex-end; /* 右对齐 */ 42 } 43 44 .loading-indicator { 45 text-align: center; 46 padding: 10px; 47 color: #666; 48 font-size: 14px; 49 } 50 51 .loading-indicator.hidden { 52 display: none; 53 } 54 </style> 55</head> 56<body> 57 <div class="chat-container" id="chatContainer"> 58 <div class="loading-indicator hidden" id="loadingIndicator">加载历史消息...</div> 59 <!-- 消息会动态插入到这里 --> 60 <div class="message self">这是我的消息 1</div> 61 <div class="message">这是对方的消息 1</div> 62 <div class="message self">这是我的消息 2</div> 63 <div class="message">这是对方的消息 2</div> 64 <div class="message self">这是我的消息 3</div> 65 <div class="message">这是对方的消息 3</div> 66 <div class="message self">这是我的消息 4</div> 67 <div class="message">这是对方的消息 4</div> 68 <div class="message self">这是我的消息 5</div> 69 <div class="message">这是对方的消息 5</div> 70 <div class="message self">这是我的消息 6</div> 71 <div class="message">这是对方的消息 6</div> 72 <div class="message self">这是我的消息 7</div> 73 <div class="message">这是对方的消息 7</div> 74 <div class="message self">这是我的消息 8</div> 75 <div class="message">这是对方的消息 8</div> 76 </div> 77 78 <script> 79 const chatContainer = document.getElementById('chatContainer'); 80 const loadingIndicator = document.getElementById('loadingIndicator'); 81 82 let isLoading = false; 83 let messageCount = 8; // 初始消息数量 84 85 function loadMoreMessages() { 86 if (isLoading) return; 87 isLoading = true; 88 loadingIndicator.classList.remove('hidden'); 89 90 const oldScrollHeight = chatContainer.scrollHeight; // 记录加载前的内容高度 91 92 // 模拟数据请求 93 setTimeout(() => { 94 const fragment = document.createDocumentFragment(); 95 for (let i = 0; i < 5; i++) { // 每次加载 5 条 96 messageCount++; 97 const msgDiv = document.createElement('div'); 98 msgDiv.className = `message ${messageCount % 2 === 0 ? 'self' : ''}`; 99 msgDiv.textContent = `历史消息 ${messageCount}`; 100 fragment.appendChild(msgDiv); 101 } 102 103 // 将新消息插入到容器的顶部 (Flexbox 的 column-reverse 会使它们在视觉上位于顶部) 104 chatContainer.prepend(fragment); 105 106 // 调整滚动位置以保持用户视角 107 const newScrollHeight = chatContainer.scrollHeight; 108 const scrollDiff = newScrollHeight - oldScrollHeight; 109 chatContainer.scrollTop += scrollDiff; // 增加滚动距离以抵消内容增加 110 111 isLoading = false; 112 loadingIndicator.classList.add('hidden'); 113 console.log('加载更多消息完成'); 114 }, 1000); // 模拟网络延迟 115 } 116 117 // 节流函数 118 function throttle(func, delay) { 119 let timeout = null; 120 return function(...args) { 121 if (!timeout) { 122 timeout = setTimeout(() => { 123 func.apply(this, args); 124 timeout = null; 125 }, delay); 126 } 127 }; 128 } 129 130 chatContainer.addEventListener('scroll', throttle(() => { 131 // 当滚动到顶部时 (scrollTop 接近 0) 132 if (chatContainer.scrollTop < 50 && !isLoading) { // 50px 容错距离 133 loadMoreMessages(); 134 } 135 }, 200)); // 200ms 节流 136 137 // 初始滚动到最底部,以便用户看到最新的消息 138 chatContainer.scrollTop = chatContainer.scrollHeight; 139 </script> 140</body> 141</html>
关键点:
- 触摸事件处理 (
touchstart,touchmove,touchend) 对于下拉刷新至关重要。 event.preventDefault()在touchmove中阻止默认滚动,以实现自定义下拉行为。- 判断滚动位置:
scrollTop === 0或接近 0。 - 节流/防抖: 对
scroll和touchmove事件进行优化,避免性能问题。 - 状态管理: 使用
isRefreshing或isLoading变量防止重复加载。 - 用户体验: 提供视觉反馈(指示器、加载动画),并在加载完成后保持用户视角。
- 保持滚动位置: 这是“滚动到顶部加载更多”时非常重要的细节,否则加载新内容会导致页面跳动。
9. 判断页签是否为活跃状态
判断浏览器页签是否为活跃状态(即是否当前可见并处于活动焦点),可以使用 Page Visibility API。
该 API 提供两个主要的属性和一个事件:
document.hidden: 一个布尔值,表示页面是否隐藏。true:页面当前不可见(例如,用户切换到其他页签、最小化浏览器、锁屏)。false:页面当前可见。
document.visibilityState: 一个字符串,表示文档的当前可见状态。可能的值包括:'visible':页面内容至少部分可见。'hidden':页面内容不可见。'prerender':页面正在预渲染(用户可能还没有看到它)。'unloaded':页面正在从内存中卸载(一些旧浏览器或特定情况可能出现)。
visibilitychange事件: 当页面的可见状态发生变化时触发此事件。
示例:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Page Visibility Demo</title> 7 <style> 8 body { font-family: sans-serif; text-align: center; margin-top: 50px; } 9 .status-box { 10 font-size: 24px; 11 padding: 20px; 12 border: 2px solid; 13 display: inline-block; 14 margin-top: 20px; 15 border-radius: 8px; 16 } 17 .status-box.visible { border-color: green; color: green; } 18 .status-box.hidden { border-color: orange; color: orange; } 19 </style> 20</head> 21<body> 22 <h1>页面活跃状态检测</h1> 23 <p>请尝试切换到其他页签、最小化浏览器,然后切换回来观察状态变化。</p> 24 <div class="status-box" id="statusBox"> 25 当前页面状态: <span id="visibilityStatus"></span> 26 </div> 27 28 <script> 29 const visibilityStatusSpan = document.getElementById('visibilityStatus'); 30 const statusBox = document.getElementById('statusBox'); 31 32 function handleVisibilityChange() { 33 if (document.hidden) { 34 console.log('页面进入后台(隐藏状态)'); 35 visibilityStatusSpan.textContent = '隐藏 (hidden)'; 36 statusBox.className = 'status-box hidden'; 37 // 页面隐藏时可以暂停一些不必要的动画、视频播放、降低轮询频率等 38 } else { 39 console.log('页面进入前台(可见状态)'); 40 visibilityStatusSpan.textContent = '可见 (visible)'; 41 statusBox.className = 'status-box visible'; 42 // 页面可见时可以恢复动画、重新加载数据、恢复轮询等 43 } 44 console.log('document.visibilityState:', document.visibilityState); 45 } 46 47 // 监听 visibilitychange 事件 48 document.addEventListener('visibilitychange', handleVisibilityChange); 49 50 // 页面初次加载时检查一次状态 51 handleVisibilityChange(); 52 53 // 示例:每秒更新一个计数器,当页面隐藏时暂停 54 let intervalId; 55 let counter = 0; 56 const counterSpan = document.createElement('p'); 57 counterSpan.textContent = `计数器: ${counter}`; 58 document.body.appendChild(counterSpan); 59 60 function startCounter() { 61 if (intervalId) clearInterval(intervalId); // 确保只有一个计时器 62 intervalId = setInterval(() => { 63 counter++; 64 counterSpan.textContent = `计数器: ${counter}`; 65 }, 1000); 66 } 67 68 function stopCounter() { 69 if (intervalId) { 70 clearInterval(intervalId); 71 intervalId = null; 72 } 73 } 74 75 // 结合 visibilitychange 事件控制计数器 76 document.addEventListener('visibilitychange', () => { 77 if (document.hidden) { 78 stopCounter(); 79 console.log('计数器暂停'); 80 } else { 81 startCounter(); 82 console.log('计数器恢复'); 83 } 84 }); 85 86 // 初始启动计数器 87 if (!document.hidden) { 88 startCounter(); 89 } 90 </script> 91</body> 92</html>
应用场景:
- 节约资源: 当页面不可见时,暂停不必要的动画、视频、音频播放、WebSocket 连接、不重要的后台数据轮询等,以减少 CPU 和电池消耗。
- 数据统计: 统计用户在页面上的实际活跃时间。
- 消息通知: 当页面不活跃时,可以通过浏览器通知(
Notification API)提醒用户有新消息。 - 游戏/互动应用: 页面隐藏时暂停游戏进程。
- 会话管理: 页面长时间隐藏后,可以考虑延长或结束用户会话。
通过 Page Visibility API,开发者可以创建更高效、更节能、用户体验更好的 Web 应用。
10. 在网络带宽一定的情况下,切片上传感觉和整体上传消费的时间应该是差不多的这种说法正确吗?
这种说法不完全正确。从纯粹的数据传输总量和理论带宽利用率来看,切片上传和整体上传在“总数据量”上是一样的,因此在理想网络条件下,总的传输时间可能看似差不多。但是,实际情况下,切片上传在很多方面具有明显优势,从而可能显著减少总的“消费时间”(包括用户感知时间和实际完成时间)。
我们来分析一下:
整体上传(单文件)的劣势:
- 网络中断容错差: 如果上传过程中网络中断,整个文件都可能需要重新上传。
- 大文件内存占用: 客户端或服务器可能需要一次性处理整个文件,对内存压力较大。
- 用户体验差: 无法实时显示精确的上传进度,如果文件很大,用户会长时间等待,且不知道具体进度。
- 服务器处理压力: 服务器可能一次性接收并存储大文件,在文件完成前无法进行后续处理。
- 无法并发: 单个大文件的上传是串行的。
切片上传的优势:
- 断点续传/容错性:
- 核心优势。 每个切片是独立上传的。如果某个切片失败,只需要重传该切片;如果网络中断,下次可以从已上传的切片继续,大大节省了重传的时间和带宽。
- 对于大文件,从头重新上传的成本非常高。
- 提升并发度:
- 多线程上传: 浏览器通常会对同一域名下的请求限制并发数量(如 HTTP/1.1 下通常是 6-8 个)。但是,如果你有多个切片,可以同时上传多个切片,或者利用多个连接并发上传,从而充分利用网络带宽,加速上传过程。HTTP/2 和 HTTP/3 在这方面更进一步,可以更好地复用连接。
- 即使只有一个 TCP 连接,切片也可以在服务器端被并行处理。
- 更好的用户体验:
- 实时进度: 每个切片上传完成后,可以立即更新上传进度条,给用户明确的反馈。
- 快速恢复: 断网后可以快速恢复上传。
- 降低内存压力:
- 客户端和服务器可以逐个处理切片,不需要一次性将整个文件读入内存,降低了系统资源的消耗。
- 服务器端处理优化:
- 服务器可以接收到切片后立即对其进行处理(如存储到临时位置、进行哈希校验等),而无需等待整个文件上传完成。
- 支持分布式存储,不同切片可以存储在不同服务器上。
- CDN 友好: 如果结合 CDN,切片更容易被分发到边缘节点,提高上传效率。
- 带宽利用率: 对于带宽波动或高延迟的网络环境,切片上传可以通过并发请求更好地“填满”带宽,减少空闲时间。例如,一个连接可能因为延迟而无法达到最大吞吐量,但多个并发连接可以共同接近带宽上限。
结论:
虽然理论上在“相同带宽、无中断、单连接”的理想情况下,总的字节传输时间可能相似,但实际应用中,切片上传在文件上传的“消费时间”(包含用户感知时间、从中断中恢复的时间、总任务完成时间)上通常优于整体上传。它的核心优势在于提高容错性、提升并发能力、优化用户体验以及更灵活的服务器处理能力。对于大文件上传,切片上传是目前公认的最佳实践。
11. 大文件切片上传的时候,确定切片数量的时候,有那些考量因素
确定大文件切片上传时的切片数量(或切片大小)是一个需要综合考虑多方面因素的决策。理想的切片大小介于能够充分利用网络和服务器资源,同时又能兼顾稳定性和容错性之间。
以下是主要的考量因素:
-
网络环境(带宽和稳定性):
- 带宽: 带宽越大,单个切片可以越大,从而减少总的请求次数和 HTTP 握手开销。
- 稳定性: 如果网络不稳定,频繁断线,那么切片应该小一些。这样即使某个切片上传失败,重传的代价也小。反之,如果网络非常稳定,可以考虑较大的切片。
- 延迟 (Latency): 延迟高时,每个请求的建立连接和握手时间占比会增大,因此增大切片大小可以减少总的握手次数,降低延迟的影响。
-
服务器并发处理能力:
- 并发限制: 服务器能够同时处理的连接数和请求数量有限。如果切片过多(过小),可能导致服务器连接数飙升,超出其处理能力,反而降低效率。
- I/O 性能: 服务器处理每个切片的写入和校验操作也需要时间。过小的切片可能导致频繁的磁盘 I/O 操作,增加服务器负载。
- 合并操作: 切片全部上传后,服务器需要将它们合并成完整文件。切片数量越多,合并的复杂度(虽然通常是线性的)和时间可能会增加。
-
客户端资源限制:
- 内存: 浏览器或客户端应用程序在处理文件切片时需要一定的内存。虽然切片上传的主要优点之一是减少内存占用,但过小的切片数量(意味着单个切片很大)可能在某些极端情况下对内存造成压力。
- 浏览器并发连接数: 浏览器对同一个域名下的并发请求数通常有限制(如 HTTP/1.1 通常为 6-8 个)。如果切片数量超过这个限制,多余的切片会排队等待,无法实现完全并发。HTTP/2 和 HTTP/3 在这方面有所改进,但仍需注意。
-
重试成本与容错性:
- 失败重传代价: 切片越大,单个切片重传的成本(时间和带宽)越高。切片越小,重传的成本越低,但总请求数越多,失败概率累加。
- 平衡: 需要在“减少总请求数”和“降低单次重传代价”之间找到平衡。
-
HTTP/HTTPS 开销:
- 每个 HTTP 请求都有一定的头部开销(Header Overhead)和连接建立/关闭开销(TCP Handshake, TLS Handshake)。
- 切片数量越多(切片越小),总的 HTTP 请求数越多,这些开销累加起来就越大。
-
文件总大小:
- 对于极小的文件(例如几百 KB),切片上传的开销可能大于其带来的收益,直接上传可能更简单高效。
- 对于超大文件(几十 GB 甚至 TB),切片必须足够小以保证容错性,但又不能太小导致请求数量爆炸。
-
用户体验:
- 进度反馈: 适当的切片大小可以提供更平滑、更频繁的进度更新。
- 等待时间: 影响整体上传速度,进而影响用户等待时间。
经验法则/常见做法:
- 常见切片大小: 通常在 1MB 到 10MB 之间。
- 对于普通文件上传,1MB 到 4MB 是比较常见的选择。
- 对于超大文件,可以考虑 5MB 到 20MB。
- 自适应策略: 高级实现可能会根据用户网络状况、文件大小动态调整切片大小。例如,如果网络稳定,可以尝试更大的切片;如果网络不稳定,可以降低切片大小并增加重试次数。
- 文件数量: 如果有多个文件上传,也可以考虑将它们视作一个整体进行切片,或者单独切片后并发上传。
总结:
没有一个万能的最佳切片大小,需要根据具体的应用场景、目标用户网络环境、服务器性能和文件特点进行权衡和测试。最佳实践是在一个合理的范围内选择一个切片大小,并进行实际测试和监控,以便在性能和稳定性之间找到最佳平衡点。
12. 页面关闭时执行方法,该如何做
在前端,当页面关闭(包括页签关闭、浏览器关闭、页面刷新、导航到其他页面)时,可以执行一些方法。主要依赖浏览器提供的生命周期事件。
主要的事件有:
-
beforeunload事件:- 触发时机: 在页面即将卸载之前触发。当用户试图离开页面时(例如,关闭页签、刷新、点击链接跳转),会立即触发此事件。
- 用途: 主要用于提醒用户保存未保存的数据,防止意外离开。如果事件处理程序返回一个非空字符串,浏览器会弹出一个确认对话框,询问用户是否确定离开。
- 限制:
- 在此事件中执行耗时操作(如同步 XHR 请求)会阻塞页面,导致用户体验差。
- 异步请求(如
fetch)可能不会完成,因为浏览器可能会在请求完成前终止连接。 - 提示文本的显示行为在不同浏览器中有所差异,有些浏览器不再显示自定义文本,只显示通用提示。
- 推荐用途: 配合
navigator.sendBeacon()传输少量数据,或作为用户离开确认的提示。
1window.addEventListener('beforeunload', (event) => { 2 // 1. 提醒用户保存数据(如果需要) 3 const hasUnsavedChanges = true; // 假设有未保存的更改 4 if (hasUnsavedChanges) { 5 // 现代浏览器通常忽略自定义文本,只显示通用提示 6 event.preventDefault(); // 标准写法 7 event.returnValue = '您有未保存的更改,确定要离开吗?'; // 兼容旧浏览器 8 console.log('用户被提示离开页面'); 9 } 10 11 // 2. 发送埋点/日志数据(推荐使用 sendBeacon) 12 const analyticsData = { 13 page: window.location.pathname, 14 timestamp: Date.now(), 15 // ...其他数据 16 }; 17 const blob = new Blob([JSON.stringify(analyticsData)], { type: 'application/json' }); 18 navigator.sendBeacon('/api/log_on_unload', blob); 19 console.log('使用 sendBeacon 发送数据...'); 20}); -
unload事件:- 触发时机: 在文档完全卸载之前触发,比
beforeunload更晚。 - 用途: 理论上可以用来执行一些清理工作(如清除定时器、取消动画、释放资源)。
- 限制:
- 不推荐在此事件中发送网络请求。 现代浏览器(尤其是移动端浏览器)为了性能和用户体验,可能会限制甚至完全阻止在
unload事件中执行异步请求。请求通常会失败。 - 处理程序中的任何耗时操作都会严重影响页面卸载速度。
- 不推荐在此事件中发送网络请求。 现代浏览器(尤其是移动端浏览器)为了性能和用户体验,可能会限制甚至完全阻止在
- 推荐用途: 仅用于非常轻量的、不涉及网络请求的清理工作。 对于发送数据,强烈建议使用
sendBeacon。
1window.addEventListener('unload', () => { 2 console.log('页面正在卸载...'); 3 // 清理工作,例如: 4 // clearInterval(myInterval); 5 // clearTimeout(myTimeout); 6 // 关闭 WebSockets 连接等 7 8 // 不建议在这里发送网络请求,因为可能不成功 9 // fetch('/api/end_session', { method: 'POST' }); // 可能会失败 10}); - 触发时机: 在文档完全卸载之前触发,比
总结与最佳实践:
- 发送数据: 始终优先使用
navigator.sendBeacon()。它能保证数据在页面卸载前可靠发送,且不阻塞页面。将其放在beforeunload或unload事件监听器中都可以(unload更保险,因为不会触发确认弹窗,但beforeunload可以更早地发送数据)。 - 用户确认: 使用
beforeunload事件返回字符串来提示用户保存数据。 - 清理工作: 使用
unload事件进行不涉及网络请求的轻量级清理(例如,清除定时器)。 - 避免阻塞: 绝对避免在这些事件中执行耗时的同步 JavaScript 代码或不可靠的异步网络请求。
- Visibility API: 对于非卸载场景(如用户只是切换了页签),考虑使用
document.visibilityState和visibilitychange事件来暂停或恢复不必要的任务,以节约资源。
例如,一个完整的退出处理可能这样设计:
1// 存储待发送的埋点数据队列 2const pendingAnalytics = []; 3 4function addAnalyticEvent(eventData) { 5 pendingAnalytics.push(eventData); 6} 7 8// 示例:在某个操作发生时添加埋点数据 9document.getElementById('some-button').addEventListener('click', () => { 10 addAnalyticEvent({ type: 'button_click', id: 'some-button', time: Date.now() }); 11}); 12 13// 在页面卸载前,通过 sendBeacon 发送所有积压的埋点数据 14window.addEventListener('beforeunload', (event) => { 15 // 假设有未保存的表单数据,提示用户 16 if (document.getElementById('myForm') && document.getElementById('myForm').dataset.isDirty === 'true') { 17 event.preventDefault(); 18 event.returnValue = ''; // 现代浏览器推荐空字符串 19 } 20 21 if (pendingAnalytics.length > 0) { 22 const payload = new Blob([JSON.stringify(pendingAnalytics)], { type: 'application/json' }); 23 navigator.sendBeacon('/api/batch_analytics', payload); 24 console.log('Analytics data sent via sendBeacon.'); 25 pendingAnalytics.length = 0; // 清空队列 26 } 27 28 // 这里可以执行一些非常轻量的同步清理,但不推荐进行网络请求 29 // console.log('Performing light cleanup before unload.'); 30}); 31 32// 更安全的清理,确保不会阻塞主线程或导致网络请求失败 33window.addEventListener('unload', () => { 34 // 确保所有 WebSockets 连接关闭 35 // myWebSocket?.close(); 36 // 清除所有全局定时器 37 // if (myGlobalInterval) clearInterval(myGlobalInterval); 38 console.log('Unload event triggered. Performing final cleanup.'); 39});
13. 如何统计用户pv访问的发起请求数量
统计用户 PV (Page View) 访问时发起的请求数量,可以从多个层面进行:
-
浏览器性能 API (Performance API):
这是最准确和推荐的方式,它提供了浏览器加载页面时所有资源的详细计时信息。performance.getEntriesByType('resource'): 返回一个PerformanceResourceTiming对象的数组,包含了页面加载的所有静态资源(图片、CSS、JS、字体等)和XMLHttpRequest/Fetch请求的详细信息。performance.getEntriesByType('navigation'): 返回一个PerformanceNavigationTiming对象(通常只有一个),包含了页面主文档加载的详细信息。
示例代码:
1// 在页面加载完成后获取 2window.addEventListener('load', () => { 3 const resources = performance.getEntriesByType('resource'); 4 const navigation = performance.getEntriesByType('navigation'); 5 6 // 计算所有资源请求的数量 7 const resourceRequestCount = resources.length; 8 9 // 导航请求(主文档)也算一个请求 10 const totalRequestCount = resourceRequestCount + (navigation.length > 0 ? 1 : 0); 11 12 console.log(`页面总请求数量 (包括主文档和所有资源): ${totalRequestCount}`); 13 console.log('详细资源请求列表:', resources.map(r => ({ 14 name: r.name, 15 initiatorType: r.initiatorType, // link, script, img, fetch, xmlhttprequest 等 16 duration: r.duration, 17 decodedBodySize: r.decodedBodySize // 资源大小 18 }))); 19 20 // 上报数据 21 // navigator.sendBeacon('/api/metrics/request_count', JSON.stringify({ 22 // url: window.location.href, 23 // totalRequests: totalRequestCount, 24 // resourceDetails: resources.map(r => ({ name: r.name, type: r.initiatorType })) 25 // })); 26}); 27 28// 针对动态加载或后续发起的请求,可以使用 PerformanceObserver 持续监听 29const observer = new PerformanceObserver((list) => { 30 list.getEntries().forEach(entry => { 31 if (entry.entryType === 'resource') { 32 console.log(`新的资源请求: ${entry.name}, 类型: ${entry.initiatorType}`); 33 // 可以在这里累加计数并发送 34 } 35 }); 36}); 37observer.observe({ entryTypes: ['resource'] }); -
重写
XMLHttpRequest和fetch:
这种方法可以精确地捕获所有由 JavaScript 代码发起的 XHR 和 Fetch 请求。示例代码:
1let xhrRequestCount = 0; 2let fetchRequestCount = 0; 3 4// 重写 XMLHttpRequest 5const originalXHR = window.XMLHttpRequest; 6window.XMLHttpRequest = function() { 7 const xhr = new originalXHR(); 8 xhr.addEventListener('loadstart', () => { 9 xhrRequestCount++; 10 console.log(`XHR 请求开始 (${xhrRequestCount}): ${xhr.responseURL || xhr.currentUrl}`); 11 }); 12 xhr.addEventListener('loadend', () => { 13 // 可以根据需要在这里判断请求成功或失败 14 }); 15 return xhr; 16}; 17 18// 重写 Fetch API 19const originalFetch = window.fetch; 20window.fetch = function(...args) { 21 fetchRequestCount++; 22 console.log(`Fetch 请求开始 (${fetchRequestCount}): ${args[0]}`); 23 return originalFetch.apply(this, args).then(response => { 24 // 可以在这里处理响应,例如判断状态码 25 return response; 26 }).catch(error => { 27 console.error(`Fetch 请求失败: ${args[0]}`, error); 28 throw error; 29 }); 30}; 31 32// 页面加载完成后总计(不包括静态资源) 33window.addEventListener('load', () => { 34 console.log(`页面加载完成时:XHR 请求总数: ${xhrRequestCount}, Fetch 请求总数: ${fetchRequestCount}`); 35 // 将这些数据与 Performance API 获取的静态资源数据结合,得到总数 36}); 37 38// 示例:发起一些请求 39fetch('https://jsonplaceholder.typicode.com/todos/1') 40 .then(res => res.json()) 41 .then(data => console.log('Fetch Data:', data)); 42 43const xhr = new XMLHttpRequest(); 44xhr.open('GET', 'https://jsonplaceholder.typicode.com/posts/1'); 45xhr.send(); -
Service Worker:
如果你使用了 Service Worker,它可以在网络请求层面拦截所有请求,包括主文档、静态资源和动态数据请求。原理: 在 Service Worker 的
fetch事件中,你可以拦截请求并记录。示例 (Service Worker
sw.js):1// sw.js 2self.addEventListener('fetch', (event) => { 3 // 在这里可以记录每个请求的 URL 或其他信息 4 console.log('Service Worker 拦截到请求:', event.request.url); 5 // 可以将请求信息发送回主线程进行汇总或直接上报 6 // event.waitUntil( 7 // fetch('/api/log_request', { 8 // method: 'POST', 9 // body: JSON.stringify({ url: event.request.url, timestamp: Date.now() }), 10 // headers: { 'Content-Type': 'application/json' } 11 // }) 12 // ); 13 event.respondWith(fetch(event.request)); // 继续请求 14});注意: Service Worker 部署和管理相对复杂,通常用于缓存和离线功能。
选择哪种方法:
- 最推荐且最全面:
PerformanceObserver(特别是监听resource和navigation类型)。它由浏览器原生提供,性能好,数据准确,能覆盖所有类型的请求(包括浏览器自动发起的资源请求、XHR、Fetch)。 - 补充
PerformanceObserver: 如果需要对 XHR/Fetch 请求进行更细粒度的控制(例如,在请求发起前/后注入自定义逻辑),可以结合重写XMLHttpRequest和fetch。 - Service Worker: 适用于需要深度控制网络层面的场景,但实现成本较高。
通常,PerformanceObserver 能够满足大多数页面请求统计的需求。
14. 长文本溢出,展开/收起如何实现
长文本溢出并提供“展开/收起”功能是前端常见的需求。实现方式主要通过 CSS 和 JavaScript 结合来完成。
核心思路:
- 默认隐藏: 使用 CSS 限制文本的显示行数,超出部分隐藏并显示省略号。
- 点击切换: 通过 JavaScript 改变元素的样式或类名,实现文本的完全展开或收起。
1. 纯 CSS 限制行数溢出
对于多行文本溢出,CSS 提供了 webkit-line-clamp 属性(非标准,但兼容性较好),或者通过 max-height 和 overflow: hidden 模拟。
使用 webkit-line-clamp (推荐)
1.text-ellipsis { 2 overflow: hidden; 3 text-overflow: ellipsis; /* 显示省略号 */ 4 display: -webkit-box; /* 必须结合此属性 */ 5 -webkit-line-clamp: 3; /* 限制在 3 行 */ 6 -webkit-box-orient: vertical; /* 必须结合此属性 */ 7}
使用 max-height + overflow: hidden (更通用,但不能显示省略号)
1.text-ellipsis-max-height { 2 max-height: 3.6em; /* 假设一行是 1.2em,3 行就是 3.6em */ 3 overflow: hidden; 4 /* 这种方式没有原生省略号,可以考虑用伪元素模拟 */ 5 position: relative; 6} 7.text-ellipsis-max-height::after { 8 content: "..."; 9 position: absolute; 10 bottom: 0; 11 right: 0; 12 background: white; /* 覆盖文本 */ 13 padding-left: 5px; /* 防止省略号与文本过于靠近 */ 14} 15/* 但这种伪元素只在隐藏时显示,展开时要隐藏,需要JS控制 */
2. 实现展开/收起功能 (JavaScript 结合 CSS)
这里我们将使用 webkit-line-clamp 来处理默认溢出,然后用 JS 切换一个类来控制展开。
HTML 结构:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Expand/Collapse Text</title> 7 <style> 8 body { 9 font-family: sans-serif; 10 padding: 20px; 11 background-color: #f0f0f0; 12 } 13 14 .text-container { 15 width: 80%; 16 margin: 20px auto; 17 background-color: #fff; 18 padding: 20px; 19 border-radius: 8px; 20 box-shadow: 0 2px 5px rgba(0,0,0,0.1); 21 } 22 23 .long-text { 24 /* 默认状态:限制为 3 行,超出显示省略号 */ 25 overflow: hidden; 26 text-overflow: ellipsis; 27 display: -webkit-box; 28 -webkit-line-clamp: 3; /* 限制在 3 行 */ 29 -webkit-box-orient: vertical; 30 transition: max-height 0.3s ease-out; /* 添加平滑过渡效果 */ 31 line-height: 1.5; /* 确保行高一致 */ 32 max-height: calc(1.5em * 3); /* 初始 max-height,用于过渡 */ 33 } 34 35 /* 展开状态:移除行数限制 */ 36 .long-text.expanded { 37 -webkit-line-clamp: unset; /* 移除行数限制 */ 38 max-height: none; /* 移除 max-height */ 39 overflow: visible; /* 允许内容可见 */ 40 } 41 42 .toggle-btn { 43 background: none; 44 border: none; 45 color: #007bff; 46 cursor: pointer; 47 padding: 0; 48 margin-top: 10px; 49 font-size: 14px; 50 } 51 52 .toggle-btn:hover { 53 text-decoration: underline; 54 } 55 </style> 56</head> 57<body> 58 <div class="text-container"> 59 <p class="long-text" id="myLongText"> 60 这是一段非常长的文本,它将用于演示如何实现长文本的溢出、展开和收起功能。在默认情况下,这段文本会被限制在三行以内显示,超出部分将用省略号表示。当用户点击“展开”按钮后,文本会完全显示出来;再次点击“收起”按钮,文本又会恢复到三行显示的状态。这种功能在新闻详情、商品描述、评论等场景中非常常见,能够有效地节省页面空间,同时允许用户按需查看完整内容。它提升了页面的整洁性,并改善了用户体验。请注意观察文本在展开和收起时的平滑过渡效果,这是通过 CSS 的 transition 属性实现的。 61 这是一段非常长的文本,它将用于演示如何实现长文本的溢出、展开和收起功能。在默认情况下,这段文本会被限制在三行以内显示,超出部分将用省略号表示。当用户点击“展开”按钮后,文本会完全显示出来;再次点击“收起”按钮,文本又会恢复到三行显示的状态。这种功能在新闻详情、商品描述、评论等场景中非常常见,能够有效地节省页面空间,同时允许用户按需查看完整内容。它提升了页面的整洁性,并改善了用户体验。请注意观察文本在展开和收起时的平滑过渡效果,这是通过 CSS 的 transition 属性实现的。 62 </p> 63 <button class="toggle-btn" id="toggleBtn">展开</button> 64 </div> 65 66 <script> 67 const myLongText = document.getElementById('myLongText'); 68 const toggleBtn = document.getElementById('toggleBtn'); 69 let isExpanded = false; 70 71 // 检查文本是否真正溢出,只在溢出时显示按钮 72 function checkOverflow() { 73 // 通过比较滚动高度和可视高度来判断是否溢出 74 // 由于 -webkit-line-clamp 的特性,这里需要一个更鲁棒的判断 75 // 简单方法:创建临时的、未限制的元素,比较其高度 76 const tempDiv = document.createElement('div'); 77 tempDiv.style.cssText = ` 78 visibility: hidden; 79 position: absolute; 80 white-space: normal; 81 word-break: break-all; 82 width: ${myLongText.clientWidth}px; /* 保持与原元素相同的宽度 */ 83 font-size: ${getComputedStyle(myLongText).fontSize}; 84 line-height: ${getComputedStyle(myLongText).lineHeight}; 85 `; 86 tempDiv.textContent = myLongText.textContent; 87 document.body.appendChild(tempDiv); 88 const originalHeight = myLongText.offsetHeight; // 限制后的高度 89 const fullHeight = tempDiv.offsetHeight; // 未限制的完整高度 90 document.body.removeChild(tempDiv); 91 92 if (fullHeight > originalHeight) { 93 toggleBtn.style.display = 'inline-block'; // 显示按钮 94 } else { 95 toggleBtn.style.display = 'none'; // 隐藏按钮 96 } 97 } 98 99 function toggleText() { 100 isExpanded = !isExpanded; 101 myLongText.classList.toggle('expanded', isExpanded); 102 toggleBtn.textContent = isExpanded ? '收起' : '展开'; 103 104 // 如果使用 max-height 进行过渡,这里可能需要动态计算 105 // 例如,当展开时设置 max-height 为 content 的实际高度 106 // 比如 myLongText.style.maxHeight = isExpanded ? myLongText.scrollHeight + 'px' : 'calc(1.5em * 3)'; 107 // 但如果使用了 -webkit-line-clamp,直接切换类更简单 108 } 109 110 toggleBtn.addEventListener('click', toggleText); 111 112 // 页面加载和窗口大小变化时检查溢出 113 window.addEventListener('load', checkOverflow); 114 window.addEventListener('resize', checkOverflow); // 响应式布局下可能需要 115 </script> 116</body> 117</html>
关键点:
- CSS
webkit-line-clamp: 简化了多行文本溢出的处理。 - CSS
transition: 让展开/收起动画更平滑。 - JavaScript 状态管理: 使用一个布尔变量 (
isExpanded) 来跟踪当前状态。 - 动态按钮文本: 根据
isExpanded状态更新按钮文本。 - 溢出检测:
checkOverflow函数是可选但很重要的,它确保只有当文本真正溢出时才显示“展开”按钮,提升用户体验。检测文本是否溢出需要比较元素在限制高度下的实际高度和完全展开后的高度。
动画平滑过渡的挑战:
在使用 max-height 进行过渡时,从固定高度(如 3.6em)过渡到 auto(完全展开)是一个挑战,因为 max-height: auto 无法平滑过渡。解决方案通常是:
- 在展开时: 先将
max-height设置为scrollHeight(元素的实际内容高度),然后设置max-height: none(或者设置一个足够大的值,如9999px)。 - 在收起时: 先获取当前
scrollHeight作为max-height的起点,然后将其设置为初始的限制高度(如3.6em)。
由于 webkit-line-clamp 的实现是基于 display: -webkit-box,它本身不直接依赖 max-height 来进行截断,所以简单的 max-height 过渡可能无法直接生效。在上述示例中,我通过同时设置 max-height 和 -webkit-line-clamp 来实现过渡效果,但最平滑的过渡通常需要动态计算 scrollHeight。
对于 webkit-line-clamp,更简单的过渡方法是:
给 max-height 设置一个足够大的值 (比如 9999px),并在 expanded 类中将 max-height 设置为 auto 或者 none,然后给 max-height 添加 transition。但这个过渡效果可能不如预期,因为它涉及到 display 属性的变化。最佳实践是使用 scrollHeight 进行精确过渡,但这会增加 JavaScript 的复杂性。
例如,更精确的 max-height 过渡实现:
1// ... 2myLongText.style.maxHeight = myLongText.scrollHeight + 'px'; // 设置为当前实际高度 3requestAnimationFrame(() => { // 确保在下一帧应用 4 myLongText.style.maxHeight = 'calc(1.5em * 3)'; // 收起 5}); 6 7// ... 8myLongText.style.maxHeight = myLongText.scrollHeight + 'px'; // 展开到实际高度
这种方式需要更精细的动画控制,通常在 React/Vue 等框架中结合 ref 和生命周期钩子来实现。
15. 如何实现鼠标拖拽
实现鼠标拖拽功能通常涉及监听三个核心的鼠标事件:mousedown、mousemove 和 mouseup。
核心原理:
-
mousedown(鼠标按下):- 当鼠标在一个可拖拽元素上按下时,记录鼠标的初始位置(
clientX,clientY)和元素的初始位置(offsetLeft,offsetTop)。 - 在
document上注册mousemove和mouseup事件监听器,因为用户鼠标可能会移出元素本身。 - 改变元素的
position为absolute或fixed,使其可以自由移动。 - 设置
z-index确保拖拽元素在其他元素之上。 - 阻止默认的拖拽行为(如图片拖拽),
event.preventDefault()。
- 当鼠标在一个可拖拽元素上按下时,记录鼠标的初始位置(
-
mousemove(鼠标移动):- 当鼠标移动时,计算新的鼠标位置与初始位置的偏移量。
- 将这个偏移量应用到元素的
left和top样式上,从而使元素跟随鼠标移动。
-
mouseup(鼠标松开):- 当鼠标松开时,移除
document上注册的mousemove和mouseup事件监听器,停止拖拽。 - (可选)如果需要,可以将元素的
position恢复,或者保存其最终位置。
- 当鼠标松开时,移除
示例代码:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Drag and Drop</title> 7 <style> 8 body { 9 font-family: sans-serif; 10 margin: 0; 11 padding: 20px; 12 background-color: #f0f0f0; 13 min-height: 100vh; 14 } 15 16 #draggable { 17 width: 150px; 18 height: 150px; 19 background-color: #007bff; 20 color: white; 21 text-align: center; 22 line-height: 150px; 23 cursor: grab; /* 鼠标样式 */ 24 border-radius: 8px; 25 box-shadow: 0 4px 8px rgba(0,0,0,0.2); 26 position: absolute; /* 必须设置为 absolute 或 fixed */ 27 top: 50px; 28 left: 50px; 29 } 30 31 #draggable.dragging { 32 cursor: grabbing; 33 opacity: 0.8; 34 z-index: 1000; /* 拖拽时提高层级 */ 35 } 36 </style> 37</head> 38<body> 39 <h1>鼠标拖拽示例</h1> 40 <div id="draggable">拖拽我</div> 41 42 <script> 43 const draggable = document.getElementById('draggable'); 44 let isDragging = false; 45 let initialX, initialY; // 鼠标初始位置 46 let offsetX, offsetY; // 元素相对于鼠标点击位置的偏移量 47 48 draggable.addEventListener('mousedown', (e) => { 49 isDragging = true; 50 draggable.classList.add('dragging'); 51 52 // 阻止默认的图片或文本拖拽行为 53 e.preventDefault(); 54 55 // 记录鼠标按下时的页面坐标 56 initialX = e.clientX; 57 initialY = e.clientY; 58 59 // 计算鼠标点击位置相对于元素左上角的偏移量 60 // 这确保了拖拽时,鼠标点在元素上的相对位置不变 61 offsetX = e.clientX - draggable.offsetLeft; 62 offsetY = e.clientY - draggable.offsetTop; 63 64 // 在 document 上注册 mousemove 和 mouseup 事件 65 // 这样即使鼠标移出 draggable 元素,也能继续拖拽或停止 66 document.addEventListener('mousemove', onMouseMove); 67 document.addEventListener('mouseup', onMouseUp); 68 }); 69 70 function onMouseMove(e) { 71 if (!isDragging) return; 72 73 // 计算新的 left 和 top 值 74 // 新的元素位置 = 当前鼠标位置 - 鼠标点击时在元素内的偏移量 75 let newLeft = e.clientX - offsetX; 76 let newTop = e.clientY - offsetY; 77 78 // 可选:限制拖拽范围,例如不让元素超出视口 79 // let maxX = window.innerWidth - draggable.offsetWidth; 80 // let maxY = window.innerHeight - draggable.offsetHeight; 81 // newLeft = Math.max(0, Math.min(newLeft, maxX)); 82 // newTop = Math.max(0, Math.min(newTop, maxY)); 83 84 draggable.style.left = `${newLeft}px`; 85 draggable.style.top = `${newTop}px`; 86 } 87 88 function onMouseUp() { 89 isDragging = false; 90 draggable.classList.remove('dragging'); 91 92 // 移除事件监听器,避免不必要的性能消耗和错误行为 93 document.removeEventListener('mousemove', onMouseMove); 94 document.removeEventListener('mouseup', onMouseUp); 95 } 96 </script> 97</body> 98</html>
优化和注意事项:
- 性能优化:
- 节流/防抖 (Throttle/Debounce):
mousemove事件会非常频繁地触发。对于复杂的拖拽逻辑或大量元素,可以考虑对onMouseMove函数进行节流,减少样式更新的频率。但对于简单的元素拖拽,浏览器通常有内部优化,直接更新样式通常不会有明显性能问题。 requestAnimationFrame: 在onMouseMove中,为了确保流畅的动画,可以考虑将样式更新放入requestAnimationFrame回调中,让浏览器在下一次重绘时执行。1let animationFrameId = null; 2function onMouseMove(e) { 3 if (!isDragging) return; 4 if (animationFrameId) cancelAnimationFrame(animationFrameId); 5 6 animationFrameId = requestAnimationFrame(() => { 7 // ... 计算 newLeft, newTop 8 draggable.style.left = `${newLeft}px`; 9 draggable.style.top = `${newTop}px`; 10 animationFrameId = null; 11 }); 12}
- 节流/防抖 (Throttle/Debounce):
- 定位方式:
- 拖拽元素必须设置为
position: absolute;或position: fixed;才能通过left和top属性进行定位。 absolute相对于最近的定位父元素。fixed相对于视口。
- 拖拽元素必须设置为
- 用户体验:
cursor样式: 改变鼠标指针为grab(可抓取) 和grabbing(抓取中) 提供了良好的视觉反馈。- Z-index: 拖拽时提高元素的
z-index,确保它在其他内容之上。 - 防止默认行为:
e.preventDefault()在mousedown时很重要,可以防止文本被选中或图片被拖拽等浏览器默认行为。
- 触摸事件:
- 对于移动设备,需要监听
touchstart,touchmove,touchend事件,原理类似。 e.touches[0].clientX和e.touches[0].clientY获取触摸点坐标。
- 对于移动设备,需要监听
- 拖拽范围限制: 可以通过计算
newLeft和newTop来限制元素在特定容器内移动,避免拖出视口。 pointer-events: 在某些复杂场景,可能需要临时禁用pointer-events来防止拖拽过程中的子元素干扰。
16. 统计全站每一个静态资源加载耗时,该如何做
统计全站每一个静态资源的加载耗时,可以使用 Performance API 中的 PerformanceObserver 结合 resource 类型。这是最准确、高效且现代的方法。
核心原理:
PerformanceResourceTiming接口: 浏览器在加载每个资源(如图片、CSS、JavaScript 文件、字体、XHR/Fetch 请求等)时,都会记录详细的计时信息,这些信息存储在PerformanceResourceTiming对象中。PerformanceObserverAPI: 允许我们异步地观察这些性能条目,并在它们发生时触发回调函数,而无需轮询或阻塞主线程。
具体步骤:
-
创建
PerformanceObserver实例:
传入一个回调函数,该函数会在新的性能条目(即资源加载完成)时被调用。 -
配置观察类型:
使用observer.observe({ entryTypes: ['resource'], buffered: true })来指定你对resource类型的性能条目感兴趣。buffered: true参数非常重要,它确保PerformanceObserver会收集在观察器注册之前就已经完成加载的资源。这意味着即使观察器在页面加载后期才被初始化,它也能获取到页面初始化时加载的资源数据。
-
在回调函数中处理数据:
list.getEntries()会返回一个PerformanceEntry对象的数组,每个对象都是一个PerformanceResourceTiming实例。你可以从中提取各种加载耗时指标。
PerformanceResourceTiming 对象包含的关键指标:
name: 资源的 URL。initiatorType: 资源的发起者类型(如img,script,link,fetch,xmlhttprequest,css等)。startTime: 资源请求开始的时间。responseEnd: 资源响应完成的时间(即浏览器收到完整响应的最后一个字节)。duration:responseEnd - startTime,表示资源从请求开始到完成的总耗时。domainLookupStart,domainLookupEnd: DNS 查询开始和结束时间。connectStart,connectEnd: TCP 连接建立开始和结束时间。requestStart: 请求(第一个字节)发送时间。responseStart: 响应(第一个字节)接收时间。transferSize: 传输的字节数(包含头部)。encodedBodySize: 编码后的资源体大小(通常是压缩后的大小)。decodedBodySize: 解码后的资源体大小(通常是解压后的大小)。
示例代码:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Static Resource Timing</title> 7 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> 8 <style> 9 body { font-family: sans-serif; padding: 20px; } 10 .resource-item { border-bottom: 1px dashed #eee; padding: 10px 0; } 11 .resource-item:last-child { border-bottom: none; } 12 #resourceList { 13 max-height: 500px; 14 overflow-y: auto; 15 border: 1px solid #ccc; 16 padding: 10px; 17 border-radius: 8px; 18 } 19 </style> 20</head> 21<body> 22 <h1>静态资源加载耗时统计</h1> 23 <p>以下是页面加载的所有静态资源及其耗时:</p> 24 <div id="resourceList"></div> 25 26 <img src="https://via.placeholder.com/150/FF0000/FFFFFF?text=Image1" alt="Placeholder Image 1"> 27 <img src="https://via.placeholder.com/150/00FF00/000000?text=Image2" alt="Placeholder Image 2"> 28 <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script> 29 <script> 30 const resourceListDiv = document.getElementById('resourceList'); 31 const collectedResources = []; 32 33 // 1. 创建 PerformanceObserver 实例 34 const observer = new PerformanceObserver((list) => { 35 list.getEntries().forEach(entry => { 36 // 确保是资源类型 37 if (entry.entryType === 'resource') { 38 collectedResources.push(entry); 39 displayResourceTiming(entry); 40 } 41 }); 42 }); 43 44 // 2. 配置观察类型为 'resource',并开启 buffered 模式 45 observer.observe({ entryTypes: ['resource'], buffered: true }); 46 47 // 辅助函数:将资源耗时信息显示到页面上 48 function displayResourceTiming(entry) { 49 const itemDiv = document.createElement('div'); 50 itemDiv.className = 'resource-item'; 51 itemDiv.innerHTML = `62`; 63 resourceListDiv.appendChild(itemDiv); 64 resourceListDiv.scrollTop = resourceListDiv.scrollHeight; // 滚动到底部 65 } 66 67 // 3. 在页面加载完成后,或在合适时机上报数据 68 window.addEventListener('load', () => { 69 console.log('所有资源加载完成,总计资源数量:', collectedResources.length); 70 // 可以对 collectedResources 进行进一步处理(过滤、聚合、排序等) 71 // 例如,按耗时排序: 72 const sortedResources = [...collectedResources].sort((a, b) => b.duration - a.duration); 73 console.log('耗时最长的资源:', sortedResources.slice(0, 5)); 74 75 // 将数据上报到你的监控系统 76 // navigator.sendBeacon('/api/resource_metrics', JSON.stringify({ 77 // pageUrl: window.location.href, 78 // resources: collectedResources.map(r => ({ 79 // url: r.name, 80 // type: r.initiatorType, 81 // duration: r.duration, 82 // size: r.decodedBodySize 83 // })) 84 // })); 85 }); 86 87 // 模拟一个动态加载的资源或一个 XHR 请求 88 setTimeout(() => { 89 // 加载一个新图片 90 const newImg = document.createElement('img'); 91 newImg.src = 'https://via.placeholder.com/150/0000FF/FFFFFF?text=DynamicImage'; 92 newImg.alt = 'Dynamic Image'; 93 document.body.appendChild(newImg); 94 95 // 发起一个 Fetch 请求 96 fetch('https://jsonplaceholder.typicode.com/posts/1') 97 .then(response => response.json()) 98 .then(data => console.log('Fetched data:', data)) 99 .catch(error => console.error('Fetch error:', error)); 100 101 }, 2000); // 2秒后动态加载 102 </script> 103</body> 104</html>
优点:
- 全面性: 能够捕获几乎所有类型的静态资源加载(包括
<img>、<link>、<script>、@font-face、background-image、XHR/Fetch 等)。 - 非阻塞: 异步回调,不会阻塞主线程。
- 精准: 提供高精度的计时信息。
- 归因:
initiatorType字段帮助理解资源的加载方式。 - 动态监控: 可以持续观察后续动态加载的资源。
实践中的考量:
- 数据量: 对于大型网站,资源条目可能非常多。需要考虑上报的数据量,可能需要进行抽样、聚合或只上报关键指标。
- 上报时机: 可以在
window.onload或beforeunload时批量上报,或者在收集到一定数量的资源后分批上报。navigator.sendBeacon是上报数据到服务器的理想选择。 - 跨域资源: 对于跨域资源,如果服务器没有设置正确的
Timing-Allow-Origin响应头,PerformanceResourceTiming对象中的敏感计时信息(如responseStart等)可能会被设置为 0 或其他受限值,只能得到总的duration。 - 过滤: 可以根据
name(URL) 或initiatorType过滤掉不关心的资源,例如一些第三方统计脚本或广告。
通过这些数据,你可以分析页面的加载瓶颈,例如哪些资源加载时间过长、哪些资源过大、是否存在过多的小文件请求等,从而进行有针对性的优化。
17. 防止前端页面重复请求
防止前端页面重复请求是前端性能优化和用户体验提升的重要一环。重复请求通常指在短时间内发起相同或类似的网络请求,这可能导致:
- 性能下降: 浪费带宽和服务器资源。
- 用户体验差: 界面数据刷新混乱,可能导致数据不一致。
- 数据错误: 尤其对于提交类请求,可能导致数据重复创建。
以下是防止前端页面重复请求的常见策略:
-
用户操作限制(Debounce/Throttle):
针对用户频繁触发的事件(如点击、输入、滚动),使用节流或防抖来控制请求的发送频率。- 防抖 (Debounce): 在事件触发后,等待一个固定的时间,如果这段时间内没有再次触发事件,才执行回调函数。如果在等待时间内再次触发,则重新计时。适用于搜索框输入、窗口 resize 等。
- 节流 (Throttle): 在一个固定的时间周期内,只执行一次回调函数。适用于滚动加载、高频点击等。
示例 (防抖):
1function debounce(func, delay) { 2 let timeoutId; 3 return function(...args) { 4 clearTimeout(timeoutId); 5 timeoutId = setTimeout(() => { 6 func.apply(this, args); 7 }, delay); 8 }; 9} 10 11const searchInput = document.getElementById('searchInput'); 12const handleSearch = debounce((query) => { 13 console.log(`执行搜索请求: ${query}`); 14 // fetch('/api/search?query=' + query); 15}, 500); 16 17searchInput.addEventListener('input', (e) => handleSearch(e.target.value)); -
请求状态管理 (Loading State Flags):
在发起请求前设置一个布尔标志(如isLoading),请求进行中时设为true,请求完成后设为false。在请求发起前检查此标志,如果为true则不发送新请求。示例:
1let isLoadingData = false; 2 3async function fetchData() { 4 if (isLoadingData) { 5 console.log('数据正在加载中,请勿重复请求!'); 6 return; 7 } 8 9 isLoadingData = true; 10 try { 11 console.log('开始请求数据...'); 12 const response = await fetch('https://api.example.com/data'); 13 const data = await response.json(); 14 console.log('数据加载成功:', data); 15 // 更新UI 16 } catch (error) { 17 console.error('数据加载失败:', error); 18 } finally { 19 isLoadingData = false; 20 console.log('请求结束。'); 21 } 22} 23 24document.getElementById('loadButton').addEventListener('click', fetchData); -
请求取消 (AbortController/Axios Cancellation Tokens):
对于fetchAPI,可以使用AbortController来取消进行中的请求。当用户在短时间内多次触发相同请求时,可以取消上一个未完成的请求。示例 (Fetch with AbortController):
1let currentController = null; // 用于存储当前的 AbortController 2 3async function searchItems(query) { 4 // 如果有前一个请求正在进行,取消它 5 if (currentController) { 6 currentController.abort(); 7 console.log('取消前一个搜索请求'); 8 } 9 10 currentController = new AbortController(); 11 const signal = currentController.signal; 12 13 try { 14 console.log(`发起新的搜索请求: ${query}`); 15 const response = await fetch(`/api/search?q=${query}`, { signal }); 16 if (!response.ok) { 17 throw new Error(`HTTP error! status: ${response.status}`); 18 } 19 const data = await response.json(); 20 console.log('搜索结果:', data); 21 // 更新UI 22 } catch (error) { 23 if (error.name === 'AbortError') { 24 console.log('搜索请求被取消'); 25 } else { 26 console.error('搜索请求失败:', error); 27 } 28 } finally { 29 currentController = null; // 请求完成后清空控制器 30 } 31} 32 33const searchInput = document.getElementById('searchInput'); 34searchInput.addEventListener('input', debounce((e) => searchItems(e.target.value), 300));Axios 客户端取消请求:
Axios (0.22.0+) 也支持
AbortController,早期版本使用其自己的CancelToken。1// Axios with AbortController 2let controller; 3async function fetchDataWithAxios() { 4 if (controller) { 5 controller.abort(); // 取消上一个请求 6 } 7 controller = new AbortController(); 8 try { 9 const response = await axios.get('/user/12345', { 10 signal: controller.signal 11 }); 12 console.log(response.data); 13 } catch (error) { 14 if (axios.isCancel(error)) { 15 console.log('请求被取消:', error.message); 16 } else { 17 console.log('请求失败:', error.message); 18 } 19 } finally { 20 controller = null; 21 } 22} -
客户端缓存 (Client-Side Caching):
对于不经常变化的数据,可以将其缓存在客户端,在一定时间内直接使用缓存数据,而不是每次都发起请求。- 内存缓存: 存储在 JavaScript 变量或对象中,页面刷新后丢失。
localStorage/sessionStorage: 持久化存储,但容量有限,同步操作可能阻塞主线程。- IndexedDB: 更适合存储大量结构化数据。
- Service Worker: 拦截请求,可以实现网络优先、缓存优先等复杂的缓存策略。
- HTTP 缓存头: 服务器设置
Cache-Control,Expires,ETag,Last-Modified等响应头,让浏览器自行处理缓存。
示例 (简单内存缓存):
1const dataCache = {}; 2const CACHE_EXPIRATION_TIME = 5 * 60 * 1000; // 5 分钟缓存 3 4async function getCachedData(url) { 5 if (dataCache[url] && (Date.now() - dataCache[url].timestamp < CACHE_EXPIRATION_TIME)) { 6 console.log('从缓存获取数据:', url); 7 return dataCache[url].data; 8 } 9 10 console.log('发起新请求获取数据:', url); 11 const response = await fetch(url); 12 const data = await response.json(); 13 dataCache[url] = { 14 data: data, 15 timestamp: Date.now() 16 }; 17 return data; 18} 19 20document.getElementById('getDataButton').addEventListener('click', () => { 21 getCachedData('https://jsonplaceholder.typicode.com/todos/1') 22 .then(data => console.log('数据:', data)) 23 .catch(error => console.error('错误:', error)); 24}); -
请求批处理 (Batching):
将多个小请求合并成一个大请求发送到服务器,减少请求次数。这通常需要服务器端支持。示例 (概念性):
1const pendingRequests = []; 2let batchTimeout = null; 3 4function sendBatchedRequest() { 5 if (pendingRequests.length === 0) return; 6 7 const payload = { requests: [...pendingRequests] }; 8 console.log('发送批处理请求:', payload); 9 // fetch('/api/batch', { method: 'POST', body: JSON.stringify(payload) }); 10 11 pendingRequests.length = 0; // 清空队列 12 batchTimeout = null; 13} 14 15function addRequestToBatch(requestData) { 16 pendingRequests.push(requestData); 17 if (batchTimeout) { 18 clearTimeout(batchTimeout); 19 } 20 batchTimeout = setTimeout(sendBatchedRequest, 100); // 100ms 内收集所有请求 21} 22 23// 调用示例 24addRequestToBatch({ type: 'update_user_profile', userId: 1, field: 'name' }); 25addRequestToBatch({ type: 'track_event', event: 'button_click', data: { id: 'btn-1' } });
总结:
选择哪种方法取决于具体的业务场景和请求的特性。
- 对于用户高频操作,防抖/节流 是首选。
- 对于相同 API 但允许取消的请求,请求取消 是有效的。
- 对于不常变化的共享数据,客户端缓存 能显著提升性能。
- 对于多个小请求,请求批处理 可以减少网络开销。
- 请求状态管理 应该作为基础措施,与上述方法结合使用,确保逻辑严谨。
18. ResizeObserver作用是什么
ResizeObserver 是一个 Web API,它允许您观察 DOM 元素的 content rectangle(内容区域,即 padding box 内部的区域)或 border box(边框盒,即 border box 内部的区域)尺寸的变化。
核心作用和特点:
- 观察元素尺寸变化: 它是专门用于观察元素自身尺寸变化的,而不是像
window.onresize那样只观察视口 (viewport) 的尺寸变化。这意味着即使窗口大小不变,只要元素本身的布局(例如,由于 Flexbox、Grid 布局或内容变化)导致其尺寸变化,ResizeObserver都能检测到。 - 异步回调: 像
MutationObserver和IntersectionObserver一样,ResizeObserver也是异步的。它在浏览器渲染之前或之后执行回调,这意味着它不会在每次尺寸变化时都立即触发,从而避免了布局抖动(layout thrashing)和性能问题。回调函数会在下一次绘制前触发。 - 避免无限循环: 与在
window.onresize中手动检测元素尺寸并修改样式可能导致无限循环不同,ResizeObserver机制经过优化,可以防止这种问题。即使在回调中改变了元素的尺寸,也不会立即再次触发回调(除非这种改变是由其他原因引起的,或者在下一个动画帧发生)。 - 性能优化: 浏览器可以优化
ResizeObserver的实现,使其比手动在scroll或resize事件中计算元素尺寸更高效。它由浏览器内部管理,不需要节流或防抖。 - 提供精确尺寸: 在回调中,您可以获得每个被观察元素的新的
contentRect和borderBoxSize等信息,这些信息非常精确。
为什么需要 ResizeObserver?
在 ResizeObserver 出现之前,我们主要通过以下方式来检测元素尺寸变化:
window.onresize: 只能检测浏览器窗口大小变化,无法检测到单个元素因内部内容或 flex/grid 布局引起的尺寸变化。- 轮询 (
setInterval): 周期性地检查元素的offsetWidth或offsetHeight,效率低下,容易导致性能问题。 MutationObserver: 可以观察 DOM 树的修改,但不能直接观察元素尺寸的变化。虽然可以通过观察样式属性变化间接实现,但不如ResizeObserver专一和高效。
典型应用场景:
- 响应式组件: 当组件需要根据其自身容器的尺寸来调整布局或内容时,而不是根据整个视口尺寸。
- 例如,一个图表组件需要根据其父容器的宽度来重新绘制图表,以适应不同的容器大小。
- 一个图片画廊需要根据可用空间来调整图片的大小和排列。
- 虚拟滚动/列表: 动态计算列表中可见元素的数量和位置,尤其当元素高度不固定时。
- 元素自适应布局: 当一个元素的内容变化导致其自身尺寸变化,进而影响到其他相邻元素的布局时。
- 第三方库或组件集成: 当你在应用程序中使用第三方库(如地图、图表库)时,这些库通常需要知道它们被放置的容器的准确尺寸才能正确渲染。
基本语法:
1const observer = new ResizeObserver(entries => { 2 for (let entry of entries) { 3 // entry.target 是被观察的 DOM 元素 4 // entry.contentRect 提供了元素内容区域的尺寸 (DOMRectReadOnly 对象) 5 // entry.borderBoxSize, entry.contentBoxSize, entry.devicePixelContentBoxSize 提供了更详细的尺寸信息 6 console.log('Element:', entry.target); 7 console.log('Content Box Width:', entry.contentRect.width); 8 console.log('Content Box Height:', entry.contentRect.height); 9 10 // 例如,根据宽度调整字体大小 11 entry.target.style.fontSize = `${entry.contentRect.width / 10}px`; 12 } 13}); 14 15// 观察一个或多个元素 16const myElement = document.getElementById('myElement'); 17observer.observe(myElement); 18 19// 停止观察某个元素 20// observer.unobserve(myElement); 21 22// 停止观察所有元素 23// observer.disconnect();
示例:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>ResizeObserver Demo</title> 7 <style> 8 body { font-family: sans-serif; padding: 20px; } 9 .resizable-box { 10 width: 200px; 11 height: 150px; 12 background-color: #f0f0f0; 13 border: 2px solid #333; 14 resize: both; /* 允许用户调整大小 */ 15 overflow: auto; /* 允许内容超出时滚动 */ 16 padding: 10px; 17 margin-bottom: 20px; 18 display: flex; 19 align-items: center; 20 justify-content: center; 21 font-size: 16px; 22 box-sizing: border-box; 23 } 24 25 .flex-container { 26 display: flex; 27 width: 100%; /* 占据父容器宽度 */ 28 margin-top: 30px; 29 border: 1px solid blue; 30 } 31 .flex-item { 32 flex: 1; /* 占据剩余空间 */ 33 min-width: 50px; 34 background-color: lightblue; 35 border: 1px solid steelblue; 36 padding: 10px; 37 margin: 5px; 38 text-align: center; 39 height: 100px; 40 } 41 .flex-item:nth-child(2) { 42 flex: 2; /* 第二个项占据更多空间 */ 43 background-color: lightgreen; 44 } 45 </style> 46</head> 47<body> 48 <h1>ResizeObserver 示例</h1> 49 50 <h2>可调整大小的盒子</h2> 51 <div class="resizable-box" id="resizableBox"> 52 <p>拖动右下角调整大小</p> 53 <p id="boxDimensions"></p> 54 </div> 55 56 <h2>Flex 容器(改变窗口大小或内容)</h2> 57 <p>调整浏览器窗口大小,观察 Flex Item 的尺寸变化</p> 58 <div class="flex-container" id="flexContainer"> 59 <div class="flex-item" id="flexItem1">Flex Item 1</div> 60 <div class="flex-item" id="flexItem2">Flex Item 2</div> 61 <div class="flex-item" id="flexItem3">Flex Item 3</div> 62 </div> 63 <div id="flexItemDimensions"></div> 64 65 66 <script> 67 // 示例 1: 可调整大小的盒子 68 const resizableBox = document.getElementById('resizableBox'); 69 const boxDimensions = document.getElementById('boxDimensions'); 70 71 const boxObserver = new ResizeObserver(entries => { 72 for (let entry of entries) { 73 if (entry.target === resizableBox) { 74 const { width, height } = entry.contentRect; 75 boxDimensions.textContent = `宽度: ${width.toFixed(2)}px, 高度: ${height.toFixed(2)}px`; 76 // 模拟根据尺寸调整字体大小 77 entry.target.style.fontSize = `${Math.max(12, width / 15)}px`; 78 } 79 } 80 }); 81 boxObserver.observe(resizableBox); 82 83 // 示例 2: Flex 容器中的项目 84 const flexItem1 = document.getElementById('flexItem1'); 85 const flexItem2 = document.getElementById('flexItem2'); 86 const flexItem3 = document.getElementById('flexItem3'); 87 const flexItemDimensions = document.getElementById('flexItemDimensions'); 88 89 const flexItemsObserver = new ResizeObserver(entries => { 90 let output = ''; 91 entries.forEach(entry => { 92 const { width, height } = entry.contentRect; 93 output += `<p><strong>${entry.target.id}:</strong> 宽度: ${width.toFixed(2)}px, 高度: ${height.toFixed(2)}px</p>`; 94 }); 95 flexItemDimensions.innerHTML = output; 96 }); 97 98 flexItemsObserver.observe(flexItem1); 99 flexItemsObserver.observe(flexItem2); 100 flexItemsObserver.observe(flexItem3); 101 102 // 模拟改变内容导致元素尺寸变化 103 let toggleContent = false; 104 setInterval(() => { 105 toggleContent = !toggleContent; 106 flexItem1.textContent = toggleContent ? 'Flex Item 1 - 更长的内容' : 'Flex Item 1'; 107 }, 3000); 108 </script> 109</body> 110</html>
ResizeObserver 是构建高性能、自适应组件的现代利器,极大地简化了之前复杂的尺寸监听逻辑。
19. 要实时统计用户浏览器窗口大小,该如何做
要实时统计用户浏览器窗口大小(更准确地说是视口大小),主要使用 window.onresize 事件和 window.innerWidth/window.innerHeight 属性。
核心原理:
window.onresize事件: 当浏览器窗口大小被调整时,此事件会触发。window.innerWidth和window.innerHeight: 这两个属性分别返回浏览器视口(Viewport)的内部宽度和高度(不包括浏览器工具栏、滚动条等)。
实现步骤:
- 添加事件监听器: 在
window对象上添加resize事件的监听器。 - 获取尺寸: 在回调函数中,使用
window.innerWidth和window.innerHeight获取当前的视口尺寸。 - 优化 (节流/防抖):
resize事件可能会非常频繁地触发,尤其是在用户拖动窗口边缘时。为了避免过多的计算和 DOM 操作导致性能问题,通常需要对回调函数进行节流 (throttle) 或 防抖 (debounce) 处理。
- 防抖 (Debounce): 在一段时间(例如 200ms)内,如果
resize事件连续触发,则只在停止触发后执行一次回调。这适用于最终结果比实时响应更重要的场景(例如,发送最终尺寸数据到服务器)。 - 节流 (Throttle): 在一段时间内,无论
resize事件触发多少次,都只执行一次回调。这适用于需要周期性更新的场景(例如,调整布局、显示实时尺寸)。
通常对于实时统计,节流 更合适,因为它能保证在调整过程中周期性地获取到尺寸。
示例代码:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Window Resize Tracker</title> 7 <style> 8 body { font-family: sans-serif; text-align: center; margin-top: 50px; } 9 #sizeDisplay { 10 font-size: 36px; 11 font-weight: bold; 12 color: #007bff; 13 margin-top: 20px; 14 } 15 #resizeCount { 16 font-size: 18px; 17 color: #555; 18 margin-top: 10px; 19 } 20 .info { 21 margin-top: 40px; 22 font-size: 14px; 23 color: #888; 24 } 25 </style> 26</head> 27<body> 28 <h1>实时浏览器窗口大小统计</h1> 29 <p>请尝试调整浏览器窗口大小,观察下方显示的变化。</p> 30 <div id="sizeDisplay"></div> 31 <div id="resizeCount"></div> 32 <div class="info"> 33 <p><strong>注意:</strong>`resize` 事件可能会频繁触发。为了性能,通常需要对事件处理函数进行节流或防抖处理。</p> 34 </div> 35 36 <script> 37 const sizeDisplay = document.getElementById('sizeDisplay'); 38 const resizeCountDisplay = document.getElementById('resizeCount'); 39 let updateCount = 0; 40 41 // 获取并显示当前窗口尺寸 42 function updateWindowSize() { 43 const width = window.innerWidth; 44 const height = window.innerHeight; 45 sizeDisplay.textContent = `${width}px x ${height}px`; 46 updateCount++; 47 resizeCountDisplay.textContent = `更新次数: ${updateCount}`; 48 console.log(`Window size: ${width}x${height}`); 49 50 // 在实际应用中,你可能会将这些数据发送到后端进行统计 51 // 例如:navigator.sendBeacon('/api/window_size_metrics', JSON.stringify({ width, height })); 52 } 53 54 // 节流函数 (Throttle) 55 // 确保在指定时间内,函数只被调用一次 56 function throttle(func, delay) { 57 let timeoutId = null; 58 let lastArgs = null; 59 let lastThis = null; 60 61 return function(...args) { 62 lastArgs = args; 63 lastThis = this; 64 65 if (!timeoutId) { 66 timeoutId = setTimeout(() => { 67 func.apply(lastThis, lastArgs); 68 timeoutId = null; // 清除定时器,允许下一次触发 69 lastArgs = null; 70 lastThis = null; 71 }, delay); 72 } 73 }; 74 } 75 76 // 防抖函数 (Debounce) 77 // 确保在指定时间内,函数只在最后一次事件触发后执行 78 function debounce(func, delay) { 79 let timeoutId; 80 return function(...args) { 81 clearTimeout(timeoutId); 82 timeoutId = setTimeout(() => { 83 func.apply(this, args); 84 }, delay); 85 }; 86 } 87 88 // 绑定事件监听器 89 // 推荐使用节流来实时显示尺寸变化,因为防抖会延迟更新 90 window.addEventListener('resize', throttle(updateWindowSize, 100)); // 每 100ms 最多更新一次 91 92 // 如果需要,也可以使用防抖来获取最终的稳定尺寸 93 // window.addEventListener('resize', debounce(() => { 94 // console.log('Debounced: Final window size after resize stopped.'); 95 // updateWindowSize(); // 再次调用以确保最终数据 96 // }, 300)); 97 98 99 // 页面初次加载时也获取一次尺寸 100 updateWindowSize(); 101 </script> 102</body> 103</html>
注意事项:
-
window.innerWidth/innerHeightvsdocument.documentElement.clientWidth/clientHeight:window.innerWidth/innerHeight:返回视口(Viewport)的尺寸,包含滚动条的宽度。document.documentElement.clientWidth/clientHeight:返回文档根元素(<html>)的可用空间,不包含滚动条的宽度。
通常情况下,如果你关心的是页面内容的实际可用空间,后者可能更符合你的需求。但在现代响应式设计中,window.innerWidth通常用于媒体查询的断点。
-
性能: 再次强调,对于高频事件(如
resize、scroll、mousemove),务必使用节流或防抖来优化,以避免卡顿。 -
CSS 媒体查询: 对于大多数响应式布局需求,CSS 媒体查询是更推荐且更高效的解决方案,因为它们由浏览器原生处理,不需要 JavaScript。只有当你需要基于 JS 逻辑来动态调整布局或收集特定数据时,才需要使用
window.onresize。
20. 当项目报错,你想定位是哪个commit引l入的错误的时,该怎么做
当项目报错时,要定位是哪个 Git commit 引入了错误,最有效的工具是 git bisect。
git bisect 是 Git 提供的一个非常强大的命令,它使用二分查找算法来自动化查找引入错误的提交。其基本思想是:你告诉 Git 哪个提交是“好”的(没有错误),哪个提交是“坏”的(有错误),然后 Git 会自动检出中间的提交,让你测试,然后你再告诉 Git 这个中间提交是好是坏,如此往复,直到找到第一个引入错误的提交。
git bisect 的使用步骤:
-
开始二分查找:
进入你的 Git 仓库目录,然后运行:1git bisect start -
标记当前提交为“坏”:
当前你所在的分支或提交通常是发生错误的地方,所以把它标记为“坏”。1git bisect bad -
标记一个已知的“好”的提交:
你需要找到一个在错误引入之前,你知道是正常工作的提交。这通常是上一个版本、某个里程碑版本或者你回忆起来最近一次正常运行时的提交。你可以通过git log查看提交历史来找到这个提交的哈希值。1git bisect good <good_commit_hash> 2# 例如:git bisect good f2a3c4d5一旦你标记了“好”和“坏”的提交,Git 会自动检出这两个提交之间的一个中间提交。
-
测试中间提交:
Git 检出中间提交后,你需要重新构建你的项目(如果需要),并运行测试(手动或自动化测试)来检查这个提交是否包含错误。- 如果这个提交是好的(没有错误),运行:
1git bisect good - 如果这个提交是坏的(有错误),运行:
1git bisect bad
- 如果这个提交是好的(没有错误),运行:
-
重复测试:
Git 会根据你的反馈,继续检出“好”与“坏”之间的中间提交。你重复第 4 步,直到 Git 找到第一个引入错误的提交。 -
查找结果:
最终,git bisect会告诉你哪个提交是“第一个坏的提交”(The first bad commit is...)。<commit-hash> is the first bad commit这个提交就是引入错误的根源。
-
结束二分查找:
找到错误提交后,你需要将 Git 恢复到bisect开始前的状态。1git bisect reset这会将
HEAD恢复到你开始bisect时的分支和提交,但不会删除你在bisect过程中创建的任何标签。
自动化测试与 git bisect run:
如果你的项目有自动化测试脚本,你可以使用 git bisect run 来完全自动化这个过程。
1git bisect start 2git bisect bad 3git bisect good <good_commit_hash> 4git bisect run <your_test_script>
your_test_script 应该是一个可执行的脚本(如 test.sh 或 test.js),它在执行后会返回一个特定的退出码:
0:表示该提交是“好”的。125:表示该提交应该被跳过(例如,代码无法编译)。1-127(除了 125):表示该提交是“坏”的。
示例 test.sh:
1#!/bin/bash 2 3# 假设你的项目需要构建 4npm install 5npm run build 6 7# 假设你的测试脚本返回非0表示失败,0表示成功 8npm test 9 10# 根据测试结果返回对应的退出码 11if [ $? -eq 0 ]; then 12 exit 0 # good 13else 14 exit 1 # bad 15fi
然后运行 git bisect run ./test.sh。
总结:
git bisect 是一个非常高效的调试工具,尤其适用于:
- 定位回归 bug: 某个功能在之前版本正常,新版本出现问题。
- 查找性能下降: 某个提交导致性能显著下降。
- 大型项目: 当提交历史非常多,手动查找效率低下时。
掌握 git bisect 是每个 Git 用户必备的技能。
21. 如何移除一个指定的commit
在 Git 中,移除一个指定的 commit 有多种方法,具体取决于该 commit 是否已经推送到远程仓库,以及你希望如何处理历史记录。
重要原则:
- 不要在已共享(已推送到远程,且其他人可能已基于此提交进行工作)的分支上随意使用会重写历史的命令(如
git reset,git rebase)。 这会导致其他协作者的本地仓库与远程仓库不一致,引起冲突和麻烦。对于已共享的提交,通常推荐使用git revert。 - 在本地分支上,或确信只有自己会使用该分支时,可以使用重写历史的命令。
1. git revert (推荐用于已共享的公共提交)
git revert 会创建一个新的提交,这个新提交的作用是撤销指定 commit 引入的所有更改。原始的 commit 依然存在于历史记录中。
- 优点: 安全,不修改历史记录,不会影响其他协作者。
- 缺点: 会在历史记录中保留原始 commit 和撤销 commit,使历史看起来更“冗长”。
使用方法:
1git revert <commit_hash>
执行命令后,Git 会打开一个编辑器,让你编辑这个新的 revert commit 的提交信息。保存并关闭后,就会生成一个新的 commit。
示例:
假设你的历史是 A -- B -- C -- D (HEAD),你想移除 C。
1git revert C
结果:A -- B -- C -- D -- C' (HEAD),其中 C' 是撤销 C 的提交。
2. git reset (仅限本地或未共享的提交,会重写历史)
git reset 是一个强大的命令,用于将分支的 HEAD 指针移动到指定的 commit。它可以删除或修改之后的 commit。
- 优点: 简单粗暴,直接删除历史记录。
- 缺点: 会重写历史。 如果这些 commit 已经推送到远程仓库,会导致本地和远程历史不一致,后续推送需要
git push -f(强制推送),这会影响其他协作者。因此,只在本地分支或个人分支上使用。
git reset 有三种模式:
-
git reset --soft <commit_hash>:- 将
HEAD移到指定 commit。 - 保留从指定 commit 到当前
HEAD之间的所有更改作为暂存区内容(staged changes)。 - 不会修改工作区。
- 用途:当你只想撤销提交,但希望保留所有代码更改,以便重新提交或修改。
1git reset --soft <commit_hash_before_the_one_to_remove> 2# 例如,要移除 C,回到 B 3# git reset --soft B_hash - 将
-
git reset --mixed <commit_hash>(默认模式):- 将
HEAD移到指定 commit。 - 保留从指定 commit 到当前
HEAD之间的所有更改作为工作区内容(unstaged changes)。 - 清除暂存区。
- 用途:当你希望撤销提交,并重新审视并提交这些更改时。
1git reset --mixed <commit_hash_before_the_one_to_remove> - 将
-
git reset --hard <commit_hash>:- 将
HEAD移到指定 commit。 - 彻底删除从指定 commit 到当前
HEAD之间的所有更改。 - 清除暂存区和工作区,使其与指定 commit 完全一致。
- 危险操作,数据会丢失,请谨慎使用!
- 用途:当你确定要完全抛弃某些提交及其引入的所有更改时。
1git reset --hard <commit_hash_before_the_one_to_remove> 2# 例如,要移除 C 和 D,回到 B 3# git reset --hard B_hash - 将
3. git rebase -i (交互式变基,会重写历史)
git rebase -i 允许你对指定范围内的 commit 进行多种操作,包括删除、合并、编辑提交信息等。
- 优点: 灵活,可以精确控制要移除的 commit,并重写干净的历史。
- 缺点: 会重写历史。 同样只在本地或未共享的分支上使用。
使用方法:
-
启动交互式变基:
1git rebase -i <commit_hash_before_the_one_to_remove> 2# 例如,要移除 C 和 D,回到 B 的前一个提交 3# git rebase -i A_hash (如果 A是第一个好提交) 4# 或者 git rebase -i HEAD~N (N 是要操作的 commit 数量 + 1) 5# 例如,要移除 C (倒数第二个 commit),则 `git rebase -i HEAD~2`Git 会打开一个编辑器,显示从指定 commit 到当前
HEAD之间的所有 commit。 -
在编辑器中删除行:
要移除某个 commit,只需将对应的行从编辑器中删除。示例:
原始:pick <commit-hash-B> commit_B message pick <commit-hash-C> commit_C message pick <commit-hash-D> commit_D message要移除
C:pick <commit-hash-B> commit_B message # pick <commit-hash-C> commit_C message <-- 删除或注释掉这一行 pick <commit-hash-D> commit_D message保存并关闭编辑器。Git 会重新应用剩余的 commit。
总结:
| 方法 | 适用场景 | 是否重写历史 | 备注 |
|---|---|---|---|
git revert | 已共享 的公共提交 | 否 | 创建新提交来撤销,保留原始历史。 |
git reset | 未共享 的本地提交 | 是 | 彻底删除历史,慎用 --hard。 |
git rebase -i | 未共享 的本地提交 | 是 | 灵活地修改历史,包括删除、合并、编辑。 |
在选择方法时,安全性(是否影响他人)和是否需要修改历史(使历史更简洁)是主要考量因素。
22. 如何还原用户操作流程
还原用户操作流程通常指的是在前端应用中记录用户的行为路径,以便后续分析、调试或进行会话回放(Session Replay)。这通常不是一个单一的方法,而是一套完整的监控和记录方案。
核心思路:
- 事件捕获: 监听并记录用户在页面上的关键交互事件(如点击、输入、滚动、页面跳转等)。
- 状态记录: 记录页面或组件的关键状态变化。
- 数据传输: 将收集到的数据发送到后端进行存储和分析。
- 回放机制: 在前端或后端构建一个回放工具来重现这些操作。
具体实现方式:
1. 记录关键用户交互事件
这是最基本的步骤。需要监听页面上的常见事件。
- 点击事件 (
click): 记录点击的元素、事件坐标、元素文本/ID/类名等。1document.addEventListener('click', (event) => { 2 const target = event.target; 3 const data = { 4 type: 'click', 5 timestamp: Date.now(), 6 x: event.clientX, 7 y: event.clientY, 8 targetSelector: getElementSelector(target), // 自定义函数获取唯一选择器 9 innerText: target.innerText ? target.innerText.substring(0, 50) : '', // 截取部分文本 10 url: window.location.href, 11 }; 12 recordEvent(data); 13}, true); // 使用捕获阶段,确保能捕获到所有点击 - 输入事件 (
input,change): 记录输入框的值变化、焦点状态等。- 注意: 敏感信息(如密码、身份证号)不应被记录。
1document.addEventListener('input', (event) => { 2 const target = event.target; 3 if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') { 4 // 避免记录密码等敏感信息 5 if (target.type === 'password') return; 6 const data = { 7 type: 'input', 8 timestamp: Date.now(), 9 targetSelector: getElementSelector(target), 10 value: target.value, 11 url: window.location.href, 12 }; 13 recordEvent(data); 14 } 15}, true); - 页面滚动 (
scroll): 记录滚动位置。1window.addEventListener('scroll', throttle(() => { 2 const data = { 3 type: 'scroll', 4 timestamp: Date.now(), 5 x: window.scrollX, 6 y: window.scrollY, 7 url: window.location.href, 8 }; 9 recordEvent(data); 10}, 200)); // 节流,避免频繁记录 - 页面加载/跳转:
DOMContentLoaded,load: 记录页面加载完成。history.pushState,history.replaceState: 劫持这些方法以记录 SPA 的路由变化。popstate: 浏览器前进/后退。
1// 页面加载 2window.addEventListener('load', () => { 3 recordEvent({ type: 'page_load', timestamp: Date.now(), url: window.location.href }); 4}); 5 6// SPA 路由变化(伪代码) 7const originalPushState = history.pushState; 8history.pushState = function() { 9 originalPushState.apply(this, arguments); 10 recordEvent({ type: 'route_change', timestamp: Date.now(), newUrl: window.location.href }); 11}; 12// ... 对 replaceState 也要做类似处理 - 错误 (
error,unhandledrejection): 记录错误详情,作为操作流程中的重要事件。
2. 记录页面状态 / DOM 结构
- 初始 DOM 快照: 在页面加载时,记录初始 DOM 结构。可以使用
document.documentElement.outerHTML。 - DOM 变化 (
MutationObserver): 监听 DOM 树的变化(节点增删、属性修改、文本变化),以便在回放时重现这些变化。1const observer = new MutationObserver(mutations => { 2 mutations.forEach(mutation => { 3 // 记录 mutation.type, addedNodes, removedNodes, attributeName, oldValue 等 4 // 通常需要对这些变化进行序列化,以便回放 5 recordEvent({ 6 type: 'dom_mutation', 7 timestamp: Date.now(), 8 mutation: serializeMutation(mutation) // 自定义序列化函数 9 }); 10 }); 11}); 12observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); - CSS 样式变化: 如果样式是动态变化的,也可能需要记录。
3. 数据存储与传输
- 客户端缓存: 收集到的事件可以先在客户端(内存或
IndexedDB)中暂存。 - 批量上报: 达到一定数量或每隔一段时间(例如 5-10 秒)将积压的事件数据批量发送到后端,使用
navigator.sendBeacon或fetch。 - 页面卸载: 在
beforeunload事件中,使用sendBeacon发送最后一部分数据。
4. 回放机制
会话回放通常有以下几种方式:
-
基于 DOM 快照和事件重放:
- 加载初始 DOM 快照。
- 按照时间顺序,逐一“重放”记录的事件。
- 对于点击事件,通过之前记录的选择器找到元素,并触发模拟点击。
- 对于输入事件,设置输入框的值。
- 对于 DOM 变化,反序列化
MutationObserver记录的mutation,并应用到 DOM 上。 - 滚动事件,设置
scrollTop/scrollLeft。 - 难点: 异步操作、动态生成内容、第三方库、iframe 等复杂场景很难完美重现。
-
像素级录制:
- 直接录制屏幕的视频(通常通过
MediaDevices.getDisplayMedia)。 - 这种方式准确度最高,但文件大,隐私问题突出,通常不用于普通用户流程回放。
- 直接录制屏幕的视频(通常通过
-
虚拟 DOM 或指令回放:
- 一些更高级的方案会记录 VDOM 的差异(Diff),或者直接记录操作指令(例如:
{ type: 'click', path: '/path/to/element' })。 - 回放时,在一个“沙箱”环境中,根据指令重新构建和渲染页面。
- 代表库:
rrweb就是一个很好的开源项目,它通过记录 DOM 变化和用户事件,并提供了回放器来重现用户会话。它通过序列化和反序列化 DOM 结构、记录MutationObserver的所有变化,以及监听所有用户事件来实现高度还原。
- 一些更高级的方案会记录 VDOM 的差异(Diff),或者直接记录操作指令(例如:
rrweb 的实现原理(简述):
- 记录 (record):
- 序列化初始 DOM 结构为 JSON。
- 使用
MutationObserver监听 DOM 变化(增删改)。 - 监听所有事件(
click,input,scroll,mousemove等)。 - 将这些事件和 DOM 变化记录为一系列“事件快照”(events),每个快照包含类型、数据和时间戳。
- 回放 (replay):
- 在回放器中,根据第一个事件快照创建初始 DOM 结构。
- 然后按时间戳顺序,逐一应用后续的事件快照:
- DOM 变化:将记录的
mutation应用到回放器中的 DOM。 - 用户事件:模拟触发相应的事件。
- DOM 变化:将记录的
- 通过
requestAnimationFrame同步回放速度。
总结:
还原用户操作流程是一个复杂但非常有价值的领域。对于简单的调试和分析,可以手动记录关键事件。对于需要高度还原用户界面的场景,推荐使用像 rrweb 这样的专业库。无论是自建还是使用工具,都需要考虑数据隐私、数据量、存储成本和回放精度等问题。
23. 可有办法将请求的调用源码地址包括代码行数也上报上去?
将前端请求的调用源码地址(文件路径、代码行数和列数)上报上去,对于定位是哪段代码发起了请求,从而进行性能优化或错误追踪非常有帮助。这在传统的 JavaScript 中并非直接或开箱即用的功能,因为浏览器在执行 fetch 或 XMLHttpRequest 时,并不会自动附带调用栈信息。
但是,可以通过一些技巧和工具来实现:
-
利用
Error.prototype.stack:
这是最常见且无需构建工具支持的运行时方案。当你创建一个Error对象时,它会自动捕获当前的调用栈信息。你可以在封装的请求方法中创建一个Error对象,然后解析它的stack属性。实现原理:
- 拦截或封装
fetch或XMLHttpRequest。 - 在发起请求之前,创建一个新的
Error实例,它的stack属性将包含调用该请求的函数的堆栈信息。 - 解析
stack字符串,提取文件名、行号和列号。
示例代码:
1function getCallStackInfo() { 2 const error = new Error(); 3 // stack 格式因浏览器而异,需要解析 4 // 示例 (Chrome/Firefox): 5 // Error 6 // at getCallStackInfo (VM200:2:19) 7 // at customFetch (VM200:19:28) 8 // at <anonymous>:24:1 9 const stackLines = error.stack.split('\n'); 10 // 跳过 getCallStackInfo 和 customFetch 自身,找到实际的调用者 11 // 实际应用中需要更严谨的解析 12 const callerLine = stackLines[3] || stackLines[2]; // 尝试获取更准确的调用者 13 if (callerLine) { 14 // 示例解析:' at Object.<anonymous> (http://localhost:8000/index.html:24:1)' 15 const match = callerLine.match(/at (?:.*) \((.*?):(\d+):(\d+)\)/) || 16 callerLine.match(/at (.*?):(\d+):(\d+)/); // 适用于匿名函数等 17 if (match && match.length >= 4) { 18 return { 19 file: match[1], 20 line: parseInt(match[2]), 21 column: parseInt(match[3]) 22 }; 23 } 24 } 25 return null; 26} 27 28// 封装 fetch 29const originalFetch = window.fetch; 30window.fetch = function(...args) { 31 const stackInfo = getCallStackInfo(); 32 const url = args[0]; 33 34 // 可以在请求参数中添加 stackInfo,或者单独上报 35 console.log(`发起请求: ${url}`); 36 if (stackInfo) { 37 console.log(` 调用自: ${stackInfo.file}:${stackInfo.line}:${stackInfo.column}`); 38 // 实际操作中,可以将 stackInfo 附加到请求头、请求体或日志中 39 // const headers = args[1]?.headers || {}; 40 // headers['X-Request-Source'] = `${stackInfo.file}:${stackInfo.line}:${stackInfo.column}`; 41 // args[1] = { ...args[1], headers }; 42 } 43 44 return originalFetch.apply(this, args); 45}; 46 47// 示例调用 48fetch('https://jsonplaceholder.typicode.com/todos/1') 49 .then(response => response.json()) 50 .then(data => console.log('Fetched data:', data)) 51 .catch(error => console.error('Fetch error:', error)); 52 53// 另一个调用 54const callAnotherFunction = () => { 55 fetch('https://jsonplaceholder.typicode.com/posts/1'); 56}; 57callAnotherFunction();局限性:
Error.stack的格式是非标准的,不同浏览器(Chrome, Firefox, Safari)的输出格式不同,需要复杂的正则匹配来解析。Error.stack可能会包含匿名函数或异步调用栈的复杂性,定位到精确的源码位置可能需要 Source Map 的支持。- 创建
Error对象本身有轻微的性能开销,但对于网络请求来说,通常可以忽略。 - 在一些异步链式调用中,直接使用
new Error().stack可能只能捕获到Promise回调或async/await的内部调用栈,而不是最原始的触发点。
- 拦截或封装
-
构建工具 / Babel 插件 / Webpack Loader:
这是更健壮和自动化的方法,但需要在构建时进行处理。实现原理:
- 在编译时,通过 AST (Abstract Syntax Tree) 转换,在每个
fetch或XMLHttpRequest.send()调用点注入文件路径、行号和列号作为参数或请求头。 - 这需要自定义 Babel 插件或 Webpack loader。
示例 (概念性 Babel 插件,非完整代码):
1// babel-plugin-inject-request-source.js (概念代码) 2module.exports = function({ types: t }) { 3 return { 4 visitor: { 5 CallExpression(path, state) { 6 const callee = path.node.callee; 7 // 检查是否是 fetch 调用 8 if (t.isIdentifier(callee, { name: 'fetch' })) { 9 const { line, column } = path.node.loc.start; 10 const filename = state.file.opts.filename; // 获取当前文件路径 11 12 // 构建一个表示源信息的对象字面量 13 const sourceInfo = t.objectExpression([ 14 t.objectProperty(t.identifier('file'), t.stringLiteral(filename)), 15 t.objectProperty(t.identifier('line'), t.numericLiteral(line)), 16 t.objectProperty(t.identifier('column'), t.numericLiteral(column)), 17 ]); 18 19 // 查找 options 参数 (args[1]) 20 let options = path.node.arguments[1]; 21 if (!options) { 22 options = t.objectExpression([]); // 如果没有 options,创建一个空对象 23 path.node.arguments[1] = options; 24 } 25 26 // 假设我们想将源信息注入到 options.headers.X-Source 中 27 // 这部分需要更复杂的AST操作来查找或创建 headers 对象 28 // 简单起见,这里假设直接注入到 options 对象 29 if (t.isObjectExpression(options)) { 30 options.properties.push( 31 t.objectProperty(t.stringLiteral('sourceInfo'), sourceInfo) 32 ); 33 } 34 } 35 } 36 } 37 }; 38};优点:
- 准确性高,直接在编译阶段注入,不受运行时异步栈的影响。
- 性能开销几乎为零。
缺点: - 实现复杂,需要深入理解 AST 和构建工具。
- 每次构建都需要执行,增加构建时间。
- 在编译时,通过 AST (Abstract Syntax Tree) 转换,在每个
-
Service Worker:
Service Worker 可以拦截所有请求,但它本身无法直接获取到发起请求的客户端 JS 代码的行号和列号。它只能看到请求本身的信息。不过,你可以结合上述方法(例如在主线程通过Error.stack收集信息,然后通过postMessage发送给 Service Worker,再由 Service Worker 将其附加到请求或日志中),但这会增加复杂性。
结论:
- 运行时快速实现(推荐): 优先考虑封装
fetch或XMLHttpRequest并结合Error.prototype.stack。虽然解析stack字符串有兼容性问题,但对于主流浏览器和开发调试来说,是可行的。配合 Source Map 可以在错误监控平台中还原原始源码位置。 - 生产环境和精度要求高: 如果对精确性和性能有极高要求,并且不介意构建复杂性,可以考虑自定义 Babel 插件或 Webpack Loader 在编译时注入。
在实际的前端监控系统中,通常会结合使用 Error.stack 来捕获调用堆栈,然后通过 Source Map 逆向解析,以定位到原始的源码位置。
24. 请求失败会弹出一个toast,如何保证批量请求失败,只弹出一个toast
在处理批量请求失败时,如果每个失败的请求都弹出一个 Toast 提示,会导致用户体验非常差。我们需要一种机制来聚合这些失败,只弹出一个统一的 Toast。
以下是几种实现方案:
-
防抖 (Debounce) Toast 提示:
这是最简单有效的方案之一。当一个请求失败时,不立即弹出 Toast,而是设置一个短时间的定时器。如果在定时器到期前有新的请求失败,则取消前一个定时器并重新计时。这样,只有当所有失败请求都发生后,并且在短时间内没有新的失败时,才会弹出一次 Toast。- 优点: 实现简单,通用性强。
- 缺点: 可能会有短暂的延迟,如果短时间内失败的请求数量巨大,每次重新计时可能导致 Toast 延迟过久。
示例:
1let toastTimer = null; 2let failedRequests = []; // 存储失败请求的详细信息 3 4function showAggregatedToast(message) { 5 // 假设这里是你的 Toast 提示函数 6 console.log(`[Toast] 统一提示: ${message}`); 7 // 可以在这里显示一个更友好的弹窗,包含 failedRequests 里的所有错误 8 // 例如: failedRequests.map(err => err.message).join('; ') 9 failedRequests = []; // 显示后清空 10} 11 12const debouncedShowToast = (message) => { 13 clearTimeout(toastTimer); 14 toastTimer = setTimeout(() => { 15 showAggregatedToast(message); 16 }, 300); // 300ms 内不再有新的失败,则弹出 Toast 17}; 18 19async function makeRequest(url, requestName = '未知请求') { 20 try { 21 const response = await fetch(url); 22 if (!response.ok) { 23 throw new Error(`${requestName} 请求失败,状态码: ${response.status}`); 24 } 25 const data = await response.json(); 26 console.log(`${requestName} 成功:`, data); 27 return data; 28 } catch (error) { 29 console.error(`${requestName} 失败:`, error.message); 30 failedRequests.push({ name: requestName, message: error.message }); 31 debouncedShowToast('部分请求失败,请稍后重试或查看详情。'); // 触发防抖提示 32 throw error; // 继续抛出错误以便上层处理 33 } 34} 35 36// 批量发起请求 37document.getElementById('batchRequestBtn').addEventListener('click', () => { 38 makeRequest('https://jsonplaceholder.typicode.com/invalid-path-1', '请求 A'); 39 makeRequest('https://jsonplaceholder.typicode.com/invalid-path-2', '请求 B'); 40 makeRequest('https://jsonplaceholder.typicode.com/invalid-path-3', '请求 C'); 41}); -
请求批处理 (Batch Processing) 模式:
如果这些请求是作为一组逻辑上的“批处理”任务来发起的,可以在这组请求全部完成(无论成功或失败)后,再统一处理其结果。- 优点: 逻辑清晰,所有结果一起处理,用户体验更可控。
- 缺点: 需要明确定义请求的“批次”,不适用于分散的独立请求。
示例:
1async function processBatchRequests(urls) { 2 let allResults = []; 3 let allErrors = []; 4 let successCount = 0; 5 let failCount = 0; 6 7 // 使用 Promise.allSettled 来等待所有请求完成,无论成功或失败 8 const promises = urls.map(url => 9 fetch(url.url) 10 .then(response => { 11 if (!response.ok) { 12 throw new Error(`${url.name} 失败: ${response.status}`); 13 } 14 return response.json(); 15 }) 16 .then(data => ({ status: 'fulfilled', value: data, name: url.name })) 17 .catch(error => ({ status: 'rejected', reason: error, name: url.name })) 18 ); 19 20 const results = await Promise.allSettled(promises); 21 22 results.forEach(result => { 23 if (result.status === 'fulfilled') { 24 allResults.push(result.value); 25 successCount++; 26 } else { 27 allErrors.push(result.reason); 28 failCount++; 29 } 30 }); 31 32 console.log(`批处理完成: 成功 ${successCount} 个, 失败 ${failCount} 个`); 33 34 if (failCount > 0) { 35 const errorMessage = `共有 ${failCount} 个请求失败。详情:${allErrors.map(e => e.message).join('; ')}`; 36 console.log(`[Toast] 统一提示: ${errorMessage}`); 37 // 假设这里是你的 Toast 提示函数 38 } else { 39 console.log('所有请求都成功了!'); 40 } 41 42 return { allResults, allErrors }; 43} 44 45// 批量发起请求 46document.getElementById('batchRequestBtn').addEventListener('click', () => { 47 const requestList = [ 48 { url: 'https://jsonplaceholder.typicode.com/todos/1', name: 'Todo 1' }, 49 { url: 'https://jsonplaceholder.typicode.com/invalid-path-4', name: 'Invalid Path 4' }, 50 { url: 'https://jsonplaceholder.typicode.com/posts/1', name: 'Post 1' }, 51 { url: 'https://jsonplaceholder.typicode.com/invalid-path-5', name: 'Invalid Path 5' }, 52 ]; 53 processBatchRequests(requestList); 54}); -
显示标志位 (Single Toast Flag):
维护一个全局或组件内部的标志位,表示当前是否正在显示 Toast 或是否已经有 Toast 消息待处理。在显示 Toast 前检查此标志。- 优点: 简单直接。
- 缺点: 如果不结合防抖,可能导致多个错误叠加时只显示第一个或最后一个错误信息。需要结合防抖或将所有错误信息合并成一个字符串。
1let isToastShowing = false; 2let toastQueue = []; 3 4function showToast(message) { 5 if (isToastShowing) { 6 toastQueue.push(message); // 将消息加入队列 7 return; 8 } 9 10 isToastShowing = true; 11 console.log(`[Toast] ${message}`); 12 // 假设实际显示 Toast 组件,并设置一个自动关闭时间 13 setTimeout(() => { 14 isToastShowing = false; 15 if (toastQueue.length > 0) { 16 // 如果队列中还有消息,显示下一条(或者合并显示) 17 const nextMessage = toastQueue.shift(); 18 showToast(nextMessage); // 递归调用,但要小心栈溢出,实际用循环 19 } 20 }, 3000); // 3秒后关闭 Toast 21} 22 23// 改进的 showToast,合并消息 24let currentToastMessage = ''; 25let toastDisplayTimer = null; 26 27function showAggregatedToastCombined(message) { 28 clearTimeout(toastDisplayTimer); // 每次有新消息,重置定时器 29 30 if (currentToastMessage) { 31 // 如果已经有消息,尝试合并 32 if (!currentToastMessage.includes(message)) { // 避免重复添加完全相同的消息 33 currentToastMessage += `; ${message}`; 34 } 35 } else { 36 currentToastMessage = message; 37 } 38 39 toastDisplayTimer = setTimeout(() => { 40 console.log(`[Toast] 统一提示: ${currentToastMessage}`); 41 currentToastMessage = ''; // 显示后清空 42 }, 500); // 延迟 500ms,让足够多的错误聚合进来 43} 44 45// ... 你的 makeRequest 函数中调用 showAggregatedToastCombined 46// makeRequest(...) catch(...) { showAggregatedToastCombined('请求失败'); }
推荐方案:
- 对于用户触发的、可能导致重复请求的场景: 防抖 (Debounce) 是最常用且高效的方案,尤其适用于搜索、输入等场景。
- 对于逻辑上是一个整体的“批处理”请求: 使用
Promise.allSettled在所有子请求完成后统一处理结果并弹出一次 Toast。 - 对于分散的、独立的请求,但你希望它们失败时只弹一次 Toast: 可以将 防抖 (
debouncedShowToast) 作为核心,它能智能地聚合短时间内的多个失败事件。在debouncedShowToast的回调中,你可以汇总failedRequests数组中的信息,生成一个更详细的统一提示。
结合使用,通常能覆盖大部分场景,并提供良好的用户体验。
25. 如何减少项目里面if-else
过多的 if-else 嵌套或平铺会使代码难以阅读、理解和维护,增加认知复杂度。减少 if-else 的方法很多,核心思想是利用多态、查表、卫语句等设计模式或语言特性来扁平化逻辑。
以下是一些常见的方法:
-
使用卫语句(Guard Clauses)/提前返回:
对于函数开头的一些前置条件检查,与其使用if-else结构包裹主要逻辑,不如在条件不满足时直接返回。Bad:
1function processOrder(order) { 2 if (order) { 3 if (order.status === 'paid') { 4 if (order.amount > 0) { 5 // 核心业务逻辑 6 console.log('处理已支付订单...'); 7 } else { 8 console.log('订单金额无效'); 9 } 10 } else { 11 console.log('订单未支付'); 12 } 13 } else { 14 console.log('订单不存在'); 15 } 16}Good:
1function processOrder(order) { 2 if (!order) { 3 console.log('订单不存在'); 4 return; 5 } 6 if (order.status !== 'paid') { 7 console.log('订单未支付'); 8 return; 9 } 10 if (order.amount <= 0) { 11 console.log('订单金额无效'); 12 return; 13 } 14 // 核心业务逻辑(扁平化了) 15 console.log('处理已支付订单...'); 16} -
使用对象/Map 映射(查表法):
当有多个if-else if分支基于某个键值进行判断时,可以使用对象或 Map 来替代。Bad:
1function getAction(type) { 2 if (type === 'create') { 3 return '创建操作'; 4 } else if (type === 'read') { 5 return '读取操作'; 6 } else if (type === 'update') { 7 return '更新操作'; 8 } else if (type === 'delete') { 9 return '删除操作'; 10 } else { 11 return '未知操作'; 12 } 13}Good (Object Map):
1const actionsMap = { 2 'create': '创建操作', 3 'read': '读取操作', 4 'update': '更新操作', 5 'delete': '删除操作' 6}; 7 8function getAction(type) { 9 return actionsMap[type] || '未知操作'; 10}Good (Map for complex keys/values):
1const statusHandlers = new Map([ 2 ['pending', () => console.log('处理待处理状态')], 3 ['approved', () => console.log('处理已批准状态')], 4 ['rejected', () => console.log('处理已拒绝状态')], 5]); 6 7function handleStatus(status) { 8 const handler = statusHandlers.get(status); 9 if (handler) { 10 handler(); 11 } else { 12 console.log('未知状态'); 13 } 14} -
使用多态(Polymorphism)/策略模式:
当基于不同类型执行不同行为时,可以将这些行为封装到不同的类或对象中,并通过共同的接口或方法来调用。Bad:
1function processShape(shape) { 2 if (shape.type === 'circle') { 3 console.log(`绘制圆形,半径: ${shape.radius}`); 4 } else if (shape.type === 'rectangle') { 5 console.log(`绘制矩形,宽: ${shape.width}, 高: ${shape.height}`); 6 } else if (shape.type === 'triangle') { 7 console.log(`绘制三角形,边长: ${shape.side}`); 8 } 9}Good (Strategy Pattern):
1// 定义接口/抽象方法 2class Shape { 3 draw() { 4 throw new Error('draw() must be implemented by subclasses'); 5 } 6} 7 8class Circle extends Shape { 9 constructor(radius) { 10 super(); 11 this.radius = radius; 12 } 13 draw() { 14 console.log(`绘制圆形,半径: ${this.radius}`); 15 } 16} 17 18class Rectangle extends Shape { 19 constructor(width, height) { 20 super(); 21 this.width = width; 22 this.height = height; 23 } 24 draw() { 25 console.log(`绘制矩形,宽: ${this.width}, 高: ${this.height}`); 26 } 27} 28 29class Triangle extends Shape { 30 constructor(side) { 31 super(); 32 this.side = side; 33 } 34 draw() { 35 console.log(`绘制三角形,边长: ${this.side}`); 36 } 37} 38 39const circle = new Circle(10); 40const rect = new Rectangle(20, 30); 41const tri = new Triangle(15); 42 43circle.draw(); 44rect.draw(); 45tri.draw(); -
使用
switch语句:
当if-else if分支是基于一个变量的离散值时,switch语句通常比一长串的if-else if更清晰。Bad:
1function getWeather(code) { 2 if (code === 1000) { return '晴天'; } 3 else if (code === 1001) { return '多云'; } 4 else if (code === 1002) { return '阴天'; } 5 else { return '未知'; } 6}Good:
1function getWeather(code) { 2 switch (code) { 3 case 1000: return '晴天'; 4 case 1001: return '多云'; 5 case 1002: return '阴天'; 6 default: return '未知'; 7 } 8} -
使用三元运算符(Ternary Operator):
对于简单的二元条件判断,三元运算符可以使代码更简洁。Bad:
1let statusMessage; 2if (isActive) { 3 statusMessage = '活跃'; 4} else { 5 statusMessage = '非活跃'; 6}Good:
1const statusMessage = isActive ? '活跃' : '非活跃'; -
使用可选链 (
?.) 和空值合并运算符 (??):
处理可能为null或undefined的属性时,可以避免大量的if (x && x.y && x.y.z)。Bad:
1let userName = '未知'; 2if (user && user.profile && user.profile.name) { 3 userName = user.profile.name; 4}Good:
1const userName = user?.profile?.name ?? '未知'; -
状态机(State Machine):
对于复杂的、有明确状态转换的应用逻辑,使用状态机库(如xstate)可以清晰地定义状态和转换条件,从而将if-else逻辑抽象化。 -
配置化/元数据驱动:
将一些业务规则或逻辑以配置(JSON、YAML 等)的形式存储,通过解析配置来执行相应的行为,而不是硬编码在if-else中。
减少 if-else 并非消除所有条件判断,而是将它们以更结构化、更易于扩展和维护的方式表达出来。
26. babel-runtime作用是啥
@babel/runtime 是 Babel 的一个核心辅助库,它主要用于解决 Babel 转换代码时的一些问题,特别是关于重复代码和污染全局环境的问题。它通常与 @babel/plugin-transform-runtime 插件一起使用。
主要作用:
-
减少代码重复(Code Duplication):
- 当 Babel 转换 ES6+ 语法(如
async/await、class、_extend、_interopRequireDefault等)时,它会插入一些辅助函数(helper functions)。 - 如果没有
@babel/runtime,这些辅助函数会在每个需要它们的编译文件中重复出现,导致最终打包文件体积增大。 @babel/runtime的作用就是将这些辅助函数集中起来,作为模块按需引入。当@babel/plugin-transform-runtime插件检测到需要辅助函数时,它会从babel-runtime模块中导入,而不是直接在每个文件中内联。这大大减少了编译后的代码体积。
- 当 Babel 转换 ES6+ 语法(如
-
避免污染全局环境(No Global Pollution):
- Babel 转换过程中可能需要引入一些新的内置对象或方法(例如
Promise、Set、Map、Array.prototype.includes等),这些通常通过polyfill来提供。 - 传统的
babel-polyfill(现在是core-js和regenerator-runtime的组合) 会直接修改全局对象(如window或global),这可能导致全局环境污染,尤其是在开发组件库或需要与其他脚本隔离的环境中。 @babel/runtime不会污染全局环境。它提供的 polyfills 是以模块化的方式按需引入的,并通过包装器实现,这意味着它们只在需要它们的模块内部生效,而不会修改全局window或global对象。
- Babel 转换过程中可能需要引入一些新的内置对象或方法(例如
它做了什么(结合 @babel/plugin-transform-runtime):
当 @babel/plugin-transform-runtime 插件启用时,它会做以下事情:
-
将内联的 Babel 辅助函数替换为
require或import语句:
例如,将_classCallCheck这样的辅助函数替换为require("@babel/runtime/helpers/classCallCheck")。 -
将新的内置对象和方法的引用替换为
core-js模块导入:
例如,将new Promise()转换为new _Promise(),其中_Promise是从@babel/runtime/core-js/promise导入的。 -
处理生成器函数(Generator Functions)和
async/await:
Babel 会将它们转换为基于regenerator-runtime的代码。@babel/plugin-transform-runtime会将regenerator-runtime的引用替换为从@babel/runtime/regenerator导入。
何时使用:
- 开发库和组件: 当你开发一个供其他项目使用的库或组件时,为了避免全局污染和减小打包体积,通常会使用
@babel/runtime。这样你的库就不会影响使用者的全局环境。 - 非 Webpack / Rollup 等模块打包器环境: 在一些没有模块打包器或模块化需求较小的环境中,
@babel/runtime也能确保辅助函数按需导入,减少重复。 - 与
core-js(完整polyfill) 结合使用: 如果你的应用需要完整的polyfill来支持旧浏览器,你通常会同时使用@babel/runtime(处理辅助函数和模块化 polyfill)和core-js/stable(处理全局 polyfill,通常在入口文件引入一次)。
总结:
@babel/runtime 及其配套插件 @babel/plugin-transform-runtime 的核心作用是:通过模块化方式引入 Babel 辅助函数和 polyfill,从而减少代码重复,避免污染全局环境,使编译后的代码更小、更干净。 它是 Babel 生态系统中提高代码质量和可维护性的重要组成部分。
27. 如何实现预览PDF文件
在前端实现 PDF 文件预览有多种方法,每种方法有其优缺点,适用于不同的场景和需求。
-
使用
<iframe>标签 (最简单,依赖浏览器原生支持)- 原理: 浏览器通常内置了 PDF 阅读器功能。将 PDF 文件的 URL 设置为
<iframe>的src属性,浏览器会自动渲染 PDF。 - 优点: 实现极其简单,无需任何 JavaScript 库。性能由浏览器原生优化。
- 缺点:
- 兼容性问题: 不同浏览器对 PDF 渲染的支持和功能(如缩放、搜索、打印)可能不同。部分旧版本浏览器可能不支持。
- 样式定制受限: 无法自定义 PDF 预览器的 UI 样式。
- 安全性沙箱:
iframe内部内容与外部隔离,难以进行交互(如获取页码、高亮文本)。 - 跨域问题: 如果 PDF 文件与当前页面不同源,可能需要 CORS 设置。
- 适用场景: 对预览功能要求不高,只是希望用户能查看 PDF 内容。
示例:
1<iframe src="path/to/your.pdf" width="100%" height="600px" style="border: none;"> 2 <!-- 如果浏览器不支持 iframe 或 PDF 预览,可以提供 fallback 内容 --> 3 <p>您的浏览器不支持 PDF 预览。请 <a href="path/to/your.pdf" target="_blank">点击这里下载 PDF</a>。</p> 4</iframe>对于在线 PDF,可以直接使用其 URL。
- 原理: 浏览器通常内置了 PDF 阅读器功能。将 PDF 文件的 URL 设置为
-
使用 PDF.js (Mozilla 官方开源库,功能强大)
- 原理: PDF.js 是一个用 JavaScript 编写的 PDF 阅读器。它可以在 HTML5
<canvas>元素上渲染 PDF 文档。 - 优点:
- 完全前端实现: 不依赖浏览器内置阅读器,提供一致的跨浏览器体验。
- 高度可定制: 可以完全控制 UI、工具栏、缩放、搜索、打印等功能。
- 功能丰富: 支持文本选择、搜索、注解、表单填写等。
- 安全性: 在沙箱环境中运行。
- 缺点:
- 文件较大: 引入的 JavaScript 文件(特别是 worker 和 viewer 文件)体积较大。
- 性能开销: 渲染复杂 PDF 可能对性能有一定影响,尤其是对于大型文档。
- 实现相对复杂: 需要编写 JavaScript 代码来加载、渲染 PDF 页面,并构建 UI。
- 适用场景: 需要高度定制 PDF 预览功能、跨浏览器一致性、或者需要与 PDF 内容进行深度交互的场景。
基本使用流程:
- 引入 PDF.js 库文件:
可以从官方 GitHub (mozilla/pdf.js) 下载预构建版本,或者通过 CDN 引入。通常需要引入pdf.min.js和pdf.worker.min.js。 - 创建
<canvas>元素: 用于渲染 PDF 页面。 - 编写 JavaScript 代码:
- 加载 PDF 文档 (
pdfjsLib.getDocument(url))。 - 获取页面对象 (
pdf.getPage(pageNum))。 - 设置视口 (
page.getViewport({ scale: scale }))。 - 在
<canvas>上渲染页面 (page.render())。
- 加载 PDF 文档 (
示例 (简化版):
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>PDF.js Viewer</title> 7 <style> 8 body { font-family: sans-serif; text-align: center; } 9 #pdf-viewer-container { 10 width: 800px; 11 height: 600px; 12 margin: 20px auto; 13 border: 1px solid #ccc; 14 overflow: auto; /* 允许滚动查看大页面 */ 15 } 16 canvas { 17 border: 1px solid #eee; 18 display: block; /* 避免 canvas 底部有空白 */ 19 margin: 0 auto 5px auto; /* 页面间距 */ 20 } 21 .controls { margin-bottom: 10px; } 22 .loading-text { font-style: italic; color: #666; } 23 </style> 24</head> 25<body> 26 <h1>PDF.js 预览示例</h1> 27 <div class="controls"> 28 <button id="prev">上一页</button> 29 <span id="page_num"></span> / <span id="page_count"></span> 30 <button id="next">下一页</button> 31 <input type="range" min="0.5" max="2" step="0.1" value="1.0" id="scaleRange"> 32 <span id="scaleValue">100%</span> 33 </div> 34 35 <div id="pdf-viewer-container"> 36 <p class="loading-text" id="loadingText">正在加载 PDF...</p> 37 <!-- Canvas 将在这里动态添加 --> 38 </div> 39 40 <!-- 引入 PDF.js 库 --> 41 <!-- 实际项目中,这些文件应该从你的项目路径提供 --> 42 <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script> 43 <script> 44 // workerSrc 是 PDF.js worker 文件的路径,非常重要 45 pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; 46 47 const pdfUrl = 'https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf'; // 示例 PDF URL 48 49 let pdfDoc = null; // PDF 文档对象 50 let pageNum = 1; // 当前页码 51 let pageRendering = false; // 是否正在渲染页面 52 let pageNumPending = null; // 待渲染的页码(用于防止频繁渲染) 53 let scale = 1.0; // 缩放比例 54 55 const container = document.getElementById('pdf-viewer-container'); 56 const pageNumSpan = document.getElementById('page_num'); 57 const pageCountSpan = document.getElementById('page_count'); 58 const prevBtn = document.getElementById('prev'); 59 const nextBtn = document.getElementById('next'); 60 const scaleRange = document.getElementById('scaleRange'); 61 const scaleValueSpan = document.getElementById('scaleValue'); 62 const loadingText = document.getElementById('loadingText'); 63 64 /** 65 * 获取页面并渲染到 canvas 66 * @param num {number} 页面数字 67 */ 68 function renderPage(num) { 69 pageRendering = true; 70 loadingText.textContent = `正在加载第 ${num} 页...`; 71 72 // 清除之前的 canvas 73 container.innerHTML = ''; 74 75 // 获取指定页面 76 pdfDoc.getPage(num).then(page => { 77 const viewport = page.getViewport({ scale: scale }); 78 const canvas = document.createElement('canvas'); 79 const ctx = canvas.getContext('2d'); 80 81 canvas.height = viewport.height; 82 canvas.width = viewport.width; 83 container.appendChild(canvas); 84 85 const renderContext = { 86 canvasContext: ctx, 87 viewport: viewport 88 }; 89 90 const renderTask = page.render(renderContext); 91 92 // 等待渲染完成 93 renderTask.promise.then(() => { 94 pageRendering = false; 95 loadingText.textContent = ''; // 隐藏加载提示 96 if (pageNumPending !== null) { 97 // 有其他页面在等待渲染,继续渲染 98 renderPage(pageNumPending); 99 pageNumPending = null; 100 } 101 }); 102 }); 103 104 pageNumSpan.textContent = num; 105 } 106 107 /** 108 * 如果正在渲染页面,则将要渲染的页码保存起来,待当前页面渲染完成后再渲染 109 * @param num {number} 页面数字 110 */ 111 function queueRenderPage(num) { 112 if (pageRendering) { 113 pageNumPending = num; 114 } else { 115 renderPage(num); 116 } 117 } 118 119 // 加载 PDF 文档 120 pdfjsLib.getDocument(pdfUrl).promise.then(pdf => { 121 pdfDoc = pdf; 122 pageCountSpan.textContent = pdfDoc.numPages; // 总页数 123 loadingText.textContent = ''; // 隐藏初始加载提示 124 renderPage(pageNum); // 渲染第一页 125 }).catch(error => { 126 console.error('Error loading PDF:', error); 127 loadingText.textContent = `加载 PDF 失败: ${error.message}`; 128 }); 129 130 // 事件监听器 131 prevBtn.addEventListener('click', () => { 132 if (pageNum <= 1) { 133 return; 134 } 135 pageNum--; 136 queueRenderPage(pageNum); 137 }); 138 139 nextBtn.addEventListener('click', () => { 140 if (pageNum >= pdfDoc.numPages) { 141 return; 142 } 143 pageNum++; 144 queueRenderPage(pageNum); 145 }); 146 147 scaleRange.addEventListener('input', (event) => { 148 scale = parseFloat(event.target.value); 149 scaleValueSpan.textContent = `${Math.round(scale * 100)}%`; 150 queueRenderPage(pageNum); 151 }); 152 </script> 153</body> 154</html> - 原理: PDF.js 是一个用 JavaScript 编写的 PDF 阅读器。它可以在 HTML5
-
使用第三方在线服务/商业 SDK (推荐,如 DocuSign, PSPDFKit 等)
- 原理: 将 PDF 文件上传到(或提供 URL 给)第三方服务,然后该服务会生成一个可嵌入的查看器 URL 或提供 SDK 来渲染。
- 优点: 功能通常非常丰富,渲染质量高,支持多种文档格式,减少自身开发和维护成本。
- 缺点: 依赖第三方服务(可能需要付费),数据隐私和安全性考量,可能存在性能瓶颈或网络延迟。
- 适用场景: 对功能、兼容性要求高,且预算充足,不希望自行处理 PDF 渲染复杂性。
选择哪种方法:
- 最简单快速: 如果只追求基本功能且兼容性要求不高,使用
<iframe>。 - 高度定制化/复杂交互: 如果需要自定义 UI、深层交互或更好的跨浏览器一致性,PDF.js 是最佳选择。
- 企业级/商业产品: 如果对功能、稳定性和服务支持有高要求,且不差预算,考虑商业 SDK 或在线服务。
28. 如何在划词选择的文本上添加右键菜单(划词:标滑动选择一组字符,对组字符进行操作
要在用户划词选择的文本上添加自定义右键菜单,你需要监听 contextmenu 事件,获取选中的文本,然后根据鼠标位置显示自定义菜单。
核心步骤:
- 监听
contextmenu事件: 这个事件在用户右键点击(或长按触摸屏)时触发。你需要阻止其默认行为,以防止浏览器自身的右键菜单弹出。 - 获取选中的文本: 使用
window.getSelection()API 获取用户当前选择的文本内容。 - 获取鼠标位置: 从
contextmenu事件对象中获取clientX和clientY坐标,用于定位自定义菜单。 - 显示自定义菜单: 根据鼠标位置动态创建或显示一个预先存在的 HTML 元素作为你的右键菜单。
- 菜单项操作: 在自定义菜单中添加功能项(如“复制”、“翻译”、“搜索”),并为它们绑定点击事件,执行相应操作。
- 隐藏菜单: 当用户点击菜单项或点击页面其他地方时,隐藏自定义菜单。
示例代码:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Custom Context Menu on Selected Text</title> 7 <style> 8 body { 9 font-family: sans-serif; 10 padding: 20px; 11 background-color: #f0f0f0; 12 user-select: text; /* 确保文本可选 */ 13 } 14 .content { 15 width: 80%; 16 margin: 20px auto; 17 background-color: #fff; 18 padding: 30px; 19 border-radius: 8px; 20 box-shadow: 0 2px 5px rgba(0,0,0,0.1); 21 line-height: 1.8; 22 font-size: 16px; 23 } 24 25 /* 自定义右键菜单样式 */ 26 .custom-context-menu { 27 position: absolute; 28 background-color: #fff; 29 border: 1px solid #ddd; 30 border-radius: 4px; 31 box-shadow: 0 2px 8px rgba(0,0,0,0.2); 32 z-index: 9999; 33 list-style: none; 34 margin: 0; 35 padding: 5px 0; 36 min-width: 120px; 37 display: none; /* 默认隐藏 */ 38 } 39 40 .custom-context-menu li { 41 padding: 8px 15px; 42 cursor: pointer; 43 font-size: 14px; 44 color: #333; 45 } 46 47 .custom-context-menu li:hover { 48 background-color: #f0f0f0; 49 } 50 51 .custom-context-menu li.disabled { 52 color: #aaa; 53 cursor: not-allowed; 54 background-color: #fff; 55 } 56 </style> 57</head> 58<body> 59 <h1>在划词选择文本上添加自定义右键菜单</h1> 60 <div class="content"> 61 <p>请尝试在下方这段文本上划词(用鼠标选择一部分文字),然后右键点击选中的文字,你会看到一个自定义的菜单而不是浏览器自带的菜单。这个菜单会根据你选择的文本提供不同的操作选项。</p> 62 <p>划词功能广泛应用于各种文本编辑器、在线阅读器和翻译工具中,以提供更便捷的文本操作体验。例如,你可以选择一段文字进行翻译、搜索、高亮或者其他自定义操作。</p> 63 </div> 64 65 <!-- 自定义右键菜单结构 --> 66 <ul class="custom-context-menu" id="customContextMenu"> 67 <li id="menuCopy">复制</li> 68 <li id="menuTranslate">翻译选中文字</li> 69 <li id="menuSearch">搜索选中文字</li> 70 <li class="disabled">高亮 (暂不可用)</li> 71 </ul> 72 73 <script> 74 const contentDiv = document.querySelector('.content'); 75 const customContextMenu = document.getElementById('customContextMenu'); 76 const menuCopy = document.getElementById('menuCopy'); 77 const menuTranslate = document.getElementById('menuTranslate'); 78 const menuSearch = document.getElementById('menuSearch'); 79 80 let selectedText = ''; // 存储当前选中的文本 81 82 // 隐藏自定义菜单 83 function hideContextMenu() { 84 customContextMenu.style.display = 'none'; 85 } 86 87 // 显示自定义菜单 88 function showContextMenu(x, y) { 89 customContextMenu.style.left = `${x}px`; 90 customContextMenu.style.top = `${y}px`; 91 customContextMenu.style.display = 'block'; 92 } 93 94 // 监听右键事件 95 contentDiv.addEventListener('contextmenu', (event) => { 96 event.preventDefault(); // 阻止浏览器默认右键菜单 97 98 const selection = window.getSelection(); 99 selectedText = selection.toString().trim(); // 获取选中的文本 100 101 if (selectedText) { 102 // 如果有选中的文本,显示自定义菜单 103 showContextMenu(event.clientX, event.clientY); 104 // 根据是否有文本,启用/禁用某些菜单项 105 menuTranslate.classList.remove('disabled'); 106 menuSearch.classList.remove('disabled'); 107 } else { 108 // 没有选中文本,隐藏菜单或显示一个“无操作”的菜单 109 hideContextMenu(); 110 } 111 }); 112 113 // 监听点击事件,在点击菜单外部时隐藏菜单 114 document.addEventListener('click', (event) => { 115 // 如果点击的不是菜单本身或菜单项,则隐藏 116 if (!customContextMenu.contains(event.target)) { 117 hideContextMenu(); 118 } 119 }); 120 121 // 菜单项操作 122 menuCopy.addEventListener('click', () => { 123 if (selectedText) { 124 navigator.clipboard.writeText(selectedText) 125 .then(() => alert(`已复制: "${selectedText}"`)) 126 .catch(err => console.error('复制失败:', err)); 127 } 128 hideContextMenu(); 129 }); 130 131 menuTranslate.addEventListener('click', () => { 132 if (selectedText) { 133 alert(`将翻译文本: "${selectedText}" (实际项目中会调用翻译API)`); 134 // 实际项目中会调用翻译 API 或打开翻译弹窗 135 } 136 hideContextMenu(); 137 }); 138 139 menuSearch.addEventListener('click', () => { 140 if (selectedText) { 141 alert(`将搜索文本: "${selectedText}" (实际项目中会跳转到搜索页面)`); 142 // 实际项目中会跳转到搜索引擎或内部搜索页面 143 // window.open(`https://www.google.com/search?q=${encodeURIComponent(selectedText)}`, '_blank'); 144 } 145 hideContextMenu(); 146 }); 147 148 // 初始化:确保菜单在页面加载时是隐藏的 149 hideContextMenu(); 150 </script> 151</body> 152</html>
关键技术点和注意事项:
event.preventDefault(): 阻止默认的contextmenu事件是关键。window.getSelection(): 这是核心 API,用于获取用户在页面上选择的文本范围和内容。selection.toString():返回选中文本的字符串。selection.getRangeAt(0):返回第一个选区(Range 对象),可以获取选区的位置信息。
- 菜单定位: 使用
event.clientX和event.clientY获取鼠标点击时在视口中的坐标,然后将自定义菜单的position设置为absolute,并用left和top定位。 - 隐藏菜单:
- 在点击任何菜单项后隐藏。
- 在点击菜单外部的任何地方时隐藏(通过监听
document的click事件,并检查点击目标是否在菜单内部)。
- 用户体验:
- 启用/禁用菜单项: 根据是否有选中的文本或特定条件,动态启用或禁用菜单项(例如,如果没选中文本,翻译/搜索功能就应该是禁用的)。
- 样式: 确保自定义菜单的样式与网站整体风格一致。
- Z-index: 确保菜单的
z-index足够高,使其显示在所有其他元素之上。
user-selectCSS 属性: 确保要划词的文本元素没有设置user-select: none;,否则无法选择文本。- 移动端: 在移动端,长按通常会触发
contextmenu事件。但用户选择文本的操作(长按选择、拖动选择锚点)与桌面端有所不同。实现时需要注意兼容性。 - 文本域 (
<textarea>,<input type="text">) 和contenteditable元素: 这些元素有它们自己的文本选择和右键菜单行为。如果要在这些元素内部实现自定义右键菜单,可能需要更复杂的事件处理和协调。
29. 富文本里面,是如何做到划词的(鼠标滑动选择一组字符,对组字符进行操作?
富文本编辑器中实现划词(鼠标滑动选择一组字符,并对这组字符进行操作)是其核心功能之一。这主要依赖于浏览器原生的文本选择能力以及 JavaScript 的 Selection 和 Range API。
核心原理和步骤:
-
contenteditable属性:
富文本编辑器的基础是使一个 HTML 元素(通常是<div>或<iframe>的<body>)可编辑。这通过设置contenteditable="true"属性来实现。- 当一个元素被设置为
contenteditable后,浏览器会赋予它原生的文本编辑和选择能力,包括鼠标拖拽选择文本、键盘导航、复制粘贴等。
- 当一个元素被设置为
-
浏览器原生的文本选择机制:
当用户在contenteditable区域内用鼠标拖拽时,浏览器会自动创建一个选区(Selection)。这个选区包含了用户高亮显示的所有文本。 -
SelectionAPI (window.getSelection()):
JavaScript 通过window.getSelection()方法提供对当前选区的编程访问。Selection对象代表用户选择的文本范围。selection.toString(): 获取选区中的纯文本内容。selection.rangeCount: 获取选区中包含的Range对象的数量(通常是 1 个,除非用户按住 Ctrl/Cmd 键进行多选)。selection.getRangeAt(index): 获取指定索引的Range对象。selection.anchorNode,selection.anchorOffset,selection.focusNode,selection.focusOffset: 描述选区的起始和结束位置。
-
RangeAPI (document.createRange()):
Range对象代表文档中的一个连续的区域,可以包含 DOM 节点和文本。range.commonAncestorContainer: 选区内所有节点的最近公共祖先。range.startContainer,range.startOffset,range.endContainer,range.endOffset: 描述Range的起始和结束边界。range.deleteContents(): 删除选区内的内容。range.insertNode(node): 在选区起始位置插入节点。range.surroundContents(newParent): 用一个新节点包裹选区内的内容。range.cloneContents(): 克隆选区内的内容。
富文本编辑器如何利用这些 API 来实现“划词操作”:
- 监听事件:
编辑器通常会监听mouseup、keyup(为了支持键盘选择)、selectionchange等事件。 - 获取选区:
在这些事件的回调中,通过window.getSelection()获取当前的Selection对象。 - 判断是否有选区:
检查selection.rangeCount > 0或selection.isCollapsed === false(即选区不为空) 来判断是否有文本被选中。 - 操作选区:
- 获取文本:
selection.toString()获得选中的纯文本。这常用于复制、翻译、搜索等操作。 - 格式化: 对于“加粗”、“斜体”、“改变颜色”等操作:
- 获取
Range对象 (selection.getRangeAt(0))。 - 使用
document.execCommand()(部分被废弃,不推荐) 或更推荐的方式: - 创建新节点: 创建一个
<b>或<span>元素。 - 包裹内容: 使用
range.surroundContents(newElement)将选中的文本内容包裹在新创建的元素中。 - 替换内容: 或者先
range.extractContents()提取内容,然后对内容进行处理,最后range.insertNode()插入处理后的内容。 - 修改属性: 如果是图片等元素的选择,直接修改其属性。
- 获取
- 获取 HTML 内容: 也可以通过
range.cloneContents()获取选区内容的 HTML 结构。
- 获取文本:
- 自定义浮动菜单或右键菜单:
当检测到有文本被选中时,编辑器会根据选区的位置(通过Range.getBoundingClientRect()或Selection.getRangeAt(0).getBoundingClientRect()获取选区的矩形框位置)计算并显示一个浮动的工具栏(如 Medium 编辑器中的浮动工具栏)或自定义右键菜单。
示例(概念性,非完整编辑器代码):
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Rich Text Selection Demo</title> 7 <style> 8 body { font-family: sans-serif; padding: 20px; } 9 .editor { 10 border: 1px solid #ccc; 11 min-height: 200px; 12 padding: 15px; 13 margin-top: 20px; 14 font-size: 16px; 15 line-height: 1.6; 16 outline: none; /* 移除默认焦点边框 */ 17 background-color: #fff; 18 box-shadow: 0 2px 5px rgba(0,0,0,0.1); 19 } 20 .floating-toolbar { 21 position: absolute; 22 background-color: #333; 23 color: white; 24 padding: 5px 10px; 25 border-radius: 4px; 26 display: none; /* 默认隐藏 */ 27 z-index: 1000; 28 } 29 .floating-toolbar button { 30 background: none; 31 border: none; 32 color: white; 33 padding: 5px 10px; 34 cursor: pointer; 35 font-size: 14px; 36 } 37 .floating-toolbar button:hover { 38 background-color: #555; 39 border-radius: 3px; 40 } 41 #output { 42 margin-top: 30px; 43 padding: 15px; 44 border: 1px dashed #ddd; 45 background-color: #f9f9f9; 46 } 47 </style> 48</head> 49<body> 50 <h1>富文本划词操作示例</h1> 51 <p>在下方可编辑区域中划词,然后点击浮动工具栏上的按钮进行操作。</p> 52 53 <div class="editor" contenteditable="true" id="editor"> 54 <p>这是富文本编辑器中的一段<strong>示例文本</strong>。你可以尝试用鼠标在这里划词选择。</p> 55 <p>选择不同的词语或句子,然后你会看到一个浮动工具栏出现。</p> 56 <ul> 57 <li>列表项 1</li> 58 <li>列表项 2</li> 59 </ul> 60 <p>这个工具栏会根据你的选择,提供相应的操作,例如加粗、斜体等。</p> 61 </div> 62 63 <div class="floating-toolbar" id="toolbar"> 64 <button id="boldBtn"><b>B</b></button> 65 <button id="italicBtn"><i>I</i></button> 66 <button id="copyBtn">复制</button> 67 <button id="highlightBtn">高亮</button> 68 </div> 69 70 <div id="output"> 71 <p>选中的文本: <strong id="selectedTextOutput">无</strong></p> 72 </div> 73 74 <script> 75 const editor = document.getElementById('editor'); 76 const toolbar = document.getElementById('toolbar'); 77 const boldBtn = document.getElementById('boldBtn'); 78 const italicBtn = document.getElementById('italicBtn'); 79 const copyBtn = document.getElementById('copyBtn'); 80 const highlightBtn = document.getElementById('highlightBtn'); 81 const selectedTextOutput = document.getElementById('selectedTextOutput'); 82 83 let currentRange = null; // 存储当前选区 84 85 // 更新工具栏位置和状态 86 function updateToolbar() { 87 const selection = window.getSelection(); 88 if (!selection.rangeCount) { 89 toolbar.style.display = 'none'; 90 selectedTextOutput.textContent = '无'; 91 return; 92 } 93 94 const range = selection.getRangeAt(0); 95 currentRange = range; // 保存当前选区 96 97 if (range.collapsed) { // 如果选区是折叠的(没有选中任何内容) 98 toolbar.style.display = 'none'; 99 selectedTextOutput.textContent = '无'; 100 return; 101 } 102 103 const rect = range.getBoundingClientRect(); // 获取选区矩形框 104 const editorRect = editor.getBoundingClientRect(); 105 106 // 如果选区不在编辑器内,也隐藏工具栏 107 if (rect.left < editorRect.left || rect.right > editorRect.right || 108 rect.top < editorRect.top || rect.bottom > editorRect.bottom) { 109 toolbar.style.display = 'none'; 110 selectedTextOutput.textContent = '无'; 111 return; 112 } 113 114 // 计算工具栏位置 115 toolbar.style.left = `${rect.left + window.scrollX + (rect.width / 2) - (toolbar.offsetWidth / 2)}px`; 116 toolbar.style.top = `${rect.top + window.scrollY - toolbar.offsetHeight - 10}px`; // 向上偏移10px 117 toolbar.style.display = 'block'; 118 119 selectedTextOutput.textContent = selection.toString(); 120 } 121 122 // 监听 selectionchange 事件 123 document.addEventListener('selectionchange', updateToolbar); 124 editor.addEventListener('mouseup', updateToolbar); // 确保拖拽结束后立即更新 125 126 // 防止点击工具栏时失去焦点导致选区消失 127 toolbar.addEventListener('mousedown', (e) => e.preventDefault()); 128 129 // 格式化操作 130 boldBtn.addEventListener('click', () => { 131 if (currentRange) { 132 const selection = window.getSelection(); 133 selection.removeAllRanges(); // 移除旧的选区 134 selection.addRange(currentRange); // 恢复之前的选区 135 136 document.execCommand('bold', false, null); // 使用 execCommand 加粗 137 // 更好的做法是操作 Range API 138 // const b = document.createElement('b'); 139 // currentRange.surroundContents(b); 140 // currentRange.collapse(false); // 保持光标在选中内容之后 141 } 142 updateToolbar(); // 更新工具栏状态 143 }); 144 145 italicBtn.addEventListener('click', () => { 146 if (currentRange) { 147 const selection = window.getSelection(); 148 selection.removeAllRanges(); 149 selection.addRange(currentRange); 150 document.execCommand('italic', false, null); 151 } 152 updateToolbar(); 153 }); 154 155 copyBtn.addEventListener('click', () => { 156 if (currentRange) { 157 const selectedText = currentRange.toString(); 158 if (selectedText) { 159 navigator.clipboard.writeText(selectedText) 160 .then(() => alert('已复制!')) 161 .catch(err => console.error('复制失败:', err)); 162 } 163 } 164 updateToolbar(); 165 }); 166 167 highlightBtn.addEventListener('click', () => { 168 if (currentRange) { 169 const selection = window.getSelection(); 170 selection.removeAllRanges(); 171 selection.addRange(currentRange); 172 173 // 使用 Range API 进行高亮操作 174 const span = document.createElement('span'); 175 span.style.backgroundColor = 'yellow'; 176 span.style.color = 'black'; 177 178 try { 179 currentRange.surroundContents(span); 180 // 确保光标在操作后仍然在合适位置 181 currentRange.collapse(false); // 移动光标到选区末尾 182 selection.removeAllRanges(); 183 selection.addRange(currentRange); 184 } catch (e) { 185 console.error("无法包裹内容,可能是因为选区不完整或跨越了非文本节点:", e); 186 alert("高亮失败:可能选区不连续或包含特殊元素。"); 187 } 188 } 189 updateToolbar(); 190 }); 191 192 // 初始隐藏工具栏 193 toolbar.style.display = 'none'; 194 </script> 195</body> 196</html>
富文本编辑器中的实际复杂性:
上述示例只是一个简化版。真实的富文本编辑器会面临更多挑战:
- 跨浏览器兼容性:
Range和SelectionAPI 在不同浏览器中可能存在细微差异。 - 复杂 DOM 结构: 选区可能横跨多个节点(如
<b>,<i>,<span>,<div>)。处理这些跨节点选区需要更复杂的逻辑。 document.execCommand()的限制: 许多格式化操作在现代编辑器中不再直接使用execCommand,因为它行为不一致且已被废弃。取而代之的是手动操作Range和SelectionAPI 来创建和修改 DOM 节点。- 历史记录/撤销重做: 每次操作都需要记录下来,以便实现撤销和重做功能。
- IME 输入法支持: 确保输入法在
contenteditable元素中正常工作。 - 粘贴处理: 过滤粘贴的内容,确保格式正确和安全性。
- 性能: 对于大型文档,频繁的 DOM 操作和选区计算可能导致性能问题。
现代的富文本编辑器库(如 Quill, Draft.js, ProseMirror, TinyMCE, CKEditor)都构建在这些底层 API 之上,并解决了这些复杂性,提供了更高级、更稳定的抽象层。
30. 如何做好前端监控方案
一个好的前端监控方案是确保线上应用稳定性、性能和用户体验的关键。它通常包括以下几个方面:
-
性能监控 (Performance Monitoring)
- 指标:
- 核心 Web Vitals (Google 推荐): LCP (Largest Contentful Paint), FID (First Input Delay), CLS (Cumulative Layout Shift)。
- 传统性能指标: FMP (First Meaningful Paint), FCP (First Contentful Paint), TTI (Time to Interactive), DNS 查询时间、TCP 连接时间、首字节时间 (TTFB)、DOM Ready 时间、页面完全加载时间 (Load Event)。
- 资源加载时间: 图片、CSS、JS、字体等静态资源的加载耗时和大小。
- 自定义指标: 业务相关的关键时间点,如首屏渲染完成时间、用户登录成功时间等。
- 收集方式:
PerformanceObserverAPI: 异步、非阻塞地监听各种性能事件(paint,longtask,resource,navigation,largest-contentful-paint,first-input,layout-shift)。performance.timingAPI: 获取传统性能时间戳(但已废弃,推荐使用PerformanceNavigationTiming)。performance.getEntriesByType(): 获取已加载资源的详细信息。
- 上报: 使用
navigator.sendBeacon在页面卸载时发送数据,避免阻塞。
- 指标:
-
错误监控 (Error Monitoring)
- 类型:
- JavaScript 运行时错误: 未捕获的异常。
- 资源加载错误: 图片、脚本、CSS 加载失败。
- Promise 错误: 未捕获的 Promise rejection。
- API 请求错误: HTTP 状态码非 2xx,或业务逻辑错误。
- 框架错误: React/Vue 等框架内部的错误。
- 收集方式:
window.onerror: 捕获未被try...catch包裹的 JS 运行时错误。window.addEventListener('unhandledrejection'): 捕获未处理的 Promise rejection。element.onerror或window.addEventListener('error', ..., true): 捕获资源加载错误。- 封装
fetch/XMLHttpRequest: 在请求失败时手动上报。 - React Error Boundary / Vue
errorHandler: 捕获组件渲染错误。
- 信息: 错误信息、错误堆栈、发生错误的 URL、用户代理、浏览器信息、用户 ID、发生时间等。
- Source Map: 部署 Source Map 到服务器,以便在收到混淆/压缩后的错误堆栈时,能够还原到原始代码位置。
- 类型:
-
用户行为监控 (User Behavior Monitoring/Analytics)
- 指标:
- PV (Page View) / UV (Unique Visitor): 页面访问量和独立访客。
- 停留时间: 用户在页面上的停留时长。
- 点击热点图: 统计用户点击的区域。
- 转化漏斗: 用户从开始到完成某个任务(如注册、下单)的每一步。
- 自定义事件: 针对特定业务场景定义的事件(如表单提交、视频播放)。
- 收集方式:
- 手动埋点: 在关键操作点手动调用上报函数。
- 自动化埋点: 通过事件委托或代理,自动监听并上报点击、输入、路由变化等事件。
- Session Replay: 记录用户的所有操作(包括 DOM 变化、鼠标移动等),实现用户操作路径的回放(如 rrweb)。
- 上报: 通常采用图片打点(Image Beacon)、
navigator.sendBeacon或 XHR 异步发送数据。
- 指标:
-
接口监控 (API Monitoring)
- 指标:
- 请求成功率、失败率。
- 请求平均耗时、TP90/TP99 耗时。
- HTTP 状态码分布。
- 请求参数和响应体大小。
- 收集方式: 封装
fetch和XMLHttpRequest,在请求发送前和接收响应后记录时间、状态等信息。
- 指标:
-
日志记录 (Logging)
- 目的: 记录应用内部的调试信息、业务流程信息等,辅助排查问题。
- 方式: 重写
console.log等方法,将其输出的内容也发送到后端日志系统。
如何构建和实施方案:
- 确定监控目标: 明确你希望监控什么(性能、错误、行为),以及这些数据将如何帮助你。
- 选择合适的工具/库:
- 自研: 如果需求特殊或想完全掌控数据,可以基于
Performance API、Error Handling API等自研。 - 开源/商业监控服务: Sentry (错误监控), Grafana/Prometheus (指标监控), Google Analytics (行为分析), Hotjar/FullStory (会话回放), Fundebug/Bugsnag (国内错误监控平台), Lighthouse (本地性能审计)。
- 自研: 如果需求特殊或想完全掌控数据,可以基于
- 数据收集与上报:
- 设计合理的数据结构和字段。
- 选择高效的上报方式(
sendBeacon优先)。 - 批量上报,减少网络请求。
- 考虑网络断开时的离线存储和重传。
- 数据存储与处理:
- 选择合适的数据库(时序数据库、日志型数据库)。
- 构建数据 ETL (抽取、转换、加载) 流程。
- 数据聚合、分析、报警。
- 数据可视化与报警:
- 构建美观、直观的数据仪表盘。
- 设置关键指标的阈值报警(如错误率超过 5%、LCP 超过 2.5s)。
- 集成报警系统(邮件、短信、钉钉、企业微信)。
- 灰度发布与 A/B Test 结合:
在灰度发布新版本时,密切监控其性能和错误指标,及时发现问题。 - 隐私和合规性:
- 敏感数据脱敏: 确保用户个人信息(如 IP 地址、设备 ID)在传输和存储前进行脱敏或匿名化处理。
- 遵守法规: 遵循 GDPR、CCPA 等数据隐私法规。
- 用户同意: 在必要时征得用户同意。
- Source Map 支持:
确保部署环境中存在 Source Map,以便在错误监控系统能够将压缩代码还原到原始代码位置。 - 持续迭代: 监控方案不是一劳永逸的,需要根据业务发展和技术栈变化持续迭代和优化。
通过全面而有效的监控,团队可以及时发现并解决线上问题,优化用户体验,为业务决策提供数据支持。
31. 如何标准化处理线上用户反馈的问题
标准化处理线上用户反馈的问题,能够提高问题解决效率,提升用户满意度,并为产品迭代提供有价值的输入。这需要一套清晰的流程、工具和团队协作机制。
以下是标准化处理用户反馈的建议步骤和实践:
-
统一的反馈入口和渠道:
- 多样化渠道: 提供多种用户反馈途径,如:
- 应用内反馈/意见箱(最推荐,可自动捕获上下文信息)。
- 客服/支持邮箱或电话。
- 社交媒体、论坛。
- 专门的用户反馈平台。
- 清晰指引: 在网站或应用中明确告知用户如何反馈,并确保入口易于找到。
- 多样化渠道: 提供多种用户反馈途径,如:
-
收集关键信息:
在用户提交反馈时,尽可能收集以下关键信息(部分可通过前端代码自动收集):- 用户联系方式: 用于后续沟通和问题解决。
- 问题描述: 鼓励用户提供清晰、详细的文字描述。
- 重现步骤: 最重要!让用户尽可能提供导致问题的具体操作步骤。
- 期望结果 vs 实际结果: 用户预期是什么,实际发生了什么。
- 截图/录屏: 直观展示问题。
- 自动收集的上下文信息(前端监控系统):
- 设备信息: 浏览器类型和版本、操作系统、屏幕分辨率。
- 网络信息: 网络类型(WiFi/蜂窝)、网络延迟。
- 页面信息: 当前 URL、Referrer。
- 用户 ID/Session ID: 方便追溯用户行为。
- 错误日志: 当前会话中发生的 JS 错误、接口错误。
- 操作路径: 用户在问题发生前的最近几步操作(如果接入了 Session Replay)。
- 应用版本: 准确判断问题存在于哪个版本。
-
反馈管理和分类:
将所有反馈汇聚到统一的平台(如 Jira, ZenDesk, Intercom, Trello, Asana 或自建系统)。- 自动化分类: 根据关键词或预设规则自动标记反馈类型(如:Bug, Feature Request, Usability Issue, General Inquiry)。
- 手动标记: 人工审核并细化分类,包括优先级(高、中、低)、影响范围、所属模块等。
- 去重: 识别并合并重复的反馈。
-
问题复现与排查:
- 复现: 团队成员(通常是 QA 或开发)尝试按照用户提供的步骤复现问题。如果无法复现,需要与用户进一步沟通。
- 定位: 利用收集到的日志、监控数据、Source Map 等工具,快速定位代码层面的问题。
- 归档: 将确认的 Bug 或 Feature Request 转换为内部任务(例如,在 Jira 中创建 Bug 或 Story)。
-
内部流转与处理:
- 指派: 将任务指派给相应的开发人员或产品经理。
- 沟通: 保持团队内部对问题状态的清晰沟通,避免信息孤岛。
- 优先级: 根据问题的严重性、影响用户数、业务价值等因素确定处理优先级。
- 解决方案: 开发人员解决 Bug,产品经理评估功能需求。
-
与用户沟通与反馈:
- 及时响应: 在用户提交反馈后,自动发送确认邮件或消息,告知已收到。
- 定期更新: 如果问题需要时间解决,定期向用户更新进展。
- 问题解决通知: 问题解决或功能上线后,第一时间通知用户,并告知他们在哪个版本中得到了解决。
- 感谢: 感谢用户的反馈,让他们感受到自己的声音被重视。
-
沉淀与优化:
- 知识库: 建立常见问题 (FAQ) 知识库,用户可以自行查找
当然,我们继续。
32. px 如何转为rem
px (pixels) 是一个绝对单位,它表示屏幕上的一个物理像素点。rem (root em) 是一个相对单位,它相对于 HTML 根元素 (<html>) 的字体大小。
转换原理:
要将 px 值转换为 rem 值,你需要知道根元素 (<html>) 的基本字体大小。
公式:
rem 值 = px 值 / 根元素的字体大小 (px)
实现方式:
-
设置根元素的字体大小:
通常,在 CSS 中设置html元素的font-size。为了方便计算,通常将其设置为一个容易整除的值,例如10px或16px。更常见的做法是根据视口宽度动态设置html的font-size,以实现响应式布局。1/* 方式一:固定根字体大小,方便计算 */ 2html { 3 font-size: 16px; /* 默认浏览器字体大小 */ 4} 5/* 如果想让 1rem = 10px,方便心算,则设置为 62.5% */ 6html { 7 font-size: 62.5%; /* 相当于 16px * 0.625 = 10px */ 8}方式二:根据视口宽度动态设置 (用于移动端适配)
这通常结合 JavaScript 或 CSSvw单位实现。-
JavaScript 动态计算:
1// 设置一个基准宽度,例如设计稿是 750px 2const baseWidth = 750; 3// 设置一个基准 font-size,例如在 750px 宽度下,1rem = 100px 4const baseFontSize = 100; 5 6function setRemFontSize() { 7 const clientWidth = document.documentElement.clientWidth || document.body.clientWidth; 8 const newFontSize = (clientWidth / baseWidth) * baseFontSize; 9 document.documentElement.style.fontSize = newFontSize + 'px'; 10} 11 12// 页面加载时执行 13setRemFontSize(); 14// 窗口大小改变时重新计算 (防抖优化) 15window.addEventListener('resize', debounce(setRemFontSize, 50)); 16 17function debounce(func, delay) { 18 let timeout; 19 return function() { 20 const context = this; 21 const args = arguments; 22 clearTimeout(timeout); 23 timeout = setTimeout(() => { 24 func.apply(context, args); 25 }, delay); 26 }; 27}在这种情况下,如果设计稿是 750px 宽,你将 1rem 设为 100px。那么设计稿上 20px 的元素,在代码中就是
0.2rem。
例如:px = 20,基准fontSize = 100px。rem = 20 / 100 = 0.2rem。 -
CSS
vw结合: (更推荐的纯 CSS 方案)1/* 假设设计稿宽度为 750px */ 2/* 1rem = 100px,则在 750px 宽度下,100px 对应 (100 / 750) * 100vw = 13.33333vw */ 3/* 所以 1rem = 13.33333vw */ 4html { 5 font-size: calc(100vw / 7.5); /* 100vw / (750 / 100) = 100vw / 7.5 */ 6} 7/* 如果设计稿是 375px,1rem = 10px */ 8html { 9 font-size: calc(100vw / 37.5); /* 100vw / (375 / 10) = 100vw / 37.5 */ 10}这样设置后,在 CSS 中直接将设计稿的
px值除以你设定的rem基准值(如 100或10),即可得到rem值。
-
-
转换工具/预处理器:
在实际开发中,通常不会手动计算每个px值。CSS 预处理器(如 Sass, Less)或 PostCSS 插件(如postcss-pxtorem)可以自动化这个转换过程。使用
postcss-pxtorem插件示例:在
postcss.config.js或vue.config.js中配置:1// postcss.config.js 2module.exports = { 3 plugins: { 4 'postcss-pxtorem': { 5 rootValue: 16, // 或根据你的设计稿基准字体大小设置,如移动端设置 37.5 或 75 6 propList: ['*'], // 转换所有 CSS 属性 7 selectorBlackList: [], // 不进行 px2rem 转换的选择器 8 exclude: /node_modules/i, // 排除文件 9 unitPrecision: 5, // 转换后 `rem` 值保留的小数位数 10 // mediaQuery: false, // 允许在媒体查询中转换 px 11 // minPixelValue: 0 // 任何小于或等于 `1px` 的值都不会被转换为 `rem` 12 }, 13 }, 14};配置后,你仍然在 CSS/SCSS/Less 文件中编写
px,构建工具会自动将其转换为rem。
总结:
px 转 rem 的核心在于确定 html 元素的 font-size 作为基准。
- 对于需要固定大小的 PC 端,可以手动设置
html { font-size: 16px; },然后手动计算或使用工具。 - 对于移动端响应式适配,通常会将
html的font-size动态设置为与视口宽度相关的vw值,然后借助工具(如postcss-pxtorem)将设计稿中的px值自动转换为rem。
33. 浏览器有同源策略,但是为何cdn请求资源的时候不会有跨域限制
浏览器确实有同源策略(Same-Origin Policy),它限制了来自一个源的文档或脚本如何与来自另一个源的资源进行交互。这里的“源”由协议 (protocol)、域名 (domain) 和端口 (port) 三部分组成。如果这三者中任何一个不同,就被认为是跨域。
CDN (Content Delivery Network) 请求资源通常不会受到跨域限制,是因为它们采取了以下几种机制来规避或符合同源策略:
-
标签加载不受同源策略限制 (Same-Origin Policy does not apply to resource loading):
同源策略主要限制的是 JavaScript 发起的跨域读写操作(如XMLHttpRequest或Fetch)。但是,浏览器允许通过特定的 HTML 标签加载跨域资源,这些标签包括:<script src="...">:加载 JavaScript 文件。<link rel="stylesheet" href="...">:加载 CSS 文件。<img src="...">:加载图片。<video src="...">,<audio src="...">:加载媒体文件。<iframe src="...">:嵌入其他页面(但其内部的 JS 仍然受同源策略限制)。<object data="...">,<embed src="...">:嵌入 Flash 等插件内容。
CDN 的核心作用就是提供静态资源(JS, CSS, 图片, 视频等)的快速分发。这些资源通过上述 HTML 标签加载时,浏览器只是下载并使用它们,而不涉及 JavaScript 对其内容的读取或操作,因此不会触发同源策略的限制。
-
CORS (Cross-Origin Resource Sharing) 策略:
虽然标签加载资源不受同源策略限制,但如果你的前端 JavaScript 代码需要读取来自 CDN 的跨域资源内容(例如,通过fetch或XMLHttpRequest请求加载一个 JSON 数据、一个跨域的图片像素信息等),或者需要发送特定的请求头(如Authorization),那么就需要 CDN 服务器支持 CORS。-
CDN 服务器配置: 现代的 CDN 服务(如 Cloudflare, Akamai, 阿里云 CDN, 腾讯云 CDN)都允许用户在配置中开启 CORS,即在响应头中添加
Access-Control-Allow-Origin。Access-Control-Allow-Origin: *:允许所有源的请求访问该资源。Access-Control-Allow-Origin: https://yourdomain.com:只允许特定源的请求访问。
-
预检请求 (Preflight Request): 对于非简单请求(如带自定义头、使用
PUT/DELETE方法、Content-Type为application/json等),浏览器会先发送一个OPTIONS预检请求,询问服务器是否允许跨域操作。如果服务器返回允许的响应头,才会发送实际的请求。
-
总结:
CDN 请求资源通常不会受到同源策略的直接限制,是因为:
- HTML 标签加载静态资源(JS/CSS/图片等)时,浏览器并不执行同源策略的限制。 它只是下载和渲染这些资源。这是 CDN 的主要应用场景。
- 对于需要通过 JavaScript 读取或操作的跨域资源,CDN 服务端会配置 CORS 响应头,明确告知浏览器允许跨域访问。
因此,CDN 能够有效地解决前端资源的跨域问题,并实现全球范围内的快速分发。
34. cookie可以实现不同域共享吗
Cookie 在设计上是严格遵循同源策略的,这意味着一个域设置的 Cookie 默认情况下不能被另一个域读取或访问。
Cookie 的同源性体现在:
- Domain 属性:
Cookie只能被设置它的域或其子域访问。例如,a.example.com设置的Cookie可以被a.example.com和b.a.example.com访问,但不能被example.com或another.com访问。 - Path 属性:
Cookie仅在其定义的路径或其子路径下可见。 - Secure 和 HttpOnly 属性: 进一步增强安全性。
那么,Cookie 能否实现不同域共享(或“跨域传输”)呢?
答案是:不能直接共享或读取,但可以通过一些间接的方式实现跨域的身份验证或数据传递**,这通常不是“共享”,而是“传递”。
以下是一些常见的“实现不同域共享”的场景和解决方案:
-
子域共享
Cookie(Same Top-Level Domain):
这是最常见的“共享”场景。如果你的应用在不同的子域上(如www.example.com,user.example.com,api.example.com),可以通过设置Cookie的Domain属性为顶级域名来实现在这些子域之间共享。1// 在 www.example.com 或 user.example.com 下设置 2document.cookie = "name=value; domain=.example.com; path=/";这样设置后,
name=value这个Cookie就可以被www.example.com,user.example.com,api.example.com等所有example.com的子域访问到。 -
通过后端重定向/认证中心 (SSO - Single Sign-On):
这是实现真正意义上的跨域用户身份共享的常见模式,例如单点登录。- 原理: 用户在一个域 A (
app.com) 登录成功后,后端会生成一个令牌。然后,后端将用户重定向到另一个域 B (forum.com),并在重定向的 URL 中带上这个令牌(通常作为查询参数)。域 B 的后端接收到令牌后,会验证其有效性,并在域 B 的域名下设置一个Cookie。 - 授权码流 (Authorization Code Flow) 是 OAuth 2.0 中的一种常见模式,也常用于此。
- CORS + Token: 虽然不是直接共享 Cookie,但在多域场景下,后端接口可能需要验证用户的身份。前端在跨域请求时,将用户令牌(通常存储在
localStorage或sessionStorage,而不是Cookie)通过Authorization头发送给后端,后端验证后返回数据。
- 原理: 用户在一个域 A (
-
使用
postMessage传递数据 (Limited Scope):
window.postMessage允许不同源的窗口(包括iframe)之间安全地进行通信。你可以通过这种方式传递一些数据,但这不是Cookie的共享。- 原理: 一个域 A (
domainA.com) 中的页面,内嵌一个域 B (domainB.com) 的iframe。域 A 的页面可以将数据通过postMessage发送给域 B 的iframe,域 B 的iframe接收到数据后,可以在自己的域下设置Cookie。反之亦然。 - 限制: 只能在你知道目标窗口或
iframe的引用时使用,且需要双方都编写相应的发送和接收逻辑。
- 原理: 一个域 A (
-
同构/通用登录页面 (Less Common):
如果你的多个应用是完全不同的顶级域名,但共享相同的用户数据库,你可以在一个集中的登录页面(例如login.example.com)处理所有认证逻辑。登录成功后,再重定向回原业务域,并在重定向 URL 中带上令牌,让业务域在自己的域名下设置Cookie。
总结:
- 直接共享或读取不同顶级域名下的
Cookie是不可能的,因为浏览器同源策略的严格限制。 - 但可以通过设置
Domain属性实现在顶级域名相同但子域不同的场景下共享Cookie。 - 对于完全不同域的场景,通常是通过后端认证中心进行重定向和令牌传递来间接实现用户身份的跨域传递,或者通过
postMessage进行有限的数据通信,而不是直接共享Cookie。
35. axios是否可以取消请求
是的,Axios 可以取消请求。
在 Axios 的不同版本中,取消请求的方式有所演变:
-
Axios v0.22.0+ (推荐使用
AbortControllerAPI):
Axios 在 0.22.0 版本之后,支持了浏览器原生的AbortControllerAPI 来取消请求。这是目前推荐的方式,因为它与原生fetchAPI 的取消机制保持一致。使用方法:
1import axios from 'axios'; 2 3// 1. 创建 AbortController 实例 4const controller = new AbortController(); 5const signal = controller.signal; // 获取信号对象 6 7// 2. 发起请求时,将 signal 作为 config 的一部分传递 8axios.get('/user/12345', { signal: signal }) 9 .then(function (response) { 10 console.log('请求成功:', response.data); 11 }) 12 .catch(function (error) { 13 // 判断是否是取消错误 14 if (axios.isCancel(error)) { 15 console.log('请求被取消:', error.message); 16 } else { 17 console.error('请求失败:', error.message); 18 } 19 }); 20 21// 3. 在需要取消请求时,调用 controller.abort() 22// 例如,在组件卸载时,或者用户快速重复点击时取消前一个请求 23setTimeout(() => { 24 controller.abort('用户手动取消了请求'); // 可以传入一个字符串作为取消原因 25}, 100); // 100ms 后取消优点:
- 符合 Web 标准,与原生
fetchAPI 保持一致。 - 更现代,更简洁。
- 一个
AbortController可以取消多个请求。
- 符合 Web 标准,与原生
-
Axios v0.21.0 及之前版本 (使用
CancelTokenAPI):
在AbortController被广泛支持之前,Axios 有自己的CancelTokenAPI 来处理取消请求。使用方法:
1import axios from 'axios'; 2 3// 1. 创建一个 CancelToken.source 对象 4const CancelToken = axios.CancelToken; 5const source = CancelToken.source(); 6 7// 2. 发起请求时,将 cancelToken 作为 config 的一部分传递 8axios.get('/user/12345', { 9 cancelToken: source.token 10}).then(function (response) { 11 console.log('请求成功:', response.data); 12}).catch(function (error) { 13 // 判断是否是取消错误 14 if (axios.isCancel(error)) { 15 console.log('请求被取消:', error.message); 16 } else { 17 console.error('请求失败:', error.message); 18 } 19}); 20 21// 3. 在需要取消请求时,调用 source.cancel() 22// 例如,在组件卸载时,或者用户快速重复点击时取消前一个请求 23setTimeout(() => { 24 source.cancel('用户手动取消了请求'); // 可以传入一个字符串作为取消原因 25}, 100); // 100ms 后取消优点:
- 兼容性好,适用于旧版本的 Axios 和不支持
AbortController的环境。
缺点: - 是 Axios 自定义的 API,不如
AbortController标准化。
- 兼容性好,适用于旧版本的 Axios 和不支持
何时需要取消请求?
- 用户导航: 当用户在页面请求完成前离开当前页面(例如,点击链接跳转、刷新),可以取消未完成的请求,避免不必要的网络开销和资源浪费。
- 搜索建议/自动补全: 当用户快速输入时,每次输入都可能触发请求。可以取消旧的(过时的)请求,只保留最新的请求。
- 组件卸载: 当一个组件被销毁时,如果它内部有未完成的请求,应取消这些请求,以避免在组件不存在时对状态进行操作,从而引发内存泄漏或报错。
- 重复点击: 防止用户短时间内多次点击同一个按钮,重复发送相同的请求。
总结:
Axios 提供了强大的请求取消机制。对于新项目和现代浏览器环境,强烈推荐使用 AbortController。对于需要兼容旧环境的情况,可以使用 CancelToken。
36. 前端如何实现折叠面板效果?
前端实现折叠面板(Accordion)效果,通常指的是点击标题或触发区域时,其下方的内容区域展开或收起。这主要通过 CSS 和 JavaScript 结合来实现。
核心原理:
- 初始状态: 内容区域默认隐藏。
- 触发事件: 监听标题(或触发区域)的点击事件。
- 状态切换: 通过 JavaScript 改变内容区域的样式(如
max-height、height、display、opacity、overflow)来实现展开和收起动画。
实现方式:
1. 使用 max-height + overflow: hidden + transition (推荐,动画平滑)
这是最常用且效果最好的方式,可以实现平滑的展开/收起动画。
- 思路:
- 内容区域默认设置
max-height: 0和overflow: hidden来隐藏。 - 点击时,动态设置
max-height为内容实际的高度 (scrollHeight),使其展开。 - 配合
transition属性,让高度变化有动画效果。
- 内容区域默认设置
- 优点: 动画平滑,效果好。
- 缺点: 需要知道内容的实际高度(或设置一个足够大的
max-height值),如果内容高度不确定,max-height设置不当可能导致动画不完整或有空白。
HTML 结构:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Accordion Effect</title> 7 <style> 8 body { 9 font-family: sans-serif; 10 padding: 20px; 11 background-color: #f0f0f0; 12 } 13 14 .accordion-item { 15 margin-bottom: 10px; 16 border: 1px solid #ddd; 17 border-radius: 5px; 18 background-color: #fff; 19 overflow: hidden; /* 确保内容超出 max-height 时被隐藏 */ 20 } 21 22 .accordion-header { 23 background-color: #007bff; 24 color: white; 25 padding: 15px; 26 cursor: pointer; 27 display: flex; 28 justify-content: space-between; 29 align-items: center; 30 font-weight: bold; 31 } 32 33 .accordion-header:hover { 34 background-color: #0056b3; 35 } 36 37 .accordion-icon { 38 transition: transform 0.3s ease; /* 旋转动画 */ 39 } 40 41 .accordion-content { 42 max-height: 0; /* 默认隐藏 */ 43 overflow: hidden; 44 transition: max-height 0.3s ease-out; /* 动画过渡 */ 45 background-color: #f9f9f9; 46 } 47 48 .accordion-content-inner { 49 padding: 15px; /* 内部 padding,避免 max-height 包含 padding */ 50 } 51 52 /* 展开状态的样式 */ 53 .accordion-item.active .accordion-icon { 54 transform: rotate(90deg); /* 展开时旋转图标 */ 55 } 56 57 /* JavaScript 会动态设置 max-height */ 58 /* .accordion-item.active .accordion-content { 59 max-height: 500px; // 示例:一个足够大的值,或者通过 JS 计算实际高度 60 } */ 61 </style> 62</head> 63<body> 64 <h1>折叠面板示例</h1> 65 66 <div class="accordion-item" id="item1"> 67 <div class="accordion-header"> 68 <span>面板标题 1</span> 69 <span class="accordion-icon">▶</span> 70 </div> 71 <div class="accordion-content"> 72 <div class="accordion-content-inner"> 73 <p>这是面板 1 的内容。内容可以很长,当面板展开时,它会完全显示出来。</p> 74 <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p> 75 </div> 76 </div> 77 </div> 78 79 <div class="accordion-item" id="item2"> 80 <div class="accordion-header"> 81 <span>面板标题 2</span> 82 <span class="accordion-icon">▶</span> 83 </div> 84 <div class="accordion-content"> 85 <div class="accordion-content-inner"> 86 <p>这是面板 2 的内容,相对较短。</p> 87 </div> 88 </div> 89 </div> 90 91 <div class="accordion-item" id="item3"> 92 <div class="accordion-header"> 93 <span>面板标题 3</span> 94 <span class="accordion-icon">▶</span> 95 </div> 96 <div class="accordion-content"> 97 <div class="accordion-content-inner"> 98 <p>这是面板 3 的内容,包含更多信息和列表:</p> 99 <ul> 100 <li>子项 A</li> 101 <li>子项 B</li> 102 <li>子项 C</li> 103 </ul> 104 <p>你可以点击不同的标题来展开和收起它们。</p> 105 </div> 106 </div> 107 </div> 108 109 <script> 110 document.querySelectorAll('.accordion-header').forEach(header => { 111 header.addEventListener('click', () => { 112 const item = header.closest('.accordion-item'); 113 const content = item.querySelector('.accordion-content'); 114 const contentInner = item.querySelector('.accordion-content-inner'); 115 116 // 切换 active 类 117 item.classList.toggle('active'); 118 119 if (item.classList.contains('active')) { 120 // 展开时:获取内容的实际滚动高度并设置 max-height 121 content.style.maxHeight = contentInner.scrollHeight + 'px'; 122 } else { 123 // 收起时:先设置当前高度(防止收起时突然跳动),再变回 0 124 content.style.maxHeight = content.scrollHeight + 'px'; // 立即设置为当前高度 125 // 使用 requestAnimationFrame 确保这个样式先应用,然后下一帧再设置为0 126 requestAnimationFrame(() => { 127 content.style.maxHeight = '0'; 128 }); 129 } 130 }); 131 }); 132 </script> 133</body> 134</html>
2. 使用 display: none/block (最简单,无动画)
- 思路: 默认内容区域
display: none。点击时切换为display: block。 - 优点: 实现简单,兼容性好。
- 缺点: 没有过渡动画,展开/收起效果生硬。
1/* ... (header 样式同上) */ 2 3.accordion-content-display { 4 display: none; /* 默认隐藏 */ 5 background-color: #f9f9f9; 6 padding: 15px; 7} 8 9.accordion-item.active .accordion-content-display { 10 display: block; /* 展开时显示 */ 11}
1// JavaScript: 2// ... 3// 切换 active 类 4item.classList.toggle('active'); 5// 这种方式不需要处理 maxHeight
3. 使用 height: 0/auto (不推荐直接使用 auto 进行过渡)
- 思路: 类似
max-height,但直接使用height。 - 问题: CSS
transition属性不支持从height: 0过渡到height: auto,因为auto是一个不确定的值。这会导致动画效果不佳。 - 解决方案: 必须在 JS 中动态获取
scrollHeight,然后将height设置为这个具体值,才能实现过渡。
4. 使用 opacity + visibility (平滑淡入淡出,但不节省空间)
- 思路: 内容区域默认
opacity: 0; visibility: hidden;。点击时切换为opacity: 1; visibility: visible;。 - 优点: 可以实现平滑的淡入淡出效果。
- 缺点:
visibility: hidden的元素仍然占据空间,不能实现面板的“折叠”效果(即下方内容会上移)。
总结:
- 首选方案:
max-height+overflow: hidden+transition。它能提供最佳的用户体验和平滑的动画效果,尽管在 JavaScript 中需要动态计算scrollHeight。 - 最简单但不带动画:
display: none/block。适用于对动画要求不高的场景。 - 框架/库: 在实际项目中,通常会使用 UI 框架(如 Bootstrap, Element UI, Ant Design, Material-UI, Vant 等)提供的折叠面板组件,它们已经封装好了这些复杂的逻辑和动画。
37. dom里面,如何判定a元素是否是b元素的子元素
在 DOM 中,判断一个元素 A 是否是另一个元素 B 的子元素(直接子元素或后代子元素),有多种方法。
假设 a 是待检查的子元素,b 是潜在的父元素。
-
Node.prototype.contains(otherNode)(推荐)
这是最直接和推荐的方法。它返回一个布尔值,表示otherNode是否是Node的后代(包括Node本身)。1const parentElement = document.getElementById('parent'); 2const childElement = document.getElementById('child'); 3const anotherElement = document.getElementById('another'); 4 5// HTML 结构示例: 6// <div id="parent"> 7// <p>Some text</p> 8// <div id="child"> 9// <span>Grandchild</span> 10// </div> 11// </div> 12// <div id="another"></div> 13 14if (parentElement.contains(childElement)) { 15 console.log('childElement 是 parentElement 的子元素 (包括后代)。'); // 会输出 16} 17 18if (childElement.contains(parentElement)) { 19 console.log('parentElement 是 childElement 的子元素。'); // 不会输出 20} 21 22if (parentElement.contains(parentElement)) { 23 console.log('parentElement 包含它自己。'); // 会输出 (contains 包含自身) 24} 25 26if (parentElement.contains(anotherElement)) { 27 console.log('anotherElement 是 parentElement 的子元素。'); // 不会输出 28} -
Node.prototype.compareDocumentPosition(otherNode):
这个方法返回一个位掩码 (bitmask),描述了当前节点与另一个节点在文档中的相对位置。通过检查返回值,可以判断父子关系。Node.DOCUMENT_POSITION_CONTAINS (8):otherNode在当前节点内部。Node.DOCUMENT_POSITION_CONTAINED_BY (16): 当前节点在otherNode内部。
1const parentElement = document.getElementById('parent'); 2const childElement = document.getElementById('child'); 3 4if (parentElement.compareDocumentPosition(childElement) & Node.DOCUMENT_POSITION_CONTAINS) { 5 console.log('childElement 是 parentElement 的子元素 (通过 compareDocumentPosition)。'); // 会输出 6}这个方法比
contains()更底层,功能也更强大(可以判断兄弟、前驱、后继等关系),但对于简单的父子判断,contains()更直观。 -
循环
parentNode(传统方法):
通过不断遍历a元素的parentNode属性,直到找到b元素或达到文档根部。1function isDescendant(parent, child) { 2 let node = child.parentNode; 3 while (node !== null) { 4 if (node === parent) { 5 return true; 6 } 7 node = node.parentNode; 8 } 9 return false; 10} 11 12const parentElement = document.getElementById('parent'); 13const childElement = document.getElementById('child'); 14const anotherElement = document.getElementById('another'); 15 16if (isDescendant(parentElement, childElement)) { 17 console.log('childElement 是 parentElement 的子元素 (通过循环 parentNode)。'); // 会输出 18} 19if (isDescendant(parentElement, anotherElement)) { 20 console.log('anotherElement 是 parentElement 的子元素 (通过循环 parentNode)。'); // 不会输出 21}注意: 这种方法性能上不如原生方法,因为它需要手动遍历 DOM 树。通常不推荐在性能敏感的场景下使用。
总结:
- 首选:
Node.prototype.contains(otherNode)。它简洁、语义化清晰、性能好,并且支持判断后代元素。 - 次选:
Node.prototype.compareDocumentPosition(otherNode)。 - 传统方法:循环
parentNode,但性能和代码可读性较差。
38. 判断一个对象是否为空,包含了其原型链上是否有自定义数据或者方法。该如何判定?
判断一个对象是否“为空”,且“空”的定义包含其原型链上是否有自定义数据或方法,这是一个比较严格的定义,因为通常我们判断对象为空是针对其自身的(非继承的)可枚举属性。
如果你的“空”定义是:一个对象既没有自身的可枚举属性,也没有自身的可枚举或不可枚举属性,并且其原型链上除了 Object.prototype 之外,也没有其他自定义的原型属性或方法。
这就需要分层判断:
- 判断自身属性是否为空: 这是常规的判断。
- 判断原型链是否为空(即是否只有
Object.prototype或null): 这是问题的关键。
方法:
1function isEmptyObjectIncludingPrototype(obj) { 2 if (obj === null || typeof obj !== 'object') { 3 // 如果不是对象,我们通常不认为它是“空对象”的讨论范围,这里可以根据需求返回 false 或抛出错误 4 // 例如,isEmpty(null) 或 isEmpty(undefined) 5 return false; 6 } 7 8 // 1. 检查对象自身的可枚举属性 9 if (Object.keys(obj).length > 0) { 10 return false; // 有自身可枚举属性,不为空 11 } 12 13 // 2. 检查对象自身的不可枚举属性 (通过 Object.getOwnPropertyNames 或 Object.getOwnPropertySymbols) 14 // Object.getOwnPropertyNames 获取所有自身属性(包括不可枚举的) 15 // Object.getOwnPropertySymbols 获取所有自身 Symbol 属性 16 const ownPropertyNames = Object.getOwnPropertyNames(obj); 17 const ownPropertySymbols = Object.getOwnPropertySymbols(obj); 18 19 // 排除 'length' 和 'name' 属性,这些可能是函数自带的 20 const filteredOwnNames = ownPropertyNames.filter(name => !['length', 'name'].includes(name)); 21 22 if (filteredOwnNames.length > 0 || ownPropertySymbols.length > 0) { 23 return false; // 有自身不可枚举属性或 Symbol 属性,不为空 24 } 25 26 // 3. 检查原型链 27 let proto = Object.getPrototypeOf(obj); 28 29 // 如果对象是通过 Object.create(null) 创建的,它没有原型,那么它就只有自身属性 30 if (proto === null) { 31 // 如果前面已经检查过自身属性为空,那么这种情况下它就是“空”的 32 return true; 33 } 34 35 // 遍历原型链,检查除了 Object.prototype 之外是否有自定义的原型 36 while (proto !== null) { 37 // 如果原型是 Object.prototype,则认为是“空”的尽头 38 if (proto === Object.prototype) { 39 break; 40 } 41 42 // 检查当前原型对象上是否有自身定义的可枚举或不可枚举属性 43 const protoOwnNames = Object.getOwnPropertyNames(proto); 44 const protoOwnSymbols = Object.getOwnPropertySymbols(proto); 45 46 // 如果原型上有自定义属性或方法,则认为不是“空”的 47 if (protoOwnNames.length > 0 || protoOwnSymbols.length > 0) { 48 return false; 49 } 50 51 proto = Object.getPrototypeOf(proto); 52 } 53 54 // 如果经过所有检查,都没有发现自定义属性或原型,则认为是“空” 55 return true; 56} 57 58// --- 测试用例 --- 59 60console.log('--- 自身属性判断 ---'); 61console.log('{}', isEmptyObjectIncludingPrototype({})); // true 62console.log('{a:1}', isEmptyObjectIncludingPrototype({a:1})); // false 63console.log('Object.create({})', isEmptyObjectIncludingPrototype(Object.create({}))); // false (原型上有属性) 64console.log('Object.create(null)', isEmptyObjectIncludingPrototype(Object.create(null))); // true 65 66console.log('\n--- 包含不可枚举属性 ---'); 67const objWithNonEnumerable = {}; 68Object.defineProperty(objWithNonEnumerable, 'a', { value: 1, enumerable: false }); 69console.log('objWithNonEnumerable', isEmptyObjectIncludingPrototype(objWithNonEnumerable)); // false 70 71console.log('\n--- 包含 Symbol 属性 ---'); 72const objWithSymbol = {}; 73objWithSymbol[Symbol('test')] = 1; 74console.log('objWithSymbol', isEmptyObjectIncludingPrototype(objWithSymbol)); // false 75 76console.log('\n--- 原型链判断 ---'); 77function MyClass() {} 78MyClass.prototype.method1 = function() {}; 79MyClass.prototype.prop1 = 1; 80const instance1 = new MyClass(); 81console.log('new MyClass()', isEmptyObjectIncludingPrototype(instance1)); // false (MyClass.prototype 上有 method1, prop1) 82 83function EmptyClass() {} 84const instance2 = new EmptyClass(); 85console.log('new EmptyClass()', isEmptyObjectIncludingPrototype(instance2)); // true (EmptyClass.prototype 是空对象,只有 Object.prototype 继承) 86 87// 自定义原型链 88const customProto = { foo: 'bar' }; 89const objWithCustomProto = Object.create(customProto); 90console.log('Object.create(customProto)', isEmptyObjectIncludingPrototype(objWithCustomProto)); // false (customProto 上有 foo) 91 92// 函数对象 (它们自身有 length, name 属性,但这里过滤了) 93console.log('\n--- 函数和数组 ---'); 94console.log('()=>{}', isEmptyObjectIncludingPrototype(() => {})); // true (只考虑自定义属性和原型链) 95console.log('[]', isEmptyObjectIncludingPrototype([])); // true (数组是特殊对象,没有自身可枚举属性) 96console.log('[1]', isEmptyObjectIncludingPrototype([1])); // false (有索引属性)
解释:
- 非对象和
null: 首先排除不是对象或为null的情况,因为它们不属于“空对象”的讨论范畴。 - 自身可枚举属性:
Object.keys(obj).length > 0检查最常见的空对象情况。 - 自身不可枚举属性和 Symbol 属性:
Object.getOwnPropertyNames()和Object.getOwnPropertySymbols()用于检查对象自身的所有属性(包括不可枚举和 Symbol 属性)。这里特别过滤了函数自带的length和name属性,因为我们可能希望一个空函数被认为是“空”的。 - 原型链遍历:
Object.getPrototypeOf(obj)获取对象的原型。- 循环向上遍历原型链,直到
proto为null(没有原型) 或Object.prototype(原生对象的顶层原型)。 - 在每次循环中,检查当前原型对象自身是否定义了任何属性 (
Object.getOwnPropertyNames(proto).length > 0 || Object.getOwnPropertySymbols(proto).length > 0)。 - 如果发现任何自定义属性或方法,就认为这个对象“不为空”,因为它继承了这些自定义内容。
这种判断方式非常严格,它会认为任何拥有自定义原型链的对象(即使其自身没有属性)都是“不为空”的。在实际应用中,这种“空”的定义可能过于严格,通常我们只关心对象自身的属性是否为空。请根据你的具体业务需求来调整“空”的定义。
39. is如何判空?[空」包含了:空数组、空对象、空字符串、0、undefined、null、空map、空set,都属于为空的数据
要判断一个值是否为“空”,根据你提供的定义:空数组、空对象、空字符串、0、undefined、null、空 Map、空 Set,这需要一个综合性的判断函数。
我们可以创建一个 isEmpty 函数,它接收任何类型的值,并根据上述定义返回 true 或 false。
1function isEmpty(value) { 2 // 1. 判断 undefined 和 null 3 if (value === undefined || value === null) { 4 return true; 5 } 6 7 // 2. 判断字符串 8 if (typeof value === 'string') { 9 return value === ''; // 空字符串 10 } 11 12 // 3. 判断数字 0 13 if (typeof value === 'number') { 14 return value === 0; // 数字 0 15 } 16 17 // 4. 判断布尔值 (默认不认为 boolean 为空,可以根据需要添加) 18 // if (typeof value === 'boolean') { 19 // return false; // 或者根据需求定义 true/false 为空 20 // } 21 22 // 5. 判断对象 (包括数组、Map、Set 等) 23 if (typeof value === 'object') { 24 // 判断空数组 25 if (Array.isArray(value)) { 26 return value.length === 0; 27 } 28 29 // 判断空 Map 30 if (value instanceof Map) { 31 return value.size === 0; 32 } 33 34 // 判断空 Set 35 if (value instanceof Set) { 36 return value.size === 0; 37 } 38 39 // 判断空对象 (普通对象 {}) 40 // 排除 Date, RegExp, Function 等特殊对象,它们通常不被认为是“空”的 41 // 这里只考虑纯粹的 JS 对象 {} 42 if (Object.prototype.toString.call(value) === '[object Object]') { 43 // 使用 Object.keys() 检查可枚举属性 44 return Object.keys(value).length === 0; 45 // 如果还需要检查不可枚举属性,需要更严格的判断,如上一题所示 46 // return Object.getOwnPropertyNames(value).length === 0 && Object.getOwnPropertySymbols(value).length === 0; 47 } 48 } 49 50 // 其他所有情况(如函数、非空数字、非空字符串、非空数组/对象/Map/Set、自定义类型实例等)均不为空 51 return false; 52} 53 54// --- 测试用例 --- 55console.log('--- 基本类型 ---'); 56console.log('undefined:', isEmpty(undefined)); // true 57console.log('null:', isEmpty(null)); // true 58console.log('"":', isEmpty("")); // true 59console.log('"hello":', isEmpty("hello")); // false 60console.log('0:', isEmpty(0)); // true 61console.log('123:', isEmpty(123)); // false 62console.log('true:', isEmpty(true)); // false (如果不需要判断boolean为空) 63console.log('false:', isEmpty(false)); // false (如果不需要判断boolean为空) 64 65console.log('\n--- 数组 ---'); 66console.log('[]:', isEmpty([])); // true 67console.log('[1, 2]:', isEmpty([1, 2])); // false 68 69console.log('\n--- 对象 ---'); 70console.log('{}:', isEmpty({})); // true 71console.log('{a: 1}:', isEmpty({a: 1})); // false 72console.log('new Date():', isEmpty(new Date())); // false 73console.log('function():', isEmpty(function(){})); // false 74 75console.log('\n--- Map 和 Set ---'); 76console.log('new Map():', isEmpty(new Map())); // true 77console.log('new Map([["a", 1]]):', isEmpty(new Map([["a", 1]]))); // false 78console.log('new Set():', isEmpty(new Set())); // true 79console.log('new Set([1, 2]):', isEmpty(new Set([1, 2]))); // false 80 81console.log('\n--- 其他 ---'); 82console.log('Symbol():', isEmpty(Symbol())); // false 83console.log('BigInt(0):', isEmpty(BigInt(0))); // false 84console.log('Infinity:', isEmpty(Infinity)); // false
解释:
undefined和null: 最先判断,它们是 JavaScript 中的基本“空”值。- 字符串: 检查类型为
string且值为''。 - 数字: 检查类型为
number且值为0。注意,NaN和Infinity不被认为是0。 - 对象:
- 使用
Array.isArray()判断数组,并检查length。 - 使用
instanceof Map和instanceof Set判断Map和Set,并检查size。 - 对于普通对象 (
{}),使用Object.prototype.toString.call(value) === '[object Object]'来区分纯粹的对象字面量和其他内置对象(如Date,RegExp),然后检查Object.keys(value).length。Object.keys()只返回对象自身可枚举的属性。如果你希望更严格地包含不可枚举属性,需要使用Object.getOwnPropertyNames(value).length === 0。
- 使用
这个 isEmpty 函数涵盖了你问题中定义的所有“空”类型。请根据实际业务需求,考虑是否需要对 NaN、false 等值也定义为“空`。
40. css实现翻牌效果
CSS 实现翻牌效果通常指的是一个元素在点击或悬停时,像一张牌一样翻转过来,显示另一面的内容。这主要利用 CSS 的 3D 变换 (transform: rotateY()) 和 transform-style: preserve-3d。
核心原理:
- 两个面: 准备两个元素,分别作为牌的正面和背面。
- 3D 空间: 创建一个父容器,并设置
transform-style: preserve-3d,这样其子元素(牌的两个面)才能在 3D 空间中进行变换。 - 背面隐藏: 初始状态下,背面元素旋转 180 度,使其背面朝向用户,并设置
backface-visibility: hidden隐藏其背面。 - 正面隐藏: 初始状态下,正面元素正常显示,但也要设置
backface-visibility: hidden隐藏其背面。 - 翻转动画: 当触发翻转时,通过改变父容器的
transform: rotateY()属性,使其在 Y 轴上旋转,从而显示另一面。 - 过渡效果: 使用
transition属性,让翻转过程平滑。
HTML 结构:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>CSS Flip Card Effect</title> 7 <style> 8 body { 9 font-family: sans-serif; 10 display: flex; 11 justify-content: center; 12 align-items: center; 13 min-height: 100vh; 14 background-color: #f0f0f0; 15 margin: 0; 16 perspective: 1000px; /* 定义 3D 空间,制造透视效果 */ 17 } 18 19 .flip-card-container { 20 width: 300px; 21 height: 200px; 22 margin: 20px; 23 position: relative; 24 /* 关键:创建 3D 空间,子元素将保留其 3D 位置 */ 25 transform-style: preserve-3d; 26 transition: transform 0.8s; /* 整个卡片的翻转动画 */ 27 } 28 29 /* 鼠标悬停时翻转整个卡片 */ 30 .flip-card-container:hover { 31 transform: rotateY(180deg); 32 } 33 34 /* 也可以通过点击来控制翻转 */ 35 .flip-card-container.flipped { 36 transform: rotateY(180deg); 37 } 38 39 .flip-card-front, .flip-card-back { 40 position: absolute; 41 width: 100%; 42 height: 100%; 43 border-radius: 10px; 44 box-shadow: 0 8px 16px rgba(0,0,0,0.2); 45 display: flex; 46 justify-content: center; 47 align-items: center; 48 font-size: 24px; 49 font-weight: bold; 50 color: white; 51 /* 关键:当元素背面朝向用户时隐藏 */ 52 backface-visibility: hidden; 53 } 54 55 .flip-card-front { 56 background-color: #007bff; 57 z-index: 2; /* 确保正面在背面之上 */ 58 } 59 60 .flip-card-back { 61 background-color: #28a745; 62 /* 初始状态:背面旋转 180 度,使其背面朝向用户 */ 63 transform: rotateY(180deg); 64 z-index: 1; 65 } 66 67 .toggle-button { 68 position: absolute; 69 bottom: -50px; 70 left: 50%; 71 transform: translateX(-50%); 72 padding: 10px 20px; 73 font-size: 16px; 74 cursor: pointer; 75 border: none; 76 background-color: #6c757d; 77 color: white; 78 border-radius: 5px; 79 } 80 </style> 81</head> 82<body> 83 84 <!-- 鼠标悬停翻转示例 --> 85 <div class="flip-card-container"> 86 <div class="flip-card-front">正面</div> 87 <div class="flip-card-back">背面</div> 88 </div> 89 90 <!-- 点击翻转示例 --> 91 <div class="flip-card-container" id="clickableCard"> 92 <div class="flip-card-front">点击我翻转</div> 93 <div class="flip-card-back">点击我翻转回去</div> 94 <button class="toggle-button">点击翻转</button> 95 </div> 96 97 <script> 98 const clickableCard = document.getElementById('clickableCard'); 99 const toggleButton = clickableCard.querySelector('.toggle-button'); 100 101 toggleButton.addEventListener('click', () => { 102 clickableCard.classList.toggle('flipped'); 103 }); 104 105 // 为了避免点击按钮时也触发卡片容器的点击事件,可以阻止冒泡 106 toggleButton.addEventListener('mousedown', (e) => e.stopPropagation()); 107 </script> 108</body> 109</html>
关键 CSS 属性解释:
perspective: 1000px;(在父容器body或.scene上):- 定义了用户与 3D 平面之间的距离。值越小,透视效果越强,卡片看起来越扭曲。
- 这个属性通常设置在翻转容器的父元素上,而不是翻转容器本身,这样所有子元素(如果它们也需要 3D 效果)可以共享同一个透视点。
transform-style: preserve-3d;(在.flip-card-container上):- 这是实现 3D 效果的关键。它指定了子元素是否将在 3D 空间中呈现。如果没有这个属性,子元素会被扁平化到父元素的 2D 平面上,3D 变换将不起作用。
backface-visibility: hidden;(在.flip-card-front和.flip-card-back上):- 指定当元素背面朝向用户时是否可见。设置为
hidden会在其背面朝向用户时隐藏该元素,这是实现“翻转”错觉的关键。
- 指定当元素背面朝向用户时是否可见。设置为
transform: rotateY(180deg);(在.flip-card-back初始状态和.flip-card-container翻转状态):- 在 Y 轴上旋转元素。
- 背面元素初始旋转 180 度,使其背面朝向用户。
- 当父容器 (
.flip-card-container) 旋转 180 度时,背面元素会旋转 360 度(回到正面),而正面元素会旋转 180 度(背面朝向用户并被隐藏)。
transition: transform 0.8s;(在.flip-card-container上):- 定义
transform属性变化的过渡动画时间。
- 定义
通过这些属性的组合,可以实现平滑且富有立体感的翻牌效果。
41. flex:1代表什么
在 CSS Flexbox 布局中,flex: 1 是一个简写属性(shorthand property),它实际上是以下三个 Flex 属性的组合:
flex-grow: 1flex-shrink: 1flex-basis: 0%或flex-basis: 0(根据上下文可能有所不同,但大多数情况下等同于0%)
让我们逐一解释这三个属性的含义:
1. flex-grow: 1 (伸展因子 / 扩展能力)
- 含义: 当 Flex 容器有剩余空间时,
flex-grow决定了 Flex 项目如何分配这些剩余空间。 1的意义: 如果所有 Flex 项目的flex-grow值都为1,它们将平均分配剩余空间。如果一个项目的flex-grow为2,而其他为1,那么这个项目将获得两倍于其他项目的剩余空间。- 默认值:
0(不伸展,即使有剩余空间也不占用)。
2. flex-shrink: 1 (收缩因子 / 压缩能力)
- 含义: 当 Flex 容器空间不足,Flex 项目需要收缩时,
flex-shrink决定了 Flex 项目如何收缩。 1的意义: 如果所有 Flex 项目的flex-shrink值都为1,它们将等比例收缩。如果一个项目的flex-shrink为2,而其他为1,那么这个项目将比其他项目收缩得更快(缩小两倍)。- 默认值:
1(允许收缩)。
3. flex-basis: 0% 或 flex-basis: 0 (基本尺寸)
- 含义: 定义了 Flex 项目在分配剩余空间之前或之后所占用的基本尺寸。它可以是长度值(如
200px)、百分比(如50%)或关键字(如auto)。 0%的意义: 当flex-basis为0%时,意味着 Flex 项目的初始大小为0。在计算 Flex 容器的剩余空间时,不会考虑该项目内容的原始大小,而是将其视为一个零宽度的点。这样,所有的空间都将被视为“剩余空间”,并完全由flex-grow来分配。- 如果
flex-basis: auto;(这是flex: 0 1 auto;的默认值),项目会首先根据其内容大小(或width/height)占据空间,剩余空间再由flex-grow分配。
- 如果
flex: 1 组合起来的意义:
flex: 1 通常意味着:
flex-grow: 1: 这个项目会尽可能地扩展,占用所有可用的剩余空间(如果存在多个flex: 1的项目,它们会平分剩余空间)。flex-shrink: 1: 这个项目也允许收缩,以适应容器空间不足的情况。flex-basis: 0%: 这个项目在计算剩余空间时,其“基本大小”被视为 0。这意味着它的最终尺寸完全取决于flex-grow对剩余空间的分配,而不考虑其内容的原始大小。
实际效果:
在 Flex 容器中,如果所有子元素都设置了 flex: 1,那么它们将平均分配父容器的所有可用空间。每个项目都会占据等宽(或等高,取决于 flex-direction)的空间,并且会根据容器大小进行伸缩。
示例:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Flex: 1 Demo</title> 7 <style> 8 body { margin: 0; font-family: sans-serif; } 9 .flex-container { 10 display: flex; 11 width: 80%; /* 容器宽度 */ 12 height: 100px; 13 margin: 50px auto; 14 border: 2px solid blue; 15 background-color: #f0f0f0; 16 justify-content: center; /* 居中测试 */ 17 } 18 19 .flex-item { 20 padding: 10px; 21 border: 1px solid #ccc; 22 background-color: lightgreen; 23 text-align: center; 24 box-sizing: border-box; /* 边框和内边距不增加元素总宽度 */ 25 } 26 27 .item1 { 28 background-color: lightcoral; 29 flex: 1; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0% */ 30 } 31 32 .item2 { 33 background-color: lightblue; 34 flex: 1; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0% */ 35 } 36 37 .item3 { 38 background-color: lightsalmon; 39 flex: 1; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0% */ 40 } 41 42 .item-specific { 43 background-color: lightgoldenrodyellow; 44 flex: 0 0 150px; /* 不伸展、不收缩,固定宽度 150px */ 45 } 46 47 .item-grow-2 { 48 background-color: lightpink; 49 flex: 2; /* 占据双倍剩余空间 */ 50 } 51 </style> 52</head> 53<body> 54 55 <h2>所有项目 `flex: 1` (平均分配空间)</h2> 56 <div class="flex-container"> 57 <div class="flex-item item1">Item 1</div> 58 <div class="flex-item item2">Item 2</div> 59 <div class="flex-item item3">Item 3</div> 60 </div> 61 62 <h2>混合 `flex: 1` 和固定宽度</h2> 63 <div class="flex-container"> 64 <div class="flex-item item1">Item 1</div> 65 <div class="flex-item item-specific">固定 150px</div> 66 <div class="flex-item item2">Item 2</div> 67 </div> 68 69 <h2>不同 `flex-grow` 值</h2> 70 <div class="flex-container"> 71 <div class="flex-item item1">Item 1 (flex: 1)</div> 72 <div class="flex-item item-grow-2">Item 2 (flex: 2)</div> 73 <div class="flex-item item3">Item 3 (flex: 1)</div> 74 </div> 75 76 <p style="text-align: center;">尝试调整浏览器窗口宽度,观察 Flex 项目的变化。</p> 77</body> 78</html>
在上面的示例中,flex: 1 的项目会平均占用剩余空间。如果容器宽度改变,它们也会等比例伸缩。这使得 flex: 1 成为实现流式布局和自适应组件的非常强大的工具。
42. 一般是怎么做代码重构的
代码重构(Refactoring)是在不改变代码外部行为的前提下,改进其内部结构和可读性的过程。其目的是提高代码质量、降低维护成本、增强可扩展性,并为未来新功能的开发打下良好基础。
以下是一般做代码重构的步骤和常用方法:
重构的原则和时机
- “红灯-绿灯-重构” (Red-Green-Refactor) 循环: 这是测试驱动开发 (TDD) 的核心思想,也是重构的最佳实践。
- 红灯 (Red): 编写一个失败的测试(针对新功能或现有 Bug)。
- 绿灯 (Green): 编写最少的代码,让测试通过。
- 重构 (Refactor): 在测试通过的前提下,改进代码结构,不引入新的功能,不破坏现有功能。
- 小步快跑: 每次重构只做小而安全的改动,然后运行测试,确保没有引入新的 Bug。
- 有自动化测试: 自动化测试是重构的基石。没有测试覆盖,重构就是一场“盲人摸象”,风险极高。
- 明确目标: 为什么重构?是为了提高性能、可读性、可维护性、降低耦合?明确目标有助于指导重构方向。
- 不改变外部行为: 重构的黄金法则。用户不应该察觉到任何功能上的变化。
重构的步骤
-
识别重构区域:
- 代码坏味道 (Code Smells): 重复代码、过长的函数、过大的类、参数列表过长、发散式变化(一个改动影响多个地方)、霰弹式修改(一个改动需要修改多个地方)、条件逻辑过于复杂(大量 if-else/switch)。
- Bug 密集区: 经常出现 Bug 的地方往往是代码质量差的区域。
- 难以理解/修改的模块: 团队成员普遍抱怨难以理解或修改的代码。
- 性能瓶颈: 如果重构目标包含性能优化,找出性能瓶颈所在。
-
准备(确保安全):
- 备份代码: 提交当前代码,确保有一个干净的基线。
- 运行所有现有测试: 确保当前代码是正常的,所有测试都通过。
- 如果缺少测试,先添加测试: 为要重构的代码编写单元测试和集成测试,以确保在重构过程中不会破坏现有功能。
-
执行重构(小步快跑):
- 选择一个小的、独立的“坏味道”或改进点。
- 进行小范围的改动。
- 立即运行测试: 确保每次改动后,所有测试仍然通过。如果失败,立即撤销或修复。
- 重复此过程。
-
清理与提交:
- 一旦完成一个逻辑单元的重构,并所有测试通过,提交代码。提交信息要清晰说明重构了什么。
- 清理临时的辅助代码或注释。
常用重构手法 (Martin Fowler 的《重构》一书中有详细分类)
-
函数/方法级别:
- 提取函数 (Extract Method): 将一个大函数中独立的逻辑块提取成一个新函数。
- 内联函数 (Inline Method): 如果一个函数过于简单,直接将其内容合并到调用方。
- 替换魔法数/字符串为常量 (Replace Magic Number/String with Constant): 提高可读性,方便修改。
- 拆分条件表达式 (Decompose Conditional): 将复杂的
if-else或switch拆分成多个小函数。 - 引入解释性变量 (Introduce Explaining Variable): 为复杂的表达式引入临时变量,提高可读性。
- 参数化方法 (Parameterize Method): 如果多个方法逻辑相似但参数不同,合并为一个带参数的方法。
-
类/模块级别:
- 提取类 (Extract Class): 将一个职责过多的类拆分为多个职责单一的类。
- 内联类 (Inline Class): 如果一个类职责过少,直接将其功能合并到另一个类中。
- 移动方法/字段 (Move Method/Field): 将不属于当前类的方法或字段移动到更合适的类中。
- 引入工厂方法 (Introduce Factory Method): 统一对象的创建过程。
- 用多态取代条件表达式 (Replace Conditional with Polymorphism): 使用策略模式或多态来消除大量的
if-else。 - 移除重复代码 (Remove Duplication): 抽象出公共部分。
-
数据组织:
- 将字段封装 (Encapsulate Field): 使用 Getter/Setter 访问属性。
- 用对象取代数据值 (Replace Data Value with Object): 将一组相关数据封装成一个对象。
-
代码风格/可读性:
- 统一命名约定。
- 增加注释(解释“为什么”而不是“是什么”)。
- 遵循 ESLint/Prettier 等工具的规范。
重构的挑战和注意事项
- 没有测试覆盖: 这是最大的风险。如果不能加测试,重构要极其小心,甚至考虑不重构。
- 时间压力: 业务需求往往是第一位的,重构需要争取时间,并向团队解释其长期价值。
- “重构狂热症”: 不要为了重构而重构,应有明确的目的。
- 过度设计: 避免在重构时引入过多的设计模式,保持 KISS (Keep It Simple, Stupid) 原则。
- 与新功能开发并行: 尽量避免在重构过程中同时开发新功能,这会增加风险和复杂度。
- 版本控制: 使用 Git 的分支和提交功能,确保每次重构都能轻松回溯。
总而言之,代码重构是一个持续的、迭代的过程,它依赖于良好的自动化测试作为安全网,并遵循小步快跑的原则。它是提高软件质量和开发效率不可或缺的一部分。
43. 如何清理源码里面没有被应用的代码,主要是JS、TS.CSS代码
清理源码中未被使用的代码(Dead Code Elimination)是前端优化中的一个重要环节,可以显著减小打包体积,提高应用性能。这通常依赖于构建工具和语言特性。
1. JavaScript / TypeScript
对于 JS/TS 死代码,主要通过以下方式清理:
-
Tree Shaking (摇树优化) / Dead Code Elimination (DCE):
这是最主要也是最有效的方法。它依赖于 ES Modules (ESM) 的静态分析特性。- 原理: Tree Shaking 会在打包时分析模块间的依赖关系,如果一个模块(或其内部的某个导出)被导入了,但没有被实际使用,那么它就不会被包含在最终的打包文件中。
- 要求:
- 必须使用 ES Modules (import/export)。 CommonJS (require/module.exports) 由于其动态性,通常无法进行有效的 Tree Shaking。
- 构建工具支持: Webpack (v2+), Rollup, Parcel 等现代打包工具都内置了 Tree Shaking 功能。
- Babel 配置: 如果使用 Babel 转换 ES Modules,确保
@babel/preset-env配置中modules设置为false,这样 Babel 就不会将 ES Modules 转换为 CommonJS,从而保留模块的静态特性。1// .babelrc 或 babel.config.js 2{ 3 "presets": [ 4 ["@babel/preset-env", { 5 "modules": false // 关键设置,保留 ES Modules 6 }], 7 "@babel/preset-typescript" // 如果是 TypeScript 8 ] 9} - 副作用 (Side Effects) 声明: 在
package.json中声明项目的副作用。如果一个模块没有副作用,即使它被导入了,但如果没有使用其导出的内容,也可以安全地被移除。1// package.json 2{ 3 "name": "my-library", 4 "sideEffects": false // 表示整个包都没有副作用 5} 6// 或者指定有副作用的文件 7// "sideEffects": ["./src/foo.js", "*.css"]
- 如何识别: 在开发阶段,
ESLint等 Linter 工具的no-unused-vars规则可以帮助你发现未使用的变量和导入。
-
Uglify/Terser 压缩工具:
在打包的最后阶段,使用代码压缩工具(如TerserPluginfor Webpack)进行压缩。这些工具不仅能压缩代码,也能移除一些明显的死代码(例如,if (false) { ... }这种永远不会执行的代码块)。1// webpack.config.js 2const TerserPlugin = require('terser-webpack-plugin'); 3 4module.exports = { 5 // ... 6 optimization: { 7 minimize: true, 8 minimizer: [ 9 new TerserPlugin({ 10 // 确保 dead_code 选项为 true (默认就是) 11 terserOptions: { 12 compress: { 13 dead_code: true, // 移除死代码 14 unused: true, // 移除未使用的变量和函数 15 }, 16 }, 17 }), 18 ], 19 }, 20}; -
人工排查:
对于一些无法通过工具分析的复杂逻辑(如动态require或某些高阶函数),可能需要人工审查。使用 IDE 的“查找引用”功能可以帮助。 -
运行时分析:
虽然主要用于性能分析,但也可以间接发现不常执行的代码路径。例如,通过 Chrome DevTools 的 Code Coverage 功能,你可以看到哪些代码在页面加载和交互过程中没有被执行过。
2. CSS
清理 CSS 死代码通常比 JS/TS 复杂一些,因为它涉及静态分析 HTML 和 JavaScript 对 CSS 类名的引用。
-
PurgeCSS / PurifyCSS / uncss:
这些工具通过分析你的 HTML 和 JavaScript 文件,找出所有被使用的 CSS 类名、ID 和标签,然后从 CSS 文件中移除所有未被使用的样式规则。- 原理: 它们会解析你的模板文件(HTML、JS/TS 文件,以及 Vue/React 组件),提取所有类名、ID 等选择器,然后与你的 CSS 文件进行对比,将未出现在模板中的 CSS 规则移除。
- 集成: 通常作为 PostCSS 插件或 Webpack 插件使用。
使用
PurgeCSS示例 (Webpack):1// webpack.config.js 2const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3const PurgeCSSPlugin = require('purgecss-webpack-plugin'); 4const path = require('path'); 5const glob = require('glob'); // 用于匹配文件路径 6 7const PATHS = { 8 src: path.join(__dirname, 'src') 9}; 10 11module.exports = { 12 // ... 13 plugins: [ 14 new MiniCssExtractPlugin({ 15 filename: '[name].[contenthash].css', 16 }), 17 new PurgeCSSPlugin({ 18 paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }), // 匹配所有 HTML, JS, TS, Vue, React 组件文件 19 // 如果你的项目有特定的文件类型,可以更具体地定义 20 // paths: glob.sync(`${PATHS.src}/**/*.{html,js,jsx,ts,tsx,vue}`, { nodir: true }), 21 // 如果使用 Tailwind CSS 等,可能需要 safelist 选项 22 // safelist: ['html', 'body'] 23 }), 24 ], 25 // ... 26}; -
CSS Linting 工具:
stylelint等工具可以帮助你发现一些明显的重复或冗余 CSS 规则。 -
人工排查:
对于一些通过 JS 动态添加的类名,或者通过模板字符串拼接的类名,静态分析工具可能无法完全识别。这种情况下需要人工审查或添加配置到工具的白名单 (safelist)。
总结:
清理未使用的代码是一个自动化为主、人工为辅的过程:
- JS/TS: 依赖 Tree Shaking (确保使用 ES Modules 和正确的 Babel 配置) 和 代码压缩工具 (
Terser)。 - CSS: 依赖
PurgeCSS等工具来分析模板文件并清除未使用的样式。
在整个开发流程中,将这些工具集成到构建管道中,并在 CI/CD 中强制执行,是保证最终产物精简的有效策略。定期审查 Code Coverage 报告也是发现死代码的有效方式。
44. 前端应用如何做国际化?
前端应用的国际化(Internationalization,通常缩写为 i18n,因为 i 和 n 之间有 18 个字母)是指使应用程序能够适应不同语言和地区的过程。这通常包括:
- 文本翻译 (Translation): 将应用中的所有用户可见文本翻译成多种语言。
- 日期、时间、数字、货币格式化: 根据不同地区的习惯进行格式化。
- 复数规则: 根据不同语言的语法规则处理复数形式。
- 图片和媒体资源: 根据语言或地区显示不同的图片或媒体。
- 布局方向 (RTL/LTR): 支持从右到左(Right-to-Left, RTL)的语言(如阿拉伯语、希伯来语)。
核心步骤和实践:
-
抽离所有文本内容:
这是国际化的第一步。所有用户可见的静态文本、提示信息、按钮文字等,都不能硬编码在代码中,而应抽离成键值对的形式。Bad:
<div>欢迎使用!</div>
Good:<div>{t('welcome_message')}</div> -
选择国际化库:
通常会选择一个成熟的国际化库来管理翻译、格式化等复杂逻辑。- 通用 JavaScript 库:
i18next,FormatJS(包含react-intl,intl-messageformat等)。 - 框架特定库: Vue I18n (for Vue), React Intl (for React)。
- 轻量级:
i18n.js(简单场景)。
- 通用 JavaScript 库:
-
定义语言文件 (Locale Files):
为每种支持的语言创建独立的 JSON 文件或 JavaScript 模块,存储键值对的翻译内容。en.json(英文):1{ 2 "welcome_message": "Welcome!", 3 "greeting": "Hello, {{name}}!", 4 "button_submit": "Submit", 5 "plural_apples": "You have {{count}} apple.", 6 "plural_apples_plural": "You have {{count}} apples." 7}zh.json(中文):1{ 2 "welcome_message": "欢迎!", 3 "greeting": "你好,{{name}}!", 4 "button_submit": "提交", 5 "plural_apples": "你有 {{count}} 个苹果。" 6}对于复数规则,某些库(如
i18next)支持根据数字自动选择正确的复数形式,无需手动定义两个键。 -
配置国际化库并加载语言文件:
以
i18next为例:1import i18n from 'i18next'; 2import { initReactI18next } from 'react-i18next'; // 如果是 React 项目 3 4import en from './locales/en.json'; 5import zh from './locales/zh.json'; 6 7i18n 8 .use(initReactI18next) // 绑定 React 9 .init({ 10 resources: { 11 en: { 12 translation: en 13 }, 14 zh: { 15 translation: zh 16 } 17 }, 18 lng: 'en', // 默认语言 19 fallbackLng: 'en', // 当当前语言没有某个键时,回退到哪个语言 20 interpolation: { 21 escapeValue: false // React 默认是安全的 22 }, 23 debug: true // 开发模式开启调试 24 }); 25 26export default i18n; -
在组件中使用翻译函数:
通过国际化库提供的函数(通常是t或formatMessage)来获取翻译文本。React (使用
react-i18next):1import React from 'react'; 2import { useTranslation } from 'react-i18next'; 3 4function MyComponent() { 5 const { t } = useTranslation(); 6 return ( 7 <div> 8 <h1>{t('welcome_message')}</h1> 9 <p>{t('greeting', { name: 'Alice' })}</p> 10 <button>{t('button_submit')}</button> 11 <p>{t('plural_apples', { count: 1 })}</p> {/* 会根据 count 自动选择复数规则 */} 12 <p>{t('plural_apples', { count: 5 })}</p> 13 </div> 14 ); 15}Vue (使用
vue-i18n):1<template> 2 <div> 3 <h1>{{ $t('welcome_message') }}</h1> 4 <p>{{ $t('greeting', { name: 'Alice' }) }}</p> 5 <button>{{ $t('button_submit') }}</button> 6 </div> 7</template> 8 9<script> 10export default { 11 name: 'MyComponent' 12} 13</script> -
语言切换机制:
提供一个 UI 界面让用户切换语言。1// 例如,i18next 的切换方法 2i18n.changeLanguage('zh'); // 切换到中文 3// 切换后,组件会自动重新渲染,显示新的语言内容。 -
日期、时间、数字、货币格式化:
使用Intl对象或国际化库提供的工具函数。1// 原生 Intl API 2const date = new Date(); 3console.log(new Intl.DateTimeFormat('en-US').format(date)); // 4/23/2023 4console.log(new Intl.DateTimeFormat('zh-CN').format(date)); // 2023/4/23 5 6const number = 1234567.89; 7console.log(new Intl.NumberFormat('en-US').format(number)); // 1,234,567.89 8console.log(new Intl.NumberFormat('de-DE').format(number)); // 1.234.567,89 9 10const price = 123.45; 11console.log(new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price)); // $123.45 12console.log(new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price)); // ¥123国际化库通常会封装这些
IntlAPI,提供更便捷的调用方式。 -
检测用户偏好语言:
- 浏览器语言:
navigator.language或navigator.languages。 - 用户设置: 存储在
localStorage或Cookie中,下次访问时读取。 - URL 参数/路径:
example.com/en/home,example.com/zh/home。 - 后端提供: 后端根据用户 IP 或用户设置返回相应的语言。
- 浏览器语言:
-
RTL (Right-to-Left) 布局支持:
对于阿拉伯语、希伯来语等从右到左的语言,需要调整页面布局。- 在
<html>标签上设置dir="rtl"。 - 使用 CSS 逻辑属性(
margin-inline-start,padding-inline-end等)而非物理属性(margin-left,padding-right),或者使用专门的 RTL-aware CSS 框架。
- 在
国际化流程中的挑战:
- 翻译质量: 确保翻译准确、地道,符合目标文化习惯。最好有专业翻译人员参与。
- 字符串管理: 随着应用增长,语言文件会变得庞大。需要工具(如 Crowdin, Phraseapp)来管理翻译流程、版本控制和协作。
- 占位符和 HTML: 翻译中包含变量(
{{name}})或内联 HTML(欢迎访问 <a href="#">网站</a>)的处理。 - 懒加载: 语言文件可能很大,需要按需懒加载。
- 测试: 确保所有文本都正确翻译并在不同语言环境下显示正常。
一个完善的国际化方案能极大地提升应用的全球用户体验。
45. 应用如何做应用灰度发布
应用灰度发布(Grey Release),也称为金丝雀发布(Canary Release),是一种在新版本全面上线之前,先将新版本的功能和代码部署到一小部分用户(或服务器)上进行测试和验证的策略。其目的是在小范围内发现并解决潜在的问题,降低全量发布可能带来的风险。
核心理念和目标:
- 风险控制: 降低新版本 Bug 对所有用户造成影响的风险。
- 快速验证: 在真实环境下验证新功能和修复的有效性。
- 性能监控: 在小流量下监控新版本的性能指标,避免出现性能问题。
- 用户反馈: 收集早期用户的反馈。
- 平滑回滚: 如果发现问题,可以快速回滚到旧版本,不影响大部分用户。
实现灰度发布的关键步骤和技术:
-
版本管理:
- 多版本共存: 线上同时存在旧版本和新版本代码。
- 版本标识: 每个版本有唯一的标识符(如 Git Commit ID, 版本号)。
-
流量路由/分发策略:
这是灰度发布的核心,决定哪些用户访问新版本,哪些用户访问旧版本。- 基于用户维度:
- 用户 ID/白名单: 只有特定用户(如内部员工、测试用户)能访问新版本。
- 用户地理位置: 优先在新功能影响较小的区域发布。
- 用户属性: 如会员等级、购买历史等。
- 基于请求维度:
- IP 地址: 仅允许来自特定 IP 段的请求访问新版本。
- Header 参数: 在请求头中携带特定标识(如
X-Version: new),仅带有此头的请求访问新版本。通常用于内部测试或特定客户端版本。 - Cookie/Local Storage: 在用户浏览器中设置特定标记,标记为灰度用户。
- 基于设备维度:
- 设备类型/操作系统: 如仅对 Android 手机或 iOS 16+ 的用户开放。
- 浏览器类型: 如仅对 Chrome 用户开放。
- 基于比例/随机:
- 最常见的策略。将 1%、5%、10% 等比例的流量导入新版本。这通常通过负载均衡器(如 Nginx)、API 网关或服务网格(如 Istio)实现。
- 哈希算法: 对用户 ID、IP 或其他唯一标识进行哈希,然后根据哈希值决定用户分组。
- A/B Test 平台: 专业的 A/B Test 工具通常包含了灰度发布的功能。
- 基于用户维度:
-
部署架构:
- 多实例部署: 在生产环境中同时部署旧版本和新版本的服务实例。
- 负载均衡器/网关: 负责根据配置的流量路由策略将请求分发到不同版本的实例。
- 服务网格(Service Mesh): 如 Istio, Linkerd,提供更高级的流量管理能力(例如按请求头路由、流量比例、故障注入等)。
- DNS 权重: 调整 DNS 记录的权重,将少量流量导向新版本的 IP 地址(不推荐用于细粒度控制,因为 DNS 缓存问题)。
-
监控与报警:
灰度发布期间,监控是至关重要的。- 性能监控: 响应时间、吞吐量、CPU/内存使用率等。对比新旧版本的性能指标。
- 错误监控: 错误率、错误日志(JS 错误、HTTP 错误、后端错误)。
- 业务指标监控: 用户转化率、留存率、关键业务流程的完成情况。
- 日志分析: 实时收集和分析新旧版本服务的日志。
- 报警机制: 设置关键指标的阈值报警,一旦新版本出现异常,立即通知相关人员。
-
回滚机制:
- 快速回滚: 如果新版本出现严重问题,能够迅速将所有流量切回旧版本。这通常通过修改负载均衡器的配置或回滚部署来实现。
- 一键回滚: 自动化回滚操作,减少人工干预和错误。
灰度发布流程示例:
- 准备: 确保新版本代码已测试充分,所有监控和报警已配置。
- 小流量灰度 (金丝雀阶段):
- 将 1% 或 5% 的流量(通常选择内部用户或低风险用户)导入新版本。
- 密切监控各项指标,收集反馈。
- 持续数小时到数天,如果稳定,进入下一阶段。
- 逐步扩大灰度范围:
- 将流量比例逐步提升到 10%、30%、50% 等。
- 每个阶段都进行充分的观察和验证。
- 全量发布:
- 当新版本在灰度阶段表现良好,各项指标稳定,达到预期时,将所有流量切到新版本,旧版本下线。
- 后续观察: 即使全量发布后,仍需持续监控,以防遗漏问题。
前端在灰度发布中的作用:
- 版本标识: 在前端代码中嵌入版本号,方便后端路由或前端自行判断当前所处环境。
- 用户分组标记: 读取后端设置的灰度标记(如 Cookie、HTTP Header),或前端根据规则(如用户 ID 哈希)自行决定是否进入灰度。
- 性能和错误监控: 接入前端监控系统,实时上报新旧版本下的性能数据和错误信息,这是灰度发布决策的重要依据。
- A/B Test 框架: 如果有 A/B Test 需求,前端可能需要集成相应的 SDK 来根据分组显示不同 UI 或功能。
灰度发布是现代软件开发和运维中不可或缺的一环,它有效平衡了快速迭代与稳定性的需求。
46. [微前端]为何通常在微前端应用隔离,不选择iframe方案
微前端(Micro-Frontends)架构的核心思想是将一个大型的、单体的前端应用拆分成多个独立自治的小型应用,这些小型应用可以独立开发、部署和运行。尽管 <iframe> 看起来是天然的隔离方案,但它在微前端实践中通常不被推荐作为主流的隔离技术,主要原因如下:
-
路由管理困难:
- URL 同步:
iframe内部的路由变化不会直接反映到主应用的 URL 上,反之亦然。需要通过postMessage等方式手动同步主应用和iframe内部的 URL,这增加了复杂性。 - 前进/后退: 浏览器自带的前进/后退按钮只对主应用生效,无法直接控制
iframe内部的历史记录。 - 刷新问题: 刷新主应用时,
iframe也会刷新,可能导致状态丢失或额外的加载时间。
- URL 同步:
-
状态共享和通信困难:
iframe之间以及iframe与主应用之间是天然的沙箱隔离。虽然这提供了强大的隔离性,但也意味着它们之间的数据共享和通信非常困难,主要依赖于postMessageAPI。postMessage是异步的,且发送和接收需要额外的事件监听和消息解析逻辑,效率相对较低且易出错。- 需要手动序列化和反序列化数据,传递复杂数据结构不便。
-
UI 体验限制:
- 布局限制:
iframe默认是一个矩形区域,自适应高度和宽度可能存在挑战,特别是当iframe内部内容高度不固定时。 - 样式隔离过强: 导致样式难以共享(如全局主题、通用组件库样式),或者需要通过父子
iframe之间传递样式变量等复杂方式。 - 弹窗/模态框:
iframe内部的弹窗只能在iframe内部显示,如果需要覆盖整个页面,必须通过postMessage让主应用去创建弹窗,这增加了复杂性。 - 滚动条:
iframe可能会出现双重滚动条,影响用户体验。 - 元素拖拽: 如果需要拖拽
iframe内部的元素到外部,或反之,实现起来非常复杂。
- 布局限制:
-
性能开销:
- 独立上下文: 每个
iframe都创建一个独立的浏览器上下文(独立的 DOM、CSSOM、JavaScript 引擎)。这会增加内存消耗和 CPU 负担,尤其是在页面中存在多个iframe时。 - 资源重复加载: 不同
iframe之间无法共享公共资源(如通用库、CSS 变量),可能导致重复加载。 - 加载时间:
iframe的加载通常比动态加载组件慢,因为它们是独立的文档。
- 独立上下文: 每个
-
SEO 不友好:
- 搜索引擎爬虫对
iframe内容的抓取和索引支持有限,不利于 SEO。
- 搜索引擎爬虫对
-
可访问性 (Accessibility) 问题:
- 辅助技术(如屏幕阅读器)在处理
iframe内容时可能遇到困难。
- 辅助技术(如屏幕阅读器)在处理
那么,微前端通常选择哪些隔离方案?
主流的微前端框架(如 Qiankun, single-spa, module-federation)通常采用以下方案来替代 iframe:
-
基于 JavaScript 的沙箱隔离 (Sandbox):
- 代理
window和document对象: 劫持全局变量的读写操作,为每个子应用创建一个独立的运行环境,防止子应用之间互相污染,也防止子应用污染主应用。 - 样式隔离: 利用 Shadow DOM、CSS Modules、BEM 命名规范或动态修改 CSS 选择器来防止样式冲突。
- 优点: 灵活性高,可以实现更细粒度的控制,更好的通信和状态共享机制,优化加载和渲染性能。
- 缺点: 实现复杂,需要解决运行时环境的兼容性和隔离深度问题。
- 代理
-
基于 Web Components 的隔离:
- 使用 Custom Elements 和 Shadow DOM 来封装每个微前端,利用 Shadow DOM 的样式隔离能力。
- 优点: 浏览器原生支持,天然的样式和 DOM 隔离。
- 缺点: 学习成本,浏览器兼容性(需要 Polyfill)。
-
基于 Webpack Module Federation (模块联邦) 的隔离:
- Webpack 5 引入的新特性,允许不同的 Webpack 构建应用共享模块,甚至在运行时加载。
- 优点: 彻底的模块共享和隔离,无需在运行时进行额外的沙箱操作。
- 缺点: 仅限于 Webpack 生态,对于异构技术栈支持不如运行时沙箱。
总结:
尽管 iframe 提供了最强的隔离性,但其在路由、通信、UI 体验和性能上的诸多限制,使其不适合作为微前端架构中主流的子应用隔离方案。微前端更倾向于使用更灵活、更可控的 JavaScript 沙箱(如 Qiankun)或 Web Components 等技术,以实现更好的用户体验和开发效率。
47. [微前端]Qiankun是如何做JS隔离的
Qiankun 作为一个基于 single-spa 的微前端框架,其核心优势之一就是提供了非常完善和灵活的 JavaScript 隔离方案,旨在解决微前端应用之间的全局变量污染问题,以及确保子应用在独立运行和被加载到主应用中时行为一致。
Qiankun 的 JS 隔离主要通过代理机制 (Proxy) 和 快照机制 (Snapshot) 两种方式结合实现,其中代理机制是其主要且推荐的方案。
1. 代理机制 (Proxy Sandbox) - 主要方案
-
原理: Qiankun 利用 ES6 的
Proxy对象劫持子应用运行时对window和document等全局对象的访问。- 当子应用尝试读取全局属性时,Qiankun 会先在其“虚拟”的
window代理对象中查找,如果不存在,则再回退到真实的window。 - 当子应用尝试修改全局属性时,Qiankun 会将这些修改记录在其“虚拟”的
window代理对象上,而不会直接修改真实的window。
- 当子应用尝试读取全局属性时,Qiankun 会先在其“虚拟”的
-
实现细节:
- 激活沙箱: 在子应用加载并执行之前,Qiankun 会创建一个
Proxy实例作为子应用自己的window。 - 劫持
window读写:get:当子应用读取window.xxx时,先从当前子应用的activeWindow(代理对象)中查找xxx,如果找不到,再从真实的window中查找。这样子应用可以访问全局变量,但会优先读取自己的副本。set:当子应用设置window.xxx = value时,将xxx和value记录到当前子应用的activeWindow中,而不会修改真实的window。
- 兼容性处理: 对于一些无法通过
Proxy劫持的全局变量(如document、history、location等),Qiankun 会通过在子应用激活时保存原始值,并在子应用失活时恢复原始值的方式进行处理。对于这些特殊全局对象,Qiankun 也会创建对应的代理对象。 eval和new Function: 对于动态执行代码(如eval),Qiankun 也会进行处理,确保它们在沙箱环境中执行。
- 激活沙箱: 在子应用加载并执行之前,Qiankun 会创建一个
-
优点:
- 隔离彻底: 对全局变量的读写操作进行细粒度控制,防止子应用互相污染。
- 性能较好:
Proxy是原生 API,性能开销相对较低。 - 无感接入: 大多数情况下,子应用无需修改代码即可在沙箱中运行。
- 支持多实例: 可以同时激活多个相同子应用的实例,每个实例都有独立的沙箱环境。
-
局限性:
Proxy无法完美劫持所有原生对象或方法(如Window.prototype上的属性),某些高级用例仍可能出现问题。- 对一些第三方库(如
jQuery等直接操作全局对象或检查instanceof Window的库)兼容性可能需要特殊处理。 webpackJsonp等 Webpack 运行时变量:Qiankun 会对这些做特殊处理,确保不同 Webpack 构建的子应用能够共存。
2. 快照机制 (Snapshot Sandbox) - 备用方案(已废弃或不推荐用于现代应用)
-
原理: 这种机制在子应用激活前记录下全局
window的状态(快照),子应用激活时恢复上次记录的状态。子应用运行时对全局window的所有修改都会被记录下来。当子应用失活时,将window恢复到它激活前的状态;当它再次激活时,恢复到它失活前的状态。 -
实现细节:
- 激活: 遍历
window对象,记录所有属性的原始值。 - 运行时: 子应用对
window的修改会直接作用于真实的window。 - 失活: 遍历记录的快照,将
window恢复到子应用激活前的状态,并记录下子应用离开时的状态。 - 再次激活: 恢复到子应用上次离开时的状态。
- 激活: 遍历
-
优点:
- 兼容性好:不需要
Proxy支持,可在旧浏览器环境下运行。
- 兼容性好:不需要
-
缺点:
- 不彻底的隔离: 子应用运行时会直接修改真实
window,如果有多个子应用,它们会相互污染。 - 性能较差: 频繁的遍历
window对象和状态恢复开销较大。 - 无法支持多实例: 无法同时运行同一个子应用的多个实例,因为它们会共享同一个真实
window。
- 不彻底的隔离: 子应用运行时会直接修改真实
总结:
Qiankun 主要依靠 Proxy 机制来实现其强大的 JS 隔离能力。它通过创建 window 代理对象,劫持子应用对全局变量的读写,从而为每个子应用提供一个独立的、隔离的运行环境,极大地解决了微前端应用间的全局变量污染问题。快照机制作为备用和兼容旧环境的方案,但在现代应用中已不被推荐。
48. [微前端]微前端架构一般是如何做JavaScript隔离
微前端架构的核心挑战之一就是 JavaScript 隔离,即如何确保多个独立的子应用在同一个页面中运行时,不会互相污染全局环境,同时又能保持相对独立的开发和部署。
以下是微前端架构中常见的 JS 隔离方法,以及它们各自的优缺点:
-
手动命名空间 / 约定 (Namespace / Convention):
- 原理: 所有子应用都约定将自己的代码和变量封装在一个全局唯一的命名空间下,避免直接暴露在全局
window对象上。 - 实现:
1// app1.js 2window.App1 = { 3 init: function() { /* ... */ }, 4 data: {} 5}; 6// app2.js 7window.App2 = { 8 init: function() { /* ... */ }, 9 data: {} 10}; - 优点: 简单易实现,无需特殊工具。
- 缺点:
- 容易出错: 依赖开发者的自觉和约定,一旦有人不遵守约定,就会造成污染。
- 不彻底: 无法隔离
eval、setTimeout等全局函数内部对window的操作,也无法隔离三方库对全局变量的污染。 - 缺乏自动化: 没有工具强制执行,维护成本高。
- 适用场景: 对隔离要求不严格的小型项目,或者作为辅助手段。
- 原理: 所有子应用都约定将自己的代码和变量封装在一个全局唯一的命名空间下,避免直接暴露在全局
-
IFRAME(天然沙箱):- 原理: 每个子应用都运行在一个独立的
iframe中。iframe提供了最彻底的浏览器级隔离,包括 JS、CSS 和 DOM。 - 优点: 隔离性最强,冲突风险最低。
- 缺点: (详见问题 46 的回答)
- 路由管理复杂: URL 不同步,历史记录难维护。
- 通信困难: 依赖
postMessage,异步且复杂。 - UI 体验差: 弹窗、样式、布局、滚动条等问题。
- 性能开销大: 独立的上下文和资源重复加载。
- SEO 不友好。
- 适用场景: 对隔离要求极高,且对交互体验和性能要求不高的遗留系统集成。
- 原理: 每个子应用都运行在一个独立的
-
基于
Proxy的沙箱 (Sandbox) - 主流方案 (如 Qiankun):- 原理: 利用 ES6 的
Proxy对象劫持子应用对window、document等全局对象的读写操作。为每个子应用创建一个独立的“虚拟”全局环境。 - 实现:
- 劫持读写: 当子应用读取
window.xxx时,先从其沙箱中查找,如果沙箱中没有,则回退到真实window。当子应用写入window.xxx时,写入到其沙箱中,不污染真实window。 - 副作用处理: 对于一些难以代理的原生对象(如
document.createElement),会通过 AOP(面向切面编程)等方式在方法执行前后进行额外处理,或在应用激活/失活时进行恢复。 - 动态执行代码: 对
eval、new Function等进行重写,确保其在沙箱内执行。
- 劫持读写: 当子应用读取
- 优点:
- 隔离彻底且灵活: 可以细粒度控制全局变量的访问,实现多实例。
- 无感接入: 大多数子应用无需修改即可在沙箱中运行。
- 性能相对较好:
Proxy是原生 API。
- 缺点:
- 实现复杂: 需要处理各种边缘情况和浏览器兼容性问题。
- 并非 100% 完美隔离: 少数原生属性或某些特殊库可能无法完全被代理。
- 适用场景: 现代微前端框架的首选,如 Qiankun。
- 原理: 利用 ES6 的
-
基于
Snapshot的沙箱 (Sandbox) - 旧方案 / 备用 (Qiankun 早期方案):- 原理: 在子应用激活前,记录下当前全局
window的所有属性和值(快照)。子应用运行时直接修改真实的window。当子应用失活时,将window恢复到激活前的快照状态。 - 优点: 兼容性好(不需要
Proxy)。 - 缺点:
- 隔离不彻底: 运行时仍会污染真实
window,不同子应用之间可能互相影响。 - 性能开销大: 频繁遍历和恢复
window状态。 - 无法多实例。
- 隔离不彻底: 运行时仍会污染真实
- 适用场景: 兼容不支持
Proxy的老旧浏览器环境。
- 原理: 在子应用激活前,记录下当前全局
-
Web Components (Shadow DOM) + JS 模块化:
- 原理: 将每个微前端封装成一个独立的 Custom Element,并利用 Shadow DOM 进行样式和 DOM 隔离。JS 隔离则依赖于标准的 ES Modules。
- 实现:
- 子应用代码作为独立的 JS 模块编写。
- 主应用动态加载这些模块,并将它们挂载到 Custom Element 中。
- 由于 ES Modules 本身就提供了模块作用域,大部分 JS 污染问题可以通过良好的模块化习惯解决。
- 优点:
- 浏览器原生支持: 符合 Web 标准,未来趋势。
- 天然 DOM/CSS 隔离: Shadow DOM 提供强大的隔离。
- JS 模块化: 通过 ES Modules 隔离 JS 变量。
- 缺点:
- JS 隔离仍依赖良好实践: 无法阻止子应用直接修改
window。 - 兼容性: 部分浏览器对 Web Components 的支持需要 polyfill。
- 通信: 仍然需要通过自定义事件或共享 Store 来通信。
- JS 隔离仍依赖良好实践: 无法阻止子应用直接修改
- 适用场景: 如果团队对 Web Components 有深入了解,追求原生化和标准化的方案。
-
Webpack Module Federation (模块联邦):
- 原理: Webpack 5 引入的特性,允许不同的 Webpack 构建应用(宿主和远程)在运行时共享代码模块。它通过在构建时定义模块的暴露和引用,实现了模块级别的隔离和共享。
- 优点:
- 真正意义上的模块共享和隔离: 共享的模块只加载一次。
- 解决重复依赖问题: 大大减小打包体积。
- 无需运行时沙箱: 隔离是在构建和模块加载层面完成的。
- 缺点:
- 强绑定 Webpack 生态: 适用于所有子应用都使用 Webpack 的场景。
- 学习成本: 配置相对复杂。
- 运行时动态性: 运行时加载模块可能带来一些新的调试挑战。
- 适用场景: 所有微前端应用都采用同一套构建工具(Webpack)的场景,追求极致的性能优化和依赖共享。
总结:
在微前端架构中,JavaScript 隔离是一个多维度的问题。
- 主流框架(如 Qiankun) 通常采用
Proxy劫持的方式,提供相对无感的运行时隔离。 - 新一代方案 则倾向于利用 Webpack Module Federation 实现构建时的模块共享和隔离,或者拥抱 Web Components 的原生隔离能力。
iframe因其固有缺陷,在大多数场景下已不作为首选方案。
选择哪种隔离方案取决于项目需求、团队技术栈、兼容性要求以及对性能和开发效率的权衡。
49. [React]循环渲染中为什么推荐不用index做key
在 React 中,当你在一个列表中渲染多个组件或元素时,key 属性是必不可少的。它帮助 React 识别列表中哪些项被修改了、添加了或删除了。key 属性的本质是提供一个稳定且唯一的标识符给列表中的每个元素。
然而,不推荐使用数组的 index 作为 key,尤其是在以下两种情况:
- 列表项的顺序可能会改变。
- 列表项可能会被添加、删除或重新排序。
为什么 index 作为 key 会有问题?
React 使用 key 来优化渲染性能。当列表项的顺序发生变化时,如果使用 index 作为 key,React 会错误地认为列表中的项是相同的,只是内容发生了变化,从而导致以下问题:
-
性能问题:
- 当列表项顺序改变时,React 会认为同一个
index上的元素只是内容发生了变化,而不是元素本身被移动了。 - 这会导致 React 不会销毁旧的 DOM 节点并创建新的节点,而是尝试就地修改现有节点的内容。
- 如果列表项内部有自己的状态(如输入框的值、动画状态等),这种就地修改会使得这些状态与实际数据不匹配,导致不必要的 DOM 操作和性能浪费。
- 举例:如果列表 A, B, C,使用 index (0, 1, 2) 作为 key。现在变成了 C, A, B。React 会认为:
- index 0 上的
A变成了C - index 1 上的
B变成了A - index 2 上的
C变成了B
它会尝试修改这三个 DOM 元素的内容,而不是将A移动到 1,B移动到 2,C移动到 0。这可能比直接移动或重新创建更低效。
- index 0 上的
- 当列表项顺序改变时,React 会认为同一个
-
状态错乱/Bug:
这是最严重的问题。如果列表项是受控组件(例如带有input元素的列表),或者列表项内部有自己的组件状态:- 输入框内容错位: 如果一个列表项被删除或插入,使用
index作为key会导致后续的项的key都发生变化。React 可能会将某个input的值(由于其key没变而被认为没变)保留下来,但它实际上对应的 DOM 元素已经与新的数据项不匹配。- 想象一个有输入框的列表:
[A (0), B (1), C (2)] - 用户在
B的输入框中输入了 "X"。 - 现在,删除
A。列表变为[B (0), C (1)]。 - 如果使用
index作key,React 会认为:- 原先
key=1的B变成了key=0的B - 原先
key=2的C变成了key=1的C
- 原先
- 实际上,
key=1的那个 DOM 元素现在对应C,但它可能仍然保留着B的输入框内容 "X",导致数据错乱。
- 想象一个有输入框的列表:
- 组件生命周期错乱: 组件的
componentDidMount/componentWillUnmount等生命周期方法可能不会按预期触发,因为 React 认为组件实例只是被更新,而不是被销毁和重建。
- 输入框内容错位: 如果一个列表项被删除或插入,使用
什么时候可以使用 index 作为 key?
只有在以下两个条件都满足时,才可以使用 index 作为 key:
- 列表和列表项是静态的: 列表项的顺序永远不会改变,也不会有添加或删除操作。
- 列表没有唯一的 ID: 列表项本身没有一个稳定的、唯一的 ID 可用。
最佳实践:
- 使用稳定且唯一的 ID: 始终为列表中的每个项提供一个稳定且唯一的 ID 作为
key。这个 ID 通常来自后端数据库,或者是在客户端生成的一个全局唯一标识符 (UUID)。1const todoList = [ 2 { id: 'a1b2c3d4', text: 'Learn React' }, 3 { id: 'e5f6g7h8', text: 'Build a project' }, 4]; 5 6{todoList.map(todo => ( 7 <TodoItem key={todo.id} todo={todo} /> 8))} - 避免在
key中使用随机数: 随机数每次渲染都不同,会导致 React 每次都销毁并重建所有组件,性能极差。
总结来说,使用 index 作为 key 会破坏 React 的优化机制,导致性能问题和难以追踪的 Bug,因此应尽量避免。
50. [React]如何避免使用context的时候,引起整个挂载节点的重新渲染
在使用 React Context API 时,一个常见的问题是,当 Context 的 value 发生变化时,所有消费该 Context 的组件都会重新渲染,即使这些组件只使用了 value 中的一小部分数据,且这部分数据并未发生变化。这可能导致不必要的性能开销。
要避免 Context 的 value 变化导致整个挂载节点(即所有消费组件)的重新渲染,有几种策略:
1. 拆分 Context (Separate Contexts)
将一个大的 Context 拆分成多个更小的、职责单一的 Context。这样,每个组件只需要订阅它关心的那部分数据。
Bad (单个大 Context):
1// ThemeContext.js 2const ThemeContext = React.createContext(); 3 4function ThemeProvider({ children }) { 5 const [theme, setTheme] = React.useState({ 6 color: 'blue', 7 fontSize: '16px', 8 padding: '10px', 9 // 很多其他样式属性 10 }); 11 12 // 当 theme 任何属性变化时,所有消费 ThemeContext 的组件都会重新渲染 13 return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>; 14}
Good (拆分 Context):
1// ColorContext.js 2const ColorContext = React.createContext(); 3// FontSizeContext.js 4const FontSizeContext = React.createContext(); 5// ...其他 Context 6 7function ThemeProvider({ children }) { 8 const [color, setColor] = React.useState('blue'); 9 const [fontSize, setFontSize] = React.useState('16px'); 10 // ... 11 12 return ( 13 <ColorContext.Provider value={color}> 14 <FontSizeContext.Provider value={fontSize}> 15 {children} 16 </FontSizeContext.Provider> 17 </ColorContext.Provider> 18 ); 19} 20 21// 消费组件 A 只关心颜色 22function MyComponentA() { 23 const color = React.useContext(ColorContext); 24 // ... 25} 26 27// 消费组件 B 只关心字体大小 28function MyComponentB() { 29 const fontSize = React.useContext(FontSizeContext); 30 // ... 31}
- 优点: 最直接有效的方式。当某个 Context 的值变化时,只有消费该特定 Context 的组件会重新渲染,其他组件不受影响。
- 缺点: 可能会导致 Context Providers 的嵌套层级过多,代码结构变得复杂。
2. 使用 React.memo / shouldComponentUpdate (配合 useMemo / useCallback)
如果你的消费组件是函数组件,可以使用 React.memo (对应类组件的 shouldComponentUpdate) 来阻止不必要的重新渲染。但关键是,Context 的 value 本身(作为一个对象)在每次渲染时都可能是新的引用,即使其内部数据没有变化,也会导致 React.memo 失效。因此,需要配合 useMemo 或 useCallback 来稳定 Context 的 value 引用。
Bad (Context value 每次都是新对象):
1function MyProvider({ children }) { 2 const [count, setCount] = React.useState(0); 3 const [text, setText] = React.useState('hello'); 4 5 // value 对象每次都会是新引用,即使 count 或 text 不变 6 const contextValue = { count, text }; 7 8 return <MyContext.Provider value={contextValue}>{children}</MyContext.Provider>; 9} 10 11const MyConsumer = React.memo(() => { 12 const { count, text } = React.useContext(MyContext); 13 console.log('MyConsumer render'); // count 变化时,这里会渲染。text 变化时,这里也会渲染 14 return <div>Count: {count}, Text: {text}</div>; 15});
Good (使用 useMemo 稳定 Context value 引用,并配合选择器模式):
- 思路: 将 Context
value对象用useMemo包裹,确保只有当依赖项(Context 内部真正变化的数据)改变时,value对象才重新创建。同时,在消费组件中,通过“选择器”模式只提取组件所需的部分。
1import React, { createContext, useState, useMemo, useContext, useCallback } from 'react'; 2 3const MyContext = createContext(); 4 5function MyProvider({ children }) { 6 const [count, setCount] = useState(0); 7 const [text, setText] = useState('hello'); 8 9 // 使用 useMemo 稳定 Context value 的引用 10 // 只有当 count 或 text 改变时,contextValue 才会重新创建 11 const contextValue = useMemo(() => { 12 return { 13 count, 14 text, 15 increment: () => setCount(prev => prev + 1), 16 changeText: (newText) => setText(newText) 17 }; 18 }, [count, text]); // 依赖项是 count 和 text 19 20 return ( 21 <MyContext.Provider value={contextValue}> 22 {children} 23 </MyContext.Provider> 24 ); 25} 26 27// 消费者组件 A:只关心 count 28const CountDisplay = React.memo(() => { 29 // 关键:只提取需要的部分,并使用 useMemo 优化 30 // 注意:useContext 返回的是 Context.Provider 提供的整个 value 对象 31 // 如果 value 对象本身引用变了,这里会重新渲染,所以 MyProvider 里的 useMemo 很关键 32 const { count } = useContext(MyContext); // 每次 contextValue 引用变,这里都会触发渲染 33 console.log('CountDisplay renders. Count:', count); 34 return <div>Current Count: {count}</div>; 35}); 36 37// 消费者组件 B:只关心 text 38const TextDisplay = React.memo(() => { 39 const { text } = useContext(MyContext); // 每次 contextValue 引用变,这里都会触发渲染 40 console.log('TextDisplay renders. Text:', text); 41 return <div>Current Text: {text}</div>; 42}); 43 44// 消费者组件 C:按钮,用于改变 count 45const ControlPanel = () => { 46 const { increment, changeText } = useContext(MyContext); 47 console.log('ControlPanel renders'); // 不会因 count 或 text 变化而重新渲染 48 return ( 49 <div> 50 <button onClick={increment}>Increment Count</button> 51 <button onClick={() => changeText('world')}>Change Text to World</button> 52 </div> 53 ); 54}; 55 56// 父组件 57function App() { 58 return ( 59 <MyProvider> 60 <CountDisplay /> 61 <TextDisplay /> 62 <ControlPanel /> 63 </MyProvider> 64 ); 65} 66 67export default App;
分析上面“好”的示例:
MyProvider中,contextValue被useMemo缓存。只有当count或text发生变化时,contextValue的引用才会改变。CountDisplay和TextDisplay都使用了React.memo。当count改变时,contextValue引用改变,CountDisplay和TextDisplay都会重新渲染。这是 Context API 的固有行为。- 要完全避免,你需要结合选择器模式,或者将 Context 拆分。
3. 使用 Context 选择器模式 (Custom Hook for Selection)
这是在不拆分 Context 的情况下,解决不必要渲染的更高级方法。
- 原理: 不直接使用
useContext,而是创建一个自定义 Hook,它在每次 Contextvalue变化时,手动比较你真正关心的那部分数据是否变化。如果没变,则不触发组件更新。
1import React, { createContext, useState, useMemo, useRef, useEffect } from 'react'; 2 3const MyContext = createContext(); 4 5function MyProvider({ children }) { 6 const [count, setCount] = useState(0); 7 const [text, setText] = useState('hello'); 8 9 const increment = useCallback(() => setCount(prev => prev + 1), []); 10 const changeText = useCallback((newText) => setText(newText), []); 11 12 const contextValue = useMemo(() => ({ 13 count, 14 text, 15 increment, 16 changeText 17 }), [count, text, increment, changeText]); 18 19 return ( 20 <MyContext.Provider value={contextValue}> 21 {children} 22 </MyContext.Provider> 23 ); 24} 25 26// 自定义 Context 选择器 Hook 27function useSelector(selector) { 28 const context = useContext(MyContext); // 每次 contextValue 引用变,这里都会触发渲染 29 30 // 使用 useRef 存储上一次的选择结果 31 const lastSelectedValue = useRef(); 32 33 // 计算当前选择结果 34 const newSelectedValue = useMemo(() => selector(context), [context, selector]); 35 36 // 比较新旧选择结果,如果相同则返回旧值,避免不必要的组件更新 37 // 实际应用中,如果 selector 返回的是对象,需要深比较 38 useEffect(() => { 39 lastSelectedValue.current = newSelectedValue; 40 }, [newSelectedValue]); 41 42 if (Object.is(lastSelectedValue.current, newSelectedValue)) { 43 return lastSelectedValue.current; 44 } else { 45 return newSelectedValue; 46 } 47} 48 49// 消费者组件 A:只关心 count 50const CountDisplay = () => { 51 const count = useSelector(state => state.count); 52 console.log('CountDisplay renders. Count:', count); 53 return <div>Current Count (via selector): {count}</div>; 54}; 55 56// 消费者组件 B:只关心 text 57const TextDisplay = () => { 58 const text = useSelector(state => state.text); 59 console.log('TextDisplay renders. Text:', text); 60 return <div>Current Text (via selector): {text}</div>; 61}; 62 63// 消费者组件 C:按钮,用于改变 count 64const ControlPanel = () => { 65 const { increment, changeText } = useContext(MyContext); 66 console.log('ControlPanel renders (not optimized by selector)'); // 仍会因为 contextValue 引用变而渲染 67 return ( 68 <div> 69 <button onClick={increment}>Increment Count</button> 70 <button onClick={() => changeText('world')}>Change Text to World</button> 71 </div> 72 ); 73}; 74 75// 父组件 76function App() { 77 return ( 78 <MyProvider> 79 <CountDisplay /> 80 <TextDisplay /> 81 <ControlPanel /> 82 </MyProvider> 83 ); 84} 85 86export default App;
- 优点: 解决了 Context API 的粒度问题,组件只会因其真正关心的部分数据变化而重新渲染。
- 缺点: 实现相对复杂,需要手动编写选择器 Hook,且
selector返回的是对象时需要考虑深比较(通常使用lodash.isequal或fast-deep-equal)。ControlPanel仍然会因为 Contextvalue引用变化而渲染,因为useContext仍然会触发其渲染,除非它也使用useSelector。
总结和推荐:
- 首选:拆分 Context。 如果逻辑上可以拆分,这是最清晰、最符合 React 理念且性能最好的方式。
- 次选(适用于无法拆分且
value频繁更新):结合useMemo稳定 Contextvalue的引用,并考虑自定义useSelectorHook。 这种方法能够精细控制消费组件的渲染。 - 避免: 仅仅对消费组件使用
React.memo而不优化 Contextvalue本身。
React Context API 主要用于低频更新的全局数据(如主题、用户信息),对于高频更新或复杂的状态管理,推荐使用 Redux、Zustand、Jotai 等状态管理库,它们通常内置了选择器优化,能够更好地处理细粒度更新。
好的,我们继续回答剩余的问题。
51. 前端如何实现截图?
前端实现截图功能,主要有以下几种方法:
-
HTML Canvas
toDataURL()和drawImage()(基本但有限制)- 原理:
- 创建一个
<canvas>元素。 - 使用
canvas.getContext('2d').drawImage()将图片绘制到 Canvas 上。 - 使用
canvas.toDataURL()将 Canvas 的内容导出为 Data URL (Base64 编码的图片)。
- 创建一个
- 优点: 简单直接,适用于纯图片、同源图片。
- 缺点:
- 跨域问题: 最核心的限制。如果图片是跨域的,
drawImage()绘制后,Canvas 将被“污染”(tainted),导致toDataURL()无法使用,会抛出安全错误。需要图片服务器设置Access-Control-Allow-Origin头,并且img标签设置crossorigin="anonymous"。 - 无法截取 DOM 结构: 只能处理图片,不能将 HTML 元素(文本、CSS 样式、排版等)直接渲染到 Canvas。
- 复杂性: 如果需要截取复杂布局的页面内容,此方法不适用。
- 跨域问题: 最核心的限制。如果图片是跨域的,
示例 (同源图片截图):
1<img id="myImage" src="path/to/your/same-origin-image.jpg" alt="可截图图片" style="width: 300px;"> 2<button id="captureBtn">截图图片</button> 3<canvas id="myCanvas" style="border: 1px dashed #ccc; margin-top: 20px;"></canvas> 4<div id="output"></div> 5 6<script> 7 const myImage = document.getElementById('myImage'); 8 const captureBtn = document.getElementById('captureBtn'); 9 const myCanvas = document.getElementById('myCanvas'); 10 const outputDiv = document.getElementById('output'); 11 const ctx = myCanvas.getContext('2d'); 12 13 captureBtn.addEventListener('click', () => { 14 if (!myImage.complete) { 15 alert('图片未完全加载,请稍候再试。'); 16 return; 17 } 18 19 myCanvas.width = myImage.naturalWidth; 20 myCanvas.height = myImage.naturalHeight; 21 22 try { 23 ctx.drawImage(myImage, 0, 0); 24 const imageDataUrl = myCanvas.toDataURL('image/png'); // 导出为 PNG 25 outputDiv.innerHTML = ``; 26 // 可以将 imageDataUrl 发送到后端 27 } catch (error) { 28 console.error('截图失败:', error); 29 alert('截图失败,可能是因为图片是跨域的,或者 Canvas 被污染。请检查控制台。'); 30 } 31 }); 32</script> - 原理:
-
HTML2Canvas / html2image (推荐,将 DOM 渲染为 Canvas)
- 原理: 这类库(如
html2canvas.js或dom-to-image.js)通过解析 DOM 树和计算元素的样式,然后在 Canvas 上模拟渲染出页面的截图。 - 优点:
- 可以截取整个页面或指定 DOM 元素。
- 支持大部分 CSS 样式和 HTML 元素(文本、图片、SVG、Canvas)。
- 高度灵活,可以定制截取范围、缩放等。
- 缺点:
- 性能开销: 渲染复杂页面或大型 DOM 树可能很耗时。
- 兼容性问题: 无法完美支持所有复杂的 CSS 特性(如某些伪类、混合模式、CSS 动画)或新的浏览器特性。
- 跨域问题: 对于引用了跨域图片或字体,同样需要服务器开启 CORS 或将图片转为 Base64 内联。
- 字体渲染: 自定义字体可能需要加载。
- 适用场景: 截取用户可见的页面区域、生成海报、错误报告等。
使用
html2canvas示例:1<!-- 引入 html2canvas 库 --> 2<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script> 3 4<div id="captureArea" style="width: 400px; padding: 20px; border: 1px solid #007bff; background-color: #e6f7ff; margin-bottom: 20px;"> 5 <h2>要截图的区域</h2> 6 <p>这是包含不同元素的文本。</p> 7 <button style="background-color: #28a745; color: white; padding: 8px 15px; border: none; border-radius: 5px;">一个按钮</button> 8 <img src="https://via.placeholder.com/100/FF0000/FFFFFF?text=Demo" alt="小图片" style="margin-top: 10px;"> 9</div> 10<button id="captureDomBtn">截图上面区域</button> 11<div id="outputDom" style="margin-top: 20px;"></div> 12 13<script> 14 document.getElementById('captureDomBtn').addEventListener('click', () => { 15 const captureArea = document.getElementById('captureArea'); 16 html2canvas(captureArea, { 17 // 配置项 18 useCORS: true, // 如果包含跨域图片,尝试设置 19 // allowTaint: true, // 如果 useCORS 失败,可以尝试允许污染,但可能无法导出 20 backgroundColor: '#ffffff', // 设置截图背景色 21 scale: 2 // 提高截图分辨率 (默认 1) 22 }).then(canvas => { 23 // 将 canvas 转换为图片 URL 24 const imageDataUrl = canvas.toDataURL('image/png'); 25 document.getElementById('outputDom').innerHTML = `29`; 30 }).catch(err => { 31 console.error('DOM 截图失败:', err); 32 alert('DOM 截图失败,请检查控制台或跨域问题。'); 33 }); 34 }); 35</script> - 原理: 这类库(如
-
MediaDevices.getDisplayMedia()(浏览器原生录屏/截图 API,用于捕获屏幕)- 原理: 这个 API 允许用户选择捕获整个屏幕、某个应用程序窗口或某个浏览器标签页的内容,并将其作为媒体流(视频流)返回。你可以从中获取帧并绘制到 Canvas 上进行截图。
- 优点:
- 原生支持: 浏览器原生功能,无需第三方库。
- 可以截取非本页面的内容: 甚至包括用户桌面上的其他应用。
- 无需担心跨域: 它截取的是渲染后的像素。
- 缺点:
- 用户交互: 必须由用户授权选择要共享的屏幕区域/窗口。
- 获取单帧: 需要从视频流中提取单帧进行处理,相对复杂。
- 不是直接的截图工具: 更偏向于录屏功能,截图是其衍生的能力。
- 兼容性: 并非所有浏览器都完全支持所有选项。
- 适用场景: 在线会议、协同办公、录屏应用等需要用户授权捕获屏幕内容的场景。
示例 (获取屏幕流并截取一帧):
1document.getElementById('screenCaptureBtn').addEventListener('click', async () => { 2 try { 3 // 请求用户授权选择屏幕/窗口/标签页 4 const mediaStream = await navigator.mediaDevices.getDisplayMedia({ 5 video: true, 6 audio: false // 通常截图不需要音频 7 }); 8 9 const video = document.createElement('video'); 10 video.srcObject = mediaStream; 11 video.onloadedmetadata = () => { 12 video.play(); 13 // 等待一帧,确保视频流已开始渲染 14 setTimeout(() => { 15 const captureCanvas = document.createElement('canvas'); 16 captureCanvas.width = video.videoWidth; 17 captureCanvas.height = video.videoHeight; 18 const captureCtx = captureCanvas.getContext('2d'); 19 captureCtx.drawImage(video, 0, 0, captureCanvas.width, captureCanvas.height); 20 21 // 停止媒体流 22 mediaStream.getTracks().forEach(track => track.stop()); 23 video.remove(); // 移除临时 video 元素 24 25 const imageDataUrl = captureCanvas.toDataURL('image/png'); 26 document.getElementById('outputScreen').innerHTML = ` 27 <p>屏幕截图成功!</p> 28 <img src="${imageDataUrl}" style="max-width: 100%; border: 1px dashed #ddd;"> 29 <a href="${imageDataUrl}" download="screen_screenshot.png">下载图片</a> 30 `; 31 }, 100); // 稍微延迟一下,确保第一帧已绘制 32 }; 33 } catch (err) { 34 console.error('获取屏幕媒体流失败:', err); 35 if (err.name === 'NotAllowedError') { 36 alert('用户拒绝了屏幕共享请求。'); 37 } else { 38 alert('屏幕截图失败,请检查浏览器权限。'); 39 } 40 } 41});1<button id="screenCaptureBtn">屏幕截图 (需要用户授权)</button> 2<div id="outputScreen" style="margin-top: 20px;"></div> -
服务器端渲染截图 (Puppeteer / Playwright):
- 原理: 在后端使用无头浏览器(Headless Chrome,如 Puppeteer 或 Playwright)访问你的前端页面,然后调用其 API 进行截图。
- 优点:
- 最强大: 可以截取任何复杂的页面,包括动态内容、JS 动画、跨域资源,因为是在完整的浏览器环境中进行。
- 无需前端处理: 减轻前端负担。
- 解决跨域问题: 截图发生在服务器端,不受前端浏览器同源策略限制。
- 缺点:
- 需要后端服务: 增加了架构复杂性。
- 性能开销: 每次截图都需要启动一个无头浏览器实例,消耗服务器资源。
- 实时性差: 不适合需要用户实时操作并截图的场景。
- 适用场景: 自动化测试、生成网站缩略图、长页面截图、生成海报分享等。
总结:
- 截取页面 DOM 元素并支持大部分 CSS: 推荐使用
html2canvas。 - 截取纯图片(同源): 直接使用 Canvas
drawImage()和toDataURL()。 - 需要截取用户桌面或浏览器窗口(带用户授权): 使用
navigator.mediaDevices.getDisplayMedia()。 - 对截图效果要求极高,或需要截取任意复杂的页面,且可接受后端开销: 使用 服务器端无头浏览器 (Puppeteer)。
选择哪种方法取决于你的具体需求、截图内容以及对性能和用户体验的权衡。
52. 当QPS达到峰值时,该如何处理?
当 QPS (Queries Per Second,每秒查询数) 达到峰值时,系统可能会面临巨大的压力,导致响应变慢、错误增多甚至崩溃。处理 QPS 峰值是一个系统性的问题,需要从前端、后端、数据库、基础设施等多个层面进行优化。
以下是处理 QPS 峰值的常见策略:
前端层面:
-
优化请求频率:
- 防抖 (Debounce) 和 节流 (Throttle): 对于用户触发的高频操作(搜索、输入、滚动、点击),限制请求的发送频率。
- 请求合并/批处理 (Batching): 将短时间内发生的多个相似的小请求合并成一个大请求发送。
- 请求取消 (Request Cancellation): 对于过时的请求(如用户快速输入搜索词),取消之前的未完成请求,只保留最新的。
-
客户端缓存:
- HTTP 缓存: 充分利用
Cache-Control、ETag、Last-Modified等 HTTP 头,让浏览器缓存静态资源和不经常变化的 API 响应。 - 本地存储: 将不敏感、不常变化的数据缓存到
localStorage,sessionStorage或IndexedDB。 - 内存缓存: 在 JavaScript 变量中缓存数据,避免重复请求。
- HTTP 缓存: 充分利用
-
静态资源优化:
- CDN: 将静态资源放到 CDN 上,分发到离用户最近的节点,减轻源服务器压力。
- 压缩: 启用 Gzip/Brotli 压缩,减小传输文件大小。
- 图片优化: 懒加载、WebP 格式、图片压缩、响应式图片。
-
UI 优化 (感知性能):
- 骨架屏/加载动画: 在数据加载时提供视觉反馈,减少用户等待焦虑。
- 渐进式渲染: 优先加载和渲染页面关键部分。
- 离线缓存 (Service Worker): 利用 Service Worker 缓存静态资源和 API 响应,实现离线访问或更快加载速度。
后端层面:
-
增加并发处理能力 (横向扩展):
- 负载均衡 (Load Balancer): 将请求分发到多个后端服务器实例,分摊压力。
- 自动化扩缩容: 根据 QPS 或 CPU/内存使用率,自动增加或减少服务器实例。
-
优化代码和算法:
- 性能分析: 使用 APM (Application Performance Monitoring) 工具定位代码中的性能瓶颈。
- 缓存:
- Nginx 缓存: 代理层缓存。
- Redis/Memcached: 缓存热点数据、查询结果、会话信息等。
- Guava Cache/Ehcache (Java): 应用内部缓存。
- 异步化: 将非核心、耗时长的操作(如发送邮件、生成报表)异步化,通过消息队列 (MQ) 处理。
- 代码优化: 优化算法复杂度、减少不必要的计算、使用高性能数据结构。
-
数据库优化:
- 读写分离: 将读请求和写请求分发到不同的数据库实例,减轻主库压力。
- 分库分表: 水平拆分数据,分散读写压力。
- 索引优化: 确保查询语句使用了合适的索引。
- 慢查询优化: 定期检查慢查询日志并优化。
- 缓存: 使用 Redis 等缓存数据库缓存查询结果。
- 数据库连接池: 合理配置连接池大小,避免过多或过少的连接。
-
系统架构优化:
- 微服务/服务化: 将单体应用拆分成多个独立的服务,便于独立扩缩容和优化。
- 消息队列 (MQ): 解耦服务,削峰填谷,平滑处理突发流量。
- 降级和限流:
- 限流 (Rate Limiting): 限制单位时间内允许通过的请求数量,保护后端服务不被压垮。可以基于 IP、用户 ID、接口等维度。
- 降级 (Degradation): 在系统负载过高时,暂时关闭部分非核心功能,保证核心功能的可用性。例如,暂停评论、推荐功能。
- 熔断 (Circuit Breaking): 当某个服务出现故障时,暂停对该服务的调用,避免雪崩效应。
- 超时设置: 合理设置各种请求的超时时间,避免请求长时间阻塞。
-
网络和基础设施:
- 带宽升级: 增加服务器或数据中心的网络带宽。
- 网络设备优化: 使用高性能路由器、交换机。
- 防火墙和 DDOS 防护: 抵御恶意攻击。
应急处理:
- 监控和报警: 确保有完善的监控系统,并在 QPS 达到阈值时及时报警。
- 应急预案: 提前制定处理峰值的应急预案,包括降级策略、扩容步骤、数据库连接限制等。
- 快速回滚: 如果新版本导致性能问题,能够迅速回滚到稳定版本。
- 人工干预: 在自动化扩缩容和降级无法满足需求时,进行人工介入。
处理 QPS 峰值是一个持续优化的过程,需要不断地测试、监控和调整,以确保系统在高并发场景下的稳定性和可用性。
53. js超过Number最大值的数怎么处理?
在 JavaScript 中,Number 类型是双精度浮点数,它能精确表示的最大安全整数是 2^53 - 1,即 9007199254740991。这个值可以通过 Number.MAX_SAFE_INTEGER 获取。
当处理超过 Number.MAX_SAFE_INTEGER 的整数时,JavaScript 的 Number 类型会开始丢失精度,导致计算结果不准确。
1console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991 2console.log(Number.MAX_SAFE_INTEGER + 1); // 9007199254740992 3console.log(Number.MAX_SAFE_INTEGER + 2); // 9007199254740992 (!!! 精度丢失)
为了处理超过此限制的大整数,JavaScript 提供了 BigInt 类型。
BigInt 类型:
- 定义:
BigInt是一种新的原始数据类型,可以表示任意精度的整数。 - 创建:
- 在整数后面加上
n后缀:123n - 使用
BigInt()构造函数:BigInt("123")
- 在整数后面加上
- 用途: 主要用于需要处理非常大的整数,如加密、时间戳、高精度计算中的 ID 等。
如何处理大数:
-
使用
BigInt:- 当你知道或预期某个数字可能超过
Number.MAX_SAFE_INTEGER时,就应该使用BigInt来表示和操作它。 - 注意:
BigInt不能和Number类型混合运算。如果你需要将BigInt和Number进行运算,必须先将其中一个转换为另一个类型(通常是将Number转换为BigInt)。 typeof 1n返回'bigint'。
1const largeNumber = 9007199254740991n; // 使用 n 后缀创建 BigInt 2console.log(largeNumber + 1n); // 9007199254740992n (精确) 3console.log(largeNumber + 2n); // 9007199254740993n (精确) 4 5const anotherLargeNumber = BigInt("900719925474099123456789"); // 从字符串创建 6console.log(anotherLargeNumber); 7 8// 混合运算会报错 9// console.log(10n + 5); // TypeError: Cannot mix BigInt and other types 10 11// 转换后运算 12console.log(10n + BigInt(5)); // 15n 13console.log(Number(10n) + 5); // 15 - 当你知道或预期某个数字可能超过
-
输入和输出处理:
- 从后端接收: 后端传来的大整数通常以字符串形式表示,因为 JSON 无法直接表示
BigInt。前端接收后,需要使用BigInt()构造函数将其转换为BigInt类型进行处理。 - 发送到后端:
BigInt类型不能直接 JSON 序列化。在发送给后端之前,需要将其转换为字符串。1const obj = { id: 12345678901234567890n }; 2// JSON.stringify(obj) 会报错 TypeError: Do not know how to serialize a BigInt 3// 需要自定义序列化 4const serialized = JSON.stringify(obj, (key, value) => 5 typeof value === 'bigint' ? value.toString() : value 6); 7console.log(serialized); // {"id":"12345678901234567890"}
- 从后端接收: 后端传来的大整数通常以字符串形式表示,因为 JSON 无法直接表示
-
兼容性考虑:
BigInt是 ES2020 特性,现代浏览器和 Node.js 版本(10.4.0+)都支持。对于需要支持旧版本浏览器或 Node.js 的项目,可能需要使用 Polyfill 或专门的大数处理库。
-
旧版浏览器兼容方案 (在
BigInt出现之前):
在BigInt出现之前,如果需要处理大整数,通常使用以下第三方库:js-big-decimalbignumber.jsdecimal.js- 这些库通常将大数存储为字符串,并通过模拟算术运算来实现精确计算。虽然它们比
BigInt更慢,但提供了更好的兼容性。
总结:
在现代 JavaScript 开发中,处理超过 Number.MAX_SAFE_INTEGER 的整数的首选且标准方法是使用 BigInt。它提供了原生的支持和精确的计算能力。在进行 BigInt 和 Number 之间的操作时,务必注意类型转换,并在与后端交互时处理 BigInt 的序列化和反序列化。对于必须兼容旧环境的场景,可以考虑使用第三方大数处理库。
54. 使用同一个链接,如何实现PC打开是web应用、手机打开是-个H5 应用?
使用同一个链接实现 PC 打开是 Web 应用,手机打开是 H5 应用(移动端 Web 应用),通常称为响应式设计 (Responsive Design) 或 自适应布局 (Adaptive Design)。这并非通过“同一个链接指向两个不同的应用”来实现,而是通过在同一个应用内部根据用户设备的特性来呈现不同的 UI 和体验。
核心思想是:一个代码库,多端适配。
具体实现方法包括:
-
CSS 媒体查询 (Media Queries) - 基础且最常用:
这是响应式设计的基石。通过检测视口宽度、高度、设备方向等特性,应用不同的 CSS 样式规则。- 原理: 根据屏幕尺寸的断点,为不同的设备类型提供不同的布局和样式。
- 示例:
1/* 默认样式 (移动端优先) */ 2.container { 3 width: 100%; 4 padding: 10px; 5 font-size: 14px; 6} 7.sidebar { 8 display: none; /* 移动端隐藏侧边栏 */ 9} 10 11/* PC 端 (屏幕宽度大于 768px) */ 12@media (min-width: 768px) { 13 .container { 14 width: 960px; /* PC 端固定宽度 */ 15 margin: 0 auto; 16 padding: 20px; 17 font-size: 16px; 18 display: flex; /* 开启 Flexbox 布局 */ 19 } 20 .sidebar { 21 display: block; /* PC 端显示侧边栏 */ 22 width: 200px; 23 margin-right: 20px; 24 } 25 .main-content { 26 flex: 1; /* 主内容占据剩余空间 */ 27 } 28} - 优点: 纯 CSS 实现,性能好,浏览器原生支持。
- 缺点: 只能控制样式和布局,无法控制逻辑和组件的加载。
-
JavaScript 判断设备类型并渲染不同组件/逻辑:
当仅靠 CSS 无法满足需求时(例如,移动端和 PC 端的功能模块、组件结构或交互逻辑差异较大),可以使用 JavaScript 在运行时判断设备。- 原理:
- 通过
navigator.userAgent判断是移动设备还是 PC。 - 通过
window.innerWidth/window.matchMedia判断屏幕尺寸。 - 根据判断结果,动态渲染不同的 React/Vue 组件树,或执行不同的 JS 逻辑。
- 通过
- 示例:
1// device.js 2function isMobileDevice() { 3 const userAgent = navigator.userAgent || navigator.vendor || window.opera; 4 return /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|rim)|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(userAgent) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|abi|ah(g|w)|al(av|ca)|amoi|an(d|od)|at(t|u)|av(o|p|td)|az(w|nd)|bata|be(kt|pf)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw(n|v)|c55|capi|ccwa|cdm|cell|chtm|cldc|cmd|co(mp|nd)|craw|da(v|do)|dc(f|g|u)|dica|dmob|do(c|p)o|ds(12|d)|el(49|60|1|2)|er(01|e)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(|_)|g1 u|g560|gene|gf5|gmo|go(\.w|od)|gr(ad|un)|haie|hcit|hd(m|p|t)|hei|hi(pt|ta)|hp( i|ip)|hs(c|t)|ht(c(|o|v)|ra|wo)|hwc|i(by|go)|iep|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt|klon|kpt |kwc|kyo(c|k)|le(no|xi)|lg( g|ga)|lmw|lt(e5|t)m|lyg |m1-w|m3ga|m50/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(0d|ob|ph|sn|st)|mt(t5|vc)|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|g1)|pl(ay|pn)|po(ck|rt|ze)|psio|ptg|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55/|sa(ge|ma|mm|ms|ny|va)|sc(01|h|ng)|tdg |tel(i|m)|tim-|t-mo|tkwa|tcl|tp(lm|ow)|tr(ig|sy)|ts70|ttg1|tw(g|l)|ul(no|v|w)|uno|up(\.b|e(i|m)|ly|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|7[0-1])|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(userAgent.substr(0,4)); 5} 6 7// App.js (React 示例) 8function App() { 9 const isMobile = isMobileDevice(); // 或者使用 window.matchMedia 10 11 if (isMobile) { 12 return <MobileApp />; // 渲染 H5 应用组件 13 } else { 14 return <PCApp />; // 渲染 Web 应用组件 15 } 16} 17 18// MobileApp.js 19function MobileApp() { 20 return <div>这是移动端 H5 应用界面</div>; 21} 22 23// PCApp.js 24function PCApp() { 25 return <div>这是 PC 端 Web 应用界面</div>; 26}- 优点: 可以实现更复杂的 UI 和逻辑差异。
- 缺点: 初始加载可能包含两套代码(除非按需加载),增加包体积。设备判断可能不完全准确。
- 原理:
-
视口元标签 (
<meta name="viewport">):
这是移动端 H5 应用的基础。正确设置视口元标签是确保 H5 页面在移动设备上正确缩放和显示的关键。1<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">width=device-width:将视口宽度设置为设备的物理宽度。initial-scale=1.0:页面首次加载时,不进行缩放。user-scalable=no:禁止用户缩放页面(根据需求决定是否添加)。
-
CSS 预处理器 (如 Sass/Less):
可以结合媒体查询,管理和组织不同断点下的样式,提高 CSS 的可维护性。 -
图像和媒体优化:
- 响应式图片 (
srcset,sizes,<picture>): 根据设备屏幕尺寸和分辨率加载不同大小的图片。 - SVG 图标: 矢量图,无限缩放不失真。
- 响应式图片 (
总结:
实现“同一个链接,PC 和手机展示不同应用”的最佳实践是:构建一个统一的前端应用,然后结合以下策略,在运行时根据设备特性提供差异化的 UI 和体验:
- CSS 媒体查询: 处理绝大部分布局和样式差异。
- JavaScript 设备判断: 处理功能、组件结构或交互逻辑上的较大差异,必要时进行组件的条件渲染或懒加载。
- 正确的视口设置: 确保移动端显示正常。
- CDN 和静态资源优化: 提高加载速度。
这种方法避免了维护两套独立的部署和代码库,降低了开发和维护成本,同时提供了良好的用户体验。
55. 如何保证用户的使用体验
保证用户的使用体验(User Experience, UX)是一个综合性的工程,涵盖了从产品设计、前端开发、后端性能到运维监控的多个方面。以下是前端开发者在各个阶段可以着重关注的关键点:
1. 快速加载与响应 (Performance)
- 优化首屏加载时间 (FCP, LCP):
- 减少请求数量: 合并、雪碧图、HTTP/2/3 多路复用。
- 压缩资源: Gzip/Brotli 压缩 HTML/CSS/JS。
- 代码分割 (Code Splitting): 按需加载组件或页面模块。
- 图片优化: 懒加载、WebP 格式、图片压缩、CDN。
- CDN 加速: 静态资源分发。
- 减少关键渲染路径阻塞: CSS 放头部,JS 延迟加载 (
defer,async)。 - 预加载/预渲染 (Preload/Pre-render): 预加载关键资源,或预渲染首屏内容。
- 减少交互延迟 (FID, TTI):
- 避免长任务: 分割耗时长的 JavaScript 任务,避免阻塞主线程。
- Web Workers: 将计算密集型任务放入 Web Workers,不阻塞 UI。
- 优化事件处理: 节流、防抖高频事件。
- 动画优化: 使用
transform和opacity进行动画(GPU 加速),避免触发重排重绘。 - 虚拟列表/无限滚动: 优化大数据量列表渲染。
2. 流畅的动画与交互 (Responsiveness & Smoothness)
- 60 FPS 目标: 确保动画和滚动流畅,达到每秒 60 帧。
- CSS 动画优先: 优先使用 CSS
transition和animation,利用 GPU 加速。 requestAnimationFrame: 用于基于帧的动画和 DOM 操作,确保在浏览器下一次重绘前执行。- 避免强制同步布局 (Layout Thrashing): 避免在循环中读写 DOM 属性。
3. 友好的界面设计与导航 (UI/UX Design)
- 一致性: 保持界面风格、交互模式、字体、颜色等在整个应用中的一致性。
- 清晰的导航: 易于理解和使用的导航结构,让用户知道“我在哪里”和“我能去哪里”。
- 可访问性 (Accessibility): 遵循 WCAG 标准,支持键盘导航、屏幕阅读器,确保所有用户(包括残障人士)都能正常使用。使用 ARIA 属性、语义化 HTML。
- 视觉反馈: 在用户操作后提供即时反馈(如点击按钮后的加载状态、表单验证提示)。
- 错误提示: 清晰、友好的错误提示,指导用户解决问题,而不是简单地抛出错误码。
- 响应式布局: 确保应用在不同设备(PC、平板、手机)和屏幕尺寸下都能良好显示和交互。
- 可预期性: 用户的操作行为应有可预期的结果,避免“惊喜”。
4. 可用性与可理解性 (Usability & Learnability)
- 直观操作: 尽可能减少用户学习成本,让操作直观。
- 减少用户思考: 信息展示清晰,流程设计合理,减少用户决策负担。
- 表单优化: 清晰的标签、合适的输入类型、实时验证、自动填充。
- 国际化 (i18n) 与本地化 (l10n): 支持多语言和地区特性。
5. 稳定与可靠 (Reliability)
- 错误处理: 健壮的错误捕获(JS 错误、API 错误、资源加载错误)和优雅降级。
- 接口稳定性: 与后端团队协作,确保 API 稳定、响应及时。
- 离线体验: 考虑使用 Service Worker 提供离线缓存或离线能力,增强网络不稳定时的体验。
- 监控与报警: 建立完善的前端监控系统,实时发现并解决线上性能和错误问题。
- 数据一致性: 确保前端展示的数据与后端数据一致。
6. 持续优化 (Continuous Improvement)
- 数据分析: 利用埋点、用户行为分析工具(GA, Hotjar 等)收集用户数据,发现痛点和优化空间。
- A/B Test: 对新功能或设计进行 A/B Test,验证用户接受度和效果。
- 用户反馈: 建立用户反馈渠道,重视用户意见,并及时响应和解决问题。
- 技术债务管理: 定期进行代码重构,保持代码质量。
- 性能审计: 定期使用 Lighthouse, WebPageTest 等工具进行性能审计。
通过在设计、开发和维护的各个环节贯彻这些原则和实践,才能构建出真正让用户满意、乐于使用的前端应用。
56. 如何解决页面请求接口大规模并发问题
页面请求接口大规模并发问题通常发生在以下场景:
- 初始化加载: 页面首次加载时需要获取大量数据。
- 列表刷新/筛选: 用户触发刷新或筛选操作,需要重新请求多项数据。
- 批量操作: 用户执行一个批量操作,导致同时发送多个请求。
- 实时数据: 需要频繁轮询或 WebSocket 保持连接。
大规模并发请求可能导致:
- 浏览器连接数限制: 浏览器对同一域名下的并发请求数有上限(HTTP/1.1 通常是 6-8 个)。超出部分会排队,导致请求延迟。
- 网络带宽饱和: 大量请求同时占用带宽,导致单个请求速度变慢。
- 服务器压力: 短时间内收到大量请求,可能导致服务器过载、响应变慢甚至崩溃。
- 用户体验差: 页面卡顿、数据加载慢。
解决这些问题需要从前端、后端和架构层面进行综合优化:
前端层面:
-
请求合并/批处理 (Batching):
- 原理: 将在短时间内对相同或类似资源发起的多个独立请求,合并成一个大请求发送到后端。后端处理后,将结果统一返回。
- 实现: 前端维护一个请求队列,设定一个时间窗口(如 50ms),在窗口内收集所有待发送的请求,然后一次性发送。
- 优点: 显著减少 HTTP 请求次数,降低网络开销。
- 缺点: 需要后端支持批处理接口。
-
请求去重 (Deduplication):
- 原理: 当短时间内多次发起完全相同的请求时,只发送一次,并将所有调用都指向同一个 Promise。
- 实现: 维护一个 Map 存储进行中的请求 Promise,如果请求已存在,则返回该 Promise。
- 优点: 避免重复请求相同数据。
- 示例:
1const pendingRequests = new Map(); 2 3async function safeFetch(url, options) { 4 if (pendingRequests.has(url)) { 5 console.log(`请求 ${url} 正在进行中,返回现有 Promise`); 6 return pendingRequests.get(url); 7 } 8 9 const promise = fetch(url, options) 10 .then(response => { 11 if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 12 return response.json(); 13 }) 14 .finally(() => { 15 pendingRequests.delete(url); // 请求结束后从 Map 中移除 16 }); 17 18 pendingRequests.set(url, promise); 19 return promise; 20} 21 22// 调用示例 23safeFetch('/api/data/1'); 24safeFetch('/api/data/1'); // 10ms 后再次调用,不会再次发起请求
-
请求取消 (Cancellation):
- 原理: 当新的请求发起时,取消之前未完成的旧请求(尤其是在用户快速输入或切换 Tab 导致旧数据过时时)。
- 实现: 使用
AbortController(Fetch API) 或 Axios 的取消功能。 - 优点: 节省带宽,避免处理过期数据。
- 缺点: 无法阻止后端接收到被取消的请求,只是前端不再处理其响应。
-
并发控制 (Concurrency Limit):
- 原理: 限制同时进行中的网络请求数量,例如,只允许同时发送 5 个请求,其他请求排队等待。
- 实现: 使用 Promise 队列或类似
p-limit这样的库。 - 优点: 避免浏览器并发限制和网络拥堵。
- 缺点: 可能会增加总的完成时间。
-
数据缓存 (Caching):
- HTTP 缓存: 配合后端设置
Cache-Control、ETag等头,利用浏览器缓存。 - 内存/Local Storage 缓存: 对于不经常变化的数据,在客户端缓存,并设置过期时间。
- Service Worker 缓存: 拦截请求,实现更灵活的缓存策略(如缓存优先、网络优先)。
- HTTP 缓存: 配合后端设置
-
懒加载/延迟加载 (Lazy Loading/Deferred Loading):
- 原理: 只加载当前视口或用户需要的数据,其他数据待用户滚动或触发后再加载。
- 实现: 列表滚动加载、Tab 切换时按需加载内容。
后端/架构层面:
-
横向扩展 (Scaling Out):
- 增加服务器实例: 通过负载均衡器将流量分发到更多应用服务器,提高整体吞吐量。
-
缓存层:
- CDN: 用于静态资源。
- 反向代理缓存 (Nginx Cache): 缓存热门 API 响应。
- 分布式缓存 (Redis/Memcached): 缓存热点数据和计算结果,减少数据库压力。
-
数据库优化:
- 读写分离、分库分表: 分散数据库压力。
- 索引优化、慢查询优化。
-
异步处理和消息队列 (MQ):
- 原理: 将耗时、非实时的操作(如数据统计、邮件发送、图片处理)放入消息队列,由异步消费者处理,避免阻塞主业务流程。
- 优点: 削峰填谷,提高接口响应速度。
-
限流 (Rate Limiting) 和 降级 (Degradation):
- 限流: 在 API 网关或服务器端限制单位时间内允许的请求数量,防止系统过载。
- 降级: 在高压情况下,关闭部分非核心功能,保证核心业务的可用性。
-
CDN / 全球加速:
- 对于全球用户,将接口也通过 CDN 或全球加速网络进行加速,减少网络延迟。
-
连接池优化:
- 后端数据库连接池、HTTP 客户端连接池等配置合理,避免资源耗尽。
综合解决方案:
解决大规模并发问题通常是上述多种策略的组合。例如:
- 前端使用防抖/节流、请求去重减少不必要的请求。
- 前端使用客户端缓存减少对后端的依赖。
- 前端通过并发控制避免浏览器连接数瓶颈。
- 后端使用负载均衡和横向扩容提升处理能力。
- 后端通过多级缓存减少数据库压力。
- 后端通过消息队列异步处理非核心业务。
- 后端通过限流和降级保护系统核心功能。
通过这些手段,可以构建一个在峰值流量下依然稳定可靠的系统。
57. 设计一套全站请求耗时统计工具
设计一套全站请求耗时统计工具,旨在收集、上报和分析页面中所有网络请求(包括页面主文档、静态资源、API 请求等)的加载时间,以发现性能瓶颈。
这套工具应包含以下几个核心组成部分:
- 数据采集 (Client-side Data Collection)
- 数据上报 (Data Reporting)
- 数据存储与处理 (Backend Storage & Processing)
- 数据可视化与分析 (Visualization & Analysis)
- 报警 (Alerting)
1. 数据采集 (Client-side Data Collection)
主要利用浏览器原生的 Performance API,它是最精确和推荐的方式。
-
核心 API:
PerformanceObserver监听resource和navigation类型的性能条目。 -
采集内容:
- 主文档加载耗时:
PerformanceNavigationTimingfetchStart: 请求开始时间responseStart: 收到响应的第一个字节responseEnd: 收到响应的最后一个字节domContentLoadedEventEnd: DOMContentLoaded 事件结束loadEventEnd: 页面所有资源加载完成(load 事件结束)- TTFB (Time To First Byte):
responseStart - requestStart - 页面加载总耗时 (Load Time):
loadEventEnd - fetchStart
- 所有静态资源加载耗时:
PerformanceResourceTimingname: 资源 URLinitiatorType: 资源发起者类型 (如img,script,fetch,xmlhttprequest)duration:responseEnd - startTime(总耗时)dnsLookup:domainLookupEnd - domainLookupStarttcpConnect:connectEnd - connectStartrequestResponse:responseEnd - requestStart(请求到响应完成)decodedBodySize: 资源解码后大小transferSize: 资源传输大小
- API 请求耗时: 可以通过
PerformanceResourceTiming捕获initiatorType为fetch或xmlhttprequest的条目。或者,为了更精确和加入业务信息,可以封装fetch/XMLHttpRequest。- 在请求拦截器中记录开始时间,在响应/错误拦截器中记录结束时间、状态码、错误信息。
- 上报 API URL、请求方法、状态码、耗时、成功/失败标志、请求参数(部分)、响应体大小(部分)。
- 主文档加载耗时:
-
实现示例 (JS 代码片段):
1// 假设这是你的监控 SDK 入口 2class PerformanceMonitor { 3 constructor(reportUrl) { 4 this.reportUrl = reportUrl; 5 this.resourceQueue = []; // 队列存储待上报的资源数据 6 this.apiQueue = []; // 队列存储待上报的 API 数据 7 this.initObservers(); 8 this.setupApiInterception(); 9 this.setupUnloadReporting(); 10 } 11 12 // 1. 监听性能事件 (资源和导航) 13 initObservers() { 14 // 监听资源加载 15 const resourceObserver = new PerformanceObserver((list) => { 16 list.getEntries().forEach(entry => { 17 if (entry.entryType === 'resource' && !this.isIgnoredResource(entry.name)) { 18 this.resourceQueue.push(this.formatResourceEntry(entry)); 19 } 20 }); 21 }); 22 resourceObserver.observe({ entryTypes: ['resource'], buffered: true }); 23 24 // 监听导航加载 25 const navigationObserver = new PerformanceObserver((list) => { 26 list.getEntries().forEach(entry => { 27 if (entry.entryType === 'navigation') { 28 this.resourceQueue.push(this.formatNavigationEntry(entry)); 29 navigationObserver.disconnect(); // 导航事件只发生一次 30 } 31 }); 32 }); 33 navigationObserver.observe({ entryTypes: ['navigation'], buffered: true }); 34 35 // 可以添加 LCP, FID, CLS 等 Web Vitals 观察器 36 // new PerformanceObserver((list) => { ... }).observe({ entryTypes: ['largest-contentful-paint'] }); 37 } 38 39 // 2. 封装 API 请求 (Fetch/XHR) 40 setupApiInterception() { 41 // 劫持 Fetch 42 const originalFetch = window.fetch; 43 window.fetch = async (...args) => { 44 const url = args[0]; 45 const requestStart = performance.now(); 46 try { 47 const response = await originalFetch.apply(this, args); 48 const requestEnd = performance.now(); 49 const duration = requestEnd - requestStart; 50 this.apiQueue.push(this.formatApiEntry(url, response, duration, 'fetch', true)); 51 return response; 52 } catch (error) { 53 const requestEnd = performance.now(); 54 const duration = requestEnd - requestStart; 55 this.apiQueue.push(this.formatApiEntry(url, null, duration, 'fetch', false, error.message)); 56 throw error; 57 } 58 }; 59 60 // 劫持 XMLHttpRequest (类似逻辑,更复杂一些) 61 // const originalXHR = window.XMLHttpRequest; 62 // window.XMLHttpRequest = function() { ... }; 63 } 64 65 // 3. 格式化数据 66 formatResourceEntry(entry) { 67 return { 68 type: 'resource', 69 url: entry.name, 70 initiatorType: entry.initiatorType, 71 duration: parseFloat(entry.duration.toFixed(2)), 72 decodedBodySize: entry.decodedBodySize, 73 transferSize: entry.transferSize, 74 // 更多详细时间点 75 dnsLookup: parseFloat((entry.domainLookupEnd - entry.domainLookupStart).toFixed(2)), 76 tcpConnect: parseFloat((entry.connectEnd - entry.connectStart).toFixed(2)), 77 ttfb: parseFloat((entry.responseStart - entry.requestStart).toFixed(2)), 78 contentDownload: parseFloat((entry.responseEnd - entry.responseStart).toFixed(2)), 79 }; 80 } 81 82 formatNavigationEntry(entry) { 83 return { 84 type: 'navigation', 85 url: entry.name, 86 duration: parseFloat(entry.duration.toFixed(2)), 87 domReady: parseFloat((entry.domContentLoadedEventEnd - entry.fetchStart).toFixed(2)), 88 loadTime: parseFloat((entry.loadEventEnd - entry.fetchStart).toFixed(2)), 89 ttfb: parseFloat((entry.responseStart - entry.requestStart).toFixed(2)), 90 // ... 其他导航指标 91 }; 92 } 93 94 formatApiEntry(url, response, duration, initiatorType, success, errorMessage = '') { 95 return { 96 type: 'api', 97 url: url, 98 method: response?.request?.method || 'GET', // 需要从 request 对象中获取 99 status: response?.status || 0, 100 duration: parseFloat(duration.toFixed(2)), 101 success: success, 102 errorMessage: errorMessage, 103 initiatorType: initiatorType, 104 // ... 更多参数,如请求体大小、响应体大小、traceId 105 }; 106 } 107 108 isIgnoredResource(url) { 109 // 排除掉上报地址本身,以及一些不关心的第三方监控或广告资源 110 return url.includes(this.reportUrl) || url.includes('google-analytics.com'); 111 } 112 113 // 4. 数据上报 (批量 + 延迟) 114 reportData() { 115 const dataToReport = { 116 pageUrl: window.location.href, 117 userAgent: navigator.userAgent, 118 timestamp: Date.now(), 119 // 可以添加用户ID、sessionId等上下文信息 120 resources: [...this.resourceQueue], 121 apis: [...this.apiQueue], 122 // webVitals: this.webVitalsData // 如果有 Web Vitals 123 }; 124 // 清空队列 125 this.resourceQueue = []; 126 this.apiQueue = []; 127 128 const blob = new Blob([JSON.stringify(dataToReport)], { type: 'application/json' }); 129 if (navigator.sendBeacon) { 130 navigator.sendBeacon(this.reportUrl, blob); 131 console.log('Performance data sent via sendBeacon.'); 132 } else { 133 // 降级方案:XMLHttpRequest 或 fetch,但可能阻塞或失败 134 fetch(this.reportUrl, { 135 method: 'POST', 136 body: blob, 137 keepalive: true // 尝试保持连接,提高在页面卸载时发送的成功率 138 }).then(response => { 139 if (!response.ok) console.error('Failed to send performance data:', response.statusText); 140 }).catch(e => console.error('Error sending performance data:', e)); 141 console.log('Performance data sent via Fetch.'); 142 } 143 } 144 145 // 5. 设置上报时机 146 setupUnloadReporting() { 147 // 在页面卸载时上报所有积压数据 148 window.addEventListener('beforeunload', () => { 149 this.reportData(); 150 }); 151 152 // 也可以设置定时上报,防止用户不关闭页面 153 // setInterval(() => { 154 // if (this.resourceQueue.length > 0 || this.apiQueue.length > 0) { 155 // this.reportData(); 156 // } 157 // }, 30 * 1000); // 每 30 秒上报一次 158 } 159} 160 161// 初始化监控工具 162new PerformanceMonitor('/api/performance_report');
2. 数据上报 (Data Reporting)
navigator.sendBeacon()(推荐): 在页面卸载时发送少量数据,不阻塞页面,可靠性高。- Image Beacon (
new Image().src = ...): 用于发送少量数据,作为 GET 请求,不会阻塞页面。但 GET 请求有 URL 长度限制,不适合发送复杂数据。 fetch()或XMLHttpRequest: 正常异步请求,但如果在页面卸载时发送,可能因为页面被关闭而失败。fetch的keepalive: true选项可以提高其在卸载时发送的成功率。
3. 数据存储与处理 (Backend Storage & Processing)
- 接收服务: 后端服务接收上报的数据。
- 数据清洗: 对接收到的数据进行清洗、过滤和校验。
- 数据存储:
- 时序数据库 (Time-Series Database): 如 InfluxDB, Prometheus, OpenTSDB。适合存储大量、带有时间戳的监控数据。
- NoSQL 数据库: 如 MongoDB。如果数据结构复杂或需要更灵活的查询。
- ELK 栈 (Elasticsearch, Logstash, Kibana): 用于日志收集、存储和分析,也可用于结构化数据。
- 数据聚合: 对原始数据进行聚合计算,如平均耗时、TP90/TP99、成功率、错误率等。
4. 数据可视化与分析 (Visualization & Analysis)
- 仪表盘: 使用 Grafana, Kibana, Superset 或自定义前端页面构建数据仪表盘。
- 概览: 页面加载时间趋势、错误率趋势、QPS 趋势。
- 详细视图: 资源瀑布图、API 耗时排行榜、慢请求详情。
- 多维度分析: 按浏览器、操作系统、地区、用户 ID、页面 URL 等维度进行筛选和分析。
- 漏斗分析: 结合用户行为数据,分析用户在关键流程上的性能瓶颈。
5. 报警 (Alerting)
- 设置阈值: 为关键指标设置报警阈值(如 FCP 超过 2s、LCP 超过 2.5s、错误率超过 1%)。
- 报警渠道: 集成邮件、短信、钉钉、企业微信等通知渠道。
- 异常发现: 及时发现性能下降、错误率飙升等异常情况。
额外考量:
- 数据量和抽样: 对于高流量网站,原始数据量可能非常庞大。可以考虑进行数据抽样上报,例如只上报 1% 或 10% 的请求,或只上报错误请求和慢请求。
- 用户 ID/Session ID: 将请求耗时数据与用户 ID、Session ID 关联,以便追溯单个用户的体验路径。
- Source Map: 对于错误堆栈的还原,后端需要部署 Source Map。
- 隐私和合规性: 确保不收集或存储敏感用户数据,并遵守相关法律法规。
- 性能影响: 监控工具本身不能对页面性能造成显著影响。代码需要轻量化,上报异步化。
设计一个健壮的全站请求耗时统计工具是一个复杂的项目,但对于维护和优化大型前端应用至关重要。
58. 大文件上传了解多少
大文件上传是一个常见的挑战,尤其是在网络不稳定、文件体积大(几百 MB 到几 GB 甚至更大)或需要支持断点续传的场景。它需要前端和后端协同工作来解决传统单文件上传的痛点。
传统单文件上传的痛点:
- 上传失败率高: 网络波动、中断或服务器超时可能导致整个文件上传失败,需要从头重传。
- 用户体验差: 无法显示精确进度,上传耗时过长。
- 内存占用高: 浏览器和服务器需要一次性将整个文件载入内存,可能导致内存溢出或性能问题。
- 无法并发: 单个文件无法利用多核 CPU 或多连接优势。
- 服务端处理压力: 服务器需要处理大量文件数据的同时写入,可能导致 I/O 瓶颈。
大文件上传解决方案的核心思想:切片上传 (Chunked Upload) + 断点续传 (Resumable Upload)。
1. 切片上传 (Chunked Upload)
- 原理: 将大文件在前端分割成多个小的、固定大小(如 1MB、5MB、10MB)的数据块(切片),然后逐个或并发地将这些切片上传到服务器。
- 前端实现:
- 文件读取: 使用
File对象的slice()方法(或webkitSlice,mozSlice,现在已统一为slice)来读取文件的特定字节范围,创建Blob或File切片。 - 计算哈希: 对每个切片或整个文件计算哈希值(如 MD5、SHA-256),用于:
- 校验完整性: 确保切片在传输过程中没有损坏。
- 秒传: 检查服务器是否已存在相同哈希的文件。
- 断点续传: 标记已上传的切片。
- 发送请求: 每个切片通过独立的
XMLHttpRequest或fetch请求发送到后端。通常使用FormData携带切片数据和元信息(文件哈希、切片哈希、切片索引、总切片数等)。 - 并发控制: 前端限制并发上传的切片数量(例如同时上传 3-6 个切片),避免超出浏览器连接数限制或拥堵网络。
- 进度显示: 根据已上传切片数量和已上传字节数,实时更新上传进度。
- 文件读取: 使用
- 后端实现:
- 接收切片: 接收每个切片,并根据切片元信息将其存储到临时目录。
- 校验: 验证切片哈希,确保数据完整。
- 合并: 当所有切片上传完成后,后端会根据文件哈希和切片索引,将所有切片按顺序合并成一个完整的文件。
- (可选)秒传接口: 提供一个接口,接收前端传来的文件整体哈希,如果服务器已存在,则直接返回成功。
2. 断点续传 (Resumable Upload)
- 原理: 允许在上传中断后,从上次中断的地方继续上传,而不是从头开始。
- 实现方式:
- 前端记录: 在客户端(如
localStorage或IndexedDB)存储文件的唯一标识(如文件哈希),以及已上传的切片索引列表。 - 上传前校验: 每次上传前,前端先向后端发送一个请求,告知文件唯一标识,询问后端该文件的哪些切片已经成功接收。
- 后端记录: 后端维护已上传切片的信息,例如在数据库或文件系统中记录每个文件已完成的切片索引。
- 继续上传: 前端只发送那些未上传成功的切片。
- 前端记录: 在客户端(如
3. 秒传 (Instant Upload)
- 原理: 当用户上传一个文件时,如果服务器已经存在一个内容完全相同的文件,则无需再次上传文件内容,直接标记为上传成功。
- 实现:
- 前端计算文件哈希: 在文件开始上传前(通常是文件选择后),计算整个文件的哈希值。这可能需要
spark-md5或js-md5等库,并且是计算密集型操作,可能需要Web Worker避免阻塞主线程。 - 后端校验: 前端将文件哈希发送给后端。后端查询文件库,如果发现相同哈希的文件已存在,则直接返回成功,前端跳过上传步骤。
- CDN 存储: 如果文件存储在 CDN,也可以让 CDN 提供哈希校验接口。
- 前端计算文件哈希: 在文件开始上传前(通常是文件选择后),计算整个文件的哈希值。这可能需要
4. 其他优化和考量
- Web Worker: 对于计算文件哈希(特别是大文件)这种耗时的操作,将其放入 Web Worker 中执行,避免阻塞主线程,提升用户体验。
- UI 反馈: 提供清晰的上传进度条、已上传文件列表、错误提示和重试按钮。
- 错误重试机制: 对于单个切片上传失败,前端可以设置重试策略(如指数退避)。
- 文件去重: 除了秒传,也可以通过文件大小、名称等信息进行初步去重,但哈希校验是最可靠的。
- 后端存储: 切片可以存储在文件系统、对象存储(如 AWS S3、阿里云 OSS)等。对象存储天然支持分块上传。
- 安全性: 上传的文件需要进行病毒扫描、格式校验、内容过滤,防止恶意文件上传。
流程概览:
- 选择文件: 用户选择要上传的大文件。
- 前端预处理:
- 计算文件总哈希(可选,用于秒传)。
- 将文件切片,计算每个切片哈希。
- 断点续传检测: 前端带文件总哈希向后端查询已上传的切片列表。
- 秒传检测: 如果文件总哈希与后端已存在的文件哈希匹配,则秒传成功,结束。
- 并发上传切片:
- 只上传未上传的切片。
- 前端控制并发数。
- 每个切片上传成功后,更新进度。
- 切片失败后,进行重试。
- 后端合并: 所有切片上传完成后,前端通知后端进行文件合并。
- 上传完成: 后端返回最终文件 URL。
大文件上传是复杂的前端工程实践,涉及文件处理、网络通信、并发控制、错误重试、后端协作等多个方面。
59. H5如何解决移动端适配问题
H5 (HTML5) 在移动端的适配问题,主要指的是如何让同一个 Web 页面在不同尺寸、不同分辨率的移动设备上都能良好地显示和交互。核心在于响应式设计和单位选择。
以下是解决 H5 移动端适配问题的常见方法:
1. 设置视口 (Viewport)
这是移动端适配的基石,必须正确设置。
1<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
width=device-width: 将布局视口的宽度设置为设备的独立像素(device-independent pixels)宽度,而不是默认的 980px 或 1024px 等。这确保页面内容能够适应设备屏幕宽度。initial-scale=1.0: 定义页面首次加载时的缩放比例。设置为 1.0 可以防止浏览器默认缩放。maximum-scale=1.0, user-scalable=no: (可选,根据需求决定) 禁用用户缩放。在某些场景下可以提升体验,但也会牺牲可访问性。
2. 流式布局 (Liquid/Fluid Layout)
-
原理: 使用百分比 (
%) 作为宽度单位,让元素宽度根据父容器的宽度进行伸缩。 -
优点: 简单直接,可以适应不同宽度的屏幕。
-
缺点: 无法控制高度,如果内容较多或有图片,可能导致元素变形或排版混乱。无法应对复杂布局。
1.container { 2 width: 90%; 3 margin: 0 auto; 4} 5.image { 6 width: 100%; /* 图片宽度自适应父容器 */ 7 height: auto; /* 高度等比例缩放 */ 8}
3. 弹性布局 (Flexbox)
-
原理: CSS3 引入的布局模式,用于在容器中排列项目,使其能自动伸缩和填充可用空间。
-
优点: 强大且灵活,非常适合构建各种复杂且响应式的布局,如导航栏、网格布局等。
-
缺点: 学习曲线相对陡峭,不支持 IE9 及以下版本。
1.flex-container { 2 display: flex; 3 justify-content: space-around; 4 align-items: center; 5} 6.flex-item { 7 flex: 1; /* 每个项目平均分配空间 */ 8 padding: 10px; 9}
4. 栅格布局 (Grid Layout)
-
原理: CSS Grid 布局提供了二维布局系统,可以同时控制行和列,实现更复杂的网格结构。
-
优点: 解决传统布局难以实现的复杂网格问题,布局能力更强。
-
缺点: 学习曲线更陡峭,兼容性略低于 Flexbox (不支持 IE)。
1.grid-container { 2 display: grid; 3 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); /* 自动适应列数 */ 4 gap: 10px; 5} 6.grid-item { 7 /* ... */ 8}
5. 响应式图片 (Responsive Images)
- 原理: 根据设备的屏幕尺寸和分辨率,加载不同大小或分辨率的图片,避免在小屏幕上加载大图片,浪费带宽。
- 实现:
srcset属性:<img src="small.jpg" srcset="medium.jpg 1000w, large.jpg 2000w" alt="图片"><picture>元素: 提供更复杂的控制,如不同格式图片或艺术方向。1<picture> 2 <source srcset="image.webp" type="image/webp"> 3 <source srcset="image.jpg" type="image/jpeg"> 4 <img src="image.jpg" alt="Description"> 5</picture>- CSS
background-image+ 媒体查询:1.hero { 2 background-image: url('small-hero.jpg'); 3} 4@media (min-width: 768px) { 5 .hero { 6 background-image: url('large-hero.jpg'); 7 } 8}
- 优点: 节省带宽,提高加载速度,优化视觉效果。
6. 视口单位 (Viewport Units): vw, vh, vmin, vmax
-
原理: 相对于视口尺寸的单位。
1vw= 视口宽度的 1%1vh= 视口高度的 1%vmin=vw和vh中较小的一个vmax=vw和vh中较大的一个
-
优点: 可以实现与视口尺寸完全同步的布局,适用于文字大小、元素尺寸等。
-
缺点: 在某些特定视口比例下可能出现意想不到的效果,例如横屏手机。
-
常见应用: 与
rem结合用于字体和元素大小适配。1/* 假设设计稿宽度 750px,1rem = 100px */ 2html { 3 font-size: calc(100vw / 7.5); /* 100vw / (750 / 100) = 13.33333vw */ 4} 5/* 此时,设计稿上 20px 的元素,可以直接写 0.2rem */ 6.title { 7 font-size: 0.2rem; /* 对应设计稿 20px */ 8 width: 3rem; /* 对应设计稿 300px */ 9}这种方案可以实现一套代码,在不同手机屏幕下等比缩放。
7. CSS 媒体查询 (Media Queries) - 进阶
-
原理: 根据设备的特性(如
min-width,max-width,orientation,resolution)应用不同的 CSS 规则。 -
优点: 精确控制不同设备或屏幕尺寸下的布局和样式。
-
移动端优先 (Mobile First) 策略: 先编写小屏幕的样式,然后通过
min-width媒体查询逐步添加大屏幕的样式,简化开发。1/* Mobile First 示例 */ 2.header { 3 height: 60px; /* 默认移动端高度 */ 4} 5@media (min-width: 768px) { 6 .header { 7 height: 80px; /* PC 端高度 */ 8 } 9}
8. CSS 逻辑属性 (Logical Properties)
- 原理: 使用
margin-inline-start,padding-block-end等逻辑属性代替margin-left,padding-bottom等物理属性,以支持 RTL (Right-to-Left) 布局方向。 - 优点: 更好地支持国际化,特别是在中东等 RTL 语言地区。
9. JavaScript 动态调整 (较少用,特殊场景)
- 在某些极端复杂或需要根据设备能力(如触摸支持)来加载不同组件的场景,可以使用 JS 判断设备类型、屏幕尺寸、是否支持触摸等,然后动态加载或渲染不同的模块。
- 缺点: 增加 JS 运行时开销,可能导致首屏白屏时间增加。
总结:
H5 移动端适配是一个多技术组合的方案,通常会混合使用:
viewport元标签 (必备)rem+vw视口单位 (用于字体和尺寸等比缩放)- Flexbox 和 Grid 布局 (用于复杂布局)
- 媒体查询 (Media Queries) (用于断点适配和样式差异化)
- 响应式图片 (用于图片资源优化)
通过这些手段,可以构建出在各种移动设备上都能提供良好用户体验的 H5 应用。
60. 站点一键换肤的实现方式有哪些?
站点一键换肤(或主题切换)是指用户可以方便地切换网站的整体视觉风格,例如从“浅色主题”切换到“深色主题”,或切换到不同的品牌主题。实现这种功能主要有以下几种方式:
1. CSS 变量 (Custom Properties / CSS Variables) - 推荐
这是最现代、最灵活、性能最好的方式。
- 原理: 在 CSS 中定义一组变量(例如
--primary-color,--background-color),然后将这些变量应用于你的样式。切换主题时,只需要改变根元素(通常是body或html)上这些 CSS 变量的值,所有使用这些变量的元素样式就会自动更新。 - 实现步骤:
- 定义主题变量: 在
:root(或html)选择器中定义默认主题的 CSS 变量。1/* default-theme.css */ 2:root { 3 --primary-color: #007bff; 4 --background-color: #f8f9fa; 5 --text-color: #212529; 6 --border-color: #dee2e6; 7} 8 9body { 10 background-color: var(--background-color); 11 color: var(--text-color); 12} 13button { 14 background-color: var(--primary-color); 15 color: white; 16 border: 1px solid var(--primary-color); 17} 18/* ... 其他组件使用这些变量 */ - 定义不同主题的变量值: 为其他主题(如“深色主题”)定义一组新的 CSS 变量值,这些变量通常会放在一个特定的类名下(如
.dark-theme)。1/* dark-theme.css (或直接写在同一个 CSS 文件中) */ 2.dark-theme { 3 --primary-color: #6f42c1; /* 深色主题使用紫色 */ 4 --background-color: #343a40; 5 --text-color: #f8f9fa; 6 --border-color: #495057; 7} - JavaScript 切换类名: 通过 JavaScript 动态添加或移除
body或html元素的类名。1// JavaScript 2const body = document.body; 3const themeToggleButton = document.getElementById('theme-toggle'); 4 5themeToggleButton.addEventListener('click', () => { 6 if (body.classList.contains('dark-theme')) { 7 body.classList.remove('dark-theme'); 8 localStorage.setItem('theme', 'light'); 9 } else { 10 body.classList.add('dark-theme'); 11 localStorage.setItem('theme', 'dark'); 12 } 13}); 14 15// 页面加载时读取用户保存的主题偏好 16window.addEventListener('DOMContentLoaded', () => { 17 const savedTheme = localStorage.getItem('theme'); 18 if (savedTheme === 'dark') { 19 body.classList.add('dark-theme'); 20 } 21});
- 定义主题变量: 在
- 优点:
- 实时切换: 颜色、字体等变化无需重新加载页面,动画流畅。
- 代码简洁: 避免大量重复的 CSS 规则。
- 易于维护和扩展: 增加新主题或修改颜色只需修改变量值。
- 性能好: 浏览器原生支持,无需 JS 介入计算。
- 兼容性: IE 11 及以上支持。
2. 多套 CSS 文件按需加载
- 原理: 为每个主题创建一套完整的 CSS 文件,切换主题时,动态加载或切换
<link>标签的href属性。 - 实现步骤:
- 创建多个 CSS 文件:
theme-light.css,theme-dark.css,theme-blue.css等。 - 动态切换
<link>标签:1<link rel="stylesheet" href="css/theme-light.css" id="theme-style">1// JavaScript 2const themeLink = document.getElementById('theme-style'); 3const themeToggleButton = document.getElementById('theme-toggle'); 4 5themeToggleButton.addEventListener('click', () => { 6 const currentTheme = themeLink.href.includes('dark') ? 'dark' : 'light'; 7 if (currentTheme === 'dark') { 8 themeLink.href = 'css/theme-light.css'; 9 localStorage.setItem('theme', 'light'); 10 } else { 11 themeLink.href = 'css/theme-dark.css'; 12 localStorage.setItem('theme', 'dark'); 13 } 14});
- 创建多个 CSS 文件:
- 优点: 兼容性好,支持所有浏览器。
- 缺点:
- 闪烁 (FOUC): 切换主题时,旧样式可能短暂消失,新样式加载过程中页面可能出现无样式内容闪烁。
- 性能开销: 每次切换都需要重新加载整个 CSS 文件。
- 维护成本: 多个 CSS 文件可能导致大量重复代码,不易维护。
3. CSS 预处理器编译 (如 Sass/Less)
- 原理: 在开发阶段,通过 CSS 预处理器定义变量,然后编译出多个主题的 CSS 文件。最终效果与方案 2 类似,只是开发更方便。
- 优点: 方便管理和修改变量。
- 缺点: 最终产物和运行时行为与方案 2 相同。
4. JavaScript 动态修改 Style 属性 (不推荐)
- 原理: 通过 JavaScript 直接修改元素的
style属性来改变颜色等。 - 优点: 实时。
- 缺点:
- 难以维护: 逻辑分散,需要遍历大量元素,代码冗余。
- 性能差: 大量 DOM 操作,可能导致性能问题。
- 优先级问题: 内联样式优先级最高,可能覆盖组件库样式。
- 适用场景: 极少数局部、简单的颜色切换。
5. CSS Modules / Styled-components (React/Vue 等框架中)
- 原理: 在组件级别进行样式管理时,可以通过组件的 Props 或 Context 来决定应用哪组样式。这通常与 CSS 变量或主题对象结合使用。
- 实现:
- 定义一个主题对象或 CSS 变量(如方案 1)。
- 在组件内部,根据传入的
themeprop 或 Context 值来决定渲染不同的 CSS 变量或样式规则。
- 优点: 与组件化开发模式高度契合,样式隔离良好。
- 缺点: 仅适用于组件内部,不适用于全局样式。
总结和推荐:
- 最佳实践:使用 CSS 变量 (Custom Properties)。 它是现代前端实现换肤的黄金标准,提供了卓越的灵活性、性能和可维护性。
- 兼容性要求高: 如果需要支持 IE11 以下的旧浏览器,可能需要考虑多套 CSS 文件按需加载的方案,但要接受其性能和闪烁的缺点。
- 框架集成: 在 React, Vue 等框架中,结合 CSS 变量和其提供的主题 Context/Provider 模式,可以非常优雅地实现换肤。
无论选择哪种方式,都应该在用户选择主题后,将其偏好存储在 localStorage 中,以便用户下次访问时自动加载上次选择的主题。
61. 如何实现网页加载进度条?
网页加载进度条(如 GitHub、YouTube、NProgress 等)通常指的是在页面(或部分内容)加载过程中,显示一个线性的进度条,给用户提供加载状态的视觉反馈,以提升用户体验,缓解等待焦虑。
这种进度条通常不是真实的加载百分比,而是通过模拟或结合真实加载事件来实现。
主要有以下几种实现方式:
-
纯 CSS 动画模拟 (不依赖 JS 进度)
- 原理: 创建一个固定在页面顶部的条状元素,通过 CSS 动画使其宽度从 0 逐渐增长到某个值(如 90%),在页面加载完成后,再快速增长到 100% 并消失。
- 优点: 实现简单,性能好,不阻塞主线程。
- 缺点: 无法反映真实的加载进度,只是一个视觉欺骗,适合在不确定具体进度时使用。
示例:
1<!DOCTYPE html> 2<html lang="zh-CN"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>CSS Loading Bar</title> 7 <style> 8 body { margin: 0; font-family: sans-serif; } 9 10 .loading-bar-container { 11 position: fixed; 12 top: 0; 13 left: 0; 14 width: 100%; 15 height: 3px; /* 进度条高度 */ 16 background-color: transparent; /* 容器背景透明 */ 17 z-index: 9999; 18 pointer-events: none; /* 确保不影响鼠标事件 */ 19 overflow: hidden; /* 防止内部溢出 */ 20 } 21 22 .loading-bar { 23 height: 100%; 24 width: 0%; /* 初始宽度 */ 25 background-color: #007bff; /* 进度条颜色 */ 26 animation: loading-grow 2s linear forwards; /* 动画持续时间 */ 27 } 28 29 /* 动画定义:从 0% 增长到 90% */ 30 @keyframes loading-grow { 31 0% { width: 0%; } 32 90% { width: 90%; } /* 缓慢增长到 90% */ 33 100% { width: 100%; opacity: 0; display: none; } /* 页面加载完成,快速到 100% 并消失 */ 34 } 35 36 /* 模拟页面内容,制造滚动条 */ 37 .content { 38 min-height: 150vh; 39 padding: 50px; 40 background-color: #f0f0f0; 41 } 42 </style> 43</head> 44<body> 45 <div class="loading-bar-container"> 46 <div class="loading-bar" id="loadingBar"></div> 47 </div> 48 49 <div class="content"> 50 <h1>页面内容</h1> 51 <p>这里是页面加载完成后会显示的内容。进度条会在页面加载期间出现。</p> 52 <img src="https://via.placeholder.com/600x300?text=Large+Image+1" alt="Large Image"> 53 <p>更多内容...</p> 54 <img src="https://via.placeholder.com/600x400?text=Large+Image+2" alt="Large Image"> 55 </div> 56 57 <script> 58 // 当页面完全加载后(包括所有资源),让进度条快速到 100% 并消失 59 window.addEventListener('load', () => { 60 const loadingBar = document.getElementById('loadingBar'); 61 // 停止之前的动画,并应用一个快速完成的动画 62 loadingBar.style.animation = 'none'; // 停止当前动画 63 loadingBar.offsetHeight; // 强制 reflow,确保动画停止 64 loadingBar.style.transition = 'width 0.2s ease-out, opacity 0.5s ease-out'; 65 loadingBar.style.width = '100%'; 66 loadingBar.style.opacity = '0'; 67 68 // 移除元素 69 setTimeout(() => { 70 loadingBar.parentNode.removeChild(loadingBar); 71 }, 500); 72 }); 73 </script> 74</body> 75</html> -
JavaScript 监听资源加载事件 (Fetch/XHR) + 模拟进度
- 原理: 拦截所有
XMLHttpRequest和fetch请求,统计正在进行的请求数量。根据请求完成情况更新进度。对于静态资源(图片、CSS、JS)加载,则依赖Performance API或只做估算。 - 优点: 相对更接近真实进度,尤其是对于 AJAX 驱动的单页应用。
- 缺点: 无法精确计算所有静态资源(如
<img>,<link>)的加载进度,只能估算。实现复杂。
1class ProgressBar { 2 constructor() { 3 this.progress = 0; 4 this.activeRequests = 0; 5 this.init(); 6 } 7 8 init() { 9 this.createProgressBar(); 10 this.interceptRequests(); 11 } 12 13 createProgressBar() { 14 this.barContainer = document.createElement('div'); 15 this.barContainer.style.cssText = ` 16 position: fixed; top: 0; left: 0; width: 100%; height: 3px; 17 background-color: transparent; z-index: 9999; pointer-events: none; 18 overflow: hidden; 19 `; 20 this.bar = document.createElement('div'); 21 this.bar.style.cssText = ` 22 height: 100%; width: 0%; background-color: #007bff; 23 transition: width 0.3s ease-out, opacity 0.5s ease-out; 24 `; 25 this.barContainer.appendChild(this.bar); 26 document.body.appendChild(this.barContainer); 27 } 28 29 start() { 30 this.barContainer.style.opacity = '1'; 31 this.bar.style.width = '1%'; 32 this.progress = 1; 33 this.activeRequests = 0; 34 this.tick(); // 开始模拟增长 35 } 36 37 tick() { 38 if (this.progress < 90) { // 模拟增长到 90% 39 this.progress += Math.random() * 5; // 每次增长一点 40 this.bar.style.width = `${Math.min(90, this.progress)}%`; 41 requestAnimationFrame(() => this.tick()); 42 } 43 } 44 45 setProgress(percent) { 46 this.progress = Math.max(this.progress, percent); 47 this.bar.style.width = `${this.progress}%`; 48 if (this.progress >= 100) { 49 this.finish(); 50 } 51 } 52 53 finish() { 54 this.bar.style.width = '100%'; 55 this.bar.style.opacity = '0'; 56 setTimeout(() => { 57 this.barContainer.parentNode.removeChild(this.barContainer); 58 }, 500); 59 } 60 61 // 拦截 Fetch 和 XHR 请求 62 interceptRequests() { 63 const self = this; 64 65 // 拦截 Fetch 66 const originalFetch = window.fetch; 67 window.fetch = function(...args) { 68 self.activeRequests++; 69 if (self.activeRequests === 1) self.start(); // 第一个请求开始时启动进度条 70 71 return originalFetch.apply(this, args).finally(() => { 72 self.activeRequests--; 73 if (self.activeRequests <= 0) { 74 self.finish(); 75 } else { 76 // 根据剩余请求数大致更新进度 77 self.setProgress(90 + (10 - self.activeRequests) * (10 / 10)); // 估算 78 } 79 }); 80 }; 81 82 // 拦截 XHR (更复杂的实现) 83 // ... 84 } 85} 86 87// 在页面加载初期初始化 88const progressBar = new ProgressBar(); 89 90// 可以在组件加载数据时手动调用 91// progressBar.start(); 92// setTimeout(() => { 93// // 假设加载了一部分数据 94// progressBar.setProgress(50); 95// }, 1000); 96// setTimeout(() => { 97// // 假设所有数据加载完成 98// progressBar.finish(); 99// }, 3000); 100 101// 示例:模拟 Fetch 请求 102window.addEventListener('DOMContentLoaded', () => { 103 fetch('https://jsonplaceholder.typicode.com/todos/1'); 104 fetch('https://jsonplaceholder.typicode.com/posts/1'); 105 fetch('https://jsonplaceholder.typicode.com/users/1'); 106 // 模拟一个慢请求 107 setTimeout(() => { 108 fetch('https://jsonplaceholder.typicode.com/comments/1'); 109 }, 500); 110}); - 原理: 拦截所有
-
使用第三方库 (NProgress, Pace.js)
- NProgress: 轻量级,只在页面顶部显示一个进度条,模拟加载。它不检测真实进度,而是根据请求数量和超时自动推进。
- 原理: 监听
XMLHttpRequest和fetch请求的开始和结束,当有活动请求时显示进度条,并根据请求数量和时间自动推进进度。 - 优点: 简单易用,效果流畅,无需手动管理进度。
- 缺点: 无法反映精确的百分比进度,更多是一种视觉体验优化。
- 原理: 监听
- Pace.js: 更强大,能自动监视 AJAX 请求、DOM ready 状态、CSS/JS/图片加载等,显示更真实的进度。
- 原理: 通过劫持
XMLHttpRequest、fetch、监听DOMContentLoaded、load事件,甚至检查图片加载状态来估算页面加载进度。 - 优点: 功能更强大,对不同类型的资源加载都有所考虑。
- 缺点: 文件可能较大,对性能有轻微影响,实现复杂。
- 原理: 通过劫持
使用 NProgress 示例:
1<!-- 引入 NProgress CSS --> 2<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/nprogress/0.2.0/nprogress.min.css"> 3<!-- 引入 NProgress JS --> 4<script src="https://cdnjs.cloudflare.com/ajax/libs/nprogress/0.2.0/nprogress.min.js"></script> 5 6<script> 7 // 配置 NProgress 8 NProgress.configure({ 9 showSpinner: false, // 不显示加载圈 10 trickleSpeed: 200, // 进度条每次前进的间隔时间 11 minimum: 0.08 // 最小进度 12 }); 13 14 // 在页面开始加载时启动 15 NProgress.start(); 16 17 // 监听页面加载完成事件 18 window.addEventListener('load', () => { 19 NProgress.done(); // 页面完全加载后完成进度条 20 }); 21 22 // 也可以手动控制,例如在路由切换时: 23 // NProgress.start(); // 路由开始 24 // 路由加载完成后 NProgress.done(); 25</script> - NProgress: 轻量级,只在页面顶部显示一个进度条,模拟加载。它不检测真实进度,而是根据请求数量和超时自动推进。
总结:
- 最常用且效果好: 结合 NProgress 或 纯 CSS 动画模拟。它们提供了良好的用户体验,且实现相对简单,性能开销小。
- 需要更精确的进度: 对于 AJAX 密集型应用,可以监听
fetch/XMLHttpRequest来估算进度,但难以包含所有资源。 - 全方位监控: 对于极其精确和全面的进度,可能需要引入更复杂的库如 Pace.js,或者结合 Service Worker 拦截所有网络请求来计算。
选择哪种方式取决于你对进度条的精度要求、开发成本和用户体验的平衡。大多数情况下,一个平滑的、视觉上能缓解焦虑的模拟进度条就足够了。
62. 常见图片懒加载方式有哪些?
图片懒加载(Lazy Loading Images)是一种性能优化技术,指在页面加载时,不立即加载所有图片,而是只加载当前视口(或即将进入视口)的图片。当用户滚动页面,图片进入可视区域时,才开始加载。这可以显著减少首屏加载时间,节约带宽,提升用户体验。
以下是常见的图片懒加载方式:
-
loading="lazy"属性 (浏览器原生支持,推荐)- 原理: 这是 HTML
<img>标签的一个标准属性,直接告诉浏览器该图片应该被懒加载。 - 实现:
1<img src="placeholder.jpg" data-src="real-image.jpg" alt="Description" loading="lazy">loading属性有三个值:eager: 立即加载图片(默认行为)。lazy: 延迟加载图片,直到图片进入视口附近。auto: 浏览器自行决定。
- 优点:
- 浏览器原生支持: 性能最佳,由浏览器智能判断加载时机和策略。
- 实现简单: 只需添加一个属性。
- 无额外 JS 库: 减轻 JS 负担。
- 缺点:
- 兼容性: 虽然主流现代浏览器(Chrome, Firefox, Edge, Safari 15+)都支持,但仍有部分旧版本浏览器不支持。
- 无定制化: 无法自定义加载逻辑或占位图动画。
- 适用场景: 大多数现代 Web 应用的首选,作为基础的懒加载方案。对于不支持的浏览器,需要 JavaScript Polyfill 或回退方案。
- 原理: 这是 HTML
-
Intersection Observer API(推荐,现代 JS 方案)- 原理: 异步观察目标元素(图片)与根元素(通常是视口)之间的交叉状态变化。当图片进入可视区域时,触发回调函数加载图片。
- 实现:
- 图片初始
src设置为占位图或空,真实 URL 存储在data-src属性中。 - 创建
IntersectionObserver实例,并观察所有需要懒加载的图片。 - 在回调函数中,当
entry.isIntersecting为true时,将data-src的值赋给src,并停止观察该图片。
- 图片初始
- 优点:
- 高性能: 异步执行,不阻塞主线程,无需频繁监听
scroll事件。 - 灵活: 可以自定义加载逻辑、预加载距离(
rootMargin)。 - 现代 API: 符合 Web 标准。
- 高性能: 异步执行,不阻塞主线程,无需频繁监听
- 缺点: 兼容性(IE 不支持)。
- 适用场景: 对懒加载行为有更多控制需求、需要精确加载时机、追求性能的现代 Web 应用。
示例:
1<img class="lazy-image" src="placeholder.gif" data-src="https://via.placeholder.com/600x400?text=Image1" alt="Image 1"> 2<img class="lazy-image" src="placeholder.gif" data-src="https://via.placeholder.com/600x400?text=Image2" alt="Image 2"> 3<!-- 制造滚动条 --> 4<div style="height: 1000px;"></div> 5<img class="lazy-image" src="placeholder.gif" data-src="https://via.placeholder.com/600x400?text=Image3" alt="Image 3"> 6 7<script> 8 document.addEventListener('DOMContentLoaded', () => { 9 const lazyImages = document.querySelectorAll('.lazy-image'); 10 11 if ('IntersectionObserver' in window) { 12 const observer = new IntersectionObserver((entries, observerSelf) => { 13 entries.forEach(entry => { 14 if (entry.isIntersecting) { 15 const img = entry.target; 16 img.src = img.dataset.src; // 加载真实图片 17 img.classList.add('fade-in'); // 可选:添加淡入效果 18 observerSelf.unobserve(img); // 停止观察已加载的图片 19 } 20 }); 21 }, { 22 rootMargin: '0px 0px 100px 0px' // 图片进入视口下方 100px 时开始加载 23 }); 24 25 lazyImages.forEach(img => { 26 observer.observe(img); 27 }); 28 } else { 29 // Fallback for browsers that don't support Intersection Observer 30 // Use scroll and getBoundingClientRect (less efficient) 31 console.warn('Intersection Observer not supported. Using fallback for lazy loading.'); 32 // 示例:简化的 fallback 33 let lazyLoadThrottleTimeout; 34 function lazyLoad() { 35 if (lazyLoadThrottleTimeout) { 36 clearTimeout(lazyLoadThrottleTimeout); 37 } 38 lazyLoadThrottleTimeout = setTimeout(() => { 39 lazyImages.forEach(img => { 40 if (img.dataset.src && img.getBoundingClientRect().top < window.innerHeight + 200) { 41 img.src = img.dataset.src; 42 img.removeAttribute('data-src'); // 移除 data-src 避免重复加载 43 } 44 }); 45 if (document.querySelectorAll('.lazy-image[data-src]').length === 0) { 46 window.removeEventListener('scroll', lazyLoad); 47 window.removeEventListener('resize', lazyLoad); 48 } 49 }, 200); 50 } 51 window.addEventListener('scroll', lazyLoad); 52 window.addEventListener('resize', lazyLoad); 53 lazyLoad(); // 首次执行 54 } 55 }); 56</script> -
scroll事件监听 +getBoundingClientRect()(传统 JS 方案)- 原理: 监听
window或可滚动容器的scroll事件。在事件回调中,遍历所有需要懒加载的图片,通过element.getBoundingClientRect().top判断图片顶部是否进入了视口。 - 实现: 与
Intersection Observer类似,但需要手动实现判断逻辑。 - 优点: 兼容性好,支持所有浏览器。
- 缺点:
- 性能差:
scroll事件触发频繁,每次触发都可能需要遍历所有图片并进行 DOM 测量 (getBoundingClientRect()也会引起重排)。 - 需要节流/防抖: 为了优化性能,必须对
scroll事件回调进行节流或防抖处理。
- 性能差:
- 适用场景: 必须兼容老旧浏览器,且无法使用 Polyfill 的场景。
示例: (见上述 Intersection Observer 示例中的
else块) - 原理: 监听
-
自定义占位图和动画:
无论采用哪种懒加载方式,都可以配合自定义的占位图(如低质量模糊图、纯色背景、SVG 加载动画)和加载成功后的淡入动画,提升用户体验。
总结和推荐:
- 首选且最简单:
loading="lazy"。 如果兼容性允许,直接使用它。 - 需要更多控制和最佳性能 (兼容性允许):
Intersection Observer API。 它是现代 Web 开发中推荐的懒加载解决方案。 - 必须兼容老旧浏览器: 使用
scroll事件监听 +getBoundingClientRect(),但务必进行节流优化。 - 综合方案: 优先使用
loading="lazy",并提供Intersection Observer作为 Polyfill 或降级方案,再为老旧浏览器提供scroll事件的 Fallback。
正确实施图片懒加载,能显著提升网页性能和用户感知速度,是前端性能优化的重要组成部分。
63. cookie构成部分有哪些
HTTP Cookie 是一小段文本信息,由服务器发送到用户浏览器,并由浏览器存储,之后每次对该服务器的请求都会携带这些信息。它主要用于保持用户的登录状态、跟踪用户行为、记住用户偏好等。
一个 Cookie 主要由以下几个核心构成部分(属性)组成:
-
名称 (Name):
- Cookie 的名称,用于标识这个 Cookie 的唯一性。
- 例如:
sessionId,userId,themePreference。
-
值 (Value):
- 与名称关联的数据。这是 Cookie 存储的实际信息。
- 例如:
sessionId=abcxyz123,userId=12345,themePreference=dark。
-
域 (Domain): (
Domain=example.com)- 指定 Cookie 属于哪个域名。只有请求该域名(或其子域)时,浏览器才会发送这个 Cookie。
- 如果未指定,默认为设置 Cookie 的当前域名。
- 如果设置为
.example.com(注意前面的点),则表示该 Cookie 可以被example.com及其所有子域(如www.example.com,blog.example.com)访问。 - 不能设置为一个非当前域的顶级域。例如,在
a.com下不能设置Domain=b.com。
-
路径 (Path): (
Path=/)- 指定 Cookie 在哪个路径下是有效的。只有请求该路径或其子路径时,浏览器才会发送这个 Cookie。
- 如果未指定,默认为设置 Cookie 的当前 URL 路径。
- 通常设置为
/,表示 Cookie 在整个网站(所有路径)下都有效。
-
过期时间 (Expires) 或 最大存活时间 (Max-Age):
Expires: 指定 Cookie 的精确过期日期和时间(GMT 格式)。一旦到达这个时间,Cookie 就会被浏览器删除。- 例如:
Expires=Wed, 21 Oct 2024 07:28:00 GMT;
- 例如:
Max-Age: 指定 Cookie 的存活时间,以秒为单位。这是一个相对时间。- 例如:
Max-Age=3600(表示 Cookie 在 3600 秒后过期)。
- 例如:
- 会话 Cookie (Session Cookie): 如果这两个属性都未设置,则 Cookie 是“会话 Cookie”。它只在当前浏览器会话期间有效,当浏览器关闭时就会被删除。
- 持久 Cookie (Persistent Cookie): 设置了
Expires或Max-Age的 Cookie 称为持久 Cookie,它们会被存储在用户的硬盘上,即使浏览器关闭也会保留,直到过期时间。
-
安全标志 (Secure): (
Secure)- 一个布尔标志。如果设置了
Secure属性,则 Cookie 只会通过 HTTPS 安全连接发送到服务器。 - 这意味着在 HTTP(不安全)连接上,即使 Cookie 存在,也不会被发送。
- 增强安全性,防止 Cookie 在不安全的网络中被窃听。
- 一个布尔标志。如果设置了
-
HTTP Only 标志 (HttpOnly): (
HttpOnly)- 一个布尔标志。如果设置了
HttpOnly属性,则 Cookie 无法通过 JavaScript (document.cookie) 访问。 - 这意味着 Cookie 只能由 HTTP 请求发送到服务器,而不能被客户端脚本读取、修改或删除。
- 主要用于防止跨站脚本 (XSS) 攻击: 即使攻击者成功注入了恶意 JavaScript 代码,也无法窃取用户的敏感 Cookie(如会话 ID)。
- 一个布尔标志。如果设置了
-
SameSite 属性 (SameSite=Lax/Strict/None):
- 用于控制 Cookie 是否跨站发送,主要用来防止跨站请求伪造 (CSRF) 攻击。
SameSite可以有三个值:Lax(默认值):- 在顶级导航(GET 请求,如点击链接、输入 URL)和
_self形式的表单提交时发送。 - 在
img,script,iframe,link等跨站子请求中不发送。 - 推荐作为多数场景的默认值。
- 在顶级导航(GET 请求,如点击链接、输入 URL)和
Strict:- 只有在同站请求中才会发送 Cookie。
- 即便是从外部链接导航到本站,也不会发送 Cookie。
- 安全性最高,但可能影响用户体验(例如,从邮件点击链接到站内页面需要重新登录)。
None:- 允许跨站发送 Cookie。
- 但必须同时设置
Secure属性(即只能在 HTTPS 环境下发送)。 - 主要用于需要跨站(如第三方嵌入式内容、CDN)但又需要 Cookie 的场景。
总结图:
┌─────────────────┐
│ Cookie │
├─────────────────┤
│ Name: sessionId │ <-- 名称
│ Value: abc123 │ <-- 值
│ Domain: .example.com <-- 域(可见范围)
│ Path: / │ <-- 路径(可见范围)
│ Expires: GMT_Date_Time <-- 过期时间(持久化)
│ Max-Age: 3600 │ <-- 最大存活时间(持久化)
│ Secure │ <-- 仅通过 HTTPS 发送
│ HttpOnly │ <-- JS 无法访问
│ SameSite: Lax │ <-- 跨站行为控制(防 CSRF)
└─────────────────┘
理解这些构成部分对于设计安全的、符合预期的 Cookie 策略至关重要。
64. 扫码登录实现方式
扫码登录是一种常见的认证方式,它允许用户在 PC 端网页上通过手机 App 扫描二维码来快速登录,而无需在 PC 端输入账号密码。这种方式提高了便捷性和安全性。
扫码登录的核心思想是:手机 App 作为认证中心,PC 端作为认证请求方,通过一个中间的轮询或推送机制来同步登录状态。
以下是两种常见的扫码登录实现方式:
方式一:基于轮询 (Polling) 的实现 (传统且常用)
这是最常见的实现方式,适用于大多数场景。
流程:
-
PC 端请求二维码:
- PC 端网页加载时,向后端发送请求,获取一个唯一的二维码 ID (UUID) 和一个临时二维码图片 URL。
- 后端生成这个 UUID,并将其与一个初始的“未扫描”状态关联起来,存储在缓存(如 Redis)中,并设置一个过期时间。
- 后端返回二维码 ID 和图片 URL。
- PC 端将二维码图片展示给用户。
-
PC 端轮询状态:
- PC 端在展示二维码后,使用这个二维码 ID,定时向后端发送轮询请求(如每 2-3 秒一次),查询二维码的扫描状态。
- 初始状态可能是“未扫描”、“已过期”。
-
用户手机 App 扫描二维码:
- 用户打开手机 App,使用内置的扫码功能扫描 PC 端的二维码。
- 二维码 URL 中通常包含二维码 ID (UUID)。
- 手机 App 解析二维码,获取到二维码 ID,并携带用户身份信息(如已登录的
sessionId或token)向后端发送一个“确认扫描”请求。
-
后端处理扫描请求:
- 后端接收到手机 App 的“确认扫描”请求后:
- 验证手机 App 用户的身份。
- 根据请求中的二维码 ID,更新该二维码的状态为“已扫描待确认”或“已登录”。
- 如果状态是“已扫描待确认”,可以向手机 App 返回一个确认页面,让用户在手机上点击“确认登录”。
- 后端接收到手机 App 的“确认扫描”请求后:
-
用户手机 App 确认登录 (可选,增加安全性):
- 手机 App 可能会显示一个确认页面(例如显示 PC 端登录设备信息、IP 地址),询问用户是否确定登录。
- 用户点击“确认登录”后,手机 App 再向后端发送一个最终的“确认登录”请求。
-
后端处理最终确认:
- 后端接收到手机 App 的最终“确认登录”请求后:
- 再次验证手机 App 用户的身份和二维码 ID。
- 生成一个新的登录凭证 (如
sessionId或token),用于 PC 端。 - 将这个新的登录凭证与二维码 ID 关联起来,并更新二维码状态为“已登录”。
- 后端接收到手机 App 的最终“确认登录”请求后:
-
PC 端轮询到登录成功:
- PC 端在轮询请求中发现二维码状态变为“已登录”,并且后端返回了新的登录凭证。
- PC 端获取登录凭证(如
token),将其存储在localStorage或Cookie中。 - PC 端完成登录,跳转到主页或刷新页面。
优点:
- 实现相对简单,兼容性好。
- 适用于各种网络环境。
缺点:
- 轮询开销: PC 端需要频繁向后端发送请求,即使状态没有变化,也会占用网络和服务器资源。
- 实时性略差: 状态同步存在一定的延迟(取决于轮询间隔)。
方式二:基于 WebSocket/SSE (Server-Sent Events) 的实现 (推荐,更实时高效)
这种方式利用 WebSocket 或 SSE 的长连接特性,实现状态的实时推送,避免轮询开销。
流程:
-
PC 端请求二维码并建立长连接:
- PC 端网页加载时,向后端发送请求,获取一个唯一的二维码 ID (UUID) 和一个临时二维码图片 URL。
- 后端生成 UUID,并将其与一个初始的“未扫描”状态关联。
- PC 端收到二维码 ID 和 URL 后,立即与后端建立一个 WebSocket 连接(或 SSE 连接),并将二维码 ID 作为参数传递。
- PC 端展示二维码。
-
用户手机 App 扫描二维码:
- 用户扫描二维码,手机 App 解析出二维码 ID,并携带用户身份信息向后端发送“确认扫描”请求。
-
后端处理扫描请求并推送状态:
- 后端接收到手机 App 的“确认扫描”请求后:
- 验证手机 App 用户身份。
- 更新二维码状态(如“已扫描待确认”或“已登录”)。
- 通过 WebSocket 连接,实时将更新后的状态推送到对应的 PC 端。
- 如果手机 App 需要确认登录,后端会推送确认消息到 PC 端。
- 后端接收到手机 App 的“确认扫描”请求后:
-
PC 端接收推送并处理:
- PC 端通过 WebSocket 实时接收到后端推送的状态更新。
- 如果收到“已扫描”状态,可以更新 UI(如显示“请在手机上确认”)。
- 如果收到“已登录”状态,则表示登录成功,后端会将登录凭证一并推送过来。
- PC 端获取登录凭证,完成登录,跳转。
优点:
- 实时性高: 状态变化立即同步到 PC 端,用户体验更好。
- 资源效率高: 避免了频繁的轮询请求,减少了网络和服务器资源消耗。
缺点:
- 实现复杂度相对较高,需要支持 WebSocket/SSE 的后端基础设施。
- WebSocket 连接在某些防火墙或代理后面可能存在兼容性问题。
共同的安全考量:
- 二维码 ID 唯一性: 每次扫码都应生成新的 UUID,并确保其随机性和难以猜测。
- 过期时间: 二维码应有严格的过期时间,过期后自动失效。
- 会话绑定: 手机 App 确认登录后,后端生成的 PC 端登录凭证应与二维码 ID 强绑定,防止被冒用。
- 多重验证: 可以在手机 App 确认登录前增加指纹、面容识别或密码验证,提高安全性。
- IP 地址/设备信息校验: 登录时可以比对 PC 端请求的 IP 地址和手机 App 的 IP 地址,如果差异过大可以提示风险。
- 防止劫持: 确保二维码传输和状态同步的通道安全(HTTPS/WSS)。
综合来看,基于 WebSocket/SSE 的实现是更优的选择,但如果对实时性要求不高或后端基础设施不支持长连接,传统的轮询方式也完全可行。
65. DNS 协议了解多少
DNS (Domain Name System) 协议是互联网的一项基础服务,它充当着互联网的电话簿。它的主要作用是将人类可读的域名(如 www.example.com)转换为机器可读的 IP 地址(如 192.0.2.1),反之亦然。
核心概念:
-
域名 (Domain Name):
- 互联网上网站的唯一标识符,层次结构,例如
www.example.com。 - 从右到左,层级逐渐升高:
.com(顶级域名 TLD),example(二级域名),www(三级域名/主机名)。
- 互联网上网站的唯一标识符,层次结构,例如
-
IP 地址 (IP Address):
- 互联网上设备的唯一数字标识符。可以是 IPv4 (如
192.0.2.1) 或 IPv6 (如2001:0db8::1)。
- 互联网上设备的唯一数字标识符。可以是 IPv4 (如
-
DNS 解析 (DNS Resolution):
- 将域名转换为 IP 地址的过程。
-
DNS 服务器类型:
- 根域名服务器 (Root Name Servers): 全球有 13 组根服务器,负责最高层域名的解析,知道哪些服务器负责 TLD(如
.com,.org,.cn)。 - 顶级域名服务器 (TLD Name Servers): 负责特定的顶级域名,知道哪些服务器负责该 TLD 下的二级域名(如
example.com)。 - 权威域名服务器 (Authoritative Name Servers): 负责特定域名的所有记录(如
example.com),存储该域名下的所有子域名(如www.example.com)。这是域名所有者配置 DNS 记录的地方。 - 递归 DNS 服务器 (Recursive DNS Servers) / DNS 解析器 (DNS Resolver):
- 这是我们日常上网时,ISP(互联网服务提供商)或公共 DNS 服务(如 Google DNS 8.8.8.8, Cloudflare DNS 1.1.1.1)提供的 DNS 服务器。
- 它们负责接收用户(客户端)的 DNS 查询请求,并从根服务器开始,一层一层地查询,直到找到最终的 IP 地址,然后将结果返回给客户端并进行缓存。
- 根域名服务器 (Root Name Servers): 全球有 13 组根服务器,负责最高层域名的解析,知道哪些服务器负责 TLD(如
-
DNS 记录 (DNS Records):
DNS 服务器存储的信息以“记录”的形式存在,常见的记录类型包括:A记录 (Address Record): 将域名映射到 IPv4 地址。AAAA记录 (IPv6 Address Record): 将域名映射到 IPv6 地址。CNAME记录 (Canonical Name Record): 将一个域名映射到另一个域名(别名)。例如,www.example.com可以是example.com的CNAME。NS记录 (Name Server Record): 指定哪个 DNS 服务器是该域的权威服务器。MX记录 (Mail Exchange Record): 指定负责接收该域邮件的邮件服务器。TXT记录 (Text Record): 存储任意文本信息,常用于验证域名所有权、SPF/DKIM 等邮件安全设置。
DNS 解析过程 (简化的递归查询):
- 用户在浏览器输入
www.example.com。 - 浏览器检查本地 DNS 缓存: 如果有,直接返回 IP 地址。
- 如果无缓存,浏览器向操作系统的 DNS Resolver (通常是 ISP 的 DNS 服务器) 发出请求。
- DNS Resolver (递归服务器) 检查自身缓存: 如果有,直接返回。
- 如果无缓存,DNS Resolver 向根域名服务器发起请求。
- 根域名服务器回复 TLD 服务器的 IP 地址 (如
.com的 TLD 服务器)。 - DNS Resolver 向
.comTLD 服务器发起请求。 - TLD 服务器回复
example.com权威域名服务器的 IP 地址。 - DNS Resolver 向
example.com的权威域名服务器发起请求。 - 权威域名服务器回复
www.example.com对应的 IP 地址。 - DNS Resolver 将 IP 地址返回给浏览器,并缓存结果。
- 浏览器获得 IP 地址,发起 HTTP 请求。
DNS 的重要性:
- 易用性: 使人们能够使用易于记忆的域名访问网站,而不是复杂的 IP 地址。
- 可用性: 即使服务器的 IP 地址发生变化,只要更新 DNS 记录,用户仍然可以通过相同的域名访问。
- 负载均衡和容灾: 通过 DNS 解析可以将流量分发到多个 IP 地址(DNS 轮询),或在某个 IP 故障时切换到备用 IP。
- 内容分发网络 (CDN): CDN 通过 DNS 解析将用户请求导向离他们最近的边缘节点,实现加速访问。
- 安全性: DNSSEC (DNS Security Extensions) 可以增强 DNS 的安全性,防止 DNS 劫持和缓存投毒。
前端优化中的 DNS 考虑:
- DNS 预解析 (DNS Prefetch): 在 HTML 的
<head>中添加<link rel="dns-prefetch" href="//example.com">,让浏览器提前解析域名,减少后续请求的 DNS 查询时间。 - 域名数量: 减少页面中不同域名资源的数量,可以减少 DNS 查询的次数。
- HTTP/2: HTTP/2 的多路复用减少了对并发连接的需求,使得 DNS 查询开销在一定程度上降低。
DNS 是构建和维护互联网的基石,其可靠性和效率对用户访问网站的速度和体验至关重要。
66. 函数式编程了解多少?
函数式编程(Functional Programming,简称 FP)是一种编程范式,它将计算视为数学函数的求值,并强调避免可变状态和副作用。它与面向对象编程(OOP)和命令式编程(Imperative Programming)有所不同。
核心概念和特性:
-
纯函数 (Pure Functions):
- 定义: 对于相同的输入,总是返回相同的输出。
- 无副作用: 不会修改任何外部状态(如全局变量、函数外部的变量、DOM、数据库),也不依赖任何外部可变状态。
- 可缓存: 由于纯函数不依赖外部状态且没有副作用,其结果可以被缓存(Memoization),提高性能。
- 易于测试: 独立且确定性强,只需为给定输入测试输出即可。
- 易于并行: 由于没有共享状态,可以安全地并行执行。
1// 纯函数 2function add(a, b) { 3 return a + b; 4} 5 6// 非纯函数(修改了外部变量) 7let total = 0; 8function addToTotal(num) { 9 total += num; // 副作用 10 return total; 11} 12 13// 非纯函数(依赖外部变量) 14function getRandomNumber() { 15 return Math.random(); // 依赖 Math.random 的外部状态,每次结果不同 16} -
不可变性 (Immutability):
- 定义: 一旦数据被创建,就不能被修改。如果需要修改,就创建一份新的数据副本,并对副本进行修改。
- 好处:
- 可预测性: 消除状态变化带来的复杂性。
- 易于调试: 问题的溯源更容易,因为没有意外的状态修改。
- 并发安全: 多个线程可以安全地访问数据,无需加锁。
- 时间旅行调试: 方便记录和回溯状态变化。
- 实现: 在 JavaScript 中,可以通过
Object.assign(), 展开运算符 (...),Array.prototype.slice(),Array.prototype.map()等方法来实现不可变性。- React/Redux 强烈推崇不可变性。
1const arr = [1, 2, 3]; 2// 不可变地添加元素 3const newArr = [...arr, 4]; // newArr 为 [1,2,3,4],arr 仍为 [1,2,3] 4 5const obj = { a: 1, b: 2 }; 6// 不可变地修改属性 7const newObj = { ...obj, b: 3 }; // newObj 为 {a:1, b:3},obj 仍为 {a:1, b:2} -
函数是一等公民 (First-Class Functions):
- 函数可以像其他任何值(如数字、字符串)一样被处理。
- 可以作为参数传递给其他函数。
- 可以作为函数的返回值。
- 可以赋值给变量。
1const sayHello = (name) => `Hello, ${name}`; // 赋值给变量 2function greet(fn, name) { 3 console.log(fn(name)); // 作为参数传递 4} 5greet(sayHello, 'World'); -
高阶函数 (Higher-Order Functions - HOF):
- 接受一个或多个函数作为参数,或者返回一个函数作为结果的函数。
- 常见的 HOF 有
map,filter,reduce,sort。
1const numbers = [1, 2, 3]; 2const doubled = numbers.map(num => num * 2); // map 是高阶函数 3 4function createMultiplier(multiplier) { 5 return function(num) { // 返回一个函数 6 return num * multiplier; 7 }; 8} 9const multiplyBy5 = createMultiplier(5); 10console.log(multiplyBy5(10)); // 50 -
函数组合 (Function Composition):
- 将多个简单函数组合成一个更复杂的函数。
f(g(x))或compose(f, g)(x)。
1const add5 = x => x + 5; 2const multiply2 = x => x * 2; 3const subtract10 = x => x - 10; 4 5// 命令式 6const result1 = subtract10(multiply2(add5(10))); // 20 7 8// 函数组合 (例如使用 lodash/fp 的 flow 或 compose) 9// const composedFn = flow(add5, multiply2, subtract10); 10// const result2 = composedFn(10); // 20 -
柯里化 (Currying):
- 将一个接受多个参数的函数转换为一系列只接受一个参数的函数。
1// 接受多个参数 2function add(a, b, c) { 3 return a + b + c; 4} 5 6// 柯里化后 7function curriedAdd(a) { 8 return function(b) { 9 return function(c) { 10 return a + b + c; 11 }; 12 }; 13} 14const add5 = curriedAdd(5); 15const add5and10 = add5(10); 16console.log(add5and10(20)); // 35
函数式编程的优点:
- 可维护性: 纯函数和不可变性使代码更容易理解和维护,因为它们行为确定,没有意外的副作用。
- 可测试性: 纯函数易于进行单元测试,因为它们是独立的,输入和输出确定。
- 可读性: 通过函数组合和高阶函数,代码通常更简洁、表达力更强。
- 并发性: 不可变状态使得并行处理变得安全,减少了竞态条件和死锁的风险。
- 调试: 状态变化减少,更容易追踪 Bug。
函数式编程的缺点/挑战:
- 学习曲线: 对于习惯命令式或面向对象编程的开发者来说,思维模式的转变需要时间。
- 性能: 频繁创建新数据副本可能会带来一定的性能开销(尽管现代 JS 引擎和垃圾回收器已经很高效)。
- 复杂性管理: 纯粹的函数式编程在处理 I/O、用户交互等副作用时,需要特殊的模式(如 Monads,但 JS 中不常用),这可能增加认知负担。
在 JavaScript 中的应用:
JavaScript 是一个多范式语言,非常适合函数式编程。
- React/Redux: 强烈推荐函数式编程思想,如组件是纯函数、Reducers 必须是纯函数。
- Lodash/Ramda: 提供了大量实用的函数式工具函数。
- 响应式编程 (RxJS): 很多概念与函数式编程相似。
了解函数式编程思想有助于编写更健壮、可维护和可测试的代码。
68. 什么是领域模型
领域模型 (Domain Model) 是软件工程中的一个核心概念,特别是在面向对象分析和设计(OOAD)以及领域驱动设计(Domain-Driven Design, DDD)中占据重要地位。
核心定义:
领域模型是对特定业务领域中概念、实体、数据和它们之间关系的抽象表示。它捕捉了业务的核心逻辑、规则和行为,是软件系统与现实世界业务之间的桥梁。
简而言之:
- 它不是数据库模型(虽然可能与之相关)。
- 它不是 UI 界面(虽然 UI 会展示它的数据)。
- 它不是代码实现(虽然最终会映射到代码)。
- 它是一组在特定业务上下文中,具有明确职责和行为的业务对象的集合。
主要构成元素 (在 DDD 中):
在领域驱动设计(DDD)的语境下,领域模型通常包含以下核心构建块:
-
实体 (Entities):
- 具有唯一标识符 (ID) 和生命周期的领域对象。
- 其身份比属性更重要。即使属性值发生变化,只要 ID 不变,它就是同一个实体。
- 例如:
Order(订单),Product(商品),Customer(客户)。 Order有一个唯一的orderId,即使订单的状态、商品列表变化,它仍然是同一个订单。
-
值对象 (Value Objects):
- 没有唯一标识符,由其属性值来定义。
- 当属性值全部相同时,它们被认为是相等的。
- 通常是不可变 (Immutable) 的。
- 例如:
Address(地址),Money(金额),DateRange(日期范围)。 - 两个
Address对象,如果它们的街道、城市、邮编都相同,则它们是相等的。
-
聚合 (Aggregates) 和 聚合根 (Aggregate Roots):
- 聚合: 由实体和值对象组成的集群,被视为一个单一的、事务上一致的单元。
- 聚合根: 聚合的唯一入口。所有对聚合内部对象的引用和操作都必须通过聚合根进行。
- 目的: 维护领域模型的不变性规则和一致性。
- 例如:
Order是一个聚合根,它包含OrderLine(订单行) 和Payment(支付) 等实体或值对象。外部只能通过Order来添加订单行或修改支付状态,不能直接操作OrderLine。
-
领域服务 (Domain Services):
- 当某些重要的领域操作不适合放在任何实体或值对象中时,会定义领域服务。
- 通常表示跨多个实体或聚合的复杂业务逻辑。
- 例如:
PaymentService(支付服务) 可能协调Order和Account两个聚合之间的操作。
-
领域事件 (Domain Events):
- 表示领域中发生的重要事情,可以被其他领域对象或外部系统感兴趣并响应。
- 例如:
OrderPlaced(订单已下达),PaymentReceived(收到付款)。
-
仓库 (Repositories):
- 用于存储和检索聚合的机制。它提供了一个集合状的接口,将数据持久化细节与领域模型解耦。
- 例如:
OrderRepository负责从数据库加载Order对象,或将Order对象保存到数据库。
领域模型的重要性:
- 统一语言 (Ubiquitous Language): 领域模型促进了开发人员和领域专家之间使用相同的语言交流,减少了沟通障碍。
- 业务复杂性管理: 通过对业务概念的清晰建模,将复杂的业务逻辑封装在领域对象内部,降低了系统的认知复杂性。
- 可维护性与可扩展性: 清晰的职责分离和内聚性使得代码更容易修改和扩展。
- 测试性: 领域逻辑集中在领域对象中,使得业务规则的测试变得更容易。
- 代码与业务一致: 确保软件系统真正反映了业务的运作方式。
如何构建领域模型:
通常通过与领域专家(业务人员)的密切沟通和协作,使用事件风暴、用例分析、UML 图(类图、序列图)等方法来探索和定义领域模型。它是一个迭代的过程,随着对业务理解的深入而不断演进。
69. 一直在window上面挂东西是否有什么风险
在 JavaScript 中,将变量、函数或对象直接“挂”到 window 对象上(即创建全局变量或属性)是一种常见的做法,尤其是在早期的 JavaScript 开发中。然而,这种做法在现代前端开发中被视为一种不推荐的“坏习惯”,因为它带来了多方面的风险:
-
全局污染 (Global Pollution):
- 命名冲突: 当多个脚本或第三方库都在
window上定义同名变量时,会发生命名冲突。后面的定义会覆盖前面的定义,导致不可预测的行为,甚至难以调试的 Bug。 - 调试困难: 难以追踪一个全局变量是在哪里被定义、修改或污染的,增加了调试的复杂性。
- 维护困难: 随着项目规模的增大,全局变量会变得难以管理,形成“意大利面条式”代码,影响代码的可读性和可维护性。
- 团队协作: 不同的开发者可能无意中使用或修改了其他开发者定义的全局变量。
1// script1.js 2window.myUtil = { 3 add: (a, b) => a + b 4}; 5 6// script2.js (某个第三方库) 7window.myUtil = { 8 format: (str) => str.trim() // 覆盖了 script1 中的 myUtil 9}; 10 11// 此时调用 myUtil.add 会报错 - 命名冲突: 当多个脚本或第三方库都在
-
安全性问题 (Security Concerns):
- 跨站脚本攻击 (XSS): 如果应用存在 XSS 漏洞,攻击者可以注入恶意 JavaScript 代码。由于恶意代码与你的应用代码在同一个全局上下文中运行,它可以通过
window对象访问或修改你的所有全局变量、敏感数据(如认证信息),甚至劫持用户操作。 - 敏感信息泄露: 如果你将敏感数据(如 API Key、用户凭证、调试信息)不小心挂载到
window上,恶意脚本可以轻易获取。
- 跨站脚本攻击 (XSS): 如果应用存在 XSS 漏洞,攻击者可以注入恶意 JavaScript 代码。由于恶意代码与你的应用代码在同一个全局上下文中运行,它可以通过
-
可测试性差 (Poor Testability):
- 全局状态使得单元测试变得困难。因为测试用例之间可能会相互影响,需要复杂的设置和清理才能确保每次测试都在一个干净的环境中运行。
- 依赖于全局变量的函数或模块难以独立测试。
-
代码耦合度高 (High Coupling):
- 模块之间通过全局变量进行通信,增加了模块间的隐式依赖,导致高耦合。
- 模块不再独立,难以复用或重构。
-
内存泄漏 (Memory Leaks):
- 如果将大型对象或 DOM 元素引用挂载到
window上,且不及时清理,可能会阻止垃圾回收,导致内存泄漏。
- 如果将大型对象或 DOM 元素引用挂载到
现代前端开发的解决方案:
现代前端开发通过以下方式避免全局污染和 window 挂载:
-
模块化 (Modules):
- 使用 ES Modules (
import/export) 或 CommonJS (require/module.exports) 将代码封装在独立的模块作用域内。 - 模块内部的变量默认是私有的,只有显式导出的内容才能被其他模块导入和使用。
- 这是解决全局污染最根本的方案。
1// my-util.js 2export function add(a, b) { 3 return a + b; 4} 5 6// main.js 7import { add } from './my-util.js'; 8console.log(add(1, 2)); // 3 9// add 和 my-util 变量都不会污染全局 - 使用 ES Modules (
-
IIFE (Immediately Invoked Function Expression) - 早期模块化方案:
在 ES Modules 普及之前,IIFE 是创建私有作用域、避免全局污染的常用模式。1(function() { 2 const privateVar = "I am private"; 3 window.myApp = { 4 publicMethod: function() { 5 console.log(privateVar); 6 } 7 }; 8})(); -
沙箱/微前端隔离:
在微前端架构中,为了防止不同子应用之间的全局污染,会使用Proxy或Snapshot等技术创建隔离的沙箱环境。 -
依赖注入 (Dependency Injection):
通过参数传递或依赖注入容器来管理依赖,而不是通过全局变量。
总结:
虽然偶尔将一些非常明确且无冲突的、全局需要访问的配置或少量工具函数挂载到 window 对象上可能被接受(例如,用于调试或兼容遗留系统),但总的来说,应极力避免在 window 上随意挂载变量和函数。采用模块化开发是最佳实践,它提供了清晰的依赖管理、避免了全局污染,并极大地提高了代码的可维护性、可测试性和安全性。
70. 深度SEO优化的方式有哪些,从技术层面来说
深度 SEO (Search Engine Optimization) 优化旨在提高网站在搜索引擎结果页面 (SERP) 中的排名,从而带来更多的自然流量。从技术层面来看,SEO 优化关注的是网站的可抓取性 (Crawlability)、可索引性 (Indexability) 和用户体验 (User Experience),以及向搜索引擎提供高质量内容。
以下是深度 SEO 优化从技术层面的主要方式:
1. 网站结构与可抓取性 (Crawlability & Site Structure)
- 清晰的 URL 结构:
- 语义化 URL: 使用易读、包含关键词的 URL,如
/products/shoes/nike-air-max而不是/p?id=123。 - 短而精炼: 避免过长或参数过多的 URL。
- 连字符分隔: 使用连字符
-而不是下划线_分隔单词。
- 语义化 URL: 使用易读、包含关键词的 URL,如
- 站点地图 (Sitemap.xml):
- 向搜索引擎提供一个包含所有重要页面 URL 的 XML 文件,帮助爬虫发现所有页面,特别是深层页面或没有内部链接的页面。
- 在 Google Search Console 或百度站长平台提交。
- Robots.txt 文件:
- 告诉搜索引擎哪些页面或目录可以抓取,哪些不可以。
- 用来阻止搜索引擎抓取不重要的页面(如后台、测试页面)或重复内容,避免浪费抓取预算 (Crawl Budget)。
- 内部链接结构:
- 合理链接: 确保网站内部链接结构清晰,重要页面有更多内部链接指向。
- 锚文本: 使用描述性强、包含关键词的锚文本。
- 面包屑导航: 帮助用户和搜索引擎理解网站层级。
- 规范化 URL (Canonical URLs):
- 使用
<link rel="canonical" href="canonical-url">标签,指明页面的首选(规范)URL,避免重复内容问题(如example.com/page,example.com/page?ref=a,www.example.com/page都指向同一内容)。
- 使用
- 避免死链 (Broken Links): 定期检查并修复 404 错误页面。
- 重定向 (301 Redirects): 当页面 URL 发生变化时,使用 301 永久重定向,将旧 URL 的权重传递给新 URL。
2. 页面内容与可索引性 (Indexability & Content)
- 元数据优化 (
<meta>tags):Title标签: 每个页面的<title>标签是 SEO 最重要的元素之一。应独特、准确、包含核心关键词,且长度适中(50-60 个字符)。Meta Description标签: 页面内容的简要摘要,出现在 SERP 中。应引人入胜,包含关键词,并鼓励点击(120-158 个字符)。Meta Keywords标签: 现代搜索引擎基本已不使用,可忽略。
- 语义化 HTML5 标签:
- 使用
<header>,<nav>,<main>,<article>,<section>,<footer>,<aside>等语义化标签,帮助搜索引擎理解页面结构和内容层次。 - 使用
<h1>到<h6>标签组织标题层级。
- 使用
- 图片优化:
Alt属性: 为所有图片添加描述性alt文本,帮助搜索引擎理解图片内容,也提高可访问性。- 图片文件名: 使用描述性文件名。
- 图片大小和格式: 压缩图片大小,使用 WebP 等现代化格式,提高加载速度。
- 结构化数据 (Structured Data / Schema Markup):
- 使用 Schema.org 词汇表(如 JSON-LD 格式)在 HTML 中标记内容,帮助搜索引擎更好地理解页面内容(如产品、评论、事件、文章)。
- 这可以使你的页面在 SERP 中显示为“富媒体摘要” (Rich Snippets),提高点击率。
- JavaScript 渲染问题:
- 对于重度依赖 JavaScript 渲染的 SPA (Single Page Application),搜索引擎爬虫可能无法完全抓取和索引所有内容。
- 解决方案:
- 服务端渲染 (SSR): 在服务器端预渲染页面,直接返回完整的 HTML 给爬虫。
- 预渲染 (Prerendering): 在构建时预先生成静态 HTML 文件。
- 同构应用: 同一套代码在服务端和客户端都能运行。
- 动态渲染 (Dynamic Rendering): 对爬虫返回服务端渲染的内容,对用户返回客户端渲染的内容。
- 确保客户端渲染的内容在爬虫能够等待足够时间(如 Googlebot 会执行 JS)后也能完全加载。 使用 Google Search Console 的 URL 检查工具验证抓取效果。
3. 用户体验 (User Experience - UX)
- 页面加载速度 (Page Speed):
- 核心 Web Vitals: LCP (Largest Contentful Paint), FID (First Input Delay), CLS (Cumulative Layout Shift)。Google 将这些指标作为排名因素。
- 优化方法:减少请求、图片优化、代码分割、Gzip 压缩、CDN、浏览器缓存、利用
defer/async加载 JS、避免长任务、字体优化等(与性能优化重叠)。
- 移动友好性 (Mobile-Friendliness):
- 响应式设计: 确保网站在任何设备上都能良好显示。
- 触摸目标大小: 确保移动端按钮和链接足够大,易于点击。
- 视口配置: 正确设置
<meta name="viewport">。
- HTTPS:
- 使用 HTTPS 加密,Google 已明确表示 HTTPS 是排名因素。
- 友好的 404 页面:
- 设计一个有用的 404 页面,引导用户回到网站主页或提供搜索功能。
4. 技术栈和工具
- CDN: 加速静态资源加载,提高全球用户访问速度。
- Google Search Console (GSC) / 百度站长平台:
- 监控网站在搜索引擎中的表现。
- 提交站点地图,检查抓取错误,查看搜索查询报告。
- Schema.org: 用于结构化数据标记。
- Lighthouse / PageSpeed Insights: 分析页面性能和 SEO 问题。
- ESLint / Prettier: 保持代码质量和一致性。
深度 SEO 优化是一个持续的过程,需要开发、产品、运营等多方协作,并定期进行性能监控和数据分析,以确保网站持续获得良好的搜索引擎排名。
71. 小程序为什么会有两个线程
小程序(尤其是微信小程序,以及支付宝小程序、百度智能小程序等类似平台)采用双线程架构(或称为双渲染引擎)来运行。这两个线程分别是:
-
逻辑层 (Logic Layer) / JS 引擎线程:
- 作用: 运行开发者编写的所有 JavaScript 代码。这包括业务逻辑、数据处理、API 调用、事件处理等。
- 环境: 运行在一个独立的 JS 引擎中(例如 V8 引擎)。这个环境没有完整的浏览器 DOM 和 BOM,不直接操作 UI。
- 特点: 逻辑层与渲染层通过异步通信(IPC,进程间通信)机制进行数据交换。所有 UI 相关的操作(如数据绑定、更新视图)都需要通过特定的接口(如
setData)发送到渲染层。
-
渲染层 (Render Layer) / WebView 线程:
- 作用: 负责 UI 的渲染。它加载和显示页面的 WXML(类 HTML)、WXSS(类 CSS)结构,并接收逻辑层发送的数据,然后更新视图。
- 环境: 通常运行在一个 WebView (例如 Chrome 的 WebView 内核,或 iOS 的 WKWebView) 中。WebView 提供了独立的渲染上下文,负责页面的绘制。
- 特点: WebView 只有渲染能力,不运行业务 JS。它会接收逻辑层发来的数据,然后驱动视图更新。
为什么采用双线程架构?
主要有以下几个原因:
-
性能优化和用户体验:
- 避免阻塞: 将 JS 逻辑和 UI 渲染分离到不同的线程中,可以避免耗时的 JavaScript 逻辑阻塞 UI 渲染。即使 JS 逻辑在进行复杂计算,页面滚动和动画也能保持流畅,提供更好的用户体验。这类似于 Web Worker 的思想,但小程序是强制性的双线程。
- 提升响应速度: 用户操作(如点击)产生的事件先在渲染层捕获,然后转发给逻辑层处理。逻辑层处理完成后,再将数据推送到渲染层更新视图。这种异步机制可以更好地管理资源。
-
安全隔离:
- 防止 DOM 泄露: 逻辑层无法直接访问和操作 DOM,这增强了小程序的安全性。即使开发者代码存在 XSS 漏洞,也难以直接窃取用户数据或破坏页面。
- 权限控制: 由于逻辑层不直接操作 DOM,小程序平台可以更好地控制 JS 的能力,例如禁止一些高风险的 DOM 操作,确保用户数据的安全。
-
开发效率与跨平台能力:
- 统一接口: 开发者只需编写一套 JS 逻辑,通过
setData等统一接口与视图层交互。底层渲染层的差异(如 iOS 的 WKWebView 和 Android 的 X5 内核)由小程序平台统一处理,降低了开发者的跨平台适配成本。 - 组件化与数据绑定: 逻辑层的数据(
data)通过框架自动绑定到渲染层(WXML),实现数据驱动视图,简化了 UI 开发。
- 统一接口: 开发者只需编写一套 JS 逻辑,通过
-
架构灵活性:
- 原生组件: 某些性能要求高或功能复杂的组件(如
map,video)可以直接使用原生组件渲染,而不是完全依赖 WebView。双线程架构使得原生组件和 WebView 组件可以混合渲染,提供更接近原生 App 的体验。 - 宿主环境控制: 小程序宿主(如微信 App)可以更好地介入和控制 WebView 的行为,进行性能优化和安全限制。
- 原生组件: 某些性能要求高或功能复杂的组件(如
通信机制:
逻辑层和渲染层之间的通信是通过一个桥梁 (Bridge) 实现的,它们之间传递的是序列化的 JSON 数据。
- 逻辑层 -> 渲染层: 逻辑层通过
setData方法将数据发送到渲染层,渲染层收到数据后进行 Diff 算法,更新 UI。 - 渲染层 -> 逻辑层: 用户在渲染层进行交互(如点击、输入),事件会被封装成 JSON 数据,通过 Bridge 转发到逻辑层进行处理。
总结:
小程序双线程架构是其区别于传统 H5 和原生 App 的重要特征。它通过将 JS 逻辑和 UI 渲染分离,在性能、安全和开发效率之间取得了平衡,使得小程序能够提供接近原生应用的流畅体验,同时保持 Web 开发的便捷性。
72. web应用中如何对静态资源加载失败的场景做降级处理
在 Web 应用中,静态资源(图片、CSS、JavaScript、字体等)加载失败是常见问题,可能由于网络不稳定、CDN 故障、资源 URL 错误、服务器问题等原因。对这些失败场景做降级处理,可以提升用户体验和应用的健壮性。
以下是针对不同类型静态资源加载失败的降级处理方式:
1. 图片加载失败 (<img>)
- 显示占位符或默认图片: 这是最常见的降级。
onerror属性: 在<img>标签上使用onerror事件,当图片加载失败时替换为备用图片。1<img src="path/to/image.jpg" alt="Description" onerror="this.src='path/to/placeholder.png'; this.onerror=null;"> 2<!-- this.onerror=null; 是为了防止备用图片也加载失败时陷入死循环 -->- CSS
background-image备用:1.img-container { 2 background-image: url('path/to/placeholder.png'); /* 备用背景图 */ 3 background-size: cover; 4 /* ... */ 5} 6.img-container img { 7 width: 100%; 8 height: 100%; 9 object-fit: cover; 10} 11/* 当 img 加载失败时,它会变成透明或缩小,露出背景图片 */
- 隐藏图片: 如果图片不重要,直接隐藏。
1<img src="path/to/image.jpg" alt="Description" onerror="this.style.display='none';"> - 上报错误: 通过前端监控系统上报图片加载失败信息。
1<img src="path/to/image.jpg" alt="Description" onerror="reportImageError(this.src);"> 2<script> 3function reportImageError(url) { 4 console.error('图片加载失败:', url); 5 // navigator.sendBeacon('/api/log/image_error', JSON.stringify({ url: url, page: window.location.href })); 6} 7</script>
2. CSS 文件加载失败 (<link rel="stylesheet">)
- 影响: 页面样式丢失,可能导致布局混乱(FOUC - Flash Of Unstyled Content)。
- 降级处理:
- CSS 放在
<head>: 确保 CSS 尽早加载,避免 FOUC。 - 重要的基础样式内联: 将少量关键的布局和排版样式内联到 HTML 中,即使外部 CSS 失败也能保证基本可读性。
- 使用
onerror属性(有限支持): 虽然<link>标签有onerror事件,但并非所有浏览器都完全支持或在所有错误场景下触发。
然后通过 JS 检查1<link rel="stylesheet" href="path/to/style.css" onerror="this.setAttribute('data-load-failed', 'true');">data-load-failed属性,并添加一个备用样式或提示。 - CSS 预处理器打包: 将多个 CSS 文件合并打包,减少请求数,降低单个文件失败的概率。
- 使用 CDN: 依赖 CDN 的高可用性。
- CSS 放在
3. JavaScript 文件加载失败 (<script>)
- 影响: 页面功能丧失,可能导致 JS 错误。
- 降级处理:
- 关键 JS 内联: 对于启动应用或基础交互的极少量关键 JS 代码,可以考虑内联。
- 使用
onerror属性: 可以在<script>标签上监听onerror事件。1<script src="path/to/app.js" onerror="handleJsError(this.src);"></script> 2<script> 3function handleJsError(url) { 4 console.error('JS 文件加载失败:', url); 5 alert('页面部分功能可能无法正常使用,请刷新重试。'); // 提示用户 6 // 可以加载一个备用 JS 文件,提供基础功能 7 // const fallbackScript = document.createElement('script'); 8 // fallbackScript.src = 'path/to/fallback-basic-js.js'; 9 // document.body.appendChild(fallbackScript); 10} 11</script> - 模块化与异步加载:
- 使用
defer或async属性加载非阻塞 JS,即使失败也不会阻塞 HTML 解析。 - 使用动态
import()或 Webpack 的import(),在catch块中处理加载失败。 - 将不同功能拆分成独立模块,即使某个模块加载失败,不影响其他模块。
- 使用
- 服务降级: 某些功能依赖的 JS 文件加载失败,可以将该功能按钮禁用或隐藏。
- 错误监控: 捕获
window.onerror或window.addEventListener('error', ...)上报 JS 文件加载错误。
4. 字体文件加载失败 (@font-face)
- 影响: 页面字体显示异常,可能回退到系统默认字体。
- 降级处理:
font-display属性: 在@font-face中使用font-display属性。swap:字体加载失败时立即回退到系统字体,加载成功后替换。fallback:字体加载失败时立即回退,加载成功后替换,但给一个很短的加载时间。optional:字体加载失败时立即回退,不再替换。
1@font-face { 2 font-family: 'MyCustomFont'; 3 src: url('my-custom-font.woff2') format('woff2'); 4 font-display: swap; /* 推荐,先显示系统字体,加载成功后替换 */ 5}- 使用 Web Font Loader (或类似库): 可以更精细地控制字体加载和回退。
5. 通用降级策略
- CDN: 将所有静态资源部署在高性能、高可用性的 CDN 上,这是最基本的保障。
- 多 CDN 策略 / 回源: 配置多个 CDN 服务商或在 CDN 失败时回源到自己的服务器。
- Subresource Integrity (SRI): 对外部 JS/CSS 文件添加哈希校验,防止 CDN 被篡改(尽管这主要是为了安全而非可用性,但如果校验失败也会阻止加载)。
1<script src="https://example.com/app.js" 2 integrity="sha384-xyz..." 3 crossorigin="anonymous"></script> - 前端监控: 实时上报资源加载失败,及时发现问题并报警。
- 离线缓存 (Service Worker): 使用 Service Worker 缓存静态资源,在网络不稳定或离线时仍能访问。
- 用户友好提示: 当关键资源加载失败导致功能异常时,向用户提供清晰的提示信息,引导用户刷新页面或寻求帮助。
通过多层次、多维度的降级处理,可以最大程度地保证 Web 应用在静态资源加载失败时的健壮性和用户体验。
73. html中前缀为data-开头的元素厘性是什么?
在 HTML 中,以 data- 开头的属性被称为 自定义数据属性 (Custom Data Attributes)。它们是 HTML5 引入的一个特性,允许开发者在标准的 HTML 元素上嵌入自定义的数据,而无需违反 HTML 规范。
作用和目的:
- 存储额外数据: 允许在 HTML 元素上存储与该元素相关但没有合适标准属性可用的自定义数据。
- JS 与 HTML 交互: 作为 JavaScript 和 HTML 之间数据交换的桥梁,使 JavaScript 能够方便地读取和操作这些数据,从而实现更灵活和数据驱动的交互。
- 避免全局变量: 相较于将数据存储在全局 JavaScript 变量中,将数据与相关的 DOM 元素绑定可以提高代码的组织性和可维护性。
- 无需依赖 JavaScript 框架: 即使不使用 React、Vue 等前端框架,也能方便地管理元素相关数据。
语法:
- 属性名必须以
data-开头。 data-之后的部分可以是任何小写字母、数字、连字符 (-)、点 (.)、冒号 (:) 或下划线 (_)。- 虽然 HTML 属性不区分大小写,但为了与 JavaScript 访问方式兼容,通常推荐使用小写字母和连字符的组合 (kebab-case)。
示例:
1<div id="product-card" 2 data-product-id="12345" 3 data-category="electronics" 4 data-price="999.99" 5 data-available="true" 6 data-description="This is a fantastic product." 7 data-tags="new,sale,popular"> 8 <h2>Awesome Gadget</h2> 9 <p>Price: $999.99</p> 10 <button class="add-to-cart">Add to Cart</button> 11</div>
JavaScript 访问:
在 JavaScript 中,可以通过元素的 dataset 属性来访问这些自定义数据属性。dataset 是一个 DOMStringMap 对象,它将 data- 属性名自动转换为驼峰式 (camelCase) 形式的键。
-
读取:
1const productCard = document.getElementById('product-card'); 2 3console.log(productCard.dataset.productId); // "12345" 4console.log(productCard.dataset.category); // "electronics" 5console.log(parseFloat(productCard.dataset.price)); // 999.99 (需要手动转换类型) 6console.log(productCard.dataset.available === 'true'); // true (需要手动转换布尔值) 7console.log(productCard.dataset.description); // "This is a fantastic product." 8console.log(productCard.dataset.tags.split(',')); // ["new", "sale", "popular"]注意:所有通过
dataset获取的值都是字符串类型,如果需要数字、布尔值或数组,需要手动进行类型转换。 -
设置/修改:
1productCard.dataset.price = '899.99'; // 修改价格 2productCard.dataset.available = 'false'; // 修改状态 3console.log(productCard.dataset.price); // "899.99" -
删除:
1delete productCard.dataset.tags; 2// 或者 productCard.removeAttribute('data-tags'); 3console.log(productCard.dataset.tags); // undefined
应用场景:
- 事件处理: 将事件相关的 ID、类型等数据直接绑定到触发元素上。
1<button data-action="delete" data-item-id="5">删除</button>1document.querySelector('button').addEventListener('click', (event) => { 2 const action = event.target.dataset.action; 3 const itemId = event.target.dataset.itemId; 4 if (action === 'delete') { 5 console.log(`执行删除操作,ID: ${itemId}`); 6 } 7}); - 组件状态/配置: 存储组件的初始化配置或状态。
1<div class="modal" data-modal-id="login" data-auto-open="true">...</div> - 过滤/排序: 存储用于前端过滤或排序的数据。
1<li data-color="red" data-size="M">Red T-shirt</li> - 统计/埋点: 记录元素相关的统计数据。
1<a href="/some-page" data-tracker-event="click_product" data-product-name="Laptop">Buy Now</a>
与 class 或 id 的区别:
class和id: 主要用于 CSS 样式和 JavaScript 选择器,通常不用于存储业务数据。它们有明确的语义和作用。data-属性: 专门用于存储自定义数据,其语义由开发者定义,且不会影响 CSS 或 JS 的默认行为。
总结:
data- 开头的自定义数据属性提供了一种将非语义化、自定义数据直接嵌入 HTML 元素的方法,使得 JavaScript 能够方便地获取和操作这些数据,从而实现了 HTML 和 JavaScript 之间更简洁、更直观的数据绑定和交互。这是前端开发中非常实用且被广泛接受的技术。