4

本文介绍了作者接手维护一个中型 React 历史项目时的一系列改进实践,包括模块结构拆分、业务逻辑梳理、Webpack 打包优化等。

背景

这是一个 PC 的管理后台类项目,没有引入 react-router 和 redux。待维护的页面所有模板和逻辑全部在一个千行级的 JSX 中实现,包括调用组件库、发送 fetch 请求、切换子页面状态等。并且,该项目实际上并不是单页应用,而是通过 Webpack 区分多个 entry 的方式实现了多入口页面。

模块拆分

在开始实现新增需求前,首先要做的是了解代码,整理其结构并适当地以拆分模块的形式逐步重构之。在这一步中,并不涉及最令人畏惧的【重构业务逻辑】,而更多地是【更高级的代码美化】,在完整保留原有代码逻辑和调用方式的前提下,利用一些 JS 的技巧,按照单一职责原则拆分不同的业务逻辑代码到不同的模块中,以提高【面条代码】的模块化程度。这一步处理要解决的主要问题是:

  • 历史代码中混杂了 JSX 模板结构、数据处理、异步控制、状态管理的各种逻辑。

  • 代码中如菜单名称结构、表单字段名等的各种硬编码配置分散在各处。

  • 几乎全部的业务逻辑均在一个扁平的组件中实现。

解决上述问题,并不涉及到具体业务逻辑的重写,而是通过将同类功能提取为独立模块,通过一些简单的语法糖来保证仅更改尽量少的业务代码,就能实现初步的模块拆分。

针对上述的几个问题,初步的模块拆分包括:

  1. 包含大多数 React 组件方法的主页面组件。

  2. 包含异步请求的 action 模块。

  3. 包含各种硬编码配置的 consts 模块。

  4. 包含调用组件库中表单等组件的配置文件 model 模块。

然后就可以一步步将代码逻辑迁移到新模块中,在保证页面的功能不受影响的前提下逐步实现初步的模块拆分了。这个过程中多次用到的技巧包括:

将执行异步请求的组件方法拆分至模块中,再在构造器中 bind 回组件。如一个典型的查询逻辑:

// main.js
class Demo extends Component {
  fetchData () {
    fetch('...').then(data => {
      // 此处通常有冗长的业务逻辑
      this.setState({ data })
    })
  }
}

可将其先拆分至 action.js 模块中,形如:

// action.js
// 业务逻辑完全保留,只是添加了 export function 前缀
export function fetchData () {
  fetch('...').then(data => {
    this.setState({ data })
  })
}

然后在原组件中加载并 bind 该函数,从而实现模块拆分:

import { fetchData } from './actions'
 
class Demo extends Component {
  constructor() {
    // 在此 bind 即可
    this.fetchData = fetchData.bind(this)
  }
}

以及,将一些加载时引用了 this 的配置对象封装至新模块的工厂函数中:

render() {
  // 包含冗长表单配置的配置变量
  const demo = {
    // 直接将其提取至新模块在此会报错
    value: this.state.xxx
  }
}

新建一个返回 demo 的工厂函数:

// model.js
export const getDemo  = () => ({
  // 在此的业务代码同样可原封不动地移动
  value: this.state.xxx
})

修改原有位置的调用逻辑:

import { getDemo } from './model'

render() {
  // 在调用工厂函数时绑定上下文,即可使模块中 this 指向正确
  const demo = getDemo.call(this)
}

实践中在这一步完成后,其实已经实现【将千行级代码拆分至若干个百行级的模块,每个模块均仅包含类似的逻辑功能】了。

业务梳理

在初步整理模块后,对代码结构也有了初步的了解,此时可以开始添加一些新的业务需求了。这时,对于与新需求相关的原有代码,可以在理解基础上进行梳理与局部的重构,以实现新功能(注意这时重构是为了实现新功能,而非重写原有代码以实现相同功能)。

这一步主要需要解决的问题是:

  1. 原代码中有较多晦涩的 if-else 控制流逻辑,包含对某些状态的组合判断,这对新加入业务代码会有一定的障碍。

  2. 在 JSX 中大量【嵌套的三目表达式】长度很长且不易读(这实际上是 JSX 相对模板天生的问题),这也造成了一定的困扰。

由于业务逻辑的复用价值较低,这里较难通过代码的形式给出【最佳实践】的代码,但通用的处理模式可总结如下:

  1. 通过一些简单的 log 来判断一个事件触发流程中,基本的代码调用和执行顺序。

  2. 对执行过程中遇到的组件状态,在 React 开发工具中确认 state / props 执行前后的变化,确定【某段业务逻辑所依赖的组件状态,及其触发前后的组件状态】

  3. 以【编写输入新需求下输入状态,输出新需求下输出状态】为目标,维护并编写新业务逻辑代码。

  4. 新逻辑完成后,逐步注释并最终替换掉老代码,渐进地实现业务需求。

在这一步达到较高的完善程度后,可以重新审视新增的代码段做局部重构,或提取一些可复用的逻辑到上一步中的相应模块中。到这一步为止,即可基本上将老项目像个人起手的项目一样做到较为轻车熟路的开发维护了。

Webpack 优化

在业务需求按时完成的前提下,才有必要进行这一步的优化。对一个配置文件多达数百行的稳定期项目,切换当时的 Webpack 1 到 Webpack 2 难度较大,但相应的意义却并不大。因此,在构建方向上的优化策略最后以这几条为主:

  1. 分析多页面的公共依赖配置,优化公共依赖提取,去除冗余依赖。

  2. 修复已知问题。

  3. 优化构建速度。

