シンプルな Web サイト用の webpack スターターキット

これは何

仕事で必要だったので webpack のスターターキットをつくってみました。

GitHub - ryotah/webpack-starter-basic: A simple webpack starter kit. Babel, TypeScript, ESLint, PostCSS, Jest, Environment variables, Git hooks, etc.

その時に調べたことをメモしておきます。

ちな、いつもは Nuxt などを使っているので、ゼロから用意するのはほぼ初めてでした。何が言いたかというと、そのくらいの人が書いている記事だよということです 😇

必要だったこと:

  • 複数の HTML ページ
  • TypeScript
  • JavaScript polyfills
  • 各種 Lint
  • Autoprefixer (PostCSS)
  • Unit Testing
  • 環境変数
  • SPA は不要

使い方

リポジトリREADME を読んでください 🙇

メモ

以下、調べたことのメモです。

以下のバージョンを前提としています。
webpack 4.43.0, TypeScript 3.9.5, Babel 7.10.2, ESLint 7.2.0, Prettier 2.0.5, Jest 26.0.1, core-js 3.6.5

webpack で 複数 HTML ページ

  • webpack は HTML ファイルをエントリーに指定できません
  • HtmlWebpackPlugin を使いましょう
  • chunk の設定をする必要が (きっと) あるので、webpack 用語としての chunk を知っておくと良いかと思います
// https://github.com/jantimon/html-webpack-plugin#generating-multiple-html-files
{
  entry: 'index.js',
  output: {
    path: __dirname + '/dist',
    filename: 'index_bundle.js'
  },
  plugins: [
    new HtmlWebpackPlugin(), // Generates default index.html
    new HtmlWebpackPlugin({  // Also generate a test.html
      filename: 'test.html',
      template: 'src/assets/test.html'
    })
  ]
}

コミット時に Lint チェックしたい

  • 具体的には ESLint, stylelint, コミットメッセージのチェック
  • フックに husky、コミットメッセージのチェックには commitlint を利用しました

package.json

{ 
  // ...
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{js,ts}": [
      "eslint --fix"
    ],
    "*.css": [
      "stylelint --fix"
    ],
    "*.html": [
      "prettier --write"
    ]
  }
}

commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional'],
};

詳細はコミットログからどうぞ
chore: set up commitlint · ryotah/webpack-starter-basic@bd44ce3 · GitHub

Babel + TypeScript の設定

  • Babel を利用することで core-js の対応もしたい
  • ts-loader => babel-loader の順番で処理するようにしました
  • @babel/preset-env が便利ですね

webpack.config.js

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: ['babel-loader', 'ts-loader'],
        exclude: /node_modules/,
      },
    ],
  },
};

babel.config.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ]
  ]
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": [
      "esnext",
      "dom"
    ],
    "esModuleInterop": true,
    "strict": true,
    "baseUrl": ".",
    "paths": {
      "~/*": [
        "src/*"
      ]
    }
  }
}

browserslist

  • @babel/preset-env がデフォルトで browserslist を利用します
  • Autoprefixer も browserslist を利用するので対象ブラウザはここでしっかり設定しておきましょう

package.json

{
  // ...
 "browserslist": [
    "defaults"
  ]
}

ESLint + prettier の設定

$ npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier prettie

.eslintrc

{
  "root": true,
  "env": {
    "browser": true,
    "node": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],
  "overrides": [
    {
      "files": ["**/*.js"],
      "rules": {
        "@typescript-eslint/no-var-requires": 0
      }
    }
  ]
}

.eslintignore

dist

.prettierrc

{
  "singleQuote": true
}

stylelint の設定

.stylelintrc

{
  "extends": "stylelint-config-standard"
}

Jest の設定

  • preset: 'ts-jest' って前からありましたっけ?簡単ですね。

jest.config.js

module.exports = {
  preset: 'ts-jest',
};

CSS 読み込み

  • サンプルではcss-loader => style-loader をよく見かけますが、style-loader は実行時に "Inject CSS into the DOM" するとのこと
  • 少なくともビルド時には別の loader にしたいので、今回は MiniCssExtractPlugin を利用してみました

webpack.config.js

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    // ...
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
      chunkFilename: '[id].[contenthash].css',
    }),
  ],
};

Autoprefixer を適用させるために PostCSS も利用します。postcss-loader が内部で postcss-load-config を利用しているので今回は .postcssrc を利用することにしました。

.postcssrc

{
  "plugins": {
    "autoprefixer": true
  }
}

ビルド時のファイル名をどうするか

webpack.config.js

module.exports = merge(common, {
  mode: 'production',
  devtool: 'source-map',
  output: {
    filename: '[name].[chunkhash].js',
    path: buildPath,
  },
});

パスの解決

resolve.alias を利用することでエイリアスを利用できます
例えば以下のような設定をすることで import '~/assets/scripts/utils.ts'; のような作業ディレクトリをベースにした import が可能になります。

webpack.config.js

const path = require('path');
const basePath = path.resolve(__dirname, 'src');

module.exports = {
  //...
  resolve: {
    alias: {
      '~': basePath,
    },
  }
};

webpack のエイリアス設定をした場合、TypeScript にも同様の設定が必要になります。 (tsconfigbaseUrlpaths)。

紛らわしいエイリアス名をつけてしまいましたが css ファイル内の @import '~normalize.css'; に使われている ~ とは別ものです。

https://webpack.js.org/loaders/css-loader/

To import styles from a node_modules path (include resolve.modules) and for alias, prefix it with a ~:

環境変数

// Load environment variables
const result = require('dotenv').config({
  path: `.env.${process.env.APP_ENV}`,
});
if (result.error) {
  throw result.error;
}

module.exports = {
  // ...
  plugins: [
    new webpack.DefinePlugin({
      'process.env.GA_TRACKING_ID': JSON.stringify(process.env.GA_TRACKING_ID),
    }),
  ],
};

その他

npm のコマンド `docs` `repo` `view`

パッケージのドキュメントやソースコードを簡単に確認するために npm docsnpm repo コマンドをよく使います。(最近知りました)

例えば @storybook/addon-docs のドキュメントやリポジトリを確認したい場合、以下のコマンドを入力します。
そうすると npm に登録されている @storybook/addon-docs の情報をもとに、docshomepage を、reorepository をブラウザで開いてくれます。

$ npm docs @storybook/addon-docs
$ npm repo @storybook/addon-docs

便利ですね。ありがたいありがたい。

AtCoder を JavaScript で

GitHub - ryotah/atcoder-js

AtCoder の問題を JavaScript (Node.js v5.12.0) で解くための環境を作ってみました。

Node のバージョン

nodenv で Node のバージョンを v5.12.0 に指定しています。これで、自分の環境では正解しているのに提出したらライタイムエラーになる、といった悲しいことがなくなると思います。

テンプレート

テンプレートを用意しました。npm run new foo とかでコピーできます。下のような感じでファイルが生成されます。

foo
├── README.md
├── index.js
└── input.txt

DIR=foo npm run execinput.txt の内容を読み込んで index.js を実行します。

BigInt

Number で表現できる最大の数、253 - 1 よりも大きな数値を扱うサンプルを用意しました。GitHub - peterolson/BigInteger.js: An arbitrary length integer library for Javascript を利用しています。examples/bigint で確認できます。

ESLint ルール追加したいお気持ち

既存の Nuxt プロジェクトに ESLint のルールを追加した時に調べたことを整理しました。

Lint の設定ファイルを秘伝のタレにはしたくないけれども、もう少しだけカスタマイズしたい、特にコードの複雑さを軽減しメンテナンス性を保ちたい、というモチベーションでルールの追加を検討した時の話です。

今のルール

npx create-nuxt-app <project-name> でプロジェクトを作成して ESLint と Prettier を選択すると以下のような .eslintrc.js が生成されます (Nuxt v2.11.0)。

