第四章:Vue 3 对接指南
你已经拥有了一个非常酷的首页!现在,我们刚刚把它的“心脏”换成了 Vue 3,并接通后端的“血液”(API)。
初始Vue
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue 3 计数器示例(改进版)</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<h1>计数器: {{ count }}</h1> <!-- 显示响应式数据 count -->
<button @click="decrement">-</button> <!-- 绑定减少方法 -->
<button @click="increment">+</button> <!-- 绑定增加方法 -->
</div>
<script>
// 从 Vue 全局对象解构需要的 API
const { createApp, ref } = Vue;
// 创建 Vue 应用实例
createApp({
// setup() 是组合式 API 的入口,用于定义组件的逻辑
setup() {
// 使用 ref() 创建响应式数据,初始值为 0
// ref() 返回一个 Ref 对象,需要通过 .value 访问/修改其值
const count = ref(0);
// 定义增加计数的方法,使用箭头函数
const increment = () => {
// 通过 .value 修改 ref 的值,Vue 会自动触发视图更新
count.value++;
};
// 定义减少计数的方法,使用箭头函数
const decrement = () => {
// 检查当前值是否大于 0,确保不能小于 0
if (count.value > 0) {
// 如果大于 0,则正常减少
count.value--;
} else {
// 如果已经是 0,弹出提示信息(使用 alert 实现简单提示)
// 可以替换为更优雅的 UI 提示,如添加一个消息 ref 变量
alert('不能小于 0!');
}
};
// 从 setup() 返回对象,暴露给模板使用
// 注意:在模板中,{{ count }} 会自动解包 ref.value
return {
count, // 响应式数据
increment, // 增加方法
decrement // 减少方法
};
}
}).mount('#app'); // 将应用挂载到 #app 元素
</script>
</body>
</html>
改造代码
index.html页面改造:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MovieDaily - 每日推荐</title>
<!-- 1. 引入 Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Inter', 'sans-serif'] },
colors: { 'brand-red': '#E50914' }
}
}
}
</script>
<style>
/* Vue 的过渡动画类名 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s ease, transform 0.5s ease; }
.fade-enter-from { opacity: 0; transform: translateY(20px); }
.fade-enter-to { opacity: 1; transform: translateY(0); }
/* 背景淡入淡出 */
.bg-transition { transition: background-image 0.5s ease-in-out; }
</style>
</head>
<!-- 2. 挂载 Vue 应用的根节点 -->
<body class="bg-black h-screen w-screen overflow-hidden text-white relative" id="app">
<!-- 背景层:使用 :style 绑定动态背景图 -->
<div class="absolute inset-0 w-full h-full bg-cover bg-center transition-all duration-700 ease-in-out z-0"
:style="{ backgroundImage: 'url(' + currentMovie.bg + ')' }">
</div>
<!-- 遮罩 -->
<div class="absolute inset-0 bg-gradient-to-r from-black via-black/60 to-transparent z-10"></div>
<!-- 导航 -->
<nav class="absolute top-0 left-0 p-6 z-20">
<div class="text-brand-red font-black text-2xl tracking-tighter uppercase">Movie<span class="text-white">Daily</span></div>
</nav>
<!-- 内容区 -->
<main class="relative z-20 h-full flex flex-col justify-center px-8 md:px-20 max-w-4xl">
<!-- Vue 动画组件 -->
<transition name="fade" mode="out-in">
<!-- 给 key 绑定 ID,当 ID 变化时,Vue 会自动触发重新渲染和动画 -->
<div :key="currentMovie.id" v-if="currentMovie.id !== undefined">
<div class="flex items-center gap-3 mb-4">
<!-- 数据绑定:使用 {{ }} 显示文本 -->
<span class="bg-white/20 backdrop-blur border border-white/10 px-2 py-0.5 rounded text-sm">{{ currentMovie.tags }}</span>
<span class="text-yellow-400 font-bold"><i class="fas fa-star"></i> {{ currentMovie.score }}</span>
</div>
<h1 class="text-5xl md:text-7xl font-extrabold mb-6 leading-tight">{{ currentMovie.title }}</h1>
<p class="text-gray-300 text-lg md:text-xl line-clamp-3 mb-8 max-w-2xl">{{ currentMovie.desc }}</p>
<div class="flex gap-4">
<!-- 属性绑定:使用 :href 动态生成链接 -->
<a :href="'detail.html?id=' + currentMovie.id" class="bg-brand-red hover:bg-red-700 text-white px-8 py-3 rounded font-bold text-lg flex items-center gap-2 transition group cursor-pointer">
<i class="fas fa-play"></i> 立即观看
</a>
<!-- 事件绑定:使用 @click 绑定点击事件 -->
<button @click="fetchMovie" class="bg-white/10 hover:bg-white/20 backdrop-blur border border-white/20 text-white px-6 py-3 rounded font-bold text-lg transition flex items-center gap-2">
<i class="fas fa-random" :class="{ 'animate-spin': loading }"></i>
{{ loading ? '加载中...' : '换一个' }}
</button>
</div>
</div>
</transition>
</main>
<script>
const { createApp, ref, onMounted } = Vue;
createApp({
setup() {
// 定义响应式数据
const currentMovie = ref({});
const loading = ref(false);
// 定义获取电影的方法
const fetchMovie = async () => {
loading.value = true;
try {
// 发起真正的 API 请求
const res = await fetch('/api/movie/random');
const data = await res.json();
// 为了让动画效果更明显,稍微延迟一点点(可选)
setTimeout(() => {
currentMovie.value = data;
loading.value = false;
}, 300);
} catch (error) {
console.error("获取电影失败:", error);
loading.value = false;
}
};
// 页面加载完成后,自动获取一次
onMounted(() => {
fetchMovie();
});
// 把数据和方法暴露给模板
return {
currentMovie,
fetchMovie,
loading
};
}
}).mount('#app');
</script>
</body>
</html>
detail.html页面改造
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电影详情 - MovieDaily</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Inter', 'sans-serif'] },
colors: { 'brand-red': '#E50914', 'dark-bg': '#141414' }
}
}
}
</script>
</head>
<body class="bg-dark-bg text-gray-100 min-h-screen font-sans">
<nav class="sticky top-0 z-50 bg-black/90 border-b border-gray-800 px-6 py-4 flex justify-between items-center">
<a href="index.html" class="text-gray-400 hover:text-white flex items-center gap-2 transition">
<i class="fas fa-arrow-left"></i> 返回推荐
</a>
<div class="text-brand-red font-black text-xl uppercase">Movie<span class="text-white">Daily</span></div>
</nav>
<main class="container mx-auto px-4 py-8 max-w-6xl">
<div class="relative w-full aspect-video bg-black rounded-xl overflow-hidden shadow-2xl border border-gray-800 mb-8 group">
<video id="video-player" class="w-full h-full object-contain" controls autoplay>
<source src="" type="video/mp4">
您的浏览器不支持 Video 标签。
</video>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
<div class="lg:col-span-2 space-y-6">
<div>
<h1 id="d-title" class="text-4xl md:text-5xl font-bold text-white mb-2">标题加载中...</h1>
<div class="flex items-center gap-4 text-sm text-gray-400">
<span id="d-year">2023</span>
<span class="border border-gray-600 px-1 rounded text-xs">HD</span>
<span id="d-duration">2h 0m</span>
<span class="text-green-500 font-bold">98% 推荐</span>
</div>
</div>
<div id="d-tags" class="flex gap-2"></div>
<p id="d-desc" class="text-gray-300 text-lg leading-relaxed">
简介加载中...
</p>
<div class="flex gap-4 pt-4 border-t border-gray-800">
<button class="bg-white text-black px-6 py-2 rounded font-bold hover:bg-gray-200 transition">
<i class="fas fa-share"></i> 分享给朋友
</button>
<button class="bg-gray-800 text-white px-6 py-2 rounded font-bold hover:bg-gray-700 transition">
<i class="fas fa-plus"></i> 加入收藏
</button>
</div>
</div>
<div class="space-y-8">
<div class="bg-[#1f1f1f] p-6 rounded-lg">
<h3 class="text-gray-500 text-sm font-bold uppercase tracking-widest mb-4">导演</h3>
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gray-700 rounded-full flex items-center justify-center">
<i class="fas fa-video text-gray-400"></i>
</div>
<span id="d-director" class="text-white font-medium">Director Name</span>
</div>
</div>
<div class="bg-[#1f1f1f] p-6 rounded-lg">
<h3 class="text-gray-500 text-sm font-bold uppercase tracking-widest mb-4">主演</h3>
<div id="d-actors" class="space-y-3"></div>
</div>
</div>
</div>
</main>
<footer class="border-t border-gray-800 mt-12 py-8 text-center text-gray-500 text-sm">
<p>© 2024 CineDaily Demo. 视频源自 Google/Blender Foundation 测试库。</p>
</footer>
<script>
// 暂时保留你的原始逻辑,第五章我们会把这里改成 API 请求
const moviesData = [
{
id: 0,
title: "星际穿越 (Interstellar)",
year: "2014",
duration: "2h 49m",
tag: "科幻 / 冒险 / 悬疑",
director: "Christopher Nolan",
actors: ["Matthew McConaughey", "Anne Hathaway", "Jessica Chastain"],
desc: "在地球环境日益恶化的未来,一组探险家利用新发现的虫洞,超越了人类太空旅行的极限,试图在广袤的宇宙中寻找人类的新家园。",
videoUrl: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
},
{
id: 1,
title: "赛博朋克:边缘行者",
year: "2022",
duration: "24m / Ep",
tag: "科幻 / 动画 / 动作",
director: "Hiroyuki Imaishi",
actors: ["KENN", "Aoi Yuki", "Hiroki Touchi"],
desc: "在一个沉迷于肉体改造的未来城市中,一名流浪街头的少年为了生存,选择成为一名雇佣兵——也就是众所周知的“赛博朋克”。",
videoUrl: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
},
{
id: 2,
title: "爱乐之城 (La La Land)",
year: "2016",
duration: "2h 8m",
tag: "爱情 / 歌舞 / 剧情",
director: "Damien Chazelle",
actors: ["Ryan Gosling", "Emma Stone", "John Legend"],
desc: "米娅渴望成为一名演员,但至今她仍旧只是片场咖啡厅里的一名平凡巴师。塞巴斯汀醉心于爵士乐,但在现实面前只能在餐厅里弹奏解闷的乐曲。",
videoUrl: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"
}
];
const params = new URLSearchParams(window.location.search);
const movieId = parseInt(params.get('id'));
const movie = moviesData.find(m => m.id === movieId) || moviesData[0];
document.getElementById('d-title').textContent = movie.title;
document.getElementById('d-year').textContent = movie.year;
document.getElementById('d-duration').textContent = movie.duration;
document.getElementById('d-desc').textContent = movie.desc;
document.getElementById('d-director').textContent = movie.director;
const tagsContainer = document.getElementById('d-tags');
movie.tag.split(' / ').forEach(tag => {
const span = document.createElement('span');
span.className = 'bg-gray-800 text-gray-300 text-xs px-2 py-1 rounded';
span.textContent = tag;
tagsContainer.appendChild(span);
});
const actorsContainer = document.getElementById('d-actors');
movie.actors.forEach(actor => {
const div = document.createElement('div');
div.className = 'flex items-center gap-3';
div.innerHTML = `
`;
actorsContainer.appendChild(div);
});
const videoPlayer = document.getElementById('video-player');
videoPlayer.src = movie.videoUrl;
window.scrollTo(0, 0);
</script>
</body>
</html>
1. 发生了什么变化?
- main.py: 后端数据现在更丰富了,包含了
bg(背景图 URL) 和tags。 - index.html:
- 删除了
<div id="movie-content">这种传统的 DOM ID。 - 删除了
const dom = document.getElementById(...)这种手动操作代码。 - 增加了
{{ currentMovie.title }}这样的 Vue 模板语法。 - 增加了
fetch('/api/movie/random')真正去服务器拿数据。
- 删除了
在 index.html 中,我们通过短短几十行代码就实现了一个动态网页。这里面的每一个符号都有其特殊的含义。我们将重点讲解 Composition API (组合式 API) 的写法,这是 Vue 3 目前最推荐的写法。
1. 启动引擎:createApp 和 setup
这是 Vue 应用的“地基”。
const { createApp, ref, onMounted } = Vue;
createApp({
setup() {
// ... 所有的逻辑都写在这里 ...
return { ... }; // 把数据暴露给 HTML 使用
}
}).mount('#app');
createApp: 创建一个 Vue 应用实例。setup(): 这是 Vue 3 的大脑。所有的变量定义、函数方法、生命周期钩子(什么时候干什么事)都写在这个函数里。mount('#app'): 告诉 Vue,“请接管id="app"的那个 HTML 标签”。只有在这个标签内部,Vue 的语法才会生效。
2. 数据的魔力:ref (响应式变量)
这是 Vue 最神奇的地方。
const currentMovie = ref({});
-
什么是 ref?
在普通的 JavaScript 中,let a = 1 只是一个死的数据。但在 Vue 里,ref(1) 把这个数据变成了一个 “响应式对象”。
- 响应式的意思是:一旦你修改了这个数据(
currentMovie.value = ...),Vue 会自动去更新网页上所有用到这个数据的地方。你不需要写document.getElementById(...).innerText = ...。
- 响应式的意思是:一旦你修改了这个数据(
-
注意:在
setup函数内部操作数据时,必须加.value(例如loading.value = true);但在 HTML 模板里使用时,不需要加.value。
3. 把数据印在页面上:{{ }} (插值表达式)
<h1>{{ currentMovie.title }}</h1>
- 含义:双大括号叫做“Mustache”语法(胡子语法)。
- 作用:Vue 会把大括号里的内容看作是变量,计算出它的值,然后替换掉这两个大括号。如果
currentMovie.title变了,这里显示的文字也会立刻变。
4. 控制属性:v-bind (简写为 :)
HTML 标签的属性(如 src, href, style, class)如果要绑定变量,必须用这个指令。
<!-- 场景 A:绑定链接 -->
<a :href="'detail.html?id=' + currentMovie.id">
<!-- 场景 B:绑定样式 -->
<div :style="{ backgroundImage: 'url(' + currentMovie.bg + ')' }">
<!-- 场景 C:绑定 Class -->
<i class="fas fa-random" :class="{ 'animate-spin': loading }"></i>
:的作用:告诉浏览器,“引号里写的不是字符串,而是一段 JavaScript 代码,请算出结果后再填进去”。- 场景 C 详解:
{ 'animate-spin': loading }的意思是:如果loading为true,就加上animate-spin这个类名(让图标转圈);如果是false,就不加。这非常适合做加载状态!
5. 监听操作:v-on (简写为 @)
<button @click="fetchMovie">
- 含义:当用户点击(click)这个按钮时,执行
fetchMovie这个函数。 - 常见用法:除了
@click,还有@submit(提交表单)、@input(输入框打字)等。
6. 控制显示与隐藏:v-if
<div v-if="currentMovie.id !== undefined">
- 含义:如果引号里的条件为真(True),这个
div才会显示;否则,这个div连同里面的内容会在 DOM 中被彻底移除。 - 为什么用它? 因为在页面刚加载时,
currentMovie是个空对象{},此时去读currentMovie.title可能会报错或显示空内容。我们需要等数据加载回来了,再把这块区域显示出来。
7. 生命周期:onMounted
onMounted(() => {
fetchMovie();
});
- 含义:当 Vue 把页面“挂载”(渲染)完成后的那一瞬间,自动执行里面的代码。
- 作用:通常用来发起“第一次”数据请求。如果不写这个,用户刚打开页面时将是一片空白,必须手动点按钮才有数据,体验不好。
8. 动画特效:<transition>
<transition name="fade" mode="out-in">
<div :key="currentMovie.id"> ... </div>
</transition>
这是 Vue 内置的组件,专门用来做特效。
name="fade": 告诉 Vue 去 CSS 里找以.fade-开头的样式(如.fade-enter-active)。:key: 这是动画的关键!当key的值发生变化时(比如电影 ID 从 1 变到 2),Vue 会认为这是两个不同的元素,于是它会先让“旧元素”执行离开动画(Fade Out),再让“新元素”执行进入动画(Fade In)。
👨🏫 总结图谱
| 符号/关键字 | 全称 | 作用 | 口语解释 |
|---|---|---|---|
{{ }} | 插值 | 显示文本 | "把变量的值打印在这里" |
: | v-bind | 绑定属性 | "让 HTML 属性变成动态的" |
@ | v-on | 监听事件 | "当发生这个动作时,执行那个函数" |
ref | Reference | 响应式数据 | "这个变量变了,界面要跟着变" |
v-if | Conditional | 条件渲染 | "满足条件才显示,否则隐身" |
2. 如何验证?
-
保存文件:确保
main.py和static/index.html都已更新。 -
重启服务器:
终端运行:uvicorn main:app --reload
-
访问测试:
打开浏览器访问 http://127.0.0.1:8000
🔍 观察重点:
- 刷新页面:每次刷新,显示的电影应该都不一样(或者有概率一样,因为是随机的)。
- 点击“换一个”:
- 按钮上的图标会转圈(加载动画)。
- 背景图和文字会平滑过渡到下一部电影。
- 重点:这一切都是从 Python 后端拿到的数据!你可以试着改一下
main.py里的某个电影标题,刷新页面,看看前端是不是也变了。
- 点击“立即观看”:
- 观察 URL 地址栏,应该会跳转到
detail.html?id=xx。 - 虽然详情页的数据还是假的(下一章才改),但 ID 传递是正确的。
- 观察 URL 地址栏,应该会跳转到
👨🏫 知识点:Vue 的魔法
在 index.html 里,你看到了类似 :style 和 :href 的写法。
:是v-bind:的缩写。href="..."是普通的 HTML,值是死的。:href="..."告诉 Vue:“引号里面的是代码,请帮我计算出结果再填进去”。
所以 :href="'detail.html?id=' + currentMovie.id" 最终会被渲染成 href="detail.html?id=2"。
【大熊课堂精品课程】
Python零基础入门动画课: https://www.bilibili.com/cheese/play/ss7988
Django+Vue:全栈开发: https://www.bilibili.com/cheese/play/ss8134
PyQT6开发桌面软件: https://www.bilibili.com/cheese/play/ss12314
Python办公自动化: https://www.bilibili.com/cheese/play/ss14990
Cursor AI编程+MCP:零基础实战项目课: https://www.bilibili.com/cheese/play/ss105194189
Pandas数据分析实战: https://www.bilibili.com/cheese/play/ss734522035
AI大模型+Python小白应用实战: https://www.bilibili.com/cheese/play/ss3844