基于webpack构建项目的SourceMap(伪)最佳实践

SourceMap是什么

像C++、OC等语言的编译器,在编译的时候会生成符号文件,对外无需发布这些符号文件,而当有异常上报或本地Debug二进制文件时,可以帮助开发人员将二进制尽可能还原成源码级别(好像无法完全还原)进行调试或进行错误分析。

而前端工程中的SourceMap也是类似的功能。我们构建前端工程时会做这么多动作:

  • 各类型文件处理,如vue、jsx、less、ts等
  • es6+ 转 es5
  • 代码压缩
  • 代码混淆

经过这些处理后,代码往往被处理得面目全非了,变量名也变成了无意义的字母,感受下:

点击查看大图

被处理成这样子了,要调试是很困难的,而SourceMap就是在编译阶段由构建工具生成的源码与目标代码的对照表,所以,我们可以通过SourceMap将打包后的代码完美还原为源代码:
点击查看大图

既然SourceMap有这么强大的作用,我们必然不能将其暴露到生产环境,不然我们辛辛苦苦开发的功能会被有心人直接下载,稍加修改就改头换面上线了。这也就是为什么会有本文章的原因。至于SourceMap的原理,大家可以参考阮一峰的JavaScript Source Map 详解

webpack下的SourceMap

webpack项提供了devtool选项来配置是否需要在构建的时候生成SourceMap,需要的话生成何种SourceMap。

咦,SourceMap不就是那个什么映射文件么,怎么在webpack这边还分品种了呢?实际上,不同的人对构建速度、SourceMap对编译后代码的还原程度、SourceMap的安全问题都有不同的要求,为了满足不同人的要求,同时又降低配置难度,webpack只给devtool提供了13个单选值,开发者根据需求选择就好了(说得好轻松呀:只有13个):
img

该用哪个?我好方!我好南!

不过其实静下心来仔细看看说明,搞清三个概念,再配合你对构建速度的要求、安全问题就能很快抉择了:

以SourceMap对js文件的还原度为例

  • 打包后的代码: 就是最开始那张截图,基本无法调试
  • 生成后的代码:经webpack处理了模块化依赖、经babel等loader处理了es6+转es5的代码,但保留了模块信息
  • 转换过的代码:经babel等loader处理了es6+转es5的代码,还未处理模块化(可以看到import之类的语法)
  • 原始源代码:和我们开发中的源代码一致,还原度非常高
  • 仅限行:由于SourceMap中没有列信息,所以调试只能在行级别,无法进行单行多表达式调试

记住上面几个概念,我们就能很快通过查表确定自己需要哪种SourceMap了。而我们若想通过这些配置项名称快速决定使用哪种SourceMap,请继续看。

我们仔细对比可以发现:

  • 名字中带eval的(重新)构建速度一般比其他的快
  • 名字中带cheap的没有列信息,只能进行行调试,同类型SourceMap带cheap比不带cheap速度要快一些
  • 名字中带module都能映射原始源代码
  • 名字中带inlineeval的都不能用于生产环境
  • 还原度最高的是source-mapeval-source-maphidden-source-map

注:带inlineeval的不能用于生产环境是因为这两者生成的SourceMap是内嵌在构建完成的js代码中的,会在生产环境直接暴露源代码。

怎么选?

开发环境,追求重新构建速度,同时也要高度还原代码,可选:eval-source-mapcheap-module-eval-source-map,这二者可以自己抉择,后者速度更快、生成的包体积更小,但无法进行行内调试。

生产环境,又要安全,又要高还原度,不在乎打包速度,那么可选source-maphidden-source-map

生产环境的SourceMap

为什么在生成环境需要SourceMap呢?试想下,如果生成环境出现什么Bug,而在其他环境不容易复现,最简单的方式就是直接在生产调试,如果没有SourceMap,这个调试过程势必十分痛苦。

上面我们提到了,生产环境可选的两种配置,他们的区别在于:source-map会在构建后的js文件末尾添加类似:

//# sourceMappingURL=jquery.min.map

的注释,告知浏览器该文件对应的SourceMap在何处下载,而hidden-source-map则仅仅生成SourceMap,不会告知浏览器该去哪寻找SourceMap还原代码。这里我们面临两个问题:

  1. 选择source-map,我们的SourceMap路径会暴露在外面,如果我们的浏览器能下载,那么别人一样可以下载
  2. 选择hidden-source-map,该怎么告诉浏览器我们的SourceMap在何处呢?

针对问题2,Chrome确实提供了针对单个js文件手动导入SourceMap的方式,方式是在开发者工具的Sources面板,找到需要调试的js文件,右键代码区域,在弹出的菜单中点击Add source map...,然后填入这个文件对应的SourceMap文件地址,弊端就是:得针对构建完成的文件一个个手动添加,而且一刷新就没了,需要再次手动添加。

