浅谈sourcemap原理

1,136 阅读9分钟

1.sourcemap是什么?

sourcemap是一个文本信息,存储转化前后代码的位置信息。例如,A.js经过压缩后变成了B.js,sourcemap就记录着B.js的每个位置在A.js的位置。

2.sourcemap如何记录位置信息

首先,在编译后代码的末尾加上一行代码//# sourceMappingURL=xxx,xxx是.map文件的路径。以.map为后缀的文本就是sourcemap。有了这行代码,浏览器就会找到sourcemap进行解析。

.map文件的内容是一个json,如下图所示。

json中的键值对含义如下:

  • version:sourcemap的版本号,目前最新的版本号为3
  • sources:源码文件路径
  • names:源码所有变量名和属性名
  • mappings:记录位置信息的base64-vlq编码
  • file:转化后的文件名
  • sourcesContent:源码
  • sourceRoot:源码所在的目录

3.mappings组成

实际上mappins一共分为3层

  • 第一层由分号(;)分割:分号之间代表一行的位置信息
  • 第二层由逗号(,)分割:逗号之间代表某一行中的某一列的位置信息
  • 第三层有最多五个base64-vlq编码组成

3.1第三层的五个编码代表着什么含义?为什么要用base64-vlq编码?

下文base64-vlq编码简写为vlq编码。

举个例子

上图中,index.js压缩后的代码为index.min.js。红框中的fruit变量在压缩后变为a变量,那么两者的位置信息是转化后代码的第1行、第53列对应index.js的第5行、第17列的fruit变量。那么接下我们把这些信息转化为mappings中的第三层。

这个位置信息用代码最简洁的表示方式为1,53,index.js,5,17,fruit

  1. 其中,行数可以由分号(;)表示。index.js和fruit可以由sourcemap中sources和names表示,因为他们是数组,只需要记录索引即可。假设index.js在sources中的索引为1,fruit在names中的索引为3。那么位置信息可以简化为53,1,5,17,3
  2. 在代码中的行数、列数可能达到成千上百行,索引也可能达到上百个,用相对值存储体积会较大。而与上一个位置信息相减会大幅度地减少体积。假如上一个位置信息为51,3,4,15,7,那么位置信息可以简化为2,-2,1,2,-4
  3. 2,-2,1,2,-4中可以看出分隔符,和负号-浪费了很多空间。vlq编码能把分隔符和负号去除。经过vlq编码后,位置信息为EFCEJ。经过压缩后,位置信息只占了5个字节。

经过上面的例子我们可以看出,第三层的五个编码存放以下信息,这些信息都是相对值,相对于上一个位置信息。

4.vlq编码的原理

4.1特殊的符号位和连续标识位

看到这里就会有小伙伴问,为啥vlq编码能去除负号和分隔符呢?实际上vlq编码的二进制形式就预留了符号位和连续标识位来表示负数和分隔符。

上图是vlq编码49C的二进制形式。以每6个二进制作为一组进行分解,最终分为三组,分别是111000 111101 000010

  1. 第一组的最后一个二进制是符号位,0代表正数,1代表负数。图中的第一组111000的最后一位是0,所以这个vlq编码是正数。
  2. 每一组第一个二进制是连续符号位。这个位是有规律的,最后一组为0,其他组为1。解析vlq编码时,会读取连续位,读到0就结束读取,这样就能代替分割符了。

4.2如何生成vlq编码

如上图所示,vlq编码过程分为三步,以1500作为例子:

  1. 添加符号位:1500的二进制是10111011100,左移一位,最后一位补上符号位。因为1500是整数,符号位是0,结果为101110111000
  2. 添加连续标识位:把101110111000从后以5个二进制为一组进行分组,不足5个就在前面补0,得到三组00010 11101 11000。把三个组逆序,每个组右移一位,补上连续标识位,结果为111000 111101 000010
  3. 每个组转化为base64编码:111000 111101 000010的base64形式为49C

下面是vlq编码的一段源码

var charToInteger = {};
var integerToChar = {};
// base64
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
    .split('')
    .forEach(function (char, i) {
    charToInteger[char] = i;
    integerToChar[i] = char;
});
function encode(num) {
    var result = '';
    if (num < 0) {
        num = (-num << 1) | 1;
    }
    else {
        num <<= 1;
    }
    do {
        var clamped = num & 31;
        num >>>= 5;
        if (num > 0) {
            clamped |= 32;
        }
        result += integerToChar[clamped];
    } while (num > 0);
    return result;
}

webpack中使用sourcemap

在webpack.config.js中配置devtool即可使用SourceMap功能,详细的配置项可以参考官方文档

module.exports = {
    // 省略其他配置项
    devtool: 'source-map',
}

