普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月19日掘金 前端

原子化的未来?了解一下全面进化的CSS attr函数

作者 XboxYan
2025年5月19日 09:24

欢迎关注我的公众号:前端侦探

CSS attr函数相信大家都用过了吧,通常会配合伪元素content动态生成内容,比如一个简易的tooltip

<span class="css-tips" data-title="我是tooltip" >提示上</span>

通过attr动态生成

.css-tips[data-title]:after {
  content: attr(data-title);
  /*...*/
}

效果如下

image-20250516200454818

你可以访问这个链接查看完整demo: codepen.io/xboxyan/pen…

不过,之前仅仅支持字符串形式,对于数字、颜色等都无法识别,例如

<div w="10"></div>
<style>
  div{
    width: attr(w) /**不生效/
  }
</style>

现在,CSS attr迎来了全面进化(chrome 133+),很多问题都得到了很好的解决,一起看看吧~

一、快速上手

比如这样一个结构,是不是看着有些眼熟?

<div w="100" h="100"></div>
<style>
  div{
    background: royalblue;
  }
</style>

那么,如何让属性上的尺寸传递应用到实际的宽高上呢?你可以这样

[w]{
  width: attr(w px)
}
[h]{
  height: attr(h px)
}

来看看效果

image-20250516170508419

我们可以用之前的规则,将尺寸通过content显示出来

div:before{
  content: attr(w) '*' attr(h);
  color: white;
  font-size: 14px;
}

效果如下

image-20250516170636866

更为关键的是,这些完全是自动获取的,你可以设置多个任意尺寸

<div w="100" h="100"></div>
<div w="200" h="100"></div>
<div w="300" h="100"></div>

效果如下

image-20250516170814615

是不是非常灵活?

二、语法详解

现在来看看语法规则

attr(<attr-name> <attr-type>? , <fallback-value>?)

其实相比之前的规则,多了两个可选参数,一个是attr-type,表示属性类型,完整类型可以参考

developer.mozilla.org/en-US/docs/…

还有一个是allback-value,表示回退值,一些写法如下

/* Basic usage */
attr(data-count)
attr(href)

/* With type */
attr(data-width px)
attr(data-size rem)
attr(data-name raw-string)
attr(id type(<custom-ident>))
attr(data-count type(<number>))
attr(data-size type(<length> | <percentage>))

/* With fallback */
attr(data-count type(<number>), 0)
attr(data-width px, inherit)
attr(data-something, "default")

前面的例子其实带类型的值,除了使用px,还可以使用任何已有的CSS单位,比如

<div w="100" h="100" rotate="45"></div>

这里定义了一个旋转角度,可以直接加上角度单位deg

[rotate]{
  rotate: attr(rotate deg)
}

效果如下

image-20250516172610893

但是,有些值其实是不带单位的,比如颜色,并没有什么后缀单位,比如

<div w="100" h="100" rotate="45" bg="red"></div>

这时,可以采用type来手动指定

[bg]{
  background: attr(bg type(<color>));
}

效果如下

image-20250516181308281

有些属性可能不止一种类型,比如background,支持颜色,也支持渐变,还支持图像,这里其实也能定义多种类型

[bg]{
  background: attr(bg type(<color>|<image>));
}

我们换成渐变试试

<div w="100" h="100" rotate="45" bg="linear-gradient(orange,red)"></div>

也能完美适配

image-20250516182249509

多个值写起来可能比较麻烦,可以用通配符来代替,相当于传入什么,读取的就是什么

[bg]{
  background: attr(bg type(*));
}

最后就是回退值,非常类CSS变量,当属性不存在时(注意不能是空),采用回退值,比如

div{
  background: attr(bg type(*), royalblue);
}

现在去除bg属性

<div w="100" h="100" rotate="45"></div>

就回到了默认的宝蓝色

image-20250516183003948

你也可以访问在线demo真实体验: codepen.io/xboxyan/pen…

三、带数字显示的进度条

下面来看一个案例

image-20250516185648485

在过去,如果想用单个标签、单一变量来实现,通常会用到CSS变量,就像这样

<div class="progress" style="--value:30"></div>
<div class="progress" style="--value:42.5"></div>
<div class="progress" style="--value:50"></div>
<div class="progress" style="--value:90"></div>

进度很好办,直接用这个变量计算就好了,那后面的数字怎么办呢?直接使用变量是不行的

::before{
  content: var(--value) /*不生效*/
}

其实可以用计数器来实现,类似于这样

.progress::before {
  --value: 50;
    counter-reset: progress var(--value);
    content: counter(value);
}

有兴趣可以查看张老师的这篇文章: 小tips: 如何借助content属性显示CSS var变量值

不过计数器在正常场景下不支持小数,导致有些场景受限

如果需要展示小数可以参考这篇文章: 如何让CSS计数器支持小数的动态变化?

现在有了attr,可以直接用属性来实现,实现更方便

<div class="progress" value="30"></div>
<div class="progress" value="42.5"></div>
<div class="progress" value="50"></div>
<div class="progress" value="90"></div>

直接通过渐变绘制进度attr(value %)

.progress {
  color: royalblue;
  width: 300px;
  height: 20px;
  background: linear-gradient(currentColor, currentColor) 0 0 / attr(value %) 100% no-repeat #ccc;
  border-radius: 2px;
  position: relative;
}
.progress::after {
  content: attr(value);
  position: absolute;
  top: 50%;
  left: 100%;
  transform: translate(10px, -50%);
  font-size: 20px;
}

你也可以访问在线demo真实体验:codepen.io/xboxyan/pen…

四、原子化的未来?

回头再来看看这种写法,是不是非常类似现在流行的原子化CSS?

<div w="100" h="100"></div>
<div w="200" h="100"></div>
<div w="300" h="100"></div>

嗯...等到兼容性没有问题后,现在的原子化框架都得革新了 ,只需要极少部分原子CSS即可适配大量的样式,而不是这样生成大量用到的样式

image-20250516195921515

attr可能就两行,类似这样

[fs]{
  font-size: attr(fs type(<length>))
}
p{
  padding: attr(p type(*))
}

是不是可以节省大量CSS代码?

五、优势和局限

其实很多特性和CSS变量还是比较相似,不过相比而言还是有不少优势的

  1. 支持content内容生成
  2. html结构更直观,个人觉得CSS变量放在style上有些冗余
  3. 天然原子化,比现在框架生成要高效的多

然后有一个局限性,那就是不支持链接格式,比如

<div src="xxx.png"></div>

如果直接这样使用,是不会生效的

div{
  background: url(attr(src)); /*无效*/
}

只能用这种形式,其实和现在CSS变量差不多了

<div src="url(xxx.png)"></div>

官方说明是为了安全考虑,不能用于动态构造 URL

😭太可惜了,一直想用这个功能能实现自定义 img 标签,将图片转成背景图片,这样就能做更多事情了

<img src="xxx.png">
<style>
  img{
    background: url(attr(src));
  }
</style>

总之,这是一个未来非常有潜力的新特性,敬请期待吧。最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发 ❤❤❤

别再写一堆 if-else 了:用状态模式优雅管理状态行为

2025年5月19日 09:18

前言

介绍了状态模式(State Pattern)的概念及其在管理对象动态行为中的应用。今日文章由 @Maxim Gorin 分享,前端早读课@飘飘翻译。

状态设计模式(State Design Pattern)是一种行为型软件设计模式,它允许对象在其内部状态发生改变时调整自身的行为。简单来说,状态模式让对象根据当前的状态表现出不同的行为,而不用在代码中堆满 if/else 或 switch 语句。

在我们之前的文章《为什么命令模式比你想象的更有用》中,我们探讨了如何将动作封装为对象以提升代码的灵活性。状态模式采取了类似的思路,不过它专注于将 “状态” 和 “行为” 封装为独立的对象。和命令模式一样,状态模式有助于我们减少大量条件语句,并遵循良好的设计原则 —— 但它解决的是另一类问题。

图片

一个现实生活中的类比:手机的通知模式

想象一下,你有一部智能手机,它有多个通知模式:正常、振动、静音。

  • 在 “正常” 模式下,来电会响铃;
  • 在 “振动” 模式下,手机不会响铃,而是震动;
  • 在 “静音” 模式下,既不会响铃也不会震动 —— 可能只是记录一个未接来电。

你(作为手机的使用者)可能会根据不同场景(比如上班、开会、看电影等)手动切换这些模式,而手机的行为会随之改变,而无需你每次都去修改手机内部的响铃机制。

这个例子就是状态模式的一个贴切类比:

  • 手机是那个行为会根据状态变化的对象;
  • 当前的模式(正常 / 振动 / 静音)就是手机的内部状态;
  • 每种状态定义了手机在特定操作下应该如何响应(比如接到电话时怎么做);
  • 切换模式,其实就是更换内部的状态对象,从而改变了手机的行为。

那为什么不用 if-else 或枚举呢?你当然可以用简单的 if 或 switch 来处理手机的行为:

if(mode ==NORMAL){
   响铃
}else if(mode ==VIBRATE){
   震动
}else if(mode ==SILENT){
   保持安静
}

这个方式在模式不多的时候确实没问题。但想象一下,如果手机有十几种模式,每种模式还要影响多个行为(比如来电、短信、闹钟、通知等),那么条件分支会迅速增多,而且每种模式的逻辑会分散在代码中的各个 if 语句里,维护起来就非常容易出错。

而状态模式提供了一种更清晰的方案:将每种模式都作为一个独立的状态类,里面包含各自的逻辑。手机只需要持有一个状态对象的引用(比如 SilentState 或 VibrateState 的实例),并将行为委托给这个对象。当你切换模式时,实际上就是更换状态对象,这样就避免了大量的条件判断,而是依靠多态来实现 —— 每个状态类知道在该状态下该如何处理具体的动作。

状态模式的工作原理

状态模式包含几个关键组成部分:

  • 上下文(Context)- 就是那个拥有动态内部状态的主要对象。在前面的类比中,手机就是上下文。
  • 状态接口(或抽象类)- 定义所有状态类共有的接口,声明了上下文想要委托给状态对象的一些操作方法。例如,一个 PhoneState 接口可能会声明一个 handleIncomingCall() 方法。
  • 具体状态类(Concrete State Classes)- 这些类代表特定的状态,每个类实现状态接口,并提供该状态下的具体行为,比如 NormalStateVibrateStateSilentState 分别定义了接电话时的不同响应方式。
  • 状态切换 - 上下文通常会有一个方法用于更换当前状态。这种切换可能是外部触发的(比如用户手动换模式),也可以是内部逻辑决定的(某个状态对象决定切换到其他状态)。

图片

在状态模式中,当上下文对象接收到某个请求(比如 phone.receiveCall()),它并不会自己处理这个请求,而是将其委托给当前的状态对象(比如 currentState.handleIncomingCall())。由于每个状态对象对这个方法的实现都不同,所以即便调用的是同一个方法,结果也会因当前状态的不同而有所区别。

这就是多态在起作用:一个方法调用,根据具体的状态对象不同,表现出不同的行为。

避免条件语句过于复杂

使用状态模式的主要动机之一,就是为了消除代码中重复且分散的条件逻辑。如果一个对象的行为会根据状态变化而改变,你可能会倾向于用枚举或布尔标志来跟踪状态,然后在每个需要根据状态处理的地方写 switch 或 if 语句。这种做法会让代码变得臃肿、难以维护。

状态模式的做法是把每种状态下的逻辑封装在独立的类中:

  • 每种状态的逻辑都放在自己专属的类中(比如 “静音模式” 的所有逻辑都放在 SilentState 里)。
  • 上下文(Context)对象的代码会变得更简单,不再需要处理大段的条件判断逻辑。
  • 增加新的状态或修改已有状态,不需要在多个地方修改庞大的 switch 语句 —— 只需新增或修改一个状态类。

经典定义中提到:“当某些操作包含大量依赖于对象状态的条件语句时,状态模式会将每个条件分支封装在独立的类中,把状态当作一个独立的对象来看待。”

这种封装方式符合开闭原则(Open/Closed Principle):我们可以在不修改原有上下文或其他状态类的情况下引入新的状态。同时也符合单一职责原则(Single Responsibility Principle),因为每个状态类只负责处理一种状态下的行为。

什么时候该使用(或不使用)状态模式

适合使用状态模式的场景:
  • 当一个对象的行为依赖其当前状态,并且它在运行时需要根据状态改变行为时。如果你发现自己在多个地方都写着 “如果状态是 X 做这个,状态是 Y 做那个”,那可能就适合用状态模式。
  • 当一个对象有多个行为逻辑,并且这些逻辑可以明确地按状态划分。例如,手机的响铃、震动、静音记录等行为都可以独立处理。
  • 想要避免状态判断逻辑重复出现在多个方法中。使用状态模式后,这些行为被集中封装在状态类中,不再重复。
  • 预计未来可能会增加新的状态,或每个状态下的逻辑会变得更复杂。状态模式的结构更容易扩展(新增一个状态类)或修改(只需改动一个类的代码)。
不适合使用状态模式的情况或需谨慎使用:
  • 如果对象只有一两个状态,而且每种状态下的行为差异非常简单,那么使用状态模式可能就有点小题大做了。用普通的条件判断反而更清晰。
  • 如果状态切换很少发生,或者每种状态的逻辑基本不会变,那用状态模式引入的一堆类可能并不值得。
  • 如果状态数量固定且逻辑简单明确,使用枚举加 switch 语句可能就足够了。状态模式适用于那些状态复杂且易变的场景。

可以这样想:一个只有两个状态的小状态机,用 if 来管理也没问题。但如果是一个有十种状态、状态之间还有复杂切换逻辑的状态机,那用状态模式结构化处理会更好维护。

为什么状态模式比枚举和标志变量更好?

一开始,很多人会选择用枚举或布尔标志来表示状态,比如:

enum Mode { normal, vibrate, silent }

然后用类似这样的逻辑处理行为:

if(mode == Mode.normal){
// 响铃
}else if(mode == Mode.vibrate){
// 震动
}else if(mode == Mode.silent){
// 保持静音
}

这种方式起初是可行的,但随着程序变复杂,会出现以下问题:

  • 逻辑分散 - 如果多个行为都依赖状态判断,你就会在很多方法里看到类似的 if/else 或 switch,例如 handleCall()notifyMessage()alarmRing() 等等。状态行为稍有改动,就得到处找这些条件语句并改动。
  • 违反开闭原则 - 比如你想新增一个 “请勿打扰” 模式(Do Not Disturb),就得修改所有相关的 switch 语句。每次修改都有可能引入 bug,影响原有功能。
  • 维护困难 - 状态和条件越来越多,代码就越难阅读和维护,容易变成一个嵌在业务逻辑中的 “巨型状态机”。

状态模式通过封装各个状态的行为,解决了这些问题。你不再需要一个大函数来处理各种分支,而是有多个小类,各自处理自己的状态行为。这样结构更清晰:

  • Phone 类(上下文)无需了解各个模式的具体行为,它只需把行为委托给当前的状态对象。
  • 想增加一个 “请勿打扰” 模式,只需新增一个 DoNotDisturbState 类,定义好它的行为。Phone 类可能只需做一点小改动,甚至不改(如果状态可以通过 setter 或工厂设置)。
  • 删除或修改某个状态行为,只需要修改该状态类,不会影响到其他代码,降低了出错风险。

简而言之:在复杂场景中,状态模式比枚举 / 标志 + 条件判断更健壮、更灵活。它让代码模块化,符合设计原则,也更方便多个开发者(前端、后端、移动端等)理解和协作,不用去翻那些密密麻麻的条件语句。

状态模式的优缺点

像所有设计模式一样,状态模式也有优劣之分。我们来具体看一下:

优点:
  • 代码结构更清晰 - 每种状态对应一个独立的类,满足单一职责原则。每个状态类只负责一种状态下的行为。
  • 消除复杂的条件语句 - 上下文对象不再被 if/else 或 switch 语句包围,结构更简单,维护起来更轻松。
  • 更符合开闭原则 - 添加新状态无需修改原有代码,特别是上下文对象或其他状态类,扩展性好。
  • 状态切换逻辑集中管理 - 可以在状态类或上下文中集中处理状态之间的转换,流程更易管理和理解。
  • 支持多态行为 - 通过运行时替换状态对象来改变行为,其他系统部分不需要感知变化,降低出错率。
缺点:
  • 类数量增加,结构变复杂 - 每种状态都需要一个类,对于简单场景来说,这可能是 “用大炮打蚊子”,过度设计。
  • 状态爆炸问题 - 如果一个对象有很多种状态,就会出现大量状态类,状态间的切换也变得复杂。(解决办法:可以分组、分层,或重新思考是否真有那么多必要的状态)
  • 状态与上下文之间耦合 - 状态类通常需要知道上下文对象的情况,甚至可能要知道其他状态类的信息,这会引入耦合。好在这种耦合是局部且可控的,通常是可以接受的权衡。
  • 学习成本 - 对一些不熟悉设计模式的开发者来说,“对象中还有另一个对象来处理逻辑” 这个思路可能不太直观。对比直观的 if 条件语句,状态模式可能需要点时间适应。
  • 内存 / 性能开销 - 在某些语言中,频繁创建状态对象可能会有一点性能损耗(不过大多数情况下可以忽略)。如果状态对象包含大量从上下文复制的数据,可能会导致效率问题。但通常状态类都很轻量,甚至可以使用单例模式来复用,因此一般不会成为瓶颈。
应对这些缺点的建议:
  • 担心类太多?可以将状态类写成内部类,甚至匿名类(如果语言支持),这样可以把它们和上下文放在一起。
  • 担心对象创建成本?可以复用状态实例,状态模式并不要求每次都新建对象。你可以用单例或无状态对象。
  • 担心别人看不懂?使用清晰的命名和注释,让每个状态类的职责一目了然,降低理解门槛。

用 Dart 实现状态模式(以手机模式为例)

为了加深理解,我们用 Dart 来实现前面讲到的智能手机通知模式的例子。我们将创建一个简单的模拟程序,模拟手机在不同模式下接到电话的行为。代码是完整可运行的,可以在 Dart 在线编辑器或其他环境中直接运行并在控制台查看输出。

示例设计结构:
  • 我们会创建一个抽象类 PhoneState,定义当手机接到来电时应该执行的方法(onReceiveCall)。
  • 创建三个具体的状态类:NormalStateVibrateState 和 SilentState,分别继承 PhoneState 并实现各自不同的来电响应逻辑。
  • Phone 类是上下文类,它持有一个 PhoneState 类型的状态属性。所有的来电处理都会委托给当前状态对象,同时它也提供方法来切换状态(setState())。
  • 我们将模拟手机在不同模式下接听电话的过程,以观察行为变化。

以下是 Dart 示例代码:

// 状态接口(在 Dart 中用抽象类表示)
 abstract class PhoneState{
  void onReceiveCall(Phone context);
}

// 具体状态类:正常模式(响铃)
class NormalState implements PhoneState{
   @override
   void onReceiveCall(Phone context){
      print("来电:铃铃铃!📢(正常模式)");
      // 正常模式下手机响铃,不会自动切换状态
   }
}

// 具体状态类:振动模式
class VibrateState implements PhoneState{
   int _vibrateCount =0;// 模拟内部状态,例如振动次数

   @override
   void onReceiveCall(Phone context){
     _vibrateCount++;
     print("来电:嗡嗡嗡…… 🤫(振动模式)");
     // 如果振动次数过多,自动切换为静音模式(只是示例,现实中不会这样)
    if(_vibrateCount >=3){
       print("连续振动 $_vibrateCount 次未接听,切换为静音模式。");
       context.setState(SilentState());
    }
  }
}

// 具体状态类:静音模式
classSilentStateimplementsPhoneState{
   @override
   void onReceiveCall(Phone context){
      print("来电:(静音模式,无声音)🤐");
      print("手机保持静音,稍后可能会看到未接来电。");
   }
}

// 上下文类:手机
class Phone{
   // 默认从正常模式开始
   PhoneState _state =NormalState();

   void setState(PhoneState newState){
     _state = newState;
     // 可以在这里打印或记录模式变更(可选)
   }

  void receiveCall(){
     // 将行为委托给当前状态对象
     _state.onReceiveCall(this);
  }

   // 可选:获取当前状态名称用于打印日志
   String get modeName => _state.runtimeType.toString();
}

void main(){
   Phone phone =Phone();
   print("手机当前模式:${phone.modeName}");

   // 模拟来电(正常模式)
   phone.receiveCall();// 响铃

   // 切换到振动模式
   phone.setState(VibrateState());
   print("\n手机当前模式:${phone.modeName}");
   phone.receiveCall();// 第一次振动
   phone.receiveCall();// 第二次振动
   phone.receiveCall();// 第三次振动,触发自动切换为静音

   // 此时应自动进入静音模式
   print("\n手机当前模式:${phone.modeName}");
   phone.receiveCall();// 静音,不响铃

   // 手动切换回正常模式
   phone.setState(NormalState());
   print("\n手机当前模式:${phone.modeName}");
   phone.receiveCall();// 再次响铃
}
上面的代码要点:
  • Phone 类(上下文)并不知道来电时具体该做什么,它只是调用 _state.onReceiveCall(this),由当前状态对象处理行为。这正是状态模式的核心。
  • 每个状态类只处理一种模式下的行为。比如 SilentState 只处理静音模式的来电情况。
  • VibrateState 中加入了一个小彩蛋:如果连续接到 3 个电话都没有应答,它会自动切换为静音模式。这是为了演示状态对象内部可以触发状态切换的能力。
  • 切换状态的方式很简单:调用 phone.setState(SomeState()) 即可。你可以想象这种行为由用户操作或程序逻辑触发。

运行这段代码,会看到类似如下的输出:

 手机当前模式:NormalState
 来电:铃铃铃!📢(正常模式)

 手机当前模式:VibrateState
 来电:嗡嗡嗡…… 🤫(振动模式)
 来电:嗡嗡嗡…… 🤫(振动模式)
 来电:嗡嗡嗡…… 🤫(振动模式)
 连续振动 3 次未接听,切换为静音模式。

 手机当前模式:SilentState
 来电:(静音模式,无声音)🤐
 手机保持静音,稍后可能会看到未接来电。

 手机当前模式:NormalState
 来电:铃铃铃!📢(正常模式)

可以看到,每次状态变化后,手机的行为也随之改变。而这些行为的逻辑并不在 Phone 类中,而是被封装在各自的状态类中。这体现了状态模式的强大之处。

局限性和权衡取舍

虽然状态模式功能强大,但也并非万能:

复杂性 vs 简洁性

在使用前要权衡问题的复杂度。只有当状态模式能降低整体复杂度时才值得引入。如果感觉它反而增加了不必要的层级结构,那也许应该退一步,采用更简单的方案。一个常用的经验法则是:当你有两三个以上的行为分支,并且这些行为可能会扩展或变化时,就可以考虑使用状态模式。

状态切换逻辑的归属问题

设计时一个常见的挑战是:状态切换的逻辑应放在哪?在我们的示例中,是由 VibrateState 自己决定切换到 SilentState。在其他设计中,可能会让 Phone(上下文)来判断是否切换,这取决于具体业务。状态模式对此并没有硬性规定,你可以根据可读性和清晰度来决定。若切换逻辑太复杂,建议写好注释或简化切换规则。

状态数量管理

如果你预计状态会爆炸式增长,那需要重新思考是否每个状态都值得用一个类。有时候看起来像 “多个状态” 的,其实可以合并处理,或者通过数据而非类来管理。比如手机的 10 级音量,不需要写 10 个状态类,而是把音量值作为一个参数放到 “正常模式” 类中就行。只有当状态间行为本质上不同时,才值得使用不同的状态类。

尽管存在这些考虑因素,状态模式仍是一种久经考验的工具。它使代码保持灵活且易于扩展。许多框架和库在内部使用状态模式或类似的概念(例如,用户界面组件通常具有启用 / 禁用 / 悬停等状态,这些状态在幕后通过状态对象或状态模式来实现)。

总结

状态设计模式能让对象更具灵活性、更易维护,它通过将状态相关的行为封装到独立的类中,实现了 “行为与状态解耦” 的目标。我们的手机模式示例展示了:一个设备如何通过切换内部状态对象,改变自己的行为(响铃、振动、静音),而无需在主类中堆积大量条件语句。

如果你正在开发一个系统,它涉及 “模式、阶段、条件行为” 的切换,状态模式是你值得掌握的一种工具。虽然初期设置可能略显麻烦,但随着项目增长,你会越来越体会到它带来的好处:关注点分离、逻辑清晰、可扩展性强。

关于本文
译者:@飘飘
作者:@Maxim Gorin
原文:maxim-gorin.medium.com/stop-writin…

MCP Server 的三种实现

作者 唐诗
2025年5月19日 09:11

MCP 规范定义了三种标准的传输机制,也就对应了三种 MCP Server 的实现方式

本文对三种数据传输方式及 Server 的实现进行了实践,代码已上传 GitHub 猛击访问

image.png

MCP 三种标准的传输机制

标准输入和标准输出的通信 STDIO

专为本地 MCP 连接设计,比如通过 node index.js 执行 MCP Server 进行交互

服务器发送事件 SSE

目前大多数远程 MCP 客户端都支持,但预计随着时间的推移将被流式 HTTP 取代。

它需要两个端点:一个用于发送请求,另一个用于接收流式响应。

可流式传输 HTTP

2025 年 3 月引入的新传输方法。它通过使用单个 HTTP 端点简化了双向通信。

目前,它正在被远程 MCP 客户端采用,预计未来将成为标准的传输方式。

为什么要替换可以看 Replace HTTP+SSE with new "Streamable HTTP" transport GitHub 上的这个 RFC

MCP 三种 Server 的实现

创建一个 server

三种实现方式只是数据传输的方式不同, server 实现是一样的

创建一个简单的 MCP server 导出

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import timezone from 'dayjs/plugin/timezone.js'

dayjs.extend(utc)
dayjs.extend(timezone)

const server = new McpServer({
  name: 'mcp-server-time',
  version: '1.0.0'
})

// 获取当前时间的工具
server.tool(
  'get_current_time',
  '获取当前时间',
  {
    timezone: z.string().optional(),
  },
  async ({ timezone }) => {
    const tz = timezone || process.env.LOCAL_TIMEZONE || 'Asia/Shanghai';
    const currentTime = dayjs().tz(tz).format('YYYY-MM-DD HH:mm:ss');
    return {
      content: [{ type: "text", text: JSON.stringify({ currentTime }, null, 2) }],
    };
  }
)

// 日期时间转换工具
server.tool(
  'convert_time',
  '在时区之间转换日期时间',
  {
    source_timezone: z.string(),
    datetime: z.string().regex(/^\d{4}-\d{1,2}-\d{1,2} ([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, '日期时间格式无效,应为 YYYY-MM-DD HH:mm:ss'),
    target_timezone: z.string(),
  },
  async ({ source_timezone, datetime, target_timezone }) => {
    const sourceTime = dayjs.tz(datetime, source_timezone);
    const convertedTime = sourceTime.clone().tz(target_timezone).format('YYYY-MM-DD HH:mm:ss');
    return {
      content: [{ type: "text", text: JSON.stringify({ convertedTime }, null, 2) }],
    };
  }
)

server.tool(
  'get_text',
  '返回测试文本',
  {},
  async () => {
    const text = '在这个充满变化的时代,每一天都带来了新的机遇与挑战。科技的发展不仅改变了我们的生活方式,也让我们的思维方式不断更新。面对未知,我们或许会感到迷茫,但正是这种探索精神推动着社会不断进步。从传统的理念到现代的创新,每一次转变都蕴含着无限可能。测试文本的存在,正是为了验证系统的生成和处理能力。相信在不断的尝试中,我们能够找到更好的解决方案,为未来的发展铺平道路。'
    return {
      content: [{ type: "text", text: text }],
    };
  }
)

export default server

标准输入和标准输出的通信 STDIO

#!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import server from './mcpServerTest.js'

// 启动服务器
async function runServer() {
  // 使用 StdioServerTransport 标准输入输出
  const transport = new StdioServerTransport()
  await server.connect(transport)
  console.error('获取当前时间和时区转换的 MCP 服务器已在 stdio 上启动')
}

runServer().catch((error) => {
  console.error('启动服务器时出错:', error)
  process.exit(1)
})

测试

执行 npx @modelcontextprotocol/inspector node transports/dist/stdio.js 启动测试页面

image.png

访问 http://127.0.0.1:6274

image.png

注意这里的配置,确认没有问题点击连接即可

iShot_2025-05-14_11.40.50.gif

服务器发送事件 SSE

这是一个简单的 mcp sse 服务的实现,在生产中可能需要继续完善代码比如对跨域、鉴权的处理

import express from "express";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import server from './mcpServerTest.js'
import {addInfoLog, addWarnLog, addSuccessLog} from './utils.js'

const app = express();
app.use(express.json());

// 存储连接
const connections = new Map<string, SSEServerTransport>();

app.get('/sse', async (req, res) => {

  addSuccessLog('客户端连接参数:', req.query)

  // 创建 sse 传输
  const transport = new SSEServerTransport('/messages', res);
  const sessionId = transport.sessionId

  addInfoLog(`新的 SSE 连接建立: ${sessionId}`)  

  // 注册连接
  connections.set(sessionId, transport);
  
  res.on("close", () => {
    connections.delete(sessionId);
    addInfoLog(`SSE 连接关闭: ${sessionId}`)  
  });
  
  // 将传输对象与MCP服务器连接
  await server.connect(transport);
  addSuccessLog(`MCP 服务器连接成功: ${sessionId}`)  
});

// 旧消息端点
app.post('/messages', async (req, res) => {
  const sessionId = req.query.sessionId as string;

  addInfoLog(`收到客户端消息: ${sessionId}`)  
  
  console.log('query',req.query, '\r\n')
  console.log('body',req.body, '\r\n')
  console.log('params',req.params, '\r\n')

  // 获取连接
  const transport = connections.get(sessionId)
  if (transport) {
    await transport.handlePostMessage(req, res, req.body);
  } else {
    addWarnLog(`未找到活跃的 ${sessionId} 连接`) 
    res.status(400).send(`未找到活跃的 ${sessionId} 连接`);
  }
});


// 启动服务器
const port = process.env.PORT || 9001;
app.listen(port, () => {
  addSuccessLog(`MCP SSE 服务器已启动:`, `http://localhost:${port}`)
  addInfoLog('SSE 连接端点:', `http://localhost:${port}/sse`)
  addInfoLog('SSE 消息处理端点:', ` http://localhost:${port}/messages`)

  console.log('=========================== success ===========================\r\n')
});

测试

执行 node dist/sse.js 启动服务

image.png

执行 npx @modelcontextprotocol/inspector http://127.0.0.1 启动测试页面

image.png

访问 http://127.0.0.1:6274

image.png

这里需要选择 sse 和确认 sse 服务的地址及端口,确认没有问题后点击连接即可

iShot_2025-05-14_13.44.16.gif

可流式传输 HTTP

这是一个简单的 Streamable HTTP mcp 服务实现,其实是官方仓库的示例代码 (看起来有点绕🤪) 加了一些注释便于理解

import express from "express";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"
import server from './mcpServerTest.js'
import {addInfoLog, addSuccessLog} from './utils.js'
import pc from 'picocolors'

const app = express();
app.use(express.json());

/**
 * POST 请求:创建新的传输实例并保存到 transports 映射(以 sessionId 为键)。
 * GET 请求(SSE):通过已保存的传输实例推送数据流到客户端。
 * DELETE 请求:通过已保存的传输实例终止会话,断开连接。
 */

// 保存 会话 id 到 传输实例的映射 
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};

// Handle POST requests for client-to-server communication
app.post('/mcp', async (req, res) => {
  // Check for existing session ID
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
  let transport: StreamableHTTPServerTransport;

  if (sessionId && transports[sessionId]) {
    // 如果传输实例已经存在则直接使用
    transport = transports[sessionId];
  } else if (!sessionId && isInitializeRequest(req.body)) {
    // isInitializeRequest 判断 是否是一个合法的 mcp 的请求
    // 创建一个新的传输实例
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (sessionId) => {
        // 通过会话 id 保存传输实例
        transports[sessionId] = transport;
        addInfoLog(`创建传输实例成功 ${sessionId}`)
      }
    });

    // 接收到 DELETE 请求关闭连接
    transport.onclose = () => {
      if (transport.sessionId) {
        delete transports[transport.sessionId];
      }
    };

    await server.connect(transport);
    addSuccessLog(`MCP 服务器连接成功: ${sessionId}`)  
  } else {
    // 无效的请求
    res.status(400).json({
      jsonrpc: '2.0',
      error: {
        code: -32000,
        message: '错误请求:未提供有效的会话 ID',
      },
      id: null,
    });
    return;
  }

  // 处理请求 这里 根据 get、delete 做对应的处理
  // 如果请求是 GET,通常会被解释为服务器推送通知(SSE)。
  // 如果请求是 DELETE,通常会被解释为终止会话(断开连接)。
  await transport.handleRequest(req, res, req.body);
});

