跳到主内容

从压缩报错到源码定位:SourceMap 还原实战 ​

问题背景:线上错误为什么总指向第 1 行 ​

前端项目发布前通常会经过 Vite、Rollup 或 Terser 压缩、混淆与拆包。线上真正运行的产物可能只剩一个很短的变量名,错误堆栈也类似下面这样:

text
TypeError: Cannot read properties of undefined (reading 'name')
    at Object.821 (https://cdn.example.com/assets/index-B3f9.js:1:82431)
    at r (https://cdn.example.com/assets/vendor-K91a.js:1:43920)
    at HTMLButtonElement.o (https://cdn.example.com/assets/index-B3f9.js:1:12084)

这类信息只能告诉你错误发生在某个产物文件的第 1 行第 82431 列,却无法直接对应到 src/components/UserCard.vue 或某个业务函数。手工在压缩文件里搜索,不仅耗时,还容易因为版本不一致导致误判。

SourceMap 还原的目标,是把浏览器上报的 url、line、column 映射回源码文件、源码行列和函数上下文。它本质上是错误监控链路中的“符号表”能力:浏览器负责采集压缩堆栈,监控服务端或构建平台保存对应版本的 map 文件,再用 source-map 库完成解析。

方案对比:浏览器还原、构建期还原与服务端还原 ​

常见方案有三种。

第一,浏览器开发者工具还原。开发时把 .map 与 .js 一起部署,DevTools 能直接显示源码。它适合本地调试,但不适合生产环境:公开 map 等于暴露业务源码,而且无法自动沉淀到错误监控系统。

第二,构建期扫描还原。发布脚本对已知错误日志批量解析,适合离线巡检,但实时性差,无法在告警发生时立刻展示源码位置。

第三,服务端基于符号表实时还原。构建时生成 map,但不发布到 CDN,而是上传到监控平台;线上 SDK 只上报压缩堆栈和版本号,服务端按版本取符号表解析。这是目前稳定性、安全性和实时性最均衡的做法。

浏览器运行压缩产物采集错误堆栈监控 SDK 上报版本号与行列号监控服务端匹配符号表SourceMap 解析原始源码位置CI 构建生成 map上传但不公网发布

Vite 生产配置:生成 map 但不要直接暴露 ​

在 Vite 或 VitePress 项目中,可以通过 build.sourcemap 开启产物映射。注意这里选择 hidden:它会生成 .map 文件,但不会在 JS 末尾追加 //# sourceMappingURL=... 注释,避免浏览器或爬虫轻易发现 map 地址。

typescript
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  define: {
    // 每次发布注入唯一版本号,监控上报和符号表都使用它
    __APP_VERSION__: JSON.stringify(process.env.GIT_COMMIT_SHA || 'local-dev'),
  },
  build: {
    // hidden:生成 map,但不在产物中声明 sourceMappingURL
    sourcemap: 'hidden',
    rollupOptions: {
      output: {
        // 文件名保留 contenthash,便于长期缓存和问题追踪
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
      },
    },
  },
});

CI 构建完成后,需要把 .map 文件上传到符号表服务,同时删除发布目录中的 map,保证 CDN 上只有 JS、CSS 等运行资源。

javascript
// scripts/upload-sourcemaps.mjs
import { glob } from 'glob';
import { readFile, unlink } from 'node:fs/promises';
import path from 'node:path';

const release = process.env.GIT_COMMIT_SHA;
const uploadUrl = process.env.MONITOR_UPLOAD_URL;

if (!release || !uploadUrl) {
  throw new Error('缺少 GIT_COMMIT_SHA 或 MONITOR_UPLOAD_URL');
}

const files = await glob('dist/assets/**/*.js.map');

for (const file of files) {
  const map = await readFile(file, 'utf8');
  const relativePath = path.relative('dist', file).replace(/\.map$/, '');

  const res = await fetch(`${uploadUrl}/sourcemaps`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      release,
      // 必须与线上报错 URL 能对应上,例如 assets/index-B3f9.js
      minifiedFile: relativePath,
      map: JSON.parse(map),
    }),
  });

  if (!res.ok) {
    throw new Error(`符号表上传失败:${file} ${res.status}`);
  }

  // 上传成功后再删除,避免发布到公网 CDN
  await unlink(file);
  console.log('已上传并移除', file);
}

