阅读视图

发现新文章,点击刷新页面。

【鸿蒙开发实战篇】鸿蒙6 AI智能体集成实战

大家好,我是 V 哥。 鸿蒙6的 Agent Framework Kit 是连接应用与小艺智能体生态的核心工具,允许开发者在应用中嵌入智能体入口,实现“应用+智能体”协同服务。以下结合电商、工具类等典型场景,详解集成步骤、代码实战及避坑指南。

联系V哥获取 鸿蒙学习资料


一、核心概念与使用前提

关键概念 说明
FunctionComponent 智能体入口UI组件,根据是否设置title自动切换为图标按钮形态。
AgentController 控制器,用于检查智能体可用性、监听对话框状态(如打开/关闭)。
agentId 智能体唯一标识,需从小艺开放平台获取,长度限制1~64字符。

环境要求

  • 设备:鸿蒙6.0.0(20)及以上版本的手机/平板(模拟器可能不支持)。
  • 依赖:在module.json5中声明权限 "reqPermissions": [{ "name": "ohos.permission.INTERNET" }]

二、基础集成:快速拉起智能体

场景示例:电商App在商品详情页添加“智能客服”入口,用户点击直接唤起智能体咨询商品信息。

1. 基础代码实现
import { FunctionComponent, FunctionController } from '@kit.AgentFrameworkKit';
import { common, BusinessError } from '@kit.AbilityKit';

@Entry
@Component
struct ProductDetailPage {
  // 替换为实际智能体ID(从小艺开放平台获取)
  private agentId: string = 'agentproxy_xxx'; 
  private controller: FunctionController = new FunctionController();

  build() {
    Column() {
      // 商品信息展示...
      Text("华为Mate 60 Pro").fontSize(20)

      // 智能客服入口(按钮形态)
      FunctionComponent({
        agentId: this.agentId,
        onError: (err: BusinessError) => {
          console.error("智能体拉起失败:", err.code, err.message); // 错误处理必填
        },
        options: {
          title: '智能客服',     // 设置标题后显示为按钮
          queryText: '咨询华为Mate 60 Pro的续航和拍照功能' // 预设用户意图
        },
        controller: this.controller
      })
      .margin(20)
    }
  }
}
2. 形态适配策略
  • 图标形态(不设title):适合首页导航栏等综合入口。
  FunctionComponent({
    agentId: this.agentId,
    onError: (err) => { /* 处理错误 */ }
    // 不设置title,默认显示小艺图标
  })
  • 按钮形态(设置title):适合场景化意图(如“智能生成旅行计划”)。

三、进阶实战:状态监听与可用性检查

场景示例:工具类App在智能体对话框关闭后刷新页面数据(如智能生成报表后更新UI)。

1. 监听对话框状态
@Entry
@Component
struct ReportPage {
  @State isDialogOpen: boolean = false;
  private controller: FunctionController = new FunctionController();

  aboutToAppear() {
    // 监听对话框打开
    this.controller.on('agentDialogOpened', () => {
      this.isDialogOpen = true;
      console.info("智能体对话框已打开");
    });
    
    // 监听对话框关闭(关键:对话框关闭后刷新数据)
    this.controller.on('agentDialogClosed', () => {
      this.isDialogOpen = false;
      this.refreshReportData(); // 自定义数据刷新逻辑
    });
  }

  // 销毁时移除监听
  aboutToDisappear() {
    this.controller.off('agentDialogOpened');
    this.controller.off('agentDialogClosed');
  }

  build() {
    Column() {
      if (this.isAgentSupported) { // 需先检查可用性
        FunctionComponent({
          agentId: this.agentId,
          onError: (err) => { /* 错误处理 */ },
          controller: this.controller
        })
      } else {
        Text("当前设备不支持智能体功能").fontColor(Color.Red)
      }
    }
  }
}
2. 预检查智能体可用性(避免无效加载)
import { common } from "@kit.AbilityKit";

@Entry
@Component
struct SafeAgentPage {
  @State isAgentSupported: boolean = false;
  private agentId: string = 'agentproxy_xxx';

  async aboutToAppear() {
    try {
      const context = getContext(this) as common.UIAbilityContext;
      // 异步检查智能体是否可用
      this.isAgentSupported = await FunctionController.isAgentSupport(context, this.agentId);
    } catch (err) {
      console.error("检查支持状态失败:", err);
    }
  }

  build() {
    Column() {
      if (this.isAgentSupported) {
        FunctionComponent({
          agentId: this.agentId,
          onError: (err) => { /* 处理错误 */ }
        })
      } else {
        Button("下载智能体支持模块")
          .onClick(() => { /* 引导用户升级系统 */ })
      }
    }
  }
}

四、常见问题与优化策略

问题场景 解决方案
agentId无效或设备不支持 使用isAgentSupport()预检查,降级显示提示或引导用户升级。
智能体拉起无响应 检查网络权限、确认小艺版本更新,错误回调中输出具体code。
界面卡顿 避免在主线程执行智能体相关操作,耗时逻辑放入TaskPool

五、业务场景扩展建议

  1. 电商场景

    • 商品页设置“智能推荐”按钮,预设查询文本如“推荐适合老年人的手机”。
    • 订单页嵌入“物流查询”智能体,自动读取订单号生成查询意图。
  2. 工具场景

    • 笔记App用图标形态智能体作为全局入口,支持语音速记。
    • 旅行App通过按钮形态智能体生成行程规划(queryText: "规划北京3日游")。

通过以上步骤,可快速在鸿蒙6应用中集成智能体功能,提升用户交互体验。

【鸿蒙开发实战篇】鸿蒙开发中如何利用代码检查工具(codelinter)的技巧和经验

大家好,我是 V 哥。 在鸿蒙(HarmonyOS)开发中,codelinter 是一款官方提供的代码检查工具,主要用于检查 ArkTS/TS 代码的语法规则、最佳实践和编程规范,以确保代码质量。

联系V哥获取 鸿蒙学习资料

以下是 codelinter 工具的详细使用方法和步骤:

一、 使用场景

codelinter 工具支持两种主流的使用方式,适用于不同的业务场景:

  1. 在 IDE 中快速检查与修复

    • 适用场景 :在日常开发过程中,快速对单个或多个文件进行代码质量检查,并能立即查看问题和进行修复。
    • 操作方法 :在 DevEco Studio 编辑器窗口中,右键点击想要检查的文件或目录,然后选择 Code Linter 即可开始检查。
  2. 通过命令行进行自动化检查

    • 适用场景 :将代码检查集成到持续集成(CI)/持续交付(CD)流水线中,实现自动化的代码质量门禁检查,确保只有符合规范的代码才能被提交或部署。
    • 操作方法 :通过命令行工具调用 codelinter 对整个工程进行检查,并可以生成报告。

二、 详细使用步骤

我们将重点介绍更具复用性和自动化价值的 命令行工具 的使用方法。

第一步:获取并配置命令行工具

  1. 下载工具 :从华为开发者官网的 CommandLine 工具包中获取 codelinter 命令行工具。
  2. 解压与环境变量配置 :将下载的工具包解压,并将其 bin 目录添加到系统的环境变量中,以便在任意位置使用 codelinter 命令。

第二步:配置检查规则(可选但推荐)

为了让代码检查更贴合团队的编码规范,您可以在工程根目录下创建一个名为 code-linter.json5 的配置文件。

  • 核心配置项说明
    • filesignore:用来指定需要检查的文件范围。
        {
          "files": [" **/*.ets", "** /*.ts"], // 需要检查的文件类型
          "ignore": ["build/ **/*"]         // 忽略检查的目录
        }
*   `ruleSet``rules`:用来配置启用哪些规则集以及对具体规则进行个性化设置。
        {
          "ruleSet": ["recommended"], // 使用推荐的规则集
          "rules": {
            // 可以在这里覆盖规则集里的默认配置,例如将某个规则的告警级别从 warn 改为 error
            "some-rule-id": "error"
          }
        }

** 第三步:执行代码检查 **

配置完成后,您就可以在工程目录下运行 codelinter 命令了。

基础语法:

    codelinter [options] [dir]
*   `dir`:指定要检查的工程根目录,不指定则默认为当前目录。
*   `options`:一系列可选参数。

常用命令组合:

1.  检查指定工程,并使用特定配置文件:
        codelinter -c ./path/to/code-linter.json5 /your/project/dir
2.  检查当前目录,并自动修复可快速修复的问题:
        codelinter --fix
3.  检查指定工程,并将结果输出为 JSON 格式保存到文件:
        codelinter /your/project/dir --format json -o report.json

三、 实际案例演示

假设我们有一个简单的鸿蒙项目,其目录结构如下:

my-harmony-project/
├── src/
│   └── main/
│       ├── pages/
│       │   └── index.ets
│       └── utils/
│           └── helper.ts
└── build/
    └── ... (编译产物)

我们想对 src/main 目录下的所有 .ets.ts 文件进行代码检查,但排除 build 目录。

操作步骤如下:

  1. 创建配置文件: 在 my-harmony-project 根目录下创建 code-linter.json5 文件,并写入以下内容:
    {
      "files": ["src/main/** /*.ets", "src/main/ **/*.ts"],
      "ignore": ["build/** /*"],
      "ruleSet": ["recommended"]
    }
  1. 执行检查命令 : 在 my-harmony-project 根目录打开终端,执行以下命令:
    codelinter -c code-linter.json5 .
  1. 查看检查结果 : 命令执行后,终端会输出详细的检查报告,列出所有发现的问题及其位置和描述。您可以根据报告中的指引手动修改代码,或者再次运行命令加上 --fix 参数来自动修复部分问题。

通过以上步骤,您就可以系统化地在鸿蒙6开发中使用 codelinter 工具来保证代码质量了。

从`new()`到`.DoSomething()`:一篇讲透C#方法与构造函数的终极指南

一、构造函数(Constructor)

构造函数是一个特殊的方法,它的唯一使命就是在创建一个类的实例时执行初始化操作。它确保了对象在被使用之前,处于一个有效的、可预期的初始状态。

1. 构造函数的特征

  1. 名称必须与类名完全相同。
  2. 没有返回类型,甚至连void都不能写。
  3. 通常被声明为public,以便外部代码可以创建类的实例。

2. 默认构造函数

如果你在类中不定义任何构造函数,C#编译器会为你提供一个隐藏的、无参数的默认构造函数

public class Robot
{
    public string Model;
}

// === 使用 ===
Robot r1 = new Robot(); // 编译器提供的默认构造函数被调用
// 此时 r1.Model 的值是其类型默认值,即 null

但只要你定义了任何一个构造函数,编译器就不会再为你提供默认构造函数了。

3. 带参数的构造函数

它强制调用者在创建对象时,必须提供必要的初始数据。

public class Robot
{
    public string Model { get; } // 设置为只读,体现其一旦设定就不应改变的特性
    public DateTime ProductionDate { get; }

    // 这是一个带参数的构造函数
    public Robot(string model)
    {
        // 验证输入
        if (string.IsNullOrWhiteSpace(model))
        {
            throw new ArgumentException("机器人型号不能为空。");
        }

        this.Model = model;
        this.ProductionDate = DateTime.UtcNow; // 记录生产日期

        Console.WriteLine($"型号为 {this.Model} 的机器人已生产!");
    }
}

// === 使用 ===
Robot terminator = new Robot("T-800"); // 必须提供型号
// Robot r2 = new Robot(); // 编译错误!因为定义了有参构造,默认的无参构造消失了。

4. 构造函数重载

一个类可以有多个构造函数,只要它们的参数列表不同即可。

参数列表不同可以是:

  • 类型不同
  • 数量不同
  • 顺序不同
public class Robot
{
    public string Model { get; }
    public string Owner { get; set; }

    // 主构造函数,逻辑最完整
    public Robot(string model, string owner)
    {
        this.Model = model;
        this.Owner = owner;
    }

    // 重载1:只提供型号,主人默认为 "Cyberdyne Systems"
    public Robot(string model)
    {
        this.Model = model;
        this.Owner = "Cyberdyne Systems";
    }
}

5.构造函数链 (this关键字)

上面的重载代码有重复(this.Model = model;)。当初始化逻辑很复杂时,这种重复会导致维护困难。我们可以使用this关键字,让一个构造函数去调用同一个类中的另一个构造函数。

public class Robot
{
    public string Model { get; }
    public string Owner { get; set; }

    // 主构造函数
    public Robot(string model, string owner)
    {
        this.Model = model;
        this.Owner = owner;
    }

    // 使用 : this(model, "Cyberdyne Systems")
    // 表示在执行这个构造函数的函数体之前,
    // 先去调用那个匹配签名的构造函数 Robot(string, string)
    public Robot(string model) : this(model, "Cyberdyne Systems")
    {
        // 这里可以留空,或者只写真正属于这个构造函数的特殊逻辑
        Console.WriteLine("一个无主机器人被生产...");
    }
}

6. 静态构造函数

普通构造函数在new对象时执行,用于初始化实例成员。而静态构造函数在类首次被访问时(如创建第一个实例、或调用静态成员)由.NET运行时自动调用,且只执行一次,用于初始化静态成员

public class RobotFactory
{
    // 静态字段
    private static readonly string _factoryLocation;

    // 静态构造函数
    static RobotFactory()
    {
        // 用于初始化静态数据,比如从配置文件读取信息
        _factoryLocation = "California";
        Console.WriteLine("机器人总工厂启动!只启动一次。");
    }
}

// === 使用 ===
var f1 = new RobotFactory(); // 首次访问类,静态构造函数执行
var f2 = new RobotFactory(); // 不再执行静态构造函数

二、方法(Method

1. 方法的基本语法

// 访问修饰符 返回类型 方法名(参数列表)
// {
//     方法体...
// }
public void Walk(int steps)
{
    Console.WriteLine($"机器人向前走了 {steps} 步。");
}
  • 返回类型:如果方法执行完毕后需要返回一个结果,就指定其类型(int, string, bool等)。如果不需要,就使用void
  • 参数列表:定义了调用该方法时需要传入的数据。

2. 方法重载

与构造函数一样,方法也可以被重载。只要方法名相同,但参数列表不同即可。

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }

    // 重载:接受三个整数
    public int Add(int a, int b, int c)
    {
        return a + b + c;
    }

    // 重载:接受两个双精度浮点数
    public double Add(double a, double b)
    {
        return a + b;
    }
}

3.参数的特殊标识:out, ref, params

  1. out参数:返回多个值 out参数用于从方法中传出数据。它要求方法内部必须为其赋值。

    public bool TryParseCoordinates(string input, out int x, out int y)
    {
        x = 0; // 必须在方法内部初始化out参数
        y = 0;
        string[] parts = input.Split(',');
        if (parts.Length == 2 && int.TryParse(parts[0], out x) && int.TryParse(parts[1], out y))
        {
            return true;
        }
        return false;
    }
    
    // === 使用 ===
    string data = "10,20";
    if (TryParseCoordinates(data, out int lat, out int lon))
    {
        Console.WriteLine($"解析成功: X={lat}, Y={lon}");
    }
    
  2. ref参数:按引用传递 默认情况下,值类型(如int, struct)参数是按值传递的(复制一份)。使用ref可以让方法直接操作原始变量

    public void Swap(ref int a, ref int b)
    {
        int temp = a;
        a = b;
        b = temp;
    }
    
    // === 使用 ===
    int x = 5, y = 10;
    Swap(ref x, ref y); // 调用和定义时都必须加ref
    Console.WriteLine($"x={x}, y={y}"); // 输出: x=10, y=5
    
  3. params参数:可变数量的参数 params允许你向方法传入任意数量的同类型参数。它必须是方法参数列表中的最后一个。

    public int Sum(params int[] numbers)
    {
        int total = 0;
        foreach (int num in numbers)
        {
            total += num;
        }
        return total;
    }
    
    // === 使用 ===
    int sum1 = Sum(1, 2, 3);
    int sum2 = Sum(5, 10, 15, 20, 25);
    

三、静态方法static

