我的Vue之旅 06 超詳細、仿 itch.io 主頁設計(Mobile)

2022-10-19 06:01:44

第二期 · 使用 Vue 3.1 + TypeScript + Router + Tailwind.css 仿 itch.io 平臺主頁。

我的主題 HapiGames 是仿 itch.ioindie game hosting marketplace

效果圖


程式碼倉庫

alicepolice/Vue at 06 (github.com)

風格指南

當你掌握一門語言的時候,在寫專案之前不妨先看看風格指南吧,前人早為你鋪好了路。下面是我自己編寫專案程式碼時沒有規範到位的幾個點。

風格指南 — Vue.js (vuejs.org)


Prop 定義

Prop 定義應該儘量詳細,至少需要指定其型別。Props | Vue.js (vuejs.org)

Vue的選項式API為我們提供了Prop校驗,你可以向 props 選項提供一個帶有 props 校驗選項的物件,當 prop 的校驗失敗後,Vue 會丟擲一個控制檯警告 (開發模式)。(如果用ts的話更好)

注意 prop 的校驗是在元件範例被建立之前,所以範例的屬性 (比如 datacomputed 等) 將在 defaultvalidator 函數中不可用。


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

現在我們推倒重來,實現一下側邊導航欄

側邊欄導航也叫抽屜式導航是隱藏在介面側邊的位置,一般是通過點選介面左上角的icon彈出,主要承載的內容是除了核心功能意外的主要功能。側邊欄還分全側邊和半側邊。

      <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>
  <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

<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

image-20221018225725402

規定了比較複雜的傳入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