小程序高性能數據同步

小程序中,一個重要的性能環節就是同步 worker 進程數據到渲染進程。對於使用響應式來管理狀態的狀況,搜索社區實現,能夠發現不少只是粗暴地遞歸遍歷一下複雜對象,從而監聽到數據變化。javascript

Goldfish 中,一樣使用了響應式引擎來管理狀態數據。響應式天生的好處是:可以精確監聽狀態數據變化,而後生成最小化的數據更新對象。java

舉個例子,假如如今有一個響應式對象:node

const observableObj = {
  name: '禺疆',
  address: {
    city: 'ChengDu',
  },
};

若是將 city 修改成 'HangZhou',那麼很容易生成小程序中 setData 能直接使用的以下數據更新對象:react

const updateObj = {
  'address.city': 'HangZhou',
};

固然,咱們不可能數據每次變化的時候,就當即調用 setData 去更新數據,畢竟頻繁更新是很耗性能的。因此,咱們須要使用 setData$spliceData$batchedUpdates 批量更新。git

批量時機

要作批量更新,第一步就是劃分什麼時間段內的更新算是一個批量。github

很天然地,咱們想到使用 setTimeout:在監聽到數據更新請求時,使用 setTimeout 計時,蒐集時間段內全部的數據更新需求,在計時結束時統一更新。typescript

實際上,在移動端應當謹慎使用 setIntervalsetTimeout 計時,因爲移動設備節省電量,很容易不許。好比 setInterval 設置時間間隔爲 8 分鐘,在移動設備上很容易出現時間間隔變長爲 16 分鐘左右。小程序

既然 setTimeout 不行,那麼咱們第二個想到的多是 requestAnimationFrame。很遺憾,小程序 worker 進程裏面沒有 requestAnimationFrame數組

最後,只剩下 Microtask 了。在小程序的 worker 進程裏,咱們能夠藉助 Promise.resolve() 來生成 Microtask,參考以下僞代碼:數據結構

setData request 1
setData request 2
setData request 3

await Promise.resolve()

combine request 1 2 3
setData

實際上,因爲響應式引擎的監聽回調觸發作了 Promise.resolve() 批量處理的邏輯,而且在咱們的業務代碼中,也很容出現 Microtask,數據更新請求(setData Request)並非上述規規矩矩從上到下同步執行的,極可能在若干個 Microtask 中穿插請求。所以,上述蒐集到的數據更新請求是不完整的,咱們須要蒐集到當前同步代碼塊同步代碼塊中產生的全部 Microtask 生成的數據更新請求:

export class Batch {
  private segTotalList: number[] = [];

  private counter = 0;

  private cb: () => void;

  public constructor(cb: () => void) {
    this.cb = cb;
  }

  // 每次有數據請求的時候,都調用一下 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 調用對應的 Microtask
    if (this.counter === 0) {
      const segLength = this.segTotalList.length;
      // 看看下一個 Microtask 觸發前,是否還有新的更新請求進來。
      // 若是沒有,說明更新請求穩定了,當即觸發更新邏輯(this.cb)
      await Promise.resolve();
      if (this.segTotalList.length === segLength) {
        this.cb();
        this.counter = 0;
        this.segTotalList = [];
      }
    }
  }
}

優化更新對象

搞定更新時機以後,咱們只須要在合適的時機,將積累的更新邏輯放置在 $batchedUpdates 中執行就行了。

可是在項目中發現,頁面初始數據格式化的時候,若是數據結構很複雜,就很容易產生具備大量扁平 key 的更新對象,相似這樣:

setData({
  'state.key1': 'xxx',
  'state.key2.key21': 'xx',
  'state.key3': 'xxx',
  ...
});

雖然更新對象看起來都很「最小化」,可是傳遞給渲染進程並還原成正常對象的過程當中,確定少不了耗時的 key 恢復處理。咱們也實際測試過,若是直接調用 setData 去更新複雜數據對象,小程序仍是比較流暢的,可是換成「最小化」更新對象以後,小程序有明顯的卡滯。

所以,在構造更新數據時,應當設置一個 key 數量上限,若是超出上限,應當合併,造成 key 數量更小的更新對象。好比上述示例,能夠合併成:

setData({
  state: {
    ...this.data.state,
    ...{
      key1: 'xxx',
      key2: {
        key21: 'xx',
      },
      key3: 'xxx',
    },
  },
  ...
});

咱們能夠把更新對象當作一棵樹,好比上述例子,對應的樹形結構以下:

state
     /   |   \
 key1   key2  key3
         |
        key21

有多少個葉子節點,就會生成多少個 key。

在蒐集更新請求階段,能夠順手構造對應的樹形結構。在更新時,按照深度優先的順序遍歷樹,生成更新對象。遍歷過程當中,記錄已生成的 key 數量。可能遍歷到樹中某個節點時,發現加上直接子節點數量,已經超過 key 數量限制了,此時就不要向下遍歷了,直接在該節點處生成更新對象。代碼參考:

class UpdateTree {
  private root = new Ancestor();

  private view: View;

  private limitLeafTotalCount: LimitLeafCounter;

  public constructor(view: View, limitLeafTotalCount: LimitLeafCounter) {
    this.view = view;
    this.limitLeafTotalCount = limitLeafTotalCount;
  }
 
  // 構造樹
  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();
  }
}

到此爲止,咱們已經能在合適的時機,針對某個頁面或組件生成限定數量的 key 去同步數據了。

還有個問題須要解決:更新順序。上述更新過程,咱們會針對普通對象,使用 setData,針對數組,使用 $spliceData。在這兩個方法以前,會分別準備好兩個方法的對象參數。假設以下場景:

// page 的 data.list 中已經存在一個元素
pageInstance.data = {
  list: ['0'],
};

// 某個時刻,調用 setData 和 $spliceData 更新數據
pageInstance.setData({
  'list[1]': '1',
});
pageInstance.$spliceData({
  list: [1, 0, '2'],
});

更新完成以後,pageInstance.data.list 變爲 ['0', '2', '1'],若是調換 setData$spliceData 的順序,那麼 pageInstance.data.list 將會變爲 ['0', '1']

所以,咱們不能打亂批量更新中 setData$spliceData 的調用順序。

此時,咱們構造的批量更新邏輯必須知足:

  • 不能打亂順序;
  • 控制 key 數量上限。

爲了保持順序,在批量更新塊中,好比:

setData request
setData request
spliceData request
spliceData request
setData request

前兩個合併成一個 setData 更新對象,中間兩個合併成一個 $spliceData 更新對象,最後一個是單獨的 setData 更新對象。

先後兩個 setData 更新對象的 key 數量,統一受 key 數量的限制。

絕大多數狀況下,$spliceData 更新對象會比較小,所以不限制該更新對象的 key 數量。

至此,全部已知問題處理完畢,完整代碼參考此處

相關文章
相關標籤/搜索