React 项目之 onClick 事件不触发

之前在修改项目问题的时候,发现在聚焦状态下,点击页面的按钮,其 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>
	);
}

👉 codesandbox 地址

补充说明:输入框的失焦事件,会触发一些事件,比如自动保存,校验等逻辑

猜测一:事件被堵塞了

一开始猜测是不是业务里 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 ,而后在点击按钮,并没有触发事件,需要再次点击才会触发,回到了开始的问题。

但发现在按钮上的 onMouseDownonMouseUp 事件又是能正常触发的:

经过一顿胡思乱想,猜测这个 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 事件,那么解决方案也有多种:

  1. onBlur 里会改变状态的组件进行延期 80ms 以上
  2. onClick 改成 onMouseDown 或者 onMouseUp,这样也能正常触发
  3. 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 对象即可

相关推荐
秃头女孩y5 小时前
【React中最优雅的异步请求】
javascript·vue.js·react.js
前端小小王11 小时前
React Hooks
前端·javascript·react.js
迷途小码农零零发11 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
不是鱼15 小时前
构建React基础及理解与Vue的区别
前端·vue.js·react.js
飞翔的渴望18 小时前
antd3升级antd5总结
前端·react.js·ant design
╰つ゛木槿21 小时前
深入了解 React:从入门到高级应用
前端·react.js·前端框架
用户30587584891251 天前
Connected-react-router核心思路实现
react.js
哑巴语天雨2 天前
React+Vite项目框架
前端·react.js·前端框架
初遇你时动了情2 天前
react 项目打包二级目 使用BrowserRouter 解决页面刷新404 找不到路由
前端·javascript·react.js
码农老起2 天前
掌握 React:组件化开发与性能优化的实战指南
react.js·前端框架