本周前端学习关键字

  • 基本知识

    • 受控模式vs非受控模式
    • 如何写单测
    • 如何调试
    • Storybook
    • 浏览器的5种Observer
      • IntersectionObserver:监听元素可见性变化,常用来做元素显示的数据采集、图片的懒加载
      • MutationObserver:监听元素属性和子节点变化,比如可以用来做去不掉的水印
      • ResizeObserver:监听元素大小变化
      • PerformanceObserver:监听 performance 记录的行为,来上报数据
      • ReportingObserver:监听过时的 api、浏览器的一些干预行为的报告,可以让我们更全面的了解网页 app 的运行情况
    • 网页的各种距离
  • CSS

    • sass + classnames
    • styled-components
    • css module
    • tailwind
  • Animation

    • @use-gesture/react
    • @react-spring/web
    • react-transition-group
      • TransitionCSSTransitionTransitionGroupSwitchTransition
  • Hooks封装

    • ahooks
      • useSizeuseHoveruseTimeoutuseWhyDidYouUpdateuseCountDown
    • react-use
      • useMountedStateuseLifecyclesuseCookieuseHoveruseScrolling
    • hook封装的三种方法
      • 传入 React Element 然后 cloneElement
      • 传入 ref 然后拿到 dom 执行 addEventListener
      • 返回 props 对象或者事件处理函数,调用者自己绑定
  • React常用hook

    • useState

    • useEffect

    • useLayoutEffect

    • useReducer

    • useRef

    • forwardRef + useImperativeHandle:通过 forwardRef 可以从子组件转发 ref 到父组件,如果想自定义 ref 内容可以使用 useImperativeHandle

    • useContext

    • memo + useMemo + useCallbackmemo 包裹的组件只有在 props 变的时候才会重新渲染,useMemo、useCallback 可以防止 props 不必要的变化,两者一般是结合用。不过当用来缓存计算结果等场景的时候,也可以单独用 useMemo、useCallback

  • 组件封装

    • Calendar组件
    • Icon图标组件
    • Space间距组件

基础知识

受控模式vs非受控模式

改变表单值只有两种情况:

image-20250324003456281

用户去改变 value 或者代码去改变 value。

如果不能通过代码改表单值 value,那就是非受控,也就是不受我们控制。

但是代码可以给表单设置初始值 defaultValue。

非受控模式

image-20250324003504259

代码设置表单的初始 value,但是能改变 value 的只有用户,代码通过监听 onChange 来拿到最新的值,或者通过 ref 拿到 dom 之后读取 value。

这种就是非受控模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
function App() {
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
setTimeout(() => {
console.log(inputRef.current?.value);
}, 2000);
}, []);

return <input defaultValue={'default'} ref={inputRef}/>
}

export default App

受控模式

image-20250324003515209

反过来,代码可以改变表单的 value,就是受控模式。

注意,value 和 defaultValue 不一样:

defaultValue 会作为 value 的初始值,后面用户改变的是 value。

而一旦你给 input 设置了 value,那用户就不能修改它了,可以输入触发 onChange 事件,但是表单的值不会变。

用户输入之后在 onChange 事件里拿到输入,然后通过代码去设置 value。

这就是受控模式。

什么情况用受控模式:需要对输入的值做处理之后设置到表单的时候,或者是你想实时同步状态值到父组件。

1
2
3
4
5
6
7
8
9
10
11
12
function App() {
const [value, setValue] = useState('guang');

function onChange(event: ChangeEvent<HTMLInputElement>) {
console.log(event.target.value)
setValue(event.target.value.toUpperCase());
}

return <input value={value} onChange={onChange}/>
}

export default App

如何写单测

主要用@testing-library/react 这个库,它有一些 api:

  • render:渲染组件,返回 container 容器 dom 和其他的查询 api
  • fireEvent:触发某个元素的某个事件
  • createEvent:创建某个事件(一般不用这样创建)
  • waitFor:等待异步操作完成再断言,可以指定 timeout
  • act:包裹的代码会更接近浏览器里运行的方式
  • renderHook:执行 hook,可以通过 result.current 拿到 hook 返回值

其实也没多少东西。

jest 的 api 加上 @testing-libary/react 的这些 api,就可以写任何组件、hook 的单元测试了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import React from 'react';
import { render, fireEvent, createEvent, waitFor, act, renderHook } from '@testing-library/react';
import MyComponent from './MyComponent'; // 导入需要测试的组件

// 示例 1: render
test('renders MyComponent correctly', () => {
const { container, getByText } = render(<MyComponent />);
expect(container.firstChild).toBeInTheDocument();
expect(getByText('Hello World')).toBeInTheDocument();
});

