阅读视图

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

每日一题-统计有序矩阵中的负数🟢

给你一个 m * n 的矩阵 grid,矩阵中的元素无论是按行还是按列,都以非严格递减顺序排列。 请你统计并返回 grid 中 负数 的数目。

 

示例 1:

输入:grid = [[4,3,2,-1],[3,2,1,-1],[1,1,-1,-2],[-1,-1,-2,-3]]
输出:8
解释:矩阵中共有 8 个负数。

示例 2:

输入:grid = [[3,2],[1,0]]
输出:0

 

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 100
  • -100 <= grid[i][j] <= 100

 

进阶:你可以设计一个时间复杂度为 O(n + m) 的解决方案吗?

JavaScript 函数柯里化:从入门到实战,一文搞定(面试可用)

前言

最近在复习 JavaScript 高阶函数的时候,又把函数柯里化(Currying)翻出来好好捋了一遍。很多人一听到“柯里化”就觉得高大上,其实它没那么神秘,用通俗的话说,就是把一个接受多个参数的函数,变成一个个只接受一个参数的函数链条。

这篇文章就把我自己的学习笔记整理了一下,从最基础的对比开始,慢慢讲到原理、实现、实际用法,希望能帮你把这个知识点彻底吃透。

1. 先看一个最直观的对比

普通写法:

function add(a, b) {
  return a + b;
}

console.log(add(1, 2)); // 3

柯里化写法:

function add(a) {
  return function (b) {
    return a + b;
  };
}

console.log(add(1)(2)); // 3

看到区别了吗?

  • 普通版:一次把所有参数传完。
  • 柯里化版:参数一个一个传,每次调用返回一个新函数,直到所有参数收集齐了才真正计算。

这种“一个一个传”的方式,就是函数柯里化的核心。

2. 柯里化的本质:闭包 + 参数收集

为什么能一个一个传?靠的是闭包。

在外层函数里,参数 a 被保存了下来(成了闭包里的自由变量),内层函数可以随时访问它。当我们再传进来 b 的时候,就可以用之前保存的 a 去计算。

所以说,柯里化本质上就是利用闭包把参数“攒”起来,等参数够了再执行真正的逻辑。

3. 怎么判断参数“够了”?

JavaScript 函数有一个隐藏属性 length,它表示函数定义时参数的个数(不包括剩余参数和默认参数)。

function add(a, b) {
  return a + b;
}

console.log(add.length); // 2

我们可以利用这个属性来做一个相对严谨的柯里化判断:只有当收集到的参数数量 ≥ 原函数的 length 时,才真正执行。

4. 手写一个通用柯里化函数

下面这个是我自己最常用的一版:

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args); // 直接展开,更清晰
    }
    return (...more) => curried(...args, ...more); // 这里也用展开合并
  };
}

// 测试
function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3));     // 6
console.log(curriedAdd(1, 2)(3));     // 6
console.log(curriedAdd(1)(2, 3));     // 6
console.log(curriedAdd(1, 2, 3));     // 6

这个版本的好处:

  • 支持任意数量的参数逐步传递
  • 也支持一次传多个(只要总数够了就执行)
  • 实现只有十来行,容易理解和记忆

注意:这里用了递归 + 闭包,外层每次调用都会产生新的 curried 函数,args 会被不断累加,直到满足条件。

5. 柯里化的经典实战场景

说了那么多,这么麻烦的编写柯里化函数,它到底能做什么呢?

场景一:固定部分参数,制造专用的工具函数

// 通用日志函数
const log = (type) => (message) => {
  console.log(`[${type.toUpperCase()}]: ${message}`);
};

// 通过柯里化“固定”日志类型,得到专用函数
const errorLog = log('error'); // 第一个参数
const infoLog = log('info');
const warnLog = log('warn');
// 第二个参数
errorLog('接口 404 了!');         // [ERROR]: 接口 404 了!
infoLog('页面加载完成');           // [INFO]: 页面加载完成
warnLog('即将弃用旧 API');        // [WARN]: 即将弃用旧 API

这种写法在实际项目里特别常见,尤其是做日志、埋点、事件绑定的时候,能让代码语义更清晰。

场景二:延迟执行 / 参数复用

比如我们有一个通用的 Ajax 请求函数:

function ajax(method, url, data) {
  // ...真正的请求逻辑
}

// 柯里化后
const get = curry(ajax)('GET');
const post = curry(ajax)('POST');

const fetchUserList = get('/api/users');
const fetchUserDetail = get('/api/users/');

const submitForm = post('/api/submit');

这样每次调用时就不用反复写 method,代码更简洁,也更不容易写错。

场景三:配合函数式编程库(如 lodash、ramda)

lodash 的 _.curry 功能更强大,支持占位符 __ 来跳过某些参数:

const _ = require('lodash');

const join = (sep, ...arr) => arr.join(sep);
const curryJoin = _.curry(join);

const dotJoin = curryJoin('.');
dotJoin('a', 'b', 'c'); // "a.b.c"

不过日常项目里,自己手写一个简单版往往就够用了。

6. 柯里化的优缺点总结

优点:

  1. 参数复用:固定前几个参数,快速生成新函数
  2. 延迟执行:参数没收集齐之前不会真正运行
  3. 让代码更函数式、更声明式,阅读性更好(尤其配合管道操作)

缺点:

  1. 产生大量闭包和中间函数,性能略有损耗(现代引擎优化后影响很小)
  2. 调试时调用栈会变深一点
  3. 如果滥用,会让代码看起来“太巧妙”,反而降低可读性

所以我的建议是:合适的地方用,别为了柯里化而柯里化。

最后

函数柯里化其实就是一个很小的技巧,但用好了能让你的代码更优雅、更灵活。尤其是当你开始接触函数式编程、React 高阶组件、Redux 中间件这些场景时,会发现柯里化的影子到处都是。

希望这篇从零开始的整理,能帮你把柯里化彻底搞明白。欢迎在评论区分享你用柯里化写过的有趣代码,或者你踩过的坑~

你会先找行还是直接拍平?两种二分策略你Pick哪个?

引言

你有没有遇到过这种情况:

面试官轻描淡写地扔过来一道题:“给你一个m×n的矩阵,每行递增,而且每一行的第一个数都比上一行最后一个大……问你能不能快速找到某个目标值?”

你心里一咯噔:
👉 这不是普通的二维数组啊,这简直是升序界的卷王之王

然后你灵光一闪:
💡 “等等!这不就是个‘假装是二维’的一维数组吗?”

没错,今天我们要聊的,就是一个能让二分查找从平面直角坐标系直接穿越到数轴上跳舞的经典问题。我们不讲晦涩公式,只用最接地气的语言,带你把这道题“吃干抹净”。


🧩 问题长什么样?先别慌,它是纸老虎!

题目大概是这样:

74. 搜索二维矩阵 - 力扣(LeetCode)

image.png

看到这个结构,聪明的你已经发现了关键点:

整个矩阵展开后,其实是一个严格升序的一维数组!

也就是说,上面那个矩阵等价于:

[1, 3, 5, 7, 10, 11, 16, 20, 23, 30, 34, 60] // 完全有序!

所以——
🎉 我们可以用二分查找来解决!

但问题是:怎么在“二维空间”里搞“一维操作”?

这就引出了两种神仙思路👇


🚀 方法一:两次二分 —— 先找小区再敲门

想象一下你在一栋高档公寓楼里找朋友:

  • 大楼有 m 层(对应行)
  • 每层有 n 户(对应列)
  • 所有房间号按顺序排列,且下一层第一个房间号 > 上一层最后一个

你要找住在“房间号=11”的老张。

你会怎么做?

🧠 当然是:

  1. 先看每层的第一户门牌号,确定他在哪一层;
  2. 然后再去那层挨家挨户敲门找他。

这就是两次二分法的核心思想!

✅ 第一步:二分找“可能住的那一行”

我们对第一列进行二分查找,找的是:

“最后一个首元素 ≤ target”的那一行

为什么是“最后一个”?因为后面的行开头就太大了,不可能有目标。

举个例子,target = 11:

首元素
0 1
1 10
2 23

显然,第2行开始首元素23 > 11,所以只能在第0或第1行。而我们要找的是“最后一个小于等于11的首元素”,那就是第1行(10 ≤ 11)。

于是我们锁定:目标最多只能出现在第1行!

✅ 第二步:在这行内部再二分查找

第1行是 [10, 11, 16, 20],标准升序数组,直接套模板二分即可。

找到了11 → 返回 true

🎯 成功定位,就像快递小哥精准投递!

💡 关键细节:避免死循环的小技巧

在找行的时候,为了避免 lowhigh 卡住不动,我们需要向上取整:

const mid = Math.floor((high - low + 1) / 2) + low;

否则当 low=0, high=1 时,mid 永远是0,就会陷入无限循环——相当于你一直在一楼徘徊,不敢上二楼 😵‍💫


🚀 方法二:一次二分 —— 把二维压成一维,“降维打击”!

如果说方法一是“一步步推理”,那方法二就是“开挂模式”!

它的哲学是:

“我不管你是几维,只要整体有序,我就当你是一条直线!”

🌀 思路精髓:虚拟一维数组 + 下标映射

假设矩阵是 m × n,我们可以把它看作一个长度为 m * n 的一维数组。

如何将一维下标 k 映射回二维坐标?

✨ 答案非常优雅:

row = Math.floor(k / n);   // 第几行
col = k % n;               // 第几列

是不是像极了小时候学的“排座位”?

比如一共4列:

  • 第0个同学 → 第0行第0列
  • 第5个同学 → 第1行第1列(5 ÷ 4 = 1余1)

于是我们可以直接在整个“虚拟数组”上做二分查找!

🔍 实战演示:target = 11

总长度 = 3×4 = 12,初始范围 [0, 11]

中间下标 mid = 5 → 对应位置是:

row = Math.floor(5 / 4) = 1
col = 5 % 4 = 1
→ matrix[1][1] = 11 ✅ 找到了!

boom!一击必中!


🆚 两种方法对比:谁更适合你?

维度 两次二分法 一次二分法
时间复杂度 O(log m + log n) = O(log mn) O(log mn)
空间复杂度 O(1) O(1)
代码长度 稍长(两个函数) 超短(10行搞定)
逻辑清晰度 分步思考,适合教学 数学美感强,适合装X
边界处理难度 第一列二分需小心 相对简单
推荐指数 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐⭐

🤔 如何选择?

  • 如果你是初学者,建议先掌握两次二分法——它更符合直觉,像是走路:先迈左脚,再迈右脚。
  • 如果你是老司机,直接上一次二分法,代码简洁得像诗一样,面试官看了都想鼓掌👏

🎯 我的独特理解:这不是“两种方法”,而是“两种思维方式”

很多人说:“哦,一个是分治,一个是映射。”
我说:错!这是两种人生哲学!

  • 两次二分法像是“稳扎稳打型选手”:

    “我不急,先缩小范围,再精确打击。”
    类似于生活中那些做事有计划、步步为营的人。

  • 一次二分法则是“全局视角型天才”:

    “你们还在纠结维度?我已经把它拍平了!”
    就像马斯克说的:“不要被表象迷惑,抓住本质。”

而这道题的本质是什么?

🔑 只要数据整体有序,维度只是障眼法!

你可以把它当成一张表格,也可以当成一条线,甚至可以当成一部电影的时间轴——只要你能找到“顺序”的锚点。


🛠️ 实际代码实现(可直接复制粘贴)

✅ 方法一:两次二分(推荐学习用)

var searchMatrix = function(matrix, target) {
    // 找到可能包含 target 的最后一行(基于首列)
    let low = -1;
    let high = matrix.length - 1;
    
    while (low < high) {
        const mid = Math.floor((high - low + 1) / 2) + low;
        if (matrix[mid][0] <= target) {
            low = mid;
        } else {
            high = mid - 1;
        }
    }

    if (low === -1) return false; // 比最小的还小

    // 在该行内进行二分查找
    const row = matrix[low];
    let left = 0, right = row.length - 1;
    while (left <= right) {
        const mid = Math.floor((right - left) / 2) + left;
        if (row[mid] === target) {
            return true;
        } else if (row[mid] > target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return false;
};

✅ 方法二:一次二分(推荐实战用)

var searchMatrix = function(matrix, target) {
    const m = matrix.length;
    const n = matrix[0].length;
    let low = 0;
    let high = m * n - 1;

    while (low <= high) {
        const mid = Math.floor((high - low) / 2) + low;
        const val = matrix[Math.floor(mid / n)][mid % n];

        if (val < target) {
            low = mid + 1;
        } else if (val > target) {
            high = mid - 1;
        } else {
            return true;
        }
    }
    return false;
};

⚡ 提示:背下这个下标映射公式,关键时刻能救你一命!


🌟 写在最后:学会“降维”,才能跳出题海

很多刷题人陷入困境的原因是:

“我做了100道二分题,怎么换了个马甲就不认识了?”

因为你记得的是“形式”,而不是“灵魂”。

真正的高手,看到这种矩阵不会想“二维怎么搞”,而是问自己:

❓ “这个结构有没有全局有序性?”
❓ “能不能把它变成我能处理的形式?”

一旦你掌握了这种化归思维,你会发现:

  • 旋转排序数组?→ 找断点 → 降维处理
  • 山脉数组找峰值?→ 利用单调性 → 二分收缩
  • 二维搜索?→ 扁平化 → 当作一维

🚀 所谓算法能力,不是记忆套路,而是不断把新问题翻译成旧知识的能力


❤️ 结语:愿你也能在代码世界里“以无厚入有间”

庄子说:“彼节者有间,而刀刃者无厚,以无厚入有间,恢恢乎其于游刃必有余地矣。”

翻译成程序员语言就是:

只要你找对了突破口(有序性),哪怕问题看起来坚不可摧(二维+嵌套),你的算法也能像一把薄刃,轻松滑进去,咔嚓一声——搞定!


PostCSS完全指南:功能/配置/插件/SourceMap/AST/插件开发/自定义语法

PostCSS是什么

PostCSS是一个转义CSS代码的工具,它的输入为CSS文件,输出也是CSS文件。它首先把CSS转换为抽象语法树AST,再使用插件对语法树进行修改,最后生成新的CSS代码。它的作用非常像JavaScript中的Babel。

在CSS领域,存在感更强的是SCSS和Less,它们是CSS的预处理器,扩充了CSS的语法和功能,可以编写复用性更强的代码。预处理器经过编译后,是CSS代码。而PostCSS正如它的名字,最常被用做CSS的后处理器,做一些兼容性功能。例如添加浏览器引擎的前缀,转换CSS代码以兼容不支持的浏览器等。预处理器和后处理器的关系类似于这样:

postcss-1.png

对比JavaScript的生态位,SCSS和Less像TypeScript扩充语法,PostCSS像Babel转义兼容语法。但PostCSS允许我们自定义语法规则,因此用作预处理器,甚至只用PostCSS也是可以的。

PostCSS使用

这里我们以最常用的插件Autoprefixer举例,这是一个根据兼容性设置添加浏览器引擎前缀的插件。首先创建css/index.css,作为我们要转义的CSS代码。

::placeholder {
  color: gray;
}

.image {
  width: stretch;
}

API方式

首先试一下JavaScriptAPI的方式使用PostCSS。执行下面的代码:

const fs = require("fs");
const autoprefixer = require("autoprefixer");
const postcss = require("postcss");

const originData = fs.readFileSync("./css/index.css", "utf-8");

postcss([autoprefixer])
  .process(originData, { from: "css/index.css", to: "out.css" })
  .then((res) => {
    console.log(res.css);
    fs.writeFileSync('out.css', res.css);
  });

我们读取CSS文件为字符串,放入PostCSS中进行转义,最后手动写入输出文件。虽然PostCSS要求指定from和to表示输入输出的文件路径,但实际上它们是给SourceMap用的,并不会真正帮我们读取写入(但还是必填)。最后生成的结果如下:

::-moz-placeholder {
  color: gray;
}

::placeholder {
  color: gray;
}

.image {
  width: -webkit-fill-available;
  width: -moz-available;
  width: stretch;
}

可以看到生成的CSS代码中的部分属性添加了浏览器前缀了。具体哪些前缀被添加,要根据Browserslist浏览器兼容范围确定。(在后面介绍插件的部分会提到)

命令行方式

使用PostCSS CLI可以支持以命令行方式转义CSS文件。首先需要安装postcss-cli依赖,然后命令行执行:

# 单个文件
postcss css/index.css -u autoprefixer -o out.css --no-map
# 目录
postcss css -u autoprefixer -d output --no-map

PostCSS CLI支持转义单个文件或者目录,目录会转义其中的每个文件。其中-u表示传入的插件名,-o表示输出的文件名,-d表示输出目录,--no-map表示不输出SourceMap。经过转义后,输出结果与上面API方式一致。如果更多配置,则需要使用PostCSS配置文件,我们在后面单独介绍。

Webpack中使用PostCSS

在Webpack中使用PostCSS,主要依靠postcss-loader。

创建Webpack项目

我们先创建一个Webpack项目,可以打包CSS,但不包含PostCSS。

# 创建项目
npm init -y
# 安装依赖
npm add webpack webpack-cli style-loader css-loader

创建src/index.css,内容为即为前面的CSS代码。再创建src/index.js,引入CSS文件:

import "./index.css";
console.log("你好,jzplp");

然后再创建Webapck配置文件webpack.config.js:

const path = require("path");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};

其中最关键的是style-loader和css-loader,这是引入CSS文件的必要loader。loader是从后向前链式调用,先css-loader,再style-loader。然后在package.json中增加命令:

"scripts": {
  "build": "webpack"
},

最后执行npm run build,结果输出到dist/main.js中。结果较长,这里只截取包含CSS的部分。可以看到,CSS被打包进JavaScript代码中,其内容未变。

postcss-2.png

引入postcss-loader

安装三个相关依赖:postcss postcss-loader autoprefixer。然后修改webpack.config.js,引入PostCSS:

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [
          "style-loader",
          "css-loader",
          {
            loader: "postcss-loader",
            options: {
              postcssOptions: {
                plugins: ['autoprefixer'],
              },
            },
          },
        ],
      },
    ],
  },
};

注意其中重点是新增加了postcss-loader,它的位置在数组的最后。意味着CSS文件先经过它处理,然后再给css-loader和style-loader。postcssOptions选项中可以配置插件。postcss-loader也支持使用配置文件postcss.config.js。

重新执行npm run build后,查看结果发现,除了原有代码外,还增加了浏览器前缀,说明代码成功被PostCSS转义了。(下图为了方便用两行展示CSS字符串,实际为一行)

postcss-3.png

配置文件postcss.config.js

在命令行或Webapck方式使用PostCSS时,都支持postcss.config.js作为配置文件。但是这两种配置文件的居然是不一样的,部分场景互相不兼容。因此这里分别介绍两种方式的配置文件。

命令行方式配置文件

引入插件

命令行仅支持直接引入插件对象的方式。例如前面我们列举的例子,使用配置文件内容如下:

const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
};

然后执行的命令进行修改,输出效果一致。

# 原命令
postcss css/index.css -u autoprefixer -o out.css --no-map
# 新命令
postcss css/index.css -o out.css

插件参数

为了描述插件参数,我们换一个postcss-color-gray插件。配置文件内容如下,先不使用插件参数:

const postcssColorGray = require("postcss-color-gray");
module.exports = {
  plugins: [postcssColorGray],
};

然后使用插件语法,重新写一段CSS代码,转义后的代码也在下面列出:

/* 源CSS代码 */
.jzplp {
  color: gray(0 / 90%);
}

/* 转义后CSS代码 */
.jzplp {
  color: rgba(0,0,0,0.9);
}

可以看到插件生效了。然后我们修改配置文件,增加插件参数:

const postcssColorGray = require("postcss-color-gray");
module.exports = {
  plugins: [postcssColorGray({ preserve: true })],
};

重新执行命令行,转义后的代码发生了变化,保留了gray函数,参数生效了。

/* 转义后CSS代码 */
.jzplp {
  color: rgba(0,0,0,0.9);
  color: gray(0 / 90%);
}

配置文件名称和格式

虽然PostCSS CLI中仅仅提到了配置文件名称为postcss.config.js,但我参考隔壁postcss-loader的文件名对PostCSS CLI进行了尝试,发现居然是支持的!这里我们描述一下。

首先是JavaScript类的配置文件,包括.postcssrc.js, .postcssrc.cjs, postcss.config.cjs等,PostCSS CLI是支持的,文件内容和执行效果都与postcss.config.js一致。

然后是JSON格式的配置文件,例如.postcssrc, .postcssrc.json等,PostCSS CLI也是支持的,但是由于插件必须直接引入插件对象,因此JSON格式实际上并不能用。它的报错和在postcss.config.js中直接写插件名称字符串的报错是一致的,因此判定文件本身被读取了,但不支持插件。这里举例下配置文件内容,以.postcssrc.json为例:

{
  "plugins": [["postcss-color-gray", { "preserve": true }]]
}

然后报错内容如下,可以看到是在读插件过程中的错误。使用yaml类的文件格式,报错也是一致的。

postcss-4.png

读取上下文

配置文件还可以导出一个函数,函数可以接收上下文入参,最终返回配置对象。这里我们给出配置文件的示例:

const postcssColorGray = require("postcss-color-gray");
module.exports = (ctx) => {
  console.log(ctx);
  return {
    plugins: [
      postcssColorGray(ctx.env === "development" ? { preserve: true } : {}),
    ],
  };
};

/* 输出结果
{
  cwd: 'E:\\testProj\\postcss-test\\apitest',
  env: undefined,
  options: {
    map: { inline: true },
    parser: undefined,
    syntax: undefined,
    stringifier: undefined
  },
  file: {
    dirname: 'E:\\testProj\\postcss-test\\apitest\\css',
    basename: 'index.css',
    extname: '.css'
  }
}
*/

通过上面代码可以看到,可以根据上下文入参调整配置对象的内容。这里简单说明上下文入参含义:

  • env: 为process.env.NODE_ENV的值
  • file: 文件名相关参数
    • dirname: 文件路径
    • basename: 文件名
    • extname: 文件扩展名
  • options: 命令行中输入的选项

Webpack方式配置文件

引入插件

Webpack方式不仅支持直接引入插件对象的方式,还支持直接写插件名称字符串。两种方式这里都列举下:

// 直接引入插件对象
const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
};

// 直接写插件名称字符串
module.exports = {
  plugins: ['autoprefixer'],
};

然后删除Webapck配置中的插件配置,效果一致。Webapack中配置可以是这样(仅展示相关片段):

module: {
  rules: [
    {
      test: /\.css$/i,
      use: ["style-loader", "css-loader", "postcss-loader"],
    },
  ],
},

插件参数

这里还使用前面命令行中插件参数一节里面的postcss-color-gray插件和CSS代码。这里尝试用两种插件配置方式:

// 直接引入插件对象
const autoprefixer = require("autoprefixer");
const postcssColorGray = require("postcss-color-gray");
module.exports = {
  plugins: [autoprefixer, postcssColorGray({ preserve: true })],
};

// 直接写插件名称字符串
module.exports = {
  plugins: ['autoprefixer', ['postcss-color-gray', { preserve: true }]],
};

查看结果,两种方式都能正常接收参数,结果与命令行方式一致。这里还举例了传入多个插件的方式。

配置文件名称和格式

Webpack方式的配置文件,支持多种配置文件格式和文件名,包括.postcssrc, .postcssrc.json, .postcssrc.yaml, .postcssrc.js, postcss.config.cjs等等,具体可以看postcss-loader的文档。其中JSON和YAML类的格式仅支持直接写插件名称字符串,这里示例下配置文件的格式,首先是JSON格式:

{
  "plugins": [
    "autoprefixer",
    ["postcss-color-gray", { "preserve": true }]
  ]
}

然后是YAML格式,实际内容一致:

plugins:
  - autoprefixer
  - - postcss-color-gray
    - preserve: true

读取上下文

Webpack方式的配置文件也可以导出一个函数,函数可以接收上下文入参,最终返回配置对象。这里我们给出配置文件的示例:

module.exports = (ctx) => {
  console.log(ctx);
  return {
    plugins: [["postcss-color-gray", ctx.mode === "development" ? { preserve: true } : {}]],
  };
};

/* 输出结果
{
  mode: 'production',
  file: 'E:\\testProj\\postcss-test\\webpacktest\\src\\index.css',
  webpackLoaderContext: {} // 内容很多,这里省略
  env: 'production',
  options: { plugins: [ 'autoprefixer' ] }
*/

这里的输出与命令行方式类似,但不完全一样,这里简单说明上下文入参含义:

  • env和mode: 为process.env.NODE_ENV的值
  • file: 文件路径
  • options: Webpack配置中的postcssOptions选项
  • webpackLoaderContext: Webpack loader的上下文,内容很多

其余参数

这些配置文件都可以接收PostCSS的API中ProcessOptions的参数,除了from和to。这里列举几个:

  • parser 传入解析AST的方法
  • stringifier 传入从AST生成字符串的方法
  • syntax 传入AST解析和生成的方法,相当于parser + stringifier
  • map SourceMap选项

这些参数的具体用法会在后面介绍到。

不同点总结

从上面命令行方式与Webpack方式配置文件的描述,我们可以明确两种方式配置文件的不同点:

  • 最主要的不同是引入插件方式的不同:命令行仅支持直接引入插件对象的方式,Webpack方式还支持直接写插件名称字符串。这导致了插件传参方式和配置文件格式的限制。
  • 其次是读取上下文参数的不同。这是由于两者运行方式不同,命令行与Webpack方式可以接收到的配置数据不同,因此上下文参数不一样。

各类插件简介

PostCSS中提供了非常多的插件,作用各不相同,这里列举几个进行介绍:

Autoprefixer

Autoprefixer是PostCSS中最知名的插件,它的作用是根据浏览器兼容性,添加浏览器引擎前缀。浏览器引擎前缀是浏览器为了给实验性或者浏览器引擎独有的非标准CSS属性添加的前缀,这样这个实验属性就不会影响到其它浏览器,开发者也能识别这是针对某种浏览器做的优化。常见的前缀有:

  • -webkit-: 基于WebKit内核的浏览器,例如Chrome、Safari等
  • -moz-: 火狐浏览器
  • -o-: 旧版(WebKit之前的)Opera浏览器
  • -ms-: IE 浏览器

这里举一个例子,transition属性用上面的浏览器引擎前缀可以写为:

div {
  /* 原属性 */
  transition: all 4s ease;
  /* 增加浏览器引擎前缀 */
  -webkit-transition: all 4s ease;
  -moz-transition: all 4s ease;
  -ms-transition: all 4s ease;
  -o-transition: all 4s ease;
}

现在添加浏览器引擎前缀经常是为了兼容性,为了在旧版本浏览器也可以使用较新的CSS特性。而Autoprefixer插件就可以帮我们做到这件事。这个插件没有浏览器兼容配置,而是读取工程的Browserslist配置。这里举个例子。首先给出我们要转义的CSS代码:

.jzplp {
  display: flex;
  width: stretch;
}