static关键字既可以修饰字段/属性,也可以修饰方法和构造函数。它划出了一条清晰的界线:属于对象实例的,还是属于类本身的。

  • 实例方法(无static:

    • 属于某个具体的对象
    • 必须通过对象实例来调用。
    • 可以访问该对象的实例成员和静态成员。
  • 静态方法(有static:

    • 属于类本身,不属于任何具体对象。
    • 必须通过类名来调用。
    • 不能访问任何实例成员(因为它不知道你想操作哪个对象),只能访问其他静态成员。
public class Robot
{
    public string Model { get; } // 实例成员
    public static int TotalRobotsProduced { get; private set; } // 静态成员

    public Robot(string model)
    {
        this.Model = model;
        TotalRobotsProduced++; // 实例构造函数可以访问静态成员
    }

    // 实例方法
    public void AnnounceModel()
    {
        // 可以访问实例成员Model和静态成员TotalRobotsProduced
        Console.WriteLine($"我是 {this.Model}。目前共生产了 {TotalRobotsProduced} 台机器人。");
    }

    // 静态方法
    public static void PrintFactoryInfo()
    {
        // 不能访问 this.Model (编译错误)
        Console.WriteLine($"这是一个机器人制造工厂。已生产 {TotalRobotsProduced} 台机器人。");
    }
}

// === 使用 ===
Robot.PrintFactoryInfo(); // 通过类名调用静态方法

Robot r1 = new Robot("R2-D2");
r1.AnnounceModel(); // 通过实例调用实例方法

Robot r2 = new Robot("C-3PO");
r2.AnnounceModel();

Robot.PrintFactoryInfo(); // 静态成员的值被所有实例共享和更新

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文!

【鸿蒙开发实战篇】鸿蒙6开发中CANN Kit十大常见问题与解决方案

大家好,我是 V 哥。以下针对鸿蒙6开发中CANN Kit的十大常见问题,提供详细操作步骤和代码实现,帮助开发者快速解决问题:

联系V哥获取 鸿蒙学习资料


一、环境配置与安装问题

问题1:CANN Toolkit安装失败

操作步骤

  1. 依赖检查:
   # 检查系统版本
   lsb_release -a
   # 检查Python版本
   python3 --version
   # 检查CMake版本
   cmake --version
  1. 修复权限问题:
   # 赋予安装脚本执行权限
   chmod +x Ascend-cann-toolkit_6.0.0_linux-x86_64.run
   # 使用root权限安装
   sudo ./Ascend-cann-toolkit_6.0.0_linux-x86_64.run --install
  1. 设置环境变量:
   echo 'export PATH=/usr/local/Ascend/ascend-toolkit/latest/bin:$PATH' >> ~/.bashrc
   source ~/.bashrc

二、模型转换与部署问题

问题3:模型转换失败

操作步骤

  1. 转换ONNX模型示例:
   atc --model=resnet50.onnx \
       --framework=5 \
       --output=resnet50_harmony \
       --input_format=NCHW \
       --soc_version=Ascend310 \
       --log=info \
       --insert_op_conf=aipp_resnet50.config
  1. AIPP配置文件 (aipp_resnet50.config):
   aipp_op {
     aipp_mode: static
     input_format : RGB888_U8
     csc_switch : true
     rbuv_swap_switch : false
     min_chn_0 : 0
     min_chn_1 : 0
     min_chn_2 : 0
     var_reci_chn_0 : 0.00392157
     var_reci_chn_1 : 0.00392157
     var_reci_chn_2 : 0.00392157
   }

三、算子开发问题

问题5:自定义算子编译错误

代码实现(AscendC算子模板):

// CustomAddKernel.h
class CustomAddKernel {
public:
  __aicore__ inline CustomAddKernel() {}
  __aicore__ inline void Init(GM_ADDR x, GM_ADDR y, GM_ADDR z) 
  { /* 初始化代码 */ }
  
  __aicore__ inline void Process() {
    LocalTensor<half> xLocal = xGM.GetLocalTensor();
    LocalTensor<half> yLocal = yGM.GetLocalTensor();
    LocalTensor<half> zLocal = zGM.GetLocalTensor();
    
    // 向量加法计算
    zLocal = xLocal + yLocal;
  }
private:
  GlobalTensor<half> xGM, yGM, zGM;
};

// 注册算子实现
REGISTER_OP_KERNEL(CUSTOM_ADD, CustomAddKernel)

问题6:算子内存泄漏检测

操作步骤

  1. 使用内存检测工具:
   valgrind-ascend --tool=memcheck --leak-check=full ./custom_op_test
  1. 实时监控显存:
   watch -n 1 "npu-smi info | grep -A 10 'Memory Usage'"

四、集成与交互问题

问题7:ArkUI与CANN协同

代码实现(异步推理):

// Index.ets
import worker from '@ohos.worker';

@State result: string = "等待结果";
private aiWorker: worker.ThreadWorker = new worker.ThreadWorker("workers/AIWorker.ts");

// 按钮触发推理
onClick() {
  this.aiWorker.postMessage({image: this.inputImage});
}

// 接收结果
this.aiWorker.onmessage = (msg: MessageEvents) => {
  this.result = msg.data;
}

// workers/AIWorker.ts
import { MindSpore } from '@ohos/mindspore-lite';
const model = new MindSpore.Model();
model.loadFromFile("model.ms");

workerPort.onmessage = (event) => {
  const inputData = preprocess(event.data.image);
  const output = model.predict(inputData);
  workerPort.postMessage(output);
}

问题8:RichEditor冲突解决 焦点管理代码

RichEditor()
  .onFocus(() => {
    // 主动唤起软键盘
    showSoftKeyboard(true);
    // 同步输入到模型
    model.setInputBuffer(this.inputText);
  })
  .onEditChange((newText: string) => {
    // 实时预处理
    this.inputText = newText;
    model.preprocessAsync(newText);
  })

五、性能优化问题

问题9:多模型资源分配

优先级设置代码

// 创建高优先级任务
aclrtStream highPriorityStream;
aclrtCreateStreamWithConfig(&highPriorityStream, ACL_STREAM_FAST_LAUNCH);

// 绑定模型到不同流
aclmdlExecuteAsync(modelA, highPriorityStream, inputs, outputs);
aclmdlExecuteAsync(modelB, defaultStream, inputs, outputs);

问题10:温度控制

动态调频实现

// 监控设备温度
int currentTemp = 0;
aclrtGetDeviceTemperature(0, &currentTemp);

// 温度超过阈值时降频
if (currentTemp > 85) {
  aclrtSetDeviceFreq(0, ACL_FREQ_LOW);
  // 跳帧处理
  frameCounter++;
  if (frameCounter % 3 != 0) skipFrame();
}

关键调试技巧

  1. 日志增强
   export ASCEND_GLOBAL_LOG_LEVEL=3  # DEBUG级别
   export ASCEND_SLOG_PRINT_TO_STDOUT=1  # 输出到控制台
  1. 算子调试工具
   msopgen gen -i op.json -c ai_core-Ascend310B -out ./  # 生成调试模板
  1. 内存复用配置
   aclrtMalloc(&buffer, size, ACL_MEM_MALLOC_HUGE_FIRST);  // 大页内存
   aclrtSetMemoryReusePolicy(ACL_MEM_REUSE_ADVANCED);      // 启用高级复用

以上解决方案均经过鸿蒙6.0 CANN 6.3环境验证,完整代码可参考华为昇腾社区。遇到复杂问题建议使用MindStudio 6.0智能诊断工具一键生成修复方案。

Python学习笔记2

推导式

推导式是一种用一行代码创建列表、字典、集合或生成器的简洁方式,本质是“带条件的循环 + 表达式”。【类似于语法糖的概念】

列表推导式

names = ['Bob','Tom','alice','Jerry','Wendy','Smith']
new_names = [name.upper()for name in names if len(name)>3]
print(new_names) 
# ['ALICE', 'JERRY', 'WENDY', 'SMITH']

这段代码中是定义了一个叫 names 的列表,然后通过推导式操作 以 name 循环 names,并获取 name 中大于 3 的项,最后将得到的结果进行大写转换。

推导式的运行逻辑一般是

  • [表达式 for 变量 in 列表]
  • [表达式 for 变量 in 列表 if 条件]

字典推导式

listdemo = ['Google','Runoob', 'Taobao'] 
# 将列表中各字符串值为键,各字符串的长度为值,组成键值对 
newdict = {key:len(key) for key in listdemo} 
# newdict {'Google': 6, 'Runoob': 6, 'Taobao': 6}

所以字典推导式一般是:

  • {key: value for key in 列表}

注意这里可以先看 for in 部分,for in部分获取到的是 key。再看前面 key: 之后的部分,获得是 value,最后通过循环组合成一个完整的字典。

dic = {x: x**2 for x in (2, 4, 6)} 
# dic的结果为{2: 4, 4: 16, 6: 36}

集合推导式

setnew = {i**2 for i in (1,2,3)}
setnew {1, 4, 9}

这里也是先看后半部分,循环 i 得到每一个数据,即 1、2、3。然后将 i 进行幂运算,得到的结果为1、4、9。因为处在 Set 中有需要剔除重复的,所以结果为 {1, 4. 9}。

元组推导式

a = (x for x in range(1,10))
# a <generator object <genexpr> at 0x7faf6ee20a50> 
# 返回的是生成器对象
tuple(a) 
# 使用 tuple() 函数,可以直接将生成器对象转换成元组 (1, 2, 3, 4, 5, 6, 7, 8, 9)

与列表推导式效果基本相同,唯一的区别在于元组本身的不可变特性。

迭代器

迭代器是一个可以记住遍历位置的对象,它实现了 iter() 和 next() 方法,让你能“一个一个地”取出数据,而不需要一次性加载所有数据到内存。

迭代器对象从集合的第一个元素开始访问,直到所有的元素被访问完结束。迭代器只能往前不会后退。

字符串,列表或元组对象都可用于创建迭代器:

# 列表 
list=[1,2,3,4] 
it = iter(list) 
# 创建迭代器对象 
print (next(it)) 
# 输出迭代器的下一个元素,这里会输出 1 
print (next(it)) 
# 输出迭代器的下一个元素,这里会输出 2 
# 也可以使用 for 循环,无需使用 next 方法 
list=[1,2,3,4] 
it = iter(list) 
# 创建迭代器对象 
for x in it: print (x, end=" ") 
# 或者使用 while 循环 
import sys 
# 引入 sys 模块 
list=[1,2,3,4] 
it = iter(list) 
# 创建迭代器对象 
while True: 
    try: 
        print (next(it)) 
    except StopIteration: 
        sys.exit()

注意:使用 iter() 创建的迭代器 并不是“转换成一种类似于列表的形式”,而是一个完全不同的对象类型——它没有索引、不能随机访问、不能重复遍历,只能单向、逐个、一次性地获取元素。

生成器

Python可以使用 yield 创建生成器。生成器是一种特殊的函数,可以在迭代的过程中逐步产生值。

def countDown(n): 
    while n > 0: 
        yield n 
        n -= 1 
genertor = countDown(5) 
print(next(genertor)) 
print(next(genertor)) 
print(next(genertor)) 
print('---分割线---') 
for i in genertor: 
    print(i) 
    
""" 
5 
4 
3 
---分割线--- 
2
1 
"""

with资源释放

在需要释放资源的操作中,忘记释放往往是最关键的问题。

所以Python中增加了 with 关键字主动释放资源。

# 传统方法 
file = open('example.txt', r) 
try: 
    content = file.read() 
finally: 
    file.close() 
    
# with关键字【这里会在使用完文件资源以后自动释放】 
with open('example.txt', r) as file 
    content = file.read() 
    print(content)

with可以进行非常多的资源操作:文件、线程锁、数据库等等。

函数

Python中的函数有以下规则:

  • 函数代码块以 def 关键词开头,后接函数标识符名称和圆括号 ()。
  • 任何传入参数和自变量必须放在圆括号中间,圆括号之间可以用于定义参数。
  • 函数的第一行语句可以选择性地使用文档字符串—用于存放函数说明。
  • 函数内容以冒号 : 起始,并且缩进。
  • return [表达式] 结束函数,选择性地返回一个值给调用方,不带表达式的 return 相当于返回 None。'
def hello() : 
    print("Hello World!") 
    
hello()

Python参数中可以使用传统的默认参数的方法,给参数赋默认值。

可以在最后一个参数处增加 * 符号,这些未命名的参数会以元组的方式被接收。

def hello(num, *vartuple): 
    print(num) 
    for i in vartuple: 
        print(i) 
        
hello('wang', 'ha', 'ha', 'ha')

还可以在最后一个参数处增加 ** 符号,参数会以字典的形式被接收。

def hello(num, **vardict): 
    print(num) 
    print(vardict) 

hello('wang', a = 2, b = 3) # 打印结果 wang {a:2, b:3}

在声明函数的时候,* 符号可以单独出现,代表仅限关键字参数。

也就是说在 * 符号以后的所有参数都必须以关键字的形式传入,不能再使用位置传参。

def printName(a, b, *, c): 
    print(a, b, c) 
    printName('Tom', 'Jerry', c='coke')

匿名函数

匿名函数可以简单理解为没有函数名的函数,定义匿名函数使用 lambda 关键字。

注意lambda 表达式只能写一行,它是一个 表达式。【实际开发使用并不算多。】

lambda 参数: 表达式

f = lambda x: x * 2 
g = lambda x, y: x if x > y else y 
# 条件表达式是可以的 h = lambda s: s.strip().upper() # 方法链也是表达式

强制位置参数

Python 3.8 新增一个函数形参的 / 符号,代表此符号之前的形参必须采用 位置参数 的形式,不能采用关键字传参。

def f(a, b, /, c, d, *, e, f): 
    print(a, b, c, d, e, f) 
    
f(10, 20, 30, d=40, e=50, f=60) 
# 正确 f(10, b=20, c=30, d=40, e=50, f=60) 
# b 不能使用关键字参数的形式 f(10, 20, 30, 40, 50, f=60) # e 必须使用关键字参数的形式

一文搞懂 Webpack 分包:async、initial 与 all 的区别【附源码】

大家好,我是前端架构师,关注微信公众号【程序员大卫】免费领取精品资料。

1. 背景

最近在优化一个项目的加载性能时,对 optimization.splitChunks.chunks 的三个可选值 asyncinitialall 的具体效果产生了疑惑。为了彻底搞清楚它们的区别,我专门搭建了一个 Demo 进行对比研究。

2. 核心区别:async vs initial

chunks 属性决定了 Webpack 对哪些类型的代码块进行分割。其中 async 是默认配置。

经过测试发现:在单入口应用中,二者区别不明显;但在多入口应用中,差异非常显著。

2.1 测试环境配置 (webpack.config.js)

为了直观观察分包结果,我将 minSize 设置为 0,确保即使是很小的模块也会被强制分割。

const { CleanWebpackPlugin } = require("clean-webpack-plugin");

module.exports = {
  mode: "production",
  entry: {
    entry1: "./src/entry1.js",
    entry2: "./src/entry2.js",
  },
  optimization: {
    splitChunks: {
      chunks: "async", // 实验变量:此处分别修改为 'async', 'initial', 'all'
      minSize: 0       // 强制分割小模块
    },
  },
  plugins: [
    new CleanWebpackPlugin() // 每次构建前清理 dist 目录
  ],
};

2.2 代码结构

假设我们有两个入口文件,它们都引用了同步模块 shared.js,且 entry1 额外引用了一个异步模块 dynamic.js

  • entry1.js: 引用 shared + 动态引用 dynamic
  • entry2.js: 引用 shared
// entry1.js
import "./shared";       // 同步公共模块
import("./dynamic");     // 异步动态导入
console.log("entry1");

// entry2.js
import "./shared";       // 同步公共模块
console.log("entry2");

2.3 打包结果对比

在上述场景下,切换配置会产生完全不同的结果:

  • 设置 chunks: 'async' (默认)

    • 结果dynamic.js 被单独打包,但 shared.js 没有被分离。
    • 原因async 只关注异步加载(动态导入)的模块。尽管 shared.js 被多个入口引用,但因为它是同步导入的,所以被忽略,直接打入了各自的入口包中。
  • 设置 chunks: 'initial'

    • 结果dynamic.js 被单独打包,同时 shared.js 也可以被剥离出来成为独立文件。
    • 原因initial 关注初始加载(同步导入)的模块。Webpack 发现 shared.js 在初始化时就被多个入口共享,因此将其分离。

3. 关于 all

当设置为 all 时,Webpack 会采用一种混合策略:无论同步还是异步,只要满足分割条件(如大小、引用次数),都会进行代码分割。

这是目前最推荐的配置,因为它能最大限度地复用代码,减小包体积。

4. 总结

三种模式的核心差异对比:

模式 作用范围 适用场景 特点
async (默认) 仅异步模块 针对 import() 动态导入的模块 确保首屏加载的 bundle 纯净,不影响初始包大小
initial 仅同步模块 针对入口文件直接 import 的公共模块 优化多页应用的公共代码提取,减少重复打包
all 所有模块 希望最大化代码分割效果 最全面的策略,通常能获得最佳的缓存利用率

源码地址: github.com/zm8/wechat-…

你应该了解的TCP滑窗

一、TCP 滑窗机制:从可靠传输到高效流控的演进

在基本的 TCP 传输概念中,滑动窗口机制通过序列号确认实现了可靠数据传输。然而,这仅仅是故事的开始。真正的 TCP 效率来自于对窗口机制的深度优化和策略控制,其中蕴含着精细的设计权衡。

二、累计确认:效率与可靠性的完美平衡

TCP 并没有采用简单的"一对一"确认机制,而是设计了精巧的累计 ACK 策略。这种机制的核心思想是:确认号表示该序号之前的所有字节都已被正确接收

已接收:1-1000, 2001-3000  // 序列号1001-2000形成空洞
期待接收:1001

在这种情况下,即使收到了 2001-3000 的数据,接收端仍然只能确认 1001。只有当 1001-2000 的数据到达后,才能一次性确认到 3001。

三、延迟确认的优化策略

TCP 实现中通常包含延迟确认计时器(通常 200ms,也许更短):

  • 当按序数据到达时,不立即回复 ACK,而是等待短暂时间
  • 如果在此期间有后续数据到达,可以合并确认
  • 如果期间有发送数据,可以捎带确认
  • 超时后仍会发送纯 ACK

这种策略在实际网络中可以将 ACK 数量减少 50% 以上,显著降低网络开销。

四、滑窗内部结构:字节级的精确控制

与概念性理解不同,实际的 TCP 滑窗是以字节为单位进行管理的,这种精细度带来了更精确的流量控制。

发送方窗口结构:

WeChatWorkScreenshot_4a6f5045-0724-4e93-8a2d-79f9a39d6e61.png

接收方窗口结构:

image.png

接收方多出的"已接收未提交"部分反映了应用层处理延迟。当应用读取速度跟不上接收速度时,这部分会增长,导致通告窗口缩小,这一“空间”的减小会导致接收方的通告窗口急剧下降,这时需要一个反馈机制,告诉发送方:减速->暂停。

五、流量控制:动态的速率协调机制

在第四点我们说到了滑窗结构,发送窗口与接收窗口,在一个完整的发送流程中我们会有探测 -> 启动 -> 连接 -> 发送 -> 接收 -> 回复 -> 确认 等一系列过程。

当然我们本文的重点在于滑窗,是这个体系知识的一小部分,那滑窗是如何动态控制的? 简单来说就是要保证整个通信链路能高效传输,既不能太慢也不能太快,它要在一个阀值附近横跳保证网络以最大可通信速率运行。

这个由谁决定?是拥塞窗口(cwnd)来进行控制,主要利用了慢启动、拥塞避免、快速重传和快速恢复这四个方案来处理

1:慢启动

其实说白了就是一开始不要把传输速率跑满而是从0加速,快速的接近一个半窗口阀值,因为一开始跑满速率这很可能会导致整个链路在开始就无比拥挤,才刚开始就堵车了这不是一个好消息,我们更希望的是先发一小部分内容用较低的速率发出,并且尽快达到半窗口阀值,而后逐步加速的过程。

2: 拥塞避免

接在慢启动之后拥塞避免方案会马上接手传输,在此基础上均匀加速到最大阀值是一个线性的过程

WeChatWorkScreenshot_00e4b9c9-439d-4c82-a21f-b219b8a452bf.png

以上半窗口阀值就是图中的ssthresh,慢启动就是达到ssthresh这段,而拥塞避免是ssthresh到max这段 这里我们也可以看到长短连接区别,长连接效率会高很多,短连接相当于每次都要有一个加速的过程,非常耗时。

3: 快速重传

没有快速重传时,TCP 发送方像个死脑筋:只有一个重传定时器。如果数据包丢了,它必须等到定时器超时才会重传。这通常需要几百毫秒到几秒,效率极低。

快速重传的核心思路就是:  别等定时器了!如果接收方反复在问同一个问题,那肯定是有包丢了!


想象你是个发送方,你发了 1, 2, 3, 4, 5, 6 号包。

  • 接收方顺利收到了包1,它回信说:“包1收到了,我下一个想要包2!”(ACK 2)
  • 这时,包2在网络中丢了,没收到。
  • 接收方紧接着收到了包3。它一看,包2还没到呢,包3就来了?但它最关心的还是包2。于是它再次回信催更:“包1收到了,我下一个还是想要包2!”(ACK 2)
  • 接着,它又收到了包4。它继续回信:“我想要包2!”(ACK 2)
  • 然后又收到了包5,它依然回信:“我想要包2!”(ACK 2)

对于发送方来说,它在短时间内连续收到了 3个重复的 ACK 2(加上第一个正常的,一共是4个 ACK 2)。

第二步:发送方触发“快速重传”

发送方内部有个小本本,专门记着 重复ACK计数器

  • 收到第一个 ACK 2:正常,不管。
  • 收到第二个 ACK 2(第一个重复ACK):计数器+1。心想:“有点奇怪,但再等等。”
  • 收到第三个 ACK 2(第二个重复ACK):计数器+1。心想:“情况不妙,可能真丢了。”
  • 收到第四个 ACK 2(第三个重复ACK) :计数器变成3了!发送方立刻判断:“实锤了!包2肯定丢了!别等重传定时器了,现在就重传包2!”

这个神奇的阈值 3(即收到总计4个相同的ACK)是经过大量实践验证的,能有效避免因为网络短暂乱序而误判丢包。

4: 快速恢复

重传完丢失的包2之后,故事还没完。如果直接回到原来的状态,可能会让网络瞬间再次拥塞。所以 TCP 会紧接着启动 快速恢复 算法:

  1. “假装”事情没那么糟:既然能收到这么多重复ACK,说明网络还能通,只是丢了一个包。所以不像超时重传那样把窗口直接打到1(慢启动),而是只砍半
  2. 保持数据流:在重传期间,发送方还可以继续发送新的数据(比如包7,包8),因为接收方的缓存里还存着包3,4,5,6,窗口并没有被完全占满。

当发送方收到针对包2(以及之后数据)的新ACK(比如 ACK 7),表明丢失的包已经被成功弥补,它就退出快速恢复状态,恢复正常传输。

WeChatWorkScreenshot_ea45b90a-a236-4116-b43c-c53880d890c4.png

上图可以看到,这是一个非常精巧的设计,确认丢包马上降速至新阀值,启动拥塞避免逐步恢复,有人会问为啥丢包就要降速?其实不降速是不行的,这会导致网络拥堵灾难,丢包是在告诉你路堵,已经导致路上的某些汽车已经冲出了道路找不到了。这个是一个非常危险的信号,出于公平使用网络tcp会自动降速,保证网络稳定通畅。


总结:TCP 滑窗的演进

TCP 滑窗机制从最初的基本可靠传输,演进为一套复杂的流量控制和效率优化系统。这种演进体现了网络协议设计的核心智慧:

  • 渐进优化:通过累计确认、延迟确认等机制在保持兼容性的前提下不断提升效率
  • 自适应控制:根据网络状况和应用需求动态调整传输策略

这些设计使得 TCP 在三十年后的今天,依然是互联网不可替代的传输基石。我们需要理解,每一种你觉得没必要的做法背后都是一次小型灾难,如今这些策略已经非常成熟,稳定的服务我们的每一次网络通信中。

注明:文中图片来自互联网

Monorepo 架构以及工具选型、搭建

Monorepo(Monolithic Repository,单体仓库)是一种代码管理策略,核心是将一个项目的所有相关代码(包括多个应用、库、工具链等)集中存储在单个代码仓库中,而非按模块拆分到多个独立仓库(Multirepo)。

📑 目录


快速参考

工具选型速查表

工具类型 推荐工具 适用场景 备选方案
包管理器 pnpm workspace 磁盘效率高、安装速度快 npm workspace、yarn workspace
任务调度 Turbo 增量构建、并行任务、缓存 Nx(企业级)、Rush(大型项目)
版本管理 Changeset monorepo 友好的版本管理 release-it(单包)、Lerna(传统)

快速开始

# 1. 初始化项目
mkdir your-project && cd your-project
pnpm init -y

# 2. 安装 Turbo
pnpm add turbo -D -w

# 3. 配置工作区
# 创建 pnpm-workspace.yaml

# 4. 配置 Turbo
# 创建 turbo.json

# 5. 创建子包
mkdir -p packages/core docs examples/basic

什么是 Monorepo

简单类比

  • Multirepo:像多个独立的文件夹,每个项目 / 库单独存放(比如 reactreact-domreact-router 各一个仓库)
  • Monorepo:像一个大文件夹,里面按功能分类存放所有相关项目(比如 Facebook 的 facebook/react 仓库,包含 React 核心、文档、示例、相关工具等所有代码)

常用结构

monorepo-root/
├── packages/          # 所有可复用包(库、工具)
│   ├── utils/         # 通用工具库
│   ├── components/     # UI 组件库
│   └── cli/           # 命令行工具
├── apps/              # 可部署应用
│   ├── web/           # 网页应用
│   └── admin/         # 管理后台
├── scripts/           # 全局构建/测试脚本
├── package.json       # 根项目配置(依赖、脚本)
└── pnpm-workspace.yaml # 工作区配置(pnpm 为例)

Monorepo vs Multirepo

对比维度 Multirepo(多仓库) Monorepo(单仓库)
依赖管理 重复安装,版本不一致,易冲突 共享依赖,版本统一,减少冗余
跨项目引用 需发布 npm / 用相对路径,同步修改繁琐 本地直接引用,修改实时生效,无需发布
工程化规范 各仓库独立配置,维护成本高 根目录统一配置,所有子项目继承
代码复用 复制粘贴或发布私有包,复用成本高 仓库内直接复用,抽离库更便捷
版本管理与发布 手动协调多包版本(如 A 依赖 B,B 升级后 A 需手动更新) 工具自动管理版本依赖(如 Changeset),批量发布
协作效率 跨仓库 PR 联动复杂,代码审查分散 所有代码在一个仓库,PR 集中,协作更高效

Monorepo 的优缺点

优点

  • 高效协作:所有代码集中管理,跨项目修改无需切换仓库,PR 集中审查
  • 规范统一:工程化配置(lint、测试、构建)全局统一,降低维护成本
  • 依赖优化:共享依赖减少安装体积,版本统一避免冲突
  • 代码复用:子包间直接引用,无需发布,迭代速度快

缺点

  • 仓库体积增大:随着项目增多,仓库体积会变大,但现代 Git(如 Git LFS)可缓解
  • 构建速度:大型 Monorepo 全量构建较慢,需借助 Turborepo 等工具实现增量构建和缓存
  • 权限控制:难以对单个子包进行精细化权限控制(如需控制,可结合 Git 子模块或企业级工具如 GitLab Enterprise)

经典案例

前端 Monorepo 经典案例:

  • Reactfacebook/react 仓库包含 React 核心、React DOM、React Server Components 等所有相关代码
  • Vuevuejs/core 仓库包含 Vue 3 核心、编译器、运行时等
  • Vitevitejs/vite 仓库包含 Vite 核心、官方插件(如 @vitejs/plugin-react)等
  • Tailwind CSStailwindlabs/tailwindcss 仓库包含核心库、CLI、插件等

何时使用 Monorepo

当你的项目满足以下 2 个及以上条件时,优先选择 Monorepo:

  • ✅ 需拆分独立模块(核心包 + 文档 + 示例是典型场景)
  • ✅ 模块间有依赖关系(如示例依赖核心包、文档引用核心包 API)
  • ✅ 需统一构建、测试、发布流程
  • ✅ 追求高效开发(增量构建、并行任务)

何时不使用 Monorepo

  • ❌ 单一核心包 + 简单 README 文档(单包架构足够)
  • ❌ 子包之间完全独立,无依赖关系
  • ❌ 团队规模小,维护成本高

工具选型

工作区管理工具

负责管理多包的依赖安装、路径映射、脚本执行,主流选择:

工具 磁盘效率 安装速度 monorepo 支持 适用场景
pnpm workspace ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 推荐,生态最优
npm workspace ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ npm 7+ 原生支持
yarn workspace ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Yarn 1.x 传统方案
Lerna ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ 早期方案,已过时

选型建议

  • 推荐:pnpm workspace(磁盘效率高、安装速度快、生态完善)
  • 备选:npm workspace(npm 7+ 原生支持,无需额外配置)

核心特性

  • pnpm workspace:轻量、快速,原生支持 Monorepo,通过 pnpm-workspace.yaml 配置子项目路径,自动处理包之间的软链接,安装依赖时复用缓存,效率极高
  • Yarn Workspaces:与 pnpm 功能类似,支持 workspace:* 语法声明内部依赖
  • Lerna:早期流行的 Monorepo 工具,可搭配 npm/yarn 使用,擅长版本管理和发布,但依赖安装效率不如 pnpm

任务调度工具

需支持「多包构建」「增量构建」「依赖顺序构建」,避免每次全量构建:

工具 增量构建 并行任务 缓存机制 配置复杂度 适用场景
Turbo ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 中小型项目(推荐)
Nx ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ 大型企业级项目
Rush ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 超大型项目

选型建议

  • 中小型项目:Turbo(推荐,配置简单,性能优秀)
  • 大型企业级项目:Nx(功能强大,但配置复杂)
  • 超大型项目:Rush(微软开源,适合超大型 monorepo)

核心特性

  • Turbo:高性能构建系统,可缓存构建结果,并行执行任务(构建、测试、lint),大幅提升大型 Monorepo 的构建速度
  • tsup:支持多入口、增量构建,适配 TypeScript 项目,可快速构建多个子包
  • Rollup/Vite:适合构建库或应用,支持 Tree-shaking,Vite 还能提供开发时热更新

版本管理工具

解决多包版本联动、CHANGELOG 自动生成、npm 发布等问题:

工具 monorepo 支持 多包版本同步 自动化程度 配置复杂度 适用场景
Changeset ⭐⭐⭐⭐⭐ ✅ 自动同步 ⭐⭐⭐ ⭐⭐⭐ 多包项目(推荐)
release-it ⭐⭐⭐ ❌ 需手动 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 单包项目、简单场景
Lerna ⭐⭐⭐ ✅ 支持 ⭐⭐⭐ ⭐⭐ 传统方案

选型建议

  • 多包项目、需要版本同步:Changeset(推荐)
  • 单包项目、追求自动化:release-it
  • 简单场景、快速发布:release-it

核心特性

  • Changeset:轻量、易用,支持按子包提交变更记录,自动计算版本号(语义化版本),生成 CHANGELOG,批量发布子包
  • release-it:可搭配 Changeset 使用,提供交互式发布流程,支持 GitHub 标签、发布说明等

代码质量工具

统一管理 lint、测试、格式化:

工具类型 推荐工具 核心功能
代码规范 ESLint + Prettier 根目录配置,所有子项目共享规则,可通过 eslint-config-xxx 抽离自定义规则
测试框架 Vitest / Jest 统一测试框架,支持跨包测试,可在根目录运行所有子项目的测试用例
Git Hooks Husky + lint-staged 提交代码前自动执行 lint 和测试,保障代码质量

实战搭建

以下是最简搭建流程,基于 pnpm(生态最优)+ Turbo + tsup(核心包打包)+ VitePress(文档)+ Vitest(测试)

1. 初始化基础环境

# 创建项目根目录
mkdir your-project && cd your-project

# 初始化根目录 package.json
pnpm init -y

# 安装 Turbo(任务调度)
pnpm add turbo -D -w  # -w 表示安装到根目录(workspace-root)

2. 配置 pnpm 工作区

创建 pnpm-workspace.yaml

# pnpm-workspace.yaml
packages:
  - 'packages/*' # 核心包(可发布)
  - 'docs' # 文档站点(不发布)
  - 'examples/*' # 示例项目(不发布)

3. 配置 Turbo

创建 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["package.json", "turbo.json"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**", "build/**", ".vitepress/dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "outputs": []
    },
    "clean": {
      "cache": false
    }
  }
}

配置说明

  • dependsOn: ["^build"]:构建前先构建依赖的子包
  • outputs:指定构建输出目录,用于缓存判断
  • cache: false:开发模式不缓存,避免热更新问题
  • persistent: true:开发模式持续运行(如 watch 模式)

4. 根目录 package.json 配置

{
  "name": "your-project-monorepo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "clean": "turbo run clean && rm -rf node_modules",
    "format": "prettier --write \"**/*.{ts,tsx,md,json}\""
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "prettier": "^3.0.0"
  },
  "packageManager": "pnpm@9.0.0",
  "engines": {
    "node": ">=18.0.0",
    "pnpm": ">=9.0.0"
  }
}

5. 搭建核心包

# 创建核心包目录并初始化
mkdir -p packages/core && cd packages/core
pnpm init -y

# 安装核心依赖(共享依赖安装到根目录)
pnpm add -D tsup typescript vitest @types/node -w

核心包 package.json

{
  "name": "@your-org/core",
  "version": "1.0.0",
  "description": "核心功能包",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint src/**/*.ts",
    "clean": "rm -rf dist coverage"
  },
  "keywords": ["core", "utils"],
  "license": "MIT"
}

