Featured image of post 前端编码规范

前端编码规范

古茗内部分享文章

什么是编码规范

代码风格/编程范式、命名规范、目录规范、commit 规范、ts 规范等……

为什么需要编码规范

  1. 保证代码质量——健壮性、性能(通过静态分析、编程范式等,减少开发期间代码本身造成的 bug)
    • 未删除的引用/内存泄漏
    • 死循环/dead code
    • 重复渲染/性能瓶颈
    • 代码冗余(重复的样板代码)
  2. 提高协作效率——可读性和可维护性(代码即文档)
    • 变量/函数命名拼写错误
    • 过深的嵌套/过长的单文件组件
    • 统一的代码风格,减轻理解压力
  3. 减少文件变动(减轻 cr 压力、改动溯源)
    • 代码格式化所造成的代码变动
    • git blame

通过前端基础规范,我们希望达到的目的:

  • 提高代码整体的可读性、可维护性、可复用性、可移植性和可靠性,这会从根本上降低开发成本,也是最重要的一点。
  • 保证代码的一致性:软件系统中最重要的因素之一就是编码的一致性。如果编码风格一致,也更加易于维护,因为团队内任何人都可以快速理解并修改。
  • 提升团队整体效率:开发人员通常需要花费大量的时间来解决代码质量问题,如果都按照规范编写,也有助于团队尽早发现问题,甚至完全预防问题,这将提高整个交付过程的效率。
  • 减少 code review 期间一系列的争议,因为缺乏标准,在争议过程中双方很难妥协。

什么样的规范是好的

命名规范

命名规范不仅是 camelCasesnake_casePascalCase 等命名格式,更加重要的是语义化的变量/函数名,例如:

  • formatXXX 用作格式化字段

  • renderXXX 应当返回 ReactNode | ReactElement

  • handleXXX 处理事件

  • 数组变量使用名词的复数形式,如 shopIdsusers

  • ……

Bad Case

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export function format(da: any) {
  const { banner, showCellSelect, isProcess } = da.list
  const faConfig = da.list.da.map((it: any, index: any) => {
    const cConfig = eachKeySort.map((k) => {
      if (!it[k]) return [];
      return tranModleToFieldConfig[k](it[k], it, index);
    });

    return {
      type: 'object',
      customKey: `group${index}`,
      map: _flatten([...cConfig, renderExtend(it, index)])
    };
  });
}

Good Case(赏心悦目)

 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
const ReasonManagement: React.FC<object> = () => {
  const form = useRef<any>(null);

  const [modalForm] = Form.useForm();

  const [updateStatusModalVisible, setUpdateStatusModalVisible] = useState(false);
  const [updateNodeInstanceId, setUpdateNodeInstanceId] = useState<number | undefined>();

  const handleUpdateStatus = useCallback(
    (nodeInstanceId, nodeInstanceStatus) => {
      modalForm.setFieldsValue({
        status: nodeInstanceStatus,
      });

      setUpdateNodeInstanceId(nodeInstanceId);
      setUpdateStatusModalVisible(true);
    },
    []
  );

  const columns = useMemo(
    () => getColumns({ handleUpdateStatus }),
    [handleUpdateStatus]
  );

  const resetUpdateNodeInstanceId = () => {
    setUpdateStatusModalVisible(false);
    setUpdateNodeInstanceId(undefined);
  };

  return (
    <>
      <GeneralSearchTable
        ref={form}
        pageTitle="新店任务列表"
        columns={columns}
        {...listModel}
        onFetchSchema={querySchema}
        onSearch={listModel.fetch}
      />

      <Modal
        title="修改任务"
        open={updateStatusModalVisible}
        width="400px"
        centered
        onCancel={resetUpdateNodeInstanceId}
        onOk={() => {
          modalForm.validateFields().then((res) => {
            service
              .updateProcessStatus({
                nodeInstanceId: updateNodeInstanceId,
                status: res?.status,
              })
              .then(() => {
                message.success('修改成功');
                setUpdateStatusModalVisible(false);
                form.current.search();
              });
          });
        }}
      >
        <Form form={modalForm}>
          <Form.Item
            label="任务执行状态"
            name="status"
            rules={[{ required: true, message: '请选择任务执行状态' }]}
          >
            <Select options={ProcessStatusOptions} />
          </Form.Item>
        </Form>
      </Modal>
    </>
  );
};

export default observer(ReasonManagement);

目录规范

 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
routes
├── 📁college
├── 📁banner-management
├── 📁CollegeStatistics
├── 📁Course
├── 📁DownloadCenter
├── 📁Exam
├── 📁group-management
├── 📁LearningMaterialManagement
│   ├── 📁AddLearning
│   │   ├── 📄AddLearning copy.js
│   │   ├── 📄index.jsx
│   │   ├── 📄index.module.less
│   │   ├── 📄model.js
│   │   ├── 📄select-relation-material.jsx
│   │   ├── 📄SelectArticles.js
│   │   ├── 📄SelectQuestions.js
│   │   └── 📄SelectShops.js
│   └── 📁modal-tip
│       ├── 📄AddLearning.js
│       ├── 📄AddLearning.less
│       ├── 📄DDPushMaterialDialog.js
│       ├── 📄ImgUploader.js
│       ├── 📄ImgUploader.module.less
│       ├── 📄index.module.less
│       ├── 📄index.tsx
│       ├── 📄LearningList.jsx
│       ├── 📄LearningList.module.less
│       ├── 📄TreeSort.module.less
│       ├── 📄TreeSort.tsx
│       ├── 📄TreeTable.js
│       └── 📄TreeTable.module.less
├── 📁LearnPlan
├── 📁QuestionBank
├── 📁QuestionCategory
├── 📁QuestionManagement
├── 📁special-plan
├── 📁TagManagement
├── 📁task-management
└── 📁trains-statistics

大小写混用 + 模块糅杂在一团 + 未引用的组件仍然存留,造就了维护难题

所有的文件名/目录名都应遵循:kebab-case 的命名规范

大小写混用也会存在问题,当本地 Git 没有开启大小写敏感时,会导致 diff 失效

合理的目录结构:既然只有两个页面,那么就只应当有两个目录文件夹(indexadd-learning),其他的如组件等单独放置在文件夹中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
📁learning-material-management
├── 📁add-learning
│   ├── 📄index.jsx
│   └── 📄index.module.less
├── 📁index
│   ├── 📄index.jsx
│   └── 📄index.module.less
└── 📁components
    ├── 📁dd-push-material-dialog
    ├── 📁learning-list
    ├── 📁modal-tip
    ├── 📁select-relation-material
    ├── 📁tree-sort
    └── 📄index.ts

代码风格

函数式、OOP、单一职责原则、SoC(关注点分离)原则、开闭原则

下面是一段神迹(谨慎阅读)

  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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
const TaskMenu = [
  { title: '实地拜访' },
  { title: '拜访问题跟进' },
  { title: '现场巡店' },
  { title: '校准帮扶' },
  { title: '门店任务' },
  { title: '违规整改' },
];

