前端打包&发布

420 阅读11分钟

打包(Bundle)是指将前端代码进行编译、压缩、合并等操作,将其打包成一个或多个文件,使其成为一个整体,以便于部署和使用。打包后的文件可以是一个或多个文件,可以是js、css、html等文件,也可以是图片、字体等资源文件。

项目配置文件

package.json 是一个标准的 npm 配置文件,用于描述项目的基本信息、依赖、脚本等。在项目根目录下执行 npm init -y 可以生成一个默认的 package.json 文件。

{
  "name": "project", // 项目名称
  "version": "1.0.0", // 项目版本
  "description": "", // 项目描述
  "main": "index.js", // 项目入口文件
  "scripts": { // 指定运行脚本命令的 npm 命令行缩写
    "test": "echo "Error: no test specified" && exit 1"
  },
  "author": "",// 作者
  "license": "ISC" // 许可证
}

项目概况

  1. nameversion

项目名称和版本号,是项目的唯一标识。

  • 项目名称必须是小写字母,可以包含字母、数字、下划线、连字符,不能包含空格。可以包含组织名(Scope),如 @myorg/project
  • 版本号遵循 语义化版本规范
  1. descriptionkeywords

项目描述和关键字,用于描述项目的基本信息。两者都是为了方便搜索引擎和用户查找项目。

  • 项目描述是一个字符串,用于描述项目的基本信息。
  • 关键词是一个数组,用于描述项目的关键字。
  1. homepage, bugsrepository

项目主页、问题反馈、代码仓库地址。用于用户查找项目的相关信息及反馈问题。

  • homepage 是项目的主页地址。
  • bugs 是项目的问题反馈地址。
  • repository 是项目的代码仓库地址。
  1. author, contributorslicense

项目作者、贡献者和许可证。用于说明项目的版权信息。

  • author 是项目的作者。
  • contributors 是项目的贡献者。
  • license 是项目的许可证。

项目运行环境

  1. engines

用于指定项目运行所需的环境,包括 node, npm 等。

  • node 是项目运行所需的 Node.js 版本。
  • npm 是项目运行所需的 npm 版本。
{
  "engines": {
    "node": ">=10.0.0",
    "npm": ">=6.0.0"
  }
}

2. os

用于指定项目运行所需的操作系统,包括 Windows, macOS, Linux 等。

{
  "os": [
    "darwin",
    "linux",
    "!win32"
  ]
}

3. cpu

用于指定项目运行所需的 CPU 架构,包括 x86, x64 等。

{
  "cpu": [
    "x64",
    "!arm"
  ]
}

文件&目录

参考连接:

  1. main

项目的入口文件,用于指定项目的主要文件。可以是 js 文件(commonjs,ES Module),也可以是其他文件。

{
  "main": "index.js"
}

2. module(非标准)

打包工具(Webpack,rollup 等)扩展的字段,类似于 main 字段,只不过用于指定项目的 ES Module 入口文件。

{
  "module": "index.mjs"
}

3. browser(非标准)

用于指定项目在浏览器环境下的入口文件。

Webpack 在构建时有一个 target 配置项,默认为 web。如果使用 import 语法引入模块,优先级 browser > module > main;如果使用 require 语法引入模块,优先级 main > module > browser

  1. jsdelivrunpkg(非标准)

由 CDN 服务商扩展的字段,用于指定项目在 CDN 上的入口文件。

  • jsdelivr,优先级 jsdelivr > browser > main
  • unpkg,优先级 unpkg > main

例如:github.com/vuejs/core/…

  1. type(非标准)

Node.js 扩展的字段,用于指定当前 package.json 范围下,所有 .js 文件的类型。包括 modulecommonjs 等。默认为 commonjs

例如:

  • typemodule 时,所有 .js 文件都会被当作 ES Module 处理。如果需要使用 CommonJS,可以使用 .cjs 后缀。
  • typecommonjs 时,所有 .js 文件都会被当作 CommonJS 处理。如果需要使用 ES Module,可以使用 .mjs 后缀。
  1. bin

用于指定项目的可执行文件,可以是一个或多个文件。

{
  "bin": {
    "project": "./bin/project.js"
  }
}

用户在安装项目时,会将可执行文件链接到全局环境变量中,以便于用户在命令行中使用 project 直接执行。

  1. types(非标准)

TypeScript 扩展的字段,用于指定项目的类型定义文件。

{
  "types": "index.d.ts"
}

8. exports

用于指定项目的导出文件,可以是一个或多个文件。

在 commonjs 时代,包中的所有文件都是导出的,用户可以通过 require 语法引入任意文件。

但是在 ES Module 时代,可以通过 exports 字段指定导出文件,用户只能引入指定文件。

{
  "exports": {
    ".": "./index.js",
    "./module": "./module.js"
  }
}
{
  "exports": {
    ".": {
      "import": "./index.js",
      "require": "./index.cjs",
      "types": "./index.d.ts"
      "default": "./index.js"
    },
    "./module": {
      "import": "./module.js",
      "require": "./module.cjs",
      "types": "./module.d.ts"
      "default": "./module.js"
    }
  }
}

