探讨点抛出

  • 维护中的项目技术架构有些陈旧,如何切入新的技术框架
  • 组件式开发提高代码质量,复用性,扩展性的一些技巧
  • 与后端接口的联调效率问题
  • 其他

架构更新

目前前端开发框架各项比较

非主流优秀框架简单介绍: SvelteMithril

接口数量及复杂: Angular6 (500+) > Vue2 (100+) > React16 (50-)

国内开发者基础: Vue ~= React > Angular

  • React

之前的协议问题导致不少开发者流出,大厂还是有大量优质组件是React开发的如 AntDesign

  • Vue

优秀的中文文档以及因有类似小程序这样的平台框架语法类似因此开发者数量较大,大厂也有Eleme跟AntV以及Vant等成熟开源组件库

  • Angular

有google背书 UI 有知名的 Material跟Ionic,其1.x的开发者还是不少,到2.x+后因不向下兼容强跟typescript结合开发流失了不少开发者。

框架附加组件量: Angular > Vue > React

  • Angular

倾向企业级开发,方案都是集成度很高的,路由、Http、Form、Validator甚至打包(跟目前财人汇用到的F7有点类似)

  • Vue

讨巧的只引入了一般项目必要的组件,路由、状态控制、通讯用的observe,很轻量级因此能保持一个较小的体积,且能保证这些核心组件的稳定跟质量,虽然最大的问题是 Vue实际的主导者只有作者本人。

  • React

视开发者都是大神,提供了一个灵活的核心后其他的一切都随你去引入,这个导致的后果是很多初级开发者在react router这里就卡了壳 2.x、3.x、4.x、native、web ?想component根据路由按需加载?又得自己diy一个组件(当时自己被逼写的)一个完整的React核心的项目因引入了大量开源社区质量程度不一的组件后导致项目稳定性需要一个相对熟悉这些组件的人去把控,难度变得较高。

组件颗粒程度Angular ~= Vue ~= React

  • Angular 跟 Vue

都有一套自己的模板引擎,组件的引入也基本是需要在某处声明后再引入模板内。组件之间的通讯除了直接引用属性方法外都有一个EventEmitter的工具提供(Vue里是eventBus),解耦方面善用这个方式可以摆脱组件层级的限制。

  • React

有vdom因此模板引擎是不需要的,通过babel这类转义工具可以很方便的使用js原生的方式去做一些逻辑业务。组件通讯方面没有全局的监听工具因此需要自己引入。

个人开发感受:

  • Angular1.x+Ionic

兼容性较好,性能较差,写法比较刻板(企业级框架的特色)2.x+因为后续没有继续使用没有更多的感受。

  • React15/16 + (Redux / Mobx) + AntD

分别开发过2个业务比较复杂的后台项目,搭建初期会消耗非常的大的精力在路由、加载策略以及状态管理选择上,React 的 state跟props 在大多数新手开发的时候都会遇到不少问题(刷新机制)且在性能上如果写法上稍有不注意就容易出问题,API少但灵活的框架对开发人员的要求其实更高,view层中频繁写入匿名函数是常见的一种偷懒写法,直接导致业务代码抽离能力减弱。

  • Vue

因为全家桶,大多数不需要细致定制的项目几乎都可以开箱即用。相对比较弱的应该是Vuex部分,相比其他API不太容易理解,mutation 跟 action 比较多的新手都没明白区别,在引入第三方的库上也没有很好的支持,不像React你真的理解不了Redux可以用相对容易理解的Mobx。好在大多数Vue项目用不到状态管理。缺陷是如果不是用class模式的组件在编辑器里没办法提示组件所提供的属性以及方法名,经常需要打开组件代码去看,效率上相对低一些。

综合考虑下的选择

NB目前所有的前端项目,排除服务端渲染的全部使用 Vue 为主力开发框架。理由:

  • 引入的学习成本相对较小(React需要了解一堆其他的开源库、Angular则需要先熟悉TypeScript以及他那500+的API)
  • 大量的成熟业务组件可复用(ElementUI,Vant…)
  • 样式的超集选择比较灵活(lang=*来决定)以及作用域相对可控(加不加属性scoped),React的CSSInJS以及CSSModule依然需要很高的学习跟维护成本。
  • DIY一个组件的难度相对简单,React还区别 statelesspurecomponent等各种不停形态的组件以提升性能。
  • 组件的写法方式较为统一,React因为是纯ESClass的方式,因此Webpack中Babel对应的ESstage决定了定义Class代码的样,举例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 第一种
class App extends Component {
constructor() {
super();
this.state = {
name: 'React'
};
this.clickHandle = this.handleClick.bind(this);
}
clickHandle() {};
render() {
return (<div onClick={this.clickHandle}>Hello, {this.state.name}</div>);
}
}
// 使用Antd高阶组件Form
export default Form.create()(App);