首先,在优化公共依赖方面,难点并不是【如何更改公共依赖】,而是如何获知【有哪些依赖需要被提取为公共依赖】。在这方面,需要的是一个查看各 Bundle 内容及尺寸的可视化工具,可以使用 webpack-bundle-analyzer 这一 Webpack 插件来实现。使用该插件的方式也很简单,直接将其添加在 Webpack 的 plugins 配置中,重新执行打包命令即可。打包成功后,会弹出浏览器窗口展示各 Bundle 的公共依赖,如下图是优化前的公共依赖配置:

bundle-before

可以发现原始的依赖配置中,位于图中角落的 common 包仅包括了原始的 React,而组件库、lodash、moment 等依赖在每个页面包中都重复出现了。因此,在 Webpack 的 entry 配置字段中,为 common 包添加 ['babel-polyfill', 'lodash', 'moment'] 等依赖名后,即可实现公共依赖的提取。

实际上,提取公共依赖并不能减少每个页面最终的打包输出体积。只有去除冗余依赖,才能直接影响页面最终的包大小。那么这样的冗余依赖是否存在呢?答案是肯定的。在排查过程中发现,导入 moment 这一非常常用的时间库时,会默认导入其对应的多语言依赖 locale 包,而这对当前项目是完全无用的。对于这种【依赖本身依赖了冗余依赖】的情形,Webpack 同样提供了优化方案。在 Plugins 中添加如下的一行即可:

new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)

这一行代码能够直接减少开发环境 300K 的包大小!在进行了依赖优化后,得到的包体积可视化为下图:

bundle-after

可以发现,common 的大小得到了大幅增加,而各个页面的业务包体积则减少了 2/3 以上。不过,在这个优化方向上并没有做到极致。由于 Webpack 1 不支持原生的 Tree Shaking 功能,导致了 UI 组件库即便通过 import { xxx } 语法引入,最终还是会将整个组件库导入公共依赖包中,没有做到按需加载。而相应的 import 插件又存在配置上的不便,其结果是最终没有在这个项目中实现 UI 组件库的按需加载。当然,随着 Webpack 2 的普及,新项目中这应当不会成为问题。

接下来,在修复已知问题方面,优化过程中修复了两个较为常见的问题:common 包随业务包变更而变更的问题;hash 值每次全量变更的问题。

在直接通过 CommonsChunkPlugin 拆分 common 包的配置方式下,每个页面最终使用的包都是 common 包和业务包两个。这时,在页面 A 中修改业务逻辑,会造成 common 包的细微变动,导致新的打包文件中,common 包虽然没有源码变更,却随着业务包的变更而变更了。这会导致每次版本更新时包括 common 在内的所有包都会被全量更新,没有实现按需的更新。

解决方案是,在 CommonsChunkPlugin 的配置中,将 name 字段改为 names 字段,提供 ['common', 'manifest'] 两个公共依赖入口。这样,在业务包变动时,只有 manifest 会随之变动,而 common 的内容不会受到影响,这也就实现了真正意义上的按需更新,更大限度地利用浏览器缓存。虽然这一实践实际上是 Webpack 2 文档中官方的推荐做法,但 Webpack 1 也完全支持。

另一个问题是,每次打包的产物文件中虽然都附带了一个 hash 值,但对所有打包文件,该值都是一样的。这同样会导致仅有某个 bundle 变更时,全量的生产包名称变更,造成缓存的失效。相应的解决方案也很简单:将 output 配置字段中的 [hash] 改为 [chunkhash],即可为每个包添加不同的 hash 值。

最后,在提升面向开发者的打包体验方面,本次优化中主要实现的是 lint 与 Webpack 的解耦。在使用 IDE 开发时,lint 的引入较为繁琐,因此当时采用的是将 lint 作为 Webpack 的 loader 形式引入,在每次增量打包后执行 lint,对存在不符合风格指南的代码在终端报错并不予编译通过的策略。这个模式兼容性绕过了编辑器和 IDE 的配置,因而更加通用,但问题在于:

  1. 每次打包都需要重复的 lint 过程,降低了打包速度。

  2. lint 规则较严格时,调试过程受到了较大的限制。如 class 方法必须存在对 this 的引用、函数参数必须全部被使用、不允许 return 后存在业务逻辑等 lint 策略,它们虽然确实能提高代码质量,但在调试过程中局部存在这样的代码非常常见,禁止编译这些不存在语法问题的代码,对开发效率存在较大的影响。

因而,在优化中果断去除了 Webpack 的 lint 配置,转而通过 VSCode 等编辑器的 lint 插件实现开发过程中的动态 lint 提示和自动美化。另外,对 Webpack 每次打包的输出格式也进行了优化,去除了较多冗余的包信息 log 内容,仅保留每次打包的 hash 信息即可。最后的开发体验与新 Webpack 2 项目相近,实现了一定的开发效率提升。

总结

在维护过程中,首先还是理解已有业务代码,然后循序渐进地走改良路线,而不应以【老代码好乱】为理由贸然重写,这会存在很大的风险。虽然 React 本身设计较为松散,使得开发者更容易产出较无序的代码,但 JS 目前的模块和 OO 机制为无需重写的填坑提供了很大的帮助,实践中最后本质上重写的也只有新需求相关的部分,已有的逻辑得到了尽可能的保留和复用。而性能优化则属于锦上添花的【折腾向】内容,优先级较低,可以在时间相对宽松的时候处理,优化方式上也有较多的工具和插件支持,相对需要实际编码的业务而言,难度较低。

希望以上实践经验对于更多开发者的踩坑 / 填坑路能够有所帮助。


ewind
1.9k 声望40 粉丝

头像是本人