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 轉小程式?
為了讓讀者更容易理解我所說的痛點,我列舉了幾個反例場景來說明:
反例場景 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
的資料模型集合。
最終列印的結果:
Action 與 Store
與以往前端思維不同,我大費周章的折騰這麼一套出來。到底與原來一些常用框架思維中的 action 完成一切到底有什麼不同呢?
請大家思考一個問題,action 的定義到底是什麼呢?
最初 Flux 設計中, action 的設計就是為了改變 Store 中的 state,來達到狀態可控、流向明確的目的。
Redux 中的 action 甚至都是不支援非同步操作的,後來有一些變相的方式實現非同步 action,後來又有了Redux-thunk
、Redux-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
(請註明來意及掘金)