JS 开发的优良习惯、技巧与避坑

持续更新,记录 Web 和 Node.js 开发中经验技巧,优化开发体验,避免踩坑。

做好工具选型

不同的工具之间往往存在不同的利弊,要根据项目的需求和特征,来选择合适的工具取长补短;甚至是在需要的时候不使用工具直接编码解决问题,或是投入成本自行开发和维护一套工具。

工具选型甚至还涉及到非技术因素,例如,某些工具的开源许可存在限制,不允许在商业软件中使用,或是允许使用但不允许分发。


举个例子,假设现在项目中有编码和解码 url 参数的需求,作为开发者我们有几个选择:

  • 自行开发工具函数;

  • 使用原生 URLSearchParams 对象,为了避免环境不支持可以考虑引入 polyfill;

  • 使用 qsquery-string 这类工具。

此时面临选型,首先排除自行开发,因为性价比太低;然后经过分析,原生的 URLSearchParams 支持功能太少,例如不支持数组、不支持快捷解析出布尔值或数值,还需要做一次类型转换等。
如果你的需求比较简单,可以直接使用 URLSearchParams,担心用户浏览器太旧的话还可以加入 polyfill。

如果选用第三方库,此时就要在 qsquery-string 中选择,它们的利弊如下:

  • qs 的功能强大,支持数组、自动转类型等功能,对嵌套对象格式、特殊字符集编码等都有良好的支持,提供了 ESM、CJS、UMD 的引入方式;但是它的代码体积很大,UMD 文件也有 46KB 大小;
  • query-string 功能有限,但是数组、自动转类型等常用功能也都有,它特意被设计成不支持嵌套对象的格式,它只有 CJS 引入方式;它的代码体积小,未压缩版本也只有 12KB 大小。

如果是小程序类项目,对代码包体积大小敏感,此时可能就要选用 query-string 了;
而如果项目极其依赖 url 传参,需要传各种对象格式的复杂参数,甚至包含了特殊字符集,那么 qs 则更适合。


此外,npm 上有很多包仅仅有一字之差,但是区别却很大,例如:querystringquery-string,前者早已被废弃,后者还在持续维护。
这一点在安装使用时也需要注意。

明确环境变量的来源

开发中几乎都是使用第三方库,可能会让我们产生很多刻板印象。
实际上:

  • process.env.NODE_ENV 并不是凭空就有的,它的值是由工具或运行环境来配置的;
  • process.env 只来自于操作系统,Node.js 默认不会自动从 .env 中读取它;
  • 前端项目代码运行在用户的浏览器中,不可能运行 process.env 来访问用户电脑上的变量,实际上前端源码中的这些写法都会被 Webpack 等打包工具会提前处理,在编译时直接替换成对应的值。

Node.js 中的 process.env 可以获取环境变量,但这并不意味着它会自动读取 .env 文件。
实现了类似功能的库,往往都带有一系列代码或依赖用来读取 .env 文件并解析。

最常用的就是 dotenv,只要运行以下代码,它就会自动读取 .env 文件,并把其中所有的键值对设置到 process.env
示例:

require('dotenv').config()

如果你的应用需要读取 .env 文件,建议使用这个包。

create-react-app 举例,它的依赖项 react-scripts 中有 一系列代码 用来读取 .env 文件并解析,其中也用到了 dotenv,因此 cra 项目可以读取 .env 中的变量,并在 process.env 中访问;在 vue-cli 中也能找到 类似的代码

此外,dotenv 默认只读取 .env 文件,如果需要添加 .development.local 这一类后缀,需要传参数。
代码示例:

// 指定文件名,从 .env.local 读取变量
require('dotenv').config({ path: './.env.local' })

// 支持多个文件,此时较左侧的文件中的的变量优先生效
require('dotenv').config({ path: ['.env.local', '.env'] })

// 配置 override 后,较右侧的文件中的变量优先生效
require('dotenv').config({ 
  path: ['.env-aa', '.env-xx'],
  override: true
})
// override 还有使得自定义环境变量能覆盖系统环境变量的功能

如果不希望将读到的环境变量混入 process.env,则可以这样:

const variablesInEnv = {}
require('dotenv').config({ processEnv: variablesInEnv })

// 此时 .env 中的变量键值对仅会写入到对象 variablesInEnv
console.log(variablesInEnv)

