服务器端渲染-Nuxt.js综合案例

前言

阅读建议:建议通过左侧导航栏进行阅读

案例介绍

功能介绍

RealWord是一个开源学习项目,目的是帮助开发者快速学习新技能,开发者可以使用任何技术去实现它。RealWord是一个类似一个小论坛的项目,业务流程很简单,包括的功能有用户登录/注册/退出/个人中心、文章信息发布/修改/详情查看/分页展示/作者信息查看/点赞/取消点赞/评论。

相关资源链接

  • 在线示例:https://demo.realworld.io/#/
  • GitHub仓库:https://github.com/gothinkster/realworld
  • 接口文档:https://github.com/gothinkster/realworld/tree/master/api
  • 页面模板:https://github.com/gothinkster/realworld-starter-kit/blob/master/FRONTEND_INSTRUCTIONS.md

学习收获

  • 掌握使用 Nuxt.js 开发同构渲染应用
  • 增强 Vue.js 实践能力
  • 掌握同构渲染应用中常见的功能处理
    • 用户登录态管理
    • 页面访问权限处理
    • SEO 优化

准备工作

新建项目,进入项目目录

yarn init  //生成 package.json 文件
yarn add nuxt  //安装 nuxt 依赖

package.json中添加启动脚本:

"scripts": {
     
   "dev": "nuxt"
},

新建pages目录,创建子目录,并将提供的对应页面模板放入相应子目录下的index.vue中,处理完所有页面组件后,pages目录结构如下图:

服务器端渲染-Nuxt.js综合案例_第1张图片
在项目根目录下,新建一个app.html文件,并使用Nuxt.js 默认的应用模板,导入项目中需要使用的CSS样式及字体文件。

ionicons.min.css 文件中引用了其他字体文件,因此使用 jsDelivr 进行地址转换;
index.css 是项目自身的样式文件,直接下载文件,放入static/index.css即可

<html {
      {
       HTML_ATTRS }}>
  <head {
      {
       HEAD_ATTRS }}>
    {
    { HEAD }}
    
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/css/ionicons.min.css" rel="stylesheet" type="text/css">
    <link href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic" rel="stylesheet" type="text/css">
    
    <link rel="stylesheet" href="/index.css">
  head>
  <body {
      {
       BODY_ATTRS }}>
    {
    { APP }}
  body>
html>

到此,正式开发的前期准备工作完成。

具体实现

自定义路由规则

在项目根目录下新建nuxt.config.js,通过Nuxt.js路由的extendRoutes选项自定义路由规则。

/**
 * 配置自定义路由表规则
 */
module.exports = {
     
  router: {
     
    extendRoutes(routes, resolve) {
     
      routes.splice(0);//清除nuxtjs根据Pages目录生成的默认路由
      routes.push(...[
        {
     
          path: '/', 
          component: resolve(__dirname, 'pages/layout/'), //父组件
          children: [
            {
     
              path: '', //默认子路由
              name: 'home',
              component: resolve(__dirname, 'pages/home/')
            },
            {
     
              path: '/login',
              name: 'login',
              component: resolve(__dirname, 'pages/login/')
            },
            {
     
              path: '/register',
              name: 'register',
              component: resolve(__dirname, 'pages/login/')
            },
            {
     
              path: '/profile/:username',
              name: 'profile',
              component: resolve(__dirname, 'pages/profile/')
            },
            {
     
              path: '/settings',
              name: 'settings',
              component: resolve(__dirname, 'pages/settings/')
            },
            {
     
              path: '/editor',
              name: 'editor',
              component: resolve(__dirname, 'pages/editor/')
            },
            {
     
              path: '/article/:slug',
              name: 'article',
              component: resolve(__dirname, 'pages/article/')
            }
          ]
        }
      ]); 
    }
  }
}

处理登录注册页面

pages/login/index.vue 登录/注册功能需要共用一个页面组件,使用计算属性判断当前页面是登录还是注册,根据该计算属性,进行登录注册页面差异性处理。

computed: {
     
  isLogin() {
     
    return this.$route.name === 'login';
  }
},

处理顶部导航链接

