从 Webpack 到 Snowpack, 编译速度提升十倍以上——TRPG Engine迁移小记

动机

TRPG Engine经过长久以来的迭代,项目已经显得非常臃肿了。数分钟的全量编译, 每次按下保存都会触发一次10s1m不等的增量编译让我苦不堪言, 庞大的依赖使其每一次编译都会涉及很多文件和很多包,长时的编译时间大大降低了开发效率与迭代速度。

优化方式

经过一段时间的考察,我选择了Snowpack作为解决方案。与Webpack不同的是,除了第一次的全量编译以外,Snowpack的增量编译不会涉及到庞大的node_modules文件夹, 准确来说只会编译变更文件本身。甚至于如果没有对依赖进行变更,下次的全量编译会直接动用之前编译的文件缓存,不需要花时间等待node_modules的编译。

为什么会这么快?这是由于Snowpack本身的实现与设计哲学有关的。相比Webpack, Snowpack利用了现代浏览器的本身的module系统,跳过复杂的模型之间的组织编译过程而只关注于变更文件本身的编译,这样当然快了。

Snowpack官方的一张图来说:

snowpack的最小编译单位是文件,而webpack的最小编译单位为chunk, 而chunk还需要额外的计算, 不论是编译部分还是编译后的组装部分。snowpack的设计逻辑天生决定了她的速度。

优化前(使用webpack):

全量编译:

增量编译:

全量请求用时:

优化后(使用snowpack):

全量编译:

增量编译:

(看不到编译用时,但是体感在1s内. 而且该效果在电脑运行其他应用时更加显著)

全量请求用时:

  • 使用http1

  • 使用http2

以上测试是保证电脑在空闲时间,且保存与操作内容为同一文件

该用时已经是平时操作的最快时间,为此我的MBR重启了一次强制清空了swap空间, 实际表现会更加显著

因为文件依赖于浏览器的耗时,而浏览器需要串行请求依赖,因此耗时会更加长

但实际使用中使用snowpack会更加优秀。因为其相比webpack会大大节约电脑资源。在webpack编译时会占用大量的电脑资源,会影响到其他操作

遇到的坑与解决方案

TRPG Engine算是非常经典的Webpack应用了, 使用了各种Loader。光通用配置就有250+行,各种优化配置,各种alias。等等长时间迭代积攒下来的配置,因此毫不意外的会遇到很多问题与坑。

以下是我遇到的问题与解决方案:

  • 问题1:
    • 入口文件使用的是HtmlwebpackPlugin编译的handlebars文件,而snowpack不支持handlebars文件作为入口
    • 解决方案:重写一个snowpack专用的入口文件。使用handlebars主要解决的是dll的问题,snowpack不需要处理这部分的优化因此直接跳过
  • 问题2:
    • snowpack加载文件策略与node不同。有同名文件和文件夹会优先使用文件夹的index.js作为路径解析。具体看现象可以参考这个讨论: https://github.com/snowpackjs/snowpack/discussions/1320
    • 解决方案:改名字,让文件夹与文件名不会出现重复。包括同名但是大小写不同的问题,因为底层是nodefs.stat实现,在大小写敏感的系统下依旧会视为同名
  • 问题3:
    • TRPG Engine不但有web端,还有react-native端,而react-native是无法被正常解析的。我只想要处理web端的开发环境使用snowpack优化开发体验
    • 解决方案: exclude配置手动过滤
  • 问题4:
    • tspath不支持,虽然有了@snowpack/plugin-typescript但是不支持tspath。
    • 解决方案: 手动写了一个自动解析的逻辑将其变成对应的alias加到配置上
  • 问题5:

    • 在css中引入了字体文件,但是无法正常加载。因为snowpack无法正确识别url指定的资源并将其打包(webpack是使用css-loader来实现的)
    • 解决方案:
      1
      2
      3
      scripts: {
      'mount:font': 'mount src/web/assets/fonts --to /main/fonts',
      },
  • 问题6::

    • 对于一些特殊的写法我不想影响webpack的实现但是snowpack不支持这种写法。比如使用externals实现的配置引入, 比如DefinePlugin实现的process.env(在snowpack中必须使用import.meta.env), 再比如require的使用
    • 解决方案: 我实现了一个snowpack-plugin-replace插件用于将这些东西全部替换成我想要的代码。具体使用如下:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      [
      'snowpack-plugin-replace',
      {
      list: [
      {
      from: /process\.env/g,
      to: 'import.meta.env',
      },
      {
      from: `require("../../package.json").version`,
      to: '"0.0.0"',
      },
      {
      from: `const resBundle = require("i18next-resource-store-loader!./langs/index.js");`,
      to: 'import resBundle from "./langs/zh-CN/translation.json"',
      },
      {
      from: 'import Config from "config";',
      to: `const Config = ${JSON.stringify({
      sentry: require('config').get('sentry'),
      })};`,
      },
      ],
      },
      ],
  • 问题7:

    • rollup抛出无法解析this的警告
    • 解决方案: 使用context指向window来移除警告
      1
      2
      3
      4
      5
      installOptions: {
      rollup: {
      context: 'window',
      },
      },
  • 问题8:

    • snowpack打包目标路径与原有的build文件夹冲突
    • 解决方案: 修改输出目录为.snowpack并在gitignore中添加该文件夹
      1
      2
      3
      devOptions: {
      out: '.snowpack',
      },
  • 问题9:

    • 使用@snowpack/plugin-typescript内部包对全局变量的声明会出现重复声明的报错
    • 解决方案: tsconfig"skipLibCheck": true
  • 问题10:
    • 现有的依赖需要@babel/plugin-transform-runtime提供的helpers作为全局依赖
    • 解决方案: 经检查是用到了regenerator功能,手动安装regenerator-runtime并在包前引入import 'regenerator-runtime/runtime';
  • 问题11:
    • 部分依赖在其中部分代码使用了require作为引入方式, 而snowpack无法正确处理require
    • 解决方案: 检查后发现都已经修改。升级依赖到最新版即可
  • 问题12:
    • 在使用less的import逻辑无法正常运行,这是由于snowpack的具体实现决定的。
    • 暂时无法解决,使用snowpack-plugin-replace将其替换为css文件导入作为临时解决方案, 见讨论: Github

