搭建个人博客网站 – vue技术学习


开源代码:个性化个人博客系统

参考项目:风丶宇的个人博客



一、项目概述

项目主要是基于SpringBoot + Vue 开发的前后端分离博客,本文主要涉及项目前端技术部分,其中包含vue + vuex + vue-router + axios + vuetify + element + echarts等技术,主要是学习Vue技术的使用部分,同时涉及axios发送请求,markdown编辑器的引入、登录验证、跨域请求等前台问题。



二、项目前端前台结构

前端项目位于blog-vue下,blog为前台,admin为后台。


本文主要是前端前台个人博客网站基本功能实现的Vue代码的阅读分析和Vue初步入门学习。

blog
├── assets           --  全局、字体CSS文件模块
├── public           --  网站页面图标模块
├── plugins          --  vuetify UI组件模块
├── Router           --  路由模块
├── Store            --  状态模块
├── Utils            --  Markdown转换模块
├── conponents       --  网站基础组件模块
├── Views            --  网站功能组模块
├── vue.config.js    --  项目打包配置文件
├── babel.config.js  --  语法转化插件
├── package.json     --  项目依赖配置
├── Main.js          --  项目入口文件
└── App.vue          --  主应用组件



三、各模块阅读分析

(1) 前端开发组件库

源码项目使用的是Vuetify UI。其实还有其他组件库可供选择,例如Element UI ,但是为什么项目开发者并没有选择后者呢,原因可能是前者多端适配,并且自带一条样式和动画,没有专业的美工也可以做出漂亮的画面,并且源开发团队仍然在持续更新。当然二者各有优劣,这是只是我作为Vue初学者对于组件框架选择的一个学习理解。




首先在项目的src目录下的plugins目录下的vuetify.js文件,引入Vuetify UI依赖。

import Vue from "vue";
import Vuetify from "vuetify/lib";

Vue.use(Vuetify);

export default new Vuetify({});

然后在项目的src目录下的main.js文件中,全局引入Vuetify。

import vuetify from "./plugins/vuetify";

这样,组件库中的组件才可以在项目中任意使用。




(2) 配置路由页面

定义页面路由的目的是我们在访问相应路径的时候,可以根据路由来确定到我们将要访问的页面。


在views文件夹中的页面有

Views
├── Abnout.vue       --  关于我详情页
├── Home.vue         --  网站首页
├── Article.vue      --  博客详情页
├── Archive.vue      --  归档列表
├── Album.vue        --  相册详情页
├── Talk.vue         --  说说
├── TalkInfo.vue     --  说说详情页
├── Photo.vue        --  具体图片页
├── Tag.vue          --  标签页
├── Category.vue     --  分类页
├── Link.vue         --  友情链接页面
├── Message.vue      --  留言板详情页
└── User.vue         --  个人中心详情页

页面路由设置在router文件下的index.js中。配置如下:

