• Source map 超详细学习攻略_番茄出品


    Source map 学习攻略_番茄出品

    start

    • 事情的起因:番茄我最近在学习如何调试 JavaScript,发现高频出现 Source map,但是我对它又不是很了解,经常造成学习上的阻塞。
    • 随即就开始深入学习 Source map, 学习完毕,到如今写一篇博客记录收获。

    一、从源码转换讲起

    随着时代的发展,JavaScript 脚本正变得越来越复杂。大部分源码(尤其是各种函数库和框架)都要经过转换,才能投入生产环境。

    常见的源码转换,主要是以下三种情况:

    (1)压缩,减小体积。比如 jQuery 1.9 的源码,压缩前是 252KB,压缩后是 32KB。
    
    (2)多个文件合并,减少 HTTP 请求数。
    
    (3)其他语言编译成 JavaScript。
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这三种情况,都使得线上实际运行的代码不同于开发时的代码,调试代码排查问题就变得困难重重。

    通常,JavaScript 的解释器会告诉你,第几行第几列代码出错了。但是,这对于转换后的代码毫无用处。举例来说,jQuery@1.9 压缩后只有 3 行,每行 3 万个字符,所有内部变量都改了名字。你看着报错信息,感到毫无头绪,根本不知道它所对应的原始位置。

    这就是 Source map 想要解决的问题。

    编译后的 Vue.js 的源码
    在这里插入图片描述

    二、什么是 Source map

    简单来说 Source map 就是一个存储信息的文件,里面储存着位置信息。

    • Source map 英文释义:源程序映射。
    • 位置信息:转换后的代码 对应的 转换前的代码 位置映射关系。

    有了 Source map,就算线上运行的是转换后的代码,调试工具中也可以直接显示转换前的代码。这极大的方便了我们开发者调试和排错。

    三、如何使用 Source map

    只要在转换后的代码尾部,加上一行如下代码即可。

    //# sourceMappingURL=main.js.map
    
    • 1

    注意

    • = 后的名称,依据对应 map 文件名定义;
    • map 文件可以放在网络上,也可以放在本地;

    四、如何生成 Source map

    4.1 概念

    借助打包工具,在打包编译生成目标代码的同时,生成 Source map(也就是目标代码和源代码的映射关系文件)。

    4.2 演示

    我用比较常见的打包工具:webpack,来演示一下如何生成 Source map。

    NodeJs 请自行安装,版本需大于 8。

    1. 创建一个 main.js 文件

    alert('tomato')
    
    • 1

    2. 初始化项目 + 安装依赖

    npm init -y
    
    npm i webpack@5 webpack-cli -D
    
    • 1
    • 2
    • 3

    3. 创建一个 webpack.config.js 配置文件

    const path = require('path')
    
    module.exports = {
      // 1.入口文件  从那个文件打包入口文件(相对路径)
      entry: './main.js',
    
      // 2.输出内容
      output: {
        filename: 'main.js', // 一个是文件名
        path: path.resolve(__dirname, 'dist'), // 一个是输出路径(绝对路径)
      },
    
      // 3. 加载器
      // module: {
      //   rules: [],
      // },
    
      // 4. 插件
      // plugins: [],
    
      // 5. 配置
      devtool: 'source-map',
    
      // 6. 模式 // development
      mode: 'production',
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    4. 目前的文件结构

    在这里插入图片描述

    5. 开始打包

    npx webpack
    
    • 1

    注意事项:

    1. 示例中 webpack 使用的版本为 webapck@5
    2. 在 webpack 中,devtool 可以配置的属性值有很多,本次演示就以 source-map 为例。(其他属性值后续会做讲解)

    6. 输出:

    执行完上述命令后,会生成一个 dist 文件夹,其中有两个文件:

    1. 目标文件:main.js
    2. Source map 文件:main.js.map

    五、Source map 文件介绍

    5.1 文件结构

    就以我们上述案例生成的 main.js.map 为例,我们来了解一下,它的内容结构是怎样的。

    {
      // version:Source map 的版本,目前为 3。
      "version": 3,
    
      // file:转换后的文件名。
      "file": "main.js",
    
      // sourceRoot:转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空。
      "sourceRoot": "",
    
      // sources:转换前的文件。该项是一个数组,表示可能存在多个文件合并。
      "sources": ["webpack://app/./main.js"],
    
      // sourcesContent:原始代码
      "sourcesContent": ["alert('tomato')\r\n"],
    
      // names:转换前的所有变量名和属性名。
      "names": ["alert"],
    
      // mappings:记录位置信息的字符串,下文详细介绍。
      "mappings": "AAAAA,MAAM"
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    Source map 文件类似 JSON 格式,它存在 7 个属性。

    以下是对每个属性的解释:

    1. version:Source map 的版本,目前为 3。

      • Source map 也是从无到有逐渐发展过来的。对比新旧的版本,它们存储信息的编码方式存在差异。
      • 目前主流的版本为 3, 它采用的编码方式是 Base64 VLQ ,对标历史版本,文件体积精简很多。
    2. file:转换后的文件名。

    3. sourceRoot:转换前的文件所在的目录。

      如果与转换前的文件在同一目录,该项为空。

    4. sources:转换前的文件。

      该项是一个数组,表示可能存在多个文件合并成一个目标文件。

    5. sourcesContent:原始代码

      sourcesContent 属性会存储对应的源代码。

    6. names:转换前的所有变量名和属性名。

    7. mappings:记录位置信息的字符串,。

      存储位置信息的属性,下文详细介绍。

    5.2 mappings 介绍

    5.2.1 mappings 三个特点

    mappings 本身是一个字符串,其中有三个特点:

    1. 分号:每一个分号对应转换后源码的一行。
    2. 逗号:每个逗号对应转换后代码的一个位置。
    3. 字符:逗号或者分号之间的字符是以 Base64 VLQ 编码规则存储的位置信息。

    举例来说,假定 mappings 属性的内容如下:

    "mappings":"AAAAA,BBBBB;CCCCC"
    
    • 1

    就表示:

    1. 转换后的代码有两行。
    2. 第一行有两个位置,第二行有一个位置。
    5.2.2 字符的五个位置?

    例如 AAAAA, 它对应那些信息呢?

    • 第一位,表示这个位置在(转换后的代码的)的第几列。

    • 第二位,表示这个位置属于 sources 属性中的哪一个文件。

    • 第三位,表示这个位置属于转换前代码的第几行。

    • 第四位,表示这个位置属于转换前代码的第几列。

    • 第五位,表示这个位置属于 names 属性中的哪一个变量。

    5.2.3 字符解析?

    单纯存储 A 是没什么意义,我们重点关注 A 代表什么?

    Source map 使用的是 Base64 VLQ 编码规则,具体的规则后续会写,目前直接演示转换结果:

    转换演示:

    示例一:
    AAAAA
    [0,0,0,0,0]
    
    
    示例二:
    ubAAAA
    [439,0,0,0,0]
    
    
    示例三:
    MAOA,MALAA,QAAQC,IAAI,GAKN,K
    [6,0,7,0], [6,0,-5,0,0], [8,0,0,8,1], [4,0,0,4], [3,0,5,-6], [5]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    转换方法:

    用工具解析 点击这里

    注意事项:

    1. 注意,并不是一个字母对应一个位置。是一组字母表示一个位置。例如 ubAAAA 解析为: [439,0,0,0,0]

    2. 我自己学习到这里的时候,想到这么一个问题:所有的字母加特殊符号,就算考虑大小写,它们能记录的情况肯定是有限的,而任意一个源码,一行就有上万行。

      很显然一个字母对应一个位置,并不能记录上万行的内容,所以这里是一组字母对应一个位置。

    5.2.4 真实案例讲解映射关系

    上方讲述了一些概念知识,但是概念不是很好理解,至少我是有些绕的,现在用实际案例去演示一下如何解析的。

    • 注意,为了节省空间,Source map 中使用的 Base64 VLQ 编码除了第一的字符外,剩余的计算都是相对位置的计算。

    • 也就是相对于上次记录的位置的偏移量。

    5.2.4.1 案例一:

    源码:

    alert('tomato')
    
    • 1

    打包输出的代码:

    alert('tomato')
    //# sourceMappingURL=main.js.map
    
    • 1
    • 2

    打包输出的 Source map 文件:

    {
      "version": 3,
      "file": "main.js",
      "mappings": "AAAAA,MAAM",
      "sources": [
        "webpack://app/./main.js"
      ],
      "sourcesContent": [
        "alert('tomato')\r\n"
      ],
      "names": [
        "alert"
      ],
      "sourceRoot": ""
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    解析:

    1. 输出的 'mappings':
    "AAAAA,MAAM"
    
    2. 对应的数字为:
    [0,0,0,0,0], [6,0,0,6]
    
    3. 对应的含义分别为:
    转换后代码的第0列,第0个引入的文件,转换前的第0行,转换前第0列,第0个变量; 对应的就是 alert
    转换后代码的第5列,第0个引入的文件,转换前的第0行第0列。没有变量;          对应的就是 ('tomato')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    5.2.4.2 案例二:

    这个案例在案例一的基础上,添加一行注释,对比一下差异。 重点看一下源码

    源码:

    // lazy-tomato
    alert('tomato')
    
    • 1
    • 2

    打包输出的代码:

    alert('tomato')
    //# sourceMappingURL=main.js.map
    
    • 1
    • 2

    打包输出的 sourcemap 文件:

    {
      "version": 3,
      "file": "main.js",
      "mappings": "AACAA,MAAM",
      "sources": [
        "webpack://app/./main.js"
      ],
      "sourcesContent": [
        "// lazy-tomato\r\nalert('tomato')\r\n"
      ],
      "names": [
        "alert"
      ],
      "sourceRoot": ""
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    解析:

    1. 输出的 'mappings':  (历史的是 "AAAAA,MAAM")
    "AACAA,MAAM"
    
    2. 对应的数字为: (历史的是 [0,0,0,0,0], [6,0,0,6])
    [0,0,1,0,0], [6,0,0,6]
    
    3. 对应的含义分别为:
    转换后代码的第0列,第0个引入的文件,*转换前的第1行*,转换前第0列,第0个变量; 对应的就是 alert
    转换后代码的第5列,第0个引入的文件,转换前的第0行第0列。没有变量;            对应的就是 ('tomato')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    可以看到对比案例一,案例二的差异:转换前的代码行数加一了。

    5.2.4.3 案例三:

    源码:

    function fn(a, b) {
      return a + b
    }
    console.log(fn(1, 4))
    
    • 1
    • 2
    • 3
    • 4

    打包输出的代码:

    console.log(5)
    //# sourceMappingURL=main.js.map
    
    • 1
    • 2

    打包输出的 Source map 文件:

    {
      "version": 3,
      "file": "main.js",
      "mappings": "AAGAA,QAAQC,IAFCC",
      "sources": [
        "webpack://app/./main.js"
      ],
      "sourcesContent": [
        "function fn(a, b) {\r\n  return a + b\r\n}\r\nconsole.log(fn(1, 4))\r\n"
      ],
      "names": [
        "console",
        "log",
        "a"
      ],
      "sourceRoot": ""
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    解析

    1. 输出的 'mappings':
    "AAGAA,QAAQC,IAFCC"
    
    2. 对应的数字为:
    [0,0,3,0,0], [8,0,0,8,1], [4,0,-2,1,1]
    
    3. 对应的含义分别为:
    转换后代码的第0列,第0个引入的文件,转换前的第3行,转换前第0列,第0个变量;  对应的就是 console
    转换后代码的第8列,第0个引入的文件,转换前的第0行,转换前第8列。第1个变量;  对应的就是 log
    转换后代码的第4列,第0个引入的文件,转换前的第-2行,转换前第1列。第1个变量;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    会发现第三个数据无法对应,我理解它对应的是函数 fn 的执行结果,也就是 5

    • 数据无法对应,网上猜想的解释:为了加快编译速度,Source map 对于一些语法是不会计算偏移的,而是直接返回之前的偏移位置。准确的原因,待确定

    六、拓展

    6.1 Base64 VLQ 编码规则

    具体的 Base64 VLQ 编码规则 点击这里

    6.2 webpack 中 devtool 的多种配置

    官方文档地址点击这里

    查看官方文档,可以了解到,devtools 的配置项可以达到 10-20 种左右的情况。其实并不需要记住那么多情况,本质上是一些配置项的排列组合。

    配置项如下:

    • source-map:生成 sourcemap 文件,可以配置 inline,会以 dataURL 的方式内联,可以配置 hidden,只生成 sourcemap,不和生成的文件关联;
    • hidden:是否会在打包后文件的末尾增加 sourceURL;
    • inline:不产生独立的 .map 文件,把 source-map 的内容以 dataURI 的方式追加到目标文件末尾;
    • eval:浏览器 devtool 支持通过 sourceUrl 来把 eval 的内容单独生成文件,还可以进一步通过 sourceMappingUrl 来映射回源码,webpack 利用这个特性来简化了 sourcemap 的处理,可以直接从模块开始映射,不用从 bundle 级别。
    • cheap:只映射到源代码的某一行,不精确到列,可以提升 sourcemap 生成速度;
    • nosources:不生成 sourceContent 内容,可以减小 sourcemap 文件的大小;
    • module: sourcemap 生成时会关联每一步 loader 生成的 sourcemap,可以映射回最初的源码;
    • 具体的组合效果,可自行尝试。
    • webpack的 devtool 配置项排列顺序,规则:^(inline-|hidden-|eval-)?(nosources-)?(cheap-(module-)?)?source-map$

    6.3 本地调试线上代码

    我以一个 Vue2 项目为例:

    1. 在一个普通 Vue2 项目中,添加一行报错;

      在这里插入图片描述

    2. npm run build 打包一下我们的工程。

    3. 将打包输出的 dist 文件夹中的 .map 文件,剪切出来存放到本地。然后上传 dist 其他文件到服务器上,用以模拟调试线上代码的情况。

    • 这里生成 Source map 的配置,可以自己灵活配置。
    1. 查看线上代码:
    • 报错: 在这里插入图片描述
    • 对应源码: 在这里插入图片描述
    1. 手动添加 Source map 文件
    • 右键 - Add source map;
    • 通过 file 协议选择本地的 map 文件,先在浏览器地址栏中输入确保可以访问到。
    • 文件路径示例例:file:///C:/Users/17607/Desktop/study/chunk-vendors.7723b084.js.map
    • 可以直接将 Source map 文件拖拽到谷歌浏览器中,即可得到这个文件路径
    1. 回到控制台发现已经映射到源码了
    • 已经映射到源码了 在这里插入图片描述

    6.4 Vue 中如何修改 webpack 的 devtool 配置项

    Vue 项目,如果使用的是 webpack作为打包工具,想要自定义 devtool 配置,可用如下方式:

    官方解释点击这里

    // vue.config.js
    
    module.exports = {
      chainWebpack: (config) => {
        config
          .when(process.env.NODE_ENV === 'development, (config) => config.devtool('hidden-source-map'))
      },
    }
    
    // 2. 当然还有一个属性 productionSourceMap,可以设置是否生成 Source map。
    // productionSourceMap
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    6.5 其他打包工具

    上述演示生成 Source map,使用的是 webpack 来演示的,了解一下其他打包工具,如何生成 Source map;

    rollup

    1.新建一个打包配置文件: rollup.config.js

    module.exports = {
      input: './main.js',
      output: {
        file: './bundle.js',
        format: 'cjs',
        sourcemap: true,
      },
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    2.安装依赖,开始打包

    npm i -g  rollup
    
    rollup -c
    
    # 注意一下,注意 NodeJs 版本不要太低。
    
    • 1
    • 2
    • 3
    • 4
    • 5

    6.5 reverse-sourcemap

    提到这么一个 npm 依赖,是因为很久之前,番茄我有一次电脑进水了,导致本地 git 仓库丢失了部分 commit 的记录。(大白话来说,代码丢失了

    • 丢失的代码行数还是比较多的,一整天的工作成果;
    • 在无法找回源代码的情况下,我发现最新 Source map 文件还存在。最后我通过这个工具,反编译 Source map 文件,找回了大部分我丢失的代码。

    使用案例:

    # 1. 全局安装此依赖
    npm install --global reverse-sourcemap
    
    # 2. 指定编译文件后输出的文件目录,指定编译什么文件;
    reverse-sourcemap --output-dir outDir main.js.map 
    
    • 1
    • 2
    • 3
    • 4
    • 5

    阅读源码:

    看一下 reverse-sourcemap 的源码,源码就一个 js 文件,如下:

    reverse-sourcemap/index.js

    'use strict';
    
    const path = require('path');
    const sourceMap = require('source-map');
    
    /**
     * @param {string} input Contents of the sourcemap file
     * @param {object} options Object {verbose: boolean}
     *
     * @returns {object} Source contents mapped to file names
     */
    module.exports = (input, options) => {
    
      const consumer = new sourceMap.SourceMapConsumer(input);
    
      return consumer.then((response) => {
        let map = {};
        if (response.hasContentsOfAllSources()) {
          if (options.verbose) {
            console.log('All sources were included in the sourcemap');
          }
          debugger
          console.log(response)
          response.sources.forEach((source) => {
            const contents = response.sourceContentFor(source);
            map[path.normalize(source).replace(/^(\.\.[/\\])+/, '').replace(/[|\&#,+()?$~%'":*?<>{}]/g, '').replace(' ', '.')] = contents;
          });
        } else if (options.verbose) {
          console.log('Not all sources were included in the sourcemap');
        }
        return map;
      }).catch((e) => {
        console.log(e);
        return {};
      });
    };
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    小结:
    浏览了一下 reverse-sourcemap 的源码,使用了 source-map 提供的一个对象,来实现的文件转换。

    6.6 source-map

    待补充…

    七、参考和学习过的文章

    1. 阮一峰_JavaScript Source Map 详解
    2. zxg_神说要有光_彻底搞懂 Webpack 的 sourcemap 配置原理
    3. SourceMap解析
    4. VLQ & Base64 VLQ 编码方式的原理及代码实现

    感谢ღ( ´・ᴗ・` )

    end

    • 以上就是番茄我对 Source map 的收获总结了。
    • 希望自己越来越强,加油!
  • 相关阅读:
    测试进阶必备,这5款http接口自动化测试工具不要太香~
    表关联查询
    Python实现从Labelme数据集中挑选出含有指定类别的数据集
    【Git 学习笔记】第三章 分支、合并及配置项(下)
    MVCC及实现原理
    Go--切片,append
    Anaconda 的一些配置
    [云原生.docker安装]Centos7安装docker环境
    拥抱 Spring 全新 OAuth 解决方案
    网络安全-渗透测试
  • 原文地址:https://blog.csdn.net/wswq2505655377/article/details/127721558