前端业务开发的通用经验 - 业务基建

3,385 阅读12分钟

什么是业务基建

业务基建是指由业务团队维护的上层公共设施,比如公共方法、状态管理方案、commit 规范等等。

业务基建和工程基建不同,工程基建主要指工具链体系,面向构建部署测试等,业务基建主要面向业务开发。

业务基建的主要意义有两个,一是复用,提高开发效率,二是技术架构标准化。

所谓【技术架构标准化】,是指【有约束力的最佳实践】。没有约束,就谈不上标准的落地。标准化约束应该在项目一开始就建立完整,如果放松限制,或者“等有必要的时候再做”,代码腐化的速度会超出想象。等意识到需要优化的时候,已经积重难返,当初轻而易举的事情,成本已经成倍放大,任何改动,可能都要瞻前顾后、慎重测试、改几十个页面、花费别人看 pr 的时间、上线回归。。。

自由的代价,是永恒的警惕

不过什么是最佳实践呢?整个行业有明确的共识吗?每个入行的前端工程师都有办法知道这一点吗?除了各类脚手架提供的行业通识性的工程方案还有什么缺的、需要业务团队自己处理?


静态分析

如果不给 ESLint 配置规则,那它什么也不会做。ESLint 的有效性,取决于规则配置的全面性。市面上已经有许多成熟的规则集,比如 standard、airbnb。各大框架也有自己定制的规则集。

lint 可不只是保障格式统一这么点儿功能,比如还可以控制复杂度(complexity),统一 ES6 语法(prefer-const、prefer-rest-params、object-shorthand...)、提示语法错误(no-undef...)、约束函数定义(max-params...)等等。

css 也需要 lint,尤其是预处理语言带来了那么多新花样,更需要约束。比如很多人喜欢嵌套,殊不知随意嵌套会带来许多问题,比如选择器权重过高、与模板结构耦合...,这个问题就可以用 max-nesting-depth 或者 selector-max-compound-selectors 规则来限制。甚至 :not(:first-child) 这样普遍的技巧,也不是很好,因为这种规则与 DOM 结构产生了耦合,如果有人改变了 DOM 结构,就可能导致这个规则失效,而改的人很可能没办法知道,这个可以用 selector-pseudo-class-blacklist 限制。还有像 selector-no-vendor-prefix 这样的规则,可以避免有人不知道 autoprefixer 还在业务代码里辛勤的写 -webkit- 这样的前缀。

其实研究规则集,就知道了语言用法的最佳实践。

除了通用的 ESLint,还有其它的静态分析工具可以帮助发现某种特定问题,比如 js-copypaste-detectpurifycss(清理无用 css)、circular-dependency-plugin(检测模块循环引用)、 es-check(检测上线代码是否包含 es6)等等。

lint 需要在每次编译、每次 commit、每次发布上线都做一遍,时刻守卫代码质量。

ESLint 本身具备 autofix 的功能,进一步集成就是 prettier。prettier 是团队协作维护项目的必备利器。


基础样式

如果一个 web 项目连基本的样式重置都没有,岂不是相当蛋疼?p、h1、ul 这样自带默认样式的地方,都得自个儿手动覆写,恨不能手刃项目搭建者。

常见的覆写库有 reset.css 和 normalize.css,也有些知名组件库拥有自己的重置库,例如 bootstrap-reboot.css

以我个人经验看,如果一个新项目,CSS 上来就写出下列代码,一定是老司机。

