Expressを使ったアプリケーションの起動を早くするために、ルーターを遅延ロードできるexpress-lazy-routerというライブラリを書きました。

モチベーション

Expressを使ったウェブアプリを書くときに、TypeScriptをコンパイルするためにts-node(ts-node-devを使っています。 これは、起動時にすべてのTypeScriptファイルをコンパイルすることを意味しています。

大量のファイルのコンパイルはWebアプリケーションの起動を遅くします。 ts-nodeなどはimportしたファイルをその場で同期的にコンパイルする仕組みであるため、読み込むファイルが多いほどコンパイル時間が長くなります。

起動時には必要ないものは後でコンパイルすることで、Node.jsアプリの起動時間を短縮できます。 express-lazy-routerは、この不要なモジュールを遅延ロードするための仕組みです。

フロントエンドでは、既にReact Router, Vue Routerなどのルータライブラリを使って遅延ロードするのが一般的です。

また、webpackでは実験的な機能としてextrems.lazyCompilationをサポートしています。

これと同じことをExpress routingでもしたいというのがモチベーションです。

導入結果

最初にどのぐらい変わるのか、実際のアプリケーションで試した結果を載せておきます。

ts-node-dev + express + Docker for mac

  • Before
    • 起動時に 123 tsファイル をコンパイル
    • 起動までの時間: 34236ms
  • After(express-lazy-routerを使用):
    • 起動時に 14 tsファイル をコンパイル
    • 起動までの時間: 14238ms

まとめとしては次の通りです。

  • コンパイル時間は1tsファイルあたり200msかかっている
  • コンパイルするファイル数が減ったので、34236ms → 14238msまで起動時間が短縮できた(約20秒早くなった

Install

Install with npm:

npm install express-lazy-router

使い方

Expressの app.use(path, handler) のhandler部分に Dynamic ImportでRouterモジュールを読み込むようにします。

import express from 'express';
import { createLazyRouter } from 'express-lazy-router';
const lazyLoad = createLazyRouter({
    // In production, Load router asap
    preload: process.env.NODE_ENV === 'production',
});
const app = express();
// Load ./api.js when receive request to "/api"
app.use(
    '/api',
    lazyLoad(() => import('./api')),
);
app.listen(8000, () => {
  console.log(`Example app listening at http://localhost:8000`)
});

この例では、実際に /api にアクセスがきたときに ./api.ts 読み込まれてコンパイルされます。 React.lazyを使った遅延ロードとほぼ同じ仕組みです。

本番では、遅延ロードする必要性はないのでpreloadオプションでproductionならプリロードするように指定しています。

具体的な例

もう少し具体的なコードで、どのように遅延ロードするように変更するかを見ていきます。

Before: まだ遅延ロードしてないコードです。

index.js:

import express from 'express';
import api from "./api";
const app = express();
app.use(
    '/api',
    api
);
app.listen(8000, () => {
  console.log(`Example app listening at http://localhost:8000`)
});

api.js:

import express from 'express';
const router = express.Router();
// GET api/status
router.get("/status", (_, res) => {
    res.json({ ok: true })
});
export default router;

このときには、次のような流れでファイルが読み込まるので、起動前にすべてのファイルを読み込んでいます。

  • load index.js
  • load api.js
  • complete to launch the express app
  • GET /api/status
  • { ok: true }

After: 遅延ロードを導入したバージョンです。

index.js:

import express from 'express';
- import api from "./api";
+ import { createLazyRouter } from 'express-lazy-router';
+ const lazyLoad = createLazyRouter({
+     preload: process.env.NODE_ENV === 'production',
+ });
const app = express();
app.use(
    '/api',
-    api
+    lazyLoad(() => import("./api"))
);
app.listen(8000, () => {
    console.log(`Example app listening at http://localhost:8000`)
});

api.js: 特に変更はいりません。

遅延ロードに変更した場合(productionではないとき)は次のような流れでファイルが読み込まれます。 api.js は実際にリクエストがきたタイミングで読み込まれます。(ts-node-devを使っているならこのタイミングでコンパイルします)

  • load index.js
  • complete to launch the express app
  • GET /api/status
  • load api.js
  • { ok: true }

おわりに

Expressアプリケーションをルータ単位で遅延ロードするexpress-lazy-routerを作りました。

モチベーション的には、ts-node(ts-node-dev)のコンパイルがボトルネックになりそうなぐらい遅いところから来ています。

ただ、TypeScriptを使っていない場合でも、requireしているモジュールが巨大だとロード時間がかかり起動時間が遅くなることがあります。 どのモジュールのロード時間が長いかは次のツールを使うとデバックできます。

そのため、TypeScriptやBabelなどのコンパイルをしていない場合でも遅延ロードは一定の効果があると思います。 アプリケーションが巨大になるほど遅くなるだけだと問題があります。 ルーター単位で切り出すのはクライアントサイドでもよく見るので、サーバサイドでもやれるようにしたのがexpress-lazy-routerの発想です。