linqable
v0.3.0
Published
TypeScript-first LINQ-style chainable collection utilities for arrays and iterables.
Maintainers
Readme
linqable
TypeScript first 的前端集合操作库,参考 C# LINQ,为数组、Iterable、Set、Map、NodeList 等数据提供优雅的链式查询能力。
import { from } from "linqable";
const names = from(users)
.where((x) => x.age > 18)
.orderBy((x) => x.name)
.select((x) => x.name)
.toArray();特性
- 零第三方运行时依赖。
- TypeScript first,导出完整类型声明。
- 支持 TypeScript type predicate,
where()、whereIf(true, ...)、first()、takeWhile()等方法可以自动缩小联合类型。 - 支持数组、
Iterable、Set、Map、类数组对象。 where、whereIf、select、skip、take、concat等操作优先懒执行。- 连续的
where()/select()会在常见场景下自动融合,减少中间迭代层。 - 数组和类数组上的
count()、last()、elementAt()、reverse()会优先走下标快路径。 orderBy().thenBy()支持稳定的多级排序,并针对多条件排序减少中间对象分配。- 补齐
range()、repeat()、leftJoin()、pairwise()、squeeze()等常用序列处理能力。 - 适合前端处理表格、列表、筛选、分组、报表和聚合数据。
- 所有公开 API 都带中文 JSDoc,编辑器悬浮即可查看说明。
安装
npm install linqable也可以使用 pnpm 或 yarn:
pnpm add linqable
yarn add linqable快速示例
import { from } from "linqable";
type User = {
id: number;
name: string;
age: number;
department: string;
salary: number;
};
const users: User[] = [
{ id: 1, name: "Alice", age: 28, department: "R&D", salary: 30000 },
{ id: 2, name: "Bob", age: 17, department: "Sales", salary: 12000 },
{ id: 3, name: "Cathy", age: 33, department: "R&D", salary: 36000 },
];
const report = from(users)
.where((user) => user.age >= 18)
.groupBy(
(user) => user.department,
(user) => user.salary,
)
.select((group) => ({
department: group.key,
count: group.values.length,
totalSalary: from(group).sum(),
averageSalary: from(group).average(),
}))
.orderByDescending((row) => row.totalSalary)
.toArray();数据源
from(source) 是默认入口。
from([1, 2, 3]);
from(new Set([1, 2, 3]));
from(new Map([["a", 1], ["b", 2]])); // 元素类型为 [string, number]
from(document.querySelectorAll("li"));也可以直接创建数字范围或重复值序列:
import { range, repeat } from "linqable";
range(3, 4).toArray(); // [3, 4, 5, 6]
repeat("x", 3).toArray(); // ["x", "x", "x"]还可以创建空序列:
import { empty } from "linqable";
const values = empty<number>().defaultIfEmpty(0).toArray();懒执行与物化
多数转换操作不会立即遍历数据源,只有在 toArray()、sum()、first()、forEach() 等终结操作,或使用 for...of 迭代时才开始读取。
懒执行方法包括:
where
whereIf
select
selectMany
distinct
distinctBy
skip
take
skipWhile
takeWhile
concat
append
prepend
union
intersect
except
chunk
zip
defaultIfEmptyTypeScript 类型守卫
如果你的数据是联合类型,可以把类型守卫直接传给 where(),后面的链条会自动拿到缩小后的类型。
type UserRow = { kind: "user"; name: string };
type OrderRow = { kind: "order"; amount: number };
type Row = UserRow | OrderRow;
declare const rows: Row[];
const isUser = (row: Row): row is UserRow => row.kind === "user";
const names = from(rows)
.where(isUser)
.select((user) => user.name)
.toArray();如果使用 whereIf(),只有当 condition 是字面量 true 时,TypeScript 才会把类型缩小到守卫后的类型。普通 boolean 在编译期无法确认筛选一定发生,因此会保留原始元素类型。
同样的类型缩小也适用于会返回元素的条件方法,例如 first()、firstOrDefault()、single()、singleOrDefault()、last()、lastOrDefault() 和 takeWhile():
const firstUser = from(rows).first(isUser); // UserRow
const maybeUser = from(rows).firstOrDefault(undefined, isUser); // UserRow | undefinedall() 也支持类型守卫。判断通过后,当前查询对象会在 TypeScript 中缩小为更具体的 Enumerable 类型:
const query = from(rows);
if (query.all(isUser)) {
query.select((user) => user.name);
}where/select 融合
where() 和 select() 仍然是懒执行的,但 linqable 会把常见的连续链条自动合并成更少的内部迭代层。
const result = from(rows)
.where((row) => row.enabled)
.where((row) => row.score > 80)
.select((row) => row.name)
.select((name) => name.trim())
.toArray();上面这种不使用索引参数的链条,会尽量在一层循环里完成过滤和映射,减少 generator 层层传递的开销。
如果回调声明了索引参数,linqable 会保守地回到普通懒迭代逻辑,确保每一步的 index 语义不变:
from(rows)
.where((row, index) => index > 0)
.select((row, index) => ({ row, index }));数组和类数组快路径
如果数据源本身支持 length 和下标访问,例如数组、NodeList、HTMLCollection 或普通类数组对象,linqable 会记住这一点。
所以这些直达操作不会从头遍历:
const rows = from(largeArray);
rows.count(); // 直接读取 length
rows.last(); // 直接读取最后一个下标
rows.elementAt(50000); // 直接读取指定下标
rows.reverse(); // 倒序按下标读取如果中间加了 where()、select()、skip() 等转换,结果已经不再等同于原始下标序列,linqable 会回到正常的懒迭代逻辑,保证语义正确。
需要全量数据的方法会在迭代或调用时物化。数组和类数组上的 count()、reverse() 等直达场景会优先使用上面的下标快路径:
orderBy
orderByDescending
thenBy
thenByDescending
groupBy
reverse
toArray
toMap
toSet
sum
average
count
min
max
minBy
maxBy
aggregate常用 API
过滤与映射
const result = from(products)
.where((x) => x.enabled)
.select((x) => ({ label: x.name, value: x.id }))
.toArray();需要根据外部条件动态追加筛选时,可以使用 whereIf()。当条件为 false 时,它会直接保留当前查询,不会调用谓词:
const result = from(products)
.where((x) => x.enabled)
.whereIf(keyword !== "", (x) => x.name.includes(keyword))
.whereIf(category !== undefined, (x) => x.category === category)
.select((x) => ({ label: x.name, value: x.id }))
.toArray();排序
const rows = from(users)
.orderBy((x) => x.department)
.thenByDescending((x) => x.salary)
.thenBy((x) => x.name)
.toArray();排序会在迭代或 toArray() 时读取完整序列,并保持稳定排序:多个元素的排序键都相等时,会保留它们在原始序列中的先后顺序。内部会缓存每个排序条件的 key,并排序下标数组,减少为每个元素创建临时对象的开销。
可以传入自定义比较器:
from(files).orderBy(
(x) => x.name,
(a, b) => a.localeCompare(b, "zh-CN"),
);分组
groupBy 返回 Grouping<TKey, TElement>,包含 key 和 values,同时自身也可以迭代。
const groups = from(orders)
.groupBy(
(order) => order.status,
(order) => order.amount,
)
.toArray();
for (const group of groups) {
console.log(group.key, from(group).sum());
}去重
from([1, 2, 2, 3]).distinct().toArray(); // [1, 2, 3]
from(users)
.distinctBy((user) => user.id)
.toArray();如果只想压缩相邻重复值,用 squeeze()。它不会做全局去重,后面再次出现的相同值仍会保留:
from([1, 1, 2, 2, 1]).squeeze().toArray(); // [1, 2, 1]
from(statusLogs)
.squeezeBy((row) => row.status)
.toArray();分页
const page = from(rows)
.skip((pageIndex - 1) * pageSize)
.take(pageSize)
.toArray();聚合
const total = from(orders).sum((x) => x.amount);
const average = from(orders).average((x) => x.amount);
const count = from(orders).count((x) => x.status === "paid");
const best = from(users).maxBy((x) => x.score);连接
const rows = from(users)
.join(
departments,
(user) => user.departmentId,
(department) => department.id,
(user, department) => ({
userName: user.name,
departmentName: department.name,
}),
)
.toArray();左连接会保留没有匹配项的左侧元素,此时内部元素参数为 undefined:
const rows = from(users)
.leftJoin(
orders,
(user) => user.id,
(order) => order.userId,
(user, order) => ({
name: user.name,
amount: order?.amount ?? 0,
}),
)
.toArray();相邻配对与序列比较
from([1, 2, 3]).pairwise().toArray(); // [[1, 2], [2, 3]]
from([1, 2, 3]).sequenceEqual([1, 2, 3]); // true
from(["a", "b", "a"]).indexOf("b"); // 1转换为原生集合
const array = from(users).toArray();
const set = from(users).select((x) => x.department).toSet();
const map = from(users).toMap(
(x) => x.id,
(x) => x.name,
);键值对序列可直接转换为 Map:
const map = from([["a", 1] as const, ["b", 2] as const]).toMap();完整方法列表
入口:
fromemptyrangerepeat
基础方法:
wherewhereIfselectgroupByorderBythenByorderByDescendingthenByDescendingdistinctdistinctBysqueezesqueezeByfirstfirstOrDefaultsinglesingleOrDefaultsumaveragecountanyallcontainsindexOfsequenceEqualskiptakeskipWhiletakeWhileconcatappendprependreversetoArraytoMaptoSetforEach
高级方法:
selectManyjoinleftJoingroupJoinunionintersectexceptchunkzippairwiseminmaxminBymaxByaggregatedefaultIfEmptyelementAtelementAtOrDefaultlastlastOrDefault
错误行为
这些方法在找不到元素或输入不合法时会抛出错误:
first():没有元素或没有匹配元素时抛错。single():没有匹配元素或匹配多个元素时抛错。average():空序列抛错。min()/max()/minBy()/maxBy():空序列抛错。elementAt(index):索引越界抛错。range(start, count)/repeat(value, count):count必须是非负整数。skip(count)/take(count):count必须是非负整数。chunk(size):size必须是正整数。
如果希望失败时返回默认值,可以使用:
firstOrDefault(defaultValue)
singleOrDefault(defaultValue)
lastOrDefault(defaultValue)
elementAtOrDefault(index, defaultValue)
defaultIfEmpty(defaultValue)类型提示
linqable 会尽量保留链式调用中的类型信息:
const ids: number[] = from(users)
.where((user) => user.age >= 18)
.select((user) => user.id)
.toArray();
const map: Map<number, string> = from(users).toMap(
(user) => user.id,
(user) => user.name,
);构建与验证
项目使用 TypeScript 编译为 ESM:
pnpm run typecheck
pnpm run test
pnpm run buildtest 使用 Node.js 内置测试运行器,会先构建 dist,再测试实际发布产物。
发布
发布前建议运行:
pnpm run prepublishOnly包入口:
- ESM:
dist/index.js - Types:
dist/index.d.ts
License
MIT
