案例:Nuxt综合案例

SSR
案例

# 案例功能介绍

  • 名称:RealWorld
  • 一个开源的学习项目,目的是帮助开发者快速学习新技能,这里面可以登录,看别人的文章,点赞,评论,还可以自己发布文章,个人中心可以管理个人资料,还可以查看自己的文章和自己点赞收藏的文章。这个项目说大不大说小不小,但是可以通过这个案例学习用不同的技术实现他来熟悉一个技术栈。
  • 有前端的技术栈也有后端的技术栈,在实战中体会价值。
  • 还提供了页面模板和api文档,这样可以让我们忽略页面样式,专注于我们要学习的技术栈。他有服务器,是开源的,在国外可能使用会有些慢。

# 案例相关资源

# 学习前提

  • Vue.js使用经验
  • Nuxt.js基础
  • Node.js、webpack相关使用经验

# 学习目标

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

# 项目初始化

# 创建项目

  1. 创建文件夹
# 创建项目目录 
mkdir realworld-nuxtjs 
# 进入项目目录 
cd realworld-nuxtjs 
# 生成 package.json 文件 
npm init -y 
# 安装 nuxt 依赖 
npm install nuxt
1
2
3
4
5
6
7
8
  1. 在 package.json 中添加启动脚本:
"scripts": { "dev": "nuxt" }
1
  1. 创建 pages/index.vue :
<template> 
    <div class="home">首页</div>
</template> 
<script> 
export default { 
    name: 'HomePage',
    components: {},
    props: {},
    data () {
      return {}
    },
    computed: {},
    watch: {},
    created () {},
    mounted () {},
    methods: {}
}
</script> 

<style> </style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  1. 启动服务:
npm run dev
1
  1. 在浏览器中访问 http://localhost:3000/ 测试。

# 导入页面模板

  • 导入样式资源
  • 配置布局组件
  • 配置页面组件

# 导入资源样式

由于样式是国外的源,所以可以从cdn上找到中国的站点加载。

  • ionicons-2.0.1版本:https://cdn.jsdelivr.net/npm/ionicons@2.0.1/css/ionicons.min.css
  • googleFonts:在国内被支持
  • css:http://demo.productionready.io/main.css, 全选粘贴下载到本地static/index.css文件
  1. 根目录下创建app.html,然后将模块写进去
<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
  <head {{ HEAD_ATTRS }}> 
    {{ HEAD }} 
    <!-- 字体图标 -->
    <link href="https://cdn.jsdelivr.net/npm/ionicons@2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
    <!-- google字体文件 -->
    <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">
    <!-- css文件 -->
    <link rel="stylesheet" href="/index.css"> 
  </head> 
  <body {{ BODY_ATTRS }}> 
    {{ APP }} 
  </body> 
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 配置布局组件

  1. 创建pages/layout/index.vue,里面写将中的导航栏和底部栏都粘贴进去
<template> 
    <div>
      <!-- nav -->
      <nav class="navbar navbar-light">
        <div class="container">
          ...
          </ul>
        </div>
      </nav>
      <!-- /nav end -->

      <!-- 子路由 -->
      <nuxt-child/>
      <!-- / 子路由 -->

      <!-- footer -->
      <footer>
       ...
      </footer>
      <!-- /footer end -->
    </div>
</template> 
<script> 
export default { 
    name: 'LayoutPage'
}
</script> 

<style> </style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  1. 重启服务可以看到页面的上下部分已经出现

image

# 配置页面组件

nuxt的路由规则,如果是大型项目,可能有的时候需要自己的结构,原来的会阻碍功能,所以这里要自己配置路由规则,不要其原始的目录路由规则。

  1. 在根目录下创建文件nuxt.config.js,里面进行路由的重新配置
