一个豆瓣电影 MovieDob 网页版

912 阅读7分钟

前言

前面已经写了一个 一个豆瓣电影小程序 的微信小程序;现在这个是 React+Typescript 的网页版,基于 这里 的修改版,antd 换为 antd-mobile

源码在线预览

1、列表 keep-alive

由于 React 没有像 Vue 提供的 <keep-alive></keep-alive> 组件,要实现这个就自己动手来,这个在 这里 已经大概说了一下

1.1 路由的写法

这里的 AuthRoute 是基于官方 Route 的封装;主要就是使用 Route 的 render 方法 渲染列表页,然后详情页是作为 children 挂在列表页下面的

// src/routes/home.tsx
import AuthRoute from '@/routes/auth-route';
import * as React from 'react';
import Loadable from '@loadable/component';

const Home = Loadable(() => import('@/views/home'));
const SearchList = Loadable(() => import('@/views/search-list'));

// home
export default [
  <AuthRoute 
    key="search"
    path="/search"
    render={() => (
      <SearchList>
        <AuthRoute 
          exact={true} 
          path="/search/movie-detail/:id" 
          component={Loadable(() => import('@/views/movie-detail'))} 
        />
      </SearchList>
    )}
  />,
  <AuthRoute 
    key="home" 
    path="/" 
    render={() => (
      <Home>
        <AuthRoute 
          exact={true} 
          path="/movie-detail/:id" 
          component={Loadable(() => import('@/views/movie-detail'))} 
        />
      </Home>
    )}
  />
]

1.2 列表组件的处理

1.2.1 详情页组件

  • 在详情页路由时,隐藏列表页的内容
  • this.props.children 就是上面 <Home> 里面的东西
// src/views/home/index.tsx

  public isDetailPage() {
    return this.props.location.pathname.includes("/movie-detail/");
  }

  public render() {
    const { 
      movieLineStatus, 
      isLoading, 
      movieLine, 
      movieComing, 
      movieTop250, 
      isTop250FullLoaded
    } = this.state;

    return (
      <div className={`${styles.home}`}>
        {!this.isDetailPage() &&
          <HeaderSearch onConfirm={(val) => this.onConfirm(val)} />
        }
        <div 
          className={`${styles['home-content']} center-content`}
          style={{display: this.isDetailPage() ? 'none' : 'block'}}
        >
          <section className={styles['movie-block']}>
            <div className={styles['block-title']}>
              <span className={`${styles['title-item']} ${movieLineStatus === 0 && styles['title-active']}`}
                onClick={() => this.movieStatusChange(0)}
              >院线热映</span>
              <span className={`${styles['title-item']} ${movieLineStatus === 1 && styles['title-active']}`}
                onClick={() => this.movieStatusChange(1)}
              >即将上映</span>
            </div>
    
            {movieLineStatus === 0 ? (
              <MovieItem movieList={movieLine} toDetail={(id: string) => this.toDetail(id)} />
            ) : (
              <MovieItem movieList={movieComing} toDetail={(id: string) => this.toDetail(id)} />
            )}
          </section>
    
          <MovieTop250 
            isLoading={isLoading} 
            movieTop250={movieTop250} 
            toDetail={(id: string) => this.toDetail(id)} 
          />
    
          {isLoading && <Loading />}

          <TopBtn />

          {isTop250FullLoaded && <div className={styles.nomore}>没有更多数据了~</div>}
        </div>
        
        {/* detial */}
        { this.props.children }
      </div>
    )
  }

1.2.2 滚动位置恢复

  • 在列表页路由下,监听滚动事件,保存滚动条位置 scrollTop
  • 进入详情页路由时,移除滚动事件监听
  • 回到列表页面时,恢复滚动条位置
// src/views/home/index.tsx
  constructor(props: IProps) {
    super(props);
    this._onScroll = this._onScroll.bind(this);
  }

  public componentDidMount() {
    this._getMovieLine();
    this._getMovieTop250();
    getMovieTop250All();

    this.props.history.listen(route => {
      this.onRouteChange(route);
    })

    window.addEventListener('scroll', this._onScroll);
  }

  public componentWillUnmount() {
    // 组件销毁后,不操作数据
    this.setState = () => {};
    window.removeEventListener('scroll', this._onScroll);
  }

  // 监听路由变化
  public onRouteChange(route: any) {
    // 首页
    if (route.pathname === '/') {
      const { scrTop } = this.state;
      window.addEventListener('scroll', this._onScroll);
      // 恢复滚动条位置
      this.setScrollTop(scrTop);
    }
    // 详情页
    if (route.pathname.includes("/movie-detail/")) {
      // 重置滚动条位置
      this.setScrollTop(0);
      window.removeEventListener('scroll', this._onScroll);
    }
  }

  // 设置滚动条位置
  public setScrollTop(top: number) {
    document.body.scrollTop = top;
    document.documentElement.scrollTop = top;
  }

  public _onScroll() {
    const winHeight = window.innerHeight;
    const srcollHeight = document.documentElement.scrollHeight;
    const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
    const toBottom = srcollHeight - winHeight - scrollTop;

    if (toBottom <= 200) {
      this._getMovieTop250({ start: this.state.currentPage*10 });
    }
    if (this.props.location.pathname === '/') {
      this.setState({ scrTop: scrollTop });
    } else {
      window.removeEventListener('scroll', this._onScroll);
    }
  }