// 示例 2: fireEvent
test('fires click event correctly', () => {
const { getByText } = render(<MyComponent />);
const button = getByText('Click Me');
fireEvent.click(button);
expect(getByText('Button clicked!')).toBeInTheDocument();
});

// 示例 3: createEvent(一般不推荐直接使用)
test('creates custom event correctly', () => {
const customEvent = createEvent.keyDown(document.body, {
key: 'Enter',
keyCode: 13,
});
expect(customEvent.key).toBe('Enter');
});

// 示例 4: waitFor
test('waits for asynchronous content to load', async () => {
const { getByText, findByText } = render(<MyComponent />);
fireEvent.click(getByText('Load Data'));
const loadedElement = await findByText('Data loaded!');
expect(loadedElement).toBeInTheDocument();
});

// 示例 5: act
test('uses act to wrap async code', async () => {
await act(async () => {
const { getByText } = render(<MyComponent />);
fireEvent.click(getByText('Async Action'));
await waitFor(() => {
expect(getByText('Action Complete')).toBeInTheDocument();
});
});
});

// 示例 6: renderHook
test('renders hook and accesses current result', () => {
const { result } = renderHook(() => useCustomHook());
expect(result.current.value).toBe('test value');
});

如何调试

image-20250324004850306

选择 chrome 类型的调试配置:

image-20250324004858556

把端口号改为3000

image-20250324004909125

但是我们跑的是新的浏览器实例,没有用户数据,也就没有你安装的插件

用户数据是保存在 userDataDir 里的,一个 userDataDir 对应一个浏览器实例。

这样子就可以用我们浏览器的插件了

image-20250324005414191

或者直接false

image-20250324005458410

Storybook

浏览器的5种Observer

  • IntersectionObserver:监听元素可见性变化,常用来做元素显示的数据采集、图片的懒加载
  • MutationObserver:监听元素属性和子节点变化,比如可以用来做去不掉的水印
  • ResizeObserver:监听元素大小变化
  • PerformanceObserver:监听 performance 记录的行为,来上报数据
  • ReportingObserver:监听过时的 api、浏览器的一些干预行为的报告,可以让我们更全面的了解网页 app 的运行情况

IntersectionObserver

IntersectionObserver 可以监听一个元素和可视区域相交部分的比例,然后在可视比例达到某个阈值的时候触发回调。

1
2
<div id="box1">BOX111</div>
<div id="box2">BOX222</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#box1,#box2 {
width: 100px;
height: 100px;
background: blue;
color: #fff;

position: relative;
}
#box1 {
top: 500px;
}
#box2 {
top: 800px;
}
1
2
3
4
5
6
7
8
9
10
11
12
const intersectionObserver = new IntersectionObserver(
function (entries) {
console.log('info:');
entries.forEach(item => {
console.log(item.target, item.intersectionRatio)
})
}, {
threshold: [0.5, 1]
});

intersectionObserver.observe( document.querySelector('#box1'));
intersectionObserver.observe( document.querySelector('#box2'));

MutationObserver

MutationObserver 可以监听对元素的属性的修改、对它的子节点的增删改。

1
<div id="box"><button></button></div>
1
2
3
4
5
6
7
#box {
width: 100px;
height: 100px;
background: blue;

position: relative;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(() => {
box.style.background = 'red';
},2000);

setTimeout(() => {
const dom = document.createElement('button');
dom.textContent = '东东东';
box.appendChild(dom);
},3000);

setTimeout(() => {
document.querySelectorAll('button')[0].remove();
},5000);

2s 的时候修改背景颜色为红色,3s 的时候添加一个 button 的子元素,5s 的时候删除第一个 button。

然后监听它的变化:

1
2
3
4
5
6
7
8
const mutationObserver = new MutationObserver((mutationsList) => {
console.log(mutationsList)
});

mutationObserver.observe(box, {
attributes: true,
childList: true
});

img

第一次改变的是 attributes,属性是 style:

第二次改变的是 childList,添加了一个节点:

第三次也是改变的 childList,删除了一个节点:

ResizeObserver

元素可以用 ResizeObserver 监听大小的改变,当 width、height 被修改时会触发回调

1
2
3
4
const resizeObserver = new ResizeObserver(entries => {
console.log('当前大小', entries)
});
resizeObserver.observe(box);

image-20250324010208225

PerformanceObserver

PerformanceObserver 用于监听记录 performance 数据的行为,一旦记录了就会触发回调,这样我们就可以在回调里把这些数据上报。

比如 performance 可以用 mark 方法记录某个时间点:

1
performance.mark('registered-observer');

用 measure 方法记录某个时间段:

1
performance.measure('button clicked', 'from', 'to');

后两个个参数是时间点,不传代表从开始到现在。

我们可以用 PerformanceObserver 监听它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html>
<body>
<button onclick="measureClick()">Measure</button>

<img src="https://p9-passport.byteacctimg.com/img/user-avatar/4e9e751e2b32fb8afbbf559a296ccbf2~300x300.image" />

<script>
const performanceObserver = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
console.log(entry);// 上报
})
});
performanceObserver.observe({entryTypes: ['resource', 'mark', 'measure']});

performance.mark('registered-observer');

function measureClick() {
performance.measure('button clicked');
}
</script>
</body>
</html>

创建 PerformanceObserver 对象,监听 mark(时间点)、measure(时间段)、resource(资源加载耗时)
这三种记录时间的行为。

然后我们用 mark 记录了某个时间点,点击 button 的时候用 measure 记录了某个时间段的数据,还加载了一个图片。

当这些记录行为发生的时候,希望能触发回调,在里面可以上报。

我们在浏览器跑一下试试:

可以看到 mark 的时间点记录、资源加载的耗时、点击按钮的 measure 时间段记录都监听到了。

分别打印了这三种记录行为的数据:

mark:

图片加载:

measure:

有了这些数据,就可以上报上去做性能分析了。

ReportingObserver

当浏览器运行到过时(deprecation)的 api 的时候,会在控制台打印一个过时的报告:

浏览器还会在一些情况下对网页行为做一些干预(intervention),比如会把占用 cpu 太多的广告的 iframe 删掉:

会在网络比较慢的时候把图片替换为占位图片,点击才会加载:

这些干预都是浏览器做的,会在控制台打印一个报告:

这些干预或者过时的 api 并不是报错,所以不能用错误监听的方式来拿到,但这些情况对网页 app 来说可能也是很重要的:

比如我这个网页就是为了展示广告的,但浏览器一干预给我把广告删掉了,我却不知道。如果我知道的话或许可以优化下 iframe。

比如我这个网页的图片很重要,结果浏览器一干预给我换成占位图了,我却不知道。如果我知道的话可能会优化下图片大小。

所以自然也要监听,所以浏览器提供了 ReportingObserver 的 api 用来监听这些报告的打印,我们可以拿到这些报告然后上传。

1
2
3
4
5
6
7
const reportingObserver = new ReportingObserver((reports, observer) => {
for (const report of reports) {
console.log(report.body);//上报
}
}, {types: ['intervention', 'deprecation']});

reportingObserver.observe();

ReportingObserver 可以监听过时的 api、浏览器干预等报告等的打印,在回调里上报,这些是错误监听无法监听到但对了解网页运行情况很有用的数据。

网页的各种距离

这类属性比较多,我们整体过了一遍:

  • 鼠标相关属性

    • e.pageY:鼠标距离文档顶部的距离

    • e.clientY:鼠标距离可视区域顶部的距离

    • e.offsetY:鼠标距离触发事件元素顶部的距离

    • e.screenY:鼠标距离屏幕顶部的距离

  • 页面滚动 & 元素滚动

    • window.scrollY:页面滚动的距离,也叫 window.pageYOffset,等同于 document.documentElement.scrollTop

    • element.scrollTop:元素滚动的距离

    • element.clientTop:上边框高度

    • element.offsetTop:相对有 position 的父元素的内容顶部的距离,可以递归累加,加上 clientTop,算出到文档顶部的距离

  • 元素尺寸

    • clientHeight:内容高度,不包括边框

    • offsetHeight:包含边框的高度

    • scrollHeight:滚动区域的高度,不包括边框

    • window.innerHeight:窗口的高度

  • 元素矩形信息

    • element.getBoundingClientRect:拿到 width、height、top、left 属性,其中 top、left 是元素距离可视区域的距离,width、height 绝大多数情况下等同 offsetHeight、offsetWidth,但旋转之后就不一样了,拿到的是包围盒的宽高

其中,还要注意 react 的合成事件没有 offsetY 属性,可以自己算,react-use 的 useMouse 的 hook 就是自己算的,也可以用 e.nativeEvent.offsetY 来拿到。

image-20250324010342354

image-20250324010346225

image-20250324010433761

react 事件是合成事件,所以它少了一些原生事件的属性,比如这里的 offsetY,也就是点击的位置距离触发事件的元素顶部的距离。

你写代码的时候 ts 就报错了:

那咋办呢?

react-use 提供的 useMouse 的 hook 就解决了这个问题:

它是用 e.pageY 减去 getBoundingClientRect().top 减去 window.pageYOffset 算出来的。

这里的 getBoundingClientRect 是返回元素距离可以可视区域的距离和宽高的:

而 window.pageYOffset 也叫 window.scrollY,顾名思义就是窗口滚动的距离。

想一下,pageY 去了 window.scrollY,去了 getBoundingClientRect().top,剩下的可不就是 offsetY 么:

Hooks封装

  • hook封装的三种方法

    • 传入 React Element 然后 cloneElement

    • 传入 ref 然后拿到 dom 执行 addEventListener

    • 返回 props 对象或者事件处理函数,调用者自己绑定

传Element

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { cloneElement, useState } from "react";

export type Element = ((state: boolean) => React.ReactElement) | React.ReactElement;

const useHover = (element: Element): [React.ReactElement, boolean] => {
const [state, setState] = useState(false);

const onMouseEnter = (originalOnMouseEnter?: any) => (event: any) => {
originalOnMouseEnter?.(event);
setState(true);
};
const onMouseLeave = (originalOnMouseLeave?: any) => (event: any) => {
originalOnMouseLeave?.(event);
setState(false);
};

if (typeof element === 'function') {
element = element(state);
}

const el = cloneElement(element, {
onMouseEnter: onMouseEnter(element.props.onMouseEnter),
onMouseLeave: onMouseLeave(element.props.onMouseLeave),
});

return [el, state];
};

export default useHover;

这里注意如果传入的 React Element 本身有 onMouseEnter、onMouseLeave 的事件处理函数,要先调用下:

image-20250324011631621

最后用 cloneElement 复制 ReactElement,给它添加 onMouseEnter、onMouseLeave 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const App = () => {
const element = (hovered: boolean) =>
<div>
Hover me! {hovered && 'Thanks'}
</div>;

const [hoverable, hovered] = useHover(element);

return (
<div>
{hoverable}
<div>{hovered ? 'HOVERED' : ''}</div>
</div>
);
};

export default App;

传Ref

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { RefObject, useEffect, useState } from 'react';

export interface Options {
onEnter?: () => void;
onLeave?: () => void;
onChange?: (isHovering: boolean) => void;
}

export default (ref: RefObject<HTMLElement>, options?: Options): boolean => {
const { onEnter, onLeave, onChange } = options || {};

const [isEnter, setIsEnter] = useState<boolean>(false);

useEffect(() => {
ref.current?.addEventListener('mouseenter', () => {
onEnter?.();
setIsEnter(true);
onChange?.(true);
});

ref.current?.addEventListener('mouseleave', () => {
onLeave?.();
setIsEnter(false);
onChange?.(false);
});
}, [ref]);

return isEnter;
};
1
2
3
4
5
6
7
8
import React, { useRef } from 'react';
import { useHover } from 'ahooks';

export default () => {
const ref = useRef<HTMLDivElement>(null);
const isHovering = useHover(ref);
return <div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>;
};

sass+classnames

1
2
cnpm install --save sass
cnpm install classnames

image-20250324012407738

当 className 的确定需要一段复杂计算逻辑的时候,就用 classnames 这个包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.calendar-month {
&-body {
&-row {
height: 100px;
display: flex;
}
&-cell {
flex: 1;
border: 1px solid #eee;
//padding: 10px;
color: #ccc;
overflow: hidden;
&-current {
color: #000;
}
&-date {
padding: 10px;
&-selected {
background: blue;
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
color: #fff;
border-radius: 50%;
cursor: pointer;
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function renderDays(
days: Array<{ date: Dayjs, currentMonth: boolean}>,
dateRender: MonthCalendarProps['dateRender'],
dateInnerContent: MonthCalendarProps['dateInnerContent']
) {
const rows = [];
for(let i = 0; i < 6; i++ ) {
const row = [];
for(let j = 0; j < 7; j++) {
const item = days[i * 7 + j];
row[j] = <div className={
"calendar-month-body-cell " + (item.currentMonth ? 'calendar-month-body-cell-current' : '')
}>
{
dateRender ? dateRender(item.date) : (
<div className="calendar-month-body-cell-date">
<div className="calendar-month-body-cell-date-value">{item.date.date()}</div>
<div className="calendar-month-body-cell-date-content">{dateInnerContent?.(item.date)}</div>
</div>
)
}
</div>
}
rows.push(row);
}
return rows.map(row => <div className="calendar-month-body-row">{row}</div>)
}

styled-components

css module

tailwind

Animation

react-spring/web

use-gesture/react

react-transition-group

  • TransitionCSSTransitionTransitionGroupSwitchTransition