在过去一年里我所在的前端小组主要负责快应用项目的开发与维护,随着产品不断完善、业务复杂度提升以及对接场景的多样化,前端代码的管理和维护成为我们团队的核心挑战之一。本文总结了汽车之家快应用在开发过程中遇到一些问题以及思考。
先介绍下开发环境
- 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
| 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
| 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
| 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); } }); }); }
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
| const injectRef = Object.getPrototypeOf(global) || global;
import * as Utils from './utils/index.js'; const { api } = Utils; injectRef.UTILS = Utils; injectRef.API = api;
|
在业务代码中的调用方式,如:index.ux
在模板中可直接通过 API.getBrandmenus
获取接口数据, UTILS.Formate
方法对日期做格式化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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); } } }
|
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
| 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);
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
| 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; }, 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) { 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
方法。
当 pageShow
为 true
时请求数据,渲染列表。
当用户切换到下一个频道时当前 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); }
|
在模板中 onShow
和 onHide
直接调用 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 签的情况使用 startTime
和 endTime
编程的方式上报。
调用示例:
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 个问题:
- 获取设备信息这种昂贵的操作没有被缓存和复用。
- 在部分手机厂商手机上会提示 2 次需要用户同意的授权提示框,或者仅提示一个且只有提示的回调会被执行(导致丢失逻辑)。
- 之家快应用的很多落地页支持网页唤起,这时用户作为首次访问,需要获取的设备信息 和 PV 统计方法中需要获取的设备信息重叠。
解决方法:抽象获取设备信息公共方法,需要获取设备信息的需求依赖获取设备信息公共方法执行完毕后调用,解决共用数据保证调用顺序。
实现获取设备信息 从 PV 方法中剥离:
getDeviceData
方法返回获取设备基础信息的 Promise (包括 device.getInfo
、device.getId
、 network.getType
、geolocation.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); }
|
至此实现多入口调用集中依赖同一个获取方法的功能。
总结
上面总结的一些小方法和思路应用到项目中可以提升开发效率,在项目中我们遵循开发规范可以保证快应用项目的可维护性和扩展性,未来我们将会持续打磨和优化代码,并更多的输出一些我们在项目开发过程中的经验。