React实战—Class式组件仿CNode

265 阅读5分钟

前言

接触React也有一个月了。为了能让自己更好的学习React,于是便寻思着写下自己实战的过程。

这是一篇很简单的实战,不涉及Redux,非常适合像我一样的初学者。

本文用的是class式的写法,当然接下来也会用函数式重写一遍。

源码

项目传送门

技术栈

react + react-router + axios+ antd

实现效果

项目结构

|-cnode 项目名
    |-node_modules  依赖包
    |-public  
    |-src  
        |-components    组件目录
            |-articlelist		首页文章列表组件
            |-articlelistmenu   首页文章导航组件
            |-detailmain        文章详情主体组件
            |-detailreply       文章详情回复组件
            |-usertopic      	用户详情组件
        |-pages         页面
            |-home          首页
            |-detail        文章详情页
            |-user        	用户详情页
        |-routes        路由配置
        |-utils         工具类
       	|-App.css		页面样式
        |-App.jsx       主页面
        |-index.js      入口文件
    packjon.json     全局配置
    README.md        readme文件

功能实现

路由配置

首先是路由配置,虽然只有三个页面,但是为了结构清晰,且培养好的编码习惯,还是将路由配置抽离出来。

这里我将项目的路由配置文件放在routes文件下,然后在App.js中引入,并map循环遍历出来。

路由配置文件

import Home from '../pages/home';
import User from '../pages/user';
import Detail from '../pages/detail';

const routes = [
    {
        path:'/:tab?',
        name:'首页',
        exact:true,
        component:Home
    },
    {
        path:'/detail/:id?',
        name:'文章详情',
        exact:true,
        component:Detail
    },
    {
        path:'/user/:loginname?',
        name:'用户详情',
        exact:true,
        component:User
    }
];

export default routes;

App.jsx

import React from 'react';
import Axios from 'axios';
import './App.css';
import { Switch , Route , Link} from 'react-router-dom';
import { Layout } from 'antd';

import routes from './routes/index';
const { Header , Content } = Layout;

Axios.defaults.baseURL = 'https://cnodejs.org/api/v1';  //设置请求默认地址

class App extends React.Component{

  render(){
    return (
      <Layout>
          <Header className='header'>    {/* 设置项目通用头部,主要包括logo */}
              <Link to='/'>
                  <div className='logo'>
                    <img src="//static2.cnodejs.org/public/images/cnodejs_light.svg" alt=""/>
                  </div>
              </Link>
          </Header>
          <Content className='main'>    {/* 循环遍历路由配置。并根据不同的路由渲染不同的内容 */}
              <Switch>
                  {
                    routes.map( item =>{
                      return (
                        <Route key={item.name} exact={item.exact} path={item.path} component={item.component} />
                      )
                    })
                  }
              </Switch>
          </Content>
      </Layout>
    )
  }
}

export default App;

首页

首页主要包括导航和文章列表两个组件。

menuList , colorList这两个配置文件是为了循环渲染出5个导航,并根据不同的导航渲染不同的图标。

import React , { Component } from 'react';
import { Row , Col } from 'antd';

import ArticleList from '../components/articlelist';
import ArticleMenu from '../components/articlelistmenu';
import { menuList , colorList } from '../utils/utils'; 

export default class Home extends Component {
    render(){
        const tab = this.props.match.params.tab ?? 'all';  
        //这里使用的是ES的新语法,意思是tab为null或undefined时默认为'all'
        return (
            <Row>
                <Col span={2}>
                    <ArticleMenu menuList={menuList} tab={tab} /> {/* 引入导航组件 */}
                </Col>
                <Col span={22}>
                    <ArticleList menuList={menuList} colorList={colorList} tab={tab} /> {/* 引入文章列表组件 */}
                </Col>
            </Row>
        )
    }
} 

导航组件

将初始导航状态存在state里的tab里,并在componentDidMount之后将父组件传进来的tab设为state里的tab。

将父组件传进来的menuList循环遍历成5个导航,因为menuList是一个对象,所以这里使用Object.entries方法。这个方法可以将对象设为二维数组并返回。

import React , { Component } from 'react';
import { Menu } from 'antd';
import { Link } from 'react-router-dom';

export default class ArticleMenu extends Component {
    
    state={
        tab:'all'
    }

    componentDidMount(){
        this.setState({
            tab:this.props.tab
        })
    }
    
    render(){
        const { menuList } = this.props;
        const { tab } = this.state;
        return (
            <Menu
                theme='light'
                mode='vertical'
                selectedKeys={[tab]}
                onSelect={({key})=>{
                    this.setState({tab:key})
                }}
            >
                {
                    Object.entries(menuList).map(item => {
                    return <Menu.Item key={item[0]}>
                                <Link to={`/${item[0]}`}>{item[1]}</Link>
                            </Menu.Item>
                    })
                }
            </Menu>
        )
    }
} 

tips:这里有个两个小坑。

1.导航选中一项后,刷新页面,会发现路由没变,但是导航的选中状态变为了[全部]
2.导航的选中状态不会随着点击而实时改变

解决方法:

1.原因是使用了antd的Menu组件,并设置了defaultSelectedKeys,将defaultSelectedKeys修改为selectedKeys就好了
2.同样是Menu组件的原因,设置onSelcct,通过onSelcct触发的回调函数更改state里的tab值即可

文章列表组件

主要作用是通过点击导航列表里的不同项,加载不同类型的文章列表,并渲染在页面上。

在这个组件里将数据请求封装在了getData方法里,并在componentDidMount组件挂载完成后触发。

import React,{ Component } from 'react';
import Axios from 'axios';
import { fromNow , locate } from 'silly-datetime'
import { List , Avatar , Tag } from 'antd';
import {Link} from 'react-router-dom';
locate('zh-cn');
export default class ArticleList extends Component{   
    state = {
        dataList:[],
        page:1
    }

    componentDidMount(){
        const { tab } = this.props;
        this.getData(tab);
    }

    shouldComponentUpdate(nextProps){
        if(nextProps.tab !== this.props.tab){
            this.getData(nextProps.tab)
            return false;
        }
        return true;
    }

    getData = (tab='all',page=1) => {
        Axios.get(`/topics?page=${page}&tab=${tab}&limit=${15}`
        )
        .then(data => data.data.data)
        .then( data => this.setState({dataList:data}))
    }
    
    render(){
        const { dataList } = this.state;
        const { menuList , colorList , tab } = this.props;
        return (
            <List
                className='list'
                dataSource={dataList}
                pagination={{ 
                    pageSize: 15 , 
                    total: 600 , 
                    showSizeChanger:false , 
                    onChange:(page)=>{
                        this.setState({page});
                        this.getData( tab ,page)
                    }
                }}
                renderItem={(item)=>{
                    return (<List.Item 
                                key={item.id} 
                                className='item'
                                actions={[`回复 ${item.reply_count}`,`访问 ${item.visit_count}`]}
                            >
                                <List.Item.Meta 
                                    avatar={
                                        <Link to={`/user/${item.author.loginname}`} >
                                            <Avatar src={item.author.avatar_url}/>
                                        </Link>
                                    }
                                    title={ 
                                        <div>
                                            <Tag
                                                color={colorList[item.tab]}
                                            >
                                                {item.top ? '置顶' : (item.good ? '精华' : menuList[item.tab])}
                                            </Tag>
                                            <Link to={`/detail/${item.id}`}>
                                                {item.title}
                                            </Link>
                                        </div> 
                                    }
                                    description={
                                        <div>
                                            <Link to={`/user/${item.author.loginname}`} className='user_name'>
                                                {item.author.loginname}
                                            </Link>
                                            <span className='reply_time'>
                                                最后回复时间:{fromNow(item.last_reply_at)}
                                            </span>
                                        </div>
                                    }
                                />
                            </List.Item>)
                }}
            />
        )
    }
}

tips:这里也有个小坑,就是点击不同的导航项,路由改变,但列表数据并不会重新渲染。

