dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

複数のモジュール形式(CommonJS, ES Modules, UMD)をサポートしたnpmパッケージの作り方 in TypeScript

はじめに

npmパッケージを開発するとき、パッケージ利用者の実行環境に合わせて適切なモジュール形式のファイルをパッケージに含め、提供する必要があります。
具体的には、たとえば以下のようなバリエーションが考えられます。

  • Node.js環境であれば CommonJS 形式 (module.exports / require() )
  • ブラウザ環境で、webpackやRollupなどのモジュールバンドラーを前提とするならば CommonJSES Modules 形式 (export / import )
  • ブラウザ環境で、モジュールバンドラーなどは使わず<script>タグでファイルを読み込んで利用するならば UMD 形式

このとき、パッケージ提供側はどういったファイルをパッケージに含めるべきなのか、またそれを TypeScript でどのように実現できるのかがあまりよくわかっていなかったので、整理しました。

注:今回想定していない環境

目次



最初にまとめ

最終的なディレクトリ構成は以下のようになりました。

f:id:dackdive:20190923032928p:plain

また、package.jsonには以下main, module, types, filesフィールドを指定します。

// package.json
{
  "main": "lib/index.js",
  "module": "esm/index.js",
  "types": "lib/index.d.js",
  "files": [
    "lib",
    "esm",
    "umd"
  ],
  // ...
}

詳しくはGitHubにサンプルリポジトリを作りました。


1. CommonJS 形式

CommonJSはNode.js環境で採用されているモジュール形式であることと、ブラウザ環境でもwebpackなどのモジュールバンドラーやTypeScriptがこの形式をサポートしていることから、
とりあえずCommonJS形式でパッケージを提供しておけばNode.js環境とブラウザ環境を両方サポートできます。


tsconfig.json

TypeScriptでCommonJS形式でコンパイルする場合、tsconfig.jsonmoduleプロパティにcommonjsを指定します。
また、出力先のディレクトリ名は任意ですが、ここではlibとしてoutDirプロパティに指定しておきます。

// tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "./lib"
    // ...
  }
}

こうすると、ソースファイルを格納しているディレクトリ(ここではsrc)と同じ構成のまま、
TypeScript → JavaScriptコンパイルした結果がlibディレクトリに出力されます。

- src/
  - index.ts
  - moduleA.ts
  - moduleB.ts
- lib/
  - index.js
  - moduleA.js
  - moduleB.js


package.json

package.jsonでは、mainフィールドにパッケージのエントリーポイントとなるファイルのパスを、filesに出力先ディレクトlibを記載します。

// package.json
{
  "main": "lib/index.js",
  "files": [
    "lib"
  ]
}


2. ES Modules 形式

webpack(v2以降)やRollupなどのモジュールバンドラーには、Tree Shakingという機能が備わっています。
これは、exportしているがどこからもimportされていない不要なモジュールをバンドル時に削除する機能です。

参考:webpackの公式ドキュメント内でTree Shakingについて説明されているページ

パッケージ提供側が多数のモジュールを提供している場合、ES Modules形式つまり import/export で書かれたファイルをCommonJS形式に変換せずそのまま提供することで、利用者側はTree Shakingの恩恵を受けることができます。


tsconfig.json

TypeScriptでこれを実現する場合、CommonJSと同様にmoduleプロパティを使用します。
先ほどはcommonjsという値を指定しましたが、今度はESNextを指定します。

また、先ほどのCommonJS形式のファイルと出力先のディレクトリを分けるため、outDir: "./esm"も指定します。同じくディレクトリ名は任意です。

// tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "./esm"
    // ...
  }
}

こうすることで、CommonJS形式と同じくsrc下と同じ構成のファイル群のJSがesmに生成されます。

- src/
  - index.ts
  - moduleA.ts
  - moduleB.ts
- lib/  # 中身はCommonJS( module.exports / require )
  - index.js
  - moduleA.js
  - moduleB.js
- esm/  # 中身はES Modules( export / import )
  - index.js
  - moduleA.js
  - moduleB.js