如果应用开发者并没有使用 dotenv,使用者想使用 .env 文件来向应用的源码中的 process.env 中注入值,则可以安装 dotenv-cli,并使用 dotenv 作为启动指令(注意没有 “-cli” 后缀),这样也可以让 .env 文件被应用中的 process.env 读取。

dotenv-cli 还支持添加 -v KEY=VALUE 参数来手动注入变量,也支持 -e .env.local 参数来手动指定文件名。

如果你使用 Node.js 的 20 或以上版本,它原生支持读取 .env 这类文件,可以参考这篇 文档


process.env.NODE_ENV 这个值被我们广泛使用,用来判断当前运行环境。
实际上,这个值并不是凭空而来的,如果没有任何工具或者库来设置这个变量,系统也没有设置,那么这个值始终是空的。

一般来说,各个库的 CLI 工具都会在例如 servestartdev 等启动指令时注入 NODE_ENV = development 的变量;
而在 builddistprod 等启动指令时注入 NODE_ENV = production 变量。

例如,在 create-react-appbuild 指令的代码中,开头就是 这些代码

'use strict';

// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';

// ...

如果你的 Node.js 应用没有类似的代码,也没有使用类似的工具,那么获取到的 process.env.NODE_ENV 为空。此时,可以根据启动指令手动设置变量,或使用 dotenvcross-env 等工具来配置。

Node.js 的 文档 中说了总是假定运行在开发环境,用户手动设置 NODE_ENV = production 才会使 Node.js 转入生产模式运行;对于 Node.js 本身而言,这两种模式的区别仅仅在日志大小和缓存级别上,没有其他影响。

Docker 的官方镜像的 README 文件 中,也提供了一个带有 NODE_ENV=production 的 Docker compose 配置示例文件。


前端项目运行在用户的浏览器中,此时不可能有 process.env,因此,前端项目中这些写法都会被打包工具处理掉。
在 Webpack 中,内部有 EnvironmentPlugin 插件用于处理所有用到 process.env 的代码,把这些值通过 DefinePlugin 替换成静态的值。

create-react-app源码 中,使用正则表达式匹配只读取 REACT_APP_ 开头的变量,以及这个库自身提供的一些变量,把这些传给 Webpack 的 DefinePlugin。因此用户在 .env 中定义的变量只有使用这个前缀,才能在代码中被访问到。
这么做的目的,可能是避免意外暴露编译项目的用户电脑上的一些环境变量。

如果用户在前端代码中直接使用 process.env,会导致出错。
但是 create-react-app 会把这段代码替换为一个所有可用的环境变量合成的对象,使得这段代码可以正常工作(但是会暴露这些变量)。参考上面链接中的代码的大约 102 行开始的内容。

对象存储都使用 S3 API 并带上 MIME

对于 Node.js 开发者而言,开发中肯定会接触到对象存储;对前端开发者而言,也会有上传 CDN 等操作,可能也会用到对象存储。
一般来说,我们会使用供应商提供的对象存储 SDK 来进行文件上传、下载等操作。

目前,亚马逊云对象存储 S3 的 API 已经成为各家厂商普遍兼容的标准,国内几乎所有的云服务供应商都兼容 S3 API 来访问他们的对象存储服务。

所以,我们可以不使用供应商的对象存储 SDK,而是使用 S3 API。
这样,以后更换供应商时,仅需要修改一些地址、密钥参数就可以,不需要修改任何代码。

这里以我自己的项目 paperplane-api 为例,我使用的是腾讯云对象存储 COS:
之前的代码:

import COS from 'cos-nodejs-sdk-v5'

// 初始化实例
const cos = new COS({
  SecretId: '密钥 ID',
  SecretKey: '密钥 Key',
})

// 上传文件
cos.uploadFile(
  {
    Bucket: '存储桶名',
    Region: '地区',
    Key: '文件云端路径',
    FilePath: '本地文件',
  },
  function (err, data) {}
)

这样写,深度绑定的云服务供应商。以后如果换成阿里云或者其他家的云服务,那么这段代码可能要大幅改写。

现在换成了 S3 API 的写法:

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import mime from 'mime'

// 初始化实例
const s3Client = new S3Client({
  accessKeyId: '密钥 ID',
  secretAccessKey: '密钥 Key',
  region: '区域',
  endpoint: 'https://cos.区域.myqcloud.com',
})