import Vue from "vue";
import VueRouter from "vue-router";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    component: resolve => require(["../views/home/Home.vue"], resolve)
  },
  {
    path: "/articles/:articleId",
    component: resolve => require(["../views/article/Article.vue"], resolve)
  },
  {
    path: "/archives",
    component: resolve => require(["../views/archive/Archive.vue"], resolve),
    meta: {
      title: "归档"
    }
  },
  {
    path: "/albums",
    component: resolve => require(["../views/album/Album.vue"], resolve),
    meta: {
      title: "相册"
    }
  },
  {
    path: "/talks",
    component: resolve => require(["../views/talk/Talk.vue"], resolve),
    meta: {
      title: "说说"
    }
  },
  {
    path: "/talks/:talkId",
    component: resolve => require(["../views/talk/TalkInfo.vue"], resolve),
    meta: {
      title: "说说"
    }
  },
  {
    path: "/albums/:albumId",
    component: resolve => require(["../views/album/Photo.vue"], resolve)
  },
  {
    path: "/tags",
    component: resolve => require(["../views/tag/Tag.vue"], resolve),
    meta: {
      title: "标签"
    }
  },
  {
    path: "/categories",
    component: resolve => require(["../views/category/Category.vue"], resolve),
    meta: {
      title: "分类"
    }
  },
  {
    path: "/categories/:categoryId",
    component: resolve => require(["../views/article/ArticleList.vue"], resolve)
  },
  {
    path: "/tags/:tagId",
    component: resolve => require(["../views/article/ArticleList.vue"], resolve)
  },
  {
    path: "/links",
    component: resolve => require(["../views/link/Link.vue"], resolve),
    meta: {
      title: "友链列表"
    }
  },
  {
    path: "/about",
    component: resolve => require(["../views/about/About.vue"], resolve),
    meta: {
      title: "关于我"
    }
  },
  {
    path: "/message",
    component: resolve => require(["../views/message/Message.vue"], resolve),
    meta: {
      title: "留言板"
    }
  },
  {
    path: "/user",
    component: resolve => require(["../views/user/User.vue"], resolve),
    meta: {
      title: "个人中心"
    }
  },
  {
    path: "/oauth/login/qq",
    component: resolve => require(["../components/OauthLogin.vue"], resolve)
  },
  {
    path: "/oauth/login/weibo",
    component: resolve => require(["../components/OauthLogin.vue"], resolve)
  }
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes
});

export default router;

上述代码中,可以发现有一个类似的结构,就是例如下面所示的结构。

  {
    path: "/",
    component: resolve => require(["../views/home/Home.vue"], resolve)
  },

此处举例结构的作用是进行路由配置,规定’/'引入到home组件,而其中的

resolve => require(["../views/home/Home.vue"]

这种写法是其实是异步模块获取,打包的时候每次访问这个路由的时候会单调单个文件,按需加载。resolve 就是 promise 的 resolve 回调,组件加载成功后调用,同时因为因为 webpack 支持多种模块规范语法 所以还有很多其他方式的异步加载,例如:


①AMD异步

require(['./a', './b'], function(a, b){
    console.log(a, b)
});

②Commonjs异步

require.ensure([], function(require){
    var a = require('./a');
    console.log(a)
});

③ES异步

import('./a').then(a => {
    console.log(a)
})




而require这种写法我认为是一种路由懒加载的方法。还有另外种写法就是用import,这种是把component打包到一个文件里面,初次就读取全部,具体写法可以改成下面这种:

{
  path: '/',
  component: () => import('../views/home/Home.vue'),
  meta: {
    title: 'home'
  }
}

(3)登录页面

登录界面这里主要是根据邮箱号和密码进行登录的,同时用户也可以选择第三方快捷登录,减少注册成本。项目登录页面如下图所示:


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mIAKmi2q-1671395264328)(images/f7f61c2ceb320c02b9e17428890758d41e33ac11ad1650f43bd34e6baab85ef8.png)]




3.1、登录验证

这里点击登录按钮以后,会有一个验证登陆的过程,通过阅读源代码,我在这里分析一下验证登录的思路:

1.邮箱密码登录

发起登录请求之后,通过URLSearchParams()方法以及append方法来查询对应的用户信息字符串,并通过axios.post方法来反馈登录信息验证结果。如果用户存在那么我们通过$store.commit方法将数据共享到我们的浏览器,并且打印登陆成功信息。若用户不存在则打印错误信息。

2.第三方登录

同样的也是要有一个登录验证的过程,但是这个就比较简单,直接链接跳转至第三方的授权登录页面即可

