前回からだいぶ時間が空いてしまった。 何もやっていなかったわけではなくて、プラグインの機構をどう作るか考えていました。
さて、今回のお品書きですが、下記のPRになります。
ほぼリファクタです!!!!!!そして一部デグレッたことが記事を書いているときにわかりました(ビルドには問題はない)。
今回、"プラグイン"を導入するためだけに修正量をみるだけでも相当な変更を入れました。 まずはそのモチベーションを箇条書きで書き出して置きます。
meta
タグのプロパティをoverrideしたかった2,3,4は1のついででしかなかったw
とにかくやりたかったのは、Twitterにサイトのリンクを貼ったときにog:title
とかが反映されること。
これを実現するためには、ページごとにのタイトルをmetaタグに記入する必要がありました。
やろうと思えばできなくもなくって、
---
title: ページタイトル
og:title: ページタイトル
---
という感じで拡張できなくもないのですが、2度書くのはDRYの精神に反するので却下だし、これまで書いてきた記事に対しても同じ作業をやるのは苦痛以外の何物でもない。
このような邪念理由でプラグイン機構を導入しようと決断したのです。
まぁ、他にもいろいろとメリットはあるのですが、こうやって記事を書いている以上はSEO部分もちゃんとサポートしておくべきでしょう、 ということで優先度を上げて対応をしました。
これらが今回のモチベーションです。(まとまってないけど、まとめる気もない)
さっき書いてしまいましたが、EventEmitter形式で構築しました。
Gatsbyのコードを見てみると、mittというEventEmitterライブラリを用いてプラグイン機構を実装しています。
custom-site
ではどうやってこれを導入するか、を考えたときに問題が生じました。
それは、アプリケーション内のStateをどう管理するか、というところです。
プラグインによって改変されても良いStateと、されてはいけないStateが存在しています。
これらを別々にStore管理することで責務を分離して行くことで保守性を上げていくようにしました。
StoreはStateを管理するためのものです。functionベースでもclassベースでも実装できますが、今回はclassベースで実装しました。 (Storeの階層構造を保つ場合はclassの方が取り扱いやすいだろうと思い)
実際のコードは次の通り
interface State {
[id: string]: any;
}
export class Store<U extends {}> {
private state: State = {};
public set<T extends keyof U>(params: { type: T; id: string; state: U[T] }): void {
this.state[`${params.type}/${params.id}`] = params.state;
}
public get<T extends keyof U, S>(params: { type: T; id: string }, defaultState?: S): S extends U[T] ? U[T] : (U[T] | undefined) {
return this.state[`${params.type}/${params.id}`] || defaultState;
}
}
Storeは拡張できるようにGenricsを使っていろいろと仕込みました。
プラグインのStateは次のようになっていて、これをGeneritcsにぶちこんでやればset
/get
のtype
、state
が同時にきまる。
idはどうしても同一のStateを扱いたいが、同じ(内部Stateの)キー名で保存したくない場合に利用する。
(ここは本当はデフォルト値などを指定して、必要なときにだけ明示的に指定するような形にしたい)
export interface State {
GENERATE_META_DATA: { metaData: HtmlMetaData };
}
また、get
時は第2引数にdefaultState
を用意した。これは地味に便利で、取り出し時にundefined
にならないようにするために作った。
プラグインのStoreの場合はこれが特に有効で、次のように記述できる。
const newState = pluginStore.get({ type: "GENERATE_META_DATA", id: "" }, oldState); // State
プラグインはその特質上、「あってもなくてもいい存在」なので、改変の対象となるStateはプラグインの前後に必ずいるはずである。 そのコンテキストがあるにもかかわらず、初期値を指定できないでいると、次のようなパターンを量産することになる。
let newState = pluginStore.get({ type: "GENERATE_META_DATA", id: "" }); // State | undefined
if (!newState) {
newState = oldState
}
Swiftのようなifの条件文に代入ができるような書き方ができたら良かったのになぁ、とこの度に思う。
(instanceof
を使った書き方っぽい事はできるけど、そういうパターンでは今回はないので)
mittの実装を拡張して書き直しました。 特徴は、プラグイン用のStoreを注入して、emit時に内部改変を許容しことです。
const createPluginEventEmitter = (store: PluginStore) => {
const handlers: EventHandlerMap = {};
return {
on<K extends keyof EventHandlerMap>(event: K, handler?: CreateHandler<K>): void {
if (handler) {
((handlers[event] || (handlers[event] = [])) as Array<CreateHandler<K>>).push(handler);
}
},
emit<K extends keyof EventHandlerMap>(event: K, state: State[K] & { id: string }): void {
let newState: State[K] = store.get({ type: event, id: state.id }, state);
((handlers[event] || (handlers[event] = [])) as Array<CreateHandler<K>>).forEach(handler => {
// Stateのoverrideを許容する
newState = handler(newState);
});
store.set({ type: event, id: state.id, state: newState });
},
};
};
初期化時にStoreを渡しておくことで、それ移行は通常のEventEmitterと同じように利用することができます。 プラグインによって改変されたStateを取り出すときは、プラグイン管轄のStoreからgetすれば良いだけになります。
今回だと、metaタグの書き換え箇所が例となります。
const createHead = (page: PageState) => {
const id = `GENERATE_META_DATA/${page.uri}`;
const state = { metaData: page.metaData, id };
pluginEventEmitter.emit("GENERATE_META_DATA", state);
const metaData = plugin.get({ type: "GENERATE_META_DATA", id }, state).metaData;
return createHeadContent(metaData);
};
プラグイン自体はなくても良いものなので、もしこのプラグイン機構自体が不要になった場合は
const createHead = (page: PageState) => {
return createHeadContent(page.metaData);
とするだけ良いでしょう(あくまでも上記は例)。 プラグインを導入する箇所の前後には、改変対象のstateが必ずいるので、 デフォルト値を利用する価値がここにあることがわかると思います。
プラグインは外部からimportしてくるので探索して上げる必要があります。
Nodeの場合はnpm i
でパッケージをインストールして、パッケージ名をプラグインの設定箇所に書くだけ、というのが
利用者にとって体験は良いでしょう。
また、ちょこっとしたプラグインを開発したいときに相対パスで自前で書けるのも嬉しいと思います。
node_modules
以下のパッケージを探索する場合に参考になるライブラリは
があります。require("module")._resolveFilename
を探索を任せていて、
見ていてアハ体験しました。
自分で書かなくていいのはとても良いことです...ただ、型定義ファイルは_resolveFilename
が書かれていないので、
型を潰しての利用になってしまいますが。
書き出すとキリがなくて、あれもこれもとなってしまうので"実装を見て!"が本当のところ。 あとは記事を書くのはいいんだけど、使い方のドキュメントをはよかけよ、というのが心の中から聞こえているので、 そちらもぼちぼち書きたいなという気持ちです。
読んだ資料とかリンクは残しておくので、自分で作るときの参考にしてくださいな。 (WEBフレームワークのミドルウェアや、プラグインの実装も結構有用なので好きなやつ読んだほうが理解がはかどります)
いろいろなライブラリのプラグイン機構に目を通すと実装の幅が広がります。