网易来钱前端技术栈持续改进历程

阿凡达2018-06-28 14:40

从2017年年初接手网易来钱项目,到现在不到一年时间。这期间,来钱前端技术栈经历了渐进式地持续改进。优化没有终点,追求没有止境,但是对于过去的2017年,我想可以先做一个阶段性的总结。

接下来,我会把自己在改造过程中面临的主要问题和做出的抉择一一道来。这其中很多问题具有特定场景,并非通用问题。有些抉择事后看来也并不是最优方案,当然还有一些问题有更好的解决方案,只是能力所限,我不知道或应用不了。

将这些分享出来,或许能为各位同事在遇到类似问题时做个参考,如果有同事能给出更好的建议就更好了。水平有限,希望大家不吝赐教。

背景

网易来钱是一款内嵌于网易支付 APP 的贷款产品,2017年初,来钱的前端技术栈大致为下面的样子:

这个技术栈基本是在网易金融其他系统既有模式下稍加改造得到的,主要有以下特点:

  • 浏览器端是多页面模式,一个业务流程涉及多个页面,流程页面间通过参数和 Hybrid 接口传递数据
  • 页面内容通过后端 FreeMarker 模板渲染得到
  • 浏览器端基于 jQuery 实现页面交互和功能
  • 前端代码基于 CMD 拆分模块,通过 Sea.js 在浏览器端异步加载模块
  • 部署时进行项目构建,前端构建基于 Grunt,构建得到的页面(FTL、HTML)发布到应用服务器,静态资源发布到静态资源服务器
  • 依赖网易支付 APP 的功能(如用户身份校验)通过 APP 开放的 Hybrid 接口进行调用

整个技术栈的问题包括:

  • 业务流程跨多个页面,每个步骤都要打开新页面,用户体验较差;页面回退等状态不易控制,较难处理不合理的页面跳转
  • FreeMarker 模板相较于 JavaScript 不够强大,开发效率低,且模板代码复杂难维护
  • jQuery 用于修改 DOM 时,会有与 FreeMarker 类似的渲染,重复编码
  • 基于 Sea.js 的 CMD 模式并非主流,在使用开源工具库、构建等方面都有限制

此外,还存在以下问题:

  • 缺少代码规范,多人协助时风格混乱
  • 没有自动化测试,修改、重构代码时质量不能保障
  • 本地开发时以配置了数据的 FTL 文件作为 mock 方案,比较笨重,不利于,且需要入侵业务代码以配合调用本地 mock 接口

这些问题很多是老系统的共用问题,但由于产品处于初期阶段,功能迭代较快,并不能停下来进行一步到位的改造,重写或大规模重构是不现实的。

改造过程

在保障正常业务迭代需求的情况下,我们对来钱前端技术栈进行了渐进式地改进。每次实现一个“小目标”,前后经历大概半年时间,达到一个初步稳定的状态。主要改造历程如下:

1 引入 Webpack

引入 Webpack 的改动还是比较大的。

1.1 构建

首先,需要将原本 Grunt 构建过程中的处理都在 Webpack 中实现。还好,Webpack 有丰富的插件,像 CSS 预编译、文件 hash 处理、代码压缩,这些都很好配置。

比较难处理的是 FTL 文件。Webpack 并没有给后端渲染模板的构建提供很好的支持,通常使用的 html-webpack-plugin 插件,也是用于处理前端模板,而且输出结果为 HTML,并不适用于这种输出结果为非 HTML 结构的情况。

为此,几经尝试,最终选择将 FTL 作为普通文本文件进行处理,通过编写 Webpack 插件方式,将构建后的资源地址插入 FTL 文件中引用。这个插件开源了:asset-inject-html-webpack-plugin

通过在 FTL 中以注释方式声明要插入的资源类型、名称,在 Webpack 构建过程中对注释进行替换,例如:

<head>
...
<!-- css_inject_point chunk_common -->
</head>
<body>
...
<!-- js_inject_point asset_jquery -->
<!-- js_inject_point chunk_index -->
</body>

