使用Webpack构建NPM Library

虽然 Webpack 多数情况下被用于构建 Web 应用,但与 Rollup、Snowpack 等工具类似,Webpack 同样具有完备的构建 NPM 库的能力。与一般场景相比,构建 NPM 库时需要注意:

  • 正确导出模块内容;

  • 不要将第三方包打包进产物中,以免与业务方环境发生冲突;

  • 将 CSS 抽离为独立文件,以方便用户自行决定实际用法;

  • 始终生成 Sourcemap 文件,方便用户调试。

本文将从最基础的 NPM 库构建需求开始,逐步叠加上述特性,最终搭建出一套能满足多数应用场景、功能完备的 NPM 库构建环境。

开发一个 NPM 库

新建一个全新的NPM库,初始化如下:

mkdir test-lib && cd test-lib
npm init -y

虽然有很多构建工具能够满足 NPM 库的开发需求,但现在暂且选择 Webpack,所以需要先装好基础依赖:

yarn add -D webpack webpack-cli

接下来,可以开始写一些代码了,首先创建代码文件:

mkdir src
touch src/index.js

之后,在**test-lib/src/index.js**文件中随便实现一些功能,比如:

// test-lib/src/index.js
export const add = (a, b) => a + b

至此,项目搭建完毕,目录如下:

├─ test-lib
│  ├─ package.json
│  ├─ src
│  │  ├─ index.js

使用 Webpack 构建 NPM 库

接下来,我们需要将上例 **test-lib**构建为适合分发的产物形态。

// webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    filename: 'test-lib.js', // 输出文件的名称
    path: path.resolve(__dirname, 'dist'), // 输出文件的路径,'dist'目录
    library: 'MyLibrary', // 生成的库的名称
    libraryTarget: 'umd', // 指定库的模块格式,可以是 'commonjs2'、'amd'、'var'等
    },
};

提示:我们还可以在上例基础上叠加任意 Loader、Plugin,例如: babel-loadereslint-loaderts-loader 等。

上述配置会将代码编译成一个 IIFE 函数,但这并不适用于 NPM 库,我们需要修改 **output.library**配置,以适当方式导出模块内容:

// webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    filename: 'test-lib.js', // 输出文件的名称
    path: path.resolve(__dirname, 'dist'), // 输出文件的路径,'dist'目录
    library: 'MyLibrary', // 生成的库的名称
    libraryTarget: 'umd', // 指定库的模块格式,可以是 'commonjs2'、'amd'、'var'等
+   library: {
+     name: "_",
+     type: "umd",
+   },
   },
};

这里对library解释

  • library : 这个配置选项用于定义库的名称和类型,以便在构建时正确处理库的输出。
    • name : 这是库的名称。用于定义模块名称,在浏览器环境下使用 script 加载该库时,可直接使用这个名字调用模块 。例如

      <!DOCTYPE html>
      <html lang="en">
      ...
      <body>
          <script src="https://examples.com/dist/main.js"></script>
          <script>
              // Webpack 会将模块直接挂载到全局对象上
              window._.add(1, 2)
          </script>
      </body>
      
      </html>
      
    • type : 这里指定为 "umd",表示使用通用模块定义(Universal Module Definition)。UMD 是一种 JavaScript 模块格式,旨在支持多种环境,可选值有:commonjsumdmodulejsonp 等 ,

提示:JavaScript 最开始并没有模块化方案,这就导致早期 Web 开发需要将许多代码写进同一文件,极度影响开发效率。后来,随着 Web 应用复杂度逐步增高,社区陆陆续续推出了许多适用于不同场景的模块化规范,包括:CommonJS、UMD、CMD、AMD,以及 ES6 推出的 ES Module 方案,不同方案各有侧重点与适用场景,NPM 库作者需要根据预期的使用场景选择适当方案。

使用**output.library**修改前后对应的产物内容如下:

可以看到,修改前(对应上图左半部分)代码会被包装成一个 IIFE ;而使用 output.library 后,代码被包装成 UMD(Universal Module Definition) 模式:

(function webpackUniversalModuleDefinition(root, factory) {
    if(typeof exports === 'object' && typeof module === 'object')
        module.exports = factory();
    else if(typeof define === 'function' && define.amd)
        define([], factory);
    else if(typeof exports === 'object')
        exports["_"] = factory();
    else
        root["_"] = factory();
})(self, function() {
 // ...
});

