前端架构:记一次基金推广页面需求

379 阅读10分钟

前言

近几年做的需求大部分都是内部应用,对性能、UI规范要求相对较低,近期客户提出基金推广页面需求,针对外部c端客户,开发中遇到一些问题,最终一一解决,获得客户好评,这里来记录一下。

需求描述

  • 背景:
    基于现有基金管理后台FMS(fund management sysment)系统数据,开发基金推广前端页面基金推广前端页面需要单独部署在外网,支持后台修改数据,前端页面同步展示。
  • 基金列表页面:
    支持复制、删除基金
  • 详情页面内容:
    风险披露 + 基金描述 + 基金增长、回报率等数据图表(6个chart+2个table)+ tab(基金详情 + 投资组合+基金表现+获奖)+ 相关文档 + 免责声明
  • 其他:
    动画、支持中英文 、 支持4套字体、支持pc端和手机端
  • 补充说明
    后台的数据分为客户填写 和 交易系统获取的,需要展示的交易系统获取数据长达十几年,数据超5000条

开发过程

我作为该需求的前端负责人,带领其他2位前端同事开发此需求。

架构分析

  • 基金管理后台是一个成熟的基于vue的后台系统,基金推广前端页面 单独部署在外网,另外搭一个系统还是现有系统开发?
    考虑到 客户在后台编辑的时候,需要支持预览,为了避免开发两套代码,所以考虑在现有系统开发。
    但是这样就需要将 基金推广前端页面 单独打包,移动端适配、字体包的方案要考虑不影响到其他页面。

部署方案

公司网络架构将VPC划分成DMZ、SF和PTR三个默认隔离的网络域,方便进行业务部署。

•     DMZ网络域:Demilitarized Zone,俗称隔离区,可以通过NAT网关、IGW网关或ELB等服务和公网连接,通常用来部署前端系统。

•     SF网络域:Server Farm Zone,俗称内部服务区,通常用来部署应用服务器和数据库服务器。它无法直接与公网连接,但是可以通过设置被其他两个网络域访问。

网络域之间是相互隔离的,需要配置访问策略才能够互通。

  • 申请资源
    内网应用部署在外网,涉及到SF网络域 到 DMZ网络域 的访问策略。基于现有架构,我们需要申请很多资源。 现有基金管理后台的接口和其他页面都不能支持外网访问,所以需要在DMZ 申请一套资源,提供外网访问的出口,架构涉及到tomcat、nginx、ip。
  • 节约成本
    考虑到基金推广页面仅仅是一个单页面,涉及的接口数量少,考虑利用现有的 DMZ网络域 资源tomcat N,将前端html部署在资源tomcat N的静态资源里面,资源tomcat N代理sf内网的FMS接口。

接口设计

基金列表页面:

  1. copyFundById 支持复制基金
  2. deleteFundById 删除基金

基金详情页面

  • 基金推广页面 上所有数据几乎都来自基金管理后台,通过调用接口获取
  • 中文环境下,返回中文数据,英文下环境下,返回英文数据;中英文下的图表数字数据相同
  • 其中三个chart图表数据是已有数据库获取的,其他都是客户在页面编辑的

基于此,详情部分我们设计了5个接口,

  1. getDetailById 入参基金id、语言环境lang,接口返回数据如下 {id:xx,lang:'zh/en',langText:string,commonData:string}
  2. submitDetailById 入参 {id:xx,lang:'zh/en',langText:string,commonData:string}
  3. getChart1ById 返回chart数据
  4. getChart3ById
  5. getChart5ById

其中详情接口将原本 json类型的详情数据 langTextcommonData 转为 string类型存储,避免后端新建复杂的数据库表,避免前端每次增减字段,都需要修改数据库。弊端就是搜索起来不方便,考虑到如果有搜索场景,可以采用nosql数据库。

拆分任务

大体方案定下来了,就着手开发了,先直接开发详情页面。

我主要负责整体的方案,上面所说的 动画、中英文 、4套字体、pc端和手机端适配,页面其他内容主要交给其他2名前端开发。

为了控制项目进度,大家一起着手开发。

拆分组件

长页面内容比较多,首先拆分组件,将页面按内容分为6个大的模块