NOTE: tsconfig.jsonの構成をどのようにすべきか

このように、同じTypeScriptのソースファイルから複数の設定でコンパイルする場合、プロパティの異なる複数のtsconfig.jsonが必要になります。
tsconfig.jsonextendsというプロパティがあり、これを使うとある設定ファイルの内容を継承して一部だけ上書きできます。
モジュール形式によらないプロパティはベースの設定ファイルに記述し、モジュール形式によって差が出るプロパティはベースを継承した別の設定ファイルを定義するのが良いかと思います。

// tsconfig.base.json
{
  "compilerOptions": {
    "target": "es5",
    "strict": true,
    // ...
  }
}
// tsconfig.esm.json
{
  "extends": "./tsconfig.base",
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "./esm"
  }
}

また、コンパイル時は-pまたは--projectオプションで使用する設定ファイルを指定できるので、これをpackage.jsonのscriptsに記載しておくと良いでしょう。

// package.json
{
  "scripts": {
    "build": "npm run build:cjs && npm run build:esm",
    // CommonJS用
    "build:cjs": "tsc -p tsconfig.cjs.json",
    // ES Modules 用
    "build:esm": "tsc -p tsconfig.esm.json"
  }
}

詳しくはサンプルリポジトリを参考にしてください。


package.json

package.jsonには、CommonJS形式のときと同じくfilesフィールドに出力したディレクトリ(esm)を追加するほか、moduleフィールドでesm内のエントリーポイントを指定します。

// package.json
{
  "main": "lib/index.js",
  "module": "esm/index.js",
  "files": [
    "lib",
    "esm"
  ]
}

moduleフィールドについて、公式ドキュメントには載っていませんでしたが、以下のStack Overflowが見つかりました。

Node.jsでES Modulesを扱うためのProposalということでしょうか...(あんまりわかってない)
また、Rollupのドキュメントには以下の記述があります。

https://rollupjs.org/guide/en/#publishing-es-modules

If your package.json file also has a module field, ESM-aware tools like Rollup and webpack 2+ will import the ES module version directly.

つまり、モジュールバンドラーはこのフィールドで指定したパスのファイルをES Modules形式で扱うようです。


3. UMD

Reactなどのメジャーなライブラリでは、インストール方法として npm install だけではなく以下のようにCDNで利用できる形式をサポートしています。

https://reactjs.org/docs/cdn-links.html

<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

これをHTMLに書くとReactReactDOMグローバル変数に定義され、使えるようになるため、利用者側はモジュールバンドラーを使った環境を構築する必要がありません。
オンラインエディターなどでも簡単に試すことができます。

これを実現するためのモジュール形式がUMDです。


webpack.config.js

パッケージをUMD形式で配布するためには最終的にファイルを1つにバンドルする必要があり、これはTypeScript単体では完結しません。何らかのモジュールバンドラーを利用する必要があります。
ここではwebpackを例にします。

webpackでUMD形式のファイルを作成するには、output.libraryおよびoutput.libraryTargetフィールドを使用します。

https://webpack.js.org/configuration/output/#outputlibrary
https://webpack.js.org/configuration/output/#outputlibrarytarget

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

module.exports = {
  mode: "production",
  entry: "./src/index.ts",
  output: {
    path: path.resolve(__dirname, "umd"),
    filename: "my-typescript-package.js",
    library: "MyTsPackage",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.ts(x*)?$/,
        exclude: /node_modules/,
        use: {
          loader: "ts-loader",
          options: {
            configFile: "tsconfig.umd.json"
          }
        }
      }
    ]
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js"]
  }
};

libraryTargetumdを指定することでUMD形式でのバンドルを行います。またバンドルしたファイルを読み込んだときにlibraryに指定したグローバル変数名でアクセスできるようになります。

なお、webpackを使用する場合、TypeScriptもwebpack内で使用するためts-loaderが必要となります。
options.configFileは設定ファイルがtsconfig.jsonという名前の場合は不要です。


tsconfig.json

