乐趣区

React搭建个人博客一项目简介与React前端踩坑

一. 简介

项目最开始目的是为了熟悉 React 用法,之后加入的东西变多,这里分成三部分,三篇博客拆开来讲。

前端部分

  • [x] React
  • [x] React-Router4.0
  • [x] Redux
  • [x] AntDesign
  • [x] webpack4

后端部分

  • [x] consul+consul-template+nginx+docker 搭建微服务
  • [x] cdn 上传静态资源
  • [x] thinkJs

运维部分

  • [x] daocloud 自动化部署
  • [x] Prometheus+Grafana 监控系统

博客网站分为两个部分,前台博客展示页面,后台管理页面。先看下效果:
前台页面:

也可以看我线上正在用的博客前台,点这里
后台页面:

这一篇只讲前端部分
  • 功能描述

    • [x] 文章列表展示
    • [x] 登录管理
    • [x] 文章详情页展示
    • [x] 后台文章管理
    • [x] 后台标签管理
    • [x] MarkDown 发文
  • 项目结构

前后台页面项目结构类似,都分为前端项目和后端项目两个部分。前端项目开发环境使用 webpack 的 devserver,单独启动一个服务,访问后端服务的接口。生产环境直接打包到后端项目指定目录,线上只启动后端服务。
前台页面:前端项目代码地址 在这里。后端项目代码地址在这里。
后台页面:前端项目代码地址 在这里。后端项目代码地址在这里。

二.React 踩坑记录

这里讲一下项目中 React 踩坑和一些值得留意的问题。

1. 启动报错如下:

下图是一开始 blog-react 项目一个报错

因为项目里我使用 webpack 来运行项目,以及添加相关配置,而不是刚建好项目时的 react 命令,所以一开始启动的时候会报 Module build failed: SyntaxError: Unexpected token 错误。说明 ReactDom.render 这句话无法解析。
解决方法, 添加 babel 的 react-app 预设, 我直接加到了 package.json 里。

    "scripts": {
        "start": "cross-env NODE_ENV=development webpack-dev-server --mode development --inline --progress --config build/webpack.config.dev.js",
        "build": "cross-env NODE_ENV=production webpack --env=test --progress  --config ./build/webpack.config.prod.js",
        "test": "react-scripts test",
        "eject": "react-scripts eject"
    },
    "babel": {
        "presets": ["react-app"]
    },

2.React 组件方法绑定 this 问题

React 跟 Vue 不一样的一点是,组件里定义的方法,如果直接调用会报错。
比如:

class App extends React.Component{
state={name:'sx}
handleClick(){console.log(this.state.name)
}
render()
{return <Button onClick={this.handleClick}>click</Button>
}
}

这样会直接报错,必须要给方法绑定 this。
解决方法:

  1. bind 绑定 this
export default class LeftImg extends React.Component {handleClick(){}
    render() {return <Button onClick={this.handleClick.bind(this)}></Button>
    }
}
  1. 使用箭头函数继承外部 this
export default class LeftImg extends React.Component {
    state={name:'sx'}
    handleClick = () => {console.log(this.state.name)
    };
    render() {const { src} = this.props;

        return (
            <div className="left-img-container">
                <img src={src} onClick={this.handleClick} />
            </div>
        );
    }
}

也可以:

  <div className="tip left" onClick={() => this.handleControlPage("left")}>
   {left}
  </div>
  1. 构造函数内部绑定
    在构造函数里绑定,这种相对最好,因为只绑定一次。
export default class JoinUs extends React.Component {constructor(props) {super(props);
        this.handleScroll = this.handleScroll.bind(this);
    }
  }

注意,不要这么写

handleClick(){this.setState({name:'xx'})}
<Button onClick={this.handleClick('edit')}></Button>

React 这里跟 vue 不同,这么写初始化就会执行这个方法,而不是触发事件才会调用。会导致报错:

3.setState 问题

setState 方法,当你想赋的新 state 值是通过旧 state 计算得到时,要使用

this.setState((preState,preProps)=>{return {name:preState.name+'ox'}})

preState 是改变前的 state,preProps 是改变前的 props。
注意:
1.setState 第二个参数是改变 state 的回调,因为 setState 是异步方法,改变后的操作需要在回调里定义。用的不 多但是面试可能会问。
2.setState 不能再 render 调用。

4. 受控组件问题

项目中使用 antDesign 作为 UI 库,当使用 Upload 组件上传时,发生 onChange 只执行一次的现象,代码如下:

    handleChange = info => {console.log("info", info);
        if (info.file.status === "uploading") {return;}
        if (info.file.status === "done") {
         this.setState({fileList: [...info.fileList]
        });
            this.setState({uploadImg: { ...info.file.response.data, uid: info.file.uid}
            });
        }
    };
     <Upload
                    name="image"
                    listType="picture-card"
                    className="avatar-uploader"
                    fileList={this.state.fileList}
                    onChange={this.handleChange}
                >
                    {this.state.fileList.length >= maxImgCount
                        ? null
                        : uploadButton}
     </Upload>

通过查找,发现 Upload 是受控组件,也就是用户的输入需要再动态改变组件的值。类似与 vue 里不使用 v -model 实现的双向绑定。这种组件值与用户 onChange 的同步,就是受控组件的特点。
这里解决方法就是 handleChange 里的所有分支里,都需要改变 fileList, 如下:

handleChange = info => {
        this.setState({fileList: [...info.fileList]
        });
        if (info.file.status === "uploading") {return;}
        if (info.file.status === "done") {
            this.setState({uploadImg: { ...info.file.response.data, uid: info.file.uid}
            });
        }
    };

5.React 父级组件传入 props 变化,子级组件更新 state 问题

当子组件的 state 初始值取决于父组件传入的 props,如这里的 fileList

class UploadImg extends React.Component {
    state = {
        visibleUpload: false,
        imageUrl: "",
        fileList: this.props.defaultFileList,
        previewImage: "",
        previewVisible: false,
        uploadImg: {},
        delModalVisible: false,
        deleteTitle: "",
        deletefile: {}};
}

如果 props.defaultFileList 变化,子组件的 state 不会重新计算,推荐的解决方法有以下两种

  1. 利用 key 变化,使子组件重新实例化
 <UploadImg
                    visible={this.state.visibleUpload}
                    onConcel={() => {this.setState({ visibleUpload: false});
                    }}
                    onOk={this.confirmUpload}
                    key={this.props.thumb}
                    maxImgCount={1}
                    defaultFileList={this.props.thumb}
 />
  1. 子组件使用 componentWillReceiveProps 钩子函数

     父组件 props 变化,会触发子组件钩子函数 componentWillReceiveProps,它参数是更新后的 props,可以根据新的 props 改变子组件自己的 state
     
    

componentWillReceiveProps(nextProps) {const { data} = this.state
    const newdata = nextProps.data.toString()
    if (data.toString() !== newdata) {
      this.setState({data: nextProps.data,})
    }
  }

6. 高阶组件的装饰器写法不支持问题

高阶组件是 React 非常实用的一个功能,装饰器方法写起来也很方便,但是直接写无法解析。

@withRouter
class Header extends React.Component {}

解决方式:babel 转码

  1. 执行 npm install @babel/plugin-proposal-decorators
  2. 配置 babel plugin

     babel 解析插件的定义,这次我写在了 webpackage.config.base.js 里。下面是关键部分代码
     
    
{test: /\.(js|mjs|jsx)$/,
                        include: resolve("src"),

                        loader: require.resolve("babel-loader"),
                        options: {
                            plugins: [
                                [
                                    "@babel/plugin-proposal-decorators",
                                    {legacy: true}
                                ]
                            ],
                            cacheDirectory: true,
                            cacheCompression: true,
                            compact: true
                        }
                    }

7.React Router4 注意点

  1. 使用 Switch 组件匹配第一个 Route
  2. v4 不提供 browerHistory,想要得到 history

    - 使用 context 对象获取,this.context.router.history
    - 使用 withRouter
    - 子级创建 history
  3. v4 之前嵌套的路由可以放在一个 router 中如下,但是在 4.0 以后这么做会报错,需要单独放置在嵌套的跟 component 中处理路由
// 4.0 之前
<Route component={App}>
    <Route path="groups" components={Groups} />
    <Route path="users" components={Users}>
      <Route path="users/:userId" component={Profile} />
    </Route>
</Route>
//v4.0
<Route component={App}>
    <Route path="groups" components={Groups} />
    <Route path="users" components={Users}>
    </Route>
</Route>
const Users = ({match}) => (
  <div>
    <h2>Topics</h2>
    <Route path={`${match.url}/:userId`} component={Profile}/>
  </div>
)

8.React 防止服务泄漏

开发过程中,有时会遇到这种报错

Can't perform a React state update on an unmounted component. 
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount method”

大概意思就是组件已经销毁了,不能再设置它的 tate 了,防止内存泄漏。出现的场景是,用 AsyncComponent 实现路由懒加载时(后面会讲),当路由跳转后,如果跳转前存在异步方法,回调函数包含设置跳转前组件的 state。这时如果路由已经跳转,旧的组件已经销毁,再设置 state 就会报上面的错。
解决方法:
利用 componentWillUnmount 钩子和 setState 改造,使得组件销毁后,阻止执行 setState。

function inject_unount (target){
        // 改装 componentWillUnmount,销毁的时候记录一下
        let next = target.prototype.componentWillUnmount
        target.prototype.componentWillUnmount = function () {if (next) next.call(this, ...arguments);
            this.unmount = true
         }
         // 对 setState 的改装,setState 查看目前是否已经销毁
        let setState = target.prototype.setState
        target.prototype.setState = function () {if ( this.unmount) return ;
            setState.call(this, ...arguments)
        }
    }

代码比较好理解,在组件销毁之前,设置 this.unmount 为 true。修改组件原型的 setState 方法,判断如果 this.unmount 为 true,就不执行操作。
应用 inject_unmount 时,可以使用普通的传参,也能用修饰器

// 普通用法
export default inject_unmount(BaseComponent)
// 修饰器
export default
@inject_unount
class BaseComponent extends Component {.....}

三. 项目前端记录点

手写实现路由守卫

vue-router 中存在路由守卫,当实现登录过滤及权限判断时会特别方便。react-router 里没有这个功能,于是手写一个组件实现简单的守卫功能。
后台项目中,有登录功能,这里使用路由守卫判断用户是否登录,没登录直接跳回登录页。

import React from "react";
import {Route, Redirect} from "react-router-dom";
import {getToken} from "@/utils/auth";
import {withRouter} from "react-router-dom";
import history from "@/utils/history";
class RouterGurad extends React.Component {render() {const { location, config} = this.props;
        const {pathname} = location;
        const token = getToken();
        const targetRouterConfig = config.find(v => v.path === pathname);
        console.log("token", token);
        if (token) {if (pathname === "/login") {return <Redirect to="/" />;} else {return <div />;}
        } else {if (pathname !== "/login") {history.push("/login");
                return <div />;
            } else {return <div />;}
        }
    }
}
export default withRouter(RouterGurad);

用户登录后,服务端返回的 token 会返回前端,并存储在 cookie 里,这里判断了 token 是否存在,不存在不让用户访问 login 以外的页面。
App.jsx 中加入这个组件

