小程序中,一個重要的性能環(huán)節(jié)就是同步 worker 進(jìn)程數(shù)據(jù)到渲染進(jìn)程。對于使用響應(yīng)式來管理狀態(tài)的情況,搜索社區(qū)實現(xiàn),可以發(fā)現(xiàn)很多只是粗暴地遞歸遍歷一下復(fù)雜對象,從而監(jiān)聽到數(shù)據(jù)變化。
在 Goldfish 中,同樣使用了響應(yīng)式引擎來管理狀態(tài)數(shù)據(jù)。響應(yīng)式天生的好處是:能夠精確監(jiān)聽狀態(tài)數(shù)據(jù)變化,然后生成最小化的數(shù)據(jù)更新對象。
舉個例子,假如現(xiàn)在有一個響應(yīng)式對象:
const observableObj = {
name: '禺疆',
address: {
city: 'ChengDu',
},
};
如果將 city 修改為 'HangZhou',那么很容易生成小程序中 setData 能直接使用的如下數(shù)據(jù)更新對象:
const updateObj = {
'address.city': 'HangZhou',
};
當(dāng)然,我們不可能數(shù)據(jù)每次變化的時候,就立即調(diào)用 setData 去更新數(shù)據(jù),畢竟頻繁更新是很耗性能的。所以,我們需要使用 setData、 $spliceData、 $batchedUpdates 批量更新。
批量時機(jī)
要做批量更新,第一步就是劃分什么時間段內(nèi)的更新算是一個批量。
很自然地,我們想到使用 setTimeout:在監(jiān)聽到數(shù)據(jù)更新請求時,使用 setTimeout 計時,搜集時間段內(nèi)所有的數(shù)據(jù)更新需求,在計時結(jié)束時統(tǒng)一更新。
實際上,在移動端應(yīng)當(dāng)謹(jǐn)慎使用 setInterval、setTimeout 計時,由于移動設(shè)備節(jié)省電量,很容易不準(zhǔn)。比如 setInterval 設(shè)置時間間隔為 8 分鐘,在移動設(shè)備上很容易出現(xiàn)時間間隔變長為 16 分鐘左右。
既然 setTimeout 不行,那么我們第二個想到的可能是 requestAnimationFrame。很遺憾,小程序 worker 進(jìn)程里面沒有 requestAnimationFrame。
最后,只剩下 Microtask 了。在小程序的 worker 進(jìn)程里,我們可以借助 Promise.resolve() 來生成 Microtask,參考如下偽代碼:
setData request 1
setData request 2
setData request 3
await Promise.resolve()
combine request 1 2 3
setData
實際上,由于響應(yīng)式引擎的監(jiān)聽回調(diào)觸發(fā)做了 Promise.resolve() 批量處理的邏輯,并且在我們的業(yè)務(wù)代碼中,也很容出現(xiàn) Microtask,數(shù)據(jù)更新請求(setData Request)并不是上述規(guī)規(guī)矩矩從上到下同步執(zhí)行的,很可能在若干個 Microtask 中穿插請求。因此,上述搜集到的數(shù)據(jù)更新請求是不完整的,我們需要搜集到當(dāng)前同步代碼塊及同步代碼塊中產(chǎn)生的所有 Microtask 生成的數(shù)據(jù)更新請求:
export class Batch {
private segTotalList: number[] = [];
private counter = 0;
private cb: () => void;
public constructor(cb: () => void) {
this.cb = cb;
}
// 每次有數(shù)據(jù)請求的時候,都調(diào)用一下 set。
public async set() {
const segIndex = this.counter === 0
? this.segTotalList.length
: (this.segTotalList.length - 1);
if (!this.segTotalList[segIndex]) {
this.segTotalList[segIndex] = 0;
}
this.counter += 1;
this.segTotalList[segIndex] += 1;
await Promise.resolve();
this.counter -= 1;
// 同步塊中最后一個 set 調(diào)用對應(yīng)的 Microtask
if (this.counter === 0) {
const segLength = this.segTotalList.length;
// 看看下一個 Microtask 觸發(fā)前,是否還有新的更新請求進(jìn)來。
// 如果沒有,說明更新請求穩(wěn)定了,立即觸發(fā)更新邏輯(this.cb)
await Promise.resolve();
if (this.segTotalList.length === segLength) {
this.cb();
this.counter = 0;
this.segTotalList = [];
}
}
}
}
優(yōu)化更新對象
搞定更新時機(jī)之后,我們只需要在合適的時機(jī),將積累的更新邏輯放置在 $batchedUpdates 中執(zhí)行就好了。
但是在項目中發(fā)現(xiàn),頁面初始數(shù)據(jù)格式化的時候,如果數(shù)據(jù)結(jié)構(gòu)很復(fù)雜,就很容易產(chǎn)生具有大量扁平 key 的更新對象,類似這樣:
setData({
'state.key1': 'xxx',
'state.key2.key21': 'xx',
'state.key3': 'xxx',
...
});
雖然更新對象看起來都很“最小化”,但是傳遞給渲染進(jìn)程并還原成正常對象的過程中,肯定少不了耗時的 key 恢復(fù)處理。我們也實際測試過,如果直接調(diào)用 setData 去更新復(fù)雜數(shù)據(jù)對象,小程序還是比較流暢的,但是換成“最小化”更新對象之后,小程序有明顯的卡滯。
因此,在構(gòu)造更新數(shù)據(jù)時,應(yīng)當(dāng)設(shè)置一個 key 數(shù)量上限,如果超出上限,應(yīng)當(dāng)合并,形成 key 數(shù)量更小的更新對象。比如上述示例,可以合并成:
setData({
state: {
...this.data.state,
...{
key1: 'xxx',
key2: {
key21: 'xx',
},
key3: 'xxx',
},
},
...
});
我們可以把更新對象當(dāng)做一棵樹,比如上述例子,對應(yīng)的樹形結(jié)構(gòu)如下:
state
/ | \
key1 key2 key3
|
key21
有多少個葉子節(jié)點(diǎn),就會生成多少個 key。
在搜集更新請求階段,可以順手構(gòu)造對應(yīng)的樹形結(jié)構(gòu)。在更新時,按照深度優(yōu)先的順序遍歷樹,生成更新對象。遍歷過程中,記錄已生成的 key 數(shù)量??赡鼙闅v到樹中某個節(jié)點(diǎn)時,發(fā)現(xiàn)加上直接子節(jié)點(diǎn)數(shù)量,已經(jīng)超過 key 數(shù)量限制了,此時就不要向下遍歷了,直接在該節(jié)點(diǎn)處生成更新對象。代碼參考:
class UpdateTree {
private root = new Ancestor();
private view: View;
private limitLeafTotalCount: LimitLeafCounter;
public constructor(view: View, limitLeafTotalCount: LimitLeafCounter) {
this.view = view;
this.limitLeafTotalCount = limitLeafTotalCount;
}
// 構(gòu)造樹
public addNode(keyPathList: (string | number)[], value: any) {
let curNode = this.root;
const len = keyPathList.length;
keyPathList.forEach((keyPath, index) => {
if (curNode.children === undefined) {
if (typeof keyPath === 'number') {
curNode.children = [];
} else {
curNode.children = {};
}
}
if (index < len - 1) {
const child = (curNode.children as any)[keyPath];
if (!child || child instanceof Leaf) {
const node = new Ancestor();
node.parent = curNode;
(curNode.children as any)[keyPath] = node;
curNode = node;
} else {
curNode = child;
}
} else {
const lastLeafNode: Leaf = new Leaf();
lastLeafNode.parent = curNode;
lastLeafNode.value = value;
(curNode.children as any)[keyPath] = lastLeafNode;
}
});
}
private getViewData(viewData: any, k: string | number) {
return isObject(viewData) ? viewData[k] : null;
}
private combine(curNode: Ancestor | Leaf, viewData: any): any {
if (curNode instanceof Leaf) {
return curNode.value;
}
if (!curNode.children) {
return undefined;
}
if (Array.isArray(curNode.children)) {
return curNode.children.map((child, index) => {
return this.combine(child, this.getViewData(viewData, index));
});
}
const result: Record<string, any> = isObject(viewData) ? viewData : {};
for (const k in curNode.children) {
result[k] = this.combine(curNode.children[k], this.getViewData(viewData, k));
}
return result;
}
private iterate(
curNode: Ancestor | Leaf,
keyPathList: (string | number)[],
updateObj: Record<string, any>,
viewData: any,
availableLeafCount: number,
) {
if (curNode instanceof Leaf) {
updateObj[generateKeyPathString(keyPathList)] = curNode.value;
this.limitLeafTotalCount.addLeaf();
} else {
const children = curNode.children;
const len = Array.isArray(children)
? children.length
: Object.keys(children || {}).length;
if (len > availableLeafCount) {
updateObj[generateKeyPathString(keyPathList)] = this.combine(curNode, viewData);
this.limitLeafTotalCount.addLeaf();
} else if (Array.isArray(children)) {
children.forEach((child, index) => {
this.iterate(
child,
[
...keyPathList,
index,
],
updateObj,
this.getViewData(viewData, index),
this.limitLeafTotalCount.getRemainCount() - len,
);
});
} else {
for (const k in children) {
this.iterate(
children[k],
[
...keyPathList,
k,
],
updateObj,
this.getViewData(viewData, k),
this.limitLeafTotalCount.getRemainCount() - len,
);
}
}
}
}
// 生成更新對象
public generate() {
const updateObj: Record<string, any> = {};
this.iterate(
this.root,
[],
updateObj,
this.view.data,
this.limitLeafTotalCount.getRemainCount(),
);
return updateObj;
}
public clear() {
this.root = new Ancestor();
}
}
到此為止,我們已經(jīng)能在合適的時機(jī),針對某個頁面或組件生成限定數(shù)量的 key 去同步數(shù)據(jù)了。
還有個問題需要解決:更新順序。上述更新過程,我們會針對普通對象,使用 setData,針對數(shù)組,使用 $spliceData。在這兩個方法之前,會分別準(zhǔn)備好兩個方法的對象參數(shù)。假設(shè)如下場景:
// page 的 data.list 中已經(jīng)存在一個元素
pageInstance.data = {
list: ['0'],
};
// 某個時刻,調(diào)用 setData 和 $spliceData 更新數(shù)據(jù)
pageInstance.setData({
'list[1]': '1',
});
pageInstance.$spliceData({
list: [1, 0, '2'],
});
更新完成之后,pageInstance.data.list 變?yōu)?['0', '2', '1'],如果調(diào)換 setData 和 $spliceData 的順序,那么 pageInstance.data.list 將會變?yōu)?['0', '1']。
因此,我們不能打亂批量更新中 setData 和 $spliceData 的調(diào)用順序。
此時,我們構(gòu)造的批量更新邏輯必須滿足:
- 不能打亂順序;
- 控制 key 數(shù)量上限。
為了保持順序,在批量更新塊中,比如:
setData request
setData request
spliceData request
spliceData request
setData request
前兩個合并成一個 setData 更新對象,中間兩個合并成一個 $spliceData 更新對象,最后一個是單獨(dú)的 setData 更新對象。
前后兩個 setData 更新對象的 key 數(shù)量,統(tǒng)一受 key 數(shù)量的限制。
絕大多數(shù)情況下,$spliceData 更新對象會比較小,因此不限制該更新對象的 key 數(shù)量。
至此,所有已知問題處理完畢,完整代碼參考此處。