前端最佳实践与规范(补充中)

Sends-FrontEnd’s Best Practice

前言

本文旨在统一开发规范,提供最佳实践和高效的前端开发环境

详细技术细节请看官方文档,本文只提供规范以及部分关键概念

注:本文引用了大量官方文档的链接👍

统一技术栈

Vue3(主流的渐进式前端框架) + Vite(下一代前端构建工具) + Pinia(符合直觉的 Vue.js 状态管理库) + VueRouter(Vue.js 的官方路由) + TypeScript(具有强大,灵活的类型系统的 js 超集)

Vue

是的,我们需要一些规范

一些约定

  1. async函数调用链顶层使用try-catch

如果有多层async函数调用,请在最顶层的调用处包裹try-catch语句,这有利于大伙排查错误

请确保任何与API请求有关的async函数调用链都有try-catch处理,不然debug火葬场!!!


  1. 不要在App.vue(根组件)中使用顶层await

顶层await使用条件是父组件中存在Suspense内置组件,但是App.vue这样的根组件会被作为作为创建Vue应用的根组件,无法使用Suspense!

App.vue中使用顶层await会导致界面加载不出来!


  1. 初始化步骤应当封装在插件

main.ts里的初始化工作按照功能点封装成模块化的插件并存放至plugins文件夹,这样更清晰!


  1. 使用组合式API

逻辑的盛宴,强烈推荐!

为什么要有组合式API?


  1. 使用<script setup>来写组件逻辑

方便又清晰!

看这里


  1. 使用try-catch和async/await代替.then()和.catch()

用法请看这篇博客

神说:”要用同步的方式编写异步代码,这样会使异步代码更加清晰”


  1. 封装models

models封装类及其具体实现
在其他地方调用models封装好的行为


  1. 及时重构

我们写功能时,一开始或许是粗糙的实现,这时候我们先不着急优化,我们可以先把功能写完。

写完之后再考虑优化重构,这时候我们可以从一个更高的角度来看这段代码,更有利于重构!

切记不可心急!

切记不可心急!

切记不可心急!


组件

好了,我现在知道谁是Vue开发中最重要的了

类型约定:

统一使用单文件组件(SFC)的形式

组件类型:

  1. 界面级别的组件

或者说一个路由界面的根组件

由若干个非界面级别组件构成

XXX-View.vue来命名(其实是为了附和Volar的multi-word检查)

Home-View.vue , User-View.vue等等…

  1. 非界面级别的组件

或者说一个界面级组件的功能实现组件

相关性来命名

比如说有一个叫Post-Comment.vue的实现评论区的组件

现在我们想给评论区添加一个卡片组件来展示评论,那么我们应该这样命名:Post-Comment-Card.vue

可以看到,这个组件的名字是紧接着其相关组件的名字的,这样具有良好的可读性

怎么命名?:

约定使用多单词/multi-word命名组件,多个单词间以-分开

Post-Item.vue , User-Card.vue等等…

组件怎么封装?:

应当遵循单向数据流动

除非是极其密切相关的组件:
例如编辑器组件可以分成编辑区和编辑内容展示区那么这时候这两个子组件都需要用到编辑器这个父组件的内容,并且编辑区需要修改内容,这时候可以使用reactive对象作为props传入子组件方便修改。

组件放哪个文件夹?:

一般来说,使用 Vue 官方脚手架(create-vue)创建项目时时,初始会带有这两个文件夹:componentsviews.

接下来我们来区分一下这两个文件夹应该分别存放什么类型的组件

components:

这个文件夹应当存放可被整个应用多次复用(次数至少大于 1)的组件:如 xxx-Card.vue。

views:

这个文件夹应当存放界面级别的组件

渲染锁

“如果没有渲染锁,那么我将浪费大量的时间debug!” —麦克阿瑟

概念

渲染锁一种对模板引用异步数据的解决方案。
它和全局事件总线差不多,都是基于已有技术总结下来的一种解决方案.

为什么要用?

在我们使用Vue开发时,我们往往需要使用例如axios,fetch,xhr等异步工具来发异步请求给服务器来获取服务器返回的数据,

如果我们此时我们在Vue的模板中引用了异步数据,并且没有做等待异步操作,那么运行时就非常容易出现引用undefined.xxx的情况,例如引用异步数据中对象的属性(因为此时异步数据还未被服务器返回,处于undefined状态).为了解决这个问题,我们可以使用渲染锁(Render lock).

怎么用?

