说一说基于dva 实现dva-hackernews 的过程。
基本思路是按照 service -> model -> component 的顺序来实现的,好处是可以用真实数据,不用额外写 mock 方法。
通过dva-cli 生成项目初始文件,然后 npm start
启动。
hackernews 数据接口来自 firebase,所以可以直接用 firebase 这个 package 。firebase 基于 websocket 连接实现,除了初次请求慢些,后面的数据加载很快。相比 http 来说,省去不少请求。
为了方便在 effects 里调用,service 方法需要返回 promise 。 watchList
除外,这个不在 effects 里调,而是在 subscriptions 里,用于实时更新列表数据。
先设计数据结构,为了让 reducer 里写得比较容易,所以选择扁平化的方式。即把 item 拎出来,以 id 为 key 统一存放,然后其他地方即可引用 id 。
{ list: { top: [123, 456], new: [123, 456], }, itemsById: { 123: { title: 'foo' }, 456: { title: 'bar' }, 789: { title: 'wow' }, }, }
这样更新 item 就比较简单,反之如果要更新 list.top['123'] 的数据,想想都麻烦。(没用 immutable.js)
然后是完成处理 action 的部分,reducers 和 effects,分别负责 state 更新和异步逻辑。
state 更新的部分写在 reducers 里,没什么特别的,灵活掌握 array 和 object 的各种方法就可以了,注意 array 到 object 的转换可以用 reduce 简化。
'item/saveItems'(state, { payload: itemsArr }) { const items = itemsArr.reduce((memo, item) => { memo[item.id] = item; return memo; }, {}); return { ...state, itemsById: { ...state.itemsById, ...items }}; },
异步逻辑部分,写在 effects 里。通过 generator 组织,所以基本上都是一层缩进下来就完了。
*'item/fetchList'({ payload }) { const { type, page } = payload; yield put({ type: 'app/showLoading' }); const ids = yield call(fetchIdsByType, type); const itemsPerPage = yield select(state => state.item.itemsPerPage); const items = yield call( fetchItems, ids.slice(itemsPerPage * (page - 1), itemsPerPage * page) ); yield put({ type: 'item/saveList', payload: { ids, type } }); yield put({ type: 'item/saveItems', payload: items }); yield put({ type: 'app/hideLoading' }); },
为了实时性,切换页面不管 item 是否有缓存,都会重新请求一遍。
评论数据是递归获取的,因为不知道有几层。还好是 websocket,如果换成 http 的实现应该会很慢。虽然是比较快,但在评论页面也能明显感觉到是一层层更新出来的。
定义完所有 action 的处理,接下来要看如何调用他们。基本上就两个地方,subscriptions 和 component 。
而初始数据加载实际上是订阅了 history 的变更,待满足 url 匹配时,触发 action 加载远程数据。这些逻辑不放 route component 还有好处是可以更好地配合 hmr,同时让 route component 保持 stateless component 的写法。
由于 react-router 的限制,这里需使用path-to-regexp 库来解决 url 匹配的问题。
history.listen(({ pathname }, { params }) => { if (pathToRegexp(`/item/:itemId`).test(pathname)) { dispatch({ type: 'item/fetchComments', payload: params.itemId, }); } });
当用户进入 item 页面时,通过 action item/fetchComments
获取评论数据。
同上,实时更新也写在 subscriptions 里,等于是订阅了 list 的数据源。有更新时,保存新的 id,然后重新加载本页数据。
watchList(type, ids => { dispatch({ type: 'item/saveList', payload: { type, ids }, }); dispatch({ type: 'item/fetchList', payload: { type, page, }, }); });
由于我们的数据是扁平化的,不能直接交由 component 渲染,需要一层 selector 。比如我想要 top 下第 1 页的列表。
export function listSelector(state, ownProps) { const page = parseInt(ownProps.params.page || 1, 10); const { itemsPerPage, activeType, lists, itemsById } = state.item; const ids = lists[activeType].slice(itemsPerPage * (page - 1), itemsPerPage * page); const items = ids.reduce((memo, id) => { if (itemsById[id]) memo.push(itemsById[id]); return memo; }, []); const maxPage = Math.ceil(lists[activeType].length / itemsPerPage); return { items, page, maxPage, activeType, }; }
写完 model 层,感到一阵轻松,剩下的基本不费脑了。
动画没有用上 react-motion,而是基于 ReactCSSTransitionGroup 实现,方法和 vue 以及 angular 都类似。动效可以上 nganimate 找一个喜欢的样式过来用。
<ReactCSSTransitionGroup transitionName="item" transitionEnterTimeout={500} transitionLeaveTimeout={500} > { items.map(item => <Item key={item.id} item={item} />) } </ReactCSSTransitionGroup>
以上是实现 hackernews 一些经验。先写什么并不重要,主要是要有分层的概念,可以先写 model,也可以先写 component 。dva 借鉴 elm 的概念整合了 reducers, effects 和 subscriptions 到 model,让分层更清晰,并让各种觉得的代码有所归属。希望大家能动手实践一把,会发现相比现有 redux 方法的优势。