React の useOpaqueIdentifier について
React, JavaScript, Accessibilityこの記事の内容は古いです
この記事に書かれていた useOpaqueIdentifier
は、React 18 から useId Hooks として正式にリリースされました。
前書き
React の SSR 向けに Opaque ID を生成する useOpaqueIdentifier
Hooks について覚書。
この記事で書かれているのは、実験的な Hooks で、仕様が変わるかもしれない。
デモはこちら。
ID参照と Hydration の問題
マークアップでは、ある要素が別の要素を参照する時、label
要素の for
属性や aria-labelledby
などの ARIA 属性を用いる。その属性には、参照する要素の ID を指定する必要があり、ID を持った要素が DOM 上になければならない。
html
<!-- id が inputFoo の要素を参照している label -->
<label for="inputFoo">Foo</label>
<!-- id に inputFoo を持つ要素が DOM にないと label はエラー -->
<input id="inputFoo" type="text" />
今回は、そのようなものを React コンポーネントで実装する時のお話。
InputField.tsx
import * as React from 'react'
type Props = {
label: string
handleChangeText: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export const InputSample: React.FC<Props> = props => {
return (
<>
<label htmlFor="inputSample">{props.label}</label>
<input id="inputSample" name="inputSample" type="text" />
</>
)
}
上記の InputSample
が 2 つあると、inputSample
の ID も 2 つ文書に存在することになる。
ページ内のIDはユニークでなければならないため、 ID の重複を防ぐ必要がある。
簡単なものとして、UID を Props なりで渡し、プレフィックス的に用いる方法がある。
しかし、ID 参照のための ID を管理する、悲しみの歴史を繰り返すことになる。
pages/index.tsx
{/* どうして uid を渡さないといけないんですか? */}
<InputSample uid={'input1'} />
コンポーネント側で生成する時の懸念
ID 参照のための UID を利用者が設定するのではなく、いっそのこと自動で設定されるのはどうだろう。
コンポーネントが UID が生成してくれれば、それに越したことはないため。
しかし、SSR では、この UID の生成方法について懸念があった。
サーバー側で生成された UID と、Hydration 時の UID が一致しないケースが考えられるから。
Hydration では、属性の値が一致しない時に修正される保証はないので、ID 参照に依存するコンポーネントを作成するには無視できない問題。
例えば、コンポーネント内部で uuid
を生成した時の挙動を見てみる。
下記では uuid
を生成して、htmlFor
と id
属性にそれぞれ結び付けている。
InputField.tsx
/* ----------省略---------- */
export const InputSample: React.FC<Props> = props => {
const uid = React.useMemo(() => uuid.v4(), [])
return (
<>
<label htmlFor={uid}>{props.label}</label>
<input id={uid} name="inputSample" type="text" />
</>
)
}
しかし、サーバー/クライアントで uid
の戻り値が一致しないため、警告が表示される。
下記のようなインクリメントだとどうなるのか。
クライアントがカウントをリセットしても、サーバーではカウントされ続けるので、結局は不一致が生じる。
InputField.tsx
import * as React from 'react'
let count = 0 // カウント
export const InputSample: React.FC<Props> = props => {
const uid = `uid_${count++}` // リロードでずれる
return (
<>
<label htmlFor={uid}>{props.label}</label>
<input id={uid} name="inputSample" type="text" />
</>
)
}
downshift では、インクリメントの問題に対してコンポーネントにid
を渡す方法で対処しているが、これは結局、最初の項目で挙げたような UID 管理の問題。
コンポーネントの使用者を混乱させ、 Issue が立ってしまうかも。
react-uid は、 Context API から ID を流し込む手法を取り入れている。
専用の Consumer で囲めば、サーバー/クライアント間で一貫した UID が生成される。
しかし、結局この方法も、開発者が UID を生成するための Wrapper で囲むことを強いられてしまう。
Server-side_friendly_UID
import {UIDReset, UIDConsumer} from 'react-uid';
<UIDReset>
<UIDConsumer>
{(id,uid) => (
<Fragment>
<input id={id} />
<label htmlFor={id} />
data.map( item => <li key={uid(item)}>{item}</li>)
</Fragment>
)}
</UIDConsumer>
</UIDReset>
このような背景もあってか「React 側で UID 生成できればいいよね」問題が以前から議論されていた。
https://github.com/facebook/react/issues/5867
そこから派生して RFC も出ていた。
https://github.com/reactjs/rfcs/pull/32
そして、useOpaqueIdentifier
の PR がマージされた。
https://github.com/facebook/react/pull/17322
useOpaqueIdentifier とは
useOpaqueIdentifier
は、Hydration 時の不一致を起こさない UID を生成する Hooks となる。
現在、この Hooks は experimental なので、使用する場合は experimental build をインストールする。
shell
npm install react@experimental react-dom@experimental
使用方法はシンプルで、React.unstable_useOpaqueIdentifier()
を呼びだすだけ。
foo.tsx
const Foo: React.FC = (props) => {
// @ts-ignore
const uid = React.unstable_useOpaqueIdentifier()
return <p id={uid}>props.children</p>
}
id 属性に入っている uid
の値を確認すると、r:0
という文字列が入っていることがわかる。
0
の部分は 36 進数で表され、コンポーネントが追加されたり、Route が切り替わるたびに更新される。
そして、リロードすると連番が両方とも 0 に戻るので、先に挙げたカウンターの不一致も防げる。
何をしてるの: 文字列の生成
挙動を見ると、r:0
から始まる一貫した UID を生成する Hooks のように思える。
しかし、この Hooks の役割は、Hydration 時に属性を更新し、再レンダリングを試みること。実際に生成される ID 自体は、サーバーとクライアントで別にある。
まず、サーバー側では R:
を含む何らかのプレフィックスと、インクリメントされた UID の結果を返す。
プレフィックスがあるのは、複数の SSR が混在することを想定しての仕様だと思われる。
packages/react-dom/src/server/ReactPartialRendererHooks.js
function useOpaqueIdentifier(): OpaqueIDType {
return (
(currentPartialRenderer.identifierPrefix || '') +
'R:' +
(currentPartialRenderer.uniqueID++).toString(36)
);
}
クライアント側では、似たような処理が ReactDOM
の makeClientId
にある。
やっていることはサーバー側と同じだけど、プレフィックスは小文字の r:
のみで、サーバーの出力とは異なることがわかる。
packages/react-dom/src/client/ReactDOMHostConfig.js
let clientId: number = 0;
export function makeClientId(): OpaqueIDType {
return 'r:' + (clientId++).toString(36);
}
つまり生成時点では、r:
でなく R:
形式の文字列が ID や ARIA 属性に指定されている。
ブラウザの設定で JavaScript を無効化すると確認できる。
サーバー/クライアント間のプレフィックスはあえて合わせていない。
SSR と CSR が混在するケースで、ID の衝突を防ぐためだそう。
何をしてるの: OpaqueHydratingObject と Hooks
先ほどは UID の文字列を生成する処理を見たけど、実際にはサーバーとクライアントでそれぞれ別の ID を生成していることがわかった。
次は、 Hydration 時に生成される OpaqueHydratingObject
を見てから、Hooks の処理を調べる。
まず、 Hydration 時には、単なる UID の文字列ではなく、OpaqueHydratingObject
なるものを生成する。
このオブジェクトは React の要素の 1 つであることを示す $$typeof
Symbol を持ち、toString
と valueOf
をオーバーライドしている。
Object の toString
と valueOf
は、文字列へ変換される時などに呼び出されるものだけど、OpaqueHydratingObject
では代わりに attemptToReadValue
が発火する仕掛けとなっている。
packages/react-dom/src/client/ReactDOMHostConfig.js
export function makeOpaqueHydratingObject(
attemptToReadValue: () => void,
): OpaqueIDType {
return {
$$typeof: REACT_OPAQUE_ID_TYPE,
toString: attemptToReadValue,
valueOf: attemptToReadValue,
};
}
次は Hooks Fiber
の mountOpaqueIdentifier
。
長いので、ちょっとずつ見ていく。
まず最初の makeId
は、r:0
などの文字列を生成する makeClientId
関数。
Hydration でない時は、この文字列がそのまま mountState(id)
されて、 return される。
mountState(id)
は React.useState(id)
と同じ役割。
ReactFiberHooks.new.js
// ここで生成
const makeId = __DEV__
? makeClientIdInDEV.bind(
null,
warnOnOpaqueIdentifierAccessInDEV.bind(null, currentlyRenderingFiber),
)
: makeClientId;
if (getIsHydrating()) {
/* --------省略-------- */
} else {
// `makeClientId` をセットして return する
const id = makeId();
mountState(id);
return id;
}
Hydration 時はどうなるのか。
まず readValue
なる関数式が出てくるので、その中身を見ていく。
ReactFiberHooks.new.js
if (getIsHydrating()) {
let didUpgrade = false;
const fiber = currentlyRenderingFiber;
const readValue = () => {
if (!didUpgrade) {
// Only upgrade once. This works even inside the render phase because
// the update is added to a shared queue, which outlasts the
// in-progress render.
didUpgrade = true;
if (__DEV__) {
isUpdatingOpaqueValueInRenderPhase = true;
setId(makeId());
isUpdatingOpaqueValueInRenderPhase = false;
warnOnOpaqueIdentifierAccessInDEV(fiber);
} else {
setId(makeId());
}
}
// throw する
invariant(
false,
'The object passed back from useOpaqueIdentifier is meant to be ' +
'passed through to attributes only. Do not read the value directly.',
);
};
/* 続く */
invariant
は次のセクションで説明するが throw Error
で、不正な Hooks の使用を防ぐためにある。
setId(makeId())
についても後述する。
その後、話題の OpaqueHydratingObject
を生成する。引数に渡すのは readValue
。
ReactFiberHooks.new.js
/* 上のコードの続き */
// OpaqueHydratingObject
const id = makeOpaqueHydratingObject(readValue);
// setState
const setId = mountState(id)[1];
// Legacy Mode なら useEffect のような操作をして setID を実行
if ((currentlyRenderingFiber.mode & BlockingMode) === NoMode) {
currentlyRenderingFiber.effectTag |= UpdateEffect | PassiveEffect;
pushEffect(
HookHasEffect | HookPassive,
() => {
setId(makeId());
},
undefined,
null,
);
}
return id;
}
setId
は readValue
でも出てきたものだけど、mountState
の dispatch 、要するに useState
の setState にあたる。
OpaqueHydratingObject
を id として return する。
オマケで return 前のビット演算については、レンダリングが BlockingMode
(Concurrent Mode
への移行向けモード)以上の新しい mode でない場合は Legacy Mode
とみなし、useEffect
相当の操作を行うらしい。
なんで OpaqueHydratingObject なの
長々と書いたけど、やることは UID の文字列か OpaqueHydratingObject
をセットするだけ。
なぜ Hydration 時は文字列でなく、OpaqueHydratingObject
なのか。
ひとつは、Hydration 中の ID を、文字列ではなく Opaque ポインタのように扱う狙いがあるのだと思われる。
OpaqueHydratingObject
は、バリデーション時にサーバーから渡される ID さえも上書きしていて、DEV 環境で Hydration 不一致の警告を出さない理由にもなっている。
本当に必要な ID は再レンダリング後のものなので、readValue
などで直接参照するまでは隠蔽するのだと思っている。
この方法は id 属性の値に意味を持たせる必要がないからできること。
連番の情報も必要だと、生成順が保証されないこの方法は厳しそう。
例えば Concurrent Mode では複数のコンポーネントが Suspend されたり、ネストされたコンポーネントがさらに Suspend されていることも考えられる。
Hydration も様々で、部分的な Hydration もあれば、将来的には Progressive Hydration もある。
今回のケースでは、完璧な ID を生成する魔法を考えるより、ID の生成と再レンダリング処理を押し込めて、随時置き換えさせる試みの方が、現実的に思える(思えてきた)。
もうひとつは、この Hooks が「ID 参照」の問題を解決することにユースケースを絞っているから。
あくまでアクセシビリティ対応のためにあり、sessionID の生成などに使うのを意図していない。
実際にこの Hooks は、属性の値を設定する以外の用途で使うことを禁じている。
例えば先の項目で触れた invariant
は、JSX 内で値が不正に読み込まれていれば、readValue
経由で throw
される。
packages/react-dom/src/client/ReactDOMHostConfig.js
invariant(
false,
'The object passed back from useOpaqueIdentifier is meant to be ' +
'passed through to attributes only. Do not read the value directly.',
);
そうでなくとも makeClientId
には DEV 環境限定でデバッグ用の関数が入っており、開発者が文字列化させようとすると警告が出るので、警告さえ読まれれば、間違った用途で使われることはない。
packages/react-dom/src/client/ReactDOMHostConfig.js
export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType {
const id = 'r:' + (clientId++).toString(36);
return {
// 勝手に文字列にしたら怒られる
toString() {
warnOnAccessInDEV();
return id;
},
valueOf() {
warnOnAccessInDEV();
return id;
},
};
}
もうひとつ、オブジェクトの toString()
を実行させて間接的に throw
させ、再レンダリングを狙うような実装も見られた。 ReactDOMComponent
の diffProperties
では、nextProp
がこの $$typeof
かを調べ、toString()
を発火させている。
Concurrent Mode
向けの処理で、ID を強制的に更新させる狙いがあるとは思うけど、本当は違うのかもしれない、発火しないので…。
packages/react-dom/src/client/ReactDOMComponent.jsより
else if (
typeof nextProp === 'object' &&
nextProp !== null &&
nextProp.$$typeof === REACT_OPAQUE_ID_TYPE
) {
// If we encounter useOpaqueReference's opaque object, this means we are hydrating.
// In this case, call the opaque object's toString function which generates a new client
// ID so client and server IDs match and throws to rerender.
nextProp.toString();
}
感想
何もわからんけど便利そう。
useOpaqueIdentifier
が ID 参照に関する Hooks というのは、説明しないとわからないレベル。
最初は useUniqueId
という名前だったけど、それぞれ異なる場所から Reference
する意味が強く、 useOpaqueReference
という名前に変わった。しかし今度は ref
オブジェクトとして扱う印象を与えてしまうので、現在の useOpaqueIdentifier
という名前になった。
それでもユースケースを的確に表せているとは言い難く、さらに変わってもおかしくなさそう。
アクセシビリティの闇でもあるこの問題に、ライブラリ側が解決に取り組むのはなかなか斬新で、React のアクセシビリティ対応への手厚さと、現時点でもだいぶ実用的な Hooks を開発したコアメンバーの腕力を感じる。
もちろん不安定なところもあり、これからは IDREFs(ARIA 属性に複数の ID を指定する)への対応などもあるようなので、もうしばらく追い続けようと思っている。
参考
React Hooks の useState がどういう原理で実現されてるのかさっぱりわからなかったので調べてみた - Subterranean Flower Blog