5分钟学会!antd5自研的css-in-js的定制主题原理

lxf2023-03-16 07:56:01

前言

antd4以前的版本用less的变量来实现多套less模板,来渲染主题。局限性很大,所以antd5做了新的官方的css-in-js的主题实现,来控制更复杂的主题业务场景,接着我会展示源码细节并深入学习

我的理解

按我的理解css-in-js就是将通过序列化css的属性名来实现css的模块化,所以你会看到webpack可以编译module.css的文件来隔离, 包括其他emotion、styled-components的cssinjs的方案的大放异彩,js控制样式自然灵活度极高,所以你几乎看不到css文件,但是同时你的项目内会有大量的js的编译代码,说白了打包出来的js文件会很大。antd5区别于其他方案,做了各种缓存,以及更可控的组件级别的控制相比性能对于组件库的层面是要优于其他css-in-js的方案的

困境

css-in-js最大的问题在于你编写的css样式类名经过模块化后是不一致的,每次在react的重渲染下,大量的props带来额外性能开销也是很明显的。

Antd的css-in-js优缺点

Antd最大的特点是组件级别的缓存,那么hash最终的结果要受到组件名 + token + version 等,这么做很明显是为了props防止重渲染。对于我们的场景的业务场景你要考虑好组件名的唯一,也就是hash的唯一性,对于大型web应用管理hash也是一件头疼的事情。

5分钟学会!antd5自研的css-in-js的定制主题原理

源码

首先在任意一个antd5的component中你可以看到这段代码,useStyle返回的wrapSSR和hashId,hashId会被用于classNames的序列化样式属性中,那么重点是wrapSSR这个函数,函数传入了react的元素节点,大概率也能想到传入的节点要混入style对象

5分钟学会!antd5自研的css-in-js的定制主题原理

5分钟学会!antd5自研的css-in-js的定制主题原理

//./style/index.tsx
export default genComponentStyleHook('Collapse', (token) => {
  const collapseToken = mergeToken<CollapseToken>(token, {
    collapseContentBg: token.colorBgContainer,
    collapseHeaderBg: token.colorFillAlter,
    collapseHeaderPadding: `${token.paddingSM}px ${token.padding}px`,
    collapsePanelBorderRadius: token.borderRadiusLG,
    collapseContentPaddingHorizontal: 16, // Fixed value
  });
    
  //返回的是一个通过js对象描述的一个样式表
  return [
    genBaseStyle(collapseToken),
    genBorderlessStyle(collapseToken),
    genGhostStyle(collapseToken),
    genArrowStyle(collapseToken),
    genCollapseMotion(collapseToken),
  ];
});

你会看到在每个组件的styles文件夹都会有一个genComponentStyleHook的api,第一个就是组件标识肯定会用来做属性的区分等,第二个是一个回调传入的token是一个样式配置对象,所以重点是这个token对象的样式配置,这个token你可以理解为可以切换不同优先级的配置对象,可以覆盖原有的样式,你可以把它想象成一个主题变量,控制所有的组件对应的样式修改,也可以对单个组件的某个样式属性修改

第二个参数回调其实是StyleFn,是控制整个组件主题的核心函数。这里简单看下源码genBaseStyle、genBorderlessStyle等做了什么

5分钟学会!antd5自研的css-in-js的定制主题原理

所以最终返回的是一个受token控制的js的样式对象,接下来去找genComponentStyleHook, 但是得先聊一聊token这个东西。

token是一个组件样式修改的变量

5分钟学会!antd5自研的css-in-js的定制主题原理

5分钟学会!antd5自研的css-in-js的定制主题原理

5分钟学会!antd5自研的css-in-js的定制主题原理

最主要的是AliasToken和ComponentTokenMap这两个类型,一个对全局所有组件的混入修改,一个是对于组件的以及组件内部的混入修改。包括附带的一些前缀用于区分组件。所以token是可以控制优先级的一个样式配置对象

Design Token的生命周期

