前端最佳实践与规范(补充中)
Sends-FrontEnd’s Best Practice
前言
本文旨在统一开发规范,提供最佳实践和高效的前端开发环境
详细技术细节请看官方文档,本文只提供规范以及部分关键概念
注:本文引用了大量官方文档的链接👍
统一技术栈
Vue3(主流的渐进式前端框架) + Vite(下一代前端构建工具) + Pinia(符合直觉的 Vue.js 状态管理库) + VueRouter(Vue.js 的官方路由) + TypeScript(具有强大,灵活的类型系统的 js 超集)
Vue
是的,我们需要一些规范
一些约定
- async函数调用链顶层使用try-catch
如果有多层async函数调用,请在最顶层的调用处包裹try-catch语句,这有利于大伙排查错误
请确保任何与API请求有关的async函数调用链都有try-catch处理,不然debug火葬场!!!
- 不要在App.vue(根组件)中使用顶层await
顶层await使用条件是父组件中存在Suspense内置组件,但是App.vue这样的根组件会被作为作为创建Vue应用的根组件,无法使用Suspense!
在App.vue中使用顶层await会导致界面加载不出来!
- 初始化步骤应当封装在插件
将main.ts里的初始化工作按照功能点封装成模块化的插件并存放至plugins文件夹,这样更清晰!
逻辑的盛宴,强烈推荐!
方便又清晰!
- 使用try-catch和async/await代替.then()和.catch()
用法请看这篇博客
神说:”要用同步的方式编写异步代码,这样会使异步代码更加清晰”
- 封装models
在models封装类及其具体实现
在其他地方调用models封装好的行为
- 及时重构
我们写功能时,一开始或许是粗糙的实现,这时候我们先不着急优化,我们可以先把功能写完。
写完之后再考虑优化重构,这时候我们可以从一个更高的角度来看这段代码,更有利于重构!
切记不可心急!
切记不可心急!
切记不可心急!
组件
好了,我现在知道谁是Vue开发中最重要的了
类型约定:
统一使用单文件组件(SFC)的形式
组件类型:
- 界面级别的组件
或者说一个路由界面的根组件
由若干个非界面级别组件构成
以XXX-View.vue来命名(其实是为了附和Volar的multi-word检查)
如 Home-View.vue , User-View.vue等等…
- 非界面级别的组件
或者说一个界面级组件的功能实现组件
以相关性来命名
比如说有一个叫Post-Comment.vue的实现评论区的组件
现在我们想给评论区添加一个卡片组件来展示评论,那么我们应该这样命名:Post-Comment-Card.vue
可以看到,这个组件的名字是紧接着其相关组件的名字的,这样具有良好的可读性
怎么命名?:
约定使用多单词/multi-word命名组件,多个单词间以-分开
如 Post-Item.vue , User-Card.vue等等…
组件怎么封装?:
应当遵循单向数据流动。
除非是极其密切相关的组件:
例如编辑器组件可以分成编辑区和编辑内容展示区那么这时候这两个子组件都需要用到编辑器这个父组件的内容,并且编辑区需要修改内容,这时候可以使用reactive对象作为props传入子组件方便修改。
组件放哪个文件夹?:
一般来说,使用 Vue 官方脚手架(create-vue)创建项目时时,初始会带有这两个文件夹:components和views.
接下来我们来区分一下这两个文件夹应该分别存放什么类型的组件
components:
这个文件夹应当存放可被整个应用多次复用(次数至少大于 1)的组件:如 xxx-Card.vue。
views:
这个文件夹应当存放界面级别的组件。
渲染锁
“如果没有渲染锁,那么我将浪费大量的时间debug!” —麦克阿瑟
概念
渲染锁一种对模板引用异步数据的解决方案。
它和全局事件总线差不多,都是基于已有技术总结下来的一种解决方案.
为什么要用?
在我们使用Vue开发时,我们往往需要使用例如axios,fetch,xhr等异步工具来发异步请求给服务器来获取服务器返回的数据,
如果我们此时我们在Vue的模板中引用了异步数据,并且没有做等待异步操作,那么运行时就非常容易出现引用undefined.xxx的情况,例如引用异步数据中对象的属性(因为此时异步数据还未被服务器返回,处于undefined状态).为了解决这个问题,我们可以使用渲染锁(Render lock).
怎么用?
Talk is cheap, show me your code.
可以看到我们在组件的根节点加了个v-if来异步渲染组件,这样可以保证在数据获取前Vue不会对模板中的undefined进行引用.
注意不能使用v-show来实现渲染锁,因为v-show会被渲染,只是加上了一个display:none的style。
Pinia
非常好菠萝🍍,使我威优开发效率提升。
使用 stores 管理
使用 Pinia 时应当将各Store放入stores目录下的modules目录
并在stores的根目录下的index.ts中定义一个全局性质的状态管理,封装需要使用多个 Store的行为
或者用来重导出也是可以的。
文件树如下:
1 |
|
Tip: 不仅stores如此,其他可分为模块的文件夹都建议这么写
命名为useXXXStore
store的定义导出函数约定这样命名:
1 |
|
这是来自官方文档的约定哦!
Store里应该做什么?
屏蔽实现细节,封装调用逻辑!
封装models层的调用逻辑
Store里应该负责接管models提供的封装好的屏蔽实现细节的方法,并基于实际情况进行调用封装。
看下面这段代码:
1 |
|
这个PostStore封装了models提供的类实例,并提供了getPosts方法来屏蔽mainPosts.getPosts的具体调用细节。
由于上面调用细节过于简单,所以直接原样传递了option参数…
但是如果我想根据其他Store的状态来限制getPosts,
这时候就可以避免干涉models的实现,直接在当前store的方法里补充调用细节即可;
这样做的好处是让Store仅充当非侵入式状态管理的角色;
换句话说: Store里负责实现调用细节,models里负责逻辑的具体实现。
因此Store中不推荐包含任何复杂的功能的具体实现!
使用接口约束State
上一个内容里有这么一个代码片段
1 |
|
可以看到我们使用了PostState来约束state,这样做方便你复盘的时候对Store的结构更加清晰
VueRouter
任何人都需要一个Router
使用()=>import(…)代替在顶部引入组件(路由懒加载)
Typescript
最喜欢的一集
学习资料
- ts视频教程 https://www.bilibili.com/video/BV1vX4y1s776
- ts简单教程 https://juejin.cn/post/7092415149809598500
- ts详细教程 https://juejin.cn/post/7088304364078497800
- ts类型体操练习 https://github.com/type-challenges/type-challenges
内置类型工具
TypeScript 提供了一系列的内置类型工具,也被称为工具类型(Utility Types),以方便开发者在不改变原有数据结构的前提下,通过预定义的类型转换来创建新的类型。这些工具类型在 lib.es5.d.ts
文件中有定义。以下是一些常用的 TypeScript 内置工具类型:
常用
Partial<T>: 将类型
T
的所有属性变为可选的。1
2
3
4
5
6
7
8interface Todo {
title: string;
description: string;
}
function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
return { ...todo, ...fieldsToUpdate };
}Required<T>: 与
Partial
相反,将类型T
的所有属性变为必选的。1
2
3
4
5
6interface Props {
a?: number;
b?: string;
}
const obj: Required<Props> = { a: 5, b: "String" };Readonly<T>: 将类型
T
的所有属性设置为只读的。1
2
3
4
5
6
7
8
9interface Todo {
title: string;
}
const todo: Readonly<Todo> = {
title: "Delete inactive users",
};
todo.title = "Hello"; // Error: cannot reassign a readonly propertyRecord<K, T>: 构造一个对象类型,其属性键为
K
,属性值为T
。1
2
3
4
5
6
7
8
9
10
11interface PageInfo {
title: string;
}
type Page = "home" | "about" | "contact";
const x: Record<Page, PageInfo> = {
home: { title: "Home" },
about: { title: "About" },
contact: { title: "Contact" },
};Pick<T, K>: 从类型
T
中挑选一些属性K
来构造类型。1
2
3
4
5
6
7
8
9
10
11
12interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Pick<Todo, "title" | "completed">;
const todo: TodoPreview = {
title: "Clean room",
completed: false,
};Omit<T, K>: 从类型
T
中剔除一些属性K
,构造一个新类型。1
2
3
4
5
6
7
8
9
10
11
12interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Omit<Todo, "description">;
const todo: TodoPreview = {
title: "Clean room",
completed: false,
};Exclude<T, U>: 从类型
T
中排除所有可以赋值给U
的属性,构造一个新类型。1
type T0 = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
Extract<T, U>: 提取类型
T
中可以赋值给U
的类型,构造一个新类型。1
type T0 = Extract<"a" | "b" | "c", "a" | "f">; // "a"
不太常用
NonNullable<T>: 从类型
T
中排除null
和undefined
。1
type T0 = NonNullable<string | number | undefined>; // string | number
ReturnType<T>: 获取函数类型
T
的返回类型。1
type T0 = ReturnType<() => string>; // string
Parameters<T>:获取函数类型的参数类型
1
2
3
4type Props = Parameters<() => string> // []
type Props1 = Parameters<(data: string) => void> // [string]
type Props2 = Parameters<any>; // unknown[]
type Props3 = Parameters<never>; // neverInstanceType<T>: 获取构造函数类型
T
的实例类型。1
2
3
4
5
6class C {
x = 0;
y = 0;
}
type T0 = InstanceType<typeof C>; // CThisParameterType: 从函数类型中提取
this
的类型。OmitThisParameter: 从函数类型中移除
this
参数。ThisType<T>: 这个工具不返回转换后的类型,而是用于上下文中的
this
类型的标记。
封装类
内部成员命名
private私有成员加上一个下划线_前缀同时遵循小驼峰,如: _id
public公开成员遵循小驼峰即可.
使用 models 抽象数据结构
都用 ts 了那必须得用上它强大的类型系统啊
在实际开发中,随着数据越来越多,越来越复杂,我们一般会选择创建一个单独的文件夹(models)去抽象出一些具有良好结构的数据结构,比如说类和接口.
请确保所有相关定义都在models下,不要为了图方便把定义写到.vue文件内,除非你非常确定它只会在这里用到!
某个项目的models结构如下:
1 |
|
可以看到models下有一个index.ts文件和modules目录:
index.ts用来统一各导出的或者定义一些全局性质的配置;
modules目录用来分层的: 比如说上面例子中的user和post两个层级,每个层级下还具体细分了各定义的类型!
API层
不调API就无法生存!!!
1. 什么是API层?
一个负责组织,抽象,封装axios等请求工具以及后端API的逻辑层.
2. 怎么封装API层?
目前推荐的一种方式是使用单例模式:
来看年度账单项目中api层的实现:
1 |
|
3. API层的注意事项!
该干的:
- 封装异步函数返回使用models层接口约束的数据
- 处理接口调用失败(比如说后端附在body的code)或网络错误,传给相应的错误处理层
- 校验数据是否满足对应的接口约束,若不满足则应该返回标识信息给调用方(比如使用Promise<…|undefined>作为返回值)
不该干的:
- 进行任何特定数据解析或改变原始数据的操作!
用type还是用interface?
我们先来看一段GPT4大人给出的回答:
在 TypeScript 中,
type
和interface
各有用武之地。通常情况下,如果你要定义一个对象的形状或者需要声明合并,你可能会选择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组件