// 第二种写法开启修饰符特性
@Form.create()
export default class App extends Component {
// 较新的es构建属性写法
state = {
name: 'React'
};
clickHandle = () => {};
render() {
const { name } = this.state;
return (<div onClick={this.clickHandle}>Hello, {name}</div>);
}
}
  • 打包构建速度相对较快,打包后的体积也较小,在H5下比react有一定优势。React虽然可以通过其他方式减少体积(如preact等)但后遗症是一些组件稳定性跟兼容性会不可控。

老项目的迁移步骤

  • 利用组件化的特性,在原有项目上做逐步的迁移。举例 F7框架项目,下面是步骤代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// gulp中先引入用于编译 Vue 组件的模块
// npm install babel-core babel-preset-env gulp-babel
// gulp-rename gulp-vue-single-file-component --save-dev
var vueComponent = require('gulp-vue-single-file-component');
var rename = require('gulp-rename');
var babel = require('gulp-babel');

// 创建一个编译 Vue 的任务
gulp.task('vue', function() {
return gulp
.src('./src/components/src/**/*.vue')
.pipe(vueComponent({ debug: true, loadCssMethod: 'loadCss' }))
.pipe(babel({ plugins: 'transform-es2015-modules-amd' }))
.pipe(rename({ extname: '.js' }))
.pipe(gulp.dest('./src/components/public'));
});

// 找到项目中 requirejs config 部分,将 Vue编译后的js目录指向 paths
require.config({
...
paths: {
...
'Vue': '//cdn.jsdelivr.net/npm/vue/dist/vue',
'@': '../components/public'
},
...
});

// F7依然先完成 Router以及controller载入的工作,在初始化的时候将 Vue组件挂载上去
// xxx.controller.js

define([
'api',
'Vue',
'@/navbar',
'@/testPage'
], function(Api, Vue, Navbar, TestPage) {
function init() {

// 避免动态渲染的内容覆盖,先清除原有内容
document.querySelector('.navbar').innerHTML = '';

/**
* 顶部组件
* @type {Navbar}
*/
var CNavbar = new Vue.extend(Navbar)({
el: document.querySelector('.navbar'),
propsData: {
styleId: 0,
left: '<i class="icon icon-back"></i>',
title: '我的特权',
right: '会员规则'
}
});

// 可以处理一些监听事件
CNavbar.$on('onRightClick', function() {...});

/**
* 主页面内容
* @type {TestPage}
*/
var CPage = new Vue.extend(TestPage)({
el: document.querySelector('.page-test'),
propsData: {
Api
}
});
}

return {
init: init
};
});

提高组件代码的质量

  • 数据以及函数的依赖解耦(依赖越少,可定义的接口越多,可复用率越好)举例海通社区页面顶部navbar定义一个通用组件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<!-- 定义 Navbar.vue 组件 -->
<template>
<div class="navbar-inner" :class="{[classId]: true}">
<div class="left" @click="$emit('onBackClick')">
<slot name="left"></slot>
</div>
<div class="center">{{ title }}</div>
<div class="right" @click="$emit('onRightClick')">
<slot name="right"></slot>
</div>
</div>
</template>
<script>
export default {
props: {
// 增加一个样式容器id以应对不同风格变动
classId: {
type: String,
default: 'default'
},
title: {
type: String,
default: ''
}
}
}
</script>

<!-- 引用组件 -->
<template>
<Navbar title="我的特权"
@on-left-click="onLeftClick"
@on-right-click="onRightClick"
:classId="default"
>
<i class="icon icon-back" slot="left"></i>
<a href="javascript:;" slot="right">会员规则</a>
</Navbar>
</template>
<script>
export default {
components: {
Navbar: () => import('Navbar.vue'),
},
methods: {
onLeftClick() {},
onRightClick() {},
}
}
</script>
  • 不要贪图一时的方便,将可能因环境变化而发生改动的数据放入可配置的js内。避免每次想切环境都要去修改对应的
    举例,可根据打包参数进行转换的api地址前缀:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

// xxx.config.js
let config = {
apiPrefix: {
ajzq: {
test: 'http://118.xxx.129.xxx:8082',
production: 'https://nbapp.xxx.xxx:8890',
},
zhzq: {
test: 'http://118.xxx.129.xxx:8082',
production: 'http://180.167.90.xxx:8082',
}
}[__CLIENT__][__API__]
};

// webpack对应的全局定义 plugin
new webpack.DefinePlugin({
__API__: JSON.stringify(process.env.API),
__CLIENT__: JSON.stringify(process.env.CLIENT),
}),

