ライブラリとしての出力形態についての考察
#tech
ライブラリを作る際に構文の変換とponyfillをどこまでやるべきかで迷ったので、調べたり試した結果をまとめて考察してみる。
polyfillに関してはそもそも、そのメソッドやオブジェクトがコードで使われているかという静的に決定できる部分と、そのメソッドやオブジェクトが実行環境に存在するかという動的にしか決定できない部分とがあり、実行環境に存在しないもののみを読ませたいという場合は polyfill.io などを利用して動的に解決するしかないため、ここではpolyfillを含むライブラリを作る際にどの程度browserslistによるターゲットの指定に即した静的な出力が得られるのかについて見ていく。
構文の変換について
@rollup/plugin-babel にもあるように、アプリのビルドでは少なくとも構文の変換についてはアプリのコードのみを対象とするのが理想的で、ライブラリの作者は利用者がライブラリ中の .ts をコンパイルする必要がないようにすべきなのと同様に、esmを除くes6以上の構文を含む .js を配布すべきでないというのはおそらくかなり正しい主張だと思われる。
実際に babel-loader では基本的に exclude: /node_modules/ とするし、Reactも依存しているes6の Map や Set はpolyfillで補うこととしているが構文に関してはes5に変換して提供している。
https://github.com/rollup/plugins/tree/babel-v5.2.1/packages/babel#external-dependencies
https://github.com/babel/babel-loader/tree/v8.2.1#usage
https://ja.reactjs.org/docs/javascript-environment-requirements.html
https://github.com/facebook/react/blob/v17.0.1/babel.config.js
microbundle や @babel/preset-modules の例のように、esm以降向けの形式も追加で配布しているライブラリもある。
この場合は main か browser にes5相当の形式を指定し、module にesm以降向けの形式を指定することが多い。
module は非標準のプロパティだが、webpackなど多くのバンドラはこれを考慮して扱うファイルを変えている。
ちなみにwebpack@5はNode.jsでもサポートされている exports プロパティによる指定にも対応している。
このあたりの設定は、microbundle の実装や preact の package.json を参考にするのがよさそう。
Node.js向けに main と exports.require を、ブラウザ向けに module と exports.browser を一致させる感じか。
ちなみに main などと違い exports に記述するパスは ./ から始める必要があるようだ。`
https://github.com/developit/microbundle/tree/v0.12.4#-modern-mode-
https://github.com/babel/preset-modules/tree/0.1.4#installation--usage
https://docs.npmjs.com/cli/v6/configuring-npm/package-json#main
https://docs.npmjs.com/cli/v6/configuring-npm/package-json#browser
https://dwango-js.github.io/performance-handbook/startup/module-field/
https://github.com/rollup/rollup/wiki/pkg.module
https://webpack.js.org/configuration/resolve/#resolvemainfields
https://webpack.js.org/blog/2020-10-10-webpack-5-release/#resolving
https://nodejs.org/api/packages.html#packages_conditional_exports
https://webpack.js.org/guides/package-exports/
https://blog.cybozu.io/entry/2020/10/06/170000
https://github.com/preactjs/preact/blob/10.5.7/package.json
https://github.com/callstack/linaria/blob/v2.0.2/package.json#L42-L45
構文の変換は @babel/preset-modules の機能も bugfixes: true で有効にできる @babel/preset-env で行う。
対象は browserslist で設定し、例えば > 0.2%, not dead, not op_mini all, not ie 11 のようにする。
上述のように複数の形式で配布する場合は、babelもbrowserslistも NODE_ENV で設定を切り替えられるのでこれを利用する。
もしくは @babel/preset-env の targets で直接browserslistと同じ形式で対象を指定する。
アプリの場合は @babel/preset-env の useBuiltIns: "usage" と corejs でpolyfillを入れるが、これはグローバルスコープを汚染するためライブラリではまず設定せず、後述するようにグローバルスコープを汚染しないponyfillで考える。
@babel/plugin-transform-runtime と @babel/runtime でサイズを抑制でき、後述するがここでもponyfillを扱える。
@babel/preset-env が regenerator-runtime を必要とするコードを生成する場合は、microbundle でも使われている babel-plugin-transform-async-to-promises を使うと大抵は回避できる。
https://babeljs.io/docs/en/babel-preset-env#bugfixes
https://create-react-app.dev/docs/supported-browsers-features/#configuring-supported-browsers
https://github.com/browserslist/browserslist/tree/4.14.7#configuring-for-different-environments
https://babeljs.io/docs/en/config-files#apienv
https://github.com/zloirock/core-js/tree/v3.7.0#babelpreset-env
https://github.com/babel/babel-loader/tree/v8.2.1#babel-is-injecting-helpers-into-each-file-and-bloating-my-code
https://github.com/rpetrich/babel-plugin-transform-async-to-promises
https://github.com/babel/babel/issues/8121
https://babeljs.io/docs/en/plugins/#plugin-ordering
https://zenn.dev/sa2knight/articles/67f6f5cc4ed5e26e391c
ponyfillの同梱について
babel/babel#10008 や下記Mediumの記事のように、可搬性のためライブラリそれ自体にponyfillを含める選択は十分ありえる。
しかしこれは理想論であり、後述するように無駄も多いため利用者にグローバルにpolyfillしてもらう方がよさそうに思える。
実際に core-js を dependencies に持つライブラリは稀で、前述したようにReactもponyfillまではやっていない。
https://medium.com/@lee_85949/polyfilling-a-javascript-library-the-right-way-337806a54152
ponyfillの同梱は @babel/plugin-transform-runtime と @babel/runtime-corejs3 の併用や babel-plugin-polyfill-corejs3 と core-js-pure@3 の併用などで実現できるが、前者は useBuiltIns: "usage" 相当の挙動はするもののbrowserslistの設定は無視するためこれを改善した後者を method: "usage-pure" で利用するのがよい。
しかしこれでもcore-jsのファイル構成の関係で new Promise(r => ...) などとしただけで Promise.allSettled() のponyfillも読み込まれてしまうため、browserslistの設定を見て可能なら exclude: ["es.promise"] のようにするとよい。
一応core-jsはネイティブのものが使える場合はそれを使うそうだが、例えば Object.fromEntries() を使うとbrowserslistの設定に関わらず array.keys() までponyfillされてしまったりと無駄が多く、気にしていてもサイズは簡単に増えてしまう。
https://babeljs.io/docs/en/babel-plugin-transform-runtime#corejs
https://github.com/babel/babel-polyfills/tree/babel-plugin-polyfill-corejs3%400.0.7#history-and-motivation
https://github.com/zloirock/core-js/blob/v3.7.0/packages/core-js/es/promise/index.js
https://github.com/zloirock/core-js/tree/v3.7.0#configurable-level-of-aggressiveness
https://github.com/babel/babel-polyfills/blob/babel-plugin-polyfill-corejs3%400.0.7/docs/usage.md
まとめと考察
ここまでで構文に関してはes5相当に変換しpolyfillに関しては利用者に対応してもらうのがよさそうという結論になった。
これは構文の変換で問題が生じた場合はかなり手間だが、polyfillに関しては比較的対応しやすいという事実とも符号している。
ちなみにライブラリをバンドルすべきかについては、バンドルすると <script> で読ませやすくなるがアプリで利用する場合には最適化が効きにくくなるなど一長一短であり、両方の形式で配布している場合もある。
https://dwango-js.github.io/performance-handbook/startup/reduce-bundle/
https://webpack.js.org/guides/author-libraries/
https://webpack.js.org/blog/2020-12-08-roadmap-2021/#esm-library
ついでにとりあえずこんな感じになると思いますという設定も貼っておく。
これで lib に型定義とesm形式の lib/index.module.js とcjs形式の lib/index.js が吐かれる。
https://github.com/ahuglajbclajep/session-typed-worker
code:package.json
{
"main": "lib/index.js",
"module": "lib/index.module.js",
"types": "lib/index.d.ts",
"exports": {
"browser": "./lib/index.module.js",
"require": "./lib/index.js"
},
"scripts": {
"build": "rollup -c",
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.1",
"@rollup/plugin-babel": "^5.2.1",
"babel-plugin-transform-async-to-promises": "^0.8.15",
"rollup": "^2.33.2",
"rollup-plugin-typescript2": "^0.29.0",
"typescript": "^4.0.5"
},
}
code:rollup.config.js
import typescript from "rollup-plugin-typescript2";
import { getBabelOutputPlugin } from "@rollup/plugin-babel";
export default {
input: "src/index.ts",
output: [
{
file: "lib/index.module.js",
format: "es",
plugins: [
getBabelOutputPlugin({
presets: "@babel/env", { bugfixes: true },
}),
],
},
{
file: "lib/index.js",
format: "cjs",
plugins: [
getBabelOutputPlugin({
presets: "@babel/env", { ignoreBrowserslistConfig: true },
plugins: "babel-plugin-transform-async-to-promises",
}),
],
},
],
plugins: typescript(),
};
code:tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": "esnext", "dom", // for rollup-plugin-typescript2
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"newLine": "lf" // for rollup-plugin-typescript2
},
"include": "src/**/*", // for rollup-plugin-typescript2
"exclude": "src/**/*.spec.ts"
}
おまけ
browserslistでは supports es6-module でesmに対応している環境を指定できる
@babel/preset-env は ignoreBrowserslistConfig: true でbrowserslistの設定を無視し単純にes5に変換できる
https://babeljs.io/docs/en/babel-preset-env#ignorebrowserslistconfig
メソッドやオブジェクトがpolyfillの対象かは eslint-plugin-compat で確認できる
https://github.com/amilajack/eslint-plugin-compat
browserslistの設定も読み、互換性については caniuse-db と mdn-browser-compat-data の両方を見る
@babel/plugin-transform-runtime と @babel/runtime によるサイズの抑制は、tsc でビルドしている場合も tslib と "importHelpers": true で同様の効果が得られる。
https://github.com/microsoft/tslib/tree/2.0.3#usage
https://mizchi.dev/202008081732-effect-by-tslib
@rollup/plugin-typescript は型定義を作らないため microbundle も rollup-plugin-typescript2 を使っている
https://github.com/rollup/plugins/issues/394
https://github.com/developit/microbundle/issues/63
rollup-plugin-typescript2 も import type は無視するが "include": ["src/**/*"] などで回避できる
https://github.com/ezolenko/rollup-plugin-typescript2/issues/211
tsc は --declaration --emitDeclarationOnly --declarationDir で指定した場所に型定義だけを吐ける
制約がありバンドラの対応状況もまちまちだが package.json の sideEffects である程度Tree Shakingを制御できる
https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free
https://github.com/rollup/rollup/issues/2593#issuecomment-616208761