Vmo前端資料模型設計

YeeWang王大白發表於2019-03-01

Vmo 是一個用於前端的資料模型。解決前端介面訪問混亂,服務端資料請求方式不統一,資料返回結果不一致的微型框架。

Vmo 主要用於處理資料請求,資料模型管理。可配合當前主流前端框架進行資料模型管理 Vue,React,Angular。

能夠有效處理以下問題:

  • 介面請求混亂,axios.get...隨處可見。
  • 資料管理混亂,請求到的資料結果用完即丟、拿到的資料直接放進Store
  • 資料可靠性弱,不能保證請求資料是否穩定,欄位是否多、是否少。
  • Action方法混亂,Action中及存在同步對Store的修改,又存在非同步請求修改Store
  • 程式碼提示弱,請求到的資料無法使用TypeScript進行程式碼提示,只能定義 any 型別。
  • 無效欄位增多,人員變動,欄位含義資訊逐步丟失,新業務定義新欄位。
  • 專案遷移繁重,專案重構時,對欄位不理解,重構過程功能點、資料丟失。

背景介紹

隨著現有大前端的蓬勃發展,Vue、React 等框架不斷流行,RN、Weex、Electron 等使用 JS 開發客戶端應用的不斷髮展,Taro、mpVue、CML 等新型小程式框架的不斷創新。JavaScript 將變得更加流行與多樣,使用 JS 同構各端專案將不再是夢。

JS 的靈活在賦予大家方便的同時也同樣存在著一些問題,同樣實現一個資料獲取到頁面渲染的簡單操作,可能就會有非常多的寫法。正常的,在 Vue 中,可能會直接這樣寫:

const methods = {
  /**
   * 獲得分類資訊
   */
  async getBarData() {
    try {
      const { data } = await axios.get(url, params);

      return data;
    } catch (e) {
      console.error("something error", e);
    }
  }
};
複製程式碼

這樣的做法在功能上講沒什麼問題,但在新增一些其他動作後,這樣的做法就變得非常難以管理。

比如,需要在請求中加入一些關聯請求,需要獲取一個商品頁的列表,查詢引數包含,分頁引數(當前頁,查詢數),分類 Id,搜尋內容,排序方式,篩選項。

在執行該請求時,發現分類 Id 也需要另外一個介面去獲取。於是程式碼成了:

const params = {
  sort: -1,
  search: "",
  filter: "",
  page: {
    start: 1,
    number: 10
  }
};
const methods = {
  /**
   * 獲得商品列表
   */
  async getGoodsData() {
    try {
      const { data } = await axios.get(url.goodsType); // 獲取所有分類Id
      const { id: typeId } = data;
      const res = await axios.get(url.goods, { ...params, typeId }); // 獲取商品

      return res.data;
    } catch (e) {
      console.error("something error", e);
    }
  }
};
複製程式碼

這樣看上去貌似是完成了這個業務,但其實在業務不斷變化的環境下,這樣直接在元件中書寫介面請求是非常脆弱的。

比如以下問題:

  • 返回結果中,有欄位需要單獨處理後才能使用。比如:後端可能返回的一個陣列是,隔開
  • 返回結果中,有欄位在某種情況下缺失
  • 介面地址發生變動
  • 隨著業務變動,介面欄位需要改動
  • 其他元件需要使用同樣這份資料,但不能保證元件呼叫順序
  • 部分介面資料需要前端快取
  • 介面儲存方式發生變化。比如:有網路走介面,沒網路走 LocalStorage
  • 前端專案框架遷移,介面不變。Vue 轉 React?Vue 轉小程式?

Vmo前端資料模型設計

為了讓讀者更容易理解我所說的痛點,我列舉了幾個反例場景來說明:

反例場景 1

const methods = {
  /**
   * 獲取過濾項資訊
   */
  async getFilterInfo() {
    try {
      const { data: filterInfo } = await axios.get(url.goodsType); // 獲取所有分類Id
      // filterInfo.ids => "2,3,5234,342,412"
      filterInfo.ids = filterInfo.ids.map(id => id.split(","));

      return filterInfo;
    } catch (e) {
      console.error("something error", e);
    }
  }
};
複製程式碼

