babel降级|>, Object.groupBy

0 阅读12分钟

|> 管道操作符

是ES2025提出的语法,可以用一种更清晰的,可读性更强的链式方式传递值,非常直观,可以用来代替嵌套多层调用, 使用管道操作符配合我们数组方法用于处理数据其实很合适, 管道操作符可以以链式的直观的方式描述数据每一步是怎么被转换的, 可以看下面的一个例子

const students = [
  { name: '张三', score: 45 },
  { name: '李四', score: 78 },
  { name: '王五', score: 92 },
  { name: '赵六', score: 58 }
];
需求:选出成绩  60 的学生,提取其姓名,将姓名转换为大写,然后用逗号连接成一个字符串。

使用管道操作符的话可以写为如下模式


const result = students
  |> _.filter(%, s => s.score >= 60.)
  |> _.map(%, s => s.name)
  |> _.map(%, name => name.toUpperCase())
  |> _.join(%, ',');

但是还没有浏览器支持这个表达式, babel提供了插件支持,允许你使用管道操作符,我们也可以利用babel只做类型支持,我们自己实现降级管道操作符的降级处理

const add = x => x + 1;
const sub = x => x - 1;
let num = 5 |> add(%) 
            |> (x => x *2)(%)
            |> sub(%);
//在这个例子中,我们最终的转换要求转变为如下代码
const add = x => x + 1;
const sub = x => x - 1;
let num = sub((x => x * 2)(add(5)));

babel允许我们编写自定义的插件, 比如@babel/core库在转换代码的时候允许你传毒plugins参数,我们可以在这个参数中传入我们自己的插件

const ans = await babel.transformAsync(codeStr, {
  plugins: [
    ["@babel/plugin-syntax-pipeline-operator", {proposal: "hack", topicToken: "%"}],
    myPlugin,
  ],
});

babel的插件其实就是一个函数,在这个函数中,参数是一个对象,我们常用这个对象中的types属性,这个属性也是一个对象,它是对我们js AST操作的工具,为操作ast树提供了很多好用的操作,balbel提供了一个库 @babe;/types用于操作ast树 , 这里的t和 这个工具库导出的内容是一样的, 比如

import * as babelTypes from "@babel/Types"


const myPlugin = ({types: t}) => {
....
}
这里的t和babelTypes是一样的! 推荐使用t

我们要求这个插件函数返回一个对象这个对象包括一个属性名为visitor的对象,意思是这个是整个babel插件的入口,可以在这里注册一些函数,函数名和ast的节点要求一致, 当babel遍历到这个节点时,就会执行你在这里注册的函数, 看下面的例子

const myPlugin = () => {
  return {
  visitor: {
    BinaryExpression() {
      console.log("VISITED!!!");
    }
  }
  }
}

//这里的BinnaryExpression其实是二进制表达式的意思
//当babel遍历到ast树上的二进制表达式节点的时候就会执行这个函数

假如我有如下代码

 a * b
 a + b
 c + k
 a |> (x => x + 1)(%)

那么根据我们上面的描述可以直到会打印出5次VISITED!!!,因为这些都是二元制表达式

可以看到确实是打印了5次。

Snipaste_2026-05-19_18-18-52.png

我们在插件里写BinaryExpression其实就是想要在babel遍历到二元表达式的时候进入该函数, 这个函数有一个参数,按照惯性思维其实很容易就认为参数应该是当前匹配到的ast节点,其实不是,给的参数其实是一个对象,babel管这个对象叫做path, 按babel描述的,这个path其实是一个包装对象,它封装了很多函数, 这些函数是去用于方便的访问和修改ast树的, 其实有点类似于DOM树中提供append, prepend,remove这类操作dom节点的函数,而且babel提供的path不仅是封装了节点操作的方法,它还封装了在书中的位置信息,父节点的信息,尤其是它有.scope属性可以让你访问到当前节点所处的作用域,纯ast树是没有办法把作用域作为一个节点的对吧,因为它不是表达式,也不是函数声明,也不是导入导出声明这些具体的东西,作用域是一种规则, 没有办法作为一个节点,所以path.scope是在对ast树解析后附带的一个信息,

在我们编写|> 管道操作符降级代码前,我们还需要搞清楚babel的节点概念,请查看下面一个例子

Snipaste_2026-05-19_18-36-12.png

