一個 Vue3 項目的流水賬

首先這是一個出于了解 Vue3 語法及相關生態而搞的類似于 在簡書仿簡書 的項目。

具體而言這個項目是這 在簡書仿簡書 的基礎上搞的 Vue3 版本。

Vue2 版本的代碼可以到 這里 查看。

Vue3 版本的代碼可以到 這里 查看。這不給整個star。

在很久很久以前,對于 Vue3 的認識:

新項目預覽點這 road.cemcoe.com


下面是無聊流水賬:

  1. 創建 Vue3 項目

Step 1. 打開 Vue 的官網,看一看最新的腳手架的命令


npm_init_vuelatest.png

Step2. 執行命令

npm init vue@latest

不要傻了吧唧地無腦 vue create,下面是執行操作時終端的輸出:

$ npm init vue@latest

Vue.js - The Progressive JavaScript Framework

√ Project name: ... xbook
√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add an End-to-End Testing Solution? ? No
√ Add ESLint for code quality? ... No / Yes

Scaffolding project in C:\Users\cemcoe\workplace\demo\xbook...

Done. Now run:

  cd xbook
  npm install
  npm run dev


好耶,這里出現了一位新朋友,名為 Pinia,這貨是來狀態管理的,有一說一,用了它之后,我是一點也不想用 VueX 了。

Step3. 跟著官網或者終端的提示把依賴裝一裝,執行一下啟動命令,瞧一瞧頁面。

cd xbook
npm install
npm run dev

毫無意外地會看到這個樣子:


init.png

Step4. 管理一下項目

當然,最好把項目用 git 給管理起來,在配置好 git config 的前提下把項目給 init 一下

git init
  1. 瞧一眼初始化的目錄結構

跟 Vue2 差別不是很大,比較起眼的就是 vite 了,現如今是開發時 Vite, 打包時 rollup

vite.png
  1. 刪除(替換)一下不必要的文件
  • public/favicon.ico 換成自己的
  • src/assets 刪除里面的文件
  • src/componets 清空里邊的文件
  1. 觀摩一下 APP.vue

簡化一下 APP.vue

<script setup></script>

<template>
  <div class="app">
    <h2>app</h2>
  </div>
</template>

<style scoped></style>

觀摩一下 APP.vue,把 script 標簽放在了前面,添加了 setup 語法糖。

  1. 再次運行看一下有沒有錯誤
npm run dev

大概率會出現諸如文件不存在的錯誤,按照提示改一改就好。

  1. 初始化 css

這里用到了 normalize.css,按照官網一把梭安裝導入完事。

  1. 整理項目目錄結構

主要還是和 Vue2 的保持一致

  • assets 靜態文件,imgs css
  • components 組件目錄
  • hooks 封裝的 hooks
  • router 路由相關
  • service/modules 分模塊管理請求
  • service/request 封裝的請求函數
  • store/modules 分模塊管理狀態
  • utils 工具函數
  • views 視圖組件
  1. 選用一個得力的組件庫

這是一個移動端的項目,沒得選就是 Vant 了,打開官網,自己寫著玩當然是選用最新版的啦。

vant.png
npm i vant

執行之后你會發現,額,不對勁,這版本不是 4 呀。

vant-version.png

去官網確定一下命令,你發現,額,我搞得是對的呀。

vant-install-version4.png

這個時候想裝上 vant@4 咋搞。

明顯的是這玩意還沒把默認版本個升到 4,但是文檔 4 對應的安裝命令沒改就很難受,這里就需要自己去找一找了。

  1. 安裝非正式版的 Vant4

既然官方文檔還沒更新,那就到 npm 上去看一下版本號,自己裝一下。

vant-version4.png
npm i vant@4.0.0-rc.6

等安裝完成后再打開 package.json 瞧一下 vant 的版本,欸,不錯,用上了 vant 的新 rc 版本。

"dependencies": {
  "pinia": "^2.0.23",
  "vant": "^4.0.0-rc.6",
  "vue": "^3.2.41",
  "vue-router": "^4.1.5"
},

切記不要裝 alpha 版本,除非,你真的想踩坑,你可能會遇到組件名并沒有導出的狀況,你問我怎么知道的?

當然是我試了 alpha 版本,然后組件都導不進。切記,新也要適度。

  1. 配置組件庫

回到 Vant 的文檔中,按需導入配一下,沒什么東西,照著文檔配就完事了。

import-on-demand.png

配置好之后最好自己測試一下。

別忘了文檔中的第四步,函數組件的樣式記得手動導入一下。