总结

Snowpack虽然作为一个新兴的打包工具,目前尚不是非常完善, 功能也没有webpack这样丰富与齐全。但是它的新的打包设计对于有一定规模的前端应用还是非常优秀的。能极大提升开发效率。不失为一种好的解决方案。当然最后输出还是需要使用webpack对其进行一定的优化,毕竟原生的module支持目前浏览器的支持度还没有达到覆盖一个理想的地步https://caniuse.com/es6-module

最后这是我最后提交的pr

TRPG Engine —— 一个功能完善即时通讯解决方案

项目背景

除了传统的聊天软件,还有为固定需求打造的定位其他的聊天软件

比如钉钉立足于工作流,slack专注于程序员之间的项目沟通。而TRPG Engine就是一款为了小众的跑团玩家所打造的通用即时通讯解决方案

项目亮点

  • 基于XML描述的人物卡系统(可以理解为动态表单) - Playground
  • Web端与RN端共享redux状态的实践与工具链
  • 多端并存与代码共享(Web端, RN端, Portal端 (Portal端是RN端通过webview进行一部分中间操作的方式,类似于各种手机App的H5端) )
  • 其他的一些自研实用工具,如RN端WEB端通用Portal组件, 快速生成通用表单, 基于BBCode的消息解释器, 通用缓存管理机制
  • 工程化代码,可拓展性强
  • 移动端兼容与PWA

依赖

  • MySql 5.7+
  • Redis

功能列表

通用功能

  • 用户登入登出
  • 用户注册
  • 私聊/群聊
  • 头像上传与裁剪
  • 用户设置
  • 好友管理
    • 好友发送邀请/同意邀请
  • 基于BBCode的消息解析器
    • url内容
    • 图片内容
    • @提及
  • 多种消息类型
    • 通用消息
    • 提示消息
    • 卡片消息
  • 消息回复与消息撤回
  • 自动抓取消息内的网址的预览信息
  • 基于slate的富文本编辑器
  • 消息通知
    • 移动端基于upush。包括本地进程未被杀死的本地推送与本地被杀死后的upush推送
  • 多种文件管理策略
    • 头像上传七牛云或本地
    • 聊天图片使用外置图片服务转发到第三方图床
    • 聊天文件存储在本地,定时删除
  • 单向聊天消息机器人
  • 群组多面板
    • 多面板类型: 目前有笔记面板与文字频道
    • 面板的编辑/删除/拖拽排序
  • app热更新与apk更新
    • 热更新基于自部署的codepush服务器, apk更新会自动获取最新的apk版本
  • app下载管理
  • 多国语言(中英, 尚未完全覆盖)

