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

今天给你们介绍的主要是咱们全栈CMS系统的未讲解完的后台部分和前台部分,若是对项目背景和技术栈不太了解,能够查看我以前的文章javascript

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

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

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

摘要

本文将主要介绍以下内容:vue

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

因为每个技术点实现的细节不少,建议先学习相关内容,不懂的能够和我交流。若是只想了解vue或react相关的内容,能够直接跳到文章的第4部分。java

正文

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

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

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

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

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

源码以下:ios

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作多站点的配置以及如何优化代码的内容我会用整篇文件作一个详细的介绍,但愿你们有所收获,若是想学习项目源码,能够关注公众号《趣谈前端》加入咱们一块儿学习讨论。

更多推荐

相关文章
相关标签/搜索