如何用TypeScript和Webpack将JS库重构为长尾形式的库?

2026-04-11 11:281阅读0评论SEO资讯
  • 内容介绍
  • 文章标签
  • 相关推荐

本文共计4273个文字,预计阅读时间需要18分钟。

如何用TypeScript和Webpack将JS库重构为长尾形式的库?

记录使用TypeScript配合Webpack打包一个JavaScript library的配置过程。目标是构建一个同时支持`CommonJs`、`esm`、`amd`这几个JS模块系统的JavaScript库,并单独打包出一个CSS样式文件。

记录使用typescript配合webpack打包一个javascript library的配置过程. 目标是构建一个可以同时支持`CommonJs`, `esm`, `amd`这个几个js模块系统的javascript库, 然后还有一个单独打包出一个css的样式文件的需求.
  • 依赖说明
  • 入口文件
  • tsconfig配置
  • webpack配置文件
    • webpack入口文件配置
    • webpack为typescript和less文件配置各自的loader
    • webpack的output配置
    • 运行webpack进行打包
    • 测试验证
  • 输出esm模块
    • 已经输出了umd格式的js了, 为什么还要输出esm模块? ----TreeShaking
    • 用tsc输出esm和类型声明文件
  • 完善package.json文件
    • package.json中添加exports配置声明模块导出路径
  • 用api-extractor提取出干净的.d.ts
    • 配置使用API extractor
    • 更新package.json
    • 用@internal标注只希望在内部使用的class
  • 小结

记录使用typescript配合webpack打包一个javascript library的配置过程.

目标是构建一个可以同时支持CommonJs, esm, amd这个几个js模块系统的javascript库, 然后还有一个单独打包出一个css的样式文件的需求.

为此以构建一个名为loaf的javascript库为例; 首先新建项目文件目录loaf, 并进入此文件目录执行npm init命令, 然后按照控制台的提示输入对应的信息, 完成后就会在loaf目录下得到一个package.json文件

然后使用npm i命令安装所需的依赖

npm i -D webpack webpack-cli typescript babel-loader @babel/core @babel/preset-env @babel/preset-typescript ts-node @types/node @types/webpack mini-css-extract-plugin css-minimizer-webpack-plugin less less-loader terser-webpack-plugin 依赖说明

  • webpack webpack-cli: webpack打包工具和webpack命令行接口
  • typescript: 用于支持typescript语言
  • babel-loader @babel/core @babel/preset-env @babel/preset-typescript: babel相关的东西, 主要是需要babel-loader将编写的typescript代码转译成es5或es6已获得更好的浏览器兼容性
  • ts-node @types/node @types/webpack: 安装这几个包是为了能用typescript编写webpack配置文件(webpack.config.ts)
  • mini-css-extract-plugin less less-loader: 编译提取less文件到单独的css文件的相关依赖
  • css-minimizer-webpack-plugin terser-webpack-plugin: 用于最小化js和css文件尺寸的webpack插件
入口文件

通常使用index.ts作为入口, 并将其放到src目录下, 由于有输出样式文件的需求, 所以还要新建styles/index.less

mkdir src && touch src/index.ts mkdir src/styles && touch src/styles/index.less tsconfig配置

新建tsconfig.json文件

touch tsconfig.json

填入以下配置(部分选项配有注释):

