本文介绍了做者接手维护一个中型 React 历史项目时的一系列改进实践,包括模块结构拆分、业务逻辑梳理、Webpack 打包优化等。react
这是一个 PC 的管理后台类项目,没有引入 react-router 和 redux。待维护的页面全部模板和逻辑所有在一个千行级的 JSX 中实现,包括调用组件库、发送 fetch 请求、切换子页面状态等。而且,该项目实际上并非单页应用,而是经过 Webpack 区分多个 entry 的方式实现了多入口页面。webpack
在开始实现新增需求前,首先要作的是了解代码,整理其结构并适当地以拆分模块的形式逐步重构之。在这一步中,并不涉及最使人畏惧的【重构业务逻辑】,而更多地是【更高级的代码美化】,在完整保留原有代码逻辑和调用方式的前提下,利用一些 JS 的技巧,按照单一职责原则拆分不一样的业务逻辑代码到不一样的模块中,以提升【面条代码】的模块化程度。这一步处理要解决的主要问题是:web
历史代码中混杂了 JSX 模板结构、数据处理、异步控制、状态管理的各类逻辑。npm
代码中如菜单名称结构、表单字段名等的各类硬编码配置分散在各处。redux
几乎所有的业务逻辑均在一个扁平的组件中实现。浏览器
解决上述问题,并不涉及到具体业务逻辑的重写,而是经过将同类功能提取为独立模块,经过一些简单的语法糖来保证仅更改尽可能少的业务代码,就能实现初步的模块拆分。缓存
针对上述的几个问题,初步的模块拆分包括:性能优化
包含大多数 React 组件方法的主页面组件。babel
包含异步请求的 action 模块。react-router
包含各类硬编码配置的 consts 模块。
包含调用组件库中表单等组件的配置文件 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) }
实践中在这一步完成后,其实已经实现【将千行级代码拆分至若干个百行级的模块,每一个模块均仅包含相似的逻辑功能】了。
在初步整理模块后,对代码结构也有了初步的了解,此时能够开始添加一些新的业务需求了。这时,对于与新需求相关的原有代码,能够在理解基础上进行梳理与局部的重构,以实现新功能(注意这时重构是为了实现新功能,而非重写原有代码以实现相同功能)。
这一步主要须要解决的问题是:
原代码中有较多晦涩的 if-else 控制流逻辑,包含对某些状态的组合判断,这对新加入业务代码会有必定的障碍。
在 JSX 中大量【嵌套的三目表达式】长度很长且不易读(这其实是 JSX 相对模板天生的问题),这也形成了必定的困扰。
因为业务逻辑的复用价值较低,这里较难经过代码的形式给出【最佳实践】的代码,但通用的处理模式可总结以下:
经过一些简单的 log 来判断一个事件触发流程中,基本的代码调用和执行顺序。
对执行过程当中遇到的组件状态,在 React 开发工具中确认 state / props 执行先后的变化,肯定【某段业务逻辑所依赖的组件状态,及其触发先后的组件状态】
以【编写输入新需求下输入状态,输出新需求下输出状态】为目标,维护并编写新业务逻辑代码。
新逻辑完成后,逐步注释并最终替换掉老代码,渐进地实现业务需求。
在这一步达到较高的完善程度后,能够从新审视新增的代码段作局部重构,或提取一些可复用的逻辑到上一步中的相应模块中。到这一步为止,便可基本上将老项目像我的起手的项目同样作到较为轻车熟路的开发维护了。
在业务需求按时完成的前提下,才有必要进行这一步的优化。对一个配置文件多达数百行的稳按期项目,切换当时的 Webpack 1 到 Webpack 2 难度较大,但相应的意义却并不大。所以,在构建方向上的优化策略最后以这几条为主:
分析多页面的公共依赖配置,优化公共依赖提取,去除冗余依赖。
修复已知问题。
优化构建速度。
首先,在优化公共依赖方面,难点并非【如何更改公共依赖】,而是如何获知【有哪些依赖须要被提取为公共依赖】。在这方面,须要的是一个查看各 Bundle 内容及尺寸的可视化工具,可使用 webpack-bundle-analyzer 这一 Webpack 插件来实现。使用该插件的方式也很简单,直接将其添加在 Webpack 的 plugins 配置中,从新执行打包命令便可。打包成功后,会弹出浏览器窗口展现各 Bundle 的公共依赖,以下图是优化前的公共依赖配置:
能够发现原始的依赖配置中,位于图中角落的 common 包仅包括了原始的 React,而组件库、lodash、moment 等依赖在每一个页面包中都重复出现了。所以,在 Webpack 的 entry 配置字段中,为 common
包添加 ['babel-polyfill', 'lodash', 'moment']
等依赖名后,便可实现公共依赖的提取。
实际上,提取公共依赖并不能减小每一个页面最终的打包输出体积。只有去除冗余依赖,才能直接影响页面最终的包大小。那么这样的冗余依赖是否存在呢?答案是确定的。在排查过程当中发现,导入 moment 这一很是经常使用的时间库时,会默认导入其对应的多语言依赖 locale 包,而这对当前项目是彻底无用的。对于这种【依赖自己依赖了冗余依赖】的情形,Webpack 一样提供了优化方案。在 Plugins 中添加以下的一行便可:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
这一行代码可以直接减小开发环境 300K 的包大小!在进行了依赖优化后,获得的包体积可视化为下图:
能够发现,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 的配置,于是更加通用,但问题在于:
每次打包都须要重复的 lint 过程,下降了打包速度。
lint 规则较严格时,调试过程受到了较大的限制。如 class 方法必须存在对 this 的引用、函数参数必须所有被使用、不容许 return 后存在业务逻辑等 lint 策略,它们虽然确实能提升代码质量,但在调试过程当中局部存在这样的代码很是常见,禁止编译这些不存在语法问题的代码,对开发效率存在较大的影响。
于是,在优化中果断去除了 Webpack 的 lint 配置,转而经过 VSCode 等编辑器的 lint 插件实现开发过程当中的动态 lint 提示和自动美化。另外,对 Webpack 每次打包的输出格式也进行了优化,去除了较多冗余的包信息 log 内容,仅保留每次打包的 hash 信息便可。最后的开发体验与新 Webpack 2 项目相近,实现了必定的开发效率提高。
在维护过程当中,首先仍是理解已有业务代码,而后按部就班地走改良路线,而不该以【老代码好乱】为理由贸然重写,这会存在很大的风险。虽然 React 自己设计较为松散,使得开发者更容易产出较无序的代码,但 JS 目前的模块和 OO 机制为无需重写的填坑提供了很大的帮助,实践中最后本质上重写的也只有新需求相关的部分,已有的逻辑获得了尽量的保留和复用。而性能优化则属于锦上添花的【折腾向】内容,优先级较低,能够在时间相对宽松的时候处理,优化方式上也有较多的工具和插件支持,相对须要实际编码的业务而言,难度较低。
但愿以上实践经验对于更多开发者的踩坑 / 填坑路可以有所帮助。