通过在package.json中设置不同的Browserslist配置,我们能得到不同的代码生成结果,这对应的是不同浏览器版本的兼容性。兼容的版本越多,那么需要处理的就越多。

/* 配置 "browserslist": "> 1%" 的生成结果 */ 
.jzplp {
  display: flex;
  width: -webkit-fill-available;
  width: stretch;
}

/* 配置 "browserslist": "> 0.01%" 的生成结果 */
.jzplp {
  display: -moz-box;
  display: flex;
  width: -webkit-fill-available;
  width: -moz-available;
  width: stretch;
}

postcss-custom-properties

postcss-custom-properties是一个增加CSS变量兼容性的插件,对于不支持的CSS var的浏览器提供后备值。这里来举例试一下:

/* 源CSS代码 */
.jzplp {
  background: var(--jza);
  color: var(--jzb, red);
}

/* 生成CSS代码 */
.jzplp {
  background: var(--jza);
  color: red;
  color: var(--jzb, red);
}

通过上面例子可以看到,如果提供了CSS var的后备值,那么插件将会生成一个不带CSS var的版本。如果不支持的浏览器读取到不带CSS var的版本,可以正常展示;支持的浏览器则使用var的版本覆盖前一个不支持的属性值。这样实现了CSS var的浏览器兼容性处理。如果我们没有提供后备值,则不会生成兼容性代码。

但是如果在同一个文件中提供了CSS变量的值,那么即使var函数中没提供后备值,也可以生成兼容性代码。我们看一下例子:

/* 源CSS代码 */
:root {
  --jza: blue;
}
.jzplp {
  background: var(--jza);
  color: var(--jzb);
}

/* 生成CSS代码 */
:root {
  --jza: blue;
}
.jzplp {
  background: blue;
  background: var(--jza);
  color: var(--jzb);
}

可以看到例子中--jza提供了变量值,因此生成了兼容性代码;jzb没有则不会生成。变量值必须与使用变量的代码在同一个文件中才有效。

postcss-global-data

在前面postcss-custom-properties插件中我们看到,只有在同一个文件中提供了CSS变量值,才能生成兼容性代码。但是工程中的全局变量与使用位置一般不会在一个文件内,这会导致postcss-custom-properties插件无法识别。而postcss-global-data就可以解决这个问题。

postcss-global-data插件允许我们提供一些全局CSS文件,作为每个被编译文件的附加数据使用。但这些全局文件的编译结果会在输出前被移除,因此不会使每个编译后文件的代码体积增加。我们来看一下例子,首先是PostCSS配置文件, 注意postcss-global-data插件必须在前:

const postcssCustomProperties = require("postcss-custom-properties");
const postcssGlobalData = require("@csstools/postcss-global-data");

module.exports = (ctx) => {
  return {
    plugins: [
      postcssGlobalData({
        files: ["./global.css"],
      }),
      postcssCustomProperties,
    ],
  };
};

然后是要编译的文件index.css和全局CSS文件global.css:

/* global.css */
:root {
  --jza: blue;
}

/* index.css */
.jzplp {
  background: var(--jza);
}

最后我们看下编译的结果:

/* 未使用postcss-global-data插件 */
.jzplp {
  background: var(--jza);
}

/* 已使用postcss-global-data插件 */
.jzplp {
  background: blue;
  background: var(--jza);
}

可以看到在使用postcss-global-data插件的情况下,生成代码中增加了兼容性代码,读取了全局CSS文件中的变量值。但是全局CSS文件global.css中的CSS代码却没有包含进来。

cssnano

cssnano是一个代码压缩工具,将CSS代码进行语义化压缩。与gzip等纯压缩工具的不一样的是,它会根据语义对代码本身进行改动,例如去掉注释,去掉重复属性,合并选择器等等。这里我们举例试试:

/* 源CSS代码 */
.jzplp {
  color: red;
  /* 我是注释 */
  color: red;
}
.jzplp {
  width: 20px;
}

/* 生成CSS代码 */
.jzplp{color:red;width:20px}

可以看到,同样的选择器被合并,同样的属性值被合并了,注释和中间的换行符空格都去掉了。cssnano还支持预设或者插件,这里就不描述了。

PostCSS兼容性插件

使用postcss-preset-env

前面介绍了几个PostCSS的插件,但有一个插件却留到了这一节介绍:postcss-preset-env。类似与Babel中的@babel/preset-env预设,postcss-preset-env中包含了很多PostCSS浏览器兼容性的插件。它会读取我们配置的浏览器兼容版本,根据CSSDB上面的特性列表以及我们代码使用了哪些新特性,选择应用哪些插件,从而在浏览器不支持某些CSS新特性的情况下,允许使用新特性。

首先接入插件,由于postcss-preset-env是做兼容性处理的,因此需要放在其它插件的后面,让兼容性插件最后处理:

const postcssPresetEnv = require("postcss-preset-env");
module.exports = {
  plugins: [
    // 其他插件
    postcssPresetEnv({ /* 插件参数 */ }),
  ],
};

postcss-preset-env插件也是读取工程中的Browserslist配置。这里我们提供一段CSS源代码,让postcss-preset-env插件在不同浏览器配置下编译试试。

/* 源CSS代码 */
.jzplp {
  width: stretch;
  color: #01020380;
}

/* 转义后CSS代码 "browserslist": "> 1%" */
.jzplp {
  width: -webkit-fill-available;
  width: stretch;
  color: #01020380;
}

/* 转义后CSS代码 "browserslist": "> 0.1%" */
.jzplp {
  width: -webkit-fill-available;
  width: -moz-available;
  width: stretch;
  color: rgba(1,2,3,0.50196);
}

通过上面的结果,我们可以看到不同的浏览器兼容性配置,会生成不同的代码。postcss-preset-env中包含了Autoprefixer,因此会自动添加浏览器引擎前缀。此外,还会根据支持程度添加其它兼容性代码。

例如#rrggbbaa,是分别以两个16进制数字表示红R绿G蓝B透明度A表示颜色的方式,只在相对较新的浏览器中支持。当browserslist>1%时不进行转义,当>0.1%时就转义为rgba函数的形式。

是否任何语法都能转义

postcss-preset-env插件可以根据源CSS代码使用的特性来转义代码,增加兼容性。那么是不是什么特性都能转义,转义后的效果是不是与转义前一致?这很显然是否定的。即使是JavaScript代码,转义和Polyfill也做不到将所有新特性的兼容模式运行和新特性完全一致。例如Vue3之所以不兼容IE的一个原因,就是无法兼容JavaScript语法中Proxy的所有特性。至于CSS兼容性的限制就更大了。这里我们依然使用之前的CSS变量代码来举例:

/* 源CSS代码 */
.jzplp {
  background: var(--jza, red);
  color: var(--jzb);
}

/* 生成CSS代码 "browserslist": "> 0.001%" */
.jzplp {
  background: red;
  background: var(--jza, red);
  color: var(--jzb);
}

即使浏览器兼容性配置的要求很高,生成的代码也是这样。当我们提供了后备值时,插件会为我们生成兼容性的固定值background: red。如果没提供,那插件则无能为例。不管有没有生成固定值,这段代码在不支持CSS变量的浏览器运行时,效果与支持的浏览器不一样:因为变量的运行时变更功能无法被兼容。因此这明显可以得出:转义插件并不是任何属性都能转义,相反它不能做到的事情特别多,只能够尽量。

PostCSS与SCSS和Less

这一部分我们以Webapck作为环境,从直接引入SCSS与Less开始,再到用PostCSS做后处理器,再直接用PostCSS解析SCSS与Less。

Webapck引入SCSS

首先尝试在Webpack中引入SCSS。还是前面创建的Webpack工程,安装依赖sass和sass-loader,然后修改webpack.config.js:

const path = require("path");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.scss$/i,
        use: ["style-loader", "css-loader", "sass-loader"],
      },
    ],
  },
};

可以看到,增加了一条规则匹配SCSS文件。先使用sass-loader把SCSS解析成CSS,然后再用常规的CSS处理。我们要打包的代码如下。首先是入口文件,其中引入了CSS和SCSS文件。

import "./index.scss";
import "./index.css";
console.log("你好,jzplp");

然后是CSS和SCSS文件内容:

/* index.css */
.jzplp {
  color: blue;
}

/* index.scss */
$jzabc: red;
div {
  color: $jzabc;
}

SCSS文件中使用了变量的特性,SCSS在编译后会变成它的实际值,我们看看打包结果。文件较长,仅展示相关部分:

i.push([e.id, ".jzplp {\n  color: blue;\n}", ""]);
i.push([e.id, "div{color:red}", ""]);
console.log("你好,jzplp");

可以看到,SCSS文件被编译成功,也打包进了最终成果中。

Webapck引入Less

再尝试在Webpack中引入Less。安装依赖less和less-loader,然后修改webpack.config.js,这里只列出module部分,其它和Webapck引入SCSS一致:

const path = require("path");

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.less$/i,
        use: ["style-loader", "css-loader", "less-loader"],
      },
    ],
  },
};

然后创建index.less,内容如下:

@jzabc: red;
div {
  color: @jzabc;
}

在index.js中引入index.less:import "./index.less";。其它内容和上面一致,然后进行打包,生成结果如下(仅展示相关部分)。可以看到,Less文件也被编译成功,也打包进了最终成果中。

i.push([e.id, "div {\n  color: red;\n}\n", ""]);
i.push([e.id, ".jzplp {\n  color: blue;\n}", ""]);
console.log("你好,jzplp");

用PostCSS做后处理器

在文章的一开始,我们介绍了PostCSS最常用作后处理器,这一节我们就在前面引入SCSS与Less的基础上,集成PostCSS用作后处理器。

const path = require("path");

const postcssConfig = {
  loader: "postcss-loader",
  options: {
    postcssOptions: {
      plugins: ["autoprefixer"],
    },
  },
};

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader", postcssConfig],
      },
      {
        test: /\.less$/i,
        use: ["style-loader", "css-loader", postcssConfig, "less-loader"],
      },
      {
        test: /\.scss$/i,
        use: ["style-loader", "css-loader", postcssConfig, "sass-loader"],
      },
    ],
  },
};

上面同时支持CSS/SCSS/Less文件引入,都配置了PostCSS。看PostCSS的位置是在SCSS和Less之后,这就是后处理器的含义。然后是要打包的各个文件代码:

/* index.js */
import "./index.scss";
import "./index.less";
import "./index.css";
console.log("你好,jzplp");

/* index.scss */
$jzabc: red;
.jzplp-scss {
  color: $jzabc;
  width: stretch;
}

/* index.less */
@jzabc: red;
.jzplp-less {
  color: @jzabc;
  width: stretch;
}

/* index.css */
.jzplp-css {
  color: blue;
  width: stretch;
}

最后生成的结果如下:

".jzplp-scss{color:red;width:-webkit-fill-available;width:-moz-available;width:stretch}",
".jzplp-css {\n  color: blue;\n  width: -webkit-fill-available;\n  width: -moz-available;\n  width: stretch;\n}",
".jzplp-less {\n  color: red;\n  width: -webkit-fill-available;\n  width: -moz-available;\n  width: stretch;\n}\n",
console.log("你好,jzplp");

可以看到,不仅成功编译了SCSS和Less,而且代码经过了PostCSS处理,加上了浏览器引擎前缀。

PostCSS直接解析SCSS与Less

在前面PostCSS用作后处理器的方式中,修改一下编译的代码:

/* index.js */
import "./index.scss";
import "./index.less";
import "./index.css";
console.log("你好,jzplp");

/* index.scss */
$jzabc: stretch;
.jzplp-scss {
  width: $jzabc;
}

/* index.less */
@jzabc: stretch;
.jzplp-less {
  width: @jzabc;
}

重新编译,结果如下:

".jzplp-less {\n  width: -webkit-fill-available;\n  width: -moz-available;\n  width: stretch;\n}\n"
".jzplp-scss{width:-webkit-fill-available;width:-moz-available;width:stretch}"
console.log("你好,jzplp");

如果我们将postcss-loader与less-loader和sass-loader换一下位置,先处理PostCSS,再编译SCSS和Less,结果会如何呢?参考配置如下,仅展示改动部分:

{
  test: /\.less$/i,
  use: ["style-loader", "css-loader", "less-loader", postcssConfig],
},
{
  test: /\.scss$/i,
  use: ["style-loader", "css-loader", "sass-loader", postcssConfig],
},

重新编译,结果如下:

".jzplp-less {\n  width: stretch;\n}\n"
".jzplp-scss{width:stretch}"
console.log("你好,jzplp");

可以看到生成结果并不同,stretch没有增加浏览器引擎前缀。原因是先经过PostCSS处理时,并不能识别SCSS和Less语法,因此无法处理相关的内容。而其中的正常CSS语法,PostCSS依然可以处理。

PostCSS还提供了SCSS和Less语法的解析器postcss-scss和postcss-less,我们引入试一下。首先修改Webapck配置:

{
  test: /\.less$/i,
  use: [
    "style-loader",
    "css-loader",
    "less-loader",
    {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          syntax: 'postcss-less',
          plugins: ["autoprefixer"],
        },
      },
    },
  ],
},
{
  test: /\.scss$/i,
  use: [
    "style-loader",
    "css-loader",
    "sass-loader",
    {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          syntax: 'postcss-scss',
          plugins: ["autoprefixer"],
        },
      },
    },
  ],
},

重新编译,结果如下:

".jzplp-less {\n  width: stretch;\n}\n"
".jzplp-scss{width:stretch}"
console.log("你好,jzplp");

可以看到效果与上面PostCSS先处理的情况一致,stretch没有增加浏览器引擎前缀。这是因为postcss-scss和postcss-less都是将SCSS和Less代码转换为AST,然后再将AST转换回来,并不编译为CSS代码。因此SCSS和Less变量并未被PostCSS插件转义。它们的作用主要是提供SCSS和Less的AST结点,方便对应的SCSS和Less的PostCSS插件做处理。虽然相关文档的使用场景说了可以直接使用普通PostCSS插件处理,但上面的测试结果说明,可以处理但并不完美。

PostCSS的SourceMap

命令行生成SourceMap

首先来看一下命令行方式如何生成SourceMap。先给出一个简单的源文件:

.jzplp {
  width: stretch;
}
div {
  color: var(--abc, red);
}

然后执行命令:postcss index.css -u autoprefixer -o out.css。这种情况下PostCSS不会去读postcss.config.js配置文件,而是仅使用命令行传参来转义。,默认情况下,PostCSS会生成inline,即附加在生成文件中作为注释的SourceMap。生成结果如下:

.jzplp {
  width: -webkit-fill-available;
  width: -moz-available;
  width: stretch;
}
div {
  color: var(--abc, red);
}

/*# sourceMappingURL* 防止报错 *=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImNzcy9pbmRleC5jc3MiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7RUFDRSw2QkFBYztFQUFkLHFCQUFjO0VBQWQsY0FBYztBQUNoQjtBQUNBO0VBQ0Usc0JBQXNCO0FBQ3hCIiwiZmlsZSI6Im91dC5jc3MiLCJzb3VyY2VzQ29udGVudCI6WyIuanpwbHAge1xyXG4gIHdpZHRoOiBzdHJldGNoO1xyXG59XHJcbmRpdiB7XHJcbiAgY29sb3I6IHZhcigtLWFiYywgcmVkKTtcclxufVxyXG4iXX0= */

如果不希望生成SourceMap,那么命令行中附加--no-map即可:postcss css/index.css -u autoprefixer -o out.css --no-map。但如果希望生成独立的SourceMap文件,就必须使用PostCSS配置文件了。我们先把命令行修改为:postcss css/index.css -o out.css。然后配置postcss.config.js:

const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
};

执行命令行,发现生成文件与--no-map效果一致,不生成SourceMap。如果生成inline的SourceMap,配置文件修改为:

const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
  map: true,
};

如果希望生成的独立的配置文件,则配置文件修改为:

const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
  map: { inline: false },
};

这时候会生成一个文件out.css.map,其内容如下:

{
  "version": 3,
  "sources": [
    "css/index.css"
  ],
  "names": [],
  "mappings": "AAAA;EACE,6BAAc;EAAd,qBAAc;EAAd,cAAc;AAChB;AACA;EACE,sBAAsB;AACxB",
  "file": "out.css",
  "sourcesContent": [
    ".jzplp {\r\n  width: stretch;\r\n}\r\ndiv {\r\n  color: var(--abc, red);\r\n}\r\n"
  ]
}

可以看到,这个SourceMap文件的格式规范与之前JavaScript的SourceMap规范是一致的。

API方式生成SourceMap

API方式与命令行的配置方式类似,首先是生成inline的SourceMap:

const fs = require("fs");
const autoprefixer = require("autoprefixer");
const postcss = require("postcss");

const originData = fs.readFileSync("./css/index.css", "utf-8");

postcss([autoprefixer])
  .process(originData, { from: "css/index.css", to: "out.css", map: true })
  .then((res) => {
    console.log(res.css);
    fs.writeFileSync('out.css', res.css);
  });

生成的SourceMap会直接在res.css中作为注释存在,输出到文件中和命令行输出一致。如果不希望生成inline的SourceMap,则是这样配置:

postcss([autoprefixer])
  .process(originData, {
    from: "css/index.css",
    to: "out.css",
    map: { inline: false },
  })
  .then((res) => {
    console.log(res.map);
    const str = res.map.toString();
    fs.writeFileSync("out.css", res.css);
    fs.writeFileSync("out.css.map", str);
  });

/* 输出结果
SourceMapGenerator{...省略 }
*/

这时候SourceMap在res.map中,而且不是以字符串的形式存在,而是以SourceMapGenerator对象的形式存在。我们在快速定位源码问题:SourceMap的生成/使用/文件格式与历史文章中描述过,这是source-map包中的对象类型,可以直接输出SourceMap字符串,也可以做进一步处理。这里将它输出成字符串,并打印到文件中,内容和命令行输出一致。

SourceMap内容解析

我们使用前面文章Webpack中各种devtool配置的含义与SourceMap生成逻辑中介绍过的解析SourceMap工具代码,来解析上面生成的SourceMap文件。解析结果如下:

生成代码行10  源代码行10  源名称-            源文件:css/index.css
生成代码行22  源代码行22  源名称-            源文件:css/index.css
生成代码行231 源代码行216 源名称-            源文件:css/index.css
生成代码行32  源代码行22  源名称-            源文件:css/index.css
生成代码行323 源代码行216 源名称-            源文件:css/index.css
生成代码行42  源代码行22  源名称-            源文件:css/index.css
生成代码行416 源代码行216 源名称-            源文件:css/index.css
生成代码行50  源代码行30  源名称-            源文件:css/index.css
生成代码行60  源代码行40  源名称-            源文件:css/index.css
生成代码行72  源代码行52  源名称-            源文件:css/index.css
生成代码行724 源代码行524 源名称-            源文件:css/index.css
生成代码行80  源代码行60  源名称-            源文件:css/index.css

可以看到,全是生成代码和源码的对应关系,一个标识符都没有。这也正常,毕竟CSS代码中并没有真正意义上JavaScript的那种标识。这里我们表格列一下对应关系指向的具体内容:

源代码标识符 源代码行号 源代码列号 生成代码标识符 生成代码行号 生成代码列号
.jzplp 1 0 .jzplp 1 0
width 2 2 width 2 2
; 2 16 ; 2 31
width 2 2 width 3 2
; 2 16 ; 3 23
width 2 2 width 4 2
; 2 16 ; 4 16
} 3 0 } 5 0
div 4 0 div 6 0
color 5 2 color 7 2
; 5 24 ; 7 24
} 6 0 } 8 0

通过上面的表格,可以看到PostCSS记录SourceMap的规律:标记规则选择器的开头,规则大括号的结尾}。标记CSS声明的开头,声明值的结尾;。

PostCSS的AST

生成AST数据

我们来试一下如何使用PostCSS生成AST数据。首先还是先构造一个CSS文件,里面涵盖了CSS的一些常见场景:

.jzplp {
  width: 10px;
  color: var(--abc, red);
}
div .jzplp2 {
  color: blue !important;
}
/* this is comment jzplp */
.jz1 {
  margin: 5px;
  .jz2 {
    padding: 10px 20px;
  }
}
@media (max-width: 768px) {
  #jz3 {
    border: 1px solid red;
  }
}

然后使用前面API的方式处理CSS文件,在res.root中就能拿到AST数据:

const fs = require("fs");
const postcss = require("postcss");

const originData = fs.readFileSync("./css/index.css", "utf-8");
postcss()
  .process(originData, {
    from: "css/index.css",
    to: "out.css",
    map: { inline: false },
  })
  .then((res) => {
    console.log(res.root);
  });

PostCSS还提供了方法,可以直接解析CSS拿到AST数据:

const fs = require("fs");
const postcss = require("postcss");

const originData = fs.readFileSync("./css/index.css", "utf-8");
const data = postcss.parse(originData);
const dataJson = JSON.stringify(data.toJSON());
console.log(dataJson);

然后得到我们最终拿到的AST数据的JSON格式,后面的分析都基于这份数据:

{
  "raws": { "semicolon": false, "after": "" },
  "type": "root",
  "nodes": [
    {
      "raws": { "before": "", "between": " ", "semicolon": true, "after": "\r\n" },
      "type": "rule",
      "nodes": [
        {
          "raws": { "before": "\r\n  ", "between": ": " },
          "type": "decl",
          "source": {
            "end": { "column": 14, "line": 2, "offset": 24 },
            "inputId": 0,
            "start": { "column": 3, "line": 2, "offset": 12 }
          },
          "prop": "width",
          "value": "10px"
        },
        {
          "raws": { "before": "\r\n  ", "between": ": " },
          "type": "decl",
          "source": {
            "end": { "column": 25, "line": 3, "offset": 51 },
            "inputId": 0,
            "start": { "column": 3, "line": 3, "offset": 28 }
          },
          "prop": "color",
          "value": "var(--abc, red)"
        }
      ],
      "source": {
        "end": { "column": 1, "line": 4, "offset": 54 },
        "inputId": 0,
        "start": { "column": 1, "line": 1, "offset": 0 }
      },
      "selector": ".jzplp"
    },
    {
      "raws": { "before": "\r\n", "between": " ", "semicolon": true, "after": "\r\n" },
      "type": "rule",
      "nodes": [
        {
          "raws": { "before": "\r\n  ", "between": ": " },
          "type": "decl",
          "source": {
            "end": { "column": 25, "line": 6, "offset": 96 },
            "inputId": 0,
            "start": { "column": 3, "line": 6, "offset": 73 }
          },
          "prop": "color",
          "important": true,
          "value": "blue"
        }
      ],
      "source": {
        "end": { "column": 1, "line": 7, "offset": 99 },
        "inputId": 0,
        "start": { "column": 1, "line": 5, "offset": 56 }
      },
      "selector": "div .jzplp2"
    },
    {
      "raws": { "before": "\r\n", "left": " ", "right": " " },
      "type": "comment",
      "source": {
        "end": { "column": 27, "line": 8, "offset": 128 },
        "inputId": 0,
        "start": { "column": 1, "line": 8, "offset": 101 }
      },
      "text": "this is comment jzplp"
    },
    {
      "raws": { "before": "\r\n", "between": " ", "semicolon": false, "after": "\r\n" },
      "type": "rule",
      "nodes": [
        {
          "raws": { "before": "\r\n  ", "between": ": " },
          "type": "decl",
          "source": {
            "end": { "column": 14, "line": 10, "offset": 152 },
            "inputId": 0,
            "start": { "column": 3, "line": 10, "offset": 140 }
          },
          "prop": "margin",
          "value": "5px"
        },
        {
          "raws": { "before": "\r\n  ", "between": " ", "semicolon": true, "after": "\r\n  " },
          "type": "rule",
          "nodes": [
            {
              "raws": { "before": "\r\n    ", "between": ": " },
              "type": "decl",
              "source": {
                "end": { "column": 23, "line": 12, "offset": 187 },
                "inputId": 0,
                "start": { "column": 5, "line": 12, "offset": 168 }
              },
              "prop": "padding",
              "value": "10px 20px"
            }
          ],
          "source": {
            "end": { "column": 3, "line": 13, "offset": 192 },
            "inputId": 0,
            "start": { "column": 3, "line": 11, "offset": 156 }
          },
          "selector": ".jz2"
        }
      ],
      "source": {
        "end": { "column": 1, "line": 14, "offset": 195 },
        "inputId": 0,
        "start": { "column": 1, "line": 9, "offset": 130 }
      },
      "selector": ".jz1"
    },
    {
      "raws": { "before": "\r\n", "between": " ", "afterName": " ", "semicolon": false, "after": "\r\n" },
      "type": "atrule",
      "name": "media",
      "source": {
        "end": { "column": 1, "line": 19, "offset": 270 },
        "inputId": 0,
        "start": { "column": 1, "line": 15, "offset": 197 }
      },
      "params": "(max-width: 768px)",
      "nodes": [
        {
          "raws": { "before": "\r\n  ", "between": " ", "semicolon": true, "after": "\r\n  " },
          "type": "rule",
          "nodes": [
            {
              "raws": { "before": "\r\n    ", "between": ": " },
              "type": "decl",
              "source": {
                "end": { "column": 26, "line": 17, "offset": 262 },
                "inputId": 0,
                "start": { "column": 5, "line": 17, "offset": 240 }
              },
              "prop": "border",
              "value": "1px solid red"
            }
          ],
          "source": {
            "end": { "column": 3, "line": 18, "offset": 267 },
            "inputId": 0,
            "start": { "column": 3, "line": 16, "offset": 228 }
          },
          "selector": "#jz3"
        }
      ]
    }
  ],
  "source": {
    "end": { "column": 2, "line": 19, "offset": 270 },
    "inputId": 0,
    "start": { "column": 1, "line": 1, "offset": 0 }
  },
  "inputs": [
    {
      "hasBOM": false,
      "css": ".jzplp {\r\n  width: 10px;\r\n  color: var(--abc, red);\r\n}\r\ndiv .jzplp2 {\r\n  color: blue !important;\r\n}\r\n/* this is comment jzplp */\r\n.jz1 {\r\n  margin: 5px;\r\n  .jz2 {\r\n    padding: 10px 20px;\r\n  }\r\n}\r\n@media (max-width: 768px) {\r\n  #jz3 {\r\n    border: 1px solid red;\r\n  }\r\n}",
      "id": "<input css abVh40>"
    }
  ]
}

公共属性

PostCSS的AST结点类型不多,且不同的结点有一些公共属性,这里列举一下:

属性名 属性含义 数据举例 数据举例说明
type 结点类型 decl CSS声明类型
nodes 子结点列表 [...] 子结点数组
raws 结点中的空白字符 { "semicolon": false, "after": "\r\n" } 下面单独说明
source 结点的源码位置 {...} 下面单独说明

