HIS 系统前端重构经验

作者 | 朱士奇

杏仁前端开发工程师,代码洁癖症早期,关注前端技术。

自入职以来,85% 的工作都是在做 HIS:Hospital Information System (医院信息系统)的需求。 从前端的角度来看,这是一个很典型的后台管理系统,而且有趣的是,你可以从项目的 git 记录中窥探到近年前端技术的变迁。

在我接手到这项目的时候,它已经稳定运行了有三年多的时间了,而且每月都有至少两个以上新需求需要去完成。 这是一个非常好的事情,说明我们一直是稳步前进的,不过对于开发者来说,未必是那么的美好。 因为出于项目的稳定性考虑,正在使用的技术可能是很久以前的技术,我并不是狂热新技术推崇者,有经验的人都明白,万金油般的解决方案是不存在的,最优的技术选型还是要由场景来决定。 所以这次重构并不是为了重构而重构,而是为了 提高项目的可维护性提升开发效率 而做出的决定,因此专门抽出一段时间来做重构,收益是远远大于付出的。

重构前的痛点

首先介绍一下重构前的技术方案,当时项目还没有采用目前主流的前后端分离的方式,后端使用的是: Spring Boot + thymeleaf(Java 模板引擎),前端采用的技术就比较多了:

  • jQuery (操作 Dom,发起 ajax 请求)

  • 基于 jQuery 封装的常用插件(日期选择、表格、select 等等)

  • Handlebars(老牌的前端模板引擎)

  • sass

  • gulp (编译 sass,压缩 js,移动编译后的文件,启动 webpack )

  • vue1.x

  • webpack1.x (编译 .vue 文件)

  • lodash (仅仅是为了使用 lodash 的 template,所以项目中一共有四套模板语法 thymeleaf、Handlebars、template、vue)

除了以上列举出的这些,还有其它使用频次不太高的js库没有列出。

那这样一个糅合了多种前端技术方案的项目,要如何启动进行开发调试呢? 前面提到项目前后端没有分离,所以要想跑起来就必须要配置java开发环境(jdk, maven, mysql, redis...),和前端开发环境(node, npm, gulp, webpack...)。 首先通过 gulp 和 webpack 编译打包前端代码,移动到后端指定的 resource 目录,然后再通过 mvn 启动后端服务(过程比较漫长)。 每当前后端代码有改动的时候,则需要重新执行上线的步骤,等待时间极为漫长。 讲到这里你可能已经感受了一些痛点了:

  1. 上手成本高(前端同学要去了解一些后端,后端同学又要去了解一些前端)

  2. 前端部分技术方案老旧(vue1.x, gulp, webpack1.x)

  3. 调试成本极高,每当前端代码更新就需要重新打包前端,重启后端服务,无法使用热更新,耗费时间极长

为了解决以上这些痛点,在我入职第二周,项目经理便和我聊了重构的问题,重构的目标就是将项目打造成前后端分离,提高以后的开发效率和代码质量。 刚接到这个任务的时候还是比较兴奋的,因为一直写业务代码确实有些厌倦了,想接受新的挑战,做一些重构和架构方面的东西。 但是也有几分的惆怅,因为当时整个项目组只有我一个前端,而且明确表示不会有专门的开发周期用于重构。 如同项目经理多次提到的那样,我们要做的事情是“ 给一辆高速行驶中的汽车换轮胎 ”。 当然在重构的最后阶段还是争取到了一周多宝贵的时间用于全身心投入重构工作。

重构前的准备

既然没有专门的开发周期用于重构,那只能在日常的需求迭代中一点点做重构,所以技术选型就显得尤为重要。 最终在主流的 Angular、React、Vue 三者中选择了 Vue,原因如下:

  1. vue使用起来非常灵活。 只要在页面中引入 vue.js 即可,然后逐步剥离 jQuery、Handlebars 这些老旧的技术,同时引入 vue 生态内的 router、vuex 等等,项目会向一个单页应用慢慢演变。 虽然 Angular 和 React 也可以直接通过 script 标签的方式引入使用,但是这样做的话开发体验上比 vue 差很多。

  2. 项目有已经有一下部分页面用使用 vue1.x 编写,如果再选别的框架,在迭代过程中又会加重开发的负担。

