阅读 1379

CMS全栈项目之Vue和React篇(下)(含源码)

今天给大家介绍的主要是我们全栈CMS系统的未讲解完的后台部分和前台部分,如果对项目背景和技术栈不太了解,可以查看我之前的文章

基于nodeJS从0到1实现一个CMS全栈项目(上)

基于nodeJS从0到1实现一个CMS全栈项目(中)

基于nodeJS从0到1实现一个CMS全栈项目的服务端启动细节

摘要

本文将主要介绍如下内容:

  • 实现自定义的koa中间件和restful API
  • koa路由和service层实现
  • 模版引擎pug的基本使用及技巧
  • vue管理后台页面的实现及源码分享
  • react客户端前台的具体实现及源码分享
  • pm2部署以及nginx服务器配置

由于每一个技术点实现的细节很多,建议先学习相关内容,不懂的可以和我交流。如果只想了解vue或react相关的内容,可以直接跳到文章的第4部分。

正文

1.实现自定义的koa中间件和restful API

Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。我们可以使用koa提供的use接口和async函数去自定义一些中间件。一个用来实现打印log的中间件如下:

// logger
app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.get('X-Response-Time');
  console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});
复制代码

有关koa的更多介绍可以去官网学习,我们开始正式进入实现中间件的环节。

在我第一章介绍CMS时剖出了目录结构和层级,我们在源码中找到middlewares目录,首先我们来看看common.js,这个文件是存放我们通用中间件的地方,一共定义了如下中间件:

源码如下:

import logger from 'koa-logger';
import koaBody from 'koa-body';
import session from 'koa-session';
import cors from 'koa2-cors';
import sessionStore from '../lib/sessionStore';
import redis from '../db/redis';
import statisticsSchema from '../db/schema/statistics';

// 设置日志
export const Logger = app => app.use(logger())
// 处理请求体
export const KoaBody = app => app.use(koaBody())

// 配置跨域资源共享
export const Cors = app => app.use(cors({
    origin: function(ctx) {
      if (ctx.url.indexOf('/api') > -1) {
        return false;
      }
      return '*';
    },
    exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
    maxAge: 5,
    credentials: true,
    allowMethods: ['GET'],
    allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
  })
)

// 设置session
export const Session = app => {
    app.keys = ['xujiang']
    const SESSION_CONFIG = {
        key: 'zxzkCMS',
        maxAge: 12 * 60 * 60 * 1000,   // session的失效时间,设置为半天
        store: new sessionStore(redis),
        signed: true
    }

    app.use(session(SESSION_CONFIG, app));
}

// 统计网站数据
export const siteStatistics = app => app.use(async (ctx, next) => {
  if(ctx.url.indexOf('articleList?iSaJAx=isAjax') > -1) {
    const views = await statisticsSchema.hget('views')
    statisticsSchema.hmset('views', +views + 1)
  }
  await next()
})
复制代码

其实实现一个中间件很简单,我们只需要在app.use的参数中创建自己的async业务函数就好了,比如siteStatistics,可以参考此方法去做自定义的中间件。

关于restful API的实现,我们在基础架构层来实现。可以看源码的lib下的descorator.js文件。大致分为几块内容:

这块实现会涉及到更多的es6+知识,包括修饰器,symbol等,如有不懂的可以和我交流沟通。

2.koa路由和service层实现

这一块主要采用MVC模式,我们在之前定义了基础的路由类,这样我们就可以正式处理服务端业务,我们可以按模块定义不同的业务接口,通过路由控制器统一管理。

我们实现router和service分离的模式如上图,在api router下我们只会定义请求相应相关的内容,具体的业务逻辑和数据操作统一在service层处理,这样做的好处是方便后期扩展和管理业务逻辑,让代码更可读。当然也可以把数据操作和http统一放在router里,但是这样会造成代码耦合度过高,不利于项目管理。我们来看看具体的实现方式:

  1. router层
// router/statistics
import { controller, get } from '../lib/decorator'
import {
    getSiteStatistics
} from '../service/statistics'

