宇宙超级无敌分享会!!!

307 阅读6分钟

本次分享会,包含以下三个主题

  1. ✨vue3 巧妙的 vnode 类型分类所引发的思考
  2. 🏸typescript 类型体操
  3. 🛠Git rebase

1. vue3 巧妙的 vnode 类型分类所引发的思考

vue3 是什么?

Vue 是尤雨溪早在 2013 年开发的一个前端框架,距离现在已经过去了 9 年的时间,发展到现在也由于它的学习成本不算太高,活跃的社区氛围深受国内前端开发者的们喜爱。目前我们后台使用的是 vue3 版本

为什么需要 vue3,传统的使用 Javascript 面临什么问题?

传统的使用 Javacscript 如果需要控制页面的某个输入框,更改里面的值,就需要获取页面的DOM元素然后绑定 input 事件,这种叫命令式编程。我们要告诉浏览器我要拿哪个页面元素,要怎么操作,达到什么效果,让我们既关注 HTML 代码又要关注数据本身的流向,很累。。。

code.png

如果我们使用 Vue,这将极大地提升了我们的开发效率,我们只需要关注数据流向就可以了,我们只需要简单对对象进行配置数据,一切有关页面的 DOM 操作都将由 Vue 内部完成。例如:

code3.png

以上都不是重点

什么是 vnode 及其作用?

vnode 又叫做虚拟节点,传统上在页面上都是通过 DOM 节点描述页面中的元素,但在 vue 中,由于需要提升页面性能,节点与节点之间需要寻找哪些节点是需要被更新,哪些节点不需要更新的,又因为直接通过真实的 DOM 节点对比旧节点和新节点的不同比较耗时(因为真实 DOM 节点上面有很多属性和方法),尤雨溪放弃了这种低效的做法,转而使用一个Javascript对象来描述一个页面节点,这个Javascript对象就是我们说的 vnode。其实 vnode 也是对真实 DOM 节点的一种上层的抽象,最终都会通过 Vue 的内部编译把 vnode 全部转成真实 DOM 节点

image.png

至于为什么要用 vnode,跟真实的 DOM 节点有什么不同?请看下面

const vnode = h('div', {}, 'ssss')

console.log('虚拟节点',vnode)   // 打印虚拟节点

image.png

<div ref='div'>ssss</div>

// 打印真实DOM节点
const div = ref(null)
console.log('真实DOM节点',div.value)

image.png

可以看到,真实 DOM 节点打印出来的东西比 vnode 对象都要多,通过这么多的属性判断这个节点有没有改变是不切实际的,不可能每个属性都需要跟旧节点的每个属性比较一遍看是否有改变。

vnode 的类型分类是什么?

页面上的真实 DOM 节点一般分成:文本节点,注释节点,元素节点等等...之所以分类都是为了能区分不同节点在页面中产生不同作用,处理不一样的事情。那我们的 vnode 也是一样的。

vnode 的分类一般分为:普通元素,组件(多个元素的集合),文本子节点,数组子节点(多个子节点组合成的数组)等等...下面就是 vue3 源码列出来的详细的 vnode 分类。

vnode 类型分类源码在此是 🎈: github.com/vuejs/core/…

export const enum ShapeFlags {
  ELEMENT = 1,                     // 元素vnode
  FUNCTIONAL_COMPONENT = 1 << 1,   // 函数组件节点
  STATEFUL_COMPONENT = 1 << 2,     // 静态组件节点
  TEXT_CHILDREN = 1 << 3,          // 文本子节点
  ARRAY_CHILDREN = 1 << 4,         // 数组子节点
  SLOTS_CHILDREN = 1 << 5,         // 插槽子节点
  TELEPORT = 1 << 6,               // 传送门节点
  SUSPENSE = 1 << 7,               // Suspense内置组件节点
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,  // 需 keepalive 的节点
  COMPONENT_KEPT_ALIVE = 1 << 9,   // 已 keepalive 的节点
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT  // 普通组件节点
}

值得注意的是,尤雨溪并不是用的普通的对象去标记 vnode 类型,而是选择使用二进制左移运算符,为什么这样呢?这其中有什么好处?直接这样写不好吗?