const TaskCard: React.FC = () => {
  const taskList = store.taskList || {};
  const { patrolTask, reformTask, shopTask, complianceTask, visitTask, visitProblemTask } =
    taskList;
  let finishNum = 0;
  let totalNum = 0;
  const getTaskProgress = (taskType) => {
    switch (taskType) {
      case 1:
        finishNum = visitTask?.finishNum || 0;
        totalNum = visitTask?.totalNum || 0;
        break;
      case 2:
        finishNum = visitProblemTask?.finishNum || 0;
        totalNum = visitProblemTask?.totalNum || 0;
        break;
      case 3:
        finishNum = patrolTask?.finishNum || 0;
        totalNum = patrolTask?.totalNum || 0;
        break;
      case 4:
        finishNum = reformTask?.finishNum || 0;
        totalNum = reformTask?.totalNum || 0;
        break;
      case 5:
        finishNum = shopTask?.finishNum || 0;
        totalNum = shopTask?.totalNum || 0;
        break;
      case 6:
        finishNum = complianceTask?.finishNum || 0;
        totalNum = complianceTask?.totalNum || 0;
        break;

      default:
        break;
    }
    return totalNum ? Number((finishNum / totalNum) * 100).toFixed(2) : 100;
  };

  const getTaskTitle = (title, taskType) => {
    switch (taskType) {
      case 1:
        return `${title}(${visitTask?.finishNum || 0}/${visitTask?.totalNum || 0})`;
      case 2:
        return `${title}(${visitProblemTask?.finishNum || 0}/${visitProblemTask?.totalNum || 0})`;
      case 3:
        return `${title}(${patrolTask?.finishNum || 0}/${patrolTask?.totalNum || 0})`;
      case 4:
        return `${title}(${reformTask?.finishNum || 0}/${reformTask?.totalNum || 0})`;
      case 5:
        return `${title}(${shopTask?.finishNum || 0}/${shopTask?.totalNum || 0})`;
      case 6:
        return `${title}(${complianceTask?.finishNum || 0}/${complianceTask?.totalNum || 0})`;

      default:
        return '--(0/0)';
    }
  };

  const IsCompleted = (taskType) => {
    return getTaskProgress(taskType) === 100;
  };

  const toDetail = (taskType) => {
    switch (taskType) {
      case 1:
        Taro.setStorageSync('todo-tab', 'visitTask');
        break;
      case 2:
        Taro.setStorageSync('todo-tab', 'visitProblemTask');
        break;
      case 3:
        Taro.setStorageSync('todo-tab', 'patrolReportTask');
        break;
      case 4:
        Taro.setStorageSync('todo-tab', 'reformTask');
        break;
      case 6:
        Taro.setStorageSync('todo-tab', 'complianceTask');
        break;

      default:
        Taro.setStorageSync('todo-tab', 'allTask');
        break;
    }
    if (taskType === 5) {
      Taro.navigateTo({ url: '/pages/package/todo/index/index' });
      return;
    }
    taskStore.setFromIndex(true);
    Taro.switchTab({ url: '/pages/package/todoTask/index/index' });
  };

  return (
    <View className="task-container">
      <View className="title-block">
        <Text className="left-title">今日待办</Text>
        <Text className="right-title" onClick={() => toDetail(0)}>
          查看全部
          <Icon value="chevron-right" className="arrow-right" />
        </Text>
      </View>
      <View className="task-list">
        {TaskMenu.map((menu, index) => {
          return (
            <View className="task-item" key={menu.title}>
              <View className="task-name">{getTaskTitle(menu.title, index + 1)}</View>
              <View className="task-progress-text">已完成{getTaskProgress(index + 1)}%</View>
              <Progress percent={getTaskProgress(index + 1)} className="task-progress" />
              <Button
                theme="primary"
                size="small"
                shape="round"
                className="button"
                disabled={IsCompleted(index + 1)}
                onClick={() => toDetail(index + 1)}
              >
                {IsCompleted(index + 1) ? '已完成' : '去完成'}
              </Button>
            </View>
          );
        })}
      </View>
    </View>
  );
};
export default observer(TaskCard);

这是重构之前,改需求的 diff 信息:

 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
...
     totalNum = visitTask?.totalNum || 0;
     break;
+  case 2:
+    finishNum = visitProblemTask?.finishNum || 0;
+    totalNum = visitProblemTask?.totalNum || 0;
+    break;
+  case 3:
+    finishNum = patrolTask?.finishNum || 0;
+    totalNum = patrolTask?.totalNum || 0;
+    break;
   case 4:
-    finishNum = visitProblemTask?.finishNum || 0;
-    totalNum = visitProblemTask?.totalNum || 0;
+    finishNum = reformTask?.finishNum || 0;
+    totalNum = reformTask?.totalNum || 0;
     break;
   case 5:
-    finishNum = reformTask?.finishNum || 0;
-    totalNum = reformTask?.totalNum || 0;
+    finishNum = shopTask?.finishNum || 0;
+    totalNum = shopTask?.totalNum || 0;
     break;
   case 6:
-    finishNum = shopTask?.finishNum || 0;
-    totalNum = shopTask?.totalNum || 0;
+    finishNum = complianceTask?.finishNum || 0;
+    totalNum = complianceTask?.totalNum || 0;
     break;