@controller('/api/v0/siteStatistics')
class statisticsController {
    /**
     * 获取所有统计数据
     * @param {*} ctx 
     * @param {*} next 
     */
    @get('/all')
    async getSiteStatistics(ctx, next) {
        const res = await getSiteStatistics()
        if(res && !Array.isArray(res)) {
            ctx.status = 200
            ctx.body = {
                data: res,
                state: 200
            }
        }else {
            ctx.status = 500
            ctx.body = {
                data: res ? res.join(',') : '服务器错误',
                state: 500
            }
        }
    }
}

export default statisticsController
复制代码
  1. service层
// service用来处理业务逻辑和数据库操作
import statisticsSchema from '../db/schema/statistics'

export const getSiteStatistics = async () => {
    const result = await statisticsSchema.hgetall()
    return result
}
复制代码

这里我们举了个简单的例子方便大家理解,至于admin和config等模块的开发也类似,可以结合自己的业务需要去处理。其他模块的代码已写好,可以在我的github中找到。如有不懂,可以和我交流。

3.模版引擎pug的基本使用及技巧

模版引擎这块不是项目中的重点,在项目中也没有涉及到诸如jade,ejs这些模版引擎,但是作为前端,这些多了解还是很好的。我在这里简单介绍一下pug(也就是jade的升级版)。

为了在koa项目中使用模版引擎,我们可以使用koa-views来做渲染,具体使用方式如下:

/***** koa-view基本使用 *****/
 import views from 'koa-views';
 app.use(views(resolve(__dirname, './views'), { extension: 'pug' }));
 app.use(async (ctx, next) => {
     await ctx.render('index', {
         name: 'xujiang',
         years: '248岁'
     })
 });
复制代码

具体页面的pug文件:

  1. index.pug

  1. layout/default

pug采用缩进的方式来规定代码层级,可以使用继承等语法,感兴趣可以参考pug官网学习。这里不做详细介绍。

4.vue管理后台页面的实现及源码分享

首先我们看看vue管理后台的组织架构:

由于后台大部分是动态配置的数据,而且还会有预览功能,所以涉及到大量数据共享的情况,这里我们统一采用vuex来管理状态,vuex的模型如下:

state用来定义初始化store,mutation主要用来处理同步action,action用来处理异步action,type是用来定义state类型的接口文件,如下:

// type.ts
export interface State {
    name: string;
    isLogin: boolean;
    config: Config;
    [propName: string]: any;  // 用来定义可选的额外属性
}

export interface Config {
    header: HeaderType,
    banner: Banner,
    bannerSider: BannerSider,
    supportPay: SupportPay
}

export interface HeaderType {
    columns: string[],
    height: string,
    backgroundColor: string,
    logo: string
}

export interface Banner {
    type: string,
    label: string[],
    bgUrl: string,
    bannerList: any[]
}

export interface BannerSider {
    tit: string,
    imgUrl: string,
    desc: string
}

export interface SupportPay {
    tit: string,
    imgUrl: string
}

// 处理相应的类型
export interface Response {
    [propName: string]: any;
}
复制代码

mutation内容如下:

action如下:

//action.ts
import { 
    HeaderType,
    Banner,
    BannerSider,
    SupportPay,
    Response
 } from './type'
import http from '../utils/http'
import { uuid, formatTime } from '../utils/common'
import { message } from 'ant-design-vue'

export default {
    /**配置 */
    setConfig(context: any, paylod: HeaderType) {
        http.get('/config/all').then((res:Response) => {
            context.commit('setConfig', res.data)
        }).catch((err:any) => {
            message.error(err.data)
        })
    },

    /**header */
    saveHeader(context: any, paylod: HeaderType) {
        http.post('/config/setHeader', paylod).then((res:Response) => {
            message.success(res.data)
            context.commit('saveHeader', paylod)
        }).catch((err:any) => {
            message.error(err.data)
        })  
    },

    /**banner */
    saveBanner(context: any, paylod: Banner) {
        http.post('/config/setBanner', paylod).then((res:Response) => {
            message.success(res.data)
        }).catch((err:any) => {
            message.error(err.data)
        })  
    },

    /**文章列表 */
    getArticles(context: any) {
        http.get('article/all').then((res:Response) => {
            context.commit('getArticles', res.data);
        }).catch((err:any)=>{
            message.error(err.data)
        })
    },

    addArticle(context: any, paylod: any) {
        paylod.id = uuid(8, 10);
        paylod.time = formatTime(Date.now(), '/');
        paylod.views = 0;
        paylod.flover = 0;
        return new Promise((resolve:any, reject:any) => {
            http.post('/article/saveArticle', paylod).then((res:Response) => {
                context.commit('addArticle', paylod)
                message.success(res.data)
                resolve()
            }).catch((err:any) => {
                message.error(err.data)
                reject()
            })
        })  
    }
    // ...
};
复制代码

