Slide 1

Slide 1 text

パッケージ開発者の苦悩 -JavaScriptランタイム群雄割拠- 小田島 太郎 @shimataro999 TechFeed Experts Night #11 〜JavaScript/TypeScript最前線〜 スライドはこちら→

Slide 2

Slide 2 text

自己紹介 さくらインターネット所属 大体このアイコン使ってます 小田島 太郎 Web業界の前は手品業界にいました (今も副業でやってます) @shimataro999 https://shimataro.me スライドはこちら→

Slide 3

Slide 3 text

この発表について 対象 ● パッケージ開発者 ● Node.js / Denoの基本的なことは知っている ○ Bunは「TypeScriptが動くNode.js」くらいの認識で OK 完全に理解した 何も わからない チョット デキル このへん 前提知識(説明しないこと) ● CommonJS / ES Modulesについて ● トランスパイラ(tsc / Babel)の役目や使い方 ● パッケージの公開方法 スライドはこちら→

Slide 4

Slide 4 text

背景 JavaScriptランタイムって色々あるよね 古いバージョンは技術的負債があるよね (特にNode.js) アプリケーション開発ではトランスパイラも使われるよね (パッケージ利用側) パッケージは古いランタイムバージョンにも 対応したいよね Node.js / Deno / Bun tsc / Babel CommonJS / ES Modules Conditional Exports Node.js v4 / Deno v1.0

Slide 5

Slide 5 text

目標 Node.js / Deno / Bun対応 CommonJS / ES Modules対応 (AMDとかは省略...) パッケージ利用者が tsc / Babelを使っていても動く 古いランタイムバージョンにも対応

Slide 6

Slide 6 text

目標 Node.js / Deno / Bun対応 CommonJS / ES Modules対応 (AMDとかは省略...) パッケージ利用者が tsc / Babelを使っていても動く 古いランタイムバージョンにも対応 単一のコードベースから パッケージを作りたい!

Slide 7

Slide 7 text

目標 Node.js / Deno / Bun対応 CommonJS / ES Modules対応 (AMDとかは省略...) パッケージ利用者が tsc / Babelを使っていても動く 古いランタイムバージョンにも対応 単一のコードベースから パッケージを作りたい! わりと昔話が多めです

Slide 8

Slide 8 text

前提(レギュレーション) パッケージはロジックだけで完結 コードベースはTypeScript パッケージは複数のファイルで構成 JSランタイムのAPIに 非依存 パッケージ内部でも 別ファイルをimport Node.js向けには *.d.ts ファイルも提供 Deno/Bun向けには *.ts ファイルのまま提供 極力ランタイムのネイティブ形式で提供 ES Modules対応のランタイムには 極力ES Modules形式で提供

Slide 9

Slide 9 text

パッケージ開発者の苦悩

Slide 10

Slide 10 text

苦悩1: ランタイムごとにモジュールの扱いが異なる 苦悩レベル: ★☆☆☆☆ ● Node.js: CommonJS / ES Modules(v8.5以降) ● Deno/Bun: ES Modules それぞれに合わせたファイルを用意する必要あり ビルド時に複数生成すればいいだけなので 省略

Slide 11

Slide 11 text

苦悩2: ES Modulesは拡張子省略不可 苦悩レベル: ★★★★☆ パッケージ内部で別のファイルを読み込む場合 import foo from "./foo.mjs" // for Node.js import foo from "./foo.ts" // for Deno/Bun 共通のコードベースから、ビルド時に拡張子を解決する必要あり

Slide 12

Slide 12 text

苦悩3: tscはimport対象の拡張子を変更してくれない 苦悩レベル: ★★★★☆ TypeScriptはJavaScriptのスーパーセット JavaScriptとして有効なソースコードは TypeScriptでも有効 JavaScriptとして有効なソースコードは ビルド時に一切手を加えない

Slide 13

Slide 13 text

苦悩3: tscはimport対象の拡張子を変更してくれない TypeScriptはJavaScriptのスーパーセット JavaScriptとして有効なソースコードは TypeScriptでも有効 JavaScriptとして有効なソースコードは ビルド時に一切手を加えない 拡張子追加してよ JSのコードは変更しないよ

Slide 14

Slide 14 text

