背景
最近在做一个 Design 下面的 PC 端项目,之前一直从事的移动端开发,使用 Vue 和 Egret 偏多,但这次是用 React 和 Antd 开发完成的,摸鱼的时候就顺便读一下 Antd 的源码。我的习惯是:先学会怎么用,再看它怎么实现。(时隔三年,再次开启写作。。。)
起手
通过 Vite 简单启动一个 React 项目,然后删去不需要的文件,最终我们只看下面两个文件
main.tsx
import ReactDOM from 'react-dom';
import './index.css';
const App: React.FC = () => <h1>hello</h1>;
ReactDOM.render(<App />, document.getElementById('root'));
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
],
});
项目已正常运行,今天我们研究的对象是 Antd,需要先安装,再进行如下配置:
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import styleImport from 'vite-plugin-style-import';
// https://vitejs.dev/config/
export default defineConfig({
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true,
},
},
},
plugins: [
react(),
styleImport({
libs: [
{
libraryName: 'antd',
esModule: true,
resolveStyle: name => {
return `antd/es/${name}/style/index`;
},
},
],
}),
],
});
通过 vite-plugin-style-import 这个插件,帮助我们自动导入组件样式。后面出于篇幅问题,不再提供重复的代码预览了。
使用
main.tsx
const App: React.FC = () => {
const onClick = React.useCallback(() => {}, []);
return (
<Button type="primary" onClick={onClick}>
按钮
</Button>
);
};
ReactDOM.render(
<ConfigProvider>
<App />
</ConfigProvider>,
document.getElementById('root'),
);
不出意外,页面上可以看到一个大大的按钮了。正式进入今天的第一个主角:ConfigProvider
ConfigProvider
在深入之前,我们得先了解一下 Context (懂的跳过)。在 React 的某个版本中,为了解决组件层级过深传递 Props 的麻烦问题,引入了上下文 Context 这个方案,不论层级多深,都可以通过 Consumer 拿到顶层的 Value。
const MyContext = React.createContext(defaultValue);
const App = <MyContext.Provider value={/* some value */}>
<Child />
</MyContext.Provider>
const Child = <MyContext.Consumer>
{value => /* render something based on the context value */}
</MyContext.Consumer>
但我们可能更喜欢通过下面的方式拿到 Value
const Child = () => {
const value = React.useContext(MyContext)
return <div>{value}<div>
}
那么为什么 Antd 需要提供一个 ConfigProvider 呢?
我需要所有按钮都用最小状态
<Button size="sm" />
我需要所有弹出框挂载到 app 节点,而不是 body
<Modal getContainer={() => document.getElementById('app')} />
我需要
事实上,在业务开发中,组件只是一个很小的单元罢了,上层封装了非常多的业务组件,我们不可能每个组件的属性都重复设置一遍吧。通过上下文的方式,我们只要在最顶层设置一次,所有组件都能够响应。
<ConfigProvider componentSize="small">
<App />
</ConfigProvider>
其它 API 点击查看 config-provider
ConfigProvider 非 Provider
这句话怎么理解呢?其实是这样的,为了清晰每个上下文的作用,Antd 内部总共拆分为 5 个上下文,它们分别是: RcFromContext、LocaleContext、IconContext、SizeContext、configContext。对外暴露的 ConfigProvider 是一个高阶组件,接受用户的属性输入,按类别分别给到不同的上下文,这样组件在获取上下文的时候就相对职责清晰,比如:
const Child = () => {
const size = React.useContext(SizeContext)
return <button size={size} />
}
所以 ConfigProvider 的实现大致如下:
const ConfigProvider = (props) => {
return <ConfigContext.Provider value={...}>
<SizeContext.Provider value={...}>
<IconContext.Provider value={...}>
<LocaleContext.Provider value={...}>
<RcFromContext.Provider value={...}>
{props.children}
</RcFromContext.Provider>
</LocaleContext.Provider>
</IconContext.Provider>
</SizeContext.Provider>
</ConfigContext.Provider>
}
Button
不知道谁说的:读懂一个组件库,就看读懂它的按钮组件,那我们就从 button/button.tsx 开始。
// 正则表达式:匹配两个汉字
const rxTwoCNChar = /^[\u4e00-\u9fa5]{2}$/;
// 判断一个字符串是不是两个汉字
const isTwoCNChar = rxTwoCNChar.test.bind(rxTwoCNChar);
tuple 函数,非常巧妙的生成一个纯字符串元祖类型的数组
// 生成一个 元祖类型 的数组对象
const tuple = <T extends string[]>(...args: T) => args;
// 按钮类型的数组
const ButtonTypes = tuple('default', 'primary', 'ghost', 'dashed', 'link', 'text');
// 按钮类型的类型
type ButtonType = typeof ButtonTypes[number]
ref
一般情况下,ref 属性用的不多,但在组件库中使用颇多。但 ref 属性只能用于 DOM 元素,或者 class 组件,对于 class 组件,我们拿到的是实例。详细说明可以看一下 这一章。
函数组件,不存在实例,所以在上层我们就拿不到组件实例。如果我们想拿到函数组件的 DOM 呢?
首先想到了 props 传递(ref属性会被props属性剥离,所以必须使用别名)
const Parent = () => {
const ref = React.useRef(null)
return <Child inputRef={ref} />
}
const Child = (props) => {
return <input ref={props.inputRef} />
}
总感觉多一个 inputRef prop 不够优雅,于是:
const Parent = () => {
const ref = React.useRef(null)
return <Child ref={ref} />
}
const Child = React.forwardRef<HTMLInputElement, {}>((props, ref) => {
return <input ref={ref} />
})
使用 React.forwardRef 包裹,就可以传递 ref 了,详细可以看一下这一章
记得给匿名的函数组件定义一个 displayName 否则在 devtools 中就不太好看了
classNames
使用 classNames 这个库来处理 classname 拼接问题,这里 prefixCls 也是受上文提到的 ConfigProvider 的 prefixCls 属性控制
const classes = classNames(
prefixCls,
{
[`${prefixCls}-${type}`]: type,
[`${prefixCls}-${shape}`]: shape !== 'default' && shape,
[`${prefixCls}-${sizeCls}`]: sizeCls,
[`${prefixCls}-icon-only`]: !children && children !== 0 && !!iconType,
[`${prefixCls}-background-ghost`]: ghost && !isUnborderedButtonType(type),
[`${prefixCls}-loading`]: innerLoading,
[`${prefixCls}-two-chinese-chars`]: hasTwoCNChar && autoInsertSpace,
[`${prefixCls}-block`]: block,
[`${prefixCls}-dangerous`]: !!danger,
[`${prefixCls}-rtl`]: direction === 'rtl',
},
className,
);
vite.config.ts
export default defineConfig({
css: {
preprocessorOptions: {
less: {
modifyVars: {
// 这里和 prefixCls 一致即可
'ant-prefix': 'aa',
},
},
},
},
}
到这里 Button 组件的细节就说完了,但是还有两个遗漏的点,一个是 Loading,一个是 Wave 。这是 Button 组件中的两个动画效果,实际还是比较复杂的,涉及到 React 中动画的实现,可以放到单独的章节再讲。
最后
应该是出于字符数不达标,小编一直不给过。我现在是懂了,为什么有些文章里面废话很多~~那就结尾再说一下动画吧,后面会抽出章节,讲一下框架中的封装。
动画是人眼对于单位时间内物体发生频繁变化的视觉停留效果,一般来说,每秒变化20下在人眼中已经是连续的了,专业点来说就是每秒20帧。(有些人眼睛有毒,可能需要更高的帧数,否则就会发现卡顿)。
动画的几个概念
单位时间
其实就是时间线,它代表某一时刻开始,连续不断的递增过程。就像青春,一去不复回。
时钟
时间线本身不会变化,需要一个力,而这个力,我们专业称之为时钟。就像一个高级的闹钟,我们可以设置时间,设置快慢,定时等等一系列操作。而时钟的电池,就是 requestAnimationFrame。由于 requestAnimationFrame 本身运行的不稳定性(每一帧回调时间有长有短),所以时钟本身还需要有一定的自我矫正能力。
属性变化
一般情况下,物体本质不会变,变的是它的属性,位置、大小、外观等。既然是变化,肯定有一个初值和终值,在单位时间内初值变化到种植,这中间就产生了插值,通过将插值反应到物体身上,动画自然就成了。如果用一个函数来表示就是:
但是大部分情况下,不会这么简单。
插值计算
可能我们更习惯称之为缓动方程/计算/算法。 假设物体初始位置为 x1,终点位置为 x2,运行时间为 t,时间线当前时间为 dt,那么简单的插值计算方式为:
这其实就是最简单的 linear 函数了。
封装
动画的本质就是上面这些内容了,无非是不同的语言,不同的实现,归根到底就是:数值在单位时间内的计算。要封装的就是就是两点:时间怎么动、数值怎么算。
而在视图层面,不同的物体,设置属性的方式不同罢了。