NHN Cloud NHN Cloud Meetup!

ツリーシェイクされたUIライブラリの作り方(A to Z)

はじめに

あなたがウェブページを作成するとき、豊富な機能を提供するため、よく作られているUIライブラリを用いることがありませんか?今回は、NHNで開発したTOAST UIにおいて、これまでに培った経験とノウハウを大放出したいと思います。ここでは、UIライブラリの作成方法を紹介するために、ライブラリの目標、機能、使用した技術スタック、Webpackの設定まで、すぐに適用できるように実用的な内容で構成しました。UIライブラリの作成方法や、ノウハウA to Zを知りたい方は、パソコンを用意して一緒に始めましょう。

まず、TOAST UI Gridについて紹介しましょう。Gridはさまざまなツールやシステムに使用されており、TOAST UI Calendar v2にもGridが使用されています。Gridは大容量のデータをレンダリングするため、パフォーマンスが非常に重要となるUIライブラリですが、Preactを導入し、独自のリアクティブシステムを開発したことで、画期的な変化を引き起こしました。

最新UIライブラリの目標、機能、技術スタック

TOAST UI Gridで得られるメリットを含め、私たちが考えるUIライブラリは、次のような目標と機能を持ちます。

目標

技術的には、ユーザーおよび開発者の観点からの目標として、次のようなものが挙げられます。

  1. ユーザーは、ライブラリから必要な機能のみを使用することができ、ウェブページのサイズを減らすことができる。
  2. 開発者は、便利な開発環境を構築して生産性を高め、性能に優れた使いやすいライブラリを作成できる。

主な機能

2つの目標を持ったライブラリが対応すべき主な機能です。

  1. ツリーシェイク対応
  2. 仮想DOMでレンダリング最適化
  3. サーバーレンダリング対応

LodashやMomentは、機能に優れたよいライブラリです。しかし、使用しない機能まですべてバンドルした場合、ライブラリのサイズが大きくなるため、サイズを小さくするための最適化手法が提供されています。ツリーシェイクもその1つであり、ツリーシェイクに対応するライブラリを作成して、ウェブページのサイズを減らせるようにします。

主な技術スタック

TOAST UIのライブラリは、すべてWebpackを使用してバンドルされています。他にも導入している技術スタックを見てみましょう。

TypeScript(タイプスクリプト)導入
TypeScriptを使うことでメンテナンスをしやすくし、エラーの発生を減らすことができるという利点から、TypeScriptの使用を決定しました。

仮想DOM(Preact)導入
従来は、テンプレートエンジンとしてhandlebarsを使用していました。レンダリングのたびに、全体DOMを常に更新する必要があったため、不要なレンダリングを減らすために、仮想DOMの導入を決定しました。

ES6モジュール導入
ES6モジュールで開発することは、もはや驚くべき内容ではありませんね。ツリーシェイクをするには、ES6モジュールの使用が必須となるためです。しかし、ツリーシェイクに対応したUIライブラリを開発することは、さらなる挑戦を意味します。この部分は、次の「主な技術スタックを採用した理由」で詳しく紹介します。

サーバーサイドレンダリング対応
技術スタックと見るには少し無理がありますが、Preactを導入し、仮想DOMをHTML文字列に変換することが容易になりました。

SassとPostCSS導入
従来はStylusを使用していましたが、CSSをうまく構造化させられるSassを用いて、CSSクラスライブラリ固有のセレクタを付与するため、PostCSSに変換しました。

次のトピックで、ツリーシェイクに対応したUIライブラリを作るために経験したこと、技術スタックを採用した理由について、詳しく紹介していきます。説明よりもすぐに開発環境を設定してみたい方は、次のトピックをスキップして、「実践 – 素晴らしい開発環境を作る」に進んでください。

主な技術スタックを採用した理由

TypeScriptは、協業メンバー間の合意によって導入することに決めました。TypeScriptを導入せずにツリーシェイクされるUIライブラリを作成したい場合は、BabelとPreactJSXを使うことで同じ目的を達成することができます。では、技術スタックをどのように選択したか、JavaScriptの面から詳しく見てみましょう。

ツリーシェイク対応

TOAST UI Calendarは、日間ビュー、週間ビュー、月間ビューなど、いくつかの表示タイプに対応していますが、特定の表示タイプのみを使用するユーザーも存在します。使用していない表示タイプのために、全体のライブラリをすべて含める必要があるでしょうか?たとえば、月間表示のみを使用する場合、週間表示のソースコードを含める必要はありませんね。ツリーシェイクができるようにコードを構成しましょう。ユーザーにバンドルされるJavaScriptには含まれないようにして、サイズを小さくすることが目的です。