这里大致列举了几个典型的action,方便大家学习和理解,再进一步的化,我们可以基于它去封装baseAction,这要可以减少大部分复用信息,这里大家可以试试做封装一波。 最后我们统一在index里统一引入:

import Vue from 'vue';
import Vuex from 'vuex';
import { state } from './state';
import mutations from './mutation';
import actions from './action';

Vue.use(Vuex);

export default new Vuex.Store({
  state,
  mutations,
  actions,
});
复制代码

通过这种方式管理vuex,对于后期可扩展性和可维护性,也有一定的帮助。

vue页面部分大家根据之前node篇的用例和数据模型可以知道大致的页面模块和功能点,这里就不在细谈。我们来看看几个关键点:

  • 如何保证页面刷新导航可以正确定位
  • 如何切换页面时做自定义缓存
  • 如何实现模拟pc端,移动端预览
  • 如何使用vuex高级api实现数据监听机制
  • 如何做登录鉴权

接下来我直接剖出我的方案,大家可以参考。

1.如何保证页面刷新导航可以正确定位
// layout.vue
// 页面路由表
const routeMap: any = {
    '/': '1',
    '/banner': '2',
    '/bannerSider': '3',
    '/article': '4',
    '/addArticle': '4',
    '/support': '5',
    '/imgManage': '6',
    '/videoManage': '7',
    '/websiteAnalysis': '8',
    '/admin': '9',
};

// 监听路由变化,匹配当前选中导航
@Watch('$route')
private routeChange(val: Route, oldVal: Route) {
  //  do something
  if(val.path.indexOf('/preview') < 0) {
    this.curSelected = routeMap[val.path] || routeMap[oldVal.path];
  }
}
复制代码
2.如何切换页面时做自定义缓存

我们使用keep-alive做缓存,被他包裹的路由视图下传递key值来确定下次是否被走缓存:

<template>
  <div id="app">
    <keep-alive>
      <router-view :key="key" />
    </keep-alive>
  </div>
</template>

<script lang="ts">
import { Vue } from 'vue-property-decorator';
import Component from 'vue-class-component';

@Component
export default class App extends Vue {
  get key() {
    // 缓存除预览页面之外的其他页面
    console.log(this.$route.path)
    if(this.$route.path.indexOf('/preview') > -1) {
      return '0'
    }else if(this.$route.path === '/login') {
      return '1'
    }else {
      return '2'
    }
  }
}
</script>
复制代码

由于我们的业务是预览和管理页面切换的时候要更新到最新数据,所以我们在这两个模块切换时不走缓存,调用最新数据。登录同理,通过设置不同的key来做分布式缓存。

3.如何实现模拟pc端,移动端预览

实现预览主要我采用基于宽度来做的模拟,通过定义预览路由,来定义pc和移动的屏幕。如果有不懂的,可以和我交流,当然你们也可以采用iframe用模拟。

4.如何使用vuex高级api实现数据监听机制

这里直接剖代码:

public created() {
    let { id, label } = this.$route.query;
    this.type = id ? 1 : 0;
    if(id) {
        // 监听vuex中文章数据的变化,变化则触发action显示文章数据
        // 注:这里这么做是为了防止页面刷新数据丢失
        let watcher = this.$store.watch(
            (state,getter) => {
                return state.articles
            },
            () => {
                this.getDetail(id, label, watcher)
            }
        )

        if(Object.keys(this.$store.state.articles).length) {
            this.getDetail(id, label, watcher)
        }
    }
  }
复制代码

我们使用vuex的watch去监听store的变化,然后去做相应的处理,watch API接受两个回调参数,第一个回调返回一个值,如果值变化了,就会触发第二个参数的回调,这有点类似与react hooks的memo和callback。

5.如何做登录鉴权

登录鉴权主要是和后端服务协商一套规则,后台通过校验是否登录或者是否有权限操作某个模块,一般通过response的相应数据通知给前端,这里我们主要讲一下登录鉴权的,如果当前用户没登录或者session过期,node服务端会返回401,这样前端就可以去做重定向操作了:

//http模块封装
import axios from 'axios'
import qs from 'qs'

axios.interceptors.request.use(config => {
  // loading
  return config
}, error => {
  return Promise.reject(error)
})

axios.interceptors.response.use(response => {
  return response
}, error => {
  return Promise.resolve(error.response)
})

function checkStatus (response) {
  // loading
  // 如果http状态码正常,则直接返回数据
  if(response) {
    if (response.status === 200 || response.status === 304) {
      return response.data
      // 如果不需要除了data之外的数据,可以直接 return response.data
    } else if (response.status === 401) {
      location.href = '/login';
    } else {
      throw response.data
    }
  } else {
    throw {data:'网络错误'}
  }
  
}

// axios默认参数配置
axios.defaults.baseURL = '/api/v0';
axios.defaults.timeout = 10000;

export default {
  post (url, data) {
    return axios({
      method: 'post',
      url,
      data: qs.stringify(data),
      headers: {
        'X-Requested-With': 'XMLHttpRequest',
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
      }
    }).then(
      (res) => {
        return checkStatus(res)
      }
    )
  },
  get (url, params) {
    return axios({
      method: 'get',
      url,
      params, // get 请求时带的参数
      headers: {
        'X-Requested-With': 'XMLHttpRequest'
      }
    }).then(
      (res) => {
        return checkStatus(res)
      }
    )
  },
  del (url, params) {
    return axios({
      method: 'delete',
      url,
      params, // get 请求时带的参数
      headers: {
        'X-Requested-With': 'XMLHttpRequest'
      }
    }).then(
      (res) => {
        return checkStatus(res)
      }
    )
  }
}
复制代码

至于具体的axios请求拦截器和响应拦截器的设置,我们可以根据具体业务来操作和添加自定义逻辑。

5.react客户端前台的具体实现及源码分享

react部分我主要采用自己搭建的webpack做模块打包,想学习webpack的可以参考我的webpack配置,目前打包文件可以兼容到ie9+。 react前台主要有:

这几部分都是通过vue后台配置出来的,大家也可以配置符合自己风格的网站。 react前台我们主要使用react hooks来搭建,没有采用redux等状态管理库,如果想学习redux相关知识,可以进入我们的学习群一起学习。 首页代码如下:

import React, { useState, useEffect } from "react"
import { Carousel } from 'antd'
import ArticleItem from '../../components/ArticleItem'
import { isPC, ajax, unparam } from 'utils/common'

import './index.less'

function Home(props) {
    let [articles, setArticles] = useState([])
    let { search } = props.location

    function getArticles(cate = '', num = 10, page = 0) {
        ajax({
            url: '/article/articleList',
            method: 'get',
            data: { cate, num, page }
        }).then(res => {
            setArticles(res.data || [])
        }).catch(err => console.log(err))
    }

    if(search && sessionStorage.getItem('prevCate') !== search) {
        getArticles(unparam(search).cate)
        sessionStorage.setItem('prevCate', search)
    }

    useEffect(() => {
        getArticles()
        return () => {
            sessionStorage.removeItem('prevCate')
        }
    }, [])
    return <div className="home-wrap">
        <div className="banner-wrap">
            {
                isPC ?
                <React.Fragment>
                    <div className="banner-sider">
                        <div className="tit">{ props.bannerSider.tit }</div>
                        <img src={props.bannerSider.imgUrl} alt="" />
                        <div className="desc">{ props.bannerSider.desc }</div>
                    </div>
                    {
                        +props.banner.type ?
                        <Carousel autoplay className="banner">
                            {
                                props.banner.bannerList.map((item, i) => (
                                    <div key={i}>
                                        <a className="banner-img" href="" style={{ backgroundImage: 'url('+ item.imgUrl +')'}}>
                                            <p className="tit">{ item.tit }</p>
                                        </a>
                                    </div>
                                ))
                            }
                        </Carousel>
                        :
                        <div className="banner">
                            <div className="banner-img" style={{backgroundImage: 'url('+ props.banner.bgUrl +')'}}>
                                {
                                    props.banner.label.map((item, i) => (
                                        <span className="banner-label" style={{left: 80*(i+1) + 'px'}} key={i}>
                                            { item }
                                        </span>
                                    ))
                                }
                            </div>
                        </div>
                    }
                </React.Fragment>
                :
                <Carousel autoplay className="banner">
                    {
                        props.banner.bannerList.map((item, i) => (
                            <a className="banner-img" href="" key={i} style={{ backgroundImage: 'url('+ item.imgUrl +')'}}>
                                <p className="tit">{ item.tit }</p>
                            </a>
                        ))
                    }
                </Carousel>
            }
        </div>
        <div className="article-list">
            <div className="tit">最新文章</div>
            {
                articles.map((item, i) => (
                    <ArticleItem {...item} key={i} />
                ))
            }
        </div>
    </div>
}