在這個例子中,獲取過濾項資訊中返回的結果資訊假設為:

{
  "ids": "2,3,5234,342,412",
  ...
}
複製程式碼

在資料解析中,就需要處理為前端接受的陣列,類似的解析還有非常多。

也許現在看這段程式碼無關痛癢,但若每次呼叫這個介面都需要這樣處理,長期處理類似欄位。甚至有很多開發者在一開始拿到這個欄位都會暫時不去處理,到用到的地方再處理,每用一次處理一次。

那想想該是多麼非常噁心的一件事情。

如果使用Vmo會在資料模型開始時,就使用load()來對資料做適配,拿到的資料能夠穩定保證是我們所定義的那種型別。

反例場景 2

// component1
// 需要使用 Goods 資料

const mounted = async () => {
  const goods = await this.getGoodsData();
  this.$store.commit("saveGoods", goods); // 在store中儲存

  this.goods = goods;
};

const methods = {
  /**
   * 獲得商品列表
   */
  async getGoodsData() {
    try {
      const { data } = await axios.get(url.goodsType); // 獲取所有分類Id
      const { id: typeId } = data;
      const res = await axios.get(url.goods, { ...params, typeId }); // 獲取商品

      return res.data;
    } catch (e) {
      console.error("something error", e);
    }
  }
};
複製程式碼
// component2
// 也需要使用 Goods 資料

const mounted = async () => {
  const goods = this.$store.state.goods;

  this.goods = goods;
};
複製程式碼

在這個例子中,簡單描述了兩個元件程式碼(也許看上去很 low,但這種程式碼確實存在),他們都會需要使用到商品資料。按照正常流程元件元件的載入流程可能是

component1->component2

這樣的順序載入,那麼上面這段是可以正常執行的。但假若業務要求,突然有一個component3要在兩個元件之前載入,並且也需要使用商品資料,那麼對於元件的改動是非常頭疼的(因為實際業務中,可能你的資料載入要比這裡複雜的多)。

反例場景 3

小明是一位前端開發人員,他與後端人員愉快的配合 3 個月完成了一款完整的 H5 SPA 應用。

業務發展的很快,又經過數十次迭代,他們的日活量很快達到了 5000,但存在 H5 的普遍痛點,使用者留存率不高。

於是產品決定使用小程式重構當前專案,UI、後端介面不用改變。

小明排期卻說要同樣 3 個月,對此產品非常不理解,認為當初從無到有才用了 3 個月,現在簡單遷移為什麼也需要這麼久。

小明認為,雖然介面、UI 不變。但小程式與 H5 之間存在語法差異,為了考慮後續 H5、小程式多端迭代保持統一,需要花時間在技術建設上,抽離出公共部分,以減輕後續維護成本。

產品非常不理解問開發,如果不抽離會怎麼樣,能快點嗎?就簡單的複製過來呢?於是小明為難之下,非常不滿的說那可能 2 周。

Deal!就這麼辦。

2 周開發,1 周測試,成功上線!

第 4 周,隨著需求迭代,後端修改了一個介面的返回內容,前後端聯動上線後發現之前的 H5 頁面出現大面積白屏。

事後定位發現,由於後端修改導致 H5 資料解析出現 JS 異常。專案組一致認為是由於前段人員考慮不夠全面造成的本次事故,應該由小明承擔責任。

5 個月後,小明離職...

反例場景 4

在業務場景中假設有一段介面返回的 Json 如下:

{
  "c": "0",
  "m": "",
  "d": {
    "bannerList": [
      {
        "bannerId": "...",
        "bannerImg": "...",
        "bannerUrl": "...",
        "backendColor": null
      }
    ],
    "itemList": [
      {
        "obsSkuId": "...",
        "obsItemId": "...",
        "categoryId": null,
        "itemName": "...",
        "mainPic": "...",
        "imgUrlList": null,
        "suggestedPriceInCent": null,
        "priceInCent": null,
        "obsBrandId": "...",
        "width": null,
        "height": null,
        "length": null,
        "bcsPattern": null,
        "commissionPercent": null,
        "buyLink": "...",
        "phoneBuyLink": false,
        "storeIdList": null,
        "storeNameList": null,
        "storeNumber": null,
        "cityIdList": null,
        "provinceIdList": null,
        "obsModelId": null,
        "desc": null,
        "shelfImmediately": null,
        "status": 1,
        "brandName": "...",
        "modelPreviewImg": null,
        "similarModelIdList": null,
        "similarModelImgList": null,
        "relatedModelId": null,
        "relatedModelImg": null,
        "brandAddress": null,
        "promotionActivityVO": null,
        "tagIds": null,
        "tagGroups": [],
        "favored": false
      }
    ],
    "newsList": [
      {
        "id": "...",
        "img": "...",
        "title": "...",
        "desc": "...",
        "date": null,
        "order": null
      }
    ],
    "activityList": [],
    "itemListOrder": 1,
    "activityOrder": 4,
    "lessonOrder": 3,
    "newsOrder": 1,
    "designerOrder": 2,
    "comboListOrder": 2
  }
}
複製程式碼

可以看到裡面有非常多的欄位,雖然一些公司會嘗試使用類似 Yapi 等一些介面管理系統定義欄位。

但隨著業務發展,版本快速迭代,人員變動等因素影響,很有可能有一天

問前端人員,前端人員說這個是後端傳過來就這樣,我不清楚。

問後端人員,後端人員說這個是前端這麼要的,我不清楚。

這上面的欄位公司上下沒有一個人能夠完全描述清楚其作用。

這個時候如果該介面有業務變動,需要做欄位調整,為了不產生未知的介面事故,很可能就說提出不改變之前的介面內容,新增一個介面欄位實現功能的方案。

長此以往,介面返回越來越多,直到專案組花大力氣,重寫介面,前端重寫介面對接。

閃亮登場

基礎原型

先來看一段 Vmo 的程式碼:

import { Vmo, Field } from "@vmojs/base";

interface IFilterValue {
  name: string;
  value: string;
}
export default class FilterModel extends Vmo {
  @Field
  public key: string;
  @Field
  public name: string;
  @Field
  public filters: IFilterValue[];

  public get firstFilter(): IFilterValue {
    return this.filters[0];
  }

  /**
   * 將資料適配\轉換為模型欄位
   * @param data
   */
  protected load(data: any): this {
    data.filters = data.values;
    return super.load(data);
  }
}

const data = {
  key: "styles",
  name: "風格",
  values: [
    { name: "現代簡約", value: "1" },
    { name: "中式現代", value: "3" },
    { name: "歐式豪華", value: "4" }
  ]
};

const filterModel = new FilterModel(data); // Vmo通過load方法對資料做適配
複製程式碼

通過以上方式就成功的將一組 json 資料例項化為一個FilterModel的資料模型。這將會為你帶來什麼好處呢?

  • 適配來源資料,處理需要改變的欄位型別,如string => array
  • 可靠的欄位定義,即使介面欄位變動,資料模型欄位也不會變
  • TypeScript書寫提示,一路回車不用說了,爽
  • 計算屬性,如firstFilter
  • 一次定義,終生受益。不認識\未使用的欄位 say GoodBye
  • 如果專案需要遷移、後端同構,拿來即用。

派生能力

在 Vmo 的設計中,資料模型只是基類,你同樣可以為資料模型賦予一些 "特殊能力" ,比如資料獲取

AxiosVmo 是基於 Vmo 派生的一個使用 axios 作為 Driver(驅動器) 實現資料獲取、儲存能力的簡單子類。

你同樣可以封裝自己的 Driver ,通過相同介面,實現多型方法,來做到在不同介質上儲存和獲取資料。比如 IndexDB,LocalStorage。

import { AxiosVmo } from "@vmojs/axios";
import { Field, mapValue } from "@vmojs/base";
import { USER_URL } from "../constants/Urls";
import FilterModel from "./FilterModel";

// 商品查詢引數
interface IGoodsQuery {
  id: number;
  search?: string;
  filter?: any;
}

interface IGoodsCollection {
  goods: GoodsModel[];
  goodsRows: number;
  filters: FilterModel[];
}

export default class GoodsModel extends AxiosVmo {
  protected static requestUrl: string = USER_URL;