-  case 6:
-    finishNum = complianceTask?.finishNum || 0;
-    totalNum = complianceTask?.totalNum || 0;
+  case 7:
+    finishNum = visitProblemTask?.finishNum || 0;
+    totalNum = visitProblemTask?.totalNum || 0;
     break;
   default:
     break;
...
   case 1:
     return `${title}(${visitTask?.finishNum || 0}/${visitTask?.totalNum || 0})`;
+  case 2:
+    return `${title}(${visitProblemTask?.finishNum || 0}/${visitProblemTask?.totalNum || 0})`;
   case 3:
     return `${title}(${patrolTask?.finishNum || 0}/${patrolTask?.totalNum || 0})`;
-  case 2:
-    return `${title}(${visitProblemTask?.finishNum || 0}/${visitProblemTask?.totalNum || 0})`;
   case 4:
     return `${title}(${reformTask?.finishNum || 0}/${reformTask?.totalNum || 0})`;
-  case 3:
-    return `${title}(${patrolTask?.finishNum || 0}/${patrolTask?.totalNum || 0})`;
   case 5:
     return `${title}(${shopTask?.finishNum || 0}/${shopTask?.totalNum || 0})`;
+  case 6:
+    return `${title}(${complianceTask?.finishNum || 0}/${complianceTask?.totalNum || 0})`;
+  case 7:
+    return `${title}(${visitProblemTask?.finishNum || 0}/${visitProblemTask?.totalNum || 0})`;
   default:
     return '--/--';
...
     Taro.setStorageSync('todo-tab', 'visitTask');
     break;
   case 2:
-    Taro.setStorageSync('todo-tab', 'visitProblemTask');
+    Taro.setStorageSync('todo-tab', 'patrolReportTask');
+    Taro.setStorageSync('todo-tab', 'visitProblemTask');
     break;
   case 3:
-    Taro.setStorageSync('todo-tab', 'patrolReportTask');
+    Taro.setStorageSync('todo-tab', 'reformTask');
     break;
   case 4:
-    Taro.setStorageSync('todo-tab', 'reformTask');
+    Taro.setStorageSync('todo-tab', 'shopTask');
     break;
   case 5:
-    Taro.setStorageSync('todo-tab', 'shopTask');
+    Taro.setStorageSync('todo-tab', 'complianceTask');
     break;
   case 6:
-    Taro.setStorageSync('todo-tab', 'complianceTask');
+    Taro.setStorageSync('todo-tab', 'visitProblemTask');
     break;
   default:
     Taro.setStorageSync('todo-tab', 'allTask');
     break;
