State的不可變化帶來的麻煩
在用Redux處理深度複雜的資料時會有一些麻煩。由於js的特性,我們知道當對一個物件進行復制時實際上是複製它的引用,除非你對這個物件進行深度複製。Redux要求你每次你返回的都是一個全新的State,而不是去修改它。這就要求我們要對原來的State進行深度複製。這往往帶來複雜的操作(查詢,合併)。一種簡單的情況是通過擴充套件符號或者Object.assign來處理:
1 2 3 4 5 6 7 |
return { ...state, data: { ...state.data, id: 5 } } |
這種方式在處理簡單的資料來說是比較方便的,但是如果遇到更深一級的資料結構時會顯得很無力,而且程式碼會變得冗長。不僅僅如此,當我們要處理一個包含著物件的陣列時,我們要怎麼辦才好呢?我想,除了深度複製陣列然後修改新的陣列之外大概沒有其他的方法了吧?而且很重要的一點是,如果你對原來整個陣列進行了複製,那麼繫結了資料的UI會自動渲染,即使它們的資料沒有發生變化,簡單來說,就是你修改了某一條表格資料,但是介面上整個表格被重新渲染了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const TablesSource = { query: 'tables', tableId: 10, data: [{ key: 11, name: '胡彥斌', age: 32,//我要修改這裡,要複製整個陣列後修改新的嗎? address: '西湖區湖底公園1號' }, { key: 12, name: '胡彥祖', age: 42, address: '西湖區湖底公園1號' }] }; |
在Redux官方文件中提到了一種解決方案,即正規化化資料:概括起來就一句話:減少層級,唯一id索引,用後端建表的方法構建我們的資料結構。其中最重要原則無非是扁平化和關聯性。最終我們需要將資料形式轉化成以下格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
{ "entities": { "bykey": { "11": { "key": 11, "name": "胡彥斌", "age": 32, "address": "西湖區湖底公園1號" }, "12": { "key": 12, "name": "胡彥祖", "age": 42, "address": "西湖區湖底公園1號" } }, "table": { "10": { "query": "tables", "tableId": 10, "data": [ 11, 12 ] } } }, "result": 10 } |
按照滷煮的理解,正規化化資料無非就是給物件瘦瘦身,再深的層級,我們也儘量將它們扁平化,這樣會減少我們對State的查詢帶來的效能消耗。然後是建立索引表,標識每組資料之間的聯絡。那麼怎麼樣才能得到我們想要的資料呢?
normalizr方法使用指南
官方最薦normalizr模組,它的用法還是需要時間的去研究的。下面我們就以上面的資料為示例,說明它的用法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
$ npm i normalizr -S //下載模組 ......... import {normalize, schema} from 'normalizr';//日常匯入,沒問題 //原始資料 const TablesSource = { query: 'tables', tableId: 10, data: [{ key: 11, name: '胡彥斌', age: 32, address: '西湖區湖底公園1號' }, { key: 12, name: '胡彥祖', age: 42, address: '西湖區湖底公園1號' }] }; //建立實體,名稱為bykey,我們看到它的第二個引數是undefined,說明它是最後一層級的物件 const bykey = new schema.Entity('bykey', undefined, { idAttribute: 'key' }); //建立實體,名字為table,索引是tableid。 const table = new schema.Entity('table', { data: [bykey] //這裡需要說明這些實體的關係,意思是bykey原來table下面的是一個陣列,他對應的是data資料,bykey將會取這裡的資料建立一個以key為索引的物件。 }, { idAttribute: 'tableId'//以tableId為為索引 }); const normalizedData = normalize(TablesSource, table);//生成新資料結構 |
說明:new schema.Entity的第一個參數列示你建立的最外層的實體名稱,第二個引數是它和其他新建立的實體的關係,預設是最小的層級,即它只是最後一層,不包含其他層級了。第三個引數裡面有個idAttribute,指的是以哪個欄位為索引,預設是”id”,它也可以是個引數,返回你自己構造的唯一值,記住,是唯一值。按照這樣的套路,你可以隨意構建你想要的扁平化資料結構,無論源資料的層級有多深。我們最終都會得到希望的資料結構。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
{ "entities": { "bykey": {、、實體名稱 "11": {//我們之前設定的唯一所用key "key": 11, "name": "胡彥斌", "age": 32, "address": "西湖區湖底公園1號" }, "12": { "key": 12, "name": "胡彥祖", "age": 42, "address": "西湖區湖底公園1號" } }, "table": {//實體名 "10": {、、唯一所用tableid "query": "tables", "tableId": 10, "data": [ //data變成了儲存key值索引的集合了!因為在之前我們說明了兩個實體之間的關係 data: [bykey] 11, 12 ] } } }, "result": 10//這裡同樣儲存著table實體裡面的索引集合 normalizr(TableSource, table) } |
github上有詳細的官方文件可供查詢,滷煮在此只是簡單的說明一下用法,諸位可以仔細觀察文件上的用法,不需要多少時間就可以熟練掌握。到此為止,萬里長城,終於走完了第一步。
如何將正規化化資料後再次轉化
什麼?好不容易轉化成自己想要的資料結構,還需要再次轉化嗎?很遺憾告訴你,是的。因為正規化化後的資料只便於我們在維護Redux,而介面業務渲染的資料結構往往跟我們處理後的資料是不一樣的,舉個例子:bootstrap或者ant.design的表格渲染資料結構是這個樣的:
1 2 3 4 5 6 7 8 9 10 11 |
const dataSource = [{ key: '1', name: '胡彥斌', age: 32, address: '西湖區湖底公園1號' }, { key: '2', name: '胡彥祖', age: 42, address: '西湖區湖底公園1號' }]; |
因而在介面引用State上的資料時,我們需要一箇中介,把正規化化的資料再次轉化成業務資料結構。我相信這個步驟十分簡單,只需要寫一個簡單的轉換器就行了:
1 2 3 4 5 6 7 8 |
const transform = (source) => { const data = source.entities.bykey; return Object.keys(data).map(v => data[v]); }; const mapStateToProps = (state,ownProps) => ({table: transform(state)}) export default connect(mapStateToProps)(view) |
如果你在mapStateToProps裡面斷點除錯,你會發現每一次dispatch都會強行執行mapStateProps方法保證物件的最新狀態(除非你引用的是基礎型別資料),因此,不管介面的操作是如何,被connect資料都會被強行執行一次,雖然介面沒有變化,但是顯然,js的效能會有折扣,尤其是對深度物件的複雜處理。因此,官方推薦我們建立可記憶的函式高效計算Redux Store裡面的衍生資料。
Reselect方法使用指南
1 2 3 4 5 6 7 8 |
//快取data裡面的索引 const reNormalDataSource = (state, props) => state.app.entities.table['10'].data; //快取bykey裡面對得基礎資料 const reNormal = (state, props) => state.app.entities.bykey; // 快取計算結果 const createNormalTableData = createSelector([reNormalDataSource, reNormal], (keys, source) => keys.map(item => source[item])); //每次當mapStateToProps重新執行時,會儲存上次計算的結果,它只會重新計算變化的資料,其他非相關變化不做計算 const mapStateToProps = (state, own) => ({source: createNormalTableData(state)}); |
我在這裡做了個耍了點花樣,你可以看到,我是按照table.data這個陣列來查詢介面業務資料的。這種操作可以使得我們只需要關心table.data這個簡單的一維陣列,在刪除或者新增一條資料的時候顯得尤為有用。
我們最後為了計算state,引用了dot-prop-immutable模組,他是immutable的擴充套件,對於資料計算非常高效。我接著使用了另外一個dot-prop-immutable-chain模組,它增加了dot-prop-immutable的鏈式用法。關於dot-prop-immutable的用法滷煮不再詳細說明,在後面給出的例子中一眼就能看明白,而且官方文件上也有詳細說明。下面我們通過一個表格增刪查改來實際展示我們這次的解決方案。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import {normalize, schema} from 'normalizr'; import dotProp from 'dot-prop-immutable-chain'; const reducer = (state = normalizedData, action) => { switch(action.type) { //修改一條資料 case 'EDITOR': return dotProp(state).set(`entities.bykey.${action.key}.age`, action.age).value(); //新增一條資料 case 'ADD': const newId = UID++; return dotProp(state).set(`entities.bykey.${newId}`, Object.assign({}, model, {key: newId}))//新增一條新資料 .merge(`entities.table.10.data`, [newId]).value();//新資料的data中的引用 //刪除一條資料 case 'DELETE': const index = state.entities.table['10'].data.indexOf(Number(action.key)); //可以看到,由於我們介面資料是根據data裡面的項來決定的,因此我們只需要處理data這個簡單的一維陣列,而這顯然要容易維護得多 return dotProp(state).delete(`entities.table.10.data.${index}`).value(); } return state; }; |
瞧,我們展示了整個reducer,相信它已經變得容易維護得多了,並且由於使用了正規化化資料結構以及immutable的擴充套件模組,不僅僅提升了計算效能,減少介面的的渲染,而且還符合Redux的State不可修改的原則。
結束語
React+Redux組合在實際應用過程中需要優化的地方還很多,這裡只是簡單展示其中的一個小點。雖然在計算dom介面變化時React已經做得足夠好,但並不意味著我們可以不用為介面渲染問題背鍋,React肩負了多數介面更新計算的任務,而讓前端開發人員更多地去處理資料,因此,我們可以在這裡層多花點時間把專案做好。