// 上传文件操作
const uploadCommand = new PutObjectCommand({
  Bucket: '存储桶名',
  Body: '文件内容',
  Key: '文件云端路径',

  // ↓ 下面这行很重要,见下文
  ContentType: mime.getType('文件名或文件扩展名'),
})

// 上传文件
await s3Client.send(uploadCommand)

这样写,即使以后换了存储服务的厂商,只需要修改这里面的密钥等参数即可,不需要大幅改写代码。


使用这类对象存储上传文件时,要注意尽量带上文件的 MIME 类型
可以看到 MIME 有 很多种,一般可以根据文件扩展名来判断,直接使用类似于 mime 这类包即可。

不提供 MIME 类型时,对象存储服务有可能会赋予默认的 MIME 类型 application/octet-stream,它表示是二进制数据。

如果把对象存储服务对接 CDN,浏览器在加载文件时,会根据响应的 Content-Type 的值来判断资源的 MIME 类型(浏览器也带有 MIME 嗅探功能)。需要特别注意的是,CSS 的 MIME 类型是 text/plain 或是 text/css 等文本类型,如果服务端返回的响应其 Content-Typeapplication/octet-stream,那么浏览器很可能直接拒绝加载和应用这个 CSS 文件,此时没有任何报错提示;JS 文件也同理。

写死的对象类参数放置在组件以外

先来看两段 antd 的栅格组件代码:
第一种用法:

function MyComponent() {
  return <Row gutter={[16, 16]}></Row>
}

// 或者

function MyComponent() {
  const gutter = [16, 16]
  return <Row gutter={gutter}></Row>
}

第二种用法:

const gutter = [16, 16]

function MyComponent() {
  return <Row gutter={gutter}></Row>
}

可以明显看出,第一种用法因为传递给 <Row> 组件的参数是一个数组(换成对象类型也一样),无论数组通过字面量传递还是通过变量传递,组件每次被创建时这个参数的引用都和上次不相同,所以会导致 <Row> 组件被重新创建,即使 <Row> 组件是被 React.memo() 创建出的。

而第二种用法,把数组或对象类型的参数放置在方法组件以外,它的引用被 “固定” 住,不会变化,因此 React 得以优化组件的渲染。

因此,如果组件接受对象/数组/方法类型的参数,如果参数无需修改,最好将参数变量抽取到方法组件代码的外部;如果参数可能被修改,也最好使用 useState()useMemo() 固定住,从而避免每次方法组件执行导致子组件接收到的参数引用发生变化。


此外,使用数组或对象类型的参数,还会涉及到解构的用法。请看下一个小节。

组件中的参数解构更好的用法

组件代码中,一般存在两种解构方式:解构 props 取出参数,以及解构参数中的对象类型。

解构 props 时,如果要设置默认值,需要注意入参的 null 值问题。
这里给出一段示例代码:

const emptyFriendList = []

