背景#
最近手間を省くために油猴スクリプトを使って自動的にフォームを入力するタスクを作成しました。
直接そのノードを見つけて、対応する value を変更しても効果がなく、再度フォーカスすると元の値が戻ってしまいます。これはなぜでしょうか?
図と文は無関係です。
过程#
React フレームワークの使用範囲が広いため、input コンポーネントは非常に基本的です。
この問題はすでに解決されているかもしれないので、まずは検索してみることにしました。
まずはブログや某有名な四文字の盗用サイトを調べました。
信頼できる方法はほとんど見つからず、何を発信しても効果がありませんでした。
ただ、一つのウェブページでは React 開発のウェブページが下の_valueTracker 属性を呼び出すと効果があると書かれていました。
私は調整しましたが、効果はありませんでした。
しかし、この問題はキーボード入力をシミュレートすることではないでしょう。
問題は解決しませんでしたが、ウェブページが React フレームワークで作られていることは分かりました。
このような偶然の方法の他にも、wappalyzer プラグインを使用することができますが、すべての機能をアンロックするには料金が必要です。
しかし、実際には手動で <head> を確認することも不可能ではないと思います。
この基本的な問題は解決できないとは思えません。
運試しに React のソースコードを調べてみると、解決方法が input コンポーネントの単体テストに書かれていることが分かりました。
大規模なオープンソースプロジェクトは、コメントがコードよりも多く、ファイル名が明確で理解しやすいので、規範的で美しいです。
// https://github.com/facebook/react/blob/main/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js#L273
const setUntrackedValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
).set;
...
// プログラム的に設定します。
input.value = 'bar';
// DOMのinputイベントが発火しても、Reactは実際のinput値が
// ('bar')がすでに記録された「現在の」値と同じであることを認識します。
input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
expect(input.value).toBe('bar');
// この場合、Reactイベントが発生することは期待していません。
expect(called).toBe(0);
// しかし、基礎となるセッターを呼び出すことでユーザーの入力をシミュレートできます。
setUntrackedValue.call(input, 'foo');
// 今、イベントが発火すると、実際のinput値('foo')は
// 以前に記録された「現在の」値('bar')と異なります。
input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
expect(input.value).toBe('foo');
// この場合、Reactはそれに対してイベントを発火させるべきです。
expect(called).toBe(1);
原因#
では、なぜ直接 value 属性を設定しても効果がないのでしょうか?
私たちは、JavaScript が批判される理由がないわけではないことを知っています。
私たちの現代 lisp はどうしたのか
さまざまなトリッキーな操作を実現できます。
さまざまな変数が混在し、ES6 以前はほとんどスコープの制限がありませんでした。
他のコードの属性を簡単に変更したり、undefined を置き換えたりすることができます。
有名な evil リポジトリはこの点を利用してサプライチェーン攻撃を行っています。
React が ReactDOMInput コンポーネントをラップする際に、
ブラウザのネイティブ input コンポーネントの value に対応する descriptor を置き換えました。
// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/inputValueTracking.js#L79
Object.defineProperty(node, valueField, {
configurable: true,
// $FlowFixMe[missing-this-annot]
get: function () {
return get.call(this);
},
// $FlowFixMe[missing-local-annot]
// $FlowFixMe[missing-this-annot]
set: function (value) {
if (__DEV__) {
checkFormFieldValueStringCoercion(value);
}
currentValue = '' + value;
set.call(this, value);
},
});
// 最初にこれを渡すことができましたが、
// それはIE11とEdge 14/15でバグを引き起こします。
// defineProperty()を再度呼び出すことは同等であるべきです。
// https://github.com/facebook/react/issues/11768
Object.defineProperty(node, valueField, {
enumerable: descriptor.enumerable,
});
const tracker = {
getValue() {
return currentValue;
},
setValue(value: string) {
if (__DEV__) {
checkFormFieldValueStringCoercion(value);
}
currentValue = '' + value;
},
stopTracking() {
detachTracker(node);
delete node[valueField];
},
};
return tracker;
ここで currentValue
は input コンポーネントの値の変化を追跡し、tracker getValue
の戻り値です。
直接value
属性を設定すると、set
メソッドに進み、同時にcurrentValue
を設定し、ネイティブのset
メソッドを呼び出します。
tracker が追跡するcurrentValue
とネイティブ input コンポーネント内のvalue
値は同じです。
そのため、input イベントが発火した後でも、外部から強制的に書き込まれた値は React 内部の値を変更しません。
// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/inputValueTracking.js
export function updateValueIfChanged(node: ElementWithValueTracker): boolean {
if (!node) {
return false;
}
const tracker = getTracker(node);
// この時点でトラッカーがない場合、再試行しても成功する可能性は低いです。
if (!tracker) {
return true;
}
const lastValue = tracker.getValue(); // 注:currentValue
const nextValue = getValueFromNode(node); // 注:input.value
if (nextValue !== lastValue) {
tracker.setValue(nextValue);
return true;
}
return false;
}
input が再度フォーカスを得ると、ReactDOMInput
のrestoreControlledInputState
が呼び出され、ネイティブコンポーネントに強制的に書き込まれた値が React に記録された値で上書きされます。
// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactDOMInput.js#L340
export function restoreControlledInputState(element: Element, props: Object) {
const rootNode: HTMLInputElement = (element: any);
updateInput(
rootNode,
props.value,
props.defaultValue,
props.defaultValue,
props.checked,
props.defaultChecked,
props.type,
props.name,
);
...
}
では、なぜcurrentValue
のメカニズムを追加する必要があるのでしょうか?React の開発者たちが油猴スクリプトを好まないからでしょうか?
git blame
を使ってpackages\react-dom\src\events\plugins\__tests__\ChangeEventPlugin-test.js
の最後のコミットを確認すると、ディレクトリ構造の整理が行われたことが分かります。ここには、このメカニズムを追加する理由を詳しく説明したコメントがありました。
// https://github.com/facebook/react/commit/a67757e11540936677b0a5d89d623b3e38b5fe69#diff-76a986d539bc7c94d63e46233a9537e03d8994f69ac4e4cd332631bc911a1c09R81
// "重複"するReactの変更イベントを発火させないようにします。
// しかし、どのイベントが重複していて無視されるべきかを判断するために、
// "現在の"入力値を追跡しており、変更があった後に発生するイベントのみを尊重します。このテストでは、プログラム的に設定しても
// "現在の"値を追跡できることを確認します。
// プログラム的に設定します。
input.value = 'bar';
2016 年には、このメカニズムは onChange 属性が設定された Input コンポーネントにのみ適用されました。
このコメントから、次の結論が得られます:
このメカニズムを追加したのは、当初は change イベントの重複を避けるためであり、input.value
の他にcurrentValue
を追加して、onChange にバインドされた関数が複数回トリガーされるのを防ぐためでした。
感想#
オープンソースは業界と個人の成長に非常にポジティブな影響を与えています。
問題に直面したとき、自分で調べて学ぶことができます。
他人からの三次資料や曖昧な閉じられたソフトウェアの文書から得られる無力感を感じるよりも、時間をかけて学ぶことができます。