前言
近几年做的需求大部分都是内部应用,对性能、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接口。
接口设计
基金列表页面:
- copyFundById 支持复制基金
- deleteFundById 删除基金
基金详情页面
- 基金推广页面 上所有数据几乎都来自基金管理后台,通过调用接口获取
- 中文环境下,返回中文数据,英文下环境下,返回英文数据;中英文下的图表数字数据相同
- 其中三个chart图表数据是已有数据库获取的,其他都是客户在页面编辑的
基于此,详情部分我们设计了5个接口,
- getDetailById 入参基金id、语言环境lang,接口返回数据如下
{id:xx,lang:'zh/en',langText:string,commonData:string}
- submitDetailById 入参
{id:xx,lang:'zh/en',langText:string,commonData:string}
- getChart1ById 返回chart数据
- getChart3ById
- getChart5ById
其中详情接口将原本 json类型的详情数据 langText
和 commonData
转为 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 的宽度判断。
这里也遇到几个坑:
- JS部分,我原采用screen.width方式判断设备,结果客户那边效果与预计不符合。后面分析代码,我想到客户应该是调整了显示器分辨率,比如放大2倍,浏览器像素 1920/2,但是屏幕大小 screen.width 还是1920,于是我将它换为 window.innerWidth
- 在电脑端调试样式,chorme模拟的手机设备最小font-size是12px,所以原本font-size:0.8vw的,在真机上就特别小,于是写了大量 媒体查询@media screen 处理。
- 滚动条部分,ios端只有滑动才展示滚动条,业务需要一直展示。webkit-scrollbar 在pc端chrome、safiri都是生效的,在ios上就不生效,于是我们直接使用element-ui的el-scrollar组件,它是使用div去模拟滚动条,保证了效果的一致。
6、减小内存
减少打包静态资源大小
- el-element等组件在入口处,按需加载
- 图片、字体包、js、css等压缩
减小运行时内存
- 接口数据等比取点,echart减小渲染的点
- 封装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代理了,不想再无限拉扯了。
啊啊啊,经过此事,我只想学好英语口语,去香港搬砖,香港的砖搬起来应该很轻松~~~
如有更好方案,愿各位大佬不吝赐教
欢迎关注我的前端自检清单,我和你一起成长