苦悩4: Dual Package対応の罠 苦悩レベル: ★★★★★ うまい具合に ./libs/main.js と ./libs/main.mjs を作れたとして package.jsonをどう書けばいい? requireされたら ./libs/main.js を読ませたい importされたら ./libs/main.mjs を読ませたい

Slide 15

Slide 15 text

苦悩5: default importの扱い 苦悩レベル: ★★★☆☆ import foo from "foo"; var foo = require("foo").default; ".default" が追加される tsc / Babel

Slide 16

Slide 16 text

苦悩5: default importの扱い var foo = require("foo").default; var foo = require("foo"); 生のCommonJSで書いていても tsc/Babelを使っていても どちらでも動くようにしたい! パッケージの利用者が

Slide 17

Slide 17 text

苦悩6: 以前のDenoはNPM非対応 苦悩レベル: ★☆☆☆☆ Deno v1.27以前はNPM非対応 Deno用パッケージはエントリーポイントをURLで公開 import foo from "https://example.com/mod.ts"; GitHubとかで簡単に公開できるので 省略

Slide 18

Slide 18 text

パッケージの作り方

Slide 19

Slide 19 text

ソースコード記述時のポイント(1) パッケージ内で別ファイルをインポートする時は 相対パス&拡張子をつけない import foo from "foo.ts"; import foo from "./foo"; ❌ ⭕ 拡張子はビルド時に解決 詳細は後で! main.ts main.ts

Slide 20

Slide 20 text

ソースコード記述時のポイント(2) パッケージのエントリーポイントからは default exportだけ行う export function foo() {...}; export function bar() {...}; export default { foo: () => {...}, bar: () => {...}, }; 理由は後で! ❌ ⭕ main.ts main.ts

Slide 21

Slide 21 text

ビルド時のポイント(1) CommonJS用のビルドには エントリーポイントの最後におまじないを追加 // tscの出力 exports.default = { foo: () => {...}, bar: () => {...}, }; // おまじないを2行追加! module.exports = exports.default; module.exports.default = exports.default; require(“foo”) require(“foo”).default どちらでも使える! main.js

Slide 22

Slide 22 text

ビルド時のポイント(2) ES Modules for Node.js用のビルドは tscとBabelの多段構成 { "scripts": { "build:esm": "run-s build:esm:*", "build:esm:1-tsc": "tsc", "build:esm:2-babel": "babel ./src --out-dir ./libs --out-file-extension .mjs" } } 文法変換 .d.ts ファイルの作成 import文の拡張子解決 package.json

Slide 23

Slide 23 text

ビルド時のポイント(3) 拡張子解決のBabelプラグインを使う babel-plugin-module-extension-resolver { "plugins": [ ["module-extension-resolver", {"dstExtension": ".mjs"}] ] } ワシが作った .babelrc

Slide 24

Slide 24 text

ビルド時のポイント(4) Deno/Bun用のビルドは専用ツールを使う deno-module-extension-resolver ワシが作った プラグインではなくCLIツール 拡張子 .ts を解決 { "scripts": { "build:deno": "deno-module-extension-resolver ./src ./libs" } } package.json

Slide 25

Slide 25 text

疑問: 1つのファイルにまとめれば拡張子解決不要では? モジュールバンドラーで 1つにまとめれば 拡張子の解決に悩まずに済むのでは? Webpack, rollup, esbuild, deno bundle, … Deno/Bun向けには TypeScriptのまままとめてくれる バンドラーが必要 知ってたら教えてください

Slide 26

Slide 26 text