export const enum ShapeFlags {
  ELEMENT = 1,                     // 元素vnode
  FUNCTIONAL_COMPONENT = 2,   // 函数组件节点
  STATEFUL_COMPONENT = 3,     // 静态组件节点
  TEXT_CHILDREN = 4,          // 文本子节点
  ARRAY_CHILDREN = 5,         // 数组子节点
  SLOTS_CHILDREN = 6,         // 插槽子节点
  TELEPORT = 7,               // 传送门节点
  SUSPENSE = 8,               // Suspense内置组件节点
  COMPONENT_SHOULD_KEEP_ALIVE = 9,  // 需 keepalive 的节点
  COMPONENT_KEPT_ALIVE = 10   // 已 keepalive 的节点
  COMPONENT = 11  // 普通组件节点
}
const currentVnode = [1, 4] // 需要使用数组装着不同的状态

if (currentVnode.includes(ShapeFlags.ELEMENT) || currentVnode.includes(ShapeFlags.FUNCTIONAL_COMPONENT)) {}

else if (currentVnode.includes(ShapeFlags.ELEMENT) && currentVnode.includes(ShapeFlags.TEXT_CHILDREN)) {}

好处:

  1. 提高性能,减少查询,使用数组还要用 includes 方法去查询(includes 底层是一层 while 循环),我一个 & 操作符判断是否为 0 就查出来了
  2. 减少内存占用,使用太多的数组占用内存,直接使用二进制表示多种状态会减少更多的内存占用

不过缺点也挺明显的,可读性差,一个不熟悉位运算符的开发者(比如我)接手这一块的代码的话还需要花费一定的学习成本。

接下来我根据源码的思路浅浅的来了一段代码:

code4.png

我认为这种适合作为权限管理的一种思想和解决方案,| 运算赋予权限,&运算检查权限,^运算删除权限,网易云官方也出过相关的文章来介绍这种权限管理设计的思路请看链接

|运算符的运算规则:

对于每一个比特位,当两个操作数相应的比特位至少有一个 1 时,结果为 1,否则为 0。

00000100000   mather
00000000010   teacher

00000100000 | 00000000010 = 00000100010

&运算符的运算规则:

对于每一个比特位,只有两个操作数相应的比特位都是 1 时,结果才为 1,否则为 0。

00000100000   mather
00000000010   teacher

00000100000 & 00000000010 = 00000000000  将会是 0

^运算符的运算规则:

对于每一个比特位,当两个操作数相应的比特位有且只有一个 1 时,结果为 1,否则为 0。

00000100000   mather
00000000010   teacher

00000100000 ^ 00000000010 = 00000100010  、
这个结果跟 `|`运算符 的结果一样

但是,如果相同比特位时 如果两个操作数 都是 1 的时候,那么运算出来的结果就是 0

00000100010 ^ 00000100000 = 00000000010
这个结果就相当于把 MATHER 的标识位给删除了。剩下的角色权限只有 TEACHER

想了解更多可以看我在掘金上的相关文章 # 🚀vue3 render 渲染函数的功能实现以及巧妙的 vnode 类型分类!

2. Typescript 类型体操

这里主要讲解如何通过类型体操的锻炼,能够在实际开发中让你手中的 Typescript 尽可能的发挥出它强大的力量,得到更精准的代码类型提示!

这里我先放一个仓库,该仓库是 vue3 core member:@antfu 写的,你们可以通过这个仓库来深入 Typescript 的类型编程(每天 3 题)

以下是@神光总结的五个类型编程的套路:

  1. 模式匹配做提取
  2. 重新构造做变换
  3. 递归复用做循环
  4. 数组长度做计数
  5. 联合分散做简化

套路一:模式匹配做提取

Javascript 里面通常我们都是用 replace 做模式匹配,比如:

'C123D'.replace(/(C)123D/,`$1`)  // 输出: 'C'

🧐 那么 Typescript 里面呢?我们可以用infer来提取我们想要的,❗❗ 不过 infer 只能在条件类型(extends)中使用。

我们拿 type-challenge 里面的一道题来讲解:

📚 easy-first-array