// 在执行npm script 的时候将配置信息带入,无需配置多条cmd (package.json)
"config": {
"env": "production",
"client": "ajzq"
},
"scripts": {
"dev": "cross-env NODE_ENV=development API=test CLIENT=$npm_package_config_client npx webpack-dev-server",
"build": "cross-env NODE_ENV=$npm_package_config_env CLIENT=$npm_package_config_client webpack --mode production"
}

// 命令举例:
// > npm config set [packagename]:env [env]
// > npm config set [packagename]:client [clientName]
// > npm run build

// 注意:应为平台的不同设置环境变量的语法不同,这里推荐用 cross-env 做兼容处理
// 注意:config set 这个语句是影响全局的,因此会记住上一次配置
  • 如果组件内部的方法需要无视嵌入深度的,定义一个全局的eventbus来处理。改造之前的Navbar组件 注意点:这个事件的名称最好由一个字典配置管理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<div class="navbar-inner" :class="{[classId]: true}">
<div class="left" @click="onLeftClick">
<slot name="left"></slot>
</div>
...
</div>
</template>
<script>
// 这里是需要从引导js里引入
// const EventEmitter = new Vue();
import EventEmitter from 'EventEmitter';
export default {
methods: {
onLeftClick() {
// 这个事件会广播到全项目中,需要捕获的组件只要监听就能响应
EventEmitter.$emit('Navbar:onLeftClick');
// EventEmitter.$on('Navbar:onLeftClick', () => { ... });
}
}
}
</script>
  • 不要过度颗粒化组件,这点跟React不同,React因为有stateless组件的概念组件的颗粒度越高效率也会越高。Vue并不建议这么做,并且在客户定制化变化频繁的情况下也无法实现。
1
2
3
4
5
6
7
8
9
// React -- 颗粒化的无状态组件
export {
Button: (props) => <Button onClick={props.onClick}>{props.text}</Button>
}

// Vue -- 不推荐
<template>
<button @click="$emit('btnClick', $event)">{{text}}</button>
</template>
  • 客户需求变化需要调整组件结构的时候,是否新起一个组件取决于对组件元有逻辑的破坏程度,可继承续用不到50%的可考虑新启组件开发。

  • 因为 Vue组件不存在所谓controller层分离,因此数据接口的预处理尽量避免在组件内完成,最佳的办法是这些处理统一由容器组件(Page)完成,将组件需要的数据结构以组件 props的方式传入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 避免这样
<template>
<UserList :dataset="{{userList}}" />
</template>
<script>
export default {
...
methods: {
getUserlist() {
axios.get(...).then(res => {
// 大量的数据整合处理
// ...
this.userList = res;
});
}
}
}
</script>

// 更好的方式
<script>
import UserModel from 'model/user';
export default {
...
methods: {
getUserlist() {
// 当接口产出的数据发生变化或者需要额外的接口合并的时候
// 只需要修改 model/user.js 的相关逻辑,
// 保持组件内数据产出的纯粹性可以有效提高扩展性跟维护性
// 更进一步,userModel 可以跟 vuex 结合做到跨组件的数据状态保持
UserModel.getUserlist().then(resp => {
this.userList = res;
});
},
// 如果babel支持 async/await
getUserlistAsync: async () => {
const res = await UserModel.getUserlist();
this.userList = res;
}
}
}
</script>
  • 谨慎使用全局植入函数或属性,在财富 react 商城项目中看到 app.js 中包含了大量的 window.xxx。如何避免?引入前面讲到的一个能贯穿项目的事件观察机制(Observe,Vue里的EventBus)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

/**
* 极简的全局自定义监听 observe.js
*/

export default class {
constructor() {
// 创建一个空对象
this._handlers = Object.create(null);
}

/**
* 触发某自定义事件
* @param {String} eventName 事件名
* @param {Mixed} data 传递任意数据
*/
fire(eventName, data) {
let handlers =
eventName in this._handlers && this._handlers[eventName].slice();
if (!handlers) return;

for (let i = 0; i < handlers.length; i += 1) {
let handler = handlers[i];
if (!handler.__calling) {
handler.__calling = true;
handler.call(this, data);
handler.__calling = false;
}
}
}

/**
* 捕获事件
* @param {String} eventName 事件名
* @param {Function} handler 处理函数
*/
on(eventName, handler) {
let handlers =
this._handlers[eventName] || (this._handlers[eventName] = []);
handlers.push(handler);

return {
cancel: function() {
var index = handlers.indexOf(handler);
if (~index) handlers.splice(index, 1);
}
};
}
}


// 使用方法
import Observe from 'libs/observe';

