你真的会写组件么

167 阅读12分钟

仅以此篇总结个人对于一个需求开发时的设计,包括目录结构设计,基建设计,业务组件设计,容器组件设计等,保证功能性的情况下减少冗余代码的产生,优化开发流程和用户体验,使用技术栈react,将持续化更新

目录结构设计

做为一个组件库而言,一个清晰的目录结构对我们的日常开发有着极其重要的作用。当你需要进行一个持久性的组件库开发时,你必须对每个组件所开发的内容和存放位置有着清晰的规划,保证当前业务的使用情况下,能为未来的迭代升级或者组件复用提供一臂之力。

作为前端而言,我们通常熟悉以下的一个业务开发结构

-package.json
-src
    -components
    -utils
    -pages/routes
    -hooks
    -config
    -fetching
    

这个目录结构是没有任何问题的,问题出现在我们的组件设计时,你是不是经常会这么做

-components
    -SomePageList
        -components
            -Filter.tsx
            -List.tsx
        -hook.ts
        -index.less
        -index.tsx

对于我们的单个业务来说,我们当前的组件拆分是十分受用的,但是我们并不是只进行单个业务的开发,还需要进行组件复用,页面优化等设计,这里我们的Filter是不是可以拆离出一个独立的筛选组件,List是不是可以拆离出基础的虚拟列表等,hook是不是也可以进行一些基础的如debounce或者throttle的抽离或者独立的hook封装,这样我们的目录结构就会成为

-components
    -List//放在List目录结构下方便查询
        -SomePageList
            -index.tsx//引用List的InfiniteScrollList和FilterParams下的Filter组件,引用hooks里的useDebounce进行防抖优化等
            -index.less
        -InfiniteScrollList
            -index.tsx
            -index.less
        -PublicList
            -index.tsx
            -index.less
-hooks
    -useDebounce.tsx
    -useThrottle.tsx
    -useUserInfo.tsx
    ......
-alias
    -less//放一些公用的less做全局的样式设置保证可以进行样式定制等

当然以现在的目录结构来看可能不是特别明显,但是,当你有上百个组件存在,并且业务以年为单位持续发展的时候,这个以组件为独立模块的目录结构会极其受用。

基建组件设计

现在很多市面上的组件库可以满足我们的日常基础开发需求,但是对于持久化发展的项目,为了项目的独特展现,在设计的时候都会对功能和样式进行一个独特的规划,基于这个规划所进行的业务设计,通常在两个相似的业务之间会有很多近乎80%90%的样式和功能重复。这类业务重复在开发初期是不会表现出来的,需要根据我们对开发业务的了解和前端成员的基本功能预判,进行组件库预开发的准备,与产品、ued、后台同学提前对齐未来的一些规划。这个规划,包括数据结构,包括主题色,包括ued组别对基础组件的设计,包括前端对业务的了解和未来可持续发展的留口,包括产品天马行空却十分有道理的设计理念.....当这些都确定好以后,我们的组件根基就确定了,就可以进行我们基建组件的设计了。

现在我提出一个需求:需要一个仅可输入数字的一个组件,并且切换的时候调用callback使外层可根据这个操作进行查询或写入的操作,你会如何进行代码书写呢?

若你仅将此作为一个业务需求,进行直接的代码编写,通过state的设计,在输入代码的时候直接判别state是否是数字就好,就如同下边的代码一样

const Input = (props)=>{
    const { callback } = props;
    const [value,setValue] = useState("");
    const handleChangeValue = useCallback((e)=>{
    const val = e.target.value.replace(/\D/g, '');
    setValue(val);
    callback(val);
    },[callback])
    return <Input value={value} onChange={handleChangeValue}/>
}

这种行为正确么,正确,但是当下次你需要限制长度呢?重复写一个一样的组件做下长度判别么?那再下次你需要一个errorTip呢?继续重写么??需要filterParams的改造你也要如此这般么???

当然不!这时候我们就得基于上述多人的多次探讨,敲定的样式,业务,数据结构等,再加上前端的基本业务思想能力,做出如下的设计