github.com/type-challe… 题目告诉我们需要提取数组的第一个元素,那么...

## easy-first-array

type First<T extends unknown[]> = T extends [infer First, ...unknown[]]
  ? First
  : never

使用:
type Result = First<[3,2,1]> // 将会推导出: 3

套路二:重新构造做变换

众所周知,type 一旦被声明,是无法被修改的,如果需要修改,就需要给类型做各种变换。就拿Javascript的数组来说,改变一个数组的内容,我们可以用pushpopunshiftshift。同样,我们使用Typescript的类型来模拟这种改变数组内容方法。

Push

不要忘了 Tuple 还能 rest 元素

type Push<T extends unknown[], U> = [...T, U]

使用:
type Arr = [1,2,3]
type Result = Push<Arr, 4>   // 将会推导出: [1,2,3,4]

Pop

infer 用于提取除了最后一个元素之外的所有元素

type Pop<T extends unknown[]> = T extends [infer R, unknown] ? R : never

使用:
type Arr = [1,2,3]
type Result = Pop<Arr>   // 将会推导出: [1,2]

套路三:递归复用做循环

在 Javascript 中我们会用到递归来解决一些对条件不确定时的情况,比如:

这里有个数组Arr[1, 2, 3, 4, 5, 6],我们想把它倒置:[6, 5, 4, 3, 2, 1] 虽然可以使用 Array.property.revert,为了演示我们用递归解决这个问题。

const revert = (arr, result = []) => {
    if(arr.length === 1){
        return [arr[arr.length - 1], ...result]
    }
    const [first,...rest] = arr
    return [...revert(rest, result), first]
}
revert([1,2,3,4,5,6]) // 输出: [6, 5, 4, 3, 2, 1]

那么因为 Typescript 的类型编程里面并没有循环语句,所以我们只能借助递归解决上面的问题

type ReverseArr<Arr extends unknown[]> =
    Arr extends [infer First, ...infer Rest]
        ? [...ReverseArr<Rest>, First]
        : Arr;

如果递归层数过多,它还会报一个嵌套层数过多的错误,Typescript 4.6.X 版本最多只能嵌套 999 层,现在的版本我不知道,有待实验。

套路四:数组长度做计数

Typescript 的类型编程不是图灵完备的,前面说了这么多却没说数值运算(加减乘除),因为 typescript 的数值运算需要我们转变思维,思维将会回到最原始的 一个 🍎 + 一个 🍎 = 两个 🍎

type num1 = 1
type num2 = 1

num1 + num2  // 这种大漏特漏,Typescript根本就不认识

既然这种做法不可取,那怎么对两个类型相加呢,我们记得数组可以合并是吧!

type arr1 = [1, 2]

type arr2 = [3, 4, 5]
// 如果这两个数组合并起来呢?
type Result = [...arr1, ...arr2]  // [1,2,3,4,5]

然后 Typescript 对数组或者 Tuple 类型也提供了一个属性length,我们就可以使用 length 获取到数组的长度,从而变相的实现了数值的相加

type Add<A extends unknown[], B extends unknown[]> = [
  ...A,
  ...B
]['length']

type example = Add<arr1, arr2>  // 推断出:5

进一步优化一下,我们要想给 Add 的泛型传入数字呢?比如:Add<3, 4>然后推断出 7

显然我们需要通过传入的数字来创建数组,之后通过数组解构再合并就可以了。

创建数组:

type BuildArr<
  Length extends number,
  Ele extends unknown = unknown,
  Arr extends unknown[] = []
> = Arr['length'] extends Length ? Arr : BuildArr<Length, Ele, [...Arr, Ele]>

使用上

type Result = BuildArr<3> // 将会推断出: [unknown,unknown,unknown]

之后我们再把创建数组BuildArrAdd结合起来!

/**
 *  加法
 */
type BuildArr<
  Length extends number,
  Ele extends unknown = unknown,
  Arr extends unknown[] = []
> = Arr['length'] extends Length ? Arr : BuildArr<Length, Ele, [...Arr, Ele]>

// 数组长度做计数 通过两个数组合并成一个数组然后length取数字 type Arrlength<arr> = arr['length']

