Edited at

mdx-jsの実装を解読するまでの話

この記事は 第2のドワンゴ Advent Calendar 2018 の12日目の記事です。

普段はReactを使ってフロントエンド開発のお仕事をしています。

※ お仕事とは関係ないお話です


話のはじまり


  • フロントエンド周りのコードを自分のブログとかで動かしてコレクション化したいなぁ

  • でも管理するの面倒だなぁ

  • そのあたりをうまく管理する静的サイトジェネレーターないかなぁ(StaticGenを探した)

1つの解としては外部サービス(CodeSandboxとかcodepenjsfiddle)を埋め込んで利用する、に行き着くんですよね。

でも、やっぱり自分の管理下でやりたい、という支配欲(?)があって長続きしません。

「じゃあ作るか → どうやって?」となるんですけど、ゼロベースだとなかなか大変なので、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とか、そのへんのおもしろそうな記事とかリポジトリとかの紹介です。

2018年もおわりかー。Twitterたのしい。にゃーん。