// 处理 get 和 delete 请求
async function handleSessionRequest(req: express.Request, res: express.Response) {
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
  if (!sessionId || !transports[sessionId]) {
    res.status(400).send('会话 ID 无效或缺失');
    return;
  }
  
  // 获取传输实例执行对应的的操作
  const transport = transports[sessionId];
  await transport.handleRequest(req, res);
}

// 通过 SSE 处理服务器到客户端通知的 GET 请求
app.get('/mcp', handleSessionRequest);

// 处理会话终止的 DELETE 请求
app.delete('/mcp', handleSessionRequest);

// 启动服务器
const port = process.env.PORT || 9002;
app.listen(port, () => {
  addSuccessLog(`MCP Streamable 服务器已启动: ${pc.green(`http://localhost:${port}`)}`)

  console.log('=========================== success ===========================\r\n')
});

测试

执行 node dist/streamable.js 启动服务

image.png

执行 npx @modelcontextprotocol/inspector http://127.0.0.1 启动测试页面

image.png

访问 http://127.0.0.1:6274

这里需要选择 Streamable HTTP 和确认服务的地址及端口,确认没有问题后点击连接即可

image.png

iShot_2025-05-14_17.24.21.gif

server 同时兼容 SSE 与 Streamable 的写法

把 sse 和 Streamable HTTP 的 server 实现组合起来,没有什么魔法!

MCP 三种 Client 的实现

三种客户端的实现区别不大, MCP SDK 导出了三个文件对应三种 server 的实现

import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";

import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

STDIO

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { addInfoLog, addErrLog, addSuccessLog } from './utils.js'

async function createStdioClient() {
  // 创建 client
  const client = new Client({
    name: 'stdio-client',
    version: '1.0.0',
  });

  try {
    const sseTransport = new StdioClientTransport({
      command: 'node',
      args: ['../transports/dist/stdio.js']
    });

    // 连接到 MCP 服务
    await client.connect(sseTransport);

    addSuccessLog(' MCP 服务 连接成功!')

    // 获取 工具列表
    const toolInfo = await client.listTools()

    addInfoLog('MCP 工具信息', toolInfo)

    // 调用生成文本工具
    const callToolInfo = await client.callTool({
      "name": "get_text",
      "arguments": {}
    })

    addInfoLog('get_text 返回信息', callToolInfo)
  } catch (error) {
    addErrLog('MCP 客户端错误', error);
  }
}

createStdioClient()

image.png

SSE

这里需要先启动 sse serevr node ./dist/sse.js

image.png

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { addInfoLog, addErrLog, addSuccessLog } from './utils.js'

let client: Client | undefined = undefined
const baseUrl = new URL('http://localhost:9001/sse');

async function createSseClient() {
  client = new Client({
    name: 'sse-client',
    version: '1.0.0',
  });

  try {
    const sseTransport = new SSEClientTransport(baseUrl, {
      requestInit: {
        headers: {
          // 这里的参数可以在 messages req.headers 中获取
          'X-Custom-Param': 'custom_value'
        },
      }
    });

    // 连接到 MCP 服务
    await client.connect(sseTransport);

    addSuccessLog(' MCP 服务 连接成功!')

    // 获取 工具列表
    const toolInfo = await client.listTools()

    addInfoLog('MCP 工具信息', toolInfo)

    // 调用生成文本工具
    const callToolInfo = await client.callTool({
      "name": "get_text",
      "arguments": {}
    })

    addInfoLog('get_text 返回信息', callToolInfo)
  } catch (error) {
    addErrLog('MCP 客户端错误', error);
  }

  // 关闭连接
  // client.close()
}

createSseClient()

image.png

Streamable HTTP

实现与 SSE 几乎相同将 SSEClientTransport 替换为 StreamableHTTPClientTransport 即可

如果失败并出现 4xx 错误时尝试使用 SSE 客户端

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

let client: Client | undefined = undefined
const baseUrl = new URL('http://localhost:9001/sse');

async function createStreamableHttpClient() {
  try {
    client = new Client({
      name: 'streamable-http-client',
      version: '1.0.0'
    });

    const transport = new StreamableHTTPClientTransport(baseUrl);
    await client.connect(transport);

    console.log("Connected using Streamable HTTP transport");
  } catch (error) {
    // 如果失败并出现 4xx 错误,请尝试较旧的 SSE 传输 
    
    console.log("Streamable HTTP connection failed, falling back to SSE transport");

    // todo 使用 sse client 与 server 交互
  }
}

createStreamableHttpClient()

参考文章

modelcontextprotocol typescript-sdk

cloudflare / Agents 代理 / Transport 传输

开发 SSE 类型的 MCP 服务

前端周刊第414期(2025年5月12日–5月18日)

2025年5月19日 09:06

image.png

💬 推荐语

这周的前端更新节奏不减,CSS 世界持续花活创新,从 SVG 转形状到滚动驱动动画;React 则再掀讨论热度,RSC 的边界和“Context 渲染过多”的争议也被一一拆解;工具链方面,ESLint 终于开始原生支持 HTML lint,让代码质量保障更进一步。总之,如果你想知道 2025 年前端该怎么玩,这一周的内容,你值得仔细看看。

🧠 博主点评

从这期内容来看,前端生态进入了“工具整合与语义回归”的新阶段。像 html-eslint、Rslib、View Transitions 的持续演化,说明前端正往“表达更清晰、体验更细腻”的方向进化。而 React 圈依旧火热,RSC(React Server Component)与自定义渲染器的深水区讨论,表明框架开发者正在探索架构的下一个层级。

🗂️ 本期精选目录

Web 开发

🔹如何打造新的表单控件:从底层构建一个原生表单控件,完整介绍交互设计与可访问性实现。

🔹从 SplitText 到 MorphSVG:5 个免费的 GSAP 创意动画 Demo:动效爱好者不容错过,展示如何用 GSAP 插件制作高级动画。

🔹GitHub Copilot 入门:用 AI 构建 React 应用:GitHub 官方出品的 Copilot 教程,带你从 0 到 1 搭建 React 应用。

🔹如何用 Lighthouse 优化网页性能:深入解读 Lighthouse 报告并指导优化实践。

工具

🔹ESLint 支持 HTML 代码校验了!:通过 html-eslint 插件,HTML 文件也能纳入 ESLint 检查体系。

🔹Rslib:基于 Rspack 打包构建的库开发方案:来自 Rspack 团队的新工具,用于快速构建 JS/TS 库。

可访问性(Accessibility)

🔹HTML 邮件可访问性年度报告:全面分析 2025 年 HTML 邮件在可访问性方面的表现与改进方向。

🔹结合 Next.js 和 axe DevTools 打造可访问应用:实战指导,帮助开发者更容易集成无障碍功能。

🔹可访问性之道:用现代方式学习 React/Angular/Vue 的无障碍开发:一套系统性学习最佳实践的在线课程资源。

🔹三款工具助你解决常见 WCAG 2.2 可访问性错误:快速修复网页常见可访问性问题的利器。

CSS

🔹容器查询中的“元素外部空间”检测:扩展容器查询的用法边界,考虑“外部空间”布局。

🔹SVG 转 CSS 形状生成器:支持将 SVG 图形转为 clip-path 等 CSS 样式,提升创作效率。

🔹纯 CSS 实现无限滚动画廊:炫酷滚动展示画廊组件,无需 JS 支持。

🔹用 clip-path: shape() 绘制花朵图形:CSS 创意玩法再升级。

🔹百分比高度的谜团:CSS 高度是如何计算的:解析 percentage-based height 的常见误解。

🔹Scroll 驱动动画在 CSS 轮播图中的应用:结合 CSS Scroll Timeline 实现响应式动效。

🔹平滑动画 border-radius:View Transitions 应用探索:借助 View Transitions API 实现更自然的样式过渡。

🔹CSS Masking:为动画增加一维空间感:遮罩动画的高级进阶玩法。

🔹CSS 如何自动选择对比色:基于背景自动生成可读文本颜色,提升用户体验。

🔹初探 View Transitions:前端视觉过渡的实战尝试。

JavaScript & React

🔹我开始喜欢上 Generator 的使用体验了:作者谈为何 Generator 正逐渐变得顺手。

🔹JavaScript 正则表达式终极指南:详解语法、边界情况与调试技巧。

🔹JS 编译提示:是什么、什么时候用?:深入探讨编译优化 hint 与性能提升的关系。

🔹Angular Can I Use:像 CanIUse 一样检查 Angular API 在不同版本的支持情况。

🔹如何在 React 中集成 Rive 动效:带你了解 Rive 在实际项目中如何协作。

🔹React 真酷,你居然不知道?:一篇不拘一格的 React 使用体验文章,风格狂野但不失干货。

🔹RSC 的极限:一位开发者的实践历程:揭示 React Server Components 的优势与局限。

🔹如何构建自定义 React 渲染器:适合探索底层实现的开发者阅读。

🔹React Three Fiber:在 Web 上实现 3D 渲染:深入理解如何用 React 组织 WebGL 渲染。

🔹别再说 Context 导致频繁渲染了:深入分析 React Context 的性能表现与常见误解。

小结

这期内容可以看作是前端的“全生态热身”:从底层 HTML 与 ESLint 扩展,到高级动画、CSS 语义增强、React 渲染机制探索,一方面是性能与体验的持续优化,另一方面是对复杂架构和可访问性的新尝试。值得点赞的是,多个工具和文档都在“为开发者赋能”上下了不少功夫。

OK,以上就是本次分享,欢迎加我威 atar24,备注「前端周刊」,我会邀请你进交流群👇

🚀 每周分享技术干货
🎁 不定期抽奖福利
💬 有问必答,群友互助

10+ 代码案例快速掌握 CSS quotes 属性

作者 云浪
2025年5月19日 08:50

CSS 的 quotes 属性用于设置浏览器应如何渲染引号,这些引号会自动添加到 HTML 的 <q> 标签中,或通过 CSS content 属性的 open-quotes、close-quotes 值(或使用 no-open-quote、no-close-quote 值省略)添加。

浏览器会在 <q> 标签的开头和结尾插入引号,并为 content 属性的 open-quoteclose-quote 值添加引号。每一对开始或结束引号都会根据嵌套深度,从 quotes 属性的值中选择对应的字符串进行替换;如果 quotes 显式设置为 auto 或最终解析为 auto ,则使用的引号取决于语言设置。

open-quoteclose-quote 设置为使用法语引号格式:

quotes: "«" "»";

设置两级引号样式:

quotes: "«" "»" "‹" "›";
/* 
第一级:« »
第二级:‹ ›
*/

none

quotes 属性的值为 none 时表示不添加引号。

即,不会为 <q> 标签添加引号。content 属性值为 open-quoteclose-quote 时,也不会添加引号,就像是 content 属性值为 no-open-quoteno-close-quote 时一样。

<style>
  .test {
    quotes: none;
  }
</style>

<div class="test">
  <q>你是人间四月天</q>
</div>

没有生成引号:

a8.png

<style>
  .test {
    quotes: none;
  }

  .quotes::before {
    content: open-quote;
  }
  .quotes::after {
    content: close-quote;
  }
</style>

<div class="test">
  <span class="quotes">你是人间四月天</span>
</div>

没有生成引号:

a9.png

[<string> <string>]+

quotes 属性的值类型:[<string> <string>]+

定义一组或多组用于开头引号和结尾引号的标记值。每组包含两个值:第一个值作为 open-quote 的标记,第二个值作为 close-quote 的标记。

第一组代表最外层的引号样式。若存在第二组,则代表第一级嵌套引号样式。后续组别依次用于更深层级的嵌套引号。若引号嵌套深度超过定义的组数,则重复使用最后一组标记值。

具体使用哪组引号标记取决于当前引号的深度(即嵌套层级),若深度为 0,则使用第一对引号;若深度为 1,则使用第二对引号,依此类推。

<style>
.test {
  quotes: "«" "»" "[" "]";
}
</style>

<div class="test">
  <!-- 若引号嵌套深度超过定义的组数,则重复使用最后一组标记值 -->
  <q>
    太空研究
    <q>
    正在进入一个新阶段,随着
    <q>
        火箭的负载越来越大,以及发射成本的降低和发射频率的提高,我们现在可以把真正大型
    </q>
    的仪器发射到
    </q>
    太空。
  </q>
</div>

a1.png

<!--
 具体使用哪组引号标记取决于当前引号的深度(即嵌套层级),
 若深度为 0,则使用第一对引号;若深度为 1,则使用第二对引号,依此类推 
-->
<style>
.test {
  quotes: "«" "»" "‹" "›" "[" "]";
}
</style>


<div class="test1">
  <q> <!-- 第 1 级 --><q> <!-- 第 2 级 --><q>人间</q> <!-- 第 3 级 -->
      四月
    </q></q>
</div>

a2.png

auto

CSS 的 quotes 属性的默认值是 auto 。浏览器会根据元素继承的语言(即通过父元素或祖先元素设置的 lang 属性),自动选用符合该语言排版规范的引号样式。

<style>
  q {
    quotes: auto;
  }
  li:not(:last-of-type) {
    border-bottom: 1px solid;
  }
</style>

<ul>
  <!-- 法语的引号 -->
  <li lang="fr">
    <q>Ceci est une citation française.</q>
  </li>
  <!-- 俄语的引号 -->
  <li lang="ru">
    <q>Это русская цитата</q>
  </li>
  <!-- 德语的银行 -->
  <li lang="de">
    <q>Dies ist ein deutsches Zitat</q>
  </li>
  <!-- 英语的引号 -->
  <li lang="en">
    <q>This is an English quote.</q>
  </li>
</ul>

如果 CSS 的 quotes 属性设置的值是非法的,则浏览器会将quotes 属性回退到 auto 来处理

<style>
  .test {
    /* 没有成对,是非法的 quotes 属性值 */
    quotes: "«" "»" "[";
  }
</style>

<div class="test">
  <q>
    太空研究
    <q>
      正在进入一个新阶段,随着
      <q>
        火箭的负载越来越大,以及发射成本的降低和发射频率的提高,我们现在可以把真正大型
      </q>
      的仪器发射到
    </q>
    太空。
  </q>
</div>

对于非法的 quotes 属性值,最终浏览器回退到了默认值,即 auto

a3.png

content 属性生成的内容

除了使用 <q> 标签添加引号外,也可通过为特定类名的元素内容前后添加 ::before::after 伪元素来实现引号的插入。

contentopen-quote 时,内容替换为 quotes 属性中定义的开引号

contentclose-quote 时,内容替换为 quotes 属性中定义的闭引号

<style>
  .quote {
    quotes: '"' '"' "'" "'";
  }
  .quote::before {
    content: open-quote;
  }
  .quote::after {
    content: close-quote;
  }
</style>

<p>
  <span class="quote">I should be using quotes</span>, I thought,
  <span class="quote">But why use semantic HTML elements when I can add classes to
    <span class="quote">ALL THE THINGS!</span>?
  </span>
</p>

a4.png

open-quoteclose-quote 值相对的,有 no-open-quoteno-close-quote

content 值为 no-open-quote 时,隐藏当前位置本应出现的开引号

content 值为 no-close-quote 时,隐藏当前位置本应出现的闭引号

<style>
  .quote {
    quotes: "《" "》" "〈" "〉";
  }
  .quote::before {
    content: open-quote;
  }
  .quote::after {
    content: close-quote;
  }
</style>

<div>
  正常引用
  <span class="quote">
    第一层<span class="quote">第二层</span>
  </span>
</div>

a5.png

隐藏开引号的例子:

<style>
  .quote {
    quotes: "《" "》" "〈" "〉";
  }
  .quote::before {
    content: open-quote;
  }
  .quote::after {
    content: close-quote;
  }
  .quote.no-open::before {
    content: no-open-quote;
  }
</style>

<div>
  正常引用
  <span class="quote no-open">
    第一层<span class="quote">第二层</span>
  </span>
</div>

a6.png

文本引号与空引号的使用

CSS 的 quotes 属性值也可以是非引号字符,比如使用 open-quote 标记说话者的身份,此时由于 open-quote 未设置为正常的开引号,因此,close-quote 通常会保持为空值。

<style>
  [data-speaker="karen" i] {
    quotes: "She said: " "";
  }
  [data-speaker="chad" i] {
    quotes: "He said: " "";
  }
  [data-speaker="pat" i] {
    quotes: "They said: " "";
  }
  [data-speaker] q {
    quotes: auto;
  }
</style>

<ul>
  <li><q data-speaker="karen">Hello</q></li>
  <li><q data-speaker="chad">Hi</q></li>
  <li><q data-speaker="karen">this conversation is not interesting</q></li>
  <li>
    <q data-speaker="pat"
      >OMG! <q>Hi</q>? Seriously? at least <q>hello</q> is five letters long.</q
    >
  </li>
</ul>

a7.png

上面的例子也可以用 content 属性来代替 <q> 标签实现:

<style>
  [data-speaker="karen" i] {
    quotes: "She said: " "";
  }
  [data-speaker="karen" i]::before {
    content: open-quote;
  }
  [data-speaker="karen" i]::after {
    content: close-quote;
  }

  [data-speaker="chad" i] {
    quotes: "He said: " "";
  }
  [data-speaker="chad" i]::before {
    content: open-quote;
  }
  [data-speaker="chad" i]::after {
    content: close-quote;
  }

  [data-speaker="pat" i] {
    quotes: "They said: " "";
  }
  [data-speaker="pat" i]::before {
    content: open-quote;
  }
  [data-speaker="pat" i]::after {
    content: close-quote;
  }

  [data-speaker] span {
    quotes: auto;
  }
  .quotes::before {
    content: open-quote;
  }
  .quotes::after {
    content: close-quote;
  }
</style>

<ul>
  <li><div data-speaker="karen">Hello</div></li>
  <li><div data-speaker="chad">Hi</div></li>
  <li><div data-speaker="karen">this conversation is not interesting</div></li>
  <li>
    <div data-speaker="pat"
      >OMG! 
      <span class="quotes">Hi</span>? Seriously? at least 
      <span class="quotes">hello</span> is five letters long.
    </div>
  </li>
</ul>

总结

CSS 的 quotes 属性在日常的开发中比较少用。

quotes 属性用于设置浏览器应如何渲染引号,这些引号会自动添加到 HTML 的 <q> 标签中,或通过 CSS content 属性的 open-quotesclose-quotes 值添加。

CSS content 属性值设置为 no-open-quoteno-close-quote 省略引号。

参考

quotes

JavaScript篇:for...in vs for...of:遍历JavaScript数据的正确姿势,你踩坑了吗?

2025年5月19日 08:48

        大家好,我是江城开朗的豌豆,一名拥有6年以上前端开发经验的工程师。我精通HTML、CSS、JavaScript等基础前端技术,并深入掌握Vue、React、Uniapp、Flutter等主流框架,能够高效解决各类前端开发问题。在我的技术栈中,除了常见的前端开发技术,我还擅长3D开发,熟练使用Three.js进行3D图形绘制,并在虚拟现实与数字孪生技术上积累了丰富的经验,特别是在虚幻引擎开发方面,有着深入的理解和实践。

        我一直认为技术的不断探索和实践是进步的源泉,近年来,我深入研究大数据算法的应用与发展,尤其在数据可视化和交互体验方面,取得了显著的成果。我也注重与团队的合作,能够有效地推动项目的进展和优化开发流程。现在,我担任全栈工程师,拥有CSDN博客专家认证及阿里云专家博主称号,希望通过分享我的技术心得与经验,帮助更多人提升自己的技术水平,成为更优秀的开发者。

大家好,我是前端开发工程师小杨。今天咱们来聊聊 JavaScript 里两个看似相似但完全不同的循环语句——for...in 和 for...of

你是不是也曾经纠结过:

  • for...in 和 for...of 到底该用哪个?
  • 为什么有时候遍历数组会得到奇怪的结果?
  • 遍历对象时,哪种方式更安全?

别急,看完这篇你就全懂了!


1. 一句话总结区别

  • for...in → 遍历对象的可枚举属性(包括原型链)
  • for...of → 遍历可迭代对象的值(Array、Map、Set等)

简单来说:

  • for...in 适合对象(但要注意原型链问题)
  • for...of 适合数组、字符串等可迭代结构

2. for...in:遍历对象的“家底”

for...in 会遍历对象的所有可枚举属性,包括继承自原型链的属性

🌰 举个栗子:遍历对象

const user = { name: '我', age: 25, role: 'developer' };

for (const key in user) {
  console.log(key, user[key]); 
}
// 输出:
// name 我
// age 25
// role developer

看起来没问题?但如果原型链上有属性呢?

Object.prototype.customProp = '来自原型链';

for (const key in user) {
  console.log(key); 
}
// 输出:
// name
// age
// role
// customProp (意外多了一个属性!)

解决方案:用 hasOwnProperty 过滤

for (const key in user) {
  if (user.hasOwnProperty(key)) { // 只遍历自己的属性
    console.log(key);
  }
}
// 输出:
// name
// age
// role

⚠️ 注意:for...in 不保证顺序!

  • 对象的属性遍历顺序不固定(尤其是数字键时)
  • 如果需要顺序,改用 Object.keys() + for...of

3. for...of:专治可迭代对象

for...of 专门用于遍历可迭代对象(Iterable) ,比如:
✅ Array
✅ String
✅ Map / Set
✅ NodeList(DOM 元素集合)
✅ arguments 对象

🌰 举个栗子:遍历数组

const skills = ['JS', 'CSS', 'React'];

for (const skill of skills) {
  console.log(skill);
}
// 输出:
// JS
// CSS
// React

比 for...in 更安全,不会遍历到奇怪的东西!

🌰 再举个栗子:遍历字符串

const name = '我';

for (const char of name) {
  console.log(char);
}
// 输出:
// 我

🚫 for...of 不能直接遍历普通对象!

const user = { name: '我' };

for (const val of user) { // ❌ 报错!
  console.log(val);
}
// TypeError: user is not iterable

解决方案:用 Object.values() / Object.entries()

for (const val of Object.values(user)) {
  console.log(val); // 输出:我
}

4. 对比总结

特性 for...in for...of
适用对象 普通对象(会遍历原型链) 可迭代对象(Array、Map、Set等)
返回值 键名(key) 值(value)
顺序保证 ❌ 不保证顺序(尤其数字键) ✅ 保证顺序
原型链问题 可能遍历到继承属性(需过滤) 不会遍历原型链
适用场景 遍历对象属性 遍历数组、字符串等

5. 最佳实践

✅ 用 for...in 时:

  • 一定要加 hasOwnProperty 检查,避免原型链污染
  • 不要用于数组(顺序不可控,可能遍历到非数字键)

✅ 用 for...of 时:

  • 优先用于数组、字符串、Map、Set
  • 普通对象先用 Object.keys() / Object.values() 转换

6. 终极选择指南

场景 推荐方式
遍历对象属性 for...in + hasOwnProperty
遍历数组值 for...of
遍历字符串字符 for...of
遍历Map/Set for...of
需要索引的数组遍历 for 循环 或 forEach

7. 总结

  • for...in → 遍历对象属性(小心原型链)
  • for...of → 遍历可迭代对象的值(数组、字符串等)
  • 普通对象想用 for...of  先用 Object.keys() / Object.values() 转换

🚀 现在你彻底搞懂了吧?下次写循环时别再选错了!

你在使用 for...in 或 for...of 时踩过什么坑?欢迎评论区分享!  👇

JavaScript篇:"三次握手和四次挥手:TCP 连接的‘恋爱仪式’全解析!"

2025年5月19日 08:48

        大家好,我是江城开朗的豌豆,一名拥有6年以上前端开发经验的工程师。我精通HTML、CSS、JavaScript等基础前端技术,并深入掌握Vue、React、Uniapp、Flutter等主流框架,能够高效解决各类前端开发问题。在我的技术栈中,除了常见的前端开发技术,我还擅长3D开发,熟练使用Three.js进行3D图形绘制,并在虚拟现实与数字孪生技术上积累了丰富的经验,特别是在虚幻引擎开发方面,有着深入的理解和实践。

        我一直认为技术的不断探索和实践是进步的源泉,近年来,我深入研究大数据算法的应用与发展,尤其在数据可视化和交互体验方面,取得了显著的成果。我也注重与团队的合作,能够有效地推动项目的进展和优化开发流程。现在,我担任全栈工程师,拥有CSDN博客专家认证及阿里云专家博主称号,希望通过分享我的技术心得与经验,帮助更多人提升自己的技术水平,成为更优秀的开发者。

大家好,我是前端开发工程师小杨。今天咱们来聊聊 TCP 连接的建立和断开,也就是传说中的三次握手(Three-way Handshake)四次挥手(Four-way Handshake)

你可能觉得这是后端的事情,但前端也得懂点网络知识,尤其是调试接口慢、连接异常时,知道底层原理才能更快定位问题!


1. TCP 连接:像谈恋爱一样严谨

TCP(传输控制协议)是一种可靠的传输协议,就像两个人谈恋爱,得先确认彼此心意(三次握手),分手时也要好好告别(四次挥手)。

🌰 举个栗子:我和服务器谈恋爱

  • 三次握手:我和服务器互相确认,建立稳定连接
  • 四次挥手:我和服务器和平分手,确保数据不丢失

2. 三次握手:TCP 的“确认眼神”

三次握手的目标是确保客户端和服务器都能正常收发数据

流程:

  1. 第一次握手(SYN)

    •  → 服务器:SYN=1, seq=x(“嗨,能听到我吗?”)
    • 进入 SYN_SENT 状态
  2. 第二次握手(SYN+ACK)

    • 服务器 → 我:SYN=1, ACK=1, seq=y, ack=x+1(“听到了!你也能听到我吗?”)
    • 服务器进入 SYN_RCVD 状态
  3. 第三次握手(ACK)

    •  → 服务器:ACK=1, seq=x+1, ack=y+1(“听到了!咱们开始聊天吧!”)
    • 双方进入 ESTABLISHED 状态,连接建立!

为什么是三次?两次不行吗?

  • 两次握手:服务器无法确认我是否能收到它的回复(可能我早就断开了)
  • 三次握手:确保双方都能正常收发数据,避免资源浪费

3. 四次挥手:TCP 的“和平分手”

TCP 连接断开时,需要确保数据全部传输完毕,不能突然“拉黑”。

流程:

  1. 第一次挥手(FIN)

    •  → 服务器:FIN=1, seq=u(“我要走了,拜拜!”)
    • 进入 FIN_WAIT_1 状态
  2. 第二次挥手(ACK)

    • 服务器 → 我:ACK=1, ack=u+1(“好的,等我处理完最后的数据”)
    • 服务器进入 CLOSE_WAIT 状态
    • 进入 FIN_WAIT_2 状态
  3. 第三次挥手(FIN)

    • 服务器 → 我:FIN=1, ACK=1, seq=v, ack=u+1(“我也准备好了,再见!”)
    • 服务器进入 LAST_ACK 状态
  4. 第四次挥手(ACK)

    •  → 服务器:ACK=1, ack=v+1(“好的,正式断开!”)
    • 进入 TIME_WAIT 状态(等待 2MSL 后彻底关闭)

为什么是四次?三次不行吗?

  • 服务器可能还有数据要发送,不能直接断开
  • 四次挥手确保双方都完成数据收发,避免数据丢失

4. 常见面试题

Q1:为什么 TIME_WAIT 要等 2MSL?

  • MSL(Maximum Segment Lifetime)  是数据包在网络中的最大存活时间

  • 等待 2MSL 是为了:

    • 确保最后一个 ACK 到达服务器(如果丢失,服务器会重发 FIN)
    • 让旧连接的数据包彻底消失,避免影响新连接

Q2:握手能变成两次吗?挥手能变成三次吗?

  • 握手不能两次(无法确认客户端能收到服务器的回复)
  • 挥手有时能三次(如果服务器没有数据要发,FIN 和 ACK 可以合并)

5. 总结

  • 三次握手:建立连接,确保双方都能通信
  • 四次挥手:断开连接,确保数据不丢失
  • TIME_WAIT:防止旧连接干扰新连接

🚀 前端也要懂点网络知识!  下次遇到接口慢、连接异常时,想想是不是 TCP 的“恋爱仪式”出了问题~

你有遇到过 TCP 相关的问题吗?欢迎评论区交流!  👇

栗子前端技术周刊第 81 期 - Parcel v2.15.0、Rslib、React Router 7.6...

2025年5月19日 08:38

🌰栗子前端技术周刊第 81 期 (2025.05.12 - 2025.05.18):浏览前端一周最新消息,学习国内外优秀文章视频,让我们保持对前端的好奇心。

📰 技术资讯

  1. Parcel v2.15.0:Parcel v2.15.0 版本带来两项重大更新:采用 Rust 重构 HTML/SVG 转换器与压缩器;依赖项精简 25%,node_modules 体积缩减 45%。

  2. Rslib:Rslib 由字节跳动 Web Infra 团队开发,能够帮助开发者以简单直观的方式创建 JavaScript 库和 UI 组件库,并享受 Rspack 和 Rsbuild 带来的极致开发体验。

  3. React Router 7.6:React Router 7.6 发布,更新内容包括:新增 routeDiscovery 配置项,支持更灵活地控制“懒路由发现”功能;类型系统智能升级,实现未来特性标志(future flags)开启下的自动类型推导等等。

📒 技术文章

  1. Categorize Your Dependencies:分类管理项目依赖项 - Anthony Fu 分享如何运用命名目录对依赖项进行分类。

  2. Best Practices for Creating a Modern npm Package:现代 npm 包开发最佳实践指南 - 这是一份与时俱进的分步教程,完整演示如何运用当下最新规范创建 npm 包。

  3. 编程常用的 MCP Server,用自然语言写代码:本文介绍了编程常用的 MCP Server,可借助 LLM + MCP 用自然语言完成编程相关操作。常用 MCP Server 包括 fileSystem MCP server、fetch MCP server、github MCP server 等等。

🔧 开发工具

  1. jsdiff 8.0:JavaScript 文本差异比对库。
image-20250517220447336
  1. Flatlogic Templates:提供免费的网页与移动端模板。
image-20250517223500154
  1. tscircuit:用 React 开发电子电路项目。

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

撸一个小程序运行容器

2025年5月19日 08:00

作者:蔡钧

一、背景

现在几大热门的APP都能运行小程序,每个APP都有自己的小程序开发工具(有谁懂。。。。。。),每个APP的小程序都有自己的“语法”,虽然有细微的差别但大差不差,那如果我希望自己写的APP也能拥有小程序运行能力的话应该怎么做呢?

叠甲:本文实现思路参考smallapp,在其基础上解决了一些bug和删减,能跑起一个demo,主要讲解实现思路,中间还有很多可以优化和拓展的东西就不展开了。

再叠:微信有自己的WMPF,它更偏向应用型,再拆一层就是我们要做的东西。

二、目标

构建一个 "可插拔" 的小程序容器,支持多平台运行(Web/App/Desktop),并且展示效果一致。

三、架构设计

容器层需要关注的核心

  • 数据-视图,正常渲染
  • 基础组件
  • 事件处理
  • 生命周期

四、容器层实现

小程序转换层

我们把webview作为运行容器的宿主,把小程序语法转换成html即可

画板

css

通过postcss和postcss-import打包成一个css文件

export class WxssFileNode extends FileNode {
  constructor(path, type, name, tag) {
    super(path, type, name, tag);
    this.type = "wxss";
  }
  async transform(input) {
    this.ast = input;
    const that = this;
    const res = await postcss([
      require("postcss-import")({
        resolve(id) {
          const url = resolve(that.path, "../", id);
          return url;
        },
      }),
    ]).process(input, {
      from: that.path,
      to: that.path,
    });
    this.code = res.css;
  }
}

js

通过esbuild把js文件打包成一个bundle

export class JsFileNode extends FileNode {
  constructor(path, type, name, tag) {
    super(path, type, name, tag);
    this.type = "js";
  }

  async transform() {
    const out = await build({
      entryPoints: [this.path],
      bundle: true,
      format: "esm",
      sourcemap: false,
      write: false,
      outdir: "out",
    });

    this.code = String.fromCharCode.apply(null, out.outputFiles[0].contents);
  }
}

wxml

逐行处理wxml内容

// 0. 原始状态
<view class="wrap">{{arr.length}}</view>
<view wx:for="{{arr}}">
    <text>{{item.a}}:{{item.b}}:{{index}}</text>
</view>
<view wx:if="{{arr.length > 5}}">show</view>
<view wx:elseif="{{arr.length > 2}}">show2</view>
<view wx:else>hidden</view>
<button bind:tap="add">add</button>

// 1. 状态处理后的WXML
<view class="wrap">{{state.arr.length}}</view>
<view wx:for="{{state.arr}}">
    <text>{{item.a}}:{{item.b}}:{{index}}</text>
</view>
<view wx:if="{{state.arr.length > 5}}">show</view>
<view wx:elseif="{{state.arr.length > 2}}">show2</view>
<view wx:else>hidden</view>
<button bind:tap="add">add</button>

// 2. 词法分析结果(tokens)
[
  { type: 'tag', value: 'view', attributes: { class: 'wrap' } },
  { type: 'text', value: '{{state.arr.length}}' },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  {
    type: 'tag',
    value: 'view',
    attributes: { 'wx:for': '{{state.arr}}' }
  },
  { type: 'tag', value: 'text', attributes: {} },
  { type: 'text', value: '{{item.a}}:{{item.b}}:{{index}}' },
  { type: 'tag', value: 'text', attributes: {}, closeStart: true },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  {
    type: 'tag',
    value: 'view',
    attributes: { 'wx:if': '{{state.arr.length > 5}}' }
  },
  { type: 'text', value: 'show' },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  {
    type: 'tag',
    value: 'view',
    attributes: { 'wx:elseif': '{{state.arr.length > 2}}' }
  },
  { type: 'text', value: 'show2' },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  { type: 'tag', value: 'view', attributes: { 'wx:else': '' } },
  { type: 'text', value: 'hidden' },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  { type: 'tag', value: 'button', attributes: { 'bind:tap': 'add' } },
  { type: 'text', value: 'add' },
  { type: 'tag', value: 'button', attributes: {}, closeStart: true }
]

// 3. 语法分析结果(AST)
{
  children: [
    {
      type: 'node',
      name: 'view',
      attributes: [Object],
      children: [Array]
    },
    ...
  ],
}

// 4. 生成jsx
{
  '0': '<comp.View class={`wrap`}>{String(state.arr.length)}</comp.View>',
  '1': '{$for(state.arr,(item, index) => (<comp.View ><comp.Text >{String(item.a)}:{String(item.b)}:{String(index)}</comp.Text></comp.View>))}',
  '2': '{state.arr.length > 5?<comp.View >show</comp.View>:state.arr.length > 2?<comp.View >show2</comp.View>:<comp.View >hidden</comp.View>}',
  '3': '<comp.Button  onClick={$handleEvent("add", "2", "bind:tap")} >add</comp.Button>'
}

举几个有意思的方法

在生成过程jsx中

把wx:开头的方法放进directives中

把bind方法都转换成on"Event"事件并绑定在$handleEvent触发下

bind:tap="add"

转换为

onClick={$handleEvent("add", pageid, "bind:tap")}

const generateProps = (node, state, asset) => {
  let code = "";
  for (let name in node.attributes) {
    const value = node.attributes[name];
    if (name.startsWith("wx:")) {
      node.directives = node.directives || [];
      node.directives.push([name, value]);
    } else if (name.startsWith("bind")) {
      if (state.methods.indexOf(value) < 0) {
        state.methods.push(value);
      }
      const key = wriedName(name);
      code += ` ${key}={$handleEvent("${value}", "${getId(
        asset
      )}", "${name}")} `;
    } else if (node.name === "import") {
      state.imports.push(value);
    } else {
      let compiled = compileExpression(value, node.type);
      code += `${name}=${compiled || "true"}`;
    }
  }
  return code + ">";
};

在生成direct的时候,我们把wx:for通过自定义方法$for包裹起来,把wx:if用三元的表达式转换

<view wx:for="{{arr}}">
    <text>{{item.a}}:{{item.b}}:{{index}}</text>
</view>

转换为

{$for(state.arr,(item, index) => (<comp.View><comp.Text>{String(item.a)}:{String(item.b)}:{String(index)}</comp.Text></comp.View>))}
<view wx:if="{{arr.length > 5}}">show</view>
    <view wx:elseif="{{arr.length > 2}}">show2</view>
<view wx:else>hidden</view>

转换为

{state.arr.length > 5?<comp.View >show</comp.View>:state.arr.length > 2?<comp.View show2</comp.View>:<comp.View >hidden</comp.View>}
const generateDirect = (node, code, next) => {
  for (let i = 0; i < node.directives.length; i++) {
    const [name, value] = node.directives[i];
    const compiled = compileExpression(value, "direct");
    if (code[0] === "{") {
      code = `<div>${code}</div>`;
    }
    if (name === "wx:for") {
      const item = findItem(node);
      code = `{$for(${compiled},(${item}, index) => (${code}))}`;
    }
    if (name === "wx:if") {
      ifcode += `{${compiled}?${code}:`;
      if (isElse(next)) {
        continue;
      } else {
        code = ifcode + "null}";
        ifcode = "";
      }
    }
    if (name === "wx:elseif") {
      ifcode += `${compiled}?${code}:`;
      if (isElse(next)) {
        continue;
      } else {
        code = ifcode + "null}";
        ifcode = "";
      }
    }
    if (name === "wx:else") {
      if (ifcode === "") {
        ifcode += `{!${compiled}?${code}:null}`;
      } else {
        ifcode += `${code}}`;
      }
      code = ifcode;
      ifcode = "";
    }
    return code;
  }
};

到这里我们已经可以转换好了每个页面对应的css,js和jsx,但wxml转换后的代码还需要被React包裹成组件才能执行,包裹一下就能得到完整的jsx文件代码。

export const packWxml = (fileNode) => {
  const code = `export default (props) => {
  const [state, setState] = React.useState(props.data)
  React.useEffect(()=>{
    setStates[${fileNode.parent.id}] = setState
  },[]);
  return <>${fileNode.out}</>
};\n`;
  return code;
};

app包

最终我们打包编译并生成一个app.js文件作为完整的构建完成文件

window.manifest = {
  origin: {
    pages: ["pages/page1/index", "pages/page2/index"],
    tabBar: {
      color: "#7A7E83",
      selectedColor: "#3cc51f",
      borderStyle: "rgb(200,200,200)",
      backgroundColor: "#ffffff",
      list: [
        {
          iconPath: "/public/icon_API.png",
          selectedIconPath: "/public/icon_API_HL.png",
          pagePath: "pages/page1/index",
          text: "组件",
        },
        {
          iconPath: "/public/icon_API.png",
          selectedIconPath: "/public/icon_API_HL.png",
          pagePath: "pages/page2/index",
          text: "组件2",
        },
      ],
    },
    window: {
      backgroundTextStyle: "light",
      navigationBarBackgroundColor: "#fff",
      navigationBarTitleText: "WeChat",
      navigationBarTextStyle: "black",
    },
  },
  pages: [
    {
      id: 2,
      info: { usingComponents: {} },
      scripts: [
        '// example/pages/page1/test.js\nvar test = () => {\n  console.log("test");\n};\n\n// example/pages/page1/index.js\nPage({\n  data: {\n    arr: [\n      {\n        a: 0,\n        b: 0\n      }\n    ]\n  },\n  onLoad(options) {\n    console.log(1, options);\n  },\n  add() {\n    this.setData({\n      arr: this.data.arr.concat([\n        {\n          a: this.data.arr.length,\n          b: this.data.arr.length * 2\n        }\n      ])\n    });\n  },\n  test() {\n    test();\n  }\n});\n\n',
        'var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __getOwnPropNames = Object.getOwnPropertyNames;\nvar __hasOwnProp = Object.prototype.hasOwnProperty;\nvar __export = (target, all) => {\n  for (var name in all)\n    __defProp(target, name, { get: all[name], enumerable: true });\n};\nvar __copyProps = (to, from, except, desc) => {\n  if (from && typeof from === "object" || typeof from === "function") {\n    for (let key of __getOwnPropNames(from))\n      if (!__hasOwnProp.call(to, key) && key !== except)\n        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n  }\n  return to;\n};\nvar __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);\nvar stdin_exports = {};\n__export(stdin_exports, {\n  default: () => stdin_default\n});\nmodule.exports = __toCommonJS(stdin_exports);\nvar stdin_default = (props) => {\n  const [state, setState] = React.useState(props.data);\n  React.useEffect(() => {\n    setStates[2] = setState;\n  }, []);\n  return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(comp.View, { class: `wrap` }, String(state.arr.length)), $for(state.arr, (item, index) => /* @__PURE__ */ React.createElement(comp.View, null, /* @__PURE__ */ React.createElement(comp.Text, null, String(item.a), ":", String(item.b), ":", String(index)))), state.arr.length > 5 ? /* @__PURE__ */ React.createElement(comp.View, null, "show") : state.arr.length > 2 ? /* @__PURE__ */ React.createElement(comp.View, null, "show2") : /* @__PURE__ */ React.createElement(comp.View, null, "hidden"), /* @__PURE__ */ React.createElement(comp.Button, { onClick: $handleEvent("add", "2", "bind:tap") }, "add"));\n};\n',
      ],
      styles: ["/2.css"],
      path: "/pages/page1/index",
    },
    {
      id: 3,
      info: { usingComponents: {} },
      scripts: [
        '// example/pages/page2/index.js\nPage({\n  data: {\n    num: 0\n  },\n  async getBatteryInfo() {\n    const res = await wx.getBatteryInfo();\n    console.log(res);\n    this.setData({\n      num: res\n    });\n  },\n  onLoad() {\n    console.log("onload");\n  },\n  onShow() {\n    console.log("onshow");\n  }\n});\n\n',
        'var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __getOwnPropNames = Object.getOwnPropertyNames;\nvar __hasOwnProp = Object.prototype.hasOwnProperty;\nvar __export = (target, all) => {\n  for (var name in all)\n    __defProp(target, name, { get: all[name], enumerable: true });\n};\nvar __copyProps = (to, from, except, desc) => {\n  if (from && typeof from === "object" || typeof from === "function") {\n    for (let key of __getOwnPropNames(from))\n      if (!__hasOwnProp.call(to, key) && key !== except)\n        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n  }\n  return to;\n};\nvar __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);\nvar stdin_exports = {};\n__export(stdin_exports, {\n  default: () => stdin_default\n});\nmodule.exports = __toCommonJS(stdin_exports);\nvar stdin_default = (props) => {\n  const [state, setState] = React.useState(props.data);\n  React.useEffect(() => {\n    setStates[3] = setState;\n  }, []);\n  return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(comp.Button, { onClick: $handleEvent("getBatteryInfo", "3", "bind:tap") }, "get"), /* @__PURE__ */ React.createElement(comp.Text, null, "\\u5F53\\u524D\\u8BBE\\u5907\\u7535\\u91CF\\uFF1A", String(state.num)));\n};\n',
      ],
      styles: ["/3.css"],
      path: "/pages/page2/index",
    },
  ],
};

运行层

代码已经生成了,想要把这个页面运行起来还需要完善前面埋下的一些全局变量以及Page方法

Page

将Page中的参数全都丢进自定义实体中,实现this.setData并实际调用对应组件的setState

const pages = manifest.pages;
const pageGraph = {};

var global = {
  modules: {},
  Page,
  $for,
  $handleEvent,
  useEffect: React.useEffect,
  setStates: {},
};

if (path === "/") {
  window.location.href = window.location.origin + pages[0].path;
} else {
  p = pages.find((i) => i.path === path);
}

const { scripts, styles, id } = p;

execScript(scripts[1], global); //new Function -> call 将编译后的组件挂在到global.modules下
execScript(scripts[0], global); //new Function -> call 将页面的方法挂在到pageGraph下

execStyles(styles); // 添加css的link

var Page = (option) => {
  pageGraph[p.id] = new _Page(option, p.id);
};

var _Page = class {
  constructor(option, id) {
    this.id = id;
    this.parent = null;
    this.eventMap = {};
    for (const key in option) {
      this[key] = option[key];
    }
  }
  setData(data) {
    this.data = { ...this.data, ...data };
    const setState = global.setStates[this.id];
    setState(this.data);
  }
};

comp

全局自定义组件,想要实现原生的组件就可以在这里编写

// components.js
import Button from "./Button";

var comp = {
  Button,
  View: "div",
  Text: "span",
};

window.comp = comp;


// Button.jsx
import "./index.less";
export default (props) => {
  const { onClick, children } = props;
  return (
    <button className="wx-button" onClick={onClick}>
      {children}
    </button>
  );
};

$for

遍历dom

function $for(arr, fn, key) {
  arr = arr || [];
  return arr.map((item, index) => {
    const vdom = fn(item, index);
    vdom.key = key || index;
    return vdom;
  });
}

$handleEvent

call触发

function $handleEvent(name, id, custom) {
  const ins = pageGraph[id];
  const method = ins[name] || (ins.methods || {})[name] || function () {};
  ins.eventMap[custom] = name;
  return (e) => {
    method.call(ins, e);
  };
}

渲染

执行过scripts[1]后拿到export出来的页面组件Comp并将其渲染

const Comp = global.modules[scripts[1]].default;

ReactDOM.render(
  React.createElement(wrap, {
    page: pageGraph[id],
    tabBar,
    path,
    manifest,
    Comp,
  }),
  document.body
);

// wrap.jsx
export const wrap = (props) => {
  const { page, manifest, tabBar, Comp, path } = props;

  const [show, setShow] = React.useState(false);

  React.useEffect(() => {
    page.onLoad && page.onLoad();
    return () => {
      page.onUnload && page.onUnload();
    };
  }, []);
  
  return (
    <>
      <Comp data={page.data} />
      {show && (
        <div
          style={{
            position: "fixed",
            display: "flex",
            justifyContent: "center",
            alignItems: "center",
            top: 0,
            left: 0,
            right: 0,
            padding: "8px 0 20px",
            height: "100vh",
            width: "100vw",
            fontSize: "20px",
            background: "rgba(0, 0, 0, 0.5)",
            color: "#fff",
          }}
        >
          <span>编译中</span>
        </div>
      )}
      {!manifest.origin.tabBar.custom && (
        <div
          style={{
            position: "fixed",
            display: "flex",
            bottom: 0,
            left: 0,
            right: 0,
            padding: "8px 0 20px",
            fontSize: "10px",
            backgroundColor: tabBar.backgroundColor,
            borderTop: `1px solid ${tabBar.borderStyle}`,
            color: tabBar.color,
          }}
        >
          {tabBar.list.map((item) => {
            const isSelect = "/" + item.pagePath === path;
            return (
              <div
                key={item.pagePath}
                style={{
                  display: "block",
                  width: "100%",
                  textAlign: "center",
                }}
                onClick={() => {
                  if (isSelect) return;
                  location.href = "/" + item.pagePath;
                }}
              >
                <img
                  src={isSelect ? item.selectedIconPath : item.iconPath}
                  style={{ width: "30px", height: "30px" }}
                />
                <div
                  style={{
                    color: isSelect ? tabBar.selectedColor : tabBar.color,
                  }}
                >
                  {item.text}
                </div>
              </div>
            );
          })}
        </div>
      )}
    </>
  );
}

node层

页面

我使用的是多页的方式实现,在node层启动了一个express并且注册了这些页面

export const server = (options) => {
  const express = require("express");

  const distdir = resolve(options.o);
  const appEntry = resolve(options.e, "app.json");
  const appJson = require(appEntry);

  const app = express()
    .use(express.static(distdir))
    .get("/", (_, res) => {
      getIndexHtmlCode().then((data) => {
        res.end(data);
      });
    });

  appJson.pages.forEach((page) => {
    app.get("/" + page, (_, res) => {
      getIndexHtmlCode().then((data) => {
        res.end(data);
      });
    });
  });

  
  app.listen(port, (err) => {
    if (err) throw err;
    console.log(`start:http://localhost:${port}`);
  });

  return app;
};


export async function getIndexHtmlCode() {
  return `<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>miniapp</title>
    <style>
    *{
        margin: 0;
        padding: 0;
    }
    </style>
    <link rel="stylesheet" href="/runtime.css">
</head>
<body>
    <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
    <script src="/app.js"></script>
    <script src="/runtime.js"></script>
</body>
<script>
var wx = {}
if (window.JSBridge) {
  console.log("app env");
} else {
  console.log("brower env");
}
</script>
</html>`;
}

更新重新编译

node层&前端引入socket.io,监听入口文件

if (options.w) {
  chokidar
    .watch(resolve(options.e), {
      persistent: true,
      awaitWriteFinish: {
        stabilityThreshold: 500,
        pollInterval: 500,
      },
    })
    .on("change", async () => {
      ser.reloadStart?.();
      await rebuild(options);
      ser.reloadEnd?.();
    });
}
const http = require("http").createServer(app);
const io = require("socket.io")(http);
http.listen(8109, () => {
  console.log("socket.io listening on *:8109");
});
io.on("connection", (socket) => {
  socket.on("disconnect", () => {});
});

app.reloadEnd = () => {
  io.emit("reload-end");
};

app.reloadStart = () => {
  io.emit("reload-start");
};
const socketUrl = window.location.origin.replace(/:\d+/, ":8109");
const io = socket(socketUrl, {
  transports: ["websocket"],
  autoConnect: false,
  reconnection: false,
  forceNew: true,
  multiplex: false,
});

io.on("reload-start", () => {
  setShow(true);
});

io.on("reload-end", () => {
  setShow(false);
  window.location.reload();
});

五、应用层接入

在index.html里面我们判断是否含有JSBridge来判断宿主环境是app还是浏览器,所以只要在应用层接入时往webview的wx挂载一个变量并实现wx API即可

tauri实现

上层用tauri然后实现方法wx.getBatteryInfo

省事改下index.html的代码

<script>
var wx = {}
if (window.__TAURI_INTERNALS__) {
  const { invoke } = window.__TAURI_INTERNALS__;
  wx.getBatteryInfo = async () => {
    const res = await invoke('getBatteryInfo')
    return res;
  };
}
</script>
use battery::{units::ratio::percent, Manager, State};

#[tauri::command]
fn getBatteryInfo() -> u64 {
    let manager = Manager::new().unwrap();
    let batteries = manager.batteries().unwrap();
    let mut p = 0.0;

    for battery in batteries {
        let battery = battery.unwrap();
        p = battery.state_of_charge().value * 100.0;
        println!("设备电量: {:.1}%", battery.state_of_charge().value * 100.0);
        println!("电池状态: {:?}", battery.state());
    }

    return p as u64;
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![getBatteryInfo])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

六、demo运行

微信开发者工具效果

七、总结

把这个容器的实现类比成乐高积木

  1. 拆解(编译转换)
    • 把wxml拆成ast
  2. 组装说明(运行时规则)
    • ast组装成jsx
    • 确定规则(数据更新/事件触发)
  3. 随处可玩(多平台接入)
    • 多平台可接入运行

还能怎么玩?

  • 安装电机(用编译型语言替换node实现/wasm加速)
  • 安装熊孩子保护罩(沙箱防护)
  • 拼接其他品牌的积木(从wxml -> ast -> jsx 提供直接从 ast ->jsx 的方法)

剖析RAG之父所说的:数据处理才是RAG系统的护城河!

作者 华洛
2025年5月19日 07:56

RAG之父Douwe Kiela在一场演讲中,提到了这样的论点:内部知识是我们的竞争壁垒,数据处理是我们的护城河

"大规模处理企业数据的能力才是护城河,重点要放在让AI有效处理大规模、多元和含噪数据上,而非过度清洗数据。" Kiela格外强调了这句话。

这一点与我这几年的实战经验非常契合。

这是很多AI产品失败的原因之一:数据准备过度理想化

很多产研团队觉得他们在测试阶段测试数据的准确率等于生产环境的准确率!

这是一个很荒谬的误区,我们的测试数据通常都是标准的理想化的数据占比更多,杂乱的、喊噪音的数据占比更少,但是生成环境其实正相反:杂乱的、喊噪音的数据占比更多,反而标准的数据占比更少

就行Kiela在演讲中的案例:某银行的AI诈骗检测系统在试点阶段准确率达95%,但部署到生产环境后跌至62%

真实世界数据总是混乱的,能处理不完美数据的系统才是王道。构建处理真实世界数据的能力,比幻想完美数据集更实际。

RAG工程解决方案

下面给大家看一下我们是如何增强我们RAG系统的数据处理能力来解决的一些标准RAG无法解决的问题:

只能进行文本回复,无法提供相关的图片、视频等能力。

RAG系统想要加入图片、视频等能力,有两种方案可以解决:元数据描述

第一种方案:元数据是应用在结构化数据中,在结构化数据中添加有meta字段,meta字段代表着与我们的这条数据有关的其他数据。

当用户的query在知识库检索时检索到了我们这条数据,我们就可以根据meta字段进行相应的图片\视频等内容的输出。

例如,在售前场景中。我们有一批专利图片,我们要利用元数据的方案就要在我们的excel中加入图片地址信息:

meta.jpg

当用户询问专利相关问题时,我们的程序会获取到meta字段,还记得方才我们说的那个知识库响应内容content字段么?

meta字段就在里面,我们的程序拿到后就可以进行处理,返回给前端显示对应的专利图片。

第二种方案:描述是应用在非结构化的数据中,通过自然语言对图片\视频进行描述,然后把描述文案向量化之后添加到向量数据库中。

这样当用户的query和我们向量数据库中的内容进行匹配时,就可以匹配到一个图片信息,然后我们的大模型可以根据描述介绍图片,我们的程序可以根据信息返回图片。

还是刚才那个例子我们有一批专利图片,如果是使用描述的方案:

我们可以进行专利的描述,然后把描述文案加入到向量数据库中:

  • 企业申请的XXX专利。
  • 企业获取的XXX授权。
  • 企业的国际PCT专利。

当用户询问专利相关问题时,知识库就会检索到相关的内容,我们就可以进行图片化的回复了。

如果用户query不标准,问题不全,我们的知识库可能匹配不到内容。

这个问题的出现是因为:RAG系统本身是不支持上下文的:

例如,我们先问:你们企业有哪些授权? AI回复之后,我们又问:专利呢?

这时需要进入RAG系统参与匹配的应该是你们企业有哪些专业?而不是专利呢?

所以这时候就需要上下文分析的能力来分析上下文得到用户的真实问题你们企业有哪些专业?

我们的解决方案是使用提示词来进行上下文分析 + query改写

上下文分析提示词如下:

## 业务知识

【这里根据自己的业务场景,添加必须让大模型知道的业务知识,例如对某些名词的解释】

## 要求

- 根据user的上下文对话,分析出user本次对话的真实意图。
- 必要的知识放在【业务知识】中,查询业务知识的信息与user对齐概念。
- 把user最终的真实意图转化成与上下文文风一致的问题后直接输出,不要输出分析过程
- 输出格式为{user:真实意图}

## 上下文

question:【上一次分析的结果query】
answer:【回复内容】
question:【用户本次query】

## 输出

上下文分析 提示词用在解决使用RAG系统时,问题需要有上下文关联性的场景,可以帮助我们获得完整的用户意图。

query改写提示词如下:

## 业务知识

【这里根据自己的业务场景,添加必须让大模型知道的业务知识,例如对某些名词的解释】

## 要求

- 对问题进行一次改写,改写为一条新的问题。
- 需要保持语义一致性,核心意图不变,允许根据上下文扩展关联信息。
- 如果只有一个名词,用户的默认意图是需要解释。
- 用词保持简单,保证新问题是一句话,没有过多的冗余内容。
- 改写标准为主语 + 谓语 + 宾语的标准语法,不要使用倒装句等其他语法。
- 输出格式为{user:新问题}

## 上下文

question:【上一次分析的结果query】
answer:【回复内容】

## 问题

【用户本次query】

## 输出

query改写 提示词用在我们为了提高准确性,会通过改写query,对RAG系统进行多次匹配的场景。可以帮助我们把用户的非标准问题改写成更容易匹配我们的知识库的问题。

比如这个提示词,我们的知识库中全都是主谓宾组成的标准语法,那么我们就更希望把用户的问题也全部改成主谓宾的标准语法。

知识库中的内容仍然存在匹配错误的情况。例如:用户问A产品的价钱,我们知识库筛选出了B产品的价钱,然后回复给了用户。

这个问题的出现是因为:RAG系统是根据语义进行匹配的,虽然现在大家都是用混合检索了,但是在匹配的过程中还是会出现匹配错误的情况

我们当然是不允许把错误的信息返回给用户的。

所以在匹配到答案之后,需要再验证一次这个答案是否解决了用户的问题。

例如:用户问A会议的开始时间,我们拿到了B会议的信息,其中也有开始时间。如果不做相关性验证,就对用户造成了误导。

提示词如下:

## 业务知识
【这里根据自己的业务场景,添加必须让大模型知道的业务知识,例如对某些名词的解释】

## 资料
【知识库匹配出来的答案】

## 问题
【用户的query】

## 要求

- 必要的知识放在【业务知识】中,查询业务知识的信息与user对齐概念。
- 判断资料是否能够有效的回复user的问题。
- 如果资料是有效的,返回'''Y''',否则返回'''N''',不要输出任何其他内容。

## 输出

当相关性验证没有通过时,我们还可以调用query改写,再进行重试。或者把知识库匹配到的问题直接以相似问的形式返会给用户。

  • query改写的逻辑是把query的语法、用词等修改成和我们的知识库中存的数据相似性高一些的新query,以此来增加匹配度。
  • 相似问的逻辑是我们把问题抛回给用户,让用户自己进行选择他想问的问题或者重新提问。

经典的中文二义性问题。用户的问题可以用A来回答,也可以用B来回答,怎么办?

中文是具有的二义性问题的:

  • 能吃多少吃多少?,是多吃点还是少吃点?
  • 咬死了猎人的狗。,是要死了猎人的狗,还是猎人的狗被咬死了?

这些二义性问题还可以根据上下文来大概进行判断,但是还有一些二义性问题,就无法利用上下文了,例如:

Q1:清华大学怎么样? Q2:计算机专业怎么样?

用户是问计算机专业怎么样?还是清华大学的计算机专业怎么样?,这种问题结合上下文和不结合上下文是完全两个问题。

对于这种场景的问题,给大家分享一个我的解决方案,两个问题答案我们都回复给用户,利用结构化的表达方式,无论用户的真实意图是哪一个,都可以感受到被回答了。

回复示例:

【内容一:计算机专业介绍,回复计算机专业怎么样】
上下文衔接内容
【内容二:计算机专业的前景、发展等内容】
上下文衔接内容
【内容三:清华大学的计算机专业优势、介绍。】

既然回答哪个都有可能是错的,那么我们就全都要,全部回复即可。

结语

传统的RAG目前比较主流存在的问题有以下几个:

  • 知识库内容缺失:现有的文档其实回答不了用户的问题,系统有时被误导,给出的回应其实是“胡说八道”,理想情况系统应该回应类似“抱歉,我不知道”。
  • TopK截断有用文档:和用户查询相关的文档因为相似度不足被TopK截断,本质上是相似度不能精确度量文档相关性。
  • 上下文整合丢失:从数据库中检索到包含答案的文档,因为重排序/过滤规则等策略,导致有用的文档没有被整合到上下文中。
  • 有用信息未识别:受到LLM能力限制,有价值的文档内容没有被正确识别,这通常发生在上下文中存在过多的噪音或矛盾信息时。
  • 提示词格式问题:提示词给定的指令格式出现问题,导致大模型/微调模型不能识别用户的真正意图。
  • 准确性不足:LLM没能充分利用或者过度利用了上下文的信息,比如给学生找老师首要考虑的是教育资源的信息,而不是具体确定是哪个老师。另外,当用户的提问过于笼统时,也会出现准确性不足的问题。
  • 答案不完整:仅基于上下文提供的内容生成答案,会导致回答的内容不够完整。比如问“文档 A、B和C的主流观点是什么?”,更好的方法是分别提问并总结。

总的来看:

  • 问题1-3:属于知识库工程层面的问题,可以通过完善知识库、增强知识确定性、优化上下文整合策略解决。
  • 问题4-6:属于大模型自身能力的问题,依赖大模型的训练和迭代。
  • 问题7:属于RAG架构问题,更有前景的思路是使用Agent引入规划能力。

今天的内容主要是知识库工程层面的问题的解决方案,大模型自身能力的问题的解决方案可以交给微调来解决。而RAG架构问题目前大家也在不断提供新的解决方案例如Graph RAG之类的。

☺️你好,我是华洛,如果你对程序员转型AI产品负责人感兴趣,请给我点个赞。

你可以在这里联系我👉www.yuque.com/hualuo-fztn…

已入驻公众号【华洛AI转型纪实】,欢迎大家围观,后续会分享大量最近三年来的经验和踩过的坑。

精选专栏文章

# 从0到1打造企业级AI售前机器人——实战指南三:RAG工程的超级优化

# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐

# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐

# 聊一下MCP,希望能让各位清醒一点吧🧐

# 实战派!百万PV的AI产品如何搭建RAG系统?

# 5000字长文,AI时代下程序员的巨大优势!

Canvas签名功能常见的几种问题

2025年5月18日 23:52

. 如何实现基础签名功能?


<!DOCTYPE html>
<canvas id="signature" width="500" height="300"></canvas>
<script>
  const canvas = document.getElementById('signature');
  const ctx = canvas.getContext('2d');
  let isDrawing = false;
  
  canvas.addEventListener('mousedown', startDrawing);
  canvas.addEventListener('mousemove', draw);
  canvas.addEventListener('mouseup', stopDrawing);
  canvas.addEventListener('mouseout', stopDrawing);

  function startDrawing(e) {
    isDrawing = true;
    ctx.beginPath();
    ctx.moveTo(e.offsetX, e.offsetY);
  }

  function draw(e) {
    if (!isDrawing) return;
    ctx.lineTo(e.offsetX, e.offsetY);
    ctx.stroke();
  }

  function stopDrawing() {
    isDrawing = false;
  }
</script>

1. 如何检测签名是否为空?

function isCanvasBlank(canvas) {
  // 获取画布像素数据
  const context = canvas.getContext('2d');
  const pixelBuffer = new Uint32Array(
    context.getImageData(0, 0, canvas.width, canvas.height).data.buffer
  );
  
  // 检查是否有非透明像素
  return !pixelBuffer.some(color => color !== 0);
}

2. 如何处理不同设备DPI问题?

function setupHighDPICanvas(canvas) {
  // 获取设备像素比
  const dpr = window.devicePixelRatio || 1;
  
  // 获取CSS显示尺寸
  const rect = canvas.getBoundingClientRect();
  
  // 设置实际尺寸为显示尺寸乘以像素比
  canvas.width = rect.width * dpr;
  canvas.height = rect.height * dpr;
  
  // 缩放上下文以匹配CSS尺寸
  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  
  // 设置CSS尺寸保持不变
  canvas.style.width = `${rect.width}px`;
  canvas.style.height = `${rect.height}px`;
}

3. 如何实现撤销/重做功能?

class SignatureHistory {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.history = [];
    this.currentStep = -1;
  }
  
  saveState() {
    // 截取当前画布状态
    const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    
    // 如果当前不是最新状态,截断历史
    if (this.currentStep < this.history.length - 1) {
      this.history = this.history.slice(0, this.currentStep + 1);
    }
    
    this.history.push(imageData);
    this.currentStep++;
  }
  
  undo() {
    if (this.currentStep > 0) {
      this.currentStep--;
      this.ctx.putImageData(this.history[this.currentStep], 0, 0);
    }
  }
  
  redo() {
    if (this.currentStep < this.history.length - 1) {
      this.currentStep++;
      this.ctx.putImageData(this.history[this.currentStep], 0, 0);
    }
  }
}