可以看到babel把我们的import _ from "lodash"描述为一个ImportDEcalration节点,也就是一个导入声明节点,而下面的eport default ...被认为是一个ExportDefaultDeclaration, 是一个默认导出声明, 我们展开这个ExportDefaultDeclaration节点,发现它里面有一个declaration属性,这个属性是一个FunctionDeclaration, 是一个函数声明节点

Snipaste_2026-05-19_18-42-06.png

而函数声明还有一个body属性,这个属性是一个BlockStatement节点,我们可以很容易联想到这其实就是一个语句块,是{}花括号在ast中的化身, 而花括号有body属性,这个属性是一个数组,包含所有的语句块,可以看到其中包含一个ReturnStatement,表示返回语句, 最后呢就是我们的VariableDeclaration, 是一个变量声明,它内部有一个kind属性,这个kind属性只能取值"var" | "const" | "let"这三个之一,经过上述分析我们可以发现babel的ast树是很语义化的,基于上述和babel插件文档,我们就可以为管道操作符写降级代码了,

import babel from "@babel/core";
import fs from "fs";
import myPlugin from "./plugin.js";
import { createRequire } from "node:module";
// const _require = createRequire(import.meta.url);
// const syncPipe = _require("@babel/plugin-syntax-pipeline-operator");

const codeStr = fs.readFileSync("./code.js", "utf-8");

const ans = await babel.transformAsync(codeStr, {
  plugins: [
    ["@babel/plugin-syntax-pipeline-operator", {proposal: "hack", topicToken: "%"}],
    myPlugin,
  ],
});

fs.writeFileSync("./code-transform.js", ans?.code || "", "utf-8");

这里的@babel/plugin-syntax-pipeline-operator表示的是让babel支持语法上解析|> ,但是不做实际解析,不写我们的插件的化,babel默认不会处理|>, propsal填入hack, 因为管道操作符有两种风格提案,一种是hack风格,它要求在管道操作符中写一个占位符号,["^^", "@@", "^", "%", "#"]这些它都是支持的, 还有一种就是minimal它就不要求你写占位符号,但是这样就失去了灵活性,你无法这么使用fn(1, %)按需求往函数将%作为指定位置填入,我们选择hack风格,选择占位符号也就是topicToken填入"%", 我们可以先在babel演练场中分析管道操作符是什么ast结构

Snipaste_2026-05-19_19-01-37.png 我们可以看到,这一行被解析为ExpressionStatement表达式语句,其有一个left 节点是NumbericLiteral数字节点,operator表示二元操作符的类型,在这里是|> , 而right节点就被解析成又一个BinaryExpression节点,首先写出我们的函数

import { type PluginObj } from "@babel/core";
import * as babelTypes from "@babel/types";

const myPlugin = ({types: t}: {types: typeof babelTypes}): PluginObj => {
  return {
    visitor: {
     BinaryExpression(path) {
      if (path.node.operator !== "|>") return false;
      console.log(path.node, "e");
     }
  }
  }
}

export default myPlugin

这里的OluginObj来自@babel/core,表示返回一个插件对象, t和babelTypes是一样的,我们上面说过,它相当于ast的'lodash', 所以这里用babelTypes做为t的类型以此获得类型支持,

我们需要在遇到第一个管道操作符的时候,就遍历这个节点,然后把left节点替换为right右节点中的%标识符,当right也是一个|>的时候,我们就继续递归遍历,可以看出,这是一个深度优先遍历, 我们要做的操作类似于下面的示例图

1 |> add(%) |> sub(%) |> String(%)
1 |> add(%) |> string(sub(%))
1 |> string(sub(add(%)))
string(sub(add(1)));
import { type PluginObj } from "@babel/core";
import * as babelTypes from "@babel/types";
import type * as traverse from "@babel/traverse";