ライブラリのユーザーが、WebpackやRollupなどバンドラーでツリーシェイクをする場合、ライブラリはES6モジュールで開発しなければなりません。ES6モジュールはインポートエクスポートを使用します。バンドラーは、モジュールでエクスポートした機能のうち、実際に使用した機能だけを残して、残りのコードはバンドルから除外させます。言葉では簡単ですが、ES6モジュールで構成されたUIライブラリを提供するには、いくつか考慮すべき点があります。

モジュールのサイドエフェクトが発生しないことで、はじめてツリーシェイクが可能となる

モジュールでエクスポートした機能のうち、使用しない機能をバンドルから削除するには、その機能が本当に使用されていないことをバンドルが判断しなければなりません。たとえば、エクスポートした関数AがBを参照している場合、ライブラリのユーザーが関数Aのみを使用したとしても、関数Bをバンドルに含めなければなりません。これがサイドエフェクトの発生であり、明示的に使用している関数でなくても、他の関数を含む可能性があるということを意味します。バンドラーはこのようなケースをすべて考慮し、関数が互いに参照しているかどうかを判断しなければなりません。これは判断しにくい問題であり、バンドルの性能を落とすことがあります。

これを解決するために、ライブラリの作成者は、サイドエフェクトが発生しないように開発したことを保証するフラグを設定します。Webpackはこのフラグを信用して、使用していないモジュールをバンドルから除外します。package.jsonで”sideEffects”: falseと設定します。

トランスパイルされた後は、純粋にJavaScriptでのみ動作する必要がある

Webpackは、JavaScriptをバンドルするとき、ローダーやプラグインなどの各種ツールを使用しています。ローダーを必要とするJavaScriptは、JavaScriptVMから直接実行することができないJavaScriptであり、Webpackが前処理をする必要があるため、そのままでは実行できません。ローダーを通じてトランスパイルおよびバンドルされた後、純粋にJavaScriptのみ実行できる状態になります。

結論として、ツリーシェイクされたES6モジュールを作り出すには、開発時にWebpackを使用するツールが利用できないということになります。

なぜなら、WebpackがJavaScriptモジュールをWebpackのモジュールに変換してしまうからです。UMDでバンドルした場合は、__webpack_require__のようなコードを使ったモジュールに変更され、すべてのモジュールが1つあるいは複数のバンドルファイルに集まってしまいます。そのため、ライブラリのユーザーがライブラリを使用するときは、ES6モジュールがないため、ツリーシェイクが不可能となります。また、モジュールが1つのバンドルファイルに集まった場合、サイドエフェクトの発生が非常に多くなるので、ツリーシェイクが実際にはほとんど行われない可能性が高いです。

では、Rollupはどうでしょうか?RollupはES6ライブラリを作成できるようにサポートするバンドラーです。Webpackのように、独自のモジュール技術を使わずにES6モジュールの形態を維持してくれます。Rollupでバンドルする場合、すべてのES6モジュールが1つのファイルにバンドルされます。ただし、ライブラリのユーザーがWebpackを使ってツリーシェイクを試みたとき、サイドエフェクトによりツリーシェイクが正常に行われません。

では、Webpackのローダーの便利な機能をすべて捨てなければならないのでしょうか?TOAST UI Calendar v1は、テンプレートエンジンとしてhandlebarsを使用していました。これはHTMLを直感的に作成できるので便利ではありますが、やはりWebpackのローダーを使用するためには他の選択肢を探す必要があります。そして見つけたのが、まさに仮想DOMをサポートするPreactJSXでした。BabelTypeScriptJSXh関数に変換することができ、JavaScriptをトランスパイルすると純粋なJavaScriptを作り出します。

仮想DOMでレンダリング最適化

仮想DOMのメリットは、不要なレンダリングを減らし、レンダリングを最適化することができます。また、別途のテンプレートエンジンを必要とせずにJSXを使用することができます。したがって、Webpackのローダーを使用しても構いません。BabelとTypeScriptがPreactに対応しているので、JSXはトランスパイルを経てJavaScript関数h()に変換されます。Webpackのローダーを使用していないので、ES6モジュールの形態を維持します。また、別途のテンプレートエンジンを使用しなくてもよいという大きなメリットも持っています。

サーバーサイドレンダリング対応