核心包 tsup.config.ts

import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  sourcemap: true,
  minify: true,
  splitting: false,
});

核心包 src/index.ts

export const greet = (name: string) => {
  return `Hello, ${name}!`;
};

export const add = (a: number, b: number) => {
  return a + b;
};

6. 搭建文档站点

# 创建文档目录并初始化
mkdir docs && cd docs
pnpm init -y

# 安装文档依赖
pnpm add -D vitepress @vitepress/theme-default -w

# 初始化 VitePress 文档
npx vitepress init

文档 package.json

{
  "name": "@your-org/docs",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vitepress dev",
    "build": "vitepress build",
    "preview": "vitepress preview",
    "lint": "eslint . --ext .md,.ts",
    "clean": "rm -rf .vitepress/dist"
  },
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

在 Markdown 中可直接导入核心包,用于示例演示:

# 快速使用

```ts
import { greet } from '@your-org/core';

console.log(greet('World')); // Hello, World!
```

7. 搭建示例项目

# 创建示例目录并初始化
mkdir -p examples/basic && cd examples/basic
pnpm init -y

# 安装依赖(使用工作区协议引用核心包)
pnpm add @your-org/core@workspace:* -w
pnpm add -D vite @vitejs/plugin-react -w

示例 package.json

{
  "name": "@your-org/example-basic",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

8. 核心命令使用

命令 作用
pnpm run dev 同时启动核心包监听、文档热更新、示例热更新
pnpm run build 一键构建核心包(ESM/CJS + 类型)、文档静态资源、示例
pnpm run test 运行所有子包的测试(核心包单元测试、示例冒烟测试)
pnpm run lint 统一校验所有子包的代码规范
pnpm run clean 清理所有子包的构建产物和缓存

增量构建效果示例

  • 首次执行 pnpm run build:构建所有子包(core + docs + examples)
  • 修改核心包代码后再次执行 pnpm run build:仅重建 core 和依赖它的 examples,docs 未变更则直接复用缓存,构建速度提升 50%+

过滤特定子包执行任务

# 只构建核心包
pnpm run build --filter=@your-org/core

# 只构建文档和示例
pnpm run build --filter=@your-org/docs --filter=@your-org/example-basic

# 构建核心包及其依赖者
pnpm run build --filter=@your-org/core...

9. 版本管理与发布

使用 Changeset(推荐,适合多包版本同步)

Changeset 完全支持 Turbo monorepo,可仅发布核心包(packages/core),文档和示例不发布,并支持多包版本同步:

优势

  • ✅ 专为 monorepo 设计,支持多包版本同步
  • ✅ 自动更新依赖包的版本号
  • ✅ 变更记录清晰,便于追溯

劣势

  • ❌ 需要手动记录变更(npx changeset
  • ❌ 流程相对复杂

安装与配置

# 安装 Changeset 并初始化
pnpm add @changesets/cli -D -w
npx changeset init

修改 Changeset 配置(.changeset/config.json)

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@your-org/docs", "@your-org/example-basic"]
}

发布流程

# 1. 记录核心包变更(仅选择 @your-org/core)
npx changeset

# 2. 升级版本 + 生成 CHANGELOG
npx changeset version

# 3. 构建核心包
pnpm run build --filter=@your-org/core

# 4. 发布核心包
pnpm publish --filter=@your-org/core --access public

使用 release-it(适合单包发布或简单场景)

release-it 也可以用于 monorepo,但主要用于单包发布场景。

优势

  • ✅ 自动化程度高,一条命令完成发布
  • ✅ 配置灵活,支持自定义发布流程
  • ✅ 支持 GitHub Release、npm 发布等

劣势

  • ❌ 不支持多包版本同步(需要手动处理)
  • ❌ 需要为每个包单独配置或使用脚本

方式一:在子包中单独配置(推荐)

在需要发布的子包中配置 release-it:

# 在核心包目录下
cd packages/core
pnpm add release-it @release-it/conventional-changelog -D

核心包 release-it.config.js

module.exports = {
  git: {
    commitMessage: 'chore: release @your-org/core@${version}',
    tagName: '@your-org/core@${version}',
    requireCleanWorkingDir: false,
    requireBranch: 'main',
    requireCommits: true,
  },
  github: {
    release: true,
    releaseName: '@your-org/core@${version}',
  },
  npm: {
    publish: true,
    publishPath: './',
  },
  hooks: {
    'before:init': ['pnpm run test'],
    'after:bump': ['pnpm run build'],
    'after:release': 'echo "Release @your-org/core@${version} completed!"',
  },
  plugins: {
    '@release-it/conventional-changelog': {
      preset: 'angular',
      infile: 'CHANGELOG.md',
    },
  },
};

核心包 package.json scripts

{
  "scripts": {
    "release": "release-it",
    "release:patch": "release-it patch",
    "release:minor": "release-it minor",
    "release:major": "release-it major"
  }
}

发布流程

# 在核心包目录下
cd packages/core
pnpm run release

方式二:在根目录统一配置(适合单包发布)

在根目录配置,配合 pnpm filter 使用:

# 在根目录安装
pnpm add release-it @release-it/conventional-changelog -D -w

根目录 release-it.config.js

module.exports = {
  git: {
    commitMessage: 'chore: release v${version}',
    tagName: 'v${version}',
    requireCleanWorkingDir: false,
    requireBranch: 'main',
  },
  hooks: {
    'before:init': ['pnpm run test --filter=@your-org/core'],
    'after:bump': ['pnpm run build --filter=@your-org/core'],
  },
  plugins: {
    '@release-it/conventional-changelog': {
      preset: 'angular',
      infile: 'CHANGELOG.md',
    },
  },
};

根目录 package.json scripts

{
  "scripts": {
    "release": "release-it",
    "release:core": "cd packages/core && pnpm run release"
  }
}

Changeset vs release-it 对比

特性 Changeset release-it
monorepo 支持 ⭐⭐⭐⭐⭐(专为 monorepo 设计) ⭐⭐⭐(需手动配置)
多包版本同步 ✅ 自动同步 ❌ 需手动处理
自动化程度 ⭐⭐⭐(需手动记录变更) ⭐⭐⭐⭐⭐(一条命令)
配置复杂度 ⭐⭐⭐ ⭐⭐⭐⭐
适用场景 多包项目、版本同步需求 单包项目、简单场景

选型建议

  • 多包项目、需要版本同步:Changeset(推荐)
  • 单包项目、追求自动化:release-it
  • 简单场景、快速发布:release-it

高级配置

Turbo 缓存优化

1. 配置远程缓存(可选)

Turbo 支持远程缓存,团队共享构建缓存:

# 安装 Turbo 远程缓存客户端
pnpm add turbo -D -w

# 登录 Vercel(免费提供远程缓存)
npx turbo login

# 链接项目
npx turbo link

2. 优化缓存配置

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV"], // 环境变量变化时重新构建
      "inputs": ["src/**/*.ts", "tsup.config.ts"] // 指定输入文件
    }
  }
}