-  if (taskType === 4) {
+  if (taskType === 5) {
     Taro.navigateTo({ url: '/pages/package/todo/index/index' });
     return;

这是重构之后的代码

 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
const TaskMenu: ITaskMenuItem[] = [
  { title: '实地拜访', taskType: 'visitTask' },
  { title: '拜访问题跟进', taskType: 'visitProblemTask' },
  { title: '现场巡店', taskType: 'patrolTask' },
  { title: '校准帮扶', taskType: 'reformTask' },
  { title: '上新实操打卡', taskType: 'supervisorClockInTask' },
  { title: '门店任务', taskType: 'shopTask' },
  { title: '违规整改', taskType: 'complianceTask' },
];
const TaskCard: React.FC = () => {
  const { taskList = {} } = store;

  const getTaskProgress = (taskType: ITaskType) => {
    const { finishNum = 0, totalNum = 0 } = taskList[taskType];
    return totalNum ? Number((finishNum / totalNum) * 100).toFixed(2) : 100;
  };

  const getTaskTitle = (menu: ITaskMenuItem) => {
    const { finishNum, totalNum } = taskList[menu.taskType];
    return `${menu.title || '--'}(${finishNum || 0}/${totalNum || 0})`;
  };

  const IsCompleted = (taskType: ITaskType) => {
    return getTaskProgress(taskType) === 100;
  };

  const toDetail = (taskType?: string) => {
    if (taskType === 'shopTask') {
      // 门店任务跳转门店代办列表
      Taro.navigateTo({ url: '/pages/package/todo/index/index' });
      return;
    }
    if (!taskType) {
      Taro.setStorageSync('todo-tab', 'allTask');
    } else {
      Taro.setStorageSync('todo-tab', taskType);
    }
    taskStore.setFromIndex(true);
    Taro.switchTab({ url: '/pages/package/todoTask/index/index' });
  };

  return (
    <View className="task-container">
      <View className="title-block">
        <Text className="left-title">今日待办</Text>
        <Text className="right-title" onClick={() => toDetail()}>
          查看全部
          <Icon value="chevron-right" className="arrow-right" />
        </Text>
      </View>
      <View className="task-list">
        {TaskMenu.map((menu) => {
          return (
            <View className="task-item" key={menu.title}>
              <View className="task-name">{getTaskTitle(menu)}</View>
              <View className="task-progress-text">已完成{getTaskProgress(menu.taskType)}%</View>
              <Progress percent={getTaskProgress(menu.taskType)} className="task-progress" />
              <Button
                theme="primary"
                size="small"
                shape="round"
                className="button"
                disabled={IsCompleted(menu.taskType)}
                onClick={() => toDetail(menu.taskType)}
              >
                {IsCompleted(menu.taskType) ? '已完成' : '去完成'}
              </Button>
            </View>
          );
        })}
      </View>
    </View>
  );
};
export default observer(TaskCard);

为什么会出现这种情况?

  1. 出现:可能并没有这么多情况,也许只有两三个列表项,不需要做这么多的抽离,嫌麻烦直接写了
  2. 增长:随着需求的增多,时间偏紧张时,不会考虑重构
  3. 最终:忍无可忍被迫重构,需要评估涉及页面跳转的影响,徒增工作量

在问题还没有暴露的时候就解决它,不要做给自己挖坑的人

过度封装/抽象同样不可取

参考:再见,整洁代码

TS 规范——体操 or AnyScript?

如何在利用类型系统提高开发效率的同时,不被复杂的类型体操束缚了手脚

命名规范

所有的类型定义应遵循:PascalCase 规范,类型命名应由有语义化的单词组成,不建议在单词前添加 ITE等类型前缀

  • 用户不需要感知类型是 interface 还是 type,都应当成 TS 类型看待

  • 在 TS 中,类可以实现接口,接口可以继承接口,接口可以继承类,类和接口都是某种意义上的抽象和封装,继承时不需要关心它是一个接口还是一个类。如果用 I 作前缀,当一个变量的类型更改了,比如由接口变成了类,那变量名称就必须同步更改

  • 根据三方开源库类型命名规范,具体可参考常见开源库的类型定义:reactnode/vmantd/button

如何编写类型

  1. 覆盖所有边界条件,不要让类型链路中断

如果一个参数存在为空的可能,就将其标为可选或 undefined

类型的信任危机:我是否可以信任同事编写的组件类型?

  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
type RequiredOneOf<T, K extends keyof T> = { 
  [P in K]: Omit<T, P> & { [S in P]?: never } 
}[K] extends infer O
  ? { [K in keyof O]: O[K] }
  : never;

// Table 的 width 和 children 不能同时传入
type AllColumnProps = {
  dataIndex?: string
  width: number
  children: RequiredOneOf<AllColumnProps, "width" | "children">[]
}

type ColumnProps = RequiredOneOf<AllColumnProps, "width" | "children">[]

const columns: ColumnProps = [
  {
    dataIndex: "a",
    width: 2,
  },
  {
    width: 2,
    children: [] // 报错
  }
]

优势:利用强类型校验和类型推断,减少开发时可能存在的问题,可以理解为一种 TDD

劣势:可读性差、可拓展性差

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const interviews = ['technical', 'behavior', 'cross', 'hr'] as const;

type CodingSkill = 'shit' | 'rough' | 'normal' | 'good' | 'excellent';
type FrontendKnowledge = 'react' | 'vue' | 'angular';
type RequireOne<T> = T & { [P in keyof T]: Required<Pick<T, P>> }[keyof T];

type Engineer = {
  codingSkill: Exclude<CodingSkill, 'shit'>;
  frontendKnowledge: RequireOne<{ [interested in FrontendKnowledge]?: 'familiar' }>;
  tsLevel: any;
  selfDriven: true;
  makeSameMistake: never;
  toBWorkingExperience?: number;
  pass<T extends typeof interviews[number]>(the: T): boolean;
}

function isMokaFEQualified<T extends Engineer>(you: T): you is Qualified {
  return interviews.every(you.pass);
}
  1. 善用泛型及内置工具、不要重复编写类型
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
interface OptionType<LabelType = string, ValueType = string> {
  label: LabelType;
  value: ValueType;
}

type ItemType = Pick<GetShopListResponse, "id" | "name">

interface SelectAllProps extends SelectProps {
  value: string
}

一个思路:让有内在联系的类型通过泛型工具建立起类型上的联系

  1. 谨慎使用 any

如果没有必要,尽量避免使用 any,一个更好的选择是使用 unknown,因为 any 代表着完全不进行类型检查,并且会具有传染性,在与现有组件/库类型冲突时,考虑使用 declare 修改类型声明或是直接修改组件类型,any 是最后的选择。

React 规范

减少重复的 hook / 多使用自定义 hook

 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
const [form] = Form.useForm();
const { dispatch, AddLearning } = props;
const { activeDetail, groupRankId, groupTagTree = [] } = AddLearning;

const [relativeShops, setRelativeShops] = useState([]);
const [relativeQuestions, setRelativeQuestions] = useState([]);
const [relativeArticles, setRelativeArticles] = useState([]);
const [videoList, setVideoList] = useState([]);
const [videoVisible, setVideoVisible] = useState(false);
const [videoUrls, setVideoUrls] = useState('');
const areaGroupRef = useRef(null);
const [allCategory, setAllCategory] = useState([]);
const [uploading, setUploading] = useState(false);
const [initImgValue, setInitImgValue] = useState([]);
const [update, setUpdate] = useState(false);
const [relationTagInfo, setRelationTagInfo] = useState([]);
const [visible, setVisible] = useState(false);
const [selectedRows, setSelectedRows] = useState([]);
// const [originRichContent, setOriginRichContent] = useState({});

const OSSUploadRef = useRef(null);

const handleSetDefaultCoverImage = (url) => {
  // ...
};

const fetchAllCategory = async () => {
  // ...
};

const EDITOR_CONFIG = {
  // ...
};

const editor0Ref = useRef(null);
const editor1Ref = useRef(null);
const editor2Ref = useRef(null);

处理同一个逻辑的 state 和 effect 应当在代码层面是临近的、便于抽离的

避免嵌套的三目运算符 / 使用 Early Return

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  label: '首次下发日期',
  value:
    formData.periodSendV2?.unit === 'DAY'
      ? moment(formData.periodSendV2?.firstSendDate).format('YYYY-MM-DD')
      : formData.periodSendV2?.unit === 'WEEK'
      ? `第${Number(formData.periodSendV2?.firstSendDate) + 1}周 (${getWeeks()?.[formData.periodSendV2?.firstSendDate]?.start} ~ ${getWeeks()?.[formData.periodSendV2?.firstSendDate]?.end})`
      : formData.periodSendV2?.unit === 'MONTH'
      ? `第${Number(formData.periodSendV2?.firstSendDate) + 1}月 (${getMonths()?.[formData.periodSendV2?.firstSendDate]?.label})`
      : '',
  disabled: formData.periodSendV2?.unit === 'FIXED_TIME',
}

这里的逻辑还是比较清晰的,如果每次 else 分支的逻辑都和前面的有差异,就会变得难以阅读和维护了。

解决方案:使用函数包裹,抽离出去引入或者使用 IIFE

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  label: '执行人',
  value: (() => {
      if (formData.createFrom === 'SHOP') {
          if (formData?.assignType === 'ROLE') {
              return formData.assignmentRoles.map((role) => role.roleName).join(', ');
          }
          return formData.targetUsers
              .map((user: { userName: string }) => user.userName)
              .join(', ');
      } else {
          const options =
              formData.targetObjectType === 'STORE_USER'
                  ? USER_ROLE_CHECKBOX_OPTION
                  : SUPERVISOR_USER_ROLE_CHECKBOX_OPTION;

          return options
              .filter((item) => formData.targetRoles?.includes(item.value))
              .map((item) => item.label)
              .join(', ');
      }
  })(),
  disabled: formData.targetObjectType === 'HEADQUARTERS_USER',
}