PS:如果是停掉所有的新需求专门重构的话,我可能不会选择 Vue,具体原因暂且不表,也莫问。

框架敲定之后,就该对重构过程中的方案进行思考了,这里我直接给出方案的结果,这些都是基于个人的经验总结出来的,未必是最优的仅供参考:

  • 首先就是创建一个全新的目录,用于存放新的代码,可以很好的隔离新代码和老代码。 目录结构和目前主流的前端项目大同小异。

├── 老代码文件夹
├── v2(新代码文件夹)
│   ├── api
│   ├── components
│   ├── constants
│   ├── plugins
│   ├── styles
│   ├── util
├── └── views
└── pages(webpack 入口文件)
  • 修改打包脚本。 在上面的的目录结构中有一个 pages 文件夹,它是用来存放 webpack 入口文件用的。 因为没有前后端分离,所以除了公共的静态资源外,不同的页面也会加载各自的 js/css 文件,pages 文件夹下存放的就是每个页面对应的 js 文件,v2/views 下面的文件则是每个页面对应的 vue 组件,最后借用 webpack 的多入口功能来打包。

module.exports = {
  entry: {
    foo: './pages/foo.js',
    bar: './pages/bar.js'
  }
}

但是这样做有一个缺点,每当添加新的入口文件时,就需要修改一下入口配置。 这里可以采用读取 pages 文件,动态创建 entry 对象的方式来解决这个问题:

const fs = require('fs');
const path = require('path');

const entry = {};
fs.readdirSync(path.resolve(__dirname, 'pages/'))
  .forEach(fileName => {
    if (fileName.endsWith('.js')) {
      entry[fileName.replace('.js', '')] = `v2/pages/${fileName}`
    }
  });

module.exports = {
  entry
}

不过最终并没有这样做,因为已经用到了 gulp,所以使用了  webpack-stream  这个插件,他的原理是通过 gulp 把文件转成 stream 传给 webpack-stream 打包编译,这样做也不用再单独启动 webpack 了。

const gulp = require('gulp');
const webpack = require('webpack-stream');
const named = require('vinyl-named');
const uglify = require('gulp-uglify');
const webpackConfig = require('./webpack.config.js');

gulp.task('webpack', function () {
  return gulp.src(['js/pages/*.js'])
    .pipe(named())
    .pipe(webpack(webpackConfig))
    .pipe(uglify())
    .pipe(gulp.dest('dist/js/'));
});
  • 对于全新的需求,一律使用vue开发。 首先由后端同学定义好后端路由(页面 url ),并且提供对应的空模板页面。 对于前端而言,这个空面便是一个单页应用了,你可以在这里任意发挥,所以在替换为前端路由之前,每一个 url 对应的就是一个 spa,最后只要把后端路由替换成前端路由,就成为一个完整的单页应用了。

<!-- hello-world.html  -->
<!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout"
      xmlns:th="http://www.thymeleaf.org" layout:decorator="layout">
<body>

<th:block layout:fragment="css">
  <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
  <link rel="stylesheet" th:href="@{/css/hello-world.css}"/>
</th:block>

<th:block layout:fragment="content">
  <div id="app"></div>
</th:block>

</body>

<th:block layout:fragment="script">
  <script src="https://unpkg.com/vue"></script>
  <script src="https://unpkg.com/element-ui/lib/index.js"></script>
  <script th:src="@{/js/hello-world.js}"></script>
</th:block>

</html>

然后在 pages 下面创建对应的js文件。

/** hello-world.js */
import Vue from 'vue';
import HelloWorld from './v2/views/hello-world/index.vue'