TOAST UI Calendar v1は、サーバーサイドレンダリングを考慮して開発されていませんでした。v1もサーバーサイドレンダリングを考慮して追加開発ができますが、リアルDOMに基づいて動作しているため、nodeで実行するのが容易ではないかもしれません。
すでにPreactを使用することが決定しているので、仮想DOMをHTML文字列に変換してくれるサーバーサイドレンダリングは、おまけでついてくるようなものです。

実践 – 素晴らしい開発環境を作る

では、実践してみましょう。読んで真似していけば前述の目標と機能を提供するUIライブラリを作成することができます。

最終成果物を見てみよう

まず、バンドルとトランスパイルされたファイルの最終的な姿を見てみましょう。v1のように引き続き単一バンドルファイルも提供し、ES6モジュールも提供します。

ES5単一バンドルファイルのリスト

  • dist/tui-calendar.js
  • dist/tui-calendar.css
  • dist/tui-calendar.min.js
  • dist/tui-calendar.min.css

ツリーシェイクされたES6モジュールは、dist/esmフォルダに下図のように作成されます。(プロトタイピング中のv2の様子。サンプルでは単純なクラスをいくつか作成しています。)

主な技術と開発環境のリストを見ながら、項目別にインストールと設定を進めましょう。バンドラーはWebpack v4を使用します。

主な開発環境の説明

  • 基本設定
  • 静的解析ツール適用
  • 文書化ツール設定 @toastui/docにAPIドキュメント作成
  • TypeScript設定
  • CSS設定
  • 開発サーバー設定
  • バンドルおよび配布設定
  • PreactでUIライブラリ作成
  • テスト実行

基本設定

プロジェクトフォルダesm-ui-libraryを作成して、パッケージを初期化します。

mkdir esm-ui-library
cd esm-ui-library
npm init // And Be The Yes Man

ソースフォルダは、次のような構造です。

  • dist
  • src
    • images
    • sass
    • ts

Webpack基本パッケージをインストールします。

npm i --save-dev webpack webpack-cli

TypeScriptとTypeScriptローダーをインストールします。

npm i --save-dev typescript ts-loader

Webpack設定ファイルを作成します。webpack.config.js

const path = require('path');
const webpack = require('webpack');
const pkg = require('./package.json');

const isProduction = process.env.NODE_ENV === 'production';
const FILENAME = pkg.name + (isProduction ? '.min' : '');

const config = {
  entry: './src/ts/index.ts',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: FILENAME + '.js'
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader'
      }
    ]
  },
};

module.exports = config;

tsconfig.jsonファイルを作成します。内容は空でも構いませんが、ファイルが存在しない場合はエラーが発生します。

{}

エントリーファイルを作成します。src/ts/index.ts 

export default {};

ここまででビルドは正常に実行されます。

npx webpack --mode development

            Asset     Size  Chunks             Chunk Names
esm-ui-library.js  4.4 KiB    main  [emitted]  main
Entrypoint main = esm-ui-library.js
[./src/ts/index.ts] 66 bytes {main} [built]

ライブラリ関連の設定

webpack.config.jsのoutputを修正して、モジュールタイプ、ネームスペースなどライブラリに関連する設定を行います。

output: {
  library: ['tui', 'Calendar'],  // ライブラリネームスペースの設定
  libraryTarget: 'umd',          // ライブラリターゲットの設定
  libraryExport: 'default',     // エントリーポイントのdefaultexportをネームスペースに設定するオプション
  ...
}

libraryExportは、commonjsでモジュールを作成する場合は、設定する必要がありません。しかし、ES6モジュールにデフォルトエクスポートした場合は、この値を必ず設定しなければなりません。値がない場合、ネームスペースは次のようにアクセスしなければならず、不便です。

設定前

const calendar = new tui.Calendar.default();

設定後

const calendar = new tui.Calendar();

モジュール解決とエイリアス設定

webpack.config.jsにresolveを追加して、モジュール解決を設定します。他のモジュールをインポートするときに相対パスを使用すると、相対的なフォルダパスをすべて把握しなければならないため不便です。エイリアス(alias)を追加しましょう。

resolve: {
  extensions: ['.ts', '.tsx'],
  alias: {
    '@src': path.resolve(__dirname, './src/ts/')
  }
}

設定前

import Month from '../../view/month';

設定後

import Month from '@src/view/month';

バンドルファイルのバナー設定

ES5単一バンドルファイルにバナーを設定して、バージョン、ビルドの日付、作成者、ライセンスを明示します。

