探究原理 - Scrollbar 自定义滚动条

前言

为何要自定义滚动条?答案是追求 website 的极致体验。

浏览器为 DOM 元素容器所提供的默认滚动条样式,在局部滚动区域内使用会影响页面布局的美观,特别是在 windows 系统Mac 系统设置 显示滚动条 为始终 下较为明显。

尽管 CSS 一些浏览器厂商属性可以改变滚动条样式,但能够自定义出来的体验效果也很有限,也存在一些浏览器兼容问题。

利用 DOM 来模拟滚动条样式,则成为许多网站实现上的首选方案。社区也提供了一些现成的工具来实现,比如:el-scrollbar

下面,我们参考 el-scrollbar 来探究和实现一个 React 版本的 Scrollbar 组件。

大纲如下:

  1. Scrollbar 的实现思想
  2. Scrollbar 的基础结构(元素组成部分)
  3. 隐藏浏览器原生滚动条
  4. 计算滚动滑块 thumb 尺寸(width/height)
  5. 监听内容变化,自动更新滚动滑块 thumb 尺寸
  6. handleScroll 滚动事件
  7. 点击 bar 滚动条实现滚动
  8. 拖动 thumb 滚动滑块实现滚动

一、Scrollbar 的实现思想

  1. 首先是容器的 滚动行为 还是采用浏览器原生能力,即为 滚动容器 设置 CSS overflow: scroll,这样容器的 onScroll 滚动可以正常使用;
  2. 要实现自定义滚动条,势必要先考虑 如何隐藏浏览器原生滚动条,可利用 CSS 布局溢出隐藏的方式将 原生滚动条 超出隐藏;
  3. 其次是自定义 滚动条 bar滚动滑块 thumb 样式,采用 div 布局即可实现;
  4. 最后结合 鼠标事件 实现 滚动条滚动滑块 的滚动行为。

二、Scrollbar 的基础结构(元素组成部分)

bash 复制代码
scrollbar
├── scrollbar-view-wrap
│   └── scrollbar-view
│       └── children
├── scrollbar-bar (horizontal)
│   └── scrollbar-thumb
└── scrollbar-bar (vertical)
    └── scrollbar-thumb
  • scrollbar 是外层容器,用做设置 overflow: hidden
  • scrollbar-view 是滚动容器,用作设置 overflow: scroll
  • scrollbar-view 是内容容器,主要用于承载 chidlren
  • children 是需要展示的内容;
  • scrollbar-bar 自定义实现的滚动条,分 水平(horizontal) 和 垂直(vertical) 两个;
  • scrollbar-thumb 自定义实现的滚动滑块;

对应我们初版的代码布局如下:

tsx 复制代码
import React, { useState, useRef, useEffect, UIEvent, MouseEvent as ReactMouseEvent } from "react";
import "./scrollbar.scss";
import classNames from "classnames"; // 一个连接 className 的工具库

export interface IScrollbarProps {
  children: React.ReactNode;
  className?: string;
  wrapClass?: string;
  viewClass?: string;
  wrapStyle?: React.CSSProperties;
  onScroll?: React.UIEventHandler<HTMLDivElement>;
}

function Scrollbar(props: IScrollbarProps) {
  const { className, wrapClass, viewClass, wrapStyle = {}, onScroll, children } = props;
  const containerEle = useRef<HTMLDivElement>(null); // 外层容器
  const wrapEle = useRef<HTMLDivElement>(null); // 滚动容器
  const viewEle = useRef<HTMLDivElement>(null); // 内容容器
  
  return (
    <div className={classNames("scrollbar", className)} ref={containerEle}>
      <div className={classNames("scrollbar-view-wrap", wrapClass)} style={wrapStyle} ref={wrapEle}>
        <div className={classNames("scrollbar-view", viewClass)} ref={viewEle}>
          {children}
        </div>
      </div>
      <Bar />
      <Bar vertical />
    </div>
  );
}

export interface IBarProps {
  vertical?: boolean; // 是否为 垂直 Bar
}

