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小時內與您取得聯系。