纸飞机的信笺
博客Awesome开源Demos制品库

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

  1. 做好工具选型
  2. 明确环境变量的来源
  3. 对象存储都使用 S3 API 并带上 MIME
  4. 写死的对象类参数放置在组件以外
  5. 组件中的参数解构更好的用法
  6. 组件外显字段用 ReactNode 类型
  7. 培养良好的语法习惯
  8. 方法类参数改为支持异步方法
  9. 多写 function,少写 const
  10. 尝试 void 关键字
  11. 避开 JS 标准库中的坑
  12. encodeURIComponent() 不符合规范,它转码的字符不全
  13. 在中国时区,传仅含日期的字符串调用 new Date(),时间是早 8 点而不是 0 点
  14. 不能用 number.toFixed() 来四舍五入
  15. JS 的按位运算是基于 32 位有符号整数来取结果的
  16. Number.isNaN() 和全局的 isNaN() 存在区别

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

2024年 2月 21日JS#JS#DOC#WebAPI#Scaffold留言: ...

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

做好工具选型#

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

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

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

  • 自行开发工具函数;
  • 使用原生 URLSearchParams 对象,为了避免环境不支持可以考虑引入 polyfill;
  • 使用 qs 和 query-string 这类工具。

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

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

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

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

此外,npm 上有很多包仅仅有一字之差,但是区别却很大,例如:querystring 和 query-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 工具都会在例如 serve、start、dev 等启动指令时注入 NODE_ENV = development 的变量; 而在 build、dist、prod 等启动指令时注入 NODE_ENV = production 变量。

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

'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 为空。此时,可以根据启动指令手动设置变量,或使用 dotenv、cross-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 { PutObjectCommand, S3Client } 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-Type 是 application/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] 这种年-月-日的数组格式。 假设我们需要根据当前的 year 和 month 来生成选择 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 的列表仅依赖 year 和 month 两个数字。这两个数字不变时,不会重新计算 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。

修订记录

  • 2026年 5月 6日
    feat(blog): 博客支持日期、类别、标签分组;更新博客文章页面
  • 2026年 1月 14日旧版 Hexo 博客
    chore(website): 新的用户名,替换全局的 jia-niang 和 paperplanecc 为 chiskat
  • 2025年 1月 23日旧版 Hexo 博客
    refactor(tab): 所有 Tab 现在替换为了空格
  • 2024年 6月 3日旧版 Hexo 博客
    feat(article): 《JS 优良习惯》篇更新
  • 2024年 4月 21日旧版 Hexo 博客
    feat(article): 更新大部分文章中的 JavaScript、TypeScript 拼写
查看全部修订

留言