一个 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. 更新下数据请求的步骤

未完,不续

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容