JavaScript 十进制库选型

众所周知,计算机以二进制存储数字,而真实世界中用十进制表示数字。二进制和十进制的差异可以用一个乍看非常怪异的例子说明:

> 0.1 + 0.2
0.30000000000000004

不管是 JavaScript, Python 还是 Java,都能够得到类似的结果。

分析原因,就像十进制中的 1/3 是无限循环小数,二进制中的 1/10 和 1/5 也是无限循环小数。计算机的基本数字类型都是有精度限制的。两个无限循环小数相加,导致精度损失,产生了 0.30000000000000004 这样的结果。

在很多情况下,这个并不会有实际影响。因为十进制同样有精度误差,十进制并不比二进制更精确。它真正导致的问题是违反直觉。对于人直接可见的数字计算,比如购物车结算总价,出现二进制精度问题显然会让不了解二进制原理的普通用户感到诧异。

另外,原生浮点数的取整方法都是四舍五入。按照统计学规律,大量累加起来,总数会偏大。对于电商,银行和支付系统,会导致交易的某一方长期多付出,是违反公平的。因此这些系统常常采用银行家算法(四舍六入五成双)。

最后,整数和浮点数都有固定的精度限制,且受限于硬件或编译器无法更改。计算超出范围的大数会出错,不安全。

基于这三点原因,在电商和金融系统中,会使用十进制类而非基本数据类型来计算和存储交易数字。

JS 常用库比较

JavaScript 目前仍然没有原生的十进制类。我们只能从一些提供十进制类的 NPM 包中选择。不同的十进制库提供的功能不完全相同。通常功能越多体积越大。目前开发比较活跃且比较流行的 JS 库都来自同一位作者。差异详见此处

NPM 包big.jsbignumber.jsdecimal.js
打包尺寸 Minified + Gzipped2.9kB8.1kB12.3kB
精度控制N位小数N位小数N位有效数字
除法精度损失有损有损有损
加、减、乘法精度损失无损无损有损
超大数支持,计算较慢支持,计算较快支持,计算较快
超小数支持,计算较慢支持,计算较慢支持,计算较快
舍入模式ROUND_DOWN
ROUND_HALF_UP
ROUND_HALF_EVEN
ROUND_UP
ROUND_UP
ROUND_DOWN
ROUND_CEIL
ROUND_FLOOR
ROUND_HALF_UP
ROUND_HALF_DOWN
ROUND_HALF_EVEN
ROUND_HALF_CEIL
ROUND_HALF_FLOOR
ROUND_UP
ROUND_DOWN
ROUND_CEIL
ROUND_FLOOR
ROUND_HALF_UP
ROUND_HALF_DOWN
ROUND_HALF_EVEN
ROUND_HALF_CEIL
ROUND_HALF_FLOOR
EUCLID
指数计算支持支持支持
对数计算不支持不支持支持
复数计算不支持不支持支持
三角函数计算不支持不支持支持

从简单到复杂排列:big.js < bignumber.js < decimal.js

从打包体积来看,big.js 最小,在浏览器端占据优势。

在电商和金融场景,更常见的精度控制方法是控制 N 位小数,big.js 和 bignumber.js 更加合适。而在科学计算和工程领域,可能 decimal.js 的控制 N 位有效数字的特性会更合适一些。

除法都是有精度损失的,这是无法避免的。但是 decimal.js 的加减乘法也有精度损失,这一点需要注意。

big.js 对于超大数的计算会比 bignumber.js 或 decimal.js 慢,要结合具体场景分析是否会性能瓶颈。在前端/客户端场景,或者小型电商后端,计算量很小,且大数不常见,那么 big.js 完全可以胜任。在大型电商平台和金融交易后端,大数计算量繁重,那么 bignumber.js 会更好一些。科学和工程领域需要的超大数计算,则 decimal.js 会更合适。(但是这些领域通常也不会用 JS 这么慢的语言,所以 decimal.js 的定位有点尴尬)

超小数通常只在金融,科学和工程领域出现,这种数据的计算 decimal.js 会比 big.js 和 bignumber.js 快。和上面的大数应用场景分析类似,要视情况选择。

默认的舍入模式都是四舍五入(ROUND_HALF_UP),且都支持银行家算法——四舍六入五成双(ROUND_HALF_EVEN)。对于大多数电商和金融场景都足够用了。

decimal.js 还支持对数,复数,三角函数等高级数学运算。

按应用场景选择

中小型电商

中小型电商的交易数字都偏小,big.js 在前后端都可以胜任,满足精度控制,舍入模式和性能的需求。同时 big.js 的体积较小,有利于优化前端传输速度。

大型电商平台和金融系统

前端/客户端通常只处理单一客户的计算需求,大数出现频率较少,通常不存在性能问题。 big.js 依然可以胜任。

后端则可能会频繁处理大数,这时候 bignumber.js 会更快一些。但是,这种情况通常表明整个系统都面临性能问题,更加实际的做法是将重度计算的服务用 Java 之类的更高性能语言重构……

科学计算和工程领域