type Add<A extends number, B extends number> = [
  ...BuildArr<A>,
  ...BuildArr<B>
]['length']

type example = Add<15, 30>   // 将会推断出:45

至于能不能运算浮点数,typescript 不支持,暂时只能整数运算。 减法乘法除法可以留下来给各位实现一下

套路五:联合分散做简化

❓ 联合分散是什么意思呢?Typescript 有种概念叫分布式条件类型

🎈 该特性出现在当联合类型作为条件类型的时候,联合类型的每一项将会逐个(分布式的)判断条件是否满足,从而最终返回不一样的类型结果。

例如:下面的代码把 Union 类型里面的全部 string 类型大写转成了小写

type Union = 'A' | 'B' | 'C'

type LowercaseAll<U> = U extends string ? Lowercase<U> : never

type Result2 = LowercaseAll<Union> // 将会推断出:'a' | 'b' | 'c'

❓ 简化是什么意思呢?

实际应用上可能就是把能组合的所有可能性都通过类型约束起来了,大大地减少了不必要的代码重复编写。比如:

// 常规我们傻傻的定义了三个甚至更多相似的类型 一堆重复的代码,第二天就被开除了
type Option1 = {
    option: '蹦迪',
    all: '蹦迪' | '喝酒' | '抽烟'
}
type Option2 = {
    option: '喝酒',
    all: '蹦迪' | '喝酒' | '抽烟'
}
type Option3 = {
    option: '抽烟',
    all: '蹦迪' | '喝酒' | '抽烟'
}
...
// 所有的选项
type AllOptions =  Option1 | Option2 | Option3

有了分布式条件类型,我们大大缩减了代码量

type UseUnion<T, All = T> = T extends T ? {
    option: T,
    all: All
} : never

type AllOptions = UseUnion<'蹦迪' | '喝酒' | '抽烟'>

// 将会推导出:
type AllOptions = {
    option: '蹦迪',
    all: '蹦迪' | '喝酒' | '抽烟'
} | {
    option: '喝酒',
    all: '蹦迪' | '喝酒' | '抽烟'
} | {
    option: '抽烟',
    all: '蹦迪' | '喝酒' | '抽烟'
}

解释一下上面的泛型入参All = T 和条件类型T extends T

All = T

由于结果的all属性需要一个整个联合类型 '蹦迪' | '喝酒' | '抽烟',所以在泛型入参的时候保存了T

T extends T

为了触发联合类型的分布式条件类型,T 的每一项将会一个接着一个的传入到条件类型中。 vOFklR.png

3. Git rebase

日常的开发使用 Git 的时候我们通常会产生一些不必要的 commit,比如commit hash: xxxx 暂存commit hash:xxx feat(列表):UI界面完成度70% 等等......为了使提交记录更加美观,直接,可读,我们可以考虑使用 git 的 rebase 去合并这些临时 commit。

这是 Vue3 的 commit 夹杂着不同的开发者贡献的 commit,但是却清晰明了,没有任何多余的 merge 节点,commit 树呈现出一条直线,这得益于 git rebase 的特性,剪切 commit 并拼接在主分支的头部(不仅仅是头部,任何地方都可以)。 image.png

然而我们的提交却时常伴随着一些奇怪的 commit,如果你使用的合并方式是 merge,那么它极易为你自动生成一个合并 commit。

image.png

我们假设一种情况,如果 A 分支是 main 分支(主分支)检出来的分支,并且某人开始使用 A 分支进行开发,这时组长又在 main 分支的基础上进行开发,这就产生了并行开发。过了一段时间当 A 分支被开发完成,如果 A 分支并没有拉取 main 分支的最新代码,而是直接 merge 到 main 分支的话,那么 Git 将会对A分支A分支和main分支的共同祖先main分支进行三方合并,自动生成了一个合并分支(merge branch A of xxxxxx into main)并且它的合并策略是recursive

image.png

什么是三方合并?哪三方?

一方是自己的commit、一方是同事的commit、最后一方是我和他之间最近祖先的commit

base 祖先
···
30行    console.log(111)
···

我
···
30行    console.log(222)
···

同事
···
30行    console.log(111)
···