这种形态会在 NPM 库启动时判断运行环境,自动选择当前适用的模块化方案,此后我们就能在各种场景下使用 test-lib 库,例如:

// ES Module
import {add} from 'test-lib';

// CommonJS
const {add} = require('test-lib');

// HTML
<script src="https://examples.com/dist/main.js"></script>
<script>
    // Webpack 会将模块直接挂载到全局对象上
    window._.add(1, 2)
</script>

拓展1

什么是IIFE

立即调用函数表达式(IIFE,Immediately Invoked Function Expression)是一种常见的 JavaScript 编程模式,它可以创建一个独立的作用域,避免变量污染全局命名空间。IIFE 的基本结构如下:

(function() {
    // 代码逻辑
})();


//或者使用 ES6 的箭头函数语法:

(() => {
    // 代码逻辑
})();

编译成 IIFE

假设你有以下的普通函数或者代码逻辑,我们将其编译成 IIFE。

示例 1:普通函数

function sayHello() {
    console.log("Hello, World!");
}

sayHello();

编译成 IIFE

(function() {
    console.log("Hello, World!");
})();

编译成IIFE的作用:

  1. 避免全局命名空间污染

在 JavaScript 中,所有的变量和函数都默认是在全局作用域中定义的,这可能会导致命名冲突和意外覆盖。使用 IIFE 可以将变量和函数封装在一个独立的作用域中,从而避免污染全局命名空间。

  1. 创建私有变量

IIFE 使得我们可以创建私有变量和函数,这些变量和函数在 IIFE 内部可访问,但外部无法访问。这种封装有助于数据保护和信息隐藏。

var counter = (function() {
    var count = 0; // 私有变量

    return {
        increment: function() {
            count++;
            return count;
        },
        getCount: function() {
            return count;
        }
    };
})();

console.log(counter.increment()); // 输出: 1
console.log(counter.getCount()); // 输出: 1
  1. 立即执行代码

IIFE 可以立即执行一些代码,例如初始化操作或设置某些状态,而无需在后面显式调用它。这样可以提高代码的可读性和组织性。

(function() {
    console.log("This runs immediately!");
})();

正确使用第三方包

接下来,假设我们需要在 test-lib 中使用其它 NPM 包,例如 lodash

// src/index.js
import _ from "lodash";

export const add = (a, b) => a + b;

export const max = _.max;

此时执行编译命令**npx webpack**,我们会发现产物文件的体积非常大:

这是因为 Webpack 默认会将所有第三方依赖都打包进产物中,这种逻辑能满足 Web 应用资源合并需求,但在开发 NPM 库时则很可能导致代码冗余。以 test-lib 为例,若使用者在业务项目中已经安装并使用了 lodash,那么最终产物必然会包含两份**lodash** 代码!

为解决这一问题,我们需要使用**externals** 配置项,将第三方依赖排除在打包系统之外:

// webpack.config.js
module.exports = {
  // ...
+  externals: {
    //将 lodash 作为外部依赖配置
+   lodash: {
+     commonjs: "lodash",//在 CommonJS 环境(如 Node.js)中导入 lodash 的方式。这里的值为 "lodash",表示使用 require('lodash') 导入。
+     commonjs2: "lodash",
+     amd: "lodash",//在 AMD 环境(如 RequireJS)中导入 lodash 的方式,值为 "lodash",表示使用 define(['lodash'], function(_) {...}) 的方式。
+     root: "_",//在浏览器环境中全局变量的名称。在这种情况下,root 的值为 "_",这意味着在浏览器中可以通过 _ 来访问 lodash。

示例
+   },
+ },
  // ...
};

提示: Webpack 编译过程会跳过 externals 所声明的库,这个选项用于告诉 Webpack 哪些模块应该被视作外部依赖,不要将其打包进输出文件中。这在库开发中非常有用,因为它可以减少最终打包文件的大小,并避免重复打包已经存在的库。

例如,我们可以将 React 声明为外部依赖,并在页面中通过 <script> 标签方式引入 React 库,之后 Webpack 就可以跳过 React 代码,提升编译性能。如下代码展示

externals: {

react: 'React',

'react-dom': 'ReactDOM',

},

改造后,再次执行npx webpack,编译结果如下:

改造后,主要发生了两个变化:

  1. 产物仅包含 test-lib 库代码,体积相比修改前大幅降低;

  2. UMD 模板通过 requiredefine 函数中引入 lodash 依赖并传递到 factory

至此,Webpack 不再打包**lodash** 代码,我们可以顺手将 lodash 声明为**peerDependencies**:

{
  "name": "6-1_test-lib",
  // ...
+ "peerDependencies": {
+   "lodash": "^4.17.21"
+ }
}

实践中,多数第三方框架都可以沿用上例方式处理,包括 React、Vue、Angular、Axios、Lodash 等,方便起见,可以直接使用 webpack-node-externals 排除所有 node_modules 模块,使用方法:

// webpack.config.js
const nodeExternals = require('webpack-node-externals');

module.exports = {
  // ...
+  externals: [nodeExternals()]
  // ...
};

拓展2

1.++UMD++(Universal Module Definition)是一种模块定义模式,旨在使 JavaScript 模块能够在多种环境中共享和使用,包括:

  • CommonJS(如 Node.js)
  • AMD(如 RequireJS)
  • 直接在浏览器中作为全局变量

UMD 模板结构

下面是一个典型的 UMD 模板示例:

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD 环境
        define(['lodash'], factory);
    } else if (typeof exports === 'object') {
        // CommonJS 环境
        module.exports = factory(require('lodash'));
    } else {
        // 浏览器全局变量
        root.myLibrary = factory(root._);
    }
}(typeof self !== 'undefined' ? self : this, function (_) {
    'use strict';

    // 库的实现
    function myFunction() {
        var array = [1, 2, 3, 4];
        var shuffled = _.shuffle(array); // 使用 lodash
        console.log(shuffled);
    }

    // 导出库的接口
    return {
        myFunction: myFunction
    };
}));

1.自执行函数

(function (root, factory) { ... }(typeof self !== 'undefined' ? self : this, function (_) { ... }));
  • 自执行函数(Immediately Invoked Function Expression, IIFE)用于封装代码,避免全局变量污染。
  • root 是全局对象(在浏览器中是 window,在 Node.js 中是 global)。
  • factory 是工厂函数,作为模块的实现。

2. 环境检测

if (typeof define === 'function' && define.amd) {
    // AMD 环境
    define(['lodash'], factory);
} else if (typeof exports === 'object') {
    // CommonJS 环境
    module.exports = factory(require('lodash'));
} else {
    // 浏览器全局变量
    root.myLibrary = factory(root._);
}
  • AMD 环境:

    • 检测 define 是否为函数且带有 amd 属性。
    • 使用 define 注册模块,传入 lodash 作为依赖,并调用 factory 生成模块。
  • CommonJS 环境:

    • 检测 exports 是否为对象,说明是在一个 CommonJS 环境(如 Node.js)。
    • 使用 module.exports 导出模块,并通过 require 导入 lodash
  • 浏览器环境:

    • 如果以上两种都不满足,说明是在浏览器中。
    • 将库绑定到全局对象(root),使用 root._ 作为 lodash 的全局变量。

使用方法:

  • 在 AMD 环境下:

    require(['path/to/myLibrary'], function(myLibrary) {
    myLibrary.myFunction();
    });

  • 在 CommonJS 环境下:

    const myLibrary = require('path/to/myLibrary');
    myLibrary.myFunction();

  • 在浏览器中:

    <script src="path/to/lodash.js"></script> <script src="path/to/myLibrary.js"></script> <script> myLibrary.myFunction(); </script>

3. 工厂函数

function (_) {
    'use strict';
    
    // 库的实现
    function myFunction() {
        ...
    }

    // 导出库的接口
    return {
        myFunction: myFunction
    };
}
  • 工厂函数中,参数 _ 是传入的 lodash
  • 这里可以实现库的逻辑,并定义需要暴露给外部的 API(如 myFunction)。
  • 最后,通过返回一个对象来导出库的接口。

2.++externals++

