ngx-transforms: 90 standalone Angular pipes that actually compose

ngx-transforms: 90 standalone Angular pipes that actually compose

I shipped a 90-pipe Angular library, and the thing I want to write about isn’t the pipes themselves. It’s what happens when you compose them. ngx-transforms is a standalone Angular pipe library - 90 pipes across 8 categories, lightweight and verified against Angular 17 / 19 / 21 in CI. You can install it today: npm install ngx-transforms

我发布了一个包含 90 个管道(pipe)的 Angular 库,但我今天想聊的并不是这些管道本身,而是当你将它们组合使用时会发生什么。ngx-transforms 是一个独立的 Angular 管道库,包含 8 个类别共 90 个管道,轻量且在 CI 中通过了 Angular 17 / 19 / 21 的验证。你可以立即安装使用:npm install ngx-transforms

But this post isn’t really a feature dump. It’s about something. I noticed three categories in it: the pipes get more useful when they’re composed than when they’re used alone, and Angular doesn’t have a strong cultural pattern for that yet. Most pipe libraries are flat catalogs. I want to push for a different framing: pipes as a composable language for template-side data transformation. If you stick with me, by the end of this post you’ll have six concrete patterns you can lift into your next Angular project.

但这篇文章并不是简单的功能罗列。我发现这些管道在组合使用时比单独使用更有价值,而 Angular 社区目前尚未形成这种强大的文化模式。大多数管道库只是扁平的目录。我想提出一种新的视角:将管道视为一种用于模板端数据转换的可组合语言。如果你能读完这篇文章,你将掌握六种可以直接应用到下一个 Angular 项目中的具体模式。

Why I built this

我为什么要构建它

Every Angular project I’ve worked on has a pipes/ folder that grows organically. Truncate. Time-ago. Slug from title. Mask the credit card. Format bytes. Group by region. Each one written by hand, in each codebase, with subtle bugs at the edges (what does truncate do with null? what about emoji? what about whitespace input to slugify?).

我参与过的每个 Angular 项目都有一个不断增长的 pipes/ 文件夹。截断字符串、显示相对时间、生成 URL 别名、掩码信用卡号、格式化字节、按区域分组……每一个都是在各个代码库中手写的,且在边缘情况处理上存在细微 Bug(例如:截断函数如何处理 null?如何处理 Emoji?如果输入包含空格,slugify 函数该怎么办?)。

There are existing pipe libraries, but most of them are NgModule-based artifacts from the Angular 2–14 era. With standalone components stable since Angular 14 and the default since 17, the calculus changed: you can now import a single pipe class directly into a single component without polluting an NgModule. Tree-shaking works. Bundle size doesn’t suffer. So I rewrote what I’d been writing by hand for years, as 90 individually-importable standalone pipes, with tests and a docs site.

市面上虽然已有现成的管道库,但大多数是 Angular 2–14 时代的产物,依赖于 NgModule。随着 Angular 14 引入并稳定了独立组件(Standalone Components),并在 17 版本成为默认设置,情况发生了变化:你现在可以直接将单个管道类导入到组件中,而无需污染 NgModule。Tree-shaking(摇树优化)可以正常工作,包体积也不会受到影响。因此,我将多年来手写的代码重写为 90 个可单独导入的独立管道,并配有测试和文档网站。

The library is at 0.3.2 today, deliberately pre-1.0 until real-world adoption surfaces issues I can’t predict from in-house testing. (More on that philosophy below.)

该库目前版本为 0.3.2,特意保持在 1.0 之前,直到实际应用中出现我无法通过内部测试预见的问题。(关于这一理念,下文会有更多说明。)

What’s in the box

包含内容

8 categories, 90 pipes total: 包含 8 个类别,共 90 个管道:

CategoryCountA taste
Text27truncate, slugify, latinize, template, wrap
Array20groupBy, orderBy, unique, chunk, intersection
Math13min, max, sum, average, bytes, percentage
Object8keys, pairs, pick, omit, invert, diffObj
Boolean8isDefined, isNull, isString, isArray, isEmpty
Data5count, timeAgo, jsonPretty, device, textToSpeech
Security5htmlSanitize, creditCardMask, emailMask, ipAddressMask
Media4qrCode, barcode, gravatar, colorConvert

Every pipe is a standalone class. You import it directly: 每个管道都是一个独立的类。你可以直接导入它:

import { Component } from '@angular/core';
import { TruncatePipe, TimeAgoPipe } from 'ngx-transforms';

@Component({
  standalone: true,
  imports: [TruncatePipe, TimeAgoPipe],
  template: `
    <p>{{ post.body | truncate:80 }}</p>
    <small>{{ post.createdAt | timeAgo }}</small>
  `,
})
export class PostCard {}

Nothing fancy. The interesting part starts when you stop thinking of pipes as standalone utilities and start thinking of them as a chainable language. 这没什么特别的。有趣的部分在于,当你不再将管道视为独立的工具,而是将其视为一种可链式调用的语言时。

The compositional thesis

组合式论点

Here’s a sentence I’d put on a poster: Angular templates are a great place to express data transformations, and we’ve underused them for years. The reason is cultural, not technical. NgModules made importing pipes annoying enough that most apps imported one or two and wrote the rest as component-class getters or RxJS map chains.

我想把这句话印在海报上:Angular 模板是表达数据转换的绝佳场所,但多年来我们一直低估了它们的作用。 原因在于文化而非技术。NgModules 使得导入管道变得非常麻烦,以至于大多数应用只导入一两个,其余的则写在组件类的 getter 或 RxJS 的 map 链中。

Standalone components dissolved that friction. You can now drop three pipes into one component and chain them with no overhead. When you do that, three things become true: 独立组件消除了这种阻力。你现在可以在一个组件中放入三个管道并将它们链式调用,且没有任何额外开销。当你这样做时,会产生三个好处:

  1. The transformation lives next to the markup it controls. A reader sees both the shape of the data and how it renders, in one place. 转换逻辑与它所控制的标记语言紧密相连。 阅读者可以在同一个地方同时看到数据的形态及其渲染方式。
  2. Memoization is free. Pure pipes cache by reference. Angular reuses cached results across change detection cycles automatically, no computed() needed. 记忆化(Memoization)是免费的。 纯管道按引用缓存。Angular 会在变更检测周期中自动重用缓存结果,无需使用 computed()
  3. The component class shrinks. Often to zero transformation logic. Just inputs, signals, and event handlers. 组件类变得更精简。 通常可以将转换逻辑缩减为零。只保留输入、信号(Signals)和事件处理程序。

That last point is the one that surprised me. By the time I had 60+ pipes, my own components had stopped having transformation methods at all. The class became data; the template became an algorithm. Some people will find that ugly. I find it honest that the template is already where transformation happens conceptually, so making it the literal place too removes a layer of indirection.

最后一点让我感到惊讶。当我拥有 60 多个管道时,我自己的组件中已经完全没有转换方法了。类变成了数据,模板变成了算法。有些人可能会觉得这很丑陋,但我认为这很直观——因为从概念上讲,模板本身就是转换发生的地方,所以将其作为实际的转换位置消除了中间层。

Six patterns I’d reach for

我会使用的六种模式

The library’s docs site has six full recipes, each with a live interactive playground. Here’s the abbreviated tour, one pattern per pipe-composition shape. 该库的文档网站提供了六个完整的配方,每个配方都有一个实时交互的演练场。以下是简要介绍,每种模式对应一种管道组合形式。

1. Recursive walker for rendering unknown JSON

1. 用于渲染未知 JSON 的递归遍历器

You have a value of unknown shape. Render it as a tree. 你有一个形状未知的值,将其渲染为树状结构。

@Component({
  selector: 'app-tree-node',
  standalone: true,
  imports: [JsonPipe, IsArrayPipe, IsObjectPipe, PairsPipe, TreeNode], // ← self-import
  template: `
    @if (value() | isArray) {
      <ul> @for (item of asArray(); track $index) { <li><app-tree-node [value]="item" /></li> } </ul>
    } @else if (value() | isObject) {
      <dl> @for (entry of value() | pairs; track entry[0]) { <dt>{{ entry[0] }}</dt> <dd><app-tree-node [value]="entry[1]" /></dd> } </dl>
    } @else {
      <span>{{ value() | json }}</span>
    }
  `,
})
export class TreeNode {
  value = input<unknown>(null);
  asArray = () => this.value() as unknown[];
}

Three pipes (isArray, isObject, pairs) and a self-importing standalone component. The whole walker is the template. No class methods. 三个管道(isArray, isObject, pairs)加上一个自导入的独立组件。整个遍历器就是模板本身,无需任何类方法。

2. Sequential chain to slug from any title

2. 从任意标题生成 URL 别名的顺序链

<small>/blog/{{ title | truncate:60:'':true | latinize | slugify }}</small>

Truncate first to preserve word boundaries while spaces still exist, latinize to flatten diacritics to ASCII, slugify to lowercase and replace whitespace. Order matters. The chain reads top-to-bottom like a pipeline diagram. 先截断以在空格存在时保留单词边界,再进行 latinize 将变音符号转换为 ASCII,最后 slugify 转为小写并替换空格。顺序很重要。这个链条从上到下阅读,就像一个管道流程图。

3. Parallel fan-out — KPI dashboard

3. 并行扇出 — KPI 仪表盘

One source array, six independent aggregations: 一个源数组,六个独立的聚合:

<div class="card">{{ orders | sum:'total' | currency }}</div>
<div class="card">{{ orders | average:'total' | currency }}</div>
<div class="card">{{ orders | max:'total' | currency }}</div>