一、如何设计一个组件库?
“设计组件库是一个系统性工程,我会把它当成一个正式的产品来对待。我的整体思路可以概括为四个关键步骤:定规范、搭体系、优生态、促迭代。”
“第一步,定规范。这是地基,决定了组件库的品质和一致性。”
- “首先,我会和设计师紧密合作,确立一套统一的设计语言(Design System)。这包括颜色、字体、间寄等等,确保所有组件的视觉和交互体验是完全一致的。”
- “其次,我们会制定清晰的 API 设计原则。比如,所有组件的
props命名风格、事件回调的命名方式 (onXxx) 都要统一。目标是让开发者仅凭直觉就能正确使用,降低学习成本。”
“第二步,搭体系。这是组件库的骨架,决定了它的健壮性和开发效率。”
- “在技术选型上,我会倾向于使用 Monorepo 架构,比如用
pnpm workspace来管理。这样方便我们共享配置、管理多个组件包,并且能清晰地处理组件间的依赖。” - “开发和构建会选择 TypeScript 和 Vite。TS 是质量保障的必须品,而 Vite 的库模式对打包组件库非常友好,开发体验也很好。”
- “最重要的是质量保障。我们会建立一套完整的自动化测试流程,至少包括用 Vitest 写的单元测试来保证组件逻辑,以及用 Storybook 配合 Chromatic 这样的工具做视觉回归测试,防止 UI 更新时出现意料之外的样式变动。”
“第三步,优生态。组件库的价值最终体现在开发者的使用体验上。”
- “我会把文档放在和编码同等重要的位置。我们会用 Storybook 来构建交互式文档,它不仅能展示组件用法,还能让开发者在线调试,这是提升开发者体验的关键。”
- “同时,性能是重中之重。我们会确保所有组件都支持按需加载(Tree-shaking),并对产物体积进行持续监控,绝不能让组件库成为业务方的性能负担。”
“第四步,促迭代。一个好的组件库是需要持续进化的。”
- “我们会建立一个完全自动化的 CI/CD 流程。从代码提交,到运行测试,再到生成文档、最后发布到 npm,整个过程无需人工干预,这样我们才能快速响应需求和修复 bug。”
- “版本管理会严格遵循 SemVer(语义化版本),并自动生成 Changelog。同时,我们会建立清晰的 Issue 反馈和贡献指南,鼓励用户参与进来,让组件库保持长久的生命力。”
“总而言之,我会把组件库看作一个连接设计和开发、服务于业务的内部产品来用心打造。我的目标是交付一个高质量、体验好、易于维护,并能实实在在提升整个团队研发效能的资产。”
二、如何设计一个组件?
设计一个组件,我会从六个核心点入手:定、接、场、健、体、文。
-
定 - 定位职责:遵循单一职责原则,明确组件的功能边界,让它只做一件事。
-
接 - 设计接口:提供清晰、易用的 API。包括命名恰当的
Props,规范的Events,和灵活的Slots(插槽)。 -
场 - 考虑场景:让组件能适应不同环境。比如同时支持受控和非受控两种模式,并提供方便的样式定制方案。
-
健 - 保证健壮:处理好边界和异常情况,确保组件不会轻易崩溃,并用单元测试保证质量。
-
体 - 优化体验:确保组件好用。包括性能优化(如避免重渲染、虚拟列表)和**无障碍(A11y)**支持(如键盘导航、ARIA属性)。
-
文 - 撰写文档:提供清晰的 API 文档和交互示例,让别人能快速上手使用。
总的来说,一个好组件应是高内聚、低耦合、高健壮、易扩展、体验好的。
三、大型前端项目架构设计
第一阶段:技术选型与需求分析 (Thinking before coding)
在敲下第一行代码前,我会先问清楚几个问题,因为没有银弹,技术是为业务服务的。
-
业务形态是什么?
- To C 的营销/内容型网站? -> 优先考虑
Next.js(React) 或Nuxt.js(Vue)。因为它们天生支持 SSR/SSG,对 SEO 和首屏性能至关重要。 - To B 的中后台管理系统? -> 使用
Vite+React/Vue的纯客户端渲染 (CSR) 方案就足够了。这类应用对 SEO 没要求,更看重开发效率和灵活性。 - 是一个跨多端的应用吗? -> 可能需要考虑
Taro或uni-app。
- To C 的营销/内容型网站? -> 优先考虑
-
团队技术栈是什么?
- 优先选择团队最熟悉的框架,这能最大化开发效率,降低学习成本。
-
未来的规模和复杂度如何?
- 如果预见到未来会是一个包含多个子系统的大型应用,我会在初期就考虑引入 Monorepo 方案(如 Turborepo + pnpm workspace),方便未来管理公共库和多项目。
决策产出:一份明确的技术选型文档,例如:“本项目为中后台管理系统,决定采用 Vite + React + TypeScript + pnpm 的技术栈。”
第二阶段:工程化基础建设 (Building the guardrails)
选型确定后,开始搭建项目的“规矩”和“工具集”,保证团队成员进来后,能在一个统一、规范的环境下开发。
-
项目初始化:
- 使用官方脚手架
pnpm create vite my-project --template react-ts快速生成项目基础结构。
- 使用官方脚手架
-
统一代码规范:
- Linter: 集成
ESLint(代码质量检查) 和Stylelint(样式检查)。 - Formatter: 集成
Prettier(代码格式化)。 - 配置:解决 ESLint 和 Prettier 的冲突 (
eslint-config-prettier),确保它们协同工作。目标是:Prettier 只管格式,ESLint 只管质量。
- Linter: 集成
-
统一 Git 工作流:
- Git Hooks: 引入
husky,在 Git 的钩子(如pre-commit,commit-msg)上执行命令。 - 提交前检查: 配合
lint-staged,在pre-commit阶段,只对本次暂存区的文件执行 lint 和 format 操作。这能避免对全量代码进行检查,速度极快,且能保证入库的代码都是符合规范的。 - 规范提交信息: 引入
commitlint,强制开发者写出符合规范的 Git Commit Message(如feat: add login page),便于后续生成CHANGELOG和代码回溯。
- Git Hooks: 引入
-
统一环境配置:
- 使用
.env文件系列 (.env,.env.development,.env.production) 来管理环境变量。并在.gitignore中忽略本地配置文件,只提交.env.example作为示例。
- 使用
决策产出:一个配置好上述所有工具的、纯净的(删掉脚手架自带的 demo 代码)项目骨架。新人拉下代码,pnpm install 之后,就拥有了和团队完全一致的开发环境和规范约束。
第三阶段:应用架构设计 (Structuring the application)
有了坚实的地基,现在开始规划应用内部的“房间格局”,让代码清晰、可维护、易扩展。
-
目录结构 (Directory Structure):
- 我会规划一套清晰的目录结构,例如:
src/assets: 存放静态资源,如图片、字体。src/components: 存放通用/无业务逻辑的 UI 组件。src/pages或src/views: 存放页面级组件。src/hooks: 存放自定义 React Hooks。src/services或src/api: 统一管理所有 API 请求。src/store: 存放状态管理逻辑 (Pinia/Zustand)。src/styles: 存放全局样式、变量等。src/utils: 存放通用工具函数。
- 我会规划一套清晰的目录结构,例如:
-
请求层封装:
- 基于
axios或fetch进行二次封装。我会创建一个统一的请求实例,在里面处理:- 统一的 Base URL 和超时配置。
- 请求/响应拦截器:用于统一添加
token、处理通用错误(如 401 跳转登录页)、添加 loading 状态等。 - 类型定义:为 API 请求和响应定义明确的 TypeScript 类型。
- 基于
-
状态管理选型:
- 对于简单应用,优先使用组件自带状态。
- 当需要跨组件共享状态时,引入轻量的状态管理库,如
Zustand(React) 或Pinia(Vue),它们比 Redux/Vuex 更简单、更符合直觉。
-
CSS 方案:
- 推荐使用
CSS-in-JS(如 Styled-components) 或原子化 CSS (如Tailwind CSS),结合CSS Modules,以实现作用域隔离和更好的组件化。
- 推荐使用
决策产出:一套清晰的代码组织规范和几个核心模块(如请求模块)的实现范例。
第四阶段:持续集成与部署 (Automation)
最后,打通从代码提交到上线的“最后一公里”。
- CI/CD Pipeline:
- 使用
GitHub Actions或Jenkins配置自动化流水线。 - 流水线至少包含以下步骤:
Lint Check->Unit Tests->Build。 - 当代码推送到
main分支时,自动触发构建,并将产物部署到生产服务器或 CDN。
- 使用
四、与AI对话时,流式传输打字机效果实现方案
核心思想
两种方案的核心都是利用 HTTP 长连接,允许服务器持续地向客户端发送数据,而不是一次性返回所有内容。这极大地改善了处理大型数据或实时数据的用户体验。
1. SSE (Server-Sent Events) 方案实现过程
SSE 是一种专门为服务器向客户端单向推送数据设计的 HTML5 标准技术。它的客户端 API 非常简洁易用。
后端实现 (server.js)
- 设置响应头 (Headers):这是 SSE 的关键。
Content-Type: 必须设置为text/event-stream,声明这是一个事件流。Cache-Control: 设置为no-cache,防止浏览器或代理缓存响应。Connection: 设置为keep-alive,保持长连接。
- 发送数据:
- 使用
res.write()分块发送数据。 - 数据必须遵循特定格式:
data: [你的数据]\n\n。注意,[你的数据]通常是字符串或 JSON 字符串,并且必须以两个换行符\n\n结尾,这代表一个消息的结束。
- 使用
- 发送自定义事件 (可选):
- 可以通过
event: [事件名]\n来定义一个具名事件,如代码中的event: end。前端可以通过addEventListener监听这个特定事件。
- 可以通过
- 关闭连接:
- 数据发送完毕后,调用
res.end()关闭连接。 - 同时,监听
req.on('close', ...)事件,当客户端主动断开连接时,清除定时器等资源,避免内存泄漏。
- 数据发送完毕后,调用
// demo/stream/server.js L21-L46
app.get('/stream', (req, res) => {
// 1. 设置关键的 HTTP 头部
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 立即发送头部
const message = "..."
let charIndex = 0;
const intervalId = setInterval(() => {
if (charIndex < message.length) {
const char = message[charIndex++];
// 2. 按照 SSE 格式发送数据
res.write(`data: ${JSON.stringify({ char })}\n\n`);
} else {
clearInterval(intervalId);
// 3. 发送自定义结束事件
res.write(`event: end\ndata: {}\n\n`);
// 4. 关闭连接
res.end();
}
}, 100);
// 客户端关闭处理
req.on('close', () => {
clearInterval(intervalId);
res.end();
});
});
前端实现 (sse.html)
- 创建
EventSource实例:const eventSource = new EventSource('/stream');,传入后端的流式数据接口地址。浏览器会自动处理连接。
- 监听事件:
onopen: 连接建立时触发。onmessage: 收到默认(未命名)的data消息时触发,数据在event.data中。addEventListener('自定义事件名', ...): 监听后端通过event:定义的特定事件。onerror: 发生错误时触发。一个重要的特性是,默认情况下,如果连接断开,EventSource会自动尝试重新连接。
- 关闭连接:
- 调用
eventSource.close()主动关闭连接。
- 调用
// demo/stream/sse.html L14-L53
const resultDiv = document.getElementById('result');
// 1. 创建 EventSource 实例
const eventSource = new EventSource('/stream');
// 2. 监听 open 事件
eventSource.onopen = function() {
console.log("连接已打开");
};
// 2. 监听 message 事件
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
// ... 更新 UI ...
};
// 2. 监听自定义的 'end' 事件
eventSource.addEventListener('end', function(event) {
console.log("服务器已发送结束信号,关闭连接。");
// 3. 关闭连接
eventSource.close();
});
// 2. 监听 error 事件
eventSource.onerror = function(err) {
console.error("EventSource 失败:", err);
eventSource.close();
};
2. Fetch API + ReadableStream 方案实现过程
这是一种更通用的方法,不限于特定的 Content-Type。它将 fetch 的响应体 (response.body) 作为一个可读流来处理,给予开发者更高的自由度,但实现也相对复杂。
后端实现 (server.js)
- 设置响应头 (Headers):
- 这里的头部没有严格规定,可以是
application/json、application/octet-stream或任何自定义类型。 - 同样建议设置
Cache-Control: no-cache和Connection: keep-alive。
- 这里的头部没有严格规定,可以是
- 发送数据 (自定义协议):
- 直接使用
res.write()发送数据块。 - 因为没有像 SSE 那样的标准格式,你需要自己定义数据块之间的边界。最常见的方式是使用换行符
\n分隔每一条 JSON 数据。 - 你还需要自己定义数据流结束的信号,例如在最后一个 JSON 对象中包含一个特殊字段,如
{ done: true }。
- 直接使用
- 关闭连接:
- 数据发送完毕后,调用
res.end()关闭连接。 - 同样需要处理
req.on('close', ...)事件。
- 数据发送完毕后,调用
// demo/stream/server.js L49-L74
app.get('/fetch-stream', (req, res) => {
// 1. 设置 HTTP 头部
res.setHeader('Content-Type', 'application/json');
// ... 其他头部 ...
const message = "...";
let charIndex = 0;
const intervalId = setInterval(() => {
if (charIndex < message.length) {
const char = message[charIndex++];
// 2. 发送自定义格式的数据块 (换行符分隔)
res.write(JSON.stringify({ char, done: false }) + '\n');
} else {
clearInterval(intervalId);
// 2. 发送结束信号
res.write(JSON.stringify({ char: '', done: true }) + '\n');
// 3. 关闭连接
res.end();
}
}, 100);
// ... 关闭处理 ...
});
前端实现 (fetch.html)
- 发起
fetch请求:- 调用
fetch('/fetch-stream')。
- 调用
- 获取
ReadableStream读取器:- 从
response.body中获取流,然后通过.getReader()方法得到一个读取器reader。
- 从
- 循环读取数据:
- 使用
while (true)循环和await reader.read()来持续读取数据块。 reader.read()返回一个 Promise,解析后得到{ done, value }对象。done为true表示流结束,value是一个Uint8Array类型的二进制数据。
- 使用
- 解码和处理数据:
- 使用
TextDecoder将Uint8Array解码成字符串。 - 处理数据粘包/半包问题:由于数据块的大小不确定,一个
read()可能包含多个或半个消息。必须手动处理边界,例如,将接收到的数据存入缓冲区buffer,然后按换行符\n分割,最后一个不完整的片段留在buffer中等待下一次数据。 - 解析(如
JSON.parse)并处理每一条完整的消息。
- 使用
- 结束和错误处理:
- 当
reader.read()返回的done为true时,跳出循环。 - 整个过程需要用
try...catch包裹,以捕获网络错误等异常。 - 没有自动重连机制,需要手动实现。
- 当
// demo/stream/fetch.html L22-L82
async function startFetchStream() {
try {
// 1. 发起 fetch 请求
const response = await fetch('/fetch-stream');
// 2. 获取读取器
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 3. 循环读取
while (true) {
const { done, value } = await reader.read();
if (done) break; // 5. 流结束
// 4. 解码和处理粘包
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留不完整的部分
for (const line of lines) {
if (line.trim()) {
const data = JSON.parse(line);
if (data.done) return; // 5. 收到结束信号
// ... 更新 UI ...
}
}
}
} catch (error) {
// 5. 错误处理
console.error("Fetch 流式传输错误:", error);
}
}
3. 总结与对比 (面试关键点)
| 特性 | SSE (Server-Sent Events) | Fetch API (ReadableStream) |
|---|---|---|
| API 简洁度 | 非常高。客户端使用 EventSource,API 封装良好,使用简单。 | 较低。需要手动获取 Reader,循环读取,解码 Uint8Array,并自己处理数据分块/粘包问题。 |
| 自动重连 | 内置支持。当连接意外断开时,浏览器会自动尝试重新连接。 | 不支持。需要手动实现重连逻辑。 |
| 事件系统 | 内置支持。后端可以通过 event: 字段发送具名事件,前端用 addEventListener 监听。 | 不支持。需要自己在数据载荷(payload)中设计事件类型字段来模拟。 |
| 数据格式 | 有标准格式 (data: ...\n\n),只能传输 UTF-8 文本数据。 | 无限制,完全自定义。可以传输文本、JSON,也可以是二进制数据 (ArrayBuffer)。 |
| 请求类型 | 只能是 GET 请求。 | 支持所有 HTTP 请求类型 (GET, POST, PUT 等),可以随请求发送 body。 |
| 错误处理 | 通过 onerror 事件句柄处理。 | 通过标准的 try...catch 块捕获异常。 |
| 浏览器兼容性 | 现代浏览器都支持,但在一些旧版浏览器或非浏览器环境中可能受限。 | 现代浏览器都支持,是更底层的 Web 标准。 |
如何选择?(面试加分项)
-
选择 SSE 的场景:
- 需求是简单的从服务器到客户端的单向文本消息推送,例如:股票价格更新、新闻 Feed、站内通知、ChatGPT 打字机效果等。
- 追求开发简洁、快速,并且希望利用其自动重连的特性时。
-
选择 Fetch API + ReadableStream 的场景:
- 需要更底层的控制权和更高的灵活性。
- 需要传输二进制数据,例如流式加载图片、视频、文件下载等。
- 需要通过 POST 请求来启动一个数据流(例如,向服务器提交一个复杂的查询参数,然后流式返回结果)。
- 当需要实现自定义的重连策略、错误处理或数据分帧逻辑时。
一句话总结:SSE 是一个“开箱即用”的高级解决方案,专为服务器推送文本事件而生;而 Fetch Stream 是一个更底层、更通用的工具,让你能以流式方式处理任何类型的 HTTP 响应体,但需要写更多的模板代码。
五、Markdown 解析器和编译器 Marked.js 的实现原理
这是一个非常经典的问题,它考察了对编译原理、文本处理和前端安全性的理解。
它的核心任务是接收 Markdown 格式的纯文本字符串,然后输出对应的 HTML 字符串。这个过程和编译器的工作流程非常相似。
Marked 的实现可以拆解为三个核心阶段:词法分析 (Lexing)、语法分析 (Parsing) 和 渲染 (Rendering/Compiling)。
1. 词法分析 (Lexer)
这是整个流程的第一步。Lexer(词法分析器)的职责是读取原始的 Markdown 字符串,然后根据预设的规则,将其分割成一个个有意义的**“词法单元” (Tokens)**。它并不关心这些单元之间的层级关系,只是做初步的识别和分类。
实现细节:
- 基于正则表达式:
Marked的核心是大量使用正则表达式来匹配 Markdown 的各种语法。它内部定义了一套完整的、按优先级排序的正则表达式规则。 - 两级词法分析:
Marked巧妙地将分析分为两类:- 块级元素 (Block-level Elements): Lexer 首先会从头到尾扫描整个文本,优先匹配那些占据整行或多个块的元素,例如:标题 (
#)、代码块 (```)、引用 (>)、列表 (*,-,1.)、水平线 (---)、段落等。 - 行内元素 (Inline-level Elements): 行内元素的分析不会在这一步进行。此时的段落、标题等 token 内部仍然是原始的 Markdown 文本。
- 块级元素 (Block-level Elements): Lexer 首先会从头到尾扫描整个文本,优先匹配那些占据整行或多个块的元素,例如:标题 (
示例:
假设输入是:
# Title
This is **bold** text.
经过 Block Lexer 处理后,会生成一个扁平的 Token 数组,大致如下:
[
{ type: 'heading', depth: 1, raw: '# Title', text: 'Title' },
{ type: 'paragraph', raw: 'This is **bold** text.', text: 'This is **bold** text.' }
]
注意,此时 **bold** 并没有被处理,它仍然是 paragraph token 的 text 属性中的一部分。
2. 语法分析 (Parser)
Parser(语法分析器)接收 Lexer 生成的 Token 数组,并开始理解它们的结构和意义。它的主要工作是构建一个能表达 Markdown 文档层级关系的结构(可以理解为一个简化的抽象语法树 - AST),并处理嵌套在块级元素内部的行内元素。
实现细节:
- 遍历 Token 流: Parser 会遍历之前生成的块级 Token 数组。
- 调用行内 Lexer: 当遇到可以包含行内元素的 Token 时(如
heading,paragraph,list_item),Parser 会调用一个内部的、专门处理行内元素的 Lexer (Inline Lexer) 来处理该 Token 的text属性。 - 行内元素解析: 这个 Inline Lexer 同样使用正则表达式来匹配粗体 (
**...**)、斜体 (*...*)、行内代码(`...`)、链接、图片等。它会将原始文本替换成更细粒度的行内 Token。 - 构建层级关系: 解析后的行内 Token 会被附加到父 Token 上,形成一个嵌套的树形结构。
示例 (续):
Parser 接收到上面的 Token 数组后:
- 处理
headingToken:它的text是 "Title",没有需要进一步解析的行内元素。 - 处理
paragraphToken:它的text是 "This is bold text."。Parser 会对这个字符串调用 Inline Lexer。- Inline Lexer 匹配到
**bold**,生成一个strong类型的 Token。 - 最终,这个
paragraphToken 的结构会变成类似这样:
{ type: 'paragraph', raw: 'This is **bold** text.', text: 'This is bold text.', tokens: [ { type: 'text', raw: 'This is ', text: 'This is ' }, { type: 'strong', raw: '**bold**', text: 'bold', tokens: [ { type: 'text', raw: 'bold', text: 'bold' } ]}, { type: 'text', raw: ' text.', text: ' text.' } ] } - Inline Lexer 匹配到
3. 渲染 (Renderer/Compiler)
这是最后一步。Renderer(渲染器)接收 Parser 处理过的、带有完整层级关系的 Token 树,然后将其“翻译”成最终的 HTML 字符串。
实现细节:
- 方法映射: Renderer 是一个对象或类,它包含了一系列与 Token 类型同名的方法。例如
renderer.heading(text, level),renderer.paragraph(text),renderer.strong(text)等。 - 递归遍历: 编译过程会递归地遍历 Token 树。每遇到一个 Token,就调用 Renderer 上对应的方法,并将该 Token 的内容作为参数传入。
- 字符串拼接: 每个 Renderer 方法返回一小段 HTML 字符串。整个遍历过程就是将这些 HTML 片段拼接成最终完整结果的过程。
示例 (续):
- 遇到
headingToken,调用renderer.heading('Title', 1)-> 返回<h2>Title</h2>。 - 遇到
paragraphToken,它会先递归处理其内部的tokens数组:- 调用
renderer.text('This is ')-> 返回"This is "。 - 遇到
strongToken,递归调用renderer.strong('bold')-> 返回<strong>bold</strong>。 - 调用
renderer.text(' text.')-> 返回" text."。 - 将内部结果拼接后,调用
renderer.paragraph('This is <strong>bold</strong> text.')-> 返回<p>This is <strong>bold</strong> text.</p>。
- 调用
最终,将所有块级元素的 HTML 结果拼接起来,得到:
<h2>Title</h2><p>This is <strong>bold</strong> text.</p>
额外亮点:扩展性与安全性
一个优秀的库不仅要完成核心功能,还要考虑扩展和安全。
-
扩展性 (Extensibility):
Marked的强大之处在于它的高度可定制性。- 自定义 Renderer: 你可以传入一个自己的 Renderer 对象,重写任何一个方法,来改变最终的HTML输出。例如,让所有链接都在新标签页打开。
- 扩展 (Extensions): 你可以定义自己的词法规则(Tokenizers)和渲染规则,来支持标准的 Markdown 之外的自定义语法(例如 GFM 的 task lists
[ ]/[x])。 - Hooks:
Marked提供了hooks(如preprocess和postprocess),允许你在解析前后对原始 Markdown 或最终 HTML 进行全局处理。
-
安全性 (Security):
- Sanitization: 因为
Marked的输入可能来自用户,所以必须防范 XSS (跨站脚本攻击)。如果用户输入了<script>alert('hack')</script>,直接渲染到页面上会执行恶意脚本。 Marked曾内置一个sanitize选项,它会过滤掉不安全的 HTML 标签和属性。但维护一个完美的 sanitizer 非常困难,因此新版的Marked已经废弃了内置的 sanitize 功能,并强烈推荐用户在接收到Marked的输出后,使用专门的、更可靠的库(如 DOMPurify)来进行 HTML 清理。这是一个更现代、更安全的做法。
- Sanitization: 因为
总结
Marked.js 的实现是一个经典的**“输入 -> 词法分析 -> 语法分析 -> 编译输出”** 的流水线。它通过巧妙的两级词法分析(Block & Inline)和高度可定制的渲染器,高效且灵活地将 Markdown 文本转换为了 HTML,同时也将安全处理的责任交给了更专业的工具库。
六、 大文件分片上传
我的回答将不仅仅是流程的陈述,更会聚焦于前端侧的技术选型、性能瓶颈、代码健壮性以及最终用户体验的打磨。
一、 核心挑战与设计思路
首先,我们要明确为什么需要分片上传。直接上传大文件存在四大痛点:
- HTTP 超时:请求时间过长,容易被浏览器或服务器超时中断。
- 服务器限制:多数Web服务器(如Nginx)对单个请求体的大小有限制。
- 无状态与不可靠:HTTP是无状态的,一旦网络抖动导致上传中断,必须从头开始,成本极高。
- 糟糕的用户体验:在整个上传过程中,UI可能被冻结,用户看不到明确的进度,也无法暂停。
因此,我们的核心设计思路是:化整为零,分而治之,并在此基础上构建一个稳定、高效、可控的前端上传系统。
整个实现可以分为四大支柱:
- 分片与标识:前端对文件进行预处理。
- 并发控制与断点续传:核心的上传与容错逻辑。
- 用户体验与监控:为用户提供清晰的视觉反馈和操作能力。
- 前后端契约:定义清晰的通信协议。
下面,我将详细展开这四个方面。
二、 支柱一:分片与标识
这是上传流程的起点,也是性能优化的第一个关键点。
1. 文件切片 (Slicing)
我们会使用 File.prototype.slice() 方法对文件进行切割。这是一个极其高效的原生API,因为它返回的是包含文件部分数据的 Blob 对象,而不会将整个文件加载到内存中,避免了内存溢出。
const CHUNK_SIZE = 5 * 1024 * 1024; // 定义切片大小,例如5MB
function createFileChunks(file, size = CHUNK_SIZE) {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push({ index: chunks.length, file: file.slice(cur, cur + size) });
cur += size;
}
return chunks;
}
2. 文件唯一标识 (Hashing) 为文件生成一个唯一标识是实现断点续传和秒传的基石。这个标识必须与文件内容挂钩。
- 技术选型:使用
spark-md5等成熟的库来计算文件的MD5值。 - 性能瓶颈与解决方案:
- 问题:对大文件计算Hash是一个CPU密集型任务,直接在主线程中计算会造成UI线程长时间阻塞,导致页面卡死。这是绝对不可接受的。
- 专家级方案:必须使用 Web Worker。我们将文件或文件切片传递给Worker线程,在后台完成Hash计算,然后将结果通过
postMessage返回给主线程。这能确保UI的绝对流畅。
// main.js - 主线程
const worker = new Worker('hash-worker.js');
worker.postMessage({ file });
worker.onmessage = event => {
const { hash } = event.data;
// 拿到hash后,开始执行上传逻辑
};
// hash-worker.js - Worker线程
importScripts('spark-md5.min.js'); // 引入库
self.onmessage = event => {
// ... 在这里执行耗时的Hash计算 ...
const hash = /* ...计算结果... */;
self.postMessage({ hash });
};
- 优化策略:对于超大文件,可以采用“抽样哈希”,只计算文件头、尾和中间几个关键点的数据,牺牲极小的哈希碰撞概率来换取秒级的文件识别速度。
三、 支柱二:并发控制与断点续传
这是整个上传系统的核心,体现了健壮性。
1. 断点续传 (Resumable Uploads) 在计算出文件Hash后,真正的上传开始前,先向后端发起一个“验证”请求。
sequenceDiagram
participant Client as 前端
participant Server as 后端
Client->>Server: POST /verify (fileHash)
Server-->>Client: { uploadedChunks: ["0", "1", "3"] }
前端拿到服务器已有的切片索引列表后,就可以从待上传的切片队列中剔除这些已完成的部分,只上传剩余的切片。
2. 并发控制与失败重试
- 问题:如果文件被切成1000片,直接用
Promise.all会瞬间发出1000个请求,这会耗尽浏览器连接池并可能导致服务器拒绝服务。 - 专家级方案:实现一个可控制并发数量的请求池。维护一个固定大小的“正在进行中”的请求集合,当集合中有请求完成时,再从等待队列中取下一个任务执行,确保同时进行的请求数量不会超过设定的阈值(例如4或6)。
async function sendRequestsInPool(chunksToUpload, maxConcurrency = 4) {
// ... 实现一个并发池逻辑 ...
// 对于每个切片上传,都要封装一层重试逻辑
const uploadWithRetry = async (chunk, retries = 3) => {
try {
return await uploadChunk(chunk); // uploadChunk是真正的上传函数
} catch (error) {
if (retries > 0) {
console.warn(`切片 ${chunk.index} 上传失败,正在重试...`);
return uploadWithRetry(chunk, retries - 1);
}
throw new Error(`切片 ${chunk.index} 上传失败`);
}
};
// ... 在并发池中执行 uploadWithRetry ...
}
对于每个切片的上传,都应包裹一层重试逻辑。例如,某个切片上传失败后,等待1秒再重试,最多尝试3次。这能极大地提高在不稳定网络下的成功率。
四、 支柱三:用户体验与监控
技术最终要服务于人,清晰的反馈和控制是前端专家的核心职责。
1. 精确的上传进度
- 整体进度:
(已成功上传的切片数 / 总切片数) * 100%。 - 瞬时进度:为了显示上传速度和剩余时间,我们需要监听每个切片的上传进度。
- 技术选型:标准
fetchAPI 无法监听上传进度。因此,在这个场景下,我们必须回退到使用XMLHttpRequest(XHR),或者axios(其底层是XHR),因为它提供了onUploadProgress事件。
function uploadChunk(chunk) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.onprogress = event => {
if (event.lengthComputable) {
const percentage = (event.loaded / event.total) * 100;
// 在这里更新该切片的UI进度条
}
};
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(xhr.statusText);
// ... 构建 FormData并发送 ...
xhr.send(formData);
});
}
2. 可控的暂停与恢复
- 暂停:前端维护一个“isPaused”状态。用户点击暂停时,将此状态设为
true,并调用所有正在进行的XHR请求的.abort()方法来中止它们。 - 恢复:用户点击恢复时,将“isPaused”设为
false,并重新触发主上传函数。由于我们有断点续传机制,程序会自动从上次中断的地方继续,无需额外处理。
五、 支柱四:前后端契约
一个健壮的前端系统离不开与后端清晰的通信协议。
-
验证接口 (
/api/verify)POST请求,携带fileHash。- 成功:返回
{ code: 200, data: { uploadedChunks: [...] } }。 - 文件已存在(秒传):返回
{ code: 201, message: 'File already exists.' }。
-
上传接口 (
/api/upload)POST请求,使用multipart/form-data。- 表单数据包含:
chunk(二进制数据),fileHash,chunkIndex。 - 成功:返回
{ code: 200, message: 'Chunk uploaded.' }。
-
合并接口 (
/api/merge)POST请求,application/json。- 请求体:
{ fileHash, fileName, chunkSize }。 - 后端接收到后,根据
fileHash找到所有切片,校验数量是否正确,然后按chunkIndex排序合并。 - 成功:返回
{ code: 200, message: 'File merged.' }。 - 失败(如切片丢失):返回特定错误码,如
{ code: 400, message: 'Missing chunks.', data: { missingChunks: [...] } },前端可以根据这个信息尝试重传丢失的切片。
总结
面试官,以上就是我作为一名前端专家对“大文件分片上传”的完整实现方案。它不仅仅是一个简单的流程,而是一个集性能优化(Web Worker)、并发控制、容错机制(失败重试)、精确监控(XHR进度)和清晰协议于一体的综合性前端工程。这个方案的核心在于,我们始终将应用的健壮性和用户的最终体验放在首位进行设计和决策。
七、虚拟列表
虚拟列表,或者叫虚拟滚动,是一项前端性能优化的核心技术。它旨在解决当我们需要渲染一个包含成千上万条数据项的超长列表时,所遇到的性能瓶颈问题。
如果一个有10,000个项目的列表被一次性全部渲染,浏览器需要创建10,000个DOM节点。这会导致两个致命问题:
- 极高的内存占用:大量的DOM节点会消耗巨额内存。
- 严重的性能卡顿:首次渲染会花费很长时间,导致页面白屏;并且在滚动过程中,浏览器需要对海量元素进行计算和重绘,导致页面滚动时出现掉帧和卡顿。
虚拟列表的核心思想非常简单:只渲染用户当前视口(Viewport)内可见的列表项。
下面我将从实现原理、关键挑战和代码结构三个方面来详细阐述。
一、 实现原理
虚拟列表的实现,可以拆解为以下几个关键步骤:
-
构建基本DOM结构:
- 我们需要一个外层的、固定高度的容器 (Container),这个容器的
overflow属性需要设置为auto或scroll,使其可以滚动。 - 在容器内部,我们需要一个占位元素 (Phantom/Sizer)。这个元素是不可见的,但它的高度等于整个列表所有项目累加起来的总高度。它的唯一作用就是“撑开”容器,产生一个看起来正确的、可供用户拖动的滚动条。
- 最后,我们需要一个内容呈现区域 (Content Wrapper),它使用绝对定位(
position: absolute)放置在容器内部。所有需要被真实渲染的列表项DOM节点,都会被放置在这个区域里。
<div class="virtual-list-container"> <!-- 1. 滚动容器 --> <div class="phantom-sizer"></div> <!-- 2. 高度占位 --> <div class="content-wrapper"> <!-- 3. 内容呈现区域 --> <!-- 真实渲染的列表项将在这里 --> </div> </div> - 我们需要一个外层的、固定高度的容器 (Container),这个容器的
-
监听滚动事件与计算:
- 我们监听容器的
scroll事件。当用户滚动时,我们可以从事件对象中获取当前的滚动偏移量scrollTop。 - 根据
scrollTop、每个列表项的预估高度itemHeight以及容器本身的高度containerHeight,我们可以精确计算出:- 列表的起始索引
startIndex:Math.floor(scrollTop / itemHeight) - 列表的结束索引
endIndex:Math.ceil((scrollTop + containerHeight) / itemHeight) - 当前视口应该渲染的数据片段:
allData.slice(startIndex, endIndex)
- 列表的起始索引
- 我们监听容器的
-
渲染与定位:
- 拿到需要渲染的数据片段后,我们将其渲染成真实的DOM节点,放入
content-wrapper中。 - 最关键的一步:为了让这些渲染出来的项显示在正确的位置,我们需要给
content-wrapper设置一个transform: translateY()。这个Y轴的偏移量等于startIndex * itemHeight。 - 为什么用
transform而不是top或margin-top? 因为transform属性的改变通常只会触发浏览器的合成 (Composite) 过程,不会引发重排 (Reflow) 和重绘 (Repaint),性能开销极小。而改变top会导致昂贵的重排,这也是虚拟列表性能优化的核心所在。
- 拿到需要渲染的数据片段后,我们将其渲染成真实的DOM节点,放入
二、 关键挑战与优化策略
一个生产环境可用的虚拟列表,还需要处理几个复杂的问题:
1. 可变高度的列表项 (Variable Item Height) 前面的计算都基于所有列表项高度固定的假设。如果高度是动态的(例如,列表项里有不同行数的文本),事情会变得复杂。
- 解决方案:
- 预估与缓存:在渲染前,我们给所有项一个预估高度(例如,一个平均值或最小值)。
- 当一个列表项被真实渲染到DOM中后,我们通过
getBoundingClientRect()或offsetHeight获取它的真实高度。 - 将这个真实高度缓存起来,并用它来更新之前预估的高度。
- 同时,我们需要动态地更新那个“占位元素”的总高度,并维护一个记录每个项目起始位置的数组,以便在滚动时能正确查找。这个过程相对复杂,需要精细管理。
2. 滚动过快导致的白屏 (Blank Screen on Fast Scroll) 如果用户滚动得非常快,JavaScript的计算和渲染速度可能跟不上,导致用户在短暂的时间内看到空白区域。
- 解决方案:缓冲区 (Buffer/Overscan)
- 我们不在只渲染
startIndex到endIndex的区域。而是在这个基础上,向上和向下多渲染几个列表项。例如,渲染startIndex - bufferCount到endIndex + bufferCount的范围。 - 这个“缓冲区”为JavaScript的执行争取了时间,极大地改善了快速滚动时的用户体验。
- 我们不在只渲染
3. 频繁的事件处理 (Throttling Scroll Events)
scroll 事件的触发频率非常高。如果在每个事件回调中都进行大量的计算和DOM操作,仍然可能导致性能问题。
- 解决方案:使用
requestAnimationFrame- 将所有的计算和渲染逻辑都包裹在
requestAnimationFrame的回调中。这能保证我们的DOM操作在浏览器下一次绘制之前执行,与浏览器的渲染节奏保持同步,避免了不必要的计算和布局抖动,是处理滚动事件的最佳实践。
- 将所有的计算和渲染逻辑都包裹在
三、 简化版的React代码结构
下面是一个简化的React Hooks实现思路,用于展示核心逻辑:
function VirtualList({ allData, itemHeight }) {
const containerRef = React.useRef(null);
const [visibleData, setVisibleData] = React.useState([]);
const [offsetY, setOffsetY] = React.useState(0);
const totalHeight = allData.length * itemHeight;
const handleScroll = React.useCallback(() => {
if (!containerRef.current) return;
const scrollTop = containerRef.current.scrollTop;
const containerHeight = containerRef.current.clientHeight;
// 使用 requestAnimationFrame 优化
window.requestAnimationFrame(() => {
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
setVisibleData(allData.slice(startIndex, endIndex));
setOffsetY(startIndex * itemHeight);
});
}, [allData, itemHeight]);
// 初始化
React.useEffect(() => {
handleScroll();
}, [handleScroll]);
return (
<div
ref={containerRef}
style={{ height: '500px', overflow: 'auto' }}
onScroll={handleScroll}
>
<div style={{ height: `${totalHeight}px`, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleData.map(item => (
<div key={item.id} style={{ height: `${itemHeight}px` }}>
{item.content}
</div>
))}
</div>
</div>
</div>
);
}
总结
总而言之,虚拟列表是一项通过牺牲部分内存(用于缓存位置和高度)来换取巨大渲染性能提升的前端关键技术。它的实现核心在于计算视口内的数据、用占位元素模拟滚动条、以及通过 transform 高效地移动渲染窗口。
一个资深的前端专家不仅要能实现其基本功能,更要能处理好可变行高、滚动白屏等棘手问题,并对底层的性能优化原理(如transform与reflow的关系,requestAnimationFrame的使用)有深刻的理解。在实际项目中,我们通常会使用如 react-window 或 tanstack-virtual 这样成熟的社区库,但理解其底层原理是必不可少的。
拓展:海量可交互表单表格
一个表格放到一个表单里边,然后可能有几百上千行,然后每一个单元格它又能输入,或者是说做一些下拉选,就是表单的操作。如何性能优化
当面临一个包含成百上千行、且每个单元格都是一个表单控件(输入框、下拉框等)的表格时,性能瓶颈主要来自三个方面:
- 渲染压力:一次性渲染成千上万个 DOM 节点,会导致页面首次加载极慢,后续的任何一次重排/重绘都可能是灾难性的。
- 状态管理:如果使用 React/Vue,如何高效地管理这数万个表单控件的状态?一次输入不应导致整个表格的重新渲染。
- 事件处理:为每个控件都绑定事件监听器会消耗大量内存,并可能导致交互卡顿。
针对以上问题,可以采用以下组合优化策略:
核心策略一:虚拟化渲染 (Virtualization)
这是解决海量数据渲染问题的根本性方案。
- 核心思想:不渲染用户看不见的东西。无论表格有多少行,我们只渲染当前视口(Viewport)内可见的行,以及在视口上下方保留少量缓冲区。
- 实现方式:
- 创建一个外层容器,并监听其
scroll事件。 - 内部创建一个内容区,通过
padding或transform撑开总高度,以保证滚动条的长度是正确的。 - 根据当前的滚动位置(
scrollTop),计算出哪些行的数据应该被显示。 - 只渲染计算出的这些行,并使用
position: absolute和transform: translateY(...)将它们精确地放置在视口内的正确位置。
- 创建一个外层容器,并监听其
- 落地:
- React: 可以直接使用成熟的社区库,如
react-window,react-virtualized, 或更现代的tanstack-virtual。 - Vue: 可以使用
vue-virtual-scroller。 - 自己实现也是一个很好的面试加分项,能体现对底层原理的理解。
- React: 可以直接使用成熟的社区库,如
核心策略二:状态与视图的原子化更新
这是保证交互流畅的关键。
- 核心思想:用户的任何操作,都应该只影响最小的渲染单元。输入A单元格,不应该导致B单元格甚至整个表格重新渲染。
- 实现方式:
- 组件拆分与 Memoization:将表格的每一行(
Row),甚至每一个单元格(Cell)都拆分成独立的组件。并使用React.memo(React) 或v-memo(Vue 3.2+) 包裹,确保只有当传递给它的 props 真正改变时,组件才会重新渲染。 - 原子化的状态管理:传统的 Redux 或 Vuex 模式下,一个微小的改动可能导致整个大状态对象的变更,从而触发大范围的重新渲染。在这里应该采用更“原子化”的状态管理库:
- Jotai / Recoil: 这类库允许你为每一个单元格或每一行数据创建一个“原子”(Atom)。当更新状态时,只有订阅了这个“原子”的组件会重新渲染,完美契合这个场景。
- Zustand: 也可以实现类似效果,通过创建精细化的 selector,让组件只订阅它关心的那一部分数据。
- 避免将表单状态提升到顶层:尽量不要把所有单元格的
value和onChange都放在顶层的 Table 组件中管理,这会造成灾难性的 props “瀑布”。应该将状态和逻辑尽可能地“下沉”到Cell或Row组件内部。
- 组件拆分与 Memoization:将表格的每一行(
核心策略三:事件委托 (Event Delegation)
- 核心思想:避免为成千上万个输入框分别绑定事件监听器。
- 实现方式:
- 只在最外层的表格容器上监听
change,click,focus等事件。 - 当事件触发时,通过
event.target来判断事件源是哪个具体的单元格。 - 可以给每个单元格的 DOM 元素添加
data-row-id和data-column-key这样的自定义属性,以便在事件回调中轻松地识别出正在操作的单元格,并进行相应的状态更新。
- 只在最外层的表格容器上监听
辅助策略四:优化输入体验
- 防抖 (Debounce): 对于输入框的
onChange事件,如果它需要触发一些复杂计算或向中心状态同步,使用防抖可以有效减少更新频率,避免在用户快速输入时造成卡顿。 - 使用
useTransition(React 18+): 对于非紧急的状态更新(例如,将单元格的值同步到全局状态),可以将其包裹在startTransition中。这会告诉 React 这是一个可中断的更新,从而优先保证输入框本身响应的流畅性。
八、单点登录与无感刷新token
单点登录 (Single Sign-On, SSO)
1. 它解决了什么问题?
在一个企业或生态下,用户通常需要访问多个独立的应用系统(如邮箱、OA系统、CRM系统)。如果没有 SSO,用户每访问一个新系统就需要重新登录一次,这非常繁琐。
SSO 的核心目标是:用户只需登录一次,就可以访问所有相互信任的应用系统。
2. 核心原理与流程
SSO 的实现需要一个独立的认证中心 (Identity Provider, IdP),以及多个业务方应用 (Service Provider, SP)。
最常见的流程(基于 OAuth 2.0 / OIDC)如下:
- 用户访问应用A (SP1):用户首次访问
app-a.com。 - SP1检查会话:
app-a.com发现用户没有本地登录会话。 - 重定向至认证中心(IdP):
app-a.com将用户的浏览器重定向到sso-auth.com,并带上自己的身份标识和回调地址redirect_uri。https://sso-auth.com/login?client_id=app-a&redirect_uri=https://app-a.com/callback - IdP处理登录:
- 如果用户在 IdP 未登录:
sso-auth.com显示登录页面,用户输入账号密码。 - 登录成功后,IdP 会在自己的域名 (
sso-auth.com) 下种下一个 Cookie,标记用户已在认证中心登录。 - 如果用户在 IdP 已登录 (因为之前访问过别的应用),IdP 会检测到这个 Cookie,直接跳过登录步骤。
- 如果用户在 IdP 未登录:
- IdP回调SP1:IdP 将浏览器重定向回
app-a.com/callback,并附带一个一次性的授权码 (code)。 - SP1获取令牌:
app-a.com的后端拿到这个code后,向 IdP 的接口发起请求,用code换取正式的 Access Token。这一步在后端进行是为了安全。 - SP1创建本地会话:
app-a.com后端验证 Token 有效后,为用户创建在app-a.com下的本地登录会话(通常是设置一个自己的 Cookie),然后返回页面给用户。至此,用户在应用A登录成功。
画龙点睛的一步来了:
- 用户访问应用B (SP2):用户访问
app-b.com。app-b.com发现用户未登录,同样将用户重定向到sso-auth.com。 - IdP静默认证:此时,
sso-auth.com检测到浏览器中存在之前登录时种下的 Cookie,它便知道用户是谁,无需用户再次输入密码。它会直接生成一个新的code,并立即重定向回app-b.com。这个过程对用户是完全透明的。 - SP2完成登录:后续流程同步骤 6-7,
app-b.com拿到code换取 Token 并创建本地会话。
3. 前端的职责
- 发起登录:当应用检测到用户未登录时,前端需要执行
window.location.href = '...'来重定向到 IdP。 - 处理回调:在 IdP 回调的页面(如
/callback),前端需要从 URL 中解析出code,并发送给自己的后端去换取 Token。 - 存储Token:从后端拿到 Token 后,将其存储起来(如内存、LocalStorage),并在后续的 API 请求中携带。
无感刷新 Token (Silent Token Refresh)
1. 它解决了什么问题?
为了安全,Access Token 的有效期通常很短(比如 15-60 分钟)。如果 Access Token 过期了,难道要让用户重新输入账号密码吗?这体验太差了。
无感刷新 Token 的目标是:在 Access Token 过期后,能自动获取一个新的 Access Token,而用户完全感觉不到这个过程。
2. 核心原理与流程
这套机制的核心是引入了另一个角色:Refresh Token。
- Access Token: 短有效期,用于访问业务接口,存储在客户端(如内存),泄露风险相对可控。
- Refresh Token: 长有效期(比如 7 天或 30 天),唯一的作用就是用来获取新的 Access Token。它必须被安全地存储,通常在后端的 session 中或者前端的
HttpOnlyCookie 里,防止被 JS 脚本窃取。
最经典的前端实现流程(基于请求拦截器):
- 初次登录:用户登录成功后,后端返回一个
accessToken和一个refreshToken。 - 发起业务请求:前端在请求头中携带
accessToken去请求业务 API。axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; - Token过期:
accessToken过期后,再次请求 API,服务器会返回 401 Unauthorized 错误。 - 请求拦截器捕获错误:我们在
axios或fetch的响应拦截器中捕获这个 401 错误。 - 执行刷新逻辑:
- 拦截器发现是 401 错误后,暂停所有后续的 API 请求。
- 然后,它会向专门的刷新接口(如
/api/refresh_token)发起请求,这个请求会携带refreshToken。 - 后端验证
refreshToken有效后,会返回一个新的accessToken(有时也会返回一个新的refreshToken,这叫刷新令牌滚动机制,可以增加安全性)。
- 更新并重发:
- 拦截器拿到新的
accessToken后,用它更新本地存储和axios的请求头配置。 - 然后,将刚才失败的那个 API 请求,用新的 Token 重新发送一次。
- 同时,恢复执行被暂停的其他 API 请求。
- 拦截器拿到新的
- 用户无感知:整个过程对用户是静默的,用户看到的只是接口响应慢了一点,但业务功能正常完成了。
3. 前端的职责与挑战
- 实现拦截器:这是核心。封装
axios实例,编写响应拦截器来处理 401 错误和刷新逻辑是标准操作。 - 处理并发请求(Race Condition):这是一个非常关键的细节。如果页面同时发出了 A、B、C 三个请求,它们都因为 Token 过期而失败了,我们不希望发起三次刷新 Token 的请求。
- 解决方案:需要一个“锁”。当第一个请求触发 401 并开始刷新时,设置一个状态
isRefreshing = true。后续失败的请求发现正在刷新中,就不要再去请求刷新了,而是进入一个“等待队列”。当刷新成功后,遍历这个队列,用新的 Token 把所有待处理的请求全部重新发出去。
- 解决方案:需要一个“锁”。当第一个请求触发 401 并开始刷新时,设置一个状态
4. 代码示例
// 文件: src/api/axiosInstance.js
import axios from 'axios';
// 假设你有一个模块来处理 token 的存储和读取
// 例如: localStorage, secure-storage 等
// import { getTokens, setTokens, clearTokens } from './tokenManager';
// --- 模拟 Token 管理 ---
let memoryTokens = {
accessToken: 'initial-access-token',
refreshToken: 'initial-refresh-token',
};
const getTokens = () => memoryTokens;
const setTokens = (tokens) => { memoryTokens = tokens; };
const clearTokens = () => { memoryTokens = null; };
// --------------------
const axiosInstance = axios.create({
baseURL: '/api', // 你的 API base URL
});
// --- 核心:状态锁和请求队列 ---
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// --- 请求拦截器 ---
// 在每个请求发出前,为其附上 Authorization 头
axiosInstance.interceptors.request.use(
config => {
const tokens = getTokens();
if (tokens?.accessToken) {
config.headers['Authorization'] = 'Bearer ' + tokens.accessToken;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// --- 响应拦截器 ---
axiosInstance.interceptors.response.use(
response => {
return response; // 如果请求成功,直接返回响应
},
async error => {
const originalRequest = error.config;
// 1. 如果错误是 401,并且不是刷新 token 的请求本身,且不是重试请求
if (error.response.status === 401 && originalRequest.url !== '/auth/refresh' && !originalRequest._retry) {
// 2. 如果正在刷新 token,将当前失败的请求暂停并存入队列
if (isRefreshing) {
return new Promise(function (resolve, reject) {
failedQueue.push({ resolve, reject });
})
.then(token => {
// 当刷新成功后,队列中的请求会拿到新 token 并被重新执行
originalRequest.headers['Authorization'] = 'Bearer ' + token;
return axiosInstance(originalRequest);
})
.catch(err => {
return Promise.reject(err);
});
}
// 3. 这是第一个收到 401 的请求,开始刷新 token
originalRequest._retry = true; // 标记为重试请求,防止无限循环
isRefreshing = true;
const tokens = getTokens();
if (!tokens?.refreshToken) {
// 如果没有 refresh token,直接登出
console.log('No refresh token, logging out.');
clearTokens();
// window.location.href = '/login';
return Promise.reject(error);
}
try {
// 4. 发送刷新请求
const response = await axios.post('/api/auth/refresh', {
refreshToken: tokens.refreshToken,
});
const newTokens = response.data;
setTokens(newTokens); // 更新存储的 token
// 5. 刷新成功后,处理队列中的所有请求
processQueue(null, newTokens.accessToken);
// 6. 重试原始请求
originalRequest.headers['Authorization'] = 'Bearer ' + newTokens.accessToken;
return axiosInstance(originalRequest);
} catch (refreshError) {
// 7. 如果刷新 token 本身就失败了,清空队列并登出
processQueue(refreshError, null);
console.error('Unable to refresh token.', refreshError);
clearTokens();
// window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false; // 8. 无论成功与否,都要释放锁
}
}
// 对于非 401 的其他错误,直接返回失败
return Promise.reject(error);
}
);
export default axiosInstance;
九、前端工程化
“前端工程化并不是指某一个具体的工具或技术,而是一套完整的思想和解决方案。它借鉴了软件工程的系统化、规范化的方法,核心目标就是**『提效』和『保质』**,最终让我们能够更从容地应对复杂业务和团队协作。”
“为了实现这两个目标,前端工程化主要包含了以下几个关键的方面:”
-
“首先是『模块化和组件化』,这是工程化的基石。JS 模块化(比如 ES Module)解决了依赖管理和命名冲突。而组件化(比如 React/Vue 的组件)让我们能像搭积木一样开发,极大提高了代码的复用性和可维护性。”
-
“其次是『规范化』。团队协作离不开统一的标准。
- 代码层面,我们会用 ESLint、Prettier 来统一代码风格,避免低级错误。
- 流程层面,会约定 Git Flow 工作流和 Conventional Commits 这样的提交规范,让协作更顺畅,代码历史也更清晰。”
-
“然后是『自动化』,这是提升效率最核心的手段。
- 我们会用 脚手架(比如 Vite 或 CRA)快速搭建项目,避免了繁琐的从零配置。
- 核心是 构建工具(现在主要是 Vite 和 Webpack)。它们能帮我们做代码编译(比如 ES6 转 ES5)、代码打包、压缩混淆、代码分割等一系列优化工作。开发时的 热更新(HMR) 也极大地提升了开发体验。”
-
“最后是『自动化测试和部署』。
- 为了保证质量,我们会引入 自动化测试,比如用 Jest 做单元测试,用 Cypress 做端到端测试。
- 这些测试会集成到 CI/CD 流程中(比如用 GitHub Actions)。当我们提交代码后,会自动运行检查和测试,通过后会自动部署到线上,形成一个完整的自动化闭环,保证了线上代码的质量和部署效率。”
“所以,总结一下,我认为前端工程化就是把从开发、到测试、再到部署的整个流程,通过工具和规范串联起来,形成一个自动化的、标准化的体系。它让前端开发从过去的‘手工作坊’模式,真正演变成了现代化的‘工业生产’,让我们开发者能把更多精力放在业务创新上。”
十、前端未来发展趋势
我认为前端未来的发展趋势可以概括为以下几个核心方向:
-
框架与工具链的进化:一方面,我们会看到更高性能、更快构建速度的趋势,例如通过编译时优化(如Svelte)和原生语言工具链(如SWC, esbuild)。另一方面,像Next.js、Nuxt.js这样的元框架会更加成熟,简化全栈应用的开发,模糊前后端界限,比如React Server Components这类技术。
-
AI与前端的深度融合:AI将扮演更重要的角色。首先是AI辅助开发,像GitHub Copilot这类工具会帮助我们更快地编码、测试。其次是AI赋能用户体验,前端将集成更多AI能力,提供个性化内容和更智能的交互。
-
Web平台能力的持续增强:WebAssembly (Wasm) 的应用会更广泛,不仅用于性能敏感模块,也可能拓展到UI渲染。同时,浏览器会持续推出新的Web API,比如WebGPU,增强图形处理和PWA能力。
-
大前端与跨端方案的演进:声明式UI思想会继续统一不同平台。我们会追求更高效、体验更接近原生的跨端方案。前端工程师的边界也会继续扩展到小程序、桌面应用等更多领域。
总的来说,我认为前端会向着更高性能、更智能、更全能的方向发展,同时开发者体验和工程化能力会持续受到重视。对我们开发者而言,持续学习和适应新技术变化是非常关键的。
您好!这几个问题都是前端面试中的经典问题,它们旨在考察候选人的学习能力、解决问题的能力以及实际工作经验。作为您的AI编程助手,我将站在一个资深前端专家的角度,为您提供一份详尽且有深度的回答。
十一、平时怎么学习?
对于前端工程师来说,技术更新迭代非常快,持续学习是必不可少的。我的学习方法可以概括为“体系化输入”和“项目化输出”,确保学习既有广度又有深度。
1. 体系化输入 (Broaden Knowledge):
- 官方文档优先: 对于一个新的技术或者语言特性(例如 React Hooks、Vue3 Composition API、ES2022新特性),我总是第一时间去阅读官方文档。官方文档是最准确、最权威的信息来源。
- 关注行业动态和权威博客:
- 社区和资讯: 我会定期浏览 InfoQ、Hacker News、Smashing Magazine、CSS-Tricks 等网站,了解最新的技术趋势和最佳实践。
- 技术博客: 关注一些行业领袖和团队的博客,比如阮一峰的网络日志、张鑫旭的博客、Google Web Dev、React/Vue的官方博客等。这能让我了解到一些前沿的思考和技术实现。
- 邮件订阅(Newsletter): 订阅如 JavaScript Weekly, Frontend Focus 等,它们每周会把精选的优质文章推送到我的邮箱,非常高效。
- 阅读源码和书籍:
- 当对某个库(如 Redux, Axios)的实现原理感兴趣时,我会去阅读它的源码,这能让我理解得更透彻。
- 阅读经典书籍,比如《你不知道的JavaScript》系列、《JavaScript忍者秘籍》、《重构》等,这些书籍帮助我构建了扎实的理论基础。
2. 项目化输出 (Deepen Understanding):
- Side Project (个人项目): 学习新技术的最好方式就是用它来做一个真实的项目。比如,为了学习
Next.js,我搭建了一个个人博客。在这个过程中,我会遇到各种实际问题(如数据获取、SEO优化、部署),解决这些问题的过程就是深度学习的过程。 - 在工作中实践: 我会评估新技术在工作项目中的适用性。如果合适,我会向团队提出技术方案,比如使用
Vite替代Webpack以提升开发体验,或者引入TypeScript来增加代码的健壮性。 - 分享与总结: 我有写技术博客和在团队内部分享的习惯。把学到的知识讲给别人听,或者写成文章,可以强迫自己把零散的知识点梳理成体系,加深理解。如果能得到他人的反馈,更是宝贵的学习机会。
十二、遇到比较难的问题怎么解决?
当我遇到一个棘手的问题时,我会遵循一个系统性的解决流程,避免无头苍蝇式地乱撞。
1. 精准定义和复现问题 (Define & Reproduce):
- 第一步不是马上写代码,而是清晰地描述问题:这个Bug是什么?在什么条件下会发生?期望的结果是什么,实际结果又是什么?
- 稳定复现:我会尝试找到一个100%能复现该问题的路径。如果问题是偶发的,我会分析日志、用户行为等,寻找共性,尝试将它变为稳定复含。
2. 缩小问题范围 (Isolate):
- 二分法定位:这是我最常用的方法。比如,如果一个功能模块出错了,我会通过注释掉部分代码、或者回滚到某个历史版本来判断问题是出在哪一部分代码或者哪一次提交中。
- 最小可复现示例 (Minimal Reproducible Example):我会尝试在一个干净的环境中(比如一个新的HTML文件,或者在 CodeSandbox/JSFiddle 上)用最少的代码复现这个问题。这个过程往往能让我自己发现问题的根源,因为它排除了所有无关变量的干扰。
3. 分析与假设 (Analyze & Hypothesize):
- 利用工具:熟练使用 Chrome DevTools 是基本功。我会检查:
- Console:看有无报错信息。
- Network:检查API请求是否正确,响应数据是否符合预期。
- Elements:检查DOM结构和CSS样式。
- Performance/Profiler:分析性能瓶颈。
- React/Vue DevTools:检查组件的 props 和 state。
- 提出假设:根据收集到的信息,我会提出几种可能的根本原因。例如:“是不是因为某个依赖库的版本冲突?”或者“是不是因为某个异步操作的竞态条件?”
4. 验证与解决 (Verify & Solve):
- 针对每一种假设,设计一个小实验去验证它。
- 如果自己无法解决,我会寻求帮助,但在求助前,我会准备好以下信息:
- 我的最小可复现示例。
- 我已经尝试过的所有方法和对应的结果。
- 我对问题原因的猜测。 这样可以最大化地节省同事的时间,也显得我更专业。
- 问题解决后,我会进行复盘(Retrospective):这个问题为什么会发生?是流程问题还是技术问题?我们如何避免未来再发生类似的问题?是否需要补充单元测试?然后将解决方案和思考记录下来。
十三、讲一下工作中遇到的问题?
好的,我分享一个我之前在项目中遇到的关于前端性能优化的实际案例。
-
背景 (Situation): 我之前参与一个数据分析类的后台管理系统。其中一个核心页面需要展示一个巨大的数据表格,这个表格有上百列,数据量可能有几千甚至上万行。最初的版本是直接将从后端获取的数据一次性渲染成DOM。
-
任务 (Task): 当数据量超过1000行时,页面首次加载会冻结长达5-8秒,滚动页面时更是卡顿到几乎无法使用,用户体验极差。我的任务是解决这个性能瓶颈,让页面加载和滚动都变得流畅。
-
行动 (Action): 我的解决过程分为三步:诊断、方案选型和实施。
-
诊断 (Diagnosis): 我使用 Chrome 的 Performance 工具录制了页面加载和滚动的过程。分析火焰图后,我定位到两个主要问题:
- 长任务 (Long Task): JavaScript 在首次渲染时,循环创建上万个DOM节点,占用了主线程太长时间,导致页面无响应。
- 内存占用过高: 大量的DOM节点驻留在内存中,导致浏览器内存压力巨大。
-
方案选型 (Solution Selection): 针对这个问题,我调研了几个方案:
- 分页 (Pagination): 最简单的方案,但产品要求在一个视图内无限滚动,所以不适用。
- 无限滚动 (Infinite Scroll): 可以解决首次加载问题,但随着用户不断向下滚动,DOM节点会无限增多,内存问题依然存在,滚动到后期还是会卡顿。
- 虚拟列表 (Virtual List / Windowing): 这是最理想的方案。它的核心思想是“只渲染可视区域内的列表项”。无论列表总数据有多少,我们只在DOM中创建屏幕能容纳的(比如20个)列表项。当用户滚动时,我们不去创建新的DOM,而是复用已有的DOM节点,只更新它们显示的数据。
-
实施 (Implementation): 我选择了虚拟列表方案。考虑到表格的复杂性(列宽可拖拽、固定列等),我没有从零开始造轮子,而是调研了社区成熟的库,如
react-window和react-virtualized。最终我选择了react-window,因为它更轻量,API也更简洁。- 我将原有的表格组件进行了重构,用
react-window的FixedSizeList组件替换了原生的map循环。 - 这需要精确计算每一行的高度和每一列的宽度,并将其作为参数传给虚拟列表组件。
- 对于横向和纵向滚动,我使用了
FixedSizeGrid来同时实现虚拟化。
- 我将原有的表格组件进行了重构,用
-
-
结果 (Result):
- 性能提升巨大: 页面首次加载时间从原来的5-8秒缩短到1秒以内,因为现在只需要渲染几十个DOM节点。
- 滚动体验丝滑: 无论列表有多少数据,滚动都非常流畅,因为DOM节点数量始终保持在一个很小的常数级别。
- 内存占用降低: 内存占用减少了约90%。
- 这个解决方案被封装成了一个通用的“高性能表格”组件,在公司其他需要展示大量数据的项目中也得到了复用,效果非常好。
通过这个案例,我不仅解决了棘手的性能问题,还为团队沉淀了可复用的技术资产。
十四、为什么看机会?
我在目前的公司工作了X年,随着项目进入稳定维护阶段,我感觉技术上的挑战和成长空间逐渐变得有限。我希望能在一个业务场景更复杂的平台迎接新的挑战,并希望能在职业发展上有更大的突破。我非常认可【】在前端领域的技术追求和行业影响力(Taro、NutUI)。我非常期待能有机会加入贵团队,积极学习和融入团队的技术体系,与同事们一起攻克技术难关,共同成长。
十五、职业发展和未来的规划
首先,是关于过去6年的回顾和沉淀。
“在过去的6年里,我主要在小规模的团队工作。这样的环境让我有机会接触到项目从0到1的全过程,锻炼了我独立解决问题和快速学习的能力,也让我在前端领域打下了比较全面的基础。
不过,我也深刻地认识到,要实现职业上更大的突破,我需要一个更广阔的平台和更复杂的业务场景来挑战自己。之前项目的复杂度有限,让我在技术深度和广度上都遇到了瓶颈。这正是我寻求新机会的主要原因,我非常渴望能加入一个像贵公司这样拥有成熟技术体系和复杂业务场景的团队。”
其次,是我的短期规划(未来1-3年)。
“如果我有幸加入团队,我计划:
- 快速融入和学习: 在入职后的前3到6个月,我的首要目标是尽快熟悉团队的开发流程、技术栈和业务逻辑,保证能高质量地完成分配给我的任务。
- 提升技术深度: 我会聚焦于咱们团队使用的核心技术(这里可以提一下你知道的公司技术栈,比如 React/Vue/Node.js),并希望在 前端性能优化、大型项目架构 或 组件库建设 这些我之前接触较少但非常感兴趣的领域进行系统性的学习和实践,实现从‘知道怎么用’到‘知道为什么这么用’的转变。
- 理解业务,创造价值: 我希望自己不只是一个代码执行者,而是能深入理解业务需求,主动思考如何通过技术更好地服务于产品和用户,真正为公司创造价值。”
最后,是我的长期规划(未来3-5年)。
“从更长远来看,我希望:
- 成为技术专家: 我期望在3到5年内,能够在团队的某个垂直领域(比如前端工程化、可视化或者某个核心业务模块)成为一名可以信赖的技术专家,当同事们遇到相关难题时,可以来找我一起探讨解决。
- 发挥更大作用: 当我的能力得到团队认可后,我希望能承担更多的责任,比如参与一些技术方案的设计和评审,或者把我的经验分享给新同事,和团队一起成长。
- 与公司共同发展: 我非常看好贵公司(可以提一下你看好的业务方向或行业前景)的发展,我希望我的个人成长能与公司的发展步伐保持一致,在这里长期稳定地贡献自己的力量。”
总结:
“总的来说,我正处在一个从‘广度’向‘深度’发展的关键阶段。我过去的经历为我打好了基础,而贵公司的平台和岗位正是我实现这一转变所需要的。我非常有信心,也有强烈的意愿在这里实现我的职业目标。”
十六、介绍一下Astro
Astro 是一个现代化的 Web 框架,它的核心目标是构建性能卓越、内容驱动的网站。
它最与众不同的设计哲学是**“默认零 JS”**。它通过生成一个纯静态的、几乎不含任何客户端脚本的 HTML 页面,来从根本上解决传统前端框架的“JavaScript 臃肿”问题,实现极致的加载速度和顶级的 SEO 表现。
为了在静态页面上实现交互,Astro 采用了两大核心技术:
- 群岛架构 (Islands Architecture):它把网页看作一片静态 HTML 的“海洋”,而任何需要交互的功能(比如一个按钮或轮播图)都是一个独立的“岛屿”。
- 部分水合 (Partial Hydration):这意味着只有这些“岛屿”组件会按需加载自己的 JavaScript,而不是一次性加载整个页面的脚本。我们可以用
client:visible这样的指令,来精确控制组件只有在进入用户视野时才加载脚本并激活。
因此,Astro 特别适合构建博客、作品集、文档和营销网站。
最后,它还有一个非常强大的特点:它是UI 框架无关的,允许我们在同一个项目里混用 React、Vue 或 Svelte 等任何我们喜欢的框架组件。
总而言之,Astro 的目标就是:用最少的 JavaScript,为用户提供最快的体验。
十七、介绍一下 Svelte
Svelte 是一个激进的、颠覆性的前端框架。它最核心的特点是:它是一个编译器,而不是一个传统的运行时框架。
和 React 或 Vue 在浏览器中通过虚拟 DOM (Virtual DOM) 来比对差异、更新视图不同,Svelte 在构建阶段就把你的组件代码编译成了极致优化的、直接操作 DOM 的原生 JavaScript 代码。
您可以把它想象成一个翻译官:我们用 Svelte 简洁的语法写组件,它在项目打包时,就直接把这些组件“翻译”成了最高效的、浏览器可以直接执行的 JS 指令。
这种“消失的框架”模式带来了两个巨大的好处:
- 极致的性能:因为最终产出的代码里不包含任何框架的“运行时”部分,所以打包体积非常小,运行速度也极快。
- 简洁的开发体验:Svelte 的语法非常贴近原生 HTML、CSS 和 JS,没有太多模板化的代码。比如,要实现响应式,我们只需要声明一个变量,然后通过赋值操作就可以自动触发界面更新,非常直观。
总结来说,Svelte 的哲学是:把框架的工作尽可能从运行时(浏览器端)提前到编译时(构建阶段),从而为用户提供一个更轻、更快的最终产品。
十八、一位五年经验的前端工程师应具备的能力
我认为,一个有五年经验的资深前端工程师,其能力不应仅仅停留在“会用”某个框架或技术的层面,而应构建一个系统化、多维度的能力体系。我将其概括为“一体两翼”:
“一体”:深度技术核心
这是工程师的立身之本,是硬实力的核心。
-
语言基础的深度:
- JavaScript/TypeScript: 不仅是熟练,而是精通。能深入解释事件循环、作用域链、闭包、原型链、异步处理(Promise/async/await)等核心概念的底层机制。对 TypeScript 的高级类型、泛型、类型推断有深刻理解,并能用它来构建健壮、可维护的类型系统。
- CSS: 具备解决复杂布局和浏览器兼容性问题的能力。能熟练运用 Flexbox、Grid,理解 BFC、层叠上下文等概念,并能手写复杂的响应式布局和动画。
- HTML: 不仅是标签,更要关注语义化、性能和可访问性(Accessibility, A11y),确保应用对所有用户都友好。
-
框架与原理的穿透力:
- 至少精通一种主流框架(如 React, Vue),并能深入理解其核心设计哲学和实现原理。例如,理解 React 的 Virtual DOM 与 Diff 算法、Hooks 的实现原理;或理解 Vue 的响应式系统、模板编译过程。
- 具备基于框架原理进行高阶封装、性能优化和疑难问题排查的能力。
-
架构设计与工程化:
- 架构能力: 能够独立负责中大型项目的前端架构设计,包括技术选型、目录结构、组件化/模块化策略、代码规范等,并能预见架构的长期演进。
- 性能优化: 具备系统性的性能优化知识体系和实战经验,能熟练使用 Chrome DevTools 等工具进行性能瓶颈分析,并从网络、渲染、计算等多个维度提出并实施优化方案。
- 工程化工具链: 精通 Webpack 或 Vite 等构建工具,有能力进行深度配置、性能调优,甚至编写自定义插件。熟悉 CI/CD 流程,能推动前端自动化部署。
- 测试: 深刻理解测试的价值,能熟练运用单元测试、集成测试、E2E 测试等工具和方法来保证代码质量和项目稳定性。
“两翼”:软技能与影响力
这是资深工程师超越普通开发者的关键,是价值放大的翅膀。
-
左翼:项目与团队领导力
- 项目管理: 能够承担项目的前端负责人角色,主导需求分析、技术方案设计、任务拆解、风险评估,并带领团队高效完成。
- 沟通协作: 能够清晰、准确地表达技术方案,并与产品、设计、后端、测试等角色高效协作,成为团队沟通的桥梁。
- 指导与传承: 乐于分享,能够指导初中级工程师,通过 Code Review、技术分享、文档沉淀等方式,帮助团队共同成长。
-
右翼:业务与产品思维
- 业务理解力: 不只关心技术实现,更关心技术服务于什么业务。能够深入理解业务逻辑和目标,并从技术角度为业务赋能。
- 产品思维: 能从用户的角度思考问题,关注用户体验,并向产品经理提出建设性的反馈和建议,用技术创造更好的产品价值。