            <LocaleProvider locale={zhCN}>
                <Router>
                    <div>
                        <RouterGurad config={[]} />
                        <Switch>
                            <Route
                                path="/login"
                                component={Login}
                                key="/login"
                            />
                            <Route path="/" component={SelftLayout} key="/" />
                            <Route path="*" component={notFound} key="*" />
                        </Switch>
                    </div>
                </Router>
            </LocaleProvider>

路由的异步组件加载

在 vue 里,想要实现路由懒加载组件,只要在 router 的 component 这么定义就可以

{
        path: "welcome",
        name: "welcome",
        hidden: true,
        component: () => import("@/views/dashboard/index")
}

这样会在路由匹配到 path 时,再执行 component 指定的 function, 返回组件,实现了懒加载。
React 里,如果你直接在 route 组件的 component 属性里这么写,是不行的,因为 Route 里不能传入一个函数作为 component。这时需要手写一个异步组件,实现渲染时才会加载所需的组件。

//asyncComponent.js
import React from "react";
import {inject_unmount} from "@/utils/unmount";
const AsyncComponent = loadComponent => {
    class AsyncComponent extends React.Component {
        state = {Component: null};

        componentWillMount() {if (this.hasLoadedComponent()) {return;}
            loadComponent()
                .then(module => module.default || module)
                .then(Component => {if (this.state) {this.setState({ Component});
                    }
                })
                .catch(err => {
                    console.error(`Cannot load component in <AsyncComponent />`);
                    throw err;
                });
        }
        hasLoadedComponent() {return this.state.Component !== null;}

        render() {const { Component} = this.state;
            return Component ? <Component {...this.props} /> : null;
        }
    }
    return inject_unmount(AsyncComponent);
};

export default AsyncComponent;

这个是在网上找的,大概意思是,AsyncComponent 传入一个组件作为参数,当不加载 AsyncComponent 时,它不会渲染(渲染 null),加载后,渲染为传入组件。
在 Route 组件里,component 的写法就变成这样。

const Login = AsyncComponent(() => import("@/components/login/index"));
class App extends React.Component {render() {
        return (<Route path="/login" component={Login} key="/login" />
        )
}

项目中 Router 结构

这里以后台项目 blog-backend-react 为例,讲一下整个项目路由的结构。
路由采用的是 hash 模式,后台项目首先是在 App.jsx 定义三个路由,对应着登录页面,登录后的管理页面,以及 404 页面。