2、代码预加载 prefetch

webpack v4.6.0+ 的功能,文档

在首页路由,浏览器空闲时下载代码,从首页进入详情页时直接从缓存中读取,没有白屏

使用如:

const Detail = Loadable(() => import(/* webpackPrefetch: true */ '@/views/movie-detail'));

路由:

// src/routes/home.tsx
import AuthRoute from '@/routes/auth-route';
import * as React from 'react';
import Loadable from '@loadable/component';

const Home = Loadable(() => import('@/views/home'));
const SearchList = Loadable(() => import('@/views/search-list'));
const Detail = Loadable(() => import(/* webpackPrefetch: true */ '@/views/movie-detail'));

// home
export default [
  <AuthRoute 
    key="search"
    path="/search"
    render={() => (
      <SearchList>
        <AuthRoute 
          exact={true} 
          path="/search/movie-detail/:id" 
          component={Detail} 
        />
      </SearchList>
    )}
  />,
  <AuthRoute 
    key="home" 
    path="/" 
    render={() => (
      <Home>
        <AuthRoute 
          exact={true} 
          path="/movie-detail/:id" 
          component={Detail} 
        />
      </Home>
    )}
  />
]

3、定位 position: sticky;

根据父元素的内容位置定位,会被限制在 padding 内,可以用 margin 负边距或者 transform 等改变位置;

3.1 回到顶部按钮

父元素有 padding: 10px 20px;,子元素设置 position: sticky; bottom: 0; left: 100%; ,但是会被限制在 padding 的范围内,原来是使用 bottom: 0; rihgt: 0; 的,但是 right: 0; 不起作用。。。所以用 margin-right: -10px 修改一下位置

CSS.supports('position', 'sticky') 可以判断浏览器是否支持 position: sticky;

<TopBtn /> 样式:

// src/components/scrollToTop/scrollToTop.scss
.top-btn {
  width: 50px;
  height: 50px;
  display: flex;
  justify-content: center;
  align-items: center;
  bottom: 20px;
  border-radius: 100px;
  border: 1px solid #eee;
  background: #fff;
  box-shadow: 0 2px 10px -1px rgba(0, 0, 0, 0.1);
  z-index: 9;
  &:active {
    background: #eee;
  }
}
.top-btn-fixed {
  position: fixed;
  right: 20px;
  @extend .top-btn;
}

.top-btn-sticky {
  position: sticky;
  left: 100%;
  margin-right: -10px;
  @extend .top-btn;
}

<TopBtn /> 组件:

// src/components/scrollToTop/index.tsx
import * as React from 'react';
import styles from './scrollToTop.scss';

const { useState, useEffect } = React;

/**
 * scrollToTop
 */
function scrollToTop() {
  const [showBtn, setShowBtn] = useState(false);

  useEffect(() => {
    const height = window.innerHeight;

    // 滚动距离大于一屏高度则显示,否则隐藏
    setShowBtn(() => (
      document.body.scrollTop >= height
      || document.documentElement.scrollTop >= height
    ));
  }, [document.body.scrollTop, document.documentElement.scrollTop]);

  function toTop() {
    if (window.scroll) {
      window.scroll({ top: 0, left: 0, behavior: 'smooth' });
      
    } else {
      document.body.scrollTop = 0;
      document.documentElement.scrollTop = 0;
    }
  }

  return (
    <div 
      className={
        CSS.supports('position', 'sticky') 
          ? styles['top-btn-sticky'] 
          : styles['top-btn-fixed']
      } 
      style={{visibility: showBtn ? 'visible' : 'hidden'}}
      onClick={toTop}
    >
      <i className="iconfont icon-arrow-upward-outline" />
    </div>
  );
}

export default scrollToTop;

4、状态管理 mobx

yarn add mobx mobx-react

相对 redux 来说,mobx 概念少,写法简单使用也简单;类组件使用装饰器,函数组件使用同名函数

  • @observable: 声明数据 state
  • @computed: 计算属性,可以从对象或数组中取出需要的数据
  • @action: 动作函数,可以直接写异步函数
  • runInAction: 注意没有 @,不是装饰器;在 @action 装饰的函数内部修改 state,如下面 setTimeout 内修改数据
  • flow: 返回一个生成器 generator 函数,用 function */yield 代替 async/await(这两个其实是他们的语法糖),不需要使用 @action/runInAction
  • @inject('homeStore'): 将 homeStore 注入到组件
  • @observer: 函数/装饰器可以用来将 React 组件转变成响应式组件。 它用 mobx.autorun 包装了组件的 render 函数以确保任何组件渲染中使用的数据变化时都可以强制刷新组件。observer 是由单独的 mobx-react 包提供的。

