持续更新,记录 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
。
示例:
|
如果你的应用需要读取 .env
文件,建议使用这个包。
以 create-react-app
举例,它的依赖项 react-scripts
中有 一系列代码 用来读取 .env
文件并解析,其中也用到了 dotenv
,因此 cra 项目可以读取 .env
中的变量,并在 process.env
中访问;在 vue-cli
中也能找到 类似的代码。
此外,dotenv
默认只读取 .env
文件,如果需要添加 .development
或 .local
这一类后缀,需要传参数。
代码示例:
|
如果不希望将读到的环境变量混入 process.env
,则可以这样:
|
如果应用开发者并没有使用
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
指令的代码中,开头就是 这些代码:
|
如果你的 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:
之前的代码:
|
这样写,深度绑定的云服务供应商。以后如果换成阿里云或者其他家的云服务,那么这段代码可能要大幅改写。
现在换成了 S3 API 的写法:
|
这样写,即使以后换了存储服务的厂商,只需要修改这里面的密钥等参数即可,不需要大幅改写代码。
使用这类对象存储上传文件时,要注意尽量带上文件的 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 文件也同理。
培养良好的语法习惯
开发时养成习惯,利好团队,一劳永逸。
组件外显字段用 ReactNode
类型
开发常见场景:
开发一个组件,例如卡片,用户名等信息作为组件参数用于外显,开始使用 string
类型接收参数,满足需求:
|
后续产品经理要求,部分用户名可能会标红、标绿,甚至前面带个 Icon。
例如产品要求用户名前面加上 VIP 标记,此时我们可能会这样写:
|
但是 name
参数只接受字符串,所以这里要么使用 // @ts-ignore
之类的注释,要么就去修改组件源码。
如果一开始这个组件的 name
字段的类型就定为 ReactNode
,使得用户名可以展示复杂内容,那么在后续开发中就不需要再来改一次了。
所以我们可以养成这样的习惯:
所有需要在组件中仅用于外显的字段,尽量使用 ReactNode
类型,而不是 string
。
方法类参数改为支持异步方法
原因也和上一条类似,提前做好兼容,避免以后这样的需求来临了,再回来改代码。
举个例子:vue-router
的 导航守卫 有一个 router.beforeEach()
方法,传入一个回调函数,每次路由变化时都会调用这个函数,这个函数可以返回 false
来阻止这次路由操作。
这个函数就支持传入一个异步函数,导航的时候会等待该异步函数返回。这便给了我们开发时很多便利,例如可以发送一个请求来判断用户权限等。开发类似功能时,可以参考 vue-router
的这个设计,接受函数参数时兼容异步函数。
多写 function
,少写 const
直接给出结论:方法能使用 function
声明的,尽量都使用这个写法,而不是用 const
后面接箭头函数。
例如:
|
当然,如果你的函数要作为构造函数,则必须使用 function
;而如果你的函数代码中需要访问外部 this
,则必须写成 const
和箭头函数。
这些场合是必须用特定的写法,没得商量。
推荐使用 function
原因有很多:
function
声明的函数是有 “名字” 的函数,const
定义的是一个箭头函数,打印输出时是没有名字的;在特定的调试场合,function
函数更容易定位到位置;- 只有
function
写法的函数可以使用arguments
参数; - 如果使用 TypeScript,只有使用
function
关键字声明的函数支持函数重载(这时也正好会需要用到arguments
参数); function
写法可以把方法返回值类型也放在开头;对于异步函数而言,通过async function
可以一眼认出这是异步函数;const
写法的函数没有这么直观;function
可以被 JS 引擎自动 “提升”,对代码放置的先后位置不敏感,比如我们可以把工具类的函数放到文件最尾部,避免影响业务代码的阅读。
尝试 void
关键字
这个关键字很少见,甚至很多人从业多年几乎一次都没用过。
最常见的是用在超链接里:
|
点击链接后不会跳转,有时用 JS 来实现超链接的功能。
当然 href
留空也可以,但部分浏览器可能会有默认行为,比如跳到页面顶端。
void
可以减少代码行数:
下面示例中的函数,它只有一行代码,但是写成箭头函数形式时却要占用 3 行:
|
如果简写成这样,可能不太好:
|
这种写法,会导致 someFunc()
是一个带有返回值的函数,别人很可能会误用。
此时,便可以使用 void
关键字:
|
这样的效果便和第一个示例一样了。
这个用法也适用于 React 组件:
|
还可以用在 IIFE 的场景:
|
这个用法在实际生产中不推荐,建议还是套上括号运行。
早期 JS 规范不完善的浏览器版本中,undefined
是一个 window
上的全局变量,可以被改写。
此时,使用 void 0
关键字便可以得到 “原始” 的真正的 undefined
值。
避开 JS 标准库中的坑
开发时养成习惯,避免撞到这些坑。
本章节部分节选自我之前写的博文 《JavaScript 标准库备忘》。
encodeURIComponent()
不符合规范,它转码的字符不全
JS 的 encodeURIComponent()
函数不会对 !'*()
这五个字符转码;不符合规范。示例:
|
可以用下面这个函数修复:
|
运行测试:
|
可以看到 decodeURIComponent()
可以正常解码,不需要修复。
在中国时区,传仅含日期的字符串调用 new Date()
,时间是早 8 点而不是 0 点
如果使用的日期字符串仅包含日期不包含时间,那么创建出的 Date
对象是相对于世界时 0 时的地区时。
例如,中国是 GMT+8 时区,所以时间就是早上 8 点。
示例:
|
如果想避免这个问题,创建出 0 点时间,建议:
|
或者使用 dayjs
等库,它们不会有这个问题。
不能用 number.toFixed()
来四舍五入
老生常谈的问题了,它既不是四舍五入,也不是银行家算法四舍六入。
实际开发中建议用 lodash
的 _.round()
。
JS 的按位运算是基于 32 位有符号整数来取结果的
举例:
|
但是 JS 的数值类型本身支持的位数是很大的,位数远不止 32 位:
|
Number.isNaN()
和全局的 isNaN()
存在区别
具体关系如下:
|
两个 “is” 开头的函数是不一样的,两个 “parse” 开头的函数是全等的。
这里给出前者的区别:
- 全局
isNaN()
,如果传入的值本身是数值,或者能转化为数值,那么返回false
,否则返回true
; Number.isNaN()
只在传入±NaN
时返回true
,除此之外一切输入均返回false
。
对于 isFinite()
而言也一样,全局的会尝试把输入转化为数值,而 Number
上的方法只要输入的不是数值,统一返回 false
。