ExtendScript(ES3)向けに簡易的なJSON.stringifyを作りたい
これの続き。デバッグ用やデータ出力用に JSON.stringify
が使いたいけれど1999年当時のままのES3なExtendScriptでは使えないらしいのでスニペット的に使えるメソッドを定義したい。ライブラリを読み込みたくないのはコードのポータビリティを気にしているため。
データを読み込むことは考えていないので、とりあえず JSON.parse
は実装しない。
JSON.stringify を実装するにあたり対応できていると嬉しいものを整理する。第二引数の replacer
はあまり使ったことがなく実装するモチベーションが湧かないので今回は飛ばす。必要性の高いものから並べていくと
- Primitive型と
Objejct
,Array
の表示- https://developer.mozilla.org/en-US/docs/Glossary/Primitive
- ExtendScriptはES3なので
bigint
とsymbol
は無い - 気をつけて作ったデータを出力する分にはこれでも問題ない場合も多い
- 循環参照のブレーク
- クラスインスタンスを表示するときなどに循環参照が含まれていることがあり落ちると使い勝手が悪いため
- インデント表示
-
Function
,Data
など頻出オブジェクトの表示 -
new Boolean
などラッパーオブジェクトのvalueOf
処理 (誰も使ってないと信じたい)
2番以降はprintfデバッグをしたい場合に欲しくなるので、ユースケース別に使い分けられると嬉しいかもしれない。気にせずフルスペックのコードをペタと貼って終わりにした方が楽かもしれないが、書き捨てのコードとはいえ読解困難なコードの塊が視界にちらつくのは精神衛生上よくないこともある。コードの複雑さは要求する機能に見合ったものが選べると良い。
TS→JSの変換は誰でも寝ていてもできるので、とりあえずTSで書いていく。
辛い点を記録しておくメモ
- テンプレートリテラルが使えないのが辛い
${...}...
-
Array.prototype.join()
が使えるのは救い。ありがとう1999年の人…- ただし
map
は無い
- ただし
-
Object.keys()
が無い-
for ... in
は使えるのでそれで回す
-
- Primitive型と Objejct, Array の表示
を満たす jsonStringify_1
を作った。この分量でJSONが出せるなら使い勝手はいいのでは無いだろうか。単純なデータを詰めたJSONを出力する分にはこれで事足りる。
type JsonValue = Record<string, any> | Array<JsonValue> | string | number | boolean | undefined | null
const jsonStringify_1 = (v: JsonValue) => {
if (!(v instanceof Object)) return typeof v === 'string' ? '"' + v + '"' : '' + v
const sv: string[] = []
if (v instanceof Array) {
for (let e of v) sv.push(jsonStringify_1(e))
return '[' + sv.join(', ') + ']'
}
for (let k in v) sv.push('"' + k + '": ' + jsonStringify_1(v[k]))
return '{' + sv.join(', ') + '}'
}
console.log(jsonStringify_1({ a: { b: 12 }, c: { d: { "e.f": [42, "hello"] } } }))
ExtendScript Debuggerで動かすのも面倒なのでts-nodeで動かした。型は types-for-adobe を利用している。型チェックで違反はしていないのでES3でも動くだろう(多分)。JSONもvalidなものが出力された。
asataka@tailmoon Desktop % ts-node stringify.ts
{"a": {"b": 12}, "c": {"d": {"e.f": [42, "hello"]}}}
asataka@tailmoon Desktop % ts-node stringify.ts | jq
{
"a": {
"b": 12
},
"c": {
"d": {
"e.f": [
42,
"hello"
]
}
}
}
- 循環参照のブレーク
を満たす jsonStringify_2
を作った。ここからは内部的な再起で使う引数が増えるので、外に見せないためにクロージャの中に閉じ込めている。path
というルートから現在見ているオブジェクト(Array
も含む)までを格納する引数を追加し、自身と一致した場合は循環判定し、それ以上深くなる方への処理を行わないようにしている。
type JsonValue = Record<string, any> | Array<JsonValue> | string | number | boolean | undefined | null
const jsonStringify_2 = (() => {
const stringify = (v: JsonValue, path: any[]) => {
if (!(v instanceof Object)) return typeof v === 'string' ? '"' + v + '"' : '' + v
for (let o of path) if (v === o) return '"[Circular]"' // ←Detect circular reference
const sv: string[] = []
if (v instanceof Array) {
for (let e of v) sv.push(stringify(e, path.concat([v])))
return '[' + sv.join(', ') + ']'
}
for (let k in v) sv.push('"' + k + '": ' + stringify(v[k], path.concat([v])))
return '{' + sv.join(', ') + '}'
}
return (v: JsonValue) => stringify(v, [])
})()
console.log(jsonStringify_2(global))
Array.prototype.concat
が非破壊的でよかった。push
と pop
を繰り返せば配列の再作成を回避できそうだが行数が増えるので今回はやめた。
循環参照のブレークができるとデバッグでの使用感が格段に良くなる。例えば global
を与えてみると
asataka@tailmoon Desktop % ts-node stringify.ts | jq
{
"global": "[Circular]",
"clearInterval": {},
"clearTimeout": {},
"setInterval": {},
"setTimeout": {},
"queueMicrotask": {},
"performance": {
"timeOrigin": 1640784936082.228,
"toJSON": {},
"addEventListener": {},
"removeEventListener": {},
"dispatchEvent": {}
},
"clearImmediate": {},
"setImmediate": {}
}
asataka@tailmoon Desktop %
のように循環参照を認識して表示してくれている。これを行わないと呼び出しスタックの最大値まで再起してしまうのでうまく機能している。
- インデント表示
想像通り、これが一番大変だった。改行やインデントを行う処理を切り貼りした結果 prindObj
でやっているような処理を定義すると、うまくまとまることがわかったが、正直なんでこれで上手くいくのかはあまり理解していない。
type JsonValue = Record<string, any> | Array<JsonValue> | string | number | boolean | undefined | null
const jsonStringify_3 = (() => {
/**
* @param lp Left parenthesis
* @param tokens Stringified elements without indent
* @param rp Right parenthesis
* @param indent Indent unit
* @param lv Indent level
* @returns
*/
const printObj = (lp: string, tokens: string[], rp: string, indent: number, lv: number) => {
if (!tokens.length) return lp + rp
const sp = (lv: number) => (indent ? '\n' : '') + Array(lv * indent + 1).join(' ')
return lp + sp(lv + 1) + tokens.join(',' + sp(lv + 1)) + sp(lv) + rp
}
/**
* @param v Value to be stringified
* @param indent Indent unit
* @param lv Indent level
* @param path Elements between the root and `v`
* @returns
*/
const stringify = (v: JsonValue, indent: number, lv: number, path: any[]): string => {
if (!(v instanceof Object)) return typeof v === 'string' ? '"' + v + '"' : '' + v
for (let o of path) if (v === o) return '"[Circular]"'
const sv: string[] = []
if (v instanceof Array) {
for (let e of v) sv.push(stringify(e, indent, lv + 1, path.concat([v])))
return printObj('[', sv, ']', indent, lv)
}
for (let k in v) sv.push('"' + k + '": ' + stringify(v[k], indent, lv + 1, path.concat([v])))
return printObj('{', sv, '}', indent, lv)
}
return (v: JsonValue, indent?: number) => stringify(v, indent || 0, 0, [])
})()
console.log(jsonStringify_3(global, 2))
console.log(jsonStringify_3(global))
動かしてみると無事インデントされている。引数を省略した場合は1行で表示する。
asataka@tailmoon Desktop % ts-node stringify.ts
{"global": "[Circular]","clearInterval": {},"clearTimeout": {},"setInterval": {},"setTimeout": {},"queueMicrotask": {},"performance": {"timeOrigin": 1640786537362.73,"toJSON": {},"addEventListener": {},"removeEventListener": {},"dispatchEvent": {}},"clearImmediate": {},"setImmediate": {}}
{
"global": "[Circular]",
"clearInterval": {},
"clearTimeout": {},
"setInterval": {},
"setTimeout": {},
"queueMicrotask": {},
"performance": {
"timeOrigin": 1640786537362.73,
"toJSON": {},
"addEventListener": {},
"removeEventListener": {},
"dispatchEvent": {}
},
"clearImmediate": {},
"setImmediate": {}
}
asataka@tailmoon Desktop %
これで jq
いらずである。処理は複雑になったが、実際使いやすくはなった。
printObj
の処理をまとめていて気づいたことをヒントとして残しておくと、「インデントにより整形されたJSONのフィールドセパレータとは何か」を正確に認識できると理解の助けになるかもしれない。「,
でフィールドが分割された文字列があり、必要に応じて改行とインデントを適宜加える」と考えると複雑だが、各フィールドが「, + 改行 + <インデント分のスペース>
で分割されたもの」と考えると途端に join
を使った結合のイメージが湧いてくる。
つまり普段スペースや改行は認識外に置きがちだが、より正確な認識としては、図でハイライトした部分、,\n
がインデントにより整形されたJSONのフィールドセパレータとなる。
というのは正直、処理をまとめてからの後付けの理屈なのだけれど。
- Function, Data など頻出オブジェクトの表示
これ以降は単純にどこまで表示したいかによる部分なので省略。適宜条件分岐に加えればよろしい。
ああ、undefined
はJSONにないので null
にしないといけない。
いや、トップレベルが undefined
だと undefined
を返して、null
は他のプリミティブと同じ感じで扱っている。undefined
のフィールドはJSONには出力されないで無視されている。
> JSON.stringify({ a: undefined })
'{}'
> JSON.stringify({ a: null })
'{"a":null}'
> JSON.stringify(undefined)
undefined
> JSON.stringify(null)
'null'
意外と仕様が複雑だ。