自分たちのプロジェクトも概ねこのような感じです。

module.exports = {
  // ...
  extends: [
    '@nuxtjs',
    'prettier',
    'prettier/vue',
    'plugin:prettier/recommended',
    'plugin:nuxt/recommended'
  ],
  // ...
}

extends している @nuxtjs、実体は @nuxtjs/eslint-config ですが、これがさらに eslint-config-standard を extends しています。

eslint-config-standardeslint:recommendedeslint-config-airbnb-base みたいなあれです。

参考:

追加検討したルール

complexity ってのを見つけたのでこれでおkなのか?と心踊りましたがどうやらそんなに簡単ではないようです。

Why complexity is off? · Issue #1758 · airbnb/javascript · GitHub

For me, more better metric and more quality code you will get if you combine:

max-params, max-statements, max-statements-per-line, max-nested-callbacks and max-depth.

Don't know how complexity is actually implemented, but above rules are more fine grained and leads you to great code.

ESLint の List of available rules から探してみると、complexity の代わりに以下の 8 つが利用できそうです。

その中から max-lenmax-statements-per-line は今回の対象から外しました。この辺りは Prettier に任せます。

ついでに max-lines も外すことにしました。Vue を単一ファイルコンポーネントで作成すると テンプレートや CSS が含まれるためどうしても一般的な JS ファイルより行数が増えてしまうからです。
(.vue ファイルを Lint の対象から外してもいいんですが、まあいいかなあという気持ち。)

残ったのが以下 5 ルールです。

次に、試しにこれらのルールを手元のコードに適用させてみました。 (max-params だけ設定をデフォルト値から変更しました。3 から 4 に変更しています。Vuex の getter など、引数が 4 つある関数がライブラリベースで存在していたためです。)

1年近く運用している開発者がばらばらな 5 つのプロダクトで試してみましたが、レポートされたのは max-lines-per-functionmax-statements がそこそこ、max-params が 1 つだけ、max-depthmax-nested-callbacks は 0 でした。

どんなプロダクトでどれほどのコード量なのかわからなければあまり意味のない数字ですが、一応このような結果でした。

レビュー

ほぼ違反がなかった max-params, max-depth, max-nested-callbacks は利用しないことにしました。(嘘です。max-depth だけは利用することにしました。) この期間開発したコードで違反がでないなら、別に入れなくてもいいかなと。
未来の負債を産まないために適用させておくという考えもあるかもしれませんが、できるだけルールを (設定ファイルを) シンプルにしたい気持ちの方が強いので。

というわけで残りの max-lines-per-functionmax-statements をみていきます。

max-lines-per-function

これは function 内の行数を制限するルールです。デフォルトでは 50 行。空行やコメントをカウントさせたくない場合は skipBlankLines, skipComments を有効にすることで対応できます。

長すぎる function は全体像を把握しにくく、変数の影響もわかりにくく、テストも書きづらいなどデメリットが多いのです。違反している既存コードもそのようなケースがいくつか見られたため、個人的には有効にしたいルールです。

一方、プロパティ数が多いオブジェクトを利用する場合など、どうしても行数が増えてしまう状況もあり (Prettier が改行してくれるし)、常に有効にしたほうがいいともいえないかもしれません。
(単純なオブジェクトなら function の外に定義すれば解決しますが、そうではない場合を想定。)

ルールは有効にしつつ、特殊な function の場合は eslint-disable-next-line などで対応するのも手だと思います。

max-statements

先ほどの max-lines-per-function に多少似たルールに思えますが、これは function 内のステートメントの数を制限するルールです。デフォルト値は 10。

ステートメントの数は、雑に説明すると ;if の数です。
Statements and declarations - JavaScript | MDN

最初はこれも便利に思えたのですが、実際の違反しているコードを確認して考えが変わりました。

例えば、単純に冗長な手続きを書かざるを得ない場合や必要とする変数が多いケースなどは設定値 10 をすぐに超えてしまっていました。

