Angular 库与 Schematics 集成实战:为你的 Library 提供 ng add、ng generate 与 ng update 的 CLI 支持
2026/9/8 21:35:45 网站建设 项目流程

Angular 库与 Schematics 集成实战:为你的 Library 提供 ng add、ng generate 与 ng update 的 CLI 支持

【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular

本篇指南讲解如何在 Angular 项目中为自研 Library 配套 Schematics 集合(collection),让第三方使用者能够通过 Angular CLI 的三个核心命令完成与库的集成:ng add一键安装并初始化库、ng generate生成库内定义的业务构件(如带依赖注入的服务)、ng update在库升级时自动迁移破坏性变更。文章以当前仓库中的官方示例schematics-for-libraries为骨架,逐文件还原集合配置、工厂函数、模板系统与构建产物组织方式,读完你便能从零搭建一套可打包、可发布、可被 CLI 自动发现的 schematics 工程。

为什么 Library 需要 Schematics 集合

当你在 Angular 工作区中创建了一个库(Library)时,库的使用者并不会天然获得任何 CLI 集成能力——他们只能手动安装包、手动复制代码、手动改造AppModule。Angular 提供的解法是:允许把 schematics 与库一起打包发布,用 schematics 描述“如何把库接入使用者的项目”。

借助 schematics,你可以为使用者提供三类自动化能力,它们都可以注册进同一个 collection 并随库一同发布:

CLI 命令触发场景典型职责
ng add使用者首次安装库安装最新版本包、把库的模块注册进应用根、按需写入dependencies
ng generate使用者创建库内定义的构件生成已预置好依赖注入与初始化逻辑的服务、组件等文件
ng update库发布含破坏性变更的新版本自动改写使用者项目中的 API 调用,平滑迁移到新版本

ng update类 schematics(常称为 migration schematics)同样登记在本篇介绍的 collection 中;下方章节会以ng addng generate两种类型为例,完整演示集合的搭建与实现。

仓库中的官方配套示例位于 adev/src/content/examples/schematics-for-libraries,其中projects/my-lib就是一个被 schematics 化的库工程,本文所有代码片段均取自该示例。

创建 Schematics 集合

一个 collection 本质上是“多个命名 schematic 的注册表”。创建流程分四步,这一阶段不会修改任何使用者项目文件:

  1. 在库根目录下创建schematics文件夹;
  2. schematics/内为第一个 schematic(ng-add)创建ng-add子文件夹;
  3. schematics根级创建collection.json文件;
  4. 编辑collection.json,定义集合的初始结构。

示例中的初始集合文件 collection.1.json 内容如下:

{ "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", "schematics": { "ng-add": { "description": "Add my library to the project.", "factory": "./ng-add/index#ngAdd", "schema": "./ng-add/schema.json" } } }

逐字段解读这份注册表:

  • $schema:指向 Angular Devkit 提供的 collection 结构约束文件,用于在编辑器中获得补全与校验(路径相对projects/my-lib所在位置解析到仓库node_modules中);
  • schematics:对象,描述这个集合中包含的所有命名 schematic;
  • 第一条目即名为ng-add的 schematic:description说明用途,factory指向该 schematic 被执行时调用的工厂函数——格式为./<路径>#<导出的函数名>,例如./ng-add/index#ngAdd表示调用ng-add/index.ts中导出的ngAdd函数;schema指向声明命令行选项的 JSON Schema 文件。

接着在库工程的 package.json 中加入schematics字段,指向集合描述文件:

{ "name": "my-lib", "version": "0.0.1", "schematics": "./schematics/collection.json" }

Angular CLI 正是通过该字段在已安装的包内定位命名的 schematics。从@angular-devkit/schematics的集合解析机制看,CLI 会把包入口中schematics字段解析为 collection 文件路径,再依据ng <command> <collection>:<name>的语法找到schematics.<name>下的factoryschema注册表先建好、package.json先声明,后续实现的每个 schematic 才能被 CLI 发现

提供安装支持:编写 ng-add schematic

ng addschematic 用于增强使用者的首次安装体验。Angular CLI 会自动安装库的最新版本,而你的 schematic 负责在安装后完成初始化改造。仍以官方示例的三个文件为准。

1. schema.json:声明命令行选项

schematics/ng-add/下创建 schema.json:

{ "$schema": "https://json-schema.org/schema", "$id": "SchematicsMyLibNgAdd", "title": "MyLib ng add Schema", "type": "object", "properties": { "project": { "type": "string", "description": "Name of the project.", "$default": { "$source": "projectName" } } } }

关键点在于project选项的$default声明:$source设为projectName,意味着当使用者在命令行没有显式传--project时,CLI 会自动把“当前/默认项目名”作为该选项的默认值注入。这类$source注入机制是 Angular CLI 选项解析约定的一部分,无需工厂函数自行兜底。

2. schema.ts:类型化接口

创建 schema.ts,为schema.json中的选项提供 TypeScript 类型:

export interface Schema { // Name of the project. project: string; }

3. index.ts:工厂函数

创建核心文件 index.ts:

import {Rule} from '@angular-devkit/schematics'; import {addRootImport} from '@schematics/angular/utility'; import {Schema} from './schema'; export function ngAdd(options: Schema): Rule { // Add an import `MyLibModule` from `my-lib` to the root of the user's project. return addRootImport( options.project, ({code, external}) => code`${external('MyLibModule', 'my-lib')}`, ); }

该工厂演示了一个非常有价值的模式——借助@schematics/angular/utility提供的addRootImport,把模块注册写进“应用根部”

  • addRootImport接收项目名与一个回调,回调需返回一段“代码块”;
  • 回调参数code是一个带标签的模板字符串函数,你在其内部书写任意希望插入的代码;
  • 代码中出现的外部符号必须用external函数包裹,例如external('MyLibModule', 'my-lib')。这样框架才会为你自动生成对应的import语句(这里会生成import { MyLibModule } from 'my-lib';),并把MyLibModule挂到根模块或应用引导配置上,而不会把 import 与使用位置硬编码耦合。

对使用者来说,ng add my-lib完成后,项目不仅装上了包,根模块中也被正确地接入了库声明的模块。

定义依赖保存类型:save 选项

CLI 执行ng add时默认把包写入dependencies,但库作者可以通过package.json里的ng-add字段自定义行为。示例 package.json 中配置为:

"ng-add": { "save": "devDependencies" }

save的可选值决定了库应写入使用者的dependenciesdevDependencies,还是不写入package.json

行为
false不把包加入package.json
true加入dependencies
"dependencies"加入dependencies
"devDependencies"加入devDependencies

对于仅在构建期使用的辅助库(例如需要与你的主库分离发布的工具包),devDependencies是常见选择;而运行时依赖的主库则通常省略该字段或设为true

构建 Schematics 并打进库产物

schematics 源码默认不会被ng-packagr编入库分发目录,因此需要先构建库、再独立编译 schematics,最后把它们一起放进dist

官方示例对应的做法需要两个前提:

  1. 为 schematics 单独提供一份 TypeScript 配置(说明如何编译、输出到哪);
  2. 在库的package.json中补充构建脚本,把编译产物复制进库的分发包。

tsconfig.schematics.json

tsconfig.lib.json(负责库构建)旁新增 tsconfig.schematics.json:

{ "compilerOptions": { "baseUrl": ".", "lib": ["es2018", "dom"], "declaration": true, "module": "commonjs", "moduleResolution": "node", "noEmitOnError": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": true, "noImplicitThis": true, "noUnusedParameters": true, "noUnusedLocals": true, "rootDir": "schematics", "outDir": "../../dist/my-lib/schematics", "skipDefaultLibCheck": true, "skipLibCheck": true, "sourceMap": true, "target": "es6", "types": ["jasmine", "node"] }, "include": ["schematics/**/*"], "exclude": ["schematics/*/files/**/*"] }

其中两个选项是整份配置的灵魂:

选项说明
rootDir声明schematics文件夹为待编译的输入根目录,保证输出目录结构以schematics为顶层
outDir输出到库的分发目录,默认即工作区根下的dist/my-lib,此处进一步落到其schematics子目录

注意excludeschematics/*/files/**/*排除在编译之外:files下的模板文件带有.template后缀与 EJS 风格占位语法,不是合法的 TypeScript,必须原样拷贝而非编译(见下文postbuild脚本)。

package.json 构建脚本

在库工程projects/my-lib的 package.json 中补充:

"scripts": { "build": "tsc -p tsconfig.schematics.json", "postbuild": "copyfiles schematics/*/schema.json schematics/*/files/** schematics/collection.json ../../dist/my-lib/" }
  • build:用自定义tsconfig.schematics.json把 schematics 的 TypeScript 源码(工厂函数、schema 接口等)编译为 CommonJS 模块并写入../../dist/my-lib/schematics
  • postbuildbuild成功后,用copyfiles不能/不必编译的 JSON 与模板文件按原路径复制进dist/my-lib/——包括各 schematic 的schema.jsonmy-service/files/**模板目录,以及集合入口collection.json

脚本依赖两个 npm 包:copyfilestypescript。示例将二者以本地路径形式写入devDependencies(如"copyfiles": "file:../../node_modules/copyfiles"),如果你是在独立环境下编写脚本,可改为普通版本号依赖,然后进入devDependencies所在的工程目录执行npm install安装后即可运行脚本。

完成这一步后,dist/my-lib中将同时包含库主体与可被 CLI 解析的 schematics 集合。

提供生成支持:编写 ng generate schematic

第二种 schematic 让使用者通过ng generate直接生成库定义好的构件。官方示例假设库定义了一个需要预置配置的服务my-service,期望使用者执行:

ng generate my-lib:my-service

命令语法my-lib:my-service中,冒号左侧是 collection 名(即包名),右侧是在集合中注册的 schematic 名。

配置新的 schematic

更新 collection.json

编辑 collection.json,为新 schematic 登记条目并指向其 schema 文件:

{ "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", "schematics": { "ng-add": { "description": "Add my library to the project.", "factory": "./ng-add/index#ngAdd", "schema": "./ng-add/schema.json" }, "my-service": { "description": "Generate a service in the project.", "factory": "./my-service/index#myService", "schema": "./my-service/schema.json" } } }
schema.json

schematics/my-service/下创建 schema.json:

{ "$schema": "https://json-schema.org/schema", "$id": "SchematicsMyService", "title": "My Service Schema", "type": "object", "properties": { "name": { "description": "The name of the service.", "type": "string" }, "path": { "type": "string", "format": "path", "description": "The path to create the service.", "visible": false, "$default": { "$source": "workingDirectory" } }, "project": { "type": "string", "description": "The name of the project.", "$default": { "$source": "projectName" } } }, "required": ["name"] }

顶层字段含义如下:

  • $id:该 schema 在集合中的唯一 ID;
  • title:对人类可读的 schema 描述;
  • type:描述properties提供值的类型(此处为对象);
  • properties:定义 schematic 的全部可选选项。

properties中每个选项都把“键”关联到type(期望值的形状)、description(当使用者通过--help查询该 schematic 用法时展示的帮助文本)以及可选的 alias。若需查阅更丰富的选项定制能力(如$default的其它$source取值、enumx-prompt等),可参考 Angular CLI 工作区自身的 schema 约定。

注意本文件顶部的required声明了name必填,而未声明path/project必填——它们都有$default回退。

schema.ts

创建配套的 schema.ts:

export interface Schema { // The name of the service. name: string; // The path to create the service. path?: string; // The name of the project. project?: string; }

三个选项在工厂中的职责:

选项说明
name希望创建的服务的名称,必填,模板将据此生成类名与文件名
path覆盖 schematic 的目标路径;未提供时默认取当前工作目录(workingDirectory
project指定要在哪个项目上运行该 schematic;未提供时在 schematic 内部可以依据默认规则推导

添加模板文件

要让 schematic 在项目中产出真实文件,需要准备自己的模板。Schematics 模板支持在文件路径与文件内容两处执行占位符替换与代码拼接。

  1. schematics/my-service/内创建files/文件夹;
  2. 创建名为__name@dasherize__.service.ts.template的文件——注意.template后缀会在最终产物中被剥除,而文件名本身由“name 选项的 dasherize 形式”决定。示例模板 __name@dasherize__.service.ts.template 会生成一个已经把HttpClient注入到http属性的服务:
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; @Injectable({ providedIn: 'root' }) export class <%= classify(name) %>Service { private http = inject(HttpClient); }

模板语法解析:

  • <%= classify(name) %>:在内容中执行表达式并输出结果——classify(name)会把 name 转为标题式类名。若传入my-data,这里渲染为MyData,拼上Service后类名即MyDataService
  • 文件名中的__name@dasherize__:路径模板占位符,@dasherize是路径用字符串转换器,把 name 转为短横线小写形式。若 name 为my-data(或MyData),文件最终名为my-data.service.ts

classifydasherize是 schematics 框架提供的字符串工具函数(@angular-devkit/corestrings命名空间中同名导出),而name则由工厂函数作为模板数据属性注入——它正是你在 schema 中定义、由命令行传入的同一个name

添加工厂函数(空规则起步)

生成类 schematic 的核心是工厂函数。Schematics 框架提供了一套文件模板系统,同时支持路径模板内容模板:系统作用于输入Tree中加载的文件/路径内的占位符,并用传入Rule的值完成填充。

官方示例从空工厂起步(见 index.1.ts):

import {Rule, Tree} from '@angular-devkit/schematics'; import {Schema as MyServiceSchema} from './schema'; export function myService(options: MyServiceSchema): Rule { return (tree: Tree) => tree; }

这个工厂直接原样返回Tree,不做任何修改;options正是从ng generate命令透传进来的选项值。接下来要做的,是把占位工厂替换为真正改写用户项目的逻辑。

定义生成规则:解析项目并渲染模板

使用者安装库的 Angular 工作区通常包含多个项目(应用与库并存)。使用者可以在命令行指定--project,也可以缺省;无论哪种情形,工厂内部都必须解析出“当前 schematic 作用于哪个项目”,才能从项目配置中取回信息。

这依赖传入工厂函数的Tree对象——Tree的方法暴露了工作区完整文件树,允许 schematic 执行期间读写任意文件。

解析工作区配置

要确定目标项目,使用workspaces.readWorkspace读取工作区配置文件angular.json,而它需要一个从Tree构建的workspaces.WorkspaceHost。示例 index.ts 先实现了一个薄封装 host:

import { Rule, Tree, SchematicsException, apply, url, applyTemplates, move, chain, mergeWith, } from '@angular-devkit/schematics'; import {strings, normalize, virtualFs, workspaces} from '@angular-devkit/core'; import {Schema as MyServiceSchema} from './schema'; function createHost(tree: Tree): workspaces.WorkspaceHost { return { async readFile(path: string): Promise<string> { const data = tree.read(path); if (!data) { throw new SchematicsException('File not found.'); } return virtualFs.fileBufferToString(data); }, async writeFile(path: string, data: string): Promise<void> { return tree.overwrite(path, data); }, async isDirectory(path: string): Promise<boolean> { return !tree.exists(path) && tree.getDir(path).subfiles.length > 0; }, async isFile(path: string): Promise<boolean> { return tree.exists(path); }, }; }

WorkspaceHost把 Tree 的文件访问能力(读/写/判目录/判文件)适配为workspaces模块可用的接口。随后在工厂内解析工作区并核对项目名:

export function myService(options: MyServiceSchema): Rule { return async (tree: Tree) => { const host = createHost(tree); const {workspace} = await workspaces.readWorkspace('/', host); const project = options.project != null ? workspace.projects.get(options.project) : null; if (!project) { throw new SchematicsException(`Invalid project name: ${options.project}`); } const projectType = project.extensions.projectType === 'application' ? 'app' : 'lib'; if (options.path === undefined) { options.path = `${project.sourceRoot}/${projectType}`; } // ...(模板规则见下一节) }; }

几个关键判断:

  • workspace.projects持有所有项目粒度的配置信息;
  • 务必校验项目存在project为 null 时抛出SchematicsException(携带“无效项目名”信息),避免后续在空值上取属性;
  • project.extensions.projectType区分applicationlibrary,代码据此把目标目录后缀定为applib
  • options.path决定模板文件最终被移动到哪。schema 中path$default是当前工作目录,但实际工作区里更稳妥的是:未显式提供path时,取项目配置里的sourceRoot拼上projectType(如src/appprojects/xxx/src/lib),这正是示例所做的回退逻辑。

用 apply/url/applyTemplates/move 渲染模板

一条Rule可以读取外部模板文件、做变换,再返回携带变换结果的新Rule。示例中把“读取模板 → 注入模板数据 → 移动到目标目录”三步用apply串起来:

const templateSource = apply(url('./files'), [ applyTemplates({ classify: strings.classify, dasherize: strings.dasherize, name: options.name, }), move(normalize(options.path as string)), ]); return chain([mergeWith(templateSource)]);

各工具函数的作用与机制:

函数说明
url()从文件系统读取源文件,路径相对当前 schematic(此处读取同目录的./files
apply()接收两个参数(一个 source、一组 rules),把多条规则依次应用到 source 上并返回变换后的 source
applyTemplates()接收“希望暴露给模板与模板文件名使用的方法/属性”对象,返回一条Rule。正是在这里注入classify()dasherize()name属性
classify()把值转为标题式(Title Case),如my serviceMyService
dasherize()把值转为小写短横线形式,如MyServicemy-service
move()在 schematic 应用时把 source 文件移动到目标位置
chain()把多条规则合并为一条规则,使单个 schematic 内可顺序执行多个操作
mergeWith()把变换好的 source 合并进当前Tree,真正把生成的文件落盘

上述流程的职责划分清晰:url('./files')负责把模板载入内存 source;applyTemplates({classify, dasherize, name})用真实数据替换文件名与内容中的占位符(这就是文档所说的 path template 与 content template 两类模板系统的统一入口);move(...)把渲染结果定向到目标目录;最后chain把模板合并规则与其它待执行逻辑收拢成最终规则返回给 CLI。

注意模板文件位于files/目录但使用了move()指向options.path,因此生成的my-data.service.ts会落在解析出的src/app之类的源码目录,而不会连同files前缀一起出现。

完整工厂函数一览

将上述片段拼合即得到完整实现,可直接对照仓库文件 index.ts 阅读:

export function myService(options: MyServiceSchema): Rule { return async (tree: Tree) => { const host = createHost(tree); const {workspace} = await workspaces.readWorkspace('/', host); const project = options.project != null ? workspace.projects.get(options.project) : null; if (!project) { throw new SchematicsException(`Invalid project name: ${options.project}`); } const projectType = project.extensions.projectType === 'application' ? 'app' : 'lib'; if (options.path === undefined) { options.path = `${project.sourceRoot}/${projectType}`; } const templateSource = apply(url('./files'), [ applyTemplates({ classify: strings.classify, dasherize: strings.dasherize, name: options.name, }), move(normalize(options.path as string)), ]); return chain([mergeWith(templateSource)]); }; }

运行你的库 schematic

构建与验证的完整链路如下。

构建库与 schematics

在(作为“已安装该库”的使用方)工作区根目录先构建库本体:

ng build my-lib

随后进入库目录,运行刚才定义的 schematics 构建脚本:

cd projects/my-lib npm run build

npm run build触发tsc -p tsconfig.schematics.json,继而自动执行postbuildcopyfiles,把集合与模板送入分发目录。执行顺序有讲究:schematics 必须晚于库构建,才能被放进dist/my-lib的正确位置。

链接库到 node_modules

库与 schematics 打好包后位于工作区根的dist/my-lib。为了让 CLI 能按包名解析到它,需要在当前使用的工程里把它链接进node_modules。在工程根执行:

npm link dist/my-lib

(若你在另一个独立消费工程中验证,也可在该工程内执行npm link my-lib;要点是让 node 解析到带schematics字段的、已打包好的包目录。)链接完成后,CLI 即可通过包入口发现collection.json

运行 schematic 并核对产物

现在用注册名运行刚才实现的生成类 schematic:

ng generate my-lib:my-service --name my-data

CLI 输出中会出现文件创建记录,例如:

CREATE src/app/my-data.service.ts (208 bytes)

核对生成文件 my-data.service.ts 的预期内容:my-dataclassifyMyData,类名为MyDataService;文件按 dasherize 规则落为my-data.service.ts;类中已含HttpClient的注入语句——无需使用者手写任何样板代码。

如果输出中出现Nothing to be done或找不到 collection 的错误,请依次检查:package.jsonschematics字段是否指向存在的collection.jsondist中是否真的复制了 schema 与模板文件、ng-add/my-servicefactory路径与导出函数名是否与源码一致。

小结

为 Library 编写 schematics 集合本质上是在做三件事:注册(collection.json + package.json 声明)实现(schema 描述选项 + 工厂函数改写 Tree)打包分发(tsconfig.schematics.json + build/postbuild 脚本)。本文以仓库官方示例schematics-for-libraries完整演示了ng add(用addRootImport把模块挂到应用根)与ng generate(用模板系统产出带依赖注入的服务)两条链路;同一集合中再登记带"package.json"/"migrations"等约定的 migration schematics,即可让ng update在版本升级时自动执行破坏性变更迁移。把三者做齐,你的库就能像 Angular 官方与主流生态库一样,被使用者在三条 CLI 命令内完成安装、使用与升级。

【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询