   render() {
        return (<LocaleProvider locale={zhCN}>
                <Router>
                    <div>
                        <RouterGurad config={[]} />
                        <Switch>
                            <Route
                                path="/login"
                                component={Login}
                                key="/login"
                            />
                            <Route path="/" component={SelftLayout} key="/" />
                            <Route path="*" component={notFound} key="*" />
                        </Switch>
                    </div>
                </Router>
            </LocaleProvider>
        );
    }

当登录后,进入到 SelfLayout 组件后,又会有根据管理页面的路由,比如文章管理,标签管理等。这些路由配置我在前端写在了 routerConfig.js 里,当然正常来说是该后端获取的。
React-Router 其实主要就是两部分,一部分是作为跳转的 Link 组件,一部分是为匹配路由的组件占位显示的 Route 组件。项目中左侧 SideBar 导航树是要根据 routeConfig 生成的,而且要包含 Link,满足点击时路由跳转。Route 组件编写需要将 routeConfig 里有层级的配置扁平化之后,在循环出来,因为 Route 在 react-router4 里不支持嵌套了。这里代码比较多,想看可以直接在 github 里看下吧。

Redux 的使用

大致讲一下项目中 Redux 的结构和使用方法。
与 Redux 有关的主要分为三部分

1.reducer

先看下 reducer 的写法:

const initState = {
    token: "",
    name: ""
};
const articleReducer = (state = initState, action) => {
    const type = action.type;
    switch (type) {
        case "SET_TOKEN":
            return Object.assign({}, state, { token: action.token});
        case "SET_NAME":
            return Object.assign({}, state, { token: action.name});
        default:
            return state;
    }
};

这里分为两部分,一个是 state,作为一个全局变量的对象存储数据,一个是 reducer 方法,根据传入的 action 来改变 state 的值。这里跟 vuex 中的 state 和 mutation 有点像。项目中因为要分开不同的 reducer,所以把各个业务的 reducer 分成一个 js 最后再用 combineReducers 合并。

import {combineReducers} from "redux";
import home from "./system/home";
import article from "./system/article";
import tag from "./system/tag";
export const rootReducer = asyncReducers => {
    return combineReducers({
        home,
        article,
        tag
    });
};
export default rootReducer;
2.action

action 的主要作用是放一些异步改变 state 的方法(当然也可以放同步方法,这里主要讲下异步方法),主要用作请求获取数据。

const settoken = data => {return { type: "SET_TOKEN", token: data};
};
const setName = data => {return { type: "SET_NAME", name: data};
};
export function login(params) {
    return dispatch => {
        return service({
            url: "/api/login",
            method: "post",
            data: params
        })
            .then(res => {
                const data = res;
                if (data.token) {dispatch(settoken(data.token));
                    dispatch(setName(params.username));
                }
            })
            .catch(err => {console.error("err", err);
            });
    };
}

上面代码表示,当通过请求接口取得数据后,通过 dispatch 参数,调用 reducer,根据 type 和其他属性,改变 state。我们先记住,最后调用 action 的是 dispatch 对象,例如 dispatch(login(params)),具体原因等下再解释。
我们先接着看创建 store 的方法:

//store/index.js
import rootReducer from "../reducers";
import {createStore, applyMiddleware, compose} from "redux";
import thunkMiddleware from "redux-thunk";
import {createLogger} from "redux-logger";

const loggerMiddleware = createLogger();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
export default function configureStore(preloadedState) {
    return createStore(rootReducer(),
        preloadedState,
        composeEnhancers(applyMiddleware(thunkMiddleware, loggerMiddleware))
    );
}

这里先主要讲一下 redux-thunk 的 thunkMiddleware 吧。默认情况下 redux 只能 dispatch 一个 plain object(简单对象),例如:

dispatch({
    type: 'SOME_ACTION_TYPE',
    data: 'xxxx'
});

这样就会有个问题,如果我想 dispatch 一个异步的函数——就像上面讲的 login 方法——是不可以的,所以异步获取的数据想要改变 state 就无法实现了。
thunkMiddleware 正好可以解决这个问题,看下 createThunkMiddleware 源码:

function createThunkMiddleware(extraArgument) {return ({ dispatch, getState}) => next => action => {if (typeof action === 'function') {return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

容易看出,当 dispatch 传入参数是函数时,这个函数会接收到 dispatch,getState 作为参数,异步方法得到结果后,再使用传入的 dispatch 参数改变 state 就可以了。
btw ,redux 里的 applyMiddleware 作用是添加中间件,compose 用于从左到右组合多个函数,有兴趣的可以查一下相关资料,compose 函数解析可以看这里,估计面试爱问这个。

3. 组件中应用
//Login.jsx
import {login} from "@/action/system/login";
import {connect} from "react-redux";

const mapStateProps = state => ({name:state.login.name});
const mapDispatchProps = dispatch => ({
    login: params => {console.log("this", this);
        dispatch(
            login({...params})
        );
    }
});
const enhance = connect(
    mapStateProps,
    mapDispatchProps
);
export default enhance(LoginForm);

在这里,将需要的调 action 的放在 mapDispatchProps 里,需要读取的 state 放在 mapStateProps 里,再用调用 connect。

connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])

connect 函数返回一个 connect 类,里面包含要渲染的 wrappedComponent,然后将 stateProps,dispatchProps 还有 ownProps 合并起来,一起唱诶 wrappedComponent。详细 connect 分析可以看这里,还有这里。
内部组件,如 LoginForm 可以通过 this.props,得到 mapStateProps,mapDispatchProps 的对象和方法。

四.TodoList

记录下整个博客项目之后需要完善的东西:

  • 增加评论插件
  • 增加 sentry 监控前端异常
  • 增加 react ssr
  • 增加用户与权限管理

五. 参考文档

https://segmentfault.com/a/11…
https://www.jianshu.com/p/bc5…
https://segmentfault.com/a/11…
https://www.jianshu.com/p/677…
https://segmentfault.com/a/11…
https://segmentfault.com/a/11…
https://www.yangqq.com/downlo…

六. 总结

本文例举了项目开发中前端遇到的一些问题和值得注意的点,主要侧重讲了一下初学 React 会遇到的基础问题。接下来会从服务端与部署方面讲下项目中遇到的具体问题,有空就写。

退出移动版