webpack.config.jsにwebpack.BannerPluginプラグインを追加します。

const BANNER = [
  'TOAST UI Calendar 2nd Edition',
  '@version ' + pkg.version + ' | ' + new Date().toDateString(),
  '@author ' + pkg.author,
  '@license ' + pkg.license
].join('\n');

const config = {
  ...,
  plugins: [
    new webpack.BannerPlugin({
      banner: BANNER,
      entryOnly: true
    })
  ]

ES6モジュールは、TypeScriptのソース上段に注釈を活用して作成すればトランスパイル後もコメントが残るため、各ソースファイルに作成します。
例: src/ts/month.ts

/**
 * @fileoverview Month View Interface
 * @author NHN FE Development Lab <dl_javascript@nhn.com>
 */
export const Month = {};

静的解析ツール適用

JavaScriptとCSSの両方を静的解析できるように、ESLintPrettierstylelintを先に適用します。プロジェクト初期から静的解析を適用することをお勧めします。

Eslintインストール

Eslintのルールが明確に定義された設定を継承すると便利です。eslint-config-tuiを活用します。

npm i --save-dev eslint eslint-loader eslint-config-tui eslint-plugin-react

TypeScriptを静的解析する必要があるため、関連パッケージも一緒にインストールします。

npm i --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin

Prettierインストール

npm i --save-dev prettier eslint-config-prettier eslint-plugin-prettier

stylelintインストール

npm i --save-dev stylelint stylelint-config-recommended stylelint-scss stylelint-webpack-plugin

Eslint設定

TypeScript、EslintPrettierまで適用した.eslintrc.jsファイルを次のように作成します。

module.exports = {
  root: true,
  env: {
    browser: true,
    es6: true,
    node: true
  },
  parser: "@typescript-eslint/parser",
  plugins: ["prettier", "react", "@typescript-eslint"],
  extends: [
    "tui/es6",
    "plugin:@typescript-eslint/recommended",
    "prettier/@typescript-eslint",
    "plugin:react/recommended",
    "plugin:prettier/recommended"
  ],
  parserOptions: {
    parser: "typescript-eslint-parser",
    ecmaVersion: 2018,
    sourceType: "module",
    project: "tsconfig.json"
  },
  settings: {
    react: {
      pragma: "h",
      version: "16.3"
    }
  }
};

@typescript-eslint/parserが2.0解析において、エディタでimport構文をエラーとして表示するという問題が発生しています。エディタにのみ発生するエラーで動作はうまく行われます。イシュー登録されており処理中の状態なので、パッチされたら一緒に更新しましょう。

Prettier設定

.prettierrc.jsファイルを次のように作成します。ルールは、チームやプロジェクトに合わせて変更すればよいでしょう。

module.exports = {
  printWidth: 100,
  singleQuote: true
};

stylelint設定

stylelint.config.jsファイルを次のように作成します。

module.exports = {
  extends: 'stylelint-config-recommended',
  plugins: ['stylelint-scss']
};

Webpack設定

JavaScriptの静的解析のためにeslint-loaderを追加します。use属性が配列の場合、配列の末尾からローダーが実行されるので、順番に注意しましょう。逆にした場合、TypeScriptがトランスパイルした結果を静的解析するので、自分が望んだ結果が出てこないでしょう。

const StyleLintPlugin = require('stylelint-webpack-plugin');

const config = {
  ...
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: ['ts-loader', 'eslint-loader']
      }
    ]
  },
  plugins: [
    ...
    new StyleLintPlugin()
  ]
};

Visual Studio Code設定

Visual Studio Codeで静的解析結果を表示するためにインストールが必要な拡張機能のリストです。リンクをクリックしてインストールしましょう。

そして、Visual Studio Codeの設定フォルダを作成して、設定ファイル(.vscode/settings.json)を次のように作成します。

{
  "eslint.validate": [
    {
      "language": "typescript",
      "autoFix": true
    },
    {
      "language": "typescriptreact",
      "autoFix": true
    }
  ]
}

文書化ツール設定 @toastui/docにAPIドキュメント作成

ライブラリにおいてAPIドキュメントは非常に重要です。あるライブラリを初見したとき、APIドキュメントがうまく作られたものと、そうでないものでは歴然の差があります。