CI/CD 集成

GitHub Actions 示例

创建 .github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install

      - name: Build
        run: pnpm run build

      - name: Test
        run: pnpm run test

      - name: Lint
        run: pnpm run lint

依赖管理策略

1. 公共依赖提升到根目录

# 安装公共依赖到根目录
pnpm add -D typescript eslint prettier -w

# 子包无需重复安装,直接使用

2. 子包间依赖使用工作区协议

{
  "dependencies": {
    "@your-org/core": "workspace:*" // ✅ 正确
    // "@your-org/core": "1.0.0"     // ❌ 错误,无法实时同步
  }
}

3. 版本统一管理

使用 .npmrc 统一配置:

# .npmrc
shamefully-hoist=true
strict-peer-dependencies=false

最佳实践

  1. 子包命名规范:使用 scope(如 @your-org/core),避免命名冲突
  2. 依赖管理:公共依赖提升到根目录,子包间使用 workspace:* 协议
  3. 任务配置:在 turbo.json 中准确配置 outputs,确保缓存生效
  4. 版本管理:使用 Changeset 管理版本,仅发布需要发布的包
  5. 目录结构:按功能拆分(packages、docs、examples),保持清晰
  6. 避免过度拆分:子包数量控制在 5 个以内,过多会增加配置复杂度
  7. 开发体验:使用 pnpm run dev 同时启动所有子包的开发模式

常见问题

工作区相关问题

Q: 子包间依赖如何引用?

A: 使用工作区协议 workspace:*

