本文将详细讲解实战场景中的综合问题,包括性能优化、安全问题、跨浏览器兼容等内容,适合初学者阅读。
1.如何实现一个防抖函数?并说明其在搜索框输入联想场景中的应用
一、防抖函数的实现
防抖(Debounce)的核心思想是:当事件被频繁触发时,只在最后一次触发后等待指定时间再执行回调函数。如果在等待期间再次触发事件,则重新计时,避免函数被频繁执行。
基础版防抖函数实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
function debounce(func, delay) { let timer = null;
return function(...args) { if (timer) clearTimeout(timer);
timer = setTimeout(() => { func.apply(this, args); timer = null; }, delay); }; }
|
增强版防抖函数(支持立即执行)
有时需要“首次触发时立即执行,后续触发防抖”(如搜索框聚焦时立即请求一次,之后输入防抖),可增加 immediate 参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function debounce(func, delay, immediate = false) { let timer = null;
return function(...args) { if (timer) clearTimeout(timer);
if (immediate && !timer) { func.apply(this, args); }
timer = setTimeout(() => { if (!immediate) { func.apply(this, args); } timer = null; }, delay); }; }
|
二、防抖函数在搜索框输入联想场景中的应用
搜索框输入联想(如百度搜索时的实时提示)是防抖最典型的应用场景。用户输入过程中,每输入一个字符都会触发 input 事件,若直接在事件中发送请求,会导致:
- 短时间内发送大量 API 请求(如输入“前端面试”可能触发 5 次请求);
- 服务器压力增大,网络带宽浪费;
- 前端可能收到乱序的响应(后发的请求先返回),导致联想结果错误。
应用示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| <input type="text" id="searchInput" placeholder="请输入搜索关键词...">
<div id="suggestions"></div>
<script> const searchInput = document.getElementById('searchInput'); const suggestions = document.getElementById('suggestions');
function fetchSuggestions(keyword) { console.log(`发送请求:获取 "${keyword}" 的联想结果`);
suggestions.innerHTML = ` <div>联想结果:${keyword}1</div> <div>联想结果:${keyword}2</div> `; }
const debouncedFetch = debounce(fetchSuggestions, 300);
searchInput.addEventListener('input', function(e) { const keyword = e.target.value.trim(); if (keyword) { debouncedFetch(keyword); } else { suggestions.innerHTML = ''; } }); </script>
|
三、应用解析
减少请求次数:
用户快速输入时(如每秒输入 3 个字符),防抖函数会等待用户停止输入 300ms 后才发送一次请求,而非每次输入都请求,大幅减少 API 调用次数。
优化用户体验:
300ms 的延迟接近人类输入停顿的感知阈值,既不会让用户觉得“卡顿”,又能保证联想结果在输入停顿后快速出现。
避免响应乱序:
由于只在最后一次输入后发送请求,不会出现“前一次请求的响应晚于后一次”的情况,确保联想结果与当前输入一致。
四、关键参数选择
- 延迟时间(delay):通常设置为 200-500ms。搜索场景推荐 300ms(平衡性能与体验);表单验证(如实时检测用户名是否存在)可适当缩短(如 200ms)。
- 是否立即执行(immediate):搜索联想一般不需要立即执行(首次输入时等待用户输入一段内容),但类似“滚动加载更多”场景可能需要立即执行首次加载。
总结
防抖函数通过“延迟执行 + 重新计时”的机制,解决了高频事件(如输入、滚动、resize)导致的性能问题。在搜索框联想场景中,它能有效减少 API 请求次数,优化服务器资源和用户体验,是前端性能优化的重要手段。
2.如何实现一个节流函数?并说明其在滚动加载、按钮防重复点击场景中的应用
一、节流函数的实现
节流(Throttle)的核心思想是:限制函数在单位时间内最多执行一次。无论事件被触发多少次,只会按固定频率执行回调,避免函数被高频调用导致的性能问题。
基础版节流函数(时间戳 + 定时器结合)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
function throttle(func, interval) { let lastTime = 0; let timer = null;
return function(...args) { const now = Date.now(); const remaining = interval - (now - lastTime);
if (remaining <= 0) { if (timer) { clearTimeout(timer); timer = null; } func.apply(this, args); lastTime = now; } else if (!timer) { timer = setTimeout(() => { func.apply(this, args); lastTime = Date.now(); timer = null; }, remaining); } }; }
|
说明
- 结合时间戳和定时器的优点:既保证了单位时间内必执行一次(避免事件停止后漏执行),又能在高频触发时按固定频率执行(如每 100ms 一次)。
- 适用场景:需要“均匀执行”的高频事件(如滚动、resize、鼠标移动)。
二、节流函数的应用场景
场景 1:滚动加载更多(监听滚动事件)
当用户滚动页面时,需要判断是否到达底部以加载更多内容。若直接监听 scroll 事件,滚动过程中会触发数十次/秒的回调,导致频繁计算和请求,浪费性能。节流可将检查频率限制在合理范围(如每 200ms 一次)。
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| <div id="content" style="height: 2000px; border: 1px solid #ccc;"> </div> <div id="loading" style="display: none;">加载中...</div>
<script> const content = document.getElementById('content'); const loading = document.getElementById('loading');
function checkScrollBottom() { const scrollHeight = document.documentElement.scrollHeight; const scrollTop = document.documentElement.scrollTop || document.body.scrollTop; const clientHeight = document.documentElement.clientHeight;
if (scrollHeight - scrollTop - clientHeight <= 50) { loading.style.display = 'block'; console.log('加载更多数据...'); setTimeout(() => { loading.style.display = 'none'; }, 1000); } }
const throttledCheck = throttle(checkScrollBottom, 200);
window.addEventListener('scroll', throttledCheck); </script>
|
效果:用户快速滚动时,checkScrollBottom 每 200ms 最多执行一次,避免频繁计算和请求,减少性能消耗。
场景 2:按钮防重复点击(如表单提交)
用户可能因网络延迟等原因快速点击提交按钮,导致重复提交表单(如重复创建订单、重复发送请求)。节流可限制按钮在一定时间内(如 1000ms)只能点击一次,直到前一次请求完成或超时。
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <button id="submitBtn">提交订单</button>
<script> const submitBtn = document.getElementById('submitBtn');
function submitForm() { console.log('提交订单请求...'); submitBtn.disabled = true; setTimeout(() => { console.log('订单提交成功'); submitBtn.disabled = false; }, 1000); }
const throttledSubmit = throttle(submitForm, 1000);
submitBtn.addEventListener('click', throttledSubmit); </script>
|
效果:用户 1 秒内多次点击按钮,submitForm 只会执行一次,配合按钮禁用状态,彻底防止重复提交。
三、节流与防抖的核心区别
| 场景 |
节流(Throttle) |
防抖(Debounce) |
| 核心逻辑 |
单位时间内只执行一次 |
事件停止触发后延迟执行一次 |
| 适用场景 |
滚动加载、按钮防重复点击、resize |
搜索框联想、输入验证 |
| 执行频率 |
均匀执行(如每 200ms 一次) |
最后一次触发后执行 |
总结
节流函数通过控制“单位时间内函数的最大执行次数”,解决了高频事件(滚动、点击、resize)的性能问题。在滚动加载场景中,它能平衡“实时性”和“性能消耗”;在按钮防重复点击场景中,它能避免重复操作导致的业务异常。实际开发中需根据场景选择节流(均匀执行)或防抖(最后执行),两者都是前端性能优化的重要手段。
3.如何实现一个深拷贝函数?需要考虑哪些边界情况(如循环引用、特殊类型)?
一、深拷贝函数的实现
深拷贝的核心是创建一个新对象,完整复制原对象的所有属性(包括嵌套属性),且新对象与原对象完全独立(修改新对象不会影响原对象)。与浅拷贝(仅复制顶层属性引用)不同,深拷贝需要递归处理嵌套结构。
以下是一个考虑多种边界情况的深拷贝实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| function deepClone(target, hash = new WeakMap()) { if (target === null || typeof target !== 'object') { return target; }
if (hash.has(target)) { return hash.get(target); }
const type = Object.prototype.toString.call(target);
if (type === '[object Date]') { const clone = new Date(target); hash.set(target, clone); return clone; }
if (type === '[object RegExp]') { const clone = new RegExp(target.source, target.flags); clone.lastIndex = target.lastIndex; hash.set(target, clone); return clone; }
if (type === '[object Map]') { const clone = new Map(); hash.set(target, clone); target.forEach((value, key) => { clone.set(deepClone(key, hash), deepClone(value, hash)); }); return clone; }
if (type === '[object Set]') { const clone = new Set(); hash.set(target, clone); target.forEach(value => { clone.add(deepClone(value, hash)); }); return clone; }
const clone = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target)); hash.set(target, clone);
Reflect.ownKeys(target).forEach(key => { clone[key] = deepClone(target[key], hash); });
return clone; }
|
二、需要考虑的边界情况及处理方式
1. 循环引用(核心问题)
场景:对象的属性引用自身(如 obj.self = obj)或相互引用(如 a.b = b; b.a = a)。
问题:若不处理,递归拷贝会陷入无限循环,导致栈溢出。
解决:用 WeakMap 记录“原对象 → 拷贝对象”的映射,每次拷贝前检查是否已存在该对象,若存在则直接返回已拷贝的对象(避免重复拷贝)。
2. 特殊数据类型
JavaScript 中除了普通对象(Object)和数组(Array),还有多种内置引用类型,需单独处理:
| 类型 |
特点 |
处理方式 |
Date |
日期对象,值存储在内部时间戳 |
用 new Date(target) 创建新实例,继承原时间戳。 |
RegExp |
正则对象,包含 source(模式)和 flags(修饰符) |
用 new RegExp(target.source, target.flags) 创建新实例,复制 lastIndex 属性。 |
Map/Set |
集合类型,存储键值对/唯一值 |
创建新 Map/Set 实例,递归拷贝内部的每个键和值(Map 需拷贝键和值,Set 拷贝元素)。 |
Function |
函数对象(不建议深拷贝) |
函数的作用域和闭包难以完整拷贝,通常直接返回原函数(实际场景中很少需要拷贝函数)。 |
Symbol |
基本类型,但可作为对象键 |
作为值时直接返回(基本类型);作为键时用 Reflect.ownKeys 获取并拷贝。 |
3. 原型链与继承
场景:对象通过原型链继承属性(如 const obj = Object.create(proto))。
问题:若仅拷贝自身属性,新对象会丢失原型链关联。
解决:用 Object.create(Object.getPrototypeOf(target)) 创建新对象,继承原对象的原型链,确保原型上的方法可正常访问。
4. 不可枚举属性与Symbol键
场景:对象包含不可枚举属性(如 Object.defineProperty 定义的属性)或 Symbol 类型的键。
问题:for...in 或 Object.keys 无法获取这些属性,导致拷贝不完整。
解决:用 Reflect.ownKeys(target) 获取所有键(包括不可枚举键和 Symbol 键),确保所有属性被拷贝。
5. 稀疏数组
场景:数组存在空槽(如 [1, , 3])。
问题:直接遍历可能跳过空槽,导致拷贝后空槽被填充为 undefined。
解决:通过数组索引遍历(而非 forEach),保留空槽特性(JavaScript 数组的空槽与 undefined 不同)。
三、使用示例与验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const obj = { a: 1, b: { c: 2 } }; const cloneObj = deepClone(obj); cloneObj.b.c = 3; console.log(obj.b.c);
const loopObj = { name: 'loop' }; loopObj.self = loopObj; const cloneLoop = deepClone(loopObj); console.log(cloneLoop.self === cloneLoop);
const date = new Date('2023-01-01'); const cloneDate = deepClone(date); console.log(cloneDate instanceof Date); console.log(cloneDate.getTime() === date.getTime());
const reg = /test/gim; const cloneReg = deepClone(reg); console.log(cloneReg.source === reg.source); console.log(cloneReg.flags === reg.flags);
|
四、与 JSON 序列化的对比
常见的简易深拷贝方式 JSON.parse(JSON.stringify(target)) 存在明显局限性,无法处理:
- 循环引用(直接报错);
- 特殊类型(如
Date 会被转为字符串,RegExp 会被转为空对象);
Function、Symbol 等类型(会被忽略)。
因此,对于复杂场景,必须使用手动实现的深拷贝函数(如上述代码)。
总结
实现深拷贝需核心处理:循环引用(用 WeakMap 记录映射)和特殊类型(针对性拷贝),同时兼顾原型链、不可枚举属性等边缘情况。深拷贝函数在前端面试中常用于状态管理(如 React/Vue 的状态复制)、对象快照等场景,确保数据操作的安全性。
4.如何实现一个虚拟列表组件?(要求支持动态高度、滚动加载)
虚拟列表组件实现
虚拟列表(Virtual List)是一种优化长列表性能的技术,通过只渲染可视区域内的项目来减少DOM节点数量,大幅提升滚动流畅度。下面实现一个支持动态高度和滚动加载的虚拟列表组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>动态高度虚拟列表</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#3b82f6', secondary: '#64748b', neutral: '#f1f5f9', }, fontFamily: { inter: ['Inter', 'sans-serif'], }, }, } } </script> <style type="text/tailwindcss"> @layer utilities { .content-auto { content-visibility: auto; } .virtual-list-container { @apply relative overflow-auto border rounded-lg bg-white; } .virtual-list-placeholder { @apply relative; } .virtual-list-items { @apply absolute top-0 left-0 w-full; } .list-item { @apply p-4 border-b last:border-0 hover:bg-neutral transition-colors duration-150; } .loading-indicator { @apply flex justify-center items-center p-4 text-secondary; } } </style> </head> <body class="bg-gray-50 font-inter p-4 md:p-8"> <div class="max-w-4xl mx-auto"> <h1 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-800 mb-6"> 动态高度虚拟列表示例 </h1> <p class="text-gray-600 mb-6"> 这个虚拟列表组件仅渲染可视区域内的项目,支持动态高度和滚动加载,可高效处理大量数据。 </p> <div class="h-[500px]"> <div id="virtualList" class="virtual-list-container w-full h-full"></div> </div> <div class="mt-6 text-sm text-gray-500"> <p><span class="font-semibold">提示:</span>向下滚动加载更多项目,每个项目高度随机生成以模拟动态高度场景</p> </div> </div>
<script> class VirtualList {
constructor(containerId, options = {}) { this.container = document.getElementById(containerId); this.itemCount = options.initialCount || 50; this.estimatedItemHeight = options.estimatedHeight || 80; this.bufferSize = options.bufferSize || 5; this.loadMoreThreshold = options.loadMoreThreshold || 200; this.loadMoreCallback = options.onLoadMore || (() => {}); this.items = []; this.itemHeights = new Map(); this.visibleStartIndex = 0; this.visibleEndIndex = 0; this.scrollTop = 0; this.isLoading = false; this.createDomStructure(); this.bindEvents(); this.initData(); this.render(); }
createDomStructure() { this.container.classList.add('virtual-list-container'); this.placeholder = document.createElement('div'); this.placeholder.className = 'virtual-list-placeholder'; this.container.appendChild(this.placeholder); this.itemsContainer = document.createElement('div'); this.itemsContainer.className = 'virtual-list-items'; this.container.appendChild(this.itemsContainer); this.loadingIndicator = document.createElement('div'); this.loadingIndicator.className = 'loading-indicator hidden'; this.loadingIndicator.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 加载中...'; this.container.appendChild(this.loadingIndicator); }
bindEvents() { this.container.addEventListener('scroll', this.handleScroll.bind(this)); window.addEventListener('resize', this.handleResize.bind(this)); }
initData() { for (let i = 0; i < this.itemCount; i++) { const contentLength = Math.floor(Math.random() * 3) + 1; let content = ''; for (let j = 0; j < contentLength; j++) { content += `这是列表项 #${i + 1} 的内容。 `; } this.items.push({ id: i, content: content.repeat(3) }); } }
handleScroll() { const scrollTop = this.container.scrollTop; this.scrollTop = scrollTop; this.calculateVisibleRange(); this.renderVisibleItems(); this.checkLoadMore(); }
handleResize() { this.calculateVisibleRange(); this.renderVisibleItems(); }
calculateVisibleRange() { const containerHeight = this.container.clientHeight; const scrollTop = this.scrollTop; this.calculateTotalHeight(); let startIndex = 0; let endIndex = this.items.length - 1; let cumulativeHeight = 0; for (let i = 0; i < this.items.length; i++) { const itemHeight = this.itemHeights.get(i) || this.estimatedItemHeight; if (cumulativeHeight + itemHeight > scrollTop) { startIndex = i; break; } cumulativeHeight += itemHeight; } startIndex = Math.max(0, startIndex - this.bufferSize); cumulativeHeight = 0; for (let i = 0; i < this.items.length; i++) { const itemHeight = this.itemHeights.get(i) || this.estimatedItemHeight; cumulativeHeight += itemHeight; if (cumulativeHeight > scrollTop + containerHeight) { endIndex = i; break; } } endIndex = Math.min(this.items.length - 1, endIndex + this.bufferSize); this.visibleStartIndex = startIndex; this.visibleEndIndex = endIndex; }
calculateTotalHeight() { let totalHeight = 0; for (let i = 0; i < this.items.length; i++) { totalHeight += this.itemHeights.get(i) || this.estimatedItemHeight; } this.placeholder.style.height = `${totalHeight}px`; return totalHeight; }
renderVisibleItems() { this.itemsContainer.innerHTML = ''; let offsetY = 0; for (let i = 0; i < this.visibleStartIndex; i++) { offsetY += this.itemHeights.get(i) || this.estimatedItemHeight; } this.itemsContainer.style.transform = `translateY(${offsetY}px)`; for (let i = this.visibleStartIndex; i <= this.visibleEndIndex; i++) { const item = this.items[i]; if (!item) continue; const itemElement = this.createItemElement(item, i); this.itemsContainer.appendChild(itemElement); this.updateItemHeight(i, itemElement); } }
createItemElement(item, index) { const element = document.createElement('div'); element.className = 'list-item'; element.dataset.index = index; element.innerHTML = ` <div class="font-semibold text-primary mb-1">项目 #${item.id + 1}</div> <div class="text-gray-700">${item.content}</div> `; return element; }
updateItemHeight(index, element) { requestAnimationFrame(() => { const height = element.offsetHeight; if (this.itemHeights.get(index) !== height) { this.itemHeights.set(index, height); this.calculateTotalHeight(); this.renderVisibleItems(); } }); }
checkLoadMore() { const totalHeight = this.calculateTotalHeight(); const containerHeight = this.container.clientHeight; const scrollTop = this.scrollTop; if ( totalHeight - scrollTop - containerHeight <= this.loadMoreThreshold && !this.isLoading ) { this.loadMore(); } }
async loadMore() { this.isLoading = true; this.loadingIndicator.classList.remove('hidden'); try { await new Promise(resolve => setTimeout(resolve, 1000)); const start = this.items.length; const end = start + 30; for (let i = start; i < end; i++) { const contentLength = Math.floor(Math.random() * 3) + 1; let content = ''; for (let j = 0; j < contentLength; j++) { content += `这是列表项 #${i + 1} 的内容。 `; } this.items.push({ id: i, content: content.repeat(3) }); } this.calculateVisibleRange(); this.renderVisibleItems(); this.loadMoreCallback(this.items.length); } catch (error) { console.error('加载更多失败:', error); } finally { this.isLoading = false; this.loadingIndicator.classList.add('hidden'); } }
render() { this.calculateTotalHeight(); this.calculateVisibleRange(); this.renderVisibleItems(); } } document.addEventListener('DOMContentLoaded', () => { new VirtualList('virtualList', { initialCount: 50, estimatedHeight: 80, bufferSize: 5, loadMoreThreshold: 200, onLoadMore: (totalCount) => { console.log(`已加载 ${totalCount} 个项目`); } }); }); </script> </body> </html>
|
实现说明
这个虚拟列表组件的核心原理是只渲染可视区域内的项目,而非全部项目,从而大幅减少DOM节点数量,提升性能。主要实现要点如下:
核心功能
动态高度支持:
- 使用
Map存储每个已渲染项目的实际高度
- 对未渲染项目使用预估高度
- 项目渲染后通过
offsetHeight获取实际高度并更新布局
滚动加载:
- 监听滚动事件,当接近底部(小于阈值)时触发加载
- 加载过程中显示加载指示器
- 支持自定义加载阈值和加载回调
性能优化:
- 可视区域外的项目不渲染
- 增加缓冲区域(bufferSize),提前渲染即将进入可视区域的项目
- 使用
requestAnimationFrame确保高度计算的准确性
- 仅在高度变化时更新布局,避免不必要的重绘
实现细节
- DOM结构:包含三个主要部分 - 容器(提供滚动区域)、占位元素(撑开列表高度)、项目容器(渲染可视项目)
- 计算逻辑:通过累计高度计算可视区域的起始和结束索引
- 事件处理:监听滚动和窗口大小变化事件,动态调整渲染内容
- 动态高度处理:每个项目渲染后测量实际高度,并更新布局
这个实现可以高效处理大量数据(成千上万的列表项),同时支持每个项目有不同的高度,适用于内容长度不固定的场景,如动态文本、评论列表等。
5.如何实现一个图片懒加载组件?(分别使用原生 loading 属性和 IntersectionObserver)
图片懒加载组件实现
图片懒加载是一种优化技术,它会延迟加载当前视口之外的图片,当用户滚动到图片附近时才会加载,这可以显著提高页面加载速度并减少带宽消耗。下面分别使用原生 loading 属性和 IntersectionObserver API 实现图片懒加载组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>图片懒加载组件实现</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#4f46e5', secondary: '#64748b', neutral: '#f1f5f9', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .content-auto { content-visibility: auto; } .lazy-image-container { @apply relative overflow-hidden rounded-lg bg-neutral mb-8 shadow-sm transition-all duration-300 hover:shadow-md; } .image-placeholder { @apply absolute inset-0 flex items-center justify-center bg-gray-100; } .image-loading { @apply animate-pulse text-gray-400; } .image-loaded { @apply opacity-100 transition-opacity duration-300; } .section-title { @apply text-2xl font-bold mb-6 text-gray-800 border-b pb-2; } } </style> </head> <body class="bg-gray-50 font-sans p-4 md:p-8"> <div class="max-w-5xl mx-auto"> <header class="mb-12 text-center"> <h1 class="text-[clamp(1.8rem,4vw,3rem)] font-bold text-gray-800 mb-4">图片懒加载实现示例</h1> <p class="text-gray-600 max-w-3xl mx-auto"> 展示两种图片懒加载实现方式:原生 loading 属性(简单高效)和 IntersectionObserver API(灵活可控)。 向下滚动页面查看懒加载效果,打开控制台可观察图片加载时机。 </p> </header> <section class="mb-16"> <h2 class="section-title"> <i class="fa fa-bolt text-primary mr-2"></i>原生 loading 属性实现 </h2> <p class="text-gray-600 mb-6"> 最简单的实现方式,只需添加 <code class="bg-gray-100 px-1 py-0.5 rounded text-primary">loading="lazy"</code> 属性, 由浏览器原生支持,无需额外 JavaScript。 </p> <div id="native-lazy-loading" class="space-y-8"> </div> </section> <section> <h2 class="section-title"> <i class="fa fa-cogs text-primary mr-2"></i>IntersectionObserver 实现 </h2> <p class="text-gray-600 mb-6"> 更灵活的实现方式,使用 IntersectionObserver API 监听元素可见性, 可自定义加载阈值、加载动画和错误处理。 </p> <div id="intersection-lazy-loading" class="space-y-8"> </div> </section> </div>
<script> const generateImageData = (count, prefix) => { return Array.from({ length: count }, (_, i) => ({ id: `${prefix}-${i + 1}`, width: Math.floor(Math.random() * 300) + 600, height: Math.floor(Math.random() * 200) + 300, category: ['nature', 'city', 'people', 'animals', 'architecture'][Math.floor(Math.random() * 5)] })); }; const initNativeLazyLoading = () => { const container = document.getElementById('native-lazy-loading'); const images = generateImageData(15, 'native'); images.forEach(img => { const wrapper = document.createElement('div'); wrapper.className = 'lazy-image-container'; wrapper.style.height = `${img.height}px`; const placeholder = document.createElement('div'); placeholder.className = 'image-placeholder'; placeholder.innerHTML = '<i class="fa fa-image text-3xl text-gray-300"></i>'; const imgElement = document.createElement('img'); imgElement.className = 'w-full h-full object-cover opacity-0'; imgElement.src = `https://picsum.photos/seed/${img.id}/${img.width}/${img.height}`; imgElement.alt = `示例图片 ${img.id} (${img.category})`; imgElement.loading = 'lazy'; imgElement.onload = () => { imgElement.classList.add('image-loaded'); placeholder.remove(); console.log(`原生懒加载: 图片 ${img.id} 已加载`); }; wrapper.appendChild(placeholder); wrapper.appendChild(imgElement); container.appendChild(wrapper); }); }; const initIntersectionLazyLoading = () => { const container = document.getElementById('intersection-lazy-loading'); const images = generateImageData(15, 'intersection'); const observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const imgElement = entry.target; const src = imgElement.dataset.src; const placeholder = imgElement.previousElementSibling; placeholder.innerHTML = '<i class="fa fa-spinner fa-spin text-3xl text-primary"></i>'; imgElement.src = src; imgElement.onload = () => { imgElement.classList.add('image-loaded'); placeholder.remove(); observer.unobserve(imgElement); console.log(`Intersection懒加载: 图片 ${imgElement.dataset.id} 已加载`); }; imgElement.onerror = () => { placeholder.innerHTML = '<i class="fa fa-exclamation-triangle text-3xl text-yellow-500"></i>'; console.error(`图片 ${imgElement.dataset.id} 加载失败`); }; } }); }, { rootMargin: '200px 0px', threshold: 0.01 }); images.forEach(img => { const wrapper = document.createElement('div'); wrapper.className = 'lazy-image-container'; wrapper.style.height = `${img.height}px`; const placeholder = document.createElement('div'); placeholder.className = 'image-placeholder'; placeholder.innerHTML = '<i class="fa fa-image text-3xl text-gray-300"></i>'; const imgElement = document.createElement('img'); imgElement.className = 'w-full h-full object-cover opacity-0'; imgElement.dataset.src = `https://picsum.photos/seed/${img.id}/${img.width}/${img.height}`; imgElement.dataset.id = img.id; imgElement.alt = `示例图片 ${img.id} (${img.category})`; wrapper.appendChild(placeholder); wrapper.appendChild(imgElement); container.appendChild(wrapper); observer.observe(imgElement); }); }; document.addEventListener('DOMContentLoaded', () => { initNativeLazyLoading(); initIntersectionLazyLoading(); }); </script> </body> </html>
|
实现说明
两种图片懒加载实现方式各有特点,适用于不同场景:
1. 原生 loading 属性实现
实现原理:
- 利用 HTML5 新增的
loading="lazy" 属性,由浏览器原生支持懒加载
- 只需在
<img> 标签上添加 loading="lazy" 即可启用
- 浏览器会自动判断图片何时进入视口附近,并触发加载
优点:
- 实现简单,无需编写额外 JavaScript 代码
- 浏览器原生优化,性能最佳
- 自动处理各种边缘情况(如快速滚动)
缺点:
- 自定义程度低,无法添加加载动画或自定义加载阈值
- 存在一定的兼容性问题(主要是旧浏览器)
- 无法精确控制加载时机
适用场景:
- 简单的图片展示需求
- 希望用最少的代码实现懒加载
- 目标用户使用现代浏览器
2. IntersectionObserver 实现
实现原理:
- 使用 IntersectionObserver API 监听图片元素与视口的交叉状态
- 图片初始不设置
src 属性,而是将真实地址存放在 data-src 中
- 当图片进入视口(或指定的阈值范围)时,将
data-src 赋值给 src 属性,触发加载
优点:
- 高度可定制,可自定义加载阈值、加载动画和错误处理
- 兼容性更好(可通过 polyfill 支持旧浏览器)
- 可以精确控制加载时机和行为
缺点:
- 需要编写更多代码
- 相比原生实现,性能略低(但现代浏览器优化良好)
适用场景:
- 需要自定义加载行为和动画效果
- 需要支持较旧的浏览器
- 复杂的页面布局和交互需求
在实际项目中,可以根据具体需求选择合适的实现方式。对于大多数现代网站,原生 loading="lazy" 属性已经足够满足需求,而对于需要更多控制的场景,则可以选择 IntersectionObserver 实现。
6.如何实现一个简易的 AJAX 请求函数?(支持 GET、POST 方法,处理成功和失败回调)
简易 AJAX 请求函数实现
AJAX(Asynchronous JavaScript and XML)是一种在不重新加载整个页面的情况下,与服务器交换数据并更新部分页面的技术。以下实现一个支持 GET/POST 方法、处理成功/失败回调的简易 AJAX 函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
|
function ajax(options) { const { url, method = 'GET', data = null, success = () => {}, fail = () => {} } = options;
const xhr = new XMLHttpRequest();
let dataStr = ''; if (data) { dataStr = Object.entries(data) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); }
if (method.toUpperCase() === 'GET' && dataStr) { url.indexOf('?') > -1 ? (url += `&${dataStr}`) : (url += `?${dataStr}`); }
xhr.open(method, url, true);
if (method.toUpperCase() === 'POST') { xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); }
xhr.onreadystatechange = () => { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { try { const responseData = JSON.parse(xhr.responseText); success(responseData); } catch (e) { success(xhr.responseText); } } else { fail(new Error(`请求失败,状态码:${xhr.status}`)); } } };
xhr.onerror = () => { fail(new Error('网络错误,无法完成请求')); };
const sendData = method.toUpperCase() === 'POST' ? dataStr : null; xhr.send(sendData); }
|
函数说明与使用示例
核心功能解析
- 参数处理:支持配置请求地址(
url)、方法(method,默认 GET)、数据(data)、成功回调(success)、失败回调(fail)。
- 数据序列化:将传入的对象数据(
{ key: value })转换为 key=value&key2=value2 格式,适配 HTTP 传输。
- 请求区分:
- GET 方法:数据拼接在 URL 末尾(查询字符串)。
- POST 方法:数据放在请求体中,并设置
Content-Type: application/x-www-form-urlencoded 头。
- 状态处理:
- 当
readyState=4(请求完成)且状态码为 2xx 时,触发成功回调(自动解析 JSON 响应)。
- 非 2xx 状态码或网络错误时,触发失败回调(返回错误信息)。
使用示例
1. GET 请求(获取数据)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ajax({ url: '/api/users', method: 'GET', data: { page: 1, limit: 10 }, success: (data) => { console.log('获取用户列表成功:', data); }, fail: (error) => { console.error('获取用户列表失败:', error.message); } });
|
2. POST 请求(提交数据)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ajax({ url: '/api/login', method: 'POST', data: { username: 'admin', password: '123456' }, success: (data) => { console.log('登录成功:', data); }, fail: (error) => { console.error('登录失败:', error.message); } });
|
扩展方向
- 支持 JSON 格式数据(设置
Content-Type: application/json 并序列化数据为 JSON 字符串)。
- 增加请求超时处理(
xhr.timeout 和 on timeout 事件)。
- 支持请求头自定义(允许传入
headers 参数设置额外请求头)。
- 增加取消请求功能(通过
xhr.abort() 实现)。
该函数覆盖了 AJAX 的核心功能,适合简单场景使用,理解其原理后可根据需求扩展更复杂的功能。
7.如何实现一个简易的路由系统?(支持 hash 模式,实现路由跳转和参数获取)
简易 hash 模式路由系统实现
hash 模式路由利用 URL 中的 # 后面的部分(哈希值)实现路由跳转,其特点是哈希值变化不会触发页面刷新,仅通过前端逻辑控制视图切换。以下实现一个支持路由定义、跳转、参数获取的简易路由系统。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>简易 Hash 路由系统</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#3b82f6', secondary: '#64748b', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .nav-link { @apply px-4 py-2 rounded hover:bg-primary/10 transition-colors duration-200; } .nav-link.active { @apply bg-primary text-white; } .route-container { @apply min-h-[300px] p-6 border rounded-lg bg-white mt-6; } } </style> </head> <body class="bg-gray-50 p-4 md:p-8"> <div class="max-w-4xl mx-auto"> <h1 class="text-2xl md:text-3xl font-bold text-gray-800 mb-6">简易 Hash 路由系统</h1> <nav class="flex flex-wrap gap-2 mb-4"> <a href="#/" class="nav-link" data-route="/">首页</a> <a href="#/user" class="nav-link" data-route="/user">用户列表</a> <a href="#/user/123" class="nav-link" data-route="/user/123">用户详情(ID:123)</a> <a href="#/about?name=路由系统&version=1.0" class="nav-link" data-route="/about">关于页(带参数)</a> </nav> <div id="router-view" class="route-container"></div> <div class="mt-6"> <button id="btn-to-home" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 mr-2"> 编程式跳转到首页 </button> <button id="btn-to-user" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90"> 编程式跳转到用户页 </button> </div> </div>
<script> class Router {
constructor(options) { this.routes = options.routes || []; this.el = document.querySelector(options.el); this.currentRoute = null; this.routeMap = this.routes.reduce((map, route) => { map[route.path] = route; return map; }, {}); this.handleHashChange = this.handleHashChange.bind(this); this.init(); }
init() { window.addEventListener('hashchange', this.handleHashChange); window.addEventListener('load', this.handleHashChange); }
handleHashChange() { const hash = this.getHash(); const { path, params } = this.parseHash(hash); const matchedRoute = this.matchRoute(path); if (matchedRoute) { this.currentRoute = { ...matchedRoute, params }; this.render(); this.updateNavActive(); } else { console.warn(`未匹配到路由: ${path}`); } }
getHash() { let hash = window.location.hash; return hash ? hash.slice(1) : '/'; }
parseHash(hash) { const [pathPart, queryPart] = hash.split('?'); const path = pathPart || '/'; const params = {}; if (queryPart) { queryPart.split('&').forEach(pair => { const [key, value] = pair.split('='); if (key) { params[key] = decodeURIComponent(value || ''); } }); } const dynamicParams = this.parseDynamicParams(path); return { path, params: { ...dynamicParams, ...params } }; }
parseDynamicParams(path) { const params = {}; this.routes.forEach(route => { const routeReg = new RegExp( `^${route.path.replace(/:([^\/]+)/g, '([^\/]+)')}$` ); const match = path.match(routeReg); if (match) { const keys = route.path.match(/:([^\/]+)/g)?.map(key => key.slice(1)) || []; keys.forEach((key, index) => { params[key] = match[index + 1]; }); } }); return params; }
matchRoute(path) { if (this.routeMap[path]) { return this.routeMap[path]; } for (const route of this.routes) { const routeReg = new RegExp( `^${route.path.replace(/:([^\/]+)/g, '([^\/]+)')}$` ); if (routeReg.test(path)) { return route; } } return null; }
render() { if (!this.el || !this.currentRoute) return; const { component, params } = this.currentRoute; const html = typeof component === 'function' ? component(params) : component; this.el.innerHTML = html; }
updateNavActive() { const currentPath = this.currentRoute?.path; document.querySelectorAll('.nav-link').forEach(link => { const route = link.getAttribute('data-route'); if (route === currentPath) { link.classList.add('active'); } else { link.classList.remove('active'); } }); }
push(path) { window.location.hash = path; } } const HomeComponent = () => ` <div> <h2 class="text-xl font-bold mb-4 text-primary">首页</h2> <p class="text-gray-700">这是首页内容,欢迎使用简易路由系统!</p> </div> `; const UserListComponent = () => ` <div> <h2 class="text-xl font-bold mb-4 text-primary">用户列表</h2> <ul class="list-disc list-inside text-gray-700"> <li>用户1:ID=123</li> <li>用户2:ID=456</li> <li>用户3:ID=789</li> </ul> </div> `; const UserDetailComponent = (params) => ` <div> <h2 class="text-xl font-bold mb-4 text-primary">用户详情</h2> <p class="text-gray-700">用户ID:${params.id || '未知'}</p> <p class="text-gray-700 mt-2"> <button onclick="router.push('/user')" class="text-primary hover:underline"> <i class="fa fa-arrow-left mr-1"></i>返回用户列表 </button> </p> </div> `; const AboutComponent = (params) => ` <div> <h2 class="text-xl font-bold mb-4 text-primary">关于页</h2> <p class="text-gray-700">页面名称:${params.name || '未知'}</p> <p class="text-gray-700">版本号:${params.version || '未知'}</p> </div> `; const routes = [ { path: '/', component: HomeComponent }, { path: '/user', component: UserListComponent }, { path: '/user/:id', component: UserDetailComponent }, { path: '/about', component: AboutComponent } ]; const router = new Router({ routes, el: '#router-view' }); document.getElementById('btn-to-home').addEventListener('click', () => { router.push('/'); }); document.getElementById('btn-to-user').addEventListener('click', () => { router.push('/user'); }); window.router = router; </script> </body> </html>
|
实现说明
该路由系统基于 hash 模式,核心功能包括路由定义、hash 变化监听、路由匹配、参数解析和视图渲染,具体实现要点如下:
1. 核心原理
- hash 模式:利用 URL 中
# 后的哈希值(如 #/user/123)作为路由标识,哈希变化会触发 hashchange 事件,通过监听该事件实现路由切换。
- 路由匹配:将定义的路由规则与当前哈希路径匹配,找到对应的组件并渲染。
- 参数解析:支持两种参数形式——查询参数(如
?name=test)和动态路由参数(如 /user/:id)。
2. 核心模块
(1)路由配置与初始化
- 通过
routes 数组定义路由规则,每个规则包含 path(路由路径)和 component(对应组件,可为函数或 HTML 字符串)。
- 初始化时创建路由映射表,监听
hashchange 和 load 事件,确保页面加载和哈希变化时都能触发路由更新。
(2)哈希解析与参数提取
- 路径处理:通过
getHash() 提取当前哈希值(去掉 #),默认路径为 /。
- 参数解析:
- 查询参数:解析
? 后的键值对(如 ?name=路由系统 → { name: '路由系统' })。
- 动态路由参数:通过正则匹配
:param 格式的动态路径(如 /user/:id 匹配 /user/123 → { id: '123' })。
(3)路由匹配与视图渲染
- 匹配逻辑:先尝试精确匹配,再通过正则匹配动态路由。
- 渲染机制:找到匹配的路由后,执行组件函数(传入参数)获取 HTML,插入到挂载点(
#router-view)。
(4)路由跳转方式
- 声明式跳转:通过
<a href="#/path"> 标签修改哈希值。
- 编程式跳转:提供
push(path) 方法,通过 window.location.hash = path 实现跳转。
3. 关键功能亮点
- 动态路由支持:通过
:param 定义动态路径,如 /user/:id 可匹配任意用户 ID。
- 参数自动解析:自动提取查询参数和动态参数,传入组件供使用。
- 导航状态同步:自动更新导航栏激活状态,高亮当前路由。
总结
该实现覆盖了 hash 模式路由的核心功能,适合理解前端路由的基本原理。实际框架(如 Vue Router、React Router)在此基础上增加了嵌套路由、路由守卫、历史模式(History API)等高级功能,但核心思想一致——通过监听 URL 变化,匹配对应的组件并渲染。
8.如何实现一个简易的 Vue 响应式系统?(基于 Object.defineProperty 或 Proxy)
简易 Vue 响应式系统实现
Vue 响应式系统的核心是数据劫持与依赖收集:当数据变化时,自动触发依赖该数据的视图或逻辑更新。以下分别基于 Object.defineProperty(Vue 2 方式)和 Proxy(Vue 3 方式)实现简易版本。
一、基于 Proxy 的实现(推荐,支持数组和动态属性)
Proxy 能直接代理整个对象,支持数组变化监听和动态添加属性,比 Object.defineProperty 更强大。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
| class Dep { constructor() { this.subscribers = new Set(); }
depend() { if (activeWatcher) { this.subscribers.add(activeWatcher); } }
notify() { this.subscribers.forEach(watcher => watcher.update()); } }
class Watcher { constructor(target, key, callback) { this.target = target; this.key = key; this.callback = callback; activeWatcher = this; this.target[this.key]; activeWatcher = null; }
update() { this.callback(this.target[this.key]); } }
let activeWatcher = null;
function reactive(target) { if (typeof target !== 'object' || target === null) { return target; }
const dep = new Dep();
if (Array.isArray(target)) { const originalMethods = Array.prototype; const arrayMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']; arrayMethods.forEach(method => { target[method] = function(...args) { const result = originalMethods[method].apply(this, args); dep.notify(); return result; }; }); }
return new Proxy(target, { get(target, key, receiver) { const value = Reflect.get(target, key, receiver); dep.depend(); return reactive(value); }, set(target, key, value, receiver) { const oldValue = Reflect.get(target, key, receiver); if (oldValue === value) return true; const result = Reflect.set(target, key, value, receiver); dep.notify(); return result; }, deleteProperty(target, key) { const result = Reflect.deleteProperty(target, key); dep.notify(); return result; } }); }
const data = reactive({ name: 'Vue', count: 0, list: [1, 2, 3] });
new Watcher(data, 'name', (newValue) => { console.log(`视图更新:name变为 ${newValue}`); });
new Watcher(data, 'count', (newValue) => { console.log(`视图更新:count变为 ${newValue}`); });
new Watcher(data, 'list', (newValue) => { console.log(`视图更新:list变为 ${JSON.stringify(newValue)}`); });
data.name = 'Reactive Vue'; data.count++; data.list.push(4); delete data.count;
|
二、基于 Object.defineProperty 的实现(Vue 2 方式)
Object.defineProperty 需遍历对象属性,对每个属性进行劫持,不支持数组索引变化和动态添加属性(需额外处理)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| class Dep { constructor() { this.subscribers = new Set(); }
depend() { if (activeWatcher) { this.subscribers.add(activeWatcher); } }
notify() { this.subscribers.forEach(watcher => watcher.update()); } }
class Watcher { constructor(target, key, callback) { this.target = target; this.key = key; this.callback = callback; activeWatcher = this; this.target[this.key]; activeWatcher = null; }
update() { this.callback(this.target[this.key]); } }
let activeWatcher = null;
function reactive(target) { if (typeof target !== 'object' || target === null) { return target; }
if (Array.isArray(target)) { const arrayMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']; arrayMethods.forEach(method => { target[method] = function(...args) { const result = Array.prototype[method].apply(this, args); depMap.get(this).notify(); return result; }; }); }
const dep = new Dep(); depMap.set(target, dep);
Object.keys(target).forEach(key => { let value = target[key]; reactive(value);
Object.defineProperty(target, key, { get() { dep.depend(); return value; }, set(newValue) { if (value === newValue) return; value = newValue; reactive(newValue); dep.notify(); } }); });
return target; }
const depMap = new WeakMap();
const data = reactive({ name: 'Vue 2', count: 0, list: [1, 2, 3] });
new Watcher(data, 'name', (newValue) => { console.log(`视图更新:name变为 ${newValue}`); });
new Watcher(data, 'list', (newValue) => { console.log(`视图更新:list变为 ${JSON.stringify(newValue)}`); });
data.name = 'Vue 2 Reactive'; data.list.push(4);
data.age = 18;
|
核心原理说明
两种实现的核心流程一致,均包含以下模块:
数据劫持:
- 通过
Proxy 或 Object.defineProperty 拦截对象的 get(访问)和 set(修改)操作。
- 当访问属性时,触发依赖收集;当修改属性时,触发更新通知。
依赖收集(Dep):
- 每个响应式对象/属性对应一个
Dep 实例,用于存储依赖该属性的订阅者(Watcher)。
订阅者(Watcher):
- 关联数据和更新逻辑(如视图渲染函数),当数据变化时执行回调。
数组特殊处理:
- 重写数组的变异方法(
push、pop 等),确保数组操作能触发更新。
两种方式的对比
| 特性 |
Object.defineProperty(Vue 2) |
Proxy(Vue 3) |
| 劫持粒度 |
逐个属性劫持 |
代理整个对象 |
| 数组支持 |
需重写变异方法,不支持索引修改 |
原生支持数组监听(包括索引修改) |
| 动态属性 |
不支持(需用 Vue.set 手动处理) |
原生支持动态添加/删除属性 |
| 嵌套对象 |
需递归遍历劫持 |
访问时递归代理(懒处理,性能更优) |
总结
简易响应式系统的核心是“数据变化自动通知依赖”,通过劫持数据访问和修改实现。Proxy 因其更全面的特性成为 Vue 3 的首选,而 Object.defineProperty 帮助理解 Vue 2 的实现思路。实际 Vue 中还会结合编译器(解析模板)和虚拟 DOM,形成完整的响应式视图系统。
9.如何实现一个简易的 TodoList 应用?(使用 Vue 或 React,包含增删改查功能)
简易 TodoList 应用实现(Vue 版本)
下面使用 Vue 实现一个包含完整增删改查功能的 TodoList 应用。这个应用将支持添加任务、标记完成状态、编辑任务内容、删除任务以及清空已完成任务等功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Vue TodoList 应用</title> <script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#4f46e5', secondary: '#64748b', neutral: '#f1f5f9', success: '#10b981', danger: '#ef4444', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .todo-item { @apply flex items-center justify-between p-4 border-b last:border-0 hover:bg-neutral/50 transition-colors duration-200; } .todo-input { @apply flex-1 px-4 py-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary; } .btn { @apply px-4 py-2 font-medium rounded transition-all duration-200; } .btn-primary { @apply bg-primary text-white hover:bg-primary/90 focus:ring-2 focus:ring-primary/50; } .btn-danger { @apply bg-danger text-white hover:bg-danger/90 focus:ring-2 focus:ring-danger/50; } .btn-secondary { @apply bg-gray-200 text-gray-700 hover:bg-gray-300; } .completed { @apply text-gray-500 line-through; } } </style> </head> <body class="bg-gray-50 font-sans p-4 md:p-8"> <div class="max-w-2xl mx-auto"> <div id="app" class="bg-white rounded-xl shadow-md overflow-hidden"> <div class="p-6 border-b"> <h1 class="text-2xl font-bold text-gray-800 mb-2"> <i class="fa fa-list-ul text-primary mr-2"></i>Todo List </h1> <p class="text-gray-600 text-sm">添加、管理你的任务清单</p> </div> <div class="p-6 border-b flex"> <input v-model="newTodo" @keyup.enter="addTodo" type="text" placeholder="请输入新任务..." class="todo-input" > <button @click="addTodo" class="btn btn-primary rounded-l-none"> <i class="fa fa-plus mr-1"></i>添加 </button> </div> <div v-if="todos.length > 0" class="divide-y"> <div v-for="(todo, index) in todos" :key="todo.id" class="todo-item"> <div v-if="!todo.editing" class="flex-1 flex items-center"> <input type="checkbox" v-model="todo.completed" @change="toggleComplete(index)" class="w-5 h-5 rounded text-primary focus:ring-primary mr-3" > <span :class="{ completed: todo.completed }" class="flex-1"> {{ todo.title }} </span> </div> <div v-else class="flex-1"> <input v-model="todo.title" @keyup.enter="saveEdit(todo)" @blur="saveEdit(todo)" type="text" class="todo-input" ref="editInput" > </div> <div class="flex gap-2"> <button v-if="!todo.editing" @click="editTodo(todo)" class="btn btn-secondary px-2" title="编辑" > <i class="fa fa-pencil"></i> </button> <button @click="deleteTodo(index)" class="btn btn-danger px-2" title="删除" > <i class="fa fa-trash"></i> </button> </div> </div> </div> <div v-else class="p-8 text-center text-gray-500"> <i class="fa fa-inbox text-4xl mb-3 opacity-30"></i> <p>暂无任务,添加你的第一个任务吧!</p> </div> <div v-if="todos.length > 0" class="p-4 bg-gray-50 flex flex-wrap justify-between items-center gap-2"> <div class="text-sm text-gray-600"> 剩余 <span class="font-semibold text-primary">{{ remainingCount }}</span> 个任务 </div> <div class="flex gap-2"> <button @click="clearCompleted" v-if="completedCount > 0" class="btn btn-secondary text-sm" > <i class="fa fa-trash-o mr-1"></i>清空已完成 </button> <button @click="toggleAll" class="btn btn-secondary text-sm" > <i class="fa fa-check-square-o mr-1"></i> {{ allCompleted ? '全部未完成' : '全部完成' }} </button> </div> </div> </div> <div class="mt-6 text-center text-sm text-gray-500"> <p>提示:点击任务前的复选框标记完成状态,可编辑或删除任意任务</p> </div> </div>
<script> new Vue({ el: '#app', data() { return { newTodo: '', todos: [ { id: 1, title: '学习 Vue 基础', completed: false, editing: false }, { id: 2, title: '实现 TodoList 应用', completed: false, editing: false } ] }; }, computed: { remainingCount() { return this.todos.filter(todo => !todo.completed).length; }, completedCount() { return this.todos.filter(todo => todo.completed).length; }, allCompleted() { return this.todos.length > 0 && this.remainingCount === 0; } }, methods: { addTodo() { const title = this.newTodo.trim(); if (!title) return; this.todos.push({ id: Date.now(), title, completed: false, editing: false }); this.newTodo = ''; }, deleteTodo(index) { this.todos.splice(index, 1); }, toggleComplete(index) { this.todos[index].completed = !this.todos[index].completed; }, editTodo(todo) { this.todos.forEach(t => t.editing = false); todo.editing = true; this.$nextTick(() => { const input = this.$refs.editInput; if (input) input.focus(); }); }, saveEdit(todo) { todo.title = todo.title.trim(); if (!todo.title) { const index = this.todos.indexOf(todo); this.deleteTodo(index); return; } todo.editing = false; }, clearCompleted() { this.todos = this.todos.filter(todo => !todo.completed); }, toggleAll() { const allCompleted = this.allCompleted; this.todos.forEach(todo => { todo.completed = !allCompleted; }); } } }); </script> </body> </html>
|
功能说明
这个 TodoList 应用实现了完整的增删改查功能,主要包括:
添加任务(Create)
- 通过输入框输入任务内容,点击”添加”按钮或按回车键添加
- 自动过滤空任务和纯空格的任务
查看任务(Read)
- 列表形式展示所有任务
- 清晰区分已完成和未完成任务(已完成任务有删除线)
- 显示剩余未完成任务数量
修改任务(Update)
- 点击编辑按钮进入编辑模式
- 编辑完成后按回车键或点击其他区域保存
- 支持一键标记所有任务为完成/未完成
删除任务(Delete)
- 单个任务删除:点击每个任务后的删除按钮
- 批量删除:通过”清空已完成”按钮删除所有已完成任务
实现亮点
用户体验优化
- 编辑时自动聚焦到输入框
- 支持键盘操作(回车添加、回车保存)
- 空状态友好提示
- 操作按钮有悬停效果和图标提示
数据管理
- 使用 Vue 的响应式数据管理任务列表
- 通过计算属性实时更新任务统计信息
- 使用时间戳作为任务的唯一标识
视觉设计
- 采用现代化 UI 设计,清晰的层次结构
- 已完成任务有明显的视觉区分
- 响应式布局,适配不同屏幕尺寸
这个实现保持了代码的简洁性和可维护性,同时提供了完整的功能和良好的用户体验,适合作为 Vue 入门学习的示例。
10.如何实现一个无限滚动列表?(监听滚动事件,判断是否到达底部,加载更多数据)
无限滚动列表实现
无限滚动(Infinite Scroll)通过监听滚动事件,当用户滚动到容器底部附近时自动加载更多数据,避免一次性加载大量内容导致的性能问题。以下是一个完整实现,包含滚动监听、底部判断、加载状态管理和数据加载逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>无限滚动列表</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#3b82f6', secondary: '#64748b', neutral: '#f1f5f9', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .scroll-container { @apply relative overflow-y-auto border rounded-lg bg-white; } .list-item { @apply p-4 border-b last:border-0 hover:bg-neutral transition-colors duration-150; } .loading-indicator { @apply flex justify-center items-center p-6 text-secondary; } .end-message { @apply text-center p-6 text-gray-500; } } </style> </head> <body class="bg-gray-50 font-sans p-4 md:p-8"> <div class="max-w-4xl mx-auto"> <h1 class="text-2xl md:text-3xl font-bold text-gray-800 mb-6"> <i class="fa fa-refresh text-primary mr-2"></i>无限滚动列表 </h1> <p class="text-gray-600 mb-6"> 向下滚动列表加载更多内容,滚动到接近底部时会自动加载新数据。 </p> <div id="scrollContainer" class="scroll-container w-full h-[500px]"> <div id="listContainer" class="divide-y"></div> <div id="loadingIndicator" class="loading-indicator hidden"> <i class="fa fa-spinner fa-spin mr-2"></i> 加载中... </div> <div id="endMessage" class="end-message hidden"> <i class="fa fa-check-circle mr-1"></i> 已加载全部内容 </div> </div> </div>
<script> const state = { currentPage: 1, pageSize: 10, isLoading: false, hasMore: true, totalItems: 56 };
const scrollContainer = document.getElementById('scrollContainer'); const listContainer = document.getElementById('listContainer'); const loadingIndicator = document.getElementById('loadingIndicator'); const endMessage = document.getElementById('endMessage');
document.addEventListener('DOMContentLoaded', () => { loadMoreData(); scrollContainer.addEventListener('scroll', handleScroll); });
function handleScroll() { if (state.isLoading || !state.hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const distanceToBottom = scrollHeight - scrollTop - clientHeight; if (distanceToBottom <= 200) { loadMoreData(); } }
async function loadMoreData() { state.isLoading = true; showLoading();
try { const newItems = await fetchData(state.currentPage, state.pageSize); renderItems(newItems); state.currentPage++; const loadedItems = (state.currentPage - 1) * state.pageSize; state.hasMore = loadedItems < state.totalItems; if (!state.hasMore) { showEndMessage(); } } catch (error) { console.error('加载数据失败:', error); alert('加载数据失败,请稍后重试'); } finally { state.isLoading = false; hideLoading(); } }
function fetchData(page, pageSize) { return new Promise((resolve) => { setTimeout(() => { const start = (page - 1) * pageSize + 1; const end = Math.min(start + pageSize - 1, state.totalItems); const items = []; for (let i = start; i <= end; i++) { const contentLength = Math.floor(Math.random() * 3) + 1; items.push({ id: i, title: `列表项 #${i}`, content: `这是第 ${page} 页的列表内容,条目编号 ${i}。`.repeat(contentLength) }); } resolve(items); }, 800); }); }
function renderItems(items) { items.forEach(item => { const itemElement = document.createElement('div'); itemElement.className = 'list-item'; itemElement.innerHTML = ` <h3 class="font-semibold text-primary mb-1">${item.title}</h3> <p class="text-gray-700">${item.content}</p> `; listContainer.appendChild(itemElement); }); }
function showLoading() { loadingIndicator.classList.remove('hidden'); endMessage.classList.add('hidden'); }
function hideLoading() { loadingIndicator.classList.add('hidden'); }
function showEndMessage() { endMessage.classList.remove('hidden'); } </script> </body> </html>
|
实现说明
无限滚动列表的核心是通过滚动事件监听和底部判断逻辑,在合适时机自动加载更多数据。以下是关键实现要点:
1. 核心原理
- 滚动监听:监听容器的
scroll 事件,实时获取滚动位置。
- 底部判断:通过计算容器的
scrollTop(已滚动高度)、clientHeight(可视高度)和 scrollHeight(总高度),判断是否接近底部。
- 加载逻辑:当距离底部小于等于阈值(如 200px)时,触发数据加载,同时通过状态控制避免重复请求。
2. 关键实现细节
(1)底部判断公式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487
| const distanceToBottom = scrollHeight - scrollTop - clientHeight;
if (distanceToBottom <= 200) { loadMoreData(); } ```
设置阈值(如 200px)是为了提前触发加载,避免用户滚动到最底部时等待数据,提升体验。
##### (2)状态管理
- `isLoading`:防止滚动过程中多次触发加载(加载中时不响应新的滚动事件)。 - `hasMore`:标记是否还有更多数据(当已加载数据达到总量时,停止加载)。 - `currentPage`:跟踪当前页码,确保每次加载正确的分页数据。
##### (3)用户体验优化
- **加载指示器**:加载过程中显示 spinner 动画,告知用户正在加载。 - **结束提示**:数据全部加载完成后,显示“已加载全部内容”,避免用户困惑。 - **提前加载**:通过阈值设置,在用户到达底部前就开始加载,减少等待感。
##### (4)数据加载逻辑
- 模拟接口请求:使用 `setTimeout` 模拟网络延迟,实际项目中替换为真实 API 调用。 - 分页数据生成:根据当前页码和每页数量,生成对应范围的模拟数据。 - 动态渲染:将新数据追加到列表容器,避免重新渲染整个列表。
### 扩展方向
- **节流优化**:对滚动事件添加节流处理(如每 100ms 触发一次),减少性能消耗。 - **错误重试**:加载失败时显示重试按钮,允许用户手动触发重新加载。 - **图片懒加载**:若列表包含图片,结合图片懒加载进一步优化性能。 - **滚动位置记录**:在路由切换时保存滚动位置,返回时恢复,提升体验。
该实现适用于内容列表、评论区、商品列表等需要展示大量数据的场景,通过分批加载显著提升初始加载速度和滚动流畅度。
## 11.如何实现一个表单验证组件?(支持必填、邮箱、手机号、密码强度验证)
### 表单验证组件实现
以下是一个支持多种验证规则的表单验证组件,包括必填项、邮箱格式、手机号格式和密码强度验证。组件采用实时验证和提交验证相结合的方式,提供清晰的错误提示和视觉反馈。
```html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>表单验证组件</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#3b82f6', success: '#10b981', error: '#ef4444', warning: '#f59e0b', neutral: '#f3f4f6', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .form-group { @apply mb-6; } .form-label { @apply block text-sm font-medium text-gray-700 mb-1; } .form-input { @apply w-full px-4 py-2 border rounded-lg focus:outline-none transition-all duration-200; } .form-input:focus { @apply ring-2 ring-primary/50; } .form-input.valid { @apply border-success ring-1 ring-success; } .form-input.invalid { @apply border-error ring-1 ring-error; } .error-message { @apply text-error text-xs mt-1 flex items-center; } .help-text { @apply text-gray-500 text-xs mt-1; } .password-strength { @apply h-1.5 mt-2 rounded-full overflow-hidden; } .strength-bar { @apply h-full transition-all duration-300; } .strength-text { @apply text-xs mt-1; } } </style> </head> <body class="bg-gray-50 font-sans p-4 md:p-8"> <div class="max-w-md mx-auto"> <div class="bg-white rounded-xl shadow-md p-6 md:p-8"> <h1 class="text-2xl font-bold text-gray-800 mb-6 text-center"> <i class="fa fa-user-circle text-primary mr-2"></i>用户注册 </h1> <!-- 表单 --> <form id="registrationForm" class="space-y-4"> <!-- 姓名(必填) --> <div class="form-group"> <label for="name" class="form-label"> 姓名 <span class="text-error">*</span> </label> <input type="text" id="name" name="name" class="form-input" data-validate="required" placeholder="请输入您的姓名" > <div class="error-message hidden"></div> </div> <!-- 邮箱 --> <div class="form-group"> <label for="email" class="form-label"> 邮箱 <span class="text-error">*</span> </label> <input type="email" id="email" name="email" class="form-input" data-validate="required|email" placeholder="请输入您的邮箱" > <div class="error-message hidden"></div> <div class="help-text">用于登录和接收通知</div> </div> <!-- 手机号 --> <div class="form-group"> <label for="phone" class="form-label"> 手机号 <span class="text-error">*</span> </label> <input type="tel" id="phone" name="phone" class="form-input" data-validate="required|phone" placeholder="请输入您的手机号" > <div class="error-message hidden"></div> </div> <!-- 密码 --> <div class="form-group"> <label for="password" class="form-label"> 密码 <span class="text-error">*</span> </label> <input type="password" id="password" name="password" class="form-input" data-validate="required|password" placeholder="请设置密码" > <div class="error-message hidden"></div> <!-- 密码强度指示器 --> <div class="password-strength bg-gray-200"> <div id="strengthBar" class="strength-bar bg-gray-300 w-0"></div> </div> <div id="strengthText" class="strength-text text-gray-500"> 密码强度:请输入密码 </div> <div class="help-text mt-2"> 密码需包含大小写字母、数字和特殊字符,长度至少8位 </div> </div> <!-- 确认密码 --> <div class="form-group"> <label for="confirmPassword" class="form-label"> 确认密码 <span class="text-error">*</span> </label> <input type="password" id="confirmPassword" name="confirmPassword" class="form-input" data-validate="required|confirmPassword" placeholder="请再次输入密码" > <div class="error-message hidden"></div> </div> <!-- 提交按钮 --> <div class="pt-2"> <button type="submit" class="w-full bg-primary hover:bg-primary/90 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2" > <i class="fa fa-check mr-1"></i> 注册 </button> </div> </form> </div> <div class="mt-4 text-center text-sm text-gray-500"> 已有账号?<a href="#" class="text-primary hover:underline">立即登录</a> </div> </div>
<script> // 表单验证器类 class FormValidator { constructor(formId) { this.form = document.getElementById(formId); this.fields = this.form.querySelectorAll('[data-validate]'); this.strengthBar = document.getElementById('strengthBar'); this.strengthText = document.getElementById('strengthText'); // 初始化验证规则 this.initRules(); // 绑定事件 this.bindEvents(); } // 初始化验证规则 initRules() { // 验证规则集合 this.rules = { // 必填验证 required: (value) => { return value.trim() !== '' ? true : '此字段为必填项'; }, // 邮箱验证 email: (value) => { const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return regex.test(value) ? true : '请输入有效的邮箱地址'; }, // 手机号验证(中国大陆) phone: (value) => { const regex = /^1[3-9]\d{9}$/; return regex.test(value) ? true : '请输入有效的手机号'; }, // 密码强度验证 password: (value) => { // 密码强度检测 this.checkPasswordStrength(value); // 基本验证:至少8位 if (value.length < 8) { return '密码长度至少8位'; } // 复杂密码验证 const hasUpper = /[A-Z]/.test(value); const hasLower = /[a-z]/.test(value); const hasNumber = /\d/.test(value); const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(value); if (hasUpper && hasLower && hasNumber && hasSpecial) { return true; } else { return '密码需包含大小写字母、数字和特殊字符'; } }, // 确认密码验证 confirmPassword: (value) => { const password = document.getElementById('password').value; return value === password ? true : '两次输入的密码不一致'; } }; } // 绑定事件 bindEvents() { // 为每个字段绑定验证事件 this.fields.forEach(field => { // 失焦时验证 field.addEventListener('blur', () => this.validateField(field)); // 输入时实时验证(对于密码和确认密码) if (field.id === 'password' || field.id === 'confirmPassword') { field.addEventListener('input', () => this.validateField(field)); } }); // 表单提交验证 this.form.addEventListener('submit', (e) => { e.preventDefault(); if (this.validateForm()) { // 表单验证通过,提交表单 this.submitForm(); } }); } // 验证单个字段 validateField(field) { const value = field.value; const validateTypes = field.dataset.validate.split('|'); const errorElement = field.nextElementSibling; let isValid = true; let errorMessage = ''; // 执行所有验证规则 for (const type of validateTypes) { const result = this.rules[type](value); if (result !== true) { isValid = false; errorMessage = result; break; // 只要有一个验证失败就停止 } } // 更新字段状态和错误信息 this.updateFieldStatus(field, isValid, errorElement, errorMessage); return isValid; } // 更新字段状态 updateFieldStatus(field, isValid, errorElement, message) { // 移除之前的状态类 field.classList.remove('valid', 'invalid'); if (isValid) { // 验证通过 field.classList.add('valid'); errorElement.classList.add('hidden'); } else { // 验证失败 field.classList.add('invalid'); errorElement.classList.remove('hidden'); errorElement.innerHTML = `<i class="fa fa-exclamation-circle mr-1"></i> ${message}`; } } // 验证整个表单 validateForm() { let isFormValid = true; // 验证所有字段 this.fields.forEach(field => { const fieldIsValid = this.validateField(field); if (!fieldIsValid) { isFormValid = false; // 滚动到第一个验证失败的字段 if (isFormValid === false) { field.scrollIntoView({ behavior: 'smooth', block: 'center' }); field.focus(); } } }); return isFormValid; } // 检查密码强度 checkPasswordStrength(password) { if (!password) { this.strengthBar.className = 'strength-bar bg-gray-300 w-0'; this.strengthText.textContent = '密码强度:请输入密码'; this.strengthText.className = 'strength-text text-gray-500'; return; } // 密码强度评分(0-4分) let score = 0; // 长度加分 if (password.length >= 8) score++; if (password.length >= 12) score++; // 包含小写字母 if (/[a-z]/.test(password)) score++; // 包含大写字母 if (/[A-Z]/.test(password)) score++; // 包含数字 if (/[0-9]/.test(password)) score++; // 包含特殊字符 if (/[^A-Za-z0-9]/.test(password)) score++; // 限制最高分 score = Math.min(score, 5); // 计算宽度百分比 const width = (score / 5) * 100; // 设置强度条样式 let color, text, textClass; switch (true) { case (score <= 2): color = 'bg-error'; text = '密码强度:弱'; textClass = 'strength-text text-error'; break; case (score <= 3): color = 'bg-warning'; text = '密码强度:中'; textClass = 'strength-text text-warning'; break; case (score <= 4): color = 'bg-primary'; text = '密码强度:良好'; textClass = 'strength-text text-primary'; break; case (score === 5): color = 'bg-success'; text = '密码强度:强'; textClass = 'strength-text text-success'; break; } // 更新DOM this.strengthBar.className = `strength-bar ${color} w-[${width}%]`; this.strengthText.textContent = text; this.strengthText.className = textClass; } // 提交表单 submitForm() { // 收集表单数据 const formData = new FormData(this.form); const data = Object.fromEntries(formData.entries()); // 模拟表单提交 console.log('表单提交数据:', data); // 显示提交成功提示 alert('注册成功!'); // 重置表单 this.form.reset(); // 重置所有状态 this.fields.forEach(field => { field.classList.remove('valid', 'invalid'); const errorElement = field.nextElementSibling; if (errorElement.classList.contains('error-message')) { errorElement.classList.add('hidden'); } }); // 重置密码强度指示器 this.strengthBar.className = 'strength-bar bg-gray-300 w-0'; this.strengthText.textContent = '密码强度:请输入密码'; this.strengthText.className = 'strength-text text-gray-500'; } } // 初始化表单验证器 document.addEventListener('DOMContentLoaded', () => { new FormValidator('registrationForm'); }); </script> </body> </html>
|
实现说明
这个表单验证组件提供了全面的验证功能和良好的用户体验,主要特点如下:
1. 支持的验证规则
- 必填验证:确保字段不为空或纯空格
- 邮箱验证:使用正则表达式验证标准邮箱格式(
xxx@xxx.xxx)
- 手机号验证:验证中国大陆手机号格式(以1开头的11位数字)
- 密码强度验证:
- 长度至少8位
- 包含大小写字母
- 包含数字
- 包含特殊字符(!@#$%^&*等)
- 确认密码验证:确保与密码字段一致
2. 验证方式
- 实时验证:密码和确认密码字段在输入时实时验证
- 失焦验证:其他字段在失去焦点时验证
- 提交验证:点击提交按钮时验证所有字段,确保表单完全有效
3. 用户体验优化
- 视觉反馈:通过颜色和图标区分验证状态(未验证、通过、失败)
- 错误提示:显示具体的错误原因,帮助用户修正
- 密码强度指示器:动态显示密码强度,提供视觉引导
- 平滑滚动:表单提交时若有错误,自动滚动到第一个错误字段
- 提交反馈:成功提交后显示提示并重置表单
4. 代码设计亮点
- 模块化:使用类封装验证逻辑,便于维护和扩展
- 可扩展性:验证规则采用配置化设计,易于添加新规则
- 事件驱动:通过事件监听实现实时验证,不阻塞用户输入
- 响应式:适配不同屏幕尺寸,在移动设备上也有良好表现
该组件可以直接应用于用户注册、登录等需要表单验证的场景,也可以根据实际需求扩展更多验证规则(如身份证号、URL等)。
12.如何实现一个模态框(Modal)组件?(支持显示 / 隐藏、遮罩层、键盘关闭)
模态框(Modal)组件实现
模态框是Web开发中常用的交互组件,用于显示重要信息或收集用户输入,同时阻断对页面其他部分的操作。以下实现一个支持显示/隐藏、遮罩层和键盘关闭的模态框组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>模态框组件</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#3b82f6', secondary: '#64748b', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .modal-backdrop { @apply fixed inset-0 bg-black/50 z-50 flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300; } .modal-backdrop.active { @apply opacity-100 pointer-events-auto; } .modal-content { @apply bg-white rounded-lg shadow-xl w-full max-w-md mx-4 transform translate-y-8 opacity-0 transition-all duration-300; } .modal-backdrop.active .modal-content { @apply translate-y-0 opacity-100; } .modal-header { @apply px-6 py-4 border-b flex justify-between items-center; } .modal-body { @apply px-6 py-4; } .modal-footer { @apply px-6 py-4 border-t flex justify-end gap-3; } .btn { @apply px-4 py-2 rounded font-medium transition-colors duration-200; } .btn-primary { @apply bg-primary text-white hover:bg-primary/90; } .btn-secondary { @apply bg-gray-200 text-gray-700 hover:bg-gray-300; } } </style> </head> <body class="bg-gray-50 font-sans p-8"> <div class="max-w-4xl mx-auto"> <h1 class="text-2xl md:text-3xl font-bold text-gray-800 mb-6"> <i class="fa fa-window-maximize text-primary mr-2"></i>模态框组件示例 </h1> <p class="text-gray-600 mb-8"> 点击下方按钮打开模态框,支持点击遮罩层关闭、按ESC键关闭,以及带动画过渡效果。 </p> <button id="openModalBtn" class="btn btn-primary"> <i class="fa fa-plus mr-1"></i> 打开模态框 </button> <div id="exampleModal" class="modal-backdrop"> <div class="modal-content"> <div class="modal-header"> <h2 class="text-xl font-bold text-gray-800">示例模态框</h2> <button class="modal-close text-gray-500 hover:text-gray-700"> <i class="fa fa-times text-xl"></i> </button> </div> <div class="modal-body"> <p class="text-gray-700 mb-4"> 这是一个模态框示例,包含标题、内容和底部按钮区域。 </p> <p class="text-gray-700"> 可以通过以下方式关闭: </p> <ul class="list-disc list-inside text-gray-600 mt-2 ml-2 space-y-1"> <li>点击右上角关闭按钮</li> <li>点击底部"关闭"按钮</li> <li>点击模态框外部的遮罩层</li> <li>按下键盘上的ESC键</li> </ul> </div> <div class="modal-footer"> <button class="modal-close btn btn-secondary"> 关闭 </button> <button class="btn btn-primary"> 确认操作 </button> </div> </div> </div> <div id="customModal" class="modal-backdrop"> <div class="modal-content"> <div class="modal-header"> <h2 class="text-xl font-bold text-gray-800">自定义内容</h2> <button class="modal-close text-gray-500 hover:text-gray-700"> <i class="fa fa-times text-xl"></i> </button> </div> <div class="modal-body"> <div class="space-y-4"> <div> <label class="block text-sm font-medium text-gray-700 mb-1">用户名</label> <input type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label> <input type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"> </div> </div> </div> <div class="modal-footer"> <button class="modal-close btn btn-secondary"> 取消 </button> <button class="btn btn-primary"> 保存 </button> </div> </div> </div> <button id="openCustomModalBtn" class="btn btn-secondary ml-3"> <i class="fa fa-cog mr-1"></i> 打开自定义模态框 </button> </div>
<script> class Modal {
constructor(modalId, options = {}) { this.modal = document.getElementById(modalId); if (!this.modal) { console.error(`未找到ID为${modalId}的模态框元素`); return; } this.options = { closeOnBackdropClick: true, closeOnEscape: true, ...options }; this.closeButtons = this.modal.querySelectorAll('.modal-close'); this.bindEvents(); this.handleEscape = (e) => this.onEscapeKey(e); }
bindEvents() { this.closeButtons.forEach(button => { button.addEventListener('click', () => this.hide()); }); this.modal.addEventListener('click', (e) => { if (this.options.closeOnBackdropClick && e.target === this.modal) { this.hide(); } }); }
show() { this.modal.classList.add('active'); document.body.style.overflow = 'hidden'; if (this.options.closeOnEscape) { document.addEventListener('keydown', this.handleEscape); } this.triggerEvent('modal:show'); }
hide() { this.modal.classList.remove('active'); document.body.style.overflow = ''; if (this.options.closeOnEscape) { document.removeEventListener('keydown', this.handleEscape); } this.triggerEvent('modal:hide'); }
toggle() { if (this.modal.classList.contains('active')) { this.hide(); } else { this.show(); } }
onEscapeKey(e) { if (e.key === 'Escape' || e.keyCode === 27) { this.hide(); } }
triggerEvent(eventName) { const event = new CustomEvent(eventName, { bubbles: true, cancelable: true }); this.modal.dispatchEvent(event); }
destroy() { this.closeButtons.forEach(button => { button.removeEventListener('click', () => this.hide()); }); this.modal.removeEventListener('click', () => {}); document.removeEventListener('keydown', this.handleEscape); this.hide(); } } document.addEventListener('DOMContentLoaded', () => { const exampleModal = new Modal('exampleModal'); document.getElementById('openModalBtn').addEventListener('click', () => { exampleModal.show(); }); const customModal = new Modal('customModal'); document.getElementById('openCustomModalBtn').addEventListener('click', () => { customModal.show(); }); document.getElementById('exampleModal').addEventListener('modal:show', () => { console.log('示例模态框已显示'); }); document.getElementById('exampleModal').addEventListener('modal:hide', () => { console.log('示例模态框已隐藏'); }); }); </script> </body> </html>
|
实现说明
这个模态框组件具有完整的功能和良好的用户体验,主要特点如下:
1. 核心功能
- 显示/隐藏控制:通过
show()、hide() 和 toggle() 方法控制模态框显示状态
- 遮罩层:半透明背景层,点击可关闭模态框(可通过配置禁用)
- 键盘操作:支持按 ESC 键关闭模态框(可通过配置禁用)
- 多重关闭方式:关闭按钮、遮罩层点击、ESC 键和底部按钮
2. 交互体验优化
- 平滑过渡:显示/隐藏时带有淡入淡出和位移动画
- 滚动锁定:模态框显示时,页面背景不滚动
- 焦点管理:直观的视觉反馈,清晰区分可交互元素
- 响应式设计:在移动设备上自动适应屏幕宽度
3. 组件设计亮点
- 封装性:使用类封装模态框逻辑,支持多个模态框实例共存
- 可配置性:通过选项控制是否允许点击遮罩层关闭和ESC键关闭
- 事件机制:提供
modal:show 和 modal:hide 自定义事件,便于扩展
- 可扩展性:支持自定义内容,轻松集成表单、文本或其他元素
- 可销毁性:提供
destroy() 方法,彻底移除事件监听,避免内存泄漏
4. 使用方法
- 创建模态框HTML结构,包含遮罩层、内容区、头部、主体和底部
- 通过JavaScript初始化模态框:
const modal = new Modal('modalId')
- 调用方法控制模态框:
modal.show()、modal.hide() 或 modal.toggle()
- 可通过配置选项自定义行为:
new Modal('modalId', { closeOnBackdropClick: false })
这个模态框组件可以直接应用于各种需要弹窗交互的场景,如确认对话框、表单弹窗、信息提示等,并且可以根据具体需求轻松扩展更多功能。
13.如何实现一个下拉菜单(Dropdown)组件?(支持 hover 或点击触发、防止点击穿透)
下拉菜单(Dropdown)组件实现
下拉菜单是Web界面中常用的交互组件,用于展示额外选项或功能。以下实现一个支持hover和点击两种触发方式、带有动画效果且能防止点击穿透的下拉菜单组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>下拉菜单组件</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#3b82f6', secondary: '#64748b', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .dropdown-container { @apply relative inline-block; } .dropdown-trigger { @apply px-4 py-2 rounded-md font-medium transition-colors duration-200 cursor-pointer; } .dropdown-trigger-primary { @apply bg-primary text-white hover:bg-primary/90; } .dropdown-trigger-secondary { @apply bg-white border border-gray-300 text-gray-700 hover:bg-gray-50; } .dropdown-menu { @apply absolute left-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10 opacity-0 invisible transform translate-y-2 transition-all duration-200 origin-top-right; } .dropdown-menu.show { @apply opacity-100 visible translate-y-0; } .dropdown-item { @apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer transition-colors duration-150; } .dropdown-divider { @apply my-1 border-t border-gray-100; } } </style> </head> <body class="bg-gray-50 font-sans p-8"> <div class="max-w-4xl mx-auto"> <h1 class="text-2xl md:text-3xl font-bold text-gray-800 mb-6"> <i class="fa fa-caret-down text-primary mr-2"></i>下拉菜单组件示例 </h1> <p class="text-gray-600 mb-8"> 展示不同触发方式的下拉菜单:hover触发和click触发,点击外部区域可关闭菜单。 </p> <div class="flex flex-wrap gap-4 mb-8"> <div class="dropdown-container" data-trigger="hover"> <button class="dropdown-trigger dropdown-trigger-primary"> <i class="fa fa-user mr-2"></i>Hover触发 </button> <div class="dropdown-menu"> <a class="dropdown-item" href="#"> <i class="fa fa-user-circle mr-2"></i>个人资料 </a> <a class="dropdown-item" href="#"> <i class="fa fa-cog mr-2"></i>设置 </a> <div class="dropdown-divider"></div> <a class="dropdown-item" href="#"> <i class="fa fa-sign-out mr-2"></i>退出登录 </a> </div> </div> <div class="dropdown-container" data-trigger="click"> <button class="dropdown-trigger dropdown-trigger-secondary"> <i class="fa fa-bars mr-2"></i>Click触发 </button> <div class="dropdown-menu"> <a class="dropdown-item" href="#"> <i class="fa fa-file mr-2"></i>新建 </a> <a class="dropdown-item" href="#"> <i class="fa fa-folder-open mr-2"></i>打开 </a> <a class="dropdown-item" href="#"> <i class="fa fa-save mr-2"></i>保存 </a> <div class="dropdown-divider"></div> <a class="dropdown-item" href="#"> <i class="fa fa-trash mr-2"></i>删除 </a> </div> </div> <div class="dropdown-container ml-auto" data-trigger="click"> <button class="dropdown-trigger dropdown-trigger-secondary"> <i class="fa fa-ellipsis-v mr-2"></i>右侧菜单 </button> <div class="dropdown-menu" style="right: 0; left: auto;"> <a class="dropdown-item" href="#"> <i class="fa fa-star-o mr-2"></i>收藏 </a> <a class="dropdown-item" href="#"> <i class="fa fa-share-alt mr-2"></i>分享 </a> <a class="dropdown-item" href="#"> <i class="fa fa-flag mr-2"></i>举报 </a> </div> </div> </div> <div class="bg-white p-6 rounded-lg shadow-sm mb-8"> <h2 class="text-xl font-semibold text-gray-800 mb-4">点击穿透防护演示</h2> <p class="text-gray-600 mb-4"> 下面的下拉菜单打开时,点击菜单项不会触发下方链接的跳转,这展示了防止点击穿透的效果。 </p> <a href="https://example.com" class="text-primary hover:underline mb-4 inline-block"> 这是一个链接(点击会跳转) </a> <div class="dropdown-container" data-trigger="click"> <button class="dropdown-trigger dropdown-trigger-secondary"> <i class="fa fa-shield mr-2"></i>带穿透防护的菜单 </button> <div class="dropdown-menu"> <a class="dropdown-item" href="#"> <i class="fa fa-check mr-2"></i>选项1 </a> <a class="dropdown-item" href="#"> <i class="fa fa-check mr-2"></i>选项2 </a> <a class="dropdown-item" href="#"> <i class="fa fa-check mr-2"></i>选项3 </a> </div> </div> </div> </div>
<script> class Dropdown {
constructor(container) { this.container = container; this.trigger = container.querySelector('.dropdown-trigger'); this.menu = container.querySelector('.dropdown-menu'); this.triggerType = container.dataset.trigger || 'click'; this.bindEvents(); this.initialized = true; }
bindEvents() { if (this.triggerType === 'hover') { this.trigger.addEventListener('mouseenter', () => this.show()); this.container.addEventListener('mouseleave', () => this.hide()); } else { this.trigger.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(); }); } const menuItems = this.menu.querySelectorAll('.dropdown-item'); menuItems.forEach(item => { item.addEventListener('click', (e) => { if (item.tagName === 'A' && item.href && !item.hasAttribute('href', '#')) { return; } e.preventDefault(); e.stopPropagation(); console.log('点击了菜单项:', item.textContent.trim()); this.hide(); }); }); if (this.triggerType === 'click') { document.addEventListener('click', (e) => { if (!this.container.contains(e.target)) { this.hide(); } }); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isShown()) { this.hide(); this.trigger.focus(); } }); }
show() { this.closeOtherDropdowns(); this.menu.classList.add('show'); this.trigger.setAttribute('aria-expanded', 'true'); this.triggerEvent('dropdown:show'); }
hide() { this.menu.classList.remove('show'); this.trigger.setAttribute('aria-expanded', 'false'); this.triggerEvent('dropdown:hide'); }
toggle() { if (this.isShown()) { this.hide(); } else { this.show(); } }
isShown() { return this.menu.classList.contains('show'); }
closeOtherDropdowns() { document.querySelectorAll('.dropdown-container').forEach(container => { if (container !== this.container && container.dropdownInstance) { container.dropdownInstance.hide(); } }); }
triggerEvent(eventName) { const event = new CustomEvent(eventName, { bubbles: true, cancelable: true }); this.container.dispatchEvent(event); }
destroy() { if (!this.initialized) return; if (this.triggerType === 'hover') { this.trigger.removeEventListener('mouseenter', () => this.show()); this.container.removeEventListener('mouseleave', () => this.hide()); } else { this.trigger.removeEventListener('click', () => this.toggle()); } this.container.dropdownInstance = null; this.initialized = false; } } document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.dropdown-container').forEach(container => { container.dropdownInstance = new Dropdown(container); container.addEventListener('dropdown:show', () => { console.log('下拉菜单已显示'); }); container.addEventListener('dropdown:hide', () => { console.log('下拉菜单已隐藏'); }); }); }); </script> </body> </html>
|
实现说明
这个下拉菜单组件具有灵活的触发方式和完善的交互体验,主要特点如下:
1. 核心功能
双触发方式:
- Hover触发:鼠标悬停在触发元素上显示菜单,离开时隐藏
- Click触发:点击触发元素显示菜单,再次点击或点击外部隐藏
防止点击穿透:
- 通过
e.stopPropagation() 阻止事件冒泡,避免点击菜单项时触发底层元素的事件
- 对非链接项使用
e.preventDefault() 防止默认行为
智能关闭机制:
- 点击文档其他区域自动关闭
- 按ESC键可快速关闭
- 打开新菜单时自动关闭其他已打开的菜单
2. 交互体验优化
- 平滑动画:显示/隐藏时带有淡入淡出和位移过渡效果
- 焦点管理:关闭菜单后焦点自动回到触发按钮
- 视觉反馈:菜单项悬停时有背景色变化
- 响应式设计:自适应不同屏幕尺寸,支持右侧对齐
3. 组件设计亮点
- 封装性:使用类封装下拉菜单逻辑,每个菜单都是独立实例
- 可配置性:通过
data-trigger 属性轻松切换触发方式
- 事件机制:提供
dropdown:show 和 dropdown:hide 事件,便于扩展功能
- 可访问性:使用
aria-expanded 属性标识菜单状态
- 可扩展性:支持自定义菜单项内容和样式,轻松集成图标
4. 防止点击穿透的实现原理
点击穿透是指点击下拉菜单时,事件会穿透菜单触发下方元素的点击事件。本组件通过两种方式防止:
- 事件冒泡阻止:
e.stopPropagation() 阻止事件向上传播到父元素
- 默认行为控制:
e.preventDefault() 根据菜单项类型决定是否阻止默认行为
这种实现既保证了正常链接的跳转功能,又防止了非预期的事件触发。
该组件可广泛应用于导航菜单、用户菜单、操作菜单等场景,通过简单配置即可适应不同的交互需求。
14.如何实现一个日期选择器(DatePicker)组件?(支持选择年月日、禁用过去日期)
日期选择器(DatePicker)组件实现
以下是一个支持选择年月日、禁用过去日期的日期选择器组件。该组件提供直观的日历视图,支持月份切换、快速选择今天,并通过视觉反馈清晰区分可选日期、禁用日期和已选日期。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>日期选择器组件</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"> <script> tailwind.config = { theme: { extend: { colors: { primary: '#3b82f6', secondary: '#64748b', neutral: '#f3f4f6', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, } } } </script> <style type="text/tailwindcss"> @layer utilities { .datepicker-container { @apply relative inline-block w-full max-w-sm; } .date-input { @apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all duration-200; } .calendar-popup { @apply absolute left-0 mt-2 w-full bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden transform opacity-0 invisible scale-95 transition-all duration-200; } .calendar-popup.show { @apply opacity-100 visible scale-100; } .calendar-header { @apply flex items-center justify-between p-4 bg-gray-50 border-b; } .month-selector { @apply flex items-center gap-2; } .month-btn { @apply p-1.5 rounded-full hover:bg-gray-200 transition-colors duration-150; } .weekdays { @apply grid grid-cols-7 bg-gray-50; } .weekday { @apply py-2 text-center text-xs font-medium text-gray-500; } .days-grid { @apply grid grid-cols-7 gap-1 p-2; } .day-cell { @apply h-9 w-9 flex items-center justify-center rounded-full text-sm cursor-pointer transition-all duration-150; } .day-cell.other-month { @apply text-gray-300; } .day-cell.today { @apply bg-primary/10 text-primary font-medium; } .day-cell.selectable { @apply hover:bg-primary/20; } .day-cell.selected { @apply bg-primary text-white font-medium; } .day-cell.disabled { @apply text-gray-300 cursor-not-allowed hover:bg-transparent; } .calendar-footer { @apply p-3 border-t flex justify-end; } .today-btn { @apply px-3 py-1 text-sm text-primary hover:text-primary/80 hover:bg-primary/5 rounded transition-colors duration-150; } } </style> </head> <body class="bg-gray-50 font-sans p-8"> <div class="max-w-4xl mx-auto"> <h1 class="text-2xl md:text-3xl font-bold text-gray-800 mb-6"> <i class="fa fa-calendar text-primary mr-2"></i>日期选择器组件 </h1> <p class="text-gray-600 mb-8"> 一个支持选择年月日的日期选择器,默认禁用过去的日期,只能选择今天及以后的日期。 </p> <div class="bg-white p-6 rounded-lg shadow-sm mb-8 max-w-md"> <h2 class="text-xl font-semibold text-gray-800 mb-4">选择日期示例</h2> <div class="datepicker-container" id="datepicker"> <input type="text" class="date-input" placeholder="请选择日期" readonly > <div class="calendar-popup"> <div class="calendar-header"> <button class="month-btn" data-action="prev"> <i class="fa fa-chevron-left text-gray-600"></i> </button> <div class="month-selector"> <span class="current-month font-medium"></span> <span class="current-year text-gray-600"></span> </div> <button class="month-btn" data-action="next"> <i class="fa fa-chevron-right text-gray-600"></i> </button> </div> <div class="weekdays"> <div class="weekday">日</div> <div class="weekday">一</div> <div class="weekday">二</div> <div class="weekday">三</div> <div class="weekday">四</div> <div class="weekday">五</div> <div class="weekday">六</div> </div> <div class="days-grid"></div> <div class="calendar-footer"> <button class="today-btn" data-action="today"> <i class="fa fa-calendar-check-o mr-1"></i>今天 </button> </div> </div> </div> <div class="mt-6 p-4 bg-gray-50 rounded-lg"> <p class="text-sm text-gray-600"> <i class="fa fa-info-circle text-primary mr-1"></i> 已选择的日期:<span id="selectedDate" class="font-medium text-primary">未选择</span> </p> </div> </div> <div class="bg-white p-6 rounded-lg shadow-sm max-w-md"> <h2 class="text-xl font-semibold text-gray-800 mb-4">使用说明</h2> <ul class="list-disc list-inside text-gray-600 space-y-2"> <li>点击输入框打开日期选择器</li> <li>使用左右箭头切换月份</li> <li>点击"今天"按钮快速选择当前日期</li> <li>过去的日期已被禁用,无法选择</li> <li>点击选中的日期或输入框外部关闭选择器</li> </ul> </div> </div>
<script> class DatePicker {
constructor(containerId, options = {}) { this.container = document.getElementById(containerId); if (!this.container) { console.error(`未找到ID为${containerId}的容器元素`); return; } this.options = { disablePast: true, format: 'yyyy-mm-dd', ...options }; this.input = this.container.querySelector('.date-input'); this.popup = this.container.querySelector('.calendar-popup'); this.daysGrid = this.container.querySelector('.days-grid'); this.currentMonthEl = this.container.querySelector('.current-month'); this.currentYearEl = this.container.querySelector('.current-year'); this.prevBtn = this.container.querySelector('[data-action="prev"]'); this.nextBtn = this.container.querySelector('[data-action="next"]'); this.todayBtn = this.container.querySelector('[data-action="today"]'); this.selectedDate = null; this.currentDate = new Date(); this.today = new Date(); this.bindEvents(); this.renderCalendar(); }
bindEvents() { this.input.addEventListener('click', () => { this.popup.classList.toggle('show'); }); this.prevBtn.addEventListener('click', () => { this.currentDate.setMonth(this.currentDate.getMonth() - 1); this.renderCalendar(); }); this.nextBtn.addEventListener('click', () => { this.currentDate.setMonth(this.currentDate.getMonth() + 1); this.renderCalendar(); }); this.todayBtn.addEventListener('click', () => { this.selectDate(new Date()); this.popup.classList.remove('show'); }); this.daysGrid.addEventListener('click', (e) => { const dayCell = e.target.closest('.day-cell'); if (dayCell && dayCell.dataset.date) { const date = new Date(dayCell.dataset.date); this.selectDate(date); this.popup.classList.remove('show'); } }); document.addEventListener('click', (e) => { if (!this.container.contains(e.target)) { this.popup.classList.remove('show'); } }); this.popup.addEventListener('click', (e) => { e.stopPropagation(); }); }
renderCalendar() { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; this.currentMonthEl.textContent = monthNames[month]; this.currentYearEl.textContent = year; this.daysGrid.innerHTML = ''; const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const prevMonth = month === 0 ? 11 : month - 1; const prevMonthYear = month === 0 ? year - 1 : year; const daysInPrevMonth = new Date(prevMonthYear, prevMonth + 1, 0).getDate(); for (let i = 0; i < firstDay; i++) { const day = daysInPrevMonth - firstDay + i + 1; const date = new Date(prevMonthYear, prevMonth, day); this.addDayCell(date, true); } for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); this.addDayCell(date, false); } const totalCells = firstDay + daysInMonth; const nextMonthDays = 42 - totalCells; const nextMonth = month === 11 ? 0 : month + 1; const nextMonthYear = month === 11 ? year + 1 : year; for (let day = 1; day <= nextMonthDays; day++) { const date = new Date(nextMonthYear, nextMonth, day); this.addDayCell(date, true); } }
addDayCell(date, isOtherMonth) { const day = date.getDate(); const cell = document.createElement('div'); cell.className = 'day-cell'; if (isOtherMonth) { cell.classList.add('other-month'); } if (this.isSameDay(date, this.today)) { cell.classList.add('today'); } if (this.selectedDate && this.isSameDay(date, this.selectedDate)) { cell.classList.add('selected'); } const isDisabled = this.options.disablePast && date < this.getStartOfDay(this.today); if (isDisabled) { cell.classList.add('disabled'); } else if (!isOtherMonth) { cell.classList.add('selectable'); cell.dataset.date = date.toISOString(); } cell.textContent = day; this.daysGrid.appendChild(cell); }
selectDate(date) { if (this.options.disablePast && date < this.getStartOfDay(this.today)) { return; } this.selectedDate = date; this.input.value = this.formatDate(date); document.getElementById('selectedDate').textContent = this.formatDate(date); this.renderCalendar(); this.triggerEvent('date:selected', { date: this.selectedDate }); }
formatDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); switch (this.options.format) { case 'yyyy-mm-dd': return `${year}-${month}-${day}`; case 'mm/dd/yyyy': return `${month}/${day}/${year}`; case 'dd-mm-yyyy': return `${day}-${month}-${year}`; default: return `${year}-${month}-${day}`; } }
isSameDay(date1, date2) { return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate(); }
getStartOfDay(date) { const newDate = new Date(date); newDate.setHours(0, 0, 0, 0); return newDate; }
triggerEvent(eventName, detail) { const event = new CustomEvent(eventName, { bubbles: true, cancelable: true, detail: detail }); this.container.dispatchEvent(event); } } document.addEventListener('DOMContentLoaded', () => { const datePicker = new DatePicker('datepicker', { disablePast: true, format: 'yyyy-mm-dd' }); document.getElementById('datepicker').addEventListener('date:selected', (e) => { console.log('选中的日期:', e.detail.date); }); }); </script> </body> </html>
|
实现说明
这个日期选择器组件提供了完整的日期选择功能,特别关注了用户体验和交互细节,主要特点如下:
1. 核心功能
- 年月日选择:通过直观的日历网格选择具体日期,支持月份切换
- 禁用过去日期:默认配置下,所有过去的日期会被禁用且视觉上有明显区分
- 日期格式化:支持多种日期格式(yyyy-mm-dd、mm/dd/yyyy等)
- 今天快捷选择:提供”今天”按钮,一键选择当前日期
2. 交互体验优化
视觉反馈:
- 今天的日期有特殊背景色
- 选中的日期有明显的高亮效果
- 过去的日期置灰并禁用点击
- 其他月份的日期颜色较浅,不干扰当前月份选择
动画过渡:
- 日历弹出/隐藏时有平滑的缩放和淡入淡出动画
- 日期选择时有状态变化的过渡效果
操作便捷性:
- 点击输入框弹出/隐藏日历
- 点击外部区域自动关闭日历
- 左右箭头快速切换月份
- 选中日期后自动填充到输入框
3. 技术实现亮点
日期逻辑处理:
- 准确计算每月天数和星期分布
- 处理跨月份显示(显示上月末和下月初的部分日期)
- 精确比较日期,正确判断过去日期
组件设计:
- 封装性:使用类封装所有功能,便于维护和扩展
- 可配置性:支持通过选项控制是否禁用过去日期和日期格式
- 事件机制:提供
date:selected事件,便于集成到业务逻辑中
- 响应式:自适应不同屏幕尺寸,在移动设备上同样有良好表现
性能优化:
- 使用事件委托处理日期单元格点击
- 高效渲染日历网格,只更新必要的DOM元素
该组件可直接应用于需要日期选择的场景,如预约系统、日程安排、表单提交等,通过简单配置即可适应不同的业务需求。
15.如何排查和解决前端页面白屏问题?(从网络、代码、资源加载等角度分析)
前端页面白屏是开发中常见的问题,可能由多种因素导致,需要从网络、代码、资源加载等多个维度系统性排查。以下是具体的分析思路和解决方法:
一、网络层面排查
网络请求异常是白屏的常见原因,主要表现为资源加载失败或接口错误。
1. 排查方法
使用浏览器开发者工具(F12):
- 打开 Network 面板,刷新页面,查看所有请求的状态码:
- 红色状态(如404、403、500、503):资源加载失败或服务器错误。
- 灰色状态(pending/blocked):请求被拦截或未发出。
- 筛选关键资源(HTML、CSS、JS、接口),检查是否有缺失或加载失败。
检查请求详情:
- 对于失败的请求,查看 Response 标签,确认服务器返回的具体错误信息(如“文件不存在”“权限不足”)。
- 查看 Headers 标签,确认请求URL、请求方法、Cookie、跨域头(CORS)是否正确。
2. 常见问题及解决
资源404错误:
- 原因:资源路径错误(如拼写错误、相对路径层级错误)、文件未部署到服务器。
- 解决:修正资源引用路径(建议使用绝对路径或正确的相对路径),确认服务器上存在对应文件。
跨域错误(CORS error):
- 原因:前端请求的接口域名与当前页面域名不同,且服务器未配置跨域许可。
- 解决:服务器端添加跨域响应头(如
Access-Control-Allow-Origin: *),或通过代理服务器转发请求(如Vue CLI的proxy配置)。
请求被拦截(blocked):
- 原因:资源被浏览器插件(如广告拦截器)屏蔽,或HTTPS页面请求了HTTP资源(混合内容错误)。
- 解决:关闭拦截插件,将所有资源升级为HTTPS,或在HTTPS页面中避免请求HTTP资源。
接口超时/500错误:
- 原因:后端服务崩溃、接口响应超时、数据库异常。
- 解决:排查后端服务状态,优化接口性能,增加超时重试机制,前端添加加载状态兜底(避免因接口失败导致白屏)。
二、代码层面排查
JavaScript执行错误或逻辑异常可能导致页面渲染中断,尤其在SPA(单页应用)中,路由或组件初始化失败会直接导致白屏。
1. 排查方法
查看Console面板:
- 检查是否有红色错误日志(如
Uncaught ReferenceError、TypeError),错误信息会定位到具体文件和行号。
- 注意框架特有的错误(如Vue的
[Vue warn]、React的Uncaught Error: Minified React error #130),通常与组件渲染或状态管理有关。
断点调试:
- 在关键生命周期(如
DOMContentLoaded、框架的mounted/componentDidMount)设置断点,逐步执行代码,观察是否有逻辑中断。
- 对于SPA,检查路由配置(如
vue-router/react-router),确认初始路由是否匹配正确的组件。
2. 常见问题及解决
语法错误:
- 原因:漏写括号、变量未定义、使用未声明的函数(如
undefined is not a function)。
- 解决:根据Console错误定位到具体代码,修复语法问题(可借助ESLint等工具提前检测)。
框架渲染错误:
- 原因:Vue/React组件渲染时依赖了
null/undefined的属性(如this.user.name中user为null),导致渲染中断。
- 解决:添加属性校验(如Vue的
v-if="user"、React的短路运算符user && user.name),避免访问不存在的属性。
路由匹配失败:
- 原因:SPA中初始路由未配置,或路由参数错误导致组件无法加载。
- 解决:检查路由配置,确保存在默认路由(如
path: '*'指向404组件),避免因路由不匹配导致的空白页面。
内存泄漏/无限循环:
- 原因:代码中存在无限循环(如
while(true)未正确终止)或内存泄漏,导致JS线程阻塞,页面无法渲染。
- 解决:使用浏览器 Performance 面板录制执行过程,定位耗时操作;通过
console.log追踪循环变量,修复逻辑错误。
三、资源加载层面排查
CSS、JS、字体等资源加载失败或顺序错误,可能导致页面样式丢失(看起来像白屏)或功能异常。
1. 排查方法
检查资源加载顺序:
- 在Network面板查看资源加载的“Waterfall”(瀑布流),确认依赖关系是否正确(如JS依赖的库是否先加载)。
- 例如:若
app.js依赖vue.js,但vue.js后加载,会导致Vue is not defined错误。
检查资源完整性:
- 确认JS/CSS文件是否完整(如因网络中断导致文件只加载了部分),可通过对比本地文件大小与服务器文件大小判断。
- 对于CDN资源,检查是否被篡改(可使用
integrity属性验证,如<script src="xxx" integrity="sha256-xxx"></script>)。
2. 常见问题及解决
CSS加载失败/顺序错误:
- 原因:CSS文件404,或加载顺序错误(如重置样式在组件样式之后,导致样式被覆盖)。
- 解决:修复CSS路径,调整加载顺序(重置样式→公共样式→组件样式),确保关键CSS优先加载。
JS体积过大导致加载超时:
- 原因:未压缩的JS文件(如开发环境代码直接上线)体积过大,加载时间过长,导致页面长时间白屏。
- 解决:开启代码压缩(如Webpack的
terser-webpack-plugin)、分包加载(code splitting)、使用CDN加速,添加加载动画提升用户体验。
字体/图片加载失败:
- 原因:字体文件跨域、图片路径错误,导致页面因等待资源而阻塞渲染。
- 解决:配置字体跨域头(
Access-Control-Allow-Origin),修复图片路径,使用font-display: swap避免字体加载阻塞页面。
四、浏览器与环境层面排查
浏览器兼容性、缓存问题或特殊环境(如iframe、无痕模式)可能导致白屏。
1. 排查方法
测试不同浏览器:
- 在Chrome、Firefox、Safari、Edge等主流浏览器中测试,确认是否仅在特定浏览器白屏(可能是兼容性问题)。
- 使用 Can I use 工具检查代码中使用的API(如
Promise、flex)是否被目标浏览器支持。
清除缓存与Cookie:
- 浏览器缓存的旧资源(如错误的JS/CSS)可能导致白屏,可通过“Ctrl+Shift+R”强制刷新,或清除缓存后重试。
检查无痕模式/隐私窗口:
- 部分浏览器插件或扩展(如广告拦截器)在普通模式下会干扰资源加载,在无痕模式中测试可排除插件影响。
2. 常见问题及解决
浏览器兼容性问题:
- 原因:使用了低版本浏览器不支持的语法(如ES6的
箭头函数、let/const),或CSS属性(如grid)。
- 解决:使用Babel转译ES6+语法,添加polyfill(如
core-js);使用Autoprefixer自动添加CSS前缀,兼容低版本浏览器。
缓存导致的旧资源生效:
- 原因:浏览器缓存了未更新的资源(如JS/CSS未加版本号),导致新代码未生效。
- 解决:为资源添加版本号(如
app.js?v=2)或哈希值(如Webpack的contenthash),强制浏览器加载新资源。
Service Worker缓存异常:
- 原因:Service Worker缓存了错误的资源,且未正确更新,导致页面始终加载旧的白屏资源。
- 解决:在开发者工具 Application→Service Workers 中注销旧的Service Worker,重新注册;优化Service Worker的缓存策略(如只缓存静态资源,不缓存HTML)。
五、服务器与部署层面排查
服务器配置错误或部署问题也可能导致白屏,尤其在页面首次加载时。
1. 排查方法
检查服务器返回的HTML:
- 在Network面板中查看HTML请求的Response,确认是否返回完整的HTML结构(而非空响应或错误页面)。
- 若HTML为空,可能是服务器渲染失败(如SSR服务崩溃)。
检查MIME类型配置:
- 服务器对JS/CSS的MIME类型配置错误(如将JS识别为
text/plain),会导致浏览器不执行JS或不解析CSS。
- 查看Response Headers的
Content-Type:JS应为application/javascript,CSS应为text/css。
2. 常见问题及解决
project-root/
├── public/ # 静态资源(不经过构建工具处理)
├── src/ # 源代码目录(核心)
│ ├── api/ # 接口请求层
│ ├── assets/ # 可被构建工具处理的静态资源
│ ├── components/ # 组件库
│ │ ├── common/ # 通用基础组件
│ │ └── business/ # 业务通用组件
│ ├── config/ # 配置文件
│ ├── constants/ # 常量定义
│ ├── features/ # 业务功能模块(核心)
│ ├── hooks/ # 自定义钩子(React)/组合式API(Vue)
│ ├── layouts/ # 布局组件
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理
│ ├── styles/ # 全局样式
│ ├── types/ # 类型定义(TypeScript)
│ ├── utils/ # 工具函数
│ ├── App.{jsx, vue} # 应用入口组件
│ └── main.{js, ts} # 程序入口文件
├── tests/ # 测试文件
├── docs/ # 项目文档
├── scripts/ # 构建/部署脚本
├── .eslintrc.js # ESLint配置
├── .prettierrc # 代码格式化配置
├── tsconfig.json # TypeScript配置(如有)
├── package.json # 依赖配置
└── README.md # 项目说明
1 2 3 4 5 6 7
| ### 二、核心目录详解与设计思路
#### 1. `public/`:静态资源根目录
存放不需要经过Webpack/Vite等构建工具处理的静态文件,构建时会被直接复制到输出目录。
|
public/
├── favicon.ico # 网站图标
├── index.html # HTML模板(入口页面)
├── robots.txt # 搜索引擎爬虫规则
└── static/ # 其他静态资源(如第三方JS、PDF等)
1 2 3 4 5 6 7
| **设计原则**:仅存放必须直接访问的资源,避免滥用(构建工具无法优化这里的资源)。
#### 2. `src/api/`:接口请求层
统一管理所有后端接口请求,避免接口调用散落在组件中,便于集中处理请求/响应拦截、错误处理。
|
src/api/
├── index.js # 导出所有API(对外统一入口)
├── client.js # 请求客户端配置(如Axios实例、拦截器)
├── user.js # 用户相关接口(登录、信息获取等)
├── order.js # 订单相关接口
└── goods.js # 商品相关接口
1 2 3 4 5 6 7
| **设计原则**:按业务领域拆分文件,每个文件对应一个业务模块的接口,使用统一的请求客户端。
#### 3. `src/assets/`:可编译静态资源
存放会被构建工具处理的资源(如图片、字体、CSS预编译文件等),支持模块化引用。
|
src/assets/
├── images/ # 图片资源(按业务模块分类)
│ ├── common/ # 通用图片(如logo、默认头像)
│ └── goods/ # 商品相关图片
├── fonts/ # 字体文件
└── styles/ # 样式资源(如主题变量、动画定义)
├── variables.scss # SCSS变量(如颜色、尺寸)
└── animations.css # 全局动画
1 2 3 4 5 6 7
| **设计原则**:按资源类型+业务模块分类,避免所有资源堆放在一起。
#### 4. `src/components/`:组件库
按复用范围拆分,区分“通用基础组件”和“业务通用组件”,避免组件职责模糊。
|
src/components/
├── common/ # 通用基础组件(与业务无关,可复用至任何项目)
│ ├── Button/ # 按钮组件(含index.js、style.css等)
│ ├── Modal/ # 模态框组件
│ └── Table/ # 表格组件
└── business/ # 业务通用组件(与当前项目业务强相关,跨功能模块复用)
├── GoodsCard/ # 商品卡片(在列表、详情页等多处复用)
├── UserAvatar/ # 用户头像(带业务逻辑,如默认头像规则)
└── OrderStatus/ # 订单状态标签(依赖订单业务定义)
1 2 3 4 5 6 7 8 9 10
| **设计原则**:
- 通用组件:职责单一(如只做UI展示,不包含业务逻辑),通过props控制行为。 - 业务组件:可包含业务逻辑,但需避免过度耦合特定页面,保持通用性。
#### 5. `src/features/`:业务功能模块(核心)
按业务功能拆分的独立模块,是大型项目的核心目录,每个模块包含自身的组件、状态、路由等,实现“高内聚低耦合”。
|
src/features/
├── auth/ # 认证模块(登录、注册、权限)
│ ├── components/ # 模块内部私有组件(不对外复用)
│ │ ├── LoginForm/ # 登录表单(仅在auth模块内使用)
│ │ └── RegisterForm/
│ ├── hooks/ # 模块内部自定义钩子
│ ├── store/ # 模块状态(如登录状态)
│ ├── routes.js # 模块路由配置(如/login、/register)
│ ├── index.js # 模块对外导出(组件、路由等)
│ └── AuthPage.jsx # 模块入口页面
├── goods/ # 商品模块(列表、详情、搜索)
├── order/ # 订单模块(创建、支付、列表)
└── user/ # 用户中心模块(个人信息、地址管理)
1 2 3 4 5 6 7 8 9 10 11
| **设计原则**:
- 每个模块独立闭环,包含自身所需的组件、状态、路由,减少对外部的依赖。 - 模块内部再按“私有组件”“状态”“路由”细分,结构自洽。 - 跨模块共享的功能应提取到`components/business`或`utils`中,避免模块间直接引用。
#### 6. `src/layouts/`:布局组件
存放全局或模块级别的布局组件,定义页面的整体结构(如头部、侧边栏、页脚)。
|
src/layouts/
├── MainLayout.jsx # 主布局(含Header、Sidebar、Footer)
├── AuthLayout.jsx # 认证页布局(如登录/注册页,无侧边栏)
└── BlankLayout.jsx # 空白布局(如弹窗内嵌页面)
1 2 3 4 5 6 7
| **设计原则**:布局组件只负责结构,不包含业务逻辑,通过`children`插槽嵌入内容。
#### 7. `src/router/`:路由配置
集中管理路由,按模块拆分路由配置,支持动态路由和权限控制。
|
src/router/
├── index.js # 路由入口(合并所有模块路由)
├── routes.js # 全局路由(如404、首页)
└── modules/ # 模块路由(与features对应)
├── auth.routes.js # 认证模块路由
├── goods.routes.js # 商品模块路由
└── order.routes.js # 订单模块路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| **示例**(Vue Router):
```javascript // src/router/modules/goods.routes.js export default [ { path: '/goods', component: () => import('../../features/goods/GoodsPage'), children: [ { path: 'list', component: () => import('../../features/goods/components/GoodsList') }, { path: ':id', component: () => import('../../features/goods/components/GoodsDetail') }, ], }, ]; ```
#### 8. `src/store/`:状态管理
按模块拆分全局状态,避免单一状态树过大,适用于Redux、Vuex、Pinia等。
|
src/store/
├── index.js # 状态入口(合并所有模块)
└── modules/ # 模块状态(与features对应)
├── user.js # 用户状态(登录信息、权限)
├── cart.js # 购物车状态
└── app.js # 应用全局状态(如主题、语言)
1 2 3 4 5 6 7 8 9 10
| **设计原则**:
- 优先使用组件内状态,仅将跨组件共享的状态放入全局store。 - 按业务领域拆分store模块,与`features`保持一致,便于维护。
#### 9. `src/utils/`:工具函数
存放通用工具函数,按功能分类,避免重复开发。
|
src/utils/
├── index.js # 工具函数入口(统一导出)
├── format/ # 格式化工具(日期、金额、字符串)
│ ├── date.js # 日期格式化
│ └── money.js # 金额格式化
├── validator/ # 验证工具(手机号、邮箱、表单验证)
├── storage/ # 本地存储工具(localStorage封装)
└── dom/ # DOM操作工具(如滚动、尺寸计算)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455
| **设计原则**:工具函数应纯函数化(无副作用),避免依赖外部状态,保证可复用性。
#### 10. 其他辅助目录
- `src/config/`:配置文件(如API域名、环境变量、功能开关),区分开发/生产环境。 - `src/constants/`:常量定义(如订单状态枚举、路由路径常量),避免硬编码。 - `src/hooks/`:全局自定义钩子(如`useAuth`权限钩子、`useRequest`请求钩子),复用逻辑。 - `src/types/`:TypeScript类型定义(如接口返回类型、组件Props类型),提升类型安全性。
### 三、框架适配调整
以上结构为通用设计,不同框架可灵活调整:
#### React项目
- 组件文件使用`.jsx`或`.tsx`,配合`hooks`目录管理逻辑复用。 - 状态管理可使用Redux Toolkit(按`store/modules`拆分)或Context API(适合轻量场景)。
#### Vue项目
- 组件使用单文件组件`.vue`,`components`目录可保留,`hooks`对应`composables`目录。 - 状态管理推荐Pinia,按模块拆分`stores`目录(替代`store/modules`)。 - 路由配置可直接在`router`目录使用Vue Router的模块化语法。
### 四、设计原则总结
1. **单一职责**:每个目录/文件只负责一件事(如`api/`只处理接口,`utils/`只放工具函数)。 2. **模块化拆分**:按业务功能(`features/`)和技术职责(`components/`、`api/`)双重维度拆分,降低耦合。 3. **可扩展性**:新增业务时只需在`features/`中添加新模块,无需修改现有结构;通用功能可通过`components/`或`hooks/`扩展。 4. **一致性**:保持命名规范(如组件目录用 PascalCase,工具函数用 camelCase)、文件结构一致(每个模块内部结构统一)。 5. **可导航性**:开发者能通过“业务功能→模块→文件类型”快速定位代码(如“修改商品详情API”→`features/goods/`→`api/goods.js`)。
通过这套结构,大型项目可在团队协作中保持代码整洁,同时支持业务的快速迭代和扩展。 前端权限管理是保障系统安全的核心环节,主要解决“谁能访问什么资源、执行什么操作、查看什么数据”的问题。通常需要从**路由权限**(页面级)、**按钮权限**(操作级)、**数据权限**(内容级)三个维度实现,结合后端权限体系(用户-角色-权限模型)完成全链路控制。
### 一、权限体系设计基础
权限管理的核心是“用户-角色-权限”的关联模型:
- **用户**:系统操作者(如`user1`)。 - **角色**:具有相同权限的用户分组(如`管理员`、`普通用户`)。 - **权限**:具体的可执行操作或可访问资源(如`访问/dashboard`、`点击删除按钮`、`查看所有订单`)。
前端权限管理的前提是:**用户登录后,后端返回该用户的权限集合**(如`["user:read", "order:delete"]`),前端基于此集合进行权限控制。
### 二、路由权限(页面级控制)
路由权限控制用户能否访问某个页面,防止通过URL直接跳转未授权页面。核心是“路由配置+导航拦截”。
#### 1. 实现方案
##### (1)路由配置分类
将路由分为**公共路由**(无需权限,如登录页、404页)和**私有路由**(需权限,如 dashboard、订单管理),私有路由需声明所需权限。
示例(Vue Router):
```javascript // src/router/routes.js export const constantRoutes = [ // 公共路由 { path: '/login', component: () => import('@/views/login') }, { path: '/403', component: () => import('@/views/403') }, ];
export const asyncRoutes = [ // 私有路由(需权限) { path: '/dashboard', component: () => import('@/views/dashboard'), meta: { title: '仪表盘', permission: 'dashboard:view' // 访问该路由需要的权限 } }, { path: '/order', component: () => import('@/views/order'), meta: { title: '订单管理', permission: 'order:view' // 需order:view权限 } } ]; ```
##### (2)动态生成可访问路由
用户登录后,根据后端返回的权限列表,筛选出用户有权访问的私有路由,动态添加到路由实例中。
示例(Vue):
```javascript // src/store/modules/permission.js import { asyncRoutes, constantRoutes } from '@/router/routes';
// 筛选有权访问的路由 function filterRoutes(routes, permissions) { const res = []; routes.forEach(route => { const temp = { ...route }; // 若路由需要权限且用户有权限,则保留 if (!temp.meta?.permission || permissions.includes(temp.meta.permission)) { res.push(temp); } }); return res; }
export default { state: { routes: [], // 最终可访问的所有路由 }, mutations: { SET_ROUTES: (state, routes) => { state.routes = constantRoutes.concat(routes); // 合并公共路由和私有路由 } }, actions: { generateRoutes({ commit }, permissions) { return new Promise(resolve => { const accessedRoutes = filterRoutes(asyncRoutes, permissions); commit('SET_ROUTES', accessedRoutes); resolve(accessedRoutes); }); } } }; ```
##### (3)路由守卫拦截未授权访问
通过路由守卫(如`beforeEach`)在跳转前检查权限,若无权访问则跳转到403页。
示例(Vue Router):
```javascript // src/router/index.js import router from './routes'; import store from '@/store';
router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token'); // 未登录且访问非登录页,跳转到登录页 if (!token && to.path !== '/login') { return next('/login'); } // 已登录且访问登录页,跳转到首页 if (token && to.path === '/login') { return next('/dashboard'); } // 已登录,检查路由权限 if (token) { // 若未加载权限,先获取权限 if (!store.state.user.permissions.length) { const { permissions } = await store.dispatch('user/getUserInfo'); // 从后端获取权限 const accessedRoutes = await store.dispatch('permission/generateRoutes', permissions); // 动态添加路由 accessedRoutes.forEach(route => router.addRoute(route)); // 重新跳转(确保动态路由已生效) return next({ ...to, replace: true }); } // 检查当前路由是否需要权限且用户是否有权限 const needPermission = to.meta?.permission; if (needPermission && !store.state.user.permissions.includes(needPermission)) { return next('/403'); // 无权限跳403 } } next(); }); ```
#### 2. 关键注意点
- **防止URL直接访问**:路由守卫必须覆盖所有导航场景(包括手动输入URL)。 - **动态路由刷新问题**:刷新页面后动态路由会丢失,需在`app.vue`的`mounted`中重新生成路由。 - **404页面配置**:404路由需放在所有路由的最后,且仅在动态路由生成后添加,避免拦截未授权路由。
### 三、按钮权限(操作级控制)
按钮权限控制页面中具体操作(如新增、删除、编辑)的可见性或可用性,核心是“权限判断+动态渲染”。
#### 1. 实现方案
##### (1)封装权限按钮组件
通过组件接收权限标识,根据用户权限列表决定是否渲染按钮。
示例(Vue组件):
```vue <!-- src/components/PermissionButton.vue --> <template> <button v-if="hasPermission" :class="className" @click="$emit('click')" > <slot></slot> </button> </template>
<script> import { mapState } from 'vuex'; export default { props: { // 所需权限标识 permission: { type: String, required: true }, // 按钮样式 className: { type: String, default: 'btn' } }, computed: { ...mapState({ userPermissions: state => state.user.permissions }), // 判断是否有权限 hasPermission() { return this.userPermissions.includes(this.permission); } } }; </script> ```
使用方式:
```vue <!-- 在页面中使用 --> <permission-button permission="order:delete" class="btn-danger" @click="handleDelete" > 删除订单 </permission-button> ```
##### (2)使用自定义指令(更灵活)
对于非按钮元素(如链接、输入框),可通过自定义指令控制显示/禁用。
示例(Vue指令):
```javascript // src/directives/permission.js import Vue from 'vue'; import store from '@/store';
Vue.directive('permission', { inserted(el, binding) { const { value: permission } = binding; // 权限标识,如"order:edit" const userPermissions = store.state.user.permissions; // 若无权限,移除元素或禁用 if (permission && !userPermissions.includes(permission)) { el.parentNode?.removeChild(el); // 直接移除 // 或禁用:el.disabled = true; el.classList.add('disabled'); } } }); ```
使用方式:
```vue <!-- 禁用无权限的输入框 --> <input v-permission="'order:edit'" type="text" placeholder="仅有权限可编辑" >
<!-- 控制链接显示 --> <a v-permission="'user:export'" href="/export">导出用户</a> ```
#### 2. 关键注意点
- **权限粒度**:按钮权限标识需与后端保持一致(如`order:delete`对应删除订单操作)。 - **性能优化**:权限判断在组件/指令初始化时执行一次,避免频繁计算。 - **降级处理**:无权限时,建议“完全隐藏”而非“仅禁用”,减少用户困惑。
### 四、数据权限(内容级控制)
数据权限控制用户能查看/操作的数据范围(如经理看所有订单,员工只看自己的订单),核心是“前后端配合,后端为主,前端为辅”。
#### 1. 实现方案
##### (1)后端主导:基于权限过滤数据
前端在请求数据时,传递用户标识(如用户ID、角色),后端根据权限规则过滤数据后返回。
示例:
```javascript // 前端请求订单列表时,带上用户ID async getOrderList() { const { data } = await api.get('/orders', { params: { userId: store.state.user.id, // 传递用户ID role: store.state.user.role // 传递角色 } }); this.orders = data; // 后端已过滤,直接展示 } ```
后端逻辑(伪代码):
```java // 后端根据用户角色过滤数据 if (user.role == 'admin') { return allOrders; // 管理员看所有 } else if (user.role == 'manager') { return orders.where(shopId == user.shopId); // 经理看门店内订单 } else { return orders.where(createBy == user.id); // 员工看自己创建的 } ```
##### (2)前端辅助:二次过滤敏感数据
对于已返回的数据,前端可隐藏无权限查看的字段(如普通用户看不到客户手机号)。
示例:
```vue <template> <table> <tr v-for="order in filteredOrders" :key="order.id"> <td>{{ order.id }}</td> <td>{{ order.name }}</td> <!-- 有权限才显示手机号 --> <td v-if="hasPhonePermission">{{ order.phone }}</td> </tr> </table> </template>
<script> import { mapState } from 'vuex'; export default { computed: { ...mapState({ userPermissions: state => state.user.permissions, orders: state => state.order.orders }), hasPhonePermission() { return this.userPermissions.includes('order:viewPhone'); }, // 过滤数据(可选,仅前端辅助) filteredOrders() { return this.orders.map(order => { // 无权限则删除敏感字段 if (!this.hasPhonePermission) { const { phone, ...rest } = order; return rest; } return order; }); } } }; </script> ```
#### 2. 关键注意点
- **安全优先**:数据权限的核心逻辑必须在后端实现,前端过滤仅作为辅助(防止用户通过抓包获取敏感数据)。 - **权限标识**:数据权限也需定义标识(如`order:viewAll`、`order:viewPhone`),与后端同步。
### 五、权限管理整体流程
1. **登录阶段**:用户登录后,后端返回`token`和用户权限列表(如`["dashboard:view", "order:delete"]`),前端存储到`store`和`localStorage`。 2. **初始化阶段**:前端根据权限列表动态生成可访问路由,通过路由守卫控制页面访问。 3. **渲染阶段**:页面渲染时,通过权限组件/指令控制按钮显示,通过后端过滤+前端辅助控制数据展示。 4. **权限更新**:用户角色变更后,需重新获取权限列表,更新`store`并重新生成路由,实现权限动态刷新。
### 总结
前端权限管理需结合**路由拦截**、**组件/指令控制**、**数据过滤**三个层面,核心是基于后端返回的权限列表进行判断。其中:
- 路由权限:控制“能去哪个页面”,防止越权访问。 - 按钮权限:控制“能做什么操作”,细化权限粒度。 - 数据权限:控制“能看什么内容”,需前后端协同,后端主导安全控制。
通过这套体系,可实现从页面到操作再到数据的全链路权限管控,保障系统安全。
## 18.前端项目中如何实现权限管理?(如路由权限、按钮权限、数据权限)
前端权限管理的核心是通过**“用户-角色-权限”关联模型**,从**页面访问(路由权限)、操作控制(按钮权限)、内容范围(数据权限)** 三个维度实现精细化管控,需与后端权限体系协同,确保“未授权资源不可见、不可操作、不可访问”。以下是具体实现方案:
### 一、基础:权限模型与数据约定
权限管理的前提是后端返回清晰的权限数据。通常采用“用户-角色-权限”三级模型:
- **用户**:登录主体,关联一个或多个角色; - **角色**:权限集合的载体(如“管理员”“普通用户”); - **权限**:具体资源标识(如`dashboard:view`(访问仪表盘)、`order:delete`(删除订单))。
**前端核心依赖**:用户登录后,后端返回包含`permissions`数组的用户信息(如`["dashboard:view", "order:delete", "user:export"]`),前端基于此数组实现权限控制。
### 二、路由权限(页面级控制)
控制用户能否访问某个页面,防止通过URL直接跳转未授权页面,核心是“**路由配置分类 + 动态路由生成 + 导航拦截**”。
#### 1. 路由配置分类
将路由分为**公共路由**(无需权限,如登录页、404页)和**私有路由**(需权限,如管理页),私有路由通过`meta.permission`声明所需权限。
示例(Vue Router):
```javascript // src/router/routes.js // 公共路由(所有人可访问) export const constantRoutes = [ { path: '/login', component: () => import('@/views/Login') }, { path: '/403', component: () => import('@/views/403') }, // 无权限页 { path: '/404', component: () => import('@/views/404') }, // 页面不存在 ];
// 私有路由(需权限控制) export const asyncRoutes = [ { path: '/dashboard', component: () => import('@/views/Dashboard'), meta: { title: '仪表盘', permission: 'dashboard:view' // 访问需"dashboard:view"权限 } }, { path: '/order', component: () => import('@/views/Order'), meta: { title: '订单管理', permission: 'order:view' // 访问需"order:view"权限 }, children: [ { path: 'list', component: () => import('@/views/Order/List'), meta: { permission: 'order:list' } }, { path: 'detail/:id', component: () => import('@/views/Order/Detail'), meta: { permission: 'order:detail' } }, ] } ];
|
2. 动态生成可访问路由
用户登录后,根据后端返回的permissions数组,筛选出有权访问的私有路由,动态添加到路由实例中(避免加载未授权路由)。
示例(Vuex/Pinia状态管理):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| import { asyncRoutes, constantRoutes } from '@/router/routes';
const filterAccessibleRoutes = (routes, permissions) => { const accessibleRoutes = []; routes.forEach(route => { const temp = { ...route }; if (temp.meta?.permission && !permissions.includes(temp.meta.permission)) { return; } if (temp.children) { temp.children = filterAccessibleRoutes(temp.children, permissions); } accessibleRoutes.push(temp); }); return accessibleRoutes; };
export default { state: { allRoutes: [], }, mutations: { SET_ROUTES(state, routes) { state.allRoutes = constantRoutes.concat(routes); } }, actions: { generateRoutes({ commit }, permissions) { return new Promise(resolve => { const accessibleRoutes = filterAccessibleRoutes(asyncRoutes, permissions); accessibleRoutes.push({ path: '*', redirect: '/404', hidden: true }); commit('SET_ROUTES', accessibleRoutes); resolve(accessibleRoutes); }); } } };
|
3. 路由守卫拦截未授权访问
通过beforeEach路由守卫,在页面跳转前检查权限,拦截未授权访问。
示例(Vue Router守卫):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| import router from './routes'; import store from '@/store';
router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token');
if (!token && to.path !== '/login') { return next('/login'); }
if (token && to.path === '/login') { return next(from.path || '/dashboard'); }
if (token && !store.state.user.permissions.length) { try { const { permissions } = await store.dispatch('user/getUserInfo'); const accessibleRoutes = await store.dispatch('permission/generateRoutes', permissions); accessibleRoutes.forEach(route => router.addRoute(route)); return next({ ...to, replace: true }); } catch (error) { localStorage.removeItem('token'); return next('/login'); } }
const needPermission = to.meta?.permission; if (needPermission && !store.state.user.permissions.includes(needPermission)) { return next('/403'); }
next(); });
|
三、按钮权限(操作级控制)
控制页面中具体操作(如新增、删除、导出)的可见性或可用性,核心是“权限判断 + 动态渲染”,常用两种实现方式:
1. 封装权限按钮组件(推荐)
通过组件接收权限标识,根据用户权限列表决定是否渲染按钮,适合按钮类元素。
示例(Vue组件):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| <!-- src/components/PermissionButton.vue --> <template> <button v-if="hasPermission" :class="['btn', className]" @click="$emit('click')" :disabled="disabled" > <slot></slot> </button> </template>
<script setup> import { useStore } from 'vuex'; import { computed } from 'vue';
const store = useStore(); const props = defineProps({ permission: { // 所需权限标识 type: String, required: true }, className: { // 自定义样式 type: String, default: '' }, disabled: { // 是否禁用 type: Boolean, default: false } });
// 判断是否有权限 const hasPermission = computed(() => { return store.state.user.permissions.includes(props.permission); });
defineEmits(['click']); </script> ```
**使用方式**:
```vue <!-- 页面中使用权限按钮 --> <permission-button permission="order:delete" className="btn-danger" @click="handleDelete" > <i class="fa fa-trash"></i> 删除订单 </permission-button>
<permission-button permission="order:export" className="btn-primary" @click="handleExport" > <i class="fa fa-download"></i> 导出订单 </permission-button>
|
2. 自定义权限指令(灵活)
通过自定义指令控制元素的显示/禁用,适合非按钮元素(如链接、输入框、表格列)。
示例(Vue指令):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { useStore } from 'vuex';
export default { install(app) { app.directive('permission', { mounted(el, binding) { const store = useStore(); const { value: requiredPermission } = binding; if (requiredPermission && !store.state.user.permissions.includes(requiredPermission)) { el.parentNode?.removeChild(el); } } }); } }; ```
**使用方式**:
```vue <!-- 控制链接显示 --> <a v-permission="'user:edit'" href="/user/edit/1">编辑用户</a>
<!-- 控制输入框禁用 --> <input v-permission="'order:edit'" type="text" placeholder="仅有权限可编辑">
<el-table-column v-permission="'order:viewPhone'" label="客户手机号"> <template #default="scope">{{ scope.row.phone }}</template> </el-table-column>
|
四、数据权限(内容级控制)
控制用户能查看/操作的数据范围(如“管理员看所有订单,员工只看自己的订单”),核心是“后端主导过滤 + 前端辅助隐藏”,确保安全优先。
1. 后端主导:基于权限过滤数据
前端请求数据时传递用户标识(如用户ID、角色),后端根据权限规则过滤数据后返回,避免敏感数据泄露。
前端请求示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| import request from '@/utils/request';
export const getOrderList = (params) => { const { page = 1, size = 10 } = params; return request({ url: '/api/orders', method: 'get', params: { page, size, userId: store.state.user.id, role: store.state.user.role } }); }; ```
**后端逻辑示例(伪代码)**:
```java
public List<Order> getOrderList(Long userId, String role) { QueryWrapper<Order> query = new QueryWrapper<>(); if ("admin".equals(role)) { return orderMapper.selectList(query); } else if ("manager".equals(role)) { query.eq("shop_id", getUserShopId(userId)); return orderMapper.selectList(query); } else { query.eq("create_by", userId); return orderMapper.selectList(query); } }
|
2. 前端辅助:隐藏敏感字段
对于已返回的数据,前端可隐藏无权限查看的敏感字段(如普通用户看不到客户手机号),但必须以后端过滤为核心(前端隐藏无法防止抓包获取)。
示例(Vue计算属性):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| <template> <el-table :data="filteredOrders" border> <el-table-column label="订单ID" prop="id"></el-table-column> <el-table-column label="客户名称" prop="customerName"></el-table-column> <!-- 有权限才显示手机号 --> <el-table-column v-if="hasPhonePermission" label="客户手机号"> <template #default="scope">{{ scope.row.phone }}</template> </el-table-column> </el-table> </template>
<script setup> import { useStore } from 'vuex'; import { computed } from 'vue'; import { getOrderList } from '@/api/order';
const store = useStore(); const orders = ref([]);
// 获取订单列表 const fetchOrders = async () => { const res = await getOrderList({ page: 1, size: 10 }); orders.value = res.data; };
// 判断是否有权限查看手机号 const hasPhonePermission = computed(() => { return store.state.user.permissions.includes('order:viewPhone'); });
// 前端辅助过滤敏感字段(可选) const filteredOrders = computed(() => { return orders.value.map(order => { if (!hasPhonePermission.value) { // 无权限:删除手机号字段 const { phone, ...rest } = order; return rest; } return order; }); });
// 初始化加载 fetchOrders(); </script>
|
五、权限管理整体流程
- 登录阶段:用户登录后,后端返回
token和permissions数组,前端存储到localStorage和状态管理(如Vuex)。
- 初始化阶段:前端调用
generateRoutes筛选可访问路由,动态添加到路由实例,通过路由守卫控制页面跳转。
- 渲染阶段:页面渲染时,通过权限组件/指令控制按钮显示,通过后端过滤+前端辅助控制数据范围。
- 权限更新:用户角色变更后,需重新调用
getUserInfo获取最新权限,更新状态并重新生成路由,实现权限动态刷新。
总结
前端权限管理需从“页面-操作-数据”三层协同,核心原则:
- 路由权限:控制“能去哪”,通过动态路由+守卫拦截未授权访问;
- 按钮权限:控制“能做什么”,通过组件/指令实现精细化操作控制;
- 数据权限:控制“能看什么”,后端主导过滤,前端辅助隐藏,确保安全优先。
通过与后端权限体系的深度协同,可实现全链路权限管控,保障系统安全与合规。
19.前端项目中如何处理接口请求?(如请求拦截、响应拦截、错误处理、请求重试)
在前端项目中,接口请求的统一处理是保障项目稳定性和可维护性的关键环节。合理的请求/响应拦截、错误处理和重试机制能有效减少重复代码、提升用户体验。以下是基于Axios(前端最常用的HTTP客户端)的完整解决方案:
一、基础配置:创建Axios实例
首先创建一个Axios实例,统一配置基础URL、超时时间、请求头格式等,避免重复配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import axios from 'axios'; import { ElMessage } from 'element-plus';
const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json;charset=utf-8' } });
export default service;
|
二、请求拦截器:统一预处理请求
请求拦截器用于在请求发送前做统一处理,如添加认证token、序列化参数、显示加载动画等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| service.interceptors.request.use( (config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; }
if (config.method?.toUpperCase() === 'GET' && config.params) { config.paramsSerializer = { indexes: null }; }
return config; }, (error) => { ElMessage.error('请求参数错误'); return Promise.reject(error); } );
|
三、响应拦截器:统一处理响应与错误
响应拦截器用于处理返回结果(如剥离外层包装)、统一错误提示,并区分HTTP错误和业务错误。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| service.interceptors.response.use( (response) => {
const { data } = response;
if (data.code === 200) { return data.data; }
ElMessage.error(data.message || '操作失败'); return Promise.reject(new Error(data.message || '业务错误')); }, (error) => {
const { response, request } = error; if (!response) { if (request._timeout) { ElMessage.error('请求超时,请重试'); } else { ElMessage.error('网络错误,请检查网络连接'); } return Promise.reject(error); }
const { status, data } = response; switch (status) { case 401: localStorage.removeItem('token'); window.location.href = '/login'; ElMessage.error('登录已过期,请重新登录'); break; case 403: ElMessage.error('权限不足,无法操作'); break; case 404: ElMessage.error('请求的资源不存在'); break; case 500: case 502: case 503: ElMessage.error('服务器错误,请稍后重试'); break; default: ElMessage.error(data?.message || `请求错误(${status})`); }
return Promise.reject(error); } );
|
四、请求重试:处理临时错误
对于临时网络波动或服务器过载(如503、429),可实现自动重试机制,提升成功率。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| const createRetryService = () => { const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json;charset=utf-8' }, retry: 3, retryDelay: 1000, retryStatuses: [429, 500, 502, 503] });
service.interceptors.request.use(...);
service.interceptors.response.use( (response) => { }, async (error) => { const config = error.config;
if (!config || !config.retry) { return Promise.reject(error); }
config.__retryCount = config.__retryCount || 0;
if (config.__retryCount >= config.retry) { return Promise.reject(error); }
const shouldRetry = config.retryStatuses.includes(error.response?.status); if (!shouldRetry) { return Promise.reject(error); }
config.__retryCount += 1;
const delay = config.retryDelay * Math.pow(2, config.__retryCount - 1);
return new Promise((resolve) => { setTimeout(() => { resolve(service(config)); }, delay); }); } );
return service; };
export const retryService = createRetryService();
|
五、接口封装:统一管理API
将所有接口集中管理,避免散落在组件中,便于维护和修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import service from '@/utils/request';
export const userAPI = { getUserInfo: () => service.get('/user/info'), login: (data) => service.post('/user/login', data), getUserList: (params) => service.get('/user/list', { params }) };
import { retryService } from '@/utils/request';
export const orderAPI = { createOrder: (data) => retryService.post('/order/create', data), getOrderDetail: (id) => service.get(`/order/detail/${id}`) };
|
六、组件中使用接口
在组件中调用封装好的API,专注于业务逻辑,无需关心请求细节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { userAPI } from '@/api/user'; import { onMounted, ref } from 'vue';
const userInfo = ref(null);
const fetchUserInfo = async () => { try { const data = await userAPI.getUserInfo(); userInfo.value = data; } catch (error) { console.error('获取用户信息失败:', error); } };
onMounted(() => { fetchUserInfo(); });
|
七、高级优化:取消重复请求
对于用户快速多次点击按钮导致的重复请求,可通过Axios的CancelToken取消之前的请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| const pendingRequests = new Map();
const generateKey = (config) => { const { method, url, params, data } = config; return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&'); };
service.interceptors.request.use((config) => { const requestKey = generateKey(config); if (pendingRequests.has(requestKey)) { pendingRequests.get(requestKey)('取消重复请求'); } config.cancelToken = new axios.CancelToken((cancel) => { pendingRequests.set(requestKey, cancel); });
return config; });
service.interceptors.response.use( (response) => { const requestKey = generateKey(response.config); pendingRequests.delete(requestKey); return response; }, (error) => { if (axios.isCancel(error)) { console.log('已取消重复请求:', error.message); } else { const requestKey = generateKey(error.config); pendingRequests.delete(requestKey); } return Promise.reject(error); } );
|
总结
接口请求处理的核心是“统一化、可配置、易维护”:
- 请求拦截:集中处理认证、参数序列化、加载状态,减少重复代码;
- 响应拦截:统一解析数据格式、区分业务/HTTP错误,标准化错误提示;
- 错误处理:按错误类型(网络错误、权限错误、服务器错误)分级处理,提升用户体验;
- 请求重试:针对临时错误自动重试,提高接口成功率;
- 取消重复请求:避免重复提交导致的资源浪费和数据混乱。
通过这套方案,可有效降低接口处理的复杂度,让开发人员更专注于业务逻辑实现。
20.简述你参与过的前端项目的技术栈选型理由、遇到的难点及解决方案
在我参与的某企业级SaaS平台项目(主要用于客户关系管理与数据分析)中,技术栈选型、难点解决均围绕“大型应用的可维护性、性能稳定性、业务扩展性”展开,具体如下:
一、技术栈选型及理由
项目核心技术栈:React + TypeScript + Redux Toolkit + React Router + Ant Design + Vite + Axios
框架与语言:React + TypeScript
- 选型理由:项目涉及复杂表单(如客户信息录入)、动态数据展示(如销售报表),React的组件化思想适合拆分复杂UI;TypeScript的静态类型检查可减少70%+运行时错误,尤其在多人协作的大型项目中,能通过接口定义(Interfaces)规范数据流转,降低沟通成本。
状态管理:Redux Toolkit
- 选型理由:传统Redux模板代码冗余,Redux Toolkit简化了状态定义(Slice)、异步操作(createAsyncThunk),且内置immer支持直接“修改”状态(实际是不可变更新),适合管理跨组件共享状态(如用户信息、全局筛选条件)。
UI组件库:Ant Design
- 选型理由:企业级SaaS需大量标准化组件(表格、表单、弹窗等),Ant Design的组件覆盖全面,且支持主题定制(符合客户品牌调性),其Form组件的校验逻辑可直接对接业务需求(如手机号、邮箱格式验证)。
构建工具:Vite
- 选型理由:相比Webpack,Vite基于ES Module实现“按需编译”,开发环境启动时间从30s+缩短至3s,热更新速度提升5倍,大幅提升开发效率;生产环境基于Rollup打包,资源体积更小。
接口请求:Axios + 封装拦截器
- 选型理由:Axios支持拦截器、取消请求、超时控制,通过封装统一处理认证(Token添加)、错误提示(网络错误/401权限不足),减少重复代码。
二、遇到的难点及解决方案
1. 难点:大型表单处理(含200+字段,多tab联动校验)
- 问题:客户信息表单分6个tab页,字段间存在复杂联动(如“客户类型”选择“企业”时,需显示“企业规模”字段),且需实时校验(如“合同金额”需大于“预付款金额”),直接用原生state管理导致代码混乱,校验逻辑重复。
- 解决方案:
- 用
Formik管理表单状态,通过useForm集中维护字段值、校验状态,支持表单重置、提交控制;
- 用
Yup定义校验规则(如amount: Yup.number().moreThan(Yup.ref('prepay'))),实现联动校验逻辑复用;
- 拆分“基础信息”“合同信息”等子组件,通过
Formik的useField钩子共享表单上下文,避免props层级传递。
2. 难点:首屏加载慢(首次加载需8-10s)
- 问题:项目包含100+页面,第三方依赖(如ECharts、XLSX)体积大,且首页需加载用户权限、基础配置等3个接口数据,导致首屏白屏时间过长。
- 解决方案:
- 代码分割:用
React.lazy + Suspense实现路由级懒加载(如const Dashboard = React.lazy(() => import('./pages/Dashboard'))),首屏仅加载登录页和核心依赖;
- 依赖优化:通过
rollup-plugin-visualizer分析包体积,将ECharts等非首屏依赖改为动态导入(import('echarts').then(...)),减小主包体积;
- 接口优化:后端合并3个首屏接口为1个,减少请求次数;前端添加接口缓存(
localStorage缓存不常变的基础配置);
- 静态资源优化:用CDN分发静态资源,开启Gzip压缩(JS/CSS体积减少60%),添加
preload预加载关键资源。
3. 难点:大数据列表卡顿(10000+条数据渲染)
- 问题:客户列表页需展示10000+条数据,支持分页、筛选、排序,直接渲染导致DOM节点过多(10000+ tr),滚动时帧率降至10fps以下,操作卡顿。
- 解决方案:
- 用
react-window实现虚拟列表,仅渲染可视区域内的20条数据(约500px高度),DOM节点从10000+降至50以内;
- 列表筛选/排序逻辑迁移至后端(通过接口参数传递),前端仅负责展示,避免前端处理大量数据;
- 表格组件改用
react-virtualized的VirtualizedTable,优化单元格重绘逻辑(减少不必要的重排)。
4. 难点:复杂权限管理(多角色+动态权限)
- 问题:系统支持10+角色(管理员、销售、财务等),权限细化到“路由访问”“按钮操作”“数据可见范围”,且角色权限可动态配置(后端实时返回),硬编码权限判断导致代码冗余。
- 解决方案:
- 基于“用户-角色-权限”模型,封装
usePermission自定义hook,通过后端返回的permissions数组(如['customer:edit', 'order:delete'])判断权限;
- 路由权限:用
React Router的Navigate组件配合usePermission实现拦截(无权限时跳403页);
- 按钮权限:封装
PermissionButton组件(如<PermissionButton permission="order:delete">删除</PermissionButton>),无权限时自动隐藏;
- 数据权限:接口请求时携带用户角色参数,后端根据角色过滤数据(如销售只能看自己的客户),前端仅做展示层过滤(隐藏敏感字段)。
总结
该项目的技术选型紧扣“大型应用”的核心需求(可维护、高性能、可扩展),而难点解决的关键在于:通过工具链简化复杂逻辑(如Formik处理表单)、用性能优化手段突破瓶颈(如虚拟列表)、抽象通用能力减少重复开发(如权限hook)。最终项目首屏加载时间优化至2s内,操作流畅度提升80%,且支持20+客户的定制化需求快速迭代。