TOAST UI Docは、最近発表された文書化ツールで、TOAST UIのすべての製品群に適用しています。JSDocを解析し、APIドキュメントを作成して、サンプルをまとめて1つの文書にしてくれるツールです。いくつかのオプションを設定して実行するだけで、JavaScriptライブラリ向けの文書を簡単に作成することができます。デモを見ると、ライブラリのAPIドキュメントがなぜ必要なのか共感できるでしょう。

パッケージインストール

npm i -g @toast-ui/doc

設定ファイル作成

tuidoc.config.jsonファイルを作成します。TypeScriptはまだサポートしていないため、dist/esmフォルダに作成されたES6モジュールを対象にAPIドキュメントを作成すればよいでしょう。GitHubリポジトリを設定すると、実際のソースがすぐに確認できるリンクが提供され便利です。下記はサンプルのため、画像、テキストなどは、自分に合ったものに修正しましょう。

{
  "header": {
    "logo": {
      "src": "https://uicdn.toast.com/toastui/img/tui-component-bi-white.png",
      "linkUrl": "/"
    },
    "title": {
      "text": "Calendar",
      "linkUrl": "https://github.com/nhn/toast-ui.doc"
    },
    "version": true
  },
  "footer": [
    {
      "title": "NHN",
      "linkUrl": "https://github.com/nhn"
    },
    {
      "title": "FE Development Lab",
      "linkUrl": "https://github.com/nhn/fe.javascript"
    }
  ],
  "main": {
    "filePath": "README.md"
  },
  "api": {
    "filePath": "dist/esm",
    "permalink": {
      "repository": "https://github.com/nhn/toast-ui.doc",
      "ref": "master"
    }
  }
}

JSDoc追加

次のようにJSDocを追加します。

/**
 * @class Calendar Calendar View
 */
export default class Calendar {}

npmスクリプトで簡単作成

package.jsonにスクリプトを追加して実行すると、_lastestフォルダにドキュメントが作成されます。

{
  "scripts": {
    "doc": "tuidoc"
  }
}

TypeScript設定

TypeScriptパッケージをインストールして、TypeScriptの設定ファイルを作成します。2つの設定ファイルを作成します。1つは、ES5でトランスパイルされ、単一バンドルファイルが作成されるものです。もう1つは、ES6でトランスパイルされてツリーシェイク可能なES6モジュールに変換するものです。

ES5の単一バンドルファイル

Webpack設定でts-loaderを追加しているので、Webpack設定はすでに完了した状態です。TypeScriptの設定ファイルを次のように作成します。

ES5のtsconfig.json

{
  "compilerOptions": {
    "noImplicitAny": true,
    "target": "es5",
    "jsx": "react",
    "jsxFactory": "h",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "baseUrl": ".",
    "paths": {
      "@src/*": ["src/ts/*"]
    },
    "sourceMap": true
  },
  "include": ["src/ts/**/*.ts", "src/ts/**/*.tsx"],
  "exclude": ["node_modules"]
}

Webpack設定でエイリアスを追加しても、Visual Studio Codeのモジュールが見つからないというエラーが表示されますが、TypeScriptの設定ファイルを追加し、baseUrlpathsも同様にエイリアスを設定すれば、エラー表示がなくなります。

エントリーファイル追加- package.json
ライブラリの開始点をES5の単一バンドルファイルで指定します。package.jsonのmain属性です。

{
  "main": "dist/esm-ui-library.js"
}

(ツリーシェイク可能な)ES6モジュール

TypeScriptをES6モジュールにトランスパイルするためにはWebpackを使用せず、直接TypeScriptトランスパイルを使用しなければなりません。
ES6でトランスパイルするためにTypeScriptの設定ファイルをもう1つ追加します。

ES6モジュール用 tsconfig.esm.json

{
  "compilerOptions": {
    "noImplicitAny": true,
    "target": "es6",
    "jsx": "react",
    "jsxFactory": "h",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "node",
    "outDir": "dist/esm/",
    "strict": true,
    "baseUrl": ".",
    "paths": {
      "@src/*": ["src/ts/*"]
    }
  },
  "include": ["src/ts/**/*.ts", "src/ts/**/*.tsx"],
  "exclude": ["node_modules"]
}

ES5向けと異なる点です。

  • 変更: “target”: “es6”  
  • 追加:“moduleResolution”: “node”ライブラリのユーザーがnode環境で開発したり、nodeでコードが実行されるために設定します。
  • 追加:“outDir”: “dist/esm/”ES6モジュール作成パスを指定
  • 削除:sourceMapの除去、ES6モジュールはバンドルしておらず必要ありません。

