如何架构和优化好大型单页应用?

近期的几个月算是参与和重构了公司比较重要的一款应用产品,其中有一些心得体会现在和大家分享,能够和各位交流下如何设计和优化大型前端页面。

注:红线为数据流入,橙色为数据流出

总架构

具体背景

对运用传统技术开发的老系统进行Vue翻新,核心为了提高可维护性,提高体验。

刚拿到这款重构任务的时候其实是很迷茫的,老代码的核心报关页面的纯业务单个文件单业务的JS的代码量达到惊人的9000+,全部采用EasyUI实现,而且其中无任何关联和规律,无从下手。

小型码表到大型码表加起来达到惊人的100+,录入页面的初始化中几乎都需要加载这些码表,这些码表定时还会进行更新,因此不能粗暴的全部先一起写入前端缓存起来。

录入页面的表头表体一个页面搞定所有页面的录入来提高用户的录入体验,一张大表后拖着众多表体,并且主录入页面的体验一定要提高。一张表头,10多张表体,很多字段需要录入。

给自己定的目标

主页面加载性能在2-5秒以内,录入页面初始化也需要做到秒级的加载,并且由于需要留给测试测试的时间,最开始产品经理给我们的开发周期在2-3周,因此需要有闪电般的迭代速度,与时间赛跑。

面临的问题

  1. 业务有很多码表数据,如果一下子全部加载会是加载性能的短板
  2. 兼容老的中间件产品所带来的未知区域(如何新酒装旧瓶?)
  3. 时间非常紧
  4. 在做这个项目之前对其业务完全一无所知
  5. 性能优化从何入手?
  6. 如何做到较为清晰的数据流向?
  7. 从单库向读写分离迁移,前端会面临多少改造?

1、棘手的码表 —— 但却是秒级的响应

上百个码表的初始化还需要做到秒加载是一个很大的挑战,可以做和优化的东西非常多,甚至把他作为一个单独的模块进行剥离开发。

国家码表最终页面展示

一股脑儿全部加载不现实,由于会发生变化因此不能做模块引入策略,因此就想到了采用异步加载的方式,首先先进行码表分类,对于用户配置和上万数据量的码表采用后端接口策略,其余的码表采用静态资源请求后缓存的策略来做到性能上的优化。

  • 码表就加载一次,第二次如果传入相同的码表key,直接走缓存策略,不再请求码表以提升性能。
  • 即使筛选出一些不走后端的码表有些码表的数据量都会上千,如果全部放入基础组件层select-item(参考el-select-item)内做渲染,由于数据的双向绑定,面临的结局就是任意一次筛选触发事件,页面就极度的卡,更何况码表类控件在一个页面上会出现几十个,页面渲染,组件动画直接掉帧,用户体验很差,需要做进一步的优化 —— 前端筛选。默认显示前20条,如果有参数进行筛选,把筛选完的前20带入组件进行渲染,做到页面渲染的流畅,这种模式对于用户的使用体验上来说也感受不到与全部渲染的任何区别。
  • 把码表组件库就分成了三个,基础、本地和远程,并且三个组件应用层接口配置完全相同,只是数据源不同。
三层码表组件







// codeMap.js,仅仅展示接口和定义
// 码表缓存对象
let cacheCodeMap: Array = {  }
// 懒加载码表,codeMapName为码表名,ajax请求
function lazyRequestCodeMap(codeMapName: string) {}
// 删除码表
export function delCodeMap (name: string) {}
// 更新码表
export function updateCodeMap (name: string) {}
// 生成码表实例,此处为小码表,参数项在20个以内,可以直接在页面渲染,不做筛选
export function generateVueInstance(vm: VueInstance, type: string, cacheKey: string) {}
// 远程,批量获取码表,调用私有方法lazyRequestCodeMap,如果缓存内已有则不请求
export function getCodeMap(name: (string|Array)) {}
// 筛选码表,val为用户录入数据,codekey为码表key,temparr为筛选对象的key值,topnum取前几位
export function filterCodeMap (val: string, codeKey: string, tempArr: Array, topNum?: number) {}
// 获取缓存里面的内容,适用于已取到码表之后
export function getCacheObjByKey (key: string, objKey: string) {}
/**
 * Find Cache Obj
 * @param {String} objKey Cache Object
 * @param {Function} callback Function that User Can custom
 * 
 * EXAMPLE:
 * findObjByCache('body105', item => {
 *   return item.body105 === '035'
 * })
 */