import order

保证固定的 import order 可以减少 git diff 的变动

组件类型定义规范

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Bad Case
const Component: React.FC<{ title: string; status: number }> = ({ title, status }) => {
  ...
}
// 更有甚者
const Component = ({ title, status }) => {
  ...
}

// Good Case
enum ComponentStatus {
  success = 1,
  fail,
}

interface ComponnetsProps {
  title: string;
  status: ComponentStatus
}

const Component: React.FC<ComponentProps> = (props) => {
  const { title, status } = props;
  ...
}

不要把类型和 props 都放到同一行去写,类型声明单独抽离到 types 文件中

Effect Dependencies 规范

Dependencies 数组,并不是一个可选项

不是你选择 effect dependencies,而是 effect dependencies 就应该有这些依赖

参考:useEffect 完整指南

React Hook useEffect has missing dependencies: xxx. Either include them or remove the dependency array. If xxx changes too often, find the parent component that defines it and wrap that definition in useCallback. eslint(react-hooks/exhaustive-deps)

React Hooks 的 exhaustive-deps eslint 插件将不全或冗余的依赖数组项都抛出了 warning,它并不是一个 error,这也是 React 团队的一个 trade-off。按理说 React 团队的最初想法应该是强制所有 hook dependencies 都加入所有涉及到的依赖项,但无奈这是一件不可能完成的事——你没法保证所有人都按照你所设定的方式去使用框架。

