基于 Vue 2 和 CSS 动画实现电子书翻页效果
在本文中,我将介绍如何使用 Vue 2 和 CSS 动画创建一个具有翻页效果的电子书展示组件。
这个组件模拟了实体书的翻页体验,适用于展示宫崎骏作品集等书籍内容。
核心实现思路
该电子书组件主要利用 CSS 3D 变换和动画来实现翻页效果,并通过 Vue 的生命周期和 DOM 操作功能来动态构建书籍内容。
首先先让cursor进行分析

然后对其关键技术点进行解析
-
CSS 3D 变换 :通过
perspective
和rotateY
实现 3D 翻页效果,使页面翻转时具有空间感。 -
CSS 动画 :使用
@keyframes
定义翻页动画,包括turnForward
(向前翻页)和turnBackward
(向后翻页),并配合animation
属性应用到页面元素上。
/* 淡出动画 */ @keyframes fadeOut { 0% { visibility: visible; } 49% { visibility: visible; } 50% { visibility: hidden; } 100% { visibility: hidden; } } /* 淡入动画 */ @keyframes fadeIn { 0% { visibility: hidden; } 50% { visibility: hidden; } 51% { visibility: visible; } 100% { visibility: visible; } } @keyframes turnForward { 0% { z-index: 999; } 40% { z-index: 999; } 49% { transform: rotateY(-90deg); } 50% { transform: rotateY(-90deg); } 100% { transform: rotateY(-180deg); z-index: null; } } @keyframes turnBackward { 0% { z-index: 999; } 40% { z-index: 999; } 49% { transform: rotateY(-90deg); } 50% { transform: rotateY(-90deg); } 100% { transform: rotateY(0deg); z-index: null; } }
-
Vue 生命周期 :在
mounted
阶段初始化书籍内容,此时 DOM 已经渲染完成,可以安全地进行 DOM 操作。 -
DOM 操作封装 :将创建页面元素的逻辑抽取为独立方法(如
createPage
),提高代码复用性和可读性。 -
事件处理 :为每个页面添加点击事件监听器,根据当前状态(
isTurnIng
和showPageNumber
)决定是否执行翻页操作。 -
z-index 动态计算:在翻页过程中动态调整页面的 z-index 值,确保翻页顺序正确。
代码实现
以下是完整的组件代码实现:
html
<template>
<div class="book" ref="book"></div>
</template>
<script>
export default {
data() {
return {
isTurnIng: false, // 是否正在翻页
showPageNumber: 0, // 当前显示的页码
timer: null, // 动画定时器
pages: [ // 页面内容数据
{
front: { title: "《千与千寻》", text: "内容1", background: "<https://i04piccdn.sogoucdn.com/6e02ef629da4a23c>" },
back: { title: "《天空之城》", text: "内容2", background: "<https://i02piccdn.sogoucdn.com/a454ac48b1e33aec>" }
},
{
front: { title: "《悬崖上的金鱼姬》", text: "内容3", background: "<https://i04piccdn.sogoucdn.com/c93cbdfb64b1e1e7>" },
back: { title: "《魔女宅急便》", text: "内容4", background: "<https://i02piccdn.sogoucdn.com/7c13d38533902d8b>" }
}
],
covers: [ // 封面和封底数据
{ front: { title: "『宫崎骏作品集』", background: "<http://5b0988e595225.cdn.sohucs.com/images/20190602/4de5e4720e294714adf3f0f782c4f277.jpeg>" } },
{ back: { title: "", background: "<https://i04piccdn.sogoucdn.com/a608bd742dbf3629>" } }
]
}
},
mounted() {
this.initBook()
},
methods: {
// 初始化电子书
initBook() {
this.createCoverEnd()
this.createPages()
this.createCover()
this.bindPageEvents()
},
// 创建封底
createCoverEnd() {
const coverEnd = document.createElement("span")
coverEnd.classList.add("cover", "coverEnd")
coverEnd.setAttribute("data-index", this.pages.length + 1)
const backPage = this.createPage(this.covers[1].back, true)
backPage.classList.add("back-page")
coverEnd.appendChild(backPage)
this.$[refs.book](http://refs.book).appendChild(coverEnd)
},
// 创建书页
createPages() {
this.pages.reverse().forEach((page, index) => {
const pageElement = document.createElement("span")
pageElement.classList.add("page", "turn-page")
pageElement.setAttribute("data-index", this.pages.length - index)
const frontPage = this.createPage(page.front)
frontPage.classList.add("front-page")
const backPage = this.createPage(page.back)
backPage.classList.add("back-page")
pageElement.appendChild(frontPage)
pageElement.appendChild(backPage)
this.$[refs.book](http://refs.book).appendChild(pageElement)
})
},
// 创建封面
createCover() {
const cover = document.createElement("span")
cover.classList.add("cover")
cover.setAttribute("data-index", "0")
const frontPage = this.createPage(this.covers[0].front, true)
frontPage.classList.add("front-page")
cover.appendChild(frontPage)
this.$[refs.book](http://refs.book).appendChild(cover)
},
// 创建页面元素
createPage(info = {}, isCover = false) {
const pageElement = document.createElement("div")
const prefix = isCover ? "cover" : "page"
if (info.background) {
const img = document.createElement("img")
img.classList.add(`${prefix}-image`)
img.src = info.background
pageElement.appendChild(img)
}
if (info.title) {
const titleElement = document.createElement("div")
titleElement.innerText = info.title
titleElement.classList.add(`${prefix}-title`)
pageElement.appendChild(titleElement)
}
const textElement = document.createElement("div")
textElement.innerText = info.text || ""
textElement.classList.add(`${prefix}-text`)
pageElement.appendChild(textElement)
return pageElement
},
// 绑定页面点击事件
bindPageEvents() {
const root = document.documentElement
const duration = getComputedStyle(root).getPropertyValue("--animation-duration")
const coverElements = this.$[refs.book](http://refs.book).querySelectorAll(".cover")
const pageElements = this.$[refs.book](http://refs.book).querySelectorAll(".page")
;[...coverElements, ...pageElements].forEach(page => {
page.addEventListener("click", (e) => {
this.pageClick(e, page)
})
})
},
// 计算 z-index
calZIndex(selector = ".turn") {
const turnedPages = document.querySelectorAll(selector)
return turnedPages.length + 1
},
// 页面点击处理
pageClick(e, target) {
const index = parseInt(target.getAttribute("data-index"))
if (![this.showPageNumber, this.showPageNumber - 1].includes(index)) {
return
}
if (target.classList.contains("turn")) {
if (this.isTurnIng === "turnBack") return
this.showPageNumber = index
this.isTurnIng = "turn"
[target.style](http://target.style).transform = "rotateY(-180deg)"
target.classList.remove("turn")
target.classList.add("turnBack")
[target.style](http://target.style).zIndex = null
} else {
if (this.isTurnIng === "turn") return
this.showPageNumber = index + 1
this.isTurnIng = "turnBack"
[target.style](http://target.style).transform = "rotateY(0deg)"
target.classList.remove("turnBack")
target.classList.add("turn")
[target.style](http://target.style).zIndex = this.calZIndex()
}
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.isTurnIng = false
}, parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--animation-duration")) * 1000)
}
}
}
</script>
<style>
:root {
--width: 32rem;
--height: 20rem;
--font-size: 1.2rem;
--animation-duration: 2s;
}
body {
background-color: #4f4e68;
height: 100%;
overflow: hidden;
}
.book {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: var(--width);
height: var(--height);
perspective: 70rem;
}
.coverEnd {
right: -0.5rem !important;
}
.cover {
background-color: #36354e;
transform: rotateY(0deg);
width: calc(var(--width) / 2);
height: var(--height);
overflow: hidden;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
z-index: 0;
opacity: 0.8;
}
.cover-title {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
background: linear-gradient(45deg, #ff0000, #ff7f00, #ffff00);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
font-size: 2rem;
font-weight: bold;
text-align: center;
z-index: 1;
width: 100%;
}
.page {
width: calc(var(--width) / 2 - 0.5rem);
height: calc(var(--height) - 0.5rem);
background-color: #e9e6c4;
transform: rotateY(0deg);
text-align: right;
font-size: var(--font-size);
color: #777;
font-family: monospace;
overflow: hidden;
text-indent: 2em;
}
.page::after {
display: block;
content: "";
position: absolute;
top: calc(var(--font-size) * 2);
left: 1rem;
right: 1rem;
bottom: 1rem;
background-image: linear-gradient(to bottom,
currentColor 1px,
transparent 1px);
background-size: 100% calc(var(--font-size) + 0.5rem);
/* 调整行高 */
opacity: 1;
/* 调整透明度 */
pointer-events: none;
/* 让点击事件穿透伪元素 */
}
.cover,
.page {
position: absolute;
padding-top: 1rem;
transform-origin: 0 0;
border-radius: 5px 0 0 5px;
box-shadow: inset 3px 0px 20px rgba(0, 0, 0, 0.2),
0px 0px 15px rgba(0, 0, 0, 0.1);
box-sizing: border-box;
text-align: left;
right: 0;
}
.page-title {
transform: translate(0, 0);
pointer-events: none;
background: linear-gradient(45deg, #ff0000, #ff7f00, #ffff00);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
font-weight: bold;
text-align: center;
text-indent: 0;
}
.page-text {
mix-blend-mode: hard-light;
color: #000000;
padding-left: 1em;
padding-right: 1em;
font-size: calc(var(--font-size) * 0.9);
line-height: calc(var(--font-size) + 0.5rem);
font-weight: bold;
}
.page-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
opacity: 0.8;
z-index: 0;
}
.turn {
animation: turnForward var(--animation-duration) forwards;
}
.turnBack {
animation: turnBackward var(--animation-duration) forwards;
}
.front-page,
.back-page {
overflow: hidden;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
pointer-events: none;
right: 0;
bottom: 0;
margin: 0;
padding-top: 1rem;
z-index: 1;
}
.back-page {
transform: rotateY(180deg);
visibility: hidden;
}
.turn-page.turn>div.front-page {
animation: fadeOut var(--animation-duration) forwards;
}
.turn-page.turnBack>div.front-page {
animation: fadeIn var(--animation-duration) forwards;
}
.turn-page.turnBack>div.back-page {
animation: fadeOut var(--animation-duration) forwards;
}
.turn-page.turn>div.back-page {
animation: fadeIn var(--animation-duration) forwards;
}
/* 淡出动画 */
@keyframes fadeOut {
0% {
visibility: visible;
}
49% {
visibility: visible;
}
50% {
visibility: hidden;
}
100% {
visibility: hidden;
}
}
/* 淡入动画 */
@keyframes fadeIn {
0% {
visibility: hidden;
}
50% {
visibility: hidden;
}
51% {
visibility: visible;
}
100% {
visibility: visible;
}
}
@keyframes turnForward {
0% {
z-index: 999;
}
40% {
z-index: 999;
}
49% {
transform: rotateY(-90deg);
}
50% {
transform: rotateY(-90deg);
}
100% {
transform: rotateY(-180deg);
z-index: null;
}
}
@keyframes turnBackward {
0% {
z-index: 999;
}
40% {
z-index: 999;
}
49% {
transform: rotateY(-90deg);
}
50% {
transform: rotateY(-90deg);
}
100% {
transform: rotateY(0deg);
z-index: null;
}
}
</style>
使用场景
让cursor帮我们将其封装成组件

这个电子书组件就可以适用于多种场景,包括但不限于以下场景:
- 在线阅读应用:为用户提供一个沉浸式的阅读体验
- 作品展示:展示宫崎骏等著名导演的作品集
- 教育平台:作为互动教材的一部分
- 产品演示:展示产品特性或使用指南
总结
最终的效果


通过上述实现,我们成功创建了一个具有翻页效果的电子书组件。这个组件充分利用了 CSS 3D 变换和动画功能,同时结合 Vue 的组件化思想,实现了既美观又实用的交互效果。
代码结构清晰,易于维护和扩展,可以方便地应用于各种需要展示书籍内容的场景。