Progress是一个轻量级的进度条组件,在Github上已经2.4万star数了,虽然这个组件已经好久没有更新了,最近一次更新是20年4月份,改了jQuery的版本,但是该组件的使用频率还是很高的。
$ npm install --save nprogress
$ yarn add nprogress
App.vue
<script lang="ts">
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import { useRouter } from "vue-router";
export default {
setup() {
const router=useRouter();
router.beforeEach((to, from, next)=> {
NProgress.start();
next();
});
router.afterEach(()=> {
NProgress.done();
});
},
};
</script>
main.ts
import 'nprogress/nprogress.css'
axios.ts
import NProgress from 'nprogress'
axios.interceptors.request.use(
function (config: any) {
// 在发送请求之前做某件事
NProgress.start() //开始
return config
},
(error: { data: { error: { message: any } } })=> {
return Promise.reject(error.data.error.message)
}
)
axios.interceptors.response.use(
function (config: any) {
aspShow.value=false
NProgress.done() //结束
if (config.status===200 || config.status===204) {
return Promise.resolve(config)
}
return Promise.reject(config)
},
function (error: any) {
aspShow.value=false
NProgress.done() //结束
if (error.response.status) {
switch (error.response.status) {
// 401: 未登录
// 未登录则跳转登录页面,并携带当前页面的路径
// 在登录成功后返回当前页面,这一步需要在登录页操作。
case 401:
router.replace({
path: '/login',
query: {}
})
break
// 其他错误,直接抛出错误提示
default:
message.error(`${error.response.status}:${error.response.statusText}`)
}
NProgress.done()////结束
return Promise.reject(error)
}
}
)
import NProgress from 'nprogress'
// 页面切换之前取消上一个路由中未完成的请求
router.beforeEach((_to: any, _from: any, next: ()=> void)=> {
NProgress.start()
next()
})
router.afterEach(()=> {
// 进度条
NProgress.done()
})
//全局进度条的配置
NProgress.configure({
easing: 'ease', // 动画方式
speed: 1000, // 递增进度条的速度
showSpinner: false, // 是否显示加载ico
trickleSpeed: 200, // 自动递增间隔
minimum: 0.3, // 更改启动时使用的最小百分比
parent: 'body', //指定进度条的父容器
})
TS的项目,还需要安装其类型声明文件,命令如下:
npm i @types/nprogress -D
或者如下声明
declare module 'nprogress';
代前端应用逻辑日趋复杂,在平衡性能和数据实效性方面,前端也逐渐开始承担一些责任。
最近我们在项目中就碰到了这样一个场景。我们的项目只是一个非常传统的数据看板类项目,用户打开页面,通过调用API读取数据,渲染页面,完成任务。
但是这个项目有几个特点,我需要特别说明一下:
于是乎,一个本来看似简单的项目,就逐渐变成性能优化的急先锋。
最开始我们的策略非常简单,就是给把数据存储到indexedDB中,并设置一个过期时间。整体流程如下:
关于indexedDB的初始版本代码大致包含如下几个部分
const TABLE_NAME='xhr_cache'
export const getDBConnection=()=> {
const request=window.indexedDB.open(DB_NAME)
request.onupgradeneeded=function (event) {
const db=event.target.result
if (!db.objectStoreNames.contains(TABLE_NAME)) {
const table=db.createObjectStore(TABLE_NAME, {
keyPath: 'uid'
})
table.createIndex('uid', 'uid', { unique: true })
}
}
return new Promise((resolve, reject)=> {
request.onsuccess=function () {
resolve(request.result)
}
})
}
const dbConn=await getDBConnection()
import MD5 from 'crypto-js/md5'
getKey(config) {
const hashedKey=MD5( `${config.url}_${JSON.stringify(config.payload)}` ).toString()
return hashedKey
}
根据一个请求的URL + payload,我们可以识别一个唯一的请求。
对其值进行md5哈希之后得到一个唯一的键,代表一个请求,并将其作为存储在indexedDB中的主键。
/* 写入API response数据 */
const response={
uid: key,
content: axiosRequest.response.data,
created_at: new Date().getTime(),
expired: expired_at
}
const addResponseToIndexedDB=function (response) {
dbConn
.transaction([TABLE_NAME], 'readwrite')
.objectStore(TABLE_NAME)
.put(response)
}
/* 读取缓存 */
const request=dbConn
.transaction([TABLE_NAME], 'readonly')
.objectStore(TABLE_NAME)
.index('uid')
.get(key)
const result=await new Promise((resolve=> {
request.onsuccess=function () {
resolve(request.result)
}
})
虽然indexedDB可以存储远大于localStorage的数据,但我们也不希望indexedDB随着用户不断访问存储大量冗余数据。因此,会在每次应用加载的开始对于过期数据统一进行一次清理:
const isExpireded=(result, expired=60000)=> {
const now=new Date().getTime()
const created_at=result.created_at
return !created_at || (now - created_at > expired) ? true : false
}
const delCacheByExpireded=()=> {
var request=dbConn
.transaction([TABLE_NAME], 'readwrite')
.objectStore(TABLE_NAME)
.openCursor();
request.onsuccess=function (e) {
var cursor=e.target.result;
if (cursor && cursor !==null) {
const key=cursor.key
const expireded=isExpireded(cursor.value)
if (expireded) {
that.delCacheByKey(key)
}
cursor.continue();
}
}
}
有了上述这些能力,我们就可以在自己的Axios拦截器中使用indexedDB的缓存数据。
...
const CACHED_URL_REGEX=[
'somepath/data/version/123',
'user/info/name',
...
]
Axios.interceptors.request.use(async function (config) {
const r=new Regex(`${CACHED_URL_REGEX.join('|')}$`)
if (r.test(config.url)) {
const key=getKey()
const request=dbConn
.transaction([TABLE_NAME], 'readonly')
.objectStore(TABLE_NAME)
.index('uid')
.get(key)
const result=await new Promise((resolve)=> {
request.onsuccess=function (event) {
resolve(request.result)
}
request.onerror=function (event) {
resolve()
}
})
if (result && isExpired(result)) {
config.adapter=function (config) {
return new Promise((resolve)=> {
const res={
data: result.content,
status: 200,
statusText: 'OK',
headers: { 'content-type': 'text/plain; charset=utf-8' },
config,
request: {}
}
return resolve(res)
})
}
}
return config
}
})
...
可以看到,我们在request 拦截器中进行了以下操作:
注意下面这段代码
const result=await new Promise((resolve)=> {
request.onsuccess=function (event) {
resolve(request.result)
}
request.onerror=function (event) {
resolve()
}
})
这里的代码使用了await,以此等待indexedDB的异步查询结束。异步查询结束之后才能根据其结果判断是否要直接返回还是继续axios默认行为。
Axios.interceptors.response.use(function (response) {
...
let success=response.status < 400
const key=getKey(response.config)
dbConn
.transaction([TABLE_NAME], 'readwrite')
.objectStore(TABLE_NAME)
.put({
uid: key,
content: response.data,
created_at: new Date().getTime()
})
...
return response
}
在response拦截器中,无需等待indexedDB的异步写入过程,因此不需要使用await。
截至目前,基于Axios + indexedDB的缓存方案已经大体可用,当然以上代码并不完全,如需使用还得根据自己的项目做一些修改。
上述设计方案实现之后,我们发现在读取indexedDB的时候有时会很快,但有些时候却非常慢。根据观测,在某些手机上,读取一小段不超过100K的数据,有时候需要400ms以上。根据经验这是无法理解的。
进一步调查发现,在主线程繁忙时,初始化indexedDB事务到indexedDB返回数据就会比较慢;反之,在主线程空闲时,经过测量,同一过程耗时大约在5ms以下,这才在数据库读取速度的正常认知范围之内。
但众所周知,基于react + antd的前端应用,DOM结构复杂,主线程在渲染时会非常繁忙,这就造成了我们观察到的读取indexedDB耗时较长。
说到这里,还记得上边在Axios Request Interceptor中需要先等待读取到indexedDB数据,根据结果判断是否要请求API的代码吗?
于是尴尬的一幕出现了。假设一次请求叠加了如下因素:
本来应该提高性能的手段,在这种条件下不仅没有节省耗时,反而会增加耗时。更进一步,在我们自己的调试过程中,发现对于某些低级手机机型,渲染初始页面时CPU本就繁忙,此时即便从本地缓存获取到的数据没有过期,耗时也可能高达无法理解的一秒左右。这种结果表示,此场景下的缓存方式显然是得不偿失的。
下表为我们针对alpha版本缓存方案在Chrome浏览器上的性能做出的统计。其中每一列分别表示在React进行初始化渲染阶段的indexedDB请求耗时。
API 1 | API 2 | API 3 |
180ms | 82ms | 51ms |
如果将Chrome的CPU throttle调低到1/4的效率,数据则更加无法理解
API 1 | API 2 | API 3 |
956ms | 183ms | 253ms |
与之对应的,在CPU空闲的时候,也就是初始化渲染完毕之后的indexedDB请求耗时分别为:
API 1 | API 2 | API 3 |
13ms | 12ms | 13ms |
由于上一节的结论,这样的缓存策略显然无法达到本来的目的。因此我们又设计了几个方案进行对比:
其中dump数据到内存中进行缓存取用的三种细分,我们分别命名为:
策略的对比如下:
方案 | 对比 |
ReactAPP初始化MemCache | 为了避免API调用在dump数据到内存完成之前,需要等待初始化MemCache之后再调用react app的render方法 由于这种顺序执行,会牺牲一部分APP渲染的耗时 |
HTML加载时初始化MemCache | HTML加载时初始化,CPU相对比较空闲,进行dump操作效率较高,但也取决于当时是否正在对加载的JS资源进行script evaluate 如浏览器正在进行脚本文件的执行和编译,dump时长仍然比较长 |
webWorker初始化MemCache | 利用webworker在主线程之外进行indexedDB的dump操作,可以避免主渲染线程繁忙与否对于indexedDB读取耗时的影响 但初始化webworker本身仍然需要额外耗时 |
由于以上方案相对于上一节中单次indexedDB调用增加了前置dump数据到内存的操作耗时,所以我们这次对测量方案增加了TOTAL一栏,表示从html页面载入到react app完全渲染完毕的耗时。
下表中包含共5种方案的性能对比:alpha版本,serviceWorker方案,以及MemCache的三种方案。每种方案测试十次,取四个阶段以及TOTAL耗时的平均值:
评测数据见下表(细字体的部分为正常CPU负载情况下,粗体字的部分表示CPU效率降级为1/4时的情况):
方案 | 静态资源 | API 1 | API 2 | API 3 | TOTAL | 静态资源 | API 1 | API 2 | API 3 | TOTAL |
alpha版本 | 525.4 | 180.4 | 82.2 | 51.3 | 1544.7 | 2500.5 | 956.5 | 183.6 | 253 | 6562.5 |
service worker方案 | 827.5 | 60.9 | 208.4 | 351.6 | 1777.2 | 4053.7 | 138.4 | 991.4 | 546.3 | 7357.3 |
ReactAPP初始化MemCache | 1042.9 | 1.8 | 26 | 9.8 | 1659.5 | 4512.9 | 7.5 | 31.3 | 35.5 | 6410.1 |
HTML加载时初始化MemCache | 1021 | 2.4 | 10.3 | 9.7 | 1564.7 | 5273.1 | 7.2 | 31.6 | 34.5 | 7178.3 |
webWorker初始化MemCache | 797.9 | 0.9 | 8.9 | 7.1 | 1299.6 | 3853.7 | 5.6 | 31.2 | 45 | 5975 |
根据数据显示,对于我们的场景来说,使用webworker启动MemCache的方案是最经济的。方案设计如下图所示:
由于dump数据的操作基本一致,因此WebWorker脚本和APP内部用于dump数据的lib文件内容基本一致。大体代码可见:
const DB_NAME='db_name'
const TABLE_NAME='xhr_cache'
export const getDBConnection=()=> {
const request=window.indexedDB.open(DB_NAME)
request.onupgradeneeded=function (event) {
const db=event.target.result
if (!db.objectStoreNames.contains(TABLE_NAME)) {
const table=db.createObjectStore(TABLE_NAME, {
keyPath: 'uid'
})
table.createIndex('uid', 'uid', { unique: true })
}
}
return new Promise((resolve, reject)=> {
let completed=false
request.onsuccess=function () {
if (completed===false) {
completed=true
resolve(request.result)
} else {
request.result.close()
}
}
request.onerror=function (err) {
if (completed===false) {
completed=true
reject(err)
}
}
setTimeout(()=> {
if (completed===false) {
completed=true
reject(new Error('getDBConnection timeout after app rendered'))
}
}, 1000)
})
}
export const dump2Memory=async (db)=> {
const transaction=db.transaction([TABLE_NAME], 'readonly')
const table=transaction.objectStore(TABLE_NAME)
const request=table.index('uid').getAll()
const records=await new Promise((resolve, reject)=> {
request.onsuccess=function () {
resolve(request.result)
}
request.onerror=function () {
console.log('dump2Memory error')
resolve()
}
})
return records
}
export const delCacheByExpireded=async (records)=> {
const validRecords=records.filter((record)=> !getExpireded(record))
const objectStore=DBCache.conn
.transaction(['xhr_cache'], 'readwrite')
.objectStore('xhr_cache')
const clearRequest=objectStore.clear()
clearRequest.onsuccess=function () {
validRecords.forEach((record)=> {
objectStore.add(record)
})
}
return validRecords
}
在这里定义了三个函数
注意在获取indexedDB链接的函数中,相对alpha版本增加了容错处理。如果一个浏览器多个tab同时打开同一个indexedDB的链接,可能会导致后面打开的indexedDB链接被block住。因此在这里做了超时处理。
如果新的链接打开超时则不初始化内存缓存,作为降级处理方案。
于此同时,MemCache类也需要对这种降级做出兼容。
DBCache.conn=null
DBCache.memCache={
__memCache: null,
initialize: function (records) {
this.__memCache=new Map(records.map((record)=> [record.uid, record]))
},
get: function (key) {
const result=this.__memCache.get(key)
if (result) {
return cloneDeep(result)
} else {
return null
}
},
add: function (record) {
this.__memCache.set(record.uid, record)
}
}
DBCache.prepare=async function () {
try {
DBCache.conn=await getDBConnection()
let dbRecordList=[]
if (window.__db_cache_prepared_records__.length) {
dbRecordList=cloneDeep(window.__db_cache_prepared_records__)
} else {
console.time('dump')
dbRecordList=await dump2Memory(DBCache.conn)
console.timeEnd('dump')
}
const validRecords=await delCacheByExpireded(dbRecordList)
DBCache.memCache.initialize(validRecords || [])
} catch (err) {
DBCache.memCache.initialize([])
console.error(err)
}
}
DBCache.updateRecord=(record)=> {
if (DBCache.conn) {
DBCache.memCache.add(record)
DBCache.conn
.transaction(['xhr_cache'], 'readwrite')
.objectStore('xhr_cache')
.put(record)
}
}
请注意,DBCache对象的prepare静态方法中:
由于获取链接超时会抛出异常,因此在getDBConnection方法外围添加了try{}catch{}块。
如果获取DB连接发生异常,则会给MemCache初始化为空数组,这样Axios拦截器在调用DBCache.memCache.get方法时则会永远返回缓存未命中,于是所有Axios请求全部降级为API调用。
另外一个需要注意的点是,DBCache.memCache.get的方法实现中对于内存中的数据进行深拷贝的操作。原因在于,如果直接向react业务代码传递该内存块的引用,很显然业务代码会对该内存引用的对象进行修改。那么下次再使用命中的缓存时,就会因为缓存数据与API返回的数据结构不一致导致报错。
到现在为止,几乎所有必须模块的代码都已经实现了。整个流程只剩下最后一块砖:HTML里script标签内用于启动WebWorker以及WebWorker中通知主线程的代码。
<script>
window.__db_cache_prepared_records__=[]
if (window.Worker) {
console.time('dump in html')
const dbWorker=new Worker('./webworker.dump.prepare.js');
dbWorker.onmessage=function(e) {
if (e.data.eventName='onDBDump') {
if (window.__db_cache_prepared_records__.length===0)
window.__db_cache_prepared_records__=e.data.data
console.timeEnd('dump in html')
}
}
}
</script>
// other codes in dump script section.
// I'm not gonna repeat those. see it yourself please
...
if (indexedDB) {
console.time('dump2Memory')
getDBConnection().then(conn=> {
dump2Memory(conn).then(records=> {
console.timeEnd('dump2Memory')
postMessage({
eventName: 'onDBDump',
data: records
})
})
}).catch(err=> {
console.error(err)
})
}
截至目前,我们使用Axios + indexedDB + WebWorker实现的最高效的前端API缓存方案就到此为止了。
实话实说,现在还只是搭建了一个高效缓存的框架,至于各种适合不同应用场景的缓存策略还没有实现。
如果你有有意思的缓存场景或需要何种缓存策略,欢迎留言。
服务端
优势
*请认真填写需求信息,我们会在24小时内与您取得联系。