其中最重要的是type和nodes属性,一个表示结点的类型,一个是包含的子结点列表。raws中分类整理了结点中的空白字符,这里列举一下属性含义:

  • raws.before 结点前的空白符
  • raws.after 结点后的空白符
  • raws.between 对于decl结点是属性和值中间的空白符,例如key: value中间的,对于其它结点类型是其它位置的空白。
  • raws.semicolon 如果最后一个子结点有分号则为true
  • raws.afterName 在at规则和它的条件之间的空白。例如@media (中间
  • raws.left 注释中/*之后,注释内容之前。
  • raws.right 注释内容之后,注释*/之前。

source是生成SourceMap使用的数据,通过文件标识+起点终点的行号列号,可以精确找到结点在源文件的位置。这里也列举一下属性含义:

  • source.inputId 源文件标识
  • source.start 起点位置信息
  • source.end 终点位置信息
  • source.start.line或source.end.line 行号
  • source.start.column或source.end.column 列号
  • source.start.offset或source.end.offset 距离文件起始的偏移量

CSS结点类型和私有属性

这里列举一下PostCSS的AST结点类型,以及它们私有的属性:

root结点类型

标识一个CSS文件,其中包含所有解析的结点。

rule结点类型

rule结点是一个CSS规则,包含选择器,后面跟着一个声明块。举例如下:

/* 一个rule结点 */
.jzplp {
  width: 10px;
  color: var(--abc, red);
}
/* 一个rule结点 */
.jz1 {
  margin: 5px;
  /* 嵌套一个rule结点 */
  .jz2 {
    padding: 10px 20px;
  }
}

rule结点有一个私有属性selector,表示这个结点的选择器,值例如.jzplp,或者组合选择器div .jzplp2

comment结点类型

comment结点表示一个注释。它有个私有属性text,表示注释内容。

decl结点类型

decl结点表示一个CSS声明,即key: value;的结构。例如width: 10px;或者border: 1px solid red;。即使值包含函数或者多个值集合,decl结点也不再细分,不包含子结点。decl结点的私有属性如下:

  • prop: CSS声明属性名,例如width
  • value: CSS声明属性值,例如10px
  • important: 如果声明设置了!important,这里的值为true

atrule结点类型

atrule结点表示一个at规则,即以@符号开头的规则。举例如下:

/* 一个atrule结点 */
@media (max-width: 768px) {
  /* 嵌套一个rule结点 */
  #jz3 {
    border: 1px solid red;
  }
}

atrule结点有私有属性name,表示@后面跟的标识符,例如上面的media。还有一个私有属性params,表示at规则内的参数,例如上面的(max-width: 768px)

插件开发

新建插件

首先我们创建pluginJzplp.js文件存放插件代码,然后在PostCSS配置文件中引入:

// pluginJzplp.js
function pluginJzplp() {
  console.log('init');
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log('Root');
    },
    Declaration() {},
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

// postcss.config.js
const pluginJzplp = require('./pluginJzplp');
module.exports = {
  plugins: [pluginJzplp],
};

可以看到,插件就是一个函数,函数的返回值是一个对象,其中postcssPlugin属性表示函数名称,其它属性大部分都和遍历AST有关。这个函数本身还需要设置postcss属性为true,这样才能被PostCSS认为是插件。我们看下执行后的输出:

// 转义单文件
init
init
Root
// 转义两个文件
init
init
Root
init
init
Root

可以看到,每转义一个文件,插件函数会被执行两次,但实际遍历(输出Root)只有一次。我们再来看下函数入参:

// pluginJzplp.js
function pluginJzplp(p1, p2, p3) {
  console.log(p1, p2, p3);
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log('Root');
    },
    Declaration() {},
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

// postcss.config.js
const pluginJzplp = require('./pluginJzplp');
module.exports = {
  plugins: [pluginJzplp('jz1', 'jz2', 3)],
};

/* 单文件输出结果
jz1 jz2 3
Root
Root
*/

可以看到,插件函数的入参全部是插件引入时传的参数。

遍历AST

