第二期 · 使用 Vue 3.1 + TypeScript + Router + Tailwind.css 仿 itch.io 平臺主頁。 我的主題 HapiGames 是仿 itch.io 的 indie game hosting marketplace。 效果圖 代碼倉庫 alicepolice ...
第二期 · 使用 Vue 3.1 + TypeScript + Router + Tailwind.css 仿 itch.io 平臺主頁。
我的主題 HapiGames 是仿 itch.io 的 indie game hosting marketplace。
效果圖
代碼倉庫
alicepolice/Vue at 06 (github.com)
風格指南
當你掌握一門語言的時候,在寫項目之前不妨先看看風格指南吧,前人早為你鋪好了路。下麵是我自己編寫項目代碼時沒有規範到位的幾個點。
Prop 定義
Prop 定義應該儘量詳細,至少需要指定其類型。Props | Vue.js (vuejs.org)
Vue的選項式API為我們提供了Prop校驗,你可以向 props
選項提供一個帶有 props 校驗選項的對象,當 prop 的校驗失敗後,Vue 會拋出一個控制台警告 (開發模式)。(如果用ts的話更好)
註意 prop 的校驗是在組件實例被創建之前,所以實例的屬性 (比如 data
、computed
等) 將在 default
或 validator
函數中不可用。
v-for和v-if同時在一個標簽時,將v-if提取到計算屬性
因為 v-for 優先順序比 v-if 高,所以每次渲染時必定會遍曆數組所有元素。避免 v-if 和 v-for 用在一起
將v-if提取到計算屬性後的好處
- 過濾後的列表只會在對應數組發生相關變化時才被重新運算,過濾更高效。
- 使用
v-for="item in afterComputed"
之後,在渲染的時候遍歷元素少了,渲染更高效。 - 解耦渲染層的邏輯,可維護性 (對邏輯的更改和擴展) 更強。
緊密耦合的組件名
和父組件緊密耦合的子組件應該以父組件名作為首碼命名。緊密耦合的組件名
如果一個組件只在某個父組件的場景下有意義,這層關係應該體現在其名字上。因為編輯器通常會按字母順序組織文件,所以這樣做可以把相關聯的文件排在一起。
不建議為了緊密耦合搞目錄區分,因為會出現文件名名字相同、IDE側邊欄瀏覽組件花費時間多的問題。
components/
|- TodoList.vue
|- TodoListItem.vue
|- TodoListItemButton.vue
自閉合組件
在單文件組件、字元串模板和 JSX 中沒有內容的組件應該是自閉合的——但在 DOM 模板里永遠不要這樣做。 自閉合組件
<!-- 在單文件組件、字元串模板和 JSX 中 -->
<MyComponent/>
<!-- 在 DOM 模板中 -->
<my-component></my-component>
Prop 名大小寫
在聲明 prop 的時候,其命名應該始終使用 camelCase,而在模板和 JSX 中應該始終使用 kebab-case。 Prop 名大小寫
props: {
greetingText: String
}
<WelcomeMessage greeting-text="hi"/>
簡單的計算屬性
應該把複雜計算屬性分割為儘可能多的更簡單的 property。 簡單的計算屬性
好處是易於測試、易於閱讀、更好的“擁抱變化”。
單文件組件的頂級元素的順序
單文件組件應該總是讓 <script>、<template> 和 <style> 標簽的順序保持一致。且 <style> 要放在最後,因為另外兩個標簽至少要有一個。 單文件組件的頂級元素的順序
隱性的父子組件通信
應該優先通過 prop 和事件進行父子組件之間的通信,而不是
this.$parent
或變更 prop。 隱性的父子組件通信
數據流應該是單向的,不要反向修改 props。
方便調試
為了方便調試,我們在 index.css 下新增一個樣式組合,通過添加test類樣式類看到塊元素的邊框。
.test{
@apply border border-gray-900
}
目錄結構
├───assets
│ ├───avater
│ │ 用戶頭像
│ ├───blog
│ │ 博文封面圖
│ ├───diffuse
│ │ 模糊背景
│ ├───game
│ │ 游戲封面圖
│ ├───logo
│ │ 網站logo
│ ├───slideshow
│ │ 輪播圖樣圖
│ └───svg
│ 很多矢量圖
├───components
│ ├───common
│ │ BottomBar.vue
│ │ CommentArea.vue
│ │ SideBar.vue
│ │ SideBarHref.vue
│ │ SlideShow.vue
│ │ TopBar.vue
│ │
│ └───HomeView
│ GameBlog.vue
│ GameInfo.vue
│ GameList.vue
│ HomeFAQ.vue
│ HomeFooter.vue
│ PlatformNavigation.vue
│ TopNavigation.vue
│
├───router
│ index.ts
└───views
AboutView.vue
CommentTestView.vue
HomeLoginView.vue
HomeView.vue
LoginView.vue
RegisterView.vue
網站頂部組件 TopBar.vue
在 src/components/common 下新建 TopBar.vue,並移入之前寫的 BottomBar.vue。
先從網站頂部開始,該組件在每個頁面都會顯示,併在滾動過程中固定定位。
編寫代碼,實現頂部欄。
<template>
<div class="h-12 shadow-md">
<div class="inline-block h-full w-16">
<b-icon-list class="text-3xl mt-2 ml-4"></b-icon-list>
</div>
<div class="inline-block h-full w-48">
<img src="@/assets/logo/logo3.png" class="mt-1 h-4/5 w-full" />
</div>
<div class="inline-block float-right mt-2.5 mr-4">
<div
class="
border-2 border-gray-300
px-3.5
py-0.5
rounded-sm
text-sm
font-bold
"
>
Log in
</div>
</div>
</div>
</template>
側邊導航欄組件 SideBar.vue
註釋底部導航欄
我的Vue之旅、05 導航欄、登錄、註冊 (Mobile) - 小能日記 - 博客園 (cnblogs.com)
在前一期內容中,我們創建的導航欄是底部導航欄。
現在我們推倒重來,實現一下側邊導航欄。
側邊欄導航也叫抽屜式導航是隱藏在界面側邊的位置,一般是通過點擊界面左上角的icon彈出,主要承載的內容是除了核心功能意外的主要功能。側邊欄還分全側邊和半側邊。
當我們在App.vue中註釋掉現有的底部導航欄,此時會出現錯誤item.routerName => item對象的類型為 "unknown"。
<template>
<router-view @set-bottom-flag="setBottomFlag" />
<!-- <BottomBar v-show="bottomFlag" :items="bottomItems" /> -->
</template>
<script lang="ts">
import { defineComponent } from "vue";
// import BottomBar from "@/components/BottomBar.vue";
export default defineComponent({
name: "App",
components: {
// BottomBar,
}
...
[TS]使用高級類型PropType註釋props類型
IDE報錯並不影響當前Vue實例,因為BottomBar組件並未掛載。但為了去除報錯,使用高級類型註釋來修改BottomBar.vue。
運行時 props
選項僅支持使用構造函數來作為一個 prop 的類型,沒有辦法指定多層級對象或函數簽名之類的複雜類型。在這裡可以使用 PropType 註釋複雜的props類型,報錯解決。
<script lang="ts">
import { PropType } from "vue";
interface BottomItem {
text: string;
icon: string;
routerName: string;
}
export default {
props: {
items: {
type: Array as PropType<BottomItem[]>,
required: true,
},
},
};
</script>
SideBarHref.vue
在 src/components/common 下新建 SideBarHref.vue
側邊導航欄有相似之處,不妨將這一塊提取成獨立的組件,然後復用三次。
添加樣式 hover:text-rose-500 hover:underline
,在移動端按下時會改變顏色。
<a :href="value.href">
用於臨時超鏈接占位,後續可改為router-link
<template>
<div class="mt-8 mx-2">
<div class="font-bold text-stone-700 text-sm">{{ items.title }}</div>
<div class="w-full text-stone-600 mt-2 text-sm">
<template v-for="(value, index) in items.items" :key="index">
<a :href="value.href">
<div
class="
py-1
inline-block
w-1/2
align-middle
hover:text-rose-500 hover:underline
"
v-html="value.text"
></div>
</a>
</template>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from "vue";
interface item {
text: string;
href: string;
}
interface items {
items: item[];
title: string;
}
export default {
name: "SideBarHref",
props: {
items: { type: Object as PropType<items>, required: true },
},
};
</script>
SideBar.vue
遮蔽層
在 src/components/common 下新建 SideBar.vue 以下代碼片段均為分段表示,不是完整代碼。
先寫model層(遮蔽層),一般指側邊欄滾出後背景變黑的部分。
我們使用自定義類名實現過渡動畫。類名也是TailWind.css的類樣式,給定200毫秒時間,過渡透明度狀態。
div嵌套了兩層,把opacity-50寫到裡面的div層能解決opacity-50在外面div層的時候出現背景全黑問題。
fixed 用於固定遮蔽層。z-30用於設置優先順序,先顯示在前面。v-show由App.vue傳入,頂部組件通知App.vue事件對應的方法修改,進而引發當前transition的過渡。
html - Vue Transition with Tailwind - Stack Overflow
<template>
<transition
enter-active-class="duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200"
leave-to-class="opacity-0"
leave-from-class="opacity-100"
>
<div class="fixed z-30 h-full w-full" v-show="showFlag" id="model">
<div class="bg-black h-full w-full opacity-50"></div>
</div>
</transition>
側邊欄
側邊欄的動畫效果跟遮蔽層一個原理,只不過修改成為了移動而不是改變透明度。
overflow-auto 可以讓側邊欄在內容溢出時具備滾動條。
<transition
enter-active-class="duration-200 ease-out"
enter-from-class="-translate-x-64"
enter-to-class="translate-x-0"
leave-active-class="duration-200 ease-in"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-64"
>
<div
class="fixed z-40 top-12 w-64 h-full bg-stone-100 border-r overflow-auto"
v-show="showFlag"
id="sideBar"
>
搜索框
focus:outline-none focus:ring focus:border-blue-200
噹噹前游標指向該input標簽時更改樣式,讓四角發光變藍。該段代碼也可以提取成基本組件。
<div class="mt-3 mx-2">
<input
id="search"
class="
bg-white
focus:outline-none focus:ring focus:border-blue-200
py-1.5
pl-3
w-full
border border-gray-300
text-sm
"
type="text"
placeholder="Search games & creators"
v-model="search"
/>
</div>
SideBarHref
三次復用之前定義的SideBarHref組件,並傳入了props
<SideBarHref :items="popularTags"></SideBarHref>
<SideBarHref :items="browse"></SideBarHref>
<SideBarHref :items="gamesByPrice"></SideBarHref>
download app
讓圖標和下載超鏈接完全數據化,增加網頁動態變化能力。
<div class="h-20 text-center">
<div class="pt-6">
<template v-for="(value, index) in appInfo.apps" :key="index">
<a :href="value.href">
<component
:is="value.icon"
class="inline m-1 text-xl hover:text-rose-500"
></component>
</a>
</template>
<a :href="appInfo.download.href">
<span
class="
text-xs text-stone-800
mx-2
hover:text-rose-500 hover:underline
"
>{{ appInfo.download.title }}</span
>
</a>
</div>
</div>
數據驅動
除非結構要改,現在完全可以靠data里的對象數據驅動當前側邊欄的所有內容。
data() {
return {
search: "",
popularTags: {
title: "POPULAR TAGS",
items: [
{ text: "Horror games", href: "" },
{ text: "Multiplayer", href: "" },
{ text: "Visual novels", href: "" },
{ text: "HTML5 games", href: "" },
{ text: "Simulation", href: "" },
{ text: "macOS games", href: "" },
{ text: "Roguelike", href: "" },
{ text: "Linux games", href: "" },
{ text: "Browse all tags", href: "" },
],
},
browse: {
title: "BROWSE",
....
聯動頂部組件與側邊導航欄組件
我們的想法是按下頂部組件左邊的 list icon,彈出導航欄,再按一次關閉導航欄。
很容易想到父子通信的解決方案,這也是Vue單向數據流的最佳實現。
<template>
<TopBar @changeSideFlag="changeSideFlag"></TopBar>
<SideBar :show-flag="sideFlag"></SideBar>
<div class="absolute top-12 w-full z-10">
<router-view />
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import TopBar from "./components/common/TopBar.vue";
import SideBar from "./components/common/SideBar.vue";
export default defineComponent({
name: "App",
components: {
TopBar,
SideBar,
},
data() {
return {
sideFlag: false as boolean,
};
},
methods: {
changeSideFlag(): void {
this.sideFlag = !this.sideFlag;
},
},
});
</script>
[TS]defineComponent 作用
App.vue 里的 export default defineComponent({
是什麼?
搭配 TypeScript 使用 Vue | Vue.js (vuejs.org)
defineComponent 是TypeScript獨有的,可以根據選項式API的props、data自動推導各個欄位的類型,當在生命周期函數、Methods函數、模板表達式中使用這些欄位時可以進行類型檢查。(不顯式引入編譯器預設自動引入)
移動端主頁
HomeView.vue
我們將一個主頁拆分為各個組件,並完全依托數據驅動,圖片僅用來本地測試。
<template>
<HomeFAQ />
<TopNavigation :top-navigation="topNavigation"></TopNavigation>
<GameInfo :game-info="gameInfo"></GameInfo>
<GameBlog :game-blog="gameBlog"></GameBlog>
<PlatformNavigation
:platform-navigation="platformNavigation"
></PlatformNavigation>
<GameList :game-list="latestGames"></GameList>
<GameList :game-list="mostFeatureGames"></GameList>
<HomeFooter />
</template>
<script lang="ts">
import { defineComponent } from "vue";
import GameInfo from "../components/HomeView/GameInfo.vue";
import GameBlog from "../components/HomeView/GameBlog.vue";
import HomeFAQ from "../components/HomeView/HomeFAQ.vue";
import TopNavigation from "../components/HomeView/TopNavigation.vue";
import GameList from "../components/HomeView/GameList.vue";
import PlatformNavigation from "../components/HomeView/PlatformNavigation.vue";
import HomeFooter from "../components/HomeView/HomeFooter.vue";
export default defineComponent({
name: "HomeView",
components: {
GameInfo,
GameBlog,
HomeFAQ,
TopNavigation,
GameList,
PlatformNavigation,
HomeFooter,
},
data() {
return {
topNavigation: [
{ text: "All Games", href: "" },
{ text: "Game jams", href: "" },
{ text: "Developer Logs", href: "" },
{ text: "Community", href: "" },
{ text: "Bundles", href: "" },
],
gameInfo: {
youtube:
"https://www.youtube.com/embed/U7MJljsoUSo?autoplay=0&fs=0&iv_load_policy=3&showinfo=0&rel=0&cc_load_policy=0&start=0&end=0",
title: "Baba Is You",
desc: "You can change the rules by which you play",
price: "$14.99",
platforms: ["b-icon-windows", "b-icon-apple"],
images: [
require("@/assets/slideshow/1.png"),
require("@/assets/slideshow/2.png"),
require("@/assets/slideshow/3.png"),
require("@/assets/slideshow/4.png"),
require("@/assets/slideshow/5.png"),
],
},
gameBlog: [
{
title: "Games of the Month: surrealist solitaire puzzles",
text: `What’s that? You need more games? I hear you, anonymous hapi fan.
We’ve reached the part of the year when games start coming out fast`,
img: require("@/assets/blog/1.jpg"),
},
{
title: "Games of the Month: Puzzles!",
text: `Sometimes you need a good puzzle game, just something to throw all of
your attention at and ignore anything else going on. Well if that
sometime for you is right now, then you’re in luck because in this
Games of the Month`,
img: require("@/assets/blog/2.jpg"),
},
{
title: "The next hapi Creator Day is July 29th!",
text: ` I don’t think I’m allowed to make the entire body of this post “The
next itch.io Creator Day is taking place on Friday July 29th.” I mean
it’s true, we are hosting the next itch.io Creator Day on Friday July
29th but I should probably write more here.`,
img: require("@/assets/blog/3.jpg"),
},
],
platformNavigation: [
{
title: "Windows",
href: "",
img: require("@/assets/svg/windows.svg"),
},
{
title: "macOS",
href: "",
img: require("@/assets/svg/apple.svg"),
},
{
title: "Linux",
href: "",
img: require("@/assets/svg/linux.svg"),
},
{
title: "Android",
href: "",
img: require("@/assets/svg/android.svg"),
},
{
title: "iOS",
href: "",
img: require("@/assets/svg/apple.svg"),
},
{
title: "Web",
href: "",
img: require("@/assets/svg/web.svg"),
},
{
title: "Free",
href: "",
img: require("@/assets/svg/free.svg"),
},
{
title: "On Sale",
href: "",
img: require("@/assets/svg/sale.svg"),
},
{
title: "Top Seller",
href: "",
img: require("@/assets/svg/star.svg"),
},
{
title: "Recent",
href: "",
img: require("@/assets/svg/recent.svg"),
},
],
latestGames: {
title: "Latest Featured Games",
button: {
title: "View all",
href: "",
},
games: [
{
title: "Late Night Mop",
text: "A haunted house cleaning simulator.",
img: require("@/assets/game/1.png"),
price: 0,
},
{
title: "an average day at the cat cafe",
text: "A haunted house cleaning simulator.",
img: require("@/assets/game/2.png"),
price: 0,
web: true,
},
{
title: "Corebreaker",
text: "A fast-paced action-platform shooter game with roguelike elements.",
img: require("@/assets/game/3.png"),
price: 19.99,
tags: ["Difficult", "Fast-Paced"],
},
{
title: "Beacon Pines",
text: "Normal isn't what it used to be.",
img: require("@/assets/game/4.png"),
price: 4.99,
},
{
title: "Atuel",
text: "Traverse a surrealist landscape inspired by the Atuel River in Argentina.",
img: require("@/assets/game/5.png"),
price: 0,
},
],
},
mostFeatureGames: {
title: "Most Featured Games",
button: {
title: "View all",
href: "",
},
games: [
{
title: "Hitobito no Hikari - Heian Jidai",
text: "A survival horror TTRPG about cursed priestesses.",
img: require("@/assets/game/6.png"),
tags: ["Physical games"],
price: 3,
},
{
title: "Doko Roko",
text: "A symbiosis with ancient shadows. A tower full of demons. A proverb.",
img: require("@/assets/game/7.png"),
price: 10,
},
{
title: "The Zachtronics Solitaire Collection",
text: "All seven Zachtronics solitaire games, updated with new 4K graphics, plus one brand new Tarot-themed solitaire variant.",
img: require("@/assets/game/8.png"),
price: 9.99,
tags: ["Card Game", "Singleplayer"],
},
{
title: "Mixolumia",
text: "Entrancing musical falling block puzzler.",
img: require("@/assets/game/9.png"),
price: 10,
tags: ["High Score", "Arcade"],
},
{
title: "Atuel",
text: "Traverse a surrealist landscape inspired by the Atuel River in Argentina.",
img: require("@/assets/game/5.png"),
price: 0,
},
{
title: "Corebreaker",
text: "A fast-paced action-platform shooter game with roguelike elements.",
img: require("@/assets/game/3.png"),
price: 19.99,
tags: ["Difficult", "Fast-Paced"],
},
],
},
};
},
});
</script>
HomeFAQ.vue
<template>
<div class="h-24 p-2 text-sm bg-stone-100">
<div class="mt-1">
<b>HapiGames</b> is a simple way to find and share indie games online for
free.
</div>
<div class="mt-2">
<a class="underline text-rose-500">Add your game</a> or
<a class="underline text-rose-500">Read the FAQ</a>
</div>
</div>
</template>
<script>
export default {
name: "HomeFAQ",
}
</script>
TopNavigation
overflow-x-auto flex flex佈局,併在溢出時開啟橫軸滾動條
whitespace-nowrap 類可以防止換行,讓所有元素保持在一行上。
html - Div with horizontal scrolling only - Stack Overflow
<template>
<div class="overflow-x-auto flex bg-white">
<template v-for="(value, index) in topNavigation" :key="index">
<a :href="value.href">
<div
class="
p-3
font-bold
text-sm text-stone-800
hover:text-rose-500
whitespace-nowrap
"
>
{{ value.text }}
</div>
</a>
</template>
</div>
</template>
<script lang="ts">
import { PropType } from "vue";
interface TopNavigation {
text: string;
href: string;
}
export default {
name: "topNavigation",
props: {
topNavigation: {
type: Array as PropType<TopNavigation[]>,
required: true,
},
},
};
</script>
GameInfo.vue
嵌入YOUTUBE視頻可參考 youtubeembedcode.com
<div class="w-full">
<img :src="currentImg[0]" class="w-1/2 inline-block" />
<img :src="currentImg[1]" class="w-1/2 inline-block" />
</div>
用於生成兩張輪播圖,每四秒切換一次,具體方法如下。註意 currentImg: function (): string[] {
可以給計算屬性添加類型檢查。
methods: {
startSlide: function (): void {
this.timer = setInterval(this.next, 4000);
},
next: function (): void {
this.currentIndex += 1;
},
},
computed: {
currentImg: function (): string[] {
let index = Math.abs(this.currentIndex) % this.gameInfo.images.length;
let index2 = (index + 1) % this.gameInfo.images.length;
return [this.gameInfo.images[index], this.gameInfo.images[index2]];
},
},
考慮 img 標簽的 :src 只能接收 string ,我們假設所有 require 方法獲取的圖片均為 string 類型。定義prop類型
import { PropType } from "vue";
interface GameInfo {
youtube: string;
title: string;
desc: string;
price: number;
platforms: string[];
images: string[];
}
完整代碼如下
<template>
<div
class="bg-auto p-1 text-white"
:style="
'background-image:url(' + require('@/assets/diffuse/diffuse.jpg') + ')'
"
>
<div class="h-52 m-2">
<iframe
class="h-full w-full"
frameborder="0"
scrolling="no"
marginheight="0"
marginwidth="0"
type="text/html"
:src="gameInfo.youtube"
></iframe>
</div>
<div class="ml-2 font-bold text-xl">{{ gameInfo.title }}</div>
<div class="ml-2 text-sm">{{ gameInfo.desc }}.</div>
<div class="m-2">
<div class="w-full">
<img :src="currentImg[0]" class="w-1/2 inline-block" />
<img :src="currentImg[1]" class="w-1/2 inline-block" />
</div>
</div>
<div class="h-8 m-2">
<div
class="
inline-block
text-black
bg-white
rounded-md
text-xs
px-1
py-0.5
font-bold
"
>
${{ gameInfo.price }}
</div>
<template v-for="(value, index) in gameInfo.platforms" :key="index">
<component :is="value" class="inline-block ml-2"></component>
</template>
</div>
<div class="h-10 m-2 w-44">
<div
class="
h-full
text-lg
font-bold
py-1
px-3
border-2 border-white
rounded-sm
"
>
<span class="inline-block">Get the game</span>
<b-icon-arrow-right
class="inline-block ml-1 text-lg"
></b-icon-arrow-right>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from "vue";
interface GameInfo {
youtube: string;
title: string;
desc: string;
price: number;
platforms: string[];
images: string[];
}
export default {
name: "GameInfo",
props: {
gameInfo: {
type: Object as PropType<GameInfo>,
required: true,
},
},
data() {
return {
timer: null as unknown,
currentIndex: 0,
};
},
mounted() {
this.startSlide();
},
methods: {
startSlide: function (): void {
this.timer = setInterval(this.next, 4000);
},
next: function (): void {
this.currentIndex += 1;
},
},
computed: {
currentImg: function (): string[] {
let index = Math.abs(this.currentIndex) % this.gameInfo.images.length;
let index2 = (index + 1) % this.gameInfo.images.length;
return [this.gameInfo.images[index], this.gameInfo.images[index2]];
},
},
};
</script>
GameBlog.vue
<template>
<div class="m-2 mt-4">
<div class="font-bold">From the blog</div>
<div class="overflow-x-auto flex mt-2">
<template v-for="(value, index) in gameBlog" :key="index">
<div class="w-48 flex-shrink-0 mr-2">
<img class="h-24 w-full" :src="value.img" />
<div class="text-xs font-bold mt-1 text-stone-800 whitespace-normal">
{{ value.title }}
</div>
<div class="h-12 text-xs overflow-clip mt-1 text-stone-500">
{{ value.text }}
</div>
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from "vue";
interface GameBlog {
title: string;
text: string;
img: string;
}
export default {
name: "GameBlog",
props: {
gameBlog: {
type: Array as PropType<GameBlog[]>,
required: true,
},
},
};
</script>
TopNavigation.vue
<template>
<div class="m-2 mt-4">
<div class="font-bold inline-block">Platform & Sale</div>
<div class="flex mt-2 flex-wrap">
<a
:href="value.href"
v-for="(value, index) in platformNavigation"
:key="index"
class="w-1/5 flex-shrink-0 hover:text-rose-500"
>
<div>
<img :src="value.img" class="w-2/5 mx-auto mt-1" />
<div class="text-center m-1.5 text-xs">{{ value.title }}</div>
</div>
</a>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from "vue";
interface platformNavigation {
title: string;
href: string;
img: string;
}
export default {
name: "PlatformNavigation",
props: {
platformNavigation: {
type: Array as PropType<platformNavigation[]>,
required: true,
},
},
data() {
return {};
},
};
</script>
GameList.vue
規定了比較複雜的傳入prop類型,考慮到tags可能為空,在原來的模板外層div做v-if判斷,否則會ts報錯value.tags可能為undefined。
interface Game {
title: string;
text: string;
img: string;
price: number;
web?: boolean;
tags?: string[];
}
interface GameList {
title: string;
button: {
title: string;
href: string;
};
games: Game[];
}
<div class="text-xs font-normal mt-1" v-if="value.tags">
<template v-for="(tag, index) in value.tags" :key="index">
<a class="text-rose-500" href="">#{{ tag }}</a>
<template v-if="index != value.tags.length - 1">,</template>
</template>
</div>
完整代碼
<template>
<div class="m-2 mt-4">
<div>
<div class="font-bold inline-block">{{ gameList.title }}</div>
<div v-if="gameList.button" class="float-right">
<div
class="
border border-rose-400
text-sm
font-bold
text-rose-500
rounded-sm
px-4
py-1
active:bg-rose-400 active:text-white
"
>
{{ gameList.button.title }}
<b-icon-arrow-right
class="inline-block text-lg align-text-top"
></b-icon-arrow-right>
</div>
</div>
<div class="w-full mt-4 flex flex-wrap justify-between">
<template v-for="(value, index) in gameList.games" :key="index">
<div class="w-44 inline-block align-top">
<img class="h-28 w-full" :src="value.img" />
<div
class="text-xs font-bold mt-1 text-stone-800 w-3/4 inline-block"
>
{{ value.title }}
</div>
<div
class="
inline-block
w-1/4
align-top
text-xs
bg-stone-200
rounded-sm
py-0.5
mt-1
text-center
font-bold
"
:class="{ 'bg-stone-500': value.price != 0 }"
>
<span v-if="value.web">WEB</span>
<span v-else-if="value.price == 0">FREE</span>
<span v-else-if="value.price != 0" class="font-normal text-white"
>${{ value.price }}</span
>
</div>
<div class="text-xs font-normal mt-1" v-if="value.tags">
<template v-for="(tag, index) in value.tags" :key="index">
<a class="text-rose-500" href="">#{{ tag }}</a>
<template v-if="index != value.tags.length - 1">,</template>
</template>
</div>
<div class="text-xs font-normal text-stone-500 mt-1">
{{ value.text }}
</div>
<div class="my-1"></div>
</div>
</template>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PropType } from "vue";
interface Game {
title: string;
text: string;
img: string;
price: number;
web?: boolean;
tags?: string[];
}
interface GameList {
title: string;
button: {
title: string;
href: string;
};
games: Game[];
}
export default {
name: "GameList",
props: {
gameList: {
type: Object as PropType<GameList>,
required: true,
},
},
};
</script>
HomeFooter.vue
<template>
<div class="mx-2 my-4">
<div class="text-center font-bold text-sm">
Don't see anything you like?
</div>
<div
class="
w-11/12
h-10
pt-2.5
text-center
m-auto
mt-4
border border-rose-500
font-bold
text-sm text-rose-500
"
>
View all Games
<b-icon-arrow-right
class="inline-block text-lg align-text-top"
></b-icon-arrow-right>
</div>
<div
class="
w-11/12
h-10
pt-2.5
text-center
m-auto
mt-4
border border-rose-500
font-bold
text-sm text-rose-500
"
>
View something random
<b-icon-arrow-left-right
class="inline-block text-lg align-text-top"
></b-icon-arrow-left-right>
</div>
</div>
</template>
<script>
export default {
name: "HomeFooter",
props: {
}
}
</script>
幾個問題
這裡列舉我在開發過程遇到的一些問題,也許能幫助到你。
ERROR Error: The project seems to require yarn but it's not installed.
明明 yarn serve 成功了,並顯示如下內容,但連接網頁還是轉圈圈。嘗試重啟電腦後重新 yarn serve
App running at:
- Local: http://localhost:8080/
得到如下報錯
ERROR Error: The project seems to require yarn but it's not installed.
解決方法:刪除當前目錄下的 yarn.lock 文件,命令行輸入 npm install -g yarn
Type assertion expressions can only be used in TypeScript files.Vetur(8016)
解決方法:修改 <script> 為 <script lang="ts">
網站資源
Bootstrap Icons · Official open source SVG icon library for Bootstrap (getbootstrap.com)
Working with props declaration in Vue 3 + Typescript - DEV Community