微信号:FrontDev

介绍:分享 Web 前端相关的技术文章、工具资源、精选课程、热点资讯

打包工具的配置教程见的多了,但它们的运行原理你知道吗?

2018-06-17 21:07 前端大全

(点击上方公众号,可快速关注)


作者: jrainlau

segmentfault.com/a/1190000015291911



前端模块化成为了主流的今天,离不开各种打包工具的贡献。社区里面对于webpack,rollup以及后起之秀parcel的介绍层出不穷,对于它们各自的使用配置分析也是汗牛充栋。为了避免成为一位“配置工程师”,我们需要来了解一下打包工具的运行原理,只有把核心原理搞明白了,在工具的使用上才能更加得心应手。


本文基于parcel核心开发者@ronami的开源项目minipack而来,在其非常详尽的注释之上加入更多的理解和说明,方便读者更好地理解。


1、打包工具核心原理


顾名思义,打包工具就是负责把一些分散的小模块,按照一定的规则整合成一个大模块的工具。与此同时,打包工具也会处理好模块之间的依赖关系,最终这个大模块将可以被运行在合适的平台中。


打包工具会从一个入口文件开始,分析它里面的依赖,并且再进一步地分析依赖中的依赖,不断重复这个过程,直到把这些依赖关系理清挑明为止。


从上面的描述可以看到,打包工具最核心的部分,其实就是处理好模块之间的依赖关系,而minipack以及本文所要讨论的,也是集中在模块依赖关系的知识点当中。


为了简单起见,minipack项目直接使用ES modules规范,接下来我们新建三个文件,并且为它们之间建立依赖:


/* name.js */

 

export const name = 'World'


/* message.js */

 

import { name } from './name.js'

 

export default `Hello ${name}!`


/* entry.js */

 

import message from './message.js'

 

console.log(message)


它们的依赖关系非常简单:entry.js → message.js → name.js,其中entry.js将会成为打包工具的入口文件。


但是,这里面的依赖关系只是我们人类所理解的,如果要让机器也能够理解当中的依赖关系,就需要借助一定的手段了。


2、依赖关系解析


新建一个js文件,命名为minipack.js,首先引入必要的工具。


/* minipack.js */

 

const fs = require('fs')

const path = require('path')

const babylon = require('babylon')

const traverse = require('babel-traverse').default

const { transformFromAst } = require('babel-core')


接下来,我们会撰写一个函数,这个函数接收一个文件作为模块,然后读取它里面的内容,分析出其所有的依赖项。当然,我们可以通过正则匹配模块文件里面的import关键字,但这样做非常不优雅,所以我们可以使用babylon这个js解析器把文件内容转化成抽象语法树(AST),直接从AST里面获取我们需要的信息。


得到了AST之后,就可以使用babel-traverse去遍历这棵AST,获取当中关键的“依赖声明”,然后把这些依赖都保存在一个数组当中。


最后使用babel-core的transformFromAst方法搭配babel-preset-env插件,把ES6语法转化成浏览器可以识别的ES5语法,并且为该js模块分配一个ID。


let ID = 0

 

function createAsset (filename) {

  // 读取文件内容

  const content = fs.readFileSync(filename, 'utf-8')

 

  // 转化成AST

  const ast = babylon.parse(content, {

    sourceType: 'module',

  });

 

  // 该文件的所有依赖

  const dependencies = []

 

  // 获取依赖声明

  traverse(ast, {

    ImportDeclaration: ({ node }) => {

      dependencies.push(node.source.value);

    }

  })

 

  // 转化ES6语法到ES5

  const {code} = transformFromAst(ast, null, {

    presets: ['env'],

  })

 

  // 分配ID

  const id = ID++

 

  // 返回这个模块

  return {

    id,

    filename,

    dependencies,

    code,

  }

}


运行createAsset('./example/entry.js'),输出如下:


{ id: 0,

  filename: './example/entry.js',

  dependencies: [ './message.js' ],

  code: '"use strict";nnvar _message = require("./message.js");nnvar _message2 = _interopRequireDefault(_message);nnfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }nnconsole.log(_message2.default);' }


可见entry.js文件已经变成了一个典型的模块,且依赖已经被分析出来了。接下来我们就要递归这个过程,把“依赖中的依赖”也都分析出来,也就是下一节要讨论的建立依赖关系图集。


3、建立依赖关系图集


新建一个名为createGragh()的函数,传入一个入口文件的路径作为参数,然后通过createAsset()解析这个文件使之定义成一个模块。


接下来,为了能够挨个挨个地对模块进行依赖分析,所以我们维护一个数组,首先把第一个模块传进去并进行分析。当这个模块被分析出还有其他依赖模块的时候,就把这些依赖模块也放进数组中,然后继续分析这些新加进去的模块,直到把所有的依赖以及“依赖中的依赖”都完全分析出来。


与此同时,我们有必要为模块新建一个mapping属性,用来储存模块、依赖、依赖ID之间的依赖关系,例如“ID为0的A模块依赖于ID为2的B模块和ID为3的C模块”就可以表示成下面这个样子:


{

  0: [function A () {}, { 'B.js': 2, 'C.js': 3 }]

}


搞清楚了个中道理,就可以开始编写函数了。


