RealWorld – Nuxt实战解析

1,320 阅读8分钟

Why Nuxt?

为什么我们要使用Nuxt?这要从Vue,React等响应式框架出现之前说起。在这些框架出现前,前端页面的内容是直观的,也就是说,服务端发出的内容是什么,客户端接收的是什么,例如html的内容如下:

<!DOCTYPE html>
<html>
  <head>
    <title>
      Hello World
</title>
  </head>
  <body>
    <h1>Hello World</h1>
  </body>
  <script>….</script>
</html>

如果服务端所发送的是如上的页面,客户端就直接渲染它。然而,随着前端越来越复杂,开发者需要使用React,Vue等框架开发SPA应用,页面基本是用JSX或Vue文件处理,也就是用JS处理页面,最后打包生成的应用基本上是JS代码,所以发送到客户端的页面类似这样:


<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> 
        <title>Hello World</title>
        <base href="/" />

        <link rel="stylesheet" href="/dist/vendor.css?v=qJ-18eTKRuIOxkBcVlEElYR9pHRWHWr38sTzHmaRuCA" />
        
            <link rel="stylesheet" href="/dist/site.css?v=uvQClgv-3Z8KNbFqh_X_ZE5uexudDL31JjBPDPNJUKY" />
        
    </head>
    <body>
        
<div id='app-root'>
    loading...
</div>


        <script src="/dist/hello.js?v=Io9BFPQEpEWEJ1Q9pnpuMZIFLrV6BKjn3ie18qSGjJ0"></script>
        
    <script src="/dist/main.js?v=yVaGFXJfMb1IBYuoO91EAQN-qeOqMG4lq9pl2tMQAgs"></script>

    </body>
</html>

我们可以看到,以上的代码html部分基本没什么内容,大部分工作是由所包含的JS代码处理。因此现在的页面不像以前般直观。 这导致两个问题出现。一是客户端首次渲染变慢了,因为浏览器要处理JS代码才能渲染页面;二是SEO问题,由于客户端呈现的页面代码如上,搜索引擎不知道这是要搜索的内容。 而Nuxt这类的框架则是为了解决以上问题而出现,它把首次渲染放在服务端完成,SPA其余要处理的部分则由客户端处理。这样解决以上两个问题,然而相应的代价是服务器的工作量增加,而开发难度上升,因为开发者要同时考虑服务和客户端的问题,但这问题其实Nuxt已经帮助开发者大大减轻难度了。

项目介绍

RealWorld是一个开源的学习项目,它是一个类似medium(类似国内的简书)的应用,可以阅览和发布文章。 Layout

项目github地址: github.com/gothinkster…

创建项目

如同一般的node.js项目,新建一个文件夹,初始化项目,然后下载Nuxt:

mkdir realworld-nuxtjs
cd realworld-nuxtjs
npm init –y
npm install nuxt

可以加一个脚本方便启动Nuxt:

“scripts”: {
  “dev”: “nuxt”
}

Nuxt是约定优于配置,架构部分已经规定好,如我们想添加一个页面,则新建一个pages活页夹,它是Nuxt指定用来存放页面的文件夹。新建一个文件index.vue,添加些内容进去:

<template>
  <div>
    Hello World
  </div>
</template>
<script>
  export default {
  name: “homepage”
}
</script>

index.vue是Nuxt默认的首頁名称。

npm run dev

启动Nuxt,用浏览器打开 http://localhost:3000/,可以看到应用。

也可以用Nuxt的脚手架直接创建应用,脚手架的名称叫create-nuxt-app,基本用法和vue-cli差不多,所以不多说了。

页面架构

写代码前,先把页面拉到项目中,页面模板:github.com/gothinkster…

现在pages如下:

pages

每个文件夹里有index.vue,对应其中一个页面,有些文件夹下有组件,存放在components。 用一个表简单描述一下页面的作用:

页面作用
article文章详情
editor编辑文章
home首页
layout布局
login登录/注册
profile用户页面
settings用户设置

最后引入样式,在根目录创建app.html,内容如下:

<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
  <head {{ HEAD_ATTRS }}>
    {{ HEAD }}
    <!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->
    <link href="https://cdn.jsdelivr.net/npm/ionicons@2.0.1/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">
    <!-- Import the custom Bootstrap 4 theme from our hosted CDN -->
    <!-- <link rel="stylesheet" href="//demo.productionready.io/main.css"> -->
    <link rel="stylesheet" href="/index.css">
  </head>
  <body {{ BODY_ATTRS }}>
    {{ APP }}
  </body>
</html>

新建文件夹static,用来存放静态文件,把index.css放在这里。

实现导航连接高亮

基本文件配置后,设定路由。Nuxt有默认的路由配置,简单来说,就是当开发者把页面放在pages时,可以直接在页面中用,如pages中有about.vue,路径写为/about,就会直接转到对应页面,与超链接用法相同,只是nuxt-link是单页面跳转,浏览器没有刷新页面。 如果想自定义路由,可以在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/')
            },
            {
              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/')
            }
          ]
        }
      ])
    }
  },
}

添加router,其对象里添加extendRoutes,可以自定义路由,先把默认路由清除,添加自定义路由,基本上与vue-router差不多。 把pages/layout/index.vue里的对应超链接换成nuxt-link,to设定对应路径。 现在处理高亮。导航栏应该把用户所处位置的连接高亮。Nuxt默认在所处位置的nuxt-link添加class,其值为nuxt-link-active,然而css中处理高亮的class为active,所以我们要改一下配置。 nuxt.config.js的router中加上: linkActiveClass: 'active' 最后layout.vue如下:

<template>
  <div>
    <!-- 顶部导航栏 -->
    <nav class="navbar navbar-light">
      <div class="container">
        <!-- <a class="navbar-brand" href="index.html">conduit</a> -->
        <nuxt-link
          class="navbar-brand"
          to="/"
        >Home</nuxt-link>
        <ul class="nav navbar-nav pull-xs-right">
          <li class="nav-item">
            <!-- Add "active" class when you're on that page" -->
            <!-- <a class="nav-link active" href="">Home</a> -->
            <nuxt-link
              class="nav-link"
              to="/"
              exact
            >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="/profile/123">
                <img
                  class="user-pic"
                  :src="user.image"
                >
                {{ user.username }}
              </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>
        </ul>
      </div>
    </nav>
    <!-- /顶部导航栏 -->

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

    <!-- 底部 -->
    <footer>
      <div class="container">
        <a href="/" class="logo-font">conduit</a>
        <span class="attribution">
          An interactive learning project from <a href="https://thinkster.io">Thinkster</a>. Code &amp; design licensed under MIT.
        </span>
      </div>
    </footer>
    <!-- /底部 -->
  </div>
</template>

类似Vue-Router,首面的nuxt-link也要加上exact精确匹配,不然该连接一直有active的class。

封装请求模块

因为之后不少场景需要调用api,所以先封装一下axios,方便之后调用。新建util/request.js:

  import axios from ‘axios’

  const request = axios.create({
baseURL: 'https://conduit.productionready.io/'})

  export default request

创建axios实例,给上api的根地址,之后写api可以少写些。

登录注册

实现基本登录功能

现在正式开始写功能。首先把注册登录完成,因为之后的内容不少是依赖它的。 先看一下演示的页面,看一下大概需要做什么。

signin

signup

两个页面其实差不多,就是注册比登录多点内容罢了,所以我们需要一个判断,知道到底现在的页面是登录还是注册,决定一些内容是否显示。 现在的问题是怎样获得判断,答案很简单,当前路径就可得知。使用this.$route.name就能获取当前路径的路由名称,所以我们可以这样写:

<script>
  export default {
    computed: {
      isLogin() {
        return this.$route.name === ‘Login’
}
}
}
</script>

现在可以在template处把需要判断决定显示的地方加上isLogin:

<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">
            <!-- <a href="">Have an account?</a> -->
            <nuxt-link v-if="isLogin" to="/register">Need an account?</nuxt-link>
            <nuxt-link v-else to="/login">Have an account?</nuxt-link>
          </p><form >
            <fieldset v-if="!isLogin" class="form-group">
              <input v-model="user.username" class="form-control form-control-lg" type="text" placeholder="Your Name" required>
            </fieldset>
            <fieldset class="form-group">
              <input v-model="user.email" class="form-control form-control-lg" type="email" placeholder="Email" required>
            </fieldset>
            <fieldset class="form-group">
              <input v-model="user.password" class="form-control form-control-lg" type="password" placeholder="Password" required 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>

视图部分基本完成,然后完成窗体提交部分。首先先把输入框的数据进行绑定,还要阻止窗体默认行为,使用axios提交数据:

<template>
          <form @submit.prevent="onSubmit">
            <fieldset v-if="!isLogin" class="form-group">
              <input v-model="user.username" class="form-control form-control-lg" type="text" placeholder="Your Name" required>
            </fieldset>
            <fieldset class="form-group">
              <input v-model="user.email" class="form-control form-control-lg" type="email" placeholder="Email" required>
            </fieldset>
            <fieldset class="form-group">
              <input v-model="user.password" class="form-control form-control-lg" type="password" placeholder="Password" required minlength="8">
            </fieldset>
</template>
<script>
export default {
  data() {
    return {
      user : {
        username: ‘’,
        email: ‘’,
        password: ‘’,
}
}
}
}
</script>

现在余下onSubmit这个方法要处理。处理之前,先处理api部分。项目的api可以在这个文档中查阅: github.com/gothinkster… 新建api/user.js,把api进行封装:

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
  })
}

@是项目根目录。好啦,现在可以把onSubmit实现:

import {login, register} from ‘@/api/user’

methods: {
  async onSubmit () {
    try{
      const {data} = this.isLogin ? await login(this.user) ? await register(this.user)
    } catch(err) {
      console.log(err)
    }
    this.$router.push(‘/’)
  }
}

onSubmit是一个异步函数,根据isLogin的值来调用对应方法。由于axios返回的是一个对象,而我们实际需要的是里面的data属性,所以用 {data}。由于axios可能会报错,所以要用try…catch处理。

存储用户登录状态

现在有axios返回的data,我们还需要保存下来,可以在整个应用中使用,所以要用vuex。 在项目根目录新建store/index.js,它专门用来存放vuex代码。

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

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

然后在onSubmit添加 this.$store.commit(‘setUser’, data.user)保存用户登录状态。

Cookies

如果希望关闭应用后,状态依然保存,应该怎样做?平时做vue开发,可以用localStorage等方法把状态保存在客户端,重新打开应用获取状态,然而,由于现在是服务端渲染,它是不知道客户端保存的信息,所以保存客户端的方法不可行。这里可以用Cookies。用户再次发起请求,可以在header中存放cookies,服务器对cookies进行解析,获取相应信息进行渲染。 先安装相关的包:

npm install js-cookie cookieparser

js-cookie是把JS里cookies部分api封装,更方便使用cookies。Cookieparser,则是把cookies解析为对象。两个包分别处理客户端和服务端的功能实现。 先说客户端。在pages/login.vue中添加相应代码:

<script>
// 仅在客户端加载 js-cookie 包
const Cookie = process.client ? require('js-cookie') : undefined
…
onSubmit () {
  this.$store.commit(‘setUser’, data.user)
  Cookie.set(‘user’, data.user) //第一个参数键,第二个为值
…
}
</script>

process.client是Nuxt的属性,用来判断当前是否在客户端。

服务端则在store/index.js加上以下代码:

const cookieparser = process.server ? require('cookieparser') : undefined

export const state = () => {
  return {
    user: null
  }
}

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

export const actions = {
  nuxtServerInit ({ commit }, { req }) {
    let user = null

    if (req.headers.cookie) {
      // 使用 cookieparser 把 cookie 字符串转为 JavaScript 对象
      const parsed = cookieparser.parse(req.headers.cookie)
      try {
        user = JSON.parse(parsed.user)
      } catch (err) {
        // No valid cookie found
      }
    }

    // 提交 mutation 修改 state 状态
    commit('setUser', user)
  }
}