*, 
*:before, 
*:after {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

项目通用基础 CSS 库,应该包含以下三类样式:

  • Basic Reset:浏览器样式重置
  • OOCSS:抽象类,看看 的设计就立马明白了
  • Basic Tools:工具类,比如最常见的 clearfix

OOCSS 与 Basic Tools 是供开发调用的,均以类名的形式调用,这方面做得比较知名的是 tailwind.css

基础样式库在设计上有四个参考原则:

  • 最小子集原则:不提供任何非通用的具体的 UI 样式,确保代码体积和学习成本最小化
  • 最小干扰原则:尽可能保持最小选择器权重
  • 最大通用原则:只有大概率会被用到的代码才会出现在这里
  • 最简记忆原则:供调用的名称必须最容易记忆和使用

通用基础样式库可以比较明显的提高开发效率,但难点在于如何推广开,让大家都接受。


布局适配

布局适配的核心,是单位。单位涉及三个问题:绝对单位问题、相对单位问题、最小单位问题。h5 环境下,3 个问题都要考虑,移动端包括 rn 因为本身采用的就是绝对单位,因此主要考虑相对单位问题。

绝对单位问题:ppi 适配

ppi 适配要解决的问题是:在最小基本单位尺寸不固定的情况下,如何找到一个固定大小的尺寸单位。

对于文字,我们希望 16px 的文字无论在什么样的屏幕下看起来都是一样大的。也就是说我们希望这里的 px 是一个实际物理尺寸固定的单位。

设置 viewport 就可以实现这个目的:

1个逻辑像素的尺寸 = 1 / ppi * 缩放因子 = 1/163 inch

所以说设置 viewport 本质上是把 px 变成了一个“绝对单位”。

那么用 cm 或 mm 作为尺寸单位行不行呢?其实是符合此场景的,只不过有些反人类。


相对单位问题:resolution 适配

分辨率适配要解决的问题是:找到一个相对单位,使得同一尺寸在不同大小的屏幕上看上去相对大小一致。

比如一张宽 100%,高 100unit 的 banner 图,我们希望它在任何大小的屏幕上能够等比例缩放,因此我们需要这里的 unit 是一个相对单位。

vw 和 vh 就是很好的相对单位,但考虑到兼容性,目前主流的做法还是用 flexible 方案,用 rem 代替 vw。


最小单位问题:dpr 适配

devicePixelRatio 适配要解决的问题是:在设置了 viewport,width=device-width 的情况下,如何画出1px(设备像素)的问题。

dpr=2 意味着 CSS 中的 1px 会用两个设备像素来渲染,在 iphone6s 上更会用 3 个设备像素来渲染。

解决的方案大致有:用小数、用图片、用渐变、用阴影、用 transform 缩放。手机淘宝的做法是使用 js 动态设置 viewport 的 initial-scale,不过这种做法因为副作用太大,已经在 flexible2.0 中去掉了。

参考:1px 究竟是多大


公共组件

公共组件大致分为五类:style、utility、widget、layout、icon。

style 是指无逻辑的纯 css 组件,比如 hairline(移动端 1px 细线)。

utility 是指适合通过函数调用的组件,比如 toast、modal 弹框、action-panel。

widget 是指在模板里调用的组件,这种比较常见。

layout 是指布局组件,布局当然也可以用 css 实现(像 bootstrap 那样),不过某些布局可能更适合封装成 widget 的形式(可借鉴 RN 或 Native 的组件):

布局为什么值得单独封装?主要是实现 layout 和 widget 的解耦以及 layout 的复用。让 layout 像电脑主板一样,可以插入各种 widget,widget 不需要操心布局,可以被随时替换、移除,而不影响布局。

icon 的封装形式就比较多了,早期常见的方式是 sprite,后来又有了字体图标,还有不少 css 画的图标,以及组件形式的图标。图标通常随组件库一同建设,需要前端和 UED 合作维护。

用 svg 实现箭头


还有一类特殊组件:纯逻辑组件。比如 react 里的 context。某些场景,适合仅封装/复用组件的逻辑部分,而样式部分则自定义实现。

比如多选和单选组件,形态各式各样。直接封装 RadioGroup 或者 CheckboxGroup 组件,并不能满足多样化的展示需求。

但可以看到:单选和多选的逻辑,是稳定且确定的,也就是说应该可以将逻辑单独抽象出来,具体的 UI 交给具体的实现。比如这样:

<CheckboxGroupProvider
    list={this.state.data} // 提供所有选项的数组
    value={this.state.value} // 设置默认值
    onChange={v => this.setState({ value: v })} // 同步选中值的变化
>
   {(item, isActive) => ( // 消费单个选项数据 & 是否被选中
       // 具体的 UI 实现
   )}
</CheckboxGroupProvider>

设计公共组件库需要考虑哪些问题,这是个可以单独成文的话题。google 下“component library boilerplate”很容易搜出不少现成的方案,可以不必从零建设。


弹窗管理

弹窗,泛指 modal、slideModal、dialog、ActionPanel 这类全屏遮罩或浮于页面,且一般同一时间只能出现一个的组件。

弹窗是需要统一管理的。随着页面上的运营功能逐步增多,一个页面可能出现各式各样的弹窗,比如弹个什么活动的引流封面、弹个刷存在感的 popover 提示你这里有个啥功能,还有某些功能也可能是通过弹框实现的。总之这些弹窗互相独立,有各自的弹出逻辑,时序上也是独立的,当它们同时存在于一个页面时,就可能引发冲突。

设想某个弹窗 A 在满足某个条件(用户做了某个操作、停留页面达 3s 等等)时弹出,然而这时页面上可能已经有别的弹窗弹出来了,并且 A 不知道。那么 A 是弹还是不弹?弹出的 zIndex 怎么控制?

再设想,同一时间,有 5 个弹框同时满足弹出的条件,是不是要同时弹出?

显然,弹窗一多,体验会受影响。因此所有的弹窗,都应该通过一个统一封装的方法来调用。这个方法会设计一些策略,例如什么情况下丢弃优先级更低的弹框,什么情况下通过队列弹完一个再弹下一个。

非点击触发的弹窗(例如通过某种定时任务,或者在特定时机弹的框),还会面临一个问题,在单页应用下,怎么保证弹窗在正确的页面弹出。因为弹窗可能是全局的,在它弹出之前,页面可以任意切换,所以弹窗的设计,还要考虑和页面的绑定关系。


loading/toast

和弹窗问题类似,loading 和 toast 这种全局组件,会存在并发管理的问题。这种组件看起来特别简单,不过恐怕没多少团队能够正确实现。

比如 loading 组件:假设每个接口发起请求时都会展示 loading,请求结束隐藏 loading。同一时间的接口请求可能有很多,但每时每刻界面上应该只能有一个 loading。比如 a 请求发出,展示 loading,之后 b 请求发出,如果 a 请求结束时,b 还没有结束,那么继续展示 loading,反之则隐藏 loading,这可怎么办呢?

// 采用一种类似信号量的策略让 loading 组件单例化
const loading = {
    count: 0,
    el: document.createTextNode('loading'),
    show () {
        if (this.count === 0) {
            document.body.appendChild(this.el)
        }
        this.count += 1
    },
    hide () {
        this.count -= 1
        if (this.count === 0) {
            document.body.removeChild(this.el)
        }
    }
}

和单例的 loading 不同,并发的 toast 组件每一个都需要展示,最好按照列表形式呈现,而不是在同一位置一个叠一个。按列表形式呈现需要考虑超出屏幕边界的极端场景,比如可考虑限制同一时间最多展示 3 个。


点击防重

短时间内重复点击容易带来某些问题,除了重复发送接口请求(尤其是 create 类型的请求),还有代码层面的问题(比如第二次执行时上下文已经变了)。

短时间重复点击的场景可能是没加 loading 态,或者因为页面卡顿,弹窗没有及时关闭导致用户多次点击确认按钮。

考虑到点击事件的通用功能,如高亮反馈、禁用态、loading 态,可以封装一个逻辑组件,所有涉及点击事件的组件,都包上一层。

单说用于点击事件节流的逻辑组件可以这么封装:

function throttle(fn, wait) {
  let lock = false
  return function() {
    if (lock) return
    lock = true
    fn.call(null, arguments)
    setTimeout(() => (lock = false), wait)
  }
}

function ThrottledTouchable({
  onPress,
  children,
  wait = 0
}) {
  const memorizedThrottle = useCallback(throttle(onPress, wait), [
    onPress,
    wait
  ])
  return <div onCLick={memorizedThrottle}>{children}</div>
}

// 用法示例
<ThrottledTouchable onPress={this.fn}>
    <Button />
</ThrottledTouchable>

公共方法

网络请求方法的封装

网络请求方法有很多问题要考虑:语义、协议(前后端对数据格式的协定)、公共参数、数据格式、编码解码、异步、跨域、cookie、网络异常处理、业务异常处理、请求日志、progess、loading、abort、timeout、安全(风控、加密等)、登录态校验、同构等等。

development 环境下,默认超时应考虑设置成无限,方便 charles 断点调试之类的操作。

参考:useRequest- 蚂蚁中台标准请求 Hooks


跳转方法的封装

公共跳转方法一般要考虑:通用参数、多端兼容(小程序里跳、APP 里跳、浏览器环境里跳)。通用参数里最好带上 from 字段用于标识跳转来源,以便于排查问题。比如 h5 页面可以用 location.origin + location.pathname。每个页面的异常上报方法,都将 from 作为公共参数上报。

每个页面,都应该单独封装一个跳转到该页面的方法。如果把每个页面看做一种服务,那么该跳转方法,就是页面提供给外部调用的接口。这样外部调用的时候,不需要关心页面链接是啥,同时要传哪些参数,也很明确。


其它

桥、日志、埋点、生命周期,主要还是考虑多端兼容。