Talk is cheap, show me your code.

渲染锁demo.png

可以看到我们在组件的根节点加了个v-if来异步渲染组件,这样可以保证在数据获取前Vue不会对模板中的undefined进行引用.

注意不能使用v-show来实现渲染锁,因为v-show会被渲染,只是加上了一个display:none的style。

Pinia

非常好菠萝🍍,使我威优开发效率提升。

使用 stores 管理

使用 Pinia 时应当将各Store放入stores目录下的modules目录
并在stores的根目录下的index.ts中定义一个全局性质的状态管理,封装需要使用多个 Store的行为
或者用来重导出也是可以的。

文件树如下:

1
2
3
4
5
6
7
8
...
├─stores
│ ├─modules
│ │ ├─post
│ │ ├─user
│ │ └─...
│ └─index.ts
...

Tip: 不仅stores如此,其他可分为模块的文件夹都建议这么写

命名为useXXXStore

store的定义导出函数约定这样命名:

1
2
export const usePostStore = defineStore('post', {...})

这是来自官方文档的约定哦!

Store里应该做什么?

屏蔽实现细节,封装调用逻辑!

封装models层的调用逻辑

Store里应该负责接管models提供的封装好的屏蔽实现细节的方法,并基于实际情况进行调用封装

看下面这段代码:

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
//state约束
//这里应该放到models/modules/store/post/interface里,为了方便展示直接写在这里了
interface I_PostStoreState {
//记录总post列表
mainPosts: MainPosts
//记录最新的post
latestPosts: LatestPosts
//记录已经浏览过的post
visitedPosts: VisitedPosts
//记录当前的post
currentPost: Post
}

