业务背景:JS 是单线程弱类型脚本语言,运行时异常会让整条 JS 线程挂掉,页面失去响应。业务里 try-catch 写了不一定上报,漏写就丢日志。
核心思路:基于 @babel/traverse 注册 CatchClause 访问者,遍历 AST 时在每个 catch 块的语句列表头部 unshift 一段 captureException 调用,实现「代码里有 try-catch 就一定上报」。
多端适配:用 @babel/template 生成 Web / 微信小程序 / 支付宝小程序三套不同的上报模板,因为三端的 SDK 全局对象不同(window.__UAPM__ vs getApp().__UAPM__ vs my.__UAPM__)。
安全兜底:注入的代码本身用 try-catch 双层包裹,SDK 失败不会反噬业务。
最终产物:发布为 @umengfe/babel-plugin-uapm-trycatch,业务侧只需在 .babelrc 加一行配置即可接入。
// 简化后的访问者
{
CatchClause(path) {
if (isAlreadyInstrumented(path)) return;
const reportStmt = template.statement(`
try { __UAPM__.captureException(ERR, { from: 'auto-trycatch' }) }
catch (_) {}
`)({ ERR: path.node.param });
path.get('body').unshiftContainer('body', reportStmt);
}
}
两者是互补关系,不是替代。运行时全局监听是兜底,但业务一旦写了 try { ... } catch (e) { /* 静默 */ },异常就被吞掉了——全局监听根本拿不到。
编译期插桩解决的是「业务主动 catch 但忘了上报」这一类盲区。
另外,跨域脚本的 window.onerror 拿到的 e.error === null、message 也只有 Script error.,信息严重缺失;编译期插桩在源码里直接拿到 error 对象,信息完整。
最终方案:运行时监听 + 编译期插桩 + 主动 SDK API 调用,三层防御。
Babel 把源码 parse 成 AST 后,traverse 用深度优先遍历每个节点。访问者对象按节点类型(CatchClause、FunctionDeclaration 等)定义 enter/exit 钩子。
默认写 CatchClause(path) {} 等价于 enter 阶段。exit 在子节点全部访问完后触发——做依赖子节点信息的判断时要用 exit。
关键 API:path.node(当前节点)、path.parentPath、path.scope(作用域)、path.get('xxx')(子路径)、path.replaceWith / unshiftContainer / remove。
坑点:① 修改 AST 后 path.skip() 否则会无限递归你新插入的节点;② 不要在 enter 里改父节点结构;③ 对生成代码再次 traverse 要用 babel.traverse.cheap 或重新 parse。
文件级:在文件 Program.enter 时扫源码字符串,匹配 UAPM_INSTRUMENTED / UMAPM_INSTRUMENTED 这种特征常量;命中说明这个文件已经被插过桩(可能是上游构建已处理过的产物),整个文件 skip。
节点级:对单个 CatchClause,用 @babel/generator 把第一条语句 stringify,正则匹配 __UAPM__.captureException( 的特征调用——命中就 skip 这个 catch 块。
为什么两层都要:① 只做文件级会漏掉「文件被部分修改但首条特征已删除」的情况;② 只做节点级会重复 traverse 很多已经处理过的节点(性能差),而且 generator stringify 本身有开销,不适合每个节点都做。
实战取舍:先用文件级 short-circuit 大部分情况,节点级只做最后一道防线。
把 AST Transform 抽成纯函数 transform(code, options) -> { code, map },不依赖任何 Babel/Vite 的特定上下文。
Babel Plugin 适配层:导出 ({ types: t }) => ({ visitor: { ... } }),内部调用核心 transform。
Vite Plugin 适配层:导出 (opts) => ({ name, transform(code, id) { ... } }),在 transform 钩子里调用核心。
文件过滤:Vite 用 id 字符串,Babel 用 state.file.opts.filename;统一通过 picomatch 编译过滤函数,两边复用。
收益:核心逻辑 1 份,生态适配各 50 行,新增一个构建工具(Rspack / Turbopack)只要写个壳。
// 核心
export function transformAst(code: string, opts: Options): TransformResult {
const ast = parse(code, parseOpts);
traverse(ast, makeVisitor(opts));
return generate(ast, { sourceMaps: true }, code);
}
// vite-plugin
export default (opts: Options) => ({
name: 'uapm-trycatch',
transform(code: string, id: string) {
if (!matcher(id)) return null;
return transformAst(code, opts);
},
});
Babel Plugin:依赖业务自己配的 @babel/preset-typescript / @babel/preset-react 在我之前先把 TS/JSX 转成标准 JS,我的 visitor 只关心 CatchClause 这种 ES 标准节点,所以透明工作。
Vite Plugin:Vite 默认用 esbuild 处理 TS,我需要在 enforce: 'post' 阶段跑,等 esbuild 转完再插桩。Vue SFC 要先经过 @vitejs/plugin-vue 拆出 <script> 块,我的插件再处理。
坑点:Vue 2 SFC 的 <script> 经 Vue Loader 转出来的代码可能带特殊注释 ?vue&type=script,文件名匹配要兼容这种 query。
性能:picomatch 把 glob 编译成正则后缓存,命中是纯正则匹配,benchmark 比 minimatch 快 10x+。
包体积:picomatch 零依赖、~10KB;minimatch 依赖 brace-expansion;micromatch 内部还是用 picomatch。
API 简洁:picomatch(pattern) 返回一个 (filepath) => boolean 的纯函数,适合在 transform 高频路径里调用。
生态背书:Rollup / Vite / chokidar / fast-glob 内部都在用,稳定性有保证。
排查链路:① 拿出错文件路径;② 用插件提供的 --debug 输出 transform 前后的 diff;③ 单独跑 transformAst(code, opts) 复现;④ 关键嫌疑节点用 path.toString() 看 generator 出来的代码。
Source Map:@babel/generator 默认会维护 mappings,但 unshiftContainer 插入的新节点没有 loc,需要手动 path.node.loc = path.parent.loc 或用 cloneNode 继承位置;否则线上报错堆栈会指错行。
Fallback:每个文件 transform 用 try-catch 包,失败时打 warn 日志并返回原始 code,绝不阻塞构建——监控插件不能反过来弄挂业务的 CI。
测试:对每种语法形态(箭头函数 catch、嵌套 try、async/await、generator)写 fixture 快照测试,改 visitor 必须跑完才能合。
核心:从业务根节点(#app / #root)做 DFS 遍历,对每个元素用 getBoundingClientRect 判定它是否落在视口内,有内容的元素累加权重,最后得到 0~1 的「内容分」。
为什么 DFS 而不是只看 body:body 下可能有大量定位到屏外、或 display:none 的元素,简单看 innerHTML.length 会误判;DFS 能精准量化「视口里到底有多少有效内容」。
深度衰减权重 1/2^n:越深的节点对「看上去有没有内容」贡献越小——首屏一个大 banner 比深层 10 个小图标更重要。这种数学衰减保证了打分稳定性。
剪枝:当前节点 0 权重(尺寸为 0 / 不在视口 / display:none)直接 return,不进入子树。
最终阈值:得分 < 1.5(可配) 判为白屏。
问题:DOM 遍历 + getBoundingClientRect 会触发强制同步布局(forced reflow),阻塞主线程,可能反过来影响首屏渲染——监控不能成为性能问题源。
rIC:requestIdleCallback 在浏览器空闲时回调,带 timeout 兜底防止饥饿;保证检测发生在「不抢渲染时间」的窗口。
rAF:requestAnimationFrame 把读 DOM 操作对齐到帧的开始,这时 layout 已经稳定,读 getBoundingClientRect 不会触发额外 reflow。
双层组合:rIC 进入后再嵌一层 rAF,既空闲又对齐帧——既不抢渲染又不引入重排,主线程开销基本控制在毫秒级。
setTimeout 的问题:① 不知道当前是不是空闲;② 4ms 最小延迟和 reflow 时机无关,可能正好打断关键渲染。
function detect() {
requestIdleCallback(
() => requestAnimationFrame(() => {
const score = scoreNode(rootEl, 0);
if (score < THRESHOLD) report(score);
}),
{ timeout: 2000 }
);
}
Bug 现象:线上客户反馈某 SPA 站「首屏明明有内容,白屏率却 100%」。
定位:抓了误报样本,发现命中时机是 SPA 主 bundle 还在下载——此时 DOM 里 <body> 只有 <noscript> 和一个空 <div id='app'>,我的算法把 noscript 的内容算进了得分。但真正的白屏其实是「JS 还没执行完」,不应该报。
修复方案三连:
① 扩充黑名单标签:noscript / script / style / link 不计入打分;
② 挂载态前置校验:检测前先看 #app / #root 是否有非黑名单子节点,没有 → 大概率框架还没挂载,不报;
③ MutationObserver 3s 观察期:命中候选白屏后,再观察 3s,期间只要有有效 DOM 变更,就取消上报并重新归类为「首屏慢」。
收益:线上 noscript 误报降到 0,白屏指标准确率提升明显。
白屏只是结果指标,客户真正想知道的是「为什么白」。所以命中时同步打包:
① 首屏关键接口状态:之前 XHR/fetch Hook 采集到的 API 列表过滤出 timing < 白屏命中时刻 的请求,看是否有 5xx/超时;
② 静态资源 404:从 PerformanceObserver('resource') 拉所有失败资源(responseStatus >= 400 或 transferSize === 0);
③ JS 错误堆栈:取命中前 5s 内的 JS 错误,匹配是否有 ChunkLoadError / SyntaxError;
④ 截图:得分 < 1.5 触发 html2canvas 截图,LZ-String 压缩后用 sendBeacon 上报。
设计原则:白屏命中是触发器,真正的诊断价值在归因数据;客户后台能直接看到「白屏 → 是因为 xxx 接口 502」这种因果,而不是只有一个白屏率数字。
截图大小:html2canvas 默认输出 PNG base64,首屏截图通常 200KB ~ 2MB,移动端首屏甚至到 5MB。
问题:sendBeacon 单次上限 64KB(规范) / 部分浏览器 256KB,直接传截图必爆。
LZ-String 选择:base64 字符串本质是 ASCII,LZ-String 对重复 pattern 极敏感,实测压缩比 3~5x;移动端截图压完后 ~150~400KB,服务端再做一次分片接收。
优化方向:① 对截图先 canvas.toDataURL('image/jpeg', 0.6) 用 JPEG + 60% 质量;② 限定截图区域到首屏;③ 失败客户不重试,避免雪崩。
本质都是「用户看到一片空」,区分点在最终是否能渲染出来。
做法:检测命中后启动 MutationObserver(根节点) + 一个 3s 计时器:
- 3s 内根节点子树发生有效 DOM 变更 → 标记为「首屏慢」(loading slow),不报白屏;
- 3s 内无任何有效变更 → 标记为「真白屏」(blank),走白屏上报链路。
有效变更的定义:addedNodes 中存在非黑名单标签且尺寸 > 0,过滤掉 noscript/script/comment。
收益:把「白屏」和「慢」拆成两个独立指标,前者是 P0,后者是 P1,优化优先级清晰。
我的算法对纯 Canvas 单元素页面(比如游戏 / 数据大屏)会全部判为白屏——因为整个 DOM 树就一个 <canvas> 节点,DFS 出来内容分极低。
应对:① 提供配置 customRoots: ['#chart'] 让客户标注「这一块是有效内容」;② 对 canvas 元素本身,如果尺寸 > 视口的 30% 就给一个固定权重 1;③ 暴露 disableBlankDetect() API 让 Canvas 重度页面手动关闭。
iframe:跨域 iframe 内部不可见,只能拿 iframe 元素本身的尺寸,统一按一个加权节点处理。
核心思路:保存原型方法引用,重写为「采集 → 调原方法」。
open:拦截 method/url/async,挂到 xhr 实例上做后续上报锚点;send:记录开始时间 startTs、注册 loadend 监听采集 status/duration/responseSize;setRequestHeader:特殊 header(traceparent)需要透传给业务自己设置的 header 列表,避免重复注入。
坑点 1 - this 绑定:必须用 function() {} 不能用箭头,否则 this 不是 xhr 实例。
坑点 2 - 多次 hook:用 xhr.__uapm_hooked = true 标记,防止业务侧也 hook 后再被我们二次 hook 出现死循环。
坑点 3 - 不可枚举:Object.defineProperty 设 enumerable: false,避免 for...in 遍历到。
坑点 4 - response 类型:responseType = 'blob' / 'arraybuffer' 时不要去读 responseText,会抛 InvalidStateError。
const oOpen = XMLHttpRequest.prototype.open;
const oSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__uapm = { method, url, startTs: 0 };
return oOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
this.__uapm.startTs = performance.now();
this.addEventListener('loadend', () => {
report({
...this.__uapm,
duration: performance.now() - this.__uapm.startTs,
status: this.status,
size: this.getResponseHeader('content-length'),
});
});
return oSend.call(this, body);
};
核心:window.fetch = (input, init) => oFetch(input, init).then(采集 res).catch(采集 err),纯函数式包装。
和 XHR 的差异:
① fetch 没有 setRequestHeader,header 是在 init.headers 里,要兼容 Headers / Record<string, string> / Array<[k,v]> 三种形态;
② fetch 的 response body 是 stream,res.clone().text() 才能既不影响业务又能拿响应内容,clone 有内存开销,只在错误码或采样命中时做;
③ AbortController 取消的请求 .catch 会拿到 DOMException: AbortError,要单独标记 aborted: true 不算错误;
④ fetch 在 SSR / Worker / Service Worker 里都有,XHR 只有浏览器主线程——fetch hook 覆盖面更广。
Trace Context 是 W3C 标准化的分布式链路追踪 header 协议,让前后端、不同语言的链路能拼成一条 trace。
traceparent 格式:{version}-{trace-id}-{parent-id}-{trace-flags},例:00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01。
- version:固定 00
- trace-id:128bit hex(32 字符),整条链路唯一
- parent-id:64bit hex(16 字符),当前 span id
- flags:01 采样,00 不采样
tracestate:厂商自定义透传字段,逗号分隔的 KV,如 congo=t61rcWkgMzE,rojo=00f067aa0ba902b7。
SDK 用法:在 XHR/fetch 出口处生成新的 parent-id,设到 traceparent header,后端 OpenTelemetry / Skywalking 接住后链路就能串起来。
TTFB(Time to First Byte):responseStart - requestStart,从 performance.timing 或 PerformanceNavigationTiming 拿。
FP(First Paint):浏览器首次绘制非空白像素;PerformanceObserver({ type: 'paint' }) 取 entry.name === 'first-paint'。
FCP(First Contentful Paint):首次绘制有意义内容(文字/图);同 paint observer,name === 'first-contentful-paint'。
LCP(Largest Contentful Paint):视口内最大内容绘制完成;PerformanceObserver({ type: 'largest-contentful-paint', buffered: true }),LCP 会持续更新到用户首次交互为止,要在 pagehide / visibilitychange 时上报最终值。
FID(First Input Delay):首次交互的输入处理延迟(将被 INP 替代);PerformanceObserver({ type: 'first-input' }),processingStart - startTime。
CLS(Cumulative Layout Shift):累计布局偏移分;type: 'layout-shift',过滤掉 hadRecentInput === true 的 entry,把 5s 窗口内的偏移分加起来。
采集时机:大多数 web vitals 要等用户离开页面才能确定最终值,统一在 visibilitychange === 'hidden' 时通过 sendBeacon 上报。
优势:① 浏览器在 unload / pagehide 中也能保证发出(XHR 可能被取消);② 异步、低优先级,不阻塞页面卸载;③ API 极简 navigator.sendBeacon(url, data)。
限制:
① 单次 body 64KB 上限(规范),Chrome/Edge 实际可能更小;
② 只能 POST,不能改 method;
③ 不能自定义大部分 header(Content-Type 受限),要用 Blob 包一层才能改 type;
④ 没有 response 回调,完全发不出去也无法感知——所以失败要靠多通道兜底;
⑤ 浏览器对 beacon 队列总长度有限制,刷得太频会被丢弃。
多通道降级:JSBridge → sendBeacon → XHR (with keepalive: true for fetch) → <img> GET 兜底。
通道独立采样:pv / error / api / perf / blank 五个通道各有自己的采样率(如 pv 100%、api 10%、blank 50%),互不影响;否则 api 量大的客户会把整体配额吃光,白屏这种低频但关键的指标反而采不到。
云控下发:每个通道的采样率支持服务端动态下发,用 localStorage + 分钟级 TTL 缓存,过期再拉;故障时秒级降级到 1% 保底。
三层 AND:shouldReport = channelSample && cloudConfig && globalSwitch
- channelSample:本通道的本地采样命中(Math.random() < rate)
- cloudConfig:云控该通道未关闭
- globalSwitch:整个 SDK 总开关(应急能整站关掉)
收益:三层任意一层为 false 就不上报,故障时可以从任何一层切断流量,运维粒度最细。
问题:同一个 bug 一秒内可能上报上千次,服务端要先做客户端去重。
stack hash 算法:取 stack 字符串,做以下规整后 hash:
① 去掉行列号(:123:45 → 空),不同次数 / 不同压缩版本会变;
② 去掉绝对路径前缀,只留 chunk-xxx.js / app.js 这种文件名;
③ 取前 3 帧就够了,深栈通常是同一原因;
④ 用 fnv-1a / cyrb53 生成 32bit hash,够用又快。
客户端缓存:Map<hash, count>,1 分钟窗口内同 hash 只发一次,带 count 上报。
服务端:再做一次 fingerprint(基于 message + 顶帧 fn 名 + 文件名),把不同浏览器/版本的同一类错聚合。
function stackHash(stack) {
const norm = stack
.split('\n').slice(0, 3)
.map(l => l.replace(/:\d+:\d+/g, '').replace(/https?:\/\/[^/]+/g, ''))
.join('|');
return cyrb53(norm);
}
根因:浏览器同源策略保护——跨域脚本如果没声明 crossorigin,出错时 window.onerror 回调里 error 字段被置 null,message 只有 Script error.,line/col/source 都打码。
修复三步:
① 静态资源 CDN 响应头加 Access-Control-Allow-Origin: *(或具体域名);
② HTML 里 <script src='...' crossorigin='anonymous'>;
③ Webpack/Vite 配置 output.crossOriginLoading = 'anonymous',让动态加载的 chunk 也带这个属性。
SDK 兜底:即使做了上述配置,某些 polyfill / 三方脚本仍会触发 Script Error;SDK 里要做 e.error === null 的空值防御,否则取 e.error.stack 会二次抛异常,反过来污染监控。
核心思路:利用 antd Form name 支持数组/字符串路径的能力,把每个字段的多语言版本统一为 { field: { zh_CN, zh_TW, en_US } } 结构,Form 自动按路径写到嵌套对象。
写法:<Form.Item name={['title', 'zh_CN']} label='标题(简中)' />,提交时 form.getFieldsValue() 直接拿到 { title: { zh_CN, zh_TW, en_US }, content: {...} }。
Tabs 切换:Tabs 只切显示,Form 实例不卸载——所有语言的字段都在同一个 Form 里,切 tab 不会丢数据。如果用 v-if/conditional render 销毁会丢,所以一定是 display: none 或全部 mount。
新增语言零侵入:配置文件加一行 { key: 'ja_JP', label: '日本語' },Tabs 自动渲染、Form 自动写到 field.ja_JP 路径,前端代码 0 改动。
const LANGS = [
{ key: 'zh_CN', label: '简体中文' },
{ key: 'zh_TW', label: '繁體中文' },
{ key: 'en_US', label: 'English' },
];
<Form form={form}>
<Tabs>
{LANGS.map(({ key, label }) => (
<Tabs.TabPane tab={label} key={key} forceRender>
<Form.Item name={['title', key]} label="标题">
<Input />
</Form.Item>
<Form.Item name={['content', key]} label="正文">
<RichEditor />
</Form.Item>
</Tabs.TabPane>
))}
</Tabs>
</Form>
表结构:
- articles(主表):id, status, author_id, created_at, ...
- article_translations(扩展表):id, article_id, lang, title, content,联合唯一索引 (article_id, lang)
为什么不直接 articles 里加 title_zh / title_en 列:① 新增语言要 DDL,运维成本高;② 列稀疏浪费空间;③ 不利于按语言查询。
原子性:发布时一个事务里做 N+1 个写——主表 status 改 published、N 条 translations upsert;任一失败 rollback。
优化:发布频次低,事务时间不敏感;读频次高,列表页用 join + 缓存,详情页直接按 (article_id, current_lang) 走联合索引。
初始签发:acme.sh --issue -d example.com --nginx,用 nginx 的 .well-known 验证;DNS 验证则用 --dns dns_cf(Cloudflare)、--dns dns_ali 等。
安装到 nginx:acme.sh --install-cert -d example.com --key-file /etc/nginx/ssl/key.pem --fullchain-file /etc/nginx/ssl/cert.pem --reloadcmd 'systemctl reload nginx'
自动续期:acme.sh 安装时会自己往 crontab 加一行 0 0 * * * /root/.acme.sh/acme.sh --cron,每天凌晨检查,距过期 < 30 天才真续。
生命周期:Let's Encrypt 证书 90 天有效,acme.sh 默认 60 天续一次,留 30 天缓冲;续完 reloadcmd 触发 nginx reload(平滑,不断连接)。
收益:整套机制配好后多年零维护,我那个项目跑了 4 年没出过证书过期事故。
架构:
- 官网前台(React SSR / 静态化):面向访客,SEO 优先,部署到 CDN + 一台源站
- CMS 后台(React SPA + antd):面向运营,鉴权,部署到内网或加 IP 白名单
- 内容 API(EggJS):统一数据出入口,前后台都调它
Nginx 配置:
- example.com → 静态资源 + SSR Node 进程(proxy_pass http://127.0.0.1:7001)
- cms.example.com → CMS SPA 静态文件,location /api/ → 转发到内容 API,加 basic auth
- api.example.com → 内容 API,跨域配 Access-Control-Allow-Origin
好处:三个工程独立发版,运营改后台不用动官网,API 升级前后端解耦。
核心差异:Egg 是阿里在 Koa 之上做的「约定大于配置」企业框架。
优势:
① 目录约定:controller / service / middleware / config 自动加载,不用手写 require;
② 多进程内置:基于 cluster 起 worker,自带 master 守护、平滑重启;
③ 插件机制:egg-mysql / egg-redis / egg-validate 配置即用;
④ 安全:CSRF / XSS / SafeRedirect 默认开启;
⑤ 国内文档好,阿里背书。
为什么不用 Express:Express 中间件是回调风格,异步错误处理麻烦;Egg 全 async/await,更现代。
为什么不用 NestJS:Nest 是后端 Java/Spring 风格,装饰器多;对前端转后端的人 Egg 学习曲线更平。
双线程模型(三家通用):逻辑层(JSCore / V8)+ 渲染层(WebView),通过 native bridge 通信——所以没有 DOM、没有 window。
全局对象:微信 wx、支付宝 my、字节 tt,SDK 入口要 polyfill 一个统一的 __platformBridge__。
网络:wx.request / my.request / tt.request,API 形态相似但参数细节差异(timeout 单位、header 大小写),SDK 内部抽 request(opts) → Promise。
上报通道:三端都没有 sendBeacon,只能 wx.request / 同名 API,App 退后台时上报会被中断——SDK 要在 onAppHide 同步触发 flush。
性能 API:微信有 wx.getPerformance(),支付宝/字节较弱,自己用 Date.now() 模拟首屏指标。
统一抽象:platform/{wechat,alipay,bytedance}.ts 三个适配器实现同一接口,核心逻辑只依赖接口。
RN 没有的:DOM、window、document、XHR(实现是 RN 自己的)、PerformanceObserver(部分有)、html2canvas、MutationObserver。
RN 有的:fetch(基于 RN 自己的 networking)、ErrorUtils.setGlobalHandler(全局错误)、AppState(前后台)、native module 直接走桥。
SDK 改造:
① 错误监控用 ErrorUtils.setGlobalHandler + Promise polyfill 的 unhandled rejection;
② 网络用 fetch hook,XHR 也包一层(部分库还在用);
③ 性能指标:首屏用「JS bundle 加载完」+「首个 setState 触发渲染」打点,LCP 用 RN 的 InteractionManager;
④ 上报通道用 fetch + AsyncStorage 持久化失败队列,App 重启再发。
⑤ 白屏检测概念上不适用——RN 用 ScrollView/Flatlist,可以监听 onLayout 判断是否有节点挂上。
Unity:JS 脚本通过 IL2CPP 编译到 C++,没有 V8/JSCore,只能调 Unity 暴露的 C# API;监控 SDK 几乎不能复用浏览器代码,要做成 C# package。
Cocos Creator:V8 引擎或 JavaScriptCore,有 ES6+ 但没有 DOM;window 是 mock 出来的;XHR 由 native 实现,行为接近浏览器但 cookie / cors 不同。
LayaBox:类似 Cocos,自己的 JS runtime,API 集中在 Laya.*;Web 部署时跑在浏览器,Native 部署跑在嵌入的 JS 引擎。
SDK 策略:
① 抽象 Platform 层只定义「报错入口、上报通道、时间戳」三件事;
② Cocos / LayaBox 共用一份「mock-DOM 兼容版」,Unity 单独出 C# 版;
③ 性能指标退化为「场景加载完 + 首帧渲染 + 首次交互」,不强求 web vitals。
预研产出:能力矩阵表格 + 降级策略文档,直接影响产品 Roadmap 是否立项 RN/Unity 新产品线。
事件循环:执行栈空 → 取一个宏任务 → 执行 → 清空所有微任务 → 渲染 → 下一个宏任务。
宏任务(MacroTask):script 整体、setTimeout、setInterval、setImmediate(Node)、I/O、UI rendering、postMessage、MessageChannel。
微任务(MicroTask):Promise.then/catch/finally、queueMicrotask、MutationObserver、process.nextTick(Node 优先级最高)。
关键点:每个宏任务后清空所有微任务,即使微任务里又产生新微任务也要执行完;这就是「微任务饥饿」的根源——一直在 then 里 schedule 新的 promise,会让宏任务永远执行不到,UI 卡死。
面试常考:console.log 顺序题,看清楚 await 后是 microtask、setTimeout 是 macrotask、process.nextTick 在 microtask 之前(Node)。
问题:重写 XMLHttpRequest.prototype.open 后,所有用 XHR 的代码(包括 Sentry、Google Analytics、jQuery.ajax)都会经过我们;一旦我们的代码抛异常或改了入参,所有第三方都跟着挂。
防御措施:
① try-catch 包裹整个 hook,失败时调用原方法、不污染业务;
② 不修改入参:apply(this, arguments) 透传,绝不重新组装;
③ 避免修改 response:只读不写;
④ 可关闭:暴露 disableNetworkHook() API,客户冲突时能临时关掉;
⑤ 检测重复 hook:if (XMLHttpRequest.prototype.open.__uapm) return; 防止多 SDK 共存时套娃;
⑥ enumerable: false:用 defineProperty 设属性,避免 for...in 暴露内部状态。
核心痛点:SDK 经常需要给 DOM 元素挂「附加数据」(比如关联的请求 traceId),用普通 Map<HTMLElement, data> 会让元素永远不能 GC,造成内存泄漏。
WeakMap:key 是对象引用,GC 不计数;元素被业务移除后,WeakMap 里对应条目自动消失。
WeakRef(2021+):new WeakRef(target) 包一层,ref.deref() 取目标,目标被 GC 后 deref 返回 undefined。
SDK 用例:
① 跟踪 DOM 节点对应的资源:elementToResource = new WeakMap();
② 跟踪 XHR 实例的 metadata:xhrMeta = new WeakMap(),XHR loadend 后实例可以释放;
③ 性能监控里缓存大对象用 WeakRef,避免缓存反而成为内存负担。
FinalizationRegistry(配套):对象被 GC 时通知,做日志清理或资源回收。
分代假说:大多数对象很快就死,熬过几次 GC 的会活很久;按生命周期分代回收效率最高。
新生代(Young Generation):小空间(默认 32MB),分 From / To 两个 semi-space,对象先进 From;Minor GC(Scavenge)触发时,把 From 里活对象拷到 To,然后 swap;经历 2 次 Scavenge 还活的提升到老生代。
老生代(Old Generation):大对象、长期存活对象;Major GC(Mark-Sweep / Mark-Compact):从根遍历标记活对象,清除未标记内存,必要时压缩整理减少碎片。
优化机制:
① Incremental Marking:Major GC 标记拆成多步,降低单次 STW(Stop The World);
② Lazy Sweeping:清扫延迟到下次分配时;
③ Concurrent Marking / Sweeping:利用辅助线程并发执行,Chrome 70+ 默认开。
前端可控点:① 避免长生命周期持有大对象;② 复用对象池(Canvas/WebGL 场景);③ 别滥用闭包持有 DOM。
dev 快的原因:
① 依赖预构建用 esbuild(Go 写,比 Webpack 的 Babel 快 10-100x),把 CommonJS / UMD 第三方库一次性预转成 ESM 缓存到 node_modules/.vite;
② 源码 ESM on-demand:浏览器原生支持 <script type='module'>,Vite 起一个 dev server,按 import 路径请求才转;首屏只加载首屏需要的模块,不打包整个 app;
③ HMR 精确:文件改了只重发那一个模块和 boundary,而不是整个 chunk。
生产用 Rollup:① 浏览器对深层 import 嵌套支持还不完美,网络请求瀑布流伤性能;② Rollup 的 Tree Shaking 比 esbuild 更彻底;③ Rollup 生态(插件)成熟;④ esbuild 还在迭代,作为 prod bundler 的稳定性不如 Rollup。
未来:Vite 6+ 在推 Rolldown(Rust 重写的 Rollup),目标是 dev/prod 同一引擎。
前提:基于 ESM 静态分析——import/export 是声明式的,bundler 能在编译期推断哪些 export 没被引用。CommonJS 的 require 是运行时的,无法 tree shake。
流程:Rollup/Webpack 构建图谱 → 标记每个 export 的引用情况 → 未引用的 export 不输出。
副作用问题:import 'polyfill' 这种没具名导出但执行时有副作用的语句不能删——会破坏功能。
标记方式:
① package.json 里 sideEffects: false:声明整个包无副作用,bundler 可以放心删未用 export;
② sideEffects: ['/*.css', './side-effect.js']**:数组形式精细化标注;
③ 代码层面:Webpack 还支持 /*#__PURE__*/ 注释标记函数调用无副作用,可以被删。
坑点: import './global.css' 这种 CSS 副作用要在 sideEffects 里 include,否则会被 tree shake 掉变成空白页。
Plugins 在 Presets 之前:无论数组里写在哪,plugins 永远先跑。
Plugins 数组:从前到后(top-to-bottom)。
Presets 数组:从后到前(bottom-to-top)——这个顺序最反直觉,典型例子是 presets: ['@babel/preset-env', '@babel/preset-react'],先跑 react(转 JSX)再跑 env(降级到目标 ES 版本)。
理由:Babel 的设计哲学认为「外层 preset 应该作用于已经被内层处理过的代码」,所以反向。
遍历层面:同一阶段(enter / exit)的多个 visitor 按注册顺序合并,Babel 一次遍历同时跑多个插件的同节点 visitor——所以插件之间冲突要靠 enter/exit 区分时机。
Cursor:日常 IDE,改代码、跑测试、做重构;它的 Composer / Agent 模式可以让 AI 直接改多文件,我做 SDK 重构、批量 rename、写测试都是它。
Claude(直接对话或 API):适合深度问题——架构设计、tricky bug 推理、长 prompt 多轮迭代;复杂场景比 Cursor 内置模型更稳。
Codex / GPT 系:补全场景偏多,比如样板代码、单测;速度快但深度不如 Claude。
TRAE SOLO:字节自家的 Agent IDE,内部项目里跑;特点是和字节工程基建集成更深(代码库 / 流水线 / 文档检索)。
实战搭配:Cursor 写日常代码 → 卡住时切 Claude 长对话推方案 → 方案定了切回 Cursor 让它批量改。
定义:Andrej Karpathy 提出的概念——「忘掉代码细节,聚焦在 vibe(意图/感觉)上」,用自然语言描述意图,让 AI 生成、调整、修复代码。
和传统编程的差异:
- 传统:工程师 → 代码 → 编译器/runtime
- Vibe Coding:工程师 → 意图描述 → AI Agent → 代码 → runtime;工程师角色从「写每一行」变成「定义需求 + Review + 调方向」。
对效率的实际影响:
① 样板代码 / CRUD / 简单 UI:10x+ 提速,几乎不需要手写;
② 复杂业务逻辑:Agent 可以打草稿,但需要工程师重度 Review,提速 2-3x;
③ SDK 内核 / 性能敏感代码:Agent 帮看不出微妙的副作用,提速有限,主要做「对话辅助」;
④ 调试 / 排错:Agent 给假设和方向,人来执行验证,提速明显;
⑤ 代码 Review / 重构:Agent 看大量上下文比人快,但深度判断仍要人。
核心改变:抽象层级上移——以前工程师在 if-else 层抽象,现在在「这个模块应该长什么样」这一层抽象,设计能力的权重大大上升。
最有用的环节:
① AST 节点结构探索:让 AI 把目标代码 parse 成 AST 节点 JSON 给我看,直接知道用哪个 visitor、哪个属性;
② 测试 fixture 生成:让 AI 生成 100 个边界 case 的源码片段,我跑过去验证 visitor 覆盖度;
③ 跨语言文档检索:Babel / SWC / OXC 的 AST 节点定义对比,AI 拉过来比我自己翻文档快;
④ commit message / PR 描述:省心。
不太有用的环节:
① 性能优化:微观 perf 决策(rIC vs rAF、预分配数组 vs push)AI 没有 runtime 数据,猜的多;
② 跨平台兼容性预研:这种工作核心是「在真实设备上跑出指标」,AI 推不出来,只能帮整理表格;
③ 架构权衡:多通道独立采样 vs 全局采样这种决策,AI 给的方案常常少一个维度,需要工程师补;
④ 代码 Review 微妙处:AI 看不出「这一行在并发场景下有 race」这种细节。
态度:AI 是放大器,工程经验是放大对象——经验越深,AI 帮你节省的杂活越多;但核心判断永远在人。
模板(可结合到岗位定制):
「我叫 XXX,8 年+ 前端经验,聚焦 SDK 与前端基础架构方向。
过去 5 年在友盟+ 做 APM SDK,主导过两个有代表性的项目:
一个是基于 Babel AST 的前端异常自动插桩方案,把「业务忘记上报」这一类盲区从 SDK 层根治,发布为开源插件并集成到全量 APM SDK,服务大量小程序和 H5 客户;
另一个是 SPA 白屏监控,设计了 DOM 打分模型 + MutationObserver 误报治理,把线上 noscript 误报降到 0。
技能上,React/Vue/JS/TS 是底子,深度方向是 AST 工具链、性能监控、跨平台兼容(覆盖 Web / 小程序 / RN / Unity);
工程能力上熟悉前端工程化全链路,也独立交付过全栈项目(React + EggJS + MySQL + Nginx)。
当前在火山引擎做 SDK 方向,希望接下来能在性能监控 / 编译期工具 / 跨端框架 这类深度领域继续深耕。」
节奏:不超过 60 秒。先身份(角色 + 年限) → 代表项目(2 个最亮) → 技能轮廓 → 与岗位的连接点。
STAR 法回答:
S(Situation):友盟 APM SDK 早期错误监控完全依赖业务主动 try-catch + 上报,客户漏写的盲区占错误总量的 30%+,问题反馈周期长。
T(Task):我作为方案负责人,要在不改变客户代码、不影响业务性能的前提下,把这部分盲区根治。
A(Action):① 调研 Babel AST + 业内自动插桩方案;② 设计「@babel/traverse 注册 CatchClause + @babel/template 多端模板 + 双层 try-catch 兜底」核心方案;③ 抽出「AST 内核 + 构建适配」架构,产出 Babel Plugin 和 Vite Plugin 一份内核两套生态;④ 设计文件级 + 节点级双重去重防止重复插桩;⑤ 发布为 @umengfe/babel-plugin-uapm-trycatch。
R(Result):方案集成到友盟 APM SDK 全量小程序 + H5 客户,客户接入成本降到 .babelrc 加一行,异常盲区从 30%+ 降到接近 0,被作为友盟前端基础能力沉淀,直接影响后续 SDK 体系建设。
为什么有成就感:① 从 0 到 1,有真实业务价值;② 跨工具链(Babel/Vite)抽象,有架构难度;③ 上线后稳定运行,经过线上多版本验证。
主线表达(正向、不抱怨):
① 业务方向:友盟 5 年我把 APM Web / 小程序方向做得相对完整,从运行时 SDK 到编译期插桩、白屏监控、跨端兼容性体系都覆盖到了;接下来想拓宽到「面向更大流量、更广客户群」的产品形态,火山引擎在云 + AI 方向有大量这类机会。
② 技术深度:火山引擎的 SDK 团队(智能美化特效)对性能、跨端、native 协作有更高门槛,正好是我想在跨端基础架构方向深入的下一站。
③ 个人节奏:5 年算一个完整周期,需要新的环境、新的技术栈来打破舒适区。
避免:不批评前东家、不强调薪资、不让人觉得是被动离开。
简短背景:火山引擎官网后台的 EBox 模块,是面向运营/PM 的内容/产品管理工作台。
职责:① 模块前端架构搭建与维护(选型 / 组件库 / 状态管理 / 路由);② 业务功能交付(从需求评审到上线);③ 性能与体验优化(首屏、长列表、复杂表单);④ 跨团队对接(后端 API、设计、PM、QA)。
结合简历亮点:把过去做 SDK 沉淀的工程化能力(组件抽象、可复用区块、性能监控接入)用到 EBox,前端架构的可维护性和迭代效率明显提升。
注意:面试时如果被追问具体内部细节,做合规处理——只讲职责和能力,不暴露内部数据/架构细节。
主线:沿「前端基础架构 / SDK / 编译期工具 / 跨端」这条深度路径继续往专家方向走。
3 年内:
① 在当前业务里把模块/SDK 做到对外可输出(开源 / 内部基建);
② 主导 1-2 个有体系化沉淀的方案(例如:AI 时代的前端监控体系 / 跨端编译工具链);
③ 带 2-3 人小组,完成从「单点贡献」到「方向负责人」的转变。
5 年内:
① 在某个细分领域(性能监控 / 编译期工具 / 跨端框架)做出业内有影响力的工作;
② 同时拓宽到 AI Agent + 前端工程结合的方向(因为这是未来 5 年最大的工程范式变化);
③ 输出技术影响力(分享 / 开源 / 文章)。
避免:① 说「想做管理」显得逃避技术;② 说「想创业」显得不稳定;③ 说「看公司安排」显得没主见。