代码如下:

  methods: {
    openRegister() {
      this.$store.state.loginFlag = false;
      this.$store.state.registerFlag = true;
    },
    openForget() {
      this.$store.state.loginFlag = false;
      this.$store.state.forgetFlag = true;
    },
    login() {
      var reg = /^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/;
      if (!reg.test(this.username)) {
        this.$toast({ type: "error", message: "邮箱格式不正确" });
        return false;
      }
      if (this.password.trim().length == 0) {
        this.$toast({ type: "error", message: "密码不能为空" });
        return false;
      }
      const that = this;
      // eslint-disable-next-line no-undef
      var captcha = new TencentCaptcha(this.config.TENCENT_CAPTCHA, function(
        res
      ) {
        if (res.ret === 0) {
          //发送登录请求
          let param = new URLSearchParams();
          param.append("username", that.username);
          param.append("password", that.password);
          that.axios.post("/api/login", param).then(({ data }) => {
            if (data.flag) {
              that.username = "";
              that.password = "";
              that.$store.commit("login", data.data);
              that.$store.commit("closeModel");
              that.$toast({ type: "success", message: "登录成功" });
            } else {
              that.$toast({ type: "error", message: data.message });
            }
          });
        }
      });
      // 显示验证码
      captcha.show();
    },
    qqLogin() {
      //保留当前路径
      this.$store.commit("saveLoginUrl", this.$route.path);
      if (
        navigator.userAgent.match(
          /(iPhone|iPod|Android|ios|iOS|iPad|Backerry|WebOS|Symbian|Windows Phone|Phone)/i
        )
      ) {
        // eslint-disable-next-line no-undef
        QC.Login.showPopup({
          appId: this.config.QQ_APP_ID,
          redirectURI: this.config.QQ_REDIRECT_URI
        });
      } else {
        window.open(
          "https://graph.qq.com/oauth2.0/show?which=Login&display=pc&client_id=" +
            +this.config.QQ_APP_ID +
            "&response_type=token&scope=all&redirect_uri=" +
            this.config.QQ_REDIRECT_URI,
          "_self"
        );
      }
    },
    weiboLogin() {
      //保留当前路径
      this.$store.commit("saveLoginUrl", this.$route.path);
      window.open(
        "https://api.weibo.com/oauth2/authorize?client_id=" +
          this.config.WEIBO_APP_ID +
          "&response_type=code&redirect_uri=" +
          this.config.WEIBO_REDIRECT_URI,
        "_self"
      );
    }
  }

上述代码中涉及打印的函数,toast是一种轻量级的反馈或者提示,可以用来显示不会打断用户操作的内容,适合用于页面转场、数据交互的等场景中。一次只显示一个Toast,有图标的Toast字数为 4-6 个,没有图标的Toast字数不宜超过14个。



3.1、状态同步

在上述代码中,我们用到了$store来同步用户信息,那么这个同步是如何完成的呢,其实是我们在store文件下的index.js中进行了封装和设置。

index.js中的代码如下:

import Vue from "vue";
import Vuex from "vuex";
import createPersistedState from "vuex-persistedstate";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    searchFlag: false,
    loginFlag: false,
    registerFlag: false,
    forgetFlag: false,
    emailFlag: false,
    drawer: false,
    loginUrl: "",
    userId: null,
    avatar: null,
    nickname: null,
    intro: null,
    webSite: null,
    loginType: null,
    email: null,
    articleLikeSet: [],
    commentLikeSet: [],
    talkLikeSet: [],
    blogInfo: {}
  },
  mutations: {
    login(state, user) {
      state.userId = user.userInfoId;
      state.avatar = user.avatar;
      state.nickname = user.nickname;
      state.intro = user.intro;
      state.webSite = user.webSite;
      state.articleLikeSet = user.articleLikeSet ? user.articleLikeSet : [];
      state.commentLikeSet = user.commentLikeSet ? user.commentLikeSet : [];
      state.talkLikeSet = user.talkLikeSet ? user.talkLikeSet : [];
      state.email = user.email;
      state.loginType = user.loginType;
    },
    logout(state) {
      state.userId = null;
      state.avatar = null;
      state.nickname = null;
      state.intro = null;
      state.webSite = null;
      state.articleLikeSet = [];
      state.commentLikeSet = [];
      state.talkLikeSet = [];
      state.email = null;
      state.loginType = null;
    },
    saveLoginUrl(state, url) {
      state.loginUrl = url;
    },
    saveEmail(state, email) {
      state.email = email;
    },
    updateUserInfo(state, user) {
      state.nickname = user.nickname;
      state.intro = user.intro;
      state.webSite = user.webSite;
    },
    savePageInfo(state, pageList) {
      state.pageList = pageList;
    },
    updateAvatar(state, avatar) {
      state.avatar = avatar;
    },
    checkBlogInfo(state, blogInfo) {
      state.blogInfo = blogInfo;
    },
    closeModel(state) {
      state.registerFlag = false;
      state.loginFlag = false;
      state.searchFlag = false;
      state.emailFlag = false;
    },
    articleLike(state, articleId) {
      var articleLikeSet = state.articleLikeSet;
      if (articleLikeSet.indexOf(articleId) != -1) {
        articleLikeSet.splice(articleLikeSet.indexOf(articleId), 1);
      } else {
        articleLikeSet.push(articleId);
      }
    },
    commentLike(state, commentId) {
      var commentLikeSet = state.commentLikeSet;
      if (commentLikeSet.indexOf(commentId) != -1) {
        commentLikeSet.splice(commentLikeSet.indexOf(commentId), 1);
      } else {
        commentLikeSet.push(commentId);
      }
    },
    talkLike(state, talkId) {
      var talkLikeSet = state.talkLikeSet;
      if (talkLikeSet.indexOf(talkId) != -1) {
        talkLikeSet.splice(talkLikeSet.indexOf(talkId), 1);
      } else {
        talkLikeSet.push(talkId);
      }
    }
  },
  actions: {},
  modules: {},
  plugins: [
    createPersistedState({
      storage: window.sessionStorage
    })
  ]
});

上述代码实现中用到的vuex store缓存存储,vuex的设计是将数据存在一个对象树的变量中,我们的个人博客网站从这个变量中取数据,然后供应用使用,当将当前页面关闭,vuex中的变量会随着消失,重新打开页面的时候,需要重新生成。

上述代码实现中完全没有使用浏览器缓存(Cookies,Token),所以用户每次退出之后重新登录都需要重新输入账号密码,十分麻烦。

我认为要使用 vuex 还是使用浏览器缓存,要看具体的业务场景比如:像用户校验的token就可以存在 cookie 中,因为用户再次登录的时候能用到。而像用户的权限数据,这些是有一定安全性考虑,且不同用户的权限不同,放在vuex中更合理,用户退出时,自动销毁。像本项目这样使用单一存储方式的,对用户体验的影响比较大。

进一步分析,上述代码中,vuex中的state的设计思路是保证数据的一致性和连续性,而让state中的值只能通过action来发起commit,进而改变state中的值。而,action中是同步还是异步,都是单向地改变state中的值。



3.3、axios拦截器

由于我们的登录有时候会出现密码输出错误的情况,虽然不需要做任何操作,但是有时候我们还是需要进行弹窗提示,这样对于这种错误信息的弹窗,我们就需要对其进行封装和设置。

原项目代码采取了一种较为分散的方式进行设置:

 //发送登录请求
