前端异常解析之Source Map

3,607 阅读11分钟

前端异常解析之Source Map

引子

线上正式环境代码抛错,打开控制台一看:

image-20211222180654848.png

点进去看:

image-20211222180826064

​ emmm~~ 完全不知道代码哪里写错了!

​ 因为在正式的线上环境中,我们的代码往往都是经过压缩和转义了的,抛出的异常信息都是指向对应的压缩文件,这就导致了基本无法理解这些异常抛错。

​ 这时候就需要借助Source Map文件追溯源文件的位置了。

Source Map 简介

​ 简单的来讲,Source Map 就是信息文件,里面存储了代码打包转换后的位置信息,实质是一个 json 描述文件,维护了打包前后的代码映射关系。

Source Map 格式

​ 最初的源映射格式是由 Joseph Schorr 创建,在 Closure Inspector(谷歌的公共工具)中用来开启 JavaScript 源码级别的调试优化。随着使用了源映射的项目规模扩大,格式冗余开始成为一个问题。v2 版本为了减小源映射整体大小,牺牲了一些简单性和灵活性,目前最新的版本是 v3 。

​ 按照约定,源映射文件跟源文件拥有相同的名称,只是后缀为 .map 。比如 page.js 的产生的源映射名称是 page.js.map 。这个约定并不是强制性的。

​ 整个文件是一个 JSON 对象:

{
  "version" : 3, // source map 规范版本,必须是一个正整数。
  "file": "out.js", // 可选项,转换后产生的源映射文件名。
  "sourceRoot": "", // 可选项,资源更路径,在服务器上重新定位源文件和移除 sources 中重复的值有用处。这个值会预先添加到 sources 字段中每一个值。如果是跟源文件相同的路径,则为空。
  "sources": ["foo.js", "bar.js"], // 存放 mappings 中使用的源文件。
  "sourcesContent": [null, null], // 可选性,存放源内容。列表的顺序跟 sources 字段中顺序一致。如果一些源要按照名称检索,可能会使用 null 。
  "names": ["src", "maps", "are", "fun"], // mappings 中使用的一些标识名。
  "mappings": "A,AAAB;;ABCDE;" // 记录了映射信息的字符串。
}

如何生成 Source Map

​ 目前各种主流前端打包工具都支持生成 Source Map

vite

