普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月29日技术

每日一题-使数组和能被 K 整除的最少操作次数🟢

2025年11月29日 00:00

给你一个整数数组 nums 和一个整数 k。你可以执行以下操作任意次:

  • 选择一个下标 i,并将 nums[i] 替换为 nums[i] - 1

返回使数组元素之和能被 k 整除所需的最小操作次数。

 

示例 1:

输入: nums = [3,9,7], k = 5

输出: 4

解释:

  • nums[1] = 9 执行 4 次操作。现在 nums = [3, 5, 7]
  • 数组之和为 15,可以被 5 整除。

示例 2:

输入: nums = [4,1,3], k = 4

输出: 0

解释:

  • 数组之和为 8,已经可以被 4 整除。因此不需要操作。

示例 3:

输入: nums = [3,2], k = 6

输出: 5

解释:

  • nums[0] = 3 执行 3 次操作,对 nums[1] = 2 执行 2 次操作。现在 nums = [0, 0]
  • 数组之和为 0,可以被 6 整除。

 

提示:

  • 1 <= nums.length <= 1000
  • 1 <= nums[i] <= 1000
  • 1 <= k <= 100

3512. 使数组和能被 K 整除的最少操作次数

作者 stormsunshine
2025年4月14日 06:15

解法

思路和算法

每次操作可以将数组 $\textit{nums}$ 中的一个元素值减少 $1$,因此为了计算使数组元素和能被 $k$ 整除的最少操作次数,需要考虑数组元素和的最小减少量。考虑数组 $\textit{nums}$ 的元素和除以 $k$ 的余数 $r$。

  • 如果 $r = 0$,则数组元素和已经能被 $k$ 整除,不需要执行操作。

  • 如果 $r > 0$,则至少要将数组元素和减少 $r$ 才能被 $k$ 整除,因此需要执行 $r$ 次操作。

因此,对于 $0 \le r < k$ 的任意余数 $r$,使数组元素和能被 $k$ 整除的最少操作次数是 $r$。

计算数组 $\textit{nums}$ 的元素和除以 $k$ 的余数,即为使数组元素和能被 $k$ 整除的最少操作次数。

代码

###Java

class Solution {
    public int minOperations(int[] nums, int k) {
        return Arrays.stream(nums).sum() % k;
    }
}

###C#