用户在引入项目时,会根据导出文件的配置,自动选择合适的文件进行导入。

  1. imports(非标准)

用于指定项目导入文件的 alias,必须以 # 开头。

{
  "imports": {
    "#package": "vue"
  }
}

10. files

用于指定项目发布到 npm 时,需要包含的文件和目录。

{
  "files": [
    "dist",
    "src"
  ]
}

以下文件总是会被包含,所以不用在 files 字段中指定:

  • package.json
  • README
  • LICENSELICENCE
  • main 字段指定的文件
  • bin 字段指定的文件
  1. sideEffects(非标准)

用于指定项目的副作用文件,即项目的文件是否有副作用。副作用文件是指在导入时会执行一些操作,而不是导出一些内容。副作用文件会影响 tree-shaking 的效果。

多数情况下可以直接设置为 false,这样打包工具就会自动删除不需要的 import。但是有些情况例外:

  • 注册全局事件监听器、修改全局状态等
  • 样式文件
  • Polyfill
{
  "sideEffects": false
}
  • false 表示项目的所有文件都没有副作用,可以进行 tree-shaking
  • true 表示项目的所有文件都有副作用,不可以进行 tree-shaking
  • ["*.css"] 表示项目的所有 css 文件都有副作用,不可以进行 tree-shaking

补充说明

Pure ESM package

Pure ESM package 是指只包含 ES Module 的包,不包含 CommonJS 的包。提出的目的是推进 ES Module 的使用,减少 CommonJS 的使用。

链接:gist.github.com/sindresorhu…

Pure ESM packagepackage.json 配置如下:

{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/my-lib.js"
      "types": "./dist/my-lib.d.ts"
    }
  }
  "engines": {
    "node": ">=18"
  }
}

import 可以导入 commonjs 和 ES Module 文件,require() 只能导入 commonjs 文件。如果想在 commonjs 中引入 Pure ESM package 中的 js 文件,可以使用 import() 函数。

从 Node.js 22 开始,require() 也可以同步导入 ES Module 文件,但是强烈建议向 import 迁移。

对于任意一个包,推荐的 package.json 的最佳实践是:

{
  "name": "my-lib",
  "type": "module", // 优先使用 ES Module
  "files": ["dist"],
  "main": "./dist/my-lib.umd.cjs", // 兼容 CommonJS
  "module": "./dist/my-lib.js", // 兼容旧的打包工具
  "types": "./dist/my-lib.d.ts", // 提供类型定义
  "exports": { // 使用新规范来导出文件
    ".": {
      "import": "./dist/my-lib.js",
      "require": "./dist/my-lib.umd.cjs",
      "types": "./dist/my-lib.d.ts"
    }
  },
  "sideEffects": false, // 无副作用,按需设置
}

项目依赖

  1. dependenciesdevDependencies

项目依赖和开发依赖,用于指定项目的依赖包及版本号。

{
  "dependencies": {
    "vue": "^3.0.0"
  },
  "devDependencies": {
    "webpack": "^5.0.0"
  }
}
  • 项目依赖用于指定在生产环境下需要的依赖包。或者说是项目运行时需要的依赖包。
  • 开发依赖用于指定在开发环境下需要的依赖包。包括构建工具、测试工具、代码检查工具等。

当用户安装项目时,只会安装 dependencies 中的依赖包,不会安装 devDependencies 中的依赖包。

  1. peerDependencies

对等依赖,这表示项目依赖于其他包运行,但是不会将其打包到项目中,而是期望用户在安装项目时,手动安装这些包。

例如:

  • 当前开发的包 A 是 B 的一个插件,A 依赖于 B 运行,但是 B 不会打包到 A 中,而是期望用户在安装 A 时,手动安装 B。
  • 当前包 A 依赖 C,当 A 被 B 引用时,B 项目已经安装了 C 的特定版本,当这个版本与 A 依赖的版本不一致时,会提示用户安装正确的版本。
{
  "peerDependencies": {
    "vue": "^3.0.0"
  }
}

3. optionalDependencies

可选依赖,用于指定项目的可选依赖包。这表示项目依赖于其他包,但是不是必须的,如果用户安装了这些包,项目会使用这些包,如果没有安装,项目会正常运行。

{
  "optionalDependencies": {
    "colors": "^1.4.0"
  }
}

4. peerDependenciesMeta

定义对等依赖的元数据,用于指定对等依赖的一些特性,如:是否可选。

{
  "peerDependencies": {
    "vite": "^2.0.0"
  },
  "peerDependenciesMeta": {
    "vite": {
      "optional": true
    }
  }
}

设置 optionaltrue,表示对等依赖是可选的。如果没有安装对等依赖,也不会报错。

  • 当前包依赖的版本和用户安装的版本不一致
  • 当前包还可以在其他环境下运行,不依赖于对等依赖。例如:Vite 和 Rollup

它和 optionalDependencies 的区别是 optionalDependencies 会直接被引用,而 peerDependencies 不会被直接引用。

项目的打包