// https://vant-ui.github.io/vant/v4/#/en-US/quickstart#4.-style-of-function-components
Some components of Vant are provided as function, including Toast, Dialog, Notify and ImagePreview. When using function components, unplugin-vue-components can not auto import the component style, so we need to import style manually.
  1. 生成主要的路由 views

到 src/views 目錄下創建如下文件,并填充基本結構

  • home/home.vue
  • following/following.vue
  • profile/profile.vue
  1. 為主要的路由 views 配置路由

打開 router/index.js,照葫蘆畫瓢,搞就完事了。

我更喜歡把 tabbar 相關的路由放在一個單獨的文件中,比如 router/tabbar-routes.js。

這么做的目的在于,對于 tabbar 數據進行統一的管理,同時 meta 中會存儲圖片等信息。

// 其中一個路由對象
{
  path: "/",
  component: () => import("@/views/home/home.vue"),
  meta: {
    text: "首頁",
    image: "tabbar/home.svg",
    imageActive: "tabbar/home_active.svg",
  },
},
  1. 配置 tabbar

到 components 下創建 tab-bar/tab-bar.vue
這是就體現了將 router/tabbar-routes.js 抽離并導出的好處了。

直接把路由信息導入

import { tabbarRoutes } from "@/router/tabbar-routes";

就很棒,不用再搞一份數據了,到 meta 中去拿就好了。

剩下的步驟就很簡單了,就是使用組件庫,具體看 vant 的文檔就行了。

中場休息

現在的大致進度應是,應用底部有一個 tabbar,點擊會切換對應的圖片以及顏色且相關的路由也會一并切換。


  1. 發送網絡請求

別的都不說,先把數據拿到,可供選擇的方案

  • fetch
  • axios

先發一個請求試一試

<script setup>
fetch("https://api.cemcoe.com/v1/posts?page=1&per_page=10", {})
  .then((res) => res.json())
  .then((res) => {
    const { status, data } = res;
    if (status === 200) {
      console.log(data.post);
    }
  });
</script>

再將結果保存到變量中,以便渲染到頁面上去。

簡單定義一個數組,將拿到的數據給 push 上去。

<script setup>
let postList = [];
fetch("https://api.cemcoe.com/v1/posts?page=1&per_page=10", {})
  .then((res) => res.json())
  .then((res) => {
    const { status, data } = res;
    if (status === 200) {
      console.log(data.post);
      // postList = data.post;

      postList.push(...data.post);
    }
  });
</script>

嘗試將 postList 渲染到頁面上,模板部分和 Vue2 沒差,這里就不展示了。

不出意外 postList 新數據是不會展示到頁面上的。

那簡單呀,上 ref,于是又有了下面的代碼:

<script setup>
import { ref } from "vue";
let postList = ref([]);
fetch("https://api.cemcoe.com/v1/posts?page=1&per_page=10", {})
  .then((res) => res.json())
  .then((res) => {
    const { status, data } = res;
    if (status === 200) {
      console.log(data.post);
      // postList = data.post;

      postList.push(...data.post);
    }
  });
</script>

小腦袋瓜轉的真快,可還是不行,額,這里少了一個 value。

于是又又有了下面的代碼:

<script setup>
import { ref } from "vue";
let postList = ref([]);
fetch("https://api.cemcoe.com/v1/posts?page=1&per_page=10", {})
  .then((res) => res.json())
  .then((res) => {
    const { status, data } = res;
    if (status === 200) {
      postList.value.push(...data.post);
    }
  });
</script>

這時的代碼大抵是可用來,頁面上展示了列表了( ?? ω ?? )y

  1. ref 是個什么東東

啥也不說,無腦打開官方文檔瞧一瞧,直奔 API

ref.png
// 從文檔上cv來的
function ref<T>(value: T): Ref<UnwrapRef<T>>;

interface Ref<T> {
  value: T;
}

臨時抱一下 TypeScript 的腳。

function ref<T>(value: T): Ref<UnwrapRef<T>>;

哇,有點復雜的兄弟。簡化一下先,比如將尖括號去掉,基本的類型注解還是瞧的懂的吧。

function ref(value): Ref;

TypeScript 的一大好處就是代碼即文檔,上面代碼的意思是:

有個名為 ref 的函數,你給它一個 value,它給你一個返回值,這個返回值的類型是 Ref

那么 Ref 有是個什么鬼,不要著急,看下一段代碼咯

interface Ref<T> {
  value: T;
}

interface 是定義 interface 的關鍵字(好像什么都沒說),不重要,重要是可以用來干什么?
這里還有一個 T 也是比較特殊的,這玩意和尖括號一起可以稱為泛型,名字很頂呀,簡單來說就是類型變量,可以在使用時聲明具體的類型。

下面寫幾個符合條件的變量:

interface Ref<T> {
  value: T;
}

const ref1: Ref<number> = {
  value: 1,
};

const ref2: Ref<string> = {
  value: "hello",
};

很清楚明白,interface 約束了一個對象,而泛型 T 又約束了 value 的類型。

下面再來匯總一下代碼

// 從文檔上cv來的
function ref<T>(value: T): Ref<UnwrapRef<T>>;

interface Ref<T> {
  value: T;
}

翻譯一下:

ref 是一個函數
函數的形參 value 在使用時指定類型T
函數的返回值為一個由 Ref interface 約束的對象
返回值對象有一個 key 名為value
而 value 的值則是由另一個 UnwrapRef 以及T決定。

那么這個 UnwrapRef 又是啥?文檔上我沒找到,瞧一眼源碼:

// ref.ts

export type UnwrapRef<T> = T extends ShallowRef<infer V>
  ? V
  : T extends Ref<infer V>
  ? UnwrapRefSimple<V>
  : UnwrapRefSimple<T>;

這里又多了一些類型,有空再接著捋下去。

  1. 抽一下請求函數
<script setup>
import { ref } from "vue";
let postList = ref([]);
fetch("https://api.cemcoe.com/v1/posts?page=1&per_page=10", {})
  .then((res) => res.json())
  .then((res) => {
    const { status, data } = res;
    if (status === 200) {
      postList.value.push(...data.post);
    }
  });
</script>

上面的代碼肯定是可以實現功能的,但肯定是不能這么寫的。至少也要把請求地址給搞出去的。

先簡單搞搞,搞成一個請求函數:

<script setup>
import { ref } from "vue";
let postList = ref([]);

const http = (url, options = {}) => {
  const BASE_URL = "https://api.cemcoe.com/v1";
  // 1. 拼湊完整的請求地址
  const resource = BASE_URL + url;
  // 2. 整合options
  options = {
    method: "GET", // 默認是GET請求
    headers: {},
    mode: "cors",
    credentials: "omit",
    cache: "default",
    ...options,
  };


  fetch(resource, options)
    .then((res) => res.json())
    .then((res) => {
      const { status, data } = res;
      if (status === 200) {
        postList.value.push(...data.post);
      }
    });
};

http("/posts?page=1&per_page=10");
</script>

上面的代碼泥,還有一個問題,那就是最終的請求結果需要到調用方,按照單一職責的原則,數據請求函數中也不應該對外部作用域的變量進行修改,ok,接著改。

<script setup>
import { ref } from "vue";
let postList = ref([]);

const http = (url, options = {}) => {
  const BASE_URL = "https://api.cemcoe.com/v1";
  // 1. 拼湊完整的請求地址
  const resource = BASE_URL + url;
  // 2. 整合options
  options = {
    method: "GET", // 默認是GET請求
    headers: {},
    mode: "cors",
    credentials: "omit",
    cache: "default",
    ...options,
  };

  return new Promise((resolve, reject) => {
    fetch(resource, options)
      .then((res) => {
        return res.json();
      })

      .then((res) => {
        resolve(res);
      });
  });
};

http("/posts?page=1&per_page=10").then((res) => {
  const { status, data } = res;
  if (status === 200) {
    postList.value.push(...data.post);
  }
});
</script>

這么改吧改吧已經有了一些可用的樣子了,下面要做的就是請求攔截和響應攔截以及一些錯誤處理,這里就不展開了,畢竟,每個公司的接口規范也不盡相同。

  1. 各回各家

上面的代碼呢最好是分到不同的文件里。怎么起名看著來。

div.png

圖中白色的抽離下面說,先將其忽略。

看看一下現在的數據流向

用戶訪問 Home 頁面,Home 頁面執行請求函數,而請求函數定義在 service/modules/home.js,而該文件會引用封裝的請求函數 http(名字無所謂,愛叫啥叫啥),而該請求函數則是對 fetch 的封裝,當然了,不用 fetch,用 axios 也可以。

用張圖來表示一下:

data.png

這個搞的好處是什么呢?

想一下其實網絡請求是和 Vue 這個框架無關的,按照上面的方式,如果要將原先的項目升級到 Vue3 的話,或者換成 React,其實只有第一部分需要改。而后面的兩部分是不用動的。

而如果把第一部分和第二部分代碼放到 views 文件中,那改起來可就麻煩了。

  1. 太大了,來點組件化

隨著代碼不斷堆下去,Home.vue 文件會越來越大。

不可避免地要使用一下組件化。

組件化有哪些知識泥?

實踐是檢驗真理的唯一標準。

定義 PostList 組件,接收拿到的數據,渲染列表。

這里就涉及到了組件間的數據傳遞。