4. 如何添加签名压力感应效果?

// 监听指针事件(支持压力感应设备)
canvas.addEventListener('pointerdown', startDrawing);
canvas.addEventListener('pointermove', drawWithPressure);
canvas.addEventListener('pointerup', stopDrawing);

function drawWithPressure(e) {
  if (!isDrawing) return;
  
  // 获取压力值(0-1),默认0.5用于鼠标
  const pressure = e.pressure || 0.5;
  
  // 根据压力调整线条宽度
  ctx.lineWidth = pressure * 10 + 2; // 2-12px范围
  
  ctx.lineTo(e.offsetX, e.offsetY);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(e.offsetX, e.offsetY);
}

5. 如何防止签名图片被篡改?

function generateSignatureHash(canvas) {
  // 获取画布数据
  const imageData = canvas.toDataURL('image/png');
  
  // 使用SHA-256生成哈希
  return crypto.subtle.digest('SHA-256', new TextEncoder().encode(imageData))
    .then(hash => {
      // 转换为十六进制字符串
      const hashArray = Array.from(new Uint8Array(hash));
      return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
    });
}

6. 如何加密存储签名数据?

async function encryptSignatureData(canvas) {
  // 获取画布数据
  const imageData = canvas.toDataURL('image/png');
  
  // 生成加密密钥
  const key = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );
  
  // 加密数据
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    new TextEncoder().encode(imageData)
  );
  
  return {
    key,
    iv,
    encryptedData: Array.from(new Uint8Array(encrypted))
  };
}