常见的打包工具有 Webpack、Rollup、Vite 等。按照项目的需求,选择合适的打包工具进行打包。

个人推荐:一般的包,都使用 Rollup 进行打包,因为 Rollup 的打包速度快,打包结果体积小。对于 Vue 项目,使用 Vite 进行打包。

为了创建更加通用的包,推荐使用 TypeScript 进行开发。打包时同时生成 CommonJS 和 ES Module 文件,并且提供类型定义文件。

Webpack

Webpack 打包输出 ES Module 格式还处于试验阶段,要打包输出 ES Module 格式,需要配置 experiments.outputModuletrue

export default {
  experiments: {
    outputModule: true,
  },
  output: {
    filename: 'index.js',
    library: {
      type: 'module',
    },
  },
}

而且 Webpack 打包的结果会包含一些额外的代码,如:__webpack_require__ 等,这些代码会影响打包结果的体积。

所以,Webpack 打包的结果不适合作为库发布,适合作为应用程序的打包工具。

Rollup

Rollup 对 ES Module 的支持更好,打包结果更加纯净,适合作为库的打包工具。

export default {
  input: 'src/index.js',
  output: [
    {
      entryFileNames: '[name].js',
      dir: 'dist',
      format: 'es',
    },
    {
      entryFileNames: '[name].cjs',
      dir: 'dist',
      format: 'cjs',
    },
    {
      entryFileNames: '[name].browser.js',
      dir: 'dist',
      format: 'iife',
    },
  ],
}

preserveModules 选项可以保留模块结构,不会将所有模块打包成一个文件。

export default {
  input: 'src/index.js',
  output: {
    entryFileNames: '[name].js',
    dir: 'dist',
    format: 'es',
    preserveModules: true,
    preserveModulesRoot: 'src',
  },
}

Vite

Vite 是一个基于 Rollup 的打包工具,基本配置和 Rollup 一样,打包库时需要使用 Library Mode

export default {
  build: {
    lib: {
      entry: ['src/index.js'],
      formats: ['es', 'cjs', 'iife'], // 打包输出格式
      name: 'MyLib',
      fileName: '[name]',
    },
    outDir: 'dist',
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: {
          vue: 'Vue',
        },
      },
    },
  },
}

Bebel.js ?

在打包时,虽然可以使用 Babel.js 进行语法转换,但是不推荐使用。

因为使用 Babel.js 会添加很多 Polyfill 代码,而通常主项目已经配置了 Babel.js,这会导致打包结果中包含了很多重复的代码。

所以我推荐不在库中使用 Babel.js,而是通过配置主项目来处理。(browser 文件除外。)

例如,在 Vue-cli 中,设置 transpileDependencies

module.exports = {
  transpileDependencies: [/@vue[/\].*/],
}

而在 Vite 中,默认的 target 配置为:

['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14']

而且 esbuild 打包时会自动转换语法。

如果需要兼容老的浏览器,@vitejs/plugin-legacy 插件可以帮助我们自动转换语法和添加 Polyfill。

import { defineConfig } from 'vite'
import legacy from '@vitejs/plugin-legacy'

export default defineConfig({
  plugins: [
    legacy({
      polyfills: true,
      targets: ['defaults', 'not IE 11'],
    }),
  ],
})

外部依赖

在打包库时,默认情况下会将所有依赖包打包到库中,这会导致打包结果体积过大。并且在用户使用库时,如果用户的项目中已经安装了这些依赖包,会导致重复打包。

由于安装时会自动安装依赖包,所以在打包库时,可以将依赖包设置为外部依赖,打包结果中就不包含这些依赖包。 源码:

import mitt from 'mitt'

export const rollup = 1

export const bus = mitt()

排除前:

排除后:

项目的发布

在发布项目之前,最好先进行测试,确保项目的功能正常。然后更新项目的版本号。

配置好需要发布的文件和目录,确保 package.json 中的 files 字段包含了所有需要发布的文件和目录。

{
  "files": ["dist"]
}

如果 package.json 中的 files 字段不存在,会默认发布所有文件和目录。

确保 package.json 中的 private 字段为 false,否则无法发布。

{
  "private": false
}

发布配置

package.json 中的 publishConfig 字段用于指定发布配置,包括发布的目标地址、发布的 tag 等。

{
  "publishConfig": {
    "registry": "https://npm-fe.transsion.com/",
    "tag": "latest"
  }
}

PNPM 对 publishConfig 进行了扩展:pnpm.io/package_jso…

使我们得以在发布时,替换 package.json 中的字段,如:mainmoduleexports 等。

一个问题

由于 exports 的存在,我们在引入文件时可以忽略物理目录结构,只需要配置 exports 字段即可。但是在不支持 exports 的环境中,我们需要保留物理目录结构,以正常引入文件。

为了使不同环境下导入文件时具有相同目录结构,最好在发布时避免目录的嵌套,例如去掉 dist 目录。

PNPM 提供的 publishConfig.directory 字段可以指定发布的目录。

{
  "publishConfig": {
    "directory": "dist",
    "linkDirectory": true
  }
}