7

Ant Design 4.0 的一些杂事儿 - Menu 篇

 3 years ago
source link: https://zhuanlan.zhihu.com/p/373180973
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Ant Design 4.0 的一些杂事儿 - Menu 篇

《豆酱》漫画作者

在 antd 中,有一些语法糖组件不参与实际渲染或者有些特殊定制,导致其无法支持 HOC。比如 Table.ColumnSelect.OptionTabs.TabPaneMenu.SubMenu 等等。我们有一串列表来记录了这些组件:

A List of `antd`'s components that cannot work with HOC · Issue #4853 · ant-design/ant-design

最近 Menu 组件底层在进行重构,剥离边界处理直接修改 dom 的逻辑。同时也希望这次可以使得 Menu 组件可以支持 HOC,本文会就具体实现思路进行探索。

为何 HOC 难?

上述不支持 HOC 的组件的共同点就是我们会通过 props.children 来遍历获取当前组件的子组件的 propskey 数据来进行转换。我们拿一个典型的语法糖 Table.Column 举例:

const columns = [];
React.Children.forEach(props.children, (child) => {
  // 实际代码会做更多的检测,你可以查阅相关源码了解具体实现
  columns.push({
    ...child.props,
    key: child.key,
  });
});

return (
  <tr>
    {columns.map(column => (
      <th key={column.key}>{column.title}</th>
    ))}
  </tr>
);

你可以看到在实际渲染中,我们并没有直接使用用户传递过来的节点实例,而是只提取了其中的数据。这就导致了 HOC 写法无法生效:

const MyColumn = (props) => <Table.Column {...props} title="HOC" />

无法获得 title

<Table dataSource={list}>
  <MyColumn />
</Table>

来份 HOC

除了语法糖组件外,我们还有一部分组件介于语法糖与节点之间。比如说 Tabs.TabPaneMenu.SubMenu ,父层需要获取子节点的部分 props 信息以进行列表、交互相关的渲染。而剩余部分则是交由子组件自行渲染。 因而,我们希望对此进行改造,尝试将 HOC 支持引入进来。

为了支持 HOC,我们需要将遍历进行剥离。同时又确保父节点仍然可以正确获取子节点的 props 信息。因而一个直观的想法就是通过 context 进行注册,在介绍真正的 Menu 中数据收集实现前,我们假设现在有一个平铺的 Tree 组件需要收集每个 key 对应的路径信息:

const RegisterContext = React.createContext();

// ============================= 根节点 =============================
const Tree = ({ children }) => {
  const [pathData, setPathData] = React.useState(new Map());

  // 示例,具体实现可参考 `rc-menu` 源码
  const registerPath = (key, path) => {
    const clone = new Map(pathData);

    if (path) {
      clone.set(key, path);
    } else {
      clone.delete(key);
    }
    
    setPathData(clone);
  }

  return (
    <RegisterContext.Provider value={registerPath}>
      {children}
    </RegisterContext.Provider>
  );
};

// ============================= 子节点 =============================
const PathContext = React.createContext([]);

const TreeNode = ({ nodeKey, children }) => {
  const registerPath = React.useContext(RegisterContext);
  const parentPath = React.useContext(PathContext);

  // 链接路径
  const path = React.useMemo(
    () => [...parentPath, nodeKey],
    [parentPath, nodeKey]
  );

  // 注册节点
  React.useEffect(() => {
    registerPath(nodeKey, path);
    return () => registerPath(nodeKey);
  }, [nodeKey, path]);

  return (
    <PathContext.Provider value={path}>
      {children}
    </PathContext.Provider>
  );
};

那么,在节点被 mount 时。它的路径信息就可以被注册到根节点从而实现 HOC 能力。但是相对于遍历子节点,其缺点在于需要额外的渲染以实现完整的路径记录。但是好在我们可以通过 useEffect 将 N 个子节点的 setState 转换成一次性更新:

const [, forceUpdate] = React.useState();
const pathDataRef = React.useRef(new Map());

const registerPath = (key, path) => {
  // 更新部分同上...
  pathDataRef.current = clone;
};

// 利用 effect 本身异步性进行一次性更新
React.useEffect(() => {
  forceUpdate({});
}, [pathDataRef.current]);

// ...

在实际开发中,除非配置 forceRender 属性。antd 不会渲染尚未使用的节点,这也导致了上述逻辑对于没有渲染的节点并不会进行注册。 比如说 Menu 的弹出菜单:

在鼠标移入之前不会渲染,因而第一层节点也不知道子节点是否被选中了

因而,我们需要改造渲染逻辑。最直观的想法就是如果是不展示时,mount 节点渲染虚拟节点。展示时渲染 dom 节点:

const TreeNode = ({ title, nodeKey, visible, children }) => {
  // ...

  const childNode = (
    <PathContext.Provider value={path}>
      {children}
    </PathContext.Provider>
  );

  // 可见时渲染真实节点
  return visible ? (
    <div>
      {title}
      <div>{childNode}</div>
    </div>
  ) : childNode;
};

然而,这样会造成过多复杂性。在触发一些会影响 virtual dom tree 结构的变更时,也会触发路径的重新卸载与收集。对于 rc-menu 支持 Inline collapse 切换的组件,弹层和内联列表势必是两个不同的节点。同时为了体验优化,在模式切换时,我们会让内联列表完成动画后再卸载。这使得最终路径数据被卸载:

  1. 加载弹出列表(注册路径 并覆盖 现有路径)
  2. 收缩动画启动
  3. 收缩动画完成,卸载内联列表(卸载路径)
v2-0edad192e4cc7cf762d4f463f379eaca_b.jpg
为了平滑切换,我们对切换中的组件做了冗余

为了防止真实节点渲染与度量节点相互作用,我们可以额外添加一次子节点使其只做度量使用:

const MeasureContext = React.createContext(false);

// ============================= 根节点 =============================
const Tree = ({ children }) => {
  // ...
  return (
    <>
      {children}
      <MeasureContext.Provider value>
        {children}
      </MeasureContext.Provider>
    </>
  );
};

// ============================= 子节点 =============================
const TreeNode = () => {
  const isMeasureNode = React.useContext(MeasureContext);

  // ...

  // 注册节点
  React.useEffect(() => {
    if (!isMeasureNode) return;

    registerPath(nodeKey, path);
    return () => registerPath(nodeKey);
  }, [isMeasureNode, nodeKey, path]);

  // 如果是度量节点则只做结构渲染而不产生真实 dom 节点
  return isMeasureNode ? children : (
    <div>
      {title}
      <div>{childNode}</div>
    </div>
  );
}

度量节点不再因为 props 变化而改变 virtual dom tree,从而保证了其稳定性。从而将渲染与收集进行解耦。

在完成了一系列改造后,antd 仍然有一个历史债务需要解决。我们为了减少开发者记忆成本,复用了 key 属性作为唯一标识符。这也使得子组件渲染时其实不能从 props 里获取 key 。 在 Menu 内部,我们会通过 cloneElementprops 添加一个 eventKey 从而把父节点获取子节点的 key 传递下去。因而在用户侧进行 HOC 需要显式传递该属性:

const MyMenuItemList = () => (
  <>
    <Menu.Item {...props1} eventKey={props1.key} />
    <Menu.Item {...props2} eventKey={props2.key} />
  </>
);

<Menu>
  <MyMenuItemList />
</Menu>

除了 HOC 改进之外,新的 Menu 也优化了折行省略以及无障碍体验并支持全键盘交互。由于改动较大,我们仍在测试中。你可以通过安装 4.16.0-alpha.2 进行提前体验,也欢迎协助我们排查问题。谢谢大家~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK