前端需要注意的要点

聊聊公司团队前端交互需要注意的一些要点。

  1. 一般上 Tab 切换的页面都应该通过路由定义,切换选项卡即切换路由,而且要用 replace 而不是 push 切换。
  2. 页面分页、侧栏分类切换分类等会重新加载列表的操作都要通过路由进行切换。
  3. 链接必须使用 a 标签+设置 href 属性声明跳转,允许用户右键新选项卡打开,不要通过 click 事件进行跳转。
  4. 引入外部插件、添加 DOM 事件到 document/body 时,一定要销毁,不做很容易导致内存泄漏。
  5. 能用框架提供的接口/方法解决就用框架,不要自己另外实现一份。
  6. 页面/组件的每一个状态都要处理。

组件状态处理

重点说一下这个,以页面路由组件为例子。

例如打开一个页面,状态大致可以分为数据加载中/加载完成/没有数据三种。

在写每一个页面的时候,都需要针对这 3 种状态进行显示 Loading/骨架图、渲染、空数据提示的处理。

另外说一个例子,一个页面包含多个相互独立的业务组件(每一个业务组件就可以当作子系统)。

需要考虑到每一个业务组件可能出现的状态:

有一些组件比较简单,直接渲染 UI 就好了;

有一些组件较为复杂,有 Ajax 请求,那么要考虑到请求中、请求成功、失败、没有数据等等状态;

有一些组件可能引入了很大的第三方库(如Echarts),这些第三方库必须异步加载,这时就需要考虑异步加载第三方库时的状态,比如显示 Loading 之类;

其他诸如屏幕适配也可以当作状态的一种,不过屏幕适配一般是在<App />中做,平时不需要太注意。

平时开发组件的时候一定要想好到底有多少种状态,哪些状态要处理,哪些状态不必处理。

另外说下组件状态的理解

对比下下面几种写法的优劣势:

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
<template>
<div>
<skeleton v-if="aData.loading && bData.loading && cData.loading"></skeleton>
<div v-else-if="aData.error || bData.error || cData.error">页面发生错误</div>
<template v-else>
<part-a :data="aData.list"></part-a>
<part-b :data="bData.list"></part-b>
<part-c :data="cData.list"></part-c>
</template>
</div>
</template>
<script>
export default {
data() {
return {
aData: {
loading: true,
error: false,
list: []
},
bData: {
loading: true,
error: false,
list: []
},
cData: {
loading: true,
error: false,
list: []
}
}
},
async created() {
this.loadA();
this.loadB();
this.loadC();
}
};
</script>
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
<template>
<div>
<skeleton v-if="loading"></skeleton>
<div v-else-if="hasError">页面发生错误</div>
<template v-else>
<part-a :data="aData"></part-a>
<part-b :data="bData"></part-b>
<part-c :data="cData"></part-c>
</template>
</div>
</template>
<script>
export default {
data() {
return {
loading: true,
hasError: false,
aData: [],
bData: [],
cData: []
}
},
async created() {
try {
await Promise.all([this.loadA(), this.loadB(), this.loadC()]);
}
catch(err) {
this.hasError = true;
}
finally {
this.loading = true;
}
}
};
</script>
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
<template>
<div>
<skeleton v-if="loading"></skeleton>
<div v-else-if="hasError">页面发生错误</div>
<template v-else>
<part-a :data="aData"></part-a>
<part-b :data="bData"></part-b>
<part-c :data="cData"></part-c>
</template>
</div>
</template>
<script>
export default {
data() {
return {
aData: {
loading: true,
error: false,
list: []
},
bData: {
loading: true,
error: false,
list: []
},
cData: {
loading: true,
error: false,
list: []
}
}
},
computed: {
loading() {
return aData.loading && bData.loading && cData.loading;
},
hasError() {
return aData.error || bData.error || cData.error;
}
},
async created() {
this.loadA();
this.loadB();
this.loadC();
}
};
</script>

其实一个组件可以当作一个有限状态机。

然后就可以把页面复杂的逻辑问题简化为几个状态机自身的状态转移问题,可以有效简化问题又失灵活,以应对多变的需求。

为什么要使用状态管理

我们平时开发的大部分项目,由于复杂度不够, 很少使用 Vuex、Redux 等状态管理库,就算引入了 Vuex 这些库,也只是当作一个全局数据引用,并非对应用状态进行管理。

但一旦页面的复杂度比较高,必然要引入状态管理,今天就聊聊我理解中的状态管理。

如果涉及到举例,由于我对Vuex更熟悉,团队内也大多比较熟悉Vue,因此会使用Vuex作例子。

到底什么时候应该使用状态管理

举例几个需要用 Vuex 的例子:

例子一

1
2
3
4
5
// Page.vue
<page>
<component-a />
<component-b />
</page>
1
2
3
4
// ComponentA.vue
<div>
<component-a-child />
</div>

比如这个例子中,<component-a-child />想和<component-b />通信,使用事件传递来解决非常麻烦。

当然也可以使用 EventBus,加一个全局的 vue 实例解决,但用 EventBus 还需要去关心事件的绑定解绑,需要手动处理事件,当这类组件,就会变得非常麻烦。

最好的解决办法就是抽象出通用的组件状态,放到 state 里面,接着通过 action/mutation 改变通用状态,而需要这些状态的组件则自己调用(mapState/mapGetter),不需要去关注组件之间的关系。

例子二

1
2
3
4
5
6
7
// Page.vue
<page>
<topic-list
:list="list"
:activity="activity"
:user="user" />
</page>
1
2
3
4
5
6
7
// TopicList.vue
<div>
<topic-header :list="list" :activity="activity" :user="user" />
<template v-for="item in list">
<topic :list="list" :data="item" :activity="activity" :user="user" />
</template>
</divt

这个例子里:list="list" :activity="activity" :user="user"在被不断的传递,实际里面的组件可能只需要里面的一两个属性。

当然,例子里面的代码比较简单,也可以通过合理的组件设计来解决。

但一旦碰到这种某几个状态数据不断被其子组件以及后代组件使用的状况,可以考虑使用状态管理来解耦,可能使代码更加简洁。

状态管理解决了什么

  • 最主要是解耦,把组件与组件之间的复杂关系解耦为数据与数据的关系,组件仅作单纯的数据渲染,而且由于是单一数据源,整体上非常便于维护。

以前是:

现在是:

  • 由于单一数据源+数据不可变,带来了应用状态的快照,可以很方便的实现前进/后退以及历史记录管理。

  • 可测试性,可以分别针对视图和数据进行测试,而不是混淆在一起,导致测试难度极大。

状态管理带来的新问题

最主要是由于解决使得单个组件复杂度的提升,但相比整体复杂度的降低以及更高的可维护性,这点代价是完全值得。

使用Promise实现简单的Ajax缓存

业务场景

在不少业务场景下,我们需要实现简单的请求缓存(即某个请求只发起一次请求),例如上传 Token 的获取、获取配置的接口等。

这些接口可以通过 Promise 实现简单的缓存并能够控制更新,而不需要另外引入缓存层。

示范代码

用七牛上传作例子,一般我们会把七牛上传封装为一个单独的 Upload 组件,外部只需要调用组件,而 token 的获取封装到组件内部实现。

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
//Upload.vue
let fetchToken = null;
export default {
data() {
return {
token: ''
};
},
methods: {
async upload() {
try {
// ...
}
catch(err) {
alert(err.message);
this.refreshToken();
}
},
refreshToken() {
fetchToken = null;
this.fetchToken();
},
fetchToken() {
if (!fetchToken) {
fetchToken = request.get('/api/qiniu/token');
}
try {
this.token = await fetchToken;
}
catch(err) {
console.error(err);
}
}
},
created() {
this.fetchToken();
}
};

上面是一个简单的缓存上传 token 的例子,并且会在上传失败时刷新 token。

与直接缓存 Token 的值比较,缓存请求有什么好处?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 缓存值的代码
export default {
methods: {
fetchToken() {
if (!fetchToken) {
fetchToken = await request.get('/api/qiniu/token');
}
try {
this.token = fetchToken;
}
catch(err) {
console.error(err);
}
}
}
}

一个比较常见的 Upload 组件 的应用场景,在一个页面里同时使用多次该组件。

1
2
3
4
<template>
<div class="upload1"><upload /></div>
<div class="upload2"><upload /></div>
</template>

就上面的代码例子,如果使用缓存值的方法,那么页面一打开就会请求两次获取 Token 接口。

继续完善 Upload 组件

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
//Upload.vue
let fetchToken = null;
export default {
methods: {
async upload() {
try {
this.fetchToken();
const token = await fetchToken;
// ...
} catch (err) {
alert(err.message);
this.refreshToken();
}
},
refreshToken() {
fetchToken = null;
this.fetchToken();
},
fetchToken() {
if (!fetchToken) {
fetchToken = request.get('/api/qiniu/token');
}
}
},
created() {
this.fetchToken();
}
};

为了防止多个 Upload 组件 token 不同步问题,不再通过this.token保存 token,而是每次都等待 fetchToken resolved,保证获取到的 token 一定是最新的。

当然,这里还有很多需要优化,例如失败后的重试、判断是 401 失败才刷新 token、设置错误时间、定时刷新等等,但总体思路就是上面代码所展示的内容。

另外再介绍一个经典应用场景

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
const fetchConfig = (() => {
let configRequest = null;
return () => {
if (!configRequest) {
configRequest = Promise.all([services.customer.config1, services.customer.config2])
.then(([data1, data2]) => {
return { data1, data2 };
})
.catch(err => {
configRequest = null;
return Promise.reject(err);
});
}
return configRequest;
};
})();

export default {
async beforeRouteEnter(to, from, next) {
try {
// 配置信息仅需要成功请求一次
const [data, config] = await Promise.all([services.customer.getInfo(), fetchConfig()]);
next(vm => {
vm.data = data;
vm.config = config;
vm.init();
};
} catch (err) {
next(err);
}
}
};

好的业务组件设计

一个好的业务组件必然是逻辑清晰以及方便修改维护。

下面以 Vue 为例子进行进行概念上的简单说明。

一些涉及到的概念

  1. 数据驱动 UI
  2. 单向数据流
  3. 有限状态机

模板

模板应保证逻辑清晰,业务复杂的部分可拆分成独立的业务组件又或者通过 computed 组装数据关系。

不应该在模板写逻辑语句,仅使用简单的条件判断以及方法调用或表达式。

状态的设计(data、computed)

状态分全局状态以及本地状态,全局状态就是 sotre(Vuex 或者自己另外定义的 Vue 对象),本地状态包括数据(data)以及根据数据响应的状态(computed)。

需要根据 data、store 或者 其他 computed 响应的同步状态都属于 computed,computed 必须是同步数据,computed 内部禁止任何异步操作。

所有需要异步获取以及无法由其他数据响应变化的数据都是 data。

有时候可以把 computed 作为一个不可写属性使用,返回一个常量或者其他想输入到模板的值。

原则是 data 的结构应清晰简单,数据之间的关系放在 computed。

数据的处理

服务器获取的数据不要在业务组件直接写请求,应通过 services 封装。

数据单位应保存一致(例如时间使用 13 位,金钱使用分),需要进行转换的数据按就近原则进行处理:如果是服务器数据在 services 进行转换,如果是用户输入的数据,通过 computed 进行 get/set 处理。

发送到服务器的数据也是一样,应在 services 里面处理数据的单位以及一些简单的判断。

保证代码的可维护性

  1. 代码、数据逻辑之间的关系应保持简单与一致,不应存在多种不同的业务关系模型。
  2. 单向数据流。
  3. 数据处理以及操作分开。
  4. 交互状态复杂的业务组件应用有限状态机以及 computed。

几个CSS技巧的分享

创建剪切动画

对于剪切动画,使用clip-path代替width/height,避免DOM重排导致性能过低。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.animate {
width: 200px;
height: 200px;
background: #000;
animation: 1s clip;
}
@keyframes clip {
0% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 100% 100% 0);
}
}

clip-path也能用来进行其他规则/不规则图形的剪切

1
2
3
4
5
.clip {
clip-path: polygon(0 100%, 50% 0, 100% 100%, 0 30%, 100% 30%); /* 多边形 */
clip-path: circle(30px at 35px 35px); /* 圆形 */
clip-path: ellipse(30px 25px at 35px 35px); /* 椭圆 */
}

优化动画性能

除了使用transform3d开启gpu加速,还可以使用will-change强制gpu加速优化动画性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.animate {
width: 200px;
height: 200px;
background: #000;
animation: 1s clip;
will-change: clip-path;
}
@keyframes clip {
0% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 100% 100% 0);
}
}

实现长宽比

使用padding模拟,然后子元素使用绝对定位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 1:1 */
.container {
width: 200px;
}
.container:after {
display: block;
content: ' ';
padding-top: 100%;
}

/* 16:9 */
.container {
width: 200px;
}
.container:after {
display: block;
content: ' ';
padding-top: calc(100% * 9 / 16);
}

垂直居中

