之前在修改项目问题的时候,发现在聚焦状态下,点击页面的按钮,其 onClick
事件不会触发,需要在次点击,才会触发事件,效果如果:
精简过后的代码如下:
typescript
import React, { useState } from "react";
export default function App() {
const [value, setValue] = useState(0);
const Comp1 = () => (<button onClick={() => console.log("****** Comp1")}>comp1: {value}</button>);
return (
<div className="App">
<div>失焦次数:{value}</div>
<input onBlur={() => setValue(value + 1)} />
<Comp1 />
</div>
);
}
补充说明:输入框的失焦事件,会触发一些事件,比如自动保存,校验等逻辑
猜测一:事件被堵塞了
一开始猜测是不是业务里 onBlur
事件处理的任务太长堵塞浏览器的 js 线程,导致浏览器不能响应 click 事件,于是给整个 blur 事件加上 setTimeout
或异步函数来处理,发现依然不能正常。
typescript
export default function App() {
// ...
return (
<div className="App">
<div>失焦次数:{value}</div>
<input onBlur={async () => await setValue(value + 1)} /> {/* 改动这里 */}
<Comp1 />
</div>
);
}
接着网上检索了一下,发现有相关的问题,但没给出相关的原因
于是把 setTimeout
的时间延迟了一些,发现就可以了:
代码如下:
typescript
export default function App() {
// ...
return (
<div className="App">
<div>失焦次数:{value}</div>
<input
onBlur={() => {
/* 改动这里 */
setTimeout(() => {
setValue(value + 1);
}, 100);
}}
/>
<Comp1 />
</div>
);
}
也顺便测试了一下,在 onBlur
事件触发到 onClick
事件触发,中间有大概 80ms 左右的空闲时间(可能和运行环境有关系)
这说明不是堵塞的问题,应该是什么代码原因导致 react 的事件没能正常触发,进一步分析发现是代码里有一个的 setState
操作引起了 rerender
导致,莫非这里的 rerender,导致 react 的合成事件失效?
猜测二:合成事件被取消了
我们知道 react 合成事件是在挂载容器上通过捕获和冒泡去实现的,可以在挂载容器上监听 click
事件,看看原生事件是否正常触发,在控制台或者入口文件可以监听下挂在节点的 click
事件:
typescript
const rootElement = document.getElementById("root");
rootElement.addEventListener("click", () => console.log("root click"));
ReactDOM.render(<App />,rootElement);
可以看到在点击输入框的时候会触发 root click
,而后在点击按钮,并没有触发事件,需要再次点击才会触发,回到了开始的问题。
但发现在按钮上的 onMouseDown
和 onMouseUp
事件又是能正常触发的:
经过一顿胡思乱想,猜测这个 rerender
改变了这个按钮 dom
,使得 click
前后不是同一个 dom
所以不触发。
猜测三:rerender 改变了 dom
看回开头的代码,这个 Comp1
组件是声明在函数内的,也就是每次 rerender
都会重新声明,但看起来也和普通函数组件没有声明区别,但前后并没有什么变化,于是我加上了 key
来保证渲染前后去复用同一个节点,发现依然有同样的问题,那么只能看一下 diff 过程
typescript
import React, { useState } from "react";
export default function App() {
const [value, setValue] = useState(0);
const Comp1 = () => (<button onClick={() => console.log("****** Comp1")}>comp1: {value}</button>);
return (
<div className="App">
<div>失焦次数:{value}</div>
<input onBlur={() => setValue(value + 1)} />
<Comp1 key="Comp1" />
</div>
);
}
首先看一下 <Comp1 />
被 React.createElement
创建之后的 element
对象
然后打个断点,发现在 diff 时比较两个节点能否复用,除了 判断key 之外,还会判断节点 type,来看一下代码: 如果没有 key
,则 key
都为 null,所以默认 key
的对比都是相同的。
接着进入 updateElement
中,会比较 type
是否一致,而函数式组件的 element.type
是函数本身,上面 Comp1
每次都重新创建,自然每次比较都不一样,所以就不会复用而是 重新创建 了,在点击前后这个按钮就不是同一个 dom
了
typescript
function updateElement(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
if (current !== null) {
// 比较 current 树的elementType 和 新的fiber树的节点的 type 是否一致
if (current.elementType === element.type) {
const existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
return existing;
}
// 重新创建
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
解决方案
到这里已经知道问题原因是什么了,简单概括就是组件声明在函数内的时候,每次渲染都会是一个新的节点,而不是会复用,所以在 blur
事件引起了 rerender
后,等 mouseUp
时已经不是同一个节点了,所以就不会触发 onClick
事件,那么解决方案也有多种:
- 对
onBlur
里会改变状态的组件进行延期 80ms 以上 - 将
onClick
改成onMouseDown
或者onMouseUp
,这样也能正常触发 - 将
Comp1
提取到外部,使element.type
的引用前后都是一样的,具体可以在 这里尝试,代码:
typescript
import React, { useState } from "react";
const Comp2 = ({ s }) => (
<button onClick={() => console.log("****** Comp2")}>comp2: {s}</button>
);
export default function App() {
const [value, setValue] = useState(0);
const Comp1 = () => (<button onClick={() => console.log("****** Comp1")}>comp1: {value}</button>);
return (
<div className="App">
<div>失焦次数:{value}</div>
<input onBlur={() => setValue(value + 1)} />
<Comp1 key="Comp1" />
<Comp2 s={value} />
</div>
);
}
最佳的方案当然是第 3 种,不然每次 rerender
都会导致都要先删除旧的节点,在插入新的节点,这种操作成本比较大,会影响性能。
什么,你问我选择了那种方案? 当然是第一种(狗头),听我解 (jiao) 释 (bian),原项目里之所以要把组件声明在内部,是因为要消费内部的变量,对这些变量做一些处理,但可以不用组件的形式,直接写成一个 React.Element
对象即可