const vm = new Vue({
  components: {
    HelloWorld
  },
  render() {
    return (<hello-world />);
  }
}).$mount('#app');
  • 对于老页面,如果改动比较小,则继续沿用之前的写法; 如果改动较大则需要根据工时、风险性、后期收益来评估是用 Vue 重写还是继续沿用之前的开发方式。 不过大部分情况下,两种方式是可以并存的。 比如某个页面中一部分需要进行较大的改动,便可以将这一部分用 vue 重写,下次再将另一部分用 vue 重写,最终这个页面中可能会有多个 vue 实例,我们只要把这些实例以组件的形式组织起来,这个页面就算是重构完成了。 当然这还要得益于 vue 的灵活性。

<!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout"
xmlns:th="http://www.thymeleaf.org" layout:decorator="layout">
<body>

<th:block layout:fragment="css">
<link rel="stylesheet" th:href="@{/css/other.css}"/>
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<link rel="stylesheet" th:href="@{/css/hello-world.css}"/>
</th:block>

<th:block layout:fragment="content">
<div>
<!-- 老的模板 -->
<div>xxxx</div>
<!-- 本次的改动 -->
<div id="foo"></div>
</div>
</th:block>

</body>

<th:block layout:fragment="script">
<link rel="stylesheet" th:href="@{/js/other.js}"/>
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script th:src="@{/js/hello-world.js}"></script>
</th:block>

</html>

每次改动只要将老的模板用 vue 的方式重写即可,在这个不断替换的过程中,要注意文件的加载顺序。 可能会涉及到老的部分和 vue 部分的交互,一般使用事件订阅和 props 的方式都是可以解决的。

const foo = new Vue({
  components: { Foo },
  data() {
    return { bar: '' }
  },
  render() {
    return (<foo onXxx={($event) => { this.$emit('xxx', $event); }} bar={bar}/>);
  }
}).$mount('#foo');

$(() => {
  vm.$on('xxx', (data) => {
    // todo
  });
  // xxx
  vm.bar = 'xxx';
})

重构中遇到的问题和解决方案

  • 让人头疼的版本问题。

前面提到部分页面使用了 vue1.x,考虑到 vue1 升级到 vue2 曲线相对平滑一下,所以前期一直想着先把这部分代码升级到 vue2,官方提供了  vue-migration-helper  帮助开发者识别出代码中旧有的特性,并且会告知你给出建议,同时附上关于详细信息的链接。 不过当我跑完命令后,这种想法已经减少了一半了。

1200多处修改,几乎涉及到了所有的 vue1.x 的代码。 即使通过编辑器批量修改,也不能保证修改后可以顺利运行。 在我看过部分的代码后,完全取消了直接升级的想法了,原因有二: 1. 大部分组件都使用事件通讯,事件满天飞,光理清里面的业务(事件流)就需要很长的时间 2. 里面有一部分常用组件是自己开发的,重构的版本引入了 element-ui 组件库,这些老的组件都要替换 3. 没有单元测试,升级后之后的代码无法保障正确性 所以最终决定这部分代码不升级,当改到具体页面的时候再进行升级重构,(不过这也引发了我的思考,框架在设计的时候一定要设计好对外暴露的 api,没要每次升级,就改一次 api,开发者很痛苦的,说的就是你,AngularJS)。

除了框架版本,还有打包工具的版本也困扰着我。 项目一开始使用 sass 编写样式的,但是在 vue1.x 中却没有看到使用 sass,所有的样式都是写在单独的 .scss 文件中,然后通过gulp 打包,最后以 link 的方式在页面中引入。 为什么不直接写在单文件组件中呢? 还可以通过 Scoped CSS 防止样式污染。

于是我就添加了 sass-loader 和 node-sass,但是很遗憾,vue-loader 版本过低,当升级了 vue-loader 后,vue1.x 的代码就无法编译通过了。 最后找到了一个可以同时兼容 vue1 和 vue2 的版本,但是他却不支持 webpack1,然后升级 webpack 后,有引发了另外一些问题,我发现版本依赖的问题似乎无法解决了,要么全部升级,包括用 vue1.x 写的代码,这个方案已经在前面被否定了; 要么不用 sass-loader 。 最后在尝试使用 less-loader 后,发现没有什么问题,最终确定使用 less-loader。

