耿健的个人博客

一个即将放飞理想的咸鱼博主

0%

20.前端开发规范

规范背景

随着市面上的需求越来越复杂.
个人开发者已无法满足项目版本的开发速度.
那么团队开发是复杂项目的必然选择.
不过如何管理研发团队的开发风格,
确保每一行代码都像是同一个人编写的.
从而减少团队中代码的沟通成本.
这是团队开发中重要的一环.

目录结构规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.
└── src
├── api // 调用接口api
├── components // 公共组件
├── config // 配置环境等
├── hooks // 公共Hooks
├── images // 本地资源
├── less // 公共样式
├── pages // 页面文件夹
│ └── Home // 首页
│ ├── components // 首页用局部组件
│ ├── data // 首页用局部数据
│ └── utils // 首页用局部方法
├── redux // Redux
│ ├── actions // 业务代码可调用的方法
│ ├── constants // 消息枚举
│ ├── reducers // 对redux操作的实现
│ └── store // 唯一仓库
├── service // 封装管理器(接口请求管理器、缓存管理器等)
└── utils // 公共方法(计算时间、格式化文本等)
  1. 组件设计

    每个组件都是一个文件夹. 组件名即为文件夹名.
    文件夹内包括 index.tsx(.js) 、 index.less 以及 README.md(如组件内部业务过于复杂需要书写 README, 功能清晰的组件只需在代码上方注释书写组件功能、传参含义即可).
    组件内尽量不要使用 redux.
    所有依赖数据尽量使用 props 传值.
    内部逻辑实现尽量抽象, 不要依赖业务元素.
    这样以保证其复用性.
    组建内部尽量不要使用页面级生命周期. (待实践)

    组件传入的每个参数都要注释其意义, 以及是否必传.
    公共组件放到./src/components
    局部组件放到对应页面下的 components 文件夹内

  2. 页面设计

    每个页面都是一个文件夹. 页面名即为文件夹名.
    文件夹内包括 components、index.tsx(.js) 、 index.less、 index.config.ts

    页面传入的每个参数都要注释其意义, 以及是否必传.

git 管理规范

  1. 保证 master 分支代码, 是没有风险的, 随时可以打包上线. 禁止直接在 master 分支修改代码.
  2. dev 分支为预发环境, 保证该分支代码是健康的, 也为即将可以合并 master 分支状态.
  3. stage 分支为测试环境, 大杂烩分支, 想要部署到测试环境上的代码都可以往里放, 该分支可能随时会删除重新从 dev 开辟出来.
  4. dev-something 即为对应需求的开发分支, 从 dev 分支开辟出来. 功能开发完毕后, 需要合并到 dev 分支, 同步书写该版本的文档. 在回归测试没问题后, 通过提交 PR 请求, 经过其他人代码 review 之后, 合并到 master 提单发版.
  5. 可通过 gitlab runner 搭建 CI 持续集成.
  6. 提交代码时候, 尽量按照功能的原子性提交, 不要多个事情放到一个 commit 里面去.
  7. 请按照一定语法去 commit, 每一条 commit 由以下几部分构成.
1
2
3
4
5
6
7
8
9
10
修改类型+(影响模块)+:+[bug单号]+问题描述
如: fix(会员购买页面):[7405-7405]会员等级购买ios购买规避政策
修改类型分为以下几种:
feat: 开发新功能
style: 调整样式
fix: bug修复
refactor: 代码重构
merge: 代码合并
doc: 书写文档
config: 调整配置

PS:
定制版本过多的话, 不建议以分支去隔离.
随着业务分叉严重, 版本更迭时间过长.
合代码的时候, 任务重, 风险大.
可考虑通过 webpack 打包不同文件后缀名, 进行文件隔离. (待实践)

代码编写规范

  1. 统一开发环境

建议 VSCode + Prettier + ESLint + Stylelint + Tailwind

.vscode/setting.json (仅针对该项目)

1
2
3
4
{
"editor.formatOnSave": true,
"files.autoSave": "onFocusChange"
}

.editorconfig

1
2
3
4
5
6
7
8
9
10
11
12
# http://editorconfig.org
root = true

