Vue.js项目重构,轻松实现上拉加载滚动位置还原
前言
上一篇《Vue.js轻松实现页面后退时,还原滚动位置》只是简单的实现了路由切换时进行的滚动位置还原,很多朋友就来问上拉加载怎么实现啊!于是我想起了以前做过一个叫vue-cnode的项目,于是花了两天时间进行了重构,完全的移除了Vuex,使用了Vuet来做为状态的管理工具。如果关注Vuet的朋友就会发现,版本更新得好快,简直就是版本帝啊!!!其实Vuet的版本升级,都是向下兼容的,每次的版本发布都会经过完整的单元测试和e2e测试,极大的保证了发布版本的稳定性。
项目源码
需求分析
- 记录上拉请求时的页数
- 页面后退时,还原之前列表页面的状态
- 列表分类切换时,进行状态重置
- 从列表A点击详情A,页面后退,重新打开详情A,还原之前访问详情A状态
- 从列表A点击详情A,页面后退,重新打开详情B,清除详情A的状态,初始化详情B的状态
安装
npm install --save vuet
Vuet实例
import Vue from 'vue'
import Vuet from 'vuet'
import utils from 'utils'
import http from 'http'
Vue.use(Vuet)
export default new Vuet({
pathJoin: '-', // 定义模块的连接符
modules: {
topic: {
create: {
data () {
return {
title: '', // 标题
tab: '', // 发表的板块
content: '' // 发表的内容
}
},
manuals: {
async create ({ state }) {
if (!state.title) {
return utils.toast('标题不能为空')
} else if (!state.tab) {
return utils.toast('选项不能为空')
} else if (!state.content) {
return utils.toast('内容不能为空')
}
const res = await http.post(`/topics`, {
...state
})
if (res.success) {
this.reset()
} else {
utils.toast(res.error_msg)
}
return res
}
}
},
/********* 实现列表上拉加载滚动位置还原的核心代码开始 *************/
list: {
data () {
return {
data: [], // 列表存储的数据
loading: true, // 数据正在加载中
done: false, // 数据是否已经全部加载完成
page: 1 // 加载的页数
}
},
async fetch ({ state, route, params, path }) {
// 注,在vuet 0.1.2以上版本,会多带一个params.routeWatch参数,我们可以根据这个来判断页面是否发生了变化
if (params.routeWatch === true) { // 路由发生了变化,重置模块状态
this.reset(path)
} else if (params.routeWatch === false) { // 路由没有变化触发的请求,可能是从详情返回到列表
return {}
}
// params.routeWatch 没有参数,则是上拉加载触发的调用
const { tab = '' } = route.query
const query = {
tab,
mdrender: false,
limit: 20,
page: state.page
}
const res = await http.get('/topics', query)
const data = params.routeWatch ? res.data : [...state.data, ...res.data]
return {
data, // 更新模块的列表数据
page: ++state.page, // 每次请求成功后,页数+1
loading: false, // 数据加载完成
done: res.data.length < 20 // 判断列表的页数是否全部加载完成
}
}
},
/********* 实现列表上拉加载滚动位置还原的核心代码结束 *************/
detail: {
data () {
return {
data: {
id: null,
author_id: null,
tab: null,
content: null,
title: null,
last_reply_at: null,
good: false,
top: false,
reply_count: 0,
visit_count: 0,
create_at: null,
author: {
loginname: null,
avatar_url: null
},
replies: [],
is_collect: false
},
existence: true,
loading: true,
commentId: null
}
},
async fetch ({ route }) {
const { data } = await http.get(`/topic/${route.params.id}`)
if (data) {
return {
data,
loading: false
}
}
return {
existence: false,
loading: false
}
}
}
},
user: { // 登录用户的模块
self: {
data () {
return {
data: JSON.parse(localStorage.getItem('vue_cnode_self')) || {
avatar_url: null,
id: null,
loginname: null,
success: false
}
}
},
manuals: {
async login ({ state }, accesstoken) { // 用户登录方法
const res = await http.post(`/accesstoken`, { accesstoken })
if (typeof res === 'object' && res.success) {
state.data = res
localStorage.setItem('vue_cnode_self', JSON.stringify(res))
localStorage.setItem('vue_cnode_accesstoken', accesstoken)
}
return res
},
signout () { // 用户退出方法
localStorage.removeItem('vue_cnode_self')
localStorage.removeItem('vue_cnode_accesstoken')
this.reset()
}
}
},
detail: {
data () {
return {
data: {
loginname: null,
avatar_url: null,
githubUsername: null,
create_at: null,
score: 0,
recent_topics: [],
recent_replies: []
},
existence: true,
loading: true,
tabIndex: 0
}
},
async fetch ({ route }) {
const { data } = await http.get(`/user/${route.params.username}`)
if (data) {
return {
data,
loading: false
}
}
return {
existence: false,
loading: false
}
}
},
messages: {
data () {
return {
data: {
has_read_messages: [],
hasnot_read_messages: []
},
loading: true
}
},
async fetch () {
// 用户未登录,拦截请求
if (!this.getState('user-self').data.id) return
const { data } = await http.get(`/messages`, { mdrender: true })
return {
data
}
},
count: {
data () {
return {
data: 0
}
},
async fetch () {
// 用户未登录,拦截请求
if (!this.getState('user-self').data.id) return
const res = await http.get('/message/count')
if (!res.data) return
return {
data: res.data
}
}
}
}
}
}
})
在Vuet实例创建完成后,我们就可以在组件中连接我们的Vuet了。
- 首页列表
<template>
<div>
<nav class="nav">
<ul flex="box:mean">
<li v-for="item in tabs" :class="{ active: item.tab === ($route.query.tab || '') }">
<router-link :to="{ name: 'index', query: { tab: item.tab } }">{{ item.title }}</router-link>
</li>
</ul>
</nav>
<!--
注意了,由于我的页面布局是一个局部滚动条,所以需要指定一个name
如果你的页面是全局滚动条,设置指令为
v-route-scroll.window="{ path: 'topic-list' }"
-->
<v-content v-route-scroll="{ path: 'topic-list', name: 'content' }">
<ul class="list">
<li v-for="item in list.data" key="item.id">
<router-link :to="{ name: 'topic-detail', params: { id: item.id } }">
<div class="top" flex="box:first">
<div class="headimg" :style="{ backgroundImage: 'url(' + item.author.avatar_url + ')' }"></div>
<div class="box" flex="dir:top">
<strong>{{ item.author.loginname }}</strong>
<div flex>
<time>{{ item.create_at | formatDate }}</time>
<span class="tag">#分享#</span>
</div>
</div>
</div>
<div class="common-typeicon" flex v-if="item.top || item.good">
<div class="icon" v-if="item.good">
<i class="iconfont icon-topic-good"></i>
</div>
<div class="icon" v-if="item.top">
<i class="iconfont icon-topic-top"></i>
</div>
</div>
<div class="tit">{{ item.title }}</div>
<div class="expand" flex="box:mean">
<div class="item click" flex="main:center cross:center">
<i class="iconfont icon-click"></i>
<div class="num">{{ item.visit_count > 0 ? item.visit_count : '暂无阅读' }}</div>
</div>
<div class="item reply" flex="main:center cross:center">
<i class="iconfont icon-comment"></i>
<div class="num">{{ item.reply_count > 0 ? item.reply_count : '暂无评论' }}</div>
</div>
<div class="item last-reply" flex="main:center cross:center">
<time class="time">{{ item.last_reply_at | formatDate }}</time>
</div>
</div>
</router-link>
</li>
</ul>
<v-loading :done="list.done" :loading="list.loading" @seeing="$vuet.fetch('topic-list')"></v-loading>
</v-content>
<v-footer></v-footer>
</div>
</template>
<script>
import { mapModules, mapRules } from 'vuet'
export default {
mixins: [
mapModules({ list: 'topic-list' }), // 连接我们定义的Vuet.js的状态
mapRules({ route: 'topic-list' }) // 使用Vuet.js内置的route规则来对页面数据和滚动位置进行管理
],
data () {
return {
tabs: [
{
title: '全部',
tab: ''
},
{
title: '精华',
tab: 'good'
},
{
title: '分享',
tab: 'share'
},
{
title: '问答',
tab: 'ask'
},
{
title: '招聘',
tab: 'job'
}
]
}
}
}
</script>
- 页面详情
<template>
<div>
<v-header title="主题">
<div slot="left" class="item" flex="main:center cross:center" v-on:click="$router.go(-1)">
<i class="iconfont icon-back"></i>
</div>
</v-header>
<!--
设置详情的局部滚动条
-->
<v-content style="bottom: 0;" v-route-scroll="{ path: 'topic-detail', name: 'content' }">
<v-loading v-if="detail.loading"></v-loading>
<v-data-null v-if="!detail.existence" msg="话题不存在"></v-data-null>
<template v-if="!detail.loading && detail.existence">
<div class="common-typeicon" flex v-if="data.top || data.good">
<div class="icon" v-if="data.good">
<i class="iconfont icon-topic-good"></i>
</div>
<div class="icon" v-if="data.top">
<i class="iconfont icon-topic-top"></i>
</div>
</div>
<ul class="re-list">
<!-- 楼主信息 start -->
<li flex="box:first">
<div class="headimg">
<router-link class="pic" :to="{ name: 'user-detail', params: { username: author.loginname } }" :style="{ backgroundImage: 'url(' + author.avatar_url + ')' }"></router-link>
</div>
<div class="bd">
<div flex>
<router-link flex-box="0" :to="{ name: 'user-detail', params: { username: author.loginname } }">{{ author.loginname }}</router-link>
<time flex-box="1">{{ data.create_at | formatDate }}</time>
<div flex-box="0" class="num">#楼主</div>
</div>
</div>
</li>
<!-- 楼主信息 end -->
<!-- 主题信息 start -->
<li>
<div class="datas">
<div class="tit">{{ data.title }}</div>
<div class="bottom" flex="main:center">
<div class="item click" flex="main:center cross:center">
<i class="iconfont icon-click"></i>
<div class="num">{{ data.visit_count }}</div>
</div>
<div class="item reply" flex="main:center cross:center">
<i class="iconfont icon-comment"></i>
<div class="num">{{ data.reply_count }}</div>
</div>
</div>
</div>
<div class="markdown-body" v-html="data.content"></div>
</li>
<!-- 主题信息 end -->
<li class="replies-count" v-if="replies.length">
共(<em>{{ replies.length }}</em>)条回复
</li>
<!-- 主题评论 start -->
<li v-for="(item, $index) in replies">
<div flex="box:first">
<div class="headimg">
<router-link class="pic" :to="{ name: 'user-detail', params: { username: item.author.loginname } }" :style="{ backgroundImage: 'url(' + item.author.avatar_url + ')' }"></router-link>
</div>
<div class="bd">
<div flex>
<router-link flex-box="0" :to="{ name: 'user-detail', params: { username: item.author.loginname } }">{{ item.author.loginname }}</router-link>
<time flex-box="1">{{ item.create_at | formatDate }}</time>
<div flex-box="0" class="num">#{{ $index + 1 }}</div>
</div>
<div class="markdown-body" v-html="item.content"></div>
<div class="bottom" flex="dir:right cross:center">
<div class="icon" @click="commentShow(item, $index)">
<i class="iconfont icon-comment-topic"></i>
</div>
<div class="icon" :class="{ fabulous: testThing(item.ups) }" v-if="item.author.loginname !== user.data.loginname" @click="fabulousItem(item)">
<i class="iconfont icon-comment-fabulous"></i>
<em v-if="item.ups.length">{{ item.ups.length }}</em>
</div>
</div>
</div>
</div>
<reply-box v-if="detail.commentId === item.id" :loginname="item.author.loginname" :replyId="item.id"></reply-box>
</li>
<!-- 主题评论 end -->
</ul>
<div class="reply" v-if="user.data.id">
<reply-box @success="$vuet.fetch('topic-detail')"></reply-box>
</div>
<div class="tip-login" v-if="!user.data.id">
你还未登录,请先
<router-link to="/login">登录</router-link>
</div>
</template>
</v-content>
</div>
</template>
<script>
import http from 'http'
import replyBox from './reply-box'
import { mapModules, mapRules } from 'vuet'
export default {
mixins: [
// 连接详情和登录用户模块
mapModules({ detail: 'topic-detail', user: 'user-self' }),
// 一样是使用route规则对页面的数据进行管理
mapRules({ route: 'topic-detail' })
],
components: { replyBox },
computed: {
data () {
return this.detail.data
},
author () {
return this.detail.data.author
},
replies () {
return this.detail.data.replies
}
},
methods: {
testThing (ups) { // 验证是否点赞
return ups.indexOf(this.user.data.id || '') > -1
},
fabulousItem ({ ups, id }) { // 点赞
if (!this.user.data.id) return this.$router.push('/login')
var index = ups.indexOf(this.user.data.id)
if (index > -1) {
ups.splice(index, 1)
} else {
ups.push(this.user.data.id)
}
http.post(`/reply/${id}/ups`)
},
commentShow (item) { // 显示隐藏回复框
if (!this.user.data.id) return this.$router.push('/login')
this.detail.commentId = this.detail.commentId === item.id ? null : item.id
}
}
}
</script>
总结
因为篇幅有限,所以只列出了列表和详情的代码,大家有兴趣深入的话,可以看下vue-cnode的代码。这是基于Vuet进行状态管理的完整项目,包含了用户的登录退出,路由页面,滚动位置还原,帖子编辑状态保存等等,麻雀虽小,却是五脏俱全。