2024年9月

前言

分布式ID,在我们日常的开发中,其实使用的挺多的。

有很多业务场景在用,比如:

  1. 分布式链路系统的trace_id
  2. 单表中的主键
  3. Redis中分布式锁的key
  4. 分库分表后表的id

今天跟大家一起聊聊分布式ID的一些常见方案,希望对你会有所帮助。

1 UUID

UUID (Universally Unique IDentifier) 通用唯一识别码 ,也称为 GUID (Globally Unique IDentifier) 全球唯一标识符。

UUID是一个长度为128位的标志符,能够在时间和空间上确保其唯一性。

UUID最初应用于Apollo网络计算系统,随后在Open Software Foundation(OSF)的分布式计算环境(DCE)中得到应用。

可让分布式系统可以不借助中心节点,就可以生成唯一标识, 比如唯一的ID进行日志记录。

UUID是基于时间戳、MAC地址、随机数等多种因素生成,理论上全球范围内几乎不可能重复。

在Java中可以通过UUID的randomUUID方法获取唯一字符串:

import java.util.UUID;

/**
 * @author 苏三
 * @date 2024/9/13 上午10:38
 */
public class UuidTest {
    public static void main(String[] args) {
        String uuid = UUID.randomUUID().toString();
        System.out.println(uuid);
    }
}

运行结果:

22527933-d0a7-4c2b-a377-aeb438a31b02

优点:UUID不借助中心节点,可以保持程序的独立性,可以保证程序在不同的数据库之间,做数据迁移,都不受影响。

缺点:UUID生成的字符串太长,通过索引查询数据的效率比较低。此外,UUID生成的字符串,顺序没有保证,不是递增的,不满足工作中的有些业务场景。

在分布式日志系统或者分布式链路跟踪系统中,可以使用UUID生成唯一标识,用于串联请求的日志。

2 数据库自增ID

在很多数据库中自增的主键ID,数据库本身是能够保证唯一的。

MySQL中的auto_increment。

Oracle中sequence。

我们在业务代码中,不需要做任何处理,这个ID的值,是由数据库自动生成的,并且它会保证数据的唯一性。

优点:非常简单,数据查询效率非常高。

缺点:只能保证单表的数据唯一性,如果跨表或者跨数据库,ID可能会重复。ID是自增的,生成规则很容易被猜透,有安全风险。ID是基于数据库生成的,在高并发下,可能会有性能问题。

在一些老系统或者公司的内部管理系统中,可能会用数据库递增ID作为分布式ID的方案,这些系统的用户并发量一般比较小,数据量也不多。

3 数据库号段模式

在高并发的系统中,频繁访问数据库,会影响系统的性能。

可以对数据库自增ID方案做一个优化。

一次生成一定步长的ID,比如:步长是1000,每次数据库自增1000,ID值从100001变成了101001。


将100002~101001这个号段的1000个ID,缓存到服务器的内存从。

当有获取分布式ID的请求过来时,先从服务器的内存中获取数据,如果能够获取到,则直接返回。

如果没有获取到,则说明缓存的号段的数据已经被获取完了。

这时需要重新从数据库中获取一次新号段的ID,缓存到服务器的内存中,这样下次又能直接从内存中获取ID了。

优点:实现简单,对数据库的依赖减弱了,可以提升系统的性能。

缺点:ID是自增的,生成规则很容易被猜透,有安全风险。如果数据库是单节点的,有岩机的风险。

4 数据库的多主模式

为了解决上面单节点岩机问题,我们可以使用数据库的多主模式。

即有多个master数据库实例。

在生成ID的时候,一个请求只能写入一个master实例。

为了保证在不同的master实例下ID的唯一性,我们需要事先规定好每个master下的大的区间,比如:master1的数据是10开头的,master2的数据是11开头的,master3的数据是12开头的。

然后每个master,还是按照数据库号段模式来处理。

优点:避免了数据库号段模式的单节点岩机风险,提升了系统的稳定性,由于结合使用了号段模式,系统性能也是OK的。

缺点:跨多个master实例下生成的ID,可能不是递增的。

5 Redis生成ID

除了使用数据库之外,Redis其实也能产生自增ID。

我们可以使用Redis中的incr命令:

redis> SET ID_VALUE 1000
OK

redis> INCR ID_VALUE
(integer) 1001

redis> GET ID_VALUE 
"1001"

给ID_VALUE设置了值是1000,然后使用INCR命令,可以每次都加1。

这个方案跟我们之前讨论过的方案1(数据库自增ID)的方案类似。

优点:方案简单,性能比方案1更好,避免了跨表或者跨数据库,ID重复的问题。

缺点:ID是自增的,生成规则很容易被猜透,有安全风险。并且Redis可能也存在单节点,岩机的风险。

6 Zookeeper生成ID

Zookeeper主要通过其znode数据版本来生成序列号,可以生成32位和64位的数据版本号,客户端可以使用这个版本号来作为唯一的序列号。

由于需要高度依赖Zookeeper,并且是同步调用API,如果在竞争较大的情况下,需要考虑使用分布式锁。

因此,性能在高并发的分布式环境下,也不太理想。

很少人会使用Zookeeper来生成唯一ID。

7 雪花算法

Snowflake(雪花算法)是Twitter开源的分布式ID算法。

核心思想:使用一个 64 bit 的 long 型的数字作为全局唯一 id。

最高位是符号位,始终为0,不可用。

41位的时间序列,精确到毫秒级,41位的长度可以使用69年。时间位还有一个很重要的作用是可以根据时间进行排序。

10位的机器标识,10位的长度最多支持部署1024个节点

12位的计数序列号,序列号即一系列的自增id,可以支持同一节点同一毫秒生成多个ID序号,12位的计数序列号支持每个节点每毫秒产生4096个ID序号。

优点:算法简单,在内存中进行,效率高。高并发分布式环境下生成不重复ID,每秒可生成百万个不重复ID。
基于时间戳,以及同一时间戳下序列号自增,基本保证ID有序递增。并且不依赖第三方库或者中间件,稳定性更好。

缺点:依赖服务器时间,服务器时钟回拨时可能会生成重复ID。

8 Leaf

Leaf是美团开源的分布式ID生成系统,它提供了两种生成ID的方式:

  • Leaf-segment号段模式
  • Leaf-snowflake雪花算法

Leaf-segment号段模式,需要创建一张表:

这个模式就是我们在第3节讲过的数据库号段模式。

biz_tag用来区分业务,max_id表示该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。

原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。

Leaf-snowflake雪花算法,是在传统雪花算法之上,加上Zookeeper,做了一点改造:

Leaf-snowflake服务需要从Zookeeper按顺序的获取workId,会缓存到本地。

如果Zookeeper出现异常,Leaf-snowflake服务会直接获取本地的workId,它相当于对Zookeeper是弱依赖的。