tsconfig.json側は特別な設定は必要ありません。出力先のディレクトリも今回はwebpack.config.js側で指定しているため不要です。

ただ、
最新版TypeScript+webpack 4の環境構築まとめ(React, Vue.js, Three.jsのサンプル付き) - ICS MEDIA
にもあるように、moduleESNextを指定していないと、ソースファイル内に使用していないexportが混ざっていてもTree Shakingが機能せず、バンドルファイルに含まれることになります。
(これはアプリケーションを開発する際は重要ですが、npmパッケージとして提供したいようなライブラリの場合は使っていないexportは基本ないはず...)

また、ベースの設定ファイルでdeclaration: trueにして型定義ファイルを出力するようにしている場合、UMD用の設定では出力をOFF(declaration: false)にしておくのが良いと思います。


package.json

filesumdディレクトリを追加することと、UMDビルド用のスクリプトを追加すること以外は特にありません。

   "scripts": {
-    "build": "npm run build:cjs && npm run build:esm",
+    "build": "npm run build:cjs && npm run build:esm && npm run build:umd",
     "build:cjs": "tsc -p tsconfig.cjs.json",
     "build:esm": "tsc -p tsconfig.esm.json",
+    "build:umd": "webpack",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "files": [
     "lib",
-    "esm"
+    "esm",
+    "umd"
   ],

ここまでで、CommonJS、ES Modules、UMDのファイルが以下のような構成で出力されるようになっています。

- src/
  - index.ts
  - moduleA.ts
  - moduleB.ts
- lib/  # 中身はCommonJS( module.exports / require )
  - index.js
  - moduleA.js
  - moduleB.js
- esm/  # 中身はES Modules( export / import )
  - index.js
  - moduleA.js
  - moduleB.js
- umd/  # UMD
  - my-typescript-package.js


NOTE: development用とproduction用の2つのバンドルを用意する場合は?

先ほど例として挙げたReactもそうですが、UMD形式に対応しているライブラリはdevelopment用 (foo.development.js)とproduction用(foo.production.js)の2種類を提供していることがあります。

https://reactjs.org/docs/cdn-links.html
f:id:dackdive:20190918030405p:plain:w400

(試していませんが) これを実現したい場合、webpackの
Configuration Types > Exporting multiple configurations
によると、webpack.config.jsで配列を返すようにすれば複数の設定でバンドルした結果を出力してくれます。
modeoutput.filenameだけ異なる2つの設定を用意し、まとめてumd/以下に出力すれば良いでしょう。


NOTE: 出力先のディレクトリ名について

UMD形式についてもディレクトリ名は任意です。
が、umdまたはdistという名前にしておくと、unpkg.comというCDNがnpm publish時に自動的に認識してくれ、publishしたパッケージが

https://unpkg.com/<package name>@<version>/<`umd` or `dist`>/<file name>

というURLで利用できるようになるので、この慣習にならっておくのがおすすめです。

参考:https://unpkg.com/#workflow

f:id:dackdive:20190918030605p:plain


4. (おまけ) 型定義ファイル(.d.ts)

モジュール形式とは別の話ですが、TypeScriptで開発したパッケージの場合、本体のスクリプトと一緒に型定義ファイルもパッケージに含めて提供してあげる必要があります。

必要なことは

  • tsconfig.jsondeclaration: trueを追加
  • package.jsontypesフィールドに出力した型定義ファイルのエントリーポイントを指定

の2点です。

後者は、たとえばlib/ディレクトリ以下に

- src/
  - index.ts
  - moduleA.ts
  - moduleB.ts
- lib/
  - index.js
  - index.d.ts
  - moduleA.js
  - moduleA.d.ts
  - moduleB.js
  - moduleB.d.ts

というように型定義ファイルを出力するようにしている場合、

// package.json
{
  "main": "lib/index.js",
  "types": "lib/index.d.js"
}

となります。

参考:https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#including-declarations-in-your-npm-package

※サンプルリポジトリではlib/にもesm/にも型定義ファイルを出力するようになっていますが、どちらかだけで十分のはず


余談

上に書きませんでしたが、調べている過程で見つけたTips的な情報を。


package.jsonfilesフィールドは必ず必要なのか

filesはnpm publish時にパッケージに含めるファイルやディレクトリを指定するためのフィールドで、指定しなかった場合は全てのファイルがパッケージに含められます。
そのため、余計なファイルが含まれることを許容すればこのフィールドは指定しなくても良さそうに思えます。

が、 https://docs.npmjs.com/files/package.json#files を読むと

You can also provide a .npmignore file in the root of your package or in subdirectories, which will keep files from being included. (中略) If there is a .gitignore file, and .npmignore is missing, .gitignore’s contents will be used instead.

とあります。つまり

  • .npmignore に列挙したファイルやディレクトリはパッケージから除外される
  • .npmignore を置いていない場合、.gitignoreの内容が使われる

ということです。
今回のlibのようにコンパイルしたファイルの出力先はgit commitしないよう.gitignoreに記載するのが一般的だと思われるので、filesの指定がないと意図せずパッケージにも含まれなくなってしまいます。
このような事故を避けるため、またパッケージの内容を明示的にするためにもfilesは記載しておくのがおすすめです。


package.jsonunpkgフィールドとは

UMD形式をサポートしているパッケージをいくつか調べたところ、package.jsonunpkgというフィールドを指定しているパッケージがありました。

これについては unpkg.com のサイトを確認したところ

https://unpkg.com/#examples

If you omit the file path (i.e. use a “bare” URL), unpkg will serve the file specified by the unpkg field in package.json, or fall back to main.

とあるので、UMD形式で出力したファイルのパスを指定しておけば、利用者は

https://unpkg.com/<package name>@<version>

というURLでパッケージを読み込めるというわけですね。
(あんましなさそうですが)


webpack.config.jsのexternalsオプションとは

これもUMD形式をサポートしているパッケージでよく見かけました。

参考:

// webpack.config.js
externals: {
  react: 'React',
  'react-dom': 'ReactDOM',
}

// または
externals: {
  react: {
    amd: 'react',
    commonjs: 'react',
    commonjs2: 'react',
    root: 'React',
  },
  // ...
}

これについてはwebpackの公式ドキュメント
https://webpack.js.org/configuration/externals/

によると、このオプションで指定したモジュールはバンドルしたファイルに含まれなくなり、パッケージ利用者側が別途モジュールを読み込む必要があります。


リファレンス

モジュール形式については以下が参考になりました。

参考にしたライブラリ

TypeScript: 配列の値をString Literal Typesとして使う

メモ。
こんな感じで、ある配列として定義されている値に対し、「その値のいずれか」を意図した String Literal Types を作りたい。

const size = ['small', 'medium', 'large'];

type Size = 'small' | 'medium' | 'large';

でも、配列と型定義とで値を二度書きたくない。


こうする

TypeScript 3.4 で導入された const assertion を使うと、次のように書ける。

const size = ['small', 'medium', 'large'] as const; // readonly ["small", "medium", "large"]

type Size = typeof size[number]; // 'small' | 'medium' | 'large'

(Playground)

こちらの Stack Overflow を参考にした。


リファレンス

TypeScript: ReactNode型とReactElement型とReactChild型

メモ。

  • ReactNode
  • ReactElement
  • ReactChild

の関係性、何回か調べている気がするので整理しておく。

f:id:dackdive:20190807012948p:plain

@types/react の型定義
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts
を参照した。

図で、ReactNodeArrayArray<ReactNode> 以外の線は Union Types を表している。
たとえば

type ReactChild = ReactElement | ReactText

である。


メモ

  • JSX が受け付けるすべてのものをまとめた type として ReactNode があり、そこから string や null などを除いた純粋な React コンポーネントを意味するのが ReactElement
  • ほとんどが type alias だが、ReactNodeArrayReactPortal だけは interface だった
    • ReactNodeArrayArray<ReactNode> を継承してるだけ

npm installしたパッケージをVSCodeでデバッグする

npmでインストールしたパッケージが期待通りに動かず内部の動作を確認したい、となったときの話。

これまでは該当のスクリプトconsole.log() とか直接書いてデバッグしてたんですが、あまりにも効率が悪いのでもうちょっと良いやり方を見つけたい。
VSCodeでbreakpoint置きつつ変数の中身を見たりステップ実行したい。
と思ったので調べました。

ここではデバッグするパッケージの例として Storybook を取り上げてみます。
Storybookはたいていpackage.json

"scripts": {
  "storybook": "start-storybook -p 6006 -s ./assets"
}

のようなスクリプトを書いて実行することが多いと思います。
このとき、start-storybookというコマンド内部で何が行われているかを調査します。


手順

1. start-storybookコマンドで実行されるファイルを特定する

まずは、start-storybookコマンドによって実行されるファイルを見つけます。
npm scriptにコマンドで書けるということは node_modules/.bin 以下に該当のコマンドがあるはずなので探すと、

$ ls -l node_modules/.bin | grep start-storybook
lrwxr-xr-x  1 yamazaki  staff  32  6 20 22:37 start-storybook@ -> ../@storybook/react/bin/index.js

ということで、今回の場合 node_modules/@storybook/react/bin/index.js を見ればいいことがわかります。

また、VSCodeの場合シンボリックリンクはマウスオーバーでリンク先がわかります。

f:id:dackdive:20190620230331g:plain

(もうちょっと良い表示方法ないのかな)

別の方法として、対象のnpmパッケージのpackage.jsonbinを見るという方法もあります。

見つけたファイルの冒頭にbreakpointを置いておきます。
今回の @storybook/react/bin/index.js は @storybook/react/dist/server/index.js を読み込んでいるだけだったので、そちらの先頭にbreakpointをつけました。

f:id:dackdive:20190620230941p:plain


2. デバッグ用の設定ファイル(launch.json)を作る

次は、VSCode上でデバッグを行うために必要な設定ファイルを作ります。 コマンドパレット(Macならcmd+shift+P)で

Debug: Open launch.json

を実行します。または、直接.vscode/ディレクトリ下にlaunch.jsonというファイルを作成してもいいです。

コマンドパレットから作成した場合は

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "program": "${workspaceFolder}/index.js"
    }
  ]
}