跑团相关

  • 基于Slate的笔记系统
  • 人物卡系统
    • 基于XML的布局描述与内置JS沙盒解释器来解释js脚本
    • 人物卡的切换与切换时发送消息变换头像与名字
    • 人物卡分享与Fork
  • 投骰表达式与消息拦截器
  • 输入时向所有人发送输入状态
  • 在线招募系统

线上监控

  • 计划任务记录
  • 接口耗时统计
  • 请求限流
  • 系统日志: 日志会被转发到loggly或本地记录。其他的操作相关会存储到数据库
    • 用户登录记录
    • 机器人记录
    • 投骰记录
    • oss文件记录
  • 登录/注册统计汇总
  • 前端后端错误汇报

项目规模

  • 开发时间: 3年
  • 所用数据表: 61张
  • 功能完整的多端:
    • 两版网页端
    • 基于React Native的安卓端

预览

在线地址: https://trpg.moonrailgun.com/
开源地址: https://github.com/TRPGEngine/Client

又拍云整站网页存放到OSS的解决方案

背景

前后端分离后允许前端页面静态访问,因此整站放到OSS上成为可能

但是有一个问题就是要如何实现原来单页应用上刷新页面无法获取到正确的网页文件的问题

该问题在nginx上的解决方案是这样的:

1
2
3
location / {
try_files $uri /index.html;
}

解决方案

又拍云边缘规则编程模式下配置规则

1
$WHEN($NOT($MATCH($_URI,'[\\.]')))/index.html

表示所有连接都返回根目录的index.html文件

更加复杂的场景

对于实际场景中,可能有多个应用并存,比如我的TRPG Engine中就有多个单页应用存在一个域名下

三个应用:

  • /index.html 主应用
  • /playground/index.html playground页,只有一个页面
  • /portal/index.html portal页, 会有多个页面存在

我给出配置如下:

优先级1: playground break

1
$WHEN($EQ($_URI,'/playground'))/playground/index.html

优先级2: portal break

1
$WHEN($ALL($MATCH($_URI,'^/portal'),$NOT($MATCH($_URI,'[\\.]'))))/portal/index.html

优先级3: 单页应用

1
$WHEN($NOT($MATCH($_URI,'[\\.]')))/index.html

其中break的意思是满足一个条件则不会继续往下匹配

参考链接

参考链接: https://cnodejs.org/topic/5badd93037a6965f59051d40

记一次重启网络服务后网络不通的调试

背景

我的网络服务端是用docker搭建的Nginx服务器。在一次重启网络服务service network restart后监控机器人告知服务挂了。检查以后发现服务可以ping通,查询日志无异常,docker服务反复重启也无法使其正常生效。考虑到使用的是弹性公网会不会是虚拟网络之间的问题,经排查后也不存在该问题。

解决方案

在google找方案的时候受到启发,输入netstat -tunlp检查监听端口。果然没有只有一条tcp6 :::80的监听而没有应当有的0.0.0.0:80的监听。那么问题就出在端口方面,而我docker的状态明明写的是0.0.0.0:80->80/tcp啊,为什么会没有监听ipv4的端口?

为使其能够正常运行。编辑/etc/sysctl.conf加上一条以开启ipv4转发

1
net.ipv4.ip_forward=1

如果改行已存在则直接修改.

成功后输入sysctl -p检查变更已成功应用。

无需重启即可生效,检查后发现网络服务已恢复正常

让Chrome显示完整网址

Chrome 76+版本以后默认隐藏 https:// , www无关紧要的标识符

作为开发者,这些信息是非常有价值的。因此需要手动再将其重新开启

在flags页面中将其重新开启

地址栏chrome://flags, 进入flags页面

Chrome 版本小于83

Omnibox UI Hide Steay-State URL Scheme and Trivial Subdomains设置为禁用

Chrome 版本大于等于83

Context menu show full URLs设置为启用, 并右键地址栏选中总是显示完整网址

adb devices无法获取夜神模拟器的解决方案

在开启模拟器的前提下。在终端输入

1
$ adb devices

如果没有看到夜神模拟器的设备。则将${ANDROID_HOME}/platform-tools/adb.exe复制并覆盖夜神模拟器根目录下的adb.exenox_adb.exe

重启后查看效果(重启前确保后台的adb.exenox_adb.exe进程已被关闭)

新服务器必做二三事

主要是给自己一个提醒。一个新服务器需要做哪些必备项目

增加新用户用于常用用户

1
2
$ useradd xxxx
$ passwd xxx

