Skip to main content

· 5 min read

我在实际项目开发过程中,经常要自测,这个时候并不能完全指望服务端 mock 接口和数据,所以其实一段时间了,前前后后沉淀了有这么个 ts-koa 的应用(koa-lite),多用于用于调试接口和 mock 数据,当然,做个改装,也可以用于正式的服务端项目

当然有人要问,为啥不用 nest、egg 这些脚手架,主要原因很简单吧,这些脚手架有额外的上手成本,你要熟悉他们的代码架构,然后相对应的代码文件也比较多,并且功能很齐全。但是咱不需要那么齐全,毕竟初衷是为了用于个人自测项目和 Node.js 学习,一开始就用那么齐全的库,效果反而适得其反

回到项目本身吧,做了一些基础且顺应技术趋势的事情

支持 ts

用 ts-node 编译 ts 代码,nodemon 可以监听文件和热更(根目录下可以通过 nodemon.json 配置规则,参考 nodemon 的配置文件

入口文件

提供了 http 和 websocket 两种应用(app.ts 和 wsApp.ts),分别占用不同的端口,可以配置不同的 script 命令,比如:

"dev": "nodemon -e ts app",
"dev:ws": "nodemon -e ts wsApp",

可以开两个终端同时启动俩应用

websocket 应用是借助了koa-websocket这个中间件实现的,wsApp.ts的基础代码如下:

import Koa from "koa";
import consola from "consola";
import websockify from "koa-websocket";

const wsOptions = {};
const app = websockify(new Koa(), wsOptions);

app.ws.use((ctx, next) => {
return next(ctx);
});

app.ws.use((ctx) => {
ctx.websocket.send("===Hello");
ctx.websocket.on("message", function (msg) {
console.log("===recive msg", msg);
});
ctx.websocket.on("close", () => {
console.log("===close websocket");
});
});

/**
* Create HTTP server.
*/
const host = process.env.HOST || "localhost";
const port = Number(process.env.PORT) || 3001;

const server = app.listen(port, host, () => {
consola.ready({
message: `websocket server listening on ws://${host}:${port}`,
badge: true,
});
});

http

rest api

借助 koa-router,具体 get、post 接口的实现可以参考项目里的示例

建议不同模块按照前缀区分,然后分为不同文件,在入口文件中引入

//...
app.use(homeRouter.routes()).use(homeRouter.allowedMethods());
app.use(userRouter.routes()).use(router.allowedMethods());

文件接口

借助 koa-body 实现上传。这个中间件不仅用于处理 post 请求的数据,还能处理文件类型,即 multipart/form-data。如果是 koa-bodyparser,还要结合 koa-multer 处理文件数据,而且不能和 koa-body 共用,会有冲突

// app.ts
import koaBody from "koa-body";
//...
app.use(
koaBody({
multipart: true,
formidable: {
// the max size of uploading file
maxFileSize: 200 * 1024 * 1024,
},
})
);
// homeRouter.ts
function saveFile(file) {
// 文件读写流
const reader = fs.createReadStream(file.path);
let filePath = path.join(__dirname, "/static/upload/") + `/${file.name}`;
const upStream = fs.createWriteStream(filePath);
reader.pipe(upStream);
}

homeRouter.post("/upload", async (ctx, next) => {
// console.log(ctx.request.files);
const files = ctx.request.files.file;
// 支持多文件
if (files.length > 1) {
for (let file of files) {
saveFile(file);
}
} else if (files) {
saveFile(files);
}
ctx.body = "Upload successfully.";
});

借助 koa-send 实现下载

import send from "koa-send";
// homeRouter.ts
homeRouter.get("/download/:filename", async (ctx, next) => {
let filename = ctx.params.filename;
let filePath = `./static/download/${filename}`;
// mime type, active the download window in browser
ctx.attachment(filePath);
await send(ctx, filePath);
console.log("download successfully.");
});

之前写过一篇 mock 文件接口的文章,参考 mock 文件接口

跨域处理

借助 koa2-cors 定义允许跨域的规则

import cors from "koa2-cors";
//...
app.use(
cors({
origin: function (ctx) {
// 设置允许来自指定域名请求
if (ctx.url === "/test") {
return "*";
}
return "http://localhost:8080";
},
maxAge: 5,
credentials: true,
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization", "Accept"],
exposeHeaders: ["WWW-Authenticate", "Server-Authorization"],
})
);
//...

模板引擎

基于 koa-viewsejs。当然也可以用其他模板引擎,看个人喜好了,我主要是觉得 ejs 更贴近于 html 的形式(对比 pug,缩进那一套确实不太能 get)

import views from "koa-views";
//...
app.use(
views(path.join(__dirname, "./views"), {
extension: "ejs",
})
);

静态资源托管

基于 koa-static,指定一个单独的文件夹来存放静态资源文件

import koaStatic from "koa-static";
//...
// 静态资源目录(相对入口文件app.ts)
const staticPath = "./static";
app.use(koaStatic(path.join(__dirname, staticPath)));

其他

项目相对比较简单,欢迎 comment 和 fork(别忘了 star 哈)。后面主要是个人使用过程中持续迭代,有好的建议欢迎提呀~

仓库地址: https://github.com/GitHubJackson/koa-lite

· 4 min read

以前其实做过类似的项目构建上的优化工作,但比较散乱,希望能做一个系统的整理,至少划分出几个基本的优化方向,以作为后续项目优化的参考

这里的构建优化,暂时针对的是 webpack 构建的单页面应用项目

现有问题修复

当然要优先解决掉项目现有的问题

代码规范配置

这个是为了提高协作效率,主要是新增以下配置

  • .vscode
  • eslint
  • prettier
  • commitlint
  • husky
  • editorConfig

配置详情可以参考我之前整理的文章 https://blog.zhouweibin.top/FEED/create-react-app/

热更新异常

触发热更新时,控制台会报错 React Uncaught ReferenceError: process is not defined

原因是 react-error-overlay 最新版本已支持 webpack5 ,如果要适配 webpack4,插件需要回退到 6.0.9。问题参考 https://stackoverflow.com/questions/70368760/react-uncaught-referenceerror-process-is-not-defined

后面升级到 webpack5,其实也就自动 fix 这个问题了

支持本地单独打包

之前单独打包后,没法用 serve 调试,排查发现是 package.json 的 homepage 改了资源路径,去除后单独构建就正常了

开发体验

  • 增加构建进度条
  • 增加项目版本输出信息
  • 增强 axios 接口封装函数
  • react 组件局部热更。好像暂不支持 webpack5,留意后续的更新吧

构建速度

分析工具:

create-react-app@5(CRA5)默认做了蛮多构建优化的工作了,后面打算研究下源码看看

升级 webpack5

增加了开箱即用的持久化缓存,是 webpack4 的 hardSourcesPlugin/cache-loader/loader cache的有效替代品,rebuild 速度提升明显。当然还有其他的特性和优化,可以参考 webpack5 升级踩坑

升级前后构建耗时参考:

  • 升级前,首次构建 ≈50s,二次构建 ≈18s;build≈110s
  • 升级后,首次构建 ≈42s,二次构建 ≈3.6s;build≈70s

esbuild-loader

替换 babel-loader,但 CRA5 高度配置的情况下不好替换,估计还得 eject 配置,待定

包体积优化

分析工具:

优化方法

  • 移除不用的依赖
  • 大体积包按需引入
    • echarts 按需引入(210k-->69k)
    • antv/G2 按需加载暂不支持,现在是项目使用的最大的包,后续有计划统一换成 echarts
    • loadsh 属于其他模块引入,暂不处理
    • ant-design 增加按需引用 babel-plugin-import 插件,发现实际并没效果,应该是 antd 本身已支持 tree-shaking,参考这个 issue。antd 的样式应该还是可以通过 babel-plugin-import 进行按需加载,不过 antd5 已经用 css-in-js 重构了样式代码

分包

分包(code-spliting)的目的是防止多个文件(比如路由懒加载产生的多个文件)重复打包公共的依赖,比如 react、vue、antd 等,同时也能更好地利用浏览器并行下载资源来加速首屏

怎么合理地分包是个值得探究的问题 ~

其他

最后

项目构建优化这方面感觉还是有很多东西待探索的,而且不局限于单页面应用,持续更新 ing...

· 4 min read

实际开发项目,经常会遇到文件相关的接口,比如文件上传和下载,这里针对前端总结一些 mock 文件接口在本地调试的方法

文件下载

基于Node.js快速启动一个 web 服务,借助中间件 koa-send 实现下载功能

const path = require('path');
const Koa = require('koa');
const staticServe = require('koa-static');
const router = require('koa-router')();
const send = require('koa-send');
const app = new Koa();
const staticPath = '/static';
const absolutePath = path.join(__dirname + staticPath);

// 指定静态目录
app.use(staticServe(absolutePath));
app.use(async (ctx) => {
ctx.body = 'hello word'
});
router.get('/file/:name', async (ctx){
const name = ctx.params.name;
const path = `static/${name}`;
ctx.attachment(path);
await send(ctx, path);
})

app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
console.log('server is listening in 3000...');
})

koa-send 内部其实用了 fs.createReadStream,以块的形式发送出去,这样前端可以更快地接收数据。fs.readFile 还会将整个文件加载到内存中

前端调用接口

fetch

fetch()
.then((res) => {
return res.blob();
// txt、base64或其他文本类的文件可用以下方式解析
// return res.text()
})
.then((res) => {
// 下载文件示例
let url = URL.createObjectURL(new Blob([res]));
let link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.download = `image.png`;
document.body.appendChild(link);
link.click();
// 解析base64
// const data = window.atob(res)
});

如果是 axios,需要额外配置 responseType: 'blob',其实也就是 XMLHttpRequest 的配置

类似的中间件有 koa-sendfile,主要是解决中文编码的问题

文件上传

前端主要是通过表单来上传文件,但是文件数据在服务器端并不能像普通参数一样通过 ctx.request.body 获取。这里我们可以借助 koa-body 实现上传功能,该中间件的作用主要是将文件数据拼接到 ctx.request.body.files.file

const koaBody = require('koa-body');
// ...
app.use(koaBody({
multipart: true,
// formidable: {
// 默认限制只有2M,超过会报错
// maxFileSize: 100*1024*1024
// }
}));
router.post('/upload', async (ctx){
const file = ctx.request.body.files.file;
const reader = fs.createReadStream(file.path);
const upStream = fs.createWriteStream(`upload/${file.name}`);
reader.pipe(upStream);
return ctx.body = '上传成功';
})
// ...

前端拼接文件数据

const formData = new FormData();
formData.append("file", input.files[0]);
formData.append("user", "Lucas");
fetch(url, {
method: "POST",
body: formData,
});

不起 web 服务

纯 js 由于浏览器的安全考虑是没法直接操作文件的,如果不想启动服务,还可以借助文件选择或拖拽来模拟读取文件接口。以下示例基于 React Hook(只是提供获取文件的方法,具体使用还需要自行验证)

文件选择:

const fileInputRef = useRef();
// ...
// 监听文件选择
fileInputRef.current.onchange = function (event) {
const file = event.target.files[0];
};
// ...
<input type="file" name="test" ref={fileInputRef} />;

拖拽:

const dragDropRef = useRef();
// ...
// 监听
dragDropRef.current.addEventListener(
"drop",
function (event) {
const file = event.dataTransfer.files[0];
},
false
);
// ...
<div ref={dragDropRef} />;

更多前端处理二进制数据的方法可以参考 https://blog.zhouweibin.top/js/%E4%BA%8C%E8%BF%9B%E5%88%B6%E6%95%B0%E6%8D%AE/

· One min read

详细报错如下:

Treating warnings as errors because process.env.CI = true.
Most CI servers set it automatically.
Failed to compile.

看起来 CRA5 会将警告视作报错,也就是更严格的构建要求,解决办法有俩

  • 逐个清理项目构建过程中的警告 ~
  • 构建时修改环境变量 CI

第一种不用赘述,第二种也很简单,在构建命令前设置 CI 为空就行

"scripts": {
"build": "CI='' yarn build"
}

· 6 min read

最近把组内的一个比较大的微前端项目做了下重构。之前是基于 single-spa 结合软链接子应用代码来统一打包,存在以下问题:

  • 基座有额外的维护成本。基座需要适配所有子应用的打包配置,比如 less-loader、ts-loader,这样造成的情况是新增应用的成本较大,有未知的修改基座的成本在,因为可能会修改构建配置
  • 软链接子应用代码的形式,造成打包逻辑较为复杂,中途构建出错需要删除干净所有的软链
  • 不支持子项目使用移动端适配插件,比如 px2rem
  • 不支持子项目定义全局样式,会造成 css 污染
  • css 隔离和 js 沙箱配置难度大

综上,重构目标有几点

  • 去除基座的维护成本。将统一打包改为子应用分别打包,但是会相应地增加一些重复的公共依赖
  • 更靠谱的前端编译。优化前端打包流程,去除软链接,减少前端构建出错的情况
  • 更独立的子项目,涉及样式定义、移动端适配等
  • 更容易接入新的子应用,支持 css 隔离和 js 沙箱

重构步骤

应用部署方式

共用一个端口,通过二级目录划分子应用,主应用通过一级目录路由访问对应的子应用

部署方式参考 https://qiankun.umijs.org/zh/cookbook#%E5%A6%82%E4%BD%95%E9%83%A8%E7%BD%B2

基座引入 qiankun

基座不限技术栈,可以基于 CRA5 新建

yarn add qiankun

修改入口文件 index.ts

import { registerMicroApps, start, setDefaultMountApp } from "qiankun";

registerMicroApps([
{
name: "reactApp",
// 需要确保斜线结尾,防止资源加载异常
entry: "/project/app1/",
container: "#app1-root",
activeRule: "/app1",
},
{
name: "vueApp",
entry: "/project/app2/",
container: "#app2-root",
activeRule: "/app2",
},
]);

// 启动 qiankun
start({
// 去除baidu脚本的跨域限制
// 注意:这种方式只支持异步引入baidu sdk,不支持在script标签引入
excludeAssetFilter: (url) => {
return url.indexOf("api.map.baidu.com") !== -1;
},
});
// 设置默认启动应用
setDefaultMountApp("/app1");

调整 index.html

// ...
<div id="root"></div>
<div id="app1-root"></div>
<div id="app2-root"></div>

异步引入 baidu jssdk

去除 script 标签引入的代码

<!--引入百度地图api-->
<script
type="text/javascript"
src="https://api.map.baidu.com/api?v=3.0&ak=你的密钥"
></script>

改成以下方式:

function loadBaiduJssdk(url) {
return new Promise((resolve, reject) => {
const scriptEl = document.createElement("script");
scriptEl.type = "text/javascript";
scriptEl.src = url;
document.body.appendChild(scriptEl);
scriptEl.onload = resolve;
scriptEl.onerror = reject;
});
}
// 异步加载
async function initMap() {
await loadBaiduJssdk("https://api.map.baidu.com/api?v=3.0&ak=你的密钥");
}

子应用配置

CRA5 新增配置

// config-overrides.js
const setOutputForQiankun = () => (config) => {
config.output.library = `${name}`;
config.output.libraryTarget = "umd";
config.output.globalObject = "window";
return config;
};
// ...
module.exports = {
webpack: override(
setOutputForQiankun(),
// ...
)
devServer: overrideDevServer((config) => {
config.historyApiFallback = true;
config.open = false;
config.headers = {
"Access-Control-Allow-Origin": "*",
};
return config;
}),
}

在 src 下新增 pubilc-path.js(ts 也行)

if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

调整入口文件(src/index.ts),暴露钩子函数。注意根节点要与 container 保持一致(也需要调整 public/index.html,默认根节点 id 为 root)

import "public-path.js";
// ...
function getSubRootContainer(container) {
return container
? container.querySelector("#app1-root")
: document.querySelector("#app1-root");
}