const replaceTopic = (node: any, replacement: traverse.Node):any => {
  if (babelTypes.isTopicReference(node)) {  //为什么判断是否是TopicReference, 因为 babel把%认作为一个TopicReference节点, 所以我们istopicReference判断
    return babelTypes.cloneNode(replacement);
  }

  if (Array.isArray(node)) {
  //比如节点是一个函数调用,那么它的arguemnt就是一个节点数组,所以这里我们需要判断node是不是一个数组,然后递归的遍历它返回
    return node.map(n => replaceTopic(n, replacement));
  }

  if (babelTypes.isNode(node)) {
    //如果是node
    const newNode:any = {};
    //这里用Object.keys而不直接使用.arguments然后遍历这个数组从里面找topicReference是因为这里其实不一定是一个函数调用,它也可以是另外一个二元表示式,这个时候就没有argument,每个节点属性都不一样,包含的有topicReference的属性可能叫argument也可能是二元表达式的right节点, 甚至它可能还会是一个ArrayExpression数组表达式,对吧,这个时候含有TopicExpression节点的属性就是elements了,我们是没有办法一一枚举出来的,所以我们在这里使用Object.keys递归遍历对象属性然后判断某个属性里面的某一个是否是TopicRefernce,如果是就替换了,如果不是则直接返回
    for (const key of Object.keys(node)) {
      newNode[key] = replaceTopic((node as any)[key], replacement);
    }
    return newNode;
  }

  return node;
}




const recursion = (path: traverse.NodePath): traverse.Node => {
  if (!(path.isBinaryExpression() && path.node.operator === "|>")) return path.node;

  const left = recursion(path.get("left")); //通过get函数可以得到left节点的path对象,如果直接left, 我们得到的是一个node对象,
  const right = recursion(path.get("right"));

  console.log("没有进来判断吗");
  return replaceTopic(right, left);


};

const myPlugin = ({types: t}: {types: typeof babelTypes}): PluginObj => {
  return {
    visitor: {
     BinaryExpression(path) {
      if (path.node.operator !== "|>") return false;


      const newNode = recursion(path);
      path.replaceWith(newNode);


     }
  }
  }
}

export default myPlugin

我们可以深入看看bable对一个节点的定义, babel的每个节点都是使用内部的defineType这么个函数定义的,每个节点都遵守一定的结构,比如二元表达式如下

defineType("BinaryExpression", {
  builder: ["operator", "left", "right"],
  fields: {
    operator: {
      validate: (0, _utils.assertOneOf)(..._index.BINARY_OPERATORS)
    },
    left: {
      validate: function () {
        const expression = (0, _utils.assertNodeType)("Expression");
        const inOp = (0, _utils.assertNodeType)("Expression", "PrivateName");
        const validator = Object.assign(function (node, key, val) {
          const validator = node.operator === "in" ? inOp : expression;
          validator(node, key, val);
        }, {
          oneOfNodeTypes: ["Expression", "PrivateName"]
        });
        return validator;
      }()
    },
    right: {
      validate: (0, _utils.assertNodeType)("Expression")
    }
  },
  visitor: ["left", "right"],
  aliases: ["Binary", "Expression"]
});

可以看到我们的定义中,主要得有builder,fields, visitor,aliases这几个字段, builder是一个构造器方法用的描述字段,在@babel/types库中(前面我们已经提到了这是一个提供了操作ast树的工具库),我们呢会使用t.binaryExpression这样的形式去构造一个二元表达式节点,根据这里的定义,我们也能明确得知我们在这个构造器中应该填入,

operator, left, right, 这三个参数,至于这三个东西具体是什么,我们是在 fileds的validate字段中定义的,这里提供了这么一个写法(0, _utils.assertOneOf)(..._index.BINARY_OPERATORS),实际作用上完全等价于utils.assertOneOf(..._index.BINARY_OPERATORS), 这种格式好处是没有_utils上下文, (0, utils.assertOneOf())实际上返回的是assertOneOf函数自身, 这是为了兼容commonjs的写法,因为代码是这么引入的 var _utils = require(".utils.js"), 这个core.js文件中,使用的是commonjs的模块导入方法,同时兼容了esm模块, 是为了防止引入_utils这个上下文才这么写的, 我们回到fields的解释,这里的BINARY_OPERATORS其实就是一个二元表达式字符数组,可以很容易看出来,这个operator就是要填入,二元表达式数组中的任意一个,left,right抛去一些边界判断,其实就是要求是表达式,visitor表示对这个节点使用traverse的时候, 指定要进入哪些子节点,以及进入子节点的顺序, 像这里就表达式进入两个节点,先进入left,然后进入right, aliases是别名的意思,表示这里判断节点的时候如果你使用t.isBinary判断这个节点也是可以的,它自己的判断函数应当是t.isBinaryExpression,aliases告诉你使用别的构造器也是可以的

Object.groupBy