切换到用户 设置为秘钥登录

1
$ su xxx
1
2
3
4
5
$ cd ~
$ mkdir .ssh
$ chmod 700 .ssh
$ vim authorized_keys # 插入登录公钥
$ chmod 600 authorized_keys

关闭密码登录和root用户登录 仅允许密码登录

1
$ exit # 退出到root用户
1
2
$ vim /etc/ssh/sshd_config
$ service sshd restart

设定以下参数:

1
2
PermitRootLogin no # 不允许root用户直接登录
PasswordAuthentication no # 不允许通过密码登录(即仅允许秘钥登录)

可选项目

修改主机名

1
2
$ vim /etc/hostname
$ hostname $(cat /etc/hostname)

快速构建软件文档——docusaurus v2

背景

来源是想要给我的应用做一个首页。需要有文档、首页两项。如果文档中能插入一些比较复杂的内容(如在线预览)这样的功能的话就更好了

开始使用的是docusaurus v1, 后来发现他无法在markdown文档中插入iframe。 会触发一个诡异的bug,导致渲染中断,无法正常渲染后面的内容,因此就中断了。后来发现docusaurus开始开发了v2版。虽然处于Beta阶段但还是能进行初步的使用了。

环境

  • docusaurus v2.0.0-alpha.50

特性

  • 使用React开发。可以体会到现代语言的优势。
  • 内置文档、博客,集成Algolia DocSearch。开箱即用。
  • 完全可定制的组件和样式, 增加完全的可定制化能力
  • MDX实现。在Markdown中也能使用React组件

自定义独立页面

略过初始化内容。我们直接进入自定义的阶段。

docusaurus 的独立页面在src/pages文件夹中, 就和写React组件一样我们可以利用React很轻松的构建属于自己的页面, 首页就是index.js。

对于非模板化的页面。比如首页、showcase、问卷调查这些,都可以通过独立页面来实现。

自定义渲染组件