エントリーポイントの指定 { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js", "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 Bun用 拡張子を省略 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js

Slide 27

Slide 27 text

エントリーポイントの指定 { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js", "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 Bun用 拡張子を省略 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js ES ModulesからCommonJSが参照される状況が出てしまう v12.0-12.15 v13.0-13.1

Slide 28

Slide 28 text

CommonJSとES Modulesの相互運用 参照(利用者) 被参照(パッケージ) CommonJS ES Modules CommonJS ES Modules require import dynamic import default import だからdefault exportが必要!

Slide 29

Slide 29 text

実際に作ったパッケージ ● written in TypeScript ● hybrid package ● supports Node.js >=4 ● well-tested (in many versions & 100% coverage) { "id": "123.45", "name": "Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Ciprin Cipriano de la Santísima Trinidad Ruiz y Picasso" } input { "id": 123, "name": "Pablo Diego José" } output id: 数値型 ● 整数(端数は切り捨て) ● 1以上(1未満はエラー) name: 文字列型 ● 空文字列はエラー ● 最大16文字(17文字目以降は削除) schema value-schema

Slide 30

Slide 30 text

まとめ

Slide 31

Slide 31 text

単一コードベースでハイブリッドパッケージを作る方法 ソースコードは・・・ ● 拡張子をつけずに相対パスで import ● エントリーポイントからはdefault exportだけ 行う ビルド時は・・・ ● CommonJS版は最後におまじない ● ES Modules版はtsc+Babelでビルド ● Deno/Bun向けには専用ツールでビルド package.jsonは・・・ ● mainには拡張子をつけずに指定 ● Conditional Exportsをよしなに設定 ● 一部のバージョンで、ES Modulesから CommonJS版が参照されます

Slide 32

Slide 32 text

No content

Slide 33

Slide 33 text

おまけスライド -Dual Packageの苦悩- ここまで見てくれてありがとう (*´艸`)

Slide 34

Slide 34 text

苦悩4: Dual Package対応の罠(再掲) 苦悩レベル: ★★★★★ うまい具合に ./libs/main.js / ./libs/main.mjs を作れたとして package.jsonをどう書けばいい? requireされたら ./libs/main.js を読ませたい importされたら ./libs/main.mjs を読ませたい

Slide 35

Slide 35 text

苦悩4: Dual Package対応の罠(再掲) 苦悩レベル: ★★★★★ うまい具合に ./libs/main.js / ./libs/main.mjs を作れたとして package.jsonをどう書けばいい? requireされたら ./libs/main.js を読ませたい importされたら ./libs/main.mjs を読ませたい 言うほど苦悩? Conditional Export使うだけなのに?

Slide 36

Slide 36 text

エントリーポイントの指定(再掲) { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js", "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 拡張子を省略して指定 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js Bun用

Slide 37

Slide 37 text

エントリーポイントの指定(再掲) { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js", "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 拡張子を省略して指定 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js Bun用 ES ModulesからCommonJSが参照される状況が出てしまう v12.0-12.15 v13.0-13.1

Slide 38

Slide 38 text

エントリーポイントの指定(再掲) { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js", "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 拡張子を省略して指定 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js Bun用 ES ModulesからCommonJSが参照される状況が出てしまう v12.0-12.15 v13.0-13.1 コレ、実は不正確です

Slide 39

Slide 39 text

package.jsonのexportsフィールドについて https://nodejs.org/api/packages.html#exports Conditional Exportsはv12.16以降 / v13.2以降でサポート Subpath Exportsはv12.7以降でサポート 特定のバージョン(12.11-12.15, 13.0-13.1)では そもそもConditional Exportsを使おうとするとエラー

Slide 40

Slide 40 text

Node.js v12.11.0での実行例 { "name": "example-package", "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js", "default": "./libs/main.mjs" } } package.json $ node --experimental-modules example.mjs (node:30008) ExperimentalWarning: The ESM module loader is experimental. internal/modules/esm/default_resolve.js:79 let url = moduleWrapResolve(specifier, parentURL); ^ Error: Cannot resolve package exports target 'undefined' matched for '.' in /PATH/TO/example/node_modules/example-package/package.json, imported from /PATH/TO/example/example.mjs at Loader.resolve [as _resolve] (internal/modules/esm/default_resolve.js:79:13) at Loader.resolve (internal/modules/esm/loader.js:73:33) at Loader.getModuleJob (internal/modules/esm/loader.js:152:40) at ModuleWrap. (internal/modules/esm/module_job.js:43:40) at link (internal/modules/esm/module_job.js:42:36) { code: 'ERR_MODULE_NOT_FOUND' } import example from "example-package"; example.mjs "exports"の中に "." が ないよ!

Slide 41

Slide 41 text

つまりこういうこと 該当のバージョンでは、 "exports" フィールド内では Subpath Exportsを期待 example-packageの package.json 内に "." がないのでエラー Subpath Exportsはv12.7で導入されたのに なぜv12.11からこうなったのかは謎 v12.11のExports Sugarが影響?

Slide 42

Slide 42 text

色々試してみた(1) { "main": "./libs/main", "exports": { ".": { "require": "./libs/main.js", "default": "./libs/main.mjs" } } } package.json Subpath Exportの中にConditional Exports $ node --experimental-modules example.mjs (node:30240) ExperimentalWarning: The ESM module loader is experimental. internal/modules/esm/default_resolve.js:79 let url = moduleWrapResolve(specifier, parentURL); ^ Error: Cannot resolve package exports target '[object Object]' matched for '.' in /PATH/TO/example/node_modules/example-package/package.json, imported from /PATH/TO/example/example.mjs at Loader.resolve [as _resolve] (internal/modules/esm/default_resolve.js:79:13) at Loader.resolve (internal/modules/esm/loader.js:73:33) at Loader.getModuleJob (internal/modules/esm/loader.js:152:40) at ModuleWrap. (internal/modules/esm/module_job.js:43:40) at link (internal/modules/esm/module_job.js:42:36) { code: 'ERR_MODULE_NOT_FOUND' } まだオブジェクトに対応していない

Slide 43

Slide 43 text

色々試してみた(2) { "main": "./libs/main", "exports": { ".": "./libs/main.js", "require": "./libs/main.js", "default": "./libs/main.mjs" } } package.json Subpath ExportとConditional Exportsを 併用 該当のバージョンでは動いた! (CommonJSを参照) $ node --experimental-modules example.mjs (node:30391) ExperimentalWarning: The ESM module loader is experimental. internal/modules/esm/resolve.js:58 let url = moduleWrapResolve(specifier, parentURL); ^ SyntaxError: Cannot resolve package exports in /PATH/TO/example/node_modules/example-package/package.json, imported from /PATH/TO/example/example.mjs. "exports" cannot contain some keys starting with '.' and some not. The exports object must either be an object of package subpath keys or an object of main entry condition name keys only. at Loader.defaultResolve [as _resolve] (internal/modules/esm/resolve.js:58:13) at Loader.resolve (internal/modules/esm/loader.js:85:40) at Loader.getModuleJob (internal/modules/esm/loader.js:188:40) at ModuleWrap. (internal/modules/esm/module_job.js:42:40) at link (internal/modules/esm/module_job.js:41:36) { code: 'ERR_INVALID_PACKAGE_CONFIG' } Subpath ExportsとConditional Exportsは 併用できない!! v12.16(Conditional Exports対応版)では...

Slide 44

Slide 44 text

結局どうすりゃええの? 1. 一部のバージョンを あきらめる 動かないバージョンはかなり限定されているので あきらめる "engines": { "node": ">=4.0.0 <12.11 || >=12.16 <13.0 || >=13.2" } 2. ES Modulesを あきらめる "exports" フィールドを撤去 常にCommonJS版を参照 一応どのバージョンでも動く 全バージョン対応が最優先ならこちら package.json

Slide 45

Slide 45 text

苦悩4: Dual Package対応の罠 苦悩レベル: ★★★★★ お わ か り い た だ け た だ ろ う か

Slide 46

Slide 46 text

おまけスライド -CommonJSとES Modulesの相互運用-

Slide 47

Slide 47 text

CommonJSとES Modulesの相互運用(再掲) 参照(利用者) 被参照(パッケージ) CommonJS ES Modules CommonJS ES Modules require import dynamic import default import だからdefault exportが必要!

Slide 48

Slide 48 text

CommonJSとES Modulesの相互運用(再掲) 参照(利用者) 被参照(パッケージ) CommonJS ES Modules CommonJS ES Modules require import dynamic import default import だからdefault exportが必要! 実は... named exportもES Modulesから使えます

Slide 49

Slide 49 text

やり方 exports.foo = () => {...}; exports.bar = () => {...}; module.js import module from "module"; module.foo(); module.bar(); main.mjs named exportして... default importする

Slide 50

Slide 50 text

やり方 exports.foo = () => {...}; exports.bar = () => {...}; module.js import module from "module"; module.foo(); module.bar(); main.mjs named exportして... default importする 色々ややこしいので やめたほうがいいです。 Node.js v12.0-15: default import Node.js v13.0-1: default import それ以外&Deno/Bun: named import

Slide 51

Slide 51 text

おまけスライド
 完