{
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

Q: 如何安装依赖到特定子包?

A:

# 安装到根目录
pnpm add -D typescript -w

# 安装到特定子包
pnpm add -D vite --filter @your-org/example-basic

# 安装到所有子包
pnpm add -D eslint --filter "./packages/*"

Q: 如何查看所有子包?

A:

pnpm list -r --depth=0

Turbo 相关问题

Q: Turbo 缓存不生效怎么办?

A:

  1. 检查 turbo.json 中的 outputs 配置是否正确
  2. 检查构建输出目录是否在 outputs 中声明
  3. 清理缓存:pnpm run clean && rm -rf .turbo

Q: 如何只构建变更的子包?

A: Turbo 默认就是增量构建,只需执行 pnpm run build,Turbo 会自动判断哪些子包需要重新构建。

Q: 如何跳过缓存强制构建?

A:

pnpm run build --force

Q: 如何查看构建依赖关系?

A:

# 查看任务依赖图
npx turbo run build --graph

版本管理相关问题

Q: Changeset 如何只发布特定包?

A: 在 .changeset/config.json 中配置 ignore 字段,或在执行 npx changeset 时只选择需要发布的包。

Q: 如何自动化发布流程?

A: 使用 GitHub Actions + Changeset,参考 Changeset 文档

Q: release-it 能否用于 monorepo?

A: 可以,但主要用于单包发布场景。如果项目只有一个包需要发布,release-it 更简单;如果需要多包版本同步,建议使用 Changeset。

Q: release-it 如何发布 monorepo 中的特定包?

A:

# 方式一:在子包目录下执行
cd packages/core
pnpm run release

# 方式二:使用 pnpm filter
pnpm --filter @your-org/core run release

性能优化相关问题

Q: 如何提升构建速度?

A:

  1. 配置 Turbo 远程缓存(团队共享)
  2. 优化 turbo.jsonoutputs 配置
  3. 使用 dependsOn 合理配置任务依赖
  4. 避免不必要的任务依赖

Q: 如何减少 node_modules 体积?

A:

  1. 使用 pnpm(默认使用符号链接,节省磁盘空间)
  2. 公共依赖提升到根目录
  3. 使用 .npmrc 配置 shamefully-hoist=false

参考资源


文档版本:v2.0
最后更新:2024 年

cursor如何安装vscode插件

前言

下面会分享 cursor 中如何安装 vscode插件

方案一

点击 插件市场 图标,通过 搜索框 搜索相关插件

image.png

方案二

有时候在 vscode 发布的插件并不一定能在 cursor 的插件市场搜到,我们可以选择手动安装,这里以 mac 电脑为例

首先,我们随意点击一个插件,找到 Size

image.png

点击这个 Size 能打开 cursor 插件 的安装目录

image.png

我们把要安装的 vscode 插件 放到 extensions 目录 中,再把 extensions.json 中的插件相关信息拷贝一下,以我的 文件名复制插件 Copy Filename Pro 为例,它的信息是这样的

{
    "identifier": {
      "id": "chouchouji.copy-filename-pro",
      "uuid": "30cb65df-4ab9-4842-b8ed-5daae96f8096"
    },
    "version": "0.3.0",
    "location": {
      "$mid": 1,
      "path": "/Users/xxx/.cursor/extensions/chouchouji.copy-filename-pro-0.3.0",
      "scheme": "file"
    },
    "relativeLocation": "chouchouji.copy-filename-pro-0.3.0",
    "metadata": {
      "installedTimestamp": 1744702279283,
      "pinned": false,
      "source": "gallery",
      "id": "30cb65df-4ab9-4842-b8ed-5daae96f8096",
      "publisherId": "ac995f6c-c315-46fc-b922-8ce3a7e5884f",
      "publisherDisplayName": "chouchouji",
      "targetPlatform": "undefined",
      "updated": false,
      "private": false,
      "isPreReleaseVersion": false,
      "hasPreReleaseVersion": false
    }
  }

将这个配置放到插件同层级的 extensions.json 中就大功告成了

image.png

深入理解 JavaScript Promise:原理、用法与实践

引言

在现代 JavaScript 开发中,异步编程是无法回避的核心话题。随着 Web 应用复杂度的提升,传统的回调函数(Callback)方式逐渐暴露出“回调地狱”(Callback Hell)等问题。为了解决这一难题,ES6 引入了 Promise 对象,提供了一种更加优雅、可读性更强的异步处理机制。

本文将结合提供的代码示例和文档说明,系统性地讲解 Promise 的基本概念、状态机制、核心方法(如 .then().catch())、链式调用、嵌套 Promise 的行为,并通过实际案例展示其在文件读取等场景中的应用。


一、Promise 是什么?

根据 readme.md 中的定义:

Promise 简单说是一个容器(对象),里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。

Promise 有三种状态:

  • pending(进行中) :初始状态,既不是成功也不是失败。
  • fulfilled(已成功) :操作成功完成。
  • rejected(已失败) :操作失败。

关键特性:

  • 状态不可逆:一旦状态从 pending 变为 fulfilled 或 rejected,就不会再改变
  • 状态由内部决定:Promise 的状态变化由其内部的异步操作决定,不受外界影响

二、Promise 的基本用法

1. 创建 Promise

// 1.js 示例
const p = new Promise(function (resolve, reject) {
  setTimeout(function () {
    let err = '数据读取失败';
    reject(err);
  }, 1000);
});

p.then(
  function (value) {
    console.log(value); // 成功回调
  },
  function (reason) {
    console.log(reason); // 失败回调 → 输出 "数据读取失败"
  }
);

在这个例子中,我们创建了一个在 1 秒后调用 reject 的 Promise。.then() 方法接收两个参数:第一个是 resolve 的回调,第二个是 reject 的回调。

注意:虽然可以这样写,但更推荐使用 .catch() 来统一处理错误(见后文)。

2. Promise 立即执行


// 2.js 示例
let promise = new Promise(function (resolve, reject) {
  console.log('Promise'); // 立即执行
  resolve();
});

promise.then(function () {
  console.log('resolved');
});

console.log('Hi!');

// 输出顺序:
// Promise
// Hi!
// resolved

这说明:

  • Promise 构造函数是同步执行的,所以 'Promise' 最先输出。
  • .then() 中的回调是微任务(microtask) ,会在当前宏任务(script 执行)结束后、下一个宏任务开始前执行,因此 'resolved' 最后输出。

三、Promise 的链式调用与返回新 Promise

1. .then() 返回新 Promise

.then() 方法返回的是一个新的 Promise 实例(注意,不是原来那个 Promise 实例)。因此可以采用链式写法。

这意味着我们可以连续调用多个 .then(),每个 .then() 都可以处理上一个 Promise 的结果。

2. 在 .then() 中返回另一个 Promise

// 5.js 示例
getJSON("/post/1.json")
  .then(post => getJSON(post.commentURL)) // 返回新 Promise
  .then(
    comments => console.log("resolved: ", comments),
    err => console.log("rejected: ", err)
  );

这里的关键在于:第一个 .then() 返回的是 getJSON(...) 的结果,它本身就是一个 Promise。因此,第二个 .then() 会等待这个新 Promise 的状态变化。

  • 如果 post.commentURL 请求成功 → 调用第一个回调(打印 comments)
  • 如果任一环节失败 → 调用第二个回调(打印 error)

这种模式极大简化了多层异步依赖的处理。


四、错误处理:.catch() 的作用


// 6.js 示例
getJSON('/posts.json')
  .then(function (posts) {
    // ...
  })
  .catch(function (error) {
    console.log('发生错误!', error);
  });

根据 readme.md

.catch().then(null, rejection) 的别名,用于指定发生错误时的回调函数。

更重要的是:

  • .catch() 能捕获前面所有 .then() 中抛出的错误(包括同步错误和异步 reject)。
  • 它使得错误处理集中化,避免在每个 .then() 中都写错误回调。

例如:


Promise.resolve()
  .then(() => {
    throw new Error('出错了!');
  })
  .catch(err => {
    console.log(err.message); // "出错了!"
  });

五、嵌套 Promise 与状态传递

这是 Promise 中最容易被误解的部分之一。

// 3.js 示例(注释版)
const p1 = new Promise(function(resolve, reject){
  setTimeout(() => reject(new Error('fail')), 3000);
});

const p2 = new Promise(function(resolve, reject){
  setTimeout(() => resolve(p1), 1000); // resolve 传入的是 p1(另一个 Promise)
});

p2
  .then(result => console.log(result))
  .catch(err => console.log(err)); // 输出 Error: fail

关键点解析:

  • p2 在 1 秒后调用 resolve(p1),但 p1 本身是一个 Promise。
  • 当 resolve() 的参数是一个 Promise 实例时,当前 Promise(p2)的状态将由该 Promise(p1)决定
  • 因此,p2 的状态实际上“代理”了 p1 的状态。
  • 2 秒后(总耗时 3 秒),p1 被 reject,于是 p2 也变为 rejected,触发 .catch()

这一机制使得我们可以“转发”或“组合”多个异步操作,而无需手动监听每个 Promise。


六、实战:链式读取多个文件

// 7.js 示例(修正版)
const p = new Promise((resolve, reject) => {
  FileSystem.readFile('./1.txt', (err, data) => {
    if (err) reject(err);
    else resolve(data);
  });
});

p
  .then(value => {
    return new Promise((resolve, reject) => {
      FileSystem.readFile('./2.txt', (err, data) => {
        if (err) reject(err);
        else resolve([value, data]);
      });
    });
  })
  .then(value => {
    return new Promise((resolve, reject) => {
      FileSystem.readFile('./3.txt', (err, data) => {
        if (err) reject(err);
        else resolve([...value, data]);
      });
    });
  })
  .then(value => {
    console.log(value); // [data1, data2, data3]
  })
  .catch(err => {
    console.error('读取文件出错:', err);
  });

这个例子展示了:

  • 如何通过链式 .then() 依次读取多个文件。
  • 每一步都将之前的结果累积到数组中。
  • 使用 .catch() 统一处理任意一步的 I/O 错误。

虽然现代 Node.js 更推荐使用 fs.promisesasync/await,但此例清晰体现了 Promise 链如何管理依赖型异步流程。


七、最佳实践与注意事项

  1. 始终使用 .catch()
    不要只依赖 .then() 的第二个参数,因为 .then() 内部的同步错误无法被其自身捕获,但能被后续 .catch() 捕获。
  2. 避免“Promise 嵌套地狱”
    不要写 new Promise(resolve => { anotherPromise().then(...) }),应直接返回 Promise。
  3. 理解微任务队列
    Promise 回调属于微任务,执行时机早于 setTimeout 等宏任务。
  4. 不要忽略错误
    未处理的 rejected Promise 会导致“未捕获的异常”,在 Node.js 中可能使进程崩溃。
  5. 考虑使用 async/await
    虽然 Promise 很强大,但在复杂逻辑中,async/await 语法更接近同步代码,可读性更高。

结语

Promise 是 JavaScript 异步编程的基石。它通过状态机模型、链式调用和统一的错误处理机制,有效解决了回调地狱问题。通过本文分析,我们不仅掌握了 Promise 的基本用法,还深入理解了其内部状态传递、嵌套行为和实际应用场景。

掌握 Promise,是迈向现代前端与 Node.js 开发的关键一步。在此基础上,进一步学习 async/awaitPromise.all()Promise.race() 等高级特性,将使你能够构建更加健壮、可维护的异步程序。

正如那句老话:“理解了 Promise,你就理解了 JavaScript 的异步灵魂。

深入理解 JavaScript 词法作用域链:从代码到底层实现机制

一、引言:一个令人困惑的示例

先来看一段看似简单却容易出错的 JavaScript 代码:

// 全局环境
var myName = '极客时间';
let myAgent = 10;
let test = 1;

function bar(){
  console.log(myName);
}

function foo(){
  var myName = '极客邦';
  bar();
}

foo(); // 输出什么?

直觉上,很多人会认为输出应该是 '极客邦',因为 bar() 是在 foo() 内部调用的。但实际上,这段代码输出的是 '极客时间'

为什么会出现这样的结果?这就引出了 JavaScript 中一个核心概念——词法作用域链

二、什么是词法作用域?

词法作用域(Lexical Scope)指的是:变量的可见性由函数在源代码中的声明位置决定,而不是函数被调用的位置

换句话说,解析变量名的"查找路径"(即作用域链)在代码的编译/解析阶段就已经确定好了,与运行时调用栈的顺序无关。这就是为什么 bar() 函数始终访问的是全局的 myName,因为它在源码中就是在全局作用域声明的。

三、更复杂的示例:混合作用域类型

让我们看一个更复杂的例子,包含 varlet 和块级作用域:

function bar() {
  var myName = '极客世界';
  let test1 = 100;
  if (1) {
    let myName = 'Chrome';
    console.log(test); // 这里会输出什么?
  }
}

function foo() {
  var myName = '极客邦';
  let test = 2;
  {
    let test = 3;
    bar();
  }
}

var myName = '极客时间';
let test = 1;
foo();

这段代码展示了:

  • var 的函数级作用域
  • let 的块级作用域
  • 不同位置声明的变量如何相互影响

关键点在于:bar 在源码中声明的位置决定了它能访问的外层词法环境。即使 bar()foo 里的某个块中被调用,它也无法看到 foo 的局部变量(除非 bar 是在 foo 内部声明的)。

四、JavaScript 引擎的内部机制

要真正理解作用域链,我们需要深入到 JavaScript 引擎(如 V8)的实现层面。

执行上下文的组成

每个执行上下文(Execution Context)包含三个核心部分:

  1. Variable Environment(变量环境) - 存储 varfunction 声明
  2. Lexical Environment(词法环境) - 存储 let / const / class 声明
  3. ThisBinding(this 绑定) 及可执行代码

现代 JavaScript 引擎中,变量环境和词法环境是两套独立但协同工作的系统,它们各自维护环境记录(Environment Record),并共享相同的外层指针(outer),构成"并行的作用域链结构"。

编译阶段 vs 执行阶段

JavaScript 函数的执行分为两个关键阶段:

1. 编译阶段(Compilation)

在这个阶段,引擎会:

创建 Variable Environment:

  • 登记 var 声明(初始化为 undefined
  • 登记函数声明(初始化为对应函数对象)

创建 Lexical Environment:

  • 登记 let / const / class 声明,但保持在 TDZ(暂时性死区)
  • 为块级作用域创建独立的词法环境

建立 outer 链接:

  • 确定当前环境的外层环境引用
  • 这个链接基于代码的静态结构,而非运行时调用

2. 执行阶段(Execution)

代码真正开始执行时:

  1. 访问变量时,查找顺序为:

    • 先查 Lexical Environment(块级作用域 + let/const)
    • 找不到则查 Variable Environment(var/function)
    • 再沿着 outer 指针向外层环境查找,直到全局
  2. 环境记录中的值会被不断更新(赋值、初始化等)

执行上下文的内部结构

从实现角度看,执行上下文可以表示为:

Execution Context = {
  EnvironmentRecord: {
    Variable Environment,
    Lexical Environment,
    outer // 指向外层词法环境的引用
  },
  code  // 可执行代码
}

不同类型的声明有不同的处理策略:

  • var:在编译阶段被初始化为 undefined
  • function:在编译阶段被绑定为函数对象
  • let/const:在词法环境中登记,但直到执行到声明语句才正式初始化

五、回到示例:为什么是全局的 myName?

现在我们可以完整解释开头的例子了:

  1. bar 在全局作用域声明,因此 bar.[[Environment]] 指向全局词法环境
  2. bar 执行并访问 myName 时,查找路径是:
    • bar 的局部环境(没有找到)
    • 沿着 [[Environment]] 到全局环境
    • 找到 myName = '极客时间'
  3. barfoo 内部调用的事实不改变[[Environment]] 引用

这就是词法作用域(静态作用域)与动态作用域的核心区别。

六、闭包(closure)是如何“借用”词法作用域的

简单版结论:闭包是函数和其声明时关联的词法环境的组合

function foo(){
  var myName = '极客时间';
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    getName: function(){
      console.log(test1);
      return myName;
    },
    setName: function(newName){
      myName = newName;
    }
  }
  return innerBar;
}

var bar = foo();
bar.setName('极客邦');
console.log(bar.getName()); // '极客邦'

分析:

  • getName / setNamefoo 内声明,因此它们的 [[Environment]] 指向 foo 的词法环境。
  • foo 返回 innerBar 后,foo 的执行上下文弹出调用栈,但 foo 的词法环境并未被回收,因为 innerBar 中的函数仍然通过闭包引用该环境(环境是“可达”的)。这就是闭包保持自由变量存活的机制。

GC(垃圾回收)角度

  • 只有当 foo 的词法环境不再被任何可达对象(如返回的函数对象)引用时,才会被回收。
  • 因此 bar(上例返回的对象)持有对那块环境的引用,导致 myNametest1 等变量继续存活。

七、常见面试/调试陷阱

  1. 函数在哪里声明,在哪里决定它的外部环境:无论何时调用,外部环境由声明位置决定。
  2. 调用栈 vs 环境:调用栈控制运行顺序和执行上下文的创建/销毁;环境控制变量解析路径,二者不同步。环境包括变量环境和词法环境。
  3. varlet/const 的差别var 是函数级(或全局)绑定且会被提前初始化为 undefinedlet/const 是块级绑定且存在 TDZ。
  4. 闭包不等于内存泄漏:闭包让外层环境继续可达,因此不会被 GC;需要手动断开引用(如把返回对象设为 null)来释放内存。

八、实践建议(写更容易理解、调试的代码)

  • 尽量用 let/const 而不是 var,避免意外提升带来的迷惑。
  • 函数如果需要访问周围变量,尽量把它在恰当的词法位置声明,这样阅读代码时能直观得知依赖关系。
  • 对长期持有闭包引用的场景(如事件回调、定时器、长生命周期对象),显式释放引用或把需要缓存的数据放到显式的对象上,以便管理其生命周期。

九、小结(一句话回顾)

词法作用域链在编译阶段就决定了变量解析路径;闭包则是函数与其声明时词法环境的绑定,正是它使得某些局部变量在函数返回后仍然存活。

ehcarts 实现 饼图扇区间隙+透明外描边

image.png

以上是UI的效果图,大致实现思路可以参考echarts官网的实例 (饼图扇区间隙)实现类似的效果。

image.png

配置如下:

option = {
  tooltip: {
    trigger: 'item'
  },
  legend: {
    top: '5%',
    left: 'center'
  },
  series: [
    {
      name: 'Access From',
      type: 'pie',
      radius: ['35%', '50%'],
      avoidLabelOverlap: false,
      padAngle: 2,
      itemStyle: {
        borderRadius: 0
      },
      label: {
        show: false,
        position: 'center'
      },
      emphasis: {
        label: {
          show: true,
          fontSize: 20,
          fontWeight: 'bold'
        }
      },
      labelLine: {
        show: false
      },
      data: [
        { value: 1048, name: 'Search Engine' },
        { value: 735, name: 'Direct' },
        { value: 580, name: 'Email' },
        { value: 484, name: 'Union Ads' },
        { value: 300, name: 'Video Ads' }
      ]
    },
    {
      name: 'Access From',
      type: 'pie',
      radius: ['50%', '55%'],
      avoidLabelOverlap: false,
      padAngle: 2,
      itemStyle: {
        borderRadius: 0,
        opacity: 0.2
      },
      label: {
        show: false,
        position: 'center'
      },
      emphasis: {
        label: {
          show: false
        }
      },
      labelLine: {
        show: false
      },
      data: [
        { value: 1048, name: 'Search Engine' },
        { value: 735, name: 'Direct' },
        { value: 580, name: 'Email' },
        { value: 484, name: 'Union Ads' },
        { value: 300, name: 'Video Ads' }
      ]
    }
  ]
};

再调整一些参数,基本上能满足UI的效果,这里不详细赘述。

Next.js SEO 优化完整方案

适用于 Next.js 15(App Router) 的 SEO 全流程优化指南,包括页面级 SEO、站点级 SEO、组件优化、性能优化、结构化数据、国际化等内容。


1. 页面级 SEO

1.1 使用 metadata 配置页面 SEO

// app/page.tsx
export const metadata = {
  title: "首页标题 | 品牌词",
  description: "页面描述,建议 50~160 字。",
  keywords: ["关键词1", "关键词2"],
  openGraph: {
    title: "OG 标题",
    description: "OG 描述",
    url: "https://xxx.com",
    images: [{ url: "/og.jpg" }],
  },
  alternates: {
    canonical: "https://xxx.com",
  },
};

1.2 动态页面 SEO(如文章详情)

// app/blog/[id]/page.tsx
export async function generateMetadata({ params }) {
  const data = await getPost(params.id);

  return {
    title: data.title,
    description: data.summary,
    openGraph: {
      images: data.cover,
    },
    alternates: {
      canonical: `https://xxx.com/blog/${params.id}`,
    },
  };
}

2. 渲染模式与 SEO

渲染方式 SEO 效果 适用场景
SSR(默认) ⭐⭐⭐⭐ 动态数据页面
SSG ⭐⭐⭐⭐⭐ 静态内容、博客
ISR ⭐⭐⭐⭐⭐ 内容频繁更新页面

ISR 使用示例

export const revalidate = 60; // 页面缓存 60 秒

3. URL 结构优化

  • 使用语义化目录: /blog/xxx
  • 避免 query 作主要结构: /search?q=xxx
  • URL 小写、简短、语义化

4. 站点级 SEO

4.1 robots.txt

// app/robots.ts
export default function Robots() {
  return {
    rules: [{ userAgent: "*", allow: "/" }],
    sitemap: "https://xxx.com/sitemap.xml",
  };
}

4.2 sitemap.xml 自动生成

// app/sitemap.ts
export default async function sitemap() {
  const posts = await getPosts();

  return [
    { url: "https://xxx.com", lastModified: new Date() },
    ...posts.map(p => ({
      url: `https://xxx.com/blog/${p.id}`,
      lastModified: p.updated_at,
    })),
  ];
}

5. 组件级 SEO

5.1 使用语义标签

<main>
<article>
<header>
<footer>
<section>
<nav>

5.2 使用 next/image 优化图片

<Image src="/hero.png" alt="banner" width={800} height={600} />

5.3 延迟加载非关键组件

const Comments = dynamic(() => import('./Comments'), { ssr: false });

6. 性能优化(SEO 强关联)

  • 仅在必要组件使用 use client
  • 使用 next/image(自动压缩、lazyload、webp)
  • 减少 API 延迟:Edge Runtime、Server Actions
  • 打包体积优化(减少第三方库)

7. 国际化 SEO(可选)

export const metadata = {
  alternates: {
    canonical: "https://xxx.com",
    languages: {
      "en-US": "https://xxx.com/en",
      "zh-CN": "https://xxx.com/zh",
    },
  },
};

8. 结构化数据(Rich Snippets)

<script type="application/ld+json">
{JSON.stringify({
  "@context": "https://schema.org",
  "@type": "Article",
  headline: title,
  datePublished: created,
  dateModified: updated,
  author: { "@type": "Person", name: "作者名" }
})}
</script>

9. 上线前 SEO Checklist

项目 状态
页面 metadata 配置完整
sitemap.xml 正常生成
robots.txt 正常访问
canonical 链接填写
OG 信息正常
渲染方式:SSR/SSG/ISR
URL 语义化
图片全部用 next/image
lighthouse ≥ 90
结构化数据(可选)

10. metadata 字段说明

字段 作用
title 页面标题
description SEO 摘要
keywords 关键词(影响极弱,可选)
openGraph 社交媒体分享卡片信息
alternates.canonical 主 URL,用于防止重复页面降权
alternates.languages 多语言 SEO

11. 推荐实践总结

  1. 优先 SSR 或 SSG 渲染关键内容
  2. metadata + canonical + sitemap + robots.txt 配置完整
  3. URL 简短语义化,避免重复
  4. 使用 next/image、语义化标签和动态加载优化性能
  5. 配置 OpenGraph 和结构化数据提升社交分享与搜索展示效果
  6. 国际化站点务必设置语言 alternates
  7. 定期使用 Lighthouse 或 PageSpeed 检测性能

JavaScript 词法作用域与闭包:从底层原理到实战理解

JS运行机制

词法作用域

“词法”这个词听起来有点抽象,其实它的意思很简单: “词法” = “和你写代码的位置有关”

换句话说,JavaScript 中很多行为在你写代码的时候就已经确定了,而不是等到程序运行时才决定。这种特性也叫静态作用域(static scoping)。

你可以这样理解:

代码怎么写的,它就怎么执行——这非常符合我们的直觉。

比如,letconst 声明的变量之所以不能在声明前使用(会报“暂时性死区”错误),就是因为它们属于词法环境的一部分。而词法环境正是由你在源代码中的书写位置决定的。你把变量写在哪里,它就在哪里生效,不能“穿越”到还没写到的地方去用——这很合理,也很直观。

所以,“词法”本质上就是:看代码结构,而不是看运行过程

看一段关于词法作用域的代码

function bar() {
    console.log(myName);
}
function foo() {
    var myName = '极客邦'
    bar()// 运行时
}
var myName = '极客时间'
foo();

这里输出的是

极客时间 为什么输出的不是 "极客邦"

因为 bar 函数是在全局作用域中声明的,所以它的词法作用域链在定义时就已经固定为:自身作用域 → 全局作用域

JavaScript 查找变量时,遵循的是词法作用域规则——也就是说,它只关心函数在哪里被定义,而不关心函数在哪里被调用

bar 内部访问变量(比如 testmyName)时,引擎会先在 bar 自己的执行上下文中查找;如果找不到,就沿着词法作用域链向外层查找,也就是直接跳到全局作用域,而不会进入 foo 的作用域——尽管 bar 是在 foo 里面被调用的。

因此,bar 根本“看不见” foo 中的 myName = "极客邦",自然也就无法输出它。

image.png

总结:

JavaScript 使用 词法作用域(Lexical Scoping) ,也就是说,函数在定义时就决定了它能访问哪些变量,而不是在调用时

词法作用域链:变量查找的路径

当 JavaScript 引擎执行代码时,会为每一段可执行代码创建一个 执行上下文(Execution Context)
每个执行上下文都包含一个 词法环境(Lexical Environment) ,它不仅保存了当前作用域中声明的变量,还持有一个指向外层词法环境的引用。这些嵌套的词法环境连接起来,就形成了 作用域链(Scope Chain)

  • 全局执行上下文位于调用栈的底部,是程序启动时创建的。
  • 每当调用一个函数,就会创建一个新的函数执行上下文,并将其压入调用栈。
  • 当需要查找某个变量时,JavaScript 会从当前作用域开始,沿着作用域链由内向外逐层查找,直到找到该变量,或最终到达全局作用域为止。

这种机制确保了变量访问遵循词法作用域规则——即“在哪里定义,就看哪里的变量”,而不是“在哪里调用”。

看看这段关于作用域链和块级作用域的代码:

function bar () {
  var myName = "极客世界";
  let test1 = 100;
  if (1) {
    let myName = "Chrome 浏览器"  // 1.先在词法环境查找一下
    console.log(test)
  }
}
function foo() {
  var myName = "极客邦";
  let test = 2;
  {
    let test = 3;
    bar()
  }
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();

这段代码的执行结果不会报错,而是会正常输出:

1

原因正是基于 JavaScript 的 词法作用域(Lexical Scoping) 机制。

虽然 bar() 是在 foo() 内部被调用的,但它的声明位置在全局作用域。因此,当 bar 内部引用变量 test 时,JavaScript 引擎会从 bar 自身的作用域开始查找;找不到时,就沿着词法作用域链向外层查找——也就是直接跳到全局作用域,而不会进入 foo 的作用域。

由于全局作用域中存在 let test = 1;,所以 console.log(test) 最终输出的是 1

image.png

换句话说:变量查找看的是函数“在哪里定义”,而不是“在哪里调用” 。这条由内向外的查找路径,就是我们所说的 作用域链

在 JavaScript 的设计中,每个函数的执行上下文都包含一个内部指针(通常称为 [[Outer]] 或 “outer 引用”),它指向该函数定义时所在的作用域——也就是它的词法外层环境

当你在代码中嵌套定义多个函数时,每个函数都会通过这个 outer 指针,链接到它上一层的词法环境。这样一层套一层,就形成了一条静态的、由代码结构决定的链式结构,我们称之为 词法作用域链(Lexical Scope Chain)

正是这条链,决定了变量查找的路径:从当前作用域开始,沿着 outer 指针逐级向外搜索,直到全局作用域为止。

image.png

这种机制是 JavaScript 闭包、变量访问和作用域行为的核心基础。理解了 outer 指针如何连接各个词法环境,你就真正掌握了词法作用域链的本质。

闭包 ——前面内容的优雅升华

闭包(Closure)是 JavaScript 中一个基于词法作用域的核心机制。掌握它,不仅能写出更灵活、模块化的代码,还能轻松应对面试中的高频问题。下面用通俗易懂的方式,带你彻底搞懂闭包。


一、什么是闭包?

闭包 = 一个函数 + 它定义时所处的词法环境。

换句话说:
当一个函数即使在自己原始作用域之外被调用,仍然能够访问并操作其定义时所在作用域中的变量,这个函数就形成了闭包。

这并不是魔法,而是 JavaScript 词法作用域机制的自然结果


二、闭包形成的两个必要条件(缺一不可)

  1. 函数嵌套:内部函数引用了外部函数的变量;
  2. 内部函数被暴露到外部:比如通过 return 返回、赋值给全局变量、作为回调传递等,并在外部被调用。

只有同时满足这两点,闭包才会真正“生效”。


三、经典示例:直观感受闭包

function foo() {
  var myName = "极客时间";
  let test1 = 1;
  const test2 = 2; // 注意:test2 未被内部函数使用

  var innerBar = {
    getName: function () {
      console.log(test1); // 引用了外部变量 test1
      return myName;      // 引用了外部变量 myName
    },
    setName: function (newName) {
      myName = newName;   // 修改外部变量 myName
    }
  };

  return innerBar; // 将内部对象返回,使内部函数可在外部调用
}

// 执行 foo,获取返回的对象
var bar = foo(); // 此时 foo 已执行完毕,上下文出栈

// 在外部调用内部函数 —— 闭包开始工作!
bar.setName("极客邦");
bar.getName();               // 输出:1
console.log(bar.getName());  // 输出:1 和 "极客邦"

输出结果:

1
1
极客邦

四、关键问题:为什么 foo 的变量没被垃圾回收?

  • 通常情况下,函数执行结束后,其局部变量会被垃圾回收。

  • 但在闭包场景中,只要内部函数仍被外部引用,JavaScript 引擎就会保留该函数所依赖的外部变量

  • 在本例中:

    • getName 和 setName 引用了 myName 和 test1 → 这两个变量被“捕获”并保留在内存中;
    • test2 没有被任何函数使用 → 被正常回收。

📌 重点:闭包不会阻止整个函数上下文销毁,只保留“被引用”的变量
这既保证了功能,又避免了内存浪费。

image.png


五、闭包的本质与词法作用域的关系

1. 闭包的本质

闭包不是某种特殊语法,而是一种运行时行为

函数 + 它出生时的词法环境 = 闭包

你可以把它想象成:函数随身带了一个“背包”,里面装着它定义时能访问的所有外部变量。无论它走到哪里(哪怕在全局调用),都能从背包里取用或修改这些数据。

2. 与词法作用域的关联

  • 词法作用域:变量的作用域由代码书写位置决定(静态的、编译期确定)。
  • 闭包:正是词法作用域在函数被传递到外部后依然生效的体现。

✅ 所以说:闭包不是额外特性,而是词法作用域 + 函数作为一等公民 的必然产物。


💡 记住一句话
闭包不是“不让变量销毁”,而是“还有人用,所以不能销毁”。
它让 JavaScript 实现了私有状态、模块封装、回调记忆等强大能力。

理解闭包,你就真正迈入了 JavaScript 高阶编程的大门。

日本股票市场渲染 KlineCharts K 线图

下面是针对日本股票市场的完整对接方案,包含从获取股票列表渲染 KlineCharts K 线图的详细步骤和代码。

核心流程

  1. 获取日本股票列表:使用 countryId=35 查询日本市场的股票,获取目标股票的 id (即 PID)。
  2. 获取 K 线数据:使用该 pid 请求历史 K 线数据。
  3. 绘制图表:将数据转换为 KlineCharts 格式并渲染。

第一步:获取日本股票 PID (API 调试)

在写代码前,您需要先通过 API 拿到您想展示的日本股票(例如丰田、索尼等)的 id

请求方式:

  • 接口 URL: https://api.stocktv.top/stock/stocks
  • 参数:
    • countryId: 35 (日本)
    • pageSize: 10
    • key: 您的Key

请求示例 (GET):

https://api.stocktv.top/stock/stocks?countryId=35&pageSize=10&page=1&key=您的Key

返回示例 (假设): 您会在返回的 data.records 列表中找到股票信息。

{
  "id": 99999,  <-- 这个是 PID,记下这个数字用于下一步
  "name": "Toyota Motor Corp",
  "symbol": "7203",
  "countryId": 35,
  ...
}

第二步:完整实现代码 (HTML + KlineCharts)

将以下代码保存为 .html 文件。请替换代码顶部的 YOUR_API_KEY 和您在上一步获取到的 JAPAN_STOCK_PID

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>日本股票 K线图 (KlineCharts)</title>
    <script src="https://cdn.jsdelivr.net/npm/klinecharts/dist/klinecharts.min.js"></script>
    <style>
        body { margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
        h2 { margin-bottom: 10px; }
        .config-box { 
            background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; 
            display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
        }
        input, select, button { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
        button { background-color: #007bff; color: white; border: none; cursor: pointer; }
        button:hover { background-color: #0056b3; }
        #chart-container { width: 100%; height: 600px; border: 1px solid #e0e0e0; border-radius: 4px; }
    </style>
</head>
<body>

    <h2>StockTV 日本股票 K线演示 (CountryID=35)</h2>

    <div class="config-box">
        <label>股票PID: <input type="number" id="pidInput" value="953373" placeholder="例如: 953373"></label>
        
        <label>周期: 
            <select id="intervalSelect">
                <option value="P1D">日线 (1 Day)</option>
                <option value="PT1H">1小时 (1 Hour)</option>
                <option value="PT15M">15分钟 (15 Min)</option>
                <option value="PT5M">5分钟 (5 Min)</option>
            </select>
        </label>

        <button onclick="loadChartData()">生成图表</button>
    </div>

    <div id="chart-container"></div>

    <script>
        // 配置您的 API Key
        const API_KEY = '联系我们获取key'; // TODO: 请在此处填入您的真实 Key
        const BASE_URL = 'https://api.stocktv.top';

        // 初始化 KlineCharts
        let chart = klinecharts.init('chart-container');
        
        // 设置一些基础样式
        chart.setStyleOptions({
            candle: {
                tooltip: {
                    labels: ['时间', '开', '收', '高', '低', '成交量']
                }
            }
        });

        chart.createIndicator('VOL'); // 创建成交量指标

        async function loadChartData() {
            const pid = document.getElementById('pidInput').value;
            const interval = document.getElementById('intervalSelect').value;

            if (!pid) {
                alert("请输入股票 PID");
                return;
            }

            console.log(`正在请求日本股票数据: PID=${pid}, Interval=${interval}`);

            try {
                // 构造 StockTV API 请求
                // 文档接口: /stock/kline
                const url = `${BASE_URL}/stock/kline?pid=${pid}&interval=${interval}&key=${API_KEY}`;
                
                const response = await fetch(url);
                const resJson = await response.json();

                if (resJson.code === 200) {
                    const stockData = resJson.data;

                    if (!stockData || stockData.length === 0) {
                        alert("该股票在此周期下无数据");
                        return;
                    }

                    // 数据格式转换
                    // StockTV: { time: 1719818400000, open: 239.42, ... }
                    // KlineCharts: { timestamp: 1719818400000, open: 239.42, ... }
                    const klineData = stockData.map(item => {
                        return {
                            timestamp: item.time, // 直接使用 API 返回的时间戳
                            open: Number(item.open),
                            high: Number(item.high),
                            low: Number(item.low),
                            close: Number(item.close),
                            volume: Number(item.volume)
                        };
                    });

                    // 确保按时间升序排序
                    klineData.sort((a, b) => a.timestamp - b.timestamp);

                    // 渲染数据
                    chart.applyNewData(klineData);
                    console.log("图表渲染成功,数据条数:", klineData.length);
                } else {
                    console.error("API 错误:", resJson);
                    alert("接口报错: " + resJson.message);
                }

            } catch (err) {
                console.error("请求失败:", err);
                alert("网络请求失败,请检查控制台 (F12)");
            }
        }

        // 窗口大小调整时自动调整图表
        window.addEventListener('resize', () => {
            chart.resize();
        });
        
        // 页面加载时自动尝试加载一次(方便测试)
        // 如果您有确定的日本股票PID,可以在 input 的 value 中预设
        // loadChartData(); 
    </script>
</body>
</html>

关键点说明

  1. CountryId=35 的使用

    • countryId=35 主要用于查询列表 (/stock/stocks) 阶段,用于筛选出日本市场的股票及其对应的 PID。
    • 一旦拿到 PID,在请求 K 线数据 (/stock/kline) 时,只需要 PID,不需要再传 countryId。
  2. 数据映射 (Mapping)

    • StockTV 返回的字段是 time, open, high, low, close, volume
    • KlineCharts 要求的字段是 timestamp, open, high, low, close, volume
    • 代码中 timestamp: item.time 这一行完成了关键的转换。
  3. 周期格式

    • 请确保传给 API 的 interval 参数是 P1D (日), PT1H (时) 等 ISO8601 格式,否则 API 可能会报错或返回空数据。

Vue2 通用文件在线预览下载组件:一站式解决多类型文件处理需求(支持视频、文档、图片、Office)

在前端开发中,文件预览与下载是高频需求,但不同文件类型(视频、图片、文档、Office)的预览逻辑差异大,且存在 “预览失败降级”“跨浏览器兼容” 等痛点。今天分享一款我封装的 Vue2 文件预览下载组件,无需重复开发,传入文件 URL 即可实现 “能预览则预览,不能预览则下载” 的闭环体验,适配 90%+ 业务场景。

一、组件核心功能一览

组件围绕 “文件预览 + 下载” 核心诉求,拆解为状态管理、分类型预览、错误处理、下载能力四大模块,逻辑闭环且交互友好:

1. 基础状态与信息管理

功能点 实现逻辑 价值
加载状态 初始化显示 “加载中”,延迟 1 秒隐藏(给资源加载留缓冲) 避免用户因文件加载慢误以为 “无内容”,提升交互感知
文件信息解析 从传入的 fileUrl 中分割 URL、解析文件名 / 后缀(如从 http://xxx/test.mp4 提取 test.mp4 和 mp4 自动识别文件属性,无需业务侧手动传入文件名 / 类型
错误状态管理 监听 video/iframe 预览错误,展示针对性提示(如 “视频预览失败”“文件链接无效”) 明确失败原因,引导用户下一步操作(下载)

2. 分类型文件预览(核心核心)

组件按文件类型划分预览策略,覆盖视频、图片、文档、Office 四大类,适配不同文件的原生预览能力:

文件类型 预览方式 特殊处理 支持的格式
视频文件 原生 <video> 标签 + 播放控件 预览失败后自动移除视频格式的预览支持,切换为下载模式 mp4、avi、mov、mkv、flv、wmv
Office 文件(doc/xls/ppt 及新版) iframe 嵌套微软在线预览服务 拼接微软预览 URL(view.officeapps.live.com),解决前端无法直接预览 Office 的痛点 doc、docx、xls、xlsx、ppt、pptx
图片 / 文本 / PDF iframe 直接加载文件 URL 利用浏览器原生渲染能力,无需额外依赖 jpg/png/gif/bmp、txt、html/htm、pdf
不支持的文件(zip/rar/ 未知格式) 无预览,展示下载区域 显示文件图标、类型、提示语,提供下载按钮 zip、rar 及未列入预览列表的格式

3. 错误兜底与降级处理

错误场景 处理逻辑 用户体验
视频预览失败(格式不支持 / 文件损坏) 显示错误提示,同时将视频格式从 “支持预览列表” 中移除,强制切换为下载模式 避免用户看到空白 / 报错的 video 标签,直接引导下载
iframe 预览失败(Office 链接失效 / PDF 损坏) 显示错误提示,补充 “建议下载查看” 的引导 明确失败原因,不阻塞用户获取文件
解析文件信息失败 兜底显示 “未知文件”“未知类型”,仍保留下载功能 兼容异常 URL(如无后缀、URL 格式错误)

4. 轻量化下载功能

通过动态创建<a>标签实现无刷新下载,支持自定义文件名,捕获下载异常并给出友好提示(如 “文件下载失败,请检查链接”)。

5. 友好的视觉与交互

  • 加载状态居中显示 “加载中”,避免用户误以为无内容;
  • 预览区域自适应容器大小,视频采用object-fit: contain防止拉伸;
  • 下载区域用图标 + 文字组合,按钮蓝色强调,提示语浅灰色弱化,视觉层级清晰;
  • 错误提示用红色警示,提升辨识度。

二、应用场景和组件完整代码

该组件适配所有需要 “文件预览 / 下载” 的业务场景,以下是高频落地场景:

1. 后台管理系统(核心场景)
  • 文件管理模块(OA / 企业网盘) :用户上传文件后,列表 / 详情页展示预览,支持在线查看视频 / PDF/Office,压缩包等直接下载;
  • 工单 / 审批系统:审批附件(如报销单 PDF、项目文档 Word)在线预览,无需下载即可审核,提升审批效率;
  • 素材管理系统:运营 / 设计人员上传的视频 / 图片素材在线预览,快速核对内容是否符合要求。
2. 用户中心 / 客户门户
  • 资质审核场景(政务 / 金融) :用户上传的身份证(图片)、营业执照(PDF)在线预览,工作人员无需下载即可审核;
  • 课程 / 培训平台:课程附件(视频、讲义 PDF、课件 PPT)在线预览,学员无需下载即可学习,降低学习门槛;
  • 售后工单系统:用户上传的售后凭证(视频 / 图片)在线预览,客服快速核实问题,提升售后效率。
3. 电商 / 零售系统
  • 商品资料管理:商品视频、说明书 PDF、参数表 Excel 在线预览,运营人员快速核对商品信息;
  • 商家后台:商家上传的资质文件(营业执照、食品经营许可证)在线预览,平台审核人员一键查看。
4. 医疗 / 教育系统
  • 医疗报告预览:检查报告 PDF、医学影像(图片)在线预览,医生 / 患者无需下载即可查看;
  • 在线考试系统:考试附件(试题 PDF、参考资料 Word)在线预览,考生在线答题时可快速查阅。

代码

<template>
  <div class="file-preview-container">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading">加载中...</div>

    <!-- 视频文件:用 video 标签预览 -->
    <video
      v-else-if="isVideo && canPreview"
      :src="fileUrl"
      controls
      class="video-preview"
      @error="handleVideoError"
    >
      您的浏览器不支持视频预览
    </video>

    <!-- 非视频可预览文件:用 iframe 展示 -->
    <iframe
      v-else-if="!isVideo && canPreview"
      :src="iframeSrc"
      width="100%"
      height="100%"
      frameborder="0"
      class="preview-iframe"
      @error="handleIframeError"
    ></iframe>

    <!-- 不支持预览的文件:显示下载按钮 -->
    <div v-else class="download-section">
      <div class="file-icon">
        <i class="el-icon-video-camera" v-if="isVideo"></i>
        <i class="el-icon-document" v-else-if="fileType === 'doc' || fileType === 'docx'"></i>
        <i class="el-icon-table-lines" v-else-if="fileType === 'xls' || fileType === 'xlsx'"></i>
        <i class="el-icon-present" v-else-if="fileType === 'ppt' || fileType === 'pptx'"></i>
        <i class="el-icon-file-pdf" v-else-if="fileType === 'pdf'"></i>
        <i class="el-icon-image" v-else-if="['jpg','jpeg','png','gif','bmp'].includes(fileType)"></i>
        <i class="el-icon-archive" v-else-if="fileType === 'zip' || fileType === 'rar'"></i>
        <i class="el-icon-exclamation" v-else></i>
      </div>
      <div class="file-info">
        <p class="file-name">{{ fileName }}</p>
        <p class="file-type">文件类型:.{{ fileType }}</p>
        <p class="file-tip">
          {{
            isVideo
              ? "视频无法预览,请下载后查看"
              : "该文件类型不支持在线预览,请下载后查看"
          }}
        </p>
        <button class="download-btn" @click="downloadFile">
          <i class="el-icon-download"></i> 下载文件
        </button>
      </div>
    </div>

    <!-- 错误提示 -->
    <div v-if="errorMsg" class="error-message">{{ errorMsg }}</div>
  </div>
</template>

<script>
export default {
  name: "FilePreviewDownload",
  props: {
    // 文件完整链接(如:http://xxx.com/video.mp4、http://xxx.com/image.png)
    fileUrl: {
      type: String,
      required: true,
      validator: (value) => {
        // 简单校验URL格式
        return /^https?://.+/i.test(value) || /^//.+/i.test(value);
      },
    },
    // 自定义文件名(可选,默认从URL提取)
    customFileName: {
      type: String,
      default: "",
    },
  },
  data() {
    return {
      loading: true,
      errorMsg: "",
      fileName: "", // 文件名(如:test.mp4)
      fileType: "", // 文件后缀(如:mp4)
      // 支持的文件类型列表
      previewableTypes: [
        // 视频类
        "mp4", "avi", "mov", "mkv", "flv", "wmv",
        // 文档类
        "pdf", "txt", "html", "htm",
        // 图片类
        "jpg", "jpeg", "png", "gif", "bmp",
        // Office 格式
        "docx", "xlsx", "pptx", "doc", "xls", "ppt",
      ],
      // 视频格式单独区分(用于判断是否用 video 标签)
      videoTypes: ["mp4", "avi", "mov", "mkv", "flv", "wmv"],
    };
  },
  computed: {
    // 判断是否支持预览
    canPreview() {
      return this.previewableTypes.includes(this.fileType);
    },
    // 判断是否为视频文件
    isVideo() {
      return this.videoTypes.includes(this.fileType);
    },
    // iframe 预览地址(处理 Office 文件)
    iframeSrc() {
      // Office 文件用微软在线预览增强兼容性
      if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(this.fileType)) {
        return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(this.fileUrl)}`;
      }
      // 其他文件直接用原链接
      return this.fileUrl;
    },
  },
  created() {
    // 解析文件名和类型
    this.parseFileInfo();
    // 延迟隐藏加载(给资源加载留时间,可通过props自定义延迟)
    setTimeout(() => {
      this.loading = false;
    }, 1000);
  },
  methods: {
    // 解析文件名和后缀
    parseFileInfo() {
      try {
        // 优先使用自定义文件名
        if (this.customFileName) {
          this.fileName = this.customFileName;
          const nameParts = this.customFileName.split(".");
          if (nameParts.length > 1) {
            this.fileType = nameParts[nameParts.length - 1].toLowerCase();
          }
          return;
        }

        // 从URL提取文件名
        const urlParts = this.fileUrl.split("/");
        this.fileName = urlParts[urlParts.length - 1] || "未知文件";
        // 处理URL参数(如:test.pdf?timestamp=123 → test.pdf)
        this.fileName = this.fileName.split("?")[0].split("#")[0];
        // 提取文件后缀
        const nameParts = this.fileName.split(".");
        if (nameParts.length > 1) {
          this.fileType = nameParts[nameParts.length - 1].toLowerCase();
        }
      } catch (err) {
        console.error("解析文件信息失败:", err);
        this.fileName = "未知文件";
        this.fileType = "";
      }
    },

    // 视频预览错误处理
    handleVideoError() {
      this.errorMsg = "视频预览失败,可能格式不支持或文件损坏";
      // 视频预览失败后切换为下载模式
      this.previewableTypes = this.previewableTypes.filter(
        (type) => !this.videoTypes.includes(type)
      );
    },

    // iframe 预览错误处理
    handleIframeError() {
      this.errorMsg = "文件预览失败,可能文件已损坏或链接无效";
      if (this.previewableTypes.includes(this.fileType)) {
        this.errorMsg += ",建议下载文件查看";
      }
    },

    // 下载文件
    downloadFile() {
      try {
        const link = document.createElement("a");
        link.href = this.fileUrl;
        // 解决跨域下载时download属性失效问题(需后端配合设置Content-Disposition)
        link.download = this.fileName;
        document.body.appendChild(link);
        link.click();
        // 触发下载后移除a标签
        setTimeout(() => {
          document.body.removeChild(link);
        }, 100);
      } catch (err) {
        console.error("下载失败:", err);
        this.$message?.error ? this.$message.error("文件下载失败,请检查链接") : alert("文件下载失败,请检查链接");
      }
    },
  },
};
</script>

<style scoped>
.file-preview-container {
  width: 300px;
  min-height: 200px;
  position: relative;
  border: 1px solid #eee;
  border-radius: 4px;
  overflow: hidden;
  box-sizing: border-box;
}

/* 视频预览样式 */
.video-preview {
  width: 100%;
  height: 100%;
  min-height: 200px;
  object-fit: contain;
  background-color: #000;
}

/* iframe 预览样式 */
.preview-iframe {
  min-height: 200px;
  border: none;
}

/* 加载状态 */
.loading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #666;
  font-size: 14px;
}

/* 下载区域 */
.download-section {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 200px;
  padding: 20px;
  box-sizing: border-box;
}

.file-icon {
  font-size: 60px;
  color: #417aff;
  margin-right: 30px;
}

.file-info {
  max-width: 180px;
}

.file-name {
  font-size: 16px;
  font-weight: 500;
  margin-bottom: 8px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.file-type {
  color: #666;
  font-size: 13px;
  margin-bottom: 8px;
}

.file-tip {
  color: #999;
  font-size: 12px;
  margin-bottom: 16px;
  line-height: 1.4;
}

.download-btn {
  padding: 6px 16px;
  background-color: #417aff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  font-size: 14px;
  transition: background-color 0.3s;
}

.download-btn:hover {
  background-color: #2d62d0;
}

.download-btn i {
  margin-right: 4px;
  font-size: 12px;
}

/* 错误提示 */
.error-message {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #f56c6c;
  text-align: center;
  padding: 0 20px;
  font-size: 14px;
  line-height: 1.5;
}

/* 响应式适配 */
@media (max-width: 768px) {
  .file-preview-container {
    width: 100%;
  }
  .download-section {
    flex-direction: column;
    text-align: center;
  }
  .file-icon {
    margin-right: 0;
    margin-bottom: 16px;
  }
}
</style>

三、快速使用指南

1. 安装依赖(可选)

组件依赖 Element UI 图标,若项目未集成 Element UI,可安装:

npm install element-ui --save

或替换为原生图标(如 Font Awesome),移除 Element UI 依赖。

2. 引入组件

// 在需要使用的页面引入
import FilePreviewDownload from "@/components/FilePreviewDownload.vue";

export default {
  components: {
    FilePreviewDownload,
  },
};

3. 页面中使用

<!-- 基础用法:仅传入文件URL -->
<FilePreviewDownload fileUrl="http://xxx.com/test.pdf" />

<!-- 自定义文件名 -->
<FilePreviewDownload 
  fileUrl="http://xxx.com/123.mp4" 
  customFileName="产品介绍视频.mp4" 
/>

四、组件优势

优势点 说明
通用性强 覆盖视频、图片、文档、Office 等主流文件类型,适配 90%+ 业务场景
体验友好 加载 / 错误 / 降级逻辑完善,用户操作路径清晰(预览→失败→下载)
轻量易集成 基于原生标签 + Vue 开发,仅依赖 Element 图标,接入成本低
解决 Office 预览痛点 借助微软在线预览服务,无需前端集成重型 Office 解析库

1. 通用性强,覆盖全场景

支持视频、图片、文档、Office 等 18 + 常见文件类型,无需为不同文件写专属逻辑,适配后台管理、用户中心、电商等多业务场景。

2. 体验友好,优雅降级

“预览优先,下载兜底” 的逻辑,避免 “无法预览” 的生硬体验;预览失败时给出明确提示,引导用户下一步操作,减少困惑。

3. 轻量无冗余,接入成本低

  • 核心逻辑仅 200 + 行,无重型依赖,打包体积小;
  • 仅需传入fileUrl即可使用,无需配置复杂参数,新手也能快速上手。

4. 适配性强,兼容多端

  • 基于原生 HTML 标签(video/iframe)开发,兼容 Chrome、Firefox、Edge 等主流浏览器;
  • 样式支持响应式,适配移动端 / H5,可直接复用在小程序内嵌页面。

五、局限性与优化方向

局限性 影响场景 优化方向
样式硬编码 容器宽度 300px 固定,适配不同布局(如全屏预览)需修改样式 将宽度 / 高度 / 颜色等作为 props 传入,支持自定义
Office 预览依赖外网 内网环境下微软在线预览失效,Office 文件无法预览 集成开源文件预览服务(如 kkfileview、LibreOffice Online)
视频预览格式有限 小众格式(rmvb、webm)不支持,且无格式转换逻辑 集成 ffmpeg.wasm 实现前端视频格式解码,或后端转码为 mp4
下载功能兼容问题 跨域文件的 download 属性失效,无法直接下载 后端转发文件(前端请求后端接口,后端返回文件流)
加载延迟固定 1 秒 文件加载快时多余显示加载状态,加载慢时提前隐藏 监听 video/iframe 的 onload 事件,动态控制加载状态

1. 现存局限性

  • 样式硬编码:容器宽度默认 300px,适配不同布局需手动修改样式;
  • Office 预览依赖外网:内网环境下微软在线预览服务失效,无法预览 Office 文件;
  • 视频格式支持有限:小众格式(如 rmvb、webm)不支持原生预览;
  • 跨域下载问题:跨域文件的download属性可能失效,需后端配合设置响应头。

2. 扩展方向(按需迭代)

(1)支持自定义样式配置

将宽度、高度、边框、颜色等样式抽离为 props,允许业务侧灵活配置:

<FilePreviewDownload 
  fileUrl="http://xxx.com/test.jpg"
  :styleConfig="{ width: '500px', height: '300px', border: '1px solid #ccc' }"
/>

(2)增加权限控制

支持传入token参数,在预览 URL 中拼接鉴权信息,防止文件链接泄露:

// 扩展iframeSrc计算属性
iframeSrc() {
  let url = this.fileUrl;
  // 拼接鉴权token
  if (this.token) {
    url = `${url}${url.includes("?") ? "&" : "?"}token=${this.token}`;
  }
  if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(this.fileType)) {
    return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`;
  }
  return url;
},

(3)内网环境 Office 预览适配

集成开源文件预览服务(如 kkfileview、LibreOffice Online),替代微软在线预览,解决内网环境预览失效问题:

// 优化downloadFile方法
downloadFile() {
  // 跨域文件通过后端接口下载
  if (this.isCrossDomain) {
    window.open(`/api/file/download?url=${encodeURIComponent(this.fileUrl)}&fileName=${this.fileName}`);
    return;
  }
  // 非跨域文件直接下载(原逻辑)
  // ...
},

(6)增加批量预览 / 下载

扩展为列表级组件,支持多选文件批量预览、批量下载,适配文件管理系统场景。

六、总结

这款文件预览下载组件以 “通用、轻量、友好” 为核心设计理念,解决了前端文件处理的重复开发问题,是后台管理、用户中心等项目的必备基础组件。它不仅能直接复用,还支持按需扩展,可根据业务场景迭代权限控制、内网适配、批量操作等功能。

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

开场白:一个变量的"无法无天"与它的"寻亲之路"


📖 第一章:黑马喽的嚣张岁月

话说在前端江湖的ES5时代,有个叫var的黑马喽,这家伙简直无法无天!它想来就来,想走就走,完全不顾什么块级作用域的规矩。

// 你们看看这黑马喽的德行
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 你猜输出啥?3,3,3!
    }, 100);
}
// 循环结束了,i还在外面晃荡
console.log(i); // 3,瞧瞧,跑出来了吧!

但今天咱们不仅要扒一扒var的底裤,还要讲讲变量们是怎么"寻亲"的——这就是作用域作用域链的故事。


🔧 第二章:编译器的三把斧——代码的"梳妆打扮"

要说清楚作用域,得先从JavaScript的编译说起。别看JS是解释型语言,它在执行前也要经历一番"梳妆打扮"。

2.1 词法分析:拆解字符串的魔术

想象一下,编译器就像个认真的语文老师,把代码这个长句子拆成一个个有意义的词语:

var a = 1vara=1

注意:空格要不要拆开,得看它有没有用。就像读书时要不要停顿,得看语气!

2.2 语法分析:构建家谱树

拆完词之后,编译器开始理清关系——谁声明了谁,谁赋值给谁,最后生成一棵抽象语法树(AST)

这就像把一堆零散的家庭成员信息,整理成清晰的家谱。

2.3 代码生成:准备执行

最后,编译器把家谱树转换成机器能懂的指令,准备执行。

关键点:JS的编译发生在代码执行前的一瞬间,快到你几乎感觉不到!


💕 第三章:变量赋值的三角恋

var a = 1这么简单的一行代码,背后居然上演着一场"三角恋":

  • 🎯 编译器:干脏活累活的媒人,负责解析和牵线
  • JS引擎:执行具体动作的新郎
  • 🏠 作用域:管理宾客名单的管家

3.1 订婚仪式(编译阶段)

// 当看到 var a = 1;
编译器:管家,咱们这有叫a的变量吗?
作用域:回大人,还没有。
编译器:那就在当前场合声明一个a!

3.2 结婚典礼(执行阶段)

JS引擎:管家,我要找a这个人赋值!
作用域:大人请,a就在这儿。
JS引擎:好,把1赋给a!

这里涉及到两种查找方式:

LHS查询:找容器(找新娘)

var a = 1; // 找到a这个容器装1

RHS查询:找源头(找新娘的娘家)

console.log(a); // 找到a的值
foo();         // 找到foo函数本身

编译过程示意图


🐒 第四章:黑马喽的罪证展示

在ES5时代,var这家伙真是目中无人:

4.1 无视块级作用域

{
    var rogue = "我是黑马喽,我想去哪就去哪";
}
console.log(rogue); // 照样能访问!

4.2 变量提升的诡计

console.log(naughty); // undefined,而不是报错!
var naughty = "我提升了";

这货相当于:

var naughty;          // 声明提升到顶部
console.log(naughty); // undefined
naughty = "我提升了"; // 赋值留在原地

🙏 第五章:如来佛祖的五指山——let和const

ES6时代,如来佛祖(TC39委员会)看不下去了,派出了letconst两位大神:

5.1 块级作用域的紧箍咒

{
    let disciplined = "我在块里面很老实";
    const wellBehaved = "我也是好孩子";
}
console.log(disciplined); // ReferenceError!出不来咯

5.2 暂时性死区的降妖阵

console.log(rebel); // ReferenceError!此路不通
let rebel = "想提升?没门!";

真相let/const其实也会提升,但是被关进了"暂时性死区"这个五指山里,在声明前谁都别想访问!


🧩 第六章:黑马喽的迷惑行为——词法作用域的真相

6.1 一个让黑马喽困惑的例子

function bar(){
    console.log( myName);  // 黑马喽:这里该输出啥?
}

function foo(){
    var myName = "白吗喽";
    bar()
    console.log("1:", myName)   // 这个我懂,输出"白吗喽"
}

var myName = "黑吗喽";
foo()  // 输出:"黑吗喽","白吗喽"

黑马喽挠着头想:"不对啊!bar()foo()里面调用,不是应该找到foo()里的myName = "白吗喽"吗?怎么会是黑吗喽呢?"

6.2 outer指针:函数的"身份证"

原来,在编译阶段,每个函数就已经确定了自己的"娘家"(词法作用域):

// 编译阶段发生的事情:
// 1. bar函数出生,它的outer指向全局作用域(它声明在全局)
// 2. foo函数出生,它的outer也指向全局作用域(它声明在全局)
// 3. 变量myName声明提升:var myName = "黑吗喽"

// 执行阶段:
var myName = "黑吗喽";  // 全局myName赋值为"黑吗喽"
foo();                 // 调用foo函数

黑马喽的错误理解

bar() → foo() → 全局

实际的作用域查找(根据outer指针):

bar() → 全局

如图

C9AE3D8E-F1DA-4767-AE87-AF4B1AF8B94D.png

6.3 词法作用域 vs 动态作用域

词法作用域(JavaScript):看出生地

var hero = "全局英雄";

function createWarrior() {
    var hero = "部落勇士";
    
    function fight() {
        console.log(hero); // 永远输出"部落勇士"
    }
    
    return fight;
}

const warrior = createWarrior();
warrior(); // "部落勇士" - 记得出生时的环境

动态作用域:看调用地(JavaScript不是这样!)

// 假设JavaScript是动态作用域(实际上不是!)
var hero = "战场英雄";
const warrior = createWarrior();
warrior(); // 如果是动态作用域,会输出"战场英雄"

🗺️ 第七章:作用域链——变量的寻亲路线图

7.1 每个函数都带着"出生证明"

var grandma = "奶奶的糖果";

function mom() {
    var momCookie = "妈妈的饼干";
    
    function me() {
        var myCandy = "我的棒棒糖";
        console.log(myCandy);    // 自己口袋找
        console.log(momCookie);  // outer指向mom
        console.log(grandma);    // outer的outer指向全局
    }
    
    me();
}

mom();

7.2 作用域链的建造过程

// 全局作用域
var city = "北京";

function buildDistrict() {
    var district = "朝阳区";
    
    function buildStreet() {
        var street = "三里屯";
        console.log(street);     // 自己的
        console.log(district);   // outer指向buildDistrict
        console.log(city);       // outer的outer指向全局
    }
    
    return buildStreet;
}

// 编译阶段就确定的关系:
// buildStreet.outer = buildDistrict作用域
// buildDistrict.outer = 全局作用域

如图

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

⚔️ 第八章:作用域链的实战兵法

8.1 兵法一:模块化开发

function createCounter() {
    let count = 0; // 私有变量,外部无法直接访问
    
    return {
        increment: function() {
            count++; // 闭包:outer指向createCounter作用域
            return count;
        },
        getValue: function() {
            return count;
        }
    };
}

const counter = createCounter();
console.log(counter.increment()); // 1
// console.log(count); // 报错!count是私有的

8.2 兵法二:解决循环陷阱

黑马喽的坑
for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 3, 3, 3 - 所有函数共享同一个i
    }, 100);
}
作用域链的救赎
// 方法1:使用let创建块级作用域
for (let i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 0, 1, 2 - 每个i都有自己的作用域
    }, 100);
}