pages/layout/index.vue 将页面模板顶部导航栏组件中所有的a标签换成nuxt-link标签,并为其to属性添加正确的页面链接,达到点击以后跳转到正确页面的效果。


<nav class="navbar navbar-light">
  <div class="container">
    <nuxt-link class="navbar-brand" to="/">conduitnuxt-link>
    <ul class="nav navbar-nav pull-xs-right">
      <li class="nav-item">
        
        <nuxt-link exact class="nav-link" to="/">Homenuxt-link>
      li>
      <template v-if="user">
        <li class="nav-item">
          <nuxt-link class="nav-link" to="/editor">
            <i class="ion-compose">i> New Post
          nuxt-link>
        li>
        <li class="nav-item">
          <nuxt-link class="nav-link" to="/settings">
            <i class="ion-gear-a">i> Settings
          nuxt-link>
        li>
        <li class="nav-item">
          <nuxt-link class="nav-link" to="/profile/lpz">
            <img class="user-pic" 
              :src="user.image">
              {
    {user.username}}
          nuxt-link>
        li>
      template>
      <template v-else>
        <li class="nav-item">
          <nuxt-link class="nav-link" to="/login">Sign innuxt-link>
        li>
        <li class="nav-item">
          <nuxt-link class="nav-link" to="/register">Sign upnuxt-link>
        li>
      template>
    ul>
  div>
nav>

为了使当前激活的顶部导航栏高亮,需要在nuxt.config.js中配置linkActiveClass

module.exports = {
     
  router: {
     
    //全局配置  组件默认的激活类名,使当前激活的顶部导航栏高亮
    linkActiveClass: 'active',
    .....
  }	
}

封装axios请求模块

安装axios:

yarn add axios

plugins/request.js封装基于 axios的请求模块

import axios from 'axios';

export const request = axios.create({
     
  baseURL: 'https://conduit.productionready.io/', //设置基准路径
});

实现用户登录注册功能

api/user.js 封装登录/注册网络请求模块

import {
      request } from '@/plugins/request';

//用户登录接口
const login = data => {
     
  return request ({
     
    method: 'Post',
    url: '/api/users/login',
    data
  });
}

//用户注册接口
const register = data => {
     
  return request ({
     
    method: 'Post',
    url: '/api/users',
    data
  });
}

export {
      login, register }

pages/login/index.vue获取用户在页面输入的用户信息,进行表单验证,完成基本的登录/注册功能,并将登录失败之后的错误信息在页面展示。

<template>
  <div class="auth-page">
    <div class="container page">
      <div class="row">
        <div class="col-md-6 offset-md-3 col-xs-12">
          <h1 class="text-xs-center">{
     {
      isLogin ? 'Sign in' : 'Sign up' }}</h1>
          <p class="text-xs-center">
            <nuxt-link v-if="isLogin" to="/register">Need an account?</nuxt-link>
            <nuxt-link v-else to="/login">Have an account?</nuxt-link>
          </p>
          <!--显示请求失败之后的信息-->
          <ul class="error-messages">
            <template v-for="(messages, filed) in errors">
              <li v-for="(message, index ) in messages" 
                 :key="index">{
     {
      filed }} {
     {
     message}}</li>
            </template>
          </ul>

          <form @submit.prevent="onSubmit">
            <fieldset v-if="!isLogin" class="form-group">
              <!--required标识字段是必填项-->
              <input required
                v-model="user.username"
                class="form-control form-control-lg"
                type="text"
                placeholder="Your Name"
              />
            </fieldset>
            <fieldset class="form-group">
              <input required
                v-model="user.email"
                class="form-control form-control-lg"
                type="email"
                placeholder="email"
              />
            </fieldset>
            <fieldset class="form-group">
              <input required
                v-model="user.password"
                class="form-control form-control-lg"
                type="password"
                placeholder="Password"
                minlength="8"
              />
            </fieldset>
            <button class="btn btn-lg btn-primary pull-xs-right">
              {
     {
      isLogin ? 'Sign in' : 'Sign up' }}
            </button>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import {
      login, register } from '@/api/user';