ps: 实际上hidden-source-map更多的场景是用于类似于sentry这样的异常监控平台,在监控到线上的异常后上传对应的SourceMap文件,精确分析错误。

针对问题1,我们可以在构建后将SourceMap上传至一个仅内网可访问的地方,这样,同样打开控制台,我们自己的人可以进行源码调试,而其他人则没办法。但是,这样就没问题了嘛?没问题说明你的日子还是太安逸啊,居然没有在休假的时候被打电话告知线上出现了一个bug,需要紧急定位一下,这时候,我们用VPN拨号回公司确实可以解决问题,那么还有没有其他方式呢?

只要努力去想,方法的数量总是高过问题的。

我这里借助gitlab来实现,相信很多公司都部署了私有的gitlab,同时为了方便小伙伴在任何地方都方便提交代码,是允许外网授权访问的。gitblab中的项目权限有一项:“内部”,也就是仅内部员工登录后可查看,这就符合我们的需求了。
图片

借力gitlab

心路历程:其实我一开始是去尝试用gitlab的代码片段(类似于GitHub的gist)来做这个事情,但是后面发现其比gist的功能要弱很多,只支持单文件,只好放弃。

依据我们的需求,webpack提供的几种SourceMap已经无法满足我们的需求了,需要通过SourceMapDevToolPlugin插件来定制需求,该插件实现了对SourceMap生成内容进行更细粒度的控制,说白了,上面预制的一些选项都可以通过该插件配置而来。

先来确定下我们的需求:

  • 完整的SourceMap
  • SourceMap地址需要以自定义地址的形式注释在js文件的末尾

SourceMapDevToolPlugin很容易达成该要求:

new webpack.SourceMapDevToolPlugin({
    append: `\n//# sourceMappingURL=[url]`,
    filename: '[file].map',
    publicPath: 'https://gitlab.xxx.com/xxxx/sourcemap/raw/dev',
    fileContext: 'js',
    include: /\.jsx?$|\.vue$/,
})

这样,我们构建完成的js文件最后都会带上诸如如下的SourceMap路径注释了:

//# sourceMappingURL=https://gitlab.xxx.com/xxxx/sourcemap/raw/dev/xxxx.map

另一边,我们还需要将map文件给提交到项目xxxx/sourcemapdev分支才行,要拿到SourceMap文件并上传,需等到资源构建完成或者webpack的整个流程完成,我们这里选择用插件的形式在资源构建完成的时候push至gitlab,先完成插件:

const path = require('path');
const fs = require('fs');

class SourcemapWebpackPlugin {
  constructor(handler) {
    if (typeof handler !== 'function')
      throw new TypeError('SourcemapWebpackPlugin构造函数参数只能是函数');
    this.handler = handler;
  }

  apply(compiler) {
    // 监听`afterEmit`事件
    compiler.hooks.afterEmit.tapAsync('SourcemapPlugin', (compilation, callback) => {
     // 取得所有构建出来的资源
      const assets = compilation.assets;
      const files = [];
      Object.keys(assets).forEach(fileName => {
         // 我们只care map文件
        if (/\.map$/.test(fileName)) {
        // 拧出文件完整路径、文件名、文件内容
          files.push({
            path: assets[fileName].existsAt,
            name: path.basename(fileName),
            // 不用到的时候不要取出来
            get content() {
              return assets[fileName].source();
            },
          });
        }
      });
      // 丢给插件使用者自行做上传处理
      this.handler(files, err => {
        // map文件清理,防止发布到线上
        files.forEach(file => fs.unlink(file.path, () => {}));
        callback(err);
      });
    });
  }
}

gitlab文件的push需要用到这个API,然后事情就简单了:

const got = require('got');
const qs = require('qs');

new SourcemapWebpackPlugin((files, callback) => {
    console.log('开始上传SourceMap...');
    const actions = files.map(file => ({
    action: 'create',
    file_path: file.name,
    content: file.content,
  }));
  const data = {
    actions,
    // 事先创建一个空白的master分支,然后在此基础上分出dev
    // 以后每次都是以master为基础,对dev进行commit
    // 配合 force: true 选项,每次构建都能清理上次提交的map文件
    force: true,
    branch: "dev",
    start_branch: "master",
    commit_message: 'commit at sourcemap',
  };
  return got
    .post(`https://gitlab.xxx.com/api/v4/projects/xxx%2F/sourcemap/repository/commits`, {
      body: qs.stringify(data, { arrayFormat: 'brackets' }),
      headers: {
        'PRIVATE-TOKEN': "<YOUR_ACCESS_TOKEN>",
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }).then(() => callback()).catch(err => callback(err.body));
    // plugin hook的callback正常情况下不能传参,传参代表失败
});

至此,全部工作完成,可以build构建试试,如果没得效果,请确认当前浏览器是否登录了gitlab,是否有权限打开xxx/sourcemap这个项目。

任何框架、方法都不是银弹,只有适合自己实际情况才是王道,并没有完美的最佳实践。

《完》