function render(props) {
const { container } = props;
ReactDOM.render(<App />, getSubRootContainer(container));
}

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}

export async function bootstrap() {
console.log("react app bootstraped");
}

export async function mount(props) {
console.log("props from main framework", props);
render(props);
}

export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(getSubRootContainer(container));
}

以上代码基于 react16

react18 可以参考 https://github.com/ice-lab/icestark/issues/581

路由配置

basename 和 qianiun 的配置保持一致,如果是 qiankun 访问,需要追加主应用配置的路由前缀,参考如下

// app.js
// ...
return (
<Provider store={STORE}>
<Router
basename={
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.__POWERED_BY_QIANKUN__ ? "/app1" : process.env.REACT_APP_BASEURL
// 在环境变量中增加路由前缀 REACT_APP_BASEURL
// 和 activeRule 对齐,也就是 /app1
}
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<MainView />}
{/* ...其他路由 */}
{/* 路由重定向 */}
<Route path="*" element={<Navigate to="/" />}
</Routes>
</Suspense>
</Provider>
)

构建加速

因为项目的特殊性,每一次构建都要构建所有子项目,因此可以使用缓存,缓存各项目在主分支上最近可用的 build 包,主要是根据 commitId 判断缓存的有效性

最终达到的效果是只会构建有修改的子项目,其余子项目沿用缓存,无需重新安装依赖和构建

优化 TODO

  • 理论上还能做 node_modules 的缓存
  • qiankun 提取公共依赖,减少子项目重复安装

参考

· One min read

git commit 时,lint-staged(eslint --fix)检查代码时提示 error,无法完成自动 fix。但是返回编辑器发现工作区代码全丢了,重新 commit 提示: nothing to commit, working tree clean

这个是之前遇到的大坑,遇到过两次,资料也很难找到,第一次的时候我是凭热乎的记忆将代码重新改了一遍...

其实是 lint-staged 将代码暂存了起来,解决办法如下:

# 查看stash栈,应该就是第一条
git stash list
git stash apply stash@{0}

之前的代码就恢复了,更详细的解释可以参考 https://stackoverflow.com/questions/60334337/why-are-my-changes-gone-after-a-cancelled-git-commit-and-how-do-i-recover-them

· One min read

公司用的 ubuntu16,经常提示 enter password to unlock your login keyring,解决办法如下:

find ~/ -name login.keyring
# 找到指定的文件,删除它
rm -f [login.keyring path]

重新启动 chrome,输入相同的密码即可,比如都是空格。之后就都不会有提示了

但如果是提示 enter password for keyring 'Default Keyring' to unlock,解决方法如下:

  1. 搜索 password,找到 Passwords and Keys 并点开(或者直接在命令行输入 seahorse 并回车)
  2. 在弹出来的面板中找到 Default keyring(左侧菜单),右键选择 change password
  3. 输入当前的登录密码
  4. 之后重新设置密码或者置空

参考自 https://www.xmodulo.com/disable-entering-password-unlock-default-keyring.html

· 8 min read

这阵子把组内最大的前端项目做了下 webpack 的升级,构建效率直线上升,主要得益于 webpack5 的缓存策略

webpack5 带来了什么?

  • 持久化缓存。webpack4 需要通过 cache-loader/hardSourcePlugin 来实现中间缓存,webpack5 相当于内置了这部分功能
  • 更好的 hash 算法。hash-->fullhash,比如 webpack 4 如果添加空白、注释或修改变量名是会影响 contenthash 值的计算,webpack5 则不会影响,从而能继续使用缓存,这个方式降低了缓存的失效率,间接加快了应用 rebuild 的速度
  • Asset Modules。指的是图片和字体等这一类型文件模块,它们无须使用额外的预处理插件
  • 模块联邦。实现应用级的模块复用,这个是现在蛮多新兴微前端框架的基础
  • tree-shaking 改进。据说可以减少约 30%的 bundle-size,不过实际项目中并没有体会到这个变化
  • 更严格的代码检查。这个也是造成很多 webpack4 项目在升级后突然在运行时报错的原因,会导致页面 crash
  • 确定的 moduleId/chunkId
  • Node Polyfill 脚本被移除
  • 原生 worker 支持。可以参考 react 项目中使用 web worker 里面有提及 webpack5 下如何使用 web worker

开始升级

这个过程也是遇到了一些问题,总结如下:

因为我们的项目是基于 CRA4 搭建的,并且基于 react-app-rewired 扩展 webpack 配置,所以首先需要升级 react-scripts、react-app-rewired、customize-cra

额外引入的 loader、plugin 都要升级到支持 webpack5 的版本。没有支持 webpack5,那就去 github issue 里面看看啥时候支持?

首先是 addLessLoader 不适配,需要更换插件如下:

// config-overrides.js
const addLessLoader = require("customize-cra-less-loader");
//...
addLessLoader({
lessOptions: {
javascriptEnabled: true,
sourceMap: false,
},
}),

可以借助 npm-check-updates 这个插件检查所有依赖版本

去除一些废弃的插件,比如以下的资源插件

  • url-loader 将文件作为 dataURI 内联到 bundle 中
  • file-loader 将文件发送到输出目录
  • raw-loader 将文件导入为字符串

webpack5 通过以下配置就可以完成对资源文件的解析

  • asset/resource 发送一个单独的文件并导出 URL(file-loader)
  • asset/inline 导出一个资源的 dataURI(url-loader)
  • asset 在导出一个 dataURI 和发送一个单独的文件之间自动选择。webpack4 需要通过使用 url-loader 并且配置资源体积限制来实现
  • asset/source 导出资源的源代码

配置方式参考:

// ...
rules: [
{
test: /\.png$/i,
use: 'file-loader'
},
{
test: /\.(jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 1024,
},
},
],
}
],
// 改成
// ...
rules: [
{
test: /\.png$/i,
use: 'asset/resource'
},
{
test: /\.(jpg|gif)$/i,
type: 'asset',
parser: {
dataurlCondition: {
maxSize: 1024 // 单位是B
}
}
}
],

这里可以参考下我给项目做的资源配置

// config-overrides.js
// ...
addWebpackModuleRule({
test: /\.(png|jpe?g|gif|webp|svg|bmp|ttf|eot|woff|woff2)$/i,
type: "asset",
parser: {
dataUrlCondition: {
maxSize: 1024 * 1024,
},
},
generator: {
filename: "img/[name].[hash:4][ext]",
publicPath: process.env.PUBLIC_URL,
},
}),
addWebpackModuleRule({
test: /\.(objs?|mtl)$/i,
type: "asset/resource",
generator: {
filename: "model/[name].[hash:4][ext]",
publicPath: process.env.PUBLIC_URL,
},
}),
// ...

去除 hard-source-webpack-plugin,取而代之的是 cache

cache: {
// memory(内存)|filesystem(持久化缓存)
type: "filesystem",
buildDependencies: {
config: [__filename],
},
version: '1.0'
}

缓存文件会保存在 node_modules/.cache。参考 https://github.com/webpack/webpack/issues/6527

如果配置 filesystem 做持久化储存,webpack5 还是会同时使用 memory,用于 watch 模式

如果是 eject 导出完整 webpack 配置的项目,可能还会遇到以下问题:

部分插件的引入方式需要调整,比如 webpack-merge 改成解构的方式引入

const webpackMerge = require("webpack-merge");
const ManifestPlugin = require("webpack-manifest-plugin");
// 改成
const { merge: WebpackMerge } = require("webpack-merge");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");

如果有单独引用 dev-sever 也要升级,启动命令调整如下:

// package.json
{
"scripts": {
"serve": "webpack-dev-server --config webpack.config.dev.js"
}
}
// 改成
{
"scripts": {
"serve": "webpack server --config webpack.config.dev.js"
}
}

通过 require 引入的图片会无法正常加载,需要在 url-loader 配置中添加 esModule: false

sourcemap 的名称可能也需要调整

devtool: "cheap-eval-module-source-map";
// 改成
devtool: "eval-cheap-module-source-map";

开启 hot: true 热更新无效,需要添加配置如下:

{
target: process.env.NODE_ENV === "development" ? "web" : "browserslist",
}

这像是一个 bug,参考 https://github.com/webpack/webpack-dev-server/issues/2758

除去编译问题后,接下来主要在于解决一些运行时的报错,基本上按照提示一个个去解就行了

JSON 模块只能使用默认引入,调整如下

import { name } from "package.json";
// 改为
import pkgInfo from "package.json";
const { name } = pkgInfo;

其他的问题比如重复的函数或变量、undefined 等,在 webpack5 更严格的检查下会暴露,逐个修复就行了

进一步优化

CRA5 在 webpeck 的配置上已经做了很充分的优化工作,参考源码 https://github.com/facebook/create-react-app/blob/main/packages/react-scripts/config/webpack.config.js

进一步优化的空间其实不多,可以做下 code spilting,配置参考:

// config-overrides.js
module.exports = {
webpack: override(
// ...
isProduction &&
setWebpackOptimizationSplitChunks({
chunks: "all",
cacheGroups: {
react: {
test: /[\\/]node_modules[\\/](react|react-dom)/,
name: "react",
priority: 1,
},
three: {
test: /[\\/]node_modules[\\/](three)/,
name: "three",
priority: 1,
},
antd: {
test: /[\\/]node_modules[\\/](antd|@ant-design)/,
name: "antd",
priority: 1,
},
echarts: {
test: /[\\/]node_modules[\\/](echarts|zrender)/,
name: "echarts",
priority: -1,
},
antv: {
test: /[\\/]node_modules[\\/](@antv)/,
name: "antv",
priority: -1,
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendor",
priority: -5,
},
},
})
),
};
// ...

如果是多页或者多路由页面的应用,还可以结合 React.lazy 进行页面级别的分包,按需加载页面及其资源

总结

实际升级下来,其实问题不多,花个一天左右就填完坑了,可能是 react-scripts@5.0 已经做了大部分工作了,然后疑难问题基本上都可以找到解决方法。升级效果也很明显,主要体现在 rebuild 的速度上。我觉得还没升级的话,可以大胆升级一波,升级完之后就可以尝试 webpack5 的各种新特性啦 ~

· One min read

最近在一个 react 单页面项目(webpack4)里遇到了一个热更新的报错: Uncaught ReferenceError: process is not defined。

原因是 react-error-overlay 这个插件版本(@6.0.11)太高,这个版本是支持了 webpack5,和 react-scripts@4 有不兼容的依赖项导致报错

解决办法是安装 6.0.9 版本

yarn add react-error-overlay@6.0.9

调整 package.json

{
"resolutions": {
"react-error-overlay": "6.0.9"
},
"overrides": {
"react-error-overlay": "6.0.9"
}
}

resolutions for yarn,overrides for npm >= 8.3

如果是用的 npm,并且 6<=版本<8.3,可以用以下方法

npm install npm-force-resolutions --save-dev

调整 package.json

{
"resolutions": {
"react-error-overlay": "6.0.9"
},
"scripts": {
"preinstall": "npm-force-resolutions"
}
}

后续执行 npm install 就会强制 react-error-overlay 为 6.0.9

by the way,升级 webpack5 也不是不行 ~ 只不过坑也蛮多的。上述办法就比较轻松了

· One min read

注意:环境为 macOS

如果你是用 zsh 作为 shell,可能会碰到以下报错:

zsh: corrupt history file ~/.zsh_history

可以尝试按以下命令修复下:

cd ~
mv .zsh_history .zsh_history_bak
strings -eS .zsh_history_bak > .zsh_history
fc -R .zsh_history

当然,要是频率很高的话,可以写成脚本,把以上命令加进去

#!/usr/bin/env zsh
cd ~
mv .zsh_history .zsh_history_bak
strings -eS .zsh_history_bak > .zsh_history
fc -R .zsh_history
rm .zsh_history_bak

比如放到/usr/bin目录下,命名为 zsh_history_fix,之后执行

zsh_history_fix

需要先把 /usr/bin export 出来,追加以下命令到 ~/.zshrc 文件尾部

export PATH=/usr/local/bin:$PATH

source ~/.zshrc

大概率会提示没权限,加一下呗

sudo chmod -R 777 /usr/bin/zsh_history_fix
zsh_history_fix

done.

其他

· 24 min read

最近有新的项目需求,突然想整理下新建项目的这个过程,供以后借鉴,或者给他人提供一些思路 ~

完整项目模板参考 Github 地址

技术调研和选型

技术调研和选型都应该从业务出发,用技术赋能业务。以我们的项目为例,这是一个 pc 项目,主要是对地图进行编辑(绘制图形等),对页面性能要求较高,供内部人员使用,无 seo 需求。最终确定使用的技术栈如下:

  • react17、Hooks
  • react-router v6
  • typescript
  • webpack 5
  • axios
  • mobx。数据管理,因为涉及较多的数据交互,使用 mobx 在性能上更胜一筹,模板代码也少一些。其他的选择有 redux、xstate
  • less、css module
  • ahooks。内置了很多工具 hooks,墙裂推荐 ~
  • 其他的一些必要依赖

包管理器

这个看个人喜好吧,选 yarn 或 npm 都挺好的。个人比较喜欢用 yarn,初始安装速度比 npm 快,具体对比可以参考 https://pnpm.io/zh/benchmarks

搭建完项目后就可以配置.npmrc.yarnrc文件,设置 npm 源为淘宝源,这样安装依赖的时候就不用手动指定了

# .yarnrc
registry https://registry.npmmirror.com/
# 或者建议换成公司内部的 npm 源

搭建项目

基于 create-react-app 脚手架快速搭建一个 使用 typescript 的 react 项目

yarn create react-app my-app --template typescript
# 或者
npx create-react-app my-app --template typescript

安装好后,需要先把所有必要的依赖都先安装上,注意区分开发环境的依赖和生产环境的依赖

接下来在项目模板的基础上完善项目结构,主要是调整 src 的目录结构

src
├─ assets
│ ├─ css # 全局样式
│ ├─ icon
│ └─ image
├─ containers # 页面组件
├─ components # 公共组件
├─ helper # 存放工具函数或者业务抽象函数
│ ├─ utils # 工具函数
│ ├─ hooks # 公共 hooks
│ ├─ service.ts # 请求工具函数
├─ type # 全局类型
├─ store # 状态管理
└─ ...

规范配置

目的是统一代码编写格式和规范,助力团队协作,提高代码健壮性和可读性。按照后面的方法增加完配置文件后的目录结构如下:

├─ src
├─ .vscode # vscode 编辑器配置
├─ .husky # husky 配置
├─ .commitlintrc.js
# 代码检查
├─ .stylelintrc.js
├─ .eslintrc.js
# 代码格式化
├─ .prettierrc
├─ .editorConfig
├─ .gitignore
├─ .npmrc
├─ tsconfig.json

.vscode

现在 vscode 是前端最热门的编辑器,可以约定团队成员统一使用 vscode 编码,然后预设必要的编辑器配置,比如保存自动格式化代码、默认格式化工具采用 prettier 等。在 .vscode 下新建 settings.json

{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

eslint

注意只是让 eslint 检查语法和发现错误而不是纠正格式,代码格式由 prettier 统一。可以让同事多注重下代码质量,而不是依赖格式化修正代码错误

yarn add eslint -D
yarn eslint --init
# 通过命令行交互生成 .eslintrc.js

.eslintrc.js 参考配置如下:

module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
jest: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["react", "@typescript-eslint"],
rules: {
"no-var-requires": 0,
},
};

package.json 增加 script 命令,用于全局检查项目代码

"eslint": "eslint --fix src/**/*.{js,ts,jsx,tsx}",

需要配合 vscode 的 eslint 插件来使用

详细配置参考 https://eslint.org/docs/user-guide/configuring/

stylelint

类似于 eslintstylelint 可以帮助检查和修复 css/less/scss 代码

yarn add stylelint stylelint-config-standard -D