export default {
     
  name: 'Login',
  data() {
     
    return {
     
      user: {
     
        username: '',
        email: '',
        password: ''
      },
      errors: {
     }
    }
  },
  computed: {
     
    //判断页面是否为登陆页面还是注册页面,方便进行差异化处理
    isLogin() {
     
      return this.$route.name === 'login';
    }
  },
  methods: {
     
    async onSubmit() {
     
      // 通过 try catch 捕获错误信息
      try {
     
        // 调用登录/注册接口
        const {
      data } = this.isLogin ? 
           await login({
      user: this.user }) : await register({
      user: this.user });
        this.$router.push('/'); //跳转到首页
      } catch (error) {
     
        //记录登录/注册失败的信息,用于页面显示
        this.errors = error.response.data.errors;
      }
    }
  }
};
</script>

存储并持久化用户登录态

存储用户登录态

  • 根据用户是否登录来控制页面的访问权限
  • 有些页面的内容也需要根据用户是否登录进行不同的显示
  • 有些api接口也必须携带登录用户的身份令牌Token

基于以上原因,必须在用户成功登录后将用户信息存储起来,以方便后续数据共享

注意:不同于纯客户端渲染,这里存储起来的用户登录态需要前后端共享,要同时能被客户端和服务端获取

store/index.vue 创建数据容器

/**
 * 在服务端运行期间都是同一个实例
 * 为了防止数据命名冲突,要把state定义成一个函数,返回数据对象
 */
const state = () => {
     
  return {
     
    user: null //当前登录用户的登录态
  }
}

const mutations = {
     
  setUser(state, data) {
     
    state.user = data;
  }
}

注意:
1、Nuxt.js中已经集成了vuex,它会自动加载store目录下的数据容器模块,承载数据模块的目录名必须是store
2、服务端运行期间都是同一个实例,为了防止数据命名冲突,要把state定义成一个函数,然后返回数据对象

pages/login/index.vue 将登录用户的登录态保存到数据容器vuex中

this.$store.commit('setUser', data.user); //保存用户登录状态

持久化用户登录态

使用vuex进行用户登录态的存储,只是在程序运行期间,将数据存储到了容器中,属于内存中的数据,刷新之后,页面进行初始化,同时内存中的数据也被初始化了,要想一直保持用户登录态需要对数据容器中的数据进行持久化。由于是服务端渲染,前后端需要共享数据,用户登录态需要在客户端和服务端同时访问,这里选择cookie进行数据持久化。
store/index.vue

const cookieparser = process.server ? require('cookieparser') : undefined;
/**
 * 在服务端运行期间都是同一个实例
 * 为了防止数据命名冲突,要把state定义成一个函数,返回数据对象
 */
const state = () => {
     
  return {
     
    user: null //当前登录用户的登录态
  }
}

const mutations = {
     
  setUser(state, data) {
     
    state.user = data;
  }
}

const actions = {
     
  /**
   * nuxtServerInit是nuxt特有的action方法,只会在服务端渲染期间自动调用
   * 作用:初始化容器数据,传递给客户端使用
   */
  nuxtServerInit ({
      commit }, {
      req }) {
     
    let user = null
    //如果请求头中有cookie
    if (req.headers.cookie) {
     
      //使用cookieparser将cookie字符串转换为js对象
      const parsed = cookieparser.parse(req.headers.cookie)
      try {
     
        user = JSON.parse(parsed.user)
      } catch (err) {
     
        // No valid cookie found
      }
    }
    commit('setUser', user)
  }
}

export {
      state, mutations, actions }

pages/login/index.vue

//如果是浏览器端就加载js-cookie
const Cookie = process.client ? require('js-cookie') : undefined
...
this.$store.commit('setUser', data.user); //保存用户登录状态
Cookie.set('user', data.user); //数据持久化

处理页面访问权限

考虑到同构渲染的页面拦截,从服务端的角度出发,进入页面之前就处理页面的访问,需要使用中间件来处理页面访问权限。Nuxt.js提供了自己的方案——路由中间件,它既能处理服务端渲染的路由拦截,也能处理客户端渲染的路由拦截

middleware/authenticated.js

/**
 * 验证用户是否登录的中间件
 */
