前端场景:跨端交互与发布治理
把 Canvas 高清绘制、移动端下拉刷新、跨上下文通信、国际化切换和 npm 包发布整理成可实现、可验证的场景题。
前端场景:跨端交互与发布治理
这篇文章处理几类很容易“只会背 API”的面试题。回答时始终从约束出发:目标是什么、状态如何变化、哪些边界会失败、如何验证结果。
1. Canvas 高清绘制、坐标与命中测试
1.1 CSS 尺寸和位图尺寸是两套坐标
canvas 的 style.width/height 决定它在页面中的 CSS 尺寸,canvas.width/height 决定内部位图的像素数。默认位图尺寸通常是 300 × 150;在高 DPI 屏幕上如果只改 CSS 尺寸,浏览器会放大低分辨率位图,文字和线条就会发虚。
初始化时先得到 CSS 尺寸,再按 devicePixelRatio 放大 backing store,并把绘图上下文缩放回 CSS 坐标。这样业务模型仍可使用 CSS 像素,绘制时不必在每个坐标上手动乘倍数:
function resizeCanvas(canvas, ctx) {
const rect = canvas.getBoundingClientRect()
const dpr = Math.max(1, window.devicePixelRatio || 1)
// 真实项目可根据最大纹理尺寸和内存预算设置上限,不能盲目放大。
const pixelWidth = Math.round(rect.width * dpr)
const pixelHeight = Math.round(rect.height * dpr)
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth
canvas.height = pixelHeight
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
return { width: rect.width, height: rect.height, dpr }
}
setTransform 比重复调用 scale 更容易保持幂等。窗口缩放、容器尺寸变化、系统显示缩放和横竖屏切换都可能改变尺寸,通常用 ResizeObserver 重新计算;重设 canvas.width 会清空上下文状态和像素,因此要先保存模型数据,再按新尺寸重绘,而不是把旧位图简单拉伸。
1.2 从指针坐标还原到模型坐标
事件中的 clientX/clientY 是视口坐标,getBoundingClientRect() 也是视口坐标。两者相减即可得到 CSS 像素坐标;因为上下文已经缩放到 CSS 坐标,命中测试不应再次乘 dpr:
function pointInCanvas(event, canvas) {
const rect = canvas.getBoundingClientRect()
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
}
如果 Canvas 本身还应用了 CSS transform(例如缩放、旋转)或位于滚动容器中,不能只凭 offsetLeft 推算位置,应使用 DOMMatrix 的逆矩阵把视口坐标变换到绘图坐标。缩放画布时应明确“视觉坐标”和“模型坐标”两层:模型坐标保持稳定,视图矩阵只影响绘制和输入映射。
1.3 命中测试要以模型为准
不要把“鼠标落在 Canvas 元素上”当成命中某个图形。常见策略按场景选择:
| 场景 | 做法 | 取舍 |
|---|---|---|
| 图形很少 | 对模型的矩形、圆形或多边形做几何判断 | 实现简单,复杂图形要自己处理边界 |
| 路径形状 | 使用 Path2D 配合 isPointInPath/isPointInStroke |
与绘制路径一致,但要维护路径对象 |
| 几万以上图形 | 先按网格、R-tree 或四叉树缩小候选集,再做精确判断 | 建索引有成本,适合频繁指针移动 |
| 可选座位/地图 | 建立“模型 ID -> 几何区域”的索引,渲染只是结果 | 便于键盘操作、回放和服务端校验 |
命中边界要定义清楚:线条可设置可交互的容差,重叠图形按 z-index 或业务优先级从后往前检查。拖拽时保存被命中的模型 ID,不要在每一帧重新猜测目标;指针离开、pointercancel 或组件卸载时清理捕获状态。
1.4 可访问性与敏感内容边界
Canvas 本身不是可访问的语义树。座位图、流程图等交互内容应同时提供 DOM 列表、键盘焦点和文本状态,至少让辅助技术知道“元素 ID、状态、可执行动作”。签名板要提供清除、撤销和键盘/按钮替代操作;支付码等敏感图像要控制缓存、日志和截图权限,Canvas 不是安全容器。
跨源图片绘制前设置正确的 crossOrigin 并让服务器返回允许的 CORS 头,否则 Canvas 会变成非 origin-clean,toBlob()/toDataURL() 读取像素时会抛出安全异常。导出大图优先 toBlob(),避免把整个二进制内容膨胀成 Base64 字符串;导出任务应可取消,并在完成后释放临时位图和对象 URL。
面试回答骨架
先区分 CSS 尺寸、位图尺寸和模型坐标
-> 按 DPR 设置 backing store,并在 resize 后重绘
-> 用 rect/逆矩阵把指针映射到模型坐标
-> 先空间索引、再几何命中,明确重叠和容差
-> 提供 DOM/键盘替代,并说明跨源和敏感数据边界
2. 下拉刷新、触摸事件与滚动冲突
2.1 先定义状态机和边界
一个可控的下拉刷新组件至少有以下状态:
idle -> pulling -> ready -> refreshing -> settling -> idle
\-> cancelled
只有在滚动容器确实处于顶部、手势主要向下、且没有横向滑动意图时才进入 pulling。达到阈值后显示 ready,松手才触发一次刷新;刷新期间忽略新的拖拽,成功或失败都要回到可继续交互的状态。用状态机而不是几个布尔值,可以避免“请求已完成但指示器仍在转”“快速松手触发两次”等竞态。
2.2 Pointer Events、passive 和 preventDefault
现代 Web 优先使用 Pointer Events,统一鼠标、触摸和触控笔,并在拖拽对象上使用 setPointerCapture 保持事件连续性。若必须使用 Touch Events,要明确监听器的 passive 选项:
element.addEventListener('touchmove', onTouchMove, { passive: false })
function onTouchMove(event) {
if (!shouldTakeOverVerticalPull(event)) return
event.preventDefault()
updatePullDistance(event.touches[0].clientY)
}
浏览器默认把部分触摸滚动监听视为 passive,以便尽早开始滚动;passive 监听器中调用 preventDefault() 不会生效。不要全局注册非 passive 监听器:只有确认组件在顶部、方向锁定为垂直下拉并需要接管手势时,才在局部监听器中阻止默认行为。CSS touch-action 应先表达意图,例如 pan-x 允许横向滚动、由组件处理纵向拖拽;它不能代替状态判断。
2.3 嵌套滚动、边界回弹和原生行为
页面可能有外层窗口、内层列表和横向轮播。开始手势时记录起点,比较 dx 与 dy 做方向锁定;查找实际滚动容器的 scrollTop,不能只检查 window.scrollY。内层列表未到顶部时应把手势交给内层滚动,到了顶部才允许外层下拉。overscroll-behavior-y: contain 可以减少滚动链和浏览器边界回弹,但不同浏览器的系统级“下拉刷新”行为仍需在目标设备上验证,不应假定 CSS 在所有 WebView 中一致。
距离反馈可使用阻尼函数(例如 distance = raw * 0.5),并设置最大拖拽距离,防止指示器把布局撑得过大。动画只改变 transform,避免拖动过程中反复修改布局属性;尊重 prefers-reduced-motion,为不使用手势的用户提供明确的“刷新”按钮或键盘操作。
2.4 取消、异常和清理
pointercancel、页面隐藏、方向切换和组件卸载都应结束当前手势并释放捕获。- 刷新请求使用
AbortController,超时或路由离开时取消;取消不应被当成业务失败提示。 - 刷新成功后再更新列表和锚点,避免用户在等待期间看到跳动的内容。
- 监听器、计时器和订阅必须成对清理;否则返回页面后可能一次拖拽触发多次刷新。
3. iframe、窗口和 Worker 的可靠通信
3.1 先选通道,再定义协议
postMessage 只提供消息传输,不提供业务级请求响应、鉴权或重试。跨上下文场景可按需求选通道:
| 需求 | 通道 | 关键边界 |
|---|---|---|
| 父子 iframe/窗口 | window.postMessage |
固定 targetOrigin,校验 origin 与 source |
| 同源多标签页 | BroadcastChannel |
无持久队列,页面关闭后消息丢失 |
| 主线程与 Dedicated Worker | Worker.postMessage |
Worker 可终止,消息需可结构化克隆 |
| 需要独立双向端口 | MessageChannel |
通过 MessagePort 建立专用连接 |
| 多页面共享计算 | SharedWorker |
端口管理和生命周期更复杂 |
协议建议统一信封,至少有版本、唯一 ID、消息类型和截止时间:
type Envelope = {
version: 1
id: string
type: string
replyTo?: string
sentAt: number
deadline?: number
payload?: unknown
error?: { code: string; message: string }
}
接收方先验证来源和协议版本,再校验 type 对应的 payload schema,最后进入业务处理。不要直接把未知消息转成函数调用,也不要把 token、完整用户资料或未脱敏的 URL 广播给所有同源页面。
3.2 请求响应、超时和取消
发送请求时把 id -> {resolve, reject, timer} 放入 pending 表;收到 replyTo 时只完成对应请求。超时要删除 pending 项并向对端发送可选的 cancel 消息;对端必须把取消传递给 AbortController 或计算任务。所有入口都要处理 messageerror、Worker error、端口关闭和上下文销毁,否则 Promise 会永久悬挂。
function request(port, type, payload, signal, timeout = 5000) {
const id = crypto.randomUUID()
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new DOMException('request timeout', 'TimeoutError'))
}, timeout)
pending.set(id, { resolve, reject, timer })
port.postMessage({ version: 1, id, type, payload, sentAt: Date.now() })
signal?.addEventListener('abort', () => {
clearTimeout(timer)
pending.delete(id)
port.postMessage({ version: 1, id: crypto.randomUUID(), type: 'cancel', replyTo: id })
reject(signal.reason)
}, { once: true })
})
}
示例省略了 schema 校验和端口关闭处理;生产代码还要避免同一个 id 重复完成,并限制单条消息大小、并发请求数和队列长度。Transferable(如 ArrayBuffer、MessagePort、ImageBitmap)可以避免复制大对象,但转移后发送方不再拥有原对象;需要共享可变内存时才考虑 SharedArrayBuffer,并先满足跨源隔离等部署前提。
3.3 握手、安全和版本演进
iframe 建立连接时可先发送随机 nonce,双方交换支持的协议版本和能力列表;之后每条消息都绑定已验证的 source、origin 和会话 ID。targetOrigin 不要写 *,接收端也不能只判断 event.origin 而忽略 event.source。Origin 校验是边界校验,不等于用户鉴权;敏感操作仍需服务端授权和一次性签名。
协议升级采用向后兼容字段和明确的 capability,未知字段忽略、未知类型拒绝并记录指标。需要顺序的业务要携带序列号或版本号;跨不同通道不能假定全局有序。Worker 任务可用任务 ID、取消标志和版本号防止旧结果覆盖新结果,关闭 Worker 后清空 pending 表并释放 ImageBitmap、ArrayBuffer 等资源。
4. 国际化资源与路由切换
4.1 语言选择和资源分层
把 locale 当成受约束的应用状态,而不是散落在组件里的字符串。常见优先级是:显式 URL/用户设置 > 已保存偏好 > 浏览器 navigator.languages/Accept-Language > 默认语言。最终值应经过 allowlist 规范化,例如将 zh-CN、zh-cn 映射到同一个 canonical locale,并为 RTL 语言设置 dir="rtl"。
资源按语言、命名空间和页面拆分,避免一个 JSON 包覆盖整个应用:
locales/
zh-CN/common.json
zh-CN/checkout.json
en-US/common.json
en-US/checkout.json
文案使用稳定 key 和 ICU 风格的复数、日期、数字占位,不要通过字符串拼接句子:
cart.items = {count, plural,
=0 {购物车为空}
one {# 件商品}
other {# 件商品}
}
日期、时间、金额使用 Intl.DateTimeFormat、Intl.NumberFormat 等 API,并显式传入时区和 currency;不要把服务端时间字符串直接按用户机器时区渲染成业务结论。
4.2 懒加载、回退和竞态
路由切换时只加载当前页面需要的 namespace。加载任务要带 locale + namespace 版本,并用请求代次或 AbortController 防止用户快速切换语言时旧资源覆盖新资源:
let localeRequest = 0
async function changeLocale(nextLocale) {
const requestId = ++localeRequest
const messages = await loadMessages(nextLocale)
if (requestId !== localeRequest) return
i18n.replaceMessages(nextLocale, messages)
document.documentElement.lang = nextLocale
document.documentElement.dir = isRtl(nextLocale) ? 'rtl' : 'ltr'
}
回退链应可观测且有限,例如 zh-Hant-TW -> zh-Hant -> zh-CN,不能静默吞掉关键缺失。缺 key 在开发和 CI 中应报警,生产可以显示安全占位并上报 key、locale、route;不要把用户输入或完整翻译内容写进日志。资源缓存要带版本号,发布新版本时避免旧 namespace 与新代码混用。
4.3 SSR、路由和状态同步
SSR 首屏必须使用与客户端相同的 locale 和资源版本,否则会发生水合不一致或先闪现默认语言。语言偏好若保存在 Cookie,服务端要校验 allowlist;若由 URL 决定,切换后应更新 history、canonical 链接和页面标题。多标签页切换语言可用 BroadcastChannel,收到消息后重新加载缺失 namespace,而不是广播整套资源。
路由是否翻译路径要单独决策:展示文案可以切换而不改变资源 ID;若 URL 也本地化,必须维护稳定的 route ID、反向映射、分享链接和 404 回退,不能用翻译后的标题当数据库主键。表单错误、无障碍标签、日期和方向性布局都要纳入回归测试。
5. npm 包的 exports、类型与发布闭环
5.1 公开契约要和产物一致
package.json 是消费者看到的 API 契约。exports 只暴露稳定入口,types 指向与运行时代码同版本的声明,sideEffects 必须描述真实的顶层副作用:
{
"name": "@acme/ui",
"version": "1.4.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
},
"files": ["dist", "README.md", "LICENSE"],
"sideEffects": ["**/*.css"],
"peerDependencies": { "react": ">=18" }
}
exports 存在时,消费者不能再依赖未公开的深层路径;这是有意的封装,不应为了兼容偶然路径而全部开放。条件导出的顺序、ESM/CJS 互操作和 Node/打包器支持矩阵需要用真实消费者验证。声明文件不能只“生成出来就算完成”:要检查泛型、事件类型、CSS 模块和子路径入口是否可被消费者正确推断。
sideEffects: false 不是性能开关。若模块注册全局行为、注入样式或执行 polyfill,错误标记会让打包器删除必要代码;若每个样式文件都是副作用,应精确列出 CSS,而不是把整个包标成有副作用导致 tree-shaking 失效。
5.2 构建、测试和包内容检查
发布前至少做四类检查:
- 产物检查:
npm pack --dry-run查看文件清单,确认没有测试夹具、本地配置、源码地图敏感路径或未编译源码;检查 ESM、CJS、.d.ts和 CSS 入口都存在。 - 消费者冒烟:在临时目录安装打包后的 tarball,分别用 Node、TypeScript 和目标 bundler 导入根入口及每个公开子路径。
- 行为矩阵:运行单元、类型、Lint、SSR/浏览器交互和按支持框架划分的兼容测试;组件库要检查键盘、焦点、主题和样式是否被 tree-shaking 错误删除。
- 依赖审计:运行锁文件一致性和漏洞扫描,确认框架放在
peerDependencies,运行时必需包没有误放到devDependencies。
构建应可复现:固定 Node/包管理器版本,CI 使用锁文件,产物由当前提交生成。把 dist 手工改后发布会破坏回滚和溯源;版本号、变更日志、Git tag 与构建产物要一一对应。
5.3 版本策略、registry 和回滚
语义化版本只是沟通工具,先定义“公共 API 的兼容性”:新增可选导出通常是 minor,修复是 patch,删除导出或改变默认行为是 major。弃用时保留迁移期和运行时提示,提示不能在生产高频刷屏。预发布版本使用独立 dist-tag,验证通过后再提升稳定标签。
发布到私有 registry 时显式配置 scope、registry、访问权限和 provenance 要求,CI 中使用短期凭据,不把 token 写进日志或包内。发布失败优先修复后发布新版本;不要依赖覆盖同一版本号,很多 registry 会禁止或缓存旧产物。真正的回滚通常是让消费者回到已知稳定版本、撤回 dist-tag 或在应用侧锁定版本,而不是删除历史包。
发布答题清单
公共入口和类型是否与 dist 一致?
-> exports 是否限制了深层路径,ESM/CJS 是否都由消费者冒烟验证?
-> sideEffects 是否准确,CSS/注册副作用是否会被误删?
-> npm pack 内容、依赖类别、锁文件和安全扫描是否通过?
-> 版本、变更日志、tag、registry 和回滚入口是否可追溯?
6. 请求并发池、超时预算与可观测性
6.1 并发池解决什么问题
浏览器、WebView 和服务端都可能有连接数、CPU 或下游 QPS 限制。请求层的并发池是应用级背压,不等同于浏览器的连接池:它应该按资源或业务隔离,例如 search、upload、analytics 使用不同的上限,必要时再按用户或租户建立配额。不能把所有请求塞进一个全局队列,否则一个慢的批量任务会阻塞登录、保存等关键操作。
队列项至少包含任务函数、优先级、截止时间、取消信号和幂等信息。调度器只有在“未过期、未取消且当前槽位可用”时才启动任务;任务结束、抛错或取消都必须在 finally 中释放槽位。可以采用带上限的 FIFO 加优先级队列,并为低优先级任务保留配额,避免高频预加载把交互请求饿死。
type QueueTask<T> = {
run: (signal: AbortSignal) => Promise<T>
priority: number
deadline: number
signal?: AbortSignal
}
class RequestPool {
private active = 0
private queue: Array<QueueTask<unknown> & {
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
}> = []
constructor(private readonly limit: number) {}
add<T>(task: QueueTask<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({
...task,
resolve: (value) => resolve(value as T),
reject,
})
this.queue.sort((a, b) => b.priority - a.priority)
this.drain()
})
}
private drain() {
while (this.active < this.limit && this.queue.length > 0) {
const task = this.queue.shift()!
if (task.signal?.aborted) {
task.reject(task.signal.reason ?? new DOMException('request cancelled', 'AbortError'))
continue
}
if (task.deadline <= Date.now()) {
task.reject(new DOMException('queue deadline exceeded', 'TimeoutError'))
continue
}
this.active += 1
const remaining = Math.max(1, task.deadline - Date.now())
const timeout = AbortSignal.timeout(remaining)
task.run(timeout)
.then(task.resolve, task.reject)
.finally(() => {
this.active -= 1
this.drain()
})
}
}
}
示例只展示调度边界,生产实现还应让队列项在“过期/已取消”时调用 reject,并组合调用方信号与超时信号(如 AbortSignal.any,或手动转发 abort)。不要把一个已被调用方取消的任务继续占用槽位;也不要在队列等待期间就开始计算网络超时。
6.2 用绝对截止时间管理超时
把超时写成一个孤立的 setTimeout(5000) 很容易在排队、重试和刷新 Token 后超预算。更可靠的做法是请求开始时生成一个绝对 deadline,把剩余时间传给每个阶段:
总预算 8s
排队等待 0.8s
首次请求最多 4s
退避 0.5s
Token 刷新最多 1.5s
重放请求只能使用剩余 1.2s
每次开始尝试前计算 remaining = deadline - now;若小于最小可用窗口就直接结束,并把原因标成 deadline_exceeded。重试只对幂等请求或带服务端幂等键的写请求开放;每次尝试释放并发槽位后再退避,不能让睡眠中的重试占着池子。超时、主动取消、网络错误和 HTTP 业务错误要使用不同的结果码,避免监控把用户主动离开误报成服务故障。
队列也要有容量上限和拒绝策略。超过上限时可丢弃可重建的预加载、返回 queue_full,或让调用方选择降级;不能无限堆积 Promise 和请求体。上传、下载等长任务应按字节数或带宽预算另行限制,不能只看请求个数。
6.3 可观测字段和验证
每个逻辑请求使用稳定的 requestId,每次重试有递增的 attempt;跨服务调用再携带不含敏感信息的 traceId。至少记录:
poolKey、优先级、queueWaitMs、inFlight、队列长度和拒绝数;用户/租户维度应使用脱敏或哈希后的标识。deadlineMs、attempt、退避时长、durationMs、HTTP 状态或网络错误码,以及outcome(success、timeout、cancel、queue_full、http_error)。- 请求方法、规范化 URL 模板、资源大小和响应字节数;不要记录 Authorization、完整查询参数或请求体。
验证时分别压测单资源突发、多个资源互相影响、队列满、超时后重试、调用方取消和页面卸载。关注 P95/P99 排队等待与端到端耗时,而不是只看平均响应时间;如果池的上限变化,必须能从发布版本和配置快照还原当时的行为。
7. ESLint/tsconfig 继承、增量迁移与 CI 基线
7.1 共享约束,局部覆盖
大型仓库应把“默认约束”和“运行环境差异”分开。TypeScript 可维护一个根 tsconfig.base.json,再由应用、Node 脚本和测试配置通过 extends 继承;每个子配置只声明自己的 include、outDir、JSX 或环境类型。项目较大时可用 Project References 把包拆成可独立构建的图,避免一个配置把浏览器和 Node 全局类型混在一起。
ESLint 也应有一个版本化的共享配置(现代 ESLint 可用 flat config 或发布 shareable config),在根部统一 parser、规则、忽略目录和插件版本,再按包/文件类型添加窄范围覆盖。不要把 dist、生成代码和 vendor 目录靠大量 /* eslint-disable */ 隐藏;忽略规则和源码范围应在配置中可审查。tsc 类型检查与 ESLint 规则是两条不同的质量线,不能用其中一个代替另一个。
一个常见的目录边界如下:
tsconfig.base.json # 严格度、模块解析、共享路径
tsconfig.app.json # 浏览器/JSX 入口
tsconfig.node.json # 构建脚本和 Node 类型
packages/*/tsconfig.json # 包的 include、outDir、references
eslint.config.js # 共享规则和忽略范围
配置本身也要固定在 lockfile 和 CI 的 Node/TypeScript/ESLint 版本中;升级配置工具时先看规则和模块解析的行为变化,再批量改代码。
7.2 老项目的增量迁移
不要一次把数万条历史错误混入每个 PR。先建立基线报告,记录文件、规则/诊断码、责任域和豁免期限;新提交必须“不能新增错误”,迁移工作再按包或目录逐步收敛。迁移顺序通常是:
- 先统一解析器、路径和生成目录,消除配置噪声。
- 打开低风险格式/规则并修复自动可修复项,再处理
noImplicitAny、严格空值等语义风险。 - 对 JS 目录先用
allowJs,必要时按边界启用checkJs;新文件直接使用严格配置,旧文件按迁移清单逐步纳入。 - 每个包迁移完成后收紧
include、删除临时豁免并更新基线,避免基线成为永久垃圾场。
类型迁移要把外部输入(HTTP、Storage、Schema、环境变量)放在运行时校验边界;不要用 any、@ts-ignore 或大量非空断言把错误从报告里抹掉。确有第三方声明缺陷时,使用带链接和到期日期的局部声明补丁,并让 CI 统计豁免数量。
7.3 CI 阻断级别和修复基线
CI 至少分三层:
| 层级 | 例子 | 默认策略 |
|---|---|---|
| 必须阻断 | 配置解析失败、类型错误、Lint error、单元测试失败、构建失败 | PR 不能合并 |
| 受控告警 | 新增 Lint warning、覆盖率轻微下降、bundle 预算接近阈值 | 有预算和责任人,超过阈值才阻断 |
| 观察指标 | 全仓历史错误、非关键包迁移进度、缓存命中率 | 记录趋势,不用一次性阻塞所有团队 |
“只检查改动文件”适合迁移初期的增量门禁,但发布分支仍应跑受影响包的完整类型检查、测试和构建;否则跨包契约可能在合并后才暴露。CI 缓存键应包含 lockfile、配置文件、编译器版本和目标包,配置变更必须使缓存失效。
基线报告要可再生、可审计:每条豁免注明原因、负责人、创建提交和截止日期;CI 比较本次报告与基线,新增项直接失败,修复项从基线移除。每月或每个里程碑统计剩余项和平均修复时间,达到门槛后删除兼容开关。这样既不会用“全仓归零”阻断迁移,也不会把临时容忍变成永久标准。
8. 统一的场景题答题与验证模板
这几类题可以用同一套结构回答:
成功标准 -> 约束/能力检测 -> 状态机与数据模型
-> 关键 API 和协议 -> 失败、取消、兼容与安全边界
-> 可观测字段 -> 最小复现和回归验证 -> 后续演进
验证不应停留在“看起来能用”:Canvas 用不同 DPR、缩放和触摸输入验证坐标;下拉刷新用嵌套滚动、pointercancel 和 reduced-motion 验证;通信协议注入未知来源、乱序、重复和超时消息;i18n 用缺 key、慢网络、SSR 和 RTL 验证;npm 包用真实消费者安装 tarball 验证入口、类型和 tree-shaking。这样才能把资料中的题目转化为可迁移的工程能力。
高频追问
为什么 Canvas 不能直接截图 DOM?
drawImage 接受图像、视频或另一个 Canvas 等可绘制源,不会把任意 DOM 树按样式布局转换成位图。DOM 截图需要浏览器或专门渲染器逐项处理字体、伪元素、跨源资源和布局;Canvas 只适合绘制已经准备好的图像数据。(深入阅读:Canvas 坐标与绘制边界)
passive: false 是否能解决所有下拉刷新冲突?
不能。它只决定监听器是否可以阻止默认行为;还要判断滚动边界、手势方向、touch-action、嵌套容器和系统级回弹,并处理取消和无障碍替代操作。(深入阅读:Pointer Events 与默认行为)
postMessage 校验了 origin 就安全吗?
不够。还要校验 event.source、会话/nonce、消息 schema、权限和重放边界。消息通道安全只解决传输来源,不能替代服务端授权。(深入阅读:握手、安全与版本演进)
sideEffects: false 为什么可能导致样式消失?
打包器会据此删除未被显式引用的模块。如果样式导入或全局注册依赖模块执行副作用,错误标记会把它当成可删除代码。应精确列出 CSS 或其他确实有副作用的文件,并用消费者包冒烟测试验证。(深入阅读:包入口与副作用声明)