npm库的打包原理与实践详解 (上)

前言

所谓打包就是将开发阶段的代码处理成生产环境可以供给其他用户使用的产物的过程。一般我们开发的 web 应用,最终会在浏览器上给用户使用,而现代前端的开发环境一般是 Node.js,而 Node.js 环境的重要部分就是 CommonJS 规范,因为 Node.js 的模块系统,就是参照 CommonJS 规范实现的,遵循 CommonJS 规范的代码是不能直接在浏览器中使用的,所以我们需要通过打包工具把遵循 CommonJS 规范的代码转换成浏览器可以识别的代码。而 CommonJS 规范的模块,本质上可以看成是一个函数,只要提供对应的 CommonJS 规范独有的变量,例如 module、exports、require、global 等,浏览器就可以识别 CommonJS 规范的代码。这就是普通应用产品项目打包所需要做的事情,即将开发阶段在 Node.js 环境运行的代码转换成浏览器环境可以执行的代码。

可能你会奇怪,明明在开发阶段也可以在浏览器进行执行预览,怎么还需要打包呢,这是因为开发环境的工具(例如 webpack、Vite)进行了预打包,而在发布阶段则是将所有的开发环境的代码转换成生产环境的代码并保存到 bundle 文件中。这是一般生产环境是浏览器的打包情况。


那么除了浏览器环境还有其他生产环境吗?当然有,常见的就是 Node.js 环境了,比如我们开发的一些类库,在现代开发过程中通常它们的运行环境就是 Node.js,比如 Vue 和 React 这种类库,它们开发完成之后会发布到 npm 上供给用户使用,而用户一般是在 Node.js 环境下使用它们进行应用开发,这样它们的生产环境就相当于 Node.js 了。此外还有服务器端渲染(SSR),也是在 Node.js 环境下运行的。Node.js 在 13.2.0(2019 年)之前都是只能识别 CommonJS 模块,在 Node.js 13.2.0 版本后,Node.js 也支持使用 ESM(ECMAScript Modules)模块。也就是现在我们的开发产物的生产环境是 Node.js 的话,我们需要提供两种格式的产物,分别是 CommonJS 的代码和 ESM 的代码。

还有一点就是通常打包后的代码都是难以调试的,所以需要通过 source map 文件,将压缩后的代码映射回原始的、未压缩的代码,以便于开发者调试和定位代码出错的位置。

  1. 提供浏览器环境可以识别的代码(一般是 UMD 或 IIFE)
  2. 提供 Node.js 环境的 CommonJS 规范的代码
  3. 提供 Node.js 环境的 ESM 规范的代码
  4. 提供 source map 文件

为什么要提供 ESM、CJS 两种代码

也许有同学会有疑问,为什么要同时提供 CommonJS 规范的代码和 ESM 规范的代码呢?

这是因为 Node.js 在 13.2.0(2019 年)之前都是只能识别 CommonJS 模块,在 Node.js 13.2.0 版本后,Node.js 也支持使用 ESM(ECMAScript Modules)模块。所以提供 CommonJS 代码是为了兼容旧版本的 Node.js,那么既然新老版本的 Node.js 都支持 CommonJS,可不可以只提供 CommonJS 一种代码呢,这样就省了 ESM 的代码。

但又因为 ESM 规范的代码更利于 Tree shaking。关于 Tree shaking,以下是 webpack 中文网的对 Tree shaking 的描述:

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。原文链接

在最后 webpack 中文网中还提到:如果要利用 tree shaking 的优势,你必须使用 ES2015 模块语法(即 import 和 export),确保没有编译器将你的 ES2015 模块语法转换为 CommonJS。

简单来说就是 Tree shaking 就是可以让打包后的代码体积更小。那么既然 ESM 规范这么好,可不可以只提供 ESM 规范的代码就可以了,这样就减少了打包 CommonJS 规范的代码的工作量以及代码体积,但在某些场景下并不能使用 ESM 模块,比如在一些需要依赖低版本的 Node.js 的项目(服务器端渲染 [SSR] )中,也就是开头说的 CommonJS 代码是为了兼容旧版本的 Node.js,又或者说一些旧工具只依赖 CommonJS,比如说 gulp,gulp 就只提供了 CommonJS 的使用方式。