7. 如何实现多人协同签名?

class CollaborativeSignature {
  constructor(canvas, socket) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.socket = socket;
    
    // 本地绘制事件
    canvas.addEventListener('mousedown', this.handleLocalDrawStart.bind(this));
    canvas.addEventListener('mousemove', this.handleLocalDrawing.bind(this));
    canvas.addEventListener('mouseup', this.handleLocalDrawEnd.bind(this));
    
    // 远程绘制事件
    socket.on('remote-draw-start', this.handleRemoteDrawStart.bind(this));
    socket.on('remote-drawing', this.handleRemoteDrawing.bind(this));
    socket.on('remote-d
昨天 — 2025年5月18日掘金 前端

Step - Invert

作者 烛阴
2025年5月18日 22:51

Task

Write a shader that splits the screen into two parts: the left half should be red and the right half should be black.

编写一个着色器,将屏幕分成两部分:左半部分应为红色,右半部分应为黑色。

Requirements

The shader should avoid using branching or conditional statements in its code, and instead rely on the step function to determine the color of each pixel.

着色器应避免在其代码中使用分支或条件语句,而是依靠step函数来确定每个像素的颜色。

Theory

您可以step通过从中减去阶跃函数结果来反转函数的结果1

如果输入值小于阈值,并且输入值大于或等于阈值,则函数返回。通过从中减去此结果,step可以有效地反转输出:0.0``1.0``1.0

  • • 如果原始结果是0.0,则反转的结果将是1.0
  • • 如果原始结果是1.0,则反转的结果将是0.0

例子

下面是一个示例代码片段,用于说明如何step在 GLSL 中反转函数的结果:

float invertedStep = 1.0 - step(threshold, value);

因此,如果大于或等于,invertedStep则为,否则为。0.0``value``threshold``1.0

Answer

uniform vec2 iResolution;

void main() {
  // Normalized pixel coordinates (from 0 to 1)
  vec2 uv = gl_FragCoord.xy / iResolution.xy;
  float result = step(0.5, 1.0 - uv.x);

  gl_FragColor = vec4(result, 0.0, 0.0, 1.0);
}

效果

image.png

练习

Step - Invert

最后

如果你觉得这篇文章有用,记得点赞、关注、收藏,学Shader更轻松!!

小红书一面:长达一个小时的拷打😭

作者 Danta
2025年5月18日 22:40

前言

兄弟也是好起来了,又又有大厂面试了。


面试过程:

一、自我介绍

这个自我介绍我之前在面试文章中提到过,大家可以翻翻查看。

二、实习经历

面试官看到我目前在一家公司实习,于是让我聊了聊我的业务内容。


三、项目方面

1. 你为什么选用 Tailwind CSS?能说说有什么好处吗?

  • 原子化设计:Tailwind CSS 是一种原子化 CSS 框架,将样式拆分为最小的功能单元,每个类只负责一个特定的样式属性。
  • 开发效率高:像写内联类一样快速编写样式,无需额外创建 CSS 文件。
  • 响应式友好:支持大量响应式类,例如 md:w-1/2lg:w-1/4lg:flex-row 等。
  • 样式隔离性强:在 Vue 单文件组件中使用 Tailwind 类,避免传统 CSS 中的样式冲突问题,比如 TabBar、ShowProducts 等组件各自维护自己的样式。
  • 技术广度体现:其实当时用这个是想展示一下对现代前端工具链的理解。
  • 缺点
    • 学习曲线较陡,需要记忆大量类名和约定;
    • 熟练后开发效率更高。
  • 拓展思考
    • 便于 AI 辅助开发:原子类不依赖嵌套或继承,减少 AI 理解上下文的压力;
    • 降低样式覆盖风险,减少了 CSS 的“层叠”问题和选择器冲突。

2. 你的项目用到了组件懒加载,讲讲好处?

  • 在路由懒加载中使用了组件懒加载,实现 按需加载,只有当用户导航到特定路由时,才会加载相应的组件。
  • 如果不使用懒加载,打包时会把所有页面打包成一个文件,首页一次性加载全部资源,导致加载速度慢,用户体验差。
  • 使用路由懒加载后,首页资源被拆分为多个 chunk 文件(如 app.js, home.js),CSS 同样被拆分。
  • 文章参考链接:前端性能优化
面试官追问:你知道为什么会这样吗?

我当时没回答上来,但后来查资料得知:

  • import() 的调用处被视为代码分割点,被请求模块及其子模块会被分离为独立的 chunk。
  • Webpack 等构建工具识别 import(),并将动态导入的模块单独打包,从而减小初始加载体积。
  • 总体积不变,但首屏加载资源减少,提升用户体验。

3. 聊聊你项目中的动态组件

  • 在实现一个礼物推荐助手时,我需要展示用户提问与 AI 回答。
  • 为此我封装了两个组件:一个是用户消息组件,一个是 AI 回复组件。
  • 每次对话内容存储在一个数组中,根据标志属性判断渲染哪个组件。
  • 最终通过 Vue 内置的 <component> 标签结合 :is 属性实现了动态组件切换。

4. 你实现 keep-alive 的目的,以及和 v-if / v-show 的区别?应用场景?

  • keep-alive 目的
    • 缓存组件状态(如表单输入、滚动位置);
    • 避免组件频繁销毁重建;
    • 减少 API 请求,提高性能和用户体验。
  • 缓存控制
    • 使用 include="cachedComponents" 属性,只缓存设置了 meta.cache = true 的组件。
v-ifv-show 的区别:
对比项 keep-alive v-if v-show
是否保持状态 ✅ 是 ❌ 否 ✅ 是
渲染机制 组件缓存 条件为 false 不渲染 切换 display 属性
性能 切换成本低,适合频繁切换 初始化开销小 切换快,初始渲染全量
适用场景 多 tab 切换、表单缓存 不常切换、复杂组件 高频切换简单元素

5. 自定义图片懒加载怎么实现的?

流程如下:

scrollTop + offsetTop
=> getBoundingClientRect()
=> IntersectionObserver

从手动计算逐步过渡到现代浏览器 API,性能越来越好。

又问:你了解 HTML 中原生的 lazy 吗?能否讲讲?
  • 原生 HTML 支持懒加载:<img loading="lazy"><iframe loading="lazy">
  • 优点:简单易用,无需 JS,现代浏览器原生支持;
  • 缺点:兼容性一般,IE 不支持,功能有限;
  • 定制性不强,更高级的需求建议使用 IntersectionObserver 自定义实现。

6.响应式布局这方面,你是怎么做的?

  1. 我的项目中,有一个商品展示的功能,使用的是wc-waterfall ,动态的绑定gap和cols两个属性,通过生命周期挂载,添加事件监听,根据屏幕的大小,调整相应的值,来实现响应式布局。
  2. 通过@media声明在不同尺寸下微调样式细节
  3. 商城项目,经常用得到商品的展示,所以我会将它封装成一个组件,方便复用

四、场景题

1.setimeout

这个是一个面试经常问到的题目,但是他问的很细

for(var i=0;i<4;i++){
    setTimeout(function(){
        console.log(i);
    },1000)
}
首先问你输出什么?

由于 var i 的声明具有函数作用域(在这里指全局作用域),所有的 setTimeout 回调函数实际上引用的是同一个 i 变量。当定时器触发时(即循环已经结束),i 的值已经是 4,因此所有回调打印的结果都是 4。在整个循环过程中只有一个 i,最后连着输出四个4.

又问大概在什么时候输出: 因为定时器不一定准,所以是大概的时间,可能就会回答在4秒后了,实际上,执行同步代码的循环后,定时器四个任务,相继执行,因为间隔时间很短,所以就很像四个定时器并发了一样,实际上还是一个又一个执行的。在大概一秒后。

如何输出 0 1 2 3呢?

var => let

  • 块级作用域:在每次 for 循环迭代中,都会创建一个新的 i 实例。这些 i 变量被限制在循环体的块级作用域内。
  • 延迟执行的回调函数:每个 setTimeout 回调函数捕获的是它对应的那个特定的 i 实例。因此,当定时器触发并执行回调函数时,它们能够访问到正确的 i 值,而不是所有回调都指向同一个 i
那我想要每隔大概一秒输出一个数字呢?
  1. 当时我想到了是:
for (let i = 0,time=1000; i < 4; i++,time+=1000) {
    setTimeout(function () {
        console.log(new Date())
        console.log(i);
    }, time);
}
2025-05-18T14:14:54.808Z
0
2025-05-18T14:14:55.806Z
1
2025-05-18T14:14:56.814Z
2
2025-05-18T14:14:57.800Z
3
  1. 还有就是使用闭包加立即执行函数了:
for (var i = 0; i < 4; i++) {
    (function (i) {
        setTimeout(function () {
            console.log(i);
        }, 1000*i);
    })(i);
}

  1. 进阶:

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

(async function () {
    for (var i = 0; i < 4; i++) {
        await delay(1000);
        console.log(i);
    }
})();

2. 数组求和

const nestedObj = [1, [2, [3, [4, 5]]], 6, 7], 求和。前几天刚好看到了数组和对象的扁平化,刚好就能用上了,不过面试官好像想让我用更简单的方法,我没想出来。

let sum=0
function flattenObject(obj ) {
    for (const item of obj) {
        if (Array.isArray(item)) {
            flattenObject(item);
        } else {
            sum=sum+item;
        }
    }
    return sum;
}
console.log(flattenObject(nestedObj));// [ 1, 2, 3, 4, 5, 6, 7]

学习点其他简单的方法:

  1. 递归
function sum(arr) {
    return arr.reduce((total, item) => {
        return total + (Array.isArray(item) ? sum(item) : item);
    }, 0);
}

const nestedObj = [1, [2, [3, [4, 5]]], 6, 7];
console.log(sum(nestedObj)); // 输出: 28

结语

面了几家大厂后,也有一些心得:

大厂面试一定是穷追猛打,问到你不会为止。所以有些难题回答不出来也没关系。

而面试其实就是一场表演,大家可以在项目中准备几个亮点,自己演练几遍,在面试时流畅表达出来,体现出自己的深度和思考能力,这才是关键!

为了不碰Word,程序员必学docxtemplater

2025年5月18日 22:38

我正在参加Trae「超级体验官」创意实践征文,本文所使用的 Trae 免费下载链接:www.trae.com.cn/?utm_source…

序章、Word之罪

2025人工智能元年,天下苦Word久矣。时至今日,不少企事业单位仍然使用Word群发模版的形式收集数据。甚至还有win7系统和各种不同的WORD版本,尤其涉及表格、图文插入,因为格式问题耽误了千万人无数个“两分钟”。不统一的坏处,联想到《过秦论》。本文将使用Node.js的docxtemplater铜鼓代码的方法操作Word动态内容的生成。

图片.png ————— 指导AI模仿《过秦论》

  • 格式不统之害:仿《过秦论》「仁义不施」之叹,指Word版本迭代导致格式混乱,用户被迫「学习成本」激增。
  • 跨平台裂隙:类比战国诸侯割据,Windows与Mac系统差异使文档生态分裂。
  • 协作之弊:以秦「法令滋彰」反讽Word文档当百万问卷,致办公效率低下。
  • 末段警语:化用「攻守之势异也」,呼吁统一标准、简化设计。

零、实战演示

图片.png

图片.png

一、利器docxtemplater

docxtemplater——“使用模版语法{变量名}插值形式, 强大的Word、PPT、Excel生成器。”

图片.png 准备Node.js开发环境,和pnpm包管理工具。这里只用到Word功能,基础插值、循环插值可以解决90%的问题,剩下的用程序反而不值得。

特别注意:docxtemplater图片插入模块、EXCEL等模块是收费模版,完全可以用其他开源模块代替,这里只演示其好用的生成Word功能。

pnpm i  pizzip docxtemplater

二、基础插值

准备一个 Word模版,进行{变量名}插值占位即可。有观众可能要问,那我非要全用Word生成样式+代码怎么办?不要没苦硬吃,能用Word自身设置的样式先预设模板。

图片.png

let 数据 ={
    员工编号:`0001`,
    姓名:`张三`,
    部门:`百万前端向前冲`,
    职位:`经理`,
    入职日期:`2025-05-17`,
}

目标结果:

图片.png

三、表格循环一行插值

首先对数据进行处理,数据源头为 数组格式,这里取键名C。

let 数据 = {
    C: [
        { 员工编号: `0001`, 姓名: `张三`, 部门: `百万前端向前冲`, 职位: `经理`, 入职日期: `2025-05-17` },
        { 员工编号: `0002`, 姓名: `李四`, 部门: `百万前端向前冲`, 职位: `工程师`, 入职日期: `2025-05-17` },
        { 员工编号: `0003`, 姓名: `王五`, 部门: `百万前端向前冲`, 职位: `HR`, 入职日期: `2025-05-17` }
    ]
}

WORD模版修改。在需要循环的行前面使用{#C} 循环体 {/C},这里C是由上面数据的键名决定的,是自定义的,我这里为了演示定义为字母C,用中文命名变量同样可以。 图片.png

输出结果:

图片.png

主函数语句:

// 引入自定义模块 KoWord
const { KoWord, co } = require(`./js/KoWord.js`)

let 模版文件 = `./模版/掘金社区.docx`
let 输出文件 = `./生成/基础演示.docx`

let 数据 = {
    C: [
        { 员工编号: `0001`, 姓名: `张三`, 部门: `百万前端向前冲`, 职位: `经理`, 入职日期: `2025-05-17` },
        { 员工编号: `0002`, 姓名: `李四`, 部门: `百万前端向前冲`, 职位: `工程师`, 入职日期: `2025-05-17` },
        { 员工编号: `0003`, 姓名: `王五`, 部门: `百万前端向前冲`, 职位: `HR`, 入职日期: `2025-05-17` }
    ]
}

KoWord(模版文件,输出文件,数据) 
co(`汇总完成生成完成:${输出文件}`)

四、表格循环自身插值

我们要得到下面的效果,根据每个员工循环表格整体。 图片.png

数据格式同上,主函数语句完全一致。只需要修改WORD模版,将{#C} 循环体 {/C}循环外置。

表格无法循环小技巧:注意表格多用必须占位,不要浮动在页面。如果遇到表格无法循环,可以回车到输入光标,在复制整个表格重新占位。

图片.png

五、基础循环

1. 在{变量名}直接插入的都是普通字符。

图片.png

2.特别注意多层对象的循环

属性写法仍然是{#A}{属性名}{/A},不可以直接使用{A.属性名}

let 数据 = {
    A: { 员工编号: `0001`, 姓名: `张三`, 部门: `百万前端向前冲`, 职位: `经理`, 入职日期: `2025-05-17` },
    B:{
        name:`百万前端向前冲`,
        age:18,
    },
    C:[`0001`, `张三`, `百万前端向前冲`, `经理`, `2025-05-17`]
}

如果子数据是 数组结构,可以用 {.} 代替 图片.png

附录

1. 自定义模块KoWord.js



const PizZip = require("pizzip"); // 引入PizZip模块
const Docxtemplater = require("docxtemplater"); // 引入Docxtemplater模块


const fs = require("fs"); 

const { styleText } = require('util') 


async function KoWord(模版文件,输出文件,数据){
    const content = fs.readFileSync(模版文件, "binary");

    // 解压文件的内容
    const zip = new PizZip(content); // 创建一个新的PizZip实例

    // 解析模板,并在模板无效时抛出错误,例如,如果模板是"{user"(没有闭合标签)
    const doc = new Docxtemplater(zip, { // 创建Docxtemplater实例
        paragraphLoop: true, // 允许段落循环
        linebreaks: true, // 保持换行符
    });

    doc.render(数据);

    // 获取zip文档并将其生成为Node.js缓冲区
    const buf = doc.getZip().generate({ // 生成文档的zip格式
        type: "nodebuffer", // 生成类型为nodebuffer
        compression: "DEFLATE", // 压缩类型为DEFLATE
    });
    
    fs.writeFileSync(输出文件, buf); // 将渲染后的文档写入到output.docx文件
}//KoWord



function co(文本内容){
    // 定义随机背景颜色组(移除分号)
    const colorArr = ['bgBlack', 'bgRed', 'bgGreen', 'bgYellow', 'bgBlue', 'bgMagenta', 'bgCyan', 'bgWhite', 'bgGray', 'bgRedBright', 'bgGreenBright', 'bgYellowBright', 'bgBlueBright', 'bgMagentaBright', 'bgCyanBright', 'bgWhiteBright']
    
    const fontColorArr = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright', 'magentaBright', 'cyanBright', 'whiteBright']

    // 修改:将Emoji表情加载到数组中并保持一行
    const emojiArr = ['🌰','🌱','🌲','🌳','🌴','🌵','🌷','🌸','🌹','🌺','🌻','🌼','🌽','🌾','🌿','🍀','🍁','🍂','🍃','🍄','🍅','🍆','🍇','🍈','🍉','🍊','🍋','🍌','🍍','🍎','🍏','🍐','🍑','🍒','🍓','🍔','🍕','🍖','🍗','🍘','🍙','🍚','🍛','🍜','🍝','🍞','🍟','🍠','🍡','🍢','🍣','🍤','🍥','🍦','🍧','🍨','🍩','🍪','🍫','🍬','🍭','🍮','🍯','🍰','🍱','🍲','🍳','🍴','🍵','🍶','🍷','🍸','🍹','🍺','🍻','🍼','🍾','🍿','🎀','🎁','🎂']
    // 生成随机索引(移除分号)
    const i = Math.floor(Math.random() * fontColorArr.length)
    const j = Math.floor(Math.random() * emojiArr.length)
    // 获取随机背景颜色(移除分号)
    const 背景颜色 = fontColorArr[i]
    let 内容 = `${emojiArr[j].repeat(3)}  ${文本内容}`
    console.log(styleText( 背景颜色,内容 ))  // 移除分号并简化空格
}//信息处理




// 导出核心函数,供其他文件引用
module.exports = { KoWord, co }

2、使用Trae 列举Word罪状

图片.png

总结 祛WORD

某些功能WORD的邮件合并也能做,只是不喜欢WROD,发WORD模版甚至远不如HTML+CSS静态网页采集。 祛WORD化! 图片.png

深入解析:基于 React 与 Konva.js 实现高级图片编辑功能(附源码解读)

作者 睡着学
2025年5月18日 19:44

深入解析:基于 React 与 Konva.js 实现高级图片编辑功能(附源码解读)

引言:打造交互式图片编辑体验

在现代 Web 应用中,图片的处理与编辑功能扮演着越来越重要的角色。无论是社交媒体的滤镜、电商的产品展示,还是在线教育的课件标注,用户对于图片编辑的实时性、交互性和功能丰富性都有着较高的期待。本文将带领大家深入探索一个基于 React 和 Konva.js 实现的高级图片编辑组件。我们将从实际代码出发,详细解读其核心功能,包括图片加载与蒙版处理、自由绘制(画笔与橡皮擦)、图片拖拽与缩放、操作历史记录(撤销与重做)以及最终的图片保存等。通过本文,你不仅能了解到 Konva.js 在复杂图形操作中的应用技巧,还能学习到如何将这些技术整合到 React 组件中,构建出高效、可维护的前端应用。让我们一起揭开这个强大图片编辑器的神秘面纱吧!

核心技术栈:React 与 Konva.js 的强强联合

在深入探讨具体功能实现之前,我们首先来了解一下这个图片编辑组件所依赖的核心技术栈:React 和 Konva.js。React 作为当前最流行的前端框架之一,以其组件化、声明式编程和高效的 Virtual DOM 更新机制,为构建复杂用户界面提供了坚实的基础。而 Konva.js 则是一个强大的 HTML5 2D Canvas 库,它专注于提供高性能的图形绘制、动画以及交互能力,特别适合处理复杂的图形编辑场景。

React:构建用户界面的基石

在这个项目中,React 主要负责以下几个方面:

  • 组件化架构:整个图片编辑器被设计为一个 React 组件 (ModalComponent),这使得它可以方便地被复用和集成到其他应用中。组件内部又可以根据功能划分为更小的、可管理的单元,例如工具栏、画布区域等(虽然在提供的代码片段中,这些子组件没有显式拆分,但可以通过 React 的组织方式进行扩展)。
  • 状态管理:React 的 useState Hook 被广泛用于管理组件的各种状态,例如当前选中的工具 (tool)、画笔/橡皮擦的大小 (penSize, eraserSize)、图片的位置 (imagePosition) 和缩放比例 (scaleRatio)、历史记录 (history, historyStep) 等。这种声明式的状态管理方式使得组件的逻辑更加清晰和易于维护。
  • 事件处理:React 的事件处理机制被用于响应用户的各种操作,例如鼠标按下 (handleMouseDown)、移动 (handleMouseMove)、松开 (handleMouseUp)、滚轮滚动 (handleWheel) 等。这些事件最终会触发状态的更新,进而重新渲染画布。
  • 生命周期与副作用管理useEffect Hook 用于处理组件的副作用,例如在组件挂载或特定依赖项变化时加载图片、初始化蒙版、创建离屏 Canvas、以及监听图片加载状态等。这确保了在合适的时机执行必要的操作。

Konva.js:赋能复杂图形操作

Konva.js 在这个项目中扮演了至关重要的角色,它使得在浏览器中进行复杂的图形绘制和交互成为可能:

  • 分层画布 (Stage & Layer) :Konva.js 引入了 Stage(舞台)和 Layer(图层)的概念。Stage 是所有图形内容的顶层容器,而 Layer 则可以包含具体的图形元素(Shape),如图片、线条、圆形等。这种分层结构有助于组织复杂的场景,并且可以独立地对不同图层进行操作和重绘,从而提高性能。在代码中,我们通过 StageLayer 组件(来自 react-konva)来创建和管理画布。
  • 图形对象 (Shape) :Konva.js 提供了丰富的内置图形对象,如 Image(用于显示图片和蒙版)、Line(用于绘制画笔轨迹)、Circle(用于绘制画笔的单个点)。这些对象都具有丰富的属性(如位置、大小、颜色、透明度等)和事件处理能力。
  • 事件系统:Konva.js 拥有自己独立的事件系统,可以监听图形对象上的各种事件,如 mousedown, mousemove, mouseup, wheel 等。react-konva 将这些事件很好地集成到了 React 的事件处理方式中,使得我们可以像处理普通 DOM 元素事件一样处理 Konva 图形对象的事件。
  • 离屏 Canvas 与性能优化:虽然 Konva.js 本身已经做了很多性能优化,但在处理大量绘制操作(如自由画笔)时,直接在 Konva 的 Layer 上频繁创建和销毁 Shape 对象可能会导致性能瓶颈。代码中巧妙地引入了原生的离屏 Canvas (drawingCanvasRef) 来处理画笔和橡皮擦的绘制。用户的绘制操作首先在离屏 Canvas 上完成,然后将离屏 Canvas 的内容作为一个整体的图像绘制到 Konva 的 Layer 上。这种方式可以显著提升绘制的流畅性。此外,历史记录中的快照 (maskSnapshot) 也是通过 ImageData 的形式保存离屏 Canvas 的状态,进一步优化了撤销/重做操作的性能。

通过 React 的组件化和状态管理能力,结合 Konva.js 强大的 2D 图形处理能力,我们可以构建出功能丰富且具有良好用户体验的图片编辑应用。在接下来的章节中,我们将详细剖析这些技术是如何协同工作,以实现编辑器的各项核心功能的。

功能实现详解:一步步构建高级图片编辑器

在了解了核心技术栈之后,现在让我们深入到具体的代码实现中,逐一解析图片编辑器的各项核心功能是如何实现的。我们将重点关注图片与蒙版加载、绘图操作、图片变换、历史记录以及保存等关键环节。

1. 图片与蒙版加载及预处理:奠定编辑基础

任何图片编辑操作的第一步都是加载待编辑的图片以及可能存在的初始蒙版。在这个组件中,图片和蒙版的加载主要依赖于 use-image 这个第三方 Hook 以及 React 的 useEffect

 // ... (imports)
 import useImage from 'use-image';
 
 // ... (interface definitions)
 
 const ModalComponent: React.FC<IModalComponentProps> = ({ imagePath, maskImage, jobDomain, width, height, updateParams, onClose }) => {
   // ... (other state variables)
   const [image] = useImage(jobDomain + imagePath, 'anonymous');
   const [mask] = useImage(jobDomain + maskImage, 'anonymous');
 
   // ... (refs)
   const maskCanvasRef = useRef<HTMLCanvasElement | null>(null);
   // ...
 
   // 初始化蒙层
   useEffect(() => {
     if (image && mask) {
       const canvas = document.createElement('canvas');
       canvas.width = width;
       canvas.height = height;
       const ctx = canvas.getContext('2d');
 
       if (!ctx) return;
 
       ctx.drawImage(mask, 0, 0, width, height);
       const imageData = ctx.getImageData(0, 0, width, height);
       const data = imageData.data;
 
       // 只保留白色像素为紫色,其余像素完全透明
       for (let i = 0; i < data.length; i += 4) {
         if (data[i] > 200 && data[i + 1] > 200 && data[i + 2] > 200 && data[i + 3] > 0) {
           data[i] = 114; // R
           data[i + 1] = 46;  // G
           data[i + 2] = 209; // B
           data[i + 3] = 255; // A (opaque purple)
         } else {
           // 非白色像素完全透明
           data[i + 3] = 0; // A (fully transparent)
         }
       }
 
       ctx.putImageData(imageData, 0, 0);
       maskCanvasRef.current = canvas; // 保存处理后的蒙版 Canvas
 
       // 将蒙层绘制到 drawingCanvas 并记录初始快照 (部分逻辑)
       // ... (this part also involves drawingCanvasRef and history initialization)
       if (!drawingCanvasRef.current) drawingCanvasRef.current = document.createElement('canvas');
       const dCanvas = drawingCanvasRef.current;
       dCanvas.width = width;
       dCanvas.height = height;
       const dCtx = dCanvas.getContext('2d');
       if (dCtx) {
         dCtx.clearRect(0, 0, width, height);
         dCtx.drawImage(maskCanvasRef.current!, 0, 0, width, height);
         const snap = dCtx.getImageData(0, 0, width, height);
         setHistory([{ paintLines: [], maskSnapshot: snap }]);
         setHistoryStep(0);
         setMaskCleared(false);
         stageRef.current?.draw(); // 触发 Konva 更新
       }
     }
   }, [image, mask, width, height]);
 
   // ... (other useEffects and functions)
 }

图片加载: 组件通过 useImage(jobDomain + imagePath, 'anonymous')useImage(jobDomain + maskImage, 'anonymous') 来异步加载主图片和蒙版图片。useImage Hook 会返回一个包含图片加载状态和图片对象的数组。当图片成功加载后,imagemask变量会分别持有对应的 HTMLImageElement 对象。设置 'anonymous' 参数是为了支持跨域图片的加载,这在处理来自 CDN 或其他域的图片时非常重要。

蒙版预处理: 在 useEffect Hook 中,当 imagemask 都成功加载后,会进行蒙版的预处理。这段逻辑的核心目标是将蒙版图片中的特定颜色(这里是白色)转换为一种醒目的颜色(紫色 rgba(114, 46, 209, 255)),并将其他非白色像素设置为完全透明。这个过程如下:

  1. 创建一个临时的 HTMLCanvasElement (canvas),其尺寸与主图片一致。

  2. 获取该 Canvas 的 2D 渲染上下文 (ctx)。

  3. 将加载的原始蒙版图片 (mask) 绘制到这个临时 Canvas 上。

  4. 使用 ctx.getImageData() 获取临时 Canvas 上所有像素的数据。imageData.data 是一个 Uint8ClampedArray,其中每四个连续的元素代表一个像素的 R、G、B、A(红、绿、蓝、透明度)值。

  5. 遍历像素数据:

    • 如果一个像素的 R、G、B 值都大于 200(近似白色)且其 Alpha 值大于 0(非完全透明),则将其颜色修改为紫色 (R=114, G=46, B=209) 并保持不透明 (A=255)。
    • 否则,将该像素的 Alpha 值设置为 0,使其完全透明。
  6. 使用 ctx.putImageData() 将修改后的像素数据写回到临时 Canvas。

  7. 最后,将这个处理过的临时 Canvas (canvas) 存储在 maskCanvasRef.current 中,以备后续在主绘图区域使用。

初始状态设置: 蒙版预处理完成后,代码还会将这个处理好的蒙版绘制到 drawingCanvasRef.current(这是用于实际绘制操作的离屏 Canvas)。同时,会捕获 drawingCanvasRef 的当前状态作为历史记录的初始快照 (maskSnapshot),并初始化历史记录栈。setMaskCleared(false) 确保了初始蒙版是可见的。最后调用 stageRef.current?.draw() 来刷新 Konva 画布,将初始的蒙版(通过离屏 Canvas)渲染出来。

通过这样的加载和预处理流程,组件确保了在用户开始编辑之前,图片和经过特殊处理的蒙版已经准备就绪,为后续的绘制和编辑操作提供了清晰的视觉基础和正确的初始状态。

2. 核心绘图功能:画笔与橡皮擦的丝滑体验

图片编辑器的核心在于其绘图功能,允许用户在图片上自由涂鸦或擦除特定区域。该组件巧妙地结合了 Konva.js 的事件处理和离屏 Canvas 技术,实现了高效且灵活的画笔和橡皮擦功能。

2.1 离屏 Canvas:绘图性能的保障

直接在 Konva 的 Layer 上频繁创建和更新大量的线条或点对象(尤其是在鼠标快速移动时)可能会导致性能问题,造成卡顿。为了解决这个问题,代码引入了一个原生的 HTML5 Canvas 元素作为“离屏 Canvas” (drawingCanvasRef)。用户的画笔和橡皮擦操作实际上是先在这个离屏 Canvas 上进行的,然后整个离屏 Canvas 的内容会作为一个图像被绘制到 Konva 的 Stage 上。这种做法大大减少了 Konva 需要管理的图形对象数量,从而提升了绘图的流畅度。

 // ... (state and refs)
 const drawingCanvasRef = useRef<HTMLCanvasElement | null>(null);
 const [paintLines, setPaintLines] = useState<any[]>([]); // 存储所有绘制的线条/点
 const [currentLine, setCurrentLine] = useState<any[]>([]); // 当前正在绘制的线条
 const [isDrawing, setIsDrawing] = useState(false);
 const [tool, setTool] = useState("move"); // 'pen', 'eraser', 'move'
 const [penSize, setPenSize] = useState(30);
 const [eraserSize, setEraserSize] = useState(30);
 // ... (imagePosition, scaleRatio)
 
 // 初始化离屏Canvas (部分逻辑在之前的useEffect中已展示)
 useEffect(() => {
   if (!drawingCanvasRef.current) drawingCanvasRef.current = document.createElement("canvas");
   const canvas = drawingCanvasRef.current;
   canvas.width = width; // 图片原始宽度
   canvas.height = height; // 图片原始高度
   const ctx = canvas.getContext("2d");
   if (ctx) {
     ctx.clearRect(0, 0, width, height);
     // 初始快照也在这里创建和记录
   }
 }, [width, height]);
 
 // 每次 paintLines 或 maskCleared 状态变化时,重新绘制离屏 Canvas
 useEffect(() => {
   if (!drawingCanvasRef.current) return;
   const canvas = drawingCanvasRef.current;
   const ctx = canvas.getContext("2d");
   if (!ctx) return;
 
   ctx.clearRect(0, 0, width, height); // 清空画布
 
   // 1. 先绘制蒙版 (如果未被清除)
   if (maskCanvasRef.current && !maskCleared) {
     ctx.globalCompositeOperation = "source-over";
     ctx.drawImage(maskCanvasRef.current, 0, 0, width, height);
   }
 
   // 2. 绘制所有历史线条和圆点
   paintLines.forEach(line => {
     // 根据是画笔还是橡皮擦,设置不同的合成操作
     ctx.globalCompositeOperation = line.type === "eraser" ? "destination-out" : "source-over";
     if (line.type === "circle") { // 单点绘制 (圆点)
       ctx.fillStyle = line.color === "#000000" ? "#000000" : "rgba(114, 46, 209)"; // 橡皮擦用黑色,画笔用紫色
       // 对于橡皮擦的圆点,也需要是 "destination-out" 才能擦除蒙版或之前的画笔痕迹
       ctx.globalCompositeOperation = line.color === "#000000" ? "destination-out" : "source-over";
       ctx.beginPath();
       ctx.arc(line.points[0], line.points[1], line.width / 2, 0, Math.PI * 2);
       ctx.fill();
     } else { // 连续线条绘制
       ctx.strokeStyle = line.color; // 画笔颜色或橡皮擦的'擦除色'
       ctx.lineWidth = line.width;
       ctx.lineCap = "round";
       ctx.lineJoin = "round";
       ctx.beginPath();
       ctx.moveTo(line.points[0], line.points[1]);
       for (let i = 2; i < line.points.length; i += 2) {
         ctx.lineTo(line.points[i], line.points[i + 1]);
       }
       ctx.stroke();
     }
   });
   // 每次离屏 Canvas 更新后,需要通知 Konva Stage 重绘
   stageRef.current?.getLayer()?.batchDraw(); // 或者 stageRef.current?.draw()
 }, [paintLines, maskCleared, width, height, maskCanvasRef.current]);

在上述 useEffect 中,每当 paintLines (存储所有绘制操作的数组) 或 maskCleared (蒙版是否被清除的标志) 发生变化时,都会触发离屏 Canvas 的重绘。重绘过程包括:

  1. 清空离屏 Canvas。

  2. 如果蒙版 (maskCanvasRef.current) 存在且未被清除 (!maskCleared),则先将预处理过的蒙版绘制到离屏 Canvas 上。这里 globalCompositeOperation 设置为 source-over,表示新绘制的内容会覆盖在原有内容之上。

  3. 遍历 paintLines 数组,将每一条历史绘制操作(线条或圆点)重新应用到离屏 Canvas 上。

    • 关键点:globalCompositeOperation

      • 对于画笔操作 (line.type === "line"line.type === "circle" 且颜色不是黑色),globalCompositeOperation 设置为 source-over,画笔颜色为紫色 rgba(114, 46, 209)。这意味着画笔的痕迹会叠加在蒙版或之前的画笔痕迹之上。
      • 对于橡皮擦操作 (line.type === "eraser"line.type === "circle" 且颜色为黑色 #000000),globalCompositeOperation 设置为 destination-out。这是一种非常重要的 Canvas 合成模式,它使得新绘制的内容(橡皮擦的轨迹)会“擦除”掉目标 Canvas 上已有的内容,即新绘制区域会变成透明,从而实现橡皮擦的效果。橡皮擦的 strokeStylefillStyle 通常设为任意不透明颜色(代码中是黑色),因为 destination-out 关心的是形状和位置,而不是颜色。

2.2 鼠标事件处理与绘制逻辑

用户的绘制行为是通过监听 Konva Stage 上的鼠标事件来捕捉和处理的:handleMouseDownhandleMouseMovehandleMouseUp

 // ... (inside ModalComponent)
 const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
   if (tool === "move") { /* ...移动逻辑... */ return; }
 
   setIsDrawing(true);
   const stage = e.target.getStage();
   const pos = stage?.getPointerPosition(); // 获取鼠标在 Stage 上的原始坐标
   if (pos) {
     // 将 Stage 坐标转换为图片原始坐标系下的坐标
     const x = pos.x / (scaleRatio / 100) - imagePosition.x;
     const y = pos.y / (scaleRatio / 100) - imagePosition.y;
 
     if (isPointInImage(x, y)) { // 确保只在图片范围内开始绘制
       setCurrentLine([x, y]); // 初始化当前线条的起始点
     }
   }
 };
 
 const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
   if (!isDrawing || tool === "move") return;
 
   const stage = e.target.getStage();
   const pos = stage?.getPointerPosition();
   if (!pos) return;
 
   const x = pos.x / (scaleRatio / 100) - imagePosition.x;
   const y = pos.y / (scaleRatio / 100) - imagePosition.y;
 
   if (isPointInImage(x, y)) {
     setCurrentLine(prev => [...prev, x, y]); // 将新点追加到当前线条
   }
 };
 
 const handleMouseUp = () => {
   setIsDrawing(false);
   if (tool === "move" || currentLine.length === 0) {
     setCurrentLine([]);
     return;
   }
 
   // 鼠标抬起,一条绘制操作完成
   const ctx = drawingCanvasRef.current!.getContext("2d")!;
   // 再次在离屏 Canvas 上应用当前的绘制操作 (这一步是为了生成快照)
   ctx.globalCompositeOperation = tool === "eraser" ? "destination-out" : "source-over";
   ctx.strokeStyle = tool === "eraser" ? "#000000" : "rgba(114, 46, 209)";
   ctx.lineWidth = tool === "eraser" ? eraserSize : penSize;
   ctx.lineCap = "round";
   ctx.lineJoin = "round";
   ctx.beginPath();
   ctx.moveTo(currentLine[0], currentLine[1]);
   for (let i = 2; i < currentLine.length; i += 2) {
     ctx.lineTo(currentLine[i], currentLine[i + 1]);
   }
   ctx.stroke();
 
   const snapshot = ctx.getImageData(0, 0, width, height); // 获取离屏 Canvas 快照
 
   let newLineEntry;
   if (currentLine.length === 2) { // 单点点击,视为画一个圆点
     newLineEntry = {
       type: "circle",
       points: [currentLine[0], currentLine[1]],
       color: tool === "eraser" ? "#000000" : "rgba(114, 46, 209)",
       width: tool === "eraser" ? eraserSize : penSize
     };
   } else { // 连续拖动,视为画一条线
     newLineEntry = {
       type: tool === "eraser" ? "eraser" : "line",
       points: currentLine,
       color: tool === "eraser" ? "#000000" : "rgba(114, 46, 209)",
       width: tool === "eraser" ? eraserSize : penSize
     };
   }
 
   const newPaintLines = [...paintLines, newLineEntry];
   setPaintLines(newPaintLines);
 
   // 更新历史记录
   const newHistory = [...history.slice(0, historyStep + 1), { paintLines: newPaintLines, maskSnapshot: snapshot }];
   setHistory(newHistory);
   setHistoryStep(newHistory.length - 1);
 
   setCurrentLine([]); // 清空当前线条,为下一次绘制做准备
 };
 
 // Konva Stage 定义,其中包含了离屏 Canvas 的 Image 对象
 // <Stage ... onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} onWheel={handleWheel}>
 //   <Layer>
 //     <Image image={image} x={imagePosition.x * (scaleRatio / 100)} y={imagePosition.y * (scaleRatio / 100)} width={width * (scaleRatio / 100)} height={height * (scaleRatio / 100)} />
 //     {drawingCanvasRef.current && (
 //       <Image image={drawingCanvasRef.current} x={imagePosition.x * (scaleRatio / 100)} y={imagePosition.y * (scaleRatio / 100)} width={width * (scaleRatio / 100)} height={height * (scaleRatio / 100)} listening={false} />
 //     )}
 //   </Layer>
 // </Stage>
  • 坐标转换:由于图片可能被缩放 (scaleRatio) 或移动 (imagePosition),鼠标在 Stage 上的原始坐标需要转换为图片原始尺寸下的坐标。转换公式为: imageCoordX = stageMouseX / (scaleRatio / 100) - imagePosition.x imageCoordY = stageMouseY / (scaleRatio / 100) - imagePosition.y isPointInImage(x, y) 函数用于检查转换后的坐标是否在图片范围内,避免在图片外部绘制。

  • handleMouseDown:当用户按下鼠标且当前工具不是“移动”时,设置 isDrawingtrue,并记录当前鼠标在图片坐标系下的位置作为 currentLine 的起始点。

  • handleMouseMove:如果 isDrawingtrue 且工具不是“移动”,则随着鼠标移动,不断将新的图片坐标系下的点追加到 currentLine 数组中。此时并不会立即更新 paintLines 或重绘离屏 Canvas,以保证拖动过程的流畅性。Konva Stage 上的视觉反馈是实时更新的(如果直接在 Konva Layer 上绘制线条的话),但在这个实现中,由于主要依赖离屏 Canvas,实时预览效果可能需要额外的处理(例如,在 handleMouseMove 中也临时在离屏 Canvas 上绘制 currentLine,但这部分代码未显式提供,通常为了性能,仅在 mouseUp 时最终确认绘制)。不过,由于 paintLines 改变后 useEffect 会重绘离屏 Canvas,而 Konva 的 Image 组件会显示这个 drawingCanvasRef.current,所以用户还是能看到绘制过程。

  • handleMouseUp:当用户松开鼠标时,表示一条绘制操作(画线或画点)完成。

    1. 设置 isDrawingfalse
    2. 如果 currentLine 为空(例如只是点击移动工具后松开),则直接返回。
    3. 在离屏 Canvas 上最终确认绘制:将 currentLine 中的点连接成线(或画一个点),并根据当前是画笔还是橡皮擦,使用相应的 globalCompositeOperation、颜色和线宽,在 drawingCanvasRef.current 上绘制出来。这一步是必要的,因为它确保了历史记录中保存的 maskSnapshot (ImageData) 是包含了当前这次绘制操作之后的状态。
    4. 获取快照:使用 ctx.getImageData()drawingCanvasRef.current 获取当前完整的像素数据,作为本次操作后的画布快照 (snapshot),用于历史记录。
    5. 创建新的绘制记录:根据 currentLine 的长度(如果只有两个坐标点,说明是单击,创建一个 type: "circle" 的记录;否则是 type: "line"type: "eraser" 的记录),包含点坐标、颜色、宽度等信息。
    6. 更新 paintLines:将新的绘制记录追加到 paintLines 数组中。这个状态的改变会触发前面提到的 useEffect,从而使用最新的 paintLines 完整重绘离屏 Canvas。
    7. 更新历史记录:将新的 paintLines 数组和刚才获取的 snapshot 存入 history 数组,并更新 historyStep
    8. 清空 currentLine,为下一次绘制做准备。

Konva Stage 结构 在 JSX 结构中,Konva 的 Stage 组件包裹了一个 LayerLayer 中包含两个 Image 组件:

  1. 第一个 Image 用于显示原始的背景图片 (image={image})。它的位置和尺寸会根据 imagePositionscaleRatio 进行调整。
  2. 第二个 Image 用于显示我们的离屏 Canvas (image={drawingCanvasRef.current})。它与背景图片保持相同的位置和缩放,确保绘制内容能准确叠加在背景图上。listening={false} 属性表示这个 Image 对象不响应鼠标事件,所有鼠标事件都由其父级 Stage 或其他可交互的 Konva 对象处理。

通过这种离屏 Canvas 与 Konva 结合的方式,以及精细的鼠标事件处理和状态管理,组件实现了流畅且功能完备的画笔和橡皮擦功能,同时为后续的撤销/重做操作打下了坚实的基础。

huiling1.gif

3. 图片操作:自由移动与精准缩放

除了核心的绘图功能,一个优秀的图片编辑器还应该允许用户方便地移动和缩放图片,以便更好地观察细节或调整编辑区域。该组件通过监听鼠标事件和滚轮事件,实现了图片的拖拽移动和中心缩放功能。

3.1 图片拖拽移动

图片的拖拽移动功能主要在 tool 状态为 "move" 时激活。相关的事件处理主要在 handleMouseDownhandleMouseMove 中。

 // ... (state variables: imagePosition, lastMousePosition, scaleRatio, tool)
 
 const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
   if (tool === "move") {
     setIsDrawing(true); // 虽然不是绘图,但借用 isDrawing 状态来标记拖拽开始
     const stage = e.target.getStage();
     const pos = stage?.getPointerPosition();
     if (pos) {
       setLastMousePosition({ x: pos.x, y: pos.y }); // 记录当前鼠标按下时的 Stage 坐标
     }
     return;
   }
   // ... (drawing logic)
 };
 
 const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
   if (!isDrawing) return;
 
   const stage = e.target.getStage();
   const pos = stage?.getPointerPosition(); // 获取当前鼠标在 Stage 上的坐标
   if (!pos) return;
 
   if (tool === "move") {
     if (lastMousePosition) {
       // 计算鼠标在 Stage 坐标系下的位移
       const deltaX = pos.x - lastMousePosition.x;
       const deltaY = pos.y - lastMousePosition.y;
 
       // 更新图片位置。注意,imagePosition 是在图片原始(未缩放)坐标系下的偏移量
       // 所以,Stage 上的位移需要除以当前的缩放比例,才能正确应用到 imagePosition 上
       setImagePosition({
         x: imagePosition.x + deltaX / (scaleRatio / 100),
         y: imagePosition.y + deltaY / (scaleRatio / 100)
       });
 
       setLastMousePosition({ x: pos.x, y: pos.y }); // 更新上一次鼠标位置为当前位置
     }
     return;
   }
   // ... (drawing logic)
 };
 
 const handleMouseUp = () => {
   setIsDrawing(false);
   if (tool === "move") {
     setLastMousePosition(null); // 清除上一次鼠标位置
   }
   // ... (drawing logic, including setCurrentLine([]))
 };
  • handleMouseDown:当 tool"move" 时,鼠标按下会设置 isDrawingtrue (这里复用了 isDrawing 状态来表示拖拽操作正在进行),并记录下当前鼠标在 Konva Stage 上的位置 (lastMousePosition)。

  • handleMouseMove:如果 isDrawingtruetool"move",则在鼠标移动时:

    1. 获取当前鼠标在 Stage 上的位置。
    2. lastMousePosition 比较,计算出鼠标在 Stage 坐标系中的水平和垂直位移 (deltaX, deltaY)。
    3. 核心逻辑:更新 imagePositionimagePosition 存储的是图片左上角相对于其“原始”未缩放状态下的容器左上角的偏移量。由于用户在 Stage 上看到的图片是经过缩放的,因此 Stage 上的鼠标位移量需要根据当前的 scaleRatio 进行反向缩放,才能得到在图片原始坐标系下的正确位移量。所以,deltaXdeltaY 都需要除以 (scaleRatio / 100)
    4. 更新 lastMousePosition 为当前鼠标位置,为下一次移动计算做准备。
  • handleMouseUp:鼠标松开时,设置 isDrawingfalse,并清空 lastMousePosition

3.2 图片滚轮缩放(以画布中心为焦点)

图片的缩放功能通过监听 Konva Stage 上的 wheel 事件(即鼠标滚轮事件)来实现。缩放的焦点被设计为当前画布的中心点,这意味着无论图片当前如何移动和缩放,滚轮操作都会使得画布中心点在图片上的对应位置保持不变,从而提供一种自然的缩放体验。

 // ... (state variables: scaleRatio, imagePosition)
 // ... (refs: stageRef)
 
 const handleWheel = (e: KonvaEventObject<WheelEvent>) => {
   e.evt.preventDefault(); // 阻止浏览器默认的滚轮行为(如页面滚动)
 
   let newScale = scaleRatio;
   if (e.evt.deltaY < 0) { // 滚轮向上,放大
     newScale = Math.min(500, scaleRatio + 5); // 限制最大缩放比例为 500%
   } else { // 滚轮向下,缩小
     newScale = Math.max(25, scaleRatio - 5); // 限制最小缩放比例为 25%
   }
 
   const stage = stageRef.current?.getStage();
   if (stage) {
     const stageWidth = stage.width();
     const stageHeight = stage.height();
 
     // 1. 计算当前画布中心点在图片原始坐标系下的位置 (centerX, centerY)
     // stageWidth / 2 是画布中心在 Stage 坐标系下的 x 坐标
     // (stageWidth / 2) / (scaleRatio / 100) 将其转换到缩放前的 Stage 坐标尺度
     // 再减去 imagePosition.x 得到在图片原始坐标系下的 x 坐标
     const centerX = stageWidth / 2 / (scaleRatio / 100) - imagePosition.x;
     const centerY = stageHeight / 2 / (scaleRatio / 100) - imagePosition.y;
 
     // 2. 计算新的 imagePosition,使得缩放后,上述 (centerX, centerY) 这一点
     // 在新的缩放比例下,仍然位于画布中心。
     // 我们希望:stageWidth / 2 / (newScale / 100) - newImagePosition.x = centerX
     // 变形得到:newImagePosition.x = stageWidth / 2 / (newScale / 100) - centerX
     const newImagePosition = {
       x: stageWidth / 2 / (newScale / 100) - centerX,
       y: stageHeight / 2 / (newScale / 100) - centerY
     };
 
     setScaleRatio(newScale);
     setImagePosition(newImagePosition);
   } else {
     // 如果 stage 获取不到,仅更新缩放比例(这种情况理论上不应发生)
     setScaleRatio(newScale);
   }
 };
  • 阻止默认行为e.evt.preventDefault() 用于防止滚轮事件触发浏览器的默认滚动行为。

  • 计算新缩放比例:根据 e.evt.deltaY 的正负(向上滚动 deltaY < 0,向下滚动 deltaY > 0)来增加或减少 scaleRatio。缩放比例被限制在 25% 到 500% 之间。

  • 保持中心点不变的核心逻辑

    1. 获取 Konva Stage 的当前尺寸 (stageWidth, stageHeight)。

    2. 计算不变点:找出当前 Stage 中心点(stageWidth / 2, stageHeight / 2)在图片原始坐标系中所对应的点 (centerX, centerY)。这个计算考虑了当前的 scaleRatioimagePosition

      • stageWidth / 2 / (scaleRatio / 100):这是 Stage 中心点在 “未平移的、但已按 scaleRatio 缩放的图片” 坐标系中的 X 坐标。
      • 减去 imagePosition.x:将其转换到图片自身的原始坐标系中。
    3. 计算新的 imagePosition:当应用新的缩放比例 newScale 后,我们希望之前计算出的 centerX, centerY 这一点,在新的视图下仍然显示在 Stage 的中心。因此,我们需要反向计算出新的 imagePosition

      • stageWidth / 2 / (newScale / 100):这是 Stage 中心点在 “未平移的、但已按 newScale 缩放的图片” 坐标系中的 X 坐标。
      • 用这个值减去 centerX,就得到了新的 imagePosition.x
  • 更新状态:最后,调用 setScaleRatio(newScale)setImagePosition(newImagePosition) 来应用新的缩放比例和图片位置。

此外,组件还提供了 reduceImageSizeaddImageSize 两个辅助函数,它们通过按钮触发,功能与滚轮缩放类似,也是以画布中心为焦点进行固定步长的缩放,并同样更新 scaleRatioimagePosition

 // 减少图片放大比例
 const reduceImageSize = () => {
   if (scaleRatio <= 25) return;
   const newScale = scaleRatio - 5;
   // ... (与 handleWheel 中类似的中心点保持逻辑)
   // ... 更新 scaleRatio 和 imagePosition
 };
 
 // 增加图片放大比例
 const addImageSize = () => {
   if (scaleRatio >= 500) return;
   const newScale = scaleRatio + 5;
   // ... (与 handleWheel 中类似的中心点保持逻辑)
   // ... 更新 scaleRatio 和 imagePosition
 };

通过这些精心设计的事件处理和坐标转换逻辑,用户可以流畅地拖动图片,并以画布中心为焦点进行缩放,极大地提升了图片编辑的操作便利性和用户体验。

huiling5.gif

4. 历史记录:轻松实现撤销与重做

对于任何编辑器而言,撤销 (Undo) 和重做 (Redo) 功能都是不可或缺的,它们给予用户试错和修改的自由。该图片编辑组件实现了一套基于状态快照的历史记录系统,能够准确地回溯和重放用户的绘制操作。

4.1 历史记录的数据结构与状态管理

历史记录的核心是 history 状态数组和 historyStep 状态变量:

 // ... (state variables)
 const [paintLines, setPaintLines] = useState<any[]>([]); // 当前的绘制线条/点数据
 const [history, setHistory] = useState<{ paintLines: any[]; maskSnapshot: ImageData | null }[]>([]); // 历史记录栈
 const [historyStep, setHistoryStep] = useState(0); // 当前在历史记录中的步骤索引
 
 // drawingCanvasRef for getting ImageData snapshots
 const drawingCanvasRef = useRef<HTMLCanvasElement | null>(null);
 // ...
  • history: 这是一个数组,数组中的每个元素都是一个对象,代表一个历史状态。每个历史状态对象包含两个关键属性:

    • paintLines: 该历史步骤完成时,paintLines 数组的完整副本。paintLines 存储了所有的绘制指令(如线条的坐标、颜色、宽度,圆点的坐标、颜色、半径等)。
    • maskSnapshot: 该历史步骤完成时,离屏 Canvas (drawingCanvasRef.current) 的 ImageData 快照。这个快照捕获了当时离屏 Canvas 上包括蒙版和所有已绘制内容的完整像素级状态。
  • historyStep: 一个整数,表示当前用户界面显示的是 history 数组中第 historyStep 个索引对应的状态。当用户进行新的绘制操作时,historyStep 会指向最新的历史记录;当用户执行撤销时,historyStep 会减小;执行重做时,historyStep 会增大。

初始化历史记录: 在组件加载并初始化蒙版和离屏 Canvas 时,会创建历史记录的第一个条目,代表画布的初始状态(通常是只有蒙版的状态,或者一个空白状态)。

 // In the useEffect hook for mask initialization (and also for initial blank snapshot)
 // ... (after drawing initial mask or clearing canvas for initial blank state)
 if (dCtx) { // dCtx is the context of drawingCanvasRef.current
   // ... draw initial content (e.g., mask) onto dCtx ...
   const snap = dCtx.getImageData(0, 0, width, height);
   setHistory([{ paintLines: [], maskSnapshot: snap }]); // Initial history entry
   setHistoryStep(0);
 }

记录新的历史步骤: 每当用户完成一次有效的绘制操作(即在 handleMouseUp 中,当 currentLine 非空时),一个新的历史条目会被创建并添加到 history 数组中。

 // In handleMouseUp, after a drawing operation is completed:
 const handleMouseUp = () => {
   // ... (drawing logic on drawingCanvasRef.current ...)
   // ... (obtaining currentLine data ...)
 
   if (currentLine.length > 0) {
     const ctx = drawingCanvasRef.current!.getContext("2d")!;
     // ... (final draw of currentLine on offscreen canvas for snapshot accuracy)
     const snapshot = ctx.getImageData(0, 0, width, height); // Capture snapshot AFTER current draw
 
     const newLineEntry = { /* ... details of the new line/circle ... */ };
     const newPaintLines = [...paintLines, newLineEntry];
     setPaintLines(newPaintLines); // Update current paintLines
 
     // Create new history entry
     // If undo operations were performed, slice history to discard redo stack
     const newHistory = [       ...history.slice(0, historyStep + 1),       { paintLines: newPaintLines, maskSnapshot: snapshot }     ];
     setHistory(newHistory);
     setHistoryStep(newHistory.length - 1); // Point to the new latest state
 
     setCurrentLine([]);
   }
 };

关键在于 history.slice(0, historyStep + 1):如果在执行新的绘制操作之前,用户已经进行了一些撤销操作(即 historyStep 不是指向 history 数组的最后一个元素),那么所有在 historyStep 之后的“未来”历史(即重做栈)都应该被丢弃。新的绘制操作将成为新的历史终点。

4.2 撤销 (Undo) 功能实现

撤销操作 (handleUndo) 会将当前状态回退到历史记录中的上一个步骤。

 const handleUndo = () => {
   if (historyStep > 0) { // Ensure there is a previous step to undo to
     const newStep = historyStep - 1;
     setHistoryStep(newStep);
 
     const entry = history[newStep]; // Get the historical state entry
 
     // 1. Restore paintLines data
     setPaintLines(entry.paintLines);
 
     // 2. Restore offscreen canvas snapshot
     const ctx = drawingCanvasRef.current!.getContext("2d")!;
     if (entry.maskSnapshot) {
       ctx.putImageData(entry.maskSnapshot, 0, 0);
     }
 
     // 3. Trigger Konva stage redraw to reflect changes
     stageRef.current?.draw(); // Or stageRef.current?.getLayer()?.batchDraw();
   }
 };

0. 检查 historyStep > 0,确保当前不是历史记录的起点。

  1. historyStep 减 1。
  2. history 数组中获取索引为 newStep 的历史条目 entry
  3. 恢复 paintLines:调用 setPaintLines(entry.paintLines),将当前的绘制指令集恢复到该历史步骤的状态。这个更新会触发重绘离屏 Canvas 的 useEffect,但为了更直接和准确地恢复像素状态,我们还会用到快照。
  4. 恢复离屏 Canvas 快照:获取 drawingCanvasRef.current 的 2D 上下文,并使用 ctx.putImageData(entry.maskSnapshot, 0, 0) 将该历史步骤保存的 ImageData 快照直接绘制回离屏 Canvas。这确保了离屏 Canvas 的像素级内容与历史状态完全一致,包括蒙版和所有当时的绘制痕迹。
  5. 触发 Konva 重绘:调用 stageRef.current?.draw() 来刷新 Konva Stage,使其显示更新后的离屏 Canvas 内容。

4.3 重做 (Redo) 功能实现

重做操作 (handleRedo) 允许用户恢复之前被撤销的操作,即前进到历史记录中的下一个步骤。

 const handleRedo = () => {
   if (historyStep < history.length - 1) { // Ensure there is a next step to redo to
     const newStep = historyStep + 1;
     setHistoryStep(newStep);
 
     const entry = history[newStep]; // Get the historical state entry
 
     // 1. Restore paintLines data
     setPaintLines(entry.paintLines);
 
     // 2. Restore offscreen canvas snapshot
     const ctx = drawingCanvasRef.current!.getContext("2d")!;
     if (entry.maskSnapshot) {
       ctx.putImageData(entry.maskSnapshot, 0, 0);
     }
 
     // 3. Trigger Konva stage redraw
     stageRef.current?.draw();
   }
 };

重做逻辑与撤销非常相似:

  1. 检查 historyStep < history.length - 1,确保当前不是历史记录的终点。
  2. historyStep 加 1。
  3. 获取新的 historyStep 对应的历史条目 entry
  4. 恢复 paintLines 和离屏 Canvas 的 maskSnapshot,与撤销操作中的步骤相同。
  5. 触发 Konva Stage 重绘。

通过这种方式,组件有效地管理了操作历史。paintLines 的恢复确保了逻辑上的绘制状态正确(例如,如果后续有基于 paintLines 的分析或导出操作),而 maskSnapshot 的恢复则直接保证了视觉上画布的精确回溯。这种双重恢复机制使得撤销和重做功能既准确又高效。

huiling3.gif

5. 一键清新:画布清除功能

在编辑过程中,用户可能需要重新开始或者清除当前所有的绘制内容和蒙版效果。为此,组件提供了一个 clearCanvas 功能,可以将画布恢复到初始的空白状态(或者说,一个没有绘制痕迹、也没有原始蒙版的状态)。

 // ... (state variables: paintLines, history, historyStep, maskCleared)
 // ... (refs: drawingCanvasRef, stageRef)
 // ... (image dimensions: width, height)
 
 const clearCanvas = () => {
   // 1. Clear paintLines array
   setPaintLines([]);
 
   // 2. Set maskCleared to true, indicating the original mask should also be hidden
   setMaskCleared(true);
 
   // 3. Clear the offscreen canvas (drawingCanvasRef)
   const ctx = drawingCanvasRef.current!.getContext("2d")!;
   ctx.clearRect(0, 0, width, height);
 
   // 4. Create a snapshot of the cleared canvas for history
   const snapshot = ctx.getImageData(0, 0, width, height);
 
   // 5. Reset history to a single entry representing the cleared state
   setHistory([{ paintLines: [], maskSnapshot: snapshot }]);
   setHistoryStep(0);
 
   // 6. Clear the Konva stage (optional, as redrawing with empty drawingCanvasRef will also clear it)
   // stageRef.current?.clear(); // This would remove all children from all layers
 
   // 7. Trigger Konva stage redraw to show the cleared state
   stageRef.current?.draw();
 };

清除画布的步骤详解

  1. 清空绘制数据 (setPaintLines([])) :将存储所有画笔和橡皮擦操作的 paintLines 数组设置为空。这将导致在下一次离屏 Canvas 重绘时(由 useEffect 监听 paintLines 变化触发),不会再绘制任何历史线条或圆点。

  2. 标记蒙版已清除 (setMaskCleared(true))maskCleared 是一个布尔状态,用于控制是否在离屏 Canvas 上绘制初始的蒙版。将其设置为 true 后,在离屏 Canvas 的重绘逻辑中,if (maskCanvasRef.current && !maskCleared) 条件将不再满足,因此初始蒙版也不会被绘制。

  3. 清空离屏 Canvas (ctx.clearRect(0, 0, width, height)) :直接调用离屏 Canvas (drawingCanvasRef.current) 的 2D 上下文的 clearRect 方法,将整个离屏 Canvas 的内容擦除,使其变为完全透明的空白状态。

  4. 创建空白快照 (const snapshot = ctx.getImageData(0, 0, width, height)) :在清空离屏 Canvas 后,立即获取其 ImageData 快照。这个快照代表了画布被彻底清除后的状态。

  5. 重置历史记录

    • setHistory([{ paintLines: [], maskSnapshot: snapshot }]): 将 history 数组重置为只包含一个条目的新数组。这个唯一的条目代表了画布清除后的状态,其 paintLines 为空,maskSnapshot 为刚刚获取的空白快照。
    • setHistoryStep(0): 将历史步骤指针也重置为 0,指向这个新的初始状态。 这样做意味着“清除画布”操作本身会成为历史记录的新起点,之前的撤销/重做栈都会被清除。用户如果想恢复到清除前的状态,需要依赖应用层面的其他机制(如果设计了的话),或者重新加载原始图片和蒙版。
  6. Konva Stage 清理 (可选) :代码中注释掉了 stageRef.current?.clear()Konva.Stage.clear() 方法会移除舞台上所有层中所有子节点。在这个组件的实现中,由于 Konva Image 组件的内容是直接来自 drawingCanvasRef.current,当 drawingCanvasRef.current 被清空并且 paintLines 也为空时,Konva Image 自然会显示为空白。因此,显式调用 stageRef.current?.clear() 可能不是绝对必要的,但如果 Stage 上还有其他不由 drawingCanvasRef 控制的临时图形元素,则可能需要它。

  7. 触发 Konva 重绘 (stageRef.current?.draw()) :最后,调用 Konva Stage 的 draw 方法,使其重新渲染。此时,由于 drawingCanvasRef.current 已经是空白的,Konva Image 组件会显示一个空白的画布,达到了清除的效果。

通过以上步骤,clearCanvas 函数能够有效地将用户的编辑区域恢复到一个干净的状态,同时正确地重置了相关的状态和历史记录,为用户提供了重新开始的便捷途径。

huiling4.gif

6. 保存最终成果:生成与上传蒙版图片

当用户完成所有编辑操作后,最终需要将编辑结果保存下来。在这个组件中,“保存”操作特指根据用户的绘制(涂抹和擦除)生成一个新的蒙版图片,并将其上传到服务器。handleSave 函数负责这一系列复杂的流程。

 // ... (state: uploading, width, height, maskCleared, paintLines)
 // ... (refs: maskCanvasRef -- though its direct use in save logic for drawing is superseded by reloading maskImage)
 // ... (props: jobDomain, maskImage, updateParams, onClose)
 
 const handleSave = async () => {
   setUploading(true); // 开始保存,设置上传状态为 true,可以用于显示加载指示
 
   // 1. 创建一个新的目标 Canvas 用于生成最终的蒙版图片
   const exportCanvas = document.createElement('canvas');
   exportCanvas.width = width;  // 使用原始图片的尺寸
   exportCanvas.height = height;
   const ctx = exportCanvas.getContext('2d');
 
   if (!ctx) {
     setUploading(false);
     toast.warning('无法创建画布用于保存');
     return;
   }
 
   // 2. 设置纯黑背景
   // 最终生成的蒙版通常是二值的(例如黑白),黑色代表背景或未选中区域。
   ctx.fillStyle = '#000000';
   ctx.fillRect(0, 0, width, height);
 
   // 3. 创建一个临时的 Konva Stage 来合成蒙版和用户绘制内容
   // 这样做的好处是可以使用 Konva 的图形对象和层级管理来精确控制绘制顺序和效果,
   // 而不影响主显示画布。
   const tempStage = new Konva.Stage({
     container: document.createElement('div'), // Konva Stage 需要一个容器元素
     width: width,
     height: height
   });
   const tempLayer = new Konva.Layer();
   tempStage.add(tempLayer);
 
   // 异步操作的 Promise 包装,确保所有绘制完成后再导出
   const drawingPromise = new Promise<void>((resolve, reject) => {
     let operationsPending = 0;
 
     const checkCompletion = () => {
       if (operationsPending === 0) {
         resolve();
       }
     };
 
     // 3.1 可选:绘制原始蒙版 (如果未被用户清除)
     // 注意:这里是重新加载原始的 maskImage,而不是使用预处理过的紫色蒙版。
     // 这意味着如果原始蒙版有非纯白区域,它们也会被绘制到这个黑色背景上。
     // 用户后续的白色涂抹会覆盖它,橡皮擦会擦除它。
     if (!maskCleared && maskImage && jobDomain) { // 确保 maskImage 和 jobDomain 有效
       operationsPending++;
       const originalMaskObj = new window.Image();
       originalMaskObj.crossOrigin = 'anonymous';
       originalMaskObj.src = jobDomain + maskImage;
       originalMaskObj.onload = () => {
         const konvaMask = new Konva.Image({
           image: originalMaskObj,
           width: width,
           height: height
         });
         tempLayer.add(konvaMask);
         operationsPending--;
         checkCompletion();
       };
       originalMaskObj.onerror = () => {
         console.error('原始蒙版加载失败 for save');
         operationsPending--;
         checkCompletion(); // 即使失败也继续,后续绘制用户笔迹
       };
     } else {
       // 如果没有原始蒙版或已被清除,直接进入下一步
     }
 
     // 3.2 绘制用户的涂鸦和擦除痕迹
     // 用户的“画笔”操作(在屏幕上显示为紫色)在保存时会被转换为纯白色。
     // 用户的“橡皮擦”操作会使用 'destination-out' 合成模式,实现擦除效果。
     const drawingGroup = new Konva.Group();
     paintLines.forEach(line => {
       let shape;
       const isEraser = line.type === 'eraser' || (line.type === 'circle' && line.color === '#000000');
 
       if (line.type === 'circle') {
         shape = new Konva.Circle({
           x: line.points[0],
           y: line.points[1],
           radius: line.width / 2,
           fill: '#FFFFFF', // 所有用户绘制区域(非橡皮擦)在最终蒙版上为白色
           globalCompositeOperation: isEraser ? 'destination-out' : 'source-over'
         });
       } else { // 'line' or 'eraser'
         shape = new Konva.Line({
           points: line.points,
           stroke: '#FFFFFF', // 所有用户绘制线条(非橡皮擦)在最终蒙版上为白色
           strokeWidth: line.width,
           lineCap: 'round',
           lineJoin: 'round',
           globalCompositeOperation: isEraser ? 'destination-out' : 'source-over'
         });
       }
       drawingGroup.add(shape);
     });
     tempLayer.add(drawingGroup);
 
     // 如果没有异步加载原始蒙版,立即 resolve
     if (operationsPending === 0) {
         resolve();
     }
   });
 
   try {
     await drawingPromise; // 等待所有 Konva 绘制操作完成
     tempStage.batchDraw(); // 确保所有内容都绘制到临时 Stage
 
     // 4. 从临时 Konva Stage 导出图像数据 (Data URL)
     const dataURL = tempStage.toDataURL({ mimeType: 'image/png' }); // 通常保存为 PNG 以支持透明度
 
     // 5. 将 Data URL 转换为 File 对象以便上传
     const byteString = atob(dataURL.split(',')[1]);
     const mimeString = dataURL.split(',')[0].split(':')[1].split(';')[0];
     const ab = new ArrayBuffer(byteString.length);
     const ia = new Uint8Array(ab);
     for (let i = 0; i < byteString.length; i++) {
       ia[i] = byteString.charCodeAt(i);
     }
     const blob = new Blob([ab], { type: mimeString });
     const timestamp = new Date().getTime();
     const fileName = `mask_${timestamp}.png`;
     const fileToUpload = new File([blob], fileName, { type: mimeString });
 
     // 6. 调用上传函数 (uploadImage 是组件内部定义的,它会调用 API)
     // 注意:uploadImage 期望一个 FileList,所以传递 [fileToUpload]
     if (files && files.length > 0) { // This is from the original uploadImage function signature, adapting here
         const file = fileToUpload; // Assuming uploadImage can take a single file or we adapt it
         const formdata = new FormData();
         formdata.append('file', file);
 
         const res = await OssApi.upload(formdata); // OssApi is an external dependency
         if (res.code === 0 && res.data) {
           updateParams('maskImage', res.data.path); // 更新父组件中的蒙版路径
           toast.success('蒙版保存并上传成功!');
         } else {
           toast.warning(res.info || '蒙版上传失败');
         }
     } else { // Fallback if the structure of uploadImage was different
         // This part needs to align with how `uploadImage` is actually defined and used elsewhere.
         // The provided snippet has `uploadImage(files: FileList | null)`
         // So, we'd call it like this:
         await uploadImageInternal([fileToUpload]); // Assuming uploadImageInternal is the refactored version of the logic in the original snippet
     }
 
   } catch (error) {
     toast.warning(`保存失败: ${String(error)}`);
   } finally {
     setUploading(false); // 无论成功或失败,结束上传状态
     onClose(); // 关闭编辑模态框
     tempStage.destroy(); // 清理临时 Konva Stage 资源
   }
 };
 
 // Helper function based on the original snippet's upload logic
 const uploadImageInternal = async (files: FileList | File[]) => { // Made flexible
     try {
       if (files && files.length > 0) {
         const file = files[0];
         if (!file) return;
         const formdata = new FormData();
         formdata.append('file', file);
 
         const res = await OssApi.upload(formdata);
         if (res.code === 0 && res.data) {
           updateParams('maskImage', res.data.path);
           console.log(res.data.domain + res.data.path, 'new maskImage path');
           toast.success('蒙版已更新');
         } else {
           toast.warning(res.info || '上传新蒙版失败');
         }
       }
     } catch (error) {
       toast.warning(String(error));
     }
     // `finally` block with `setUploading(false)` and `onClose()` is in `handleSave`
 };

保存流程解析

  1. 状态初始化setUploading(true) 用于触发 UI 上的加载状态,告知用户操作正在进行中。

  2. 创建导出画布:首先创建一个与原图等大的 HTMLCanvasElement (exportCanvas),并将其背景填充为纯黑色。这是生成蒙版的基础色,通常在二值蒙版中,黑色代表不被选中的区域。

  3. 临时 Konva Stage:为了灵活地将原始蒙版(如果需要)和用户的绘制痕迹合成到最终图片上,代码创建了一个临时的、不可见的 Konva Stage (tempStage) 和一个 Layer (tempLayer)。

    • 绘制原始蒙版(可选) :如果 !maskCleared (用户未清除初始蒙版) 且原始蒙版图片路径 (maskImage) 有效,则会异步加载这个原始蒙版图片,并将其作为一个 Konva.Image 对象添加到 tempLayer。注意,这里加载的是未经预处理的原始蒙版,而不是之前在画布上显示的紫色蒙版。

    • 绘制用户编辑内容:遍历 paintLines 数组,将用户的每一笔画(线条或圆点)在 tempLayer 上重现。关键在于:

      • 所有“画笔”性质的涂抹(在界面上可能是紫色)都会被绘制成纯白色 (#FFFFFF)。
      • 所有“橡皮擦”性质的操作,其对应的 Konva 图形对象的 globalCompositeOperation 会被设置为 destination-out。这使得橡皮擦的轨迹能够“擦除”掉 tempLayer 上已经存在的内容(无论是黑色背景、原始蒙版还是之前绘制的白色笔迹),使得这些区域在最终导出的 PNG 图片中可能表现为透明(如果背景是透明的)或者显露出更底层的颜色(在这个场景中,是黑色背景,所以擦除后是黑色)。最终目标是生成一个主要由黑色和白色构成的蒙版。
  4. 等待绘制并导出:通过 Promise 确保所有(包括异步加载的原始蒙版)绘制操作在 tempStage 上完成后,调用 tempStage.toDataURL({ mimeType: 'image/png' }) 将整个 tempStage 的内容导出为一个 Base64 编码的 PNG 图片数据字符串。

  5. 转换为 File 对象:将 Data URL 字符串转换为一个标准的 File 对象,这是因为文件上传接口通常需要 File 对象或 Blob 对象。

  6. 上传文件:调用 uploadImageInternal (一个根据原代码片段中上传逻辑封装的辅助函数,它内部使用 OssApi.upload),将生成的蒙版 File 对象上传到服务器。上传成功后,会调用 updateParams('maskImage', res.data.path) 来更新父组件中记录的蒙版图片路径,并给出用户提示。

  7. 清理与收尾:在 finally 块中,设置 setUploading(false) 来结束加载状态,调用 onClose() 关闭当前的编辑弹窗,并销毁临时的 Konva Stage (tempStage.destroy())以释放资源。

通过这一系列步骤,handleSave 函数不仅准确地将用户的编辑意图(在黑色背景上用白色标记区域,用橡皮擦调整)转换成了一个新的蒙版图片,还完成了图片的上传和状态更新,形成了一个完整的闭环操作。这种在保存时重新合成图像的策略,确保了最终输出的蒙版是干净且符合预期格式的(例如,特定的背景色,以及将用户友好的显示颜色转换为标准的蒙版颜色)。

image.png

Snipaste_2025-05-18_19-47-06.jpg

代码组织与状态管理:构建可维护的编辑器组件

一个功能复杂的组件,其代码组织和状态管理的优劣直接影响到可维护性和可扩展性。在这个图片编辑组件 (ModalComponent) 中,虽然所有逻辑都集中在一个文件中,但通过 React Hooks 和合理的变量命名,仍然保持了一定的清晰度。我们来分析一下其代码组织和状态管理方面的一些特点和可以探讨的点。

1. 组件结构与 Props

该组件被设计为一个模态框 (ModalComponent),通过 Props 接收外部传入的必要数据和回调函数:

  • imagePath: 原始图片的路径。
  • maskImage: 初始蒙版的路径。
  • width, height: 图片的原始尺寸,这是进行各种坐标计算和画布初始化的基础。
  • jobDomain: 图片和蒙版资源所在的域名或基础路径。
  • updateParams: 一个回调函数,用于在保存新蒙版后,通知父组件更新蒙版图片的路径。
  • onClose: 一个回调函数,用于在操作完成或取消时关闭模态框。

这种接口设计使得组件具有较好的封装性,父组件只需关心输入和输出,无需了解内部复杂的实现细节。

2. 状态管理 (State Management)

组件的核心状态都通过 React useState Hook 进行管理。主要的状体包括:

  • 绘图相关状态

    • paintLines: 存储所有绘制操作(线条、圆点)的数组,是重绘离屏 Canvas 和实现历史记录的关键。
    • currentLine: 存储当前正在绘制的线条的点坐标。
    • isDrawing: 布尔值,标记当前是否处于绘制或拖拽状态。
    • tool: 字符串,表示当前选中的工具(如 "pen", "eraser", "move")。
    • penSize, eraserSize: 数字,分别表示画笔和橡皮擦的粗细。
    • maskCleared: 布尔值,标记初始蒙版是否已被用户清除。
  • 图片变换状态

    • imagePosition: 对象 { x, y },表示图片在画布容器中的偏移量(相对于原始未缩放状态)。
    • scaleRatio: 数字,表示图片的缩放比例(百分比)。
    • lastMousePosition: 对象 { x, y }null,用于图片拖拽时计算位移。
  • 历史记录状态

    • history: 数组,存储每个操作步骤的快照(paintLinesmaskSnapshot)。
    • historyStep: 数字,指向 history 数组中的当前步骤。
  • UI 与交互状态

    • uploading: 布尔值,标记是否正在上传保存的蒙版。
    • stageDimensions: 对象 { width, height },存储 Konva Stage 的实际渲染尺寸,用于响应式布局(尽管其更新逻辑在提供的代码中未完全展示如何动态适应容器变化)。
    • canvasCursorPos: 对象 { x, y }null,用于显示自定义光标或调试鼠标位置(代码中声明了但未见明显使用)。

使用 useState 管理这些状态使得组件的更新能够自动触发 React 的重新渲染,保证了数据与视图的同步。对于更复杂的应用,可能会考虑使用如 Redux, Zustand 或 React Context API 进行更集中的状态管理,但对于单个组件而言,useState 通常足够灵活。

3. 副作用处理 (Side Effects with useEffect)

useEffect Hook 在组件中被广泛用于处理各种副作用:

  • 初始化离屏 Canvas (drawingCanvasRef) :在组件挂载或图片尺寸变化时创建或更新离屏 Canvas 的尺寸。
  • 加载和预处理蒙版 (maskCanvasRef) :当 imagemask 图片加载成功后,进行蒙版的颜色处理并将其存储。
  • 重绘离屏 Canvas:当 paintLinesmaskCleared、图片尺寸或 maskCanvasRef.current 发生变化时,重新在 drawingCanvasRef.current 上绘制所有内容(蒙版 + 用户笔迹)。这是实现视觉更新的核心环节。
  • 初始化历史记录:在蒙版加载或画布首次创建时,生成历史记录的初始条目。
  • 监听图片加载useImage 本身就是处理图片加载副作用的 Hook,组件也通过 useEffect 监听 image 对象的变化来更新 stageDimensions

useEffect 的依赖项数组被精确设置,以确保副作用函数仅在必要时执行,避免不必要的计算和重绘。

4. Refs 的使用

useRef Hook 主要用于:

  • containerRef, sideToolRef, topToolRef: 获取 DOM 元素的引用,用于计算 Konva Stage 的可用尺寸。这体现了与 DOM 的直接交互,以实现动态布局。
  • stageRef: 获取 Konva Stage 实例的引用,用于调用 Stage 的方法(如 draw(), getStage(), toDataURL())。
  • maskCanvasRef, drawingCanvasRef: 持有离屏 Canvas 元素的引用。这些 Canvas 不是由 React 直接渲染到 DOM 中的,而是通过 JavaScript动态创建和操作,useRef 提供了在组件的多次渲染之间持久化这些引用的方式。

5. 代码组织与可读性

  • 函数划分:核心功能如鼠标事件处理 (handleMouseDown, handleMouseMove, handleMouseUp, handleWheel)、历史操作 (handleUndo, handleRedo)、清除 (clearCanvas)、保存 (handleSave) 等都被封装在独立的函数中,提高了代码的模块化程度。
  • 常量与变量命名:变量和函数名大多具有较好的自描述性,有助于理解代码意图。
  • 注释:代码中有一些注释,解释了部分逻辑,但对于复杂的算法(如缩放时的中心点保持、globalCompositeOperation 的运用),更详尽的注释会更有帮助。

可以进一步优化的思考点

  • 自定义 Hooks:一些相关的状态和逻辑(例如,处理图片缩放和移动的逻辑,或者历史记录管理的逻辑)可以被抽取到自定义 Hook 中,使主组件更简洁。
  • 组件拆分:工具栏、画布区域等可以考虑拆分为独立的子组件,各自管理其内部状态和逻辑,通过 Props 和回调与父组件通信。这对于更大型的应用尤为重要。
  • 常量管理:一些魔术数字(如缩放限制 25, 500;画笔颜色 rgba(114, 46, 209))可以定义为常量,提高可维护性。
  • 类型定义:虽然使用了 TypeScript (interface IProps, KonvaEventObject 等),但 paintLines 的类型是 any[],可以定义更精确的类型来描述线条和圆点对象的结构,增强类型安全。
  • 错误处理与用户反馈:代码中使用 sonner (toast) 进行了一些用户反馈,这是好的实践。可以进一步完善错误边界和更细致的错误提示。

总体而言,该组件在 React 的框架下,通过 Hooks 有效地组织了状态和副作用,实现了复杂的图片编辑功能。虽然存在一些可以进一步模块化和精细化的地方,但其核心逻辑清晰,是学习 React 与 Canvas 结合应用的一个很好的实例。

总结与展望:构建更强大的前端图形编辑器

本文深入剖析了一个基于 React 和 Konva.js 实现的高级图片编辑组件。我们从其核心技术栈出发,详细解读了图片与蒙版加载、预处理机制,探讨了如何利用离屏 Canvas 和 Konva.js 实现流畅的画笔与橡皮擦功能,分析了图片拖拽移动和中心缩放的算法,并揭示了基于状态快照的历史记录(撤销/重做)系统的构建方法。此外,我们还研究了画布清除以及最终蒙版生成与上传的完整流程,最后对组件的代码组织和状态管理策略进行了梳理。

通过这个实例,我们可以看到现代前端技术在构建复杂交互式应用方面的强大能力:

  • React 的声明式 UI 与组件化为构建可维护、可复用的用户界面提供了坚实基础,其 Hooks 系统(useState, useEffect, useRef)使得状态管理和副作用处理更为直观和灵活。
  • Konva.js 的专业图形处理能力简化了在 HTML5 Canvas 上的复杂图形操作、事件处理和性能优化,使得开发者可以更专注于实现核心编辑逻辑。
  • 离屏 Canvas 技术在处理频繁绘制操作时,作为一种有效的性能优化手段,能够显著提升用户体验。
  • 精巧的算法设计(如坐标转换、中心点保持缩放、globalCompositeOperation 的妙用)是实现精确、自然交互效果的关键。

主要收获与关键技术点回顾:

  1. 图片与蒙版处理:通过 useImage 加载图片,利用原生 Canvas API 进行像素级操作实现蒙版预处理。
  2. 高效绘图:结合离屏 Canvas 与 Konva Image 对象,实现高性能的画笔和橡皮擦功能,并通过 globalCompositeOperation 控制绘制模式。
  3. 交互式变换:精确的坐标计算实现了图片的自由拖拽和以画布中心为基准的平滑缩放。
  4. 可靠的历史记录:通过存储 paintLines 数据和离屏 Canvas 的 ImageData 快照,构建了稳健的撤销/重做体系。
  5. 结果输出:在保存时,通过临时 Konva Stage 重新合成图像,确保输出蒙版的准确性和格式规范,并结合异步上传流程完成闭环。

未来展望与功能拓展:

尽管该组件已经具备了相当完善的核心功能,但仍有许多可以拓展和优化的方向,使其成为一个更通用的、功能更强大的前端图形编辑器:

  • 更多绘图工具

    • 形状工具:如矩形、圆形、箭头、多边形等。
    • 文本工具:允许用户在图片上添加和编辑文字,支持字体、大小、颜色等设置。
    • 滤镜效果:如模糊、锐化、灰度、亮度/对比度调整等,可以利用 Canvas 的 filter 属性或 WebGL 实现。
  • 高级选择与编辑

    • 选区工具:如矩形选框、套索工具,允许用户选择特定区域进行操作。
    • 图层管理:引入类似 Photoshop 的图层概念,允许用户对不同元素进行独立编辑和层级调整。
  • 性能优化

    • 局部重绘:对于非常大的画布或非常复杂的操作,可以研究更精细的局部重绘策略,而不是每次都完整重绘离屏 Canvas。
    • WebGL 加速:对于某些计算密集型操作(如复杂滤镜、大量粒子效果),可以考虑引入 WebGL 进行硬件加速。
  • 用户体验提升

    • 实时光标预览:根据画笔/橡皮擦大小和形状,动态改变鼠标光标样式。
    • 更丰富的自定义选项:如画笔颜色选择器、透明度控制等。
    • 国际化与主题化:支持多语言,允许自定义编辑器界面风格。
  • 导出与集成

    • 多种导出格式:除了 PNG 蒙版,还可以支持导出为 JPG、SVG 或包含编辑状态的项目文件。
    • 与其他应用集成:提供更友好的 API,方便嵌入到各种内容管理系统、在线协作工具中。
  • 代码架构

    • 进一步模块化:将工具栏、画布、属性面板等拆分为更小的、独立的 React 组件或自定义 Hooks,提高代码的可维护性和可测试性。
    • 状态管理方案:对于更复杂的应用,可以引入 Zustand、Redux Toolkit 等状态管理库,或者更深入地使用 React Context API。

总而言之,前端图形编辑是一个充满挑战和机遇的领域。通过不断学习和实践,结合优秀的前端框架和图形库,我们可以构建出越来越强大、用户体验越来越出色的在线编辑工具。希望本文的解析能为你在这方面的探索提供一些有益的启示和参考。

面试之道——手写call、apply和bind

作者 哆啦美玲
2025年5月18日 18:30

嗨嗨嗨~这里是哆啦美玲分享的知识点,一起来学呀!

这次的文章基于我之前写的this的显式绑定的文章,有不懂的可以倒回去看看哦——搞懂this,如此简单 - 掘金

image.png

callapplybind 都是 JavaScript 中函数的调用方法,它们的作用是改变函数的上下文 (this) 和传递参数,但它们之间有一些不同点。

首先我们看下面这段代码:

let obj = {
    a: 1
}

function foo(x, y) {
    console.log(this.a, x + y);
    return 'hello'
}
console.log(foo(1, 2)); // undefined 3 hello

我们声明了一个对象obj和函数foo,在独立调用foo时,this指向的是全局,所以this.a会返回undefiend。那我们如何实现this指向obj呢?

一、call

1. call的特点

  • call 方法立即调用一个函数,并且可以指定 this 的值,同时传入参数。
  • 参数是按顺序传递的,多个参数使用逗号分隔。
const res = foo.call(obj, 3, 4) // call的this指向foo,foo的this指向obj
console.log(res); // 1 7 hello

2. 手写myCall(context, ...args)

在手写myCall之前我们需要分析:myCall写哪里? image.png 如图的代码结果,foo既是函数也是对象,但是使用对象obj调用call方法会报错:call is not a function,所以myCall方法写在构造函数Function的原型(Function.prototype)。

因为函数也是对象,所以foo.call()会导致call的this指向foo;call的执行会让obj调用foo,让foo的this指向obj;

foo在声明前需要多少参数call并不清楚,所以call可以使用...args的形式接收剩余参数;另外,我们写的foo是有返回值的,在call的调用后会返回foo的结果,所以我们手写call的时候也要写返回值。

我们再看,如果call传入的参数中第一位不是对象,会得到什么? image.png 从图上代码的输出结果可以看出来:call会把传入的参数除去第一位后按顺序交给foo,且第一位必须是对象才能改变this的指向。

根据分析,手写代码如下:

// 手写call
Function.prototype.myCall = function (context, ...args) { // foo.__proto__ == Function.prototype
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window

    const key = Symbol('fn') // 唯一的key
    context[key] = this // this = foo
    const res = context[key](...args) // 隐式绑定 this = context
    delete context[key]
    return res
}
console.log(foo.myCall(obj, 2, 3)); // 1 5 hello

代码第6行 context = context || window 的意思是:如果 context 变量已经有值(即不是 null 或 undefined),那么就使用 context 的值;如果 context 没有值(即为 null 或 undefined),就使用 window 作为默认值。

代码第8-9行是给foo函数创建一个唯一的key值,确保不会修改掉函数内部原本的属性值。

最后为了不修改原对象,要记得把新增的属性删除!

二、apply

1. apply的特点

  • apply方法也立即调用一个函数,并指定 this 的值,同时传入参数。
  • 参数传递方式是将一个数组或类数组对象作为参数列表传入。
const res1 = foo.apply(obj, [3, 4]) // apply的this指向foo, foo的this指向obj
console.log(res1); // 1 7 hello

2. 手写myApply

apply与call的用法是一样的,唯一不同的就是接收的参数不一样,所以只需要修改一点点就可以了,代码如下:

// 手写apply
Function.prototype.myApply = function (context, args) { // foo.__proto__ == Function.prototype
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window

    const key = Symbol('fn')
    context[key] = this // this = foo
    const res = context[key](...args) // 隐式绑定 this = context
    delete context[key]
    return res
}

console.log(foo.myApply(obj, [2, 4])); // 1 6 hello

三、bind

1. bind的特点

  • bind方法并不立即调用函数,而是返回一个新的函数,新的函数会绑定指定的 this 和参数。
  • 这个返回的新函数可以在之后的某个时刻被调用,且新函数也可以接收零散参数。
  • 当新函数被 new 调用时, 返回的是调用 bind 的那个函数的实例对象
const fn = foo.bind(obj, 4)
const res2 = fn(4)
console.log(res2); // 1 8 hello

const f = new fn(4)
console.log(f); // undefined 8 foo {}

从代码中可以看出:bind函数调用时,foo函数在接收参数时会先在bind传入的参数里面按顺序找,如果不够再去找bind返回的新函数f传入的参数找。

另外,在代码的5-6行我们会发现,在new fn()时,本来应该返回一个fn的实例对象fn{},但实际返回的却是foo的实例对象foo{},并且foo中的this指向了全局,所以在new的过程会导致this指向全局,fn()执行返回foo的实例对象。

2. 手写myBind

bind与前面两个方法很不一样,第一需要注意的点就是调用bind后会返回一个新的函数体。

接下来我们看看:如果bind传入的第一个不是参数,新函数会是什么? image.png

从图中的结果可知:foo.bind(123)返回的是一个foo的实例对象,所以this指向的是全局。

另外,在前面我们已经说了如果我们new foo.bind(obj)得到的新函数,也会得到一个foo的实例对象。所以bind返回的函数体不能是箭头函数,因为箭头函数里面没有this,不能被new。

这就需要我们区分new fn()fn():因为new会使函数的this直接指向得到的实例对象,且让实例对象的隐式原型等于构造函数的显式原型,所以我们采用判断this.__proto__ === F.prototype来判断是否被new。

代码如下:

Function.prototype.myBind = function (context, ...args) {
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window
    const self = this // 存储this的值 foo

    return function F(...args2) { 
        if(this.__proto__ === F.prototype){ // 被 new 直接返回 foo 实例,所以这里不能是箭头函数
            return new self(...args, ...args2) // 返回foo 的实例对象
        }else{
            return self.apply(context, [...args, ...args2])  // foo指向obj,foo执行且返回值接收并返回
        }
    }
}

const fo = foo.myBind(obj, 1, 2)
console.log(fo(),'//////'); // 1 3 hello //////

const fun = new fo()
console.log( 'new fo() 得到的结果:', fun); // undefined 3  new fo() 得到的结果:foo {}

好啦,本次知识点分享完毕,家人们下次见!!!

喜欢这次的文章就麻烦点个赞赞啦~谢谢大家!

image.png

tauri2项目动态添加 Sidecar可行性方案(运行时配置)

作者 1024小神
2025年5月18日 18:28

tauri2官方文档:Embedding External Binaries | Tauri

Tauri 的 Sidecar 功能允许你将外部二进制文件(External Binaries)与你的 Tauri 应用程序捆绑在一起,并在运行时调用它们。根据你提供的链接和 Tauri 的文档,以下是关于 Sidecar 路径配置和动态添加的解答:


1. Sidecar 的路径配置方式

在 Tauri 中,Sidecar 二进制文件的路径可以通过以下方式配置:

  • 固定路径(编译时绑定)
    在 tauri.conf.json 中直接指定二进制文件的路径,这些路径会在编译时解析,并打包到最终应用中。例如:

    <pre>
    

    { "tauri": { "bundle": { "sidecar": [ { "path": "/path/to/your/binary", // 可以是绝对路径或相对路径 "name": "my-binary" // 运行时调用的名称 } ] } } }

    <p>这种方式适合那些<strong>已知且不会变动</strong>的二进制文件。</p>
    </li>
    <li>
    <p><strong>环境变量或相对路径</strong><br />
    你可以通过环境变量动态设置路径(需在编译时能解析),例如:</p>
    
    <pre>
    

    "path": "${ENV_VAR_NAME}/subpath/binary"


2. 动态添加 Sidecar(运行时配置)

Tauri 默认不支持在运行时动态从配置文件(如外部的 config.json)添加或删除 Sidecar。原因是:

  1. 安全限制:Sidecar 的路径在应用构建时会被硬编码到打包结果中(尤其是 macOS 的 .app 或 Windows 的安装包)。
  2. 签名验证:某些平台(如 macOS)要求所有二进制文件在打包时签名,动态加载未签名的二进制文件可能导致应用被拒绝运行。

替代方案

如果你需要动态调用外部二进制文件,可以考虑以下方法:

方案 1:通过配置文件调用外部二进制(非 Sidecar)

  • 将二进制文件放在可执行文件同级目录(如 resources/)或用户指定路径。
  • 使用 Tauri 的 Command API 直接调用这些二进制文件(而不是通过 Sidecar):
    import { Command } from '@tauri-apps/api/shell';
    const binaryPath = await loadPathFromConfig(); // 从 config.json 读取路径
    const command = Command.sidecar(binaryPath, [args]);
    const output = await command.execute();
    
    <blockquote>
    <p><strong>注意</strong>:这种方式需要手动处理二进制文件的路径解析和平台兼容性(如 Windows 的&nbsp;<code>.exe</code>&nbsp;后缀)。</p>
    </blockquote>
    </li>
    

方案 2:预置多个 Sidecar,按需启用

  • 在 tauri.conf.json 中预定义所有可能的 Sidecar 二进制文件。
  • 通过应用逻辑决定调用哪一个(例如根据配置文件选择):
    const command = Command.sidecar('binary-name-from-config', [args]);
    

方案 3:动态下载二进制文件

  • 在应用启动时从远程服务器或本地 config.json 中获取二进制文件的 URL 或路径。
  • 下载并保存到用户目录(如 appDataDir),然后用 Command 调用。

关键限制

  • 安全策略:动态加载外部二进制文件可能触发安全警告(尤其是 macOS 的 Gatekeeper 或 Windows Defender)。
  • 打包约束:Sidecar 的路径必须在构建时确定,无法在运行时从完全任意的路径加载。

总结

  • 直接动态添加 Sidecar:不支持,因 Tauri 的设计和平台限制。
  • 推荐替代方案
    使用 Command API 调用外部二进制文件,或预置多个 Sidecar 通过逻辑选择。

如果你的需求是“用户自定义插件式二进制”,可能需要自行实现二进制管理逻辑(如下载、验证、路径解析等)。

从浏览器进程层面理解事件循环

2025年5月18日 17:49

现代浏览器的进程架构

进程之间是独立存在的,因此为了防止浏览器 tab 连续崩坏的情况,如今已经演变成了多进程架构,每个进程都会有个独立的内存空间

一般就是下面这六个进程:

  1. 浏览器主进程(Browser Process)
  • 负责管理浏览器的界面显示(比如导航栏)、用户交互、子进程管理
  • 处理书签、历史记录、下载等功能
  • 协调其他进程
  1. 渲染进程(Renderer Process)
  • 负责网页内容的渲染
  • 每个标签页通常会有自己的渲染进程(沙箱隔离)
  • 包含多个线程:
    • 主线程:处理JavaScript执行、DOM解析、CSS计算等
    • 合成线程:处理图层合成
    • 工作线程:处理Web Workers
    • 光栅化线程:将图形转换为位图
  1. GPU进程(GPU Process)
  • 处理GPU任务,加速图形渲染
  • 负责将渲染进程的输出合成并显示到屏幕上
  1. 网络进程(Network Process)
  • 处理网络请求
  • 实现HTTP/HTTPS协议
  • 管理缓存
  1. 插件进程(Plugin Process)
  • 运行浏览器插件(如Flash)
  • 每个插件通常有自己的进程
  1. 实用程序进程(Utility Process)
  • 处理一些辅助功能,如音频服务、打印等

1.png 在 windows 下,我们可以通过快捷键 Ctrl + Shift + Esc 来打开 windows 的实时进程,其实浏览器也有对应的界面,我们可以通过 Shift + Esc 来打开浏览器的实时进程

2.png

比如我这里开了两个 tab,首先每个 tab 都会存在一个独立的进程,这个进程就是我们说的渲染进程,图中可以看到 第一个 Chrome 图标的那个就是浏览器的主进程,第二个为 GPU 进程,第三个为 网络进程,第四个为 实用程序进程,然后后面几个 service worker 其实就是后台的特殊进程

其实早在 2018年,Chrome 就已经更新了一个名为 站点隔离(Site Isolation)的机制,这意味着不再是简单地一个标签页一个进程,而是按照网站的源(协议 + 域名 + 端口)来分配进程,这么做对于很多用户来讲应该可以很大程度减少 Chrome 内存占用,像是开发过程中你可能 csdn 会有很多 tab 同时存在,不过Chrome 会够根据用户的硬件设备调整不同的进程架构,像是站点隔离在内存受限的设备上就不会生效

实用程序进程这里可以看到是个 storage service ,这就是他处理存储的功能,当我们看 B站 视频时,就会存在一个 audio service,就说明在发挥它的 音频 功能

六个进程中我们了解大概就差不多了,但是渲染进程我们需要单独聊聊,这对前端仔来讲还是非常重要的

因为我们的前端代码均是在这个进程程执行的

渲染进程

渲染进程负责将 HTML,CSS 和 JS 转换为用户可以看到的网页内容和交互

渲染进程核心的职责就是我们熟知的下面五个步骤:

  1. 解析HTML和CSS:将HTML转换为DOM树,将CSS转换为CSSOM树
  2. 执行JavaScript:运行页面中的JavaScript代码
  3. 布局计算:确定每个元素在屏幕上的确切位置和大小
  4. 绘制:将元素绘制到内存中的位图
  5. 合成:将不同的绘制层合成为最终显示的图像

若将渲染进程进行线程拆分,那么它主要是靠三个线程:主线程、合成线程和光栅线程

还有 工作线程(Web Workers),定时器线程,事件触发线程

3.png 我们的前端代码其实就是在 渲染进程中的 主线程 执行的,接下来我们引入事件循环来结合讲解

渲染主线程

渲染主线程负责渲染进程的大部分工作,所以它需要处理许多任务,执行 js 是它,绘制页面又是它,可能发生性能瓶颈,说 js 代码阻塞其实就是因为渲染主线程只有一个,无法同时处理两份属于自己的工作,渲染主线程的主要工作如下:

  • 执行 JS 代码
  • 处理 DOM 操作
  • 处理用户事件(如 Click)
  • 处理计时器回调
  • 处理网络请求回调
  • 执行微任务和宏任务

这个时候其实你可能会很好奇为何渲染主线程要做的东西这么多,也确实容易出现问题,那他为何不分配一些新的线程来做呢,比如专门给一个 js 线程来执行 js 代码,专门一个 dom 线程来处理 dom 操作

其实这个问题会有很多原因,其中我们最容易理解的就是因为 js 可以操作 dom,多个线程同时修改 dom 会导致难以预测的竞态条件;还有就是历史原因,js 最初就是个单线程语言,web 本身就非常向后兼容,改变这个线程模型就会破坏现有网站;再一个就是实际上浏览器已经采取了多线程优化方案,比如 WebWorker 新开了一个线程执行 js,但是这些 js 又不能直接访问 dom

事件循环(event-loop / message-loop)我们已经很熟悉了,但是想要真正理解透彻我们应该将 UI 渲染这一步骤结合进来

事件循环出来的目的也就是因为 js 执行变得复杂,此前单线程的原因并没有考虑这个问题,后来才逐步引入的机制,比如一个 for 循环很多次,执行的过程中某个定时器的回调也到了时间应该如何调度呢,如何调度其实就是事件循环,通俗理解这个东西就是让任务之间进行排队

事件循环在主线程上的工作流程如下:

  1. 执行当前的 JS 调用栈中的所有同步代码
  2. 检查微任务队列(microtask queue),执行所有微任务直到队列清空
  3. 执行一个宏任务(macrotask)
  4. 再次检查微任务队列,执行所有微任务
  5. 如果需要,执行UI渲染
  6. 返回步骤3,继续循环

我们再来复习下常见的宏微任务有哪些:

微任务

  • Promise回调(.then/.catch/.finally)
  • MutationObserver回调
  • queueMicrotask() API
  • 处理优先级较高,在每个宏任务之后立即执行

宏任务

  • Script 标签
  • setTimeout/setInterval回调
  • 用户交互事件(点击、键盘输入等)
  • 网络请求回调(XHR, fetch)
  • MessageChannel
  • requestAnimationFrame
  • I/O操作

也许有人会争 宏任务比 同步先执行,这么说也是对的,因为本身 Script 就是个宏任务

4.png 执行 js 代码过程中,不一定会是当前执行代码所引入的 回调 进入消息队列,也有可能来自浏览器进程监听的用户交互,比如 click 事件,浏览器进程虽然不会执行 js 代码,js 执行是在渲染进程的渲染主线程中,浏览器进程可以监听事件然后放入消息队列,js 再从消息队列拿回调来执行

所以我们现在可以明白浏览器如何处理这些事件的,我们可以临时往消息队列中塞任务,但是执行就不一定是立即执行,因为他需要先等当前的调用栈执行完毕后,再依次检查消息队列中前面的事件是否执行完毕再去执行这个临时加的任务

5.png

event-loop 就是为了解决 js 单线程问题,异步的目的就是为了不让页面卡死,因为渲染这个关键步骤也是在 event-loop 之中或者说由渲染主线程负责

消息队列的优先级

消息队列可以理解为任务队列,我们在面试时常常会说宏任务队列,微任务队列,但是大家也清楚,其实宏任务这个概念官方已经抛弃了,此前宏任务就是 macrotask,现在官方换成了 task ,就是一个 任务,微任务依旧为 microtask

我也不清楚为何要换个名字,其实 task 和 macrotask 没有本质区别,因此我们完全可以沿用这个宏任务概念

对于 js 而言,任务是没有优先级的,它只管从 消息队列 依次去获取任务执行,这个任务就是回调,其实每个任务都会被浏览器包装成一个对象或者说是一个块

但是消息队列是具有优先级的,task queue 其实就是宏任务队列,因为浏览器的复杂程度越来越高,宏任务队列其实会被划分成不同的队列,有可能定时器会专门有一个定时器的队列,然后事件回调会有一个事件回调的队列,ui 渲染会专门有一个 渲染队列,这些队列具有优先级,另外,每种任务其实会专门放到对应的任务队列中,但是其实也可以放到其他不同的任务队列,比如监听事件的回调可以放到定时器队列,但是这样做后,它就不能放到其余的队列中了

这是 w3c 给出的规定,同一个类型必须在同一个队列,也可以分属不同的队列

宏任务细分下去的任务队列优先级其实不需要我们关注,我们只需要清楚微任务队列的优先级永远是最高的

不过对于宏任务队列而言,一般用户交互的事件队列是最高级的,比如点击,键盘,鼠标,其次再是 ui 渲染,这个也很好理解,为用户体验着想,总不可能用户点击后这个任务还有延迟执行吧,后面的优先级详细内容请看下面图示

6.png

有个地方需要特别留意,微任务优先级最高,这就意味着 高于 了 ui 渲染这个队列

另外,这里也可以看到定时器哪怕在宏任务队列中都是优先级较低的存在,就算不管微任务队列这个最高优先级,他的执行都是靠后的,因此他的计时肯定是不准的,因为他的回调需要等待前面的任务执行完毕才能继续执行,另外在 w3c 中有个 规定,定时器嵌套(nesting)层级超过了 5 层,后面的定时器就会增加 4 ms 的误差,其实浏览器的定时器实现本身就是调用的操作系统,操作系统本身的计时也是存在误差的,这点无法避免,真正准时的永远是原子钟

最后

总结下本文主要内容

浏览器有很多进程,但是对于前端仔来讲主要关注渲染进程,这个进程其实主要发挥作用的又是渲染主线程,由于前端代码都是在这个进程运行的,因此可以说 js 是个单线程语言,渲染其实也是个 task queue 类型,他的优先级还是比较高的,因此在一轮 event-loop 结束后,就是 ui 渲染线程开始执行

2025 年应该怎样初始化 Angular 项目

2025年5月18日 17:12

Angular 在近两年发布了多个版本,不断更新和改进。如果你是一个 Angular 开发者,你可能会发现 Angular 的生态系统变得越来越庞大,新的工具和技术层出不穷。那么,如果你要开始一个新的 Angular 项目,你应该使用哪些工具和技术呢?

初始化 Angular 项目

首先推荐使用 pnpm。

使用 pnpm 创建一个新的 Angular 项目:

pnpm create @angular@latest --experimental-zoneless --ssr false --style scss [yourProjectName]

@angular/create 也是 Angular 官方提供的脚手架工具,不需要全局安装 Angular CLI,就可以创建 Angular 应用,并且支持所有 ng new 的选项和功能。

  • --experimental-zoneless 参数表示使用 zoneless 模式
  • --ssr false 表示不启用服务端渲染
  • --style scss 表示使用 SCSS 作为样式预处理器。

创建完成之后在 angular.json 中添加 "changeDetection": "OnPush",具体路径位于 projects.[yourProjectName].schematics.@schematics/angular:component

UI

接下来是选择 UI 组件库。Angular 的 UI 组件库有很多,最常用的有:

这里推荐使用 Angular Material,因为它是 Angular 官方提供的 UI 组件库,和 Angular 的生态系统兼容性最好,文档也很完善。

pnpm ng add @angular/material

除了组件库之外,我们还希望配置 Tailwind CSS 来编写样式。Tailwind CSS 是一个功能类优先的 CSS 框架,允许我们使用类名来构建样式,在 Angular 中使用也非常简单。

pnpm install tailwindcss @tailwindcss/postcss postcss

在项目根目录下创建 .postcssrc.json 文件,内容如下:

{
  "plugins": {
    "@tailwindcss/postcss": {}
  }
}

添加 @import "tailwindcss";src/styles.scss 文件中。

Linting

代码规范和格式化是团队开发中非常重要的一部分。我们使用 angular-eslintprettierhuskylint-staged 来实现代码规范和格式化。

首先安装 angular-eslint

pnpm ng add angular-eslint

angular-eslint 会自动为我们配置 ESLint。

接下来安装 prettierhuskylint-staged

pnpm i -D husky prettier lint-staged

简单说一下这几个工具的作用:

  • prettier:代码格式化工具,可以自动格式化代码,在代码提交之前通过 husky 钩子自动运行。
  • husky:Git 钩子工具,可以在 Git 提交和推送时执行一些脚本,比如运行 ESLint 和 Prettier。
  • lint-staged:可以在 Git 提交时只检查和格式化暂存区的文件,避免检查和格式化所有文件,提高效率。

初始化 husky

pnpm husky init

这会在项目根目录下创建一个 .husky 文件夹,并在其中创建一个 pre-commit 钩子文件。

创建 .lintstagedrc 文件,内容如下:

{
  "*.{js,ts,json,html,scss,css,md}": ["prettier --write"]
}

.husky/pre-commit 文件中添加以下内容:

pnpm ng lint
pnpm ng test --watch=false
pnpm lint-staged --allow-empty

这样在提交代码时会自动运行 ESLint 和 Prettier,并且 Prettier 只格式化暂存区的文件。

❌
❌