「Roppongi.rb #3 で「RailsエンジニアがReactを始めてSSRとReduxを導入するまで」という発表を行いました - Bit Journey's Tech Blog」あたりの話のなかで触れている、KibelaにおけるJSのビルド環境に関係する設定ファイルなどを公開します。
現在もこのときと基本的な構成は変わっておらず、すでに数ヶ月安定して運用できています。
環境
ディレクトリ構成
client/
- フロントエンド用TSコード(.ts, .tsx,.json)spec/javascripts/*
- テスト用TSコード
なおプロダクション用フロントエンドコードは 100% TypeScript です。そのうち、SSRで使うファイルはUniversal JavaScript (a.k.a. Isomorphic JavaScript) になっています。
TypeScript 設定ファイル
TypeScriptの実行環境は3つあるため、設定ファイルも3種類+基本設定の4ファイルあります。
tsconfig.base.json
: 基本設定tscon.json
: SSR用。ts-node*3で実行- vscodeを触るときはこれが参照される
tsconfig.webpack.json
: webpackでコンパイル&バンドルしてbrowserで実行spec/javascripts/tsconfig.json
: テスト用。ts-nodeで実行
それぞれ見ていきましょう。なお詳細は tsconfig.json · TypeScript をどうぞ。
tsconfig.base.json
{ "compilerOptions": { "allowSyntheticDefaultImports": true, "alwaysStrict": true, "forceConsistentCasingInFileNames": true, "jsx": "react", "lib": [ "es2017", "dom" ], "module": "commonjs", "moduleResolution": "node", "noEmitOnError": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": false, "noImplicitThis": true, "noImplicitReturns": true, "noUnusedParameters": false, "noUnusedLocals": false, "outDir": "./build/", "pretty": true, "removeComments": false, "sourceMap": true, "strictNullChecks": false, "target": "es5", "traceResolution": false } }
注意点としては、 "removeComments": false
です。これがないと後述のwebpack code splittingの制御ができません。
その他の3ファイルは差分だけです。
tsconfig.json
SSR用であるとともに、vscodeに読み込ませるための設定ファイルです。
{ "extends": "./tsconfig.base", "include": [ "./client/**/*.*", "./hypernova.ts" ] }
tsconfig.webpack.json
これだけ "module": "esnext"
になっています。ES modules構文をwebpackに処理させてcode splittingを制御するためです。
{ "extends": "./tsconfig.base", "compilerOptions":{ "module": "esnext" }, "include": [ "./client/**/*.*" ] }
spec/javascripts/tsconfig.json
特に変わったことはありません。ts-nodeで実行されます。
{ "extends": "../../tsconfig.base", "compilerOptions": { "baseUrl": "../../client", "outDir": "../../build/", "paths": { "*": [ "*" ] } } }
webpack.config.js
webpackerではなくwebpack-railsなので、普通にwebpack.config.jsを書いてます。
ウェブアプリ全体でただひとつのエントリポイント bundle.ts
を参照し、不要なコードをwebppackのcode splitting*4で分割する構成です。
バッとはってしまうとこんな感じです。
const webpack = require('webpack'); const StatsPlugin = require('stats-webpack-plugin'); const fs = require('fs'); const path = require('path'); const dotenv = require('dotenv'); const typescriptLoader = require('awesome-typescript-loader'); // Load .env and .env.${environment}(e.g. .env.development) // priority: .env.${environment} > .env dotenv.config(); dotenv.config({ path: `./.env.${process.env.NODE_ENV}` }); // must match config.webpack.dev_server.port (default to 3808) const devServerPort = 3808; const production = ['production', 'staging',].includes(process.env.NODE_ENV); const publicDir = "assets"; const publicPath = `${process.env.ASSET_HOST}/${publicDir}/`; const config = { entry: { 'bundle': [ "core-js/shim", 'dom4', 'blueimp-canvas-to-blob', './client/bundle.ts', ], }, output: { path: `${__dirname}/../public/${publicDir}`, publicPath, filename: production ? '[name]-[chunkhash].js' : '[name].js', chunkFilename: production ? '[name]-[chunkhash].chunk.js' : '[name].chunk.js', }, resolve: { modules: [ `${__dirname}/../client`, `node_modules`, ], extensions: [ '.js', '.jsx', '.ts', '.tsx', ], }, node: { __filename: true, __dirname: true, }, plugins: [ // must match config.webpack.manifest_filename new StatsPlugin('manifest.json', { // We only need assetsByChunkName chunkModules: false, source: false, chunks: false, modules: false, assets: true }), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify(process.env.NODE_ENV), }, }), new typescriptLoader.CheckerPlugin(), ], module: { rules: [ { test: /\.(?:ts|tsx)$/, exclude: /node_modules/, use: { loader: "awesome-typescript-loader", options: { configFileName: 'tsconfig.webpack.json', useBabel: false, silent: true, useCache: true, } }, }, { test: /\.css$/, use: [ "style-loader", "css-loader", ], }, ] }, }; if (production) { config.plugins.push( new webpack.optimize.UglifyJsPlugin({ sourceMap: true, mangle: true, compress: { warnings: true, drop_console: true, }, }), new webpack.optimize.ModuleConcatenationPlugin(), new class CleanupAssets { apply(compiler) { compiler.plugin('done', (stats) => { const statsJson = stats.toJson(); const assets = Array.prototype.concat.apply([], statsJson.chunks.map((chunk) => chunk.files)); const assetPatterns = Array.prototype.concat.apply([], statsJson.chunks.map((chunk) => { return chunk.files.map((file) => { const pattern = file.replace(chunk.hash, '[a-fA-F0-9]+'); return new RegExp(`^${pattern}\$`); }); })); const files = fs.readdirSync(`${__dirname}/../public/${publicDir}`); files.filter((file) => assetPatterns.some((pattern) => pattern.test(file))) .filter((file) => !assets.includes(path.basename(file))) .forEach((assetToRemove) => { console.log("Removing %s", assetToRemove); fs.unlinkSync(`${__dirname}/../public/${publicDir}/${assetToRemove}`); }); process.exit(); }); } } ); config.devtool = 'source-map'; } else { config.devServer = { port: devServerPort, headers: { 'Access-Control-Allow-Origin': '*' }, stats: 'minimal', disableHostCheck: true, }; config.output.publicPath = `//localhost:${devServerPort}/${publicDir}/`; config.devtool = 'cheap-source-map'; } module.exports = config;
CleanupAssetsが独自ですね。これは本番サーバの public/assets
にゴミがたまるので掃除するコードです。blue-green deploymentするなら不要なはずなのでいずれ消すことになるでしょう。
見ての通りbabelは使っておらず、polyfillとしては core-js/shim
を直接バンドルして使っています。
またcode splittingを有効にしているのですが、webpackはdynamic importに対するマジックコメント*5でcode splittingの挙動を制御できます。
たとえば、以下のマジックコメントでchunk name (分割したコードのファイル名)を変えられます。
const foo = await import(/* webpackChunkName: "foo" */ 'foo');
また、webpackMode: "eager"
によりdynamic importを使いながらcode splittingを抑制することもできます。
const foo = await import(/* webpackMode: "eager" */ 'foo');
Hypernova
SSRのためのnodejs server + client codeです。いまのところ事前コンパイルはしておらず、ts-nodeでtsファイルを自動コンパイルしています。
Hypernova用の実行コードもwebpackなどで事前にコンパイルしおくほうがブラウザ環境に寄せられるのでより良いのですが、まだそれはしていません。
なお、Hypernova serverはpm2*6でプロセス管理をしています。
所感
- TypeScript設定ファイルはパスやモジュールの解決まわりでかなりハマるので厄介だった
- 単一ファイルでブラウザとnodejsをサポートするのは不可能だとわかったのでファイルを分けることにした
- SSRは慣れてくるとそれほど問題なくUniversal JSなコードを書けるようになる
- Hypernovaはnodejs serverなのでnodejs serverの知見をそのまま使える
- SSRのときのエラーをブラウザに表示してくれる便利機能もあるのでデバッグはわりとしやすい
- ただしhypernovaの構造上Rails側でfragment cacheできなくなるというデメリットがある
- webpacker (v3.0) はちょっとみたところ重厚すぎてこのプロジェクトには合わなかった
- すでに完成されたwebpack.config.jsがあるのでこれを再利用できるツールがよい
- しかしwebpack-rails はメンテされないと明言されているのでいずれ何かに移行しなければならないのでどうしたものかというところ