Nuxt.js 综合案例
介绍
- GitHub仓库:https://github.com/gothinkster/realworld
- 在线示例:https://demo.realworld.io/#/
- 接口文档:https://github.com/gothinkster/realworld/tree/master/api
- 页面模板:https://github.com/gothinkster/realworld-starter-kit/blob/master/FRONTEND_INSTRUCTIONS.md
创建项目
mkdir realworld-nuxtjs
cd realworld-nuxtjs
npm init -y
npm install nuxt
在 package.json 中添加启动脚本:
"scripts": {
"dev": "nuxt"
}
创建 pages/index.vue :
<template>
<div>
<h1>Home Page</h1>
</div>
</template>
<script>
export default {
name: 'HomePage'
}
</script>
<style>
</style>
导入样式资源
当前目录结构:

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">
<link
href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
rel="stylesheet" type="text/css">
<link rel="stylesheet" href="/index.css">
</head>
<body {{ BODY_ATTRS }}>
{{ APP }}
</body>
</html>
配置布局组件
pages/layout/index.vue
<template>
<div>
<nav class="navbar navbar-light">
<div class="container">
<a class="navbar-brand" href="index.html">conduit</a>
<ul class="nav navbar-nav pull-xs-right">
<li class="nav-item">
<a class="nav-link active" href="">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="">
<i class="ion-compose"></i> New Post
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="">
<i class="ion-gear-a"></i> Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="">Sign up</a>
</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>
<script>
export default {
name: "LayoutIndex"
}
</script>
<style scoped>
</style>
添加nuxt.config.js 配置文件,配置自定义路由表
module.exports = {
router: {
extendRoutes(routes, resolve) {
routes.splice(0)
routes.push(...[
{
path: '/',
component: resolve(__dirname, 'pages/layout'),
children: [
{
path: '',
name: 'home',
component: resolve(__dirname, 'pages/home')
}
]
},
])
}
}
}
添加pages/home/index.vue ,并配置默认子路由
<template>
<div class="home-page">
<div class="banner">
<div class="container">
<h1 class="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div class="container page">
<div class="row">
<div class="col-md-9">
<div class="feed-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item">
<a class="nav-link disabled" href="">Your Feed</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="">Global Feed</a>
</li>
</ul>
</div>
<div class="article-preview">
<div class="article-meta">
<a href="profile.html"><img src="http://i.imgur.com/Qr71crq.jpg"/></a>
<div class="info">
<a href="" class="author">Eric Simons</a>
<span class="date">January 20th</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i> 29
</button>
</div>
<a href="" class="preview-link">
<h1>How to build webapps that scale</h1>
<p>This is the description for the post.</p>
<span>Read more...</span>
</a>
</div>
<div class="article-preview">
<div class="article-meta">
<a href="profile.html"><img src="http://i.imgur.com/N4VcUeJ.jpg"/></a>
<div class="info">
<a href="" class="author">Albert Pai</a>
<span class="date">January 20th</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i> 32
</button>
</div>
<a href="" class="preview-link">
<h1>The song you won't ever stop singing. No matter how hard you try.</h1>
<p>This is the description for the post.</p>
<span>Read more...</span>
</a>
</div>
</div>
<div class="col-md-3">
<div class="sidebar">
<p>Popular Tags</p>
<div class="tag-list">
<a href="" class="tag-pill tag-default">programming</a>
<a href="" class="tag-pill tag-default">javascript</a>
<a href="" class="tag-pill tag-default">emberjs</a>
<a href="" class="tag-pill tag-default">angularjs</a>
<a href="" class="tag-pill tag-default">react</a>
<a href="" class="tag-pill tag-default">mean</a>
<a href="" class="tag-pill tag-default">node</a>
<a href="" class="tag-pill tag-default">rails</a>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "HomeIndex"
}
</script>
<style scoped>
</style>
当前效果:

访问localhost:3000时,首先加载pages/index.vue组件,在nuxt-child中加载子路由,子路由path为空字符串‘’,因此访问localhost:3000时会同时加载pages/index.vue 、pages/layout/index.vue 、pages/home/index.vue 三个组件
登录注册
在模板网址中找到登录模板,由于当前项目登录/注册业务不复杂,所以使用同一个组件模板,利用this.$route.name 将其处理为动态组件。其中需要处理文字显示、按钮显示、路有指向,代码如下:
pages/login/index.vue
<template>
<div class="auth-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">{{ isLogin ? 'Sign in' : 'Sign up' }}</h1>
<p class="text-xs-center">
<nuxt-link v-if="isLogin" to="/register">Need an account?</nuxt-link>
<nuxt-link v-else to="/login">Have an account?</nuxt-link>
</p>
<ul class="error-messages">
<li>That email is already taken</li>
</ul>
<form>
<fieldset v-if="!isLogin" class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Your Name">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Email">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="password" placeholder="Password">
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right">
{{ isLogin ? 'Sign in' : 'Sign up' }}
</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "LoginIndex",
computed: {
isLogin() {
return this.$route.name === 'login'
}
}
}
</script>
<style scoped>
</style>
导入剩余页面
路径 | 页面 |
---|
/ | 首页 | /login | 登录 | /register | 注册 | /settings | 用户设置 | /editor | 发布文章 | /editor/:slug | 编辑文章 | /profile/:username | 文章详情 | /profile/:username/favorites | 用户页面/喜欢的文章 |
用户页面
pages/profile/index.vue
<template>
<div class="profile-page">
<div class="user-info">
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-10 offset-md-1">
<img src="http://i.imgur.com/Qr71crq.jpg" class="user-img"/>
<h4>Eric Simons</h4>
<p>
Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from the Hunger Games
</p>
<button class="btn btn-sm btn-outline-secondary action-btn">
<i class="ion-plus-round"></i>
Follow Eric Simons
</button>
</div>
</div>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-10 offset-md-1">
<div class="articles-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item">
<a class="nav-link active" href="">My Articles</a>
</li>
<li class="nav-item">
<a class="nav-link" href="">Favorited Articles</a>
</li>
</ul>
</div>
<div class="article-preview">
<div class="article-meta">
<a href=""><img src="http://i.imgur.com/Qr71crq.jpg"/></a>
<div class="info">
<a href="" class="author">Eric Simons</a>
<span class="date">January 20th</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i> 29
</button>
</div>
<a href="" class="preview-link">
<h1>How to build webapps that scale</h1>
<p>This is the description for the post.</p>
<span>Read more...</span>
</a>
</div>
<div class="article-preview">
<div class="article-meta">
<a href=""><img src="http://i.imgur.com/N4VcUeJ.jpg"/></a>
<div class="info">
<a href="" class="author">Albert Pai</a>
<span class="date">January 20th</span>
</div>
<button class="btn btn-outline-primary btn-sm pull-xs-right">
<i class="ion-heart"></i> 32
</button>
</div>
<a href="" class="preview-link">
<h1>The song you won't ever stop singing. No matter how hard you try.</h1>
<p>This is the description for the post.</p>
<span>Read more...</span>
<ul class="tag-list">
<li class="tag-default tag-pill tag-outline">Music</li>
<li class="tag-default tag-pill tag-outline">Song</li>
</ul>
</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "UserProfile"
}
</script>
<style scoped>
</style>
用户设置
pages/settings
<template>
<div class="settings-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">Your Settings</h1>
<form>
<fieldset>
<fieldset class="form-group">
<input class="form-control" type="text" placeholder="URL of profile picture">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Your Name">
</fieldset>
<fieldset class="form-group">
<textarea class="form-control form-control-lg" rows="8" placeholder="Short bio about you"></textarea>
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Email">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="password" placeholder="Password">
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right">
Update Settings
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "SettingsIndex"
}
</script>
<style scoped>
</style>
创建文章
editor/inde.vue
<template>
<div class="editor-page">
<div class="container page">
<div class="row">
<div class="col-md-10 offset-md-1 col-xs-12">
<form>
<fieldset>
<fieldset class="form-group">
<input type="text" class="form-control form-control-lg" placeholder="Article Title">
</fieldset>
<fieldset class="form-group">
<input type="text" class="form-control" placeholder="What's this article about?">
</fieldset>
<fieldset class="form-group">
<textarea class="form-control" rows="8" placeholder="Write your article (in markdown)"></textarea>
</fieldset>
<fieldset class="form-group">
<input type="text" class="form-control" placeholder="Enter tags"><div class="tag-list"></div>
</fieldset>
<button class="btn btn-lg pull-xs-right btn-primary" type="button">
Publish Article
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "EditorIndex"
}
</script>
<style scoped>
</style>
文章详情
pages/editor
<template>
<div class="editor-page">
<div class="container page">
<div class="row">
<div class="col-md-10 offset-md-1 col-xs-12">
<form>
<fieldset>
<fieldset class="form-group">
<input type="text" class="form-control form-control-lg" placeholder="Article Title">
</fieldset>
<fieldset class="form-group">
<input type="text" class="form-control" placeholder="What's this article about?">
</fieldset>
<fieldset class="form-group">
<textarea class="form-control" rows="8" placeholder="Write your article (in markdown)"></textarea>
</fieldset>
<fieldset class="form-group">
<input type="text" class="form-control" placeholder="Enter tags"><div class="tag-list"></div>
</fieldset>
<button class="btn btn-lg pull-xs-right btn-primary" type="button">
Publish Article
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "EditorIndex"
}
</script>
<style scoped>
</style>
处理顶部导航链接
将模板中的a 链接全部替换为nuxt-link
pages/layout/index.vue
<template>
<div>
<nav class="navbar navbar-light">
<div class="container">
<nuxt-link class="navbar-brand" to="/">Home</nuxt-link>
<ul class="nav navbar-nav pull-xs-right">
<li class="nav-item">
<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="/register">Sign up</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="/profile/123">
<img class="user-pic"
src="https://pic1.zhimg.com/80/v2-3358e380b520aaa16d4c16bbacb7dab9_720w.jpg?source=1940ef5c">
5coder
</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>
<script>
export default {
name: "LayoutIndex"
}
</script>
<style scoped>
</style>

