NuxtJS项目案例–RealWorld(创建Nuxt项目、Git Actions自动发布和PM2部署)
文章内容输出来源:大前端高薪训练营
一、案例项目realworld介绍
1. 案例项目介绍
案例名称:RealWorld
这是一个开源的学习项目,目的就是帮助开发者快速学习新技能。
GitHub仓库:https://github.com/gothinkster/realworld
在线实例:https://demo.realworld.io/
2. 案例相关资源
- 页面模板:https://github.com/gothinkster/realworld-starter-kit/blob/master/FRONTEND_INSTRUCTIONS.md
- 接口文档:https://github.com/gothinkster/realworld/tree/master/api
3. 学习前提
- Vue.js使用经验
- Nuxt.js基础
- Node.js、webpack相关使用经验
4. 学习收获
-
掌握使用Nuxt.js开发同构渲染应用
-
增强Vue.js实践能力
-
掌握同构渲染应用中常见的功能处理
- 用户状态管理
- 页面访问权限处理
- SEO优化
-
掌握同构渲染应用的发布与部署
二、项目初始化
1. 创建项目
mkdir realworld-nuxtjs
yarn init -y
yarn add nuxt
- 配置启动脚本
- 创建pages目录,配置初始页面
2. 导入样式资源
Real world的仓库里提供了样式文件:https://github.com/gothinkster/realworld-starter-kit/blob/master/FRONTEND_INSTRUCTIONS.md
<!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->
<link href="//code.ionicframework.com/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放到我们项目中的app.html模板文件中,这个app.html要新建,默认模板就是Nuxt官网上的导航里的视图中的代码:
<!DOCTYPE html>
<html {
{
HTML_ATTRS }}>
<head {
{
HEAD_ATTRS }}>
{
{ HEAD }}
</head>
<body {
{
BODY_ATTRS }}>
{
{ APP }}
</body>
</html>
把三个link放到head标签里,由于ionicons的CDN地址在国外,打开速度较慢,又包含字体文件,无法直接下载到本地,所以我们去一个国内CDN网站上找到它来使用。
国内CDN网站:https://www.jsdelivr.com/
搜索ionicons,选择我们需要的版本的css的min版本,复制CDN链接,替换到link中
第二个link的CDN国内支持访问,就不用本地化了。
第三个link的CDN也是在国外,需要本地化,然而它不含字体文件,所以可以直接另存到本地,我们另存到了static/index.css
最终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="/index.css">
</head>
<body {
{
BODY_ATTRS }}>
{
{ APP }}
</body>
</html>
3. 布局组件
- 重写路由表
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: '', // 默认子路由
component: resolve(__dirname, 'pages/Home')
}
]
}
])
}
}
}
Layout/index.vue
将模板代码中的导航和页脚代码放到layout/index.vue里面,导航和页脚之间放子路由组件<nuxt-child/>
路由表中的layout组件的默认子组件是HomePage,html部分的代码来自于模板代码中的home部分。代码如下
home/index.vue
然后重启项目,访问项目的根路径,就是Layout组件
4. 导入登录注册页面
将仓库中的登录/注册模板代码拷贝到pages/login/index.vue中,登录和注册共用一个页面,通过计算属性来判断当前是登录还是注册页面,进而进行不同的文字渲染。
但是配置两个不同的路由,在nuxt.config.js中的layout路由的children数组中再添加两个子路由:
{
path: '/login',
name: 'login',
component: resolve(__dirname, 'pages/login')
},
{
path: '/register',
name: 'register',
component: resolve(__dirname, 'pages/login')
}
页面如下:
5. 导入剩余页面
个人简介profile、设置settings、文章新增修改页editor、文章详情页article
6. 处理顶部导航链接
将a
标签替换成nuxt-link
标签,href
属性替换成to
属性
7. 处理导航链接的高亮
给nuxt.config.js的router对象增加linkActiveClass: 'active'
属性
然后删掉导航链接中的Home链接写死的active类,再增加exact属性,表示只有精确匹配到Home的路径时才高亮。
<nuxt-link class="nav-link" exact to="/">Home</nuxt-link>
8. 封装请求状态
安装axios: yarn add axios
封装请求:utils/request.js
/**
* 基于axios封装的请求模块
*/
import axios from 'axios'
const request = axios.create({
baseURL: 'https://conduit.productionready.io'
})
// 请求拦截器
// 响应拦截器
export default request
三、登录注册
1. 封装登录方法
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
})
}
pages/login/index.vue
import {
login } from '@/api/user'
export default {
name: 'LoginPage',
computed: {
isLogin () {
return this.$route.name === 'login'
}
},
data () {
return {
user: {
email: '',
password: ''
}
}
},
methods: {
async onSubmit () {
// 提交表单,请求登录
const {
data } = await login({
user: this.user
})
console.log('data', data)
// TODO 保存用户的登录状态
// 跳转到首页
this.$router.push('/')
}
}
}
2. 错误处理
data中存放errors: {} // 错误信息
改写onSubmit方法(try-catch捕获错误):
async onSubmit () {
try {
// 提交表单,请求登录
const {
data } = await login({
user: this.user
})
console.log('data', data)
// TODO 保存用户的登录状态
// 跳转到首页
this.$router.push('/')
}
catch (err) {
console.log('请求失败', err)
console.dir(err)
this.errors = err.response.data.errors
}
}
遍历错误信息:
<ul class="error-messages">
<template v-for="(messages, field) in errors">
<li v-for="(message, index) in messages" :key="index">
{
{ field }} {
{ messages}}
</li>
</template>
</ul>
3. 注册
data () {
return {
user: {
username: '',
email: '',
password: ''
},
errors: {
} // 错误信息
}
},
methods: {
async onSubmit () {
try {
// 提交表单,请求登录
const {
data } = this.isLogin ? await login({
user: this.user
}): await register({
user: this.user
})
console.log('data', data)
// TODO 保存用户的登录状态
// 跳转到首页
this.$router.push('/')
}
catch (err) {
console.log('请求失败', err)
console.dir(err)
this.errors = err.response.data.errors
}
}
}
4. 解析存储登录状态实现流程
https://zh.nuxtjs.org/examples/auth-external-jwt
5. 将登录状态存储到容器中
store/index.js
// 为了防止在服务端渲染期间,运行的都是同一个实例,防止数据冲突,务必要把state定义成一个函数,返回数据对象
export const state = () => {
return {
user: null
}
}
export const mutations = {
setUser (state, data) {
state.user = data
}
}
export const actions = {
}
登录成功时保存用户状态到容器中,login/index.vue
// 保存用户的登录状态
this.$store.commit('setUser', data.user)
6. 登录状态持久化
将数据存到store中,只是在内存里,页面一刷新就没了。所以我们应该想办法将数据进行持久化。以前的做法是存到本地存储里,而现在在服务端也要渲染,所以不可以存在本地存储,否则服务端获取不到。正确的做法是存在cookie中,cookie可以随着http请求发送到服务端。
所以在login/index.vue页面中容器保存完登录状态后,还要将数据存储到Cookie中
// 仅在客户端加载js-cookie
const Cookie = process.client ? require('js-cookie'): undefined
// ...
// 保存用户的登录状态
this.$store.commit('setUser', data.user)
// 为了防止刷新页面数据丢失,数据需要持久化
Cookie.set('user', data.user)
在Store/index.js的actions
中增加nuxtServerInit
方法,nuxtServerInit
是一个特殊的action
方法,这个方法会在服务端渲染期间自动调用,作用是初始化容器数据,传递数据给客户端使用
export const actions = {
nuxtServerInit ({
commit }, {
req }) {
let user = null
// 如果请求头中有个Cookie
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
}
}
commit('setUser', user)
}
}
7. 处理导航栏链接展示状态
Layout/index.vue页面中,增加计算属性user,通过user判断用户是否是登录状态:
import {
mapState } from 'vuex'
export default {
name: 'LayoutIndex',
computed: {
...mapState(['user'])
}
}
然后将导航栏上的用户名称那个li
调到登录注册的li
前面去,将最后两个li
套在template
里,将另3个li
套在另外一个template
里,如果用户登录了,则显示第一个template
,如果未登录则显示后面两个登录注册所在的template
8. 处理页面访问权限 – 中间件
https://zh.nuxtjs.org/guide/routing#%E4%B8%AD%E9%97%B4%E4%BB%B6
中间件允许您定义一个自定义函数运行在一个页面或一组页面渲染之前。
每一个中间件应放置在 middleware/
目录。文件名的名称将成为中间件名称 (middleware/auth.js
将成为 auth
中间件)。然后给要保护的页面增加middleware
属性,值为中间件的文件名
中间件执行流程顺序:
nuxt.config.js
- 匹配布局
- 匹配页面
定义两个中间件,
middleware/authenticated.js
export default function ({
store, redirect }) {
// 如果用户未登录,则跳转到登录页
if (!store.state.user) {
return redirect('/login')
}
}
middleware/notAuthenticated.js
export default function ({
store, redirect }) {
// 如果用户已登录,则跳转到首页
if (store.state.user) {
return redirect('/')
}
}
然后给settings/index.vue
、profile/index.vue
、editor/index
页面增加属性middleware
值为authenticated
export default {
name: 'Settings',
middleware: 'authenticated'
}
给login/index.vue
页面增加属性middleware
值为notAuthenticated
export default {
name: 'LoginPage',
middleware: 'notAuthenticated',
// ...
}
四、首页
首页展示我的关注的文章和公共文章,所有文章可以选择标签,还可以分页。
1. 展示公共文章列表
Api/article.js
import request from '@/utils/request'
// 获取公共的文章列表
export const getArticles = params => {
return request({
method: 'GET',
url: '/api/articles',
params
})
}
为了更好地优化SEO,将数据渲染放到服务端进行,数据初始化代码写到asyncData ()
函数中,循环渲染文章信息。
home/index.vue
<div
class="article-preview"
v-for="article in articles"
:key="article.slug"
>
<div class="article-meta">
<nuxt-link
:to="{
name: 'profile',
params: {
username: article.author.username
}
}"
><img :src="article.author.image"
/></nuxt-link>
<div class="info">
<nuxt-link
:to="{
name: 'profile',
params: {
username: article.author.username
}
}"
class="author"
>{
{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 :to="{
name: 'article',
params: {
slug: article.slug
}
}" class="preview-link">
<h1>{
{article.title}}</h1>
<p>{
{article.description}}</p>
<span>Read more...</span>
</nuxt-link>
</div>
import {
getArticles } from '@/api/article'
export default {
name: "HomePage",
async asyncData () {
const {
data } = await getArticles()
return {
articles: data.articles,
articlesCount: data.articlesCount
}
}
};
2. 列表分页
-
分页参数的使用
async asyncData () { const page = 1 const limit = 2 const { data } = await getArticles({ limit, offset: (page - 1) * limit }) return { articles: data.articles, articlesCount: data.articlesCount } }
limit
表示每次展示多少条,offset
表示跳过前多少条。所以当点击了页码为page
时,offset
则为(page - 1) * limit
-
分页处理
<!-- 分页 --> <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>
import { getArticles } from '@/api/article' export default { name: "HomePage", watchQuery: ['page'