Open10

ExtendScript(ES3)向けに簡易的なJSON.stringifyを作りたい

astkastk

https://zenn.dev/link/comments/88a99ca83bfcf5

これの続き。デバッグ用やデータ出力用に JSON.stringify が使いたいけれど1999年当時のままのES3なExtendScriptでは使えないらしいのでスニペット的に使えるメソッドを定義したい。ライブラリを読み込みたくないのはコードのポータビリティを気にしているため。

データを読み込むことは考えていないので、とりあえず JSON.parse は実装しない。

astkastk

JSON.stringify を実装するにあたり対応できていると嬉しいものを整理する。第二引数の replacer はあまり使ったことがなく実装するモチベーションが湧かないので今回は飛ばす。必要性の高いものから並べていくと

  1. Primitive型と Objejct, Array の表示
  2. 循環参照のブレーク
    • クラスインスタンスを表示するときなどに循環参照が含まれていることがあり落ちると使い勝手が悪いため
  3. インデント表示
  4. Function, Data など頻出オブジェクトの表示
  5. new Boolean などラッパーオブジェクトの valueOf 処理 (誰も使ってないと信じたい)

2番以降はprintfデバッグをしたい場合に欲しくなるので、ユースケース別に使い分けられると嬉しいかもしれない。気にせずフルスペックのコードをペタと貼って終わりにした方が楽かもしれないが、書き捨てのコードとはいえ読解困難なコードの塊が視界にちらつくのは精神衛生上よくないこともある。コードの複雑さは要求する機能に見合ったものが選べると良い。

astkastk

TS→JSの変換は誰でも寝ていてもできるので、とりあえずTSで書いていく。

astkastk

辛い点を記録しておくメモ

  • テンプレートリテラルが使えないのが辛い${...}...
  • Array.prototype.join() が使えるのは救い。ありがとう1999年の人…
    • ただし map は無い
  • Object.keys() が無い
    • for ... in は使えるのでそれで回す
astkastk
  1. 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"
      ]
    }
  }
}
astkastk
  1. 循環参照のブレーク

を満たす 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 が非破壊的でよかった。pushpop を繰り返せば配列の再作成を回避できそうだが行数が増えるので今回はやめた。

循環参照のブレークができるとデバッグでの使用感が格段に良くなる。例えば 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 %

のように循環参照を認識して表示してくれている。これを行わないと呼び出しスタックの最大値まで再起してしまうのでうまく機能している。

astkastk
  1. インデント表示

想像通り、これが一番大変だった。改行やインデントを行う処理を切り貼りした結果 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のフィールドセパレータとなる。

というのは正直、処理をまとめてからの後付けの理屈なのだけれど。

astkastk
  1. Function, Data など頻出オブジェクトの表示

これ以降は単純にどこまで表示したいかによる部分なので省略。適宜条件分岐に加えればよろしい。

astkastk

ああ、undefined はJSONにないので null にしないといけない。

astkastk

いや、トップレベルが undefined だと undefined を返して、null は他のプリミティブと同じ感じで扱っている。undefined のフィールドはJSONには出力されないで無視されている。

> JSON.stringify({ a: undefined })
'{}'
> JSON.stringify({ a: null })
'{"a":null}'
> JSON.stringify(undefined)
undefined
> JSON.stringify(null)
'null'

意外と仕様が複雑だ。