JavaScript 面试真题补充:浏览器 API 与安全场景
根据 JavaScript 面试真题补充浏览器存储、文件上传、XHR、可视区域检测、跨域安全与 CSRF/XSS 的工程边界。
JavaScript 面试真题补充:浏览器 API 与安全场景
本文根据编号 1《JavaScript 面试真题》整理。类型转换、数组方法、Promise、数值精度和递归的基础答案已经分别并入 JavaScript 运行机制与资深面试、数据类型、尾递归 和 大数相加;这里只保留资料中项目原有文章没有展开的浏览器 API 与安全场景。
1. Cookie、Web Storage 与 IndexedDB
Cookie 的属性和边界
Cookie 会随匹配域名、路径的 HTTP 请求自动发送,适合保存短小的会话标识,不适合保存大段业务数据。常见属性的作用如下:
Expires或Max-Age控制持久化时间;不设置时通常是会话 Cookie。Domain限定可发送的域名范围;扩大到父域会增加暴露面。Path限定 URL 路径,但不是安全隔离边界。Secure要求通过 HTTPS 发送。HttpOnly禁止 JavaScript 通过document.cookie读取,可降低 XSS 窃取 Cookie 的风险。SameSite=Lax/Strict/None控制跨站请求携带行为;SameSite=None必须同时使用Secure。
客户端不能通过 document.cookie 读取 HttpOnly Cookie。服务端仍要校验会话和权限,不能把“前端隐藏 Cookie”当成授权措施。
localStorage 与 sessionStorage
两者都按源(协议、主机、端口)隔离,API 是同步的键值存储,值会被转换为字符串:
localStorage.setItem('profile', JSON.stringify({ name: 'Ada' }))
const profile = JSON.parse(localStorage.getItem('profile') || 'null')
sessionStorage.setItem('draft', 'temporary')
localStorage通常跨页面和浏览器重启保留,直到显式清除或被浏览器回收。sessionStorage按标签页会话隔离,关闭标签页后通常被清除。setItem可能因为配额、隐私模式或用户策略抛出异常,生产代码要捕获异常。storage事件通常在同源的其他文档中触发,不会在修改值的当前文档中触发。- 不要把长期有效的敏感凭据直接放入可被脚本读取的存储;一旦发生 XSS,localStorage 内容可被读取。
IndexedDB 是异步的结构化数据存储,支持对象、索引、事务和更大的数据量,适合离线缓存、草稿和文件元数据。它需要围绕事务和版本升级设计,不能当成更大的 localStorage 字符串盒子。(深入阅读:浏览器存储与安全边界)
| 需求 | 更合适的选择 | 原因 |
|---|---|---|
| 短小会话标识 | HttpOnly + Secure + SameSite Cookie |
请求自动携带,脚本不可读 |
| 非敏感的小型偏好 | localStorage |
API 简单、跨会话 |
| 当前标签页临时状态 | sessionStorage |
生命周期短、标签页隔离 |
| 大量离线结构化数据 | IndexedDB | 异步、可建索引、支持事务 |
2. 文件选择、分片上传与类型校验
文件输入的 change 事件可以拿到 File 对象。FileReader 适合读取文件内容,Blob.prototype.slice 可以切出分片,FormData 负责把分片和元数据交给 XHR 或 fetch:
const input = document.querySelector('input[type="file"]')
input.addEventListener('change', async () => {
const file = input.files?.[0]
if (!file) return
const chunkSize = 10 * 1024 * 1024
for (let offset = 0, index = 0; offset < file.size; offset += chunkSize, index += 1) {
const chunk = file.slice(offset, offset + chunkSize)
const form = new FormData()
form.append('chunk', chunk, file.name)
form.append('index', String(index))
form.append('name', file.name)
form.append('size', String(file.size))
await fetch('/api/upload/chunk', { method: 'POST', body: form })
}
})
完整的断点续传协议还要有会话 ID、分片总数或总大小、服务端已上传分片查询和最终合并步骤。资料中用 MD5 标识文件的思路可用于去重和续传协商,但哈希不能替代鉴权、权限检查或内容安全扫描。
生产实现还要明确并发、重试和幂等边界:服务端以“上传会话 ID + 分片序号”作为幂等键,客户端先查询已完成分片,再只上传缺失部分;单个分片失败可指数退避重试,但要限制次数和并发数。最终合并应由服务端校验分片数量、总字节数和文件摘要后执行,不能只相信客户端传来的文件名或大小。网络中断时保留会话 ID,重新选择同一文件后再协商续传;会话过期和文件内容变化时应重新建会话。
分片大小不是越大越好:过小会增加请求和服务端元数据,过大则让失败重传成本变高。应结合网络、服务端限制和目标文件大小压测,不能因为“总带宽相同”就断言分片上传与一次上传耗时一定相同。(深入阅读:断点续传实现)
2.1 文件指纹与客户端恢复状态
断点续传需要先判断“这次选择的文件”是否仍然是上一次上传的同一个文件。常见的协商指纹由文件大小、最后修改时间、名称和内容摘要组成;名称和时间只能做快速提示,不能当作内容证明。对大文件不必一开始就把整个文件读入内存,可以先读取首尾片段生成快速指纹,再由服务端用完整摘要做最终确认。安全敏感或需要去重的场景应使用服务端认可的密码学摘要,而不是只信任客户端传来的 MD5。
客户端可以把上传会话和已完成分片索引保存在 IndexedDB(资料中的 localForage 只是它的封装)中:
async function resumeUpload(file, fingerprint, api) {
const chunkSize = 10 * 1024 * 1024
const uploadKey = `${fingerprint}:${chunkSize}`
const record = await api.getLocalRecord(uploadKey)
const session = record && record.size === file.size
? await api.resume(record.sessionId)
: await api.create({ name: file.name, size: file.size })
const completed = new Set(session.completedChunks)
for (let index = 0; index < session.totalChunks; index += 1) {
if (completed.has(index)) continue
await api.putChunk(session.id, index, file.slice(
index * session.chunkSize,
Math.min(file.size, (index + 1) * session.chunkSize),
))
completed.add(index)
await api.saveLocalRecord(uploadKey, {
sessionId: session.id,
size: file.size,
completedChunks: [...completed],
})
}
return api.complete(session.id)
}
恢复流程必须以服务端查询结果为准:本地记录可能因清理、跨设备或服务端过期而失效。文件大小、指纹或分片大小发生变化时要新建会话;会话完成后删除本地元数据。记录已完成分片时使用集合或位图而不是只保存“已完成数量”,因为并发上传可能先完成后面的分片。(深入阅读:断点续传实现)
2.2 并发、重试与合并状态机
上传器可以限制同时在途的分片数量,并把每个分片建模为 pending -> uploading -> uploaded/failed。重试只针对可恢复的网络错误或明确的 5xx/429,采用指数退避和抖动;4xx、鉴权失败、文件变化和服务端校验失败应立即停止并交给业务处理。合并接口也应幂等,客户端不能因为某个请求超时就盲目再次创建会话。
选择文件 -> 计算/协商指纹 -> 创建或恢复会话
-> 查询已完成分片 -> 限制并发上传
-> 持久化每个成功索引 -> 服务端校验并合并
-> 删除本地记录 / 展示最终结果
进度应按已确认的字节数计算,而不是按请求发出次数计算;失败重试期间进度不能倒退。服务端合并前还要校验会话所属用户、分片数量、总字节数、摘要和病毒/内容扫描结果,合并到临时文件后再原子改名,避免读到半成品。
文件类型校验必须分层处理:
file.type、文件名扩展名和前端正则只用于改善用户体验,都可以被伪造。- 服务端要根据文件头(magic bytes)、解析结果和允许的 MIME 白名单再次校验。
- 服务端生成最终文件名并隔离存储目录,避免路径穿越、覆盖和脚本执行。
- 上传进度使用
xhr.upload.onprogress或等价 API;lengthComputable为false时不能计算百分比。
标准 File 属性是 file.name,不是 file.filename。预览使用 URL.createObjectURL(file) 后,要在不再使用时调用 URL.revokeObjectURL(url)。(深入阅读:文件上传安全风险)
3. XHR 的状态机与请求边界
XMLHttpRequest 的常见流程是 open -> 注册事件 -> send。readyState 的关键状态为:
| 值 | 含义 |
|---|---|
0 |
未初始化,尚未调用 open |
1 |
已调用 open |
2 |
已收到响应头 |
3 |
正在接收响应体 |
4 |
请求完成 |
readyState === 4 只表示传输完成,还要检查 HTTP 状态。通常把 200 <= status < 300 视为成功,并单独处理 304、超时、网络错误和取消:
function requestJson(url, { method = 'GET', body, signal } = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(method, url, true)
xhr.responseType = 'json'
xhr.timeout = 15_000
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response)
} else {
reject(new Error(`HTTP ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.ontimeout = () => reject(new Error('Request timeout'))
xhr.onabort = () => reject(new DOMException('Request aborted', 'AbortError'))
if (signal) {
if (signal.aborted) xhr.abort()
signal.addEventListener('abort', () => xhr.abort(), { once: true })
}
xhr.send(body ?? null)
})
}
GET 查询参数应使用 URL、URLSearchParams 或 encodeURIComponent 编码,不能直接拼接未处理的用户输入。跨源 XHR 还受到 CORS 响应头和预检请求约束,不能用 mode: 'no-cors' 读取任意跨源响应。(深入阅读:异步并发、竞态与背压)
4. 判断元素是否进入可视区域
offsetTop 是相对 offsetParent 的位置,嵌套滚动容器中容易误判。getBoundingClientRect() 返回元素相对当前视口的矩形,更适合一次性判断:
function isFullyVisible(element) {
const { top, right, bottom, left } = element.getBoundingClientRect()
return (
top >= 0 &&
left >= 0 &&
right <= window.innerWidth &&
bottom <= window.innerHeight
)
}
滚动事件中频繁读取布局并修改样式,可能造成强制同步布局。需要懒加载或曝光统计时优先使用 IntersectionObserver:
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
loadImage(entry.target)
observer.unobserve(entry.target)
}
}
}, { threshold: 0.1 })
document.querySelectorAll('[data-src]').forEach((element) => {
observer.observe(element)
})
threshold 表示交叉比例,不是像素距离;root 可以指定滚动容器。回调是异步通知,不能把它当作同步布局测量 API。(深入阅读:渲染失效、图层与滚动)
5. 跨域、XSS 与 CSRF
同源与 CORS
同源由协议、主机和端口共同决定,三者任一不同就属于跨源。CORS 是服务端通过响应头授予浏览器读取权限的机制:
- 简单请求可以直接发送,但浏览器仍会检查
Access-Control-Allow-Origin。 - 非简单请求通常先发
OPTIONS预检,服务端需要声明允许的方法和请求头。 - 携带凭据时,
Access-Control-Allow-Origin不能使用*,还要显式返回Access-Control-Allow-Credentials: true。 - CORS 只控制浏览器脚本能否读取响应,不等于服务端授权;服务端仍要验证身份和权限。
JSONP 通过 <script> 的跨源加载能力工作,只支持 GET,且响应会直接作为脚本执行;新接口优先使用 CORS 或服务端代理。
XSS 的输入位置与输出编码
XSS 的关键不只是“过滤字符串”,而是把不可信数据放进正确的上下文并进行编码。优先使用 textContent、安全的属性赋值和框架默认转义,谨慎使用 innerHTML、outerHTML、document.write、eval、字符串形式的 setTimeout/setInterval 以及 v-html/dangerouslySetInnerHTML。
// 文本内容不会把输入解析成 HTML
message.textContent = untrustedText
// 需要允许少量 HTML 时,先使用经过审计的 sanitizer,再写入
// message.innerHTML = sanitize(untrustedHtml)
CSP 可以限制脚本来源,HttpOnly 可以降低 Cookie 被脚本读取的风险,但两者都不能替代输出编码和服务端鉴权。
CSRF 的成因和防护
CSRF 利用浏览器会自动携带 Cookie 的特性,让受害者在已登录状态下发出非预期的写操作。常见防护组合包括:
- 对状态修改请求使用服务端生成且绑定会话的 CSRF token(同步 token 或双重提交 Cookie)。
- 设置合理的
SameSiteCookie,并在服务端校验Origin/Referer。 - 不让 GET、图片或链接触发有副作用的操作。
- 关键操作要求再次认证或显式确认。
XSS 是把脚本注入受信任页面,CSRF 是借用受害者已有身份发起请求;一处 XSS 往往能绕过很多基于 token 的前端防护,因此要优先修复 XSS。(深入阅读:XSS、CSRF 与 CSP 的安全模型)
6. Canvas 导出、跨源边界与大图处理
Canvas 的 toDataURL() 可以把 origin-clean 画布编码为 Data URL,toBlob() 通常更适合导出大图,因为它不会把整个二进制结果长期展开成体积更大的字符串。导出前应根据用途选择 MIME 类型和质量参数,并在完成后释放对象 URL:
canvas.toBlob((blob) => {
if (!blob) return
const url = URL.createObjectURL(blob)
download(url, 'snapshot.webp')
setTimeout(() => URL.revokeObjectURL(url), 0)
}, 'image/webp', 0.85)
只要画布绘制过没有获得 CORS 许可的跨源图片或视频,画布就会变成 tainted;此时调用 toDataURL()、toBlob() 或 getImageData() 会抛出安全错误。必须在设置 src 之前设置 image.crossOrigin = 'anonymous',并由资源服务器返回匹配的 Access-Control-Allow-Origin;凭据模式还要配合明确的来源,不能使用通配符。CORS 失败后再设置属性不会“洗白”已有像素。
const image = new Image()
image.crossOrigin = 'anonymous'
image.onload = () => context.drawImage(image, 0, 0)
image.src = 'https://cdn.example.test/photo.jpg'
context.drawImage(document.documentElement, ...) 不能直接把 DOM 截成画布;DOM 不是 Canvas 的图像源。需要网页截图时应使用经过评估的 DOM 渲染工具,并处理字体、跨源资源、隐私内容和内存峰值。大画布导出还要考虑设备上限、分辨率缩放和用户授权,不能把 Data URL 当作上传大文件的默认格式。(深入阅读:DPR 对 Canvas 绘制的影响)
7. PDF 预览的实现取舍
PDF 预览至少有三种路径,选择取决于是否需要控制渲染、搜索和安全策略:
| 方案 | 优点 | 主要边界 |
|---|---|---|
<a target="_blank"> / window.open |
实现最简单,交给浏览器内置查看器 | 不保证所有环境都有相同的查看器能力,无法统一工具栏和埋点 |
pdf.js + worker + Canvas |
可控制页码、缩放、搜索和渲染进度 | 需要托管 worker,处理大文档的内存、跨源和权限;不要把 PDF 当 HTML 执行 |
| 受控 iframe / 第三方 viewer | 接入快,能复用现成界面 | 跨源通信、隐私、可用性和第三方服务依赖需要评估 |
使用 pdf.js 时,主线程负责文档状态和交互,解析等工作交给对应 worker;Canvas 绘制通常仍在主线程,确有需要时再评估 OffscreenCanvas 和浏览器支持。页面只渲染当前视口附近的页,并在切换文档或组件卸载时取消任务、销毁 worker 和释放 Canvas。PDF 地址、鉴权和下载权限仍由服务端控制,不能因为前端隐藏下载按钮就认为内容不可获取。跨源 iframe 通信必须使用固定 targetOrigin 并校验 event.origin 与 event.source,不要使用 '*' 接收敏感数据。
预览功能的面试回答可以按“需求(只读/搜索/批注) -> 渲染方案 -> 资源与权限 -> 取消和释放 -> 失败降级”展开,而不是只背一个库名。(深入阅读:浏览器多进程与线程模型)
8. 与已有专题的对应关系
PDF 中以下主题已经在项目现有文章中展开,复习时直接进入对应文章即可:
- 类型转换、
==/===、typeof、instanceof:类型转换与相等算法。 - 字符串和数组方法:数组转换 与 数组扩展。
- Promise、async/await、事件循环:Promise 基本用法、await 的基本用法、事件循环的基本流程。
- 浮点精度和大数:JavaScript 数字精度问题。
- 递归和尾调用:尾递归。
这种拆分把真题的浏览器场景与语言机制分开,避免同一结论在多篇文章中出现不同版本。