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 add与ng generate两种类型为例,完整演示集合的搭建与实现。
仓库中的官方配套示例位于 adev/src/content/examples/schematics-for-libraries,其中projects/my-lib就是一个被 schematics 化的库工程,本文所有代码片段均取自该示例。
创建 Schematics 集合
一个 collection 本质上是“多个命名 schematic 的注册表”。创建流程分四步,这一阶段不会修改任何使用者项目文件:
- 在库根目录下创建
schematics文件夹; - 在
schematics/内为第一个 schematic(ng-add)创建ng-add子文件夹; - 在
schematics根级创建collection.json文件; - 编辑
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>下的factory与schema。注册表先建好、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的可选值决定了库应写入使用者的dependencies、devDependencies,还是不写入package.json:
| 值 | 行为 |
|---|---|
false | 不把包加入package.json |
true | 加入dependencies |
"dependencies" | 加入dependencies |
"devDependencies" | 加入devDependencies |
对于仅在构建期使用的辅助库(例如需要与你的主库分离发布的工具包),devDependencies是常见选择;而运行时依赖的主库则通常省略该字段或设为true。
构建 Schematics 并打进库产物
schematics 源码默认不会被ng-packagr编入库分发目录,因此需要先构建库、再独立编译 schematics,最后把它们一起放进dist。
官方示例对应的做法需要两个前提:
- 为 schematics 单独提供一份 TypeScript 配置(说明如何编译、输出到哪);
- 在库的
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子目录 |
注意exclude把schematics/*/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;postbuild:build成功后,用copyfiles把不能/不必编译的 JSON 与模板文件按原路径复制进dist/my-lib/——包括各 schematic 的schema.json、my-service/files/**模板目录,以及集合入口collection.json。
脚本依赖两个 npm 包:copyfiles与typescript。示例将二者以本地路径形式写入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取值、enum、x-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 模板支持在文件路径与文件内容两处执行占位符替换与代码拼接。
- 在
schematics/my-service/内创建files/文件夹; - 创建名为
__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。
classify、dasherize是 schematics 框架提供的字符串工具函数(@angular-devkit/core的strings命名空间中同名导出),而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区分application与library,代码据此把目标目录后缀定为app或lib;options.path决定模板文件最终被移动到哪。schema 中path的$default是当前工作目录,但实际工作区里更稳妥的是:未显式提供path时,取项目配置里的sourceRoot拼上projectType(如src/app或projects/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 service→MyService |
dasherize() | 把值转为小写短横线形式,如MyService→my-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 buildnpm run build触发tsc -p tsconfig.schematics.json,继而自动执行postbuild的copyfiles,把集合与模板送入分发目录。执行顺序有讲究: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-dataCLI 输出中会出现文件创建记录,例如:
CREATE src/app/my-data.service.ts (208 bytes)核对生成文件 my-data.service.ts 的预期内容:my-data经classify得MyData,类名为MyDataService;文件按 dasherize 规则落为my-data.service.ts;类中已含HttpClient的注入语句——无需使用者手写任何样板代码。
如果输出中出现Nothing to be done或找不到 collection 的错误,请依次检查:package.json的schematics字段是否指向存在的collection.json、dist中是否真的复制了 schema 与模板文件、ng-add/my-service的factory路径与导出函数名是否与源码一致。
小结
为 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),仅供参考