因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,它内部有一套机制解决机器时钟回拨的问题:

如果你想知道美团Leaf的更多细节,可以看看Github地址:
https://github.com/Meituan-Dianping/Leaf

最近整理了一份10万字的面试宝典,可以免费送给大家,获取方式加我微信:su_san_java,备注:面试。

9 Tinyid

Tinyid是滴滴用Java开发的一款分布式id生成系统,基于数据库号段算法实现。

Tinyid是在美团的ID生成算法Leaf的基础上扩展而来,支持数据库多主节点模式,它提供了REST API和JavaClient两种获取方式,相对来说使用更方便。

但跟美团Leaf不同的是,Tinyid只支持号段一种模式,并不支持Snowflake模式。

基于数据库号段模式的简单架构方案:

ID生成系统向外提供http服务,请求经过负载均衡router,能够路由到其中一台tinyid-server,这样就能从事先加载好的号段中获取一个ID了。

如果号段还没有加载,或者已经用完了,则需要向db再申请一个新的可用号段,多台server之间因为号段生成算法的原子性,而保证每台server上的可用号段不重,从而使id生成不重。

但也带来了这些问题:

  • 当id用完时需要访问db加载新的号段,db更新也可能存在version冲突,此时id生成耗时明显增加。
  • db是一个单点,虽然db可以建设主从等高可用架构,但始终是一个单点。
  • 使用http方式获取一个id,存在网络开销,性能和可用性都不太好。

为了解决这些这些问题:增加了tinyid-client本地生成ID、使用双号段缓存、增加多 db 支持提高服务的稳定性。

最终的架构方案如下:

Tinyid方案主要做了下面这些优化:

  • 增加tinyid-client:tinyid-client向tinyid-server发送请求来获取可用号段,之后在本地构建双号段、id生成,如此id生成则变成纯本地操作,性能大大提升。
  • 使用双号段缓存:为了避免在获取新号段的情况下,程序获取唯一ID的速度比较慢。Tinyid中的号段在用到一定程度的时候,就会去异步加载下一个号段,保证内存中始终有可用号段。
  • 增加多db支持:每个DB都能生成唯一ID,提高了可用性。

如果你想知道滴滴Tinyid的更多细节,可以看看Github地址:
https://github.com/didi/tinyid

10 UidGenerator

百度 UID-Generator 使用 Java 语言,基于雪花算法实现。

UidGenerator以组件形式工作在应用项目中, 支持自定义workerId位数和初始化策略, 从而适用于docker等虚拟化环境下实例自动重启、漂移等场景。

在实现上, UidGenerator通过借用未来时间来解决sequence天然存在的并发限制。

采用RingBuffer来缓存已生成的UID, 并行化UID的生产和消费, 同时对CacheLine补齐,避免了由RingBuffer带来的硬件级「伪共享」问题. 最终单机QPS可达600万。

Snowflake算法描述:指定机器 & 同一时刻 & 某一并发序列,是唯一的。据此可生成一个64 bits的唯一ID(long)。默认采用上图字节分配方式:

  • sign(1bit):固定1bit符号标识,即生成的UID为正数。
  • delta seconds (28 bits) :当前时间,相对于时间基点"2016-05-20"的增量值,单位:秒,最多可支持约8.7年
  • worker id (22 bits):机器id,最多可支持约420w次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。
  • sequence (13 bits):每秒下的并发序列,13 bits可支持每秒8192个并发。

sequence决定了UidGenerator的并发能力,13 bits的 sequence 可支持 8192/s 的并发,但现实中很有可能不够用,从而诞生了 CachedUidGenerator。

CachedUidGenerator 使用 RingBuffer 缓存生成的id。RingBuffer是个环形数组,默认大小为 8192 个(可以通过boostPower参数设置大小)。

RingBuffer环形数组,数组每个元素成为一个 slot。

Tail 指针、Cursor 指针用于环形数组上读写 slot:

  • Tail指针:表示 Producer 生产的最大序号(此序号从 0 开始,持续递增)。Tail 不能超过 Cursor,即生产者不能覆盖未消费的 slot。当 Tail 已赶上 curosr,此时可通过 rejectedPutBufferHandler 指定 PutRejectPolicy。
  • Cursor指针:表示 Consumer 消费到的最小序号(序号序列与 Producer 序列相同)。Cursor 不能超过 Tail,即不能消费未生产的 slot。当 Cursor 已赶上 tail,此时可通过 rejectedTakeBufferHandler 指定 TakeRejectPolicy。

RingBuffer填充触发机制:

  • 程序启动时,将RingBuffer填充满。
  • 在调用getUID()方法获取id时,如果检测到RingBuffer中的剩余id个数小于总个数的50%,将RingBuffer填充满。
  • 定时填充(可配置是否使用以及定时任务的周期)。

如果你想知道百度uid-generator的更多细节,可以看看Github地址:
https://github.com/baidu/uid-generator

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

前言

在Vue3.5版本中
响应式 Props 解构
终于正式转正了,这个功能之前一直是
试验性
的。这篇文章来带你搞清楚,一个String类型的props经过解构后明明应该是一个常量了,为什么还没丢失响应式呢?本文中使用的Vue版本为欧阳写文章时的最新版
Vue3.5.5

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

看个demo

我们先来看个解构props的例子。

父组件代码如下:

<template>
  <ChildDemo name="ouyang" />
</template>

<script setup lang="ts">
import ChildDemo from "./child.vue";
</script>

父组件代码很简单,给子组件传了一个名为
name
的prop,
name
的值为字符串“ouyang”。

子组件的代码如下:

<template>
  {{ localName }}
</template>

<script setup lang="ts">
const { name: localName } = defineProps(["name"]);
console.log(localName);
</script>

在子组件中我们将
name
给解构出来了并且赋值给了
localName
,讲道理解构出来的
localName
应该是个常量会丢失响应式的,其实不会丢失。

我们在浏览器中来看一下编译后的子组件代码,很简单,直接在network中过滤子组件的名称即可,如下图:
network

从上面可以看到原本的
console.log(localName)
经过编译后就变成了
console.log(__props.name)
,这样当然就不会丢失响应式了。

我们再来看一个另外一种方式解构的例子,这种例子解构后就会丢失响应式,子组件代码如下:

<template>
  {{ localName }}
</template>

<script setup lang="ts">
const props = defineProps(["name"]);
const { name: localName } = props;
console.log(localName);
</script>

在上面的例子中我们不是直接解构
defineProps
的返回值,而是将返回值赋值给
props
对象,然后再去解构
props
对象拿到
localName

network2

从上图中可以看到这种写法使用解构的
localName
时,就不会在编译阶段将其替换为
__props.name
,这样的话
localName
就确实是一个普通的常量了,当然会丢失响应式。

这是为什么呢?为什么这种解构写法就会丢失响应式呢?别着急,我接下来的文章会讲。