docusaurus 提供了很强的自定义能力,使用@theme/*引用组件。提供一套预设的组件用于渲染网站的各个部位。其内容在https://github.com/facebook/docusaurus/tree/master/packages/docusaurus-theme-classic

对于该项目中的每一个组件,我们都能进行定制。使用docusaurus swizzle <theme name> [component name]命令将样式包中的组件复制到自己的src/theme文件夹中。可以指定的替换相应组件的实现

如:

1
npm run swizzle @docusaurus/theme-classic Footer

来重写页脚。

@theme/* 引用会以此查找本地的src/theme文件夹的组件,主题包里的组件。

自定义markdown渲染

Markdown是我们写文档重要的工具,有时候Markdown基本的语法无法满足我们的需要,此时就可以自己编写渲染逻辑。这样在不丧失Markdown简洁的前提下给予我们文档更加强大的表现力

比如我就自己写了一个组件, 用于将代码与预览相互转化: https://github.com/TRPGEngine/Server/blob/master/services/Website/website/src/plugins/remark-template-previewer.js

当然还有一种方法是利用MDX的特性在Markdown中引用React组件

成果

更多docusaurus的特性请查阅官方文档

wsl2入坑指南

背景

WSL2支持了docker环境,这给了我一个入坑wsl的理由。在一段时间的配置和踩坑以后我将wsl配置需求记录下来

任务目标:

  • 配置wsl2
  • 在wsl安装docker环境
  • 搭建基本环境

安装

更新Windows10的版本以安装wsl2

WSL 2 仅适用于 Windows 10 版本 18917 或更高版本

因此首先我们要window10版本升级到匹配的版本。目前来说正式发行版无法升级到相应版本,因此需要启用开发者预览版。启用方式是搜索insider打开Windows 预览体验计划的设置页面(在设置的 更新和安全 的最后一项)

根据提示将自己的微软账号注册为开发者账号。在获取预览版本的频率中选择 慢(Slow) 因为我们需要一个相对稳定的版本。

然后手动检测windows更新即可升级到符合条件的版本

PS: 注意, 更新版本以后可能会丢失一些windows的系统设置。需要手动检查并重新设置回来

启用wsl2

管理员身份打开Powershell

首先启用wsl

1
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

重启计算机

以管理员身份打开Powershell

1
2
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart

重启计算机

Microsoft Store搜索wsl下载自己喜欢的Linux系统

注意!!!!
如果你使用wsl2的目的是为了docker。请务必选择Ubuntu 18.04系统! 因为Debian无法正常启动docker服务(其他系统没有测试过)!

在Powershell中查看自己当前已安装的Linux系统与使用的版本

1
2
3
wsl -l -v
# 或
wsl --list --verbose

设置linux系统使用的wsl版本。wsl1和wsl2据开发者所说会一直共存下去。因此要手动分配

1
wsl --set-version <Distro> 2

<Distro> 替换为上面列出的系统名称

将wsl2设置为默认的wsl体系

1
wsl --set-default-version 2

这会使你安装的任何新发行版均初始化为 WSL 2 发行版。

将软件源更换为清华源(可选)

  • 清华源
    1
    2
    sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak
    sudo vim /etc/apt/sources.list

将软件源设置为国内源的话操作会更加流畅

安装Docker

进入wsl, 输入uname -a可以检测版本

快速安装Docker

1
2
3
4
$ curl -fsSL https://get.docker.com -o get-docker.sh
$ sudo sh get-docker.sh
$ sudo service docker start
$ sudo usermod -aG docker $USER

使用service docker status查看docker服务的状态

安装zsh作为常用的shell

1
2
3
4
5
# 安装zsh
sudo apt-get install zsh

# 安装oh-my-zsh
sh -c "$(wget https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh -O -)"

相互访问网络应用

WSL 2 做了架构的巨大变更,使用了虚拟化技术,并仍在努力改进网络支持。由于 WSL 2 现在运行在虚拟机中,因此你从 Windows 访问 Linux 网络应用程序需要使用该 VM 的 IP 地址,反之亦然,你需要 Windows 主机的 IP 地址才能从 Linux 中访问 Windows 网络应用程序。 WSL 2 的目标是尽可能使用 localhost 访问网络应用程序!可以在文档中找到有关如何执行此操作的完整详细信息和步骤。

参考资料

微软: WSL 2 的安装说明
知乎: WSL 2中安装Docker

浅谈节流(throttle)与防抖动(debounce)

简介

节流与防抖是性能优化常见的方式,用于限制一些高频操作导致对cpu无意义的负担。
我搜索了网络上很多相关的文章,都没有办法直观表现出两者的区别。因此我自己总结了一下两者的区别,并用几个例子来解释一下两者区别

一下内容均以 lodash 的实现为例

防抖动debounce

防抖动的核心概念是当停止调用函数一段时间后。调用该函数

example:

1
2
3
4
5
6
const d = _.debounce(() => console.log('call func'), 1000, {leading: true, trailing: true});

const dt = setInterval(() => {
console.log('call timer');
d();
}, 200);

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
call func # 如果 leading 为true
call timer
call timer
call timer
call timer
call timer
...
call timer
call timer
call timer
(执行clearInterval(dt))
(等待1s后)
call func # 如果 trailing 为true

即如果在限定时间(本例为1s)内不断调用防抖函数,则永远不会触发1s后的trailing的输出

节流throttle

节流的核心概念是一段时间内,函数最多只能被调用一次。

example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const t1 = _.throttle(() => console.log('call func'), 1000, {leading: true, trailing: true});
t1(); // 立即输出call func

const t2 = _.throttle(() => console.log('call func'), 1000, {leading: false, trailing: true});
t2(); // 一秒后输出call func

const t3 = _.throttle(() => console.log('call func'), 1000, {leading: false, trailing: false});
t3(); // 无反应

const t4 = _.throttle(() => console.log('call func'), 1000, {leading: true, trailing: true});
const t4t = setInterval(t4, 200); // 立即输出call func, 之后每秒输出一次

const t5 = _.throttle(() => console.log('call func'), 1000, {leading: false, trailing: true});
const t5t = setInterval(t5, 200); // 一秒后输出call func, 之后每秒输出一次

const t6 = _.throttle(() => console.log('call func'), 1000, {leading: false, trailing: false});
const t6t = setInterval(t6, 200); // 一秒后输出call func, 之后每秒输出一次 因为内部是通过 debounce 的 maxWait来实现的

如果在 throttle 规定的时间内有多次触发的话。必定会在时间段结束时触发函数调用
如果只有一次触发的话。如果 leadingtrailing 都为 true 。只会触发开始的那次函数调用

共同点

对于lodash来说
throttle的实现是通过debounce来实现的

lodash 实现

1
2
3
4
5
6
7
8
9
throttle(func, wait, options);

// 等价于

debounce(func, wait, {
options.leading || true,
options.trailing || true,
'maxWait': wait
});

参考文章

https://css-tricks.com/debouncing-throttling-explained-examples/