// BasicInput
const BasicInput = (props)=>{
const {
// 长度,可在可不在
length,
//正则校验的正则或者字符串
test=/\d/,
// 需要错误提示么
needErrorTip=false,
//错误提示内容
errorTipMessage = "请输入正确的内容",
// 输入后的回调,这里对于基建组件设计的props名称尽量简洁表达意思
onChange,
//给filterParams留口,方便外层callback封装更改
key=""
} = props;
const [value,setValue] = useState("");
const [isError,setIsError] = useState(false);
const handleValueChange = useCallback((e)=>{
let val = e.target.value;
//首先会进行一下长度的判断
if(val.length > length){
// 当然你也可以和errorTip一样展示,仅需要一个新的state做对应变化和isError的设计,这里不展示详细写法了
message.error("已达到最大输入值");
return;
}
//当不需要错误提示的时候,我们需要直接将正确的value展示界面并正则判别
if(!needErrorTip){
let newVal = test.exec(val)[0] || "";
if(newVal){
setValue(newVal);
onChange(newVal)
}
return;
}
setValue(val)
//进行正则的判别
if(!test || test.test(val)){
setIsError(false);
//仅输入内容正确才调用回调,没有test的时候不做其他设计
onChange(val,key)
}else{
setIsError(true);
}


},[onChange,needErrorTip,key]);

return <>
<Input value={value} onChange={handleValueChange}/>
// 这里对应的组件库有对应的error提示设计,这里仅为表达明确而写,不要照着抄啊
{needErrorTip && isError && <span>{errorTipMessage}</span>}
</>
} 

现在你拥有上述的基建组件了,那么,让我们在实际业务中进行探索

// 一个仅可输入数字的组件,当A和B页面重复的需求时可直接使用,当仅仅是test变化,也可改变传参达到组件的复用
const NumInput = ()=>{
return <BasicInput test={/\d/g} onChange={()=>{做你的操作即可}}/>
}
//一个需要错误提示的组件,这个应该很多表单都会使用吧,高频使用啊
const TipErrorInput = ()=>{
return <BasicInput test={/\d/g} onChange={()=>{做你的操作即可}} needErrorTip={true} errorTipMessage="请输入正确的内容"/>
}

当产品基于这个组件提出一些新增或一些细微更改的需求时

//新增:你需要一个前置的prefix
BasicInput里的props可以添加一个prefix入参
return 处更改
return <>
<Input prefix={prefix} value={value} onChange={handleValueChange}/>
// 这里对应的组件库有对应的error提示设计,这里仅为表达明确而写,不要照着抄啊
{needErrorTip && isError && <span>{errorTipMessage}</span>}
</>
} 
//更改:所有输入数字的地方若出现输入错误的情况直接toast提示,不做用户输入内容改变的处理
//注释我们的ErrorTip,将对应的提示逻辑和setValue的逻辑更改为指定的需求即可

业务组件设计

其实业务组件和基建组件很大程度的相似,区别大概就是业务组件是由多个基建组件或因业务需要进行三方贡献的组件。

在自用层,我们对于一个类似筛选列表的业务而言,可能会用到Filter、InfinityScrollList、ShowItem、BasicButton等基建组件进行搭建,使形成我们的一个筛选某些内容的业务列表。

在业务层,基于三方合作而言,会需要我们将产品的业务提炼,形成一个可和三方对接的,内里将接口交互等设计完善的一个组件。

那么我们开始举例:有一个下拉框选择城市的需求,现在内部有两个业务需要:一个是需要我们进行一个是三级的城市选择,可多选,另一个是单选二级城市,让你进行组件的规划,这时候你会怎么办呢??

  • 首先我们要明确知道一点,这个组件一经发包,这个版本的组件你是无法进行更改的,只能做后续的迭代更新。尤其对于三方合作的组件,需要我们将所有的错误情况进行预考虑:比如多选可选择多少,超出的错误提示如何?;当接口报错时,如何处理,是重发还是报错,重发要设置多少的限制?是否有不可选择的情况产生,由前端还是后端做数据处理,如何展现?......
  • 第二点,未来产生相同或者类似的需求如何做?这里就需要我们的提前规划:尽可能的将所有的业务情况涵盖进来,与产品商量好具体的业务流程,多考虑各种各样的业务情况,减少未来冗余的代码更改
  • 第三点,涉及接口侧跨域类情况或者三方滥用如何办?毕竟业务组件,可能产生三方使用问题,这里就需要我们提前规划,README文件做好编写,线上工具使用一波(推荐使用Bits进行组件网站搭建,就算不用也可以用线上办公软件写好对接文档)。同时,一定备注对接联系人,对业务组件的调用方做好域名限制,使他人不可滥用我们的业务......

现在,我们的提前预警做好了,开始进行组件的设计


