React实现的超高仿豆瓣电影

8,214 阅读7分钟

关于

先贴上地址,喜欢可以先 star 一波 😳

在线预览地址: http://118.24.21.99:5000/ (加载时间略长)

GitHub仓库地址: douban-movie-react

(在线预览在电影页有些静态资源加载不到应该是 Nginx 配置的问题,想获取最佳体验可以 clone 到本地,运行方法见 GitHub 文档)

nginx 开启 gzip 后加载速度已明显提升。。。


基于 React 的超高仿豆瓣电影 PC 版,实现了 主页,电影页,人物页,排行榜,短评页,长评页,影讯&购票页,分类页,排行榜页,搜索页,404 页。

技术栈

  • react
  • redux
  • redux-thunk
  • react-router
  • react-slick
  • antd
  • scss
  • css-modules

store vs. state

本项目有一个很大的()缺点就是所有向 API 请求的数据都存在了 redux store 中,并且是每个组件发出一个对应内容的请求(写到一半发现这样写不好,但是后面懒得改了,逃),直接导致了要写的样本代码量增加。实际上这些数据应该放在各个组件的 react state 或者由一个高阶组件完成多个请求的发起,再将数据传递给各个木偶组件,redux 的文档中讨论了究竟什么样的数据适合放在 store 而不是放在 state 中,有以下几点原则:

Do other parts of the application care about this data?

不会,都只是一些固定的数据展示,数据之间不会有交互。

Do you need to be able to create further derived data based on this original data?

不会,只是一些固定的数据展示。

Is the same data being used to drive multiple components?

不会,豆瓣提供的各个 API,很多数据都是重叠的,比如获取某电影的的评论的数据,返回值中不光会有评论的一个 array,还会包含这个电影的一个 subject 的数据,不需要通过 redux 来存储评论所在的电影的数据来展示到评论页上。

Is there value to you in being able to restore this state to a given point in time (ie, time travel debugging)?

不会,没有什么 undo, redo 一类的的操作。

Do you want to cache the data (ie, use what's in state if it's already there instead of re-requesting it)?

如果说有必要将数据放在 redux store 中的话,勉强符合条件的就是这点了,我们可以将浏览过的电影页面的数据给缓存起来(毕竟一个电影的信息在浏览期间几乎不可能变动)

过多的样板代码

页面上每个组件的内容都是通过请求后端获得的,如果每个组件都 将发请求、创建各种action,reducer什么的写一遍,样本代码量就有些太大了,再加上基本每个组件都是单纯的展示组件,逻辑都是相似的,所以项目里将重复的代码抽象出来。

这是store的结构(我自己也很想吐槽):

每个展示组件不相同的部分是 pageName, moduleName(用于组织 redux store), API(不同的展示组件请求不同的 URI), view(每个组件用来展示的木偶组件),param(好多 URI 是需要通过路由里的参数来拼接的,比如请求 /subject/26430636 对应的 API 是 /subject/:id)。所以,每次在创建一个新的组件时只需要这样:

export default  viewGenerator(
  {
    pageName,
    moduleName,
    API: API_CELEBRITY,
    view: Celebrity
  }
)

// 再传入路由中的query对和对应的值即可
<Celebrity id={id}
  params={{
    id: this.props.match.params.id,
  }}
/>

原理也简单,利用 pageName, moduleName 通过 actionCreator 生成对应的发起请求的函数,和 redux store 中对应的的 state 作为 mapStateToPropsmapDispatchToProps 去 connect 出 HOC,这个 HOC 只起一个Decorator 的作用,完成这些展示组件相同的数据请求逻辑 —— 根据传入的 props 来 fetchByParam(params) (API 被柯里化掉了,因为也不会变,所以只需要提供 params 即可),这个 HOC 会在 constructor 中会调用传进来的请求函数,再 render 它要包裹的木偶组件,将数据逻辑与界面分离,具体的代码可以查看utils/fetchGenerator

至此,每次生成一个新组建只需要写这个组件对应的木偶组件即可,生成组件的流程是 viewGenerator -> viewDecorator -> (木偶)viewviewGenerator 负责生成对应的请求函数和 state, viewDecorator 负责套用执行数据逻辑,完成后将数据传递给 view 并渲染 view

在 reducer 中也需要类似操作:

let contentReducer = reducerGenerator(
  {
    pageName,
    moduleName
  }
)