项目根目录下新增 .stylelintrc.js,配置参考如下:

module.exports = {
extends: "stylelint-config-standard",
customSyntax: "postcss-less",
};

增加 script 命令,便于全局检查样式文件:

"stylelint": "stylelint --fix src/**/*.{css,less}",

然后记得在 vscode 中安装 stylelint 插件

prettier

yarn add prettier -D

.prettierrc 参考配置如下:

{
"tabWidth": 2,
"singleQuote": true,
"semi": true,
"trailingComma": "all"
}

增加 script 命令,便于格式化项目代码:

"prettier": "prettier --write src/**/*.{js,ts,jsx,tsx,less,css}"

需要安装 vscode 的 prettier 插件,然后可以选择开启保存时自动格式化代码的功能

详细配置参考 https://prettier.io/docs/en/configuration.html

editorConfig

使用不同编辑器打开同一份文件,如果编辑器配置不统一,显示效果和输入内容很有可能不一致。EditorConfig 就主要用于统一代码编辑器编码风格

.editorConfig 配置参考

# https://editorconfig.org

# 已经是顶层配置文件,不必继续向上搜索
root = true

[*]
# 编码字符集
charset = utf-8
# 缩进风格是空格
indent_style = space
# 一个缩进占用两个空格,因没有设置tab_with,一个Tab占用2列
indent_size = 2
# 换行符 lf
end_of_line = lf
# 文件以一个空白行结尾
insert_final_newline = true
# 去除行首的任意空白字符
trim_trailing_whitespace = true

[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

详细配置参考 https://editorconfig.org/

Git 扩展

  • husky。操作 git 钩子的工具
  • lint-staged。本地暂存代码检查工具,可以让 husky 只检验 git 工作区的文件
yarn add husky lint-staged -D

package.json 追加以下配置

"scripts": {
"prepare": "husky install",
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --cache --fix",
"git add"
],
"src/**/*.{css,less}": [
"stylelint --fix",
"git add"
],
},
  1. 初始化 husky
yarn prepare
  1. 安装 commitlint。这个插件可以校验提交的 commit 信息是否符合规范,不符合则不可以提交
yarn add @commitlint/cli @commitlint/config-conventional -D
  1. 在根目录下创建 .commitlintrc.js
module.exports = {
extends: ["@commitlint/config-conventional"],
};
  1. 然后执行以下命令,会自动在生成的 .husky 文件夹下新建两个钩子文件commit-msgpre-commit
npx husky add .husky/commit-msg 'yarn commitlint --edit "$1"'
npx husky add .husky/pre-commit 'yarn lint-staged'

文件内容如下(确保自动生成的文件内容跟以下的保持一致):

  • commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo "========= 校验 commit-msg ======="
yarn commitlint --edit $1
  • pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo "========= 执行 lint-staged ======="
yarn lint-staged

在 git commit 之前会进入工作区文件的扫描,执行 prettier 脚本,修改 eslint 问题,校验 commit msg,通过后再提交到工作区

注意:以上脚本执行的路径要确保与 .git 同级,如果出现以下情况

- .git
- front
- src
- package.json

先调整 package.json

"scripts": {
//...
"prepare": "cd .. && husky install front/.husky",
},

然后执行 yarn prepare,重新生成 hook 脚本

npx husky add .husky/commit-msg 'yarn commitlint --edit "$1"'
npx husky add .husky/pre-commit 'yarn lint-staged'

修改文件内容如下

  • commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo "========= 校验 commit-msg ======="
cd front
yarn commitlint --edit $1
  • pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo "========= 执行 lint-staged ======="
cd front
yarn lint-staged
  • commitizen【可选】。命令行交互辅助输入 commit msg
npm i -g commitizen
commitizen init cz-conventional-changelog --save --save-exact

之后在提交 commit 时运行 git cz

cz-customizable。英文看着不爽的话,可以用这个插件自定义 commitizen 中文配置,自行搜索和配置吧!

扩展 webpack

基于 react-app-rewired 扩展,这样后续也能享受到 react-scripts 的更新。当然如果是需要高度定制 webpack 配置的话,可以把配置文件导出来(eject)

alias

比较常规的路径映射有 @ --> src 如下:

import { utils } from "@/helper/utils"
  1. 借助 react-app-rewiredcustomize-cra 扩展 webpack 配置
yarn add react-app-rewired customize-cra

修改 package.json

"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},

项目根目录下新增 config-overrides.js 文件

const { override, addWebpackAlias } = require("customize-cra");
const path = require("path");

module.exports = {
webpack: override(
// 配置alias
addWebpackAlias({
["@"]: path.resolve(__dirname, "src"),
["components"]: path.resolve(__dirname, "src/components"),
["mock"]: path.resolve(__dirname, "src/mock"),
})
),
};
  1. 需要先修改 tsconfig,确保在编译期间 ts 能正确映射到某个路径下的文件。正常情况下是直接配置 tsconfigpaths 就可以了,但是 CRA 脚手架执行项目(start)时会将其覆盖。可以通过在 tsconfig 引入 paths.json,避开这个问题
// tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"extends": "./paths.json",
"include": ["src", "config"]
}

根目录新增 paths.json

{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

然后尝试在组件中使用 @ 引入模块吧!

less + css module

css 预处理语言,其实 sass 和 less 都可以吧,也是看个人喜好,然后结合 css module 设置样式作用域。在 config-overrides.js 中追加配置如下:

yarn add less less-loader -D
// config-overrides.js
const { override } = require("customize-cra");
// 需要先安装 yarn add customize-cra-less-loader -D
const addLessLoader = require("customize-cra-less-loader");

module.exports = {
webpack: override(
// ...
// 添加less解析器
addLessLoader({
lessOptions: {
javascriptEnabled: true,
sourceMap: false,
// modifyVars: { '@primary-color': '#1DA57A' },
},
})
),
// ...
};

之后在项目中使用,不过可能会报下面的错

Cannot find module './index.module.less' or its corresponding type declarations

需要在 react-app-env.d.ts 中追加 less 文件的声明:

declare module '*.less' {
const content: { [className: string]: string };
export default content;
}

设置全局 less 变量,新增 src/styles/global.less

// base variables
@font-size-base: 16px;
@font-size-lg: @font-size-base + 2px;
@font-size-sm: 12px;

// color
@color-green: #00b050;
@color-yellow: yellow;
@color-orange: orange;
@color-red: red;

// animation
@animation-duration-slow: 0.3s;
@animation-duration-base: 0.2s;
@animation-duration-fast: 0.1s;

// z-index list

更新 webpack 的 less 文件处理规则,需要安装 style-resources-loader

const { override, adjustStyleLoaders } = require("customize-cra");

module.exports = {
webpack: override(
//...
adjustStyleLoaders((rule) => {
if (rule.test.toString().includes("less")) {
rule.use.push({
loader: "style-resources-loader",
options: {
patterns: path.resolve(__dirname, "src/styles/global.less"),
injector: "append",
},
});
}
})
),
};

环境配置

1.默认使用 dotenv 和 env 文件来配置环境变量。CRA 默认有开发(.env.development)和线上环境(.env.production),如果只有这两个环境需要配置,只需要在 src 目录下新建这两个文件,然后配置变量就行了,构建过程会自动加载环境变量,参考官方文档 https://create-react-app.dev/docs/adding-custom-environment-variables/#what-other-env-files-can-be-used

如果不只是这两个环境,可以在 src 下新建 env 文件夹,存放对应的环境配置文件,然后参考以下方式修改 package.json(不同环境对应不同的构建命令)

"scripts": {
"buid:dev": "dotenv -e env/.env.dev react-app-rewired build",
"build:prod": "dotenv -e env/.env.prod react-app-rewired build",
// ...
}

记得安装 dotenv 插件

yarn add dotenv-cli -D

注意变量需要以 REACT_APP_ 开头,其余自定义的变量会被忽略。配置文件参考:

// .env.dev
# 自定义环境变量
REACT_APP_ENV=dev
# 路由前缀
REACT_APP_BASEURL="/"
# 静态资源路径前缀
PUBLIC_URL="/"

2.可以通过 cross-env 和不同的执行命令注入环境变量,调整 package.json,示例如下:

"scripts": {
"start": "cross-env REACT_APP_NODE_ENV=dev react-app-rewired start",
"build:test": "cross-env REACT_APP_NODE_ENV=test react-app-rewired build",
"build": "cross-env REACT_APP_NODE_ENV=prod react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
},

之后可以在代码中通过区分 REACT_APP_NODE_ENV 来加载对应的环境变量。参考以下配置文件:

const configMap: Record<Env, IConfig> = {
[Env.Dev]: { ...commonConfig, ...devConfig },
[Env.Test]: { ...commonConfig, ...testConfig },
[Env.Prod]: { ...commonConfig, ...prodConfig },
};
const currentEnv = process.env.REACT_APP_NODE_ENV ?? Env.Dev;
export const envConfig = configMap[currentEnv as Env];

这个是相比 dotenv 要多出的一个前置步骤

增加编译进度条

yarn add chalk@^4 progress-bar-webpack-plugin -D
// config-overrides.js
const chalk = require('chalk');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
// ...
module.exports = {
webpack: override(
// ...
addWebpackPlugin(
new ProgressBarPlugin({
format: ` :msg [:bar] ${chalk.green.bold(':percent')} (:elapsed s)`,
}),
),
}

增加项目信息输出

// config-overrides.js
const path = require("path");
const exec = require("child_process").execSync;

process.env.REACT_APP_NAME = require("./package.json").name;
process.env.REACT_APP_VERSION = require("./package.json").version;
process.env.REACT_APP_BUILD_TIME = new Date().toLocaleString("zh-CN", {
hour12: false,
});
process.env.REACT_APP_GIT_COMMIT = exec('git rev-parse --short=8 HEAD')
.toString()
.trim();

增加基础函数

// helper/utils/index.ts
/**
* 显示项目配置信息,比如项目名、版本等
*/
export function showProjectInfo() {
log.capsule(
`${process.env.REACT_APP_NAME}`,
`v${process.env.REACT_APP_VERSION}`
);
log.primary(`Build Time: ${process.env.REACT_APP_BUILD_TIME}`);
log.primary(`Last Commit: ${process.env.REACT_APP_GIT_COMMIT}`);
}

然后修改入口文件

// src/index.tsx
import { showProjectInfo } from "./utils";
// ...
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root"),
() => {
// show project info in console
showProjectInfo();
}
);

最终的 config-overrides.js 文件参考 https://github.com/GitHubJackson/react-spa-template/blob/main/config-overrides.js

包体积分析

借助 webpack-bundle-analyzer 插件

yarn add webpack-bundle-analyzer -D

扩展 webpack 配置

// config-overrides.js
const BundleAnalyzerPlugin =
require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
// ...
addWebpackPlugin(
// ...
new BundleAnalyzerPlugin(),
),

修改 package.json,增加相关的命令行

"scripts": {
// ...
"build:analyzer": "react-app-rewired build --stats && webpack-bundle-analyzer build/bundle-stats.json -m static -r build/bundle-stats.html -O",
}

执行命令后,浏览器打开 build/bundle-stats.html 文件就可以看到项目依赖的体积总览

项目实践

路由配置

基于 react-router v6,借助 React.lazy 和动态 import 实现路由懒加载

yarn add react-router-dom

根目录新增 routes/index.ts,路由配置如下:

import React from "react";

const LazyLogin = React.lazy(() => import("@/components/login"));
const LazyHome = React.lazy(() => import("@/components/home"));

export const routes = [
{
/**
* 登录页面
*/
path: "/login",
component: LazyLogin,
},
{
/**
* 主页
*/
path: "/home",
component: LazyHome,
},
];

App.tsx 中新增路由代码

import React, { Suspense } from "react";
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";
import { routes } from "./routes";
import "antd/dist/antd.min.css";
import "./App.css";

function App() {
const FallbackComponent = null;
return (
<Router>
<Suspense fallback={FallbackComponent}>
<Routes>
{routes.length > 0 &&
routes.map((router) => {
return (
<Route
path={router.path}
element={<router.component />}
key={router.path}
/>
);
})}
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
</Suspense>
</Router>
);
}

export default App;

在布局组件中,可以通过以下的方式插入子页面

import { Outlet } from "react-router";
// ...
<Content className={styles["content"]}>
<Outlet />
</Content>;

状态管理

mobx 可以给 react 增加响应式更新的能力,减少多余组件渲染的开销。参考代码如下:

import { createContext } from "react";
import { makeAutoObservable } from "mobx";

// NOTE 如果要限制,得先确保所有代码只通过 action 修改 store 属性
// 否则渲染过程有打印 warning log,会造成一定的性能损耗
// configure({ enforceActions: "always" });

// Model the application state.
class CommonStore {
count = 0;
constructor() {
makeAutoObservable(this);
}
add = () => {
this.count++;
};
reduce = () => {
this.count--;
};
get getCount() {
return "count is " + this.count;
}
}
export const commonStore = new CommonStore();
export const rootStore = {
commonStore,
};

export const storeContext = createContext(rootStore);
export const StoreProvider = storeContext.Provider;

index.tsx 中引入 store

//...
import { rootStore, StoreProvider } from "./store";

ReactDOM.render(
<React.StrictMode>
<StoreProvider value={rootStore}>
<App />
</StoreProvider>
</React.StrictMode>,
document.getElementById("root")
);

还可以写个公共 hooks,供函数组件调用

// useStore.ts
import { useContext } from "react";
import { storeContext } from ".";

export const useStore = () => useContext(storeContext);

在组件中使用

import React from "react";
import { useLocalStore, useObserver } from "mobx-react-lite";
import { useStore } from "@/helper/hooks/useStore";
import styles from "./index.module.less";

function Check() {
const { commonStore } = useStore();
const { add, reduce } = commonStore;
// 定义局部 store,仅限当前组件使用
const todo = useLocalStore(() => ({
// state
title: "Click to toggle",
done: false,
// action
toggle() {
todo.done = !todo.done;
},
// getter
get emoji() {
return todo.done ? "😜" : "🏃";
},
}));

return useObserver(() => (
<div className={styles["container"]}>
<button onClick={add}>add</button> {commonStore.count}
<button onClick={reduce}>reduce</button>
</div>
));
}

export default Check;

webpack 需要扩展配置来支持 mobx-react

mobx-react 有用到装饰符,CRA 目前还没有内置的装饰器支持,需要增加相应的 babel 插件。如果是只使用 React Hook 和mobx-react-lite 开发,则不需要配置

const {
override,
addDecoratorsLegacy,
disableEsLint,
//...
} = require("customize-cra");

module.exports = {
webpack: override(
addDecoratorsLegacy(),
disableEsLint()
//...
),
};

disableEsLint 是用于防止报错 Parsing error: Using the export keyword between a decorator and a class is not allowed. Please use 'export @dec class' instead

接口使用规范

这个主要看个人编码喜好吧,我自己定义的接口使用规范涉及三层:

  1. 基于 axios 创建 service 工具函数,在这里设置一些基本配置,比如 api url 前缀、拦截器逻辑、通用的状态码处理、...
// helper/utils/service.ts
import axios from "axios";
import { envConfig } from "@/config";

export const service = axios.create({
baseURL: envConfig.baseUrl,
timeout: 100000,
});

// Add a request interceptor
service.interceptors.request.use(
function (config) {
// Do something before request is sent
return config;
},
function (error) {
// Do something with request error
return Promise.reject(error);
}
);

// Add a response interceptor
service.interceptors.response.use(
function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
},
function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
return Promise.reject(error);
}
);
  1. 接口函数,封装具体接口的处理逻辑、异常处理、...
// services/index.ts
import { service } from "@/helper/utils/service.ts";

