0%

angular 自定义shema (二)

上一篇文章我们了解了自定义原理图的基本操作,可以简单生成文件,并和Angular Cli进行链接,我们如何真正的创建我们自己的原理图?在使用Angular Cli自带的命令创建组件的时候是可以更新模块里面声明组价的语句的,那么它是如何实现的?我们来细致的分析下@schematics/angular/component的源代码。

文件组织结构

文件组织结构

  • index.ts里面是处理模板和项目对接的逻辑
  • schema.json里面是这个声明这个原理图里需要的参数
  • files里面是模板文件,会根据参数生成最终的组件文件(html文件、样式文件、spec测试文件、ts文件)

index.ts文件分析

首先看文件里面的方法:

  • function readIntoSourcefile(host: Tree, moudlePath: string): ts.SourceFile
  • function addDeclarationToNgMoudle(options: CommponentOptions): Rule
  • function buildSelector(options: Components, projectPrefix: string): void

根据方法名字(以及概览一下index.ts)来看,readIntoSourceFile是读取对应的module源文件,以辅助addDeclarationToNgModule方法在将新创建的component声明到module文件的时候去更新module文件。而buildSelector方法是处理生成的组件的元信息selector的选择器字符串。

主体方法 export default function (options: ComponentOptions): Rule

主体方法返回了一个异步的方法,因为方法内部使用await调用了一个异步获取Angular项目配置的文件。

1
return async (host: tree) => {...}

获取Angular的项目配置

1
2
const workspace = await getWorkspace(host);
const project = workspace.projects.get(options.project as string);

这里getWorkspace方法是库提供的:import {getWorkspace} from "@schematics/angular/utility/workspace";,从当前的Angular中获取angular.json文件。然后获取参数中project的配置:workspace.projects.get(options.project as string)。如果我们不传project参数的话,应该是取的默认项目,这个在angular.json有对应的配置项的。

1
2
3
if (options.path === undefined && project) {
options.path = buildDefaultPath(project);
}

如果在执行component的原理图的时候没有输入path参数,那么会从angular.json中读取默认的项目的的path

获取距离最近的NgModule

我们在执行ng g c命令来创建组件的时候会将组件的声明写入“合适”的ngModule中。获取合适的ngModule

1
options.module = findModuleFromOptions(host, options);

findModuleFromOptions方法是组件提供的方法:import {findModuleFromOptions} from "@schematics/angular/utility/find-module"

解析路径、名称、选择器

1
2
3
4
const parsedPath = parseName(options.path as string, options.name);
options.name = parsedPath.name;
options.path = parsedPath.path;
options.selector = options.selector || buildSelector(options, project && project.prefix || '');

上面这一段是为了从options.name中解析路径、组件名称、以及组件的选择器。

比如,当我们想在src/app/pages/user下面创建create组件,那么我们的命令是:

1
ng g c pages/user/create

上面的代码会解析出来,我们的路径是:src/app/pages/user/create,组件名称是create,选择器是app-create

继续解析上面的代码,第一行中的parseName是组件提供的方法import {parseName} from "@schematics/angular/utility/parse-name",而buildSelector方法是在这里自定义的,方法比较短,我们来看下这个方法:

1
2
3
4
5
6
7
8
9
function buildSelector(options: ComponentOptions, projectPrefix: string) {
let selector = strings.dasherize(options.name);
if (options.prefix) {
selector = `${options.prefix}-${selector}`;
} else if (options.prefix === undefined && projectPrefix) {
selector = `${projectPrefix}-${selector}`;
}
return selector;
}

很容易理解,首先说下strings.dasherize(),这个strings是库提供的的一组字符串处理方法,需要引入:import {strings} from "@angular-devkit/core";,在看strings.dasherize()方法的作用是,用破折号替换下划线、空格或小驼峰。也就是格式化name字符串为xxx-xxx的形式。

然后就是看有没有prefix参数,有的话就是prefix-name,没有的话就是app-name,这个很有用,防止项目中组件过多导致选择器重复,我们可以在创建子类的组件的时候,加上prefix修饰。

然后是验证组件的名字和选择器:

1
2
validateName(options.name);
validateHtmlSelector(options.selector);

validateNamevalidateHtmlSelector是组件提供的方法,需要引入:import {validateHtmlSelector, validateName} from "@schematics/angular/utility/validation";,用来验证nameselector的合法性。

应用于模板文件

1
2
3
4
5
6
7
8
9
10
11
const templateSource = apply(url('./files'), [
options.skipTests ? filter(path => !path.endsWith('.spec.ts.template')) : noop(),
options.inlineStyle ? filter(path => !path.endsWith('.__style__.template')) : noop(),
options.inlineTemplate ? filter(path => !path.endsWith('.html.template')) : noop(),
applyTemplates({
...strings,
'if-flat': (s: string) => options.flat ? '' : s,
...options,
}),
move(parsedPath.path),
]);