module.exports = {
  router: {
    // 自定义路由表规则
    extendRoutes(routes, resolve) {
      // 清除 Nuxt.js 基于 pages 目录默认生成的路由表规则
      routes.splice(0)
       // 然后添加自己的路由对象
      routes.push(...[
        {
          path: '/',// 根目录
          component: resolve(__dirname, 'pages/layout/'),
          children: [
            {
              path: '', // 默认子路由
              name: 'home',
              component: resolve(__dirname, 'pages/home/')
            }
          ]
        }
      ])
    }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  1. 创建pages/home/index.vue,然后把给的页面模板的Home文件粘贴进去
<template> 
  <div class="home-page">
    <div class="banner">
      ...
    </div>

    <div class="container page">
      ...
    </div>
  </div>
</template> 
1
2
3
4
5
6
7
8
9
10
11
  1. 这个时候可以看到首页已经出来了,

image

# 导入登录注册页面

  1. 创建pages/login/index.vue,然后将页面模板中的登录注册页面粘贴进去
<template> 
  <div class="auth-page">
    <div class="container page">
      <div class="row">
        ...
      </div>
    </div>
  </div>
</template> 
1
2
3
4
5
6
7
8
9
  1. nuxt.config.js配置路由
routes.push(...[
    {
      path: '/',
      component: resolve(__dirname, 'pages/layout/'),
      children: [
        ...
        {
          path: '/login',
          name: 'login',
          component: resolve(__dirname, 'pages/login/')
        },
        {
          path: '/register',// 注册和登录一个组件,这里单独配置一个路由
          name: 'register',
          component: resolve(__dirname, 'pages/login/')
        }
      ]
    }
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  1. 现在要根据不同的路由展示不一样的页面,在pages/login/index.vue中添加计算属性isLogin,可以看到如果路由是login就是true,如果是register就是false
computed: {
  isLogin () {
    // 用name比path更合适,因为名字是自己起的
    return this.$route.name === 'login'
  }
}
1
2
3
4
5
6
  1. 在组件上进行文案的区分
  • 展示大标题做区分
  • 跳转登录/注册换成nuxt-link,添加v-if并且指定跳转链接
  • 表格中name这一栏在注册界面才有
  • 按钮的分案做区分
<template> 
  ...
    <div class="col-md-6 offset-md-3 col-xs-12">
      <!-- 1. 展示大标题 -->
      <h1 class="text-xs-center">{{ isLogin ? 'Sign in' : 'Sign up' }}</h1>
      <!-- 2. 跳转登录/注册 -->
      <p class="text-xs-center">
        <nuxt-link v-if="!isLogin" to="/login">Have an account?</nuxt-link>
        <nuxt-link v-else to="/register">Need an account?</nuxt-link>
      </p>
      ...
      <form>
        <!-- 3. name这一栏在注册页面才有 -->
        <fieldset v-if="!isLogin" class="form-group">
          <input class="form-control form-control-lg" type="text" placeholder="Your Name">
        </fieldset>
        ...
        <!-- 4. 底部button区别文案 -->
        <button class="btn btn-lg btn-primary pull-xs-right">
          {{ isLogin ? 'Sign in' : 'Sign up' }}
        </button>
      </form>
    </div>
...
</template> 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  1. 打开浏览器访问http://localhost:3000/login,可以看到两个页面的不同

image

image

# 导入个人中心展示页面

  1. 创建pages/profile/index.vue,然后将页面模板中的profile页面粘贴进去
<template> 
  <div class="profile-page">
    <div class="user-info">
        ...
    </div>

    <div class="container">
        ...
    </div>
  </div>
</template> 
1
2
3
4
5
6
7
8
9
10
11
  1. nuxt.config.js配置路由
routes.push(...[
    {
      path: '/',
      component: resolve(__dirname, 'pages/layout/'),
      children: [
        ...
        {
          path: '/profile/:username',
          name: 'profile',
          component: resolve(__dirname, 'pages/profile/')
        }
      ]
    }
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 导入设置页面

  1. 创建pages/settings/index.vue,然后将页面模板中的settings页面粘贴进去
<template> 
  <div class="settings-page">
    <div class="container page">
      ...
    </div>
  </div>
</template> 
1
2
3
4
5
6
7
  1. nuxt.config.js配置路由
routes.push(...[
    {
      path: '/',
      component: resolve(__dirname, 'pages/layout/'),
      children: [
        ...
        {
          path: '/settings',
          name: 'settings',
          component: resolve(__dirname, 'pages/settings/')
        }
      ]
    }
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 导入创建和编辑文章

  1. 创建pages/editor/index.vue,然后将页面模板中的editor页面粘贴进去
<template> 
  <div class="editor-page">
    <div class="container page">
      ...
    </div>
  </div>
</template> 
1
2
3
4
5
6
7
  1. nuxt.config.js配置路由
routes.push(...[
    {
      path: '/',
      component: resolve(__dirname, 'pages/layout/'),
      children: [
        ...
        {
          path: '/editor',
          name: 'editor',
          component: resolve(__dirname, 'pages/editor/')
        }
      ]
    }
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 导入文章列表模板

  1. 创建pages/article/index.vue,然后将页面模板中的article页面粘贴进去
<template> 
  <div class="article-page">
    <div class="banner">
      ...
    </div>
    <div class="container page">
      ...
    </div>
  </div>
</template> 
1
2
3
4
5
6
7
8
9
10
  1. nuxt.config.js配置路由
routes.push(...[
    {
      path: '/',
      component: resolve(__dirname, 'pages/layout/'),
      children: [
        ...
        {
          path: '/article/:slug', // 动态路由,slug是链接
          name: 'article',
          component: resolve(__dirname, 'pages/article/')
        }
      ]
    }
])
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 处理顶部链接跳转

layout/index.vue中,将a链接转换成nuxt-link,并且把href都改成to,添加跳转的路由地址,之后再添加一个登录sign in的超链接,还有一个登录完成之后显示头像和姓名的超链接。

<template> 
    <div>
      <nav class="navbar navbar-light">
        <div class="container">
          <!--跳到首页-->
          <nuxt-link 
            class="navbar-brand" 
            to="/"
          >conduit</nuxt-link>
          <ul class="nav navbar-nav pull-xs-right">
            <li class="nav-item">
              <!--跳到首页-->
              <nuxt-link class="nav-link active" to="/">Home</nuxt-link>
            </li>
            <li class="nav-item">
              <!--跳到编辑页面-->
              <nuxt-link class="nav-link" to="/editor"><i class="ion-compose"></i>&nbsp;New Post</nuxt-link>
            </li>
            <li class="nav-item">
              <!--跳到设置页面-->
              <nuxt-link class="nav-link" to="/settings">
                <i class="ion-gear-a"></i>&nbsp;Settings
              </nuxt-link>
            </li>
            <!--新加:跳到登录页面-->
            <li class="nav-item">
              <nuxt-link class="nav-link" to="/login">Sign in</nuxt-link>
            </li>
            <li class="nav-item">
              <!--跳到注册页面-->
              <nuxt-link class="nav-link" to="/register">Sign up</nuxt-link>
            </li>
            <!--新加:登录之后显示用户头像和名称-->
            <li class="nav-item">
              <nuxt-link class="nav-link" to="/profile/112">
                <img class="user-pic" src="https://tvax4.sinaimg.cn/crop.0.0.512.512.1024/001nd69sly8gjfgoc7b8dj60e80e8q3a02.jpg?KID=imgbed,tva&Expires=1609784063&ssig=EQg2ckyhVH">
                aibugi111
              </nuxt-link>
            </li>
          </ul>
        </div>
      </nav>
      <!-- /nav end -->
</template> 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

# 处理导航添加高亮

  1. 在点击导航栏的时候,点击别的地方并没有显示高亮,在路由配置的地方,如果点击跳转路由,对应的<nuxt-link>中会有类名nuxt-link-active,这个是高亮的类,那么这个可以在nuxt.config.js的router中进行配置,改为active
module.exports = {
  router: {
    // 自定义高亮类名,默认nuxt-link-active
    linkActiveClass: 'active',
    ...
   }
 }
1
2
3
4
5
6
7
  1. 这个时候有高亮效果了,但是就算我们把active去掉Home键还是一直高亮,是因为匹配的规则中,是包含关系并不是精确匹配,要做到精确匹配,在'/'中添加属性exact,就可以实现精确匹配,不会常亮。
<!--layout/index.vue-->
<nuxt-link 
    class="nav-link" 
    to="/" 
    exact
>Home</nuxt-link>
1
2
3
4
5
6
  1. 高亮设置完成

# 封装请求模块

  1. 安装axios,npm i axios --save
  2. 根目录下创建utils/request.js,然后写请求模块架构
import axios from 'axios'

const request = axios.create({
  // 在页面模板中有设置基准路径,后面跟api
  baseURL: 'https://conduit.productionready.io'
})

// 请求拦截器

// 相应拦截器

export default request
1
2
3
4
5
6
7
8
9
10
11
12

# 登录注册

# 实现基本登录功能

  1. 去接口文档中找到登录接口,需要的是email和password,而且都是必须的。
  2. login/index.vue文件中定义data
data () {
  return {
    user: {
      email: '',
      password: ''
    }
  }
}
1
2
3
4
5
6
7
8
  1. 在template中的email和password中加入监听,并设置required成为必填项,email添加格式验证(原生的有兼容性问题,这里不是重点不做处理),密码不少于8位。
<!-- email -->
<fieldset class="form-group">
  <input 
    v-model="user.email"
    class="form-control form-control-lg" 
    type="email"
    placeholder="Email"
    required
  >
</fieldset>
<!-- password -->
<fieldset class="form-group">
  <input 
    v-model="user.password"
    class="form-control form-control-lg"
    type="password" 
    placeholder="Password"
    required
    minlength="8"
  >
</fieldset>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  1. 在from表单中绑定submit事件,并且将默认的提交操作阻止,绑定自己的事件
<form @submit.prevent="onSubmit">
    ...
</form>
1
2
3
  1. 在methods中定义提交事件,获取data,并且跳到首页
methods: {
  async onSubmit () {
    // 提交表单请求扽牢固
    const { data } = await request({
      method: 'POST',
      url: '/api/users/login',
      data: {
        user: this.user
      }
    })

    console.log(data)
    // 保存用户的登录状态

    // 跳转到首页
    this.$router.push('/')
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  1. 演示一下登录接口确实拿到了数据
{
    "user": {
        "id": 133697,
        "email": "shuangfeng1993@163.com",
        "createdAt": "2021-01-06T06:20:22.385Z",
        "updatedAt": "2021-01-06T06:20:22.392Z",
        "username": "hu77163",
        "bio": null,
        "image": null,
        "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MTMzNjk3LCJ1c2VybmFtZSI6Imh1NzcxNjMiLCJleHAiOjE2MTUwOTgwMjJ9.0W_dJZNpLXfI0Q5bsLz-qAqRsJCVq3yVpEOWFwnBMy0"
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
  1. 下面将api的请求进行一下优化,根目录下创建api/user.js
import request from '@/utils/request'

// 用户登录
export const login = data => {
  return request({
    method: 'POST',
    url: '/api/users/login',
    data
  })
}

// 用户注册
export const register = data => {
  return request({
    method: 'POST',
    url: '/api/users',
    data
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  1. login/index.vue中修改
async onSubmit () {
    // 提交表单请求扽牢固
    const { data } = await login({
      user: this.user
    })
    ...
}
1
2
3
4
5
6
7

# 错误处理

  1. login/index.vue中将try-catch把提交表单的部分包裹起来,然后把error信息用console.dir(error)打印出来。

image

  1. 分析error,我们打算将key和数组一起展示,数组中有几条就展示几条,那么先对errors进行对象遍历,然后对值进行数组遍历,将键与值合并展示。
<ul class="error-messages">
    <!-- 先将errors对象进行遍历,massages是数组,也进行遍历,将key在前,value在后 -->
    <template 
      v-for="(messages, field) in errors">
      <li 
        v-for="(message, index) in messages" 
        :key="index"
      >
      {{ field }} {{ massage }}
      </li>
    </template>
  </ul>
1
2
3
4
5
6
7
8
9
10
11
12
  1. 添加errors字段
data () {
    return {
      ...
      errors: {} // 错误信息,格式是key:value[]
    }
}

...

async onSubmit () {
  try {
    ...
  } catch (error) {
    console.dir(error)
    this.errors = error.response.data.errors
  }
  
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 实现用户注册

  1. login/index.vue中添加username字段
<fieldset v-if="!isLogin" class="form-group">
  <!--绑定username,设置不能缺少-->
  <input v-model="user.username" required class="form-control form-control-lg" type="text" placeholder="Your Name">
</fieldset>
1
2
3
4
// 把登录和注册引入
import { login, register } from '@/api/user'
...
data () {
  return {
    user: {
      // 添加username字段
      username: '',
      ...
    }
    ...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. 提交的时候判断登录还是注册
async onSubmit () {
    try {
      // 提交表单请求登录或者注册
      const { data } = this.isLogin 
        ? await login({
          user: this.user
        })
        : await register({
          user: this.user
        })
    
      console.log(data)
      // 保存用户的登录状态
    
      // 跳转到首页
      this.$router.push('/')
    } catch (error) {
      console.dir(error)
      this.errors = error.response.data.errors
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 保存登录状态

  • Nuxt官方示例中的保存登录状态示例 (opens new window)
  • 因为登录状态的存储需要多个页面共享,也需要前后端共享,所以这里会用到Vuex,在nuxt中已经集成了Vuex。
  • process.client和process.server判断环境
    • process.client在客户端为true,在服务端运行为false
    • process.server 在服务端运行为true,在客户端运行为false
    • 这个对象是在nuxt中特殊提供的对象,同来判断环境
  • nuxtServerInit是store中用到的服务端特殊的方法,详情见 nuxtServerInit 方法 (opens new window)

# 将登录状态存储到容器中

  1. 根目录下创建store/index.js,在里面将state和mutation和action定义之后导出,nuxt会去加载。

必须要叫这个目录,nuxt发现store,会自动加载容器模块。

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

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

export const actions = {
  
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  1. login/index.vue中存储登录状态
async onSubmit () {
    try {
      ...
      // 保存用户的登录状态
      // 1. 将程序运行期间的状态先存在内存中
      this.$store.commit('setUser', data.user)
      ...
    } catch (error) {
      ...
    }
  }
1
2
3
4
5
6
7
8
9
10
11

# 登录状态持久化

  1. 在获取到登录状态的时候进行存储,先安装npm i js-cookie --save-dev
  2. login/index.vue中使用
// 判断环境按需引入
const Cookie = process.client ? require('js-cookie') : undefined
1
2
async onSubmit () {
    try {
      ...
      // 保存用户的登录状态
      // 1. 将程序运行期间的状态先存在内存中
      this.$store.commit('setUser', data.user)
      // 2. 为了防止刷新页面数据丢失,将状态持久化,放入cookie里,这个方法会将数据进行编码转成字符串放在cookie里
      Cookie.set('user', data.user)
      ...
    } catch (error) {
      ...
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. 安装后端渲染的包npm i cookieparser --save-dev
  2. 在页面初始化的时候,从服务端存入数据store/index.js,先引入需要的包
// 后端渲染需要的包
const cookieparser = process.server ? require('cookieparser') : undefined
1
2
  1. 在下面导入actions,然后将nuxtServerInit方法写入
export const actions = {
  // nuxt中特殊的action方法
  // 这个 action 方法会在服务端渲染期间自动调用,且仅在服务端调用
  // 作用:初始化容器数据,传递数据给客户端使用
  nuxtServerInit ({ commit }, { req }) {
    let user = null

    // 如果请求头中有 Cookie
    if (req.headers.cookie) {
      // 使用 cookieparser 把 cookie 字符串转为 json 对象
      const parsed = cookieparser.parse(req.headers.cookie)
      // 如果存的不是json字符串,避免报错
      try{
        user = JSON.parse(parsed.user)
      } catch (err) {
        // No valid cookie found
        // 失败不用做处理,还是null
      }
    }

    // 提交mutation修改state状态
    commit('setUser', user)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 导航栏页面状态区分、

  1. 去导航栏页面layout/index.vue将登录未登录的地方进行显示区分
<ul class="nav navbar-nav pull-xs-right">
    <!--首页导航不需要登录状态-->
    <li class="nav-item">
      <nuxt-link class="nav-link" to="/" exact>Home</nuxt-link>
    </li>
    <!--需要登录状态的用template进行包裹,判断条件是user-->
    <template v-if="user">
      <li class="nav-item">
        <nuxt-link class="nav-link" to="/editor"><i class="ion-compose"></i>&nbsp;New Post</nuxt-link>
      </li>
      <li class="nav-item">
        <nuxt-link class="nav-link" to="/settings">
          <i class="ion-gear-a"></i>&nbsp;Settings
        </nuxt-link>
      </li>
      <li class="nav-item">
        <!--这里将用户名和头像进行展示-->
        <nuxt-link class="nav-link" to="/profile/112">
          <img class="user-pic" :src="user.image? user.image : defaultImage">
          {{ user.username }}
        </nuxt-link>
      </li>
    </template>
    <!--未登录状态下展示的内容,也有template包裹-->
    <template v-else>
      <li class="nav-item">
        <nuxt-link class="nav-link" to="/login">Sign in</nuxt-link>
      </li>
      <li class="nav-item">
        <nuxt-link class="nav-link" to="/register">Sign up</nuxt-link>
      </li>
    </template>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  1. 将登录状态进行区分
<script> 
// 将mapState导入
import { mapState } from 'vuex'
export default { 
  name: 'LayoutPage',
  computed: {
    ...mapState(['user'])
  },
  data () {
    return {
      defaultImage: 'https://tvax4.sinaimg.cn/crop.0.0.512.512.1024/001nd69sly8gjfgoc7b8dj60e80e8q3a02.jpg?KID=imgbed,tva&Expires=1609784063&ssig=EQg2ckyhVH'
    }
  }
} 
</script> 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 这个时候重新启动服务,登录之后可以看到导航栏区别和用户名头像的展示。

# 处理页面访问权限

# 未登录不能访问的页面

虽然上面处理了不让直接访问editor链接,但是通过地址栏输入,依旧可以访问编辑文章等页面,这里就需要给页面添加访问权限。

如果是Vue的话,可以通过使用路由拦截器的方式,现在在Nuxt里面可以换一种方式。他提供了一种叫 路由中间件 的方式,既能处理服务端路由拦截,也可以处理客户端路由拦截。

  1. 在根目录下创建middleware/authenticated.js文件,然后在里面进行处理
/**
 * 验证是否登录的中间件
 */
// 从上下文中获取store对象和redirect方法
export default function ({ store, redirect }) {
  // 判断如果没有user,就是没有登录
  if(!store.state.user) {
    // 跳转到登录页面
    return redirect('/login')
  }
}
1
2
3
4
5
6
7
8
9
10
11
  1. 在需要的页面(profile,settings,editor)中添加属性
export default { 
  // 在路由匹配组件渲染之前会先执行中间件处理
  // 如果有一个中间件写字符串里面,如果有多个中间件写数组里面
  // 这里的值就是中间件的js文件名,会找到这个文件然后调用
  middleware: 'authenticated',
  name: 'EditorPage'
}
1
2
3
4
5
6
7
  1. 重启服务之后退出登录,访问http://localhost:3000/editor就会自动跳转到登录页面。

# 登录之后限制访问的页面

如果登录之后,那么登录和注册的页面也不能访问,这里也进行处理。

  1. 在middleware文件夹中添加noauthenticated.js文件
// 从上下文中获取store对象和redirect方法
export default function ({ store, redirect }) {
  // 判断如果有user,就是有登录
  if(store.state.user) {
    // 跳转到首页
    return redirect('/')
  }
}
1
2
3
4
5
6
7
8
  1. login/index.vue中注册
export default { 
  middleware: 'noauthenticated',
  name: 'LoginIndex',
  ...
}
1
2
3
4
5
  1. 这个时候登录后的登录页面直接返回首页

# 首页

# 业务介绍

  • 文章列表
    • 用户关注文章列表(登录之后才可见)
    • 所有文章列表
    • 文章分类筛选(点击全部消失)
  • 分页

# 展示公共文章列表

  1. api找 List Articles,这里面是文章列表,创建api/article.js,在里面写接口
import request from '@/utils/request'

// 获取公共文章列表
export const getArticles = params => {
  return request({
    method: 'GET',
    url: '/api/articles',
    params
  })
}
1
2
3
4
5
6
7
8
9
10
  1. home/index.vue中请求数据,这里使用后端渲染,所以写在asyncData中
async asyncData() {
    const {data} = await getArticles()
    console.log(data)
    // 看下面的数据结构,然后将articles和articlesCount解构出来
    return {
      articles: data.articles,
      articlesCount: data.articlesCount
    }
}
1
2
3
4
5
6
7
8
9
  1. 可以看到打印出来的数据是
{
    "articles": [
        {
            // 标题
            "title": "12",
            // 文章唯一标识
            "slug": "12-hbwkz7",
            // 内容
            "body": "ww",
            // 文章创建时间
            "createdAt": "2021-01-06T12:09:58.098Z",
            // 文章更新时间
            "updatedAt": "2021-01-06T12:09:58.098Z",
            // 标签
            "tagList": [],
            // 描述
            "description": "32",
            // 作者列表
            "author": [
                null
            ],
            // 是否点赞过
            "favorited": false,
            // 一共几个点赞信息
            "favoritesCount": 0
        }
        ... 
    ],
    // 文章总数
    "articlesCount": 500
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
  1. 在这页上面进行列表渲染
<!--文章列表内容,对articles进行遍历,key就是slug-->
<div
    class="article-preview"
    v-for="article in articles"
    :key="article.slug"
    >
    <div class="article-meta">
    <!--用户头像,把a链接改为nuxt-link,跳转到这个用户的主页to里面添加params-->
      <nuxt-link 
        :to="{
          name:'profile',
          params: {
            username: articles.author.username
          }
        }">
        <img :src="article.author.image" /></nuxt-link>
      <!--用户名称,a链接改为nuxt-link,跳转到这个用户的主页-->
      <div class="info">
        <nuxt-link 
        class="author"
          :to="{
            name:'profile',
            params: {
              username: articles.author.username
            }
          }">
          {{ article.author.username }}</nuxt-link>
        <!--文章创建时间-->
        <span class="date">{{ article.createdAt }}</span>
      </div>
      <!--文章点赞数量,添加动态样式自己是否给她点过赞-->
      <button 
        class="btn btn-outline-primary btn-sm pull-xs-right" 
        :class="{
          active: article.favorited
        }">
        <i class="ion-heart"></i> {{ article.favoritesCount }}
      </button>
    </div>
    <!--a链接替换,这里链接到文章详情-->
    <nuxt-link 
      :to="{
        name: 'article',
        params: {
          slug: article.slug
        }
      }" 
      class="preview-link"
    >
      <!--文章标题和描述-->
      <h1>{{ article.title }}</h1>
      <p>{{ article.description }}</p>
      <span>Read more...</span>
    </nuxt-link>
  </div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
  1. 之后可以看到首页有数据渲染出来。

# 分页功能

这里要处理两部分内容:

  • 页码对应的数据处理
  • 和用户的交互

在文章列表中有两个参数,一个是limit(每次请求文章的条数),一个是offset(默认是0,文章偏移量,如果值为2表示跳过前两条取后面的)

  1. 现在定义url中的页面形式是http://localhost:3000/?page=3,每个页面从query中取,如果没有默认是1,然后传参的时候,限制为20,偏移量是(当前页-1)*偏移量
async asyncData({ query }) {
    const page = Number.parseInt(query.page || 1)
    const limit = 20
    const {data} = await getArticles({
      limit,
      offset: (page - 1) * limit
    })
    console.log(data)
    // 将page和limit也导出
    return {
      articles: data.articles,
      articlesCount: data.articlesCount,
      page,
      limit
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  1. 将页码从demo中copy出来
<nav>
    <ul class="pagination">
      <li class="page-item">
        <a class="page-link">1</a>
      </li>
    </ul>
</nav>
1
2
3
4
5
6
7
  1. 下面我们求出总页数,这里使用计算属性,总页数/每页数量,向上取整即可。
computed: {
    totalPage () {
      return Math.ceil(this.articlesCount / this.limit)
    }
}
1
2
3
4
5
  1. 之后在分页列表中进行修改
<nav>
    <ul class="pagination">
      <!--根据总页数进行内容遍历,然后添加动态高亮active,当页数等于当前值的时候-->
      <li
        class="page-item"
        :class="{
          active: item === page
        }"
        v-for="item in totalPage"
        :key="item">
        <!--使用nuxt-link不跳转,点击的时候给后面query添加page参数,值是当前点击的值-->
        <nuxt-link
          class="page-link"
          :to="{
            name: 'home',
            query: {
              page: item
            }
          }">{{item}}</nuxt-link>
      </li>
    </ul>
</nav>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  1. 目前这样会有问题,因为没有刷新页面所以数据不会重新获取,如果要改变query的时候也调用asyncData,那么就看这个文档

这里要在home/index.vue页面中添加page的改变进行监听,这里修改之后必须更新,热更新有问题。

export default { 
    watchQuery: ['page']
}
1
2
3
  1. 这样点击更新就会更新页面了。

# 标签列表

可以根据标签,筛选显示的内容。先找到对应的接口/api/tags

  1. 创建api/tag.js,写对应接口
import request from '@/utils/request'

// 获取标签列表
export const getTags = () => {
  return request({
    method: 'GET',
    url: '/api/tags'
  })
}
1
2
3
4
5
6
7
8
9
  1. home/index.vue中,因为标签也需要seo,所以这里也需要在asyncData中获取数据
  async asyncData({ query }) {
    ...
    // 因为已经有data了,所以这里做个别名tagData
    const { data: tagData } = await getTags()
    console.log(tagData)
    // 这里return的时候将tags返回
    return {
      articles: data.articles,
      articlesCount: data.articlesCount,
      page,
      limit,
      tags: tagData
    }
  },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  1. 可以看到拿到的数据,tags里面是一个数组
{
    "tags": [
        "\u200c",
        "\u200c\u200c",
        "\u200c\u200c\u200c",
        "\u200c\u200c\u200c\u200c\u200c",
        "\u200c\u200c\u200c\u200c",
        "\u200c\u200c\u200c\u200c\u200c\u200c\u200c\u200c",
        "\u200c\u200c\u200c\u200c\u200c\u200c\u200c",
        "\u200c\u200c\u200c\u200c\u200c\u200c\u200c\u200c\u200c\u200c\u200c",
        "\u200c\u200c\u200c\u200c\u200c\u200c",
        "\u200c\u200c\u200c\u200c\u200c\u200c\u200c\u200c\u200c\u200c",
        "HuManIty",
        "Hu\u200cMan\u200cIty",
        "Gandhi",
        "HITLER",
        "SIDA",
        "BlackLivesMatter",
        "Black\u200cLives\u200cMatter",
        "test",
        "dragons",
        "butt"
    ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  1. 因为获取文章列表和获取标签列表没有依赖关系,完全可以并行,使用promise.all
async asyncData({ query }) {
    const page = Number.parseInt(query.page || 1)
    const limit = 20
    // Promise.all的结果,第一个执行的结果在下标为0,第二个执行的结果在下标为1
    const [ articleRes, tagRes ] = await Promise.all([
      getArticles({
        limit,
        offset: (page - 1) * limit
      }),
      getTags()
    ])
    const { articles, articlesCount } = articleRes.data
    const { tags } = tagData.data
    return {
      articles,
      articlesCount,
      page,
      limit,
      tags
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  1. 点击标签,和分页一样,同步在query中,这里先将标签遍历,并且点击的时候跳转传参query
<div class="tag-list">
  <nuxt-link :to="{
    name: 'home',
    query: {
      tag: item
    }
  }" class="tag-pill tag-default"
  v-for="item in tags"
  :key="item">{{ item }}</nuxt-link>
</div>
1
2
3
4
5
6
7
8
9
10
  1. 这个时候将搜索文章的接口中传入tag参数,并且监听页面的tag query
export default { 
  ...
  async asyncData({ query }) {
    ...
    // 传入tag
    const [ articleRes, tagRes ] = await Promise.all([
      getArticles({
        limit,
        offset: (page - 1) * limit,
        tag: query.tag
      }),
      getTags()
    ])
    ...
  },
  // 监听tag变化
  watchQuery: ['page','tag'],
  ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  1. 这个时候要对页面和标签进行联动,点击tag的时候是默认第一页所以不用管,如果是有tag的时候点页数,那么需要加两个参数
<ul class="pagination">
  <li
    class="page-item"
    :class="{
      active: item === page
    }"
    v-for="item in totalPage"
    :key="item">
    <!--里面添加tag,值是当前的route里面的tag-->
    <nuxt-link
      class="page-link"
      :to="{
        name: 'home',
        query: {
          page: item,
          tag: $route.query.tag
        }
      }">{{item}}</nuxt-link>
  </li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 文章列表导航栏

# 说明

  • 登录状态下,有用户点赞的文章、所有文章
  • 未登录状态下,只有所有文章
  • 点击标签,会新增一栏标签栏文章
  • 点击我点赞的文章和所有文章,标签栏文章会去掉

# 设计

用户点赞的文章:tab = your_feed 所有文章: tab = global_feed(默认) 标签文章: tab = tag

# 实现

  1. 首先,确定第一个用户点赞文章,是在登录状态下才展示,引入登录状态
import { mapState } from "vuex";
...
computed: {
    ...mapState(['user']),
    ...
}
1
2
3
4
5
6
<ul class="nav nav-pills outline-active">
  <!-- 1.登录状态下才展示,设置v-if为user -->
  <li class="nav-item" v-if="user">
  <!--2. a链接修改为nuxt-link,
      3. 添加动态高亮,active是tab为your_feed的时候高亮,4. 然后添加跳转,点击的时候,要跳到首页并且query的tab为you_feed
      5. 因为路由只匹配/,后面的query没法匹配,所以这里要用精准匹配-->
    <nuxt-link
      class="nav-link"
      :class="{
        active: tab === 'your_feed'
      }"
      :to="{
        name: 'home',
        query: {
          tab: 'your_feed'
        }
      }"
      exact>Your Feed</nuxt-link>
  </li>
  ...
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  1. 所有文章,不需要登录状态
<li class="nav-item">
<!--1. a标签替换为nuxt-link
    2. 标签高亮,当tab为global_feed
    3. 跳转的时候query后面tab变成global_feed
    4. 因为路由的关系要精准匹配-->
    <nuxt-link
      class="nav-link"
      :class="{
        active: tab ==='global_feed'
      }"
      :to="{
        name: 'home',
        query: {
          tab: 'global_feed'
        }
      }"
      exact>Global Feed</nuxt-link>
  </li>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  1. 这里要设置所有文章是默认的
async asyncData({ query }) {
    ...
    return {
      ...
      tab: query.tab || 'global_feed'
    }
}
1
2
3
4
5
6
7
  1. 这里的时候,文章高亮还是不对,除了精准匹配,这里还要对tab的query进行监听
watchQuery: ['page','tag','tab']
1
  1. 之后就是最后一个tag的导航栏了,因为一直用到这个tag,要放在query中,还要展示在标签导航栏上,首先将query中的tag存到data中
async asyncData({ query }) {
    ...
    const { tag } = query
    ...
    return {
      articles,
      articlesCount,
      page,
      limit,
      tags,
      tag,
      tab: query.tab || 'global_feed'
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  1. 最后一个tag的导航栏
<!--1. 如果tag不存在就不展示-->
<li class="nav-item" v-if="tag">
    <!--2. a链接编程nuxt-link
        3. 动态高亮,tab为tag
        4. 跳转,query为两个参数,一个是tab为tag,一个是tag
        5. 标签的文案进行变化-->
    <nuxt-link 
      class="nav-link"
      :class="{
        active: tab === 'tag'
      }"
      :to="{
        name: 'home',
        query: {
          tab: 'tag',
          tag: tag
        }
      }"
    >#{{ tag }}</nuxt-link>
  </li>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  1. 这个时候点击标签列表,标签的tab没有高亮,所以需要修改标签列表的跳转。
<div class="tag-list">
  <nuxt-link :to="{
    name: 'home',
    query: {
      tag: item,
      tab: 'tag'
    }
  }" class="tag-pill tag-default"
  v-for="item in tags"
  :key="item">{{ item }}</nuxt-link>
</div>
1
2
3
4
5
6
7
8
9
10
11
  1. 点击分页按钮,要对当前的tab进行保持
<li
    class="page-item"
    :class="{
      active: item === page
    }"
    v-for="item in totalPage"
    :key="item">
    <nuxt-link
      class="page-link"
      :to="{
        name: 'home',
        query: {
          page: item,
          tag: tag,
          tab: tab
        }
      }">{{item}}</nuxt-link>
</li>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 用户文章列表

  1. 用户文章列表需要进行改造,首先有一个api是针对这个的
// 获取用户文章列表
export const getFeedArticles = params => {
  return request({
    method: 'GET',
    url: '/api/articles/feed',
    params
  })
}
1
2
3
4
5
6
7
8

2,之后在home/index.vue中使用

// 1. 引入状态管理store
async asyncData({ query, store }) {
    const page = Number.parseInt(query.page || 1)
    const limit = 20
    // 2. 将tab赋值到一个变量
    const { tag, tab = 'global_feed' } = query
    // 3. 判断登录状态,且是不是your_feed标签栏,分别设置给不同的函数
    const loadArticles = store.state.user && tab === 'your_feed' ? getFeedArticles : getArticles
    // 4. 调用函数获取文章列表
    const [ articleRes, tagRes ] = await Promise.all([
      loadArticles({
        limit,
        offset: (page - 1) * limit,
        tag: tag
      }),
      getTags()
    ])
    const { articles, articlesCount } = articleRes.data
    const { tags } = tagRes.data
    // 把tab进行修改
    return {
      articles,
      articlesCount,
      page,
      limit,
      tags,
      tag,
      tab
    }
  },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  1. 保存之后报错,因为我们并没有证明我们自己的身份,我们需要在请求的时候在headers里面传token参数,这个在文档中有写到。

image

  1. 这里需要用到axios的拦截器
// 请求拦截器
// 任何请求都要经过拦截器,可以在请求拦截器中做一些公共的业务处理,例如统一设置 token
request.interceptors.request.use(function (config) {
  // 请求会经过这里
  // 这里拿到的config是请求中的所有数据,先拿到请求头,然后写文档中要求的字段Authorization
  config.headers.Authorization = `Token 用户token`

  // 返回 config 请求配置对象
  return config;
}, function (error) {
  // 如果请求失败(此时请求还没有发出去)就会进入这里
  // Do something with request error
  return Promise.reject(error);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  1. 关键那个用户token怎么获取,我们之前使用store获取,但是现在他是用函数导出的方式,我们获取不到user,我们使用纯客户端的方式是完全不行的,这里用到了新的知识点 —— Nuxt的插件

Nuxt的插件

Nuxt.js 允许您在运行 Vue.js 应用程序之前执行 js 插件。这在您需要使用自己的库或第三方模块时特别有用。
这里我们可以拿到我们需要的状态

  • 使用第三方模块,安装使用,我们这个一直在用
  • Vue插件,创建plugin文件夹,然后注册到vue中,还要配置到nuxt.config.js
  • ES6插件,如果是ESModules,而且在node_modules中,要添加到transpile中
  • 注入$root和context

通过插件,让插件导出一个函数,在函数中就可以拿到运行期间的app根实例,或者上下文对象,包括store都在里面。都能正确拿到数据

  1. 创建plugins/request.js,用插件的方式写axios
import axios from 'axios'

const request = axios.create({
  // 在页面模板中有设置基准路径,后面跟api
  baseURL: 'https://conduit.productionready.io'
})

// 通过插件机制获取到上下文对象(query,params,req,res,app,store...)
export default (context) => {
  console.log(context)
}  
1
2
3
4
5
6
7
8
9
10
11
  1. 然后记得要在config中进行注册
module.exports = {
  router: {
    ...
   },

   // 注册插件,前面要加~,从根路径出发的意思
   plugins: [
     '~/plugins/request.js'
   ]
 }
1
2
3
4
5
6
7
8
9
10
  1. 浏览器打印可以看到输出的上下文
{
  isStatic: false,
  isDev: true,
  isHMR: false,
  app: {
    ...
  },
  store: Store {
    ...
  },
  payload: undefined,
  error: [Function: bound
  ...
}  
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  1. 然后将contxt解构出来,拿到用户信息之后将request也按需导出
/**
 * 基于 axios 封装的请求模块
 */

import axios from 'axios'

// 按需导出
export const request = axios.create({
  // 在页面模板中有设置基准路径,后面跟api
  baseURL: 'https://conduit.productionready.io'
})

// 插件导出函数只能用一个成员,且是默认成员
// 通过插件机制获取到上下文对象(query,params,req,res,app,store...)
export default ({ store }) => {
  // // 请求拦截器
  //   // 任何请求都要经过拦截器,可以在请求拦截器中做一些公共的业务处理,例如统一设置 token
    request.interceptors.request.use(function (config) {
      // 请求会经过这里
      // 这里拿到的config是请求中的所有数据,先拿到请求头,然后写文档中要求的字段Authorization
      const { user } = store.state
      if (user && user.token) {
        config.headers.Authorization = `Token ${user.token}`
      }

      // 返回 config 请求配置对象
      return config;
    }, function (error) {
      // 如果请求失败(此时请求还没有发出去)就会进入这里
      // Do something with request error
      return Promise.reject(error);
    });
  // 相应拦截器
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
  1. 然后我们将api文件夹中的请求文件和store的请求文件都换到plugins下面,而且要用下面的方式按需加载request
import { request } from '@/plugins/request'
1
  1. 打开浏览器,可以看到访问的时候正确了,因为我没有给任何人点过赞,所以这里看不到点赞信息。可以看到请求头中已经添加了token,说明生效了。

image

# 日期格式化处理

推荐一个dayjs的事件格式化类库,是moment.js的轻量化方案,为什么轻量是因为其采用模块化方式,本身只集成了最核心的方法,其他的不常用的需要的话就按需加载。

我们除了首页要使用日期格式化,还要在文章详情页使用,所以最好是做一个全局的过滤器。我们这里要用到插件。

  1. 安装npm i dayjs --save
  2. 创建plugins/dayjs.js
// 导入vue和dayjs
import Vue from 'vue'
import dayjs from 'dayjs'

// {{ 表达式 | 过滤器 }}
// 注册全局过滤器date,参数是value,第二个是format,如果用户传自己的就用自己的,不传就用默认的。
Vue.filter('date', (value, format='YYYY-MM-DD:HH:mm:ss') => {
  return dayjs(value).format(format)
})
1
2
3
4
5
6
7
8
9
  1. 在nuxt.config.js中进行注册
plugins: [
 '~/plugins/request.js',
 '~/plugins/dayjs.js'
]
1
2
3
4
  1. home/index.vue中添加过滤器
<span class="date">{{ article.createdAt | date }}</span>
1
  1. 如果想要修改格式,可以直接添加参数
<span class="date">{{ article.createdAt | date('MMM DD, YYYY') }}</span>
1

# 文章点赞功能

  • 给文章点赞,点赞按钮高亮,然后数字+1
  • 取消点赞,按钮不高亮,数字-1
  1. api/article.js中添加接口
// 添加点赞
export const addFavorite = slug => {
  return request({
    method: 'POST',
    url: `/api/articles/${slug}/favorite`
  })
}

// 取消点赞
export const deleteFavorite = slug => {
  return request({
    method: 'DELETE',
    url: `/api/articles/${slug}/favorite`
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 给按钮添加点击事件
<button 
    class="btn btn-outline-primary btn-sm pull-xs-right" 
    :class="{
      active: article.favorited
    }"
    @click="onFavorite(article)">
    <i class="ion-heart"></i> {{ article.favoritesCount }}
  </button>
1
2
3
4
5
6
7
8
  1. 在methods中添加方法
async onFavorite (article) {
  if(article.favorited) {
    // 取消点赞
    await deleteFavorite(article.slug)
    // 处理视图
    article.favorited = false
    article.favoritesCount += -1
  } else {
    // 添加点赞
    await addFavorite(article.slug)
    // 处理视图
    article.favorited = true
    article.favoritesCount += 1
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 添加请求过程中点击禁用的功能,先在asyncData初始化的时候遍历添加一个可点状态的属性
async asyncData({ query, store }) {
    ...
    const { articles, articlesCount } = articleRes.data
    const { tags } = tagRes.data
    // 添加可点赞状态
    articles.forEach(articles => articles.favoriteDisabled = false)
    return {
      articles,
      articlesCount,
      page,
      limit,
      tags,
      tag,
      tab
    }
  },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  1. 然后在button中绑定
<button 
    class="btn btn-outline-primary btn-sm pull-xs-right" 
    :class="{
      active: article.favorited
    }"
    @click="onFavorite(article)"
    :disabled="article.favoriteDisabled">
    <i class="ion-heart"></i> {{ article.favoritesCount }}
  </button>
1
2
3
4
5
6
7
8
9
  1. 之后再methods中修改
async onFavorite (article) {
  // 请求之前禁用点击
  article.favoriteDisabled = true
  if(article.favorited) {
    ...
  } else {
    ...
  }
  // 请求之后开启点击
  article.favoriteDisabled = false
}
1
2
3
4
5
6
7
8
9
10
11

# 文章详情

# 功能分析

  • 展示文章详情
  • 写评论、展示评论功能
  • 头像、名称、收藏和点赞可以封装组件

image

# 展示基本信息

  1. api/article.js中写接口
// 获取文章详情
export const getArticle = slug => {
  return request ({
    method: 'GET',
    url: `/api/articles/${slug}`
  })
}
1
2
3
4
5
6
7
  1. article/index.vue中引入请求
import { getArticle } from "@/api/article";
export default { 
    name: 'ArticlePage',
    async asyncData({ params }) {
      // 将slug传入,返回data
      const { data } = await getArticle(params.slug)
      console.log(data)
    }
}
1
2
3
4
5
6
7
8
9

看到我们返回的data中的结构,所以这里返回article

{
  article: {
    title: '1003 Lamps',
    slug: '1003-lamps-v1n15n',
    body: 'count',
    createdAt: '2021-01-07T07:19:59.490Z',
    updatedAt: '2021-01-07T07:19:59.490Z',
    tagList: [...],
    description: 'count',
    author: {
      username: 'Aprii',
      bio: 'hellooo0',
      image: 'https://st.quantrimang.com/photos/image/2016/11/11/anh-gif-3.gif/backGRound/https://images.unsplash.com/photo-1609270019516-73e889aafaac?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MXw3MDY2fDB8MXxjb2xsZWN0aW9ufDI1fDMxNzA5OXx8fHx8Mnw&ixlib=rb-1.2.1&q=80&w=1080',
      following: false
    },
    favorited: false,
    favoritesCount: 0
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { getArticle } from "@/api/article";
export default { 
    name: 'ArticlePage',
    async asyncData({ params }) {
      const { data } = await getArticle(params.slug)
      // 将article返回
      return {
        article: data.article
      }
    }
}
1
2
3
4
5
6
7
8
9
10
11
  1. 修改文章标题,可以看到页面有正常的显示
<h1>{{ article.title }}</h1>
1

# 将正文的markdown格式转化为html格式

  1. 因为文章正文支持markdown格式,使用第三方包markdown-it,将markdown格式的转化成html格式,下面进行安装
npm i markdown-it --save
1
  1. article/index.vue中引用
import MarkdownIt from "markdown-it";
...
async asyncData({ params }) {
  console.log(params)
  const { data } = await getArticle(params.slug)
  const { article } = data
  // 在导出之前将文章内容转化为html
  const md = new MarkdownIt()
  article.body = md.render(article.body)
  console.log(data)
  return {
    article
  }
}
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这个时候可以看到body的东西有了标签展示

image

  1. html中进行引用
<div class="row article-content">
    <div class="col-md-12" v-html="article.body"></div>
</div>
1
2
3

# 作者信息组件

  1. 在article文件夹中创建components/articl-meta.js文件,然后把模板拷贝进去。
<template> 
  <div class="article-meta">
    <a href=""><img src="https://tvax4.sinaimg.cn/crop.0.0.690.690.1024/0075v9WWly8gm3jbk0ufnj30j60j6t9f.jpg?KID=imgbed,tva&Expires=1609784063&ssig=zs0nC1fpU1" /></a>
    <div class="info">
      <a href="" class="author">Eric Simons</a>
      <span class="date">January 20th</span>
    </div>
    <button class="btn btn-sm btn-outline-secondary">
      <i class="ion-plus-round"></i>
      &nbsp;
      Follow Eric Simons <span class="counter">(10)</span>
    </button>
    &nbsp;&nbsp;
    <button class="btn btn-sm btn-outline-primary">
      <i class="ion-heart"></i>
      &nbsp;
      Favorite Post <span class="counter">(29)</span>
    </button>
  </div>
</template>   
<script> 
export default { 
    name: 'ArticleMeta'
}
</script> 

<style> </style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  1. 然后在index.vue中注册并使用组件,将article-mate的div换成下面的单标签,两个地方,并且将文章的数据传入。
<article-meta :article="article"/>
1
import ArticleMeta from './components/article-meta';
...
components: {
  ArticleMeta
}
1
2
3
4
5
  1. 在article-meta.vue中进行参数的接收
// 确定article是对象,且必须
props: {
  article: {
    type: Object,
    require: true
  }
}
1
2
3
4
5
6
7
  1. 展示在页面上
<template> 
  <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 
        :to="{
          name: 'profile',
          params: {
            username: article.author.username
          }
        }" 
        class="author"
      >
        {{ article.author.username }}
      </nuxt-link>
      <!--日期,使用过滤器-->
      <span class="date">{{ article.createdAt | date('MMM DD, YYYY') }}</span>
    </div>
    <!-- 关注,类名看是否关注,去掉数量(接口中没有) -->
    <button 
      class="btn btn-sm btn-outline-secondary"
      :class="{
        active: article.author.following
      }"
    >
      <i class="ion-plus-round"></i>
      &nbsp;
      Follow Eric Simons
    </button>
    &nbsp;&nbsp;
    <!-- 点赞,类名看是否点赞,记录点赞数量 -->
    <button 
      class="btn btn-sm btn-outline-primary"
      :class="{
        active: article.favorited
      }"
    >
      <i class="ion-heart"></i>
      &nbsp;
      Favorite Post <span class="counter">({{ article.favoritesCount }})</span>
    </button>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
  1. 这样就完成了组件的配置

# 设置页面meta优化SEO

我们想要文章详情的标题成为html标题,并且设置meta,如何处理?

  • Nuxt视图 > HTML 头部 (opens new window)
  • 参考上面的网址,Nuxt.js使用了vue-meta更新应用头部Head和html属性
  • 我们除了可以在index.html中手动设置标题和meta信息,还可以使用nuxt.config.js对head字段进行配置
head: {
  meta: [
    { charset: 'utf-8' },
    { name: 'viewport', content: 'width=device-width, initial-scale=1' }
  ],
  link: [
    { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Roboto' }
  ]
}
1
2
3
4
5
6
7
8
9
<script>
  export default {
    data() {
      return {
        title: 'Hello World!'
      }
    },
    head() {
      return {
        title: this.title,
        meta: [
          {
            hid: 'description',
            name: 'description',
            content: 'My custom description'
          }
        ]
      }
    }
  }
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  1. 所以在article/index.vue中添加head方法
// 加入这个对SEO非常有用 
head () {
  // 网页标题是文章标题 - 网站名
  return {
    title: `${this.article.title} - RealWorld`,
    // hid:为了避免子组件中的meta标签不能正确覆盖父组件中相同的标签而产生重复的现象,利用hid为meta配一个唯一的标识编号。
    meta: [{
      hid: 'description',
      name: 'description', 
      content: this.article.description
    }]
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 通过客户端渲染展示评论列表

1.创建article/compponents/article-comments.vue文件,然后将index.vue中的评论内容copy过去

<template>
  <div>
    <!-- 发表评论 -->
    <form class="card comment-form">
      ...
    </form>
    
    <!-- 评论卡片 -->
    <div class="card">
      ...
    </div>

    <div class="card">
      ...
    </div>
  </div>
</template>

<script>
export default {
  name:'articleComments'
}
</script>

<style>

</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  1. 在index.vue中注册并引用组件,并把文章信息传过去
<article-comments :article="article"/>
1
import ArticleComments from './components/article-comments';
...
components: {
  ArticleMeta,
  ArticleComments
}
...
1
2
3
4
5
6
7
  1. 网页展示没有问题,那么在api/article.js中开始写获取文章评论的接口
// 获取文章评论
export const getComments = slug => {
  return request ({
    method: 'GET',
    url: `/api/articles/${slug}/comments`
  })
}
1
2
3
4
5
6
7
  1. 然后在组件comments中引用,因为这里不需要优化seo,所以使用客户端渲染页面
<script>
import { getComments } from '@/api/article'
export default {
  name:'articleComments',
  data () {
    return {
      comments: [] // 文章列表
    }
  },
  props: {
    article: {
      type: Object,
      required: true
    }
  },
  async mounted () {
    const { data } = await getComments(this.article.slug)
    console.log(data)
    this.comments = data.comments
  }
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  1. 可以看到打印出来的数据格式
{
    "comments": [
        {
            "id": 81967,
            "createdAt": "2021-01-07T10:02:17.803Z",
            "updatedAt": "2021-01-07T10:02:17.803Z",
            "body": "11",
            "author": {
                "username": "luopengppxcll",
                "bio": "ceshiyixia",
                "image": "https://st.quantrimang.com/photos/image/2016/11/11/anh-gif-3.gif",
                "following": false
            }
        }
    ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  1. 下面对页面进行修正
<!-- 发表评论 -->
<form class="card comment-form">
  <div class="card-block">
    <textarea class="form-control" placeholder="Write a comment..." rows="3"></textarea>
  </div>
  <div class="card-footer">
    <!--这里使用的是用户头像-->
    <img :src="article.author.image" class="comment-author-img" />
    <button class="btn btn-sm btn-primary">
    Post Comment
    </button>
  </div>
</form>
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 评论卡片,对comments进行遍历 -->
<div class="card" 
  v-for="comment in comments"
  :key="comment.id">
  <div class="card-block">
    <!--评论主体-->
    <p class="card-text">{{ comment.body }}</p>
  </div>
  <div class="card-footer">
    <!--用户头像和名称链接,跳转到用户的主页-->
    <nuxt-link 
      class="comment-author"
      :to="{
        name: 'profile',
        params: {
          username: comment.author.username
        }
      }">
      <img :src="comment.author.image" class="comment-author-img" />
    </nuxt-link>
    &nbsp;
    <nuxt-link 
      class="comment-author"
      :to="{
        name: 'profile',
        params: {
          username: comment.author.username
        }
      }">
      {{ comment.author.username }}
    </nuxt-link>
    <!--显示评论的时间并添加过滤器-->
    <span class="date-posted">{{ comment.createdAt | date('MMM DD, YYYY') }}</span>
  </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
  1. 评论列表也就完成了。

# 发布部署

发布部署看下面

更新时间: 2022-01-30 15:35