export function findObjByCache(objKey: string, callback: Function) {}
  • 组件层调用码表缓存层的API,由码表缓存找到匹配的内容后进行筛选,可以按照中文、英文和数字码进行筛选,筛选的结果回流入码表组件,组件再对其进行匹配渲染。
进一步优化,压榨最后一丝性能:建立码表版本号描述文件

对码表建立静态的集中式的版本号文件,此时版本号描述文件以及所有码表带入前端缓存,每次请求码表前请求前置版本号文件描述,根据过期的版本号请求刷新对应码表做到性能的进一步提升。

这边很多人有可能还有个问题,为什么不把数据放入Vuex?因为这个模块为公用模块,有可能采用不同的技术进行调用,尽量采用原生开发模块的好处就是今后任意一个技术都能做到整个模块的迁移,因此自己做了一层码表缓存层。

2-3、开发流程 —— 就像人体艺术家,从骨架到肌肉的开发模式。

从基础的录入功能到辅助功能逐步增量循序进行,需要的是模块化的开发。

首先搭建页面骨架

搭建大的布局骨架,确保每个元素在该在的位子上,由于码表之类的具化模块是后期迭代出来的,因此前期的页面布局全部采用input进行页面直出布局模式,大大加快了原型的输出速度。

架构和业务同步进行,由于组件库也采用公司自主研发,组件维护人员觉得有必要进行抽象的直接在组件底层实现,加快了集成的步伐,由于大项目引发的一些组件的BUG也会及时的迭代修复。

肌肉等细节需要慢慢雕琢

后期代码逐渐完善、包括单证复制,辅助导入,辅助排序等功能逐渐添加进来,不仅锻炼了应用层的开发的能力,更是对公司组件库的一次练兵,是组件自动化测试后的一种辅助,这才是一种理想的共同迭代模式。

4、如何快速迭代一款不清楚业务的产品

由于不处于一个团队,因此不清楚业务是很正常的事情,架构的第一步就需要快速浏览老系统,找到其中的一些关键点先进行模块的搭建。

第二点就是找到他们是最了解业务的人,在我们这边就是测试和项目经理,因此测试的压力是很大的,不要怕出BUG,我们的目的是最快的完成开发,测试会明确的和你说明有哪些细节需要完成,这一块的业务的理解不到位,需要调整,作为工程师需要做模块拆分,用最小的颗粒度和些许胶水代码(代码连接逻辑,连接着一个个分散的模块为一个大整体)来面对理解错误导致的改动。

5.业务组件的懒加载 —— 性能之巅

拥有良好的架构还不够,性能如果不好,产品还是失败的,因此我们在最开始就对性能提出苛刻的要求,凡事用户没有点击的模块一定是用户不想要的模块,因此就不做加载,我们全应用采用懒加载策略,即事件驱动页面。

模块的懒加载

模块的懒加载采用VueRouter推荐的加载模式,这种也被大家广泛运用于生产,详情请看https://router.vuejs.org/zh/guide/advanced/lazy-loading.html

业务页面动态加载

业务层我们运用tab进行布局,一个tab就是一个模块,而此时当你没有点击到这个tab时,整个组件是完全不做任何实例化的(通过tab内部的v-if进行实现),以此做到业务组件的懒加载。优化后的页面性能就比较可观了。但这时候很多人就会问:业务组件不加载不是会导致数据不完整吗?因为往往数据会和业务组件绑定。这时候就要看下一章节:数据流的处理了。

按需加载

6. 数据流架构 —— 粗暴、高效的单向数据流