官方提供的配置项很多,但它们是有规律的!它们实际上是由几个关键字排列组合而成的,形式如下[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map

  1. eval:内联sourcemap。把每个模块放入eval中,每一个eval拥有一行base64格式的sourcemap。能提高重新构建效率。
(()=>{
"use strict";
var __webpack_modules__={992:()=>{
    eval('
        //省略的代码
        //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIj
    ')
}},__webpack_exports__={};__webpack_modules__[992]()})();
  1. inline:每一个bundle末尾有一行base64格式的sourcemap
// 省略的代码
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInd
  1. cheap:只定位到行,不定位到列。但假如用了loader,就会定位到经过loader处理后的代码。能提高构建效率。
  2. cheap-module:只定位到行,不定位到列。与cheap的区别是用了loader会定位到源码。提高构建效率。
  3. hidden:能编译出.map文件。但bundle的末尾不添加sourcemap的路径,错误会定位到编译后的代码。
  4. nosources:浏览器会隐藏源代码(把sourcemap的sourcesContent置空),错误定位不会显示源码或编译后的代码。

在日常开发中,我们可以根据项目的特点选择合适的配置。个人推荐以下的配置:

  • 开发环境

    • eval-cheap-module-source-map:既能满足错误定位的需求,又能保持较好的构建和重构建效率
  • 生产环境

    • hidden-source-map:因为不会在bundle的末尾添加sourcemap的路径,就不会泄露源码。并且能把生成sourcemap,可以辅助定位线上问题

如何利用sourcemap定位线上错误

在线上的页面难免会存在bug,因为页面经过打包压缩,根据错误堆栈信息很难定位出问题。下面我给大家提供一些思路解决这个问题。

使用webpack打包的时候,devtool配置项选择hidden-source-map。这个配置项能生成sourcemap,但不会启用sourcemap,能保证源码不泄露,同时还能拿sourcemap搞点骚操作。

下面的代码是一个sourcemap方解析的demo。通过sourcemap、报错的堆栈信息,找出报错的源码。

const sourceMap = require('source-map');
const StackTracey = require('stacktracey');
​
​
const fs = require('fs');
const map = fs.readFileSync('./main.js.map').toString();
const consumer = new sourceMap.SourceMapConsumer(map)
​
// 解析错误堆栈信息
const tracey = new StackTracey('');
tracey.items.forEach(stackinfo => {
    getOrigin(stackinfo.line, stackinfo.column)
})
​
function getOrigin(line, column) {
    const {
        source, // 源文件名
        line: originline, // 源码行数
        column: origincolumn, // 源码列数
        name // 变量名
    } = consumer.originalPositionFor({line, column}); // 反解析sourcemap
    const code = consumer.sourceContentFor(source).split('\n')[originline - 1]; // 解析出来报错的源码
    console.log(`报错的文件:${source};行数:${originline};列数:${origincolumn};涉及的代码:${code};变量名:${name}`);
}
​

反解析的过程如下:

  1. 报错的堆栈信息是有一定的规律。从第二行开始,每一行会包含错误代码所在的文件路径、行数和列数。代码中使用stacktracey从堆栈信息中提取行数和列数。stacktracey通过正则匹配从堆栈信息中提取信息;
SyntaxError: Identifier 'line' has already been declared
    at tracey.items.forEach.stackinfo (/reverse/index.js:10:5)
    at Array.forEach (<anonymous>)
    at Object.<anonymous> (/reverse/index.js:9:14)
  1. 拿到行数和列数后,调用source-maporiginalPositionFor方法进行位置信息反解析,获取源文件名、源码行数、源码列数和变量名;
  2. 最后通过source-mapsourceContentFor方法获取源码,并根据源码列数把错误列的代码找出来。

originalPositionFor位置信息反解析原理

代码中使用的source-map版本为0.5.6。最新版的核心代码是以WebAssembly格式保存的,源码难以阅读,所以采用了用js编写的老版本。

上一小节提过,调用originalPositionFor时,只需要传入转化后代码的行数、列数和sourcemap,就会得到源码的相关信息。

originalPositionFor位置信息反解析分为两个步骤:

  1. 上文提到,mappings中是由三层vlq编码组成,那么第一步就是把vlq编码解码,并且计算出绝对值,还原成以下形式。最后进行排序。
[
  {
    generatedLine: 1, // 转化后代码的行数
    generatedColumn: 13, // 转化后代码的列数
    source: 0, // sources中第几个源码的路径
    originalLine: 2, // 源码中的行数
    originalColumn: 0, // 源码中的列数
    name: 0 // names中第几个变量
  },
  {
    generatedLine: 1, 
    generatedColumn: 13,
    source: 0,
    originalLine: 2,
    originalColumn: 0,
    name: 0
  }
  // ...
]

源码如下。

BasicSourceMapConsumer.prototype._parseMappings =
  function SourceMapConsumer_parseMappings(aStr, aSourceRoot) {
    var generatedLine = 1;
    var previousGeneratedColumn = 0;
    var previousOriginalLine = 0;
    var previousOriginalColumn = 0;
    var previousSource = 0;
    var previousName = 0;
    var length = aStr.length;
    var index = 0;
    var cachedSegments = {};
    var temp = {};
    var originalMappings = [];
    var generatedMappings = [];
    var mapping, str, segment, end, value;
​
    while (index < length) {
      // ;为一行,需要把行数加一,得到源码的行数
      if (aStr.charAt(index) === ';') {
        generatedLine++;
        index++;
        previousGeneratedColumn = 0;
      }
      else if (aStr.charAt(index) === ',') {
        index++;
      }
      else {
        mapping = new Mapping();
        mapping.generatedLine = generatedLine;
​
        // 截取编码块,最多5个编码组成,用;,区分
        for (end = index; end < length; end++) {
          if (this._charIsMappingSeparator(aStr, end)) {
            break;
          }
        }
        str = aStr.slice(index, end);
​
        segment = cachedSegments[str];
        if (segment) {
          index += str.length;
        } else {
          segment = [];
          while (index < end) {
            // vlq的解码
            base64VLQ.decode(aStr, index, temp);
            value = temp.value;
            index = temp.rest;
            segment.push(value);
          }
​
          if (segment.length === 2) {
            throw new Error('Found a source, but no line and column');
          }
​
          if (segment.length === 3) {
            throw new Error('Found a source and line, but no column');
          }
        // 因为编码块都是相对值,存在相同的编码块可能性还是有的,所以缓存下来,避免重复解码
          cachedSegments[str] = segment;
        }
​
        // 与上一个转化后列数相加,得到当前位置信息转化后列数的绝对值
        mapping.generatedColumn = previousGeneratedColumn + segment[0];
        previousGeneratedColumn = mapping.generatedColumn;
​
        if (segment.length > 1) {
          // 与上一个sources索引相加,得到当前位置信息sources索引的绝对值
          mapping.source = previousSource + segment[1];
          previousSource += segment[1];
​
          // 与上一个源码行数相加,得到当前位置信息源码行数的绝对值
          mapping.originalLine = previousOriginalLine + segment[2];
          previousOriginalLine = mapping.originalLine;
          // 因为行数是从0开始计算的,所以要加1
          mapping.originalLine += 1;
​
          // 与上一个源码列数相加,得到当前位置信息源码列数的绝对值
          mapping.originalColumn = previousOriginalColumn + segment[3];
          previousOriginalColumn = mapping.originalColumn;
​
          if (segment.length > 4) {
            // 与上一个names索引相加,得到当前位置信息names索引的绝对值
            mapping.name = previousName + segment[4];
            previousName += segment[4];
          }
        }
​
        generatedMappings.push(mapping);
        if (typeof mapping.originalLine === 'number') {
          originalMappings.push(mapping);
        }
      }
    }
​
    // 以generatedLine、generatedColumn、source、originalLine、originalColumn、name作为比较顺序,从小到大排序
    quickSort(generatedMappings, util.compareByGeneratedPositionsDeflated);
    this.__generatedMappings = generatedMappings;
​
    quickSort(originalMappings, util.compareByOriginalPositions);
    this.__originalMappings = originalMappings;
  };
  1. 采用二分查找的方式匹配位置信息。因为在第一步已经经过排序,所以能使用二分查找。二分查找能降低时间复杂度,同时能实现模糊匹配的功能,源码如下。
function recursiveSearch(aLow, aHigh, aNeedle, aHaystack, aCompare, aBias) {
  var mid = Math.floor((aHigh - aLow) / 2) + aLow;
  var cmp = aCompare(aNeedle, aHaystack[mid], true);
  if (cmp === 0) {
    return mid;
  }
  else if (cmp > 0) {
    if (aHigh - mid > 1) {
      return recursiveSearch(mid, aHigh, aNeedle, aHaystack, aCompare, aBias);
    }
    // 假如找不到相匹配的位置信息,就返回最接近的位置信息,实现模糊匹配
    if (aBias == exports.LEAST_UPPER_BOUND) {
      return aHigh < aHaystack.length ? aHigh : -1;
    } else {
      return mid;
    }
  }
  else {
    if (mid - aLow > 1) {
      return recursiveSearch(aLow, mid, aNeedle, aHaystack, aCompare, aBias);
    }
    // 假如找不到相匹配的位置信息,就返回最接近的位置信息,实现模糊匹配
    if (aBias == exports.LEAST_UPPER_BOUND) {
      return mid;
    } else {
      return aLow < 0 ? -1 : aLow;
    }
  }
}
​
// 二分查找中的aCompare方法就是这个,用于与输入的位置信息比较
function compareByGeneratedPositionsDeflated(mappingA, mappingB, onlyCompareGenerated) {
  var cmp = mappingA.generatedLine - mappingB.generatedLine;
  if (cmp !== 0) {
    return cmp;
  }
​
  cmp = mappingA.generatedColumn - mappingB.generatedColumn;
  if (cmp !== 0 || onlyCompareGenerated) {
    return cmp;
  }
​
  cmp = mappingA.source - mappingB.source;
  if (cmp !== 0) {
    return cmp;
  }
​
  cmp = mappingA.originalLine - mappingB.originalLine;
  if (cmp !== 0) {
    return cmp;
  }
​
  cmp = mappingA.originalColumn - mappingB.originalColumn;
  if (cmp !== 0) {
    return cmp;
  }
​
  return mappingA.name - mappingB.name;
}

5.参考资料