[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

.prettierrc.js

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
module.exports = {
tailwindConfig: "./tailwind.config.js",
tailwindAttributes: ["customClass", "className"],
jsxSingleQuote: false,
singleQuote: false,
printWidth: 140,
tabWidth: 2,
useTabs: false,
semi: true,
trailingComma: "es5",
jsxBracketSameLine: false,
bracketSpacing: true,
arrowParens: "always",
quoteProps: "preserve",
proseWrap: "preserve",
htmlWhitespaceSensitivity: "css",
organizeImportsSkipDestructiveCodeActions: false,
stylelintIntegration: true,
importOrder: [
"<THIRD_PARTY_MODULES>",
"^@/(.*)$",
"^../(.*)",
"^./((?!less).)*$",
"^./(.*)",
],
importOrderSeparation: false,
importOrderSortSpecifiers: true,
plugins: ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
};

.eslintrc.js

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
module.exports = {
extends: ["taro/react"],
parserOptions: {
ecmaFeatures: {
jsx: true,
tsx: true,
},
useJSXTextNode: true,
useTSXTextNode: true,
},
rules: {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-key": "error",
"jsx-quotes": ["error", "prefer-double"], // 强制在JSX属性(jsx-quotes)中一致使用双引号
"no-const-assign": 2, // 禁止修改const声明的变量
"no-fallthrough": 1, // 禁止switch穿透
"no-func-assign": 2, // 禁止重复的函数声明
"no-multiple-empty-lines": [1, { max: 2 }], // 空行最多不能超过2行
"no-param-reassign": 2, // 禁止给参数重新赋值
"no-mixed-spaces-and-tabs": [2, false], // 禁止混用tab和空格
"no-sequences": 0, //禁止使用逗号运算符
"no-unneeded-ternary": 2, // 禁止不必要的嵌套 var isYes = answer === 1 ? true : false;
"no-unused-vars": [1, { vars: "all", args: "after-used" }], // 不能有声明后未被使用的变量或参数
"no-undef": "error",
"no-var": 0, // 禁用var,用let和const代替
"arrow-parens": 0, // 箭头函数用小括号括起来
"arrow-spacing": 0, // =>的前/后括号
curly: [2, "all"], // 必须使用 if(){} 中的{}
"default-case": 2, // switch语句最后必须有default
eqeqeq: 2, // 必须使用全等
"init-declarations": 0, // 声明时必须赋初值
"import/order": 0, // import顺序有误
"import/no-commonjs": 0, // 忽略require使用告警
"import/no-named-as-default": 0, // 忽略默认导出方法的名称
},
};

.stylelintrc.js

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
109
110
111
112
113
114
115
116
117
118
119
120
121
module.exports = {
processors: [],
plugins: ["stylelint-order"],
extends: ["stylelint-config-standard", "stylelint-config-css-modules"],
globals: {
wx: true,
App: true,
Page: true,
getApp: true,
Component: true,
},
rules: {
"selector-class-pattern": [
// 命名规范 -
"^([a-z][a-z0-9]*)(-[a-z0-9]*_?[a-z0-9]+)*$",
{
message: "Expected class selector to be kebab-case",
},
],
"no-invalid-double-slash-comments": null, // 禁止双斜杠注释
"block-no-empty": null, // 禁止空块
"at-rule-empty-line-before": null,
"at-rule-no-unknown": null,
"length-zero-no-unit": true, // 禁止零长度的单位(可自动修复)
"shorthand-property-no-redundant-values": true, // 简写属性
"declaration-block-no-duplicate-properties": true, // 禁止声明快重复属性
"declaration-block-no-redundant-longhand-properties": null, // 禁止在声明块中使用缩写属性
"no-descending-specificity": true, // 禁止在具有较高优先级的选择器后出现被其覆盖的较低优先级的选择器。
"selector-max-id": 0, // 限制一个选择器中 ID 选择器的数量
"max-nesting-depth": 3,
"order/properties-order": [
// 规则顺序
"box-sizing",
"position",
"top",
"left",
"right",
"bottom",
"z-index",
"flex",
"display",
"flex-direction",
"flex-wrap",
"justify-content",
"align-items",
"grid-template-columns",
"grid-gap",
"float",
"width",
"height",
"max-width",
"max-height",
"min-width",
"min-height",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"margin-collapse",
"margin-top-collapse",
"margin-right-collapse",
"margin-bottom-collapse",
"margin-left-collapse",
"transform",
"overflow",
"overflow-x",
"overflow-y",
"clip",
"clear",
"font",
"font-family",
"font-size",
"font-smoothing",
"osx-font-smoothing",
"font-style",
"font-weight",
"line-height",
"letter-spacing",
"word-spacing",
"color",
"text-align",
"text-decoration",
"text-indent",
"text-overflow",
"text-rendering",
"text-size-adjust",
"text-shadow",
"text-transform",
"word-break",
"word-wrap",
"white-space",
"vertical-align",
"list-style",
"list-style-type",
"list-style-position",
"list-style-image",
"pointer-events",
"cursor",
"background",
"background-color",
"border",
"border-color",
"border-radius",
"box-shadow",
"content",
"outline",
"outline-offset",
"opacity",
"filter",
"visibility",
"size",
"transition",
],
},
};
  1. 事件绑定函数命名规范

采用小驼峰命名法.

  • 开头: 自身实现前缀 handle, 组件暴露出来的前缀 on
  • 中间模块名称: 如: Cell、Item、Title 等
  • 尾部事件名称: 如: Click、TouchMove、Change 等
1
2
3
4
5
6
7
const handleCellClick = () => {
console.log("handleCellClick");
};

render() {
<ListCell onCellClick={handleCellClick} />
}
  1. 自定义函数命名规范

采用小驼峰命名法, 见名知意, 通过函数名来知道意义.

  • 校验类: check 开头
  • 处理类: deal 开头
  • 方法类: process 开头
  • 格式化类: format 开头
  • 渲染类: render 开头
  1. 组件命名规范

采用大驼峰命名法. 使用名词开头, 后接形容词. 如:

  • ListSelect(实现可选择的列表)
  • ModuleTitle(拥有标题的模块)
  • PanelBottom(位于底部的面板)
  1. css 样式命名规范

采用小驼峰命名法. 每个 class 中间要空一行,且注释不要使用双斜杠,而应使用 /**/如:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* bad */
.pageWrap {
}

.pageContent {
}

/* good */
.page-wrap {
}

.page-content {
}
  1. 路由传值

由于路由传值都被转为 string 类型. 传值 undefined, false, true 等歧义变量, 很容易引发隐蔽性 bug.

1
2
`/pages/Index/index?isAdmin=false&isOwner=true` // bad
`/pages/Index/index?role=owner`; // good
  1. 封装方法传入的参数尽量是对象
1
2
3
4
5
6
7
8
9
10
11
// bad
dealDateInfo(data, show, tip, success);

// good
const params = {
data: "1",
show: true,
tip: 99,
success: () => {},
};
dealDateInfo(params);

如果一个函数需要多个参数实现其逻辑, 尽量将这些参数组成一个对象.
这样的好处在于:

a) 方便定义类型.
b) 如果部分参数是非必传的情况, 方便处理.
c) 对于编译器减少 push 函数参数的操作