それ以外にも、ステートメントを減らすために「変数を使い回す」といったようなアンチパターンを利用したり (そんな人はいないはずですが)、全体の可読性よりもステートメントを減らすことに意識が向くなど、状況によってはコードが悪化する可能性も感じました。

設定値を変える、そもそも「単純に冗長な手続きを書かざるを得ない場合や必要とする変数が多いケース」この考えが誤っている可能性もありますが、一旦見送りにしました。

max-depth

max-depth はコードの入れ子レベルを制限するルールです。

「ほぼ違反がなかった max-params, max-depth, max-nested-callbacks は利用しないことにしました。」と途中で書きましたが、これは嘘です。デフォルト値は 4 でしたが 3 に変更して、このルールを採用しました。

この設定の場合以下のコードの Nested 4 deep が違反することになります。

/*eslint max-depth: ["error", 3]*/
function foo() {
    for (;;) { // Nested 1 deep
        while (true) { // Nested 2 deep
            if (true) { // Nested 3 deep
                if (true) { // Nested 4 deep
                }
            }
        }
    }
}

設定を変更して再度 Lint を実行したところ、いくつかのコードが違反していました。違反箇所は総じて読みづらく、またリファクタリングも容易だったため、このルールも採用することにしました。

追加ルール no-else-return

max-depth入れ子を確認している時にいくつか見つけたのが、if の中で return しているがその後に if elseelse を続けているコードです。

コードのネストが深いと読み手は『精神的スタック』に条件をプッシュしなければならない

リーダブルコード

といわれているように、不要なネストをつくりたくありません。no-else-return は ESLint の --fix オプションで自動修正が可能なので、このルールも適用させることにしました。

結論

最終的に採用したルールはあまり多くありませんでした。no-else-returnmax-depth はオススメですが、max-lines-per-function はどちらでもいいかなという考えです。

module.exports = {
  // ...
  rules: {
    // ...
    // https://eslint.org/docs/rules/no-else-return
    'no-else-return': [
      2,
      {
        allowElseIf: false,
      },
    ],
    // 利用したくない人は、このルールをオフってね
    // https://eslint.org/docs/rules/max-lines-per-function
    'max-lines-per-function': [
      1,
      {
        skipBlankLines: true,
        skipComments: true,
      },
    ],
    // https://eslint.org/docs/rules/max-depth
    'max-depth': [1, 3],
  },
  overrides: [
    {
      files: ['*.test.{ts,js}'],
      rules: {
        'max-lines-per-function': 0
      }
    }
  ]
}

max-xxx ルールを自身のコードに適用して、有効そうなルールをピックアップするという作業はなかなか面白いと思います。

その他

<template> に存在していない Vue コンポーネントを動的に追加

Vue インスタンスを作成して $mount すればおk。

add() {
  const instance = new Bar({
    propsData: {
      date: new Date().valueOf()
    }
  })
    .$on("click", date => console.log(date))
    .$mount();
  document.body.appendChild(instance.$el);
}
  • vm.$mount
    • vm.$mount() は アンマウントな Vue インスタンスのマウンティングを手動で開始するために使用することができます。

    • elementOrSelector 引数が提供されない場合、テンプレートはドキュメント要素外で描画され、ドキュメントにそれを挿入するためにあなた自身でネイティブ DOM API を使用しなければなりません。

$mount に elementOrSelector を設定、あるいは Vue インスタンスの生成時に el を設定しても一応可能ですが、その場合「追加」ではなく「置換」になるので注意が必要です。

vue-append-instance-programmatically - StackBlitz

利用ケース

デバッグ用の View を追加したいけど、<DebugView v-if="isDebug" /> のように既存のコードに手を加えたくない場合にこんな感じで対応しました。

Nuxt の場合 plugin を利用して以下のように記述できると思います。