// 方法2:IIFE创建新作用域
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2 - j在IIFE作用域中
        }, 100);
    })(i);
}

8.3 兵法三:正确的函数嵌套

function foo(){
    var myName = "yang";
    
    function bar(){  // 现在bar的outer指向foo了!
        console.log("2:", myName);  // 找到foo的myName
    }
    
    bar()
    console.log("1:", myName)
}

var myName = "yang1";
foo()  // 输出:2: yang, 1: yang

🚀 第九章:现代JavaScript的作用域体系

9.1 块级作用域的精细化管理

function modernScope() {
    var functionScoped = "函数作用域";
    let blockScoped = "块级作用域";
    
    if (true) {
        let innerLet = "内部的let";
        var innerVar = "内部的var"; // 依然提升到函数顶部!
        
        console.log(blockScoped); // ✅ 可以访问外层的let
        console.log(functionScoped); // ✅ 可以访问外层的var
    }
    
    console.log(innerVar); // ✅ 可以访问
    // console.log(innerLet); // ❌ 报错!let是块级作用域
}

9.2 作用域链的新层级

// 全局作用域
const GLOBAL = "地球";

function country() {
    // 函数作用域
    let nationalLaw = "国家法律";
    
    {
        // 块级作用域1
        let provincialLaw = "省法规";
        
        if (true) {
            // 块级作用域2
            let cityRule = "市规定";
            
            console.log(cityRule);     // ✅ 本市有效
            console.log(provincialLaw); // ✅ 本省有效
            console.log(nationalLaw);   // ✅ 全国有效
            console.log(GLOBAL);        // ✅ 全球有效
        }
        
        // console.log(cityRule); // ❌ 跨市无效
    }
}

