关于我明明用了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 里,才能追上循环的脚步。

相关推荐
an317421 小时前
解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南
前端·javascript·vue.js
汪汪队长4 小时前
谷歌浏览器自定义油猴插件
前端
ZFSS4 小时前
SeeDance Tasks API 的对接和使用
前端·人工智能
睿智的仓鼠4 小时前
🦞OpenClaw 快速部署及使用指南
前端·人工智能
前端付豪4 小时前
Nest 项目小实践之图书增删改查
前端·node.js·nestjs
比特鹰4 小时前
手把手带你用Flutter手搓人生K线
前端·javascript·flutter
奔跑路上的Me4 小时前
前端导出 Word/Excel/PDF 文件
前端·javascript
bluceli4 小时前
JavaScript异步编程深度解析:从回调到Async Await的演进之路
前端·javascript
青青家的小灰灰4 小时前
Vue 3 新标准:<script setup> 核心特性、宏命令与避坑指南
前端·vue.js·面试