本周前端学习关键字
基本知识
- 受控模式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
Transition
、CSSTransition
、TransitionGroup
、SwitchTransition
Hooks封装
ahooks
useSize
、useHover
、useTimeout
、useWhyDidYouUpdate
、useCountDown
react-use
useMountedState
、useLifecycles
、useCookie
、useHover
、useScrolling
- hook封装的三种方法
- 传入 React Element 然后 cloneElement
- 传入 ref 然后拿到 dom 执行 addEventListener
- 返回 props 对象或者事件处理函数,调用者自己绑定
React常用hook
useState
useEffect
useLayoutEffect
useReducer
useRef
forwardRef + useImperativeHandle
:通过 forwardRef 可以从子组件转发 ref 到父组件,如果想自定义 ref 内容可以使用 useImperativeHandle
useContext
memo + useMemo + useCallback
memo 包裹的组件只有在 props 变的时候才会重新渲染,useMemo、useCallback 可以防止 props 不必要的变化,两者一般是结合用。不过当用来缓存计算结果等场景的时候,也可以单独用 useMemo、useCallback
组件封装
- Calendar组件
- Icon图标组件
- Space间距组件
基础知识
受控模式vs非受控模式
改变表单值只有两种情况:

用户去改变 value 或者代码去改变 value。
如果不能通过代码改表单值 value,那就是非受控,也就是不受我们控制。
但是代码可以给表单设置初始值 defaultValue。
非受控模式

代码设置表单的初始 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
|
受控模式

反过来,代码可以改变表单的 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';
test('renders MyComponent correctly', () => { const { container, getByText } = render(<MyComponent />); expect(container.firstChild).toBeInTheDocument(); expect(getByText('Hello World')).toBeInTheDocument(); });
test('fires click event correctly', () => { const { getByText } = render(<MyComponent />); const button = getByText('Click Me'); fireEvent.click(button); expect(getByText('Button clicked!')).toBeInTheDocument(); });
test('creates custom event correctly', () => { const customEvent = createEvent.keyDown(document.body, { key: 'Enter', keyCode: 13, }); expect(customEvent.key).toBe('Enter'); });
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(); });
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(); }); }); });
test('renders hook and accesses current result', () => { const { result } = renderHook(() => useCustomHook()); expect(result.current.value).toBe('test value'); });
|
如何调试

选择 chrome 类型的调试配置:

把端口号改为3000

但是我们跑的是新的浏览器实例,没有用户数据,也就没有你安装的插件
用户数据是保存在 userDataDir 里的,一个 userDataDir 对应一个浏览器实例。
这样子就可以用我们浏览器的插件了

或者直接false

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 });
|

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

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

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

ResizeObserver
元素可以用 ResizeObserver 监听大小的改变,当 width、height 被修改时会触发回调
1 2 3 4
| const resizeObserver = new ResizeObserver(entries => { console.log('当前大小', entries) }); resizeObserver.observe(box);
|

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、浏览器干预等报告等的打印,在回调里上报,这些是错误监听无法监听到但对了解网页运行情况很有用的数据。
网页的各种距离
这类属性比较多,我们整体过了一遍:
鼠标相关属性
页面滚动 & 元素滚动
window.scrollY:页面滚动的距离,也叫 window.pageYOffset,等同于 document.documentElement.scrollTop
element.scrollTop:元素滚动的距离
element.clientTop:上边框高度
element.offsetTop:相对有 position 的父元素的内容顶部的距离,可以递归累加,加上 clientTop,算出到文档顶部的距离
元素尺寸
元素矩形信息
- element.getBoundingClientRect:拿到 width、height、top、left 属性,其中 top、left 是元素距离可视区域的距离,width、height 绝大多数情况下等同 offsetHeight、offsetWidth,但旋转之后就不一样了,拿到的是包围盒的宽高
其中,还要注意 react 的合成事件没有 offsetY 属性,可以自己算,react-use 的 useMouse 的 hook 就是自己算的,也可以用 e.nativeEvent.offsetY 来拿到。



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 的事件处理函数,要先调用下:

最后用 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
|


当 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; 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
Transition
、CSSTransition
、TransitionGroup
、SwitchTransition