PostCSS插件遍历AST的方法与Babel类似,都是以结点类型名作为函数属性。当遍历到对应类型的结点时,函数被触发,入参为对应的结点数据。我们来看一个插件例子:

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log("Root");
    },
    Rule(data) {
      console.log("Rule", data.selector);
    },
    Declaration: {
      color(data) {
        console.log("Declaration color", data.value);
      },
      width(data) {
        console.log("Declaration width", data.value);
      },
    },
    AtRule: {
      media(data) {
        console.log("AtRule media", data.params);
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

/* 输出结果
Root
Rule .jzplp
Declaration width 10px
Declaration color var(--abc, red)
Rule div .jzplp2
Declaration color blue
Rule .jz1
Rule .jz2
AtRule media (max-width: 768px)
Rule #jz3
*/

对应节点类型函数的入参,就是这个类型的结点数据本身,上面的例子中输出了一些节点属性。对于AtRule和Declaration类型的结点可以接受更细分的类型函数,例如Declaration可以把属性名作为key分别定义遍历函数。

每个类型的结点可以定义两种类型的函数,一种是进入结点时,一种是退出结点时。区别在于是否已经遍历过子结点。上面的介绍的函数都是进入结点时的函数,退出函数名则需要在后面加Exit,例如RootExit, RuleExit。只有拥有子结点的类型有退出函数,Declaration这种没有子结点的结点就只有一个函数。

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Once() {
      console.log("Once");
    },
    OnceExit() {
      console.log("OnceExit");
    },
    Root() {
      console.log("Root");
    },
    RootExit() {
      console.log("RootExit");
    },
    Rule(data) {
      console.log("Rule", data.selector);
    },
    RuleExit(data) {
      console.log("RuleExit", data.selector);
    },
    Declaration(data) {
      console.log("Declaration", data.prop, data.value);
    },
    AtRule: {
      media(data) {
        console.log("AtRule media", data.params);
      },
    },
    AtRuleExit: {
      media(data) {
        console.log("AtRuleExit media", data.params);
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

/* 输出结果
Once
Root
Rule .jzplp
Declaration width 10px
Declaration color var(--abc, red)
RuleExit .jzplp
Rule div .jzplp2
Declaration color blue
RuleExit div .jzplp2
Rule .jz1
Declaration margin 5px
Rule .jz2
Declaration padding 10px 20px
RuleExit .jz2
RuleExit .jz1
AtRule media (max-width: 768px)
Rule #jz3
Declaration border 1px solid red
RuleExit #jz3
AtRuleExit media (max-width: 768px)
RootExit
OnceExit
*/

通过上面的例子可以看到,先触发进入结点的函数,再访问内部结点,然后再触发退出结点的函数。上面例子中还有Once和OnceExit函数,这两个是最早和最后触发的两个函数,入参是root数据。

修改AST结点属性

首先从修改AST结点的属性开始。下面的插件修改了Declaration和AtRule的属性:

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log("Root");
    },
    RootExit() {
      console.log("RootExit");
    },
    Rule(data) {
      console.log("Rule", data.selector);
    },
    RuleExit(data) {
      console.log("RuleExit", data.selector);
    },
    Declaration: {
      color(data) {
        data.value = "yellow";
        console.log("Declaration color");
      },
    },
    AtRule: {
      media(data) {
        data.params = "(max-width: 1000px)";
        console.log("AtRule media");
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

我们简化一下转义的CSS代码,运行PostCSS后查看输出结果,发现生成的代码中确实被改掉了。

/* 源CSS代码 */
div .jzplp2 {
  color: blue !important;
}
.jz2 {
  width: 10px;
}
@media (max-width: 768px) {}

/* 生成CSS代码 */
div .jzplp2 {
  color: yellow !important;
}
.jz2 {
  width: 10px;
}
@media (max-width: 1000px) {}

/* 命令行输出
Root
Rule div .jzplp2
Declaration color
RuleExit div .jzplp2
Rule .jz2
RuleExit .jz2
AtRule media
RootExit
Root
Rule div .jzplp2
Declaration color
RuleExit div .jzplp2
AtRule media
RootExit
*/

/* 插件中删掉修改AtRule的代码 命令行输出
Root
Rule div .jzplp2
Declaration color
RuleExit div .jzplp2
Rule .jz2
RuleExit .jz2
AtRule media
RootExit
Root
Rule div .jzplp2
Declaration color
RuleExit div .jzplp2
RootExit
*/

从命令行中输出中,我们还发现,Root被遍历了两次。第一次遍历了全部属性,第二次仅仅遍历了修改过的属性以及他们的父结点。如果把插件中修改AtRule的代码删除再执行,发现Root还是被遍历了两次。因此父结点的再上级结点也会被遍历,一直到根结点。因此,假设我们一直将属性修改为不同的值,就会触发无限循环遍历。开发插件的时候要注意这件事。

let a = 1;
function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log("Root");
    },
    RootExit() {
      console.log("RootExit");
    },
    Declaration: {
      width(data) {
        data.value = ++a;
        console.log("Declaration width");
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

/* 命令行输出
Root
Declaration width
RootExit
Root
Declaration width
RootExit
Root
...无限循环
*/

增删AST结点

PostCSS提供了每个结点的创建函数,可以创建对应类型的结点。每个结点上还挂载了父节点,子结点,各种位置添加结点的函数,可以让我们方便增删和处理结点。例如下面这个插件例子:

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log("Root");
    },
    RootExit() {
      console.log("RootExit");
    },
    Declaration: {
      width(data, { Declaration }) {
        const decl = new Declaration({ prop: "color", value: "black" });
        data?.parent?.append(decl);
      },
    },
    Rule(data, { Rule, Declaration }) {
      if (data.selector === "div .jzplp2") {
        const decl = new Declaration({ prop: "color", value: "yellow" });
        const rule = new Rule({ selector: ".jz3", nodes: [decl] });
        data.before(rule);
      }
    },
    AtRule: {
      media(data) {
        if (!data.flag) {
          data.flag = 1;
          const cloned = data.clone(data);
          data.push(cloned);
        }
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

注意部分可能造成无限循环的场景需要进行限制。输出结果如下:

/* 源CSS代码 */
div .jzplp2 {
  color: blue !important;
}
.jz2 {
  width: 10px;
}
@media (max-width: 768px) {}


/* 生成CSS代码 */
.jz3 {
  color: yellow;
}
div .jzplp2 {
  color: blue !important;
}
.jz2 {
  width: 10px;
  color: black;
}
@media (max-width: 768px) {
@media (max-width: 768px) {}}

/* 命令行输出
Root
RootExit
Root
RootExit
*/

AST辅助工具

在前面介绍过PostCSS的AST结点类型一共就只有几种,例如规则rule规则结点和decl声明结点。但CSS实际的复杂度要更高,例如结点选择器还可以分为属性选择器,类选择器,标签选择器,还有组合选择器,伪类伪元素选择器等等,有些甚至是函数的形态。CSS声明的值也是多种多样,有字符串,数字,函数,组合值等等。像这类数据PostCSS并没有直接提供解析方式,但有一些开源工具可以帮助解析。下面我们列举几个工具简单介绍:

postcss-selector-parser

postcss-selector-parser是一个解析CSS选择器的工具。虽然名字中带postcss,但实际上不依赖PostCSS运行。postcss-selector-parser可以将选择器字符串解析为AST数据。其中包含一些结点类型,用type字段表示:

  • attribute 属性选择器结点
  • class 类选择器结点
  • combinator 组合选择器结点
  • id id选择器结点
  • pseudo 伪类和伪元素选择器结点
  • tag 标签选择器结点
  • selector 选择器容器结点
  • 等等...

这里我们构造一个复杂一点的选择器字符串,遍历结点,看能解析出哪些类型:

const parser = require('postcss-selector-parser');
const selectorStr = '.jz1, div+jz2:not(#jzplp::first-line)';

const transform = selectors => {
    selectors.walk(data => {
      console.log(data.type.padEnd(12), data.toString().padEnd(35), data?.nodes?.map?.(item => item.toString()));
    });
};
const transformed = parser(transform).processSync(selectorStr);

/*
selector     .jz1                                [ '.jz1' ]
class        .jz1                                undefined
selector      div+jz2:not(#jzplp::first-line)    [ ' div', '+', 'jz2', ':not(#jzplp::first-line)' ]
tag           div                                undefined
combinator   +                                   undefined
tag          jz2                                 undefined
pseudo       :not(#jzplp::first-line)            [ '#jzplp::first-line' ]
selector     #jzplp::first-line                  [ '#jzplp', '::first-line' ]
id           #jzplp                              undefined
pseudo       ::first-line                        []
*/

可以看到,组合选择器里面并不包含其它选择器,而是仅有连接符号本身。这个工具本身没有直接包含将代码转为JSON形式的AST数据的方法。我试了下ast方法,输出的内容也不是JSON数据,因此这里就不列出了。

const parser = require('postcss-selector-parser');
parser(() => {})
  .ast(selectorStr)
  .then((data) => console.log(data));
// 输出非JSON数据

工具内提供了各种创建AST结点的方法,这里我们尝试修改和添加AST结点试试。

const parser = require("postcss-selector-parser");
const selectorStr = ".jz1, div+jz2:not(#jzplp::first-line)";
const transform = (selectors) => {
  selectors.walk((data) => {
    // 修改结点属性
    if (data.type === "tag" && data.value === "jz2") data.value = "h1";
    // 添加结点
    if (data.type === "selector" && data.toString() === '.jz1') {
      const attr = parser.attribute({ attribute: "href" });
      data.nodes.push(attr);
    }
  });
};
const transformed = parser(transform).processSync(selectorStr);
console.log(transformed);

/* 输出结果
.jz1[href], div+h1:not(#jzplp::first-line)
*/

postcss-value-parser

postcss-selector-parser是一个解析CSS值的工具,也可以脱离PostCSS运行。它将CSS声明中的值字符串解析为AST数据。首先我们构造一个复杂的值例子,输出对应的AST数据:

const valueParser = require("postcss-value-parser");
const valueStr = "calc(50vh + 10px) solid var(--jzplp, rgba(255,255,255, 0.5))";
var parsedValue = valueParser(valueStr);
console.log(JSON.stringify(parsedValue));

/* 输出结果
{
  "nodes": [
    {
      "type": "function",
      "sourceIndex": 0,
      "value": "calc",
      "before": "",
      "after": "",
      "sourceEndIndex": 17,
      "nodes": [
        { "type": "word", "sourceIndex": 5, "sourceEndIndex": 9, "value": "50vh" },
        { "type": "space", "sourceIndex": 9, "sourceEndIndex": 10, "value": " " },
        { "type": "word", "sourceIndex": 10, "sourceEndIndex": 11, "value": "+" },
        { "type": "space", "sourceIndex": 11, "sourceEndIndex": 12, "value": " " },
        { "type": "word", "sourceIndex": 12, "sourceEndIndex": 16, "value": "10px" }
      ]
    },
    { "type": "space", "sourceIndex": 17, "sourceEndIndex": 18, "value": " " },
    { "type": "word", "sourceIndex": 18, "sourceEndIndex": 23, "value": "solid" },
    { "type": "space", "sourceIndex": 23, "sourceEndIndex": 24, "value": " " },
    {
      "type": "function",
      "sourceIndex": 24,
      "value": "var",
      "before": "",
      "after": "",
      "sourceEndIndex": 60,
      "nodes": [
        { "type": "word", "sourceIndex": 28, "sourceEndIndex": 35, "value": "--jzplp" },
        { "type": "div", "sourceIndex": 35, "sourceEndIndex": 37, "value": ",", "before": "", "after": " " },
        {
          "type": "function",
          "sourceIndex": 37,
          "value": "rgba",
          "before": "",
          "after": "",
          "sourceEndIndex": 59,
          "nodes": [
            { "type": "word", "sourceIndex": 42, "sourceEndIndex": 45, "value": "255" },
            { "type": "div", "sourceIndex": 45, "sourceEndIndex": 46, "value": ",", "before": "", "after": "" },
            { "type": "word", "sourceIndex": 46, "sourceEndIndex": 49, "value": "255" },
            { "type": "div", "sourceIndex": 49, "sourceEndIndex": 50, "value": ",", "before": "", "after": "" },
            { "type": "word", "sourceIndex": 50, "sourceEndIndex": 53, "value": "255" },
            { "type": "div", "sourceIndex": 53, "sourceEndIndex": 55, "value": ",", "before": "", "after": " " },
            { "type": "word", "sourceIndex": 55, "sourceEndIndex": 58, "value": "0.5" }
          ]
        }
      ]
    }
  ]
}
*/

通过上面的例子,CSS声明值被解析成了JSON形式的AST数据。其中每个结点以type属性来区分类型。我们列举下其中的常用AST结点:

  • word 普通值结点,例如 10px, 255等
  • string 字符串节点,例如 "/jz/123"
  • div 分隔节点,例如 逗号,斜杠等
  • space 空格结点
  • comment 注释结点
  • function 函数结点,里面可以包含其它结点,例如 rgba(), var()等

然后我们再试着遍历AST,以及修改结点内容。这里在前面代码的基础上继续写:

parsedValue.walk(function (node) {
  console.log(node.type.padEnd(10), "[", node.value.padEnd(10), "]");
  if (node.type === "word" && node.value === "10px")  node.value = "10em";
});
console.log(parsedValue.toString());

/* 输出结果
function   [ calc       ]
word       [ 50vh       ]
space      [            ]
word       [ +          ]
space      [            ]
word       [ 10px       ]
space      [            ]
word       [ solid      ]
space      [            ]
function   [ var        ]
word       [ --jzplp    ]
div        [ ,          ]
function   [ rgba       ]
word       [ 255        ]
div        [ ,          ]
word       [ 255        ]
div        [ ,          ]
word       [ 255        ]
div        [ ,          ]
word       [ 0.5        ]
calc(50vh + 10em) solid var(--jzplp, rgba(255,255,255, 0.5))
*/

自定义语法规则

PostCSS不仅可以编译CSS语法,还可以自定义语法规则实现功能扩展。

编写自定义语法规则

PostCSS编写自定义语法的方法,就是实现parser/stringifier/syntax方法,然后在PostCSS参数中传入对应方法即可。

  • parser方法 将字符串转为抽象语法树
  • stringifier方法 将抽象语法树转为字符串
  • syntax 相当于parser + stringifier

编写自定义语法规则是一件很复杂的事情,需要经过词法分析句法分析等步骤,很显然超出了这篇文章的范畴。因此这里我们只给出一个非常简单的demo,示意一下自定义语法的开发接口。首先来看下自定义语法的方式。这里我们设置文件内容的每行为xxx=xxx,尝试用自定义语法解析和生成这样的结构。

/* 需要解析的文件 index.jzcss
jzplp=12345
jz2=98765
*/

const postcss = require("postcss");

function parseLine(strLine) {
  const strList = strLine.split("=");
  const customKey = { type: "customKey", value: strList[0] };
  const customValue = { type: "customValue", value: strList[1] };
  const equal = { type: "equal", value: "=", nodes: [customKey, customValue] };
  return equal;
}

function parse(cssStr) {
  console.log("parser!");
  const strList = cssStr.split("\n");
  const nodes = strList.map(line => parseLine(line));
  const root = postcss.root();
  root.nodes = nodes;
  return root;
}

function recursion(node) {
  if (node.type === "customKey" || node.type === "customValue") return node.value;
  if (node.type === "equal") return recursion(node.nodes[0]) + node.value + recursion(node.nodes[1]);
  return node.nodes.map((item) => recursion(item)).join("\n");
}

function stringify(root, builder) {
  console.log("stringify!");
  builder(recursion(root), root);
}

module.exports = {
  parse,
  stringify,
};

首先是parse方法,外面包裹一个PostCSS的root结点。里面切分出每行,对每行切分出equal等号结点,子结点为customKey和customValue两个。解析成AST数据后返回。stringify方法使用了递归,针对不同的结点类型输出不同的字符串,进行拼合。最后将字符串和root结点传回builder回调。然后我们尝试使用自定义规则来解析文件,输出AST数据:

const fs = require("fs");
const postcss = require("postcss");
const customParser = require("./parser");

const originData = fs.readFileSync("./index.jzcss", "utf-8");
postcss()
  .process(originData, {
    from: "index.jzcss",
    to: "out.css",
    syntax: customParser,
  })
  .then((res) => {
    console.log(res.css);
    console.log(JSON.stringify(res.root.toJSON()));
  });

/* 输出结果
parser!
stringify!
jzplp=12345
jz2=98765
{
  "raws": {},
  "type": "root",
  "nodes": [
    {
      "type": "equal",
      "value": "=",
      "nodes": [
        { "type": "customKey", "value": "jzplp" },
        { "type": "customValue", "value": "12345\r" }
      ]
    },
    {
      "type": "equal",
      "value": "=",
      "nodes": [
        { "type": "customKey", "value": "jz2" },
        { "type": "customValue", "value": "98765" }
      ]
    }
  ],
  "lastEach": 1,
  "indexes": {},
  "inputs": []
}
*/

为自定义规则编写插件

创建了自定义规则结点之后,我们再为这种规则结点编写一个插件,尝试修改AST结点再生成代码。

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Once(root) {
      root.walk((node) => {
        if (node.type === "equal") {
          node.value = "==";
          if (node.nodes[0].value === "jz2")
            node.nodes[0].value = "jzplp2";
        }
      });
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

我们自己创建的结点类型太简陋了,不能被PostCSS识别为结点,不能使用结点类型名称开头的函数;walk函数也无法遍历更深层的结点。因此我们手动修改子结点的属性。最后引入插件并执行,从输出结果看改动成功了。

const fs = require("fs");
const postcss = require("postcss");
const customParser = require("./parser");
const pluginJzplp = require("./pluginJzplp");
const originData = fs.readFileSync("./index.jzcss", "utf-8");

postcss([pluginJzplp])
  .process(originData, {
    from: "index.jzcss",
    to: "out.css",
    syntax: customParser,
  })
  .then((res) => {
    console.log(res.css);
  });

/* 输出结果
jzplp==12345
jzplp2==98765
*/

总结

这篇文章介绍了PostCSS的作用:转换CSS的代码成AST,经过插件处理再生成新CSS代码,经常被用作后处理和添加兼容性;还介绍了各种使用方法:命令行方式,API方式,Webapck);然后介绍了几个插件的作用,以及与SCSS和Less组合使用。后面又介绍了PostCSS的SourceMap与AST结构,如何开发插件与自定义语法。

从作用上来,虽然CSS本身不是完整的编程语言,但PostCSS对CSS却像编程语言一样处理。只不过AST结点类型只有几种,看起来有点简陋,还需要额外的辅助工具,比如解析选择器和声明值。这些辅助工具也是用AST来实现的。

除了PostCSS之外,还有CSSTree,CSSOM等都可以将CSS转换为AST语法树,但都没有PostCSS知名度高,有的甚至已经不维护了。

参考

【图解】疯狂撕数字,一图秒懂(Python/Java/C++/C/Go/JS/Rust)

lc1351-2c.png{:width=600px}

注:也可以从左下角开始,方法类似。

总结:利用 $\textit{grid}$ 行列有序的性质,我们可以用 $\mathcal{O}(1)$ 的时间获取 $\mathcal{O}(m)$ 或 $\mathcal{O}(n)$ 的信息。相比之下,$\mathcal{O}(mn)$ 的暴力查找(一个一个地找),每次花费 $\mathcal{O}(1)$ 的时间,只获取了 $\mathcal{O}(1)$ 的信息。

这就是为什么我们可以把 $\mathcal{O}(mn)$ 的暴力查找优化成 $\mathcal{O}(m+n)$。

###py

class Solution:
    def countNegatives(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        ans = 0
        i, j = 0, n - 1  # 从右上角开始
        while i < m and j >= 0:  # 还有剩余元素
            if grid[i][j] < 0:
                ans += m - i  # 这一列剩余元素都是负数
                j -= 1
            else:
                i += 1  # 这一行剩余元素全都非负,排除
        return ans

###java

class Solution {
    public int countNegatives(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        int ans = 0;
        int i = 0;
        int j = n - 1; // 从右上角开始
        while (i < m && j >= 0) { // 还有剩余元素
            if (grid[i][j] < 0) {
                ans += m - i; // 这一列剩余元素都是负数
                j--;
            } else {
                i++; // 这一行剩余元素全都非负,排除
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int countNegatives(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        int ans = 0;
        int i = 0, j = n - 1; // 从右上角开始
        while (i < m && j >= 0) { // 还有剩余元素
            if (grid[i][j] < 0) {
                ans += m - i; // 这一列剩余元素都是负数
                j--;
            } else {
                i++; // 这一行剩余元素全都非负,排除
            }
        }
        return ans;
    }
};

###c

int countNegatives(int** grid, int gridSize, int* gridColSize) {
    int m = gridSize, n = gridColSize[0];
    int ans = 0;
    int i = 0, j = n - 1; // 从右上角开始
    while (i < m && j >= 0) { // 还有剩余元素
        if (grid[i][j] < 0) {
            ans += m - i; // 这一列剩余元素都是负数
            j--;
        } else {
            i++; // 这一行剩余元素全都非负,排除
        }
    }
    return ans;
}

###go

func countNegatives(grid [][]int) (ans int) {
m, n := len(grid), len(grid[0])
i, j := 0, n-1 // 从右上角开始
for i < m && j >= 0 { // 还有剩余元素
if grid[i][j] < 0 {
ans += m - i // 这一列剩余元素都是负数
j--
} else {
i++ // 这一行剩余元素全都非负,排除
}
}
return
}

###js

var countNegatives = function(grid) {
    const m = grid.length, n = grid[0].length;
    let ans = 0;
    let i = 0, j = n - 1; // 从右上角开始
    while (i < m && j >= 0) { // 还有剩余元素
        if (grid[i][j] < 0) {
            ans += m - i; // 这一列剩余元素都是负数
            j--;
        } else {
            i++; // 这一行剩余元素全都非负,排除
        }
    }
    return ans;
};

###rust

impl Solution {
    pub fn count_negatives(grid: Vec<Vec<i32>>) -> i32 {
        let m = grid.len();
        let n = grid[0].len();
        let mut ans = 0;
        let mut i = 0;
        let mut j = n - 1; // 从右上角开始
        while i < m && j < n { // 还有剩余元素
            if grid[i][j] < 0 {
                ans += m - i; // 这一列剩余元素都是负数
                j -= 1;
            } else {
                i += 1; // 这一行剩余元素全都非负,排除
            }
        }
        ans as _
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(m + n)$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(1)$。

:本题不存在时间复杂度低于 $\mathcal{O}(m + n)$ 的算法,见 240 题我的题解 文末的注。

相似题目

240. 搜索二维矩阵 II

分类题单

如何科学刷题?

  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站@灵茶山艾府

两种解法,带图解

解题思路

第一种:全部遍历,没什么技巧。
第二种:从右上到左下下梯子。
WeChat Image_20200308103704.jpg

代码

第一种:

class Solution:
    def countNegatives(self, grid: List[List[int]]) -> int:
        total = 0
        for item in grid:
            for num in item:
                if num < 0 :
                    total += 1
        
        return total
            

第二种:

class Solution:
    def countNegatives(self, grid: List[List[int]]) -> int:
        total = 0
        i, j = 0, len(grid[0])-1

        while i < len(grid) and j>=0:
            if grid[i][j] >= 0:
                i += 1
            else:
                total += len(grid) - i
                j -= 1
        return total

统计有序矩阵中的负数

方法一:暴力

观察数据范围注意到矩阵大小不会超过 $100*100=10^4$,所以我们可以遍历矩阵所有数,统计负数的个数。

###C++

class Solution {
public:
    int countNegatives(vector<vector<int>>& grid) {
        int num = 0;
        for (int x : grid) {
            for (int y : x) {
                if (y < 0) {
                    num++;
                }
            }
        }
        return num;
    }
};

###Java

class Solution {
    public int countNegatives(int[][] grid) {
        int num = 0;
        for (int[] row : grid) {
            for (int value : row) {
                if (value < 0) {
                    num++;
                }
            }
        }
        return num;
    }
}

###C#

public class Solution {
    public int CountNegatives(int[][] grid) {
        int num = 0;
        foreach (int[] row in grid) {
            foreach (int value in row) {
                if (value < 0) {
                    num++;
                }
            }
        }
        return num;
    }
}

###Go

func countNegatives(grid [][]int) int {
    num := 0
    for _, row := range grid {
        for _, value := range row {
            if value < 0 {
                num++
            }
        }
    }
    return num
}

###Python

class Solution:
    def countNegatives(self, grid: List[List[int]]) -> int:
        num = 0
        for row in grid:
            for value in row:
                if value < 0:
                    num += 1
        return num

###C

int countNegatives(int** grid, int gridSize, int* gridColSize) {
    int num = 0;
    for (int i = 0; i < gridSize; i++) {
        for (int j = 0; j < gridColSize[i]; j++) {
            if (grid[i][j] < 0) {
                num++;
            }
        }
    }
    return num;
}

###JavaScript

var countNegatives = function(grid) {
    let num = 0;
    for (const row of grid) {
        for (const value of row) {
            if (value < 0) {
                num++;
            }
        }
    }
    return num;
};

###TypeScript

function countNegatives(grid: number[][]): number {
    let num = 0;
    for (const row of grid) {
        for (const value of row) {
            if (value < 0) {
                num++;
            }
        }
    }
    return num;
};

###Rust

impl Solution {
    pub fn count_negatives(grid: Vec<Vec<i32>>) -> i32 {
        let mut num = 0;
        for row in grid {
            for value in row {
                if value < 0 {
                    num += 1;
                }
            }
        }
        num
    }
}

复杂度分析

  • 时间复杂度:$O(nm)$,即矩阵元素的总个数。
  • 空间复杂度:$O(1)$。

方法二:二分查找

注意到题目中给了一个性质,即矩阵中的元素无论是按行还是按列,都以非递增顺序排列,可以考虑把这个性质利用起来优化暴力。已知这个性质告诉了我们每一行的数都是有序的,所以我们通过二分查找可以找到每一行中从前往后的第一个负数,那么这个位置之后到这一行的末尾里所有的数必然是负数了,可以直接统计。

  1. 遍历矩阵的每一行。

  2. 二分查找到该行从前往后的第一个负数,考虑第 $i$ 行,我们记这个位置为 $pos_i$,那么第 $i$ 行 $[pos_i,m-1]$ 中的所有数都是负数,所以这一行对答案的贡献就是 $m-1-pos_i+1=m-pos_i$。

  3. 最后的答案就是 $\sum_{i=0}^{n-1}(m-pos_i)$。

###C++

class Solution {
public:
    int countNegatives(vector<vector<int>>& grid) {
        int num = 0;
        for (auto x : grid) {
            int l = 0, r = (int)x.size() - 1, pos = -1;
            while (l <= r) {
                int mid = l + ((r - l) >> 1);
                if (x[mid] < 0) {
                    pos = mid;
                    r = mid - 1;
                } else {
                    l = mid + 1;
                }
            }
            
            if (~pos) {  // pos != -1 表示这一行存在负数
                num += (int)x.size() - pos;
            }
        }
        return num;
    }
};

###Java

class Solution {
    public int countNegatives(int[][] grid) {
        int num = 0;
        for (int[] row : grid) {
            int l = 0, r = row.length - 1, pos = -1;
            while (l <= r) {
                int mid = l + (r - l) / 2;
                if (row[mid] < 0) {
                    pos = mid;
                    r = mid - 1;
                } else {
                    l = mid + 1;
                }
            }
            if (pos != -1) {
                num += row.length - pos;
            }
        }
        return num;
    }
}

###C#

public class Solution {
    public int CountNegatives(int[][] grid) {
        int num = 0;
        foreach (int[] row in grid) {
            int l = 0, r = row.Length - 1, pos = -1;
            while (l <= r) {
                int mid = l + (r - l) / 2;
                if (row[mid] < 0) {
                    pos = mid;
                    r = mid - 1;
                } else {
                    l = mid + 1;
                }
            }
            if (pos != -1) {
                num += row.Length - pos;
            }
        }
        return num;
    }
}

###Go

func countNegatives(grid [][]int) int {
    num := 0
    for _, row := range grid {
        l, r, pos := 0, len(row) - 1, -1
        for l <= r {
            mid := l + (r - l) / 2
            if row[mid] < 0 {
                pos = mid
                r = mid - 1
            } else {
                l = mid + 1
            }
        }
        if pos != -1 {
            num += len(row) - pos
        }
    }
    return num
}

###Python

class Solution:
    def countNegatives(self, grid: List[List[int]]) -> int:
        num = 0
        for row in grid:
            l, r, pos = 0, len(row) - 1, -1
            while l <= r:
                mid = l + (r - l) // 2
                if row[mid] < 0:
                    pos = mid
                    r = mid - 1
                else:
                    l = mid + 1
            if pos != -1:
                num += len(row) - pos
        return num

###C

int countNegatives(int** grid, int gridSize, int* gridColSize) {
    int num = 0;
    for (int i = 0; i < gridSize; i++) {
        int l = 0, r = gridColSize[i] - 1, pos = -1;
        while (l <= r) {
            int mid = l + (r - l) / 2;
            if (grid[i][mid] < 0) {
                pos = mid;
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        if (pos != -1) {
            num += gridColSize[i] - pos;
        }
    }
    return num;
}

###JavaScript

var countNegatives = function(grid) {
    let num = 0;
    for (const row of grid) {
        let l = 0, r = row.length - 1, pos = -1;
        while (l <= r) {
            const mid = l + Math.floor((r - l) / 2);
            if (row[mid] < 0) {
                pos = mid;
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        if (pos !== -1) {
            num += row.length - pos;
        }
    }
    return num;
};

###TypeScript

function countNegatives(grid: number[][]): number {
    let num = 0;
    for (const row of grid) {
        let l = 0, r = row.length - 1, pos = -1;
        while (l <= r) {
            const mid = l + Math.floor((r - l) / 2);
            if (row[mid] < 0) {
                pos = mid;
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        if (pos !== -1) {
            num += row.length - pos;
        }
    }
    return num;
}

###Rust

impl Solution {
    pub fn count_negatives(grid: Vec<Vec<i32>>) -> i32 {
        let mut num = 0;
        for row in grid {
            let (mut l, mut r, mut pos) = (0, row.len() as i32 - 1, -1);
            while l <= r {
                let mid = l + (r - l) / 2;
                if row[mid as usize] < 0 {
                    pos = mid;
                    r = mid - 1;
                } else {
                    l = mid + 1;
                }
            }
            if pos != -1 {
                num += row.len() as i32 - pos;
            }
        }
        num
    }
}

复杂度分析

  • 时间复杂度:二分查找一行的时间复杂度为$logm$,需要遍历$n$行,所以总时间复杂度是$O(nlogm)$。
  • 空间复杂度:$O(1)$。

方法三:分治

方法二其实只利用了一部分的性质,即每一行是非递增的,但其实整个矩阵是每行每列均非递增,这说明了一个更重要的性质:每一行从前往后第一个负数的位置是不断递减的,即我们设第 $i$ 行的第一个负数的位置为 $pos_i$,不失一般性,我们把一行全是正数的 $pos$ 设为 $m$,则
$$
pos_0>=pos_1>=pos_2>=...>=pos_{n-1}
$$
所以我们可以依此设计一个分治算法。

我们设计一个函数 $solve(l,r,L,R)$ 表示我们在统计 $[l,r]$ 行的答案,第 $[l,r]$ 行 $pos$ 的位置在 $[L,R]$ 列中,计算 $[l,r]$ 的中间行第 $mid$ 行的的 $pos_{mid}$,算完以后根据之前的方法计算这一行对答案的贡献。然后根据我们之前发现的性质,可以知道 $[l,mid-1]$ 中所有行的 $pos$ 是大于等于 $pos_{mid}$,$[mid+1,r]$ 中所有行的 $pos$ 值是小于等于 $pos_{mid}$ 的,所以可以分成两部分递归下去,即:
$$
solve(l,mid-1,pos_{mid},R)
$$

$$
solve(mid+1,r,L,pos_{mid})
$$
所以答案就是 $m-pos_{mid}+solve(l,mid-1,pos_{mid},R)+solve(mid+1,r,L,pos_{mid})$。

递归函数入口为 $solve(0,n-1,0,m-1)$。

###C++

class Solution {
public:
    int solve(int l, int r, int L, int R, vector<vector<int>>& grid) {
        if (l > r) {
            return 0;
        }
        
        int mid = l + ((r - l) >> 1);
        int pos = -1;
        // 在当前行中查找第一个负数
        for (int i = L; i <= R; ++i) {
            if (grid[mid][i] < 0) {
                pos = i;
                break;
            }
        }
        int ans = 0;
        if (pos != -1) {
            // 当前行找到负数,计算当前行的负数个数
            ans += (int)grid[0].size() - pos;
            // 递归处理上半部分(使用更小的列范围)
            ans += solve(l, mid - 1, pos, R, grid);
            // 递归处理下半部分(使用相同的列起始范围)
            ans += solve(mid + 1, r, L, pos, grid);
        } else {
            // 当前行没有负数,只需要递归处理下半部分
            ans += solve(mid + 1, r, L, R, grid);
        }
        
        return ans;
    }
    
    int countNegatives(vector<vector<int>>& grid) {
        return solve(0, (int)grid.size() - 1, 0, (int)grid[0].size() - 1, grid);
    }
};  

###Java

class Solution {
    private int solve(int l, int r, int L, int R, int[][] grid) {
        if (l > r) {
            return 0;
        }
        
        int mid = l + (r - l) / 2;
        int pos = -1;
        // 在当前行中查找第一个负数
        for (int i = L; i <= R; i++) {
            if (grid[mid][i] < 0) {
                pos = i;
                break;
            }
        }
        
        int ans = 0;
        if (pos != -1) {
            // 当前行找到负数,计算当前行的负数个数
            ans += grid[0].length - pos;
            // 递归处理上半部分(使用更小的列范围)
            ans += solve(l, mid - 1, pos, R, grid);
            // 递归处理下半部分(使用相同的列起始范围)
            ans += solve(mid + 1, r, L, pos, grid);
        } else {
            // 当前行没有负数,只需要递归处理下半部分
            ans += solve(mid + 1, r, L, R, grid);
        }
        return ans;
    }
    
    public int countNegatives(int[][] grid) {
        return solve(0, grid.length - 1, 0, grid[0].length - 1, grid);
    }
}

###C#

public class Solution {
    private int Solve(int l, int r, int L, int R, int[][] grid) {
        if (l > r) {
            return 0;
        }
        
        int mid = l + (r - l) / 2;
        int pos = -1;
        // 在当前行中查找第一个负数
        for (int i = L; i <= R; i++) {
            if (grid[mid][i] < 0) {
                pos = i;
                break;
            }
        }
        int ans = 0;
        if (pos != -1) {
            // 当前行找到负数,计算当前行的负数个数
            ans += grid[0].Length - pos;
            // 递归处理上半部分(使用更小的列范围)
            ans += Solve(l, mid - 1, pos, R, grid);
            // 递归处理下半部分(使用相同的列起始范围)
            ans += Solve(mid + 1, r, L, pos, grid);
        } else {
            // 当前行没有负数,只需要递归处理下半部分
            ans += Solve(mid + 1, r, L, R, grid);
        }
        
        return ans;
    }
    
    public int CountNegatives(int[][] grid) {
        return Solve(0, grid.Length - 1, 0, grid[0].Length - 1, grid);
    }
}

###Go

func countNegatives(grid [][]int) int {
    var solve func(l, r, L, R int) int
    solve = func(l, r, L, R int) int {
        if l > r {
            return 0
        }
        
        mid := l + (r - l) / 2
        pos := -1
        // 在当前行中查找第一个负数
        for i := L; i <= R; i++ {
            if grid[mid][i] < 0 {
                pos = i
                break
            }
        }
        
        ans := 0
        if pos != -1 {
            // 当前行找到负数,计算当前行的负数个数
            ans += len(grid[0]) - pos
            // 递归处理上半部分(使用更小的列范围)
            ans += solve(l, mid-1, pos, R)
            // 递归处理下半部分(使用相同的列起始范围)
            ans += solve(mid+1, r, L, pos)
        } else {
            // 当前行没有负数,只需要递归处理下半部分
            ans += solve(mid+1, r, L, R)
        }
        
        return ans
    }
    
    return solve(0, len(grid)-1, 0, len(grid[0])-1)
}

###Python

class Solution:
    def countNegatives(self, grid: List[List[int]]) -> int:
        def solve(l: int, r: int, L: int, R: int) -> int:
            if l > r:
                return 0
            mid = l + (r - l) // 2
            pos = -1
            # 在当前行中查找第一个负数
            for i in range(L, R + 1):
                if grid[mid][i] < 0:
                    pos = i
                    break
            ans = 0
            if pos != -1:
                # 当前行找到负数,计算当前行的负数个数
                ans += len(grid[0]) - pos
                # 递归处理上半部分(使用更小的列范围)
                ans += solve(l, mid - 1, pos, R)
                # 递归处理下半部分(使用相同的列起始范围)
                ans += solve(mid + 1, r, L, pos)
            else:
                # 当前行没有负数,只需要递归处理下半部分
                ans += solve(mid + 1, r, L, R)

            return ans
            
        return solve(0, len(grid) - 1, 0, len(grid[0]) - 1)

###C

int solve(int l, int r, int L, int R, int** grid, int gridSize, int gridColSize) {
    if (l > r) {
        return 0;
    }
    
    int mid = l + (r - l) / 2;
    int pos = -1;
    // 在当前行中查找第一个负数
    for (int i = L; i <= R; i++) {
        if (grid[mid][i] < 0) {
            pos = i;
            break;
        }
    }
    
    int ans = 0;
    if (pos != -1) {
        // 当前行找到负数,计算当前行的负数个数
        ans += gridColSize - pos;
        // 递归处理上半部分(使用更小的列范围)
        ans += solve(l, mid - 1, pos, R, grid, gridSize, gridColSize);
        // 递归处理下半部分(使用相同的列起始范围)
        ans += solve(mid + 1, r, L, pos, grid, gridSize, gridColSize);
    } else {
        // 当前行没有负数,只需要递归处理下半部分
        ans += solve(mid + 1, r, L, R, grid, gridSize, gridColSize);
    }
    
    return ans;
}

int countNegatives(int** grid, int gridSize, int* gridColSize) {
    return solve(0, gridSize - 1, 0, gridColSize[0] - 1, grid, gridSize, gridColSize[0]);
}

###JavaScript

var countNegatives = function(grid) {
    const solve = (l, r, L, R) => {
        if (l > r) {
            return 0;
        }
        
        const mid = l + Math.floor((r - l) / 2);
        let pos = -1;
        // 在当前行中查找第一个负数
        for (let i = L; i <= R; i++) {
            if (grid[mid][i] < 0) {
                pos = i;
                break;
            }
        }
        
        let ans = 0;
        if (pos !== -1) {
            // 当前行找到负数,计算当前行的负数个数
            ans += grid[0].length - pos;
            // 递归处理上半部分(使用更小的列范围)
            ans += solve(l, mid - 1, pos, R);
            // 递归处理下半部分(使用相同的列起始范围)
            ans += solve(mid + 1, r, L, pos);
        } else {
            // 当前行没有负数,只需要递归处理下半部分
            ans += solve(mid + 1, r, L, R);
        }
        
        return ans;
    };
    
    return solve(0, grid.length - 1, 0, grid[0].length - 1);
};

###TypeScript

function countNegatives(grid: number[][]): number {
    const solve = (l: number, r: number, L: number, R: number): number => {
        if (l > r) {
            return 0;
        }
        
        const mid = l + Math.floor((r - l) / 2);
        let pos = -1;
        // 在当前行中查找第一个负数
        for (let i = L; i <= R; i++) {
            if (grid[mid][i] < 0) {
                pos = i;
                break;
            }
        }
        
        let ans = 0;
        if (pos !== -1) {
            // 当前行找到负数,计算当前行的负数个数
            ans += grid[0].length - pos;
            // 递归处理上半部分(使用更小的列范围)
            ans += solve(l, mid - 1, pos, R);
            // 递归处理下半部分(使用相同的列起始范围)
            ans += solve(mid + 1, r, L, pos);
        } else {
            // 当前行没有负数,只需要递归处理下半部分
            ans += solve(mid + 1, r, L, R);
        }
        
        return ans;
    };
    
    return solve(0, grid.length - 1, 0, grid[0].length - 1);
}

###Rust

impl Solution {
    pub fn count_negatives(grid: Vec<Vec<i32>>) -> i32 {
        fn solve(l: i32, r: i32, L: i32, R: i32, grid: &Vec<Vec<i32>>) -> i32 {
            if l > r {
                return 0;
            }
            
            let mid = l + (r - l) / 2;
            let mut pos = -1;
            // 在当前行中查找第一个负数
            for i in L..=R {
                if grid[mid as usize][i as usize] < 0 {
                    pos = i;
                    break;
                }
            }
            
            let mut ans = 0;
            if pos != -1 {
                // 当前行找到负数,计算当前行的负数个数
                ans += grid[0].len() as i32 - pos;
                // 递归处理上半部分(使用更小的列范围)
                ans += solve(l, mid - 1, pos, R, grid);
                // 递归处理下半部分(使用相同的列起始范围)
                ans += solve(mid + 1, r, L, pos, grid);
            } else {
                // 当前行没有负数,只需要递归处理下半部分
                ans += solve(mid + 1, r, L, R, grid);
            }
            
            ans
        }
        
        solve(0, grid.len() as i32 - 1, 0, grid[0].len() as i32 - 1, &grid)
    }
}

复杂度分析

  • 时间复杂度:代码中找第一个负数的位置是直接遍历 $[L,R]$ 找的,再考虑到 $n$ 和 $m$ 同阶,所以每个 $solve$ 函数里需要消耗 $O(n)$ 的时间,由主定理可得时间复杂度为:
    $$
    T(n)=2T(n/2)+O(n)=O(nlogn)
    $$

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

方法四:倒序遍历

考虑方法三发现的性质,我们可以设计一个更简单的方法。考虑我们已经算出第 $i$ 行的从前往后第一个负数的位置 $pos_i$,那么第 $i+1$ 行的时候,$pos_{i+1}$ 的位置肯定是位于 $[0,pos_i]$ 中,所以对于第 $i+1$ 行我们倒着从 $pos_i$ 循环找 $pos_{i+1}$ 即可,这个循环起始变量是一直在递减的。

###C++

class Solution {
public:
    int countNegatives(vector<vector<int>>& grid) {
        int num = 0;
        int m = (int)grid[0].size();
        int pos = (int)grid[0].size() - 1;
        
        for (auto& row : grid) {
            int i;
            for (i = pos; i >= 0; --i) {
                if (row[i] >= 0) {
                    if (i + 1 < m) {
                        pos = i + 1;
                        num += m - pos;
                    }
                    break;
                }
            }
            if (i == -1) {
                num += m;
                pos = -1;
            }
        }
        
        return num;
    }
};

###Java

class Solution {
    public int countNegatives(int[][] grid) {
        int num = 0;
        int m = grid[0].length;
        int pos = grid[0].length - 1;
        
        for (int[] row : grid) {
            int i;
            for (i = pos; i >= 0; i--) {
                if (row[i] >= 0) {
                    if (i + 1 < m) {
                        pos = i + 1;
                        num += m - pos;
                    }
                    break;
                }
            }
            if (i == -1) {
                num += m;
                pos = -1;
            }
        }
        
        return num;
    }
}

###C#

public class Solution {
    public int CountNegatives(int[][] grid) {
        int num = 0;
        int m = grid[0].Length;
        int pos = grid[0].Length - 1;
        
        foreach (int[] row in grid) {
            int i;
            for (i = pos; i >= 0; i--) {
                if (row[i] >= 0) {
                    if (i + 1 < m) {
                        pos = i + 1;
                        num += m - pos;
                    }
                    break;
                }
            }
            if (i == -1) {
                num += m;
                pos = -1;
            }
        }
        
        return num;
    }
}

###Go

func countNegatives(grid [][]int) int {
    num := 0
    m := len(grid[0])
    pos := len(grid[0]) - 1
    
    for _, row := range grid {
        i := pos
        for ; i >= 0; i-- {
            if row[i] >= 0 {
                if i + 1 < m {
                    pos = i + 1
                    num += m - pos
                }
                break
            }
        }
        if i == -1 {
            num += m
            pos = -1
        }
    }
    
    return num
}

###Python

class Solution:
    def countNegatives(self, grid: List[List[int]]) -> int:
        num = 0
        m = len(grid[0])
        pos = len(grid[0]) - 1
        
        for row in grid:
            i = pos
            while i >= 0:
                if row[i] >= 0:
                    if i + 1 < m:
                        pos = i + 1
                        num += m - pos
                    break
                i -= 1
            if i == -1:
                num += m
                pos = -1
        
        return num

###C

int countNegatives(int** grid, int gridSize, int* gridColSize) {
    int num = 0;
    int m = gridColSize[0];
    int pos = gridColSize[0] - 1;
    
    for (int i = 0; i < gridSize; i++) {
        int j;
        for (j = pos; j >= 0; j--) {
            if (grid[i][j] >= 0) {
                if (j + 1 < m) {
                    pos = j + 1;
                    num += m - pos;
                }
                break;
            }
        }
        if (j == -1) {
            num += m;
            pos = -1;
        }
    }
    
    return num;
}

###JavaScript

var countNegatives = function(grid) {
    let num = 0;
    const m = grid[0].length;
    let pos = grid[0].length - 1;
    
    for (const row of grid) {
        let i;
        for (i = pos; i >= 0; i--) {
            if (row[i] >= 0) {
                if (i + 1 < m) {
                    pos = i + 1;
                    num += m - pos;
                }
                break;
            }
        }
        if (i === -1) {
            num += m;
            pos = -1;
        }
    }
    
    return num;
};

###TypeScript

function countNegatives(grid: number[][]): number {
    let num = 0;
    const m = grid[0].length;
    let pos = grid[0].length - 1;
    
    for (const row of grid) {
        let i: number;
        for (i = pos; i >= 0; i--) {
            if (row[i] >= 0) {
                if (i + 1 < m) {
                    pos = i + 1;
                    num += m - pos;
                }
                break;
            }
        }
        if (i === -1) {
            num += m;
            pos = -1;
        }
    }
    
    return num;
}

###Rust

impl Solution {
    pub fn count_negatives(grid: Vec<Vec<i32>>) -> i32 {
        let mut num = 0;
        let m = grid[0].len();
        let mut pos = (grid[0].len() - 1) as i32;
        
        for row in grid {
            let mut i = pos;
            while i >= 0 {
                if row[i as usize] >= 0 {
                    if i + 1 < m as i32 {
                        pos = i + 1;
                        num += (m as i32) - pos;
                    }
                    break;
                }
                i -= 1;
            }
            if i == -1 {
                num += m as i32;
                pos = -1;
            }
        }
        
        num
    }
}

复杂度分析

  • 时间复杂度:考虑每次循环变量的起始位置是单调不降的,所以起始位置最多移动 $m$ 次,时间复杂度 $O(n+m)$。
  • 空间复杂度:$O(1)$。

从0到1实现通用微任务调度器:理解前端异步调度核心

从0到1实现通用微任务调度器:理解前端异步调度核心

微任务(Microtask)是前端异步编程的核心概念,广泛应用于Vue/react的更新、Promise回调、事件循环优化等场景。手动实现一个通用的微任务调度器,不仅能加深对事件循环的理解,还能解决「批量执行异步任务、统一控制执行时机」的实际开发需求。

本文将从「最小可用版」到「生产级优化版」,一步步拆解微任务调度器的实现思路,最终给出可直接复用的完整代码。

一、核心需求与设计思路

1. 要解决的问题

在前端开发中,频繁触发的异步任务(如多次更新DOM、批量数据处理)如果直接执行,会导致多次微任务创建,增加性能开销。我们需要:

  • 批量管理任务,去重且统一在微任务队列执行;

  • 支持任务执行前/后的回调(如初始化/清理逻辑);

  • 外部可通过await等待所有任务执行完成;

  • 兼容浏览器/Node.js环境(处理queueMicrotask API兼容性)。

2. 核心设计原则

  • 去重执行:用Set存储任务,避免重复添加;

  • 防重复调度:标记是否已将执行逻辑放入微任务队列,避免多次触发;

  • 状态隔离:用私有属性封装内部状态(如isFlushing),对外暴露可控的API;

  • 兼容降级:无queueMicrotask时,用Promise.resolve().then()兜底。

二、Step 1:实现最小可用版调度器

先搭建核心骨架,完成「添加任务、批量执行」的基础能力,暂不处理回调、异常、兼容等细节。

1. 定义基础类型

先明确核心类型,让代码语义更清晰:

// 待执行的任务类型(无参无返回值函数)
export type TaskToQueue = () => void;

// 取消回调的函数类型
export type Unsubscribe = () => void;

2. 核心类实现(最小版)

/**
 * 最小可用版微任务调度器
 * 核心能力:添加任务、批量在微任务执行、防重复调度
 */
export class MicroScheduler {
  // 任务队列:Set自动去重
  private readonly taskQueue = new Set<TaskToQueue>();
  // 标记是否已调度微任务执行(防重复)
  private isFlushScheduled = false;
  // 标记是否正在执行任务(避免嵌套执行)
  private isFlushing = false;

  /**
   * 添加任务到队列,并触发调度
   */
  enqueue(task: TaskToQueue): void {
    this.taskQueue.add(task);
    this.scheduleFlush();
  }

  /**
   * 调度任务执行到微任务队列(核心:防重复调度)
   */
  private scheduleFlush(): void {
    // 已调度/正在执行,直接返回
    if (this.isFlushScheduled || this.isFlushing) return;

    this.isFlushScheduled = true;
    // 核心:将执行逻辑放入微任务队列
    queueMicrotask(() => {
      this.isFlushScheduled = false;
      this.executeTasks();
    });
  }

  /**
   * 批量执行所有任务
   */
  private executeTasks(): void {
    if (this.isFlushing) return;
    this.isFlushing = true;

    // 遍历执行所有任务,执行后从队列移除
    this.taskQueue.forEach((task) => {
      task();
      this.taskQueue.delete(task);
    });

    this.isFlushing = false;
  }
}

3. 测试最小版功能

// 实例化调度器
const scheduler = new MicroScheduler();

// 添加2个任务(故意重复添加,测试去重)
const task1 = () => console.log('执行任务1');
scheduler.enqueue(task1);
scheduler.enqueue(task1); // 重复添加,Set会自动去重
scheduler.enqueue(() => console.log('执行任务2'));

// 输出结果(微任务阶段执行):
// 执行任务1
// 执行任务2

关键逻辑说明

  • scheduleFlush:核心防重复逻辑,确保多次调用enqueue仅触发一次微任务;

  • isFlushing:避免执行任务时嵌套调用executeTasks(如任务中再次enqueue);

  • Set存储任务:天然解决重复添加问题,比数组更高效。

三、Step 2:扩展核心能力

在最小版基础上,添加「同步执行、前后回调、外部等待」能力,逐步完善功能。

1. 添加同步执行方法flushSync

支持手动同步执行任务(不等待微任务):

/**
 * 同步触发任务执行(立即执行,不等待微任务)
 */
flushSync(): void {
  this.executeTasks();
}

2. 添加执行前/后回调

支持注册/取消「执行前/后」的回调,满足初始化、清理等场景:

// 新增:执行前/后回调队列
private readonly beforeFlushCallbacks = new Set<() => void>();
private readonly afterFlushCallbacks = new Set<() => void>();

/**
 * 注册「任务执行前」的回调
 * @returns 取消回调的函数
 */
onBeforeFlush(callback: () => void): Unsubscribe {
  this.beforeFlushCallbacks.add(callback);
  return () => this.beforeFlushCallbacks.delete(callback);
}

/**
 * 注册「任务执行后」的回调
 */
onAfterFlush(callback: () => void): Unsubscribe {
  this.afterFlushCallbacks.add(callback);
  return () => this.afterFlushCallbacks.delete(callback);
}

// 改造executeTasks:执行前后回调
private executeTasks(): void {
  if (this.isFlushing) return;
  this.isFlushing = true;

  // 1. 执行前置回调
  this.beforeFlushCallbacks.forEach((cb) => cb());

  // 2. 执行任务
  this.taskQueue.forEach((task) => {
    task();
    this.taskQueue.delete(task);
  });

  // 3. 执行后置回调
  this.afterFlushCallbacks.forEach((cb) => cb());

  this.isFlushing = false;
}

3. 支持外部await等待执行完成

添加tick属性,基于固定的Promise实现外部等待(核心:用初始化的Promise做「时间锚点」,避免每次创建新Promise导致等待失效):

// 新增:固定的微任务Promise(仅初始化一次)
private readonly microtaskPromise = Promise.resolve<void>(undefined);

// 公开只读属性:外部可await
readonly tick = this.microtaskPromise;

测试扩展能力

const scheduler = new MicroScheduler();

// 注册前后回调
const unsubBefore = scheduler.onBeforeFlush(() => console.log('任务执行前'));
scheduler.onAfterFlush(() => console.log('任务执行后'));

// 添加任务
scheduler.enqueue(() => console.log('执行任务'));

// 外部等待任务执行完成
async function test() {
  await scheduler.tick;
  console.log('所有任务执行完毕');
  unsubBefore(); // 取消前置回调
}

test();

// 输出顺序:
// 任务执行前
// 执行任务
// 任务执行后
// 所有任务执行完毕

四、Step 3:兼容与容错优化(生产级)

完成核心功能后,添加「环境兼容、参数校验、异常捕获」,让代码更健壮。

1. 兼容queueMicrotask API

Node.js和部分老浏览器可能未实现queueMicrotask,需降级到Promise

// 声明全局queueMicrotask类型(兼容TS)
declare const queueMicrotask: ((callback: () => void) => void) | undefined;

// 新增:兼容后的微任务执行函数
private readonly queueMicrotaskFn: (callback: () => void) => void;

// 改造构造函数:初始化兼容函数
constructor() {
  this.queueMicrotaskFn =
    typeof queueMicrotask !== 'undefined'
      ? queueMicrotask
      : (cb) => this.microtaskPromise.then(cb);
}

// 改造scheduleFlush:使用兼容后的函数
private scheduleFlush(): void {
  if (this.isFlushScheduled || this.isFlushing) return;
  this.isFlushScheduled = true;
  this.queueMicrotaskFn(() => {
    this.isFlushScheduled = false;
    this.executeTasks();
  });
}

2. 添加参数校验

避免传入非函数类型的任务/回调:

// 改造enqueue
enqueue(task: TaskToQueue): void {
  if (typeof task !== 'function') {
    throw new TypeError('enqueue 必须传入函数类型的任务');
  }
  this.taskQueue.add(task);
  this.scheduleFlush();
}

// 封装回调注册逻辑,添加校验
private registerCallback(callbacks: Set<() => void>, callback: () => void): Unsubscribe {
  if (typeof callback !== 'function') {
    throw new TypeError('回调必须是函数类型');
  }
  callbacks.add(callback);
  return () => callbacks.delete(callback);
}

// 改造onBeforeFlush/onAfterFlush,复用校验逻辑
onBeforeFlush(callback: () => void): Unsubscribe {
  return this.registerCallback(this.beforeFlushCallbacks, callback);
}

onAfterFlush(callback: () => void): Unsubscribe {
  return this.registerCallback(this.afterFlushCallbacks, callback);
}

3. 异常捕获(容错)

单个任务/回调执行失败,不影响整体流程:

private executeTasks(): void {
  if (this.isFlushing) return;
  this.isFlushing = true;

  try {
    // 1. 执行前置回调(捕获单个回调异常)
    const beforeCallbacks = Array.from(this.beforeFlushCallbacks);
    beforeCallbacks.forEach((cb) => {
      try {
        cb();
      } catch (e) {
        console.error('执行前置回调失败:', e);
      }
    });

    // 2. 执行任务(复制队列,避免迭代时修改)
    const tasks = Array.from(this.taskQueue);
    tasks.forEach((task) => {
      try {
        task();
      } catch (e) {
        console.error('执行任务失败:', e);
      }
      this.taskQueue.delete(task);
    });
  } finally {
    // 无论是否出错,都执行后置回调+重置状态
    const afterCallbacks = Array.from(this.afterFlushCallbacks);
    afterCallbacks.forEach((cb) => {
      try {
        cb();
      } catch (e) {
        console.error('执行后置回调失败:', e);
      }
    });
    this.isFlushing = false;
  }
}

五、完整生产级代码

整合所有优化,最终的完整代码如下(可直接复制复用):

// 类型命名更精准,贴合场景
export type TaskToQueue = () => void;
// 取消回调的函数类型(语义更清晰)
export type Unsubscribe = () => void;

// 声明全局 queueMicrotask 类型(兼容 Node.js 和浏览器环境)
declare const queueMicrotask: ((callback: () => void) => void) | undefined;

/**
 * 微任务调度器类
 * 核心能力:批量管理任务,统一在微任务队列执行,支持执行前后回调
 */
export class MicroScheduler {
  // ========== 私有属性(封装,外部不可访问) ==========
  // 任务队列:Set 自动去重,避免重复执行
  private readonly taskQueue = new Set<TaskToQueue>();
  // 执行前回调队列
  private readonly beforeFlushCallbacks = new Set<() => void>();
  // 执行后回调队列
  private readonly afterFlushCallbacks = new Set<() => void>();
  // 标记是否正在执行任务
  private isFlushing = false;
  // 标记是否已将 flush 调度到微任务队列
  private isFlushScheduled = false;
  // 固定的微任务 Promise,用于外部 await 等待执行完成(仅初始化一次)
  private readonly microtaskPromise = Promise.resolve<void>(undefined);
  // 兼容后的微任务执行函数(适配无 queueMicrotask 的环境)
  private readonly queueMicrotaskFn: (callback: () => void) => void;

  // ========== 公开只读属性(外部可访问,不可修改) ==========
  /**
   * 用于等待微任务执行完成的 Promise
   * 外部可通过 await scheduler.tick 等待任务执行完毕
   */
  readonly tick = this.microtaskPromise;

  constructor() {
    // 兼容 queueMicrotask API(前端工程化必备)
    this.queueMicrotaskFn =
      typeof queueMicrotask !== 'undefined'
        ? queueMicrotask
        : (cb) => this.microtaskPromise.then(cb);
  }

  // ========== 公开方法(外部可调用) ==========
  /**
   * 添加任务到调度队列,并触发异步执行调度
   * @param task 待执行的微任务(无参无返回值函数)
   */
  enqueue(task: TaskToQueue): void {
    if (typeof task !== 'function') {
      throw new TypeError('enqueue 必须传入函数类型的任务');
    }
    this.taskQueue.add(task);
    this.scheduleFlush();
  }

  /**
   * 异步触发任务批量执行(放入微任务队列)
   * 多次调用仅会触发一次微任务,避免重复执行
   */
  flush(): void {
    this.scheduleFlush();
  }

  /**
   * 同步触发任务批量执行(立即执行,不等待微任务)
   */
  flushSync(): void {
    this.executeTasks();
  }

  /**
   * 注册「任务执行前」的回调
   * @param callback 执行前的回调函数
   * @returns 取消回调的函数(调用后不再触发该回调)
   */
  onBeforeFlush(callback: () => void): Unsubscribe {
    return this.registerCallback(this.beforeFlushCallbacks, callback);
  }

  /**
   * 注册「任务执行后」的回调
   * @param callback 执行后的回调函数
   * @returns 取消回调的函数(调用后不再触发该回调)
   */
  onAfterFlush(callback: () => void): Unsubscribe {
    return this.registerCallback(this.afterFlushCallbacks, callback);
  }

  // ========== 私有方法(内部逻辑,对外隐藏) ==========
  /**
   * 调度 flush 到微任务队列(核心:防重复调度)
   */
  private scheduleFlush(): void {
    if (this.isFlushScheduled || this.isFlushing) return;

    this.isFlushScheduled = true;
    this.queueMicrotaskFn(() => {
      this.isFlushScheduled = false;
      this.executeTasks();
    });
  }

  /**
   * 核心执行逻辑:执行前后回调 + 批量执行任务
   */
  private executeTasks(): void {
    if (this.isFlushing) return;
    this.isFlushing = true;

    try {
      // 1. 执行前置回调(复制后遍历,避免迭代时修改)
      const beforeCallbacks = Array.from(this.beforeFlushCallbacks);
      beforeCallbacks.forEach((cb) => {
        try {
          cb();
        } catch (e) {
          console.error('调度器执行前置回调失败:', e);
        }
      });

      // 2. 执行所有任务(复制后遍历,避免迭代时修改队列)
      const tasks = Array.from(this.taskQueue);
      tasks.forEach((task) => {
        try {
          task();
        } catch (e) {
          // 捕获单个任务异常,不影响其他任务执行(前端容错最佳实践)
          console.error('调度器执行任务失败:', e);
        }
        this.taskQueue.delete(task);
      });
    } finally {
      // 无论是否出错,都重置状态 + 执行后置回调
      const afterCallbacks = Array.from(this.afterFlushCallbacks);
      afterCallbacks.forEach((cb) => {
        try {
          cb();
        } catch (e) {
          console.error('调度器执行后置回调失败:', e);
        }
      });
      this.isFlushing = false;
    }
  }

  /**
   * 注册回调并返回取消函数(复用逻辑)
   * @param callbacks 回调队列
   * @param callback 要注册的回调函数
   * @returns 取消回调的函数
   */
  private registerCallback(callbacks: Set<() => void>, callback: () => void): Unsubscribe {
    if (typeof callback !== 'function') {
      throw new TypeError('回调必须是函数类型');
    }
    callbacks.add(callback);
    return () => callbacks.delete(callback);
  }
}

export default MicroScheduler;

六、使用场景与扩展方向

1. 典型使用场景

  • 批量DOM更新:多次修改DOM的任务加入调度器,避免频繁重排重绘;

  • 状态管理库:如Vue的响应式更新,批量执行依赖收集后的回调;

  • 异步数据处理:批量处理接口返回的数据,统一执行后续逻辑。

2. 扩展方向

  • 添加「任务优先级」:区分高/低优先级任务,按优先级执行;

  • 支持「任务超时」:设置任务执行超时时间,超时后抛出异常;

  • 增加「任务统计」:记录任务执行数量、耗时,便于性能监控。

七、总结

实现微任务调度器的核心是「理解事件循环+控制执行时机」:

  1. Set管理任务,解决重复执行问题;

  2. isFlushScheduled/isFlushing控制调度时机,避免重复执行;

  3. 用固定的Promise做时间锚点,支持外部可靠等待;

  4. 兼容环境+异常捕获,保证生产环境可用。

从「最小可用版」到「生产级优化版」的实现过程,不仅能掌握微任务的核心逻辑,还能学习到前端工程化的最佳实践(封装、兼容、容错)。希望本文能帮助你理解异步调度的本质,也能在实际项目中落地这个通用的微任务调度器。

Tailwind CSS:用“类名编程”重构你的前端开发体验

一、从前端“写样式”到“拼乐高”:Tailwind 是什么?

如果你还在为 .btn-primary-large-rounded-shadow-hover 这种类名而失眠,那你可能需要认识一下这位前端界的“极简主义艺术家”——Tailwind CSS

它不让你写 CSS,而是让你“用类名造 UI”。听起来像玄学?别急,举个🌰:

<button class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition">
  我是按钮,点我不会怀孕
</button>

看懂了吗?px-4 是内边距,bg-blue-600 是背景蓝,hover: 表示“当我被悬停时”,连动画过渡 transition 都给你安排得明明白白。

这不是代码,这是UI 的说明书。Tailwind 把 CSS 拆成一个个“原子类”,你只需要像搭乐高一样组合它们,就能快速构建出漂亮、响应式的界面。


二、从零开始:3 分钟搭建一个 React + Tailwind 项目(比泡面还快)

我们来走一遍真实开发流程,保证你手不抖、心不慌。

✅ 第一步:初始化 Vite 项目(现代前端的“快捷启动键”)

npm init vite

然后按提示走:

  • 项目名:my-cool-app
  • 框架:React
  • 变体:JavaScript

接着进入项目并安装依赖:

cd my-cool-app
npm install

💡 小贴士:Vite 是新时代的打包工具,快得像开了氮气加速,热更新比你换台电视还快。


✅ 第二步:安装 Tailwind(给 React 装上“喷气背包”)

npm install -D tailwindcss postcss autoprefixer

📌 注意:-D 表示开发依赖,毕竟生产环境不需要编译器帮你“拼类名”。


✅ 第三步:生成配置文件(Tailwind 的“出生证明”)

npx tailwindcss init -p

这会生成两个关键文件:

  • tailwind.config.js —— Tailwind 的“大脑”
  • postcss.config.js —— 编译流程的“交通警察”

✅ 第四步:配置内容扫描路径(防止“内存泄漏”式打包)

编辑 tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{js,jsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

⚠️ 划重点:content 字段告诉 Tailwind:“只打包我实际用到的类”,否则你会得到一个包含 10,000+ 类的 CSS 文件——那不是样式表,那是《CSS 百科全书》。


✅ 第五步:引入 Tailwind(给项目注入“超能力”)

src/index.css 中加入:

@tailwind base;
@tailwind components;
@tailwind utilities;

然后在 main.jsx 引入这个 CSS:

import './index.css'

最后,启动项目:

npm run dev

🎉 成功!你现在拥有了一个 React + Vite + Tailwind 的现代化前端开发环境,可以开始“类名编程”了!


三、Tailwind 的三大绝技:响应式、状态、原子化

🔥 绝技一:移动端优先,响应式如丝般顺滑

传统写法:

@media (min-width: 768px) {
  .layout { display: flex; }
}

Tailwind 写法:

<div className="flex flex-col md:flex-row gap-4">
  <main className="md:w-2/3">主内容</main>
  <aside className="md:w-1/3">侧边栏</aside>
</div>
  • 移动端:垂直排列,占满宽度
  • md: 断点以上:水平排列,主内容 2/3,侧边栏 1/3

无需写一行媒体查询,Tailwind 已经帮你预设好断点(sm: 640px, md: 768px, lg: 1024px...),简直是“断点自由主义者”。


🔥 绝技二:状态管理不用 JS,CSS 自己搞定

想实现“鼠标悬停变色 + 渐变动画”?

<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded transition">
  悬停我试试?
</button>
  • hover:bg-blue-700:悬停时背景变深
  • transition:加上平滑过渡
  • 不需要 JS 监听 onMouseEnter,也不需要写额外 CSS

Tailwind 支持 focus:active:disabled: 等伪类,真正做到了“样式即交互”。


🔥 绝技三:原子化类名,组合自由度拉满

Tailwind 的每个类只做一件事:

  • text-center → 文本居中
  • mt-4 → 上边距 1rem
  • shadow-lg → 大阴影
  • rounded-xl → 超大圆角

你可以像调鸡尾酒一样混合它们:

<div className="bg-white p-6 rounded-xl shadow-lg hover:shadow-2xl transition transform hover:scale-105">
  我是一个会“呼吸”的卡片
</div>

🤯 想象一下:以前你要写 .card-hover-effect,现在直接用类名描述行为,连文档都不用写。


四、React + Tailwind:组件化的“黄金搭档”

Tailwind 和 React 是天作之合。来看一个实战例子:

const ArticleCard = ({ title, summary }) => (
  <div className="p-5 bg-white rounded-xl shadow hover:shadow-lg transition border">
    <h2 className="text-lg font-bold text-gray-800">{title}</h2>
    <p className="text-gray-500 mt-2">{summary}</p>
  </div>
);

export default function App() {
  return (
    <>
      <ArticleCard 
        title="Tailwind 真香警告" 
        summary="用 utility class 快速构建 UI,告别 SCSS 嵌套地狱" 
      />
      <ArticleCard 
        title="React 组件化哲学" 
        summary="把 UI 拆成乐高,组合出千变万化" 
      />
    </>
  );
}

你会发现:

  • 样式全部由类名控制,组件逻辑更清晰
  • 无需维护 .scss 文件,结构和样式都在 JSX 中
  • 修改 UI?改几个类名就行,不用翻遍 CSS 文件

五、常见误解 & 正确打开方式

❌ 误解一:“类名太多,HTML 变丑了”

反驳:HTML 本来就不是给人“读”的,是给浏览器“吃”的。你见过谁吐槽“这家餐厅菜单太长”吗?关键是菜好不好吃。

而且,你可以用 @apply 提取常用组合:

.btn-primary {
  @apply px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition;
}

然后在 JSX 中:

<button className="btn-primary">提交</button>

✅ 建议:小项目直接用原子类,大项目可结合 @apply 或组件封装。


❌ 误解二:“Tailwind 学习成本高”

真相:Tailwind 的命名极其规律:

  • p-{size} → padding
  • m-{direction}-{size} → margin
  • text-{color} → 文字颜色
  • w-{fraction} → 宽度(w-1/2 就是 50%)

背 10 个类,就能写 80% 的布局。官方文档搜索功能强大,Ctrl+K 一搜就出结果,比记 CSS 属性还快。


六、总结:Tailwind 是“懒人”的胜利,也是“高效者”的武器

Tailwind 并不是要取代 CSS,而是提供了一种更高效、更一致、更可控的 UI 构建方式。

传统 CSS Tailwind
写规则 → 编译 → 调试 直接用类名 → 实时预览
容易冗余、难复用 原子化、高复用
响应式需手动写 media query 断点前缀一键切换

🎯 适合谁?

  • 快速原型开发
  • 设计系统统一的项目
  • 不想写 CSS 但又想要精致 UI 的人
  • 想摆脱“.container-wrapper-inner-content-box”这种类名噩梦的人

最后一句暴言:

“未来三年,不会用 Tailwind 的前端,就像不会用 Git 的程序员。”

别等了,现在就去 npm create vite,然后 npm install -D tailwindcss,开启你的“类名编程”之旅吧!


el-table源码解读2-2——createStore()初始化方法

1. createStore()初始化方法

export function createStore<T extends DefaultRow>(
  table: Table<T>,
  props: TableProps<T>
) {
  if (!table) {
    throw new Error('Table is required.')
  }

  const store = useStore<T>()
  // fix https://github.com/ElemeFE/element/issues/14075
  // related pr https://github.com/ElemeFE/element/pull/14146

  /**
   * 原始方法:_toggleAllSelection 是执行全选/取消全选的逻辑
   * 防抖包装:用 debounce 包装,延迟 10ms 执行
   * 方法替换:将防抖后的方法赋值给 toggleAllSelection
   */
  store.toggleAllSelection = debounce(store._toggleAllSelection, 10)
  Object.keys(InitialStateMap).forEach((key) => {
    /**
     * props是Table组件的props,key是InitialStateMap的key
     * 这段代码用于初始化 store 的状态:
     * 遍历 InitialStateMap 的所有 key,从 props 中取值并同步到 store。
     */
    handleValue(getArrKeysValue(props, key), key, store)
  })
  // 监听InitialStateMap中定义的所有属性
  proxyTableProps(store, props)
  return store
}
  /**
   * 原始方法:_toggleAllSelection 是执行全选/取消全选的逻辑
   * 防抖包装:用 debounce 包装,延迟 10ms 执行
   * 方法替换:将防抖后的方法赋值给 toggleAllSelection
   */
   
  store.toggleAllSelection = debounce(store._toggleAllSelection, 10)
  
// 用户点击全选框时
store.toggleAllSelection()  // 调用防抖后的方法
  → debounce 延迟 10ms
    → _toggleAllSelection()  // 执行实际逻辑
      → 修改 selection 和 isAllSelected 状态
      
为什么需要防抖?
_toggleAllSelection方法会遍历所有行数据、更新每行的选择状态、触发事件,
如果用户快速连续点击,可能会导致状态不一致、性能问题、UI闪烁,而防抖可以避免这些问题      

2. getArrKeysValue()

/**
 * 从 props 中按路径取值,支持嵌套属性(如 'treeProps.hasChildren')
 * @param props Table组件的props
 * @param key InitialStateMap的key
 * @returns
 */
function getArrKeysValue<T extends DefaultRow>(
  props: TableProps<T>,
  key: string
) {
  if ((key as keyof typeof props).includes('.')) {
    const keyList = (key as keyof typeof props).split('.')
    let value: string | Record<string, any> = props
    keyList.forEach((k) => {
      value = (value as Record<string, any>)[k]
    })
    return value
  } else {
    return (props as any)[key] as boolean | string
  }
}

3. handleValue()

/**
 * 将props的值同步到store的状态中,并处理映射关系和默认值
 * @param value 从props中按InitialStateMap的key取到的值,支持嵌套属性(如 'treeProps.hasChildren')
 * @param propsKey InitialStateMap的key
 * @param store TableStore
 */
function handleValue<T extends DefaultRow>(
  value: string | boolean | Record<string, any>,
  propsKey: string,
  store: Store<T>
) {
  // 保存从props中按InitialStateMap的key取到的原始值
  let newVal = value
  // 从InitialStateMap获取映射配置
  // 可能是字符串(如 'rowKey')或对象(如 { key: 'lazyColumnIdentifier', default: 'hasChildren' })
  let storeKey = InitialStateMap[propsKey as keyof typeof InitialStateMap]
  if (isObject(storeKey)) {
    // 如果newVal为空,则使用默认值
    newVal = newVal || storeKey.default
    storeKey = storeKey.key
  }
  ; ((store.states as any)[storeKey] as any).value = newVal
}

4. proxyTableProps()

/**
 * 用于监听 props 的变化,当 props 中的值改变时,自动同步到 store 的状态中
 * @param store
 * @param props
 */
function proxyTableProps<T extends DefaultRow>(
  store: Store<T>,
  props: TableProps<T>
) {
  // 遍历 InitialStateMap 的所有 key,为每个 key 创建一个 watch 监听器
  Object.keys(InitialStateMap).forEach((key) => {
    watch(
      // 监听 getArrKeysValue(props, key) 的返回值
      () => getArrKeysValue(props, key),
      (value) => {
        // 当值变化时,调用 handleValue 同步到 store
        handleValue(value, key, store)
      }
    )
  })
}

核心编程思维提炼

1. 配置驱动编程(Configuration-Driven Programming)

思维:将变化的部分抽离为配置,用统一逻辑处理。

// ❌ 硬编码思维(你可能会这样写)
function syncPropsToStore(props, store) {
  store.states.rowKey.value = props.rowKey
  store.states.data.value = props.data
  store.states.defaultExpandAll.value = props.defaultExpandAll
  // ... 每个都要写一遍
}

// ✅ 配置驱动思维(Element Plus 的做法)
const config = {
  rowKey: 'rowKey',
  data: 'data',
  defaultExpandAll: 'defaultExpandAll'
}
Object.keys(config).forEach(key => {
  store.states[config[key]].value = props[key]
})

实际应用场景:

  • API 字段映射:后端字段名 → 前端字段名
  • 表单验证规则:统一配置,统一处理
  • 权限控制:路由权限配置表
// 实际工作中的应用示例
const API_FIELD_MAP = {
  'user_name': 'userName',
  'create_time': 'createTime',
  'user_info.avatar': 'avatar'
}

function transformApiData(apiData) {
  const result = {}
  Object.keys(API_FIELD_MAP).forEach(apiKey => {
    const frontendKey = API_FIELD_MAP[apiKey]
    result[frontendKey] = getNestedValue(apiData, apiKey)
  })
  return result
}

2. 映射层模式(Mapping Layer Pattern)

思维:在数据源和目标之间建立映射层,解耦命名差异。

// 映射层的作用
Props 命名(用户友好)  →  映射层  →  Store 命名(内部实现)
'treeProps.hasChildren'InitialStateMap'lazyColumnIdentifier'

实际应用场景:

  • 第三方 API 对接:外部 API 字段 → 内部数据模型
  • 多语言支持:语言 key → 翻译文本
  • 状态机转换:状态名 → 状态值
// 实际工作中的应用示例
const STATUS_MAP = {
  'pending': { label: '待处理', color: 'orange', value: 0 },
  'processing': { label: '处理中', color: 'blue', value: 1 },
  'completed': { label: '已完成', color: 'green', value: 2 }
}

function getStatusInfo(status) {
  return STATUS_MAP[status] || STATUS_MAP['pending']
}

3. 数据转换管道(Data Transformation Pipeline)

思维:将复杂的数据转换拆分为多个步骤,每个步骤职责单一。

解释 reduce 和数据管道的执行过程:

reduce 方法详解

1. reduce 的基本语法

array.reduce((accumulator, currentValue) => {
  // 处理逻辑
  return newAccumulator
}, initialValue)
  • accumulator(累加器):上一次处理的结果
  • currentValue(当前值):当前处理的元素
  • initialValue(初始值):第一次处理时的初始值

2. 数据管道的执行过程

const dataPipeline = [
  (data) => transformApiFields(data),      // 步骤1:字段转换
  (data) => validateData(data),            // 步骤2:数据验证
  (data) => formatDates(data),             // 步骤3:日期格式化
  (data) => enrichData(data),              // 步骤4:数据增强
]

function processData(rawData) {
  return dataPipeline.reduce((data, transform) => transform(data), rawData)
}

3. 逐步执行过程(拆解)

等价写法:

function processData(rawData) {
  // 初始值:rawData
  let result = rawData
  
  // 第1次循环:transform = transformApiFields
  result = transformApiFields(result)
  // 此时 result = transformApiFields(rawData)
  
  // 第2次循环:transform = validateData
  result = validateData(result)
  // 此时 result = validateData(transformApiFields(rawData))
  
  // 第3次循环:transform = formatDates
  result = formatDates(result)
  // 此时 result = formatDates(validateData(transformApiFields(rawData)))
  
  // 第4次循环:transform = enrichData
  result = enrichData(result)
  // 此时 result = enrichData(formatDates(validateData(transformApiFields(rawData))))
  
  return result
}

4. 用具体例子演示

// 假设原始数据
const rawData = {
  user_name: '张三',
  create_time: '2024-01-01',
  age: 25
}

// 定义转换函数
const transformApiFields = (data) => {
  return {
    userName: data.user_name,  // 下划线转驼峰
    createTime: data.create_time,
    age: data.age
  }
}

const validateData = (data) => {
  if (!data.userName) throw new Error('用户名不能为空')
  return data
}

const formatDates = (data) => {
  return {
    ...data,
    createTime: new Date(data.createTime).toLocaleDateString()
  }
}

const enrichData = (data) => {
  return {
    ...data,
    status: 'active',
    id: Math.random().toString(36).substr(2, 9)
  }
}

// 数据管道
const dataPipeline = [
  transformApiFields,
  validateData,
  formatDates,
  enrichData
]

// 执行过程
function processData(rawData) {
  return dataPipeline.reduce((data, transform) => transform(data), rawData)
}

// 执行结果
const result = processData(rawData)
console.log(result)
// {
//   userName: '张三',
//   createTime: '2024/1/1',
//   age: 25,
//   status: 'active',
//   id: 'abc123xyz'
// }

5. 执行流程图

原始数据: { user_name: '张三', create_time: '2024-01-01', age: 25 }
    ↓
[reduce 开始,初始值 = rawData]
    ↓
步骤1: transformApiFields(rawData)
    → { userName: '张三', createTime: '2024-01-01', age: 25 }
    ↓
步骤2: validateData(上一步结果)
    → { userName: '张三', createTime: '2024-01-01', age: 25 } (验证通过)
    ↓
步骤3: formatDates(上一步结果)
    → { userName: '张三', createTime: '2024/1/1', age: 25 }
    ↓
步骤4: enrichData(上一步结果)
    → { userName: '张三', createTime: '2024/1/1', age: 25, status: 'active', id: 'abc123xyz' }
    ↓
最终结果

6. 用 for 循环等价写法(更容易理解)

function processData(rawData) {
  let result = rawData  // 初始值
  
  // 依次执行每个转换函数
  for (let i = 0; i < dataPipeline.length; i++) {
    const transform = dataPipeline[i]
    result = transform(result)  // 将上一步的结果作为下一步的输入
  }
  
  return result
}

7. 为什么用 reduce

优势:

  1. 函数式编程:更简洁、声明式
  2. 链式处理:数据像流水线一样依次处理
  3. 易于扩展:添加新步骤只需在数组中添加函数
  4. 易于测试:每个转换函数可以独立测试

8. 实际工作中的应用场景

// 场景1:表单数据处理
const formDataPipeline = [
  (data) => trimFields(data),           // 去除空格
  (data) => validateRequired(data),     // 必填验证
  (data) => validateFormat(data),       // 格式验证
  (data) => transformToApiFormat(data)  // 转换为 API 格式
]

// 场景2:列表数据处理
const listDataPipeline = [
  (data) => transformFields(data),      // 字段转换
  (data) => filterInvalid(data),        // 过滤无效数据
  (data) => sortByDate(data),           // 按日期排序
  (data) => paginate(data)              // 分页
]

// 场景3:API 响应处理
const apiResponsePipeline = [
  (data) => extractData(data),          // 提取数据
  (data) => handleError(data),          // 错误处理
  (data) => normalizeData(data),      // 数据标准化
  (data) => cacheData(data)             // 缓存数据
]

9. 调试技巧

如果想看每一步的结果:

function processData(rawData) {
  return dataPipeline.reduce((data, transform, index) => {
    console.log(`步骤 ${index + 1}:`, data)
    const result = transform(data)
    console.log(`步骤 ${index + 1} 结果:`, result)
    return result
  }, rawData)
}

总结

  • reduce 的作用:将数组中的每个函数依次执行,前一个函数的输出作为下一个函数的输入
  • 数据管道:像工厂流水线,数据依次经过每个处理步骤
  • 优势:代码简洁、易于扩展、易于测试

这就是函数式编程中的“管道模式”(Pipeline Pattern)。

面试官: “ 说一下 JS 中什么是事件循环 ? ”

JS 中的事件循环原理以及异步执行过程

这些知识点对新手来说可能有点难,但是是必须迈过的坎,逃避是解决不了问题的,本篇文章旨在帮你彻底搞懂它们。


1. JS 是单线程的

我们都知道 JS 是单线程执行的(原因:我们不想并行地操作 DOM,DOM 树不是线程安全的,如果多线程,那会造成冲突)。

这里小说明一下:V8 是谷歌浏览器的 JS 执行引擎,在运行 JS 代码的时候,是以函数作为一个个帧(保存当前函数的执行环境)按代码的执行顺序压入执行栈(call stack)中,栈顶的函数先执行,执行完毕后弹出再执行下一个函数。其中堆是用来存放各种 JS 对象的。

image.png

假设浏览器就是上图的这种结构的话,执行同步代码是没什么问题的,如下

function foo() {
    bar()
    console.log('foo')
}
function bar() {
    baz()
    console.log('bar')
}
function baz() {
    console.log('baz')
}

foo()

我们定义了 foobarbaz 三个函数,然后调用 foo 函数,控制台输出的结果为:

baz
bar
foo

执行过程如下:

  1. 一个全局匿名函数最先执行(JS 的全局执行入口,之后的例子将忽略),遇到 foo 函数被调用,将 foo 函数压入执行栈。
  2. 执行 foo 函数,发现 foo 函数体中调用了 bar 函数,则将 bar 函数压入执行栈。
  3. 执行 bar 函数,发现 bar 函数体中调用了 baz 函数,又将 baz 函数压入执行栈。
  4. 执行 baz 函数,函数体中只有一条语句 console.log('baz'),执行,在控制台打印:baz,然后 baz 函数执行完毕弹出执行栈。
  5. 此时的栈顶为 bar 函数,bar 函数体中的 baz() 语句已经执行完,接着执行下一条语句(console.log('bar')),在控制台打印:bar,然后 bar 函数执行完毕弹出执行栈。
  6. 此时的栈顶为 foo 函数,foo 函数体中的 bar() 语句已经执行完,接着执行下一条语句(console.log('foo')),在控制台打印:foo,然后 foo 函数执行完毕弹出执行栈。
  7. 至此,执行栈为空,这一轮执行完毕。

动图展示

还是图直观点,以上步骤对应的执行流程图如下:

fc266fc5ceece50a1622961cf201eec5.gif

非动图 image.png


2. 事件循环(event loop)

  • 事件循环:JS 处理异步任务的机制,因单线程特性,通过循环读取任务队列实现非阻塞。

  • 过程:

    1. 执行同步代码(调用栈清空)。
    2. 执行所有微任务(Promise回调等),直到微任务队列清空。
    3. 执行一个宏任务(setTimeout等),然后回到步骤 2,循环往复。

我们改变一下代码 1, 如下是代码 2:

function foo() {
    bar()
    console.log('foo')
}
function bar() {
    baz()
    console.log('bar')
}
function baz() { 
    setTimeout(() => {
        console.log('setTimeout: 2s')
    }, 2000)
    console.log('baz') 
}

foo()

根据 1 中的假设,浏览器只由一个 JS 引擎构成的话,那么所有的代码必然同步执行(因为 JS 执行是单线程的,所以当前栈顶函数不管执行时间需要多久,执行栈中该函数下面的其他函数必须等它执行完弹出后才能执行(这就是代码被阻塞的意思)),执行到 baz 函数体中的 setTimeout 时应该等 2 秒,在控制台中输出 setTimeout: 2s,然后再输出:baz。所以我们期望的输出顺序应该是:setTimeout: 2s -> baz -> bar -> foo(这是错的)。

浏览器如果真这样设计的话,肯定是有问题的!遇到 AJAX 请求、setTimeout 等比较耗时的操作时,我们页面需要长时间等待,就被阻塞住啥也干不了,出现了页面 “假死”,这样绝对不是我们想要的结果。

实际当然并非我以为的那样,这里先重点提醒一下:JS 是单线程的,这一点也没错,但是浏览器中并不仅仅只是由一个 JS 引擎构成,它还包括其他的一些线程来处理别的事情。如下图 !

image.png

浏览器除了 JS 引擎(JS 执行线程,后面我们只关注 JS 引擎中的执行栈)以外,还有 Web APIs(浏览器提供的接口,这是在 JS 引擎以外的)线程、GUI 渲染线程等。JS 引擎在执行过程中,如果遇到相关的事件(DOM 操作、鼠标点击事件、滚轮事件、AJAX 请求、setTimeout 等),并不会因此阻塞,它会将这些事件移交给 Web APIs 线程处理,而自己则接着往下执行。Web APIs 则会按照一定的规则将这些事件放入一个任务队列(callback queue,也叫 task queue)中,当 JS 执行栈中的代码执行完毕以后,它就会去任务队列中获取一个事件回调放入执行栈中执行,然后如此往复,这就是所谓的事件循环机制。

线程名 作用
JS 引擎线程 也称为 JS 内核,负责处理 JavaScript 脚本。(例如 V8 引擎)负责解析 JS 脚本,运行代码。一直等待着任务队列中的任务的到来,然后加以处理。一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程运行 JS 程序。
事件触发线程 归属于渲染进程而不是 JS 引擎,用来控制事件循环。当 JS 引擎执行代码块如setTimeout时(也可来自浏览器内核的其他线程,如鼠标点击、Ajax 异步请求等),会将对应任务添加到事件线程中。当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。 注意:由于 JS 的单线程关系,所以这些待处理队列中的事件都是排队等待 JS 引擎处理,JS 引擎空闲时才会执行。
定时触发器线程 setIntervalsetTimeout所在的线程。浏览器定时计数器并不是由 JS 引擎计数的。JS 引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确,因此,通过单独的线程来计时并触发定时。计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行。注意:W3C 在 HTML 标准中规定,setTimeout中低于 4ms 的时间间隔算为 4ms。
异步 HTTP 请求线程 XMLHttpRequest在连接后通过浏览器新开一个线程请求。当检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调放入事件队列中,再由 JS 引擎执行。
GUI 渲染线程 负责渲染浏览器界面,包括:解析 HTML、CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。重绘(Repaint)以及回流(Reflow)处理。

这里让我们对事件循环先来做个小总结

  1. JS线程负责处理JS代码,当遇到一些异步操作的时候,则将这些异步事件移交给Web APIs 处理,自己则继续往下执行。
  2. Web APIs线程将接收到的事件按照一定规则按顺序添加到任务队列中(应该是添加到任务集合中的各个事件队列中)。
  3. JS线程处理完当前的所有任务以后(执行栈为空),它会去查看任务队列中是否有等待被处理的事件,若有,则取出一个事件回调放入执行栈中执行。
  4. 然后不断循环第3步。

让我们来看看真正的浏览器中执行是什么个流程吧!

动图展示

第二段代码 3.gif

细心的小伙伴可能有发现Web API在计时器时间到达后将匿名回调函数添加到任务队列中了,虽然定时器时间已到,但它目前并不能执行!!!因为JS的执行栈此时并非空,必须要等到当前执行栈为空后才有机会被召回到执行栈执行。由此,我们可以得出一个结论:setTimeout设置的时间其实只是最小延迟时间,而并不是确切的等待时间。(当主线程的任务耗时比较长的时候,等待时间将会变得更长


3. 事件循环(进阶)与异步

3.1 试试 setTimeout(fn, 0)

function foo() {
    console.log('foo')
}

setTimeout(function() {
    console.log('setTimeout: 0s')
}, 0);

foo();

运行结果:

foo
setTimeout: 0s
4.gif

即使 setTimeout 的延时设置为 0(实际上最小延时 >= 4ms),JS 执行栈也将该延时事件发放给 Web API 处理,Web API 再将事件添加到任务队列中,等 JS 执行栈为空时,该延时事件再压入执行栈中执行。


3.2 事件循环中的 Promise

其实以上的浏览器模型是ES5标准的,ES6+标准中的任务队列在此基础上新增了一种,变成了如下两种:

3.2.1 宏任务 / 微任务

现在W3C重新对事件循环进行了定义,取消了宏任务,取而代之的是任务队列,微任务依旧保留,优先级为最高。

MDN 官网 : 事件循环会将作业分成两类: 任务微任务。微任务具有更高的优先级,在任务队列被拉出之前,微任务队列会先被排空

任务队列(macrotask queue)普通优先级的任务,通常包括:

  • setTimeout / setInterval / setImmediate(Node.js)
  • I/O 操作(文件读写、Ajax事件 / 网络请求等)
  • UI 渲染事件 (用户交互事件)
  • 脚本整体代码(第一次执行的同步代码)

微任务队列(microtask queue)高优先级的任务,通常包括:

  • Promise.then / Promise.catch / Promise.finally
  • async/await 中 await 后面的代码(其实是 .then 的语法糖)
  • MutationObserver(浏览器)
  • process.nextTick(Node.js,优先级比普通微任务更高)
image.png

事件循环的处理流程变成了如下:

  1. JS 线程负责处理 JS 代码,当遇到一些异步操作的时候,则将这些异步事件移交给 Web APIs 处理,自己则继续往下执行。
  2. Web APIs 线程将接收到的事件按照一定规则添加到任务队列中,宏事件添加到宏任务队列中,微事件添加到微事件队列中。
  3. JS 线程处理完当前的所有任务以后(执行栈为空),它会先去微任务队列获取事件,并将微任务队列中的所有事件一件件执行完毕,直到微任务队列为空后再去宏任务队列中取出一个事件执行。
  4. 然后不断循环第 3 步。
image.png

排一下先后顺序: 执行栈 --> 微任务 --> 渲染 --> 下一个宏任务


3.2.2 单独使用 Promise

function foo() {
    console.log('foo')
}

console.log('global start')

new Promise((resolve) => {
    console.log('promise')
    resolve()
}).then(() => {
    console.log('promise then')
})

foo()

console.log('global end')

控制台输出的结果为:

global start
promise
foo
global end
promise then

动图展示

5.gif

代码执行过程解析(文字描述)

  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出:global start
  1. 执行 new Promise(...)
  • 注意:在使用 new 关键字创建 Promise 对象时,传递给 Promise 的函数称为 executor

    • 当 Promise 被创建时,executor 函数会自动同步执行
    • .then 里的回调才是异步执行的部分。
  • 执行 Promise 参数中的匿名函数(同步执行):

    • 执行 console.log('promise'),控制台输出:promise

    • 执行 resolve(),将 Promise 状态变为 resolved

  • 继续执行 .then(...)

    • 遇到 .then 会将回调提交给 Web API 处理。
    • Web API 将该回调添加到 微任务队列(此时微任务队列中有一个 Promise 事件待执行)。
  1. 继续执行同步代码
  • 执行栈在提交完 Promise 事件后,继续往下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 处理微任务队列
  • 事件循环机制首先查看 微任务队列 是否为空:

    • 发现有一个 Promise 事件待执行,将其压入执行栈。

    • 执行 .then 中的回调:

      • 执行 console.log('promise then'),控制台输出:promise then
    • 至此,新的一轮事件循环(Promise 事件)执行完毕,执行栈为空。

  1. 检查任务队列
  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。
    2. 再查看 宏任务队列,发现也为空。
  • 执行栈进入等待事件状态。


3.2.3 Promise 结合 setTimeout

function foo() {
    console.log('foo')
}

console.log('global start')

setTimeout(() => {
    console.log('setTimeout: 0s')
}, 0)

new Promise((resolve) => {
    console.log('promise')
    resolve()
}).then(() => {
    console.log('promise then')
})

foo()

console.log('global end')

控制台输出的结果为:

global start
promise
foo
global end
promise then
setTimeout: 0s

动图展示

6.gif
  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出: global start
  1. 处理 setTimeout(改变部分)
  • 继续往下执行,遇到 setTimeout

    • JS 执行栈将其移交给 Web API 处理。
    • 延迟 0 秒后,Web API 将 setTimeout 事件添加到 宏任务队列(此时宏任务队列中有一个 setTimeout 事件待处理)。
  1. 继续执行同步代码
  • JS 线程转交 setTimeout 事件后,继续往下执行:

    • 遇到 new Promise(...)

      • Promise 的 executor 函数 同步执行

        • 执行 console.log('promise'),控制台输出:promise

        • 执行 resolve(),将 Promise 状态变为 resolved

      • 执行 .then(...)

        • 遇到 .then 会将回调提交给 Web API 处理。
        • Web API 将该回调添加到 微任务队列(此时微任务队列中有一个 Promise 事件待处理)。
  1. 继续执行同步代码
  • 执行栈在提交完 Promise 事件后,继续往下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 处理微任务队列
  • 事件循环机制首先查看 微任务队列 是否为空:

    • 发现有一个 Promise 事件待执行,将其压入执行栈。

    • 执行 .then 中的回调:

      • 执行 console.log('promise then'),控制台输出: promise then
    • 至此,新的一轮事件循环(Promise 事件)执行完毕,执行栈为空。

  1. 处理宏任务队列(改变部分)
  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。

    2. 再查看 宏任务队列,发现有一个 setTimeout 事件待处理:

    • 将 setTimeout 中的匿名函数压入执行栈执行:

      • 执行 console.log('setTimeout: 0s'),控制台输出:setTimeout: 0s
    • 至此,新的一轮事件循环(setTimeout 事件)执行完毕,执行栈为空。

7.** 检查任务队列**

  • 执行栈再次为空,事件循环:

    1. 先查看 微任务队列,发现已空。
    2. 再查看 宏任务队列,发现也为空。
  • 执行栈进入等待事件状态。


3.3 事件循环中的 async/await

这里简单介绍下async函数:

  1. 函数前面 async 关键字的作用就2点:①这个函数总是返回一个promise。②允许函数内使用await关键字。

  2. 关键字 await 使 async 函数一直等待(执行栈当然不可能停下来等待的,await将其后面的内容包装成promise交给Web APIs后,执行栈会跳出async函数继续执行),直到promise执行完并返回结果。await只在async函数函数里面奏效

  3. async函数只是一种比promise更优雅得获取promise结果(promise链式调用时)的一种语法而已。

function foo() {
    console.log('foo')
}

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}

async function async2() {
    console.log('async2')
}

console.log('global start')
async1()
foo()
console.log('global end')

执行的结果如下:

global start
async1 start
async2
foo
global end
async1 end
  1. 执行同步代码
  • 执行 console.log('global start'),控制台输出:global start
  1. 调用 async1()
  • 执行 async1(),进入 async1 函数体:

    • 执行 console.log('async1 start'),控制台输出:async1 start

    • 执行 await async2()

      • await 关键字会暂停 async1 函数的执行,直到 await 后面的 Promise 返回结果。
      • await async2() 会像调用普通函数一样执行 async2()
  1. 执行 async2()
  • 进入 async2 函数体:

    • 执行 console.log('async2'),控制台输出:async2

    • async2 函数执行结束,弹出执行栈。

    • 由于 async2 没有显式返回 Promise,它会隐式返回一个已 resolved 的 Promise。

4.暂停 async1() 并继续执行同步代码

  • 因为 await 关键字之后的代码被暂停,async1 函数执行结束,弹出执行栈。

  • JS 主线程继续向下执行:

    • 执行 foo() 函数,控制台输出:foo

    • 执行 console.log('global end'),控制台输出:global end

  • 至此,本轮事件循环的同步代码执行完毕,执行栈为空。

  1. 事件循环处理微任务
  • 事件循环机制开始工作:

    1. 先查看 微任务队列

      • 发现有一个微任务事件,该事件是 async1 函数中 await async2() 之后的代码(可以理解为:用一个匿名函数包裹 await 之后的代码,作为微任务事件)。

      • 执行该微任务:

        • 执行 console.log('async1 end'),控制台输出:async1 end
    2. 执行栈再次为空,本轮事件执行结束。

  1. 检查任务队列
  • 事件循环机制再次查看:

    1. 微任务队列:已空。
    2. 宏任务队列:也为空。
  • 执行栈进入等待事件状态。


4. 大综合(自测)

4.1 简单融合

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end');

输出结果:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

4.2 变形 1

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    new Promise(function(resolve) {
        console.log('promise1');
        resolve();
    }).then(function() {
        console.log('promise2');
    });
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise3');
    resolve();
}).then(function() {
    console.log('promise4');
});

console.log('script end');

输出的结果:

script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout

4.3 变形 2

async function async1() {
    console.log('async1 start');
    await async2();
    setTimeout(function() {
        console.log('setTimeout1');
    },0)
}
async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout3');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end');

输出的结果:

script start
async1 start
async2
promise1
script end
promise2
setTimeout3
setTimeout1

4.4 变形 3

async function a1 () {
    console.log('a1 start')
    await a2()
    console.log('a1 end')
}
async function a2 () {
    console.log('a2')
}

console.log('script start')

setTimeout(() => {
    console.log('setTimeout')
}, 0)

Promise.resolve().then(() => {
    console.log('promise1')
})

a1()

let promise2 = new Promise((resolve) => {
    resolve('promise2.then')
    console.log('promise2')
})

promise2.then((res) => {
    console.log(res)
    Promise.resolve().then(() => {
        console.log('promise3')
    })
})
console.log('script end')

输出的结果:

script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout

5. 结语

  • JS 是单线程执行的,同一时间只能处理一件事。
  • 浏览器是多线程的,JS 引擎通过分发这些耗时的异步事件给 Web APIs 线程处理,避免了单线程被阻塞。
  • 事件循环机制是为了协调事件、用户交互、JS 脚本、页面渲染、网络请求等事件的有序执行
  • 微任务的优先级高于宏任务

【Vue3 高级技巧】函数重载+Watch:打造类型安全的通用事件监听 Hook

【Vue3 高级技巧】函数重载+Watch:打造类型安全的通用事件监听 Hook

📖 引言

在 Vue3 项目开发中,事件监听是一项非常基础但频繁使用的功能。我们经常需要为 DOM 元素或 window 对象绑定各类事件,如点击、滚动、键盘输入等。虽然原生 API 使用起来并不复杂,但在组件化开发中,手动管理事件的绑定与解绑不仅繁琐,还容易导致内存泄漏。

今天,我们将探索如何利用 Vue3 的watchAPI 和 TypeScript 的函数重载特性,打造一个类型安全、自动清理、使用便捷的通用事件监听 Hook,彻底解决事件管理的痛点。

🎯 问题剖析:原生事件绑定的痛点

先来看一段我们在 Vue 组件中经常写的事件绑定代码:

<template>
  <div ref="divRef"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const divRef = ref();
onMounted(() => {
  divRef.value.addEventListener("click", (e) => {
    console.log(e);
  });
});
onUnmounted(() => {
  divRef.value.removeEventListener("click");
});
</script>

这段代码看似简单,但存在以下几个问题:

  1. 代码重复:每个需要事件绑定的组件都要写类似的onMountedonUnmounted逻辑
  2. 手动管理:必须手动调用removeEventListener,容易遗漏导致内存泄漏
  3. 缺乏灵活性:无法很好地处理动态渲染的 DOM 元素(如 v-if 控制的元素)
  4. 类型不安全:事件处理函数中的事件对象缺乏类型提示

💡 解决方案:封装通用事件监听 Hook

针对上述问题,我们可以封装一个通用的事件监听 Hook——useEventListener,利用 Vue3 的watchAPI 来自动管理事件的生命周期。

核心实现思路

  1. 自动清理机制:利用watchonClear回调实现事件的自动解绑
  2. 动态目标支持:同时支持 window 对象和 DOM 元素作为事件目标
  3. 响应式处理:通过watch监听目标元素的变化,支持动态 DOM
  4. 类型安全:使用 TypeScript 的函数重载提供完整的类型提示

基础版本实现

import { watch, unref } from "vue";

export function useEventListener(...args) {
  // 判断目标:如果第一个参数是字符串,则目标为window;否则为传入的DOM元素
  const target = typeof args[0] === "string" ? window : args.shift();

  // 使用watch监听目标元素的变化
  return watch(
    () => unref(target),
    (element, _, onClear) => {
      // 处理DOM不存在的情况(如v-if初始为false)
      if (!element) return;

      // 绑定事件
      element.addEventListener(...args);

      // 清理函数:在组件卸载或watch停止时执行
      onClear(() => {
        element.removeEventListener(...args);
      });
    },
    {
      immediate: true, // 立即执行
    }
  );
}

用法示例

封装完成后,我们可以通过两种方式使用这个 Hook:

// 1. 给window绑定事件
useEventListener("click", () => console.log("Window clicked!"), options);

// 2. 给指定DOM元素绑定事件
useEventListener(domRef, "click", () => console.log("DOM clicked!"), options);

如果需要手动结束事件监听,可以调用返回的stop方法:

const handle = useEventListener(domRef, "click", () => {});
// 手动终止监听
handle.stop();

🚀 进阶优化:函数重载实现类型安全

基础版本虽然功能完整,但在 TypeScript 环境下使用时缺乏类型提示,这会影响开发体验。为了解决这个问题,我们可以利用 TypeScript 的函数重载特性。

函数重载的定义

函数重载允许我们为同一个函数提供多个类型定义,TypeScript 会根据传入的参数类型自动选择匹配的重载版本。

类型安全版本实现

import { watch, unref, Ref } from "vue";

// 重载1:给window绑定事件
export function useEventListener<K extends keyof WindowEventMap>(
  type: K,
  handle: (event: WindowEventMap[K]) => void,
  options?: boolean | AddEventListenerOptions
);

// 重载2:给指定DOM元素绑定事件
export function useEventListener<K extends keyof HTMLElementEventMap>(
  target: Ref<HTMLElement | null>,
  type: K,
  handle: (event: HTMLElementEventMap[K]) => void,
  options?: boolean | AddEventListenerOptions
);

// 通用实现
export function useEventListener(...args: any[]) {
  // 判断目标:如果第一个参数是字符串,则目标为window;否则为传入的DOM元素
  const target = typeof args[0] === "string" ? window : args.shift();

  // 使用watch监听目标元素的变化
  return watch(
    () => unref(target),
    (element, _, onClear) => {
      // 处理DOM不存在的情况(如v-if初始为false)
      if (!element) return;

      // 绑定事件
      element.addEventListener(...args);

      // 清理函数:在组件卸载或watch停止时执行
      onClear(() => {
        element.removeEventListener(...args);
      });
    },
    {
      immediate: true, // 立即执行
    }
  );
}

类型重载的优势

  1. 智能提示:IDE 会根据传入的参数类型提供对应的事件名称和事件对象类型提示
  2. 类型检查:TypeScript 会检查事件处理函数的参数类型是否正确
  3. 错误预防:避免传入不存在的事件类型或错误的事件处理函数签名

🎯 技术深度解析

1. Watch API 的高级用法

在这个 Hook 中,我们充分利用了 Vue3 watch API 的高级特性:

  • 响应式监听:通过unref(target)确保可以同时处理 ref 和普通值
  • immediate 选项:确保组件挂载后立即绑定事件
  • onClear 回调:提供了可靠的清理机制,避免内存泄漏

2. TypeScript 类型系统的强大

  • 事件映射类型WindowEventMapHTMLElementEventMap提供了浏览器原生事件的完整类型定义
  • 泛型约束:使用K extends keyof EventMap确保事件类型的正确性
  • 函数重载:为不同的使用场景提供精确的类型定义

3. 自动清理机制的原理

当以下情况发生时,onClear回调会被自动调用:

  • 组件卸载时
  • 调用返回的stop方法时
  • 监听的目标元素发生变化时

这种机制确保了事件监听始终与组件生命周期同步,彻底避免了内存泄漏。

📝 最佳实践与注意事项

1. 事件处理函数的注意事项

  • 避免箭头函数陷阱:如果需要在事件处理函数中访问this,应使用普通函数
  • 事件对象的正确使用:利用 TypeScript 的类型系统确保事件对象的属性访问安全

2. 性能优化建议

  • 事件委托:对于大量相似元素,优先考虑事件委托而不是为每个元素单独绑定事件
  • 合理使用事件选项:根据需要设置passivecapture等选项优化性能

3. 扩展使用场景

  • 自定义事件:可以扩展支持自定义事件的类型定义
  • 组件事件:结合 Vue 的组件事件系统使用
  • 第三方库集成:与 Chart.js、Mapbox 等第三方库的事件系统集成

🔧 实战案例:实时键盘监听

让我们通过一个实际案例来展示useEventListener的强大功能:

<template>
  <div>
    <h2>键盘监听演示</h2>
    <p>当前按下的键:{{ pressedKey }}</p>
    <p>按下次数:{{ pressCount }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { useEventListener } from "./useEventListener";

const pressedKey = ref("");
const pressCount = ref(0);

// 使用通用事件监听Hook
useEventListener(
  "keydown",
  (event: KeyboardEvent) => {
    pressedKey.value = event.key;
    pressCount.value++;
  },
  { passive: true }
);
</script>

这个示例展示了如何轻松实现一个实时键盘监听功能,无需手动管理事件的绑定与解绑。

📚 扩展阅读

  1. Vue3 Composition API - Watch
  2. TypeScript 函数重载
  3. DOM 事件 API
  4. 前端内存泄漏排查与解决

💭 思考题

  1. 如何扩展这个 Hook 以支持自定义事件类型?
  2. 如果需要同时监听多个事件,应该如何优化实现?
  3. 如何将这个 Hook 与 Vue 的响应式系统更好地结合?

🎉 总结

通过本文的介绍,我们学习了如何利用 Vue3 的watchAPI 和 TypeScript 的函数重载特性,打造一个类型安全、自动清理的通用事件监听 Hook。这个 Hook 不仅解决了原生事件绑定的痛点,还提供了良好的开发体验和类型支持。

核心技术点回顾:

  • 函数重载:提供精确的类型定义和智能提示
  • Watch API:实现响应式监听和自动清理
  • 自动管理:事件生命周期与组件同步,避免内存泄漏
  • 灵活使用:支持 window 和 DOM 元素,适应各种场景

这个简单而强大的 Hook 展示了 Vue3 Composition API 的灵活性和 TypeScript 类型系统的强大,是我们在日常开发中值得掌握的高级技巧。

取件伙伴性能提升——长列表

取件伙伴性能提升——长列表

在移动应用开发中,List是最常见也是最容易出现性能瓶颈的场景之一。在 取件伙伴 项目中,取件列表页面需要展示可能多达数百条的包裹信息。如果不进行优化,随着数据量的增长,应用会出现滑动掉帧、内存占用过高甚至崩溃的问题,特别是最近我增加了在深色模式下的雪花效果,列表更是卡的不行!

本文将详细介绍我们如何利用 性能优化 "三剑客" —— LazyForEach@ReusablecachedCount,将列表渲染性能提升至极致。


核心问题分析

在早期的开发中,如果直接使用 ForEach 渲染列表:

// ❌ 性能较差的写法
List() {
  ForEach(this.packages, (item) => {
    PackageCard({ packageInfo: item })
  })
}

这种方式存在两个主要缺陷:

  1. 全量加载:无论列表有多长,ForEach 都会一次性创建所有的数据对象和组件节点。如果有 1000 个包裹,就会瞬间创建 1000 个 PackageCard,导致内存激增。
  2. 频繁销毁与创建:当用户滑动列表时,移出屏幕的组件会被销毁,新进入屏幕的组件需要重新创建、布局和渲染。对于包含图片和复杂布局的卡片,这种开销是巨大的,直接导致滑动卡顿。

解决方案:性能优化 "三剑客"

1. LazyForEach:按需加载

LazyForEach 是专门为长列表设计的渲染控制语法。与 ForEach 不同,它只渲染屏幕可见区域的组件,并配合数据源(IDataSource)实现按需加载。

实现步骤:

首先,我们需要实现一个 IDataSource 接口的数据源类:

entry/src/main/ets/utils/BasicDataSource.ets

// 通用数据源基类,实现了 IDataSource 接口
export class BasicDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private originDataArray: T[] = [];

  // 获取数据的总条数
  public totalCount(): number {
    return this.originDataArray.length;
  }

  // 获取指定索引的数据
  public getData(index: number): T {
    return this.originDataArray[index];
  }

  // 注册/注销监听器(框架调用)
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  // === 通知 LazyForEach 刷新 ===
  notifyDataReload(): void {
    this.listeners.forEach(listener => listener.onDataReloaded());
  }
  
  public setData(data: T[]) {
    this.originDataArray = data;
    this.notifyDataReload();
  }
}

2. @Reusable:组件复用

这是解决“滑动卡顿”的关键。通过 @Reusable 装饰器,我们可以让组件具备“复用”能力。当一个列表项滑出屏幕时,它的组件实例不会被销毁,而是被放入缓存池;当新数据滑入屏幕时,直接从缓存池取出实例并更新数据,跳过了昂贵的组件创建和布局计算过程

entry/src/main/ets/components/PackageCard.ets

@Component
@Reusable // <--- 1. 标记为可复用组件
export struct PackageCard {
  @State packageInfo: PackageInfo | undefined = undefined;
  
  /**
   * 2. 复用生命周期回调
   * 当组件被复用时触发。在此处更新状态变量,驱动 UI 刷新。
   * 
   * @param params 上层传入的新参数
   */
  aboutToReuse(params: Record<string, Object>) {
    // 快速更新数据
    this.packageInfo = params.packageInfo as PackageInfo;
    
    // 更新其他状态
    if (params.compactModeEnabled !== undefined) {
      this.compactModeEnabled = params.compactModeEnabled as boolean;
    }
    // ...
  }

  build() {
    // 构建复杂的卡片布局...
    // 复用时,这里的节点结构保持不变,仅数据发生变化
  }
}

3. cachedCount:预加载

LazyForEach 默认只加载屏幕可见的项。为了让滑动更流畅,我们可以利用 cachedCount 属性,让列表在屏幕上下方预先加载几个项目。

entry/src/main/ets/pages/PackagesPage.ets

List({ space: 12 }) {
  // 使用 LazyForEach + 自定义数据源
  LazyForEach(this.packagesDataSource, (packageInfo: PackageInfo, index: number) => {
    ListItem() {
      // 使用可复用组件
      PackageCard({
        packageInfo: packageInfo,
        // ...
      })
    }
  }, (item: PackageInfo) => `${item.id}_${item.updateTime}`) // 键值生成器
}
.width('100%')
.cachedCount(5) // <--- 设置缓存数量为 5
  • 原理cachedCount(5) 表示在屏幕视口之外,预先渲染并缓存 5 个列表项。
  • 收益:当用户快速滑动时,即将进入屏幕的卡片已经渲染好了,消除了白屏和闪烁,极大提升了跟手性。

优化效果对比

指标 优化前 (ForEach) 优化后 (LazyForEach + @Reusable) 提升原理
首屏加载时间 慢(加载所有数据) (仅加载首屏可见项) 按需渲染
内存占用 高(随数据量线性增长) 低且稳定(仅维持可见项+缓存项) 对象复用
滑动帧率 掉帧明显 满帧运行 (60/90/120Hz) 避免频繁创建销毁节点
CPU 占用 高(频繁 GC 和布局计算) 复用现有节点结构

总结

在开发复杂列表界面时,"LazyForEach + @Reusable + cachedCount" 是标准的高性能解决方案。

  1. LazyForEach 替代 ForEach,解决内存和首屏问题。
  2. @Reusable 改造子组件,解决滑动掉帧问题。
  3. cachedCount 调节预加载,进一步提升流畅度。

这套方案在 PickupPartner 项目中经受住了大量数据的考验,为用户提供了丝滑的操作体验。

webpack的生命周期与Loader/Plugin

核心对象:

  • compiler: 代表Webpack构建的全局上下文,钩子是全局生命周期钩子
  • compilation: 代表一次构建的编译过程,钩子是编译阶段钩子

生命周期:

整个过程分为【初始化->编译->输出->结束】,一共四个阶段,以下列举一下各个阶段的核心钩子:

  1. 初始化:
  • entryOption 动态新增入口
  1. 编译:
  • compile:等同于vite的config阶段,能够获得最终生成的config文件,如果需要添加一些环境值的占位符,可以在这个阶段介入
compiler.hooks.compiler.tap('插件名',()=>{
    compiler.options.plugins.push(
        new webpack.DefinePlugin({
          'process.env.APP_VERSION': JSON.stringify(PLACEHOLDER),
        })
    );
    console.log('✅ 已注入APP_VERSION占位符:', PLACEHOLDER);
})
  • compilation: 内部包含整个打包构建流程,像是buildModule和optimize等等,实际打包实例已经创建完成,一般这个阶段可以拿到文件最终的contenthash值
compiler.hooks.compilation.tap('ContentHashReplacePlugin', () => {
  this.contentHash = this.generateContentHash();
  console.log('✅ 生成真实contentHash:', this.contentHash);
});
  1. 输出:
  • emit: 异步钩子!必须和callback和tapAsync结合使用!准备将数据写入磁盘,这个阶段可以把环境值的占位符替换成真正的数据
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
  // 遍历所有输出的资源
  for (const [filename, source] of Object.entries(compilation.assets)) {
    if (filename.endsWith('.js')) {
      // 读取原内容,添加版权注释
      const content = source.source();
      const newContent = `/* 版权所有 © 2025 MyProject */\n${content}`;
      // 替换资源内容
      compilation.assets[filename] = {
        source: () => newContent,
        size: () => newContent.length
      };
    }
  }
  callback(); // 异步钩子必须调用 callback 结束
});
  1. 结束:
  • done: 构建完成之后输出一些日志信息,生成报告

tap和tapAsync的区别:

同步钩子和异步钩子调用时候的函数。

tapAsync必须和callback结合使用,用于通知webpack异步流程已经结束,可以继续接下来的流程,不然会卡住。

Plugin: 用于webpack打包生命周期中执行的一些函数,比如css和图片压缩的plugin,无视打包模块的IgnorePlugin

Loader: 用于代码转换,因为浏览器只能解析html,css和js,所以会有各种loader将浏览器没法解析的东西转换成能解析的语言,同时webpack本身无法识别一些文件,也需要Loader做转换,比如css-loader,sass-loader,ts-loader,style-loader,postcss-loader

(1.1) css-loader:因为webpack没办法识别css文件,webpack其实只能理解js和json,所以才会使用需要css-loader去处理css文件引用,将其转为模板字符串

(1.2) style-loader: 将css-loader生成的样式字符串注入到style标签中

(1.3) postcss-loader: 对css做兼容处理,自动增加前缀

执行顺序是postcss-loader->css-loader->style-loader,而配置的时候需要从右向左配置,顺序错误,会导致报错

Loader整体执行阶段类似于洋葱模型,从左到右依次遍历对应的loader,再从右到左执行对应的loader

module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader',  'postcss-loader']
      }
    ]
  }
};

vite自身没有loader,可以通过装插件来配置loader,但是没有原生好用,而且vite很多loader功能都原生内嵌了,而且因为工具特性,vite依赖es模块,而浏览器是完全支持es模块的,无需loader做模块转换,整体上都是依靠plugin进行处理

Vue.js 源码揭秘(一):Vue3 架构总览

Vue.js 源码揭秘(一):Vue3 架构总览

本文从全局视角解析 Vue3 的核心架构,建立源码阅读的整体认知。

一、整体架构

┌─────────────────────────────────────────────────────────────┐
│                      Vue Application                        │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                    Compiler (编译时)                         │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │   Parse     │─►│  Transform  │─►│   Codegen   │         │
│  │  (解析)     │  │   (转换)    │  │  (代码生成)  │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼ render function
┌─────────────────────────────────────────────────────────────┐
│                    Runtime (运行时)                          │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │ Reactivity  │  │   Renderer  │  │  Scheduler  │         │
│  │  (响应式)   │  │   (渲染器)   │  │   (调度器)   │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                      DOM / Platform                         │
└─────────────────────────────────────────────────────────────┘

二、编译时 vs 运行时

2.1 编译时(Compile Time)

// 模板
<template>
  <div>{{ msg }}</div>
</template>

// 编译后的 render 函数
function render(_ctx) {
  return _createElementVNode("div", null, _toDisplayString(_ctx.msg))
}

2.2 运行时(Runtime)

// 运行时执行 render 函数
const vnode = render(ctx)

// patch 到 DOM
patch(null, vnode, container)

三、响应式系统

3.1 核心 API

// reactive - 对象响应式
const state = reactive({ count: 0 })

// ref - 基本类型响应式
const count = ref(0)

// computed - 计算属性
const double = computed(() => count.value * 2)

// effect - 副作用
effect(() => {
  console.log(count.value)
})

3.2 依赖收集与触发

┌─────────────────────────────────────────────────────────────┐
│                    响应式流程                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────┐    get     ┌─────────┐                       │
│   │  Proxy  │ ─────────► │  track  │ ──► 收集当前 effect    │
│   └─────────┘            └─────────┘                       │
│        │                                                    │
│        │ set                                                │
│        ▼                                                    │
│   ┌─────────┐            ┌─────────┐                       │
│   │ trigger │ ─────────► │ effects │ ──► 执行所有 effect    │
│   └─────────┘            └─────────┘                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.3 Dep 与 Effect

// Dep - 依赖容器
class Dep {
  subs: Set<Subscriber>  // 订阅者集合
  
  track() {
    if (activeSub) {
      this.subs.add(activeSub)
    }
  }
  
  trigger() {
    this.subs.forEach(sub => sub.notify())
  }
}

// ReactiveEffect - 副作用
class ReactiveEffect {
  deps: Link[]  // 依赖链表
  
  run() {
    activeSub = this
    return this.fn()
  }
  
  notify() {
    this.scheduler ? this.scheduler() : this.run()
  }
}

四、虚拟 DOM

4.1 VNode 结构

interface VNode {
  type: string | Component    // 节点类型
  props: object | null        // 属性
  children: VNode[] | string  // 子节点
  el: Element | null          // 真实 DOM
  key: string | number        // diff key
  shapeFlag: number           // 节点类型标记
  patchFlag: number           // 优化标记
}

4.2 ShapeFlags

enum ShapeFlags {
  ELEMENT = 1,                    // 普通元素
  FUNCTIONAL_COMPONENT = 1 << 1,  // 函数组件
  STATEFUL_COMPONENT = 1 << 2,    // 有状态组件
  TEXT_CHILDREN = 1 << 3,         // 文本子节点
  ARRAY_CHILDREN = 1 << 4,        // 数组子节点
  SLOTS_CHILDREN = 1 << 5,        // 插槽子节点
  TELEPORT = 1 << 6,              // Teleport
  SUSPENSE = 1 << 7,              // Suspense
  COMPONENT = STATEFUL_COMPONENT | FUNCTIONAL_COMPONENT
}

五、渲染器

5.1 patch 函数

const patch = (n1, n2, container) => {
  if (n1 === n2) return
  
  // 类型不同,卸载旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    unmount(n1)
    n1 = null
  }
  
  const { type, shapeFlag } = n2
  
  switch (type) {
    case Text:
      processText(n1, n2, container)
      break
    case Fragment:
      processFragment(n1, n2, container)
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(n1, n2, container)
      }
  }
}

5.2 组件挂载

const mountComponent = (vnode, container) => {
  // 1. 创建组件实例
  const instance = createComponentInstance(vnode)
  
  // 2. 设置组件(执行 setup)
  setupComponent(instance)
  
  // 3. 设置渲染副作用
  setupRenderEffect(instance, vnode, container)
}

const setupRenderEffect = (instance, vnode, container) => {
  const effect = new ReactiveEffect(() => {
    if (!instance.isMounted) {
      // 首次挂载
      const subTree = instance.render()
      patch(null, subTree, container)
      instance.subTree = subTree
      instance.isMounted = true
    } else {
      // 更新
      const nextTree = instance.render()
      patch(instance.subTree, nextTree, container)
      instance.subTree = nextTree
    }
  })
  
  effect.run()
}

六、调度器

6.1 任务队列

const queue: SchedulerJob[] = []
let isFlushing = false

function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job)
    queueFlush()
  }
}

function queueFlush() {
  if (!isFlushing) {
    isFlushing = true
    Promise.resolve().then(flushJobs)
  }
}

function flushJobs() {
  queue.sort((a, b) => getId(a) - getId(b))
  
  for (const job of queue) {
    job()
  }
  
  queue.length = 0
  isFlushing = false
}

6.2 nextTick

const resolvedPromise = Promise.resolve()

function nextTick(fn?) {
  return fn 
    ? resolvedPromise.then(fn) 
    : resolvedPromise
}

七、组件系统

7.1 组件实例

interface ComponentInternalInstance {
  uid: number                    // 唯一 ID
  type: Component                // 组件定义
  parent: ComponentInternalInstance | null
  
  // 状态
  data: object                   // data()
  props: object                  // props
  setupState: object             // setup() 返回值
  ctx: object                    // 渲染上下文
  
  // 渲染
  render: Function               // render 函数
  subTree: VNode                 // 渲染的 VNode 树
  effect: ReactiveEffect         // 渲染副作用
  
  // 生命周期
  isMounted: boolean
  isUnmounted: boolean
  
  // 生命周期钩子
  bc: Function[] | null          // beforeCreate
  c: Function[] | null           // created
  bm: Function[] | null          // beforeMount
  m: Function[] | null           // mounted
  bu: Function[] | null          // beforeUpdate
  u: Function[] | null           // updated
  bum: Function[] | null         // beforeUnmount
  um: Function[] | null          // unmounted
}

7.2 setup 执行

function setupComponent(instance) {
  const { props, children } = instance.vnode
  
  // 初始化 props
  initProps(instance, props)
  
  // 初始化 slots
  initSlots(instance, children)
  
  // 执行 setup
  const { setup } = instance.type
  if (setup) {
    const setupResult = setup(instance.props, {
      attrs: instance.attrs,
      slots: instance.slots,
      emit: instance.emit,
      expose: instance.expose
    })
    
    handleSetupResult(instance, setupResult)
  }
}

八、编译优化

8.1 PatchFlags

enum PatchFlags {
  TEXT = 1,              // 动态文本
  CLASS = 1 << 1,        // 动态 class
  STYLE = 1 << 2,        // 动态 style
  PROPS = 1 << 3,        // 动态 props
  FULL_PROPS = 1 << 4,   // 有动态 key
  NEED_HYDRATION = 1 << 5,
  STABLE_FRAGMENT = 1 << 6,
  KEYED_FRAGMENT = 1 << 7,
  UNKEYED_FRAGMENT = 1 << 8,
  NEED_PATCH = 1 << 9,
  DYNAMIC_SLOTS = 1 << 10,
  HOISTED = -1,          // 静态提升
  BAIL = -2              // 退出优化
}

8.2 Block Tree

// 编译优化:只追踪动态节点
const _hoisted_1 = createVNode("div", null, "static")

function render() {
  return (openBlock(), createBlock("div", null, [
    _hoisted_1,  // 静态提升
    createVNode("span", null, ctx.msg, PatchFlags.TEXT)  // 动态节点
  ]))
}

九、完整渲染流程

┌─────────────────────────────────────────────────────────────┐
│                    Vue 渲染流程                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. createApp(App).mount('#app')                            │
│        │                                                    │
│        ▼                                                    │
│  2. 创建 VNode                                              │
│        │                                                    │
│        ▼                                                    │
│  3. render(vnode, container)                                │
│        │                                                    │
│        ▼                                                    │
│  4. patch(null, vnode, container)                           │
│        │                                                    │
│        ▼                                                    │
│  5. processComponent → mountComponent                       │
│        │                                                    │
│        ├── createComponentInstance                          │
│        ├── setupComponent (执行 setup)                      │
│        └── setupRenderEffect                                │
│              │                                              │
│              ▼                                              │
│  6. ReactiveEffect.run()                                    │
│        │                                                    │
│        ▼                                                    │
│  7. instance.render() → subTree VNode                       │
│        │                                                    │
│        ▼                                                    │
│  8. patch(null, subTree, container)                         │
│        │                                                    │
│        ▼                                                    │
│  9. 递归处理子节点 → 挂载到 DOM                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

十、小结

Vue3 架构的核心:

  1. 响应式系统:基于 Proxy,依赖收集 + 触发更新
  2. 虚拟 DOM:VNode 描述 UI,patch 算法高效更新
  3. 编译优化:PatchFlags、Block Tree、静态提升
  4. 调度器:批量更新,nextTick 微任务队列
  5. 组件系统:setup + Composition API

📦 源码地址:github.com/vuejs/core

下一篇:响应式系统详解

如果觉得有帮助,欢迎点赞收藏 👍

面试官: “ 请你说一下什么是 ajax ? ”

一、AJAX 核心定义

AJAX 是 Asynchronous JavaScript and XML 的缩写,翻译为 “异步的 JavaScript 和 XML”。

  • 本质:它不是一种新的编程语言,而是一套使用现有技术组合实现的编程方案
  • 核心作用:让 JavaScript 在不刷新整个网页的情况下,异步地与服务器交换数据,实现网页局部更新。
  • 关键特点:异步(请求发送后,页面不用等待服务器响应,仍可正常交互)、局部更新(只更新需要变化的部分,提升用户体验)。

补充:虽然名字里有 XML,但现在实际开发中几乎都用JSON(更轻量、易解析)来传输数据,AJAX 只是沿用了历史名称。

二、AJAX 工作原理

AJAX 的核心是浏览器提供的 XMLHttpRequest 对象(简称 XHR),现代浏览器也提供了更易用的 fetch API。其基本工作流程如下:

  1. 创建 AJAX 请求对象(XHR/fetch);
  2. 配置请求参数(请求方式、URL、是否异步等);
  3. 发送请求到服务器;
  4. 监听服务器响应状态;
  5. 接收服务器返回的数据;
  6. 用 JavaScript 更新网页局部内容。

三、AJAX 代码示例

1. 传统 XHR 方式(兼容所有浏览器)

// 1. 创建XHR对象
const xhr = new XMLHttpRequest();

// 2. 配置请求:请求方式(GET)、URL、是否异步(true)
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);

// 3. 监听请求状态变化(核心)
xhr.onreadystatechange = function() {
    // readyState=4 表示请求完成;status=200 表示响应成功
    if (xhr.readyState === 4 && xhr.status === 200) {
        // 4. 解析服务器返回的JSON数据
        const data = JSON.parse(xhr.responseText);
        // 5. 局部更新页面(比如把数据显示到id为result的元素里)
        document.getElementById('result').innerHTML = `
            <h3>任务标题:${data.title}</h3>
            <p>是否完成:${data.completed ? '是' : '否'}</p>
        `;
    }
};

// 处理请求失败的情况
xhr.onerror = function() {
    document.getElementById('result').innerHTML = '请求失败!';
};

// 4. 发送请求
xhr.send();

2. 现代 fetch 方式(ES6+,更简洁)

fetch 是 AJAX 的现代替代方案,基于 Promise,语法更优雅:

// 1. 发送GET请求
fetch('https://jsonplaceholder.typicode.com/todos/1')
    // 2. 处理响应:先判断是否成功,再解析为JSON
    .then(response => {
        if (!response.ok) {
            throw new Error('请求失败,状态码:' + response.status);
        }
        return response.json();
    })
    // 3. 使用数据更新页面
    .then(data => {
        document.getElementById('result').innerHTML = `
            <h3>任务标题:${data.title}</h3>
            <p>是否完成:${data.completed ? '是' : '否'}</p>
        `;
    })
    // 4. 捕获异常
    .catch(error => {
        document.getElementById('result').innerHTML = error.message;
    });

四、AJAX 的典型应用场景

  • 表单提交(比如登录验证,不用刷新页面就能提示 “用户名密码错误”);
  • 数据分页加载(比如滚动到底部自动加载下一页内容);
  • 搜索框联想(输入关键词实时显示匹配结果);
  • 局部数据刷新(比如网页的点赞、评论功能,点击后直接更新数字)。

五、关键注意点

  1. 同源策略:浏览器默认限制 AJAX 请求只能访问同域名、同端口、同协议的服务器(比如http://localhost:8080不能请求http://baidu.com),跨域需要服务器配置 CORS 或使用代理。
  2. 异步特性:AJAX 请求是异步的,不能在请求发送后立即获取结果,必须在回调函数(onreadystatechange)或 Promise 的then里处理返回数据。

总结

  1. AJAX 是一套实现 “网页异步请求数据、局部更新” 的技术方案,核心是XMLHttpRequest对象(或现代的fetch)。
  2. 核心优势:无需刷新整个页面,提升用户体验,实现网页与服务器的异步数据交互。
  3. 现代开发中,fetch(结合 Promise/async-await)已逐步替代传统 XHR,是 AJAX 的主流实现方式。

shadcn-ui 的 Radix Dialog 这两个警告到底在说什么?为什么会报?怎么修?

问题

最近在项目里遇到两个来自 Radix Dialog 的控制台提示:

image.png

它们不是 “功能错误”,但属于 无障碍(a11y)级别的警告:Radix 在开发环境主动提醒你对话框缺少 “可被屏幕阅读器正确理解” 的关键语义。

本文基于 coco-app(React 18 + TS + Tailwind + shadcn-ui)里真实踩坑的修复过程总结。


1. 背景:Radix Dialog 需要什么语义?

一个可访问的 Dialog 至少需要:

  • 可访问名称(accessible name):告诉读屏软件 “这个弹窗叫啥”
    • 对应:<DialogTitle />(内部映射到 aria-labelledby
  • 可访问描述(accessible description):告诉读屏软件 “这个弹窗在说啥/要用户做啥”
    • 对应:<DialogDescription />(内部映射到 aria-describedby

Radix 的示例结构也是这个顺序(Title + Description + 内容 + Close 等)。


2. 报错 1:为什么必须要 DialogTitle

2.1 报错含义

DialogContent requires a DialogTitle...

意思是:你的 <DialogContent /> 里没有提供标题,导致 Dialog 没有可访问名称。读屏用户打开弹窗时,不知道这是 “更新提示” 还是 “删除确认”。

2.2 coco-app 的修复方式

我们在 UpdateApp 弹窗中增加了一个 “对视觉隐藏、对读屏可见” 的标题:

  • 文件:src/components/UpdateApp/index.tsx:164
  • 代码形态:
<DialogTitle className="sr-only">{t("update.title")}</DialogTitle>

为什么用 sr-only

  • Tailwind 的 sr-only 能达到UI 不变,读屏可读的效果(有些 shadcn 模板会有现成的 VisuallyHidden 组件)。

3. 报错 2:为什么必须要 DialogDescription / aria-describedby

3.1 报错含义

Warning: Missing Description or aria-describedby={undefined} for {DialogContent}.

意思是:Dialog 没有可访问描述,或者你显式把 aria-describedby 置为 undefined 但又没有描述节点关联上。

Radix 的逻辑大致是:

  • 你提供 <DialogDescription />:Radix 自动把它的 id 绑定到 aria-describedby
  • 你不提供 <DialogDescription />:Radix 会提醒你 “缺描述”,避免读屏用户只听到标题但不知道要做什么

3.2 coco-app 的修复方式

我们把原先展示更新说明的 div 替换为 DialogDescription(UI class 不变,只换组件语义):

  • 文件:src/components/UpdateApp/index.tsx:179-193
  • 代码形态:
<DialogDescription className="text-sm leading-5 py-2 text-foreground text-center">
  {updateInfo ? ... : t("update.date")}
</DialogDescription>

这样 Radix 就能自动生成正确的 aria-describedby,warning 消失。


4. “洁癖”:它不是 bug,但是就是在控制台报红了...

shadcn-ui 的 Dialog 本质是对 Radix Dialog 的一层轻封装(项目里对应 src/components/ui/dialog.tsx),它不会强制你必须写 Title/Description。

4.1 为什么更容易踩坑?

因为 UI 上你可能觉得:

  • 我已经有图标(logo)
  • 我已经有一段说明文字(div/p)
  • 我不想显示标题

视觉上满足 ≠ 语义上满足。读屏依赖的是 aria-labelledby/aria-describedby 的关联,而不是你页面里有没有一个看起来像标题的 div


5. 最推荐的写法、更标准的写法

5.1 标题不想显示:用 sr-only

<DialogTitle className="sr-only">{t("xxx.title")}</DialogTitle>

5.2 描述存在:用 DialogDescription

<DialogDescription className="sr-only">
  {t("xxx.description")}
</DialogDescription>

是否一定要隐藏 Description?

  • 不一定。像更新弹窗这种 “正文就是描述”,直接用 DialogDescription 包住正文最自然。

6. 也可以手动 aria-describedby 吗?可以,但更容易出错

你当然可以自己写:

<DialogContent aria-describedby="my-desc">
  <div id="my-desc">...</div>
</DialogContent>

但坑在于:

  • id 可能忘了写 / 重复
  • 条件渲染导致节点不在 DOM(aria 指向不存在的 id)
  • 重构时删掉了 id 没发现
  • 多弹窗复用组件时 id 冲突

所以在 shadcn/Radix 体系里,优先使用 DialogTitle / DialogDescription 让 Radix 负责关联更稳。


7. 真正的“坑点清单”(建议以后 review 的时候对照)

  • 只写了 <DialogContent />,把标题/正文都塞进普通 div
  • 标题用视觉元素表达(比如 logo 或大号文本),但没用 DialogTitle
  • 描述是条件渲染的,导致有时没有 DialogDescription
  • 想隐藏标题却直接不写(应该隐藏而不是删除)

8. coco-app 里的落地实践(最终结论)

在 coco-app 里,我们最终遵循了一个简单规则:

  • 每个 DialogContent 内部都应该有且只有一个语义标题:DialogTitle
  • 只要弹窗有 “说明性文本”,优先用 DialogDescription 承载
  • 如果 UI 不需要展示标题/描述:用 sr-only 隐藏(而不是不写)

案例+图解带你一文读懂Svg、Canvas、Css、Js动画🔥🔥(4k+字)

引言

动画在前端开发中扮演着重要的角色。它不仅可以提升用户体验,还可以使界面更加生动和有趣。在这篇文章中,我们将深入探讨前端动画的各种实现方式,包括 CSS 动画、JavaScript 动画、SVG 动画等。我们还将讨论一些触发动画的方式和动画在用户体验中的最佳实践。

前端动画分类

  • CSS 动画

    • CSS Transition
      CSS 过渡,属于补间动画,即设置关键帧的初始状态,然后在另一个关键帧改变这个状态,比如大小、颜色、透明度等,浏览器将自动根据二者之间帧的值创建的动画。

    • CSS Animation
      CSS 动画,可以理解是 CSS Transition 的加强版,它既可以实现 补间动画 的动画效果,也可以使其以 逐帧动画 的方式进行绘制。

  • SVG 动画

    • SVG 动画用于矢量图形,提供了高质量的动画效果,常用于图标和图形动画。可以使用 SMIL 在SVG中定义动画。同样的也可以使用css或者js来控制svg动画。
  • Canvas 动画

    • 通过结合使用 requestAnimationFrame、路径和变换等技术对画布的元素进行擦除和重新绘制,可以实现复杂的动画效果。另外Canvas还可以用于绘制复杂的背景或静态内容,从而减少每帧的绘制工作量。

    • 可以参考我的一篇关于canvas制作动画的文章:用Canvas绘制一个高可配置的圆形进度条

  • JS 动画

    • setTimeout / setInterval / requestAnimationFrame
      setTimeoutsetInterval 这两个 API 设定的时间会因为浏览器当前工作负载而有所偏差,而且无法与浏览器的绘制帧保持同步。所以才有了 与浏览器的绘制帧同步 的原生 API requestAnimationFrame,以取代 setTimeoutsetInterval 实现动画。

    • Web Animations API
      浏览器动画 API,通过 JavaScript 操作。这些 API 被设计成 CSS TransitionCSS Animation 的接口,很容易通过 JS 的方式实现 CSS 动画,它是对动画化的支持最有效的方式之一。

css 动画

css过渡动画 transition

注意

由于浏览器是根据样式差异化的两帧自动计算并过渡,所以 transition 只支持可识别中间值的属性 (如大小、颜色、位置、透明度等),而如 display 属性则不支持。

语法定义

  • transition-property: 指定哪个或哪些 CSS 属性用于过渡。只有指定的属性才会在过渡中发生动画,其他属性仍如通常那样瞬间变化。

  • transition-duration: 指定过渡的时长。你可以为所有属性指定一个值,或者指定多个值,或者为每个属性指定不同的时长。

  • transition-timing-function: 指定一个缓动函数,定义属性值怎么变化。常见的缓动函数是一个三次贝塞尔曲线 ( cubic-bezier(<x1>, <y1>, <x2>, <y2>) )。当然也可以选择关键字

    • linearcubic-bezier(0.0, 0.0, 1.0, 1.0)
    • easecubic-bezier(0.25, 0.1, 0.25, 1.0)
    • ease-incubic-bezier(0.42, 0.0, 1.0, 1.0)
    • ease-outcubic-bezier(0.0, 0.0, 0.58, 1.0)
    • ease-in-outcubic-bezier(0.42, 0.0, 0.58, 1.0)
  • transition-delay: 指定延迟,即属性开始变化时与过渡开始发生时之间的时长。

代码示例

  /* 单条 简写形式 */
  transition: 
    <property> <duration> <timing-function> <delay>;
  
  
  /* 多条 简写形式 */
  transition: 
    <property> <duration> <timing-function> <delay>,
    <property> <duration> <timing-function> <delay>,
    ...;


  /* 单条 子属性形式 */
  transition-property: <property-name>;
  transition-duration: <duration-time>;
  transition-timing-function: <timing-function>;
  transition-delay: <duration-time>;
  
  
  /* 多条 子属性形式 */
  transition-property: <property-name> [, <property-name>, ...];
  transition-duration: <duration-time> [, <duration-time>, ...];
  transition-timing-function: [, <cubic-bezier>, ...];
  transition-delay: [, <duration-time>, ...];
  
  
  // 如果任意属性值列表的长度比其他属性值列表要短,则其中的值会重复使用以便匹配
  
  // 如果某个属性的值列表长于 `transition-property` 的属性,则将被截短
  

css过渡动画 触发方式

1. 伪类触发(:hover、:focus、:active等)
.button {
  background-color: blue;
  transition: background-color 0.3s ease;
}

.button:hover {
  background-color: red;
}

2. 类名切换(通过JS动态切换类名来触发过渡效果)
<button id="toggleButton">Toggle</button>
<div id="box" class="box"></div>

<style>
  .box {
    width: 100px;
    height: 100px;
    background-color: blue;
    transition: background-color 0.3s ease;
  }

  .box.active {
    background-color: red;
  }
</style>

<script>
  document.getElementById('toggleButton').addEventListener('click', function() {
    document.getElementById('box').classList.toggle('active');
  });
</script>

3. 属性变化
<button id="toggleButton">Toggle</button>
<div id="box" class="box"></div>

<style>
  .box {
    width: 100px;
    height: 100px;
    background-color: blue;
    transition: background-color 0.3s ease;
  }
</style>

<script>
  document.getElementById('toggleButton').addEventListener('click', function() {
    const box = document.getElementById('box');
    box.style.backgroundColor = box.style.backgroundColor === 'red' ? 'blue' : 'red';
  });
</script>

4. 伪元素触发(通过伪元素如::before::after的状态变化来触发过渡效果。)
<div class="box"></div>

<style>
  .box {
    width: 100px;
    height: 100px;
    position: relative;
  }

  .box::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: blue;
    transition: background-color 0.3s ease;
  }

  .box:hover::before {
    background-color: red;
  }
</style>

css动画 animation

注意

CSS Animation 具备了对 关键帧和循环次数 的自定义能力。CSS Animation 在实现像 CSS Transition 补间动画 效果时,还可以在起始帧和结束帧之间自定义中间帧,使得动画更加平滑过渡的同时,对动画有了更好的控制和自定义能力。

语法定义

先创建一个带名称的 @keyframes 规则,以便后续使用 animation-name 属性将动画同其关键帧声明进行匹配。每个规则包含多个关键帧,也就是一段样式块语句,每个关键帧有一个百分比值作为名称,代表在动画进行中,在哪个阶段触发这个帧所包含的样式。

  • animation-name:指定一个或多个 @keyframes 的名称,描述了要应用于元素的动画。多个 @keyframes 以逗号分隔。

  • animation-duration:设置动画完成一个动画周期所需的时间,需要指定单位,如 1s500ms

  • animation-delay:指定执行动画之前的等待时间。动画可以稍后开始、立即从开头开始、立即在动画中途播放 (如 -1s) 。其中 -1s 意思是动画立即从 1s 处开始。

  • animation-iteration-count:设置动画序列在停止前应播放的次数,有效值 0、正整数、正小数、无限循环 infinite

  • animation-direction:设置动画是正向播放 normal、反向播放 reverse、正向交替播放 alternate、反向交替播放 alternate-reverse

  • animation-play-state:设置动画是运行还是暂停,有效值 runningpaused

  • animation-fill-mode:设置 CSS 动画在执行之前和之后如何将样式应用于其目标,有效值如下:

    • none:当动画未执行时,动画将不会将任何样式应用于目标,而是已经赋予给该元素的 CSS 规则来显示该元素。这是默认值
    • forwards:目标将保留由执行期间遇到的最后一个关键帧计算值。
    • backwards:动画将在应用于目标时立即应用第一个关键帧中定义的值。 animation-timing-function:设置动画在每个周期的持续时间内如何进行,主要是如下两种函数:
  • cubic-bezier 三次贝塞尔曲线 ( cubic-bezier(<x1>, <y1>, <x2>, <y2>) ),以实现 补间动画 效果。

  • steps 是一个分段的阶跃函数,,以实现 逐帧动画。n 相当于单次动画的帧数,每帧动画的时间是均等的 (steps(n, <jumpterm>)),其中 jumpterm (默认值 end) 含义如下:

    1. jump-start:在起始位置阶跃,n=2 ⇒ 50% 100%; (100 / 2)
    2. jump-end:在结束位置阶跃, n=4 ⇒ 0% 25% 50% 75%; (100 / 4)
    3. jump-none:起止位置均无跳跃,n=5 ⇒ 0% 25% 50% 75% 100%; (100 / 4)
    4. jump-both:起止位置均有跳跃 n=3 ⇒ 25% 50% 75%; (100 / 4)
    5. start:等同 jump-start
    6. end:等同 jump-end
    7. step-start:等同 steps(1, jump-start)
    8. step-end:等同 steps(1, jump-end)
  /* animation 声明样式顺序 */ 
  /* animation-duration */
  /* animation-easing-function */
  /* animation-delay */ 
  /* animation-iteration-count */
  /* animation-direction */
  /* animation-fill-mode */
  /* animation-play-state */
  /* animation-name */
  animation: 3s ease-in 1s 2 reverse both paused slidein; 

  
  /* animation - duration | easing-function | delay | name */
  animation: 3s linear 1s slidein;
  
  
  /* more animations - duration | easing-function | delay | name */
  animation: 3s linear slidein, 3s ease-out 5s slideout;

 
  /* animation-name */
  animation-name: none;
  animation-name: animate1;
  animation-name: animate1, animate2;
  
  
  /* animation-timing-function */
  animation-timing-function: ease;
  animation-timing-function: step-start;
  animation-timing-function: cubic-bezier(0.1, 0.7, 1, 0.1);
  animation-timing-function: ease, step-start, cubic-bezier(0.1, 0.7, 1, 0.1);
  

css animation 动画触发方式

和css transition 触发动画方式相似

此外还可以增加一个图层,专门用于制作动画效果。

例如:鼠标在点击按钮时,会有涟漪动画。


// 涟漪动画定义
@keyframes ripple {
  0% {
    transform: scale(0);
    opacity: 1;
  }

  to {
    transform: scale(4);
    opacity: 0;
  }
}

// 图层动画 css
.ripple {
  position: absolute;
  border-radius: 50%;
  background: rgba(8, 7, 7, 0.2);
  pointer-events: none;

  animation: ripple 0.6s linear;
}

// 制作动画  这样每次点击按钮 就会生成动画,动画结束便销毁动画元素
const makeAnimate = (e: React.MouseEvent) => {
  const dom = e.currentTarget;
  const rect = dom.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  const size = 100;

  const ripple = document.createElement('span');
  ripple.classList.add('ripple');
  ripple.style.width = `${size}px`;
  ripple.style.height = `${size}px`;
  ripple.style.left = `${x - size / 2}px`;
  ripple.style.top = `${y - size / 2}px`;
  dom.appendChild(ripple);

  ripple.addEventListener('animationend', () => {
    ripple.remove();
  });
};


svg 动画

常用的 SMIL 动画元素

  • <animate>:用于动画化单个属性。
  • <animateTransform>:用于动画化变换属性,如旋转、缩放、平移等。
  • <animateMotion>:用于沿着路径动画化元素。(路径动画)
  • <set>:用于在指定时间点设置属性值。
<svg width="100" height="100">
  <circle cx="50" cy="50" r="40" fill="red">
    <animate attributeName="cx" from="50" to="150" dur="2s" repeatCount="indefinite" />
  </circle>
</svg>

svg 描边动画

SVG动画的路径实现主要依赖属性:stroke(描边)和 fill(填充)。

  • stroke:定义svg的轮廓线。常用css属性有: stroke-dasharray(描边的样式),stroke-dashoffset(起始位置),stroke-color(描边的颜色),stroke-opacity(描边的透明度),stroke-linecap(描边端点形状)等。
  • fill:定义svg内部颜色或图案 ,常用css属性有fill-opacity(定义填充的透明度), fill-rule(定义填充规则)等。

stroke-dasharray (定义虚线的长度和间隔)

提供一个奇数或偶数数列,其中数与数之间用逗号或空格隔开,用来指定短划线和缺口的长度,并重复。 如果是偶数数列,则一个表示短线长度,一个表示缺口长度。 如果是奇数数列,将奇数数列复制一个变成偶数数列,然后按照短线,缺口的顺序绘制。

(偶数数列) stroke-dasharray="5, 5" x1="10" y1="10" x2="190" y2="10"表示从坐标(10,10)到(200,10)这条水平线上,短划线和缺口都为5个px

image.png

(奇数数列) stroke-dasharray="20 10 5" x1="10" y1="10" x2="190" y2="10"表示从坐标(10,10)到(200,10)这条水平线上,短划线和缺口按照20 10 5 20 10 5的顺序排列。

image.png

stroke-dashoffset (定义虚线的起始位置)

stroke-dashoffset 属性用于指定路径开始的距离(正值向左偏移,负值向右偏移)

image.png

描边动画示例:

code.juejin.cn/pen/7391734…

js 动画

setTimeout / setInterval API

设定定时器,通过周期性的触发重复执行绘制动画的函数,来实现 “逐帧动画” 的效果。

  • 优势

    1. 具有很好的浏览器兼容性
  • 劣势

    1. 只能接近设备屏幕刷新率,无法做到和浏览器同步,所以可能会存在卡顿、丢帧、抖动的现象
    2. 由于浏览器单线程机制,存在队列中回调函数被阻塞的可能,所以无法保证每一次调用的时间间隔都相同,某次回调可能会被跳过,导致跳帧。

requestAnimationFrame API

为了弥补 setTimeout / setInterval 在动画方面的不足,浏览器提供了为动画而生的 API,它可以让 DOM 动画、Canvas 动画、 SVG 动画等有一个统一的刷新机制,随着浏览器的屏幕刷新,统一绘制动画帧。

  let id = null
  
  // 动画函数
  const draw = () => {
    /* 动画绘制... */
  }
  
  const start = () => {
    draw()
    cancelAnimationFrame(id)
    id = requestAnimationFrame(start)
  }
  const stop = () => { cancelAnimationFrame(id) }
  
  • 优势

    1. 由系统来决定回调函数的执行时机, 它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次, 这样就不会引起丢帧现象, 也不会导致动画出现卡顿的问题。
    2. 在运行时浏览器会自动优化方法的调用,并且如果页面不是激活状态下的话,动画会自动暂停,有效节省了CPU的开销。
  • 不足

    1. 同 setTimeout/setInterval 一样,它是以逐帧动画的方式进行绘制,无法做到像 CSS 动画,让游览器自动根据两帧之间的差异创建插值,以实现补间动画的过渡效果。

Web Animations API

  1. requestAnimationFramesetTimeout/setInterval 都是以逐帧绘制的方式实现动画, 而 Animations API 不仅可以 “逐帧动画”,还可以实现 “补间动画” 的效果。
  2. CSS 动画有一定的局限性,需要事先预设动画样式,而且无法与 JS 进行交互。相比之下,Animations API 可以随时定义并使用动画,自然是更加灵活方便。

参考文档: developer.mozilla.org/zh-CN/docs/…

语法示例:

  const element = document.getElementById("container");

  const animation = element.animate(
    [
      { transform: "translateY(0%)" },
      { transform: "translateY(100%)" },
    ],
    { duration: 3000, fill: "forwards" }
  );
  

代码示例:

code.juejin.cn/pen/7391799…

关于Flip动画

浏览器计算位置很快,绘制可能很慢。利用浏览器强大的计算能力,获取动画的起止状态,接着单独开启一个线程做动画。这样触发布局更新的操作,只会发生在一帧时间内,剩下的动画跑在单独的线程上,会更流畅。

介绍下FLIP 。

  1. F 代表 First,也就是动画的开始状态。
  2. L 代表 Last,代表动画结束状态。
  3. I 代表 Invert,也就是状态反转,使用 transform 等属性,创建单独的图层,并将元素状态反转回去。
  4. P 代表 Play,播放动画。

示例代码:

其中,在初始帧中,应用逆变换(translate 和 scale),将元素从其最终状态逆变换到初始状态。

最后一帧 transform: "none" 的作用是将元素的变换属性重置为其最终状态。具体来说,transform: "none" 表示不应用任何变换,这意味着元素将恢复到由 CSS 设置的最终位置和大小。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>FLIP Animation Example</title>
  <style>
    #box {
      width: 100px;
      height: 100px;
      background-color: #4caf50;
      position: absolute;
    }
  </style>
</head>
<body>
  <div id="box"></div>
  <button id="animateButton" style="margin-top: 300px;">Animate</button>

  <script>
    const box = document.getElementById('box');
    const button = document.getElementById('animateButton');

    button.addEventListener('click', () => {
      // First: 记录初始状态
      const first = box.getBoundingClientRect();

      // 修改元素的位置
      box.style.top = `${300}px`;
      box.style.left = `${300}px`;

      // Last: 记录最终状态
      const last = box.getBoundingClientRect();

      // Invert: 计算初始状态和最终状态之间的变换
      const deltaX = first.left - last.left;
      const deltaY = first.top - last.top;
      const deltaW = first.width / last.width;
      const deltaH = first.height / last.height;


      // 应用 FLIP 动画
      box.animate(
        [
          {
            transformOrigin: "top left",
            transform: `
            translate(${deltaX}px, ${deltaY}px)
            scale(${deltaW}, ${deltaH})`,
          },
          {
            transformOrigin: "top left",
            transform: "none",
          },
        ],
        {
          duration: 300,
          easing: "ease-in-out",
          fill: "both",
        }
      );
    });
  </script>
</body>
</html>

React下拉框接口请求hook封装

前言

记录一下公司 下拉框封装的 接口hook。主要是支持

  • 初始化或者指定状态下请求接口
  • 防抖搜索
  • 支持不同类型的接口(get、post)

代码

主体 hook

import { IObj, IOption, TRecord } from "@/utils/interface";
import { to } from "@/utils/tools";
import { useMount } from "@quarkunlimit/react-hooks";
import { debounce } from "lodash";
import { useEffect, useRef, useState } from "react";
import {
  IUseMountFetchDataNewProps,
  IUseMountFetchDataResult,
} from "./interface";

/**
 * 初始化请求下拉框接口
 * @param props
 * @returns
 */
export const useSearchSelectFetchNew = (
  props: IUseMountFetchDataNewProps
): IUseMountFetchDataResult => {
  const {
    fetchDataApi,
    request,
    searchParamKey,
    transformOptions,
    refreshFetch,
    initFetch = true,
    needSetExtarData = false,
  } = props;

  const [data, setData] = useState<IOption[]>([]);
  const isMount = useRef<boolean>(false);
  const originData = useRef<IOption[]>([]);

  const fetchData = async (otherRequest?: TRecord) => {
    let newRequst: IObj = {}
    newRequst = {
        page: 1,
        size: 100,
        ...request,
        ...otherRequest
    }
    const [err, res] = await to(
      (() => {
        return fetchDataApi(newRequst);
      })()
    );
    if (!(err || !res)) {
      const data = transformOptions(res);
      setData(data);
      if (!isMount.current) {
        originData.current = data;
        isMount.current = true;
      }
    }
  };

  const onSearch = debounce((value: string) => {
    if (value.trim()) {
      fetchData({
        [searchParamKey]: value,
      });
    } else {
      setData(originData.current);
    }
  }, 500);

  useEffect(() => {
    if (refreshFetch) {
      fetchData();
    }
  }, [refreshFetch]);

  useMount(() => {
    if (initFetch) {
      fetchData();
    }
  });

  const setExtarData = (list: IOption[]) => {
    const newData: IOption[] = [];
    const idSet = new Set<string>();
    for (let item of data) {
      newData.push(item);
      idSet.add(item.value);
    }

    for (let item of list) {
      if (typeof item !== "object") {
        continue;
      }
      if (idSet.has(item?.value)) {
        continue;
      }
      idSet.add(item.value);
      newData.push(item);
    }
    setData(newData);
  };

  return {
    options: data,
    onSearch,
    onFocus: () => fetchData(),
    ...(needSetExtarData ? { setExtarData } : {}),
  };
};

类型定义

import { SelectProps } from "antd";

export interface IOption {
  label: string;
  value: string;
  [key: string]: any;
}

export interface IUseMountFetchDataNewProps {
  /**@param 接口Api */
  fetchDataApi: (...arg: any) => Promise<IApiData>;
  /**@param 初始化时接口额外参数 */
  request?: TRecord & { enableFlag?: boolean; dataScopeEnableFlag?: boolean };
  /**@param 搜索时的key */
  searchParamKey: string;
  /**@function 转换数据源为options */
  transformOptions: (res: IApiData) => IOption[];
  /**@param 满足某种条件时加载数据,使用时请将 initFetch 设置为 false  */
  refreshFetch?: boolean;
  /**@param 是否挂载时默认加载数据 */
  initFetch?: boolean;
  /** @param 是否需要setExtarData */
  needSetExtarData?: boolean;
}

export interface IUseMountFetchDataResult extends SelectProps {
  /** @param 下拉框选项 */
  options: IOption[];
  /** @function 下拉框搜索 */
  onSearch: (value: string) => void;
  /** @function 手动添加额外的数据源 */
  setExtarData?: (list: IOption[]) => void;
}

使用


/** @function 获取字典值集合 */
export const sys_dict_value = (params?: IReqSysDictValueDictValue) => {
  return Service.get("/api/business/v1/sys-dict-value/dict-value", {
    params,
  }) as Promise<IResDetail<IResSysDictValueDictValue[]>>;
};

export interface IReqSysDictValueDictValue {
  /** @param 字典编码 */
  dictCode?: string;
  /** @param 字典编码集合 */
  dictCodeList?: string;
  /** @param 字典名称 */
  dictName?: string;
  /** @param 字典值 */
  dictValue?: string;
  /** @param 备注 */
  memo?: string;
}

export interface IResSysDictValueDictValue {
  dictCode: string;
  dictName: string;
  dictValue: string;
  enableFlag: boolean;
  id: string;
  memo: string;
  color: string;
  sortNum: number;
}

const TXDiplomaRadio = function TXDiplomaRadio_({
  initFetch = true,
  refreshFetch = false,
  extraReq = {},
  ...rest
}: ITXDiplomaRadioProps) {
  const { options } = useSearchSelectFetchNew({
    fetchDataApi: sys_dict_value(你的接口,返回promise),
    request: {
      dictCode: "diploma",
      ...extraReq,
    },
    initFetch,
    refreshFetch,
    searchParamKey: "dictName",
    transformOptions: (res) => {
      return res?.data?.map((x: IResSysDictValueDictValue) => ({
        label: x.dictName,
        value: x.dictValue,
      }));
    },
  });

  return <Radio.Group options={options} {...rest} />;
};

export default TXDiplomaRadio;
❌