// plugins/routes-viewer/index.ts
// 登録されている全てのルートを表示するコンポーネント
import { Context } from '@nuxt/types';
import VueRouter, { RouterOptions } from 'vue-router';
import Viewer from './Viewer.vue';

export default (context: Context) => {
  const router = context.app.router;
  if (!router) {
    return;
  }
  const removeGuard = router.afterEach(() => {
    const instance = new Viewer({
      propsData: {
        paths: pickPaths(router),
      },
    })
      .$on('click', (path: string) => router.push(path))
      .$mount();
    document.body.appendChild(instance.$el);
    removeGuard();
  });
};

const pickPaths = (router: VueRouter) => {
  const routes = (router.options as RouterOptions).routes;
  if (!routes) {
    return [];
  }
  return routes.map(route => route.path).sort();
};

メモ

$el

$el は Vue インスタンスが管理しているルート DOM 要素です。$mount される前はまだ存在しないです。

const instance = new Bar({ ... });
console.log(instance.$el);
// => undefined
instance.$mount();
console.log(instance.$el);
// => <div>Bar<br>1578449862325</div>

new Bar() or new Vue() ?

Bar がプレーンなオブジェクトなのか、Vue.extend を利用して生成された Vue コンストラクタのサブクラスなのか。

Bar が Vue コンストラクタのサブクラスならばサンプル通りに new Bar() が利用できます。そうではない場合、当然ですが new Bar() ではエラーになります。new Vue({ ...Bar, /* */ }); のようになります。

生成した Vue インスタンス内で Router などが使えない

新たに作成した Vue インスタンスは、既存のルート Vue インスタンスとは別の管理になります。つまり、このままでは this.$routerthis.$store にはアクセスできません。
新たに inject することもできますが、今回のようなケースでは追加されるコンポーネントには機能をあまりもたせず、追加する側で対応する方がよさそうに思えます。

TypeScript お試し環境をお手軽コピペコマンドで

小ネタです。コピペでおk。Mac なら動くと思います。
VS CodeVS Code ESLint extension をインストールしておくと ESLint の自動修正も動くので快適です。

echo mkdir
mkdir ts-`date '+%Y%m%d%H%M%S'` && cd $_
echo typescript
npm init -y && npm i -D typescript && node_modules/.bin/tsc --init && echo -e "const arr: number[] = [1, 3, 5];\nconsole.log(arr);" > index.ts

# prettier 使いたい人はここも
echo prettier
npm i -D prettier && echo -e '{\n  "singleQuote": true,\n  "trailingComma": "es5"\n}' > .prettierrc

# さらに eslint 使いたい人はここも
echo eslint
npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-plugin-prettier && echo -e '{\n"root": true,\n"env": {\n"browser": true,\n"node": true\n},\n"extends": [\n"eslint:recommended",\n"plugin:@typescript-eslint/eslint-recommended",\n"plugin:@typescript-eslint/recommended",\n"plugin:prettier/recommended",\n"prettier/@typescript-eslint"\n],\n"rules": {}\n}' > .eslintrc && node_modules/.bin/prettier --write .eslintrc

# さらに vscode で eslint の auto fix 使いたい人はここも
echo vscode
mkdir .vscode ; echo -e '{\n"editor.formatOnSave": false,\n"editor.codeActionsOnSave": {\n"source.fixAll.eslint": true\n}}' > .vscode/settings.json && node_modules/.bin/prettier --write .vscode/settings.json

# git もお好みで
echo git
git init && echo node_modules/ > .gitignore && git add . && git commit -m 'init'

実行結果

.
├── .eslintrc
├── .git
├── .gitignore
├── .prettierrc
├── .vscode
│   └── settings.json
├── index.ts
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json

何ができるの

  • typescript のインストール
  • tsc --init を利用した TypeScript プロジェクトの初期化と tsconfig.json の生成
  • ESLint と prettier のインストールと設定
    • Lint には最低限と思われる recommended な設定だけいれてあります
      eslint:recommended, plugin:@typescript-eslint/recommended
      各ルールがコンフリクトしないための設定もいれています