一个token能玩出花了哈哈,说白了就是配置对象从Seed Token这个最小单位开始,组装成一个Map的主题对象,那么他会返回一个Map Token对应,你会看到下面从ColorPrimary变出了colorPrimaryBg和colorPrimary这两个。colorPrimaryBg是由colorPrimary派生的。为什么要有Seed Token,是因为方便设计师创造出对于的主题色,并通过算法去调和其他主题色。

说白了Seed Token就是一个零件,而MapToken是组装零件的。那么,Aliae Token又是个啥,其实就是一个别名,做复用多个Map token 有共性的零件。最终影响组件主题色,那么这个派生关系或者说代码的执行流程就是Design Token的生命周期

5分钟学会!antd5自研的css-in-js的定制主题原理

genComponentStyleHook 遍历生成组件样式对象的集合

import { useStyleRegister } from '@ant-design/cssinjs';
//...
export default function genComponentStyleHook<ComponentName extends OverrideComponent>(
  component: ComponentName,
  styleFn: (token: FullToken<ComponentName>, info: StyleInfo<ComponentName>) => CSSInterpolation,
  getDefaultToken?:
    | OverrideTokenWithoutDerivative[ComponentName]
    | ((token: GlobalToken) => OverrideTokenWithoutDerivative[ComponentName]),
) {
   //注意! 这里其实就是useStyle这个函数
  return (prefixCls: string): UseComponentStyleResult => {
    const [theme, token, hashId] = useToken();
    const { getPrefixCls, iconPrefixCls } = useContext(ConfigContext);
    const rootPrefixCls = getPrefixCls();

    // Generate style for all a tags in antd component.
    useStyleRegister({ theme, token, hashId, path: ['Shared', rootPrefixCls] }, () => [
      {
        // Link
        '&': genLinkStyle(token),
      },
    ]);

    return [
      useStyleRegister(
        { theme, token, hashId, path: [component, prefixCls, iconPrefixCls] },
        () => {
          const { token: proxyToken, flush } = statisticToken(token);

          const defaultComponentToken =
            typeof getDefaultToken === 'function' ? getDefaultToken(proxyToken) : getDefaultToken;
          const mergedComponentToken = { ...defaultComponentToken, ...token[component] };

          const componentCls = `.${prefixCls}`;
          const mergedToken = mergeToken<
            TokenWithCommonCls<GlobalTokenWithComponent<OverrideComponent>>
          >(
            proxyToken,
            {
              componentCls,
              prefixCls,
              iconCls: `.${iconPrefixCls}`,
              antCls: `.${rootPrefixCls}`,
            },
            mergedComponentToken,
          );

          const styleInterpolation = styleFn(mergedToken as unknown as FullToken<ComponentName>, {
            hashId,
            prefixCls,
            rootPrefixCls,
            iconPrefixCls,
            overrideComponentToken: token[component],
          });
          flush(component, mergedComponentToken);
          return [genCommonStyle(token, prefixCls), styleInterpolation];
        },
      ),
      hashId,
    ];
  };
}

你会发现最核心的是useStyleRegister这个api返回的就是genComponentStyleHook,那么useStyle又返回就是warpssr,所以重点就是useStyleRegister,那么我们看看在@ant-design/cssinjs包里面useStyleRegister做了什么。

useStyleRegister 注册全局的样式表

/**
 * 注册全局的样式表
 */