先判断是否在服务端,是的话就加载cookieparser。在actions中加上nuxtServerInit,它会在服务端渲染时调用。

报错处理

如果发生报错,我们希望信息呈现至视图。所以要添加error作为data属性,挂载到页面:

<template><p class="text-xs-center">
            <!-- <a href="">Have an account?</a> -->
            <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, field) in errors"
            >
              <li
                v-for="(message, index) in messages"
                :key="index"
              >{{ field }} {{ message }}</li>
            </template>
          </ul>
</template>
<script>data: {
    errors: {}
}
methods: {
…
catch(err){
  this.errors = err.response.data.errors
}
}
</script>

解释一下页面的双重v-for。 <template v-for="(messages, field) in errors>" 是遍历errors对象,其中messages是对象的值,field是属性。下一个v-for v-for="(message, index) in messages"把每一个messages数组遍历。

处理页面访问权限

如果已经处于登录状态,此时应该不能进入login页面,这时需要中间件帮助。中间件是自定义函数,能够在页面渲染前调用。 首先,先新建middleware,添加 notAuthenticated.js:

export default function ({ store, redirect }) {
  // If the user is authenticated redirect to home page
  if (store.state.user) {
    return redirect('/')
  }
}

中间件接收的参数是一个上下文对象context,我们只需要store和redirect。 挂载到login页面中

export default {
  middleware: 'notAuthenticated'
}

Login页面总算是写完啦。以下是完整代码:

<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">
            <!-- <a href="">Have an account?</a> -->
            <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, field) in errors"
            >
              <li
                v-for="(message, index) in messages"
                :key="index"
              >{{ field }} {{ message }}</li>
            </template>
          </ul>

          <form @submit.prevent="onSubmit">
            <fieldset v-if="!isLogin" class="form-group">
              <input v-model="user.username" class="form-control form-control-lg" type="text" placeholder="Your Name" required>
            </fieldset>
            <fieldset class="form-group">
              <input v-model="user.email" class="form-control form-control-lg" type="email" placeholder="Email" required>
            </fieldset>
            <fieldset class="form-group">
              <input v-model="user.password" class="form-control form-control-lg" type="password" placeholder="Password" required 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'

// 仅在客户端加载 js-cookie 包
const Cookie = process.client ? require('js-cookie') : undefined

export default {
  middleware: 'notAuthenticated',
  name: 'LoginIndex',
  computed: {
    isLogin () {
      return this.$route.name === 'login'
    }
  },
  data () {
    return {
      user: {
        username: '',
        email: 'lpzmail@163.com',
        password: '12345678'
      },
      errors: {} // 错误信息
    }
  },

  methods: {
    async onSubmit () {
      try {
        // 提交表单请求登录
        const { data } = this.isLogin
          ? await login({
              user: this.user
            })
          : await register({
            user: this.user
          })

        // console.log(data)
        // TODO: 保存用户的登录状态
        this.$store.commit('setUser', data.user)

        // 为了防止刷新页面数据丢失,我们需要把数据持久化
        Cookie.set('user', data.user)

        // 跳转到首页
        this.$router.push('/')
      } catch (err) {
        // console.log('请求失败', err)
        this.errors = err.response.data.errors
      }
    }
  }
}
</script>

<style>

</style>

拦截器

登录注册部分已经完成。当用户登入,服务器返回一个Token,如果想调用一些api需要用户验证,则需要Token。如果每次调用api都要验证信息都要增加Token,就要写好多重复代码,nuxt可以用插件帮我们处理。 插件可以在vue运行前执行。新建plugins/request.js,把之前request的代码放进去,然后添加拦截器:

/**
 * 基于 axios 封装的请求模块
 */

import axios from 'axios'

// 创建请求对象
export const request = axios.create({
  baseURL: 'http://realworld.api.fed.lagounews.com'
})