という雛形が作成されているかと思います。


3. デバッグしたいコマンドをprogramに記載する

作成したlaunch.jsonprogramという値が、デバッグ実行されるコマンドです。
なので、ここにnpm scriptとして書かれていたコマンドをコピーしてきます。

注意事項としては

  • npm scriptにstart-storybookと書いていた場合、programには${workspaceFolder}/node_modules/.bin/start-storybookと書く
    • ${workspaceFolder}は必須っぽいです
  • 引数がある場合、programではなくargsという属性に配列で記載する

という点があります。

なので、今回の場合

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "debug start-storybook",
      "program": "${workspaceFolder}/node_modules/.bin/start-storybook",
      "args": ["-p 6006", "-s ./assets"]
    }
  ]
}

となります。

なおnameは識別しやすい好きな名前に変えていいです。


4. デバッグ実行する

ここまでで準備はできたので、コマンドを実行します。
左側のDebugアイコンか、Macだとcmd+shift+Dデバッグ用のパネルが表示されます。

f:id:dackdive:20190620232929p:plain

パネル上部にnameで指定したコマンドが表示されます。目的のコマンドを選んで▶をクリックします。

f:id:dackdive:20190620233142p:plain

無事、最初にbreakpointを置いた箇所で止まってくれました。
左側のパネルのVARIABLES、WATCH、CALL STACK、BREAKPOINTSなどはだいたいブラウザのDeveloper Toolsと同じ機能です。