经过构建,输出:

<head>
...
<link rel="stylesheet" href="https://cdn.example.com/common.a50e7157.css">
</head>
<body>
...
<script src="https://cdn.example.com/jquery.min.js"></script>
<script src="https://cdn.example.com/index.f63474ef.js"></script>
</body>

FTL 文件带来的另一个问题是本地开发的页面渲染。如果是 HTML 文件,直接使用 Webpack 的 dev server/middleware 就好,但是对应 FTL 文件,需要额外的服务端渲染工具。

这意味着,在本地开发时,对于 FTL 文件,还是需要输出为本地文件,然后再通过启动类似 ftl-server 的工具来实现 FTL 渲染的。为此,选用了 write-file-webpack-plugin 插件,单独将 FTL 文件输出。

然后 ftl-server 需要启动,我希望能够只启动一个 dev server,本地开发时会简单很多。为此,通过研究 ftl-server,将其核心部分抽离,然后包装为 express middleware:freemarker-middleware

这样,本地开发时,只需要启动一个 dev server,加载 webpack-dev-middleware 和 freemarker-middleware。

1.2 模块化

模块化的改造是指,将基于 Sea.js 的 CMD 模块,改造为 CommonJS 模块,然后搞定原来针对 Sea.js 所做的特殊配置,将其迁移、配置到 Webpack 中。

对于类似 jQuery 这样的外部库,考虑到并不会频繁更新,所以没有纳入构建过程,而是直接在页面进行引用,然后将 jQuery 这样的全局变量包装为模块使用,例如:

// webpack.config.js

module.exports = {
    // ...
    externals: {
        jquery: 'jQuery'
    }
}

// index.js

var $ = require('jquery');

$(function () {
    // ...
});

以上改造,并没有对原有的 FTL 的渲染逻辑、JS 中原有业务逻辑进行改动,这样就降低了上线风险。相关技术方案经过验证成熟后,在一个发布迭代(一周时间)中引入工程,进行一次替换后,即可上线。

2 初步制定代码规范

通过引入 eslint、stylelint,将对代码的规定落实下来,在开发中辅助进行检查,确保代码风格一致,没有明显的问题。

不过,并没有通过插件将 eslint、stylelint 的检查纳入构建过程中。一方面是由于对于代码的改造不是短时间可以完成的,还有大量的业务需要需要支持;另一方面,部门层面没有统一制定规则,只能在项目层面进行约束,但只能是一种“自觉”,而不能“强制”。

3 引入 Vue.js

FTL 后端渲染 + jQuery 的方式,一个很大的弊端是开发效率低。有的情况下,FTL 渲染出的内容,还会由于用户交互,要 jQuery 再进行渲染更新。

例如来钱首页,由于用户借款、还款是异步处理的,需要及时获取最新状态并更新页面显示。页面首次加载时服务器端通过 FTL 模板渲染得到内容进行展示,浏览器端判断处于借款中、还款中状态时,需要频繁查询后台获取最新状态,一旦状态变更,则在前端进行更新渲染。

在产品不断完善的过程中,来钱首页的展示逻辑越来越复杂,例如添加了账户冻结、逾期、还款卡解绑等等新的逻辑,这使得传统的页面渲染技术越发捉襟见肘,开发效率非常低,而且由于渲染逻辑分散,很容易出问题。

在这种情况下,我们选择引入更好的技术方案。如项目技术栈的现状而言,将服务端渲染改为前端渲染是首先要考虑的问题。而前端渲染方面,使用前端模板还是现代化的前端框架,也是要进行抉择的。

最终,在经过慎重考虑之后,我们选用了 Vue.js 进行前端渲染的方案。一方面,Vue.js 上手简单,对于构建过程要求低,初步上线时可以不引入任何特别的处理,方便在业务迭代情况下低风险使用。

为此,我们首先在压力最大的来钱首页进行了实践。后端配合改造,将原本提供给 FTL 的业务数据进行 JSON 序列化处理,随 FTL 渲染后“数据直出”,这样前端在页面加载后可以立即进行渲染,体验上不会太差。