不过要注意, 通过对象入参, 会让参数变为引用传参,
在函数内切记不要直接修改入参的值, 否则会改变入参的原数据, 以免引发其他问题.

开发规范

  1. import 书写顺序

a) 首先引入第三方库
b) 次之引入设置别名的绝对路径
c) 最后引入相对路径

而同级则以引入库的字母顺序排列.

引用公共组件/公共方法应当使用别名路径或者绝对路径,
专属于自己组件的引用文件可考虑使用相对路径.

严禁如: ../../../../../../../xxx.js

  1. 类开发内书写顺序

    1. 优先书写变量相关

      1. 优先书写 构造函数: 自定义变量
      2. 其次书写 state、data 的定义
      3. 次之书写 computed
      4. 最后书写 watch
    2. 次之书写函数相关

      1. 优先书写 methods
      2. 其次书写 events 等自定义事件
      3. 次之书写 生命周期
      4. 最后书写自定义函数
    3. 如为 tsx 文件

      1. 自定义 render 函数
      2. 返回的 render 函数
  2. TSX 文件书写顺序(待实践)

    1. 自定义变量
    2. 自定义函数
    3. 生命周期
    4. 绑定事件函数
    5. 渲染函数
    6. 主渲染函数

    详细:

    1. useRouter
    2. useRef
    3. useState
    4. useMemo
    5. 自定义变量
    6. 自定义方法
    7. useDidShow
    8. useEffect
    9. usePullDownRefresh
    10. useReachBottom
    11. handleXXX 绑定事件
    12. renderXXX 渲染函数
    13. render return() 主渲染函数
  3. 善用 CSS 变量 / CSS 原子化

    颜色、字号、边距、边角已经有规定尺寸, 常规情况下, 不要直接去写数值. 会造成 UI 风格不一致.

  4. 数据管理

    前端要有自己的数据管理能力.
    由于组件设计过程中, 组件内的变量命名应该是抽象的.
    那么在接口获取数据之后,
    都需要将得到的数据, 转换为组件内对应的变量位置装好.
    这样, 数据处理位置比较居中, 同时也能倒逼设计组件更加抽象化.

  5. 生命周期的运用(仅参考, 根据具体业务来运用)

    1. useEffect, 只用来处理变量
    2. useDidShow, 用来请求接口数据
  6. 组件抽象化
    在组件的设计过程中, 组件内的变量命名应该是抽象的. 不要把轻易业务变量丢到组件内部. (除非放弃该组件的复用性)

