线上项目演示地址:http://toutiao.liulongbin.top/
npm install
npm run serve
npm run build
npm run lint
运行如下的命令:
vue create toutiao
清空 App.vue
组件中的代码,并删除 components
目录下的 HelloWorld.vue
组件
清空 /src/router/index.js
路由模块:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
// 清空路由规则
const routes = []
const router = new VueRouter({
routes
})
export default router
执行 npm run serve
命令,把项目运行起来看效果
添加 .prettierrc
的配置文件:
{
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}
在 .eslintrc.js
配置文件中,添加如下的规则:
rules: {
'space-before-function-paren': 0
}
完整导入:
import Vue from 'vue'
import Vant from 'vant'
import 'vant/lib/index.css'
Vue.use(Vant)
参考文档:https://vant-contrib.gitee.io/vant/#/zh-CN/advanced-usage#rem-bu-ju-gua-pei
运行如下的命令:
npm install postcss-pxtorem -D
在 vue 项目根目录下,创建 postcss 的配置文件 postcss.config.js
,并初始化如下的配置:
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 37.5, // 根节点的 font-size 值
propList: ['*'] // 要处理的属性列表,* 代表所有属性
}
}
}
关于 px -> rem 的换算:
iphone6
375px = 10rem
37.5px = 1rem
1px = 1/37.5rem
12px = 12/37.5rem = 0.32rem
运行如下的命令:
npm i amfe-flexible -S
在 main.js 入口文件中导入 amfe-flexible
:
import 'amfe-flexible'
安装:
npm i axios -S
创建 /src/utils/request.js
模块:
import axios from 'axios'
const instance = axios.create({
// 请求根路径
baseURL: 'http://toutiao-app.itheima.net'
})
export default instance
创建 /src/views/Login/Login.vue
登录组件:
<template>
<div>
<h1>登录组件</h1>
</div>
</template>
<script>
export default {
name: 'Login'
}
</script>
<style lang="less" scoped>
</style>
修改路由模块,导入Login.vue
登录组件并声明路由规则:
import Vue from 'vue'
import VueRouter from 'vue-router'
// 1. 导入组件
import Login from '@/views/Login/Login.vue'
Vue.use(VueRouter)
const routes = [
// 2. 登录组件的路由规则
{ path: '/login', component: Login, name: 'login' }
]
const router = new VueRouter({
routes
})
export default router
在 App.vue
中声明路由占位符:
<template>
<div>
<!-- 路由占位符 -->
<router-view></router-view>
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
<style lang="less" scoped>
</style>
基于 vant 导航组件下的
NavBar 导航栏组件
,渲染 Login.vue 登录组件的头部区域
渲染登录组件的 header 头部区域:
<template>
<div>
<!-- NavBar 组件:只提供 title 标题 -->
<van-nav-bar title="黑马头条 - 登录" />
</div>
</template>
基于 vant 展示组件下的 Sticy 粘性布局
组件,实现 header 区域的吸顶效果:
<template>
<div>
<van-sticky>
<van-nav-bar title="黑马头条 - 登录" />
</van-sticky>
</div>
</template>
3 种实现方案:
- 定义全局样式表,通过审查元素的方式,找到对应的 class 类名后,进行样式的覆盖操作。
- 通过定制主题的方式,直接覆盖 vant 组件库中的 less 变量;
- 通过定制主题的方式,自定义 less 主题文件,基于文件的方式覆盖默认的 less 变量;
在 src
目录下新建 index.less
全局样式表,通过审查元素的方式找到对应的 class 类名,进行样式的覆盖:
// 覆盖 NavBar 组件的默认样式
.van-nav-bar {
background-color: #007bff;
.van-nav-bar__title {
color: white;
font-size: 14px;
}
}
在 main.js
中导入全局样式表即可:
// 导入 Vant 和 组件的样式表
import Vant from 'vant'
import 'vant/lib/index.css'
// 导入全局样式表
+ import './index.less'
// 注册全局插件
Vue.use(Vant)
修改 main.js
中导入 vant 样式的代码,把 .css
的后缀名改为 .less
后缀名:
// 导入 Vant 和 组件的样式表
import Vant from 'vant'
// 这里要把 .css 后缀名改为 .less
import 'vant/lib/index.less'
在项目根目录下新建 vue.config.js
配置文件:
module.exports = {
css: {
loaderOptions: {
less: {
modifyVars: {
// 直接覆盖变量,注意:变量名之前不需要加 @ 符号
'nav-bar-background-color': '#007bff',
'nav-bar-title-text-color': 'white',
'nav-bar-title-font-size': '14px'
}
}
}
}
}
修改 main.js
中导入 vant 样式的代码,把 .css
的后缀名改为 .less
后缀名:
// 导入 Vant 和 组件的样式表
import Vant from 'vant'
// 这里要把 .css 后缀名改为 .less
import 'vant/lib/index.less'
在 src
目录下新建 cover.less
主题文件,用来覆盖 vant 默认主题中的 less 变量:
@blue: #007bff;
@white: white;
@font-14: 14px;
// NavBar
@nav-bar-background-color: @blue;
@nav-bar-title-text-color: @white;
@nav-bar-title-font-size: @font-14;
在项目根目录下新建 vue.config.js
配置文件:
const path = require('path')
// 自定义主题的文件路径
const coverPath = path.join(__dirname, './src/cover.less')
module.exports = {
css: {
loaderOptions: {
less: {
modifyVars: {
// 通过 less 文件覆盖(文件路径为绝对路径)
hack: `true; @import "${coverPath}";`
}
}
}
}
}
渲染 DOM 结构:
<!-- 提交表单且验证通过后触发 submit 事件 -->
<van-form @submit="onSubmit">
<van-field v-model="formLogin.mobile" type="tel" label="手机号" placeholder="请输入手机号" required :rules="formLoginRules.mobile" />
<van-field v-model="formLogin.code" type="password" label="密码" placeholder="请输入密码" required :rules="formLoginRules.code" />
<div style="margin: 16px;">
<van-button block type="info" native-type="submit">登录</van-button>
</div>
</van-form>
声明 data 数据和表单验证规则对象:
data() {
return {
// 登录的表单数据对象
formLogin: {
mobile: '13888888888',
code: '246810'
},
// 登录表单的验证规则对象
formLoginRules: {
mobile: [
{ required: true, message: '请填写手机号', trigger: 'onBlur' },
{
pattern: /^1\d{10}$/,
message: '请填写正确的手机号',
trigger: 'onBlur'
}
],
code: [{ required: true, message: '请填写密码', trigger: 'onBlur' }]
}
}
},
在 src/api/
目录下,封装 user.js
模块,对外提供登录的 API 方法:
import axios from '@/utils/request'
// 登录
export const login = data => {
return axios.post('/v1_0/authorizations', data)
}
在 Login.vue
组件中,声明 onSubmit
方法如下:
import { login } from '@/api/user'
methods: {
// 组件内自定义的方法
async onSubmit() {
const { data: res } = await login(this.formLogin)
console.log(res)
if (res.message === 'OK') {
// 把登录成功的结果,存储到 vuex 中
}
}
}
在 vuex 模块中声明 state 数据节点:
export default new Vuex.Store({
state: {
// 登录成功之后的 token 信息
tokenInfo: {}
}
})
声明 updateTokenInfo
方法:
mutations: {
// 更新 token 的信息
updateTokenInfo(state, payload) {
state.tokenInfo = payload
}
},
在 Login.vue
组件中,通过 mapMutations
辅助方法,把 updateTokenInfo
方法映射到当前组件中使用:
// 1. 按需导入辅助方法
import { mapMutations } from 'vuex'
export default {
// 2. 映射 mutations 中的方法
...mapMutations(['updateTokenInfo']),
// 3. 组件内自定义的方法
async onSubmit() {
const { data: res } = await login(this.formLogin)
console.log(res)
if (res.message === 'OK') {
// 4. 更新 state 中的 token 信息
this.updateTokenInfo(res.data)
// 5. 跳转到主页
this.$router.push('/')
}
}
}
定义 initState
对象:
// 初始的 state 数据
let initState = {
// 登录成功之后的 token 信息
tokenInfo: {}
}
读取本地存储中的 state 数据:
// 读取本地存储中的数据
const stateStr = localStorage.getItem('state')
// 判断是否有数据
if (stateStr) {
initState = JSON.parse(stateStr)
}
为 vuex 中的 state 赋值:
export default new Vuex.Store({
state: initState,
// 省略其它代码...
})
在 /src/utils/request.js
模块中,声明请求拦截器:
instance.interceptors.request.use(
config => {
return config
},
error => {
return Promise.reject(error)
}
)
在 request.js
模块中导入 vuex 的 store
模块:
import store from '@/store/index'
添加 token 认证信息:
instance.interceptors.request.use(
config => {
// 1. 获取 token 值
const tokenStr = store.state.tokenInfo.token
// 2. 判断 token 值是否存在
if (tokenStr) {
// 3. 添加身份认证字段
config.headers.Authorization = 'Bearer ' + tokenStr
}
return config
},
function(error) {
return Promise.reject(error)
}
)
在 src/views/Layout
目录下新建 Layout.vue
组件:
<template>
<div>
<!-- 路由占位符 -->
<!-- TabBar 区域 -->
</div>
</template>
<script>
export default {
name: 'Layout'
}
</script>
<style lang="less" scoped>
</style>
在路由模块中导入 Layout.vue
组件,并声明路由规则:
import Layout from '@/views/Layout/Layout.vue'
const routes = [
{ path: '/login', component: Login, name: 'login' },
// Layout 组件的路由规则
{ path: '/', component: Layout }
]
渲染 TabBar 区域:
<!-- TabBar 区域 -->
<van-tabbar route>
<van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
<van-tabbar-item icon="user-o" to="/user">我的</van-tabbar-item>
</van-tabbar>
美化样式:
.van-tabbar {
border-top: 1px solid #f8f8f8;
}
在 views
目录下分别声明 Home.vue
和 User.vue
组件:
Home.vue 组件的基本结构:
<template>
<div class="home-container">
<!-- 头部区域 -->
<van-nav-bar fixed>
<template #left>
<img src="../../assets/toutiao_logo.png" alt="logo" class="logo" />
</template>
<template #right>
<van-icon name="search" color="white" size="18" />
</template>
</van-nav-bar>
</div>
</template>
<script>
export default {
name: 'Home'
}
</script>
<style lang="less" scoped>
.home-container {
padding-top: 46px;
}
.logo {
height: 80%;
}
</style>
User.vue 组件的基本结构:
<template>
<div>
User 页面
</div>
</template>
<script>
export default {
name: 'User'
}
</script>
<style lang="less" scoped>
</style>
在路由模块中导入 Home 和 User 组件,并声明对应的路由规则:
import Home from '@/views/Home/Home.vue'
import User from '@/views/User/User.vue'
const routes = [
{ path: '/login', component: Login, name: 'login' },
{
path: '/',
component: Layout,
children: [
// 默认子路由
{ path: '', component: Home, name: 'home' },
{ path: '/user', component: User, name: 'user' }
]
}
]
在 Layout.vue
组件中声明路由占位符:
<!-- 路由占位符 -->
<router-view></router-view>
<!-- TabBar 区域 -->
在 /src/api
目录下新建 home.js
模块:
import axios from '@/utils/request'
// 获取频道列表
export const getChannelList = () => {
return axios.get('/v1_0/user/channels')
}
在 Home.vue
组件中按需导入 getChannelList
方法:
// 按需导入获取频道列表数据的 API 方法
import { getChannelList } from '@/api/home'
在 data
节点中声明 channels
数组,存放频道列表的数据:
data() {
return {
// 频道列表
channels: []
}
}
在 created
生命周期函数中预调用 getChannels
方法,获取频道列表的数据:
created() {
this.getChannels()
}
在 Home.vue
组件的 methods
节点中声明 getChannels
方法如下:
// 获取频道列表的数据
async getChannels() {
const { data: res } = await getChannelList()
// 判断数据是否请求成功
if (res.message === 'OK') {
this.channels = res.data.channels
}
}
基于 Vant 导航组件下的 Tab 标签页组件,渲染出频道列表的基础结构
渲染频道列表的 DOM 结构:
<!-- Tab 标签页 -->
<van-tabs>
<van-tab v-for="item in channels" :key="item.id" :title="item.name">
{{item.name}}
</van-tab>
</van-tabs>
在 src/views/Home
目录下新建 ArticleList.vue
组件:
<template>
<div>
AritcleList 组件 --- {{id}}
</div>
</template>
<script>
export default {
name: 'AritcleList',
props: {
// 频道 Id
id: {
type: Number,
required: true
}
}
}
</script>
<style lang="less" scoped>
</style>
在 Home.vue
组件中导入、注册并使用 ArticleList
组件
导入:
import ArticleList from './ArticleList.vue'
注册:
components: {
ArticleList
}
使用:
<!-- Tab 标签页 -->
<van-tabs>
<van-tab v-for="item in channels" :key="item.id" :title="item.name">
<!-- 文章列表组件 -->
<article-list :id="item.id"></article-list>
</van-tab>
</van-tabs>
定制主题:定制选中项的高亮颜色:
// cover.less
// Tab 标签页
@tabs-bottom-bar-color: @blue;
在 @/api
目录下的 home.js
模块中,声明获取文章列表数据的方法:
// 根据频道 Id 获取文章列表数据
export const getArticleList = id => {
return axios.get('/v1_1/articles', {
params: {
channel_id: id, // 频道id
timestamp: Date.now(), // 时间戳整数 单位毫秒
with_top: 1
}
})
}
在 ArticleList.vue
组件中按需导入获取文章列表数据的方法:
import { getArticleList } from '@/api/home'
在 ArticleList.vue
组件的 data 节点下声明文章列表的数组:
data() {
return {
// 文章列表的数据
articles: []
}
},
在 created
生命周期函数中预调用 getArticleList
方法:
created() {
this.getArticleList()
},
在 methods
节点下声明 getArticleList
方法如下:
methods: {
// 获取文章列表数据
async getArticleList() {
const { data: res } = await getArticleList(this.id)
// 判断数据是否请求成功
if (res.message === 'OK') {
this.articles = res.data.results
}
}
}
渲染基本的标题和文章信息:
<template>
<div>
<van-cell v-for="item in articles" :key="item.art_id">
<!-- 标题区域的插槽 -->
<template #title>
<div class="title-box">
<!-- 标题 -->
<span>{{item.title}}</span>
</div>
</template>
<!-- label 区域的插槽 -->
<template #label>
<div class="label-box">
<span>{{item.aut_name}} {{item.comm_count}}评论 {{item.pubdate}}</span>
<!-- 关闭按钮 -->
<van-icon name="cross" />
</div>
</template>
</van-cell>
</div>
</template>
并美化样式:
.label-box {
display: flex;
justify-content: space-between;
align-items: center;
}
根据 item.cover.type
的值,按需渲染1张或3张图片:
<template>
<div>
<van-cell v-for="item in articles" :key="item.art_id">
<!-- 标题区域的插槽 -->
<template #title>
<div class="title-box">
<!-- 标题 -->
<span>{{item.title}}</span>
<!-- 单张图片 -->
<img :src="item.cover.images[0]" alt="" v-if="item.cover.type === 1" class="thumb">
</div>
<!-- 三张图片 -->
<div class="thumb-box" v-if="item.cover.type > 1">
<img v-for="(imgsrc, i) in item.cover.images" :key="i" :src="imgsrc" alt="" class="thumb">
</div>
</template>
<!-- label 区域的插槽 -->
<template #label>
<div class="label-box">
<span>{{item.aut_name}} {{item.comm_count}}评论 {{item.pubdate}}</span>
<!-- 关闭按钮 -->
<van-icon name="cross" />
</div>
</template>
</van-cell>
</div>
</template>
并美化图片的样式:
.thumb {
// 矩形黄金比例:0.618
width: 113px;
height: 70px;
background-color: #f8f8f8;
object-fit: cover;
}
.title-box {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.thumb-box {
display: flex;
justify-content: space-between;
}
Home.vue
组件中,为 van-tabs
组件添加 sticky
属性,即可开启纵向滚动吸顶效果。van-tabs
组件添加 offset-top="1.22667rem"
属性,即可控制吸顶时距离顶部的位置。基于 Vant 展示组件下的 List 列表组件,可以轻松实现上拉加载更多的效果。
在 ArticleList.vue
组件中,使用 van-list
组件把文章对应的 van-cell
组件包裹起来,并提供如下的属性:
<!-- 上拉加载更多 -->
<!-- 1. v-model="loading" 用来控制当前是否正在请求下一页的数据; true 表示正在请求中、false 表示当前没有发起任何请求 -->
<!-- 2. :finished="finished" 用来表示是否还有下一页数据; true 表示没有下一页的数据了;false 表示还有下一页数据 -->
<!-- 3. finished-text="没有更多了" 用来设置数据加载完毕后,最终的提示 -->
<!-- 4. @load="onLoad" 用来监听触底的事件,从而发起下一页数据的请求 -->
<van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<van-cell v-for="item in articles" :key="item.art_id">
<!-- 省略不必要的代码... -->
</van-cell>
</van-list>
在 data 中声明如下两个数据项,默认值都为 false:
data() {
return {
// 是否正在加载数据
loading: false,
// 数据是否加载完毕
finished: false
}
}
声明 @load
事件的处理函数如下:
// 触发了上拉加载更多的操作
onLoad() {
this.getArticleList()
},
修改 getArticleList
函数如下:
// 获取文章列表数据
async getArticleList(isRefresh) {
const { data: res } = await getArticleList(this.id)
if (res.message === 'OK') {
// 旧数据后面,拼接新数据
this.articles = [...this.articles, ...res.data.results]
// 数据加载完之后,需要把 loading 设置为 false,方便下次发起 Ajax 请求
this.loading = false
// 判断所有数据是否加载完成
if (res.data.results.length === 0) {
this.finished = true
}
}
},
注释掉 created
声明周期函数中,请求首屏数据的方法调用。因为 van-list
组件初次被渲染时,会立即触发一次 @load
事件:
created() {
// this.getArticleList()
},
基于 Vant 反馈组件下的 PullRefresh 下拉刷新,可以轻松实现下拉刷新的效果。
在 ArticleList.vue
组件中,使用 van-pull-refresh
组件把 van-list
列表组件包裹起来,并定义如下两个属性:
<!-- 下拉刷新 -->
<!-- v-model="refreshing" 用来控制当前是否正在请求下拉刷新的数据。 true 表示正在请求数据;false 表示没有发起任何请求 -->
<!-- @refresh="onRefresh" 用来监听下拉刷新的事件,从而发起下拉刷新的数据请求 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 上拉加载更多 -->
<van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<van-cell v-for="item in articles" :key="item.art_id">
<!-- 省略其它代码... -->
</van-cell>
</van-list>
</van-pull-refresh>
在 data
中定义如下的数据节点:
data() {
return {
// 是否正在刷新列表数据
refreshing: false
}
},
在 ArticleList.vue
组件中声明 @refresh
的事件处理函数如下:
// 触发了下拉刷新
onRefresh() {
// true 表示当前以下拉刷新的方式,请求列表的数据
this.getArticleList(true)
}
进一步改造 getArticleList
函数如下:
// 获取文章列表数据
async getArticleList(isRefresh) {
const { data: res } = await getArticleList(this.id)
if (res.message === 'OK') {
if (isRefresh) {
// 当前为:下拉刷新
this.articles = [...res.data.results, ...this.articles] // 新数据在前,旧数据在后
// 数据加载完成之后,把 refreshing 设置为 false,方便下次发起 Ajax 请求
this.refreshing = false
} else {
// 当前为上拉加载更多
this.articles = [...this.articles, ...res.data.results] // 旧数据在前,新数据在后
// 数据加载完之后,需要把 loading 设置为 false,方便下次发起 Ajax 请求
this.loading = false
}
// 判断所有数据是否加载完成
if (res.data.results.length === 0) {
this.finished = true
}
}
},
dayjs 中文官网:https://dayjs.fenxianglu.cn/
安装 dayjs
包:
npm install dayjs --save
在 main.js
入口文件中导入 dayjs
相关的模块:
// 导入 dayjs 的核心模块
import dayjs from 'dayjs'
// 导入计算相对时间的插件
import relativeTime from 'dayjs/plugin/relativeTime'
// 导入本地化的语言包
import zh from 'dayjs/locale/zh-cn'
配置插件和语言包:
// 配置插件
dayjs.extend(relativeTime)
// 配置语言包
dayjs.locale(zh)
定义格式化时间的全局过滤器:
Vue.filter('dateFormat', dt => {
return dayjs().to(dt)
})
在 ArticleList.vue
组件中,使用全局过滤器格式化时间:
<!-- label 区域的插槽 -->
<template #label>
<div class="label-box">
<span>{{item.aut_name}} {{item.comm_count}}评论 {{item.pubdate | dateFormat}}</span>
<!-- 关闭按钮 -->
<van-icon name="cross" />
</div>
</template>
基于 Vant 展示组件下的 Lazyload 懒加载指令,实现图片的懒加载效果
在 main.js
入口文件中,按需导入 Lazyload 指令:
import Vant, { Lazyload } from 'vant'
在 main.js
中将 Lazyload
注册为全局可用的指令:
Vue.use(Lazyload)
在 ArticleList.vue
组件中,为 <img>
标签删除 src
属性,并应用 v-lazy
指令,指令的值是要展示的图片地址
:
<!-- 单张图片 -->
<img alt="" v-if="item.cover.type === 1" class="thumb" v-lazy="item.cover.images[0]" />
<!-- 三张图片 -->
<div class="thumb-box" v-if="item.cover.type > 1">
<img v-for="(imgsrc, i) in item.cover.images" :key="i" alt="" class="thumb" v-lazy="imgsrc" />
</div>
在 @/views/Home
目录之下新建 ArticleInfo.vue
组件,并声明 DOM 结构:
<template>
<van-cell>
<!-- 标题区域的插槽 -->
<template #title>
<div class="title-box">
<!-- 标题 -->
<span>{{article.title}}</span>
<!-- 单张图片 -->
<img alt="" v-if="article.cover.type === 1" class="thumb" v-lazy="article.cover.images[0]" />
</div>
<!-- 三张图片 -->
<div class="thumb-box" v-if="article.cover.type > 1">
<img v-for="(imgsrc, i) in article.cover.images" :key="i" alt="" class="thumb" v-lazy="imgsrc" />
</div>
</template>
<!-- label 区域的插槽 -->
<template #label>
<div class="label-box">
<span>{{article.aut_name}} {{article.comm_count}}评论 {{article.pubdate | dateFormat}}</span>
<!-- 关闭按钮 -->
<van-icon name="cross" />
</div>
</template>
</van-cell>
</template>
定义 props
属性:
export default {
name: 'ArticleInfo',
props: {
// 要渲染的文章信息对象
article: {
type: Object,
required: true
}
}
}
美化组件样式:
.article-info-container {
border-top: 1px solid #f8f8f8;
}
.label-box {
display: flex;
justify-content: space-between;
align-items: center;
}
.thumb {
width: 113px;
height: 70px;
background-color: #f8f8f8;
object-fit: cover;
}
.title-box {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.thumb-box {
display: flex;
justify-content: space-between;
}
在 ArticleList.vue
组件中导入、注册、并使用 ArticleInfo.vue
组件:
导入:
import ArticleInfo from './ArticleInfo.vue'
注册:
components: {
ArticleInfo
}
使用:
<template>
<!-- 下拉刷新 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 上拉加载更多 -->
<van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<!-- 使用 ArticleInfo 组件 -->
<article-info v-for="item in articles" :key="item.art_id" :article="item"></article-info>
</van-list>
</van-pull-refresh>
</template>
js 中的安全数字:
> Number.MAX_SAFE_INTEGER > 9007199254740991 > Number.isSafeInteger(1323819148127502300) > false
id 的值已经超出了 JavaScript 中最大的 Number 数值,会导致 JS 无法正确的进行数字的处理和运算,例如:
> 1323819148127502300 + 1 === 1323819148127502300 + 2 > true
解决方案:json-bigint(https://www.npmjs.com/package/json-bigint)
安装依赖包:
npm i json-bigint -S
在 @/utils/request.js
模块中导入 json-bigint
模块:
import bigInt from 'json-bigint'
声明处理大数问题的方法:
// 处理大数问题
const transBigInt = data => {
try {
// 尝试着进行大数的处理
return bigInt.parse(data)
} catch {
// 大数处理失败时的后备方案
return JSON.parse(data)
}
}
在调用 axios.create()
方法期间,指定 transformResponse
选项:
const instance = axios.create({
// 请求根路径
baseURL: 'http://toutiao-app.itheima.net',
transformResponse: [transBigInt]
})
在 ArticleList.vue
组件中使用文章Id 时,需要调用 .toString()
方法,把大数对象转为字符串表示的数字:
<article-info v-for="item in articles" :key="item.art_id.toString()" :article="item"></article-info>
在 ActionInfo.vue
组件中,为关闭按钮绑定点击事件处理函数:
<!-- 关闭按钮 -->
<!-- 通过 .stop 修饰符,阻止事件冒泡 -->
<van-icon name="cross" @click.stop="onCloseClick" />
在 methods
节点中声明 onCloseClick
事件处理函数如下:
// 点击了叉号按钮
onCloseClick() {
// 展示动作面板
this.show = true
},
在 ActionInfo.vue
组件中声明动作面板的 DOM 结构:
<!-- 动作面板 -->
<!-- v-model="show" 用来控制动作面板的显示与隐藏 -->
<!-- cancel-text="取消" 用来定义取消按钮的文本 -->
<!-- :closeable="false" 用来隐藏右上角的关闭按钮 -->
<!-- @closed="onSheetClose" 用来监听动作面板关闭之后的事件 -->
<van-action-sheet v-model="show" cancel-text="取消" :closeable="false" @closed="onSheetClose">
<!-- 自定义动作面板要渲染的内容 -->
</van-action-sheet>
在 data
中声明布尔值 show
,用来控制动作面板的展示与隐藏:
data() {
return {
// 控制 ActionSheet 的显示与隐藏
show: false
}
}
在 ArticleInfo.vue
组件的 data
中声明 actions
数组:
data() {
return {
// 第一个面板的可选项列表
actions: [
{ name: '不感兴趣' },
{ name: '反馈垃圾内容' },
{ name: '拉黑作者' }
],
}
}
在动作面板中,循环渲染第一个面板的列表项:
<!-- 动作面板 -->
<van-action-sheet v-model="show" cancel-text="取消" :closeable="false" @closed="onSheetClose">
<!-- 展示第一个面板 -->
<div>
<van-cell :title="item.name" v-for="(item, i) in actions" :key="i" clickable title-class="center-title" @click="onCellClick(item)" />
</div>
</van-action-sheet>
在 style
节点中声明 center-title
美化每一项的样式:
.center-title {
text-align: center;
}
在 methods
中声明 onCellClick
如下:
// 点击第一层的 item 项
onCellClick(info) {
if (info.name === '不感兴趣') {
console.log('不感兴趣')
this.show = false
} else if (info.name === '拉黑作者') {
console.log('拉黑作者')
this.show = false
} else if (info.name === '反馈垃圾内容') {
// TODO:展示第二个面板的数据
}
},
在 ArticleInfo.vue
组件中,找到动作面板对应的结构,并声明第二个面板对应的 DOM 结构:
<!-- 动作面板 -->
<van-action-sheet v-model="show" cancel-text="取消" :closeable="false" @closed="onSheetClose">
<!-- 展示第一个面板 -->
<div>
<van-cell :title="item.name" v-for="(item, i) in actions" :key="i" clickable title-class="center-title" @click="onCellClick(item)" />
</div>
<!-- 展示第二个面板 -->
<div v-else>
<van-cell title="返回" title-class="center-title" />
</div>
</van-action-sheet>
按需控制两个面板的显示与隐藏:
在 data 中声明布尔值 showFirstAction
,用来控制第一个面板的显示与隐藏(true:显示;false:隐藏):
data() {
return {
// 是否展示第一个面板
showFirstAction: true,
}
}
使用 v-if
和 v-else
指令,控制两个面板的显示与隐藏:
<!-- 展示第一个面板 -->
<div v-if="showFirstAction">
<van-cell :title="item.name" v-for="(item, i) in actions" :key="i" clickable title-class="center-title" @click="onCellClick(item)" />
</div>
<!-- 展示第二个面板 -->
<div v-else>
<van-cell title="返回" title-class="center-title" />
</div>
点击反馈垃圾内容显示第二个面板:
// 点击第一层的 item 项
onCellClick(info) {
if (info.name === '不感兴趣') {
console.log('不感兴趣')
this.show = false
} else if (info.name === '拉黑作者') {
console.log('拉黑作者')
this.show = false
} else if (info.name === '反馈垃圾内容') {
// 将布尔值改为 false,即可显示第二个面板
this.showFirstAction = false
}
},
点击返回按钮,显示第一个面板:
<van-cell title="返回" title-class="center-title" @click="showFirstAction = true" />
在动作面板关闭后,为了方便下次能够直接看到第一个面板,需要把 showFirstAction
的值重置为 true
:
// 监听 Sheet 关闭完成后的事件
onSheetClose() {
// 下次默认渲染第一个面板
this.showFirstAction = true
},
在 @/api/constant
目录下,新建 reports.js
模块,用来定义第二个面板要用到的常量数据:
// 以模块的方式导出 举报文章 时,后端接口约定的举报类型
const reports = [
{
value: 0,
label: '其它问题'
},
{
value: 1,
label: '标题夸张'
},
{
value: 2,
label: '低俗色情'
},
{
value: 3,
label: '错别字多'
},
{
value: 4,
label: '旧闻重复'
},
{
value: 6,
label: '内容不实'
},
{
value: 8,
label: '侵权'
},
{
value: 5,
label: '广告软文'
},
{
value: 7,
label: '涉嫌违法犯罪'
}
]
export default reports
在 ArticleInfo.vue
组件中导入,并把常量数据挂载为 data 节点:
import reports from '@/api/constant/reports'
export default {
name: 'ArticleInfo',
data() {
return {
// 第二个面板要用到的列表数据
reports
}
}
}
在第二个面板中循环渲染列表数据,并为每一项绑定点击事件处理函数 onFeedbackCellClick
:
<!-- 展示第二个面板 -->
<div v-else>
<van-cell title="返回" title-class="center-title" @click="showFirstAction = true" />
<van-cell :title="item.label" v-for="(item, i) in reports" :key="i" clickable title-class="center-title" @click="onFeedbackCellClick(item)" />
</div>
在 methods
节点下定义 onFeedbackCellClick
处理函数:
// 点击了反馈面板中的按钮
onFeedbackCellClick(info) {
// 关闭动作面板
this.show = false
}
默认情况下,我们在 ArticleInfo.vue
组件中使用的 ActionSheet
组件,因此它会被渲染到 List 列表组件
内部
List 列表组件的
下拉刷新解决方案:把 ActionList
组件,通过 get-container
属性,挂载到 body
元素下:
<van-action-sheet v-model="show" cancel-text="取消" :closeable="false" @closed="onSheetClose" get-container="body">
<!-- 省略其它代码 -->
</van-action-sheet>
在 @/api/home.js
模块中声明如下的 API 方法:
// 将文章设置为不感兴趣
export const dislikeArticle = artId => {
return axios.post('/v1_0/article/dislikes', {
target: artId
})
}
在 ArticleInfo.vue
组件中,按需导入 dislikeArticle
方法:
import { dislikeArticle } from '@/api/home'
在 onCellClick
方法中调用不感兴趣的 API 接口:
if (info.name === '不感兴趣') {
// 调用接口,将此文章设置为不感兴趣
const { data: res } = await dislikeArticle(
this.article.art_id.toString()
)
// 接口调用成功
if (res.message === 'OK') {
// TODO:将此文章从列表中移除
console.log(this.article.art_id.toString())
}
// 关闭动作面板
this.show = false
}
在 ArticleInfo.vue
组件中,通过 this.$emit()
触发自定义事件,把要删除的文章 Id 传递给父组件:
// 接口调用成功
if (res.message === 'OK') {
// TODO:将此文章从列表中移除
this.$emit('remove-article', this.article.art_id.toString())
}
在 ArticleList.vue
组件中,监听 ArticleInfo.vue
组件的 remove-article
自定义事件:
<article-info v-for="item in articles" :key="item.art_id.toString()" :article="item" @remove-article="onArticleRemove">
</article-info>
在 ArticleList.vue
组件中,声明 onArticleRemove
函数如下:
// 触发了删除文章的自定义事件
onArticleRemove(artId) {
this.articles = this.articles.filter(x => x.art_id.toString() !== artId)
}
在 @/api/home.js
模块中声明如下的方法:
// 举报文章
export const reportArticle = (artId, type) => {
return axios.post('/v1_0/article/reports', {
target: artId, // 文章的 Id
type // 举报的类型
})
}
在 ArticleInfo.vue
中按需导入 reportArticle
方法:
import { dislikeArticle, reportArticle } from '@/api/home'
在点击动作面板中反馈选项的时候,调用接口反馈提交反馈信息:
<!-- 展示第二个面板 -->
<div v-else>
<van-cell title="返回" title-class="center-title" @click="showFirstAction = true" />
<van-cell :title="item.label" v-for="(item, i) in reports" :key="i" clickable title-class="center-title" @click="onFeedbackCellClick(item)" />
</div>
声明 onFeedbackCellClick
如下:
// 点击了反馈面板中的按钮
async onFeedbackCellClick(info) {
// 发起请求,反馈文章的问题
const { data: res } = await reportArticle(
this.article.art_id.toString(), // 文章的 Id
info.value // 文章存在的问题编号
)
if (res.message === 'OK') {
// 反馈成功,从列表中移除对应的文章
this.$emit('remove-article', this.article.art_id.toString())
}
// 关闭动作面板
this.show = false
}
在 Home.vue
组件中,渲染小图标的基本结构:
<template>
<div>
<!-- Tab 标签页 -->
<van-tabs sticky offset-top="1.22667rem">
<van-tab v-for="item in channels" :key="item.id" :title="item.name">
<!-- 文章列表组件 -->
<article-list :id="item.id"></article-list>
</van-tab>
</van-tabs>
<!-- 右侧的编辑频道的小图标 -->
<van-icon name="plus" size="14" class="moreChannels" />
</div>
</template>
美化标签页和小图标的样式:
// 设置 tabs 容器的样式
/deep/ .van-tabs__wrap {
padding-right: 30px;
background-color: #fff;
}
// 设置小图标的样式
.moreChannels {
position: fixed;
top: 62px;
right: 8px;
z-index: 999;
}
渲染基本的 DOM 结构:
<!-- 弹出层组件 -->
<!-- close-on-click-overlay 是否在点击遮罩层后关闭(false 不关闭) -->
<van-popup v-model="show" :close-on-click-overlay="false">
<div class="popup-container">
<!-- 弹出层的头部区域 -->
<van-nav-bar title="频道管理">
<template #right>
<van-icon name="cross" size="14" color="white" @click="show = false" />
</template>
</van-nav-bar>
<!-- 弹出层的主体区域 -->
<div class="pop-body">
<!-- 我的频道 -->
<div class="my-channel-box">
<div class="channel-title">
<span>我的频道:</span>
<span>编辑</span>
</div>
<!-- 我的频道列表 -->
<van-row type="flex">
<van-col span="6" v-for="item in channels" :key="item.id">
<div class="channel-item van-hairline--surround">{{item.name}}</div>
</van-col>
</van-row>
</div>
<!-- 更多频道 -->
<div class="more-channel-box">
<div class="channel-title">
<span>点击添加更多频道:</span>
</div>
<!-- 更多频道列表 -->
<van-row type="flex">
<van-col span="6" v-for="item in channels" :key="item.id">
<div class="channel-item van-hairline--surround">{{item.name}}</div>
</van-col>
</van-row>
</div>
</div>
</div>
</van-popup>
美化样式:
.van-popup,
.popup-container {
background-color: transparent;
height: 100%;
width: 100%;
}
.popup-container {
display: flex;
flex-direction: column;
}
.pop-header {
height: 90px;
background-color: #007bff;
color: white;
text-align: center;
font-size: 14px;
position: relative;
.title {
width: 100%;
position: absolute;
bottom: 15px;
}
}
.pop-body {
flex: 1;
overflow: scroll;
padding: 8px;
background-color: white;
}
.my-channel-box,
.more-channel-box {
.channel-title {
display: flex;
justify-content: space-between;
font-size: 14px;
line-height: 28px;
padding: 0 6px;
}
}
.channel-item {
font-size: 12px;
text-align: center;
line-height: 36px;
background-color: #fafafa;
margin: 5px;
}
在 data 中声明 show
来控制 popup 组件的显示与隐藏:
data() {
return {
// 控制弹出层组件的显示与隐藏
show: false
}
}
在点击 +
号小图标时展示弹出层:
<!-- 右侧的编辑频道的小图标 -->
<van-icon name="plus" size="14" class="moreChannels" @click="show = true" />
后台没有提供直接获取更多频道的 API 接口,需要程序员动态地进行计算:
更多频道 = 所有频道 - 我的频道
此时,需要先获取到所有频道地列表数据,再使用计算属性动态地进行筛选即可
请求所有频道的列表数据:
在 @/api/home.js
模块中封装 getAllChannels
方法:
// 获取所有频道列表
export const getAllChannels = () => {
return axios.get('/v1_0/channels')
}
在 Home.vue
组件中按需导入 getAllChannels
方法:
import { getChannelList, getAllChannels } from '@/api/home'
在 data
中声明 allChannels
的数组,用来存放所有的频道列表数据:
data() {
return {
// 所有频道的列表数据
allChannels: []
}
}
在 created
声明周期函数中,预调用 this.getAllChannels()
方法:
created() {
this.getChannels()
// 请求所有的频道列表数据
this.getAllChannels()
},
在 methods
中定义 getAllChannels
方法如下:
// 获取所有频道列表的数据
async getAllChannels() {
const { data: res } = await getAllChannels()
if (res.message === 'OK') {
this.allChannels = res.data.channels
}
},
在 Home.vue
组件中声明 moreChannels
的计算属性:
computed: {
// 更多频道的数据
moreChannels() {
// 1. 对数组进行 filter 过滤,返回的是符合条件的新数组
return this.allChannels.filter(x => {
// 判断当前循环项,是否在 “我的频道” 列表之中
const index = this.channels.findIndex(y => y.id === x.id)
// 如果不在,则 return true,表示需要把这一项存储到返回的新数组之中
if (index === -1) return true
})
}
},
修改更多频道列表的数据源:
<!-- 更多频道列表 -->
<van-row type="flex">
<!-- 把数据源从 channels 修改为计算属性 moreChannels -->
<van-col span="6" v-for="item in moreChannels" :key="item.id">
<div class="channel-item van-hairline--surround">{{item.name}}</div>
</van-col>
</van-row>
在 Home.vue
组件的 data 节点下声明布尔值 isEdit
,来控制当前是否处于编辑状态:
data() {
return {
// 频道数据是否处于编辑状态
isEdit: false
}
}
为编辑按钮绑定点击事件,动态切换 isEdit
的值和渲染的文本内容:
<span @click="isEdit = !isEdit">{{isEdit ? '完成' : '编辑'}}</span>
在我的频道中,渲染删除的徽标,并使用 v-if 控制其显示与隐藏:
<!-- 我的频道列表 -->
<van-row type="flex">
<van-col span="6" v-for="item in channels" :key="item.id">
<!-- 频道的 Item 项 -->
<div class="channel-item van-hairline--surround">
{{item.name}}
<!-- 删除的徽标 -->
<van-badge color="transparent" class="cross-badge" v-if="isEdit">
<template #content>
<van-icon name="cross" class="badge-icon" color="#cfcfcf" size="12" />
</template>
</van-badge>
</div>
</van-col>
</van-row>
美化删除徽标的样式:
.cross-badge {
position: absolute;
right: -3px;
top: 0;
border: none;
}
注意:“推荐”这个频道不允许被删除!
为频道的 Item 项绑定点击事件处理函数:
<!-- 频道的 Item 项 -->
<div class="channel-item van-hairline--surround" @click="removeChannel(item.id)">
</div>
在 methods
中声明点击事件处理函数:
// 移除频道
removeChannel(id) {
// 如果当前不处于编辑状态,直接 return
if (!this.isEdit) return
// 如果当前要删除的 Id 等于 0,则不允许被删除
if (id === 0) return
// 对频道列表进行过滤
this.channels = this.channels.filter(x => x.id !== id)
}
不为 “推荐” 频道展示 “删除” 的徽标:
<!-- 删除的徽标 -->
<van-badge color="transparent" class="cross-badge" v-if="isEdit && item.id !== 0">
<template #content>
<van-icon name="cross" class="badge-icon" color="#cfcfcf" size="12" />
</template>
</van-badge>
删除完毕之后,需要把最新的频道列表数据保存到后台数据库中:
在 @/api/home.js
中定义更新频道列表数据的 API 方法:
// 更新我的频道列表
export const updateMyChannels = data => {
return axios.put('/v1_0/user/channels', data)
}
修改 Home.vue
组件的 methods 节点中声明更新频道数据的 updateChannels
方法:
// 更新频道数据
async updateChannels() {
// 1. 处理要发送到服务器的 data 数据
const data = {
channels: this.channels
.filter(x => x.id !== 0) // 不需要把 “推荐” 发送给后端
.map((x, i) => ({
id: x.id, // 频道的 Id
seq: i + 1 // 频道的序号(给后端排序用的)
}))
}
// 2. 调用 API 接口,把频道数据存储到后端
const { data: res } = await updateMyChannels(data)
if (res.message === 'OK') {
// 3. 通过 notify 弹框提示用户更新成功
this.$notify({ type: 'success', message: '更新成功', duration: 1000 })
}
},
在 removeChannel
方法中调用 updateChannels
方法,更新数据库中的频道数据:
// 移除频道
removeChannel(id) {
// 如果当前不处于编辑状态,直接 return
if (!this.isEdit) return
// 如果当前要删除的 Id 等于 0,则不允许被删除
if (id === 0) return
// 对频道列表进行过滤
this.channels = this.channels.filter(x => x.id !== id)
this.updateChannels()
},
为更多频道列表中的频道 Item 项绑定点击事件处理函数:
<!-- 更多频道列表 -->
<van-row type="flex">
<van-col span="6" v-for="item in moreChannels" :key="item.id">
<div class="channel-item van-hairline--surround" @click="addChannel(item)">{{item.name}}</div>
</van-col>
</van-row>
在 methods
中声明 addChannel
如下:
// 新增频道
addChannel(channel) {
// 向前端数据中新增频道信息
this.channels.push(channel)
// 把前端数据保存到后台数据库中
this.updateChannels()
}
监听弹出层关闭完成后的事件:
<!-- 监听 closed 事件 -->
<van-popup v-model="show" :close-on-click-overlay="false" @closed="onPopupClosed">
</van-popup>
声明 onPopupClosed
方法如下:
// 监听关闭弹出层且动画结束后触发的事件
onPopupClosed() {
this.isEdit = false
}
在 Home.vue
组件中声明 activeTabIndex
索引值,用来记录激活的 tab 标签页的索引:
data() {
return {
// 激活的 Tab 标签页索引,默认激活第一项
activeTabIndex: 0
}
}
为 van-tabs
组件通过 v-model
指令双向绑定激活项的索引:
<!-- Tab 标签页 -->
<van-tabs sticky offset-top="1.22667rem" v-model="activeTabIndex">
<van-tab v-for="item in channels" :key="item.id" :title="item.name">
<!-- 文章列表组件 -->
<article-list :id="item.id"></article-list>
</van-tab>
</van-tabs>
在点击我的频道 Item 项时,把索引值传递到点击事件的处理函数中:
<!-- 频道的 Item 项 -->
<div class="channel-item van-hairline--surround" @click="removeChannel(item.id, i)">
</div>
改造 removeChannel
方法如下:
// 移除频道
removeChannel(id, index) {
// 当前不处于编辑状态
if (!this.isEdit) {
// 为 tab 标签页的激活项索引重新赋值
this.activeTabIndex = index
// 关闭弹出层
this.show = false
return
}
// 如果当前要删除的 Id 等于 0,则不允许被删除
if (id === 0) return
// 对频道列表进行过滤
this.channels = this.channels.filter(x => x.id !== id)
this.updateChannels()
},
在 @/views/
目录下新建 Search
文件夹,并创建 Search.vue
和 SearchResult.vue
组件。
在路由模块中导入上述的两个组件,并声明对应的路由规则:
// 导入搜索相关的组件
import Search from '@/views/Search/Search.vue'
import SearchResult from '@/views/Search/SearchResult.vue'
const routes = [
{ path: '/login', component: Login, name: 'login' },
{
path: '/',
component: Layout,
children: [
{ path: '', component: Home, name: 'home' },
{ path: '/user', component: User, name: 'user' }
]
},
// 搜索组件的路由规则
{ path: '/search', component: Search, name: 'search' },
// 搜索结果组件的路由规则
{ path: '/search/:kw', component: SearchResult, name: 'search-result' }
]
在 Home.vue
组件中,为 NavBar 右侧的搜索图标绑定点击事件处理函数,通过编程式导航跳转到搜索组件页面:
<van-icon name="search" color="white" size="18" @click="$router.push('/search')" />
在 Search.vue
组件中声明如下的 DOM 结构:
<template>
<div>
<div class="search-header">
<!-- 后退按钮 -->
<van-icon name="arrow-left" color="white" size="18" class="goback" @click="$router.go(-1)" />
<!-- 搜索组件 -->
<van-search v-model.trim="kw" placeholder="请输入搜索关键词" background="#007BFF" shape="round" />
</div>
</div>
</template>
在 data
中声明 kw
关键词:
data() {
return {
// 搜索关键词
kw: ''
}
}
美化样式:
.search-header {
height: 46px;
display: flex;
align-items: center;
background-color: #007bff;
overflow: hidden;
// 后退按钮
.goback {
padding-left: 14px;
}
// 搜索组件
.van-search {
flex: 1;
}
}
为 van-search
组件添加 ref
引用:
<!-- 搜索组件 -->
<van-search v-model.trim="kw" placeholder="请输入搜索关键词" background="#007BFF" shape="round" ref="search" />
在 mounted
生命周期函数中,获取组件的引用,并通过 DOM 操作查找到 input 输入框,使其获得焦点:
mounted() {
// 如果搜索组件的 ref 引用存在,则获取下面的 input 输入框,使其自动获得焦点
this.$refs.search && this.$refs.search.querySelector('input').focus()
}
在 data
中声明 timerId
,用来存储延时器的 Id:
data() {
return {
// 延时器的 Id
timerId: null
}
}
监听搜索组件的 input
输入事件:
<!-- 搜索组件 -->
<van-search v-model.trim="kw" placeholder="请输入搜索关键词" background="#007BFF" shape="round" ref="search" @input="onInput" />
在 methods
中声明 onInput
处理函数:
// 监听文本框的输入事件
onInput() {
// 清除延时器
clearTimeout(this.timerId)
// 判断是否输入了内容
if (this.kw.length === 0) return
// 创建延时器
this.timerId = setTimeout(() => {
console.log(this.kw)
}, 500)
}
在 @/api/
目录下新建 search.js
模块:
import axios from '@/utils/request'
// 获取搜索关键词的列表
export const getSuggList = kw => {
return axios.get('/v1_0/suggestion', {
params: {
q: kw
}
})
}
在 Search.vue
组件的 data
中声明搜索建议的数组:
data() {
return {
// 建议列表
suggList: []
}
}
在 Search.vue
组件中按需导入 getSuggList
方法:
import { getKwList } from '@/api/search'
定义 getKeywordsList
方法如下:
// 请求搜索关键词的列表
async getKeywordsList() {
const { data: res } = await getSuggList(this.kw)
if (res.message === 'OK') {
this.suggList = res.data.options
}
}
修改 onInput
方法:
// 监听文本框的输入事件
onInput() {
// 清除延时器
clearTimeout(this.timerId)
// 判断是否输入了内容
if (this.kw.length === 0) {
this.suggList = []
return
}
// 创建延时器
this.timerId = setTimeout(() => {
// TODO:请求搜索建议的关键词
this.getKeywordsList()
}, 500)
},
基于 v-for
指令循环渲染搜索建议列表:
<!-- 搜索建议 -->
<div class="sugg-list">
<div class="sugg-item" v-for="(item, i) in suggList" :key="i">{{item}}</div>
</div>
美化样式:
.sugg-list {
.sugg-item {
padding: 0 15px;
border-bottom: 1px solid #f8f8f8;
font-size: 14px;
line-height: 50px;
// 实现省略号的三行代码
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
把插值表达式改造为 v-html
指令:
<!-- 搜索建议 -->
<div class="sugg-list">
<div class="sugg-item" v-for="(item, i) in suggList" :key="i" v-html="item"></div>
</div>
在 methods
中声明 hightlightKeywords
方法:
// 高亮关键词
hightlightKeywords(arr) {
// 1. 创建正则实例,其中:
// 修饰符 i 表示执行对大小写不敏感的匹配
// 修饰符 g 表示全局匹配(查找所有匹配而非在找到第一个匹配后停止)
const reg = new RegExp(this.kw, 'ig')
// 2. 循环数组中的每一项,返回一个处理好的新数组
return arr.map(x => {
// 2.1 调用字符串的 .replace(正则, 替换的函数) 方法进行替换
const result = x.replace(reg, val => {
// 2.2 return 一个替换的结果
return `<span style="color: red; font-weight: bold;">${val}</span>`
})
// 3. 当前 map 循环需要 return 一个处理的结果
return result
})
}
改造 getKeywordsList
方法:
// 请求搜索关键词的列表
async getKeywordsList() {
const { data: res } = await getSuggList(this.kw)
if (res.message === 'OK') {
// this.suggList = res.data.options
// 调用 hightlightKeywords 方法,对关键字进行高亮处理
this.suggList = this.hightlightKeywords(res.data.options)
}
},
在 data
中定义假数据:
data() {
return {
// 搜索历史
history: ['API', 'java', 'css', '前端', '后台接口', 'python']
}
}
渲染搜索历史的 DOM 结构:
<!-- 搜索历史 -->
<div class="search-history">
<!-- 标题 -->
<van-cell title="搜索历史">
<!-- 使用 right-icon 插槽来自定义右侧图标 -->
<template #right-icon>
<van-icon name="delete" class="search-icon" />
</template>
</van-cell>
<!-- 历史列表 -->
<div class="history-list">
<span class="history-item" v-for="(tag, i) in history" :key="i">{{tag}}</span>
</div>
</div>
美化样式:
.search-icon {
font-size: 16px;
line-height: inherit;
}
.history-list {
padding: 0 10px;
.history-item {
display: inline-block;
font-size: 12px;
padding: 8px 14px;
background-color: #efefef;
margin: 10px 8px 0px 8px;
border-radius: 10px;
}
}
根据搜索关键字 kw
的 length 是否为 0,再结合 v-if
和 v-else
指令,实现搜索建议和搜索历史的按需展示:
<!-- 搜索建议 -->
<div class="sugg-list" v-if="kw.length !== 0"></div>
<!-- 搜索历史 -->
<div class="search-history" v-else></div>
- 关键词去重
- 最新的关键词插入到头部位置
- 通过 Set 对象实现数组的去重
改造 getKeywordsList
方法:
// 请求搜索关键词的列表
async getKeywordsList() {
const { data: res } = await getSuggList(this.kw)
if (res.message === 'OK') {
this.suggList = this.hightlightKeywords(res.data.options)
// 1. 创建一个 Set 对象,用来去重
const set = new Set([this.kw, ...this.history])
// 2. 把去重之后的结果,转化成数组,存放到 history 数组中
this.history = Array.from(set)
}
},
定义 watch 侦听器,监视数组的变化,持久化存储到 localStorage
中:
watch: {
// 监视 history 数组的变化,持久化存储到本地
history(newVal) {
window.localStorage.setItem('searchHistory', JSON.stringify(newVal))
}
}
在 data
中初始化 history
数组:
data() {
return {
// 搜索历史
history: JSON.parse(window.localStorage.getItem('searchHistory') || '[]')
}
}
为搜索建议的 item 项绑定 click
点击事件处理函数:
<!-- 搜索建议 -->
<div class="sugg-list" v-if="kw.length !== 0">
<div class="sugg-item" v-for="(item, i) in suggList" :key="i" v-html="item" @click="gotoSearchResult"></div>
</div>
为历史列表的 item 项绑定 click
点击事件处理函数:
<!-- 历史列表 -->
<div class="history-list">
<span class="history-item" v-for="(tag, i) in history" :key="i" @click="gotoSearchResult(tag)">{{tag}}</span>
</div>
在 Search.vue
组件中声明如下的 methods 处理函数:
// 点击搜索结果 Or 搜索历史,跳转到搜索结果页
gotoSearchResult(e) {
// 1. 获取到搜索关键字
const q = typeof e === 'string' ? e : e.target.innerText
// 2. 编程式导航 + 命名路由
this.$router.push({
// 2.1 路由名称
name: 'search-result',
// 2.2 路由参数
params: {
kw: q
}
})
}
渲染搜索结果页面的基本 DOM 结构:
<template>
<div class="search-result-container">
<!-- 点击实现后退效果 -->
<van-nav-bar title="搜索结果" left-arrow @click-left="$router.go(-1)" fixed />
</div>
</template>
<script>
export default {
name: 'SearchResult',
props: ['kw']
}
</script>
<style lang="less" scoped>
.search-result-container {
padding-top: 46px;
}
</style>
在 @/api/search.js
模块中封装 getSearchResult
方法:
// 根据关键词查询搜索结果列表的数据
export const getSearchResult = (q, page) => {
return axios.get('/v1_0/search', {
params: {
q,
page
}
})
}
在 SearchResult.vue
组件中,通过 van-list
组件实现上拉加载更多的效果:
<!-- 上拉加载更多 -->
<van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<!-- 文章信息组件 -->
<article-info v-for="item in searchResult" :key="item.art_id" :article="item"></article-info>
</van-list>
在 data
中声明对应的数据节点:
data() {
return {
// 页码值
page: 1,
// 搜索的结果
searchResult: [],
// 是否正在请求数据
loading: false,
// 数据是否已经加载完毕
finished: false
}
}
导入接口和 ArticleInfo.vue
组件:
// 导入 API 接口
import { getSearchResult } from '@/api/search'
// 导入组件
import ArticleInfo from '@/views/Home/ArticleInfo.vue'
// 注册组件
components: {
ArticleInfo
}
在 methods 中声明 onLoad
处理函数如下:
// 加载数据
async onLoad() {
// 请求数据
const { data: res } = await getSearchResult(this.kw, this.page)
// 判断是否请求成功
if (res.message === 'OK') {
// 拼接数据
this.searchResult = [...this.searchResult, ...res.data.results]
// 重置加载状态
this.loading = false
// 页码值 + 1
this.page += 1
// 判断数据是否加载完毕
if (res.data.results.length === 0) {
this.finished = true
}
}
}
在 ArticleInfo.vue
组件中,新增名为 closable
的 props 节点:
props: {
// 是否展示关闭按钮
closable: {
type: Boolean,
// 默认值为 true,表示展示关闭按钮
default: true
}
}
使用 v-if
动态控制关闭按钮的展示与隐藏:
<!-- 关闭按钮 -->
<van-icon name="cross" @click.stop="onCloseClick" v-if="closable" />
在 SearchResult.vue
组件中使用 ArticleInfo.vue
组件时,不展示关闭按钮:
<!-- 上拉加载更多 -->
<van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<!-- 文章信息组件 -->
<article-info v-for="item in searchResult" :key="item.art_id" :article="item" :closable="false"></article-info>
</van-list>
在 @/views/ArticleDetail
目录下,新建 ArticleDetail.vue
组件:
<template>
<div>
文章详情
</div>
</template>
<script>
export default {
name: 'ArticleDetail',
props: {
// 文章Id
artId: {
type: [String, Number],
required: true
}
}
}
</script>
<style lang="less" scoped>
</style>
在 @/router/index.js
路由模块中,声明详情页的路由规则:
// 导入文章详情页
import ArticleDetail from '@/views/ArticleDetail/ArticleDetail.vue'
const routes = [
// 省略其它代码...
// 文章详情页的路由规则
{
path: '/article/:artId',
component: ArticleDetail,
name: 'article-detail',
props: true // 开启路由的 props 传参
}
]
在文章列表页面和搜索结果页面,为 <article-info>
组件绑定 click
事件处理函数,通过编程式导航跳转到文章详情页:
<article-info v-for="item in articles" :key="item.art_id.toString()" :article="item" @remove-article="onArticleRemove" @click="gotoDetail(item.art_id.toString())"></article-info>
methods: {
// 跳转到文章详情页
gotoDetail(artId) {
// 编程式导航 + 命名路由
this.$router.push({
name: 'article-detail',
params: { // 导航参数
artId
}
})
}
}
在 ArticleInfo.vue
组件中,为最外层包裹性质的容器绑定 click
事件处理函数,通过 $emit()
触发自定义的 click
事件:
<div @click="$emit('click')">
<van-cell>
<!-- 省略其它代码... -->
</van-cell>
</div>
声明如下的 DOM 结构:
<template>
<div>
<!-- Header 区域 -->
<van-nav-bar fixed title="文章详情" left-arrow @click-left="$router.back()" />
<!-- 文章信息区域 -->
<div class="article-container">
<!-- 文章标题 -->
<h1 class="art-title">小程序</h1>
<!-- 用户信息 -->
<van-cell center title="张三" label="3天前">
<template #icon>
<img src="" alt="" class="avatar">
</template>
<template #default>
<div>
<van-button type="info" size="mini">已关注</van-button>
<van-button icon="plus" type="info" size="mini" plain>关注</van-button>
</div>
</template>
</van-cell>
<!-- 分割线 -->
<van-divider></van-divider>
<!-- 文章内容 -->
<div class="art-content">好好学习, 天天向上</div>
<!-- 分割线 -->
<van-divider>End</van-divider>
<!-- 点赞 -->
<div class="like-box">
<van-button icon="good-job" type="danger" size="small">已点赞</van-button>
<van-button icon="good-job-o" type="danger" plain size="small">点赞</van-button>
</div>
</div>
</div>
</template>
美化样式:
.article-container {
padding: 10px;
margin-top: 46px;
}
.art-title {
font-size: 16px;
font-weight: bold;
margin: 10px 0;
}
.art-content {
font-size: 12px;
line-height: 24px;
width: 100%;
overflow-x: scroll;
word-break: break-all;
}
.van-cell {
padding: 5px 0;
&::after {
display: none;
}
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #f8f8f8;
margin-right: 5px;
border: none;
}
.like-box {
display: flex;
justify-content: center;
}
### 8.3 请求并渲染文章详情
1. 在 `@/api` 目录下新建 `article.js` 模块:
```js
import axios from '@/utils/request'
// 获取文章详情数据
export const getArticleInfo = artId => {
return axios.get(`/v1_0/articles/${artId}`)
}
在 ArticleDetail.vue
组件中发起数据请求:
// 1. 按需导入 API 接口
import { getArticleInfo } from '@/api/article'
export default {
name: 'ArticleDetail',
props: {
// 文章Id
artId: {
type: [String, Number],
required: true
}
},
data() {
return {
// 2. 定义文章详情的数据
article: {}
}
},
// 4. 页面初次加载时,请求详情数据
created() {
this.getArticleDetail()
},
methods: {
// 3. 声明获取文章数据的方法
async getArticleDetail() {
const { data: res } = await getArticleInfo(this.artId)
if (res.message === 'OK') {
console.log(res)
this.article = res.data
}
}
}
}
渲染文章详情的数据:
<template>
<div>
<!-- Header 区域 -->
<van-nav-bar title="文章详情" left-arrow @click-left="$router.back()" />
<!-- 文章信息区域 -->
<div class="article-container">
<!-- 文章标题 -->
<h1 class="art-title">{{article.title}}</h1>
<!-- 用户信息 -->
<van-cell center :title="article.aut_name" :label="article.pubdate | dateFormat">
<template #icon>
<img :src="article.aut_photo" alt="" class="avatar">
</template>
<template #default>
<div>
<van-button type="info" size="mini" v-if="article.is_followed">已关注</van-button>
<van-button icon="plus" type="info" size="mini" plain v-else>关注</van-button>
</div>
</template>
</van-cell>
<!-- 分割线 -->
<van-divider></van-divider>
<!-- 文章内容 -->
<div class="art-content" v-html="article.content"></div>
<!-- 分割线 -->
<van-divider>End</van-divider>
<!-- 点赞 -->
<div class="like-box">
<van-button icon="good-job" type="danger" size="small" v-if="article.attitude === 1">已点赞</van-button>
<van-button icon="good-job-o" type="danger" plain size="small" v-else>点赞</van-button>
</div>
</div>
</div>
</template>
在 @/api/article.js
模块中声明如下两个接口:
// 关注用户
export const followUser = uid => {
return axios.post('/v1_0/user/followings', {
target: uid
})
}
// 取消关注用户
export const unfollowUser = uid => {
return axios.delete(`/v1_0/user/followings/${uid}`)
}
在 @/utils/request.js
模块中,修改 transBigInt
函数如下:
// 处理大数问题
const transBigInt = data => {
// 如果接口请求成功后没有响应任何数据,则直接返回空字符串
if (!data) return ''
try {
return bigInt.parse(data)
} catch {
return JSON.parse(data)
}
}
在 ArticleDetail.vue
组件中,为关注按钮绑定点击事件处理函数:
<van-button type="info" size="mini" v-if="article.is_followed" @click="setFollow(false)">已关注</van-button>
<van-button icon="plus" type="info" size="mini" plain v-else @click="setFollow(true)">关注</van-button>
按需导入相关的 API 方法,并定义 setFollow
方法如下:
// 1. 按需导入 API 接口
import { getArticleInfo, followUser, unfollowUser } from '@/api/article'
// 修改关注状态
async setFollow(followState) {
if (followState) {
// 调用关注的接口
await followUser(this.article.aut_id.toString())
this.$toast.success('关注成功!')
} else {
// 调用取消关注的接口
await unfollowUser(this.article.aut_id.toString())
this.$toast.success('取消关注成功!')
}
// 修改文章的状态
this.article.is_followed = followState
}
在 @/api/article.js
模块中定义如下的方法:
/**
* 点赞
* @param {*} id 文章Id
* @returns
*/
export const addLike = id => {
return axios.post('/v1_0/article/likings', {
target: id
})
}
/**
* 取消点赞
* @param {*} id 文章Id
* @returns
*/
export const delLike = id => {
return axios.delete(`/v1_0/article/likings/${id}`)
}
在 ArticleDetail.vue
中,为点赞按钮绑定点击事件处理函数:
<van-button icon="good-job" type="danger" size="small" v-if="article.attitude === 1" @click="setLike(false)">已点赞</van-button>
<van-button icon="good-job-o" type="danger" plain size="small" v-else @click="setLike(true)">点赞</van-button>
导入对应的 API 方法,并声明 setLike
方法如下:
// 1. 按需导入 API 接口
import {
getArticleInfo,
followUser,
unfollowUser,
addLike,
delLike
} from '@/api/article'
// 修改点赞状态
async setLike(likeState) {
if (likeState) {
// 调用点赞的接口
await addLike(this.article.art_id.toString())
this.$toast.success('点赞成功!')
} else {
// 调用取消点赞的接口
await delLike(this.article.art_id.toString())
this.$toast.success('取消点赞成功!')
}
this.article.attitude = likeState ? 1 : -1
}
在 @/views/ArticleDetail
目录下新建 ArtCmt.vue
组件:
<template>
<div>
<!-- 评论列表 -->
<div class="cmt-list">
<!-- 评论的 Item 项 -->
<div class="cmt-item">
<!-- 头部区域 -->
<div class="cmt-header">
<!-- 头部左侧 -->
<div class="cmt-header-left">
<img src="" alt="" class="avatar">
<span class="uname">zs</span>
</div>
<!-- 头部右侧 -->
<div class="cmt-header-right">
<van-icon name="like" size="16" color="red" />
<van-icon name="like-o" size="16" color="gray" />
</div>
</div>
<!-- 主体区域 -->
<div class="cmt-body">
基于字体的图标集,可以通过 Icon 组件使用,也可以在其他组件中通过 icon 属性引用。基于字体的图标集,可以通过 Icon 组件使用,也可以在其他组件中通过 icon 属性引用。
</div>
<!-- 尾部区域 -->
<div class="cmt-footer">3天前</div>
</div>
</div>
</div>
</template>
美化样式:
.cmt-list {
padding: 10px;
.cmt-item {
padding: 15px 0;
+ .cmt-item {
border-top: 1px solid #f8f8f8;
}
.cmt-header {
display: flex;
align-items: center;
justify-content: space-between;
.cmt-header-left {
display: flex;
align-items: center;
.avatar {
width: 40px;
height: 40px;
background-color: #f8f8f8;
border-radius: 50%;
margin-right: 15px;
}
.uname {
font-size: 12px;
}
}
}
.cmt-body {
font-size: 14px;
line-height: 28px;
text-indent: 2em;
margin-top: 6px;
word-break: break-all;
}
.cmt-footer {
font-size: 12px;
color: gray;
margin-top: 10px;
}
}
}
在 ArticleDetal.vue
组件中导入并使用 ArtCmt.vue
组件:
// 导入组件
import ArtCmt from './ArtCmt.vue'
// 注册组件
components: {
ArtCmt
}
<!-- 使用文章评论组件 -->
<art-cmt></art-cmt>
带有评论的文章链接地址:http://localhost:8080/#/article/1323570687952027648
在 @/api/article.js
中定义获取评论数据的接口:
// 获取文章的评论列表
export const getCmtList = (artId, offset) => {
return axios.get('/v1_0/comments', {
params: {
// a表示对文章的评论 ,c表示对评论的回复
type: 'a',
// 文章的 Id
source: artId,
// 获取评论数据的偏移量,值为评论id,表示从此id的数据向后取,不传表示从第一页开始读取数据
offset
}
})
}
在 ArtCmt.vue
组件中声明文章 Id 的 props:
props: {
// 文章的 Id
artId: {
type: [String, Number],
required: true
}
},
在 ArticleDetail.vue
组件中使用 <art-cmt>
组件时,把文章 Id 传递到评论组件中:
<!-- 文章评论组件 -->
<art-cmt :artId="article.art_id.toString()" v-if="article.art_id"></art-cmt>
在 ArtCmt.vue
组件的 data 中声明如下的数据:
data() {
return {
// 偏移量
offset: null,
// 是否正在加载数据
loading: false,
// 数据是否加载完毕了
finished: false,
// 评论列表的数据
cmtlist: []
}
},
将 class="cmt-list"
的 div 替换为 <van-list>
组件:
<!-- 评论列表 -->
<van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad" class="cmt-list">
</van-list>
定义 onLoad
函数如下:
methods: {
// 触发了加载数据的事件
async onLoad() {
const { data: res } = await getCmtList(this.artId, this.offset)
if (res.message === 'OK') {
// 为偏移量赋值
this.offset = res.data.last_id
// 为评论列表数据赋值
this.cmtlist = [...this.cmtlist, ...res.data.results]
// 重置 loading 和 finished
this.loading = false
if (res.data.results.length === 0) {
this.finished = true
}
}
}
}
在 @/api/article.js
模块中定义如下两个 API 接口:
// 评论点赞
export const addCmtLike = cmtId => {
return axios.post('/v1_0/comment/likings', {
target: cmtId
})
}
// 评论取消点赞
export const removeCmtLike = cmtId => {
return axios.delete(`/v1_0/comment/likings/${cmtId}`)
}
为点赞和取消点赞的图片绑定点击事件处理函数:
<van-icon name="like" size="16" color="red" v-if="item.is_liking" @click="setLike(false, item)" />
<van-icon name="like-o" size="16" color="gray" v-else @click="setLike(true, item)" />
按需导入评论点赞的 API 方法:
import { getCmtList, addCmtLike, removeCmtLike } from '@/api/article'
在 methods
中声明 setLike
方法如下:
// 切换评论的点赞与取消点赞
async setLike(likeState, cmt) {
// 获取评论的 Id
const cmtId = cmt.com_id.toString()
if (likeState) {
// 点赞
await addCmtLike(cmtId)
this.$toast.success('点赞成功!')
} else {
// 取消点赞
await removeCmtLike(cmtId)
this.$toast.success('取消点赞成功!')
}
// 切换当前评论的点赞状态
cmt.is_liking = likeState
}
渲染基本的 DOM 结构:
<!-- 底部添加评论区域 - 1 -->
<div class="add-cmt-box van-hairline--top">
<van-icon name="arrow-left" size="18" @click="$router.back()" />
<div class="ipt-cmt-div">发表评论</div>
<div class="icon-box">
<van-badge :content="cmtCount ? cmtCount : ''" :max="99">
<van-icon name="comment-o" size="20" />
</van-badge>
<van-icon name="star-o" size="20" />
<van-icon name="share-o" size="20" />
</div>
</div>
<!-- 底部添加评论区域 - 2 -->
<div class="cmt-box van-hairline--top">
<textarea placeholder="友善评论、理性发言、阳光心灵"></textarea>
<van-button type="default" disabled>发布</van-button>
</div>
在 data
中定义 cmtCount
的值:
data() {
return {
// 评论数量
cmtCount: 0
}
},
在请求数据的方法中,为 cmtCount
赋值:
// 触发了加载数据的事件
async onLoad() {
const { data: res } = await getCmtList(this.artId, this.offset)
console.log(res)
if (res.message === 'OK') {
this.offset = res.data.last_id
// 为评论数量赋值
this.cmtCount = res.data.total_count
this.cmtlist = [...this.cmtlist, ...res.data.results]
this.loading = false
if (res.data.results.length === 0) {
this.finished = true
}
}
},
美化样式:
// 外层容器
.art-cmt-container-1 {
padding-bottom: 46px;
}
.art-cmt-container-2 {
padding-bottom: 80px;
}
// 发布评论的盒子 - 1
.add-cmt-box {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
box-sizing: border-box;
background-color: white;
display: flex;
justify-content: space-between;
align-items: center;
height: 46px;
line-height: 46px;
padding-left: 10px;
.ipt-cmt-div {
flex: 1;
border: 1px solid #efefef;
border-radius: 15px;
height: 30px;
font-size: 12px;
line-height: 30px;
padding-left: 15px;
margin-left: 10px;
background-color: #f8f8f8;
}
.icon-box {
width: 40%;
display: flex;
justify-content: space-evenly;
line-height: 0;
}
}
.child {
width: 20px;
height: 20px;
background: #f2f3f5;
}
// 发布评论的盒子 - 2
.cmt-box {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 80px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
padding-left: 10px;
box-sizing: border-box;
background-color: white;
textarea {
flex: 1;
height: 66%;
border: 1px solid #efefef;
background-color: #f8f8f8;
resize: none;
border-radius: 6px;
padding: 5px;
}
.van-button {
height: 100%;
border: none;
}
}
在 data
中定义布尔值 isShowCmtInput
用来控制 textarea 的显示与隐藏:
data() {
return {
// 是否展示评论的输入框
isShowCmtInput: false
}
}
使用 v-if 和 v-else 指令控制两个区域的按需展示:
<!-- 底部添加评论区域 - 1 -->
<div class="add-cmt-box van-hairline--top" v-if="!isShowCmtInput"></div>
<!-- 底部添加评论区域 - 2 -->
<div class="cmt-box van-hairline--top" v-else></div>
点击发表评论的 div,展示 textarea 所在的盒子:
<div class="ipt-cmt-div" @click="showTextarea">发表评论</div>
// 点击发表评论的 div, 展示 textarea 所在的盒子
showTextarea() {
this.isShowCmtInput = true
// 让文本框自动获得焦点
this.$nextTick(() => {
this.$refs.cmtIpt.focus()
})
},
在 textarea 失去焦点时,重置布尔值为 false:
<textarea placeholder="友善评论、理性发言、阳光心灵" ref="cmtIpt" @blur="onCmtIptBlur"></textarea>
// 文本框失去焦点
onCmtIptBlur() {
this.isShowCmtInput = false
}
动态控制 ArtCmt.vue
组件外层容器底部的 padding 距离:
<div :class="isShowCmtInput ? 'art-cmt-container-2' : 'art-cmt-container-1'"></div>
为评论的小图标绑定 click 点击事件处理函数:
<!-- 评论的小图标 -->
<van-badge :content="cmtCount ? cmtCount : ''" :max="99">
<van-icon name="comment-o" size="20" @click="scrollToCmtList" />
</van-badge>
在 methods
中声明 scrollToCmtList
函数如下:
// 实现滚动条平滑滚动的方法
scrollToCmtList() {
// 1.1 返回文档在垂直方向已滚动的像素值
const now = window.scrollY
// 1.2 目标位置(文章信息区域的高度)
let dist = document.querySelector('.article-container').offsetHeight
// 1.3 可滚动高度 = 整个文档的高度 - 浏览器窗口的视口(viewport)高度
const avalibleHeight = document.documentElement.scrollHeight - window.innerHeight
// 2.1 如果【目标高度】 大于 【可滚动的高度】
if (dist > avalibleHeight) {
// 2.2 就把目标高度,设置为可滚动的高度
dist = avalibleHeight
}
// 3. 动态计算出步长值
const step = (dist - now) / 10
setTimeout(() => {
// 4.2 如果当前的滚动的距离不大于 1px,则直接滚动到目标位置,并退出递归
if (Math.abs(step) <= 1) {
return window.scrollTo(0, dist)
}
// 4.1 每隔 10ms 执行一次滚动,并递归地进行下一次的滚动
window.scrollTo(0, now + step)
this.scrollToCmtList()
}, 10)
}
在 @/api/article.js
模块中定义如下的 API 方法:
// 对文章发表评论
export const pubComment = (artId, content) => {
return axios.post('/v1_0/comments', {
target: artId, // 文章的 id
content // 评论的内容
})
}
在 ArtCmt.vue
组件中按需导入 API 方法:
import {
getCmtList,
addCmtLike,
removeCmtLike,
pubComment
} from '@/api/article'
为输入框添加 v-model.trim
的双向数据绑定:
<textarea placeholder="友善评论、理性发言、阳光心灵" ref="cmtIpt" v-model.trim="cmt"></textarea>
data() {
return {
// 评论内容
cmt: ''
}
}
动态控制发布按钮的禁用状态:
<van-button type="default" :disabled="cmt.length === 0">发布</van-button>
修改输入框的 blur
事件处理函数:
<textarea placeholder="友善评论、理性发言、阳光心灵" ref="cmtIpt" @blur="onCmtIptBlur" v-model.trim="cmt"></textarea>
// 文本框失去焦点
onCmtIptBlur() {
// 延迟隐藏的操作,否则无法触发按钮的 click 事件处理函数
setTimeout(() => {
this.isShowCmtInput = false
})
},
为发布文章的按钮绑定点击事件处理函数:
<van-button type="default" :disabled="cmt.length === 0" @click="pubCmt">发布</van-button>
声明 pubCmt
事件处理函数:
// 发布新评论
async pubCmt() {
// 转存评论内容
const cmt = this.cmt
// 清空评论文本框
this.cmt = ''
// 隐藏输入区域
this.isShowCmtInput = false
// 发布评论
const { data: res } = await pubComment(this.artId, cmt)
if (res.message === 'OK') {
// 更新评论数据(头部插入)
this.cmtlist = [res.data.new_obj, ...this.cmtlist]
// 提示成功
this.$toast.success('评论成功!')
// 总评论数 +1
this.cmtCount++
}
}
声明个人中心页面的基本 DOM 结构:
<template>
<div class="user-container">
<!-- 用户基本信息面板 -->
<div class="user-card">
<!-- 用户头像、姓名 -->
<van-cell>
<!-- 使用 title 插槽来自定义标题 -->
<template #icon>
<img src="" alt="" class="avatar">
</template>
<template #title>
<span class="username">用户名</span>
</template>
<template #label>
<van-tag color="#fff" text-color="#007bff">申请认证</van-tag>
</template>
</van-cell>
<!-- 动态、关注、粉丝 -->
<div class="user-data">
<div class="user-data-item">
<span>0</span>
<span>动态</span>
</div>
<div class="user-data-item">
<span>0</span>
<span>关注</span>
</div>
<div class="user-data-item">
<span>0</span>
<span>粉丝</span>
</div>
</div>
</div>
<!-- 操作面板 -->
<van-cell-group class="action-card">
<van-cell icon="edit" title="编辑资料" is-link />
<van-cell icon="chat-o" title="小思同学" is-link />
<van-cell icon="warning-o" title="退出登录" is-link />
</van-cell-group>
</div>
</template>
美化样式:
.user-container {
.user-card {
background-color: #007bff;
color: white;
padding-top: 20px;
.van-cell {
background: #007bff;
color: white;
&::after {
display: none;
}
.avatar {
width: 60px;
height: 60px;
background-color: #fff;
border-radius: 50%;
margin-right: 10px;
}
.username {
font-size: 14px;
font-weight: bold;
}
}
}
.user-data {
display: flex;
justify-content: space-evenly;
align-items: center;
font-size: 14px;
padding: 30px 0;
.user-data-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 33.33%;
}
}
}
在 @/api/user.js
模块中,定义获取用户信息的 API 接口:
// 获取用户的基本信息
export const getUserInfo = () => {
return axios.get('/v1_0/user')
}
在 User.vue
组件中调用接口,获取用户的基本信息:
// 按需导入 API 接口
import { getUserInfo } from '@/api/user'
export default {
name: 'User',
data() {
return {
// 用户的基本信息
user: {}
}
},
created() {
// 在组件初始化的时候,请求用户信息
this.initUserInfo()
},
methods: {
// 初始化用户的基本信息
async initUserInfo() {
const { data: res } = await getUserInfo()
if (res.message === 'OK') {
console.log(res)
this.user = res.data
}
}
}
}
渲染用户的基本信息:
<!-- 用户基本信息面板 -->
<div class="user-card">
<!-- 用户头像、姓名 -->
<van-cell>
<!-- 使用 title 插槽来自定义标题 -->
<template #icon>
<img :src="user.photo" alt="" class="avatar">
</template>
<template #title>
<span class="username">{{user.name}}</span>
</template>
<template #label>
<van-tag color="#fff" text-color="#007bff">申请认证</van-tag>
</template>
</van-cell>
<!-- 动态、关注、粉丝 -->
<div class="user-data">
<div class="user-data-item">
<span>{{user.art_count}}</span>
<span>动态</span>
</div>
<div class="user-data-item">
<span>{{user.follow_count}}</span>
<span>关注</span>
</div>
<div class="user-data-item">
<span>{{user.fans_count}}</span>
<span>粉丝</span>
</div>
</div>
</div>
为了方便在多个页面之间共享用户的信息,可以把用户的信息存储到 vuex 中
在 vuex 的 state 节点下,声明 user
数据节点:
// 初始的 state 数据
let initState = {
// 登录成功之后的 token 信息
tokenInfo: {},
// 用户的基本信息
user: {}
}
在 mutaions
节点下新增 updateUserInfo
函数:
// 更新用户的基本信息
updateUserInfo(state, payload) {
state.user = payload
this.commit('saveToStorage')
}
在 User.vue
组件中按需导入 vuex 的辅助函数:
import { mapState, mapMutations } from 'vuex'
注释掉 data
节点下的 user
节点,并使用 mapState
把数据映射为 computed 计算属性:
export default {
name: 'User',
data() {
return {
// 用户的信息对象
// user: {}
}
},
computed: {
// 映射需要的 state 数据
...mapState(['user'])
},
}
使用 mapMutations
把方法映射为当前组件的 methods
处理函数,并进行调用:
methods: {
// 1. 映射需要的 mutations 方法
...mapMutations(['updateUserInfo']),
// 初始化用户的基本信息
async initUserInfo() {
const { data: res } = await getUserInfo()
if (res.message === 'OK') {
console.log(res)
// 2. 注释掉下面这一行,不再把数据存储到当前组件的 data 中
// this.user = res.data
// 3. 把数据存储到 vuex 中
this.updateUserInfo(res.data)
}
}
}
为了防止 user
为空对象时导致的数据渲染失败问题,可以为 User.vue
最外层的 div 元素添加 v-if
指令的判断:
<template>
<div class="user-container" v-if="user.name">
<!-- 省略其它代码... -->
</div>
</template>
为退出登录的 van-cell
组件绑定 click
点击事件处理函数:
<van-cell icon="warning-o" title="退出登录" is-link @click="logout" />
在 vuex 的 mutations 中定义 cleanState
方法:
// 清空 state 中的关键数据
cleanState(state) {
state.tokenInfo = {}
state.user = {}
// 清空本地存储
window.localStorage.clear()
}
在 User.vue
组件中通过 mapMutations
辅助函数,把 cleanState
映射到当前组件中:
methods: {
// 映射需要的 mutations 方法
...mapMutations(['updateUserInfo', 'cleanState']),
}
在 User.vue
组件的 methods 中声明 logout
方法如下:
// 退出登录
async logout() {
// 1. 询问用户是否退出登录
const confirmResult = await this.$dialog
.confirm({
title: '提示',
message: '确认退出登录吗?'
})
.catch(err => err)
// 2. 用户取消了退出登录
if (confirmResult === 'cancel') return
// 3. 执行退出登录的操作
// 3.1 调用 mutations 中的方法,清空 vuex 中的数据
this.cleanState()
// 3.2 跳转到登录页面
this.$router.push('/login')
}
在 @/views/User
目录下新建 UserEdit.vue
组件:
<template>
<div class="user-edit-container">
<!-- Header 区域 -->
<van-nav-bar title="编辑资料" left-arrow @click-left="$router.back()" fixed />
</div>
</template>
<script>
export default {
name: 'UserEdit'
}
</script>
<style lang="less" scoped>
.user-edit-container {
padding-top: 46px;
}
</style>
在 @/router/index.js
路由模块中导入 UserEdit.vue
组件并声明对应的路由规则:
// 导入编辑用户信息的组件
import UserEdit from '@/views/User/UserEdit.vue'
const routes = [
// 编辑用户信息的路由规则
{
path: '/user/edit',
component: UserEdit,
name: 'user-edit'
}
]
在 User.vue
组件中,为编辑资料对应的 van-cell
组件添加 to
属性绑定:
<!-- 通过命名路由实现导航跳转 -->
<van-cell icon="edit" title="编辑资料" is-link :to="{name: 'user-edit'}" />
在 @/api/user.js
模块中定义如下的 API 方法:
// 获取用户的简介信息
export const getProfile = () => {
return axios.get('/v1_0/user/profile')
}
在 @/store/index.js
模块中定义 profile
节点来存放用户的简介,并提供更新 profile
的 mutations 方法:
// 初始的 state 数据
let initState = {
// 登录成功之后的 token 信息
tokenInfo: {},
// 用户的基本信息
user: {},
// 用户的简介
profile: {}
}
export default new Vuex.Store({
state: initState,
mutations: {
// 更新用户的简介信息
updateProfile(state, payload) {
state.profile = payload
this.commit('saveToStorage')
},
// 清空 state 中的关键数据
cleanState(state) {
state.tokenInfo = {}
state.user = {}
state.profile = {}
// 清空本地存储
window.localStorage.clear()
}
}
})
在 UserEdit.vue
组件中请求用户的简介信息:
// 1. 按需导入 API 和辅助函数
import { getProfile } from '@/api/user'
import { mapState, mapMutations } from 'vuex'
export default {
name: 'UserEdit',
// 2. 页面首次被加载时请求用户的简介
created() {
this.getUserProfile()
},
computed: {
// 2.1 映射数据
...mapState(['profile'])
},
methods: {
// 2.2 映射方法
...mapMutations(['updateProfile']),
// 3. 获取用户的简介信息
async getUserProfile() {
const { data: res } = await getProfile()
if (res.message === 'OK') {
console.log(res)
this.updateProfile(res.data)
}
}
}
}
渲染用户的基本资料:
<template>
<div class="user-edit-container">
<!-- Header 区域 -->
<van-nav-bar title="编辑资料" left-arrow @click-left="$router.back()" fixed />
<!-- 用户资料 -->
<van-cell-group class="action-card">
<van-cell title="头像" is-link center>
<template #default>
<van-image round class="avatar" :src="profile.photo" />
</template>
</van-cell>
<van-cell title="名称" is-link :value="profile.name" />
<van-cell title="性别" is-link :value="profile.gender === 1 ? '男' : '女'" />
<van-cell title="生日" is-link :value="profile.birthday" />
</van-cell-group>
</div>
</template>
美化样式:
.user-edit-container {
padding-top: 46px;
.avatar {
width: 50px;
height: 50px;
}
}
在 data
中声明如下的数据:
data() {
return {
// 是否展示修改用户名的对话框
isShowNameDialog: false,
// 名称
username: ''
}
}
渲染对话框的基本 DOM 结构:
<!-- 修改用户名称的对话框 -->
<van-dialog v-model="isShowNameDialog" title="修改名称" show-cancel-button :before-close="onNameDialogBeforeClose" @closed="username = ''">
<!-- 输入框 -->
<van-field v-model.trim="username" input-align="center" maxlength="7" placeholder="请输入名称" autofocus ref="unameRef" />
</van-dialog>
为名称对应的 van-cell
绑定 click 事件处理函数:
<van-cell title="名称" is-link :value="profile.name" @click="showNameDialog" />
定义 showNameDialog
方法:
// 展示修改名称的对话框
showNameDialog() {
// 显示修改之前的旧名称
this.username = this.profile.name
this.isShowNameDialog = true
// 让对话框中的文本框自动获得焦点
this.$nextTick(() => {
this.$refs.unameRef.focus()
})
},
定义对话框 before-close
时对应的处理函数:
// 用户名对话框 - 关闭之前
onNameDialogBeforeClose(action, done) {
// 1. 取消
if (action !== 'confirm') {
done()
return
}
// 2. 确认
if (this.username.length === 0 || this.username.length > 7) {
// 长度不合法
this.$notify({
type: 'warning',
message: '名称的长度为1-7个字符',
duration: 2000
})
done(false)
return
}
// 3. TODO:发起请求修改名称
done(false)
}
在 @/api/user.js
模块下新增 API:
// 修改姓名,生日,性别都使用此接口,修改传参即可
export const updateProfile = data => {
return axios.patch('/v1_0/user/profile', data)
}
修改 onNameDialogBeforeClose
方法,预调用 updateUserProfile
方法:
import { getProfile, updateProfile } from '@/api/user'
// 用户名对话框 - 关闭之前
onNameDialogBeforeClose(action, done) {
// 省略其它代码...
// 3. TODO:发起请求修改名称
this.updateUserProfile(
{ name: this.username },
'名称被占用,请更换后重试!',
done
)
}
定义 updateUserProfile
方法如下:
// 更新用户简介的方法
async updateUserProfile(data, errMsg, done) {
try {
// 3.1 发起请求,更新数据库
const { data: res } = await updateProfile(data)
if (res.message === 'OK') {
// 重新请求用户的数据
this.getUserProfile()
// 提示用户成功
this.$toast.success('修改成功!')
// 关闭对话框
done && done()
}
} catch {
// 3.2 如果网络请求失败,则对用户进行友好提示
this.$notify({
type: 'warning',
message: errMsg,
duration: 2000
})
done && done(false)
}
}
在 UserEdit.vue
组件的 data 中定义如下的数据:
data() {
// 是否展示选择出生日期的 ActionSheet
isShowBirth: false,
// 最小的可选的日期
minDate: new Date(1900, 0, 1),
// 最大的可选日期
maxDate: new Date(2030, 10, 1),
// 当前日期
currentDate: new Date()
}
基于 van-action-sheet
和 van-datetime-picker
渲染修改生日的 DOM 结构:
<!-- 修改时间 -->
<van-action-sheet v-model="isShowBirth">
<!-- 日期选择控件 -->
<van-datetime-picker v-model="currentDate" type="date" title="选择出生日期" :min-date="minDate" :max-date="maxDate" :show-toolbar="true" @cancel="onPickerCancel" @confirm="onPickerConfirm" />
</van-action-sheet>
点击生日的 van-cell
展示日期选择控件:
<van-cell title="生日" is-link :value="profile.birthday" @click="isShowBirth = true" />
定义 onPickerCancel
和 onPickerConfirm
方法如下:
// 日期控件 - 取消
onPickerCancel() {
this.isShowBirth = false
},
// 日期控件 - 确认
onPickerConfirm(value) {
// 1. 隐藏选择日期的 ActionSheet
this.isShowBirth = false
// 2. 格式化时间
const dt = new Date(value)
const y = dt.getFullYear()
const m = (dt.getMonth() + 1).toString().padStart(2, '0')
const d = dt.getDate().toString().padStart(2, '0')
const dtStr = `${y}-${m}-${d}`
// 3. 更新出生日期
this.updateUserProfile({ birthday: dtStr }, '更新生日失败,请稍后再试!')
}
借助于 file 文件选择框,实现更新用户头像的功能
在 @/api/user.js
模块中定义如下的 API 接口:
// 更新用户的头像
export const updateUserPhoto = fd => {
return axios.patch('/v1_0/user/photo', fd)
}
在 UserEdit.vue
组件中按需导入 updateUserPhoto
的 API 方法:
import { getProfile, updateProfile, updateUserPhoto } from '@/api/user'
在头像对应的 van-cell
组件中,添加隐藏的 input:file
文件选择框:
<van-cell title="头像" is-link center>
<template #default>
<van-image round class="avatar" :src="profile.photo" @click="choosePhoto" />
<!-- file 选择框 -->
<input type="file" ref="iptFile" v-show="false" accept="image/*" @change="onFileChange" />
</template>
</van-cell>
定义 choosePhoto
事件处理函数如下:
// 选择头像的照片
choosePhoto() {
// 模拟点击操作
this.$refs.iptFile.click()
},
定义 onFileChange
事件处理函数如下:
// 文件选择框的选中项发生了变化
async onFileChange(e) {
// 1. 获取选中的文件列表
const files = e.target.files
// 2. 判断选中的个数是否为 0
if (files.length === 0) return
// 3.1 创建 FormData 实例
const fd = new FormData()
// 3.2 添加用户的头像
fd.append('photo', files[0])
// 4.1 调用接口
const { data: res } = await updateUserPhoto(fd)
if (res.message === 'OK') {
// 4.2 重新拉取数据
this.getUserProfile()
}
}
和 http 协议类似,websocket 也是是一个网络通信协议,是用来满足前后端数据通信的。
HTTP 协议:客户端与服务器建立通信连接之后,服务器端只能被动地响应客户端的请求,无法主动给客户端发送消息。
websocket 协议:客户端与服务器建立通信连接之后,服务器端可以主动给客户端推送消息了!!!
需要服务端主动向客户端发送数据的场景,比如我们现在要做的智能聊天
<img alt="">
在 @/views/Chat
目录下新建 Chat.vue
组件:
<template>
<div class="container">
<!-- 固定导航 -->
<van-nav-bar fixed left-arrow @click-left="$router.back()" title="小思同学"></van-nav-bar>
<!-- 聊天主体区域 -->
<div class="chat-list">
<!-- 左侧是机器人小思 -->
<div class="chat-item left">
<van-image fit="cover" round src="https://img.yzcdn.cn/vant/cat.jpeg" />
<div class="chat-pao">hi,你好!我是小思</div>
</div>
<!-- 右侧是当前用户 -->
<div class="chat-item right">
<div class="chat-pao">我是编程小王子</div>
<van-image fit="cover" round src="https://img.yzcdn.cn/vant/cat.jpeg" />
</div>
</div>
<!-- 对话区域 -->
<div class="reply-container van-hairline--top">
<van-field v-model.trim="word" placeholder="说点什么...">
<template #button>
<span @click="send()" style="font-size:12px;color:#999">提交</span>
</template>
</van-field>
</div>
</div>
</template>
<script>
export default {
name: 'Chat',
data() {
return {
word: ''
}
},
methods: {
send() {
if (!this.word) return
console.log(this.word)
}
}
}
</script>
<style lang="less" scoped>
.container {
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
background: #fafafa;
padding: 46px 0 50px 0;
.chat-list {
height: 100%;
overflow-y: scroll;
.chat-item {
padding: 10px;
.van-image {
vertical-align: top;
width: 40px;
height: 40px;
}
.chat-pao {
vertical-align: top;
display: inline-block;
min-width: 40px;
max-width: 70%;
min-height: 40px;
line-height: 38px;
border: 0.5px solid #c2d9ea;
border-radius: 4px;
position: relative;
padding: 0 10px;
background-color: #e0effb;
word-break: break-all;
font-size: 14px;
color: #333;
&::before {
content: '';
width: 10px;
height: 10px;
position: absolute;
top: 12px;
border-top: 0.5px solid #c2d9ea;
border-right: 0.5px solid #c2d9ea;
background: #e0effb;
}
}
}
}
}
.chat-item.right {
text-align: right;
.chat-pao {
margin-left: 0;
margin-right: 15px;
&::before {
right: -6px;
transform: rotate(45deg);
}
}
}
.chat-item.left {
text-align: left;
.chat-pao {
margin-left: 15px;
margin-right: 0;
&::before {
left: -5px;
transform: rotate(-135deg);
}
}
}
.reply-container {
position: fixed;
left: 0;
bottom: 0;
height: 44px;
width: 100%;
background: #f5f5f5;
z-index: 9999;
}
</style>
在 @/router/index.js
路由模块中,导入组件并声明小思聊天的路由规则:
// 导入小思同学的组件页面
import Chat from '@/views/Chat/Chat.vue'
const routes = [
// 小思聊天的路由规则
{
path: '/chat',
component: Chat,
name: 'chat'
}
]
在 @/views/User/User.vue
组件中,为小思同学对应的 van-cell
组件添加 to
属性:
<van-cell icon="chat-o" title="小思同学" is-link to="/chat" />
在 data 中声明 list
数组,用来存放机器人和用户的聊天消息内容:
data() {
return {
// 用户填写的内容
word: '',
// 所有的聊天消息
list: [
// 1. 只根据 name 属性,即可判断出这个消息应该渲染到左侧还是右侧
{ name: 'xs', msg: 'hi,你好!我是小思' },
{ name: 'me', msg: '我是编程小王子' }
]
}
},
动态渲染聊天消息:
<!-- 聊天主体区域 -->
<div class="chat-list">
<!-- 左侧是机器人小思 -->
<div v-for="(item, index) in list" :key="index">
<div class="chat-item left" v-if="item.name === 'xs'">
<van-image fit="cover" round src="https://img.yzcdn.cn/vant/cat.jpeg" />
<div class="chat-pao">{{item.msg}}</div>
</div>
<!-- 右侧是当前用户 -->
<div class="chat-item right" v-else>
<div class="chat-pao">{{item.msg}}</div>
<van-image fit="cover" round src="https://img.yzcdn.cn/vant/cat.jpeg" />
</div>
</div>
</div>
动态渲染用户的头像:
// 1. 按需导入辅助函数
import { mapState } from 'vuex'
computed: {
// 2. 把用户的信息,映射为当前组件的计算属性
...mapState(['profile'])
}
<!-- 3. 动态绑定用户头像 -->
<van-image fit="cover" round :src="profile.photo || 'https://img.yzcdn.cn/vant/cat.jpeg'" />
用户点击按钮,把消息存储到 list
数组中:
methods: {
send() {
// 1. 判断内容是否为空
if (!this.word) return
// 2. 添加聊天消息到 list 列表中
this.list.push({
name: 'me',
msg: this.word
})
// 3. 清空文本框的内容
this.word = ''
}
},
安装 websocket 客户端相关的包:
npm i socket.io-client@4.0.0 -S
# 如果 npm 无法成功安装 socket.io-client,可以尝试用 yarn 来装包
在 Chat.vue
组件中,导入 socket.io-client
模块:
// 1.1 导入 socket.io-client 包
import { io } from 'socket.io-client'
// 1.2 定义变量,存储 websocket 实例
let socket = null
在 Chat.vue
组件的 created 生命周期函数中,创建 websocket 实例对象:
created() {
// 2. 创建客户端 websocket 的实例
socket = io('ws://www.liulongbin.top:9999')
}
在 Chat.vue
组件的 beforeDestroy 生命周期函数中,关闭 websocket 连接并销毁 websocket 实例对象:
// 组件被销毁之前,清空 sock 对象
beforeDestroy() {
// 关闭连接
socket.close()
// 销毁 websocket 实例对象
socket = null
},
在 created
生命周期函数中,监听 websocket 实例对象的 connect
、message
、disconnect
事件:
created() {
// 创建客户端 websocket 的实例
socket = io('ws://www.liulongbin.top:9999')
// 建立连接的事件
socket.on('connect', () => {
console.log('connect')
})
// 接收到消息的事件
socket.on('message', msg => {
// 把服务器发送过来的消息,存储到 list 数组中
this.list.push({
name: 'xs',
msg
})
})
// 关闭的事件
socket.on('disconnect', () => {
console.log('disconnect')
})
},
在 message
事件中,把服务器发送到客户端的消息,存储到 list
数组中:
// 接收到消息的事件
socket.on('message', msg => {
// 把服务器发送过来的消息,存储到 list 数组中
this.list.push({
name: 'xs',
msg
})
})
客户端调用 socket.emit('send', 消息内容)
方法把消息发送给 websocket 服务器:
// 向服务端发送消息
send() {
// 判断内容是否为空
if (!this.word) return
// 添加聊天消息到 list 列表中
this.list.push({
name: 'me',
msg: this.word
})
// 把消息发送给 websocket 服务器
socket.emit('send', this.word)
// 清空文本框的内容
this.word = ''
}
https://developer.mozilla.org/zh-CN/docs/web/api/element/scrollintoview
在 methods
中声明 scrollToBottom
方法:
// 滚动到页面底部
scrollToBottom() {
// 获取到所有的聊天 Item 项
const chatItem = document.querySelectorAll('.chat-item')
// 获取到最后一项对应的 DOM 元素
const lastItem = chatItem[chatItem.length - 1]
// 调用 scrollIntoView() 方法,显示这个元素
lastItem.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'nearest'
})
}
在 Chat.vue
组件中定义 watch
侦听器,监视 list
数组的变化,从而自动滚动到页面底部:
watch: {
list() {
// 监视到 list 数据变化后,等下次 DOM 更新完毕,再执行滚动到底部的操作
this.$nextTick(() => {
this.scrollToBottom()
})
}
},
在 @/router/index.js
模块中声明全局前置导航守卫:
// 导入 store 模块,方便拿到 store 中的数据
import store from '@/store/index'
// 导航守卫
router.beforeEach((to, from, next) => {
const tokenInfo = store.state.tokenInfo // {token, refresh_token}
// 访问的是有权限的页面
if (to.path === '/user') {
if (!tokenInfo.token) {
// token 的值不存在,强制跳转到登录页
next('/login?pre=' + to.fullPath)
} else {
// token 的值存在,放行
next()
}
} else {
// 访问的是普通页面
next()
}
})
在 Login.vue
组件中,修改 onSubmit
方法如下:
async onSubmit() {
const { data: res } = await login(this.formLogin)
if (res.message === 'OK') {
this.updateTokenInfo(res.data)
// 1. 判断是否携带了 pre 参数
if (this.$route.query.pre) {
// 1.1 如果有,则跳转到指定页面
this.$router.push(this.$route.query.pre)
} else {
// 1.2 如果没有,则跳转到 / 主页
this.$router.push('/')
}
}
}
在路由导航守卫中添加如下的判断条件:
else if (to.path === '/login') {
if (!tokenInfo.token) {
next()
} else {
next(false)
}
}
两种主流方案:
- 只要发现 Token 过期,则强制用户跳转到登录页,并清空本地和 Store 中的关键数据!
- 如果发现 Token 过期,则自动基于 refresh_token 无感知地请求一个新 Token 回来,在替换掉旧 Token 的同时,继续上次未完成的请求!
在 @/utils/request.js
模块中,导入 Store 和 Router 模块:
import store from '@/store/index'
import router from '@/router/index'
在 axios 的 instance
实例上声明响应拦截器,如果身份认证失败,则强制用户跳转到登录页:
// 响应拦截器
instance.interceptors.response.use(
response => {
// 响应成功
return response
},
error => {
// 响应失败时,处理未授权的情况:
// 1. 判断是否为未授权(Token 过期)
if (error.response && error.response.status === 401) {
// 2. 清空 Store 中的关键数据
store.commit('cleanState')
// 3. 跳转到登录页
router.push('/login?pre=' + router.currentRoute.fullPath)
}
return Promise.reject(error)
}
)
在 @/utils/request.js
模块中,导入 Store 和 Router 模块:
import store from '@/store/index'
import router from '@/router/index'
在 axios 的 instance
实例上声明响应拦截器,如果身份认证失败,则根据 refresh_token
重新请求一个有效的新 Token 回来:
// 响应拦截器
instance.interceptors.response.use(
response => {
return response
},
async error => {
// 1. 从 vuex 中获取 token 对象
const tokenInfo = store.state.tokenInfo
// 2. 判断是否为未授权(Token 过期)
if (error.response && error.response.status === 401 && tokenInfo.token) {
try {
// 3.1 TODO: 发起请求,根据 refresh_token 重新请求一个有效的新 token
// 3.2 TODO: 更新 Store 中的 Token
// 3.3 基于上次未完成的配置,重新发起请求
return instance(error.config)
} catch {
// 4. 证明 refresh_token 也失效了:
// 4.1 则清空 Store 中的关键数据
store.commit('cleanState')
// 4.2 并强制跳转到登录页
router.push({
path: '/login?pre=' + router.currentRoute.fullPath
})
}
}
return Promise.reject(error)
}
)
发起请求,根据 refresh_token
重新请求一个有效的新 token:
const { data: res } = await axios({
method: 'PUT',
url: 'http://toutiao-app.itheima.net/v1_0/authorizations',
headers: {
Authorization: `Bearer ${tokenInfo.refresh_token}`
}
})
更新 Store 中的 Token:
store.commit('updateTokenInfo', {
refresh_token: tokenInfo.refresh_token,
token: res.data.token
})
结合 vue 内置的 keep-alive 组件,可以实现组件的状态保持。
在 App.vue
组件中,在 <router-view>
路由占位符之外包裹一层 <keep-alive>
组件,从而实现 Layout 组件的状态保持:
<template>
<div>
<!-- 路由占位符 -->
<keep-alive>
<router-view></router-view>
</keep-alive>
</div>
</template>
通过步骤 1,的确实现了 Layout 组件的状态保持。但是随之而来的:详情页也被缓存了,导致了文章数据不会动态刷新的问题。
可以通过 <keep-alive>
组件提供的 include
属性,来有条件的缓存组件:
<template>
<div>
<!-- 路由占位符 -->
<keep-alive include="Layout">
<router-view></router-view>
</keep-alive>
</div>
</template>
点击 tabBar 实现 Home 页面和 User 页面切换展示的时候,发现 Home 组件的状态每次都会被刷新
在 Layout.vue
组件中,在 <router-view>
路由占位符之外包裹一层 <keep-alive>
组件,从而实现 Home 组件的状态保持:
<template>
<div class="layout-container">
<!-- 路由占位符 -->
<keep-alive>
<router-view></router-view>
</keep-alive>
<!-- TabBar 区域 -->
<van-tabbar route>
<van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
<van-tabbar-item icon="user-o" to="/user">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
通过步骤 1,的确实现了 Home 组件的状态保持。但是随之而来的,User.vue
组件也被缓存了,导致修改用户头像后,头像不刷新的问题。
可以在被缓存的 User.vue
组件中,声明 activated
和 deactivated
声明周期函数,来监听组件被激活和被缓存的状态变化:
created() {
// 把下面这一行注释掉,因为 activated 在组件首次加载时也会调用一次
// this.initUserInfo()
},
// 被激活了
activated() {
// 只要组件被激活了,就重新初始化用户的信息
this.initUserInfo()
},
// 被缓存了
deactivated() {
console.log('被缓存了')
},
在 App.vue
组件中,为 <keep-alive>
组件添加要缓存的组件名称:
<template>
<div>
<!-- 路由占位符 -->
<keep-alive include="Layout,SearchResult">
<router-view></router-view>
</keep-alive>
</div>
</template>
在 SearchResult.vue
组件的 data 中声明 preKw
节点,用来缓存上次的搜索关键词:
data() {
return {
// 缓存的搜索关键词
preKw: ''
}
}
在 SearchResult.vue
组件中定义 activated
和 deactivated
声明周期如下:
// 组件被激活
activated() {
// 如果上一次的 kw 不为空,且这次的 kw 和上次缓存的 kw 值不同,则需要重新请求列表数据
if (this.preKw !== '' && this.kw !== this.preKw) {
// 1. 重置数据
this.page = 1
this.searchResult = []
this.loading = false
this.finished = false
// 2. 重新请求列表数据
this.onLoad()
}
},
// 组件被缓存
deactivated() {
// 组件被缓存时,将搜索关键词保存到 data 中
this.preKw = this.kw
},
基于 highlight.js 美化详情页的代码片段
运行如下的命令,在项目中安装 highlight.js
:
npm i highlight.js@10.6.0 -S
在 index.html
页面的 <head>
标签中引入 highlight.js
的样式表:
<link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/10.6.0/styles/default.min.css" />
在 ArticleDetail.vue
组件中导入 highlight.js
模块:
// 导入 highlight.js 模块
import hljs from 'highlight.js'
在 ArticleDetail.vue
组件的 updated
声明周期函数中,对位置内容进行高亮处理:
// 1. 当组件的 DOM 更新完毕之后
updated() {
// 2. 判断是否有文章的内容
if (this.article.content) {
// 3. 对文章的内容进行高亮处理
hljs.highlightAll()
}
},
在 ArticleDetail.vue
组件中,在文章信息区域和文章评论组件之外包裹一个 div 元素:
<template>
<div>
<!-- Header 区域 -->
<van-nav-bar fixed title="文章详情" left-arrow @click-left="$router.back()" />
<div v-if="article.title">
<!-- 文章信息区域 -->
<!-- 文章评论组件 -->
</div>
<van-loading size="24px" vertical v-else class="loading">文章加载中...</van-loading>
</div>
</template>
添加 loading
样式:
.loading {
margin-top: 50px;
}
在终端下运行如下的打包命令:
npm run build
基于 Node + Express
手写一个 web 服务器,对外托管 web 项目:
// app.js
// 导入 express 模块
const express = require('express')
// 创建 express 的服务器实例
const app = express()
// 1. 将 dist 目录托管为静态资源服务器
app.use(express.static('./dist'))
// 调用 app.listen 方法,指定端口号并启动web服务器
app.listen(3001, function () {
console.log('Express server running at http://127.0.0.1:3001')
})
基于 Express 的 express-compression 中间件,可以在服务器端对文件进行压缩。
压缩后文件网络传输的体积会大幅变小,客户端在接收到压缩的文件后会自动进行解压。
在终端下运行如下的命令:
npm i express-compression -S
在 app.js
中导入并使用网络传输压缩的中间件:
// 导入 express 模块
const express = require('express')
// 创建 express 的服务器实例
const app = express()
// 2. 安装并配置网络传输压缩的中间件
// 注意:必须在托管静态资源配置此中间件
const compression = require('express-compression')
app.use(compression())
// 1. 将 dist 目录托管为静态资源服务器
app.use(express.static('./dist'))
// 调用 app.listen 方法,指定端口号并启动web服务器
app.listen(3001, function () {
console.log('Express server running at http://127.0.0.1:3001')
})
最终的效果截图:
<img alt="">
运行如下的命令,安装 Babel 插件:
npm install babel-plugin-transform-remove-console --save-dev
在 babel.config.js
中新增如下的 plugins
数组节点:
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
// 配置移除 console 的插件
plugins: ['transform-remove-console']
}
重新运行打包的命令:
npm run build
打开 package.json
配置文件,为 scripts
节点下的 build
命令添加 --report
参数:
{
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build --report",
"lint": "vue-cli-service lint"
}
}
重新运行打包的命令:
npm run build
打包完成后,发现在 dist
目录下多了一个名为 report.html
的文件。在浏览器中打开此文件,会看到详细的打包报告。
未配置
externals
之前,项目中使用import
导入的第三方模块,在最终打包时,会被打包合并到一个 js 文件中。最后导致项目体积过大的问题。
配置了
externals
之后,webpack 在进行打包时,会把externals
节点下声明的第三方包排除在外。因此最终打包生成的 js 文件中,不会包含externals
节点下的包。这样就优化了打包后项目的体积。
在项目根目录下找到 vue.config.js
配置文件,在里面新增 configureWebpack
节点如下:
module.exports = {
// 省略其它代码...
// 增强 vue-cli 的 webpack 配置项
configureWebpack: {
// 打包优化
externals: {
// import 时的包名称: window 全局的成员名称
'highlight.js': 'hljs'
}
}
}
打开 public
目录下的 index.html
文件,在 body
结束标签之前,新增如下的资源引用:
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>
<%= htmlWebpackPlugin.options.title %>
</title>
<!-- 1. 引用 highlight.js 的样式表 -->
<link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/10.6.0/styles/default.min.css" />
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- 2. 为 window 对象全局挂载 hljs 成员 -->
<script src="https://cdn.staticfile.org/highlight.js/10.6.0/highlight.min.js"></script>
</body>
</html>
重新运行打包发布的命令,对比配置 externals
前后文件的体积变化。
在 vue.config.js
配置文件中,找到 configureWebpack
下的 externals
,添加如下的配置项:
// 增强 vue-cli 的 webpack 配置项
configureWebpack: {
// 打包优化
externals: {
'highlight.js': 'hljs',
vue: 'Vue',
'vue-router': 'VueRouter',
vuex: 'Vuex',
axios: 'axios',
vant: 'vant',
'socket.io-client': 'io',
dayjs: 'dayjs',
'bignumber.js': 'BigNumber'
}
}
在 /public/index.html
文件的 <head>
结束标签之前,添加如下的样式引用:
<!-- heightlight.js 的样式 -->
<link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/10.6.0/styles/default.min.css" />
<!-- vant 的图标样式 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vant@2.12.7/lib/icon/local.css" />
在 /public/index.html
文件的 <body>
结束标签之前,添加如下的 js 引用:
<script src="https://cdn.staticfile.org/highlight.js/10.6.0/highlight.min.js"></script>
<script src="https://cdn.staticfile.org/vue/2.6.11/vue.min.js"></script>
<script src="https://cdn.staticfile.org/vue-router/3.2.0/vue-router.min.js"></script>
<script src="https://cdn.staticfile.org/vuex/3.6.2/vuex.min.js"></script>
<script src="https://cdn.staticfile.org/axios/0.21.1/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vant@2.12.7/lib/vant.min.js"></script>
<script src="https://cdn.staticfile.org/socket.io/4.0.0/socket.io.min.js"></script>
<script src="https://cdn.staticfile.org/dayjs/1.10.4/dayjs.min.js"></script>
<script src="https://cdn.staticfile.org/bignumber.js/9.0.1/bignumber.min.js"></script>
在 development 开发阶段的需求是:急速的打包生成体验、不需要移除 console、也不需要对打包的体积进行优化
在 production 生产阶段的需求是:移除 console、基于 externals 对打包的体积进行优化
问题:如何判断当前打包期间的运行模式?
// 获取当前编译的环境 development 或 production
const env = process.env.NODE_ENV
在 babel.config.js
配置文件中,先获取到当前打包的模式,再决定是否使用移除 console
的 Babel 插件:
// 1. 获取当前编译的环境 development 或 production
const env = process.env.NODE_ENV
// 2. 当前是否处于发布模式
const isProd = env === 'production' ? true : false
// 3.1 插件的数组
const plugins = []
// 3.2 判断是否处于发布模式
if (isProd) {
plugins.push('transform-remove-console')
}
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
// 4. 动态的向外导出插件的数组
plugins
}
在 vue.config.js
配置文件中,先获取到当前打包的模式,再决定是否开启 externals
特性:
// 1. 获取当前编译的环境 development 或 production
const env = process.env.NODE_ENV
// 2. 当前是否处于发布模式
const isProd = env === 'production' ? true : false
// 3. 自定义的 webpack 配置项
const customWebpackConfig = {
externals: {
'highlight.js': 'hljs',
vue: 'Vue',
'vue-router': 'VueRouter',
vuex: 'Vuex',
axios: 'axios',
vant: 'vant',
'socket.io-client': 'io',
dayjs: 'dayjs',
'bignumber.js': 'BigNumber'
}
}
module.exports = {
// 省略其它配置节点...
// 4. 增强 vue-cli 的 webpack 配置项
configureWebpack: isProd ? customWebpackConfig : {},
}
由于 externals 节点是按需生效的。为了与之匹配,index.html 页面中的 css 样式和 js 脚本也要按需进行引入。
问题:在 index.html 页面中,如何判断当前的打包模式呢?
答案:可以对 html-webpack-plugin 插件进行自定义配置,从而支持在 index.html 页面中获取到当前的打包模式。
在 vue.config.js
中新增 chainWebpack
节点,可以对 webpack 已有的配置进行修改:
module.exports = {
// 省略其它配置节点...
// 对 webpack 已有的配置进行修改
chainWebpack: config => { /* 在这个函数中对 webpack 已有的配置进行修改 */ }
}
具体代码示例如下:
module.exports = {
// 省略其它配置节点...
// 对 webpack 已有的配置进行修改
chainWebpack: config => {
config.plugin('html').tap(args => {
// 打印 html 插件的参数项
// console.log(args)
// 当前是否处于发布模式
args[0].isProd = isProd
return args
})
}
}
在 index.html
中,根据 html-webpack-plugin
插件提供的 <% %>
模板语法,按需渲染 link 和 script 标签:
<% if (htmlWebpackPlugin.options.isProd) { %>
<!-- heightlight.js 的样式 -->
<link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/10.6.0/styles/default.min.css" />
<!-- vant 的图标样式 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vant@2.12.7/lib/icon/local.css" />
<% } %>
<% if (htmlWebpackPlugin.options.isProd) { %>
<!-- 为 window 对象全局挂载 hljs 成员 -->
<script src="https://cdn.staticfile.org/highlight.js/10.6.0/highlight.min.js"></script>
<script src="https://cdn.staticfile.org/vue/2.6.11/vue.min.js"></script>
<script src="https://cdn.staticfile.org/vue-router/3.2.0/vue-router.min.js"></script>
<script src="https://cdn.staticfile.org/vuex/3.6.2/vuex.min.js"></script>
<script src="https://cdn.staticfile.org/axios/0.21.1/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vant@2.12.7/lib/vant.min.js"></script>
<script src="https://cdn.staticfile.org/socket.io/4.0.0/socket.io.min.js"></script>
<script src="https://cdn.staticfile.org/dayjs/1.10.4/dayjs.min.js"></script>
<script src="https://cdn.staticfile.org/bignumber.js/9.0.1/bignumber.min.js"></script>
<% } %>
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。