export default Home
复制代码

文章详情:

import React, { useState, useEffect } from "react"
import { Button, Modal, Skeleton, Icon } from 'antd'
import { ajax, unparam } from 'utils/common'
import QTQD from 'images/logo.png'
import './index.less'

function ArticleDetail(props) {
    let [isPayShow, setIsPayShow] = useState(false)
    let [detail, setDetail] = useState(null)
    let [likeNum, setLikeNum] = useState(0)
    let [articleContent, setArticleContent] = useState(null)
    let [isShowLike, setShowLike] = useState(false)

    function toggleModal(flag) {
        setIsPayShow(flag)
    }

    function getcontent(url) {
        ajax({
            url
        }).then(res => {
            setArticleContent(res.content)
            
        })
    }

    function showLike() {
        if(!isShowLike) {
            ajax({
                url: `/article/likeArticle/${unparam(props.location.search).id}`,
                method: 'post'
            }).then(res => {
                setShowLike(true)
                setLikeNum(prev => prev + 1)
            })
        }
    }

    useEffect(() => {
        ajax({
            url: `/article/${unparam(props.location.search).id}`
        }).then(res => {
            setDetail(res.data)
            setLikeNum(res.data.flover)
            getcontent(res.data.articleUrl)
        })
        return () => {
            
        };
    }, [])

    return !detail ? <Skeleton active /> 
        :
    <div className="article-wrap">
        <div className="article">
            <div className="tit">{ detail.tit }</div>
            <div className="article-info">
                <span className="article-type">{ detail.label }</span>
                <span className="article-time">{ detail.time }</span>
                <span className="article-views"><Icon type="eye" />&nbsp;{ detail.views }</span>
                <span className="article-flover"><Icon type="fire" />&nbsp;{ likeNum }</span>
            </div>
            <div className="article-content" dangerouslySetInnerHTML={{__html: articleContent}}></div>
            <div className="article-ft">
                <div className="article-label">

                </div>
                <div className="support-author">
                    <p>给作者打赏,鼓励TA抓紧创作!</p>
                    <div className="support-wrap">
                        <Button className="btn-pay" type="danger" ghost onClick={() => toggleModal(true)}>赞赏</Button>
                        <Button className="btn-flover" type="primary" onClick={showLike} disabled={isShowLike}>{ !isShowLike ? '点赞' : '已赞'}({likeNum})</Button>
                        {
                            isShowLike && <Icon type="like" className="like-animation" />
                        }
                    </div>
                </div>
            </div>
        </div>
        <div className="sider-bar">
            <h2>友情赞助</h2>
            <div className="sider-item">
                <img src={QTQD} alt=""/>
                <p>公众号《趣谈前端》</p>
            </div>
        </div>
        <Modal 
            visible={isPayShow} 
            onCancel={() => toggleModal(false)} 
            width="300px"
            footer={null}
        >
            <div className="img-wrap">
                <img src={props.supportPay.imgUrl} alt={props.supportPay.tit} />
                <p>{ props.supportPay.tit }</p>
            </div>
        </Modal>
    </div>
}

export default ArticleDetail
复制代码

由于前台实现起来比较简单,至于如何定义router,如何使用骨架屏,我都在代码里写了完整注释,感兴趣的可以和我交流。

6.pm2部署以及nginx服务器配置

pm2做服务器持久化以及nginx做多站点的配置以及如何优化代码的内容我会用整篇文件做一个详细的介绍,希望大家有所收获,如果想学习项目源码,可以关注公众号《趣谈前端》加入我们一起学习讨论。

更多推荐

关注下面的标签,发现更多相似文章
评论