module bundlerの作り方(ECMAScript Modules編)
前回の準備編では、module bundlerがどのように動いているかを説明しました。
今回は、dynamic import以外の最低限の実装を入れていきます。
変更されたコード一覧はこちら
ECMAScript Modules(ESM)について
さて、多くの人がすでに使っている以下のような構文がESMと呼ばれるものです。
import { version } from 'module'; export const a = 1;
仕様等のドキュメント
また、Node.jsでのESMの解決方法はCJSとの互換性を保つために別途異なるのでここでは扱いません。
webpackでは現在、Node.jsのscope-packageの対応中なので、もう少しお待ち下さい。
ECMAScript Modules と CommonJS Modulesの違い
ESMの特徴は、事前に静的解析を行います。 そうすると以下のようなメリットが得られます。
- ランタイムでシンタックスエラーが発生することを避けれる
- 不必要なコードを消す(dead code elimination)ことが容易に行える
- CJSのtree shaking対応はwebpackで現在進行中
ESMはトップレベルで宣言しないといけないのはそのような理由があるからです。
また、CJSは同期ですが、dynamic importは非同期です。(require.ensure
除く)
出力されるランタイムコード
先に完成したコードを見ていきます。
// entry.js import { add as addNumber } from './module1.js'; console.log(addNumber(10)); // module1.js export function add(n) { return 10 + n; }
上記のコードは以下のような出力になります。
((modules) => { const usedModules = {}; function require(moduleId) { if (usedModules[moduleId]) { return usedModules[moduleId].exports; } const module = (usedModules[moduleId] = { exports: {}, }); modules[moduleId](module, module.exports, require); return module.exports; } require.__defineExports = (exports, exporters) => { Object.entries(exporters).forEach(([key, value]) => { Object.defineProperty(exports, key, { enumerable: true, get: value, }); }); }; return require(0); })({ 0: function (module, exports, require) { const __BUNDLER__1 = require(1); console.log(__BUNDLER__1['add'](10)); }, 1: function (module, exports, require) { function add(n) { return 10 + n; } require.__defineExports(exports, { add: () => add, }); }, });
今回はCJSとの互換をあまり考えないため、__esModule
をexports
にアサインはしません。
ほかのパターンを見る場合はこちら
CJSとESMの出力の違い
ESMも最終的にはCJSに合わせた状態(require
)になりますが、大きな違いが2点あります。
ESMではランタイムコード生成時に既に実行先が確定される
先程、話したとおりESMでは静的解析を行うことが前提となるため実行前にすべて確定されます。
このコードをCJSで書くと以下のような出力になります。
// CJS })({ 0: function (module, exports, require) { const { add: addNumber } = require(1); console.log(addNumber(10)); }, 1: function (module, exports, require) { function add(n) { return 10 + n; } module.exports = { add, }; }, });
CJSの場合、1
が0
に呼び出されてその時にusedModules[1].exports
にadd
が登録され、0
で使われます。 しかし、0
からすると本当に1
にadd
があるのかわからないため、実行時に落ちる可能性があります。
ESMは事前に実行するものを予約したコードに変換することによりこの問題を防ぎます。
// ESM })({ 0: function (module, exports, require) { const __BUNDLER__1 = require(1); console.log(__BUNDLER__1['add'](10)); }, 1: function (module, exports, require) { function add(n) { return 10 + n; } require.__defineExports(exports, { add: () => add, }); }, });
__BUNDLER__1['add'](10)
のように必ず実行できる形でコードが吐かれます。
もう少ししっかり書くなら、(0, __BUNDLER__1['add'])(10);
と変換したほうが良いです。 こうしないとthis
のスコープを担保できないからです。更に、.add
に変えるとコード量も減ります。(たしかwebpack@3でそっちに移行した記憶)
つまり、呼び出す側が既に呼び出される側の内部を把握している状態となり実行時にエラーを起こすのを防げるということです。
属性の付与が必要となる
新しくrequire
に__defineExports
を追加しました。
これは、exportsされるものに必ずenumerable
を付与しなければいけないためです。
IIFE内で共通で使うために以下を定義します。
require.__defineExports = (exports, exporters) => { Object.entries(exporters).forEach(([key, value]) => { Object.defineProperty(exports, key, { enumerable: true, get: value, }); }); };
そして、モジュールが初めて呼び出された時に以下のrequire.__defineExports
を実行し、usedModules[1].exports
にObject.defineProperty
経由でプロパティを追加します。
1: function (module, exports, require) { function add(n) { return 10 + n; } require.__defineExports(exports, { add: () => add, // このオブジェクトにこのファイル内のexportされるものが追加されている });
コードを書き換える
ASTを弄りたい人はastexplorerを使うと便利です。
CJSではコードの書き換えはrequire('module')
->require(1)
のようにmoduleIdのみの書き換えでしたがESMでは呼び出し元等を編集する必要があります。
ESMもCJSとモジュールのマップ作成の処理は共通なので、コードの走査処理だけがESM時に新しく追加されます。コード箇所
- CJS: modulesのリストを作る ->
require
の中身をmoduleIdに変更する - ESM: modulesのリストを作る -> ソースコードをトラバースする ->
require
の中身をmoduleIdに変更する- 二段階目で
require
に変換し、CJSと同じ形になるため三段階目はCJSと共通で動く
- 二段階目で
それでは、babelを使いASTを走査し以下のことを達成していきます。
import
をrequire
へ変換- (e.g.
import a from 'module'
->const a = require('module')
)
- (e.g.
- 外部モジュールから使っている変数、関数等をすべて置換する
- (e.g.
a(1);
->__BUNDLER__1["a"](1)
)
- (e.g.
export
をすべてマッピングし、不必要なシンタックスを消す- (e.g.
export const a = 1
->const a = 1
)
- (e.g.
importを変更する
親はImportDeclaration
となり、typeはImportDefaultSpecifier
, ImportNamespaceSpecifier
, ImportSpecifier
となります。
// ImportDefaultSpecifier import a from 'module'; // ImportNamespaceSpecifier import { a } from 'module'; import * as a from 'module'; // ImportSpecifier import { a as b } from 'module'; import { default as a } from 'module'; // CJSとESMの互換ブリッジ
ゴールはrequire
に変更し、格納先の変数名にId込の名前を付与することです。
最初にモジュールのマッピングをしIdを発行しているのでそれを変数名へ紐付けていきます。
import { a } from 'module'
-> const __BUNDLE__1 = require('module')
import
は、どのtypeでもconst a = require('b')
となるためすべて共通化できます。
exportを変更する
export
はいろいろなケース(e.g. アグリゲート)があるので、最小限の実装にしています。
親はExportDeclaration
となり、typeはExportDefaultDeclaration
とExportNamedDeclaration
となります。
// ExportDefaultDeclaration // FunctionDeclaration export default function a() {} // Identifier const a = 1; export default a; // ArrowFunctionExpression 本当はclassも追加しないとダメ export default () => {} // ExportNamedDeclaration export const a = 1; // 未実装 export { a, b }; export a from 'module';
ゴールは、exportされるもののマッピングで、名前と接続先を把握し以下のように展開します。
function add(n) { return 10 + n; } require.__defineExports(exports, { // 名前 接続先 add: () => add, });
名前を付与する
最初にexportされるものの名前を取得しなければなりませんが、状況によって取り方が異なります。
// export default function a() node.declaration.name // function a() {} // export default a; node.declaration.id && node.declaration.id.name // export const a = 1 node.declaration.declarations && node.declaration.declarations[0].id.name
これで名前が取れないときは、export default () => {}
やクラスの可能性を考慮します。
この場合は、名前をこちらが付けてあげて接続先を作ります。(e.g. key: default
, name: __default__
)
export default () => {};
-> const __default = () => {};
と書き換えて名前を付けます。
不要なコードを消す
Object.defineProperty(exports, key)
で値を展開していくため、コード内に不要なコードがあります。
export default function a() {}
->function a() {}
export default a
-> この行事自体が不要export const a = 1
->const a = 1
exportに付随したコードはランタイムでは必要なくなります。
importされたものをコード内で紐付け置換する
コード内のimportしたものを使っている箇所をすべて書き換えます。
ReferencedIdentifier
に入ってきて、かつ親のtypeがImportDeclaration
がターゲットです。
ゴールは、importされたモジュールが呼び出されている場所を__BUNDLER__{id}[function/variable name]
に変えていくことです。
// before import { foo as bar } from 'module'; console.log(bar(10)); // after const __BUNDLER__1 = require('module'); console.log(__BUNDLER_1['foo'](10));
最初に、scope hoistingを考えないといけないため以下の処理を行いスコープを固定します。
これを行うことによりimportされたものとコード内で使われているものが正しく紐付けられます。
const localName = path.node.name; const localBinding = path.scope.getBinding(localName); const { parent } = localBinding.path; // 親を確定させる
importは複数連続({a, b, ...}
)して入っているのでループし、確認していきます。
parent.specifiers.forEach(({ type, local, imported }) => {});
このループの中でそれぞれのexportされたものとコード内で使用されるものを置換してきます。
それのlocal
はコード内の情報を示し、imported
はコード外の情報を意味します。
つまり、a as b
の場合、imported
as local
となりimported
は元の情報を辿る上で重要な役割を果たします。
ImportNamespaceSpecifier: import * as foo from 'module'
一番めんどくさいです。
というのも、default
を省略できるかどうかはプラットフォームに依存しているため互換性を考えないといけないからです。(Node.js, Babel, typescript, etc..)
例えば、出力先がmodule.exports = require('./foo')
とかが代表的な例です。
ここでの最終的な展開式は以下のようになります。
const __BUNDLER_1 = require(1); console.log(__BUNDLER_1['default']); // import先のdefault console.log(__BUNDLER_1['func']()); // import先のnamed exportされたfunc console.log(__BUNDLER_1); /** * default: xxxx * func: yyyy */
しかし、これは互換性を崩しているので実際はよくなくて、以下のほうが安全です。
const __BUNDLER_1 = require(1); console.log(__BUNDLER_1['default']['default']); // import先のdefault console.log(__BUNDLER_1['default']['func']()); // import先のnamed exportされたfunc console.log(__BUNDLER_1); /** * default: { * default: xxxx, * func: yyyy * }, * func: yyyy */ // esModuleの場合はgetDefaultを使うを判別する関数をIIFEに作成する const getDefaultExport = function (module) { const getter = module && module.__esModule ? function getDefault() { return module['default']; } : function getModuleExports() { return module; }; Object.defineProperty(getter, 'esm', { enumerable: true, get: getter }); return getter; };
このサンプルではdefault
を1階層挟まずにやっているためObjectのkeyが直で名前となります。
ImportDefaultSpecifier: import foo from 'module'
defaultは必ず一つしか無いため、nameはdefault
となります。
ImportSpecifier: import { foo } from 'module'
普通のnamed importであれば、nameはimport先と同じです。
問題は、リネームされている場合です。この場合は、imported.name
を見る必要があります。
そして、ローカルのリネームされたものは全部置き換えます。
// import { foo as bar } from 'module'; const __BUNDLER__1 = require('module'); console.log(__BUNDLER_1['foo'](10)); // <--- barはもういらないのでfooに戻してあげる
置換を行う
それぞれの状態でもnameを取れたので最後に置換を行います。
const assignment = t.identifier(`${prefix}${moduleId}${name ? `[${JSON.stringify(name)}]` : ''}`); // ImportNamespaceSpecifierは今回defaultを省いたので以下 // replace `['foo'].foo` with `['foo']` path.parentPath.replaceWith(assignment); // その他 path.replaceWith(assignment);
ここでのnodeの区間は以下の箇所です。
// parent node: | ここ | // current node: | この区間 | ここはとなり | foo() __BUNDLER_1['foo']()
なぜImportNamespaceSpecifier
ではparentPath.replaceWith
を使っているかというと()
部分も修正したかったからです。
これでコード置換が行えたので、最初に出したランタイムコードが作成されます。
さいごに
以上で、module bundlerでESMの対応をする方法の紹介は終わりです。
すべてのコードはこのPRを見るとわかります。
本当はここでtree shakingもやろうと思ったんだけど、予想以上に量が多くなってしまったので次回やります。
もし何か聞きたいことあったら、Twitterまで。
module bundlerの作り方(準備編)
今回は中身がどう動いているかを解説したいと思います。
最初のこの記事では、最低限の実装を説明していくことにします。
webpackのアルゴリズムの仕組みはこちらを読んでください。
必要なステップ
必要なステップは以下の3つです。
- エントリーポイントからのすべてのモジュールを走査し、
require
を解決後にユニークidを付与していく - コード内のモジュールパス(
require
の引数(e.g../module.js
))をidへ置換する - runtime のコードテンプレートの作成
- IIFE(即時関数)箇所とそれに付随する引数のmodule群
この実装されあれば、動くコードはできます。(2つめはoptionalでもいいけど後からつらくなる)
モジュール解決
今回は説明しやすいように関数を2つに分けています。
- すべてのモジュールの把握とID作成
- コード内のrequire部分をIDを置換
1. 使われるモジュールをすべて列挙し、ユニークIDを添付させる
ここでのゴールは、モジュールのコードをすべてASTにしそれぞれのモジュールにユニークなIDを付与していくことです。
今回は、webpackでも使うJSのパーサーであるacornを使っても良いのですが、codegenがほしいのでbabelを使って行います。(もちろん、acornとescodegenを使っても良い)
@babel/parser
でコードをASTに落とし、@babel/traverse
でトラバースをし、@babel/generator
でASTからコードを生成します。
以下の処理を再帰させることにより、使用されるモジュールを列挙します。
ファイル名を取得する
require
の declarations type は CallExpression
で callee
のtypeは Identifier
となります。
const parser = require('@babel/parser'); const { default: traverse } = require('@babel/traverse'); const { default: generate } = require('@babel/generator'); const basePath = dirname(entryPath); const ast = parser.parse(await promises.readFile(entryPath, 'utf8')); traverse(ast, { CallExpression({ node: { callee, arguments: args } }) { if (callee.type === 'Identifier' && callee.name === 'require') { const filePath = getScriptFilePath(basePath, args[0].value); } }, });
つまり、このように構文解析を行えば、呼び出されるモジュールファイルがわかります。
そして、CJSでは拡張子が省略可能であり、index.js
も.
で省略可能なので、以下の処理が必要です。
function getFilename(filename) { // index.js === . if (filename === '.') { return 'index.js'; } // omit .js if (extname(filename) === '') { return `${filename}.js`; } return filename; }
node_modulesのファイルを読み込む
注意点は以下の通りです。
- node_modulesも
xxx/yyy
の形式でも動く - mainフィールドがない場合は、
'./index.js'
を参照する - node_modules内のモジュールの子供は
./
等でアクセスするため少し処理が複雑になる- 再帰処理を行う時に
xxx
が来たらベースのパスをリセットしrootの値にする - そうでない場合は前回のベースのパスを引き継ぐために引数に渡す
- 再帰処理を行う時に
また、この処理は以下の点がめんどくさいので一旦不完全として進めて行きたいと思います。
function getScriptFilePath(basePath, filename) { if (isNodeModule(filename)) { return join(basePath, getFilename(filename)); } // node_modules const moduleBasePath = join(basePath, 'node_modules', filename); // e.g. require('a/b') // need to split by / if (filename.includes('/')) { const dir = dirname(moduleBasePath); const name = basename(moduleBasePath); return join(dir, getFilename(name)); } // TODO: add module, browser, exports const { main } = require(join(moduleBasePath, 'package.json')); // when main field is undefined, index.js will be an entry point return join(moduleBasePath, getFilename(main)); }
使われるモジュールのマップを作成する
これでバンドラーからモジュールへのファイルアクセスが行えたので、そのモジュールの中身を取得しASTに変換し、保持します。ここでは以下の情報を整理します。
- id => moduleに振られたユニークid
- path => 絶対パス、
require('./module1')
の./module1
での検索は困難で書き方が多く一意でないため絶対パスが検索キー - ast => 後から再度使うのでASTの形で保持しておく
const modulesMap = new Set(); // エントリーポイントは0番 modulesMap.add({ id: 0, path: entryPath, ast: entryCodeAst, });
ここで最初にモジュールをすべて把握するのかというと、あとからすべてのコードのrequire
にモジュールのIDを振っていくため一度すべてのモジュールの対応表を作らなければなりません。
filenameをidにしない理由
- メリット
- どのファイルが読まれているかわかりやすい
require
の引数を置換しなくてもいいので、実装が楽- 最終的に
require
を実行して走査するので、idの対応付けが必要でファイル名をidにすると単純なコードでは動く
- 最終的に
- デメリット
なので、モジュールそれぞれに数値idをふることにより上記の問題を解決するのが一般的です。
モジュールをキャッシュする
これを行うことにより、すでに読み込まれたモジュールの追加を防ぎ、Circular Dependency防ぎます。
以下の場合の対応を行わないと再帰が終わらずに無限にループします。
Circular Dependencyの例
// entry.js module.exports = 'from entry'; const a = require('./module1'); console.log('main:', a);
// module1.js const a = require('./entry'); console.log('module1:', a); module.exports = 'from module1';
以下のように現在走査しているファイルの絶対パスを使って確認を行います。
const hasAlreadyModule = Array.from(modulesMap).some(({ path }) => path === filePath);
無ければ、追加し、そのモジュールはキャッシュされていないのでそのモジュールの依存を辿るために再帰を行います。
if (!hasAlreadyModule) { try { // node_modulesだったら現在のベースパスをリセット const nextDir = isNodeModule(args[0].value) ? entryDir : dirname(filePath); const code = readFileSync(filePath, 'utf-8'); const ast = parser.parse(code); modulesMap.add({ id: modulesMap.size, // これで自動的にIDがインクリメントされていく ast, path: filePath, }); walkDeps(ast, nextDir); // まだ見てないモジュールの中身を見に行く } catch (e) { console.warn('could not find the module:', e.message); } }
全体コード
async function buildModulesMap(entryDir, entryFilename) { const modulesMap = new Set(); const entryPath = getScriptFilePath(entryDir, `./${entryFilename}`); const entryCodeAst = parser.parse(await promises.readFile(entryPath, 'utf8')); // add an entry point modulesMap.add({ id: 0, path: entryPath, // an absolute path ast: entryCodeAst, }); // start from the entry-point to check all deps walkDeps(entryCodeAst, entryDir); function walkDeps(ast, currentDir) { traverse(ast, { CallExpression({ node: { callee, arguments: args } }) { if (callee.type === 'Identifier' && callee.name === 'require') { const filePath = getScriptFilePath(currentDir, args[0].value); const hasAlreadyModule = Array.from(modulesMap).some(({ path }) => path === filePath); if (!hasAlreadyModule) { try { // reset the current directory when node_modules // ./ has 2 types which are local of the first party and local of the third party module const nextDir = isNodeModule(args[0].value) ? entryDir : dirname(filePath); const ast = parser.parse(readFileSync(filePath, 'utf-8')); modulesMap.add({ id: modulesMap.size, ast, path: filePath, }); walkDeps(ast, nextDir); } catch (e) { console.warn('could not find the module:', e.message); } } } }, }); } return modulesMap; }
2. すべてのコードのrequire
をidに置換する
ここでのゴールは、先程生成したmodulesMap
のコードのrequire
の中身をすべてidを書き換えることです。
先程作成した以下の情報を使っていきます。
const modulesMap = new Set(); modulesMap.add({ id: 0, path: entryPath, // 絶対パス ast: entryCodeAst, });
それをすべて回しつつ、ASTがすでにあるので再度トラバースを行い自身(modulesMap
)の中に入っている他のモジュールを探しそのIDをrequire
部分を上書きします。
e.g. ./module.js
=== 1
(id) => require('./module')
===>require(1)
for (const { id, ast, path } of modulesMap.values()) { traverse(ast, { CallExpression({ node: { callee, arguments: args } }) { if (callee.type === 'Identifier' && callee.name === 'require') { const filePath = getScriptFilePath( // node_modulesのときはプロジェクトのベースパスではなく、そのモジュールのpathをベースにする isNodeModule(args[0].value) ? dirname(path) : basePath, args[0].value ); const { id: moduleID } = // ここでrequireの中身のファイルIDを手に入れる Array.from(modulesMap.values()).find(({ path }) => path === filePath) || {}; // requireの引数の中身を変更する args[0].value = moduleID; // './xxxx' => 0 等の数字(moduleID)へ置換する } }, }); }
最終的な展開式は、各モジュールのidとcodeだけあればいいので、以下のように管理します。
path
はなくてもいいですが、bundleされたファイルにコメントでファイル名書いてあげるとわかりやすいので入れておいたほうがいいです。
const modules = new Map(); modules.set(id, { path, code: moduleTemplate(generate(ast).code), });
実行で読み込まれるファイルのすべての依存を解決し、一意なモジュールのidに置換が行えました。
全体コード
function convertToModuleId(basePath, modulesMap) { const modules = new Map(); for (const { id, ast, path } of modulesMap.values()) { traverse(ast, { CallExpression({ node: { callee, arguments: args } }) { if (callee.type === 'Identifier' && callee.name === 'require') { const filePath = getScriptFilePath( // don't reset the path when node_modules // because the path during searching in node_modules is the base path of modulesMap isNodeModule(args[0].value) ? dirname(path) : basePath, args[0].value ); const { id: moduleId } = Array.from(modulesMap.values()).find(({ path }) => path === filePath) || {}; args[0].value = moduleId; } }, }); modules.set(id, { path, code: moduleTemplate(generate(ast).code), }); } return modules; }
ランタイムのコード作成
最後に二種類の実行コードの作成を行います。
- モジュールテンプレート
- 本体テンプレート(IIFE)
モジュールテンプレート
モジュールは以下のように展開されます。
// before function m(txt) { console.log('module', txt); } module.exports = m;
// after { [id]: function (module, exports, require) { function m(txt) { console.log('module', txt); } module.exports = m; } }
このように引数のmodule
, exports
, require
を持った関数に囲います。
これは後ほど、本体の引数として使われます。
本体テンプレート
上記で作成したモジュールテンプレートをvalueとして保持し、keyをそのモジュールのidとしたオブジェクトをIIFEの引数に渡します。
((modules) => { const usedModules = {}; function require(moduleId) { if (usedModules[moduleId]) { return usedModules[moduleId].exports; } const module = (usedModules[moduleId] = { exports: {}, }); modules[moduleId](module, module.exports, require); return module.exports; } return require(0); // この0はエントリーポイントの0 })({ // ここからは上のテンプレート 0: function (module, exports, require) { const m = require(1); m('from entry.js'); }, 1: function (module, exports, require) { function m(txt) { console.log('module', txt); } module.exports = m; }, // ここまで });
これでモジュール解決も行えて、1ファイルで実行できる形となりました。
これが最低限のベースコードとなります。
処理フロー
1 引数に設定しているモジュール群が本体コードに渡りすべての実行が始まる
((modules) => {
2 最初に return require(0);
が実行される
0(entry pointのmoduleId)はすでにこのコードを生成する時にセットしておく
((modules) => { ... return require(0); })({...});
3 require(0)
を実行する
ここの処理は再帰的に行われるため以下共通
const usedModules = {}; function require(moduleId) { if (usedModules[moduleId]) { return usedModules[moduleId].exports; } const module = (usedModules[moduleId] = { exports: {}, }); modules[moduleId](module, module.exports, require); return module.exports; }
- 最初にキャッシュ用の変数(
usedModules
)を確認する(今回は最初なので空)- キャッシュ変数のキーはmoduleIDなので、もしあればその中の
exports
を返して終わり
- キャッシュ変数のキーはmoduleIDなので、もしあればその中の
--- ここからはキャッシュが無い時の処理 ----
- 次にキャッシュ変数に今の引数のmoduleIdの値を入れ初期化(次回以降のキャッシュのため)
- 出口の
exports
だけあれば全部のモジュールがつながるのでそれだけ初期化する
- 出口の
const module = (usedModules[moduleId] = { exports: {}, });
- 次に以下の処理を実行する
// nodeには、exportsとmodule.exportsがあるため、第1引数と第2引数を使いmodule.exportsに格納する // requireは走査用ラッパー modules[moduleId](module, module.exports, require);
- ここで第3引数に
require
を渡しているため再帰的に走査を行いキャッシュ変数に使ったモジュールを貯めつつ実行をしていきます
0: function (module, exports, require) { // 引数経由で来たIIFE内のrequire functionがここで実行され、1の走査が始まる // 1の中にrequireがあれば更にそれが呼ばれる を繰り返しコードを組み立てる // require functionの戻り値はキャッシュ変数に格納されたexports const m = require(1); m('from entry.js'); },
このようにエントリーポイントからスタートし、上から順に再帰的にモジュールからモジュールへ呼び出しを行い実行していきます。
bundleされたコード例
'use strict'; const { version } = require('react'); console.log(version);
これの変換後は以下のようになります。
the-sample-of-module-bundler/nm.js at master · hiroppy/the-sample-of-module-bundler · GitHub
これを見ればわかりますが、tree shaking/dead code eliminationの必要性が出てきます。
リポジトリ
すべてのコードはこのリポジトリにあります。
さいごに
次回は、tree shaking + ESMについて書こうと思います。
webpack@5で入るModule Federationについて
💁♀️ ブログが移管されたので,新しい方へ移動します。
Module Federation(以下 mfe)はwebpack@5から入る新しい仕組みの一つです。
Proposal
目的
アプリケーションを作る時に、webpackはビルド時のソースコードは使う前提で実行するので、様々な最適化を行うことができます。
もし、node_modules経由以外でライブラリを使うという場合はscriptタグやESMから取得するというのが一般的です。
しかし、この場合だと問題点が出てきます。それはライブラリの重複問題です。
取得するライブラリはすでにbundle済みなため、その中には同じライブラリが存在することが多いです。
ユーザーは重複するライブラリ(e.g. react, react-dom, etc.)の取得を行う可能性が高いため、その最適化を行うのがModule Federationです。
上記の図だと、main.jsで使っている緑のライブラリはlib-aでもlib-bでも使っていて、それを3つダウンロードするのは無駄なのでmain.jsがダウンロードしている緑にlib-a/bが依存すればネットワークの最適化ができます。
DLLPluginやexternals
でも同様のことはできますがそれらは貧弱なので今後はこちらに乗り換えることが可能なケースがあります。
この記事では内部は深く触れないため、内部アルゴリズムが知りたい人はこちら
コード: containers
この仕組みはMicro Frontendsのためだけにあるわけではありませんが、 一番機能する可能性が高いとは思います。
Micro Frontendsとはなにか?
用語
- ローカル(モジュール) => 使う側
- ビルドラインに入っている通常モジュール
- リモート(モジュール) => 使われる側
- 実行時にコンテナーから呼び出されるモジュール
- コンテナ => ローカル、リモートにそれぞれいるマネージャー
- モジュールの前段にいるもの、実際にはリモートのURLではなくリモートのコンテナURLって言ったほうが正しい
- ここでモジュールの公開をするかどうかを制御する
- コンテナ間での循環的な依存が可能で、このコンテナが上書きAPI(
__webpack_override__
)を提供する- 兄弟関係でのみ上書きは可能で、単一方向の操作
- コンテナは以下の処理を行います
- 非同期チャンクの読み込み
- そのチャンクの評価
注意
リモートモジュールは、非同期チャンクとなりチャンクロードの処理が必要なため基本的には、import()
が使われますが、require()
やrequire.ensure()
にも使用可能です。
top-levelでのimportもビルドはできますが、同期チャンクとなってしまうため実行時にはエラーとなります。
つまり、この仕組みはbundle時にまだ不明なプログラムを許容するため、実行時に初めてエラーがわかります。
そして、ローカルもリモートになることが可能なため、各チャンクは立ち位置がその場の状況によって変化します。
ローカルと決めてたチャンクにexposesを設定すればそれがリモートチャンクとなるということです。
この場合だと、左のノードがその例で、/sub
へアクセスした場合はLocalという扱いになりますが、/
へアクセスすると左のノードはリモートという扱いになります。(下のノードから見てリモート)
設定とコード
先にコードを見ていきましょう。この構成では、リモートのファイルをローカルが取るシンプルな例です。
リモート
// webpack.config.js const { ModuleFederationPlugin } = require('webpack').container; module.exports = { output: { // このリモートのファイルがローカルで展開されるときにpublicPathを書かないと親の実行時に親のURLを見てしまうので必須 // このPRで変更される: https://github.com/webpack/webpack/pull/10703 publicPath: 'http://localhost:8081/', }, plugins: [ new ModuleFederationPlugin({ name: 'page1', // エントリーの名前、exposesがある場合は必須キー library: { type: 'var', // scriptタグを経由する、他のオプションはこちら https://github.com/webpack/webpack/blob/dev-1/schemas/plugins/container/ModuleFederationPlugin.json#L155 name: 'page1' // importされるときの名前(この場合は、import('page1/xxxx')) }, filename: 'page1RemoteEntry.js', // 出力されるentryのファイル名 exposes: { Page: './src/index.js', // コンポーネント名(この場合は、import('page1/Page')) }, }), ], };
// src/index.js import React from 'react'; const Page1 = () => <h1>This is Page1</h1>; export default Page1; // React.lazyはdefault exportsのみ許容
Asset Size 579.js 7.27 KiB [emitted] 579.js.LICENSE.txt 295 bytes [emitted] main.js 7.68 KiB [emitted] [name: main] main.js.LICENSE.txt 295 bytes [compared for emit] page1RemoteEntry.js 2.09 KiB [emitted] [name: page1]
ローカル
// webpack.config.js const { ModuleFederationPlugin } = require('webpack').container; const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { plugins: [ new HtmlWebpackPlugin({ template: './index.html', }), new ModuleFederationPlugin({ remotes: { page1: 'page1', // page1をローカル側で使用することを伝える、import('page1/xxx') }, }), ], };
// src/index.js import React, { lazy, Suspense } from 'react'; import { render } from 'react-dom'; // page1/page(remote)をdynamic import const Page1 = lazy(() => import('page1/Page')); const Wrapper = () => ( <div> <Suspense fallback={<span>Loading...</span>}> <Page1 /> </Suspense> </div> ); render(<Wrapper />, document.getElementById('root'));
<!-- index.html --> <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> </head> <body> <div id="root"></div> <!-- import('page1/page'))で読み込むためにremoteのファイルを取得 --> <script src="http://localhost:8081/page1RemoteEntry.js"></script> </body> </html>
Asset Size index.html 194 bytes [compared for emit] main.js 128 KiB [emitted] [name: main] main.js.LICENSE.txt 790 bytes [compared for emit]
このように、import('<scope>/<request>')
という形でローカル側は取り込みます。
依存関係共有
shared
というオプションをつけることにより、依存関係の共有を行い、リモートはローカルの依存関係を優先的に参照します。
もしローカルに依存関係がない場合、リモートは独自にダウンロードを行います。
この仕組みにより、バンドルを跨いだ関係でも最小限にファイル量を留めることができます。
上記のコードでは、このオプションを入れる前のNetworkは以下のようになります。
579.jsというリモートのJSに注目してください。このファイルはpage1RemoteEntry.jsから呼び出されます。
この579.jsにReactのライブラリソースコードが入っています。(main.jsの中にも同様のコードが入っている)
では、ローカルもリモートもReactを使っているので、両者のwebpack.config.jsに以下を追加します。
new ModuleFederationPlugin({ ..., shared: ['react'], ]
そうすると、以下のように579.jsのサイズが3.4kbから506bまで下がりました。
これは、Reactライブラリのソースコードがなくなり、579.jsがmain.jsに含まれているReactのライブラリコードを参照しているという状態です。
この579.jsに残っているコードはconst Page1 = () => <h1>This is Page1</h1>;
のみとなります。
では、リモート側にはshared: ['react']
を付け、ローカル側をなくすとどうなるでしょう?
最初に説明したとおり、ローカル側がshared
を設定してなかった場合は、リモート側が自身のURLからReactライブラリをダウンロードするようにフォールバックが行われます。
この466.jsがReactのライブラリコードとなります。
これらはコンテナであるpage1RemoteEntry.jsが管理していて、リモートに同一のライブラリがないのでこのコンテナが466.jsをダウンロードする処理を行います。
まとめると、リモートは共有されるであろうライブラリはshared
に入れておいた方が管理が楽だと思います。 そうすると使う側がそれを許容するかどうかを管理できるためです。
Q&A
Q: URLはHTMLに書く必要あるの?
A: html-webpack-pluginの対応を待つかwebpackでcontainerのurlが引けるためそれをhtmlにわたす実装を書く
Q: 共有ライブラリのバージョンが異なる場合どうすればいいの?
A: 標準ではないが、以下のような書き方はできる。
shared: { "react@6": "react" }
Q: ハッシュ付きファイル名の場合どうすればいいの?
A: webpack間でのオーケストレーションを維持するためハッシュ付きファイル名は出力されません
Q: ローカルとリモートで共通ライブラリを管理するのだるい
A: 今後自動的に入れる仕組みが入ると思う。3rd partyではすでに存在するらしい。ただ手で書いたほうがいい気はする
Q: IDEでの補完が効きません。
A: わかります、けどこれwebpack.config.jsをIDEが理解できるようにならないと解決しない
Q: SSRでも動くか?
A: 設計上、webに限定して作ってないので動く。library.type
をvar
からcommonjs-module
に変えてみて。
さいごに
ユーザーはこのsharedさえ知っていればよくてcontainersとかは基本的には知らなくていいです。
まだ安定的なフェーズではなく実験的なのでインターフェイスの変更には注意してください。
なにか聞きたいことあれば、twitterまで。
webpackのinline syntax
誰得かわからないですが、これの質問が来たのでここで説明しようと思います。
webpackは、ローダーチェインと呼ばれる仕組みで動いています。
これには、inlineの書き方が存在しますが開発者ですら使うのは非推奨です。
しかし、webpackのログにはこの表記が多々出てくるため疑問がある人は多いと思います。
設定は以下のファイル群です。
// webpack.config.js module.exports = { mode: 'development', module: { rules: [ { test: /\.css$/i, use: [ 'style-loader', 'css-loader', ], }, { test: /\.css$/i, use: [require.resolve('./loader')], enforce: 'post', } ], }, };
// loader.js module.exports = function postLoader(res) { console.log(this.resource); return res; }
この場合は、拡張子.cssがきた場合にcss-loader -> style-loader -> ./loaderというローダーチェインが発生します。
基本
webpackのローダーの読み方は右からとなり、optionsのつなぎはqueryです。
また、inlineはwebpack.config.jsに書かれたものよりも優先度が高く上書き可能です。
以下の場合だと、css-modulesとsourceMapが上書きされ有効化されます。
ローダー間は!
でセパレートします。
import style from 'style-loader!css-loader?modules&sourceMap!./style.css';
接頭辞
webpackではprefixにつけるものによって内容が変化します。
!
import style from '!style-loader!css-loader?modules&sourceMap!./style.css';
先頭に!
をつけることにより、webpack.config.jsに書かれたローダーの設定群を無効にします。
!!
import style from '!!style-loader!css-loader?modules&sourceMap!./style.css';
先頭に!!
をつけることにより、!
に加えenforce
のpre
, post
を無効にします。
なので、この記事の例だと./loader
の呼び出しも!!
にすることにより無効化出来ます。
!!!...
これ以降はまだ予約なし。
-!
import style from '-!style-loader!css-loader?modules&sourceMap!./style.css';
先頭に-!
をつけることにより、!
に加えenforce
のpre
を無効化します。(post
のみ動く)
この記事の場合、./loader
はpost
で設定されるため呼び出しが行われます。
Match Resource
!=!
を使うことにより、リソースのmatchを行います。
import style from './style.js!=!style-loader!css-loader?modules&sourceMap!./style.css'
この場合は、cssで処理してたものを強制的にjsと認識させます。
なので、実際にはstyle.jsというファイル名は存在しません。
これは主にModule Typeを変更するときに使われます。
例えば、jsonファイルをjsに変換してなにか処理を続行する場合等。
上の例で上げたwebpack.config.jsを見てみます。
module.exports = { mode: 'development', module: { ... { test: /\.css$/i, use: [require.resolve('./loader')], enforce: 'post', } ], }, };
// loader module.exports = function postLoader(res) { console.log(this.resource); return res; }
Match Resouceは変換先の拡張子をwebpack.config.jsへ渡すため、この場合のローダーの出力は何も出ません。
さて、以下のようにキャッチするのをcssではなくjsに変更します。
module.exports = { mode: 'development', module: { ... { - test: /\.css$/i, + test: /\.js$/i, use: [require.resolve('./loader')], enforce: 'post', } ], }, };
ローダーの出力は以下のようになります。
/webpack/src/index.js /webpack/src/style.css <----- !!!!! /webpack/node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js /webpack/node_modules/css-loader/dist/runtime/api.js
でてきました。すごい不思議だとは思いますが、testの条件にマッチしていないように見えます。
importのファイル名は.css
なのにtest: /\.js$/i
のローダに入っていることとなります。
この場合は、match resouceによって変換されていることが読み取れます。
最後に
覚えなくていいです。
2019年を振り返る
2019年は人生の中で一番大きな年でした。
3月から8月までアイルランドの方に住んでいて、人生でもう一度あるかないかわからない経験をしました。
大きな経験と様々な刺激があり、日本に帰ってこれてよかったと思っています。
ブログ
今年はブログの投稿数は少なく、18記事となりました。
結構メタい記事を書いたのも今年な気がします。(普段は書かないので)
OSS
自分のリポジトリのスター数が3600近くになりました。
この年で200 -> 3400に増えたのは他ならぬユーザーさんが使ってくれたり宣伝してくれたおかげです。
ありがとうございました。
また、webpackのメインに復帰したのも今年です。
現在は、webpack@v5のリリースに向けて、調整中です。
スポンサーも始めましたのでもし応援してくれる方いたらぜひ!
登壇
アイルランドの方で、招待されて30分の登壇を行ってきました。
初海外登壇をやっとできました!
私生活
7月から10月までプリコネにめっちゃハマっていました。
本当に楽しかったですが、社会人になり私生活が忙しくなると自分の中でソシャゲに割く時間がなくなってしまいフェードアウトしてしまいました。
2020年
来年は私生活を充実させることに注力させます。
Node.jsをPolicyにより安全に実行する
この記事は、Node.js Advent Calendar 2019の18日目です。
Policy とは?
実行コードを制御するセキュリティ機構がNode.jsに入りました。
ポリシーファイルを使い、整合性のチェックを行います。
これにより、require
したときにファイルが変化していないことを保証することが可能です。
この機能は、まだ実験中のフェーズなためフラグが必要となります。
使い方
// app.js console.log('app'); require('fs'); require('./foo.js');
// foo.js console.log('foo');
最初に各ファイルのハッシュ値を生成する必要があります。
$ printf "sha384-$(cat app.js | openssl dgst -sha384 -binary | base64)" sha384-stoY+K7ZeOkSLHmCYOOrfWcLsKFb1Niv/dkz6f9Q0UP5FQi9pRTglvtGcQ/IPyZR $ printf "sha384-$(cat foo.js | openssl dgst -sha384 -binary | base64)" sha384-1RHAoU62Cn5DMHZLSZZKEnvcml+GOtj5O4BXAUohiYtjGzo0v/iFIM7KOxRwoG7P
policy.jsonを作成し、以下のように登録します。
これは、実行ファイルすべて列挙する必要があります。(entry-point含め)
{ "resources": { "./app.js": { "integrity": "sha384-stoY+K7ZeOkSLHmCYOOrfWcLsKFb1Niv/dkz6f9Q0UP5FQi9pRTglvtGcQ/IPyZR", "dependencies": { "fs": true, "./foo.js": "./foo.js" } }, "./foo.js": { "integrity": "sha384-1RHAoU62Cn5DMHZLSZZKEnvcml+GOtj5O4BXAUohiYtjGzo0v/iFIM7KOxRwoG7P" } } }
dependencies
は booleanでも可能ですが、本番環境では推奨されません。
今回は、ビルトインモジュールのfsなのでtrue
としています。
各ファイルでrequire
するモジュールファイルは、すべて列挙する必要性があります。
Dependency Redirection(dependencies
)は、require.cache
やmodule.constructor
等のアクセスに対しては、防ぐことができません。
つまり、require
のみのチェックを行うということになります。
以下のようにpolicy.jsonを指定して実行します。
$ node --experimental-policy=policy.json app.js
まとめ
厳格に行うのであれば、コードよりハッシュ値は変わるためPolicyを使うのは有効です。
しかしこれを一つづつ行うのは大変なため、何かしらのCLIで一括で行える機構が必要です。
まだユーザーランドには自分が知る限り存在しないため今後に期待です。
SPA + SSR + PWA の作り方とセキュリティについて
一年前に以下の記事を書いて、その後放置していたら多くのライブラリのメジャーリリースで完全に動かなくなってしまったのでリニューアルしました。
以下のセクションで説明していきますが、コードを読んだほうが早いです。
- リポジトリ
- 技術スタック
- Server Side Rendering
- Single Page Application
- Progressive Web Application
- Audits
- Security
- おわり
リポジトリ
このリポジトリはこれを見れば様々な実装の動く土俵を作れるというのを目的としています。
環境構築ですら毎回忘れるので。。
なので冗長に書いてある部分も多いですが、今後も新しい必要な実装を小さく追加していく予定です。
動きを確認したい人は、cloneして手元で動かしてみてください。
技術スタック
これはあくまでもサンプルなので、sagaとapolloが混じってますが実際はどちらかで大丈夫です。
SPAのベースはredux, sagaで設計していて、apollo-stateは使わずあくまでもqueryとmutationのみです。
主要なライブラリは以下のとおりです。
deps | devDeps |
---|---|
react | typescript |
redux | webpack |
react-router | babel |
react-helmet | storybook |
redux-saga | storyshots |
styled-components | jest |
loadable-components | testing-library |
apollo-boost | nodemon |
express | prettier |
nanoid | workbox |
typescript-eslint | |
autocannon |
Server Side Rendering
注目するべき点はloadable-componentsの大幅なアルゴリズム改善だと思っています。
これにより、パフォーマンスは改善されました。
loadable-components
未だ、ReactのほうがSSRに対応していないため、引き続きreact-loadableやloadable-componentsは必要となります。
SuspenseはSSR対応進めています。
メジャーバージョンでreact-loadableと同様にwebpackを使いassetsのmapを作成するようになりました。
これにより、SSRの処理が高速化されました。
しかし、babel-pluginに依存しないと動かないため、babel-preset-typescriptをこのライブラリでは使っています。
データの送り方
SSRで取得したデータはStoreを構築し、それをクライアントサイドにHTML経由で渡します。
以下のようにdata
属性を使い、scriptタグ経由で渡すのがいいと自分も思っています。
<script nonce="xxxxx" id="initial-data" type="text/plain" data-json="${preloadedState}"></script>
このpreloadedState
はエスケープ処理が必要なので注意してください。
クライアント側の読み込み方
const initialData = JSON.parse(document.getElementById('initial-data')!.getAttribute('data-json')!); const { store } = configureStore(initialData);
ssr-sample/index.tsx at master · hiroppy/ssr-sample · GitHub
useEffect
SSRでは、componentDidMount前までしか実行されません。
つまり、hooksではuseEffect
は呼び出されずFCにはconstructorは存在しません。
一体どこに初期化処理とかあれば書けばいいのかベストプラクティスは自分はわかりません。
if (!process.env.IS_BROWSER) { dispatch(loadSagaPage(maxLength)); } else { useEffect(() => { dispatch(loadSagaPage(maxLength)); }, []); }
今はこんなふうに書いているけど気持ち悪いので辞めたい。。
レンダリングコード
// ここでassetsのmapを取得する const statsFile = resolve( __dirname, process.env.NODE_ENV !== 'production' ? '../../../../dist/client/loadable-stats.json' : '../../../../client/loadable-stats.json' ); export async function get(req: Request, res: Response) { const baseUrl = `${req.protocol}://${req.get('Host')}`; const { nonce }: { nonce: string } = res.locals; const { store, runSaga } = configureStore(); const client = createClient({ link: new SchemaLink({ schema }) }); const sheet = new ServerStyleSheet(); const context = {}; // Node.jsでは完全なurlが必要なのでstoreにわたす store.dispatch(setBaseUrl(baseUrl)); const App = () => ( <ApolloProvider client={client}> <Provider store={store}> <StaticRouter location={req.url} context={context}> {/* add `div` because of `hydrate` */} <div id="root"> <Router /> </div> </StaticRouter> </Provider> </ApolloProvider> ); try { const extractor = new ChunkExtractor({ statsFile }); // assets mapがあるのでrenderToStringを走らせる必要がなくなった const tree = extractor.collectChunks(<App />); await Promise.all([ // react-apolloの処理をキックすることにより、redux-saga, react-helmet, styled-componentsの処理を実行 getMarkupFromTree({ tree, renderFunction: renderToStaticMarkup // あくまでも処理を実行するためなので軽量なstaticMarkupで良い }), // 上記のrenderToStaticMarkupで実行され、sagaの終了を待つ runSaga() ]); const body = renderToString(tree); // ここでクライアントに渡すhtmlのレンダリングを行う // ここからはhtmlに埋め込むscriptタグの生成やstoreのデータをクライアントに渡すためのjson等を作成 const preloadedState = JSON.stringify(store.getState()); const helmetContent = Helmet.renderStatic(); const meta = ` ${helmetContent.meta.toString()} ${helmetContent.title.toString()} `.trim(); const style = sheet.getStyleTags(); const scripts = extractor.getScriptTags({ nonce }); const graphql = JSON.stringify(client.extract()); return res.send(renderFullPage({ meta, body, style, preloadedState, scripts, graphql, nonce })); } catch (e) { console.error(e); return res.status(500).send(e.message); } }
ssr-sample/renderer.tsx at master · hiroppy/ssr-sample · GitHub
Single Page Application
SPAのベースはreduxのstore(or apollo-state), routingがreact-router, 副作用の操作をredux-sagaでこのサンプルは行っています。
hooks
reactにhooksが入ったことにより、react-routerやredux、apolloのhooks対応されました。
export const Saga: React.FC = () => { const dispatch = useDispatch(); // reduxのdispatch const samples = useSelector(getSagaCode); // reduxのselector const { search } = useLocation(); // react-routerでlocationを取得 const maxLength = new URLSearchParams(search).get('max'); if (!process.env.IS_BROWSER) { dispatch(loadSagaPage(maxLength)); // actionを実行し、typeとpreloadをdispatchへ(containersが行っていたこと) } else { useEffect(() => { dispatch(loadSagaPage(maxLength)); }, []); } const like = useCallback((id: number) => { // reactのuseCallback dispatch(addLike(id)); }, []); return ( <> <Head title="saga-page" /> <p>get => get all samples</p> <p>post => add a like count</p> {samples.length !== 0 && <CodeSamplesBox samples={samples} addLike={like} />} </> ); };
ssr-sample/Saga.tsx at master · hiroppy/ssr-sample · GitHub
redux
reduxはhooksが入ったことにより大きな変更があります。
それは、presentationalとcontainerという単語が無くなりそうです。
今までのreduxは、presentationalとcontainerで責務(関心事)が別れていました。
それは自分にとってはきれいだと思っていました、presentationalでstoreからくる値はpropsを渡す感じ。
しかし、hooksが入ったことにより、dispatchをpresentationalから呼ぶことになったのでcontainerも必要ないです。
apollo
apolloは本当にきれいに書くことができるようになり満足しています。
移行記事は以下を参考にしてください。
export const GET_SAMPLES = gql` query getSamples($maxLength: Int) { samples(maxLength: $maxLength) { id name code likeCount description } } `; export const ADD_LIKE = gql` mutation addLike($id: Int) { addLike(id: $id) { id } } `; export const Apollo = () => { const dispatch = useDispatch(); const { search } = useLocation(); const maxLength = new URLSearchParams(search).get('max'); const { loading: queryLoading, error: queryError, data: queryData } = useQuery<{ // queryのhooks samples: Samples; }>(GET_SAMPLES, { variables: { maxLength: Number(maxLength) } }); const [ addLike, { loading: mutationLoading, error: mutationError, data: mutationData } ] = useMutation(ADD_LIKE, { // mutationのhooks // ここは実際、refetch行うべきじゃないけど、これサンプルなので手抜きです refetchQueries: [{ query: GET_SAMPLES, variables: { maxLength: Number(maxLength) } }] }); const like = useCallback((id: number) => { addLike({ variables: { id } }); // mutationを実行 }, []); // SPAをsagaで管理している関係上、ここでもstopだけ行わないといけない if (!process.env.IS_BROWSER) { dispatch(loadApolloPage()); } return ( <> <Head title="apollo-page" /> <p>query => get all samples</p> <p>mutation => add a like count</p> {queryLoading && <p>loading...</p>} {queryError && <p>error...</p>} {queryData && <CodeSamplesBox samples={queryData.samples} addLike={like} />} </> ); };
ssr-sample/Apollo.tsx at master · hiroppy/ssr-sample · GitHub
はぁ、きれい。。。(saga捨てたい顔)
redux-saga
sagaが行うこととして、クライアントサイドとサーバーサイドで一点異なる点があります。
それは、sagaのプロセスをサーバーサイドの場合停止させないといけなく、そうしないとクライアントにhtmlを返せません。
なので、以下のように止めるようにします。
function* loadTopPage(actions: ReturnType<typeof LoadTopPage>) { yield changePage(); yield put(loadTopPageSuccess()); if (!process.env.IS_BROWSER) { yield call(stopSaga); // ENDを呼ぶ } }
ssr-sample/pages.ts at master · hiroppy/ssr-sample · GitHub
自分がSPAで実装を行うときは、sagaを2ライン走らせます。
- 全体を管理するappProcess
- 読み込み完了、エラー(502, etc...)、どこのページでも行う処理(e.g. login, ga, etc..)
- 各ページのpageProcess
- ページ固有の処理(e.g. fetching, etc...)
export function* pagesProcess() { yield takeLatest(LOAD_APP_PROCESS, appProcess); yield takeLatest(LOAD_TOP_PAGE, loadTopPage); yield takeLatest(LOAD_SAGA_PAGE, loadSagaPage); yield takeLatest(LOAD_APOLLO_PAGE, loadApolloPage); }
これらが終わり次第、ENDを呼ぶ構築が一番いいと思います。
App Shell
PWAと少し被りますが、sagaとの話もあるのでここで。
react-routerでパスに応じたcomponentsはここに流れてきます。
つまり、このコンポーネントが上位階層で、ここでheaderだけのレンダリング(App)と共通処理(appProcess)を行います。
export const App: React.FC = ({ children }) => { const location = useLocation(); const dispatch = useDispatch(); // ここはmiddlewareで行う共通の処理(appProcess)を実行(起動させる) if (!process.env.IS_BROWSER) { dispatch(loadAppProcess()); } else { useEffect(() => { dispatch(loadAppProcess()); }, []); } // e.g. send to Google Analytics... useEffect(() => {}, [location]); // ここでSPA全体のパス変更を監視し、イベントを発火させる(e.g. GA) return ( <> <Header /> {/* ここは変わることがない(あってもredux経由でHeader内selectorによる再描画) */} <GlobalStyle /> <Container>{children}</Container> {/* childrenはreact-routerから来たコンポーネント */} </> ); };
ssr-sample/App.tsx at master · hiroppy/ssr-sample · GitHub
Progressive Web Application
Manifest
PWAのmanifestは、manifest.json
ではなくmanifest.webmanifest
というファイル名にします。
仕様
webpack-pwa-manifestを使い、生成します。
// webpack.config.js new PwaManifest({ filename: 'manifest.webmanifest', name: 'ssr-sample', short_name: 'ssr-sample', theme_color: '#3498db', description: 'introducing SPA and SSR', background_color: '#f5f5f5', crossorigin: 'use-credentials', icons: [ { src: resolve('./assets/avatar.png'), sizes: [96, 128, 192, 256, 384, 512] } ] })
ssr-sample/webpack.client.prod.config.js at master · hiroppy/ssr-sample · GitHub
add to home screen等はデバッグしづらいので、もし原因がわからなかったらchrome://flags/#bypass-app-banner-engagement-checks
をオンにすると幸せになります。
PCでのadd to home screenはこんな感じになります。
Service Worker
Workboxを使います。
// webpack.config.js new GenerateSW({ clientsClaim: true, skipWaiting: true, include: [/\.js$/], // 今回出力がjsしかないため runtimeCaching: [ { urlPattern: new RegExp('.'), // start_urlに合わせる handler: 'StaleWhileRevalidate' // cacheを使い裏でfetchする }, { urlPattern: new RegExp('api|graphql'), handler: 'NetworkFirst' // ネットワークアクセスを優先する }, { urlPattern: new RegExp('https://fonts.googleapis.com|https://fonts.gstatic.com'), handler: 'CacheFirst' // cacheを優先する。expire設定したほうがいい } ] })
ssr-sample/webpack.client.prod.config.js at master · hiroppy/ssr-sample · GitHub
後ほど、説明しますが、CSPには注意してください。
service-workerからのアクセスはconnect
となります。
Audits
Expressのhttp/2対応がマージされれば全部100になります。(or Nginx置いて)
Security
Content Security Policy
CSPとは、XSSを防ぐために信頼したものしかブラウザが実行しないように制御できます。
サーバーで毎回ハッシュ値を生成し、それをscriptタグにつけhttp headerからContent-Security-Policy
の属性を照会し一致するものだけを実行します。
これは、script以外にもcssやfont, images, connect等幅広く設定できます。
今回のサンプルでは、google fontを使うためgoogle fontとreadmeのバッジで使われるshieldsを許可しています。
// google font: https://stackoverflow.com/a/34576000/7014700 const baseDirectives: helmet.IHelmetContentSecurityPolicyDirectives = { defaultSrc: ["'self'"], styleSrc: ["'unsafe-inline'", 'fonts.googleapis.com'], // for styled-components fontSrc: ["'self'", 'data: fonts.gstatic.com'], imgSrc: ["'self'", 'img.shields.io'], // for README connectSrc: ["'self'", 'img.shields.io', 'fonts.googleapis.com', 'fonts.gstatic.com'], // for service-worker workerSrc: ["'self'"] };
ssr-sample/csp.ts at master · hiroppy/ssr-sample · GitHub
このように指定したところへのアクセスだけを許可することよりXSSに対して強固なwebアプリケーションが作成できます。
このアプリケーションでは、nonce
方式を説明していきます。
export function generateNonceId(req: Request, res: Response, next: NextFunction) { res.locals.nonce = Buffer.from(nanoid(32)).toString('base64'); next(); }
<meta property="csp-nonce" content="ZmZIaTAyQ25rZ2FxVERUVVI4VUhBTUVJc29uTV9leUo="> <script async data-chunk="components-pages-Top" src="/public/vendors~components-pages-Apollo~components-pages-NotFound~components-pages-Saga~components-pages-Top.bundle.js" nonce="ZmZIaTAyQ25rZ2FxVERUVVI4VUhBTUVJc29uTV9leUo="></script>
もっと詳しく知りたい方はPixivの記事が詳しいので読むと良さそうです。
Dynamic Import
CSPの問題点として、dynamic importの対応の難しさが上げられます。
dynamic importの場合、nonce
が存在しないためです。
CSPには、level3とlevel2が存在し、level3にはstrict-dynamicという仕組みがありそれがこの問題を解決します。
strict-dynamicでは、nonce付きの実行されたスクリプトの子供はnonceが無くても実行可能となります。
FirefoxやChromeではすでにlevel3が対応済みなのでこの問題は解決できますが、level3に対応していないブラウザに対してdynamic importは解決が行なえません。
__webpack_nonce__
を使えば、動きますがnonceは本来毎アクセス時にhashを生成しないと攻撃者に推測される可能性があるため、ビルド時ではいけなく根本的な解決ではありません。
// chrome, firefox const lv3Directives: helmet.IHelmetContentSecurityPolicyDirectives = { ...baseDirectives, scriptSrc: [(req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'", "'unsafe-eval'"] }; // safari const lv2Directives: helmet.IHelmetContentSecurityPolicyDirectives = { ...baseDirectives, scriptSrc: [ "'self", (req, res) => `'nonce-${res.locals.nonce}'`, "'unsafe-eval'", "'unsafe-inline'" ] };
Service Worker
service workerからの問い合わせはconnect-src
となります。
以下のように、styleSrc
やfontSrc
と同じurlがconnectSrc
に書いてあるのがわかります。
const baseDirectives: helmet.IHelmetContentSecurityPolicyDirectives = { defaultSrc: ["'self'"], styleSrc: ["'unsafe-inline'", 'fonts.googleapis.com'], // for styled-components fontSrc: ["'self'", 'data: fonts.gstatic.com'], imgSrc: ["'self'", 'img.shields.io'], // for README connectSrc: ["'self'", 'img.shields.io', 'fonts.googleapis.com', 'fonts.gstatic.com'], // for service-worker workerSrc: ["'self'"] };
GraphQL
GraphQLはスキーマが自由であるため、サーバーへの負荷対策を行う必要があります。
例えば、入れ子の深い不正クエリーが送られてきたときにはサーバー側の処理に負荷がかかる可能性があるため、その処理に到達させる前に弾く必要があります。
DoSを防ぐためにも必ず入れる対策がこの対策です。
有名な方法は以下のとおりです。
- ホワイトリスト
- リストに書かれたクエリのみを通過させる
- 深さ制限
- 指定したクエリの深さ以下を通過させる
- 重み(コスト)制限
- クエリーに重さ(深さ含む)付けをし、それの合計値が指定値以下の場合は通過させる
このサンプルでは、重み制限を使用しています。
例えば、今回は使ってないですがgraphql-validation-complexityであれば以下の計算式となります。(実際、fragments等でもう少し難しくなりますが)
// Conclusion // Field: 1 // root: scalarCost * 1 // not root: objectCost * 1 // list: listFactor * 10 // query { // a { # * objectCost // a1a # * scalarCost // a1b { # * objectCost // b1a # * scalerCost // b1b # * scalerCost // } // } // arr { # * objectCost // arr1 { # * objectCost * listFactor // name # listFactor // } // arr2 { # objectCost * listFactor // name # listFactor // id # listFactor // } // } // } // a * objectCost + a.a1a * scalarCost + a.a1b * objectCost + a.a1b.b1a * scalerCost + a.a1b.b1b * scalerCost // + arr * objectCost + arr.arr1 * objectCost * listFactor + arr.arr1.name * listFactor // + arr.arr2 * objectCost * listFactor + arr.arr2.name * listFactor + arr.arr2.id * listFactor
今回は、graphql-query-complexityを使っています。
const apollo = new ApolloServer({ plugins: [ { requestDidStart: () => ({ didResolveOperation({ request, document }) { const complexity = getComplexity({ schema, query: request.operationName ? separateOperations(document)[request.operationName] : document, variables: request.variables, estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })] }); // graphqlのスキーマのコストがlimitCost(今回は10)以上であれば、throwし中断させる if (complexity >= limitCost) { throw new Error(`${complexity} is over ${limitCost}`); } console.log('Used query complexity points:', complexity); }, didEncounterErrors(err) { console.error(err); } }) } ] });
ssr-sample/apollo.ts at master · hiroppy/ssr-sample · GitHub
GraphQLはプロダクションでリリースするときには必ず、このような対策が必要となります。
おわり
長文になりましたが、機能追加等のPR/Issue歓迎しています。
また、もし興味あればGitHub Sponsorsもよろしくおねがいします。
あと、webpack@5楽しみ🥰
webpack5の大きな変更
— hiroppy (@about_hiroppy) November 19, 2019
- Node.jsのpolyfillが自動で入らなくなる
- Tree Shakingのアルゴリズム改善
- ビルトイン出力ファイルのバージョンを指定するoutput.ecmaVersionが追加
- 永続キャッシュによる開発効率化
- webpackChunkName の自動化
- file-loaderがビルトイン
- top-level-await