值得注意的是,Vue3 源码在处理 ESM 规范的代码时,还进行细分,分别是浏览器环境的 ESM 文件,例如:reactivity.esm-browser.js,和 Node.js 环境的 ESM 文件,例如:reactivity.esm-bundler.js

它们的区别就是浏览器环境的 ESM 文件是全量打包,Node.js 环境的 ESM 文件是只打包开发文件本身的代码,也就是浏览器环境的 ESM 文件会把引用到的 node_modules 文件中的包也会打包进去,而 Node.js 环境的 ESM 文件则不会,这样 Node.js 环境的 ESM 文件体积会变得更小,但只能在 Node.js 环境中运行

到时在开发应用产品的时候,在打包成浏览器环境的产物时,会配合 webpack、rollup、vite 等打包工具将 node_modules 文件中的包打包进去。

此外,尤雨溪对此的看法是:未来的发展趋势是逐渐慢慢地淡化 CommonJS 规范,所有的 npm 包都逐渐只提供 ESM 规范的代码,比如说 Vite5 就废弃了 CommonJS API。

尤雨溪观点出处视频链接:# Vue & Vite:现状与未来 - 尤雨溪

生成 UMD 模块代码(全量打包)

我们普通业务项目应用打包的本质就是将开发环境的代码转换成浏览器环境可以识别的代码,一般可以通过 webpack、vite 这些打包工具进行打包。现代的类库则一般都是使用 rollup 进行打包,主要是 rollup 配置相对比较简单。 Vue3 的源码都是使用 rollup 进行打包的。如果大家对 rollup 了解不多,可以先到 rollup 官网 进行基础的了解。 rollup 有两种使用方式,一种是配置文件 + 命令行方式,一种是通过 JavaScript API 的方式,一般业务项目都是通过配置文件 + 命令行的方式,但在类库中我们则是通过 JavaScript API 的方式,主要原因是:

虽然配置文件提供了一种简单的配置 Rollup 的方式,但它们也限制了 Rollup 可以被调用和配置的方式。特别是如果你正在将 Rollup 嵌入到另一个构建工具中,或者想将其集成到更高级构建流程中,直接从脚本中以编程方式调用 Rollup 可能更好。

上述描述来自 rollup 中文官网。

我们为了方便管理,我们将打包相关的操作全放到一个文件夹内。我们在根目录下创建 build 的打包项目目录。接着在命令终端安装 rollup 包。

相关项目结构如下:

我们知道 Vue 项目打包时候主要做的工作就是将 .vue 文件编译成 JavaScript 文件,我们知道要做什么之后,我们接下来要做的就是去查找和了解相关资料,在 rollup 中是如何做到这些的。在 webpack 中是通过 vue-loader 来识别 .vue 文件的,在 rollup 中则是通过插件来识别的,通常是 rollup-plugin-vue 插件,但因为 rollup-plugin-vue 已经不再维护,推荐使用 @vitejs/plugin-vue;此外 rollup 默认是不能解析 npm 包的,需要通过 @rollup/plugin-node-resolve 插件;

我们把相关插件进行安装。

复制代码
pnpm install rollup @vitejs/plugin-vue @rollup/plugin-node-resolve  -D

我们在 build 目录下新建一个 full-bundle.js 用于进行全量打包的执行文件。我们上面也说到了 rollup 的打包设置是非常简单,就三个过程:配置入口文件、配置插件、配置输出文件格式。所以我们把代码架构搭建起来,为了顾名思义,我们把打包的函数命名为:buildFullEntry。