{ "compilerOptions": { "outDir": "dist/lib", "sourceMap": false, "noImplicitAny": true, "module": "commonjs", // 开启这个选项, 可以让你直接通过`import`的方式来引用commonjs模块 // 这样你的代码库中就可以统一的使用import导入依赖了, 而不需要另外再使用require导入commonjs模块 "esModuleInterop": true, // 是否允许合成默认导入 // 开启后, 依赖的模块如果没有导出默认的模块 // 那么typescript会帮你给该模块自动合成一个默认导出让你可以通过默认导入的方式引用该模块 "allowSyntheticDefaultImports": true, // 是否生成`.d.ts`的类型声明文件 "declaration": true, // 输出的目标js版本, 这里用es6, 然后配和babel进行转译才以获得良好的浏览器兼容 "target": "es6", "allowJs": true, "moduleResolution": "node", "lib": ["es2015", "dom"], "declarationMap": true, // 启用严格的null检查 "strictNullChecks": true, // 启用严格的属性初始化检查 // 启用后类属性必须显示标注为可空或赋一个非空的初始值 "strictPropertyInitialization": true }, "exclude": ["node_modules"], "include": ["src/**/*"] } webpack配置文件

创建webpack.config.ts

touch webpack.config.ts

webpack.config.ts

import path from "path"; import { Configuration, Entry } from "webpack"; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import CssMinimizer from 'css-minimizer-webpack-plugin'; import TerserPlugin from 'terser-webpack-plugin' const isProd = process.env.NODE_ENV === 'production'; /** * 这里用到了webpack的[Multiple file types per entry](webpack.js.org/guides/entry-advanced/)特性 * 注意`.less`入口文件必须放在`.ts`文件前 */ const entryFiles: string[] = ['./src/styles/index.less', './src/index.ts']; const entry: Entry = { index: entryFiles, 'index.min': entryFiles, }; const config: Configuration = { entry, optimization: { minimize: true, minimizer: [ new TerserPlugin({ test: /.min.js$/ }), new CssMinimizer({ test: /.min.css$/, }), ], }, module: { rules: [ { test: /.ts$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/env', '@babel/typescript'], }, }, { test: /.less$/, use: [ isProd ? MiniCssExtractPlugin.loader : 'style-loader', { loader: 'css-loader', }, 'postcss-loader', 'less-loader', ], }, ], }, output: { path: path.resolve(__dirname, 'dist/umd'), library: { type: 'umd', name: { amd: 'loaf', commonjs: 'loaf', root: 'loaf', }, }, }, resolve: { extensions: ['.ts', '.less'], }, devtool: 'source-map', plugins: [ new MiniCssExtractPlugin({ filename: '[name].css', }), ], }; export default config;

webpack入口文件配置

... const isProd = process.env.NODE_ENV === 'production'; /** * 这里用到了webpack的[Multiple file types per entry](webpack.js.org/guides/entry-advanced/)特性 * 注意`.less`入口文件必须放在`.ts`文件前 */ const entryFiles: string[] = ['./src/styles/index.less', './src/index.ts']; const entry: Entry = { index: entryFiles, 'index.min': entryFiles, }; const config: Configuration = { entry, ... } ...

在上面的webpack.config.json中,我们配置了两个入口分别是indexindex.min,不难看出,多出的一个index.min入口是为了经过压缩后js和css文件,在生产环境使用一般都会使用.min.js结尾的文件以减少网络传输时的尺寸; 实现这个还需要结合optimization相关配置, 如下:

optimization: { minimize: true, minimizer: [ new TerserPlugin({ test: /.min.js$/ }), new CssMinimizer({ test: /.min.css$/, }), ], },

另外,indexindex.min的值都是相同的entryFiles对象,这个对象是一个字符串数组,里面放的就是我们的入口文件相对路径,这里一定要注意把./src/styles/index.less置于./src/index.ts之前。

webpack为typescript和less文件配置各自的loader

配置完入口后, 就需要为typescript和less代码配置各自的loader

module: { rules: [ { test: /.ts$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/env', '@babel/typescript'], }, }, { test: /.less$/, use: [ isProd ? MiniCssExtractPlugin.loader : 'style-loader', { loader: 'css-loader', }, 'postcss-loader', 'less-loader', ], }, ], },

  • mini-css-extract-plugin less less-loader: 编译提取less文件到单独的css文件的相关依赖
    上面的配置为.ts结尾的文件配置了babel-loader; 为.less结尾的文件配置一串loader, 使用了use, use中的loader的执行顺序是从后往前的, 上面less的配置就是告诉webpack遇到less文件时, 一次用less-loader->postcss-loader->css-loader->生产环境用 MiniCssExtractPlugin.loader() 否则用 style-loader;

MiniCssExtractPlugin.loader使用前要先在plugins进行初始化

... const config = { ... plugins: [ new MiniCssExtractPlugin({ filename: '[name].css', }), ], ... } ...webpack的output配置

... const config = { ... output: { path: path.resolve(__dirname, 'dist/umd'), library: { type: 'umd', name: { amd: 'loaf', commonjs: 'loaf', root: 'loaf', }, }, }, ... } ...

这里配置webpack以umd的方式输出到相对目录dist/umd目录中, umdUniversal Module Definition(通用模块定义)的缩写, umd格式输出library允许用户通过commonjs, AMD, <script src="...">的方式对library进行引用config.library.name可以为不同的模块系统配置不同的导出模块名供客户端来进行引用; 由于这里的导出模块名都是loaf, 所以也可以直接config.library.name设置成loaf.

如何用TypeScript和Webpack将JS库重构为长尾形式的库?

运行webpack进行打包

现在回到最开始通过npm init生成的package.json文件, 在修改其内容如下

{ "name": "loaf", "version": "1.0.0", "description": "A demo shows how to create & build a javascript library with webpack & typescript", "main": "index.js", "scripts": { "build:umd": "webpack -c webpack.config.ts --node-env production --env NODE_ENV=production", "test": "npm run test" }, "keywords": [ "demo" ], "author": "laggage", "license": "MIT", "devDependencies": { "@babel/core": "^7.18.6", "@babel/preset-env": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@types/node": "^18.0.0", "@types/webpack": "^5.28.0", "babel-loader": "^8.2.5", "css-loader": "^6.7.1", "css-minimizer-webpack-plugin": "^4.0.0", "less": "^4.1.3", "less-loader": "^11.0.0", "mini-css-extract-plugin": "^2.6.1", "postcss-loader": "^7.0.0", "style-loader": "^3.3.1", "terser-webpack-plugin": "^5.3.3", "ts-node": "^10.8.2", "typescript": "^4.7.4", "webpack": "^5.73.0", "webpack-cli": "^4.10.0" } }

新增了一个脚本命令 "build:umd": "webpack -c webpack.config.ts --node-env production --env NODE_ENV=production", 然后命令行到项目目录下执行npm run build:umd, 不出意外应该就构建成功了, 此时生成的dist目录结构如下

dist └── umd ├── index.css ├── index.js ├── index.js.map ├── index.min.css ├── index.min.js └── index.min.js.map 1 directory, 6 files 测试验证

新建demo.html进行测试

mkdir demo && touch demo/demo.html

demo/demo.html

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta api-extractor.com */ { "$schema": "developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", /** * Optionally specifies another JSON config file that this file extends from. This provides a way for * standard settings to be shared across multiple projects. * * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be * resolved using NodeJS require(). * * SUPPORTED TOKENS: none * DEFAULT VALUE: "" */ // "extends": "./shared/api-extractor-base.json" // "extends": "my-package/include/api-extractor-base.json" /** * Determines the "<projectFolder>" token that can be used with other config file settings. The project folder * typically contains the tsconfig.json and package.json config files, but the path is user-defined. * * The path is resolved relative to the folder of the config file that contains the setting. * * The default value for "projectFolder" is the token "<lookup>", which means the folder is determined by traversing * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error * will be reported. * * SUPPORTED TOKENS: <lookup> * DEFAULT VALUE: "<lookup>" */ // "projectFolder": "..", /** * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor * analyzes the symbols exported by this module. * * The file extension must be ".d.ts" and not ".ts". * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> */ "mainEntryPointFilePath": "<projectFolder>/dist/typings-temp/index.d.ts", /** * A list of NPM package names whose exports should be treated as part of this package. * * For example, suppose that Webpack is used to generate a distributed bundle for the project "library1", * and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part * of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly * imports library2. To avoid this, we can specify: * * "bundledPackages": [ "library2" ], * * This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been * local files for library1. */ "bundledPackages": [], /** * Determines how the TypeScript compiler engine will be invoked by API Extractor. */ "compiler": { /** * Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * Note: This setting will be ignored if "overrideTsconfig" is used. * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/tsconfig.json" */ // "tsconfigFilePath": "<projectFolder>/tsconfig.json", /** * Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk. * The object must conform to the TypeScript tsconfig schema: * * json.schemastore.org/tsconfig * * If omitted, then the tsconfig.json file will be read from the "projectFolder". * * DEFAULT VALUE: no overrideTsconfig section */ // "overrideTsconfig": { // . . . // } /** * This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended * and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when * dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses * for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck. * * DEFAULT VALUE: false */ // "skipLibCheck": true, }, /** * Configures how the API report file (*.api.md) will be generated. */ "apiReport": { /** * (REQUIRED) Whether to generate an API report. */ "enabled": true /** * The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce * a full file path. * * The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/". * * SUPPORTED TOKENS: <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<unscopedPackageName>.api.md" */ // "reportFileName": "<unscopedPackageName>.api.md", /** * Specifies the folder where the API report file is written. The file name portion is determined by * the "reportFileName" setting. * * The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy, * e.g. for an API review. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/etc/" */ // "reportFolder": "<projectFolder>/etc/", /** * Specifies the folder where the temporary report file is written. The file name portion is determined by * the "reportFileName" setting. * * After the temporary file is written to disk, it is compared with the file in the "reportFolder". * If they are different, a production build will fail. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/temp/" */ // "reportTempFolder": "<projectFolder>/temp/" }, /** * Configures how the doc model file (*.api.json) will be generated. */ "docModel": { /** * (REQUIRED) Whether to generate a doc model file. */ "enabled": true /** * The output path for the doc model file. The file extension should be ".api.json". * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/temp/<unscopedPackageName>.api.json" */ // "apiJsonFilePath": "<projectFolder>/temp/<unscopedPackageName>.api.json" }, /** * Configures how the .d.ts rollup file will be generated. */ "dtsRollup": { /** * (REQUIRED) Whether to generate the .d.ts rollup file. */ "enabled": true, /** * Specifies the output path for a .d.ts rollup file to be generated without any trimming. * This file will include all declarations that are exported by the main entry point. * * If the path is an empty string, then this file will not be written. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts" */ "untrimmedFilePath": "<projectFolder>/dist/typing/index.d.ts" /** * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release. * This file will include only declarations that are marked as "@public" or "@beta". * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "" */ // "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts", /** * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release. * This file will include only declarations that are marked as "@public". * * If the path is an empty string, then this file will not be written. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "" */ // "publicTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-public.d.ts", /** * When a declaration is trimmed, by default it will be replaced by a code comment such as * "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the * declaration completely. * * DEFAULT VALUE: false */ // "omitTrimmingComments": true }, /** * Configures how the tsdoc-metadata.json file will be generated. */ "tsdocMetadata": { /** * Whether to generate the tsdoc-metadata.json file. * * DEFAULT VALUE: true */ // "enabled": true, /** * Specifies where the TSDoc metadata file should be written. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * The default value is "<lookup>", which causes the path to be automatically inferred from the "tsdocMetadata", * "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup * falls back to "tsdoc-metadata.json" in the package folder. * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<lookup>" */ // "tsdocMetadataFilePath": "<projectFolder>/dist/tsdoc-metadata.json" }, /** * Specifies what type of newlines API Extractor should use when writing output files. By default, the output files * will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead. * To use the OS's default newline kind, specify "os". * * DEFAULT VALUE: "crlf" */ // "newlineKind": "crlf", /** * Configures how API Extractor reports error and warning messages produced during analysis. * * There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages. */ "messages": { /** * Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing * the input .d.ts files. * * TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551" * * DEFAULT VALUE: A single "default" entry with logLevel=warning. */ "compilerMessageReporting": { /** * Configures the default routing for messages that don't match an explicit rule in this table. */ "default": { /** * Specifies whether the message should be written to the the tool's output log. Note that * the "addToApiReportFile" property may supersede this option. * * Possible values: "error", "warning", "none" * * Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail * and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes * the "--local" option), the warning is displayed but the build will not fail. * * DEFAULT VALUE: "warning" */ "logLevel": "warning" /** * When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md), * then the message will be written inside that file; otherwise, the message is instead logged according to * the "logLevel" option. * * DEFAULT VALUE: false */ // "addToApiReportFile": false } // "TS2551": { // "logLevel": "warning", // "addToApiReportFile": true // }, // // . . . }, /** * Configures handling of messages reported by API Extractor during its analysis. * * API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag" * * DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings */ "extractorMessageReporting": { "default": { "logLevel": "warning" // "addToApiReportFile": false } // "ae-extra-release-tag": { // "logLevel": "warning", // "addToApiReportFile": true // }, // // . . . }, /** * Configures handling of messages reported by the TSDoc parser when analyzing code comments. * * TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text" * * DEFAULT VALUE: A single "default" entry with logLevel=warning. */ "tsdocMessageReporting": { "default": { "logLevel": "warning" // "addToApiReportFile": false } // "tsdoc-link-tag-unescaped-text": { // "logLevel": "warning", // "addToApiReportFile": true // }, // // . . . } } }

更新package.json

{ ... "scripts": { "build": "shx rm -rf dist/** && npm run build:umd && npm run build:lib-esm && npm run build:extract-api", "build:umd": "webpack -c webpack.config.ts --node-env production --env NODE_ENV=production", "build:lib-esm": "tsc -p tsconfig.json --declarationDir ./dist/typings-temp -m es6 --outDir dist/lib-esm", "build:extract-api": "api-extractor run && shx rm -rf dist/typings-temp", "build:extract-api-local": "shx mkdir -p ./etc && npm run build:lib-esm && api-extractor run -l", "test": "npm run test" }, ... }

注意, 这里处理新增了一个build:extract-api到scripts配置中, 还修改了build:lib-esm的配置, 将其输出的typescript类型声明文件放到了, typings-temp目录中, 最后这个目录是要删除; 还要注意, 每次提交代码到版本管理工具前, 要先运行npm run build:extract-api-local, 这个命令会生成./etc/<libraryName>.api.md文件, 这个文件是api-extractor生成的api文档, 应该要放到版本管理工具中去的, 以便可以看到每次提交代码时API的变化.

用@internal标注只希望在内部使用的class

比如, 我希望Bar类不能被此库的使用者使用, 我可以加上下面这段注释

/** * * @internal */ export class Bar { bar() {} }

然后来看看生成的index.d.ts文件:

/** * * @internal */ declare class Bar { bar(): void; } export declare class Foo { private _bar; constructor(_bar?: Bar); foo(): void; loaf(): void; } export { }

可以看出index.d.ts文件中虽然declare了Bar, 但是并未导出Bar

这个特性是由api-extractor提供的, 更多api-extractor的内容移步官方文档

小结

至此, 我们就可以构建一个可以通过诸如AMD CommonJs esm等js模块系统或是使用script标签的方式引用的js库了, 主要用到了webpack typescript api-extractor这些工具. 完整的示例代码可以访问github-laggage/loaf查看.

本文共计4273个文字,预计阅读时间需要18分钟。

如何用TypeScript和Webpack将JS库重构为长尾形式的库?

记录使用TypeScript配合Webpack打包一个JavaScript library的配置过程。目标是构建一个同时支持`CommonJs`、`esm`、`amd`这几个JS模块系统的JavaScript库,并单独打包出一个CSS样式文件。

记录使用typescript配合webpack打包一个javascript library的配置过程. 目标是构建一个可以同时支持`CommonJs`, `esm`, `amd`这个几个js模块系统的javascript库, 然后还有一个单独打包出一个css的样式文件的需求.
  • 依赖说明
  • 入口文件
  • tsconfig配置
  • webpack配置文件
    • webpack入口文件配置
    • webpack为typescript和less文件配置各自的loader
    • webpack的output配置
    • 运行webpack进行打包
    • 测试验证
  • 输出esm模块
    • 已经输出了umd格式的js了, 为什么还要输出esm模块? ----TreeShaking
    • 用tsc输出esm和类型声明文件
  • 完善package.json文件
    • package.json中添加exports配置声明模块导出路径
  • 用api-extractor提取出干净的.d.ts
    • 配置使用API extractor
    • 更新package.json
    • 用@internal标注只希望在内部使用的class
  • 小结

记录使用typescript配合webpack打包一个javascript library的配置过程.

目标是构建一个可以同时支持CommonJs, esm, amd这个几个js模块系统的javascript库, 然后还有一个单独打包出一个css的样式文件的需求.

为此以构建一个名为loaf的javascript库为例; 首先新建项目文件目录loaf, 并进入此文件目录执行npm init命令, 然后按照控制台的提示输入对应的信息, 完成后就会在loaf目录下得到一个package.json文件

然后使用npm i命令安装所需的依赖

npm i -D webpack webpack-cli typescript babel-loader @babel/core @babel/preset-env @babel/preset-typescript ts-node @types/node @types/webpack mini-css-extract-plugin css-minimizer-webpack-plugin less less-loader terser-webpack-plugin 依赖说明

  • webpack webpack-cli: webpack打包工具和webpack命令行接口
  • typescript: 用于支持typescript语言
  • babel-loader @babel/core @babel/preset-env @babel/preset-typescript: babel相关的东西, 主要是需要babel-loader将编写的typescript代码转译成es5或es6已获得更好的浏览器兼容性
  • ts-node @types/node @types/webpack: 安装这几个包是为了能用typescript编写webpack配置文件(webpack.config.ts)
  • mini-css-extract-plugin less less-loader: 编译提取less文件到单独的css文件的相关依赖
  • css-minimizer-webpack-plugin terser-webpack-plugin: 用于最小化js和css文件尺寸的webpack插件
入口文件

通常使用index.ts作为入口, 并将其放到src目录下, 由于有输出样式文件的需求, 所以还要新建styles/index.less

mkdir src && touch src/index.ts mkdir src/styles && touch src/styles/index.less tsconfig配置

新建tsconfig.json文件

touch tsconfig.json

填入以下配置(部分选项配有注释):

{ "compilerOptions": { "outDir": "dist/lib", "sourceMap": false, "noImplicitAny": true, "module": "commonjs", // 开启这个选项, 可以让你直接通过`import`的方式来引用commonjs模块 // 这样你的代码库中就可以统一的使用import导入依赖了, 而不需要另外再使用require导入commonjs模块 "esModuleInterop": true, // 是否允许合成默认导入 // 开启后, 依赖的模块如果没有导出默认的模块 // 那么typescript会帮你给该模块自动合成一个默认导出让你可以通过默认导入的方式引用该模块 "allowSyntheticDefaultImports": true, // 是否生成`.d.ts`的类型声明文件 "declaration": true, // 输出的目标js版本, 这里用es6, 然后配和babel进行转译才以获得良好的浏览器兼容 "target": "es6", "allowJs": true, "moduleResolution": "node", "lib": ["es2015", "dom"], "declarationMap": true, // 启用严格的null检查 "strictNullChecks": true, // 启用严格的属性初始化检查 // 启用后类属性必须显示标注为可空或赋一个非空的初始值 "strictPropertyInitialization": true }, "exclude": ["node_modules"], "include": ["src/**/*"] } webpack配置文件

创建webpack.config.ts

touch webpack.config.ts

webpack.config.ts

import path from "path"; import { Configuration, Entry } from "webpack"; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import CssMinimizer from 'css-minimizer-webpack-plugin'; import TerserPlugin from 'terser-webpack-plugin' const isProd = process.env.NODE_ENV === 'production'; /** * 这里用到了webpack的[Multiple file types per entry](webpack.js.org/guides/entry-advanced/)特性 * 注意`.less`入口文件必须放在`.ts`文件前 */ const entryFiles: string[] = ['./src/styles/index.less', './src/index.ts']; const entry: Entry = { index: entryFiles, 'index.min': entryFiles, }; const config: Configuration = { entry, optimization: { minimize: true, minimizer: [ new TerserPlugin({ test: /.min.js$/ }), new CssMinimizer({ test: /.min.css$/, }), ], }, module: { rules: [ { test: /.ts$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/env', '@babel/typescript'], }, }, { test: /.less$/, use: [ isProd ? MiniCssExtractPlugin.loader : 'style-loader', { loader: 'css-loader', }, 'postcss-loader', 'less-loader', ], }, ], }, output: { path: path.resolve(__dirname, 'dist/umd'), library: { type: 'umd', name: { amd: 'loaf', commonjs: 'loaf', root: 'loaf', }, }, }, resolve: { extensions: ['.ts', '.less'], }, devtool: 'source-map', plugins: [ new MiniCssExtractPlugin({ filename: '[name].css', }), ], }; export default config;

webpack入口文件配置

... const isProd = process.env.NODE_ENV === 'production'; /** * 这里用到了webpack的[Multiple file types per entry](webpack.js.org/guides/entry-advanced/)特性 * 注意`.less`入口文件必须放在`.ts`文件前 */ const entryFiles: string[] = ['./src/styles/index.less', './src/index.ts']; const entry: Entry = { index: entryFiles, 'index.min': entryFiles, }; const config: Configuration = { entry, ... } ...

在上面的webpack.config.json中,我们配置了两个入口分别是indexindex.min,不难看出,多出的一个index.min入口是为了经过压缩后js和css文件,在生产环境使用一般都会使用.min.js结尾的文件以减少网络传输时的尺寸; 实现这个还需要结合optimization相关配置, 如下:

optimization: { minimize: true, minimizer: [ new TerserPlugin({ test: /.min.js$/ }), new CssMinimizer({ test: /.min.css$/, }), ], },

另外,indexindex.min的值都是相同的entryFiles对象,这个对象是一个字符串数组,里面放的就是我们的入口文件相对路径,这里一定要注意把./src/styles/index.less置于./src/index.ts之前。

webpack为typescript和less文件配置各自的loader

配置完入口后, 就需要为typescript和less代码配置各自的loader

module: { rules: [ { test: /.ts$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/env', '@babel/typescript'], }, }, { test: /.less$/, use: [ isProd ? MiniCssExtractPlugin.loader : 'style-loader', { loader: 'css-loader', }, 'postcss-loader', 'less-loader', ], }, ], },

  • mini-css-extract-plugin less less-loader: 编译提取less文件到单独的css文件的相关依赖
    上面的配置为.ts结尾的文件配置了babel-loader; 为.less结尾的文件配置一串loader, 使用了use, use中的loader的执行顺序是从后往前的, 上面less的配置就是告诉webpack遇到less文件时, 一次用less-loader->postcss-loader->css-loader->生产环境用 MiniCssExtractPlugin.loader() 否则用 style-loader;

MiniCssExtractPlugin.loader使用前要先在plugins进行初始化

... const config = { ... plugins: [ new MiniCssExtractPlugin({ filename: '[name].css', }), ], ... } ...webpack的output配置

... const config = { ... output: { path: path.resolve(__dirname, 'dist/umd'), library: { type: 'umd', name: { amd: 'loaf', commonjs: 'loaf', root: 'loaf', }, }, }, ... } ...

这里配置webpack以umd的方式输出到相对目录dist/umd目录中, umdUniversal Module Definition(通用模块定义)的缩写, umd格式输出library允许用户通过commonjs, AMD, <script src="...">的方式对library进行引用config.library.name可以为不同的模块系统配置不同的导出模块名供客户端来进行引用; 由于这里的导出模块名都是loaf, 所以也可以直接config.library.name设置成loaf.

如何用TypeScript和Webpack将JS库重构为长尾形式的库?

运行webpack进行打包

现在回到最开始通过npm init生成的package.json文件, 在修改其内容如下

{ "name": "loaf", "version": "1.0.0", "description": "A demo shows how to create & build a javascript library with webpack & typescript", "main": "index.js", "scripts": { "build:umd": "webpack -c webpack.config.ts --node-env production --env NODE_ENV=production", "test": "npm run test" }, "keywords": [ "demo" ], "author": "laggage", "license": "MIT", "devDependencies": { "@babel/core": "^7.18.6", "@babel/preset-env": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@types/node": "^18.0.0", "@types/webpack": "^5.28.0", "babel-loader": "^8.2.5", "css-loader": "^6.7.1", "css-minimizer-webpack-plugin": "^4.0.0", "less": "^4.1.3", "less-loader": "^11.0.0", "mini-css-extract-plugin": "^2.6.1", "postcss-loader": "^7.0.0", "style-loader": "^3.3.1", "terser-webpack-plugin": "^5.3.3", "ts-node": "^10.8.2", "typescript": "^4.7.4", "webpack": "^5.73.0", "webpack-cli": "^4.10.0" } }

新增了一个脚本命令 "build:umd": "webpack -c webpack.config.ts --node-env production --env NODE_ENV=production", 然后命令行到项目目录下执行npm run build:umd, 不出意外应该就构建成功了, 此时生成的dist目录结构如下

dist └── umd ├── index.css ├── index.js ├── index.js.map ├── index.min.css ├── index.min.js └── index.min.js.map 1 directory, 6 files 测试验证

新建demo.html进行测试

mkdir demo && touch demo/demo.html

demo/demo.html

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta api-extractor.com */ { "$schema": "developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", /** * Optionally specifies another JSON config file that this file extends from. This provides a way for * standard settings to be shared across multiple projects. * * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be * resolved using NodeJS require(). * * SUPPORTED TOKENS: none * DEFAULT VALUE: "" */ // "extends": "./shared/api-extractor-base.json" // "extends": "my-package/include/api-extractor-base.json" /** * Determines the "<projectFolder>" token that can be used with other config file settings. The project folder * typically contains the tsconfig.json and package.json config files, but the path is user-defined. * * The path is resolved relative to the folder of the config file that contains the setting. * * The default value for "projectFolder" is the token "<lookup>", which means the folder is determined by traversing * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error * will be reported. * * SUPPORTED TOKENS: <lookup> * DEFAULT VALUE: "<lookup>" */ // "projectFolder": "..", /** * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor * analyzes the symbols exported by this module. * * The file extension must be ".d.ts" and not ".ts". * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> */ "mainEntryPointFilePath": "<projectFolder>/dist/typings-temp/index.d.ts", /** * A list of NPM package names whose exports should be treated as part of this package. * * For example, suppose that Webpack is used to generate a distributed bundle for the project "library1", * and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part * of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly * imports library2. To avoid this, we can specify: * * "bundledPackages": [ "library2" ], * * This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been * local files for library1. */ "bundledPackages": [], /** * Determines how the TypeScript compiler engine will be invoked by API Extractor. */ "compiler": { /** * Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * Note: This setting will be ignored if "overrideTsconfig" is used. * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/tsconfig.json" */ // "tsconfigFilePath": "<projectFolder>/tsconfig.json", /** * Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk. * The object must conform to the TypeScript tsconfig schema: * * json.schemastore.org/tsconfig * * If omitted, then the tsconfig.json file will be read from the "projectFolder". * * DEFAULT VALUE: no overrideTsconfig section */ // "overrideTsconfig": { // . . . // } /** * This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended * and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when * dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses * for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck. * * DEFAULT VALUE: false */ // "skipLibCheck": true, }, /** * Configures how the API report file (*.api.md) will be generated. */ "apiReport": { /** * (REQUIRED) Whether to generate an API report. */ "enabled": true /** * The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce * a full file path. * * The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/". * * SUPPORTED TOKENS: <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<unscopedPackageName>.api.md" */ // "reportFileName": "<unscopedPackageName>.api.md", /** * Specifies the folder where the API report file is written. The file name portion is determined by * the "reportFileName" setting. * * The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy, * e.g. for an API review. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/etc/" */ // "reportFolder": "<projectFolder>/etc/", /** * Specifies the folder where the temporary report file is written. The file name portion is determined by * the "reportFileName" setting. * * After the temporary file is written to disk, it is compared with the file in the "reportFolder". * If they are different, a production build will fail. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/temp/" */ // "reportTempFolder": "<projectFolder>/temp/" }, /** * Configures how the doc model file (*.api.json) will be generated. */ "docModel": { /** * (REQUIRED) Whether to generate a doc model file. */ "enabled": true /** * The output path for the doc model file. The file extension should be ".api.json". * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/temp/<unscopedPackageName>.api.json" */ // "apiJsonFilePath": "<projectFolder>/temp/<unscopedPackageName>.api.json" }, /** * Configures how the .d.ts rollup file will be generated. */ "dtsRollup": { /** * (REQUIRED) Whether to generate the .d.ts rollup file. */ "enabled": true, /** * Specifies the output path for a .d.ts rollup file to be generated without any trimming. * This file will include all declarations that are exported by the main entry point. * * If the path is an empty string, then this file will not be written. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts" */ "untrimmedFilePath": "<projectFolder>/dist/typing/index.d.ts" /** * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release. * This file will include only declarations that are marked as "@public" or "@beta". * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "" */ // "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts", /** * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release. * This file will include only declarations that are marked as "@public". * * If the path is an empty string, then this file will not be written. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "" */ // "publicTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-public.d.ts", /** * When a declaration is trimmed, by default it will be replaced by a code comment such as * "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the * declaration completely. * * DEFAULT VALUE: false */ // "omitTrimmingComments": true }, /** * Configures how the tsdoc-metadata.json file will be generated. */ "tsdocMetadata": { /** * Whether to generate the tsdoc-metadata.json file. * * DEFAULT VALUE: true */ // "enabled": true, /** * Specifies where the TSDoc metadata file should be written. * * The path is resolved relative to the folder of the config file that contains the setting; to change this, * prepend a folder token such as "<projectFolder>". * * The default value is "<lookup>", which causes the path to be automatically inferred from the "tsdocMetadata", * "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup * falls back to "tsdoc-metadata.json" in the package folder. * * SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName> * DEFAULT VALUE: "<lookup>" */ // "tsdocMetadataFilePath": "<projectFolder>/dist/tsdoc-metadata.json" }, /** * Specifies what type of newlines API Extractor should use when writing output files. By default, the output files * will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead. * To use the OS's default newline kind, specify "os". * * DEFAULT VALUE: "crlf" */ // "newlineKind": "crlf", /** * Configures how API Extractor reports error and warning messages produced during analysis. * * There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages. */ "messages": { /** * Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing * the input .d.ts files. * * TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551" * * DEFAULT VALUE: A single "default" entry with logLevel=warning. */ "compilerMessageReporting": { /** * Configures the default routing for messages that don't match an explicit rule in this table. */ "default": { /** * Specifies whether the message should be written to the the tool's output log. Note that * the "addToApiReportFile" property may supersede this option. * * Possible values: "error", "warning", "none" * * Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail * and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes * the "--local" option), the warning is displayed but the build will not fail. * * DEFAULT VALUE: "warning" */ "logLevel": "warning" /** * When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md), * then the message will be written inside that file; otherwise, the message is instead logged according to * the "logLevel" option. * * DEFAULT VALUE: false */ // "addToApiReportFile": false } // "TS2551": { // "logLevel": "warning", // "addToApiReportFile": true // }, // // . . . }, /** * Configures handling of messages reported by API Extractor during its analysis. * * API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag" * * DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings */ "extractorMessageReporting": { "default": { "logLevel": "warning" // "addToApiReportFile": false } // "ae-extra-release-tag": { // "logLevel": "warning", // "addToApiReportFile": true // }, // // . . . }, /** * Configures handling of messages reported by the TSDoc parser when analyzing code comments. * * TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text" * * DEFAULT VALUE: A single "default" entry with logLevel=warning. */ "tsdocMessageReporting": { "default": { "logLevel": "warning" // "addToApiReportFile": false } // "tsdoc-link-tag-unescaped-text": { // "logLevel": "warning", // "addToApiReportFile": true // }, // // . . . } } }

更新package.json

{ ... "scripts": { "build": "shx rm -rf dist/** && npm run build:umd && npm run build:lib-esm && npm run build:extract-api", "build:umd": "webpack -c webpack.config.ts --node-env production --env NODE_ENV=production", "build:lib-esm": "tsc -p tsconfig.json --declarationDir ./dist/typings-temp -m es6 --outDir dist/lib-esm", "build:extract-api": "api-extractor run && shx rm -rf dist/typings-temp", "build:extract-api-local": "shx mkdir -p ./etc && npm run build:lib-esm && api-extractor run -l", "test": "npm run test" }, ... }

注意, 这里处理新增了一个build:extract-api到scripts配置中, 还修改了build:lib-esm的配置, 将其输出的typescript类型声明文件放到了, typings-temp目录中, 最后这个目录是要删除; 还要注意, 每次提交代码到版本管理工具前, 要先运行npm run build:extract-api-local, 这个命令会生成./etc/<libraryName>.api.md文件, 这个文件是api-extractor生成的api文档, 应该要放到版本管理工具中去的, 以便可以看到每次提交代码时API的变化.

用@internal标注只希望在内部使用的class

比如, 我希望Bar类不能被此库的使用者使用, 我可以加上下面这段注释

/** * * @internal */ export class Bar { bar() {} }

然后来看看生成的index.d.ts文件:

/** * * @internal */ declare class Bar { bar(): void; } export declare class Foo { private _bar; constructor(_bar?: Bar); foo(): void; loaf(): void; } export { }

可以看出index.d.ts文件中虽然declare了Bar, 但是并未导出Bar

这个特性是由api-extractor提供的, 更多api-extractor的内容移步官方文档

小结

至此, 我们就可以构建一个可以通过诸如AMD CommonJs esm等js模块系统或是使用script标签的方式引用的js库了, 主要用到了webpack typescript api-extractor这些工具. 完整的示例代码可以访问github-laggage/loaf查看.