即可生成对应的 reducer,也允许传入自定义的 reducer,会覆盖掉相同 action.type 的 reducer,比如在 tag 页加载更多数据的时候,新的 reducer 就是要 concat 新请求到的数据而不是替换,传入的自定义的 reducer:

[SUCCESS_ACTION]: (state, action) => {
  let payload = action.doesPushBack ?
  pushPayload(state.payload, action.payload) :
  action.payload
  return {
    ...state,
    isLoading: false,
    payload
  }
}

缓存

有些页其实加载过一次之后其实没必要再重新请求一次了(比如电影页),在这里本来可以用 redux-presist 但是试着自己写了个更简单粗暴(简陋)的 middleware,就是直接根据 URI 请求到的数据来缓存,如果想缓存某个URI 的返回值就直接在请求成功的那个 action 那里确定 cacheKeycacheValue,再在发送请求前加上isCached 的判断即可,如果被缓存了就无需再次发送请求,直接去 caches 里去拿到缓存,这里所有缓存都是存储在内存里而不是 localStorage 中,整个 middleware 代码如下:

const caches = {}

const hasCachedKey = (cacheKey) => {
  return Object.keys(caches).indexOf(cacheKey) >= 0
}

const cacheMiddleware = store => next => action => {
  if (typeof action.cacheKey === 'undefined' ||
    typeof action.cacheValue === 'undefined') {
    next(action)
    return
  }

  if (!hasCachedKey(action.cacheKey)) {
    caches[action.cacheKey] = action.cacheValue
  }
  next(action)
}

const getCache = (cacheKey) => {
  if (hasCachedKey) {
    return caches[cacheKey]
  }
}

const isCached = (cacheKey, cacheDetector = hasCachedKey) => {
  return hasCachedKey(cacheKey)
}

export { cacheMiddleware, isCached, getCache }

其他

TODO:

  • 首屏加载过慢(卧槽足足500k。。。),对根据路由的 code splitting 意义不大,主要是各种第三方库太TM大了,虽然已经按需加载了,还是需要优化。
  • 服务端渲染
  • 适配移动端

预览

主页

两个轮播图 + 一个自定义的 List,轮播图用的是 react-slick,那个页数指示器 react-slick 没有提供,在父组件的 state 中定义页数,将 arrow-prevarrow-next 的 onClick 用来 setState 页数即可。鼠标悬浮的预览类似于 modal,位置可能超过父组件的范围,是通过 createProtal 来实现的,加在了 document.body 上,通过getBoundingBox 让父组件给他传递要渲染的位置。

电影页

人物页

排行榜

短评页

短评页的内容是通过解析路由 query 中的 startcount 去获取数据的,封装了一个 pagination 组件来改变路由的 query 即可做到切换上下页

长评页

同短评页,多了一点,为了实现和豆瓣一样的展开长内容后,收起栏 fix 在底部的效果,用 getBoundingBox 来判断当前展开的长评是否应该 fix 收起栏 —— !this.props.isFold && contentRect.top <= innerHeight && contentRect.bottom >= innerHeight - barRectHeight,这样来判断即可,需要注意的是直接点击展开时也要进行一次检测,因为不只是需要在滚动时判断。

影讯&购票页

分类页

影视的分类是写死的,点击引起路由改变再引起 param 的改变就会重新发起。加载更多这个按钮发出的请求和初始化的请求的区别就是 URI 中的 start 不同,比如初始化时的请求是 /movie/search?tag=电影&count=20,第一次点击加载更多就是 /movie/search?tag=电影&start=20。既然如此就只需要再发一次请求然后把第二次请求到的数据加上去,和第一次的不同的是需要在点击的时候,即 ACTION.START 时就要在对应的 state 里完成 count += 20 的操作(否则如果连续点击两次,会发出两个相同的请求),然后自定义一个reducer, 将请求到的含有电影数组的数据 concat 到原来的数据后面。

搜索页

偷懒直接用了ant的输入框,但是要记得给输入框的 onChange 函数加个 debounce。

404页

3,2,1,回首页

API

项目中的 API 来源

总结

再一次贴上地址,喜欢可以star 😳

在线预览地址: http://118.24.21.99:5000/

GitHub仓库地址: douban-movie-react

断断续续写了几个月,现在看来依旧写的很渣,希望各位大佬多多提出宝贵意见,欢迎留言讨论。