摸索后发现原因是数据请求只在componentDidMount里触发了一次,要在shouldComponentUpdata里通过对比props传入的tab,不同则再请求一次数据来触发页面更新。

文章详情页

文章详情页主要包括文章主体和文章回复两个组件。

在componentDidMount组件挂载完毕后请求数据,并将数据传递给两个子组件。

import React, { Component } from 'react';
import Axios from 'axios';

import DetailMain from '../components/detailmain';
import DetailReply from '../components/detailreply';

export default class Detail extends Component{
    state = {
        data:[]
    }

    componentDidMount(){
        const { id } = this.props.match.params;
        Axios.get(`/topic/${id}`).then(data => {
            this.setState({data:data.data.data})
        })
    }

    render(){
        return (
            <div className='wrap'>
                <DetailMain data={this.state.data} />
                {this.state.data.replies?.length ? <DetailReply data={this.state.data.replies} /> : '' }
            </div>
        )
    }
} 

文章主体内容组件

这个组件UI采用的是antd的Card,接受到父组件传递过来的数据后进行展示。

由于数据中的内容部分接受到的是Dom结构的数据,所以采用dangerouslySetInnerHTML来加载内容。

import React,{ Component } from 'react';
import { fromNow } from 'silly-datetime';
import { Card , Tag } from 'antd';

import { menuList , colorList } from '../utils/utils';

export default class DetailMain extends Component{
    render(){ 
        const { data } = this.props;
        return (
            <Card
                type='inner'
                title={
                    <div>
                        <div className='title'>
                            <Tag
                                color={colorList[data.tab]}
                            >
                                {data.top ? '置顶' : (data.good ? '精华' : menuList[data.tab])}
                            </Tag>
                            <span className='title_name'>
                                {data.title}
                            </span>
                        </div>
                        <div className='info'>
                            <span> 发布于 {fromNow(data.create_at)} </span>
                            <span> 作者 {data.author?.loginname} </span>
                            <span> {data.visit_count} 次浏览 </span>
                            <span> 来自 {menuList[data.tab]} </span>
                        </div> 
                    </div>
                }
            >
               {
                   <div dangerouslySetInnerHTML={{
                       __html:data.content
                   }}
                   className='content'
                   />
               }
            </Card>
        )
    }
}

文章回复内容组件

这个组件在文章回复量为0的时候不展示。

由于数据中的内容部分接受到的是Dom结构的数据,所以采用dangerouslySetInnerHTML来加载内容。

import React,{ Component } from 'react';
import { Card , List , Avatar } from 'antd';
import { LikeOutlined } from '@ant-design/icons';
import { fromNow } from 'silly-datetime';
import { Link } from 'react-router-dom';
const { Item } = List;

export default class DetailReply extends Component{
    render(){
        const { data } = this.props;
        return (
            <Card
                title={
                <div>{data?.length}条回复</div>
                }
            >
                <List 
                    itemLayout='vertical'
                    className='reply_list'
                    pagination={{
                        pageSize:5,
                        total: data?.length,
                        hideOnSinglePage:true
                    }}
                    dataSource={data}
                    renderItem={(item)=>{
                        return (
                                <Item
                                    extra={item.ups?.length ? <div><LikeOutlined /> {item.ups?.length}</div>  : '' }
                                    className='item'
                                >
                                    <Item.Meta
                                        avatar={
                                            <Link to={`/user/${item.author.loginname}`}>
                                                <Avatar src={item.author?.avatar_url}/> 
                                            </Link>
                                        }
                                        description={
                                            <div>
                                                <Link to={`/user/${item.author.loginname}`} className='user_name'>
                                                    {item.author?.loginname}
                                                </Link>
                                                <span>发表于:{fromNow(item.create_at)}</span>
                                            </div>
                                        }
                                    >
                                    </Item.Meta>
                                    {
                                      <div dangerouslySetInnerHTML={{
                                          __html:item.content
                                      }} />
                                    }
                                </Item>
                            )
                    }}
                />
            </Card>
        )
    }
}