/**
* 通常我们使用 api 产生的错误逻辑
* 以及登录权限判断等系统级别的处理都可以通过订阅事件来响应触发。
* 为API请求创建一个全局监听的对象,便于做提示信息以及log记录
*/
const APIObserver = new Observe();

axios({
...
}).then(res => {
// 比方token失效,这里的事件名称如果会量很多建议放到一个单独的字典文件中
if (res.error_no == 304) {
return APIObserver.fire('TOKEN_INVALID', res);
}
// 其他错误统一处理
if (res.error_no !== 0) {
return APIObserver.fire('API_FAILED', res);
}
APIObserver.fire('API_SUCCESS', res);
});

// 模拟一个操作日志写入
APIObserver.on('log', message => {
api.setLog(message);
});

export { APIObserver }

// Vue (mounted) 或者 React (componentDidMount)
// import 这个实例化监听者后就可以做到全局事件的响应
import APIObserver from 'APIObserver';
mounted() || componentDidMount() {
APIObserver.on('TOKEN_INVALID', () => {
routerTo('/login');
// 甚至可以再发起一个事件广播
APIObserver.fire('log', 'redirect to login page.');
});
}
// 注意:这种全局的方法监听要避免循环调用,对于一些只需要触发一次的操作要记得取消订阅。

Mock 数据 & 数据替换代理

mock 方法比较多样,目前看不太推荐将开关做到 config 中,直接通过本地代理重定位 api url 地址是比较好的办法,这样在开发接口部分可用部分不可用的时候 mock 具体哪几个接口可以做到颗粒度的控制,目前代理的工具比较新的是有赞的 zan proxy
还有一款使用起来复杂一些的类似库 whistle
请求数据的代理转发在前端开发中扮演比较重要的角色:

  • 线上正式代码本地 hotfix (将正式js或者css替换成本地的文件,接口依然沿用正式的接口,做到修改bug上线能一把通过)
  • 替换线上正式的index.html文件,在尾部追加类似 vconsole.js 这样的调试代码,实现 h5 在手机上的热调试(手机的wifi需要配置代理)
  • 库预升级测试,升级某个库是否有风险,通过代理替换而无需去动源代码。

除了本地替换的方式外,也可以利用先上的一些工具,比如 easy-mock,可以事先跟后端同学沟通好,将api route以及字段都在上面定义好,或者通过swagger导出的json结构自动生成对应的api,提高制作mock的效率。

前端在调用api的时候用的比较多的比如postman等工具,但为了提高效率,建议在编辑器内整合。比如 vscode中的 REST Client 插件,可以快速的通过定一个一个.http文件并定义几个不同参数的 http 请求快速的在编辑器 Tab中得到数据结果,并可以方便的复制粘贴这些数据。

项目结构

  • 不单纯的以文件类型来做目录管理

这个结构中,less,css,font,img 其实都是服务于修饰页面文档层面的,可做适当的优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
assets
|- styles
|- less
|- css
|- ... 其他的一些样式超集语言
|- img
|- font
libs
|- models // 数据模型包含api的操作的数据整理等,从组件中抽离
|- vendor // 第三方引入的组件,除了 npm 方式以外如果需要对源码做微调的也可以放在此处
|- utils // 工具合集
components
|- pages // 页面级别的组件(也可以称为容器组件)
|- partials // 局部复用的UI组件(子容器,基本常见的header,bar等,也可以取名为layout,布局级别的组件)
|- modules // 功能性复用的组件目录
|- pagination // 举例分页组件
|- assets // scoped 样式目录 里面存放 样式文件图片等跟组件一起独立的资源
|- pagination.vue // 这里如果还要再做子组件,也可以pagination-xxx.vue
config // 如果配置文件过多可以单独起一个目录
|- clients // 可针对不同客户单独的配置
|- urls.js
|- ..

项目结构的最低要求,未来扩展或者调整结构代码不需要大幅的调整,影响全局跟局部的文件目录最好有所区别。

  • 利用打包工具为资源设置别名路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.config.js
resolve: {
extensions: ['.js', '.jsx', '.css', '.less', ...],
modules: [path.resolve('node_modules'), path.resolve('src')],
alias: {
common: path.resolve('src/common'),
utils: path.resolve('src/utils'),
static: path.resolve('src/static')
}
}

// 之后的引用就可以变为
import Utils from 'utils';
import General from 'common/general';
import AppConfig from 'utils/appConfig';
import FavoriteBtn from '@/favorite-btn';

// 好处:万一需要优化调整目录,不至于要逐个去修改引入路径,只需要调整webpack 的别名配置路径。

后端接口相关

excel文件不利于更新及时,且想要搜索或者看一个大概的数据结构非常不便,
建议后端采用 swagger 类似的方式,对于前端开发来讲很直观,提高效率,这个需要后端的同学推进下。