decimal.js 功能更加丰富,对极大和极小数计算的性能更好,比 big.js 和 bignumber.js 更合适。

但是,这些领域通常对性能要求很高,用 JS 的情况非常少见。建议直接使用一种更合适的语言……

总结

前端选择 big.js 一般都没错。它简单小巧,却又能提供所有我们需要的功能。

后端 Node.js 不需要考虑打包尺寸,如果有比较多的大数计算量,也可以上 bignumber.js 的。

decimal.js 的定位则比较尴尬,除非你特别需要某些科学计算功能,否则没有必要选它。

实践

  1. 阿里巴巴 Fusion Design 从 bignumber.js 切换到 big.js PR 链接

Linux 科学上网指南:DNS

最近经常出现 Firefox 可以打开 GitHub 网站,但是命令行 Git 无法拉取/推送代码的情况。甚至开了 VPN 也不行。运行 ping 命令发现 github.com 被解析到了 127.0.0.1,这是 DNS 被劫持了。是谁搞的鬼,相信你一定懂得。

为啥 Firefox 可以打开 GitHub

最新的 Firefox 桌面版默认启用了 DoH(DNS over HTTPS),通过向特定的服务器发送 HTTPS 请求获取域名的 IP 地址。这就绕过了电信/联通/移动等提供的有毒 DNS 服务器。因为 DoH 采用 HTTPS 协议,不容易被劫持。另外国内用 Firefox 的很少,这个技术并不普及,所以暂时没有被老大哥盯上。

但是很可惜,DoH 目前无法在 Linux 系统层面支持,运行命令行仍然是使用 ISP 提供的 DNS 服务器。电信/联通/移动经常抽风,甚至某些路由器都会给你下毒,让你打不开网页或者直接跳到某网址导航。

为啥开了 VPN 也不行

因为通常是先连接本地网络,这时候已经从 ISP 获取了一个 DNS 服务器。再连接 VPN,(以 OpenVPN 为例)仍然是用的这个 DNS 服务器。

某些商业 VPN 客户端是会在连接上 VPN 之后自动更换 DNS 服务器的。但是 Linux 自带的网络管理是没这个能力的。

因此,如果你只用 Linux 自带的网络管理,最好的解决方法依然是手动设置一个可靠的 DNS 服务器。

DNS 服务器哪家强

如果你去网上搜,很多老的文章会推荐这两个:

  1. Google 的 8.8.8.8
  2. Cloudflare 的 1.1.1.1

这两个 DNS 还是能用的,但是在有些地方不太稳定,甚至直接连不上。

目前还是推荐国内正规企业提供的 DNS 服务器:

  1. DNSPod/腾讯云 119.29.29.29
  2. 阿里云 223.5.5.5

并不能保证腾讯和阿里的 DNS 百分之百可靠,但是比电信/联通/移动好太多。起码 GitHub 是可以正常解析的。

如何设置 DNS

这里就只介绍普通桌面用户用 NetworkManager 和 KDE 的用法。GNOME 基本类似。用 Wicked 的都是技术大佬,相信也不用看下面这些了。

  1. 从系统托盘网络图标右击,打开“网络设置”。
  2. 选择你的有线或者 WiFi 链接,进行编辑。
  3. 切换到 IPv4 标签页。
  4. 将“方法”从“自动”改成“自动(仅网络地址)”。
  5. 将“DNS服务器”改成“119.29.29.29”。
  6. 点“应用”并关闭“网络设置”窗口。
  7. 点击系统托盘的网络图标,打开网络列表,断开并重新链接。

注意,如果需要频繁更换不同的 WiFi 链接,则需要对不同的 WiFi 配置添加自己的 DNS 服务器。

如果是自己家的网络,可以在路由器上配置,方法和上面类似,都是配置 IPv4 方法和 DNS 服务器。(注意,路由器的 WAN 互联网和 LAN 局域网设置都要配一下)这样家里的设备就不用单独配置了。

清空 DNS 缓存

因为 DNS 记录是有本地缓存的,即使你更换了 DNS 服务器,依然会优先从缓存里取 IP 地址。所以更换 DNS 之后,需要清空 DNS 缓存。

这是一个比较头疼的问题,因为各家 Linux 发行版用来管理 DNS 的方式不一样,清空 DNS 缓存的方法也不一样。

最通用的方法:重启系统。

如果不想重启系统,那么可以参考这篇文章 https://www.techrepublic.com/article/how-to-flush-the-dns-cache-on-linux/

我大概总结一下,就是逐个试下面的命令:

sudo systemd-resolve --flush-caches
sudo systemctl restart nscd
sudo systemctl restart named

如果没有用的话,还是重启系统吧。

测试 DNS 解析

首先用 nslookup 测试一下 DNS 服务器是否能解析,如果解析出来的 Server 是你之前配的地址,Address 不是 127.0.0.1 或者 0.0.0.0 这种,应该就是好的。

$ nslookup github.com
Server:         119.29.29.29
Address:        119.29.29.29#53

Non-authoritative answer:
Name:   github.com
Address: 20.205.243.166

但是 nslookup 能解析,并不意味着就能连上。实际连接还要看 ping 命令。注意 github.com 并不回应 ping 请求,也就是数据包都会 lost,这是正常的。只要 ping 能解析到 IP 地址就行了。

$ ping github.com
PING github.com (20.205.243.166) 56(84) 字节的数据。
^C
--- github.com ping 统计 ---
已发送 3 个包, 已接收 0 个包, 100% packet loss, time 2049ms

如果 ping 没问题,最后再试一下 git pull 命令。如果不能访问,则需要试试 VPN 了。

前端最佳实践:字体

字体单位

px 依旧是最全面的选择,尤其是重交互的网站:

  1. 能够做到设计稿的像素级还原。
  2. 用户体验统一,变量少,可控。
  3. 方便配合 JS 动态计算布局,JS 接口通常只能获取像素为单位的尺寸和位置。
  4. icon,图片和 border 通常都是使用像素尺寸,字体采用像素单位更容易配合。

rem 在文字内容型网站上更加灵活,适合新闻,博客等:

  1. 自适应不同设备,不同用户偏好,获得最佳阅读体验。
  2. 无需手写繁复的 media query。

有些创意内容网站使用 vw 尺寸系统,字体和图片总是同窗口比例缩放,以实现类似海报布局的整体感,但也仅限于这一类型的网站。

em 和 % 会用在局部使用,但存在嵌套后尺寸不容易控制的问题,极少在项目中大范围使用。

pt 是印刷常用单位,不适合现如今的 Web 环境,且和 px 对应关系复杂,应该避免。

正文字体大小

12px 是 Chrome 浏览器默认支持显示的最小字体。即使 CSS 设置了 9px,最终用户看到的仍然是 12px 字体。因此在设计中使用小于 12px 的字体是一个严重的错误。12px 的英文字体可读性尚可,但是中文可读性比较差。建议作为次要文本字体的尺寸。

13px 是信息密度较高,中文可读性尚可的选择。比如 Facebook 和百度用的就是 13px。

14px 是信息密度和中文可读性的一个比较好的平衡点,适合界面复杂且空间比较紧张的网站的正文字体大小。比如

16px 是大部分浏览器的默认字体大小,可读性好,但是信息密度不高,适合一般网站。

18px 以上通常只有在创意营销网站上才会作为正文字体大小使用。

一个比较通用的策略是:

  • 12px:次要文字,页脚链接,标签分类,面包屑,输入框提示,小按钮,小输入框
  • 14px:普通文字,正文,输入框,按钮
  • 16px:突出文字,卡片标题,大按钮,搜索栏

标题层级字体大小

HTML 支持 H1 到 H6 共六级标题。但实际应用中,我们应当控制标题的层级数量,层级越多,使用体验会越差。

功能为主的网站对标题依赖比较小,更多是靠 Card 和 Tab 等容器进行界面层级管理。

内容型网站对标题层级的需求会更高一些,但也尽量不超过三级。

H1 字体大小取决于网站类型对信息密度的要求。以功能为主的系统通常 H1 会比较小,不会超过 40px。

H1, H2, H3 之间应当有 30% 左右的递减,视觉上差异才够显著。比如:

  • H1, 36px:页面标题
  • H2, 24px:章节标题
  • H3, 16px:子章节标题,卡片标题,弹窗标题

字重

最早字体只支持两种字重,正常(normal, 400)和粗体(bold, 700)。现代 Web 字体的字重支持 100 到 900 等 9 种字重,100 最细,900 最粗。但是系统字体支持的字重数量通常较少。

Windows 默认中文字体“雅黑”支持 300, 400 和 700 三种字重。macOS 和 iOS 默认中文字体“苹方”支持 100/200/300/400/500/600 六种字重(苹果更喜欢使用较细的字体设计)。也就是说“苹方”的粗体,会比“雅黑”要细一些。Android 系统默认字体 Noto Sans CJK 支持 200/300/400/500/700/900 六种字重,最为全面。部分 Android 系统,比如魅族 Flyme 和小米 MIUI,甚至搞出了字重的无极调节。

由于中文字体体量巨大,通常网站不会加载远端中文字体,而是使用系统字体。因此,在使用字重的时候,就需要考虑系统字体的字重支持。综合主流操作系统,得出兼容性最好的字重集合:细体 300,正常 400,粗体 700/600

变更日志 Changelog 的书写方法

变更日志(Changelog)是软件工程中的一种常用手段,用于记录每个软件版本所发生的变更。一方面,可以方便下游及时兼容;另一方面,让协作开发者和用户了解项目进展。变更日志需要打版本号,但并不一定要遵循 Semantic Versioning。

最简单的形式

  1. 推荐使用 Markdown 语法,文件名为 CHANGELOG.md。
  2. 用二级标题标记版本号,建议标注发布日期,以便日后回溯。合并主干分支但尚未发布的,用“未发布”标题,放在开头。
  3. 对于不兼容的变化,用“破坏性变更”标记。
# 变更日志

## 未发布

- 修复 Button

## 2.0.0 - 2021-09-17

- **破坏性变更**: Table 组件重命名为 Grid

