はじめに
サーバーから受け取ったデータをそのままクライアントに渡したいだけなのに、JSON.stringifyが「circular structure」やら「undefinedが消える」やらで文句を言ってきた経験、ありませんか。
MapやSetを使っていたら{}に化けていた、Dateを渡したら文字列になって戻す処理を書き忘れていた、オブジェクトが自己参照していてTypeErrorで落ちた。JSONというフォーマットの制約に、何度も足を引っ張られてきました。
devalueは、そんな場面のために作られたシリアライゼーションライブラリです。npm上の説明文がそのまま端的で、「Gets the job done when JSON.stringify can't(JSON.stringifyにできないことをやり遂げる)」というのがdevalueのキャッチコピーです。Svelte/SvelteKitチームが自社製品の内部シリアライザとして育て、切り出して公開しているパッケージでもあります。
とはいえ、読むより触った方が早いと思います。循環参照やMap、Setを含むオブジェクトをJSON.stringifyとdevalueの両方で変換して、違いをその場で確かめられるサンプルを置いてあるので、先に挙動を見たい方はこちらからどうぞ。
devalueとは
devalueは、JavaScriptの値を文字列(またはJS実行可能なコード)に変換し、また元の値に復元するためのライブラリです。MITライセンスで公開されており、TypeScriptの型定義も同梱されています。
JSON.stringify/JSON.parseの上位互換のような位置づけですが、単なる置き換えではなく「サーバーで生成した値を、安全かつロスなくクライアントへ渡す」という具体的な課題を解決するために設計されています。SvelteKitはサーバーからクライアントへのデータ受け渡しに、内部でdevalueを利用しています。
主な特徴
- JSONより多くの型を扱える -
undefined、NaN、Infinity、-0、正規表現、Date、Map、Set、BigInt、ArrayBuffer、TypedArray、URL、URLSearchParamsなどをそのままシリアライズできます - 循環参照・重複参照に対応 -
obj.self = objのような自己参照や、同じオブジェクトを複数箇所から参照している状態も、内部的に参照を追跡してコンパクトに表現します - XSS対策済みの出力 - 文字列中の
</script>のような危険な部分文字列を自動でエスケープするため、サーバーが生成した値をそのままHTMLの<script>タグに埋め込んでも安全です unevalによるコード生成 - JSONではなく、評価可能なJavaScriptのソースコードとして値を出力できるuneval関数を持っています- カスタム型への対応 - reducer/reviverを渡すことで、独自クラスのインスタンスもシリアライズ・復元の対象にできます
インストール
npmやyarnなど、お使いのパッケージマネージャーでインストールできます。
npm install devalue
yarn add devalue
pnpm add devalue
devalueのサンプルを動かす
下のサンプルでは、Date・Set・Map・undefinedに加えて自己参照(data.self = data)まで持たせたオブジェクトを用意しています。「JSON.stringifyで試す」ボタンを押すとTypeError: Converting circular structure to JSONで失敗し、「devalue.stringifyで試す」ボタンを押すとdevalueのstringifyとparseで正しく型が復元されることが確認できます。
import { stringify, parse } from 'devalue'
const data = { tags: new Set(['js', 'ts']), self: null }
data.self = data // 循環参照もそのままでOK
JSON.stringify(data) // TypeError: Converting circular structure to JSON
const str = stringify(data) // devalueなら通る
const restored = parse(str)
restored.tags instanceof Set // true
restored.self === restored // true(循環参照も復元される)
実際に手を動かして確認できるサンプルが以下です。ボタンを2つとも押して、結果の違いを見比べてみてください。
JSON.stringifyは循環参照を持つオブジェクトを渡した時点で例外を投げて止まってしまいますが、devalueのstringify/parseは同じデータをそのまま扱えます。tagsやscoresがSet・Mapのインスタンスとして復元され、data.selfが元のオブジェクト自身を指す参照として復元されている点にも注目してください。
基本的な使い方
devalueの基本はstringifyとparseの2関数だけです。使い方自体はJSON.stringify/JSON.parseとほぼ同じで、置き換えるだけで対応可能な型が広がります。
import { stringify, parse } from 'devalue'
const user = { id: 1, name: 'Kenta', joinedAt: new Date() }
const serialized = stringify(user)
// => "[{\"id\":1,\"name\":2,\"joinedAt\":3},1,\"Kenta\",[\"Date\",\"...\"]]"
const restored = parse(serialized)
restored.joinedAt instanceof Date // true
出力形式はJSONとは異なる独自フォーマットです。人間が読みやすい形式にすることは目的にしていないので、デバッグ表示用途ではなく、あくまで「シリアライズしてどこかに送り、devalueでparseし直す」という用途に使うのが基本です。
実践的なユースケース
unevalでJSコードとして埋め込む
stringifyはJSON互換の文字列を返すのに対し、unevalは「評価すればその値になるJavaScriptのソースコード」を文字列として返します。SvelteKitのようなSSRフレームワークが、サーバーで作った値を<script>window.__DATA__ = ...</script>のような形でHTMLに直接埋め込む際に使われている書き方です。
import { uneval } from 'devalue'
const data = { name: 'Ada', roles: new Set(['admin']) }
uneval(data)
// => '{name:"Ada",roles:new Set(["admin"])}'
// -> そのまま <script>window.__DATA__ = ...</script> に差し込める
uneval({ note: '</script>危険な文字列' })
// => '{note:"\\u003C/script\\u003E危険な文字列"}'
// -> </script> がエスケープされ、閉じタグとして解釈されない
名前欄にわざと</script>を含む文字列を入れて、「unevalでコード生成」を押してみてください。unevalの出力ではエスケープされているのに対し、JSON.stringifyの出力をそのまま<script>タグに埋め込んだ想定の文字列では</script>が生のまま残ることが分かります。
unevalの出力では</script>が\u003C/script\u003Eのような形にエスケープされるため、そのままHTMLに埋め込んでもタグとして解釈されません。一方でJSONを生で埋め込むと、入力した</script>がその位置で本当にタグを閉じてしまいます。サーバーサイドでユーザー由来のデータをHTMLに埋め込む場面では、この差が実際のXSS対策として効いてきます。
カスタムクラスのシリアライズ
MapやDateのような組み込み型だけでなく、自作のクラスのインスタンスもシリアライズ対象にできます。stringify/parseの第2引数にreducer/reviverのペアを渡すのがポイントです。reducerは「そのクラスのインスタンスかどうかを判定し、復元に必要なデータを配列で返す関数」、reviverは「その配列からインスタンスを再構築する関数」です。
import { stringify, parse } from 'devalue'
class Vector {
constructor(x, y) { this.x = x; this.y = y }
get length() { return Math.hypot(this.x, this.y) }
}
const str = stringify(new Vector(3, 4), {
Vector: (v) => v instanceof Vector && [v.x, v.y],
})
const restored = parse(str, {
Vector: ([x, y]) => new Vector(x, y),
})
restored instanceof Vector // true
restored.length // 5
x・yの値を書き換えて「Vectorをシリアライズ→復元」を押すと、devalueのstringifyが独自クラスVectorのインスタンスを文字列化し、parseが同じクラスのインスタンスとして復元していることが確認できます。復元後もlengthのようなgetterがちゃんと使えている点に注目してください。
非同期値を含むデータのシリアライズ
stringifyは同期関数なので、値の中にPromiseが混ざっていると正しく扱えません。そこで使うのが非同期版のstringifyAsyncです。渡したオブジェクトの中のPromiseをすべて解決してから文字列化してくれるため、awaitして使います。サーバーの一部の値だけ非同期に取得している、という状況でそのままクライアントに渡したい場合に向いています。
import { stringifyAsync, parse } from 'devalue'
const payload = {
user: { name: 'Ada' },
posts: fetchPosts(), // Promiseのまま渡せる
}
const str = await stringifyAsync(payload)
const restored = parse(str)
restored.posts // Promiseではなく、解決済みの値になっている
ボタンを押すと、postsにPromiseを持たせたオブジェクトをstringifyAsyncに渡します。実際のAPI通信の代わりにsetTimeoutで0.5秒後に配列を返すPromiseを使っていますが、考え方は非同期取得したデータをそのまま渡す場合と同じです。
stringifyAsyncが返すのはPromiseで、解決を待つと通常のstringifyと同じ形式の文字列が得られます。parseする側は同期のままで良く、Promiseだったことを意識せず解決済みの値としてそのまま扱えます。
まとめ
devalueは、「JSON.stringifyではどうにもならない場面」に的を絞ったシリアライゼーションライブラリです。Map・Set・Date・undefined・循環参照といったJSONが苦手とする値をそのまま扱え、unevalによるXSS対策済みのコード生成、reducer/reviverによるカスタムクラス対応、stringifyAsyncによる非同期値の扱いまで、サーバーからクライアントへデータを受け渡すという一つの課題に対して手厚く作り込まれています。
SvelteKitの内部シリアライザとして磨かれてきた実績もあり、フレームワークを問わず「サーバーの値をそのままHTMLに埋め込みたい」「JSONでは表現できない型を安全にやり取りしたい」という場面では、JSON.stringifyの代わりとして十分に検討に値するライブラリです。まずは今回のサンプルのコードをコピーして、手元のオブジェクトを流し込んでみてください。