这里用的库的方法很多,我们需要慢慢来分析

  • apply(source: Source, rules: Rule[]): Source 对源文件应用多个规则,并返回转换后的源。
  • url(path: string) 从路径字符串获取模板文件。
  • filter()类似于array的filter方法,过滤掉满足条件的项
  • noop()返回一个空的规则
  • applyTemplates() 将参数以及strings方法应用于模板文件
  • move() 移动处理后的模板到特定目录。

以上方法均需要引入库方法:import {mergeWith, apply, Rule, url, Tree, move, filter, applyTemplates, noop} from "@angular-devkit/schematics";

总的来说,这一段就是应用输入的参数,比如有是否跳过测试文件,是否是内联样式,是否是内联html等。

最后一哆嗦

1
2
3
4
5
return chain([
addDeclarationToNgModule(options),
mergeWith(templateSource),
options.lintFix ? applyLintFix(options.path) : noop(),
]);

最后的规则组合。这里也有几个库方法:

  • chain() 将多个规则链接为一个规则。
  • mergeWith() 将输入源与输入树合并
  • applyLintFix() 这个不知道是什么方法,根据字面意思来看,好像是应用tslint修复?(参数控制的,而且这个参数也不怎么常用)

再来看addDeclarationToNgModule(options)方法,这个方法是自定义的,比较长,我们做为一个分支另说。我们只需要知道这个方法是将组件的声明添加到ngModule里面的即可。

schema.json文件分析

我们知道,schema.ts中定义了我们这个原理图可以接受的参数,那么schema.json有什么用?当然,这个更有用,它不仅提供的了参数的描述,也附带了更多的功能,类似于,定义参数的默认值,定义参数的命令行提示,定义哪些参数是必须的等等。

它的结构是:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"$schema": "http://json-schema.org/schema",
"id": "SchematicsAngularComponent",
"title": "Angular Component Options Schema",
"type": "object",
"description": "Creates a new generic component definition in the given or default project.",
"properties": {
...
},
"required": [
"name"
]
}

里面重要的是idproperties以及required

  • id 声明这个原理图的id,类似于它的”名字“。(猜想:好想和angular.json文件有关联,后续再发现)
  • `properties’ 这个原理图需要接受的参数,都可以在这个里面进行定义。
  • required 声明在执行这个原理图的时候必须的参数

properities中我们也着重几个常用且重要的来看下。

name 参数

1
2
3
4
5
6
7
8
9
"name": {
"type": "string",
"description": "The name of the component.",
"$default": {
"$source": "argv",
"index": 0
},
"x-prompt": "What name would you like to use for the component?"
},

这里描述了name参数的类型、介绍,以及默认第一个不加标志的参数就是name的值。这样我们可以直接使用:

1
ng g c tony

这样来创建一个组件,而原理图接受的name参数就是tony',等效于:ng g c –name=tony`。

project 参数

1
2
3
4
5
6
7
"project": {
"type": "string",
"description": "The name of the project.",
"$default": {
"$source": "projectName"
}
},

这里定义了project的默认值,当我们创建组件的时候,如果没有特意说明project参数,那么就会拿angular.json中的defaultProject里面的默认项目。这个很有用。

其他的有:

  • style 定义了默认的样式文件后缀以及样式文件后缀的枚举。
  • type 定义了这个原理图创建的文件的后缀,这里是component
  • prefix 选择器的前缀。默认是app

模板文件分析

html文件

html文件是__name@dasherize__.__type@dasherize__.html.template,我们可以结合前面的strings里面的方法来分析,这里:

  • __name@dasherize__ 是输出name变量,并应用dasherize方法,也就是将name字符串解析为短横线的格式。
  • __type@dasherize__ 是输出type变量,并应用dasherize方法,同上。

举个例子,我们的创建语句是:ng g c tonyBest,那么创建的html文件就是:tony-best.component.html

再看模板内容:

1
<p><%= dasherize(name) %> works!</p>

很简单的一句话,这样就是:<p>tony-best workes</p>

样式文件

样式文件是:__name@dasherize__.__type@dasherize__.__style__.template,结合上面html文件,唯一的区别是这里的__style__,这是输出style变量的值,假如我们的默认样式是css,那么这里会生成:tony-best.component.css

ts文件

ts文件是:__name@dasherize__.__type@dasherize__.ts.template,这里同上,我们可以知道,它会生成文件:tony-best.component.ts

ts文件里面内容比较多,但都是判断语句,我们可以来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

import { Component, OnInit<% if(!!viewEncapsulation) { %>, ViewEncapsulation<% }%><% if(changeDetection !== 'Default') { %>, ChangeDetectionStrategy<% }%> } from '@angular/core';

@Component({<% if(!skipSelector) {%>
selector: '<%= selector %>',<%}%><% if(inlineTemplate) { %>
template: `
<p>
<%= dasherize(name) %> works!
</p>
`,<% } else { %>
templateUrl: './<%= dasherize(name) %>.<%= dasherize(type) %>.html',<% } if(inlineStyle) { %>
styles: []<% } else { %>
styleUrls: ['./<%= dasherize(name) %>.<%= dasherize(type) %>.<%= style %>']<% } %><% if(!!viewEncapsulation) { %>,
encapsulation: ViewEncapsulation.<%= viewEncapsulation %><% } if (changeDetection !== 'Default') { %>,
changeDetection: ChangeDetectionStrategy.<%= changeDetection %><% } %>
})
export class <%= classify(name) %><%= classify(type) %> implements OnInit {

constructor() { }

ngOnInit() {
}
}