从哪里开下手?

既然这个是在编译时将
localName
处理成
__props.name
,那我们当然是在编译时debug了。

还是一样的套路,我们在vscode中启动一个
debug
终端。
debug-terminal

在之前的
通过debug搞清楚.vue文件怎么变成.js文件
文章中我们已经知道了
vue
文件中的
<script>
模块实际是由
vue/compiler-sfc
包的
compileScript
函数处理的。

compileScript
函数位置在
/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js

找到
compileScript
函数就可以给他打一个断点了。

compileScript函数


debug
终端上面执行
yarn dev
后在浏览器中打开对应的页面,比如:
http://localhost:5173/
。此时断点就会走到
compileScript
函数中。

在我们这个场景中简化后的
compileScript
函数代码如下:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const scriptSetupAst = ctx.scriptSetupAst;

  // 2.2 process <script setup> body
  for (const node of scriptSetupAst.body) {
    if (node.type === "VariableDeclaration" && !node.declare) {
      const total = node.declarations.length;
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i];
        const init = decl.init;
        if (init) {
          // defineProps
          const isDefineProps = processDefineProps(ctx, init, decl.id);
        }
      }
    }
  }

  // 3 props destructure transform
  if (ctx.propsDestructureDecl) {
    transformDestructuredProps(ctx);
  }

  return {
    //....
    content: ctx.s.toString(),
  };
}

在之前的
为什么defineProps宏函数不需要从vue中import导入?
文章中我们已经详细讲解过了
compileScript
函数中的入参
sfc
、如何使用
ScriptCompileContext
类new一个
ctx
上下文对象。所以这篇文章我们就只简单说一下他们的作用即可。

  • 入参
    sfc
    对象:是一个
    descriptor
    对象,
    descriptor
    对象是由vue文件编译来的。
    descriptor
    对象拥有template属性、scriptSetup属性、style属性,分别对应vue文件的
    <template>
    模块、
    <script setup>
    模块、
    <style>
    模块。

  • ctx
    上下文对象:这个
    ctx
    对象贯穿了整个script模块的处理过程,他是根据vue文件的源代码初始化出来的。在
    compileScript
    函数中处理script模块中的内容,实际就是对
    ctx
    对象进行操作。最终
    ctx.s.toString()
    就是返回script模块经过编译后返回的js代码。

搞清楚了入参
sfc
对象和
ctx
上下文对象,我们接着来看
ctx.scriptSetupAst
。从名字我想你也能猜到,他就是script模块中的代码对应的AST抽象语法树。如下图:
scriptSetupAst

从上图中可以看到
body
属性是一个数组,分别对应的是源代码中的两行代码。

数组的第一项对应的Node节点类型是
VariableDeclaration
,他是一个变量声明类型的节点。对应的就是源代码中的第一行:
const { name: localName } = defineProps(["name"])

数组中的第二项对应的Node节点类型是
ExpressionStatement
,他是一个表达式类型的节点。对应的就是源代码中的第二行:
console.log(localName)

我们接着来看
compileScript
函数中的外层for循环,也就是遍历前面讲的body数组,代码如下:

function compileScript(sfc, options) {
  // ...省略
  // 2.2 process <script setup> body
  for (const node of scriptSetupAst.body) {
    if (node.type === "VariableDeclaration" && !node.declare) {
      const total = node.declarations.length;
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i];
        const init = decl.init;
        if (init) {
          // defineProps
          const isDefineProps = processDefineProps(ctx, init, decl.id);
        }
      }
    }
  }
  // ...省略
}

我们接着来看外层for循环里面的第一个if语句:

if (node.type === "VariableDeclaration" && !node.declare)

这个if语句的意思是判断当前的节点类型是不是变量声明并且确实有初始化的值。

我们这里的源代码第一行代码如下:

const { name: localName } = defineProps(["name"]);

很明显我们这里是满足这个if条件的。

接着在if里面还有一个内层for循环,这个for循环是在遍历node节点的
declarations
属性,这个属性是一个数组。

declarations
数组属性表示当前变量声明语句中定义的所有变量,可能会定义多个变量,所以他才是一个数组。在我们这里只定义了一个变量
localName
,所以
declarations
数组中只有一项。

在内层for循环,会去遍历声明的变量,然后从变量的节点中取出
init
属性。我想聪明的你从名字应该就可以看出来
init
属性的作用是什么。

没错,
init
属性就是对应的变量的初始化值。在我们这里声明的
localName
变量的初始化值就是
defineProps(["name"])
函数的返回值。

接着就是判断
init
是否存在,也就是判断变量是否是有初始化值。如果为真,那么就执行
processDefineProps(ctx, init, decl.id)
判断初始化值是否是在调用
defineProps
。换句话说就是判断当前的变量声明是否是在调用
defineProps
宏函数。

processDefineProps函数

接着将断点走进
processDefineProps
函数,在我们这个场景中简化后的代码如下:

function processDefineProps(ctx, node, declId) {
  if (!isCallOf(node, DEFINE_PROPS)) {
    return processWithDefaults(ctx, node, declId);
  }
  // handle props destructure
  if (declId && declId.type === "ObjectPattern") {
    processPropsDestructure(ctx, declId);
  }
  return true;
}

processDefineProps
函数接收3个参数。

  • 第一个参数
    ctx
    ,表示当前上下文对象。

  • 第二个参数
    node
    ,这个节点对应的是变量声明语句中的初始化值的部分。也就是源代码中的
    defineProps(["name"])

  • 第三个参数
    declId
    ,这个对应的是变量声明语句中的变量名称。也就是源代码中的
    { name: localName }


为什么defineProps宏函数不需要从vue中import导入?
文章中我们已经讲过了这里的第一个if语句就是用于判断当前是否在执行
defineProps
函数,如果不是那么就直接
return false

我们接着来看第二个if语句,这个if语句就是判断当前变量声明是不是“对象解构赋值”。很明显我们这里就是解构出的
localName
变量,所以代码将会走到
processPropsDestructure
函数中。

processPropsDestructure
函数

接着将断点走进
processPropsDestructure
函数,在我们这个场景中简化后的代码如下:

function processPropsDestructure(ctx, declId) {
  const registerBinding = (
    key: string,
    local: string,
    defaultValue?: Expression
  ) => {
    ctx.propsDestructuredBindings[key] = { local, default: defaultValue };
  };

  for (const prop of declId.properties) {
    const propKey = resolveObjectKey(prop.key);
    registerBinding(propKey, prop.value.name);
  }
}

前面讲过了这里的两个入参,
ctx
表示当前上下文对象。
declId
表示变量声明语句中的变量名称。

首先定义了一个名为
registerBinding
的箭头函数。

接着就是使用for循环遍历
declId.properties
变量名称,为什么会有多个变量名称呢?

答案是解构的时候我们可以解构一个对象的多个属性,用于定义多个变量。