object,groupBy的支持要比|>管道操作符好很多, 这个方法其实类似于大号的filter, filter只能筛出一类条件的数据,Object,groupBy可以筛出多个, 比如上面的student题目,如果要求同时给出及格和不及格的学生列表,我们用filter的话就需要两次,但是objectgropBy一次就够了,

const students = [
  { name: '张三', score: 45 },
  { name: '李四', score: 78 },
  { name: '王五', score: 92 },
  { name: '赵六', score: 58 }
];
Object.groupBy(students, (item) => item.score >= 60 ? "及格" : "不及格");

Object.groupBy接受两个参数,第一个参数是指定的数据集,第二个参数要求是一个回调,这个回调函数返回值被作为key, 最后得到一个对象,格式为{key1: [数据项1, 数据项2] , key2: [数据项3, 数据项4]... },我们可以很轻松的利用reduce写出groupBy的polyfill

if (Object.groupBy) {
  Object.groupBy = function (data, callback) {
    return data.reduce((acc, curr, index) => {
      const key = callback(curr, index);
      if (!acc[key]) {
        acc[key] = [];
      }
      acc[key].push(curr);
    }, {}); 
  };
}


如果我们不想使用这种polyfill,我们还可以使用babel在源代码中动态的替换Object.groupBy为我们自己实现的groupBy, 首先现在项目的polyfill目录下准备一个_groupBy文件,然后将上面写的函数赋值给它, 我们现在要做的就是在使用了Object.groupBy的函数代码中,动态的替换Object.groupBy,参数一致,然后在文件中添加import导入我们自己实现的polyfill

Snipaste_2026-05-20_09-54-19.png

可以看到,我们这里Object.groupBy是一个CallExpression,然后callee节点是成员表达式, 这个成员表达式的object是标识符,这个标识符的name是"object", 然后它有一个property属性,这个属性的节点也是一个标识符,这个标识符的name属性是"groupBy"

我们需要转换的结果如下图

Snipaste_2026-05-20_09-57-28.png

可以看到,多了一个importDeclaration节点,callee属性也变成了标识符name为_groupBy,这个时候没有成员表达式了,

      CallExpression(path) {
        if (
          !(
            t.isMemberExpression(path.node.callee) &&
            t.isIdentifier(path.node.callee.object, {name: "Object"}) &&
            t.isIdentifier(path.node.callee.property, {name: "groupBy"})
          )
        )
          return false;

        let uniqueId = (path.hub as any).file.groupBy;

        if (!uniqueId) {
          uniqueId = path.scope.generateUid("groupby");
          (path.hub as any).file.groupBy = uniqueId;
          //在body中插入import语句
          const program = path.findParent((n) =>
            n.isProgram(),
          ) as traverse.NodePath<babelTypes.Program>;

          const importNode = t.importDeclaration(
            [
              t.importSpecifier(
                t.identifier(uniqueId),
                t.identifier("_groupBy"),
              ),
            ],
            t.stringLiteral("./pollyfills/object-group-by.js"),
          );
          program.node.body.unshift(importNode);
        }

        path.node.callee = t.identifier(uniqueId);
      },

运行这个插件,我们运行如下的代码


if (true) {
      Object.groupBy([1, 2, 3, 4, 5], x => x + 1);
      Object.groupBy([2, 3], (x) => x - 1);
}


Object.groupBy([2, 3], (x) => x - 1);

可以看到它被转换为了

import { _groupBy as _groupby } from "./pollyfills/object-group-by.js";
if (true) {
  _groupby([1, 2, 3, 4, 5], x => x + 1);
  _groupby([2, 3], x => x - 1);
}
_groupby([2, 3], x => x - 1);

在这里做的其实就两件事,第一我们根据当前的作用域生成一个唯一的id, 然后在程序的body头部生成一个导入语句并插入,这个导入语句 t.importSpecifier(t.identifier(uniqueId), t.identifier("_groupBy"))

第一个参数是本地的名字,第二个参数是具名导出的名字,就是上面的as groupby 和 _groupBy两个标识符, 这一步其实是为了防止导入的函数名和本地的命名冲突了,然后我们把path.node.callee的成员表达式替换为我们生成的标识符,我们把这个id存入path.hub.file.groupBy中,hub是babel推荐的一个共享数据的容器,它实际上就是一个普通的对象,babel推荐你把共享数据存入hub.file.metadata里面, 其实实现这样的数据缓存还算比较简单的,只要所以的节点都共享同一个对象就可以了