1、riskDisclosure 风险披露
2、description 基金描述
3、chartErea 基金增长、回报率等数据图表区域(6个chart+2个table)
4、tabErea(基金详情 + 投资组合+基金表现+获奖)四个tab
5、document 相关文档
6、declaimer 免责声明

其中3chartErea继续拆分为8个子组件, 4tabErea继续拆分4个子组件,

代码目录

fundList.vue 基金列表
fundIntro.vue 单个基金详情
1、riskDisclosure.vue 、riskDisclosureEdit.vue
2、description.vue、descriptionEdit.vue
3、chartErea
- index.vue
- chart1.vue、chart2.vue、table3.vue ... chart8.vue
- chart1Edit.vue、chart2Edit.vue、table3Edit.vue ... chart8Edit.vue
4、tabErea(基金详情 + 投资组合+基金表现+获奖)四个tab
- index.vue
- fundDetail.vue 、 portfolio.vue 、performance.vue 、award.vue
- fundDetailEdit.vue、 portfolioEdit.vue、 performanceEdit.vue、 awardEdit.vue
5、document.vue、documentEdit.vue
6、declaimer.vue、declaimerEdit.vue

数据流

我们将整个页面的数据放到最外层fundIntro,通过父子组件传参 props 传递数据流

submitData: { 
  langText:{
    riskDisclosure:{},
    description:{},
    chartErea:{chart1:{},chart2:{}...},
    tabErea:{fundDetail:{},portfolio:{}...},
    document:{},
    declaimer:{},
  }
  commonData:{
    riskDisclosure:{},
    description:{},
    chartErea:{chart1:{},chart2:{}...},
    tabErea:{fundDetail:{},portfolio:{}...},
    document:{},
    declaimer:{},
  }
}

路由注册

基金管理后台 注册路由时传入props:{isPreview:true/false},

{
  name:'FundProview',
  path:'/FundProview',
  component:()=>import '@/views/fundIntro/fundIntro.vue',
  props:{isPreview:true}
},{
  name:'FundEdit',
  path:'/FundEdit',
  component:()=>import '@/views/fundIntro/fundIntro.vue',
  props:{isPreview:false}
}

fundIntro 页面布局

<template>
   <div class="fms-fundIntro">
       <div class="fms-fundIntro__button" v-if="!isPreview">
         <el-button @click="openPreview">打开预览页面<el-button>
         <el-button @click="toggleLang">切换中英文<el-button>
       </div>
       <div class="fms-fundIntro_content">
         <RiskDisclosure :submitData="submitData" :isPreview="isPreview" @openEdit="openEdit"/>
         <Description :submitData="submitData" :isPreview="isPreview" @openEdit="openEdit"/>
         <ChartErea :submitData="submitData" :isPreview="isPreview" @openEdit="openEdit"/>
         <TabErea :submitData="submitData" :isPreview="isPreview" @openEdit="openEdit"/>
         <Document :submitData="submitData" :isPreview="isPreview" @openEdit="openEdit"/>
         <Declaimer :submitData="submitData" :isPreview="isPreview" @openEdit="openEdit"/>
       </div>
       <el-draw :visible="openEditDraw" class="fms-fundIntro_edit">
         <EditComponent ref="editRef" :submitData="submitData"/>
         <el-button @click="save">保存<el-button>
         <el-button @click="openEditDraw=false">取消<el-button>
       </el-draw>
   </div>
</template>
 <script>
  export default {
    props:[isPreview],
    data() {
      openEditDraw:false,
      submitData:{langText:{},commonData:{}}
    },
    create(){
      this.getDetail()
    )
    methods:{
      //axios获取详情数据
      getDetail(){... },
      //切换中英文
      toggleLang(){
        this.$router.replace({ path: '/FundEdit',query:
         {id:this.$route.query.id,lang:this.$route.query.id==='EN'?'ZH':'EN'} })
        this.getDetail()
      },
      //打开预览页面
      openPreview(){
        window.open('/FundProview?id='this.$route.query.id+'&lang='+this.$route.query.lang)
      },
      //打开编辑框
      openEdit(path="/chartErea/chart1Edit"){
        Vue.component('EditComponent',()=>import '@/views/fundIntro'+path,)
      },
      //axios保存详情数据
      save(){...}
    }
  }
</script>

开发设计