上面所描述的两个版本问题一个没有解决,一个用了替代方案,总体上来说是很失败的,不过倒是从中获取了一些经验: 1. 当升级版本的时遇到较多的改动,而时间又不充裕,且没有单测时,最好不要升级 2. 当遇到版本依赖问题时,最好先想想为什么要升级,不升级是否有替代方案,不然会引发一系列连锁反应。

  • 记不住的命令

每次调试的时候都要敲好多命令,根本记不住,一开始都是把这些命令记在一个地方,用到的时候就复制粘贴一下。 但是还是觉得不爽,后来想到可以用 alias 让这些命令更简洁,比如自定义一些  his-build-fe his-build-be  这样的命令,好用也好记,同时也激发了我对linux命令的兴趣。

  • 一些失败的尝试

前面提到每次代码有变动的时候,都要重新编译打包、重启服务,非常浪费时间。 尤其是前端代码改动也要重新打包,作为一个前端不允许这种事情发生,所以就尝试用来 webpack 的 watch 来监听代码,但是由于是通过 webpack-stream 启动的,加了 watch 并没有用。 于是就用 gulp-watch 来检测文件变更,虽然可以实现功能,但是 cpu 风扇一直嗡嗡的转,机器发热量也比较大,所以最后还是放弃了这种想法。

重构的一些经验和忠告

  • 首先就是要有平和的心态。 重构的过程中要不可避免的和老代码打交道,你可能会看到令你惊叹的奇巧淫技,但更多的是让你头晕眼花的业务逻辑和满口脏话的代码。 当遇到这样的情况时,一定要调整好心态,用足够的耐心去梳理其中的逻辑。

  • 尽可能的做到低耦合。 这一点可能在初期阶段显得不重要,但是到后面做全局性的改动和替换时,你就能感受到他的好处了。 举一个例子,在项目中经常有需要从地址栏获取参数做一些业务逻辑的运算,一开始我是将这些参数在组件内部获取并且保存,到后面替换前端路由的时候,很多地址都变了,参数读取的方式也发生了很大的改变,如果当初是把这些值设计成组件的 props,那后期的工作将会减轻很多; 同样的还有地址跳转,全局变量等等这些场景。

  • 要对所使用的技术非常熟悉。 重构和新开项目、重写项目是不同的,经常会遇到需要新老兼容的场景,如果对项目所使用的技术不熟悉,会很难应付这些问题。

  • 尽早的制定规范。 重构初期的工作都是我一个人在做,后面随着团队的壮大新加入了一些小伙伴,在和他们介绍项目背景和如何开发之后便做各自的任务了。 由于一开始没有制定规范,所以大伙的代码风格,编写方式还是比较多样的,这也为后面统一调整的阶段增加了一些额外的工作量,如果一开始就好的代码规范就不会发生这样的事情了。

  • 不定期重构代码。 在阅读项目中老代码的时候,会见到一些让人啼笑皆非的代码,我想写这段代码的人看到后也会怀疑自己当初是怎么写出这样的代码的,所以重构不是一次性的工作,好的代码就像粥一样,是熬出来。 不定期的去重构整理自己曾经写过的代码和业务,不仅能让代码变得更健壮,每一次也是自我的升华。

结语

在写这篇文章的时候,HIS 系统已经重构完成为一个纯粹的单页应用了,并且在线上运行了有一个多月的时间,虽然没有收到用户反馈,但是重构后开发体验和效率确实有了很大的提升,切面切换速度也比以前快了很多,所以重构的结果个人还是十分满意的。

本文并没有太多代码和架构层面描述,如果你想了解这些可以留言讨论,我会尽可能的给予答复。

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章