归根到底实际上就是一句话:既然它不会变,为什么不把他加进去;既然它会变,凭什么不把它加进去。

If it’s the same then including it in the dependencies won’t hurt you. I want to emphasize it — if you’re sure your dependencies never change then there is no harm in listing them. However, if later it happens that they change (e.g. if a parent component passes different function depending on state), your component will handle that correctly.

「Optional」为什么这么说?——重新认识 useEffect

一个很多人会忽视的点:hooks 不是生命周期,React 的数据流与更新,需要开发者自己把控。

我们从任何一个角度去理解 React 的运作,都离不开一个公式:

UI = render(state)

从这个角度来看,useEffect 本质上只在做一件事——当组件 render 时,根据当前的状态,作出不一样的处理,至于组件何时触发更新,并不是它所关心的。所以这里隐含着一个概念,就是,如果一个 effect 函数内使用了某个值,那么,当这个值变化时,你如何保证它不会产生其他的影响?例如导致原本应当产生的副作用没有触发,或是应当清除的副作用没有清除。

所以,一个好的 Effect 写法,应当是包含了所有的依赖项,在其内部涵盖了对所有依赖项变动的处理,这样可以从框架层面为我们规避掉许多问题(主要是闭包陷阱)

关于 React Hook 的数据流,也不再是基于 created => mounted => updated 这样的生命周期循环,而是基于 render => re-render 这样的基于视图变动的循环,每一个时间节点的 React 组件,彼此之间并没有严格意义上的生命周期与之对应。它们只有 state 的不同,而面对 state 的 render 函数,并不会因为 state 的不同而改变,我们无法决定它在某个特定的时间点做特定的事情,而是一视同仁地对待所有 state,这也是 React 纯函数思想的一个体现。

传统的生命周期

React Hooks 的“生命周期”

一个典型的例子——init 函数

当我们改变思维,重新看下面的代码

1
2
3
4
5
6
7
8
9
const [data, setData] = useState([])

const init = () => {
  queryData().then(res => setData(res));
}

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

这是个很常见的场景,例如在列表页的时候我们经常会做一个初始化的请求,随后在请求参数变化时做另外的请求,像下面这样

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const [data, setData] = useState([])

const init = () => {
  queryData({ /* default params */ }).then(res => setData(res));
}

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