export const usePostStore = defineStore('post', {
state: (): I_PostStoreState => {
return {
//记录总post列表
mainPosts: new MainPosts(),
//记录最新的post
latestPosts: new LatestPosts(),
//记录已经浏览过的post
visitedPosts: new VisitedPosts(),
//记录当前浏览的post
currentPost: new Post()
}
},
actions: {
//根据条件获取post列表
async getPosts(option?: I_GetPostOption) {
await this.mainPosts.getPosts(option)
},
...
}

这个PostStore封装了models提供的类实例,并提供了getPosts方法来屏蔽mainPosts.getPosts的具体调用细节

由于上面调用细节过于简单,所以直接原样传递了option参数…

但是如果我想根据其他Store的状态来限制getPosts,
这时候就可以避免干涉models的实现,直接在当前store的方法里补充调用细节即可;

这样做的好处是让Store仅充当非侵入式状态管理的角色;
换句话说: Store里负责实现调用细节,models里负责逻辑的具体实现

因此Store中不推荐包含任何复杂的功能的具体实现!

使用接口约束State

上一个内容里有这么一个代码片段

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
//state约束
interface PostState {
//记录总post列表
mainPosts: MainPosts
//记录最新的post
latestPosts: LatestPosts
//记录已经浏览过的post
visitedPosts: VisitedPosts
//记录当前的post
currentPost: Post
}

export const usePostStore = defineStore('post', {
//注意这里的state写成箭头函数,这是官方文档的最佳实践
state: (): PostState => {
return {
//记录总post列表
mainPosts: new MainPosts(),
//记录最新的post
latestPosts: new LatestPosts(),
//记录已经浏览过的post
visitedPosts: new VisitedPosts(),
//记录当前浏览的post
currentPost: new Post()
}
},
...
}

可以看到我们使用了PostState来约束state,这样做方便你复盘的时候对Store的结构更加清晰

VueRouter

任何人都需要一个Router

使用()=>import(…)代替在顶部引入组件(路由懒加载)

具体看这里!

Typescript

最喜欢的一集

学习资料

  1. ts视频教程 https://www.bilibili.com/video/BV1vX4y1s776
  2. ts简单教程 https://juejin.cn/post/7092415149809598500
  3. ts详细教程 https://juejin.cn/post/7088304364078497800
  4. ts类型体操练习 https://github.com/type-challenges/type-challenges

内置类型工具

TypeScript 提供了一系列的内置类型工具,也被称为工具类型(Utility Types),以方便开发者在不改变原有数据结构的前提下,通过预定义的类型转换来创建新的类型。这些工具类型在 lib.es5.d.ts 文件中有定义。以下是一些常用的 TypeScript 内置工具类型:

常用

  1. Partial<T>: 将类型 T 的所有属性变为可选的。

    1
    2
    3
    4
    5
    6
    7
    8
    interface Todo {
    title: string;
    description: string;
    }

    function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
    return { ...todo, ...fieldsToUpdate };
    }
  2. Required<T>: 与 Partial 相反,将类型 T 的所有属性变为必选的。

    1
    2
    3
    4
    5
    6
    interface Props {
    a?: number;
    b?: string;
    }

    const obj: Required<Props> = { a: 5, b: "String" };
  3. Readonly<T>: 将类型 T 的所有属性设置为只读的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    interface Todo {
    title: string;
    }

    const todo: Readonly<Todo> = {
    title: "Delete inactive users",
    };

    todo.title = "Hello"; // Error: cannot reassign a readonly property
  4. Record<K, T>: 构造一个对象类型,其属性键为 K,属性值为 T

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    interface PageInfo {
    title: string;
    }

    type Page = "home" | "about" | "contact";

    const x: Record<Page, PageInfo> = {
    home: { title: "Home" },
    about: { title: "About" },
    contact: { title: "Contact" },
    };
  5. Pick<T, K>: 从类型 T 中挑选一些属性 K 来构造类型。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    interface Todo {
    title: string;
    description: string;
    completed: boolean;
    }

    type TodoPreview = Pick<Todo, "title" | "completed">;

    const todo: TodoPreview = {
    title: "Clean room",
    completed: false,
    };
  6. Omit<T, K>: 从类型 T 中剔除一些属性 K,构造一个新类型。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    interface Todo {
    title: string;
    description: string;
    completed: boolean;
    }

    type TodoPreview = Omit<Todo, "description">;

    const todo: TodoPreview = {
    title: "Clean room",
    completed: false,
    };
  7. Exclude<T, U>: 从类型 T 中排除所有可以赋值给 U 的属性,构造一个新类型。

    1
    type T0 = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
  8. Extract<T, U>: 提取类型 T 中可以赋值给 U 的类型,构造一个新类型。

    1
    type T0 = Extract<"a" | "b" | "c", "a" | "f">; // "a"

不太常用

  1. NonNullable<T>: 从类型 T 中排除 nullundefined

    1
    type T0 = NonNullable<string | number | undefined>; // string | number
  2. ReturnType<T>: 获取函数类型 T 的返回类型。

    1
    type T0 = ReturnType<() => string>; // string
  3. Parameters<T>:获取函数类型的参数类型

    1
    2
    3
    4
    type Props = Parameters<() => string> // []
    type Props1 = Parameters<(data: string) => void> // [string]
    type Props2 = Parameters<any>; // unknown[]
    type Props3 = Parameters<never>; // never
  4. InstanceType<T>: 获取构造函数类型 T 的实例类型。

    1
    2
    3
    4
    5
    6
    class C {
    x = 0;
    y = 0;
    }

    type T0 = InstanceType<typeof C>; // C
  5. ThisParameterType: 从函数类型中提取 this 的类型。

  6. OmitThisParameter: 从函数类型中移除 this 参数。

  7. ThisType<T>: 这个工具不返回转换后的类型,而是用于上下文中的 this 类型的标记。

封装类

内部成员命名

private私有成员加上一个下划线_前缀同时遵循小驼峰,如: _id

public公开成员遵循小驼峰即可.

使用 models 抽象数据结构

都用 ts 了那必须得用上它强大的类型系统啊

在实际开发中,随着数据越来越多,越来越复杂,我们一般会选择创建一个单独的文件夹(models)去抽象出一些具有良好结构的数据结构,比如说类和接口.

请确保所有相关定义都在models下,不要为了图方便把定义写到.vue文件内,除非你非常确定它只会在这里用到!

某个项目的models结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...
models
├─ index.ts
└─ modules
├─ user
│ ├─ interface
│ │ └─ index.ts
│ └─ class
│ └─ index.ts
└─ post
├─ type
│ └─ index.ts
├─ interface
│ └─ index.ts
├─ enum
│ └─ index.ts
├─ const
│ └─ index.ts
└─ class
└─ index.ts
...

可以看到models下有一个index.ts文件和modules目录:

index.ts用来统一各导出的或者定义一些全局性质的配置;

modules目录用来分层的: 比如说上面例子中的user和post两个层级,每个层级下还具体细分了各定义的类型!

API层

不调API就无法生存!!!

1. 什么是API层?

一个负责组织,抽象,封装axios等请求工具以及后端API的逻辑层.

2. 怎么封装API层?

目前推荐的一种方式是使用单例模式:

来看年度账单项目中api层的实现:

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import axios from 'axios'
import {
type I_LearningStatistic,
type I_PaymentStatistic
} from '@/models/modules/bill/interface/index'

//API基地址
const baseUrl = 'https://api.sends.cc'

//下划线开头命名类表示是内部实现类,不对外导出,推荐在单例模式中使用这种命名法
class _API {
private _userAPI = axios.create({
baseURL: `${baseUrl}/user/`
})
private _yearBillAPI = axios.create({
baseURL: `${baseUrl}/yearBill/`
})
//登陆
public async login(code: string): Promise<string> {
try {
const { data } = await this._userAPI.post('bill_login', {
code
})
if (data.code === 1000) {
return data.data
} else {
alert(`something was wrong! ${data.msg}`)
return ''
}
} catch (error) {
alert(`something was wrong! ${error}`)
return ''
}
}
//!初始化用户数据 必须要初始化之后才能拿到数据
// 初始化之后要在本地存储标识!
public async initUser(token: string) {
if (localStorage.getItem('isInitialized')) {
return
} else {
try {
const { data } = await this._yearBillAPI.get('init', {
headers: {
token
}
})
if (data.code === 1000) {
localStorage.setItem('isInitialized', 'true')
} else {
alert(`something was wrong! ${data.msg}`)
}
} catch (error) {
alert(`something was wrong! ${error}`)
}
}
}
//获取付款信息
public async getPayment(token: string): Promise<I_PaymentStatistic | null> {
if (localStorage.getItem('isInitialized')) {
return null
} else {
try {
const { data } = await this._yearBillAPI.get('pay', {
headers: {
token
}
})
if (data.code === 1000) {
return data.data as I_PaymentStatistic
} else {
alert(`something was wrong! ${data.msg}`)
return null
}
} catch (error) {
alert(`something was wrong! ${error}`)
return null
}
}
}
//获取学习信息
public async getLearning(token: string): Promise<I_LearningStatistic | null> {
if (localStorage.getItem('isInitialized')) {
return null
} else {
try {
const { data } = await this._yearBillAPI.get('learn', {
headers: {
token
}
})
if (data.code === 1000) {
return data.data as I_LearningStatistic
} else {
alert(`something was wrong! ${data.msg}`)
return null
}
} catch (error) {
alert(`something was wrong! ${error}`)
return null
}
}
}
}


//这里导出单例
export const API = new _API()

3. API层的注意事项!

该干的:

  1. 封装异步函数返回使用models层接口约束的数据
  2. 处理接口调用失败(比如说后端附在body的code)网络错误,传给相应的错误处理层
  3. 校验数据是否满足对应的接口约束,若不满足则应该返回标识信息给调用方(比如使用Promise<…|undefined>作为返回值)

不该干的:

  1. 进行任何特定数据解析改变原始数据的操作!

用type还是用interface?

我们先来看一段GPT4大人给出的回答:

在 TypeScript 中,typeinterface 各有用武之地。通常情况下,如果你要定义一个对象的形状或者需要声明合并,你可能会选择 interface。如果你需要使用联合类型、交叉类型、映射类型等更复杂的类型组合,你可能会选择 type

类型命名规范

Good naming is half the battle – 好的命名是成功的一半

我们使用部分前缀命名来区分我们抽象的数据结构

变量

变量命名使用小驼峰命名法

即除第一个单词之外,其他单词首字母大写

常量

常量命名使用全大写的蛇形命名法(UPPER_SNAKE_CASE)

即所有单词都大写,多个单词间用下划线_隔开,如STUDENT_TYPE

函数

函数命名使用小驼峰命名法

配合动词加名词的形式,如一个获取name的函数,应当命名为getName

类命名使用大驼峰命名法

相比小驼峰法,大驼峰法把第一个单词的首字母也大写了,例如 MyName , MyAge…

接口

接口命名以 I_ 开头加上大驼峰命名法以便区分

如: I_Post , I_User , I_UserInfo

待续…


补充概念

界面级别的组件

指通过 VueRouter 控制的组件,也就是你在 router 文件夹中配置的组件

高复用级别的组件

指存放在components文件夹下的被多次复用的组件,最典型的例子比如说XXX-Card组件


前端最佳实践与规范(补充中)
https://azzellz.github.io/2023/09/17/前端规范/
作者
Tyee
发布于
2023年9月17日
许可协议