在上篇《重新设计 React 组件库》中我们从宏观层面一起讨论了构成自由且数据解耦的 React 组件库应该如何设计,在本文中我们将从实践的角度来和大家一起探讨如何将这样的设计落地。
组件分类
在传统的组件库设计中,组件分类一直都不是一个必选项,大多数人都认为一个组件究竟是属于组件类还是控件类,不过是名字上的不同而已,并没有实际的意义。但在将组件代码写法区分为纯函数与 ES6 class 两种之后,我们发现组件的写法同时也代表了组件的类型,这时我们也就可以给予不同组件一个更清晰的定义,分别是:
在进行了这样清晰的分类之后,每当我们需要新增一个组件时,我们都可以从是否含有内部状态、是否有交互等几个方面来将其归入组件或控件,并以此来确认其相应的代码规范。
延伸来说,除了基础的组件与控件的分别之外,我们还推荐大家从业务的角度出发再划分出一种组件类型,叫做容器。
举例来说,在 Material Design 大行其道的今天,应该不会有人对卡片这样一种基础的内容展示形式感到陌生,对应到前端组件库中,作为内容的骨架,卡片本身应当是一个纯渲染组件,但在我们代入具体的业务场景后就会发现,卡片本身其实是有状态的,最常见的如数据加载中、数据为空、数据错误等。这样一个无交互但含有自身状态的组件无论归于上述的哪个分类都会让人感到奇怪,所以我们又引入了容器这样一个新的分类,专门用来存放卡片这类组件。看到这里,相信聪明的你应该能体会到组件分类的真正意义了,那就是用组件分类这样一种形式来强迫工程师去思考每一个组件的本质,然后再利用 pure render 等方法去优化组件性能。作为离用户最近的一批工程师,前端工程师所应该关心的,除了代码本身之外,用户体验、人机交互等领域方面的经验与知识,也是判断一位前端工程师是否优秀的一把标尺。
另一方面来讲,我们又可以从卡片这样一个容器组件延伸出强依赖数据的组件应该如何设计这样一个更加抽象的问题。从组件库设计的角度来讲,正如上一篇文章中所提到的,我们是不建议将数据获取等逻辑放在组件里去做的,但结合业务场景来说,统一数据获取等逻辑确实是提升业务开发效率的不二选择,这方面的具体实践大家可以参考@琼玖之前的文章《React实践 - Component Generator》。
回到代码本身,抛开纯函数组件不谈,我们这里再来讨论一个编写智能组件时经常会踩到的坑。
在 React 的生命周期函数中,有一个功能十分强大的函数,那就是 componentWillReceiveProps,在这个函数中,我们既可以拿到 this.props 又可以拿到 nextProps,所以从理论上来讲,我们可以在这里利用这些数据对组件做任何逻辑上的变更。另一方面,智能组件一般需要支持木偶与智能两种调用方式,以方便使用者在使用时根据是否需要在业务代码中保存组件状态使用。木偶组件标配的 props 一般为 value 加一个回调函数 onChange,这时组件本身就只负责根据接收到的 props 来进行渲染。而智能组件的标配 props 一般就只有一个 defaultValue,也就是外部只负责定义组件的初始状态,接下来组件自己会根据交互来改变内部状态。这时很多“偷懒”的开发者就会想到,我能不能这样来写代码呢?
- constructor(props) {
- super(props);
- this.state = {
- value: props.defaultValue,
- };
- }
- componentWillReceiveProps(nextProps) {
- if (this.props.defaultValue !== nextProps.defaultValue) {
- this.setState({
- value: defaultValue,
- });
- }
- }
- handleChange(value) {
- this.setState({
- value,
- });
- this.props.onChange(value);
- }
- render() {
- const {
- value
- } = this.state;
- return < input value = {
- value
- }
- onChange = {: :this.handleChange
- }
- />
- ;
- }/
这里只用了 defaultValue 一个 props,就“支持”了组件木偶与智能两种调用方式,也就是说外部传过来的 defaultValue 如果是一个常量,它就表现得像智能组件一样,如果外部是一个变量,他每次也会去更新自己内部的 state,表现得像木偶组件一样。
这样一个看似机智的处理方式,其实违反了 React 一个非常根本的设计原则,那就是单一数据源。如果外部传给 defaultValue 的值是一个变量的话,相当于这个值会在外部与组件内部同时保存两份,只不过是通过 componentWillReceiveProps 来强行保证了两边值相同,而 componentWillReceiveProps 也不过只能保证外部的值改变了内部的值会跟着改变,却不能够保证内部 setState 过后,外部也会更新(这个是由外部的 onChange 方法来保证的,组件本身无能为力)。而且在双方互相通信的过程中,其实很多步骤是冗余的,极大地降低了组件本身的性能。
我们来看一下正确的写法:
- constructor(props) {
- super(props);
- this.state = {
- innerValue: props.defaultValue,
- };
- }
- handleChange(value) {
- if (this.props.value) {
- this.props.onChange(value);
- } else {
- this.setState({
- innerValue: value,
- });
- }
- }
- render() {
- const {
- value
- } = this.props;
- const {
- innerValue
- } = this.state;
- return < input value = {
- value || innerValue
- }
- onChange = {: :this.handleChange
- }
- />
- ;
- }/
value 与 onChange 永远配套使用,而 defaultValue 只在初始化组件状态时使用,渲染时优先使用 props 中的 value,若没有再使用 state 中的 value。只有这样才能够保证组件数据流的清晰,减少使用者在使用时出错的几率,让组件本身的调试工作变得更加简单。
文档管理
编写组件库本身并不是我们的最终目的,让更多的人在业务开发中使用起来才是,所以一个组件库的文档是否足够清晰、完善,也是决定一个组件库成败的关键。
什么样的文档是好的?我想它起码应该达到以下两个要求:
属性全覆盖的重要性在这里就不赘述了,因为使用者在不阅读源码的前提下想要了解组件的所有功能,除了阅读组件文档外就没有其他的方法了,大部分的组件库也都可以做到这一点,但示例丰富,就是体现组件库开发者良心之处的地方了。
是的,编写大量的示例非常耗时,但由于 React 组件本身是高度可定制的,如果开发者不能够提供具体的示例,使用者在使用组件进行一个复杂业务开发时就将因为缺少指导而变得异常痛苦。另一方面,丰富的示例也是对组件单元测试的一次具象,在未来维护组件增加新功能时,就会体现出示例丰富的好处了。当你增加了一些逻辑,而原先所有的示例都仍可以完美运行时,你应该会更有信心地向同事们安利这个新功能吧。
打包发布
作为业务项目的基础依赖,组件库一般都需要打包发布至 npm 以方便业务项目使用,与普通 npm 包不同的是,组件库一般都需要在入口文件中使用 module.exports 的方式将组件一次性暴露出去。
- import components from './components';
- import controls from './controls';
- import containers from './containers';
- module.exports = {
- ...components,
- ...controls,
- ...containers,
- };
这里需要注意的一点是,如果使用 module.exports 的写法的话,相应地需要在 webpack 中配置 output 中的 libraryTarget 为 commonjs2,否则外部将引用不到相应的组件。
- output: {
- path: path.resolve(__dirname, 'build'),
- filename: '[name].js',
- library: 'xxx',
- libraryTarget: 'commonjs2',
- }
相较于将整个组件库打包成一个文件,将每个组件单独打包为一个个独立的 npm 包也不失为一种很好的打包方式。对于业务项目来说,使用单独组件的 npm 包,可以减小最终线上代码的大小,提升页面加载速度。另一方面,我们在维护老的业务项目时,很可能这个项目本身已经引用了一套其他的组件库,而新的需求又需要使用属于新开发的组件库的某个组件开发。这时如果新开发的组件库能够支持单个组件独立引用,我们就可以以最小的成本接入某几个组件并实现需求了。
将所有的组件单独打包听上去并不是一件难事,我们只需要给每个组件都配一个相应的 package.json 和 webpack.config.js 就可以了,但事实却远比想象中要复杂。首先,我们不希望每个组件都是一个独立的 git repo,因为这样非常不利于代码管理,也会造成大量公用代码的冗余。所以我们需要将所有的组件都放在一个 git repo 中做统一管理,且使用 npm link 的方式保证组件之间相互引用时使用的都是对方最新的代码。
这里推荐一个专注于做 package split 的工具 Lerna。
在使用了 Lerna 后,项目的基本文件结构会有一定的改变,如下图:
- my-lerna-repo/
- package.json
- packages/
- package-1/
- package.json
- package-2/
- package.json
所有的组件都需要放在 packages 目录下,这样我们在使用
- lerna bootstrap
命令后 lerna 将自动根据每个包的 package.json 安装其所需要的依赖并将 packages 目录下已有的包进行 symlinks。
这样我们就优雅地完成了 js 文件之间的依赖管理,但没有依赖管理工具的 CSS 仍然是一个令人讨厌的问题。
这里笔者提供两种思路供大家参考,第一种思路是将所有组件的 css 都统一打包成一个 main.css。这种方式简单粗暴,也可以解决问题,但缺点一是打包出来的 css 文件会庞大很多,可能其中 99% 的样式在业务项目中都用不到,而且需要提醒用户即使你引了很多个单独的组件,但 css 只需要 import 一次即可,与常识相悖。第二种思路就是自己来做 css 的依赖管理,通过 import 的方式自己在每个组件的 css 文件中引入其需要用到的其他组件的 css,如下图 button 组件依赖 icon 组件的 css:
- @import "~xui-icon/build/main.css";
- .xui-button {
- font-size: 16px;
- }
这种方式的缺点就是比较麻烦,需要人为地去做依赖管理,也会导致单独打包的组件代码与整体打包的组件代码有所不同,不便于维护。
当然还有一种更激进的做法就是 CSS in JS,上篇文章中提到的 material-ui 就是这样做的,但因为笔者还未在实际工作中深入实践 CSS in JS 这种做法,所以这里暂时将这个问题抛回给大家,以供讨论。不过,我们应该马上会在新的架构中尝试 CSS in JS,到时如果能沉淀出良好的经验的话,也一定会再总结成文章,回馈给大家。
在本文中,我们主要从组件分类、文档管理、打包发布三个方面阐述了如何将构成自由且数据解耦的 React 组件库落到实处。
按照计划,原本还将组件库国际化方案与复杂组件设计也放在了这篇文章中与大家分享,但因篇幅所限,我们会将以上这两部分内容一起放在下一篇文章中,敬请期待。
来源: https://juejin.im/post/59fc188f51882554bd506a03