2つのJSONの差分を動的に表示する。シンタックスハイライトもする。powered by shiki
2つのJSON文字列の差分をシンタックスハイライト付きで表示したいケースがありました。
Zennでも同じ差分かつシンタックスハイライトができますね。下のようなコードブロックがそうです。
{
- "name": "Bob",
+ "name": "Alice",
"age": 20
}
Zennでは行頭に+
や-
をつけることで差分としてハイライトされるようになっています。
このようなシンタックスハイライトかつ差分ハイライトを、2つのJSON文字列の差分に対して行いたいと思いました。つまり、差分を表示したい箇所に明示的かつ静的にマークしていくのではなく、2つのテキストから動的に差分を計算して差分ハイライトを表示してくれる機能です。
この記事ではその方法を紹介します。
なお、僕がJSONの差分を表示したかったのでJSONで例を出しますが、好きな言語で、なんならプレーンテキストでも応用可能です。好きなだけdiff表示してください。
先出し結論
jsdiffでJSONの差分トークンを取得し、shikiの差分表示対応テキストに変換する。
jsdiff:
shiki:
サンプルコードのリポジトリ:
shikiとは
shikiはJavaScriptのシンタックスハイライトライブラリです。
npm install shiki
VS Codeと同じ言語解析エンジンを使用しているので、出力がVS Codeに近いカラーリングになります。テーマや解析可能な言語も豊富です。
また、バンドラーフレンドリーに設計されており、使う分だけのテーマや言語定義をバンドルに取り込めるようになっています。
shikiの簡単な使い方
codeToHTML
を使用するだけで、スタイリング済みのHTMLが得られます。あとはこれをブラウザで表示するだけ。style属性で色付けがされているため、CSSの読み込みも不要です。簡単!
import { codeToHtml } from "shiki";
const code = `const foo = document.createElement("div")`;
const highlighted = await codeToHtml(code, {
lang: "javascript",
theme: "light-plus",
});
console.log(highlighted);
// '<pre class="shiki light-plus" style="background-color:#FFFFFF;color:#000000" tabindex="0"><code><span class="line"><span style="color:#0000FF">const</span><span style="color:#0070C1"> foo</span><span style="color:#000000"> = </span><span style="color:#001080">document</span><span style="color:#000000">.</span><span style="color:#795E26">createElement</span><span style="color:#000000">(</span><span style="color:#A31515">"div"</span><span style="color:#000000">)</span></span></code></pre>'
ただし上記の方法では全てのテーマや言語定義が内部でロードされているため、ブラウザに届くJavaScriptコードでの使用には向いていません。間違って使ってしまうと、shikiの分だけで1.2MB(gzipped)のバンドルファイルができあがります(!)
細かくバンドルサイズをチューニングするには、createHighlighterCore
で読み込むテーマや言語定義を細かく指定します。
import { createHighlighterCore } from "shiki/core";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
const highlighter = await createHighlighterCore({
engine: createOnigurumaEngine(import("shiki/wasm")),
themes: [
import("shiki/themes/dark-plus.mjs"),
import("shiki/themes/light-plus.mjs"),
],
langs: [
import("shiki/langs/javascript.mjs"),
import("shiki/langs/typescript.mjs"),
],
});
const code = `const foo = document.createElement("div")`;
const highlighted = highlighter.codeToHtml(code, {
lang: "javascript",
theme: "light-plus",
});
console.log(highlighted);
// '<pre class="shiki light-plus" style="background-color:#FFFFFF;color:#000000" tabindex="0"><code><span class="line"><span style="color:#0000FF">const</span><span style="color:#0070C1"> foo</span><span style="color:#000000"> = </span><span style="color:#001080">document</span><span style="color:#000000">.</span><span style="color:#795E26">createElement</span><span style="color:#000000">(</span><span style="color:#A31515">"div"</span><span style="color:#000000">)</span></span></code></pre>'
createHighlighterCore
は非同期関数ですが、作られたShikiHighlightCore
インスタンスのメソッドは同期的に実行できるのも嬉しいです。Reactコンポーネントのレンダリングフェーズでも実行できるので。
shikiでのdiff表示方法
shikiでdiffを表示するにはtransformerNotationDiff
を使用します。これは@shikijs/transformers
で提供されているので追加でインストールします。
npm install @shikijs/transformers
そしてcodeToHtml
の引数に渡すだけ。HTML生成側の準備はこれだけで、とても簡単です。
import { codeToHtml } from "shiki";
import { transformerNotationDiff } from "@shikijs/transformers";
const code = `...`;
const highlighted = await codeToHtml(code, {
lang: "javascript",
theme: "light-plus",
transformers: [transformerNotationDiff()],
});
シンタックスハイライトされるコード側の記法は、追加された行なら// [!code ++]
を、削除された行なら // [!code --]
を行末に書いておきます。transformerNotationDiff
がこれらの文字列を見つけると、差分を識別できるclass(diff
, add
など)としてspan
に付与してくれます。
const code = `
const foo = document.createElement("div") // [!code ++]
const bar = document.createElement("span") // [!code --]
`;
注意点としては、@shikijs/transformers
の変換結果にはスタイリングが含まれないことです。なので、差分のための色付けは自前でCSSを用意する必要があります。サンプルコードリポジトリには実際に差分classに対して色付けするCSSのサンプルもあるので参考になれば幸いです[1]。
jsdiffとは
JavaScriptでテキストの差分をトークン列として計算してくれるライブラリです。
jsdiffのdiffLines
関数を使うことで、行単位でどんなテキストが増えてどんなテキストが削除されたかを判断できます。今回は使用しませんがdiffChars
やdiffWords
などもあって、好きな粒度でテキストの差分比較ができるようになります。
jsdiffの差分トークンを見てみる
"a\nb\nc\nd\ne"
と "a\nb\n\cc\ndd\ne"
の2つをdiffLines
に渡して計算した戻り値は次のようになります(\n
は改行コードです)。
[
{ count: 3, added: false, removed: false, value: "a\nb\nc\n" },
{ count: 2, added: false, removed: true, value: "d\ne" },
{ count: 2, added: true, removed: false, value: "dd\nee" },
];
added
/removed
の組み合わせで追加されたテキストなのか、削除されたテキストなのか、現状維持のテキストなのかを判断できるようになっています。value
には複数行がまとめて格納されています。value
が何行分のテキスト含んでいるかをcount
で判断できます。
この情報があればテキストdiffビューアが実装できそうな気がしますね!
jsdiffからshikiの差分テキストを組み立てる
jsdiffによって増えた行、減った行が判断できるようになりました。あとはjsdiffのトークン列からshikiのdiff表示用の文字列を組み立てれば、2つのテキストの実際の差分を表示することができますね。
shikiでdiffを表示するには追加行に// [!code ++]
を、削除行に // [!code --]
を行末に付与するのでした。それを実現するソースコードはこちら。
const diffTextShiki = (oldText: string, newText: string): string => {
const diffs = diffLines(oldText, newText);
return diffs
.reduce((acc, diff) => {
if (diff.added) {
const concat =
acc +
diff.value
.split("\n")
.map((line) => (line ? line + "// [!code ++]" : ""))
.join("\n");
return concat.endsWith("\n") ? concat : concat + "\n";
} else if (diff.removed) {
const concat =
acc +
diff.value
.split("\n")
.map((line) => (line ? line + "// [!code --]" : ""))
.join("\n");
return concat.endsWith("\n") ? concat : concat + "\n";
} else {
return acc + diff.value;
}
}, "")
.trim();
};
value
には複数行分の文字列が格納されているので、それを改行コードで分解してから "// [!code ++]"
または "// [!code --]"
を追加しています。終端に改行を含まない2つのテキストを比較する時、最後のトークンのvalue
にも改行が含まれないので強制的に"\n"
を付け足しています(これをしないと崩れる)。
このdiffTextShiki
を使えば、2つのテキストからshikiのdiff表示に使えるテキストに変換できます。この関数の出力結果をshikiのcodeToHtml
に渡せば、diff関連のclass付きHTMLを得られます。
Reactで組み合わせて使う
Reactでshikiを使用します。
React Server Componentの場合
React Server Component(RSC)でレンダリングするだけなら、非同期処理が扱えることとバンドルサイズを気にしなくてもよいことから、単にcodeToHtml
を使用しても良いでしょう。
import { codeToHtml } from "shiki";
const code = `const foo = document.createElement("div")`;
const Page: React.FC = async () => {
const highlighted = await codeToHtml(code, {
lang: "javascript",
theme: "light-plus",
transformers: [transformerNotationDiff()],
});
return <div dangerouslySetInnerHTML={highlighted} />;
};
Next.jsの場合、ランタイムとしてedge
とnode
が選択可能ですが、Edgeランタイムではshikiの読み込みに不具合が起きる可能性があるとして、Node.jsランタイムでの実行がおすすめされています。
Client Componentの場合
必要な分のテーマや言語だけを読み込み可能なcreateHighlighterCore
を使用します。createHighlighterCore
は非同期処理なのでReactで扱うにはひと工夫必要です。ここではReact 19から使えるuse
とSuspense
でshikiをラップしたコンポーネントを紹介します。
まずはcreateHighlighterCore
で使いたい分だけのテーマ・言語を読み込んだHighlighterCore
インスタンスのPromise
をモジュールスコープに定義します。await
しないのがポイントです。
import { createHighlighterCore, HighlighterCore } from "shiki/core";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
const highlighterPromise = createHighlighterCore({
engine: createOnigurumaEngine(import("shiki/wasm")),
themes: [
import("shiki/themes/dark-plus.mjs"),
import("shiki/themes/light-plus.mjs"),
],
langs: [
import("shiki/langs/json.mjs"),
import("shiki/langs/javascript.mjs"),
import("shiki/langs/typescript.mjs"),
],
});
モジュールスコープでcreateHighlighterCore
を実行することで実行回数を節約します。また、themes
とlangs
ではdynamic importでバンドルチャンクの分割をしています。static importしたものをthemes
/langs
に渡すこともできますが、アプリケーションコードに巻き込まれるにはあまりに大きいのでdynamic importをおすすめします(例えばshiki/wasm
だけでviteビルドかつgzip後サイズが230kBある)。
そして、次のようにネストした2つのコンポーネントを用意します。
export const ShikiHighlighter: React.FC<{
language: string;
code: string;
}> = ({ language, code }) => {
return (
<Suspense
fallback={
<div>
<pre>{code}</pre>
</div>
}
>
<ShikiHighlighterInner language={language} code={code} />
</Suspense>
);
};
const ShikiHighlighterInner: React.FC<{
language: string;
code: string;
}> = ({ language, code }) => {
const highlighter = use(highlighterPromise);
const highlighted = highlighter.codeToHtml(code, {
lang: "javascript",
theme: "light-plus",
transformers: [transformerNotationDiff()],
});
return <div dangerouslySetInnerHTML={{ __html: highlighted }} />;
};
親コンポーネントのほうは子コンポーネントをSuspense
でくくることが役目です。また、Suspense
のfallback
に<pre>
要素でcode
を表示することで、shikiがロード完了するまではハイライトされていないコードを代替表示できます。
子コンポーネントは、モジュールスコープのPromise<HighlighterCore>
から、React.use
を使ってPromise
の中身を取り出します。shikiのロードが完了していない間はサスペンドし、親のSuspense
に待機してもらいます。shikiのロードが完了していれば、HighlighterCore
インスタンスを取得できます。HighlighterCore
インスタンスのメソッドは同期処理であるため、レンダリングフェーズで実行して、結果をdangerouslySetInnerHTML
に渡すことで描画完了です!
親コンポーネントであるShikiHighlighter
だけをexportしています。シンタックスハイライトコンポーネントを使いたい側からすれば、関心があるのはShikiHighlighter
だけです。<ShikiHighlighter />
に色付けしたいコードとその言語の種類を渡すだけでよく、サスペンドするかどうかや、dangerouslySetInnerHTML
を使っていることも隠蔽します。
diff用のテキストは使う側で用意する
diffTextShiki
はcodeToHtml
でもShikiHighlighter
でも有効な文字列を生成します。使う側で差分のテキストを生成し、codeToHtml
やShikiHighlighter
にわたすだけでよいです。
ここまで用意すれば、次のようなリアルタイムに差分を表示するサイトも作れます。
楽しい!
まとめ
2つのJSONの差分を表示するために、jsdiffとshikiを組み合わせて使う方法を紹介しました。jsdiffで差分トークンを取得し、shikiの差分表示対応テキストに変換することで、2つのJSONの差分をシンタックスハイライト付きで表示できるようになります。
この方法を使えば、Zennのような差分表示ができるだけでなく、シンタックスハイライトもできるので、コードの変更箇所をわかりやすく表示できます。ぜひお試しください!
それでは良いshikiライフを!
-
このサンプルはダークモードの考慮もしているので少し煩雑になっています。それと、TailwindCSSの
@apply
を使っています ↩︎
ちょっと株式会社(chot-inc.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion