首页
技术分享
实用工具 发布文章 新浪微博 Github

在过去一年里我所在的前端小组主要负责快应用项目的开发与维护,随着产品不断完善、业务复杂度提升以及对接场景的多样化,前端代码的管理和维护成为我们团队的核心挑战之一。本文总结了汽车之家快应用在开发过程中遇到一些问题以及思考。

先介绍下开发环境

  • macOS 10.14.2
  • node v10.15.0
  • hap-toolkit 0.2.1
  • 编辑器 VSCode

关于接口封装

在项目中,针对接口的高频调用,需要封装高效且易用的公共方法,进而很大程度上提升代码规范质量及编码效率。封装应该解决的问题:

  • async await 支持
  • 易于配置扩展
  • 易于管理,方便调用
  • 统一错误处理

先看一段接口配置文件

在配置文件 api.js 中通过调用 reqMethod 方法构造接口函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// api.js
import req from './reqMethod.js'; // 封装接口方法

const baseUrl = 'https://www.域名1.com'; // 配置默认域名
const reqMethod = function () {
let arg = Array.from(arguments);
arg.splice(3, 0, baseUrl);
return req.apply(null, arg);
};

export const getBrandmenus = params => reqMethod('GET', `/api/path`, params);
export const editNickname = params => reqMethod('GET', '/api/path', params);
export const addFavouriteCar = params => reqMethod('GET', '/api/path', params);
export const delFavouriteCar = params => reqMethod('GET', '/api/path', params);

因为 const 特性保证了 API 接口名称的唯一性(多人开发不会出现命名冲突),并保证了接口配置集中在 api.js 文件中方便统一管理维护。

将接口配置挂载到全局对象上

1
2
3
4
// app.ux
const injectRef = Object.getPrototypeOf(global) || global;
import * as api from './api.js';
injectRef.API = api;

调用示例

在页面中可以直接使用 asycn/await 方式调用全局 API 方法获取接口数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default {
async getDataList() {
try {
const res = await API.getBrandmenus({
sessionid: deviceInfo.deviceId
});
this.list = res.result.map(item => {
item.date = UTILS.Formate(item.date, 'YYYY-MM-DD');
return item;
});
} catch (error) {
console.log(error);
}
}
}

reqMethod 实现:

reqMethod(method, url, params, baseUrl, stateDetection, showPrompt)

参数 说明 类型 可选值 默认值 可选性
method 请求类型 String GET / POST required
url 请求地址 String required
params 请求参数 Object {} optional
baseUrl 基础路径配置,最终请求地址为 baseUrl + url String - optional
stateDetection 返回状态检测 Boolean true / false true optional
showPrompt 是否弹窗提示错误状态 Boolean true / false true optional
  • 增加 基础路径 参数,可支持多域名配置。(注:reqMethod 方法入参是完整 URL 时会覆盖默认域名配置)

  • 返回状态检测: 默认:是,业务规定 返回数据中 returncode = 0 为正常请求。

  • 是否弹窗提示: 默认:是,404、500、超时等是否弹窗提示。

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
// reqMethod.js
import fetch from '@system.fetch';
import prompt from '@system.prompt';

function reqMethod(method, url, params = {}, baseUrl, stateDetection = true, showPrompt = true) {
fixXiaomiParamsBug(params);
url = /^http/.test(url) ? url : baseUrl + url; // 扩展支持多域名配置
params = sign(params);
return request(method, url, params, stateDetection, showPrompt);
}

function request(method, url, params, stateDetection = true, showPrompt = true) {
return new Promise((resolve, reject) => {
fetch.fetch({
url: url,
data: params,
method: method,
success: (res) => {
try {
if (res.code !== 200) {
prompt.showToast({
message: '网络错误'
});
reject(res);
return;
}

const data = JSON.parse(res.data);
if (data.returncode === 0) {
resolve(data);
return;
} else {
if (!stateDetection) {
resolve(data);
return;
}
if (showPrompt) {
prompt.showToast({
message: data.message
});
}
reject(res);
}
} catch (error) {
reject(res);
}
},
fail: function (res, code) {
prompt.showToast({
message: '网络错误'
});
reject(res);
}
});
});
}

// 小米 1030 版本bug 小数点后超过3位的数字会被截取,解决方法转换成字符串传递
function fixXiaomiParamsBug(params) {
if (typeof params === 'object') {
for (let key in params) {
if (typeof params[key] === 'number') {
const x = String(params[key]).indexOf('.') + 1; //小数点的位置
const y = String(params[key]).length - x; //小数的位数
if (y > 3) params[key] = String(params[key]);
}
}
}
}

function sign(obj) {
// 加密签名方法
}

export default reqMethod;

sign 方法为签名方法,fixXiaomiParamsBug 修复 小米 1030 版本bug,小数点后超过 3 位的数字会被截取,解决方法为转换成字符串传递。

缩小快应用rpk包的体积

因为快应用对 rpk 有 1M 尺寸的限制,我们的业务在 3.0 初期版本时一度达到 900K 的尺寸,缩小 rpk 尺寸成为我们的首要任务。

除了压缩图片,适量地使用网络图片,提取公共组件和方法外,我们还发现:在快应用的模板文件中,如果多个页面通过 import 方式引入相同公共 js 文件,最后这个文件会被多次打包到 rpk 文件中,也就是说构建工具不会提取页面之间的重复引入,在公共模块使用频率较高的情况下会大幅增加包的体积。

解决方法:

将公共方法挂载到全局作用域上,模板中直接调用全局方法。最终打包的结果中只包含一份公共 js 的引入。

入口文件 app.ux

我们将 utils 文件夹下的方法挂在到全局 UTILS 下,对于高频使用的方法比如 API 方法可提取出来单独挂载,缩短调用路径。

{2,6}
1
2
3
4
5
6
7
// app.ux
const injectRef = Object.getPrototypeOf(global) || global; // 获取全局对象

import * as Utils from './utils/index.js'; // 引入公共方法
const { api } = Utils;
injectRef.UTILS = Utils; // 挂载到全局对象
injectRef.API = api; // API 方法是对 fetch 方法的封装含有 sign 和 token

在业务代码中的调用方式,如:index.ux

在模板中可直接通过 API.getBrandmenus 获取接口数据, UTILS.Formate 方法对日期做格式化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// index.ux
export default {
async getDataList() {
try {
const res = await API.getBrandmenus({ // 直接调用全局 API 方法获取接口数据。
sessionid: deviceInfo.deviceId
});
this.list = res.result.map(item => {
item.date = UTILS.Formate(item.date, 'YYYY-MM-DD'); // 调用全局公共方法 UTILS 下的 Formate 格式化时间
return item;
});
} catch (error) {
console.log(error);
}
}
}

callback 转换成 Promise 模式

在快应用中很多系统能力 API 都是以 callback 形式提供。比如获取地理位置API:

1
2
3
4
5
6
7
8
9
10
11
12
geolocation.getLocation({
success: function(data) {
console.log(
`handling success: longitude = ${data.longitude}, latitude = ${
data.latitude
}`

)
},
fail: function(data, code) {
console.log(`handling fail, code = ${code}`)
}
})

在业务中如果使用 callback 形式很容易写出回调地狱并且不利于代码整洁,我们可以通过一个简单的方法将 callback 形式的 API 转换成 Promise 模式的,这样业务中就可以使用 promise 或者 async/await 形式调用了。

在我们的业务中有一个 promiseAPI.js 的公共方法,负责将 callback 转换成 Promise。

{5}
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
// promiseAPI.js
import storage from '@system.storage';
import device from '@system.device';
import network from '@system.network';
import geolocation from '@system.geolocation';

const promiseFactory = (pointer, params = {}) => {
return new Promise((resolve, reject) => {
params = Object.assign({
success: (data) => { resolve(data); },
fail: (err, code) => { reject(err, code) }
}, params);
pointer(params);
});
};

// 获取设备信息。
export const getDeviceInfo = () => promiseFactory(device.getInfo);
// 获取设备Id。
export const getDeviceId = () => promiseFactory(device.getId, { type: ["device", "mac"] });
// 读取存储内容。
export const getStorage = (key) => promiseFactory(storage.get, { key });
// 修改存储内容。
export const setStorage = (key, value) => promiseFactory(storage.set, { key, value });
// 清空存储内容。
export const clearStorage = (key, value) => promiseFactory(storage.clear);
// 获取网络类型。
export const getNetworkType = () => promiseFactory(network.getType);
// 获取地理位置。
export const getLocation = () => promiseFactory(geolocation.getLocation, { timeout: 3000 });

业务代码中调用方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// index.ux
import * as promiseApi from './promiseAPI.js';

const getDeviceIdMethods = async () => {
try {
const data = await promiseApi.getDeviceId();
systemInfo = Object.assign({}, systemInfo, {
'deviceId': data.device,
'IMEI': data.device,
'macAddress': data.mac
});
} catch (error) {
console.log('获取设备ID失败');
}
};

tabs 优化

一个内容丰富的选项卡,通常会包含许多页签内容。 tabs 系统组件默认会直接加载所有页签内容,导致 JS 线程持续忙于渲染每个页签,无法响应用户点击事件等,降低用户体验,为此我们在官方给出的 demo 基础上做出了一些优化。官方DEMO

优化目标

  • 页签内容懒加载
  • 缓存:切换时渲染过的页签不再重复渲染,不再重复请求接口
  • 统计数据:可以分别统计每一个频道的访问次数和停留时长。

效果:

示例代码如下:

{52,55,59,62}
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
<import name="original-tab" src="./original.ux"></import>
<import name="recommend-tab" src="./recommend.ux"></import>
<import name="chejiahao-tab" src="./chejiahao.ux"></import>

<template>
<div class="container">
<tabs onchange="onChangeTabIndex" index="{{currentindex}}" class="tab">
<tab-bar class="tab-header" mode="scrollable">
<stack class="tab-header__item" for="{{tabHeadList}}" @click="clickTabBar($idx)">
<text class="tab-header__text {{currentindex == $idx ? 'tab-header__text--active' : ''}}">{{$item.title}}</text>
<div class="tab-header__line {{currentindex == $idx ? 'tab-header__line--active' : ''}}"></div>
</stack>
</tab-bar>
<tab-content class="tab-content">
<div class="tab-content-section">
<trend-tab page-show="{{tabHeadList[0].isShow}}"></trend-tab>
</div>
<div class="tab-content-section">
<recommend-tab page-show="{{tabHeadList[1].isShow}}"></recommend-tab>
</div>
<div class="tab-content-section">
<original-tab page-show="{{tabHeadList[2].isShow}}"></original-tab>
</div>
</tab-content>
</tabs>
</div>
</template>

<script>
export default {
data() {
return {
tabHeadList: [{
title: '热榜',
pv: 'hotlist',
isShow: false
},
{
title: '推荐',
pv: 'recommend',
isShow: false
},
{
title: '原创',
pv: 'original',
isShow: false
}
],
currentindex: -1
}
},
onShow() {
this.watchCurrentIndexChange(this.currentindex, null);
},
onHide() {
this.watchCurrentIndexChange(null, this.currentindex);
},
async onInit() {
this.$watch('currentindex', 'watchCurrentIndexChange');
this.currentindex = 1; // 可以根据业务 设置某一个tab为默认显示tab
},
watchCurrentIndexChange(newVal, oldVal) {
if (oldVal !== null && oldVal >= 0) { // 判断条件:排除首次点击是
this.tabHeadList.splice(oldVal, 1, Object.assign({}, this.tabHeadList[oldVal], {
isShow: false
}));
PV_TRACK.endTime(this.tabHeadList[oldVal].pv);
}
if (newVal !== null && oldVal >= 0) { // 排除离开页面时阻止触发
this.tabHeadList.splice(newVal, 1, Object.assign({}, this.tabHeadList[newVal], {
isShow: true
}));
PV_TRACK.startTime(this.tabHeadList[newVal].pv);
}
},
clickTabBar(index) {
this.currentindex = index
},
onChangeTabIndex(evt) {
this.currentindex = evt.index;
},
}
</script>

