Giter Site home page Giter Site logo

lio-mengxiang / mx-design Goto Github PK

View Code? Open in Web Editor NEW
793.0 793.0 65.0 11.99 MB

最好的react ui组件库教程代码 (The code for the best react component library series tutorial)

Home Page: https://lio-mengxiang.github.io/mx-design/

JavaScript 1.55% Shell 0.01% HTML 0.10% TypeScript 86.93% Less 11.41% CSS 0.01%

mx-design's People

Contributors

lio-mengxiang avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

mx-design's Issues

你知道Compositionstart和Compositionend事件吗,react 组件库之Input组件的坑

前言

Input组件大家都用过,是吧,但是你有没有想过这样一个场景,如下图,我正在搜索数据

你组件上注册了onChange事件,然后边输入,底下会显示你搜索相关的内容,

image.png

但是有一个问题就是,输入中文的时候,你比如打三国的三字,要先输入san然后才出现三

image.png

可问题来了,onChange事件监听的是san,已经开始搜索了,其实我们根本不想这样,我们想的是中文的话,等中文显示在输入框,也就是输入完“三”这个字的时候,才搜索。这个咋办

这就需要我们今天的主角Compositionstart和Compositionend事件出场了。

Compositionstart和Compositionend事件是啥

compositionstart 事件在用户开始进行非直接输入的时候触发,而在非直接输入结束,也即用户点选候选词或者点击「选定」按钮之后,比如按回车键,会触发 compositionend 事件。

举个例子,还是上面输入三这个字的过程,当你输入s的时候,已经打开了中文输入法,此时compositionstart事件触发了,当你输入完三并且确认的时候,compositionend事件触发。

好了,所以如果你用的组件库,Input组件没有优化这一项的话,也就是onChange事件在中文输入法下,应该返回的是中文输入完成,也就是compositionend事件完成的字符,而不是输入过程的字符,那么就需要组件封装一下这个逻辑了,如果封装,我们看下源码。

本来是打算解析完整的Input组件的,后来想想其实这个组件太简单了,就只讲一下其中的难点吧。

源码探析解决方案

首先Input组件如下

 <input
        value={innerValue}
        onCompositionStart={handleCompositionStart}
        onCompositionEnd={handleCompositionEnd}
        onChange={handleChange}
      />

然后我们看下value属性的formatDisplayValue是什么

// 用来记录此时是否Compositionstart事件触发了,如果触发就置为true
// Compositionend结束就置为false
 const composingRef = useRef(false);
 // 暴露给
  const [value, onChange] = useState<string>('');
 const [composingValue, setComposingValue] = useState<string>('');
 // 如果启动了中文输入法,那么innerValue就是composingValue
 // composingValue就是中文输入的时候比如“三国”,你输入从“s”到“sanguo”,此时innerValue都是composingValue
 // 除了中文输入法外,innerValue都是value
 const innerValue = composingRef.current ? composingValue : value ?? '';

上面可以看到innerValue是最终渲染给input框的value,用户一般通过onChange事件获取值,所以
我们在中文输入的时候,只要不触发onChange事件是不是就好了!

关键啊!最重要的知识点就是Compositionstart事件触发了,代表正在输入中文,那么onChange事件就不要触发,所以我们接着把事件的代码补上

 // 开始输入中文的时候把 composingRef.current 置为true
  function handleCompositionStart(e: React.CompositionEvent<HTMLInputElement>) {
      composingRef.current = true;
      const {
        currentTarget: { value },
      } = e;
    }
    // 中文输入完毕,把composingRef.current置为false,并把此时输入完的值给handleChange(handleChange会触发onChange)
    function handleCompositionEnd(e: React.CompositionEvent<HTMLInputElement>) {
      if (composingRef.current) {
        composingRef.current = false;
        handleChange(e);
      }
    }

 function handleChange(e: React.ChangeEvent<HTMLInputElement> | React.CompositionEvent<HTMLInputElement>) {
      let { value: newStr } = e.currentTarget;
      // 当中文输入的时候,不触发onChange事件,触发setComposingValue只是为了让输入框同步你输入的text
      if (composingRef.current) {
        setComposingValue(newStr);
      } else {
        // 完成中文输入时同步一次 composingValue
        setComposingValue(newStr);
        // 中文输入完毕,此时触发onChange
        onChange(newStr, { e });
      }
    }

好了,本文结束,其实里面还有两个细节,后面再写一篇文章说,就是我们input框传参,可以有defaultValue这个value是外部传入的,还有props传入的value,还有组件默认的defalut value,这3个值怎么写一个hooks去处理,还有就是你们知道input框有种类型是显示password吗,这种显示密码的input框,输入文字后显示的是...如下:

image.png

点击眼睛图标就能看到输入的文字了,这个是咋办到的。后面说,吃饭去了!

我的天!又在几个知名组件库发现相同的bug!(组件库zIndex管理方案)

前言

这篇文章的重点是z-index管理方案,主标题是标题党吸引点流量,请谅解,希望我用高质量的方案对比来消解你对标题党的怒火😅。

有些人可能觉得z-index有啥难的,这其实是一个很经典的前端难题了。我们先看看以下几个组件库如何让它们的z-index管理出现异常。

以下问题在国内3个知名组件库,阿里的ant design,腾讯的tdesign和semi-design出现。后续也会简单说一下他们的z-index管理方案的原理以及出现问题的原因。

这里字节的arco design是解决了这个问题的。后面也会讲arco是怎么设计的。

我们先看如何复现问题,有在线案例。

首先我们把一个两个Button组件放在一起,如下图:

在线链接

image.png

然后第一个Button组件是触发弹出Modal框的,第二个按钮是类似Tooltip组件,我们让这个弹层永远显示,这样好复现bug。

然后点击Open Modal的button按钮,出现Tooltip把Modal的黑色蒙层遮盖了的问题。

image.png

你可能说这算啥bug,出现几率很低,我们再来一个?

看看ant vue有没有问题(只要你知道他的z-index方案,你能想出n个方法让他出问题)

在线链接

image.png

在分析产生问题的原因前,大家是否想过一个问题,你可以看看你用的组件库,把一些弹出框组件(例如,modal组件,tooltip组件,message组件等等)都渲染在了dom流的哪个地方?

答案是body下,如下图

image.png

你思考过为什么要这么做吗?比如上图,正常情况,不是应该按钮在哪里,这个对应的弹框跟按钮在一起吗,渲染到body下干嘛?

这其中一个重要的原因就是为了管理z-index。

层叠上下文

为了说明这个问题,我们还要弄清楚一个概念,叫层叠上下文?我想问大家,zIndex越大一定就在最高的层级吗?

答案是no!

举个例子

<style>
    .box1,
    .box2 {
        position: relative;
        z-index: 1;
    }

    .child1 {
        width: 200px;
        height: 100px;
        background: #168bf5;
        text-align: right;
        position: absolute;
        top:0;
        z-index: 99999;
    }

    .child2 {
        width: 100px;
        height: 200px;
        background: #32c292;
        position: absolute;
        top:0;
        z-index: 1;
    }
</style>
</head>

<body>
    <div class="box1">
        <div class="child1">child1</div>
    </div>

    <div class="box2">
        <div class="child2">child2</div>
    </div>
</body>

以上代码,我们可以看到child2,zIndex是1,child1的zIndex是99999,按道理来说,child1的zindex更大,它应该展示在child2上面,可是结果如下:

image.png

原因就是box1和box2都创造了层叠上下文(如果有zindex为数字 + 非postion:static布局会产生层叠上下文,还有很多条件也能创造层叠上下文,这里就不细说了)

box1和box2的层叠等级一样,所以遵循谁后写谁在上面,所以box2永远在box1上面

所以box2里面的元素,是永远比box1里面的元素层级更高的。

那么child1和child2比较根本没有意义,因为他们并不在一个层叠上下文中,只有在一个层叠上下文中,比较zindex才有意义。

为什么放到body下

因为我们可以看到,业务代码有可能会在很多隐蔽的地方产生层叠上下文,这个组件库是无法控制的,所以如果大家把很可能产生遮盖效果异常的组件都放在body上,就相当于大家在一个层叠上下文中了,可以更好的控制遮盖问题。

特殊情况

从上面来看,放在body下,大家都在一个层叠上下文中,那么就会遵循谁后出现,谁在层级之上的效果,但是总有一些常见的情况是不想要这样的,比如:

  • 我有一个message组件,弹出消息,3秒之后消失,在1秒的时候我就点击modal框,但是我们遵循谁后点击,谁在层级上,那么modal组件就把message组件覆盖了,这并不是我们想要的。

所以一般情况,对于message和notification的弹出消息,我们总是希望他们层级是最高的。

  • 还有就是我在文章开始复现的一个问题,就是因为modal的z-index没有tooltip的层级高

这下大家知道为什么产生问题的原因了吧。如何解决呢?我们看看bootstrap5的方案:

bootstrap zindex设计

$zindex-dropdown:                   1000;
$zindex-sticky:                     1020;
$zindex-fixed:                      1030;
$zindex-modal-backdrop:             1040;
$zindex-offcanvas:                  1050;
$zindex-modal:                      1060;
$zindex-popover:                    1070;
$zindex-tooltip:                    1080;

这个简单看看就好,我觉得有点过时了,因为bootstrap是在jquery那个年代的流行产物,并不会将所有弹出框类似的组件渲染到body下.

但是这起码说明一个问题,就是按照bootstrap这个标准,至少很少有弹出层异常的问题,为什么是很少有呢?

因为特殊情况基本上都是层叠上下文导致的,这种特殊情况只有组件单独导入zindex适配业务需求。

通过设置 z-index层级的方案

类似bootstrap,通过对特殊弹框类组件设置不同的z-index来避免遮盖问题,我们列举了以下方案:

ant design

// ant-design/components/style/themes/default.less
/* z-index列表, 按值从小到大排列 */
@zindex-badge: auto;
@zindex-table-fixed: 2;
@zindex-affix: 10;
@zindex-back-top: 10;
@zindex-picker-panel: 10;
@zindex-popup-close: 10;
@zindex-modal: 1000;
@zindex-modal-mask: 1000;
@zindex-message: 1010;
@zindex-notification: 1010;
@zindex-popover: 1030;
@zindex-dropdown: 1050;
@zindex-picker: 1050;
@zindex-popoconfirm: 1060;
@zindex-tooltip: 1070;
@zindex-image: 1080;

在我的组件库里,因为popover,dropdown,tooltip,selelct类型的下拉框都属于popup组件,所以跟ant design略有不同,他们都是一个层级。

为什么我能试出来ant vue的bug,大家可以看ant design中@zindex-dropdown: 1050,然后@zindex-popover: 1030,那么意味着,在同一个层叠上下文中,我先触发dropdown,再触发popover,那么popover一定是在dropdown底下的,所以会产生bug。

后来我看ant design5学聪明了,不支持传入组件,只能传入数组了。。。我的组件也是这么做滴,嘿嘿,当然不仅仅是因为这个bug,后期要为低代码平台做铺垫,所有传入的数据最好都是普通数据,比如数组,对象,而不是react组件。

全局管理器方案

elementUI将弹窗层级管理收敛到了一个入口PopupManager中,涉及zIndex层级的弹窗组件实例都需要注册到PopupManager中。

简单来说,就是用一个全局的对象记录当前最高的zindex,然后下一个比这个更高,简单来说如下:

class ZIndexManager {
  constructor() {
    this.zIndex = 1000; // 初始的 z-index 值
    this.zIndexMap = new Map(); // 用于存储元素和对应的 z-index 值
  }

  getNextZIndex() {
    this.zIndex += 1;
    return this.zIndex;
  }

  registerElement(element) {
    const nextZIndex = this.getNextZIndex();
    this.zIndexMap.set(element, nextZIndex);
    this.updateElementZIndex(element, nextZIndex);
  }

  unregisterElement(element) {
    this.zIndexMap.delete(element);
  }

  updateElementZIndex(element, zIndex) {
    element.style.zIndex = zIndex;
  }
}

const zIndexManager = new ZIndexManager();
export default zIndexManager;

但是我觉得,很多场景并不是说我需要后面出现的弹层一定要比前面的层级高。

我们之前也说了,message(也就是toaster)肯定是最高层级的,我们不希望modal比它还高,所以这个方案我觉得还能更好。

改进ant design方案

在我看来,ant deisgn的方案稍微改一下,基本上就使用百分之95%以上的场景了,特殊场景用户自己去单独给组件传入z-index或者改变层叠上下文的层级,也就是自定义设置了。

以下层级由低到高:

  • Affix
  • Drawer, Message, Modal,modal-mask, popup相同层级(从而让后出来的在层级最上面)
  • notification
  • message

上面的popup包含很多,比如select所有的类似下拉框组件(比如picker),tooltip,dropdown等等

arco design方案

字节的arco design在这方面我觉得是国内做的比较好的,文章初的两种bug均对它无效。

字节的处理基本上跟我上面改进的方案差不多,但是它只对Modal和Drawer组件内部的所有组件的z-index进行了+1处理

为什么要这么做,我们要看下arco的z-index方案。

  // z-index
  '--z-index-popup-base': 1000,
  '--z-index-affix': 'calc(var(--z-index-popup-base) - 1)', // 999
  '--z-index-popup': 'var(--z-index-popup-base)', // 1000
  '--z-index-drawer': 'calc(var(--z-index-popup-base) + 1)', // 1001
  '--z-index-modal': 'calc(var(--z-index-popup-base) + 1)', // 1001
  '--z-index-tooltip': 'var(--z-index-popup-base)', // 1000
  '--z-index-message': 'calc(var(--z-index-popup-base) + 3)', // 1003
  '--z-index-notification': 'calc(var(--z-index-popup-base) + 3)', // 1003
  '--z-index-image-preview': 'calc(var(--z-index-popup-base) + 1);', // 1001

以上是我的最开始的z-index方案,就是从arco借鉴而来,但是我们发现上面有什么问题呢?modal的zindex是1001,popup的zindexshi 1000,意味着我先打开modal框,然后modal框里有一个popup按钮,再触发popup按钮后,显示的文字居然回到modal框后面(我的组件库目前有这个bug)