SDK 上报:错误堆栈必须带上发布版本 ​

前端监控 SDK 采集错误时,至少要带上 message、stack、filename、lineno、colno、release。如果缺少 release,服务端就无法判断该用哪一次构建的符号表;尤其是多次灰度并存时,很容易把新版本错误还原到旧 map 上。

typescript
// monitor.ts
declare const __APP_VERSION__: string;

interface ErrorFrame {
  filename: string;
  lineno: number;
  colno: number;
  function?: string;
}

interface ErrorReport {
  message: string;
  stack: string;
  release: string;
  pageUrl: string;
  createdAt: number;
}

const queue: ErrorReport[] = [];

function reportError(error: Error) {
  const payload: ErrorReport = {
    message: error.message,
    stack: error.stack || '',
    release: __APP_VERSION__,
    pageUrl: location.href,
    createdAt: Date.now(),
  };

  queue.push(payload);
  navigator.sendBeacon('/api/monitor/errors', JSON.stringify(payload));
}

window.addEventListener('error', (event) => {
  // Script error 通常来自跨域脚本,拿不到真实错误对象
  if (event.error) reportError(event.error);
});

window.addEventListener('unhandledrejection', (event) => {
  const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
  reportError(error);
}

export function captureError(error: unknown) {
  reportError(error instanceof Error ? error : new Error(String(error)));
}

业务代码中可以主动捕获异步异常:

vue
<script setup lang="ts">
import { ref } from 'vue';
import { captureError } from './monitor';

const user = ref<{ name: string }>();

async function loadUser() {
  try {
    const res = await fetch('/api/user');
    if (!res.ok) throw new Error(`用户接口异常:${res.status}`);
    user.value = await res.json();
  } catch (error) {
    captureError(error);
  }
}
</script>

<template>
  <button @click="loadUser">加载用户</button>
  <div v-if="user">{{ user.name }}</div>
</template>

服务端还原:用 source-map 建立索引 ​

服务端可以使用 Mozilla 的 source-map 库。为了做到秒级定位,不建议每次请求都重新读取和解析 map,应按 release + minifiedFile 建立 SourceMapConsumer 缓存。下面示例演示核心解析流程。

javascript
// server/sourcemap-service.js
import { SourceMapConsumer } from 'source-map';

const consumerCache = new Map();

async function loadConsumer(release, minifiedFile, mapStore) {
  const key = `${release}:${minifiedFile}`;
  const cached = consumerCache.get(key);
  if (cached) return cached;

  const rawMap = await mapStore.findMap(release, minifiedFile);
  if (!rawMap) return null;

  // SourceMapConsumer 需要 wasm 支持时,按部署文档初始化全局 SourceMapConsumer.initialize
  const consumer = await new SourceMapConsumer(rawMap);
  consumerCache.set(key, consumer);
  return consumer;
}

export async function resolveFrame(frame, release, mapStore) {
  const filename = frame.filename.split('?')[0];
  const minifiedFile = filename.includes('/assets/')
    ? filename.slice(filename.indexOf('assets/'))
    : filename;

  const consumer = await loadConsumer(release, minifiedFile, mapStore);
  if (!consumer) {
    return { ...frame, resolved: false, reason: 'SYMBOL_TABLE_NOT_FOUND' };
  }

  // 浏览器行列通常从 1 开始,source-map 行列从 0 开始
  const original = consumer.originalPositionFor({
    line: frame.lineno,
    column: Math.max(frame.colno - 1, 0),
  });

  if (!original.source) {
    return { ...frame, resolved: false, reason: 'NO_MAPPING' };
  }

  return {
    ...frame,
    resolved: true,
    source: original.source,
    line: original.line,
    column: original.column + 1,
    name: original.name,
  };
}

解析后的结果可以写回错误详情:

javascript
import { resolveFrame } from './sourcemap-service.js';n
function parseStack(stack) {
  const frameRegExp = /at\s+(?:(.+?)\s+\()?https?:\/\/[^)]+?:(\d+):(\d+)\)?/g;
  const frames = [];
  let match;

  while ((match = frameRegExp.exec(stack))) {
    frames.push({
      function: match[1] || 'anonymous',
      filename: match[0].match(/https?:\/\/[^\s:)]+/)?.[0] || '',
      lineno: Number(match[2]),
      colno: Number(match[3]),
    });
  }

  return frames;
}

export async function resolveError(error, mapStore) {
  const frames = parseStack(error.stack);
  const resolvedFrames = await Promise.all(
    frames.map((frame) => resolveFrame(frame, error.release, mapStore))
  );

  return { ...error, frames: resolvedFrames };
}

符号表治理:版本、路径与保留周期 ​

符号表不是简单上传一个 map 文件就结束,还需要治理三个关键字段。

其一是版本。推荐使用 Git Commit SHA,而不是 latest。构建时注入、SDK 上报、map 上传三处必须完全一致。

其二是路径。报错里可能是完整 URL,也可能带 query,例如 https://cdn.example.com/assets/index-B3f9.js?v=1;上传时保存的 key 可能是 assets/index-B3f9.js。服务端需要统一做 URL 解析、去 query、归一化,而不是直接字符串比较。

其三是保留周期。线上故障有时几天后才被发现,建议至少保留 30 到 90 天;长期支持版本可按发布单独立保留。过期符号表清理时,不要只按时间删除,正在灰度的版本应加保护。

避坑总结:这些问题会让还原结果失真 ​

1. 行列号差一位,定位到上一行代码 ​

常见现象是还原结果总偏到前一个字符或上一行。原因通常是混淆产物列号从 0 开始,而浏览器 colno 从 1 开始。修正方式是调用 originalPositionFor 前执行 column: colno - 1,返回展示时再加回 1。行号一般直接使用浏览器的 1 起始值,不要盲目再减。

2. 本地能还原,线上提示找不到符号表 ​

这通常不是 map 内容错误,而是路径 key 不一致。CDN 域名、静态资源前缀、query 参数、部署子路径都会影响匹配。例如上传 key 是 /assets/index-B3f9.js,上报 URL 是 https://cdn.example.com/app/assets/index-B3f9.js。应统一抽取 pathname,并去掉开头的应用 base,再按 release + assets/xxx.js 查询。

3. map 被公开访问,源码直接泄露 ​

有些项目为了省事把 .map 一同上传到 CDN,虽然压缩代码能被 DevTools 自动还原,但业务逻辑、接口路径甚至注释都可能暴露。生产环境应使用 sourcemap: 'hidden',CI 上传符号表后删除本地 .map,CDN 访问 .map 后缀建议直接返回 403 或 404。

4. 异步 chunk 对不上,首屏错误正常、懒加载错误失败 ​

多页应用或动态导入会产生大量 chunk,只上传入口文件 map 不够。每个 assets/**/*.js.map 都要上传,并保留原始 chunk 文件名。若服务端只保存 index.js.map,点击菜单后加载的 UserPage.[hash].js 报错就无法还原。

5. 生产构建关闭了列号或源码上下文 ​

部分压缩配置为了进一步减小体积,会关闭列信息、函数名或源码内容。这样即使 map 存在,也可能只能得到文件,无法得到准确函数和代码片段。排查时确认不要把生产 sourcemap 设为仅文件级映射;同时监控平台可保存 sourcesContent,但必须做好权限控制。

把构建、上传、上报、解析四个环节串起来后,压缩代码中的 1:82431 就能稳定变成 src/components/UserCard.vue:18:12。这不仅缩短了线上故障响应时间,也让错误监控从“看到异常”真正走向“定位根因”。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。