ReactのSerialize
ReactのSerialize周りを調べる
とりあえずシリアライズ関数はこのあたり
renderFunctionComponent
とか renderElement
から呼ばれる
renderModelDestructive
↑
renderModelDestructive( renderFunctionComponent, renderElement, renderModel とかからも呼ばれる)
↑
retryTask
↑
performWork
↑
startWork
↑
renderToReadableStream (Next.js側)
renderModelDestructiveを再帰的に呼び出してシリアライズしてる、という感じぽい
renderClientElement が Client Component のレンダリングぽい
[REACT_ELEMENT_TYPE, type, key, props]
が返される
↑は直接 renderElement
から返される
↑をtask.toJSONでシリアライズ
toJSONは、JSON.stringifyの第二引数(replacer)に渡される
つまり、JSON.stringifyによって勝手に要素を辿って関数を適用していくことになる
createRequest
経由でリクエストを作っていて、そこからtaskを生成している
というわけで toJSON の正体 → renderModel
Streamを考える。
たとえば次のようなもの
2:"$Sreact.suspense"
4:I["(app-pages-browser)/./src/app/Client.tsx",["app/page","static/chunks/app/page.js"],"Client"]
5:"$SSYMBOL"
a:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
b:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
3:{"id":"4450fa3df088dad8db026c6c81f2c0bb592158f7","bound":null}
6:[["mapKey1","mapValue1"],["mapKey2","mapValue2"],["mapKey3","mapValue3"]]
7:["set1","set2","set3"]
8:{"id":"7de9e92d780a000fa18feb74a7ec9a83a73ddc8f","bound":null}
0:["development",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},["$L1",["$","main",null,{"children":[["$","h1",null,{"children":"Page"}],["$","$2",null,{"children":[["$","form",null,{"action":"$F3","children":["$","button",null,{"type":"submit","children":"Submit"}]}],["$","$L4",null,{"string":"abc","number":123,"boolean":true,"undefined":"$undefined","null":null,"symbol":"$5","iter_string":["a","b","c"],"iter_array":["a","b","c"],"iter_map":"$Q6","iter_set":"$W7","iter_typed_array":[1,2,3],"date":"$D2024-04-13T08:10:15.385Z","object":{"key1":"key1value","key2":{"key3":12344,"key4":false}},"jsx":["$","span",null,{"children":"JSX"}],"action":"$F8","promise":"$@9"}]]}]]}],null]]},[null,["$","html",null,{"children":["$","body",null,{"children":["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$Lb",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[],"styles":null}]}]}],null]],[null,"$Lc"]]]]
c:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","link","2",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}]]
1:null
9:"RESOLVED!!"
これらは、ReactFlightServer
内の emitXXX
系の関数とかを見るとよい
先頭の 0:
とか a:
とかは、個々のタスク(モデルとか、importとか)に応じた連番を16進数化したものが付与されてる。
:
の後ろにはタグが付与されることがある。
4:I["(app-pages-browser)/./src.....
みたいなものだと、l
が付与されてる。
この場合は、Import系のチャンクという扱いになる。
他にも次のような種類がある
- P : Postpone Chunk
- E : Error Chunk
- H : Hint Chunk
- D : Debug Chunk
- W : Console Chunk
に記述のあるシリアライズ周り
- プリミティブ
- 文字列
- 数値
- bigint
- ブーリアン
- undefined
- null
- シンボル、ただし Symbol.for を通じてグローバルシンボルレジストリに登録されたシンボルのみ
- シリアライズ可能な値を含んだ Iterable
- 文字列
- 配列
- Map
- Set
- TypedArray と ArrayBuffer
- Date
- プレーンなオブジェクト: オブジェクト初期化子で作成され、シリアライズ可能なプロパティを持つもの
- サーバアクション (server action) としての関数
- クライアントまたはサーバコンポーネントの要素(JSX)
- プロミス
以下がサポートされない
- クライアントとマークされたモジュールからエクスポートされていない、または 'use server' でマークされていない関数
- クラス
- 任意のクラスのインスタンス(上記の組み込みクラスを除く)や、null プロトタイプのオブジェクト
- グローバルに登録されていないシンボル、例:Symbol('my new symbol')
実際の挙動からみてみる
RSC Devtools で内容を見る
Server Components
import { Suspense } from "react";
import { Client } from "./Client";
export const revalidate = 0;
async function serverAction() {
"use server";
await Promise.resolve();
}
export default function Page() {
const promise = new Promise((resolve) => setTimeout(resolve, 1000));
return (
<main>
<h1>Page</h1>
<Suspense>
<Client
string="abc"
number={123}
// bigint={BigInt(9999999999999999999)}
boolean={true}
undefined={undefined}
null={null}
symbol={Symbol.for("SYMBOL")}
iter_string={"abc"[Symbol.iterator]()}
iter_array={["a", "b", "c"]}
iter_map={(() => {
const map = new Map();
map.set("mapKey1", "mapValue1");
map.set("mapKey2", "mapValue2");
map.set("mapKey3", "mapValue3");
return map;
})()}
iter_set={new Set(["set1", "set2", "set3"])}
iter_typed_array={new Int8Array([1, 2, 3])}
date={new Date()}
object={{ key1: "key1value", key2: { key3: 12344, key4: false } }}
jsx={<span>JSX</span>}
action={serverAction}
promise={promise}
/>
</Suspense>
</main>
);
}
Client Component
"use client";
import { useRouter } from "next/navigation";
import { use } from "react";
type Props = {
string: string;
number: number;
// bigint: BigInt;
boolean: boolean;
undefined: undefined;
null: null;
symbol: Symbol;
iter_string: Iterator<string>;
iter_array: Array<any>;
iter_map: Map<any, any>;
iter_set: Set<any>;
iter_typed_array: Int8Array;
date: Date;
object: { key1: string; key2: { key3: number; key4: boolean } };
jsx: JSX.Element;
action: () => Promise<void>;
promise: Promise<any>;
};
export function Client(props: Props) {
use(props.promise);
const router = useRouter();
return (
<section>
<h1>Client</h1>
<button type="button" onClick={() => router.refresh()}>
Refresh
</button>
</section>
);
}
Refreshボタンを押すことで router.refresh が発火して、こんな感じのデータが得られる(整形済み)
2:"$Sreact.suspense"
4:I["(app-pages-browser)/./src/app/Client.tsx",["app/page","static/chunks/app/page.js"],"Client"]
5:"$SSYMBOL"
a:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
b:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
3:{"id":"4450fa3df088dad8db026c6c81f2c0bb592158f7","bound":null}
6:[["mapKey1","mapValue1"],["mapKey2","mapValue2"],["mapKey3","mapValue3"]]
7:["set1","set2","set3"]
8:{"id":"7de9e92d780a000fa18feb74a7ec9a83a73ddc8f","bound":null}
0:["development",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},["$L1",["$","main",null,{"children":[["$","h1",null,{"children":"Page"}],["$","$2",null,{"children":[["$","form",null,{"action":"$F3","children":["$","button",null,{"type":"submit","children":"Submit"}]}],["$","$L4",null,{"string":"abc","number":123,"boolean":true,"undefined":"$undefined","null":null,"symbol":"$5","iter_string":["a","b","c"],"iter_array":["a","b","c"],"iter_map":"$Q6","iter_set":"$W7","iter_typed_array":[1,2,3],"date":"$D2024-04-13T08:10:15.385Z","object":{"key1":"key1value","key2":{"key3":12344,"key4":false}},"jsx":["$","span",null,{"children":"JSX"}],"action":"$F8","promise":"$@9"}]]}]]}],null]]},[null,["$","html",null,{"children":["$","body",null,{"children":["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$Lb",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[],"styles":null}]}]}],null]],[null,"$Lc"]]]]
c:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","link","2",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}]]
1:null
9:"RESOLVED!!"
[
"development",
[
[
["", { "children": ["__PAGE__", {}] }, "$undefined", "$undefined", true],
[
"",
{
"children": [
"__PAGE__",
{},
[
"$L1",
[
"$",
"main",
null,
{
"children": [
["$", "h1", null, { "children": "Page" }],
[
"$",
"$2",
null,
{
"children": [
[
"$",
"form",
null,
{
"action": "$F3",
"children": [
"$",
"button",
null,
{ "type": "submit", "children": "Submit" }
]
}
],
[
"$",
"$L4",
null,
{
"string": "abc",
"number": 123,
"boolean": true,
"undefined": "$undefined",
"null": null,
"symbol": "$5",
"iter_string": ["a", "b", "c"],
"iter_array": ["a", "b", "c"],
"iter_map": "$Q6",
"iter_set": "$W7",
"iter_typed_array": [1, 2, 3],
"date": "$D2024-04-13T08:10:15.385Z",
"object": {
"key1": "key1value",
"key2": { "key3": 12344, "key4": false }
},
"jsx": ["$", "span", null, { "children": "JSX" }],
"action": "$F8",
"promise": "$@9"
}
]
]
}
]
]
}
],
null
]
]
},
[
null,
[
"$",
"html",
null,
{
"children": [
"$",
"body",
null,
{
"children": [
"$",
"$La",
null,
{
"parallelRouterKey": "children",
"segmentPath": ["children"],
"loading": "$undefined",
"loadingStyles": "$undefined",
"loadingScripts": "$undefined",
"hasLoading": false,
"error": "$undefined",
"errorStyles": "$undefined",
"errorScripts": "$undefined",
"template": ["$", "$Lb", null, {}],
"templateStyles": "$undefined",
"templateScripts": "$undefined",
"notFound": [
[
"$",
"title",
null,
{ "children": "404: This page could not be found." }
],
[
"$",
"div",
null,
{
"style": {
"fontFamily": "system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"",
"height": "100vh",
"textAlign": "center",
"display": "flex",
"flexDirection": "column",
"alignItems": "center",
"justifyContent": "center"
},
"children": [
"$",
"div",
null,
{
"children": [
[
"$",
"style",
null,
{
"dangerouslySetInnerHTML": {
"__html": "body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"
}
}
],
[
"$",
"h1",
null,
{
"className": "next-error-h1",
"style": {
"display": "inline-block",
"margin": "0 20px 0 0",
"padding": "0 23px 0 0",
"fontSize": 24,
"fontWeight": 500,
"verticalAlign": "top",
"lineHeight": "49px"
},
"children": "404"
}
],
[
"$",
"div",
null,
{
"style": { "display": "inline-block" },
"children": [
"$",
"h2",
null,
{
"style": {
"fontSize": 14,
"fontWeight": 400,
"lineHeight": "49px",
"margin": 0
},
"children": "This page could not be found."
}
]
}
]
]
}
]
}
]
],
"notFoundStyles": [],
"styles": null
}
]
}
]
}
],
null
]
],
[null, "$Lc"]
]
]
]
各種シリアライズの詳細
Number
-0, Infinity, NaN のみ固有の文字列にマッピングしてる。
ほかはそのまま値を利用
文字列 (および Date)
- Taint API によるチェック
- 末尾が
Z
の場合かつparent[parentPropertyName]
が Date なら Dateの文字列として扱う - 大きい文字列は別チャンクにする
- 先頭が
$
の場合は$$
にエスケープする
※
Dateの場合はDate自体が持つ toJSON()
の実行後の値がreplacerに渡されるため、 "2024-04-13T05:34:21.966Z"
のような文字列になる
BitInt
$n
の後ろに toStringしたものをくっつけてる
Boolean
そのまま返す
undefined
"$undefined"
で固定
null
nullのまま
Symbol
- シンボルを別チャンクに
- 同じシンボルは同じチャンクになる
-
"$S"
+ シンボル名 の名前になるぽい - シンボルのチャンクへの参照が入る
シンボルのチャンクが 4:"$SSYMBOL"
なら、 "$4"
になる
Array および Iterable
Array
Iterable
- Fragmentの場合を考慮したシリアライズが入る (task.keyPath で判断している)
- Fragmentじゃない場合はそのまま返す(JSON.stringifyのreplacerによって配列内の要素は再度呼び出される)
Map / Set
- 別チャンクになる
- Mapは
$Q
+ id、Setは$W
+ id
チャンクは↓みたいな感じ
5:[["mapKey1","mapValue1"],["mapKey2","mapValue2"],["mapKey3","mapValue3"]]
6:["set1","set2","set3"]
TypedArray
- React の FeatureFlag
enableBinaryFlight
によっては専用の形式にシリアライズされる - が、Next.js 14.2.0 現時点では、
enableBinaryFlight
は false - なので、単純な Iterable として処理される
Function
Client Reference
Server Reference
※WIP
Promise
-
createTask()
で ID を得る。 - IDをもとに
'$@'
+ ID とする
JSON内が "promise":"$@8"
だとして、Promiseを "RESOLVED!!"
といった文字列でresolveした場合、 Stream に 8:"RESOLVED!!"
が来る
thenable.then(
value => {
newTask.model = value;
pingTask(request, newTask);
},
reason => {
if (
enablePostpone &&
typeof reason === 'object' &&
reason !== null &&
(reason: any).$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (reason: any);
logPostpone(request, postponeInstance.message);
emitPostponeChunk(request, newTask.id, postponeInstance);
} else {
newTask.status = ERRORED;
const digest = logRecoverableError(request, reason);
emitErrorChunk(request, newTask.id, digest, reason);
}
request.abortableTasks.delete(newTask);
if (request.destination !== null) {
flushCompletedChunks(request, request.destination);
}
},
);
return newTask.id;
postpone is 何
関連しそうなPR
-
unstable_postpone()
(※実態はpackages/react/src/ReactPostpone.js
#postpone()
)を呼ぶことで、REACT_POSTPONE_TYPE
がマークされたErrorがthrowされる - Promiseがrejectされた理由が、この
REACT_POSTPONE_TYPE
のエラーが理由だった場合だけ特別な扱いにしている- 解決されない無限のPromiseで使う、みたいな話らしい
Client側は "P" のフラグを見てこのあたりでさばいてそう
ただ、現状 enablePostpone
が false なのでNextだとあんまり関係ない
普通に使ってた。
あっきーさんに教えてもらったが、PPR絡みの模様
クライアント側のメモ
直近関係はなさそうだが、 enableBinaryFlight
が有効になるとこのへんも絡む
(現状Nextだとフラグが無効なので関係ない)
Streamを読み込んでるのはこのへん
Next.jsだとここから呼び出してる
rowを処理してる場所
先頭から1文字ずつStreamの内容を処理していく。
処理の段階によって rowState
の値が変化していく
rowStateの初期値は0
0は、定数 ROW_ID と同じ
ROW_ID のときの処理
IDを探し中の処理
-
":"
が見つかるまで探す - そこまでに出てきたものは rowId として記録する
- 16進数文字列なので、それをビット演算して10進数のNumberに戻してる
e.g.)
"1a" が来た場合、
- "1" の charCode は 49 なので、49 - 48 = 1 を一時的に rowId に設定
- 現時点での rowId を 4ビット(16進1桁の範囲)を左にシフト ※Aとする
- "a" の charCode は97 なので、97 - 87 = 10 を取得。Aと論理和を取る → 26 になる
:
が見つかったら、ステータスを ROW_TAG に切り替え
ROW_TAG のときの処理
-
:
の次の1文字を見る - "T" または enableBinaryFlight が true で "A", "C", "c", "U", "S", "s", "L", "l", "F", "d", "N", "m", "V" のとき
- rowTag に取得した1文字を設定
- rowState を ROW_LENGTH に切り替え
- ↑に該当せず、"A" - "Z" のとき
- rowTag に取得した1文字を設定
- rowState を ROW_CHUNK_BY_NEWLINE に切り替え
- ↑の両方に該当しないとき
- rowTag を 0 に設定
- rowState を ROW_CHUNK_BY_NEWLINE に切り替え
まとめると、ROW_LENGTHまたはROW_CHUNK_BY_NEWLINEに切り替える判断をしてる感じぽい
ROW_LENGTH のときの処理
- "," が見つかるまでの文字列を、16進数から10進数値に変換してる(IDの処理と近い)
- これを rowLength に設定
- "," が見つかったら、rowState を ROW_CHUNK_BY_LENGTH に切り替え
参考:
Server側で、TypedArray などの場合は、タグの後ろに16進数文字列でサイズ情報が入ってくるぽい。
ROW_CHUNK_BY_LENGTH
- ROW_LENGTH 時の処理で得た rowLength をもとに、チャンクの終了位置を lastIdx に保持
ROW_CHUNK_BY_NEWLINE のときの処理
- "\n" の位置をチャンクの終了位置として lastIdx に保持
残りの部分をチャンク本体として processFullRow
として処理する
ここまでに得た rowID, rowTag も渡してる
Client - チャンク本体の Deserialize
先のチャンク解析で得たrowTag(この関数内では tag)によって処理を分岐していく。
つまりは、チャンクが 4:I["(app-pages-browser)/./src/app/Client.tsx",...
であれば、先頭の 4:I
の 4 が id で I
が tag
"I"
- おそらく Import の I
-
requireAsyncModule()
経由でモジュールをimport - webpackの場合だと、
__webpack_require__
が実行される
4:I["(app-pages-browser)/./src/app/Client.tsx",["app/page","static/chunks/app/page.js"],"Client"]
とかだと、 ["(app-pages-browser)/./src/app/Client.tsx",["app/page","static/chunks/app/page.js"],"Client"]
の部分を parseJson する。
↓のような型になる想定らしい
"H"
- HintのHらしい
- Server側では
emitHint()
でチャンクが生成されるが、packages/react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js
からしか呼び出しが無い - Float (https://github.com/facebook/react/pull/25243 ) 関連
-
flushSyncWork
prefetchDNS
preconnect
preload
preloadModule
preinitScript
preinitStyle
preinitModuleScript
とかが該当する
-
このあたりから、Floatのメソッドが呼ばれるぽい https://github.com/facebook/react/blob/ed3c65caf042f75fe2fdc2a5e568a9624c6175fb/packages/react-client/src/ReactFlightClient.js#L1150-L1157
"E"
- Error の E
- 回り回って
wakeChunk
が呼ばれるぽい
↓ ↓
ServerからはPromiseをRejectしたりすると飛んでくるぽい
"T"
- TextのT
- チャンクを記録
大きい文字列の場合に別チャンクになってる
"D"
- Debug の D
- Dev時以外は無視される(分岐には入るが何もしない)
- 他のチャンクに紐付くように _debugInfo に記録する
Lazyとかで使ってるぽい
"W"
- 何の W かはわからないが、とりあえずDev時しか送られてこない想定らしい
- consoleに出力する
このPRで入ったぽい
どうやら、現在は experimental な enableServerComponentLogs
フラグが有効化されると、サーバサイドでのconsole.logをStremとしてクライアント側に流せるようになるらしい
"P"
- Postpone の P
↓で送られてるやつ
- wakeChunk につながる
{
[
t
f
n
0
-9
)
その他のtag ( - データ系のJSONなど
- 0番チャンク(レンダリング対象チャンク)もここに含まれる
- initializeModelChunk → parseModel から JSON.parse が呼ばれる。
- シリアライズ時同様、JSON.parse自体の仕組みでkey-valueを巡回する
- JSON.parseに渡す reciver は createFromJSONCallback 経由で作成されてる
(ここから非常に長くなるので、別スレッドでメモ)
クライアント側での処理の起点
Streamを作ったときに、getRoot()
を実行してる
ID:0 のチャンクを探して、PENDING状態で保持する
このPENDING状態の空チャンクは、直後に then()
が実行される。
PENDINGであり chunk.value
が空なので、chunk.value
に thenable の resolve
が入る
Next.js側まで辿ると、レスポンスを use() で待機してから描画してる
つまり
createFromReadableStream
↓
getRoot(0チャンクを待機・取得)
↓
0チャンク経由でレンダリング結果を得る
という流れ
parseしたやつは、react-domの reconcileChildrenArray
に流れていく
resolveModel 周り
fromJSONはこれ
- 文字列なら
parseModelString
- オブジェクトなら
parseModelTuple
- それ以外ならそのまま返す(nullの場合?)
parseModelString
- 文字列の場合、他チャンクへの参照などである可能性がある
-
'$'
から始まる場合に該当する - シリアライズ側の分岐と併せて見るとよい
https://zenn.dev/mugi/scraps/b68d05abf0d087#comment-185b96fbf90394