export default function useStyleRegister(
  info: {
    theme: Theme<any, any>;
    token: any;
    path: string[];
    hashId?: string;
    layer?: string;
  },
  styleFn: () => CSSInterpolation,
) {
  const { token, path, hashId, layer } = info;
  const {
    autoClear,
    mock,
    defaultCache,
    hashPriority,
    container,
    ssrInline,
    transformers,
    linters,
  } = React.useContext(StyleContext);
  const tokenKey = token._tokenKey as string;
   
   //注意这里的fullPuth用于缓存查找的key,已经做很细粒度的path
  const fullPath = [tokenKey, ...path];

  // 根据环境判断是否要处理样式
  let isMergedClientSide = isClientSide;
  if (process.env.NODE_ENV !== 'production' && mock !== undefined) {
    isMergedClientSide = mock === 'client';
  }

  const [cachedStyleStr, cachedTokenKey, cachedStyleId] = useGlobalCache(
    'style',
    fullPath,
    // Create cache if needed
    () => {
      const styleObj = styleFn();
      const [parsedStyle, effectStyle] = parseStyle(styleObj, {
        hashId,
        hashPriority,
        layer,
        path: path.join('-'),
        transformers,
        linters,
      });
      const styleStr = normalizeStyle(parsedStyle);
      const styleId = uniqueHash(fullPath, styleStr);

      if (isMergedClientSide) {
        const style = updateCSS(styleStr, styleId, {
          mark: ATTR_MARK,
          prepend: 'queue',
          attachTo: container,
        });

        (style as any)[CSS_IN_JS_INSTANCE] = CSS_IN_JS_INSTANCE_ID;

        // Used for `useCacheToken` to remove on batch when token removed
        style.setAttribute(ATTR_TOKEN, tokenKey);

        // Dev usage to find which cache path made this easily
        if (process.env.NODE_ENV !== 'production') {
          style.setAttribute(ATTR_DEV_CACHE_PATH, fullPath.join('|'));
        }

        // Inject client side effect style
        Object.keys(effectStyle).forEach((effectKey) => {
          if (!globalEffectStyleKeys.has(effectKey)) {
            globalEffectStyleKeys.add(effectKey);

            // Inject
            updateCSS(
              normalizeStyle(effectStyle[effectKey]),
              `_effect-${effectKey}`,
              {
                mark: ATTR_MARK,
                prepend: 'queue',
                attachTo: container,
              },
            );
          }
        });
      }

      return [styleStr, tokenKey, styleId];
    },
    // Remove cache if no need
    ([, , styleId], fromHMR) => {
      if ((fromHMR || autoClear) && isClientSide) {
        removeCSS(styleId, { mark: ATTR_MARK });
      }
    },
  );

  return (node: React.ReactElement) => {
    let styleNode: React.ReactElement;
    
    //一个你得在非ssr服务端渲染,一个是否在client,一个外部的配置,如果不满足就返回空
    if (!ssrInline || isMergedClientSide || !defaultCache) {
      styleNode = <Empty />;
    } else {
      styleNode = (
        <style
          {...{
            [ATTR_TOKEN]: cachedTokenKey, //token的缓存标识
            [ATTR_MARK]: cachedStyleId,  //样式的缓存标识
          }}
          dangerouslySetInnerHTML={{ __html: cachedStyleStr }}
        />
      );
    }

    return (
      <>
        {styleNode}
        {node}
      </>
    );
  };
}

首先这里通过useGlobalCache函数,传入对应的fullPath,然后执行传入的函数,会执行前面提到过的StyleFn这个函数,styleFn执行时组件本身的样式和被合并的token就被加载到一个StyleObj对象上了。

通过parseStyle函数传入的path、hashId、以及暴露在外的api最终解析出来的是一个内部key都被序列化的对象。将返回的cachedStyleStr, cachedTokenKey, cachedStyleId这三个缓存的值传入<style>这个标签。

在html中,style标签是使用来定义html文档的样式信息,在该标签中你可以规定浏览器怎样显示html文档内容。那么存入了对应的token缓存标识、样式的缓存标识、以及样式的字符串,最终被解析渲染,那么你会发现其实他的样式是运行时,同时也是组件级别的样式按需更新。

核心原理总结

首先在组件内的useStyle传入了warpSSR和hashID,执行genComponentStyleHook,最终返回useStyleRegister这个函数并传入styleFn,核心执行useGlobalCache函数,styleFn执行时组件本身的样式和被合并的token就被加载到一个StyleObj对象上了。通过parseStyle函数传入的path、hashId、以及暴露在外的api最终解析出来的是一个被序列化的对象,最终cachedStyleStr, cachedTokenKey, cachedStyleId渲染到style标签上,这样可以让组件本身具备了更细粒度的包体积和性能。