// 此组件接受一个参数 friendList,渲染好友列表
function MyFriends(props: { friendList?: string[] }) {
  const { friendList = emptyFriendList } = props

  return (
    <ul>
      {friendList.map(item => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  )
}

上述代码中,参数 friendList 为可选参数,如果不提供参数,那么将使用默认的空数组作为参数。
通常情况下,这段代码可以正常运行,即使 friendList 留空,也可以正常渲染,不会出错。但是,考虑到一种场景:参数来源于 HTTP 请求的结果,此时如果后端返回一个 null,那么这段代码会直接报错。

原因很好得出,因为 const { friendList = emptyFriendList } = props 这种解构方式,赋默认值的行为只在参数为 undefined 的情况生效,很容易遗漏 null 的情况,而后端返回的结果保不准就会有个 null

因此,对于解构赋默认值的场合,可能需要考虑意外传入 null 值的场景,为此我们可能需要专门调整代码。


第二种情况,通过解构取出对象或数组类型的参数,以此可以达成优化的效果。

设想一个移动端日期选择器组件,参数形如 [2023, 10, 15] 这种年-月-日的数组格式。
假设我们需要根据当前的 yearmonth 来生成选择 date 的列表:根据年份判断是否闰年,根据月份判断当月有多少天,此时组件内代码可能是这样的:

function DatePicker(props: { value: [number, number, number] }) {
  const { value } = props
  const [year, month, date] = value

  const dateList = useMemo(() => {
  	return calcDateList(year, month)
    // ↓ 注意这里
	}, [year, month])

  // ...
}

我们对 value 进行解构,取出其中的年、月、日参数,此后就可以使用这些数值类型的参数,而不是使用对象格式的 value
上面这种写法,使得 date 的列表仅依赖 yearmonth 两个数字。这两个数字不变时,不会重新计算 date 列表。

如果使用下面的写法,那么性能便会较差:

function DatePicker(props: { value: [number, number, number] }) {
  const { value } = props

  const dateList = useMemo(() => {
  	return calcDateList(value[0], value[1])
    // ↓ 注意这里
	}, [value])

  // ...
}

这样使得 date 列表依据于 value,而它是一个对象,我们难以保证这个对象的引用不发生变化,所以这种写法的优化非常有限。

组件外显字段用 ReactNode 类型

开发常见场景:
开发一个组件,例如卡片,用户名等信息作为组件参数用于外显,开始使用 string 类型接收参数,满足需求:

/** 用户卡片组件 */
function Card(props: { name: string }) {
  const { name } = props

  return <div>用户名:{name}</div>
}

后续产品经理要求,部分用户名可能会标红、标绿,甚至前面带个 Icon。
例如产品要求用户名前面加上 VIP 标记,此时我们可能会这样写:

<Card name={<span><VipIcon />王小明</span>} />

但是 name 参数只接受字符串,所以这里要么使用 // @ts-ignore 之类的注释,要么就去修改组件源码。
如果一开始这个组件的 name 字段的类型就定为 ReactNode,使得用户名可以展示复杂内容,那么在后续开发中就不需要再来改一次了。

所以我们可以养成这样的习惯:
所有需要在组件中仅用于外显的字段,尽量使用 ReactNode 类型,而不是 string

培养良好的语法习惯

开发时养成习惯,利好团队,一劳永逸。

方法类参数改为支持异步方法

原因也和上一条类似,提前做好兼容,避免以后这样的需求来临了,再回来改代码。

举个例子:
vue-router导航守卫 有一个 router.beforeEach() 方法,传入一个回调函数,每次路由变化时都会调用这个函数,这个函数可以返回 false 来阻止这次路由操作。

这个函数就支持传入一个异步函数,导航的时候会等待该异步函数返回。这便给了我们开发时很多便利,例如可以发送一个请求来判断用户权限等。开发类似功能时,可以参考 vue-router 的这个设计,接受函数参数时兼容异步函数。

多写 function,少写 const

直接给出结论:方法能使用 function 声明的,尽量都使用这个写法,而不是用 const 后面接箭头函数。

例如:

function someFunc() {}
// ↑ 上面的写法更为推荐
const someFunc = () => {}

当然,如果你的函数要作为构造函数,则必须使用 function;而如果你的函数代码中需要访问外部 this,则必须写成 const 和箭头函数。
这些场合是必须用特定的写法,没得商量。

推荐使用 function 原因有很多:

  • function 声明的函数是有 “名字” 的函数,const 定义的是一个箭头函数,打印输出时是没有名字的;在特定的调试场合,function 函数更容易定位到位置;
  • 只有 function 写法的函数可以使用 arguments 参数;
  • 如果使用 TypeScript,只有使用 function 关键字声明的函数支持函数重载(这时也正好会需要用到 arguments 参数);
  • function 写法可以把方法返回值类型也放在开头;对于异步函数而言,通过 async function 可以一眼认出这是异步函数;const 写法的函数没有这么直观;
  • function 可以被 JS 引擎自动 “提升”,对代码放置的先后位置不敏感,比如我们可以把工具类的函数放到文件最尾部,避免影响业务代码的阅读。

尝试 void 关键字

这个关键字很少见,甚至很多人从业多年几乎一次都没用过。

最常见的是用在超链接里:

<a href="javascript:void(0);">超链接</a>

点击链接后不会跳转,有时用 JS 来实现超链接的功能。
当然 href 留空也可以,但部分浏览器可能会有默认行为,比如跳到页面顶端。


void 可以减少代码行数:

下面示例中的函数,它只有一行代码,但是写成箭头函数形式时却要占用 3 行:

const someFunc = () => {
  doSomething()
}

如果简写成这样,可能不太好:

const someFunc = () => doSomething()

这种写法,会导致 someFunc() 是一个带有返回值的函数,别人很可能会误用。
此时,便可以使用 void 关键字:

const someFunc = () => void doSomething()

这样的效果便和第一个示例一样了。

这个用法也适用于 React 组件:

<Button onClick={() => void doSomething()}>按钮</Button>

还可以用在 IIFE 的场景:

// IIFE 直接这样写,会报语法错误,一般都是用括号包起来:
function () { console.log('hello') }()

// 前面加上 void 关键字就能正常运行了
void function () { console.log('hello') }()

// 还可以用更简短的波浪符号(按位取反符号)
~ function () { console.log('hello') }()

这个用法在实际生产中不推荐,建议还是套上括号运行。


早期 JS 规范不完善的浏览器版本中,undefined 是一个 window 上的全局变量,可以被改写。
此时,使用 void 0 关键字便可以得到 “原始” 的真正的 undefined 值。

避开 JS 标准库中的坑

开发时养成习惯,避免撞到这些坑。
本章节部分节选自我之前写的博文 《JavaScript 标准库备忘》

encodeURIComponent() 不符合规范,它转码的字符不全

JS 的 encodeURIComponent() 函数不会对 !'*() 这五个字符转码;不符合规范。示例:

encodeURIComponent("!'()*") 
// 结果:"!'()*"
// 这五个字符完全没有转码

可以用下面这个函数修复:

function fixedEncodeURIComponent (str) {
  return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
    return '%' + c.charCodeAt(0).toString(16);
  });
}