  @Field
  public id: number;
  @Field
  public catId: number;
  @Field
  public aliasName: string;
  @Field
  public uid: number;
  @Field
  public userId: number;
  @Field
  public size: { x: number; y: number };

  /**
   * 返回GoodsModel 集合
   * @param query
   */
  public static async list(query: IGoodsQuery): Promise<GoodsModel[]> {
    const { items } = await this.fetch(query);
    return items.map(item => new GoodsModel(item));
  }

  /**
   * 返回GoodsModel 集合 及附屬資訊
   * @param query
   */
  public static async listWithDetail(
    query: IGoodsQuery
  ): Promise<IGoodsCollection> {
    const { items, allRows, aggr } = await this.fetch(query);
    const goods = items.map(item => new GoodsModel(item));
    const filters = aggr.map(item => new FilterModel(item));
    return { goods, goodsRows: allRows, filters };
  }

  public static async fetch(query: IGoodsQuery): Promise<any> {
    const result = await this.driver.get(this.requestUrl, query);
    return result;
  }

  /**
   * 將請求的資料適配轉換為Model
   * @param data
   */
  protected load(data: any): this {
    data.catId = data.cat_id;
    data.aliasName = data.aliasname;
    data.userId = data.user_id;

    return super.load(data);
  }
}

(async () => {
  // 通過靜態方法建立 GoodsModel 集合
  const goods = await GoodsModel.listWithDetail({ id: 1 });
})();
複製程式碼

像上面這樣的一個GoodsModel中,即定義了資料模型,又定義了介面地址、請求方式與適配方法。 在返回結果中會建立出GoodsModel的資料模型集合。

最終列印的結果:

Vmo前端資料模型設計

Action 與 Store

與以往前端思維不同,我大費周章的折騰這麼一套出來。到底與原來一些常用框架思維中的 action 完成一切到底有什麼不同呢?

請大家思考一個問題,action 的定義到底是什麼呢?

Vmo前端資料模型設計

最初 Flux 設計中, action 的設計就是為了改變 Store 中的 state,來達到狀態可控、流向明確的目的。

Redux 中的 action 甚至都是不支援非同步操作的,後來有一些變相的方式實現非同步 action,後來又有了Redux-thunkRedux-saga這類非同步中介軟體實現。

所以,最開始 action 的設計初衷是為了管理 Store 中狀態,後來因為需要,開發者們賦予了 action 非同步呼叫介面並改變 Store 狀態的能力。

所以很多專案中,看到 action 經常會類似這樣的方法,getUsers()呼叫介面獲取使用者資料,addUser()新增使用者,removeUser()刪除使用者。

那麼哪個方法會有非同步請求呢?哪個方法是直接操作 Store 而不會發生介面請求呢?

Vmo 希望能夠提供一種設計思路,將資料模型、非同步獲取與頁面狀態 分開管理維護。

將資料獲取、適配處理、關聯處理等複雜的資料操作,交給Vmo

Vmo處理後的資料模型,交給 Store。作為最終的頁面狀態。

Mobx

Vmo還可以配合Mobx使用,完成資料模型與資料響應結合使用。

import { Vmo, Field } from "@vmojs/base";
import { observable } from "mobx";

interface IFilterValue {
  name: string;
  value: string;
}
export default class FilterModel extends Vmo {
  @Field
  @observable
  public key: string;
  @Field
  @observable
  public name: string;
  @Field
  @observable
  public filters: IFilterValue[];

  /**
   * 將資料適配\轉換為模型欄位
   * @param data
   */
  protected load(data: any): this {
    data.filters = data.values;
    return super.load(data);
  }
}
複製程式碼

總結

Vmo 強調的是一種設計

通過Vmo希望能夠幫助前端人員建立起對資料的重視,對資料模型的認知。對資料的操作處理交給Model,恢復Store對前端狀態的設計初衷。

Vmo 是我的第一個個人開源專案,凝聚了我對目前大前端資料處理的思考沉澱,原始碼實現並不複雜,主要是想提供一種設計思路。

GitHub 中有完整的 Example,感興趣的讀者可以移步至專案地址檢視。

專案地址

讓各位觀眾老爺見笑了,歡迎指點討論~

個人郵箱:wyy.xb@qq.com

個人微信:wangyinye (請註明來意及掘金)

相關文章