というわけで、無事やりたいことが実現できました。


よくわかっていないこと

公式ドキュメントによると、launch.jsonconfigurationsの書き方は何種類かあるようです。

そのうちの1つに Launch via NPM というのがあるので、npm scriptはこっちを使えばいいのかと思ってましたがうまく起動できませんでした。
(エラーにはならないが何も起きない)


リファレンス

VSCodeでのデバッグ方法については、公式ドキュメントにかなり多くの情報が載っています。

自分もまだ完全には理解できてません。

Google I/O'19の「Speed at Scale: Web Performance Tips and Tricks from the Trenches」を観たメモ

途中飛ばしてしまった箇所もありますが、一通り観たのでメモを。

長いので最初にまとめ

動画の最後のスライドにキーワードがまとまってます。

f:id:dackdive:20190604035502p:plain

  • 速くする(Get fast)ための13個のTips
  • 速さを維持する(Stay Fast)ためのTipsとしてPerformance BudgetsおよびLightWallet

が紹介されています。
また、このセッションではパフォーマンス改善の事例が多数紹介されています。

f:id:dackdive:20190604035551p:plain


Performance Budgets

  • パフォーマンスを維持するためのしくみ
    • 予算を管理するように守るべきパフォーマンス指標を設定できる
  • Performance Budgetsでできること
    • Time (TTI ◯秒以下)
    • Resources (JSは◯KB以下)
    • Lighthouse (スコア◯点以上)