运行测试:

fixedEncodeURIComponent("!'()*")
// 结果:"%21%27%28%29%2a"

decodeURIComponent('%21%27%28%29%2a')
// 结果:"!'()*"
// 可以正常解码

可以看到 decodeURIComponent() 可以正常解码,不需要修复。

在中国时区,传仅含日期的字符串调用 new Date(),时间是早 8 点而不是 0 点

如果使用的日期字符串仅包含日期不包含时间,那么创建出的 Date 对象是相对于世界时 0 时的地区时。
例如,中国是 GMT+8 时区,所以时间就是早上 8 点。

示例:

new Date('2024-01-01')
// 结果:Mon Jan 01 2024 08:00:00 GMT+0800 (中国标准时间)
// 在中国时区,创建出的对象时间是早上 8 点

如果想避免这个问题,创建出 0 点时间,建议:

new Date('2024-01-01 0:')
// 结果:Mon Jan 01 2024 00:00:00 GMT+0800 (中国标准时间)

new Date(2024, 0, 1)
// 这种调用方式月份从 0 开始
// 结果:Mon Jan 01 2024 00:00:00 GMT+0800 (中国标准时间)

或者使用 dayjs 等库,它们不会有这个问题。

不能用 number.toFixed() 来四舍五入

老生常谈的问题了,它既不是四舍五入,也不是银行家算法四舍六入。
实际开发中建议用 lodash_.round()

JS 的按位运算是基于 32 位有符号整数来取结果的

举例:

0b1 << 30 // 结果:1073741824

0b1 << 31 // 结果:-2147483648
// 此时补码最高位为 1,所以变成负数了

0b1 << 32 // 结果:1
// 溢出了

但是 JS 的数值类型本身支持的位数是很大的,位数远不止 32 位:

2 ** 31 // 结果:2147483648
2 ** 32 // 结果:4294967296
2 ** 33 // 结果:8589934592

Number.isNaN() 和全局的 isNaN() 存在区别

具体关系如下:

isNaN === Number.isNaN       // false
isFinite === Number.isFinite // false

parseInt === Number.parseInt     // true
parseFloat === Number.parseFloat // true

两个 “is” 开头的函数是不一样的,两个 “parse” 开头的函数是全等的。

这里给出前者的区别:

  • 全局 isNaN(),如果传入的值本身是数值,或者能转化为数值,那么返回 false,否则返回 true
  • Number.isNaN() 只在传入 ±NaN 时返回 true,除此之外一切输入均返回 false

对于 isFinite() 而言也一样,全局的会尝试把输入转化为数值,而 Number 上的方法只要输入的不是数值,统一返回 false