所以acro怎么避免这个情况呢,例如,在modal框里,会把modal框里传入的组件所有index重新设置为当前modal的zindex + 1,所以arco避免了这种bug。

而我怎么做呢,我只要把modal的z-index改成和popup一样,这不就天然是谁后出现,谁在上面了吗,巧妙的达成了和arco一样的效果。

求个star

做组件库教程不易,求个star,哈哈,

【全网最硬核的react组件库教程】手写增强版 @popper-js (逻辑代码完整版)

前言

这类文章一般都是给做react组件库深度玩家看的,原版的@popper-js 是用的flow类型系统,我这里用的ts。如果想了解主要逻辑,看上面【目前最好的react组件库教程】手写增强版 @popper-js (主体逻辑分析)这篇文章就够了。

废话不多说,我们继续写增强版的@popper-js 的逻辑。

渲染主逻辑分析

1、创建负责表示当前定位信息和更新定位信息的实例

首先定义几个名词:
如下图:

image.png

首先调用 createPopper方法,传入3个参数

  1. reference:参考元素。它可以是一个 DOM 元素或一个返回 DOM 元素的函数。Popper 将会根据 reference 的位置计算 popper 的位置。
  2. popper:popper 元素,即要进行定位的元素。它也可以是一个 DOM 元素或一个返回 DOM 元素的函数。
  3. options:外界传入的自定义参数,比如定位的位置,要top,还是bottom。

2、初始化时调用了Instance对象的setOption方法,目的是合并options

例如我们默认弹出位置是reference的下方,但是外部可以自定位置,比如是reference的上方,所以需要合并options

3、setOption方法触发定位逻辑,主要是计算到底popper元素定位的坐标是多少

  • 由于定位的时候,我们后续会滚动滚动条,定位元素最开始是在上方,由于滚动到最上面可能需要调整位置,如下图:
    image.png

可以看到下图,由于浏览器滚动到上方,上方预留的空间不够popper显示到reference元素的上方,所以我们popper的位置变为了朝下。

image.png

所以我们就需要监听所有popper和reference元素的中父元素有滚动条的情况。

此时我们需要找到这些父元素。体现在代码:

  state.scrollParents = {
        reference: isElement(reference) ? listScrollParents(reference as Element) : [],
        popper: listScrollParents(popper),
      };

这里的关键函数是listScrollParents,它的逻辑简述为:

  • 从当前节点开始,查看是否是具有滚动属性,判断方式:
export function isScrollParent(element: Element | HTMLElement): boolean {
  const { overflow, overflowX, overflowY, display } = getComputedStyle(element);
  return /auto|scroll|overlay|hidden|clip/.test(overflow + overflowY + overflowX) && !['contents'].includes(display);
}

比如说,你的属性有overflow: auto, overflow: scroll等等,我认为你是可能具有滚动条的,但是为什么源码将overflow:hidden也算进去,就不知道为什么了,了解的同学可以留言区告知。

  • 此时会检查reference和popper是否是html元素,如果不是就退出,并控制台警告,必须是html元素才行。 判断方式主要是查看是否有getBoundingRect方法:
export function areValidElements(...args: Array<any>): boolean {
  return !args.some((element) => !(element && typeof element.getBoundingClientRect === 'function'));
}
  • 接着计算reference元素的位置,也就是popper元素想定位,那么定位到reference元素的左上角的话,坐标是多少,对应代码如下:
      state.rects = {
        reference: getCompositeRect(reference, getOffsetParent(popper)),
        popper: getLayoutRect(popper),
      };

这里的计算方法【目前最好的react组件库教程】手写增强版 @popper-js (主体逻辑分析) 在这篇文章里有详细描述。

  • 接着把计算出来了reference元素的位置经过中间件处理,比如说,我想把popper元素定位到reference元素的上方,那么首先我们说了,上面计算得到reference元素的左上角的坐标赋给popper,但是此时popper的左上角跟reference元素的左上角重合了

如果要放置到reference元素的上方,是不是还要把此时左上角的y坐标再减去popper元素的高度才对,然后x坐标需要:

reference.x + reference.width / 2 - popper.width / 2

至于为什么这样,你可以思考一下,这里你主要明白我的意思即可,要了解详细代码逻辑,具体代码地址在文章开始处。

  • 我们这里的中间件主要有4个

popperOffsets中间件

这里主要是计算坐标,比如说刚才我们假设popper元素要定位到reference元素上方时,如何计算,那么所有位置的坐标换算,都在这个中间件处理。

这里位置示意图如下,共有12个位置:

image.png

computeStyles中间件

这个中间件有些逻辑有点绕,还是要说明一下

之前我们通过popperOffsets中间件按道理来说已经计算完毕了,但还需要一些补充

  • 假如此时我们popper相当于reference元素是在上方,如下图:

image.png

此时,我们加入popper的定位方式是:

position: 'absolute',
top: 100px
left: 100px

如果此时我将popper的内容(也就是This is a popup box),变为了两行,会发生什么情况呢?如下图:

image.png

因为高度变高了,换行的内容遮盖住了reference元素。如何解决呢,computeStyles组件里,我们发现是这种情况,把定位方式变一下:

之前是:

position: 'absolute',
top: 100px
left: 100px

我们改为:

position: 'absolute',
bottom: -600px
left: 100px

我们以bottom为标准,此时再改变高度,就变为:

image.png

是不是解决方式比较巧妙呢,但是最好的解决方式是什么,监听popper的元素如果有width和height的变化,重新计算定位坐标。

第二个重点是,computeStyles组件使用了css加速,利用transform将我们最终得到的x坐标和y坐标导出来,最终我们会在react层面把这个坐标赋值给popper元素的style属性。

offset中间件

比如,我们想要popper元素在定位后向上移动10px,或者向左移动10px,都可以将参数传入offset中间件,它就是来变换坐标的。

flip中间件

flip是目前遇到逻辑最复杂的中间件,它主要解决的问题是什么呢?

原理是,比如我们现在placement:bottom,表示定位到reference元素的下方,当我们向下滚动的时候,是不是这个定位的元素因为在下方,迟早会到视口的下面,如下图:

image.png

为了能看见tooltip,我们自动翻转到上方!

image.png

这就是flip的功能,至于如何实现,我们拿最简单的上面的案例做分析:

我们需要在滚动的时候,就检测上图的tooltip元素是否已经有部分超出浏览器视口了,只有检测到超出,我们是才能进行翻转。

检测思路:

  • 首先滚动的地方可能不只是window窗口,还可能tooltip元素的父元素也能滚动,此时,tooltip元素的父元素的滚动也能让tooltip元素在滚动时隐藏,如下:

image.png

图中有两个滚动条,任意一个滚动到一定范围都能让蓝色的button组件隐藏。

所以我们要收集所有的滚动容器,他们中的所有上边框距离button元素最近的距离是多少,所有左边框距离button元素最近的距离是多少,下边和右边也是一样。

这样就计算出一个button元素活动的最小范围,在这个范围内随时有可能因为滚动而隐藏。

这个函数在@popper-js被抽离出叫做detectOverflow,最终返回一个对象:

{
    top: popper元素距离最近的滚动容器上方的距离,正数表示还在可是区域内,
    left: popper元素距离最近的滚动容器左边的距离,,正数表示还在可是区域内,
    right: popper元素距离最近的滚动容器右方的距离,正数表示还在可是区域内,
    bottom: popper元素距离最近的滚动容器下方的距离,,正数表示还在可是区域内,
}

这样,我们只要检查,比如popper元素此时应该是在上方,那么它的上边,左边和右边是否都是正数,只要有一个负数,说明此时popper元素已经隐藏部分区域了。

所以我们只要找到一个位置,它的3个方向都是正值,那么这个方向的popper元素就完全在可视区域内。

问题来了,如果所有的方向都不在可视区域内该怎么办呢?

那此时咋也不管了,原本是哪个方向就还是哪个方向,这在视觉上是完全没问题的。

本文完毕,如果本文你觉得还不错,欢迎给我的react 组件库项目satr哦,还在持续迭代中,我相信这个教程会成为全网最硬核的,能上生产环境react组件库项目!

不吹牛,完爆ant design的定位组件!floating-ui来也

前言

因为要写react定位组件(这不是标题党,就是完爆ant design的定位组件,你应该看到一半就会同意我的观点),如下图:

image.png

红框部分是用绝对定位放在按钮上面的,你们B端用的主流组件库都是这样实现的,它是很多组件的基础组件,比如下图:

下拉框组件

image.png

select组件

image.png

还有什么DataPicker,TreeSelect,Dropdown组件等等的下拉框都是以定位组件为基础的。

这个组件实现的复杂度在哪

上面提到,这不过就是一个绝对定位嘛(我们假设红框部分的的dom绝对定位是相较于body元素),我们拿最简单的情况来看,如下图,如何把红框部分渲染到按钮”更多“的下方呢?

image.png

我们可以计算更多按钮的getBoundingRect(),返回

const reference = {
  top: xx, // 按钮距离浏览器顶部的距离
  left: xx, // 按钮距离浏览器左边的距离
  width: xx, // 按钮的宽:没有padding
  height:xx,// 按钮的高:没有padding
  ...等等其他属性
}

所以红框部分左上角的坐标就轻易的计算出来了,上面的数据在reference对象上,所以借助reference的定位,我们计算红框部分的下拉框的定位是在哪

{
    position: 'absolute',
    top: reference.top + window.pageYOffset // 竖直方向滚动距离 + reference.height
    left: reference.left + window.pageXOffset // 横向滚动距离 
}

为啥是上面这么计算呢,假如没有滚动条滚动,那么红框部分的绝对定位的top,是不是等于按钮的距离浏览器顶部的高度 + 本身的高度,这个没问题吧?

然后,如果滚动条滚动了的话,是不是要在上面top的基础上加上这段距离,就是红框部分在文档流绝对定位的top。

好了,到此为止,就是最基本的定位组件的逻辑了,我们接下来看复杂点!

复杂度1

还是拿上面的图的红色部分下拉框来说,下拉框一般是在下面,但是我可以定位到上边吧?左边,右边也没啥吧,再过分点,右上,左下?定位组件要处理对吧

复杂度2

假设,我们定位在下面,我想向左偏移8px,向下偏移3px咋办,你是不是应该有暴露一个口子

复杂度3

假设我定位在下面,那么我一直滚,马上就要滚动到看不见下拉框了,如下图

image.png

此时我想让定位在上面,能不能自动帮我处理?如下图:

image.png

复杂度4

是不是还有可能超出浏览器视口了,如下图:

image.png

我们想自动处理,遇到超出就自动变为下方样子:

image.png

复杂度5

此时我定位了一次,但是有可能滚动容器不是window,是另一个div,这个计算咋办?还有,是不是我滚动的时候,我要监听滚动事件,还要监听浏览器resize事件,因为我定位的值可能会变?为啥呢,我们上面复杂度3是不是自动帮我们在滚动的时候调整位置

所以你不监听滚动事件你咋知道要调整位置了?

还有很多细枝末节,比如浏览器兼容性等等。。。。

国内组件库怎么实现这个功能

目前阿里的ant design和字节的arco design都是自己实现的,我们拿arco来看(ant内部叫rc-trriger组件,arco叫trriger组件),面向过程的代码,看的我头皮发麻。。。我截个图:

image.png

上面的代码属于把我们提到的复杂度全部揉在了一起。

floating-ui为啥代码质量比ant高

它是以中间件的形式去处理的,思路是什么呢?它假设最开始有一个 computePosition函数,我们假设上面提到的复杂度都没有,也就是不考虑的前提下,我们怎么计算定位组件的坐标,也就是我们最前面的图里说的,红色框部分绝对定位的的的top值和left值:

API如下:

computePosition(要挂载的dom节点,下拉框组件,参数...)

然后我们刚才提到的复杂度,它分别用中间件的形式去处理,比如复杂度2,是想定位之后还有点偏移量,floating-ui咋做的呢

import {computePosition, offset} from '@floating-ui/dom';

// referenceEl: 要挂载的dom节点
// floatingEl:下拉框组件(或者说想要挂载到上面referenceEl的dom元素)
computePosition(referenceEl, floatingEl, {
  middleware: [offset(10)],
});

如上,offset就是一个中间件,offset(10),就是向左偏移10px

好了,如果想处理复杂度3呢,我们用另一个中间件

import {computePosition, flip} from '@floating-ui/dom';
 
computePosition(referenceEl, floatingEl, {
  middleware: [flip()],
});

这样就自动处理了,是不是很简单啊

其实所有这些复杂度的解决方案,在floating-ui里都是以中间件的形式去处理的,还可以传多个中间件解决多个问题。

中间件的形式好在哪

那么我们就可以自定义很多中间件了,也就是你的组件不仅仅提供了很多功能,解决了很多常用的问题,你还允许用户写代码去拓展,试问,现在哪个组件库的代码是这么写的?没有吧?

代码中间件原理

我们先看看floating-ui的computePosition API是怎么实现的,它是floating-ui的核心方法,是串联所有中间件的基础。

下一篇写完整的源码(很晦涩,估计也没几个人看,所以这期就不写了),理解起来说实话,你不熟悉原生dom的话有点困难,比如说为啥这个库要用window.pageYoffset而不是document.body.scrollTop去获取浏览器html元素的滚动距离,因为document.body.scrollTop固定为0,取不到。。。

核心思路讲解:我们还是拿下图做类比

image.png

let {x, y} = 求出红色框里的下拉框绝对定位的x坐标和y坐标