f:id:dackdive:20190604035618p:plain

  • Budgetsすなわち予算を設定することで、shipする前にパフォーマンスの問題に気づける
  • 事例: Walmart Grocely
    • PR時にチェックし、バンドルサイズが1%以上増加するとPRは失敗、Issue登録されPerformance Engineerにエスカレーションされる


LightWallet

  • Webサイトのパフォーマンス測定ツールであるLighthouseでPerformance Budgetsをサポート
  • CLI版のLighthouseで使える
    • Lighthouse自体はCLI版とChrome拡張版がある
$ npm i -g lighthouse
$ lighthouse https://example.com --budgetPath budgets.json --chrome-flags="--window-size=1280,660" --view

🤔時間系のBudgetsは指定できない?


画像

Lazy Loading
  • JSのライブラリを使った例だと、こんな書き方をすることが多い
    • LazySizesやReact Lazy Loadなど
<img data-src="images/unicorn.jpg" class="lazyload">
  • 事例: chrome.com サイト
    • 20% Faster page load times (mobile)
    • 26% Faster page laod times (desktop, Windows)
    • 画像サイズに関してはページ初回ロード(initial page)で46%の削減に成功
  • 事例: Netflix

    • たくさんの動画がタイル状に並んだページ
    • 初期描画では最初に見える数行分の画像のみロードし、残りはlazy load
    • ページ全体で4.4MBの画像があるのを、初期描画では1.2MBに抑えることができた
    • メモリだと45MB(Full Page Load) -> 8MB(Initial Page State)
  • ブラウザのAPIで画像のlazy loadをサポート(comming to Chrome this summer.)

    • iframeも
<img loading=eager>
<img loading=lazy>


Responsive Images
<!-- By width -->
<img src="cat.jpg"
  srcset="cat-240.jpg 240w,
          cat-480.jpg 480w,
          cat-960.jpg 960w>


Images CDNs
  • image optimization as a service
    • Cloudinary, Imgix, Thumborなど

f:id:dackdive:20190604035824p:plain

  • 事例: Trivago
    • Image CDNとしてCloudinaryを採用
    • Image CDNを利用することにより、自社のエンジニアはコアビジネスに集中できた
    • 全体の画像サイズを80%削減
  • 日本の事例もいくつか(一休、ANA)

f:id:dackdive:20190604035910p:plain

JavaScript

Defer Third-Party JavaScript
  • third-partyのコードはJS実行時間の57%を占める
  • 事例:Telegraph
    • JSをすべて(third-partyだけでなく自社のものも含め)deferして描画を3s高速化した
  • ref. asyncdeferの違いについては \<script> タグに async / defer を付けた場合のタイミング - Qiita
    • deferはJSの実行順序を保証してくれる
  • 事例: chrome.com
    • トップページに埋め込んでいたYouTube動画をlazy loadしたところTTIで10sの高速化