export async function getUserList(): Promise<IUserData[]> {
const res = await service.get("xxx");
if (res.code !== 200) {
message.error(`message: ${res.msg}, error: ${res.errorMsg}`, 2.5);
return;
}
// NOTE obj2CamelCase 用于转换下划线为驼峰命名,因为前端统一使用驼峰命名
return obj2CamelCase(res.data) as IUserData[];
}
  1. useRequest(ahooks),业务层消费接口
import { getUserList } from "./services/index.ts";
// 获取用户列表
const { data } = useRequest(getUserList, {
manual: false,
onSuccess: (data) => {
//...
},
});

新建页面

示例代码如下:

import React from 'react';
import { RouteComponentProps } from 'react-router-dom';

const : React.FC<RouteComponentProps> = props => {

return (
<div>

</div>
);
}

export default ;

可以结合 vscode 定制页面模板代码(preferences > user snippets),快速导入。比如新增上述模板代码,先选择 typescript react 进入 json 文件,新增以下配置:

{
//...
"quickly hook": {
"prefix": "reacthook",
"body": [
"import React from 'react';",
"import { RouteComponentProps } from 'react-router-dom';",
"",
"const $1: React.FC<RouteComponentProps> = props => {",
"",
"\treturn (",
"\t\t<div>",
"\t\t\t",
"\t\t</div>",
"\t);",
"}",
"",
"export default $1;"
],
"description": "quick reacthook"
}
//...
}

之后在 tsx 文件中,输入 reacthook 就可以通过提示导入模板代码

单元测试

前端可以通过 Jest 做一些逻辑和组件的单元测试,场景包括 UI 组件库、utils、公共 hooks 等

CRA 其实已经集成了这个能力,我们可以通过编写满足条件的测试用例文件,来快速使用这个能力

Jest 将使用以下任何流行的命名约定来查找测试文件:

  • __tests__ 文件夹中带有 .[js|ts] 后缀的文件
  • 带有 .test.[js|ts] 后缀的文件
  • 带有 .spec.[js|ts] 后缀的文件

这些文件可以放在 src 下任意文件夹中,建议统一放置于 src/__tests__ 中。编写完之后可以通过 yarn test 运行测试,Jest 将以 watch(观察) 模式启动。 每次保存文件时,它都会重新运行测试

更多内容可以参考 https://www.html.cn/create-react-app/docs/running-tests/

sentry

sentry 可以帮助我们监控和收集页面的异常

yarn add @sentry/react @sentry/tracing

然后在 src/index.tsx 中引入即可

import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";

Sentry.init({
dsn: "xxx", // TODO 这个需要在 sentry 中注册后获取,create project 时注意选择 react 应用
integrations: [new Integrations.BrowserTracing()],

// 我们建议在生产中调整此值,或使用 tracesSampler 进行更精细的控制
tracesSampleRate: 1.0,
});

之后所有未处理的异常都会被 Sentry 自动捕获,为了验证配置的有效,可以尝试在 App.tsx 中加入这个组件

<button onClick={methodDoesNotExist}>Break the world</button>

然后启动应用,之后可以在 project > 问题 中看到对应的错误信息

sentry 默认是纯英文,可以进入 User settings 修改 language,改为 Simplified Chinese 然后刷新页面

添加 ErrorBoundary

如果您使用的是 React 16 或更高版本,则可以使用 Error Boundary 组件将组件树内部的 Javascript 错误自动发送到 Sentry,并设置回退 UI

Mock

前端经常需要先 mock 数据开发,我们可以通过搭建一个 mock server 来帮助我们快速高效地 mock 接口数据

yarn add koa koa-router koa-bodyparser mockjs nodemon -D

在 src 下新建一个 mock 文件夹,然后在 mock 文件夹下创建 server.js 文件

const Koa = require("koa");
const router = require("koa-router")();
const bodyParser = require("koa-bodyparser");
const testData = require("./test.js");

const app = new Koa();
app.use(bodyParser());
app.use(router.routes());

router.get("/test", async (ctx, next) => {
ctx.body = testData;
await next();
});

// error-handling
app.on("error", (err, ctx) => {
console.error("server error", err, ctx);
});

app.listen(3001);

新增 test.js,编写测试接口。接口定义建议先和后端对齐

const Mock = require("mockjs");

const data = Mock.mock({
"list|1-10": [
{
"id|+1": 1,
},
],
});

module.exports = data;

然后修改 package.json

"mock": "nodemon src/mock/server.js",

在实际开发的时候,就可以多开一个终端运行 mock server,然后在前端页面直接访问 mock 的接口就可以了

最后

我建立了一个仓库 react-spa-template,集成了以上的能力,欢迎 comment ~

持续更新,更多玩法探索中......

· 20 min read

最近在使用 fabric.js 做 canvas 相关的开发,搜集了蛮多资料,但是有点分散,所以想整合一下,方便参考,下面也只是列举一些常用的 api,更详细的内容还是要参考官方文档(ps: 官方文档 不太友好 emmm...)

简单介绍

Fabric.js 是一个强大而简单的 canvas 库,提供了交互式对象模型、多种易用的 api 和 SVG 解析器等。比较明显的缺点是不支持 webGL,所以不太适合渲染巨量图形的场景,可以另外尝试 pixi.js。结合 typescript 开发的话,需要下载声明文件 @types/fabric

常用对象

  • Point
  • 线条 Line、多段线 Polyline
  • 矩形 Rect、多边形 Polygon
  • 圆形 Circle
  • 三角形 Triangle
  • 文本 Text(IText)
  • 图像 Image
  • 分组 Group

对象常用属性汇总

  • left 横坐标;top 纵坐标
  • width 宽度;height 高度
  • originX 对象转换的水平原点;originY 对象转换的垂直原点
  • scaleX 水平方向缩放倍数;scaleY 垂直方向缩放倍数
  • angle 偏转角度;snapAngle 设置对象在旋转时锁定的角度
  • stroke 图形线条颜色;strokeWidth 线条宽度
  • fill 对象的填充色;backgroundColor 对象的背景色
  • selectable 对象是否可选中
  • hasControls 值为 false 时无法对对象进行旋转和拉伸
  • lockRotation 是否禁止旋转对象
  • lockMovementXlockMovementY 是否禁止移动对象
  • lockScaleXlockScaleY 是否禁止缩放对象
  • fontFamily 字体;fontSize 字号
  • type 标记对象类型

基础使用

import { fabric } from 'fabric';
import { Canvas } from 'fabric/fabric-impl';
fabric.Object.prototype.originX = 'center';
fabric.Object.prototype.originY = 'center';
const CANVAS_ID = 'map-canvas';

class MyCanvas {
initialize () {
let myCanvas = new fabric.Canvas(CANVAS_ID);
myCanvas.selection = false;
myCanvas.setWidth(canvasWidth);
myCanvas.setHeight(canvasHeight);
// 鼠标事件监听
canvas.on('mouse:wheel', (evt) => {
// actionManager 是一个事件管理器,统筹所有的用户操作和事件订阅
// rafThrottle 是节流函数
rafThrottle(() = actionManager.handleMouseWheel(evt));
});
canvas.on('mouse:move', (evt) => {
rafThrottle(() => actionManager.handleMouseMove(evt));
});
// 对象选中监听
canvas.on('selection:created', (e: any) => {
actionManager.handleObjectSelect(e);
});
canvas.on('selection:updated', (e: any) => {
actionManager.handleObjectSelect(e);
});
canvas.on('selection:cleared', (e: any) => {
actionManager.handleObjectUnSelect();
});
canvas.on('object:moving', (e: any) => {
actionManager.handleObjectMove(e);
});
// 对象变动监听,包括位置、大小、角度等变化的监听
canvas.on('object:modified', (e: any) => {
actionManager.handleObjectModified(e);
});
}
}
// ...

入口组件初始化 MyCanvas

useEffect(() => {
new MyCanvas().initialize()
}, [])
// ...
<canvas id={CANVAS_ID}>Map canvas</canvas>

常用方法

画布

import { fabric } from "fabric";
import { Canvas } from "fabric/fabric-impl";

//创建画布
const myCanvas = new fabric.Canvas("myCanvas");
// myCanvas.setBackgroundColor();
// myCanvas.setWidth();
// myCanvas.setHeight();
//标识画布中元素选中时,是否还按原有的层级位置展示
myCanvas.preserveObjectStacking = true;
// 重新渲染一遍画布,当画布中的对象有变更,在最后显示的时候,需要执行一次该操作
myCanvas.renderAll();
// 清除画布中所有对象:
myCanvas.clear();
// 只绘制视图区域的元素
myCanvas.skipOffscreen = true;

/**
* 设置对象位置的基准参考位置为自身中心点
*/
fabric.Object.prototype.originX = "center";
fabric.Object.prototype.originY = "center";

/**
* 设置元素选中框的样式
*/
// 边角节点大小
fabric.Object.prototype.cornerSize = 6;
// 边角节点背景透明 false
// fabric.Object.prototype.transparentCorners = false;
// 边框颜色
// fabric.Object.prototype.borderColor = '#ccc';
// 角节点内部颜色
// fabric.Object.prototype.cornerColor = '#fff';
// 角节点边框颜色
// fabric.Object.prototype.cornerStrokeColor = '#ccc';

对象

// 获得画布上的所有对象:
const items = myCanvas.getObjects();

// 设置画布中的对象的某个属性值,比如第 0 个对象的 id
const items = myCanvas.getObjects();
tems[0].id ="items_id0" 或 items[0].set("id","items_id0")

// 获得画布中对象的某个属性,比如 第0 个对象的 id
const items = myCanvas.getObjects();
items[0].id;
// or items[0].get("id");

// 对象按指定位置放置
const t = myCanvas.getActiveObject();
t.center();
t.centerH(); // 水平居中
t.centerV(); // 垂直居中

// 设置对象属性,如 rect.set({top: 50,left:100})
object.set()
// 缩放对象
object.scale()
// 旋转对象
object.rotate()
// 返回对象的坐标和边界信息
object.getBoundingRect()
// 当对象修改了坐标、长宽、缩放、角度、倾斜程度等可能改变对象位置的属性时需要通过该方法重新计算位置
object.setCoords()
// 检查对象是否与另一个对象相交
object.intersectsWithObject(other)

// 加载图片时图片缩放到指定的大小
fabric.Image.fromURL(image_src, function(img) {
img.set({
left:tmp_left,
top:tmp_top,
centeredScaling:true,
cornerSize: 7,
cornerColor: "#9cb8ee",
transparentCorners: false,
});
img.scaleToWidth(image_width);
img.scaleToHeight(image_height);
myCanvas.add(img).setActiveObject(img);
});

活动对象

// 设置画布上的某个对象为活动对象
const items = myCanvas.getObjects();
myCanvas.setActiveObject(items[i]);

// 获得画布上的活动对象
myCanvas.getActiveObject();

// 取消画布中的所有对象的选中状态
myCanvas.discardActiveObject(); // 如果这样不生效,可以使用 myCanvas.discardActiveObject().renderAll();

// 清除画布中的活动对象:
const t = canvas.getActiveObject();
myCanvas.remove(t);

// 设置活动对象在画布中的层级
const t = canvas.getActiveObject();
myCanvas.sendBackwards(t); // 向下跳一层
myCanvas.sendToBack(t); // 向下跳底层:
myCanvas.bringForward(t); // 向上跳一层:
myCanvas.bringToFront(t); // 向上跳顶层:
// or
t.sendBackwards();
t.sendToBack();
t.bringForward();
t.bringToFront();

常用事件

// 选中事件
myCanvas.on("selection:created", function (options) {});
myCanvas.on("selection:updated", function (options) {});
// 取消选中
myCanvas.on("selection:cleared", function (options) {});

// 对象移动事件
myCanvas.on("object:moving", function (options) {});
// 对象旋转事件
myCanvas.on("object:rotating", function (options) {});
// 对象缩放事件
myCanvas.on("object:scaling", function (options) {});
// 对象变动监听,包括位置、大小、角度等变化的监听
myCanvas.on("object:modified", function (e) {});
// 点击事件监听
myCanvas.on("mouse:down", function (e) {});
myCanvas.on("mouse:up", function (e) {});
// 文本编辑事件
myCanvas.on("text:changed", function (options) {});

场景

总结一些常用场景的实现方法

移动画布

// ...
this.canvas.on("mouse:move", this.handleMouseMove.bind(this))
handleMouseMove(event) {
const e = event.e as MouseEvent;
// ...
const delta = new fabric.Point(e.movementX, e.movementY);
canvas.relativePan(delta);
}

缩放画布

/**
* @description 从鼠标处缩放地图
* @param {Canvas} canvas
* @param {WheelEvent} e
*/
function zoomMapCenter(canvas: Canvas, e: WheelEvent) {
let zoom = 1;
// 控制缩放范围
zoom *= 0.999 ** e.deltaY;
if (zoom > 20) zoom = 20;
if (zoom < 0.01) zoom = 0.01;

canvas.zoomToPoint(
{
x: e.offsetX,
y: e.offsetY,
},
canvas.getZoom() * zoom
);
// updateMapOffset 更新地图偏移值
// adaptStrokeWidth 保持线条宽度
}

/**
* @description 从中心点放大地图
* @param {Canvas} canvas
* @param {number} [zoom=1.1]
*/
function zoomInMap(canvas: Canvas, zoom: number = 1.1) {
if (zoom > 20) zoom = 20;
canvas.zoomToPoint(
{ x: (canvas.width ?? 0) / 2, y: (canvas.height ?? 0) / 2 },
canvas.getZoom() * zoom
);
// updateMapOffset 更新地图偏移值
// adaptStrokeWidth 保持线条宽度
}

/**
* @description 从中心点缩小地图
* @param {Canvas} canvas
* @param {number} [zoom=0.9]
*/
function zoomOutMap(canvas: Canvas, zoom: number = 0.9) {
if (zoom < 0.01) zoom = 0.01;
canvas.zoomToPoint(
{ x: (canvas.width ?? 0) / 2, y: (canvas.height ?? 0) / 2 },
canvas.getZoom() * zoom
);
// updateMapOffset 更新地图偏移值
// adaptStrokeWidth 保持线条宽度
}

缩放时保持线条宽度

function adaptStrokeWidth(canvas: Canvas, width: number = 2) {
let strokeWidth = width / canvas.getZoom();
for (let object of canvas.getObjects()) {
if (object.strokeWidth) {
object.set("strokeWidth", strokeWidth);
if (object.strokeDashArray && object.strokeDashArray.length > 0) {
// 有虚线的话,还要做下调整
object.set("strokeDashArray", [strokeWidth * 5, strokeWidth * 2]);
}
}
}
}

操作后的鼠标定位

在对画布进行移动、缩放操作时,重新添加图形,会发现位置不准确的问题,其实就是偏移量的问题,因为画布尺寸并没有发生改变

1.首先要设置偏移量对象,记录最后一次的偏移量和缩放比例

const mapOffset = { x: 0, y: 0, zoom: 1 };

2.移动画布时累加偏移量

const mouseMoveEvent = (e) => {
// ...
mapOffset.x += e.e.movementX / mapOffset.zoom;
mapOffset.y += e.e.movementY / mapOffset.zoom;
};

3.缩放画布时计算偏移量和保存最新的缩放系数

const mouseWheelEvent = (e) => {
// ...
if (zoom === mapOffset.zoom) return;
const [x, y] = [e.offsetX, e.offsetY];
// 计算缩放时产生的偏移量
mapOffset.x += x / zoom - x / mapOffset.zoom;
mapOffset.y += y / zoom - y / mapOffset.zoom;
// 缩放操作
const zoomPoint = new fabric.Point(x, y);
myCanvas.zoomToPoint(zoomPoint, zoom);
mapOffset.zoom = zoom;
};

接下来新增图形时,通过以下代码可以正确获取到当前节点在画布中的真实位置

// ...
const mouseClickEvent = (e) => {
// ...
const [left, top] = [
e.pointer.x / mapOffset.zoom - mapOffset.x
e.pointer.y / mapOffset.zoom - mapOffset.y
];
}