1
2
3
4
5
6
7
8
// 业务需求: 处于某某身份则展示编辑按钮
// 组件内的展示逻辑: 纯粹跟他是否应当展示有关, 而不是在组件内部还去关注他是什么身份.

// bad
<Header isGM={true} isAM={false} isBM={false} isCM={true} />

// good
<Header isShowEdit={true} />
  1. 可自闭合标签, 采取自闭合方式书写

为防止标签内的内容过多时, 闭合标签匹配混乱的问题.

1
2
3
4
5
// bad
<Video></Video>

// good
<Video />
  1. 标签属性书写顺序

应当按照以下给出的顺序依次排列, 确保代码的易读性
a) class
b) id, name
c) data-_
d) src, for, type, href, value
e) title, alt
f) role, aria-_

1
2
3
4
5
6
7
<img
className="imgContent"
id="img_apple"
data-info="aaa"
src="../aa/bb/cc/dd.jpg"
title="图片"
/>
  1. switch 的相关操作

一定要有 default 的情况作为兜底,
每个 case 的作用域应当一定要被大括号包裹, 以免变量声明提前导致一些隐形 bug.

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
// bad
const test1 = (type) => {
let a = 999;
switch (type) {
case 1:
console.log("case 1: a = ", a); // 直接飘红报错, 暂时性死区
break;
case 2:
const a = 8;
console.log("case 2: a =", a); // 可以正常输出 case 2: a = 8
break;
default:
break;
}
};

// good
const test2 = (type) => {
let a = 999;
switch (type) {
case 1: {
console.log("case 1: a = ", a); // 可以正常输出 case 1: a = 999
break;
}
case 2: {
const a = 8;
console.log("case 2: a =", a); // 可以正常输出 case 2: a = 8
break;
}
default: {
break;
}
}
};

套路场景

  1. 多条件判断可配置化

简单判断可如下:

1
2
3
4
5
6
7
8
9
10
11
// bad
if (
strMySelfRole === "GM" ||
strMySelfRole === "DM" ||
strMySelfRole === "AM"
) {
}

// good
if (["GM", "DM", "AM"].includes(strMySelfRole)) {
}

复杂判断可如下:

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
const TAB_LIST = [
{
type: "APPLE", // 业务所需字段
title: "Apple", // 业务所需字段
// APPLE 展示规则: 不存在 supportRules 字段, 默认就会展示
},
{
type: "BANANA",
title: "Banana",
// BANANA 展示规则: 如果 isAM 是 true 则展示; 或者 nBM 是 100 或 200 则展示; 或者 isAM 是 false 并且 strCM 是 ok 或 sure 则展示;
supportRules: [
{
isAM: [true],
},
{
nBM: [100, 200],
},
{
isAM: [false],
strCM: ["ok", "sure"],
},
],
},
{
type: "CHERRY",
title: "Cherry",
// CHERRY 展示规则: 即为自定义函数的返回值
supportRules: (matchRules) => {
const { isAM, nBM, strCM, arrDM, objEM } = matchRules || {};
return !!objEM?.a && nBM > 1.23 && nBM < 12.34 && arrDM.includes("happy");
},
}
{
type: "ORANGE",
title: "Orange",
// ORANGE 的展示规则: 不符合合法规则, 不予展示
supportRules: true,
},
];
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
const updateTabList = (params) => {
const { aDetail, bDetail, cDetail, dDetail } = params || {};
const { am } = aDetail || {};
const { bm } = bDetail || {};
const { cm } = cDetail || {};
const { dm } = dDetail || {};
const { em } = eDetail || {};

const matchRules = {
isAM: am, // boolean
nBM: bm, // number
strCM: cm, // string
arrDM: dm, // array
objEM: em, // object
};

const tabList = TAB_LIST.filter((item) => {
// 如不存在 supportRules 字段, 默认就会展示
if (item.supportRules === undefined) {
return true;
}
// 如 supportRules 字段是函数, 则按照规则(数组为`或`逻辑, 对象为`与`逻辑)展示
if (item.supportRules) {
return item.supportRules.some((rule: any) => {
return Object.keys(rule).every((key) => {
return rule[key].includes(nowRule[key]);
});
});
}
// 如 supportRules 字段是函数, 则按照该函数返回值展示
if (item.supportRules) {
return item.supportRules.some((rule: any) => {
return Object.keys(rule).every((key) => {
return rule[key].includes(nowRule[key]);
});
});
}
// 兜底情况, 则不展示
return false;
});

return tabList;
};