​ 在其配置文件 vite.config.js`中设置,详见官方文档

module.exports = {
  build: {
    sourcemap: true
  }
}

image-20211222192606372.png

webpack

​ 在其配置文件 webpack.config.js中设置,详见官方文档

module.exports = {
    devtool: "source-map"
}

image-20211222192526705.png

一般来讲:

​ 开发环境:

  • 我们在开发环境对 sourceMap 的要求是:快(eval),信息全(module)
  • 且由于此时代码未压缩,我们并不那么在意代码列信息(cheap),

所以开发环境比较推荐配置:devtool: cheap-module-eval-source-map

值得一提的是: vue-cli开发环境source map默认的就是这个值

​ 生产环境:

  • 生产环境会涉及到安全相关的问题,我们不希望别人能看到我的的源码
  • 同时我们又要生成信息完成的source map文件,供后续分析线上可能产生的异常

所以生产环境比较推荐配置:devtool: hidden-source-map

如何使用Source Map

​ 从 Chrome 39 开始,开发者工具中 Source Maps 设置项默认是开启的。

​ 本地开发的时候,类似 Webpack 构建工具都支持生成 Source Map 文件,调试的时候在 Chrome 中就可以在开发者工具 Sources 栏中看到对应源代码位置。

​ 正式环境,一般不提供Source Map,或者对Source Map文件做访问限制。

问题1:游览器是怎么知道有Source Map文件的呢?

​ 查看正常打包生成代码文件:

image-20211223101700617.png

​ 可以看到最后一行有这句话注释:

//# sourceMappingURL=bundle.js.map

正是因为这句注释,标记了该文件的 Source Map 地址,浏览器才可以正确的找到源代码的位置。 sourceMappingURL 指向 Source Map 文件的 URL

问题2:正式环境,线上不提供Source Map,或者对Source Map文件做访问限制,这就导致了正式环境抛错后调试困难,怎么解决呢?

一般来讲,公司可以搭建一个前端异常监控系统,用于处理正式环境的情况。

大致的工作流程可以分为以下几步:

  1. 前端收集错误
  2. 前端上报错误到监控系统服务端
  3. 前端代码上线打包后将对应的Source Map文件上传至监控系统服务端
  4. 发生错误时监控系统服务端接收错误并记录到日志中
  5. 根据sourcemap和错误日志内容进行错误分析

下面我们可以尝试搭建一个简易的监控系统了解下其中的原理

简易前端监控系统

​ 我们只是为了了解其中的原理,所以适当缩减下上面的流程,我们只做以下几步尝试下:

  1. 前端简单的收集错误并上报
  2. 搭建一个简单的监控服务端
  3. 利用Source Map进行错误分析

1. 前端简单的收集错误并上报

接下来我们利用webpack搭建一个简单的前端项目:

// webpack.config.js
const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  devtool: "hidden-source-map" // 指定Source Map类型
};

hidden-source-map代表会生成 Source Map文件,但代码文件最后一行的注释不在保留,即模拟正式环境看不到Source Map信息,同时又生成了Source Map文件供监控系统服务端使用。

// src/index.js
import axios from 'axios'

// 上报错误
const fetchError = (error) => {
  axios.get('http://localhost:3000/error', {
    params: {
      error
    }
  }).then((res) => {
    console.log(res.data.data)
  })
}

// 收集错误
window.addEventListener('error', (e) => {
  fetchError(e.error.stack)
}, true)


// 模拟错误代码, b 未定义,这里一定会报错
console.log(b) 

2. 搭建一个简单的监控服务端

利用koa搭建一个简单的node服务

// scripts/index.js
const koa = require('koa')
const cors = require('koa2-cors')
const analysis = require('./analysis')

const app = new koa()

// 永许跨域请求配置
app.use(
  cors({
    origin: function(ctx) { //设置允许来自指定域名请求
      return '*'; // 允许来自所有域名请求
    },
    maxAge: 5, //指定本次预检请求的有效期,单位为秒。
    credentials: true, //是否允许发送Cookie
    allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], //设置所允许的HTTP请求方法'
    allowHeaders: ['Content-Type', 'Authorization', 'Accept'], //设置服务器支持的所有头信息字段
    exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'] //设置获取其他自定义字段
  })
);

// 监听上报接口
app.use(async (ctx, next) => {
  if (ctx.request.path === '/error') {
    // 调用analysis方法分析错误信息
    const data = await analysis(ctx.request.query.error)
    ctx.body = {
      status: 200,
      data
    }
  } else  {
    await next()
  }
})

app.listen(3000)

3. 利用Source Map进行错误分析

利用 stacktracey 包解析上报的错误栈,获取错误在打包后代码中所在的位置信息。

利用 source-map 包和上面的位置信息以及Source Map文件解析出,错误在源码中的位置信息和源码内容。

const sourceMap = require('source-map')
const Stacktracey = require('stacktracey')
const fs = require('fs')
const path = require('path')

const readFile = (filePath) => new Promise((resolve, reject) => {
  fs.readFile(filePath, { flag: 'r' }, (err, data) => {
    if (err) reject(err)
    else resolve(data.toString())
  })
})


const analysis = async (errorStack) => {
  // 读取Source Map文件, 直接读取dist目录下对应的map文件,真实情况是需要上传至服务器的
  const sourceMapFileContent = await readFile(path.resolve(__dirname, '../dist/bundle.js.map'))
  // 解析错误栈信息
  const tracey = new Stacktracey(errorStack);
  const sourceMapContent = JSON.parse(sourceMapFileContent);
  // 根据source map文件创建SourceMapConsumer实例
  const consumer = await new sourceMap.SourceMapConsumer(sourceMapContent);

  // 获取第一条错误栈信息
  const errorInfo = tracey.items[0]

  // 根据打包后代码的错误位置解析出源码对应的错误信息位置
  const originalPosition = consumer.originalPositionFor({
    line: errorInfo.line,
    column: errorInfo.column,
  });

  // 获取源码内容
  const sourceContent = consumer.sourceContentFor(originalPosition.source);

  // 返回解析后的信息
  return {
    sourceContent,
    ...originalPosition
  }
}

module.exports = analysis

然后我们验证下:

根目录运行npm run build打包好前端代码至dist文件夹下

dist目录下运行 http-server命令,使用 http-server 在本地模拟部署在 locahost:8080

运行 node scripts/index.js 启动监控系统服务端

游览器打开页面 locahost:8080, 查看控制台

image-20211223112855016.png

可以看到我们解析处理错误在源码的第22行第12列,ide下对比下源代码:

image-20211223113026367.png

至此我们就实现了一个简易的监控系统,想要实现一个完整的可以使用的监控系统还有许多地方需要完善,比如更加完善的前端错误收集和上报,Source Map文件的自动上传,以及服务端的日志记录,后台查看等等一系列功能需要处理。

所以搭建一个完整的异常监控系统的工作量和难度还是不小的,当然业界目前也有几款可以直接接入使用的产品,像 FundebugSentry 可以自行查看对应文档了解和部署,就不多说了。

由于搭建完整的异常监控系统是存在一定成本的,而目前我们大多数的前端项目都并没有接入对应的系统,那么我们线上抛错了,有办法借助Source Map文件找到异常位置吗?就想本文档开头引子里遇到的情况,有办法解决吗?

答案是肯定的,我们可以借助下面实现的一个小工具来利用Source Map文件手动解析异常。

手动解析异常

之前有在掘金上看到有人搭建过一个node小工具用来实现手动解析异常。

大致流程是:

  1. 查看线上抛错信息,人工获取错误在打包后代码中的行和列已经错误文件信息。

  2. 在命令行使用工具,并传入 错误文件对应Source Map文件地址、 错误在打包后代码中的行和列,即可获取错误在源码中的信息

我们简单实现下:

先使用 vue-cli搭建一个简易的项目用来模拟:

vue create vue-demo

添加 vue.config.js 配置文件

// vue.config.js
module.exports = {
  productionSourceMap: false, // 模拟真实线上环境,打包时不生成 Source Map 文件
  lintOnSave: false // 关闭eslint检测,不然我们抛错代码无法正常打包
}

修改src/components/HellowWorld.vue 文件制造错误

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String 
  },
  created() {
    console.log(b) // 这一行一定会抛错
  }
}
</script>

然后构建 npm run build 部署 cd dist && http-server 模拟线上环境运行报错

1.人工获取抛错信息

​ 打开模拟的线上环境页面的控制台:

image-20211223135736403.png ​ 可以知道抛错位置在打包后的app.52b51166.js的第一行,点击进去

image-20211223135913225.png

可以看到具体在第1行4236列处。

即错误对应的Source Map文件为dist/js/app.52b51166.js.map ,错误在第1行第4236列

2.使用工具解析

工具需传入错误文件对应Source Map文件地址 错误在打包后代码中的行和列。

根据上面的设定我们先实现这个工具:

src/index.js

#! /usr/bin/env node
const path = require('path')
const fs = require('fs')
const { program } = require('commander')
const packageConfig = require('../package.json')
const sourceMap = require('source-map')

// 读取文件信息
const readFile = (filePath) => new Promise((resolve, reject) => {
  fs.readFile(filePath, { encoding: 'utf-8' }, (err, data) => {
    if (err) reject(err)
    else resolve(data.toString())
  })
})

// 定义命令行参数
program.version(packageConfig.version)  // --version 版本
  .requiredOption('-s, --source-path <sourcePath>', 'source-map文件地址')
  .requiredOption('-l, --line <line>', '设置错误所在行')
  .requiredOption('-c, --column <col>', '设置错误所在列')
  .description('source-map 工具')
  .action(async (options) => {

    const sourcePath = path.resolve(process.cwd(), options.sourcePath)
    const line = Number(options.line)
    const column = Number(options.column)

     // 读取传入地址的source map文件
    const sourceMapFileContent = await readFile(sourcePath)
    const sourceMapContent = JSON.parse(sourceMapFileContent)
    // 根据source map内容生成SourceMapConsumer实例
    const consumer = await new sourceMap.SourceMapConsumer(sourceMapContent);

    // 根据传入的打包后错误位置解析出源码错误位置信息
    const originalPosition = consumer.originalPositionFor({
      line,
      column
    })

    consumer.destroy()
    // 打印源码错误位置信息
    console.log(originalPosition)

  })

program.parse(process.argv)

// package.json
{
  "name": "source-map-tools",
  "version": "1.0.0",
  "description": "source-map-tools",
  "homepage": "",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "bin": {
    "smt": "src/index.js" // 定义命令标识
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "commander": "^8.3.0",
    "source-map": "^0.7.3"
  }
}

使用 npm link将包链接到全局用以调试

然后切换到 vue-demo 项目,根据刚才抛错位置信息在命令行输入

smt -s dist/js/app.52b51166.js.map -l 1 -c 4236

结果:

image-20211223143641443.png

可以看到错误对应源码位置在 src/components/HelloWorld.vue 的第40行16列,在ide中检查:

image-20211223143823783.png

正确。

至此我们就实现了一个可以手动解析异常的工具。

当然实际情况相对案例会复杂点,比如线上环境代码是在linux上远程构建的,而我们只能在本机使用相同的源码构建生成Source Map文件,这就导致了线上和本地生成的代码可能对应不上,或者由于不同机器打包的导致文件hash不一致,导致难以辨别出出错代码文件对应的Source Map文件是哪一个。其实这个不用太担心,只要线上和本机存在同一份package-lock.json文件,那么线上代码和本机代码最终生成的内容会是一致的,至少使用Source Map问题不大。而hash不一致导致的难以找到对应的Source Map文件是哪一个问题,也可以通过生成hash规则中的[name] 判断,实在无法判断的可以通过复制一段源码内容在编辑器中进行检索比对找到对应的Source Map文件,然后使用工具查出源码异常位置。

最后 source-map-tools 这包已经上传npm ,可以直接 下载npm install source-map-tools -g

参考