## 1.1.0 - 2021-03-21

- Card 组件支持 size (small/large/medium)
- 新增 List 和 Table 组件

## 1.0.0 - 2021-02-08

- 新增 Card 组件
- 新增 Button 组件

增加模块标记

对于模块比较多的库或应用,变更项目会很多,不容易阅读。比较好的方式是在开头标记这个变更属于哪个模块,不属于任何模块的用 Other 标记。比如说你的应用有很多页面,则可以按页面来划分。如果是组件库,则可以按组件来分。如果是单个复杂组件,则可以按属性或功能来分。

## 2.1.0 - 2021-09-17

- Card: 增加 size (small/large/medium) 属性
- List: 新组件
- Button: 修复 loading 样式
- Input: 增加 warning 图标
- Other: 修复 IE 11 兼容性

细分变更类型

实践中,分类越详细,开发者维护变更日志的工作量越大。不分类是完全可行的,只有当你有充足的时间和很高的追求的时候,再考虑。

有很多种方法可以给变更分类。

按特性(Feature)和修复(Fix)分类是最简单可行的,也更符合当下软件工程的模型,容易配合各种项目管理工具。最重要的是,通常没有什么争议。

## 2.1.0 - 2021-09-17

### 特性

- Card: 增加 size (small/large/medium) 属性
- List: 新组件
- Input: 增加 warning 图标

### 修复

- Button: 修复 loading 样式
- Other: 修复 IE 11 兼容性

如果你听说过 Keep a Changelog ,它提供的分类方法要复杂得多。

## 1.0.0 - 2022-02-02

### 新增

- List: 新组件
- Input: 增加 warning 图标

### 更改

- Card: large size 的 padding 从 12px 调整为 16px

### 移除

- Card: align 属性,用 headerAlign 和 footerAlign 替代

### 废弃

- Input: focus 属性,建议用 Ref 

### 修复

- Button: 修复 loading 样式
- Other: 修复 IE 11 兼容性

对大部分项目来说,这样做意义不大,会消耗开发者更多的精力。因此建议变更不做细分,或者按照上面特性+修复的简单分类方法。

为什么不用 Git 历史

大多数项目的 Git 历史 commit message 含义不清,很难去阅读。

为什么不用项目管理系统

JIRA 和 AONE 之类的项目管理系统,或者 NPM 这样的软件包平台,确实能够在一定程度上记录软件发布历史。但实际项目中,会出现不得不从一个平台迁移到另一个平台的情况。这些发布记录有时候并不能随代码迁移到新平台。另外,软件分发给客户或者下游之后,他们并不一定能访问我们内部系统的发布历史。

React 页面加载速度优化(非SSR)

React 应用页面首次加载通常比后端渲染静态页面慢,这对用户体验有着极大的负面影响,尤其是移动端。服务器端渲染(SSR)是非常有效的手段,但实现成本过高,不止要调整技术架构,也会增加服务器负载。那么在不使用 SSR 的前提下,如何优化 React 应用的首屏加载速度呢?

加载过程分析

使用 Firefox 开发者工具网络面板对页面加载过程中的网络流量进行监控,是最常用的分析方法。

注意右侧的进度瀑布图有两根线条,一根红色,一根蓝色:

  • 蓝色是 DOMContentLoaded 事件触发的时间点
  • 红色是 load 事件触发的时间点

但是对于 React 应用而言,以上两个事件并不标志着页面内容已加载完成。React 应用通常会需要请求一些 JSON 接口(比如商品信息),然后再加载 UI 组件(比如商品列表)。实际加载时间,我们可以通过 performance.now() 接口在组件实际挂载(componentDidMount 或 useEffect)时获取:

import React, { useEffect, useState } from 'react';

function App() {
  const [products, setProducts] = useState([]);
  useEffect(() => {
    fetch('api/products').then(res => res.json()).then(setProducts);
  }, []);
  if (products?.length > 0) {
    return <ProductList products={products} />
  } else {
    return <div>Loading...</div>;
  }
}

function ProductList({ products }) {
  useEffect(() => {
    console.log('ProductList loaded in ' + performance.now()/1000 + 's');
  }, []);
  return products.map(({name}) => <div>{name}</div>);
}

本地的测试分析不能反映实际用户的加载速度。建议将加载时长结果上报到服务器,进行统计分析,制作按日/小时的趋势图,以及按加载秒数的分布图。统计结果将有助于判断后续的优化工作的效用有多大。

数据加载优化

首屏展示依赖的数据,后端直接注入到 HTML 中,从而减少网络请求数量。

<script>
  window.user = {{ JSON.stringify(user) }};
  window.productInfo = {{ JSON.stringify(productInfo) }};
  window.translations = {{ JSON.stringify(translations) }};
</script>
<script src="path/to/js"></script>

要小心控制注入 HTML 中的数据数量,过大的 HTML 可能会导致页面加载失败的概率增加。首屏不展示的数据,或者不重要的数据要排除:Select 的数据源,弹窗中用到的数据,通知数据等。翻译数据最好按首屏需要做一次切分,且只透出当前用户语言。

