剛才在看到《騰訊祭出大招VasSonic,讓你的H5頁面首屏秒開!》裡的一句話:
“計算機領域任何一個問題,都可以通過引入中間層來解決。”
D.Va
最開始聽同事說起 dva ,我還以為他守望先鋒玩魔怔了 —— 後來才知道他說的 dva 是螞蟻金服 antd 元件庫的御用輕量級框架。不過那個時候我們公司用的還是基於 redux-thunk 體系的腳手架,多少是和 dva 有點差別。所以很長一段時間內,我只是知道有這麼個東西而已。
最近開始用它做專案,上手有點彆扭。
作為一個兼職前端(好像現在也不算兼職了),我的知識體系還是比較混亂,總是知其然而不知其所以然。雖然 Angular 和 React 程式碼寫的也算不少了,寫來寫去總缺少臨門一腳的頓悟,最後還是一團漿糊。
前幾天翻譯了一篇講 Android 函式式響應型程式設計的文章,突然間就理解了 dva 編碼思想。再回頭審視用 dva 寫的程式碼,發現那篇文章裡講解的很多理論和 dva 不謀而合,終於有種摸到大門的欣慰。
說起來也滿狗血的。
思維盲區
我最開始學習使用 dva 是從《12 步 30 分鐘,完成使用者管理的 CURD 應用》開始的,這同時也是 dva 的官方教程。然而因為領悟能力太差,最開始完全沒理解。前 4 步還跟得上,第 5 步建立 model 和改造 service 就懵逼了。硬著頭皮照抄程式碼,抄到最後資料沒出來,我還不知道自己哪兒錯了。
大寫的尷尬。
現在再看這篇教程,發現從第 5 步的 model 開始,dva 的作者就試圖推廣一種最近流行的理念:響應型程式設計。
就目前來說,渲染資料流有至少兩種方式。由外界改變元件內部狀態的主動型,以及由元件監聽外界改變而渲染自身的響應型。很多人 —— 尤其是 oop 重度患者 —— 的慣性思維是第一種。無論把負責業務的 service 類扔的多遠,service 和 controller 都是直接連線的。
以請求資料為例,我還停留在拿取資料推送到元件中進行渲染的階段。有那麼一段時間,我對 container 和 component 之間的值交換還侷限在屬性傳值和回撥函式層面上。雖然回撥函式實現了一定程度上的事件響應,但元件之間仍舊脫離不開互相直連的主動型編碼的怪圈。
吃火鍋的正確姿勢
舉一個吃火鍋的例子來解釋主動型程式設計和響應型程式設計的差別:
一般情況下,吃火鍋的時候都是點了菜和肉擺在自己臉跟前,想吃什麼自己夾了往鍋裡扔,看看快熟了就撈出來吃掉。
這是主動型。
但是這麼吃火鍋有三個問題:
第一,一切都要親力親為。想吃肉就要親自把肉放到鍋裡去,想吃菜也要親自把菜放進去。如果還想吃豆腐、蘑菇、粉條、羊尾魚丸…想吃的東西越多,操作就越複雜。
第二,既然是親自放東西,就得把東西擺在自己面前。桌子一共就那麼點兒地方,想吃的東西越多佔用的空間就越大。既不容易留出足夠的空間吃燙熟的茼蒿,也不容易把想吃的牛肉片從眼前一大堆的蔬菜裡挑出來。萬一要換桌,還得的把這一大堆吃的一起打包帶走,漏掉一樣就吃不到。
第三,如果我想吃撒尿牛丸和蝦滑、魷魚,旁邊的哥們海鮮過敏。是應該我負責往鍋裡放然後燙熟撈出來,他從我這裡撈他能吃的;還是我倆各放各的,自己撈自己想吃的東西?前者雖然一個人做了共同的事情,但是別人一起吃的時候難免會撈錯;後者雖然看起來互不干擾,但是兩個人都在燙牛丸,多少是浪費。萬一是個鴛鴦鍋我還不吃辣怎麼辦?燙到最後全亂套了。
用程式設計的術語說,便是:低內聚,高耦合。
或許正統的 oop 語言(比如 Java)可以用封裝、繼承、多型來某種程度的緩解這個問題(僅僅是某種程度上),但是 JavaScript 想從語言的角度實現就會無比操蛋(JavaScript 用 prototype 模擬 oop 實現,而 es6 裡的 class 和 Java 裡的 class 又完全不是一個東西)。
現在我們換種方式吃火鍋:分出一個人來啥也不吃,把所有吃的都放在他面前。想吃蘑菇就對他說一聲,讓他替你把蘑菇放進火鍋燙熟,替你把熟蘑菇放進蘸料碟裡。
你唯一要做的事情就是吼一嗓子,然後從自己的蘸料碟裡夾蘑菇,吃。
哎呀,這個就太爽了。
想吃豬腦,“來盤豬腦”;想吃鴨血,“來盤鴨血”;想吃 10 盤地瓜片就大喊“來10盤地瓜片”,用不著自己費事一盤一盤的往鍋裡倒。
而且既然食材都堆在另一個地方,自己面前留一個吃東西的蘸料碟就夠用,十干淨整潔。桌子隨便換,揮揮衣袖帶雙筷子走就可以了。
一群人組團吃,大家各點各的吃互不干擾。燙火鍋的也始終只有一個人,既不會造成資源浪費,又不必讓其他人關心額外的東西。
這就是所謂的響應型編碼。
被分出去的那個人,在 React 體系裡就是 Redux(或者相同功能的庫);具體到 dva 框架中,就是 model。
(理想情況下)所有的元件只和 model 連線,互相之間完全沒有直接交集,這便是響應型編碼思想在 dva 框架中的體現。
dva 中的響應型編碼
有了響應型編碼的理論以後,我很容易的就理解第 5 步的操作。
此時我的情況是:
- 可以通過 http://localhost:8000/user 訪問到 user
- 可以通過 http://localhost:8000/api/users 訪問到 user 資料
通過 dva g model user
可以很方便的建立 model/user.js
並註冊進 index.js
中(命令列萬歲!) ,雖然目前還什麼都沒有:
我需要做的事情就是把資料從 api/user
介面拉下來,渲染進 route/user 裡(component 可以等等再談)。
把大象...我是說資料渲染進 route/user 需要三步:
- 編寫請求介面的方法
- 使用 1 的方法獲得資料
- 將 2 資料渲染進頁面
編寫請求介面的方法
dva 的新手大禮包裡已經提供了基礎的網路請求函式 utils/resquest.js
,雖然大多數情況下都會對其進行一些擴充套件才能滿足現實專案的需求,但是就目前來說暫且是夠用的。
以 oop 觀點來看,utils/resquest.js
相當於專案所有請求函式的基類(base class)。如果需要進行具體業務的編寫,應該新建一個繼承 utils/resquest.js
的子類。但 JavaScript 不算是純種 oop 的語言,所以慣例都是新建一個具體的業務類 services/user.js
,通過在 services/user.js
中 import
的方式呼叫 utils/resquest.js
。
// 在 services 目錄下新建 services/user.js,負責具體的 user 業務
import request from '../utils/request';
export function getUserData() { // 偷懶,暫時把 example.js 的程式碼拷貝過來
return request('api/users'); // 這裡是一個 promise 物件
}複製程式碼
實際上這個時候如果直接把請求函式寫在 route/user.js
裡已經可以渲染頁面了。
// 這是一個錯誤的示範
import React, { Component, PropTypes } from 'react';
import * as userService from '../services/user';
class User extends Component {
static propTypes = {
className: PropTypes.string,
};
constructor(props) {
super(props);
this.state = {
list : []
}
}
componentDidMount() {
this.getData();
}
getData = () => {
userService.getUserData().then((res) => {
this.setState({
list: res.data
});
})
}
buildContent = () => {
const {list} = this.state;
return list.map( (itm, index) => {
return <div key={index}>{itm.name}</div>
})
}
render() {
return (
<div>
{this.buildContent()}
</div>
);
}
}
export default User;複製程式碼
這明顯是主動型程式設計寫法,和 dva 的響應型理念背道而馳。也許簡單或者低互動度的介面這麼寫起來會很省事,但是可擴充套件性接近於零。一旦複雜度和互動度提升,元件的會變得越來越複雜,最後變成一個巨大的坑。
在 model 中使用 services 函式並獲得資料
有了 services/user.js
函式,可以進行具體的請求動作,在 model/user.js
請求資料了。
應該寫在 model/user.js
哪裡呢?
這裡可能又要多說一點所謂純函式
的概念,即對於給定的輸入有唯一不變的輸出並不含任何明顯可見的副作用(side effects)的函式
(可參考這篇英文文章或者中文版)。
請求網路資料自帶副作用屬性(非同步操作),而副作用(side effect)看起來確實和 model/user.js
裡的某個屬性有點相似...
dva 的官方說法是:
真實情況最常見的副作用就是非同步操作,所以 dva 提供了 effects 來專門放置副作用,不過可以發現的是,由於 effects 使用了 Generator Creator,所以將非同步操作同步化,也是純函式。
dva 負責處理非同步的是封裝後的 redux-saga 模組。也就是說,需要使用 call
方法。所以 dva 的請求資料套路是這樣的:
effects: {
*getData(action, { call, put }) { // 第一個引數是 dispatch 所發出的 action,第二個引數是 dva 封裝的 saga 函式。可以直接用 es 6 的解構風格拿取所需要的具體函式。
const temp = yield call(userService.getUserData, {}); // 因為現在沒有引數
console.log('temp', temp); // 純函式中不應有副作用(把資料轉移到控制檯也算副作用),這裡只是方便在 chrome 裡檢視,
}
},複製程式碼
寫完了?並沒有。
讚美太陽...呸!dispatch!
我眼中 dva 裡 dispatch-atcion 與 model/effect 的原理有點像 Android 四大元件之一的廣播:
- 通過 dispatch 函式發出一個包含 type 屬性(此為必須)的 action。
- dva 監聽到 action 後會根據 action 的 type 值尋找對應 model 的 effect 的方法(action.type 的值就是 effects 裡的方法名,在 model 外 dispatch 需要使用
modelName/effectsMethodName
的格式) - 找到方法後就呼叫方法,把 action 作為引數放在第一個位置。
使用 dispatch 的好處是顯而易見的:切分業務模組。
元件不必再負責具體的業務操作(自己動手涮肉),只需要 dispatch action (大喊一聲) 到對應的 model 裡(給那個負責上菜的人)。
需要使用者列表資料的元件未必只有 route/user.js
,其他需要資料的元件可以在自己裡面 dispatch action。
同時 model/user.js
的 getData 方法是獨一份,你 dispatch 多少 type 為user/getData
(如果在 model 內 dispatch 可以省略字首)的 action 都得歸到我這來處理。
高內聚(業務處理集中),低耦合( 隨時隨地隨便哪個元件隨意姿勢 dispatch)。
官方教程中給出的做法是在 model 裡的訂閱部分 subscriptions
寫一個監聽,根據監聽到具體的事件(進入 /user 頁面)進行特定操作(dispatch action)。
subscriptions: {
setup({ dispatch, history }) { // eslint-disable-line
return history.listen( ({pathname, query}) => {
if(pathname === '/user') {
dispatch({
type: 'getData',
payload: {
txt: 'hello, dva'
}
})
}
})
},
},複製程式碼
這麼做同樣也是進一步切離業務,不必把 dispatch 寫在具體元件的生命週期中,減少元件的複雜程度(其實關鍵還是 dispatch ,訂閱說到底也是為 dispatch 服務的)。
現在應該可以看到輸出後的資料了。
渲染資料
雖然現在拿到了資料,但是資料還憋在 model/effects 裡和 route/user.js
沒什麼關係,總的想個辦法把資料和元件關聯起來。
是時候讓 dva 的 state 出場了。
我理解的 dva 中 model 內的 state 屬性,實際上是封裝後的 Redux 全域性 store 的一部分。通過不重複的 namespace(桌號) 確定 store(餐館) 中唯一的 model(餐桌),把 model/effects 請求到的原始資料(生食)放進 model/reducer (特定的火鍋)裡進行必要的處理(燙熟),再放進 model/state (蘸料碟)裡,route/user.js
只需要從這裡拿取所需要的資料(吃的)就可以了。
從 effects 裡往 reducer 裡傳遞資料使用的是 saga 的put 方法,引數同樣也是一個 action 物件,action 中必須包含的 type 屬性的值就是 reducer 屬性裡的方法名:
import * as userService from '../services/user';
export default {
namespace: 'user',
state: {},
reducers: {
dealData(state, action) {
// 理論上 reducer 裡的函式應該是純函式,此處只是為了方便在控制檯裡看引數
console.log('state==>', state);
console.log('action==>', action);
return { ...state }
}
},
effects: {
*getData(action, { call, put }) {
const temp = yield call(userService.getUserData, {});
yield put({
type: 'dealData',
payload: {
temp
}
});
}
},
subscriptions: {
setup({ dispatch, history }) { // eslint-disable-line
return history.listen( ({pathname, query}) => {
if(pathname === '/user') {
dispatch({
type: 'getData',
payload: {
txt: 'hello, dva'
}
})
}
})
},
},
};複製程式碼
剩下的做法就是在 model/user.js
的 state 屬性裡定義一個屬性並賦值了。
state: {
dataList: []
},
reducers: {
dealData(state,
{ payload: { temp: { data: dataList } } }
// action
// { payload: { temp: { data: dataList } }}
// 是 es 6 的解構做法,等同於
// const {payload} = action;
// const {temp} = payload;
// const {data} = temp;
// const dataList = data;
) {
return { ...state, dataList }; // 必須有返回值(純函式必須有返回值),否則會報錯
// 經評論提醒 修改
// 等同於
// let tmp = Object.assign([], this.state)
// tmp.dataList = dataList
}
},複製程式碼
現在需要的資料已經掛在 model/user.js
的 state 屬性裡了,最後一步便是在 route/user.js
裡使用 connect
和 mapStateToProps
讓元件監聽資料來源,實現響應型編碼了。
import React from 'react';
import { connect } from 'dva'; // 0.關鍵的 connect
import styles from './User.css';
import * as userService from '../services/user';
function User({ dataList }) { // 5. 這裡的屬性就是 3 裡的返回值
return (
<div className={styles.normal}>
{
!!dataList.length && dataList.map((data, index) => {
return <div key={index}>{data.name}</div>
})
}
</div>
);
}
function mapStateToProps(store) { // 1關鍵的 mapStateToProps
const { dataList } = store.user; // 2.從 model/user.js 拿取需要的資料
return { dataList }; // 3.將資料作為屬性返回
}
export default connect(mapStateToProps)(User); // 4.連線元件複製程式碼
碎碎念
其實往後的程式碼還有蠻多,分頁、封裝、引入 antd 調整樣式。不過都是一些需要花時間慢慢雕琢、順便發發 dispatch 的細節(其實細節也很重要 >_<),至少理解起來比較容易了。
理解第 5 步思路的順序是基於資料流向的,而實際開發中的編寫順序剛好是倒過來:先確定頁面需要的資料,再編寫 model 中的業務,最後把網路介面掛進來。不過現在這麼幹已經心裡有譜,知道怎麼回事了。
可喜可賀。