ES6モジュールパス設定 – package.json

{
  "module": "dist/esm/",
  "sideEffects": false
}
  • module 属性:ES6モジュールとして使用する場合は、ナビゲーションパスを設定します。
  • sideEffects属性:ツリーシェイクでサイドエフェクトにより、不要なモジュールを削除できない場合があります。このライブラリのモジュールにサイドエフェクトがないことを保証するフラグを設定します。ライブラリの作成者が責任を負うことで、Webpackこのツリーシェイクをより明示的に行うことができます。

ES6モジュールでエイリアスを相対パスに変換

TypeScriptトランスパイルを経た後でも@srcのようなエイリアスは変わらずにそのまま残っています。

import Month from '@src/month';
import Week from '@src/week';

ライブラリのユーザーがES6モジュールをインポートした場合、ユーザーは@srcを解釈できないので、モジュールを見つけることができないというエラーが発生します。したがって、エイリアスは再び相対パスに変換してなければなりません。ttypescript – Transform Typescripttypescript-transform-pathsプラグインを追加して、相対パスに変換します。

npm i --save-dev ttypescript typescript-transform-paths

ES6モジュール用のtsconfig.esm.jsonにプラグインを追加します。

{
  "compilerOptions": {
    ...,
    "plugins": [
      {
        "transform": "typescript-transform-paths"
      }
    ]
  },
  ...
}

設定前

import Month from '@src/month';
import Week from '@src/week';

設定後

import Month from './month';
import Week from './week';

CSS設定

v1は、StylusのCSSトランスパイルツールを使用しており、stylus-loaderを使用しています。JavaScriptは、ツリーシェイクをサポートするためにWebpackのローダーを使用することができないと前述しました。しかし、CSSの場合は状況が異なります。CSSは、HTMLにバンドルCSSを含めるだけなので、どのようなローダーやツールでも使用することができます。

WebpackでCSSをバンドルするために、css-loaderを含む必要なパッケージをインストールします。

npm i --save-dev css-loader style-loader mini-css-extract-plugin

CSSインポート方法

まず注意すべき点は、CSSをJavaScriptのソースからインポートしないということです。Webpackを使って開発する場合、一般的にJavaScriptコードでCSSファイルをインポートします。こうなると、JavaScriptがトランスパイルされた後にインポートされた場合、ライブラリのユーザー側でCSSファイルのインポートを一緒に処理しなければならない問題が生じます。そうでなければ、CSSファイルのパスが合わないことから、モジュールのインポートに失敗してしまうでしょう。したがって、CSSファイルのインポートはJavaScriptのソースではなく、別途エントリーポイントとして追加します。Webpackのエントリーポイントを配列型に追加すると、JavaScriptのソース内でCSSをインポートしなくても、依存性グラフを1つ追加してバンドルの過程に含めることができます。

webpack.config.js

module.exports = {
  entry: ['./src/sass/index.scss', './src/ts/index.ts'],
  ...
};

SCSS使用

Stylusも優れたツールですが、TOAST UI Calendarの協業メンバーがSassの扱いに慣れていることと、シェアがより高いことが理由となりました。

Sasssass-loaderをインストールします。

npm i --save-dev node-sass sass-loader

ライブラリ専用のプレフィックス(prefix)をクラスセレクタに追加

TOAST UI CalendarのCSSはtui-full-calendar-というプレフィックスをつけてクラスセレクタを作成します。固有名を付与して、セレクタが重複するのを防ぐためです。v1は、preprocess-loaderを使用して、バンドルの過程でStylusのコードの文字列が置換されるようにしました。

v1のWebpack設定を見ると、最後の段階で文字列を置換しています。

const context = JSON.stringify({CSS_PREFIX: ‘tui-full-calendar-‘});
...
module: {
  rules: [
    {
      test: /\.styl$/,
      use: [
        `preprocess-loader?${context}`,
        ‘css-loader’,
        ‘stylus-loader’
      ]
    }
  ]
}

Stylusファイルから{css-prefix}の部分がtui-full-calendar-に置換されます。

.{css-prefix}holiday {
  color: red;
  font-size: 15px;
}

しかし、コードが整然としていないので、デバッグの際にセレクタをコードで検索しずらくメンテナンスが困難でした。PostCSSを使った方がはるかにきれいにコードを作成することができます。

postcss-loaderpostcss-prefixerをインストールします。

npm i --save-dev postcss-loader postcss-prefixer

すると、CSSがさらにきれいになります。

