文章目录
- 目标
- 过程与代码
- 页面滚动到目标位置显示搜索框
- 优化:节流
- 搜索栏
- 显示时间同步
- 效果
- 总代码
- 修改或添加的文件
- search-bar.vue
- useScroll.js
- store的main.js
- formatDate.js
- home.vue
- 参考
本项目博客总结:【前端】Vue项目:旅游App-博客总结
目标
窗口滚动到固定位置时显示搜索栏:
且搜索栏左侧显示的时间与入住离开的时间相匹配。
过程与代码
页面滚动到目标位置显示搜索框
我们可以用v-if
控制搜索框的显示与否,用上篇写的useScroll
得知页面的滚动情况。
为了useScroll
的扩展性,我们可以把scrollHeight
、scrollTop
、clientHeight
都return
出来:
// 关于滚动到底部的代码逻辑
import { onMounted, onUnmounted } from "@vue/runtime-core";
import { ref } from 'vue'
export default function useScroll() {
// 初始默认为没有到底
const isReachBottom = ref(false)
const scrollTop = ref(0)
const clientHeight = ref(0)
const scrollHeight = ref(0)
const scrollBottomListener = () => {
// 当前位置到顶部的距离
scrollTop.value = document.documentElement.scrollTop
// 屏幕的长度
clientHeight.value = document.documentElement.clientHeight
// 页面总体长度
scrollHeight.value = document.documentElement.scrollHeight
// 滚动到底部:提前一点刷新
if (scrollHeight.value <= scrollTop.value + clientHeight.value + 1) {
console.log('滚动到底部')
isReachBottom.value = true
}
}
onMounted(() => {
window.addEventListener('scroll', scrollBottomListener)
})
onUnmounted(() => {
window.removeEventListener('scroll', scrollBottomListener)
})
return { isReachBottom, scrollHeight, clientHeight, scrollTop }
}
在需要知道当前页面滚动到哪里的时候把scrollTop
解构出来即可。
需求:在页面滚动到开始搜索
按钮时显示搜索栏(严谨地说,是此按钮刚好被上滑的页面遮盖住时显示)。因此我们可以:
scrollBottomListener
是窗口滚动时会启动的事件,我们这样就可以实时监听到scrollTop
的变化。
浏览器控制台输出“开始搜索按钮刚好被遮盖一点”时页面距离窗口顶部的距离为:484
watch
监听scrollTop
,当它>=484时令搜索框显示。
html:
<div class="search-bar" v-if="isShowSearchBar">
我是搜索框
</div>
js:
// 是否显示搜索栏的控制
const isShowSearchBar = ref(false)
const { scrollTop } = useScroll()
watch(scrollTop, (newValue) => {
if (scrollTop.value >= 484) {
isShowSearchBar.value = true
}
else {
isShowSearchBar.value = false
}
})
用计算属性优化:
定义的可响应数据依赖于另一个可响应数据,可以使用计算属性
const isShowSearchBar = computed(() => {
return scrollTop.value >= 484
})
效果:
有了一点遮挡时,显示:
反之没有:
优化:节流
相关资料:
面试官:什么是防抖和节流?有什么区别?如何实现? | web前端面试 - 面试官系列 (vue3js.cn)
Underscore.js 简介 | Underscore.js 中文文档 | Underscore.js 中文网 (underscorejs.cn)
我们观察useScroll函数,每当窗口滚动时,都会调用回调函数scrollBottomListener
。
它调用函数十分频繁,会降低前端的性能。因此,我们要对它进行优化。
优化有两种主要方式:防抖和节流。
定义:
节流: n 秒内只运行一次,若在 n 秒内重复触发,只有一次生效
防抖: n 秒后在执行该事件,若在 n 秒内被重复触发,则重新计时
应用场景:
防抖在连续的事件,只需触发一次回调的场景有:
搜索框搜索输入。只需用户最后一次输入完,再发送请求
手机号、邮箱验证输入检测
窗口大小resize。只需窗口调整完成后,计算窗口大小。防止重复渲染。节流在间隔一段时间执行一次回调的场景有:
滚动加载,加载更多或滚到底部
监听搜索框,搜索联想功能
这里很明显要使用节流。
我们可以使用已经封装好的库来调用节流的函数。
npm i underscore
第一个参数是要进行节流的函数,这里是scrollBottomListener
;
第二个参数是再次执行函数要间隔的时间。
时间设为100ms刚好,若设置为1000ms会明显感觉到延迟。注意,wait不要设置太大。
const scrollBottomListener = throttle(() => {
// 当前位置到顶部的距离
scrollTop.value = document.documentElement.scrollTop
// 屏幕的长度
clientHeight.value = document.documentElement.clientHeight
// 页面总体长度
scrollHeight.value = document.documentElement.scrollHeight
// 滚动到底部:提前一点刷新
if (scrollHeight.value <= scrollTop.value + clientHeight.value + 1) {
console.log('滚动到底部')
isReachBottom.value = true
}
// console.log(scrollTop.value)
}, 100)
搜索栏
想要在固定位置有个搜索栏,要position:fixed
html:
<div class="search-bar" v-if="isShowSearchBar">
<searchBar/>
</div>
css:
.search-bar{
// 定位在屏幕某位置,不随页面滚动而改变
position: fixed;
top: 0;
left: 0;
right: 0;
height: 45px;
background-color: #fff;
// 防止被house-item组件中有绝对定位的覆盖掉
z-index: 9;
}
对应searchBar组件:
<template>
<div class="search">
<div class="left">
<div class="item start">
<div class="name">住</div>
<div class="time">02.01</div>
</div>
<div class="item end">
<div class="name">离</div>
<div class="time">02.02</div>
</div>
</div>
<div class="content">
<div class="keyword">关键字/位置/民宿</div>
</div>
<div class="right">
<van-icon size="20px" color="#3f4954" name="search" />
</div>
</div>
</template>
<script setup>
</script>
<style lang="less" scoped>
.search {
display: flex;
justify-content: start;
align-items: center;
border-radius: 10px;
background-color: #F5F5F5;
margin: 5px 15px 10px;
// padding: 5px;
.left {
display: flex;
flex-direction: column;
margin-left: 10px;
.item {
display: flex;
flex-direction: row;
font-size: 12px;
margin: 3px;
}
.name {
color: #999;
}
.time {
margin: 0 3px;
color: #000;
}
}
.content {
width: 80%;
margin: 0 10px;
.keyword {
color: #999;
font-size: 13px;
}
}
.right {
margin-right: 15px;
}
}
</style>
效果:
显示时间同步
想让显示时间同步,显然我们要把时间放在store中统一管理。在日历中修改时间即在store中修改时间。任何需要读取时间的地方都在store中读取。
import { defineStore } from "pinia"
const startDay = new Date()
const endDay = new Date()
endDay.setDate(startDay.getDate() + 1)
const useMainStore = defineStore('main', {
state: () => ({
token:'',
startDay:startDay,
endDay:endDay
}),
actions: {
}
})
export default useMainStore
在search-box中将时间同步:
// 日期
const { startDay, endDay } = storeToRefs(mainStore)
const startDayStr=ref(formatMonthDay(startDay.value))
const endDayStr=ref(formatMonthDay(endDay.value))
watch(startDay,(newValue)=>{
startDayStr.value=formatMonthDay(startDay.value)
})
watch(endDay,(newValue)=>{
endDayStr.value=formatMonthDay(endDay.value)
})
// 日历
const date = ref('1');
const showCalendar = ref(false);
const formatDate = (date) => `${date.getMonth() + 1}/${date.getDate()}`;
const onConfirm = (values) => {
const [start, end] = values;
showCalendar.value = false;
mainStore.startDay=start
mainStore.endDay=end
date.value = getDiffDate(start, end)
};
在search-bar中时间同步的操作相似,不赘述。
效果
总代码
修改或添加的文件
search-bar.vue
组件:搜索栏。
<template>
<div class="search">
<div class="left">
<div class="item start">
<div class="name">住</div>
<div class="time">{{ startDayStr }}</div>
</div>
<div class="item end">
<div class="name">离</div>
<div class="time">{{ endDayStr }}</div>
</div>
</div>
<div class="content">
<div class="keyword">关键字/位置/民宿</div>
</div>
<div class="right">
<van-icon size="20px" color="#3f4954" name="search" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import useMainStore from '@/store/modules/main';
import { formatMonthDay2 } from '@/utils/formatDate'
const mainStore = useMainStore()
const { startDay, endDay } = storeToRefs(mainStore)
const startDayStr = ref(formatMonthDay2(startDay.value))
const endDayStr = ref(formatMonthDay2(endDay.value))
</script>
<style lang="less" scoped>
.search {
display: flex;
justify-content: start;
align-items: center;
border-radius: 10px;
background-color: #F5F5F5;
margin: 5px 15px 10px;
// padding: 5px;
.left {
display: flex;
flex-direction: column;
margin-left: 10px;
.item {
display: flex;
flex-direction: row;
font-size: 12px;
margin: 3px;
}
.name {
color: #999;
}
.time {
margin: 0 3px;
color: #000;
}
}
.content {
width: 80%;
margin: 0 10px;
.keyword {
color: #999;
font-size: 13px;
}
}
.right {
margin-right: 15px;
}
}
</style>
useScroll.js
增加了拓展性和节流。
// 关于滚动到底部的代码逻辑
import { onMounted, onUnmounted } from "@vue/runtime-core";
import { ref } from 'vue'
import { throttle } from "underscore";
export default function useScroll() {
// 初始默认为没有到底
const isReachBottom = ref(false)
const scrollTop = ref(0)
const clientHeight = ref(0)
const scrollHeight = ref(0)
const scrollBottomListener = throttle(() => {
// 当前位置到顶部的距离
scrollTop.value = document.documentElement.scrollTop
// 屏幕的长度
clientHeight.value = document.documentElement.clientHeight
// 页面总体长度
scrollHeight.value = document.documentElement.scrollHeight
// 滚动到底部:提前一点刷新
if (scrollHeight.value <= scrollTop.value + clientHeight.value + 1) {
console.log('滚动到底部')
isReachBottom.value = true
}
// console.log(scrollTop.value)
}, 100)
onMounted(() => {
window.addEventListener('scroll', scrollBottomListener)
})
onUnmounted(() => {
window.removeEventListener('scroll', scrollBottomListener)
})
return { isReachBottom, scrollHeight, clientHeight, scrollTop }
}
store的main.js
整个项目的时间显示保存在这里。
import { defineStore } from "pinia"
const startDay = new Date()
const endDay = new Date()
endDay.setDate(startDay.getDate() + 1)
const useMainStore = defineStore('main', {
state: () => ({
token:'',
startDay:startDay,
endDay:endDay
}),
actions: {
}
})
export default useMainStore
formatDate.js
新增了"x.x"日期的格式化方法。
import dayjs from 'dayjs'
// 格式化“x月x日”
export function formatMonthDay(date) {
return dayjs(date).format('MM月DD日')
}
// 格式化“x.x”
export function formatMonthDay2(date) {
return dayjs(date).format('MM.DD')
}
// end-start
export function getDiffDate(start, end) {
return dayjs(end).diff(start, 'day')
}
home.vue
新增页面滚动到目标位置显示搜索框,以及对应的css。
更新了时间同步。
<template>
<div class="home">
<div class="nav-bar">
<div class="title">旅游App</div>
<div class="banner">
<img src="@/assets/img/home/banner.webp" alt="">
</div>
</div>
<div class="search-box">
<div class="section location">
<div class="city">
<router-link to="/city">{{ cityStore.currentCity.cityName }}</router-link>
</div>
<div class="position">
<div class="text">我的位置</div>
<img src="@/assets/img/home/icon_location.png" alt="">
</div>
</div>
<div class="section time-range" :value="date" @click="showCalendar = true">
<div class="start">
<span>入住</span>
<div class="time">
{{ startDayStr }}
</div>
</div>
<div class="stay">共{{ date }}晚</div>
<div class="end">
<span>离店</span>
<div class="time">
{{ endDayStr }}
</div>
</div>
</div>
<!-- 日历 -->
<van-calendar :round="false" v-model:show="showCalendar" type="range" @confirm="onConfirm"
:show-confirm="false" />
<!-- 价格人数 -->
<div class="price-counter section">
<div class="left">价格不限</div>
<div class="right">人数不限</div>
</div>
<!-- 关键字 -->
<div class="keyword section">
<span>关键字/位置/民宿名</span>
</div>
<!-- 热门推荐 -->
<div class="hotSuggest section">
<template v-for="(item, index) in hotSuggestData" :key="index">
<div class="hotSuggestItem">
{{ item.tagText.text }}
</div>
</template>
</div>
<div class="searchBtn" @click="searchBtnClick()">
开始搜索
</div>
<div class="search-bar" v-if="isShowSearchBar">
<searchBar />
</div>
<homeCategories />
<homeContent />
</div>
</div>
</template>
<script setup>
import useCityStore from '../../store/modules/city';
import useHomeStore from '../../store/modules/home';
import useMainStore from '../../store/modules/main'
import { formatMonthDay, getDiffDate } from '@/utils/formatDate'
import homeCategories from './cpns/home-categories.vue'
import homeContent from './cpns/home-content.vue';
import useScroll from '../../hooks/useScroll';
import searchBar from '../../components/search-bar/search-bar.vue';
import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router'
const cityStore = useCityStore()
const homeStore = useHomeStore()
const mainStore = useMainStore()
const router = useRouter()
homeStore.fetchCategories()
const { categories } = storeToRefs(homeStore)
console.log(categories)
function moreList() {
homeStore.fetchHouseList()
}
// 日期
const { startDay, endDay } = storeToRefs(mainStore)
const startDayStr=ref(formatMonthDay(startDay.value))
const endDayStr=ref(formatMonthDay(endDay.value))
watch(startDay,(newValue)=>{
startDayStr.value=formatMonthDay(startDay.value)
})
watch(endDay,(newValue)=>{
endDayStr.value=formatMonthDay(endDay.value)
})
// 日历
const date = ref('1');
const showCalendar = ref(false);
const formatDate = (date) => `${date.getMonth() + 1}/${date.getDate()}`;
const onConfirm = (values) => {
const [start, end] = values;
showCalendar.value = false;
mainStore.startDay=start
mainStore.endDay=end
date.value = getDiffDate(start, end)
};
// 热门数据
homeStore.fetchHotSuggest()
const { hotSuggest } = storeToRefs(homeStore)
const hotSuggestData = hotSuggest
// 搜索按钮跳转
function searchBtnClick() {
router.push({
path: '/search',
query: {
// 因为是响应式
startDay: startDay.value,
endDay: endDay.value
}
})
}
// 是否显示搜索栏的控制
const { scrollTop } = useScroll()
const isShowSearchBar = computed(() => {
return scrollTop.value >= 484
})
</script>
<style lang="less" scoped>
.home {
.nav-bar {
.title {
height: 46px;
// flex居中,以后左右有东西可以直接加
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-color);
font-size: 16px;
font-weight: 700;
}
.banner {
// 图片本身大很多,让它大小刚好
img {
width: 100%;
}
}
}
.search-box {
--van-calendar-popup-height: 100%;
// search-box里的每个部分都加上section
// 都有类似的样式
.section {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 0 20px;
color: #999;
margin-top: 10px;
}
.location {
height: 44px;
display: flex;
align-items: center;
padding: 0 20px;
color: #53565c;
.city {
// flex:1 === flex:1 1 auto 除了position之外的剩余部分都属于city
flex: 1;
}
.position {
width: 74px;
display: flex;
align-items: center;
.text {
font-size: 12px;
}
img {
width: 20px;
margin-left: 5px;
}
}
}
.time-range {
display: flex;
justify-content: space-between;
height: 45px;
span {
font-size: 16px;
}
.time {
color: #53565c;
}
}
.price-counter {
justify-content: space-between;
height: 35px;
}
.keyword {
height: 35px;
}
.hotSuggest {
.hotSuggestItem {
margin: 3px;
padding: 4px 8px;
font-size: 12px;
background-color: #f1f3f5;
color: #3f4954;
border-radius: 20px;
}
}
.searchBtn {
display: flex;
justify-content: center;
align-items: center;
height: 38px;
font-size: 18px;
// 渐变色要用image
background-image: var(--theme-linear-gradient);
color: #fff;
border-radius: 20px;
margin: 20px 20px;
}
.search-bar {
// 定位在屏幕某位置,不随页面滚动而改变
position: fixed;
top: 0;
left: 0;
right: 0;
height: 45px;
background-color: #fff;
// 防止被house-item组件中有绝对定位的覆盖掉
z-index: 9;
}
}
}
</style>
参考
面试官:什么是防抖和节流?有什么区别?如何实现? | web前端面试 - 面试官系列 (vue3js.cn)
Underscore.js 简介 | Underscore.js 中文文档 | Underscore.js 中文网 (underscorejs.cn)
Vue 警告 Write operation failed: computed value is readonly_PKQ1023的博客-CSDN博客
【Js】检查Date对象是否为Invalid Date_smart_dream的博客-CSDN博客_invalid date
vue父组件通过ref获取子组件data数据出现undefined(问题篇)_skyblue_afan的博客-CSDN博客