其他的配置:

  • 下载插件
    yarn add babel-plugin-transform-decorators-legacy -D
    
  • 然后在 .babelrc: 使用装饰器
    "plugins": ["transform-decorators-legacy"]
    
  • tsconfig.json: 使用装饰器
    "compilerOptions": {
      "experimentalDecorators": true,
    }
    

4.1 项目入口

使用 Provider 包括项目

import { Provider } from 'mobx-react';
import * as React from 'react';
import * as ReactDOM from 'react-dom';

import { Provider } from 'mobx-react';
import store from './store';
import AxiosConfig from './api';
import Router from './router';
import './index.scss';
import registerServiceWorker from './registerServiceWorker'; 

const Loading = () => (<div>loading...</div>);

AxiosConfig(); // 初始化 axios

ReactDOM.render(
  <React.Suspense fallback={<Loading />}>
    <Provider {...store}>
      <Router />
    </Provider>
  </React.Suspense>,
  document.getElementById('root') as HTMLElement
);

registerServiceWorker();

4.2 模块

// src/store/home.ts
import * as mobx from 'mobx';

// 禁止在 action 外直接修改 state 
mobx.configure({ enforceActions: "observed"});
const { observable, action, computed, runInAction } = mobx;

let cache = sessionStorage.getItem('homeStore');

// 初始化数据
let initialState = {
  count: 0,
  data: {
    time: '2019-11-08'
  },
};

// 缓存数据
if (cache) {
  initialState = {
    ...initialState,
    ...JSON.parse(cache)
  }
}

class Home {
  @observable
  public count = initialState.count;

  @observable
  public data = initialState.data;

  @computed
  public get getTime() {
    return this.data.time;
  }

  @action
  public setCount = (_count: number) => {
    this.count = _count;
  }

  @action
  public setCountAsync = (_count: number) => {
    setTimeout(() => {
      runInAction(() => {
        this.count = _count;
      })
    }, 1000);
  }

  // public setCountFlow = flow(function *(_count: number) {
  //   yield setTimeout(() => {}, 1000);
  //   this.count = _count;
  // })
}

const homeStore = new Home();

mobx.spy((event) => {
  // 数据变化后触发,数据缓存
  if (event.type === 'reaction') {
    const obj = mobx.toJS(homeStore);
    sessionStorage.setItem('homeStore', JSON.stringify(obj));
  }
})

export type homeStoreType = typeof homeStore;
export default homeStore;

4.3 缓存

这里使用 sessionStorage,改为其他随意

数据缓存的时候,可以根据需要,匹配某些 key 去缓存,而不是所有数据;

  • 初始化数据

    数据初始化时,如果缓存中有数据,则使用缓存的数据覆盖默认数据

    let cache = sessionStorage.getItem('homeStore');
    
    // 初始化数据
    let initialState = {
      count: 0,
      data: {
        time: '2019-11-08'
      },
    };
    
    // 缓存数据
    if (cache) {
      initialState = {
        ...initialState,
        ...JSON.parse(cache)
      }
    }
    
  • 监听数据变化

    监听数据变化,在 reaction 后,将 homeStore 转化为 js 对象(只包含 state ),然后存到缓存中

    const homeStore = new Home();
    
    mobx.spy((event) => {
      // 数据变化后触发,数据缓存
      if (event.type === 'reaction') {
        const obj = mobx.toJS(homeStore);
        sessionStorage.setItem('homeStore', JSON.stringify(obj));
      }
    })
    

4.4 模块管理输出

// src/store/index.ts
import homeStore from './home';

/**
 * 使用 mobx 状态管理
 */
export default {
  homeStore
}

4.5 组件使用

使用装饰器在 class 上就可以了, inject 注入对应模块,可以多次 inject

注意 @inject('homeStore') @observer 这两个的顺序,不然会有警告

// src/views/home/index.tsx
import { observer, inject } from 'mobx-react';
import { homeStoreType } from '@/store/home';
...

interface IProps extends RouteComponentProps {
  history: History,
  homeStore: homeStoreType
}

@inject('homeStore')
@observer
class Home extends React.Component<IProps> {
  ...
  
  public componentDidMount() {

    this.props.homeStore.setCount(2);
    console.log(this.props.homeStore.count); // 2
    
  }

  ...
}

最后

其他的没什么,项目本身也不复杂;框架用的是之前搭的 React+Typescript+antd-mobile,axios/css-modules/sass 等等这些都是标配啦;东西不多,原来的是 antd 这个是移动端所以换成 antd-mobile