let param = new URLSearchParams();
param.append("username", that.username);
param.append("password", that.password);
that.axios.post("/api/login", param).then(({ data }) => {
    if (data.flag) {
        that.username = "";
        that.password = "";
        that.$store.commit("login", data.data);
        that.$store.commit("closeModel");
        that.$toast({ type: "success", message: "登录成功" });
        } else {
            that.$toast({ type: "error", message: data.message });
            }

//以及其他vue中的
    that.axios.post("/api/login", param).then(({ data }) => {
    if (data.flag) {
    // 登录后保存用户信息
    that.$store.commit("login", data.data);
    // 加载用户菜单
    generaMenu();
    that.$message.success("登录成功");
    that.$router.push({ path: "/" });
    } else {
    that.$message.error(data.message);
    }

类似的还有很多,阅读结束发现源代码貌似并没有使用全局的axios拦截器,虽然功能都可以正常实现,但是可能在让其他人学习时产生一些疑惑和困扰。经过学习源代码,我思考过另外的写法,我想对axios设置一个全局定义拦截器,包括前置拦截和后置拦截,当如果说我们返回数据的code或者status不正常就会弹窗提示相应的信息。




操作是在在src目录下创建一个文件axios.js(与main.js同级),定义axios的拦截,并且尝试使用另外一种UI框架:

import axios from "axios"
import Element from "element-ui"
import router from "../router"
import store from "../store";
 
 
//设置统一请求路径
axios.defaults.baseURL = "/api"
//前置拦截
axios.interceptors.request.use(config => {
    return config
})
 
/**
 * 对请求的返回数据进行过滤
 */
axios.interceptors.response.use(response => {
    let res = response.data;
    console.log(res)
    //如果状态码是200,直接放行
    if (res.code === 200) {
        return response
    } else {
        //如果是用户名错误会直接断言处理,不会到达这一步!
        //弹窗提示!
        Element.Message.error('用户名或密码错误!', {duration: 3 * 1000})
        //返回错误信息
        return Promise.reject(response.data.msg)
    }
},
    //如果是非密码错误,会到达这一步
    error => {
        console.log(error)
        //如果返回的数据里面是空
        if (error.response.data){
            error.message = error.response.data.msg;
        }
        //如果状态码是401,
        if (error.response.status === 401){
            store.commit("REMOVE_INFO")
            router.push("/login")
        }
 
        //弹出错误信息
        Element.Message.error(error.message, {duration: 3 * 1000})
        return Promise.reject(error)
})
 

如果按照上述写法的改变,那么在main.js中也要写import导入该axios.js全局拦截器文件。其中使用的两种拦截,是在阅读源代码学习axios拦截器使用过程中涉及的部分知识,简单来说就是

前置拦截: 在请求之前的拦截,可以在其中统一为所有需要权限的请求装配上header的token信息,这样就不要在使用的时候再配置。

后缀拦截: 在请求返回之后的拦截,可以在请求之后对返回的数据进行处理和验证,


(4)博客列表(首页)

在我们登录完成之后就会进入了博客的主页面,在该页面主要是展示了当前录入到系统中的博客信息,如下所示:
在这里插入图片描述

整个博客的显示是按照时间线的方式展开的,最后发布的博客会在第一个出现,同时你会发现在博客主页的头部会展示我们的一些基本信息,包括个人头像,用户名以及搜索,友链等功能,这个头部信息会一直显示在我们的页面中,所以为了能够实现代码复用,减少代码的使用量,我们将头部信息全部都抽取了出来,放置在了TopNavBar.vue页面中,

<template>
  <v-app-bar app :class="navClass" hide-on-scroll flat height="60">
    <!-- 手机端导航栏 -->
    <div class="d-md-none nav-mobile-container">
      <div style="font-size:18px;font-weight:bold">
        <router-link to="/">
          {{ blogInfo.websiteConfig.websiteAuthor }}
        </router-link>
      </div>
      <div style="margin-left:auto">
        <a @click="openSearch"><i class="iconfont iconsousuo"/></a>
        <a @click="openDrawer" style="margin-left:10px;font-size:20px">
          <i class="iconfont iconhanbao" />
        </a>
      </div>
    </div>
    <!-- 电脑导航栏 -->
    <div class="d-md-block d-none nav-container">
      <div class="float-left blog-title">
        <router-link to="/">
          {{ blogInfo.websiteConfig.websiteAuthor }}
        </router-link>
      </div>
      <div class="float-right nav-title">
        <div class="menus-item">
          <a class="menu-btn" @click="openSearch">
            <i class="iconfont iconsousuo" /> 搜索
          </a>
        </div>
        <div class="menus-item">
          <router-link class="menu-btn" to="/">
            <i class="iconfont iconzhuye" /> 首页
          </router-link>
        </div>
        <div class="menus-item">
          <a class="menu-btn">
            <i class="iconfont iconfaxian" /> 发现
            <i class="iconfont iconxiangxia2 expand" />
          </a>
          <ul class="menus-submenu">
            <li>
              <router-link to="/archives">
                <i class="iconfont iconguidang" /> 归档
              </router-link>
            </li>
            <li>
              <router-link to="/categories">
                <i class="iconfont iconfenlei" /> 分类
              </router-link>
            </li>
            <li>
              <router-link to="/tags">
                <i class="iconfont iconbiaoqian" /> 标签
              </router-link>
            </li>
          </ul>
        </div>
        <div class="menus-item">
          <a class="menu-btn">
            <i class="iconfont iconqita" /> 娱乐
            <i class="iconfont iconxiangxia2 expand" />
          </a>
          <ul class="menus-submenu">
            <li>
              <router-link to="/albums">
                <i class="iconfont iconxiangce1" /> 相册
              </router-link>
            </li>
            <li>
              <router-link to="/talks">
                <i class="iconfont iconpinglun" /> 说说
              </router-link>
            </li>
          </ul>
        </div>
        <div class="menus-item">
          <router-link class="menu-btn" to="/links">
            <i class="iconfont iconlianjie" /> 友链
          </router-link>
        </div>
        <div class="menus-item">
          <router-link class="menu-btn" to="/about">
            <i class="iconfont iconzhifeiji" /> 关于
          </router-link>
        </div>
        <div class="menus-item">
          <router-link class="menu-btn" to="/message">
            <i class="iconfont iconpinglunzu" /> 留言
          </router-link>
        </div>
        <div class="menus-item">
          <a
            class="menu-btn"
            v-if="!this.$store.state.avatar"
            @click="openLogin"
          >
            <i class="iconfont icondenglu" /> 登录
          </a>
          <template v-else>
            <img
              class="user-avatar"
              :src="this.$store.state.avatar"
              height="30"
              width="30"
            />
            <ul class="menus-submenu">
              <li>
                <router-link to="/user">
                  <i class="iconfont icongerenzhongxin" /> 个人中心
                </router-link>
              </li>
              <li>
                <a @click="logout"><i class="iconfont icontuichu" /> 退出</a>
              </li>
            </ul>
          </template>
        </div>
      </div>
    </div>
  </v-app-bar>
</template>

<script>
export default {
  mounted() {
    window.addEventListener("scroll", this.scroll);
  },
  data: function() {
    return {
      navClass: ""
    };
  },
  methods: {
    scroll() {
      const that = this;
      let scrollTop =
        window.pageYOffset ||
        document.documentElement.scrollTop ||
        document.body.scrollTop;
      that.scrollTop = scrollTop;
      if (that.scrollTop > 60) {
        that.navClass = "nav-fixed";
      } else {
        that.navClass = "nav";
      }
    },
    openSearch() {
      this.$store.state.searchFlag = true;
    },
    openDrawer() {
      this.$store.state.drawer = true;
    },
    openLogin() {
      this.$store.state.loginFlag = true;
    },
    logout() {
      //如果在个人中心则跳回上一页
      if (this.$route.path == "/user") {
        this.$router.go(-1);
      }
      this.axios.get("/api/logout").then(({ data }) => {
        if (data.flag) {
          this.$store.commit("logout");
          this.$toast({ type: "success", message: "注销成功" });
        } else {
          this.$toast({ type: "error", message: data.message });
        }
      });
    }
  },
  computed: {
    avatar() {
      return this.$store.state.avatar;
    },
    blogInfo() {
      return this.$store.state.blogInfo;
    }
  }
};
</script>

<style scoped>
i {
  margin-right: 4px;
}
ul {
  list-style: none;
}
.nav {
  background: rgba(0, 0, 0, 0) !important;
}
.nav a {
  color: #eee !important;
}
.nav .menu-btn {
  text-shadow: 0.05rem 0.05rem 0.1rem rgba(0, 0, 0, 0.3);
}
.nav .blog-title a {
  text-shadow: 0.1rem 0.1rem 0.2rem rgba(0, 0, 0, 0.15);
}
.theme--light.nav-fixed {
  background: rgba(255, 255, 255, 0.8) !important;
  box-shadow: 0 5px 6px -5px rgba(133, 133, 133, 0.6);
}
.theme--dark.nav-fixed {
  background: rgba(18, 18, 18, 0.8) !important;
}
.theme--dark.nav-fixed a {
  color: rgba(255, 255, 255, 0.8) !important;
}
.theme--light.nav-fixed a {
  color: #4c4948 !important;
}
.nav-fixed .menus-item a,
.nav-fixed .blog-title a {
  text-shadow: none;
}
.nav-container {
  font-size: 14px;
  width: 100%;
  height: 100%;
}
.nav-mobile-container {
  width: 100%;
  display: flex;
  align-items: center;
}
.blog-title,
.nav-title {
  display: flex;
  align-items: center;
  height: 100%;
}
.blog-title a {
  font-size: 18px;
  font-weight: bold;
}
.menus-item {
  position: relative;
  display: inline-block;
  margin: 0 0 0 0.875rem;
}
.menus-item a {
  transition: all 0.2s;
}
.nav-fixed .menu-btn:hover {
  color: #49b1f5 !important;
}
.menu-btn:hover:after {
  width: 100%;
}
.menus-item a:after {
  position: absolute;
  bottom: -5px;
  left: 0;
  z-index: -1;
  width: 0;
  height: 3px;
  background-color: #80c8f8;
  content: "";
  transition: all 0.3s ease-in-out;
}
.user-avatar {
  cursor: pointer;
  border-radius: 50%;
}
.menus-item:hover .menus-submenu {
  display: block;
}
.menus-submenu {
  position: absolute;
  display: none;
  right: 0;
  width: max-content;
  margin-top: 8px;
  box-shadow: 0 5px 20px -4px rgba(0, 0, 0, 0.5);
  background-color: #fff;
  animation: submenu 0.3s 0.1s ease both;
}
.menus-submenu:before {
  position: absolute;
  top: -8px;
  left: 0;
  width: 100%;
  height: 20px;
  content: "";
}
.menus-submenu a {
  line-height: 2;
  color: #4c4948 !important;
  text-shadow: none;
  display: block;
  padding: 6px 14px;
}
.menus-submenu a:hover {
  background: #4ab1f4;
}
@keyframes submenu {
  0% {
    opacity: 0;
    filter: alpha(opacity=0);
    transform: translateY(10px);
  }
  100% {
    opacity: 1;
    filter: none;
    transform: translateY(0);
  }
}
</style>

如果在其他页面中需要该头部信息时,只需要将TopNavbar页面引用到该页面中,之后在内容中写入即可。


(5)博客编辑/文章发布

原项目中的博客编辑功能在后台管理模块中,博客编辑页面中我们可以对已经发布的博客进行编辑,也可以发布新的博文,但是该项功能是只有在用户登录个人博客网站,即用户为博客网站博主的状态下才能使用的,在博客编辑页面中,我们引入了markdown编辑器,该编辑器有关于vue的支持。我们直接导入相关依赖拿来用就可以了。如下图所示:

在这里插入图片描述

引入markdown编辑器比较简单,注意好在main.js中的全局注册就行:

export default function markdownToHtml(content) {
  const MarkdownIt = require("markdown-it");
  const hljs = require("highlight.js");
  const md = new MarkdownIt({
    html: true,
    linkify: true,
    typographer: true,
    breaks: true,
    highlight: function(str, lang) {
      // 当前时间加随机数生成唯一的id标识
      var d = new Date().getTime();
      if (window.performance && typeof window.performance.now === "function") {
        d += performance.now();
      }
      const codeIndex = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
        /[xy]/g,
        function(c) {
          var r = (d + Math.random() * 16) % 16 | 0;
          d = Math.floor(d / 16);
          return (c == "x" ? r : (r & 0x3) | 0x8).toString(16);
        }
      );
      // 复制功能主要使用的是 clipboard.js
      let html = `<button class="copy-btn iconfont iconfuzhi" type="button" data-clipboard-action="copy" data-clipboard-target="#copy${codeIndex}"></button>`;
      const linesLength = str.split(/\n/).length - 1;
      // 生成行号
      let linesNum = '<span aria-hidden="true" class="line-numbers-rows">';
      for (let index = 0; index < linesLength; index++) {
        linesNum = linesNum + "<span></span>";
      }
      linesNum += "</span>";
      if (lang == null) {
        lang = "java";
      }
      if (lang && hljs.getLanguage(lang)) {
        // highlight.js 高亮代码
        const preCode = hljs.highlight(lang, str, true).value;
        html = html + preCode;
        if (linesLength) {
          html += '<b class="name">' + lang + "</b>";
        }
        // 将代码包裹在 textarea 中,由于防止textarea渲染出现问题,这里将 "<" 用 "<" 代替,不影响复制功能
        return `<pre class="hljs"><code>${html}</code>${linesNum}</pre><textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy${codeIndex}">${str.replace(
          /<\/textarea>/g,
          "</textarea>"
        )}</textarea>`;
      }
    }
  })
    .use(require("markdown-it-sub"))
    .use(require("markdown-it-sup"))
    .use(require("markdown-it-mark"))
    .use(require("markdown-it-abbr"))
    .use(require("markdown-it-container"))
    .use(require("markdown-it-deflist"))
    .use(require("markdown-it-emoji"))
    .use(require("markdown-it-footnote"))
    .use(require("markdown-it-ins"))
    .use(require("markdown-it-katex-external"))
    .use(require("markdown-it-task-lists"));
  // 将markdown替换为html标签
  return md.render(content);
}

同时,在博客详情页中,我们需要回显我们的博客信息,但是我们在发布博客的时候,使用的是markdown编辑器,所以在回显的时候,我们回显的内容带有markdown标签的,那么应该如何回显我们正式编辑的文本呢?

在这里需要使用一个插件markdown-it,它的作用是解析md文档。就如上述代码结尾所示。

具体逻辑是这样的;请求博客详情接口,返回的博客详情content通过markdown-it工具进行渲染。

得到的效果如下:

在这里插入图片描述

(6) 权限路由拦截

因为我们最开始也提到了部分页面是需要在登录的状态下才能访问的,那么在前台应该如何进行拦截呢?其实思路是很简单的,主要就是给每一个页面请求添加一个参数,标记其是否是需要在登录状态下才能访问,同时过滤拦截每一个请求


四、写在最后

到这里整个项目的前端前台部分基本就是阅读完毕了,其余的搜索、友链、相册功能为什么不再赘述了呢。主要是因为实现原理大致相同,都是基于Vue框架的不同玩法,并且通过上面对于个人博客网站开发的一个流程以及必要功能涉及的Vue的技术的一个小总结和学习,让我受益匪浅,文章中许多写的地方一定是不够完善的。其实通过这个项目来入门Vue尚且有些仓促,在这之后应该会看更多类似的源码,争取以后能自己实现一个自己的项目。

这次阅读分析源代码,主要是对axios有了一个更深层次的了解,同时非常深刻的认识到了jsp和Vue的区别和优劣之处。

新手上路,本文章仅作为自己学习记录,不作任何其他用途。

Logo

前往低代码交流专区

更多推荐