关于我明明用了ref还是陷入React闭包陷阱

js 复制代码
const appendChildren = (
  nodes: DataNode[],
  targetKey: React.Key,
  children: DataNode[],
): DataNode[] =>
  nodes.map((node) => {
    if (node.key === targetKey) {
      return {
        ...node,
        children,
      };
    }
    if (node.children && node.children.length) {
      return {
        ...node,
        children: appendChildren(node.children, targetKey, children),
      };
    }
    return node;
  });


export const DepartmentTree = forwardRef<
  DepartmentTreeRef,
  DepartmentTreeProps
>(function DepartmentTree(props, ref) {
  const treeDataRef = useRef<DataNode[]>([]);
  treeDataRef.current = treeData;

  // useEffect(() => {
  //   treeDataRef.current = treeData;
  // }, [treeData]);

  useEffect(() => {
    let shouldIgnore = false;

    const loadRootData = async () => {
      try {
        const rootRequest = newRequest({
          cache: true,
          ...(searchParams ?? {}),
        });
        const rootRes: any = await rootRequest;
        if (shouldIgnore) return;
        const finalTreeData = normalizeNodes(rootRes?.data?.data || []);
        setTreeData(finalTreeData);
      } catch (error) {
        console.warn( error);
      }
    };

    loadRootData();

    return () => {
      shouldIgnore = true;
    };
  }, [searchParams, getAllRootDeptFullPaths]);

  // 异步加载子节点
  const onLoadData: TreeProps['loadData'] = ({ key, children }) =>
    new Promise<void>((resolve) => {
      if (!key || (children && children.length > 0)) {
        resolve();
        return;
      }
      const newSearchParams = _cloneDeep(searchParams ?? {});
      _set(newSearchParams, 'data.code', key);
      newRequest({ cache: true, ...newSearchParams })
        .then((res: any) => {
          const childrenNodes = normalizeNodes(res?.data?.data || []);

          setTreeData((prev) => {
            const newData = appendChildren(prev, key, childrenNodes);
            // 关键
            treeDataRef.current = newData;
            return newData;
          });
        })
        .catch((error) => {
          console.warn( error);
        })
        .finally(() => resolve());
    });
    
  const onExpand = (nextExpandedKeys: React.Key[]) => {
    setExpandedKeys(nextExpandedKeys);
    setAutoExpandParent(false);
  };

  const findNodeByKey = (
    nodes: DataNode[],
    targetKey: React.Key,
  ): DataNode | null => {
    for (const node of nodes) {
      // 如果当前节点的ID就是我们要找的,直接返回这个节点
      if (node.key === targetKey) return node;
      // 如果当前节点有子节点,就递归查找子节点
      if (node.children && node.children.length) {
        const child = findNodeByKey(node.children as DataNode[], targetKey);
        if (child) return child;
      }
    }
    return null;
  };

  const expandPath = useCallback(
    async (pathKeys: string[]) => {
      // 遍历路径中的所有节点ID(除了最后一个,因为最后一个不需要展开)
      for (const key of pathKeys.slice(0, -1)) {
        const current = findNodeByKey(treeDataRef.current, key);
        if (!current) continue;
        // 根据pathKeys顺序,一层层加载子节点数据
        if (!current.children || current.children.length === 0) {
          await onLoadData({ key, children: current.children } as any);
        }
      }
      setExpandedKeys(pathKeys.slice(0, -1));
      setAutoExpandParent(true);
    },
    [onLoadData],
  );

  const handleSearchSelect = useCallback(
    async (value: string, option: any) => {
      const targetKey = option?.deptId ?? option?.value ?? value;
      const path = String(option?.deptFullPath ?? '')
        .split('/')
        .filter(Boolean);

      if (path.length > 0) {
        await expandPath(path);
      }

      setSelectedKeys([targetKey]);
      setSearchValue(value);
    },
    [onSelect, expandPath],
  );

  return (
    <Tree
        loadData={onLoadData}
        treeData={displayTreeData}
        blockNode
        onSelect={handleTreeSelect}
        onExpand={onExpand}
        expandedKeys={expandedKeys}
        autoExpandParent={autoExpandParent}
        selectedKeys={selectedKeys}
     />
  );
});

这是一个非常经典且高阶的 "React 渲染周期 vs JavaScript 执行顺序" 的问题。

简单来说:JavaScript 的代码执行速度(微任务队列)快于 React 的重新渲染速度。

仅仅在组件主体(或 useEffect)中写 treeDataRef.current = treeData 是不够的,因为这两者之间存在一个致命的时间差

下面拆解这个"时间差"到底发生在哪里:

1. 场景重现:如果没有手动赋值

假设 expandPath 正在循环处理 ['A', 'B'] 两个节点(先加载 A,再加载 B)。

第一步:加载节点 A

  1. expandPath 循环开始,找到节点 A。
  2. 调用 await onLoadData(A)
  3. 请求回来,数据有了。
  4. 执行 setTreeData(newData)
    • 注意: 此时 React 仅仅是**"收到了更新通知"**,它把这次更新放入了队列,准备稍后重新渲染组件。
    • 关键点: 组件还没有重新渲染!所以组件最上面的 treeDataRef.current = treeData 这行代码还没有运行!Ref 里存的还是树。
  5. onLoadData 的 Promise 结束(resolve)。

第二步:灾难发生(加载节点 B)

  1. await 结束,JS 引擎立刻继续执行下一行代码(for 循环进入下一次迭代)。
  2. 循环尝试处理节点 B。
  3. 执行 const current = findNodeByKey(treeDataRef.current, 'B')
  4. 问题来了: 此时 React 还没有来得及 执行下一次渲染。treeDataRef.current 依然是的(里面还没有 A 的子节点)。
  5. 结果:找不到节点 B,逻辑中断,展开失败。

第三步:马后炮(React 终于渲染了)

  1. JS 的同步代码和微任务跑完后,React 终于开始 Re-render。
  2. 组件函数重新运行。
  3. 执行 treeDataRef.current = treeData(这时候 Ref 才变成新的)。
  4. 但此时 expandPath 的循环早就跑完了,黄花菜都凉了。

2. 为什么手动赋值能解决?

当写成这样时:

javascript 复制代码
setTreeData((prev) => {
  const newData = appendChildren(prev, key, childrenNodes);
  
  // 【关键操作】强行插队
  // 不等 React 渲染,直接在 JS 层面立刻更新 Ref
  treeDataRef.current = newData; 
  
  return newData;
});

流程变成了:

  1. expandPath 调用 onLoadData
  2. 请求回来,执行 setTreeData 的回调。
  3. 立刻 更新 treeDataRef.current = newData(这是同步的 JS 操作,不需要等 React 渲染)。
  4. onLoadData Promise 结束。
  5. expandPath 继续循环。
  6. 循环取 treeDataRef.current,此时它已经是最新的了(因为我们在第3步强行更新了它)。
  7. 成功找到下一个节点。

总结

  • treeDataRef.current = treeData (写在组件体内) :依赖于 React 的渲染周期。只有等 React 画完下一帧,Ref 才会变。
  • expandPath 中的 await 循环 :依赖于 JS 的 Promise 队列。它的速度极快,不会等待 React 的渲染。

结论: 因为 for 循环跑得太快,不等 React 渲染就要用新数据,所以必须在数据产生的源头(setTreeData 回调里)手动、同步地把最新数据塞进 Ref 里,才能追上循环的脚步。

相关推荐
提子拌饭1334 分钟前
个人月事记录表应用 - 鸿蒙PC Electron框架完整实现指南
前端·javascript·华为·electron·前端框架·开源·鸿蒙系统
YHL12 分钟前
📚 JS执行机制(执行上下文 + 调用栈 + 编译流程)
前端·javascript
不简说18 分钟前
这次真香!sv-print 可视化打印设计器更新:插件脚手架、Excel 导出、弹窗 API 三连发
前端·javascript·前端框架
无聊的老谢22 分钟前
Web GIS 最佳实践:Vue 集成 Leaflet/OpenLayers 实现基站海量点位渲染
前端·javascript·vue.js
yingyima26 分钟前
GCP Cloud Scheduler 核心语法与实战示例速查手册
前端
用户573501072520626 分钟前
Elpis 项目阶段性总结 - 基于 vue3 完成领域模型架构建设
前端
假如让我当三天老蒯33 分钟前
为什么 setData 能获取到 prev 参数?(自学用)
前端·react.js
AskHarries1 小时前
Workspace:文件系统、项目上下文和执行边界
java·服务器·前端
Aphasia3111 小时前
从内存模型看深浅拷贝
前端·javascript·面试
IT策士1 小时前
第45篇 k8s之实战:将 Web 应用迁移到 Kubernetes(下)
前端·容器·kubernetes