1. 对齐目标
前端想实现一个类似的书架放置书籍的效果,目标如下:

2. 思路梳理
我们使用的技术栈:vue
实现这样的一个效果,我们需要知道以下信息:
- 每行可以放置多少书本?
- 放下所有的书本需要多少行?
- 需要什么样的数据结构?
我们现在一个个来思考,既然我们选择了vue来实现,秉持着数据驱动视图的理念,我们先从需要什么样的数据结构进行入手,其实很简单,只需要一个二维数组就可以了。
二维数组的第一层就是书架的每一行,二维数组的第二层就是每一行对应的书本
bash
[
[
{id:1,,name:"语文课本1"},//每一行放置的课本
{id:2,name:"语文课本2"},
],
[
{id:3,,name:"语文课本1"},//第二行放置的课本
{id:4,name:"语文课本2"},
],
]
那么我们就可以按照这样的一个数据结构来遍历展示即可。
3. 实现步骤
3.1 界面实现
我们可以先按照我们上面已经写好的数据,来写好对应的Html和css,然后将效果渲染出来。
xml
<template>
<div class="shelf">
<div class="shlef-row" v-for="(row, rowIndex) in bookData" :key="rowIndex">
<div class="book-item" v-for="book in row" :key="book.id">
{{ book.bookName }}
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const bookData = ref([
[
{ id: 1, bookName: "语文课本1" },
{ id: 2, bookName: "语文课本2" },
],
[
{ id: 3, bookName: "语文课本1" },//第二行放置的课本
{ id: 4, bookName: "语文课本2" },
]
])
</script>
<style>
.shelf {
width: 1200px;
height: auto;
border: 1px solid #ccc;
margin: 0 auto;
}
.shlef-row {
width: 100%;
margin: 0 0 20px 0;
display: flex;
border-bottom: 2px solid orange;
}
.shlef-row:last-child {
margin-bottom: 0;
}
.book-item {
box-sizing: border-box;
padding: 10px;
margin-right: 20px;
width: 130px;
height: 160px;
color: #fff;
background-color: skyblue;
}
</style>
3.2 根据真实的数据构造页面数据
我们在真实的环境下,肯定是通过接口获取到真实的后端数据,后端给我们的数据可能并不是我们想要的,我们就要对后端的数据进行构造,我们先分析下我们获取到真实的后端数据,来做一下分析。
yaml
[
{ id: 1, bookName: "语文课本1" },
{ id: 2, bookName: "语文课本2" },
{ id: 3, bookName: "数学课本1" },
{ id: 4, bookName: "数学课本2" },
{ id: 5, bookName: "数学课本3" },
{ id: 6, bookName: "数学课本4" },
{ id: 7, bookName: "化学课本1" },
{ id: 8, bookName: "化学课本2" },
{ id: 9, bookName: "化学课本1" },
{ id: 10, bookName: "化学课本2" },
{ id: 11, bookName: "物理课本1" },
{ id: 12, bookName: "物理课本2" },
{ id: 13, bookName: "物理课本3" },
{ id: 14, bookName: "物理课本4" },
{ id: 15, bookName: "生物课本1" },
{ id: 16, bookName: "生物课本2" }
]
可以看出,后端的数据给我们的是一整个数组,那么对于我们来说就需要解决以下问题:
- 计算一行可以放置多少本书
- 计算总共多少行
每行可以放置书本数:Math.floor(书架宽度 / 每本书实际占据的宽度(包含margin))
总共多少行书架:Math.ceil(书本总数 / 每行可以放置的书本树)
截取数组:循环书架行数,然后不停的从后端数据中去截取对应数量数据即可。
ini
// 构造页面数据 rawData:后端数据
const genBookData = (rawData) => {
const counts = Math.floor(1200 / 150);//每行可放置书本数fam
const rowCount = Math.ceil(rawData.length / counts);//总共有多少行
const rowArr = [];//书架二维数组
for (let i = 0; i < rowCount; i++) {
//每次截取对应的书本,添加到二维数组
const rowBooks = rawData.slice(i * counts, (i + 1) * counts);
rowArr.push(rowBooks);
}
return rowArr;
}
其实,这个时候,就已经实现了基本的书架功能了。

4. 附加功能优化
上面虽然已经实现了基本的书架效果,但是我们面临以下的问题:
- 现在最后一本书距离右侧空间太大,我想让书本平分空间。
- 当用户改变浏览器窗口,我对应的书架宽度改变了,需要去根据屏幕更新每行放置的书本数。
1. 书本平分空间遇到的问题
对于评分空间,大家一定觉得很容易处理,直接使用flex布局,让每本书flex:1
平分空间即可。
但是这里我重点想说的是,如果最后一行书架的书本如果放不满书架,那么就会受到flex:1
的影响,自动撑大宽度,导致和上一行的书本宽度不一致。效果如下:

解决方法:就是添加一些虚拟的占位元素(placeholder),我们改动一下我们的构造数据的函数。
ini
// 构造页面数据
const genBookData = (rawData) => {
const counts = Math.floor(1200 / 150);//每行可放置书本数fam
const rowCount = Math.ceil(rawData.length / counts);//总共有多少行
const rowArr = [];//书架二维数组
for (let i = 0; i < rowCount; i++) {
const rowBooks = rawData.slice(i * counts, (i + 1) * counts);
//+++
if (i === rowCount - 1 && rowBooks.length < counts) {
// 当这一行实际的书本数 < 每行能放置的书本数时 向二维数组中添加占位元素
const placeholders = Array(counts - rowBooks.length).fill().map((_, index) => ({
id: `placeholder-${index}`,
isPlaceholder: true
}));
rowArr.push([...rowBooks, ...placeholders]);
} else {
rowArr.push(rowBooks);
}
}
return rowArr;
}
这样就正常了,大家可以把占位元素直接给隐藏( visibility: hidden;)即可
2. 解决动态计算问题
动态计算的时候其实也很简单,我们只需要获取到当前书架的宽度,然后监听window
的resize
事件,再去重新执行我们的构造数据的逻辑即可。
但是我有一个更好的方法,使用计算属性! 我们计算属性中依赖一下我们当前屏幕宽度的变量(shelfWidth),这样我们在改变屏幕的时候,直接更新shelfWidth即可,然后计算属性会自动执行,重新计算我们的数据。直接看最终代码。
xml
<template>
<div class="shelf" ref="shelfRef">
<div class="shlef-row" v-for="(row, rowIndex) in bookData" :key="rowIndex">
<div class="book-item" v-for="book in row" :key="book.id">
{{ book.bookName }}
</div>
</div>
</div>
<button @click="changeWidtn">改变宽度</button>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
const changeWidtn = () => {
shelfWidth.value = 900;
}
// 请求接口的数据
const apiData = [
{ id: 1, bookName: "语文课本1" },
{ id: 2, bookName: "语文课本2" },
{ id: 3, bookName: "数学课本1" },
{ id: 4, bookName: "数学课本2" },
{ id: 5, bookName: "数学课本3" },
{ id: 6, bookName: "数学课本4" },
{ id: 7, bookName: "化学课本1" },
{ id: 8, bookName: "化学课本2" },
{ id: 9, bookName: "化学课本1" },
{ id: 10, bookName: "化学课本2" },
{ id: 11, bookName: "物理课本1" },
{ id: 12, bookName: "物理课本2" },
{ id: 13, bookName: "物理课本3" },
{ id: 14, bookName: "物理课本4" },
{ id: 15, bookName: "生物课本1" },
]
/* 书架效果 */
const shelfRef = ref(null);//书架Ref
const shelfWidth = ref(1200);//书架宽度
// 构造页面数据
const bookData = computed(() => { //页面渲染的数据
if (!shelfRef.value || !shelfWidth.value) {
return []
}
const counts = Math.floor(shelfWidth.value / 150);//每行可放置书本数
const rowCount = Math.ceil(apiData.length / counts);//总共有多少行
const rowArr = [];//书架二维数组
// 如果是最后一行且不满,添加占位元素,解决flex问题
for (let i = 0; i < rowCount; i++) {
const rowBooks = apiData.slice(i * counts, (i + 1) * counts);
if (i === rowCount - 1 && rowBooks.length < counts) {
const placeholders = Array(counts - rowBooks.length).fill().map((_, index) => ({
id: `placeholder-${index}`,
isPlaceholder: true,
bookName: '占位元素'
}));
rowArr.push([...rowBooks, ...placeholders]);
} else {
rowArr.push(rowBooks);
}
}
return rowArr;
})
// 更新屏幕宽度
const updateShelfWidth = () => {
shelfWidth.value = shelfRef.value.offsetWidth;
}
onMounted(() => {
updateShelfWidth();//页面加载后,更新下屏幕宽度
window.addEventListener('resize', updateShelfWidth);
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateShelfWidth);
})
</script>
<style>
.shelf {
width: 1200px;
height: auto;
border: 1px solid #ccc;
margin: 0 auto;
min-width: 1000px;
}
.shlef-row {
width: 100%;
margin: 0 0 20px 0;
display: flex;
border-bottom: 2px solid orange;
}
.shlef-row:last-child {
margin-bottom: 0;
}
.shlef-row .book-item:last-child {
margin-right: 0;
}
.book-item {
flex: 1;
box-sizing: border-box;
padding: 10px;
margin-right: 20px;
width: 130px;
height: 160px;
color: #fff;
background-color: skyblue;
}
</style>