function Bar(props: IBarProps) {
  const { vertical } = props;
  const barEle = useRef<HTMLDivElement>(null);
  const thumbEle = useRef<HTMLDivElement>(null);
  const bar = BAR_MAP[vertical ? "vertical" : "horizontal"];
  
  return (
    <div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle}>
      <div className={classNames("scrollbar-thumb")} ref={thumbEle}></div>
    </div>
  );
}

BAR_MAP 记录了 Bar 在 水平 和 垂直 方向 上所用到的属性定义。

tsx 复制代码
interface IBarInfo {
  key: "vertical" | "horizontal";
  size: "height" | "width";
  axis: "Y" | "X";
  offset: "offsetHeight" | "offsetWidth";
  scroll: "scrollTop" | "scrollLeft";
  scrollSize: "scrollHeight" | "scrollWidth";
  client: "clientY" | "clientX";
  direction: "top" | "left";
}

const BAR_MAP: Record<"vertical" | "horizontal", IBarInfo> = {
  vertical: {
    key: "vertical",
    size: "height",
    axis: "Y",
    offset: "offsetHeight",
    scroll: "scrollTop",
    scrollSize: "scrollHeight",
    client: "clientY",
    direction: "top",
  },
  horizontal: {
    key: "horizontal",
    size: "width",
    axis: "X",
    offset: "offsetWidth",
    scroll: "scrollLeft",
    scrollSize: "scrollWidth",
    client: "clientX",
    direction: "left",
  },
};

CSS 采用 SASS 编写,样式布局代码如下:

scss 复制代码
.scrollbar {
  // 容器设置超出隐藏
  overflow: hidden;
  position: relative;
  
  &,
  * {
    padding: 0;
    margin: 0;
    box-sizing: border-box;
  }

  // 移入容器展示滚动条
  &:hover,
  &:active,
  &:focus {
    .scrollbar-bar {
      opacity: 1;
      transition: opacity 340ms ease-out;
    }
  }

  .scrollbar-view-wrap {
    // view-wrap 设置区域滚动
    overflow: scroll;
    height: 100%;

    // 使用 CSS 来隐藏原生滚动条(用于 Mac 将 显示滚动条 设置为 根据鼠标和触摸板自动显示)
    &__hidden-default {
      scrollbar-width: none;
      &::-webkit-scrollbar {
        width: 0;
        height: 0;
      }
    }
  }

  .scrollbar-bar {
    position: absolute;
    // 水平/垂直 方向与容器留 2px 间隔
    right: 2px;
    bottom: 2px;
    z-index: 1;
    border-radius: 4px;
    opacity: 0;
    transition: opacity 120ms ease-out;

    &.is-vertical {
      width: 6px;
      top: 2px;

      .scrollbar-thumb {
        width: 100%;
      }
    }

    &.is-horizontal {
      height: 6px;
      left: 2px;

      .scrollbar-thumb {
        height: 100%;
      }
    }
  }

  .scrollbar-thumb {
    position: relative;
    display: block;
    width: 0;
    height: 0;
    cursor: pointer;
    border-radius: inherit;
    background-color: rgba(#909399, 0.3);
    transition: 0.3s background-color;

    &:hover {
      background-color: rgba(#909399, 0.5);
    }
  }
}

三、隐藏浏览器原生滚动条

上面「Scrollbar 的实现思路」已经介绍:要自定义滚动条,就需要先将原生滚动条隐藏。

实现隐藏浏览器原生滚动条常见的一种方式是:计算出 滚动条 的宽度,然后通过溢出隐藏的方式,将滚动条裁剪出可视容器。

但是计算 滚动条 的宽度,要针对两个场景做处理:

  1. 一是 Windows 系统 或者是 Mac 系统设置为 浏览器滚动条 始终展示,滚动条会占据滚动容器的空间,可以通过计算得到滚动条的宽度;
  2. 而是 Mac 系统设置滚动条显示为「根据鼠标或触摸板自动显示」 ,滚动条是 浮动在滚动容器内,无法通过计算得到滚动条的宽度;

3.1 JS 计算浏览器原生滚动条宽度,实现溢出隐藏

针对场景一,我们实现一个 getScrollBarWidth() 方法计算得到滚动条宽度 gutter,然后将滚动条的宽度部分进行溢出隐藏。

溢出隐藏的方式有两种:

  1. 为滚动容器设置 margin-right: -滚动条宽度margin-bottom: -滚动条宽度el-scrollbar 采用这种方式实现;
  2. 为滚动容器设置 width = 外层容器.width + 滚动条宽度height = 外层容器.height + 滚动条宽度,这是 本文的实现方式
tsx 复制代码
let scrollBarWidth: number | undefined;
function getScrollBarWidth() {
  if (scrollBarWidth !== undefined) return scrollBarWidth;

  // 创建一个用于测试的元素
  const outer = document.createElement("div");
  outer.style.visibility = "hidden";
  outer.style.width = "100px";
  outer.style.position = "absolute";
  outer.style.top = "-9999px";
  document.body.appendChild(outer);

  // 获取元素的内部宽度
  const widthNoScroll = outer.offsetWidth;
  // 强制显示滚动条
  outer.style.overflow = "scroll";

  // 创建一个内部元素
  const inner = document.createElement("div");
  inner.style.width = "100%";
  outer.appendChild(inner);

  // 获取带有滚动条的宽度
  const widthWithScroll = inner.offsetWidth;
  // 移除测试元素
  outer.parentNode!.removeChild(outer);
  // 计算滚动条宽度
  scrollBarWidth = widthNoScroll - widthWithScroll;

  return scrollBarWidth;
}

function Scrollbar(props: IScrollbarProps) {
  ...
  // 获取浏览器原生滚动条宽度
  const gutter = getScrollBarWidth();
  // 隐藏滚动条:通过偏移量溢出实现(场景:Windows 系统 或者是 Mac 系统设置为 浏览器滚动条 始终展示)
  if (gutter && containerEle.current) {
    // el-scrillbar 采用 margin-right 和 marign-bottom 设置负值隐藏滚动条;这里采用给 width 和 height 多加上 滚动条宽度 实现隐藏。
    const { clientWidth, clientHeight } = containerEle.current;
    const gutterStyle = {
      width: clientWidth + gutter + "px",
      height: clientHeight + gutter + "px",
    };
    Object.assign(wrapStyle, gutterStyle);
  }
  
   return (
    <div className={classNames("scrollbar", className)} ref={containerEle}>
      <div className={classNames("scrollbar-view-wrap", wrapClass)} style={wrapStyle} ref={wrapEle}>...</div>
    </div>
  );
}

3.2 CSS 属性隐藏原生滚动条

针对场景二,由于使用 getScrollBarWidth() 会得到浏览器滚动条宽度为 0,只能采用 CSS 来隐藏滚动条。在 gutter = 0 时,为滚动容器添加 .scrollbar-view-wrap__hidden-default 类样式。

diff 复制代码
<div
+ className={classNames("scrollbar-view-wrap", wrapClass, { "scrollbar-view-wrap__hidden-default": !gutter })}
  style={wrapStyle}
  ref={wrapEle}>
  ...
</div>
diff 复制代码
.scrollbar-view-wrap {
  // view-wrap 设置区域滚动
  overflow: scroll;
  height: 100%;

+ &__hidden-default {
+   scrollbar-width: none;
+   &::-webkit-scrollbar {
+     width: 0;
+     height: 0;
+   }
+ }
}

四、计算滚动滑块 thumb 尺寸(width/height)

  • 如果是 vertical bar,滚动滑块 thumb 的尺寸指代 height
  • 如果是 horizontal bar,滚动滑块 thumb 的尺寸指代 width

vertical bar 为例,滚动滑块 thumb 的尺寸(height),可通过 滚动容器.clientHeight滚动容器.scrollHeight 计算得出。

我们在组件 mount 时计算出滚动滑块 thumb 的尺寸,得到一个百分比值,以 size 属性的方式传递给 Bar 组件。

tsx 复制代码
function Scrollbar(props: IScrollbarProps) {
  ...
  // 定义 Bar thumb 尺寸(水平和垂直)
  const [size, setSize] = useState<Record<"width" | "height", string>>({ width: "", height: "" });
  
  useEffect(() => {
    // mount 时根据内容计算一次 Bar size
    computedSize();
  }, []);
  
  // 根据内容计算出 Bar size
  const computedSize = () => {
    let heightPercentage, widthPercentage;
    const wrap = wrapEle.current;
    if (!wrap) return;

    heightPercentage = (wrap.clientHeight * 100) / wrap.scrollHeight;
    widthPercentage = (wrap.clientWidth * 100) / wrap.scrollWidth;
    // 计算 滚动滑块 size 尺寸的公式:以垂直滚动为例,容器的可视高度 / 容器的滚动高度
    setSize({
      height: heightPercentage < 100 ? heightPercentage + "%" : "",
      width: widthPercentage < 100 ? widthPercentage + "%" : "",
    });
  };
  
  return (
    <div className={classNames("scrollbar", className)} ref={containerEle}>
      ...
      <Bar size={size.width} />
      <Bar vertical size={size.height} />
    </div>
}

export interface IBarProps {
  size: string; // scrollbar-thumb 的尺寸(宽/高)
  vertical?: boolean; // 是否为 垂直 Bar
}

function renderThumbStyle({ size, bar }: { size: string; bar: IBarInfo }) {
  const style: React.CSSProperties = {};
  style[bar.size] = size;
  return style;
}

function Bar(props: IBarProps) {
  ...
  const { vertical, size } = props;
  const thumbStyle = renderThumbStyle({ size, bar });
  
  return (
    <div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle}>
      <div className={classNames("scrollbar-thumb")} style={thumbStyle} ref={thumbEle}></div>
    </div>
  );
}

五、监听内容变化,自动更新滚动滑块 thumb 尺寸

由于我们计算 thumb 尺寸只是在组件 mount 时执行了一次,若内容发生 增/减,滚动滑块的尺寸该如何实时同步呢?

这里我们可以借助 ResizeObserver API 监控内容容器的尺寸变化,执行 computedSize() 重新计算 thumb 尺寸。

diff 复制代码
function Scrollbar(props: IScrollbarProps) {
  ...
  useEffect(() => {
    // mount 时根据内容计算一次 Bar size
    computedSize();
+   // 启动监听,在 view 内容发生变化后,重新计算 Bar size
+   addResizeListener(viewEle.current!, computedSize);
+   return () => {
+     viewEle.current && removeResizeListener(viewEle.current);
+   };
  }, []);
}

// 防抖函数
+ function debounce(time: number, fn: Function) {
  let timer: undefined | number;
  return function (...args: unknown[]) {
    clearTimeout(timer);
    timer = window.setTimeout(() => {
      fn.apply(this, args);
    }, time);
  };
}

// 添加 ResizeObserver
+ const addResizeListener = function (element: HTMLElement, fn: Function) {
  (element as any).__ro__ = new ResizeObserver(debounce(16, fn));
  (element as any).__ro__.observe(element);
};

// 销毁 ResizeObserver
+ const removeResizeListener = function (element: HTMLElement) {
  (element as any).__ro__.disconnect();
};

六、handleScroll 滚动事件

由于我们实现上要保留滚动容器的原生滚动能力,所以滚动容器的 onScroll 事件行为可以正常使用。

虽然现在容器可以正常滚动,但是我们自定义实现的 thumb 滚动滑块,并未跟随滚动行为而移动其在滚动条上的位置。

接下来我要实现:在滚动期间,滚动滑块 thumb 能够 跟随滚动实时调整所处滚动条的位置

vertical bar 为例,通过 滚动容器.scrollTop / 滚动容器.clientHeight 可以计算出滚动滑块 thumb 在垂直方向上的偏移量 move.y(百分比值)。

然后将 move 作为属性传递给 Bar 组件,通过 transform: translate() 实现 thump 跟随滚动移动。

diff 复制代码
function Scrollbar(props: IScrollbarProps) {
  ...
+ // 定义 Bar thumb 滚动位置(水平和垂直)
+ const [move, setMove] = useState<Record<"x" | "y", number>>({ x: 0, y: 0 });
  
  // 监控 view wrap 的滚动,实时计算和调整 Bar.thumb(滚动滑块)的滚动位置
+ const handleScroll = (event: UIEvent<HTMLDivElement>) => {
+   const wrap = wrapEle.current!;
+   // 计算 滚动滑块 的 move 位置公式:以垂直滚动为例,容器顶部已滚动的距离 / 容器的可视高度
+   setMove({
+     y: (wrap.scrollTop * 100) / wrap.clientHeight,
+     x: (wrap.scrollLeft * 100) / wrap.clientWidth,
+   });
+   onScroll && onScroll(event);
+ };
  
  return (
    <div className={classNames("scrollbar", className)} ref={containerEle}>
      <div
        className={classNames("scrollbar-view-wrap", wrapClass, { "scrollbar-view-wrap__hidden-default": !gutter })}
        style={wrapStyle}
+       onScroll={handleScroll}
        ref={wrapEle}>
        <div className={classNames("scrollbar-view", viewClass)} ref={viewEle}>
          {children}
        </div>
      </div>
+     <Bar size={size.width} move={move.x} />
+     <Bar vertical size={size.height} move={move.y} />
    </div>
  );
}

export interface IBarProps {
  size: string; // scrollbar-thumb 的尺寸(宽/高)
+ move: number; // scrollbar-thumb 所处偏移量位置(滚动位置)
  vertical?: boolean; // 是否为 垂直 Bar
}

function renderThumbStyle({ move, size, bar }: { move: number; size: string; bar: IBarInfo }) {
  const style: React.CSSProperties = {};
+ const translate = `translate${bar.axis}(${move}%)`;
  style[bar.size] = size;
+ style.transform = translate;
+ style.msTransform = translate;
+ style.WebkitTransform = translate;
  return style;
}

function Bar(props: IBarProps) {
  ...
+ const { vertical, size, move } = props;
+ const thumbStyle = renderThumbStyle({ move, size, bar });
  
  return (
    <div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle}>
      <div className={classNames("scrollbar-thumb")} style={thumbStyle} ref={thumbEle}></div>
    </div>
  );
}

七、点击 bar 滚动条实现滚动

我们知道原生滚动条 bar 允许点击,且有这样一交互:点击滚动条,让 thumb 滑块的中心,移动到所点击的位置,基于这个位置来计算出 滚动值。

垂直滚动 为例,实现滚动条 点击滚动 的思路如下:

  1. 首先计算出 鼠标所点位置 距离 滚动条 barEle 起始位置 相差多少,记做 offset。公式:event.clientY - target.getBoundingClientRect().top
  2. 其次,拿到 thumbEle 一半的高度:thumbEle.offsetHeight / 2,记做 thumbClickPosition
  3. offset - thumbClickPosition 则是滚动的距离;
  4. 在计算得出滚动距离后,执行 wrapEle.offsetTop 让滚动容器进行滚动;

在对滚动容器执行滚动后,会触发 onScroll 事件并进入 handleScroll,重新计算 move 去改变 thumb 滚动滑块的位置。

diff 复制代码
export interface IBarProps {
  size: string; // scrollbar-thumb 的尺寸(宽/高)
  move: number; // scrollbar-thumb 所处偏移量位置(滚动位置)
+ wrapEle: React.RefObject<HTMLDivElement>; // view wrap ele
  vertical?: boolean; // 是否为 垂直 Bar
}

function Bar(props: IBarProps) {
  ...
+ const { wrapEle, vertical, size, move } = props;
  
+ // 点击滚动条,让 thumb 滑块的中心,移动到所点击的位置,基于这个位置来计算出 滚动值
+ const handleBarClick = (event: ReactMouseEvent<HTMLDivElement>) => {
+   const target = barEle.current!;
    // 1. 以 垂直滚动 为例,首先计算出 鼠标所点位置距离滚动条 barEle 起始位置有多少距离。公式:event.clientY - target.getBoundingClientRect().top
+   const offset = event[bar.client] - target.getBoundingClientRect()[bar.direction];
    // 2. 拿到 thumbEle 一半的高度:thumbEle.offsetHeight / 2
+   const thumbClickPosition = thumbEle.current![bar.offset] / 2;
    // 3. 计算出将 thumbEle 中心放在点击位置时,barEle 起始位置 到 thumbEle 起始位置这段距离所占 barEle viewHeight 的百分比
+   const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / target[bar.offset];
    // 4. 根据百分比计算出要滚动值:(percentage * wrapEle.scrollHeight )
+   wrapEle.current![bar.scroll] = (thumbPositionPercentage * wrapEle.current![bar.scrollSize]) / 100;
+ };
  
  return (
+   <div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle} onMouseDown={handleBarClick}>
      <div className={classNames("scrollbar-thumb")} style={thumbStyle} ref={thumbEle}></div>
    </div>
  );
}

