一、服务端渲染基础
渲染就是把数据和模版拼接在一起呈现给用户。常见的页面渲染一共有三种方式:1、传统的服务端渲染;2、客户端渲染;3、现代化的服务端渲染,也叫同构渲染。
① 传统的服务端渲染
是由服务器负责把数据源和页面模版结合起来渲染成HTML,响应给客户端浏览器,客户端接收到的就是一个完整的HTML,只负责渲染到页面中即可。传统的服务端渲染具有一些优点,包括对搜索引擎友好、首次加载速度快、对于低性能设备和网络较差的用户友好等。然而,它也有一些限制,如对服务器资源的要求较高、交互性较差等。
② 客户端渲染
服务端只负责请求数据库的数据,并且把数据返回给客户端浏览器,客户端负责组装数据和模版为一个完整的HTML并且渲染到页面中。客户端渲染具有一些优点,如更好的交互性、动态更新和较少的服务器压力。然而,它也存在一些挑战,如对搜索引擎优化(SEO)的不友好、较长的首次加载时间、对性能和设备要求较高等。
客户端渲染首屏渲染为什么慢?
因为要加载很多的js进行页面渲染,如果网速很慢,js加载的很慢。但是如果是服务端渲染,服务端直接返回一个完成的页面,客户端网速的快慢就不影响首屏渲染速度。
为什么不利于SEO?
搜索引擎获取网页内容,获取的是HTML字符串。服务端渲染的网页,搜索引擎能直接获取网页的全部内容,但是客户端渲染的网页获取到的HTML,里面的内容还是js渲染之前的HTML,没有相关的数据和实质性的内容。
③ 现代化的服务端渲染,也叫同构渲染
在同构渲染中,页面的初始渲染发生在服务器端,服务器接收到请求后执行相应的处理逻辑,包括数据获取和模板渲染等。然后,服务器返回已经渲染好的 HTML 页面给客户端。
客户端在接收到服务器返回的 HTML 页面后,会检测到其中的特殊标记或脚本,并进行重新渲染和事件绑定。这样,页面就能在客户端接管后继续交互和更新,而无需重新加载整个页面。
同构渲染的优点在于结合了客户端渲染和服务端渲染的优势。它能提供更好的首次加载性能和搜索引擎优化,同时保留了客户端渲染的交互性和动态更新能力。
然而,同构渲染也增加了开发和部署的复杂性,因为需要在服务器端和客户端都进行一定程度的渲染和逻辑编写。同时,同构渲染也要求服务器和客户端保持一致的代码和环境,以确保渲染结果的一致性。
二、NuxtJS基础
• 一个基于 vue js 生态的第三方开源服务端渲染应用框架
• 它可以帮我们轻松的使用 vue.js 技术栈构建同构应用,NustJS已经集成了Vue 2、Vue-Router、Vuex、Vue 服务器端渲染 (SSR)、Vue-Meta(用于管理应用的title标签、meta标签和其他与SEO相关的标签)的功能,并且非常轻量,压缩并 gzip 后,总代码大小为:57kb (如果使用了 Vuex 特性的话为 60kb)。
另外,Nuxt.js 使用 Webpack 和 vue-loader 、 babel-loader 来处理代码的自动化构建工作(如打包、代码分层、压缩等等)。
NuxtJS可以实现服务端渲染,也可以实现单页面应用、以及生成静态化的HTML页面。
(一)初始化项目目录
Nuxt.js 团队创建了脚手架工具 create-nuxt-app,类似于vue-cli,可以很方便地创建NuxtJS项目。除了使用create-nuxt-app脚手架工具创建项目,还可以完全自己手动地创建项目。为了方便学习使用手动创建项目的方式。按照官网的步骤非常的简单。
① 创建自己的项目根目录
② 在项目根目录中运行npm init -y
初始化package.json,修改其中的内容为官网提供的内容
{
"name": "my-app",
"scripts": {
"dev": "nuxt"
}
}
③ 运行npm install --save nuxt
安装nuxt
④ 创建 pages 目录和根页面 pages/index.vue
。Nuxt.js 会依据 pages 目录中的所有 *.vue
文件生成应用的路由配置。
⑤ 运行npm run dev
启动项目
这里报了一个错:
表示没有指定命令,nuxt会列出常见的命令,其中有一个dev
此时,package.json
中的dev
指定的是运行nuxt
命令,所以应该改成"dev": "nuxt dev"
,当运行npm run dev
的时候,真正执行的是nuxt dev
,这样再运行npm run dev
就可以了,会在3000端口启动服务
pages/index.vue
默认就是服务的首页。
在pages目录下新增一个页面,NuxtJS会自动配置好路由
访问http://localhost:3000/about
地址就可以看到about页面
然而,发现自己安装的还是有问题,运行npm run dev
之后生成的目录,都是压缩后的。并且在后续的动态路由的学习中发现不好使,这样的js文件也没办法检查是哪里出错了。
跟 create-nuxt-app 脚手架安装的还是不一样,所以还是用脚手架安装的吧!
使用脚手架建立的项目,生成的.nuxt目录都是开发环境版本
(二)路由
Nuxt.js 依据 pages 目录结构自动生成 vue-router 模块的路由配置。
1、基础路由
在NuxtJS中,我们只需要建立好项目目录,NuxtJS会自动配置好路由。pages
目录默认对应的是根路径,URL中根路径再拼接上相对于pages
的路径就可以访问 pages
目录下其他的页面。如果是目录的根页面,访问的时候直接写到目录层就可以。例如,下面定义了一个pages/user/index.vue
,通过URL访问的时候,只需要使用路径http://localhost:3000/user
路由导航有三种方式:
① a
标签,会导致页面重新刷新
② nuxt-link
<template>
<h1>index.vue</h1>
<button @click="onClick()">跳转到用户首页</button>
</template>
<script>
export default {
methods:{
onClick(){
// 会向 history 栈添加一个新的记录,所以,当用户点击浏览器后退按钮时,会回到之前的 URL。
// this.$router.push('/user')
// 不会向 history 添加新记录
// this.$router.replace('/user')
// 在history中前进一条记录,如果是负数,就是返回
this.$router.go(1)
}
}
}
</script>
2、动态路由
类似于Vue-router中的动态路由,动态路由可以定义带参数的路由,动态字段可以是任意字符串,都会匹配上同一条路由规则。
在 Nuxt.js 里面定义带参数的动态路由,需要创建对应的以下划线作为前缀的 Vue 文件 或 目录。
如下图所示,创建一个users
目录,里面新建一个_id.vue
文件
我们可以去.nuxt/router.js
文件中查看NuxtJS给我们生成的路由规则。如下图所示,带有下划线前缀的文件,会被创建成动态路由规则。
如果该文件所在的根目录,也就是users
目录下没有index.vue文件,这个动态参数会被设置为可选的,也就是说,通过http://localhost:3000/users
、http://localhost:3000/users/1
、http://localhost:3000/users/lisi
等路径,都可以访问该页面。
我们可以通过组件实例的$route.params
查看动态路由参数
created() {
console.log(this.$route.params)
}
(三)嵌套路由
嵌套路由和vue-router中的嵌套路由功能一样,只不过在NuxtJS中,不需要手写路由配置,只需要按照规则,创建好文件目录,NuxtJS会根据文件目录自动生成路由规则。创建内嵌子路由,你需要添加一个 Vue 文件,同时添加一个与该文件同名的目录用来存放子视图组件。
例如下图所示,在pages文件夹下分别创建了users.vue
文件和users文件夹,users文件夹里面的文件会自动配置为users路径的子路由
我们查看一下NuxtJS为我们生成的路由规则
在父路由的模版中,需要使用nuxt-child
标签给子路由的显示占位。在users文件夹中,如果有名为index.vue的组件,该组件会作为默认子路由。如下面的例子,父级路由中:
<template>
<div>
<h1>用户界面</h1>
<nuxt-child></nuxt-child>
</div>
</template>
子路由:
此时访问http://localhost:3000/users
路径,nuxt-child
区域展示的内容就是users/index.vue定义的模版内容
修改路径后缀就可以访问其他子路由
例如访问动态路由:
(四)自定义路由配置
如果通过用户目录配置的路由不足以满足要求,可以根据官方文档进行自定义路由配置。自定义路由通过配置文件进行配置,即根目录下的 nuxt.config.js
文件。例如,要给所有页面的路径增加一个前缀,就可以在配置对象中增加一项router配置
export default {
// 路由
router:{
base: '/app/'
},
}
这样配置之后,项目中的页面路径路由都会增加一个前缀
如果要扩展Nuxt.js生成的路由表,可以使用extendRoutes
选项。例如增加一个路由配置,当访问/hello
路由的时候,也展示users组件
router:{
base: '/app/',
// routes 路由表
// resolve 解析路由组件路径的方法
extendRoutes(routes, resolve) {
routes.push({
name: 'custom',
path: '/hello',
// 获取当前文件的绝对路径,拼上第二个参数
component: resolve(__dirname, 'pages/users.vue')
})
}
},
(五)视图
1、模版
Nuxt.js 有自己默认的应用模板。如果需要修改默认的应用模版,需要在根目录下新建app.html文件。默认的模版内容如下,其中的插值表达式中的内容是动态数据,也都是可以自定义的,但是一般不需要修改。{{APP}}
的位置会渲染应用真实展示的内容
<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
<head {{ HEAD_ATTRS }}>
{{ HEAD }}
</head>
<body {{ BODY_ATTRS }}>
{{ APP }}
</body>
</html>
如果在{{ APP }}
下一行加入自定义文本
{{ APP }}
<h1>dedd</h1>
那么应用中所有的页面都会增加这么一行
2、布局
可通过添加 layouts/default.vue
文件来扩展应用的默认布局。默认布局的源码如下:
<template>
<nuxt />
</template>
如果修改这个文件中的内容也会导致所有的页面都增加自定义的显示。可以很方便的在所有页面增加同一个组件。比如在所有页面增加跳转到首页的按钮
<template>
<div>
<button @click="toHome()">跳转到首页</button>
<nuxt />
</div>
</template>
<script>
export default {
methods:{
toHome(){
this.$router.push('/')
}
}
}
</script>
不同的组件还可以指定不同的父级布局,只需要在组件实例对象中使用layout
选项指定,并且,布局组件都必须放在layouts文件夹中。
例如,给users页面指定一个父级布局,首先创建一个布局组件
然后,给users.vue
导出的组件实例中指定layout
选项
此时,users组件,以及users的子级路由,都会使用自定义的布局,并且展示其中自定义的内容
(六)asyncData
asyncData
是一个生命周期方法,用来获取异步数据,在服务端或路由更新之前被调用。使用asyncData
调接口获取异步数据,有利于SEO以及加快首屏渲染。
asyncData
方法是在组件初始化前被调用的,在这个方法内部无法通过this
获取当前组件实例。
asyncData
最终获取数据后需要通过return
返回出去,返回的数据会和data
中return
的数据混合在一起,挂载到组件实例上。asyncData
有两种使用方式,我这里都使用new Promise()
的方式模拟,实际应用中,应该是调接口获取数据的请求。
① 返回Promise
,nuxt.js 会等待该Promise
被解析之后才会设置组件的数据,从而渲染组件
asyncData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
name: 'zs',
age: 14
})
}, 200)
})
},
② 使用 async / await
async asyncData() {
const res = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
name: 'zs',
age: 14
})
}, 200)
})
return res
},
我们可以在mounted
生命周期中打印一下当前组件实例
mounted() {
console.log(this)
}
可以看到,异步获取的数据和data
中初始化的数据都被挂载到了组件实例上
在模版中就可以通过插值表达式直接绑定异步获取的数据:{{ name }}
asyncData
方法会在服务端渲染的阶段以及路由跳转的阶段执行,第一次加载页面的时候,会在服务端渲染时执行。我们在该方法中打印一下文本,通过控制台查看打印结果,下面是启动项目时,在Nuxt SSR服务端渲染过程中打印的
如果发生路由跳转,可以看出,此时是客户端执行的asyncData
方法:
asyncData
方法不能通过this
获取当前组件实例,但是可以接收一个上下文对象参数。虽然当前组件实例还没有被初始化,但是应用的根组件实例、路由都已经被创建,可以通过上下文对象中的属性获取。
三、NuxtJS综合案例
通过综合案例掌握NuxtJS在实际应用中的使用。综合案例是通过github上的开源项目来学习。
realworld 这个项目是专门用来学习的案例项目,可以点击看一下 要实现的效果 。你可以用任何技术来实现这个项目的效果。这个项目已经被很多开发者用各种技术实现,这个网站可以查看各种前端和后端实现的该项目。
该项目使用的接口已经部署到了服务器上,可以通过后端接口API查看接口的使用。
(一)初始化项目
1、新建项目
2、引入css资源
该案例项目用到的一些css资源有现成的包,我们通过link
标签引入css文件。这里就需要使用到模版,在根目录下创建一个app.html
自定义应用的模版,这样可以比较自由的引入我们需要的资源。有一个比较慢的css文件,建议在static
文件夹中新建index.css
,把css
文件的内容复制粘贴进去。
将下面的html
复制粘贴下面代码到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|Sour
ce+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"> -->
<!--会自动找static文件夹中的index.css-->
<link rel="stylesheet" href="/index.css">
</head>
<body {{ BODY_ATTRS }}>
{{ APP }}
</body>
</html>
检查一下,css资源文件已经引入成功
3、配置布局组件
在项目中,所有的页面都有相同的头部和底部,这些相同的组件,我们称之为布局组件。
这个页面的整体由上中下三部分组成,上边可以用header
标签,下边用footer
标签,中间放一个nuxt-child
子路由占位标签。我们在根目录下新建目录layout/index.vue
,复制粘贴代码到组件中
<template>
<div>
<nav class="navbar navbar-light">
<div class="container">
<nuxt-link class="navbar-brand" to="/">conduit</nuxt-link>
<ul class="nav navbar-nav pull-xs-right">
<li class="nav-item">
<!-- Add "active" class when you're on that page" -->
<nuxt-link class="nav-link active" to="/">Home</nuxt-link>
</li>
<li class="nav-item">
<nuxt-link class="nav-link" to="/editor">
<i class="ion-compose"></i> 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="/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>
<a href="https://github.com/gothinkster/angularjs-realworld-example-app" target="_blank" style="position:fixed;bottom:0;width:100%;background:linear-gradient(#485563, #29323c);text-align: center;padding:15px;box-shadow:0 5px 5px 5px rgba(0,0,0,0.4);z-index:999;color:#fff;font-size:1.5rem;display:block"><i class="ion-social-github"></i> Fork on GitHub</a>
</footer>
</div>
</template>
<script>
export default {
name: 'LayoutIndex'
}
</script>
<style>
</style>
此时访问http://localhost:3000/layout
就可以查看布局组件。当前layout/index.vue
组件对应的页面就是项目的根页面,但是默认的路由表必须使用/layout
才能访问该页面。默认路由表有很多的限制,所以我们直接清空,全部使用自定义的路由。要修改路由表需要在根目录下的nuxt.config.js
中配置
// 自定义路由表规则
router: {
extendRoutes(routes, resolve) {
// routes是一个数组;清空默认生成的路由表
routes.splice(0)
routes.push({
name: 'layout',
path: '/',
component: resolve(__dirname, 'pages/layout/') // 可以省略最后的index.vue
})
}
}
此时,访问http://localhost:3000/
呈现的就是布局组件内容。
4、Home页面
布局组件上下部分内容固定,中间需要放置子路由的内容。默认的子路由是Home首页。先创建一个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>
另外还需要将Home
路由配置为Layout
的默认子路由
// 自定义路由表规则
router: {
extendRoutes(routes, resolve) {
// routes是一个数组;清空默认生成的路由表
routes.splice(0)
routes.push({
name: 'layout',
path: '/',
component: resolve(__dirname, 'pages/layout/'), // 可以省略最后的index.vue
children: [
{
name: 'home',
path: '/',
component: resolve(__dirname, 'pages/home/')
}
]
})
}
}
5、登录注册页面
登录和注册页面的内容基本相同,可以共用同一个组件,里面不一样的部分再通过条件判断展示不同的内容即可。
首先创建组件login/index.vue
,给layout路由增加子路由
{
name: 'login', // 登录
path: '/login',
component: resolve(__dirname, 'pages/login/')
},
{
name: 'register', // 注册
path: '/register',
component: resolve(__dirname, 'pages/login/')
}
里面有一些地方是不一样的,主要得判断当前路由是login
还是register
。可以创建一个计算属性,通过当前路由的路径进行判断。根据这个计算属性,使用v-if
设置依赖该数据的展示内容。
<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 invalid.</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="email"
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.path === '/login'
}
}
}
</script>
<style>
</style>
6、用户个人资料页面
用户个人资料页面是用户登录之后点击头像进入到的用户详情页。首先创建目录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: 'ProfileIndex'
}
</script>
路由配置应该配置成动态路由
{
name: 'profile', // 用户详细信息
path: '/profile/:username', // 动态路由
component: resolve(__dirname, 'pages/profile/')
}
7、设置页面
新建目录settings/index.vue
<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>
路由配置
{
name: 'settings', // 设置
path: '/settings',
component: resolve(__dirname, 'pages/settings/')
}
8、创建和编辑文章页面
创建目录editor/index.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>
路由配置
{
name: 'editor', // 编辑文章
path: '/editor/:slug?', // slug是文章的唯一标识,如果不传相当于新增文章,传的话就是编辑文章
component: resolve(__dirname, 'pages/editor/')
}
9、文章详情页面
新建目录article/index.vue
<template>
<div class="article-page">
<div class="banner">
<div class="container">
<h1>How to build webapps that scale</h1>
<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-sm btn-outline-secondary">
<i class="ion-plus-round"></i>
Follow Eric Simons <span class="counter">(10)</span>
</button>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Post <span class="counter">(29)</span>
</button>
</div>
</div>
</div>
<div class="container page">
<div class="row article-content">
<div class="col-md-12">
<p>
Web development technologies have evolved at an incredible clip over the
past few years.
</p>
<h2 id="introducing-ionic">Introducing RealWorld.</h2>
<p>It's a great solution for learning how other frameworks work.</p>
</div>
</div>
<hr/>
<div class="article-actions">
<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-sm btn-outline-secondary">
<i class="ion-plus-round"></i>
Follow Eric Simons <span class="counter">(10)</span>
</button>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Post <span class="counter">(29)</span>
</button>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-md-8 offset-md-2">
<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">
<div class="card-block">
<p class="card-text">With supporting text below as a natural lead-in
to additional content.</p>
</div>
<div class="card-footer">
<a href="" class="comment-author">
<img src="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img"/>
</a>
<a href="" class="comment-author">Jacob Schmidt</a>
<span class="date-posted">Dec 29th</span>
</div>
</div>
<div class="card">
<div class="card-block">
<p class="card-text">With supporting text below as a natural lead-in
to additional content.</p>
</div>
<div class="card-footer">
<a href="" class="comment-author">
<img src="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img"/>
</a>
<a href="" class="comment-author">Jacob Schmidt</a>
<span class="date-posted">Dec 29th</span>
<span class="mod-options">
<i class="ion-edit"></i>
<i class="ion-trash-a"></i>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ArticleIndex'
}
</script>
路由配置
{
name: 'article', // 文章详情
path: '/article/:slug',
component: resolve(__dirname, 'pages/article/')
}
10、处理导航链接高亮
这里我把这几个导航换成了中文
我们需要将激活的导航设置为高亮。NuxtJS会给激活的导航增加类名nuxt-link-active
这个类名是可以自己配置的。样式文件里规定的高亮类名是active
,所以需要修改激活导航的类名。只需要在路由配置项中增加一个配置
linkActiveClass: 'active'
此时会发现,激活的导航确实实现了高亮,但是Home
导航也会一直高亮。原因是路由是模糊匹配,父级路由也会处于激活状态。要修改这一配置,就需要在Home
路由上增加一个属性exact
表示精确匹配
<nuxt-link class="nav-link" to="/" exact>首页</nuxt-link>
11、封装请求模块
项目中需要使用axios
发送很多的请求,所以可以将请求封装成一个模块,所有的请求方法都放在这个模块里。从官网提供的接口文档可以看出,服务端地址为https://api.realworld.io/api
。在设置请求的基础路径的时候,不需要加最后的/api
,主要是防止由于版本变化造成路径不正确,/api
实时看一下当前接口地址是什么。
接口文档中详细列出所有接口的使用。模块放在utils
文件夹下。utils
文件夹通常存储工具方法。新建目录utils/request.js
。可以点击axios中文文档查看使用。API文档举例说明:
登录接口,地址为/user/login
,请求方式为post
requestBody
提供了一个锚点地址,往下滚动页面,找到components
模块下的requestBodies
,其中记录了该接口需要的参数以及配置
其中参数user
还需要根据$ref
找#/components/schemas/LoginUser
,可以看出user
有两个属性,分别是email
,类型为string
;password
,类型为string
,格式为password
,应该是自定义的,服务器端应该会进行校验。
发送请求需要用到axios
库,使用npm
安装。这里有一个小坑,最开始我直接用npm i axios
安装的最新版,发现项目启动有问题,换成了npm i axios@0.27.2
就可以正常启动了。
在使用axios
发送请求的时候,可以直接使用axios
库暴露出来的全局的axios
实例,它是一个单例对象,只能使用默认的配置;也可以使用axios.create()
方法创建自己的axios
实例,这样就可以自定义超时时间、拦截器、请求头等,并且可以创建多个自定义的axios
实例。基础请求模块代码:
// 基于axios封装的请求模块
import axios from 'axios'
// 创建一个请求实例
const request = axios.create({
baseURL:'https://api.realworld.io'
})
export default request;
(二)登录注册
1、基本登录功能
登录功能简述:
1、输入邮箱和密码点击登录按钮实现登录
2、如果登录成功,跳转到首页
3、如果登录失败,给提示
首先根据接口文档,初始化user
对象,并且表单和数据使用v-model
进行双向数据绑定
<fieldset class="form-group">
<input class="form-control form-control-lg" type="email"
placeholder="邮箱" v-model="user.email">
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg" type="password"
placeholder="密码" v-model="user.password">
</fieldset>
data() {
return {
user: {
email: '',
password: ''
}
}
},
然后给表单绑定提交事件,并且阻止默认提交行为,自定义提交事件。提交的时候使用post
请求,将用户信息提交到服务端
<form @submit.prevent="onSubmit">
// @指向src目录
import request from '@/utils/request.js'
// -------------
methods: {
async onSubmit() {
// request()方法返回的是一个Promise,使用await可以解析Promise最后resolve的数据
// url会和request配置中的baseUrl拼接组成目标路径
const { data } = await request({
method: 'POST',
url: '/api/users/login',
data: {
user: this.user
}
})
console.log("data")
console.log(data)
}
}
当前我们项目还没有实现注册功能,所以要想登录成功,就需要先去示例项目注册一个自己的账号,然后在本地项目就可以登录了。登录接口返回的数据就是用户的信息,这些信息需要保存到客户端,网页中有很多显示和操作都依赖于用户登录状态
然后需要实现跳转到首页
this.$router.push('/')
2、封装请求方法
在实际项目中,不建议将请求方法直接写到组件中,因为如果有很多组件用到这个请求,就需要重复写很多次,所以可以将所有调接口的请求方法都封装起来,项目中新建一个api
文件夹,里面存放所有的请求方法;新建一个api/user.js
存放和用户相关的请求
src/user.js
import request from "@/utils/request";
// 用户登录
export function login(data) {
return request({
method: 'POST',
url: '/api/users/login',
data
})
}
// 用户注册
export function register(data){
return request({
method: 'POST',
url: '/api/users',
data
})
}
在登录组件中,提交表单的时候,调用login()
方法发送请求
async onSubmit() {
const { data } = await login({
user:this.user
})
this.$router.push('/')
}
3、错误处理
接口请求错误时,需要捕获到错误,并且将错误提示显示到页面上
需要使用try...catch
进行错误捕获
async onSubmit() {
try {
const { data } = await login({
user:this.user
})
this.$router.push('/')
} catch (err){
// 需要使用dir才能打印出来详细信息,log不行
console.dir(err)
}
}
}
输入一个错误的邮箱,点击登录,查看打印的错误信息
通过err.response.data.errors
获取错误信息。err.response.data.errors
是一个对象,key表示什么属性出现错误,value是一个数组,表示具体是什么错误,可能不止一个,所以是一个数组。需要在data
里初始化一个errors
属性接收错误信息,并且在模版中绑定这个数据。v-for
可以直接遍历对象v-for="(value,key) in obj"
。
<ul class="error-messages">
<template v-for="(messages,err) in errors">
<li v-for="message in messages">{{ err }} {{ message }}</li>
</template>
</ul>
为了测试,可以在errors
初始化的时候给它写两组数据
errors: {
email:['太长','错误'],
tele:['非纯数字','太长']
}
4、用户注册
注册和登录是在一个组件里,提交的按钮也是一个,只需要在提交方法里,判断一下this.isLogin
,如果当前路由是登录路由,则调用登录接口,否则调用注册接口;
async onSubmit() {
try {
const {data} = this.isLogin
? await login({
user: this.user
})
: await register({
user: this.user
})
this.$router.push('/')
} catch (err) {
// 需要使用dir才能打印出来详细信息,log不行
this.errors = err.response.data.errors;
}
}
user
需要增加一个username
,绑定表单
5、存储登录状态
非登录状态只显示首页、登录、注册路由
登录之后,隐藏登录、注册路由,展示首页、文章、设置、用户头像路由。
并且登录之前,用输入url路径的方式访问路由,如果不是首页、注册、登录路由,都不允许访问,直接重定向到登录页。
所以用户登录之后,需要将用户的登录状态以及登录信息存储起来。
如果是单纯的客户端项目,可以存储到本地存储或者vuex
里面,但是NuxtJS是带有服务端渲染的,所以需要客户端和服务端共享登录状态,需要把登录状态和信息存储到Cookie
里面,当客户端发送请求到服务器时,浏览器会自动将所有可用的 cookie
包含在 HTTP 请求头中。这是通过使用名为 “Cookie” 的头字段来完成的。
为了方便在应用的各个组件中共享数据,会将数据在vuex
容器中也保存一份。
整体的流程就是,点击登录或者注册的时候,将用户信息存储到客户端的vuex
中和Cookie
中,如果页面刷新,vuex
中的数据就丢失了,向服务端请求页面数据的时候会携带Cookie
,此时会进行一次服务端渲染,服务端渲染过程中,检查Cookie
,并将Cookie
中的用户信息数据存储到vuex
中。
① 存储到vuex
首先创建store/index.js
。NuxtJS内核已经实现了vuex
,会自动找这个目录,并且自动配置全局状态管理。我们只需要在这个文件中,按需创建和导出所需要的vuex
属性
store/index.js
// 在服务端渲染期间运行的都是同一个实例,
// 为了防止数据冲突,定义成函数,每次初始化不同的数据
export const state = () => {
return {
// 当前用户信息和登录状态
user: null
}
}
export const mutations = {
setUser(state, data) {
// 修改user状态
state.user = data
}
}
在登录组件中,点击登录或者注册按钮发送请求完毕之后,将服务端返回的用户信息存储到容器中。
this.$store.commit('setUser', data.user)
② 存储到 Cookie
中
存储到 Cookie
中使用的是一个第三方包js-cookie
。首先需要使用npm i js-cookie
安装,然后需要在组件中引入。这个包是专门用于客户端操作Cookie的,为了保证只在客户端加载js-cookie
,避免在浏览器渲染的时候加载浪费资源,在引入包的时候,使用process.client
做一个判断,这个是NuxtJS提供的用来判断当前运行环境是不是浏览器的一个属性。
const Cookie = process.client ? require('js-cookie') : undefined
从服务器端拿到用户详细信息后再存到Cookie
里一份
Cookie.set('user', data.user)
检查一下,当没有登录的时候,刷新浏览器页面,发送首页资源的请求的请求头中是没有Cookie
的
当登录之后,刷新页面,可以看到,此时的localhost
请求的请求头中已经携带了Cookie
,里面保存的是user
用户信息,只不过不能直接展示出来
③ 服务端渲染时,获取Cookie
,存储到容器
NuxtJS提供了一个方法nuxtServerInit,是专门用来从服务端拿数据的。这个方法需要写到容器的action
里面,会在服务端渲染的时候自动执行。第一个参数是当前上下文对象,通过下面的代码输出一下
nuxtServerInit(a1, a2) {
console.log(a1)
}
由于这个方法是在服务端渲染的过程中执行的,所以不会在浏览器控制台中打印,而是会在操作系统的命令行窗口,也就是编辑器的终端打印,可以看出第一个参数就是store
用同样的方法打印一下第二个参数,很长很长,里面保存了应用的所有信息以及请求的所有信息,其中的req
属性就是请求体
我们需要先获取请求头中的Cookie
,拿到user
用户信息,然后用commit
方法来提交mutation
,更新state
中的user
。先打印一下Cookie
,已经被转换成字符串了,此时直接拿cookie.user
得到的是undefined
,所以要先借助转换工具,将Cookie
转换成对象。
这里使用的是第三方的cookieparser
插件,是专门用来转换Cookie
的。在引入包之前还是先判断环境,避免不必要的加载浪费资源。判断环境使用的是NuxtJS提供的process.server
,用来判断当前环境是不是服务端
const cookieparser = process.server ? require('cookieparser') :undefined
export const actions = {
// nuxtServerInit是一个特殊的action
// 会在服务端渲染期间自动调用
// 作用:初始化容器数据,传递数据给客户端使用
// commit:用来提交mutation的方法
// req:请求对象
nuxtServerInit({commit}, {req}) {
let user = null
if (req.headers.cookie) {
// 将请求头中的 Cookie 字符串解析为一个对象
const parsed = cookieparser.parse(req.headers.cookie)
try {
// 将 user 还原为 JavaScript 对象
user = JSON.parse(parsed.user)
} catch (err) {
// No valid cookie found
}
}
// 将cookie中的user数据存放到客户端容器中
commit('setUser', user)
}
}
6、退出登录
在设置页面需要一个退出登录的按钮。退出登录要做的事情和登录相反,清空容器中的user
和Cookie
中的user
即可
<hr/>
<button class="btn btn-outline-danger" @click="logout">
退出登录
</button>
const Cookie = process.client ? require('js-cookie') : undefined
export default {
name:'SettingsIndex',
middleware:'auth',
methods:{
logout(){
this.$store.commit('setUser',null)
Cookie.set('user', null)
}
}
}
7、导航栏显示状态
首页导航是一直展示的,剩下的导航链接可以用template
标签包裹起来,分成两组
好的,这里刷新之后就拿不到user
了,发现了一个问题。。往Cookie里面存用户信息的时候,应该先用JSON.stringify()
转义一下。
login/index.vue
Cookie.set('user', JSON.stringify(data.user))
另外,用户信息的位置,用户名和头像绑定一下动态数据
<li class="nav-item">
<nuxt-link class="nav-link" to="/profile/123">
<img class="user-pic" :src= "$store.state.user.image">
{{$store.state.user.username}}
</nuxt-link>
</li>
还可以在组件的计算属性中映射一下user
数据,这样模版中就可以省略$store.state.
了。
computed:{
...mapState(['user'])
}
8、页面访问权限
上边虽然在未登录状态不展示那几个导航,但是仍然可以通过输入url
的方式访问这几个页面,所以还需要给这几个页面增加访问权限,未登录状态不允许访问,直接重定向到登录页。NuxtJS提供了路由中间件,类似于Vue的路由守卫,自定义一个函数,在路由跳转之前自动执行。使用起来也特别简单
① 在根目录下创建middleware
文件夹,存放所有的中间件,里面创建自定义的中间件文件,我这里创建的是auth.js
,里面导出一个方法即可。这个方法接收到的参数是当前上下文对象,其中的redirect()
方法是用来重定向路由的方法。
middleware/auth.js
// 验证是否登录的中间件
export default function ({ store, redirect }) {
// 如果用户未登录
if (!store.state.user) {
return redirect('/login')
}
}
② 在需要守卫的路由组件中增加这个中间件配置,只需要增加一个配置项middleware
,值为定义中间件的文件名即可
export default {
name:'EditorIndex',
middleware:'auth'
}
如果想守卫所有的路由,可以在nuxt.config.js
中对router
项进行配置
router: {
middleware: 'auth'
}
(三)首页
首页功能解析:
1、一个tab栏分别展示用户关注的文章和所有的公开的文章,底部有分页
2、右侧展示所有的标签,点击标签,会搜索和标签相关的文章,展示到第三个tab栏的位置
3、文章内部展示文章作者、文章标题、简介、点赞数量、文章标签;并且可以手动点赞
1、展示公共文章列表
公共文章列表在页面初始化的时候就应该加载出来,属于首屏渲染的一部分,所以可以放到asyncData()
中请求文章列表数据,加快首屏渲染速度。通过接口文档查看获取文章列表接口的使用。用到的接口定义以及类的定义都在下边列出
获取文章列表接口
parameters
是参数列表。其中直接列出来的参数有三个tag
、author
、favorited
,另外两个参数是很多接口都用到的公共参数,给出了定义的锚点,根据这个锚点再去找参数的定义。所以获取文章列表接口的可接收参数有五个:
tag 标签
author 作者
favorited Boolean 是否点赞
limit 返回的数量,默认为20
offset 文章从第几个开始展示 默认为0
接口返回的数据
返回的数据包含文章列表和总文章数两个属性,总文章数主要用于分页。
article对象的属性
article
对象的属性需要绑定到页面中
请求接口的方法放在api/article.js
文件中。
api/article.js
import request from "@/utils/request";
/** 获取公共文章列表
* @param params
* tag 标签
* author 作者
* favorited Boolean 是否点赞
* limit 返回的数量,默认为20
* offset 文章从第几个开始返回,默认为0,用于分页
*/
export function getArticles(params){
return request({
method:'GET',
url:'/api/articles',
params
})
}
在home/index.vue
组件中,创建asyncData()
生命周期,获取文章列表,此时不需要传递任何参数,获取到的参数返回出去,就会和data
中的数据组合一起挂载到当前组件实例上。
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">
<!--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>
<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>
2、分页功能
文章列表下边有页码列表,点击页码会修改url
路径中的query
,并且数据也会跟着变化
① 按页码请求数据
请求文章列表的接口有一个参数是limit
,表示一页展示多少数据;参数offset
表示从第几个索引的数据开始返回。如果一页展示15条数据,要请求第二页的数据,offset
就是15,要请求第三页的数据,offset
就是30,假设当前页码数为page
,offset
、page
、limit
之间的关系就是offset = limit*(page-1)
。limit
是客户端写死的,那么page
怎么确定呢?page
从路径参数中拿到。路径参数怎么拿到呢?通过asyncData()
的参数拿到。asyncData()
的参数包含了当前应用的很多很多的属性,其中有一个query
属性指向URL
的参数列表。将limit
和offset
传递给获取文章列表的接口即可获取目标页码的数据。把limit
和page
也return
出去,方便组件使用。
async asyncData({query}) {
const page = Number.parseInt(query.page || 1);
const limit = 15;
// 获取所有文章
const {data} = await getArticles({
limit,
offset: (page - 1) * limit
});
return {
articles: data.articles,
articlesCount: data.articlesCount,
limit,
page
}
},
② 按钮列表
先看一下demo网站是怎么实现的
这个结构我们可以借鉴,就不需要自己调样式了。但是这里的跳转链接是使用a
链接做的,建议使用nuxt-link
实现,因为使用a
链接会导致页面整体刷新,使用nuxt-link
只需要在客户端刷新,体验更好。使用v-for
循环创建跳转按钮。对谁进行循环呢?就要看总共有多少页。页码数可以用总的文章数/每页展示的文章数
算出,但是还需要向上取整数哦,因为得到的数据可能不是整数,我们需要一个计算属性来计算页码总数
computed: {
totalPage() {
return Math.ceil(this.articlesCount / this.limit)
}
}
v-for
可以直接对数字进行循环,nuxt-link
通过to
属性动态绑定一个对象,对象的name
指向路由的名称,query
是路径参数。
<!--分页-->
<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>
<!--/分页-->
3、watchQuery
asyncData()
方法在服务端渲染的时候以及路由跳转的时候执行,但是上一步的实现并没有进行路由跳转,只是给当前的路由增加了一个路径参数,所以并不会引发asyncData()
的执行。
NuxtJS提供了 watchQuery
API 专门用来解决动态参数修改时,asyncData()
等方法不自动执行的问题。watchQuery
用来监听路径参数,当指定的路径参数修改的时候,与asyncData()
用法类似的方法都会自动执行,即在服务端渲染和路由跳转时执行的方法都会执行。
为了避免消耗资源,这个配置默认是关闭状态。给组件开启路径参数监听只需要给组件实例增加配置项。如果要对所有参数开启监听,需要设置watchQuery:true
,本项目中只需要监听page
路径参数即可,要监听个别的参数,就配置一个数组指向要监听的参数列表。
watchQuery:['page'],
这样路径参数发生变化时,也会执行asyncData()
方法,由此实现了页码功能。
4、展示文章标签列表
点击文章标签之后,展示有这个标签的文章
首先需要获取所有的标签
先创建一个api/tag.js
专门用来发送tag
相关请求的模块
api/tag.js
import request from "@/utils/request";
// 获取标签列表
export const getTags = function () {
return request({
method: 'GET',
url: '/api/tags'
})
}
然后,在home/index.vue
组件中,在asyncData()
中获取标签列表,并且返回出去
// 获取所有标签
const {data: tagData} = await getTags()
return {
articles: data.articles,
articlesCount: data.articlesCount,
tags:tagData.tags,
limit,
page
}
在模板中,使用v-for
循环展示标签
<div class="tag-list">
<a href="" class="tag-pill tag-default"
v-for="tag in tags" :key="tag"
>{{ tag }}</a>
</div>
对于异步任务,可以进行优化。获取文章和获取标签的两个异步任务之间没有数据依赖的关系,所以可以并行执行,不会互相影响,使用Promise.all()
并行执行即可。
// 数组解构赋值
const [articleRes, tagRes] = await Promise.all(getArticles({
limit,
offset: (page - 1) * limit
}), getTags()
)
// 对象结构赋值
const {articles, articlesCount} = articleRes.data
const {tags} = tagRes.data
return {
articles: articles,
articlesCount: articlesCount,
tags: tags,
limit,
page
}
5、点击文章标签进行文章内容刷新
现在文章标签用的是a
标签,要改成nuxt-link
标签,避免造成整个页面的刷新。使用:to
指向要跳转的组件以及传递的参数。实现方式类似于分页,表示跳转的目标组件还是home
组件,要传递的参数是点击的tag
<nuxt-link
class="tag-pill tag-default"
v-for="tag in tags"
:key="tag" :to="{
name:'home',
query:{
tag:tag
}
}"
>{{ tag }}
</nuxt-link>
使用nuxt-link
跳转时,由于组件没有变化,不会触发asyncData()
方法的执行,需要在watchQuery
中添加tag
属性,监听路径参数tag
的变化并执行asyncData()
方法。在获取文章列表的方法中,还需要将tag
作为参数传递给接口,搜索出当前标签下的文章列表
watchQuery: ['page', 'tag'],
// -----------
const [articleRes, tagRes] = await Promise.all([getArticles({
limit,
offset: (page - 1) * limit,
tag: query.tag
}), getTags()]
)
另外,在点击页码的时候,也需要传递tag
属性
<!-- 页码 -->
<nuxt-link
class="page-link"
:to="{
name: 'home',
query: {
page: item,
tag:$route.query.tag
}
}">{{ item }}
</nuxt-link>
5、处理导航栏
导航栏一共有三栏,第一栏是当前用户所关注的用户发布的文章,只有登录状态才会展示;第二栏是网站中的公共文章;第三栏是点击了标签之后搜出来的当前标签下的文章。
这三个tab也需要使用nuxt-link
实现。另外,Your Feed
栏的显示与否需要依赖于用户的登录状态,需要判断$store.state
中有没有user
这个属性,因为之前登录的时候把用户存到了$store
中。所以先使用mapState
把user
存到当前实例。
import {mapState} from "vuex";
// -----------------
computed: {
...mapState(['user'])
}
另外,tab栏是否展示,需要依赖于当前的query
中有没有tag
属性。所以在asyncData()
中,把tag
返回出来。
async asyncData({query,store}) {
const page = Number.parseInt(query.page || 1);
const limit = 15;
// 获取查询参数中的tag
const tag = query.tag
const [articleRes, tagRes] = await Promise.all([getArticles({
limit,
offset: (page - 1) * limit,
tag: tag
}), getTags()]
)
const {articles, articlesCount} = articleRes.data
const {tags} = tagRes.data
return {
articles: articles,
articlesCount: articlesCount,
tags: tags,
limit,
page,
tag // 把tag返回出去,这样就可以在模版中直接使用
}
},
另外,这三个tab栏的激活状态需要依赖于一个参数,我们起名叫tab,用来标识当前处于激活状态的tab栏。激活状态使用动态类名实现
:class="{active:tab === 'XXX'}"
这三个tab使用nuxt-link实现,点击这三个tab的时候,也需要实现跳转,需要使用:to传递查询参数,需要把当前的tab放到query参数中标识当前点击的是哪一个tab栏。另外,为了防止路由匹配模糊匹配,需要增加exact
属性,使用精准匹配路由。
<li class="nav-item" v-if="user">
<nuxt-link
exact
class="nav-link"
:class="{active:tab === 'your_feed'}"
:to="{
name:'home',
query:{
tab:'your_feed'
}
}"
>
Your Feed
</nuxt-link>
</li>
<li class="nav-item">
<nuxt-link
exact
class="nav-link"
:class="{active:tab === 'global_feed'}"
:to="{
name:'home',
query:{
tab:'global_feed'
}
}"
>
Global Feed
</nuxt-link>
</li>
<li class="nav-item" v-if="tag">
<nuxt-link
exact
class="nav-link"
:class="{active:tab === 'tag'}"
:to="{
name:'home',
query:{
tab:'tag',
tag:tag
}
}"
>
#{{ tag }}
</nuxt-link>
</li>
在点击tab栏的时候,query
参数中的tab
属性会发生变化,我们需要此时立即执行asyncData()
来请求相应的数据,所以需要在watchQuery
中增加tab的监听
watchQuery: ['page', 'tag', 'tab'],
在asyncData()
中,需要先判断query
中的tab
,然后根据tab
的不同,执行不同的请求方法。
此时还需要在api/article.js
中增加一个方法,请求用户关注的作者发表的文章列表,也就是在Your Feed
栏展示的文章列表
// 获取关注的用户文章列表
export function getFeedArticles(params){
return request({
method:'GET',
url:'/api/articles/feed',
params
})
}
在asyncData()
中,如果tab是Your Feed并且当前用户是登录状态,就需要执行getFeedArticles()
,否则执行原来的getArticles()
async asyncData({query,store}) {
const page = Number.parseInt(query.page || 1);
const limit = 15;
const tag = query.tag
const tab = query.tab || 'global_feed' // tab给一个默认值 global_feed
const loadArticles = store.state.user && tab === 'your_feed' ?
getFeedArticles : getArticles
const [articleRes, tagRes] = await Promise.all([loadArticles({
limit,
offset: (page - 1) * limit,
tag: tag
}), getTags()]
)
const {articles, articlesCount} = articleRes.data
const {tags} = tagRes.data
return {
articles: articles,
articlesCount: articlesCount,
tags: tags,
limit,
page,
tag,
tab
}
},
此时请求的话还是有问题的,因为/api/articles/feed
这个接口还需要传递用户身份认证,需要将用户的token放在headers中。首先在浏览器的cookie中获取token
然后直接复制粘贴,放到headers Authorization中
export function getFeedArticles(params){
return request({
method:'GET',
url:'/api/articles/feed',
headers:{
// 数据格式:Token空格${Token字符串}
Authorization:'Token 个人Token'
},
params
})
}
还有一点,翻页的时候也需要传递当前的tab到query中,
<nuxt-link
class="page-link"
:to="{
name: 'home',
query: {
page: item,
tag:$route.query.tag,
tab:tab
}
}">{{ item }}
</nuxt-link>
6、统一设置用户token
上边的token是写死的,这里介绍自动设置token的方式。如果有很多接口都需要传递token,一个一个传递就会很麻烦,所以可以使用axios拦截器,给所有的请求增加token。去axios拦截器中粘贴对应的代码放到utils/request.js
中,只需要请求拦截器
utils/request.js
// 任何请求都要经过请求拦截器
// 可以在请求拦截器中做公共的业务处理
request.interceptors.request.use(function (config) {
config.headers.Authorization = `Token 用户token`
// 返回请求配置对象
return config;
}, function (error) {
// 请求失败,此时请求还没有发出去
return Promise.reject(error);
});
下一步就是需要考虑如何在这里获取用户token。这里是访问不到vuex的,所有没有办法像组件那样,通过import
引入$store
。这里需要使用nuxt的插件,将axios
方法封装成一个插件,在插件的内部就可以获取应用实例。封装插件需要两步,第一步是在plugins目录下新建js文件,作为插件的功能模块,里面需要导出一个方法
plugins/request.js
// 通过插件机制获取上下文对象
export default (context) => {
console.log(context)
}
这个方法可以获取context参数
第二步是在nuxt.config.js中的plugins
中注册插件
nuxt.config.js
plugins: [
'~/plugins/request.js'
],
先把请求拦截器代码注释掉,并且刷新页面看一下context
的输出,里面包括了应用实例app
、store
对象等等
然后把utils/request.js里面定义axios
实例和请求拦截器的代码拿过来。需要注意的是,插件导出对象必须是默认导出对象,才能拿到上下文参数,所以request
对象的导出就需要使用按需导出
plugins/request.js
// 基于axios封装的请求模块
import axios from 'axios'
// 创建一个请求实例 按需导出
export const request = axios.create({
baseURL: 'https://api.realworld.io'
})
// 通过插件机制获取上下文对象
// 插件导出函数必须作为默认成员
// 解构获取store对象
export default ({store}) => {
// 任何请求都要经过请求拦截器
// 可以在请求拦截器中做公共的业务处理
request.interceptors.request.use(function (config) {
// 解构获取user
const {user} = store.state
if (user && user.token) {
config.headers.Authorization = `Token ${store.state.user.token}`
}
// 返回请求配置对象
return config;
}, function (error) {
// 请求失败,此时请求还没有发出去
return Promise.reject(error);
});
}
另外,之前通过import
导入request
实例的地方需要修改一下导入路径
import {request} from "@/plugins/request";
这样就可以实现统一自动设置用户token
。所有的请求中都会携带token
。之前手动加token
的代码以及utils/request.js
模块可以删除了。
总结:利用插件机制,在插件的默认导出对象中可以获取用户信息,将自定义的axios
实例放在插件模块中,按需导出axios
实例。
7、文章发布日期
期望格式
可以使用第三方工具包dayjs,专门用来处理日期的库。这个库非常的轻量,本身只保留最关键的功能,其他的功能封装在其他的模块中,按需加载。使用起来非常简单
① 使用npm install dayjs --save
下载库
② 在需要处理的地方使用dayjs('2018-08-08')
为了方便使用,可以把格式化时间的方法封装为一个公共的filter过滤器。过滤器也注册成插件。
plugins/dayjs.js
import Vue from "vue";
import dayjs from "dayjs";
// 使用格式:{{ 表达式 | 过滤器 }}
// format参数可以传递,也可以不传,不传的话默认为'YYYY-MM-DD HH:mm:ss'
Vue.filter('date', (value, format = 'YYYY-MM-DD HH:mm:ss')=>{
return dayjs(value).format(format)
})
注册插件
nuxt.config.js
plugins: [
'~/plugins/request.js',
'~/plugins/dayjs.js'
],
在模板中使用过滤器
<!-- format:月份缩写 日期,年份 -->
<span class="date">{{ article.createdAt | date('MMM DD,YYYY') }}</span>
8、文章点赞功能
① 首先需要封装两个请求方法:一个是添加点赞(后端接口),一个是取消点赞。两个请求方法地址是一样的,只不过添加点赞是POST方法,取消点赞是DELETE方法。在api/article.js模块中增加两个方法
// 添加点赞
export function addFavorite(slug){
return request({
method:'POST',
url:`/api/articles/${slug}/favorite`,
})
}
// 取消点赞
export function addFavorite(slug){
return request({
method:'DELETE',
url:`/api/articles/${slug}/favorite`,
})
}
② 需要给点赞按钮绑定点击事件
<button class="btn btn-outline-primary btn-sm pull-xs-right"
:class="{active:article.favorited}"
@click="onFavorite(article)"
>
<i class="ion-heart"></i> {{ article.favoritesCount }}
</button>
onFavorite(article) {
if (article.favorited) {
deleteFavorite(article.slug)
article.favorited = false;
article.favoritesCount--;
}else{
addFavorite(article.slug)
article.favorited = true;
article.favoritesCount++;
}
}
③请求期间禁用按钮点击
为了避免在发送请求的过程中用户点击按钮导致频繁发送请求,在发送请求的过程中应该将按钮的状态设置为禁用。这个属性应该给每一个文章增加。在获取文章数据之后,添加一个属性用来标识是否禁用点赞按钮
articles.forEach(a=>{
a.favoriteDisabled = false;
})
在模板中,按钮是否禁用要依赖于这个属性,使用:disabled
绑定
<button class="btn btn-outline-primary btn-sm pull-xs-right"
:class="{active:article.favorited}"
:disabled="article.favoriteDisabled"
@click="onFavorite(article)">
<i class="ion-heart"></i> {{ article.favoritesCount }}
</button>
在点击按钮的时候,把该属性改为true
,请求完毕再改为false
。异步使用async/await
等待请求方法的执行。
async onFavorite(article) {
article.favoriteDisabled = true;
if (article.favorited) {
// 如果是选中状态,就删除选中
await deleteFavorite(article.slug)
article.favorited = false;
article.favoritesCount--;
}else{
// 如果不是选中状态,就增加选中
await addFavorite(article.slug)
article.favorited = true;
article.favoritesCount++;
}
article.favoriteDisabled = false;
}
实际项目中,所有的请求方法都要注意避免频繁触发。
(四)文章详情
文章详情页包括下面几部分。其中红框框起来的部分是一摸一样的功能,可以封装为独立的组件,进行复用。
1、展示基本信息
文章详情接口
首先根据接口封装请求方法
api/article.js
// 获取文章详情
export function getArticle(slug){
return request({
method:'GET',
url:`/api/articles/${slug}`,
})
}
文章详情页同样也需要SEO和首屏渲染优化,所以请求方法的执行要放在asyncData()
中。回顾一下文章详情页的入口,是公共文章列表中的文章的文字部分
<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>
asyncData 方法第一个参数是上下文对象,其中的params
参数就是nuxt-link
传递的params
参数,可以直接通过解构赋值拿到然后发请求获取文章详情
pages/article/index.vue
<script>
import {getArticle} from "@/api/article";
export default {
name: 'ArticleIndex',
async asyncData({params}){
const {data} = await getArticle(params.slug)
return {
article:data.article
}
}
}
</script>
根据返回的数据进行模板中的数据绑定
2、展示文章内容
这个地方之前是写的静态的内容,要变成展示article.body
文章内容
<div class="row article-content">
<div class="col-md-12">
{{article.body}}
</div>
</div>
文章内容和评论里面可以有markdown,为了更好的在页面中展示文章和评论,要将markdown转为html并绑定到模板中。这个功能可以借助一个第三方库markdown-it实现
① 安装npm install markdown-it --save
② 引入第三方包
pages/article/index.vue
import MarkdownIt from "markdown-it"
③ 转换
async asyncData({params}){
const {data} = await getArticle(params.slug)
const {article} = data
// 创建MarkdownIt实例
const md = new MarkdownIt();
// 使用render()转换,接收markdown字符串
article.body = md.render(article.body);
return {
article
}
}
这样转换过后,可以看到在模板中的展示会存在html标签
所以这里的展示不能直接使用插值表达式,应该使用v-html
。
<div class="row article-content">
<div class="col-md-12" v-html="article.body">
</div>
</div>
经过测试发现换行\n
没有正确渲染,所以要将\n
转换为<br/>
标签确保正确换行
article.body = md.render(article.body).replaceAll('\\n','</br>')
3、展示文章作者相关信息
把这里公用的这部分封装成独立的组件
新建文件pages/article/components/article-meta.vue
。article
需要由父组件传递进来,子组件中使用props
接收;其中作者图片以及作者名字都是nuxt-link
链接,点击会跳转到作者信息页面;关注作者按钮和点赞文章按钮都需要使用:class
根据相应数据确定是否高亮
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,YY') }}</span>
</div>
<!-- 关注作者 -->
<button
class="btn btn-sm btn-outline-secondary"
:class="{
active:article.author.following
}">
<i class="ion-plus-round"></i>
Follow {{ article.author.username }}
<span class="counter">(10)</span>
</button>
<!-- 点赞 -->
<button class="btn btn-sm btn-outline-primary"
:class="{
active:article.author.favorited
}">
<i class="ion-heart"></i>
Favorite Post <span class="counter">({{ article.favoritesCount }})</span>
</button>
</div>
</template>
<script>
export default {
name: 'ArticleMeta',
props: {
article: {
type: Object,
required: true
}
},
created() {
console.log(this.article)
}
}
</script>
在父组件中使用
pages/article/index.vue
<article-meta :article="article"></article-meta>
<script>
import ArticleMeta from "@/pages/article/components/article-meta";
export default {
// 注册子组件
components: {ArticleMeta},
}
</script>
4、设置页面meta优化SEO
NuxtJS提供了head API可以为特定页面个性化定制头部标签(Head) 和 html 属性
pages/article/index.vue
head() {
return {
title: `${ this.article.title } - RealWorld`,
meta: [
{
hid: 'description', // 避免父子组件都有meta导致重复,起唯一标识
name: 'description',
content: this.article.description // 摘要
}
]
}
}
可以在调试工具中看到设置的meta
和title
标签
5、获取文章评论
文章评论接口
首先增加请求的方法
api/article.js
// 获取文章评论
export function getComments(slug){
return request({
method:'GET',
url:`/api/articles/${slug}/comments`,
})
}
将评论部分的模板抽离成独立的组件,组件从父组件接收article
对象,在mounted()
生命周期获取评论数据。如果考虑SEO,在服务端渲染阶段获取数据也可以。
pages/article/components/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="user.image" class="comment-author-img"/>
<button class="btn btn-sm btn-primary">
Post Comment
</button>
</div>
</form>
<!-- 已发布的评论列表 -->
<div
class="card"
v-for="(comment,index) 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,YY') }}</span>
</div>
</div>
</div>
</template>
<script>
import {getComments} from "@/api/article";
import {mapState} from "vuex";
export default {
name:'ArticleComments',
data (){
return{
comments: [], // 文章列表
}
},
computed:{
...mapState(['user'])
},
async mounted() {
const { data } = await getComments(this.article.slug)
this.comments = data.comments
},
props:{
article: {
type: Object,
required: true
}
}
}
</script>
(五)总结
本篇文章首先简述了渲染页面的三种方式:服务端渲染、客户端渲染、同构渲染;其次介绍了基于Vue的同构渲染框架NuxtJS,最后应用NuxtJS书写实例项目,在实践中体会NuxtJS的使用。以下总结用到的知识点以及实际应用中应该借鉴和注意的部分。
🫥 NuxtJS使用起来非常的方便,集成了vuex、vue-router、webpack等等功能,路由不需要自己创建,只需要定义好文件目录,NuxtJS会自动生成路由表,路由表也支持自定义设置,在配置文件nuxt.config.js
中配置。
🫥 NuxtJS的一个关键的生命周期方法是asyncData()
,会在组件初始化之前执行。如果页面有SEO需求或者加载优化的需求,就可以将数据的获取放到这个阶段执行,这样在渲染页面的时候就可以渲染出数据完备的页面,有利于搜索引擎抓取。
🫥 一个网站中不同的页面拥有的统一的结构叫做布局组件,一般指向项目的根目录。
🫥 页面之间的跳转使用nuxt-link
实现,避免重新刷新页面。asyncData()
在路由变化的时候会执行,但是如果只是查询参数发生变化则不会执行,所以如果只有查询参数变化,需要使用watchQuery
监听nuxt-link
传递过来的查询参数query
。nuxt-link
传递查询参数用query
,路径参数用params
🫥 路由配置中还可以自定义激活导航的类名,linkActiveClass: 'active'
,这样激活的导航就会增加这个类名,然后在样式表中设置这个类名的样式即可。
🫥 请求一般都按模块封装,统一放在api/
目录下,按照功能分为不同的模块,例如用户相关的封装为user.js
,文章相关的封装为article.js
。
🫥 关于用户token的存储,为了保存用户的登录状态,需要将用户的登录状态记录到cookie中和客户端的store中。记录到cookie中使用第三方插件js-cookie
。在服务端渲染时的生命周期方法nuxtServerInit()
中获取cookie
,存到客户端。
🫥 在发送请求的时候,可以设置请求拦截器,给所有的请求加上token
。首先要确保拦截器能够拿到store
中的数据。就要使用NuxtJS提供的插件机制,创建plugins/request.js
模块,其中默认导出的方式是可以通过参数获取上下文对象的。
🫥 路由跳转可以配置中间件,类似于路由守卫。路由匹配默认是模糊匹配,前面路径一样只有路径参数不一样的时候,会识别为同一个路由,此时需要给路由配置exact
,表示开启精确匹配。
🫥 日期的格式化可以使用第三方包dayjs
,非常轻量。
🫥 在请求的过程中,为了避免用户多次操作导致频繁调用接口,通常会在请求期间禁止用户频繁操作。
🫥 可以复用的部分可以封装为独立的组件。
🫥 展示文章内容是是支持markdown的,可以借用一个第三方库markdown-it
将markdown转换为html,确保在网页中正确渲染。
🫥 NuxtJS提供了head API可以为特定页面个性化定制头部标签(Head) 和 html 属性