trend-tab 组件代码:

{22,24,25}
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
<template>
<div class="flex-coloum">
<list class="feed-list">
<list-item for="list" type="feedcard">
<text>{{$item.title}}</text>
</list-item>
</list>
</div>
</template>

<script>
export default {
props: [
'pageShow'
],
data() {
return {
list: []
}
},
onInit() {
this.$watch('pageShow', 'watchPageShowChange');
},
watchPageShowChange(newV, oldV) {
if (newV && !this.list.length) { // 组件监听到 `page-show` 参数为 `true` 时请求数据
this.getDataList('加载首屏');
}
},
async getDataList(model) {
// ... 请求数据
this.list = [1,2,3,4];
},
}
</script>

我们来分析下上面代码相比官方 demo 的调整。

  • 首先进入 tab 页后所有 tab-content 下的组件都会渲染,但并不会请求接口,因为每一个组件都没有数据相当于空模板,这一改动经过实测,效果与效率均有所提升。

  • 当 tab 组件切换或者被点击时 currentindex 的值发生改变触发监听器 watchCurrentIndexChange 执行,并修改相应 tab-content 下组件(这里用 trend-tab 组件举例)的参数 page-show

  • trend-tab 组件通过监听到 pageShow 参数触发 watchPageShowChange方法。

  • pageShowtrue 时请求数据,渲染列表。

  • 当用户切换到下一个频道时当前 trend-tab 组件的 pageShow 值为 false 还可以做一些业务操作(比如视频频道 inline 播放的 video 可以暂停)。

  • 当用户再次滑回显示 trend-tab 组件时 page-show 参数为 true,但不满足 !this.list.length 条件,所以不会再次请求接口。

1
2
3
4
5
6
onShow() {
this.watchCurrentIndexChange(this.currentindex, null);
},
onHide() {
this.watchCurrentIndexChange(null, this.currentindex);
}

在模板中 onShowonHide 直接调用 watchCurrentIndexChange 方法是出于上报 PV 的考虑,配合 watchCurrentIndexChange 方法内的判断条件过滤一些特殊情况,可以监听用户在任意一个频道的停留时长,以及在任一频道跳出时,通过触发页面的 onHide 事件进行统计上报的操作。

PV_TRACK 的设计与思考

PV_TRACK 是我们内部的针对快应用设计的一套统计 SDK,借鉴了轻粒子快应用统计的思路,再结合我们自身的特点。
统计 PV 时需要发送 2 个请求:进入页面时发送 开始时间,离开时发送 离开时间,用于统计在线时长。

startTime / endTime

startTime 用于进入页面时发送开始时间。

调用示例:

1
2
3
4
5
6
onShow() {
PV_TRACK.startTime('页面标识', this.auto_open_from);
}
onHide() {
PV_TRACK.endTime('页面标识', this.auto_open_from);
}
参数 说明 类型 可选值 默认值
page 页面标识 string
argv 业务数据扩展字段 String / JSON

page_show / page_hide

page_show v2.0 新增 startTime 语法糖,传参可省略 auto_open_from 参数,用于进入页面时发送开始时间,适用于一个页面对应一个 PV 签的场景。

相对应的,带有 tab 或一个页面因参数对应多个 PV 签的情况使用 startTimeendTime 编程的方式上报。

调用示例:

1
2
3
4
5
6
onShow() {
PV_TRACK.page_show(this, { uid: '业务扩展字段' });
}
onHide() {
PV_TRACK.page_show(this, { uid: '业务扩展字段' });
}
参数 说明 类型 可选值 默认值
this 当前页面 context Object
argv 业务数据扩展字段 JSON

V2 新增 全局调用方法 PV_TRACK,无需传递 auto_open_from 参数,方法会从 this 中获取。注:上报的 PV name 是当前页面的 router.name

click

用于发送点击事件统计。

调用示例:

1
PV_TRACK.click(page, event);
参数 说明 类型 可选值 默认值
page 页面标识 String
event 事件标识 String

以上是我们 PV 中暴露的一些基础 API,数据统计对一个项目的长期发展和持续性优化非常重要。

在我们的业务中有一些页面需要用到设备信息,但这些获取设备信息的方法多数都是异步的需要用户授权后才可获取,举个例子,之家快应用的车系综述页需要获取地理位置信息后,给出用户所在地的车源信息,此时该页面将会自行获取地理位置,但是 PV统计也获取了地理位置信息,导致程序中有两个不同位置的方法同时调用获取设备信息。

这暴露出 3 个问题:

  1. 获取设备信息这种昂贵的操作没有被缓存和复用。
  2. 在部分手机厂商手机上会提示 2 次需要用户同意的授权提示框,或者仅提示一个且只有提示的回调会被执行(导致丢失逻辑)。
  3. 之家快应用的很多落地页支持网页唤起,这时用户作为首次访问,需要获取的设备信息 和 PV 统计方法中需要获取的设备信息重叠。

解决方法:抽象获取设备信息公共方法,需要获取设备信息的需求依赖获取设备信息公共方法执行完毕后调用,解决共用数据保证调用顺序。

实现获取设备信息 从 PV 方法中剥离

  • getDeviceData 方法返回获取设备基础信息的 Promise (包括 device.getInfodevice.getIdnetwork.getTypegeolocation.getLocation)。

  • deviceData 用户存储 getDeviceData 方法执行后返回的数据。

  • PV 方法中建立了发送队列,在 pv.setInfo(res) 方法被调用前不会上报统计,待调用 pv.setInfo(res) 后 pv 方法会连同传入的设备信息按照顺序上报。

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
const { PV, getDeviceData } = Utils;
const pv = new PV();
injectRef.PV_TRACK = pv;
let deviceData = null;
let deviceCompleteArray = [];

export default {
onCreate() {
// 快应用启动统计
pv.send({
cate: 'clt',
event: 'kuai_app_launch_clt'
});
getDeviceData().then(res => {
deviceData = res;
console.log('################ 获取设备信息 #################');
pv.setInfo(res);
while (deviceCompleteArray.length) {
const resolve = deviceCompleteArray.shift();
resolve(res);
}
}).catch(err => {
console.log(err);
});
},
getDeviceInfo() {
return new Promise((resolve, reject) => {
if (!deviceData) {
deviceCompleteArray.push(resolve);
} else {
resolve(deviceData);
}
});
}
};

业务代码中实现等待设备信息逻辑:

1
2
3
4
5
6
7
8
async onInit() {
try {
await this.$app.$def.getDeviceInfo();
await this.getList();
} catch (error) {
console.log(error);
}
}
  • 业务代码中调用 this.$app.$def.getDeviceInfo() 返回一个 Promise,并插入 deviceCompleteArray 数组中。
  • 当获取所有设备信息时,会触发 deviceCompleteArray 数组中每一项的 resolve,实现阻塞代码的后续执行。
1
2
3
4
while (deviceCompleteArray.length) {
const resolve = deviceCompleteArray.shift();
resolve(res);
}

至此实现多入口调用集中依赖同一个获取方法的功能。

总结

上面总结的一些小方法和思路应用到项目中可以提升开发效率,在项目中我们遵循开发规范可以保证快应用项目的可维护性和扩展性,未来我们将会持续打磨和优化代码,并更多的输出一些我们在项目开发过程中的经验。