在上文例子中使用了:

 externals: [nodeExternals()

externals: [nodeExternals() 是一个在构建 Node.js 应用程序时使用的配置选项,通常与构建工具(如 Webpack)一起使用。它的主要作用是指示构建工具在打包时忽略某些模块,这些模块通常是从**node_modules** 目录中引入的。

1. 为什么使用 externals

在 Node.js 应用程序中,通常会依赖许多外部模块(例如,expresslodash 等)。当使用 Webpack 等打包工具时,默认情况下,它会尝试将所有的依赖项打包到一个或多个文件中。然而,在 Node.js 环境中,通常不需要将这些依赖打包,因为它们可以在运行时从**node_modules** 中直接加载。

使用 externals 可以:

  • 减小打包文件的大小: 避免将大型依赖项打包到输出文件中。
  • 提高加载速度 : 因为 Node.js 可以直接从 node_modules 加载依赖。
  • 避免潜在的版本冲突: 确保在运行时使用的是安装在项目中的依赖版本。

2. nodeExternals 的使用

nodeExternalswebpack-node-externals 包提供的一个工具,用于自动识别和排除 Node.js 的模块依赖。这意味着只要模块在 node_modules 文件夹中,它就会被视为外部依赖并不会被打包。

需要安装了 webpackwebpack-node-externals

npm install --save-dev webpack webpack-node-externals

抽离 CSS 代码

假设我们开发的 NPM 库中包含了 CSS 代码 ------ 这在组件库中特别常见,我们通常需要使用 **mini-css-extract-plugin**插件将样式抽离成单独文件,由用户自行引入。

这是因为 Webpack 处理 CSS 的方式有很多,例如使用 style-loader 将样式注入页面的 <head> 标签;使用 **mini-css-extract-plugin**抽离样式文件。作为 NPM 库开发者,如果我们粗暴地将 CSS 代码打包进产物中,有可能与用户设定的方式冲突。

module.exports = {  
  // ...
+ module: {
+   rules: [
+     {
+       test: /\.css$/,
+       use: [MiniCssExtractPlugin.loader, "css-loader"],
+     },
+   ],
+ },
+ plugins: [new MiniCssExtractPlugin()],
};

拓展3

什么是MiniCssExtractPlugin

MiniCssExtractPlugin 是一个用于 Webpack 的插件,它的主要功能是将 CSS 从 JavaScript 中提取出来并生成独立的 CSS 文件。这对于生产环境的项目来说非常有用,因为将 CSS 提取到单独的文件中可以提高页面加载速度和性能,同时使样式表的管理更加清晰。

主要功能

  • 分离 CSS:将 CSS 样式从 JavaScript 文件中提取到独立的 CSS 文件中,这样可以使样式和脚本分离。
  • 支持 CSS 文件热更新:在开发模式下,支持对 CSS 文件的热更新,而不需要重新加载整个页面。
  • 优化性能:通过将 CSS 提取到单独的文件中,可以利用浏览器的并行加载能力,从而提高页面加载速度。

如何使用 MiniCssExtractPlugin

1. 安装插件

首先,确保你已经安装了 Webpack 和相关的 loader,例如 css-loader

npm install --save-dev mini-css-extract-plugin css-loader

2. 配置 Webpack

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: './src/index.js', // 入口文件
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.css$/, // 匹配所有 css 文件
        use: [
          MiniCssExtractPlugin.loader, // 提取 CSS
          'css-loader', // 解析 CSS
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css', // 输出的 CSS 文件名
      chunkFilename: '[id].css', // 生成的 chunk CSS 文件名
    }),
  ],
  mode: 'production', // 或 'development'
};

3. 导入 CSS 文件

在你的 JavaScript 文件中,可以直接导入 CSS 文件:

import './styles.css';

4. 运行 Webpack

使用 Webpack 构建项目:

npx webpack

构建完成后,项目的输出文件夹(如 dist)中会包含提取出的 CSS 文件。

生产与开发模式

  • 生产模式 :在生产模式下,MiniCssExtractPlugin 会将 CSS 提取到独立的文件中,并且通常会启用一些优化,如压缩 CSS 文件。

  • 开发模式 :在开发模式下,可能会使用 style-loader 来将 CSS 注入到 DOM 中,而不是将其提取到文件中,这样可以更好地支持热模块替换(HMR)。

生成 Sourcemap

Sourcemap 是一种代码映射协议,它能够将经过压缩、混淆、合并的代码还原回未打包状态,帮助开发者在生产环境中精确定位问题发生的行列位置,它允许你在调试时查看和使用原始的、可读的源代码,而不是压缩或转换后的代码。所以一个成熟的 NPM 库除了提供兼容性足够好的编译包外,通常还需要提供 Sourcemap 文件。

