简书项目实战二:首页开发
本文是 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. 首页性能优化及路由跳转
-
SCU (ShouldComponentUpdate) 优化
首页的数据用了 connect 和 store 连接,一旦 store 里数据进行改变后,首页的子组件都会被重新渲染,即 render 重新执行,不管更改的数据和子组件有没有关系,这样会使性能较低。
因此就在 shouldComponentUpdate 生命周期函数里判断更改的数据是否与该组件相关。不相关的话返回 false,就不会触发更新了。
当然,react 内置了一种组件类型 PureComponent,内置了浅比较的 SCU。
注意
- PureComponent 不可滥用,因为做一次浅比较也是要消耗性能的,经常更新的组件就没必要用 PureComponent 了。
- SCU 基于数据的不可变性,需要保证数据的不可变性,immutablejs 提供了保证数据不可变性的好方式。
-
防抖节流
进度条滚动就触发 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)); }
-
路由跳转
使用 Link 标签(react-router 提供)来实现路由跳转。可以把 Link 想象成高级的 a 标签,但是是专供给单页面使用,但是 a 标签会导致另起一页,组件都重新加载了。
具体用法:
// Home/components/List.js <Link key={item.get("id")} // Link 标签内容 </Link>