一.简介

项目最开始目的是为了熟悉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非常实用的一个功能,装饰器方法写起来也很方便,但是直接写无法解析。

@withRouterclass 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_unountclass 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.jsimport 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.jsimport 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.jsximport { 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会遇到的基础问题。接下来会从服务端与部署方面讲下项目中遇到的具体问题,有空就写。