从实践经验来看,这一项措施,对加载速度提升最为明显。

图标加载优化

icon font 是图标加载方式的主流,但加载字体文件会多发出一个请求。且字体请求通常是在 CSS 中触发,会阻塞页面加载。另外,字体文件很难根据首屏内容进行切分,不够灵活。某些大型应用,图标高达上百个,加载数 MB 的图标字体会让体验变得很差。

更好的方案是用 SVG Sprite 直接嵌入 HTML 中,或用 Inline SVG 直接嵌入 JS 中,减少一次网络请求,同时只加载首屏需要的图标。

打包尺寸优化

首先,建议所有使用 Webpack 4 的项目迁移到基于 Webpack 5 或者 Rollup 的构建工具,以使用 ES6 Module 的特性进行 Tree Shaking,去掉未被使用的部分,缩减产物尺寸。

对于 lodash 这种非 esm 包,建议用 lodash-es 替代或者采用分量引用:

// Bad
import _ from 'lodash';

// Good
import get from 'lodash/get';

最后,使用 Webpack Bundle Analyzer 插件分析产物尺寸,识别对产物体积影响较大的包。

  1. 存在多版本的包,考虑升级依赖版本进行优化。
  2. 尺寸过大的包,考虑轻量级替代品,比如 moment -> date-fns/day.js

代码分割

注意:不支持 IE,Edge < 16,Firefox < 60,Chrome < 61 和 Safari < 11

常见的构建工具都支持以下语法进行代码分割:

import React from 'react';
import Header from './Header';
const Description = React.lazy(() => import('./Description'));
const Comments = React.lazy(() => import('./Comments'));
const FeedbackDialog = React.lazy(() => import('./FeedbackDialog'));
const Footer = React.lazy(() => import('./Footer'));

function ProductDetailPage() {
  return <div>
    <Header/>
    <Description/>
    <Comments/>
    <FeedbackDialog/>
    <Footer/>
  </div>
}

对于首屏需要展示的组件,Header,我们用传统的引用方式打包,在页面加载时直接加载其 JS 和 CSS。而对于不会首屏展示的组件,用 ES6 模块动态加载。这样做之后,首屏加载的 CSS 和 JS 尺寸会缩小,从而加快首屏展示。

参考 React 官方文档

但实际项目中,构成项目产物的大部分来自第三方包,比如 React,Moment 和 Ant Design 这样的库,也就是 vendor chunk。如果你的业务代码产物 app chunk 在 gzip 压缩之后不超过 200KB,做代码分割的意义就不大了。

总结

在实际工作中,由于技术架构限制或是资源限制无法使用 SSR,我们还是有很多手段可以去快速优化 React 应用加载速度。

本文没有涉及 HTTP2,缓存策略,CDN 等优化传输速度的方法,因为这些是 DevOps 考虑的范畴,在大的 IT 公司中,都已经做得比较到位了,前端开发者不需要亲历亲为。

GKD 350H 掌机的游戏移植

开源掌机可以理解为是带手柄按键和屏幕的嵌入式开发板,软件开发的方法都是一样的。移植游戏时,我们不止要保证能够编译通过,运行通过,还要让游戏适配掌机的分辨率,以及映射掌机的按键。

我玩过的第一台开源掌机是借的同学的 Gameshell,首发售价 1200 RMB。它很强大,用的是全志的 ARM 芯片,运行完整的 Debian 系统。因此可以直接 SSH 进去,安装需要的开发工具,直接编译和修改游戏即可,非常方便。

第二台开源掌机是我淘的二手 GKD 350H,全新的首发售价 399 RMB。这台掌机性能就弱很多了,用的是君正的 MIPS 芯片,运行特制的精简 Linux 系统。掌机本身的系统无法 SSH,无法安装开发工具链,性能也不支持它从事复杂编译。只能在电脑上用 buildroot 构建好开发工具链,然后交叉编译。

老张的 GKD 350H 和 GKD mini 用的相同的芯片和系统,理论上它们的构建工具和移植过的游戏也是通用的。不过我手头没有 GKD mini,不能保证兼容性。

周哥的 RG 350/351/280 系列,我手头没有设备,以后入手了可能会移植一下。

构建 Buildroot

GKD 350H 官方并没有开源系统,但是这块芯片很常见,所以 GitHub 出现了很多为 GKD 350H 或 GKD mini 定制的 buildroot。我用的是这个:

https://github.com/gameblabla/gkdmini_buildroot

首先你的电脑需要安装至少以下工具:

  • git
  • make
  • gcc
  • bc

我的电脑是 openSUSE Linux 系统,只要用包管理器安装即可。不太了解 Windows 和 macOS 要怎么安装。

然后,我们下载并构建 buildroot (国内同学建议全程开启 VPN,耗时较长,可以晚上睡觉前开始构建):

git clone git@github.com:gameblabla/gkdmini_buildroot.git
cd gkdmini_buildroot
make gkdmini_defconfig
make