后期只需要维护 TAB_LIST 常量数组的配置,
不再需要调整 updateTabList 方法,
即可清晰应对每一项的调整需求.

另外, 一般不要在带代码中直接去使用常量,
建议将所有的常量抽出来, 统一整理到一个 config.js 文件中,

同时遵循开闭原则, 即对于功能的扩展是开放的, 对功能的修改是关闭的.
即使新增需求, 也不需要去改动老代码.
以提高其拓展性和可维护性.
方便后续需求变动的时候, 能够快捷找到对应的位置.

  1. 复杂条件渲染场景

通过抽象枚举值, 善用表驱动法, 实现条件渲染.
不过要注意的是, key 值要保证是已有字段, 不然无法渲染.
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const renderPageContent = () => {
return {
VPEmpty: <VPEmpty pageInfo={objTabBarCurrent} />,
VPHome: <VPHome pageInfo={objTabBarCurrent} />,
VPHomeCarbon: <VPHomeCarbon pageInfo={objTabBarCurrent} />,
VPMine: (
<VPMine
pageInfo={objTabBarCurrent}
mineMemberEditRefresh={mineMemberEditRefresh}
onMineMemberEditClick={handleMineMemberEditClick}
/>
),
VPNew: <VPNew pageInfo={objTabBarCurrent} />,
VPNull: <VPNull pageInfo={objTabBarCurrent} />,
}[code || "VPNull"];
};
  1. 复杂场景权限校验

简单逻辑情况, 可以通过“与”运算符实现.
复杂逻辑的情况, 可通过装饰器模式、自定义指令、高阶函数或者 hook, 对业务逻辑进行装饰.
达成目的是, 降低耦合, 校验逻辑、业务逻辑互相不会污染.

1
2
3
<Permission strCheckPosition={"LEADER"}>
<View>Leader Content</View>
</Permission>
  1. 减少冗余的变量声明

对变量的声明尽量精简.
不要多个变量去控制一个事物的状态,
声明越多的变量, 需要维护的成本就会越大.

比如新建 / 编辑等场景:

组件传参只需要传递 id 即可,
是新增还是编辑通过判断 id 是否传值即可.
如果有 id 则说明是编辑场景, 没有则说明是新建场景。

这样即可少维护一个冗余字段

  1. 组件的抽象实现

为保证组件的复用性, 对暴露出来的方法要遵循单一职责原则
保证方法的高内聚, 不要携带其他副作用.
比如, tab 切换组件返回的事件, 应当只是纯粹的告诉调用者(父组件), tab 的哪一项被点击,
至于被点击之后的处理逻辑, 应由调用者(父组件)去实现.
业务逻辑不在组件内实现的好处, 在于方便在其他位置复用该组件样式.

同时, 这也是遵循单向数据流, 数据由上向下,事件由下向上.
有助于简化数据的管理和状态的维护, 提高代码的可维护性和可预测性.

PS: 只有数据的拥有者,才能有资格修改这个数据(唯一责任人)

  1. 内聚业务逻辑

把校验逻辑、限制逻辑其他杂七杂八的逻辑,尽量剥离在业务逻辑之外。让开发同学更专注于业务逻辑实现。

如:指令、装饰器。

1
2
3
4
5
6
7
8
9
10
11
12
import { Debounce } from '@/utils';