prop
属性如下图:
prop

从上图可以看到
prop
中有两个属性很显眼,分别是
key

value

其中
key
属性对应的是解构对象时从对象中要提取出的属性名,因为我们这里是解构的
name
属性,所以上面的值是
name

其中
value
属性对应的是解构对象时要赋给的目标变量名称。我们这里是赋值给变量
localName
,所以上面他的值是
localName

接着来看for循环中的代码。

执行
const propKey = resolveObjectKey(prop.key)
拿到要从
props
对象中解构出的属性名称。

将断点走进
resolveObjectKey
函数,代码如下:

function resolveObjectKey(node: Node) {
  switch (node.type) {
    case "Identifier":
      return node.name;
  }
  return undefined;
}

如果当前是标识符节点,也就是有name属性。那么就返回name属性。

最后就是执行
registerBinding
函数。

registerBinding(propKey, prop.value.name)

第一个参数为传入解构对象时要提取出的属性名称,也就是
name
。第二个参数为解构对象时要赋给的目标变量名称,也就是
localName

接着将断点走进
registerBinding
函数,他就在
processPropsDestructure
函数里面。

function processPropsDestructure(ctx, declId) {
  const registerBinding = (
    key: string,
    local: string,
    defaultValue?: Expression
  ) => {
    ctx.propsDestructuredBindings[key] = { local, default: defaultValue };
  };
  // ...省略
}

ctx.propsDestructuredBindings
是存在ctx上下文中的一个属性对象,这个对象里面存的是需要解构的多个props。

对象的key就是需要解构的props。

key对应的value也是一个对象,这个对象中有两个字段。其中的
local
属性是解构props后要赋给的变量名称。
default
属性是props的默认值。

在debug终端来看看此时的
ctx.propsDestructuredBindings
对象是什么样的,如下图:
propsDestructuredBindings

从上图中就有看到此时里面已经存了一个
name
属性,表示
props
中的
name
需要解构,解构出来的变量名为
localName
,并且默认值为
undefined

经过这里的处理后在ctx上下文对象中的
ctx.propsDestructuredBindings
中就已经存了有哪些props需要解构,以及解构后要赋值给哪个变量。

有了这个后,后续只需要将script模块中的所有代码遍历一次,然后找出哪些在使用的变量是props解构的变量,比如这里的
localName
变量将其替换成
__props.name
即可。

transformDestructuredProps函数

接着将断点层层返回,走到最外面的
compileScript
函数中。再来回忆一下
compileScript
函数的代码,如下:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const scriptSetupAst = ctx.scriptSetupAst;

  // 2.2 process <script setup> body
  for (const node of scriptSetupAst.body) {
    if (node.type === "VariableDeclaration" && !node.declare) {
      const total = node.declarations.length;
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i];
        const init = decl.init;
        if (init) {
          // defineProps
          const isDefineProps = processDefineProps(ctx, init, decl.id);
        }
      }
    }
  }

  // 3 props destructure transform
  if (ctx.propsDestructureDecl) {
    transformDestructuredProps(ctx);
  }

  return {
    //....
    content: ctx.s.toString(),
  };
}

经过
processDefineProps
函数的处理后,
ctx.propsDestructureDecl
对象中已经存了有哪些变量是由props解构出来的。

这里的
if (ctx.propsDestructureDecl)
条件当然满足,所以代码会走到
transformDestructuredProps
函数中。

接着将断点走进
transformDestructuredProps
函数中,在我们这个场景中简化后的
transformDestructuredProps
函数代码如下:

import { walk } from 'estree-walker'

function transformDestructuredProps(ctx) {
  const rootScope = {};
  let currentScope = rootScope;
  const propsLocalToPublicMap: Record<string, string> = Object.create(null);

  const ast = ctx.scriptSetupAst;

  for (const key in ctx.propsDestructuredBindings) {
    const { local } = ctx.propsDestructuredBindings[key];
    rootScope[local] = true;
    propsLocalToPublicMap[local] = key;
  }

  walk(ast, {
    enter(node: Node) {
      if (node.type === "Identifier") {
        if (currentScope[node.name]) {
          rewriteId(node);
        }
      }
    },
  });

  function rewriteId(id: Identifier) {
    // x --> __props.x
    ctx.s.overwrite(
      id.start! + ctx.startOffset!,
      id.end! + ctx.startOffset!,
      genPropsAccessExp(propsLocalToPublicMap[id.name])
    );
  }
}


transformDestructuredProps
函数中主要分为三块代码,分别是for循环、执行
walk
函数、定义
rewriteId
函数。

我们先来看第一个for循环,他是遍历
ctx.propsDestructuredBindings
对象。前面我们讲过了这个对象中存的属性key是解构了哪些props,比如这里就是解构了
name
这个props。

接着就是使用
const { local } = ctx.propsDestructuredBindings[key]
拿到解构的props在子组件中赋值给了哪个变量,我们这里是解构出来后赋给了
localName
变量,所以这里的
local
的值为字符串"localName"。

由于在我们这个demo中只有两行代码,分别是解构props和
console.log
。没有其他的函数,所以这里的作用域只有一个。也就是说
rootScope
始终等于
currentScope

所以这里执行
rootScope[local] = true
后,
currentScope
对象中的
localName
属性也会被赋值true。如下图:
currentScope

接着就是执行
propsLocalToPublicMap[local] = key
,这里的
local
存的是解构props后赋值给子组件中的变量名称,
key
为解构了哪个props。经过这行代码的处理后我们就形成了一个映射,后续根据这个映射就能轻松的将script模块中使用解构后的
localName
的地方替换为
__props.name

propsLocalToPublicMap
对象如下图:
propsLocalToPublicMap

经过这个for循环的处理后,我们已经知道了有哪些变量其实是经过props解构来的,以及这些解构得到的变量和props的映射关系。

接下来就是使用
walk
函数去递归遍历script模块中的所有代码,这个递归遍历就是遍历script模块对应的AST抽象语法树。

在这里是使用的
walk
函数来自于第三方库
estree-walker

在遍历语法树中的某个节点时,进入的时候会触发一次
enter
回调,出去的时候会触发一次
leave
回调。

walk
函数的执行代码如下:

walk(ast, {
  enter(node: Node) {
    if (node.type === "Identifier") {
      if (currentScope[node.name]) {
        rewriteId(node);
      }
    }
  },
});

我们这个场景中只需要
enter
进入的回调就行了。


enter
回调中使用外层if判断当前节点的类型是不是
Identifier

Identifier
类型可能是变量名、函数名等。

我们源代码中的
console.log(localName)
中的
localName
就是一个变量名,当递归遍历AST抽象语法树遍历到这里的
localName
对应的节点时就会满足外层的if条件。

在debug终端来看看此时满足外层if条件的node节点,如下图:
node

