mdx-jsの実装を解読するまでの話
この記事は 第2のドワンゴ Advent Calendar 2018 の12日目の記事です。
普段はReactを使ってフロントエンド開発のお仕事をしています。
※ お仕事とは関係ないお話です
話のはじまり
- フロントエンド周りのコードを自分のブログとかで動かしてコレクション化したいなぁ
- でも管理するの面倒だなぁ
- そのあたりをうまく管理する静的サイトジェネレーターないかなぁ(StaticGenを探した)
1つの解としては外部サービス(CodeSandboxとかcodepen、jsfiddle)を埋め込んで利用する、に行き着くんですよね。
でも、やっぱり自分の管理下でやりたい、という支配欲(?)があって長続きしません。
「じゃあ作るか → どうやって?」となるんですけど、ゼロベースだとなかなか大変なので、GitHubをググってみて(?)mdx-js/mdxにたどり着きました。
こやつ、何をやっているかというと、Markdown中にJSXを利用できるようにしよう、というもの。 着想が面白くて、コードも興味深い箇所があったので今回はそれを紹介します。
mdx-jsのざっくりした流れ
※ すごいざっくり説明するので、詳しくはmdx-js/mdxを見てください。
1. Markdown中にJSXを書く
# ヘッダータイトル
<ExampleComponent>ここがReactによって定義されたコンポーネントに変わる</ExampleComponent>
2. ExampleComponent
に定義したコンポーネントをあてる
import * as React from "react";
export interface ExampleComponentProps {
x: number;
y: number;
}
export class ExampleComponent extends React.Component<ExampleComponentProps, {}> {
public render() {
return (
<div className="my-component">
{this.props.children} {this.props.x * this.props.y}
</div>
);
}
}
3. mdx-jsを使ってビルドする
<h1>ヘッダータイトル</h1>
<div className="my-component">ここがReactによって定義されたコンポーネントに変わる</div>
どうやって処理するの?
最初に見たときは「JSXの変換の部分」の処理をすぐに想像できませんでした。
package.json
をいろいろ見ると、Markdown自体をパースするのはunifiedjs/unifiedあたり。JSXを変換するものは@babel/transform-react-jsxとあたり、とすぐに暴けるのですが、コンポーネントのマッピングをどうやっているのか、なかなか見えてきません。
紐解いてみる
答えは@mdx-js/runtimeのコードにありました。
v0.16.0以降のバージョンではライブラリ固有のものになりつつあるので、リンクはバージョンを固定しています。また、説明用に使うにはノイズが多いので、処理を抽出したサンプルコードを作成しました。
本題のロジックは
にあります。20行にも満たないコードです。
サンプルコードの使い方
READMEのサンプルコードにコメントをつけて説明します。
import { convert } from "convert-text-to-react";
export interface ExampleComponentProps {
x: number;
y: number;
}
// 適当なコンポーネントを用意する
export class ExampleComponent extends React.Component<ExampleComponentProps, {}> {
public render() {
return (
<div className="my-component">
{this.props.children} {this.props.x * this.props.y}
</div>
);
}
}
// 変換のマッピング
const components = {
ExampleComponent,
}
// テキストを変換するを登録した変換マップにしたがって変換する
const result = convert("<ExampleComponent x={1} y={-20}>Result of multiplication:</ExampleComponent>", components);
// ReactElementを得る
ざーっくりとこんな感じです。
プレーンテキストにReactのコンポーネントを適用させる処理
中身の処理を見てみます。コメントを付けながら解説すると、
import * as babel from "@babel/core";
import * as React from "react";
// JSXを含むプレーンテキストをJSのコードにトランスパイルする
export const toCode = (raw: string): string | null =>
babel.transform(raw, {
plugins: ["@babel/plugin-transform-react-jsx"],
}).code;
export const convert = (raw: string, components: object): React.ReactElement<any> => {
const code = toCode(raw);
// キー名を取得
const keys = Object.keys(components);
// `keys`の並び順にcomponentsを習得
const values = keys.map(key => components[key]);
// React.createElementを含む関数を生成
const create = new Function("React", ...keys, `return ${code}`);
// 関数を実行、ReactElementを得る
return create(React, ...values);
};
となります。短いですね!どんどんいきます。
Function
とは?
唐突に出てきたFunctionって、普段使わないですよね。
Function - JavaScript | MDNによれば、
動的に関数を生成し利用できます。ただし、eval
と同じくセキュリティとパフォーマンスの問題に悩まされるようです。
サンプルコードを見てみると次のようになっています。
var sum = new Function('a', 'b', 'return a + b');
sum(2, 6)
つまり、Function
の引数の最後以外が生成される関数の引数となり、最後の引数がJavaScriptの構文を含む関数として定義されます。
具体的に解説してみる
convert
関数に渡す引数は次のように定義します。
プレーンテキストをReactElementになるまで順を追って説明すると、
const raw = "<ExampleComponent x={1} y={-20}>Result of multiplication:</ExampleComponent>";
const components = {
ExampleComponent,
}
convert(raw, components)
まずはtoCode
でJSXを含むテキストを、JSのシンタックスのコードに変換します。
// IN
toCode("<ExampleComponent x={1} y={-20}>Result of multiplication:</ExampleComponent>");
// OUT
`React.createElement(ExampleComponent, { x: 1, y: -20}, "Result of multiplication:");`
JSXがJSのコードに分解されました。次に、new Function
で必要な引数を用意します。
// IN
const keys = Object.keys(components);
// OUT
["ExampleComponent"] // 文字列の配列
// IN キーの順序でコンポーネントを取り出していく
const values = keys.map(key => components[key]);
// OUT
[ExampleComponent] // ReactComponentの配列
これらを、new Function
の引数に渡します。
// IN
const create = new Function("React", ...keys, `return ${code}`);
// 展開して書くと
const create = new Function("React", "ExampleComponent", `return React.createElement(ExampleComponent, { x: 1, y: -20}, "Result of multiplication:");`);
new Function
経由ではなく、実際にcreate
関数を定義した場合は次のようになります。
const create = (React, ExampleComponent) => React.createElement(ExampleComponent, { x: 1, y: -20}, "Result of multiplication:");
もう最後はおわかりですね。
// IN
create(React, ...values);
// 展開して書くと
create(React, ExampleComponent)
これでテキストからReactのElementに変換されました!やった!
Spread Operator(...
)のおかげで、複数の変換マップを入れた場合にも拡張できます(参考)。
おわりに
タイトルに「React意識せずにReactを使いたい」と書きましたが、内部実装を気にせず使う分には達成されるのかな(?)、と思います。
巨人の力を借りて便利なツールを作るときに役に立てばいいなぁ(eval系は注意が必要ですが)。
おまけ
JSXとか、そのへんのおもしろそうな記事とかリポジトリとかの紹介です。
- JSXのファクトリ関数を自作する方法と、Reactと全然違う挙動をさせるサンプル
- これすき
- Hyperappの話
- VDOMの取扱周りの知識が凝縮しているのでかなり勉強になった。
- Node学園2017の資料がとても良かったのですが、リンクが見当たらない
- 小さなコードで多いな利益を得るコードは勇気づけられました
- テストの書き方が参考になる
- https://www.docz.site/
- mdx-jsを利用しているライブラリ。最近スターを伸ばしているのだけれど、
Function
で頭を悩ましているのだろうか(?) - storybookから置き換わるかというとう〜ん、という感じ
- mdx-jsを利用しているライブラリ。最近スターを伸ばしているのだけれど、
- https://github.com/c8r/gen
remark-react
を使った静的サイトジェネレーター(未完成のもの)。mdx-jsの作者のリポジトリにあった- 実装が面白かったので型をあてました https://github.com/Himenon/gen
- さらにmdx-jsに内部実装を切り替えたものを現在作成中 https://github.com/Himenon/rocu
2018年もおわりかー。Twitterたのしい。にゃーん。