划线

两点划线

这个两点划线的意思是先点击固定起点,另一边伺机而定。这个思路其实很简单,需要两个辅助变量 mouseFrommouseTo,和一个锁 isDrawingLine

  1. 触发第一次点击事件,赋值 mouseFrom,添加 Line
  2. 触发鼠标移动事件,赋值 mouseTo,然后修改 Line 的属性重绘画布,开锁
  3. 触发第二次点击事件,赋值 mouseTo,上锁

连续划线

其实思路和两点划线类似,只是将 Line 换成 Polyline,然后在鼠标移动过程中不断的重绘最后一段线,参考下面的代码:

// ...
object.set({
points: [
...object.points.slice(0, -1),
{ x, y },
],
});
canvas.requestRenderAll();

Polyline 会有 bug,在缩放或平移时,如果 Polyline 对象超出可视范围时会导致路线消失。解决办法有两个

  • 【推荐】改用多段 Line 的连续划线思路。其实就是用一个二维数组表示路径,每个元素是一段 Line,再增加或修改路径中某段路线的时候就很方便,不需要全量重绘
  • 缩放或平移的时候去除 offscreen 限制(设置 screen.skipOffscreen = false),但是会导致缩放或平移卡顿

绘制带洞多边形

带洞多边形其实就是在 Polygon 的基础上支持在内部绘制多个 Polygon,可以扩展 Polygon 来实现一个基础对象

import { fabric } from 'fabric';
type TPos = { x: number; y: number; z?: number };
export class FreespacePolygon extends fabric.Polygon {
fillRule: 'evenodd' | 'nonzero' = 'evenodd';
// NOTE: 注意这里是点集的二维数组,第一个为外边框,其余都是内洞
holes: TPos[][] = [];
constructor(paths: TPos[][], options?: fabric.IPolylineOptions) {
if (!paths) {
return;
}
const pathPoints = paths.map((points) => {
return points.map((point) => new fabric.Point(point.x, point.y));
});
const [outer, ...holes] = pathPoints;
super(outer, options);
this.holes = holes;
}
holesRender(ctx: CanvasRenderingContext2D) {
const { x, y } = this.pathOffset;
this.holes.forEach((hole) => {
const len = hole.length;
ctx.moveTo(hole[0].x - x, hole[0].y - y);
for (let i = 0; i < len; i += 1) {
const point = hole[i];
ctx.lineTo(point.x - x, point.y - y);
}
ctx.closePath();
});
}
_render(ctx: CanvasRenderingContext2D) {
if (!(this as any).commonRender(ctx)) {
return;
}
ctx.closePath();
this.holesRender(ctx);
this._renderPaintInOrder(ctx);
}
}

除了传参不同,其余使用方式同 Polygon

绘制路线曲线

可以通过两种方式绘制曲线用以平滑路径

  • 贝塞尔曲线
  • Catmull-Rom
type TPos = { x: number, y: number, z?: number };
type TGeometry = {
alt?: number,
lat: number,
lng: number,
};
class Vector {
x = 0;
y = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
/**
* 计算曲线路径
*/
class CatmullRom {
pointsNum = 15;
interpolatedPosition(P0: TPos, P1: TPos, P2: TPos, P3: TPos, u: number) {
const u3 = u * u * u;
const u2 = u * u;
const f1 = -0.5 * u3 + u2 - 0.5 * u;
const f2 = 1.5 * u3 - 2.5 * u2 + 1.0;
const f3 = -1.5 * u3 + 2.0 * u2 + 0.5 * u;
const f4 = 0.5 * u3 - 0.5 * u2;
const x = P0.x * f1 + P1.x * f2 + P2.x * f3 + P3.x * f4;
const y = P0.y * f1 + P1.y * f2 + P2.y * f3 + P3.y * f4;
return new Vector(x, y);
}
getPoints(controlPoints: TGeometry[]) {
const points = [];
const len = controlPoints.length;
const head = {
lng: 2 * controlPoints[0].lng - controlPoints[1].lng,
lat: 2 * controlPoints[0].lat - controlPoints[1].lat,
};
const tail = {
lng: 2 * controlPoints[len - 1].lng - controlPoints[len - 2].lng,
lat: 2 * controlPoints[len - 1].lat - controlPoints[len - 2].lat,
};
let newControlPoints = controlPoints.slice(0);
newControlPoints.splice(0, 0, head);
newControlPoints.push(tail);
for (let i = 0; i < newControlPoints.length - 3; i++) {
const p0 = new Vector(newControlPoints[i].lng, newControlPoints[i].lat);
const p1 = new Vector(
newControlPoints[i + 1].lng,
newControlPoints[i + 1].lat
);
const p2 = new Vector(
newControlPoints[i + 2].lng,
newControlPoints[i + 2].lat
);
const p3 = new Vector(
newControlPoints[i + 3].lng,
newControlPoints[i + 3].lat
);
const max = this.pointsNum;
for (let j = 0; j <= max; j++) {
const p = this.interpolatedPosition(p0, p1, p2, p3, j / max);
points.push({ lng: p.x, lat: p.y });
}
}
return points;
}
}
const CATMULL = new CatmullRom();
export default CATMULL;

使用的话很方便,调用 getPoints,把路径点集传入,就可以得到转换后的曲线点集

const curvePoints = CATMULL.getPoints(points);
// 之后将 curvePoints 作为参数重新绘制路线即可

对象层叠

有时候会遇到多个对象层叠的情况,这个时候需要将点到的对象放到最上层便于操作

性能优化

内置优化

其实 Fabric.js 在 canvas 基础上已做了很多优化,比如 requestRenderAll、只渲染视图内的对象(skipOffscreen)、skipTargetFind、分层(上层负责响应交互事件,下层负责绘制画布)、批量绘制等,后续有时间可以研究研究源码,看看具体是怎么优化的。当绘制元素多了之后,就还需要考虑其他优化方向:

本地缓存

空间换时间,我实际遇到的项目是可以用 indexDB 缓存地图数据(较大的 json 对象),通过地图数据中的 md5 值来检查缓存的有效性。我也封装了一个工具 Hook,代码如下:

import { useRef, useState } from 'react';
import { ISOTimeStamp, TPlainObject } from '@/types';
import { reject } from 'lodash';
type TObjectStore = any;
const SIM_EDITOR_DB_NAME = 'editor_default_db';
const SIM_EDITOR_STORE_NAME = 'default';
/**
* @description 创建数据库
*/
function initIndexDB(name: string = SIM_EDITOR_DB_NAME, versionNum = 1) {
const request = window.indexedDB.open(name, versionNum);
return request;
}
export interface IData {
mapKey: string;
md5Sum: string;
data: TPlainObject | string;
createTime: ISOTimeStamp;
}
export function useIndexDB({
dbname = SIM_EDITOR_DB_NAME,
storeName = SIM_EDITOR_STORE_NAME,
index = 'key',
}) {
const objectStoreRef = useRef<IDBObjectStore>();
const request = initIndexDB(dbname);
const [isUpdate, setIsUpdate] = useState<number>(0);
request.onupgradeneeded = () => {
// NOTE 首次创建和db version变化才会执行这个函数;这个场景暂时没必要维护版本
const db = request.result;
// 创建存储空间
const objectStore = db.createObjectStore(storeName, {
keyPath: 'key',
autoIncrement: true,
});
// 创建索引
objectStore.createIndex(index, index, { unique: true });
};
request.onerror = () => {
console.error('indexDB init error');
};
// 重新获取数据
function refresh() {
setIsUpdate(isUpdate + 1);
}
/**
* @description 查询全部数据
*/
function findAll(): Promise<IData[] | null> {
console.log('===查询全部数据');
console.time('findAll');
return new Promise((resolve, reject) => {
initIndexDBStore()
.then((objectStore) => {
const getRequest = objectStore.getAll();
getRequest.onsuccess = () => {
if (getRequest.result) {
console.timeEnd('findAll');
resolve(getRequest.result);
} else {
console.log('not found');
resolve(null);
}
};
getRequest.onerror = (err: Error) => {
console.error('getRequest error');
reject(err);
};
})
.catch((err) => {
console.error('indexDB getAll error', err);
reject('indexDB error');
});
});
}
/**
* @description 创建事务,要通过事务操作数据库
*/
function initIndexDBStore(): Promise<TObjectStore> {
return new Promise((resolve, reject) => {
const openRequest = window.indexedDB.open(dbname);
openRequest.onsuccess = () => {
const db = openRequest.result;
const transaction = db.transaction(
[SIM_EDITOR_STORE_NAME],
'readwrite',
);
objectStoreRef.current = transaction.objectStore(SIM_EDITOR_STORE_NAME);
// const index = objectStoreRef.current.index('key');
resolve(objectStoreRef.current);
};
openRequest.onerror = (err) => {
console.error('openRequest error');
reject(err);
};
});
}
/**
* @description 查询数据
*/
function find(key: string): Promise<IData | null> {
console.log('===find, key: ', key);
console.time('find');
return new Promise((resolve, reject) => {
initIndexDBStore()
.then((objectStore) => {
const getRequest = objectStore.get(key);
getRequest.onsuccess = () => {
// console.log("===find getRequest: ", getRequest);
if (getRequest.result) {
console.timeEnd('find');
resolve(getRequest.result);
} else {
console.log('not found');
resolve(null);
}
};
getRequest.onerror = (err: Error) => {
console.error('getRequest error');
reject(err);
};
})
.catch((err) => {
console.error('indexDB get error', err);
reject('indexDB error');
});
});
}
/**
* @description 新增数据
*/
function add(
key: string,
md5Sum: string,
data: TPlainObject | string,
createTime: number,
) {
return new Promise((resolve, reject) => {
initIndexDBStore().then((objectStore) => {
const addRequest = objectStore.add({ key, data, createTime, md5Sum });
addRequest.onsuccess = (res: any) => {
console.log('add success');
refresh();
resolve(res.result);
};
addRequest.onerror = (err: Error) => {
console.error('addRequest error');
reject(err);
};
});
}).catch((err) => {
console.error('indexDB add error', err);
reject('indexDB error');
});
}
/**
* @description 删除数据
*/
function remove(key: string, isRefresh = true) {
return new Promise((resolve, reject) => {
initIndexDBStore().then((objectStore) => {
const deleteRequest = objectStore.delete(key);
deleteRequest.onsuccess = (res: any) => {
console.log('delete success');
isRefresh && refresh();
resolve(res.result);
};
deleteRequest.onerror = (err: Error) => {
console.error('deleteRequest error');
reject(err);
};
});
}).catch((err) => {
console.error('indexDB delete error', err);
reject('indexDB error');
});
}
/**
* @description 删除全部数据
*/
function removeAll() {
return new Promise((resolve, reject) => {
initIndexDBStore().then((objectStore) => {
const clearRequest = objectStore.clear();
clearRequest.onsuccess = (res: any) => {
console.log(`clear success`);
resolve(res.result); // should be undefined
};
clearRequest.onerror = (err: Error) => {
console.error('clearRequest error');
reject(err);
};
});
}).catch((err) => {
console.error('indexDB clear error', err);
reject('indexDB error');
});
}
/**
* @description 清理过期数据
*/
function clearExpirationJsons(jsons: any[]) {
return Promise.all(jsons.map((json) => remove(json.key, false)));
}
return {
indexdb: {
findAll,
find,
remove,
removeAll,
add,
},
};
}

注意:IndexDB 在查询的时候会去读取整个对象,有一个解析耗时,比如如果只是检查是否失效,建议可以自己设计个关联对象,只保存 key 和 md5 来判定

高频操作优化

主要是做下函数节流

const lock: Record<string, boolean> = {};
export function rafThrottle(callback = (time: number) => {}, key = "default") {
if (lock[key]) {
return false;
}
lock[key] = true;
window.requestAnimationFrame((time) => {
lock[key] = false;
callback(time);
});
return true;
}

其他优化

  • 异步绘制。分量绘制
  • 分片渲染。每次渲染间隔几毫秒,会增加总的渲染时长,但可以降低页面的卡顿感
  • 考虑临时分层。当我们在复杂的图层中拖动物体时,为了避免重绘该图层,可以将拖动的物体单独移到另外一层中渲染,当拖拽结束时再将物体移动回原来的图层
  • WebGL 实现 2D 渲染,可以考虑使用 pixijs

最后

持续更新 ing...

参考

· 16 min read

选择 gatsby 主要有几点理由:

  • 基于 react
  • 内置 markdown 处理器
  • 生态良好,插件较丰富
  • 无后端、部署简单

比较明显的缺点应该就是需要在本地编辑文章和上传,但我也经常在本地写 markdown 文章,所以对我而言问题不大

搭建开发环境

先确保 node 已安装,然后全局安装 gatsby-cli,基于 gatsby-starter-blog 来快速开启博客页面

npm install -g gatsby-cli
gatsby new my-blog https://github.com/gatsbyjs/gatsby-starter-blog
cd my-blog
npm run dev

然后就可以打开 localhost:8000 访问页面了,刚开始还是一些模板代码,可以替换或者去掉

GraphQL

页面数据是通过 GraphQL 查询拿到的,在你本地启动 Gatsby 服务时,也会同步启动 GraphQL 的服务。你的文件、图片等所有资源会被 Gatsby 和一些安装的插件解析到 GraphQL 的节点上,通过特定的语法就可以按需获取需要的数据

简而言之,Gatsby 的工作原理就是通过 GraphQL 的 api 和你指定的语法完成数据的按需获取,再用获取到的数据渲染成静态网页

接入评论功能

可以通过 utterances + github issues 实现,实际上就是先拿到用户的 github 信息,然后将评论作为 issue 推送至指定的仓库。这样也就不用专门去搞个数据库存储评论数据了

  1. 创建一个存放评论信息的 github 仓库
  2. 安装 utterances 并授权
  3. 配置信息,比如按文章名作为 issue 名称。可以参考这个 https://utteranc.es/
  4. 新建一个 Comments 组件
import * as React from "react";
import { useEffect, useRef } from "react";

const Comments = () => {
const commentsRef = useRef < HTMLDivElement > null;
useEffect(() => {
const script = document.createElement("script");
script.src = "https://utteranc.es/client.js";
script.setAttribute("repo", "GitHubJackson/blog-comments");
script.setAttribute("issue-term", "title");
script.setAttribute("label", "💬");
script.setAttribute("theme", "github-light");
script.setAttribute("crossorigin", "anonymous");
script.async = true;

if (commentsRef.current) {
commentsRef.current.appendChild(script);
}

return () => {
if (commentsRef.current) {
commentsRef.current.innerHTML = "";
}
};
}, []);
return <div ref={commentsRef} />;
};

export default Comments;

src/templates/blog-post.js 中插入组件

//...
<Layout location={location} title={siteTitle}>
//...
<Comments />
</Layout>
//...

效果如图:

新增页面

直接在 pages 文件夹下新增页面即可,可以直接用 typescript 编写组件(tsx),项目已经默认支持

我的博客页面如下:

  • Archive 归档,文章归档页,按照发布时间排序
  • Categories 分类,文章分类页
  • Tags 标签,文章标签页、以标签云的方式呈现
  • About 关于,展示作者的信息、提供留言板
  • Lab 实验室,展示自己的一些小项目

上面几个是比较常见的博客页面了,还可以往后追加自己的 Github 主页等等

在文章前加上对应信息,比如:

---
title: 文章标题
createTime: "2020-10-23"
updateTime: ""
type: "js"
tags: "js,class,es6,原型,面向对象"
description: "balabala..."
---

markdown 文件会被 gatsby-plugin-remark 解析成 markdownRemark 的节点,以上描述信息会被解析到 frontmatter

可以通过修改 frontmatter 代码获取指定数据,比如我想获取文章分类,新建一个分类页面categories.tsx,参考代码如下

// categories.tsx
import { graphql } from "gatsby";
import * as React from "react";
import Layout from "../components/layout";
import "../css/categories.css";

export default ({ data, location }) => {
const siteTitle = data.site.siteMetadata?.title || `Title`;
// 拿到所有的文章数据
let posts = data.allMarkdownRemark.nodes;
let categories: any[] = [];
// 计算各分类文章的数量
posts.forEach((post) => {
const current = categories.find(
(category) => category.title === post.frontmatter.type
);
if (!current) {
categories.push({
title: post.frontmatter.type,
count: 1,
});
} else {
current.count = current.count + 1;
}
});

return (
<Layout location={location} title={siteTitle}>
{categories.map((category) => {
return (
<div key={category.title} className="category">
{category.title}{category.count}
</div>
);
})}
</Layout>
);
};

export const pageQuery = graphql`
query {
site {
siteMetadata {
title
}
}
allMarkdownRemark(
sort: { fields: [frontmatter___createTime], order: DESC }
) {
nodes {
excerpt
fields {
slug
}
// 通过该字段查询文章开头的描述信息
frontmatter {
type
}
}
}
}
`;

最终的分类页面参考 https://blog.zhouweibin.top/categories/

文章相关

toc

基于 Tocbot 为 md 文章增加一个目录

npm install tocbot

在文章代码中增加目录初始化逻辑如下:

// blog-post.tsx
useEffect(() => {
// ...
// 指定作为目录标题的标签
const headerArr = ["H1", "H2", "H3", "H4"];
const blogContentNode = document.getElementsByClassName("blog-content")[0];
if (!blogContentNode?.children?.length) {
return;
}
// 遍历文章节点,给所有的目录标题节点增加 id,可用于锚点定位
// @ts-ignore
[...blogContentNode.children].forEach((child) => {
if (headerArr.includes(child.nodeName)) {
// 去除空格以及多余标点
let headerId = child.innerText.replace(
// eslint-disable-next-line no-useless-escape
/[\s|\~|`|\!|\@|\#|\$|\%|\^|\&|\*|\(|\)|\_|\+|\=|\||\|\[|\]|\{|\}|\;|\:|\"|\'|\,|\<|\.|\>|\/|\?|\:|\,|\。]/g,
""
);
headerId = headerId.toLowerCase();
// NOTE 需要确保name唯一,最好加个自增id
child.setAttribute("id", headerId + "-" + num);
num++;
}
});
tocbot.init({
// Where to render the table of contents.
tocSelector: ".js-toc",
// Where to grab the headings to build the table of contents.
contentSelector: ".blog-content",
// Which headings to grab inside of the contentSelector element.
headingSelector: "h1, h2, h3, h4",
});
// ...
});

通过修改对应的类名可以调节目录样式。如果感觉自己写的样式不好看,可以去掘金或者其他网站借鉴下源码 ~

参考 https://tscanlin.github.io/tocbot/

阅读时长

gatsby-transformer-remark 插件已经帮我们计算好了阅读时长,直接修改 GraphQL,再到组件代码中获取

// helper/utils.ts
export function formatReadingTime(minutes: number) {
let cups = Math.round(minutes / 5);
if (cups > 4) {
return `${new Array(Math.round(cups / 4))
.fill("🍚")
.join("")}${minutes} mins`;
} else {
return `${new Array(cups || 1).fill("🍵").join("")}${minutes} mins`;
}
}
// pages/index.tsx
// 找个合适的位置
<span style={{ marginLeft: 8 }}>{`${formatReadingTime(
post.timeToRead
)}`}</span>;
// ...
export const pageQuery = graphql`
query {
site {
siteMetadata {
title
}
}
allMarkdownRemark(
sort: { fields: [frontmatter___createTime], order: DESC }
) {
nodes {
excerpt
fields {
slug
}
timeToRead
frontmatter {
createTime(formatString: "YYYY/MM/DD")
updateTime(formatString: "YYYY/MM/DD")
title
type
tags
description
}
}
}
}
`;

效果如下:

夜间模式

借助 gatsby-plugin-use-dark-mode 来实现夜间模式,保护眼睛 ~

yarn add gatsby-plugin-use-dark-mode @fisch0920/use-dark-mode
// use-dark-mode 和 Gatsby v3 的react版本有冲突
// 所以这里使用社区的fork解决版本 @fisch0920/use-dark-mode

增加插件

// post-config.js
plugins: [
"gatsby-plugin-use-dark-mode",
// ...
];

组件就先简单用 antd 的 Switch 组件

// components/dark-mode-toggle.tsx
import * as React from "react";
import useDarkMode from "@fisch0920/use-dark-mode";
import { Switch } from "antd";

const DarkModeToggle = () => {
const darkMode = useDarkMode(false);
return (
<Switch
checked={darkMode.value}
onChange={darkMode.toggle}
checkedChildren=""
unCheckedChildren=""
/>
);
};

export default DarkModeToggle;

在布局组件 Layout.js 中引入组件

import DarkModeToggle from "./dark-mode-toggle";
import useDarkMode from "@fisch0920/use-dark-mode";

//...
const darkMode = useDarkMode(false);
//...
<DarkModeToggle mode={darkMode} />

增加夜间模式相关的全局样式

/* src/style.css */
/* 主题模式 */
body.light-mode {
background-color: #fff;
color: #333;
transition: background-color 0.3s ease;
}
body.dark-mode {
background-color: #212121;
color: #999;
transition: background-color 0.3s ease;
}
.dark-mode .global-header {
background-color: #212121;
transition: background-color 0.3s ease;
}

上面只是实现了基础功能,DarkModeToggle 组件建议自行美化下。点击切换模式应该就能看到效果了。但实际效果可能还会有问题,比如有一些你自定义颜色或背景色的模块,需要在 src/style.css 针对性地去定义夜间模式下的颜色

其他组件

返回顶部

当文章太长时,往往需要增加一个返回顶部的小按钮,便于快速回到顶部,主要代码如下:

// blog-post.tsx
function handleScrollToTop() {
// 滚动到顶部
document.documentElement.scrollTo({
top: 0,
behavior: "smooth",
});
}
// ...
useEffect(() => {
// ...
function handleScroll(e) {
const rootElement = document.documentElement;
const scrollToTopBtn = document.querySelector(
".back-to-top"
) as HTMLElement;
const scrollTotal = rootElement.scrollHeight - rootElement.clientHeight;
if (rootElement.scrollTop / scrollTotal > 0.5) {
// 显示按钮
scrollToTopBtn.style.bottom = "36px";
scrollToTopBtn.style.opacity = "1";
} else {
// 隐藏按钮
scrollToTopBtn.style.bottom = "-36px";
scrollToTopBtn.style.opacity = "0";
}
}
document.addEventListener("scroll", handleScroll);
return (() => {
document.removeEventListener("scroll", handleScroll);
})
})

按钮样式自行发挥吧,可以简单写个过渡动画 ~

分析网站

可以通过谷歌的 Google Analytics 来分析自己的网站,包括网站流量、访客信息、访问设备、浏览次数等

其实工作原理就类似于埋点,将一段谷歌的代码注入博客网页,它会帮忙收集和分析登录网页的用户信息

教程详情参照 设置 Google Analytics(分析)全局网站代码

获取到代码后,将其注入到 components/seo.js 组件中即可

import { Helmet } from "react-helmet";

<Helmet>
{/* <!-- Global site tag (gtag.js) - Google Analytics --> */}
<script
async
src="https://www.googletagmanager.com/gtag/js?id=你的跟踪ID"
></script>
<script>
{`
window.dataLayer = window.dataLayer || [];
function gtag() {dataLayer.push(arguments)}
gtag('js', new Date()); gtag('config', '你的跟踪ID');
`}
</script>
</Helmet>;

这其实是 react-helmet 这个依赖帮忙将这段代码注入到网页的 head 中,感兴趣可以自行去了解 ~

sitemap

可以借助 gatsby-plugin-sitemap 插件自动生成 sitemap

// gatsby-config.js
module.exports = {
siteMetadata: {
siteUrl: `https://blog.zhouweibin.top`,
},
plugins: [`gatsby-plugin-sitemap`],
};

打包部署后,会自动在根目录下生成 sitemap 文件。我是用的 v5 版本的插件,会生成 sitemap 文件夹,存放 sitemap-index.xml(站点地图索引,会指向最终的站点地图),可以通过 'https://blog.zhouweibin.top/sitemap/sitemap-index.xml' 访问验证

之后可以上 google(google 站点地图)或百度(百度收录)上传站点地图。上传会有延迟,不代表 sitemap 失效,大概半小时生效吧

seo 优化

其实这方面已经有做了一些处理,参见 components/seo.js

部署到服务器

需要有个服务器部署博客页面(也可以试试用 CMS),我个人使用 centos 系统,服务器的话,选腾讯云阿里云的轻量服务器就行,新人可以点以下链接领取大额优惠券

还有个人域名、https 证书也都是个人网站需要的,建议在同一家云服务方按需购买

配置 nginx

配置下 nginx ,可以接入个人域名,以及配置二级域名转发等等

yum install nginx
systemctl start nginx # 启动
systemctl enable nginx # 开发自启动

nginx 配置文件目录为 /etc/nginx/nginx.conf,简单配置如下:

#...
http {
#...
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;

# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;

location / {
root /home/blog-next; # 静态资源存放位置,比如博客项目打包生成的 public 文件
index index.html;
}
}
}

修改完,重启一下 nginx systemctl restart nginx

然后通过 http://[你的ip地址]:8080 其实就可以访问到你的页面了

追加个人域名、https 和二级域名转发的配置如下:

https 证书在腾讯云或者阿里云都有对应的免费证书可领

#...
http {
#...
server {
listen 8080;
server_name zhouweibin.top;
root /usr/share/nginx/html;

# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;

location / {
root /home/blog-next;
index index.html;
#try_files $uri $uri/ /index.html; # 单页面应用需要该配置
}
}

server {
listen 80;
server_name blog.zhouweibin.top; # 二级域名转发
location / {
proxy_pass http://127.0.0.1:8080;
#root /home/blog-next;
#index index.html;
#try_files $uri $uri/ /index.html;
}
}

# Settings for a TLS enabled server.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name blog.zhouweibin.top;
root /usr/share/nginx/html;

ssl_certificate "/etc/nginx/cert/6687351_blog.zhouweibin.top.pem"; # 指向证书存放位置
ssl_certificate_key "/etc/nginx/cert/6687351_blog.zhouweibin.top.key";
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 10m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

# Load configuration files for the default server block.
include /etc/nginx/default.d/*.conf;

location / {
proxy_pass http://127.0.0.1:8080;
}
}
}

打包上传

npm run build

将打包生成的 public 文件夹上传到服务器 nginx 指定的静态资源文件夹,比如我是放在 /home/blog-next。接下来就可以通过 https://blog.zhouweibin.top 访问了

服务器相关操作可以参考我之前总结的文章 - 服务器环境入门级搭建

快速上传文件可以用 FileZilla 可视化界面直接操作

自动部署

可以借助 github 和 jenkins 实现一个简单的自动部署能力

  1. 先创建一个 github 项目,与本地项目关联上
  2. 搭建 jenkins 环境。这个可以参考我之前写的文章 - 服务器环境入门级搭建
  3. github 给项目添加一个 webhook,和 jenkins 关联上

jenkins shell 脚本如下:

#!/bin/sh
cd /var/lib/jenkins/workspace/blog-next
rm -rf node_modules public
npm config set registry https://registry.npmmirror.com/ # 也可以在项目中增加 npmrc 文章指定默认源
npm install #安装项目中的依赖
npm run build
cd public
cd /home #进入web项目根目录
if [ ! -d "blog-next" ]; then
sudo mkdir blog-next
fi
cd /home/blog-next #进入web项目根目录
sudo rm -rf *
sudo mv /var/lib/jenkins/workspace/blog-next/public/* ./ #移动刚刚打包好的项目到web项目根目录

接下来就可以尝试提交代码(git push),测试下自动部署的能力了

最后

接下来,就可以开始经营你的个人博客了,写博客、实现更多的功能(文章目录、分类等)、增加其他页面,等等...这篇文章也会在后续持续更新,带来更多的玩法 ~!

参考

· 4 min read

写在前面

  • 技术赋能业务,业务促进技术
  • 不要提前设计,不要为了用技术而用技术

拥有自己的应用模板

  • 沉淀自己的应用模板很重要,这是属于你的最佳实践
    • 快速新建项目
    • 代码规范配置
    • 预设高质量依赖
    • 良好的构建配置和插件集合
  • 尝试给自己写个脚手架,和模板配合

ts + eslint

  • 最大限度确保代码的质量,不过也不能滥用 any
  • 封装公共 type,比如 TPlainObjectISOTimeStamp、颜色枚举等
  • eslint 只做代码检查,不做代码格式化

prettier

  • 代码格式化一致性,再也不怕帮同事改代码了
  • commitlint 代码提交前强制格式化
  • .vscode 增加 formatOnSave

key

请给遍历组件加上 key,提高组件的复用率,避免错位(index 做 key)导致的更新异常现象

正确用 Hook

  • 统一使用 Hook 编写组件
  • 只能在最顶层使用,不要在循环、条件语句或函数中使用
  • 变量和对应的函数(func/effect)放一起,而不是给所有变量和所函数划分界限
  • 适当使用 useState,部分情况可以用 useRef 替代

最小原则

  • 最小渲染次数。这个是架构层面出发的必须注重的优化,react 应用的性能优化基本都会从这里开始。通过 useMemo 或者 shouldUpdateComponent/memo 来避免组件的重复渲染
  • 擅用缓存。可以借助 useCallbackuseRef 缓存变量和函数,避免重复创建
  • 最少依赖项。使用新的依赖之前,先思考必要性。比如 Redux,如果只是小项目,通信也不复杂,那直接用 Context 有何不可

组件化

遵守单一职责原则,确保组件简洁、耦合度小、功能明确

能否做好组件化是鉴别前端工程师能力的一个重要方式

卸载

别忘了卸载定时器、移除事件,不要浪费内存

错误边界

给组件都统一添加错误边界(Error Boundary)来防止白屏。让 PM、测试看到白屏和局部错误是俩回事

单元测试

公共组件和工具函数/自定义 Hook,有时间的话写点单元测试,受益匪浅哦。覆盖率尽可能大于 70%,单元测试工具推荐 Jest

应用优化

  • 加快首屏
    • 组件/路由/资源懒加载
    • 本地/离线缓存
  • 减小包体积
    • 代码分割
    • 按需加载
    • tree shaking

总结

有讲的不对、需要改进的地方,欢迎评论,一块进步 ~

· 7 min read

最近有个项目蛮折磨人的,里面有个需求是将系统发出的消息转成语音信息,一开始简单查了一下,发现其实主流浏览器都有现成的语音合成 api

SpeechSynthesis - MDN

顺手封装成了一个工具函数,在后续项目中持续使用:

export function speechSpeak(
// 播报消息
msg: string,
// 播报次数
count = 1,
// 播报异常回调
errorCallback?: (error: string) => void
) {
const utterance = new SpeechSynthesisUtterance();
utterance.lang = "zh-CN";
utterance.text = msg;
for (let i = 0; i < count; i++) {
speechSynthesis.speak(utterance);
}
utterance.onerror = (event) => {
console.log("===语音播报异常", event);
// 异常状态码参考 https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisErrorEvent/error
errorCallback && errorCallback(event.error);
};
window.onbeforeunload = function () {
// Chromium won't stop speaking after closing tab.
// So shut up, pls.
speechSynthesis.cancel();
};
}

当然你也可以封装成一个 promise 函数 ~

最好选择离线可用的语音包,否则会受网络影响; 通过 speechSynthesis.getVoices() 检查设备可用的语音包,检查对应的 localService 属性.

当时看了下 caniuse,发现这个语音合成 api 很多浏览器都支持了,而且支持的版本也蛮多,比如 chrome >= 33,就觉得这个 api 是靠谱的,也就没去想什么了。于是就有了以下踩坑日记..

第一次被恶心到的是浏览器的自动播放声音策略。在最新版移动端 chrome 和 ios 的浏览器上基本无解(有解决办法的欢迎 comment,反正我是查了很多资料都没找着方法)。最终找到的靠谱方法有以下几个,都是通过一些方法关闭用户交互限制:

  • pc 端,可以通过设置声音的白名单实现,chrome 和 safari 实践可行(chrome 打开 chrome://settings/content/sound,在允许播放声音里添加网站)
  • window pc 可以通过命令行启动 chrome(关闭自动播放策略,chrome.exe --autoplay-policy=no-user-gesture-required
  • 降级到 chrome 74-76 版本,在 chrome://flag 中关闭用户交互限制。在 pc 或安卓都可以自动播放(ios 没试过,找不到那个版本的包...)

其他未经实践的思路有:

  • windows 平板,可能和 windows pc 一样可以配置网页白名单允许自动播放?或者通过命令行启动浏览器?
  • 做成移动端应用(react-native/flutter/...)

其实不难看出,自动播放策略在各个系统上的不同版本的浏览器的表现不太一样,目前看来所以最稳妥的方式无疑是做成移动端应用..

如果产品要往外推广的话,肯定要把这个限制干掉。

第二个恶心到我的是经常会偶现不能发出语音,即使是在确保用户有初次交互的情况下。当然,绝大部分是 pad 没收到消息,这里麻烦的点在于怎么定位语音问题,比如怎么确定 pad 有没有收到消息、是不是设备或者浏览器版本限制、是不是网络因素?

场景可以简单描述下,是通过 pad 访问车上的 pc,那这其中肯定就需要 pad 有移动网络。当时是在车上的 pc 有发语音,但 pad 没出声音(在用户交互按钮打开的情况下)。这种情况其实有可能是网络因素导致语音不稳定。所以思路有两点:

  • 记录 pad 的语音消息日志,输出到 pad 上。更好的方式是搭建异常监控平台,通过埋点的方式监控语音消息的接收情况,以可视化的形式展示出来,就不用额外占用网页空间
  • 但其实如果在车上,也可以通过数据线调试。但我们的测试工程师对于 web 网页调试技能比较欠缺,他们更多的关注系统和应用层面的测试。这块也就不太可行 emm

这以上问题,全是开发和测试过程中不断踩出来的。所以对于语音合成的实现,无非就是以下几个策略

  • 无自动播放需求,加个按钮确保页面加载的初次交互
    • 使用百度语音合成或者其他合成方式,先转成音频文件发给前端,收到后再播放出来
    • 如果需要离线,那只能借助下浏览器的语音合成 api 了,而且还要先查一下是否有对应的离线包(在安卓 pad 上基本都找不到离线的语音包..可能是文件大小限制?)
    • 或者可以写个 c ++ 服务,转成 wasm 供前端调用,前提是 c++服务能实现语音合成并输出音频的二进制流给到前端
  • 有自动播放需求
    • pc 设备可控,配置白名单
    • 做成 app,百度或者讯飞都有对应的语音合成离线包(安卓/ios)

当然,如果你要在网页实现自动播放,那要么设备可控、要么浏览器版本可控,限制其实蛮多的,说不定还有更多的坑等着你

以上种种会踩坑也是由于自身知识广度不够,加上调研不充分导致。by the way,持续跟踪这个特性吧,希望未来能更加稳定

· 5 min read

一般公司内都会搭建自己的私有 npm 仓库(可以基于 nexus 搭建),主要用途:

  • 在内部服务器存储自研包,安全又方便
    • 组件库,比如基础组件/业务组件/权限组件/...
    • 工具库,比如 hooks/utils/...
  • 缓存第三方包,加速下载
  • 对 npm 包可以配置权限管理

nrm

nrm 用于管理 npm 源

npm i nrm -g
nrm --help

nrm 默认已内置一些常用 npm 源(taobao/cnpm/...),常用方法如下:

# 查看npm源列表和当前使用的npm源
nrm ls
# npm config get registry 查看当前使用的源
# 添加
nrm add <name><npm源>
# 切换
nrm use <name> # == npm config set registry <npm源>
# 删除
nrm del <name>
# 测试npm源的响应时间
nrm test <npm源>

登录

先切换到私有源,第一次登录需要先通过以下命令输入用户名、密码、邮箱

npm adduser

后续就可以直接通过以下命令登录

npm login
# 查看当前登录用户
npm whoami
# 退出
npm logout

创建包和发布

npm init 初始化 node 项目,package.json 如下:

{
"name": "my-pkg",
"version": "1.0.0",
"description": "",
"author": "",
"license": "MIT",
"main": "./bin/index.js",
"bin": {
"test-pkg": "./bin/index.js"
}
}

bin 指定包的可执行文件,编辑 ./bin/index.js

#!/usr/bin/env node
console.log("007");
module.exports = "007";

#!/usr/bin/env node 的作用是告诉系统使用 node 解释脚本

调试

开发完项目后先在本地调试,进入项目目录,执行 npm link 将包链接到全局环境,然后尝试输入 test-pkg,看输出是否正常

之后记得通过 npm unlink <包名> 解除链接

版本

按语义化版本控制规范 SemVer,版本格式为:major/minor/patch,版本号递增规则如下:

  • 大版本更新(包含 breaking change
  • 小版本更新(小的功能迭代)
  • 补丁更新(bugfix
  • 先行版本号及版本编译信息可以加到 major/minor/patch 的后面,作为延伸。如:1.0.0-alpha.0

发包前可以通过 npm version 控制版本信息

npm version prepatch # 可以指明预发版关键词 --preid=alpha # v1.1.0-alpha.0
# v1.0.1-0
npm version prerelease
# v1.0.1-1
npm version patch # 结束预发版
# v1.0.1

发布

  1. https://www.npmjs.com/signup 注册 npm 账户
  2. 在 npm 账户上启用 2FA https ://docs.npmjs.com/configuring-two-factor-authentication
  3. 在本地终端登录 npm login,后续流程如下
npm login
// 输入账号名、密码、注册所用的邮件地址、OTP秘钥,没问题就会登录成功
// Logged in as xxx on https://registry.npmjs.org/.
  1. 打包,注意 package.json 的格式如下:
{
// name 需要声明个人账号名,即 @lucascv/包名
"name": "@lucascv/box-resizer",
// 每次发布需要更新 version,否则会提示发布失败
"version": "1.0.6",
// cjs 包入口
"main": "lib/index.cjs.js",
// esm 包入口
"module": "lib/index.esm.js",
// 不能设置为 true
"private": false,
}

进入包目录,先打包

yarn build
npm publish --access=public

之后在本地安装新发布的包并测试

npm i -g my-pkg

输入命令 test-pkg 查看输出是否正常

# 删除包
npm unpublish <name@version>
# 删除整包
npm unpublish <name> --force

出现 401/403 报错时,需要检查登录状态和项目是否和已有包冲突;如果是公司内部仓库需要额外申请发包权限

一些实践

下面是我对第三方包的打包和发布的一些实践

· 8 min read

这个组件其实算是我参加正式工作以来真正意义上的第一个自研的组件,很早就开源到 github 了,虽然只收获了 3 个 star,但也还是蛮值得纪念的

不过当时是将一整个 react demo 项目上传,在 demo 项目里引用 BoxResizer 组件,不算是正规的三方库。所以最近就索性用 rollup 将其打包成一个稍微正规点的三方库便于引用

github 链接

写在前面

为啥用 rollup?其实主流的 js 库都会用它来打包,比如 antd、react、vue、balabala... 有以下几个优势:

  • 轻量、代码简洁
  • 默认支持 tree shaking
  • 插件丰富、社区活跃

其实对比 webpack,主要就在于代码简洁和打包产物体积小吧。不过现在也出了蛮多其他的构建工具了,比如最近号称 700 倍快于 webpack 的 turbopack,基于 go 的esbuild以及在其基础上的 vite、基于 rust 的 swc,感觉就是前端很卷很卷。不过大部分场景实际上都还不需要用到这么高效的工具,webpack、rollup 足矣...

那什么时候用 webpack?我理解在应用级项目中建议使用,可以带来比较好的开发体验,比如代理服务、热更新等等

项目初始化

yarn init
yarn add rollup eslint prettier -D
npx eslint --init

先加下代码规范配置 eslintprettier,参考 统一前端编码规范和相关工具

因为这是个 react 组件,使用技术栈是 react 和 typescript,所以需要先增加 ts 相关配置,其中 rollup-plugin-typescript2 是用于 rollup 编译 ts 代码的插件

yarn add typescript rollup-plugin-typescript2 -D
npx tsc --init

tsconfig.json 如下:

{
"compilerOptions": {
"target": "es2016",
"outDir": "lib",
"module": "ES2015",
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react",
// 生成声明文件,ts项目导入需要
"declaration": true, /* 生成相关的 '.d.ts' 文件。 */
"declarationDir": "./lib", /* '.d.ts' 文件输出目录 */
},
"include": ["src"]
}

更多选项参考 ts 编译选项

增加 babel 插件支持 js 代码编译

yarn add rollup-plugin-babel @babel/core @babel/preset-env @babel/preset-react -D

增加 .babelrc

{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

因为项目中使用了 less 和 css module 来编写 css 代码,需要借助 rollup-plugin-postcss 插件来编译

yarn add rollup-plugin-postcss postcss less -D

项目结构如下:

box-resizer
├─ src
│ ├─ components
│ │ └─ box-resizer
│ │ ├─ index.module.less
│ │ └─ index.tsx
│ ├─ index.ts
│ └─ react-app-env.d.ts
├─ README.md
├─ package.json
├─ rollup.config.js
├─ tsconfig.json
└─ yarn.lock

其他插件:

  • 显示包体积 rollup-plugin-filesize
  • 压缩代码 rollup-plugin-uglify

最终的 rollup.config.js 参考如下:

import babel from "rollup-plugin-babel";
import typescript from "rollup-plugin-typescript2";
import postcss from "rollup-plugin-postcss";
import filesize from "rollup-plugin-filesize";
import { uglify } from "rollup-plugin-uglify";

const isProd = process.env.NODE_ENV === "production";

export default {
input: "src/index.ts",
output: {
file: "./lib/index.esm.js",
format: "esm",
},
plugins: [typescript(), babel(), postcss(), filesize(), isProd && uglify()],
external: ["react"],
};

打包组件和测试

package.json 配置命令

// cjs包入口
"main": "lib/index.cjs.js",
// esm包入口
"module": "lib/index.esm.js",
"scripts": {
"start": "rollup -c -w",
"build": "rm -rf ./lib && NODE_ENV=production rollup -c --bundleConfigAsCjs",
}

执行打包命令

yarn build

注意打包后需要生成对应的 d.ts 文件,没有的话 check 下 tsconfig.json

本地测试

// 在 box-resizer 目录下执行,给包做个本地链接
yarn link
// 建一个demo项目用来验证组件,进入项目根目录
cd demo
yarn link @lucascv/box-resizer

之后就可以在项目中正常引入

// @ts-ignore
import { BoxResizer } from '@lucascv/box-resizer'

然后在页面验证下吧 ~

注意本地 yarn link 可能会存在不同 react 版本的问题(hook invalid...)react 官网也有提示 https://reactjs.org/warnings/invalid-hook-call-warning.html#duplicate-react

可以将 react 放在 package.json 的 peerDependencies 中,使其依赖宿主环境的 react 版本,这样就不会导致冲突了,如下

"peerDependencies": {
"react": ">=16.12.0",
"react-dom": ">=16.12.0"
}

这里也稍微提下三种依赖包:

  • dependencies 生产依赖。指应用运行时依赖的第三方包
  • devDependencies 开发依赖。指开发阶段依赖的第三方包,比如构建包期间需要的 rollup、typescript、babel、postcss 以及对应的插件、jest 等等
  • peerDependencies。提示宿主环境去安装满足 peerDependencies 所指定的包,然后在插件导入包的时候,永远都是引用宿主环境统一安装的包,避免重复安装,可以解决插件与所依赖包不一致的问题

打包 esm 和 cjs 包

当然作为组件其实没必要生成 cjs 包,况且现在很多第三方包也抛弃了 cjs,这里的目的是为了以后开发其他库做的提前练习。首先改下 rollup.config.js 和 package.json

// rollup.config.js
output: [
{
file: 'lib/index.cjs.js',
format: 'cjs'
},
{
file: 'lib/index.esm.js',
format: 'esm'
}
],

执行 yarn build,会看到 lib 中生成了两种格式的包

但注意此时通过 commonjs 方式引入的包,并没有对应的类型提示

单元测试

一般应该要通过测试用例,才能允许合入和发布。现在还没做,先过吧

组件怎么做单元测试?后续参考下开源组件的单元测试方法再补充吧

版本维护

在功能修改后提交一个 tag,便于后续追踪。按照 semver 语义化版本规则 来就行

git tag v1.0.0 -m "第一个上线版本"
// 或者可以用 npm version 标记版本
// npm version [major|minor|patch]

git push origin v1.0.0

发布

  • 创建 npm 账号,比如我是 lucascv(lucas 是我英文名,cv 也就是复制粘贴工程师)
  • 包名(package.json 的 name)以账号名开头,比如@lucascv/box-resizer,这样才能通过发布审核
// 注意切回官方源
nrm use npm
npm login
npm publish --access=public

更多 npm 发包内容可以参考我之前总结的文章 - npm 发包

如何自动化发布?在 develop merge 到 master 时自动推送到 npm 库?并更新版本,也就是自动打 tag

总结

头一次产出自己的组件包,后面也有计划开发自己的组件库,在这个过程持续增强个人的工程化能力。该文章会随着组件的优化持续更新,欢迎 comment 哈 ~

· 6 min read

现在开发项目,提到图表,十有八九都会选择 echarts,不论是使用、性能或者社区活跃度都不错。

使用

react 项目可以使用 echarts-for-react^3 版本对应 echarts^5

import ReactECharts from "echarts-for-react";

const option = {};

<ReactECharts
option={option}
notMerge={true}
lazyUpdate={true}
onChartReady={this.onChartReadyCallback}
onEvents={EventsDict}
/>;

绝大部分项目都是只需要绘制小数据量的图表,外加一些动画或者悬浮交互,这些通过调用 api 就能满足需求。当然,api 也确实有很多,建议先熟悉初始配置、setOption 、事件,之后再根据具体图表类型再进一步熟悉和应用

文档链接

遇到的问题

实际上我遇到的问题,可以归纳为 高频数据下发,数据量不保证,前端页面需要确保曲线图绘制的实时性。这里涉及到三点可能导致卡顿的问题

  • 高频数据
  • 大对象数据解析
  • 绘制曲线图

解决办法主要是以下几点:

1.数据解析角度

  • 减小数据量

  • 后端(c++)解析数据,相比 js 解析数据肯定要更快

  • web worker。分担主线程解析和生成曲线数据的压力

  • 分流。可在后端或者 worker 中处理,按固定频率绘制曲线

    2.绘制角度

  • 降采样。不适配需求,当然 echarts 有很好的降采样算法,单看趋势的话,其实完全可以试试 ~

  • 去除动画,减少绘制耗时

  • 设置 showSymbol 为 false,即减少绘制元素,包括不必要的悬浮框等

  • 只渲染固定范围,控制耗时

最终的优化方案其实就用到了上面提到的几点:

  • 后端处理数据,转换成前端绘制需要的曲线数据,做好节流(比如 1s 绘制一次)
  • 前端接收 json 并解析
  • 维护一个队列,控制只绘制固定范围(用户自定义)的曲线,将耗时控制在 30ms 以内(不用 worker 是因为这一步处理耗时并不多)
  • 前端去除多余的动画、绘制元素以确保不产生无用的耗时

其他优化思路,待验证

  • 升级 echarts5,使用脏矩形优化,初步对比优化效果不明显。可能是数据量还没达到百万级别?
  • Web Worker + Transferable Objects
  • 增量渲染 appendData
  • 离屏渲染
  • canvas 分层

这些思路可能还要研究源码才能验证,因为经过之前的优化方案已经足以满足需求也就不再继续研究了。有空的话接着学习下 ~

按需引入

默认的引入方式会引入 echarts 中所有的图表和组件,建议按需引入

// 默认引入方式
import * as echarts from "echarts";

按需引入,详情参考 按需引入 ECharts 图表和组件,主要代码如下:

// 引入 echarts 核心模块
import * as echarts from "echarts/core";
import { BarChart } from "echarts/charts";
import { TitleComponent, TooltipComponent } from "echarts/components";
import { LabelLayout } from "echarts/features";
// NOTE: 必须引入渲染器
import { CanvasRenderer } from "echarts/renderers";
// 注册组件
echarts.use([
TitleComponent,
TooltipComponent,
BarChart,
LabelLayout,
CanvasRenderer,
]);
// 接下来的使用就跟之前一样了
const myChart = echarts.init(document.getElementById("chart"));
myChart.setOption({
// ...
});

echarts-for-react 按需引入(对于 echarts5)

import ReactEChartsCore from "echarts-for-react/lib/core";
import * as echarts from "echarts/core";
import { LineChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
TitleComponent,
DatasetComponent,
} from "echarts/components";
// NOTE: 必须引入渲染器
import {
CanvasRenderer,
// SVGRenderer,
} from "echarts/renderers";

echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
LineChart,
CanvasRenderer,
]);

const option = {};

<ReactEChartsCore
echarts={echarts}
option={option}
notMerge={true}
lazyUpdate={true}
onChartReady={this.onChartReadyCallback}
onEvents={EventsDict}
/>;

echarts 5 以下版本的按需引入参考文档(搜 With Echarts.js v3 or v4:) https://github.com/hustcc/echarts-for-react/

图表对比

实际上我用过的图表库很少,所以这里只是简单提一嘴..更详细的 benchmark 还需要自行搜索资料和验证 ~

  • echarts。不论是图表类型、api、使用体验和性能都是目前最佳,但是封装程度非常高,所以很难对其进一步抽象和封装,表现为定制化能力差
  • G2。对比前者,在数据对图形的控制上要更灵活,即定制化能力更强
  • biz-charts。基于 G2 进一步封装,更易于使用

在这版需求中,其实我基本上复刻了 webviz 的曲线图组件,支持实时和离线场景,后续可以尝试封装成通用组件,并提供下借鉴经验。可以期待下哈 ~

· 8 min read

最近在做折线图(echarts)相关的需求,有遇到应用卡顿的情况,分析了卡顿原因后,发现在生成折线图的数据时耗时较大(从对象获取指定数据/数组过滤/排序/裁减),决定用 web worker 来做一些曲线绘制前的数据处理工作

Web Worker

js 是单线程执行的,但浏览器为其提供了多线程的能力 - web worker,借助 web worker 可以并行主线程做一些数据解析或计算的工作。但有一定的局限性,比如不能操作 DOM、访问 window、document 对象等、无法读取本地文件等。然后也会有额外的问题,比如内存占用、通信损耗

所以使用 web worker 并非一定会提升应用性能,需要分析当前性能瓶颈主要是卡在哪里,对症下药。解决性能问题往往需要多种方法来协调,大部分情况下解决关键因素即可,比如数据解析、计算、某个 long task 或者渲染耗时

分类:

  • Dedicated Worker
  • SharedWorker
  • ServiceWorker

其他 Worker 自行了解,下面着重讲 Dedicated Worker,以下简称 Worker

单页面应用使用 Worker

以 react 为例,如果是基于最新的 CRA 脚手架搭建的项目,即基于 webpack5 的项目,可以比较便捷地生成 Worker

const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.postMessage({
name: "Lucas",
});
worker.onmessage = ({ data: { name } }) => {
console.log(name);
};

import.meta 是一个内置在 ES 模块内部的对象,import.meta.url 表示一个模块在浏览器和 Node.js 的绝对路径。该特性属于 es2020 的一部分,webpack5 才支持

如果是基于 webpack4 的项目,那需要借助其他方法了

第一种比较简单粗暴,可以把 worker.js 固定放在 public 文件夹下,默认打包后 public 下的文件是固定放在根路径下,可以通过 http://localhost:3000/worker.js 找到

第二种是推荐做法,引入 worker-loader

  • 修改项目 webpack 配置。如果是 CRA 创建的项目,需要借助 react-app-rewired 扩展配置或者直接 eject 导出项目的 webpack 配置进行修改(不推荐)。改动内容大致如下,增加一个针对 worker 脚本的 loader 处理流程
module.exports = {
module: {
rules: [
{
// 以 .worker.js 结尾的文件将被 worker-loader 加载
test: /\.worker\.(c|m)?js$/i,
use: {
loader: "worker-loader",
},
},
],
},
};

CRA 项目中引入

const { override, addWebpackModuleRule } = require("customize-cra");

module.exports = {
webpack: override(
addWebpackModuleRule({
test: /\.worker\.(c|m)?js$/i,
use: [
{
loader: "worker-loader",
},
],
})
),
};

使用方法很简单,直接 import 导入并实例化

// main.ts
import Worker from "./test.worker.ts";
const worker = new Worker();
worker.onmessage = (event) => {
const data = event.data;
};
worker.postMessage({ data: "from main" });
worker.onerror();
// 不用的话可以关闭,节省内存
worker.terminate();
// test.worker.ts
declare const self: any;
export default {} as typeof Worker & { new (): Worker };
self.onmessage = (event) => {
const data = event.data;
}
self.postMessage({ data: "from worker" });
self.onerror();
self.close();

为了保证 worker 中的代码被 babel 转译,可以让 babel-loaderworker-loader 之前执行。ts-loader 同理

为什么不能直接 import 引入?

好问题..如果是直接 import 导入,那肯定是需要将它转成脚本路径,比如下面

import workPath from "./worker.js";
const worker = new Worker(workPath);

同样也是需要借助特定的 loader,类似于 file-loader。至于 worker-loader 则是将new Worker(workPath)的步骤内置到 loader 处理流程了,并导出一个函数,外面直接使用该函数即可创建指定的 Worker

worker-loader 是咋工作的?

其实原理不难,主要就是俩个步骤:

  1. webpack 构建过程匹配到 worker 脚本(xx.worker.js)
  2. 将文件名和源代码传入 worker-loader 处理函数中,主要输出以下内容
module.exports = function () {
return new Worker(__webpack_public_path_ + "123abc.worker.js");
};

loader inline 模式输出内容不太一样,参考 worker-loader 源码

第三种不推荐,是将 worker.js 的主函数转化为 blobUrl 导出,供主线程引用。该方法的好处是可以动态创建 worker

// worker.js
const contentCode = function () {} // worker 脚本主函数
const blob = new Blob([contentCode.toString()], {type: 'text/javascript'});
export {url: URL.createObjectURL(blob)}
// main.js
import { url } from './worker.js'
const worker = new Worker(url);

Worker 通信

  • 拷贝通信(Structured Clone),即 postMessage,对于复杂对象,可能有人觉得先序列化成字符串再拷贝通信效率会更高点,答案是不确定的。有人做了详细的对比,可以参考 https://dassur.ma/things/is-postmessage-slow/
  • 转移内存(Transfer Memory)。只支持 Transferable Objects,有数据独占和数据类型的限制
  • 共享内存SharedArrayBuffer)。浏览器兼容性很差,继续观察...

transfer 可转移对象是 ArrayBuffer、MessagePort、ImageBitmap 等二进制数据。Worker 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这使得主线程可以快速把数据交给 Worker,对于影像、声音处理等复杂计算就很有帮助

Worker 使用场景

  • 数据预取和预解析(本文开头提到的场景)
  • 视频解码:一般的视频网站 以优酷为例,当我们开始播放优酷视频的时候,就能看到它会调用 Worker,解码的代码应该写在 Worker 里面
  • 复杂文件解析。需要大量计算的网站 比如 imgcook 这个网站,它能在前端解析 sketch 文件,这部分解析的逻辑就写在 Worker 里
  • 拼写检查
  • 数据加密
  • 光线追踪...

其实主要都是为了不阻塞页面 ui,提高用户体验,但当通信比较耗时、计算不复杂时,这种时候用 Worker 就有些得不偿失了。当然,以上大部分场景都还只是在一些资料中看到,暂时还没实际项目可实践到,以后有机会再进一步写写 Worker 相关的一些文章吧

· 7 min read

最近自己心血来潮,结合之前沉淀的项目模板(react/vue/koa2...)开发了一个项目脚手架(cli) jupiter,刚好团队内缺少这样一个工具,于是也开始在团队内推广使用

Github:https://github.com/GitHubJackson/jupiter

jupiter v1.0

1.0 版本主要支持以下功能:

  • 根据用户选择生成代码模板(react/vue/vite-vue/koa2
  • 统一的编码和项目规范配置(eslint/stylelint/commitlint
  • 预装公用库,包括社区高效的开源库(ahooks、lodash)、团队内部的组件库、工具库等等
  • 支持检测更新

cli 命令

# 新建项目
jupiter init [项目名]
jupiter -v # --version
jupiter -h # --help
# 手动检测cli更新
jupiter upgrade

开发脚手架

准备

  • Node.js 运行环境
  • npm/yarn

新建项目

npm init -y

jupiter 项目结构参考以下:

├─ bin
│ └─ index.js
├─ lib
│ ├─ init.js
│ ├─ download.js
│ └─ update.js
├─ .gitignore
├─ LICENSE
├─ README.md
├─ yarn.lock
└─ package.json

package.json 增加以下字段,npm link 和全局执行包需要指定 bin

"main": "./bin/index.js",
"bin": {
"jupiter": "./bin/index.js"
}

然后安装相关依赖

yarn add -D chalk commander download fs-extra handlebars inquirer log-symbols ora update-notifier
  • chalk。实现比较好看的日志输出
  • commander。提供用户命令行输入和参数解析的功能
  • inquirer。用户与命令行交互的工具
  • update-notifier。检查更新
  • fs-extra。fs 加强版
  • ora。实现等待动画
  • handlebars。语义化模板
  • log-symbols。提供各种日志级别的彩色符号

获取版本

package.json 中的 version

修改 bin/index.js

#!/usr/bin/env node
const program = require("commander");

program.version(require("../package.json").version, "-v, --version");

program.parse(process.argv);

其中 #!/usr/bin/env node 必加,主要是让系统看到这一行的时候,会沿着对应路径查找 node 并执行。调试阶段时,为了保证 jupiter 指令可用,我们需要在项目下执行 npm link 软链接到全局(不需要指令时用 npm unlink 断开链接),然后打开终端输入

jupiter -v

查看输出是否正确

检查更新

新增 lib/update.js

const updateNotifier = require("update-notifier");
const chalk = require("chalk");
const pkg = require("../package.json");

const notifier = updateNotifier({
pkg,
// 设定检查更新周期,默认为 1 天
// updateCheckInterval: 1000,
});

function updateChk() {
if (notifier.update) {
console.log(
`New version available: ${chalk.cyan(
notifier.update.latest
)}, it's recommended that you update before using.`
);
notifier.notify();
} else {
console.log("No new version is available.");
}
}

module.exports = updateChk;

update-notifier 检测更新机制是通过 package.json 文件的 name 字段值和 version 字段值来进行校验:它通过 name 字段值从 npm 获取库的最新版本号,然后再跟本地库的 version 字段值进行比对,如果本地库的版本号低于 npm 上最新版本号,则会有相关的更新提示

修改 bin/index.js

const updateChk = require("../lib/update");

// 检查更新
program
.command("upgrade")
.description("Check the jupiter version.")
.action(() => {
updateChk();
});

program.parse(process.argv);

终端执行 jupiter upgrade ,本地测试可以将 package.json 的 name 改为 react 看看效果

注意:Chalk v5 已经使用 esm 重构,所以为了支持 commonjs,在该项目中还是要沿用 v4 版本

下载模板

这里通过 download-git-repo 下载 github 上的模板代码

下载模板的操作要能强制覆盖原有文件,主要是两步:

  1. 清空文件夹
  2. 下载文件并解压到文件夹

新增 lib/download.js

const download = require("download-git-repo");
const ora = require("ora");
const chalk = require("chalk");
const fse = require("fs-extra");
const path = require("path");

const tplPath = path.resolve(__dirname, "../template");

const asyncDownload = function (template, tplPath) {
return new Promise((resolve, reject) => {
download(template, tplPath, { clone: true }, function (err) {
if (err) {
reject(err);
}
resolve();
});
});
};

async function dlTemplate(answers) {
// 先清空模板目录
try {
await fse.remove(tplPath);
} catch (err) {
console.error(err);
process.exit();
}
const dlSpinner = ora(chalk.cyan("Downloading template..."));
const { name, type } = answers;
const templateMap = {
react: "github:GitHubJackson/react-spa-template#main",
vue: "github:GitHubJackson/vue-spa-template#main",
"vite-vue": "github:GitHubJackson/vite-vue-template#main",
koa: "github:GitHubJackson/koa2-template-lite#main",
};

dlSpinner.start();
// 下载模板后解压
return asyncDownload(templateMap[type], tplPath)
.then(() => {
dlSpinner.text = "Download template successful.";
dlSpinner.succeed();
})
.catch((err) => {
dlSpinner.text = chalk.red(`Download template failed. ${err}`);
dlSpinner.fail();
process.exit();
});
}

module.exports = dlTemplate;

具体使用参考下面的 init 函数

init

这个是 cli 的关键函数,主要流程就是:

  1. 获取用户输入的项目名和选择的模板类型
  2. 根据模板类型下载项目模板至命令路径

新增 lib/init.js

const fse = require("fs-extra");
const ora = require("ora");
const chalk = require("chalk");
const inquirer = require("inquirer");
const symbols = require("log-symbols");
const handlebars = require("handlebars");
const path = require("path");

const dlTemplate = require("./download");
const tplPath = path.resolve(__dirname, "../template");

async function initProject(projectName) {
try {
const processPath = process.cwd();
// 项目完整路径
const targetPath = `${processPath}/${projectName}`;
const exists = await fse.pathExists(targetPath);
if (exists) {
console.log(symbols.error, chalk.red("The project is exists."));
return;
}

const promptList = [
{
type: "list",
name: "type",
message: "选择项目模板",
default: "react",
choices: ["react", "vue", "vite-vue", "koa"],
},
];

// 选择模板
inquirer.prompt(promptList).then(async (answers) => {
// 根据配置拉取指定项目
await dlTemplate(answers);
// 等待复制好模板文件到对应路径去,模板文件在 ./template 下
try {
await fse.copy(tplPath, targetPath);
console.log("copy success");
} catch (err) {
console.log(symbols.error, chalk.red(`Copy template failed. ${err}`));
process.exit();
}
});
} catch (err) {
console.error(err);
process.exit();
}
}

bin/index.js 增加初始化命令

// init 初始化项目
//...
program
.name("jupiter")
.usage("<commands> [options]")
.command("init <project_name>")
.description("create a new project.")
.action((project) => {
initProject(project);
});
//...

help

查看帮助

program.on("--help", () => {
console.log(
`\r\nRun ${chalk.cyan(
`zr <command> --help`
)} for detailed usage of given command\r\n`
);
});

上传 npm

上传和调试 npm 包可以参考 发布 npm 包

使用

这个是个人日常开发常用的脚手架工具,欢迎使用和 comment,本项目会持续迭代

npm i -g @jacksonzhou52017/jupiter
jupiter init myapp

备注

可以到这里查看后续开发计划 https://github.com/GitHubJackson/fe-engineering/issues/4