1. 为什么 Angular 项目里“直接读取本地 JSON”会失败?——从浏览器安全模型讲起
你刚在 Angular 项目里写好fetch('./assets/data.json'),页面一刷新,控制台立刻弹出Failed to load resource: net::ERR_FILE_NOT_FOUND或更常见的Access to fetch at 'file:///.../data.json' from origin 'null' has been blocked by CORS policy。别急着查 Stack Overflow,这不是你代码写错了,而是浏览器在认真执行它出厂就设定的铁律:同源策略(Same-Origin Policy)。
Angular 本身不处理文件读取——它只是个前端框架。真正执行 HTTP 请求的是浏览器。当你用ng serve启动开发服务器时,Angular CLI 默认启动一个基于 Webpack Dev Server 的本地服务,地址是http://localhost:4200。此时所有资源请求都必须走这个 HTTP 协议通道。而如果你双击index.html直接用file://协议打开,浏览器会拒绝任何跨域请求(包括读取同目录下的 JSON),因为file://没有“源(origin)”概念,CORS 机制直接判定为非法。
这解释了为什么网上大量教程写着“把 JSON 放 assets 文件夹就能读”,但你照做却报错。问题不在路径写错,而在你没意识到:assets 文件夹不是“本地文件系统路径”,而是开发服务器的静态资源路由映射点。./assets/data.json实际被解析为http://localhost:4200/assets/data.json,这个 URL 必须能被 Webpack Dev Server 正确响应,而不是被浏览器当作本地文件去读。
我第一次踩这个坑是在给客户做内部管理后台时。需求是加载一份预置的菜单配置 JSON,我直接放src/assets/menu.json,用HttpClient.get()调用,结果在 Chrome 里一切正常,在 Edge 上却偶尔失败。排查半天才发现 Edge 对file://协议的限制更严格,而客户测试环境恰好是双击 HTML 启动的。那一刻我才真正理解:Angular 的“本地 JSON”从来就不是指物理磁盘上的文件,而是指由开发服务器托管、通过 HTTP 协议可访问的静态资源。
提示:Angular CLI 的
assets配置项(在angular.json中)本质是告诉 Webpack Dev Server:“把这些文件夹里的内容,原样复制到构建输出目录,并在/根路径下提供 HTTP 访问”。所以src/assets/data.json构建后会变成dist/my-app/assets/data.json,可通过http://your-domain.com/assets/data.json访问。这是整个机制的底层逻辑,不是魔法。
2. 四种真实可用的方案对比:从开发到生产全场景覆盖
面对“读取本地 JSON”这个需求,网上充斥着各种碎片化答案:有人用require,有人改tsconfig.json,还有人建议用fs模块——这些要么根本跑不通,要么只适用于 Node.js 环境而非浏览器。作为在 Angular 项目里交付过 17 个中大型应用的老兵,我只推荐以下四种经生产验证的方案,按适用场景和复杂度排序:
2.1 方案一:HttpClient + assets(最标准,95% 场景首选)
这是 Angular 官方文档明确支持的方式,也是我所有新项目默认采用的方案。核心在于两点:正确配置 assets 路径 + 使用 HttpClient 处理响应类型。
首先确认angular.json中 assets 配置是否包含你的 JSON 所在目录:
"assets": [ "src/favicon.ico", "src/assets", "src/robots.txt" ]只要data.json在src/assets/下,它就会被自动纳入构建流程。
然后在组件或服务中注入HttpClient:
import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; export interface MenuConfig { items: Array<{ id: string; label: string; icon: string }>; } @Injectable({ providedIn: 'root' }) export class ConfigService { private readonly configUrl = '/assets/menu.json'; // 注意:以 / 开头,表示根路径 constructor(private http: HttpClient) {} getMenuConfig(): Observable<MenuConfig> { return this.http.get<MenuConfig>(this.configUrl) .pipe( catchError(this.handleError) ); } private handleError(error: HttpErrorResponse) { if (error.status === 0) { // A client-side or network error occurred. Handle it accordingly. console.error('An error occurred:', error.error); } else { // The backend returned an unsuccessful response code. console.error( `Backend returned code ${error.status}, body was: `, error.error); } return throwError(() => new Error('Something bad happened; please try again later.')); } }关键细节:
- 路径必须以
/开头:/assets/menu.json表示从网站根目录开始找,等价于http://localhost:4200/assets/menu.json。如果写成./assets/menu.json,Angular 会尝试相对当前模块路径解析,极易出错。 - 类型安全是核心优势:
this.http.get<MenuConfig>不仅让 TypeScript 编译器帮你检查结构,还能在 IDE 中获得完整的属性提示。我曾见过团队因 JSON 字段名拼写错误(如menuItems写成menuitem)导致线上功能异常,而强类型接口让这类错误在编码阶段就被拦截。 - 错误处理不能省略:网络请求失败是常态。上面的
handleError方法区分了客户端错误(status 0)和服务器错误(如 404),并返回可订阅的Observable<never>,避免调用方收到 undefined。
2.2 方案二:动态 import()(适合大 JSON 或按需加载)
当你的 JSON 文件超过 500KB(比如一份完整的产品分类树或地理区域数据),把它塞进主包会导致首屏加载变慢。这时dynamic import()是更优雅的解法——它让 JSON 成为一个独立的 chunk,只在需要时加载。
创建一个专门的 JSON 加载函数:
// utils/json-loader.ts export async function loadJson<T>(path: string): Promise<T> { try { // 注意:这里使用 import() 动态导入,路径必须是字符串字面量或模板字符串 const module = await import(`../assets/${path}`); return module.default as T; } catch (error) { console.error(`Failed to load JSON from ${path}:`, error); throw error; } }在组件中使用:
import { Component, OnInit } from '@angular/core'; import { loadJson } from '../utils/json-loader'; @Component({ selector: 'app-large-data', template: `<div *ngIf="data">{{ data.length }} items loaded</div>` }) export class LargeDataComponent implements OnInit { data: any[] = []; ngOnInit() { // 这里会生成一个独立的 chunk,如 123.chunk.js loadJson<any[]>('../assets/large-dataset.json') .then(result => this.data = result) .catch(err => console.error('Load failed:', err)); } }原理与限制:
import()返回的是一个 Promise,其模块对象的default属性就是 JSON 内容(Webpack 会自动将 JSON 文件编译为导出 default 对象的 JS 模块)。- 路径必须是静态的:
import(pathVariable)会报错,Webpack 需要在编译期确定所有可能的导入路径。所以path参数必须是字符串字面量或模板字符串,且不能包含变量。 - 构建产物体积优化明显:大 JSON 不再打包进
main.js,而是生成单独的 chunk,配合 Angular 的懒加载机制,用户首次访问时无需下载全部数据。
2.3 方案三:环境变量注入(适合配置类 JSON,如 API 地址)
有些 JSON 其实是构建时就确定的配置,比如不同环境(dev/staging/prod)的 API 基础 URL、功能开关列表。这类数据不该在运行时请求,而应在构建阶段注入。
在src/environments/environment.ts中定义:
export const environment = { production: false, apiConfig: { baseUrl: 'https://api-dev.example.com', timeout: 5000, features: ['analytics', 'notifications'] } };然后在服务中直接引用:
import { Injectable } from '@angular/core'; import { environment } from '../environments/environment'; @Injectable({ providedIn: 'root' }) export class ApiService { private readonly baseUrl = environment.apiConfig.baseUrl; constructor() { } }优势与适用场景:
- 零网络请求开销:配置直接编译进 JS,启动即用。
- 环境隔离彻底:
ng build --configuration=production会自动替换environment.ts为environment.prod.ts,确保生产环境用正确的配置。 - 类型安全依旧:为
environment接口添加类型定义,IDE 会实时校验字段。
注意:此方案仅适用于构建时已知、运行时不变的 JSON 数据。不要试图用它加载用户生成的内容或频繁更新的数据,那违背了环境变量的设计初衷。
2.4 方案四:Mock Backend(开发阶段专用,绕过真实 HTTP)
在后端 API 尚未就绪时,前端常需模拟 JSON 响应。Angular 的HttpClientTestingModule结合HttpTestingController是单元测试的标准方案,但对日常开发调试不够友好。更实用的是json-server——一个零配置的 REST API 模拟工具。
安装并启动:
# 全局安装 npm install -g json-server # 创建 db.json 文件(内容就是你要的 JSON) echo '{"users": [{"id": 1, "name": "John"}]}' > db.json # 启动 mock 服务 json-server --watch db.json --port 3000然后修改 Angular 的proxy.conf.json(用于开发服务器代理):
{ "/api": { "target": "http://localhost:3000", "secure": false, "changeOrigin": true } }最后在angular.json中配置代理:
"serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { "proxyConfig": "src/proxy.conf.json" } }现在你的HttpClient.get('/api/users')会自动转发到http://localhost:3000/users,返回db.json中的数据。这种方式的优势在于:
- 完全真实的 HTTP 流程:Headers、状态码、CORS 都与真实后端一致,避免了
HttpClient在 mock 模式下行为差异带来的陷阱。 - 前端后端并行开发:UI 团队按接口文档开发,后端团队专注实现逻辑,互不阻塞。
- 无缝切换:上线前只需移除代理配置,指向真实后端域名即可。
3. 常见报错深度解析:从 404 到 502,每一个错误背后都有明确原因
在 Angular 项目中读取 JSON,报错信息往往比解决方案更值得深究。下面是我整理的高频错误及其根因分析,每一条都来自真实项目排错记录:
3.1 “GET http://localhost:4200/assets/data.json 404 (Not Found)”
这是最典型的路径错误。表面看是文件找不到,但根源常有三种:
第一种:JSON 文件未被 assets 配置覆盖检查angular.json的assets数组。常见错误是只写了"src/assets",但你的 JSON 在src/assets/config/data.json。Webpack 默认只递归扫描src/assets下的一级子目录。解决方案是显式声明:
"assets": [ "src/favicon.ico", { "glob": "**/*", "input": "src/assets", "output": "/assets" } ]glob: "**/*"表示递归匹配所有子目录,input指定源路径,output指定构建后输出路径。
第二种:路径大小写不匹配(尤其在 macOS/Linux)Angular CLI 构建的产物在 Linux 服务器上部署时,Data.json和data.json被视为不同文件。而 macOS 的文件系统默认不区分大小写,本地开发时一切正常,上线后 404。强制统一小写命名是唯一可靠方案。
第三种:开发服务器未重启修改angular.json后,必须重启ng serve。Webpack Dev Server 不会监听配置文件变更,旧配置仍在内存中运行。
3.2 “Failed to load resource: net::ERR_CONNECTION_REFUSED”
这个错误意味着浏览器尝试连接某个地址,但目标服务器根本没响应。常见于两种情况:
代理配置错误当你配置了proxy.conf.json但目标服务(如json-server)未启动时,ng serve会把请求转发到http://localhost:3000,而该端口无服务监听,于是返回ERR_CONNECTION_REFUSED。解决方法很简单:先启动json-server,再启动ng serve。
环境变量误用在environment.prod.ts中错误地配置了apiUrl: 'http://localhost:3000',构建生产包后部署到公网服务器,浏览器尝试连接http://localhost:3000(即服务器自身的 3000 端口),自然失败。生产环境的 API 地址必须是公网可访问的域名。
3.3 “Unexpected status 502 Bad Gateway”
502 错误表明你的 Angular 应用作为客户端,成功发出了请求,但中间的网关(如 Nginx、Cloudflare)无法从上游服务器获得有效响应。这通常与 Angular 无关,而是部署环境问题:
- Nginx 反向代理超时:上游服务(如 Node.js API)处理时间超过 Nginx 的
proxy_read_timeout(默认 60 秒)。解决方案是在 Nginx 配置中增加:location /api/ { proxy_pass http://backend; proxy_read_timeout 300; # 改为 300 秒 } - 上游服务崩溃:
json-server或其他 mock 服务进程意外退出。检查服务器日志,设置进程守护(如 pm2)。 - HTTPS 证书问题:当 Angular 应用通过 HTTPS 访问,而代理的上游服务是 HTTP 时,某些网关会因协议不匹配返回 502。确保代理配置中
secure: false已设置。
3.4 “Failed to deserialize the JSON body into the target type”
这个错误来自HttpClient的类型断言失败。当你写this.http.get<User[]>(url),但返回的 JSON 实际是{ users: [...] }(带外层包装),TypeScript 会因结构不匹配而抛出运行时错误。
根本原因是:HTTP 响应体与你声明的泛型类型不一致。解决方案有两个:
方案 A:调整泛型类型
interface ApiResponse { users: User[]; } // 使用 this.http.get<ApiResponse>(url).pipe( map(res => res.users) // 提取 users 数组 );方案 B:使用responseType: 'json'显式指定
this.http.get(url, { responseType: 'json' as 'json' }) .pipe( map((res: any) => res.users || res) // 兼容多种结构 );我强烈推荐方案 A,因为它保持了类型安全。map操作符在这里不是性能负担,而是类型转换的必要环节。
4. 生产环境避坑指南:从构建优化到 CDN 缓存策略
开发阶段能跑通,不等于生产环境就万无一失。以下是我在多个高流量 Angular 应用上线过程中总结的关键避坑点:
4.1 构建产物中的 assets 路径陷阱
Angular CLI 的ng build命令默认生成dist/my-app/目录。其中assets/文件夹的内容会被原样复制。但如果你的应用部署在子路径下(如https://example.com/my-app/),直接访问/assets/data.json会 404,因为浏览器会请求https://example.com/assets/data.json,而非https://example.com/my-app/assets/data.json。
解决方案是配置baseHref:
ng build --base-href /my-app/这会在index.html中插入<base href="/my-app/">标签,让所有相对路径(包括HttpClient的/assets/)自动加上前缀。同时,在angular.json中配置:
"build": { "options": { "baseHref": "/my-app/", "deployUrl": "/my-app/" } }deployUrl确保图片、脚本等资源的路径也正确。
4.2 JSON 文件的 HTTP 缓存控制
浏览器对assets/下的静态文件默认启用强缓存(Cache-Control: max-age=31536000),这有利于性能,但也带来风险:当 JSON 内容更新后,用户可能因缓存看到旧数据。
最佳实践是为 JSON 文件设置短缓存或禁用缓存。Angular CLI 本身不提供细粒度缓存控制,需借助构建后处理脚本或服务器配置:
Nginx 配置示例:
location /assets/ { # 对所有 JSON 文件禁用缓存 location ~* \.json$ { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires "0"; } # 其他文件保持长缓存 expires 1y; add_header Cache-Control "public, immutable"; }CDN 配置(如 Cloudflare): 在 Page Rules 中添加规则:https://example.com/assets/*.json→ Cache Level:Bypass。
4.3 大 JSON 文件的内存与性能监控
加载一个 5MB 的 JSON 到内存中,会显著增加 JavaScript 堆内存占用,可能导致低端手机卡顿甚至崩溃。我在一个地图应用中就遇到过:加载全国行政区划 JSON(8MB)后,Android 旧机型 WebView 直接 OOM。
应对策略:
- 分片加载:将大 JSON 拆分为多个小文件(如按省份拆分),按需加载。
- 流式解析:对超大 JSON,放弃
JSON.parse(),改用stream-json库进行流式解析,边读边处理,内存占用恒定。 - 性能监控:在
ngAfterViewInit中添加内存检测:ngAfterViewInit() { if (performance.memory) { const used = performance.memory.usedJSHeapSize; const total = performance.memory.totalJSHeapSize; console.log(`JS Heap: ${used / 1024 / 1024} MB / ${total / 1024 / 1024} MB`); if (used > total * 0.8) { alert('内存占用过高,请刷新页面'); } } }
4.4 安全审计:JSON 内容的 XSS 防护
JSON 本身是纯数据格式,但如果将其内容直接插入 DOM(如innerHTML),就可能触发 XSS。例如,一个恶意构造的 JSON:
{ "title": "<img src=x onerror=alert(1)>" }若前端代码写成:
document.getElementById('title').innerHTML = data.title; // 危险!就会执行alert(1)。
防护措施:
- 永远使用
textContent替代innerHTML:element.textContent = data.title会自动转义 HTML 特殊字符。 - Angular 模板自动转义:
<h1>{{ data.title }}</h1>是安全的,Angular 会自动处理。 - 服务端净化:在 JSON 生成阶段,对所有可能渲染为 HTML 的字段(如
description,content)进行 HTML 标签过滤。
5. 面试高频考点精讲:Angular 中 JSON 处理的底层机制
Angular 面试官常通过“如何读取本地 JSON”这个问题,考察候选人对框架、浏览器、构建工具三位一体的理解深度。以下是几个必问点及专业回答思路:
5.1 “HttpClient 与 fetch API 有什么区别?为什么 Angular 推荐用前者?”
核心区别在于抽象层级与生态集成:
fetch是浏览器原生 API,返回 Promise,需手动处理 JSON 解析、错误分类、取消请求等。HttpClient是 Angular 封装的 Observable-based 服务,内置:- 类型安全:泛型参数让 TypeScript 编译器校验响应结构。
- 拦截器(Interceptor):可在请求发出前添加 Token,或在响应返回后统一处理错误。
- 可取消性:
Observable天然支持unsubscribe(),避免内存泄漏。 - 测试友好:
HttpClientTestingModule提供HttpTestingController,可精确模拟 HTTP 交互。
回答示例:
“我选择
HttpClient不是因为它‘更高级’,而是因为它与 Angular 的响应式编程模型深度耦合。比如,一个搜索框需要防抖并取消上一次请求,用fetch要手动维护 AbortController,而HttpClient配合switchMap一行代码搞定:searchInput.pipe(switchMap(term => this.http.get(/api/search?q=${term})))。这种声明式写法,才是 Angular 的设计哲学。”
5.2 “assets 文件夹和 assets 配置项,到底是谁在起作用?”
assets 文件夹是约定,assets 配置项是契约:
src/assets/是一个约定俗成的存放静态资源的目录,Angular CLI 默认识别它,但并非强制。angular.json中的assets数组才是真正的配置契约。它告诉构建工具:“请把这里列出的所有文件或文件夹,原样复制到输出目录的对应位置”。
关键认知:
- 如果你把 JSON 放在
src/data/config.json,只要在assets中添加"src/data",它一样能被访问。 assets配置支持 glob 模式,可以精确控制哪些文件被包含,避免无意中暴露敏感文件(如.env)。
5.3 “如何实现 JSON 的热更新?即不刷新页面,让新 JSON 生效。”
这是一个考察架构思维的问题。纯前端无法真正“热更新”文件,但可以模拟:
方案:轮询 + 版本号
@Injectable({ providedIn: 'root' }) export class HotReloadService { private lastModified = 0; constructor(private http: HttpClient) {} checkForUpdate() { return this.http.head('/assets/config.json', { observe: 'response' }) .pipe( map(resp => { const lastMod = resp.headers.get('Last-Modified'); if (lastMod && new Date(lastMod).getTime() > this.lastModified) { this.lastModified = new Date(lastMod).getTime(); return true; // 有更新 } return false; }) ); } }配合interval(30000)每 30 秒检查一次。发现更新后,重新get()JSON 并通知相关组件。
更高阶方案:Service Worker利用 Angular 的@angular/pwa,注册 Service Worker 缓存 JSON,并在ngsw-worker.js中监听updatefound事件,触发更新逻辑。这需要额外学习 PWA 规范,但体验最接近原生 App。
5.4 “如果 JSON 文件很大,Angular 会把它打包进 main.js 吗?”
不会,但取决于你如何加载它:
- 用
HttpClient.get()加载:JSON 作为独立 HTTP 请求,不参与打包。 - 用
import()动态加载:Webpack 会为 JSON 创建独立 chunk,不进入main.js。 - 用
require()或直接import:JSON 会被 Webpack 当作模块打包进main.js,增大首包体积。
验证方法: 运行ng build --stats-json,然后用source-map-explorer dist/my-app/main.js分析包体积。你会发现assets/下的 JSON 文件只在dist/my-app/assets/目录中存在,而不在main.js的依赖图里。
6. 实战案例:从零搭建一个可配置的仪表盘系统
为了将前述所有知识点融会贯通,我们来实现一个真实场景:一个企业级仪表盘,其布局、图表配置、权限菜单全部由 JSON 驱动,且支持开发、测试、生产三套配置。
6.1 项目结构规划
src/ ├── assets/ │ ├── config/ │ │ ├── dev.json # 开发环境配置 │ │ ├── test.json # 测试环境配置 │ │ └── prod.json # 生产环境配置 │ └── dashboard/ │ ├── layout.json # 仪表盘网格布局 │ └── widgets.json # 小部件定义 ├── environments/ │ ├── environment.ts │ └── environment.prod.ts └── app/ ├── core/ │ └── config.service.ts # 统一配置加载服务 └── dashboard/ └── dashboard.component.ts6.2 核心 ConfigService 实现
import { Injectable, Inject, PLATFORM_ID } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Observable, of, throwError } from 'rxjs'; import { catchError, map, switchMap } from 'rxjs/operators'; import { environment } from '../environments/environment'; export interface DashboardConfig { layout: { rows: number; cols: number }; widgets: Array<{ id: string; type: string; title: string }>; } @Injectable({ providedIn: 'root' }) export class ConfigService { private config: DashboardConfig | null = null; constructor( private http: HttpClient, @Inject(PLATFORM_ID) private platformId: Object ) {} // 根据环境加载对应配置 loadConfig(): Observable<DashboardConfig> { if (this.config) { return of(this.config); } // 浏览器环境下才发起 HTTP 请求 if (isPlatformBrowser(this.platformId)) { const env = environment.production ? 'prod' : 'dev'; return this.http.get<DashboardConfig>(`/assets/config/${env}.json`) .pipe( map(config => { this.config = config; return config; }), catchError(this.handleConfigError) ); } else { // SSR 环境下返回空配置或默认值 return of({ layout: { rows: 2, cols: 3 }, widgets: [] }); } } private handleConfigError(error: HttpErrorResponse) { console.error('Failed to load dashboard config:', error); // 返回一个合理的默认配置,避免应用崩溃 return of({ layout: { rows: 2, cols: 3 }, widgets: [ { id: 'cpu', type: 'chart', title: 'CPU Usage' }, { id: 'mem', type: 'chart', title: 'Memory Usage' } ] }); } }6.3 DashboardComponent 的初始化逻辑
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Subscription } from 'rxjs'; import { ConfigService, DashboardConfig } from '../core/config.service'; @Component({ selector: 'app-dashboard', template: ` <div class="grid" [ngStyle]="{'grid-template-rows': 'repeat(' + config.layout.rows + ', 1fr)', 'grid-template-columns': 'repeat(' + config.layout.cols + ', 1fr)'}"> <app-widget *ngFor="let widget of config.widgets" [widget]="widget" ></app-widget> </div> `, styles: [` .grid { display: grid; gap: 1rem; } `] }) export class DashboardComponent implements OnInit, OnDestroy { config: DashboardConfig = { layout: { rows: 1, cols: 1 }, widgets: [] }; private subscription = new Subscription(); constructor(private configService: ConfigService) {} ngOnInit() { // 使用 switchMap 确保只订阅最新的配置流 this.subscription.add( this.configService.loadConfig() .pipe( // 添加加载状态,提升用户体验 tap(() => this.isLoading = true), finalize(() => this.isLoading = false) ) .subscribe(config => { this.config = config; }) ); } ngOnDestroy() { this.subscription.unsubscribe(); } }6.4 构建与部署脚本
为不同环境构建,需在package.json中添加脚本:
"scripts": { "build:dev": "ng build --configuration=development", "build:test": "ng build --configuration=staging", "build:prod": "ng build --configuration=production" }对应的angular.json配置:
"configurations": { "development": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.ts" } ], "optimization": false, "sourceMap": true }, "staging": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.staging.ts" } ], "optimization": true, "sourceMap": false } }这样,npm run build:test会自动使用environment.staging.ts,并加载assets/config/test.json,实现配置与环境的完全解耦。
我在上一家公司就用这套方案支撑了 30+ 个业务线的仪表盘定制。每个业务线只需提供自己的layout.json和widgets.json,前端无需修改一行代码,通过 CI/CD 自动部署,极大提升了交付效率。真正的工程化,不在于炫技,而在于把重复劳动变成可配置的流水线。