从上面的代码可以看到此时的node节点中对应的变量名为
localName
。其中
start

end
分别表示
localName
变量的开始位置和结束位置。

我们回忆一下前面讲过了
currentScope
对象中就是存的是有哪些本地的变量是通过props解构得到的,这里的
localName
变量当然是通过props解构得到的,满足里层的if条件判断。

最后代码会走进
rewriteId
函数中,将断点走进
rewriteId
函数中,简化后的代码如下:

function rewriteId(id: Identifier) {
  // x --> __props.x
  ctx.s.overwrite(
    id.start + ctx.startOffset,
    id.end + ctx.startOffset,
    genPropsAccessExp(propsLocalToPublicMap[id.name])
  );
}

这里使用了
ctx.s.overwrite
方法,这个方法接收三个参数。

第一个参数是:开始位置,对应的是变量
localName
在源码中的开始位置。

第二个参数是:结束位置,对应的是变量
localName
在源码中的结束位置。

第三个参数是想要替换成的新内容。

第三个参数是由
genPropsAccessExp
函数返回的,执行这个函数时传入的是
propsLocalToPublicMap[id.name]

前面讲过了
propsLocalToPublicMap
存的是props名称和解构到本地的变量名称的映射关系,
id.name
是解构到本地的变量名称。如下图:
overwrite

所以
propsLocalToPublicMap[id.name]
的执行结果就是
name
,也就是名为
name
的props。

接着将断点走进
genPropsAccessExp
函数,简化后的代码如下:

const identRE = /^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/;
function genPropsAccessExp(name: string): string {
  return identRE.test(name)
    ? `__props.${name}`
    : `__props[${JSON.stringify(name)}]`;
}

使用正则表达式去判断如果满足条件就会返回
__props.${name}
,否则就是返回
__props[${JSON.stringify(name)}]

很明显我们这里的
name
当然满足条件,所以
genPropsAccessExp
函数会返回
__props.name

那么什么情况下不会满足条件呢?

比如这样的props:

const { "first-name": firstName } = defineProps(["first-name"]);
console.log(firstName);

这种props在这种情况下就会返回
__props["first-name"]

执行完
genPropsAccessExp
函数后回到
ctx.s.overwrite
方法的地方,此时我们已经知道了第三个参数的值为
__props.name
。这个方法的执行会将
localName
重写为
__props.name


ctx.s.overwrite
方法执行之前我们来看看此时的script模块中的js代码是什么样的,如下图:
before

从上图中可以看到此时的代码中
console.log
里面还是
localName

执行完
ctx.s.overwrite
方法后,我们来看看此时是什么样的,如下图:
after

从上图中可以看到此时的代码中
console.log
里面已经变成了
__props.name

这就是在编译阶段将使用到的解构
localName
变量变成
__props.name
的完整过程。

这会儿我们来看前面那个例子解构后丢失响应式的例子,我想你就很容易想通了。

<script setup lang="ts">
const props = defineProps(["name"]);
const { name: localName } = props;
console.log(localName);
</script>

在处理
defineProps
宏函数时,发现是直接解构了返回值才会进行处理。上面这个例子中没有直接进行解构,而是将其赋值给
props
,然后再去解构
props
。这种情况下
ctx.propsDestructuredBindings
对象中什么都没有。

后续在递归遍历script模块中的所有代码,发现
ctx.propsDestructuredBindings
对象中什么都没有。自然也不会将
localName
替换为
__props.name
,这样他当然就会丢失响应式了。

总结

在编译阶段首先会处理宏函数
defineProps
,在处理的过程中如果发现解构了
defineProps
的返回值,那么就会将解构的
name
属性,以及
name
解构到本地的
localName
变量,都全部一起存到
ctx.propsDestructuredBindings
对象中。

接下来就会去递归遍历script模块中的所有代码,如果发现使用的
localName
变量能够在
ctx.propsDestructuredBindings
对象中找的到。那么就说明这个
localName
变量是由props解构得到的,就会将其替换为
__props.name
,所以使用解构后的props依然不会丢失响应式。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

在现代 Web 应用中,尤其是涉及视频播放、实时通信、地图导航等长时间运行的任务时,用户常常希望设备不要因为空闲而自动进入睡眠模式或屏幕变暗。为了解决这一问题,Web API 提供了一个名为 Wake Lock 的接口,允许开发者请求设备保持唤醒状态。

本文将详细介绍如何使用 Wake Lock API 来控制设备的唤醒状态,提供示例代码,并讨论一些常见的使用场景,尤其是如何确保网页隐藏或显示时自动管理唤醒锁。

什么是 Wake Lock API?

Wake Lock API 是一个用于防止设备进入睡眠或屏幕变暗的 Web API。通过 Wake Lock API,开发者可以请求设备保持活跃状态,防止因为电源管理机制导致任务中断。

目前,
Wake Lock API 只支持一种类型的唤醒锁:
screen

,它用于保持屏幕亮起,防止屏幕自动关闭或调暗。

使用 Wake Lock API 的前提

  • 浏览器支持
    :Wake Lock API 目前在大多数现代浏览器中都已经得到支持。
  • HTTPS 环境
    :该 API 需要通过 HTTPS 访问才能正常工作。

基本用例

以下是一个简单的示例,展示了如何使用 Wake Lock API 来保持屏幕唤醒:

// 创建一个全局变量来存储 WakeLock 实例
let wakeLock = null;

// 请求屏幕保持唤醒的函数
async function requestWakeLock() {
  try {
    // 请求屏幕唤醒锁
    wakeLock = await navigator.wakeLock.request('screen');
    console.log('屏幕唤醒锁已激活');
    
    // 监听唤醒锁的释放事件
    wakeLock.addEventListener('release', () => {
      console.log('屏幕唤醒锁已释放');
    });
  } catch (err) {
    console.error(`${err.name}, ${err.message}`);
  }
}

// 释放唤醒锁的函数
function releaseWakeLock() {
  if (wakeLock !== null) {
    wakeLock.release();
    wakeLock = null;
    console.log('屏幕唤醒锁手动释放');
  }
}

// 调用函数请求唤醒锁
requestWakeLock();

// 在页面关闭时释放唤醒锁
window.addEventListener('beforeunload', releaseWakeLock);

页面可见性处理:自动管理唤醒锁

由于当网页被隐藏或切换到后台时会自动释放唤醒锁,因此我们可以监听
visibilitychange
事件来确保网页重新可见时重新获取唤醒锁。当页面恢复显示时再次请求锁定,页面隐藏时则释放唤醒锁。

// 创建一个全局变量来存储 WakeLock 实例
let wakeLock = null;

