問題描述
- 面試官:後端一次性傳回10萬條資料給你,你如何處理?
- 我:歪嘴一笑,what the f**k!
問題考察點
看似無厘頭的問題,實際上考查候選人知識的廣度和深度,雖然在工作中這種情況很少遇到...
- 考察前端如何處理大量資料
- 考察候選人對于大量資料的性能優化
- 考察候選人處理問題的思考方式(關于這一點,文末會說到,大家繼續閱讀)
- ......
文末會提供完整代碼,供大家更好的了解
使用express建立一個十萬條資料的接口
若是道友對express相關不太熟悉的話,有空可以看看筆者的這一篇全棧文章(還有完整代碼哦):《Vue+Express+Mysql全棧項目之增删改查、分頁排序導出表格功能》
js複制代碼route.get("/bigData", (req, res) => {
res.header('Access-Control-Allow-Origin', '*'); // 允許跨域
let arr = [] // 定義數組,存放十萬條資料
for (let i = 0; i < 100000; i++) { // 循環添加十萬條資料
arr.push({
id: i + 1,
name: '名字' + (i + 1),
value: i + 1,
})
}
res.send({ code: 0, msg: '成功', data: arr }) // 将十萬條資料傳回之
})
點選按鈕,發請求,擷取資料,渲染到表格上
html結構如下:
html複制代碼<el-button :loading="loading" @click="plan">點選請求加載</el-button>
<el-table :data="arr">
<el-table-column type="index" label="序" />
<el-table-column prop="id" label="ID" />
<el-table-column prop="name" label="名字" />
<el-table-column prop="value" label="對應值" />
</el-table>
data() {
return {
arr: [],
loading: false,
};
},
async plan() {
// 發請求,拿資料,指派給arr
}
方案一 直接渲染所有資料
如果請求到10萬條資料直接渲染,頁面會卡死的,很顯然,這種方式是不可取的
js複制代碼 async plan() {
this.loading = true;
const res = await axios.get("http://ashuai.work:10000/bigData");
this.arr = res.data.data;
this.loading = false;
}
方案二 使用定時器分組分批分堆依次渲染(定時加載、分堆思想)
- 正常來說,十萬條資料請求,需要2秒到10秒之間(有可能更長,取決于資料具體内容)
- 而這種方式就是,前端請求到10萬條資料以後,先不着急渲染,先将10萬條資料分堆分批次
- 比如一堆存放10條資料,那麼十萬條資料就有一萬堆
- 使用定時器,一次渲染一堆,渲染一萬次即可
- 這樣做的話,頁面就不會卡死了
使用者所看到的效果圖是如下
效果圖
分組分批分堆函數
- 我們先寫一個函數,用于将10萬條資料進行分堆
- 所謂的分堆其實思想就是一次截取一定長度的資料
- 比如一次截取10條資料,頭一次截取0~9,第二次截取10~19等固定長度的截取
- 舉例原來的資料是:[1,2,3,4,5,6,7]
- 假設我們分堆以後,一堆分3個,那麼得到的結果就是二維數組了
- 即:[ [1,2,3], [4,5,6], [7]]
- 然後就周遊這個二維數組,得到每一項的資料,即為每一堆的資料
- 進而使用定時器一點點、一堆堆指派渲染即可
分組分批分堆函數(一堆分10個)
js複制代碼function averageFn(arr) {
let i = 0; // 1. 從第0個開始截取
let result = []; // 2. 定義結果,結果是二維數組
while (i < arr.length) { // 6. 當索引等于或者大于總長度時,即截取完畢
// 3. 從原始數組的第一項開始周遊
result.push(arr.slice(i, i + 10)); // 4. 在原有十萬條資料上,一次截取10個用于分堆
i = i + 10; // 5. 這10條資料截取完,再截取下十條資料,以此類推
}
return result; // 7. 最後把結果丢出去即可
}
建立定時器去依次指派渲染
比如我們每隔一秒鐘去指派渲染一次
js複制代碼 async plan() {
this.loading = true;
const res = await axios.get("http://ashuai.work:10000/bigData");
this.loading = false;
let twoDArr = averageFn(res.data.data);
for (let i = 0; i < twoDArr.length; i++) {
// 相當于在很短的時間内建立許多個定時任務去處理
setTimeout(() => {
this.arr = [...this.arr, ...twoDArr[i]]; // 指派渲染
}, 1000 * i); // 17 * i // 注意設定的時間間隔... 17 = 1000 / 60
}
},
這種方式,相當于在很短的時間内建立許多個定時任務去處理,定時任務太多了,也耗費資源啊。
實際上,這種方式就有了大資料量分頁的思想
方案三 使用requestAnimationFrame替代定時器去做渲染
關于requestAnimationFrame比定時器的優點,道友們可以看筆者的這篇文章:《性能優化之通俗易懂學習requestAnimationFrame和使用場景舉例》
反正大家遇到定時器的時候,就可以考慮一下,是否可以使用請求動畫幀進行優化執行渲染?
如果使用請求動畫幀的話,就要修改一下代碼寫法了,前面的不變化,plan方法中的寫法變一下即可,注意注釋:
js複制代碼async plan() {
this.loading = true;
const res = await axios.get("http://ashuai.work:10000/bigData");
this.loading = false;
// 1. 将大資料量分堆
let twoDArr = averageFn(res.data.data);
// 2. 定義一個函數,專門用來做指派渲染(使用二維數組中的每一項)
const use2DArrItem = (page) => {
// 4. 從第一項,取到最後一項
if (page > twoDArr.length - 1) {
console.log("每一項都擷取完了");
return;
}
// 5. 使用請求動畫幀的方式
requestAnimationFrame(() => {
// 6. 取出一項,就拼接一項(concat也行)
this.arr = [...this.arr, ...twoDArr[page]];
// 7. 這一項搞定,繼續下一項
page = page + 1;
// 8. 直至完畢(遞歸調用,注意結束條件)
use2DArrItem(page);
});
};
// 3. 從二維數組中的第一項,第一堆開始擷取并渲染(數組的第一項即索引為0)
use2DArrItem(0);
},
方案四 搭配分頁元件,前端進行分頁(每頁展示一堆,分堆思想)
這種方式,筆者曾經遇到過,當時的對應場景是資料量也就幾十條,後端直接把幾十條資料丢給前端,讓前端去分頁
後端不做分頁的原因是。他當時臨時有事情請假了,是以就前端去做分頁了。
- 資料量大的情況下,這種方式,也是一種解決方案
- 思路也是在所有資料的基礎上進行截取
- 簡要代碼如下:
js複制代碼getShowTableData() {
// 擷取截取開始索引
let begin = (this.pageIndex - 1) * this.pageSize;
// 擷取截取結束索引
let end = this.pageIndex * this.pageSize;
// 通過索引去截取,進而展示
this.showTableData = this.allTableData.slice(begin, end);
}
完整案例代碼,請看筆者的這篇文章:《後端一次性傳回所有的資料,讓前端截取展示做分頁》
實際上,這種大任務拆分成許多小任務,這種方式,做法,應用的思想就是分片的方式(時間),在别的場景,比如大檔案上傳的時候,也有這種思想,比如一個500MB的大檔案,拆分成50個小檔案,一個是10MB這樣...至于大檔案上傳的文章,那就等筆者有空了再寫呗...
方案五 表格滾動觸底加載(滾動到底,再加載一堆)
這裡重點就是我們需要去判斷,何時滾動條觸底。判斷方式主要有兩種
- scrollTop + clientHeight >= innerHeight
- 或
- new MutationObserver()去觀測
目前市面上主流的一些插件的原理,大緻是這兩種。
筆者舉例的這是,是使用的插件v-el-table-infinite-scroll,本質上這個插件是一個自定義指令。對應npm位址:www.npmjs.com/package/el-…
當然也有别的插件,如vue-scroller 等:一個意思,不贅述
注意,觸底加載也是要分堆的,将發請求擷取到的十萬條資料,進行分好堆,然後每觸底一次,就加載一堆即可
在el-table中使用el-table-infinite-scroll指令步驟
安裝,注意版本号(區分vue2和vue3)
cnpm install --save el-table-infinite-scroll@1.0.10
注冊使用指令插件
js複制代碼// 使用無限滾動插件
import elTableInfiniteScroll from 'el-table-infinite-scroll';
Vue.use(elTableInfiniteScroll);
因為是一個自定義指令,是以直接寫在el-table标簽上即可
js複制代碼<el-table
v-el-table-infinite-scroll="load"
:data="tableData"
>
<el-table-column prop="id" label="ID"></el-table-column>
<el-table-column prop="name" label="名字"></el-table-column>
</el-table>
async load() {
// 觸底加載,展示資料...
},
案例代碼
為了友善大家示範,這裡筆者直接附上一個案例代碼,注意看其中的步驟注釋即可
html複制代碼<template>
<div class="box">
<el-table
v-el-table-infinite-scroll="load"
height="600"
:data="tableData"
border
style="width: 80%"
v-loading="loading"
element-loading-text="資料量太大啦,客官稍後..."
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.5)"
:header-cell-style="{
height: '24px',
lineHeight: '24px',
color: '#606266',
background: '#F5F5F5',
fontWeight: 'bold',
}"
>
<el-table-column type="index" label="序"></el-table-column>
<el-table-column prop="id" label="ID"></el-table-column>
<el-table-column prop="name" label="名字"></el-table-column>
<el-table-column prop="value" label="對應值"></el-table-column>
</el-table>
</div>
</template>
<script>
// 分堆函數
function averageFn(arr) {
let i = 0;
let result = [];
while (i < arr.length) {
result.push(arr.slice(i, i + 10)); // 一次截取10個用于分堆
i = i + 10; // 這10個截取完,再準備截取下10個
}
return result;
}
import axios from "axios";
export default {
data() {
return {
allTableData: [], // 初始發請求擷取所有的資料
tableData: [], // 要展示的資料
loading: false
};
},
// 第一步,發請求,擷取大量資料,并轉成二維數組,分堆分組分塊存儲
async created() {
this.loading = true;
const res = await axios.get("http://ashuai.work:10000/bigData");
this.allTableData = averageFn(res.data.data); // 使用分堆函數,存放二維數組
// this.originalAllTableData = this.allTableData // 也可以存一份原始值,留作備用,都行的
this.loading = false;
// 第二步,操作完畢以後,執行觸底加載方法
this.load();
},
methods: {
// 初始會執行一次,當然也可以配置,使其不執行
async load() {
console.log("自動多次執行之,首次執行會根據高度去計算要執行幾次合适");
// 第五步,觸底加載相當于把二維數組的每一項取出來用,取完用完時return停止即可
if (this.allTableData.length == 0) {
console.log("沒資料啦");
return;
}
// 第三步,加載的時候,把二維數組的第一項取出來,拼接到要展示的表格資料中去
let arr = this.allTableData[0];
this.tableData = this.tableData.concat(arr);
// 第四步,拼接展示以後,再把二維數組的第一項的資料删除即可
this.allTableData.shift();
},
},
};
</script>
效果圖
方案六 使用無限加載/虛拟清單進行展示
什麼是虛拟清單?
- 所謂的虛拟清單實際上是前端障眼法的一種表現形式。
- 看到的好像所有的資料都渲染了,實際上隻渲染可視區域的部分罷了
- 有點像我們看電影,我們看的話,是在一塊電影螢幕上,一秒一秒的看(不停的放映)
- 但是實際上電影有倆小時,如果把兩個小時的電影都鋪開的話,那得需要多少塊電影螢幕呢?
- 同理,如果10萬條資料都渲染,那得需要多少dom節點元素呢?
- 是以我們隻給使用者看,他當下能看到的
- 如果使用者要快進或快退(下拉滾動條或者上拉滾動條)
- 再把對應的内容呈現在電影螢幕上(呈現在可視區域内)
- 這樣就實作了看着像是所有的dom元素每一條資料都有渲染的障眼法效果了
關于前端障眼法,在具體工作中,如果能夠巧妙使用,會大大提升我們的開發效率的
寫一個簡單的虛拟清單
效果圖
這裡筆者直接上代碼,大家複制粘貼即可使用,筆者寫了一些注釋,以便于大家了解。當然也可以去筆者的倉庫中去瞅瞅哦,GitHub倉庫在文末
代碼
html複制代碼<template>
<!-- 虛拟清單容器,類似“視窗”,視窗的高度取決于一次展示幾條資料
比如視窗隻能看到10條資料,一條40像素,10條400像素
故,視窗的高度為400像素,注意要開定位和滾動條 -->
<div
class="virtualListWrap"
ref="virtualListWrap"
@scroll="handleScroll"
:style="{ height: itemHeight * count + 'px' }"
>
<!-- 占位dom元素,其高度為所有的資料的總高度 -->
<div
class="placeholderDom"
:style="{ height: allListData.length * itemHeight + 'px' }"
></div>
<!-- 内容區,展示10條資料,注意其定位的top值是變化的 -->
<div class="contentList" :style="{ top: topVal }">
<!-- 每一條(項)資料 -->
<div
v-for="(item, index) in showListData"
:key="index"
class="itemClass"
:style="{ height: itemHeight + 'px' }"
>
{{ item.name }}
</div>
</div>
<!-- 加載中部分 -->
<div class="loadingBox" v-show="loading">
<i class="el-icon-loading"></i>
<span>loading...</span>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
data() {
return {
allListData: [], // 所有的資料,比如這個數組存放了十萬條資料
itemHeight: 40, // 每一條(項)的高度,比如40像素
count: 10, // 一屏展示幾條資料
start: 0, // 開始位置的索引
end: 10, // 結束位置的索引
topVal: 0, // 父元素滾動條滾動,更改子元素對應top定位的值,確定關聯
loading: false,
};
},
computed: {
// 從所有的資料allListData中截取需要展示的資料showListData
showListData: function () {
return this.allListData.slice(this.start, this.end);
},
},
async created() {
this.loading = true;
const res = await axios.get("http://ashuai.work:10000/bigData");
this.allListData = res.data.data;
this.loading = false;
},
methods: {
// 滾動這裡可以加上節流,減少觸發頻次
handleScroll() {
/**
* 擷取在垂直方向上,滾動條滾動了多少像素距離Element.scrollTop
*
* 滾動的距離除以每一項的高度,即為滾動到了多少項,當然,要取個整數
* 例:滾動4米,一步長0.8米,滾動到第幾步,4/0.8 = 第5步(取整好計算)
*
* 又因為我們一次要展示10項,是以知道了起始位置項,再加上結束位置項,
* 就能得出區間了【起始位置, 起始位置 + size項數】==【起始位置, 結束位置】
* */
const scrollTop = this.$refs.virtualListWrap.scrollTop;
this.start = Math.floor(scrollTop / this.itemHeight);
this.end = this.start + this.count;
/**
* 動态更改定位的top值,確定關聯,動态展示相應内容
* */
this.topVal = this.$refs.virtualListWrap.scrollTop + "px";
},
},
};
</script>
<style scoped lang="less">
// 虛拟清單容器盒子
.virtualListWrap {
box-sizing: border-box;
width: 240px;
border: solid 1px #000000;
// 開啟滾動條
overflow-y: auto;
// 開啟相對定位
position: relative;
.contentList {
width: 100%;
height: auto;
// 搭配使用絕對定位
position: absolute;
top: 0;
left: 0;
.itemClass {
box-sizing: border-box;
width: 100%;
height: 40px;
line-height: 40px;
text-align: center;
}
// 奇偶行改一個顔色
.itemClass:nth-child(even) {
background: #c7edcc;
}
.itemClass:nth-child(odd) {
background: pink;
}
}
.loadingBox {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.64);
color: green;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>
使用vxetable插件實作虛拟清單
如果不是清單,是table表格的話,筆者這裡推薦一個好用的UI元件,vxetable,看名字就知道做的是表格相關的業務。其中就包括虛拟清單。
vue2和vue3版本都支援,性能比較好,官方說:虛拟滾動(最大可以支撐 5w 列、30w 行)
強大!
官方網站位址:vxetable.cn/v3/#/table/…
效果圖
效果很絲滑
安裝使用代碼
注意安裝版本,筆者使用的版本如下:
cnpm i xe-utils vxe-table@3.6.11 --save
main.js
js複制代碼// 使用VXETable
import VXETable from 'vxe-table'
import 'vxe-table/lib/style.css'
Vue.use(VXETable)
代碼方面也很簡單,如下:
html複制代碼<template>
<div class="box">
<vxe-table
border
show-overflow
ref="xTable1"
height="300"
:row-config="{ isHover: true }"
:loading="loading"
>
<vxe-column type="seq"></vxe-column>
<vxe-column field="id" title="ID"></vxe-column>
<vxe-column field="name" title="名字"></vxe-column>
<vxe-column field="value" title="對應值"></vxe-column>
</vxe-table>
</div>
</template>
<script>
import axios from "axios";
export default {
data() {
return {
loading: false,
};
},
async created() {
this.loading = true;
const res = await axios.get("http://ashuai.work:10000/bigData");
this.loading = false;
this.render(res.data.data);
},
methods: {
render(data) {
this.$nextTick(() => {
const $table = this.$refs.xTable1;
$table.loadData(data);
});
},
},
};
</script>
方案七 開啟多線程Web Worker進行操作
本案例中,使用Web Worker另外開啟一個線程去操作代碼邏輯,收益并不是特别大(假如使用虛拟滾動清單插件的情況下)
不過也算是一個拓展的思路吧,面試的時候,倒是可以說一說,提一提。
對Web Worker不熟悉的道友們,可以看看筆者之前的這篇文章:《性能優化之使用vue-worker插件(基于Web Worker)開啟多線程運算提高效率》
方案八 未雨綢缪,防患于未然
以下為筆者愚見,僅供參考...
- 在上述解決方案都說完以後,并沒有結束。
- 實際上本題目在考查候選人知識的廣度和深度以外,更是考查了候選人的處理問題的思考方式,這一點尤其重要!
- 筆者曾做過候選人去求職,也曾做過面試官去面試。就程式員開發工作而言,技術知識點不熟悉,可以快速學習,如文檔、谷歌、百度、技術交流群,相關同僚都可提供一定的支援
- 更重要的是看中候選人的思考方式,思維模式
- 試想,兩個候選人實力水準差不多,但是一個隻知道埋頭苦幹,有活就幹,不去斟酌;而另外一個卻是在用心工作的時候,也會仰望星空,會分析如何幹活能夠高成本效益地完成任務,注重過程與結果
- 這樣的話,哪個更加受歡迎一些呢?
如果筆者是候選人,筆者在說了上述7種方案以後,會再補充第八種方案:未雨綢缪,防患于未然
場景模拟
面試官随意打量着其手中我的履歷,撫須怪叫一聲:“小子,後端要一次性傳回10萬條資料給你,你如何處理?”
我眉毛一挑,歪嘴一笑:“在上述7種方案陳述完以後,我想類似的問題,我們可以從根本上去解決。即第八種方案,要未雨綢缪,防患于未然。”
“哦?”面試官心中疑惑,緩緩放下我的履歷:“願聞其詳。”
我不緊不慢地答道:“在具體開發工作中,我們在接到一個需求時,在技術評審期間,我們就要和後端去商量比較合适的技術解決方案。這個問題是後端要一次性傳回我10萬條資料,重點并不在10萬條這麼多資料,而在于後端為什麼要這樣做?”
面試官擡頭,瞳孔中倒映出我的身影,認真聽了起來。
我一字一頓地說道:“除去**業務真正需要這種方案**的話(若是客戶要求的,那就沒啥好說的,幹就完了),後端這樣做的原因大緻有兩種,第一種他不太懂sql的limit語句,但這基本不可能,第二種就是他有事情,随便敷衍寫了一下。是以,就是要和他溝通,從大資料量接口請求時長過長,以及過多的dom元素渲染導緻性能變差,以及項目的可維護性等角度去溝通,我相信隻要正确的溝通,就能從根源上去避免這種不太合理的情況發生。”
面試官又突然狡黠地發問:“要是溝通以後,後端死活不給你分頁呢?你咋辦?嗨嗨!你的溝通無效果!你如何處理!人家不聽你的!”似乎是覺得這個問題很刁鑽,他雙臂抱在胸前,靠在椅背上,發出桀桀桀的詭異笑聲,他等待着看到我臉上即将綻放的回答不上來的,尴尬笑容。
我内心冷哼一聲:雕蟲小技...
我盯着面試官的眼睛,認真說道:“如果工作中溝通無效果,要麼是我自己溝通語言表達的問題,這一點我會注意,不斷提升自己的溝通技巧和說話方式,要麼就是...”
我聲音揚起了三分:“我溝通的這個人有問題!他工作摸魚偷懶耍滑!固執己見!為難他人!高高在上!自以為是!這種情況下,我會找到我的直屬上司去介入,因為這已經不是項目的需求問題了,而是員工的基本素養問題!”
停頓了一秒,我聲音又柔和了幾分:“但是,但是我相信咱們公司員工中是絕對沒有這樣的人存在的,各個都是能力強悍,态度端正的優秀員工。畢竟咱們公司在行業中久負盛名,我也是是以慕名而來的。您說對吧?”
面試官眼中閃過震驚之色,他沒有想到我居然把皮球又踢給他了,不過他為了維持形象,旋即恢複了鎮定,隻是面部肌肉在止不住的微微顫抖。
“那是當然,公司人才濟濟。”面試官随口接話道。
我又補充道:“實際上在工作中,前端作為比較貼近使用者的角色而言,需要和各個崗位的同僚進行溝通,比如後端、産品、UI、測試等。我們需要通過合理的溝通方式,去提升工作效率,完成項目,實作自己的價值,為公司創造收益,我想這是每一個員工需要做的,也是必須要做到的。”
面試官又撫須怪叫一聲:“小子表現還行,你被錄用了!一個月工資2200,自帶電腦,無社無金,007工作制,不能偷吃公司零食,以及...”
我:阿哒...
定位性能瓶頸
憑直覺感覺,資料量大是頁面渲染的主要性能瓶頸,但是作為開發人員,還是要以客觀事實為依據,不能憑感覺做事。那如何定位出頁面卡頓的性能瓶頸呢?
其實前端有一些工具可以評估網站的性能,如lighthouse、chrome的devtool的Performance,下面主要是以這兩個工具配合着來定位性能問題。
下面針對4000條資料的頁面渲染,嘗試使用lighthouse和chorme的Performance來進行性能開銷定位。
lighthouse給出優化建議
首先,通過lighthouse工具給出的網站性能名額,其中的幾項關鍵名額的定義可以參考Web 名額。如下圖所示:
透過該工具,我們可以得到目前頁面性能的一個大概得分,它是基于各個名額的分數按照一定的權重比例系列換算而來的,是一個綜合性的評價結果,分數越低性能越差。目前頁面性能的19分的計算如下圖:
lighthouse每次跑的分數有波動,跟目前的網速有一定的關系;不過沒有關系,最重要的看它給的優化建議。
針對目前頁面,lighthouse給出的優化建議如下圖所示:
因為是在本地開發環境跑的lighthouse,是以有些優化項如壓縮js等不用關注,我們主要看紅框标記的幾項,可以說這幾項是導緻性能差的主要原因,需要重點關注優化。
- 減少主線程的工作
- 主線程耗費9.4s,主要開銷包括:
- 14個長任務的執行
- 純js部分的執行耗時7.2s
- 避免dom數過大
- 頁面有24530個dom元素,包括表格渲染的dom,地圖marker渲染的dom等;dom過多會導緻頁面操作卡頓,可以參考網頁dom元素過多為什麼會導緻頁面卡頓
- 避免大量的網絡負載
- 因為本地開發環境跑的結果,代碼沒有壓縮可以先不管,真正要關注的是接口的請求響應時長,因為接口傳回的資料量約48kb,導緻接口耗時1.3s左右,後端接口需要優化
通過lighthouse的優化建議,總結一下目前頁面的主要問題:
- 主線程耗時長,包括14個長任務
- 頁面dom數量過大
- 接口響應耗時長,達1.4s
針對主線程耗時長的問題,lighthouse會給出了浏覽器渲染的整個流程中每一部分的耗時時長,但它不會詳細告訴我們每一部分具體耗時在什麼地方,這正是chrome devtool的Performance面闆的強項。
Performance面闆定位耗時真因
Performance用于記錄和分析我們的應用在運作時的所有活動,它呈現多元度的資料,可以幫助我們很好地定位性能問題。其中,利用Performance面闆main項,展示的是浏覽器主線程有關的内容,包括:
- 檢視浏覽器渲染的整個過程,包括資料加載、html解析、樣式解析計算、js加載執行、composite layers、繪制等各個階段
- 檢視js腳本執行過程的調用棧資訊及對應的耗時,很容易定位性能耗時長的地方
如下圖是使用chrome Performance面闆跑出的結果。
從中可以得知,資料渲染完畢頁面耗時近10s,其中js執行耗時花費7.6s左右,從js執行的調用棧看出主要是Microtasks下的花費3.4s的_next和3.94s的fulfilled兩部分:
- _next部分主要是頁面元件的渲染耗時,包括表格的每行渲染,以及地圖自定義marker元件的執行耗時
- fulfilled部分是将_next生成的marker和popup元件添加到地圖中,因為是使用dom渲染,在添加地圖後,涉及到marker和popup的html解析及渲染過程。
從圖上可以看出,fulfilled部分耗時主要是循環添加marker及其popup到地圖中并完成渲染。
結合lighthouse和Performance面闆的分析,可以定位前端方面影響頁面性能的主要原因有:
- 表格渲染因資料量大耗時過長,因為一次性全量渲染
- 地圖marker渲染以dom形式渲染,涉及到dom的解析和渲染,在大資料量時性能差
- 表格和地圖marker同時渲染,導緻_next部分耗時長,阻塞後續其他重要流程的執行
性能問題拆解
針對上面定位出頁面的性能問題,想到的優化解決方案:
- 表格不要一次性渲染所有資料
- 以dom形式渲染的marker改為canvas渲染
- 表格和地圖分開渲染,并且地圖marker分片渲染
大資料清單渲染
分片渲染
針對大資料表格渲染,首先想到的是分片渲染,簡單來說就是将大資料量清單劃分為n個一組進行渲染,一組稱做一個資料片。其設計思路:
建立一個隊列,通過定時器來向渲染隊列中添加渲染的切片資料。
提示一點,渲染隊列中已經渲染的分片在進行渲染時,隻是耗費節點diff時間,不會重新渲染。demo如下所示:
code.juejin.cn/pen/7142775…
分片渲染存在下面幾個主要問題:
- 總渲染時間增加,因為通過定時器分片渲染,存在間隔
- 資料最終是全量渲染的結果,導緻dom數量過大
- 快速上拉加載閃屏,可以使用requestAnimationFrame來解決,參考高性能渲染10萬條資料(時間分片)
虛拟清單
虛拟清單是解決大數量表格渲染的另一種常見的解決方案,其設計思路是:
隻對可視區域内的内容進行渲染,對非可視區域的内容不做渲染或者渲染一部分(俗稱緩沖區)
這種方案要處理的是從大量資料中過濾出可視區或者加上緩沖區的資料并渲染,主要是根據滾動事件來進行篩選,其他大部分資料内容不會渲染真正的DOM,這大大減少表格的渲染時間以及頁面dom數量,帶來的性能提升是非常可觀的。
一圖勝千言,圖檔出自這裡。
社群對于虛拟清單的介紹方案很多,具體的實作細節這裡就不做過多介紹,可以參考下面兩篇文章:
- 花三個小時,完全掌握分片渲染和虛拟清單~
- 手把手教你寫React虛拟清單
項目按照虛拟清單方案優化的參考antd提供的demo。
以上面提到的4000條資料做實驗,在沒有對地圖資料做優化的前提下,使用虛拟清單優化後的效果如下圖:
可以看到_next部分執行時間不到1s,并且js執行的時間減少至4621ms,性能提升明顯。
地圖大資料量渲染
地圖marker元素是以dom方式來渲染的,資料量少的情況下沒有什麼問題,但随着資料量增多,dom渲染的性能越來越吃力,導緻頁面卡頓甚至崩潰。鑒于地圖使用的是leaflet,是以想到的解決方案是用canvas來繪制,至于為什麼canvas相較于dom渲染性能得到提升,可以參考這篇文章HTML界的“蘇炳添”——詳解Canvas優越性能和實際應用]。
因為地圖中的marker互動比較簡單,點選marker展示對應的popup,是以項目選用leaflet官方推薦的plugins Leaflet.Canvas-Markers來生成marker,該插件需要用圖檔來設定marker,具體可參考demo。
地圖marker渲染優化前:
jsx複制代碼<MapContainer {...props}>
{
stops.map((stop) => {
return <CustomMarker key={stop.id} data={stop} />;
})
}
</MapContainer>
// CustomMarker實作:
function CustomMarker(props) {
...
// react-leaflet提供的Marker是以Dom形式渲染的
<Marker
...
position={position}
>
<Popup
{...props}
>
... // popup内容
</Popup>
</Marker>
}
優化後:
jsx複制代碼 // 首頁面的render部分有關地圖部分
<MapContainer {...props}>
<CanvasMarkers data={stops} />
</MapContainer>
// CanvasMarkers實作
import 'leaflet-canvas-marker';
function CanvasMarkers({data}) {
const map = useMap();
const canvasLayerRef = useRef();
const icon = window.L.icon({
iconUrl: '圖檔位址',
iconSize: [8, 8],
iconAnchor: [4, 4],
});
useEffect(() => {
if (!data.length) {
canvasLayerRef.current?.onRemove(map);
return;
}
canvasLayerRef.current = window.L.canvasIconLayer({}).addTo(map);
const canvasMarkers = [];
for (let i = 0, len = data.length; i < len; i++) {
const { lat, lng } = data[i];
const marker = window.L.marker([lat, lng], {
icon,
}).bindPopup(popupHtml);
canvasMarkers.push(marker);
}
canvasLayerRef.current?.addLayers(canvasMarkers);
}, [markers, icon, map]);
return null;
}
通過渲染後的dom結構可以看出,該插件最終将所有的marker元素在同一個canvas中渲染繪制出來。
為了跟上一節效果做對比,同樣以4000條資料做實驗,在使用虛拟清單優化的同時,使用canvas渲染地圖元素優化後的效果如下圖所示:
可以看出fulfilled部分執行時間減少至不到100ms,整個js執行時間降至1102ms,canvas渲染的性能提升較可見一斑。
長任務分割
根據前面4000條資料在未進行任何優化的Performance面闆分析,js執行的長任務占比7.4s左右,占整個主流程的近75%,主要是表格和地圖同時渲染導緻,嚴重影響後面任務的執行。長任務分割的一個好處減少任務的執行時間,可以為後面的任務騰出執行時間。
借鑒于清單的分片渲染方案,可不可以将表格渲染和地圖資料按照先後順序依次渲染呢,并且地圖資料采用分片渲染的機制呢?
這在技術上是可行的,而頁面在大資料量下可以先讓使用者看比較重要的資料,然後逐漸渲染出整個頁面的内容是可以接受的。
是以,在技術上做了如下兩方面優化:
- 1、表格資料與地圖資料先後渲染,先表格後地圖元素展示
- jsx複制代碼
- // 先table渲染後地圖資料渲染,關鍵代碼 setTableData(tableData); // setTableData為react的useState提供方法 setTimeout(() => { // 延遲100ms初始化地圖資料 setMarkers(stops); // setMarkers為react的useState提供方法 }, 100);
- 2、地圖元素分片渲染
- CanvasMarkers元件改造如下:
- jsx複制代碼
import 'leaflet-canvas-marker'; function CanvasMarkers({data}) { ... const [data, setData] = useState([]); // 地圖marker資料,分割渲染核心邏輯 const sliceData = useCallback((list, index: number = 0, num = 100) => { const endIdx = Math.ceil(list.length / num); if (index === endIdx) { return; } setTimeout(() => { // 每200ms執行一個批次的渲染 const toBeRenderList = list.slice(index * num, (index + 1) * num); setData(toBeRenderList); console.log(toBeRenderList, toBeRenderList.length); sliceData(list, index + 1, num); }, 200); }, []); useEffect(() => { const len = markers.length; if (len === 0) { canvasLayerRef.current?.onRemove(map); return; } canvasLayerRef.current = window.L.canvasIconLayer({}).addTo(map); // 分割大小的一個簡單設定政策 const sliceLen = len > 100000 ? 2000 : len > 50000 ? 1000 : 500; sliceData(data, 0, sliceLen); }, [markers, map, sliceData]); // 隻關注目前data的内容,為其生成L.marker useEffect(() => { if (!data.length) { return; } const canvasMarkers = []; for (let i = 0, len = data.length; i < len; i++) { const { lat, lng } = data[i]; const marker = window.L.marker([lat, lng], { icon, }).bindPopup(popupHtml); canvasMarkers.push(marker); } canvasLayerRef.current?.addLayers(canvasMarkers); }, [data, map]); return null; }
我們以相同的5萬條資料來進行實驗,在上面兩種優化的前提下,未進行長任務分割優化前的Performance面闆結果如下圖
經過上面兩種方式的優化手段,得到的效果如下圖:
可以看到頁面總的渲染時間變長了,這是為何?
前面說了分片渲染會增加總的渲染耗時,因為每批次資料在指定的計時器間隔進行異步渲染,是以會拉長總的渲染時長。
但是這并不是我們關注的點,我們更關注頁面的 TBT(總的阻塞時間): 由2690ms減少到1045ms,效率提升超過60%; 另外,頁面資料在表格渲染完成并且地圖第一批資料渲染完成時間大概在4.5s左右,較全量渲染的6.1s減少1.6s左右,性能提升比較明顯。
優化效果
以文章開頭提到的4000條資料進行比對,經過上面三種方式的優化後的結果如下圖:
可以看到性能提升明顯:
- js執行時間降為由7453ms減少至1466ms
- 頁面總耗時由9979ms減少至2999ms,這其中包括分片總耗時,理論上可以拿地圖第一批資料渲染完後的時間進行比較
優化後的頁面打開速度幾乎達到秒開的效果,使用者體驗得到很大的提升。
題外話,性能優化是前端老生常談的一個課題,需要前端開發重點關注的一個方向,尤其是在可視化領域。希望本文分享對大家有所幫助,也希望有經驗大佬分享這方面的知識。
總結
有效的溝通,源自于解決問題的思維模式,在多數情況下,重要性,大于當下所掌握的技術知識點
- 網站效果示範位址:ashuai.work:8888/#/bigData
- GitHub倉庫位址:github.com/shuirongshu…