// 记录原始placement
  let statefulPlacement = placement;
  // 所有中间件导出的值都挂载到下面的对象上
  let middlewareData: MiddlewareData = {};
  
  // 数据经过middleware的处理
  // middleware是一个数组,存放所有中间件,就是我们上面说的处理每一个复杂度的对象
  for (let i = 0; i < middleware.length; i++) {
   // name是中间件的名字,fn是处理复杂度的逻辑
    const {name, fn} = middleware[i];
   
   
   // 通过把最前面计算的x,y经过fn的处理,得到了新的x,y的值
   // data是指返回的数据,想让后面的中间件也能访问到的数据
    /**
     * 每个middleware需要返回
     * x 新的x坐标
     * y 新的y坐标
     * data 
     * reset
     */
    const {
      x: nextX,
      y: nextY,
      data,
      reset,
    } = await fn({
      /**
       * 每个middleware收到的参数
       * x 目前的x坐标
       * y 目前的y坐标
       * initialPlacement 最初传入的placement
       * placement 
       * middlewareData middleware返回的额外数据
       */
      x,
      y,
      initialPlacement: placement,
      placement: statefulPlacement,
      strategy,
      middlewareData,
      rects,
      platform,
      elements: {reference, floating},
    });

    x = nextX ?? x;
    y = nextY ?? y;

    // 每次处理后的数据想要让后面的中间件访问,就需要挂载到middlewareData对象
    // 这个对象非常好啊,用name隔离了作用域
    middlewareData = {
      ...middlewareData,
      [name]: {
        ...middlewareData[name],
        ...data,
      },
    };

   rest的处理逻辑。。。省略,不是很重要
   
   

最后return出被处理完的x,y坐标,或者自动帮我们监听滚动事件和resize事件,然后拿着x,y就可以赋在css的绝对定位的top和left上,实现了定位。

每次处理后的数据想要让后面的中间件访问,就需要挂载到middlewareData对象,这个对象非常好啊,用name隔离了作用域,这就是比koa这个框架处理的高明之处,koa里的ctx对象就像一个垃圾桶,什么属性都往上面挂载,挂载太多了,你也不知道是哪个中间件挂载的

所以floating-ui的处理思路给我打开了新的思路!nice!!!

中间件如何写

源码再开一篇文章写,这里看看就好,不用过多去关系代码

export const offset = (value: Options = 0): Middleware => ({
  name: 'offset', // 中间件名字
  options: value,  // 传给中间件的值
  async fn(middlewareArguments) { // 中间件处理函数
    const {x, y} = middlewareArguments;
    const diffCoords = await convertValueToCoords(middlewareArguments, value);

    return {
      x: x + diffCoords.x,
      y: y + diffCoords.y,
      data: diffCoords,
    };
  },
});

本文结束,所以如果市面上的组件库的每个组件都是这个形式暴露给用户,就是提供插件式的自定义的中间件,那么整个组件库的拓展性可以说碾压市面上国内所有的react的组件库了

Affix(固钉)组件原理

组件展示的基本原理

如下图,这是一个按钮(内容为"固钉"),我希望在离屏幕150px的时候固定住它。

image.png

这里我们不考虑按钮在另一个容器的情况,简单说下原理。

首先,元素本身是在文档流的,然后固定住,就是position变为fixed了,所以我们只要在浏览器滚动的时候,监听onScroll事件,并且在事件里判断,是否按钮的getBoundingClientRect()中(getBoundingClientRect()用于获得页面中某个元素的左,上,右和下分别相对浏览器视窗的位置),是否top小于等于150px。

如果是的话,就把按钮的style属性变为position:fixed,然后top: 150px即可

然后,如果你采用了fixed定位,那么元素就脱离文档流了,所以我们需要加一个元素,宽高等于按钮的宽高,插入到按钮原来的位置,撑开文档流。等发现元素top大于150px的时候,再把这个元素删除(dom api 删除和添加元素)。

我们先用这个思路实现一版,最后考虑按钮如果在另一个能滚动的容器里该怎么办。

代码实现

首先我们看下dom结构

const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
  const {
    children,
    className,
  } = props;

  const affixRef = useRef<HTMLDivElement>(null);
  const affixWrapRef = useRef<HTMLDivElement>(null);

  return (
    <div ref={affixWrapRef} className={className} style={style}>
      <div ref={affixRef}>{children}</div>
    </div>
  );
});

其中affixWrapRef是用来使用getBoundingClientRect()来获取到浏览器窗口顶部的top值,也用来添加占位元素,直接使用

affixWrapRef.current.appendChild(占位元素)

affixRef是用来改变定位的,类似

// 定位
affixRef.current.className = 固定的class,比如position:fixed;
affixRef.current.style.top = 固定的top;

好了,接着我们加入监听的代码,首次监听一定是在useEffect里

const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
  const {
    children,
    zIndex, // 固钉定位层级,样式默认为 500
    container, // 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body
    offsetBottom, // 距离容器顶部达到指定距离后触发固定
    offsetTop, // 距离容器底部达到指定距离后触发固定
    className,
    style,
    onFixedChange, // (affixed: boolean, context: { top: number }) => void 固定状态发生变化时触发
  } = props;

  
  const affixRef = useRef<HTMLDivElement>(null);
  const affixWrapRef = useRef<HTMLDivElement>(null);
  // 占位符的ref,用来创建占位的dom元素
  const placeholderEL = useRef<HTMLElement>(null);
  // 滚动容器的ref,默认是window
  const scrollContainer = useRef<ScrollContainerElement>(Window);

  // 它是用来处理滚动时,判断是否需要固定组件的函数
  const handleScroll = useCallback(() => {
       // xxx 后面会讲这里的逻辑
  }, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]);

  useImperativeHandle(ref, () => ({
    handleScroll,
  }));

  useEffect(() => {
    // 创建占位节点
    placeholderEL.current = document.createElement('div');
  }, []);

  useEffect(() => {
    // 这里可以看到首次加载Affix组件,会执行一下handleScroll,它是用来处理滚动时,判断是否需要固定组件的函数
    if (scrollContainer.current) {
      handleScroll();
      scrollContainer.current.addEventListener('scroll', handleScroll);
      window.addEventListener('resize', handleScroll);

      return () => {
        scrollContainer.current.removeEventListener('scroll', handleScroll);
        window.removeEventListener('resize', handleScroll);
      };
    }
  }, [container, handleScroll]);

  return (
    <div ref={affixWrapRef} className={className} style={style}>
      <div ref={affixRef}>{children}</div>
    </div>
  );
});

这里就有一个问题了,为啥在绑定scroll事件的时候,要提前调用一下handleScroll方法呢,因为可能首次加载就满足元素被固定的条件了,比如距离浏览器顶部150px的时候固定,首次加载完Affix组建后就正好是150px。

组件最核心的handleScroll逻辑

const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
  const {
    children,
    zIndex, // 固钉定位层级,样式默认为 500
    container, // 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body
    offsetBottom, // 距离容器底部达到指定距离后触发固定
    offsetTop, // 距离容器顶部达到指定距离后触发固定
    className,
    style,
    onFixedChange, // (affixed: boolean, context: { top: number }) => void 固定状态发生变化时触发
  } = props;

  const { classPrefix } = useConfig();

  const affixRef = useRef<HTMLDivElement>(null);
  const affixWrapRef = useRef<HTMLDivElement>(null);
  const placeholderEL = useRef<HTMLElement>(null);
  const scrollContainer = useRef<ScrollContainerElement>(null);

  const ticking = useRef(false);

  const handleScroll = useCallback(() => {
    if (!ticking.current) {
      window.requestAnimationFrame(() => {
        // top 是固定包裹元素affixWrapRef到浏览器视口顶部的距离,不包括margin
        // width是元素的宽,不包含margin
        // height是元素的搞,不包含margin
        const {
          top: wrapToTop = 0,
          width: wrapWidth = 0,
          height: wrapHeight = 0,
        } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 };


        const calcTop = wrapToTop  节点顶部到 container 顶部的距离
        // 整个视口的高减去元素的高
        const containerHeight =
          scrollContainer.current['innerHeight'] -
          wrapHeight; 

        const calcBottom = containerHeight  - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值 

        // 这里是固定的关键代码,fixedTop表示是否此时处于固定状态
        let fixedTop: number | false;
        // offsetTop,也就是外部传入的,我想元素距离浏览器顶部多远的时候固定
        // 当offsetTop存在,并且calcTop(节点顶部到 container 顶部的距离)小于我们设置的offsetTop
        // 这时候就需要固定
        if (offsetTop !== undefined && calcTop <= offsetTop) {
          // top 的触发
          fixedTop = containerToTop + offsetTop;
          
          // 下面这一行if判断的意思是,我们外部传入offsetBottom = 20的话
          // 就是希望在元素距离浏览器视口顶部20px的时候固定住
          // 所以wrapToTop,也就是元素距离浏览器视口顶部的距离,大于calcBottom的时候,
          // calcBottom是指浏览器视口的高度减去元素本身的高度,再减去offsetBottom,此时,就是元素到浏览器视口顶部剩余的高度了
          // 剩余的高度如果wrapToTop比它还大,那肯定就要固定住了呗
        } else if (offsetBottom !== undefined && wrapToTop >= calcBottom) {
          // bottom 的触发
          fixedTop = calcBottom;
        } else {
          fixedTop = false;
        }

        // 这里是处理固定时加入position: fixed的代码
        // 以及在fixed时候插入占位元素的
        if (affixRef.current) {
          // 判断当前是否需要固定状态
          const affixed = fixedTop !== false;
          // 判断此时是否已经把占位元素插入进去了
          const placeholderStatus = affixWrapRef.current.contains(placeholderEL.current);
          
          // 如果当前需要处于固定状态
          if (affixed) {
            // 定位,这里的className主要就是position:fixed
            affixRef.current.className = `${classPrefix}-affix`;
            affixRef.current.style.top = `${fixedTop}px`;
            affixRef.current.style.width = `${wrapWidth}px`;
            affixRef.current.style.height = `${wrapHeight}px`;

            // 设置z-Index
            if (zIndex) {
              affixRef.current.style.zIndex = `${zIndex}`;
            }

            // 插入占位节点
            if (!placeholderStatus) {
              placeholderEL.current.style.width = `${wrapWidth}px`;
              placeholderEL.current.style.height = `${wrapHeight}px`;
              affixWrapRef.current.appendChild(placeholderEL.current);
            }
          } else {
            affixRef.current.removeAttribute('class');
            affixRef.current.removeAttribute('style');

            // 删除占位节点
            placeholderStatus && placeholderEL.current.remove();
          }
          
          // 触发onFixedChange,这里其实腾讯的T-deisgn实现的有问题,可以去提pr了,因为它应该判断当前fiexd的值是否跟上一次的不一样,那么说明fixed的情况发生变化了
          if (isFunction(onFixedChange)) {
            onFixedChange(affixed, { top: +fixedTop });
          }
        }

        ticking.current = false;
      });
    }
    ticking.current = true;
  }, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]);

  useImperativeHandle(ref, () => ({
    handleScroll,
  }));

  useEffect(() => {
    // 创建占位节点
    placeholderEL.current = document.createElement('div');
  }, []);

  useEffect(() => {
    scrollContainer.current = getScrollContainer(container);
    if (scrollContainer.current) {
      handleScroll();
      scrollContainer.current.addEventListener('scroll', handleScroll);
      window.addEventListener('resize', handleScroll);

      return () => {
        scrollContainer.current.removeEventListener('scroll', handleScroll);
        window.removeEventListener('resize', handleScroll);
      };
    }
  }, [container, handleScroll]);

  return (
    <div ref={affixWrapRef} className={className} style={style}>
      <div ref={affixRef}>{children}</div>
    </div>
  );
});

Affix.displayName = 'Affix';
Affix.defaultProps = affixDefaultProps;

export default Affix;

最后,我们把容器如果不是Window的情况处理一下,在handleScroll函数中

const {
          top: wrapToTop = 0,
          width: wrapWidth = 0,
          height: wrapHeight = 0,
        } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 };

        // 这里加入containerToTop,表示固定元素外部容器,距离浏览器顶部的高度
        // 因为固定元素是在这个容器里被固定,所以只能获取到
        let containerToTop = 0;
        if (scrollContainer.current instanceof HTMLElement) {
          containerToTop = scrollContainer.current.getBoundingClientRect().top;
        }

        // 这里需要你思考一下为啥有了容器,距离顶部的距离就是 wrapToTop - containerToTop
        const calcTop = wrapToTop - containerToTop; // 节点顶部到 container 顶部的距离
        // 如果是有容器的情况,就不能用innerHeight API了,只有window才有,所以可以用clientHeight来得到容器的高度
        const containerHeight =
          scrollContainer.current[scrollContainer.current instanceof Window ? 'innerHeight' : 'clientHeight'] -
          wrapHeight;
        
        // 这里其实很简单,原来我们用  containerHeight - (offsetBottom ?? 0)获取到容器是Window的情况
        // 现在改为其他容器,是不是只要加上containerToTop,也就是容器到浏览器视口顶部的高度就行了,哈哈
  
        const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值

上面讲完了代码,中场休息一下,我们接着讲一下单测怎么写。

image.png

首先,我们测试,

import React from 'react';
// 这里的render理解为test/library的render即可,就是渲染组件的函数
// describe是编写单测的函数,意思是我要把单测内容分组,比如这里这个组就是 Affix 组件测试
// vi可以理解为jest函数,拥有比如vi.fn -> jest.fn , vi.spyOn -> jest.spyOn等函数,功能也是一致的
import { render, describe, vi } from '@test/utils';
// 这里获取到我们之前写的Affix组件
import Affix from '../index';

describe('Affix 组件测试', () => {
  // 这里就是把Html的getBoundingClientRect函数模拟了一下
  const mockFn = vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect');
  const mockScrollTo = async (top: number) => {
    // mockImplementation函数就是具体模拟为什么,参数就是具体模拟的函数
    // 我们可以看到,我们模拟为 一个返回值为对象的函数,对象有 top: 传入值,bottom:0的key和value
    // 为什么需要这个模拟函数呢,是因为我们判断是否固定组件的一个很重要的依据就是getBoundingClientRect的top值
    mockFn.mockImplementation(
      () =>
        ({
          top,
          bottom: 0,
        } as DOMRect),
    );
  };
  // 我们在test之前,先把getBoundingClientRect的值。模拟为0,也就是top: 0
  beforeEach(async () => {
    await mockScrollTo(0);
  });
  test('render perfectly', async () => {
    const { queryByText } = render(
      <Affix>
        <div>固钉</div>
      </Affix>,
    );

    // 意思是获取到固定元素,然后存在于document中
    expect(queryByText('固钉')).toBeInTheDocument();
  })
});