// good
@Debounce(200, { leading: false, trailing: true })
handleBtnClick() {
// bad
// if (role === 'MEMBER') {
// return;
// }

// do something...
}
  1. 判断以字符串开头/结尾
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 判断字符串是否某字符串开头
* @param {*} source
* @param {*} start
* @returns
*/
export const startsWith = (source, start) => {
return source.slice(0, start.length) === start;
};

/**
* 判断字符串是否某字符串结尾
* @param {*} source
* @param {*} end
* @returns
*/
export const endsWith = (source, end) => {
return source.indexOf(end, source.length - end.length) !== -1;
};
  1. 返回跳转问题

Close 按钮是 back 还是 navigateTo ?

个人倾向于是使用 back,
优势在于: 因为这样操作的路由栈是纯粹的, 不会因为魔改而污染路由栈.
比如点击左上角返回, 或者移动端手势操作返回的时候, 都不会有影响.

缺点在于: 如果是通过外链, 直接跳转到该页面, 那么就需要判断.
此时的关闭是返回外链来源页面, 还是返回自身应用的首页.
需要单独用代码来处理. (或者通过携带参数, 来做判断处理)

  1. Dropdown 下拉菜单实现

下拉菜单的触发区, 尽量采用 listPopup.length > 0 来判断.
这样方便后续某些场景没有下拉菜单项的时候, 就自动不会展示下拉菜单触发区(如…),
这样也省心省力.

  1. Select 的组件操作

如果有条件的话, 在从接口拿到 value 对其进行初始化的时候,
可以考虑根据 options list 的数据(如不是分页加载)做一下筛选.

这样可以避免后端给出一些可能已经不存在于 options 的 value,
从而影响到前端的空值表单校验.

  1. 分页列表刷新场景

下方加载中状态展示逻辑:
只要当前数据数量小于数据数量总数, 那就会一直展示(屏幕外也是如此)

如果滚动到底部, 触发请求分页操作.
请求分页操作做 请求锁 + 防抖处理.

如果当前请求页数不是第一页, 那么就拼接老列表数据.

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
export default function MemberScoreList() {
const isLoading = useRef(false);
const pageNum = useRef(0); // 页数
const pageSize = useRef(15); // 分页数
const pageTotalCount = useRef(999999); // 数据总数
const [listScore, setListScore] = useState([]);

const getList = async () => {
isLoading.current = true;

const res = await Api.Score.getList({
pageNum: pageNum.current,
pageSize: pageSize.current,
});

if (res?.body) {
const { totalCount = 0, list = [] } = res?.body || {};
pageTotalCount.current = totalCount;
const listNew = (list || []).map((item) => ({
...item,
createTimeStr: dayjs(item.sys_createTime).format("YYYY-MM-DD HH:mm:ss"),
}));

let listOld = pageNum.current === 0 ? [] : listScore;

setListScore(listOld.concat(listNew));
}

setTimeout(() => {
isLoading.current = false;
}, 600);
};

const init = async () => {
getQueryScoreList();
};

useEffect(() => {
init();
}, []);

const handleRefreshScore = () => {
init();
};

const hanldePageScrollToLower = useDebounce(
() => {
if (isLoading.current) {
return;
}
if (listScore.length >= pageTotalCount.current) {
return;
}

pageNum.current += 1;
getQueryScoreList();
},
500,
{ leading: false, trailing: true }
);

return (
<SAPageCore
isShowLoadingBottom={listScore.length < pageTotalCount.current}
renderPageHeader={() => {
return (
<View className="flex flex-col">
<SANavHeader isShowBtnLeft title="积分记录" />
</View>
);
}}
onScrollToLower={hanldePageScrollToLower}
>
<View className="relative box-border px-2 pb-2">
<ListScore list={listScore} />
</View>
</SAPageCore>
);
}
  1. 善用 Promise.all 并行请求接口

Promise.all 兼容性更好, Promise.allSettled 更安全.(注意返回值结构不一样)
另注意, Promise.all 如果其中一个异步操作抛出错误, 那么会全部直接返回.
如果为了避免该情况,
可以在每个异步处理中, 使用 try catch 进行包裹, 如果报错异常, 也去手动正常返回一个值, 即可避免该问题.

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
// 调用接口 A,返回列表中每个数据的 name 字段全转换为大写,然后渲染到页面的 listA 上。
// 返回列表元素大于 1,则同时调用接口 B、接口 C,并将二者返回数据数组拼接成一个数组,赋值给 listResult 。
// 返回列表元素不大于 1,则只调用接口 D,并将返回数据赋值给 listResult 。