.holiday {
  color: red;
  font-size: 15px;
}

PostCSSに変換された結果を見ると、次のようにプレフィックスがうまくついています。

.tui-full-calendar-holiday {
  color: red;
  font-size: 15px;
}

CSSで使用した画像のバンドル

url-loaderを使ってCSSで使用したイメージをBase64形式に変換してバンドルに含まれるようにします。

npm i --save-dev url-loader

CSSバンドルのためのWebpack設定

Webpack設定ファイルにCSSの設定をmodule.rulesで追加します。

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const postcssPrefixer = require('postcss-prefixer');

...
const isDevServer = process.env.DEV_SERVER === 'true';
...

module: {
  rules: [
    ...
    {
      test: /\.s[ac]ss$/i,
      use: [
        isDevServer ? 'style-loader' : MiniCssExtractPlugin.loader,
        {
          loader: 'css-loader',
          options: {
            sourceMap: true
          }
        },
        {
          loader: 'postcss-loader',
          options: {
            sourceMap: true,
            plugins: [
              postcssPrefixer({
                prefix: 'tui-full-calendar-'
              })
            ]
          }
        },
        {
          loader: 'sass-loader',
          options: {
            sourceMap: true
          }
        }
      ]
    },
    {
      test: /\.(gif|png|jpe?g)$/,
      use: 'url-loader'
    }
  ]
},
plugins: [
  ...,
  new MiniCssExtractPlugin({
    filename: `${FILENAME}.css`
  })
]

開発サーバー設定

webpack-dev-serverをインストールし、html-webpack-pluginを使ってページが表示されるか、確認してみましょう。

npm i --save-dev webpack-dev-server html-webpack-plugin

webpack.config.jsにhtml-webpack-pluginを追加し、開発サーバーの設定を追加します。このときの注意点は、resolve.extensions‘.js’拡張子を追加する必要があるということです。‘.js’を追加しないと、webpack-dev-serverがロードするjsモジュールがロードされないため、サーバーが実行されません。

const HtmlWebpackPlugin = require('html-webpack-plugin');