可以在最开始的架构图中看到,整体页面上的数据流全部是单向的,请求到数据之后扔入字段,数据结构转换之后扔入Vuex的单证缓存层,等待懒加载组件的数据获取,当点击保存或者发送校验的按钮时,通过统一往外暴露的实例接口进行数据的统一获取,没有实例化的组件则走缓存层的数据拼装来保证单证数据不丢失,最后统一通过转换层流入服务器,整个业务链路就是这样进行分工合作的。具体可以查看下面这张业务流图。

业务模块架构

这种数据流的设计模式虽然很粗暴,但是及其简洁、高效,除了接口层的双向交互,其他所有的流向都以单向数据流入,在组件内部进行数据处理,处理、整合完后通过统一的数据链路流入主页面进行收集,主页面再统一进行数据加密、转换,再次分发到服务器,服务器处理完后的热数据更新再灌入现有的Vue实例。

数据流入:交互前赋值,取值

// 数据请求,API内
export function reqGetBill (data: object, noCache: boolean) {
  return post('/api/getCustomAll', data).then(json => {
    translateToIMGD(json)
    if (!noCache) {
      // 分发入缓存
      store.dispatch('setOneBill', {
        data: json.data,
        ieFlag: null
      })
    }
    return json
  })
}
// 数据流取(组件初始化时执行)
export default {
  watch: {
    fetchDone: {
      immediate: true,
      handler (val, oldVal) {
        if (val) {
          // SET Val
          let { setValKey } = this
          let data
          let headId = this.headId || 0
          if (setValKey) {
            // 默认值或复制的处理策略
            data = store.getters.getDefBillById(headId, this.ieFlag) || {}
          } else {
            data = store.getters.getBillById(headId, this.ieFlag) || {}
          }
          // 此处简化,实际会走一层业务层进行验证转换
          this.baseSetTbVal('表体对象key', bill)
        }
      }
    }
  },
  methods: {
    baseSetTbVal (key, obj) {
      let data = cloneObj(obj[key] || [])
      // 表体的列表,所有表体都遵循这个对象规范
      this.tbData = data
    }
  }
}

数据流出:交互前的抓取数据,主框架页面层正式上线代码,核心思想为获取所有组件公布的API,通过refs访问子组件实例进行数据获取

methods: {
    getData() {
      // 设置检验检疫需要单证数据
      this.$refs.docAttachedNeed && this.$refs.docAttachedNeed.setVal()
      // 缓冲层内的报关单数据,用于数据整合
      let cacheBill = this.$store.getters.getBillById(this.id || 0, this.ieFlag) || {}

      let headData = cloneObj(this.form)
      let nodeSetting = getTranslateNodeSetting()
      // nodeSetting例子
      // { bill: { fromNode: 'head', fromRef: '', fromType: 'Array', fromSwgdTb: 'T_FORM_HEAD' } }
      for (let key in nodeSetting) {
        let setting = nodeSetting[key]
        let {fromNode, fromRef, fromType, fromSwgdTb} = setting
        let defaultData = () => {
          return 'Object' === fromType ? {} : []
        }
        // 去除head
        if (!fromType || !fromRef) continue

        let nodeResult
        // 如果组件初始化用组件内部数据,否则采用缓存数据
        if (this.$refs[fromRef]) {
          nodeResult = this.$refs[fromRef].getVal() || defaultData()
        } else {
          nodeResult = cacheBill[fromSwgdTb]
            ? cloneObj(cacheBill[fromSwgdTb])
            : defaultData()
        }
        headData[fromNode] = nodeResult
      }
      // 保持和老系统输出流一致
      let jsonData = {
        head: headData
      }
      console.log(JSON.stringify(jsonData))
      return jsonData
    },
}

这个数据流下几乎能够支撑此录入的所有辅助功能业务,在表头表体组件内再细分组件进行数据流的处理,复制老单证的统一复制流管理,单证首次保存后的单证ID的分发通知,这些功能无需伤筋动骨,纠结数据是否完整,做的仅仅是增量开发。