在这种情况下Git就会对代码进行三方(我、同事、base)合并,发现只有我更新了代码,那么Git就会自动的把我的 最新代码console.log(222)合并进去。

为什么会产生冲突?

三方的commit代码都互不相同。例如:

base 祖先
···
30行    console.log(111)
···

我
···
30行    console.log(222)
···

同事
···
30行    console.log(333)
···

你猜Git会相信谁?Git谁也不会相信,并给你一大嘴巴子,说自己手动解决冲突去!

请看下图,main 分支的代码已经领先了 A 分支,A 分支是从 main 分支检出来的,A 分支上也已经开发了一部分的代码了。

image.png

之后我切换到 main 分支并执行 merge 命令

🟢 git checkout main
🟢 git merge A
Auto-merging src/components/HelloWorld.vue
CONFLICT (content): Merge conflict in src/components/HelloWorld.vue
Automatic merge failed; fix conflicts and then commit the result.

// 这里产生冲突了,我解决完所有冲突之后,再次addcommit
🟢 git add -A
🟢 git commit -m '解决冲突'
[main e509085] 解决冲突
🟢 git push origin main
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 226 bytes | 75.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:HongxuanG/rebase-test.git
   d9aea2c..e509085  main -> main

image.png

main 最新代码合并 A 分支后,代码已经提交上去 main 分支了,由于我处理了冲突,无奈只能又 commit 了一次。如果没有冲突的话,这里的解决冲突 commit 将会是自动生成的合并分支merge branch A of github.com:HongxuanG/rebase-test into main

那我们如何避免这种情况呢?

最简单的就是 A 分支开发完成之后拉取一次 main 分支的最新代码,然后再 merge 到 main 分支上。其实这种操作也类似于rebase的操作,把最新的代码放到该分支的顶部。

如何使用 rebase?

最简单的就是

// 我们先切换到 A 分支
🟢 git checkout A    // 切换到A分支
🟢 git rebase main   // 在A分支rebase到main分支



error: could not apply 1bd8a5e... 不知情的同事提交了戴安
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
Auto-merging src/components/HelloWorld.vue
CONFLICT (content): Merge conflict in src/components/HelloWorld.vue

这时候 A 分支和 main 分支的代码起冲突了,上面的提示说解决冲突之后你可以通过 git add 添加到暂存区或者 git rm 取消该文件的跟踪,之后通过 git rebase --continue 继续执行 rebase 操作,如果想跳过本次冲突你可以使用 git rebase --skip。如果你想取消本次的 rebase,那么你可以使用git rebase --abort,代码将会恢复到执行 rebase 之前

那我当然要使用 git add 呀

🟢 git add -A
🟢 git rebase --continue
[detached HEAD f2146d1] 不知情的同事提交了戴安
 1 file changed, 2 insertions(+)
Successfully rebased and updated refs/heads/A.   // rebase 成功

接下来再看看 commit 树。

image.png

本地 A 分支已经来到了 main 分支顶部了。

如果想让本地分支 A 与远程分支 A 同步,你继续使用 git push origin A 去让远程分支 A 也指向 main 分支这层的话,Git 将会阻止你这么做。

这是因为 git 的 push 操作默认是假设远程分支和你本地的分支可以进行fast-forward(合并策略)操作,换句话说就是这个 push 命令假设你的本地分支和远端分支的唯一区别是你本地有几个新的 commit,而远端没有。

但是由于进行了 rebase 操作,现在本地的 A 多了几个之前从没见过的 commit,这种情况下是不能进行 fast-forwad 模式的合并操作的,所以当执行  git push origin A  命令时会报错误。

要解决这个问题,首先确保 A 分支是你一个人在开发,如果满足这个条件,那你大可以使用git push origin A --force

🟢 git push origin A --force
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 4 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 473 bytes | 473.00 KiB/s, done.
Total 5 (delta 4), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (4/4), completed with 4 local objects.
To github.com:HongxuanG/rebase-test.git
 + 1bd8a5e...f2146d1 A -> A (forced update)

image.png

这时候还没完,main 分支还没合并 A 分支,我们需要切换到 main 分支然后执行 merge 操作。