接着我们假设offsetTop刚开始等于-1,没有固定,然后改为-10就固定住了

  test('offsetTop and onFixedChange', async () => {
    // 这里我们mock了一个函数,用来模拟在Affix触发onScroll事件的时候触发的函数
    const onFixedChangeMock = vi.fn();

    const { getByText } = render(
      <Affix offsetTop={-1} onFixedChange={onFixedChangeMock} zIndex={2}>
        <div>固钉</div>
      </Affix>,
    );
    
    // 此时因为offsetTop没有到-1,所以expect(getByText('固钉').parentNode).not.toHaveClass('t-affix') 是对的
    // 这个class类名出现是fixed的标志
    expect(onFixedChangeMock).toBeCalledTimes(0);
    expect(getByText('固钉').parentNode).not.toHaveClass('t-affix');

    此时掉一下mockScrollTo,把top变为-10,所以fixed的class就应该出现了
    await mockScrollTo(-10);

    setTimeout(() => {
      expect(onFixedChangeMock).toHaveBeenCalledTimes(1);
      expect(getByText('固钉').parentNode).toHaveClass('t-affix');
   
    }, 20);
  });

这里其实测试代码写的有点问题,就是没办法模拟scroll事件,这个谁有思路欢迎指点一下,谢谢了。

react如何把组件渲染到任意dom元素内?

前言

继续更新react组件库源码,大家有没有想过,我们在用Modal或者Dialog组件时,这个Modal被渲染到哪了?答案是body的下面:如下图:

image.png

渲染API

import { createPortal } from 'react-dom';

在 CSS 中,可以通过position: absolute, fixed等等方式让元素脱离文档流。而在 React 中,createPortal 直接改变了组件的挂载方式,不再是挂载到上层父节点上,而是可以让用户指定一个挂载节点。

Portal 实战案例

正常来说,我们写一个Dialog组件,传入children,如下,那么这个Dialog肯定是在文档流里面的。

import { Component } from 'react'
import Dialog from './Dialog'

class App extends Component {
  render() {
    return (
      <>
        <p>我在 root 里面</p>
        <Dialog>
          <p>我不在 root 里面</p>
        </Dialog>
      </>
    )
  }
}

export default App

那么,我们有时候渲染弹框组件想让这个传入children渲染到外层,也就是ReactDOM.render(, document.getElementById('root')),render函数不包括的地方,该怎么办呢。

如下:

import { Component } from 'react'
import { createPortal } from 'react-dom'

class Dialog extends Component {
  constructor(props) {
    super(props)
    this.dom = document.createElement('div')
    this.dom.setAttribute('id', 'portal')
    document.body.appendChild(this.dom)
  }

  render() {
    return createPortal(<>{this.props.children}</>, this.dom)
  }
}

export default Dialog

可以看下图,我们传入的children就渲染到了root外层

react-portal

封装Portal组件

代码比较短,我就直接贴代码了,注释写在里面

import React, { forwardRef, useEffect, useMemo, useImperativeHandle } from 'react';
import { createPortal } from 'react-dom';
// 判断是否是浏览器环境
import { canUseDocument } from '../_util/dom';


export interface PortalProps {
  /**
   * 指定挂载的 HTML 节点, false 为挂载在 body
   */
  attach?: React.ReactElement | AttachNode | boolean;
  /**
   * 触发元素
   */
  triggerNode?: HTMLElement;
  children: React.ReactNode;
}

// 获取要挂载的dom元素,可以简单理解为如果是字符串就用querySelector去查询此dom
// 如果是函数就调用,如果是HTMLement就返回该dom
// 默认返回 document.body
export function getAttach(attach: PortalProps['attach'], triggerNode?: HTMLElement): AttachNodeReturnValue {
  if (!canUseDocument) return null;

  let el: AttachNodeReturnValue;
  if (typeof attach === 'string') {
    el = document.querySelector(attach);
  }
  if (typeof attach === 'function') {
    el = attach(triggerNode);
  }
  if (typeof attach === 'object' && attach instanceof window.HTMLElement) {
    el = attach;
  }

  // fix el in iframe
  if (el && el.nodeType === 1) return el;

  return document.body;
}

const Portal = forwardRef((props: PortalProps, ref) => {
 // attach 指定挂载的 HTML 节点, false 为挂载在 body
 // triggerNode 触发元素
  const { attach, children, triggerNode } = props;

  // 创建 container
  const container = useMemo(() => {
    if (!canUseDocument) return null;
    const el = document.createElement('div');
    el.className = `${classPrefix}-portal-wrapper`;
    return el;
  }, [classPrefix]);

 // 把元素挂载到parentElement上, 默认parentElement是document.body
  useEffect(() => {
    const parentElement = getAttach(attach, triggerNode);
    parentElement?.appendChild?.(container);

    return () => {
      parentElement?.removeChild?.(container);
    };
  }, [container, attach, triggerNode]);

  useImperativeHandle(ref, () => container);

 // 因为container挂载到parentElement上,默认parentElement是document.body
 // 然后children就挂载了外面(本应该所有元素都在root下,这个被挂载了跟body标签同级下)
  return canUseDocument ? createPortal(children, container) : null;
});

Portal.displayName = 'Portal';

export default Portal;

我勒个去!modal组件这么难写你知道吗?

前言

简单看一下modal组件是什么(请查看以下demo链接):

印象中,modal组件就是一个弹框居中,然后一个黑色蒙层fixed布局放在后面,感觉很简单啊,但是深入研究各个组件库的modal源码,发现技术细节有很多,不信你就接着看!保证没做过modal组件的同学不知道。

我简单列一下技术难点,你可以测试一下你用的组件库是否注意这些细节了,欢迎留言讨论!

技术难点1:如何处理滚动条样式和嵌套modal

为什么要处理滚动条样式?

滚动条样式当出现modal的时候,如果此时window窗口有滚动条,一般情况我们需要隐藏滚动条。其实就是在body上加一个样式overfolow: hidden。这样用户就会更专注于此时的弹框。

嵌套modal是什么?

嵌套modal,也就是按钮弹出modal弹窗,弹窗里又有一个按钮,点击之后再之前的基础上又有一个modal

技术难点在哪

一般情况,我们需要判断此时是否是最后一层modal,如果是,才把body样式的滚动条隐藏,所以嵌套modal多余一个modal时,我们是不需要再次隐藏body样式的,而且最后一个modal关闭的时候,我们还需要把body原本的样式还原。

所以我们需要一个管理所有modal的管理器,去记录所有的modal目前有几个,正在打开的是第几个。

解决方案

这里的解决方案一般有两种,一种是例如material ui,chakra, 小米公司的modal,都是用一个数据结构保存所有modal,然后每次关闭一个modal,就去所有modal里找是否是最后一个modal,如果是最后一个才回复body原本的style.

第二采取了字节arco design的处理方法,还是比较巧妙的,

请看下面的useOverflowHidden, 我们详细的看下如何处理隐藏body滚动条和恢复的时机和具体方法。

import { useEffect, useRef } from 'react';
import { resetContainerStyle, setContainerStyle } from '../utils';

export function useOverflowHidden(getContainer: () => HTMLElement, hidden: boolean) {
  const needResetContainerStyle = useRef<boolean>(false);
  const originContainerStyle = useRef<Partial<CSSStyleDeclaration>>({});

  useEffect(() => {
    hidden
      ? setContainerStyle({ needResetContainerStyle, originContainerStyle, getContainer })
      : resetContainerStyle({ needResetContainerStyle, originContainerStyle, getContainer });
    return () => {
      resetContainerStyle({ needResetContainerStyle, originContainerStyle, getContainer });
    };
  }, [getContainer, hidden]);
}
  • getContainer代码要挂载到html文档流哪个dom中,我们默认是body元素中

  • hidden是指是否弹框的时候,我们需要黑色蒙层,也就是有时候我们传参不需要这个蒙层,也就意味着我们不想让body滚动条消失,所以我们在看到modal弹框的同时,也能滚动后面的页面,当然,我们这里大家可以看为是true,我们是需要黑色蒙层的

  • needResetContainerStyle用来记录是否重置body style样式,只有调用setContainerStyle方法后,并且是第一个触发的modal框,这个值才会是true

我们马上看下setContainerStyle,也就是设置body滚动条隐藏的函数:

import { getScrollBarWidth } from './getScrollBarWidth';

/**
 * Hides the container's scroll bar
 */
export const setContainerStyle = ({ getContainer, needResetContainerStyle, originContainerStyle }) => {
  const container = getContainer();
  if (container && container.style.overflow !== 'hidden') {
    /**
     * @zh 记录container的style属性, 因为后续要将container.style.overflow设为hidden
     * @en Record the container's style property, because I'll set container.style.overflow to hidden later
     */
    const originStyle = container.style;

    /**
     * @zh 记录是否 container.style.overflow 被覆盖为hidden
     * @en Note whether container.style.overflow is overwritten as hidden
     */
    needResetContainerStyle.current = true;

    const containerScrollBarWidth = getScrollBarWidth(container);
    if (containerScrollBarWidth) {
      originContainerStyle.current.width = originStyle.width;
      container.style.width = `calc(${container.style.width || '100%'} - ${containerScrollBarWidth}px)`;
    }

    /**
     * @zh 设置container的overflow为hidden
     * @en Set container overflow to hidden
     */
    originContainerStyle.current.overflow = originStyle.overflow;
    container.style.overflow = 'hidden';
  }
};

简单来说,使用originContainerStyle来记录原本的body元素上的style.overflow属性是什么,然后把body的style.overflow设置为hidden

needResetContainerStyle也记录下,已经设置过body的styel样式了

当然,如果滚动条有宽度,我们也要把滚动条宽度那部分减去,以防点开的时候,因为滚动条overflow为hidden,宽度减小了,视觉上会有抖动,这是个很细的交互细节。

技术难点2:如何锁定焦点

什么是锁定焦点吗?

当你打开modal的时候,在键盘上按下tab键,会将

image.png

上图所示的focus状态,会在你按下回车键的时候触发这个按钮的onClick事件。而且你一直按Tab键,焦点只会在当前Modal框里,不会移除到Modal框外,这种focus状态锁定技术是需要解决的。

并且有些人并不知道tabIndex,有兴趣的同学可以搜索一下,通过tabIndex,我们可以让关闭按钮,也就是右上角的x也能获取焦点,我的组件库并没有处理这个细节,是因为按ESC键就可以关闭弹窗,这样做我感觉多此一举。

解决方案

锁定焦点,我使用了大多数组件库都会使用的库,react-focus-lock,它用于在可见区域内锁定焦点。它的原理是通过捕获焦点事件并将焦点限制在指定的容器内,以防止焦点离开该容器。

具体的原理我没有探究,本来我猜想是监听focus事件,然后用event.preventDefault()阻止那些不在视觉焦点内的focus效果,但是我试了一下不行。

继续猜想,以下代码可行:

注:以下的shift + tab是指聚焦到上一个聚焦元素上,tab键是聚焦到下一个聚焦元素上