export default function ({
      store, redirect }) {
     
  //用户没有登录,重定向到登录页面
  if (!store.state.user) {
     
    return redirect('/login')
  }
}

middleware/notAuthenticated.js

export default function ({
      store, redirect }) {
     
  //用户已经登录过了,直接跳转到首页
  if (store.state.user) {
     
    return redirect('/')
  }
}

哪个页面需要进行权限校验,就给对应的页面加上中间件

export default {
     
  //在路由匹配组件之前会先执行中间件处理
  middleware: 'authenticated', 
  name: 'Editor'
}

注意:给页面加入中间件以后,Nuxt.js会自动根据页面配置,找到middleware目录下对应的中间件,进行页面权限控制

实现首页模块功能

展示公共文章列表

api/article.js封装首页公共文章列表请求接口

import {
      request } from '@/plugins/request';

//获取公共文章接口
export const getArticles = params => {
     
  return request ({
     
    method: 'Get',
    url: '/api/articles',
    params
  });
}

pages/home/index.vue获取首页公共文章列表数据

async asyncData () {
     
   const {
      data } = await getArticles()
   return {
      articles: data.articles }
}

pages/home/index.vue首页公共文章列表模板-数据进行绑定

<div class="article-preview"
  v-for="article in articles" :key="article.slug">
  <div class="article-meta">
    <nuxt-link :to="{
     
      name: 'profile',
      params: {
     
        username: article.author.username
      }
    }">
      <img :src="article.author.image"/>
    </nuxt-link>
    <div class="info">
      <nuxt-link class="author" :to="{
     
        name: 'profile',
        params: {
     
          username: article.author.username
        }
      }">
        {
     {
      article.author.username }}
      </nuxt-link>
      <span class="date">{
     {
      article.createdAt | date('MMM DD,YYYY') }}</span>
    </div>
    <button 
      class="btn btn-outline-primary btn-sm pull-xs-right"  
     :disabled="article.favoriteDisabled"
     :class="{active: article.favorited}"
      @click="onFavorite(article)">
         <i class="ion-heart"></i>
         {
     {
     article.favoritesCount}}
    </button>
  </div>
  <nuxt-link class="preview-link" :to="{
     
    name: 'article',
    params: {
     
      slug: article.slug
    }
  }">
    <h1>{
     {
     article.title}}</h1>
    <p>{
     {
     article.description}}</p>
    <span>Read more...</span>
  </nuxt-link>
</div>

首页数据分页处理

pages/home/index.vue 处理页面参数

async asyncData ({
      query }) {
      
  const page = Number.parseInt(query.page || 1)
  const limit = 20 
  const {
      data } = await getArticles({
      
     limit, // 每页大小
     offset: (page-1) *limit  
  })
  return {
      
     limit,
     page,
     articlesCount: data.articlesCount,
     articles: data.articles  
   }
 },

pages/home/index.vue 使用计算属性计算总页码

computed: {
     
    totalPage() {
     
      return Math.ceil(this.articlesCount / this.limit);
    }
},

pages/home/index.vue遍历生成页码列表

<nav>
 <ul class="pagination">
    <li class="page-item" 
      v-for="item in totalPage" :key="item" 
      :class="{ active: item === page }">
      <nuxt-link class="page-link" :to="{
     
        name: 'home',
        query: {
     
          page: item,
          tag: $route.query.tag
        }
      }">{
     {
     item}}</nuxt-link>
    </li>
  </ul>
</nav>

pages/home/index.vue设置导航链接,实现页面展示当前点击页码的数据,使用nuxt-link实现页面的局部刷新

<nuxt-link class="page-link" :to="{
     
    name: 'home',
     query: {
     
       page: item,
       tag: $route.query.tag
     }
   }">{
     {
     item}}
</nuxt-link>

由于在vue当中,query参数的变化不会导致页面或路由组件重新渲染,页面也不会刷新——Nuxt.js提供了监听query参数变化机制。
pages/home/index.vue

//使用watchQuery属性可以监听参数字符串的更改。 如果定义的字符串发生变化,将调用所有组件方法
watchQuery: ['page', 'tag', 'tab']