复制代码
import { rollup } from "rollup";
import vue from "@vitejs/plugin-vue";
import { nodeResolve } from "@rollup/plugin-node-resolve";
// 全量打包任务函数
const buildFullEntry = async () => {
  const bundle = await rollup({
    input: "", // 配置入口文件
    plugins: [
      // 配置插件
      vue(),
      nodeResolve(),
    ],
    // 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
    external: ["vue"],
  });
  // 配置输出文件格式
  bundle.write({});
};
buildFullEntry();

以上便是 rollup 的 JavaScript API 方式的基本使用方式,在 rollup 官网 上你也可以查看到相关介绍。

我们可以设置输出文件的路径为:resolve(__dirname, "../dist/index.full.js"),。 因为我们需要提供浏览器可以识别的代码,那么打包的格式一般都是 umd,因为 UMD 格式可以兼容 CommonJS、AMD、CMD 模块规范,关于 UMD 模块规范原理我们下一小节再进行展开讨论。根据 UMD 模块规范我们需要将整个组件库要设置一个变量名称:directivesExpand,最终会挂载到全局变量上,浏览器环境是 globalThis 上,Node.js 环境则是模块 exports 对象上,此外我们需要告诉 Rollup,Vue 是外部依赖,vue 模块的 ID 为全局变量 Vue。我们姑且先这么设置,至于为什么要这么设置,我们还需要对 UMD 的模块规范原理进行了解之后才可以理解为什么要这么设置。 根据 rollup 官网提供的资料我们可以设置输出文件的格式如下:

复制代码
{
    format: "umd",
    file: resolve(__dirname, "../dist/index.full.js"),
    name: "directivesExpand",
    exports: "named",
    // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
    globals: {
      vue: "Vue",
    },
  });

}

整体代码则如下:

复制代码
// build/full.js(示例文件名)

import { rollup } from "rollup";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import vue from "@vitejs/plugin-vue";
import { nodeResolve } from "@rollup/plugin-node-resolve";

// 获取当前文件路径
const __filename = fileURLToPath(import.meta.url);
// 获取当前文件所在目录
const __dirname = dirname(__filename);

async function buildFullEntry() {
  // rollup 配置
  const bundle = await rollup({
    input: resolve(__dirname, "../src/index.js"),
    plugins: [nodeResolve(), vue()],
    // 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
    external: ["vue"],
  });

  // 输出配置
  await bundle.write({
    format: "umd",
    file: resolve(__dirname, "../dist/index.full.js"),
    name: "directivesExpand",
    exports: "named",
    // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
    globals: {
      vue: "Vue",
    },
  });
}

// 执行打包
buildFullEntry().catch((err) => {
  console.error(err);
  process.exit(1);
});

接着我们在命令终端进入 build 运行以下命令:

复制代码
node ./full-bundle.js
复制代码

我们就可以看到生成了以下代码:

我们可以在 test下新建一个 test.html 文件,然后通过借助 script 标签直接通过 CDN 来使用 Vue 和我们刚刚打包出来的组件库。代码如下:

复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Directives Expand Test</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            padding: 20px;
        }
        .container {
            margin-bottom: 20px;
        }
        button {
            padding: 10px 20px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <div id="app">
        <h1>Directives Expand Test</h1>
        
        <div class="container">
            <input type="text" v-model="text" placeholder="Type something...">
            <button v-copy="text">Copy Input Value</button>
        </div>

        <div class="container">
            <button v-copy.toast="'Static Text Copy'">Copy Static Text (with toast)</button>
        </div>
    </div>

    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="../dist/index.full.js"></script>
    <script>
        const { createApp } = Vue;
        
        console.log('directivesExpand:', directivesExpand);

        const app = createApp({
            data() {
                return {
                    text: 'Hello from Vue!'
                }
            }
        });

        app.use(directivesExpand.default);

        app.mount('#app');
    </script>
</body>
</html>
复制代码

我们实现了提供浏览器环境可以识别的代码打包功能之后,接下来便到了第二步实现提供 Node.js 环境的 CommonJS 规范和 ESM 规范的代码的打包功能。

UMD 模块规范的实现原理

UMD 叫做通用模块定义规范(Universal Module Definition)。

我们上文主要讲述了如何将整个库进行全量打包,本质上是将整个库挂着到一个全局变量上,那么了解过 Jquery 的基本实现原理的话,我们就知道 Jquery 就是一个全局变量,是通过 IIFE 函数将 Jquery 的模块对象挂载到全局变量 Jquery 上。因为 IIFE 是旧时代进行模块化的主要手段,就是自执行函数可以返回一个模块。

JQuery 的精简源码如下:

复制代码
var jQuery = (function() {
    var $
    $ = function(selector, context) {
        // ...
    }
    
    $.ajax = function () {
        // ...
    }
    return $
})()
window.jQuery = jQuery 

在 UMD 模块规范中是需要兼容 CommonJS、CMD 的,它的基本原理就是通过一个工厂函数创建一个模块对象,然后再根据当前环境赋值给不同的模块全局变量上。所以在 UMD 的基本代码结构中则是把上述像 jQuery 的构造函数放在外面当作参数传进去,然后再赋值给当前的全局变量:

复制代码
(function (window, jQuery) {
  window.jQuery = window.$ = jQuery();
})(window, function () { // 生产模块对象的工厂函数
    var $ 
    $ = function(selector, context) { 
        // ... 
    } 
    $.ajax = function () { 
        // ... 
    } 
    // 需要返回模块对象
    return $
});

这个时候,这个匿名函数就是一个模块工厂函数,它只负责生产模块对象,那么为了更加顾名思义,我们修改相关命名:

复制代码
(function (global, factory) {
  global.jQuery = global.$ = factory();
})(this, function () { // 生产模块对象的工厂函数
    var $ 
    $ = function(selector, context) { 
        // ... 
    } 
    $.ajax = function () { 
        // ... 
    } 
    // 需要返回模块对象
    return $
});

自执行函数可以通过 return 的方式返回的一个模块,

也可以通过传入一个对象,把模块需要导出的对象设置在这个对象的属性上。UMD 则是采用了后一种方式,代码如下:

复制代码
(function (global, factory) {
-   global.jQuery = factory();
+  factory((global.jQuery = {})); // jQuery 的全局变量
})(this, function (exports) { // 生产模块对象的工厂函数
    // ...
    
    // 此时 exports 就是 jQuery 全局对象
    exports.ajax = function() {}
});

那么换成我们的库则变成以下的模样:

复制代码
(function (global, factory) {
  factory((global.directivesExpand= {})); // directivesExpand 的全局变量
})(this, function (exports) { // 生产模块对象的工厂函数
  // ...
  
  function install() {} // 组件安装函数
  // 需要返回模块对象
  exports.install = install;
});

又因为 window 对象在一些 JavaScript 的运行时中是不存在的,所以我们把 window 对象改成了 this,但即便是 this,仍然可能在某些场景下是不存在的,比如严格模式下的函数中的 this 是不存在的。然后不同平台的全局对象也是不一样的,比如 Node.js 的全局对象是 global,浏览器中则是 window,还有一些环境则是 self,所以为了抹平这些差异,ES2020 给我们带来了 globalThis 对象。以下是 MDN 对 globalThis 的相关介绍。

globalThis 提供了一个标准的方式来获取不同环境下的全局 this 对象(也就是全局对象自身)。不像 window 或者 self 这些属性,它确保可以在有无窗口的各种环境下正常工作。所以,你可以安心的使用 globalThis,不必担心它的运行环境。为便于记忆,你只需要记住,全局作用域中的 this 就是 globalThis。

复制代码
(function (global, factory) {
  // 做全局变量的兼容处理
+  global = typeof globalThis !== "undefined" ? globalThis : global || self;
  factory((global.directivesExpand = {})); // directivesExpand 的全局变量
})(this, function (exports) { // 生产模块对象的工厂函数
  // ...
  
  function install() {} // 组件安装函数
  // 需要返回模块对象
  exports.install = install;
});

兼容 CommonJS 代码

复制代码
(function (global, factory) {
+  // 兼容 CommonJS 代码
+  if (typeof exports === "object" && typeof module !== "undefined") {
+    factory(exports);
+  } else {
    // 做全局变量的兼容处理
    global = typeof globalThis !== "undefined" ? globalThis : global || self;
    factory((global.ElementPlus = {})); 
+  }
})(this, function (exports) {
  function install() {} // jQuery 的模块代码
  // 需要返回模块对象
  exports.install = install;
});

兼容 AMD 代码

也是判断运行环境是否满足 AMD 规范。如果满足,则使用 require.js 提供的 define 函数定义模块。

复制代码
(function (global, factory) {
  if (typeof exports === "object" && typeof module !== "undefined") {
    // 兼容 CommonJS 代码
    factory(exports);
+  } else if (typeof define === "function" && define.amd) {
+    // 兼容 AMD 代码
+    define(["exports"], factory);
  } else {
    // 做全局变量的兼容处理
    global = typeof globalThis !== "undefined" ? globalThis : global || self;
    factory((global.ElementPlus = {}));
  }
})(this, function (exports) {
  function install() {} // jQuery 的模块代码
  // 需要返回模块对象
  exports.install = install;
});

那么我们知道 Element Plus 组件库是需要依赖 Vue 的,所以我们需要将全局中的 Vue 变量传递到工厂函数中去,这样工厂函数中也就是组件库中就可以使用 Vue 相关的 API 了。

复制代码
  (function (global, factory) {
    if (typeof exports === "object" && typeof module !== "undefined") {
      // 兼容 CommonJS 代码
+     factory(exports, require('vue'));
    } else if (typeof define === "function" && define.amd) {
      // 兼容 AMD 代码
+     define(["exports", "vue"], factory);
    } else {
      // 做全局变量的兼容处理
      global = typeof globalThis !== "undefined" ? globalThis : global || self;
+     factory(global.directivesExpand = {}, global.Vue); // 将全局中的 Vue 变量传递到工厂函数中去,这样工厂函数中也就是组件库中就可以使用 Vue 相关的 API 了
    }
+ })(this, function (exports, vue) {
+    const { ref } = vue // 组件库中需要使用到的 Vue 的 API
    function install() {} // jQuery 的模块代码
    // 需要返回模块对象
    exports.install = install;
  });

所以我们再回去看在 rollup 打包的输出配置相关设置时,我们就很容易理解为什么要那么设置了。

复制代码
{
    format: "umd",
    file: resolve(__dirname, "../dist/index.full.js"),
    // 将整个组件库要设置一个变量名称:`directivesExpand`,
    name: "directivesExpand",
    exports: "named",
    // 组件库中需要使用到的全局变量 Vue
    globals: {
      vue: "Vue",
    },
}

总的来说 UMD 格式就是一种既可以在浏览器环境下使用,也可以在 Node.js 环境下使用的代码格式。它将 CommonJS、AMD 以及普通的全局定义模块三种模块规范进行了整合。

相关推荐
mCell5 小时前
如何零成本搭建个人站点
前端·程序员·github
mCell6 小时前
为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞
前端·设计模式·agent
恋猫de小郭6 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
少云清6 小时前
【安全测试】2_客户端脚本安全测试 _XSS和CSRF
前端·xss·csrf
银烛木7 小时前
黑马程序员前端h5+css3
前端·css·css3
m0_607076607 小时前
CSS3 转换,快手前端面试经验,隔壁都馋哭了
前端·面试·css3
听海边涛声7 小时前
CSS3 图片模糊处理
前端·css·css3
IT、木易7 小时前
css3 backdrop-filter 在移动端 Safari 上导致渲染性能急剧下降的优化方案有哪些?
前端·css3·safari
0思必得07 小时前
[Web自动化] Selenium无头模式
前端·爬虫·selenium·自动化·web自动化
anOnion7 小时前
构建无障碍组件之Dialog Pattern
前端·html·交互设计