普通视图
F2C-PTD工具将需求快速转换为代码实践
HarmonyOS 5.0应用开发——V2装饰器@local的使用
后端转全栈之Next.js文件约定
JS-SDK开发企微侧边栏
可可图片编辑 HarmonyOS(6)水印效果
PS2020,将所有图片不剪切,调整为800×800像素的文档尺寸。
Java 自适应自旋锁机制详解:原理、优缺点与应用场景
Supabase云同步架构:Flutter应用的数据同步策略
# vue3 实现前端生成水印效果
vue3 实现前端生成水印效果
首先一点哈,就是单纯web前端生成水印只能作为警示使用,如果享彻底防住几乎是不可能的,有无数种方式去掉web前端生成的水印,所以这种方式只当是一个君子协议吧。
编写水印组件
首先直接把这部分封装成一个组件吧,我这边直接上代码了。
创建一个 waterMark.vue
文件,用来编写水印组件:
<template>
<div class="watermark-container" ref="parentRef">
<slot></slot>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import useWaterMarkBg from './waterMarkBg';
const props = defineProps({
text: {
type: String,
default: "版权所有"
},
fontSize: {
type: Number,
default: 25,
},
gap: {
type: Number,
default: 20,
},
color: {
type: String,
default: 'rgba(201, 35, 35, 0.5)'
}
})
let div;
const bg = useWaterMarkBg(props);
const parentRef = ref();
const ob = new MutationObserver((entries) => {
for (const entry of entries) {
for (const node of entry.removedNodes) {
if (node === div) {
resetWatermark();
return;
}
}
if (entry.target === div) {
resetWatermark();
}
}
})
onMounted(() => {
resetWatermark();
ob.observe(parentRef.value, {
childList: true,
subtree: true,
})
})
onUnmounted(() => {
ob.disconnect();
})
// 重置水印
const resetWatermark = () => {
if (!parentRef.value) { return }
if (div) {
div.remove();
}
const { base64, size } = bg.value.value;
div = document.createElement('div');
div.style.position = 'absolute';
div.style.backgroundImage = `url(${base64})`;
div.style.backgroundSize = `${size.width}px ${size.height}px`;
div.style.backgroundRepeat = "repeat";
div.style.pointerEvents = 'none';
div.style.zIndex = '9999';
div.style.inset = 0;
parentRef.value.appendChild(div);
}
</script>
<style scoped lang="scss">
.watermark-container {
position: relative;
}
</style>
可以接受四个参数,如果不够可以自己加,分别是 text 水印文本内容
、fontSize 水印文本大小
、gap 水印文本间隔
、color 水印文本颜色
。
然后在水印组件加载完成的时候调用 resetWatermark
重置水印方法实现添加水印。
水印是动态生成的图片,最后创建了一个动态的div
加上页面的,因为还想尽可能的防止一下水印删除,所以说在中途检测了一下dom
修改情况,如果修改了,比如删除了div
,或者是修改了div
的样式,那么就重置水印,重新添加一遍。
其中在组件中还是用了 useWaterMarkBg
方法,下面代码是 waterMarkBg.js
文件的内容,可以根据自己的业务需求适当的修改:
import { ref, computed } from 'vue';
/**
* 创建水印背景图片的 composable 函数
* @param {Object} options - 水印配置选项
* @param {string} options.text - 水印文字内容
* @param {number} options.fontSize - 字体大小
* @param {number} options.gap - 水印间隔
* @param {string} options.color - 文字颜色,默认为半透明灰色
* @param {number} options.rotate - 旋转角度,默认为 -15 度
* @param {string} options.fontFamily - 字体,默认为 Arial
* @returns {Object} 返回包含 base64 和 size 的响应式对象
*/
function useWaterMarkBg(options = {}) {
// 默认参数
const defaultOptions = {
text: '版权所有',
fontSize: 25,
gap: 20,
color: 'rgba(201, 35, 35, 0.5)',
rotate: -15,
fontFamily: 'Arial, sans-serif'
};
// 合并用户参数和默认参数
const waterMarkOptions = ref({ ...defaultOptions, ...options });
// 计算水印尺寸和 base64 图片
const waterMarkInfo = computed(() => {
const { text, fontSize, gap, color, rotate, fontFamily } = waterMarkOptions.value;
// 创建 canvas 元素
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法获取 canvas 上下文');
}
// 设置字体
ctx.font = `${fontSize}px ${fontFamily}`;
// 获取文字宽度
const textWidth = ctx.measureText(text).width;
// 计算 canvas 尺寸(包含文字和间隙)
const width = textWidth + gap * 2;
const height = fontSize * 2 + gap * 2;
canvas.width = width;
canvas.height = height;
// 重置上下文(因为 canvas 尺寸改变了)
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 保存当前状态
ctx.save();
// 移动到 canvas 中心并旋转
ctx.translate(width / 2, height / 2);
ctx.rotate((Math.PI / 180) * rotate);
// 绘制文字
ctx.fillText(text, 0, 0);
// 恢复之前的状态
ctx.restore();
// 转换为 base64
const base64 = canvas.toDataURL('image/png');
return {
base64,
size: {
width,
height
}
};
});
// 如果传入的 options 发生变化,可以更新水印
function updateOptions(newOptions) {
waterMarkOptions.value = { ...waterMarkOptions.value, ...newOptions };
}
return {
value: waterMarkInfo,
updateOptions
};
}
export default useWaterMarkBg;
水印组件的使用
使用的时候就很简单了,引入一下,然后包裹一下需要添加水印的dom
就可以了:
<!-- 默认红色水印 -->
<water-mark text="严禁传播">
<div class="img-con">
<img src="../../assets/imgs/watermark/1.jpg" alt="图片1">
</div>
</water-mark>
<!-- 蓝色水印 -->
<water-mark text="禁止复制" :fontSize="25" color="rgba(30, 144, 255, 0.3)">
<div class="img-con">
<img src="../../assets/imgs/watermark/2.jpg" alt="图片2">
</div>
</water-mark>
<!-- 绿色水印 -->
<water-mark text="测试水印" :fontSize="25" color="rgba(50, 205, 50, 0.4)">
<div class="img-con">
<img src="../../assets/imgs/watermark/1.jpg" alt="图片3">
</div>
</water-mark>
效果
好了,大体就这个样子,还是,水印这个很容易删除,懂得人,删的很快,最好从源头解决,只要后端返回前端的是原文件,那么就可以从浏览器获取到没有水印的内容。
0到1理解web音视频从采集到传输到播放系列之《Jessibuca系列篇音视频解封装》
本课程主要从
- 音视频采集
- 音视频编码
- 音视频协议封装传输
- 音视频协议解封装
- 音视频解码
- 音视频播放
关于Jessibuca
- 官网地址:jessibuca.com
- Demo: Demo
- Doc:Doc
- Github地址:Github
关于JessibucaPro
- 地址:JessibucaPro
- Demo: Demo
- AI:AI
- 插件:插件
第四章:音视频解封装
通过网络请求,请求到了mp4/flv/hls/webm内容。
通过解封装协议可以解封装到音视频数据。
对于音频可以解封装到:aac/g711/mp3/opus 数据。
对于音频可以解封装到:h264/h265/v9 数据。
mp4
在MP4文件格式中,整个视频容器都是由多个box和子box组成,根据box类型主要分为3大类:视频类型(ftyp)
、视频数据(mdat)
、视频信息(moov)
。
视频信息(moov)用来描述视频数据(mdat)。
在web端,可以使用mp4Box.js
来封装mp4文件。
兼容性
目前web端 可以通过video
标签进行 mp4文件的播放。
flv
FLV(Flash Video)是目前最流行的流媒体格式
,其文件体积小、封装播放简单,非常适合在网络场景下应用。
对于FLV ,是由一个文件头(FLV header)
和 很多tag组成(FLV body)
,tag又可以分成三类:audio
,video
,script
,分别代表音频流
,视频流
,脚本流
,而每个tag又由tag header
和tag data
组成
文件头 (flv header)
在文件头内容里面:
- 前3个bytes是文件类型,总是“FLV”,也就是(0x46 0x4C 0x56)。
- 第4btye是版本号,目前一般是0x01。
- 第5byte是流的信息,倒数第一bit是1表示有视频(0x01),倒数第三bit是1表示有音频(0x4),有视频又有音频就是0x01 | 0x04(0x05),其他都应该是0。
- 最后4bytes表示FLV 头的长度,
flv header 的长度是:3+1+1+4 = 9。
在web端,利用js 来解封装flv协议。
// 解析flv header
const flvHeader = flvData.slice(0,9);
// 前3字节是 'FLV' 三个字符
const f = flvHeader[0];
const l = flvHeader[1];
const v = flvHeader[2];
// 第4btye是版本号,目前一般是0x01。
const version = flvHeader[3];
// 第5字节是Flags ,用于鉴别是否含有音频、视频数据
const hasAudio = ((flvHeader[4] & 4) >>> 2) !== 0;
const hasVideo = (flvHeader[4] & 1) !== 0;
// 最后4bytes表示FLV 头的长度。
若干个tag(flv body)
对于 flv body 是由:previous tag size #0 (4 size)
+ tag #1
+ previous tag size #1 (4 size) (tag #1的大小 11+ datasize)
+ tag #2
+ previous tag size #1(4 size)
+ ... + tag #n
+ previous tag size #n(4 size)
组成。
tag header
在tag header 里面
- 第1个byte为记录着tag的类型,音频(0x8),视频(0x9),脚本(0x12)18;
- 第2到4bytes是数据区的长度,也就是tag data的长度(头部之后的数据体的大小);
- 再后面3个bytes是时间戳,单位是毫秒,类型为0x12则时间戳为0,时间戳控制着文件播放的速度,可以根据音视频的帧率类设置;
- 时间戳后面一个byte是扩展时间戳,时间戳不够长的时候用;
- 最后3bytes是streamID,但是总为0。
tag header 的长度是: 1+3+3+1+3=11。
在web端,利用js 来解封装flv协议。
const tagType = flvData[0] & 0x1F; // 5bit代表类型,8:audio 9:video 18:script other:其他
//
const tmp = new ArrayBuffer(4);
const dv = new DataView(tmp);
// 2-4 消息长度
dv.setUint8(0, flvData[3]);
dv.setUint8(1, flvData[2]);
dv.setUint8(2, flvData[1]);
dv.setUint8(3, 0);
// tag data的长度(头部之后的数据体的大小);
const payloadLen = dv.getUint32(0, true);
// 5-7 时间戳
dv.setUint8(0, flvData[6]);
dv.setUint8(1, flvData[5]);
dv.setUint8(2, flvData[4]);
// 8-8 时间戳扩展
dv.setUint8(3, flvData[7]);
const dts = dv.getUint32(0, true);
// 最后3bytes是streamID,但是总为0。
tag data
裸流数据
script tag
该类型Tag又通常被称为Metadata Tag,会放一些关于FLV视频和音频的元数据信息如:duration、width、height等。
通常该类型Tag会跟在File Header后面作为第一个Tag出现,而且只有一个。
主要结构: 由AMF1("onMetaData")
+ AMF2("width,height,...")
组成。
audio tag
通过 tag header
里面得知是audio tag
,接下来就是解析tag data
数据了
在 tag header 里面 第一个字节 表示 音频头(audio tag header)
音频头(audio tag header)
在音频头里面:
1-4bit,音频格式【SoundFormat】
0 = Linear PCM, platform endian
1 = ADPCM
2 = MP3
3 = Linear PCM, little endian
4 = Nellymoser 16 kHz mono
5 = Nellymoser 8 kHz mono
6 = Nellymoser
7 = G.711 A-law logarithmic PCM , reserved
8 = G.711 mu-law logarithmic PCM , reserved
9 = reserved
10 = AAC (supported in Flash Player 9,0,115,0 and higher)
11 = Speex (supported in Flash Player 10 and higher)
14 = MP3 8 kHz , reserved
15 = Device-specific sound , reserved
5-6bit,采样率【SoundRate】
0 = 5.5kHz
1 = 11kHz
2 = 22kHz
3 = 44kHz
7-7bit,位宽,0 = 8bit samples, 1= 16bit samples【SoundSize】
8-8bit,通道,0 = Mono, 1 = Stereo【SoundType】
如果 音频格式是aac 的话,第二个字节表示aac音频类型
0 = AAC sequence header
1 = AAC raw
剩下的字节数据就是纯音频数据
了
小结:
如果是aac tagheader(1) + 音频类型(1) + (n-2)
如果是g711a/u tagheader(1) + (n-1)
在web端,利用js 来解封装flv协议。
// 音频格式【SoundFormat】
let soundId = (flvData[0] >> 4) & 0x0F;
let soundrate = (flvData[0]>>2)&0x02;
let soundsize = (flvData[0]>>1)&0x01;
let soundtype = (flvData[0])&0x0F;
if(soundId === '10'){
// aac about sequence header and raw
// [2-2]:AAC音频类型,注,只有在SoundFormat=AAC 时,才有此数据
// 0 = AAC sequence header
// 1 = AAC raw
const packetType = flvData[1];
if (packetType === 0) {
const config = flvData.slice(0, this.needLen);
const aacSequenceHeader = flvData.slice(2, this.needLen);
console.log('AAC sequence header');
} else {
// AAC raw
const aacRaw = flvData.slice(2, this.needLen);
console.log('AAC raw')
}
else{
// g711 音频数据
const g711Raw = flvData.slice(1, this.needLen);
}
video tag
在 tag header 里面 第一个字节 表示 视频头(video tag header)
1-4bit,帧类型【FrameType】
1 = key frame (for AVC, a seekable frame)
2 = inter frame (for AVC, a non-seekable frame)
3 = disposable inter frame (H.263 only)
4 = generated key frame (reserved for server use only)
5 = video info/command frame
5-8bit,编码类型【CodecID】
2 = Sorenson H.263
3 = Screen video
4 = On2 VP6
5 = On2 VP6 with alpha channel
6 = Screen video version 2
7 = AVC(H.264)
第 2-5 个字节是 CompositionTime
小结
video tag header(1) + compsitionTime(4) + 视频数据(n-5)
在web端,利用js 来解封装flv协议。
// 1-4bit,帧类型【FrameType】
let frameType = (flvData[0] >> 4) & 0x0F;
// 5-8bit,编码类型【CodecID】
let codecId = (flvData[0]) & 0x0F;
// h264 or h265
const packetType = flvData[1];
// compositionTime
dv.setUint8(0, flvData[4]);
dv.setUint8(1, flvData[3]);
dv.setUint8(2, flvData[2]);
dv.setUint8(3, 0);
let compositionTime = dv.getUint32(0, true);
this.pts = this.dts + compositionTime;
//
完整的demo:flv-demux
兼容性
目前web端 video
标签是不支持flv 文件进行播放的。需要借助浏览器提供的mediaSource
或者webcodec
或者wasm
来解封装 flv 文件。
hls
HLS 由 TS 和 M3U8 两部分组成:
- m3u8 文件:以 UTF-8 编码的 m3u 文件。
- ts 视频文件:一个 m3u8 文件对应着若干个 ts文件。
m3u8 文件
m3u8 只存放了一些 ts 文件的配置信息和相关路径。
ts 视频文件
ts 文件存放了视频的数据。
兼容性
webm
WebM 由 Google 提出,是一种专为 Web 设计的开放,免版税的媒体文件格式。WebM 文件包含使用 VP8 或 VP9 视频编解码器压缩的视频流和使用 Vorbis 或 Opus 音频编解码器压缩的音频流。
webm 格式的视频,直接可以通过video
标签进行播放。
🚀从单体到Monorepo:四川省xxx协会官网架构重生记
一个原本臃肿不堪的前端项目,如何通过Monorepo改造实现构建速度提升70%、代码复用率提升40%?
痛点:一个开发者的噩梦
"又双叒叕要改需求?这次是官网前台还是管理后台?"
"为什么我改个共享组件,要手动同步到两个项目?"
"构建一次要10分钟,一天能构建几次?"
这可能是很多前端团队日常的真实写照。传统的多仓库架构虽然隔离了关注点,却带来了巨大的协作成本和维护负担。四川省xxx协会官网项目就曾经深陷这样的困境。
破局:Monorepo的降维打击
架构设计的艺术
我们抛弃了传统的多仓库模式,采用了npm workspaces + Turbo的现代Monorepo方案。整个项目被重新组织为清晰的层次结构:
sichuanji-cc/
├── apps/ # 应用层:隔离但统一
│ ├── web/ # 前台官网 (3000端口)
│ └── admin/ # 管理后台 (3001端口)
├── packages/ # 共享层:核心价值所在
│ ├── shared/ # 工具函数和类型定义
│ ├── ui/ # 可复用UI组件库
│ ├── auth/ # 认证授权模块
│ ├── database/ # 数据库访问层
│ └── dev-tools/ # 开发工具配置
依赖关系的完美闭环
通过精心设计的依赖关系,我们实现了真正的"高内聚、低耦合":
graph TD
A[apps/web] --> B[packages/shared]
A --> C[packages/ui]
A --> D[packages/auth]
A --> E[packages/database]
F[apps/admin] --> B
F --> C
F --> D
F --> E
C --> B
D --> B
E --> B
设计精髓:所有业务包都依赖shared
基础包,但彼此独立。这样既保证了代码复用,又避免了循环依赖。
性能飙升的秘诀
Turbo构建引擎:快如闪电
传统的单体构建需要5-10分钟,现在只需要1-2分钟!秘诀在于:
- 并行构建:自动识别依赖关系,非依赖包并行构建
- 增量构建:只构建变更的包,缓存命中率>90%
- 远程缓存:支持团队共享构建缓存,新成员也能秒级构建
开发体验:丝般顺滑
- 热重载:<1秒的响应时间,编码流畅无阻塞
- 类型检查:增量TypeScript检查,<3秒完成
-
统一命令:
npm run dev
一键启动所有服务
包设计哲学:SOLID原则的完美实践
单一职责:每个包只做一件事
-
@sichuanji/shared
:纯工具函数和类型,零业务逻辑 -
@sichuanji/ui
: dumb components,只关心渲染 -
@sichuanji/auth
:认证逻辑,可独立测试 -
@sichuanji/database
:数据访问层,隔离数据库细节
开放封闭:稳定且可扩展
所有包都通过清晰的API接口暴露功能,内部实现可以随意重构,只要接口保持不变。这种设计让后续的功能扩展变得异常简单。
自动化:从人工到智能
Changesets:版本管理的终极方案
# 开发时创建变更记录
npm run changeset
# 选择影响的包和版本类型
# 编写变更描述
# 发布时自动处理版本和发布
npm run version-packages
npm run release
革命性改进:从手动管理20+个包版本到全自动化,零错误发布。
Git Hooks:质量守护神
-
pre-commit
:提交前自动代码检查 -
lint-staged
:只检查变更文件,速度极快 - 类型检查、格式验证、测试运行全自动化
量化成果:数字说话
指标 | 优化前 | 优化后 | 提升幅度 |
---|---|---|---|
构建时间 | 5-10分钟 | 1-2分钟 | 70% |
开发启动时间 | 3-5分钟 | 1分钟以内 | 80% |
代码复用率 | 低 | 高 | 40% |
维护成本 | 高 | 低 | 30% |
实战代码示例
共享工具包的典型设计
// packages/shared/src/utils/string.ts
export const formatDate = (date: Date, format: string = 'YYYY-MM-DD'): string => {
// 实现日期格式化逻辑
return formattedDate;
};
// packages/shared/src/types/common.ts
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
UI组件的跨项目复用
// packages/ui/src/Button/Button.tsx
import { ButtonProps } from '@sichuanji/shared';
export const Button: React.FC<ButtonProps> = ({ children, variant = 'primary' }) => {
return (
<button className={`btn btn-${variant}`}>
{children}
</button>
);
};
// 在两个app中同样使用
import { Button } from '@sichuanji/ui';
为什么这个方案值得借鉴?
- 原生支持:使用npm workspaces,无需额外学习成本
- 生态完善:Turbo、Changesets等工具成熟稳定
- 渐进式:可以从现有项目逐步迁移,风险可控
- 团队友好:降低新人上手成本,提升协作效率
未来规划:不止于Monorepo
- 微前端架构:进一步解耦大型应用
- 独立发布:组件库可单独发布到npm
- 云原生:全面容器化部署
- 智能化:集成AI辅助开发工具
总结
Monorepo不是银弹,但确实是解决多项目协作痛点的利器。四川省xxx协会官网的这次架构改造,不仅解决了当下的开发效率问题,更为未来的技术演进奠定了坚实基础。
关键收获:好的架构不是一蹴而就的,而是需要在实践中不断迭代优化。Monorepo只是一个开始,更重要的是背后体现的工程化思维和架构设计理念。
如果你也在为多项目协作而苦恼,不妨从一个小模块开始,尝试Monorepo的魅力。相信我,一旦体验过那种畅快淋漓的开发体验,你就再也回不去了。
思考题:你的项目是否也遇到了类似的架构困境?欢迎在评论区分享你的经验和挑战!
低代码设计态变量挑战与设计 — 前端变量引擎介绍
⚠️ 注意:本文中的“变量引擎”,为 流程设计态 变量引擎,不涉及流程运行态
flowgram 变量引擎源代码详见:github.com/bytedance/f…
1. 变量,低代码的“神经系统”
今天想和大家分享一个话题:flowgram 变量引擎。 在开始深入技术细节之前,我们先用一个通俗的类比来聊聊“变量”到底是什么。想象一下,我们正在搭建一个复杂的乐高模型,比如一个机器人。这个机器人由很多模块组成:手臂、腿、头部等等。如果我们希望这些模块能够协同工作——比如,当头部转动时,手臂也跟着挥舞——我们就需要在它们之间建立一种信息传递的机制。
在这个场景里,“变量”就扮演着这个信息传递者的角色。它就像连接各个模块的“神经”或者“电路”,告诉一个模块另一个模块发生了什么变化。 在低代码平台(如 flowgram)中,每个功能节点(比如“获取数据”、“处理文本”、“调用 API”)都像是乐高模型中的一个模块。而变量,就是让这些独立的节点能够互相“沟通”,将数据从一个节点传递到另一个节点的关键所在。 变量就是节点间信息传递的载体。没有它,整个流程(Flow)就是一盘散沙,无法形成有意义的应用。
2. 痛点:低代码设计态中的变量难点
既然变量如此重要,那么在一个低代码平台前端的开发过程中,我们遇到了哪些挑战呢? 随着业务逻辑变得越来越复杂,我们发现变量变得 限制复杂、信息复杂、联动复杂,具体体现为以下几个问题:
2.1. 变量的可见性(可选性)
并非所有变量都应该对所有节点可见。例如,一个循环节点内部产生的临时变量(如 item 和 index),就不应该被循环外的节点访问到。如何精确地控制每个节点能“看到”和使用哪些变量?这就是“作用域”的问题。 变量引擎能够精确控制每个变量的有效范围(即作用域)。如同为不同房间配置专属钥匙,它确保了变量只在预期的节点中被访问,从而有效避免了数据污染和意外的逻辑错误。
Start 节点定义的 query 变量,在它后面的 LLM 和 End 节点都能轻松访问
LLM 节点在一个 Condition 分支里,像是在一个独立的房间。外面的 End 节点自然就拿不到它的 result 变量了。
2.2. 变量结构信息复杂
变量是个复杂的多层级结构哈:
- 变量 Object 能往下深挖好几个子字段,子字段还能定义成 Object,接着再定义往下深挖的字段
- 有些类型能互相嵌套,就像 Array<Map<String, Array>> 这样
变量引擎能让你轻松定制和管理这些结构
在这张图里,你能看到所有节点的输出变量,还有它们之间的层级关系
2.3. 类型自动推导
有时,一个变量的类型依赖于另一个或多个变量的计算结果。 例如,C = A + B。当 A 或 B 的类型发生变化时,C 的类型应该能够自动、响应式地更新。如何优雅地实现这种“级联反应”? 通过变量引擎,可以通过 简单的变量声明定义,变量引擎就会根据上下文自动联动推导出它的类型。
例如,当 Start 节点中 arr 变量的类型发生变更时,Batch 节点输出的 item 类型也会自动同步更新,确保了类型的一致性。
3. 核心概念:作用域/作用域链/AST
想像这样一个变量引擎的世界:
- 通过一个个 作用域 来划定出一个个 国家
- 每个国家包含三大公民:声明、类型、表达式
- 国家与国家之间通过 作用域链 来实现交流
3.1. 作用域与作用域链
作用域的具体划定,可以由不同的业务来确定:
流程图里,作用域通常约定为一个节点
低代码页面编辑器,作用域可以约定为一个组件(含变量)
侧边栏里面的全局变量,也是一个作用域
我们将作用域类比为国家,作用域链类比为贸易链,可以获得以下的概念类比: 国家 + 贸易链:
国家之间通过贸易链,进行商品进出口
贸易链规定:
-
可以从哪些国家进口
-
又可以出口到哪些国家
作用域 + 作用域链:
作用域之间通过作用域链,进行变量的消费与输出
作用域链规定了
- 作用域依赖:可以从哪些作用域消费变量
- 作用域覆盖:又可以输出变量到哪些作用域
abstract class ScopeChain { abstract getCovers(scope) abstract getDeps(scope) }
❓ 为什么要在节点之外,新抽象一个作用域的概念?
- 节点 !== 作用域
- 全局作用域(GlobalScope)和节点无关
- 一个节点可以存在多个作用域
3.2. 变量引擎的三大公民
声明
声明 = Identifier + Definition
商品也是一种声明,和变量类似
JavaScript 中的变量,唯一 Key + 变量的值
低代码平台中的变量,唯一Key(apiName)+ 变量定义
声明 通过 Indentifier,被其他作用域给消费
Global 作用域 的 结构体声明 被 Start 节点消费, Start 节点的变量被 LLM 节点的 fString 表达式消费, LLM 节点通过下钻,实际消费了 Global 作用域中的 Property_A 声明
类型
类型(typing,又称类型指派)赋予一组比特某个意义。 来自 Wikipedia
变量引擎中,所有变量的声明,都是围绕着类型建立的。 变量引擎内置了以下类型:String、Number、Integer、Boolean、Object、Array、Map
类型可以相互嵌套:
-
如:Array<Map<String, Array<Array>>>
-
Object 类型 和 属性声明 可以相互嵌套:
Object 类型 和 属性声明 可以相互嵌套
表达式
设计态变量引擎中的表达式,可以通过特定方式 组合 若干个 声明好的变量,并 返回一个变量类型:
例如:
JsonPath 表达式
Python 表达式
3.3. AST Is All You Need
核心思想:流程编排的本质就是一种可视化的编程语言。 Hint:可以通过 ts-ast-viewer.com/ 更加深入理解 AST
三大公民 “声明”、“表达式”、“类型” 在设计上都有一些通用点:
- 三大公民各自有 非常多的类别,且业务有扩展诉求
- 声明:变量声明、自定义类型声明、Interface 声明 等
- 类型:String、Number、Array、Map、Interface 等
- 表达式:KeyPath、JsonPath、Python、JS 等
- 三大公民相互之间可以 组织成一棵树的结构
- 变量声明
- Object
- 属性声明
- Array
- String
- 属性声明
- Number
- 属性声明
- JsonPath 表达
- 三大公民都有 监听 的诉求
Condition 类型联动操作符
变量引擎,定义了 ASTNode 来解决这三个问题:
-
AST:通过树的形式,组合 ASTNode,来描述变量信息
-
ASTNode:AST 中 可扩展、可组合、可监听 的协议节点
继承 ASTNode 可以定义非常多的类型
class StringType extends ASTNode {} class MapType extends ASTNode {} class VariableDeclaration extends ASTNode {} class StructDeclaration extends ASTNode {} class KeyPathExpression extends ASTNode {} class JsonPathExpression extends ASTNode {} // 业务扩展 AST 节点 class CustomType extends ASTNode {} class CustomType extends BaseExpression {} class CustomType extends BaseType {}
ASTNode 之间 组成了一棵树的结构
createVariableDeclaration({ key: 'variable_xxx' type: createObject({ properties: [ createProperty({ key: 'a', type: createString() }), createProperty({ key: 'b', initializer: createJsonPathExpression({ path: '$.start.outputs.query' }) }) ] }) })
业务可监听任意 ASTNode 的变化
// 监听变量变化 variable.subscribe(() => { // do something }) // 监听变量的 Name 变化 variable.subscribe(() => { // do something }, { selector: (_v) => _v.meta.name }) // 监听变量类型变化 variable.onTypeChange(() => { // do something });
参考 TypeScript AST 的定义,我们用 AST Schema 可以用来描述变量的 AST 信息:
TypeScript AST 定义示范
AST Schema 在 TypeScript AST 的基础上做了简化:
createVariableDeclaration({ key: 'variable_xxx' type: createObject({ properties: [ createProperty({ key: 'a', type: createString() }), createProperty({ key: 'b', initializer: createJsonPathExpression({ path: '$.start.outputs.query' }) }) ] }) })
聊到 AST Schema ,不避免会将其与 Json Schema 进行比较:
Json Schema 和 AST Schema 要解决的问题是不同的
- Json Schema 只能描述类型,AST Schema 可以表达 “声明”、“表达式”、“类型” 三大公民
- AST Schema 中每一个节点都可以对应到一个实际存在的 AST 节点,但是 Json Schema 不行
- Json Schema 相比 AST Schema 在团队沟通上更有优势
两者的应用场景也不同
-
变量引擎内核 需要更强大的扩展与表达能力,因此需要用 AST Schema 来描述更丰富的变量信息,包括类型联动信息
-
上层业务 需要协议更通用更易于沟通,因此可以使用 Json Schema 来降低上手成本,方便前后端沟通,并且复用生态(如 zod)
4. 架构:flowgram 变量引擎整体设计
易用性和可扩展性的平衡一直是架构设计上一大难题。 flowgram 变量引擎通过三大分层架构兼顾了易用性和可扩展性:
flowgram 变量引擎分层架构
变量引擎设计上遵循 DIP(依赖反转)原则,按照 代码稳定性、抽象层次 以及和 业务的远近 分为三层:
-
变量抽象层:变量架构中抽象层次最高,代码也最为稳定的部分。抽象层对 AST、Scope、ScopeChain 等核心概念进行了抽象类定义。
-
变量实现层:变量架构中变动较大,不同业务之间可能存在调整的部分。引擎内置了一批较为稳定的 AST 节点和 ScopeChain 的实现。当用户存在复杂的变量需求时,可以通过依赖注入注册新的 AST 或者重载已有 AST 节点实现定制化。
-
变量物料层:基于外观模式(Facade)的思路提高变量易用性,将复杂的变量逻辑封装为简单开箱即用的变量物料
5. 附录:flowgram 变量底层部分 API 简介
5.1. 变量抽象层部分 API 简介
获取变量引擎
import { useService } from "@flowgram.ai/core"; import { VariableEngine } from "@flowgram.ai/variable-core"; const variableEngine = useService(VariableEngine);
Scope CURD
// 创建 Scope const globalScope = variableEngine.createScope('global') // 创建 Scope,并带上 meta 信息 const loopNodePublicScope = variableEngine.createScope('loop-node-public', { node: loopNode, type: 'public' }) // 获取 Scope const scope = variableEngine.getScopeById('loop-node-public') scope.meta.type === 'public' // 获取全量的 Scope variableEngine.getAllScopes() // 删除 Scope variableEngine.removeScopeById('global')
Scope 的包含依赖和覆盖作用域
const scope = variableEngine.getScopeById('test') // 获取依赖作用域 console.log(scope.depScopes) // 获取覆盖作用域 console.log(scope.coverScopes);
ASTNodeJSON 通常表现为一个包含 kind 字段的 Plain Object
- kind 字段用于表示当前 ASTNode 节点的类别
- ASTNodeJSON 可以相互嵌套形成树的结构
{ kind: ASTKind.VariableDeclaration, key: 'variableKey111', meta: { name: 'xxx' }, type: { ASTKind.String } }
上面的 ASTNodeJSON 描述了一个变量声明:
- kind 为 VariableDeclaration 表明了这是一个描述变量信息的 ASTNode
- 该变量声明的 type 是当前变量声明的子节点,也用 ASTNodeJSON 来描述
- type 中的 kind为 String,表明这个 ASTNode 存储了一个为 String 的变量类型
❗️概念澄清 ASTNodeJSON 和 ASTNode 的关系,类似于 React 中 JSX 和 VDOM 的关系
ASTNode 存储在 Scope 中,Scope 中可以对 ASTNode 进行 CRUD:
import { ASTKind } from "@flowgram.ai/variable-core"; // 在 namespace 1 中存储一个新变量,变量类型为 String scope.ast.set('namespace1', { kind: ASTKind.VariableDeclaration, key: 'new-variable', type: { kind: ASTKind.String, } }) // 在 namespace 2 中存储一个表达式,引用 a 变量下钻的 b 字段 scope.ast.set('namespace2', { kind: ASTKind.KeyPathExpression, keyPath: ['a', 'b'] }) // 更新 namespace 2 中的表达式引用到 c 变量下钻的 d 字段 scope.ast.set('namespace2', { kind: ASTKind.KeyPathExpression, keyPath: ['c', 'd'] }) // 获取 namespace1 中的 ASTNode const astNode = scope.ast.get('namespace1'); // 通过 fromJSON 更新变量中的类型信息 astNode.fromJSON({ type: { kind: ASTKind.Number, } }); // 通过 toJSON 转化回 ASTNodeJSON astNode.toJSON(); // 删除 namespace2 存储的 ASTNode scope.ast.remove('namespace1');
ASTNode 基于 Rxjs 提供了强大的监听能力:
const astNode = scope.ast.get('test'); // 监听 ASTNode 节点的变化 const disposable = astNode.subscribe((_node) => { // do something to node }); // 监听 ASTNode 节点中 name 信息的变化 const disposable = astNode.subscribe((_name) => { // do something to name }, { selector: _node => _node.meta.name }); // 将一个 AnimationFrame 中所有该 AST 的变化 debounce 成一个 const disposable = astNode.subscribe((_node) => { // do something to node }, { debounceAnimation: true }); // 进阶用法:直接拿到当前 ASTNode 的 rx observable,通过自由 rx 操作符实现更复杂的异步处理逻辑 const ob$ = astNode.value$.pipe( switchMap(...), throttle(...), takeUntil(...) )
ASTNode 本质上是个抽象类,所有变量存储单元的实现都需要通过继承 ASTNode 进行
假设我们需要将一个 Employee 信息需要存储在变量引擎中:
- 为 Employee 创建一个继承 ASTNode 的实现 通过依赖注入的思想,将该实现注册到 VariableEngine中:
// 1. 涉及 Employee 的 ASTNodeJSON 结构 interface EmployeeASTJSON { employeeName: string; } // 2. 继承 ASTNode,细化 EmployeeASTNode 逻辑 class EmployeeASTNode extends ASTNode<EmployeeASTJSON> { // 2.1. 为 EmployeeASTNode 设定一个 kind 用于标识其类别 static kind: string = 'Employee'; _employeeName: string; // 2.2. 实现 Employee 的 fromJSON,描述其更新逻辑 fromJSON(json: EmployeeASTJSON) { if(json.employeeName !== this._employeeName) { this._employeeName = json.employeeName; this.fireChange(); } } toJSON(): EmployeeASTJSON { return { kind: this.kind, employeeName: this._employeeName } } } // 3. 通过依赖注入,将 EmployeeASTNode 注册 到 variableEngine 中 variableEngine.astRegisters.registerAST(EmployeeASTNode)
2. 注册完成后, Scope 中即可以创建包含 Employee 的 AST 节点
// 1. 创建 Employee 节点 // 变量引擎会通过 ASTNodeJSON 中的 kind 找到 Employee 对应的实现 const employeeNode = scope.ast.set('employee-test', { kind: 'Employee', employeeName: 'Mike Johnson' }); // 2. 监听 Employee 信息的变化 const disposable = employeeNode.subscribe(_node => { console.log(_node); }) // 3. 更新 Employee 的名称 employeeNode.fromJSON({ employeeName: 'Blank Mikeson' })
ScopeChain 是变量抽象层提供一个抽象类,包含三个抽象方法,用于让 Scope 获取依赖作用域和覆盖作用域:
export abstract class ScopeChain { // ... 内置方法实现 // 获取依赖作用域,子类实现 abstract getDeps(scope: Scope): Scope[]; // 获取覆盖作用域,子类实现 abstract getCovers(scope: Scope): Scope[]; // 获取所有作用域的排序 abstract sortAll(): Scope[]; }
下面是一个模拟的简单作用域链实现:
@injectable() export class MockScopeChain extends ScopeChain { // 所有作用域都依赖 global,global 不依赖任何作用域 getDeps(scope: Scope): Scope[] { const res: Scope[] = []; if (scope.id === 'global') { return []; } const global = this.variableEngine.getScopeById('global'); if (global) { res.push(global); } return res; } // global 可以覆盖所有作用域,但是其他作用域没有任何覆盖 getCovers(scope: Scope): Scope[] { if (scope.id === 'global') { return this.variableEngine.getAllScopes().filter(_scope => _scope.id !== 'global'); } return []; } sortAll(): Scope[] { return this.variableEngine.getAllScopes(); } } // 作用域链实现会通过 inversify bind 依赖注入到变量引擎中 new ContainerModule(bind => { bind(ScopeChain).to(MockScopeChain).inSingletonScope(); })
5.2. 变量实现层部分 API 简介
类型 是一种 ASTNode 的实现
import { BaseType } from "@flowgram.ai/variable-core"; // 获取变量的类型 const variable = scope.ast.get('variable-declaration'); const vType: BaseType = variable.type;
变量引擎内部提供了一套基于 JSON Types 的预设类型集合实现
基础类型 复合类型 基础类型提供了 Json 四种基本类型定义:String,Number,Integer,Boolean 复合类型可以下钻嵌套其他类型,也可以下钻嵌套其他声明:Object,Array,Map,Union const stringType = scope.ast.set('stringType', { kind: ASTKind.String }) const numberType = scope.ast.set('numberType', { kind: ASTKind.Number }) const integerType = scope.ast.set('integerType', { kind: ASTKind.Integer }) const booleanType = scope.ast.set('booleanType', { kind: ASTKind.Boolean }) const complicatedType = scope.ast.set('complicatedType', { kind: ASTKind.Object, properties: [ // { a: string } { kind: ASTKind.Object, key: '_object', properties: [ { kind: ASTKind.Property, key: 'a', type: ASTKind.String } ] }, // Map<String, String> { kind: ASTKind.Property, key: '_map_string', type: { kind: ASTKind.Map, valueType: { kind: ASTKind.String } } }, // Array<{ test:string }> { kind: ASTKind.Property, key: '_array_object', type: { kind: ASTKind.Array, valueType: { kind: ASTKind.Object, properties: [{ kind: ASTKind.Property, key: 'test', type: ASTKind.String }] } } } ] }); // 获取 下钻 _object.a 字段的类型 const objectDrilldownType = complicatedType.getByKeyPath(['_object', 'a']).type
声明是一种 ASTNode 的实现,它提供了一系列方法,用于获取声明的信息及内容。变量引擎内置了 VariableDeclarationList、VariableDeclaration、Property 三种声明
import { BaseVariableField } from "@flowgram.ai/variable-core"; import { ASTKind, ObjectJSON, VariableDeclarationListJSON } from "@flowgram.ai/variable-core"; const field: BaseVariableField<{ label: string; }> = scope.ast.get('custom-variable'); scope.ast.set('variable-list', { kind: ASTKind.VariableDeclarationList, declarations: [ { type: ASTKind.String, key: 'string', }, { // VariableDeclarationList 的 declarations 中可以不用声明 Kind // kind: ASTKind.VariableDeclaration, type: ASTKind.Number, key: 'number', }, { kind: ASTKind.VariableDeclaration, type: ASTKind.Integer, key: 'integer', }, { kind: ASTKind.VariableDeclaration, type: { kind: ASTKind.Object, properties: [ { key: 'key1', type: ASTKind.String, // Object 的 properties 中可以不用声明 Kind kind: ASTKind.Property, }, { key: 'key4', type: { kind: ASTKind.Array, items: { kind: ASTKind.Object, properties: [ { key: 'key1', type: ASTKind.Boolean, }, ], } as ObjectJSON, }, }, ], } as ObjectJSON, key: 'object', }, { kind: ASTKind.VariableDeclaration, type: { kind: ASTKind.Map, valueType: ASTKind.Number }, key: 'map', }, ], });
变量引擎可以通过 keyPath 访问到声明
import { BaseVariableField } from "@flowgram.ai/variable-core"; // 获取 key 为 string 的变量 const stringVar: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['string']); // 获取 key 为 boolean 的变量 const booleanVar: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['boolean']); // 获取 key 为 object 的变量,下钻到他内部的 key1 字段 const objectKey1Var: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['object', 'key1']); // 获取 key 为 object 的变量,下钻到他内部的 key2 字段,再下钻到 key2 字段中的 key1 字段 const objectKey2Key1Var: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['object', 'key2', 'key1']); // 获取 key 为 object 的变量,下钻到他内部的 key4 字段 const objectKey4Var: BaseVariableField = variableEngine.globalVariableTable.getByKeyPath(['object', 'key4']);
Scope.outputs 可以获取并监听 Scope 输出变量的信息:
// 获取当前 Scope 输出的所有 VariableDeclaration console.log(scope.outputs.variables); // 获取当前 Scope 输出的所有 VariableDeclaration 的 key 值索引 console.log(scope.outputs.variableKeys); // 在 Scope 的输出变量中寻找指定 key 的 VariableDeclaration scope.outputs.getVariableByKey('test') // 监听当前 Scope 输出的 VariableDeclaration 列表及下钻声明发生变化时 scope.outputs.onListOrAnyVarChange(() => { console.log(scope.outputs.variables); });
Scope.available 可以获取并监听 Scope 可访问变量的信息,即所有依赖作用域的输出变量:
// 获取当前 Scope 依赖的所有可访问的 VariableDeclaration console.log(scope.available.variables) // 获取当前 Scope 输出的所有可访问的 key 值索引 console.log(scope.available.variableKeys) // 在 Scope 可访问变量中寻找指定 key 的 VariableDeclaration scope.available.getVariableByKey('test') // 通过 keyPath 在 Scope 的可访问变量中找到变量或者下钻字段 scope.available.getByKeyPath(['a', 'b', 'c']) // 监听当前 Scope 可访问的变量发生变化时 scope.available.onListOrAnyVarChange(() => { console.log(scope.available.variables) }); // 监听当前 Scope 可访问变量列表的变化 scope.available.onVariableListChange(() => { console.log(scope.available.variables) }); // 监听当前 Scope 可访问变量,任意一个变量内部的信息变化 scope.available.onAnyVariableChange(() => { console.log(scope.available.variables) });
系统预设提供了三个表达式的实现:
- KeyPathExpression:通过 keyPath 引用单个变量
- EnumerateExpression:对子表达式的返回类型进行遍历,获取遍历后的数据类型
- WrapArrayExpression:将子表达式的返回类型封装成 Array
声明抽象的 ASTNodeJSON 中,initializer 可以设置为一个表达式,该声明的类型会自动同步为该表达式的类型
const variable = scope1.ast.set('variable', { kind: ASTKind.VariableDeclaration, key: 'a', type: { kind: ASTKind.Object, properties: [ { key: 'b', type: { kind: ASTKind.Array, items: { kind: ASTKind.String, }, }, }, ], }, }) // scope2 依赖 scope1 const targetVariable = scope2.ast.set('variable2', { kind: ASTKind.VariableDeclaration, key: 'target', initializer: { // 对 子表达式的 Array 类型进行遍历,获取 items 的类型 kind: ASTKind.EnumerateExpression, enumerateFor: { // 获取变量 a 下钻的 b 字段 kind: ASTKind.KeyPathExpression keyPath: ['a', 'b'] } } }) // 变量的类型和变量 a 下钻 b 字段的数组类型的 Item 类型保持一致 targetVariable.type.kind === ASTKind.String
表达式联动变量类型 Demo 效果
Java 线程安全详解:定义、常见问题与解决方案
原文来自于:zha-ge.cn/java/68
Java 线程安全详解:定义、常见问题与解决方案
大家好,我又双叒叕来写流水账了。这次说点“老生常谈”的:线程安全。要不是上周现场出Bug让小组猝不及防,我还真不会想把多年踩坑心路写下来。闲话不多说,往下看吧,绝对比《Java 并发编程实战》好消化,起码气氛活跃!
线程安全?等会,这玩意不就是锁吗
有时候刚入行的小伙伴听到“线程安全”三个字,总觉得神神秘秘。其实本质不复杂。来个极简版解释:
- 线程安全就是并发多线程访问同一资源,不出错/出脏数据。
- 不安全就像:两个人同时写你日记,最后内容全糊了。
- 线程安全就像:有人守在门口,只准一个人写日记,其他人排队。
天天喊线程安全,实战中最常见“事故”就是数据竞争。比如全局计数器、单例模式、共享集合……全是高发地带。
日常开发,看似平静水面下的“风暴”
先说说我去年翻车的小故事。我们有个在线打卡统计,后台用一个静态Map<LocalDate, Integer>
记录每一天的打卡人数:
private static final Map<LocalDate, Integer> attendanceMap = new HashMap<>();
// 业务代码每次递增
attendanceMap.put(date, attendanceMap.getOrDefault(date, 0) + 1);
图省事直接用HashMap,结果某个早高峰打卡人数突然“倒退”,甚至-1……经理还以为库被攻破,搞得人心惶惶。
其实,这种“奇怪数字”80%都跟线程安全有关,不信你加点并发压力测测,轻松复现。
踩坑瞬间
一般人踩线程安全的坑,都可以开个系列:
- 用普通集合(List、Map)存并发数据。
- 手写单例模式,结果多线程下new出好几个。
- 以为
synchronized
就是万能银弹,却没锁住“该锁的地方”。 - 不小心把可变数据暴露出去,被别的线程偷偷改了。
说出来都是泪。举个我的现场“名场面”吧:
有次想偷懒,没用并发集合。觉得反正一次只加一,顶多慢点。 结果:
- 早上打卡9:00,上报人数:56,上升的数字很欢快,偶尔会跳成53。
- 细查日志,两个线程几乎同时读出老值,然后都
+1
再覆写——等于有一票白打。
最当场出糗是历史数据莫名倒退,用户一脸懵:“我明明刷了脸,怎么又少一个?”
三板斧破局:让线程安全落地
后来当然开始猛补线程安全姿势。总结下来,其实可用的套路就几个:
- 换用“并发安全类”:
ConcurrentHashMap
、CopyOnWriteArrayList
等。 - 关键路径加
synchronized
,不嫌慢的就全锁住。 - 原子操作搞定数值型,比如
AtomicInteger
。 - 操作时「不变式」也要守,比如不在多个步骤拆开操作。
比如把上面的计数器改成这样:
private static final ConcurrentMap<LocalDate, AtomicInteger> attendanceMap = new ConcurrentHashMap<>();
attendanceMap.computeIfAbsent(date, d -> new AtomicInteger(0)).incrementAndGet();
一行解决竞争,后面就很少再出诡异数字了。
经验启示
写半天,发现老道理还是那套:
- 宁滥勿缺:不确定是否线程安全,先查文档 or 用并发类/锁,甭心疼几行代码。
- 可变状态最危险:别随便让对象在多线程下可写,哪怕“只加一行”都容易炸。
- 集合类优先并发版:99%的坑都能规避,别贪那几微秒性能。
- 多线程下,所有假设都要怀疑:面试吹牛说“线程安全”,实战就埋着坑——光修Bug都忙不过来。
最后,送大家一句程序员朋友圈名言:
“真正的并发Bug不会在本地出现,也不是今天立马炸,是六个月后,半夜,老板睡觉前……”
收个尾,线程安全问题其实随处见,别觉得老生常谈,真玩多线程的时候,坑永远比你想象的深。祝大家多写代码少掉头发,实在踩坑主动总结,比什么教程都顶用。
完!
了解 .husky:前端项目中的 Git Hooks 工具
在日常开发中,我们经常会遇到这样的问题:
- 团队成员提交的代码风格不一致(缩进、引号、分号)。
- 有些低级错误(如
console.log
、未使用的变量)混入了提交。 - 提交信息随意,难以追踪。
这些问题会导致代码库质量下降、协作成本增加。
而 Husky 就是为了解决这些问题的工具。
1. 什么是 Husky?
Husky 是一个 Git Hooks 工具,能让你在 Git 操作的不同阶段(如 commit
、push
)执行脚本。
Git Hooks 本来就存在,但原生配置麻烦。Husky 简化了这一过程,让我们只需要在 .husky/
目录下放置对应的脚本即可。
常见的 Git Hooks
- pre-commit:在提交前触发,常用于代码检查(lint、test)。
- commit-msg:在提交信息时触发,用于校验提交信息格式。
- pre-push:在 push 之前触发,用于运行测试或构建。
2. .husky
目录长什么样?
一个典型的 Vue 或 React 项目中可能有这样的目录:
.husky/
├── pre-commit # 提交前执行,比如 eslint 检查
├── commit-msg # 校验提交信息
每个文件就是一个 Hook 脚本,例如 pre-commit
:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint
意思是:在提交前,先执行 npm run lint
。
如果检查不过,提交会被中断。
3. 如何安装和使用 Husky?
步骤 1:安装依赖
npm install husky --save-dev
步骤 2:启用 Git Hooks
npx husky install
这会在项目根目录生成 .husky/
文件夹。
步骤 3:添加 Hook
例如添加一个 pre-commit
:
npx husky add .husky/pre-commit "npm run lint"
这样每次 git commit
时都会执行 npm run lint
。
4. 常见配合工具
Husky 本身只是运行脚本的工具,通常会配合其他工具使用:
-
ESLint + Prettier
格式化和检查代码,保证风格统一。npx husky add .husky/pre-commit "npx lint-staged"
配合
lint-staged
只检查改动过的文件,提高效率。 -
Commitlint
检查提交信息是否符合规范(如 Angular 提交规范)。
在.husky/commit-msg
里写:npx commitlint --edit $1
-
测试工具(Jest、Vitest 等)
可以在提交或推送前运行测试,防止错误进入主分支。
5. 优点和注意事项
优点
- 保证代码质量:提交前自动检查。
- 提交记录更规范:易于追踪和生成 changelog。
- 自动化:减少人为疏漏。
注意事项
- 不要把 Hook 脚本写得太重,否则会拖慢提交流程。
- 如果只需要检查改动的文件,最好结合
lint-staged
。 -
.husky/
目录需要提交到仓库,否则其他开发者不会生效。
6. 总结
.husky
是现代前端项目中常用的工具,用来结合 Git Hooks 管理代码质量。
-
个人项目:可以简化,甚至删除
.husky
,保持轻量。 - 团队项目:推荐保留,并结合 ESLint、Prettier、Commitlint 一起使用。
一句话总结:
👉 Husky 并不会影响代码运行,它只是为团队的“提交环节”加了一道保险。
源码精读:拆解 ChatGPT 打字机效果背后的数据流水线
源码精读:拆解 ChatGPT 打字机效果背后的数据流水线
你是否曾经好奇过 ChatGPT 的打字机效果是如何实现的?一年前,我带着同样的疑问写下了ChatGPT流式数据渲染:一份面向初学者的最佳实践演示,跟大家分享可以使用 fetch-event-source
库的几个钩子函数来实现打字机效果。
但仅仅会用是不够的!随着深入使用,我越来越好奇它背后的实现原理。于是,我深入研究了 fetch-event-source
这个核心库的源代码,并将精华总结在本文中。无论你是刚接触前端的新人,还是想深入理解现代 Web 技术的开发者,阅读本文都能让你:
-
明白“那个打字效果是怎么打出来的?”:彻底搞清楚像ChatGPT那样的打字机效果,底层是怎么一个字一个字从服务器流到你的浏览器并显示出来的,而不仅仅是调用一个API。
-
学会“代码怎么知道现在该干嘛?”:理解库如何在“刚连接成功”、“收到消息”、“出错”等不同时刻准确触发对应的回调函数,让你能精准控制每个环节。
-
搞懂“数据流水线”是怎么组装的?:深入理解如何从零构建一个高效、低内存占用的流式数据管道,学习
getBytes
→getLines
→getMessages
这一经典的三层处理模型,以及如何把零碎的二进制数据拼装成完整消息。 -
学会“让专业的模块干专业的事”:通过剖析
fetch.ts
(连接管理) 与parse.ts
(数据解析) 的职责分离,学会如何设计职责单一、易于维护和测试的模块,并将其应用到你自己的项目架构中。 -
搞定“如何一心三用管理连接和错误?”:学会库如何用巧妙的技术,同时处理好连接、收数据、断线重试这些复杂操作,让流程稳如泰山。
准备好揭开流式数据处理的神秘面纱了吗?让我们开始吧!
缘起:原生 EventSource
哪里不够好?
服务器推送事件(Server-Sent Events, SSE)是 Web 中一种轻量级的实时通信技术,非常适合实现消息通知、实时数据更新等场景。浏览器为此提供了原生的 EventSource
API。
然而,在实际的复杂项目中,原生 EventSource
很快就会显得力不从心:
-
无法携带认证信息:它不支持自定义 HTTP 请求头(Headers),这意味着我们无法方便地加入
Authorization
Token。 - 孱弱的错误处理:当连接出错时,我们无法获取详细的 HTTP 状态码,很难判断是网络问题还是服务器权限问题。
- 功能受限:只能发送 GET 请求,无法发送请求体(Body)。
fetch-event-source
的诞生,就是为了解决以上所有痛点。
核心思想:用 fetch
的超能力重塑 EventSource
这个库的作者做出了一个非常聪明的决定:摒弃原生 EventSource
,转而使用无所不能的 fetch
API 来从零开始构建一个功能完备的 SSE 客户端。
fetch
API 允许我们完全控制请求的方方面面——Headers、Method、Body、信号中断等,这从根本上解决了原生 API 的所有局限性。
架构之美:高内聚、低耦合的“双子星”
在深入代码细节之前,我们先来看看它的整体架构。其核心代码被清晰地划分在两个文件中:
-
src/fetch.ts
: 连接大总管。它负责所有与网络相关的“脏活累活”——发起请求、管理连接的生命周期(重连、关闭)、处理 HTTP 级别的错误。它对外提供简洁的 API,对内则指挥着整个流程。 -
src/parse.ts
: 纯粹的解析器。它的职责只有一个,就是接收二进制数据流,并根据 SSE 协议规范,把它“翻译”成结构化的 JavaScript 对象。它不关心数据从哪来,只关心如何正确解析。
这种关注点分离的设计思想,使得代码的每个部分都职责单一、清晰明了,极大地提高了代码的可读性和可维护性。
魔法揭秘:数据在“流处理管道”中的奇妙旅程
fetch-event-source
最令人拍案叫绝的部分,在于它如何高效地处理来自服务器的流式数据。它构建了一个由三个函数组成的、如同流水线一样的处理管道。
我们可以把它想象成一个智能快递分拣中心:
// fetch.ts
await getBytes(response.body!, getLines(getMessages(...)));
数据流就像一个源源不断的传送带,依次经过三个处理站:
1. getBytes
- 卸货工
-
职责:这是流水线的第一站。它从
fetch
响应的response.body
(一个ReadableStream
) 中,一块一块地读取最原始的二进制数据(Uint8Array
)。 - 比喻:一个勤勤恳恳的卸货工,只管把卡车上的货箱搬下来,放到传送带上,而不关心箱子里是什么。
2. getLines
- 开箱与分拣员
- 职责:这是流水线的核心枢纽。它接收一块块大小不一的数据,面临的挑战是数据可能在任何地方被截断(比如半行数据)。它的任务是利用一个内部缓冲区,将这些碎片化的数据拼接起来,并准确地按换行符切割成完整的一行一行。
- 比喻:一个经验丰富的分拣员。他把货箱打开,将里面杂乱的字条拼接成一行行通顺的“句子”,然后交给下一个人。
3. getMessages
- 包裹组装员
-
职责:流水线的最后一站。它接收一行行规整的数据,并根据 SSE 协议规则(如
data:
、id:
、event:
)将它们组装成一个结构化的消息对象(EventSourceMessage
)。当它遇到一个空行(协议中的消息结束符)时,就意味着一个完整的“包裹”组装完毕,可以派送了。 -
比喻:一个精通业务的打包员。他看懂了每一句“句子”的含义,并将它们填写到一张标准的快递单上。当所有信息填写完毕(遇到空行),他就把这个包裹发出去(触发
onmessage
回调)。
这个从外到内、层层递进的处理方式,整个过程高效、内存占用极低,无论数据流多大,都能从容应对。
源码深潜:一次数据流的完整旅程
fetch-event-source
的整体流程由 fetchEventSource
函数精心编排,其核心是一个名为 create
的内部函数,它像一个永不放弃的“任务执行官”,负责单次连接的尝试、成功、失败与重试。
整个流程可以概括为以下几个步骤:
-
启动任务: 调用
fetchEventSource
时,会立即返回一个 Promise,同时create
函数被首次调用,开始第一次连接尝试。 -
尝试连接 (try 块): 在
create
函数内部,try...catch
结构包裹了单次连接的“快乐路径”。-
await fetch(...)
: 发起网络请求,建立与服务器的连接。 -
await onopen(...)
: 连接成功后,调用onopen
回调,允许用户校验响应头和状态码。如果校验失败(例如,用户在此抛出异常),流程会立即转入catch
块。 -
await getBytes(...)
: 开始进入我们下面将要深入探索的数据处理管道。await
会在这里暂停,直到数据流正常结束。 -
onclose()
: 如果数据流正常结束,代表连接圆满完成,触发onclose
回调。
-
-
处理异常 (catch 块): 如果在
try
块的任何步骤发生错误(网络问题、onopen
抛错等),流程会进入catch
块。-
调用
onerror(...)
: 将错误交给用户提供的onerror
回调处理。 -
决定重试策略:
onerror
回调的返回值将决定是否以及何时重试。如果返回一个数字(毫秒),setTimeout
就会被安排,在指定延迟后再次调用create
函数,开始下一次尝试。 -
终止流程: 如果
onerror
回调自己也抛出异常,则认为错误是致命的,整个流程将以 Promisereject
告终。
-
调用
现在,让我们聚焦于 try
块中最核心的部分——数据处理管道。我们一起来当一次数据侦探,追踪 response.body
这个“包裹”的完整旅程。
起点:fetch.ts
- 旅程的开端
我们一起来当一次数据侦探,追踪 response.body
这个“包裹”的完整旅程。
首先,我们必须回到“总指挥室” fetch.ts
,因为getBytes
是在这里被调用的。这是理解一切的关键。
// in src/fetch.ts
const response = await fetch(sseEndpoint, { ... });
await getBytes(response.body!, getLines(getMessages(...)));
现在,我们把 getBytes
的定义放在旁边对比一下:
// in src/parse.ts
export async function getBytes(stream, onChunk) {
const reader = stream.getReader();
let result: ReadableStreamDefaultReadResult<Uint8Array>;
while (!(result = await reader.read()).done) {
onChunk(result.value);
}
}
看到了吗?getBytes
需要两个参数:
-
stream
: 我们传了response.body!
,这是数据来源。 -
onChunk
: 我们传了getLines(getMessages(...))
。
最重要的认知:传递给 getBytes
的第二个参数 onChunk
,就是 getLines
这个函数运行后的返回值!
所以,当 getBytes
内部调用 onChunk(result.value)
时,它到底在调用什么?为了找到答案,我们必须先看看 getLines
做了什么。
第一站:进入 getLines
- "我是谁?我返回了什么?"
我们来看 getLines
的结构(我简化了一下):
// in src/parse.ts
export function getLines(onLine) {
// 1. 做一些准备工作 (定义 buffer, position 等变量)
let buffer;
let position;
// ...
// 2. 返回一个全新的函数!
return function onChunk(arr) { // arr 就是 getBytes 传来的 result.value
// ... 在这里处理 arr ...
// ... 找到一行后,调用 onLine(line) ...
};
}
现在谜底揭晓了!
getBytes
拿到的 onChunk
参数,就是 getLines
返回的这个内部函数!
所以,当 getBytes
兴高采烈地执行 onChunk(result.value)
时,它实际上是把 result.value
这个数据块(chunk
),交给了我们刚刚在 getLines
内部定义的那个函数来处理。
第二站:追踪 result.value
在 getLines
内部的旅程
现在,我们就是 getLines
返回的那个函数了。我们刚刚从 getBytes
那里收到了一个 result.value
,它现在被命名为 arr
。
让我们看看 arr
会经历什么:
1. 被放入缓冲区
-
目的:处理数据被网络分割的问题。不管
getBytes
给我们的arr
是多大一块,我们都把它安全地放到buffer
这个大工作台上,确保数据是连续的。
// in src/parse.ts, inside the function returned by getLines
if (buffer === undefined) {
buffer = arr; // 如果是第一个 chunk,直接赋值
position = 0;
fieldLength = -1;
} else {
// 如果之前有剩下的数据,就把新来的 arr 拼在后面
buffer = concat(buffer, arr);
}
2. 在缓冲区中被扫描
- 目的:从连续的二进制数据中,识别出“行”的边界。
// in src/parse.ts, inside the function returned by getLines
const bufLength = buffer.length;
let lineStart = 0; // 记录当前行开始的位置
while (position < bufLength) {
// 从当前位置开始,一个字节一个字节地寻找换行符
let lineEnd = -1;
for (; position < bufLength && lineEnd === -1; ++position) {
switch (buffer[position]) {
case 13: // \r
discardTrailingNewline = true;
case 10: // \n
lineEnd = position;
break;
}
}
// ...
}
3. 被切割成“行”并送往下游
-
目的:
getLines
的核心任务完成!它把原始的、大小不一的chunk
,成功转换成了一行一行(line
)的数据。
// in src/parse.ts, inside the while loop
if (lineEnd === -1) {
// 还没找到换行符,说明这行数据不完整,等待下一个 chunk
break;
}
// 找到了!把这一行数据从 buffer 中“剪”下来
// 注意:subarray 是高效的,没有复制数据,只是创建了一个新的“视图”
const line = buffer.subarray(lineStart, lineEnd);
// 把处理好的“行”数据,交给下一个环节
onLine(line, fieldLength);
// 更新下一行的起始位置
lineStart = position;
fieldLength = -1;
第三站:进入 getMessages
- 包裹的最终组装
我们已经追踪到,原始的 result.value
经过 getLines
的处理,变成了一行一行的 line
。这些 line
通过 onLine(line)
调用被发送出去。
那么,onLine
究竟是谁?它就是 getMessages
返回的那个函数!现在,我们进入旅程的最后一站。
我们先看 getMessages
的结构:
// in src/parse.ts
export function getMessages(onId, onRetry, onMessage) {
// 1. 准备工作:创建一个空的 message 对象和一个解码器
let message = newMessage();
const decoder = new TextDecoder();
// 2. 返回一个全新的函数 onLine
return function onLine(line, fieldLength) {
// ... 核心处理逻辑就在这里 ...
}
}
这个返回的 onLine
函数,就是 result.value
变形记的终点。它接收 getLines
切割好的每一行 line
,并执行最终的“组装”工作。
在 onLine
内部,主要有两种情况:
情况一:收到一个空行 (line.length === 0
)
这是 SSE 协议中的“暗号”,代表一条完整的消息至此结束。
// in src/parse.ts, inside the returned function
if (line.length === 0) {
// 触发 onMessage 回调,把组装好的包裹发走!
onMessage?.(message);
// 再拿一张新的空快递单,准备下一个包裹
message = newMessage();
}
-
onMessage?.(message)
: 旅程结束!组装完毕的message
对象,通过这个回调函数,被正式派送给最终的用户。 -
message = newMessage()
: 旧的工作完成,立刻为新的任务做准备,体现了状态机的思想。
情况二:收到带有内容的行
这意味着我们收到了类似 data: Hello World
或 id: 123
这样的信息。
// in src/parse.ts, inside the returned function
} else if (fieldLength > 0) {
// 将二进制的 line 解码成字符串
const field = decoder.decode(line.subarray(0, fieldLength));
const value = decoder.decode(line.subarray(/*...*/));
// 根据 field 的内容,填写快递单
switch (field) {
case 'data':
// 多行 data 需要用 \n 拼接
message.data = message.data
? message.data + '\n' + value
: value;
break;
case 'id':
// 填写 id,并同时通过 onId 回调“汇报”给总指挥室
onId(message.id = value);
break;
case 'event':
message.event = value;
break;
case 'retry':
// ...处理 retry...
break;
}
}
-
解码:
decoder.decode
将二进制数据转换成我们可以理解的field
和value
字符串。 -
填充:
switch
语句像一个熟练的工人,根据field
的指示,将value
准确地填充到message
对象的相应属性上。 -
汇报: 在处理
id
和retry
的时候,它还会“顺便”调用onId
和onRetry
回调,将关键信息实时同步给fetch.ts
。
总结:result.value
的变形记
- 在
getBytes
中,它诞生了,名字叫result.value
。它是一个原始的、大小不一的二进制数据块。 -
getBytes
把它交给了getLines
返回的onChunk
。 - 在
onChunk
内部,result.value
(现在叫arr
) 被放入缓冲区,并被切割成了一段或多段更小的数据,名字叫line
。它现在是一行一行的二进制数据。 -
onChunk
把每一个line
交给了getMessages
返回onLine
。 - 在
onLine
内部,line
被最终解析,它的信息(id
,data
等)被用来填充一个message
对象。 - 最终,当一个完整的
message
对象组装完毕(以一个空行line
为标志),它就会被onmessage
回调发送出去,完成它的使命。
所以,getBytes
抛出的 result.value
并没有被“神秘地”处理掉,而是作为整个流处理管道的输入燃料,驱动了后续所有环节的运行。这是一个从原始数据到结构化信息,层层递进、不断精炼的过程。
演示示例:一次完整的请求与数据处理
让我们通过一个完整的端到端示例,来看看当用户发起请求后,数据是如何在 fetch-event-source
内部被处理的。
第一步:用户发起请求
假设我们有一个 SSE 接口 '/api/events'
,我们会像这样使用 fetchEventSource
:
import { fetchEventSource } from './fetch-event-source';
const ctrl = new AbortController();
fetchEventSource('/api/events', {
signal: ctrl.signal,
onopen(response) {
if (response.ok && response.headers.get('content-type') === 'text/event-stream') {
return; // 一切正常
}
throw new Error(`Unexpected content-type: ${response.headers.get('content-type')}`);
},
onmessage(ev) {
console.log('New message received:', ev);
},
onerror(err) {
console.error('An error occurred:', err);
ctrl.abort(); // 发生错误,终止连接
}
});
第二步:服务器响应与网络分块
服务器开始向我们推送事件。假设它要发送两条消息,原始数据流如下:
id: 1\ndata: First message\n\n_id: 2\nevent: update\ndata: Second message\n\n
然而,由于网络传输的特性,response.body
可能会将数据切割成多个 chunk
(数据块)。我们来模拟一个常见的场景,数据被分成了两个不规则的 chunk
到达:
-
Chunk 1:
id: 1\ndata: First message\n\n_id: 2\nev
-
Chunk 2:
ent: update\ndata: Second message\n\n
第三步:数据在处理管道中的旅程
现在,我们来看看这两个 chunk
是如何被 getBytes
, onChunk
, 和 onLine
逐步处理的。
处理 Chunk 1
-
getBytes
读到Chunk 1
,立即将其交给getLines
返回的onChunk
函数。 -
onChunk
开始处理Chunk 1
:- 它扫描并切割出第一行
"id: 1"
,交给onLine
处理。 - 接着切割出第二行
"data: First message"
,交给onLine
处理。 - 然后切割出一个空行
""
,也交给onLine
。 - 最后,它发现
_id: 2\nev
这一段数据不完整(末尾没有换行符),于是onChunk
将其存入自己的内部缓冲区buffer
,静待下一个数据块。
- 它扫描并切割出第一行
-
onLine
的工作:- 收到
"id: 1"
,将{ id: '1' }
存入内部的message
对象。 - 收到
"data: First message"
,将{ data: 'First message' }
存入message
对象。 - 收到空行,这是一个结束信号!
onLine
触发onmessage
回调。 -
控制台输出:
New message received: { id: '1', data: 'First message', event: '', ... }
-
onLine
重置内部的message
对象,准备处理下一条消息。
- 收到
处理 Chunk 2
-
getBytes
读到Chunk 2
(ent: update\ndata: Second message\n\n
),再次交给onChunk
。 -
onChunk
的工作:- 它首先将
Chunk 2
与缓冲区中剩下的_id: 2\nev
拼接起来,得到一个完整的新数据id: 2\nevent: update\ndata: Second message\n\n
。 - 它开始扫描这个新
buffer
:- 切割出
"id: 2"
,交给onLine
。 - 切割出
"event: update"
,交给onLine
。 - 切割出
"data: Second message"
,交给onLine
。 - 切割出最后的空行
""
,交给onLine
。
- 切割出
- 它首先将
-
onLine
的工作:- 收到
"id: 2"
,存入新的message
对象。 - 收到
"event: update"
,存入message
对象。 - 收到
"data: Second message"
,存入message
对象。 - 收到空行,再次触发
onmessage
回调! -
控制台输出:
New message received: { id: '2', data: 'Second message', event: 'update', ... }
- 收到
通过这个例子,我们可以看到,无论网络如何分割数据,fetch-event-source
强大的流处理管道都能确保每一条消息都被准确无误地解析和送达。
流程图示
下面是整个流程的序列图,可以帮助你更直观地理解各个部分之间的交互。
sequenceDiagram
participant User as 用户
participant FetchTS as fetch.ts (create)
participant FetchAPI as fetch API
participant ParseTS as parse.ts
participant Server as 服务器
User->>FetchTS: fetchEventSource(url, options)
activate FetchTS
FetchTS->>FetchAPI: fetch(url, options)
activate FetchAPI
FetchAPI->>Server: HTTP请求 (GET/POST)
Server-->>FetchAPI: Response + Stream
deactivate FetchAPI
FetchAPI-->>FetchTS: Response对象
FetchTS->>User: onopen(response)
activate User
Note over User: 用户校验响应<br>状态码、Content-Type等
User-->>FetchTS: 正常返回或抛出错误
deactivate User
alt 用户校验失败
User->>FetchTS: 抛出错误
FetchTS->>Catch: 进入异常处理
end
par 流式数据处理
loop 持续读取流
FetchTS->>ParseTS: getBytes(stream, getLines(getMessages(...)))
activate ParseTS
ParseTS->>ParseTS: getBytes: 读取chunk
ParseTS->>ParseTS: getLines: 拼接/分块 → 生成line
ParseTS->>ParseTS: getMessages: 解析line → 组装message
alt 收到完整消息(空行)
ParseTS->>User: onmessage(message)
activate User
User-->>ParseTS:
deactivate User
end
ParseTS-->>FetchTS:
deactivate ParseTS
end
and 错误处理监听
Note over FetchTS: 监听abort信号等
end
alt 流结束或连接关闭
FetchTS->>User: onclose()
FetchTS->>FetchTS: 结束本次连接
end
alt 发生错误
FetchTS->>User: onerror(err)
activate User
User-->>FetchTS: 返回重试延迟或抛出错误
deactivate User
alt 需要重试
FetchTS->>FetchTS: setTimeout(重试延迟)
Note over FetchTS: 等待后重新开始循环
else 终止流程
FetchTS->>User: Promise.reject(error)
FetchTS->>FetchTS: 终止流程
end
end
deactivate FetchTS
React源码 - 大名鼎鼎的Fiber
Fiber源码解析
接触过React的同学都或多或少的听说过Fiber这个名字。那么这篇我们就将通过源码来深入的了解Fiber架构。能更清楚的知道 fiber 对象到底是什么、起到了什么作用以及为什么重要。
在正是开始讲解 fiber 之前,我们需要对React Element和虚拟DOM有个基本的了解,因为它和fiber有着密切的联系。
虚拟DOM
什么是虚拟DOM?
虚拟DOM 英文为 Virtual DOM,也可以简称为V-DOM。 它是一个编程概念,而不是一个具体的实体。它是真实DOM在内存中的一种轻量级表示。也可以理解为使用一个JS对象来描述DOM树的特征。
下面是一段简单的React代码:
<div className={'container'}>
Hello, World!
</div>
经过Babel编译后会变成下面代码:
// 旧版本Bable
React.createElement('h1', { className: 'container' }, 'Hello, World!');
// 新版本Bable
import { jsx as _jsx } from "react/jsx-runtime";
/*#__PURE__*/_jsx("div", {
className: 'container',
children: "Hello\uFF0C World!"
});
上面这段代码最终会返回一个JavaScript对象,用来描述DOM树的结构,这就可以被理解为虚拟DOM:
{
type: 'div',
props: {
className: 'container',
},
children: ['Hello, World!'],
}
很多同学可能会疑问了,上面这个对象不是 React Element 么,为什么说它是虚拟DOM呢? 没错,但他也符合虚拟DOM的特征,所以虚拟DOM并不是一个实体,而是一个概念。任何符合该特征的对象都可以理解为虚拟DOM
而在React16 Fiber架构问世以后,我更愿意将Fiber理解为虚拟DOM。
为什么需要虚拟DOM?
JavaScript暴露了许多可以直接操作DOM的api,但调用这些api的时候,浏览器会频繁渲染,包括计算、布局、绘制、格栅化、合成等,这些操作都会消耗浏览器的性能。
如果先操作虚拟DOM,最后在统一操作真实DOM则会一定程度上的减少浏览器的性能消耗。
React Fiber
Fiber是什么?
React 团队花费了两年的时间重构了fiber架构,用来解决React的性能问题。每个Fiber节点都是一个工作单元,对应一个React Element。
旧的 Stack Reconciler 是同步的,一旦开始渲染就无法中断,而Fiber诞生实现了:
- 可中断渲染
- 优先级调度
- 并发特性
简单来说就是,Reconciler 的过程中会将每一个 fiber 作为一个工作单元来处理。在 Fiber Reconciler 的工作期间,React 会主动判断当前帧是否还有剩余时间,如果没有时间了,它会主动暂停工作,将控制权交还给浏览器,让浏览器去执行更高级别的任务(如处理用户输入、绘制动画等),从而避免造成卡顿。当浏览器处理完紧急任务,主线程再次空闲时(例如在下一帧的开始),React 会通过调度器(Scheduler) 请求再次执行工作,并且会从上次中断的那个 Fiber 节点继续往下处理。
我们知道 React Element 是一个普通的JavaScript对象,从视图层上静态描述UI应该是什么样子。
而 Fiber 则是在 React Element 的基础上包含丰富内部状态和指针的数据结构,代表要执行的工作。简单的说,我们代码会被编译为 React Element,然后通过协调和 Diff 算法将 React element 生成 Fiber 树。最后,根据Fiber上的不同flags标记对DOM进行操作并渲染到页面上。所以,我上面说更愿意将 Fiber 理解为 虚拟DOM。
既然我们知道了 React Element, Fiber 以及真实DOM之前的关系。 那么,下面我们来看一下 Fiber 是长什么样子的。
下面直接附上 Fiber 构造函数的源码:
// facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js#L136-L209
function FiberNode(
this: $FlowFixMe,
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Instance
this.tag = tag; // fiber 类型
this.key = key; // 用于调和子节点
this.elementType = null;
this.type = null; // 元素类型
this.stateNode = null; // 对应的真实 DOM 元素
// Fiber 链表结构
this.return = null; // 指向父节点(父节点)
this.child = null; // 指向第一个子节点(子节点)
this.sibling = null; // 指向下一个兄弟节点(兄弟节点)
this.index = 0; // 在父节点的子节点列表中的索引位置
this.ref = null;
this.refCleanup = null;
// Props 和 State
this.pendingProps = pendingProps; // 新一轮渲染中传入的新 props
this.memoizedProps = null; // 上一次渲染时使用的 props
this.updateQueue = null; // 状态更新队列,存储 setState 产生的更新对象
this.memoizedState = null; // 上一次渲染时使用的 state
this.dependencies = null; // 当前 Fiber 所依赖的上下文(Context)、事件订阅等
this.mode = mode;
// Effects
this.flags = NoFlags; // 当前 Fiber 需要执行的副作用(如 Placement, Update, Deletion)
this.subtreeFlags = NoFlags; // 子节点树中需要执行的副作用(用于性能优化)
this.deletions = null; // 待删除的子 Fiber 节点数组(用于记录需要被删除的节点)
// Lane 模型(优先级调度)
// React 17+ 使用的优先级调度模型,用于并发渲染
this.lanes = NoLanes; // 当前 Fiber 上待处理的更新优先级车道
this.childLanes = NoLanes; // 子节点树中待处理的更新优先级车道
// 双缓存技术
this.alternate = null; // 指向 current 树或 workInProgress 树中的对应 Fiber 节点
// 用于实现双缓存机制,在更新时交替使用两棵树
}
通过上面源码可以清楚的看到Fiber都含有哪些属性,并且通过我的注释也可以对每个属性有一个基本的概念。这些属性可以分为几类,比如有的是构建链表结构的,有的是负责处理状态和props的,还有的是负责标记副作用的。
首先我们先来了解一下Fiber的链表结构,也可以叫做 Fiber tree。
Fiber树
通过 FiberNode 构造函数我们了解到,fiber 通过 return
,child
以及 sibling
属性来构建链表结构,那么我们来看一下下面这段代码生成的Fiber Tree 是什么样子的。
function MyComponent() {
return `(`
<div>
hello,world
<p>paragraph</p>
<button>click</button>
</div>
)
}
return 表示指向父节点, child 表示指向第一个子节点, sibling 则指向兄弟节点。 所以我们就构建出了如上图所示的 Fiber Tree。
我们了解了Fiber Tree 的结构,那么接下来就来分析一下 Fiber Tree是如何构建的。
Fiber Tree 是如何构建的?
Fiber Tree 的构建可以分为初始化和更新。我们先从初始化开始介绍。
React 开发者对下面这段代码肯定再熟悉不过了, createRoot()
就是 concurrent 模式下创建 React 应用程序的入口函数。
import { createRoot } from 'react-dom/client';
const domNode = document.getElementById('root');
const root = createRoot(domNode);
root.render(<App/>)
既然想知道 React Fiber 是如何初始化的,那么我们通过源码沿着这个函数的调用栈,来看一下在调用 createRoot()
的时候,React 都做了哪些事情。
为了方便大家理解,我将只保留每个方法中的关键代码。如果想查看完整代码,大家可以根据代码块中的首行注释去github中查看。
下面是 createRoot()
的关键源码:
// facebook/react/blob/main/packages/react-dom/src/client/ReactDOMRoot.js
export function createRoot(container,options): RootType {
if (!isValidContainer(container)) {
throw new Error('Target container is not a DOM element.');
}
const root = createContainer(container, ConcurrentRoot);
markContainerAsRoot(root.current, container);
const rootContainerElement: Document | Element | DocumentFragment =
!disableCommentsAsDOMContainers && container.nodeType === COMMENT_NODE
? (container.parentNode: any)
: container;
listenToAllSupportedEvents(rootContainerElement);
// $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
return new ReactDOMRoot(root);
}
从上面源码中可以看出,在 createRoot()
函数中, 调用了 createContainer()
方法。下面,我们通过源码来看一下 createContainer()
究竟是做什么的。
// /facebook/react/blob/main/packages/react-reconciler/src/ReactFiberReconciler.js
export function createContainer(
containerInfo: Container,
tag: RootTag,
): OpaqueRoot {
const hydrate = false;
const initialChildren = null;
const root = createFiberRoot(containerInfo, tag, hydrate);
registerDefaultIndicator(onDefaultTransitionIndicator);
return root;
}
createContainer()
方法很简单,就是直接调用了 createFiberRoot()
函数:
//facebook/react/blob/main/packages/react-reconciler/src/ReactFiberRoot.js
export function createFiberRoot(
containerInfo: Container,
tag: RootTag
hydrate
): FiberRoot {
// $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
// Cyclic construction. This cheats the type system right now because
// stateNode is any.
const uninitializedFiber = createHostRootFiber(tag, isStrictMode);
root.current = uninitializedFiber;
uninitializedFiber.stateNode = root;
const initialState: RootState = {
element: initialChildren,
isDehydrated: hydrate,
cache: initialCache,
};
uninitializedFiber.memoizedState = initialState;
initializeUpdateQueue(uninitializedFiber);
return root;
}
从上面代码可以看出,通过调用 fiberRootNode() 构造函数创建了一个 fiberRoot 对象; 然后通过createHostRootFiber
方法创建了一个 RootFiber 对象。最后通过 current 和 stateNode 指针将两者关联起来。
createRoot
最终会生成如下的 fiber 结构:
这是一个 双向环形链接 结构,Root
为根对象, RootElement
则为 Current Tree 的根节点。他们之间通过 current
和 stateNode
指针关联。
至于什么是 Current Tree, 这里就涉及到了 React 架构中的另一个概念 - 双缓冲树。
什么是双缓冲树?
React 内部其实维护着两颗Fiber Tree, 分别是 Current Tree 和 Work In Progress Tree(WIP)。
- Current Tree: 代表页面现在正在展示的 Fiber 结构。
- WorkInProgress Tree: WIP 是在 Render 阶段,通过新的 react element 树和Current tree 调和比较生成的新的 fiber tree。这颗树的构建是在内存中实现的,并在 commit 阶段应用到真实的DOM上。
可以将他俩想象成两幅画,Current Tree 为正在展示的画,而 WIP 为后台正在准备的新画。当新画(WIP)准备好后,将直接交换两幅画,将新画展示给观众。
为什么要两棵树?
如果你边算边改当前树,用户就会看到半更新、逻辑不一致。双缓冲让所有改动都在 WIP 上完成,commit 时一次性落地,可以确保 UI 始终一致。
WIP 在哪里初始化的?
现在我们有了 RootFiber(HostRootFiber)作为 currentTree
的根节点,那么 WIP tree 的根节点是在哪里初始化的呢?
当我们调用 root.render(<App/>)
, react 会开始调度更新。在 渲染阶段(render phase),React 会尝试为 hostRootFiber(current tree 的根节点)创建一个 workInProgress 副本。
具体在 prepareFreshStack 函数中,会调用 createWorkInProgress 来为 hostRootFiber
创建 workInprogress
节点:
// /facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
...
const rootWorkInProgress = createWorkInProgress(root.current, null);
...
}
下面这段源码会创建一个fiber作为WIP 并通过alternate指针关联 currentTree 和 WIP tree。
// This is used to create an alternate fiber to do work on.
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
let workInProgress = current.alternate;
if (workInProgress === null) {
// We use a double buffering pooling technique because we know that we'll
// only ever need at most two versions of a tree. We pool the "other" unused
// node that we're free to reuse. This is lazily created to avoid allocating
// extra objects for things that are never updated. It also allow us to
// reclaim the extra memory if needed.
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode,
);
workInProgress.elementType = current.elementType;
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode;
// 相互设置 alternate 指针
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// We already have an alternate.
// copy from current tree
// Reset the effect tag.
...
}
// Reset all effects except static ones.
// Static effects are not specific to a render.
...
// Clone the dependencies object. This is mutated during the render phase, so
// it cannot be shared with the current fiber.
...
// These will be overridden during the parent's reconciliation
...
return workInProgress;
}
在 workInProgress 树中,节点是一个接着一个处理的,所以正在处理的 fiberNode 就被称作 workInProgress。
可以将
workInProgress
看作一个指针,永远指向正在更新的 fiberNode。
所以,当处理到App组件时,workInProgress
会指向 App。如下图所示:
小结
以上关于 React Fiber 的基本介绍就结束了。 同学们可能还有其它问题,比如,Fiber树是如何更新的以及如何根据fiber树去操作真实DOM的。这些问题,涉及到了React 的调和机制、Render Phase 和 Commit Phase, 感兴趣的同学可以关注一下我发布的React 源码 - Render Phase 的工作细节和React 源码 - Commit Phase 的工作细节这两篇文章。
最后,如果大家对React源码感兴趣,且想系统的学习React设计架构的,在这里推荐大家一本书 - 《深入浅出React开发指南 - 赵林林》
引用: 《深入浅出React开发指南 - 赵林林》
CI/CD学习记录(基于GitLab)
首先来回忆一下之前是怎么发版的?仅有测试和正式两个环境的情况下,大致如下
- 开发完成,开发分支提交代码
- 合并代码到发版(测试)的分支
- 打包构建
- 部署到服务器(测试)
- 进行测试
- 以上步骤不断重复且较为频繁,直到测试通过
- 合并代码到发版(生产)的分支
- 打包构建
- 部署到服务器(生产)
以上步骤,提交代码和合并代码其实还是比较省时的,但是构建和部署是需要花比较多的时间的也是比较容易出错的,尤其是比较大型的项目,那有没有办法让打包构建和部署,甚至是自动测试,交给一些工具来做呢?我们只需要提交代码,工具检测到代码变了,就自动帮我打包构建、自动化测试并部署到服务器。这样一来程序员就可以少干一些事情,同时也能达到一个快捷、稳定、可靠的效果。
基于这个出发点,就开始了CI/CD的学习,本次的目标是:完成提交代码后,能自动构建并部署到测试环境
CI/CD
-
CI: Continuous Integrating,持续集成,这一过程就是将代码变更提交到发版分支(如
master
或test
)等,并通过自动化工具进行构建、测试和验证。- 主要目标:确保每次代码变更都能快速的集成到对应分支,避免代码冲突并尽早发现缺陷
- 关键步骤
- 代码提交:开发人员将代码推送到版本控制系统
- 自动构建:系统自动拉取代码并编译(如构建 Docker 镜像、编译代码)
- 自动测试:运行单元测试、集成测试等,验证代码功能和稳定性
- 反馈结果:若测试失败,立即通知开发人员修复;若成功,则进入下一阶段
-
CD: 有两个含义
- Continuous Delivery,持续交付,在CI的基础上,进一步自动化将代码部署到测试、预生产环境中,确保代码随时可以发布到生产环境
- Continuous Deployment, 持续部署,在持续交付的基础上,自动化将代码直接部署到生产环境
基于GitLab的CI/CD
这里先说三个东西,理解好这三个东西,将会帮助你快速的完成GitLab的CI/CD的操作
- GitLab---托管 GitLab 实例,用于存储和管理代码的仓库工具
- Runner---执行器,用于运行GitLab上的CI/CD作业的工具
- 部署地---部署程序的服务器
以上三个东西,可以在同一台服务器上,也可以各自在不同服务器上,也可以任意组合。不同的组合,这在配置和操作上会有些许不同。 GitLab Doc中指出:出于安全和性能原因,请将 GitLab Runner 安装在与托管 GitLab 实例的机器分开的机器上 由于我这边只有两个服务器,一个是管 GitLab 实例的机器,一个是部署网站的机器,所以我这里就将Runner和部署地放到同一台机器上了。当然你也可以使用本地机器来充当Runner,用于学习测试一样可行。由于我这里已经私有化部署了GitLab了,就不介绍GitLab了,直接将怎么在GitLab上开启CI/CD,这里也简化以下,只讲打包构建和部署两个步骤,至于中间的代码检查、单元测试等就先不提。 目标是:完成提交代码后,能自动构建并部署到测试环境
安装 GitLab Runner(CentOS)
# 添加 GitLab 官方仓库
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | sudo bash
# 安装 GitLab Runner
sudo yum install gitlab-runner
# 安装服务(指定工作目录和用户)
sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
# 启动服务
sudo systemctl start gitlab-runner
# 设置开机自启
sudo systemctl enable gitlab-runner
注册GitLab Runner
关键信息获取地方
- 登录GitLab网页界面。
- 进入 项目 > 设置 > CI/CD。
- 展开 Runners 部分,点击 New project runner。
- 复制生成的 注册令牌(Registration token) 和 GitLab实例URL。
# 交互式注册
sudo gitlab-runner register
按提示输入信息
-
GitLab实例URL(例如:
https://gitlab.com/
)。 - 注册令牌(从GitLab获取)。
-
Runner描述(例如:
my-centos-runner
)。 -
Runner标签(用逗号分隔,例如:
centos,shell
)。 -
执行器类型(推荐使用
shell
或docker
)-
shell
:直接操作操作系统和文件,直接在宿主机运行命令,更简单 -
docker
:通过容器实现资源隔离,避免版本冲突,更推荐
-
- 选择docker才有的镜像选项 (例如:node:20-alpine)
至此展开 Runners ,就能看到新注册的Runner了
Docker支持
# 安装Docker依赖
sudo yum install -y yum-utils
# 添加Docker官方仓库
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
# 安装Docker引擎
sudo yum install docker-ce docker-ce-cli containerd.io
# 启动Docker服务
sudo systemctl start docker
sudo systemctl enable docker
将 gitlab-runner
用户添加到 docker
组
sudo usermod -aG docker gitlab-runner
sudo systemctl restart gitlab-runner
第三方镜像站点
由于官方的站点速度慢,经常会拉取失败,为了解决这个问题,我们可以在/etc/docker/
中增加daemon.json
{
"registry-mirrors": [
"https://docker.registry.cyou",
"https://docker-cf.registry.cyou",
"https://dockercf.jsdelivr.fyi",
"https://docker.jsdelivr.fyi",
"https://dockertest.jsdelivr.fyi",
"https://mirror.aliyuncs.com",
"https://dockerproxy.com",
"https://mirror.baidubce.com",
"https://docker.m.daocloud.io",
"https://docker.nju.edu.cn",
"https://docker.mirrors.sjtug.sjtu.edu.cn",
"https://docker.mirrors.ustc.edu.cn",
"https://mirror.iscas.ac.cn",
"https://docker.rainbond.cc",
"https://do.nark.eu.org",
"https://dc.j8.work",
"https://dockerproxy.com",
"https://gst6rzl9.mirror.aliyuncs.com",
"https://registry.docker-cn.com",
"http://hub-mirror.c.163.com",
"http://mirrors.ustc.edu.cn/",
"https://mirrors.tuna.tsinghua.edu.cn/",
"http://mirrors.sohu.com/"
],
"insecure-registries": [
"registry.docker-cn.com",
"docker.mirrors.ustc.edu.cn"
],
"debug": true,
"experimental": false
}
配置docker执行时挂载到宿主机的volumes(可选)
修改在/etc/gitlab-runner/
在config.yml
文件
concurrent = 1
check_interval = 0
shutdown_timeout = 0
[session_server]
session_timeout = 1800
[[runners]]
name = "tadpole-vue3-235"
url = "xxxxxxx"
id = 7
token = "xxxxxxx"
token_obtained_at = 2025-09-11T10:05:04Z
token_expires_at = 0001-01-01T00:00:00Z
executor = "docker"
[runners.cache]
MaxUploadedArchiveSize = 0
[runners.cache.s3]
[runners.cache.gcs]
[runners.cache.azure]
[runners.docker]
tls_verify = false
image = "node:20-alpine"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache:/cache:rw", "/usr/share/nginx/html:/usr/share/nginx/html:rw"]
shm_size = 0
network_mtu = 0
至此,基于docker的Runner就基本配置完毕了,接下来就需要写.gitlab-ci.yml
文件了
gitlab-ci.yml
首先,这里需要注意一下版本,如果GitLab的实例版本比较低,很多语法是不支持的,可能导致比如AI推荐你的语法但实际用不了。
gitlab-ci.yml语法
核心语法和指令
stages
(阶段)
- 作用:定义流水线的执行阶段,控制任务的顺序。
-
规则:
- 所有
job
必须属于某个stage
。 - 同一
stage
中的job
可并行执行。
- 所有
-
示例:
stages: - build - test - deploy
** job
(任务)**
-
定义:每个任务由
name
(名称)和配置项组成。 -
关键配置:
-
stage
: 指定任务所属的阶段。 -
script
: 要执行的命令列表。 -
before_script
/after_script
: 在script
前后运行的默认脚本。 -
only
/except
: 控制任务触发的分支或事件。 -
image
: 使用的 Docker 镜像(可选)。 -
cache
/artifacts
: 缓存依赖或保存文件。
-
示例任务
build_job:
stage: build
script:
- echo "Building the project..."
before_script:
- apt-get update
- apt-get install -y build-essential
before_script
和 after_script
-
作用:定义在所有
script
命令前/后运行的默认脚本。 -
规则:
- 全局定义(在顶层)时,适用于所有任务。
- 局部定义(在单个任务中)时,覆盖全局配置。
-
示例:
before_script: - echo "Global setup before each job" build_job: script: - echo "Build step" after_script: - echo "Cleanup after build"
image
(Docker 镜像)
- 作用:指定任务运行的环境(Docker 镜像)。
-
规则:
- 可全局定义(所有任务默认使用)。
- 也可单独为某个任务指定。
-
示例:
image: node:18 build_job: image: maven:3.8.4 script: - mvn clean package
cache
和 artifacts
**
-
cache
:缓存依赖(如node_modules
),加速后续任务。cache: paths: - node_modules/
-
artifacts
:保存构建产物(如编译结果),供后续阶段使用。artifacts: paths: - dist/
only
和 except
- 作用:控制任务在哪些分支或事件下触发。
-
示例:
build_job: only: - main # 仅当推送到 main 分支时执行 - tags # 仅当打标签时执行 except: - develop # 排除 develop 分支
variables
(变量)
- 作用:定义自定义变量或覆盖预定义变量。
-
示例:
variables: ENV_VAR: "production" DOCKER_IMAGE: "my-app:${CI_COMMIT_REF_NAME}"
-
变量优先级顺序
- 任务中定义的变量
- 全局定义的变量
- 预定义变量(如$CI_COMMIT_REF_NAME)
多行命令格式
|
(文字模式)
- 每行作为独立命令执行。
-
示例:
script: - | echo "First line" echo "Second line"
>
(折叠模式)
- 将多行合并为一行(适合长命令)。
-
示例:
script: - > echo "This is a long command that will be folded into a single line."
全局配置和特殊指令
include
- 引入其他
.gitlab-ci.yml
文件或模板。 -
示例:
include: - remote: "https://example.com/ci-templates.yml"
workflow
- 控制流水线整体行为(如触发条件)。
-
示例:
workflow: rules: - if: '$CI_COMMIT_BRANCH == "main"' when: always - when: never
示例
# GitLab CI/CD 配置 - 使用Docker执行器和pnpm
# 镜像
image: node:20-alpine
variables:
# 使用pnpm包管理器
CI: 'true'
NODE_ENV: 'test'
# pnpm配置
PNPM_VERSION: '10.12.4'
# 构建输出目录
BUILD_DIR: dist
# 部署目标目录
DEPLOY_PATH: /usr/share/nginx/html
# SSH配置
SSH_HOST: 'xxx.xxx.x.xxx'
SSH_USER: 'root'
# 全局缓存配置
cache:
key: ${CI_COMMIT_REF_SLUG}-pnpm-store
paths:
- .pnpm-store/
stages:
- build
- deploy
# 构建阶段
build-project:
stage: build
before_script:
- echo "设置pnpm环境..."
- npm install -g pnpm@${PNPM_VERSION} || echo "pnpm已安装"
- pnpm config set store-dir .pnpm-store
script:
- echo "构建项目..."
- pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
# 修改build命令,使用正确的vite.js路径
- node --max_old_space_size=5120 node_modules/vite/bin/vite.js build --mode test
# 上传构建产物
artifacts:
paths:
- dist/
expire_in: 7 days # 自定义过期时间
only:
- dev
tags:
- docker,vue
# 部署到测试环境
deploy-to-test:
stage: deploy
script:
- echo "🚀 开始本地部署到 ${DEPLOY_PATH}"
# 检查目标目录是否存在
- 'if [ ! -d "${DEPLOY_PATH}" ]; then echo "创建部署目录: ${DEPLOY_PATH}"; mkdir -p ${DEPLOY_PATH}; fi'
- echo "🛡️ 检查目录权限..."
- 'touch ${DEPLOY_PATH}/.permission-test && rm ${DEPLOY_PATH}/.permission-test && echo "目录权限正常" || (echo "错误: 没有写入${DEPLOY_PATH}的权限" && exit 1)'
- echo "📤 开始同步文件..."
- cp -r dist/* ${DEPLOY_PATH}/
- echo "✅ 文件同步完成"
- echo "🔍 验证部署..."
- echo "服务器磁盘使用情况:" && df -h ${DEPLOY_PATH}
- echo "部署目录内容:" && ls -la ${DEPLOY_PATH}/ | head -10
- echo "index.html大小:" && du -sh ${DEPLOY_PATH}/index.html 2>/dev/null || echo "index.html不存在"
- echo "🎉 部署成功,访问地址 http://${SSH_HOST}"
environment:
name: test
url: http://${SSH_HOST}
dependencies:
- build-project
only:
- dev
tags:
- docker,vue
最后
CI/CD的方案还有很多,比如GitHub Actions
、Jenkins
、Spinnaker
什么的,也可以尝试去实践一下。我认为掌握一些这个知识是非常重要的,即使根本就轮不到你来做这个事,大一些的团队应该都有专门的负责人来做这个事。
共勉。
VSCode三个TS扩展工具介绍
以下是对 VS Code 中三个常用工具 / 扩展的详细介绍:
1. TypeScript Hero
TypeScript Hero 是一款专为 TypeScript 开发者设计的 VS Code 扩展,主要用于提升代码编写效率,提供了一系列实用功能:
-
自动导入管理:智能检测未导入的模块并自动添加导入语句,同时可以清理未使用的导入
-
代码生成:快速生成类的构造函数、getter/setter 方法等
-
路径别名支持:自动识别 tsconfig 中的路径别名并在导入时使用
-
类型提示增强:优化 TypeScript 的类型提示和自动补全
-
代码导航:提供更便捷的代码跳转和导航功能
安装后,它会自动与 TypeScript 项目集成,通过配置可以自定义各种行为,特别适合大型 TypeScript 项目开发。
2. ESLint
ESLint 是一个可配置的 JavaScript/TypeScript 代码检查工具,主要用于:
-
代码质量检查:检测潜在的错误、未使用的变量、不合理的代码结构等
-
编码规范 enforcement:确保团队遵循一致的编码标准(如缩进、命名约定等)
-
自定义规则:可以根据项目需求配置或编写自定义规则
-
自动修复:许多问题可以通过
--fix
命令自动修复
在 TypeScript 项目中使用时,需要安装 @typescript-eslint
相关包。配合 VS Code 的 ESLint 扩展,可以实时在编辑器中看到错误提示,并通过配置实现保存时自动修复。
3. Prettier
Prettier 是一个代码格式化工具,专注于代码风格的统一,特点包括:
-
** Opinionated :有一套默认的格式化规则,减少团队关于代码风格的争论
- 多语言支持 :支持 JavaScript/TypeScript、CSS、HTML、JSON 等多种语言
- 与编辑器集成 :在保存时自动格式化代码
- 可配置性 **:虽然强调默认规则,但也提供了一定的配置选项(如单引号 / 双引号、换行符等)
通常建议将 Prettier 与 ESLint 配合使用:用 ESLint 负责代码质量检查,用 Prettier 负责代码格式化。可以通过 eslint-config-prettier
来解决两者可能的规则冲突。
这三个工具结合使用,可以显著提升 TypeScript 项目的开发效率和代码质量,同时保证团队开发时的代码风格一致性。