const config = {
  ...,
  resolve: {
    extensions: ['.ts', '.tsx', '.js'], // '.js'を追加する。
    ...
  },
  plugins: [
    ...,
    new HtmlWebpackPlugin()
  ],
  devtool: 'source-map',
  devServer: {
    historyApiFallback: false,
    host: '0.0.0.0',
    disableHostCheck: true
  }

画面にはまだ何も出力されませんが、実行すると、JavaScriptとCSSがうまくロードされたことが分かります。

npx webpack-dev-server --mode development
<head>
  <link href="/dist/esm-ui-library.css" rel="stylesheet">
</head>
<body style="">
  <script type="text/javascript" src="/dist/esm-ui-library.js"></script>
</body>

バンドルおよび配布設定

では、実際にES5バンドルファイルとES6モジュールファイルが正しく作成されるか、確認してみましょう。

種類別のバンドルと開発サーバーを起動させるnpmスクリプトを追加します。@toastui/docを使用したスクリプト(“doc”)は、ES6モジュールをビルドした後、APIドキュメントを作成するように変更しました。

{
  "scripts": {
    "doc": "npm run build:esm && tuidoc",
    "serve": "DEV_SERVER=true webpack-dev-server --mode development",
    "build:dev": "webpack --mode development",
    "build:prod": "NODE_ENV=production webpack --mode production",
    "build:esm": "ttsc -p tsconfig.esm.json",
    "build": "rm -rf dist && npm run build:dev && npm run build:prod && npm run build:esm"
  }
}

ES5の単一バンドルファイル作成

developmentとproductionバージョンを作成します。distフォルダにファイルが作成されます。

npm run build:dev
npm run build:prod

ES6モジュール作成

dist/esm  フォルダにファイルが作成されます。

npm run build:esm

作成されたファイルの様子

npmに配布するファイルを選択

npmに不要なファイルを配布する必要はないので、必要なファイルだけを配布できるように設定します。

package.json

{
  "files": [
    "src",
    "dist",
    "index.d.ts"
  ]
}

PreactでUIライブラリ作成

まず、Preactとサーバーサイドレンダリングのため、preact-render-to-stringをインストールします。

npm i --save preact preact-render-to-string

簡単にエントリーファイルとMonth、Weekをレンダリングするクラスを作成します。renderToStringはPreactコンポーネントをHTML文字列に変換してくれる関数です。

index.ts

import Month from '@src/month';
import Week from '@src/week';

export default {
  Month,
  Week
};

export { Month, Week };

base.ts

import { render, ComponentChild } from 'preact';
import renderToString from 'preact-render-to-string';

export default abstract class Base {
  private _container: Element;

  private _base?: Element;

  public constructor(container: Element) {
    this._container = container;
  }

  protected abstract getComponent(): JSX.Element;

  public render(): void {
    this._base = render(this.getComponent(), this._container, this._base);
  }

  public renderToString(): string {
    return renderToString.render(this.getComponent());
  }
}

month.tsx

import { h } from 'preact';
import Base from '@src/base';

export default class Month extends Base {
  protected getComponent(): JSX.Element {
    return <h2>Month View</h2>;
  }
}

week.tsx

import { h } from 'preact';
import Base from '@src/base';

export default class Week extends Base {
  protected getComponent(): JSX.Element {
    return <h2>Week View</h2>;
  }
}

テスト実行

ライブラリを使って目標どおりに動作するかテストしてみましょう。当該ライブラリの目標にしたがって、次の機能が正常に動作することを確認します。

  • ツリーシェイクテスト – 使用したモジュールのみバンドルされ、ファイルサイズが減少することを確認
  • サーバーサイドレンダリングテスト – HTML文字列を作成

テストコード

テストコードは、WeekとMonthモジュールをインポートしてレンダリングを行い、さらにMonthをサーバーサイドレンダリングでHTML文字列を作成します。

import { Month, Week } from "esm-ui-library";

const week = new Week(document.getElementById("app1"));
week.render();

const month = new Month(document.getElementById("app2"));
month.render();

document.getElementById("ssr").innerHTML = month.renderToString();

実行画面

実行すると、図のようにWeekとMonth、そしてMonthのサーバーサイドレンダリング結果であるHTMLが、正しくレンダリングされていることが分かります。

ツリーシェイクのテスト

バンドルファイルのサイズは12.8KiBで、バンドルファイルのmonth.tsxとweek.tsxモジュールのすべてが含まれていることを確認できます。

      Asset       Size  Chunks             Chunk Names
 index.html  551 bytes          [emitted]
    main.js   12.8 KiB       0  [emitted]  main
main.js.map   49.8 KiB       0  [emitted]  main
new (class extends v {
  getComponent() {
    return Object(o.h)("h2", null, "Week View");
  }
})(document.getElementById("app1")).render();
const m = new (class extends v {
  getComponent() {
    return Object(o.h)("h2", null, "Month View");
  }
})(document.getElementById("app2"));

Monthを使用しないように、ソースから削除してからバンドルしてみましょう。ツリーシェイクが正常に動作すると、Monthモジュールは削除されるはずです。

import { Month, Week } from "esm-ui-library";

const week = new Week(document.getElementById("app1"));
week.render();

// const month = new Month(document.getElementById("app2"));
// month.render();

// document.getElementById("ssr").innerHTML = month.renderToString();

バンドルファイルのサイズが12.6KiBに減少し、バンドルファイルのmonth.tsxモジュールが削除されたことが分かります。

      Asset       Size  Chunks             Chunk Names
 index.html  551 bytes          [emitted]
    main.js   12.6 KiB       0  [emitted]  main
main.js.map   49.4 KiB       0  [emitted]  main
new (class extends v {
  getComponent() {
    return Object(o.h)("h2", null, "Week View");
  }
})(document.getElementById("app1")).render();

おわりに

どのような言語でも、不要なソースを減らして最終実行ファイルのサイズを小さくすることは非常に重要です。なぜなら、ファイルサイズの増加は、一般的にコストの増加につながるためです。JavaScriptのUIライブラリは、多くの機能に対応するほどファイルサイズが大きくなります。しかし、ユーザーはライブラリが提供する特定の機能だけを使いたい場合があります。したがって、UIライブラリがウェブページを最適化できる方法を提供すれば、より素敵なライブラリになるのではないでしょうか?ここではツリーシェイクの方法を選択しましたが、もちろん他の方法もあるでしょう。

TOAST UI Calendarはよく作られたソースをv1でオープンソース化し、たくさんの反響をいただきました。GitHubのスター数も7000個を超えています。TOAST UI Calendar v2を企画し、技術スタックを選定し、可能性を確認するプロセスは実に魅力的でした。この記事がみなさんにとってもさらに一歩飛躍するきっかけとなれば嬉しいです。ここで使用した完全なソースは、こちらからご確認いただけます。

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop