电影搜索项目-Vue3.0

案例界面
源码

技术栈:

  • Vue Js、Vue-router、 Vite
  • Tailwind
  • Axios、OMDB API

01

简介

使用vite构建,Vue使用3.0版本,结合Vue router渲染页面,使用Tailwindcss构建响应式页面,使用Axios获取数据,写入页面。

预备知识

.env 文件
环境变量和模式 - vite官方文档
.env - 所有情况下都会加载
为了防止意外地将一些环境变量泄漏到客户端,只有以 VITE_ 为前缀的变量才会暴露给经过 vite 处理的代码。

1
2
3
4
5
6
例子:
DB_PASSWORD=foobar
VITE_SOME_KEY=123

只有 VITE_SOME_KEY 会被暴露为 import.meta.env.VITE_SOME_KEY 提供给客户端源码,而 DB_PASSWORD 则不会。

postcss - 来自package.json
postcss - 官方文档
这里使用的是autoprefixer - 自动添加前缀

认识Vue 的 export、export default、import
ES6模块主要有两个功能:export和import
在一个文件或模块中,export、import可以有多个,export default仅有一个
通过export方式导出,在导入时要加{ },export default则不需要

JavaScript中Map和ForEach的区别
Map - 返回新的数组
ForEach - 不返回新的数组
都是执行函数

import { ref } from "vue";
Vue 3: Reactivity Made Easy
响应式数据

import { watchEffect, ref } from '@vue/runtime-core'
使用到了哪个ref/reactive对象.value,就监听哪个
自动收集,初始化自动执行一次,拿不到oldValue

<script setup>
script setup - 官方文档
Vue3.0的新语法糖-script setup

  • 当使用 <script setup> 的时候,任何在 <script setup> 声明的顶层的绑定 (包括变量,函数声明,以及 import 引入的内容) 都能在模板中直接使用:
  • import 导入的内容也会以同样的方式暴露。
  • 组件也能使用
  • 响应式状态需要明确使用响应式 APIs 来创建 - ref
  • <script setup> 中可以使用顶层 await。结果代码会被编译成 async setup():
  • ? 省了生命周期???- 省了很多。。

defineProps
defineProps 和 defineEmits
<script setup> 中必须使用 defineProps 和 defineEmits API 来声明 props 和 emits ,它们具备完整的类型推断并且在 <script setup> 中是直接可用的:
使用props的方式

vue路由上信息 - 看看怎么用的。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
meta: {
enterClass: "animate__animated animate__fadeInLeft",
leaveClass: "animate__animated animate__fadeOutLeft",
},

// 在app.vue里面使用 - 当前路由 - 加载动画
<transition
:enter-active-class="route.meta.enterClass"
:leave-active-class="route.meta.leaveClass"
mode="out-in"
>
<component :is="Component" />
</transition>

// 怎么用的???有种引入页面是给当前页面写入jsx的感觉

1
2
3
4
const router = createRouter({ // 创建可供 Vue 应用程序使用的 Router 实例。
history: createWebHistory(), // 创建 html5历史记录。单页应用程序最常见的历史记录。应用程序必须通过 http 协议提供服务。
routes,
});

import { onMounted } from '@vue/runtime-core'
注册组件挂载后要调用的回调。 - 生命周期钩子

构建过程

开始!

目录结构

src

  • api - 获取资源 - axios
  • assets - 资源 - css + png
  • components - 可复用组件
  • router
  • views - 配合路由
  • App.vue - 渲染页面 + 动画效果
  • main.js - 起点 - 引入资源 - 全局css

.env - 环境变量

// 配置文件
tailwind.config.js
vite.config.js
postcss.config.js

路由信息

可以看到页面主要内容

/ - Home.vue
/detail/:id - Details.vue
/fav - Favorite.vue
/:pathMath(.) - NotFound.vue

数据获取

主要使用axios,获取数据,使用await,异步获取,然后写入数据。
数据都使用ref来定义,可以响应式获取 - 跨组件???

getMovie.js - 获取数据信息内容 - Details.vue使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { ref } from "vue";
import axios from "axios";

const movie = ref([]); // 使用ref,创建响应式数据
const load = ref(true);
const msg = ref("Please Wait");
const loadAMovie = ref(false);

const loadMovie = async id => {
load.value = true;
loadAMovie.value = false;
msg.value = "Please Wait";
try {
// 获取数据 - 接口地址 来自.env, id来自外部数据传入!
// data, status接收返回信息
const { data, status } = await axios.get(`${import.meta.env.VITE_API_URL}?apikey=${import.meta.env.VITE_API_KEY}&i=${id}`);
// 没有接收到
if (status != 200) {
throw new Error(data.Error);
}
// 写入数据 - 自动响应到页面
movie.value = data;
load.value = false;
loadAMovie.value = true;

} catch (err) {
console.log(err.message);
}
};
// 导出 - 外部可访问!
export default { movie, load, msg, loadAMovie, loadMovie };

getMovies.js - 获取大致内容 - Home.vue使用
根据ref很好用,数据直接传过去了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { ref } from "vue";
import axios from "axios";

const movies = ref([]); // ref
const totalResults = ref(0);
const load = ref(true);
const loadAMovie = ref(false);
const msg = ref("Please Wait");

const loadMovies = async keyword => { // 异步
if (keyword == null) {
keyword = "One Piece";
}
msg.value = "Please Wait";
try {
let { data } = await axios.get(`${import.meta.env.VITE_API_URL}?apikey=${import.meta.env.VITE_API_KEY}&s=${keyword}`);
if (data.Response == "False") {
throw new Error(data.Error);
}
movies.value = await data.Search; // 写入
totalResults.value = data.totalResults;
load.value = false;
loadAMovie.value = true;
} catch (err) {
load.value = true;
msg.value = err.message;
loadAMovie.value = false;
}
};

const nextPages = async (page, keyword) => {
load.value = true;
msg.value = "Please Wait";
try {
// 主要是在请求的时候,多了page参数!
let { data } = await axios.get(`${import.meta.env.VITE_API_URL}?apikey=${import.meta.env.VITE_API_KEY}&s=${keyword}&page=${page}`);
if (data.Response == "False") {
throw new Error(data.Error);
}
data.Search.forEach(movie => movies.value.push(movie)); // 每个信息都输入到movies里面
load.value = false;
loadAMovie.value = true;
} catch (err) {
load.value = true;
msg.value = err.message;
loadAMovie.value = false;
}
};
export default { movies, totalResults, load, msg, loadAMovie, loadMovies, nextPages };

组件内容

App.vue结构

1
2
3
4
5
div.container.mx-auto.px-6.py-7.lg:px-40.lg:py-14 // tailwind样式 mx -> 上下外边距, px -> 上下内边距
Header
router-view - 插槽。
transition - vue动画 - 使用router 里的meta信息
component - 根据插槽渲染 - 内容来源 - router

views

Details.vue结构

响应式考虑的是占多少位置 - 小就占全部,大就分一分。

主要功能:

  1. 获取当前电影的详细信息 - getMovie - 调用api - 写入页面 - 这里是用ref传过来的!!!
  2. 调用getMovies,在详细信息下方,继续显示信息,可以当作推荐电影,这里通过filter,去除了详细显示的电影
  3. 添加喜欢电影功能,如果当前已有-过滤掉 - 去除,如果没有,通过axios,再获取一次,放入favMovies,通过JSON.stringify(favMovies.value),存入localStorage
  4. 添加 / 移出按钮的判断 - filter - 看看长度大小 - 是否存在。。
  5. 如果喜欢电影 - 按钮样式变化 - 变成红色 - filter - 同上。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    div.lg:flex.lg:gap-5.lg:justify-between.lg:items-center
    IsLoading
    div.w-full.h-64.rounded-md.overflow-hidden.md:h-80.lg:w-6/12.lg:h-96 - 大尺寸 - 左右结构,小尺寸,上下结构
    div - 左侧图片
    img
    div - 右侧信息
    p
    h3
    div
    svg
    div
    svg
    div
    svg
    div
    svg
    p
    button
    svg - 图标
    span - 查找有没有指定id,没有返回 加入 信息, 有,返回 移除信息
    hr
    div
    h3
    Movies - 组件


    ----------
    onst favMovies = ref(localStorage.getItem('favMovies') ? JSON.parse(localStorage.getItem('favMovies')) : []);

    // 添加喜欢 按钮 - 是 就移出 - 不是 - 就添加。
    // 为什么要用axios再获取一次呢,这个数据是写给movies的,直接展示就可以了。
    const toggleFav = (id, e) => {
    const cek = favMovies.value.filter(movie => movie.imdbID == id);
    if (cek.length > 0) {
    // remove favorite
    favMovies.value = favMovies.value.filter(movie => movie.imdbID != id);
    localStorage.setItem('favMovies', JSON.stringify(favMovies.value));
    } else {
    if (e.target.tagName.toLowerCase() == 'span') {
    e.target.textContent = 'Wait . . .';
    }
    // add favorite
    axios
    .get(`${import.meta.env.VITE_API_URL}?apikey=${import.meta.env.VITE_API_KEY}&i=${id}`)
    .then(res => {
    const { data } = res;
    const movie = {
    imdbID: data.imdbID,
    Title: data.Title,
    Poster: data.Poster,
    Year: data.Year
    };
    favMovies.value.push(movie);
    localStorage.setItem('favMovies', JSON.stringify(favMovies.value));
    })
    .catch(err => console.log(err));
    }
    };

    // 文字变化
    const handleTextFav = imdbID => {
    if (favMovies.value.length > 0) {
    const cek = favMovies.value.filter(movie => movie.imdbID == imdbID);
    if (cek.length > 0) {
    return 'Remove from Favorite';
    } else {
    return 'Add to Favorite';
    }
    } else {
    return 'Add to Favorite';
    }
    };

    // 样式变化
    const getClass = imdbID => {
    if (favMovies.value.length > 0) {
    const cek = favMovies.value.filter(movie => movie.imdbID == imdbID);

    return {
    'text-red-600': cek.length > 0,
    'text-gray-300': cek.length == 0
    };
    } else {
    return 'text-gray-300';
    }
    };

Home.vue结构
这里的功能:

  1. 获取输入框内容,调用api 获取信息
  2. 滚动加载 - 如果到底了,继续获取数据,调用api,传递page参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    div
    Search - 功能都在Search里面,这里负责传递输入数据,获取信息
    Movies - 显示信息
    IsLoading

    ----------------------------
    相关Js
    const keyword = ref(
    localStorage.getItem('keyword')
    ? localStorage.getItem('keyword')
    : 'One Piece'
    )

    const handleKeyword = (kata) => { // 获取输入框内容
    keyword.value = kata
    loadMovies(keyword.value)
    setTimeout(() => {
    // 页面总数
    totalPage = Math.ceil(totalResults.value / 10)
    }, 1000)
    page.value = 1
    }

    loadMovies(keyword.value)

    const page = ref(1) // 响应式
    let totalPage = 0
    setTimeout(() => {
    totalPage = Math.ceil(totalResults.value / 10)
    }, 1000)

    window.onscroll = () => {
    // 滚到底部
    let bottomOfWindow =
    Math.floor(document.documentElement.scrollTop + window.innerHeight) ===
    document.documentElement.offsetHeight

    if (bottomOfWindow) {
    page.value = page.value + 1
    if (page.value <= totalPage) { // 说明有下一页。
    nextPages(page.value, keyword.value)
    }
    }
    }

Favorite.vue结构
这里功能:

  1. 从localStorage里获取 favMovies的数据,传递给Movies组件,用于显示
  2. 判断喜欢数量是否为空, 有,显示内容,没有,给出提示信息
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    div
    h3
    Movies
    IsLoading

    -------------
    const favMovies = ref( // 直接传递给 Movies
    localStorage.getItem('favMovies')
    ? JSON.parse(localStorage.getItem('favMovies'))
    : []
    )
    const load = ref(false)
    const msg = ref('')
    onMounted(() => { // 加载时,判断,是否有内容。
    if (favMovies.value.length < 1) {
    load.value = true
    msg.value = 'Empty'
    }
    })

NotFound.vue结构

这里添加了一个返回功能。。返回到前一页

1
2
3
4
5
6
7
8
9
10
11
12
13
div.my-8.flex.justify-center
div.flex.flex-col.items-center.text-gray-300
svg.w-8.h-8.mb-3
path
h3.test-lg
button.mt-5.px-6.py-2.bg-blue-600.rounded-md

---------
import { useRouter } from 'vue-router';
const router = useRouter();
const back = () => {
router.go(-1);
};

components

Header.vue结构
主要用来跳转页面。。

1
2
3
4
5
6
div
router-link - 首页
h1 - 首页
router-link - 喜欢页面
svg
span - 喜欢

IsLoading.vue结构
用于显示,内容是否存在 - 喜欢页面 / 搜索不到

1
2
div.mt-5.flex.justify-center.items-center
h2 - 提示信息。。

