[译] 模块打包器是什么,它是如何工作的?

原文地址:What is module bundler and how does it work? 2019.08.30,by Tan Li Hau

什么是模块打包器?

[译] 模块打包器是什么,它是如何工作的?

模块打包器(module bundler)是前端开发人员用来将 JavaScript 模块 打包成单个 JavaScript 文件的工具,以便在浏览器中执行。

现代模块打包器包括(排名不分先后):webpackrollupfuseboxparcel 等。

之所以需要模块打包器,是因为:

  • 浏览器不支持模块系统,尽管现在这并非完全正确

  • 它可以帮助你管理代码的依赖关系,按照依赖顺序为你加载模块

  • 它可以帮助你按依赖顺序加载资源文件,例如图片、CSS 等

举个例子,想象一下你正在构建一个由多个 JavaScript 文件组成的 Web 应用程序。通过 <script> 标签将 JavaScript 文件添加到 HTML 中。

<html>
  <script src="/src/foo.js"></script>
  <script src="/src/bar.js"></script>
  <script src="/src/baz.js"></script>
  <script src="/src/qux.js"></script>
  <script src="/src/quux.js"></script>
</html>

每个文件都会发起单独的 HTTP 请求,意味着为了启动应用程序需要 5 次往返请求。所以,如果能将这 5 个文件合并成 1 个文件会更好

<html>
  <script src="/dist/bundle.js"></script>
</html>

(虽然使用 HTTP/2 后,多文件请求已不再是大问题)

那么我们如何生成 dist/bundle.js 文件?

处理过程需要面临一些挑战:

  • 我们如何维护引入“文件”的顺序

    • 最好是在这些“文件”之间建立某种依赖关系
  • 我们如何避免“文件”之间的命名冲突

  • 我们如何确定打包过程中没用到的“文件”?

如果我们知道每个文件之间的关系,上面这些问题都可以得到解决。比如:

  • 哪个文件依赖了另一个文件?

  • 一个文件暴露了哪些接口?

  • 哪些被暴露的接口被其他文件使用了?

这些信息确实可以分别解决提出的挑战。因此,我们需要一种声明性方法(declarative method)来描述文件之间的关系,这就引导我们使用 JavaScript 模块系统

CommonJSES6 模块 为我们提供了一种引入依赖文件以及使用依赖文件接口的方式。

// CommonJS
const foo = require('./foo');
module.exports = bar;

// ES Modules
import foo from './foo';
export default bar;

如何打包?

通过模块系统收集的信息,我们如何将所有文件的内容打包在一起的呢?

如果你仔细观察 webpackrollup 生成的打包文件,你会注意到这两个最流行的打包工具在打包方面采取了完全不同的方案,我称它们为“webpack 方案”和“rollup 方案”。

让我们用一个例子来说明。

假设有三个文件,circle.jssquare.jsapp.js

const PI = 3.141;
export default function area(radius) {
    return PI * radius * radius;
}
export default function area(side) {
    return side * side;
}
import squareArea from './square';
import circleArea from './circle';
console.log('正方形面积: ', squareArea(5));
console.log('圆的面积: ', circleArea(5));

webpack 方案

webpack 方案的打包会是什么样子?

const modules = {
    'circle.js': function(exports, require) {
        const PI = 3.141;
        exports.default = function area(radius) {
            return PI * radius * radius;
        }
    },
    'square.js': function(exports, require) {
        const PI = 3.141;
        exports.default = function area(side) {
            return side * side;
        }
    },
    'app.js': function(exports, require) {
        const squareArea = require('./square.js').default;
        const circleArea = require('./circle.js').default;
        console.log('正方形面积: ', squareArea(5));
        console.log('圆的面积: ', circleArea(5));
    }
}

webpackStart({
    modules,
    entry: 'app.js'
});

为了方便说明,我在源代码基础上做了一些小修改

你会注意到的第一件事是“模块映射”(变量 modules)。它是一个将模块名映射到模块本身的字典。 “模块映射”就像注册表,通过 添加入口和导入依赖(entries) 就能轻松注册模块。

其次,每个模块都被一个函数包装。该函数模拟了模块作用域,在其中声明的所有内容都在模块作用域范围内。该函数称为“模块工厂函数”(module factory function)。模块工厂函数接受几个参数,以允许该模块导出其接口,并在其他模块中引用。

第三,应用程序通过 webpackStart 启动,这是将所有内容粘合在一起的函数。该函数本身通常被称为“运行时”,是打包文件中最重要的部分。它使用“模块映射”和入口模块来启动应用程序。

function webpackStart({ modules, entry }) {
    const moduleCache = {};
    const require = moduleName => {
        // 如果在缓存中,则返回缓存版本
        if (moduleCache[moduleName]) {
            return moduleCache[moduleName];
        }
      
        const exports = {};
        // 这将防止循环依赖中的无限 "require" 循环
        moduleCache[moduleName] = exports;
        
        // 首次 "require" 时,执行模块代码
        // 导出的内容将被分配给 "exports"
        modules[moduleName](exports, require);
      
        // 返回模块的导出内容
        return moduleCache[moduleName];
    };
  
    // 启动程序
    require(entry);
}

