普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月25日技术

每日一题-根据数字二进制下 1 的数目排序🟢

2026年2月25日 00:00

给你一个整数数组 arr 。请你将数组中的元素按照其二进制表示中数字 1 的数目升序排序。

如果存在多个数字二进制中 1 的数目相同,则必须将它们按照数值大小升序排列。

请你返回排序后的数组。

 

示例 1:

输入:arr = [0,1,2,3,4,5,6,7,8]
输出:[0,1,2,4,8,3,5,6,7]
解释:[0] 是唯一一个有 0 个 1 的数。
[1,2,4,8] 都有 1 个 1 。
[3,5,6] 有 2 个 1 。
[7] 有 3 个 1 。
按照 1 的个数排序得到的结果数组为 [0,1,2,4,8,3,5,6,7]

示例 2:

输入:arr = [1024,512,256,128,64,32,16,8,4,2,1]
输出:[1,2,4,8,16,32,64,128,256,512,1024]
解释:数组中所有整数二进制下都只有 1 个 1 ,所以你需要按照数值大小将它们排序。

示例 3:

输入:arr = [10000,10000]
输出:[10000,10000]

示例 4:

输入:arr = [2,3,5,7,11,13,17,19]
输出:[2,3,5,17,7,11,13,19]

示例 5:

输入:arr = [10,100,1000,10000]
输出:[10,100,10000,1000]

 

提示:

  • 1 <= arr.length <= 500
  • 0 <= arr[i] <= 10^4

简单题,简单做(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2025年11月7日 10:29

双关键字排序。对于 $\textit{arr}$ 中的两个数:

  • 先比较二者的二进制 $1$ 的个数是否相同,若不同,$1$ 的个数少的排前面。
  • 若二进制 $1$ 的个数相同,那么数值小的排前面。

###py

class Solution:
    def sortByBits(self, arr: List[int]) -> List[int]:
        return sorted(arr, key=lambda x: (x.bit_count(), x))

###java

class Solution {
    public int[] sortByBits(int[] arr) {
        return IntStream.of(arr)
                .boxed()
                .sorted((a, b) -> {
                    int ca = Integer.bitCount(a);
                    int cb = Integer.bitCount(b);
                    return ca != cb ? ca - cb : a - b;
                })
                .mapToInt(a -> a)
                .toArray();
    }
}

###java

class Solution {
    public int[] sortByBits(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            arr[i] = Integer.bitCount(arr[i]) << 16 | arr[i];
        }
        Arrays.sort(arr);
        for (int i = 0; i < arr.length; i++) {
            arr[i] &= 0xffff;
        }
        return arr;
    }
}

###cpp

class Solution {
public:
    vector<int> sortByBits(vector<int>& arr) {
        ranges::sort(arr, {}, [](int x) {
            return pair(popcount((uint32_t) x), x);
        });
        return arr;
    }
};

###c

int cmp(const void* a, const void* b) {
    int x = *(int*)a, y = *(int*)b;
    int cx = __builtin_popcount(x), cy = __builtin_popcount(y);
    return cx != cy ? cx - cy : x - y;
}

int* sortByBits(int* arr, int arrSize, int* returnSize) {
    qsort(arr, arrSize, sizeof(int), cmp);
    *returnSize = arrSize;
    return arr;
}

###go

func sortByBits(arr []int) []int {
slices.SortFunc(arr, func(a, b int) int {
return cmp.Or(bits.OnesCount(uint(a))-bits.OnesCount(uint(b)), a-b)
})
return arr
}

###js

var sortByBits = function(arr) {
    return arr.sort((a, b) => bitCount32(a) - bitCount32(b) || a - b);
};

// 参考 Java 的 Integer.bitCount
function bitCount32(i) {
    i = i - ((i >>> 1) & 0x55555555);
    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
    i = (i + (i >>> 4)) & 0x0f0f0f0f;
    i = i + (i >>> 8);
    i = i + (i >>> 16);
    return i & 0x3f;
}

###rust

impl Solution {
    pub fn sort_by_bits(mut arr: Vec<i32>) -> Vec<i32> {
        arr.sort_unstable_by_key(|&x| (x.count_ones(), x));
        arr
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{arr}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。忽略排序的栈开销。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

位运算和排序,看完你能写出上百种答案。

作者 sdwwld
2020年11月6日 10:00

解题思路

第一步:先求出位 1 的个数

这题是让按照位 1 的个数来排序,首先要求出 1 的个数才能参与后面的排序,关于求一个数二进制中 1 的个数,我之前写了 18 种写法,这里直接列出来,就不在详细介绍,如果有看不懂的可以在下面留言,我给你一一解答。

1、把 $n$ 往右移 32 次,每次都和 1 进行与运算

public int hammingWeight(int n) {
    int count = 0;
    for (int i = 0; i < 32; i++) {
        if (((n >>> i) & 1) == 1) {
            count++;
        }
    }
    return count;
}

2、原理和上面一样,做了一点优化

public int hammingWeight(int n) {
    int count = 0;
    while (n != 0) {
        count += n & 1;
        n = n >>> 1;
    }
    return count;
}

3、每次往左移一位,再和 $n$ 进行与运算

public int hammingWeight(int n) {
    int count = 0;
    for (int i = 0; i < 32; i++) {
        if ((n & (1 << i)) != 0) {
            count++;
        }
    }
    return count;
}

4、每次往左移一位,把运算的结果在右移判断是否是 1

public int hammingWeight(int i) {
    int count = 0;
    for (int j = 0; j < 32; j++) {
        if ((i & (1 << j)) >>> j == 1)
            count++;
    }
    return count;
}

5、这个是最常见的,每次消去最右边的 1,直到消完为止

public int hammingWeight(int n) {
    int count = 0;
    while (n != 0) {
        n &= n - 1;
        count++;
    }
    return count;
}

6、把上面的改为递归

public int hammingWeight(int n) {
    return n == 0 ? 0 : 1 + hammingWeight(n & (n - 1));
}

7、查表

public int hammingWeight(int i) {
    //table是0到15转化为二进制时1的个数
    int table[] = {0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4};
    int count = 0;
    while (i != 0) {//通过每4位计算一次,求出包含1的个数
        count += table[i & 0xf];
        i >>>= 4;
    }
    return count;
}

8、每两位存储,使用加法(先运算再移位)

public int hammingWeight(int n) {
    n = ((n & 0xaaaaaaaa) >>> 1) + (n & 0x55555555);
    n = ((n & 0xcccccccc) >>> 2) + (n & 0x33333333);
    n = (((n & 0xf0f0f0f0) >>> 4) + (n & 0x0f0f0f0f));
    n = n + (n >>> 8);
    n = n +  (n >>> 16);
    return n & 63;
}

9、每两位存储,使用加法(先移位再运算)

public int hammingWeight(int n) {
    n = ((n >>> 1) & 0x55555555) + (n & 0x55555555);
    n = ((n >>> 2) & 0x33333333) + (n & 0x33333333);
    n = (((n >>> 4) & 0x0f0f0f0f) + (n & 0x0f0f0f0f));
    n = n + (n >>> 8);
    n = n + (n >>> 16);
    return n & 63;
}

10、和第 8 种思路差不多,只不过在最后几行计算的时候过滤的比较干净

public int hammingWeight(int n) {
    n = ((n & 0xaaaaaaaa) >>> 1) + (n & 0x55555555);
    n = ((n & 0xcccccccc) >>> 2) + (n & 0x33333333);
    n = (((n & 0xf0f0f0f0) >>> 4) + (n & 0x0f0f0f0f));
    n = (((n & 0xff00ff00) >>> 8) + (n & 0x00ff00ff));
    n = (((n & 0xffff0000) >>> 16) + (n & 0x0000ffff));
    return n;
}

11、每 4 位存储,使用加法

public int hammingWeight(int n) {
    n = (n & 0x11111111) + ((n >>> 1) & 0x11111111) + ((n >>> 2) & 0x11111111) + ((n >>> 3) & 0x11111111);
    n = (((n & 0xf0f0f0f0) >>> 4) + (n & 0x0f0f0f0f));
    n = n + (n >>> 8);
    n = n + (n >>> 16);
    return n & 63;
}

12、每 3 位存储,使用加法

public int hammingWeight(int n) {
    n = (n & 011111111111) + ((n >>> 1) & 011111111111) + ((n >>> 2) & 011111111111);
    n = ((n + (n >>> 3)) & 030707070707);
    n = ((n + (n >>> 6)) & 07700770077);
    n = ((n + (n >>> 12)) & 037700007777);
    return ((n + (n >>> 24))) & 63;
}

13、每 5 位存储,使用加法

public int hammingWeight(int n) {
    n = (n & 0x42108421) + ((n >>> 1) & 0x42108421) + ((n >>> 2) & 0x42108421) + ((n >>> 3) & 0x42108421) + ((n >>> 4) & 0x42108421);
    n = ((n + (n >>> 5)) & 0xc1f07c1f);
    n = ((n + (n >>> 10) + (n >>> 20) + (n >>> 30)) & 63);
    return n;
}

14、每两位存储,使用减法(先运算再移位)

public int hammingWeight(int i) {
    i = i - ((i >>> 1) & 0x55555555);
    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
    i = (i + (i >>> 4)) & 0x0f0f0f0f;
    i = i + (i >>> 8);
    i = i + (i >>> 16);
    return i & 0x3f;
}

15、每 3 位存储,使用减法

public int hammingWeight(int n) {
    n = n - ((n >>> 1) & 033333333333) - ((n >>> 2) & 011111111111);
    n = ((n + (n >>> 3)) & 030707070707);
    n = ((n + (n >>> 6)) & 07700770077);
    n = ((n + (n >>> 12)) & 037700007777);
    return ((n + (n >>> 24))) & 63;
}

16、每 4 位存储,使用减法

public int hammingWeight(int n) {
    int tmp = n - ((n >>> 1) & 0x77777777) - ((n >>> 2) & 0x33333333) - ((n >>> 3) & 0x11111111);
    tmp = ((tmp + (tmp >>> 4)) & 0x0f0f0f0f);
    tmp = ((tmp + (tmp >>> 8)) & 0x00ff00ff);
    return ((tmp + (tmp >>> 16)) & 0x0000ffff) % 63;
}

17、每 5 位存储,使用减法

public int hammingWeight(int n) {
    n = n - ((n >>> 1) & 0xdef7bdef) - ((n >>> 2) & 0xce739ce7) - ((n >>> 3) & 0xc6318c63) - ((n >>> 4) & 0x02108421);
    n = ((n + (n >>> 5)) & 0xc1f07c1f);
    n = ((n + (n >>> 10) + (n >>> 20) + (n >>> 30)) & 63);
    return n;
}

18、每次消去最右边的 1,可以参照第 5 种解法

public static int hammingWeight(int num) {
    int total = 0;
    while (num != 0) {
        num -= num & (-num);
        total++;
    }
    return total;
}


第二步:再根据位 1 的个数进行排序

关于排序算法我之前也写了十几种
101,排序-冒泡排序
102,排序-选择排序
103,排序-插入排序
104,排序-快速排序
105,排序-归并排序
106,排序-堆排序
107,排序-桶排序
108,排序-基数排序
109,排序-希尔排序
110,排序-计数排序
111,排序-位图排序
112,排序-其他排序


第三步:最终答案
有了计算位 1 的方法,又有了排序的方法,所以我们可以随便自由组合,如果都组合一遍估计要写上百种答案了,这里不可能写那么多,我们只写一个看看,这里就用 位运算的第 5 种方式 和排序的第 3 种方式 插入排序 来写下

    public int[] sortByBits(int[] arr) {
        int[][] temp = new int[arr.length][2];
        for (int i = 0; i < arr.length; i++) {
            temp[i][0] = arr[i];
            temp[i][1] = hammingWeight(arr[i]);
        }
        insertSort(temp);
        for (int i = 0; i < arr.length; i++) {
            arr[i] = temp[i][0];
        }
        return arr;
    }

    private void insertSort(int[][] array) {
        for (int i = 1; i < array.length; i++) {
            int j;
            int[] temp = array[i];
            for (j = i - 1; j >= 0; j--) {
                //先比较位1的大小,如果相同再比较数字的大小
                if (array[j][1] > temp[1] || (array[j][1] == temp[1] && array[j][0] > temp[0])) {
                    array[j + 1] = array[j];//往后挪
                } else {
                    break;//没有交换就break
                }
            }
            array[j + 1] = temp;
        }
    }

    public int hammingWeight(int n) {
        int count = 0;
        while (n != 0) {
            n &= n - 1;
            count++;
        }
        return count;
    }

当然我们还可以使用官方的提供的类 PriorityQueue 也是可以的

    public int[] sortByBits(int[] arr) {
        PriorityQueue<int[]> priorityQueue = new PriorityQueue<>((a, b) -> a[1] == b[1] ? a[0] - b[0] : a[1] - b[1]);
        for (int i = 0; i < arr.length; i++) {
            priorityQueue.add(new int[]{arr[i], hammingWeight(arr[i])});
        }
        int index = 0;
        while (!priorityQueue.isEmpty()) {
            arr[index++] = priorityQueue.poll()[0];
        }
        return arr;
    }

    public int hammingWeight(int n) {
        int count = 0;
        while (n != 0) {
            n &= n - 1;
            count++;
        }
        return count;
    }

我把部分算法题整理成了PDF文档,截止目前总共有900多页,大家可以下载阅读
链接:https://pan.baidu.com/s/1hjwK0ZeRxYGB8lIkbKuQgQ
提取码:6666

如果觉得有用就给个赞吧,还可以关注我的LeetCode主页查看更多的详细题解

Java 两次循环打败 100%

作者 yourtion
2020年5月3日 19:07

解题思路

循环并使用 Integer.bitCount 计算数字中1的个数,乘以10000000(题目中不会大于 10^4)然后加上原数字,放入数组 map 中,并对 map 进行排序,最后 % 10000000 获取原来的数组,填充到原数组返回即可。

image.png{:width="350px"}{:align="left"}

代码

###Java

class Solution {
    public int[] sortByBits(int[] arr) {
        int[] map = new int[arr.length];
        for (int i = 0; i < arr.length; i++) {
            map[i] = Integer.bitCount(arr[i]) * 10000000 + arr[i];
        }
        Arrays.sort(map);
        for (int i = 0; i < map.length; i++) {
            map[i] = map[i] % 10000000;
        }
        return map;
    }
}
昨天 — 2026年2月24日技术

ReactNative新架构之iOS端TurboModule源码剖析

2026年2月24日 21:55

ReactNative新架构之iOS端TurboModule源码剖析

前言

注意,本文是基于React Native 0.83版本源码进行分析。

前面已经通过《ReactNative新架构之Android端TurboModule机制完全解析》了解了很多TurboModule的关键信息,接下来iOS端的分析将会略过一些已知内容,聚焦重点。

初始化

先回顾一下《React Native新架构之iOS端初始化源码分析》一文我们提过的TurboModule初始化流程:

AppDelegate中有:

    // 1: 创建 ReactNativeDelegate
    let delegate = ReactNativeDelegate()
    // 2: 创建 RCTReactNativeFactory
    let factory = RCTReactNativeFactory(delegate: delegate)
    // 3: 配置依赖提供者(Codegen 生成的模块)
    delegate.dependencyProvider = RCTAppDependencyProvider()

接下来再看看RCTReactNativeFactory 中对RCTTurboModuleManagerDelegate协议的实现:

- (Class)getModuleClassFromName:(const char *)name
{
#if RN_DISABLE_OSS_PLUGIN_HEADER
  return RCTTurboModulePluginClassProvider(name);
#else
  // 先尝试从 delegate 获取
  if ([_delegate respondsToSelector:@selector(getModuleClassFromName:)]) {
    Class moduleClass = [_delegate getModuleClassFromName:name];
    if (moduleClass != nil) {
      return moduleClass;
    }
  }
  return RCTCoreModulesClassProvider(name);
#endif
}

- (nullable id<RCTModuleProvider>)getModuleProvider:(const char *)name
{
  if ([_delegate respondsToSelector:@selector(getModuleProvider:)]) {
    return [_delegate getModuleProvider:name];
  }
  return nil;
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  if ([_delegate respondsToSelector:@selector(getTurboModule:jsInvoker:)]) {
    return [_delegate getTurboModule:name jsInvoker:jsInvoker];
  }

  return facebook::react::DefaultTurboModules::getTurboModule(name, jsInvoker);
}

- (id<RCTTurboModule>)getModuleInstanceFromClass:(Class)moduleClass
{
#if USE_OSS_CODEGEN
  if (self.delegate.dependencyProvider == nil) {
    [NSException raise:@"ReactNativeFactoryDelegate dependencyProvider is nil"
                format:@"Delegate must provide a valid dependencyProvider"];
  }
#endif
  if ([_delegate respondsToSelector:@selector(getModuleInstanceFromClass:)]) {
    id<RCTTurboModule> moduleInstance = [_delegate getModuleInstanceFromClass:moduleClass];
    if (moduleInstance != nil) {
      return moduleInstance;
    }
  }
  // 使用默认方式创建(通过 dependencyProvider)
  return RCTAppSetupDefaultModuleFromClass(moduleClass, self.delegate.dependencyProvider);
}

可以看到,这里面优先调用_delegate中的方法,而我们在自定义的ReactNativeDelegate中并未实现这些方法,那么只能去父类找,也就是RCTDefaultReactNativeFactoryDelegate.mm

@implementation RCTDefaultReactNativeFactoryDelegate

@synthesize dependencyProvider;

// 省略部分代码......

- (nullable id<RCTModuleProvider>)getModuleProvider:(const char *)name
{
  NSString *providerName = [NSString stringWithCString:name encoding:NSUTF8StringEncoding];
  return (self.dependencyProvider != nullptr) ? self.dependencyProvider.moduleProviders[providerName] : nullptr;
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  return facebook::react::DefaultTurboModules::getTurboModule(name, jsInvoker);
}

- (Class)getModuleClassFromName:(const char *)name
{
  return nullptr;
}

- (id<RCTTurboModule>)getModuleInstanceFromClass:(Class)moduleClass
{
  return nullptr;
}

@end

可以看到,getModuleClassFromNamegetModuleInstanceFromClass并没有具体实现。而getModuleProvider方法,也依赖其属性dependencyProvider。这里的dependencyProvider是由外部赋值,也就是初始化时创建的RCTAppDependencyProvider对象。

这里的RCTAppDependencyProvider实际上是由codegen工具自动生成的,并不在代码中。

TurboModule是懒加载的,这里主要是设置了Provider。继续回顾一下RCTInstance.mm中的_start方法:

  // 创建 TurboModuleManager
  _turboModuleManager = [[RCTTurboModuleManager alloc] initWithBridgeProxy:bridgeProxy
                                                     bridgeModuleDecorator:_bridgeModuleDecorator
                                                                  delegate:self
                                                                 jsInvoker:jsCallInvoker
                                             devMenuConfigurationDecorator:_devMenuConfigurationDecorator];

  // 省略......

  // 创建 ContextContainer
  auto contextContainer = std::make_shared<ContextContainer>();
  [_delegate didCreateContextContainer:contextContainer];
  // 插入核心模块
  contextContainer->insert(
      "RCTImageLoader", facebook::react::wrapManagedObject([_turboModuleManager moduleForName:"RCTImageLoader"]));
  contextContainer->insert(
      "RCTEventDispatcher",
      facebook::react::wrapManagedObject([_turboModuleManager moduleForName:"RCTEventDispatcher"]));


  // 初始化 JS Runtime 
  _reactInstance->initializeRuntime(options, [=](jsi::Runtime &runtime) {
    __strong __typeof(self) strongSelf = weakSelf;
    if (!strongSelf) {
      return;
    }

    // 安装 TurboModule JS 绑定
    [strongSelf->_turboModuleManager installJSBindings:runtime];
    // 绑定 Native Logger
    facebook::react::bindNativeLogger(runtime, [](const std::string &message, unsigned int logLevel) {
      _RCTLogJavaScriptInternal(static_cast<RCTLogLevel>(logLevel), [NSString stringWithUTF8String:message.c_str()]);
    });

    // 安装 Native Component Registry 绑定
    RCTInstallNativeComponentRegistryBinding(runtime);
    // 省略......
  });

继续跟踪RCTTurboModuleManager的初始化,源码react-native/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm

- (instancetype)initWithBridgeProxy:(RCTBridgeProxy *)bridgeProxy
              bridgeModuleDecorator:(RCTBridgeModuleDecorator *)bridgeModuleDecorator
                           delegate:(id<RCTTurboModuleManagerDelegate>)delegate
                          jsInvoker:(std::shared_ptr<CallInvoker>)jsInvoker
      devMenuConfigurationDecorator:(RCTDevMenuConfigurationDecorator *)devMenuConfigurationDecorator
{
  return [self initWithBridge:nil
                        bridgeProxy:bridgeProxy
              bridgeModuleDecorator:bridgeModuleDecorator
                           delegate:delegate
                          jsInvoker:jsInvoker
      devMenuConfigurationDecorator:devMenuConfigurationDecorator];
}



- (instancetype)initWithBridge:(RCTBridge *)bridge
                      bridgeProxy:(RCTBridgeProxy *)bridgeProxy
            bridgeModuleDecorator:(RCTBridgeModuleDecorator *)bridgeModuleDecorator
                         delegate:(id<RCTTurboModuleManagerDelegate>)delegate
                        jsInvoker:(std::shared_ptr<CallInvoker>)jsInvoker
    devMenuConfigurationDecorator:(RCTDevMenuConfigurationDecorator *)devMenuConfigurationDecorator
{
  if (self = [super init]) {
    _jsInvoker = std::move(jsInvoker);
    _delegate = delegate;
    _bridge = bridge;
    _bridgeProxy = bridgeProxy;
    _bridgeModuleDecorator = bridgeModuleDecorator;
    _invalidating = false;
    // 创建串行队列 _sharedModuleQueue 
    _sharedModuleQueue = dispatch_queue_create("com.meta.react.turbomodulemanager.queue", DISPATCH_QUEUE_SERIAL);
    _devMenuConfigurationDecorator = devMenuConfigurationDecorator;

    if (RCTTurboModuleInteropEnabled()) {
      // 省略旧架构兼容处理......
    }

    // 监听 RCTBridgeWillInvalidateModulesNotification 和 RCTBridgeDidInvalidateModulesNotification 通知,用于处理模块失效场景
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(bridgeWillInvalidateModules:)
                                                 name:RCTBridgeWillInvalidateModulesNotification
                                               object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(bridgeDidInvalidateModules:)
                                                 name:RCTBridgeDidInvalidateModulesNotification
                                               object:nil];
  }
  return self;
}

RCTTurboModuleManager初始化逻辑非常简单,我们继续查看[strongSelf->_turboModuleManager installJSBindings:runtime]方法调用,它是在JS Runtime初始化完成之后才回调的,现在跟踪一下具体实现,源码react-native/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm

- (void)installJSBindings:(facebook::jsi::Runtime &)runtime
{
  /**
   * 我们会一直保留 TurboModuleManager 实例,直到 JavaScript 虚拟机被销毁。
   * 仅从 JavaScript 端使用/创建 TurboModule 是完全有效的。
   * 在这种情况下,如果 Objective-C 代码中没有对 TurboModuleManager 的强引用,我们就不应该释放它。
   * 因此,我们让 __turboModuleProxy 持有对 TurboModuleManager 的强引用。
   */
  auto turboModuleProvider = [self,
                              runtime = &runtime](const std::string &name) -> std::shared_ptr<react::TurboModule> {
    auto moduleName = name.c_str();

    TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);

    // 检查模块是否已初始化
    auto moduleWasNotInitialized = ![self moduleIsInitialized:moduleName];
    if (moduleWasNotInitialized) {
      [self->_bridge.performanceLogger markStartForTag:RCTPLTurboModuleSetup];
    }

    /**
     * 默认情况下,所有 TurboModule 都是长期存在的。 
     * 此外,如果找不到名称为 `name` 的 TurboModule,则会触发断言失败。 
     */
    auto turboModule = [self provideTurboModule:moduleName runtime:runtime];

    if (moduleWasNotInitialized && [self moduleIsInitialized:moduleName]) {
      [self->_bridge.performanceLogger markStopForTag:RCTPLTurboModuleSetup];
    }

    if (turboModule) {
      TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    } else {
      TurboModulePerfLogger::moduleJSRequireEndingFail(moduleName);
    }
    return turboModule;
  };

  if (RCTTurboModuleInteropEnabled()) {
     // 省略旧架构兼容代码......
  } else {
    TurboModuleBinding::install(runtime, std::move(turboModuleProvider));
  }
}

该方法是负责将 TurboModule 系统绑定到 JavaScript 运行时,建立 JS 与原生模块之间的桥梁。

这里的TurboModuleBinding::install方法我们在Android端时已经分析过了,不再赘述。TurboModulePerfLogger是性能日志相关的东西,直接忽略。

所以整个iOS的初始化流程,相比Android端简单太多了。

调用流程

《ReactNative新架构之Android端TurboModule机制完全解析》一文中,我们已经详细分析过了从JS层到C++层的查找逻辑。现在回顾一下关键地方:

// TurboModuleBinding.cpp

jsi::Value TurboModuleBinding::getModule(
    jsi::Runtime& runtime,
    const std::string& moduleName) const {
  std::shared_ptr<TurboModule> module;
  {
    TraceSection s("TurboModuleBinding::moduleProvider", "module", moduleName);
    module = moduleProvider_(moduleName);  // ← 调用moduleProvider获取模块
  }
  if (module) {
    TurboModuleWithJSIBindings::installJSIBindings(module, runtime);
    // 省略......

    // 状态:在 TurboModule 上未找到 jsRepresentation
    // 创建一个全新的 jsRepresentation,并将其附加到 TurboModule
    jsi::Object jsRepresentation(runtime);
    weakJsRepresentation =
        std::make_unique<jsi::WeakObject>(runtime, jsRepresentation);

    // 在属性访问时延迟填充 jsRepresentation。
    //
    // 这是如何工作的?
    //   1. 最初 jsRepresentation 是空的:{}
    //   2. 如果在 jsRepresentation 上的属性查找失败,JS 运行时将
    //   搜索 jsRepresentation 的原型:jsi::Object(TurboModule)。
    //   3. TurboModule::get(runtime, propKey) 执行。这会创建
    //   属性,将其缓存在 jsRepresentation 上,然后将其返回给JavaScript
    auto hostObject =
        jsi::Object::createFromHostObject(runtime, std::move(module));
    jsRepresentation.setProperty(runtime, "__proto__", std::move(hostObject));

    return jsRepresentation; 
  } else {
    return jsi::Value::null();
  }
}

这里是通过moduleProvider_方法返回TurboModule实例。而moduleProvider_实际上就是TurboModuleBinding::install方法调用时设置的那个provider闭包。也就是上面分析的installJSBindings中的turboModuleProvider。回顾一下其实现:

auto turboModule = [self provideTurboModule:moduleName runtime:runtime];

闭包中是调用provideTurboModule返回turboModule,跟踪一下实现:

// RCTTurboModuleManager.mm


/**
 * 给定一个 TurboModule 的名称,返回一个 C++ 对象,该对象是
 * TurboModule C++ 类的实例。这个类包装了 TurboModule 的 ObjC 实例。
 * 如果不存在提供名称的 TurboModule ObjC 类,程序将中止。
 *
 * 注意:所有 TurboModule 实例都被缓存,这意味着它们都是长生命周期的(目前如此)
 */
- (std::shared_ptr<TurboModule>)provideTurboModule:(const char *)moduleName runtime:(jsi::Runtime *)runtime
{
  // 缓存查找
  auto turboModuleLookup = _turboModuleCache.find(moduleName);
  if (turboModuleLookup != _turboModuleCache.end()) {
    TurboModulePerfLogger::moduleJSRequireBeginningCacheHit(moduleName);
    TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);
    return turboModuleLookup->second;
  }

  TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);

 /**
  * 步骤 1:查找纯 C++ 模块。
  * 纯 C++ 模块具有优先级。
  */
  if ([_delegate respondsToSelector:@selector(getTurboModule:jsInvoker:)]) {
    int32_t moduleId = getUniqueId();
    TurboModulePerfLogger::moduleCreateStart(moduleName, moduleId);
    // 尝试从代理获取纯 C++ TurboModule
    auto turboModule = [_delegate getTurboModule:moduleName jsInvoker:_jsInvoker];
    if (turboModule != nullptr) {
      _turboModuleCache.insert({moduleName, turboModule});
      TurboModulePerfLogger::moduleCreateEnd(moduleName, moduleId);
      return turboModule;
    }

    TurboModulePerfLogger::moduleCreateFail(moduleName, moduleId);
  }

  // 在全局导出的 C++ TurboModule 映射中查找
  auto &cxxTurboModuleMapProvider = globalExportedCxxTurboModuleMap();
  auto it = cxxTurboModuleMapProvider.find(moduleName);
  if (it != cxxTurboModuleMapProvider.end()) {
    auto turboModule = it->second(_jsInvoker);
    _turboModuleCache.insert({moduleName, turboModule});
    return turboModule;
  }

  /**
   * 步骤 2:查找平台特定模块
   */
  id<RCTModuleProvider> module = [self _moduleProviderForName:moduleName];

  TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);

  // 如果我们请求创建一个 TurboModule,其相应的 ObjC 类必须存在
  // 如果类不存在,那么 _provideObjCModule 返回 nil
  if (!module) {
    return nullptr;
  }

  std::shared_ptr<NativeMethodCallInvoker> nativeMethodCallInvoker = nullptr;
  dispatch_queue_t methodQueue = (dispatch_queue_t)objc_getAssociatedObject(module, &kAssociatedMethodQueueKey);
  if (methodQueue) {
    /**
     * 步骤 2c:从 TurboModule 的方法队列创建原生 CallInvoker。
     */
    nativeMethodCallInvoker = std::make_shared<ModuleNativeMethodCallInvoker>(methodQueue);

    /**
     * 让 RCTCxxBridge 装饰原生 CallInvoker,使其能够感知 TurboModule 异步方法调用。
     * 这有助于桥接器及时触发 onBatchComplete。
     */
    if ([_bridge respondsToSelector:@selector(decorateNativeMethodCallInvoker:)]) {
      nativeMethodCallInvoker = [_bridge decorateNativeMethodCallInvoker:nativeMethodCallInvoker];
    }
  }

该方法是多级查找策略。查找优先级:

  1. 缓存查找 (最高优先级)
  2. 代理提供的纯 C++ 模块
  3. 全局 C++ 模块映射
  4. 平台特定 ObjC 模块 (最低优先级)

我们先跟踪一下纯C++ TurboModule的查找流程,这里的代理查找,其实最终就是调用DefaultTurboModules::getTurboModule(name, jsInvoker),这是前面已经得到的结论。来看一下其实现,源码react-native/packages/react-native/ReactCommon/react/nativemodule/defaults/DefaultTurboModules.cpp

/* static */ std::shared_ptr<TurboModule> DefaultTurboModules::getTurboModule(
    const std::string& name,
    const std::shared_ptr<CallInvoker>& jsInvoker) {
  if (name == NativeReactNativeFeatureFlags::kModuleName) {
    return std::make_shared<NativeReactNativeFeatureFlags>(jsInvoker);
  }

  if (name == NativeMicrotasks::kModuleName) {
    return std::make_shared<NativeMicrotasks>(jsInvoker);
  }

  if (name == NativeIdleCallbacks::kModuleName) {
    return std::make_shared<NativeIdleCallbacks>(jsInvoker);
  }

  if (name == NativeDOM::kModuleName) {
    return std::make_shared<NativeDOM>(jsInvoker);
  }

  if (ReactNativeFeatureFlags::enableWebPerformanceAPIsByDefault()) {
    if (name == NativePerformance::kModuleName) {
      return std::make_shared<NativePerformance>(jsInvoker);
    }
  }

  if (ReactNativeFeatureFlags::enableIntersectionObserverByDefault()) {
    if (name == NativeIntersectionObserver::kModuleName) {
      return std::make_shared<NativeIntersectionObserver>(jsInvoker);
    }
  }

#ifdef REACT_NATIVE_DEBUGGER_ENABLED_DEVONLY
  if (name == DevToolsRuntimeSettingsModule::kModuleName) {
    return std::make_shared<DevToolsRuntimeSettingsModule>(jsInvoker);
  }
#endif

  return nullptr;
}

可见,此处都是RN内置的一些模块。第三方要实现纯C++ TurboModule,那么注册集成就得考虑从globalExportedCxxTurboModuleMap中下手了。

现在继续查看_moduleProviderForName方法实现:

- (id<RCTModuleProvider>)_moduleProviderForName:(const char *)moduleName
{
  id<RCTModuleProvider> moduleProvider = nil;
  // 从代理获取模块提供者
  if ([_delegate respondsToSelector:@selector(getModuleProvider:)]) {
    moduleProvider = [_delegate getModuleProvider:moduleName];
  }

  if (RCTTurboModuleInteropEnabled() && ![self _isTurboModule:moduleName] && !moduleProvider) {
    return nil;
  }

  if (moduleProvider) {
    if ([moduleProvider conformsToProtocol:@protocol(RCTTurboModule)]) {
      // 如果模块提供者符合 RCTTurboModule 协议,需要初始化 ObjC 属性(如调度队列)
      return (id<RCTModuleProvider>)[self _provideObjCModule:moduleName moduleProvider:moduleProvider];
    }
    // 否则直接返回(C++ 模块)
    return moduleProvider;
  }

  // 如果没有找到模块提供者,尝试创建通过传统方式注册的 ObjC 模块
  return (id<RCTModuleProvider>)[self _provideObjCModule:moduleName moduleProvider:nil];
}


/**
 * 给定一个 NativeModule 的名称,返回该 NativeModule Objective-C 类的实例对象。如果不存在名称与给定名称匹配的 NativeModule,则返回 nil。
 *
 * 注意:所有 NativeModule 实例都会被缓存,这意味着它们都是长期存在的(目前是这样)。
 */
- (id<RCTBridgeModule>)_provideObjCModule:(const char *)moduleName moduleProvider:(id<RCTModuleProvider>)moduleProvider
{
  if (strncmp("RCT", moduleName, 3) == 0) {
    moduleName = [[[NSString stringWithUTF8String:moduleName] substringFromIndex:3] UTF8String];
  }

  ModuleHolder *moduleHolder = [self _getOrCreateModuleHolder:moduleName];

  if (!moduleHolder) {
    return nil;
  }

  TurboModulePerfLogger::moduleCreateStart(moduleName, moduleHolder->getModuleId());
  id<RCTBridgeModule> module = [self _provideObjCModule:moduleName
                                           moduleHolder:moduleHolder
                                          shouldPerfLog:YES
                                         moduleProvider:moduleProvider];

  if (module) {
    TurboModulePerfLogger::moduleCreateEnd(moduleName, moduleHolder->getModuleId());
  } else {
    TurboModulePerfLogger::moduleCreateFail(moduleName, moduleHolder->getModuleId());
  }

  return module;
}

// 获取或者创建一个ModuleHolder
- (ModuleHolder *)_getOrCreateModuleHolder:(const char *)moduleName
{
  std::lock_guard<std::mutex> guard(_moduleHoldersMutex);
  if (_invalidating) {
    return nullptr;
  }

  return &_moduleHolders[moduleName];
}

// 创建和管理 Objective-C 原生模块的实例,确保线程安全和单例模式
- (id<RCTBridgeModule>)_provideObjCModule:(const char *)moduleName
                             moduleHolder:(ModuleHolder *)moduleHolder
                            shouldPerfLog:(BOOL)shouldPerfLog
                           moduleProvider:(id<RCTModuleProvider>)moduleProvider
{
  bool shouldCreateModule = false;

  {
    std::lock_guard<std::mutex> guard(moduleHolder->mutex());

    // 如果模块已创建完成,直接返回缓存的实例
    if (moduleHolder->isDoneCreatingModule()) {
      if (shouldPerfLog) {
        TurboModulePerfLogger::moduleCreateCacheHit(moduleName, moduleHolder->getModuleId());
      }
      return moduleHolder->getModule();
    }

    if (!moduleHolder->isCreatingModule()) {
      shouldCreateModule = true;
      moduleHolder->startCreatingModule();
    }
  }

  if (shouldCreateModule) {
    /**
     * 步骤 2a:解析特定于平台的类
     */
    Class moduleClass = moduleProvider ? [moduleProvider class] : [self _getModuleClassFromName:moduleName];

    __block id<RCTBridgeModule> module = nil;

    // 判断是否应该创建模块(检查是否实现了RCTTurboModule协议)
    if ([self _shouldCreateObjCModule:moduleClass]) {
      __weak __typeof(self) weakSelf = self;
      dispatch_block_t work = ^{
        auto strongSelf = weakSelf;
        if (!strongSelf) {
          return;
        }
        module = [strongSelf _createAndSetUpObjCModule:moduleClass moduleName:moduleName moduleId:moduleHolder
                      ->getModuleId()];
      };

      // 某些模块需要在主线程初始化
      if ([self _requiresMainQueueSetup:moduleClass]) {
        RCTUnsafeExecuteOnMainQueueSync(work);
      } else {
        work();
      }
    }

    {
      std::lock_guard<std::mutex> guard(moduleHolder->mutex());

      moduleHolder->setModule(module);
      moduleHolder->endCreatingModule();
    }
    moduleHolder->cv().notify_all();

    return module;
  }

  std::unique_lock<std::mutex> guard(moduleHolder->mutex());

  // 如果其他线程正在创建模块,当前线程等待
  while (moduleHolder->isCreatingModule()) {
    /**
     * TODO(T65905574):
     * 如果负责创建和初始化 NativeModule 的线程发生阻塞,我们将在此处无限期等待
     * 这是传统 NativeModule 的行为。现在更改此行为可能会导致 TurboModule 中出现比 NativeModule 更多的崩溃/问题
     * 从而使 TurboModule 基础设施的测试更加困难。因此,我们应该考虑在 TurboModule 全面推广之后再进行更改
     */
    moduleHolder->cv().wait(guard);
  }

  return moduleHolder->getModule();
}

接下继续跟踪_createAndSetUpObjCModule实现:

/**
 * 给定一个 NativeModule 类及其名称,同步创建并初始化它。
 *
 * 此方法可以从两个不同的上下文中同步调用:
 *  - 调用 _provideObjCModule: 的线程
 *  - 主线程(如果 NativeModule 需要主队列初始化),阻塞调用 _provideObjCModule: 的线程。
 */
- (id<RCTBridgeModule>)_createAndSetUpObjCModule:(Class)moduleClass
                                      moduleName:(const char *)moduleName
                                        moduleId:(int32_t)moduleId
{
  id<RCTBridgeModule> module = nil;

  /**
   * 步骤 2b:请求宿主应用/代理来实例化此类
   */

  module = [self _getModuleInstanceFromClass:moduleClass];

  TurboModulePerfLogger::moduleCreateSetUpStart(moduleName, moduleId);

 /**
  * NativeModules 不需要/不想要 bridge 是合理的。
  * 在这种情况下,它们的实现中不会有 `@synthesize bridge = _bridge`,
  * ObjC 运行时也不会生成 `- (RCTBridge *) bridge { ... }` 方法。
  * 该属性也不会有 ivar 支持,这使得写入它变得不安全。
  * 因此,我们检查此方法是否存在,以确定是否可以安全地将 bridge 设置到 NativeModule。
  */
  if ([module respondsToSelector:@selector(bridge)] && (_bridge || _bridgeProxy)) {
    /**
      * 仅仅因为 NativeModule 有 `bridge` 方法,并不意味着
      * 它在其实现中合成了 bridge。因此,
      * 我们需要将设置 bridge 到 NativeModule 的代码
      * 包裹在 try/catch 中。这可以捕获 NativeModule
      * 作者手动指定 `bridge` 方法的情况。
      */
    @try {
        /**
         * RCTBridgeModule 将 bridge 属性声明为只读。
         * 因此,当 NativeModules 的作者通过 @synthesize bridge = bridge; 合成 bridge 时,
         * ObjC 运行时只生成 - (RCTBridge *) bridge: { ... } 方法。
         * 没有生成 setter,所以我们必须依赖 ObjC 的 KVC API
         * 来设置这些 NativeModules 的 bridge 属性。
         */
      if (_bridge) {
        [(id)module setValue:_bridge forKey:@"bridge"];
      } else if (_bridgeProxy) {
        [(id)module setValue:_bridgeProxy forKey:@"bridge"];
      }
    } @catch (NSException *exception) {
      RCTLogError(
          @"%@ has no setter or ivar for its bridge, which is not "
           "permitted. You must either @synthesize the bridge property, "
           "or provide your own setter method.",
          RCTBridgeModuleNameForClass([module class]));
    }
  }

  // 这是 conformsToProtocol:@protocol(RCTCallInvokerModule) 的更高性能替代方案
  if ([module respondsToSelector:@selector(setCallInvoker:)]) {
    RCTCallInvoker *callInvoker = [[RCTCallInvoker alloc] initWithCallInvoker:_jsInvoker];
    [(id<RCTCallInvokerModule>)module setCallInvoker:callInvoker];
  }

 /**
  * 一些模块需要自己的队列,但没有提供,所以我们需要为它们创建。
  * 这些模块通常有以下内容:
  *   `@synthesize methodQueue = _methodQueue`
  */

  dispatch_queue_t methodQueue = nil;
  BOOL moduleHasMethodQueueGetter = [module respondsToSelector:@selector(methodQueue)];

  if (moduleHasMethodQueueGetter) {
    methodQueue = [(id<RCTBridgeModule>)module methodQueue];
  }

  /**
   * 注意:RCTJSThread 是一个有效的方法队列,定义为 (id)kCFNull。
   * 它应该正确地不进入以下 if 条件块。
   */
  if (!methodQueue) {
    methodQueue = _sharedModuleQueue;

    if (moduleHasMethodQueueGetter) {
        /**
         * 如果模块有方法队列 getter,有两种情况:
         *  - 我们 @synthesize 了方法队列。在这种情况下,getter 最初会返回 nil。
         *  - 我们在 NativeModule 上有自定义的 methodQueue 函数。如果我们走到这里,
         *    那么该 getter 返回了 nil。
         *
         * 因此,我们使用 try/catch 和 ObjC 的 KVC API 尝试将方法队列分配给 NativeModule。
         * 在情况 1 中,我们会成功。在情况 2 中,会抛出异常,我们将忽略它。
         */

      @try {
        [(id)module setValue:methodQueue forKey:@"methodQueue"];
      } @catch (NSException *exception) {
        RCTLogError(
            @"%@ has no setter or ivar for its methodQueue, which is not "
             "permitted. You must either @synthesize the methodQueue property, "
             "or provide your own setter method.",
            RCTBridgeModuleNameForClass([module class]));
      }
    }
  }

  /**
   * 用 bridgeless 兼容的 API 装饰 NativeModules,这些 API 会调用 bridge。
   */
  if (_bridgeModuleDecorator) {
    [_bridgeModuleDecorator attachInteropAPIsToModule:module];
  }

  /**
   * 如果 NativeModule 符合 RCTInitializing 协议,调用其 initialize 方法
   */
  if ([module respondsToSelector:@selector(initialize)]) {
    [(id<RCTInitializing>)module initialize];
  }

#if RCT_DEV_MENU

  [_devMenuConfigurationDecorator decorate:module];

#endif

  /**
   * 将方法队列附加到 id<RCTBridgeModule> 对象。
   * 这是必要的,因为 id<RCTBridgeModule> 对象可以在需要方法队列之前被急切地创建/初始化。
   * 方法队列用于 id<RCTBridgeModule> 的 JS -> Native 调用。
   * 因此,我们需要在 provideTurboModule:runtime: 中创建 id<RCTBridgeModule> 的 
   * TurboModule jsi::HostObject 之前拥有它。
   */
  objc_setAssociatedObject(module, &kAssociatedMethodQueueKey, methodQueue, OBJC_ASSOCIATION_RETAIN);

  /**
   * 广播此 NativeModule 已创建
   *
   * TODO(T41180176): Investigate whether we can delete this after TM
   * rollout.
   */
  [[NSNotificationCenter defaultCenter]
      postNotificationName:RCTDidInitializeModuleNotification
                    object:_bridge
                  userInfo:@{@"module" : module, @"bridge" : RCTNullIfNil([_bridge parentBridge])}];

  TurboModulePerfLogger::moduleCreateSetUpEnd(moduleName, moduleId);

  return module;
}

这个方法实现很长,但其实就是创建 ObjC 模块实例并注入所有必要的依赖。具体说,就是:

  • 注入 BridgeProxy,让模块能调用 JS

  • 注入 CallInvoker,新架构的 JSI 调用机制

  • 配置方法队列,模块的异步方法在此队列执行

继续查看_getModuleInstanceFromClass方法的实现:

- (id<RCTBridgeModule>)_getModuleInstanceFromClass:(Class)moduleClass
{
  NSString *moduleNameStr = RCTBridgeModuleNameForClass(moduleClass);
  // 省略旧架构代码......

  id<RCTBridgeModule> module = (id<RCTBridgeModule>)[_delegate getModuleInstanceFromClass:moduleClass];

  if (!module) {
    module = [moduleClass new];
  }

  return module;
}

可见,最终还是通过代理的getModuleInstanceFromClass方法查找模块。前面已经分析过了,此方法最终也是调用RCTAppSetupDefaultModuleFromClass(moduleClass, self.delegate.dependencyProvider)方法查找,继续跟踪实现:

id<RCTTurboModule> RCTAppSetupDefaultModuleFromClass(Class moduleClass, id<RCTDependencyProvider> dependencyProvider)
{
  // private block used to filter out modules depending on protocol conformance
  NSArray * (^extractModuleConformingToProtocol)(RCTModuleRegistry *, Protocol *) =
      ^NSArray *(RCTModuleRegistry *moduleRegistry, Protocol *protocol) {
        NSArray<NSString *> *classNames = @[];

        if (protocol == @protocol(RCTImageURLLoader)) {
          classNames = (dependencyProvider != nullptr) ? dependencyProvider.imageURLLoaderClassNames : @[];
        } else if (protocol == @protocol(RCTImageDataDecoder)) {
          classNames = (dependencyProvider != nullptr) ? dependencyProvider.imageDataDecoderClassNames : @[];
        } else if (protocol == @protocol(RCTURLRequestHandler)) {
          classNames = (dependencyProvider != nullptr) ? dependencyProvider.URLRequestHandlerClassNames : @[];
        }

        NSMutableArray *modules = [NSMutableArray new];

        for (NSString *className in classNames) {
          const char *cModuleName = [className cStringUsingEncoding:NSUTF8StringEncoding];
          id moduleFromLibrary = [moduleRegistry moduleForName:cModuleName];
          if (![moduleFromLibrary conformsToProtocol:protocol]) {
            continue;
          }
          [modules addObject:moduleFromLibrary];
        }
        return modules;
      };
  // 省略......

  // No custom initializer here.
  return [moduleClass new];
}

三方TurboModule显然不符合上面的if判断逻辑,那么最终其实就是通过[moduleClass new]创建了RCTTurboModule的实例对象。

TurboModule注册

自动链接

首先打开react-native工程中提供的helloworld工程,配置文件react-native/private/helloworld/ios/Podfile

target 'HelloWorld' do
  config = use_native_modules!(['sh', '../scripts/config.sh'])

  use_react_native!(
    :path => "../../../packages/react-native",
    # An absolute path to your application root.
    :app_path => "#{Pod::Config.instance.installation_root}/.."
  )

  target 'HelloWorldTests' do
    inherit! :complete
    # Pods for testing
  end

  post_install do |installer|
    # scripts/react_native_pods.rb
    react_native_post_install(
      installer,
      config[:reactNativePath],
      :mac_catalyst_enabled => false,
      # :ccache_enabled => true
    )
  end
end

在以上的CocoaPod配置脚本中,use_native_modules! 函数是iOS端自动链接 (Autolinking) 的核心,它负责发现并链接所有第三方原生库,源码react-native/packages/react-native/scripts/cocoapods/autolinking.rb

# 自动链接原生模块
#
# Parameters:
# - config_command: 用于获取应用程序当前配置的命令,例如 ['npx', '@react-native-community/cli', 'config'],
# 您可以根据需要覆盖此命令,以避免依赖项。例如 ['cat', 'your_config.json']
def use_native_modules!(config_command = $default_command)
  return link_native_modules!(list_native_modules!(config_command))
end

继续查看list_native_modules!函数:

# 如果您使用这种方法来列出 React Native 模块,您的项目必须依赖于 @react-native-community/cli
#
# Parameters:
# - config_command: 用于获取应用程序当前配置的命令,例如 ['npx', '@react-native-community/cli', 'config']
def list_native_modules!(config_command)

  if !(config_command.is_a? Array and config_command.size > 0)
    # 省略日志打印......
    exit(1)
  end

  # 1. 执行 CLI 命令获取项目配置
  # 忽略标准错误输出,我们只关心标准输出和返回码。库可能会向标准错误输出警告信息,这会给 JSON 反序列化带来问题
  json, _, status = Pod::Executable.capture_command(config_command[0], config_command[1..], capture: :both)

  if not status.success?
    # 省略日志输出......
    exit(status.exitstatus)
  end

  config = JSON.parse(json)
  # 2. 获取所有依赖包
  packages = config["dependencies"]
  ios_project_root = Pathname.new(config["project"]["ios"]["sourceDir"])
  react_native_path = Pathname.new(config["reactNativePath"])
  codegen_output_path = ios_project_root.join("build/generated/autolinking/autolinking.json")

  # 3. 将配置写入 autolinking.json (供 Codegen 使用)
  FileUtils.mkdir_p(File.dirname(codegen_output_path))
  File.write(codegen_output_path, json)

  found_pods = []

  # 4. 遍历所有包,检查是否有 iOS 配置
  packages.each do |package_name, package|
    next unless package_config = package["platforms"]["ios"]

    name = package["name"]
    podspec_path = package_config["podspecPath"]
    script_phases = package_config["scriptPhases"]
    configurations = package_config["configurations"]

    # 跳过没有 podspec 的包
    if podspec_path.nil? || podspec_path.empty?
      # 省略......
      next
    end

    spec = Pod::Specification.from_file(podspec_path)

    # 跳过不支持当前目标平台的环境。
    next unless AutolinkingUtils.is_platform_supported?(current_target_definition, spec)

    podspec_dir_path = Pathname.new(File.dirname(podspec_path))

    relative_path = podspec_dir_path.relative_path_from ios_project_root

    found_pods.push({
      "configurations": configurations,
      "name": name,
      "root": package["root"],
      "path": relative_path.to_path,
      "podspec_path": podspec_path,
      "script_phases": script_phases
    })
  end

  if found_pods.size > 0
    pods = found_pods.map { |p| p[:name] }.sort.to_sentence
    Pod::UI.puts "Found #{found_pods.size} #{"module".pluralize(found_pods.size)} for target `#{current_target_definition.name}`"
  end

  return {
    "ios_packages": found_pods,
    "ios_project_root_path": ios_project_root.to_s,
    "react_native_path": react_native_path.relative_path_from(ios_project_root).to_s
  }
end

该函数主要是发现第三方库并返回扫描到的数据,生成autolinking.json。接下来查看link_native_modules!函数实现:

# Parameters:
# - config:
#   - :ios_packages - React Native iOS 包数组, e.g. [{ package_name: "Foo", package: { .. }}, ...]
#   - :ios_project_root_path - React Native 项目的 iOS 文件夹的绝对路径, e.g. /Users/foobar/project/rn_project/ios
#   - :react_native_path - React Native 相对于项目根目录的相对路径, e.g. ./node_modules/react-native
def link_native_modules!(config)
  Pod::UI.puts "link_native_modules! #{config}"

  if !(
    config[:ios_packages].is_a? Array and
    config[:ios_project_root_path].is_a? String and
    config[:react_native_path].is_a? String
  )
    # 省略日志打印......
    exit(1)
  end
  # 提取项目根路径
  ios_project_root = config[:ios_project_root_path]
  # 提取包列表
  packages = config[:ios_packages]
  found_pods = []

  # 遍历包并自动链接
  packages.each do |package|
    podspec_path = package[:podspec_path]
    configurations = package[:configurations]

    # 如果 podspec_path 为 nil 或空,添加警告到队列并继续处理下一个依赖
    if podspec_path.nil? || podspec_path.empty?
      # 省略日志打印......
      next
    end
    # 读取并解析 .podspec 文件,获取包的完整规范信息。
    spec = Pod::Specification.from_file(podspec_path)

    # 检查该包是否支持当前平台(iOS),如果不支持,跳过该包
    next unless AutolinkingUtils.is_platform_supported?(current_target_definition, spec)

    # 我们想在当前 CocoaPods target 内部进行查找
    # 以查看它是否已包含,这样做可以:
    #   1. 给你一个机会预先定义它
    #   2. 确保 CocoaPods 不会因为包含两次而崩溃
    #
    this_target = current_target_definition
    existing_deps = current_target_definition.dependencies

    # 跳过用户已经自己激活的依赖
    next if existing_deps.find do |existing_dep|
      existing_dep.name.split('/').first == spec.name
    end

    podspec_dir_path = Pathname.new(File.dirname(podspec_path))

    relative_path = podspec_dir_path.relative_path_from ios_project_root

    # 将找到的 React Native 模块注册到我们的 Pods 集合中(调用 CocoaPods 的 pod DSL 方法,添加依赖)
    pod spec.name, :path => relative_path.to_path, :configurations => configurations

    if package[:script_phases] && !this_target.abstract?
      # 可以是一个对象,或对象数组
      Array(package[:script_phases]).each do |phase|
        # see https://www.rubydoc.info/gems/cocoapods-core/Pod/Podfile/DSL#script_phase-instance_method
        # 获取完整的对象键
        Pod::UI.puts "Adding a custom script phase for Pod #{spec.name}: #{phase["name"] || 'No name specified.'}"

        # 支持传入相对于包根目录的路径
        if phase["path"]
          phase["script"] = File.read(File.expand_path(phase["path"], package[:root]))
          phase.delete("path")
        end

        # 支持将执行位置转换为 symbol
        phase["execution_position"] = phase["execution_position"]&.to_sym

        phase = Hash[phase.map { |k, v| [k.to_sym, v] }]
        script_phase phase
      end
    end

    found_pods.push spec
  end

  if found_pods.size > 0
    pods = found_pods.map { |p| p.name }.sort.to_sentence
    # 省略打印......
  end

  return {
    :reactNativePath => config[:react_native_path]
  }
end

此函数就是在处理node_module中下载的依赖,最核心的其实就是pod spec.name, :path => relative_path.to_path, :configurations => configurations这行代码,它等价于我们习惯的类似配置:

pod 'react-native-camera', :path => '../node_modules/react-native-camera', :configurations => ['Debug', 'Release']

环境配置

弄清楚了use_native_modules!自动链接,接下来再看看use_react_native! 做了什么,源码react-native/packages/react-native/scripts/react_native_pods.rb

# 设置所有 React Native 依赖的函数
# 
# 参数
# - path: React Native 安装路径。
# - fabric_enabled: 是否应启用 Fabric。
# - new_arch_enabled: [已弃用] 是否应启用新架构。
# - :production [已弃用] 依赖是否必须安装到 Debug 或 Release 构建目标。
# - hermes_enabled: 是否应启用 Hermes。
# - app_path: React Native 应用的路径。新架构需要。
# - config_file_dir: `package.json` 文件的目录,新架构需要。
def use_react_native! (
  path: "../node_modules/react-native",
  fabric_enabled: false,
  new_arch_enabled: NewArchitectureHelper.new_arch_enabled,
  production: false, # 已弃用
  hermes_enabled: true, # 已弃用。Hermes 是默认引擎,JSC 已移至社区支持
  app_path: '..',
  config_file_dir: '',
  privacy_file_aggregation_enabled: true
)
  # 1.初始化和环境变量设置

  error_if_try_to_use_jsc_from_core()
  warn_if_new_arch_disabled()

  hermes_enabled= true
  # 将 app_path 设置为环境变量,以便 podspecs 可以访问它
  ENV['APP_PATH'] = app_path
  ENV['REACT_NATIVE_PATH'] = path

  # 如果用户想跳过从 CocoaPods 运行 Codegen 步骤,我们将 RCT_SKIP_CODEGEN 设置为 true。
  # 这是我们从 CocoaPods 迁移过程中所需的
  ENV['RCT_SKIP_CODEGEN'] = ENV['RCT_SKIP_CODEGEN'] == '1' || ENV['RCT_IGNORE_PODS_DEPRECATION'] == '1' ? '1' : '0'

  ReactNativePodsUtils.check_minimum_required_xcode()

  # 当前目标定义由 CocoaPods 提供,它指的是
  # 调用了 `use_react_native!` 函数的目标。
  ReactNativePodsUtils.detect_use_frameworks(current_target_definition)

  # 2.清理和版本检测

  CodegenUtils.clean_up_build_folder(path, $CODEGEN_OUTPUT_DIR)

  # 我们也在第三方库中依赖此标志来正确安装依赖。
  # 如果使用标志启用新架构,最好依赖并启用此环境标志。
  relative_path_from_current = Pod::Config.instance.installation_root.relative_path_from(Pathname.pwd)
  react_native_version = NewArchitectureHelper.extract_react_native_version(File.join(relative_path_from_current, path))
  fabric_enabled = true

  ENV['RCT_FABRIC_ENABLED'] = "1"
  ENV['RCT_AGGREGATE_PRIVACY_FILES'] = privacy_file_aggregation_enabled ? "1" : "0"
  ENV["RCT_NEW_ARCH_ENABLED"] = "1"

  # 3.依赖配置(配置 React Native 核心库)

  prefix = path

  ReactNativePodsUtils.warn_if_not_on_arm64()

  # 更新 ReactNativeDependencies,以便我们可以轻松地在源码和预构建之间切换
  ReactNativeDependenciesUtils.setup_react_native_dependencies(prefix, react_native_version)

  # 更新 ReactNativeCoreUtils,以便我们可以轻松地在源码和预构建之间切换
  ReactNativeCoreUtils.setup_rncore(prefix, react_native_version)

  Pod::UI.puts "Configuring the target with the New Architecture\n"

  # 应包含在所有项目中的 Pods
  pod 'FBLazyVector', :path => "#{prefix}/Libraries/FBLazyVector"
  pod 'RCTRequired', :path => "#{prefix}/Libraries/Required"
  pod 'RCTTypeSafety', :path => "#{prefix}/Libraries/TypeSafety", :modular_headers => true
  pod 'React', :path => "#{prefix}/"
  if !ReactNativeCoreUtils.build_rncore_from_source()
    pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true
  end
  pod 'React-Core', :path => "#{prefix}/"
  pod 'React-CoreModules', :path => "#{prefix}/React/CoreModules"
  pod 'React-RCTRuntime', :path => "#{prefix}/React/Runtime"
  # 省略部分......
  pod 'React-jsi', :path => "#{prefix}/ReactCommon/jsi"
  pod 'RCTSwiftUI', :path => "#{prefix}/ReactApple/RCTSwiftUI"
  pod 'RCTSwiftUIWrapper', :path => "#{prefix}/ReactApple/RCTSwiftUIWrapper"

  if hermes_enabled
    setup_hermes!(:react_native_path => prefix)
  end

  pod 'React-jsiexecutor', :path => "#{prefix}/ReactCommon/jsiexecutor"
  # 省略部分......
  pod 'ReactCommon/turbomodule/core', :path => "#{prefix}/ReactCommon", :modular_headers => true
  pod 'React-NativeModulesApple', :path => "#{prefix}/ReactCommon/react/nativemodule/core/platform/ios", :modular_headers => true
  pod 'Yoga', :path => "#{prefix}/ReactCommon/yoga", :modular_headers => true
  setup_fabric!(:react_native_path => prefix)
  setup_bridgeless!(:react_native_path => prefix, :use_hermes => hermes_enabled)

  if ReactNativeDependenciesUtils.build_react_native_deps_from_source()
    pod 'DoubleConversion', :podspec => "#{prefix}/third-party-podspecs/DoubleConversion.podspec"
    pod 'glog', :podspec => "#{prefix}/third-party-podspecs/glog.podspec"
    pod 'boost', :podspec => "#{prefix}/third-party-podspecs/boost.podspec"
    pod 'fast_float', :podspec => "#{prefix}/third-party-podspecs/fast_float.podspec"
    pod 'fmt', :podspec => "#{prefix}/third-party-podspecs/fmt.podspec", :modular_headers => true
    pod 'RCT-Folly', :podspec => "#{prefix}/third-party-podspecs/RCT-Folly.podspec", :modular_headers => true
    pod 'SocketRocket', "~> #{Helpers::Constants::socket_rocket_config[:version]}", :modular_headers => true
  else
    # Install prebuilt React Native Core and React Native Dependencies
    ReactNativeCoreUtils.rncore_log("Using React Native Core and React Native Dependencies prebuilt versions.")
    pod 'ReactNativeDependencies', :podspec => "#{prefix}/third-party-podspecs/ReactNativeDependencies.podspec", :modular_headers => true

    if !ReactNativeCoreUtils.build_rncore_from_source()
      pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true
    end
  end

  pod 'ReactCodegen', :path => $CODEGEN_OUTPUT_DIR, :modular_headers => true
  pod 'ReactAppDependencyProvider', :path => $APP_DEPENDENCY_PROVIDER_OUTPUT_DIR, :modular_headers => true
  # 不需要,但 run_codegen 期望设置此值。
  folly_config = get_folly_config()
  # 运行 Codegen:扫描所有 TurboModule 和 Fabric 组件规范,生成 C++ 绑定代码
  run_codegen!(
    app_path,
    config_file_dir,
    :new_arch_enabled => NewArchitectureHelper.new_arch_enabled,
    :disable_codegen => ENV['DISABLE_CODEGEN'] == '1',
    :react_native_path => prefix,
    :fabric_enabled => fabric_enabled,
    :hermes_enabled => hermes_enabled,
    :codegen_output_dir => $CODEGEN_OUTPUT_DIR,
    :package_json_file => File.join(__dir__, "..", "package.json"),
    :folly_version => folly_config[:version]
  )

  pods_to_update = LocalPodspecPatch.pods_to_update(:react_native_path => prefix)
  if !pods_to_update.empty?
    if Pod::Lockfile.public_instance_methods.include?(:detect_changes_with_podfile)
      Pod::Lockfile.prepend(LocalPodspecPatch)
    else
      # 省略打印......
    end
  end
end

此函数太长,这里省略了大部分的依赖配置项。这里总结一下该函数做的事:

  1. 配置环境:设置环境变量、检查 Xcode 版本

  2. 添加 60+ 个 Pod 依赖:涵盖从核心到工具的所有模块

  3. 设置新架构:Fabric、TurboModule、Bridgeless

  4. 配置 Hermes 引擎

  5. 运行 Codegen:自动生成 C++ 绑定代码

  6. 处理预构建二进制:加速编译

总的说,这是 React Native iOS 项目的"一键配置"函数,它设置了新架构所需的所有依赖和工具链。

代码生成

继续分析run_codegen!函数,探索iOS端,是如何自动生成代码的。源码react-native/packages/react-native/scripts/cocoapods/codegen.rb

def run_codegen!(
  app_path,                                              # 应用根目录路径(必需)
  config_file_dir,                                       # 配置文件目录(必需)
  new_arch_enabled: true,                                # 是否启用新架构(默认 true,但当前未使用)
  disable_codegen: false,                                # 是否禁用 Codegen(默认 false)
  react_native_path: "../node_modules/react-native",    # React Native 源码路径
  fabric_enabled: false,                                 # 是否启用 Fabric(默认 false)
  hermes_enabled: true,                                  # 是否启用 Hermes(默认 true)
  codegen_output_dir: 'build/generated/ios',            # Codegen 输出目录
  config_key: 'codegenConfig',                          # package.json 中配置键名
  package_json_file: '~/app/package.json',              # package.json 文件路径(当前未使用)
  folly_version: Helpers::Constants.folly_config()[:version], # Folly 库版本
  codegen_utils: CodegenUtils.new()                     # CodegenUtils 实例(可注入用于测试)
  )

  if ENV["RCT_SKIP_CODEGEN"] == "1"
    return
  end

  codegen_utils.use_react_native_codegen_discovery!(
    disable_codegen,
    app_path,
    :react_native_path => react_native_path,
    :fabric_enabled => fabric_enabled,
    :hermes_enabled => hermes_enabled,
    :config_file_dir => config_file_dir,
    :codegen_output_dir => codegen_output_dir,
    :config_key => config_key,
    :folly_version => folly_version
  )
end

可以看到,实际上是委托给 CodegenUtils 执行代码生成。继续查看源码react-native/packages/react-native/scripts/cocoapods/codegen_utils.rb

   def use_react_native_codegen_discovery!(
      codegen_disabled,
      app_path,
      react_native_path: "../node_modules/react-native",
      fabric_enabled: false,
      hermes_enabled: true,
      config_file_dir: '',
      codegen_output_dir: 'build/generated/ios',
      config_key: 'codegenConfig',
      folly_version: Helpers::Constants.folly_config[:version],
      codegen_utils: CodegenUtils.new(),
      file_manager: File,
      logger: CodegenUtils::UI
      )
      return if codegen_disabled

      if CodegenUtils.react_codegen_discovery_done()
        logger.puts("Skipping use_react_native_codegen_discovery.")
        return
      end

      if !app_path
        logger.warn("Error: app_path is required for use_react_native_codegen_discovery.")
        logger.warn("If you are calling use_react_native_codegen_discovery! in your Podfile, please remove the call and pass `app_path` and/or `config_file_dir` to `use_react_native!`.")
        abort
      end

      relative_installation_root = Pod::Config.instance.installation_root.relative_path_from(Pathname.pwd)

      out = Pod::Executable.execute_command(
        'node',
        [
          "#{relative_installation_root}/#{react_native_path}/scripts/generate-codegen-artifacts.js",
          "-p", "#{app_path}",
          "-o", Pod::Config.instance.installation_root,
          "-t", "ios",
        ])
      Pod::UI.puts out;
      #  标记完成,防止重复执行
      CodegenUtils.set_react_codegen_discovery_done(true)
    end

此处实际上仍然是调用JS工具来生成代码,这里执行的命令,相当于:

node node_modules/react-native/scripts/generate-codegen-artifacts.js \
  -p /narwal/Your Project/helloworld \
  -o /narwal/Your Project/helloworld/ios \
  -t ios

继续跟踪react-native/packages/react-native/scripts/generate-codegen-artifacts.js

const executor = require('./codegen/generate-artifacts-executor');
// 省略......
executor.execute(argv.path, argv.targetPlatform, argv.outputPath, argv.source);

查看execute实现,源码react-native/packages/react-native/scripts/codegen/generate-artifacts-executor/index.js

/**
 * 此函数是代码生成器的入口点。它执行以下操作:
 * - 读取 package.json 文件
 * - 提取库信息
 * - 设置命令行界面以生成代码
 * - 生成代码
 *
 * @param projectRoot:包含应用程序源代码的目录,package.json 文件位于该目录下。 
 * @param baseOutputPath:代码生成器的基本输出路径。 
 * @param targetPlatform:目标平台。支持的值包括:“android”、“ios”、“all”。 
 * @param source:调用代码生成器的来源。支持的值包括:“app”、“library”。 
 * @throws 如果找不到 React Native 的配置文件。 
 * @throws 如果在文件中找不到代码生成器配置。 
 * @throws 如果找不到代码生成器的命令行工具。
 */
function execute(
  projectRoot /*: string */,
  targetPlatform /*: string */,
  optionalBaseOutputPath /*: ?string */,
  source /*: string */,
  runReactNativeCodegen /*: boolean */ = true,
) {
  try {
    codegenLog(`Analyzing ${path.join(projectRoot, 'package.json')}`);

    const supportedPlatforms = ['android', 'ios'];
    if (
      targetPlatform !== 'all' &&
      !supportedPlatforms.includes(targetPlatform)
    ) {
      throw new Error(
        `Invalid target platform: ${targetPlatform}. Supported values are: ${supportedPlatforms.join(
          ', ',
        )}, all`,
      );
    }
    // 1.读取应用的 package.json
    const pkgJson = readPkgJsonInDirectory(projectRoot);

    if (runReactNativeCodegen) {
      // 2.如果 Codegen CLI 未构建,先执行 yarn build
      buildCodegenIfNeeded();
    }

    const platforms =
      targetPlatform === 'all' ? supportedPlatforms : [targetPlatform];

    // NOTE: We cache the external libraries search (which may not run) across platforms to not change previous behaviour
    const externalLibrariesCache /*: { current?: ?Array<$FlowFixMe> } */ = {};

    for (const platform of platforms) {
      // NOTE: This needs to be computed per-platform since `platform` can alter the path via a `package.json:codegenConfig.outputDir[platform]` override
      const baseOutputPath = computeBaseOutputPath(
        projectRoot,
        optionalBaseOutputPath,
        pkgJson,
        platform,
      );
      const reactNativeConfig = readReactNativeConfig(
        projectRoot,
        baseOutputPath,
      );
      // 3.扫描所有依赖库,找出包含 codegenConfig 的库
      const codegenEnabledLibraries = findCodegenEnabledLibraries(
        pkgJson,
        projectRoot,
        baseOutputPath,
        reactNativeConfig,
        externalLibrariesCache,
      );
      if (codegenEnabledLibraries.length === 0) {
        codegenLog('No codegen-enabled libraries found.', true);
      }

      const disabledLibraries = findDisabledLibrariesByPlatform(
        reactNativeConfig,
        platform,
      );
      const libraries = codegenEnabledLibraries.filter(
        ({name}) => !disabledLibraries.includes(name),
      );

      const outputPath = computeOutputPath(
        projectRoot,
        baseOutputPath,
        pkgJson,
        platform,
      );

      const reactCodegenOutputPath =
        platform === 'android'
          ? outputPath
          : path.join(outputPath, 'ReactCodegen');

      if (runReactNativeCodegen) {
        // 4.为每个库生成 Schema JSON(描述 TurboModule/Fabric 接口)
        const schemaInfos = generateSchemaInfos(libraries);
        // 5.根据 Schema 生成 C++/ObjC++ 代码
        generateNativeCode(
          reactCodegenOutputPath,
          schemaInfos.filter(schemaInfo =>
            mustGenerateNativeCode(projectRoot, schemaInfo),
          ),
          pkgJsonIncludesGeneratedCode(pkgJson),
          platform,
        );
      }

      if (source === 'app' && platform !== 'android') {
        // 6.生成 Fabric 第三方组件注册代码
        // These components are only required by apps, not by libraries and are Apple specific.
        generateRCTThirdPartyComponents(libraries, reactCodegenOutputPath);
        // 7.生成 TurboModule 提供者代码
        generateRCTModuleProviders(
          projectRoot,
          pkgJson,
          libraries,
          reactCodegenOutputPath,
        );
        // 8.生成自定义 URL Scheme 处理器
        generateCustomURLHandlers(libraries, reactCodegenOutputPath);
        generateUnstableModulesRequiringMainQueueSetupProvider(
          libraries,
          reactCodegenOutputPath,
        );
        // 9.生成应用级依赖提供者
        generateAppDependencyProvider(
          path.join(outputPath, 'ReactAppDependencyProvider'),
        );
        // 10.生成 ReactCodegen.podspec
        generateReactCodegenPodspec(
          projectRoot,
          pkgJson,
          reactCodegenOutputPath,
          baseOutputPath,
        );
        generatePackageSwift(
          projectRoot,
          outputPath,
          findReactNativeRootPath(projectRoot),
        );
      }

      cleanupEmptyFilesAndFolders(outputPath);
    }
  } catch (err) {
    codegenLog(err);
    process.exitCode = 1;
  }

  codegenLog('Done.', true);
  return;
}

我已经为上面10个比较关键的步骤增加了注释,流程还是很清晰的。由于这篇文章主要是研究iOS端TurboModule,那么需要重点关注一下generateRCTModuleProvidersgenerateAppDependencyProvider两个函数的实现。

源码react-native/packages/react-native/scripts/codegen/generate-artifacts-executor/generateRCTModuleProviders.js

const MODULE_PROVIDERS_H_TEMPLATE_PATH = path.join(
  TEMPLATES_FOLDER_PATH,
  'RCTModuleProvidersH.template',
);

const MODULE_PROVIDERS_MM_TEMPLATE_PATH = path.join(
  TEMPLATES_FOLDER_PATH,
  'RCTModuleProvidersMM.template',
);

function generateRCTModuleProviders(
  projectRoot /*: string */,
  pkgJson /*: $FlowFixMe */,
  libraries /*: $ReadOnlyArray<$FlowFixMe> */,
  outputDir /*: string */,
) {
  // 1.创建输出目录
  fs.mkdirSync(outputDir, {recursive: true});
  // 2.生成头文件 RCTModuleProviders.h
  codegenLog('Generating RCTModulesProvider.h');
  const templateH = fs.readFileSync(MODULE_PROVIDERS_H_TEMPLATE_PATH, 'utf8');
  const finalPathH = path.join(outputDir, 'RCTModuleProviders.h');
  fs.writeFileSync(finalPathH, templateH);
  codegenLog(`Generated artifact: ${finalPathH}`);

  codegenLog('Generating RCTModuleProviders.mm');
  // 3.初始化一个模块收集容器
  let modulesInLibraries /*: {[string]: Array<$FlowFixMe>} */ = {};

  let app = pkgJson.codegenConfig
    ? {config: pkgJson.codegenConfig, libraryPath: projectRoot}
    : null;

  // 4.将应用自身的 TurboModule 加入处理列表
  const moduleLibraries = libraries
    .concat(app)
    .filter(Boolean)
    .filter(({config, libraryPath}) => {
      // 5.过滤出需要处理的模块库
      if (isReactNativeCoreLibrary(config.name) || config.type === 'components') {
        return false;
      }
      return true;
    });

  // 6.收集旧 API 配置(modulesProvider 字段)
  moduleLibraries.forEach(({config, libraryPath}) => {
    const libraryName = JSON.parse(
      fs.readFileSync(path.join(libraryPath, 'package.json'), 'utf8'),
    ).name;

    if (config.ios?.modulesProvider) {
      modulesInLibraries[libraryName] = Object.keys(
        config.ios?.modulesProvider,
      ).map(moduleName => {
        return {
          moduleName,
          className: config.ios?.modulesProvider[moduleName],
        };
      });
    }
  });

  // 7.收集新 API 配置(modules 字段)
  const iosAnnotations = parseiOSAnnotations(moduleLibraries);
  for (const [libraryName, {modules: moduleAnnotationMap}] of Object.entries(
    iosAnnotations,
  )) {
    for (const [moduleName, annotation] of Object.entries(
      moduleAnnotationMap,
    )) {
      if (annotation.className) {
        modulesInLibraries[libraryName] = modulesInLibraries[libraryName] || [];
        modulesInLibraries[libraryName].push({
          moduleName,
          className: annotation.className,
        });
      }
    }
  }

  // 8.生成 Objective-C 映射代码
  const modulesMapping = Object.keys(modulesInLibraries)
    .flatMap(library => {
      const modules = modulesInLibraries[library];
      return modules.map(({moduleName, className}) => {
        return `\t\t@"${moduleName}": @"${className}", // ${library}`;
      });
    })
    .join('\n');

  // 9.将映射代码注入模板并写入文件
  const templateMM = fs
    .readFileSync(MODULE_PROVIDERS_MM_TEMPLATE_PATH, 'utf8')
    .replace(/{moduleMapping}/, modulesMapping);
  const finalPathMM = path.join(outputDir, 'RCTModuleProviders.mm');
  fs.writeFileSync(finalPathMM, templateMM);
  codegenLog(`Generated artifact: ${finalPathMM}`);
}

现在我们来看一个生成的RCTModuleProviders实例,源码Your Project/ios/build/generated/ios/ReactCodegen/RCTModuleProviders.mm

@implementation RCTModuleProviders

+ (NSDictionary<NSString *, id<RCTModuleProvider>> *)moduleProviders
{
  static NSDictionary<NSString *, id<RCTModuleProvider>> *providers = nil;
  static dispatch_once_t onceToken;

  dispatch_once(&onceToken, ^{
    NSDictionary<NSString *, NSString *> * moduleMapping = @{
              @"RNCWebViewModule": @"RNCWebViewModule", // react-native-webview
    };

    NSMutableDictionary *dict = [[NSMutableDictionary alloc] initWithCapacity:moduleMapping.count];

    for (NSString *key in moduleMapping) {
      NSString * moduleProviderName = moduleMapping[key];
      Class klass = NSClassFromString(moduleProviderName);
      if (!klass) {
        RCTLogError(@"Module provider %@ cannot be found in the runtime", moduleProviderName);
        continue;
      }

      id instance = [klass new];
      if (![instance respondsToSelector:@selector(getTurboModule:)]) {
        RCTLogError(@"Module provider %@ does not conform to RCTModuleProvider", moduleProviderName);
        continue;
      }

      [dict setObject:instance forKey:key];
    }

    providers = dict;
  });

  return providers;
}

这是一个懒加载 + 单例模式的实现。使用NSClassFromString通过字符串反射查找类,然后[klass new]实例化 Provider 并验证协议。

最后再来看generateAppDependencyProvider实现,源码react-native/packages/react-native/scripts/codegen/generate-artifacts-executor/generateAppDependencyProvider.js

function generateAppDependencyProvider(outputDir /*: string */) {
  fs.mkdirSync(outputDir, {recursive: true});
  codegenLog('Generating RCTAppDependencyProvider');

  const templateH = fs.readFileSync(
    APP_DEPENDENCY_PROVIDER_H_TEMPLATE_PATH,
    'utf8',
  );
  const finalPathH = path.join(outputDir, 'RCTAppDependencyProvider.h');
  fs.writeFileSync(finalPathH, templateH);
  codegenLog(`Generated artifact: ${finalPathH}`);

  const templateMM = fs.readFileSync(
    APP_DEPENDENCY_PROVIDER_MM_TEMPLATE_PATH,
    'utf8',
  );
  const finalPathMM = path.join(outputDir, 'RCTAppDependencyProvider.mm');
  fs.writeFileSync(finalPathMM, templateMM);
  codegenLog(`Generated artifact: ${finalPathMM}`);

  // Generate the podspec file
  const templatePodspec = fs
    .readFileSync(APP_DEPENDENCY_PROVIDER_PODSPEC_TEMPLATE_PATH, 'utf8')
    .replace(/{react-native-version}/, packageJson.version)
    .replace(/{react-native-licence}/, packageJson.license);
  const finalPathPodspec = path.join(
    outputDir,
    'ReactAppDependencyProvider.podspec',
  );
  fs.writeFileSync(finalPathPodspec, templatePodspec);
  codegenLog(`Generated podspec: ${finalPathPodspec}`);
}

此方法生成一个 统一的依赖提供者类(RCTAppDependencyProvider),它将之前生成的所有 Codegen 产物(TurboModule、Fabric 组件、URL 处理器等)聚合到一个统一接口中,供 React Native 运行时使用。

我们直接看生成之后的文件,源码Your Project/ios/build/generated/ios/ReactAppDependencyProvider/RCTAppDependencyProvider.mm

@implementation RCTAppDependencyProvider

- (nonnull NSArray<NSString *> *)URLRequestHandlerClassNames {
  return RCTModulesConformingToProtocolsProvider.URLRequestHandlerClassNames;
}

- (nonnull NSArray<NSString *> *)imageDataDecoderClassNames {
  return RCTModulesConformingToProtocolsProvider.imageDataDecoderClassNames;
}

- (nonnull NSArray<NSString *> *)imageURLLoaderClassNames {
  return RCTModulesConformingToProtocolsProvider.imageURLLoaderClassNames;
}

- (nonnull NSArray<NSString *> *)unstableModulesRequiringMainQueueSetup {
  return RCTUnstableModulesRequiringMainQueueSetupProvider.modules;
}

- (nonnull NSDictionary<NSString *,Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents {
  return RCTThirdPartyComponentsProvider.thirdPartyFabricComponents;
}

- (nonnull NSDictionary<NSString *, id<RCTModuleProvider>> *)moduleProviders {
  return RCTModuleProviders.moduleProviders;
}

@end

到此,基本上就闭环了。在本文开头,我们就分析了delegate.dependencyProvider = RCTAppDependencyProvider(),而在getModuleProvider方法实现中,self.dependencyProvider != nullptr) ? self.dependencyProvider.moduleProviders[providerName] : nullptr一行,正是从RCTModuleProviders.moduleProviders中查找模块。

深入理解 reduce:从面试题到设计思维

作者 yuki_uix
2026年2月24日 18:24

在准备前端面试的过程中,我发现一个有趣的现象:刷题时遇到的很多问题都能用 reduce 优雅地解决,但回想自己的实际项目经历,却几乎没有直接使用过它。这种"面试高频、项目冷门"的反差让我开始重新审视这个数组方法——它究竟只是个语法糖,还是代表着某种更深层的编程思维?

这篇文章记录了我重新学习 reduce 的过程。我想探讨的不只是"怎么用",更是"为什么用"以及"什么时候该想到它"。

为什么要重新认识 reduce

面试高频 vs 项目冷门的现象

翻看 LeetCode 和各种面试题库,reduce 的身影无处不在:

  • 数组求和、求积
  • 数组扁平化
  • 实现 mapfilter
  • 函数组合 (compose/pipe)
  • 对象转换、分组

但在实际项目中,我更习惯用 for 循环、mapfilter,甚至是 forEach。这是为什么?

我的反思是:可能并不是 reduce 不好用,而是我还没有建立起使用它的心智模型。就像刚学编程时,明明知道函数很重要,却还是习惯把所有代码写在一个文件里一样。

reduce 真正的价值

经过一段时间的研究,我逐渐意识到:reduce 不只是众多数组方法中的一个,它更像是一种数据转换的思维范式

当我们使用 map 时,我们在说:"把数组中的每个元素转换一下"。
当我们使用 filter 时,我们在说:"筛选出符合条件的元素"。
当我们使用 reduce 时,我们在说:

"把整个数组归约成另一种形态"。

这种"形态转换"的视角,让我看到了更多可能性。

本文目标

这篇文章希望达到三个目标:

  1. 理解原理reduce 到底在做什么?它的执行流程是怎样的?
  2. 建立思维: 什么样的问题适合用 reduce 解决?如何培养这种直觉?
  3. 实战应用: 从面试题到实际场景,如何灵活运用?

reduce 的工作原理

核心概念:累加器的演变

reduce 方法的核心在于累加器 (accumulator) 的概念。想象一个累加过程:

// 环境: 浏览器 / Node.js
// 场景: 理解 reduce 的基本执行流程

const numbers = [12345];

// 传统方式: 用 for 循环累加
let sum = 0; // 初始累加器
for (let i = 0; i < numbers.length; i++) {
  sum = sum + numbers[i]; // 更新累加器
}
console.log(sum); // 15

// reduce 方式
const sum2 = numbers.reduce((acc, curr) => acc + curr, 0);
console.log(sum2); // 15

这两种方式在逻辑上是等价的。reduce 做的事情就是:

  1. 提供一个初始值 (累加器的起点)
  2. 对数组中的每个元素,执行一个函数来更新累加器
  3. 返回最终的累加器值

reduce 的优势在于:它是声明式的。我们描述了"做什么"(把所有元素加起来),而不是"怎么做"(逐个遍历、累加)。

参数拆解: reducer 函数的四个参数

reduce 的完整签名是:

array.reduce(callback(accumulator, currentValue, currentIndex, array), initialValue)

让我们逐个理解这些参数:

// 环境: 浏览器 / Node.js
// 场景: 完整的 reduce 参数演示

const fruits = ['apple''banana''cherry'];

const result = fruits.reduce(
  (acc, curr, index, arr) => {
    console.log({
      iteration: index + 1,
      accumulator: acc,
      currentValue: curr,
      currentIndex: index,
      originalArray: arr
    });
    
    // 返回新的累加器值
    return acc + curr.length;
  },
  0 // 初始值
);

console.log('Final result:', result); // 18

/*
输出:
{ iteration: 1, accumulator: 0, currentValue: 'apple', currentIndex: 0, ... }
{ iteration: 2, accumulator: 5, currentValue: 'banana', currentIndex: 1, ... }
{ iteration: 3, accumulator: 11, currentValue: 'cherry', currentIndex: 2, ... }
Final result: 18
*/

参数说明

  • accumulator (acc): 累加器,保存每次迭代的中间结果
  • currentValue (curr): 当前正在处理的元素
  • currentIndex (index): 当前元素的索引 (可选,不常用)
  • array (arr): 原始数组 (可选,几乎不用)

大多数情况下,我们只需要前两个参数。

执行流程可视化

让我用一个更直观的例子来展示 reduce 的执行流程:

// 环境: 浏览器 / Node.js
// 场景: 购物车总价计算

const cart = [
  { name: 'book', price: 30 },
  { name: 'pen', price: 5 },
  { name: 'bag', price: 80 }
];

const total = cart.reduce((acc, item) => {
  console.log(`Current total: ${acc}, adding ${item.name} (${item.price})`);
  return acc + item.price;
}, 0);

console.log('Total:', total); // 115

/*
执行流程:
初始状态: acc = 0

第 1 次迭代:
  - 当前商品: { name: 'book', price: 30 }
  - acc = 0 + 30 = 30

第 2 次迭代:
  - 当前商品: { name: 'pen', price: 5 }
  - acc = 30 + 5 = 35

第 3 次迭代:
  - 当前商品: { name: 'bag', price: 80 }
  - acc = 35 + 80 = 115

返回最终的 acc: 115
*/

可以看到,reduce 其实是在不断"滚雪球":从一个初始值开始,每次迭代都基于上次的结果继续累积。

初始值的重要性

这是一个容易被忽视但很重要的点:初始值可以不提供

// 环境: 浏览器 / Node.js
// 场景: 有无初始值的区别

const numbers = [1234];

// 有初始值 (推荐)
const sum1 = numbers.reduce((acc, curr) => acc + curr, 0);
console.log(sum1); // 10

// 无初始值: 第一个元素作为初始值,从第二个元素开始迭代
const sum2 = numbers.reduce((acc, curr) => acc + curr);
console.log(sum2); // 10

// 看似结果相同,但有个陷阱:
const emptyArray = [];

// 有初始值: 正常返回 0
const safeSum = emptyArray.reduce((acc, curr) => acc + curr, 0);
console.log(safeSum); // 0

// 无初始值: 抛出错误!
try {
  const unsafeSum = emptyArray.reduce((acc, curr) => acc + curr);
} catch (error) {
  console.error('Error:', error.message); 
  // TypeError: Reduce of empty array with no initial value
}

关键点

  • 不提供初始值时,reduce 会用数组的第一个元素作为初始值
  • 这在处理空数组时会报错
  • 建议总是提供初始值,让代码更健壮

另一个微妙之处:初始值的类型决定了最终结果的类型。

// 环境: 浏览器 / Node.js
// 场景: 初始值类型影响最终结果

const numbers = [123];

// 初始值是数字
const sum = numbers.reduce((acc, curr) => acc + curr, 0);
console.log(sum); // 6 (number)

// 初始值是字符串
const str = numbers.reduce((acc, curr) => acc + curr, '');
console.log(str); // '123' (string)

// 初始值是数组
const doubled = numbers.reduce((acc, curr) => {
  acc.push(curr * 2);
  return acc;
}, []);
console.log(doubled); // [2, 4, 6]

// 初始值是对象
const stats = numbers.reduce((acc, curr) => {
  acc.sum += curr;
  acc.count += 1;
  return acc;
}, { sum: 0, count: 0 });
console.log(stats); // { sum: 6, count: 3 }

这就引出了 reduce 的一个强大特性:它可以把数组转换成任何数据结构——数字、字符串、对象、甚至另一个数组。

reduce 的设计哲学

声明式编程:描述"做什么"而非"怎么做"

当我刚开始学编程时,我的思维是"命令式"的:

// 命令式思维: 告诉计算机每一步怎么做
function getAdults(users) {
  const result = [];
  for (let i = 0; i < users.length; i++) {
    if (users[i].age >= 18) {
      result.push(users[i].name);
    }
  }
  return result;
}

reduce (以及其他函数式方法) 鼓励我们用"声明式"思维:

// 声明式思维: 描述我想要什么
function getAdults(users) {
  return users
    .filter(user => user.age >= 18)
    .map(user => user.name);
}

两者的区别在于:抽象层次。声明式代码更接近"我想要成年用户的名字",而命令式代码更像"先创建空数组,然后遍历,如果年龄大于等于 18..."。

reduce 把这种声明式思维推向了极致:我们只需要描述"如何从一个值变成下一个值",剩下的交给方法本身。

数据转换思维:输入形态 → 输出形态

使用 reduce 的关键在于:清晰地定义输入和输出的形态

让我举个例子:

// 环境: 浏览器 / Node.js
// 场景: 将用户数组转换为按年龄分组的对象

const users = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
  { name: 'Charlie', age: 25 },
  { name: 'David', age: 30 }
];

// 思考过程:
// 输入: Array<User>
// 输出: { [age]: Array<User> }
// 初始值: {} (空对象)

const grouped = users.reduce((acc, user) => {
  const age = user.age;
  
  // 如果这个年龄还没有对应的数组,创建一个
  if (!acc[age]) {
    acc[age] = [];
  }
  
  // 把用户添加到对应年龄的数组中
  acc[age].push(user);
  
  return acc;
}, {});

console.log(grouped);
/*
{
  25: [
    { name: 'Alice', age: 25 },
    { name: 'Charlie', age: 25 }
  ],
  30: [
    { name: 'Bob', age: 30 },
    { name: 'David', age: 30 }
  ]
}
*/

这个例子展示了典型的 reduce 思维:

  1. 明确输入形态:数组
  2. 明确输出形态:对象
  3. 选择合适的初始值:空对象
  4. 定义转换规则:根据年龄分组

当我开始用这种方式思考问题时,很多复杂的数据处理突然变得清晰了。

为什么说 reduce 是最底层的抽象

这是一个很有趣的发现:我们可以用 reduce 来实现 mapfilter 等其他数组方法。

// 环境: 浏览器 / Node.js
// 场景: 用 reduce 实现其他数组方法

// 1. 实现 map
Array.prototype.myMap = function(callback) {
  return this.reduce((acc, curr, index) => {
    acc.push(callback(curr, index));
    return acc;
  }, []);
};

const doubled = [123].myMap(x => x * 2);
console.log(doubled); // [2, 4, 6]

// 2. 实现 filter
Array.prototype.myFilter = function(callback) {
  return this.reduce((acc, curr, index) => {
    if (callback(curr, index)) {
      acc.push(curr);
    }
    return acc;
  }, []);
};

const evens = [1234].myFilter(x => x % 2 === 0);
console.log(evens); // [2, 4]

// 3. 实现 find
Array.prototype.myFind = function(callback) {
  return this.reduce((acc, curr) => {
    // 如果已经找到,直接返回
    if (acc !== undefined) return acc;
    // 否则检查当前元素
    return callback(curr) ? curr : undefined;
  }, undefined);
};

const firstEven = [1234].myFind(x => x % 2 === 0);
console.log(firstEven); // 2

这说明什么?

reduce 是一种更通用的抽象mapfilter 都是它的特例:

  • map: 把数组转换成另一个等长的数组
  • filter: 把数组转换成长度可能更小的数组
  • reduce: 把数组转换成任何东西

从这个角度看,reduce 代表的是"归约"这个更本质的概念。

与 map/filter 的关系

那是不是说我们应该用 reduce 替代所有其他方法?并不是。

我的理解是:

  • mapfilter 表达的是特定意图,代码可读性更好
  • reduce 更加通用,但也更抽象,可能降低可读性
  • 选择合适的工具取决于具体场景
// 环境: 浏览器 / Node.js
// 场景: 可读性对比

const numbers = [12345];

// 方案 A: 链式调用 (推荐,意图清晰)
const result1 = numbers
  .filter(x => x % 2 === 0)  // 我想要偶数
  .map(x => x * 2);          // 我想要它们的两倍

// 方案 B: 单一 reduce (更高效,但意图不够清晰)
const result2 = numbers.reduce((acc, x) => {
  if (x % 2 === 0) {
    acc.push(x * 2);
  }
  return acc;
}, []);

console.log(result1); // [4, 8]
console.log(result2); // [4, 8]

在大多数情况下,我会选择方案 A,因为可读性 > 微小的性能差异。但当链式调用导致多次遍历,且性能成为瓶颈时,单一的 reduce 可能是更好的选择。

典型应用场景

理解了原理和哲学,让我们看看 reduce 在实际场景中如何应用。

场景 1: 数据聚合

这是 reduce 最常见的用途:把一组数据聚合成单个值。

// 环境: 浏览器 / Node.js
// 场景: 订单统计

const orders = [
  { id: 1, amount: 100, status: 'completed' },
  { id: 2, amount: 200, status: 'pending' },
  { id: 3, amount: 150, status: 'completed' },
  { id: 4, amount: 300, status: 'completed' }
];

// 1. 求总金额
const total = orders.reduce((sum, order) => sum + order.amount0);
console.log('Total:', total); // 750

// 2. 求已完成订单的金额
const completedTotal = orders.reduce((sum, order) => {
  return order.status === 'completed' ? sum + order.amount : sum;
}, 0);
console.log('Completed:', completedTotal); // 550

// 3. 求最大金额订单
const maxOrder = orders.reduce((max, order) => {
  return order.amount > max.amount ? order : max;
});
console.log('Max order:', maxOrder); // { id: 4, amount: 300, ... }

// 4. 一次遍历获取多个统计信息
const stats = orders.reduce((acc, order) => {
  acc.total += order.amount;
  acc.count += 1;
  if (order.status === 'completed') {
    acc.completed += 1;
  }
  return acc;
}, { total: 0, count: 0, completed: 0 });

console.log('Stats:', stats);
// { total: 750, count: 4, completed: 3 }

第 4 个例子展示了 reduce 的一个优势:一次遍历完成多项统计。如果分开计算,就需要多次遍历数组。

场景 2: 数据重组

reduce 可以把数组转换成对象,这在很多场景下非常有用。

// 环境: 浏览器 / Node.js
// 场景: 构建查找表 (lookup table)

const products = [
  { id: 'p1', name: 'Laptop', price: 1000 },
  { id: 'p2', name: 'Mouse', price: 50 },
  { id: 'p3', name: 'Keyboard', price: 80 }
];

// 1. 按 id 索引 (常用于快速查找)
const productsById = products.reduce((acc, product) => {
  acc[product.id] = product;
  return acc;
}, {});

console.log(productsById['p2']);
// { id: 'p2', name: 'Mouse', price: 50 }

// 2. 按价格区间分组
const priceRanges = products.reduce((acc, product) => {
  const range = product.price < 100'cheap''expensive';
  if (!acc[range]) {
    acc[range] = [];
  }
  acc[range].push(product);
  return acc;
}, {});

console.log(priceRanges);
/*
{
  expensive: [{ id: 'p1', name: 'Laptop', price: 1000 }],
  cheap: [
    { id: 'p2', name: 'Mouse', price: 50 },
    { id: 'p3', name: 'Keyboard', price: 80 }
  ]
}
*/

// 3. 数组去重 (利用对象的 key 唯一性)
const numbers = [1223334];
const unique = Object.keys(
  numbers.reduce((acc, num) => {
    acc[num] = true;
    return acc;
  }, {})
).map(Number);

console.log(unique); // [1, 2, 3, 4]

这些转换在实际开发中非常常见,比如:

  • 从 API 获取数组数据,转换成对象以便快速查找
  • 对数据进行分组、分类
  • 去重、去除无效数据

场景 3: 数据扁平化

扁平化是面试题的常客,用 reduce 实现很自然。

// 环境: 浏览器 / Node.js
// 场景: 多维数组扁平化

// 1. 二维数组扁平化
const nested2D = [[12], [34], [5]];

const flat2D = nested2D.reduce((acc, arr) => {
  return acc.concat(arr);
}, []);

console.log(flat2D); // [1, 2, 3, 4, 5]

// 2. 多维数组扁平化 (递归)
function flattenDeep(arr) {
  return arr.reduce((acc, item) => {
    // 如果是数组,递归扁平化
    if (Array.isArray(item)) {
      return acc.concat(flattenDeep(item));
    }
    // 否则直接添加
    return acc.concat(item);
  }, []);
}

const nested = [1, [2, [3, [4]], 5]];
console.log(flattenDeep(nested)); // [1, 2, 3, 4, 5]

// 3. 对象数组中的嵌套数组扁平化
const data = [
  { id: 1, tags: ['js''react'] },
  { id: 2, tags: ['css''html'] },
  { id: 3, tags: ['js''vue'] }
];

const allTags = data.reduce((acc, item) => {
  return acc.concat(item.tags);
}, []);

console.log(allTags);
// ['js', 'react', 'css', 'html', 'js', 'vue']

// 去重后的所有标签
const uniqueTags = [...new Set(allTags)];
console.log(uniqueTags);
// ['js', 'react', 'css', 'html', 'vue']

值得一提的是,现代 JavaScript 提供了原生的 flat() 方法,但理解如何用 reduce 实现它,有助于加深对 reduce 的理解。

场景 4: 函数组合 (compose/pipe)

这是一个更高级的场景,但在函数式编程中非常重要。

// 环境: 浏览器 / Node.js
// 场景: 实现函数组合工具

// 1. compose: 从右到左执行函数
// compose(f, g, h)(x) === f(g(h(x)))
const compose = (...fns) => {
  return (initialValue) => {
    return fns.reduceRight((acc, fn) => fn(acc), initialValue);
  };
};

// 2. pipe: 从左到右执行函数  
// pipe(f, g, h)(x) === h(g(f(x)))
const pipe = (...fns) => {
  return (initialValue) => {
    return fns.reduce((acc, fn) => fn(acc), initialValue);
  };
};

// 示例:数据处理管道
const double = x => x * 2;
const addTen = x => x + 10;
const square = x => x * x;

// 使用 pipe (更符合阅读习惯)
const transform = pipe(double, addTen, square);
console.log(transform(5)); // ((5 * 2) + 10) ^ 2 = 400

// 使用 compose (数学函数的传统写法)
const transform2 = compose(square, addTen, double);
console.log(transform2(5)); // 同样是 400

// 实际场景:用户数据处理
const users = [
  { name: 'alice', age: 17, active: true },
  { name: 'bob', age: 25, active: false },
  { name: 'charlie', age: 30, active: true }
];

const processUsers = pipe(
  users => users.filter(u => u.active),      // 只要活跃用户
  users => users.filter(u => u.age >= 18),   // 只要成年用户
  users => users.map(u => u.name),           // 只要名字
  names => names.map(n => n.toUpperCase())   // 转大写
);

console.log(processUsers(users)); // ['CHARLIE']

虽然在日常开发中我们可能不会频繁使用 compose/pipe,但这个例子展示了 reduce 作为一种抽象工具的强大之处。

场景 5: 异步场景中的 reduce

这是一个比较进阶但很实用的技巧:用 reduce 串行执行异步操作。

// 环境: Node.js / 浏览器
// 场景: 串行执行 Promise

// 假设我们有一组需要顺序执行的异步任务
const tasks = [
  () => new Promise(resolve => {
    setTimeout(() => {
      console.log('Task 1 done');
      resolve(1);
    }, 1000);
  }),
  () => new Promise(resolve => {
    setTimeout(() => {
      console.log('Task 2 done');
      resolve(2);
    }, 500);
  }),
  () => new Promise(resolve => {
    setTimeout(() => {
      console.log('Task 3 done');
      resolve(3);
    }, 800);
  })
];

// 使用 reduce 串行执行
async function runSequentially(tasks) {
  return tasks.reduce(async (previousPromise, currentTask) => {
    // 等待上一个任务完成
    const results = await previousPromise;
    // 执行当前任务
    const result = await currentTask();
    // 累积结果
    return [...results, result];
  }, Promise.resolve([]));
}

// 执行
runSequentially(tasks).then(results => {
  console.log('All tasks done:', results);
  // 输出顺序: Task 1 done, Task 2 done, Task 3 done
  // All tasks done: [1, 2, 3]
});

// 对比:如果用 Promise.all (并行执行)
// Promise.all(tasks.map(task => task())).then(results => {
//   console.log('All tasks done:', results);
//   // 输出顺序可能是: Task 2 done, Task 3 done, Task 1 done
// });

这个技巧在需要按顺序处理一系列异步操作时非常有用,比如:

  • 按顺序上传多个文件
  • 按顺序执行多个 API 请求 (每个请求依赖前一个的结果)
  • 数据库的顺序迁移操作

进阶技巧

处理异步:串行执行 Promise

在上面的场景 5 中我们已经看到了一个例子,让我再展开一些变体:

// 环境: Node.js / 浏览器
// 场景: 更复杂的异步串行处理

// 1. 每个任务依赖前一个任务的结果
const steps = [
  async (prev) => {
    console.log('Step 1, prev:', prev);
    return prev + 1;
  },
  async (prev) => {
    console.log('Step 2, prev:', prev);
    return prev * 2;
  },
  async (prev) => {
    console.log('Step 3, prev:', prev);
    return prev + 10;
  }
];

async function pipeline(steps, initialValue) {
  return steps.reduce(async (prevPromise, step) => {
    const prevValue = await prevPromise;
    return step(prevValue);
  }, Promise.resolve(initialValue));
}

pipeline(steps, 0).then(result => {
  console.log('Final result:', result);
  // Step 1, prev: 0 => 1
  // Step 2, prev: 1 => 2
  // Step 3, prev: 2 => 12
  // Final result: 12
});

// 2. 带错误处理的版本
async function pipelineWithErrorHandling(steps, initialValue) {
  return steps.reduce(async (prevPromise, step, index) => {
    try {
      const prevValue = await prevPromise;
      return await step(prevValue);
    } catch (error) {
      console.error(`Error at step ${index}:`, error.message);
      throw error; // 或者根据需求决定是否继续
    }
  }, Promise.resolve(initialValue));
}

性能考量:什么时候不该用 reduce

虽然 reduce 很强大,但并非万能。在某些情况下,使用它可能不是最佳选择:

// 环境: 浏览器 / Node.js
// 场景: 性能对比

const largeArray = Array.from({ length: 100000 }, (_, i) => i);

// 场景 1: 简单的求和
console.time('for loop');
let sum1 = 0;
for (let i = 0; i < largeArray.length; i++) {
  sum1 += largeArray[i];
}
console.timeEnd('for loop'); // 通常最快

console.time('reduce');
const sum2 = largeArray.reduce((acc, num) => acc + num, 0);
console.timeEnd('reduce'); // 稍慢,但差异不大

// 场景 2: 需要提前退出的情况
console.time('for with break');
let found1 = null;
for (let i = 0; i < largeArray.length; i++) {
  if (largeArray[i] === 50000) {
    found1 = largeArray[i];
    break; // 可以提前退出
  }
}
console.timeEnd('for with break');

console.time('reduce no early exit');
const found2 = largeArray.reduce((acc, num) => {
  if (acc !== null) return acc; // 模拟提前退出,但仍会遍历所有元素
  return num === 50000 ? num : null;
}, null);
console.timeEnd('reduce no early exit'); // 无法真正提前退出,性能较差

// 场景 3: find 比 reduce 更合适
console.time('find');
const found3 = largeArray.find(num => num === 50000);
console.timeEnd('find'); // 可以提前退出,性能好

我的建议

  1. 对于简单的求和、求积,性能差异可以忽略,优先考虑可读性
  2. 需要提前退出的场景,不要用 reduce,用 for 循环或 find/some 等方法
  3. 不要为了用 reduce 而用 reduce,选择最适合表达意图的方法

可读性平衡:复杂场景下的取舍

reduce 的逻辑变得复杂时,可读性可能成为问题:

// 环境: 浏览器 / Node.js
// 场景: 复杂的 reduce vs 多步骤处理

const transactions = [
  { type: 'income', amount: 1000, category: 'salary' },
  { type: 'expense', amount: 200, category: 'food' },
  { type: 'expense', amount: 300, category: 'transport' },
  { type: 'income', amount: 500, category: 'bonus' }
];

// 方案 A: 单一复杂的 reduce (不推荐)
const summary1 = transactions.reduce((acc, tx) => {
  if (tx.type === 'income') {
    acc.income += tx.amount;
    if (!acc.incomeByCategory[tx.category]) {
      acc.incomeByCategory[tx.category] = 0;
    }
    acc.incomeByCategory[tx.category] += tx.amount;
  } else {
    acc.expense += tx.amount;
    if (!acc.expenseByCategory[tx.category]) {
      acc.expenseByCategory[tx.category] = 0;
    }
    acc.expenseByCategory[tx.category] += tx.amount;
  }
  acc.balance = acc.income - acc.expense;
  return acc;
}, { 
  income: 0, 
  expense: 0, 
  balance: 0,
  incomeByCategory: {},
  expenseByCategory: {}
});

// 方案 B: 分步处理 (推荐)
const income = transactions
  .filter(tx => tx.type === 'income')
  .reduce((sum, tx) => sum + tx.amount0);

const expense = transactions
  .filter(tx => tx.type === 'expense')
  .reduce((sum, tx) => sum + tx.amount0);

const summary2 = {
  income,
  expense,
  balance: income - expense
};

console.log(summary2); // 更清晰

我的权衡原则:

  • 如果 reduce 的回调函数超过 5-7 行,考虑拆分或用其他方法
  • 如果需要嵌套的条件判断,可能不适合用 reduce
  • 优先考虑代码的可维护性,而非炫技

常见陷阱与调试技巧

在使用 reduce 时,我遇到过一些容易犯的错误:

// 环境: 浏览器 / Node.js
// 场景: 常见错误示例

// 陷阱 1: 忘记返回 accumulator
const wrong1 = [123].reduce((acc, num) => {
  acc.push(num * 2);
  // 忘记 return acc!
}, []);
console.log(wrong1); // undefined

// 正确做法
const correct1 = [123].reduce((acc, num) => {
  acc.push(num * 2);
  return acc; // 必须返回
}, []);

// 陷阱 2: 意外修改了原始对象
const data = { count: 0 };
const result = [123].reduce((acc, num) => {
  acc.count += num;
  return acc;
}, data); // 使用了外部对象作为初始值

console.log(data.count); // 6 - 原始对象被修改了!

// 正确做法:使用新对象
const correct2 = [123].reduce((acc, num) => {
  acc.count += num;
  return acc;
}, { count: 0 }); // 使用新对象

// 陷阱 3: 在 reduce 中使用 push 但期望得到新数组
const original = [123];
const result3 = original.reduce((acc, num) => {
  acc.push(num * 2);
  return acc;
}, []); // 虽然初始值是新数组,但每次都在修改同一个数组

// 如果需要不可变性,使用 concat
const immutable = original.reduce((acc, num) => {
  return acc.concat(num * 2);
}, []);

调试技巧

// 在 reducer 函数中添加日志
const debugReduce = [123].reduce((acc, num, index) => {
  console.log({
    iteration: index,
    current: num,
    accumulator: acc,
    returned: acc + num
  });
  return acc + num;
}, 0);

建立自己的 reduce 思维

识别模式:什么问题适合用 reduce

经过一段时间的学习和实践,我总结了一些"信号",提示我可能需要用 reduce

强信号 (很可能适合):

  1. 需要把数组"聚合"成单个值 (求和、求积、最值)
  2. 需要把数组转换成对象 (索引、分组)
  3. 需要累积一个复杂的状态 (计数器、统计信息)
  4. 需要扁平化嵌套结构
  5. 需要函数组合或管道处理

弱信号 (可能适合,但有其他选择):

  1. 需要转换数组 → 考虑 map 是否更清晰
  2. 需要过滤数组 → 考虑 filter 是否更清晰
  3. 需要查找元素 → 考虑 findsomeevery

反向信号 (可能不适合):

  1. 需要提前退出循环
  2. 逻辑非常复杂,嵌套层级深
  3. 团队成员对函数式编程不熟悉 (可读性第一)

思考框架:如何设计 reducer 函数

当我确定要用 reduce 后,我通常按这个步骤思考:

Step 1: 明确输入和输出

输入: [1, 2, 3, 4]
输出: 10

Step 2: 选择初始值

初始值: 0 (因为我要求和,0 是加法的单位元)

Step 3: 定义转换规则

每次迭代: 累加器 + 当前元素 = 新累加器

Step 4: 写成代码

[1234].reduce((acc, curr) => acc + curr, 0)

让我用一个更复杂的例子演示这个思考过程:

// 环境: 浏览器 / Node.js
// 场景: 统计单词出现次数

const text = 'hello world hello javascript world';
const words = text.split(' ');
// ['hello', 'world', 'hello', 'javascript', 'world']

// Step 1: 明确输入输出
// 输入: Array<string>
// 输出: { [word]: count }

// Step 2: 选择初始值
// 初始值: {} (空对象,用于存储单词和计数)

// Step 3: 定义转换规则
// 每次迭代:
//   - 如果单词已存在,计数 +1
//   - 如果单词不存在,设置为 1

// Step 4: 实现
const wordCount = words.reduce((acc, word) => {
  acc[word] = (acc[word] || 0) + 1;
  return acc;
}, {});

console.log(wordCount);
// { hello: 2, world: 2, javascript: 1 }

从面试题到实际项目的迁移

在刷题过程中,我发现很多 reduce 的技巧可以直接应用到实际项目中:

面试题场景 → 实际项目场景

面试题 实际场景
数组求和 购物车总价计算
数组转对象 API 数据索引优化
数组扁平化 处理嵌套的评论/回复数据
函数组合 (compose) 数据处理管道、中间件链
异步串行执行 文件上传、数据库迁移
按条件分组 数据可视化、报表生成
// 环境: React 项目
// 场景: 购物车总价计算 (实际项目例子)

// 购物车数据结构
const cartItems = [
  { id: 1, name: 'Book', price: 30, quantity: 2 },
  { id: 2, name: 'Pen', price: 5, quantity: 10 },
  { id: 3, name: 'Bag', price: 80, quantity: 1 }
];

// 计算总价 (考虑数量和折扣)
const calculateTotal = (items, discountRate = 0) => {
  const subtotal = items.reduce((sum, item) => {
    return sum + (item.price * item.quantity);
  }, 0);
  
  return subtotal * (1 - discountRate);
};

console.log(calculateTotal(cartItems)); // 190
console.log(calculateTotal(cartItems, 0.1)); // 171 (打9折)

// 在 React 组件中使用
function ShoppingCart({ items }) {
  const total = items.reduce((sum, item) => 
    sum + item.price * item.quantity0
  );
  
  return (
    <div>
      <h2>Total: ${total}</h2>
    </div>
  );
}

持续练习的建议

我的学习方法:

  1. 刷题时有意识地练习 :每次遇到可以用 reduce 解决的问题,先用 reduce 实现一遍,即使有更简单的方法

  2. 重构已有代码 :回顾项目中的循环逻辑,看看哪些可以用 reduce 改写

  3. 阅读优秀代码 :看看 Redux、Lodash 等库中 reduce 的使用方式

  4. 写博客总结 :就像我现在做的,把学到的东西写出来,加深理解

  5. 小项目实践 :试着用 reduce 实现一些工具函数:

    • 深拷贝
    • 对象 merge
    • 路径取值 (get、set)
    • 简单的状态管理

延伸与发散

在研究 reduce 的过程中,我产生了一些新的思考:

reduce 与函数式编程

reduce 其实来自函数式编程中的 fold 操作。在 Haskell、OCaml 等语言中,fold 是一个核心概念。这让我意识到:学习 reduce 不只是学一个数组方法,更是在学习一种编程范式

函数式编程的一些核心思想:

  • 不可变性 :每次返回新值,而不是修改旧值
  • 纯函数 :相同输入总是产生相同输出,无副作用
  • 声明式 :描述"做什么",而非"怎么做"

这些思想在现代前端开发中越来越重要,特别是在使用 React、Redux 等框架时。

reduce 在状态管理中的应用

Redux 的核心概念正是基于 reduce

// Redux 的 reducer 本质上就是一个 reduce 操作
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO'return [...state, action.payload];
    case 'REMOVE_TODO'return state.filter(todo => todo.id !== action.payload);
    defaultreturn state;
  }
}

// 实际上就是:
const finalState = actions.reduce(todosReducer, initialState);

理解 reduce 有助于理解 Redux 的设计哲学:状态是不可变的,每次操作都产生新状态。

相关技术的对比

在学习 reduce 时,我也了解了一些相关的概念:

  • Array.prototype.reduceRight :从右往左 reduce,用于 compose 函数
  • Observable.reduce (RxJS):在响应式编程中的应用
  • Stream.reduce (Node.js):在流处理中的应用

这些概念虽然语法不同,但核心思想是一致的:把一系列值归约成单个值。

未来可能的演进

JavaScript 还在不断演进,可能未来会有更多与 reduce 相关的特性:

  • Pipeline Operator (|>):让函数组合更自然
  • Pattern Matching:让条件分支更简洁
  • Records & Tuples:不可变数据结构的原生支持

这些提案都与 reduce 的思想相关,值得持续关注。

我的困惑与疑问

在学习过程中,我还有一些未解的疑问:

  1. 性能优化的临界点:在什么规模的数据下,reduce 的性能劣势会明显?

  2. 可读性的度量:如何量化"可读性"?如何在团队中达成共识?

  3. 初学者友好性reduce 对新手来说确实比较抽象,如何更好地教学?

  4. 最佳实践的边界:什么情况下"过度使用 reduce"?如何把握这个度?

这些问题可能没有标准答案,但思考它们本身就很有价值。

小结

写完这篇文章,我对 reduce 有了更深的理解。它不仅仅是一个数组方法,更是一种归约思维的体现。

这个学习过程让我意识到:

  • 工具的价值不在于它有多强大,而在于我们是否真正理解并掌握了它
  • 很多时候"不会用"不是因为方法不好,而是缺少合适的心智模型
  • 从面试题到实际应用,需要的是迁移能力识别模式的直觉

我现在还不能说自己完全掌握了 reduce,但至少建立了一个思考框架。接下来的计划是:

  1. 在项目中有意识地寻找 reduce 的应用场景
  2. 尝试用 reduce 重构一些旧代码,观察效果
  3. 继续研究函数式编程的其他概念

如果你也在学习 reduce,或者有不同的理解和经验,欢迎交流。学习是一个持续迭代的过程,这篇文章只是我的一个阶段性总结。

最后,引用一句话:

"Simplicity is the ultimate sophistication." — Leonardo da Vinci

reduce 的美,或许就在于它用一个简单的概念,表达了复杂的转换过程。

参考资料

gsap -滚动插件 ScrollTrigger 简单demo

作者 大时光
2026年2月24日 18:06
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GSAP Scroll Demo</title>
    <style>
      body {
        margin: 0;
        font-family: "Segoe UI", sans-serif;
        background: #0b1020;
        color: #e2e8f0;
      }
      section {
        height: 100vh;
        position: relative;
        overflow: hidden;
      }
      .center-stack {
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        width: 80vw;
        height: 60vh;
      }
      .stack-item {
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        border-radius: 18px;
        opacity: 0.95;
      }
      .stack-1 { width: 140px; height: 140px; background: #0ea5e9; }
      .stack-2 { width: 200px; height: 200px; background: #22c55e; }
      .stack-3 { width: 280px; height: 280px; background: #f59e0b; }
      .stack-4 { width: 200px; height: 200px; background: #a855f7; }
      .stack-5 { width: 140px; height: 140px; background: #ef4444; }

      .quad {
        position: absolute;
        padding: 12px 16px;
        border-radius: 10px;
        font-size: 20px;
        font-weight: 600;
        background: #111827;
      }
      .quad-up { left: 50%; top: 20%; transform: translate(-50%, 0); }
      .quad-down { left: 50%; bottom: 20%; transform: translate(-50%, 0); }
      .quad-left { left: 12%; top: 50%; transform: translate(0, -50%); }
      .quad-right { right: 12%; top: 50%; transform: translate(0, -50%); }

      .list {
        position: absolute;
        left: 10%;
        top: 50%;
        transform: translateY(-50%);
        display: flex;
        flex-direction: column;
        gap: 16px;
      }
      .list-item {
        width: 140px;
        padding: 12px 16px;
        border-radius: 10px;
        background: #111827;
        font-size: 16px;
        font-weight: 600;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
</head>
<body>
    <section id="view-one">
      <div class="center-stack">
        <div class="stack-item stack-1" id="stack-1"></div>
        <div class="stack-item stack-2" id="stack-2"></div>
        <div class="stack-item stack-3" id="stack-3"></div>
        <div class="stack-item stack-4" id="stack-4"></div>
        <div class="stack-item stack-5" id="stack-5"></div>
      </div>
    </section>

    <section id="view-two">
      <div class="quad quad-up" id="quad-up">向上</div>
      <div class="quad quad-down" id="quad-down">向下</div>
      <div class="quad quad-left" id="quad-left">向左</div>
      <div class="quad quad-right" id="quad-right">向右</div>
    </section>

    <section id="view-three">
      <div class="list">
        <div class="list-item" id="list-1">第一个</div>
        <div class="list-item" id="list-2">第二个</div>
        <div class="list-item" id="list-3">第三个</div>
      </div>
    </section>

    <script>
      gsap.registerPlugin(ScrollTrigger);
       // 第一视图
      gsap.set(["#stack-1", "#stack-5"], { x: 0 });
      gsap.set(["#stack-2", "#stack-4"], { x: 0 });
      gsap.set("#stack-3", { x: 0 });

      gsap.timeline({
        scrollTrigger: {
          trigger: "#view-one",
          start: "top top",
          end: "+=400%",
          scrub: true,
          pin: true
        }
      })
      .to(["#stack-1", "#stack-5"],{ x:(i) => i === 0 ? -220 : 220, duration: 0.4 })
      .to(["#stack-2", "#stack-4"], { x:(i) => i === 0 ? -160 : 160, duration: 0.4 })

     // 第二视图
      gsap.set(["#quad-up", "#quad-down", "#quad-left", "#quad-right"], { opacity: 0 });
      gsap.set("#quad-up", { y: -40 });
      gsap.set("#quad-down", { y: 40 });
      gsap.set("#quad-left", { x: -40 });
      gsap.set("#quad-right", { x: 40 });

      gsap.timeline({
        scrollTrigger: {
          trigger: "#view-two",
          start: "top top",
          end: "+=400%",
          scrub: true,
          pin: true
        }
      })
      .to("#quad-up", { y: 0, opacity: 1, duration: 0.25 })
      .to("#quad-down", { y: 0, opacity: 1, duration: 0.25 })
      .to("#quad-left", { x: 0, opacity: 1, duration: 0.25 })
      .to("#quad-right", { x: 0, opacity: 1, duration: 0.25 });

     // 第三视图
      gsap.set(["#list-1", "#list-2", "#list-3"], { x: 0 });

      gsap.timeline({
        scrollTrigger: {
          trigger: "#view-three",
          start: "top top",
          end: "+=400%",
          scrub: true,
          pin: true
        }
      })
      .to("#list-1", { x: 100, duration: 0.33 })
      .to("#list-2", { x: 200, duration: 0.33 })
      .to("#list-3", { x: 300, duration: 0.33 });
    </script>
</body>
</html>

这个cdn是用官网的 如果报错 请使用魔法

scrollTrigger: { 
trigger: "#element-id", // 触发动画的元素
start: "top top", // 起始位置(元素顶部, 视口顶部)
end: "+=400%", // 结束位置(起始位置+400%视口高度)
scrub: true, // 动画进度与滚动同步
pin: true // 在动画期间固定元素 
}

效果就是

image.png

image.png

依次出现上下左右 文字

image.png

然后依次 向右移动

image.png

02 登录功能实现

作者 WWWWW先生
2026年2月24日 17:59

1. 登录界面基础校验

1.1 基础数据的双向绑定

登录界面的element-UI组件由三层组成,最外层是el-form,中间层是el-form-item,最内层是el-form表单元素组件。

三个组件各施其职共同完成表单校验功能,具体的实现方式:在el-input组件上使用v-model指令实现双向绑定,比如:v-model="form.username"

1.2 表单校验配置

  1. 表单对象的字段命名需与接口字段保持一致,便于后续表单提交。比如,这里的username和password。

  2. 规则对象是采用对象嵌套数组的形式,比如:

rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ]
      }

校验规则:required设置必填项;message校验失败提示信息;trigger触发校验的事件,常用blur失焦事件。

  1. 组件绑定 el-form绑定:需要同时绑定:model="form":rules="rules"两个属性。el-form-item配置:通过prop属性指定对应的校验规则。

1.3 总结

表单校验是通过el-formel-form-itemel-input组件联合实现表单检验。其中,el-form绑定表单对象和规则对象,el-form-item指定校验规则,el-input双向绑定数据。

完整代码:

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>

        <el-form-item prop="remember">
          <el-checkbox>记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  }
}

</script>

2. 登录界面统一校验

实际上,登录时表单的校验分为两部分,第一部分是用户输入框失焦时触发的校验,另一根时点击登录按钮时的统一校验。

因为用户如果没进行任何输入,直接点击登录按钮,第一部分的校验将没法触发,因此,需要第二部分的统一校验来兜底。

实现方式: 通过给el-form组件添加ref="form"属性,然后使用this.$refs.form获取表单实例对象。

每个表单项需要通过prop属性指定对应的校验规则,如prop="username"会关联rules对象中定义的username的校验规则。

核心方法:调用表单实例的validate方法会对所有带prop属性的表单项进行统一校验。

回调参数:validate方法接收回调函数,参数valid为布尔值,当所有校验通过时为true,否则为false。

逻辑处理:通常在回调函数中判断valid值,为true时才执行后续登录逻辑。

事件绑定:给登录按钮添加@click="loginHandler"事件,在methods中定义loginHandler方法。

方法实现:在loginHandler中调用this.$refs.form.validate方法进行统一校验。

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- 基础校验:
        el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
        统一校验:
        获取表单的实例对象
        调用validate方法
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>

        <el-form-item prop="remember">
          <el-checkbox>记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn" @click="loginHandler">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          console.log(111111)
        }
      })
    }
  }
}

</script>

<style scoped lang="scss">
  .login_body {
    display: flex;
  }
  .bg {
    width: 60vw;
    height: 100vh;
    background: url('~@/assets/login-bg.svg') no-repeat;
    background-position: right top;
    background-size: cover;
  }
  .box {
    margin: 200px 10% 0;
    flex: 1;
    .title {
      padding-bottom: 76px;
      font-size: 26px;
      font-weight: 500;
      color: #1e2023;
    }
    ::v-deep() {
      .ant-form-item {
        display: flex;
        margin-bottom: 62px;
        flex-direction: column;
      }
      .ant-form-item-label label {
        font-size: 16px;
        color: #8b929d;
      }
      .ant-input,
      .ant-input-password {
        border-radius: 8px;
      }
    }
  }
  .login_btn{
    width: 100%;
  }
</style>

  1. 测试场景1:不输入任何内容直接点击登录,valid值为false,阻止登录流程。
  2. 测试场景2:输入符合规则的账号密码后点击登录,valid值为true,允许继续登录操作。
  3. 调试技巧:可以在回调函数中添加console.log(valid)来验证校验结果是否符合预期。

3. 使用vuex管理用户的token

登录的目的是获取token用于多组件共享,token是字符串类型。

e8ec8a07-8bf2-47d3-b750-043fc0c39ae5.png 三大模块:

  1. state:存储token数据状态
  2. mutation: 同步修改token的唯一途径
  3. action: 包含接口调用和提交mutation

组件只需触发action,完成接口调用后提交mutation更新state。 store/modules/user.js中代码:

import { loginAPI } from '@/api/user'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    token: ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    setToken(state, newToken) {
      state.token = newToken
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

代码解读:

首先,token初始化为'',与后端返回的类型一致。namespaced:true确保模块的独立性。

mutations为规范写法,第一个参数为state对象,第二个参数newToken为荷载(payload),通过state.token=newToken直接赋值。

异步请求接口为规范写法,第一个参数ctx为上下文对象,第二个参数使用解构赋值明确参数要求。接口分为两步:第一步调用loginAPI接口获取token;第二步通过ctx.commit提交mutation并更新state。

api/user.js中代码:

import request from '@/utils/request'

// 登录函数
/**
 * @description: 登录函数
 * @param {*} data { username,password}
 * @return {*} promise
 */

// 函数:参数 + 逻辑 + 返回值
export function loginAPI(data) {
  return request({
    url: '/park/login', // baseURL + url
    method: 'POST', // GET/POST/PUT/DELETE
    data // 请求体参数
  })
  // 返回的是一个promise
}

这里面定义了loginAPI接口,接口参数:

url: '/park/login'
method: 'POST'
参数对象包含username和password字段
返回值:Promise对象,data中包含token字段

views/Login/index.vue组件中执行代码this.$store.dispatch('user/asyncLogin', this.form)即可触发调用。

4. 登录后跳转到首页

Token存储机制:登录接口调用成功后,将获取的token数据存储在Vuex状态管理中

路由跳转时机:登录成功后需要进行首页跳转,但要注意异步操作的顺序问题

职责分离原则:

  • Vuex只负责处理用户数据相关逻辑(如token存储)
  • 业务代码(路由跳转、提示消息)应放在业务组件中实现
  • 避免将业务逻辑混入状态管理模块

核心代码:

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(async valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          // 确保token返回之后再跳转到首页,防止首页有一些需要依赖token的逻辑
          await this.$store.dispatch('user/asyncLogin', this.form)
          // 跳转到首页
          this.$router.push('/')
          // 提示用户登录成功
          this.$message({
            type: 'success',
            message: '登录成功'
          })
        }
      })
    }
  }
}

</script>

5. token持久化操作

Token的有效期会持续一定时间,在这段时间内没有必要重复请求token,但是vuex本身是基于内存的管理方式,刷新浏览器Token会丢失,为了避免丢失需要配置持久化进行缓存。

解决思路:1. 存储Token数据时,一份存入vuex,一份存入cookie;2.vuex中初始化Token时,优先从本地cookie获取,本地获取不到再初始化为空字符串。

  1. utils/auth.js中封装cookie操作的方法
// 专门用来操作cookie的方法包
// 内部封装了繁琐的操作方法 参数处理 暴露三个函数 get,set,remove
import Cookies from 'js-cookie'
import { TOKEN_KEY } from '@/constants/KEY'
// 获取token的方法
export function getToken() {
  return Cookies.get(TOKEN_KEY)
}

// 设置方法
export function setToken(token) {
  return Cookies.set(TOKEN_KEY, token)
}

// 删除方法
export function removeToken() {
  return Cookies.remove(TOKEN_KEY)
}
  1. store/modules/user.js中导入操作cookie的方法并新增存入cookie的操作,并在token初始化时优先从cookie获取
import { loginAPI } from '@/api/user'
import { setToken, getToken } from '@/utils/auth'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    // 2. vuex中初始化Token时,优先从本地cookie取,取不到再初始化为空字符串。
    token: getToken() || ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    // 1. 存Token数据时,一份存入vuex,一份存入cookie
    setToken(state, newToken) {
      // 存入vuex
      state.token = newToken
      // 存入cookie
      setToken(newToken)
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

6. token存取方式对比

  1. 为什么要使用vuex+cookies存储的方式

内存存储优势:Vuex基于内存管理,存取速度特别快(毫秒级),且封装了便捷的方法调用方式,适合高频操作场景

持久化需求:Cookies/localStorage基于磁盘存储,虽然存取速度稍慢(约慢一个量级),但具有刷新不丢失的特性

组合方案价值:同时利用Vuex的速度优势(运行时状态管理)和Cookies的持久化特性(长期存储),典型场景如用户登录态保持

  1. cookie vs localStorage

存储容量:

localStorage约5MB(不同浏览器有差异);
cookie仅几KB(个位数级别),大容量数据存储首选localStorage

操作权限:

localStorage纯前端操作;
cookie前后端均可操作(实际开发中后端操作为主),通过Set-Cookie头实现

请求携带:

cookie自动跟随接口发送(无需手动设置)
localStorage需手动添加到请求头(如Authorization头)

7. 添加token到请求头

前端请求接口后,后端需要对接口做鉴权,只有token有效,接口才能正常响应,返回正常的数据,token就是后端接口判断的标识。项目中,前端页面会请求非常多的接口,axios请求拦截器可以统一控制,一次添加,多个接口都生效。

utils/request.js中写入如下代码:

import axios from 'axios'
import { getToken } from './auth'

const service = axios.create({
  baseURL: 'https://api-hmzs.itheima.net/v1',
  timeout: 5000 // request timeout
})

// 请求拦截器
service.interceptors.request.use(
  config => {
    // 添加token
    const token = getToken()
    if (token) {
      // 前面是固定写法,后面token的拼接模式是由后端来决定。
      config.headers.Authorization = token
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  response => {
    return response.data
  },
  error => {
    return Promise.reject(error)
  }
)

export default service

8. 记住我功能的实现

交互表现

1.选中状态:当用户选中"记住我"复选框并登录后,再次返回登录页时,系统会自动回填之前输入的用户名和密码
2.未选中状态:当用户未选中复选框时登录,再次返回登录页会清除本地存储的账号密码数据

实现逻辑:

  1. 登录时处理: 当remember为true时,将账号密码存入localStorage;当remember为false时,清除localStorage中的账号密码
  2. 初始化处理:组件初始化时从localStorage取值并回填到表单

views/Login/index.vue中代码:

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- 基础校验:
        el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
        统一校验:
        获取表单的实例对象
        调用validate方法
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>
        <!-- 1.完成选择框的双向绑定,得到一个true或者false的选中状态 -->
        <!-- 2. 如果当前为true,点击登陆时,表示要己住,把当前的用户名和密码存入本地 -->
        <!-- 3. 组件初始化的时候,从本地取账号和密码,把账号密码存入用来双向绑定的form身上。 -->
         <!-- 4. 如果当前用户没有记住,状态为false,点击登录的时候要把之前的数据清空 -->
        <el-form-item prop="remember">
          <el-checkbox v-model="remember">记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn" @click="loginHandler">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>
const REMEMBER_KEY = 'remember_key'
export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      remember: false,
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  created() {
    // 去本地取一下之前存入的账号和密码,如果取到了就进行赋值操作
    const formStr = localStorage.getItem(REMEMBER_KEY)
    if (formStr) {
      const formObj = JSON.parse(formStr)
      this.form = formObj
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(async valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          // 添加记住我逻辑
          if (this.remember) {
            localStorage.setItem(REMEMBER_KEY, JSON.stringify(this.form))
          } else {
            localStorage.removeItem(REMEMBER_KEY)
          }
          // 确保token返回之后再跳转到首页,防止首页有一些需要依赖token的逻辑
          await this.$store.dispatch('user/asyncLogin', this.form)
          // 跳转到首页
          this.$router.push('/')
          // 提示用户登录成功
          this.$message({
            type: 'success',
            message: '登录成功'
          })
        }
      })
    }
  }
}

</script>

<style scoped lang="scss">
  .login_body {
    display: flex;
  }
  .bg {
    width: 60vw;
    height: 100vh;
    background: url('~@/assets/login-bg.svg') no-repeat;
    background-position: right top;
    background-size: cover;
  }
  .box {
    margin: 200px 10% 0;
    flex: 1;
    .title {
      padding-bottom: 76px;
      font-size: 26px;
      font-weight: 500;
      color: #1e2023;
    }
    ::v-deep() {
      .ant-form-item {
        display: flex;
        margin-bottom: 62px;
        flex-direction: column;
      }
      .ant-form-item-label label {
        font-size: 16px;
        color: #8b929d;
      }
      .ant-input,
      .ant-input-password {
        border-radius: 8px;
      }
    }
  }
  .login_btn{
    width: 100%;
  }
</style>

代码关键点分析:

1)选择框的双向绑定
绑定方式:使用v-model绑定remember变量
数据位置:remember作为独立变量而非表单对象属性,因为它不需要提交给后端
默认值:初始设置为false表示未选中状态
2)记住我逻辑 
存储机制:
使用localStorage存储账号密码
需要将表单对象转为JSON字符串存储
条件判断:仅在remember为true时执行存储操作,remember为false时,清除操作
常量定义:使用REMEMBER_KEY常量避免硬编码字符串重复
3)组件初始化回填
生命周期:在created或mounted钩子中执行回填逻辑
取值流程:从localStorage获取存储的字符串
使用JSON.parse转为对象
赋值给表单对象完成回填
健壮性处理:添加非空判断避免未存储数据时的错误
4)代码优化
常量提取:将'remember_key'提取为常量REMEMBER_KEY

9. 退出登录实现

退出登录功能位于导航栏右上角的用户下拉菜单中,对应组件文件为src/layout/components/Navbar.vue,方法绑定:已预置logout方法绑定在退出登录按钮的点击事件上。

<template>
  <div class="navbar">
    <div class="right-menu">
      <el-dropdown class="avatar-container" trigger="click">
        <div class="avatar-wrapper">
          <!-- 用户名称 -->
          <span class="name">黑马管理员</span>
        </div>
        <el-dropdown-menu slot="dropdown" class="user-dropdown">
          <router-link to="/">
            <el-dropdown-item> 首页 </el-dropdown-item>
          </router-link>
          <a target="_blank">
            <el-dropdown-item> 项目地址 </el-dropdown-item>
          </a>
          <!-- 实现思路:1.询问用户是否真的要退出登录;2. 用户同意之后,清空当前的用户数据并跳转到登录页 -->
          <el-dropdown-item divided @click.native="logout">
            <span style="display: block">退出登录</span>
          </el-dropdown-item>
        </el-dropdown-menu>
      </el-dropdown>
    </div>
  </div>
</template>

<script>
export default {
  methods: {
    // 退出登录
    logout() {
      // 1. 询问用户
      this.$confirm('确认要退出登录吗,是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        // 确认回调
        // 1. 清空数据
        this.$store.commit('user/clearUserInfo')
        // 2. 跳转到登录界面
        this.$router.push(`/login?redirect=${this.$route.fullPath}`)
      }).catch(() => {
        // 取消或者.then中有错误
      })
    }
  }
}
</script>

使用Element UI的$confirm方法实现二次确认,文案修改为"确认要退出登录吗,是否继续?"。

确认回调:.then()中执行数据清除和跳转逻辑
取消回调:.catch()中显示"已取消删除"提示信息
  • 数据清除

Vuex清除:通过state.token = ''清空状态管理中的token

Cookie清除:调用removeToken()方法清除本地存储的token

mutation编写:在user模块中添加clearUserInfo方法,同时处理Vuex和Cookie的清理

  • 跳转登录页

路由跳转:使用this.$router.push('/login')实现页面跳转

完整调用:通过this.$store.commit('user/clearUserInfo')提交mutation

store/modules/user.js中具体代码如下:

import { loginAPI } from '@/api/user'
import { setToken, getToken, removeToken } from '@/utils/auth'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    // 2. vuex中初始化Token时,优先从本地cookie取,取不到再初始化为空字符串。
    token: getToken() || ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    // 1. 存Token数据时,一份存入vuex,一份存入cookie
    setToken(state, newToken) {
      // 存入vuex
      state.token = newToken
      // 存入cookie
      setToken(newToken)
    },
    clearUserInfo(state) {
      // 清除vuex中的
      state.token = ''
      // 清除本地cookie中的
      removeToken()
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

10. Token控制路由跳转

如果用户没有登录,即没有token,则不让用户进入某些页面。因此,需要通过token的有无来控制路由的跳转。

54168ac7-5dfa-4156-9fe3-c434ecc83f69.png

  1. 新建src/permission.js文件,并在main.js中通过import './permission'方式引入。
permission.js文件代码

// 所有和权限控制相关的代码
import router from "./router"
import { getToken } from "./utils/auth"

console.log('权限控制生效了')

const WHITE_LIST = ['/login', '/404']

// 1. 路由前置守卫
router.beforeEach((to, from, next) => {
     // to: 目标路由对象 到哪里去
    // from:路由对象 从哪里来的那个对象
    // next: 放行函数
    const token = getToken()
    if (token) {
        // 有token
        next()
    } else {
        // 没有token
        // 1. 是否在白名单内,即在白名单数组中是否存在
        if (WHITE_LIST.includes(to.path)) {
            next()
        } else {
            next('/login')
        }

    }
})

11. 接口错误统一处理

系统中会调用很多接口,每个接口都可能会出错,为了交互体验,需要对所有的接口进行错误处理,当错误发生时,告诉用户。

实现方式:利用axios的响应拦截器捕获所有接口错误。位置:utils/request.js

import axios from 'axios'
import { getToken } from './auth'
import { Message } from 'element-ui'

const service = axios.create({
  baseURL: 'https://api-hmzs.itheima.net/v1',
  timeout: 5000 // request timeout
})

// 请求拦截器
......

// 响应拦截器
service.interceptors.response.use(
  response => {
    return response.data
  },
  // 接口出错时,自动执行这个回调
  error => {
    // console.dir(error.response.data.msg)
    // 错误类型有可能有很多种,根据不同的错误码做不同的用户提示,写的位置都在这里
    Message({
      type: 'warning',
      message: error.response.data.msg
    })
    return Promise.reject(error)
  }
)

export default service

非组件调用:通过import { Message } from 'element-ui'引入独立消息组件

类型配置:使用type: 'warning'设置警告类型提示框

参数传递:所有的错误在error中,本次的封装在将error.response.data.msg中的,取出来作为message参数传递

chown Cheatsheet

Basic Syntax

Use these core command forms for chown.

Command Description
chown USER FILE Change file owner
chown USER:GROUP FILE Change owner and group
chown :GROUP FILE Change group only
chown USER: FILE Change owner and set group to user’s login group
chown --reference=REF FILE Copy owner and group from another file

Common Examples

Common ownership changes for files and directories.

Command Description
chown root file.txt Set owner to root
chown www-data:www-data /var/www/index.html Set owner and group for a web file
sudo chown $USER:$USER file.txt Return ownership to current user
chown :developers app.log Change group only
chown --reference=source.txt target.txt Match ownership of another file

Recursive Changes

Apply ownership updates to full directory trees.

Command Description
chown -R USER:GROUP /path Recursively change owner and group
chown -R USER /path Recursively change owner only
chown -R :GROUP /path Recursively change group only
chown -R -h USER:GROUP /path Change symlink ownership itself during recursion
chown -R --from=OLDUSER:OLDGROUP NEWUSER:NEWGROUP /path Change only matching current ownership

Symlinks and Traversal

Control how chown treats symbolic links.

Command Description
chown USER:GROUP symlink Change target by default
chown -h USER:GROUP symlink Change symlink itself (not target)
chown -R -H USER:GROUP /path Follow symlink command-line args to directories
chown -R -L USER:GROUP /path Follow all directory symlinks
chown -R -P USER:GROUP /path Never follow symlinks (default)

Safe Patterns

Use these patterns to avoid ownership mistakes.

Command Description
chown --from=root root:root /path/file Change only if current owner matches
find /path -user olduser -exec chown newuser {} + Target only files owned by one user
find /path -group oldgroup -exec chown :newgroup {} + Target only one group
ls -l /path/file Verify ownership before and after changes
id username Confirm user and group names exist

Common Errors

Quick checks when ownership changes fail.

Issue Check
Operation not permitted You need root privileges; run with sudo
invalid user Verify user exists with getent passwd username
invalid group Verify group exists with getent group groupname
Changes did not apply recursively Confirm -R was used
Access still denied after chown Check permission bits with ls -l and ACLs

Related Guides

Use these guides for full ownership and permissions workflows.

Guide Description
Chown Command in Linux Full chown guide with examples
chgrp Command in Linux Change file group ownership
How to Change File Permissions in Linux (chmod command) Update permission bits
Understanding Linux File Permissions Ownership and permission model
How to List Groups in Linux Check group membership and IDs

继TailWindCss和UnoCss后的CSS-in-JS vs Utility-First 深度对比

作者 ElevenSylvia
2026年2月24日 17:17

CSS-in-JS vs Utility-First 深度对比

完整技术指南:从架构设计到开发实践的全面对比分析 配合AI硬肝

⚠️ 注意:本文讨论的是整个 CSS 方案生态,包括传统 CSS-in-JS(Styled Components、Emotion)和 Utility-First(Tailwind CSS、UnoCSS),为你选择最适合的样式方案提供全面参考。


目录

  1. 背景与趋势
  2. 核心概念解析
  3. Styled Components 深度指南
  4. Emotion 深度指南
  5. 架构设计深度对比
  6. 性能基准测试
  7. 开发体验详解
  8. 实战案例
  9. 迁移与混用策略
  10. 常见问题与解决方案
  11. 总结与选型建议

1. 背景与趋势

1.1 CSS 方案的演进历程

┌─────────────────────────────────────────────────────────────────────────────┐
                           CSS 方案演进时间线                                  
└─────────────────────────────────────────────────────────────────────────────┘

2013        2015        2017        2019        2021        2024        2026
                                                                    
                                                                    
CSS Modules  CSS-in-JS   Tailwind    Emotion     UnoCSS      Tailwind    混合方案
  (eBay)     (V1)       CSS V1      11          (Anthony    CSS 4.0    成为主流
                                      Fu)                      (Rust)
                                      
                                      
                              React 官方移除
                              CSS-in-JS 推荐

1.2 为什么 CSS-in-JS 引发争议?

1.2.1 React 团队的立场变化
// 2020 年:React 官方博客
// "We recommend CSS-in-JS libraries" - React 官方博客

// 2023 年:React 团队的变化
// React Server Components (RSC) 的出现
// - 运行时样式注入与 SSR 不兼容
// - 需要额外处理 hydration
// - 首屏性能影响显著

// React 团队的建议变化
const reactTeamRecommendation = {
  before: "CSS-in-JS is a great solution",
  now: "Consider CSS Modules or utility-first CSS",
  reason: "RSC compatibility + performance"
};
1.2.2 CSS-in-JS 的核心问题
// 问题 1:运行时开销
// 每次渲染都需要生成样式
const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'gray'};
`;

// 编译后的伪代码
function Button(props) {
  // 每次渲染都会执行
  const className = generateHash('background: blue'); // 运行时计算
  return <button className={className} />;
}

// 问题 2:SSR 不兼容
// 服务端渲染时样式未注入
// 需要使用 extractCritical 等工具
const ssrProblem = {
  server: "HTML without styles",
  client: "Flash of unstyled content (FOUC)",
  solution: "Additional SSR setup required"
};

// 问题 3:Bundle 体积
// 运行时库增加 JS Bundle
const bundleImpact = {
  'styled-components': '~32KB',
  'emotion': '~24KB',
  'total-react-app': '~400KB',
  percentage: '~8% of bundle'
};

1.3 Utility-First 的崛起

1.3.1 核心理念回归
/* 传统 CSS */
.button {
  padding: 10px 20px;
  background: blue;
  color: white;
  border-radius: 4px;
}

/* Utility-First */
<button class="px-5 py-2 bg-blue-500 text-white rounded">
  Button
</button>

/* 理念: */
/* 1. 单一职责:每个类只做一件事 */
/* 2. 组合优于继承:类名组合构建复杂样式 */
/* 3. 约束设计:预定义设计系统 */
1.3.2 为什么 2024-2026 年 Utility-First 主导?
┌─────────────────────────────────────────────────────────────────────────────┐
│                        Utility-First 主导的原因                              │
└─────────────────────────────────────────────────────────────────────────────┘

1. 性能优势
   ├── 构建时生成,无运行时开销
   ├── 原子化 CSS,Bundle 更小
   └── 首屏加载更快

2. 开发效率
   ├── 无需切换文件(样式在 HTML 中)
   ├── 响应式设计原生支持
   └── 重构友好(配置变更全局生效)

3. 生态成熟
   ├── Tailwind CSS 4.0 (Rust 引擎)
   ├── UnoCSS (即时生成,更快)
   └── 完善的设计系统集成

4. 框架无关
   ├── React、Vue、Svelte 都支持
   └── React Native (NativeWind)

2. 核心概念解析

2.1 什么是 CSS-in-JS?

CSS-in-JS 是一种将 CSS 样式作为 JavaScript 对象或字符串来编写,并最终注入到 DOM 的技术方案。

2.1.1 核心特征
// 特征 1:样式定义为 JavaScript
const styles = {
  button: {
    padding: '10px 20px',
    backgroundColor: 'blue',
    color: 'white'
  }
};

// 特征 2:组件与样式绑定
const Button = styled.button`
  background: blue;
  color: white;
`;

// 特征 3:动态样式基于 props
const DynamicButton = styled.button`
  background: ${props => props.variant === 'primary' ? 'blue' : 'gray'};
`;

// 特征 4:主题系统
const ThemedButton = styled.button`
  background: ${props => props.theme.colors.primary};
`;
2.1.2 解决的问题
问题 传统 CSS CSS-in-JS
全局污染 需要 BEM 命名 自动作用域
样式冲突 难以追踪 唯一哈希
动态样式 需要模板字符串 原生支持
死代码 难以移除 摇树优化

2.2 什么是 Utility-First?

Utility-First 是一种使用大量单一功能类(Utility Classes)组合构建界面的方法。

2.2.1 核心特征
// 特征 1:原子化类名
// flex = display: flex
// p-4 = padding: 1rem
// text-center = text-align: center
// rounded-lg = border-radius: 0.5rem

// 特征 2:约束设计系统
const designSystem = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280'
  },
  spacing: {
    1: '0.25rem',
    2: '0.5rem',
    4: '1rem'
  }
};

// 特征 3:响应式变体
// md:flex = @media (min-width: 768px) { .flex { display: flex; } }

// 特征 4:状态变体
// hover:bg-blue-500 = :hover { background: #3b82f6; }
2.2.2 解决的问题
问题 传统 CSS Utility-First
类名命名 需要思考名称 类名即样式
响应式 手动写 media query 前缀变体
设计一致性 需要规范文档 内置设计系统
样式复用 需要 CSS 组合 类名组合

3. Styled Components 深度指南

3.1 基础入门

3.1.1 安装与配置
# 安装
npm install styled-components
# 或
yarn add styled-components

# TypeScript 类型
npm install -D @types/styled-components
3.1.2 第一个组件
import styled from 'styled-components';

// 方法 1:模板字符串(推荐)
const Button = styled.button`
  padding: 10px 20px;
  background-color: blue;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  
  &:hover {
    background-color: darkblue;
  }
`;

// 方法 2:对象语法
const Button2 = styled.button({
  padding: '10px 20px',
  backgroundColor: 'blue',
  color: 'white',
  border: 'none',
  borderRadius: '4px',
  cursor: 'pointer'
});

// 使用
function App() {
  return <Button>Click me</Button>;
}

3.2 进阶用法

3.2.1 扩展样式(Extending Styles)
// 基础按钮
const BaseButton = styled.button`
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
`;

// 扩展:主要按钮
const PrimaryButton = styled(BaseButton)`
  background-color: #3b82f6;
  color: white;
  
  &:hover {
    background-color: #2563eb;
  }
`;

// 扩展:大按钮
const LargeButton = styled(BaseButton)`
  padding: 15px 30px;
  font-size: 18px;
`;

// 使用
function ButtonExamples() {
  return (
    <div>
      <BaseButton>基础</BaseButton>
      <PrimaryButton>主要</PrimaryButton>
      <LargeButton>大号</LargeButton>
    </div>
  );
}
3.2.2 动态属性(Passed Props)
// 接收 props 控制样式
const Button = styled.button<{ $variant?: 'primary' | 'secondary' | 'danger' }>`
  padding: 10px 20px;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  
  /* 基于 props 条件渲染 */
  background-color: ${props => {
    switch (props.$variant) {
      case 'primary': return '#3b82f6';
      case 'danger': return '#ef4444';
      default: return '#6b7280';
    }
  }};
  
  color: ${props => props.$variant === 'secondary' ? '#1f2937' : 'white'};
  
  /* 基于 props 控制显示 */
  opacity: ${props => props.disabled ? 0.5 : 1};
  cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
`;

// 使用
function App() {
  return (
    <div>
      <Button>Default</Button>
      <Button $variant="primary">Primary</Button>
      <Button $variant="danger">Danger</Button>
      <Button disabled>Disabled</Button>
    </div>
  );
}
3.2.3 附加 Props(Attrs)
// 使用 attrs 添加静态属性
const Input = styled.input.attrs({
  type: 'text',
  placeholder: 'Enter text...'
})`
  padding: 10px;
  border: 1px solid #ddd;
`;

// 使用 attrs 添加动态属性
const EmailInput = styled.input.attrs(props => ({
  type: 'email',
  'data-testid': props.$testId,
  ariaLabel: props.$label
}))`
  padding: 10px;
  border: 1px solid #ddd;
`;

// 使用
function App() {
  return (
    <div>
      <Input />
      <EmailInput $testId="email" $label="Email Address" />
    </div>
  );
}

3.3 主题系统(Theming)

3.3.1 ThemeProvider 配置
import { ThemeProvider } from 'styled-components';

// 定义主题类型
interface Theme {
  colors: {
    primary: string;
    secondary: string;
    background: string;
    text: string;
    border: string;
  };
  spacing: {
    sm: string;
    md: string;
    lg: string;
  };
  borderRadius: {
    sm: string;
    md: string;
    lg: string;
  };
}

// 定义主题
const lightTheme: Theme = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280',
    background: '#ffffff',
    text: '#1f2937',
    border: '#e5e7eb'
  },
  spacing: {
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem'
  },
  borderRadius: {
    sm: '4px',
    md: '8px',
    lg: '12px'
  }
};

const darkTheme: Theme = {
  ...lightTheme,
  colors: {
    primary: '#60a5fa',
    secondary: '#9ca3af',
    background: '#1f2937',
    text: '#f9fafb',
    border: '#374151'
  }
};

// 使用主题
const ThemedButton = styled.button`
  background: ${props => props.theme.colors.primary};
  color: ${props => props.theme.colors.background};
  padding: ${props => props.theme.spacing.md};
  border-radius: ${props => props.theme.borderRadius.md};
`;

// App 包装
function App() {
  const [isDark, setIsDark] = useState(false);
  
  return (
    <ThemeProvider theme={isDark ? darkTheme : lightTheme}>
      <ThemedButton>Theme Button</ThThemeProvider>
    </ThemeProvider>
  );
}
3.3.2 使用 useTheme
import { useTheme } from 'styled-components';

function ThemedComponent() {
  const theme = useTheme();
  
  return (
    <div style={{ 
      color: theme.colors.text,
      padding: theme.spacing.lg 
    }}>
      Current theme: {theme.colors.primary}
    </div>
  );
}

3.4 全局样式

import { createGlobalStyle } from 'styled-components';

const GlobalStyle = createGlobalStyle`
  /* 重置样式 */
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
  
  /* 全局样式 */
  html {
    font-size: 16px;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }
  
  body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: ${props => props.theme.colors.background};
    color: ${props => props.theme.colors.text};
    line-height: 1.5;
  }
  
  /* 全局链接样式 */
  a {
    color: ${props => props.theme.colors.primary};
    text-decoration: none;
    
    &:hover {
      text-decoration: underline;
    }
  }
  
  /* 全局按钮样式 */
  button {
    font-family: inherit;
  }
`;

// 使用
function App() {
  return (
    <>
      <GlobalStyle />
      <YourApp />
    </>
  );
}

3.5 样式组合与嵌套

3.5.1 伪类与伪元素
const InteractiveBox = styled.div`
  /* 伪类 */
  &:hover {
    background: blue;
    color: white;
  }
  
  &:focus {
    outline: 2px solid blue;
    outline-offset: 2px;
  }
  
  &:active {
    transform: scale(0.98);
  }
  
  /* 伪元素 */
  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 2px;
    background: blue;
  }
  
  &::after {
    content: ' →';
  }
  
  /* 状态组合 */
  &:hover::after {
    content: ' ←';
  }
`;
3.5.2 嵌套选择器
const Card = styled.article`
  padding: 20px;
  border: 1px solid #ddd;
  
  /* 直接子元素 */
  & > h2 {
    font-size: 1.5rem;
    margin-bottom: 10px;
  }
  
  /* 后代元素 */
  & p {
    color: #666;
    line-height: 1.6;
    
    /* 嵌套伪类 */
    &:first-of-type {
      font-weight: bold;
    }
  }
  
  /* 同一父级下的其他元素 */
  & + & {
    margin-top: 20px;
  }
  
  /* 引用父级 */
  &:hover & {
    border-color: blue;
  }
`;

3.6 动画与关键帧

import { keyframes, css } from 'styled-components';

// 定义动画
const fadeIn = keyframes`
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
`;

const pulse = keyframes`
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
`;

// 使用动画
const AnimatedBox = styled.div`
  animation: ${fadeIn} 0.3s ease-out;
  
  /* 条件动画 */
  ${props => props.$isLoading && css`
    animation: ${pulse} 1.5s ease-in-out infinite;
  `}
`;

// 组合多个动画
const ComplexAnimation = styled.div`
  animation: 
    ${fadeIn} 0.3s ease-out,
    ${pulse} 2s ease-in-out 0.3s;
`;

3.7 CSS 媒体查询

const ResponsiveBox = styled.div`
  /* 默认样式(移动端) */
  width: 100%;
  padding: 10px;
  
  /* 平板 */
  @media (min-width: 768px) {
    width: 50%;
    padding: 20px;
  }
  
  /* 桌面 */
  @media (min-width: 1024px) {
    width: 33.333%;
    padding: 30px;
  }
  
  /* 更大屏幕 */
  @media (min-width: 1440px) {
    max-width: 1200px;
    margin: 0 auto;
  }
`;

3.8 与 React 深度集成

3.8.1 继承 HTML 元素
// 支持所有 HTML 元素
const StyledDiv = styled.div``;
const StyledSpan = styled.span``;
const StyledA = styled.a``;
const StyledInput = styled.input``;
const StyledSelect = styled.select``;
const StyledTextarea = styled.textarea``;

// 自定义元素
const StyledSvg = styled.svg`
  width: 24px;
  height: 24px;
  fill: currentColor;
`;
3.8.2 传递 className
// styled-components 会自动传递 className
// 但如果有其他 className 来源,需要合并

const StyledButton = styled.button`
  padding: 10px 20px;
  
  /* 接收外部 className */
  ${props => props.className && css`
    /* 外部样式会应用 */
  `}
`;

function App() {
  // 外部传入的 className 会自动合并
  return <StyledButton className="external-class">Button</StyledButton>;
}
3.8.3 Ref 转发
import { forwardRef } from 'react';

// styled-components 默认支持 ref
const Input = styled.input`
  padding: 10px;
  border: 1px solid #ddd;
`;

function App() {
  const inputRef = useRef<HTMLInputElement>(null);
  
  return (
    <Input 
      ref={inputRef} 
      placeholder="Focus me"
    />
  );
}

// 自定义 ref 转发
const CustomInput = forwardRef<HTMLInputElement, Props>(
  ({ placeholder }, ref) => (
    <StyledInput ref={ref} placeholder={placeholder} />
  )
);

3.9 SSR 支持

3.9.1 Next.js 中的使用
// pages/_document.js (Pages Router)
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { ServerStyleSheet } from 'styled-components';

export default class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: (App) => (props) =>
            sheet.collectStyles(<App {...props} />),
        });

      const initialProps = await Document.getInitialProps(ctx);
      return {
        ...initialProps,
        styles: (
          <>
            {initialProps.styles}
            {sheet.getStyleElement()}
          </>
        ),
      };
    } finally {
      sheet.seal();
    }
  }

  render() {
    return (
      <Html lang="en">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}
3.9.2 App Router (Next.js 13+)
// 需要使用 Registry 组件
// app/components/Registry.tsx
'use client';

import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());

  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    styledComponentsStyleSheet.instance.clearTag();
    return <>{styles}</>;
  });

  if (typeof window !== 'undefined') {
    return <>{children}</>;
  }

  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  );
}

// app/layout.tsx
import StyledComponentsRegistry from './components/Registry';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
      </body>
    </html>
  );
}

4. Emotion 深度指南

4.1 基础入门

4.1.1 安装
# 完整安装(推荐)
npm install @emotion/react @emotion/styled

# 仅核心
npm install @emotion/react

# 僅 styled API
npm install @emotion/styled
4.1.2 三种使用方式
// 方式 1:css prop(最常用)
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

function App() {
  return (
    <div
      css={css`
        padding: 20px;
        background: blue;
      `}
    >
      Hello
    </div>
  );
}

// 方式 2:styled 组件
import styled from '@emotion/styled';

const Button = styled.button`
  background: blue;
  color: white;
`;

function App() {
  return <Button>Click</Button>;
}

// 方式 3:jsx 函数
import { jsx } from '@emotion/react';

const styles = {
  container: {
    padding: 20,
    background: 'blue'
  }
};

function App() {
  return <div css={styles.container}>Hello</div>;
}

4.2 css prop 详解

4.2.1 基础使用
/** @jsxImportSource @emotion/react */

function BasicExample() {
  return (
    <div
      css={{
        padding: '20px',
        backgroundColor: '#f0f0f0',
        borderRadius: '8px'
      }}
    >
      Content
    </div>
  );
}
4.2.2 嵌套与选择器
function NestedExample() {
  return (
    <div
      css={{
        padding: '20px',
        
        // 嵌套选择器
        '& .title': {
          fontSize: '24px',
          fontWeight: 'bold'
        },
        
        // 伪类
        '&:hover': {
          backgroundColor: 'blue'
        },
        
        // 伪元素
        '&::before': {
          content: '"→"',
          marginRight: '8px'
        },
        
        // 媒体查询
        '@media (min-width: 768px)': {
          padding: '40px'
        }
      }}
    >
      <div className="title">Title</div>
    </div>
  );
}
4.2.3 动画
import { keyframes } from '@emotion/react';

const fadeIn = keyframes`
  from { opacity: 0; }
  to { opacity: 1; }
`;

function AnimatedExample() {
  return (
    <div
      css={{
        animation: `${fadeIn} 0.5s ease-out`,
        
        // 动态值
        animationDuration: '0.3s',
        animationDelay: '0.1s'
      }}
    >
      Animated Content
    </div>
  );
}

4.3 Styled Components 详解

4.3.1 基础语法
import styled from '@emotion/styled';

// 模板字符串语法
const Button = styled.button`
  padding: 10px 20px;
  background: blue;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
`;

// 对象语法
const Button2 = styled.button({
  padding: '10px 20px',
  background: 'blue',
  color: 'white',
  border: 'none',
  borderRadius: '4px',
  cursor: 'pointer'
});
4.3.2 动态 Props
interface ButtonProps {
  $variant?: 'primary' | 'secondary' | 'danger';
  $size?: 'sm' | 'md' | 'lg';
}

const StyledButton = styled.button<ButtonProps>`
  padding: ${props => {
    switch (props.$size) {
      case 'sm': return '5px 10px';
      case 'lg': return '15px 30px';
      default: return '10px 20px';
    }
  }};
  
  background: ${props => {
    switch (props.$variant) {
      case 'danger': return '#ef4444';
      case 'secondary': return '#6b7280';
      default: return '#3b82f6';
    }
  }};
  
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;

function App() {
  return (
    <>
      <StyledButton>Default</StyledButton>
      <StyledButton $variant="primary">Primary</StyledButton>
      <StyledButton $size="lg">Large</StyledButton>
      <StyledButton disabled>Disabled</StyledButton>
    </>
  );
}
4.3.3 继承与扩展
// 基础样式
const BaseButton = styled.button`
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
`;

// 扩展样式
const PrimaryButton = styled(BaseButton)`
  background: #3b82f6;
  color: white;
  
  &:hover {
    background: #2563eb;
  }
`;

// 使用 as 切换基础元素
const LinkButton = styled(BaseButton)`
  background: transparent;
  color: #3b82f6;
  
  &:hover {
    text-decoration: underline;
  }
`;

function App() {
  return (
    <>
      <PrimaryButton as="a" href="/submit">As Link</PrimaryButton>
      <LinkButton>Actual Link</LinkButton>
    </>
  );
}

4.4 样式组合

4.4.1 css 标签
import { css } from '@emotion/react';

const baseStyles = css`
  padding: 10px 20px;
  border-radius: 4px;
`;

const primaryStyles = css`
  background: blue;
  color: white;
`;

function Component() {
  return (
    <div css={[baseStyles, primaryStyles]}>
      Combined Styles
    </div>
  );
}
4.4.2 条件样式
function ConditionalStyles({ isActive, isPrimary }) {
  return (
    <div
      css={[
        css`
          padding: 10px 20px;
          border-radius: 4px;
        `,
        isPrimary && css`
          background: blue;
          color: white;
        `,
        isActive && css`
          border: 2px solid blue;
        `
      ]}
    >
      Content
    </div>
  );
}

4.5 主题系统

4.5.1 ThemeProvider
import { ThemeProvider } from '@emotion/react';

interface Theme {
  colors: {
    primary: string;
    secondary: string;
    background: string;
  };
}

const theme: Theme = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280',
    background: '#ffffff'
  }
};

function App() {
  return (
    <ThemeProvider theme={theme}>
      <ThemedComponent />
    </ThemeProvider>
  );
}
4.5.2 使用主题
import { useTheme } from '@emotion/react';

function ThemedComponent() {
  const theme = useTheme();
  
  return (
    <div
      css={{
        background: theme.colors.background,
        color: theme.colors.primary
      }}
    >
      Themed Content
    </div>
  );
}

// 在 styled 中使用
const ThemedButton = styled.button`
  background: ${props => props.theme.colors.primary};
  color: ${props => props.theme.colors.background};
`;

4.6 全局样式

import { Global, css } from '@emotion/react';

function GlobalStyles() {
  return (
    <Global
      styles={css`
        * {
          box-sizing: border-box;
          margin: 0;
          padding: 0;
        }
        
        body {
          font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
          line-height: 1.5;
        }
        
        a {
          color: inherit;
          text-decoration: none;
        }
        
        button {
          font-family: inherit;
          cursor: pointer;
        }
      `}
    />
  );
}

function App() {
  return (
    <>
      <GlobalStyles />
      <YourApp />
    </>
  );
}

4.7 关键优化技巧

4.7.1 静态样式提取
// ❌ 性能问题:每次渲染都创建新对象
function BadExample({ isPrimary }) {
  return (
    <div
      css={{
        padding: '10px',
        background: isPrimary ? 'blue' : 'gray', // 动态部分
        color: 'white' // 静态部分
      }}
    />
  );
}

// ✅ 优化:分离静态和动态样式
const staticStyles = css`
  padding: 10px;
  color: white;
`;

function GoodExample({ isPrimary }) {
  return (
    <div
      css={[
        staticStyles,
        isPrimary ? css`background: blue;` : css`background: gray;`
      ]}
    />
  );
}
4.7.2 useMemo 缓存
import { useMemo } from 'react';

function OptimizedComponent({ variant, size }) {
  const styles = useMemo(() => css`
    padding: ${size === 'lg' ? '20px' : '10px'};
    background: ${variant === 'primary' ? 'blue' : 'gray'};
  `, [variant, size]);
  
  return <div css={styles}>Content</div>;
}

4.8 SSR 支持

4.8.1 extractCritical
import { renderToString } from 'react-dom/server';
import { extractCritical } from '@emotion/server';

function renderToHTML(element) {
  const html = renderToString(element);
  const { ids, css } = extractCritical(html);
  
  return {
    html,
    css,
    ids // 用于 hydration
  };
}
4.8.2 Next.js App Router
// lib/EmotionCache.tsx
'use client';

import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation';
import { CacheProvider } from '@emotion/react';
import React, { useState } from 'react';

export default function EmotionCacheProvider({ children }) {
  const [cache] = useState(() => createCache({ key: 'css' }));

  useServerInsertedHTML(() => {
    const names = Object.keys(cache.inserted);
    let i = names.length;
    let css = '';
    while (i--) {
      const name = names[i];
      css += cache.inserted[name];
    }
    return (
      <style
        key={cache.key}
        data-emotion={`${cache.key} ${names.join(' ')}`}
        dangerouslySetInnerHTML={{
          __html: css,
        }}
      />
    );
  });

  return <CacheProvider value={cache}>{children}</CacheProvider>;
}

4.9 Styled Components vs Emotion 对比

┌─────────────────────────────────────────────────────────────────────────────┐
│                    Styled Components vs Emotion                             │
├───────────────────────────────────────┬─────────────────────────────────────┤
│            Styled Components          │              Emotion                │
├───────────────────────────────────────┼─────────────────────────────────────┤
│ 模板字符串为主要 API                   │ 三种方式:css prop、styled、jsx      │
│ 自动生成类名                          │ 需要手动处理类名                     │
│ React Native 支持                     │ 更轻量,性能更好                    │
│ 更成熟的 SSR 支持                      │ 更灵活的样式组合                    │
│ API 简洁直观                          │ 学习曲线稍陡,但更灵活               │
│                                      │                                     │
│ 适用:喜欢模板字符串语法                │ 适用:需要极致性能                   │
│      React Native 项目                │      需要 css prop 便利性            │
└───────────────────────────────────────┴─────────────────────────────────────┘

5. 架构设计深度对比

5.1 渲染流程对比

┌─────────────────────────────────────────────────────────────────────────────┐
│                          CSS-in-JS 渲染流程                                  │
└─────────────────────────────────────────────────────────────────────────────┘

Styled Components:

[组件定义]
     │
     ▼
[模板解析] ──→ 生成哈希类名
     │
     ▼
[React 渲染] ──→ createElement()
     │
     ▼
[生成 <style>] ──→ 注入到 DOM
     │
     ▼
[浏览器解析] ──→ 样式应用


输出示例:
<style>
  .Button-sc-1a2b3c { background: blue; }
</style>
<button class="Button-sc-1a2b3c">Click</button>


─────────────────────────────────────────────────────────────────────────────

Emotion (@emotion/react):

[组件渲染]
     │
     ▼
[css prop 处理]
     │
     ▼
[样式序列化]
     │
     ▼
[生成 <style>] ──→ 带缓存
     │
     ▼
[浏览器解析]


─────────────────────────────────────────────────────────────────────────────

Utility-First (Tailwind CSS):

[源代码编写]
     │
     ▼
[构建阶段扫描] ──→ 提取 class 属性
     │
     ▼
[JIT 编译] ──→ 匹配工具类
     │
     ▼
[生成 CSS] ──→ 原子化输出
     │
     ▼
[打包到 CSS 文件]


输出示例:
.bg-blue-500 { --tw-bg-opacity: 1; background-color: rgb(59 130 246); }
<button class="bg-blue-500">Click</button>

5.2 样式隔离机制对比

机制 Styled Components Emotion Tailwind CSS
隔离方式 哈希类名 哈希类名 原子类名
全局污染
动态样式 运行时生成 运行时生成 类名组合
清理机制 组件卸载时 组件卸载时 无需清理
SSR 兼容 需要配置 需要配置 原生支持

5.3 动态样式能力对比

场景 Styled Components Emotion Tailwind CSS
props 驱动 ✅ 原生支持 ✅ 原生支持 ⚠️ 需条件类名
主题系统 ✅ 完整 ✅ 完整 ✅ 完整
CSS 变量 ✅ 支持 ✅ 支持 ✅ 支持
计算样式 ✅ 支持 ✅ 支持 ⚠️ 受限

6. 性能基准测试

6.1 测试场景

测试项目:
  组件数量: 500
  样式规则: 平均 15 条/组件
  动态样式: 30% 组件有 props 样式
  测试框架: React 18 + Vite 5
  渲染次数: 1000 次状态更新

6.2 开发环境性能

首次加载时间
┌─────────────────────────────────────────────────────────────────────────────┐
│                         首次加载时间 (ms)                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  @emotion/react                                                           │
│  ████████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░  165ms  │
│                                                                            │
│  @emotion/styled                                                         │
│  ██████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░  155ms  │
│                                                                            │
│  styled-components                                                        │
│  ██████████████████████████████████████████████░░░░░░░░░░░░░░░░░░  185ms  │
│                                                                            │
│  Tailwind CSS                                                            │
│  ████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  85ms   │
│                                                                            │
│  UnoCSS                                                                  │
│  ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  65ms   │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘
内存占用
┌─────────────────────────────────────────────────────────────────────────────┐
│                         运行时内存占用 (MB)                                  │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  styled-components                                                        │
│  ████████████████████████████████████████████████████████░░░░░  52MB     │
│                                                                            │
│  @emotion/react                                                           │
│  ██████████████████████████████████████████████░░░░░░░░░░░░░░░  42MB     │
│                                                                            │
│  @emotion/styled                                                          │
│  ████████████████████████████████████████████░░░░░░░░░░░░░░░░░░  38MB    │
│                                                                            │
│  Tailwind CSS                                                             │
│  ██████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  18MB     │
│                                                                            │
│  UnoCSS                                                                  │
│  ███████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  14MB    │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘

6.3 状态更新性能

1000 次渲染耗时
┌─────────────────────────────────────────────────────────────────────────────┐
│                         渲染耗时 (ms)                                        │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  styled-components                                                        │
│  ██████████████████████████████████████████████████████████████  380ms    │
│                                                                            │
│  @emotion/react                                                           │
│  ██████████████████████████████████████████████████████████░░░░░░░  345ms   │
│                                                                            │
│  @emotion/styled                                                          │
│  ████████████████████████████████████████████████████████░░░░░░░░  320ms    │
│                                                                            │
│  Tailwind CSS                                                             │
│  ████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░  145ms    │
│                                                                            │
│  UnoCSS                                                                  │
│  ██████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  120ms    │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘

6.4 生产构建对比

Bundle 大小
┌─────────────────────────────────────────────────────────────────────────────┐
│                         JS Bundle 增加 (KB)                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  styled-components                                                        │
│  ████████████████████████████████████████████████████████░░░░  +32KB     │
│                                                                            │
│  @emotion/react                                                           │
│  ██████████████████████████████████████████████░░░░░░░░░░░░░░░░  +18KB    │
│                                                                            │
│  @emotion/styled                                                          │
│  █████████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░  +14KB    │
│                                                                            │
│  Tailwind CSS                                                             │
│  ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   +2KB     │
│                                                                            │
│  UnoCSS                                                                  │
│  ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   +1KB     │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘
CSS 输出大小
┌─────────────────────────────────────────────────────────────────────────────┐
│                         CSS 输出大小 (KB, 500 组件)                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  styled-components                                                        │
│  ██████████████████████████████████████████████████████████████  156KB   │
│                                                                            │
│  @emotion/react                                                           │
│  ██████████████████████████████████████████████████████████░░░░░░░░  138KB  │
│                                                                            │
│  @emotion/styled                                                          │
│  ████████████████████████████████████████████████████████░░░░░░░░░░  128KB  │
│                                                                            │
│  Tailwind CSS (JIT)                                                       │
│  ████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░   85KB   │
│                                                                            │
│  UnoCSS                                                                   │
│  ██████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   52KB   │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘

7. 开发体验详解

7.1 代码组织对比

CSS-in-JS 模式
// 优点:样式与组件共存,易于查找
// 缺点:组件文件可能变长

// Button.tsx
const Button = styled.button`
  padding: 10px 20px;
  background: blue;
  color: white;
`;

const IconButton = styled(Button)`
  padding: 8px;
  border-radius: 50%;
`;

export function ButtonGroup() {
  return (
    <div>
      <Button>Save</Button>
      <IconButton>🔔</IconButton>
    </div>
  );
}
Utility-First 模式
// 优点:HTML 即视图,样式自解释
// 缺点:类名可能很长

// ButtonGroup.tsx
function ButtonGroup() {
  return (
    <div className="flex gap-2">
      <button className="px-5 py-2 bg-blue-500 text-white rounded">
        Save
      </button>
      <button className="p-2 bg-blue-500 text-white rounded-full">
        🔔
      </button>
    </div>
  );
}

7.2 类型安全对比

CSS-in-JS(完整类型推断)
// Styled Components
import styled, { CSSProperties, Theme } from 'styled-components';

interface Props {
  $variant: 'primary' | 'secondary';
  $size: 'sm' | 'md' | 'lg';
}

// 完整类型推断
const Button = styled.button<Props>`
  padding: ${p => p.$size === 'lg' ? '16px 32px' : '8px 16px'};
  background: ${p => p.$variant === 'primary' ? 'blue' : 'gray'};
  
  &:hover {
    background: ${p => p.$variant === 'primary' ? 'darkblue' : 'darkgray'};
  }
`;

// 使用时自动推断
<Button $variant="primary" $size="md" /> // ✅
<Button $variant="invalid" $size="md" /> // ❌ TypeScript 报错
Utility-First(类型辅助)
// 需要使用辅助库
import { cva, type VariantProps } from 'class-variance-authority';

const buttonVariants = cva(
  'rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        primary: 'bg-blue-500 text-white hover:bg-blue-600',
        secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
      },
      size: {
        sm: 'px-3 py-1.5 text-sm',
        md: 'px-4 py-2',
        lg: 'px-6 py-3 text-lg',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
);

// 类型推断
type ButtonProps = VariantProps<typeof buttonVariants>;

function Button({ variant, size, className, ...props }: ButtonProps) {
  return (
    <button 
      className={buttonVariants({ variant, size, className })} 
      {...props} 
    />
  );
}

7.3 重构体验对比

样式变更场景
场景:将所有主要按钮从蓝色改为绿色

CSS-in-JS:
├── 需要逐个文件修改
├── 搜索: styled.button`background: blue
├── 替换: background: green
└── 风险: 可能误改其他样式

Utility-First:
├── 修改 tailwind.config.js
├── theme: { colors: { primary: 'green-500' } }
└── 自动全局生效

7.4 调试体验对比

浏览器 DevTools
CSS-in-JS:
├── .sc-Button-abc123 { background: blue; }
├── React DevTools 显示组件树
├── styled-components 插件显示样式
└── 缺点: 哈希类名不易理解

Utility-First:
├── .bg-blue-500 { background: #3b82f6; }
├── 类名即样式含义
├── Tailwind DevTools 插件
└── 优点: 自解释类名

8. 实战案例

8.1 案例:带表单验证的登录页

需求
  • 邮箱/密码输入框
  • 实时验证
  • 错误提示
  • 加载状态
  • 暗色/亮色主题
Styled Components 实现
import styled, { css, keyframes } from 'styled-components';

const spin = keyframes`
  to { transform: rotate(360deg); }
`;

const Container = styled.div`
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: ${props => props.theme.colors.background};
  padding: 20px;
`;

const Form = styled.form`
  width: 100%;
  max-width: 400px;
  padding: 40px;
  background: ${props => props.theme.colors.card};
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
`;

const Title = styled.h1`
  font-size: 24px;
  font-weight: 600;
  color: ${props => props.theme.colors.text};
  margin-bottom: 24px;
  text-align: center;
`;

const InputGroup = styled.div`
  margin-bottom: 16px;
`;

const Label = styled.label`
  display: block;
  font-size: 14px;
  font-weight: 500;
  color: ${props => props.theme.colors.text};
  margin-bottom: 6px;
`;

const Input = styled.input<{ $hasError?: boolean }>`
  width: 100%;
  padding: 12px 16px;
  font-size: 16px;
  border: 2px solid ${props => 
    props.$hasError ? '#ef4444' : props.theme.colors.border
  };
  border-radius: 8px;
  outline: none;
  transition: border-color 0.2s;
  
  &:focus {
    border-color: ${props => 
      props.$hasError ? '#ef4444' : props.theme.colors.primary
    };
  }
  
  &::placeholder {
    color: ${props => props.theme.colors.placeholder};
  }
`;

const ErrorMessage = styled.span`
  display: block;
  font-size: 12px;
  color: #ef4444;
  margin-top: 4px;
`;

const SubmitButton = styled.button<{ $isLoading?: boolean }>`
  width: 100%;
  padding: 14px;
  font-size: 16px;
  font-weight: 600;
  color: white;
  background: ${props => props.theme.colors.primary};
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
  position: relative;
  
  &:hover:not(:disabled) {
    background: ${props => props.theme.colors.primaryHover};
    transform: translateY(-1px);
  }
  
  &:disabled {
    opacity: 0.7;
    cursor: not-allowed;
  }
  
  ${props => props.$isLoading && css`
    color: transparent;
    
    &::after {
      content: '';
      position: absolute;
      width: 20px;
      height: 20px;
      top: 50%;
      left: 50%;
      margin-left: -10px;
      margin-top: -10px;
      border: 2px solid white;
      border-right-color: transparent;
      border-radius: 50%;
      animation: ${spin} 0.8s linear infinite;
    }
  `}
`;

// 主题
const theme = {
  colors: {
    background: '#f5f5f5',
    card: '#ffffff',
    text: '#1f2937',
    primary: '#3b82f6',
    primaryHover: '#2563eb',
    border: '#d1d5db',
    placeholder: '#9ca3af'
  }
};

// 组件
function LoginFormStyled() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{email?: string; password?: string}>({});
  const [isLoading, setIsLoading] = useState(false);
  
  const validate = () => {
    const newErrors: typeof errors = {};
    if (!email.includes('@')) {
      newErrors.email = '请输入有效的邮箱地址';
    }
    if (password.length < 6) {
      newErrors.password = '密码至少需要 6 位';
    }
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validate()) return;
    
    setIsLoading(true);
    // 模拟 API 调用
    await new Promise(resolve => setTimeout(resolve, 1500));
    setIsLoading(false);
  };
  
  return (
    <ThemeProvider theme={theme}>
      <Container>
        <Form onSubmit={handleSubmit}>
          <Title>登录</Title>
          
          <InputGroup>
            <Label>邮箱</Label>
            <Input
              type="email"
              placeholder="your@email.com"
              value={email}
              onChange={e => setEmail(e.target.value)}
              $hasError={!!errors.email}
            />
            {errors.email && <ErrorMessage>{errors.email}</ErrorMessage>}
          </InputGroup>
          
          <InputGroup>
            <Label>密码</Label>
            <Input
              type="password"
              placeholder="••••••••"
              value={password}
              onChange={e => setPassword(e.target.value)}
              $hasError={!!errors.password}
            />
            {errors.password && <ErrorMessage>{errors.password}</ErrorMessage>}
          </InputGroup>
          
          <SubmitButton type="submit" $isLoading={isLoading}>
            {isLoading ? '' : '登录'}
          </SubmitButton>
        </Form>
      </Container>
    </ThemeProvider>
  );
}
Tailwind CSS + clsx 实现
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

function cn(...inputs: (string | undefined | null | false)[]) {
  return twMerge(clsx(inputs));
}

function LoginFormTailwind() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{email?: string; password?: string}>({});
  const [isLoading, setIsLoading] = useState(false);
  
  const validate = () => {
    const newErrors: typeof errors = {};
    if (!email.includes('@')) {
      newErrors.email = '请输入有效的邮箱地址';
    }
    if (password.length < 6) {
      newErrors.password = '密码至少需要 6 位';
    }
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validate()) return;
    
    setIsLoading(true);
    await new Promise(resolve => setTimeout(resolve, 1500));
    setIsLoading(false);
  };
  
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 p-5">
      <form 
        onSubmit={handleSubmit}
        className="w-full max-w-md p-10 bg-white rounded-xl shadow-lg"
      >
        <h1 className="text-2xl font-semibold text-gray-900 mb-6 text-center">
          登录
        </h1>
        
        <div className="mb-4">
          <label className="block text-sm font-medium text-gray-700 mb-1.5">
            邮箱
          </label>
          <input
            type="email"
            placeholder="your@email.com"
            value={email}
            onChange={e => setEmail(e.target.value)}
            className={clsx(
              "w-full px-4 py-3 text-base border-2 rounded-lg outline-none transition-colors",
              "placeholder:text-gray-400",
              errors.email 
                ? "border-red-500 focus:border-red-500" 
                : "border-gray-300 focus:border-blue-500"
            )}
          />
          {errors.email && (
            <span className="block text-xs text-red-500 mt-1">
              {errors.email}
            </span>
          )}
        </div>
        
        <div className="mb-6">
          <label className="block text-sm font-medium text-gray-700 mb-1.5">
            密码
          </label>
          <input
            type="password"
            placeholder="••••••••"
            value={password}
            onChange={e => setPassword(e.target.value)}
            className={clsx(
              "w-full px-4 py-3 text-base border-2 rounded-lg outline-none transition-colors",
              "placeholder:text-gray-400",
              errors.password 
                ? "border-red-500 focus:border-red-500" 
                : "border-gray-300 focus:border-blue-500"
            )}
          />
          {errors.password && (
            <span className="block text-xs text-red-500 mt-1">
              {errors.password}
            </span>
          )}
        </div>
        
        <button
          type="submit"
          disabled={isLoading}
          className={clsx(
            "w-full py-3.5 text-base font-semibold text-white rounded-lg",
            "transition-all duration-200",
            "hover:-translate-y-0.5 hover:shadow-lg",
            "disabled:opacity-70 disabled:cursor-not-allowed",
            isLoading && "relative text-transparent"
          )}
          style={{ background: '#3b82f6' }}
        >
          {isLoading ? '' : '登录'}
          {isLoading && (
            <span className="absolute inset-0 flex items-center justify-center">
              <span className="w-5 h-5 border-2 border-white border-r-transparent rounded-full animate-spin" />
            </span>
          )}
        </button>
      </form>
    </div>
  );
}

8.2 代码量对比

指标 Styled Components Tailwind CSS
总行数 142 行 78 行
组件文件 1 个 1 个
样式定义 内联 类名
可读性 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
可维护性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐

9. 迁移与混用策略

9.1 从 CSS-in-JS 迁移到 Utility-First

渐进式迁移策略
// 阶段 1:新组件使用 Utility-First,旧组件保持不变
function NewComponent() {
  return <div className="p-4 bg-white">新组件</div>;
}

const OldComponent = styled.div`
  padding: 1rem;
  background: white;
`;

// 阶段 2:共存
function Page() {
  return (
    <OldComponent>
      <NewComponent />
    </OldComponent>
  );
}

// 阶段 3:逐步重写旧组件
样式映射表
┌─────────────────────────────────────────────────────────────────────────────┐
                         样式转换对照表                                       
├─────────────────────────────────────┬─────────────────────────────────────┤
 styled-components                    Tailwind CSS                        
├─────────────────────────────────────┼─────────────────────────────────────┤
 display: flex                        flex                                
 display: grid                        grid                                
 flex-direction: column              flex-col                            
 align-items: center                 items-center                        
 justify-content: space-between      justify-between                    
 padding: 16px                       p-4                                 
 padding-top: 16px                   pt-4                                
 margin: 16px                        m-4                                 
 margin-bottom: 16px                 mb-4                                
 color: #fff                        │ text-white                          │
 background: #000                   │ bg-black                            │
 border-radius: 4px                 rounded                             
 font-size: 16px                    text-base (1rem)                   
 font-weight: 700                   font-bold                           
 width: 100%                         w-full                              
 height: 100%                        h-full                              
 box-shadow: 0 1px 3px rgba(0,0,0,0.1)  shadow-sm                          
 &:hover { ... }                    hover:...                           
 @media (min-width: 768px) { ... }  md:...                              
└─────────────────────────────────────┴─────────────────────────────────────┘

9.2 混合使用模式

场景:组件库 + 项目样式
// 使用 UI 库(可能使用 styled-components)
import { Button as AntButton } from 'antd';

// 项目样式使用 Tailwind
function MyPage() {
  return (
    <div className="p-4">
      <AntButton type="primary">库组件</AntButton>
      <button className="ml-4 px-4 py-2 bg-blue-500">
        项目按钮
      </button>
    </div>
  );
}
场景:Tailwind + CSS-in-JS 动画
import styled from 'styled-components';
import { keyframes } from 'styled-components';

// 使用 styled-components 处理复杂动画
const fadeIn = keyframes`
  from { opacity: 0; transform: scale(0.9); }
  to { opacity: 1; transform: scale(1); }
`;

const AnimatedContainer = styled.div`
  animation: ${fadeIn} 0.3s ease-out;
`;

// Tailwind 处理布局
function Modal() {
  return (
    <AnimatedContainer className="fixed inset-0 flex items-center justify-center bg-black/50">
      <div className="bg-white rounded-lg p-6 max-w-md">
        Modal Content
      </div>
    </AnimatedContainer>
  );
}

10. 常见问题与解决方案

10.1 CSS-in-JS 常见问题

Q1: 样式闪烁(FOUC)
// 问题:SSR 时页面闪烁
// 解决:使用 extractCritical 或 styled-components 的 SSR 支持

// Next.js App Router
import StyledComponentsRegistry from './lib/registry';

export default function Layout({ children }) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
      </body>
    </html>
  );
}
Q2: 样式不生效
// 问题:props 样式未应用
// 解决:确保使用正确的 prop 名称

// ❌ 错误
const Button = styled.button`
  background: ${props.variant}; // 缺少 theme 或正确引用
`;

// ✅ 正确
const Button = styled.button`
  background: ${props => props.$variant}; // 使用 transient props
  background: ${props => props.theme.colors.primary}; // 使用主题
`;
Q3: 性能问题
// 问题:大量动态样式导致性能下降
// 解决:提取静态样式

// ❌ 每次渲染都创建对象
const Bad = styled.div`
  padding: 10px;
  background: ${props => props.$color}; // 动态
`;

// ✅ 分离静态和动态
const StaticStyles = styled.css`
  padding: 10px;
`;

const Good = styled.div`
  ${StaticStyles}
  background: ${props => props.$color};
`;

10.2 Utility-First 常见问题

Q1: 类名过长
// 问题:复杂组件类名太多
// 解决:使用 shortcuts 或组件封装

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      shortcuts: {
        'card': 'bg-white rounded-lg shadow-sm p-6',
        'btn-primary': 'px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600',
        'input-base': 'w-full px-4 py-2 border border-gray-300 rounded focus:ring-2',
      }
    }
  }
};

// 使用
function Component() {
  return (
    <div className="card">
      <button className="btn-primary">Click</button>
    </div>
  );
}
Q2: 任意值写法
// 问题:需要使用非预设值
// 解决:使用方括号语法

// 任意颜色
<div className="bg-[#123456]">任意颜色</div>

// 任意数值
<div className="w-[123px] h-[calc(100vh-2rem)]">任意值</div>

// 任意属性
<div className="[--color:red]">CSS 变量</div>
Q3: 深层选择器
// 问题:需要复杂选择器
// 解决:使用 [&] 任意选择器

// & = 当前元素
<div className="[&]:p-4">当前元素</div>

// 嵌套
<div className="[&_p]:font-bold [&_p+&_p]:mt-2">
  <p>子元素</p>
</div>

// 伪类组合
<div className="[&:hover>span]:opacity-100">
  <span>悬停显示</span>
</div>

11. 总结与选型建议

11.1 方案对比矩阵

维度 Styled Components Emotion Tailwind CSS UnoCSS
性能 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
易用性 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
灵活性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
类型安全 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
SSR ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
学习曲线 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
生态 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
未来趋势 ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

11.2 选型决策

┌─────────────────────────────────────────────────────────────────────────────┐
│                            选型决策树                                        │
└─────────────────────────────────────────────────────────────────────────────┘

开始
  │
  ├─► 项目类型?
  │    │
  │    ├─► React Native → styled-components ✅
  │    │
  │    ├─► 新 Web 项目 → Tailwind CSS / UnoCSS ✅
  │    │
  │    └─► Vue 项目 → Tailwind CSS / UnoCSS ✅
  │
  ├─► 团队背景?
  │    │
  │    ├─► 熟悉 JS/React → CSS-in-JS 或 Utility-First 都可
  │    │
  │    └─► 熟悉 CSS → Utility-First 更容易上手
  │
  ├─► 性能要求?
  │    │
  │    ├─► 极致性能 → Utility-First
  │    │
  │    └─► 一般性能 → 都可以
  │
  └─► 项目规模?
       │
       ├─► 小型 → 任意方案
       │
       ├─► 中大型 → Utility-First + 组件库
       │
       └─► 遗留项目 → 渐进式迁移

11.3 2026 年建议

┌─────────────────────────────────────────────────────────────────────────────┐
│                          2026 年技术建议                                     │
└─────────────────────────────────────────────────────────────────────────────┘

✅ 推荐选择:
│
├── 新 Web 项目 → Tailwind CSS 4.0
│   ├── 理由:性能最佳、生态成熟、Rust 引擎
│   └── 适用:ReactVueSvelte
│
├── 需要 React Nativestyled-components
│   ├── 理由:唯一全面支持 RN 的方案
│   └── 注意:考虑逐步迁移
│
├── 遗留项目 → 渐进式迁移
│   ├── 新组件:Utility-First
│   └── 旧组件:保持不变,逐步重写
│
└── 追求极致性能 → UnoCSS
    ├── 理由:即时生成、最小 Bundle
    └── 适用:大型项目、高性能需求

⚠️ 谨慎选择:
│
├── 新项目使用 CSS-in-JS
│   ├── React 官方不推荐
│   └── 维护风险增加
│
└── 纯 CSS-in-JS 方案
    └── 考虑混合方案

🔄 趋势观察:
│
├── Tailwind CSS 4.0 (Rust) 将成为主流
├── UnoCSS 生态快速发展
├── CSS 原生特性 (@layer, CSS 变量) 减少框架依赖
└── 混合方案(Utility-First + 组件库)成为常态

11.4 行动清单

## 开始使用 Utility-First

1. [ ] 安装 Tailwind CSS 或 UnoCSS
2. [ ] 配置设计系统(颜色、间距、字体)
3. [ ] 团队学习基础工具类
4. [ ] 使用 cva/class-variance-authority 管理变体
5. [ ] 使用 tailwind-merge 处理类名合并

## 从 CSS-in-JS 迁移

1. [ ] 评估当前样式复杂度
2. [ ] 新组件使用 Utility-First
3. [ ] 旧组件逐步重写
4. [ ] 移除不必要的 CSS-in-JS 依赖
5. [ ] 统一代码规范

📚 延伸阅读


CSS、Tailwind CSS、UnoCSS篇结束

彻底讲透医院移动端手持设备PDA离线同步架构:从"记账本"到"分布式共识",吊打面试官

2026年2月24日 17:12

一套解决"手术室铅门屏蔽导致WiFi掉线"的工业级方案,如何从生活常识进化成分布式系统理论?


第一层:幼儿园版 —— 为什么要有这个算法?

想象一下,你是一个在手术室工作的护士。

场景还原

  • 你拿着一个PDA(像一个大手机)给病人做登记
  • 手术室的铅门像一个大铁盖子,WiFi信号根本穿不进来
  • 电梯里、地下室、病区走廊,网络时有时无

问题来了
如果你每次点“保存”都要等网络响应,那在信号差的地方,APP就会一直转圈圈,甚至闪退。病人等着做手术,你却在和机器怄气。

最朴素的想法
能不能不管有没有网,我先记下来?等有网的时候,手机自己悄悄传上去,别让我操心。

这就是算法的原点本地优先(Offline-First) ——网络只是用来同步的工具,不是工作的前提。


第二层:小学生版 —— 用“草稿本”和“作业本”理解

我们把整个过程简化成小学生写作业的场景。

传统模式(在线模式)

  • 老师(服务器)说:“写作业必须在我眼皮底下写”
  • 你(客户端)只能对着老师写,老师一转身(断网),你就写不了
  • 这就是“在线API”的困境

本地优先模式

第一步:准备草稿本(本地数据库)
你随身带一个草稿本(手机里的SQLite数据库)。不管老师在不在,你先在草稿本上写。

第二步:给作业打标签
你在每道题旁边画个小标记:

  • 已写完(已保存到本地)
  • 老师还没看(待同步)
  • 这是修改过的(操作类型)

第三步:抄作业机制(同步逻辑)
网络好了,你开始往老师的正式作业本上抄:

  • 先抄新写的(增量同步)
  • 抄到一半断网了,记住抄到哪了(断点续传)
  • 下次联网接着抄

第四步:两人同时改作业怎么办(冲突解决)
如果两个同学同时改了同一道题:

  • 简单处理:谁最后改的听谁的(时间戳优先)
  • 高级处理:A改了第一问,B改了第二问,合并起来(字段级合并)

核心口诀先写草稿,有空再抄,抄不完的记位置,打架了看情况合并。


第三层:初中生版 —— 数据结构的雏形

现在我们要把草稿本设计得更科学一些。

3.1 普通笔记本的局限

如果只是简单存数据,会碰到几个问题:

  1. 我怎么知道哪些数据已经同步过了?
  2. 数据被改了好几次,只记最后的结果够吗?
  3. 每次同步要把整个本子都给老师看吗?太费劲了。

3.2 给数据加“贴纸”

我们在数据库的每一行数据后面,贴上几个隐藏标签:

字段名 含义 取值
sync_status 同步状态 0-未同步,1-同步中,2-已同步
op_type 操作类型 INSERT/UPDATE/DELETE
version 版本号 时间戳或自增数字

这样设计的好处

  • 一眼就能看出哪些数据还没上传
  • 知道这条数据是新增的、修改的还是删除的
  • 版本号可以用来比对谁更新

3.3 增量同步的雏形

不用每次都把所有数据传给服务器。客户端记住自己最后一次同步的版本号(last_sync_version),下次只问服务器:

“上次同步到版本100了,你这有版本101之后的新数据吗?”

这就是增量步进机制的雏形。


第四层:高中生版 —— 引入“流水账”思维

到了高中,我们要解决一个更复杂的问题:操作日志(Op-Log)

4.1 只记结果的问题

假设你修改了一条数据3次:

  1. 体温36.5 → 37.0
  2. 体温37.0 → 37.5
  3. 体温37.5 → 36.8

如果只存最后的结果(36.8),服务器永远不知道中间发生了什么。这在某些场景下是不行的(比如医疗审计需要完整轨迹)。

4.2 引入“流水账”

我们不再只关心数据长什么样,而是关心数据是怎么变的。

新建一个操作日志表,记录:

时间 操作人 对象 字段 旧值 新值
10:01 护士A 患者X 体温 36.5 37.0
10:05 护士A 患者X 体温 37.0 37.5
10:10 护士B 患者X 血压 120 130

这个设计的神奇之处

  • 网络断了也不怕,流水账存在本地
  • 恢复联网后,按顺序重放(Replay)这些操作
  • 即使服务器数据乱了,也能通过重放恢复到正确状态
  • 可以追溯每一个操作的源头

4.3 触发器自动记账

手动记录太麻烦。我们让数据库自己记:

-- 创建触发器:当体温表被修改时,自动往日志表插一条记录
CREATE TRIGGER log_temperature_changes
AFTER UPDATE ON patient_vitals
FOR EACH ROW
BEGIN
    INSERT INTO sync_log (record_id, field_name, old_value, new_value, op_time)
    VALUES (NEW.id, 'temperature', OLD.temperature, NEW.temperature, NOW());
END;

这就是数据操作溯源的核心思想。


第五层:大学本科版 —— 完整同步协议设计

现在我们要设计一套完整的同步协议,包含握手、传输、确认、重试、冲突解决。

5.1 网络状态检测

APP需要知道网络什么时候好、什么时候坏。

基础版:监听浏览器的online/offline事件

window.addEventListener('online', () => {
    console.log('网络恢复了,开始同步');
    startSync();
});

进阶版:自适应心跳检测

  • 正常时:每30秒发一次心跳(省电)
  • 弱网时:每5秒发一次心跳(快速感知恢复)
  • 断网时:停止心跳(省流量)

5.2 同步的四个阶段

当检测到网络恢复,启动以下流程:

第一阶段:数据预校验

客户端先发个“打招呼”包,告诉服务器:

  • 我有多少条待同步数据
  • 这些数据的MD5摘要

服务器快速比对,如果有冲突,提前告诉客户端:“你有一条数据和服务器版本不一致,准备打架。”

第二阶段:双向增量同步

向上推(Push)

  • 把本地sync_status=0的数据打包
  • 每20条一个包(分片上传),避免一次性数据太大
  • 每个包带一个唯一ID(client_request_id

幂等设计:如果网络波动导致同一个包发了两次,服务器看到重复的ID,直接返回“已收到”,不重复入库。这保证了数据不重复

向下拉(Pull)

  • 客户端告诉服务器自己最新的版本号
  • 服务器返回更新的数据

第三阶段:事务确认(ACK机制)

原子提交:只有当收到服务器的成功确认(ACK)后,客户端才把本地sync_status从0改成2。

重试策略:如果失败,不能疯狂重试。采用指数避退

  • 第1次失败:等1秒重试
  • 第2次失败:等2秒
  • 第3次:等4秒
  • 第4次:等8秒
  • 最大不超过1分钟

这防止了网络刚恢复又断开时的“雪崩效应”。

第四阶段:冲突裁决

这是最复杂的部分。两个护士同时改同一个病人怎么办?

策略一:时间戳优先(Last Write Wins)

  • 谁最后改的听谁的
  • 适用于体征数据这种“只取最新值”的场景

策略二:字段级合并

  • A护士改了体温,B护士改了血压
  • 服务器把两个修改合并成一条新数据
  • 适用于病历文书这种多字段独立的场景

策略三:版本向量(Vector Clock)

  • 分布式系统的高级解法
  • 记录每个节点的修改历史
  • 复杂但精确

第六层:硕士阶段 —— 极端场景下的专项优化

现在我们要把系统做到99.9%的可用性,必须处理各种极端情况。

6.1 弱网下的分片传输

如果同步的数据里有照片(比如手术签字单),文件可能好几兆。

问题:一次性传一个大文件,传一半断网了,下次要从头传。

解法:二进制分片 + 断点续传

// 把文件切成1MB的片
const CHUNK_SIZE = 1024 * 1024; // 1MB

function uploadFile(file, fileId) {
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    
    for (let i = 0; i < totalChunks; i++) {
        const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
        uploadChunk(chunk, fileId, i);
    }
}

// 上传每个片
function uploadChunk(chunk, fileId, index) {
    // 检查这个片是否已经上传过(断点续传)
    if (isChunkUploaded(fileId, index)) {
        return; // 已上传,跳过
    }
    
    // 上传逻辑...
}

效果:医生走出手术室WiFi覆盖区,回到办公室后能从上次断开的字节位继续传,不用重头传。

6.2 乐观UI解决卡顿问题

痛点:护士点保存,如果网络不好,界面转圈圈,护士以为卡了,会再点一次,导致重复提交。

解法:乐观UI

function saveVitalSign(data) {
    // 1. 立即显示"已保存"(乐观更新)
    showSuccessMessage('已保存(本地)');
    
    // 2. 角落里显示黄色小图标"同步中"
    showSyncStatus('syncing', 'yellow');
    
    // 3. 真正去同步
    syncToServer(data).then(() => {
        // 4. 同步成功,黄变绿
        showSyncStatus('synced', 'green');
    }).catch(() => {
        // 5. 同步失败,黄变红
        showSyncStatus('failed', 'red');
    });
}

用户体验:护士不用盯着进度条发呆,可以继续做下一件事。真正实现了无感覆盖

6.3 写前日志(WAL)解决并发卡顿

问题:后台正在同步大量数据(写数据库),前台护士想查患者列表(读数据库),会不会卡?

解法:SQLite的WAL模式

默认情况下,SQLite是读写互斥的:写的时候不能读,读的时候不能写。

开启WAL(Write-Ahead Logging)模式后:

  • 写操作:写在日志文件里
  • 读操作:读原数据库文件
  • 两者可以同时进行
PRAGMA journal_mode=WAL; -- 开启WAL模式

效果:同步任务在后台疯狂写数据,前台查询患者列表依然丝滑流畅。

6.4 智能带宽管控

如果同时有很多数据要同步,不能一股脑全发出去,会把正常业务带宽占满。

策略

  • 核心数据(如危急值):高优先级,立即发
  • 普通数据(如常规体征):中优先级,排队发
  • 非关键数据(如操作日志):低优先级,空闲时发

实现:维护三个优先级的队列

class SyncQueue {
    constructor() {
        this.highPriority = []; // 立即发
        this.mediumPriority = []; // 普通
        this.lowPriority = []; // 空闲时发
    }
    
    add(data, priority) {
        this[priority + 'Priority'].push(data);
        this.scheduleSync();
    }
    
    scheduleSync() {
        // 先发高优先级
        if (this.highPriority.length > 0) {
            this.sendBatch(this.highPriority);
        } 
        // 如果网络空闲,发中优先级
        else if (this.isNetworkIdle()) {
            this.sendBatch(this.mediumPriority);
        }
        // 极空闲时发低优先级
        // ...
    }
}

第七层:博士阶段 —— 理论的升华与范式总结

站在更高的维度,我们可以总结出这套算法的数学本质哲学意义

7.1 从CAP定理看本地优先

分布式系统有个著名的CAP定理:一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance),三者只能取其二。

传统在线API选择了:

  • 放弃分区容错性(P):网络断了你就用不了
  • 保持一致性(C)和可用性(A)

本地优先架构的选择

  • 接受分区是常态(P)
  • 保证可用性(A):断网也能用
  • 通过异步同步实现最终一致性(Eventually Consistent)

哲学转变:从“强一致性”到“最终一致性”,从“网络必须可靠”到“网络不可靠是默认前提”。

7.2 数据结构的数学本质

这套算法的核心数据结构可以抽象为:

本地影子库 = 业务数据 + 元数据(状态+版本+操作类型)

操作日志 = 时间序列上的状态转移函数

同步协议 = 分布式状态机中的状态复制

用数学语言描述:

  • 每个客户端是一个独立的状态机
  • 操作日志是状态转移的输入序列
  • 同步过程是两个状态机之间的状态对齐
  • 冲突解决是状态合并函数

7.3 CRDT的引入(最前沿的方向)

CRDT(Conflict-free Replicated Data Types,无冲突复制数据类型)是一种更高级的解决方案。

传统冲突解决:先发生冲突,再解决(打架了再拉架)

CRDT的思路:设计数据结构,使其天生不会打架

比如一个计数器:

  • A护士加1
  • B护士加2
  • 无论以什么顺序同步,最终结果都是3

这就是数学上可证明的最终一致性

CRDT在医疗场景的应用

  • 计数器类数据(如输液滴数):天然适用
  • 集合类数据(如用药清单):可以设计成“添加永不冲突”的结构
  • 文本类数据(如病历):可以使用类似于Git的合并算法

7.4 算法复杂度分析

空间复杂度

  • 本地影子库:O(n),n是业务数据量
  • 操作日志:O(m),m是操作次数,可能远大于n

时间复杂度

  • 增量同步:O(k),k是变更的数据量,不是全量
  • 冲突检测:O(1) 通过版本号
  • 字段级合并:O(f),f是字段数量

网络开销

  • 相比全量同步,减少90%以上的流量
  • 相比在线API,增加约20%的握手开销

7.5 理论的落地:一个完整的数学定义

我们可以给出这个同步算法的形式化定义:

设客户端状态为 C,服务器状态为 S,同步协议 P 是一个四元组:

P = (D, L, V, M)

其中:

  • D 是本地影子库,D = {(key, value, status, version)}
  • L 是操作日志,L = [(op, timestamp, vector_clock)]
  • V 是版本向量,V = [v1, v2, ..., vn]
  • M 是合并函数,M: (C_state, S_state) → new_state

同步的目标是:经过有限次同步后,C 和 S 达到最终一致,即:
lim_{t→∞} distance(C_t, S_t) = 0


第八层:简历/面试话术 —— 如何包装成亮点

现在你已经完全理解了这套算法,关键是怎么在面试中说出来。

8.1 初级话术(说得清)

“我在做医院移动护理项目时,解决了手术室WiFi信号差的问题。我采用了本地优先的设计,数据先存SQLite,网络好了再同步。通过给数据加同步状态字段,实现了增量同步。还用了操作日志记录变更历史,保证数据不丢。”

8.2 中级话术(有深度)

“针对手术室铅门屏蔽导致的频繁断网场景,我设计了一套本地优先的增量同步架构。核心是本地影子库+操作日志+增量步进的三位一体模型。

我在业务表中扩展了sync_status、version等元数据,用于状态追踪。同时通过数据库触发器记录操作日志,确保操作可追溯。同步时采用版本比对,只传增量数据,减少90%的流量。

为了解决并发冲突,我实现了字段级合并策略,两个护士同时修改不同字段时能自动合并。针对大文件传输,我做了二进制分片和断点续传,保证照片等数据能可靠上传。”

8.3 高级话术(有体系,有数据)

“在处理手术室移动端业务时,针对铅门屏蔽导致的频繁掉线难题,我放弃了传统的在线API模式,实现了一套本地优先的增量同步架构

架构设计
我基于SQLite构建了本地影子库,在业务表基础上扩展了sync_status、version等元数据,实现数据状态的本地持久化。同时引入操作日志表,通过数据库触发器自动记录每一次字段级变更,形成可追溯的变更流水线。

同步协议
设计了四阶段同步流程:预校验(MD5摘要比对)→双向增量(分片上传+幂等处理)→事务确认(原子提交+指数避退)→冲突裁决(时间戳优先+字段级合并)。

专项优化

  • 针对弱网环境,实现二进制分片传输和断点续传,大文件传输成功率从72%提升到99.5%
  • 采用自适应心跳检测,网络恢复后500ms内启动同步
  • 引入乐观UI,护士点击保存后即时反馈,后台静默同步,用户无感知
  • 开启SQLite WAL模式,实现读写并发,同步时不阻塞前台查询

成果
这套架构把数据同步的失败率从原始的15%降低到了0.1%以下。最关键的是实现了业务上的无感覆盖:医生在盲区录入的数据,走出病区的瞬间就能在几百毫秒内完成静默同步。医生根本不知道网络断过,业务照常进行。

理论升华
这套方案的实质是从CAP理论中选择了AP(可用性+分区容忍性),通过最终一致性保证数据准确。从数学上看,它是分布式状态机之间的状态复制协议,操作日志是状态转移函数的输入序列。”

8.4 应对追问:你可能被问到的点

Q1:如果本地数据量很大,同步会不会很慢?

A:我们做了三级优化。第一,增量同步,只传变更数据。第二,分片并发,20条一批同时上传。第三,优先级调度,核心数据优先传。实测1万条数据能在30秒内完成同步。

Q2:怎么保证数据不丢?

A:四重保障。第一,本地持久化,写入成功才返回用户。第二,事务确认,收到服务端ACK才标记已同步。第三,重试机制,失败后指数避退重试。第四,操作日志溯源,即使极端情况也能通过日志恢复。

Q3:多个端同时改同一份数据怎么办?

A:我们实现了字段级合并。通过版本向量记录每个字段的最后修改时间和节点,同步时对比向量,不同字段自动合并,同一字段以时间戳为准。这比简单的“最后写入胜出”更精细。

Q4:你们的方案和现有的框架(如CouchDB、PouchDB)有什么区别?

A:现有框架解决的是通用同步问题,但我们针对医疗场景做了深度定制。比如字段级合并策略符合医疗文书的多作者协作场景,优先级调度保证危急值优先上传,分片传输针对医疗影像优化。我们是业务驱动的技术选型和定制。


第九层:上帝视角 —— 与其他技术的对比

9.1 与CouchDB/PouchDB对比

CouchDB是成熟的Offline-First数据库,自带同步协议。

我们的方案 vs CouchDB

  • 相同点:都采用MVCC(多版本并发控制)、增量同步、冲突检测
  • 不同点:我们更轻量,直接基于SQLite,不需要部署CouchDB服务端
  • 优势:医疗系统常有现有关系数据库,我们的方案更容易集成

9.2 与GraphQL订阅对比

GraphQL订阅通过WebSocket实现实时推送。

适用场景不同:

  • GraphQL订阅:适合在线实时协作(如在线文档)
  • 我们的方案:适合网络不稳定、需要离线工作的场景(如移动护理)

9.3 与WebSocket/长连接对比

WebSocket假设网络持续可用。

我们的方案假设网络不可靠是常态。

哲学差异:WebSocket是在线优先,我们是离线优先

9.4 与Git版本控制类比

有趣的是,我们的方案和Git惊人地相似:

Git 我们的方案
本地仓库 本地影子库
commit 操作日志
push/pull 双向同步
merge 冲突解决
branch 多客户端分支
rebase 版本对齐

这个类比可以帮助面试官快速理解。


第十层:总结与核心记忆点

如果面试紧张,只要记住这4个关键词,就能串联起整个知识体系:

核心四词记忆法

1. 本地优先(Offline-First)

  • 哲学:网络是同步工具,不是工作前提
  • 实现:数据先写本地SQLite

2. 操作日志(Op-Log)

  • 哲学:记流水账比记结果更有价值
  • 实现:触发器自动记录变更历史

3. 增量同步(Incremental Sync)

  • 哲学:只传变化的部分
  • 实现:版本号+MD5摘要+分片传输

4. 最终一致性(Eventual Consistency)

  • 哲学:允许暂时不一致,但最终会一致
  • 实现:冲突解决+字段级合并

🎯 一句话概括

这是一套把“网络不可靠”作为默认前提,通过“本地存储+操作日志+增量同步+冲突解决”实现业务无感覆盖的分布式数据同步方案。

🔥 终极必杀技

如果面试官问:“你觉得自己最牛的技术方案是什么?”

你可以这样回答(配合自信的眼神):

“我最引以为豪的是一个解决手术室断网同步的方案。在那个场景里,网络不是偶尔断,是物理层面被铅门屏蔽。我设计了一套本地优先的增量同步架构,把数据同步的失败率从15%降到0.1%以下。

最让我得意的是,这个方案不仅仅是写代码,而是从哲学层面重新思考了网络和业务的关系——我们不再依赖网络,而是让网络服务于业务。医生在盲区录入的数据,走出手术室的瞬间就完成静默同步,他完全感知不到网络的存在。

我觉得,最好的技术就是让用户感受不到技术的存在。这套方案做到了。”

CSS属性 - 边距属性

作者 GinoWi
2026年2月24日 17:11

CSS属性 - 边距属性

内边距

边框和内容之间的距离就是内边距。

  • 格式:
/* 非连写 */
padding-top: ${padding-top};
padding-right: ${padding-right};
padding-bottom: ${padding-bottom};
padding-left: ${padding-left};

/* 连写 */
padding: ${padding-top} ${padding-right} ${padding-bottom} ${padding-left};
  • 渲染样式:

    未设置padding属性的渲染效果:

    可以通过开发者工具中的Computed面板观察外面红色盒子的样式:

    nopadding-computed

    Computed面板当中可以看到这个时候的padding属性为0。当我们设置padding属性后观察一下变化:

    image-20260214102417792

    再通过Computed面板观察一下外面红色盒子当前的样式:

    image-20260214102741517

    这个时候可以看到盒子里面的padding属性为30px,此时元素整体的宽高也会变成560px。

    示例代码:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">
            <title>边距属性练习</title>
            <style>
                .outBox {
                    background-color: red;
                    width: 500px;
                    height: 500px;
                    /*这里使用了连写的方式同时设置了,上右下左四个方向的内边距*/
                    padding: 30px;
                }
                .inBox {
                    background-color: green;
                    width: 200px;
                    height: 200px;
                }
            </style>
        </head>
        <body>
            <div class="outBox">
                <div class="inBox"></div>
            </div>
        </body>
    </html>
    

    补充内容:

    Computed 面板展示的是“计算后的值” ‌:是将影响元素尺寸的所有 CSS 属性(如 widthheightpaddingbordermargin)都拆解并显示为最终的、浏览器实际使用的像素值(或其它单位)。

    盒模型图是核心可视化工具‌:在 Computed 面板的底部,通常会有一个‌可交互的盒模型示意图‌。这个图从内到外清晰地展示了四个层级:

    • 内容区 (content) ‌:显示 widthheight 的最终计算值。如果这两个值在 CSS 中被设置为 auto,这里就会显示 auto × auto,表示其尺寸由内容或父容器决定。
    • 内边距 (padding) ‌:显示四个方向的内边距数值。
    • 边框 (border) ‌:显示四个方向的边框宽度。
    • 外边距 (margin) ‌:显示四个方向的外边距数值。

    元素宽高‌:可以通过将 width + padding-left + padding-right + border-left + border-right 来计算出元素的‌总宽度‌(即“布局宽度”或 offsetWidth)。同理,可以通过将height + padding-top + padding-bottom + border-top + border-bottom来计算出‌总高度‌。这个“整体大小”就是元素在页面上实际占据的空间。

  • 注意点:

    • 通过上述示例我们可以观察到,给标签设置了内边距,标签的宽度和高度会发生变化(增加内边距的距离)。

    • 连写格式这三个属性的取值省略规律:

      • ${padding-top} ${padding-right} ${padding-bottom} ${padding-left} ---> ${padding-top} ${padding-right} ${padding-bottom} :省略左内边距的设置,取值和右内边距一样。
      • ${padding-top} ${padding-right} ${padding-bottom} ${padding-left} ---> ${padding-top} ${padding-right} : 省略下内边距、左内边距的设置,下内边距取值和上内边距取值一样,左内边距取值和右内边距取值一样。
      • ${padding-top} ${padding-right} ${padding-bottom} ${padding-left} ---> ${padding-top}:省略右内边距、下内边距、左内边距,则被省略的内边距的取值和上内边距的取值一样。
    • 通过上述示例可以发现,内边距也会有背景颜色,同父元素颜色。

外边距

标签和标签之间的距离就是外边距。

  • 格式:
/* 非连写 */
margin-top: ${margin-top};
margin-right: ${margin-right};
margin-bottom: ${margin-bottom};
margin-left: ${margin-left};

/* 连写 */
margin: ${margin-top} ${margin-right} ${margin-bottom} ${margin-left};
  • 渲染样式:

    • 两元素父子关系:

      先设置一个父元素div和一个子元素div的初始样式:

      nomargin

      然后给子元素设置margin-top属性:

      margin-1

      可以看到父元素的margin属性仍为0

      margin-computed

      可以观察到子元素的margin属性为30px

      margin-computed

      可以观察到,如果设置了margin-top: 30px;属性,父元素会一同被顶下来。

      我们可以通过给父元素设置border属性,来先观察当下需要了解的margin属性:

      margin-2

      <!DOCTYPE html>
      <html>
          <head>
              <meta charset="utf-8">
              <title>边距属性练习</title>
              <style>
                  .outBox {
                      background-color: red;
                      width: 500px;
                      height: 500px;
                      border: 1px solid #000;
                  }
                  .inBox {
                      background-color: green;
                      width: 200px;
                      height: 200px;
                      margin: 30px;
                  }
              </style>
          </head>
          <body>
              <div class="outBox">
                  <div class="inBox"></div>
              </div>
          </body>
      </html>
      
    • 两元素为兄弟关系:

      这里就不再做对比,直接展示设置margin属性后的效果:

      margin-brother

      <!DOCTYPE html>
      <html>
          <head>
              <meta charset="utf-8">
              <title>边距属性练习</title>
              <style>
                  .box1 {
                      background-color: red;
                      width: 100px;
                      height: 100px;
                  }
                  .box2 {
                      background-color: green;
                      width: 100px;
                      height: 100px;
                      margin: 30px;
                  }
              </style>
          </head>
          <body>
              <div class="box1"></div>
              <div class="box2"></div>
          </body>
      </html>
      
  • 注意点:

    • 连写格式取值省略时的规律:

      • ${margin-top} ${margin-right} ${margin-bottom} ${margin-left} ---> ${margin-top} ${margin-right} ${margin-bottom}:省略对左边距的指定,左边距的取值和右边距一样。
      • ${margin-top} ${margin-right} ${margin-bottom} ${margin-left} ---> ${margin-top} ${margin-right}: 省略下边距、左边距的指定,下边距取值同上边句一样,左边距取值同右边距一样。
      • ${margin-top} ${margin-right} ${margin-bottom} ${margin-left} ---> ${margin-top}:省略右边距、下边距、左边距的指定,被省略的边距的取值同上边距一样。
  • 外边距的那一部分是没有背景颜色的。
  • 在默认布局的垂直方向上,默认情况下外边距是不会叠加的,会出现合并显现,原则上是谁设定的外边距比较大就听谁的。
  • 在默认布局的水平方向上,默认情况下外边距是会叠加的,不会进行合并。

外边距合并

外边距合并的现象简单来说就是当两个垂直方向的外边距相遇,会形成一个外边距,此外边距大小为两个边距中的最大值(如果两者相等则取其中一个)。

外边距合并的三种情况:

  • 两元素为相邻的兄弟关系:

    image-20260224150323247

  • 两元素为父子关系:

    这里引用MDN文档的解释:

    如果没有设定边框(border)、内边距(padding)、行级(inline)内容,也没有创建区块格式化上下文或间隙来分隔块级元素的上边距(margin-top)与其内一个或多个子代块级元素的上边距(margin-top);或者没有设定边框、内边距、行级内容、高度(height)或最小高度(min-height)来分隔块级元素的下边距(margin-bottom)与其内部的一个或多个后代后代块元素的下边距(margin-bottom),则会出现这些外边距的折叠,重叠部分最终会溢出到父代元素的外面。

    也就是说合并后的外边距大小是取父元素及其内首个/最后一个子代块级元素的上边距/下边距的较大值,但是最终的渲染结果都会是“溢出”到父元素面的外边距。

    image-20260224155323022

  • 空元素的外边距合并:

    如果块级元素没有设定边框、内边距、行级内容、高度来分隔块级元素的上边距(margin-top)及其下边距(margin-bottom),则会出现其上下外边距的折叠。

    image-20260224160810735

注意点:

  • 上述情况的组合会产生更复杂的(超过两个外边距的)外边距折叠。
  • 即使某一外边距为 0,这些规则仍然适用。因此就算父元素的外边距是 0,第一个或最后一个子元素的外边距仍然会(根据上述规则)“溢出”到父元素的外面。
  • 如果包含负边距,折叠后的外边距的值为最大的正边距与最小(绝对值最大)的负边距的和。
  • 如果所有的外边距都为负值,折叠后的外边距的值为最小(绝对值最大)的负边距的值。这一规则适用于相邻元素和嵌套元素。
  • 外边距折叠仅与垂直方向有关。

解决外边距折叠的方法:

  • 可以通过触发BFC来解决外边距合并的问题
  • 可以通过设置padding、border属性来解决

参考资料:

W3School官方文档:www.w3school.com.cn

MDN官方文档:developer.mozilla.org

Vibe coding(AI编程一网打尽)

作者 AKclown
2026年2月24日 16:59

前言

近一年来vibe coding席卷全球,现在很多公司都开始并且要求使用AI编程工具,我本人使用过类似Cursor这种IDE形式工具,也是使用codexClaude code这种ClI工具。接下来我就使用Open code这款开源的代码agent工具来从头过一遍rulescommandagent抓取agent请求分析MCP服务skills这些基础概念以及使用,也方便后续我自己能够快速查找对应的内容。主要先用起后续在深入agent开发。

opencode的使用

1. 安装

npm install -g opencode-ai

2. 创建next.js项目(用于演示)

pnpm create next-app@latest ai-coding --yes

3. 进入到ai-coding目录,执行opencode,出现如下界面表示成功

image.png

4. 配置第三方模型

opencode也提供一些免费的模型,例如Kimi K2.5GLM-4.7等等,可以通过命令/modles来查看,后面有FREE表示免费标识。 image.png 为了达到演示效果,接下来我将配置Googlegemini服务。当然你也可以选择其他模型提供商,例如OPEN AIGITHUB Copilot

  • 配置opencode-antigravity-auth插件,该插件用于在opencode启用OAuth 对 Antigravity(Google 的 IDE)进行身份验证,这样就可以通过谷歌凭证访问Antigravity的模型。例如 gemini-3-pro 和 claude-opus-4-5-thinking

    • 方式一: 让AI agent自动帮我们完成opencode-antigravity-auth安装 (让LLM处理)
    Install the opencode-antigravity-auth plugin and add the Antigravity model definitions to ~/.config/opencode/opencode.json by following: https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/dev/README.md
    
    • 方式二:
      编辑~/.config/opencode/opencode.json文件,追加如下内容
    {
      "plugin": ["opencode-antigravity-auth@latest"]
    }
    
    • 登录认证
    opencode auth login
    

image.png

在进行认证的时候,无法完成Goole谷歌大概率是VPN墙的问题。导致了如下两个链接无法访问

https://oauth2.googleapis.com/token
https://www.googleapis.com/oauth2/v1/userinfo

可以通过postman主动去获取这两个数据,并且修改它的插件代码既可以绕过请求。在之后遇到授权问题时,可以通过debugger这个插件来排查是哪一步存在问题
<主机目录>/.cache\opencode\node_modules\opencode-antigravity-auth

image.png

关于opencode中Oauth认证的流程如下图: image.png

尴尬的是这样子绕过了登录流程的,虽然能够获取到Gemini模型列表了,但是已经模型请求依旧显示网络异常,所以还是要解决CLI不能使用系统代理问题。

提示: 通过解决上面的授权,我们能够更加清晰的知道整个授权流程是怎么样的,以及可以确定插件内部无法发送oauth2.googleapis.com导致,而不是其他问题导致的

我的翻墙工具存在终端代理的命令,只需要将这个命令输入到你需要执行opencode的窗口,然后就可以正常使用模型了。如果你的Clash应该存在TUN模式打开即可

export https_proxy=http://127.0.0.1:50750 http_proxy=http://127.0.0.1:50750 all_proxy=socks5://127.0.0.1:50751

image.png

5. oh-my-opencode (多代理协作插件)

用 Claude 做编排,用 GPT 做推理,用 Kimi 提速度,用 Gemini 处理视觉。模型正在变得越来越便宜,越来越聪明。没有一个提供商能够垄断。我们正在为那个开放的市场而构建。Anthropic 的牢笼很漂亮。但我们不住那。

核心理念

很多人使用 AI 编程工具会经历从”惊艳”到”冷静”的过程:写小功能很快,但一到重构、迁移、补测试、清 ESLint 警告等大型任务就容易卡壳。Claude-code中这个问题也是存在的, 在Cladue上面有Ralph插件来做这个事儿, 但OpenCode则有更强的 Oh-My-OpenCode 插件:

  • 不是简单叠加 UI,而是将”单模型”升级为多代理协作系统
  • 通过主控代理 Sisyphus 负责任务拆分、委派、推进
  • 把不同类型的工作分派给不同角色的代理,模拟真实开发团队的协作方式
代理角色
角色 职责
Sisyphus(主控) Tech Lead + 项目经理组合,负责拆解 TODO、分配任务、推动进度
Oracle 架构设计、深度调试、复杂问题分析
Librarian 文档检索、API 查阅、资料收集
Explore 代码库探索、依赖分析、边界定位
Frontend Engineer UI/UX 设计、前端组件开发
工作模式

ultrawork(或简写 ulw)是核心的工作模式开关:

小任务:正常使用即可
大任务(跨文件/跨模块/需要查资料/需要持续推进):开启 ultrawork 更稳

当开启 ultrawork 时,系统会:

  • 并行探索代码库
  • 启动后台任务
  • 强力推进直到完成

到目前我们完成opencode的安装以及关联第三方提供商,接下来就来了解一些关于AI agent的一些常见概念以及使用

常用的command

接下来学习一下opencode中常用且好用快捷操作命令有哪些? 帮你提效AI工具

快捷键 功能 说明
Ctrl+X -> N 新建会话 ctrl+x同时按,然后松开再按N
Ctrl+X -> L 会话列表 ctrl+x同时按,然后松开再按N
shift+Enter 换行(不发送) 用于多行提示词
⬆/⬇ 切换历史记录 用于多行提示词
ESC 中断这次会话 中断这次会话
Ctrl+X -> M 打开模型列表 切换模型列表
Ctrl+X -> U 撤销(undo) 回退到这次对话初始状态(觉得这次对话不好)
Ctrl+X -> R 重做(redo) 重回到这一次undo之前
F2 切换最近模型 快捷切换最近使用的模型
Ctrl+X -> y 复制AI消息 复制AI的完整回复
Ctrl+X -> B 切换侧边栏 查看会话饼图
PageUp/Down 滚动对话 快速浏览长对话

关于AGENTS.md文件

AGENTS.md是一个markdown文件,我们可以将其提交到Git仓库中,用于自定义AI编码代理在仓库中的行为。它位于对话记录的顶部,紧邻系统提示符下方。(该文件不是必须的)

1. 为什么需要写AGENTS.md?

AGENT非常擅长局部修改,但缺乏上下文时容易“跑偏”。AGENTS.md 就像一张地图:告诉它命令在哪里,用什么测试、PR 怎么写、哪些操作禁止。顺带一提,这也能帮助新同事快速融入团队。

2. AGENTS.md如何查找呢?(Monorepos

关于AGENTS.md文件查找跟node_modules的查找方式类似,以就近文件优先进行查找

repo/  
├─ AGENTS.md            # 全仓默认规则  
├─ apps/  
│  └─ web/  
│     ├─ AGENTS.md      # 仅对 /apps/web/** 生效  
│     └─ src/components/Button.tsx  
└─ packages/  
   └─ api/  
      ├─ AGENTS.md      # 仅对 /packages/api/** 生效  
      └─ src/routes/users.ts

3. 可选的AGENTS.override.md

有时可能需要一份临时、优先级更高的规则,例如发布封板、事故应急、合规冲刺等
• 该文件名只是约定,不是标准;只有部分工具/CLI 会自动识别。
• 如果工具不支持,可通过命令行额外指定策略文件实现同样效果。
• 建议“少量、明确、短期”:写清目的、约束内容、结束条件,窗口结束后立即删除。

repo/  
├─ AGENTS.md  
├─ AGENTS.override.md        # 全仓临时规则(若被支持)  
└─ packages/  
   └─ api/  
      ├─ AGENTS.md  
      └─ AGENTS.override.md  # 仅 API 范围的事故模式(若被支持)

4. 针对过大的agents.md文件可能来自于如下原因:

agents.md文件会在每次对话开始时自动加载,让Agent能立即了解的我们的项目。因此表明agent.md文件不宜过大才不会占用过多的上下文和消耗过多的token

It's automatically loaded at the start of every conversation, giving the agent immediate understanding of your project.
  • 不同的开发者添加了相互矛盾的建议,没有人能够完成完整风格,难以维护的混乱实际上损害了Agents的性能。
  • 另一个罪魁祸首:自动生成的 AGENTS.md 文件。 切勿使用初始化脚本自动生成 AGENTS.md。 他们在文件中充斥着“对大多数情况有用”但最好逐步披露的内容。 生成的文件优先考虑全面性而不是限制性

对于agents.md会在每一次会话中都会加载,无论这个agents.md内容是否相关,这会导致token浪费问题

设想 影响
轻量、专注的agents.md 有更多的token用于特定的任务
体积庞大、臃肿的agents.md 用于实际工作的tokens变少、agent会感到困惑
不相干的说明 tokens的浪费 + agent分心 = 浪费性能

意味编写的agents.md文件应该尽可能的小。 同时也需要注意要保持agent.md文件的更新,不要使用过时的agent.md文件

5. AGENTS.md文件内容

一般情况agents.md包含下面几大类既可以了,而且尽可能极简

  1. 整个项目的架构和模式(技术栈、项目结构)
  2. 遵循我们的编码风格和规范(代码规范)
  3. 声明正确的命令进行构建和测试(包管理工具、构建命令)
  4. 避免已知的陷阱和反模式(已知问题)
  5. 做出符合你的偏好的决策

推荐agents结构

# AGENTS.md

## Project overview
Brief description of what this project does and its primary purpose.

## Tech stack
- Framework: Next.js 14 with App Router
- Language: TypeScript (strict mode)
- Database: PostgreSQL with Prisma ORM
- Styling: Tailwind CSS

## Architecture
Explain the codebase structure and key patterns.

## Commands
```bash
bun install        # Install dependencies
bun run dev        # Start development server
bun run test       # Run tests
bun run build      # Production build
```

## Code style
- Prefer functional components
- Use early returns
- Name files in kebab-case
- Write tests for business logic

## Patterns to follow
Document established patterns in the codebase.

## Things to avoid
List antipatterns and common mistakes.

6. 使用渐进式披露

不要将所有的内容都塞进AGENTS.md中,而是使用渐进式披露。仅向Agent提供其当前所需要的内容,并在需要时将其指向其他资源。

模型启动时会加载所有 Skills 的基本信息(名称和用途),但不会读取具体内容。只有当模型真正需要某个 Skill 来指导工作时,才会去读取那份文档的详细内容。这种按需加载的方式,就是渐进式披露

特定语言的规则移至单独的文件

如下文件属于属于大包大揽行为在开启会话的时候会将所有agent.md内容都加载到会话中 image.png 针对上一个文件进行渐进式披露优化,agents.md只包含大纲内容,不会再初次开启会话时就将agent.md所有的内容都加载而是使用才加载对应的规则。例如当编写Typescript的时候才会去加载TypeScript Conventions,可以理解为按需加载 image.png

7. 如何重构AGENTS.md文件

如果我们对已有的agents.md感觉到困惑难以维护,使用如下提示词以渐进式的公式重构我们的提示词。

I want you to refactor my AGENTS.md file to follow progressive disclosure principles.

Follow these steps:

1. **Find contradictions**: Identify any instructions that conflict with each other. For each contradiction, ask me which version I want to keep.

2. **Identify the essentials**: Extract only what belongs in the root AGENTS.md:
   - One-sentence project description
   - Package manager (if not npm)
   - Non-standard build/typecheck commands
   - Anything truly relevant to every single task

3. **Group the rest**: Organize remaining instructions into logical categories (e.g., TypeScript conventions, testing patterns, API design, Git workflow). For each group, create a separate markdown file.

4. **Create the file structure**: Output:
   - A minimal root AGENTS.md with markdown links to the separate files
   - Each separate file with its relevant instructions
   - A suggested docs/ folder structure

5. **Flag for deletion**: Identify any instructions that are:
   - Redundant (the agent already knows this)
   - Too vague to be actionable
   - Overly obvious (like "write clean code")

image.png

rules (角色)

rules是给agents一个行为准则,当我们发现需要反复告诉AI同样的事情时就需要Rules了。例如问答时使用英/中回答、回复时需要精简回答、遇到不确定的事情需要告知用户、系统环境、用户偏好设置及其他无法通过代码编译器或检查器管理发现的实现细节

  • rules的特点

    1. 一次设置,长期生效
    2. 定义AI的人设行为模式
    3. 适合长期稳定的需求
  • rules的缺点

    1. 只能定义做什么不做什么无法定义怎么做
    2. 无法包含复杂的操作步骤
    3. 仍然比较抽象

如何写好rules可以根据cursor中的最佳实践来实现

示例

下面来看看加了rules和没有加rules的区别,如下是我的提示词,有规则和没有规则只有生成的文件名称导出变量不同

在当前目录下新建一个hooks-by-rules.ts,创建一个名称为useListByRules的hooks方法。请求url为请求地址参数,params为请求参数,返回对应的loading、pageSize、total等参数以及refresh、getList、changePage等方法
  • hooks.ts文件是还没有创建rules规则时创建的hooks,会有堆的loading、error的useState
  • hooks-by-rule.ts文件是具备rules规则时创建的hooks,采用了@tanstack/react-query的useQuery来实现 image.png 如下是我的hook.mdc规则编写,声明了需要使用useQuery方式来实现。 针对rules你可以自己写一部分然后让agent帮你完善或者改进
    • globs声明作用域文件
    • description标识当前rule的限定范围
    • alwaysApply 是否应用于每次会话 image.png

目前我在opencode.json中配置了,agent并没有自动读取我的rules。(暂时不知道为什么)

{
  "$schema": "https://opencode.ai/config.json",
  "instructions": [
    ".opencode/rules/*.md"
  ]
}

为了让opencode自动读取我的rules,我在agent.md文件追加了如下内容 image.png 是不是觉得rulesagent.md文件的能力差不多?
是的,你没有理解错。agent.md只是rules的更简单的一种方式,它是纯markdown文件,没有结构化配置元数据头部(就是头部不需要声明globs、description、alwaysApply定义)。它适用于项目规则比较简单的场景,Agent.md更加轻量、更易读的指导说明替代方案

MCP(模型上下文协议)

MCP是由Claude背后的公司Anthropic开放的标准。虽然听起来很有技术性,但是核心思想很简单。为AI Agent代理提供统一的方式来连接工具、服务和数据。

使用MCP即插即用。agent可以将结构化请求发送到任何MCP兼容工具,实时获取结果,甚至将多个工具链接在一起,无需提前了解具体细节

MCP Server的调用方法,当用户在AI Agent提问的时候,会将MCP作为system提示词发送给LLM,其中提示词包含了这个MCP的能力描述,以及对应的API能力(有点类似一个插件的API文档),然后LLM模型就知道了该MCP具备什么能力,从而能够判断是否需要调用MCP Server来实现你的需求。 后续在抓取请求会在分析具体传递的MCP数据会更加直观。 MCP.png

接下来实战一下MCP Server的使用,你也可以通过awesome-mcp-serverssmithery.ai来查找你需要的MCP服务

Chrome Devtools Mcp

在使用Agent工具调试前端项目的时候,是不是经常遇到这样一个痛点。Agent没办法获取到浏览器控制台的信息,导致你在调试的时候都需要手动的告知或复制对应信息Agent工具,它才能帮你修复bug问题。下面通过集成 chrome-devtools-mcp来解决这一痛点

Chrome Devtools Mcp的时候,让我们的code agent能够实时的检测和控制谷歌浏览器。它充当模型上下文协议(MCP)服务器,使我们的code agent能够访问Chrome Devtools的全部功能,实现可靠的自动化深度调试性能分析

在没有集成Chrome Devtools Mcp的时候,openCode编程工具并不能直接访问读取我们的谷歌浏览器的控制面板 image.png

接下来我们来实现一下在opencode集成Chrome Devtools Mcp,当我根据文档在~/.config/opencode/opencode.json中配置如下配置,然后通过/mcps查找发现Chrome Devtools Mcp属于disabled状态。但是并没有任何报错,因此我们可以通过向Agent提问来解决这个问题。

 "mcp": {
    "chrome-devtools": {
      "type": "local",
      "command": ["npx", "-y", "chrome-devtools-mcp@latest"],
      "enabled": true
    }
  }

通过在Agent中输入npx -y chrome-devtools-mcp@latest加上我在前面问为什么配置的MCP为disabled状态。那么模型会自动思考这个问题,发现我本地的Node版本低了一点。(在使用coding agent工具的时候,定位问题方式跟传统定位问题方式也有所改变,我们应该先让Agent帮我分析一下可能的问题,让Agent自己尝试解决问题image.png 可以通过Space空格键切换mcp的disabled或者enable状态

打开github找到chrome-devtools-mcp项目,给他点个star

在命令行输入如上prompt来看看是否Chrome Devtools Mcp是否配置成功,因为默认启动新的Chorm实例的沙盒环境因此没有登录信息,opencode会提示你登录用户信息,当你登录完成在叫opencode帮你继续执行即可。 image.png

默认chrome-devtools-mcp都会启动新 Chrome 实例的沙盒环境中运行 MCP 服务器。在沙盒环境中用户登录信息无法共享,那么如何将mcp链接到正在运行的Chrome实例呢?

  1. 在mcp配置文件中添加--browser-url选项,这个选项值是正常运行Chrom实例的URL地址。默认为http://127.0.0.1:9222地址
 "mcp": {
    "chrome-devtools": {
      "type": "local",
      "command": ["npx", "-y", "chrome-devtools-mcp@latest","--browser-url=http://127.0.0.1:9222"],
      "enabled": true
    }
  }
  1. 启动Chorme浏览器
    针对该文章更改了远程调试开关以提高安全性,从 Chrome 136 开始,我们将更改 --remote-debugging-port 和 --remote-debugging-pipe 的行为如果尝试调试默认的 Chrome 数据目录,系统将不再遵循这些开关。,这些开关必须与 --user-data-dir 开关搭配使用,才能指向非标准目录。非标准数据目录使用不同的加密密钥,这意味着 Chrome 的数据现在可以免受攻击者的侵害。
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\chrome-profile-stable"
  1. 测试一下
    输入如下的prompt提示词,就会在我使用上面命令启动的Chrome实例上运行内容,而不是又独自打开一个新的Chrome实例运行。只需要在第二步打开的Chrome实例登录保存用户即可
打开github找到chrome-devtools-mcp项目,给他点个star

image.png

让opencode结合chrome的面板帮我分析一下我的next.js代码哪里有错,如下是我的源代码 image.png 给opencode提供prompt提示词如下

http://localhost:3000/请结合chrome的面板,分析一下为什么我点击"测试一下"按钮,会报错,在控制台并没有打印出"我是测试按钮"

opencode会找到对应的错误,并且给出修复建议 image.png

我们给opencode配置了Chrome Devtools Mcp后,不再需要我们手动复制Chrome控制台信息给opencode了,我们也可以通过Chrome Devtools Mcp来实现网站性能/安全分析并且给出对应的修复建议。我们也了解了MCP的配置和用法,针对其他MCP用法也是大同小异就不一一演示了,感兴趣的小伙伴自行查阅对应MCP官网进行配置就可以了

注意事项

当我们使用MCP服务器时,会将该MCP的描述、API能力以系统提示词添加到我们的上下文中。如果我们有很多MCP服务,那么会把所有MCP服务都添加到我们的上下文中。因此我们应该只安装或者开启我们需要的MCP服务从而节省上下文,防止超出上下文限制。在opencode中可以/mcps选择对应的mcp服务器,再通过Space空格键来切换状态

skills

什么是的skills

Agent skills是一种轻量的开放格式,用于通过专业知识和工作流程扩展AI agent的功能。

skill是包含SKILL.md文件的文件夹。该文件包含元数据(name和description)以及告诉Agent如何执行特定任务的说明。SKILLS也可以包含scriptreferencesassets

my-skill/
├── SKILL.md          # Required: instructions + metadata
├── scripts/          # Optional: executable code
├── references/       # Optional: documentation
└── assets/           # Optional: templates, resources

为什么需要使用skills

skills是可重用的,基于文件系统资源为AI agent提供特定领域的专业知识。将通用AI agent变为专业的Agent在工作流程、上下文和最佳实践。与prompt不同(一次性任务对话说明),skills按需加载,无需在多个对话中重复提供相同的指导。

将通用Agent看作为一家饭店,skills在其中就充当菜谱的角色。当你的饭店拥有了菜谱那么这家饭店就具备了那些特色菜,也将菜系作为一个工作流的方式存储下。这样每次顾客点西红柿炒蛋就无需重新学一遍,做出来的口味也会更加统一

主要优点

  • 更专业的Agent(垂直agent): 根据特定领域任务定制功能
  • 减少重复: 一次创建,自动使用
  • 组合功能: 组合skills去构建复杂的工作流程

使用skills

skills在代码执行环境中运行,AI Agent拥有文件系统访问权限、bash命令和代码执行能力。 image.png

接下来演示一下find skillscreate skills,因为这两个skill比较常用和通用。我们可以在skills市场搜索开源skills使用,也可以自定义我们自己的skills。

find skills

这个skills可以帮助我们从开放Agent skills生态系统发现并且安装skill

skills.sh找到其使用方法和skills功能说明 image.png 在终端执行如下命令进行find-skills进行安装

npx skills add https://github.com/vercel-labs/skills --skill find-skills

image.png 重启opencode输入/skills即可查看已经安装skills image.png 接下来来看一下find-skillls的SKILL.md文件。一个skill文件一般包含了使用场景(description)触发条件具体的workflow可能的script\reference\assets具体实例错误处理等等。(对于使用者来说只了解该skills是解决什么问题以及如何通过prompt触发它即可)

  • description用于声明这个skills的职责是什么(清晰+简短)
  • When to Use This Skill声明了何时触发这个skill(用户询问如何完成某件事情,其中这件事情是一项常规任务且已掌握相关技能。询问可以完成某件事情的skill等等...) image.png

了解了find-skills的触发条件之。下面我们来测试一下,输入下面的prompt,opencode会自动使用find-skills提供的npx skills find去查找是否存在符合要求的skills

我想在想要创建创建一个skills,有没有对应的skills帮助我实现这个操作

image.png

skill creator

一般情况下每个公司每到年底都需要年终述职,那是不是也可以自定义一个skills来帮我们生成一个关于年终述职的PPT呢? 对于书写自定义skills我们可以通过skill-creator这个skills帮我创建。接下来通过skill-creator来创建一个关于年终述职PPT的skills

帮我生成一个提示词,用于实现年终总结PPT的skills实现的提示,我会把这个提示词喂给skill-creator来创建我的自定义skills

image.pngskill-creator也提供了一个python脚本用于打包分享给其他opencode用户 image.png 通过该skills帮我生成了一个ppt现在有点丑。因为只是演示关系我就不再优化skills提示词了,例如我们可以优化使用Office-PowerPoint-MCP-Server来使用ppt操作,而不是通过python脚本直接生成。追加更多与用户交互的提示词(让用户提供更多的信息、以及年终数据...)等等。(发挥你的想象力,去创建使用好玩的skills,分享在skills.sh也是ok的) image.png

让你的skills更加专业

skill文件一般包含了使用场景(description)触发条件具体的workflow可能的script\reference\assets具体实例错误处理等等,如何写好skills呢? 如下有三个建议:

1. 功能职责单一化
一个专业的skill首先要有清晰的职责范围,不要试图让一个skill做太多的事情,专注于解决一个具体的问题会更好。例如: 文件整理就文件整理,不要又涉及分析文件内容。这样让skill变得复杂且难以维护

2. 渐进式批漏
不要一次性把所有内容塞给AI agent,而是根据需要逐步获取信息。因为模型的上下文窗口是有限制的,而skill本质也是prompt提示词给到AI agent,全部加载进来的话会占用大量的上下文空间。渐进式纰漏就是让skill先加载目录(文件名称、description描述),只有确定需要使用某个skill才加载其的详细内容。我们一般将skill的详细文档示例代码参考资料放到单独的文件,然后再SKILL.md通过链接的形式进行关联。

就像维基百科一样: 从一个目录开始,仅在必要时才会引用越来越深的片段

3. description清晰度
如下SKILL.md可以使用,但是给出的信息太少,AI agent不知道这个skill是干什么的

name: file-rename  
description: 重命名文件  
# 文件重命名  
  
xxxxxxx

改进之后,description提供了更多信息,让AI agent能够更加准确理解这个SKILL的用途和方法

name: file-rename  
description: 批量重命名文件,支持按规则添加前缀、后缀、序号等。当用户需要批量修改文件名、统一文件命名格式时使用。  
# 文件批量重命名工具  
  
xxxxx

同时也可以参考一些优秀的开源skills写法,例如: 如何写出好的 Skill?拆解 skill-creator 背后的设计

skills如何工作

这里不再概述了claude code写的非常清晰了。这个Claude Code Skills & skills.sh - Crash Course来自于Youtube的视频讲skills也非常清晰

推荐skills

superpowers是一个完整的软件开发工作流程,适用于您的编码代理。它基于一组可组合的“skills”和一些初始指令构建而成,确保您的代理能够使用这些技能。让coding agent更加规范的编写代码,避免屎山代码

superpowers基本工作流(让AI agent执行更加规范、系统化)
image.png
安装完成superpowersskills集合后,其包含了brainstormin头脑风暴将你的粗略想法(想做一个需求,但是细节并没有想明白)输出为设计文档、writing-plans根据设计文档来生成执行计划、然后execute-plan来执行和实施计划等等工作流。 image.png 针对opencode可给定superpowers工作流需要执行哪些步骤,你可以通过/brainstormin/write-plan/execute-plan这个命令来执行。

https://github.com/obra/superpowers 学学 superpowers 怎么用,
当我提到"使用 superpowers 工作流"时,按这个顺序来:
1)先头脑风暴(brainstorm)
2)再写计划(write-plan)
3)最后按计划实现和测试(execute-plan)

上面提到的/brainstormin/write-plan/execute-plan这三步也是最核心步骤

  1. /brainstormin明确需求确定需要做什么
  2. /write-plan 写个执行计划
  3. /execute-plan按照执行计划一步一步执行

先通过brainstormin生成主题设计文档,然后通过write-plan拆分主题设计文档生成主题执行计划,然后再调用execute-plan执行任务。
image.png 最终效果如下
image.png

AGENTS.md、rules、mcp、skills的关系

3061f42318710801c85df8e1b946cc7e.png

总结

经过前面步骤,我们学会了opencode的基础操作,以及涉及了Agent.mdRulesMcps以及skills的使用,由于这篇幅比较长了。关于open routes多Agents协作、抓取Agent请求分析MCP\SKILLS交互原理、模型对比选择等放到之后有时间再整理

参考资料

agents
AGENTS.md Guide
A Complete Guide To AGENTS.md
AGENTS.md、层级规则与可选的 AGENTS.override.md
Rules & Guidelines
Agent Skills、Rules、Prompt、MCP,一文把它们理清楚了
Cursor Rules
opencode-rules
MCP Explained: The New Standard Connecting AI to Everything
chrome-devtools-mcp
Claude Code Skills 上手指南:从概念到真正用起来
Agent Skills Claude Code Skills & skills.sh - Crash Course
Introducing Agent Skills
Superpowers-入门快速指南 从“代码搬砖工”到“AI 团队主管”:OpenCode + Oh My OpenCode 开启多智能体协作新纪元

React项目白屏兜底神器?ErrorBounary你了解吗?

2026年2月24日 16:28

技术背景

JavaScript 的错误会破坏 React 的内部状态,进而导致整个页面崩溃。为了解决这个问题,React 16 引入了错误边界(ErrorBounary),错误边界可以捕获子组件的 JavaScript 错误,打印这些错误并展示降级 UI。

官方文档定义:

默认情况下,如果你的应用程序在渲染过程中抛出错误,React 将从屏幕上删除其 UI。为了防止这种情况,你可以将 UI 的一部分包装到 错误边界 中。错误边界是一个特殊的组件,可让你显示一些后备 UI,而不是显示例如错误消息这样崩溃的部分。

要实现错误边界组件,你需要提供 static getDerivedStateFromError,它允许你更新状态以响应错误并向用户显示错误消息。你还可以选择实现 componentDidCatch 来添加一些额外的逻辑,例如将错误添加到分析服务。

使用方式:

可以使用已有的 JS库 react-error-bounary 替代自己实现

# npm
npm install react-error-boundary

# pnpm
pnpm add react-error-boundary

# yarn
yarn add react-error-boundary




import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary fallback={<div>Something went wrong</div>}>
  <ExampleApplication />
</ErrorBoundary>

源码分析:

import * as React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    //构造函数初始化状态
    this.state = { hasError: false };
  }

  //这是一个静态生命周期方法,当子组件抛出错误时会被调用
  static getDerivedStateFromError(error) {
    // 更新状态,以便下一次渲染将显示后备 UI。
    return { hasError: true };
  }

  //在错误发生后调用,用于记录错误信息
  componentDidCatch(error, info) {
    logErrorToMyService(
      error,
      // 示例“组件堆栈”:
      // 在 ComponentThatThrows 中(由 App 创建)
      // 在 ErrorBoundary 中(由 APP 创建)
      // 在 div 中(由 APP 创建)
      // 在 App 中
      info.componentStack,
      // 警告:Owner Stack 在生产中不可用
      React.captureOwnerStack(),
    );
  }

  
  render() {
    if (this.state.hasError) {
      // 你可以渲染任何自定义后备 UI
            return (
        <div>
          <p>当前页面出错了,请联系Bone值班同学</p>
        </div>
      );
    }

    return this.props.children;
  }
}
<ErrorBoundary fallback={<p>Something went wrong</p>}>
  <Profile />
</ErrorBoundary>

官网地址: zh-hans.react.dev/reference/r…]

影响范围&边界

错误边界可捕获常见场景中的错误:

  1. 子组件内部错误
const ErrorUaughtComponent = () => {
  return (
    <div>
     //未定义变量CcConfig
      <h1>{CcConfig.length}</h1>
    </div>
  );
};
  1. 组件主动抛出错误
const ErrorComponent = () => {
  throw new Error('这是一个测试错误');
};
  1. 组件new一个错误(语法错误) ps:一个 React「函数组件」必须返回 ReactNode
const ErrorNewtComponent = () => {
  return new Error('这是一个测试错误');
};

错误边界无法捕获以下场景中出现的错误:

  1. 它自身抛出来的错误(并非它的子组件)
// 把错误边界自身写成会崩溃的组件
const BadBoundary = () => {
  throw new Error('BadBoundary 自己炸了');
};

const Demo1 = () => (
  <ErrorBoundary>
    <BadBoundary />
  </ErrorBoundary>
);
  1. 异步的错误(例如 setTimeoutrequestAnimationFrame 回调函数,接口报错,

form.validateFields()校验错误等)

const ErrorComponent = () => {
  setTimeout(() => {
    throw new Error('这是一个测试错误');
  }, 1000);
};
  1. 事件中的错误 (错误边界无法捕获事件处理器内部的错误)
const ErrorComponent = () => {
  const handleClick = () => {
    throw new Error('点击事件里的错误');
  };
  return <button onClick={handleClick}>点我报错</button>;
};

  1. 服务端渲染的错误(SSR渲染)
  2. console.error () (只是打印错误信息到控制台,不会终止程序)
const ErrorNewtComponent = () => {
 console.error('这只是一条日志,不会中断渲染');
};

目前已在在内部系统以页面维度、组件维度进行试用:

const ErrorBoundaryWrapper: React.FC<ErrorBoundaryProps> = (
  props: ErrorBoundaryProps,
) => {
  const location = useLocation();
  const shouldEnableErrorBoundary =
    location.pathname.startsWith('/feature/index') ||
    location.pathname.startsWith('/featureDetails/index');

  if (!shouldEnableErrorBoundary) {
    return props.children;
  }
  return <ErrorBoundary {...props} />;
};

总结

✅收益

  1. 兜底白屏:React 18 以后,生产环境任何未被捕获的错误都会把整棵组件树卸载成“白屏”;全局 ErrorBoundary 可以把白屏变成降级 UI(如“系统开小差”)。
  2. 统一埋点:一次 catch 所有渲染阶段错误,便于 Sentry、阿里 ARMS、灯塔等监控平台统计。
  3. 渐进式降级:可以配合 React.lazy、Suspense,对局部模块再包一层 ErrorBoundary,形成“全局兜底 + 局部细粒度”两级策略。

⚠️ 需要注意的 6 件事

场景 注意点 建议
事件处理器/异步代码 ErrorBoundary 只能捕获渲染阶段错误;onClicksetTimeoutPromise.reject 不在其捕获范围。 考虑事件里手动 try/catch
SSR 服务端渲染时,ErrorBoundary 抛出的错误如果不处理,Node 进程会 500。 在 SSR 入口也包一层 ErrorBoundary,并返回 500 页面。
重复渲染 一旦进入 Error 状态,React 会卸载整棵树并显示 fallback;如果 fallback 本身又抛错,会死循环。 fallback 组件必须足够简单(纯静态 UI),不要依赖 props/context。
性能 全局 ErrorBoundary 会阻止整页重渲染,对交互密集场景(如编辑器、画布)可能过度杀伤。 路由级业务模块级再包一层,做到“局部爆炸局部降级”。
错误信息泄露 生产环境不要把 error.stack 直接展示给用户,防止源码路径泄露。 fallback={error => <ErrorPage code={500} message="系统繁忙" />}
热更新 Vite/Webpack 热更新时抛错会被 ErrorBoundary 吞掉,导致看不到编译错误。 开发环境单独关掉全局 ErrorBoundary,或在 fallback 里加一个“刷新页面”按钮。

一句话总结

全局 ErrorBoundary 是“保险丝”,不是“万能药”。
只要记住“只兜底渲染错误 + 保持 fallback 简单 + 异步错误手动补”这三点,就可以放心用。

如何处理axios请求中post请求的坑

2026年2月24日 16:24

一、问题描述

  • axios.post() 直接传对象,后端收不到数据。
  • 原因是 axios 默认会把 POST 数据序列化为 JSON 格式,而很多后端(尤其是老项目)默认只识别 application/x-www-form-urlencoded 格式(即表单格式)。
  • 所以数据被 “拦截” 了,本质是前后端数据格式不兼容,而不是 axios 主动拦截

二、代码示例:为什么直接传对象不行?

  1. 错误写法(后端收不到数据)
// 错误示例:直接传对象 
axios.post('/api/login', { 
    username: 'admin',
    password: '123456' }).then(res => { 
    console.log(res)
}).catch(err => { 
    console.error(err) 
})

问题分析

  • axios 默认设置 Content-Type: application/json,发送的是 JSON 字符串:
{ "username": "admin", "password": "123456" }
  • 如果后端是用 PHP、Java 等传统方式读取 $_POSTrequest.getParameter(),它们默认只解析 application/x-www-form-urlencoded 格式,所以读不到 JSON 里的字段,表现为 “数据传不过去”。

三、正确的解决方案(代码实现)

方案一:使用 qs 库,将数据转为表单格式(推荐)

这是最稳妥的方式,和后端传统表单提交格式完全一致。

  1. 安装 qs
npm install qs --save
  1. 封装请求(推荐写法)
import axios from 'axios' 
import qs from 'qs' 

// 创建实例 
const request = axios.create({ 
  baseURL: '/api', 
  timeout: 5000 
}) 

// 请求拦截器:统一处理 POST 数据格式 
request.interceptors.request.use(config => { 
// 只对 POST 请求做处理 
    if (config.method === 'post' && config.data) { 
    // 将 JSON 对象转为 application/x-www-form-urlencoded 格式 
    config.data = qs.stringify(config.data) 
    // 同时修改 Content-Type 头(有些后端需要明确指定)
    config.headers['Content-Type'] = 'application/x-www-form-  urlencoded'
} 
  return config 
}, error => { 
  return Promise.reject(error) 
}) 
// 使用 
request.post('/login', { 
  username: 'admin', 
  password: '123456' 
})

发送的数据格式

username=admin&password=123456

后端可以直接用 $_POST['username']request.getParameter("username") 读取。

方案二:手动设置 FormData(适合上传文件或混合数据)

const formData = new FormData() 
formData.append('username', 'admin') 
formData.append('password', '123456') 

axios.post('/api/login', formData, { 
    headers: { 
        'Content-Type': 'multipart/form-data' 
    }
})

适用场景

  • 需要上传文件(File 对象)
  • 混合文本和文件数据

方案三:让后端支持 JSON 格式(现代项目首选)

如果后端是 Node.js(Express),可以直接用中间件解析 JSON:

// 后端 Express 示例 
const express = require('express') 
const app = express() 

// 解析 JSON 格式的请求体 
app.use(express.json()) 

// 现在就能直接读取 req.body.username 了 
app.post('/api/login', (req, res) => { 
    const { username, password } = req.body 
    res.send({ username, password }) 
})

前端代码就可以保持最简洁的写法:

axios.post('/api/login', { 
    username: 'admin', 
    password: '123456' 
})

四、总结:为什么说 “axios 有拦截功能”?

这句话其实是对 “默认行为” 的误解:

  • axios 并没有主动 “拦截” 你的数据,而是自动做了序列化
    • GET 请求:自动把参数拼到 URL 上。
    • POST 请求:默认把对象转为 JSON,并设置 Content-Type: application/json
  • 当后端不支持这种格式时,数据就 “传不过去”,看起来像是被拦截了。

最佳实践

  • 新项目:前后端统一使用 JSON 格式,后端配置 express.json() 等中间件。
  • 老项目:用 qs 统一转为表单格式,避免逐个接口处理。

React 核心深度解析:调度、协调与提交的闭环全解

2026年2月24日 15:21

React的更新机制

Trigger --> Schedule --> Render --> Commit

  • Trigger (触发):

setState触发状态变化

  • Schedule (调度):

根据优先级排队,决定什么时候开始更新

  • Render (协调/对比):

负责在内存中计算出一棵新的 Fiber 树(WIP 树),通过 Diff 算法找出所有需要变更的“标记(Flags)”

  • Commit (提交渲染):

把 Render Phase 的计算结果真正同步到真实 DOM

具体更新操作

Render
阶段一Render(协调
  • 特点: 异步、可中断。
  • 目标: 生成一颗新的 Fiber 树(WIP),并计算出需要更新的标记。
① 初始化阶段 (Initialization)
  • 核心函数:调用 prepareFreshStack
  • 做了什么
    • 创建 WIP 树的根节点:通过 createWorkInProgress(root.current, ...) 克隆出第一个 Fiber。
    • 设置全局变量 workInProgress:让它指向这个刚创建的根节点。此时,WIP 只有一个初始节点
② 下行阶段 (Downward - “铺路”与“标记”)
  • 核心函数beginWork
  • 做了什么这是 WIP 树生长的核心。
    • Diff 算法(协调) :对比当前节点的旧 Fiber 和新的 React Element。
    • 动态生成子节点:调用 reconcileChildFibers 现场创建出子 Fiber 节点,并将其连接到 WIP 树上。
    • 打上补丁标记 (Flags) :如果发现节点变了(比如文字改了、位置动了),就在这个 WIP 节点上标记 Placement(新增)或 Update(更新)。
    • 推进指针:返回刚建好的子节点。workLoop 会让指针跳到子节点上,循环执行下一轮 beginWork
③ 上行阶段 (Upward - “收尾”与“汇总”)
  • 核心函数completeWork
  • 触发时机:当 beginWork 返回 null(即当前分支已经修到叶子节点,没路了)时开始回溯。
  • 做了什么
    • 创建 DOM 实例:如果是第一次创建的节点,会调用 createElement 创建真实 DOM 并挂在 fiber.stateNode 上。
    • 属性初始化:处理 props,比如把 className 变成 DOM 的属性,但此时不挂载到页面上。
    • 副作用冒泡 (Flag Bubbling) :这是最重要的一步!它把子节点的 flags(变动标记)全部合并到父节点的 subtreeFlags 里。
    • 寻找兄弟:如果有兄弟节点,指针跳到兄弟节点,重新进入 ② 阶段;如果没有兄弟,继续向上回溯执行父节点的 completeWork
④ 完成阶段 (Completion)
  • 关键点:当 workInProgress 指针重新回到根节点并完成 completeWork 时,循环结束。
  • 结果:此时,内存中已经诞生了一棵完整的、被打满了变动标记的 WIP Fiber 树
  • 交接棒:React 将这棵树命名为 finishedWork,准备交给 Commit 阶段 去执行DOM 操作。

Commit
阶段二Commit(提交)
  • 特点: 同步、不可中断。
  • 目标: 操作真实 DOM,执行生命周期/Hooks 副作用。

当 Render Phase 结束,workLoop 退出,React 会拿着 WIP 树进入 Commit阶段。它会依次执行以下三个阶段

① Before Mutation 阶段 (DOM 变更前)
  • 核心函数: commitBeforeMutationEffects
  • 做了什么: 调用 getSnapshotBeforeUpdate 生命周期函数;处理 useEffect 的异步调度。
    • useEffect 仅在 Before Mutation 阶段被「调度」,实际异步执行在整个 Commit 阶段结束后
② Mutation 阶段 (DOM 变更)
  • 核心函数: commitMutationEffects
  • 做了什么: 真正操作 DOM。根据 Render 阶段打上的 Flags (Placement新增/移动, Update更新, Deletion删除) ,执行 appendChild, removeChild, commitUpdate。此时用户就能看到屏幕上的变化
③ Layout 阶段 (DOM 变更后)
  • 核心函数: commitLayoutEffects
  • 做了什么:
    • 同步生命周期/Hooks:此时真实 DOM 已经更新完毕。React 会执行 componentDidMount/Update 以及 useLayoutEffect
    • ref赋值:将最新的 DOM 实例赋值给你的 ref.current
    • 再次调度更新:如果在 useLayoutEffect 里又触发了 setState,会在这里再次发起一个新的调度任务
  • 关键点: 在这个阶段,root.current = finishedWork双缓存树的指针在这里正式完成切换。 WIP 树变成了 Current 树。

workLoop

WorkLoop 是驱动 Render 阶段不断向下执行的引擎。

// 摘自 ReactFiberWorkLoop.js
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  // 核心:只要有任务且浏览器没掉帧,就一直干活
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;
  let next;

  // 1. 【向下推导】:调用 beginWork,生成子 Fiber
  next = beginWork(current, unitOfWork, renderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // 2. 【向上回溯】:如果没有子节点了,说明当前分支到底了,执行 completeUnitOfWork
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

Fiber

Fiber 既是静态的数据结构,也是动态的工作单元

Fiber 是 React 为Render阶段设计的 「任务单元 + 数据结构」,本质是:

  1. 数据结构层面:Fiber 是一个「增强版虚拟 DOM 节点」,用双向链表(有父 / 子 / 兄弟指针)替代传统单向树,每个 Fiber 节点对应一个组件 / DOM 元素,记录了组件的更新状态、优先级、要执行的操作(比如新增 / 更新);
  2. 执行层面:Fiber 是「最小渲染任务单元」,把原来「一整块渲染任务」拆成无数个 Fiber 小任务,每个任务可独立执行、暂停、重启。
总结
  • Fiber 本质:双向链表结构的「任务单元 + 虚拟 DOM 节点」;
  • 核心作用 1:把渲染拆成可中断的小任务,解决页面卡顿;
  • 核心作用 2:支持任务优先级,保证用户交互优先执行;
  • 核心作用 3:支撑双缓存树,让 DOM 更新更高效、无闪烁。
// 摘自 ReactInternalTypes.js
function FiberNode(tag, pendingProps, key, mode) {
  // 1. 实例属性
  this.tag = tag;             // 标记组件类型 (Function/Class/Host)
  this.key = key;
  this.elementType = null;    // 大部分情况下同 type
  this.type = null;           // 具体的组件函数或 DOM 标签
  this.stateNode = null;      // 对应的真实 DOM 节点或 Class 实例

  // 2. 构成 Fiber 树的物理链表结构
  this.return = null;         // 父节点
  this.child = null;          // 第一个子节点
  this.sibling = null;        // 右侧第一个兄弟节点
  this.index = 0;

  // 3. 工作属性 (数据)
  this.pendingProps = pendingProps; // 新传入的 props
  this.memoizedProps = null;        // 上一次渲染的 props
  this.updateQueue = null;          // 状态更新队列 (存放 setState 的 action)
  this.memoizedState = null;        // 上一次渲染的 state (Hooks 存放在这)

  // 4. 副作用相关
  this.flags = NoFlags;             // 记录当前节点的增/删/改标记
  this.subtreeFlags = NoFlags;      // 子树的副作用标记 (性能优化关键)
  this.deletions = null;            // 待删除的子节点

  // 5. 优先级调度 (Lane 模型)
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // 6. 双缓存机制
  this.alternate = null;            // 指向 workInProgress 树或 current 树的对应节点
}

当前机制的弊端

React 的 beginWork 默认是贪婪的。只要父组件更新,React 无法确定子组件内部是否引用了会导致变化的数据,因此默认会重新执行所有子组件。

补救机制

React.memo

React.memo 是针对“组件级别”的拦截。

  • 原理:它将组件包装成一个特殊的 Fiber 类型(MemoComponent)。
  • 核心逻辑
    1. workLoop 走到被 memo 包裹的组件时,会触发 updateMemoComponent 函数。
    2. 它不再进行简单的引用比较,而是执行 shallowEqual (浅比较)
    3. 如果浅比较结果为 true(即所有 Props 的值都没变),它会立即执行 Bailout(跳过) 逻辑。
  • 效果:直接切断 beginWork 的向下递归,该组件及其子树完全不执行,直接复用上次的渲染结果。
const MemoChild = React.memo(function Child() {
  console.log("只有我的 Props 变了,你才能看到我打印");
  return <div>我是受保护的子组件</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>更新父组件</button>
      {/* 此时 beginWork 走到这里,会执行 shallowEqual,发现没变,直接跳过 */}
      <MemoChild /> 
    </div>
  );
}
useMemo

useMemo 是针对“计算逻辑/数据”的拦截。

  • 原理:在 Fiber 节点的 memoizedState 链表中开辟一块空间,用于持久化存储数据。
  • 核心逻辑
    1. 存储结构:它在内存中存储一个格式为 [value, deps] 的数组。
    2. 对比更新:每次组件执行时,它会取出上次存储的 deps 与当前的 deps 进行逐项对比。
    3. 拦截逻辑:如果 deps 没变,直接从内存读取 value 并返回,不再重新执行计算函数。
  • 效果:避免了昂贵的计算逻辑在每次渲染时重复运行。
function DataList({ items, filterText }) {
  // ❌ 错误示范:只要组件重绘(哪怕是因为 filterText 无关的更新),都会重新排序
  // const sortedItems = items.sort(); 

  // ✅ 正确示范:只有 items 变化时,才重新计算排序
  const sortedItems = useMemo(() => {
    console.log("正在执行高昂的排序计算...");
    return items.sort();
  }, [items]); // 依赖项检查

  return <div> { sortedItems } </div>;
}
useCallback

useCallback 是针对“函数引用”的拦截。

  • 原理:它是 useMemo 的一个变体(语法糖),专门用于缓存函数引用。
  • 核心逻辑
    1. 存储结构:在 memoizedState 中存储 [callback, deps]
    2. 引用稳定:只要依赖项(deps)不改变,它始终返回同一个函数的内存地址。
  • 为什么需要它
    • 它的核心存在意义是为了配合 React.memo
    • 如果父组件给子组件传了一个函数,不包裹 useCallback 的话,每次父组件渲染都会生成新函数,导致子组件的 React.memo 因为发现 props.onClick 引用变了而拦截失败。
const BigButton = React.memo(({ onClick }) => {
  console.log("BigButton 渲染了");
  return <button onClick={onClick}>大按钮</button>;
});

function App() {
  const [count, setCount] = useState(0);

  // ❌ 坑点:如果不包 useCallback,每次 App 更新都会生成一个新的 handleClick
  // 这会导致 BigButton 的 React.memo 浅比较失效(引用地址变了)
  // const handleClick = () => console.log("clicked");

  // ✅ 填坑:保证 handleClick 指向同一个内存地址,不会被setCount的更新影响
  const handleClick = useCallback(() => {
    console.log("clicked");
  }, []); 

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>触发更新App</button>
      <BigButton onClick={handleClick} />
    </div>
  );
}

总结:优化的本质

在 React 的 Fiber 架构中,所有的优化手段(memouseMemouseCallback)其实都在做同一件事:

通过牺牲一小部分内存(存储旧数据/旧引用),来换取在 beginWork 阶段执行 Bailout (跳过)的机会,从而减少 CPU 的计算负担。

vue3项目搭建基础

2026年2月24日 14:51

element-plus

安装依赖(确保版本适配 Vue 3)

npm install element-plus --save

plugins/element.js 实现按需引入组件

import { ElButton } from 'element-plus'  // 导入 Element Plus 的 Button(Vue 3 版本)
import 'element-plus/dist/index.css' //全局引入样式,避免找不到

 // 接收 main.js 传入的 app 实例
export default function setupElement(app) {
 // 注册 Button 组件(Vue 3 用 app.component 注册单个组件)
  app.component('ElButton', ElButton)
}

main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import setupElement from './plugins/element.js'

//创建应用实例
const app = createApp(App)
//注册 Element Plus 插件
setupElement(app)
app.use(router)
//挂载应用
app.mount('#app')

App.vue

<template>
  <div id="app">
        <el-button type="danger">Danger</el-button>
  </div>
</template>

<script>
export default {
  name: 'app'
}
</script>

<style>
        html,body,#app{
                width: 100%;
                height: 100%;
                padding: 0;
                margin: 0;
        }
</style>

代码是从上往下一行一行执行的

  • 执行 main.js中引入代码

  • import { createApp } from 'vue' → 从 Vue 库导入 createApp 方法;

  • import App from './App.vue' → 导入根组件 App.vue(仅加载,未渲染);

  • import router from './router' → 导入路由(如果有配置);

  • 当执行到import setupElement from './plugins/element.js'会立即进入 element.js 执行里面的代码,但注意:element.js 中只有「导入组件 + 定义函数」的逻辑会执行,app.component 注册组件的逻辑要等 main.js 中调用 setupElement(app) 时才执行。

从 Element Plus 加载按钮组件的代码;

执行 import 'element-plus/dist/index.css' → 加载 Element Plus 的全局样式(CSS 生效);

执行 export default function setupElement(app) {...} → 定义 setupElement 函数(仅定义,不执行函数内部代码);

执行完 element.js 后,将 setupElement 函数作为返回值,赋值给 main.js 中的 setupElement 变量

  • 执行 main.js 中的实例创建和注册逻辑

  • const app = createApp(App) → 创建 Vue 应用实例(此时还未挂载组件);

  • setupElement(app)调用 element.js 中定义的函数

    • 将 Vue 实例 app 传入函数,执行 app.component('ElButton', ElButton) → 全局注册 ElButton 组件;
  • app.use(router) → 注册路由(如果有);

  • app.mount('#app') → 将 Vue 实例挂载到页面的 #app 元素上:

    • 渲染 App.vue 组件;
    • 解析 App.vue 中的 <el-button> 标签 → 匹配到全局注册的 ElButton 组件 → 渲染出带样式的危险按钮。
import setupElement from './plugins/element.js'

//创建应用实例
const app = createApp(App)
//注册 Element Plus 插件
setupElement(app)

相当于这里要引用setupElement这个方法,创建实例时,调用setupElement这个方法,并将创建的实例当成参数传入,实现vue组件的注册。

简单说:setupElement 是一个 “组件注册工具函数”,调用它并传入 Vue 实例 app,本质就是把 ElButton 组件挂载到 Vue 应用实例上,这样整个项目的所有组件(比如 App.vue)都能使用 <el-button> 标签。

element.js

import { ElCard,ElCol,ElRow } from 'element-plus'
import 'element-plus/dist/index.css'

export default function setupElement(app) {
  app.component('ElCard', ElCard)
  app.component('ElCol', ElCol)
  app.component('ElRow', ElRow)

}

进行优化

import { ElCard,ElCol,ElRow } from 'element-plus'
import 'element-plus/dist/index.css'

export default function setupElement(app) {

    //批量注册
    const components = { ElCard, ElCol, ElRow }
    Object.entries(components).forEach(([name, component]) => {
      app.component(name, component)
    })
}

将需要注册的组件放入对象中,这里是简写语法。 const components = { ElCard, ElCol, ElRow }

const components = { ElCard: ElCard, ElCol: ElCol, ElRow: ElRow }

Object.entries(components)把对象转成「键值对数组」:转换成「二维数组」,每个子数组包含「键、值」

[     ['ElCard', ElCard], 
    ['ElCol', ElCol],
    ['ElRow', ElRow]
   ]

.forEach(...) → 遍历这个二维数组

([name, component]):ES6 数组解构,把遍历到的子数组 ['ElCard', ElCard] 拆成两个变量:

  • name = 'ElCard'(组件注册名);
  • component = ElCard(组件对象)

app.component(name, component):调用 Vue 3 的组件注册方法,等价于 app.component('ElCard', ElCard)

echarts

安装依赖

ESLint 相关的配置包版本太旧,和新版的 Vue ESLint 插件不兼容,连带导致 echarts 安装失败.直接在安装命令后加 --legacy-peer-deps,强制忽略版本冲突,快速安装 echarts

npm i -S echarts --legacy-peer-dep

正常安装

npm i -S echarts

彻底解决依赖冲突

如果想从根本上修复版本冲突,执行以下步骤(不影响已安装的 Element Plus):

步骤 1:卸载旧的 ESLint 配置包

bash

运行

npm uninstall @vue/eslint-config-standard --save-dev
步骤 2:安装兼容新版 eslint-plugin-vue@8.x 的配置包

bash

运行

npm install @vue/eslint-config-standard@latest --save-dev --legacy-peer-deps
步骤 3:重新安装 echarts

bash

运行

npm i -S echarts

main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import setupElement from './plugins/element.js'
import * as echarts from 'echarts'

const app = createApp(App)
setupElement(app)
app.config.globalProperties.$echarts = echarts
app.use(router)
app.mount('#app')

Vue 3 中移除 Vue.prototype,改用 app.config.globalProperties 的本质是:Vue 3 不再基于构造函数原型链扩展,而是基于应用实例的全局配置来挂载全局属性 / 方法,更贴合 Vue 3 的「实例化」设计(每个 createApp() 都是独立的应用实例)。app.config.globalProperties 挂载到这个对象上的属性 / 方法,会被注入到所有组件的「选项式 API」上下文(即 this)中,效果和 Vue 2 的 Vue.prototype 完全一致,但作用域仅限当前应用实例。

// 创建应用实例 
const app = createApp(App) 
// 挂载全局属性/方法 
app.config.globalProperties.自定义属性名 = 要挂载的内容


特性 Vue 2 Vue 3
核心载体 全局构造函数 Vue(所有组件共享同一个原型链) 应用实例 appcreateApp() 创建,多实例隔离)
全局挂载 Vue.prototype.$xxx = xxx(挂载到构造函数原型,全局共享) app.config.globalProperties.$xxx = xxx(挂载到单个应用实例,实例隔离)
  • 多实例隔离:Vue 3 支持一个页面创建多个独立的 Vue 应用实例(比如 app1 = createApp(), app2 = createApp()),如果用原型链,会导致多个实例的全局属性互相污染;而 globalProperties 是绑定到单个 app 实例的,不同实例的全局属性互不影响。

  • 更符合模块化:Vue 3 推崇「模块化」「按需使用」,原型链扩展是侵入式的(修改全局构造函数),而 globalProperties 是配置式的(仅修改当前应用实例),更灵活。

import * as echarts from 'echarts'

import * as echarts from 'echarts' 的作用是:把 echarts 库中所有导出的内容,整体导入并挂载到 echarts 这个变量上。这么写的根本原因是:echarts v5+ 采用「命名导出」(Named Export),而非「默认导出」(Default Export),所以不能用 import echarts from 'echarts' 这种默认导入方式

导出方式 语法示例(库作者写的代码) 导入方式(你写的代码) 适用场景
默认导出(Default Export) export default { init: () => {} } import echarts from 'echarts' 库只有一个核心导出(比如 Vue、React)
命名导出(Named Export) export const init = () => {}``export const dispose = () => {} import * as echarts from 'echarts'import { init } from 'echarts' 库有多个独立导出(比如 echarts、lodash)

echarts 源码的核心导出逻辑类似这样(简化版):

// echarts 源码中的导出逻辑(模拟)
export const init = (dom) => { /* 初始化图表 */ }
export const dispose = (instance) => { /* 销毁图表 */ }
export const registerMap = (name, data) => { /* 注册地图 */ }
// ... 还有上百个命名导出的方法/对象

:echarts 没有写 export default,只有一堆 export const/function 的「命名导出」—— 这就是为什么你用 import echarts from 'echarts' 会报错(找不到默认导出)

import * as echarts from 'echarts' 语法拆解

语法片段 含义
import * 导入目标模块中所有命名导出的内容(init/dispose/registerMap 等)
as echarts 把这些导入的内容,统一挂载到一个名为 echarts命名空间对象
from 'echarts' echarts 这个模块导入

相当于给 echarts 库的所有导出内容做了一个 “收纳箱”,箱子名字叫 echarts

  • 原本分散的 init 方法 → 现在是 echarts.init
  • 原本分散的 dispose 方法 → 现在是 echarts.dispose
  • 所有 echarts 的功能,都可以通过 echarts.xxx 访问,既整洁又不会污染全局变量。

如果想只导入部分方法,也可以写:

// 只导入 init 方法(命名导出的精准导入) 
import { init } from 'echarts'
// 使用时直接写 init(),而非 echarts.init() 
const chart = init(document.getElementById('chart'))

echarts 极致按需导入

// 极致按需导入(推荐生产环境用)
import { init } from 'echarts/core'
import { BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

// 注册需要的模块
init.use([BarChart, TitleComponent, TooltipComponent, CanvasRenderer])

// 使用 init 方法创建图表
const chart = init(document.getElementById('chart'))

echarts使用

<div ref="totalOrderRef" :style="{width:'100%',height:'100px'}" />

<script setup>
        import { onMounted,ref,getCurrentInstance } from 'vue'
        // 1. 定义 ref 变量,和模板中的 ref="totalOrderRef" 对应
        const totalOrderRef = ref(null)
        // 2. 在 onMounted 中获取元素(DOM 渲染完成)
        onMounted(() => {
          // 通过 ref 获取元素(Vue3 推荐方式)
          const chartDom = totalOrderRef.value
          if (chartDom) {
                 const instance = getCurrentInstance()
                 const $echarts = instance.appContext.config.globalProperties.$echarts
                 const chart = $echarts.init(chartDom)
                 chart.setOption({
                         xAxis: {
                                 type: 'value',
                                 show: false
                         },
                         yAxis: {
                                 type: 'category',
                                 show: false
                         },
                         series: [{
                                name: '上月平台用户数',
                                type: 'bar',
                                stack: '总量',
                                data: [300],
                                barWidth: 10,
                                itemStyle: {
                                        color: '#45c946'
                                }
                         },{
                                name: '今日平台用户数',
                                type: 'bar',
                                stack: '总量',
                                data: [200],
                                itemStyle: {
                                        color: '#ddd'
                                }
                         },{
                                 type: 'custom',
                                 stack: '总量',
                                 data: [300],
                                 renderItem: (params,api) => {
                                        const value = api.value(0)
                                        const point = api.coord([value,0])
                                        return {
                                                type: 'group',
                                                position: [point[0] - 10, point[1]] ,
                                                children: [{
                                                        type: 'path',
                                                        shape: {
                                                                d: 'M0 767.909l512.029-511.913L1024 767.909 0 767.909z',
                                                                x: 0,
                                                                y: 2,
                                                                width: 20,
                                                                height: 20
                                                        },
                                                        style: {
                                                                fill: '#45c946'
                                                        }
                                                },{
                                                        type: 'path',
                                                        shape: {
                                                                d: 'M1024 255.996 511.971 767.909 0 255.996 1024 255.996z',
                                                                x: 0,
                                                                y: -22,
                                                                width: 20,
                                                                height: 20
                                                        },
                                                        style: {
                                                                fill: '#45c946'
                                                        }
                                                }]
                                        }
                                 }
                         }],
                         grid: {
                                top: 0,
                                bottom: 0,
                                left: 0,
                                right: 0
                         }
                 })
          }
        })
</script>



getCurrentInstance()

<script setup> 中 “接触” 到组件底层实例的唯一官方入口(<script setup> 是封闭作用域,无法直接访问 this,所以无法像 Vue2 那样用 this.$echarts,只能通过 getCurrentInstance() 获取 appContext(应用上下文),进而访问全局挂载的 $echarts。必须在组件生命周期内调用,onMountedonCreated<script setup> 顶层调用。异步回调(如 setTimeout、接口请求回调)中调用可能获取不到实例。

创建 ECharts 实例

$echarts.init() 方法会创建一个独立的 ECharts 实例,并返回给变量 chart。这个实例是操作图表的 “唯一入口”

  • 每个 DOM 容器对应一个独立的 ECharts 实例(避免多个图表冲突);

  • 实例包含图表的所有配置、数据、渲染状态等核心信息。

setOption

向 ECharts 实例传入图表的配置项(Option),让 ECharts 根据配置渲染 / 更新图表

    chart.setOption({
      xAxis: { type: 'value', show: false }, // x轴配置
      yAxis: { type: 'category', show: false }, // y轴配置
      series: [/* 柱状图/自定义图形系列配置 */], // 图表数据和样式
      grid: { top: 0, bottom: 0 } // 网格布局
    })

配置合并规则

   // 完全替换旧配置,重新渲染图表 
   chart.setOption(newOption, true)            
  • 默认:setOption合并新旧配置(新配置覆盖旧配置,未修改的保留);

  • 强制替换:如需完全替换配置(而非合并),可传第二个参数 true

renderItem

它是 ECharts 「自定义系列(custom series)」的核心渲染函数,作用是「告诉 ECharts 如何手动绘制每一个数据项的图形」

renderItem 是自定义系列(type: 'custom')的必填配置,ECharts 渲染自定义系列时,会对每一个数据项调用一次 renderItem,你需要在这个函数中返回「图形描述对象」,ECharts 会根据这个描述画出对应的图形。

当 ECharts 渲染 type: 'custom' 的系列时,会遍历该系列的 data 数组(比如写的 data: [200]),对每一个数据项(这里是 200)执行一次 renderItem 函数。

通过 renderItem,你可以突破 ECharts 内置图表(如柱状图、折线图)的限制,实现:

  • 自定义形状(如三角形、五角星、不规则路径);
  • 精准控制图形位置(如定位到 200 数值处);
  • 组合多个图形(如一个三角形 + 一个文本标签);
  • 动态调整图形样式(如根据数据值改变颜色 / 大小)。

api对象提供 ECharts 内置的「坐标转换 / 数据获取」工具方法:

api.value(dimIndex):获取当前数据项指定维度的值(如 api.value(0) 取 200);

api.coord([x, y]):把逻辑坐标(如 [200, 0])转换成画布像素坐标;

api.size([width, height]):把逻辑尺寸转换成像素尺寸;

api.style():获取系列默认样式

params对象包含当前数据项的上下文信息:

params.dataIndex:当前数据项的索引(如 0)

params.value:当前数据项的原始值(如 200);

params.seriesIndex:当前系列的索引;

params.coordSys:坐标系信息(如 x/y 轴类型)

renderItem 必须返回一个「图形描述对象」(或数组),ECharts 会根据这个对象绘制图形

    return {
     type: 'group', // 图形类型:组合图形(可包含多个子图形)
     position: point, // 图形的基准位置(像素坐标)
     children: [{ // 子图形列表
       type: 'path', // 图形类型:路径(自定义形状)
       shape: { // 形状配置
         d: 'M0 767.909...', // 路径指令(三角形的绘制路径)
         x: 0,//路径的偏移
         y: 0, //路径的偏移
         width: 20, // 尺寸
         height: 20 // 尺寸
       },
       style: { fill: 'red' } // 样式:填充红色
     }]
   }
类型 作用 示例场景
path 绘制自定义路径(如三角形、不规则图形) 你画红色三角形的核心
rect 绘制矩形 自定义柱状图
circle 绘制圆形 自定义散点图
text 绘制文本 给图形加标签
group 组合多个图形 三角形 + 文本标签

和前面用 type: 'bar' 画柱状图,ECharts 会自动渲染;而 type: 'custom' 则是把渲染权完全交给你,核心差异

维度 内置系列(bar/line) 自定义系列(custom)
渲染逻辑 ECharts 自动绘制(固定形状) 你通过 renderItem 手动定义
灵活性 低(只能改样式,不能改形状) 高(可画任意形状)
复杂度 简单(只需配置 data/style) 稍高(需手动计算坐标 / 形状)
适用场景 标准图表(柱状图、折线图) 非标准图形(自定义标记、组合图形)

配置

stack

它是 ECharts 中用于实现「堆叠式图表」的关键配置,作用是「将多个同系列类型(如 bar)、同 stack 值的系列,在同一类目下按数值累加堆叠显示」 —— 两个柱状图系列因为都配置了 stack: '总量',才会从 0 开始依次累加(200+260),形成绿色 + 灰色的分段堆叠效果

组件引用

把导入的 TopView 组件 “注册” 到 Home 组件的作用域中,只有注册后,才能在 <template> 中使用;

  <template>
      <div class="home">
              <!-- 引用组件 -->
              <top-view/>  
      </div>
</template>

<script>
        import TopView from '../components/TopView'
        export default {
                name: 'Home',
                components: {
                        TopView,// 局部注册组件
                }
        }
</script>

<style>
        .home{
                width: 100%;
                height: 100%;
                padding: 0 20px;
                background: #eee;
                box-sizing: border-box;
        }
</style>

仅在当前 Home 组件内可用,其他组件(如 About.vue)不能直接用 <top-view>,需重新导入 + 注册。不会污染全局作用域,适合只在单个页面使用的组件

公共组件提取

如CommonCard这个组件很多地方用到,进行提取

创建一个mixins文件夹,创建对应的文件名card.js

import CommonCard from '../components/CommonCard/index'
export default {
        components: {
                CommonCard
        }
}

需要调用时引入card.js

<template>
        <common-card/>
</template>

<script>
        import CommonCard from '../../mixins/card'
        export default {
                mixins: [CommonCard]
        }
</script>

<style>
</style>

vue3调用公共组件

<template>
        <common-card
                title="累计订单量"
                value="2,124,223"
        >
        </common-card>
</template>

<script setup>
        import { onMounted, ref,defineOptions } from 'vue'
        import commonCardMixin from '../../mixins/commonCardMixin'
        defineOptions({
          mixins: [commonCardMixin]
        })

</script>

需要使用defineOptions

Mixin 是 Vue2 的经典写法,Vue3 更推荐用 “组件导入函数”“全局注册组件” 替代 mixin(减少隐式依赖)

// utils/importComponents.js
export const useCommonCard = () => {
  const CommonCard = defineAsyncComponent(() => import('../components/CommonCard/index'))
  return { CommonCard }
}

业务组件中使用:

 <script setup>
    import { useCommonCard } from '../../utils/importComponents'
    const { CommonCard } = useCommonCard() // 显式获取组件
</script>

全局注册组件(适合全项目高频使用的组件)

// main.js
import { createApp } from 'vue'
import CommonCard from './components/CommonCard/index'

const app = createApp(App)
app.component('CommonCard', CommonCard) // 全局注册,所有组件可直接使用

插槽

引用子组件common-card时,自定义两个插槽的内容及样式。下面代码存在有误地方,

    <template>
      <div class="compare-wrapper">
        <div class="compare">
          <span>同比</span>
          <span class="emphasis">7.3%</span>
        </div>
      </div> 
    </template>

这段代码无法正常显示出默认插槽中的内容,<template> 无任何指令(如 v-slot)直接包裹默认插槽 → 编译时会被忽略,内容不渲染.

父组件:
<template>
  <common-card 
    title="销售额"
  >

    <template>
      <div class="compare-wrapper">
        <div class="compare">
          <span>同比</span>
          <span class="emphasis">7.3%</span>
        </div>
      </div> 
    </template>
    <!-- footer 具名插槽 -->
    <template v-slot:footer>
      <span>昨日销售额:</span>
      <span class="money">¥40,123</span>
    </template> 
  </common-card>
</template>

子组件:
<template>
<div class="common-card">
<div class="title">{{title}}</div>
<div>
                <slot></slot>
</div>
<div class="total">
    <slot name="footer"></slot>
</div>
</div>
</template>

<template>...</template>,Vue 不知道这是 “默认插槽”,就不会把内容插入到 <slot></slot> 位置;加 v-slot 指令后,Vue 才会识别并渲染。

    <template v-slot>
      <div class="compare-wrapper">
        <div class="compare">
          <span>同比</span>
          <span class="emphasis">7.3%</span>
        </div>
      </div> 
    </template>
    

或者不需要template包裹

        <div class="compare-wrapper">
        <div class="compare">
          <span>同比</span>
          <span class="emphasis">7.3%</span>
        </div>
      </div> 
      
写法 是否生效 原因
<template>内容</template> ❌ 不生效 Vue 无法识别这是默认插槽,编译时忽略该 template
<template v-slot>内容</template> ✅ 生效 v-slot 指令明确标识这是「默认插槽」,Vue 会把内容插入到 <slot></slot> 位置
直接写内容(无 template) ✅ 生效 Vue 自动把未包裹的内容识别为默认插槽,是最简洁的写法

Vue2 和 Vue3 在「<template> 包裹默认插槽」的语法上确实有差别 ——Vue2 中 <template> 包裹默认插槽无需加 v-slot 就能生效,而 Vue3 必须显式加 v-slot 指令

场景 Vue2 写法(生效) Vue3 写法(生效) Vue3 错误写法(不生效)
template 包裹默认插槽 <template>内容</template> <template v-slot>内容</template> <template>内容</template>
直接写默认插槽内容 直接写内容(生效) 直接写内容(生效) -
具名插槽 <template slot="footer"> / <template v-slot:footer> <template #footer> / <template v-slot:footer> -

vue3这样都通过 v-slot 指令管理,逻辑更清晰,也避免了 “无指令 template 被误解析” 的问题。

slot作用

Vue3 中的插槽,本质是组件对外暴露的 “自定义渲染接口” —— 组件开发者给组件预留 “空白位置”(<slot>),组件使用者可以往这个位置插入任意内容(HTML、子组件、逻辑渲染的内容),实现「组件固定结构复用 + 自定义内容灵活定制」。

插槽就像你买的 “定制化蛋糕胚”,蛋糕胚的大小、形状(组件的基础 UI / 逻辑)是固定的,但你可以往上面加水果、奶油、巧克力(插槽内容),做出不同样式的蛋糕,而不用重新做蛋糕胚。

复用性:

没有插槽的组件,只能显示固定内容

<!-- 无插槽的 CommonCard 组件 -->
<template>
  <div class="card">
    <div class="title">累计销售额</div>
    <div class="content">¥1,211,312</div>
  </div>
</template>

有插槽,复用性拉满

<!-- 有插槽的 CommonCard 组件 -->
<template>
  <div class="card">
    <div class="title">{{ title }}</div>
    <!-- 插槽:预留自定义位置 -->
    <div class="content">
      <slot></slot>
    </div>
  </div>
</template>
<script>
export default { props: ['title'] }
</script>

可自定义内容,同一个组件适配多场景

<!-- 场景1:显示销售额 -->
<common-card title="累计销售额">
  <div>¥1,211,312</div>
</common-card>

<!-- 场景2:显示订单数 -->
<common-card title="累计订单数">
  <div>123,456 单</div>
</common-card>

<!-- 场景3:显示复杂内容(同比数据+箭头) -->
<common-card title="累计销售额">
  <div class="compare">
    <span>日同比 7.3%</span>
    <div class="arrow-up"></div>
  </div>
</common-card>


逻辑与 UI 解耦,降低维护成本,比如修改卡片的基础样式只需改 CommonCard,修改销售额的显示格式只需改插槽内容。

插槽可以插入任意内容:

  • 普通 HTML 标签(<div>/<span>);
  • 其他 Vue 组件(<el-button>/<chart>);
  • 带逻辑的渲染内容(v-if/v-for/{{ 变量 }});
  • 甚至是 JSX/TSX(复杂场景)。

Vue3 把插槽分为 3 类,覆盖所有自定义场景,且废弃了 Vue2 的 slot 属性,统一用 v-slot 指令(简写 #

1. 默认插槽(匿名插槽)

  • 定义:无 name 属性的 <slot>,是组件的 “默认自定义位置”;

  • 用法

    • 简洁写法(推荐):直接把内容写在组件标签内,无需 <template>
    • 完整写法:用 <template v-slot> 包裹(Vue3 必须加 v-slot)。

2. 具名插槽

  • 定义:有 name 属性的 <slot>,用于组件内多位置自定义(比如卡片的 header、body、footer);

  • 用法:用 <template #插槽名>(或 <template v-slot:插槽名>)包裹内容,和组件内的 name 一一对应。

    <!-- 组件内定义多个具名插槽 -->
    <template>
      <div class="card">
        <div class="header">
          <slot name="header"></slot> <!-- 具名插槽:header -->
        </div>
        <div class="body">
          <slot></slot> <!-- 默认插槽 -->
        </div>
        <div class="footer">
          <slot name="footer"></slot> <!-- 具名插槽:footer -->
        </div>
      </div>
    </template>
    
    <!-- 使用具名插槽 -->
    <common-card>
      <template #header>
        <h3>累计销售额</h3> <!-- 对应 header 插槽 -->
      </template>
      <div>¥1,211,312</div> <!-- 对应默认插槽 -->
      <template #footer>
        <span>昨日销售额:¥40,123</span> <!-- 对应 footer 插槽 -->
      </template>
    </common-card>
    
    

3. 作用域插槽(带数据的插槽)

  • 定义:组件可以给插槽传递数据(作用域数据),使用者可以接收并基于这些数据自定义渲染 —— 这是插槽的 “高级玩法”,实现「组件传数据 + 使用者自定义渲染」;

  • 用法

    1. 组件内:给 <slot> 绑定属性(:数据名="数据值");

    2. 使用者:用 <template v-slot="插槽变量"> 接收数据,在插槽内使用。

      <!-- 组件内定义作用域插槽(给插槽传数据) -->
      <template>
        <div class="card">
          <!-- 给默认插槽传数据:salesData -->
          <slot :salesData="sales"></slot>
          <!-- 给 footer 插槽传数据:yesterdaySales -->
          <slot name="footer" :yesterdaySales="yesterday"></slot>
        </div>
      </template>
      <script>
      export default {
        data() {
          return {
            sales: { total: 1211312, dayRatio: 7.3 }, // 组件内部数据
            yesterday: 40123
          }
        }
      }
      </script>
      
      <!-- 使用作用域插槽(接收并使用数据) -->
      <common-card>
        <!-- 接收默认插槽的 data,自定义渲染 -->
        <template v-slot="slotProps">
          <div>
            累计销售额:¥{{ slotProps.salesData.total.toLocaleString() }}
            <span>日同比:{{ slotProps.salesData.dayRatio }}%</span>
          </div>
        </template>
        <!-- 接收 footer 插槽的 data,自定义渲染 -->
        <template #footer="footerProps">
          <span>昨日销售额:¥{{ footerProps.yesterdaySales.toLocaleString() }}</span>
        </template>
      </common-card>
      

Vue3 支持对插槽变量解构,让代码更简洁:

        <!-- 解构默认插槽数据 -->
            <template v-slot="{ salesData }">
              <div>累计销售额:¥{{ salesData.total }}</div>
            </template>

            <!-- 解构具名插槽数据 + 重命名 -->
            <template #footer="{ yesterdaySales: ys }">
              <span>昨日销售额:¥{{ ys }}</span>
            </template>  
            

4.支持动态插槽名

可以用变量作为插槽名

<template #[dynamicSlotName]>
  <div>动态插槽内容</div>
</template>
<script>
export default {
  data() {
    return { dynamicSlotName: 'footer' } // 动态指定插槽名
  }
}
</script>

插槽的实战场景

数据可视化组件:封装图表组件时,用作用域插槽传递图表数据,使用者自定义 tooltip、图例的显示样式

列表渲染组件:装通用列表组件时,用作用域插槽传递列表项数据,使用者自定义列表项的渲染样式

通用组件封装:封装卡片、表格、弹窗、导航栏等通用组件时,用插槽预留自定义位置

vue-echarts

vue-echarts 完全兼容 Vue3,且专门为 Vue3 做了适配(支持 <script setup>、组合式 API 等),相比直接使用原生 ECharts,它的优势是:

  • 自动处理 ECharts 实例的创建 / 销毁,避免内存泄漏;
  • 响应式更新配置,数据变化时自动重绘图表;
  • 无需手动获取 DOM 元素,直接通过组件属性传参。
    npm install echarts vue-echarts -S

    npm install vue-echarts echarts --save

全局注册

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import setupElement from './plugins/element.js'

// 1. 核心补充:按需导入 ECharts 核心模块(必填)
import { use } from 'echarts/core'
// 导入你需要的图表类型(根据业务场景添加,比如柱状图、自定义系列)
import { BarChart, CustomChart, LineChart } from 'echarts/charts'
// 导入渲染器(必须,推荐 CanvasRenderer)
import { CanvasRenderer } from 'echarts/renderers'
// 可选:导入交互组件(如提示框、图例,按需添加)
import { TooltipComponent, LegendComponent } from 'echarts/components'

// 2. 核心补充:注册 ECharts 模块(必填)
use([
  BarChart, CustomChart, LineChart, // 图表类型
  CanvasRenderer,                  // 渲染器(必须)
  TooltipComponent, LegendComponent // 可选交互组件
])

// 3. 导入 vue-echarts 组件(你的原有代码)
import VueECharts from 'vue-echarts'

const app = createApp(App)
setupElement(app)

// 4. 全局注册 <v-chart> 组件(你的原有代码,正确)
app.component('v-chart', VueECharts)

app.use(router)
app.mount('#app')    

v-echarts

npm i v-charts echarts -S

或安装(直接执行以下命令,纠正包名 + 忽略 peer 依赖冲突

npm install vue-echarts echarts -S --legacy-peer-deps

vue2和vue3支持状态

维度 v-charts(第三方) vue-echarts(官方)
Vue3 适配 ❌ 无官方支持 ✅ 完全适配(v6+ 版本专为 Vue3 设计)
维护状态 ❌ 停止维护 ✅ 持续更新,和 ECharts 版本同步
打包体积 ❌ 内置全量 ECharts,体积大 ✅ 按需导入模块,体积可控
功能完整性 ❌ 仅封装部分 ECharts 功能 ✅ 支持 ECharts 所有功能(包括自定义系列、交互)
文档 / 社区 ❌ 文档陈旧,无社区支持 ✅ 官方文档完善,问题可在 ECharts 社区解决

v-charts(第三方)和 vue-echarts(官方),前者是 Vue2 专属,后者是 Vue3 首选

Element Plus

el-menu

<el-menu> 组件的基础使用方式,用于实现水平导航菜单(如页面顶部的销售额 / 访问量切换)

<el-menu 
  mode="horizontal" 
  :default-active="'1'"
  @select="pnSelect"
>
配置项 类型 作用 & 意义
mode="horizontal" 字符串 定义菜单的布局模式: horizontal:水平布局(顶部导航); vertical:垂直布局(侧边栏); inline:内嵌布局(侧边栏子菜单展开)
:default-active="'1'" 字符串 / 数字(响应式绑定) 设置菜单的默认选中项,值需和 <el-menu-item>index 匹配;注意:index 本质是字符串,所以这里用 '1'(加引号)更规范(也可写 1,Element Plus 会自动转换) 默认选中 “销售额”(index="1"),页面加载后该菜单项会高亮
@select="pnSelect" 事件绑定 监听菜单选中事件:点击 <el-menu-item> 时触发,回调函数会接收当前选中项的 index 点击 “销售额”/“访问量” 时,pnSelect 方法会拿到 index(1/2),用于后续业务逻辑(如切换图表数据)

react图解源码之初始化挂载

作者 攀攀大大
2026年2月24日 14:19

react内部的初始化挂载

现在下面有如下页面渲染代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>React in HTML</title>
    <script src="./react.development.js"></script>
    <script src="./react-dom.development.js"></script>
    <script crossorigin src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<style>
  .component {
    border: 1px solid #ccc;
    padding: 10px;
    margin: 10px;
  }
</style>
<body>
  <div id="root"></div>

  <!-- 使用 type="text/babel" 让 Babel 编译 JSX -->
  <script type="text/babel">

    function A() {
      return (
        <div className="component" data-name="A">
          <div>A</div>
          <div>B</div>
        </div>
      );
    }


    // 创建 React 组件
    function App() {
      return (
        <A />
      );
    }

    // 渲染组件到 DOM
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<App />);
  </script>
</body>
</html>

Fiber结构介绍

上面的代码大概是如下的层级结构:

<App>
    <A>
        <div>
            <div>A</div>
            <div>B</div>
        </div>
    </A>
</App>

上面的结构在React中会构建如下图的一个FiberTree

image-2.png

从上图中可以看到,在该FiberTree中一共包含2种不同的类型:

  • FiberRootNode: FiberRootNode 是一个特殊节点,充当 React 的根节点,它保存着整个应用程序所需的元数据。其 current 属性指向实际的 Fiber 树结构,每次构建新的 Fiber 树时,它都会将 current 重新指向新的 HostRoot
  • FiberNode: react内部中对结点的一种表示,包含很多属性可以对其结点进行描述
    • tag: FiberNode有许多不同的子类型,在render以及commit阶段会根据该值进行不同的处理,如HostRootFunctionComponentClassComponentHostComponent等等
    • stateNode: 对于tagHostComponetFiberNode,其指向页面中实际渲染的DOM节点
    • childsiblingreturn: 分别指向子节点,兄弟节点以及父节点,用于构造完整Fiber
    • flags: 用于表示在 commit 阶段需要更新的类型。subtreeFlags 表示其子树需要更新的类型

上面对于FiberNode的属性介绍只包含当前初始化页面所需要的属性,其他属性在后面需要用到时再进行解释。

大致过程

我们先来把整个渲染过程进行一个粗略的介绍,我们在渲染页面时,会执行下面的代码

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

上面的代码中,分为了以下两个主要过程:

  1. createRoot
  2. render

createRoot

上面的createRoot会创建一个FiberRootNode

function createRoot(container, options) {
    var root = createContainer(container, ConcurrentRoot, null, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError);
    return new ReactDOMRoot(root);
  }

function createContainer(containerInfo, tag, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError, transitionCallbacks) {
    var hydrate = false;
    var initialChildren = null;
    return createFiberRoot(containerInfo, tag, hydrate, initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError);
  }


function createFiberRoot(containerInfo, tag, hydrate, initialChildren, hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError, transitionCallbacks) {
    var root = new FiberRootNode(containerInfo, tag, hydrate, identifierPrefix, onRecoverableError);
    var uninitializedFiber = createHostRootFiber(tag, isStrictMode);
    root.current = uninitializedFiber;
    uninitializedFiber.stateNode = root;
    return root;
  }

上面代码的大致执行流程如下:

image-13.png

执行完成后,会创建一个FiberRootNode,保存在ReactDomRoot实例的this._internalRoot中,充当 React 的根节点,它保存着整个应用程序所需的元数据。其 current 属性指向实际的 Fiber 树结构,每次构建新的 Fiber 树时,它都会将 current 重新指向新的 HostRoot。生成如下FiberTree结构:

image-8.png

当前的root属性如下:

image-4.png

上图中的root为当前ReactDOMRoot的实例,内部_internalRoot为当前实例的FiberRootNode,其current指向当前页面的FiberNode节点。其中tag的值为3,表示FiberNode的节点类型为HostRoot

image-5.png

render

render过程中主要执行了下面内容:

ReactDOMRoot.prototype.render = function (children) {
  var root = this._internalRoot;
  updateContainer(children, root, null, null);
};

function updateContainer(element, container, parentComponent, callback) {
  // some other code...
  var update = createUpdate(eventTime, lane);
  update.payload = {
    element: element
  };
  callback = callback === undefined ? null : callback;
  var root = enqueueUpdate(current$1, update, lane);

  if (root !== null) {
    // 进入调度器 schedule
    scheduleUpdateOnFiber(root, current$1, lane, eventTime);
  }
  return lane;
}

function scheduleUpdateOnFiber(root, fiber, lane, eventTime) {
  if ((executionContext & RenderContext) !== NoLanes && root === workInProgressRoot) {
    // some code...
  } else {
    // 确保FiberRootNode被调度
    ensureRootIsScheduled(root, eventTime);
  }
}

function ensureRootIsScheduled(root, currentTime) {
  // some code...
  // 获取更新优先级,拿取最高级别的优先级任务进行执行
   scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
}

function performSyncWorkOnRoot(root) {
    var exitStatus = renderRootSync(root, lanes);
    var finishedWork = root.current.alternate;
    root.finishedWork = finishedWork;
    root.finishedLanes = lanes;
    commitRoot(root, workInProgressRootRecoverableErrors, workInProgressTransitions);
    return null;
  }

上面的代码可以概括为下面的流程图:

image-6.png

其中scheduleUpdateOnFiberensureRootIsScheduled以及scheduleSyncCallback都是调度相关的函数,本章的重点是渲染,先暂时跳过这些内容,后期调度相关会详细讲解。渲染相关的核心函数为performSyncWorkOnRoot,以及performConcurrentWorkOnRoot函数,performSyncWorkOnRoot同步模式performConcurrentWorkOnRoot并发模式

同步模式
performSyncWorkOnRoot
    ↓
renderRootSync  // 同步渲染根节点
    ↓
workLoopSync    // 同步工作循环(不可中断)
    ↓
completeUnitWork // 完成单元工作
    ↓
commitRoot      // 提交变更到DOM
并发模式
performConcurrentWorkOnRoot
    ↓
renderRootConcurrent  // 并发渲染根节点
    ↓
workLoopConcurrent    // 并发工作循环(可中断)
    ↓
shouldYield? → 是 → 暂停并返回
    ↓
completeUnitWork
    ↓
commitRoot

上面的同步模式与并发模式的主要区别为渲染根节点的过程,同步模式创建FiberTree的过程不可中断并发模式则可以被高优先级任务中断,而commitRoot过程则是一致的,都是同步执行。

上面代码可以看到render函数中执行了renderRootSynccommitRoot两个函数,也是React中比较重要的两个部分,一个是创建FiberNode以及对应的stateNodeflagssubtreeFlags,并生成FiberTree,另一个是根据前面创建的FiberTree,获取对应节点的flags以及subtreeFlags来进行对应的DOM节点挂载操作。

📢 注意:由于commitRoot一次执行完成的挂载过程,为了避免浏览器产生闪烁以及重绘等,React内部进行了优化,也就是在rendercomplete阶段,就将子树的整个DOM树构建完成了,并将其对应的flags冒泡到父节点的subtreeFlags属性中,后续在commit阶段直接比较该属性进行相应的子树挂载即可。下面会进行详细的解析:

renderRootSync

renderRootSync函数中的核心为do...while(true)的执行workLoopSync函数

function renderRootSync(root, lanes) {
  prepareFreshStack(root, lanes);
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);
  workInProgressRoot = null;
  workInProgressRootRenderLanes = NoLanes;

  return workInProgressRootExitStatus;
}

function prepareFreshStack(root, lanes) {
  root.finishedWork = null;
  root.finishedLanes = NoLanes;

  workInProgressRoot = root;
  var rootWorkInProgress = createWorkInProgress(root.current, null);
  workInProgress = rootWorkInProgress;
  finishQueueingConcurrentUpdates();
  return rootWorkInProgress;
}

function createWorkInProgress(current, pendingProps) {
  var workInProgress = current.alternate;

  if (workInProgress === null) {
    // We use a double buffering pooling technique because we know that we'll
    // only ever need at most two versions of a tree. We pool the "other" unused
    // node that we're free to reuse. This is lazily created to avoid allocating
    // extra objects for things that are never updated. It also allow us to
    // reclaim the extra memory if needed.
    // 双缓存机制,创建 alternate
    workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;

    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    workInProgress.pendingProps = pendingProps;
    workInProgress.type = current.type;
    workInProgress.flags = NoFlags;
    workInProgress.subtreeFlags = NoFlags;
    workInProgress.deletions = null;
  }


  workInProgress.flags = current.flags & StaticMask;
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;
  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  var currentDependencies = current.dependencies;
  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;
  workInProgress.ref = current.ref;

  return workInProgress;
}
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  var current = unitOfWork.alternate;
  setCurrentFiber(unitOfWork);
  var next;

  if ( (unitOfWork.mode & ProfileMode) !== NoMode) {
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
  } else {
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
  }

  resetCurrentFiber();
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }

  ReactCurrentOwner$2.current = null;
}

上面的代码中,主要实现了两部分重要内容:

  • 1.创建workInProgressprepareFreshStack函数将当前FiberRootNode类型的节点传入,使用其root.current属性创建workInProgressroot.current为当前容器的FiberNode节点,也就是FiberNode(HostRoot)
  • 2.开启工作循环:workLoopSync以及performUnitOfWork函数实现了对FiberTree的创建,其中beginWork是创建FiberNodecompleteUnitOfWork为创建stateNode并构建以当前节点为根结点的DOM树的过程。

执行完上面的内容后,当前内存中的数据结构如下:

image-14.png

workLoopSync

下面是beginWork函数的主要内容,主要就是根据fiberNodetag值执行不同的方法

function beginWork(current, workInProgress, renderLanes) {
  workInProgress.lanes = NoLanes;

  switch (workInProgress.tag) {
    case IndeterminateComponent:
      {
        return mountIndeterminateComponent(current, workInProgress, workInProgress.type, renderLanes);
      }

    case FunctionComponent:
      {
        var Component = workInProgress.type;
        var unresolvedProps = workInProgress.pendingProps;
        var resolvedProps = workInProgress.elementType === Component ? unresolvedProps : resolveDefaultProps(Component, unresolvedProps);
        return updateFunctionComponent(current, workInProgress, Component, resolvedProps, renderLanes);
      }

    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);

    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);

    case HostText:
      return updateHostText(current, workInProgress);
  }
}

function updateHostRoot(current, workInProgress, renderLanes) {
  var nextProps = workInProgress.pendingProps;
  var prevState = workInProgress.memoizedState;
  var prevChildren = prevState.element;
  var nextState = workInProgress.memoizedState;
  var root = workInProgress.stateNode;
  var nextChildren = nextState.element;

  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

让我们从头进入该函数,了解一下执行过程,现在使用了如下的结构进行渲染:

<App>
    <A>
        <div>
            <div>A</div>
            <div>B</div>
        </div>
    </A>
</App>
beginWork阶段
第一次 workLoop

函数执行过程如下图:

react-begin-loop1.png

这是第一次workLoop循环,当前的workInProgress === FiberNode(HostRoot),进入beginWork函数,根据当前tag === 3,进入updateHostRoot函数,然后进入reconcileChildren函数中,此时,会将 root.render(<App />)代码中的<App />函数组件,经过babel编译后的ReactElement对象作为nextChildren传入reconcileChildFibers作为函数,该函数为执行ChildReconciler(true)后返回的函数,也就是ChildReconciler函数中的闭包reconcileChildFibers函数,其中shouldTrackSideEffects参数为true。后面继续进入reconcileSingleElement函数,该函数的主要作用是创建FiberNode(<App />),并将其return属性,指向当前FiberNode(HostRoot)。后续将当前新创建的FiberNode(<App />)作为参数传入placeSingleChild函数,添加flags,其shouldTrackSideEffects === true,则FiberNode(<App />).flags === Placement,执行到当前的内存数据接口如下:

image-17.png

然后从placeSingleChild依次从调用栈中进行返回,其中比较重要的就是在reconcileChildren函数中,将workInProgress.child属性指向了新创建的FiberNode(<App />)节点。此时结构如下:

image-18.png

然后在performUnitOfWork函数中,将workInProgress指向了FiberNode(<App />)节点。此时结构如下:

image-19.png

第二次workLoop

此时进入下一次workLoopSync循环,流程如下:

react-work-loop2.png

当前循环与上一次循环不同之处在于,此时的FiberNode(<App />).tag === IndeterminateComponent并且此时workInProgresscurrentnull,于是进入mountIndeterminateComponent函数,将该FiberNode<App />.tag修改为FunctionComponent,然后继续执行renderWithHooks函数,在该函数内,如果是函数组价,则获取其type,也就是函数来进行执行,执行完成后的返回值,传入reconcileChildren函数内,由于current === null,所以执行mountChildFibers函数,创建FiberNode(<A />),当前数据结构如下:

image-22.png

在函数返回后逐渐返回调用栈中的函数,并在reconcileChildren函数中将workInProgress.child指向当前新的结点FiberNode(<A />),并在performUnitOfWork函数中将当前新创建的结点指向workInProgress,当前数据结构如下:

image-23.png

第三次workLoop

继续执行第三次workLoop循环,流程如下:

react-work-loop3.png 当前循环与上次循环过程基本一致,除了生成的FiberNodetag === HostComponent类型,这是在createFiberFromTypeAndProps函数中根据当前的type值决定的,当前type === div,表示为宿主组件,则将tag = HostComponent,当前数据结构如下:

image-24.png 依次返回调用栈中的函数,本轮循环执行完成后数据结构如下:

image-25.png

第四次workLoop

react-work-loop4.png

第四次循环流程如上图👆🏻,当前workInProgress === FiberNode(div),则在beginWork函数中根据tag === HostComponent,会进入updateHostComponent函数,babel在编译时将HostComponent组件的子元素作为children属性放在了其element.props中,然后再创建FiberNode时,保存在了FiberNode(div).pendingProps属性中。如下代码:

function createFiberFromElement(element, mode, lanes) {
  var type = element.type;
  var key = element.key;
  var pendingProps = element.props;
  var fiber = createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes);
  return fiber;
}

然后再updateHostComponent中执行下面代码,将其子元素传入了reconcileChildren函数

var nextProps = workInProgress.pendingProps;
var prevProps = current !== null ? current.memoizedProps : null;
var nextChildren = nextProps.children;

执行到reconcileChildFibers函数内部,发现其isArray(newChild) === true,则执行了reconcileChildrenArray函数

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
  var resultingFirstChild = null;
  var previousNewFiber = null;
  var oldFiber = currentFirstChild;
  var lastPlacedIndex = 0;
  var newIdx = 0;
  var nextOldFiber = null;

  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);

      if (_newFiber === null) {
        continue;
      }

      lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);

      if (previousNewFiber === null) {
        resultingFirstChild = _newFiber;
      } else {
        previousNewFiber.sibling = _newFiber;
      }

      previousNewFiber = _newFiber;
    }

    return resultingFirstChild;
  }
  return resultingFirstChild;
}

当前函数执行完成后的内存模型如下:

image-27.png 如上图所示,生成了对应的FiberNode(div-A)节点,以及FiberNode(div-B)节点,并将其通过sibling属性进行连接。并且它们的return节点都指向了FiberNode(div)节点。

后续依次返回调用栈中的函数,本轮循环执行完成后数据结构如下:

image-29.png

第五次workLoop

进入第五次workLoop循环中的beginWork函数,当前workInProgress === FiberNode(div-A),流程如下: react-work-loop5.png 大致流程与上次循环一致,不同点为没有其子元素,函数返回的为null,其没有子节点,于是进入performUnitOfWork函数中的completeUnitOfWork函数。当前内存中数据结构如下:

image-30.png

整体的beginWork流程如下图:

beginWork.png

一个 ERR_SSL_PROTOCOL_ERROR 让我们排查了三层问题,最后发现根本不是 SSL 的锅

作者 却尘
2026年2月24日 14:17

这篇文章写给所有在本地开发时被浏览器报错 ERR_SSL_PROTOCOL_ERROR 整崩溃过的人。

背景

我使用ngrok给我的前端做了一个内网穿透。但后端一直不接受http请求。后端跑的是 HTTP,前端发的是 HTTPS,两者对不上,浏览器给了一个 ERR_SSL_PROTOCOL_ERROR。修复方案写了三层,每一层都有对应的代码证据。整个排查过程涉及:SSL 协议层、Vite 代理路由层、业务会话上下文层。

第一章:事情是怎么发生的

用户打开前端页面,点击任何一个需要后端数据的功能,浏览器 network 面板直接红:

GET https://localhost:8000/api/... 
ERR_SSL_PROTOCOL_ERROR

同一时间,后端日志里出现:

WARNING: Invalid HTTP request received.

这个警告是 uvicorn 抛出来的。uvicorn 收到了一个它根本看不懂的请求——因为客户端发来的是 TLS 握手包,而 uvicorn 根本没有启用 TLS,它启动命令是:

uvicorn http://0.0.0.0:8000

没有 --ssl-keyfile,没有 --ssl-certfile,就是纯 HTTP。

所以整件事的本质很简单:前端用了 HTTPS 去打一个 HTTP 服务器的端口,服务器不认识 TLS 握手,直接丢弃,浏览器报 SSL 错误。

但"为什么前端会用 HTTPS 去请求 localhost",这才是真正需要拆开说的部分。

第二章:前端是怎么一步步走到 HTTPS 的

场景一:开发者用 HTTPS 打开了 Vite 开发服务器

Vite 支持 HTTPS 模式启动。如果开发者本地配置了 --https 或者浏览器历史记录里有 https://localhost:5173,那么所有从这个页面发出去的 fetch 请求,如果 base URL 是绝对路径 https://localhost:8000,就会直接绕过 Vite proxy,用 HTTPS 去打后端。

而 Vite proxy(vite.config.ts:11)配置的是把 /api 转发到 http://localhost:8000这个 proxy 只在相对路径请求时生效。一旦前端代码里写死了 https://localhost:8000,请求就直接出去了,proxy 根本插不上手。

场景二:通过 ngrok 暴露后在本地调试

ngrok 给你一个 https://xxxx.ngrok.io 的域名,前端页面从这个域名加载。此时 window.location.protocolhttps:window.location.hostnamexxxx.ngrok.io(不是 localhost)。

如果前端的 API base URL 逻辑是"我在 HTTPS 环境,所以我用 https://localhost:8000 来请求后端",那就出问题了。从 ngrok 的 HTTPS 页面发出 https://localhost:8000 的请求,浏览器不会走 Vite proxy(因为你不是在 localhost 上),请求直接打到本机 8000 端口,而那里跑的是 HTTP,凉了。

第三章:修复是怎么做的?

修复分三个层次

层次一:normalizeApiBase

这个函数处理"当前环境到底该用什么 base URL"的问题。

逻辑是:如果检测到当前是 HTTPS 环境或远程 host,但目标是 localhost,就回退为空字符串(相对路径)。

空字符串意味着请求走的是 /api/... 这种相对路径,这样 Vite proxy 就能接管,把它转发到 http://localhost:8000

这一步解决了"HTTPS 页面不小心拼出 https://localhost:8000"的问题。

层次二:installLocalhostFetchPatch

这是一个更激进的兜底。它在 window.fetch 上打了一个 monkey patch:拦截所有目标是 https://localhost 的请求,把它们重写成 http://127.0.0.1:xxxx

为什么要用 127.0.0.1 而不是 localhost?因为某些浏览器对 localhost 有特殊的安全策略处理,用 127.0.0.1 更保险。

这一步是防御性的,即使上面那一层没拦住,这里也能把 HTTPS 的 localhost 请求"降级"到 HTTP。

层次三:Vite Proxy

proxy: {
  '/api': 'http://localhost:8000'
}

所有走相对路径 /api/... 的请求,在 Vite dev server 层面就被代理到后端,完全不经过浏览器的 HTTPS/HTTP 协议判断,是最干净的解法。

同时 vite.config.ts:10allowedHosts 包含了 ngrok 域名,确保通过 ngrok 访问时 Vite 不会拒绝请求。

明白了,你想把这一章从"这套方案的局限"扩展成一篇更有普适价值的 SSL 错误指南——用这次排查作为引子,讲清楚开发者最常碰到的那几类 SSL 问题。我来重写这两个部分:

第四章:SSL 报错那么多,到底哪种是哪种

ERR_SSL_PROTOCOL_ERROR 只是浏览器 SSL 错误家族里的一个成员。把它们放在一起看,你会发现每一种错误背后的根因其实差异很大,但开发者往往一看到 SSL 就开始检查证书,其实南辕北辙。

ERR_SSL_PROTOCOL_ERROR:协议对不上

这就是本文的主角。不是证书的问题,是客户端发了 TLS 握手,服务端根本不认识这个握手。最常见的触发条件:后端跑 HTTP,前端用 HTTPS 去打;或者服务端配置的 TLS 版本太低(比如只支持 TLS 1.0),而客户端要求 TLS 1.2 以上。

排查方向:先用 curl -v https://your-host:port 看连接阶段的输出,确认服务端有没有在做 TLS 握手响应。如果 curl 直接报 SSL handshake failure,问题在服务端;如果 curl 能通但浏览器不行,问题在浏览器侧(HSTS、证书信任等)。

ERR_CERT_AUTHORITY_INVALID:CA 不被信任

证书是真实的,但签发这张证书的 CA 不在浏览器的信任链里。本地开发用 openssl 自签名证书时最常见。解法有两个:一是用 mkcert 这类工具生成本地可信证书(它会把自己的 CA 写入系统信任库);二是在 Chrome 地址栏输入 thisisunsafe 临时跳过(仅限开发调试,绝对不能用于生产)。

ERR_CERT_COMMON_NAME_INVALID:域名对不上

证书是有效的,CA 也可信,但证书里写的域名和你实际访问的域名不一致。比如证书颁发给 api.example.com,你用 www.example.com 去访问,就报这个错。通配符证书(*.example.com)可以解决同一域下多子域的问题,但它不覆盖根域本身,也不覆盖二级以上的子域。

用 ngrok 做内网穿透时有时会碰到这个,因为 ngrok 的域名每次可能不同,而你本地配置的证书是固定域名。

ERR_CERT_DATE_INVALID:证书过期

最好排查也最尴尬的一种——证书到期了。Let's Encrypt 的免费证书有效期是 90 天,如果自动续签的 cron job 挂了,就会在某天突然全站 SSL 报错。运维侧应该有证书过期的提前告警(比如到期前 30 天、7 天各发一次通知)。

检查命令:

echo | openssl s_client -connect your-domain:443 2>/dev/null | openssl x509 -noout -dates

输出里的 notAfter 就是过期时间。

NET::ERR_CERT_REVOKED:证书被吊销

证书被 CA 标记为不可信,原因通常是私钥泄露或者证书错误签发。浏览器会通过 OCSP(Online Certificate Status Protocol)或 CRL(Certificate Revocation List)实时查询证书状态。这种错误在开发阶段几乎不会遇到,生产环境一旦出现,需要立即联系 CA 重新签发。

HSTS 导致的强制 HTTPS(没有专属错误码,但很坑)

HSTS(HTTP Strict Transport Security)是服务端通过响应头 Strict-Transport-Security 告诉浏览器:"以后访问我这个域名,只准用 HTTPS。"浏览器会把这个策略缓存下来,即使后来服务端改回 HTTP,浏览器也拒绝发 HTTP 请求。

本地开发最容易踩这个坑:你之前在某个端口跑过 HTTPS 服务并发了 HSTS 头,后来改回 HTTP,结果浏览器死活不肯发 HTTP 请求,报的错看起来像 SSL 问题,但其实是 HSTS 缓存在作怪。

解法:Chrome 里打开 chrome://net-internals/#hsts,在 "Delete domain security policies" 里输入对应的域名或 localhost,删掉缓存。

最后

回头看这次排查,ERR_SSL_PROTOCOL_ERROR 这个报错本身其实挺有误导性的——它让人第一反应是去检查证书、检查 TLS 配置,但真正的问题是连 TLS 都没启用,谈何配置

SSL 报错的排查有一个基本原则值得记住:先确认 TLS 在哪一层断掉的,再去找断掉的原因。 是服务端根本没有 TLS(本文的情况)、还是握手失败(协议版本不兼容)、还是握手成功但证书校验失败(域名不对、CA 不信任、已过期)——这三个阶段的问题,修法完全不同,不能混为一谈。

最短排查路径:curl -v https://target:port 看握手阶段输出,能比浏览器给你更原始的错误信息,省掉很多猜测。

❌
❌