我们常用的方式:

  • dislay: inline-block
  • top: 50% + transform: tranlsateY(-50%)
  • display: flex

其余还有padding上下撑高display: tableposition + margin: auto绝对定位 + margin等等,这些属于不常用、特殊场景才能用、CSS3之前的hack方式,CSS3之后就不必使用这些来实现垂直居中,就不多说了。

其中display: flex属于万金油,大多数场景可以直接用它,但还是有些特殊的场景不能用:

  1. 子元素需要文字截断,为了兼容4.X的Android浏览器,必须使用其他方式(一般是transform)
  2. 子元素需要多行布局,4.x的Android不支持flex-wrap,不能多行布局

使用node.js构建命令行工具

工具说明

  • inquirer.js:一个封装了常用命令行交互的node.js模块,通过该模块可以很方便地构建一个新的命令行应用。

  • shell.js:跨平台的unix shell命令模块。

  • Node版本:由于inquirer.js的异步方法默认返回Promise,建议使用node.js>=8。

目标

工作中有大量项目上线前最后一步需要执行测试、编译、更新版本号、提交,甚至执行的命令都是一样,在这里我们通过命令行工具将这些步骤一键自动化,同时进行预检查,防止错漏。

准备

  1. 创建一个新的Node.js项目。
  2. 创建文件bin/my-cli.js,node.js项目通常会把cli入口放在bin目录下,其他模块放在lib目录下。
  3. 在bin/my-cli.js文件头部添加#!/usr/bin/env node
  4. 添加"bin": {"my-cli": "./bin/my-cli.js"},到package.json,声明我们要使用的命令。
  5. 项目根目录下执行npm link,创建一个全局命令my-cli

稍微修改下my-cli.js,添加代码console.log("I am a cli tool!"),然后打开控制台运行my-cli命令,如果看到控制台输出I am a cli tool!就表示成功。

安装依赖

首先安装主要依赖的两个模块(关于这两个模块的使用请参考官方文档)

npm install inquirer shelljs

构建发布流程自动化

接下来首先实现测试、更新版本号、构建、自动提交发布的自动化

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
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));

const { version } = await inquirer.prompt([
{
type: 'list',
name: 'version',
message: '版本号更新方式:',
choices: [
{
name: `v${semver.inc(pkg.version, 'patch')}: Fix Bugs / Patch`,
value: 'patch'
},
{
name: `v${semver.inc(pkg.version, 'minor')}: Release New Version`,
value: 'minor'
},
]
}
]);
// 拉取最新版本
shelljs.exec('git pull');
// 运行测试
shelljs.exec('npm test');
//通过npm version更新版本号,但不自动添加git tag,而是在构建完成后由cli工具添加
shelljs.exec(`npm version ${version} --no-git-tag-version`);
// 构建
shelljs.exec('npm run build');
// 提交发布代码
const nextVersion = semver.inc(pkg.version, version);
shelljs.exec('git add . -A');
shelljs.exec(`git commit -m "build: v${nextVersion}"`)
shelljs.exec(`git tag -a v${nextVersion} -m "build: ${nextVersion}"`);
shelljs.exec("git push")
shelljs.exec("git push --tags");

添加新功能:配置检查

接下来给my-cli添加一个功能:

当检查到package.json的my-cli对象的check-baidu-id属性为true时,检查项目的config.json是否存在baidu-id属性

1
2
3
4
5
6
7
8
9
10
11
12
if (pkg['my-cli'] && pkg['my-cli']['check-baidu-id']) {
const configPath = path.join(process.cwd(), 'config.json');
if (!fs.existsSync(configPath)) {
shelljs.echo('找不到config.json');
shelljs.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (!config['baidu-id']) {
shelljs.echo('config.json缺少属性[baidu-id]');
shelljs.exit(1);
}
}

最后一步

这样一个简单的cli程序就实现完毕了,它自动化了构建发布流程,构建发布之前还进行了配置检查。

在实际项目中,为了提高程序的稳定性,还需要添加检查当前项目是否存在package.json,防止json解析出错、执行前确认等功能,具体见示例代码。

示例代码

地址:https://github.com/Aturan/node-cli-example

结语

虽然上述功能使用shell也可以实现,但代码编写就没那么方便快速,而且一旦碰到更复杂的问题,用shell实现就很麻烦,维护也是一个问题。

PS. 其实也可以用python,对于Ubuntu,系统自带Python是一个优势,在服务器不需要安装环境就可以直接使用,再加上Python也有Inquirer模块。

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×