Remove Expensive Libraries
  • moment.js, jQuery, Bootstrapのようなサイズの大きいライブラリをリプレイスする
    • 例: lodash -> lodash-es, momentjs -> date-fns
    • サイズが小さいことに加え、Tree-shakeableなライブラリを選ぶのも重要
  • 事例: Tokopedia
    • ランディングページをSvelteでリライトした
    • 新バージョンはabove-the-fold contentの描画に必要なJSは37KB
    • Reactで作成した現行バージョンは320KB
    • ランディングページ以外はReactのまま。Service Workerを使ってprecache
  • Update dependencies
    • 事例: Zalando (ヨーロッパのfashion retailer)
      • Reactを15.6.1 -> 16.2.0に上げただけでload timeが100ms向上した
  • Code-splitting


Critical CSS

  • Critical CSS: above-the-fold contentの描画に必要なCSS
  • ドキュメントにインラインで埋め込むべき。14KB以下
  • 事例: TUI (ヨーロッパの旅行サイト)
    • First Contentful Paintの描画を2.4s -> 1.2sに短縮
  • 事例: 日経
    • Critical CSSが300KBもあった
    • シチュエーション(ユーザーがログイン状態か、など)に応じた適切なCritical CSSを返すサーバーを構築
    • ESI: Edge Side Inclusion

Lightning Web Componentsの一部がオープンソース化された

現在開催中の TrailheaDX'19 で発表があったようですね。

記事によると、LWCの機能は以下の3つのレイヤーから成るとし、

  • The Lightning Web Components framework: the framework’s engine.
  • The Base Lightning Components: a set of over 70 UI components all built as custom elements.
  • Salesforce Bindings, a set of specialized services that provide declarative and imperative access to Salesforce data and metadata, data caching, and data synchronization.

今回オープンソースとして公開されたのは、一番下の基本的なレイヤーである framework 部分とのこと。

f:id:dackdive:20190530125155p:plain

(画像は該当のブログ記事より引用)

Salesforceプラットフォーム固有の要素を含まないので、Heroku などの任意のプラットフォームで利用できます。

発表と同時に lwc.dev という充実したドキュメントを用意したり、
create-react-app ライクな lwc-create-app というコマンドで始められたり
ドキュメント内に playground があったりというあたりはさすがですね。

なお、ブログ記事中にはGitHubリポジトリは紹介されてませんでしたが、こちらです。

TypeScript: ReactのContextに型をつける(useContextと16.3以前のLegacy Contextも含む)

TypeScript + Reactでコンポーネントを書くとき、Context を使っているコンポーネントに対してどう型を書くのが正解か迷ったので、調べたことをメモしておきます。

調べるきっかけとなったコンポーネントは React 16.3 以前の Legacy Context を使った書き方になっていたのですが、ついでに新しい Context での書き方と、最新の Hooks の useContext() を使った場合も調べます。

なお、Context および useContext() についての説明は公式ドキュメントに譲ります。

目次


Contextを使う前のサンプルコード

import React from "react";
import "./App.css";

const ThemedButton: React.FC<{ theme: string }> = props => (
  <button className={props.theme}>Click me</button>
);

const Toolbar: React.FC<{ theme: string }> = props => (
  <div>
    Hello, TypeScript & React. <ThemedButton theme={props.theme} />
  </div>
);

const App: React.FC = () => {
  return (
    <div className="app">
      <header className="app-header">
        <Toolbar theme="dark" />
      </header>
    </div>
  );
};

export default App;

ThemedButtontheme を渡すために Toolbar にも props を渡しています。


1. React 16.3 以降の Context を使う場合

import React from "react";
import "./App.css";

const ThemeContext = React.createContext("light"); // (1)

class ThemedButton extends React.Component {
  static contextType = ThemeContext; // (1)
  context!: React.ContextType<typeof ThemeContext>; // (2)

  render() {
    return (
      <button className={this.context}>Click me</button>
    );
  }
}