处理导航链接高亮
-
修改nuxt.js提供的路有导航高亮,默认值为nuxt-link-active ,修改为模板中定义的active (官方文档) vue.config.js module.exports = {
router: {
extendRoutes(routes, resolve) {
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/')
},
]
}
])
},
linkActiveClass: 'active'
}
}
-
修改精确匹配,当Home 中的路由为/时,默认会适用active ,需要将其修改为精确匹配,这样在子组件激活时,Home 不会高亮激活。(官方文档) pages/layout/index.vue <li class="nav-item">
<nuxt-link class="nav-link" to="/" exact>
Home
</nuxt-link>
</li>
当前目录结构: 
封装请求模块
登录注册
实现基本登录功能
-
登录接口 
pages/login/index.vue
<template>
<div class="auth-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">{{ isLogin ? 'Sign in' : 'Sign up' }}</h1>
<p class="text-xs-center">
<nuxt-link v-if="isLogin" to="/register">Need an account?</nuxt-link>
<nuxt-link v-else to="/login">Have an account?</nuxt-link>
</p>
<ul class="error-messages">
<li>That email is already taken</li>
</ul>
<form @submit.prevent="onSubmit">
<fieldset v-if="!isLogin" class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Your Name">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="text" placeholder="Email" v-model="user.email">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="password" placeholder="Password"
v-model="user.password">
</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 request from '@/utils/request'
export default {
name: "LoginIndex",
data() {
return {
user: {
email: '',
password: ''
}
}
},
computed: {
isLogin() {
return this.$route.name === 'login'
}
},
methods: {
async onSubmit() {
const {data} = await request({
method: 'POST',
url: '/api/users/login',
data: {
user: this.user
}
})
console.log(data)
this.$router.push('/')
}
}
}
</script>
<style scoped>
</style>
封装请求方法
为了维护方便,将请求单独再封装
创建目录及文件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
})
}
修改login.vue 中使用的登录请求
export default {
name: "LoginIndex",
data() {
return {
user: {
email: '',
password: ''
}
}
},
computed: {
isLogin() {
return this.$route.name === 'login'
}
},
methods: {
async onSubmit() {
const {data} = await login({
user: this.user
})
console.log(data)
this.$router.push('/')
}
}
}
表单验证
使用HTML原始的验证,分别在input 中添加required 和修改type="emial"
<fieldset class="form-group">
<input class="form-control form-control-lg" type="email" required placeholder="Email" v-model="user.email">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="password" required placeholder="Password"
v-model="user.password">
</fieldset>
错误处理
-
此处处理用户登录错误时的显示内容 -
未处理时,登录请求错误会出现如下页面:  -
错误信息如下:
-
使用try {} catch {err} 捕获异常 try {
const {data} = await login({
user: this.user
})
console.log(data)
this.$router.push('/')
} catch (err) {
console.dir(err)
this.errors = err.response.data.errors
}
-
在data中定义errors数据:errors:{} 初始化为空对象 -
在html模板中遍历出该错误信息 <ul class="error-messages">
<template v-for="(messages, field) of errors">
<li v-for="(message,index) in messages" :key="index">{{ field }} {{ message }}</li>
</template>
</ul>
-
处理完错误后页面如下: 
用户注册
由于注册需要提供用户名,所以在data中的user中添加username,并使用v-model绑定到input框中。
data() {
return {
user: {
username: '',
email: '',
password: ''
},
errors: {}
}
}
注册和登录的逻辑相似,因此只需要在调用login和register时做判断即可,同时发现注册时,用户提供的密码在后端做了验证,必须大于等于8为,因此在前端也进行验证。在password字段中添加minlength='8' :
<input class="form-control form-control-lg" type="password" minlength="8" required placeholder="Password" v-model="user.password">
async onSubmit() {
try {
const {data} = this.isLogin ? await login({
user: this.user
}) : await register({
user: this.user
})
console.log(data)
this.$router.push('/')
} catch (err) {
console.dir(err)
this.errors = err.response.data.errors
}
}
存储用户登录状态
(1)初始化容器数据
export const state = () => {
return {
user: null
}
}
export const mutations = {
setUser(state, data) {
state.user = data
}
}
export const actions = {}
(2)登陆成功,将用户信息存入容器
this.$store.commit('setUser', data.user)
(3)将登陆状态持久化道Cookie中
安装js-cookie
yarn add js-cookie
使用js-cookie 将data.user 数据存储到cookie 中(31行):Cookie.set('user', data.user)
export default {
name: "LoginIndex",
data() {
return {
user: {
username: '',
email: '',
password: ''
},
errors: {}
}
},
computed: {
isLogin() {
return this.$route.name === 'login'
}
},
methods: {
async onSubmit() {
try {
const {data} = this.isLogin ? await login({
user: this.user
}) : await register({
user: this.user
})
this.$store.commit('setUser', data.user)
Cookie.set('user', data.user)
this.$router.push('/')
} catch (err) {
console.dir(err)
this.errors = err.response.data.errors
}
}
}
}
(4)从Cookie中获取并初始化用户登录状态
安装cookieparser
yarn add cookieparser
使用nuxtServerInit 在服务端渲染期间,从cookie 中获取user 数据,保存到state 中的user 对象中
import cookieparser from 'cookieparser'
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) {
const parsed = cookieparser.parse(req.headers.cookie)
try{
user = JSON.parse(parsed.user)
} catch (e) {
}
}
commit('setUser', user)
}
}
- 整体逻辑为:使用
mapState 将store 中的state.user 映射到layout.vue 中,在模板中判断是否存在user ,当存在时,展示Home、New Post、Settings、用户头像信息,当不存在时,只展示Home、Sign In、Sign Up
<template v-if="user">
<li class="nav-item">
<nuxt-link class="nav-link" to="/editor">
<i class="ion-compose"></i> New Post
</nuxt-link>
</li>
<li class="nav-item">
<nuxt-link class="nav-link" to="/settings">
<i class="ion-gear-a"></i> Settings
</nuxt-link>
</li>
<li class="nav-item">
<nuxt-link class="nav-link" to="/profile/123">
<img class="user-pic"
:src="user.image">
{{ user.username }}
</nuxt-link>
</li>
</template>
<template v-else>
<li class="nav-item">
<nuxt-link class="nav-link" to="/login">Sign in</nuxt-link>
</li>
<li class="nav-item">
<nuxt-link class="nav-link" to="/register">Sign up</nuxt-link>
</li>
</template>
import {mapState} from 'vuex'
export default {
name: "LayoutIndex",
computed: {
...mapState(['user'])
}
}
处理页面访问权限
当前处理逻辑只是将登陆与否过程中是否渲染了Editor等路由,然而当用户直接在url访问localhost:3000/editor等路由时,依然可以访问页面,这时就需要使用拦截器来拦截这部分请求。
路由中间件
中间件允许你定义一个自定义函数运行在一个页面或一组页面渲染之前。
您可以通过在 middleware/ 目录中创建一个文件来创建命名中间件,文件名将是中间件名称。

