跳到正文
前端知识库
JavaScript

JavaScript 面试真题补充:浏览器 API 与安全场景

根据 JavaScript 面试真题补充浏览器存储、文件上传、XHR、可视区域检测、跨域安全与 CSRF/XSS 的工程边界。

11 分钟JavaScript · 浏览器 · XHR · 安全 · 面试

JavaScript 面试真题补充:浏览器 API 与安全场景

本文根据编号 1《JavaScript 面试真题》整理。类型转换、数组方法、Promise、数值精度和递归的基础答案已经分别并入 JavaScript 运行机制与资深面试数据类型尾递归大数相加;这里只保留资料中项目原有文章没有展开的浏览器 API 与安全场景。

1. Cookie、Web Storage 与 IndexedDB

Cookie 会随匹配域名、路径的 HTTP 请求自动发送,适合保存短小的会话标识,不适合保存大段业务数据。常见属性的作用如下:

  • ExpiresMax-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、鉴权失败、文件变化和服务端校验失败应立即停止并交给业务处理。合并接口也应幂等,客户端不能因为某个请求超时就盲目再次创建会话。

选择文件 -> 计算/协商指纹 -> 创建或恢复会话
       -> 查询已完成分片 -> 限制并发上传
       -> 持久化每个成功索引 -> 服务端校验并合并
       -> 删除本地记录 / 展示最终结果

进度应按已确认的字节数计算,而不是按请求发出次数计算;失败重试期间进度不能倒退。服务端合并前还要校验会话所属用户、分片数量、总字节数、摘要和病毒/内容扫描结果,合并到临时文件后再原子改名,避免读到半成品。

文件类型校验必须分层处理:

  1. file.type、文件名扩展名和前端正则只用于改善用户体验,都可以被伪造。
  2. 服务端要根据文件头(magic bytes)、解析结果和允许的 MIME 白名单再次校验。
  3. 服务端生成最终文件名并隔离存储目录,避免路径穿越、覆盖和脚本执行。
  4. 上传进度使用 xhr.upload.onprogress 或等价 API;lengthComputablefalse 时不能计算百分比。

标准 File 属性是 file.name,不是 file.filename。预览使用 URL.createObjectURL(file) 后,要在不再使用时调用 URL.revokeObjectURL(url)。(深入阅读:文件上传安全风险

3. XHR 的状态机与请求边界

XMLHttpRequest 的常见流程是 open -> 注册事件 -> sendreadyState 的关键状态为:

含义
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 查询参数应使用 URLURLSearchParamsencodeURIComponent 编码,不能直接拼接未处理的用户输入。跨源 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、安全的属性赋值和框架默认转义,谨慎使用 innerHTMLouterHTMLdocument.writeeval、字符串形式的 setTimeout/setInterval 以及 v-html/dangerouslySetInnerHTML

// 文本内容不会把输入解析成 HTML
message.textContent = untrustedText

// 需要允许少量 HTML 时,先使用经过审计的 sanitizer,再写入
// message.innerHTML = sanitize(untrustedHtml)

CSP 可以限制脚本来源,HttpOnly 可以降低 Cookie 被脚本读取的风险,但两者都不能替代输出编码和服务端鉴权。

CSRF 的成因和防护

CSRF 利用浏览器会自动携带 Cookie 的特性,让受害者在已登录状态下发出非预期的写操作。常见防护组合包括:

  1. 对状态修改请求使用服务端生成且绑定会话的 CSRF token(同步 token 或双重提交 Cookie)。
  2. 设置合理的 SameSite Cookie,并在服务端校验 Origin/Referer
  3. 不让 GET、图片或链接触发有副作用的操作。
  4. 关键操作要求再次认证或显式确认。

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.originevent.source,不要使用 '*' 接收敏感数据。

预览功能的面试回答可以按“需求(只读/搜索/批注) -> 渲染方案 -> 资源与权限 -> 取消和释放 -> 失败降级”展开,而不是只背一个库名。(深入阅读:浏览器多进程与线程模型

8. 与已有专题的对应关系

PDF 中以下主题已经在项目现有文章中展开,复习时直接进入对应文章即可:

这种拆分把真题的浏览器场景与语言机制分开,避免同一结论在多篇文章中出现不同版本。