我们先来分析第一行:

1
2
3
4
5
6
import { 
Component,
OnInit
<% if(!!viewEncapsulation) { %> , ViewEncapsulation<% }%>
<% if(changeDetection !== 'Default') { %>, ChangeDetectionStrategy<% }%> } from '@angular/core';

其实这里是根据viewEncapsulationchangeDection变量的值,来从@angular/core中引用ViewEncapsulation, ChangeDetectionStrategy

再看@Component的元数据部分:

  • selector 判断skipSelector参数,是否不需要selector,如果不需要就不出现这一行。
  • template 判断inlineTemplate参数,是否是内联html,如果是内联的html,就输出这一行的内容
  • templateUrl 如果不是内联html,就将生成的xxx.component.html放到这个字符串数组里面。
  • style 如果是内联css就保留这个元数据
  • styleUrl 如果不是内联css就应用刚生成的样式文件。
  • encapsulation 根据参数,如果没设定encapsulation就不输出这一行,有设定就是设定的值。
  • changeDetection 根据参数,如果没有设定就不输出这一行,如果设定了就是设定的值。

接下来就是组件的类名:

1
export class <%= classify(name) %><%= classify(type) %> implements OnInit {}

classify方法是生成大驼峰字符串的方法。比如,我们上面的ng g c tonyBest,生成的ts文件里面的这个就是:

1
export class TonyBestComponent implates OnInit {}

然后下面就是默认两个方法:constructngOninit。这是组件常用的方法,生成的时候带着这两个方法还是很有必要的。

重点看下如何写入module的

本想着再开个文章,算了,成热打铁,来看如何写入NgModule的。先看addDeclarationToNgModule方法的主体结构:

1
2
3
addDeclarationToNgModule(options: ComponentOptions) {
return (host: Tree) => {return host}
}

它对Tree做了一系列处理,然后返回。来看看内脏内部,我们分段来分析,首先看第一段:

1
2
3
4
5
6
if (options.skipImport || !options.module) {
return host;
}
options.type = !!options.type ? options.type : 'Component';
const modulePath = options.module;
const source = readIntoSourceFile(host, modulePath);

首先判断skipImportoptions.module,如果不存在options.module或者参数skipImport为真的话,等于没必要写入ngModule,所以就直接返回。

下面是确认了options.type,我们这里是component。然后获得ngModule的路径,然后读取这个ngModule的源文件。来看看readIntoSourceFile方法:

1
2
3
4
5
6
7
8
9
function readIntoSourceFile(host: Tree, modulePath: string): ts.SourceFile {
const text = host.read(modulePath);
if (text === null) {
throw new SchematicsException(`File ${modulePath} does not exist.`);
}
const sourceText = text.toString('utf-8');

return ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true);
}

这个方法是确保这个ngModule文件存在并可读,然后读取源文件并返回,没啥可说的。

然后接下来继续回到addDeclarationToNgModule看组件如何写入ngModule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const componentPath = `/${options.path}/`
+ (options.flat ? '' : strings.dasherize(options.name) + '/')
+ strings.dasherize(options.name)
+ '.'
+ strings.dasherize(options.type);
const relativePath = buildRelativePath(modulePath, componentPath);
const classifiedName = strings.classify(options.name) + strings.classify(options.type);
const declarationChanges = addDeclarationToModule(
source,
modulePath,
classifiedName,
relativePath
);
const declarationRecorder = host.beginUpdate(modulePath);
for (const change of declarationChanges) {
if (change instanceof InsertChange) {
declarationRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(declarationRecorder);

首先通过拼接生成的组件的ts文件的路径放在变量componentPath中。比如我们前面生成的TonyBest.component.ts组件的话,它的路径是:src/app/components/tony-best/tony-best.component.ts。我们在ngModule中引用组件的时候是相对路径,不会是绝对路径,所以这里调用buildRelativePath()方法生成相对路径,存放在relativePath变量中。然后classifiedName中存放这个组件的名字,然后调用addDeclarationToModule方法,将前面准备的东西都塞给它去处理,然后通过host.beginUpdatehost.commitUpdate即可完成更新。

PS:这里说的比较糙,但都是库提供的方法,具体后续可以研究。

之后是两个判断,看参数中有没有exportentryComponent这两个参数,这也不怎么重要,在创建组件的时候并不一定想的面面俱到,如果创建后有需要可以手动引入,但是自动引入这不是很酷么~😎


ok,我们这里也就结束了,我们主要是分析了@schematics/angular/components里面我们使用cli创建component组建的时候的运行过程。有些事情,你研究研究总感觉是魔法,研究清楚了,就可以为自己所用,解开那层神秘感,探寻内部的原理,这就是探索的乐趣。

下一篇我们再开始用掌握的东西来创建我们自己的原理图,来创建“page”类型的组件。

码字辛苦,打赏个咖啡☕️可好?💘