从 0 开始实现 react 版本的 hackernews (基于 dva)

1,600 阅读4分钟


Live Demo

说一说基于 dva 实现 dva-hackernews 的过程。

基本思路是按照 service -> model -> component 的顺序来实现的,好处是可以用真实数据,不用额外写 mock 方法。

脚手架

通过 dva-cli 生成项目初始文件,然后 npm start 启动。

Service

hackernews 数据接口来自 firebase,所以可以直接用 firebase 这个 package 。firebase 基于 websocket 连接实现,除了初次请求慢些,后面的数据加载很快。相比 http 来说,省去不少请求。

为了方便在 effects 里调用,service 方法需要返回 promise 。watchList 除外,这个不在 effects 里调,而是在 subscriptions 里,用于实时更新列表数据。

Model

写 model 层是脑力劳动,而写 component 层是体力劳动。

数据结构

先设计数据结构,为了让 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)

state 更新

然后是完成处理 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 。

初始数据请求

subscription 意为订阅,用于数据源的订阅。

而初始数据加载实际上是订阅了 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,
    },
  });
});

selector

由于我们的数据是扁平化的,不能直接交由 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,
  };
}

Component

写完 model 层,感到一阵轻松,剩下的基本不费脑了。

动画

动画没有用上 react-motion,而是基于 ReactCSSTransitionGroup 实现,方法和 vue 以及 angular 都类似。动效可以上 nganimate 找一个喜欢的样式过来用。


  {
    items.map(item => )
  }

总结

以上是实现 hackernews 一些经验。先写什么并不重要,主要是要有分层的概念,可以先写 model,也可以先写 component 。dva 借鉴 elm 的概念整合了 reducers, effects 和 subscriptions 到 model,让分层更清晰,并让各种觉得的代码有所归属。希望大家能动手实践一把,会发现相比现有 redux 方法的优势。

More