middleware/authenticated.js
export default function ({store, redirect}) {
if (!store.state.user) {
return redirect('/login')
}
}
editor.vue
export default {
middleware: 'authenticated',
name: "EditorIndex"
}
同理,在settings、profile等页面也设置同样的中间件。
- 在登录后,不允许用户重复登录注册,同样设置中间件
not-authenticated.js ,禁止用户在登录后再次访问登录、注册页面。
middleware/not-authenticated.js
export default function ({store, redirect}) {
if (store.state.user) {
return redirect('/')
}
}
- 在登陆注册页面加入
not-authenticated 中间件。
首页模块
展示公共文章列表
封装请求方法:
import request from "@/utils/request";
export const getArticles = params => {
return request({
method: 'GET',
url: '/api/articles',
params
})
}
home/index.vue获取数据,由于需要进行SEO优化,这里使用asyncData方法获取数据:
import {getArticles} from "@/api/article";
export default {
name: "HomeIndex",
async asyncData() {
const {data} = await getArticles()
return {
articles: data.articles,
articlesCount: data.articlesCount
}
}
}
模板绑定:
<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.createAt }}</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>
<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>
分页处理
处理分页参数
首先定义page和limit,然后计算要请求的文章数量以及从哪一条文章开始取
export default {
name: "HomeIndex",
async asyncData() {
let page = 1
const limit = 20
const {data} = await getArticles({
limit,
offset: (page - 1) * limit
})
return {
articles: data.articles,
articlesCount: data.articlesCount
}
}
}
页码处理
分页模板:
<nav>
<ul class="pagination">
<li class="page-item" v-for="item in totalPage" :key="item"
:class="{
active: item === page
}"
>
<nuxt-link class="page-link" :to="{
name: 'home',
query: {
page: item
}
}">{{ item }}
</nuxt-link>
</li>
</ul>
</nav>
-
使用计算属性计算总页码 computed: {
totalPage() {
return Math.ceil(this.articlesCount / this.limit)
}
}
-
遍历生成页码列表、设置导航链接 <nav>
<ul class="pagination">
<li class="page-item" v-for="item in totalPage" :key="item"
:class="{
active: item === page
}"
>
<nuxt-link class="page-link" :to="{
name: 'home',
query: {
page: item
}
}">{{ item }}
</nuxt-link>
</li>
</ul>
</nav>
-
相应query参数变化