何に使うの

  • ローカルで手軽に TypeScript の挙動確認をするため
  • https://stackblitz.com/http://www.typescriptlang.org/play/ も便利でよく使いますが、TypeScript のコンパイラの挙動を確認したり npm install したパッケージのコードを確認したい時はローカルで動かしたほうが捗るので
  • 正直 tsc --init だけで十分です。フォーマット崩れが気になってしまう人向けです
  • すでに設定が完了している環境 (例えば開発中のアプリケーションや npx create-xxx 的なもの) を使って挙動確認していると、前提知識や設定の違いでハマったりするので気をつけましょう
  • プロダクトに使うようなものではありません

その他

  • 手軽に実行するなら node_modules/.bin/tsc
  • 出力先をかえたい
  • コンパイル対象を変更したい
    • tsconfig.jsonfiles, include, exclude を変更
    • 初期設定は If the "files" and "include" are both left unspecified, the compiler defaults to including all TypeScript (.ts, .d.ts and .tsx) files in the containing directory and subdirectories です
    • import 対象になっているファイルは、config 上で対象になっていなくてもコンパイル対象になります
  • 読み込んでいる型定義ファイルを変更したい
    • tsconfig.jsontypes, typeRoots を変更
    • 初期値は all visible “@types” packages are included in your compilation. です。node_modules/@types などが対象になっています
    • 重要なこと Keep in mind that automatic inclusion is only important if you’re using files with global declarations (as opposed to files declared as modules).

コンポーネントの横断的関心事を処理する方法 Mixins, HOCs, Render Props, Hooks を振り返る

これは何か

複数のコンポーネントが必要とする機能を共有可能にする方法を整理してみました。プロコンとこれから。基本的には 参考 URLs にあるドキュメントの切り貼りです。

キーワード

横断的関心事とは

コンポーネントは React のコード再利用における基本単位です。しかし、いくつかのパターンの中には、これまでのコンポーネントが素直に当てはまらないことがあることに気づいたかもしれません。

https://ja.reactjs.org/docs/higher-order-components.html

具体例として以下のようなコンポーネントを考えてみます。

class CommentList extends React.Component {
  constructor(props) { /* ... */ }
  componentDidMount() {
    // Subscribe to changes
    DataSource.addChangeListener(this.handleChange);
  }
  componentWillUnmount() {
    // Clean up listener
    DataSource.removeChangeListener(this.handleChange);
  }
  handleChange() {
    // Update component state whenever the data source changes
    this.setState({
      comments: DataSource.getComments()
    });
  }
  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/higher-order-components.html

上のコードは CommentList だけですが、もう片方のコンポーネントも大部分の実装は同じになります。

  • コンポーネントのマウント時に DataSource にイベントリスナを登録
  • リスナの内部で setState をデータソースが変更されるたびに呼び出す
  • コンポーネントのアンマウント時にはイベントリスナを削除

横断的関心事とは、このような複数のコンポーネントに共通しているが1箇所にまとめるのが難しい関心事のことです。横断的関心事のロジックを1つの場所に定義し、複数のコンポーネントで共有可能にするために Mixins, HOCs, Render Props, Hooks といったテクニックが有用なことがあります。

Higher-Order Components

高階関数は「引数として関数をとる」「関数を戻り値として返す」関数です。では高階コンポーネントとはどういうものでしょうか。

具体的には、高階コンポーネントとは、あるコンポーネントを受け取って新規のコンポーネントを返すような関数です

https://ja.reactjs.org/docs/higher-order-components.html

const EnhancedComponent = higherOrderComponent(WrappedComponent);

前述の CommentList コンポーネントを高階コンポーネントを利用してリファクタリングすると以下のようになります。 withSubscription が高階コンポーネントです。

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);
const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);
// https://ja.reactjs.org/docs/higher-order-components.html
function withSubscription(WrappedComponent, selectData) {
  return class extends React.Component {
    constructor(props) { /* ... */ }
    componentDidMount() {
      DataSource.addChangeListener(this.handleChange);
    }
    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }
    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }
    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}