Movie.vue结构 - 每个电影的卡片 - 给Movies用。
单个电影展示页面,
这里功能:

  1. 如果页面海报不存在,显示备用的 - 无图片样式。
  2. 喜欢按钮 - 内容同上 - 样式 + 添加到喜欢页面
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    div
    router-link - 跳转到Details
    div
    img
    div
    img
    div
    div
    router-link- 跳转到Details
    h3
    button - 判断是否 喜欢 - 样式切换

Movies.vue结构
核心部分:
使用v-for,渲染多个Movie组件。绑定favMovie事件,子组件返回喜欢的电影信息,在这里处理
绑定 key :key="movie.imdbID"

数据来源:
外部传递 - Home.vue / Favorite.vue / Details.vue
然后给子组件。

主要功能:
处理喜欢电影内容

  • 使用ref,子组件传递过来,判断 - 如果已有,说明不喜欢,过滤掉,如果没有,写入favMovies,存入localStorage - JSON.stringify(favMovies.value)
  • 需要获取信息 - 解析JSON - JSON.parse(localStorage.getItem(‘favMovies’))
1
2
3
4
5
6
7
8
9
10
<div сlass="mt-7 w-full flex flex-wrap justify-around gap-5 md:gap-8 lg:gap-5">
<div
class="w-2/5 my-2 md:w-1/4 lg:w-2/12"
v-for="movie in movies"
:key="movie.imdbID"
>
<Movie :movie="movie" @favMovie="handleFav" />
</div>
</div>

Search.vue结构

使用form表单,触发请求
localStorage存储 - Keyword / isRecent
把输入的字符串,处理过后,通过事件发送给Home.vue,用于获取信息
这个字符串,也可以在Details.vue中用到,在下方展示推荐电影

主要功能:

  1. 根据输入框内容获取信息 // 传递给父组件
  2. 删除最近搜索记录 // filter过滤。。
  3. 删除最近搜索记录框。 // true / false
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    form
    div
    input - 输入框
    svg
    div - 显示已搜过的记录
    div - Recent - 显示信息。
    h3 -
    div
    span - Clear Recent - 去除最近搜索的盒子
    svg
    div - 搜索记录详细信息。。
    div
    span - 文字
    svg - 图标

    ------

    import { ref } from '@vue/reactivity';
    const recents = ref(localStorage.getItem('recents') ? JSON.parse(localStorage.getItem('recents')) : []);
    const keyword = ref(localStorage.getItem('keyword') ? localStorage.getItem('keyword') : 'One Piece');
    const isRecent = ref(localStorage.getItem('isRecent') == 'true' ? true : false);
    const emit = defineEmits(['keyword']); // setup必须这样获取

    const handleSearch = () => { // 输入keyword
    localStorage.setItem('keyword', keyword.value);
    localStorage.setItem('isRecent', true);

    isRecent.value = true;
    let str = keyword.value.toLowerCase();
    str = str.trim();
    if (keyword.value) {
    const cek = recents.value.filter(item => item == str);
    if (cek.length < 1) {
    recents.value.push(str);
    localStorage.setItem('recents', JSON.stringify(recents.value));
    }
    emit('keyword', str); // 返回给父组件
    }
    };
    const clearRecent = () => { // 显示与隐藏。。。
    recents.value = [];
    isRecent.value = false;
    localStorage.setItem('recents', JSON.stringify([]));
    localStorage.setItem('isRecent', false);
    };
    const filterRecent = e => { // 删除 - 过滤
    recents.value = recents.value.filter(recent => recent != e.target.dataset.recent);
    if (recents.value.length == 0) {
    isRecent.value = false;
    }
    };
    const changeKeyword = e => { // 再次返回给父组件
    keyword.value = e.target.innerHTML;
    emit('keyword', keyword.value);
    localStorage.setItem('keyword', e.target.innerHTML);
    };

总结

ref - 一个页面修改 - 都修改了。

完成功能:

组件分离

  • 获取数据 + 功能 - Home / Detail / Favorite
  • 发送数据 - Search / Movie - 喜欢事件 / Movies
  • 处理UI - Header / Movie / Movies

获取数据方式

  1. 调用Api
  2. localStorage

搜索信息 - 调用api
搜索记录 - localStorage存储,删除 - filter过滤
滚动加载 - 高度 + 调用api&page
页面动画 - router.meta + transition
添加收藏 - 样式 + 写入localStorage
响应式页面 - 依据md / lg显示,规范尺寸的作用是统一,如果尺寸小,多占点,大,就少占点。