// 请求屏幕保持唤醒的函数
async function requestWakeLock() {
  try {
    // 请求屏幕唤醒锁
    wakeLock = await navigator.wakeLock.request('screen');
    console.log('屏幕唤醒锁已激活');
    
    // 监听唤醒锁的释放事件
    wakeLock.addEventListener('release', () => {
      console.log('屏幕唤醒锁已释放');
    });
  } catch (err) {
    console.error(`${err.name}, ${err.message}`);
  }
}

// 释放唤醒锁的函数
function releaseWakeLock() {
  if (wakeLock !== null) {
    wakeLock.release();
    wakeLock = null;
    console.log('屏幕唤醒锁手动释放');
  }
}

// 处理页面可见性变化
function handleVisibilityChange() {
  if (document.visibilityState === 'visible') {
    // 页面重新可见时,重新请求唤醒锁
    requestWakeLock();
  } else {
    // 页面隐藏时,释放唤醒锁
    releaseWakeLock();
  }
}

// 监听页面可见性变化事件
document.addEventListener('visibilitychange', handleVisibilityChange);

// 页面加载时立即请求唤醒锁
requestWakeLock();

// 在页面关闭时释放唤醒锁
window.addEventListener('beforeunload', releaseWakeLock);

使用场景

Wake Lock API 在以下几种典型场景中非常有用:

1. 视频或音频播放

在播放视频或音频的应用中,用户希望屏幕保持亮起,以便可以随时调整播放进度或音量。通过 Wake Lock API,在媒体播放时保持屏幕唤醒,提供更好的用户体验。

videoElement.addEventListener('play', requestWakeLock);
videoElement.addEventListener('pause', releaseWakeLock);

2. 实时通信应用

对于视频通话、会议等实时通信应用,屏幕关闭会影响用户的互动体验。使用 Wake Lock API,可以确保设备在通话期间保持活跃,防止通话中断。

if (isInCall) {
  requestWakeLock();
} else {
  releaseWakeLock();
}

3. 导航和地图应用

在导航应用中,用户通常需要长时间查看屏幕来获取行进路线信息。使用 Wake Lock API,可以确保屏幕不会因为闲置而熄灭。

navigator.geolocation.watchPosition(() => {
  requestWakeLock();
}, () => {
  releaseWakeLock();
});

4. 游戏或全屏应用

网页游戏或需要长时间用户交互的全屏应用,也可以利用 Wake Lock API,避免游戏过程中屏幕突然熄灭。

document.addEventListener('fullscreenchange', () => {
  if (document.fullscreenElement) {
    requestWakeLock();
  } else {
    releaseWakeLock();
  }
});

错误处理和兼容性

虽然 Wake Lock API 提供了有用的功能,但它在某些设备上可能受到电源管理策略的限制。因此,开发者在请求唤醒锁时应当加入错误处理,以确保程序的健壮性。

if ('wakeLock' in navigator) {
  requestWakeLock();
} else {
  console.error('当前浏览器不支持 Wake Lock API');
}

浏览器兼容性

- Chrome Edge Firefox Opera Safari Chrome Android Firefox Android Opera Android Safari iOS Samsung Internet WebView Android
WakeLock 84 84 126 70 16.4 84 126 60 16.4 14.0 84
request 84 84 126 70 16.4 84 126 60 16.4 14.0 84

iOS 版 Safari

  • 16.4 (Released 2023-03-27)
  • 部分支持
  • 在独立的主屏幕Web应用程序不生效。详情请看
    bug 254545
    .

总结

Wake Lock API 为 Web 开发者提供了控制设备唤醒状态的能力,尤其适合那些需要保持屏幕长时间活跃的应用,如视频播放、实时通信、导航等。通过监听
visibilitychange
事件,应用程序可以智能地管理唤醒锁的状态,在页面可见时重新获取锁定,隐藏时释放锁定。

随着更多浏览器对 Wake Lock API 的支持,它将会成为提升用户体验的重要工具。如果你的应用涉及到长时间的任务或需要保持屏幕亮起,建议集成这个 API 来优化用户体验。

前言

在工业生产中,定制化的软件对于每个环节都至关重要。对于仓库管理,推荐一款开源的仓库管理系统(WMS)解决方案。

这款基于.NET 框架开发的移动应用,提供了全面的仓库操作、订单处理、主数据管理、数据分析及个人信息设置等功能,是工业仓库管理的有利助手。

项目介绍

SmoWMS 是一款基于.NET 技术开发的移动仓库管理系统。

包含了仓库管理中基础的入库、出库、订单管理、调拨、盘点、报表等功能。

支持扫码条码扫描、RFID扫描等仓库中常见的场景。

它通过 Visual Studio 作为 IDE,结合 Smobiler 开发平台,使用 SmobilerDesigner 工具来创建 .NET 组件,从而在 Visual Studio 环境中高效开发移动应用。

SmobilerClient 作为框架的客户端,采用专有的 stml 协议来实现原生控件的渲染和事件处理。

另外,SmoWMS 的云平台支持生成适用于 Android 和 iOS 的安装包,方便用户部署和使用。

项目功能

1、仓库管理

仓库部分按区域管理,包含管理、出入库、调拨和盘点等核心功能。

其中,出入库和盘点等功能支持扫码作业,可调用手机摄像头、手持终端的扫描头和 RFID 模块进行扫描。

2、订单管理

订单部分分为采购订单和销售订单,支持创建和跟踪采购与销售的各个阶段,并可进行入库、退库等操作。

右下角的快捷菜单便于快速创建采购和销售单。

3、主数据管理

在主数据部分,可以维护资产、仓库、客户、供应商等信息。

每类主数据支持三级分类,例如:资产分类 -> 电脑整机 -> 台式机 -> 联想。

4、统计功能

统计功能提供资产和耗材的商品分析、采购分析和销售分析。

每项分析均配有直观图表展示,并可选择仓库和类型以查看更详细的数据资料。

5、设置

在设置部分,可以修改个人信息,包括拍照或从相册上传个人头像,以及修改密码、邮箱和电话等。

项目环境

1、客户端运行环境要求

Android版本:支持Android 4.4及以上版本

IOS版本:支持IOS 9.0及以上版本

2、源代码运行环境要求

.NET FrameWork版本: .支持NET FrameWork 4.0及以上版本

Visual Studio版本:支持Visual studio 2010及以上版本

SmobilerDesigner版本:4.5.0及以上(下载并安装SmobilerDesigner)

项目文档

1、Smobiler 示例

Smobiler也使开发人员,可以在VisualStudio上,像开发WinForm一样拖拉控件,让许多人在开发APP时,再次回到所见即所得的开发方式中去。

地址:https://www.smobiler.com/webdemo/baseControl/albumview.aspx

2、服务端部署教程

下载源代码后,可以部署服务器进行运行和调试。具体的使用方法和部署步骤,请参考相关文档。

地址:https://www.smobiler.com/onlineDoc.html?pdf=serviceDoc_SmoWMS

3、客户端使用手册