當然可以使用 props 來進行,比如:

// Home.vue
<PostList :postList="postList">
// PostList.vue
const props = defineProps({
  postList: {
    type: Array,
    // 對象或者數組應當用工廠函數返回。
    // 工廠函數會收到組件所接收的原始 props
    // 作為參數
    default(rawProps) {
      return [];
    },
  },
});

組件化以后的 Home.vue 文件,其實還是有點邏輯多的。

// Home.vue

// 網絡請求拿值的邏輯
// 網絡請求拿值的邏輯
// 網絡請求拿值的邏輯
// 網絡請求拿值的邏輯
// 網絡請求拿值的邏輯
// 網絡請求拿值的邏輯



<PostList :postList="postList">
<PostList :postList="postList">
<PostList :postList="postList">
<PostList :postList="postList">
<PostList :postList="postList">
<PostList :postList="postList">
<PostList :postList="postList">

我不是閑得慌,把東西復制幾下。

這里假設每一個 PostList 組件是不同的,網絡請求邏輯也是不同的,而且 props 可能不止一個,可能還有事件的傳遞。

網絡請求還不是簡單的操作,一般都相對復雜,這么多的邏輯放在 Home.vue 文件中也不是很好。

  1. 結構行為和樣式分離?

前端有個東西,叫做結構行為樣式相分離。

Vue 表面上還是這種分離的寫法,三大塊分著寫。

React 干脆就把這仨貨攪和在一起。

這些框架把 DOM 操作給隱藏,將命令式編程變成了聲明式編程。

聲明式編程核心是什么?

當然就以聲明為核心咯,而聲明,它其實可以有另外一個名字,叫做狀態。

于是這種分離的思想大抵還是在的吧,自由過主角不再是 HTML CSS JS。

。。。

俺也不知。

  1. 上狀態

既然 Home.vue 文件中有太多的狀態,那不妨將狀態都交給一個專門管理狀態的伙計吧。

這個伙計現在是 Pinia,一個新歡。

Pinia 在使用上要比 Vuex 簡單多了,Vuex 的分模塊太難用了。

而在 Pinia 上,可以創建多個 store,但單例 store 好還是這種好泥?

我目前還沒有太多的體會。

ok,下面將 Home.vue 中的狀態給搞到 store 了。

不多說,打開 Pinia 的官網,瞧一眼。

// 從官網cv來的。
export const useCounterStore = defineStore("counter", () => {
  const count = ref(0);
  function increment() {
    count.value++;
  }

  return { count, increment };
});

你說這有啥學的?有點 React 那味道了。沒有引入多余的概念,什么 state,什么 mutation,什么 getter,什么不能直接改值?

Vue 好用是好用,但引如了太多的概念,而這里最突出的就是指令,你不知道那個指令,那不好意思,你就不知道如何實現某類功能。

而 Pinia 可太爽了,沒有引入多余的概念,全都是以往的概念。

當然了,如果你(比如我)還是想用類似 Vuex 的語法,Pinia 也是支持的。

// store home.js
import { defineStore } from "pinia";
import { getHomePostList } from "@/service/modules/home";

export const useHomeStore = defineStore("homeStore", {
  state: () => {
    return {
      recommendPostList: [],
      page: 1,
      per_page: 10,
    };
  },
  actions: {
    async fetchHomePostList() {
      const res = await getHomePostList(this.page, this.per_page);
      this.recommendPostList.push(...res.data.post);
      this.page++;
    },
  },
});

這里一些人可能會有種看法,那就是狀態管理,只管理全局狀態。頁面狀態就交給頁面好了。

這也是一種做法,但其實現在的 Pinia 支持多個 store,分模塊相對也比較簡單,頁面中的狀態交給它也是沒什么問題。

但頁面級別的狀態交給 Pinia,相較于交給頁面管理這里其實是需要多做一件事情的。

那就是。。。

當把狀態放到頁面里,傳數據是有點麻煩,頁面銷毀,狀態就沒了。這其實也是有好處的。

現在想象一下文章詳情頁的數據交給 Pinia,會發生什么?

用戶點擊文章 A,看到文章 A,但當用戶回到文章列表頁在點擊文章 B,此時頁面為先展示文章 A 的內容,等到文章 B 的數據拿到后才會展示文章 B 的內容。

所以,頁面級別的狀態,交給 Pinia 管理時,別忘了初始化。

這里其實就像將狀態的作用域提了一層。

究竟采用哪種方式來管理,看取舍把。存到頁面使用以及管理上不是很方便,但不用擔心初始化,當然了,全局狀態交給 Pinia 就不用選擇困難了。

  1. 更新下數據請求的步驟

未完,不續

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容