function createFocusTrap(element) {
  const focusableElements = Array.from(
    element.querySelectorAll(
      'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
  );

  let firstFocusableElement = focusableElements[0];
  let lastFocusableElement = focusableElements[focusableElements.length - 1];

  function handleKeyDown(event) {
    if (event.key === 'Tab') {
      if (event.shiftKey && document.activeElement === firstFocusableElement) {
        event.preventDefault();
        lastFocusableElement.focus();
      } else if (!event.shiftKey && document.activeElement === lastFocusableElement) {
        event.preventDefault();
        firstFocusableElement.focus();
      }
    }
  }

  element.addEventListener('keydown', handleKeyDown);
  element.focus();
}

上面的代码是非常非常巧妙的,这才是前端的稍微有一点难度的东西,而不是仅仅用ant的api。它强行创造了一个锁定聚焦区域的循环链。

首先,你传入createFocusTrap中一个element,然后,先聚焦到这个element,然后你按tab键的时候,就从这个聚焦的地方开始了,等在tab键按到最后一个聚焦的时候,我们又强行让他从聚焦区域第一个聚焦元素开始,这样就强行锁定到element元素中了

技术难点3:API设计:是否支持灵活的函数调用

modal组件是否暴露了增删改查modal内容的能力,这样modal的灵活性就会大大增加,比如关闭modal框的时候,我们希望先请求后端的接口校验,此时modal的确定按钮处于loading状态,如果后端校验通过才关闭modal,不通过就不关闭。

例如:

Modal.add 增加modal
Modal.remove 关闭modal
Modal.update 更新modal
Modal.removeAll 关闭所有modal

还有调用方法,而不是组件使用:

<Modal />

因为做过b端的同学都应该会有这个感受,一般弹框类的组件都是提示类的,提示类组件基本都是通过onClick触发,所以如果显示Modal也是函数调用就会非常方便,例如:

Modal.add({ ...xxx参数 })

所以我放弃了组件调用的方式,例如:

<Modal />

甚至我看到有些开源的平台,例如某国内知名的k8s开源平台,前端项目中,居然把组件调用的方式全部重写封装为函数调用,你就可以知道在b端是多么需要函数调用提示类弹框了。

技术难点4:细节决定成败:动画系统 + 边界case

后面会详细介绍一下我在组件库运用的动画系统,framer-motion,未来很多年后,肯定会有超过它的react动画系统,但目前,毫无疑问,它是最强的react动画系统。

大家可以去查看一下国内组件库主要用的动画系统,其中acro design和tdesign都是用的react-transition-group,这个组件库更适合以前用类组件的时候用,它对hooks的契合度总感觉很别扭(目前源码还是用的类组件实现)

还有一个就是它的性能真的很差,我们的项目多次因为这个动画系统卡顿,所以后来换到了framer-motion,framer-motion最大的缺点是包体积比较大,所以打包项目一定要做tree shaking,这样在按需加载的技术下,包的体积会大大减小。

ant design有一套自己的动画系统,没有深入研究,起码性能感觉还是不错的。

后续会有文章介绍国内组件库常用的动画系统以及framer-motion的入门教程。这里只是强调你在写组件库的时候,一定要慎重选择动画系统,一旦选择了,可能就会一直用下去。

你如果是新系统,framer-motion我是第一推荐的。

边界case有很多,比如拖住modal框,然后鼠标移入到蒙层,最后松开鼠标,此时modal框不应该关闭,只有在蒙层完成一次点击才能算关闭,所以这个需要处理。

还有,需要注册一些键盘事件,例如esc就触发onCancel事件。

好了,最后说一点最重要的,modal,message,draw,Notification组件,其实在我的组件库都用的一套hooks,稍微改改UI,就快速实现另一种组件了,这就是抽象数据层的意义,有兴趣的同学可以去看# 如何在代码质量上超过大多数react ui 组件库 (拿Message组件举例

我的天!居然国内所有react组件库都有这个bug!

前言

首先声明,没有标题党哈!

以下我知道的国内知名react组件库全部都有这个bug,你们现在都能去复现,一个提pr的好机会就让给你们了,哈哈!复现组件库(点击可以跳到对应affix组件展示网页,你们可以根据我后面说的复现bug的方式来测试):

本来字节还有一个semi design,结果我发现它没有Affix组件,也就是固钉组件,让他躲过一劫,他有这个组件我也觉得肯定会复现相同的bug。

Affix组件是什么,以及bug复现

Affix组件(固钉组件)能将页面元素钉在可视范围。如下图:

image.png

这个button组件,会在距离顶部80px的时候会固定在屏幕上(position: fixed),如下图:

image.png

如何复现bug

你在这个button元素任意父元素上,加上以下任意style属性

  • will-change: transform;
  • will-change: filter;
  • will-change: perspective;
  • transform 不为none
  • perspective不为none
  • 非safari浏览器,filter属性不为none
  • 非safari浏览器,backdrop-filter属性不为none
  • 等等

都可以让这个固定组件失效,就是原本是距离顶部80px固定。

我的组件库没有这个bug,哈哈

mx-design

目前组件不是很多,还在努力迭代中,不知道凭借没有这个bug的小小优点,能不能从你手里取一个star,哈哈

bug原因

affix组件无非都是用了fixed布局,我是如何发现这个bug的呢,我的组件库动画系统用的framer-motion,我本来是想在react-router切换路由的时候整点动画的,动画效果就是给body元素加入例如transform的变化。

然后我再看我的固钉组件怎么失效了。。。后来仔细一想,才发现想起来fixed布局的一个坑就是,大家都以为fixed布局相对的父元素是window窗口,其实是错误的!

真正的规则如下(以下说的包含块就是fixed布局的定位父元素):

  1. 如果 position 属性是 absolute 或 fixed,包含块也可能是由满足以下条件的最近父级元素的内边距区的边缘组成的:

    1. transform 或 perspective 的值不是 none
    2. will-change 的值是 transform 或 perspective
    3. filter 的值不是 none 或 will-change 的值是 filter(只在 Firefox 下生效)。
    4. contain 的值是 paint(例如:contain: paint;
    5. backdrop-filter 的值不是 none(例如:backdrop-filter: blur(10px);

评论区有很多同学居然觉的这不是bug?

其实这个问题本质是定位错误,在这些组件库里,同样使用到定位的有,例如Tooltip,Select,Popuver等等,明显这些组件跟写Affix组件的不是一个人,其他组件这个bug是没有的,只有Affix组件出现了,所以你说这是不是bug。

还有,如果因为引用了Affix组件,这个固定元素的任一父元素都不能用以上的css属性,我作为使用者,我用了动画库,动画库使用transfrom做Gpu加速,你说不让我用了,因为引起Affix组件bug,我心里想凭啥啊,明明加两行代码就解决了。

最后,只要做过定位组件的同学,其复杂度在前端算是比较高的了,这也是为什么有些组件库直接用第三方定位组件库(floating-ui,@popper-js),而不是自己去实现,因为自己实现很容易出bug,这也是例如以上组件库Tooltip为什么能适应很多边界case而不出bug。

所以你想想,这仅仅是定位组件遇到的一个很小的问题,你这个都解决不了,什么都怪css,你觉得用户会这么想吗,一有css,你所有跟定位相关的组件全部都不能用了,你们还讲理不?

总之一句话,你不能把定位组件的复杂度高怪用户没好好用,建议去看看floating-ui的源码,或者之前我写的@popper-js定位组件的简要逻辑梳理,你就会意识到定位组件不简单。边界case多如牛毛。

解决方案

  • 首先是找出要固定元素的定位元素(定位元素的判断逻辑上面写了),然后如果定位元素是window,那么跟目前所有组件库的逻辑一样,所以没有bug,如果不是window,就要求出相对定位父元素距离可视窗口顶部的top的值
  • 然后在我们原本要定位的值,比如距离顶部80px的时候固定,此时80px再减去上面说的定位父元素距离可视窗口顶部的top的值,就没有bug了

具体代码如下:

  • offsetParent固定元素的定位上下文,也就是相对定位的父元素
  • fixedTop是我们要触发固定的值,比如距离可视窗口顶部80px就固定
affixDom.style.top = `${isHTMLElement(offsetParent) ? (fixedTop as number) - offsetParent.getBoundingClientRect().top : fixedTop}px`;

如何找出offsetParent,也就是定位上下文

export function getContainingBlock(element: Element) {
  let currentNode = element.parentElement;
  while (currentNode) {
    if (isContainingBlock(currentNode)) return currentNode;
    currentNode = currentNode.parentElement;
  }
  return null;
}

工具方法,isContainingBlock如下:

import { isSafari } from './isSafari';

export function isContainingBlock(element: Element): boolean {
  const safari = isSafari();
  const css = getComputedStyle(element);

  // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
  return (
    css.transform !== 'none' ||
    css.perspective !== 'none' ||
    (css.containerType ? css.containerType !== 'normal' : false) ||
    (!safari && (css.backdropFilter ? css.backdropFilter !== 'none' : false)) ||
    (!safari && (css.filter ? css.filter !== 'none' : false)) ||
    ['transform', 'perspective', 'filter'].some((value) => (css.willChange || '').includes(value)) ||
    ['paint', 'layout', 'strict', 'content'].some((value) => (css.contain || '').includes(value))
  );
}

本文完毕,求关注,求star!!!对于react组件库感兴趣的小伙伴,欢迎加群一起交流哦!

我勒个去!modal组件这么难写你知道吗?

前言

简单看一下 modal 组件是什么(请查看以下 demo 链接):

印象中,modal 组件就是一个弹框居中,然后一个黑色蒙层 fixed 布局放在后面,感觉很简单啊,但是深入研究各个组件库的 modal 源码,发现技术细节有很多,不信你就接着看!保证没做过 modal 组件的同学不知道。

我简单列一下技术难点,你可以测试一下你用的组件库是否注意这些细节了,欢迎留言讨论!

技术难点 1:如何处理滚动条样式和嵌套 modal

为什么要处理滚动条样式?

滚动条样式当出现 modal 的时候,如果此时 window 窗口有滚动条,一般情况我们需要隐藏滚动条。其实就是在 body 上加一个样式overfolow: hidden。这样用户就会更专注于此时的弹框。

嵌套 modal 是什么?

嵌套 modal,也就是按钮弹出 modal 弹窗,弹窗里又有一个按钮,点击之后再之前的基础上又有一个 modal

技术难点在哪

一般情况,我们需要判断此时是否是最后一层 modal,如果是,才把 body 样式的滚动条隐藏,所以嵌套 modal 多余一个 modal 时,我们是不需要再次隐藏 body 样式的,而且最后一个 modal 关闭的时候,我们还需要把 body 原本的样式还原。

所以我们需要一个管理所有 modal 的管理器,去记录所有的 modal 目前有几个,正在打开的是第几个。

解决方案

这里的解决方案一般有两种,一种是例如 material ui,chakra, 小米公司的 modal,都是用一个数据结构保存所有 modal,然后每次关闭一个 modal,就去所有 modal 里找是否是最后一个 modal,如果是最后一个才回复 body 原本的 style.

第二采取了字节 arco design 的处理方法,还是比较巧妙的,

请看下面的 useOverflowHidden, 我们详细的看下如何处理隐藏 body 滚动条和恢复的时机和具体方法。

import { useEffect, useRef } from "react";
import { resetContainerStyle, setContainerStyle } from "../utils";

export function useOverflowHidden(
  getContainer: () => HTMLElement,
  hidden: boolean
) {
  const needResetContainerStyle = useRef < boolean > false;
  const originContainerStyle = useRef < Partial < CSSStyleDeclaration >> {};

  useEffect(() => {
    hidden
      ? setContainerStyle({
          needResetContainerStyle,
          originContainerStyle,
          getContainer,
        })
      : resetContainerStyle({
          needResetContainerStyle,
          originContainerStyle,
          getContainer,
        });
    return () => {
      resetContainerStyle({
        needResetContainerStyle,
        originContainerStyle,
        getContainer,
      });
    };
  }, [getContainer, hidden]);
}
  • getContainer 代码要挂载到 html 文档流哪个 dom 中,我们默认是 body 元素中

  • hidden 是指是否弹框的时候,我们需要黑色蒙层,也就是有时候我们传参不需要这个蒙层,也就意味着我们不想让 body 滚动条消失,所以我们在看到 modal 弹框的同时,也能滚动后面的页面,当然,我们这里大家可以看为是 true,我们是需要黑色蒙层的

  • needResetContainerStyle 用来记录是否重置 body style 样式,只有调用 setContainerStyle 方法后,并且是第一个触发的 modal 框,这个值才会是 true

我们马上看下 setContainerStyle,也就是设置 body 滚动条隐藏的函数:

import { getScrollBarWidth } from "./getScrollBarWidth";

/**
 * Hides the container's scroll bar
 */
export const setContainerStyle = ({
  getContainer,
  needResetContainerStyle,
  originContainerStyle,
}) => {
  const container = getContainer();
  if (container && container.style.overflow !== "hidden") {
    /**
     * @zh 记录container的style属性, 因为后续要将container.style.overflow设为hidden
     * @en Record the container's style property, because I'll set container.style.overflow to hidden later
     */
    const originStyle = container.style;

    /**
     * @zh 记录是否 container.style.overflow 被覆盖为hidden
     * @en Note whether container.style.overflow is overwritten as hidden
     */
    needResetContainerStyle.current = true;

    const containerScrollBarWidth = getScrollBarWidth(container);
    if (containerScrollBarWidth) {
      originContainerStyle.current.width = originStyle.width;
      container.style.width = `calc(${
        container.style.width || "100%"
      } - ${containerScrollBarWidth}px)`;
    }

    /**
     * @zh 设置container的overflow为hidden
     * @en Set container overflow to hidden
     */
    originContainerStyle.current.overflow = originStyle.overflow;
    container.style.overflow = "hidden";
  }
};

简单来说,使用 originContainerStyle 来记录原本的 body 元素上的 style.overflow 属性是什么,然后把 body 的 style.overflow 设置为 hidden

needResetContainerStyle 也记录下,已经设置过 body 的 styel 样式了

当然,如果滚动条有宽度,我们也要把滚动条宽度那部分减去,以防点开的时候,因为滚动条 overflow 为 hidden,宽度减小了,视觉上会有抖动,这是个很细的交互细节。

技术难点 2:如何锁定焦点

什么是锁定焦点吗?

当你打开 modal 的时候,在键盘上按下 tab 键,会将

image.png

上图所示的 focus 状态,会在你按下回车键的时候触发这个按钮的 onClick 事件。而且你一直按 Tab 键,焦点只会在当前 Modal 框里,不会移除到 Modal 框外,这种 focus 状态锁定技术是需要解决的。

并且有些人并不知道 tabIndex,有兴趣的同学可以搜索一下,通过 tabIndex,我们可以让关闭按钮,也就是右上角的 x 也能获取焦点,我的组件库并没有处理这个细节,是因为按 ESC 键就可以关闭弹窗,这样做我感觉多此一举。

解决方案

锁定焦点,我使用了大多数组件库都会使用的库,react-focus-lock,它用于在可见区域内锁定焦点。它的原理是通过捕获焦点事件并将焦点限制在指定的容器内,以防止焦点离开该容器。

具体的原理我没有探究,本来我猜想是监听 focus 事件,然后用 event.preventDefault()阻止那些不在视觉焦点内的 focus 效果,但是我试了一下不行。

继续猜想,以下代码可行:

注:以下的 shift + tab 是指聚焦到上一个聚焦元素上,tab 键是聚焦到下一个聚焦元素上

function createFocusTrap(element) {
  const focusableElements = Array.from(
    element.querySelectorAll(
      'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
  );

  let firstFocusableElement = focusableElements[0];
  let lastFocusableElement = focusableElements[focusableElements.length - 1];

  function handleKeyDown(event) {
    if (event.key === "Tab") {
      if (event.shiftKey && document.activeElement === firstFocusableElement) {
        event.preventDefault();
        lastFocusableElement.focus();
      } else if (
        !event.shiftKey &&
        document.activeElement === lastFocusableElement
      ) {
        event.preventDefault();
        firstFocusableElement.focus();
      }
    }
  }

  element.addEventListener("keydown", handleKeyDown);
  element.focus();
}

上面的代码是非常非常巧妙的,这才是前端的稍微有一点难度的东西,而不是仅仅用 ant 的 api。它强行创造了一个锁定聚焦区域的循环链。

首先,你传入 createFocusTrap 中一个 element,然后,先聚焦到这个 element,然后你按 tab 键的时候,就从这个聚焦的地方开始了,等在 tab 键按到最后一个聚焦的时候,我们又强行让他从聚焦区域第一个聚焦元素开始,这样就强行锁定到 element 元素中了

技术难点 3:API 设计:是否支持灵活的函数调用

modal 组件是否暴露了增删改查 modal 内容的能力,这样 modal 的灵活性就会大大增加,比如关闭 modal 框的时候,我们希望先请求后端的接口校验,此时 modal 的确定按钮处于 loading 状态,如果后端校验通过才关闭 modal,不通过就不关闭。

例如:

Modal.add 增加modal
Modal.remove 关闭modal
Modal.update 更新modal
Modal.removeAll 关闭所有modal

还有调用方法,而不是组件使用:

<Modal />

因为做过 b 端的同学都应该会有这个感受,一般弹框类的组件都是提示类的,提示类组件基本都是通过 onClick 触发,所以如果显示 Modal 也是函数调用就会非常方便,例如:

Modal.add({ ...xxx参数 });

所以我放弃了组件调用的方式,例如:

<Modal />

甚至我看到有些开源的平台,例如某国内知名的 k8s 开源平台,前端项目中,居然把组件调用的方式全部重写封装为函数调用,你就可以知道在 b 端是多么需要函数调用提示类弹框了。

技术难点 4:细节决定成败:动画系统 + 边界 case

后面会详细介绍一下我在组件库运用的动画系统,framer-motion,未来很多年后,肯定会有超过它的 react 动画系统,但目前,毫无疑问,它是最强的 react 动画系统。

大家可以去查看一下国内组件库主要用的动画系统,其中 acro design 和 tdesign 都是用的react-transition-group,这个组件库更适合以前用类组件的时候用,它对 hooks 的契合度总感觉很别扭(目前源码还是用的类组件实现)

还有一个就是它的性能真的很差,我们的项目多次因为这个动画系统卡顿,所以后来换到了 framer-motion,framer-motion 最大的缺点是包体积比较大,所以打包项目一定要做 tree shaking,这样在按需加载的技术下,包的体积会大大减小。

ant design 有一套自己的动画系统,没有深入研究,起码性能感觉还是不错的。

后续会有文章介绍国内组件库常用的动画系统以及 framer-motion 的入门教程。这里只是强调你在写组件库的时候,一定要慎重选择动画系统,一旦选择了,可能就会一直用下去。

你如果是新系统,framer-motion 我是第一推荐的。

边界 case 有很多,比如拖住 modal 框,然后鼠标移入到蒙层,最后松开鼠标,此时 modal 框不应该关闭,只有在蒙层完成一次点击才能算关闭,所以这个需要处理。

还有,需要注册一些键盘事件,例如 esc 就触发 onCancel 事件。

好了,最后说一点最重要的,modal,message,draw,Notification 组件,其实在我的组件库都用的一套 hooks,稍微改改 UI,就快速实现另一种组件了,这就是抽象数据层的意义,有兴趣的同学可以去看# 如何在代码质量上超过大多数 react ui 组件库 (拿 Message 组件举例

Css方案之主题切换功能设计

前言

ant design5的样式设计的功能从以前的less迁移到了css-in-js,其中有一个原因就是社区反应对ant做定制化的css改造很困难(所以主题切换更困难),大家可能并不知道困难的原因点所在,以及如何基于less做ant的定制化样式。

ant deisgn5之前定制化样式困难的原因

你知道为什么用以下的方式导入ant的组件,为什么css样式也一同导入进来了吗?

import { message } from 'antd';

是因为ant为了实现css的按需加载(就是我使用了message组件,我只加载message组件的css样式,不加载别的组件的css),使用babel-plugin-import,使用方法如下

image.png

也就是babel在编译你的代码的时候,会自动帮你引入对应组件的css。

可是成也babel-plugin-import,败也babel-plugin-import,这样确实很方便,对于新手用户而言。但问题又很明显,很多人用ant-deisgn都不知道为什么像我们上面那样导入组件,css也导入进来了,这样的话你根本没办法对ant做样式上的定制化改造。

所以很多项目,包括大厂很多项目,甚至我在用飞书的时候,飞书最开始也存在的问题,就是css覆盖,大家普遍的思路都是用新的,同名的css去覆盖ant的样式。

问题也很明显,css维护起来很糟心,尤其是大型项目,因为每个模块可能都是不同的项目组在维护,很可能一个组覆盖的css影响了另一个组。

正确的解决办法是什么呢?

首先,不要引入babel-plugin-import,然后单独封装每个组件的css,举个例子,我们有一个ant4版本的Button组件,我们这样改造:

// Button组件
import { Button } from 'antd' ;
// 这里引入的是css,当然你的项目用sass或者less,改成相应的就行了
import './style/index.css'

一般你只有用ant的less,改造成本才比较小

/** 这里复制粘贴ant button的css 然后改其中的css样式*/

当然,如果你

import './style/index.css'

改为

import './style/index.less'

但还需要把less里的公共变量也需要一起改了,这个就更复杂一些。

所以你可以看到,ant design在更改样式这块,实在是太难改了,这也是ant5为什么放弃less,转向了css-in-js,因为css-in-js相当于把css全部交给js去处理,这样我们在js里修改变量相当于修改了css。

css-in-js真的解决了定制化样式的问题了吗?

css-in-js 相当于给你开放一些css变量让你动态设置,我们看下ant5的文档是怎么使用的

image.png

也就是我们其实也不是随心所欲的去修改css,也是在人家开放接口的范围内修改。如果你想修改的css样式不在其提供的修改样式的接口里,你还是不能定制化自己的样式。

所以css-in-js并没有想象中那么完美。

如果我们用less或者sass,也能做到类似的功能,怎么做呢,休息喝口水,咋接着看

我的组件库是如何做的

我们拿button组件为例,最终打包的组件库,我会生成一个index.css,这个css中包含了一些css变量,例如

.mx-base-button {
  display: inline-flex;
  position: relative;
  align-items: center;
  justify-content: center;
  outline: none;
  padding: var(--btn-padding);
  height: var(--btn-height);
  appearance: none;
  user-select: none;
  cursor: pointer;
  white-space: nowrap;
  transition: all 0.2s var(--transition-timing-function-standard);
  box-sizing: border-box;
  line-height: 1.5715;
  border-radius: var(--btn-radius);
}

然后,对外暴露一个修改css变量的方法,例如:

import { isObject } from './is';

/**
 * 更换css变量的方法
 */
export function setCssVariables(variables: Record<string, any>, root = document.body) {
  if (variables && isObject(variables)) {
    Object.keys(variables).forEach((themKey) => {
      root.style.setProperty(themKey, variables[themKey]);
    });
  }
}

其实这里差不多就实现了自定义主题。其实实现的效果跟css-in-js差不多,但是彼此优劣也很明显,首先我们的css变量是语法更加标准,因为没有js到css转化,所以性能肯定比css-in-js更好,对react没有入侵。

这两种方式并不能说谁绝对的好和绝对的坏,我的组件库采取后者的主要原因是我看到了一篇文章叫做《我们为何放弃css-in-js》中提到了一条:

  1. css-in-js 运行时解析的实现版本增加了运行时性能压力,尤其在 React18 调度机制模式下,存在无法解决的性能问题(运行时插入样式会导致 React 渲染暂停,浏览器解析一遍样式,渲染再继续,然后浏览器又解析一遍样式)。

所以为了性能我放弃了css-in-js这个方案。我们接着来看看我的这种借助css变量实现的主题切换方案,如何跟react相结合。

我们的实现的目标为:

1、局部样式改变

比如所有的button默认都是蓝色,但是某个button我想是黄色,允许单独给button传递主题色。

2、全局样式改变

比如我想对所有的button的主色都设为绿色,提供一个全局更换颜色配置的入口。

全局更换主题色

我们在react层面提供一个ThemeProvider,把配置的主题色能够下发到每个组件,然后可以把开放的所有组件的css变量在这个ThemeProvider中完成修改,例如:

function setCssVariables(variables: Record<string, any>, root = document.body) {
  if (variables && isObject(variables)) {
    Object.keys(variables).forEach((themKey) => {
      root.style.setProperty(themKey, variables[themKey]);
    });
  }

function useIsFirstRender(): boolean {
  const isFirst = useRef(true)

  if (isFirst.current) {
    isFirst.current = false

    return true
  }

  return isFirst.current
}

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

export function MxThemeProvider(variables) {
  const variablesProps = useMemo(() => ({ ...variables }), [variables]);
  const previousVariablesProps = usePrevious(variablesProps);
  const isFirst = useIsFirstRender()

  if (isFirst || variablesProps !== previousVariablesProps) setCssVariables(variables);

  return (
      <ThemeContext.Provider value={config}>{children}</ConfigContext.Provider>
  );
}

局部更换主题色

最开始我是想像css-in-js那样,传递参数,然后用style来设置样式,但是毫无疑问不能这样做,这样会影响css优先级,有些同学更改了class可能因为没有style优先级高而样式不生效,造成奇怪的体验。

当然这也是普通css不如css-in-js的地方,可以像传入js变量一样更改css。但是我们也有办法,之前说了,这样独立样式的button毕竟不是常见的需求,因为一般大家的ui都有一套设计规范,我们在全局更换主题色即可。

这样单独修改的需求可以将从打包好的css样式中提取出对应组件的css,把样式单独更改后,和js一起导出。

也可以采取覆盖局部样式的方式。(不推荐)

现在想来,局部更换主题色似乎不太好做,还好,原生css变量支持css变量的作用域,什么意思呢?在mdn中,这种作用域被称之为继承性。以下转自mdn对继承性的解释和案例:

自定义属性会继承。这意味着如果在一个给定的元素上,没有为这个自定义属性设置值,在其父元素上的值会被使用。看这一段 HTML:

<div class="one">
  <div class="two">
    <div class="three"></div>
    <div class="four"></div>
  </div>
</div>

配套的 CSS:

.two {
  --test: 10px;
}

.three {
  --test: 2em;
}

在这个情况下, var(--test) 的结果分别是:

  • 对于元素 class="two" :10px
  • 对于元素 class="three" :2em
  • 对于元素 class="four" :10px (继承自父属性)

啥意思呢,就是我的button组件,我可以默认用全局变量的样式,比如我设置在body上,然后button组件包裹一个div,div上也有同名的一个变量,那么button组件会优先使用div上的变量。

基于以上原理我可以在button组件里,直接在style中用来设置css变量,代码如下

const localBtnTheme = {
    "--btn-color": "red",
    "--btn-width": 12
};

<div style={{ style, ...localBtnTheme }} >

其中style是正常外面传给组件的style, localBtnTheme是指css变量

如果你有其他组件库主题切换的方案,欢迎在评论区,或者我的github上,微信组件库讨论群一起讨论哦

【目录】组件原理教程

gridLayout组件原理

前言

为什么我们需要这样的组件

现在国内国外主流B端react组件库都会有一个叫布局的组件,在国内基本上有两个组件去实现布局功能:

  • Grid 栅格布局组件
  • Layout 布局组件

是因为早期浏览器对grid布局的兼容性并不好,所以这些库基本上都使用flex布局去实现复杂的二维布局。

但是今天gird布局兼容性良好,如下图

Alt text

并且显然grid布局在处理更复杂的二维布局时,是远远超过flex的。这也是它出现的原因。所以我的react ui组件库直接使用了GridLayout组件代替了传统国内组件库的上述两个组件(Grid and Layout)。

注意:flex布局并不过时,只是跟grid适用场景不同

ant design 的布局组件示例如下:

Alt text

element plus 布局组件示例如下:

Alt text

用过的同学都知道,这些组件主要是帮助我们布局的,并且可以设置在不同屏幕尺寸下布局的样式。

假如我们要布局成如下样式,以上的布局组件实现起来比较麻烦,不妨你们尝试一下:

image.png

开始用flex写了一版,总感觉代码非常不优雅,条件判断很多,后来一想,我们是新项目,不兼容ie的,为啥不用grid布局呢,但是grid布局纯写css并不难,怎么把其封装成一个通用组件,并且使用起来要:

替代antd的grid组件! element plus 的layout组件!

问题就来了,市面上通用组件库都没这个组件啊,咋办,手写呗。参考开源项目 styled-css-grid,咋们用react函数式组件实现,并附有在线案例。因为代码只涉及到css封装,vue也可以借鉴然后封装自己的超强布局组件。

代码很优雅,只有两个文件。先看效果,以下所有效果在文章开头的demo示例里都有:

案例一(这个案例的目的是说明我们组件具备子元素偏移的能力):

Alt text

import { GridLayout, Cell, Button } from '@mx-design/web';

function App() {
  const cellStyle = { background: "var(--bg-color-component)", padding: '20px 0' };
  return (
    <GridLayout columns={3}>
      <Cell style={cellStyle} middle>Top Left</Cell>
      <Cell left={3} style={cellStyle} middle>Top Right</Cell>
      <Cell left={2} top={2} style={cellStyle} middle>Middle</Cell>
      <Cell top={3} style={cellStyle} middle>Bottom Left</Cell>
      <Cell top={3} left={3} style={cellStyle} middle>Bottom Right</Cell>
    </GridLayout>
  )
}

案例2(这个案例说明我们的组件有任意分隔栅格布局的能力):

Alt text

import { GridLayout, Cell, Button } from '@mx-design/web';

function App() {
  const cellStyle = { background: "var(--bg-color-component)", padding: '4px 0' };
  return (
      <GridLayout columns={6}>
        <Cell style={cellStyle} width={1} middle>1/6</Cell>
        <Cell style={cellStyle} width={1} middle>1/6</Cell>
        <Cell style={cellStyle} width={1} middle>1/6</Cell>
        <Cell style={cellStyle} width={1} middle>1/6</Cell>
        <Cell style={cellStyle} width={1} middle>1/6</Cell>
        <Cell style={cellStyle} width={1} middle>1/6</Cell>
        <Cell style={cellStyle} width={2} middle>2/6</Cell>
        <Cell style={cellStyle} width={2} middle>2/6</Cell>
        <Cell style={cellStyle} width={2} middle>2/6</Cell>
        <Cell style={cellStyle} width={3} middle>1/3</Cell>
        <Cell style={cellStyle} width={3} middle>1/3</Cell>
        <Cell style={cellStyle} width={6} middle>1/1</Cell>
      </GridLayout>
  )
}

案例三(代表我们组件具有奇形怪状的能力):
Alt text

当然我们也具备本身flex的左对齐,右对齐,中间对齐这些功能:

Alt text

flex布局并不过时,只是跟grid适用场景不同

如果你需要一些处理稍微复杂的二维布局场景,grid布局显然是目前最强的css布局方式,但是平时我们的业务相对简单,能使用flex布局解决的,并不需要使用grid的布局。

为什么我们要封装一个这样的组件,是因为上面所说的一些react ui库,使用flex布局实现栅格化的效果,但实现的相对复杂,是因为早期浏览器对grid布局的兼容性并不好。

既然现在不存在兼容性问题,为什么不用原生的css来实现栅格化布局呢?

手写增强版 @popper-js (主体逻辑分析)

前言

首先声明,后续所有组件都会有类似详细的教程 + 代码演示(以前的组件库教程不用看了,这是一个新的开始,是生产环境可用的,并且拿国内知名组件库的功能和代码质量作为对比,市面上百分之95%的所谓react组件库教程你都不用看了,大多数骗小白的,欢迎加群交流,我的微信:a2298613245)。

注:其实看源码我发现不少疑惑的代码,觉得不应该那样写,比较明显的,我就给floating-ui提了pr,也合进去了(刚刚合了一个变量重复计算的问题),还有就是给这些知名的库提pr是非常简单的事情,只要你真的去梳理源码,总会有不少能提的,只是大家很少去钻研源码罢了。

腾讯的tdesign的弹出层组件,使用了@popper-js, 大家可以点击链接查看效果,我也看其源码实现了类似的组件,其依赖的@popper-js已经有点过时了,并且因为它是第三方库,如果你的弹出层组件有些定制化的需求,腾讯的tdsign是做不了任何优化的。

所以为了我自己能够做出超越腾讯的tdesign,并且超越ant deisgn类似功能的组件(在ant中叫rc-trigger组件,是ant底层依赖的组件,并没有出现在官网中),我从0到1改造了@popper-js代码。

注:@popper-js现在新版是floating-ui,我也看了其代码,其核心代码大同小异,主要是修复了一些边界的case,和增加了一些性能优化点,我改造的@popper-js也将这些新的代码融入其中。

所以以下的@popper-js应该是增强版,不是官方的@popper-js(看完本篇文章之后可以看第二篇实现代码逻辑)

为什么我的定位组件更好

这个疑问可以换做可为什么@popper-js比国内所有的弹出层组件更好?

受众多,功能非常稳定

因为这个组件的作者专做这一个功能已经有很多年了,在jquery时期就在开始做了,目前github上最受欢迎的弹出层组件,目前的版本来看,就算有bug,也只可能是极为特殊的case,现在是相当稳定的。

代码质量高,易于维护

整个组件以中间件的方式书写,非常易于拓展、理解和改造。其实它就是一个数据层, 我的组件库也是遵循数据和ui分离的原则,也就是每个组件有一个store,存储所有数据和事件(比如 onclick),然后导出的事件和属性用于react(react仅仅是一个视图层)消费。

主要是因为目前国内组件库的组件写法大多数都是react、数据、事件耦合在一起,维护起来很糟心。

功能对比

@popper-js本身跟ant的trigger组件功能差不多,但有一个对开发者非常重要的功能,ant是没有的,

  • @popper-js具备自动跟踪的定位的功能,比如滚动条滚动的时候,会自动帮你更改定位坐标,ant要手动设置
  • @popper-js启用了css的gpu加速,例如在绝对定位的基础上,使用transform来辅助定位

但是 @popper-js有两个致命的性能问题,

  • 其一,滚动的时候,更新定位,会重新绑定事件(先把之前的事件移除,再绑定新的滚动更新定位的事件),所以我做了一个性能优化,把绑定事件交给react组件的useEffect去做,只在组件销毁时销毁事件
  • 其二,有些复杂的相同逻辑的数据计算没有缓存数据,导致多次计算

其实字节的arco design的trigger比ant的功能要丰富的多。是唯一我看来能与@popper-js功能上平起平坐的组件(但是代码质量我觉得还是有待提高)。

废话不多说,开写!

梳理主体逻辑,实现一个最简单的定位函数

如下图,我们希望鼠标hover到按钮的时候,其上方会出现一个弹出层:

image.png

弹出效果如下:

image.png

然后我们抽象一下目标,也就是鼠标在任意的dom元素上出现(你先别管是hover,click还是什么方式触发),我们希望另一个dom元素能够出现在其的上方(可以自定义指定方位,比如下方,左方,左下方,右上方等等)。

所以主体逻辑就非常简单了:我们只要计算按钮的位置,然后得到一个定位的坐标,最后将坐标赋给弹出框即可(利用绝对定位,或者fixed定位,我们统一为绝对定位)。

核心知识点:弹框是绝对定位,那么就会有一个绝对定位的上下文,所以我们计算按钮的坐标的时候,实际上是相对于这个上下文去计算的

相对定位的上下文是什么?(之前的文章提到过)举个例子,你们一个元素的position是absolute,那么它是相当于谁定位?例如:

html
复制代码
  <body>
    <div>
      王二
    </div>
    <div style="transform: translateX(2px);">
      <span style="position: absolute; top: 0" >李四</span>
  </div>
  </body>

肯定有人说了,这个我熟啊,相当于上面包含它的元素只要不是static定位的。这个没错,但是只答对一部分,还有一种可能,本身元素是static元素也会成为定位上下文,比如给它加一个transform属性,你可以试试上面的代码,李四是相对于transform属性的div定位的。

不仅仅是transform属性,下面的方式都可以成定位上下文元素(当时看源码这里我是怎么也不明白为啥要判断下面这些)

  1. 有transform、perspective、filter属性中任意一个不为none。
  2. 是will-change属性值为以上任意一个属性的祖先元素。
  3. 是contain属性值包含paint、layout、strict或content的祖先元素。

(注:更详细的内容请查看mnd,包含块的概念)

转化思路

既然弹框定位是根据定位上下文来设置top,left这些属性的,那么其实我们只要计算定位上下文到按钮(还是上面的案例,如何计算按钮的位置这个问题)的相对位置即可。

如何计算

如下图:

image.png

所以 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离,就是按钮相对于定位上下的距离。

好了,这时定位上下文没有滚动条的情况,如果定位上下文可以滚动,我们还需要加上滚动距离。至此,我们推导出了定位公式:

x =  按钮到视口左边的距离 - 定位上下文到视口左边的距离 + 定位上下文的横向滚动距离
y = 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离 + 定位上下文的纵向滚动距离

到视口的距离,都可以用getBoundingClientRect这个API来实现,滚动距离需要区分是定位上下文是文档元素还是其他普通html元素,比如div元素

  • 普通html元素,比如div元素,使用scrollTop这个api来获取滚动距离
  • html元素,也就是文档,使用Window.pageYOffset 来获取滚动距离

offsetParent的坑

我们现在要找定位上下是谁,一般都使用offsetParent这个方法,但是它有坑,以下是mnd对其的介绍:

HTMLElement.offsetParent 是一个只读属性,返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 tabletdthbody 元素。当元素的 style.display 设置为 "none" 时,offsetParent 返回 null

也就是说,tabletdthbody元素,我们要做特殊处理,因为我们是想获取最近的定位的父元素,但是这几个,比如body元素就算是static定位,也会被获取到,我们就要排除这些可能。

代码

首先实现加强版offsetParent方法,具体代码会放到github上,这里主要是帮助大家梳理主要逻辑,后面会逐行解释代码

function getOffsetParent(element: HTMLElement): Element | Window {
  let offsetParent = getTrueOffsetParent(element);

  // https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement/offsetParent
  while (offsetParent && isTableElement(offsetParent) && getComputedStyle(offsetParent).position === 'static') {
    offsetParent = getTrueOffsetParent(offsetParent as HTMLElement);
  }

  // https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement/offsetParent
  if (
    offsetParent &&
    (getNodeName(offsetParent) === 'html' ||
      (getNodeName(offsetParent) === 'body' && getComputedStyle(offsetParent).position === 'static' && !isContainingBlock(offsetParent)))
  ) {
    return window;
  }

  return offsetParent || getContainingBlock(element) || window;
}

首先解释

 let offsetParent = getTrueOffsetParent(element);

getTrueOffsetParent要排除一些特殊情况,而不是直接使用element.offsetParent来获取offsetParent,因为例如element不是HTMLElement类型,它是没有offsetParent这个属性的,所以此时如果不是对应的类型要返回null

还有,如果一个dom元素是position是fixed,它的offsetParent属性也是null

接着

while (offsetParent && isTableElement(offsetParent) && getComputedStyle(offsetParent).position === 'static') {
    offsetParent = getTrueOffsetParent(offsetParent as HTMLElement);
  }

isTableElement的实现:

export function isTableElement(element: Element): boolean {
  return ['table', 'td', 'th'].indexOf(getNodeName(element)) >= 0;
}

是排除了之前我们说的,isTableElement排除了'table', 'td', 'th'元素,他们可能会得到错误的offsetParent

但是这里的写法我觉得是有bug的,因为如果这些table元素有transfrom,就是他们是包含块的话,依然可以是定位上下文(现实中几乎遇不到这种情况),所以还需要判断是否是包含块,这样就可以返回这些table元素了。

接着:

if (
    offsetParent &&
    (getNodeName(offsetParent) === 'html' ||
      (getNodeName(offsetParent) === 'body' && getComputedStyle(offsetParent).position === 'static' && !isContainingBlock(offsetParent)))
  ) {
    return window;
  }

你看,上面这里处理body这种特殊的offsetParent的情况,同时还判断了是否是包含块,因为即使一个dom元素的offsetParent是body,定位是static,得到错误的offsetParent,但是如果body元素是包含块,绝对定位依然是拿它当做定位上下文的。

当然,offsetParent我设置了一个封顶,基本上到html,就结束寻找了,统一返回window

接着:

return offsetParent || getContainingBlock(element) || window

如果 offsetParent 没有得到dom元素的值,就会寻找包含块,最后用window元素兜底(包含块也不存在)

我们附上判断包含块的函数:

export function getContainingBlock(element: Element): HTMLElement | null {
  let currentNode: Node | null = getParentNode(element);

  while (isHTMLElement(currentNode) && !['html', 'body', '#document'].includes(getNodeName(currentNode))) {
    if (isContainingBlock(currentNode)) {
      return currentNode;
    } else {
      currentNode = getParentNode(currentNode);
    }
  }

  return null;
}

关键函数在于:isContainingBlock,这个是根据mdn的描述来判断的:

export function isContainingBlock(element: Element): boolean {
  const safari = isSafari();
  const css = getComputedStyle(element);

  // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
  return (
    css.transform !== 'none' ||
    css.perspective !== 'none' ||
    (css.containerType ? css.containerType !== 'normal' : false) ||
    (!safari && (css.backdropFilter ? css.backdropFilter !== 'none' : false)) ||
    (!safari && (css.filter ? css.filter !== 'none' : false)) ||
    ['transform', 'perspective', 'filter'].some((value) => (css.willChange || '').includes(value)) ||
    ['paint', 'layout', 'strict', 'content'].some((value) => (css.contain || '').includes(value))
  );
}

这里,我们把offsetParent的逻辑梳理完毕,我们接着之前的定位逻辑:

x =  按钮到视口左边的距离 - 定位上下文到视口左边的距离 + 定位上下文的横向滚动距离
y = 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离 + 定位上下文的纵向滚动距离

接下来,我们实现这个函数,把定位坐标,也就是x,y坐标求出来。

整体代码如下,我们逐个分析:

export function getCompositeRect(element: Element | VirtualElement, offsetParent: Element | Window): Rect {
  const isOffsetParentAnElement = isHTMLElement(offsetParent);
  const offsetParentIsScaled = isHTMLElement(offsetParent) && isElementScaled(offsetParent as HTMLElement);
  const documentElement = getDocumentElement(offsetParent);
  const rect = getBoundingClientRect(element, offsetParentIsScaled);

  let scroll = { scrollLeft: 0, scrollTop: 0 };
  let offsets = { x: 0, y: 0 };

  if (isOffsetParentAnElement) {
    if (
      getNodeName(offsetParent as Element) !== 'body' ||
      // https://github.com/popperjs/popper-core/issues/1078
      isScrollParent(documentElement)
    ) {
      scroll = getNodeScroll(offsetParent as HTMLElement | Window);
    }

    if (isOffsetParentAnElement) {
      offsets = getBoundingClientRect(offsetParent as HTMLElement, true);
      offsets.x += (offsetParent as HTMLElement).clientLeft;
      offsets.y += (offsetParent as HTMLElement).clientTop;
    } else if (documentElement as HTMLElement) {
      offsets.x = getWindowScrollBarX(documentElement);
    }
  }

  return {
    x: rect.left + scroll.scrollLeft - offsets.x,
    y: rect.top + scroll.scrollTop - offsets.y,
    width: rect.width,
    height: rect.height,
  };
}

简单来说,第一步先获取到按钮的getBoundingClientRect,核心代码如下:

const rect = getBoundingClientRect(element, offsetParentIsScaled)

为什么单独封装了一个getBoundingClientRect方法呢?,是因为有可能offsetParent元素被缩小或者放大了,比如transform: scale(0.5),缩小到原来长宽的一半。原本dom元素的宽是1000px,加了transform: scale(0.5)之后,变为了宽500px

按道理来说,就按照缩小放大后的坐标去定位也没啥,但是官方认为,我们需要还原成正常尺寸去计算定位。

代码如下:

export function getBoundingClientRect(element: Element | VirtualElement, includeScale: boolean = false): ClientRectObject {
  const clientRect = element.getBoundingClientRect();
  let scaleX = 1;
  let scaleY = 1;

  if (includeScale && isHTMLElement(element)) {
    scaleX = (element as HTMLElement)?.offsetWidth > 0 ? Math.round(clientRect.width) / (element as HTMLElement).offsetWidth || 1 : 1;
    scaleY = (element as HTMLElement)?.offsetHeight > 0 ? Math.round(clientRect.height) / (element as HTMLElement).offsetHeight || 1 : 1;
  }

  const x = clientRect.left / scaleX;
  const y = clientRect.top / scaleY;
  const width = clientRect.width / scaleX;
  const height = clientRect.height / scaleY;

  return {
    width,
    height,
    top: y,
    right: x + width,
    bottom: y + height,
    left: x,
    x,
    y,
  };
}

其中使用了以下代码去计算缩小和放大的倍数

(element as HTMLElement)?.offsetWidth > 0 ? Math.round(clientRect.width) / (element as HTMLElement).offsetWidth || 1 : 1;

最后getBoundingClientRect获得的值都按这个倍数去还原。

接着,我们继续回到上面的公式:

x =  按钮到视口左边的距离 - 定位上下文到视口左边的距离 + 定位上下文的横向滚动距离
y = 按钮到视口顶部的距离 - 定位上下文到视口顶部的距离 + 定位上下文的纵向滚动距离

其中按钮到视口左边的距离按钮到视口顶部的距离我们上面求出来了,定位上下文到视口左边和顶部的距离,同理我们也可以用同样的方法求出,代码上面已经写了,我们回忆一下:

 
      offsets = getBoundingClientRect(offsetParent as HTMLElement, true);
      offsets.x += (offsetParent as HTMLElement).clientLeft;
      offsets.y += (offsetParent as HTMLElement).clientTop;
 

上面的clientLeft是指左边框,也就是左border的宽度,仔细一想,是要把border也算上,要不可能出现border宽度比较大的时候,按钮和定位元素没对齐。如下图:

image.png

整体逻辑如下:

image.png

上面有一个一部分一部分代码是判断getNodeName(offsetParent as Element) !== 'body',是body元素会有什么问题呢,其实也没啥问题,body元素的scrollLeft和scrollTop总是0,不判断的话,结果也是0,其实没啥区别。

其实代码都写好了,等定位组件梳理代码结束,会把代码放到github上,组件库的架子也会放上去慢慢迭代。

不用怀疑,你要想写react组件库,全网只此一家是最系统的,能上生产环境的,不是骗小白的系列文章。关注没错【目前最好的react组件库教程】手写增强版 @popper-js (主体逻辑分析)

如何在代码质量上超过大多数react ui 组件库 (拿Message组件举例)

以下是本文完整的示例代码和demo在文末。欢迎一起交流。

绝大多数react组件库,甚至包括个别大厂,写ui组件的时候基本上是不分层的,首先什么是代码分层,以及带来的好处,我们拿平时业务开发的组件来说,一般有3层(符合整洁架构这本书对于简洁架构的要求):

  • 视图层:react作为纯视图渲染
  • 数据层(领域层),相当于聚合了业务数据和业务数据的处理,这样仅仅在数据层就能清楚的看到数据流变化的方向,做技术方案时仅仅把数据层的逻辑梳理清楚就很不错了。
  • 异步管理层,主要请求后端数据和解决一些复杂的异步管理问题,例如利用rxjs处理一些复杂的异步问题,例如我这篇文章就有一些场景👍 实用rxjs学习案例

具体案例后面会讲,我们先看看为什么分层之后你的代码质量会更高。

  • 可读性,代码分层后,更加符合SOLID中的单一职责原则,一类代码分在一起,所以可读性是更高的。
  • 可维护性,代码分层后,每个模块负责一类事情,这个跟SOLID原则中的开闭原则相符(开闭原则并不是完全禁止修改,而是最小化修改的范围),所以可维护性更高。
  • 可测试性,上面我们说了,因为分层之后,每一层更专注它的功能,所以可以把每一层单独拿来测试,所以是更容易测试的。

我们拿一家国内算是Top3的大厂的Message组件的代码来看看我们的Message组件为什么分层之后比它的代码质量好很多。

我们从上到下简单梳理一下它的Message.tsx代码:

interface直接写到了组件的文件里,如果这个组件代码量很少,比如10-20行是没有什么问题的,但是你这个组件代码本身就比较长,大概200多行,最好还是把interface单独提到一个文件里更合适。

// 以下加入了两个interface
export interface XXX {

}

interface XXX {
}

下面的代码我直接没有看下去的兴趣了,为什么呢?一大堆函数放到一个文件里,一眼看上去也不知道干什么,只有一行一行读代码才行,我认为好的代码是你看到它的命名大概就知道它要干什么了,后面会拿我们的代码做对比。

我觉得直接说人家代码不好有点攻击性太强了,我把其中的代码省略掉,有兴趣知道是哪个大厂的,可以自己去探索一下,哈哈

const MessageContainer: React.FC<MessageContainerProps> = (props) => {
  // xxx代码省略

  useEffect(() => {
   // xxx
  }, []);

  return (
    xxx
  );
};


function createContainer({ attach, zIndex, placement = 'top' }: MessageOptions): Promise<Element> {
  // xxx
}


async function renderElement(theme, config: MessageOptions): Promise<MessageInstance> {
  // xxx
}

function isConfig(content: MessageOptions | React.ReactNode): content is MessageOptions {
 // xxx
}


const messageMethod: MessageMethod = (theme: MessageThemeList, content, duration?: number) => {
  // xxx
};

// 创建
export const MessagePlugin: MessagePlugin = (theme, message, duration) => messageMethod(theme, message, duration);
MessagePlugin.info = xx
MessagePlugin.error =  xx
MessagePlugin.warning = xx
MessagePlugin.success = xx
MessagePlugin.question = xx
MessagePlugin.loading = xx
MessagePlugin.config = xx


MessagePlugin.close = (messageInstance) => {
  // xx
};

MessagePlugin.closeAll = (): MessageCloseAllMethod => {
  // xx
};

export default MessageComponent;

那么一般情况下一个文件很多函数会怎么做呢,一般如果是工具函数,会放在当前组件的utils目录下,如果是hooks,会放在当前组件的hooks目录下,如果是常量会放在当前组件的constans.ts文件下等等。

这样一看文件名的命名就知道这个文件大概是干什么的了。我们再看看我这里的Message组件是如何做的

我的Message组件

首先我们看下目录:

  • hooks目录一看名字就知道是存放react hooks的
  • style目录一看名字就知道是存放样式的
  • utils目录一看名字就知道存放工具函数的
  • constants文件,一看就知道存放常量的
  • interface,一看名字就知道是存放接口定义的

Message组件核心逻辑 store.tsx

剩下几个,我们逐个详解,首先看store.tsx,这是一个领域层(数据 + 数据转换的函数),我们看一下文件里面,就马上知道这个Message组件的核心逻辑了

function useStore(defaultPosition: IPosition) {
  const [state, setState] = useState<MessageStates>({ ...initialState });

  return {
    state,
    add: (noticeProps: MessageProps) => {
      // xxx
    },

    update: (id: number, options: MessageProps) => {
      // xxx
    },

    clearAll: () => {
      // xxx
    },

    remove: (id: number) => {
      // xxx
    },
  };
}

export default useStore;

是不是看到我们的核心数据都在state中,然后对于数据的操作包含:

  • add方法,增加Message
  • update方法,更新Message
  • clearAll方法,清除所有Message
  • remove方法,清除某个Message

这就是我之前说的数据层(数据 + 数据转换的函数),这个按道理来说是跟UI层,也就是react渲染层没有任何关系的,但是我们更新视图必须用setState,所以跟框架是产生耦合了的。

如何实现一个跟框架无关的数据层

所以我们这个数据层并不纯粹,按道理应该是跟框架无关的,react,vue等等都可以有一个相同的数据层。但是因为我未来不会去做其他框架的ui组件,所以采取这种方式减少工作量了。举个例子,假如你要实现跟框架无关的数据层,这时候使用class就很适合了。

class MessageStore{
    constructor(){
        this.messages = []
        this.listener = []
    }
   
    getState(){ return this.messages }
    add(noticeProps: MessageProps){
      this.messages.push(noticeProps)
      this.listener.forEach((listener) => {
        listener.onStoreChange()
      })
    },

    update: (messageId, message) => {
    const index = this.modals.findIndex((message) => message.id === modalId)
      if (index != -1) {
        this.modals[index] = modal
        this.listener.forEach((listener) => {
          listener.onStoreChange()
        })
      }
    },
    ...省略部分方法,原理是一样的
    
    subscribe: (change) => {
      const listener = {
        listenerId: v4(),
        onStoreChange: change,
      } as SubListener
      this.listener.push(listener)
      listener.onStoreChange()
      return listener
    },
    unSubscribe: (listenerId) => {
      store.listener.splice(
        store.listener.findIndex(
          (listener) => listener.listenerId === listenerId,
        ),
        1,
      )
    },
}

然后如何跟框架产生联系呢,利用发布会订阅模式,如上面的subscribe用来注册框架的渲染函数,这样所有框架都可以注册了,实现了跨框架的数据层。

如果是react,如下示例代码:

  const [modalList, setModalList] = useState<Messagerops[]>([])
  const messageStore = new MessageStore();
  useEffect(() => {
    const listener = messageStore.subscribe(() => {
      setModalList([...messageStore.getState()])
    })
    return () => {
      messageStore.unSubscribe(listener.listenerId)
    }
  }, [])

简而言之,就是你有一个class的store,然后数据变更的时候调用框架的渲染函数(比如react的setState),做到数据和视图同步更新,但问题也有,就是react 18的可中断的渲染方式,需要使用useSyncExternalStore包裹,因为这种方式不常用,大概了解这个知识点就好。

所以看到这里,我们从以下几个维度去看看目前store这一层带来的好处:

  • 可读性,一看函数命名就知道这个store是干什么的了。
  • 可维护性,因为跟渲染层react的jsx完全隔离,更佳符合职责单一原则,这就是一个纯粹是数据层,所以更好维护,比如我们要加一个处理Message的逻辑,直接在store里加一个函数就行了。
  • 可测试性,因为职责单一,我们可以仅仅测试这里数据层,我们测试的时候甚至不用管Message组件的jsx到底写的是什么,只要我们模拟一个button点击事件分别对增删改查这几个函数测试就差不多了,大大降低了单元测试的心智负担。

我们这接着看视图层MessageWrapper.tsx里的内容

function MessageWrapper(props: MessageCardProps) {
  const { onMouseEnter, onMouseLeave } = useTimer(props);
  const { icon, type, style, title, content, operation, closable, showIcon, className, remove, id, onClose, position, themeStyle } = props;

  const toastStyle = useMemo(() => getCardStyle(position), [position]);

  return (
    <motion.div
      layout
      variants={applyNotificationSlide}
      custom={{ position }}
      animate="animate"
      exit="exit"
      initial="initial"
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
      style={toastStyle}
    >
      <Alert
        icon={icon}
        type={type}
        themeStyle={themeStyle}
        style={style}
        title={title}
        content={content}
        operation={operation}
        closable={closable}
        showIcon={showIcon}
        className={className}
        _onClose={() => {
          remove?.(id);
        }}
        onClose={onClose}
      />
    </motion.div>
  );
}

别看简单的30多行代码,其中包含了:

  • useTimer:Message出现,默认3秒后就关闭了,但是鼠标移动上去后,就不会关闭,等鼠标移除后,再开始计时3秒后关闭,所以用一个hooks封装了这个逻辑,这算是数据层
  • toastStyle数据css样式层,封装在一个getStyle函数中。
  • motion.div,是动画系统framer-motion提供的api,这是一个动画层,但是暴露出来就是很简洁的一个div元素
  • Alert组件,是我们组件库的警告提示组件,充当渲染组件,直接消费数据就行了。

这里强调一下,视图层主要的作用就是两点

  • 消费数据层的数据
  • 绑定事件更新数据和视图层

所以整体看下来代码逻辑就清晰,所以维护的时候就简单很多,大家觉得的呢?欢迎在评论区交流互动。

最后还有两个文件比较重要,我们简单分析一下

  • useMessage,使用Message组件的hooks函数
  • messageProvider,自动帮你引入的全局Provider,为什么需要这样的Provider呢?这就要说起一个Message组件的实现原理了

Message组件核心渲染逻辑

传统ant4版本,arco,tdesign的渲染逻辑

我们看下ant4版本的Message组件如何用函数驱动渲染

const App = () => {
  return (
    <Button
      onClick={() => {
        Message.info({
          content: 'This is a message!',
          closable: true,
          duration: 10000,
        });
      }}
      type='primary'
    >
      Open Message
    </Button>
  );
};

export default App;

你有没有想过,为什么调用一个 Message.info函数就渲染了dom?毕竟在react里,渲染dom是需要比如函数组件return一个react元素的?

这里第一个方案主要是采用reactDOM.render函数去渲染,这就是可以在不return react元素的情况下渲染了。

但是目前有个问题,就是react18版本跟之前的版本,render方法不一样,在react18要用createRoot 这个API,之前的版本是用render方法。

这也导致了一些小bug,因为react18的渲染是异步的,之前是同步的,所以需要写组件库的同学封装一个兼容react17和react18的方法,把react18的render也变成同步的,怎么办呢,可以使用flushSync包裹一下就好了。

我采取的hooks的方式

跟ant这种直接靠reactDOM.render来渲染的Message组件的方式不同,我这里是借助的react本身的常规的组件渲染,使用方式如下:

import { useMessage, Button } from '@mx-design/web';

function App() {
  const Message = useMessage();
  return (
    <Button
      onClick={() => {
        Message.add({
          type: 'info',
          content: 'This is an info message!'
        });
      }}
    >
      Open Message
    </Button>
  );
};

这里就有问题考考大家了,我这里直接调用Message.add为什么不依靠React.DOM的render就能渲染出dom元素呢?按道理来说一般都是这样才行

import { useMessage, Button, Message } from '@mx-design/web';

function App() {
  const Message = useMessage();
  return (
    <>
        <Message />
        <Button
          onClick={() => {
            Message.add({
              type: 'info',
              content: 'This is an info message!'
            });
          }}
        >
          Open Message
        </Button>
    </>
  );
};

本文完整代码

本文(demo展示)

如上,就是你要return里面有一个插槽渲染Message的dom对吧,我都没插槽按道理怎么渲染呢?

其实我是在全局的Provider里内置了一个全局的Message管理器,那里会在body元素里内置一个插槽。

都看到这里了,求一个star[https://github.com/lio-mengxiang/mx-design],哈哈

后续会持续更新这个系列,跟别的个人开发者不同,我这里的react 组件库参考了至少5个国内外知名的ui库,所以整体的设计和代码质量都是有保证的

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.