🟢 git checkout main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
🟢 git merge A
Updating d9aea2c..f2146d1
Fast-forward                           // 这里的marge相当爽快,并没有给你产生任何冲突
 src/components/HelloWorld.vue | 2 ++
 1 file changed, 2 insertions(+)

image.png

本地的 main 分支已经走到 A 分支这层了。然后再执行 git push origin main

🟢 git push origin main
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:HongxuanG/rebase-test.git
   d9aea2c..f2146d1  main -> main

再看一下 commit 树,是不是比普通的 merge 整洁多了。 image.png

如何使用 Git 的 rebase 合并 commit

命令:git rebase -i commitHash1{commitHash1} {commitHash2} 其中 -i 是开启可视化面板

为了演示我们随便合并 A 分支的最近 4 个 commit,图中标注的都是对应的 commit hash

image.png

很好奇为什么我要合并 4 个 commit 但是上图却标注了 5 个 commit hash 呢,那是因为参数commitHash1是开区间,而commitHash2是闭区间 (commitHash1, commitHash2] 所以 commitHash1 需要取上一个 commit hash

执行 rebase 命令
git rebase -i f2146d1ab0bd7591e3ea46910c2309dee0e4ee35 cd463eb10b1fb5d6d13e10a12f46041e85147962

然后 Terminal 会弹出一个可视化界面

image.png

pick:保留该 commit(缩写:p)

reword:保留该 commit,但我需要修改该 commit 的注释(缩写:r)

edit:保留该 commit, 但我要停下来修改该提交(不仅仅修改注释)(缩写:e)

squash:将该 commit 和前一个 commit 合并(缩写:s)

fixup:将该 commit 和前一个 commit 合并,但我不要保留该提交的注释信息(缩写:f)

exec:执行 shell 命令(缩写:x)

drop:我要丢弃该 commit(缩写:d)

按下任意键进入编辑模式,因为这些临时提交我们是不需要的,所以我把前面三个 commit 都标注为 s,随后按下 ESC 退出编辑模式,输出命令 :wq 退出即可

image.png

// 起冲突了
error: could not apply cd463eb... feat: 已完成功能
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply cd463eb... feat: 已完成功能
Auto-merging src/components/index.ts
CONFLICT (content): Merge conflict in src/components/index.ts
🟢 git add -A  // 提交到暂存区
🟢 git rebase --continue  // 继续rebase操作
Successfully rebased and updated detached HEAD.

查看 commit 树,发现一个新的分支自动生成了,仔细看里面的代码,发现之前三次的临时提交的代码已经完成的合并到该分支中。我称它为游离分支

image.png

接下来要做的是:切换回本地的 A 分支,然后把指针指向这个游离分支就可以了。

🟢 git checkout A
Warning: you are leaving 1 commit behind, not connected to
any of your branches:

  ae9b9ce feat: 已完成功能

If you want to keep it by creating a new branch, this may be a good time
to do so with:

 git branch <new-branch-name> ae9b9ce

Switched to branch 'A'

由于这是一个游离分支,Git 提示我们如果不想让游离分支消失,现在最好为此分支创建一个"新家",使用 git branch ${new-branch-name} ae9b9ce,我们不需要这么做,直接让 A 分支的指针指向这个游离分支好了。

🟢 git reset --hard ae9b9ce
HEAD is now at ae9b9ce feat: 已完成功能

大功告成!现在来看看成果吧。

image.png

是不是干净了很多。

最后

Git 官方给出这么一条准则:

如果提交存在于你的仓库之外,而别人可能基于这些提交进行开发,那么不要执行变基。

意思就是说,请确保当前开发的分支没有任何人也在此分支上开发(除了你自己),否则其他人 commit 的时候将会覆盖掉你之前在此分支上的 commit 。

也就是说:不要在main或者master分支上做 rebase 操作,使用 merge 就能对已经进行 rebase 到 main 或者 master 的分支进行快速合并了。

好文推荐

📝 Learning git branching

learngitbranching.js.org/?locale=zh_…

📚 Understanding useMemo and useCallback

www.joshwcomeau.com/react/useme…

📚 Why React Re-Renders

www.joshwcomeau.com/react/why-r…