1. 为什么需要 Source Maps

  • 转译(例如使用 Babel 将 ES6 转换为 ES5)
  • 压缩(例如使用 UglifyJS 或 Terser 压缩代码以减少文件大小)
  • 模块化(例如使用 Webpack 将多个模块合并为一个文件)

经过这些处理后,生成的最终代码通常很难阅读和调试。使用 source maps,开发者可以在浏览器的开发者工具中查看原始的源代码,而不是处理后的代码。这使得调试变得更容易,尤其是在出现错误时。

2. Source Maps 的工作原理

Source maps 是一种 JSON 格式的文件,描述了转换后的代码与原始代码之间的映射关系。它们包含的信息通常包括:

  • 来源文件:被转换或压缩的原始文件名。
  • 生成的文件:生成的文件名。
  • 行列映射:指明生成文件中的每一行和每一列对应原始文件的哪些行和列。

当你在浏览器中调试代码时,浏览器可以使用这个映射文件将错误或调试信息转换回原始源代码,从而使开发者能够看到原始的错误位置。

3.在Webpack 使用Source Maps

接入方法很简单,只需要添加适当的 devtool 配置:

// webpack.config.js
module.exports = {  
  // ...
+ devtool: 'source-map'
};

再次执行 npx webpack 就可以看到 .map 后缀的映射文件:

├─ test-lib
│  ├─ package.json
│  ├─ webpack.config.js
│  ├─ src
│  │  ├─ index.css
│  │  ├─ index.js
│  ├─ dist
│  │  ├─ main.js
│  │  ├─ main.js.map
│  │  ├─ main.css
│  │  ├─ main.css.map

此后,业务方只需使用 source-map-loader 就可以将这段Sourcemap 信息加载到自己的业务系统中,实现框架级别的源码调试能力。

其它 NPM 配置

优化 test-lib 的项目配置,提升开发效率,不是重点,感兴趣可以查找相关资料:

以下为我总结本章知识后的webpack.package.json文件

// webpack.config.js
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); // 确保安装了这个插件
const TerserPlugin = require("terser-webpack-plugin"); // 确保安装了这个插件
const nodeExternals = require("webpack-node-externals"); // 确保安装了这个插件

module.exports = {
  mode: "development", // 可以根据需要更改为 'production'
  devtool: 'source-map', // 生成完整的 source map
  entry: "./src/index.js",
  output: {
    filename: "[name].js",
    path: path.join(__dirname, "./dist"),
    library: {
      name: "_",
      type: "umd",
    },
  },
//忽略外部依赖
  externals: {
    lodash: {
      commonjs: "lodash",
      commonjs2: "lodash",
      amd: "lodash",
      root: "_",
    },
  },
//MiniCssExtractPlugin 插件处理css文件
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
      chunkFilename: '[id].css',
    }),
  ],
// JavaScript 代码的压缩和混淆
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()], // 确保安装了这个插件
  },
  // 发布前自动执行编译命令
  scripts: {
    "prepublishOnly": "webpack --mode=production",
  },
  // 忽略 node_modules 中的模块
  externals: [nodeExternals()],
};
相关推荐
蟾宫曲5 小时前
在 Vue3 项目中实现计时器组件的使用(Vite+Vue3+Node+npm+Element-plus,附测试代码)
前端·npm·vue3·vite·element-plus·计时器
秋雨凉人心5 小时前
简单发布一个npm包
前端·javascript·webpack·npm·node.js
liuxin334455665 小时前
学籍管理系统:实现教育管理现代化
java·开发语言·前端·数据库·安全
qq13267029405 小时前
运行Zr.Admin项目(前端)
前端·vue2·zradmin前端·zradmin vue·运行zradmin·vue2版本zradmin
魏时烟6 小时前
css文字折行以及双端对齐实现方式
前端·css
哥谭居民00017 小时前
将一个组件的propName属性与父组件中的variable变量进行双向绑定的vue3(组件传值)
javascript·vue.js·typescript·npm·node.js·css3
2401_882726487 小时前
低代码配置式组态软件-BY组态
前端·物联网·低代码·前端框架·编辑器·web
web130933203987 小时前
ctfshow-web入门-文件包含(web82-web86)条件竞争实现session会话文件包含
前端·github
胡西风_foxww7 小时前
【ES6复习笔记】迭代器(10)
前端·笔记·迭代器·es6·iterator
前端没钱7 小时前
探索 ES6 基础:开启 JavaScript 新篇章
前端·javascript·es6