7.读写分离

由于后台采用读写分离做的策略,因此有可能发生写后马上读的内容不能即刻作出响应,尤其是大型录入页面,追求的是定时保存,多点录入的特点,与原先web阅后即焚的策略不同,此类应用更需要的是一种数据流的持续性。因此第一个就是让后端每次保存后都返回全量的数据,让前端及时的更新。

但其中就会有一个问题,举一个例子:当你开一个新的路由的时候你的应用是input,但当你马上保存后你的此页面需要立刻变成input/{id}或者是input?id={id},用过VueRouter的人都知道虽然都是input,但如果要做多页面的化其实完全是两个页面,更何况框架层封装了一套多页面缓存的策略,此时再做此单证ID的请求读库内部有一定的几率还没同步到,需要后端配合保存后返回全量的数据做缓存策略后新开页面,新开的页面初始化时先观察缓存内部的数据,如果有则不请求直接返回缓存内的数据进行渲染。

兄弟组件的数据流解决方案

之前讨论的都是父子组件的通信,但如果一条单证的表头和表体组件进行通信,或者表体和表体通信该怎么办纳!

  1. 事件总线。建立总线机制进行业务组件的串联,优点是特别的清晰,缺点是由于有可能有多条单证实例共同缓存,分发的时候会分发到不必要的组件,导致事件验证、丢弃率变得很高,因此应用范围为框架通用封装方法。
// 关闭对应的tab页面
this.$app.trigger('close-tab')
// 打开新的tab页(如同jquery模式下新开iframe)
this.$app.trigger("locate-tab", "imports", data.headId);
// 替换此tab为目标tab(如上提到的读写分离策略的新开页面的事件总线)
this.$app.trigger('replace-tab', this.getCurrentRouter(), { id })

自己简单实现一个事件总线也非常简单

/**
 * Simple Event Bus
 * @author: Merjiezo
 * @since: 2018-07-02
 */
class Bus {

  constructor () {
    this._on = {}
  }

  trigger (evtName, ...args) {
    let evtArr = this._on[evtName]
    if (Array.isArray(evtArr)) {
      evtArr.forEach(func => {
        func.apply(this, args)
      })
    }
  }

  on (evtName, fn) {
    if (undefined === this._on[evtName]) {
      this._on[evtName] = []
    }
    let evtArr = this._on[evtName]
    if (evtArr.indexOf(fn) === -1) {
      evtArr.push(fn)
    }
  }

  off (evtName, fn) {
    if (fn) {
      let evtArr = this._on[evtName]
      let index = evtArr && evtArr.indexOf(fn)
      if (index >= 0) { evtArr.splice(index) }
    } else {
      delete(this._on[evtName])
    }
  }

}

export default Bus
  1. $emit往外带回调,这种方法就是典型的 —— 拿一票就跑的方法,即往外emit一个get事件,其中带一个回调方法,在父层取到执行一段代码获取数据再执行回调,目的很明确,结局很美好,单向数据流派很开心。
// 子组件1内
handlePinCusCiqDataAll() {
  this.$emit('pin-data-get', (data) => {
    this.pinData = data
  })
}

// 父组件拿到数据就给回调
getPinData(callback) {
    callback = callback || noopData
    let res
    res = { ... }
    callback(res)
},

向其他子组件赋值也可以用类似的方法做,emit冒泡赋值,很粗暴,但完全不用担心数据共享导致的数据互窜,维护性很高。

  1. provide-inject,此模式为官方的实现,一个典型的依赖注入,但小右并不推荐在业务层使用,但如果真想用也OK https://cn.vuejs.org/v2/api/#provide-inject

结语

Vue本身完全能支撑起巨型应用的开发,通过一些小技巧和语法糖来做到应用层具有较高的性能和反馈,充分压榨Vue和浏览器的最后一点性能。

就像小右在文档里所说:开发中唯一限制你的就是你的想象力。

你可能感兴趣的:(如何架构和优化好大型单页应用?)