// https://ja.reactjs.org/docs/higher-order-components.html

より具体的な説明:

HOC は入力のコンポーネントを改変したり、振る舞いをコピーするのに継承を利用したりしません。むしろ HOC は元のコンポーネントをコンテナコンポーネント内にラップすることで組み合わせるのです。HOC は副作用のない純関数です。

... 外側にある HOC は渡すデータが使われる方法や理由には関心がありませんし、ラップされたコンポーネントの側はデータがどこからやって来たのかには関心を持ちません。

https://ja.reactjs.org/docs/higher-order-components.html

アンチパターンとして:

コンポーネントの改変を行うような HOC は不完全な抽象化です。つまり、利用する側は他の HOC との競合を避けるため、どのように実装されているかを知っておく必要があるのです。

https://ja.reactjs.org/docs/higher-order-components.html

Vue と Higher-Order Components

高階コンポーネントフレームワークが提供する機能ではなく汎用的な考え方なので、他のフレームワークでも実現可能です。

Render Props

“レンダープロップ (render prop)”という用語は、値が関数である props を使って、コンポーネント間でコードを共有するためのテクニックを指します。

... より具体的に述べると、レンダープロップとは、あるコンポーネントが何をレンダーすべきかを知るために使う関数の props です。

https://ja.reactjs.org/docs/render-props.html

<DataProvider render={data => (
  <h1>Hello {data.target}</h1>
)}/>

具体例として「マウスに追従する猫」を以下の2つにわけて実装してみます。

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse render={mouse => (
          <Cat mouse={mouse} />
        )}/>
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/render-props.html
class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse;
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}
class Mouse extends React.Component {
  constructor(props) { /* ... */ }
  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }
  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        {/*
          Instead of providing a static representation of what <Mouse> renders,
          use the `render` prop to dynamically determine what to render.
        */}
        {this.props.render(this.state)}
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/render-props.html

Vue の Scoped Slots

Higher-Order Components と同様に Rendre Props も機能 (API) ではなく考え方です。それゆえ Vue でも同様な考え方を利用してコンポーネントのロジック共有が可能です。

通化させたい振る舞いをもつコンポーネントに何をレンダーさせるか動的に決定させる方法として Vue の場合 Scoped Slots を利用することができます。

In case you are wondering what’s the equivalent pattern in Vue, it’s called scoped slots (and if using JSX it works the same as React)

https://twitter.com/youyuxi/status/912422434300710912

以下は VeeValidate の ValidationProvider を利用した実装例です。

<ValidationProvider name="name" rules="required" v-slot="{ errors }">
  <input v-model="name">
  <p>{{ errors[0] }}</p>
</ValidationProvider>

Render Props と Higher-Order Components

ミックスインの問題を解決させる方法として HOCs が広まったようですが、HOCs にもいくつかの問題があったようです。以下は Reneder Props でそのあたりも解決できるよ、という話。

This technique avoids all of the problems we had with mixins and HOCs:

  • Indirection. We don’t have to wonder where our state or props are coming from. We can see them in the render prop’s argument list.
  • Naming collisions. There is no automatic merging of property names, so there is no chance for a naming collision.

...

Additionally, the composition model here is *dynamic*!

Use a Render Prop! - componentDidBlog

Mixins

コンポーネントのロジック共有のテクニックとしてミックスインがどうして利用されなくなったのかでしょうか。

以前に横断的関心事を処理する方法としてミックスインをお勧めしました。私たちはその後にミックスインはそれが持つ価値以上の問題を引き起こすことに気づきました。ミックスインから離れる理由と、既存のコンポーネントを移行する方法についてはこちらの詳細な記事を読んでください

https://ja.reactjs.org/docs/higher-order-components.html

