剖析 JavaScript source-map

892 阅读5分钟

简介

随着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

image-20210801172324824.png

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
vql
inline 模式映射校验

article

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