1、多入口打包

基金管理后台 使用的是vuecli4脚手架,配置多入口只用参考文档。但是这样打包出来,index和subpage的js代码是在一个文件夹里面,难以分开。

module.exports = {
  pages: {
    index: {
      entry: 'src/index/main.js',
      template: 'public/index.html',
      filename: 'index.html',
      title: 'Index Page',
      chunks: ['chunk-vendors', 'chunk-common', 'index']
    },
    subpage: 'src/subpage/main.js'
  }
}

我采取的通过不同命令,分两个入口打包。在package.josn里面

script:{
  "serve":"vue-cli-serve serve",
  "build":"vue-cli-serve build",
  "serve:fund":"vue-cli-serve serve --mode funddev",
  "build:fund":"vue-cli-serve build--mode fundprod",
}

根目录新建文件 .env.funddev 、.env.fundprod

VUE_APP_baseUrl:'https://aaa.www/fund_proxy'
VUE_APP_entry:'fundMain.js'
VUE_APP_outputDir:'fundDist'
VUE_APP_publicPath:'./'

分别修改axios请求的前缀、入口main、编译后目录、publicPath

入口的fundMain.js相比项目main.js,少了很多不许用的库,比如element-ui我们可以按需加载,不需要公共组件、公共函数、路由router、store、i18n及其他

