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 对象即可

相关推荐
摘星编程1 小时前
React Native + OpenHarmony:UniversalLink通用链接
javascript·react native·react.js
qq_177767371 小时前
React Native鸿蒙跨平台数据使用监控应用技术,通过setInterval每5秒更新一次数据使用情况和套餐使用情况,模拟了真实应用中的数据监控场景
开发语言·前端·javascript·react native·react.js·ecmascript·harmonyos
烬头88211 小时前
React Native鸿蒙跨平台应用实现了onCategoryPress等核心函数,用于处理用户交互和状态更新,通过计算已支出和剩余预算
前端·javascript·react native·react.js·ecmascript·交互·harmonyos
2601_949593654 小时前
基础入门 React Native 鸿蒙跨平台开发:卡片组件
react native·react.js·harmonyos
qq_177767375 小时前
React Native鸿蒙跨平台剧集管理应用实现,包含主应用组件、剧集列表、分类筛选、搜索排序等功能模块
javascript·react native·react.js·交互·harmonyos
qq_177767375 小时前
React Native鸿蒙跨平台自定义复选框组件,通过样式数组实现选中/未选中状态的样式切换,使用链式调用替代样式数组,实现状态驱动的样式变化
javascript·react native·react.js·架构·ecmascript·harmonyos·媒体
烬头88216 小时前
React Native鸿蒙跨平台采用了函数式组件的形式,通过 props 接收分类数据,使用 TouchableOpacity实现了点击交互效果
javascript·react native·react.js·ecmascript·交互·harmonyos
qq_177767376 小时前
React Native鸿蒙跨平台通过Animated.Value.interpolate实现滚动距离到动画属性的映射
javascript·react native·react.js·harmonyos
●VON7 小时前
React Native for OpenHarmony:项目目录结构与跨平台构建流程详解
javascript·学习·react native·react.js·架构·跨平台·von
qq_177767377 小时前
React Native鸿蒙跨平台实现消息列表用于存储所有消息数据,筛选状态用于控制消息筛选结果
javascript·react native·react.js·ecmascript·harmonyos