const onClick = () => {
  queryData({ /* new params */ }).then(res => setData(res));
}

事实上这两件事是同构的,他们本质上可以合并成一个 effect——当 params 改变时重新请求,上面的代码和下面的没有本质区别

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const [data, setData] = useState([])
const [params, setParams] = useState({ /* default params */ })

const fetchData = (_params) => {
  queryData(_params).then(res => setData(res));
}

useEffect(() => {
  fetchData(params);
}, [params]);

const onClick = () => {
  setParams({ /* new params */ })
}

这样就万事大吉了吗?我们可以再添加一下复杂度,我们让 fetchData 这个函数再依赖一个外部变量试试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const [data, setData] = useState([])
const [params, setParams] = useState({ /* default params */ })
const [type, setType] = useState("create")

const fetchData = (_params) => {
  queryData({
    ..._params,
  	type
  }).then(res => setData(res));
}

useEffect(() => {
  fetchData(params);
}, [params]);

const onClick = () => {
  setParams({ /* new params */ })
}

这时候,你就会发现,eslint 给你报 warning 了,提示你把 fetchData 这个函数加入到依赖数组中。

image.png

想象一下,如果你的函数相当复杂,你完全没有注意到函数内部引用了外部的某个变量,而在 useEffect 函数内,你又只传入了 params 一个参数,你会不会直接忽略或者禁用了,然后等测试给你提 bug……

所以说,这条规则的存在是有其意义的,我们应当主动去遵守,在这种情况下,我们主要有两种解法:

解法一:不要使用 effect 外的值,将函数定义移入 effect 内部

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const [data, setData] = useState([])
const [params, setParams] = useState({ /* default params */ })
const [type, setType] = useState("create")

useEffect(() => {
  const fetchData = (_params) => {
  	queryData({
    	..._params,
  		type
  	}).then(res => setData(res));
	}
  fetchData(params);
}, [params, type]);

const onClick = () => {
  setParams({ /* new params */ })
}

将函数定义移到 effect 内部,就可以直接在依赖数组中加入 type 了。这种解法实际上对应着一个宇宙万法的基本原则~~(不是)~~:奥卡姆剃刀原理,即——如无必要,勿增实体

解法二:使用 useCallbackuseMemo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const [data, setData] = useState([])
const [params, setParams] = useState({ /* default params */ })
const [type, setType] = useState("create")

const fetchData = useCallback((_params) => {
  queryData({
    ..._params,
  	type
  }).then(res => setData(res));
}, [type])

useEffect(() => {
  fetchData(params);
}, [params, fetchData]);

const onClick = () => {
  setParams({ /* new params */ })
}

现在你可以放心大胆地把函数也放到依赖数组里了。这是一个通用解法,也是 React 官方提倡的解法,它实际上是在做一件事——让原先可能会变的值不再变化。原先的 function 在每次 render 时都会重新销毁和创建,而现在,我们通过将函数 memorize,将其也纳入了组件的数据流中,从而使得组件的更新更加精确可控

With useCallback, functions can fully participate in the data flow. We can say that if the function inputs changed, the function itself has changed, but if not, it stayed the same. Thanks to the granularity provided by useCallback, changes to props like props.fetchData can propagate down automatically.

一个误解:关于性能优化——useCallback/useMemo 究竟解决的是什么问题?

从本质上来看,useCallback/useMemo 解决的都是一个问题,即减少无意义的更新。因此,滥用 useCallback/useMemo 并不能直接提升应用的性能,反而可能会因为过多无意义的闭包拖累性能。如何使用 useCallback/useMemo 维护出一个清晰可控的数据流才是性能优化的着眼点。

存在的问题:组件数据流的复杂程度直线上升

What’s React Forget? React without memo | by 南小北 | Medium

如何解决——如何组织数据流

范式一:useState + useEffect

范式二:useReducer

范式三:三方状态管理库(本质上和 useReducer 是一样的)

如何保证团队的编码规范

总结——业务开发的架构思维

所有的编码规范,最终的目标都是维护一套可复用、可维护、可拓展的业务架构

Built with Hugo
主题 StackJimmy 设计