fundMain.js 因为不需要路由,我们直接渲染 fundIntro.vue,考虑原代码有几处获取this.$route.query路由参数,采用fundMain入口this没有该变量,于是给vue原型增加 $route.query,参数来源location.search

 import FundIntro from '@/views/fundIntro/fundIntro.vue'

 Vue.prototype.$route={query: parse(location.search)}
 new Vue({
  render:h=> h(FundIntro,{isPreview:true})
 )

2、动画实现

我们的动画分别无限运动动画与滚动页面时才展示的动画。无限运动动画 我们直接采用css3 animation、transform 。
滚动页面时才展示的动画,查找资料我们选用 vue-animate-scroll + animate.css 技术

<div v-animate="'slide-up'">Hello</div>

使用vue-animate-scroll的时候,因为我们页面的滚动条在内部元素上面,导致监听不到滚动到可见区域,于是自己又重写了v-animate指令

Vue.directive("animate",{
  inserted(el, binding){
    const classlist = binding.value.split(" ");
    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
    window.addEventListener("scroll",
      throttle(() =>{
        const rect = el.getBoundingClientRect();
        if (rect.top <= viewportHeight && rect.bottom >= 0) {
           el.classList.add(...classlist)
         }else {
           el.classList.remove(...classlist);
         }
       },1000),
    true)
 }
};	

3、接口设计

getDetailById获取基金详情数据

vue用到的数据结构如下,在中英文下 commonData字段相同,langText 会根据中英文返回不同内容

submitData: { 
  langText:{
    riskDisclosure:{},
    description:{},
    chartErea:{chart1:{},chart2:{}...},
    tabErea:{fundDetail:{},portfolio:{}...},
    document:{},
    declaimer:{},
  }
  commonData:{
    riskDisclosure:{},
    description:{},
    chartErea:{chart1:{},chart2:{}...},
    tabErea:{fundDetail:{},portfolio:{}...},
    document:{},
    declaimer:{},
  }
}

传给后台的数据如下 {id:xx, lang:'zh/en',langText:string,commonData:string} 后台将langText 和 commonData 转成字符串存储,这样只用新建id、lang、 langTextZH、langTextEN、commonData 5个主要字段

getChart1ById获取基金十几年数据

数据多达5000条,http responce 数据达2MB,于是和产品、后端商量以下方案

  • 方案1,前端保存时将echart对应的图片存起来,先加载图片,等待接口数据返回后再替换
  • 方案2,增加按钮:成立以来、近三年、近一年、近半年、近一个月,每种条件展示300点个
  • 方案3,还是只展示300点个,等比取点

方案1: 图片暂不支持文件服务器存储(申请外网文件服务器很麻烦),只能base64存储,使用 echart.getDataURL函数,分辨率高,存储还是很大,分辨率小,效果不好,很快放弃了。

方案2: 经过与业务方的商量,业务不同意增加按钮。

方案3: 于是说服采用方案3,echart展示5000个点,鼠标悬浮也没法看到每天的数据。

为了给业务展示300个点的效果,前端临时写了算法,等比取点。但是遇到很多数据是空值,echart的折线图很明显断痕,于是前端做了处理,如果取到的点是空值,就直接用上个有数据的点。最终业务采取了该方案。

每隔10个点取数,如果为空,就取上一个不为空的值

function getEquallySpacedPoints(arr,ratio=10) {
    var points = [],pre = 0
    for (let i = 0; i < arr.length; i=i+ratio) {
      pre = arr[i] || pre
      if(pre){
        points.push(pre);
      }
    }
    return points;
}

当然,最终等比取点交给了后端同学去做,减少http的响应时间

4、中英文切换

上面接口设计可以看出,我们将有文案部分存储在 langText 字段,公共数据存储在 commonData字段。
每次后台切换中英文,更改路由lang参数,重新调用详情接口。

toggleLang(){
        this.$router.replace({ path: '/FundEdit',query:
         {id:this.$route.query.id,lang:this.$route.query.id==='EN'?'ZH':'EN'} })
        this.getDetail()
},

当然中英文除了文案不同,在一些样式上也是有区别的,比如英文下,业务不希望单词打断,使用 word-break:keep-all 在中文下,会出现一行文字不换行,进而出现滚动条。关于word-break大家有兴趣可以去查看文章 彻底搞懂word-break、word-wrap、white-space

5、移动端适配

关于移动端适配,现有有很多方案,我在之前工作中也有用过,文章 # h5适配 有介绍,考虑到不影响原有页面,也是简单起见,最终css部分采用的是 vw + 媒体查询@media screen 的方式。

我们的终端设备,分为pc(1620-1920)、笔记本及pad(600-1620)、手机端(<600)

当然我们pc端与H5是同一套代码,在js部分也会有区别,我们采用判断window.innerWidth 的宽度判断。

这里也遇到几个坑:

  1. JS部分,我原采用screen.width方式判断设备,结果客户那边效果与预计不符合。后面分析代码,我想到客户应该是调整了显示器分辨率,比如放大2倍,浏览器像素 1920/2,但是屏幕大小 screen.width 还是1920,于是我将它换为 window.innerWidth
  2. 在电脑端调试样式,chorme模拟的手机设备最小font-size是12px,所以原本font-size:0.8vw的,在真机上就特别小,于是写了大量 媒体查询@media screen 处理。
  3. 滚动条部分,ios端只有滑动才展示滚动条,业务需要一直展示。webkit-scrollbar 在pc端chrome、safiri都是生效的,在ios上就不生效,于是我们直接使用element-ui的el-scrollar组件,它是使用div去模拟滚动条,保证了效果的一致。

6、减小内存

减少打包静态资源大小

  1. el-element等组件在入口处,按需加载
  2. 图片、字体包、js、css等压缩

减小运行时内存

  1. 接口数据等比取点,echart减小渲染的点
  2. 封装easy-table组件代替el-table组件,了解el-table源码的同学就知道,el-table的功能很强大,支持自定义表头、表尾、左右固定,这样它涉及5个table布局,涉及到大量的计算,但是我们的table很简单呀,自己封装一个吧。使用自己封装的,运动内存直接减小一半...

7、异步组件

fundIntro 页面的编辑区域是有20个之多的,使用静态组件,就需要注册20个之多,使用 Vue.component异步组件,大大减小了组件注册,# 解密vue异步组件有兴趣可以看看

<template>
   <div class="fms-fundIntro">
     ...
       <el-draw :visible="openEditDraw" class="fms-fundIntro_edit">
         <EditComponent ref="editRef" :submitData="submitData"/>
         <el-button @click="save">保存<el-button>
         <el-button @click="openEditDraw=false">取消<el-button>
       </el-draw>
   </div>
</template>
 <script>
 ...
    methods:{
      //打开编辑框
      openEdit(path="/chartErea/chart1Edit"){
        Vue.component('EditComponent',()=>import '@/views/fundIntro'+path,)
      },
    }
  }
</script>

8、动态组件

如下使用也简化了页面代码

<template> 
   <el-tabs v-model="activeName"> 
       <el-tab-pane label="基金详情" name="FundDetail"></el-tab-pane> 
       <el-tab-pane label="投资组合" name="Portfolio"></el-tab-pane> 
       <el-tab-pane label="基金表现" name="Performance"></el-tab-pane> 
       <el-tab-pane label="获奖" name="Award"></el-tab-pane> 
       ...
       <el-tab-pane label="其他信息" name="twenty"></el-tab-pane>
    </el-tabs> 
   <component :is="activeName" />
</template>   

9、字体包太大

页面涉及到4套繁体字体,每个大概6M,页面需要5s后才切换字体。除了优化网络外,我们也尽可能减小字体包,首先将ttf压缩成woff2,直接小1/3,每个字体包4M。这还是不够呢。

font-spider将页面现有文案抽离,提取字体包。但是页面内容是支持编辑的,以后新增文字,就没效果了。

当时就想能不能先把抽离出来的加载,等待完整字体包加载出来再替代。然后发现了FontFaceObserver库,人家已经实现了我的设想,果然程序员的痛苦是相通的,不会的就搜索一通,没有实现就可以考虑造个轮子,哈哈~不过我目前一个轮子都没造出来,全是搬运工。我这个码农啥时候能翻身...

FontFaceObserver用法如下


 @font-face {
  font-family: fangzhengxiyuan;
  src: url(方正细元繁体.woff2) format('woff2');
}

var font = new FontFaceObserver('fangzhengxiyuan');

font.load().then(function () {
  var eles = document.querySelectorAll('.fangzhengxiyuan')
  [].foreach.call(eles,(ele)=>{
     ele.style.fontFamily = 'fangzhengxiyuan'
  })
});

将需要设置字体的html元素增加class .fangzhengxiyuan ,就可以实现字体替换。考虑到网页很多处需要增加 class,就想有没有改动较小的方案呢?

果然想到了,css变量,项目使用的是scss,在之前开发就将 'fangzhengxiyuan' 设置为scss变量(当时全局很多'fangzhengxiyuan'字样,就用了scss变量),现在改起来就方便了

// scss变量,fangzhengxiyuan2是font-spider抽离出来的
$FONT_fangzhengxiyuan = var(--fangzhengxiyuan,'fangzhengxiyuan2')

var font = new FontFaceObserver('fangzhengxiyuans');
font.load().then(function () {
  document.getElementByTagName('body')[0].style.setProperty('--fangzhengxiyuan','fangzhengxiyuan')
});

最后

在这个过程中,开发只是一部分,涉及到大量的沟通。

比如客户决定让我们提供 前端静态资源包,他们部署在另外一个香港供应商H那边,那边封装了埋点统计的数据。

香港供应商H对接人不会普通话,而我不会粤语,于是沟通采用邮件的格式,全英文哦,幸好英语阅读能力还行,但是还是想吐槽香港供应商H一番

因为接口还是部署在我们这边,静态资源部署在 供应商H 那边,于是建议他们采取 接口代理,可以避免跨域,而且担心香港部分客户直接访问我们接口网络受限。然后经过7x7回合,供应商H 终于设置好了代理,结果客户反馈接口需要8s才响应...

供应商H 让我们将数据生成静态的echart图片,他们直接调用我们静态图片,采用json数据会让他们处理并渲染大量的数据。额...cpu有点蒙了,供应商H不就是代理一下接口么?处理并渲染 不是在客户浏览器上么?

因为我们访问供应商H 那边静态资源很慢,当时猜想我们与供应商H 那边存在墙,网速受限,于是建议他们增加redis,后台每次更新数据,通知redis更新。然后他们说不会弄redis....

下班路上突然想到,我们的原本dmz服务本身针对了大量香港客户,应该不会存在墙导致香港客户无法访问。而且客户a直接访问我们服务是很快的,于是让 供应商H 直接访问,结果不出所料,dmz服务 带宽还是可以的,这就不知道 供应商H 怎么代理的了?难道他们真的将数据处理了一番再返回?...

最终还是采用跨域的方式,不经过供应商H代理了,不想再无限拉扯了。

啊啊啊,经过此事,我只想学好英语口语,去香港搬砖,香港的砖搬起来应该很轻松~~~

如有更好方案,愿各位大佬不吝赐教

欢迎关注我的前端自检清单,我和你一起成长