watchQuery: ['page'],
获取标签列表(Popular Tags)
import request from "@/utils/request";
export const getTags = () => {
return request({
method: 'GET',
url: '/api/tags',
})
}
export default {
name: "HomeIndex",
watchQuery: ['page'],
async asyncData({query}) {
const page = Number.parseInt(query.page || 1)
const limit = 20
const {data} = await getArticles({
limit,
offset: (page - 1) * limit
})
const {data: tagData} = await getTags()
return {
articles: data.articles,
articlesCount: data.articlesCount,
limit,
page,
tags: tagData.tags
}
},
computed: {
totalPage() {
return Math.ceil(this.articlesCount / this.limit)
}
}
}
<div class="tag-list">
<a href="" class="tag-pill tag-default" v-for="item in tags" :key="item" v-if="item">{{ item }}</a>
</div>
优化数据请求
前面请求的文章列表数据和标签列表数据在业务上并没有互相依赖的关系,因此可以将其从串行执行请求数据优化为并行执行请求数据,通过并行可以提高请求加载的速度。
async asyncData({query}) {
const page = Number.parseInt(query.page || 1)
const limit = 20
const [articlesResponse, tagResponse] = await Promise.all([
getArticles({
limit,
offset: (page - 1) * limit
}),
getTags()
])
const {articles, articlesCount} = articlesResponse.data
const {tags} = tagResponse.data
return {
articles,
articlesCount,
limit,
page,
tags
}
},
标签列表链接和数据
-
处理标签列表链接,类似于分页页码的处理;在标签上绑定查询参数?tag='something' <nuxt-link :to="{
name: 'home',
query: {
tag: item
}
}" class="tag-pill tag-default" v-for="item in tags" :key="item" v-if="item">{{ item }}</nuxt-link>
-
搭配?page=3?tag=‘something’ <nuxt-link class="page-link" :to="{
name: 'home',
query: {
page: item,
tag: $route.query.tag
}
}">{{ item }}
</nuxt-link>
标签高亮及链接(Tab)
-
业务逻辑:当用户登录后,显示Your Feed, 否则不显示Your Feed,只显示Global Feed。
-
业务逻辑:点击右侧Popular Tag时,动态显示#{{ tag }} 的tab导航栏
-
判断当前url中是否有tag,如果有则动态的渲染tab导航栏 <li v-if="tag">
...
</li>
-
在asyncData中需要返回tag属性 async asyncData({query}) {
const page = Number.parseInt(query.page || 1)
const limit = 20
const {tag} = query
const [articlesResponse, tagResponse] = await Promise.all([
getArticles({
limit,
offset: (page - 1) * limit,
tag
}),
getTags()
])
const {articles, articlesCount} = articlesResponse.data
const {tags} = tagResponse.data
return {
articles,
articlesCount,
limit,
page,
tags,
tag,
tab: query.tab || 'global_feed'
}
},
-
Popular Tag query 查询参数中添加tag属性 <div class="tag-list">
<nuxt-link :to="{
name: 'home',
query: {
tag: item, // 添加tag查询参数
tab: 'tag'
}
}" class="tag-pill tag-default" v-for="item in tags" :key="item" v-if="item">{{ item }}
</nuxt-link>
</div>
-
动态绑定Your Feed、Global Feed、#tag的active样式
-
给tab栏添加查询参数tab: 'your_feed' 、tab: 'global_feed' 、tab: tag -
精确匹配exact ,watchQueryt 中添加tab <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'
}
}" href="">Your Feed
</nuxt-link>
</li>
<li class="nav-item">
<nuxt-link
class="nav-link"
:class="{
active: tab === 'global_feed'
}"
exact
:to="{
name: 'home',
query: {
tab: 'global_feed'
}
}">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>
watchQuery: ['page', 'tag', 'tab'],
- 在热门标签Popular Tags中,选择页码,发现Tab并没有被激活,所以需要在页码中家也如查询参数query,
tab:tab
展示关注文章列表
将Your Feed中的数据渲染到该Tab中
- 首先在
asyncData 中解构出store 对象,从store 对象中获取user ,用于传递到接口服务器,在接口中先手动写入用户token
export const getFeedArticles = params => {
return request({
method: 'GET',
url: '/api/articles/feed',
headers: {
Authorization: `Token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MTkwMzIyLCJ1c2VybmFtZSI6IjVjb2RlciIsImV4cCI6MTYzMjUzNTA0NH0.i0tpsAKIB-462Vg_dOyCABZNcFwNMqRtvQ-jzoDTY6k`
},
params
})
}
- 根据用户登录状态、tab是否为your_feed两个条件决定加载全部文章或者关注的文章
import {getArticles, getFeedArticles} from "@/api/article";
...
async asyncData({query, store}) {
const page = Number.parseInt(query.page || 1)
const limit = 20
const {tag} = query
const tab = query.tab || 'global_feed'
const loadArticles = store.state.user && tab === 'your_feed'
? getFeedArticles
: getArticles
const [articlesResponse, tagResponse] = await Promise.all([
loadArticles({
limit,
offset: (page - 1) * limit,
tag,
}),
getTags()
])
const {articles, articlesCount} = articlesResponse.data
const {tags} = tagResponse.data
return {
articles,
articlesCount,
limit,
page,
tags,
tag,
tab: query.tab || 'global_feed'
}
},
统一添加数据token
在上一步中,我们手动设置的用户的token值,但是在实际的业务中需要动态的添加token值。也就是说需要在store中获取到state.user.token 。但是在request.js中我们无法获取到store上下文对象。Nuxt.js为我们提供了一个插件机制,插件机制可以让我们在真正请求之前拦截到请求,并在请求中可以获取上下文对象(query、params、req、res、app、store…)
插件机制使用如下:
在项目根目录创建目录plugins 以及文件request.js ,导出默认成员,代码如下:
plugins/request.js
import axios from 'axios'
export const request = axios.create({
baseURL: 'https://conduit.productionready.io'
})
export default ({store}) => {
console.log(123)
request.interceptors.request.use(function (config) {
const {user} = store.state
if (user && user.token) {
config.headers.Authorization = `Token ${user.token}`
}
return config
}, function (error) {
return Promise.reject(error)
})
}
此外,当需要使用插件时,还需要在nuxt.config.js中进行注册插件,代码如下:
module.exports = {
router: {
...
},
plugins: [
'~/plugins/request.js'
]
}
export default 导出默认成员时,可以使用对象解构,只解构出我们需要的store 对象,然后在判断user以及user.token,当条件成立后,在config中配置headers,按照API文档就进行设置。
const {user} = store.state
if (user && user.token) {
config.headers.Authorization = `Token ${user.token}`
}
此时,我们就不需要再使用utils/request.js 中的方法了,需要在api 中的所有文件(articles.js 、tag.js 、user.js )中替换导入
import { request } from "@/plugins/request";
此时,在我们home/index.vue 中的所有请求都会使用plugins/request.js ,所有请求也会经过拦截器,进而统一设置用户token 。
日期格式处理
在线demo中,日期的展现形式是:简写月份 日期, 年份,所以我们需要将获取到的articles中的createAt字段改变为此格式。这里推荐一个类似于moment.js的插件dayjs.js(Github)、官方文档
Day.js 是一个轻量的处理时间和日期的 JavaScript 库,和 Moment.js 的 API 设计保持完全一样. 如果您曾经用过 Moment.js, 那么您已经知道如何使用 Day.js
使用Vue中的全局过滤器,将日期格式化,这样可以更大限度的重用代码。同样需要在plugins目录中新建dayjs.js文件(文件名随意),代码如下:
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)
})
在pages/home/index.vue中使用管道附链接,并传入参数:
<span class="date">{{ article.createAt | date('MMM DD, YYYY') }}</span>
文章点赞
接下来处理文章点赞功能,业务逻辑:
- 未点赞状态下,点击为点赞,数量加1
- 点赞状态下,点击为取消点赞,数量减1
整体流程:
api/articles.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`
})
}
- 在
pages/home/index.vue 中的点赞按钮中绑定事件
<button class="btn btn-outline-primary btn-sm pull-xs-right"
:class="{
active: article.favorited
}" @click="onFavorite(article)"
>...</button>
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
}
}
}
pages/home/index.vue
<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>
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.favorited = true
article.favoritesCount += 1
}
article.favoriteDisabled = false
}
}
文章详情
业务介绍:
- 展示文章详情内容
- 文章标题
- 作者信息
- 点赞
- 正文
- 文章评论功能
- 发布评论
-
文章详情数据接口封装 api/articles.js
export const getArticle = slug => {
return request({
method: 'GET',
url: `/api/articles/${slug}`,
})
}
-
获取数据 pages/article/index.vue import {getArticle} from "@/api/article";
export default {
name: "ArticleIndex",
async asyncData({params}) {
const {data} = await getArticle(params.slug)
return {
article: data.article
}
}
}
-
动态渲染文章数据 这里先只渲染文章title 和文章body ,并且文章body 不进行markdown转换 <h1>{{ article.title }}</h1>
...
<div class="row article-content">
<div class="col-md-12">
{{ article.body }}
</div>
</div>
把Markdown转为HTMl

使用第三方插件markdown-it,该插件可以将markdown 语法转换为HTML 。在获取到article.body 后,使用该插件方法将其转为HTML 。
import {getArticle} from "@/api/article";
import MarkdownIt from 'markdown-it'
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
}
}
}
在模板中,使用v-html 指令填充article.body
<div class="col-md-12" v-html="article.body"></div>
展示文章作者相关信息


文章详情页面中有两部分功能相似:
文章作者信息、关注按钮、点赞按钮
pages/article/components/article-meta.vue
<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>
Follow Eric Simons <span class="counter">({{ article.followCount }})</span>
</button>
<button
class="btn btn-sm btn-outline-primary"
:class="{
active: article.favorited
}"
@click="onFavorite(article)"
>
<i class="ion-heart"></i>
Favorite Post <span class="counter">({{ article.favoritesCount }})</span>
</button>
</div>
</template>
<script>
import {addFavorite, deleteFavorite} from "@/api/article";
export default {
name: "ArticleMeta",
props: {
article: {
type: Object,
required: true
}
},
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.favorited = true
article.favoritesCount += 1
}
article.favoriteDisabled = false
}
}
}
</script>
<style scoped>
</style>
- 在
article/index.vue 中的两个地方使用组件,并且传递article 到article-meta.vue 中,article-meta.vue 使用props 接受article 数据,如上方代码
<article-meta :article="article"/>
- 动态遍历渲染
article 以及作者相关信息,如封装组件中的代码 - 动态绑定点赞按钮事件,与之前的
home/index.vue 中的用法相同
<button
class="btn btn-sm btn-outline-primary"
:class="{
active: article.favorited
}"
@click="onFavorite(article)"
>
<i class="ion-heart"></i>
Favorite Post <span class="counter">({{ article.favoritesCount }})</span>
</button>
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.favorited = true
article.favoritesCount += 1
}
article.favoriteDisabled = false
}
}
TODO 关注按钮事件,其原理与点赞按钮相同,封装API请求,点击按钮,判断当前状态,更改数据
设置页面meta优化SEO
-
修改页面标题,希望在页面标题中出现文章的标题  
特定页面的Meta标签用法
Nuxt.js 使用了 vue-meta 更新应用的 头部标签(Head) 和 html 属性 。
使用 head 方法设置当前页面的头部标签。
在 head 方法里可通过 this 关键字来获取组件的数据,你可以利用页面组件的数据来设置个性化的 meta 标签。
<template>
<h1>{{ title }}</h1>
</template>
<script>
export default {
data() {
return {
title: 'Hello World!'
}
},
head() {
return {
title: this.title,
meta: [
{
hid: 'description',
name: 'description',
content: 'My custom description'
}
]
}
}
}
</script>
注意:为了避免子组件中的 meta 标签不能正确覆盖父组件中相同的标签而产生重复的现象,建议利用 hid 键为 meta 标签配一个唯一的标识编号。
article/index.vue
import {addFavorite, deleteFavorite} from "@/api/article";
export default {
name: "ArticleMeta",
props: {
article: {
type: Object,
required: true
}
},
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.favorited = true
article.favoritesCount += 1
}
article.favoriteDisabled = false
}
},
head() {
return {
title: `${this.article.title} - RealWorld`,
meta: [
{
hid: 'description',
name: 'description',
content: this.article.description
}
]
}
}
}
显示效果

文章评论
- 首先封装组件
article-comments.vue ,将评论部分抽离出来单独获取数据。 - 使用Vue生命周期函数mounted(此部分不需要SEO优化,因此采用客户端渲染)加载数据
- 使用封装好的组件,传递相关的文章对象
article - 渲染遍历数据
article-comments.vue
<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
:to="{
name: 'profile',
params: {
username: comment.author.username
}
}"
class="comment-author">
<img :src="comment.author.image" class="comment-author-img"/>
</nuxt-link>
<nuxt-link
:to="{
name: 'profile',
params: {
username: comment.author.username
}
}"
class="comment-author">{{ 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)
console.log(data)
this.comments = data.comments
}
}
</script>
<style scoped>
</style>
发布部署
Nuxt.js 提供了两种发布部署应用的方式:服务端渲染应用部署 和 静态应用部署。
部署 Nuxt.js 服务端渲染的应用不能直接使用 nuxt 命令,而应该先进行编译构建,然后再启动 Nuxt
服务,可通过以下两个命令来完成:
nuxt build
nuxt start
打包
Nuxt.js 提供了一系列常用的命令, 用于开发或发布部署。
命令 | 描述 |
---|
nuxt | 启动一个热加载的Web服务器(开发模式)localhost:3000 | nuxt build | 利用Webpack编译应用,压缩js和css资源(发布用) | nuxt start | 以生产模式启动一个Web服务器(需要先执行nuxt build) | nuxt gnerate | 编译应用,并依据路由配置生成对应的HTML文件(用于静态站点的部署) |
如果使用了 Koa/Express 等 Node.js Web 开发框架,并使用了 Nuxt 作为中间件,可以自定义 Web 服务器的启动入口:
命令 | 描述 |
---|
NODE_ENV=development nodemon server/index.js | 启动一个热加载的自定义Web服务器(开发模式) | NODE_ENV=production node server/index.js | 以生产模式启动一个自定义Web服务器(需要先执行nuxt build) |
参数
您可以使用 --help 命令来获取详细用法。常见的命令有:
--config-file 或 -c : 指定 nuxt.config.js 的文件路径。--spa 或 -s : 禁用服务器端渲染,使用SPA 模式--unix-socket 或 -n : 指定UNIX Socket 的路径。
你可以将这些命令添加至 package.json :
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate"
}
这样你可以通过 npm run <command> 来执行相应的命令。如: npm run dev 。
提示: 要将参数传递给npm 命令,您需要一个额外的-- 脚本名称(例如: npm run dev --参数 --spa )
最简单的部署方式
nuxt.config.js
module.exports = {
router: {
extendRoutes(routes, resolve) {
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/')
},
]
}
])
},
linkActiveClass: 'active',
},
plugins: [
'~/plugins/request.js',
'~/plugins/dayjs.js'
],
head: {
meta: [
{charset: 'utf-8'}
]
},
server: {
host: '0.0.0.0',
port: 3000
}
}
使用PM2启动Node服务
使用上述方式启动服务,命令行被占用,当命令行被关闭后,服务就被关闭了,所以使用PM2,PM2专门用来管理node进程的应用。
PM2常用命令

自定化部署介绍
传统的部署方式

现代化部署方式(CI/CD:持续集成、持续部署)

准备自动部署内容



配置GitHub Actions执行脚本
-
在项目根目录创建.github/workflows 目录 -
下载main.yml 到workflows 目录中 main.yml name: Publish And Deploy Demo
on:
push:
tags:
- 'v*'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@master
- name: Build
uses: actions/setup-node@master
- run: npm install
- run: npm run build
- run: tar -zcvf release.tgz .nuxt static nuxt.config.js package.json package-lock.json pm2.config.json
- name: Create Release
id: create_release
uses: actions/create-release@master
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@master
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./release.tgz
asset_name: release.tgz
asset_content_type: application/x-tgz
- name: Deploy
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORD }}
port: ${{ secrets.PORT }}
script: |
cd /home/leo/realworld-nuxtjs
wget https://github.com/5coder-leo/realworld/releases/latest/download/release.tgz -O release.tgz
tar zxvf release.tgz
npm install --production
pm2 reload pm2.config.json
-
修改配置   -
配置PM2配置文件 pm2.config.json {
"apps": [
{
"name": "RealWorld",
"script": "npm",
"args": "start"
}
]
}
-
提交更新 git add .
git commit -m "更新测试"
git push -u origin master
git tag v0.1.0
git push origin v0.1.0
-
查看自动部署状态  -
访问网址:http://106.75.130.241:3000/
|