用户详情页

用户详情页主要包括用户信息、用户最近创建的话题、用户最近回复的话题三个部分。

用户信息内容直接用antd的Card组件的title配合Descriptions组件来完成。

由于最近创建的话题和最近回复的话题两部分非常相似,所以采用同一个组件配合父组件传入的不同数据来完成。

**tips:**这里有个坑,我摸索了很久才解决。在最近回复的话题点击其他用户的头像,路由改变但是页面不会重新渲染。

经过摸索和google发现也是由于数据只在component里只请求了一次,所以页面不会更新。

在componentDidUpdate对比前后props再请求一次数据即可。

import React, { Component } from 'react';
import { Card , Descriptions , Avatar} from 'antd';
import Axios from 'axios';
import { fromNow } from 'silly-datetime';
import UserTopic from '../components/usertopic';
const { Item } = Descriptions; 

export default class User extends Component{

    state = { 
        data : []
    }

    componentDidMount(){
        const { loginname } = this.props.match.params; 
        Axios.get(`/user/${loginname}`).then( data => data.data.data ).then( data => this.setState({data}))
    }

    componentDidUpdate(prevProps){
        if(prevProps.match.params.loginname !== this.props.match.params.loginname){
            Axios.get(`/user/${this.props.match.params.loginname}`)
            	.then(data => data.data.data)
                .then(data => this.setState({data}))
        }
    }

    render(){
        const { data } = this.state;
        return (
            <div className='wrap'>
                <Card
                    title={
                        <Descriptions
                            title={<Avatar src={data.avatar_url} style={{marginBottom:'20px'}} />}
                        >
                            <Item label='用户名'>
                                {data.loginname}
                            </Item>
                            <Item label='积分'>
                                {data.score}
                            </Item>
                            <Item label='创建时间'>
                                {fromNow(data.create_at)}
                            </Item>
                        </Descriptions>
                    }
                >
                    <UserTopic title={'最近创建的话题'} data={data.recent_topics} />
                    <UserTopic title={'最近回复的话题'} data={data.recent_replies} />
                </Card>
            </div>
        )
    }
} 

用户最近创建的话题和用户最近回复的话题

这两个部分只是数据上有差别。主体都是相似的。

import React,{ Component } from 'react';
import { Card , List , Avatar } from 'antd';
import { Link } from 'react-router-dom';
import { fromNow } from 'silly-datetime';
const { Item } = List;

export default class UserTopic extends Component{

    render(){
        const { title , data } = this.props;
        return(
            <Card
                title={title}
            >
                <List
                    dataSource={data}
                    pagination={{ 
                        pageSize: 5 , 
                        total: data?.length , 
                        hideOnSinglePage:true
                        }
                    }
                    renderItem={(item)=>{
                        return (<Item 
                                    key={item.id} 
                                    className='item'
                                    extra={`最后回复时间:${fromNow(item.last_reply_at)}`}
                                >
                                    <Item.Meta 
                                        avatar={
                                            <Link to={`/user/${item.author.loginname}`}>
                                                <Avatar src={item.author.avatar_url}/>
                                            </Link>
                                        }
                                        title={ 
                                            <div>
                                                <Link to={`/detail/${item.id}`}>
                                                    {item.title}
                                                </Link>
                                            </div> 
                                        }
                                        
                                    />
                                </Item>)
                    }}
                />
            </Card>
        )
    }
}

结语

项目传送门

虽然项目看起来简单,但是自己写起来还是碰到不少问题。不断的通过google和看文档来解决一个又一个坑,并牢记不再踩也是成长的一部分。

尤其是相同页面但是参数不同页面不重新渲染的坑,以后要牢记。

不能只在componentDidMount里只请求一次数据,要在shouldComponentUpdate或componentDidUpdate通过对比前后props来请求一次数据。

最后,项目还有很多需要完善和改进,如果有错误或者可以优化的地方,欢迎各位大佬指点一二。

**最后的最后,臭不要脸的求一个STAR!**😋