用户手册详细地介绍了如何登录系统、使用各个功能模块、维护基础数据、管理资产以及操作耗材等。如需更多详细的信息,请查阅提供的文档。

地址:https://www.smobiler.com/onlineDoc.html?pdf=clientDoc

项目地址

GitHub:
https://github.com/comsmobiler/SmoWMS

Gitee:
https://gitee.com/smobiler/SmoWMS

APP体验地址

https://apps.smobiler.com/App/AppDetails?AppID=110

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!

Canvas简历编辑器-Monorepo+Rspack工程实践

在之前我们围绕
Canvas
聊了很多代码设计层面的东西,在这里我们聊一下工程实践。在之前的文中我也提到过,因为是本着学习的态度以及对技术的好奇心来做的,所以除了一些工具类的库例如
ArcoDesign

ResizeObserve

Jest
等包之外,关于 数据结构
packages/delta
、插件化
packages/plugin
、核心引擎
packages/core
等都是手动实现的,所以在这里除了学习了
Canvas
之外,实际上还做了一些项目工程化的实践。

关于
Canvas
简历编辑器项目的相关文章:

Pnpm+Monorepo

我们先来聊聊为什么要用
monorepo
,先举一个我之前踩过的坑作为例子,在之前我的富文本编辑器项目
DocEditor
就是完全写在了独立的单个
src
目录中,在项目本身的运行过程中是没什么问题的,但是当时我想将编辑器独立出来作为
NPM
包用,打包的过程是借助了
Rollup
也没什么问题,问题就出在了引用方上。当时我在简历编辑器中引入文档编辑器的
NPM
包时,发现有一个模块被错误的
TreeShaking
了,现在都还能在编辑器中看到这部分兼容。

module: {
  rules: [
    {
      // 对`doc-editor-light`的`TreeShaking`有点问题
      test: /doc-editor-light\/dist\/tslib.*\.js/,
      sideEffects: true,
     },
   ]
}

这个问题导致了我在
dev
模式下没有什么问题,但是在
build
之后这部分代码被错误地移除掉了,导致编辑器的
wrapper
节点出现了问题,列表等元素不能正确添加。当然实际上这不能说明独立包项目不好,只能说整个管理的时候可能并不是那么简单,尤其是打包为
NPM
包的时候需要注意各个入口问题。那么现在引用我的富文本编辑器包已经变成了
4
个独立的包分别引用,各司其职,就没再出现过这个问题。

说起来打包的问题,我还踩过一个坑,不知道大家是不是见到过
React

Invalid hook call
这个经典报错。之前我将其独立拆包的时候之后,发现会报这个错,但是我在
package.json
中是标注的
peerDependencies "react": ">=16"
,按理说这里会直接应用安装该包的
React
,不可能出现版本不一致的问题,至于
Rules of Hooks
肯定也不可能,因为我之前是好好的,拆完包才出的问题。最后发现是我在
rollup
中没把
peerDependencies
这部分解析,导致
jsx-runtime
被打进了包里,虽然
React
的版本都是
17.0.2
但是实际上是运行了两个独立词法作用域的
React Hooks
,这才导致了这个问题。

Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:

1.  You might have mismatching versions of React and the renderer (such as React DOM)
2.  You might be breaking the Rules of Hooks
3.  You might have more than one copy of React in the same app See for tips about how to debug and fix this problem.

接着回到项目本身,当前项目已经抽离出来独立的
RspackMonoTemplate
,平时开发也会基于这个模版创建仓库。当前简历编辑器项目的结构
tree -L 2 -I node_modules --dirsfirst
如下:

CanvasEditor
│── packages
│   ├── core
│   ├── delta
│   ├── plugin
│   ├── react
│   └── utils
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── tsconfig.json
  • packages/core
    : 编辑器核心引擎模块,对于 剪贴板操作、事件管理、状态管理、
    History
    模块、
    Canvas
    操作、选区操作 等等都封装在这里,相当于实现了基本的
    Canvas
    引擎能力。
  • packages/delta
    : 数据结构模块,设计了基准数据结构,实现了
    DeltaSet
    数据结构以及原子化的
    Op
    操作,主要用于描述整个编辑器的数据结构以及操作,实现了
    invert
    等能力,对于实现
    History
    模块有很大的意义。
  • packages/plugin
    : 插件模块,在
    packages/delta
    的基础上设计了插件化的能力,主要为了实现编辑器的功能模块化,例如
    Text

    Image

    Rect
    等插件都是在这里实现的。
  • packages/react
    :
    React
    模块,主要是为了通过实现编辑器的视图层,在这里有比较重要的一点,我们的核心模块是视图框架无关的,如果有必要的话同样可以使用
    Vue

    Angular
    等框架来实现视图层。
  • packages/utils
    : 工具模块,主要是一些工具函数的封装,例如
    FixedNumber

    Palette
    等等,这些工具函数在整个编辑器中都有使用,是作为基础包在整个
    workspace
    中引用的。
  • package.json
    : 整个
    workspace

    package.json
    ,在这里配置了一些项目的信息,
    EsLint

    StyleLint
    相关的配置也都在这里实现。
  • pnpm-lock.yaml
    :
    pnpm
    的锁文件,用于锁定整个
    workspace
    的依赖版本。
  • pnpm-workspace.yaml
    :
    pnpm

    workspace
    配置文件,用于配置
    monorepo
    的能力。
  • tsconfig.json
    : 整个
    workspace

    tsconfig
    配置文件,用于配置整个
    workspace

    TypeScript
    编译配置,在这里是作为基准配置以提供给项目中的模块引用。

pnpm
自身是非常优秀的包管理器,通过硬链接和符号链接来节省磁盘空间,每个版本的包只需要存储一次,最重要的是
pnpm
创建了一个非扁平化的
node_modules
结构,从而确保依赖与声明严格匹配,严格控制了依赖提升,能够避免依赖升级的意外问题,这提高了项目的一致性和可预测性。

而说回到
monorepo

pnpm
不光是非常优秀的包管理器,其还提供了一个开箱即用的
monorepo
能力。在
pnpm
中存在一个
pnpm-workspace.yaml
文件,这个文件是用来配置
workspack
的,而
pnpm

workspace
就可以作为
monorepo
的能力,而我们的配置也非常简单,我们认为在
packages
目录下的所有目录都作为子项目。

packages:
  - 'packages/*'

通过
monorepo
我们可以很方便的管理所有子项目,特别是对于需要发
Npm
包的项目,将子模块拆分是个不错的选择,特别如果能够做到视图层框架无关的话就显得更加有意义。此外,
monorepo
对于整个项目的管理也有很多益处,例如在打包整个应用的时候,我们不需要对每个子项目发新的包之后才能打包,而是可以直接将编译过程放在
workspace
层面,这样就可以保证整个项目的一致性,简化了构建过程和持续集成流程,让所有项目可以共享构建脚本和工具配置。此外所有项目和模块共用同一个版本控制系统,便于进行统一的版本管理和变更跟踪,而且还有助于同步更新这些项目间的依赖关系。