function Scrollbar(props: IScrollbarProps) {
  ...
  
  return (
    <div className={classNames("scrollbar", className)} ref={containerEle}>
      ...
+     <Bar wrapEle={wrapEle} move={move.x} size={size.width} />
+     <Bar wrapEle={wrapEle} vertical move={move.y} size={size.height} />
    </div>
  );
}

八、拖动 thumb 滚动滑块实现滚动

滚动滑块 thumb 允许被按住拖动 进行滚动,实现这个交互涉及两部分:拖动实现计算滚动值

  • 拖动实现 :在按下 thumb 时,给 document 注册 mousemove 移动事件;
  • 计算滚动值 :和 点击滚动条滚动 思路相似,不同点是:点击滚动条滚动 的计算参照物是 thumbEle 高度的一半,这里则是 thumbEle 的起点到鼠标按下时的这段距离。
diff 复制代码
function Bar(props: IBarProps) {
  ...
  const startMove = useRef<Record<"X" | "Y", number>>({ X: 0, Y: 0 });
  
  // 按下拖动 thumb 滑块滚动逻辑
+ const handleThumbClick = (event: ReactMouseEvent<HTMLDivElement>) => {
    if (event.ctrlKey || event.button === 2) {
      return;
    }
    // 在鼠标按下时,记录点击位置与 thumb 滑块起始位置之间的距离
    startMove.current[bar.axis] = event[bar.client] - thumbEle.current!.getBoundingClientRect()[bar.direction];
    startDrag(event);
  };

+ const startDrag = (event: ReactMouseEvent<HTMLDivElement>) => {
    event.stopPropagation();
    document.addEventListener("mousemove", handleMousemove);
    document.addEventListener("mouseup", handleMouseup);
    document.onselectstart = () => false; // 禁止拖动滚动时,将文本滑入选中
  };

  // 按住 thumb 移动滚动,与点击 bar 滚动的区别在于:始终以鼠标按下时 thumb 的位置,来计算滚动值
+ const handleMousemove = (event: MouseEvent) => {
    const target = barEle.current!;
    // 1. 计算出最新的 鼠标位置 距离 barEle 起始位置的距离
    const offset = event[bar.client] - target.getBoundingClientRect()[bar.direction];
    // 2. 拿到在鼠标按下时距离 thumbEle 起始位置的距离
    const thumbClickPosition = startMove.current[bar.axis];
    // 3. 计算出 barEle 起始位置 到 thumbEle 起始位置这段距离所占 barEle viewHeight 的百分比
    const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / target[bar.offset];
    // 4. 根据百分比计算出要滚动值:(percentage * wrapEle.scrollHeight )
    wrapEle.current![bar.scroll] = (thumbPositionPercentage * wrapEle.current![bar.scrollSize]) / 100;
  };

+ const handleMouseup = () => {
    startMove.current[bar.axis] = 0;
    document.removeEventListener("mousemove", handleMousemove);
    document.removeEventListener("mouseup", handleMouseup);
    document.onselectstart = null;
  };
  
  return (
    <div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle} onMouseDown={handleBarClick}>
+     <div className={classNames("scrollbar-thumb")} style={thumbStyle} ref={thumbEle} onMouseDown={handleThumbClick}></div>
    </div>
  );
}

至此,一个 Scrollbar 完美实现。

文末

感谢阅读,如有不足之处,欢迎指出 👏。

相关推荐
AI大模型-小华39 分钟前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子3 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅3 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni3 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学5 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
先吃饱再说5 小时前
编写一个颜色选择器之后,我理解了 React 工程化的三层架构
react.js·前端框架·前端工程化
先吃饱再说5 小时前
别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
react.js·前端框架
赵庆明老师5 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
先吃饱再说5 小时前
从“传事件”到“只传值”:React + TypeScript 组件 Props 设计的两次进化
react.js·前端框架·前端工程化