为了方便说明,我在源代码基础上做了一些小修改

webpackStart 定义了两个东西: “require” 函数和模块缓存(moduleCache)。 这里的 “require” 函数不同于 CommonJS 中的 require。”require” 接受模块名,并返回一个模块的导出接口。例如:对于 circle.js,其导出接口即 { default: function area(radius){ ... } }。导出接口被缓存在 moduleCache 中。因此,如果我们使用相同模块名重复调用 “require” ,只会执行一次“模块工厂函数”。

有了定义好的 “require” ,启动应用程序就只需要“ require” 入口模块即可。

rollup 方案

现在你已经看到了 webpack 打包的样子,让我们来看一下 “rollup 方案” 的打包结果:

const PI = 3.141;

function circle$area(radius) {
  return PI * radius * radius;
}

function square$area(side) {
  return side * side;
}

console.log('Area of square: ', square$area(5));
console.log('Area of circle', circle$area(5));

为了方便说明,我在源代码基础上做了一些小修改

首先,与 webpack 关键区别就是 rollup 打包产物要小得多。与“webpack 方案”相比,rollup 打包产物中没有模块映射。所有模块都被 “展平”到打包文件中; 没有对模块进行函数封装,在模块内声明的所有变量/函数,现在都声明在了全局作用域。

如果个别模块作用域内声明的所有内容,现在都声明在全局作用域,那么该如何处理两个模块中的的同名变量或函数?

rollup 会重命名变量或函数名,以避免命名冲突。在我们的例子中,circle.jssquare.js 都在模块内声明了 function area(){},当打包时,会看到两个函数的声明及使用的地方被重命名以避免冲突。

TIP: 不将模块包装在函数中的副作用之一就是 eval 的行为,更详细的解释请参见 此处

其次,打包文件中的模块顺序很重要。你可以争论说 circle$areasquare$area 可以放在console.log 之后,仍然能够工作。但是由于 临时性死区 的存在,PI 必须在 console.log 之前声明。因此,按照依赖关系对模块进行排序对于“rollup 方案”非常重要。

总的来说,“rollup 方案”似乎比“webpack 方案”更好。通过删除所有函数,它具有更小的打包体积和更少的运行时开销。

使用“ rollup 方案”有什么缺点吗?

是的,有时候循环依赖会导致程序出现问题。我们来看一个人为制造的例子:

const circle = require('./circle');
// 计算圆面积的 PI 参数没有在 circle.js 中定义
module.exports.PI = 3.141;

console.log(circle(5));
// 从 shape.js 引入 PI 用于面积计算
const PI = require('./shape');
const _PI = PI * 1
module.exports = function(radius) {
  return _PI * radius * radius;
}

为了方便说明,我在源代码基础上做了一些小修改

在这个例子中,shape.js 依赖 circle.js,而 circle.js 又依赖 shape.js。因此,对于 rollup 来说,在输出文件中先处理哪个模块没有“正确”答案。无论是先处理 circle.js 再处理 shape.js 还是先处理 shape.js 再处理 circle.js 都是合理的。因此,你可能会得到以下输出代码:

// cirlce.js 在前
const _PI = PI * 1; // 抛错 ReferenceError: PI is not defined
function circle$Area(radius) {
  return _PI * radius * radius;
}

// shape.js 在后
const PI = 3.141;
console.log(circle$Area(5));

你可以看出这会有问题,对吧?

这个问题有解决方案吗?简短的回答是没有。

一个“简单”的修复方案是不使用循环依赖。如果遇到循环依赖, rollup 会向你发出警告

思考这个例子之所以报错的原因,是我们在模块内部立即访问了变量 _PI。如果我们将 _PI 的访问改成惰性的呢?

const PI = require('./shape');
const _PI = () => PI * 1; // 惰性访问 _PI
module.exports = function(radius) {
  return _PI() * radius * radius;
}

会发现现在模块的顺序就变得没那么重要了:

// cirlce.js 在前
const _PI = () => PI * 1;
function circle$Area(radius) {
  return _PI() * radius * radius;
}

// shape.js 在后
const PI = 3.141;
console.log(circle$Area(5)); // prints 78.525

这是因为在访问 _PI 时,PI 已经被定义了。

总结

至此,我们总结一下到目前为止学到的内容:

  • 模块打包器帮助我们将多个 JavaScript 模块合并成一个 JavaScript 文件

  • 不同的打包器有不同的打包方案,我们已经了解了两种现代化的打包器:webpack 和 rollup。

  • “webpack 方案”:

    • 使用模块映射

    • 使用函数来封装每个模块

    • 将模块代码粘合在一起的运行时代码

  • “rollup 方案”:

    • 更扁平、更小巧的打包结果

    • 不使用函数来封装模块

    • 顺序很重要,模块需要根据依赖关系进行排序

    • 有循环依赖的情况下,可能无法正常工作

参考

原文链接:https://juejin.cn/post/7214398563023142949 作者:zhangbao90s

(0)
上一篇 2023年3月26日 下午4:55
下一篇 2023年3月26日 下午5:06

相关推荐

发表回复

登录后才能评论