async queryList() {
try {
const resA = await queryA();
this.listA = resA
?.filter((item) => item?.total > 80)
.map((item) => {
return {
...item,
label: item.name.toUpperCase(),
};
});
if (this.listA?.length > 1) {
const [resB = [], resC = []] = await Promise.all([queryB(), queryC()]);
this.listResult = [...resB, ...resC];
} else {
this.listResult = await queryD();
}
} catch (error) {
console.log(error);
}
};
async init() {
await queryList();
}
  1. 常见风险操作注意
1
2
3
4
// 如果 obj 不是对象类型, 即会报错
const { a, b, c } = obj;
// 如果 obj 不是数组类型, 即会报错
const [x, y, z] = arr;
1
2
3
4
// 如果接口返回的格式不是固定格式, 或者接口非200异常情况, 很可能就会解析报错
const {
data: { status, body, message },
} = await queryList();
1
2
3
4
5
// obj如果存在循环引用, 即会报错
JSON.stringify(obj);

// str为非法可反序列化字符串, 即会报错
JSON.parse(str);
1
2
3
4
5
6
7
8
// arr 可能是 undefined , 那么调用不存在的数组方法即会报错
arr.map((item) => {
item.a = 1; // 内部偷偷把 arr 数据源修改了, 也可能会埋坑
return item;
});
arr.filter(() => {
return true;
});
1
2
3
4
5
6
init() {
// 如果父组件异步获取 id, 传值给子组件,
// 子组件在加载初始化的时候就使用 id , 那么有概率会因为 id 为空而导致报错
const { id } = this.props || {}
// do something...
}

代码优化

  1. 慎用 redux. 由于每次变化会触发全局刷新. 建议只存储唯一性的值.
    a) redux 是响应式, 每次更新都会触发 diff 算法, 全局刷新渲染 DOM, 影响性能.
    b) redux 是全局纬度, 其生命周期很难把握.
    如果不做处理, 只是一味的存储, 不去主动销毁, 最终就会内存泄漏.
    建议只存储全局唯一状态性的值. 如: 品牌、菜单列表等.

    搞清楚数据纬度: 组件纬度、页面纬度、全局纬度.
    各自纬度的数据, 存储到各自纬度中, 方便代码维护.

  2. 与渲染无关的数据尽量不要放在 state 中, 可以放在 useRef 中.
    与渲染无关的数据尽量不要放在 state 中, 可以考虑放在 useRef 或 this 中.
    每次渲染会对 state 对象进行遍历 diff 算法. 减少 state 的内容, 可提升渲染效率.
    且对 useRef 和 this 的修改是同步的, 能处理一些需要属性及时生效的业务场景.

  3. 善用节流、防抖.
    可对 usePageScroll 等场景装饰节流、对 input 的 value 变化场景装饰防抖.

  4. 封装组件的时候, 留意不要套多余无意义 View . 以免页面 DOM 层级太深, 影响渲染性能.
    可适当用<Block>或者<Fragment>代替

  5. Input 输入框跳位问题. 尽量不要对 Input 的 value 二次 setState.

  6. 尽量减少页面的跳转交互.
    页面跳转体验奇差. 会有白屏闪现.
    在业务能满足的情况下, 尽量单页去实现功能.
    可提出建议反馈产品设计.

  7. 尝试页面处于 loading 状态时候使用骨架屏.

  8. Input 的输入内容要进行 trim()处理.

  9. 点击等用户操作, 一定要有反馈效果.
    比如: 适当的 loading / toast,
    按钮灰度大小变化, 弹窗动画, 展开收起动画等.
    这样不仅对用户友好, 也方便后续自身定位问题.

  10. 移动端点击区域要舒适.
    不要太老实直接把事件绑定到 span / text 上面. 点击区域应适当放大一些, 以方便用户的操作.

  11. sss

项目管理

  1. 通过 jsDoc 完善 api 文档
  2. 通过 jscpd 判断代码重复率
  3. 工具化代码 review 平台
  4. 项目管理系统平台
  5. 自动化测试

后记

本篇部分内容为实际项目已经用到且取得相应成效的方法.
部分内容为个人见解尚在理论部分, 待实践.
项目规范是一个不断优化、不断完善的长期过程.
要做到因地制宜, 慢慢尝试, 找到最适合自己团队的方式,
这样才能真正提升团队开发效率.
我也会不断更新该篇文章.