需求 与 Bug
项目的 C# 桌面端使用 CefSharp 内嵌了一个三方网站,在外部实现了一个登录控件,外部登录后希望内嵌的三方网站自动登录,实现代码如下:
browser.ExecuteScriptAsync($"document.getElementsByName('username')[0].value = '{_login}'");
browser.ExecuteScriptAsync($"document.getElementsByName('password')[0].value = '{_pwd}'");
browser.ExecuteScriptAsync($"document.getElementsById('submitBtn').click()");
通过 CefSharp 提供的方法在外部执行脚本,将登录控件的值填充至网站,旧版中正常,三方网站改版后无法正常填充。
尝试解决
直接修改 input 的 value 值无效,通过控制台 sources 面板查看,发现网站用 React 重构:
React 是状态驱动的视图库,直接修改元素的 value 值不会导致 React 应用状态的改变,我们需要让 React 感知到数据变化,即事件模拟:
const inputEl = document.querySelector('input');
inputEl.value = "changed";
inputEl.dispatchEvent(new Event('input', { bubbles: true, cancelable: false, composed: true }));
结果是页面显示已经改变:
但内部的状态依然未变:
查询资料找到一个 React Issues,有人在讨论中给出解决方案 Trigger simulated input value,代码如下:
let input = someInput;
let lastValue = input.value;
input.value = 'new value';
let event = new Event('input', { bubbles: true });
// hack React15
event.simulated = true;
// hack React16 内部定义了 descriptor 拦截 value,此处重置状态
let tracker = input._valueTracker;
if (tracker) {tracker.setValue(lastValue);
}
input.dispatchEvent(event);
问题解决。
知其然而知其所以然
上述问题激起了个人好奇心,尝试通过调试理解模拟输入事件无效的原因。
通过控制台 -> Elements -> Event Listeners 面板,找到 input 事件,查看已注册的事件处理程序:
React 实现了自己的合成事件系统,几乎所有的事件统一注册在 React 应用根元素上,这里即是截图中的 root 元素,点击右侧链接定位至源码:
所有事件都经过此函数,避免时间浪费,我们加上条件,只有 input 事件才会断点:
输入触发断点,中间会经过很多我们不关心的流程,最终进入 updateValueIfChanged
函数:
function getTracker(node) {return node._valueTracker;
}function getValueFromNode(node) {var value = '';if (!node) {return value;}if (isCheckable(node)) {value = node.checked ? 'true' : 'false';} else {value = node.value;}return value;
}function updateValueIfChanged(node) {if (!node) {return false;}// 获取 trackervar tracker = getTracker(node);if (!tracker) {return true;}// 获取上次的值var lastValue = tracker.getValue();// 获取变更值var nextValue = getValueFromNode(node);// 不同则更新,此处是关键节点if (nextValue !== lastValue) {tracker.setValue(nextValue);return true;}return false;
}
tracker 的定义如下:
function trackValueOnNode(node) {// 判断 input 类型var valueField = isCheckable(node) ? 'checked' : 'value';// 定义属性描述符var descriptor = Object.getOwnPropertyDescriptor(node.constructor.prototype, valueField);// 设置初始值var currentValue = '' + node[valueField];if (node.hasOwnProperty(valueField) ||typeof descriptor === 'undefined' ||typeof descriptor.get !== 'function' ||typeof descriptor.set !== 'function') {return;}var get = descriptor.get,set = descriptor.set;// 对 value 属性的获取与设置进行拦截Object.defineProperty(node, valueField, {configurable: true,get: function () {return get.call(this);},set: function (value) {{checkFormFieldValueStringCoercion(value);}/*** 注意此处,每当设置元素的 value 属性时,currentValue 也被修改,* 导致 updateValueIfChanged 函数始终返回 false*/currentValue = '' + value;set.call(this, value);}});Object.defineProperty(node, valueField, {enumerable: descriptor.enumerable});var tracker = {getValue: function () {return currentValue;},setValue: function (value) {{checkFormFieldValueStringCoercion(value);}currentValue = '' + value;},stopTracking: function () {detachTracker(node);delete node[valueField];}};return tracker;
}
我们来看看真实输入的流程:
- 触发输入
- 进入 updateValueIfChanged
- 获取旧值和新值
- 两者不同,设置 value,updateValueIfChanged 返回 true(重要)
再来看看模拟的输入流程:
- input.value = ‘changed’
- 触发 value 属性的拦截,将内部的 tracker currentValue 更新为 changed
- 触发输入事件
- 进入 updateValueIfChanged
- 获取旧值和新值
- 两者相同,updateValueIfChanged 返回 false(重要)
这里模拟输入时,新旧值是相同的,所以 updateValueIfChanged 函数会返回 false,当返回 false 时不会触发 React 的更新机制去更新状态,即无法进入下图的流程:
我们再来看看之前的解决方案:
// 暂存旧值
let lastValue = input.value;// 设置新值
input.value = 'new value';// ...// 获取 tracker
let tracker = input._valueTracker;if (tracker) {// 将 tracker 设置为 旧值,此时新旧值不同,updateValueIfChanged 会返回 truetracker.setValue(lastValue);
}// ...
—end