构建完成之后,我们打开 output/host 这个目录,它就很像 GNU/Linux 发行版的目录结构。在 output/host/bin 目录下面,我们能够找到交叉编译所需要的工具链,比如 mipsel-linux-gcc。buildroot 推荐的使用方法是把 output/host/bin 添加到 $PATH 环境变量中:

# ~/.profile
export PATH=~/git/gkdmini_buildroot/output/host/bin:$PATH

在我们编译游戏的时候,要把工具链配置成 buildroot 提供的工具链路径。

移植 2048

2048 是当年在程序员之间很流行的小游戏。最初版本是网页游戏(JavaScript),Libretro 项目有一个 C 版本。它足够简单,依赖的库也少,所以我把它作为第一个练手项目。

make HOST=mipsel-linux CC=mipsel-linux-gcc CXX=mipsel-linux-g++ platform=linux

移植 Craft

Craft 是一个简化的 Minecraft 类沙盒游戏。我

前端构建工具测评

2022-04-02 更新:增加测评 JS Compiler 的测试数据

2022-03-21 更新:增加测评 Bundler 和 JS Minifier 的测试数据


前端构建工具已经形成了一个或多个复杂的生态系统。从最基础的 webpack, rollup 和 babel, esbuild, swc 等工具库,衍生出各种各样的插件,封装组合成 crate-react-app, vite, parcel, snowpack 这样通用解决方案,进而发展出 Next.js, ICE.js, UMI.js 这种特异化的行业解决方案。

这个生态中的每个部分,都有其替代品。比如 swc 可以替代 babel, rollup 可以替代 webpack。swc 确实比 babel 快得多,但是插件生态不如 babel 全面,两者互有胜负。项目的实际需求,决定了构建工具的选型。

随着前端工程越来越大,构建性能和热加载性能逐渐下降,大型项目不得不面临两个选择:

  1. 对仓库/应用进行拆分,采用微前端等复杂技术整合多个子应用。这样固然能够减少每次发布构建的时间,但是会增加开发人力开销。由于影响了项目架构,严重依赖人工,无法大批量复用到多个项目。
  2. 提升构建工具的性能,比如用 esbuild 这种原生程序替代 babel 这种 Node 程序,用 Vite 这种 Bundleless 工具替代 webpack-dev-server。相比迁移项目架构,改造构建工具的成本更低,完全可以通过迁移脚本自动化。

这篇文章会介绍和评测当前各种流行的构建工具,从底层库到完整解决方案,帮助大家选择和定制最适合自己的方案。

Bundler

Bundler 是能够通过 import/export 将很多 JavaScript 文件,打包成一个/几个文件的工具。在前端开发中,也经常会 Bundle 其他文件类型,比如 CSS/LESS/SCSS,以及图片和字体。这些部分通常是以 Bundler 插件的形式提供。因此 Bundler 通常是一个平台,可以通过插件来扩展特性。

Webpack 是最流行的 Bundler。Rollup,Parcel 则是后起之秀。以编译器见长的 esbuild 和 swc 也提供了 Bundle 能力,但是插件生态不足。

特性比较

spack 还处在实验阶段,功能缺失非常多,因此不建议在任何实际项目中使用。

esbuild 稍好一些,如果你不需要 CSS Modules,不用代码分割,不需要支持 IE,也不需要输出 UMD,那么 esbuild 是可以满足你的需求的。 对于个人项目来说,这完全是可行的。对于企业项目,esbuild 作为 bundler 显然还不够全面,但它作为编译器和 Rollup 配合使用还是很好用的。

parcel,rollup 和 webpack 是功能最为全面的,能够应对绝大多数前端项目需求。webpack 的插件生态最丰富,兼容性也最好。rollup 和 parcel 则更加轻量化。Parcel 的 UMD 打包格式缺失,不适合用于组件库或微前端构建,但是对普通应用还是够用的。

webpackrollupparcelesbuildspack
CSS Modules✔️✔️✔️✔️
LESS✔️✔️✔️✔️
Sass✔️✔️✔️✔️
代码分割✔️✔️✔️
CJS 输出格式✔️✔️✔️✔️✔️
ESM 输出格式✔️✔️✔️✔️✔️
UMD 输出格式✔️✔️

性能比较

测试打包 antd + lodash + react + react-dom + three.js 的速度。为了控制变量,统一采用 esbuild 作为 minifier(spack 不支持除外)。

详细测试数据见 https://github.com/guoyunhe/benchmark-bundlers

webpackrollupparcelesbuildspack
ThinkPad T4805.347s14.548s6.069s0.249s1.082s
MacBook 16, 20194.229s12.790s5.225s0.227s1.012s

esbuild 是用 Go 语言编写,spack(swcpack)是用 Rust 语言编写。两者都编译成二进制文件运行,速度比其他 JS 编写的 Bundler 快几十倍。从 20s 到 0.2s 已经是一个质的飞跃了。

相比之下,传统的 JS Bundler 就慢得多了。令人意外的是,被很多人认为过时了的 webpack 居然比 parcel 和 rollup 更快。尤其是 rollup 的性能,令人意外地差。我们分析是因为 lodash 和 react 都是 CJS 模块,rollup 需要将其转化成 ESM 才能使用,因此比 webpack 慢了一倍多。