初期上线时,我们只在来钱首页进行改造,其他页面仍采用后端渲染 + jQuery 模式,构建过程没有改动。

4 mock 改造

本地开发时,有关数据 mock 有两个主要的问题:

  • 在 FTL 提供 mock 数据的方式,维护麻烦,且对业务代码有入侵
  • 页面不同场景下的数据模拟不方便

对于第一点,由于 mock 数据有两个使用场景,FTL 模板渲染和 Ajax 请求,都需要兼顾。Ajax 请求比较简单,可以对 dev server 进行改造。而 FTL 方面,则需要对 freemarker-middleware 进行改造,使其能够接收传入的 mock 数据。

freemarker-middleware 的改造还好,底层 jar 本身就提供了传入数据的接口,只要在请求到达 freemarker-middleware 之前将数据准备好,然后约定一个方式提供给 freemarker-middleware 从而进一步用于 FTL 渲染就行了。

结合具体项目情况,我们开发了 mock-middleware,基本可以满足本地开发需求。

再来看不同场景下的数据模拟。以来钱首页为例,即账户状态方面,就有冻结、无订单、无可用额度、逾期、借款中、还款中等状态,并且还有其他如可用额度低于 500 元等情况,这些在页面展现、交互上都要分别处理。在开发特定功能,如新增首页银行卡解绑状态提示,需要将首页的 mock 数据置为特定状态。传统上,是通过手动修改对应 mock 数据的方式进行处理,这一方面不够灵活,另一方面历史上不同场景下的 mock 数据没有积累下来,其他同事开发时还需要自己修改 mock 数据。

为此,我们设计了在 mock 数据中预置多种场景下的数据,在本地开发时进行动态选取使用的方案。

简单来说,对于一个 FTL 数据或 Ajax 数据模块,针对不同场景提供不同数据:

// entry.js
module.exports = function (req) {
  switch (req.$config.Entry) {
      case 'InActive':
        return {
          result: 'success',
          data: {
            status: 'inActive',
            hasOrder: false,
            // ...
          }
        }
      case 'ActiveHasOrder':
        return {
          esult: 'success',
          data: {
            status: 'active',
            hasOrder: true,
            // ...
          }
        }
      // ...
  }
}

注意上面代码中的 req.$config.PageA,这就是配置工具可以动态修改的了。

这一功能内置到 mock-middleware 插件中,通过访问本地开发服务器的 /mock-config/ 页面,即可动态修改场景配置,从而在下一次请求到达,并进入对应的 mock 数据模块时,带入最新的场景配置,进而返回不同的数据。

采用这种机制后,鼓励开发同学将接口在不同场景下的 mock 数据都新增处理,而非覆盖原有数据,这样其他同事也能复用这些 mock 数据。当然,对应地,开发环境中要有一个配置文件,将接口和对应的各种场景列举出来,以便于在 mock-config 页面选择:

// /mock/config.js
module.exports = {
  Entry: {
    desc: '入口页面',
    options: [
      {value: 'Normal', desc: '激活'},
      {value: 'InActive', desc: '未激活'},
      {value: 'ActiveHasOrder', desc: '激活有订单'},
      {value: 'ActiveNone', desc: '激活无额度'},
      {value: 'Overdue', desc: '逾期'},
      {value: 'OverdueNoAvailable', desc: '逾期且借光了'},
      {value: 'Freeze', desc: '已冻结'},
    ]
  },
  // ...
}

将上述方案加入项目后,可以逐步替换原有的页面和接口 mock 数据,一段时间后,即可全部删除所有用于 mock 的 FTL 页面。

5 Vue.js 完善

为便于 Vue.js 的使用,我们将对 *.vue 文件的支持加入(vue-loader),也同时将 ES6、PostCSS 等支持引入,由于 Vue 社区中已经有了很多的实践,这些新增的内容并没有花费我们太多时间。