public class Solution {
    public int MinOperations(int[] nums, int k) {
        return nums.Sum() % k;
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。需要遍历数组一次计算元素和。

  • 空间复杂度:$O(1)$。

求和模 k(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2025年4月13日 09:06

题目要求 $\textit{nums}$ 的元素和 $s$ 能被 $k$ 整除。

每次操作可以把 $s$ 减少 $1$。目标是把 $s$ 变成一个 $k$ 的倍数,且操作次数越小越好。

比如 $s=8,k=3$,需要操作 $2$ 次,把 $s$ 变成 $6$。

一般地,至少要操作 $s\bmod k$ 次,才能把 $s$ 变成 $k$ 的倍数。

本题视频讲解

###py

class Solution:
    def minOperations(self, nums: List[int], k: int) -> int:
        return sum(nums) % k

###java

class Solution {
    public int minOperations(int[] nums, int k) {
        int s = 0;
        for (int x : nums) {
            s += x;
        }
        return s % k;
    }
}

###java

class Solution {
    public int minOperations(int[] nums, int k) {
        return Arrays.stream(nums).sum() % k;
    }
}

###cpp

class Solution {
public:
    int minOperations(vector<int>& nums, int k) {
        return reduce(nums.begin(), nums.end()) % k;
    }
};

###c

int minOperations(int* nums, int numsSize, int k) {
    int s = 0;
    for (int i = 0; i < numsSize; i++) {
        s += nums[i];
    }
    return s % k;
}

###go

func minOperations(nums []int, k int) int {
s := 0
for _, x := range nums {
s += x
}
return s % k
}

###js

var minOperations = function(nums, k) {
    return _.sum(nums) % k;
};

###rust

impl Solution {
    pub fn min_operations(nums: Vec<i32>, k: i32) -> i32 {
        nums.iter().sum::<i32>() % k
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

模拟

作者 tsreaper
2025年4月13日 00:18

解法:模拟

每次操作会让数组的总和减一,因此答案就是总和 $\mod k$ 的值。

复杂度 $\mathcal{O}(n)$。

参考代码(c++)

class Solution {
public:
    int minOperations(vector<int>& nums, int k) {
        int sm = 0;
        for (int x : nums) sm += x;
        return sm % k;
    }
};
昨天 — 2025年11月28日技术

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

2025年11月28日 22:09

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

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滑窗

作者 Running_slave
2025年11月28日 20:31

一、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 架构以及工具选型、搭建

作者 颜酱
2025年11月28日 19:56

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插件

2025年11月28日 19:52

前言

下面会分享 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

[Python3/Java/C++/Go/TypeScript] 一题一解:DFS(清晰题解)

作者 lcbin
2025年11月28日 07:17

方法一:DFS

我们注意到,题目保证了整棵树的节点值之和可以被 $k$ 整除,因此,如果我们删除一棵元素和能被 $k$ 整除的子树,那么剩下的每个连通块的节点值之和也一定可以被 $k$ 整除。

因此,我们可以使用深度优先搜索的方法,从根节点开始遍历整棵树,对于每个节点,我们计算其子树中所有节点值之和,如果该和能被 $k$ 整除,那么我们就将答案加一。

###python

class Solution:
    def maxKDivisibleComponents(
        self, n: int, edges: List[List[int]], values: List[int], k: int
    ) -> int:
        def dfs(i: int, fa: int) -> int:
            s = values[i]
            for j in g[i]:
                if j != fa:
                    s += dfs(j, i)
            nonlocal ans
            ans += s % k == 0
            return s

        g = [[] for _ in range(n)]
        for a, b in edges:
            g[a].append(b)
            g[b].append(a)
        ans = 0
        dfs(0, -1)
        return ans

###java

class Solution {
    private int ans;
    private List<Integer>[] g;
    private int[] values;
    private int k;

    public int maxKDivisibleComponents(int n, int[][] edges, int[] values, int k) {
        g = new List[n];
        Arrays.setAll(g, i -> new ArrayList<>());
        for (int[] e : edges) {
            int a = e[0], b = e[1];
            g[a].add(b);
            g[b].add(a);
        }
        this.values = values;
        this.k = k;
        dfs(0, -1);
        return ans;
    }

    private long dfs(int i, int fa) {
        long s = values[i];
        for (int j : g[i]) {
            if (j != fa) {
                s += dfs(j, i);
            }
        }
        ans += s % k == 0 ? 1 : 0;
        return s;
    }
}

###cpp

class Solution {
public:
    int maxKDivisibleComponents(int n, vector<vector<int>>& edges, vector<int>& values, int k) {
        int ans = 0;
        vector<int> g[n];
        for (auto& e : edges) {
            int a = e[0], b = e[1];
            g[a].push_back(b);
            g[b].push_back(a);
        }
        auto dfs = [&](this auto&& dfs, int i, int fa) -> long long {
            long long s = values[i];
            for (int j : g[i]) {
                if (j != fa) {
                    s += dfs(j, i);
                }
            }
            ans += s % k == 0;
            return s;
        };
        dfs(0, -1);
        return ans;
    }
};

###go

func maxKDivisibleComponents(n int, edges [][]int, values []int, k int) (ans int) {
g := make([][]int, n)
for _, e := range edges {
a, b := e[0], e[1]
g[a] = append(g[a], b)
g[b] = append(g[b], a)
}
var dfs func(int, int) int
dfs = func(i, fa int) int {
s := values[i]
for _, j := range g[i] {
if j != fa {
s += dfs(j, i)
}
}
if s%k == 0 {
ans++
}
return s
}
dfs(0, -1)
return
}

###ts

function maxKDivisibleComponents(
    n: number,
    edges: number[][],
    values: number[],
    k: number,
): number {
    const g: number[][] = Array.from({ length: n }, () => []);
    for (const [a, b] of edges) {
        g[a].push(b);
        g[b].push(a);
    }
    let ans = 0;
    const dfs = (i: number, fa: number): number => {
        let s = values[i];
        for (const j of g[i]) {
            if (j !== fa) {
                s += dfs(j, i);
            }
        }
        if (s % k === 0) {
            ++ans;
        }
        return s;
    };
    dfs(0, -1);
    return ans;
}

###rust

impl Solution {
    pub fn max_k_divisible_components(n: i32, edges: Vec<Vec<i32>>, values: Vec<i32>, k: i32) -> i32 {
        let n = n as usize;
        let mut g = vec![vec![]; n];
        for e in edges {
            let a = e[0] as usize;
            let b = e[1] as usize;
            g[a].push(b);
            g[b].push(a);
        }

        let mut ans = 0;

        fn dfs(i: usize, fa: i32, g: &Vec<Vec<usize>>, values: &Vec<i32>, k: i32, ans: &mut i32) -> i64 {
            let mut s = values[i] as i64;
            for &j in &g[i] {
                if j as i32 != fa {
                    s += dfs(j, i as i32, g, values, k, ans);
                }
            }
            if s % k as i64 == 0 {
                *ans += 1;
            }
            s
        }

        dfs(0, -1, &g, &values, k, &mut ans);
        ans
    }
}

时间复杂度 $O(n)$,空间复杂度 $O(n)$。其中 $n$ 是树中的节点数。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈 😄~

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

2025年11月28日 18:17

引言

在现代 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 词法作用域链:从代码到底层实现机制

作者 San30
2025年11月28日 18:13

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

先来看一段看似简单却容易出错的 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 实现 饼图扇区间隙+透明外描边

作者 黑幕困兽
2025年11月28日 18:10

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 优化完整方案

作者 七淮
2025年11月28日 17:51

适用于 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 词法作用域与闭包:从底层原理到实战理解

作者 有意义
2025年11月28日 17:41

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 线图

2025年11月28日 17:35

下面是针对日本股票市场的完整对接方案,包含从获取股票列表渲染 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)

作者 某只天落
2025年11月28日 17:34

在前端开发中,文件预览与下载是高频需求,但不同文件类型(视频、图片、文档、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的寻亲记:作用域与作用域链全解析

作者 AY1024
2025年11月28日 17:34

黑马喽大闹天宫与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 组件

作者 GYY_y
2025年11月28日 17:28

项目背景 基础需求: 我这边的需求是要把表格的表头设置为动态的情况、并且允许默写列进行显示隐藏、冻结、排序、默认某几项进行禁止取消勾选。 在这个基础需求上需要兼容按钮权限、以及每一个单元格内的点击事件和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:如何排查非线性历史中被隐秘覆盖的修改(完整实战笔记)

2025年11月28日 17:20

在多人协作开发中,尤其是 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 使用:

    ...   (三个点)
    
❌
❌