// 通过插件机制获取到上下文对象(query、params、req、res、app、store...)
// 插件导出函数必须作为 default 成员
export default ({ store }) => {

  // 请求拦截器
  // Add a request interceptor
  // 任何请求都要经过请求拦截器
  // 我们可以在请求拦截器中做一些公共的业务处理,例如统一设置 token
  request.interceptors.request.use(function (config) {
    // Do something before request is sent
    // 请求就会经过这里
    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)
  })
}

之前导入request的路径要改为plugins。最后在nuxt.config.js注册插件:

  plugins: ['~/plugins/request.js' ]

首頁模块

展示公共文章

这部分比较简单,就是调用api,获取文章,加载到页面。唯一比较麻烦的是分页处理。先把api封装好。在api中新建article.js:

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

export const getArticles = params => {
  return request({
    method: 'GET',
    url: '/api/articles',
    params
  })
}

分页

调用api前,先考虑一下分页问题。怎样才能做到分页呢? 总页数主要由两个数来决定: 总文章数和每页呈现的文章数,把总文章数除以每页呈现的文章数,就可得出总页数。总文章数可以从api获得,而每页呈现的文章数则由我们来定:

export default {
  name: 'HomeIndex',
  async Asyncdata() {
    const limit = 20
    getArticles({
      limit,
      tag
    })
    const {articles, articlesCount} = articleRes.data

    return {
      articles,
      articlesCount,
      limit
}
},
computed: {
  totalPage () {
    //Math.ceil向上取整
    return Math.ceil(this.articlesCount / this.limit )
}
}
}

Asyncdata返回的对象会作为data的值。得到总页数后,可以把页数列表渲染:

         <!-- 分页列表 -->
          <nav>
            <ul class="pagination">
              <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,
                    }
                  }"
                >{{ item }}</nuxt-link>
              </li>
            </ul>
          </nav>
          <!-- /分页列表 -->

每一个页数为一个nuxt-link,需要传参数query。如果页数为当前的页数,则:class为active。当然,现在data还没有当前页数,所以还需要有当前页数,可以在Asyncdata第一行加上 const page = query.page || 1,看一下有没有传来当前页数的值,没有则为1。还要在getArticles加上offset: (page - 1) * limit。Offset的意思是偏移,它是设定获取文章的起始点,之所以-1是因为起始是从0开始,但页数是1开始,所以要减1。然后返回的对象加上page。

不过现在即使随便点一页,浏览器没有任何反应,因为当前依然是在home的页面,nuxt是不会作出任何变化,如果希望query的值改变,页面发生变化,则需要添加watchQuery:

export default {
…
watchQuery: [‘page’],
…
}

先不急着把数据挂载到视图,还有其他事要考虑。

tag 和 tabs

影响页面文章显示不仅仅是页数,还有tag和tabs

home

Tab决定到底用户是在阅览公共还是自己的文章列表,而tags则是分类标签。 先处理Tab部分。首先得先处理用户。先把state引入:

import { mapState } from 'vuex'

computed:{
...mapState(['user']),
},

然后在视图中处理与用户登入相关的部分:

<div class="feed-toggle">
            <ul class="nav nav-pills outline-active">
              <li v-if="user" class="nav-item">
                <nuxt-link
                  class="nav-link"
                  :class="{
                    active: tab === 'your_feed'
                  }"
                  exact
                  :to="{
                    name: 'home',
                    query: {
                      tab: 'your_feed'
                    }
                  }"
                >Your Feed</nuxt-link>
              </li>
…

而tags则要先从api取数据,所以要封装一下,在api/tag.js添加getTags:

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

export const getTags = () => {
  return request({
    method: 'GET',
    url: '/api/tags'
  })
}```

还有一个api是取得用户的文章列表,在api/article.js添加getYourFeedArticles:

```javascript
export const getYourFeedArticles = params => {
  return request ({
    method: ‘GET’,
    url: ‘api/articles/feed’,
    params
  })
}

现在真正把Asyncdata完成:

import {
  getArticles,
  getYourFeedArticles,
} from '@/api/article'
import { getTags } from '@/api/tag'

  async asyncData ({ query }) {
    const page = Number.parseInt(query.page|| 1)
    const limit = 20
    const tab = query.tab || 'global_feed'
    const tag = query.tag

    const loadArticles = tab === 'global_feed'
      ? getArticles
      : getYourFeedArticles

    const [ articleRes, tagRes ] = await Promise.all([
      loadArticles({
        limit,
        offset: (page - 1) * limit,
        tag
      }),
      getTags()
    ])

    const { articles, articlesCount } = articleRes.data
    const { tags } = tagRes.data

    return {
      articles, // 文章列表
      articlesCount, // 文章总数
      tags, // 标签列表
      limit, // 每页大小
      page, // 页码
      tab, // 选项卡
      tag // 数据标签
    }
  }```

loadArticles的值由tab决定。*Promise.all*是因为getTags与loadArticles是没有任何关系,不用其中一个等下一个完成才处理,所以用Promise.all同步处理。
还有把tab和tag放进watchQuery。
剩下要做的是把数据挂载到页面:
      <div class="feed-toggle">
        <ul class="nav nav-pills outline-active">
          <li v-if="user" class="nav-item">
            <nuxt-link
              class="nav-link"
              :class="{
                active: tab === 'your_feed'
              }"
              exact
              :to="{
                name: 'home',
                query: {
                  tab: 'your_feed'
                }
              }"
            >Your Feed</nuxt-link>
          </li>
          <li class="nav-item">
            <nuxt-link
              class="nav-link"
              :class="{
                active: tab === 'global_feed'
              }"
              exact
              :to="{
                name: 'home'
              }"
            >Global Feed</nuxt-link>
          </li>
          <li v-if="tag" class="nav-item">
            <nuxt-link
              class="nav-link"
              :class="{
                active: tab === 'tag'
              }"
              exact
              :to="{
                name: 'home',
                query: {
                  tab: 'tag',
                  tag: tag
                }
              }"
            ># {{ tag }}</nuxt-link>
          </li>
        </ul>
      </div>

      <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 }}</span>
          </div>
        </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>

    <div class="col-md-3">
      <div class="sidebar">
        <p>Popular Tags</p>

        <div class="tag-list">
          <nuxt-link
            :to="{
              name: 'home',
              query: {
                tab: 'tag',
                tag: item
              }
            }"
            class="tag-pill tag-default"
            v-for="item in tags"
            :key="item"
          >{{ item }}</nuxt-link>
        </div>
      </div>
    </div>

  </div>
</div>
现在只是把数据放上页面,链接的页面还没有实现,之后再来看。

### 点赞

基本上Home页面已经完成,剩下主要功能是点赞,当用户点一篇文章的like,就是点赞,再按一次是取消,如何实现?article有一个属性,叫favorite,用来判断用户是否喜欢,可以用它判断点赞还是取消点赞。需要注意的是,当按点赞按钮,需要禁用它,直到事件完成,不然用户多次点赞,会因来不及改变favorite,使多次增加点赞数。因此我们得要在Asyncdata中,为每一个article添加禁用属性:
`articles.forEach(article => article.favoriteDisabled = false)`
之后在api/article.js添加封装点赞相关的api:
```javscript
// 添加点赞
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`
  })
}

slug是每个article的id。现在实现onFavorite:

methods: {
  async onFavorite (article) {
     article.favoriteDisabled = true
     if (article.favorited) {
       await deleteFavorite(article.slug)
       article.favorited = false
       article.favoritesCount += -1
} else {
  await addFavorite(article.slug)
  article.favorite = true
  article.favoritesCount += 1
}
article.favoriteDisabled = false
}
}

最后把点赞挂载到页面:

              <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>

筛选器

页面上日期,如果我们希望它能按指定格式显示,可以像Vue一样,用筛选器处理,不过我们要把它放在插件里,运行vue应用前执行。 先安装dayjs:

npm install dayjs

新建plugins/dayjs.js

import Vue from 'vue'
import dayjs from 'dayjs'

// {{ 表达式 | 过滤器 }}
Vue.filter('date', (value, format = 'YYYY-MM-DD HH:mm:ss') => {
  return dayjs(value).format(format)
})

在Home页面加上筛选器: <span class="date">{{ article.createdAt | date('MMM DD, YYYY') }}</span>

在nuxt.config.js注册:

  plugins: [
    '~/plugins/request.js',
    '~/plugins/dayjs.js'
  ]

authenticated

现在回到布局,把剩下的细节完成。当用户登入时,之后的Sign in/up不见,取而代之是New Post等链接。而这些链接需要用户登入后才能访问,所以我们得要写个中间件。新建middleware/authenticated.js

export default function ({ store, redirect }) {
  // If the user is not authenticated
  if (!store.state.user) {
    return redirect('/login')
  }
}

如果没有登入,复位向至login。 把中间件加载至editor, profile和setting。

文章详情

文章详情是用来展示文章,页面如下:

(截图)

把文章基本信息和评论拆成组件,放在components,分别叫 article-meta和article-comment。

article-meta

<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 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-sm btn-outline-secondary"
      :class="{
        active: article.author.following
      }"
    >
      <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"
      :class="{
        active: article.favorited
      }"
    >
      <i class="ion-heart"></i>
      &nbsp;
      Favorite Post <span class="counter">(29)</span>
    </button>
  </div>
</template>

<script>
export default {
  name: 'ArticleMeta',
  props: {
    article: {
      type: Object,
      required: true
    }
  }
}
</script>

<style>

</style>

article-comments

<template>
  <div>
    <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="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img" />
        <button class="btn btn-sm btn-primary">
        Post Comment
        </button>
      </div>
    </form>

    <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>
  </div>
</template>

<script>
import { getComments } from '@/api/article'

export default {
  name: 'ArticleComments',
  props: {
    article: {
      type: Object,
      required: true
    }
  },
  data () {
    return {
      comments: [] // 文章列表
    }
  },
  async mounted () {
    const { data } = await getComments(this.article.slug)
    this.comments = data.comments
  }
}
</script>

<style>

</style>

为什么不用Asyncdata?因为评论的api是在客户端调用的,所以可以像平时vue的写法。

在api/article添加getComment:

export const getComments = slug => {
  return request({
    method: 'GET',
    url: `/api/articles/${slug}/comments`
  })
}

最后看一下article页面:

article

<template>
  <div class="article-page">

    <div class="banner">
      <div class="container">

        <h1>{{ article.title }}</h1>

        <article-meta :article="article" />

      </div>
    </div>

    <div class="container page">

      <div class="row article-content">
        <div class="col-md-12" v-html="article.body"></div>
      </div>

      <hr />

      <div class="article-actions">
        <article-meta :article="article" />
      </div>

      <div class="row">

        <div class="col-xs-12 col-md-8 offset-md-2">

          <article-comments :article="article" />

        </div>

      </div>

    </div>

  </div>
</template>

<script>
import { getArticle } from '@/api/article'
import MarkdownIt from 'markdown-it'
import ArticleMeta from './components/article-meta'
import ArticleComments from './components/article-comments'

export default {
  name: 'ArticleIndex',
  async asyncData ({ params }) {
    const { data } = await getArticle(params.slug)
    const { article } = data
    const md = new MarkdownIt()
    article.body = md.render(article.body)
    return {
      article
    }
  },
  components: {
    ArticleMeta,
    ArticleComments
  },
  head () {
    return {
      title: `${this.article.title} - RealWorld`,
      meta: [
        { hid: 'description', name: 'description', content: this.article.description }
      ]
    }
  }
}
</script>
<style>

</style>

这里没什么好说的。安装markdown-it: npm install markdown-it,它会把markdown转换成html。文章内容 article.body转换成html后,挂载到页面: <div class="col-md-12" v-html="article.body"></div>。用v-html是因为要把html渲染。接下来看一下head。Head是用来修改页面的head,这样我们可以因不同文章标题,动态修改head。Head写成函数的好处是可以访问data和computed属性,hid是用来说明要去取代的属性,不然的话,可能子组件设定的head属性会与本来的重复。

到这里,这个项目基本上完成了。