如何构造我的 JavaScript 文件
很多人问我是怎么写 JavaScript 的——好吧,这绝对是个谎言,没人问我这个问题,但如果有人问了,我建议他们看看这篇文章。多年来,在阅读了《代码整洁之道》(以及其他书籍)并使用了多年的 PHP 之后,我逐渐形成了自己的代码风格。没错,PHP,别小看它,它拥有强大的社区和优秀的编码标准。当然,还有多年来与他人一起编写 JS 代码,并遵循不同公司的风格指南的经验。
该结构不依赖于 JS 模块,但我现在倾向于只编写 JS 模块,所以我会使用它们。
其结构概括如下:
//imports
import fs from 'fs';
import utils from 'utils';
import db from '../../../db';
import { validatePath } from './readerHelpers';
// constants
const readDir = utils.promisify(fs.readDir);
const knex = db.knex;
// main exports
export async function fileReader(p) {
validatePath(p);
return await readFile(p);
}
// core logic
function readFile(p) {
// logic
}
进口
文件顶部是导入文件。这很合理,它们会被提升到所有其他文件之上。导入文件的顺序并不重要,除非你使用一些钩子(比如 Babel 钩子),所以我倾向于使用以下结构:
- 原生模块 — Node 原生的东西
- 库模块 — lodash、knex 等等
- 当地图书馆 — 例如
../db
- 本地文件 — 类似
./helpers
或类似
保持模块井井有条,让我更容易看清导入的内容和实际使用的内容。在开始编写代码时,我也倾向于以这种方式编写依赖项。
我倾向于根本不关心字母顺序(除了非结构化导入),而且我实在看不出它有什么意义。
原生模块
我倾向于将原生模块放在最上面,并按主题保持清晰的组织,如下所示:
import path from 'path';
import fs from 'fs';
import util from 'util';
如果我在浏览器中,我显然会跳过这一步。
库模块
我尝试尽可能只从库中导入我需要的内容,但同样,我会根据某个主题对它们进行分组。
import knex from 'knex';
import { clone } from 'lodash';
我还注意到,如果我要进行默认导入(例如 knex 导入),我倾向于将其放在库模块的顶部,而将解构导入放在较低的位置。这并非必需,但我喜欢这种视觉效果。
本地/内部库
本地库指的是本地共享的模块,例如db.js
用于与 Bookshelf 建立连接的文件。或者,就我的情况而言,我们公司有多个处理数字和计算的库,这些库在我们的产品中随处可见。
import db from '../../../db';
import calculators from '../../../lib/calculators';
本地文件
最后,我会导入本地文件,这些文件通常与我正在处理的文件位于同一文件夹,或者最多位于上一级目录。例如,我为 Redux 编写了一个 Reducer,并将其与其他 Reducer 放在不同的文件夹中。在该文件夹中,我还保存了一个辅助文件,通常命名为[reducer name]Helpers.js
:
import { assignValue, calculateTotal } from './calculationReducerHelpers';
常量
导入所有依赖项后,我通常会做一些前期工作,这些工作会在模块的其余部分用到。例如,我会knex
从Bookshelf
实例中提取依赖项。或者,我可能会设置值常量。
const knex = db.knex;
const pathToDir = '../../data-folder/';
使用非常量通常表明我依赖于某种单例。我尽量避免这种情况,但有时这样做是必要的,因为没有其他简单的方法可以实现,或者这样做并不重要(例如一次性的命令行脚本)。
出口
在我基本设置好所有模块级依赖项(无论是常量值还是导入的库)之后,我会尝试将导出函数分组放在文件顶部。基本上,我会把那些充当模块粘合剂并实现模块最终目的的函数放在文件顶部。
对于 Redux,我可能会导出一个 Reducer,它会将工作拆分开来并调用相关的逻辑。对于 ExpressJS,我可能会在这里导出所有路由,而实际的路由逻辑则在下面。
import { COUNT_SOMETHING } from './calculationActions';
import helpers from './calculationHelpers';
export function calculationReducer(state, action) {
switch (action.type) {
case COUNT_SOMETHING:
return calculateSomething(state, action);
}
}
我想提一下,这不是我导出函数的唯一部分。
我觉得模块系统的工作方式使得在暴露尽可能窄的 API 和导出函数以在测试中使用它们之间划一条清晰的界限有点困难。
例如,在上面的例子中,我永远不想calculateSomething
在模块之外使用。我不太清楚面向对象编程 (OOP) 语言如何处理私有函数的测试,但这是一个类似的问题。
核心逻辑
这听起来可能有点奇怪,但对我来说,核心逻辑总是放在最后。我完全理解人们把导出和核心逻辑颠倒过来,但出于多种原因,这对我来说效果很好。
当我打开一个文件时,顶层函数会以抽象的步骤告诉我接下来会发生什么。我喜欢这样。我喜欢一眼就能知道文件会做什么。我经常进行 CSV 操作并将其插入数据库,而顶层函数总是一个易于理解的过程,其流程如下fetchCSV → aggregateData → insertData → terminate script
:
核心逻辑始终涵盖从上到下的导出操作。因此,在内联示例中,我们会得到类似这样的代码:
export async function importCSV(csvPath) {
const csv = await readCSV(csvPath);
const data = aggregateData(csv);
return await insertData(data);
}
function aggregateData(csv) {
return csv
.map(row => {
return {
...row,
uuid: uuid(),
created_at: new Date(),
updated_at: new Date(),
};
})
;
}
function insertData(data) {
return knex
.batchInsert('data_table', data)
;
}
请注意,它readCSV
不在那里。这听起来很普通,我本来应该把它拉到一个辅助文件中,然后在上面导入。除此之外,您可以再次看到我的导出与否的困境。我不希望aggregateData
在模块之外使用,但我仍然想测试它。
除此之外,我倾向于将“内容丰富”的函数放在顶部,将较小的函数放在底部。如果我有一个模块特定的实用函数,一个在模块内多次使用的函数,我会把它们放在最底部。基本上,我的排序依据是:复杂性 + 用途。
因此优先级顺序为:
- 核心逻辑函数 — 顶级导出使用的函数(按使用顺序排列)
- 更简单/更小的函数——核心逻辑函数使用的函数
- 实用函数——在模块周围多个地方使用的小函数(但不会被导出)
核心逻辑函数
核心逻辑函数就像导出函数的“粘合剂”。根据模块的复杂程度,这些函数可能存在,也可能不存在。函数的细分并非必需,但如果模块变得足够大,核心逻辑函数就好比主函数中的步骤。
如果你正在编写类似 React 或 Angular 之类的代码,这些组件就是我上面提到的导出函数。但你的核心逻辑函数将是各种监听器或数据处理器的实现。在 Express 中,这些将是你的特定路由。在 Redux Reducer 中,这些将是位于调用链足够远、不需要 switch/case 语句的独立 Reducer。
如果您使用 Angular,那么在类内而不是在整个文件范围内组织这些功能是完全公平的。
export FormComponent extends Component {
function constructor() { }
onHandleInput($event) {
// logic
}
}
更简单/更小的函数
这些函数通常介于核心逻辑和纯实用函数之间。你可能只用过一次,或者它们可能比实用函数稍微复杂一点。我可能会删除这个类别,并建议“按照复杂性或工作量递减的顺序编写你的函数”。
这里没什么可说的。也许你的onHandleInput
事件监听器需要一些逻辑来处理$event
数据,所以如果它是纯的,你可以把它从类中取出,如果不是,你可以把它保留在类中,就像这样:
export FormComponent extends Component {
onHandleInput($event) {
try {
validateFormInput($event);
} catch (e) {
}
}
validateFormInput($event) {
if (this.mode === 'strict-form') {
throw new Error();
}
}
}
最后,说说实用函数
。我倾向于将实用函数组织到最接近我使用它们的地方。要么放在同一个文件里,要么放在同一个文件夹里(必要时),要么放在同一个模块里,等等。每当函数的使用范围从文件内扩展到项目根目录或其自身的 NPM 模块时,我都会将它们移出一层。
在我看来,工具函数应该始终是纯方法,这意味着它不应该访问其作用域之外的变量,并且应该仅依赖于传入的数据,并且不产生任何副作用。除非使用工具函数来调用 API 或访问数据库。由于这些被视为副作用,我认为它们是唯一的例外。
function splitDataByType(data) {
return data
.reduce((typeCollection, item) => {
if (!typeCollection[item.type]) {
typeCollection[item.type] = [];
}
typeCollection[item.type].push(item);
return typeCollection;
}, {});
}
function insertData(data, knex) {
return knex
.batchInsert('data', data);
}
还要别的吗?
当然!我想每个人都有自己独特的代码编写方式。多年来,我每天都会编写大量代码,上述结构对我来说非常有效。最终,很多细微差别开始显现,我发现自己写代码的速度更快了,更享受其中,调试和测试也更容易了。
在完成这篇文章之前,我想分享一些我已经非常习惯的编码技巧,这些技巧与文档结构关系不大,而与编写实际代码的小偏好有关。
提前返回
当我发现提前返回时,我顿时豁然开朗。else
既然可以提前返回,为什么还要把一大段代码放在一个语句里呢?
我的经验法则是,如果提前返回条件小于剩余代码,我就会写提前返回,但如果不是,我会将代码颠倒过来,以便较小的代码块始终是提前返回。
function categorize(collection, categories) {
return collection.reduce((items, item) => {
if (!categories.includes(item.category) {
return items;
}
if (!items[item.category]) {
items[item.category] = [];
}
items[item.category].push(item);
return items;
}, {});
}
早期返回在 Switch 中也能很好地工作,我是 Redux 中它们的忠实粉丝。
分号代码块
虽然我不再那么常用了(Prettier 不再支持),但我总是会用分号在单独的一行中结束函数链,并在链式缩进左侧一个缩进。这样可以创建一个整洁的代码块,避免代码被随意搁置。
当然,这意味着我也更喜欢使用分号而不是不。
return fetchPost(id)
.then(post => processPost(post))
.then(post => updatePost(post, userInput))
.then(post => savePostUpdate(post))
; // <- terminating semicolon
或者更好的写法是这样的:
return fetchPost(id)
.then(processPost)
.then(updatePost(userInput))
.then(savePostUpdate)
; // <- terminating semicolon