优化并行异步请求
pages/home/index.vue

import {
      getArticles, getFeedArticles } from '@/api/article';

const loadArticles = store.state.user && tab === 'your_feed' ? getFeedArticles: getArticles;
const [ articleRes, tagRes ] = await Promise.all([
  loadArticles({
     
    limit,
    tag: tag,
    offset: ( page - 1 ) * limit
  }),
  getTags()
]);

统一添加数据Token

plugins/request.js通过插件获取当前app实例

/**
 * 网络请求相关的插件
 */
import axios from 'axios';

export const request = axios.create({
     
  baseURL: 'https://conduit.productionready.io/', //设置基准路径
});

//通过插件机制获取上下文对象(query、params、req、res、app、store)
export default ({
      store }) => {
     
  //请求拦截器
  request.interceptors.request.use(function (config) {
     
    const {
      user } = store.state;
    if(user && user.token) {
     
      config.headers.Authorization = `Token ${
       user.token}`;
    }
    
    return config;
  }, function (error) {
     
    //如果请求失败,对错误信息处理
    return Promise.reject(error);
  });
}

plugins/request.js注册插件

//注册插件
 plugins: [
   '~/plugins/request.js',
   '~/plugins/dayjs.js'
 ]

统一处理日期格式

plugins/dayjs.js使用dayjs插件结合过滤器统一处理日期格式

import vue from 'vue';
import dayjs from 'dayjs';

vue.filter('date', (value, format="YYYY-MM-DD HH:mm:ss") => {
     
    return dayjs(value).format(format);
})

pages/home/index.vue显示统一处理后的时间

<span class="date">{
     {
      article.createdAt | date('MMM DD,YYYY') }}</span>

文章详情页面

pages/article/index.vue由于用户提交的文章内容有可能是Markdown格式的,在页面显示的时候,需要把Markdown转化为Html,这里使用markdown-it插件

import MarkdownIt from "markdown-it";

async asyncData({
      params }) {
     
  const {
      data } = await getArticleDetails(params.slug);
  const {
      article } = data;
  const md = new MarkdownIt(); //调用构造函数
  article.body = md.render(article.body);//将Markdown转化为Html

  return {
      article };
}
<div class="col-md-12" v-html="article.body">div>

设置页面meta优化SEO

pages/article/index.vue设置页面组件的head方法

head() {
  return {
    title: `${this.article.title} -realword`,
    meta: [
      {
        hid: "description",
        name: "description",
        content: this.article.description,
      },
    ],
  };
},

注意:为了避免子组件中的 meta 标签不能正确覆盖父组件中相同的标签而产生重复的现象,建议利用 hid 键为 meta 标签配一个唯一的标识编号。

案例总结

  • 使用Nuxt.js进行同构应用的开发时,必须严格遵守Nuxt目录结构规则
    • pages页面目录用于组织应用的路由及视图
    • components组件目录用于组织应用的 Vue.js 组件
    • middleware 目录用于存放应用的中间件
    • assets资源目录 用于组织未编译的静态资源如 LESS、SASS 或 JavaScript
    • plugins 插件目录用于组织那些需要在 根vue.js应用 实例化之前需要运行的 Javascript 插件
    • static 静态文件目录用于存放应用的静态文件
    • store 目录用于组织应用的 Vuex 状态树 文件
  • 使用Nuxt.js 开发组件时,与使用Vue.js开发组件、父子组件的传值一致
  • 用户登录态需要服务器端和客户端共享,都能访问,所以必须使用cookie,而不能使用本地化存储
  • 考虑到同构渲染的页面拦截,从服务端的角度出发,进入页面之前就处理页面的访问,需要使用中间件来处理页面访问权限。Nuxt.js提供了自己的方案——路由中间件,它既能处理服务端渲染的路由拦截,也能处理客户端渲染的路由拦截

文章列表

服务器端渲染基础
服务器端渲染-Nuxt.js基础
服务器端渲染-Nuxt.js综合案例
服务器端渲染-Nuxt.js综合案例发布部署
服务器端渲染-Vue SSR搭建

你可能感兴趣的:(vue.js,vue)