function createGragh (entry) {

  // 解析传入的文件为模块

  const mainAsset = createAsset(entry)

  

  // 维护一个数组,传入第一个模块

  const queue = [mainAsset]

 

  // 遍历数组,分析每一个模块是否还有其它依赖,若有则把依赖模块推进数组

  for (const asset of queue) {

    asset.mapping = {}

    // 由于依赖的路径是相对于当前模块,所以要把相对路径都处理为绝对路径

    const dirname = path.dirname(asset.filename)

    // 遍历当前模块的依赖项并继续分析

    asset.dependencies.forEach(relativePath => {

      // 构造绝对路径

      const absolutePath = path.join(dirname, relativePath)

      // 生成依赖模块

      const child = createAsset(absolutePath)

      // 把依赖关系写入模块的mapping当中

      asset.mapping[relativePath] = child.id

      // 把这个依赖模块也推入到queue数组中,以便继续对其进行以来分析

      queue.push(child)

    })

  }

 

  // 最后返回这个queue,也就是依赖关系图集

  return queue

}


可能有读者对其中的for...of ...循环当中的queue.push有点迷,但是只要尝试过下面这段代码就能搞明白了:


var numArr = ['1', '2', '3']

 

for (num of numArr) {

  console.log(num)

  if (num === '3') {

    arr.push('Done!')

  }

}


尝试运行一下createGraph('./example/entry.js'),就能够看到如下的输出:


[ { id: 0,

    filename: './example/entry.js',

    dependencies: [ './message.js' ],

    code: '"use strict";nnvar _message = require("./message.js");nnvar _message2 = _interopRequireDefault(_message);nnfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }nnconsole.log(_message2.default);',

    mapping: { './message.js': 1 } },

  { id: 1,

    filename: 'example/message.js',

    dependencies: [ './name.js' ],

    code: '"use strict";nnObject.defineProperty(exports, "__esModule", {n  value: truen});nnvar _name = require("./name.js");nnexports.default = "Hello " + _name.name + "!";',

    mapping: { './name.js': 2 } },

  { id: 2,

    filename: 'example/name.js',

    dependencies: [],

    code: '"use strict";nnObject.defineProperty(exports, "__esModule", {n  value: truen});nvar name = exports.name = 'world';',

    mapping: {} } ]


现在依赖关系图集已经构建完成了,接下来就是把它们打包成一个单独的,可直接运行的文件啦!


4、进行打包


上一步生成的依赖关系图集,接下来将通过CommomJS规范来实现加载。由于篇幅关系,本文不对CommomJS规范进行扩展,有兴趣的读者可以参考@阮一峰 老师的一篇文章《浏览器加载 CommonJS 模块的原理与实现》,说得非常清晰。简单来说,就是通过构造一个立即执行函数(function () {})(),手动定义module,exports和require变量,最后实现代码在浏览器运行的目的。


接下来就是依据这个规范,通过字符串拼接去构建代码块。


function bundle (graph) {

  let modules = ''

 

  graph.forEach(mod => {

    modules += `${mod.id}: [

      function (require, module, exports) { ${mod.code} },

      ${JSON.stringify(mod.mapping)},

    ],`

  })

 

  const result = `

    (function(modules) {

      function require(id) {

        const [fn, mapping] = modules[id];

 

        function localRequire(name) {

          return require(mapping[name]);

        }

 

        const module = { exports : {} };

 

        fn(localRequire, module, module.exports);

 

        return module.exports;

      }

 

      require(0);

    })({${modules}})

  `

  return result

}


最后运行bundle(createGraph('./example/entry.js')),输出如下:


(function (modules) {

  function require(id) {

    const [fn, mapping] = modules[id];

 

    function localRequire(name) {

      return require(mapping[name]);

    }

 

    const module = { exports: {} };

 

    fn(localRequire, module, module.exports);

 

    return module.exports;

  }

 

  require(0);

})({

  0: [

    function (require, module, exports) {

      "use strict";

 

      var _message = require("./message.js");

 

      var _message2 = _interopRequireDefault(_message);

 

      function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

 

      console.log(_message2.default);

    },

    { "./message.js": 1 },

  ], 1: [

    function (require, module, exports) {

      "use strict";

 

      Object.defineProperty(exports, "__esModule", {

        value: true

      });

 

      var _name = require("./name.js");

 

      exports.default = "Hello " + _name.name + "!";

    },

    { "./name.js": 2 },

  ], 2: [

    function (require, module, exports) {

      "use strict";

 

      Object.defineProperty(exports, "__esModule", {

        value: true

      });

      var name = exports.name = 'world';

    },

    {},

  ],

})

 

这段代码将能够直接在浏览器运行,输出“Hello world!”。


至此,整一个打包工具已经完成。


5、归纳总结


经过上面几个步骤,我们可以知道一个模块打包工具,第一步会从入口文件开始,对其进行依赖分析,第二步对其所有依赖再次递归进行依赖分析,第三步构建出模块的依赖图集,最后一步根据依赖图集使用CommonJS规范构建出最终的代码。明白了当中每一步的目的,便能够明白一个打包工具的运行原理。


最后再次感谢@ronami的开源项目minipack,其源码有着更为详细的注释,非常值得大家阅读。



【关于投稿】


如果大家有原创好文投稿,请直接给公号发送留言。


① 留言格式:
【投稿】+《 文章标题》+ 文章链接

② 示例:
【投稿】《不要自称是程序员,我十多年的 IT 职场总结》:http://blog.jobbole.com/94148/

③ 最后请附上您的个人简介哈~




觉得本文对你有帮助?请分享给更多人

关注「前端大全」,提升前端技能

 
前端大全 更多文章 Facebook 正在重构 React Native,将重写大量底层 爬虫攻防之前端策略简析 脚本语言不行?微软技术经理透露 JS 重写 Office 365 进入尾声 如何选择 Web 前端模板引擎? 一道送分题,测测你的前端功底扎不扎实
猜您喜欢 PHP程序员的技术成长规划 【互联网大讲堂】司晓:技术发展与版权保护 CSS Grid和Flexbox解决实际的布局问题 故障率降低90%,浪潮G2保障玉树电网稳定运行 监控工具:Oracle 12c Cluster Health Monitor 详解