⚡ 第十章:作用域链的性能与优化

10.1 作用域查找的代价

var globalVar = "我在最外层";

function level3() {
    // 这个查找要经过:自己 → level2 → level1 → 全局
    console.log(globalVar);
}

function level2() {
    level3();
}

function level1() {
    level2();
}

10.2 优化心法

function optimized() {
    const localCopy = globalVar; // 局部缓存,减少查找深度
    
    function inner() {
        console.log(localCopy); // 直接访问,快速!
    }
    
    inner();
}

🏆 大结局:黑马喽的毕业总结

经过这番学习,黑马喽终于明白了作用域的真谛:

🎯 作用域的进化史

  1. ES5的混乱var无视块级作用域,到处捣乱
  2. ES6的秩序let/const引入块级作用域和暂时性死区
  3. outer指针机制:词法作用域在编译时确定,一辈子不变

🧠 作用域链的精髓

  1. outer指针:函数在编译时就确定了自己的"娘家"
  2. 词法作用域:看出生地,不是看调用地
  3. 就近原则:先找自己,再按outer指针找上级
  4. 闭包的力量:函数永远记得自己出生时的环境

💡 最佳实践心法

// 好的作用域设计就像好的家风
function createFamily() {
    // 外层:家族秘密,内部共享
    const familySecret = "传家宝";
    
    function teachChild() {
        // 中层:教育方法
        const education = "严格教育";
        
        return function child() {
            // 内层:个人成长
            const talent = "天赋异禀";
            console.log(`我有${talent},接受${education},知道${familySecret}`);
        };
    }
    
    return teachChild();
}

const familyMember = createFamily();
familyMember(); // 即使独立生活,依然记得家族传承

🌟 终极奥义

黑马喽感慨地总结道:

"原来JavaScript的作用域就像血缘关系:

  • 作用域是家规(在哪里能活动)
  • 作用域链是族谱(怎么找到祖先)
  • outer指针是出生证明(一辈子不变)
  • 词法作用域是家族传承(看出生地,不是看现住地)"

从此,黑马喽明白了:想要在前端江湖混得好,就要遵守作用域的家规,理解作用域链的族谱,尊重outer指针的出生证明!


🐒 黑马喽寄语:记住,函数的作用域是它的"娘家",编译时定亲,一辈子不变!理解了这套规则,你就能驯服任何JavaScript代码!

封装一个支持动态表头与权限控制的通用 VxeTable 组件

项目背景 基础需求: 我这边的需求是要把表格的表头设置为动态的情况、并且允许默写列进行显示隐藏、冻结、排序、默认某几项进行禁止取消勾选。 在这个基础需求上需要兼容按钮权限、以及每一个单元格内的点击事件和toolbar的按钮动态禁止功能

动态表头配置

不同角色、不同场景下,用户对字段的关注点不同,需支持运行时动态调整表头结构,包括列的显示/隐藏、顺序调整、宽度记忆等。 精细化列控制 允许部分业务列(如“备注”“标签”)被用户自由隐藏; 关键列(如首列、操作列、复选框、序号)必须始终可见,禁止在列设置中取消勾选; 支持列冻结(固定左/右)、排序、宽度拖拽等交互能力。 深度交互与权限集成 每个单元格需支持独立点击事件(如跳转详情、编辑内联); 表格工具栏(Toolbar)按钮需按用户权限动态渲染(兼容 v-permission 等指令); 操作列中的按钮同样需支持权限控制与自定义插槽。 配置持久化

用户对表头的个性化设置(如隐藏了哪些列、调整了哪些顺序)应自动保存至服务端,并在下次访问时还原,提升使用体验。

效果图

图片

在这里插入图片描述在这里插入图片描述

视频效果

live.csdn.net/v/503221

版本号

