添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

随着JavaScript脚本越来越复杂,大部分代码都需要经过一系列转换才能投入生产。而转换后的代码位置、变量名都已改变,那么如何定位转换后的代码?这就是source-map要解决的问题。

source-map 存储了压缩后代码相对于源代码的位置信息,常用于JavaScript代码调试;一般以.map结尾的json文件存储。

source-map的构成

  • version:source-map的版本,目前为3
  • sources:转换前的文件url数组
  • names:转换前可以用于映射的变量名和属性名
  • file:源文件的文件名
  • sourceRoot:源文件的目录前缀(url)
  • sourcesContent: sources对应的源文件内容
  • mappings:记录位置信息的 VLQ编码 字符串,下文讲解
  • JSON示例(webpack)
    # @param { Number } source-map的版本,目前为 3 "version" : 3 , # @param { Array < String >} 转换前的文件url数组 "sources" : [ "webpack://utils/webpack/universalModuleDefinition" , "webpack://utils/./src/utils.js" # @param { Array < String >} 转换前可以用于映射的变量名和属性名 "names" : [ "add" , "a" , "b" , "Error" ], # @param { String } base64的 VLQ 编码 "mappings" : "AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACD,O;;;;;;;;;;ACVO,SAASA,GAAT,CAAcC,CAAd,EAAiBC,CAAjB,EAAoB;AACvB,QAAMC,KAAK,CAAC,MAAD,CAAX;AACA,SAAOF,CAAC,GAAGC,CAAX;AACH,C" , # @param { Array < String >} sources对应的源文件内容 "sourcesContent" : [ "(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"utils\"] = factory();\n\telse\n\t\troot[\"utils\"] = factory();\n})(self, function() {\nreturn " , "export function add (a, b) {\r\n throw Error('test')\r\n return a + b\r\n}" ], # @param { String } 源文件的目录前缀(url) "sourceRoot" : "/path/to/static/assets" , # @param { String } 源文件的文件名 "file" : "utils.js"

    【注】JSON没有注释语句,代码中#仅作解释说明

    mappings属性

    VQL编码后

    VQL编码后的mappings字符串划分为三层:
    每行用分号(;)划分,行内每个位置信息用逗号(,)划分,具体的行列位置记录用VLQ编码存储,即

    mappings:"AAAAA,BBBBB;CCCCC"
    

    表示转换后的源码有两行,第一行记录有两个变量名和属性名位置,第二行只有一个两个位置记录

    通过以上可知基于位置的映射关系是模糊映射,即仅能指出具体某个(行列)位置所在的局域内

    VLQ编码后的字符串是变长,如上文JSON示例(webpack)中的mapping属性所示:GAAGC,CAAX;AACH,C

    VLQ编码前

    编码前的位置信息由五位组成:

  • 第一位,表示这个位置在(转换后的代码的)的第几列。
  • 第二位,表示这个位置属于sources属性中文件(名)的序列号。
  • 第三位,表示这个位置属于转换前代码的第几行。
  • 第四位,表示这个位置属于转换前代码的第几列。
  • 第五位,表示这个位置属于names属性中(变量名和属性名)数组的序列号。
  • 第五位不是必需的,如果该位置没有对应names属性中的变量,可以省略第五位
  • 在实际应用中,每行的VLQ编码前映射信息是相对前一位置的相对位置(节省空间?)
  • // 编码后:
    const mappings = "AACA;AACA,CAAC;" 
    // 解码(下文说明):
    const decodedMappings = decode(mappings) // [[[0, 0, 1, 0]], [[0, 0, 1, 0],[1, 0, 0, 1]]]
    
    VLQ解码

    应用vlq库可对mapping信息进行解码,但由于行内的位置编码是相对位置,所以获取每行的标志位置的绝对定位需要累计位置信息

    /* @param {Object} rawMap 原json格式的source-map文件
     * @return {Array} decoded 以编译后的文件行信息数组 
           // 第一行
           [  // 第一个位置
              segment, 
              segment,
    function decode(rawMap) {
        const {mappings, sources, names} = rawMap
        const {decode} = require('vlq')
        // 相对位置解码
        const lines = mappings.split(';');
        const linesWithRelativeSegs = lines.map((line) => {
            const segments = line.split(',')
            return segments.map((x) => {
              return decode(x)
        const absSegment = [0, 0, 0, 0, 0]
        const decoded = linesWithRelativeSegs.map((line) => {
            // 每行的第一个segment的列位置需要重置
            absSegment[0] = 0
            if (line.length == 0) {
              return []
            return line.map((segment) => {
              const result = []
              for (let i = 0; i < segment.length; i++) {
                // 累计转换为绝对位置
                absSegment[i] += segment[i]
                result.push(absSegment[i])
              return result
              // 返回完整的绝对路径segment
              // return absSegment.slice()  
        // fwz: 为了行号和Array的index对应
        decoded.unshift([])
        return decoded
    

    以前端性能监控场景为例,通过前端回传的文件路径及行列号信息,即可通过source-map映射到源文件的具体位置

    // 前端报错信息返回的文件路径(拆分文件不同source-map映射时使用)、行列号
    window.addEventListener('error', e => {
        console.log(e)
        const {filename,  lineno, colno} = e
    

    根据编译后的行列号及对应的source-map,获取源文件行列内容

    const { codeFrameColumns } =  require("@babel/code-frame")
    // 通过编译后文件的行列位置 获取 源文件行列位置
    originalPositionFor(rawMap, lineno, colno) {
        const decodedMap = decode(rawMap)
        const lineInfo = this.decodedMap[line]
        if (!lineInfo) throw Error(`不存在该行信息:${line}`)
        const columnInfo = lineInfo[column]
        for (let i = lineInfo.length - 1; i > -1; i--) {
            const seg = lineInfo[i]
        // fwz: 不能用全等(===)匹配列号:因为输入列不一定是VLQ编码记录的位置
            if(seg[0] <= column){
              const [column, sourceIdx, origLine, origColumn] = seg;
              const source = rawMap.sources[sourceIdx]
              const sourceContent = rawMap.sourcesContent[sourceIdx];
              // 即可获得lineno, colno对应的位置是 `source`文件`sourceContent`内容的 第`origLine+1`行, 第`origColumn+1`列(行号=数组位置+1)
              // 通过 @babel/code-frame 的 codeFrameColumns 可清楚展示具体代码内容,下图所示
              const result = codeFrameColumns(sourceContent, {
               start: {
                 line: origLine+1,
                 column: origColumn+1
              }, {forceColor:true})
              return {
                source,
                line: origLine,
                column: origColumn,
                frame: result
          throw new Error(`不存在该行列号信息:${line},${column}`)
    

    展示结果 @babel/code-frame

    source-map 库实现

    实际上以上过程,source-map库已实现并封装,可通过直接应用调用API进行解析

    source-map使用(consume)
    // 官网例子
    const sourceMap = require('source-map')
    const {SourceMapConsumer} = sourceMap
    SourceMapConsumer.with(rawSourceMap, null, consumer => {
        console.log('consumer: ', consumer)
          // { source: 'http://example.com/www/js/two.js',
          //   line: 2,
          //   column: 10,
          //   name: 'n' }
        consumer.originalPositionFor({line: 19, column: 9})
        // mappings属性各行中的segment位置对应信息
        consumer.eachMapping(function(m) {
                  generatedLine: 21,
                  generatedColumn: 0,
                  lastGeneratedColumn: null,
                  source: 'webpack://utils/src/index.js',
                  originalLine: 12,
                  originalColumn: 2,
                  name: null
            console.log(m)
    
    source-map生成

    source-map的生成过程贯穿整个js重新生成的过程:通过解析器(如jison库)将JavaScrip解析为抽象语法树(AST),在遍历AST生成压缩代码的同时生成存储关联信息的source map

    source-map库提供了两级接口:

    (1)高层接口SourceNode :

    function compile(ast) {
      switch (ast.type) {
        case "BinaryExpression":
          return new SourceNode(ast.location.line, ast.location.column, ast.location.source, [
            compile(ast.left),
            " + ",
            compile(ast.right)
        case "Literal":
          return new SourceNode(ast.location.line, ast.location.column, ast.location.source, String(ast.value));
        // ...
        default:
          throw new Error("Bad AST");
    const ast = parse("40 + 2", "add.js");
    // { code: '40 + 2', map: [object SourceMapGenerator] }
    console.log(
      compile(ast).toStringWithSourceMap({
        file: "add.js"
    

    (2)底层接口SourceMapGenerator : 还需提供生成后(generated)的位置信息

    var map = new SourceMapGenerator({ file: "source-mapped.js" })
    map.addMapping({
      generated: {
        line: 10,
        column: 35
      source: "foo.js",
      original: {
        line: 33,
        column: 2
      name: "christopher"
    // '{"version":3,"file":"source-mapped.js","sources":["foo.js"],"names":["christopher"],"mappings":";;;;;;;;;mCAgCEA"}'
    console.log(map.toString());
    

    SourceMap 全链路支持

    在多类型多文件加载的情况下会更复杂,如将A编译生成B with SourceMap, 再将B进一步编译为C with Source Map2, 如何将source map 合并,并从C反解回A呢?

    幸运的是,部分工具在转换bundle时提供响应的接口,以Rollup 为例

    import ts from 'typescript';
    import { minify } from 'terser';
    import babel from '@babel/core';
    import fs from 'fs';
    import remapping from '@ampproject/remapping';
    const code = `
    const add = (a,b) => {
      return a+b;
    const transformed = babel.transformSync(code, {
      filename: 'origin.js',
      sourceMaps: true,
      plugins: ['@babel/plugin-transform-arrow-functions']
    console.log('transformed code:', transformed.code);
    console.log('transformed map:', transformed.map);
    const minified = await minify(
        'transformed.js': transformed.code
        sourceMap: {
          includeSources: true
    console.log('minified code:', minified.code);
    console.log('minified map', minified.map);
    const mergeMapping = remapping(minified.map, (file) => {
      if (file === 'transformed.js') {
        return transformed.map;
      } else {
        return null;
    fs.writeFileSync('remapping.js', minified.code);
    fs.writeFileSync('remapping.js.map', minified.map);
    //fs.writeFileSync('remapping.js.map', JSON.stringify(mergeMapping));
    

    该小节全沿用ByteDance Web Infra, 请看原文
    【注】原文Error Stack Trace分析很开眼界!

    git库/工具

    source-map
    inline 模式映射校验

    article

    阮一峰 JavaScript Source Map 详解
    Introduction to JavaScript Source Maps
    Source Map Revision 3 Proposal