对于已有的代码,我们逐步进行重构,使用 ES6 特性简化原有 JS 代码,通过 autoprefixer 的引入,省略原有代码中的 CSS 兼容处理等。

另外一方面,是去除 jQuery 相关代码。比较重要的是对于 Ajax 实现的替换,由 $.ajax 改为使用基于 fetch 封装的类似接口,减少迁移成本。但随着 Promise 的引入,逐步由回调模式改为 Promise 模式。

经过一段时间的持续改进,新增功能时的开发体验有了明显提升。

6 单元测试

测试对代码质量的重要性不用多说,但是前端项目的自动化测试方面,我们的经验并不多。经过调研和思考,我们决定先引入单元测试,覆盖重要的公共模块。最终选用的框架是 karma + karma-webpack + jasmine 的模式。

不过在覆盖率方面,我们没有一个目标,甚至以后也没有将覆盖率提升到一个较高水平的打算。我的想法是,前端代码,特别是在引入了 Vue 后的页面,很多与 UI 相关的模块不必进行测试,并且也很难进行测试。与其追求覆盖率,不如对功能、模块进行更好地划分,让关键的代码更具有“可测试性”,UI 相关部分的代码质量由框架进行保障。例如,进行数据修改后,不会去检查页面上某个组件是否正常更新,而是检查 Vue 组件实例上与 UI 相关的数据状态是否正常更新,数据与 UI 的同步由 Vue 保障。

7 单页化改造

原有的多页模式,在涉及页面跳转时,有很多不能控制的情况。例如,在借款流程中,当用户借款成功跳转到“借款成功”页面后,不能再返回到上一页面,只能跳转到来钱首页。

为了解决这些问题,并且优化流程上前后页面切换的用户体验,我们决定对关键业务流程逐个进行单页化改造。

首先,需要后台开发同学配合的是将原来同步的 FTL 页面改造为异步 Ajax 接口提供给前端。

由于已经积累了 Vue 改造的成果,主要页面已经 Vue 化,在接口数据结构不变的情况下,前端的修改工作量并不大,基本上只是将对应的 Vue 组件配置添加到 VueRouter 的 routes 中,然后修改跳转到该页面的逻辑。

并且,借助 vue-router 提供的路由钩子机制,在页面路由变更时,可以进行有效控制,例如,签名提到的借款成功页面只能返回来钱首页的路径,可以在接口成功页面对应的组件中加入:

module.export = {
  // ...
  // 借款成功页面只能返回首页
  beforeRouteLeave(to, from, next) {
    if (to.name === 'index') {
      next();
    } else {
      next(false);
      this.$router.push({name: 'index'});
    }
  }
}

不过到目前为止,还是有一些页面并没有进行单页化改造,而且也不打算进行改造。这些页面并非核心业务,改动并不频繁,单纯为了单页化进行改造只是增加前后端以及测试的工作量而已。

经过上述改造,目前来钱的前端技术栈大致为:

总结

总结网易来钱 2017 年的前端技术栈改进历程,我自己的评价是两个字“务实”。

整个渐进式的改造历程中,我们不会暂停业务迭代,甚至有时候恰恰是为了提升业务才适当引入一些新技术。我们不追求“新技术”,也不追求“技术的纯粹性”,在进行一些技术改造时,我们会评估并控制其影响范围,考虑方案兼容性,不追求“一步到位”。

例如,在近两三个月的过程中,线上许多页面是 Vue 与 jQuery 并存的。某个页面可能整体是采用 Vue 开发,但是其调用的 toast、dialog,可能还是 jQuery 实现的。不过现在 jQuery 早已完全下线。

从这个漫长的改造中,我们收获的经验是:

  • 没有什么技术是不会过时的,解决问题就好,不单纯追求最新的技术
  • 有更好的方案时,不要怕改造,但不要盲目改造,更不要追求一步到位
  • 技术永远是为业务服务,并且在支撑业务的过程中体现价值

本文来自网易实践者社区,经作者汤康兴授权发布。