const ThemedButtonFC: React.FC<{ theme: string }> = props => (
  <button className={`app-toolbar-button ${props.theme}`}>Click me</button>
);

const Toolbar: React.FC = props => (
  <div>
    Hello, TypeScript & React. <ThemedButton />
  </div>
);

const App: React.FC = () => {
  return (
    <ThemeContext.Provider value="dark"> // (1-2)
      <div className="app">
        <header className="app-header">
          <Toolbar />
        </header>
      </div>
      <div>
        <div>
          <ThemeContext.Consumer>
            {value => <ThemedButtonFC theme={value} />} // (1-3)
          </ThemeContext.Consumer>
        </div>
      </div>
    </ThemeContext.Provider>
  );
};

export default App;

ThemedButtonFC および <ThemeContext.Consumer>...</ThemeContext.Consumer> については、Consumerを使った箇所も確認したかったのでオマケです。

新しいContextでは、React.createContext(defaultValue) を使ってContextオブジェクトを作ります。
コメントで(1) と書いたところは、通常のContextのお作法通りに書いた箇所です。
これだけで、(1-2)Providervalue(1-3)Consumer 内の関数の value は正しく型チェックされるようになります。

ポイントは (2) のところで、どうやらこれがないとコンポーネント内のthis.contextが型チェックされないようです。
この書き方はReactの型定義ファイルを参考にしました。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts#L421-L434

 /**
  * If using the new style context, re-declare this in your class to be the
  * `React.ContextType` of your `static contextType`.
  *
  * ```ts
  * static contextType = MyContext
  * context!: React.ContextType<typeof MyContext>
  * ```
  *
  * @deprecated if used without a type annotation, or without static contextType
  * @see https://reactjs.org/docs/legacy-context.html
  */


2. React 16.3 以前の Legacy Context を使う場合

Legacy Context の場合はこのようになります。

import React from "react";
import PropTypes from "prop-types";
import "./App.css";

// (1)
type ThemeContext = {
  theme: string;
};

class ThemedButton extends React.Component {
  static contextTypes = {
    theme: PropTypes.string
  };
  context!: ThemeContext; // (1)

  render() {
    return (
      <button className={this.context.theme}>
        Click me
      </button>
    );
  }
}

const Toolbar: React.FC = props => (
  <div>
    Hello, TypeScript & React. <ThemedButton />
  </div>
);

class App extends React.Component {
  static childContextTypes = {
    theme: PropTypes.string
  };

  getChildContext(): ThemeContext { // (1)
    return { theme: "light" };
  }

  render() {
    return (
      <div className="app">
        <header className="app-header">
          <Toolbar />
        </header>
      </div>
    );
  }
}

export default App;

公式ドキュメント相当の信頼できそうな情報は見つけられなかったのですが、こちらのブログを参考にしました。

ポイントとしては、Legacy Contextの場合、コンテキストはPropTypesで記述するためランタイムでのチェックができません。
そのため、(1) にあるように同じ構造のTypeScriptの型を定義した後、親のgetChildContext()、子のthis.contextに型を付けています。

ブログの記事との違いとして、ブログでは

context: ThemeContext;

としてますが、これだと--strictPropertyInitializationオプションがONのときに以下のエラーが出ます。

Property 'context' has no initializer and is not definitely assigned in the constructor. ts(2564)


3. React Hooksの useContext() を使った場合

最後に、Hooksを使った場合。

import React, { useContext } from "react";
import "./App.css";

const ThemeContext = React.createContext("light");

const ThemedButton: React.FC = props => {
  const theme = useContext(ThemeContext);

  return <button className={theme}>Click me</button>;
};

const Toolbar: React.FC = props => (
  <div>
    Hello, TypeScript & React. <ThemedButton />
  </div>
);

const App: React.FC = () => {
  return (
    <ThemeContext.Provider value="dark">
      <div className="app">
        <header className="app-header">
          <Toolbar />
        </header>
      </div>
    </ThemeContext.Provider>
  );
};

export default App;

コンポーネントが Function Component で書けるようになるということ以外は 1 と変わりません。