//第一步设计,根据情况设计入参,使尽可能将可配置的功能对外留出接口,减少发包更改率(一次更新改7、80个prerelease或者prepatch真挺丢人的)
interface IProps{
    className?:string,//辅助更改自定义样式
    maxLength?:number,//默认设置一个数字,或者不传输时无限制
    needCheckbox?:boolean,//牵扯单选多选所以控制是否展现多选框
    bundleUnion?:number,//可展现几层列表
    canSelectUnion?:number[],//当不传输时所有层级均可选择,否则仅可选择设定的层级,从0开始
    handleChangeSelect:(props)=>void;//做select更改的回调
    canSearch?:boolean;//是否支持搜索
        .....    //还有很多不过多描述,需要将原先使用的组件库的设置接入,保证组件的通用
}

// 第二步,设计好组件的功能和使用文档,以及示例、一般存放README.md
 /**
 * 当前组件为城市列表选择组件,接入前请联系(联系人方式)进行权限申请,具体使用请看(链接文档)......
     这里的数据传输由ref或者handleChangeSelect进行
 */
// 下方对Props做出具体使用介绍   
// 根据版本表述新增或者更改的组件内容做出提示

// 第三步,进行组件编写,在编写情况中,尽可能考虑所有会报错的地方提供合理提示
    // 进行接口请求
    try{
     const data = await fetch(url)
    }catch(e){
    message.error("请求失败"); //或者重新调用fetch并携带调用次数
    } 
    // 数据处理时
    const treeList = ()=>{
    const showList = [];
          const { provinceList = [], provinceToCityList = {}, cityToCountyList = {} } = data;
          const formatList = (union=1,list)=>{
              //回归进行list的调用,通过对bundleUnion的判别和data的使用,将children和label,title设计好,若值为空做好兜底
          }
    }
    // 交互处理时
    const handleSelectItem = (item)=>{
        // 这里我们有一个状态叫selectList,同时可通过ref或者handleChangeSelect将selectList抛出
        if(!canSelectUnion.include(item.union)){
        message.error("当前项不可被选择")
        return;
        }
        if(maxLength === 1){
            setSelectList([item])
        }
        if(selectList.length >= maxLength){
            message.error("超出最大限制")
        }else{
            setSelectList((pre)=>{pre.push(item);return [...pre]})
        }
        handleChangeSelect(selectList);
       
    }
//组件渲染时(这里不做多基建组件合成的业务组件讲述,可以看看容器组件的描述,有着异曲同工之妙)
    return <Cascader
        searchable = {canSearch}
        checkStrategy="independent"
        value={selectList}
        datasource={cityList}
        onChange={handleSelectItem}
        className={className}
      />

以上,一个可被自用和他用的业务组件就完成啦!

容器组件设计

其实容器组件就等于是我们的一个父子组件的实际使用吧,也算是一个自用的业务组件。其实很简单,只需要我们对大的功能进行细致的划分,以保证我们组件内部代码量不要过多,减少不必要的状态更改导致的整个组件的更新,复用我们的基建或者业务组件,达到最终的一个页面展示...这里我们可以用一个很简单的图来表明:

image.png

通过上述的一个页面来看(页面也算是组件,这样表示更为突出些),我们可以将这个页面做好整体划分(以下所提组件均为基建组件或基建组件产生的功能组件),将Header,Left,Content拆分出来,UserInfo这类数据在不同界面可能有复用,所以做单独拆分,Swiper作为独立模块做拆分,TreeList复杂结构本身需要做独立拆分,Tab和TabList因Tab不同改变TabList所以可以根据实际的业务情况将Tab和TabList做整合或者独立拆分,最终在我们的容器组件中做出使用

const PageComp = ()=>{
return <div className="container">
        <header>
            <Favicon/>
            <NavList/>
            <UserInfo/>
        </header>
        <content>
            <div className="left">
                <TreeList/>
            </div>
            <div className="content">
                <Swiper/>
                <div className="tab-content">
                    <Tab/>
                    <TabList/>
                </div>
            </div>
        </content>
    </div>
}

一些其他的絮絮叨

其实要想写好一个组件,我们除了会写以外,还得要会优化,考虑错误情况等,保证组件的长久使用。

  • 对于一个canvas的声波组件,你每次只会更新最后一个波纹的长度,你可以用getImageData和putImageData获取已绘制的并且还会被页面所需的波纹内容,减少重新绘制,优化持续性更新组件
  • 对于一个list而言,每一个item的事件绑定都会进入执行队列进入等待,这时候使用事件委托可以减少遍历队列的时长,达到快速响应
  • 对于业务的扩展而言,每使用一个新的api,都要在caniuse里进行查询,减少因少量用户使用不常用浏览器导致的页面出错情况
  • 对于容器组件而言,能尽量多重考虑,使每个Input都需要的onChange融合为一个onChange进行业务操作,减少业务代码

......