字节全栈(偏前端)一面:双引擎渲染与双 Token 深挖
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 请做一下自我介绍。
- 项目来源是什么?
- 挑项目或实习中的难点讲一下。
- Canvas 与 X6 具体怎么渲染的?怎么分层设计的?
- 为什么要做视口裁切?树节点数量有多少?节点数量不是很多的话,有这个必要吗?
- 1000 节点压力测试具体怎么做的?怎么判断视口裁切优化生效?怎么预加载的?拖拽时视口界面表现如何?
- 为什么用 Web Worker?
- 具体讲讲业务上要计算什么?最小割集是去计算什么?
- 连续发送多个请求,如何保证拿到的是最新结果?
await new Promise((resolve) => setTimeout(resolve, 0))是怎么让出事件循环的?- 这个项目是你个人开发的吗?为什么会想到做这样一个项目?有真实用户使用吗?
- Monorepo 的范围是什么?
- 为什么选双 Token?
- 怎么获得这两个 token 的?存在哪里?
- 刷新后会返回新的 refreshToken 吗?
- 怎么做到无感刷新的?
- 为什么选双 Token,单个 Token 不行吗?爬虫爬到了你的 refreshToken 怎么办?
- 具体讲讲 LangGraph 的 checkpoint 机制:代码里怎么注册 checkpoint?在什么时候把消息存入上下文?它不是有多个节点吗?
- 为什么会想到做一个埋点?
- 每块都是怎么统计的(PV、UV、performance 指标)?
- 讲讲每个性能指标的含义?
- 怎么上报 FCP 的?比如用 Vue 写,在哪个地方记录 FCP?
- 项目有用 AI 吗?纯跟着课程手搓?
- 手写:并发任务调度器。
- 手写:依赖任务并行执行批次。
- 大二学校放实习吗?学校课程怎么办?什么时候能入职?
《参考解析》
视口裁切与千节点压测:优化要有可判定的指标
视口裁切(viewport culling)解决的是「画布上有几千个节点,但用户一次只看得到几十个」的浪费:只在可视区域内渲染节点,视野外的元素从渲染层移除或降级成占位,配合空间索引(四叉树、网格分桶、或按层级分块)在拖拽/缩放时快速算出可见集合,再加上视口边缘的预加载缓冲区,避免快速拖动时出现空白。
真正难的不是做裁切,而是证明它生效、且没有副作用。可判定的指标有三类:渲染侧(每帧节点实例数、draw 调用次数、帧耗时 p95、掉帧数)、交互侧(拖拽/缩放时的 FPS 与输入延迟、首帧响应时间)、资源侧(内存占用、GC 频率)。压测的做法是用脚本批量构造 1000 节点并施加真实操作序列(连续拖拽、缩放、展开收起、随机选中),在开关裁切两种模式下各跑一遍对比同一组指标——差距明显且交互不掉帧,才算优化成立。要注意几个常见坑:裁切边界算错导致节点闪烁或该显示的不显示;频繁增删 DOM/Graphics 对象触发 GC 抖动,所以更稳的做法是对象池复用 + 只改可见性;命中测试(点击拾取)也必须走同一套空间索引,否则会出现「看不见却能点到」的诡异 bug。回答「节点数量不算多,有必要吗」时,落点要放在趋势上:现在的数据量靠暴力渲染能撑住,但节点数、连线数和交互复杂度都会涨,提前把渲染与数据解耦,后面加功能才不会推倒重来。
双 Token 与无感刷新:安全边界要说准
双 Token 的分工是:accessToken 短期有效(分钟级)且随请求发送,泄露的窗口小、无需服务端存储(JWT 自校验);refreshToken 长期有效、只在刷新接口使用,服务端可存储、可撤销、可绑定设备。这样既避免了「单个长期 token 一旦泄露就等于长期失守」,也避免了「每次都查库校验」的开销。
无感刷新的实现要点:把请求统一收口到拦截器;遇到 401(或本地判断 accessToken 快过期)时触发刷新,且要用一个「刷新中的 Promise」把并发请求挂住,避免同时弹出 N 个刷新请求互相覆盖;刷新成功后重放原请求,刷新失败(refreshToken 也过期或被撤销)就清理登录态并跳登录。刷新接口一般会轮换 refreshToken(rotation),服务端的旧 token 要立即失效,并且要做重放检测:同一个 refreshToken 被用第二次就判定为泄露,直接吊销整条会话链。
安全边界是这道题的分水岭:refreshToken 放在 HttpOnly + Secure + SameSite 的 Cookie 里,目的正是让页面 JS 读不到它,从而把 XSS 的影响限制在「当前页面能做的事」,而不是「攻击者拿走长期凭证」。至于「爬虫爬到了 refreshToken」——脚本本来就读不到 HttpOnly Cookie 与另一个源的 localStorage,真正的威胁模型是 XSS 窃取、CSRF(靠 SameSite + CSRF Token 防)、以及设备被完全控制。这里如果答成「防爬虫」,面试官一定会顺着追问,所以最好主动把威胁模型说清楚:我们防的是 XSS 与凭证长期有效,不是防爬虫。
请求竞态与让出事件循环
「连续发多个请求如何保证拿到最新结果」的通用解法是给每次请求打一个自增的 TaskId 或序列号,回来后比对「这个结果是否仍属于当前最新一次请求」,不是就丢弃;同时把上一次请求真正取消掉(AbortController + fetch 的 signal,或对分片请求逐个 abort),避免它继续占用带宽并在后续乱序到达时污染状态。前端还常配合防抖/节流减少无谓请求,以及在 UI 上做 loading 与「结果对应哪次输入」的绑定。
await new Promise((resolve) => setTimeout(resolve, 0)) 的作用是把当前这段同步执行让出去:await 之后的代码被包进微任务,而 setTimeout 的回调要等到下一个宏任务(timer 阶段)才执行,所以它会先让浏览器/Node 完成当前宏任务队列里排在前面的事情,包括渲染与用户输入处理,然后再回来继续。它常用在「把长任务切片」的场景里——每处理一批数据就 yield 一次,让主线程有机会响应交互,避免界面卡死。要能讲清边界:setTimeout(0) 实际有最小延迟(浏览器里嵌套多层后会被钳到 4ms 左右),而且它只是让出,不是并行;真正的重计算该交给 Web Worker,需要更高优先级的让出可以用 MessageChannel 或 scheduler.yield,只关心微任务顺序时可以用 await null。
Web Worker:CPU 密集与 IO 密集的分界
用 Worker 的前提是任务确实是 CPU 密集的、且计算过程不需要碰 DOM:图布局计算、最小割集/连通性这类图算法、大数组的编解码与统计,跑在主线程上就是几十毫秒到几秒的同步阻塞,界面直接卡住;放进 Worker 后主线程只负责收结果并渲染。而 IO 密集的事——发请求、读文件、等数据库——本来就是异步非阻塞的,放到 Worker 里只会多一层消息传递的开销,没有收益。
工程上要处理好几件事:数据要序列化传递(结构化克隆),大对象可以用 Transferable(ArrayBuffer)零拷贝转移所有权;Worker 的创建有成本,通常做成常驻池按需复用;算法要能被取消/替换(用户改了参数就丢弃旧任务的结果,语义上和请求竞态是同一个问题);异常与超时要能从 Worker 冒泡回主线程。答题时把「为什么用 Worker」和「什么不用 Worker」两面都说清,比只背一句「不阻塞主线程」扎实。
前端埋点与 FCP:指标含义与上报位置
性能埋点基本上是围绕 Core Web Vitals 展开:FCP(First Contentful Paint,首次渲染出任何文本/图像内容的时间)反映「白屏多久」;LCP(Largest Contentful Paint,视口内最大内容元素渲染完成的时间)反映「主要内容什么时候可见」;CLS(Cumulative Layout Shift,累计布局偏移)反映视觉稳定性;INP/TBT(交互到下次绘制的延迟 / 主线程总阻塞时间)反映交互流畅度。再配合导航计时(TTFB、DOMContentLoaded、load)与资源加载明细,才能定位瓶颈在 DNS/TLS、服务端、还是前端渲染。
上报位置是这题最容易被抓的点:FCP 由浏览器在首次内容绘制完成时触发 paint 类型的 PerformanceObserver 回调给出(entry.name === 'first-contentful-paint'),所以要在应用最早执行的入口处注册 observer——比如 main.js 里创建应用之前、路由挂载之前,而不是在某个组件的 mounted 里,否则会错过真正的首次绘制。LCP 与 CLS 是随页面生命周期不断更新的,要在 visibilitychange 变成 hidden 时把最新值上报(或用 PerformanceObserver 的 buffered 选项补收早于注册时机的条目)。SPA 的场景还要区分「首次加载」与「路由切换」,路由切换更适合用打点测量而不是复用 paint 指标,否则会把上一页的数据混进来。