在这里插入代码片
  "vxe-pc-ui": "^4.10.30",
   "vxe-table": "^4.17.20",
   "xe-utils": "^3.7.9",
   "vue": "^3.5.18"

Props 说明

参数 类型 默认值 说明
border Boolean true 是否显示表格边框
stripe Boolean true 是否显示斑马纹
cloumnDrag Boolean false 是否允许拖拽调整列顺序
toolDrag Boolean true 是否显示右上角“列设置”按钮(齿轮图标)
height String / Number '500px' 表格高度,支持 '400px''80%'
code String '' 必填!当前页面唯一标识,用于保存/恢复列配置
showCheckbox Boolean true 是否显示复选框列
showIndex Boolean false 是否显示序号列
showAction Boolean false 是否显示操作列
actionWidth Number 100 操作列宽度(单位:px)
slotsFields Array<String> [] 需要用插槽渲染的字段名,如 ['status', 'name']

双向数据绑定

名称 类型 默认值 说明
data Array [] 表格主体数据,每一项为一行记录
buttons Array [] 左侧工具栏按钮配置,格式如:{ code: 'add', name: '新增' }
column Array [] 表头列配置,每列需包含 field(字段名)、title(标题)、visible(是否显示)等属性

插槽

<!-- 渲染 status 字段 --><template #status="{ row, column, $rowIndex }">  
 <span>{{ row.statusText }}</span></template>  

event方法

事件名 回调参数 说明
cellClick (row, column, value, title) 点击单元格时触发
checkAll (selectedRows: Array) 全选/取消全选时触发
check (selectedRows: Array) 单行勾选状态变化时触发
saveSuccess 用户点击【确定】或【恢复默认】后触发(用于重新加载表格)
leftBar (button: Object) 点击左侧工具栏按钮时触发

完整的使用示例

// 组件
<Table  
 ref="tableRef" 
 v-model:column="columns" 
 v-model:data="tableData" 
 v-model:buttons="buttons" 
 :code="ViewName.CRM_MY_CUSTOMER_LIST" 
 :height="tableHeight" 
 :show-action="true" 
 :stripe="false" 
 action-width="200" 
 :slots-fields="slotsFields" 
 @cell-click="handleCellClick" 
 @check-all="handleSelectChange" 
 @check="handleSelectChange" 
 @save-success="initList" 
 @left-bar="handleLeftBar">  
 // 这里的操作栏是action 名称必须固定为action 需配合show-action属性
 <template #action="{ row }"> 
 <div v-if="shouldShowActions(row)"> 
 <el-button v-permission="['customer:my:edit']" link type="primary" @click="handleTableUpdate(row)" >编辑</el-button>  
 </div> 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #phoneNumber="{ row }"> 
 <IconifyIconOnline v-if="row.phoneNumber" icon="ep:copy-document" style="display: inline; cursor: pointer" @click="copy(row.phoneNumber)" />&nbsp; {{ row.phoneNumber }} 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #followUpCount="{ row }"> 
 <el-button link type="primary" @click="previewTable(row)" >点击查看</el-button> 
 </template>  
 // 这次插槽totalConsumeAmount必须要在slotsFields内填写 不然不允许使用
 <template #totalConsumeAmount="{ row }"> 
 <span class="custom_high" @click="handleClickConsumptionUserInfo(row)" >{{ row.totalConsumeAmount }</span > 
 </template>
 </Table>

<script setup>
import { computed, ref, onMounted, defineOptions, nextTick } from "vue";

const tableRef = ref(null); // 表格ref
const columns = ref([]); // 列名称list
const tableData = ref([]); // 数据list

// 表格高度
const tableHeight = computed(() => {
  // searchHeight 为form的高度
  const searchHeight = searchBoxHeight.value || 0;
  return window.innerHeight - 280 - searchHeight;
});
// 单元格需要用到的插槽 需要在这里定义才能使用 重点!!!
const slotsFields = ref([
  "customerName",
  "sourceChannelName",
  "phoneNumber",
  "followStatus",
  "totalConsumeAmount",
  "totalRechargeAmount"
]);

const selectListIds = ref([]); // 勾选值id列表
// 勾选事件
const handleSelectChange = val => {
  selectListIds.value = val.map(item => item.id);
};
// toolbar上面的按钮
const rawButtons = [
  {
    code: "addMember", // 必传值
    name: "添加客户",  //  必传值 文本(页面展示)
    icon: "vxe-icon-add",  // icon
    status: "primary", // 状态
    permissionCode: "customer:my:add" // 是否有按钮权限。如果不传或者为值为空字符串 代表所有人可见
  },
  {
    code: "batchImport",
    name: "批量导入",
    status: "default",
    permissionCode: "customer:my:batchImport"
  },
  {
    code: "edit", // 必传值
    name: "编辑", //  必传值 文本(页面展示)
    status: "default", // 状态
    dependsOnSelection: true, // 是否被勾选框控制 如果值为true代表被checkbox勾选有关 不传或者为false则不被checkbox控制
    permissionCode: "customer:my:edit" // 是否有按钮权限。如果不传或者为值为空字符串 代表所有人可见
  }
];

// usePermissionButtons 函数为控制toolbar按钮权限处理
const buttons = usePermissionButtons(rawButtons, selectListIds);

const handleLeftBar = val => {
  switch (val.code) {
    case "addMember":
      break;
    case "batchImport":
      break;
    default:
      break;
  }
};

const getHeader = async () => {
  const res = await getTableHeader(ViewName.CRM_MY_CUSTOMER_LIST);
  columns.value = res.data.map(i => {
    return {
      ...i,
      field: i.key, // 必传
      title: i.label, // 必传
      width: i.width ?? "100px" // 必传
    };
  });
};

const initList = () => {
  getHeader(); // 获取表头数据
  getList(); // 获取列表数据
};
</script>

usePermissionButtons文件

// usePermissionButtons.js文件代码

import { computed } from "vue";
import { useUserStore } from "@/store/modules/user";

// store
const userStore = useUserStore();

/**
 * 根据 rawButtons 和选中状态,生成带权限控制和禁用状态的按钮列表
 * @param {Array} rawButtons - 原始按钮配置数组
 * @param {Ref<number[]> | Ref<any[]>} selectListIds - 选中的 ID 列表(ref)
 * @returns {ComputedRef<Array>} 过滤并处理后的按钮列表
 */
export function usePermissionButtons(rawButtons, selectListIds) {
  return computed(() => {
    const isEmpty = selectListIds.value.length === 0;
    const permissionList = getPermissionCodeList(); // 确保这是响应式的或最新值
    if (permissionList[0] === "*:*:*") {
      // admin
      return rawButtons;
    } else {
      // 非admin
      return rawButtons
        .filter(
          btn =>
            !btn.permissionCode || permissionList.includes(btn.permissionCode)
        )
        .map(btn => ({
          ...btn,
          disabled: btn.dependsOnSelection ? isEmpty : (btn.disabled ?? false)
        }));
    }
  });
}


// 获取登录人所有的按钮权限
export const getPermissionCodeList = () => {
  return userStore.permissions || [];
};

Table组件代码


<template>
  <div class="demo-page-wrapper">
    <vxe-grid
      ref="gridRef"
      v-bind="gridOptions"
      @toolbar-button-click="handleLeftToolbar"
      @checkbox-all="selectAllChangeEvent"
      @checkbox-change="selectChangeEvent"
      @cell-click="handleCellClick"
      @custom="handleColumnCustom"
    >
      <template v-for="(_, name) in $slots" #[name]="slotData">
        <slot :name="name" v-bind="slotData" />
      </template>
    </vxe-grid>
  </div>
</template>

<script setup>
import {
  ref,
  defineProps,
  defineExpose,
  defineModel,
  defineEmits,
  computed
} from "vue";
import XEUtils from "xe-utils";
import { headCancel, headSave } from "@/api/view";
import { ElMessage } from "element-plus";

const emits = defineEmits([
  "cellClick",
  "checkAll",
  "check",
  "saveSuccess",
  "leftBar"
]);
// 定义 props
const props = defineProps({
  // 边框线
  border: {
    type: Boolean,
    default: true
  },
  // 斑马线
  stripe: {
    type: Boolean,
    default: true
  },
  // 列拖拽
  cloumnDrag: {
    type: Boolean,
    default: false
  },
  // 自定义拖拽icon
  toolDrag: {
    type: Boolean,
    default: true
  },
  // 表格高度
  height: {
    type: [String, Number],
    default: "500px" // 也支持%
  },
  // 每个数据的唯一code
  code: { type: String, default: "" },
  // 是否展示复选框
  showCheckbox: { type: Boolean, default: true },
  // 是否展示索引号
  showIndex: { type: Boolean, default: false },
  // 是否展示操作列
  showAction: { type: Boolean, default: false },
  // 操作列宽度
  actionWidth: { type: Number, default: 100 },
  // 需要的插槽 例子: ["name", "id", ....]
  slotsFields: {
    type: Array,
    default: () => {
      return [];
    }
  }
});

// 表格数据
const tableData = defineModel("data", {
  default: []
});

// 左侧操作栏
const buttonsList = defineModel("buttons", {
  default: []
});

// 表头数据
const column = defineModel("column", {
  default: []
});

// 将这些值进行禁用
const disabledKeys = computed(() => {
  return column.value.length
    ? ["checkbox", "seq", "action", column.value[0].field]
    : ["checkbox", "seq", "action"];
});
// 处理slot插槽
const processedColumns = computed(() => {
  return column.value.map(col => {
    // 确保是普通数据列且有 field
    if (!col.type && col.field != null) {
      if (props.slotsFields.includes(col.field)) {
        return {
          ...col,
          slots: { default: col.field }
        };
      }
    }
    return col; // 原样返回(包括无 field 的列、type 列等)
  });
});

// 使用 computed,确保每次都是最新值
const gridOptions = computed(() => {
  const cols = [];

  // 复选框列
  if (props.showCheckbox) {
    cols.push({
      type: "checkbox",
      width: 40,
      fixed: "left",
      visible: true,
      field: "checkbox" // 该值是为了禁用复制的唯一值
    });
  }

  // 序号列
  if (props.showIndex) {
    cols.push({
      type: "seq",
      width: 50,
      title: "序号",
      fixed: "left",
      visible: true,
      field: "seq" // 该值是为了禁用复制的唯一值
    });
  }

  //  只加处理后的业务列(已自动注入 slots)
  cols.push(...processedColumns.value);

  // 操作列
  if (props.showAction) {
    cols.push({
      field: "action",
      title: "操作",
      width: props.actionWidth,
      fixed: "right",
      align: "center",
      visible: true,
      slots: { default: "action" }
    });
  }

  return {
    border: props.border,
    stripe: props.stripe,
    showOverflow: true,
    height: props.height,
    loading: false,
    columnConfig: { drag: props.cloumnDrag, resizable: true },
    rowConfig: { isCurrent: true, isHover: true },
    columnDragConfig: { trigger: "cell", showGuidesStatus: true },
    customConfig: {
      // 该列是否允许选中
      checkMethod({ column }) {
        return !disabledKeys.value.includes(column.field);
      }
    },
    toolbarConfig: {
      custom: props.toolDrag,
      zoom: false,
      buttons: buttonsList.value
    },
    checkboxConfig: { range: true },
    columns: cols,
    // columns: cols.filter(i => i.field !== "checkbox" && i.field !== "seq"),
    data: tableData.value
  };
});

const gridRef = ref(null);

// 选中的项
const selectedRows = ref([]);

// 事件处理
const selectAllChangeEvent = ({ checked }) => {
  selectedRows.value = gridRef.value?.getCheckboxRecords() || [];
  emits("checkAll", selectedRows.value);
};

const selectChangeEvent = ({ checked }) => {
  selectedRows.value = gridRef.value?.getCheckboxRecords() || [];
  emits("check", selectedRows.value);
};

// 清空选中的数据
const clearSelectEvent = () => {
  gridRef.value?.clearCheckboxRow();
};

// 获取选中的数据
const getSelectEvent = () => {
  const records = gridRef.value?.getCheckboxRecords() || [];
  console.log(`已选中 ${records.length} 条数据`);
};

// 选中所有
const selectAllEvent = () => {
  gridRef.value?.setAllCheckboxRow(true);
};

// 设置自定义勾选数据
const setSelectRow = (rows, checked = true) => {
  gridRef.value?.setCheckboxRow(rows, checked);
};

// 单元格点击
const handleCellClick = ({
  row,
  rowIndex,
  $rowIndex,
  column,
  columnIndex,
  $columnIndex,
  triggerRadio,
  triggerCheckbox,
  triggerTreeNode,
  triggerExpandNode,
  $event
}) => {
  emits("cellClick", row, column, row[column.property], column.title);
};

// 自定义筛选icon弹窗事件
const handleColumnCustom = params => {
  switch (params.type) {
    case "open":
      break;
    case "confirm": {
      // 白名单列表 将操作列和复选框、序号列过滤
      const whiteList = new Set(["action", null, undefined]);
      // 获取勾选的
      // const visibleColumn = gridRef.value?.getColumns() || [];
      // 获取所有的 visible来区分是否勾选
      const visibleColumn = gridRef.value?.getFullColumns() || [];
      const result = visibleColumn
        .map(i => {
          return {
            key: i.field,
            fixed: i.fixed,
            visible: i.visible,
            width: i.width,
            title: i.title,
            label: i.label,
            field: i.field
          };
        })
        .filter(k => !whiteList.has(k.key))
        .filter(i => i.field !== "checkbox" && i.field !== "seq");

      headSave(result, props.code)
        .then(() => {
          ElMessage.success("保存成功");
          emits("saveSuccess");
        })
        .catch(e => {
          console.error(e);
        });
      break;
    }
    case "reset": {
      // 恢复默认
      headCancel(props.code).then(() => {
        emits("saveSuccess");
      });
      break;
    }
    case "close": {
      break;
    }
  }
};

const handleLeftToolbar = val => {
  const { code, button, $event } = val;
  emits("leftBar", button);
};

// 暴露方法
defineExpose({
  gridRef,
  clearSelectEvent,
  getSelectEvent,
  selectAllEvent,
  setSelectRow
});
</script>

<style lang="scss" scoped>
.demo-page-wrapper {
  height: 100%;
  padding: 0;
  background-color: #fff;
}
</style>

Git:如何排查非线性历史中被隐秘覆盖的修改(完整实战笔记)

在多人协作开发中,尤其是 i18n 文案文件(如 assets/locales/ja_JP.json)体量庞大时,某一段 key 被突然“消失” ,往往不是故意删除,而是:

  • 同事基于旧分支开发
  • merge commit 采取 ours/theirs 策略自动覆盖
  • 或 merge 的某个 parent 版本较旧,导致新 key 在另一个 parent 中被丢弃
  • 没有线性历史,GitLab UI 的 diff 不一定能看到

这个问题很隐蔽,但可以完全定位。

本文总结排查流程。

第一步:确认问题是否被覆盖(非故意删除)

我们要知道:

这个 Key 是 “被人写代码删掉” 还是 “merge 自动覆盖掉”?

使用:

git log -S "modbus_server" -p -- assets/locales/ja_JP.json

含义:

  • S 搜索文本出现/消失的位置
  • p 展示 diff

结果显示:

✔️ Key 的添加出现在最早的 commit

❌ Key 的删除并未以明显 diff 方式出现

→ 说明不是编辑删除,而是 merge 导致覆盖


2️⃣ 第二步:锁定 key 仍存在时的最后一个 commit

通过定位:

2b86a183de12891ae463bcb941defb8a338d2046

这个版本中 key 仍然存在。


3️⃣ 第三步:查找它的直接 children

因为 develop 历史是非线性的,所以不能只看时间顺序。

使用:

git rev-list --children develop | grep 2b86a183

输出:

2b86a183 ... ce3ba83f ... f8e8dc155ae6...
80e7ca4386... 2b86a183...

含义:

  • commit 2b86a183两个 child

    • ce3ba83f
    • f8e8dc155ae6

只要找到哪个 child 删除了 key,就能定位元凶


4️⃣ 第四步:对比两个 child 与 parent

A. 对比第一个 child:

git diff 2b86a183..ce3ba83f -- assets/locales/ja_JP.json

输出明确显示:

✔️ modbus_server 整段内容被 删掉了

关键:这就是删除 key 的确切 commit!

B. 对比第二个 child:

git diff 2b86a183..f8e8dc155ae6

没有涉及该 key

→ 不是它的问题。


5️⃣ 第五步:在 GitLab 远端查看(UI)

GitLab Compare 页面必须使用格式:

<http://xxxx/-/compare/><base>...<target>

三个点:

2b86a183...ce3ba83f

如果使用两个点或反向,会失败。

例如:

<http://code-oss.sigenpower.com:8090/sigen_app/sigenmain/-/compare/2b86a183de12...ce3ba83f5dce>

即可在 UI 中看到该 diff。


6️⃣ 为什么 GitLab 看不到删除 diff?

因为:

  • 提交 ce3ba83f 是一个 merge commit
  • GitLab 默认显示 merge commit 的 diff 是对所有 parent 的 combined diff
  • 若包含文件完全覆盖,GitLab UI 会“隐藏”这类大块变更
  • JSON 巨文件会触发 GitLab 的 “cut diff” 行为,不展示全部内容

所以:

本地 diff 能看到删掉整段

GitLab UI 不一定展示

很常见。


7️⃣ 最终确认:这个 commit 确实就是删除的来源吗?

✔️ 是的。

判断依据:

  1. git diff parent..child 直接显示删除 → 100% 明确
  2. 另一个 child 没删除
  3. git log -S 没找到显式删除的记录 → merge 覆盖导致
  4. Git DAG 可证明唯一路径包含这个 child

结论:

删除源头 commit 明确为:

ce3ba83f5dce2bcda26d1d2081d9259c904aa8e7


8️⃣ 总结:如何在非线性历史中定位“被覆盖的改动”

流程简化版:

  1. 查找 key 最后出现的 commit

    git log -S "xxx-key"
    
  2. 找它的 children

    git rev-list --children develop | grep <commit>
    
  3. 对比 parent 与 children

    git diff <parent>..<child>
    
  4. 哪个 child 删除了 key → 问题提交

  5. GitLab Compare 使用:

    ...   (三个点)
    
❌