TS+Rspack最佳实践

说了这么多使用
pnpm + monorepo
管理项目带来的好处,我们再来聊聊我对
TS

Rspack
应用于
Monorepo
的最佳实践,不知道大家是不是遇到过这样的两个问题:

  • 子项目的
    TS
    声明更改后不能实时生效,必须要编译一次子项目才可以,而子项目编译的过程中如果将
    dist
    等产物包删除,那么在
    vsc
    或者其他编辑器中就会报
    TS
    找不到引用声明的错误,这个时候就必须要用命令重新
    Reload TypeScript Project
    来去掉报错。而如果不将产物包删除的话,就会出现一些隐性的问题,例如原来某个文件命名为
    a.tsx
    ,此时因为一些原因需要将其移动到同名的
    a
    目录并且重新命名为
    index.tsx
    ,那么执行了这一顿操作之后,发现如果更改此时的
    index.tsx
    代码不会更新,必须要重启应用的
    webpack
    等编译器才行,因为其还是引用了原来的文件,产生类似的问题虽然不复杂但是排查起来还是需要时间的。
  • 更改子项目的
    TS
    代码必须要重新编译子项目,因为项目是
    monorepo
    管理的,在
    package.json
    中会有
    workspace
    引用,而
    workspace
    实际上是在
    node_modules
    被引用的,所以虽然是子项目但是仍然需要遵循
    node_modules
    的规则才可以,那么其通常需要被编译为
    js
    才可以被执行,所以每次修改代码都必须要全量执行一遍很是麻烦,当然通常我们可以通过
    -w
    命令来观察变动,但是毕竟多了一道步骤,且如果是存在
    alias
    的项目可能仅仅使用
    tsc
    来编译还不够。此外在
    monorepo
    中我们通常会有很多子项目,如果每个子项目都需要这样的话,特别在这种编译时全量编译而不是增量编译的情况下,那么整个项目的编译时间就会变得非常长。

那么在这里我们先来看第一个问题,子项目的
TS
声明更改后不能实时生效,因为我们也提到了
monorepo
子项目实际上是通过
node_modules
来管理和引用的,所以其在默认情况下依然需要遵循
node_modules
的规则,即
packages.json

types
字段指向的
TS
声明文件,那么我们有没有什么办法可以修改这个行为呢,当然是有的,我们在整个项目的根
tsconfig.json
配置
path
就可以完美解决这个问题。当我们配置好如下的内容之后,通过按住
Ctrl
加鼠标左键点击的时候,就可以跳转到子项目的根目录声明了。此外这里有个要关注的点是,在项目中不建议配置
"baseUrl": "."
,在这里会有一些奇奇怪怪的路径引用问题,所以在简历编辑器项目中除了要打包
Npm

tsconfig.build.json
之外,都是直接使用相对路径配置的。

{
  "compilerOptions": {
    "...": "...",
    "paths": {
      "sketching-core": ["./packages/core/src"],
      "sketching-delta": ["./packages/delta/src"],
      "sketching-plugin": ["./packages/plugin/src"],
      "sketching-utils": ["./packages/utils/src"],
    },
  },
  "include": [
    "packages/*/src"
  ]
}

那么解决了项目的
TS
声明问题之后,我们再来看编译的问题,这里的问题看起来会复杂一些,因为
TS
声明就单纯只是类型声明而已,不会影响到项目本身代码的编译,编译类型检查除外。那么在
Rspack
中应该配置才能让我们的代码直接指向子项目,而不是必须要走
node_modules
这套规则,实际上这里也很简单,只需要配置
resolve.alias
就可以了,这样当我们直接修改
TS
代码时,也能让编辑器立即响应增量编译。

{
// ....
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
      "sketching-core": path.resolve(__dirname, "../core/src"),
      "sketching-delta": path.resolve(__dirname, "../delta/src"),
      "sketching-plugin": path.resolve(__dirname, "../plugin/src"),
      "sketching-utils": path.resolve(__dirname, "../utils/src"),
    },
  },
// ....
}

实际上对于
Rspack
而言其帮我们做了很多事,比如即使是
node_modules

TS
文件也会编译,而对于一些通过
CRA
创建的
webpack
项目来说,这个配置就麻烦一些,当然我们同样也可以借助
customize-cra
来完成这件事,此外我们还要关闭一些类似于
ModuleScopePlugin
的插件才可以,下面是富文本编辑器项目
DocEditor
的配置。

const src = path.resolve(__dirname, "src");
const index = path.resolve(__dirname, "src/index.tsx");
const core = path.resolve(__dirname, "../core/src");
const delta = path.resolve(__dirname, "../delta/src");
const plugin = path.resolve(__dirname, "../plugin/src");
const utils = path.resolve(__dirname, "../utils/src");

module.exports = {
  paths: function (paths) {
    paths.appSrc = src;
    paths.appIndexJs = index;
    return paths;
  },
  webpack: override(
    ...[
      // ...
      addWebpackResolve({
        alias: {
          "doc-editor-core": core,
          "doc-editor-delta": delta,
          "doc-editor-plugin": plugin,
          "doc-editor-utils": utils,
        },
      }),
      babelInclude([src, core, delta, plugin, utils]),
      // ...
      configWebpackPlugins(),
    ].filter(Boolean)
  ),
};

此外,简历编辑器是纯前端的项目,这样的项目有个很大的优势是可以直接使用静态资源就可以运行,而如果我们借助
GitHub Action
就可以通过
Git Pages
在仓库中直接部署,并且可以直接通过
GitHub Pages
访问,这样在仓库中就能呈现一个完整的
DEMO

// .github/workflows/deploy.yml
name: deploy gh-pages

on:
  push:
    branches:
      - master

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
          persist-credentials: false
          
      - name: install node-v16
        uses: actions/setup-node@v3
        with:
          node-version: '16.16.0'

      - name: install dependencies
        run: |
          node -v
          npm install -g pnpm
          pnpm config set registry https://registry.npmjs.org/
          pnpm install --registry=https://registry.npmjs.org/

      - name: build project
        run: |
          npm run build:react

      - name: deploy project
        uses: JamesIves/github-pages-deploy-action@releases/v3
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH: gh-pages
          FOLDER: packages/react/build

最后

在这里我们聊了为什么要用
Monorepo
以及简单聊了一下
pnpm workspace
的优势,然后解决了在子项目开发中会遇到的
TS
编译、项目编译的两个实际问题,分别在
Monorepo

Rspack

Webpack
项目中相关的部分实践了一下,最后还简单聊了一下利用
GitHub Action
直接在
Git Pages
部署在线
DEMO
。那么再往后边的文章中,我们就需要聊一聊如何实现 层级渲染与事件管理 的能力设计。