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(类似国内的简书)的应用,可以阅览和发布文章。
项目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如下:
每个文件夹里有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> 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/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 & 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可以少写些。
登录注册
实现基本登录功能
现在正式开始写功能。首先把注册登录完成,因为之后的内容不少是依赖它的。 先看一下演示的页面,看一下大概需要做什么。
两个页面其实差不多,就是注册比登录多点内容罢了,所以我们需要一个判断,知道到底现在的页面是登录还是注册,决定一些内容是否显示。 现在的问题是怎样获得判断,答案很简单,当前路径就可得知。使用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
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>
Follow Eric Simons <span class="counter">(10)</span>
</button>
<button
class="btn btn-sm btn-outline-primary"
:class="{
active: article.favorited
}"
>
<i class="ion-heart"></i>
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>
<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页面:
<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属性会与本来的重复。
到这里,这个项目基本上完成了。