diff --git a/.vitepress/ch.mts b/.vitepress/ch.mts new file mode 100644 index 00000000..957d472d --- /dev/null +++ b/.vitepress/ch.mts @@ -0,0 +1,208 @@ +import { type DefaultTheme, defineConfig } from "vitepress"; + +export const ch = defineConfig({ + lang: "ch", + title: "Frontend Fundamentals", + description: "易于修改的前端代码指南", + lastUpdated: true, + themeConfig: { + logo: "/images/ff-symbol.svg", + nav: nav(), + + editLink: { + pattern: "https://github.com/toss/frontend-fundamentals/edit/main/:path", + text: "在GitHub编辑此页" + }, + + outline: { + label: "页面内容" + }, + docFooter: { + prev: "上一页", + next: "下一页" + }, + lastUpdated: { + text: "最后更新" + }, + + sidebar: sidebar() + } +}); + +function nav(): DefaultTheme.NavItem[] { + return [{ text: "首页", link: "/ch" }]; +} + +function sidebar(): DefaultTheme.Sidebar { + return [ + { + text: "好代码的标准", + items: [ + { + text: "开始使用", + link: "/ch/code/start" + }, + { + text: "易于修改的代码", + link: "/ch/code/" + }, + { + text: "社区", + items: [ + { + text: "介绍", + link: "/ch/code/community" + }, + { + text: "⭐ 专题讨论", + link: "https://github.com/toss/frontend-fundamentals/discussions?discussions_q=is%3Aopen+label%3A%22%EC%84%B1%EC%A7%80+%E2%9B%B2%22" + }, + { + text: "A vs B", + link: "https://github.com/toss/frontend-fundamentals/discussions/categories/a-vs-b?discussions_q=is%3Aopen+category%3A%22A+vs+B%22+sort%3Adate_created" + }, + { + text: "公开论坛", + link: "https://github.com/toss/frontend-fundamentals/discussions/categories/open-forum?discussions_q=is%3Aopen+sort%3Adate_created+category%3A%22Open+Forum%22" + } + ], + collapsed: true + } + ] + }, + { + text: "编写好代码的策略", + items: [ + { + text: "1. 可读性", + items: [ + { + text: "减少语境", + items: [ + { + text: "A. 分离不一起运行的代码", + link: "/ch/code/examples/submit-button" + }, + { + text: "B. 抽象实现细节", + link: "/ch/code/examples/login-start-page" + }, + { + text: "C. 根据逻辑类型拆分合并的函数", + link: "/ch/code/examples/use-page-state-readability" + } + ], + collapsed: true + }, + { + text: "命名", + items: [ + { + text: "A. 为复杂条件命名", + link: "/ch/code/examples/condition-name" + }, + { + text: "B. 为魔数命名", + link: "/ch/code/examples/magic-number-readability" + } + ], + collapsed: true + }, + { + text: "使其从上到下顺利阅读", + items: [ + { + text: "A. 减少视点转移", + link: "/ch/code/examples/user-policy" + }, + { + text: "B. 简化三元运算符", + link: "/ch/code/examples/ternary-operator" + } + ], + collapsed: true + } + ] + }, + + { + text: "2. 可预测性", + items: [ + { + text: "A. 避免命名重复", + link: "/ch/code/examples/http" + }, + { + text: "B. 统一同类函数的返回类型", + link: "/ch/code/examples/use-user" + }, + { + text: "C. 揭示隐藏的逻辑", + link: "/ch/code/examples/hidden-logic" + } + ] + }, + { + text: "3. 内聚性", + items: [ + { + text: "A. 需同时修改的文件位于同一目录下", + link: "/ch/code/examples/code-directory" + }, + { + text: "B. 消除魔数", + link: "/ch/code/examples/magic-number-cohesion" + }, + { + text: "C. 考虑表单的内聚性", + link: "/ch/code/examples/form-fields" + } + ] + }, + { + text: "4. 耦合性", + items: [ + { + text: "A. 单独管理责任", + link: "/ch/code/examples/use-page-state-coupling" + }, + { + text: "B. 允许重复代码", + link: "/ch/code/examples/use-bottom-sheet" + }, + { + text: "C. 消除 Props Drilling", + link: "/ch/code/examples/item-edit-modal" + } + ] + } + ] + } + ]; +} + +export const search: DefaultTheme.LocalSearchOptions["locales"] = { + ch: { + translations: { + button: { + buttonText: "搜索", + buttonAriaLabel: "搜索" + }, + modal: { + backButtonTitle: "返回", + displayDetails: "更多", + footer: { + closeKeyAriaLabel: "关闭", + closeText: "关闭", + navigateDownKeyAriaLabel: "向下", + navigateText: "移动", + navigateUpKeyAriaLabel: "向上", + selectKeyAriaLabel: "选择", + selectText: "选择" + }, + noResultsText: "没有搜索结果。", + resetButtonTitle: "全部清除" + } + } + } +}; diff --git a/.vitepress/config.mts b/.vitepress/config.mts index 9bf528aa..471f3ac0 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -6,6 +6,7 @@ import { shared } from "./shared.mts"; import { en } from "./en.mts"; import { ko } from "./ko.mts"; import { ja } from "./ja.mts"; +import { ch } from "./ch.mjs"; const require = createRequire(import.meta.url); @@ -14,6 +15,7 @@ export default defineConfig({ locales: { en: { label: "English", ...en }, ja: { label: "日本語", ...ja }, + ch: { label: "简体中文", ...ch }, root: { label: "한국어", ...ko } }, vite: { diff --git a/.vitepress/shared.mts b/.vitepress/shared.mts index 7e7ed815..1cc66892 100644 --- a/.vitepress/shared.mts +++ b/.vitepress/shared.mts @@ -1,6 +1,7 @@ import { defineConfig, HeadConfig } from "vitepress"; import { search as koSearch } from "./ko.mts"; import { search as jaSearch } from "./ja.mts"; +import { search as chSearch } from "./ch.mts"; export const shared = defineConfig({ lastUpdated: true, @@ -70,7 +71,8 @@ export const shared = defineConfig({ options: { locales: { ...koSearch, - ...jaSearch + ...jaSearch, + ...chSearch } } }, diff --git a/ch/code/community.md b/ch/code/community.md new file mode 100644 index 00000000..85c1113c --- /dev/null +++ b/ch/code/community.md @@ -0,0 +1,32 @@ +--- +comments: false +--- + +# 社区 + +`Frontend Fundamentals`(FF)与社区一起制定好代码的标准。 + +目前由 Toss 前端分部维护。 + +## 专题讨论 + +查看社区中的精彩讨论。超出《Frontend Fundamentals》 文档中的内容,拓宽你对好代码的思考。 + +- [专题讨论](https://github.com/toss/frontend-fundamentals/discussions?discussions_q=is%3Aopen+label%3A%22%EC%84%B1%EC%A7%80+%E2%9B%B2%22) + +## 探讨代码疑虑 + +如果你有令人疑惑的代码,可以将其发布到 GitHub 论坛上。 +可以在社区中多角度审查你的代码,并与社区一起探讨好代码的标准。 + +受到广泛共鸣的案例可直接上传到 Frontend Fundamentals 文档中。贡献方法将会稍后公布。 + +- [在 GitHub 论坛上发帖](https://github.com/toss/frontend-fundamentals/discussions) + +## 为好代码标准添加意见 + +如果对好代码的标准有意见,或者想提出新的观点,可以参与投票选出你认为更好的代码,并留下自己的意见。与社区沟通,共同构建更加丰富而深入的标准。 + +这可以成为一个契机,帮助你确立判断两段代码之间哪一段更好的标准。 + +- [查看 A vs B 上的代码](https://github.com/toss/frontend-fundamentals/discussions/categories/a-vs-b) diff --git a/ch/code/examples/code-directory.md b/ch/code/examples/code-directory.md new file mode 100644 index 00000000..a3f5d53c --- /dev/null +++ b/ch/code/examples/code-directory.md @@ -0,0 +1,77 @@ +# 需同时修改的文件位于同一目录下 + +
+ +
+ +在项目中编写代码时,通常会将 Hook、组件和工具函数等拆分到多个文件进行管理。为了更轻松地创建、查找和删除这些文件,设计一个合适的目录结构至关重要。 + +将需要一起修改的源文件放在同一目录下,可以更直观地展现代码的依赖关系。这不仅可以防止随意引用不应被引用的文件,还能一次性删除相关文件。 + +## 📝 代码示例 + +以下代码是按照模块类型(如 Presentational 组件、Container 组件、Hook、常量等)分类的目录结构。 + +```text +└─ src + ├─ components + ├─ constants + ├─ containers + ├─ contexts + ├─ remotes + ├─ hooks + ├─ utils + └─ ... +``` + +## 👃 闻代码 + +### 内聚性 + +如果按照这种方式按类划分文件,就很难确定代码之间的引用关系。代码文件之间的依赖关系需要开发者在分析代码时自行掌握和处理。 +此外,如果某个组件、Hook 或工具函数不再使用而被删除,相关代码可能未被同时删除,从而留下未使用代码。 + +项目的规模往往会逐步扩大,随着项目规模的增加(比如 2 倍、10 倍甚至 100 倍),代码之间的依赖关系也将变得更加复杂。一个目录可能包含 100 多个文件。 + +## ✏️ 尝试改善 + +以下是一个实例,展示了如何通过将需要一起修改的代码文件放在同一目录下,来优化项目结构。 + +```text +└─ src + │ // 整个项目中使用的代码 + ├─ components + ├─ containers + ├─ hooks + ├─ utils + ├─ ... + │ + └─ domains + │ // 只在 Domain1 中使用的代码 + ├─ Domain1 + │ ├─ components + │ ├─ containers + │ ├─ hooks + │ ├─ utils + │ └─ ... + │ + │ // 只在 Domain2 中使用的代码 + └─ Domain2 + ├─ components + ├─ containers + ├─ hooks + ├─ utils + └─ ... +``` + +将一起修改的代码文件放在同一目录下,很容易理解代码之间的依赖关系。 + +例如,假设一个域(`Domain1`)的子代码中引用另一个域(`Domain2`)的源代码,如下所示: + +```typescript +import { useFoo } '../../../Domain2/hooks/useFoo' +``` + +如果遇到这样的 import 语句,就能很容易地意识到引用了错误的文件。 + +此外,当删除与特定功能相关的代码时,只需删除整个目录即可一并清理所有相关代码,从而确保项目中不会遗留未使用的代码。 diff --git a/ch/code/examples/condition-name.md b/ch/code/examples/condition-name.md new file mode 100644 index 00000000..b31ead6a --- /dev/null +++ b/ch/code/examples/condition-name.md @@ -0,0 +1,68 @@ +# 为复杂条件命名 + +
+ +
+ +如果复杂的条件表达式没有特定的命名,就很难一眼理解其含义。 + +## 📝 代码示例 + +下列代码实现了筛选类别和价格范围相匹配的商品的逻辑。 + +```typescript +const result = products.filter((product) => + product.categories.some( + (category) => + category.id === targetCategory.id && + product.prices.some((price) => price >= minPrice && price <= maxPrice) + ) +); +``` + +## 👃 闻代码 + +### 可读性 + +在这段代码中,匿名函数和条件错综复杂。`filter`、`some`和`&&`等逻辑多层嵌套,导致很难确定正确的条件。 + +代码阅读者需要考虑的上下文过多,导致可读性变差。[^1] + +[^1]: [程序员超强大脑](https://product.dangdang.com/29567786.html)一书中提到,人的大脑一次性能够处理和存储的信息大约是 6 个。 + +## ✏️ 尝试改善 + +以下代码展示了如何给条件加上明确的名称,以减少代码阅读者需要考虑的语境。 + +```typescript +const matchedProducts = products.filter((product) => { + return product.categories.some((category) => { + const isSameCategory = category.id === targetCategory.id; + const isPriceInRange = product.prices.some( + (price) => price >= minPrice && price <= maxPrice + ); + + return isSameCategory && isPriceInRange; + }); +}); +``` + +通过明确命名筛选同类且价格范围的商品条件,可以避免追踪复杂的条件表达式,清晰表达代码的意图。 + +## 🔍 深入了解:为条件式命名的标准 + +什么时候适合给条件表达式或函数命名并将其提取? + +### 适合为条件命名的情况 + +- **处理复杂逻辑时**:当条件语句或函数中的复杂逻辑跨越多行时,最好为其命名,明确展示函数的作用。这样可以提高代码可读性,维护和审查变得更加容易。 + +- **需要重用时**:如果同一逻辑可能在多个地方反复使用,可以通过声明变量或函数来实现重用。这样可以减少代码重复,便于后续的维护。 + +- **需要单元测试时**:分离函数后,可以独立编写单元测试。单元测试可以轻松验证函数是否正常工作,尤其在测试复杂逻辑时非常实用。 + +### 不需要为条件命名的情况 + +- **当逻辑简单时**:如果逻辑非常简单,实际上不需要为其命名。例如,将数组中的元素翻倍的代码 `arr.map(x => x * 2)` ,即使不命名,也很直观。 + +- **当只使用一次时**:如果某个逻辑在代码中只出现一次,而且逻辑并不复杂,那么在匿名函数中直接处理逻辑可能更加直观。 diff --git a/ch/code/examples/error-boundary.md b/ch/code/examples/error-boundary.md new file mode 100644 index 00000000..e69de29b diff --git a/ch/code/examples/form-fields.md b/ch/code/examples/form-fields.md new file mode 100644 index 00000000..0f2276cc --- /dev/null +++ b/ch/code/examples/form-fields.md @@ -0,0 +1,156 @@ +# 考虑表单的内聚性 + +
+ +
+ +在前端开发中,经常需要用 Form 来获取用户输入的值。 +在管理 Form 时,可以通过两种方式来管理其内聚性,确保相关代码能同步修改。 + +## 字段级别的内聚性 + +字段级别的内聚是一种独立管理单个输入元素的方式。 +每个字段都拥有独立的验证逻辑,因此需要修改的范围会缩小,使得特定字段的维护变得更加容易。 +如果考虑字段级别的内聚性进行代码设计,则每个字段的验证逻辑相互独立,互不影响。 + +```tsx +import { useForm } from "react-hook-form"; + +export function Form() { + const { + register, + formState: { errors }, + handleSubmit + } = useForm({ + defaultValues: { + name: "", + email: "" + } + }); + + const onSubmit = handleSubmit((formData) => { + // 表单数据提交逻辑 + console.log("Form submitted:", formData); + }); + + return ( +
+
+ + isEmptyStringOrNil(value) ? "请输入您的姓名。" : "" + })} + placeholder="姓名" + /> + {errors.name &&

{errors.name.message}

} +
+ +
+ { + if (isEmptyStringOrNil(value)) { + return "请输入您的电子邮件。"; + } + + if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) { + return "请输入有效的电子邮件。"; + } + + return ""; + } + })} + placeholder="电子邮件" + /> + {errors.email &&

{errors.email.message}

} +
+ + +
+ ); +} + +function isNil(value: unknown): value is null | undefined { + return value == null; +} + +type NullableString = string | null | undefined; + +function isEmptyStringOrNil(value: NullableString): boolean { + return isNil(value) || value.trim() === ""; +} +``` + +## 表单级别的内聚性 + +表单级别的内聚是一种所有字段的验证逻辑依赖于整个表单的方式。它是根据整个表单的流程来设计的,通常在表单级别发生更改时加以考虑。 + +提高表单的整体内聚性后,所有验证将统一管理,逻辑更加简单明了,执行集中式状态管理,更容易理解表单的整体流程。然而,字段之间的耦合性会提高,从而降低表单的可重用性。 + +```tsx +import * as z from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +const schema = z.object({ + name: z.string().min(1, "请输入您的姓名。"), + email: z + .string() + .min(1, "请输入您的电子邮件。") + .email("请输入有效的电子邮件。") +}); + +export function Form() { + const { + register, + formState: { errors }, + handleSubmit + } = useForm({ + defaultValues: { + name: "", + email: "" + }, + resolver: zodResolver(schema) + }); + + const onSubmit = handleSubmit((formData) => { + // 表单数据提交逻辑 + console.log("Form submitted:", formData); + }); + + return ( +
+
+ + {errors.name &&

{errors.name.message}

} +
+ +
+ + {errors.email &&

{errors.email.message}

} +
+ + +
+ ); +} +``` + +## 字段级别 vs. 表单级别 内聚性 + +欲提高内聚性,需要在字段级别和表单级别选择适合场景下的方式。 +按字段级别进行划分,可提高可重用性和独立性;但如果以整个表单单位进行管理,则可以保持一致性。 + +我们需要根据变更的单位时字段级别还是整个表单级别来调整设计。 + +### 适合选择字段级别内聚性的情况 + +- **需要独立验证时**:这是指对每个字段进行复杂的验证逻辑,或者需要异步验证的情况。当每个字段需要独立且独特的验证时,例如电子邮件格式检查、电话号码有效性验证、ID 重复确认、推荐码有效验证等,这种方法会十分有用。 +- **需要考虑可重用性时**:这是指字段和验证逻辑可以在其他表单中同一使用的情况。若需独立管理公共输入字段,此方案颇为适宜。 + +### 适合选择表单级别内聚性的情况 + +- **表示单一功能时**:这是所有字段都紧密相关,共同构成一个完整功能的情况。当所有字段共同构成一个业务逻辑时(如:支付信息、配送信息等),此方案颇为适宜。 +- **需要逐步输入时**:比如像 Wizard Form(漏斗表单)一样,分步骤操作的复杂表单的情况。它适用于像会员注册或问卷调查那样,前一阶段的输入值会影响下一阶段的情况。 +- **字段之间存在依赖关系时**:适用于多个字段相互引用或相互影响的情况。需要字段间交互时,例如密码确认或总额计算的情况下实用。 diff --git a/ch/code/examples/hidden-logic.md b/ch/code/examples/hidden-logic.md new file mode 100644 index 00000000..c010945e --- /dev/null +++ b/ch/code/examples/hidden-logic.md @@ -0,0 +1,56 @@ +# 揭示隐藏的逻辑 + +
+ +
+ +如果函数或组件的名称、参数、返回值中存在未明确表达的隐藏逻辑,那么与你合作的同事可能会难以预测其行为。 + +## 📝 代码示例 + +下面的代码是一个名为 `fetchBalance` 的函数,用于查询用户的账户余额。每次调用函数时,都会隐式地启动名为 `balance_fetched` 的日志函数。 + +```typescript 4 +async function fetchBalance(): Promise { + const balance = await http.get("..."); + + logging.log("balance_fetched"); + + return balance; +} +``` + +## 👃 闻代码 + +### 可预测性 + +仅根据 `fetchBalance` 函数的名称和返回类型,无法得知是否会记录名为 `balance_fetched` 的日志。因此,即使在不需要日志记录的地方,也可能会触发日志记录。 + +另外,如果日志记录逻辑出错,获取账户余额的功能也可能突然失效。 + +## ✏️ 尝试改善 + +请仅在实现部分保留可以通过函数名、参数和返回类型来预测的逻辑。 + +```typescript +async function fetchBalance(): Promise { + const balance = await http.get("..."); + + return balance; +} +``` + +请将日志记录的代码单独分离出来。 + +```tsx + +``` diff --git a/ch/code/examples/http.md b/ch/code/examples/http.md new file mode 100644 index 00000000..cc8e0214 --- /dev/null +++ b/ch/code/examples/http.md @@ -0,0 +1,86 @@ +# 避免命名重复 + +
+ +
+ +具有相同名称的函数或变量应该具有相同的行为。微小的行为差异会降低代码的可预测性,并可能使代码阅读者感到困惑。 + +## 📝 代码示例 + +在某个前端服务中,通过封装原本使用的 HTTP 库,创建了一个以新形式发送 HTTP 请求的模块。 +巧合的是,原本的 HTTP 库和新创建的 HTTP 模块名称相同,都叫 `http` 。 + +::: code-group + +```typescript [http.ts] +// 该服务使用 `http` 库。 +import { http as httpLibrary } from "@some-library/http"; + +export const http = { + async get(url: string) { + const token = await fetchToken(); + + return httpLibrary.get(url); + } +}; +``` + +```typescript [fetchUser.ts] +// 从 http.ts 文件中导入 http 的代码 +import { http } from "./http"; + +export async function fetchUser() { + return http.get("..."); +} +``` + +::: + +## 👃 闻代码 + +### 可预测性 + +这段代码在功能上没有问题,但可能会让代码阅读者感到困惑。调用 `http.get` 的开发者可能会预期这个函数像原始的 HTTP 库一样只是发送一个简单的 GET 请求,但实际上会执行额外的操作,如获取令牌。 + +由于误解,预期行为与实际行为之间会出现差异,从而引发错误,或者使调试过程变得复杂和混乱。 + +## ✏️ 尝试改善 + +为了提高函数行为的可预测性,在服务中自定义函数时,应该使用与库函数明显区分开来的、具有描述性的名称。 + +::: code-group + +```typescript [httpService.ts] +// 该服务正在使用 `http` 库。 +import { http as httpLibrary } from "@some-library/http"; + +// 将库函数的名称与自定义函数区分开来。 +export const httpService = { + async getWithAuth(url: string) { + const token = await fetchToken(); + + // 添加认证逻辑,例如在请求头中添加令牌。 + return httpLibrary.get(url, { + headers: { Authorization: `Bearer ${token}` } + }); + } +}; +``` + +```typescript [fetchUser.ts] +// 从 http.ts 文件中引入定义的 http 模块 +import { httpService } from "./httpService"; + +export async function fetchUser() { + // 通过函数名,可知该函数发送的是已通过认证的请求。 + return await httpService.getWithAuth("..."); +} +``` + +::: + +这种方式可以减少在看到函数名时对其功能产生误解的可能性。 +当其他开发者使用该函数时,他们能够意识到这是服务中定义的函数,并能够正确的使用它。 + +另外,通过 `getWithAuth` 这个名称,可以明确传达该函数是用来发送通过认证的请求。 diff --git a/ch/code/examples/item-edit-modal.md b/ch/code/examples/item-edit-modal.md new file mode 100644 index 00000000..3a734a15 --- /dev/null +++ b/ch/code/examples/item-edit-modal.md @@ -0,0 +1,108 @@ +# 消除 Props Drilling + +
+ +
+ +Props Drilling(属性钻探)是父组件与子组件之间产生耦合的一个明显迹象。如果正在被钻取的属性发生了变化,那么所有引用该属性的组件都需要更新。 + +## 📝 代码示例 + +以下代码是用户选择 `item` 时使用的 `` 组件。 +用户输入关键词来搜索项目列表,当找到目标项目并选择时, 会调用 `onConfirm` 函数。 + +用户输入的关键词通过 `keyword` 传递,可供选择的项目通过 `items` 传递,推荐项目列表通过 `recommendedItems` 属性传递。 + +```tsx 2,9-10,12-13,39-42 +function ItemEditModal({ open, items, recommendedItems, onConfirm, onClose }) { + const [keyword, setKeyword] = useState(""); + + // 其他 ItemEditModal 逻辑 ... + + return ( + + + {/* ... 其他 ItemEditModal 组件 ... */} + + ); +} + +function ItemEditBody({ + keyword, + onKeywordChange, + items, + recommendedItems, + onConfirm, + onClose +}) { + return ( + <> +
+ onKeywordChange(e.target.value)} + /> + +
+ + + ); +} + +// ... +``` + +## 👃 闻代码 + +### 耦合性 + +该组件与其父组件 `ItemEditModal` 以及子组件 `ItemEditBody` 、 `ItemEditList` 等共享了相同的属性,如`recommendedItems` 、 `onConfirm` 、 `keyword` 等。 +正在发生父组件直接将属性传递给子组件的 [Props Drilling](https://kentcdodds.com/blog/prop-drilling) 情况。 + +Props Drilling 会导致不必要的属性被传递给多个组件。 +但是,如果属性发生变化,那么引用该属性的所有组件都必须进行修改。 + +例如,如果不再需要推荐功能,从而需要删除 `recommendedItems` 属性,那么必须在所有相关的组件中进行删除。 +这样一来,代码的修改范围变得过于广泛,耦合性很高。 + +## ✏️ 尝试改善 + +需要消除父组件直接向子组件传递属性的 Props Drilling 现象。可以采用组合(Composition) 模式来实现这一点。 + +```tsx +function ItemEditModal({ open, items, recommendedItems, onConfirm, onClose }) { + const [keyword, setKeyword] = useState(""); + + return ( + +
+ onKeywordChange(e.target.value)} + /> + +
+ +
+ ); +} +``` + +组合(Composition)模式不仅可以减少传递 Props 的问题,还能消除不必要的中间抽象,帮助开发者明确理解组件的角色和意图。 diff --git a/ch/code/examples/login-start-page.md b/ch/code/examples/login-start-page.md new file mode 100644 index 00000000..533989e4 --- /dev/null +++ b/ch/code/examples/login-start-page.md @@ -0,0 +1,233 @@ +# 抽象实现细节 + +
+ +
+ +一个人在阅读代码时,能够同时考虑的上下文总数有限。 +为了让阅读者轻松理解你的代码,可以将不必要的语境进行抽象化处理。 + +## 📝 代码示例 1: LoginStartPage + +以下 `` 组件包含检查用户是否已登录的逻辑,如果已登录,则会将用户重定向到主页。 + +```tsx +function LoginStartPage() { + useCheckLogin({ + onChecked: (status) => { + if (status === "LOGGED_IN") { + location.href = "/home"; + } + } + }); + + /* ... 登录相关逻辑 ... */ + + return <>{/* ... 登录相关组件 ... */}; +} +``` + +### 👃 闻代码 + +#### 可读性 + +在示例代码中,检查用户是否已登录以及将用户重定向到主页的逻辑没有被抽象化,而是直接显露出来。所以,你需要读懂像 `useCheckLogin` 、 `onChecked` 、 `status` 以及 `"LOGGED_IN"` 这样的变量和值,才能明白这段代码的作用。 + +除了这段代码,下面还紧接着有与实际登录相关的代码。阅读者为了理解 `LoginStartPage` 的作用,需要一次性理解很多上下文信息。 + +### ✏️ 尝试改善 + +通过将检查用户是否登录并进行导航的逻辑提取到 **HOC(高阶组件)** 或 Wrapper 组件中,可以减少代码阅读者一次性需要理解的上下文。 +从而可以提高代码的可读性。 + +此外,通过阻止不同组件中的逻辑相互引用,可以避免代码之间产生不必要地依赖关系,防止代码变得过于复杂。 + +#### 选项 A: 使用 Wrapper 组件 + +```tsx +function App() { + return ( + + + + ); +} + +function AuthGuard({ children }) { + const status = useCheckLoginStatus(); + + useEffect(() => { + if (status === "LOGGED_IN") { + location.href = "/home"; + } + }, [status]); + + return status !== "LOGGED_IN" ? children : null; +} + +function LoginStartPage() { + /* ... 登录相关逻辑 ... */ + + return <>{/* ... 登录相关组件 ... */}; +} +``` + +#### 选项 B: 使用 HOC(高阶组件) + +```tsx +function LoginStartPage() { + /* ... 登录相关逻辑 ... */ + + return <>{/* ... 登录相关组件 ... */}; +} + +export default withAuthGuard(LoginStartPage); + +// 高阶组件的定义 +function withAuthGuard(WrappedComponent) { + return function AuthGuard(props) { + const status = useCheckLoginStatus(); + + useEffect(() => { + if (status === "LOGGED_IN") { + location.href = "/home"; + } + }, [status]); + + return status !== "LOGGED_IN" ? : null; + }; +} +``` + +## 📝 代码示例 2: FriendInvitation + +以下 `` 是一个被点击时,会向用户征求同意,并给用户发送邀请的页面组件。 + +```tsx 6-27,33 +function FriendInvitation() { + const { data } = useQuery(/* 省略.. */); + + // 该组件需要的状态管理、事件处理程序及异步操作等逻辑... + + const handleClick = async () => { + const canInvite = await overlay.openAsync(({ isOpen, close }) => ( + close(false)}> + 关闭 + + } + confirmButton={ + close(true)}> + 确认 + + } + /* 中略 */ + /> + )); + + if (canInvite) { + await sendPush(); + } + }; + + // 该组件需要的状态管理、事件处理程序及异步操作等逻辑... + + return ( + <> + + {/* 用于用户界面的 JSX 标记... */} + + ); +} +``` + +### 👃 闻代码 + +#### 可读性 + +为了保持可读性,代码同时承载的上下文越少越好。如果一个组件包含太多的语境,就很难一眼看出该组件的作用。 + +`` 这一组件内实际上包含了向用户获取同意等详细的逻辑。所以在阅读代码时,需要追踪过多的语境,导致阅读难度高。 + +#### 内聚性 + +获取用户同意的逻辑代码与实际执行该逻辑的 ` + ); +} +``` + +`` 组件仅包含邀请用户的逻辑和 UI, 因此能够减少需要一次性认知的信息量,提高可读性。而且,按钮与点击后执行的逻辑紧密相连。 + +## 🔍 深入了解: 抽象化 + +在 Toss 的技术博客 [编写声明式代码](https://toss.tech/article/frontend-declarative-code) 一文中,将代码比作了文章。 + +### 文章中的抽象化 + +有一句话是“向左走十步”。 在这句话里 + +- ”向左“是”面向北方时旋转 90 度的位置“的抽象概念, +- ”90 度“是”将一个完整的旋转(360 度)等分后,取其中 90 份,即相对于初始线逆时针方向旋转的角度“的抽象概念, +- ”时针方向“的定义是”北半球上日晷的指针转动的方向“的抽象概念。 + +与此类似,”十步“、”走“这样的词汇也可以更具体的表达。因此,不进行抽象化而直接表述的句子可能会是这样: + +> 面朝北方,以 1 次旋转的角度 360 等分后乘于 90 倍的度数,在北半球中沿日晷指针转动的方向转动,然后重复将身体从一点移动到另一点的动作(比动物在陆地上用腿移动的最快方式还慢的行为)10 次。 + +直接阅读该文章时很难准确理解其真正意图。 + +### 代码中的抽象化 + +同样地,编程时如果显露过多实现细节,就很难把握代码的真正用途。 +为了让代码阅读时能同时兼顾六到七个不同语境,需要将这些语境抽象为更小的单位。 diff --git a/ch/code/examples/magic-number-cohesion.md b/ch/code/examples/magic-number-cohesion.md new file mode 100644 index 00000000..3c21dde3 --- /dev/null +++ b/ch/code/examples/magic-number-cohesion.md @@ -0,0 +1,49 @@ +# 消除魔数 + +
+ +
+ +**魔数**(Magic Number)指的是缺乏明确说明而直接插入的数值。 + +例如,直接使用 `404` 来表示未找到(Not Found)的 HTTP 状态码,或者直接使用 `86400` 秒来表示一天的时间。 + +## 📝 代码示例 + +下列代码是一个函数,当点击点赞按钮时重新获取点赞数量。 + +```typescript 3 +async function onLikeClick() { + await postLike(url); + await delay(300); + await refetchPostLike(); +} +``` + +## 👃 闻代码 + +### 内聚性 + +如果使用像 `300` 这样的固定时间值来等待动画完成,那么在动画播放的过程中进行更改时,服务可能会悄无声息地出现故障。因为后续的逻辑可能会在动画还未完成时就开始执行。 + +此外,由于只修改了需要同步更改的代码中的一部分,这段代码的内聚性很低。 + +::: info + +这个 Hook 也可以从 [可读性](./magic-number-readability.md) 的角度来考虑。 + +::: + +## ✏️ 尝试改善 + +为了更准确的表达数字 `300` 的含义,可以将其声明为常量 `ANIMATION_DELAY_MS` 。 + +```typescript 1,5 +const ANIMATION_DELAY_MS = 300; + +async function onLikeClick() { + await postLike(url); + await delay(ANIMATION_DELAY_MS); + await refetchPostLike(); +} +``` diff --git a/ch/code/examples/magic-number-readability.md b/ch/code/examples/magic-number-readability.md new file mode 100644 index 00000000..85cabece --- /dev/null +++ b/ch/code/examples/magic-number-readability.md @@ -0,0 +1,58 @@ +# 为魔数命名 + +
+ +
+ +**魔数**(Magic Number)指的是缺乏明确说明而直接插入的数值。 + +例如,直接使用 `404` 来表示未找到(Not Found)的 HTTP 状态码,或者直接使用 `86400` 秒来表示一天的时间。 + +## 📝 代码示例 + +下列代码是一个函数,当点击点赞按钮时重新获取点赞数量。 + +```typescript 3 +async function onLikeClick() { + await postLike(url); + await delay(300); + await refetchPostLike(); +} +``` + +## 👃 闻代码 + +### 可读性 + +这段代码中的 `delay` 函数传递了一个值 `300` ,但无法从上下文推测该值的具体用途。 +如果不是该代码的编写者,就无法理解 300ms 等待的是什么。 + +- 是在等待动画完成? +- 是在等待点赞反映时间? +- 是不是忘了删测试代码? + +当多名开发者共同修改同一段代码时,可能无法明确原意,从而导致代码被修改成不符合预期的结果。 + +::: info + +这个 Hook 也可以从 [内聚性](./magic-number-cohesion.md) 的角度来考虑。 + +::: + +## ✏️ 尝试改善 + +为了更准确的表达数字 `300` 的含义,可以将其声明为常量 `ANIMATION_DELAY_MS` 。 + +```typescript 1,5 +const ANIMATION_DELAY_MS = 300; + +async function onLikeClick() { + await postLike(url); + await delay(ANIMATION_DELAY_MS); + await refetchPostLike(); +} +``` + +## 🔍 深入了解 + +魔数也可以从内聚性角度来审视。请参考 [消除魔数提高内聚性](./magic-number-cohesion.md) 一文。 diff --git a/ch/code/examples/submit-button.md b/ch/code/examples/submit-button.md new file mode 100644 index 00000000..02ad2c20 --- /dev/null +++ b/ch/code/examples/submit-button.md @@ -0,0 +1,74 @@ +# 分离不一起运行的代码 + +
+ +
+ +如果不同时运行的代码被放在同一个函数或组件中,就很难一眼看清他们各自的作用。 +实现过程中内含复杂的分支,很难理解代码各个部分的作用。 + +## 📝 代码示例 + +`` 组件会根据用户的权限以不同的方式运行。 + +- 如果用户的权限是仅查看(`"viewer"`),邀请按钮会处于非激活状态,不会播放动画。 +- 如果用户是普通用户,邀请按钮处于激活状态,并且播放动画。 + +```tsx +function SubmitButton() { + const isViewer = useRole() === "viewer"; + + useEffect(() => { + if (isViewer) { + return; + } + showButtonAnimation(); + }, [isViewer]); + + return isViewer ? ( + Submit + ) : ( + + ); +} +``` + +## 👃 闻代码 + +### 可读性 + +`` 组件同时处理用户可能具有的两种权限状态,且该两种状态都在同一组件中进行处理。 +所以代码阅读者需要考虑的语境过多。 + +例如,在下面的代码中,蓝色部分表示当用户具有仅查看权限(`'viewer'`)时运行的代码,红色部分表示当用户是普通用户时运行的代码。 +由于不同时运行的代码交织在一起,理解代码时产生负担。 + +![](../../../images/examples/submit-button.png){.light-only} +![](../../../images/examples/submit-button-dark.png){.dark-only} + +## ✏️ 尝试改善 + +以下代码是将用户具有仅查看权限时和作为普通用户时的状态完全分开来管理的代码示例。 + +```tsx +function SubmitButton() { + const isViewer = useRole() === "viewer"; + + return isViewer ? : ; +} + +function ViewerSubmitButton() { + return Submit; +} + +function AdminSubmitButton() { + useEffect(() => { + showAnimation(); + }, []); + + return ; +} +``` + +- 随着原本分散在 `` 代码各处的分支合并为一,分支数量减少。 +- `` 和 `` 各自仅管理一个分支,所以代码阅读者需要考虑的语境减少。 diff --git a/ch/code/examples/ternary-operator.md b/ch/code/examples/ternary-operator.md new file mode 100644 index 00000000..7d119869 --- /dev/null +++ b/ch/code/examples/ternary-operator.md @@ -0,0 +1,35 @@ +# 简化三元运算符 + +
+ +
+ +复杂地使用三元运算符可能会掩盖条件的结构,从而使代码难以阅读。 + +## 📝 代码示例 + +以下代码根据 `条件A` 和 `条件B`,将 `status` 设置为 `"BOTH"`、 `"A"` 、 `"B"` 或 `"NONE"` 中的一个。 + +```typescript +const status = + 条件A && 条件B ? "BOTH" : 条件A || 条件B ? (条件A ? "A" : "B") : "NONE"; +``` + +## 👃 闻代码 + +### 可读性 + +这段代码使用了多个嵌套的三元运算符,很难一眼看出计算值使用了哪个条件。 + +## ✏️ 尝试改善 + +如下使用 `if` 语句展开条件,可以简单明了地显示条件。 + +```typescript +const status = (() => { + if (条件A && 条件B) return "BOTH"; + if (条件A) return "A"; + if (条件B) return "B"; + return "NONE"; +})(); +``` diff --git a/ch/code/examples/use-bottom-sheet.md b/ch/code/examples/use-bottom-sheet.md new file mode 100644 index 00000000..c3b8ea86 --- /dev/null +++ b/ch/code/examples/use-bottom-sheet.md @@ -0,0 +1,57 @@ +# 允许重复代码 + +
+ +
+ +作为开发者,我们经常会把跨多个页面或组件的重复代码整合于一个 Hook 或组件中来实现代码的共用。 +通过将重复代码整合到一个组件或 Hook 中,使得代码具有高内聚性(好代码的特征之一),从而能够同时修改那些需要一起修改的代码。 + +然而,这也可能引发不必要地耦合性,导致在修改公共组件或 Hook 时,受影响的代码范围扩大,修改代码反而变得更加困难。 + +起初因功能相似而合并的公共代码,后来可能会根据各页面的特殊需求而逐渐变得复杂起来。 +而且,每次修改公共代码时,都必须逐一测试对其依赖的代码,这反而会让代码修改更加困难。 + +## 📝 代码示例 + +下面有一个 Hook,它接受维修信息作为参数,如果系统当前处于维修中状态,就会打开底部动作条(bottom sheet)。如果用户同意接收通知,则会进行日志记录,并随后关闭当前屏幕。 + +```typescript +export const useOpenMaintenanceBottomSheet = () => { + const maintenanceBottomSheet = useMaintenanceBottomSheet(); + const logger = useLogger(); + + return async (maintainingInfo: TelecomMaintenanceInfo) => { + logger.log("打开维修底部动作条"); + const result = await maintenanceBottomSheet.open(maintainingInfo); + if (result) { + logger.log("点击维修底部动作条上的同意接收通知"); + } + closeView(); + }; +}; +``` + +这段代码因在多个页面中反复使用,被提取为一个公共 Hook。 + +## 👃 闻代码 + +### 耦合性 + +这个 Hook 之所以被通用化,是因为其包含了很多页面共同反复用到的逻辑。不过,在享受其带来的便利时,需要留意未来可能出现的各种代码变更的情况。 + +- 每个页面需要日志记录的值不同时? +- 某些页面即使在关闭维修底部动作条时也不需要关闭整个屏幕时? +- 需要在底部动作条中显示不同文本或图像时? + +上述 Hook 为了灵活对应代码变更需求,可能需要接受一些复杂的参数。 +而且,每次修改这个 Hook 时,都需要测试所有使用该 Hook 的页面,以确保动作的正常运行。 + +## ✏️ 尝试改善 + +即便重复的代码看上去有些冗余,有时候接受这种重复代码也不失为一种好方法。 + +需要积极与同事沟通,准确理解维修底部动作条的动作原理。 +如果页面上日志记录的值相同,维修底部动作条的动作一致,且其外表相同,并且预计将来也会保持现状,那么我们可以通过通用化提高代码的内聚性。 + +但是,如果每个页面的行为都可能有所不同,那么不追求通用化,而是允许代码重复,可能是更好的选择。 diff --git a/ch/code/examples/use-page-state-coupling.md b/ch/code/examples/use-page-state-coupling.md new file mode 100644 index 00000000..b25b24ac --- /dev/null +++ b/ch/code/examples/use-page-state-coupling.md @@ -0,0 +1,95 @@ +# 单独管理责任 + +
+ +
+ +不要根据逻辑类型(如查询参数、状态、API 调用等)为基准拆分函数。 由于同时涉及多种上下文类型,代码变得即难以理解又难以维护。 + +## 📝 代码示例 + +以下 `usePageState()` Hook 可以一次性管理整个页面的 URL 查询参数。 + +```typescript +import moment, { Moment } from "moment"; +import { useMemo } from "react"; +import { + ArrayParam, + DateParam, + NumberParam, + useQueryParams +} from "use-query-params"; + +const defaultDateFrom = moment().subtract(3, "month"); +const defaultDateTo = moment(); + +export function usePageState() { + const [query, setQuery] = useQueryParams({ + cardId: NumberParam, + statementId: NumberParam, + dateFrom: DateParam, + dateTo: DateParam, + statusList: ArrayParam + }); + + return useMemo( + () => ({ + values: { + cardId: query.cardId ?? undefined, + statementId: query.statementId ?? undefined, + dateFrom: + query.dateFrom == null ? defaultDateFrom : moment(query.dateFrom), + dateTo: query.dateTo == null ? defaultDateTo : moment(query.dateTo), + statusList: query.statusList as StatementStatusType[] | undefined + }, + controls: { + setCardId: (cardId: number) => setQuery({ cardId }, "replaceIn"), + setStatementId: (statementId: number) => + setQuery({ statementId }, "replaceIn"), + setDateFrom: (date?: Moment) => + setQuery({ dateFrom: date?.toDate() }, "replaceIn"), + setDateTo: (date?: Moment) => + setQuery({ dateTo: date?.toDate() }, "replaceIn"), + setStatusList: (statusList?: StatementStatusType[]) => + setQuery({ statusList }, "replaceIn") + } + }), + [query, setQuery] + ); +} +``` + +## 👃 闻代码 + +### 耦合性 + +该 Hook 肩负着“管理此页面所需的所有查询参数”的广泛责任。因此,页面内的组件或其他 Hook 可能会依赖于该 Hook,在修改代码时,影响范围也会急剧扩大。 + +随着时间的推移,这个 Hook 会变得难以维护,最终演变为难以修改的代码。 + +::: info + +这个 Hook 也可以从 [可读性](./use-page-state-readability.md) 的角度来考虑。 + +::: + +## ✏️ 尝试改善 + +可以像下列代码一样,将每个查询参数分别编写单独的 Hook。 + +```typescript +import { useQueryParam } from "use-query-params"; + +export function useCardIdQueryParam() { + const [cardId, _setCardId] = useQueryParam("cardId", NumberParam); + + const setCardId = useCallback((cardId: number) => { + _setCardId({ cardId }, "replaceIn"); + }, []); + + return [cardId ?? undefined, setCardId] as const; +} +``` + +由于分离了 Hook 所承担的责任,能够减少修改所带来的影响范围。 +因此,在修改 Hook 时, 能够防止产生预料之外的影响。 diff --git a/ch/code/examples/use-page-state-readability.md b/ch/code/examples/use-page-state-readability.md new file mode 100644 index 00000000..afbaa9aa --- /dev/null +++ b/ch/code/examples/use-page-state-readability.md @@ -0,0 +1,101 @@ +# 根据逻辑类型拆分合并的函数 + +
+ +
+ +不要根据逻辑类型(如查询参数、状态、API 调用等)为基准创建函数。 由于同时涉及多种上下文类型,代码变得即难以理解又难以维护。 + +## 📝 代码示例 + +以下 `usePageState()` Hook 可以一次性管理整个页面的 URL 查询参数。 + +```typescript +import moment, { Moment } from "moment"; +import { useMemo } from "react"; +import { + ArrayParam, + DateParam, + NumberParam, + useQueryParams +} from "use-query-params"; + +const defaultDateFrom = moment().subtract(3, "month"); +const defaultDateTo = moment(); + +export function usePageState() { + const [query, setQuery] = useQueryParams({ + cardId: NumberParam, + statementId: NumberParam, + dateFrom: DateParam, + dateTo: DateParam, + statusList: ArrayParam + }); + + return useMemo( + () => ({ + values: { + cardId: query.cardId ?? undefined, + statementId: query.statementId ?? undefined, + dateFrom: + query.dateFrom == null ? defaultDateFrom : moment(query.dateFrom), + dateTo: query.dateTo == null ? defaultDateTo : moment(query.dateTo), + statusList: query.statusList as StatementStatusType[] | undefined + }, + controls: { + setCardId: (cardId: number) => setQuery({ cardId }, "replaceIn"), + setStatementId: (statementId: number) => + setQuery({ statementId }, "replaceIn"), + setDateFrom: (date?: Moment) => + setQuery({ dateFrom: date?.toDate() }, "replaceIn"), + setDateTo: (date?: Moment) => + setQuery({ dateTo: date?.toDate() }, "replaceIn"), + setStatusList: (statusList?: StatementStatusType[]) => + setQuery({ statusList }, "replaceIn") + } + }), + [query, setQuery] + ); +} +``` + +## 👃 闻代码 + +### 可读性 + +考虑到该 Hook 的责任是“管理页面所需的所有查询参数”,这个 Hook 所承担的责任有可能会无限增大。每当有新的查询参数被加入时,这个 Hook 就会自然而然地承担起管理的责任。 + +随着 Hook 的责任范围逐渐扩大,实现部分也会变得越来越冗长,导致很难理解它到底扮演着什么角色。 + +### 性能 + +使用该 Hook 的组件会在任何查询参数变更时重新渲染。即使一个组件只引用 `cardId`,如果 `dateFrom` 或 `dateTo` 发生变化,它也会重新渲染。 + +为了提高性能,应该设计系统来确保特定状态值更新时,重新渲染的部分最小。 + +::: info + +这个 Hook 也可以从 [耦合性](./use-page-state-coupling.md) 的角度来考虑。 + +::: + +## ✏️ 尝试改善 + +可以像下列代码一样,将每个查询参数分别编写单独的 Hook。 + +```typescript +import { useQueryParam } from "use-query-params"; + +export function useCardIdQueryParam() { + const [cardId, _setCardId] = useQueryParam("cardId", NumberParam); + + const setCardId = useCallback((cardId: number) => { + _setCardId({ cardId }, "replaceIn"); + }, []); + + return [cardId ?? undefined, setCardId] as const; +} +``` + +在分离了 Hook 的职责后,它比原本 `usePageState()` Hook 有着更明确的名称。 +同时,通过缩小修改 Hook 时产生的影响范围,能够防止产生预料之外的变化。 diff --git a/ch/code/examples/use-user.md b/ch/code/examples/use-user.md new file mode 100644 index 00000000..6de3ae13 --- /dev/null +++ b/ch/code/examples/use-user.md @@ -0,0 +1,180 @@ +# 统一同类函数的返回类型 + +
+ +
+ +就像与 API 调用相关的 Hook 一样,如果同类函数或 Hook 具有不同的返回类型,代码的一致性就会降低,同事阅读代码时会产生混乱。 + +## 📝 代码示例 1: useUser + +以下 `useUser` 和 `useServerTime` Hook 都与 API 调用相关。 + +但是 `useUser` 返回的是 `@tanstack/react-query` 的 `Query` 对象,而 `useServerTime` 则是获取服务器时间后仅返回数据本身 。 + +```typescript 9,18 +import { useQuery } from "@tanstack/react-query"; + +function useUser() { + const query = useQuery({ + queryKey: ["user"], + queryFn: fetchUser + }); + + return query; +} + +function useServerTime() { + const query = useQuery({ + queryKey: ["serverTime"], + queryFn: fetchServerTime + }); + + return query.data; +} +``` + +### 👃 闻代码 + +#### 可预测性 + +如果调用服务器 API 的 Hook 返回类型各不相同,则其他同事每次使用这些 Hook 时都需要查看并确认返回类型。如果返回的是 `Query` 对象,那么就需要从中提取出 `data`;如果仅返回数据本身,则可以直接使用返回值。 + +如果执行同一功能的代码不遵循一贯原则,则阅读和编写代码时会产生混乱。 + +### ✏️ 尝试改善 + +像下列代码一样,将调用服务器 API 的 Hook 一致地返回 `Query` 对象,可以提高团队成员对代码的可预测性。 + +```typescript 9,18 +import { useQuery } from "@tanstack/react-query"; + +function useUser() { + const query = useQuery({ + queryKey: ["user"], + queryFn: fetchUser + }); + + return query; +} + +function useServerTime() { + const query = useQuery({ + queryKey: ["serverTime"], + queryFn: fetchServerTime + }); + + return query; +} +``` + +## 📝 代码示例 2: checkIsValid + +下面 `checkIsNameValid` 和 `checkIsAgeValid` 都是用来检验姓名和年龄的有效性的函数。 + +```typescript +/** 用户名必须少于20个字符。 */ +function checkIsNameValid(name: string) { + const isValid = name.length > 0 && name.length < 20; + + return isValid; +} + +/** 用户的年龄必须是18岁至99岁之间的自然数。 */ +function checkIsAgeValid(age: number) { + if (!Number.isInteger(age)) { + return { + ok: false, + reason: "年龄必须是整数。" + }; + } + + if (age < 18) { + return { + ok: false, + reason: "年龄必须年满18岁。" + }; + } + + if (age > 99) { + return { + ok: false, + reason: "年龄必须在99岁以下。" + }; + } + + return { ok: true }; +} +``` + +### 👃 闻代码 + +#### 可预测性 + +如果有效性检查函数的返回值各不相同,则同事们在使用这些函数时每次都需要确认返回类型,这很容易造成混淆。 + +特别是在不使用诸如 [严格布尔表达式](https://typescript-eslint.io/rules/strict-boolean-expressions/) 类似功能的情况下,这很可能成为代码中出错的源头。 + +```typescript +// 这段代码验证姓名是否符合规则 +if (checkIsNameValid(name)) { + // ... +} + +// 该函数只返回一个对象 { ok, ... }, +// `if` 语句内的代码总是会被执行 +if (checkIsAgeValid(age)) { + // ... +} +``` + +### ✏️ 尝试改善 + +像下列代码一样,可将有效性检验函数始终返回一个 `{ ok, ... }` 类型的对象。 + +```typescript +/** 用户名必须少于20个字符。 */ +function checkIsNameValid(name: string) { + if (name.length === 0) { + return { + ok: false, + reason: "姓名不允许为空值。" + }; + } + + if (name.length >= 20) { + return { + ok: false, + reason: "姓名不能超过20个字符。" + }; + } + + return { ok: true }; +} + +/** 用户的年龄必须是18岁至99岁之间的自然数。 */ +function checkIsAgeValid(age: number) { + if (!Number.isInteger(age)) { + return { + ok: false, + reason: "年龄必须是整数。" + }; + } + + if (age < 18) { + return { + ok: false, + reason: "年龄必须年满18岁。" + }; + } + + if (age > 99) { + return { + ok: false, + reason: "年龄必须在99岁以下。" + }; + } + + return { ok: true }; +} +``` diff --git a/ch/code/examples/user-policy.md b/ch/code/examples/user-policy.md new file mode 100644 index 00000000..7f465bcc --- /dev/null +++ b/ch/code/examples/user-policy.md @@ -0,0 +1,108 @@ +# 减少视点转移 + +
+ +
+ +在阅读代码时,反复浏览代码的各个部分,或者在多个文件、函数、变量之间翻看阅读的现象被称为**视点转移**。 +随着视点的多次转移,需要理解代码的时间也随之增加,很难把握代码的整体语境。 + +如果将代码编写成从上到下、在一个函数或文件中就能顺序阅读的方式,代码阅读者可以迅速理解其功能。 + +## 📝 代码示例 + +下列代码会根据用户的权限显示不同的按钮。 + +- 如果用户权限是管理员(Admin),则显示 `Invite` 和 `View` 按钮。 +- 如果用户权限是仅查看用户(Viewer),则非激活 `Invite` 按钮,显示 `View` 按钮。 + +```tsx +function Page() { + const user = useUser(); + const policy = getPolicyByRole(user.role); + + return ( +
+ + +
+ ); +} + +function getPolicyByRole(role) { + const policy = POLICY_SET[role]; + + return { + canInvite: policy.includes("invite"), + canView: policy.includes("view") + }; +} + +const POLICY_SET = { + admin: ["invite", "view"], + viewer: ["view"] +}; +``` + +## 👃 闻代码 + +### 可读性 + +为了理解这段代码中为何 `Invite` 按钮被非激活,你需要按照 `policy.canInvite` → `getPolicyByRole(user.role)` → `POLICY_SET` 的顺序,在代码中上下翻阅进行阅读。 +在此过程中,发生了三次视点转移,使得代码阅读者很难维持上下文的语境,增加了理解的难度。 + +虽然使用 `POLICY_SET` 等抽象来按权限管理按钮状态在复杂权限体系中很有用,但在当前简单场景下却增加了阅读者的代码理解难度。 + +## ✏️ 尝试改善 + +### A. 展开并明确展示条件 + +直接在代码中明确展示了基于权限的条件。这样一来,代码中很容易看出 `Invite` 按钮何时会被非激活。 +只需阅读代码的上下文,就能一眼看清处理权限的逻辑。 + +```tsx +function Page() { + const user = useUser(); + + switch (user.role) { + case "admin": + return ( +
+ + +
+ ); + case "viewer": + return ( +
+ + +
+ ); + default: + return null; + } +} +``` + +### B. 将条件整理成清晰易懂的对象形式 + +通过以对象的形式在组件内部管理权限逻辑,可以减少不必要的视点转移,一眼看清并修改条件。 +只需查看 `Page` 组件,就能确认 `canInvite` 和 `canView` 的条件。 + +```tsx +function Page() { + const user = useUser(); + const policy = { + admin: { canInvite: true, canView: true }, + viewer: { canInvite: false, canView: true } + }[user.role]; + + return ( +
+ + +
+ ); +} +``` diff --git a/ch/code/index.md b/ch/code/index.md new file mode 100644 index 00000000..1c61ec90 --- /dev/null +++ b/ch/code/index.md @@ -0,0 +1,81 @@ +--- +comments: false +--- + +# 易于修改的代码 + +好的前端代码是**易于修改的**代码。 +在实现新需求时,能够轻松修改和部署的代码被认为好的代码。 +你可以根据四个标准判断代码是否易于修改。 + +## 1. 可读性 + +**可读性**(Readability)指的是代码易于阅读和理解的程度。 +要使代码易于修改,首先必须理解代码的作用。 + +易于阅读的代码要求读者考虑的上下文较少,从上到下自然流畅。 + +### 提高可读性的策略 + +- **减少语境** + - [分离不一起运行的代码](./examples/submit-button.md) + - [抽象实现细节](./examples/login-start-page.md) + - [根据逻辑类型拆分合并的函数](./examples/use-page-state-readability.md) +- **命名** + - [为复杂条件命名](./examples/condition-name.md) + - [为魔数命名](./examples/magic-number-readability.md) +- **使其从上到下顺利阅读** + - [减少视点转移](./examples/user-policy.md) + - [简化三元运算符](./examples/ternary-operator.md) + +## 2. 可预测性 + +**可预测性**(Predictability)指的是与团队成员协作时,同事能够预测函数或组件行为的难易程度。 +可预测性高的代码遵循一致的规则,仅通过函数或组件的名称、参数、返回值,就能知道其执行的行为。 + +### 提高可预测性的战略 + +- [避免命名重复](./examples/http.md) +- [统一同类函数的返回类型](./examples/use-user.md) +- [揭示隐藏的逻辑](./examples/hidden-logic.md) + +## 3. 内聚性 + +**内聚性**(Cohesion)是指需要被修改的代码是否总是一起修改的特性。 +内聚性高的代码,即使修改了某一部分,也不会在其他部分引发障碍。 +这是因为在结构上保证了相关代码能够同步修改。 + +::: info 可读性与内聚性可能存在冲突 + +一般来说,为了提高内聚性,可能需要做出一些降低可读性的决策,例如抽象化变数或函数。 +以内聚性为准,通过代码的通用化和抽象化来避免未同时修改而引发的障碍。 +风险较低时,应优先考虑可读性,允许代码重复。 + +::: + +### 提高内聚性的策略 + +- [需同时修改的文件位于同一目录下](./examples/code-directory.md) +- [消除魔数](./examples/magic-number-cohesion.md) +- [考虑表单的内聚性](./examples/form-fields.md) + +## 4. 耦合性 + +**耦合性**(Coupling)是指修改代码时的影响范围。 +易于修改的代码被修改时影响范围小,因此更容易预测更改的范围。 + +### 降低耦合性的策略 + +- [单独管理责任](./examples/use-page-state-coupling.md) +- [允许重复代码](./examples/use-bottom-sheet.md) +- [消除 Props Drilling](./examples/item-edit-modal.md) + +## 多角度审视代码质量 + +遗憾的是,这四个标准很难同时兼顾。 + +例如,通过通用化和抽象化提高代码的内聚性,可确保函数或变数总是一同修改。然而,代码进一步抽象化后,可读性也会随之降低。 + +允许代码重复,可以减少代码的影响范围,从而降低耦合性。然而,这也可能导致在修改一处代码时,另一处未被及时修改,从而降低内聚性。 + +前端开发者需要结合当前面临的具体情况,深入思考并在不同价值之间权衡取舍,以确保代码在长期内更易于维护和修改。 diff --git a/ch/code/start.md b/ch/code/start.md new file mode 100644 index 00000000..e50ac93c --- /dev/null +++ b/ch/code/start.md @@ -0,0 +1,33 @@ +--- +comments: false +--- + +# 开始使用 + +`Frontend Fundamentals`(FF)为前端代码规范提供标准。作为前端开发者,当你想提高代码质量时,可以将它做为指南针,帮助你找到正确的方向。 + +它介绍了好代码的[四大原则](./index.md),以及具体的实例与解决方案。 + +## 何时使用 + +- 🦨 关心代码但**难以从逻辑上解释的开发者**。 +- 👀 想学习如何**快速检测并改善坏代码**的开发者。 +- 🤓 在代码审查等环节,通过别人分享的链接**将客观地认识**自己是如何编写代码的开发者。 +- 👥 **希望与团队**一起制定共同代码风格和质量标准的开发者。 + +## 作者 + +- [milooy](https://github.com/milooy) +- [donghyeon](https://github.com/kimbangg) +- [chkim116](https://github.com/chkim116) +- [inseong.you](https://github.com/inseong.you) +- [raon0211](https://github.com/raon0211) +- [bigsaigon333](https://github.com/bigsaigon333) +- [jho2301](https://github.com/jho2301) +- [KimChunsick](https://github.com/KimChunsick) +- [jennybehan](https://github.com/jennybehan) + +## 文档贡献者 + +- [andy0414](https://github.com/andy0414) +- [pumpkiinbell](https://github.com/pumpkiinbell) diff --git a/ch/index.md b/ch/index.md new file mode 100644 index 00000000..920efd9f --- /dev/null +++ b/ch/index.md @@ -0,0 +1,28 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "Frontend Fundamentals" + tagline: "易于修改的前端代码指南" + image: + src: /images/ff-symbol-gradient.png + alt: Frontend Fundamentals symbol + actions: + - text: 了解好代码的标准 + link: /ch/code/ + - theme: alt + text: 社区 + link: /ch/code/community + +features: + - icon: 🤓 + title: 提高你的代码阅读能力 + details: 查看判断代码是否易于修改的原则。 + - icon: 🤝 + title: 提高你的代码审查能力 + details: 主动探索各种代码改善的实例。 + - icon: 📝 + title: 如果对自己的代码感到困惑 + details: 在 GitHub 论坛与其他开发者进行交流。 +---