百度文库秋招前端一面:SSR与工程化实践
- 轮次
- 一面
- 结果
- 已过
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 为什么考虑来百度?为什么不想留在原公司?对比百度和原公司的工作节奏。
- 番茄小说阅读器排版引擎是怎么实现的?
- SSR 技术栈与 SSR 原理是什么?SSR 对比 CSR 的弊端有哪些?
- 工程化建设相关:介绍自动化测试项目、流水线接入环节,测试是否为强卡点?
- 个人项目的视频来源是什么?断点续传是怎么实现的?
- 你做过哪些性能优化?
- webpack 中 plugin 和 loader 的区别是什么?
- 讲一下对 JS 事件循环的理解,浏览器常见微任务、宏任务有哪些?浏览器与 Node 事件循环有什么区别?
- 手写代码:防抖、节流,以及手写简易 Promise。
《参考解析》
SSR 原理与对比 CSR 的弊端:SSR 是在服务端把组件渲染成 HTML 字符串返回,浏览器首屏能立刻拿到有内容的 HTML,随后再下载 JS 做 hydration(注水)把事件绑定与状态接上。典型技术栈是 React 的 renderToString/renderToPipeableStream(React 18 的 Streaming SSR + Server Components)、Next.js、Vue 的 renderToString/Nuxt。它解决的问题是首屏白屏、SEO 抓不到内容、弱网与低端机体验。弊端要说全:一是服务器压力与成本上升,每次请求都要渲染,QPS 高时 CPU 是瓶颈,需要缓存(CDN/页面级/组件级缓存)与降级到 CSR 的兜底;二是 hydration 成本,HTML 已经能看但不可交互,用户点了没反应(TTFB 好但 TTI 差),还有 hydration mismatch 的坑(服务端与客户端渲染结果不一致,常见于 Date.now()、随机数、window 判断、登录态);三是架构复杂度,需要 Node 服务、处理 cookie/token 透传、接口在服务端与客户端的双份调用与去重(Next.js 的 getServerSideProps / RSC 缓存)、部署与灰度更麻烦;四是并非所有页面都值得 SSR,后台管理类页面做 SSR 收益极低。改善手段包括 Streaming SSR、选择性注水(Selective Hydration)、岛屿架构(Astro)、静态化 + ISR 把 SSR 成本摊到构建期。
断点续传的实现:核心是「分片 + 记录进度 + 校验完整性」。客户端把文件切片(如 5MB 一片)并计算每片哈希,上传前先向服务端询问「这个文件 ID 已经有哪几片」(HEAD/查询接口返回已上传分片列表,或用秒传接口比对整体哈希),然后只传缺失分片,每片成功后服务端记录状态(Redis/数据库中 uploadId -> [分片位图]),全部完成后调合并接口按序拼接并校验整体哈希,服务端返回最终地址。断点续传的关键在于「断点」存在服务端而不是浏览器本地(本地存 localStorage 只能应对刷新,换设备就失效),以及分片要能并发上传 + 失败重试(限制并发数,例如 3~4 路,避免打满带宽)。用 HTTP 原生能力也能做:服务端支持 Accept-Ranges: bytes,客户端发 Range: bytes=start- 并在 206 响应中续写,适合单文件下载/上传的简单场景。要提的坑:分片合并的原子性(合并完成前不能让文件可读)、上传会话过期与清理、秒传必须校验哈希防伪造、大文件在浏览器端计算哈希要用 Web Worker 或分片增量哈希(crypto.subtle 或 spark-md5)以免卡住主线程。
性能优化怎么做:按加载、渲染、运行时三层组织,配指标说话。加载层:资源压缩(gzip/brotli)、图片格式与尺寸优化(WebP/AVIF、srcset、懒加载)、代码分割与按需加载(路由级 import())、首屏关键 CSS 内联、字体 font-display: swap 与子集化、CDN 与 HTTP 缓存策略(带 hash 强缓存)、HTTP/2 多路复用、预加载(preload/prefetch/preconnect)、SSR/SSG 提升首屏。渲染层:减少重排重绘(批量读写 DOM、用 transform/opacity 做动画、will-change 谨慎使用)、长列表虚拟滚动、避免过深的组件树与同步布局抖动、骨架屏与占位。运行时层:减少主线程长任务(requestIdleCallback、Web Worker、时间切片)、事件节流与 passive 监听、内存泄漏治理(监听器/定时器/闭包引用及时释放)、缓存计算结果与请求去重。度量上要能报出具体指标:LCP、INP(替代 FID)、CLS、TTFB、FCP,以及怎么测(Lighthouse、Performance 面板、RUM 真实用户监控),优化前后对比数据。面试时挑一个自己真做过的例子讲透,比罗列十条更有说服力。
webpack 的 loader 与 plugin 区别:职责与运行位置不同。loader 是「模块转换器」,作用于单个文件,在模块被引入时把源文件内容转成 webpack 能处理的模块,本质是一个 source => source 的函数(如 babel-loader、ts-loader、css-loader、sass-loader),配置在 module.rules 里,可以链式组合(从右到左、从下到上执行,前一个的输出是后一个的输入)。plugin 是「构建流程的扩展」,作用于整个编译生命周期,通过向 compiler 注册钩子(webpack 的 Tapable:emit、afterEmit、done、optimization 等)在特定阶段做副作用操作,能力更强(HtmlWebpackPlugin 生成 HTML、MiniCssExtractPlugin 抽离 CSS、DefinePlugin 注入常量、CopyWebpackPlugin 拷文件)。一句话记法:loader 管「文件怎么变成模块」,plugin 管「构建过程里还能做什么」。加分点:loader 可以异步(this.async())、可以带 options 与 cacheable;plugin 要理解 Compiler(全局唯一)与 Compilation(每次构建一份)的区别;以及现代替代方案 Vite 用 ESM + esbuild/Rollup 在开发态免打包、生产态用 Rollup,plugin 概念仍在但钩子体系不同。
事件循环(浏览器与 Node 的区别):浏览器一次循环是「执行一个宏任务 → 清空微任务队列 → 渲染(rAF → 样式布局绘制)→ 下一个宏任务」;微任务含 Promise.then、queueMicrotask、MutationObserver,宏任务含 setTimeout/setInterval、I/O、UI 事件、MessageChannel。Node 的循环分六个阶段:timers(setTimeout/setInterval)→ pending callbacks → idle/prepare → poll(等待 I/O,可能在此阻塞)→ check(setImmediate)→ close callbacks,每个阶段之间会清空 process.nextTick 队列与 Promise 微任务队列。关键差异有四点:一是 process.nextTick 优先级高于 Promise,且在每个阶段切换时都会清空,写递归容易饿死 I/O;二是 setImmediate vs setTimeout(0) 的顺序在主模块中不确定(受启动耗时影响),但在 I/O 回调里 setImmediate 总在前面;三是 Node 11 之后微任务也会在 setTimeout 回调之间被执行(以前是一个宏任务队列跑完才清微任务);四是 Node 没有渲染阶段(没有 rAF),但多了 setImmediate 与阶段划分。回答时用一张顺序表说明比背概念更清楚。
手写防抖、节流:防抖(debounce)是「停止触发 n 毫秒后才执行」,适合搜索输入、窗口 resize;节流(throttle)是「每 n 毫秒最多执行一次」,适合滚动、拖拽、鼠标移动。带取消、首次立即执行与 this/参数透传的完整版本:
function debounce(fn, wait = 300, immediate = false) {
let timer = null;
function debounced(...args) {
if (timer) clearTimeout(timer);
if (immediate && !timer) fn.apply(this, args);
timer = setTimeout(() => {
timer = null;
if (!immediate) fn.apply(this, args);
}, wait);
}
debounced.cancel = () => { clearTimeout(timer); timer = null; };
return debounced;
}
function throttle(fn, wait = 300) {
let last = 0, timer = null;
return function (...args) {
const now = Date.now();
const remaining = wait - (now - last);
if (remaining <= 0) {
last = now;
fn.apply(this, args);
} else if (!timer) {
// 保证最后一次触发也会执行(尾部调用)
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}
面试官常追问的点:防抖的 immediate 语义、节流要不要保留最后一次(trailing)、cancel/flush 方法、多个实例之间不共享定时器(所以定时器要放在闭包里)、以及组件卸载时必须 cancel 防内存泄漏。更现代的做法是 AbortController 配合取消请求、或用 requestAnimationFrame 做「每帧最多一次」的节流。
手写简易 Promise:核心是状态机 + 回调队列 + 异步落定。要点:state(pending/fulfilled/rejected)一旦落定不可变;then 返回新的 Promise 以支持链式;回调用 setTimeout(微任务模拟)异步执行;resolve 的如果是 thenable 要递归展开;then 回调返回值要按规则解析(普通值 → 下一个 resolve,抛错 → 下一个 reject)。骨架:
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.handlers = [];
const settle = (state, value) => {
if (this.state !== 'pending') return;
// 简化处理:value 为 thenable 时可递归展开
this.state = state;
this.value = value;
this.handlers.forEach((h) => this._run(h));
this.handlers = [];
};
const resolve = (v) => settle('fulfilled', v);
const reject = (e) => settle('rejected', e);
try { executor(resolve, reject); } catch (e) { reject(e); }
}
_run(handler) {
const cb = this.state === 'fulfilled' ? handler.onFulfilled : handler.onRejected;
setTimeout(() => {
if (typeof cb !== 'function') {
// 没有对应回调时把状态透传下去
(this.state === 'fulfilled' ? handler.resolve : handler.reject)(this.value);
return;
}
try { handler.resolve(cb(this.value)); }
catch (e) { handler.reject(e); }
}, 0);
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const handler = { onFulfilled, onRejected, resolve, reject };
if (this.state === 'pending') this.handlers.push(handler);
else this._run(handler);
});
}
catch(onRejected) { return this.then(null, onRejected); }
static resolve(v) { return v instanceof MyPromise ? v : new MyPromise((r) => r(v)); }
static reject(e) { return new MyPromise((_, r) => r(e)); }
static all(list) {
return new MyPromise((resolve, reject) => {
const res = []; let count = 0;
if (!list.length) return resolve(res);
list.forEach((p, i) => MyPromise.resolve(p).then((v) => {
res[i] = v;
if (++count === list.length) resolve(res);
}, reject));
});
}
}
写的时候要主动说清简化点:真实规范里 then 的回调是微任务(用 queueMicrotask 更贴近)、需要处理 then 返回自身导致的循环引用 TypeError、resolve 传入 thenable 要按规范递归并只取第一次调用、Promise.all 要按索引保序而不是 push。把这些讲出来比写完更重要。
求职动机与工作节奏对比怎么答:这类问题没有标准答案,但有安全边界——不要贬低前东家,也不要只说「百度是大厂」。结构可以是:一是我想要的业务/技术方向(文库这类有海量内容与前端复杂度高的场景,能接触到排版、渲染、性能这些我想深做的方向);二是成长环境(希望有更成熟的工程规范与技术评审、有更强的同事可以对照学习);三是节奏对比用事实描述而不是评价(原公司偏业务快速交付、迭代短平快;了解到百度这边更强调质量与流程,比如有代码评审、自动化测试与灰度机制),并说明自己能适应(给出例子证明你在高压交付和质量之间都待过)。