综合评价

目前 webpack 仍然是最全面的 Bundler 选择,支持特性最丰富,生态最为庞大。而且它的性能并不差,在应用打包场景要优于 rollup 和 parcel。

而放眼未来,esbuild 的潜力最大。它的性能无与伦比,且具有成熟的插件接口。它的代码分割能力已经在测试中,前途一片光明。甚至 Vite 也希望在 esbuild 成熟之后,移除 rollup 而直接用 esbuild 打包。

JS Minifier

Production 构建需要对 JS 文件和 CSS 文件进行 Minify 处理,让生成的文件更小,加载更快。JS 的 Minifier 包括 terser,esbuild,swc。衡量它们优劣的主要指标是速度和压缩率。

以下测试用 webpack + terser/esbuild/swc 打包和压缩 antd + lodash + react + react-dom+three.js。详情见 https://github.com/guoyunhe/benchmark-js-minifiers

no-minifyterseresbuildswc
产物尺寸4.7MB1.8MB1.9MB1.9MB
运行时间 ThinkPad T4804.677s17.046s5.615s8.997s
运行时间 MacBook 16, 20193.556s13.834s4.052s6.892s

esbuild 和 swc 在速度上大幅度领先 terser。对于这类高计算量任务,Node.js 的性能比原生程序差得多。同为原生程序,esbuild 比 swc 的性能更好,说明它的设计更优秀。而从压缩率上来看,terser 略好于 esbuild 和 swc 但差距不大。因此综合来看,我认为 esbuild 是最佳的 minifier。

JS/TS Compiler

我们在开发中喜欢使用最新的 ES 语法,但在发布时需要兼容旧的浏览器。JSX 和 Vue 文件还包含了特殊语法,即使最新的浏览器也无法识别和运行。因此越来越多的前端项目需要用 JS Compiler 或者叫 Transpiler 来转换源码。这也是一项非常耗时的任务。目前最广泛使用的编译器是 babel,它功能全面,但性能堪忧。而 esbuild 和 swc 则是以速度见长的面向未来的编译器。TypeScript 有自己的编译器 tsc,支持 TypeScript 的类型系统检查,但是它的效率极差。因此 Babel, esbuild 和 swc 都提供了自己对 TypeScript 转译的支持。

特性比较

从特性上来看,babel 仍然是最全面的。esbuild 和 swc 则注重对最新 ES 标准的支持,因此少了对非标准的 JS 修饰器语法(但是支持 TS 修饰器语法)和 Flow 语言的支持。esbuild 不兼容 ES5 和 IE 浏览器,但某些项目仍然有这方面的需求,开发者需要平衡利弊。

babelesbuildswc
ES Next
ES5, IE11
JS 修饰器语法
Flow 语言
TypeScript 语言
TS 修饰器语法
JSX

性能比较

以下测试用 Webpack + babel-loader/swc-loader/esbuild-loader 转译 antd + three.js 源码到 ES5 语法,不进行 minify。详见

babelesbuildswc
运行时间 ThinkPad T48010.133s0.464s0.549s
运行时间 MacBook 16, 2019

综合评价

如果不需要支持 IE11,那么 esbuild 是最好的选择,它的性能非常好。

如果需要支持 IE11,那么推荐 swc,性能接近 esbuild 且浏览器兼容性更好。

只有当 esbuild 和 swc 都无法支持你的项目需求时(比如用到了 JS 修饰器语法),再考虑 babel。babel 虽然功能最为全面,但性能比 esbuild 和 swc 慢几十倍,已经无法支持大型项目了。

Dev Servers

Webpack Dev Server

Webpack Dev Server 的出现,极大提升了 React 应用开发的效率:修改代码时,组件会自动被替换,无需手动刷新,也不会重新加载页面。然而早期的 Webpack 和 Webpack Dev Server 每次修改文件都会重新编译打包,给人越来越慢,又无能为力的痛苦感受。Webpack 5 和 Webpack Dev Server 4 已经在性能方面有了极大的改善。中小型项目的瓶颈大多不是 Webpack 本身造成,而是上面介绍过的 Compiler 和 Minifier。如果结合 swc 和 esbuild,Webpack Dev Server 仍然很能打。

但是在大型应用场景,Webpack Dev Server 还是有不足的。想象一下,你有一个包含几万行代码的 SPA 应用,且没有做代码分割。Webpack Dev Server 会将所有代码编译打包之后才能启动。这样一来,启动速度就会变得很慢。但实际上,我们每次只开发项目中很小的一部分,余下的页面/弹窗等组件并不需要提前编译加载。

但是这也并不是我们一定要抛弃 Webpack Dev Server 理由。大型应用场景,也极少硬着头皮上 SPA,而是用微前端框架将应用做进一步的切分。开发时 Webpack Dev Server 只需要运行子应用即可。

但是如果我们就是要做巨石 SPA 应用呢?在本地开发场景下,可以利用浏览器原生的 ES Module 能力动态地按需加载模块,而不需要编译打包所有内容。这样是不是就可以解决我们上面遇到的那个问题了呢?理论上行得通,但实际效果不尽如人意。