上の引用にもある Mixins Considered Harmful – React Blog がよくまとまっていました。私が利用していたのは Vue のミックスインですが、ほぼ共感できる内容でした。以下は記事内の "Why Mixins are Broken" より:

  • Mixins introduce implicit dependencies
    • 暗黙的な依存関係が生まれてしまう。安全にコードを変更することが難しくなるし、新しいメンバーが理解するハードルもあがる。
    • コンポーネントがミックスインのメソッドを呼び出すこともあれば、ミックスインがコンポーネントのメソッドを呼び出すこともある。
    • コンポーネントとは違いミックスインの間には階層構造がなくフラットな関係なため、データの流れや依存関係がどのようになっているのか把握するのが難しくなる。
  • Mixins cause name clashes
    • 例えば適用させる複数のミックスインが同じメソッド名を持つ場合
  • Mixins cause snowballing complexity

Mixins => HOCs => Render Props => Hooks

フックは React 16.8 から追加された React の API です。フックを導入した動機が フックの導入 – React に書かれています。

ステートフルなロジックをコンポーネント間で再利用するのは難しい

... React をしばらく使った事があれば、この問題を解決するためのレンダープロップ高階コンポーネントといったパターンをご存じかもしれません。しかしこれらのパターンを使おうとするとコンポーネントの再構成が必要であり、面倒なうえにコードを追うのが難しくなります。典型的な React アプリを React DevTools で見てみると、おそらくプロバイダやらコンシューマやら高階コンポーネントやらレンダープロップやら、その他諸々の抽象化が多層に積み重なった『ラッパー地獄』を見ることになるでしょう。... React にはステートフルなロジックを共有するためのよりよい基本機能が必要なのです。

フックを使えば、ステートを持ったロジックをコンポーネントから抽出して、単独でテストしたり、また再利用したりすることができます。フックを使えば、ステートを持ったロジックを、コンポーネントの階層構造を変えることがなく再利用できるのです。このため、多数のコンポーネント間で、あるいはコミュニティ全体で、フックを共有することが簡単になります。

フックの導入 – React

Render Props や HOCs との関係についても書かれています。

フックはレンダープロップや高階コンポーネントを置き換えるものですか?

レンダープロップや高階コンポーネントは、ひとつの子だけをレンダーすることがよくあります。フックはこのようなユースケースを実現するより簡単な手段だと考えています。これらのパターンには引き続き利用すべき場面があります(例えば、バーチャルスクローラーコンポーネントrenderItem プロパティを持つでしょうし、コンテナコンポーネントは自分自身の DOM 構造を有しているでしょう)。とはいえ大抵の場合ではフックで十分であり、フックがツリーのネストを減らすのに役立つでしょう。

フックに関するよくある質問 – React

ロジック共有のためにコンポーネントを利用する必要なんてないんですよ。

Vue Composition API

Vue に React Hooks のような関数ベースのロジック合成 API は追加されるのでしょうか。

( ´・ω) (´・ω・) (・ω・`) (ω・` ) ネー

Composition API RFC | Vue Composition API:

ここで詳しい情報を確認できるようです。

Motivation

Logic Reuse & Code Organization

...

Better Type Inference

...

Code Organization

...

But as mentioned in the Motivations section, we believe the Composition API actually leads to better organized code, particularly in complex components. Here we will try to explain why.

What is "Organized Code"?

...

export default {
  setup() { // ...
  }
}

function useCurrentFolderData(networkState) { // ...
}

function useFolderNavigation({ networkState, currentFolderData }) { // ...
}

function useFavoriteFolder(currentFolderData) { // ...
}

function useHiddenFolders() { // ...
}

function useCreateFolder(openFolder) { // ...
}

Logic Extraction and Reuse

The Composition API is extremely flexible when it comes to extracting and reusing logic across components.

...

import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)
  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  return { x, y }
}

export default {
  setup() {
    const { x, y } = useMousePosition()
    // other logic...
    return { x, y }
  }
}

( ^ω^)

参考 URLs