05.简书项目实战二:首页开发

简书项目实战二:首页开发

本文是 React 的仿写简书项目开发学习笔记。代码仓库:https://github.com/shijuhong/jianshu

1. React 路由

路由就是根据 url 的不同来显示不同的内容。

安装 react 路由

yarn add react-router-dom

创建 pages 文件夹来放页面。然后创建两个文件夹,见名知意,Home 文件夹和 Detail 文件夹。然后写最基础的类组件。

现在想要的效果是,根据不同的 url 来分别加载 Home 和 Detail 组件。

// src/App.js
function App() {
  return (
    <Provider store={store}>
      <Header />
      <BrowserRouter>
        <Routes>
          <Route path="/" exact element={<Home />}></Route>
          <Route path="/detail" exact element={<Detail />}></Route>
        </Routes>
      </BrowserRouter>
    </Provider>
  );
}

注意添加 exact,只有 url 完全对上了才会对相应的组件进行加载。否则,/detail 对于 Home 来说,也是可以匹配上的,没有 exact 就会加载两个组件。

效果如下:

2. 首页组件的拆分

去简书看一下规划,可以看到首页有两栏,所以用 HomeLeft 和 HomeRight 拆分,然后 HomeWrapper 包裹 HomeLeft 和 HomeRight。

前端组件化的意义:如果把所有的内容写在 index.js 文件里,内容将会特别多。一个大的页面拆分成一个个的小部分,每个部分都是一个组件,组件自己维护自己的内容和交互逻辑。

组件拆分的粒度需要把控好!

拆分后,左侧除了图片,还有专题推荐 Topic 和文章列表 List,右侧有热门推荐 Recommend 和推荐作者 Writer。

export default class Home extends Component {
  render() {
    return (
      <HomeWrapper>
        <HomeLeft>
          <img
            className="banner-img"
            src="https://upload.jianshu.io/admin_banners/web_images/5055/348f9e194f4062a17f587e2963b7feb0b0a5a982.png?imageMogr2/auto-orient/strip|imageView2/1/w/1250/h/540"
            alt=""
          />
          <Topic />
          <List />
        </HomeLeft>
        <HomeRight>
          <Recommend />
          <Writer />
        </HomeRight>
      </HomeWrapper>
    );
  }
}

3. 首页专题 Topic 区域布局及 reducer 的设计

代码结构如下:

export default class Topic extends Component {
  render() {
    return (
      <TopicWrapper>
        <TopicItem>
          <img
            className="topic-pic"
            src="xxx"
            alt=""
          />
          社会热点
        </TopicItem>
......

每个 TopicItem 代表每个专题,里边包裹图片和专题名称。

但是图片的 src 和专题名称都不是写死的,所以需要使用 ajax 获取数据,用循环来生成 TopicItem,数据用 store 进行管理。

// Home/components/Topic.js
      <TopicWrapper>
        {this.props.topicList.map((item) => (
          <TopicItem key={item.get("id")}>
            <img className="topic-pic" src={item.get("imgUrl")} alt="" />
            {item.get("title")}
          </TopicItem>
        ))}
      </TopicWrapper>

显示效果:

4. 首页文章列表 List 制作

同样是用 redux 管理数据,然后用 map 来遍历渲染。

代码结构如下:

// components/List.js
      <Fragment>
        {this.props.articleList.map((item) => (
          <ListItem key={item.get("id")}>
            <img className="pic" src={item.get("imgUrl")} alt="" />
            <ListInfo>
              <h3 className="title">{item.get("title")}</h3>
              <p className="desc">{item.get("desc")}</p>
            </ListInfo>
          </ListItem>
        ))}
      </Fragment>

显示效果:

图片没显示是因为没权限获取简书上的图片。后面自己找些图片换一下就行,详细文字离图片也太近了,稍微改改。

5. 首页推荐部分 Recommend 代码编写

代码结构:

// components/Recommend.js
class Recommend extends Component {
  render() {
    const { recommendList } = this.props;
    return (
      <RecommendWrapper>
        {recommendList.map((item) => (
          <RecommendItem key={item.get("id")} imgUrl={item.get("imgUrl")} />
        ))}
      </RecommendWrapper>
    );
  }
}

显示效果:

难点:

因为每个组件对应了不同 url 的背景图片,因此需要将 url 传入 RecommendItem 组件。

styled-components 接收参数的方法见下面的代码:

// Home/style.js
export const RecommendItem = styled.div`
  width: 280px;
  height: 50px;
  background: url(${(props) => props.imgUrl});
  background-size: contain;
`;

6. 推荐作者 Writer 模块编写

代码结构:

class Writer extends Component {
  render() {
    const { writerList } = this.props;
    return (
      <WriterWrapper>
        {writerList.map((item) => (
          <WriterItem key={item.get("id")}>
            <img className="pic" src={item.get("imgUrl")} alt="" />
            <div className="follow">关注</div>
            <span>{item.get("name")}</span>
            <p>
              写了{item.get("wordCount")}k字 · {item.get("likeCount")}k喜欢
            </p>
          </WriterItem>
        ))}
      </WriterWrapper>
    );
  }
}

显示效果:

没啥好说的,套路和上面的都一样。

7. 首页异步数据获取

上面的数据,需要从后端获得。一样的,咱们自己造假数据来创建接口。

在 public 文件夹里添加 home.json 文件,里面提供 ajax 返回的数据。在加载首页挂载成功的时候获取到数据并 dispatch action 来更新 store 里的 state 即可。

// Home/index.js
  componentDidMount() {
    const { changeHomeData } = this.props;
    axios.get("/api/home.json").then((res) => {
      const result = res.data.data;
      const action = {
        type: "change_home_data",
        topicList: result.topicList,
        articleList: result.articleList,
        recommendList: result.recommendList,
        writerList: result.writerList,
      };
      changeHomeData(action);
    });
  }

const mapDispatch = (dispatch) => ({
  changeHomeDate(action) {
    dispatch(action);
  },
});

export default connect(null, mapDispatch)(Home);
// Home/store/reducer.js
const reducer = (state = defaultState, action) => {
  switch (action.type) {
    case "change_home_data":
      return state.merge({
        topicList: fromJS(action.topicList),
        articleList: fromJS(action.articleList),
        recommendList: fromJS(action.recommendList),
        writerList: fromJS(action.writerList),
      });
    default:
      return state;
  }
};

写这两步后,主页便可拿到 json 数据。功能实现后,后边就需要代码优化了。ajax 请求通过 redux-thunk 在actionCreators 里执行,action 和 action types 要分别转移到 actionCreators 和 constants。

优化后:

// Home/index.js
  componentDidMount() {
    const { changeHomeData } = this.props;
    changeHomeData();
  }
}

const mapDispatch = (dispatch) => ({
  changeHomeData() {
    dispatch(actionCreators.getHomeInfo());
  },
});
// Home/store/actionCreator.js
const changeHomeData = (result) => ({
  type: constants.CHANGE_HOME_DATA,
  topicList: result.topicList,
  articleList: result.articleList,
  recommendList: result.recommendList,
  writerList: result.writerList,
});

export const getHomeInfo = () => {
  return (dispatch) => {
    axios.get("/api/home.json").then((res) => {
      const result = res.data.data;
      dispatch(changeHomeData(result));
    });
  };
};

8. 实现加载更多功能

文章列表的内容并不是一股全加载出来的,如果有 100 篇文章,一股脑加载出来,没必要的同时还增加了服务器压力。因此”阅读更多“按钮是很有必要的。

首先,还是一样的,模拟接口,然后点击”阅读更多“触发事件派发 action,然后在 actionCreators 里进行 ajax 请求后,派发 action 给 reducer。

// components/List.js
class List extends Component {
  render() {
    const { articleList, getMoreList } = this.props;
    return (
      <Fragment>
        {articleList.map((item) => (
          <ListItem key={item.get("id")}>
            <img className="pic" src={item.get("imgUrl")} alt="" />
            <ListInfo>
              <h3 className="title">{item.get("title")}</h3>
              <p className="desc">{item.get("desc")}</p>
            </ListInfo>
          </ListItem>
        ))}
        <LoadMore onClick={getMoreList}>阅读更多</LoadMore>
      </Fragment>
    );
  }
}

const mapState = (state) => ({
  articleList: state.getIn(["home", "articleList"]),
});

const mapDispatch = (dispatch) => ({
  getMoreList() {
    dispatch(actionCreators.getMoreList());
  }
})

export default connect(mapState, mapDispatch)(List);
// Home/store/actionCreators.js
const addHomeList = (list) => ({
  type: constants.ADD_ARTICLE_LIST,
  list: fromJS(list),
});

export const getMoreList = () => {
  return (dispatch) => {
    axios.get("/api/homeList.json").then((res) => {
      const result = res.data.data;
      dispatch(addHomeList(result));
    });
  };
};
// Home/store/reducer.js
const reducer = (state = defaultState, action) => {
  switch (action.type) {
    ......
    case constants.ADD_ARTICLE_LIST:
      return state.set(
        "articleList",
        state.get("articleList").concat(action.list)
      );
    default:
      return state;
  }
};

上面已经实现了点击”阅读更多“后,将所有的内容加载出来的功能,但是这里还是有问题,”阅读更多“点击后不应该将所有的数据加载出来,而是每次加载一点点而且每次加载不一样。

其实,阅读更多和下一页是一个意思。store 里添加 articlePage 属性,用来记录当前点击了几次”阅读更多“,或者讲,记录翻到了第几页,记录在 url 里,然后和后端进行沟通即可。

// components/List.js
class List extends Component {
  render() {
    const { articleList, articlePage, getMoreList } = this.props;
    return (
      <Fragment>
        {articleList.map((item) => (
          <ListItem key={item.get("id")}>
            <img className="pic" src={item.get("imgUrl")} alt="" />
            <ListInfo>
              <h3 className="title">{item.get("title")}</h3>
              <p className="desc">{item.get("desc")}</p>
            </ListInfo>
          </ListItem>
        ))}
        <LoadMore onClick={() => getMoreList(articlePage)}>阅读更多</LoadMore>
      </Fragment>
    );
  }
}

const mapState = (state) => ({
  articleList: state.getIn(["home", "articleList"]),
  articlePage: state.getIn(["home", "articlePage"]),
});

const mapDispatch = (dispatch) => ({
  getMoreList(page) {
    dispatch(actionCreators.getMoreList(page));
  },
});

export default connect(mapState, mapDispatch)(List);
// Home/store/acitonCreators.js
const addHomeList = (list, nextPage) => ({
  type: constants.ADD_ARTICLE_LIST,
  list: fromJS(list),
  nextPage,
});

export const getMoreList = (page) => {
  return (dispatch) => {
    axios.get(`/api/homeList.json?page=${page}`).then((res) => {
      const result = res.data.data;
      dispatch(addHomeList(result, page + 1));
    });
  };
};
// Home/store/reducer.js
const reducer = (state = defaultState, action) => {
  switch (action.type) {
    case constants.CHANGE_HOME_DATA:
      ......
    case constants.ADD_ARTICLE_LIST:
      return state.merge({
        articleList: state.get("articleList").concat(action.list),
        articlePage: action.nextPage,
      });
    default:
      return state;
  }
};

9. 返回顶部功能实现

返回顶部是很多网页都会提供的功能。这个功能的注意点就只有 position: fixed,点击的时候触发 window.scrollTo(0, 0); 即可。

拓展:当滚动条往下拉到一定程度才显示”返回顶部"按钮,这个功能需要通过变量来控制“返回顶部”的显示或隐藏。此时,在 store 里增添 showScroll 属性,初始值为 false。当 showScroll 变为 true 的时候,“返回顶部”按钮才会展示。

// Home/index.js
class Home extends Component {
  handleScrollTop() {
    window.scrollTo(0, 0);
  }

  render() {
    const { showScroll } = this.props;
    return (
      <HomeWrapper>
        <HomeLeft>
          <img
            className="banner-img"
            src="https://upload.jianshu.io/admin_banners/web_images/5055/348f9e194f4062a17f587e2963b7feb0b0a5a982.png?imageMogr2/auto-orient/strip|imageView2/1/w/1250/h/540"
            alt=""
          />
          <Topic />
          <List />
        </HomeLeft>
        <HomeRight>
          <Recommend />
          <Writer />
        </HomeRight>
        {showScroll && (
          <BackTop onClick={this.handleScrollTop}>回到顶部</BackTop>
        )}
      </HomeWrapper>
    );
  }
  componentDidMount() {
    ......
    this.bindEvents();
  }

  componentWillUnmount() {
    // 组件销毁之前,事件要解绑
    const { changeScrollTopShow } = this.props;
    window.removeEventListener("scroll", changeScrollTopShow);
  }

  bindEvents() {
    const { changeScrollTopShow } = this.props;
    window.addEventListener("scroll", changeScrollTopShow);
  }
}

const mapState = (state) => ({
  showScroll: state.getIn(["home", "showScroll"]),
});

const mapDispatch = (dispatch) => ({
  ......
  changeScrollTopShow() {
    if (document.documentElement.scrollTop > 400) {
      dispatch(actionCreators.toggleTopShow(true));
    } else {
      dispatch(actionCreators.toggleTopShow(false));
    }
  },
});

export default connect(mapState, mapDispatch)(Home);

action 派发给 reducer 处理即可。

10. 首页性能优化及路由跳转

  1. SCU (ShouldComponentUpdate) 优化

    首页的数据用了 connect 和 store 连接,一旦 store 里数据进行改变后,首页的子组件都会被重新渲染,即 render 重新执行,不管更改的数据和子组件有没有关系,这样会使性能较低。

    因此就在 shouldComponentUpdate 生命周期函数里判断更改的数据是否与该组件相关。不相关的话返回 false,就不会触发更新了。

    当然,react 内置了一种组件类型 PureComponent,内置了浅比较的 SCU。

    注意
    1. PureComponent 不可滥用,因为做一次浅比较也是要消耗性能的,经常更新的组件就没必要用 PureComponent 了。
    2. SCU 基于数据的不可变性,需要保证数据的不可变性,immutablejs 提供了保证数据不可变性的好方式。
  2. 防抖节流

    进度条滚动就触发 scroll 事件,频率非常高,但是并没有必要那么频繁触发事件,如果每隔 0.5 秒触发一次,这已经足够了。

    使用 lodash 模块的 debounce 和 throttle,可以方便地实现防抖和节流。

      componentWillUnmount() {
        // 组件销毁之前,事件要解绑
        const { changeScrollTopShow } = this.props;
        window.removeEventListener("scroll", _.throttle(changeScrollTopShow, 500));
      }
    
      bindEvents() {
        const { changeScrollTopShow } = this.props;
        window.addEventListener("scroll", _.throttle(changeScrollTopShow, 500));
      }
    
  3. 路由跳转

    使用 Link 标签(react-router 提供)来实现路由跳转。可以把 Link 想象成高级的 a 标签,但是是专供给单页面使用,但是 a 标签会导致另起一页,组件都重新加载了。

    具体用法:

    // Home/components/List.js
    <Link key={item.get("id")} 
      // Link 标签内容
    </Link>
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值