Snowpack

Snowpack 就是这样一个设计。理论上动态加载本地 JS 文件几乎没有延迟。但是这些模块并不都能直接在浏览器中运行。比如有很多 NPM 依赖是 CJS 而非 ESM 模块,我们写的代码有 JSX 和 TypeScript,更不要说还有 Sass,LESS,CSS Modules 这些要处理。因此动态加载每个文件时,都有一定的等待时间,而且无法并行处理。结果是在冷启动过程奇慢。在中小型项目开发中,不及 Webpack Dev Server 有效率。大型项目。而且,它并不能构建部署生产环境,仍然需要使用 Webpack 或 Rollup 来补足,无疑增加了项目维护成本。

因此在实际项目中,很难看到 Snowpack 的身影。Snowpack 本身的开发也陷入了很长时间的停滞不前。不过,Snowpack 的思路被 Vite 和 Parcel 2 所继承并改进,取得了成功。

Vite

Vite 给出了更好的答案:将依赖模块预先用 esbuild 进行 Bundle 以快速加载,项目模块采用 Unbundle 模式动态编译加载。

但是,Vite 对不同前端框架的支持程度是不同的,实际运行效率也差别很大。它的 Vue 插件用 esbuild 编译,速度极快。而它的 React 插件用的是 Babel 编译,速度一般。

另外我们发现,Vite 的缓存校验机制存在比较大的问题。更新 node_modules 中的依赖之后,Vite 缓存并没有更新。在开发过程中只能禁用缓存,这导致开发效率进一步降低。

Parcel 2

Parcel 2 和 Vite 的思路类似,但是有两个重要改进:

  • 缓存范围更广,效率极高,热启动在 1s 内
  • 采用了 swc 而非 babel 编译,构建和刷新速度更快

这两个优势使得 Parcel 2 在几乎各个性能指标上都领先于其他竞争对手。

性能比较

TODO

TypeScript 枚举 Enum

JavaScript 本身是没有枚举的,Enum 是 TypeScript 带来的特性。

虽然早在 TypeScript 2.4 就已经提供了 Enum 但是并没有被广泛使用。TypeScript 一开始就有 Union Type,且语法更简单。因此 Enum 在 TypeScript 并不是必需的,出现较晚,且语法更加复杂,阻碍了它的流行。

但是,在大型项目中,Enum 有很多不可替代的优势,结合编辑器和IDE的功能,让类型的使用和维护更加轻松。

和 Union Type 比较

在 Enum 诞生之前,我们经常使用 Union Type 来表示枚举类型:

type Role = 'superadmin' | 'admin' | 'editor' | 'contributor';

const myRole: Role = 'editor';

如果改写成 Enum 的话:

enum Role {
  SuperAdmin = 'superadmin',
  Admin = 'admin',
  Editor = 'editor',
  Contributor = 'contributor',
}

const myRole = Role.Editor;

乍一看是不是代码繁琐了很多?那为什么我们还需要用 Enum 呢?Enum 和 Union Type 相比有两个优点:

  1. 可以对每个枚举成员添加注释
  2. 可以快速且精准地修改值和重命名

项目越大,类型也就会越多,相应的术语也就会越多。对每个枚举值成员添加注释,能够帮助新加入的开发者快速熟悉项目。尤其是国内的项目,中文的项目文档和英文代码之间的联系,注释必不可少。

/** 用户角色 */
enum Role {
  /** 超级管理员,可以增删管理员,配置和删除项目 */
  SuperAdmin = 'superadmin',
  /** 管理员,可以管理除了管理员外的成员角色 */
  Admin = 'admin',
  /** 编辑,可以直接编辑内容 */
  Editor = 'editor',
  /** 贡献者,可以参与讨论和提交补丁 */
  Contributor = 'contributor',
}

const myRole = Role.Editor;

当我们需要修改枚举值时,比如将 superadmin 改为 owner,Enum 就会非常简单。只需要将枚举值更改,而不需要去更改其他引用的地方。然而对于 Union Type,我们要小心翼翼地查找和替换每个出现的地方。加入我们想把 SuperAdmin 替换成 Owner,也只需要用 IDE 和编辑器提供的重命名/重构功能,快速而精确地重命名。这种维护的便利,也是 Enum 最大的魅力。

阴间滑步

墓地

在大山的深处,有一个与外界少有往来的村庄,村民们过着自给自足的生活。一天,落难的传教士们在逃亡时误入此地。传教士们不擅长劳作,却会讲故事。他们编造了一个谎言,称村民们若不捐银钱求一块被祝福过的墓地,便会在死后受尽无数的折磨,灵魂不得安宁。村民们自此更加拼命劳作,开垦更多的土地,种植更多的粮食。即使如此,也少有人能捐得起一块墓地。久而久之,越来越多的人因辛劳而早亡。墓地越来越满,价格也水涨船高。人们开始互相抢夺和厮杀,而传教士们满载着钱财和粮食离开了。