小程序高性能數(shù)據(jù)同步

小程序中,一個重要的性能環(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ù)量。

至此,所有已知問題處理完畢,完整代碼參考此處。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容