jiayisheji / blog Goto Github PK
View Code? Open in Web Editor NEW没事写写文章,喜欢的话请点star,想订阅点watch,千万别fork!
Home Page: https://jiayisheji.github.io/blog/
没事写写文章,喜欢的话请点star,想订阅点watch,千万别fork!
Home Page: https://jiayisheji.github.io/blog/
今天,我将与你分享在使用NestJS和MongoDB时一直在使用的工作流程/技术。 此工作流程利用了Typegoose的功能。
背景:最近在升级 nest-cnode 项目,之前使用的是
Mongoose
,它的操作和Typescript有点冲突,创建schema
和interface
要写2个基本一样的,这样就比较累,虽然可以用工具生成,但是还是多了一个步骤,有没有更简单的呢,一开始想到 Typeorm ,看样子很不错的。
ORM for TypeScript and JavaScript (ES7, ES6, ES5). Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, SAP Hana, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms.
Typeorm的简介,虽说支持很多数据库,MongoDB也是支持的,还有一篇专门介绍兼容MongoDB的文档,我在另外一个项目使用了一下,发现支持的并不是那么好,这也难怪没有写 Supports
。需要寻找一个替代品,谷歌搜索找到了 Typegoose
。
注意:Typegoose是有2个版本,这点你可以github搜索,szokodiakos/typegoose,typegoose/typegoose。
关于这2个版本有什么不同呢?
基本差别不大,szokodiakos/typegoose
的暂停维护,查看原因,typegoose/typegoose
相当一个分支,不过两者写法还是有点不同。
szokodiakos/typegoose
class User extends Typegoose {
@prop()
name?: string;
}
const UserModel = new User().getModelForClass(User);
// UserModel is a regular Mongoose Model with correct types
(async () => {
const u = await UserModel.create({ name: 'JohnDoe' });
const user = await UserModel.findOne();
console.log(user);
// prints { _id: 59218f686409d670a97e53e0, name: 'JohnDoe', __v: 0 }
})();
typegoose/typegoose
class User {
@prop()
public name?: string;
}
const UserModel = getModelForClass(User);
// UserModel is a regular Mongoose Model with correct types
(async () => {
const { _id: id } = await UserModel.create({ name: 'JohnDoe' } as User); // an "as" assertion, to have types for all properties
const user = await UserModel.findById(id).exec();
console.log(user);
// prints { _id: 59218f686409d670a97e53e0, name: 'JohnDoe', __v: 0 }
})();
两者差别,一个是类继承,一个是函数。
两个目的都是一样,正如它们文档介绍那样:Typegoose - Define Mongoose models using TypeScript classes.
它们出现也是正如前面的困惑一样。介绍这么多,该进入正题了。
学习Typegoose,你需要对Mongoose熟悉,Typegoose就是在操作Mongoose。只是帮我们整合到TypeScript classes里
让我们开始吧。
首先使用@nestjs/cli初始化一个新的NestJS应用程序
nest new nest-typegoose-demo
cd nest-typegoose
接下来,让我们安装依赖项:
npm install --save @nestjs/mongoose mongoose @typegoose/typegoose
npm install --save-dev @types/mongoose
vs code 打开项目
然后,删除 app.controller.ts
和app.service.ts
。修改你的app.module.ts
使用@nestjs/mongoose
连接我们的Mongo连接。
app.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
MongooseModule.forRoot('mongodb://localhost:27017/nestjs-typegoose-demo', {
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: true,
}),
],
})
export class AppModule {}
运行你的
MongoDB
现在,你可以尝试运行服务器:
npm run start:dev
你应该会看到以下内容:
21:42:04 - File change detected. Starting incremental compilation...
21:42:04 - Found 0 errors. Watching for file changes.
[Nest] 14056 - 2020-03-16 21:42:09 [NestFactory] Starting Nest application...
[Nest] 14056 - 2020-03-16 21:42:09 [InstanceLoader] AppModule dependencies initialized +23ms
[Nest] 14056 - 2020-03-16 21:42:09 [InstanceLoader] MongooseModule dependencies initialized +0ms
[Nest] 14056 - 2020-03-16 21:42:09 [InstanceLoader] MongooseCoreModule dependencies initialized +15ms
[Nest] 14056 - 2020-03-16 21:42:09 [NestApplication] Nest application successfully started +7ms
我习惯把数据库管理单独放在一起,这样便于管理,也利于分层,在写法上,不会出现循环依赖的问题。虽然nest有解决方法,但是能避免就应该避免一下。
mkdir src/models // 存放我们所有的数据库管理
touch src/models/base.model.ts // 基础模型文件 存放通用模型,作为抽象父类
touch src/models/base.repository.ts // 基本服务文件 等下会说明
打开 base.model.ts
然后输入以下内容
import { Schema } from 'mongoose';
import { buildSchema, prop } from '@typegoose/typegoose';
import { AnyType } from 'src/shared/interfaces';
export abstract class BaseModel {
@prop()
created_at?: Date; // 创建时间
@prop()
updated_at?: Date; // 更新时间
public id?: string; // 实际上是 model._id getter
// 如果需要,可以向基本模型添加更多内容。
static get schema(): Schema {
return buildSchema(this as AnyType, {
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at',
},
toJSON: {
virtuals: true,
getters: true,
versionKey: false,
},
});
}
static get modelName(): string {
return this.name;
}
}
先来理解一下上面的代码:
created_at
,`updated_at和id是我所有领域模型的三个字段(你还可以添加更多通用属性或Schema字段)。timestamps启用映射我们创建时间和更新时间自动更新。id实际上是_id的一个getter,所以它总是在那里,但是为了能够在与.lean()或.toJSON()匹配时获取id,我们需要设置toJSON:{…}选项,如代码所示@prop()
将字段注释为Schema的一部分。在typegoose了解更多static get schema()
神奇的地方就在这里,typegoose暴露诸如getModelForClass()
和buildSchema()
之类的函数,我们只需要创建纯就可以通过这些方法和typegoose做绑定关联。为什么需要buildSchema()
,那是因为我们使用@nestjs/mongoose
,通过MongooseModule.forFeature
注册mongoose.model
,需要2个必须属性:name
和Schema
。buildSchema()
就是生成Schema
的方法。它的工作原理是我们调用buildSchema()并传入this。在这种情况下,在静态方法中,实际的类本身调用schema getter,这使得将get schema()放在抽象类上成为可能,这样我们就可以在这里通用处理一下。在此之前,我们需要编写方法来获取每个领域模型类的模式和模型名,这有点像样板文件。static get modelName()
就很简单。我们只返回this.name
,并且这个,同样在静态方法的上下文中,指向实际的类,所以this.name
返回类名。然而,如果你持怀疑态度,你可能想要返回一些其他的东西,或者只是有一个函数,它会为你的MongooseModel
返回一些有意义的名字。我倾向于在这里返回this.name
,因为我在更多的地方使用this.name
,比如swag UI
来标注tags
现在我们有了基础模型,让我们来处理基础服务。打开base.repository.ts并粘贴以下代码。但是在显示代码之前,我想解释一下。
什么是BaseRepository ?BaseRepository是Repository模式。然而,使用像mongoose这样的ODM,我感觉MongooseModel已经有点像一个存储库了。完全可以去掉存储库层,以减少整个应用程序中抽象的数量。同样,这取决于应用程序需求的特征。我只是想让大家明白我的观点,并解释我为什么要这么做。还有一个好处减少循环依赖。现在我们已经清楚了,让我们继续:
import {
ModelPopulateOptions,
QueryFindOneAndUpdateOptions,
Types,
DocumentQuery,
QueryFindOneAndRemoveOptions,
Query,
} from 'mongoose';
import { WriteOpResult, FindAndModifyWriteOpResultObject, MongoError } from 'mongodb';
import { Transform } from 'class-transformer';
import { IsOptional, Max, Min } from 'class-validator';
import { AnyType } from 'src/shared/interfaces';
import { BaseModel } from './base.model';
import { ReturnModelType, DocumentType } from '@typegoose/typegoose';
import { AnyParamConstructor } from '@typegoose/typegoose/lib/types';
import { InternalServerErrorException } from '@nestjs/common';
export type OrderType<T> = Record<keyof T, 'asc' | 'desc' | 'ascending' | 'descending' | 1 | -1>;
export type QueryList<T extends BaseModel> = DocumentQuery<Array<DocumentType<T>>, DocumentType<T>>;
export type QueryItem<T extends BaseModel> = DocumentQuery<DocumentType<T>, DocumentType<T>>;
/**
* Describes generic pagination params
*/
export abstract class PaginationParams<T> {
/**
* Pagination limit
*/
@IsOptional()
@Min(1)
@Max(50)
@Transform((val: string) => parseInt(val, 10) || 10)
public readonly limit = 10;
/**
* Pagination offset
*/
@IsOptional()
@Min(0)
@Transform((val: string) => parseInt(val, 10))
public readonly offset: number;
/**
* Pagination page
*/
@IsOptional()
@Min(1)
@Transform((val: string) => parseInt(val, 10))
public readonly page: number;
/**
* OrderBy
*/
@IsOptional()
public abstract readonly order?: OrderType<T>;
}
/**
* 分页器返回结果
* @export
* @interface Paginator
* @template T
*/
export interface Paginator<T> {
/**
* 分页数据
*/
items: T[];
/**
* 总条数
*/
total: number;
/**
* 一页多少条
*/
limit: number;
/**
* 偏移
*/
offset?: number;
/**
* 当前页
*/
page?: number;
/**
* 总页数
*/
pages?: number;
}
export abstract class BaseRepository<T extends BaseModel> {
constructor(protected model: ReturnModelType<AnyParamConstructor<T>>) { }
/**
* @description 抛出mongodb异常
* @protected
* @static
* @param {MongoError} err
* @memberof BaseRepository
*/
protected static throwMongoError(err: MongoError): void {
throw new InternalServerErrorException(err, err.errmsg);
}
/**
* @description 将字符串转换成ObjectId
* @protected
* @static
* @param {string} id
* @returns {Types.ObjectId}
* @memberof BaseRepository
*/
protected static toObjectId(id: string): Types.ObjectId {
try {
return Types.ObjectId(id);
} catch (e) {
this.throwMongoError(e);
}
}
/**
* @description 创建模型
* @param {Partial<T>} [doc]
* @returns {DocumentType<T>}
* @memberof BaseRepository
*/
createModel(doc?: Partial<T>): DocumentType<T> {
return new this.model(doc);
}
/**
* @description 获取指定条件全部数据
* @param {*} conditions
* @param {(Object | string)} [projection]
* @param {({
* sort?: OrderType<T>;
* limit?: number;
* skip?: number;
* lean?: boolean;
* populates?: ModelPopulateOptions[] | ModelPopulateOptions;
* [key: string]: any;
* })} [options]
* @returns {QueryList<T>}
*/
public findAll(conditions: AnyType, projection?: object | string, options: {
sort?: OrderType<T>;
limit?: number;
skip?: number;
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: AnyType;
} = {}): QueryList<T> {
return this.model.find(conditions, projection, options);
}
public async findAllAsync(conditions: AnyType, projection?: object | string, options: {
sort?: OrderType<T>;
limit?: number;
skip?: number;
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: AnyType;
} = {}): Promise<Array<DocumentType<T>>> {
const { populates = null, ...option } = options;
const docsQuery = this.findAll(conditions, projection, option);
try {
return await this.populates<Array<DocumentType<T>>>(docsQuery, populates);
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 获取带分页数据
* @param {PaginationParams<T>} conditions
* @param {(Object | string)} [projection]
* @param {({
* lean?: boolean;
* populates?: ModelPopulateOptions[] | ModelPopulateOptions;
* [key: string]: any;
* })} [options={}]
* @returns {Promise<Paginator<T>>}
*/
public async paginator(conditions: PaginationParams<T>, projection?: object | string, options: {
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: AnyType;
} = {}): Promise<Paginator<T>> {
const { limit, offset, page, order, ...query } = conditions;
// 拼装分页返回参数
const result: Paginator<T> = {
items: [],
total: 0,
limit,
offset: 0,
page: 1,
pages: 0,
};
// 拼装查询配置参数
options.sort = order;
options.limit = limit;
// 处理起始位置
if (offset !== undefined) {
result.offset = offset;
options.skip = offset;
} else if (page !== undefined) {
result.page = page;
options.skip = (page - 1) * limit;
result.pages = Math.ceil(result.total / limit) || 1;
} else {
options.skip = 0;
}
try {
// 获取分页数据
result.items = await this.findAllAsync(query, projection, options);
// 获取总条数
result.total = await this.count(query);
// 返回分页结果
return Promise.resolve(result);
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 获取单条数据
* @param {*} conditions
* @param {(Object | string)} [projection]
* @param {({
* lean?: boolean;
* populates?: ModelPopulateOptions[] | ModelPopulateOptions;
* [key: string]: any;
* })} [options]
* @returns {QueryItem<T>}
*/
public findOne(conditions: AnyType, projection?: object | string, options: {
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: AnyType;
} = {}): QueryItem<T> {
return this.model.findOne(conditions, projection || {}, options);
}
public findOneAsync(conditions: AnyType, projection?: object | string, options: {
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: AnyType;
} = {}): Promise<T | null> {
try {
const { populates = null, ...option } = options;
const docsQuery = this.findOne(conditions, projection || {}, option);
return this.populates<T>(docsQuery, populates).exec();
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 根据id获取单条数据
* @param {(string)} id
* @param {(Object | string)} [projection]
* @param {({
* lean?: boolean;
* populates?: ModelPopulateOptions[] | ModelPopulateOptions;
* [key: string]: any;
* })} [options={}]
* @returns {QueryItem<T>}
*/
public findById(id: string, projection?: object | string, options: {
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: AnyType;
} = {}): QueryItem<T> {
return this.model.findById(BaseRepository.toObjectId(id), projection, options)
}
public findByIdAsync(id: string, projection?: object | string, options: {
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: AnyType;
} = {}): Promise<T | null> {
try {
const { populates = null, ...option } = options;
const docsQuery = this.findById(id, projection || {}, option);
return this.populates<T>(docsQuery, populates).exec();
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 获取指定查询条件的数量
* @param {*} conditions
* @returns {Query<number>}
*/
public count(conditions: AnyType): Query<number> {
return this.model.count(conditions)
}
public countAsync(conditions: AnyType): Promise<number> {
try {
return this.count(conditions).exec();
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 创建一条数据
* @param {Partial<T>} docs
* @returns {Promise<DocumentType<T>>}
*/
public async create(docs: Partial<T>): Promise<DocumentType<T>> {
try {
return await this.model.create(docs);
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 删除指定数据
* @param {(any)} id
* @param {QueryFindOneAndRemoveOptions} options
* @returns {QueryItem<T>}
*/
public delete(
conditions: AnyType,
options?: QueryFindOneAndRemoveOptions,
): QueryItem<T> {
return this.model.findOneAndDelete(conditions, options);
}
public async deleteAsync(
conditions: AnyType,
options?: QueryFindOneAndRemoveOptions,
): Promise<DocumentType<T>> {
try {
return await this.delete(conditions, options).exec();
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 删除指定id数据
* @param {(any)} id
* @param {QueryFindOneAndRemoveOptions} options
* @returns {Query<FindAndModifyWriteOpResultObject<DocumentType<T>>>}
*/
public deleteById(
id: string,
options?: QueryFindOneAndRemoveOptions,
): Query<FindAndModifyWriteOpResultObject<DocumentType<T>>> {
return this.model.findByIdAndDelete(BaseRepository.toObjectId(id), options);
}
public async deleteByIdAsync(
id: string,
options?: QueryFindOneAndRemoveOptions,
): Promise<FindAndModifyWriteOpResultObject<DocumentType<T>>> {
try {
return await this.deleteById(id, options).exec();
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 更新指定id数据
* @param {string} id
* @param {Partial<T>} update
* @param {QueryFindOneAndUpdateOptions} [options={ new: true }]
* @returns {QueryItem<T>}
*/
public update(id: string, update: Partial<T>, options: QueryFindOneAndUpdateOptions = { new: true }): QueryItem<T> {
return this.model.findByIdAndUpdate(BaseRepository.toObjectId(id), update, options);
}
async updateAsync(id: string, update: Partial<T>, options: QueryFindOneAndUpdateOptions = { new: true }): Promise<DocumentType<T>> {
try {
return await this.update(id, update, options).exec();
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 删除所有匹配条件的文档集合
* @param {*} [conditions={}]
* @returns {Promise<WriteOpResult['result']>}
*/
public clearCollection(conditions: AnyType = {}): Promise<WriteOpResult['result']> {
try {
return this.model.deleteMany(conditions).exec();
} catch (e) {
BaseRepository.throwMongoError(e);
}
}
/**
* @description 填充其他模型
* @private
* @template D
* @param {DocumentQuery<D, DocumentType<T>, {}>} docsQuery
* @param {(ModelPopulateOptions | ModelPopulateOptions[] | null)} populates
* @returns {DocumentQuery<D, DocumentType<T>, {}>}
*/
private populates<D>(
docsQuery: DocumentQuery<D, DocumentType<T>, {}>,
populates: ModelPopulateOptions | ModelPopulateOptions[] | null): DocumentQuery<D, DocumentType<T>, {}> {
if (populates) {
[].concat(populates).forEach((item: ModelPopulateOptions) => {
docsQuery.populate(item);
});
}
return docsQuery;
}
}
这个代码很长。我们在说明一下做了什么:
T extends BaseModel
。这就是TypeScript的高级类型。在这里,我们明确地说 T extends BaseModel
,这样我们只能将实际的领域模型类传递给这个基本服务,这是一个安全行为,你可以通过TypeScript来防止将ANY作为类型参数传递。model
的受保护字段,其类型为ReturnModelType<AnyParamConstructor<T>>
。 实际上,ReturnModelType<AnyParamConstructor<T>>
只是mongoose.model()
将返回的类型。 那么是不是Model<T>
?是的。 但是Model<T>
期望T extends mongoose.Document
的接口。 使用typegoose,这里的所有内容都是类,因此我们无法真正使用Model提供的方法。 另一个想法是使模型受到保护,这意味着只有子类才能访问此字段,因此我们不会在应用程序的任何其他层(例如控制器层)中公开
userService.model`mongoose.Model
的方法并返回适当的类型。你可能已经注意到,每种方法都有两个版本。 第一个版本返回一个DocumentQuery,它使你可以链接方法以进一步:filter
, project
, 和一些其他东西,比如populate
或lean
。 第2版(异步版)可在你不关心任何其他可链接方法而只想快速获取数据的情况下提供帮助。异步版本还将具有错误处理程序,我们将在其中引发MongoError
的InternalServerErrorException
。 使用toObjectId
将字符串转换成Types.ObjectId
类型。如果愿意,你可以抽象更多方法,但对我来说,这些方法在大多数情况下都已经够用了。
现在有基础模型和集成服务,我们来创建一个用户模型:
mkdir src/models/user // 存放用户的数据模型
touch src/models/index.ts // 用户模型导出索引
touch src/models/user.model.ts // 用户模型文件
touch src/models/user.repository.ts // 用户服务文件
touch src/models/user.module.ts // 用户模型模块文件
打开user.model.ts
import { prop, pre } from '@typegoose/typegoose';
import { Schema } from 'mongoose';
import { BaseModel } from '../base.model';
@pre<User>('save', function (next) {
const now = new Date();
(this as User).updated_at = now;
next();
})
export class User extends BaseModel {
get isAdvanced(): boolean {
// 积分高于 700 则认为是高级用户
return this.score > 700 || this.is_star;
}
@prop()
name: string;
@prop({
index: true,
unique: true,
type: Schema.Types.String,
})
loginname: string;
@prop({
select: false,
type: Schema.Types.String,
})
pass: string;
@prop({
index: true,
unique: true,
type: Schema.Types.String,
})
email: string;
}
@prop(options: object)
mongoose
配置一样打开user.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { User } from './user.model';
import { ReturnModelType, DocumentType } from '@typegoose/typegoose';
import { BaseRepository } from '../base.repository';
/**
* 用户实体
*/
export type UserEntity = User;
/**
* 用户模型
*/
export type UserModel = ReturnModelType<typeof User>;
/**
* 用户模型名称
*/
export const userModelName = User.modelName;
@Injectable()
export class UserRepository extends BaseRepository<User>{
constructor(@InjectModel(User.modelName) private readonly _userModel: UserModel) {
super(_userModel);
}
async create(docs: Partial<User>): Promise<DocumentType<User>> {
docs.name = docs.loginname;
const user = await super.create(docs);
return user.save();
}
/*
* 根据邮箱,查找用户
* @param {String} email 邮箱地址
* @param {Boolean} pass 启用密码
* @return {Promise[user]} 承载用户的 Promise 对象
*/
async getUserByMail(email: string, pass: boolean): Promise<User> {
let projection = null;
if (pass) {
projection = '+pass';
}
return super.findOneAsync({ email }, projection);
}
/*
* 根据登录名查找用户
* @param {String} loginName 登录名
* @param {Boolean} pass 启用密码
* @return {Promise[user]} 承载用户的 Promise 对象
*/
async getUserByLoginName(loginName: string, pass: boolean): Promise<User> {
const query = { loginname: new RegExp('^' + loginName + '$', 'i') };
let projection = null;
if (pass) {
projection = '+pass';
}
return super.findOneAsync(query, projection);
}
/*
* 根据 githubId 查找用户
* @param {String} githubId 登录名
* @return {Promise[user]} 承载用户的 Promise 对象
*/
async getUserByGithubId(githubId: string): Promise<User> {
const query = { githubId };
return super.findOneAsync(query);
}
}
我只需要继承BaseRepository
,书写特定的快捷方法即可,并导出UserEntity
和UserModel
类型,模型名称userModelName
。
接下来打开user.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { UserRepository } from './user.repository';
import { User } from './user.model';
@Module({
imports: [
MongooseModule.forFeature([{ name: User.modelName, schema: User.schema }]),
],
providers: [UserRepository],
exports: [UserRepository],
})
export class UserModelModule { }
这里重要的一点是我们调用MongooseModule.forFeature
并传递模型数组。 MongooseModule.forFeature
并将获取当前的mongoose.Connection
并添加传入的模型,然后将这些模型提供给NestJS的IoC容器(用于依赖注入)。 现在,你可以看到schema和modelName很重要,并且BaseModel在这里有很大帮助。
导出索引index.ts
export * from './user.module';
export * from './user.repository';
现在我们在业务模块auth里面使用UserModelModule
完成注册登出等操作
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './passport/local.strategy';
import { AuthSerializer } from './passport/auth.serializer';
import { GithubStrategy } from './passport/github.strategy';
import { UserModelModule } from 'src/models';
@Module({
imports: [UserModelModule],
providers: [
AuthService,
AuthSerializer,
LocalStrategy,
GithubStrategy,
],
controllers: [AuthController],
})
export class AuthModule { }
```
AuthService中使用UserRepository:
```ts
import { Injectable, Logger } from '@nestjs/common';
import { UserRepository } from 'src/models';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name, true);
constructor(
private readonly userRepository: UserRepository,
) { }
}
```
即使这只是一个最小的示例,但我希望它向你展示如何利用TypeScript和Typegoose提取一些基础知识以加快开发过程。 此外,你甚至可以拥有一个BaseController,该Controller拥有一个受保护的baseService,它将涵盖您的基本CRUD功能。 基本就已经完结了,为了这个东西我花了很多时间和经历去挖坑,这里记录一下挖坑总结,这原因是typegoose的完整的栗子太少。
完整栗子:[传送门](https://github.com/jiayisheji/nest-cnode)
今天就到这里吧,伙计们,玩得开心,祝你好运
大神我有个问题,
我现在使用的lodash.js
只是使用了里面的几个方法,
我要做一个单页面的html给客户
客户在无网情况下使用的
我看着js的方法都是调用层次太多,
有没有什么好的方法或者工具直接提取这个方法的所有实现代码
谢谢大神指点
去年圣诞节格外冷,第二天要上班,早早洗洗睡了。半夜 10 点,老板打电话来说有个推广页要换谷歌代码。跟他说明天早上去了,就去改。凌晨 0 点,又打电话来了,说还需要审核几个小时。事不过三,这哪能拒绝了,穿好衣服爬起来,开了电脑远程公司(疫情以来,公司电脑没有关过,长年开机候命)。下载老板给的代码,找到对应的项目,一看不知道,一看吓一跳,20 多个页面了。推广页面,比较简单,一开始才就 1,2 个页面,交给其他同事负责完成。改了代码提交发布一气呵成,前后不到 10 分钟。
看到项目膨胀,到公司咨询一下推广和运维,还有负责项目的同事。目前这个推广项目页面会越来越多,还是有些重复,现在是按推广域名和推广页面文件夹挂钩,都在一个 git
仓库里,每次提交代码发布都是整个项目一起发布。开发只需要把代码提交 git
仓库即可,剩下交给运维处理发布问题。
由于都是静态文件,里面包含一些谷歌等推广协议相关页面,为了应对审核,这些协议修改也是常有事情,在编辑器可以批量替换,这可能导致错误。为了安全起见只能一个一个替换。这种方式在当前现代前端工业化水平,那相当原始钻木取火水平。想要改变就要破局,话不多说,进入正题。
先看项目结构:
---- root
|
|
|-- .git
|
|-- www.a.com
|
|-- www.b.com
|
|-- www.c.com
|
|-- www.d.com
|
|-- 更多...
每个域名文件夹大概结构:
---- www.a.com
|-- index.html
|-- style.css
|-- index.js
|-- images
|-- robots.txt
|-- 各种 meta icon 图标
|-- 可能包含:协议.html 其他页面
功能比较简单,js
中没有引入过多第三方库,比如:jQuery
,简单特效直接使用 js
操作 DOM
实现(不用考虑不兼容 ECMA 5
的浏览器)。
发现一个问题:
pc
和 m
两个文件夹,不说相信大家都应该懂了,都是做前端的。app
的服务 js
SDK,基本用的域名下面都有这个代码。还有就是处理 rem
的,也是每个域名文件都有这个代码。css 就更不要举例了,html 重复一样。html
文件问题外的思考:
结合问题外的思考,我和运维同事捣鼓一个后台管理,使用 Nestjs 搭建,终于体验一把全干工程师。发布部署都是运维搞定,运维把他们的域名相关东西也放到这个管理系统中,这样就解决 2 问题。
通过定时任务去每天检查域名是否过期,定时去检查下载域名是否正常访问。对于异常域名通过钉钉发生给运维去处理。
一直在思考问题 1 怎么解决,那就做一个推广页管理,推广页和域名直接强制关联,自动分配下载地址,开发上传页面模板(接了下的重点),推广管理推广相关的内容。修改对应参数(这些参数都是文字变量,对于图片相关处理,替换图片这种需求不是很常见,开发改代码更快),直接点击发布即可。
每次下载链接自动被替换,都会通过钉钉机器人发给测试去确定。
通过这个小项目也学到一些
nodejs
平常用的比较少的功能,及数据库设计等相关后台知识。
关于页面模板,这个也让我思考很久,最终决定使用 EJS,语法简单,通用性广。lodash.template 也是使用类似语法。前端开发需要上传对于的文件模板,比如 pc
和 m
两个文件夹,需要上传 2 个 zip 包,只有一个只需要上传一个。
在服务端使用 node-stream-zip 解压 zip
包,使用 ejs.render
把模板和推广相关数据编译成 html
。 运维又要求给他生成一些运维相关配置,比如 nginx
配置和 https
的 ssl
证书,最后执行运维提供 shell
脚本,做到一键部署。这些操作过程中,我又把每步操作实时返回给前端页面,有点类似 Jenkins
发布那个界面。
正所谓万事俱备,只欠东方,其他准备都已经完成,现在只缺模板 zip
包。
在构建这个模板项目时,前面的思考已经让我有了一些想法,使用 Monorepo
来构建项目。长时间使用 Angular 开发,对于 Monorepo
并不陌生,并且经常使用这个特性完成开发工作。
关于 Monorepo
这里有篇博客介绍 what-is-monorepo。
在前端有个比较有名 JavaScript
的 Monorepo
包管理器 Lerna,一些耳熟能详开源项目都是使用它,例如: Babel,Jest 等开源项目。
Lerna 是一个快速的现代构建系统,用于管理和发布来自同一存储库的多个 JavaScript/Typescript 软件包。
如果想要构建 Monorepo
项目,使用 Lerna
肯定是不够的,那么接下来我们就来从零开始构建一个 Monorepo
项目 CLI
工具。
Node.js
为我们提供了 process.argv 来读取命令行参数,作为一个工具,我们不应该手动解析这些参数,有 2 个包 commander 和 cac 推荐,这里我使用 cac
。
其他相关工具:
还有一些其他好用工具,这里暂时不一一列举了,后面介绍时用上在科普。
创建一个 Monorepo
工作区:
---- root
|
|-- .git
|
|-- projects 项目集合以及公共依赖(通用脚本,资源等)
|
|-- tools 核心 CLI 实现
|
|-- package.json
|
|-- README.md
|
|-- 更多工程配置文件...
创建入口(从 cac 官网实例开始):
// tools/index.js
const cac = require('cac');
const cli = cac('Template Cli');
// 这里放 cli.command
cli.help();
(async () => {
try {
// Parse CLI args without running the command
cli.parse(process.argv, { run: false });
// Run the command yourself
// You only need `await` when your command action returns a Promise
await cli.runMatchedCommand();
} catch (error) {
// Handle error here..
// e.g.
console.error(error.stack);
process.exit(1);
}
})();
我们要定义几个 command
:
// tools/serve.js
module.exports = function (cli) {
const defaultOptions = {
platform: "all",
};
cli
.command("serve [project]", "Serve a project", {
allowUnknownOptions: true,
})
.option("--name <name>", "The name of the project")
.option("--platform <platform>", "Choose a platform type", {
default: "all",
})
.alias("s")
.action(async (_, options) => {
if (options.name == null) {
throw new Error(
`The serve template name is not provided. Example: npm run serve -- --name=<name>`
);
}
// ...code
});
};
在 tools/index.js
的 cli.command
位置引入即可,其他几个 command
类似,这里不一一贴代码。
这里的 cli 没有做成 -g 命令模式,只是简单 nodejs 执行脚本形式
所有项目都存放在在 projects
文件夹里,那么有很多项目,如果项目有不一样配置该如何操作了,你可能要说 if/else
, 这一块可以学习一下 angular-cli 设计**,构建配置分离。不同的命令对于对于不同的构建器,构建器使用当前的配置。
这是我们每个项目的目录结构:
---- project
|
|-- src 源码目录
|
|-- project.json 项目配置
|
|-- README.md
|
|-- 其他配置文件,比如 eslint
本项目采用 js,并没有使用 ts 开发。
不过在 Angular
里项目配置 angular.json
随着项目不断增长会导致这个 json
文件过于庞大。我采用 project.json
为每个项目单独配置,互不影响,这样方便管理,增删改查都方便。
angular-cli
默认使用 webpack
构建项目,这里我们采用主流 webpack
,你可能会说我们为什么不使用大火的 Vite 呢?这个先按下不表,后面会有更简单方式来使用它。
我这里用的最新版 webpack5。我**就是利用 project.json
通过构建处理成 webpack.configuration 传递给 webpack
完成整个工程构建过程。
project.json
里面该写点什么,怎么保证里面配置符合预期。这个引入 json-schema 概念。关于 schema 有哪些,你可以点击下载查看,关于 json-schema-validation 标准介绍。
JSON Schema 是用于验证 JSON 数据结构的强大工具,Schema 可以理解为模式或者规则。
如果你对 json-schema
没有印象,那你一定用过 webpack
,它里面的 loader
和 plugin
输入参数配置验证就是采用 json-schema
。
当你看完中文文档,有种跃跃欲试冲动,怎么快速构建一个 project.schema.json
呢?
我们要站在巨人肩上参考 angular.json 。
```json
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "tools/project.schema.json",
"title": "Project Options Schema",
"description": "JSON Schema for `project.json` description file",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"root": {
"description": "该项目文件的根文件夹,相对于工作区文件夹。",
"type": "string"
},
"projects": {
"type": "object",
"description": "项目配置",
"patternProperties": {
"^(?:@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9_-]+$": {
"type": "object",
"properties": {
"sourceRoot": {
"description": "放置源码的路径",
"type": "string"
},
"targets": {
"type": "object",
"properties": {
"build": {
"type": "object",
"properties": {
"options": {
"description": "构建生产服务配置选项",
"type": "object",
"properties": {
"assets": {
"type": "array",
"description": "静态应用程序资源列表",
"default": [],
"items": {
"oneOf": [
{
"type": "object",
"description": "包含资源文件对象,相对于工作区文件夹",
"properties": {
"glob": {
"type": "string",
"description": "匹配的模式"
},
"input": {
"type": "string",
"description": "要应用 'glob' 的输入目录路径。默认为项目根目录。"
},
"ignore": {
"description": "要忽略的 globs 数组",
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "string",
"description": "输出的绝对路径"
}
},
"additionalProperties": false,
"required": ["glob", "input", "output"]
},
{
"description": "包含资源文件路径,相对于源码文件夹",
"type": "string"
}
]
}
},
"main": {
"type": "string",
"description": "应用程序主入口点的完整路径,相对于当前工作区"
},
"index": {
"description": "配置应用程序 index.html 的生成",
"oneOf": [
{
"type": "string",
"description": "应用程序生成的 `index.html` 文件的输出路径。将使用提供的完整路径,并将相对于应用程序配置的输出路径进行考虑。用于应用程序HTML索引的文件的路径。指定路径的文件名将用于生成的文件,并将创建在应用程序配置的输出路径的根目录中。"
},
{
"type": "object",
"description": "",
"properties": {
"input": {
"type": "string",
"minLength": 1,
"description": "用于应用程序生成的 `index.html` 的文件的路径"
},
"output": {
"type": "string",
"minLength": 1,
"default": "index.html",
"description": "应用程序生成的HTML索引文件的输出路径。将使用提供的完整路径,并将相对于应用程序配置的输出路径进行考虑。"
}
},
"required": ["input"]
},
{
"type": "array",
"description": "",
"minItems": 2,
"items": {
"type": "object",
"description": "",
"properties": {
"input": {
"type": "string",
"minLength": 1,
"description": "用于应用程序生成的 `output.html` 的文件的路径"
},
"entry": {
"type": "string",
"minLength": 1,
"description": "用于应用程序生成的 webpack.entry 入口配置 key"
},
"main": {
"type": "string",
"minLength": 1,
"description": "用于应用程序生成的 webpack.entry 入口配置 value"
},
"output": {
"type": "string",
"minLength": 1,
"description": "应用程序生成的 HTML 文件的输出路径。将使用提供的完整路径,并将相对于应用程序配置的输出路径进行考虑。"
}
},
"required": ["input", "output"]
}
}
]
},
"polyfills": {
"type": "string",
"description": "相对于当前工作区,自定义 polyfills 文件的完整路径。"
},
"outputPath": {
"type": "string",
"description": "相对于当前工作区,新输出目录的完整路径。默认情况下,将输出写入当前项目中名为 dist/ 的文件夹。"
},
"extractCss": {
"type": "boolean",
"description": "将 css 提取到 .css 文件中",
"default": false
},
"externalDependencies": {
"description": "将列出的外部依赖项排除在捆绑到捆绑包中。相反,创建的包依赖于这些依赖项,以便在运行时可用。",
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"optimization": {
"description": "启用构建输出的优化。包括压缩 script、style 和 image 及摇树优化。",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "启用 script 压缩优化",
"default": true
},
"styles": {
"type": "boolean",
"description": "启用 style 压缩优化",
"default": true
},
"images": {
"type": "boolean",
"description": "启用 image 压缩优化",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"vendorChunk": {
"type": "boolean",
"description": "生成一个单独的包,其中只包含库的单独的包使用的代码。",
"default": true
},
"commonChunk": {
"type": "boolean",
"description": "生成一个单独的包,其中包含跨多个包使用的代码。",
"default": true
},
"baseHref": {
"type": "string",
"description": "正在构建的应用程序的"
},
"outputHashing": {
"type": "string",
"description": "定义输出文件名缓存 hash 模式。",
"default": "none",
"enum": ["none", "all", "media", "bundles"]
},
"deployUrl": {
"type": "string",
"description": "将部署文件的URL"
},
"verbose": {
"type": "boolean",
"description": "为输出日志记录添加更多详细信息",
"default": false
},
"progress": {
"type": "boolean",
"description": "在构建时将进度记录到控制台",
"default": true
},
"webpackConfig": {
"type": "string",
"description": "一个函数的文件路径,该函数接受 webpack 配置、上下文并返回 webpack 配置结果。"
}
},
"required": ["outputPath"],
"additionalProperties": false
}
},
"required": ["options"]
},
"serve": {
"type": "object",
"properties": {
"options": {
"description": "构建开发服务配置选项",
"type": "object",
"properties": {
"port": {
"type": "number",
"description": "端口号",
"default": 8080
},
"host": {
"type": "string",
"description": "主机",
"default": "localhost"
},
"proxyConfig": {
"type": "string",
"description": "代理配置文件"
},
"open": {
"type": "boolean",
"description": "在默认浏览器中打开url",
"default": false
},
"verbose": {
"type": "boolean",
"description": "为输出日志记录添加更多详细信息"
},
"liveReload": {
"type": "boolean",
"description": "是否在更改时重新加载页面,使用实时重新加载",
"default": true
},
"hmr": {
"type": "boolean",
"description": "启用模块热替换",
"default": true
},
"watch": {
"type": "boolean",
"description": "监视模式默认",
"default": true
},
"poll": {
"type": "number",
"description": "启用并定义文件监视轮询时间段(以毫秒为单位)"
},
"watchOptions": {
"type": "object",
"description": "用于自定义监视模式的一组选项",
"properties": {
"aggregateTimeout": {
"type": "integer"
},
"ignored": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "string"
}
]
},
"poll": {
"type": "integer"
},
"followSymlinks": {
"type": "boolean"
},
"stdin": {
"type": "boolean"
}
}
}
},
"additionalProperties": false
}
},
"required": ["options"]
}
},
"required": ["build", "serve"]
}
},
"required": ["targets", "sourceRoot"]
}
},
"additionalProperties": false
},
"templateParameters": {
"type": "array",
"uniqueItemProperties": ["key"],
"description": "项目模板变量",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "模板变量属性名"
},
"value": {
"type": "string",
"description": "模板变量属性值"
},
"type": {
"type": "string",
"enum": ["string", "number", "null", "boolean", "json"],
"default": "string",
"description": "模板变量属性类型"
},
"remark": {
"type": "string",
"description": "模板变量属性描述"
}
},
"required": ["key", "value", "type"]
}
}
},
"required": ["root", "projects", "templateParameters"]
}
```
以上就是 project.json
要输入的内容:
projects
?因为一个项目可能包含 pc
和 m
2 个子项目,默认推荐响应式一站式。css
强制使用 scss
预处理器处理,包括 postcss
等处理。project
只会有一个 index.html
,有些 project
为了应对审查(谷歌广告)需要有多余的免责申明,隐私政策等页面。postcss
、babel
、browserslist
等配置是全局共享。定义 json-schema
规范,那就该验证输入数据是否靠谱。我们采用:
npm install -D ajv ajv-keywords
ajv
自带 ajv-formats
拓展一些字符串格式限制属性,比如常见的:date
、uri
、hostname
等。
ajv-keywords
是辅助 Ajv
自定义验证属性,一些常用的属性,比如常见的:typeof
、instanceof
、range
、regexp
等。
关于验证 json-schema
逻辑并不复杂:
// 引入包
const Ajv = require("ajv");
const addFormats = require("ajv-formats");
const addKeywords = require("ajv-keywords");
// 配置 ajv
const ajv = new Ajv({
allErrors: true,
passContext: true,
validateFormats: true,
messages: true,
});
addFormats(ajv);
addKeywords(ajv, ["range"]);
// 引入 json-schema 规则
const jsonSchema = require("../project.schema.json");
接下来只需要对外暴露一个方法,这个方法完成验证和转换。
module.exports.validateSchema = async function validateSchema(json) {
const validator = await compile(jsonSchema);
const { success, errors, data } = await validator(json);
if (!success) {
throw new SchemaValidationException(errors);
}
return transform(jsonSchema, data);
}
关于验证,ajv
自带验证方法,我们只需要使用即可:
// 执行compile后validate可以多次使用
const compile = async function (schema) {
ajv.removeSchema(schema);
let validator;
try {
validator = ajv.compile(schema);
} catch (e) {
// This should eventually be refactored so that we we handle race condition where the same schema is validated at the same time.
if (!(e instanceof Ajv.MissingRefError)) {
throw e;
}
validator = await ajv.compileAsync(schema);
}
return async (data) => {
// Validate using ajv
try {
const success = await validator.call(undefined, data);
if (!success) {
return { data, success, errors: validator.errors ?? [] };
}
} catch (error) {
if (error instanceof Ajv.ValidationError) {
return { data, success: false, errors: error.errors };
}
throw error;
}
return {
data,
success: true,
}
};
};
ajv
默认是没有转换功能,只做 json-schema
验证。这个转换是什么意思,在 json-schema
规则里有一些属性选填但有默认值,但是我们 project.json
是没有提供这些属性,实际 js 取值过程就需要去先判断这个值是否存在,如果转换之后,就可以省略这个操作。关于转换函数 transform
这里就不贴代码,原理写法跟递归深拷贝类似,如果你写出来,值得反思一下。
如果你实在没有思路,angular-cli 中有个 transform 方法,可以参考借鉴一下。
我们上面已经拿到每个项目的的配置,可以根据不同命令生成不同的 webpack
配置,主要开发和生产,正好对应 Webpack Mode。
webpack
使用方式有很多,一般作为 CLI
工具时都是使用 Node Api 来灵活定制功能。
webpack
提供 Webpack
方法将配置转换成 Compiler
,就可以直接调用 run()
,相当于命令行输入 webpack build
运行。这种方式生产发布就完全够用了,但是在开发时,还需要有启动服务器,接口代理,热更新等。这个时候就需要 WebpackDevServer(DevServerOptions, Compiler)
类,实例化之后调用方法 startCallback()
即可完成开发启动,相当于命令行输入 webpack serve
运行。
对于 Webpack 配置,可以参考文档,但是文档毕竟很长很多,想要站在巨人肩上,我们可以借助一些开源的配置,快速生成。
比如 create-react-app 的配置。它把 webpack
配置包装在一个配置工厂函数 configFactory(webpackEnv)
,传递一个环境标识,根据这个环境标识去生产哪些配置在 development
运行,哪些在 production
。webpack
配置里面很多都是数组,需要使用 [].filter(Boolean)
来过滤 undefined
,从而避免 webpack
读取配置时报错。configFactory()
函数拿到配置不是最终配置,只是一个基准配置,后面可以根据 validateSchema
处理之后 project.json
配置来做合并,这样一来,每个项目就可以定制不同的配置。
对外包装 2 个 run 方法:
runWebpack
由 configFactory('production')
生成配置,调用 Webpack(Config).run()
运行runWebpackDevServer
由 configFactory('development')
生成配置,调用 WebpackDevServer(DevServerOptions, Compiler).startCallback()
运行/**
*
* @param {webpack.Configuration} config
* @param {*} context
* @param {*} transforms
*/
exports.runWebpack = (config, context, transforms) => {
const logging =
transforms.logger ??
((stats, config) => context.logger.info(stats.toString(config.stats)));
return new Promise((resolve, reject) => {
try {
const webpackCompiler = webpack(config);
webpackCompiler.run((err, stats) => {
if (err) {
return reject(err);
}
if (!stats) {
return;
}
// 日志数据
logging(stats, config);
const statsOptions =
typeof config.stats === "boolean" ? undefined : config.stats;
const result = {
success: !stats.hasErrors(),
webpackStats: stats.toJson(statsOptions),
emittedFiles: getEmittedFiles(stats.compilation),
outputPath: stats.compilation.outputOptions.path,
};
webpackCompiler.close(() => {
resolve(result);
});
});
} catch (err) {
if (err) {
context.logger.error(
`\nAn error occurred during the build:\n${
err instanceof Error ? err.stack : err
}`
);
}
reject(err);
}
});
};
/**
*
* @param {webpack.Configuration} config
* @param {*} context
* @param {*} transforms
*/
exports.runWebpackDevServer = (config, context, transforms) => {
const logging =
transforms.loader ??
((stats, config) => context.logger.info(stats.toString(config.stats)));
const devServerConfig = transforms.devServerConfig || config.devServer || {};
if (devServerConfig.host == null) {
devServerConfig.host = "localhost";
}
return new Promise((resolve, reject) => {
let result;
const webpackCompiler = webpack(config);
webpackCompiler.hooks.done.tap("build-webpack", (stats) => {
logging(stats, config);
resolve({
...result,
emittedFiles: getEmittedFiles(stats.compilation),
success: !stats.hasErrors(),
outputPath: stats.compilation.outputOptions.path,
});
});
const devServer = new webpackDevServer(devServerConfig, webpackCompiler);
devServer.startCallback((err) => {
if (err) {
return reject(err);
}
const address = devServer.server?.address();
if (!address) {
reject(new Error(`Dev-server address info is not defined.`));
return;
}
result = {
success: true,
port: typeof address === "string" ? 0 : address.port,
family: typeof address === "string" ? "" : address.family,
address: typeof address === "string" ? address : address.address,
};
});
});
};
就可以在 cac
对应的方法里面调用对应的 run
方法。
configFactory
看起来不错,实际写的一坨代码包在一个函数里面,如果要修改一个基准配置,找脑壳痛。
我们可以把 configFactory
合理拆分:
这里就不贴代码了,太长了,主要参考 angular-cli
里面一些配置,精简一些不需要。
html
这个就没有参考了,如果做单页应用,就一个 html,我这个需要特殊处理一下,开发时候是需要编译成 .html
,打包的时候还是保留 .ejs
,方便服务端处理。简单理解就是把大函数拆分成小函数,这样就方便组合使用。现在需要提取 2 个新的方法来组合这些配置:
runWebpack
runWebpackDevServer
buildWebpack
和 serveWebpack
区别就是,是否使用 dev-server
,其他一样。
简单秀一下这 2 个函数:
exports.buildWebpack = async function (options, context, transforms = {}) {
const spinner = new Spinner();
try {
spinner.start('Building for production...');
// 获取 webpack 通用配置
const { config, projectRoot, projectSourceRoot } =
await generateWebpackConfigFromContext(options, context, (wco) => [
getCommonConfig(wco),
getStylesConfig(wco),
getInjectHTMLConfig(wco, context.templateParameters),
]);
let webpackConfig = config;
// 处理 cli webpack 配置
if (typeof transforms.webpackConfiguration === "function") {
webpackConfig = await transforms.webpackConfiguration(webpackConfig);
}
if (webpackConfig == null || typeof webpackConfig !== "object") {
throw new Error(
"transforms.webpackConfiguration return must be defined webpack.Configuration"
);
}
// 用户自定义 webpack 配置
webpackConfig = await mergeCustomWebpackConfig(
webpackConfig,
options,
context
);
// 检查 entry 是否存在
checkWebpackConfigEntry(webpackConfig);
// 启动 webpack dev server
const result = await runWebpack(webpackConfig, context, {});
spinner.succeed();
return result;
} catch (error) {
spinner.fail();
throw error;
}
};
exports.serveWebpack = async function (options, context, transforms = {}) {
const spinner = new Spinner();
try {
spinner.start('Starting development server...');
// 获取 webpack 通用配置
const { config, projectRoot, projectSourceRoot } =
await generateWebpackConfigFromContext(options, context, (wco) => [
getDevServerConfig(wco),
getCommonConfig(wco),
getStylesConfig(wco),
getInjectHTMLConfig(wco, context.templateParameters),
]);
let webpackConfig = config;
// 处理 cli webpack 配置
if (typeof transforms.webpackConfiguration === "function") {
webpackConfig = await transforms.webpackConfiguration(webpackConfig);
}
if (webpackConfig == null || typeof webpackConfig !== "object") {
throw new Error(
"transforms.webpackConfiguration return must be defined webpack.Configuration"
);
}
// 用户自定义 webpack 配置
webpackConfig = await mergeCustomWebpackConfig(
webpackConfig,
options,
context
);
// 检查 entry 是否存在
checkWebpackConfigEntry(webpackConfig);
if (!webpackConfig.devServer) {
throw new Error('Webpack Dev Server configuration was not set.');
}
// 启动 webpack dev server
const result = await runWebpackDevServer(webpackConfig, context, {
devServerConfig: webpackConfig.devServer,
});
spinner.succeed('Browser application bundle generation complete.');
return result;
} catch (error) {
spinner.fail();
throw error;
}
};
核心的构建功能已经完成,接下来就该完成 CLi 工具
module.exports = function (cli) {
const defaultOptions = {
platform: "all",
};
cli
.command("serve [project]", "Serve a Template", {
allowUnknownOptions: true,
})
.option("--name <name>", "The name of the Template")
.option("--platform <platform>", "Choose a platform type", {
default: "all",
})
.alias("s")
.action(async (_, options) => {
if (options.name == null) {
throw new Error(
`The serve template name is not provided. Example: npm run serve -- --name=<name>`
);
}
// 选择平台
if (typeof options.platform === "string") {
options.platform = ["all", "pc", "mobile"].includes(
options.platform
)
? options.platform
: defaultOptions.platform;
} else {
options.platform = defaultOptions.platform;
}
// 获取 project.json
const projectJson = await getProjectJson(options.name);
// 处理 project.json 变成配置数据
const builderSchema = await validateSchema(projectJson);
// 获取项目配置
const { options: buildOptions, context } = getProject(
builderSchema,
options.platform,
'development'
);
try {
const result = await serveWebpack(buildOptions, context, {
webpackConfiguration: (webpackConfigOptions) => {
// cli 自定义 webpack 配置
return webpackConfigOptions;
}
});
if(result.success) {
console.log(`App running at: ` + chalk.cyan(`http://${result.address === '127.0.0.1' ? 'localhost' : result.address}:${result.port}`));
} else {
console.log(result);
}
} catch (error) {
console.error(error);
}
});
};
通过 options.name
获取 project.json
,然后通过 options.platform
获取当前运行项目的配置。
其他注释都已经说明了,
这里重点说一下:getProject
和 webpackConfiguration
webpackConfiguration
在这里有什么用,这里和 project.json
里那个自定义 webpack
配置,这里 cli
自定义 webpack
配置。这里你看到没什么意义代码,接下来 build 里,你就看到它的用处。
getProject
为了保证在 serveWebpack
以及后需要功能中使用更加方便,这里统一数据结构,通过环境来生成一个项目配置,最终交给 serveWebpack
。
/**
*
* @param {*} builderSchema
* @param {"all" | "pc" | "mobile"} platform
* @param {'development' | 'production'} environment
* @returns { options: Object, context: Object }
*/
function getProject(builderSchema, platform, environment) {
const { sourceRoot, targets } = builderSchema.projects[platform];
const metadata = {
...targets,
root: builderSchema.root,
sourceRoot,
};
// require("webpack/lib/logging/runtime")
logging.configureDefaultLogger({
level: "log",
});
const options =
environment === "production"
? { templateParameters: null, template: true }
: Object.assign({}, targets.serve.options, {
optimization: false,
sourceMap: false,
template: false,
templateParameters: getTemplateParameters(
builderSchema.templateParameters
),
});
return {
options: Object.assign({ environment }, targets.build.options, options),
context: {
logger: logging.getLogger(platform),
workspaceRoot: cwd,
projectRoot: builderSchema.root,
sourceRoot,
target: {
project: platform,
metadata,
},
},
};
}
接下来你只需要运行:
npm run serve -- --name=<name>
module.exports = function (cli) {
cli
.command("build [project]", "Build a Template", {
allowUnknownOptions: true,
})
.option("--name <name>", "The name of the Template")
.alias("b")
.action(async (_, options) => {
if (options.name == null) {
throw new Error(
"The build template name is not provided. Example: npm run build -- --name=<name>"
);
}
// 获取 project.json
const projectJson = await getProjectJson(options.name);
// 处理 project.json 变成配置数据
const builderSchema = await validateSchema(projectJson);
// 获取 project.json#projects 里所有的项目配置
const projects = getProjectAll(builderSchema);
try {
for (const { options: buildOptions, context } of projects) {
await buildWebpackBrowser(
buildOptions,
context,
{
webpackConfiguration: (webpackConfigOptions) => {
addBuildReleaseZip(webpackConfigOptions, buildOptions, context);
return webpackConfigOptions;
}
}
);
}
} catch (error) {
console.error(error);
}
});
};
build
跟 serve
一样,唯一区别, serve
一次只能运行一个 project
(这也是为什么需要 platform
参数的原因),build
需要打包 projects
所有的项目
addBuildReleaseZip
就是把 dist
文件夹里项目打包成 zip
文件,方便上传。
npm run build -- --name=<name>
module.exports = function (cli) {
const defaultOptions = {
platform: "all",
proxy: false,
};
cli
.command("generate [project]", "Generate a new Template", {
allowUnknownOptions: true,
})
.option("--name <name>", "The name of the Template")
.option("--platform <platform>", "Choose a platform type", {
default: "all",
})
.option("--proxy <proxy>", "Whether support proxy")
.alias("g")
.action(async (_, options) => {
if (options.name == null) {
throw new Error(
"The generate template name is not provided. Example: npm run generate -- --name=<name>"
);
}
// 检查平台
if (typeof options.platform === "string") {
options.platform = ["all", "multi", "pc", "mobile"].includes(
options.platform
)
? options.platform
: defaultOptions.platform;
} else {
options.platform = defaultOptions.platform;
}
// 检查是否需要设置代理
if (typeof options.proxy != null) {
options.proxy = toBoolean(options.proxy);
} else {
options.proxy = defaultOptions.proxy;
}
// 查重
try {
await getPackage(options.name);
throw new Error(`Template ${options.name} already existed`);
} catch (error) {
// console.log('getPackage', error);
}
// 拼装 project.json
const projectJson = {};
// 创建项目文件
// fs.mkdir(projectJson.root)
// fs.writeJson('project.json', projectJson)
// fs.mkdir(projectJson.sourceRoot)
// 根据 project.json#projects 生成入口文件 index (js, css,ejs)
})
}
generate
没有说明复杂的,根据命令行参数,去生成 project.json
, 按照项目配置生成对应文件和写入简单示例代码。
platform
这里平台会多一个 multi
,是为了方便处理 pc
和 mobile
同时存在,有时候又只需要一个,方便处理。
npm run generate -- --name=<name>
module.exports = function (cli) {
const defaultOptions = {
platform: "all",
config: "patch",
publish: true,
};
cli
.command("release [project]", "Release a Template", {
allowUnknownOptions: true,
})
.option("--name <name>", "The name of the Template")
.option("--config <config>", "Whether to upload new variables")
.option("--publish <publish>", "Whether to publish the project")
.alias("r")
.action(async (_, options) => {
if (options.name == null) {
throw new Error(`The release template name is not provided. Example: npm run release -- --name=<name>`);
}
const form = new FormData();
const zip = fs.createReadStream(PATH_TO_FILE);
form.append('zip', zip);
// In Node.js environment you need to set boundary in the header field 'Content-Type' by calling method `getHeaders`
const formHeaders = form.getHeaders();
axios.post('http://example.com', form, {
headers: {
...formHeaders,
},
})
.then(response => response)
.catch(error => error)
});
};
release
就简单了,里面代码和 build
一样,借助 axios 和 form-data 把 dist.zip
传到服务器上。
主要方便项目开发者使用,config
自动更新模板变量到数据库,publish
自动发布该项目。
npm run release -- --name=<name>
我的想法是能程序自动处理,就自动处理。这是我对
nodejs
仅停留在做个小工具,方便小伙伴早下班。
前面我们做了很多事情,主要还原我想要做一个 Monorepo
项目工具,最基础需要哪些东西:
前面 2 个,我上面都已经实现了,自定义扩展方便确暂时无法实现,原因我的构建和 CLI 完全耦合,无法分离,我想老项目使用 webpack
, 新项目使用新潮流 vite
按照现在设计完全不可能。
接下来我们介绍 Nx,它将完全实现这个不可能。
Nx
一开始的 Angular-cli
的扩展,它的作者成员也是 Angular
核心开发者。
我从 Nx v8
开始使用它,一度放弃 Angular-cli
,因为它包含 Angular-cli
,还支持 React
、Nestjs
、Nextjs
,暂不支持 Vue
。
可能 vueer 要歧义,为什么不支持,因为
vue-cli
还不错,create-react-app
就比较拉跨,老外不知道那个什么米,我也是用了Nx
才开始写React
,最近正在写一个Nextjs
公司项目。
创建的一个 nx workspace 就可以开始构建 Monorepo 项目了。
npx create-nx-workspace projectName
在 VS code
里推荐下载 Nx Console 插件。
你创建项目以后,用 VS code
打开它就会提示你安装这个插件,安装以后方便很多。
用它来写 generate
就方便的多,只需要把模板,挡在 files
文件夹里,nx g xxxx -name=xxx
就可以愉快玩耍了,这个 nger
很眼熟吧,你没看错,底层就是和 Angular-cli
一套实现。之前版本一直都是 ng g
,最近几个版本才换成 nx
。
我的 project.json
和它 project.json
基本类似,唯一区别它有个 executor
,这玩意就可以方便实现自定义扩展,想要切换 webpack
和 vite
,那就一行代码事情。
{
...
{
- "executor": "@nrwl/web:dev-server",
+ "executor": "@nrwl/vite:dev-server",
}
}
Nx
强大之处,nx-plugin 可以让你自己写 executor
和 generate
,Nx
虽然不支持 Vue
,但是有人写了插件。
Nx
的插件组织里面有几类:
executor
,例如:webpack,esbuild,vite 等构建工具generate
,例如:nest,next 等生成工具executor
和 generate
,例如 Angular,React,Vue 等生成工具在 Nx
里你可以使用 runExecutor
运行已经在 project.json
存在的 executor
,比如,有多个项目,需要 build
,但是它们参数各不一样,如果你是统一部署的,只希望传递一个命令和对应项目名即可,就可以写一个 deploy
的命令和对应的 executor
,里面使用 runExecutor
调用 build
。
export default async function deployExecutor(
options: deployExecutor,
context: ExecutorContext
): Promise<{ success: boolean }> {
return await runExecutor(
{ project: context.projectName, target: 'build', configuration: 'production', ...options },
context
);
}
这是简单的自定义功能,如果想要借助别的更底层 executor
和 generate
呢,我这里一篇定制 nest-mvc 的插件,有兴趣可以看一下,如果有疑问,欢迎跟我交流。
说起 Angular
,很多人都不喜欢,可以不用 Angular
,但是它的工程化**,可以借鉴学习,在目前前端界,说二没有敢说一,也为你以后做构建轮子提供思路,你不想折腾,那只能呵呵。
谢谢你读到这里。下面是你接下来可以做的一些事情:
SOLID 中的最后一个字母是 Dependency Inversion Principle。它可以帮助我们解耦软件模块,以便更容易地用另一个模块替换一个模块。依赖注入模式使我们能够遵循这个原理。
在这篇文章中,我们将了解什么是依赖注入,为什么它很有用,何时使用它,哪些工具可以帮助前端开发人员使用这种模式。
我们假设你了解 JavaScript 的基本语法,并熟悉面向对象编程的基本概念,例如类和接口。不过,你不需要详细了解类和接口的TypeScript 语法,因为我们会在这篇文章中用到它。
一般来说,依赖关系的概念依赖于上下文,但为了简单起见,我们将依赖关系称为模块所使用的任何模块。当我们开始在代码中使用一个模块时,这个模块就变成了一个依赖项。
我们使用函数参数来模拟依赖。这样,无需深入学术定义,我们可以将依赖关系与函数参数进行比较。两者都以某种方式使用,两者都会影响依赖于它们的软件的功能和可操作性。
// random 函数在没有 'min' 和 'max' 参数的情况下无法工作
function random(min, max) {
if (typeof min === 'undefined' || typeof max === 'undefined') {
throw new Error('All arguments are required');
}
return Math.random() * (max - min) + min;
}
在上面的例子中,random
函数有两个参数:min
和 max
。如果我们不通过其中一个,函数将抛出一个错误。我们可以得出结论,这个函数取决于这些参数。
然而,这个函数不仅取决于这两个参数,而且依赖于 Math.Random
函数。这是因为如果 Math.Random
没有定义,random
函数也不能工作,所以 Math.Random
也是一种依赖。
如果我们将它作为参数传递给函数,可以使它更清楚:
function random(min, max, randomSource) {
if (typeof min === 'undefined' || typeof max === 'undefined' || typeof randomSource === 'undefined') {
throw new Error('All arguments are required');
}
return randomSource.random() * (max - min) + min;
}
现在很明显,random
函数不仅使用 min
和 max
,还有随机数生成器。这类函数将被这样调用:
const randomBetweenTenAndTwenty = random(10, 20, Math);
或者如果我们不想每次都手动传递 Math
作为最后一个参数,我们可以在函数参数声明中使用它作为默认值:
function random(min, max, randomSource = Math) {
// ...code
}
// 调用random函数
const randomBetweenTenAndTwenty = random(10, 20);
这就是基本的依赖注入。当然,它还没有得到”规范“
,这是非常原始的,它必须用手完成,但关键的**是一样的:我们将它工作所需要的一切传递给模块。
random
函数示例中的代码更改似乎是不必要的。实际上,为什么我们要把 Math
提取到参数中,并像那样使用它?为什么我们不直接在函数体中使用它呢?有两个原因。
当模块明确声明它需要的所有东西时,这个模块测试起来要简单得多。我们看到需要准备好的需要立即运行测试。我们知道是什么影响了这个模块的功能,如果需要,可以用另一个实现替换它,甚至是假实现来替换它。
看起来像依赖性的对象,但是做不同的东西被称为 Mock
对象。当运行测试时,它们可能会跟踪某个函数被调用了多少次,模块的状态是如何改变的,这样以后我们就可以检验预期的结果了。
一般来说,他们使测试模块更简单,有时它们是测试模块的唯一方法。random
函数是这种情况,我们不能检查这个函数应该返回的最终结果,因为每次调用这个函数都是不同的。然而,我们可以检查这个函数如何使用它的依赖项并从中得出结果。
// 我们可以创建一个Mock对象,它将总是返回0.1而不是一个随机数:
const fakeRandomSource = {
random: () => 0.1,
}
// 然后,我们将调用函数,并将这个Mock对象作为依赖项而不是Math:
const randomBetweenTenAndTwenty = random(10, 20, fakeRandomSource);
// 既然函数的算法是确定的并且不变, 我们可以预期结果总是一样的:
randomBetweenTenAndTwenty === 11; // true
在测试时替换依赖项只是一种特殊情况。通常,我们可能出于任何原因想要用另一个模块替换一个模块。如果一个新模块的行为与前一个模块相同,我们可以在没有任何问题的情况下做到这一点:
// 如果一个新对象包含 `random` 方法,我们可以把它当作一种依赖。
const otherRandomSource = {
random() {
// 自定义随机数生成的实现。
}
}
const randomNumber = random(10, 20, otherRandomSource);
当我们想让我们的模块尽可能地彼此分开时,这是非常方便的。然而,是否有一种方法可以保证新模块包含 random
方法?(这是至关重要的,因为我们以后在函数随机中依赖这个方法)显然是有的,我们可以通过接口来实现。
接口是一种功能契约。它限制了模块的行为,它必须做什么,以及它不应该做什么。在我们的案例中,为了保证随机方法的存在,我们可以使用接口。
为了确定模块应该有一个返回数字的 random
方法,我们定义了一个接口:
interface RandomSource {
random(): number;
}
为了确定一个具体的对象必须有这个方法,我们声明这个对象实现了这个接口:
// 使用冒号声明
// 这个对象实现了一个 “RandomSource” 接口
// 因此,必须以这种接口中描述的方式行事。
const otherRandomSource: RandomSource = {
random = () => {
// 它必须返回一个数字,否则 TypeScript 编译器会抛出一个错误。
return 42;
}
}
现在我们可以声明我们的 random
函数只接受一个实现 RandomSource
接口的对象作为最后一个参数:
function random(min: number, max: number, source: RandomSource = Math): number {
if (typeof min === 'undefined'
|| typeof max === 'undefined'
|| typeof source === 'undefined') {
throw new Error('All arguments are required');
}
return source.random() * (max - min) + min;
}
如果我们现在试图传递一个没有实现 RandomSource
接口的对象,TypeScript 编译器会抛出一个错误。
const randomNumber1 = random(1, 10, Math);
// `Math` 包含一个 `random` 方法,没有错误。
const randomNumber2 = random(1, 10);
// `Math` 被用作默认参数值,没有错误。
const randomNumber3 = random(1, 10, otherRandomSource);
// 没有错误,因为`otherRandomSource`实现`RandomSource`接口。
const otherObject = {
otherMethod() {};
};
const randomNumber4 = random(1, 10, otherObject);
// 错误,'otherObject' 没有实现所需的接口
乍一看,这似乎有点过分。然而,这可以帮助我们获得很多好处。
当我们预先设计一个系统时,我们倾向于使用抽象的契约。使用这些契约,我们为第三方代码设计我们自己的模块和适配器。这解锁了与其他模块交换的能力,而不改变整个系统,而只是改变一部分。
特别是当模块比上面例子中的模块更复杂时,它就变得非常方便。例如,当一个模块具有内部状态时。
在 TypeScript 中,有很多方法可以创建有状态对象,例如使用闭包或类。在这篇文章中,我们将使用类。
作为一个例子,我们将使用一个计数器。作为一个类,它应该写成这样:
class Counter {
private state: number = 0;
public increase = (): void => {
this.state++;
}
public decrease = (): void => {
this.state--;
}
get stateOf(): number {
return this.state;
}
}
它的方法为我们提供了一种改变其内部状态的方法:
const counter = new Counter();
counter.stateOf; // 0
counter.increase();
counter.stateOf; // 1
counter.decrease();
counter.stateOf; // 0
当像这样的一些物体取决于其他物品时,它就会得到。让我们假设这个计数器不仅应该保持和更改它的内部状态,而且还应该在每次更改时将它记录到一个控制台中。
class Counter {
private state: number = 0;
// 添加日志记录方法。
private log = (): void => {
console.log(this.state);
}
public increase = (): void => {
// 现在当状态发生变化时…
this.state++;
this.log();
}
public decrease = (): void => {
// 现在当状态发生变化时…
this.state--;
this.log();
}
get stateOf(): number {
return this.state;
}
}
在这里,我们看到了与本文开头所看到的相同的问题。计数器不仅使用它的状态,而且还使用另一个模块 console
。理想情况下,它还应该是明确的,或者换句话说,注入式的。
可以使用 setter
或 constructor
在类中注入一个依赖项。我们使用 constructor
。
constructor
(构造函数)是在创建对象时调用的一种特殊方法。通常在对象初始化时指定要执行的所有操作。
例如,如果我们想在创建对象时将问候信息打印到控制台,我们可以使用下面的代码:
class Counter {
constructor() {
console.log('Hello world!');
}
// ...code.
}
const counter = new Counter();
// "Hello world!"
使用构造函数,我们还可以注入所有需要的依赖项。
我们想将类以与前面例子中的函数相同的方式处理依赖关系。
因此,我们的类 Counter
使用 Console
对象的 log
方法。这意味着该类期望依赖一个具有 log
方法的对象。它是 Console
对象还是其他对象并不重要,这里唯一的条件是对象有一个 log
方法。
当我们想要限制行为时,我们需要使用接口。因此,Counter
的构造函数应该接受一个对象作为参数,该对象实现了一个带有 log
方法的接口。
interface Logger {
log(message: string): void;
}
class Counter {
// 这个私有字段将保留一个引用到 logger 对象
private logger: Logger;
constructor(logger: Logger) {
// 我们将在初始化时设置
this.logger = logger;
}
// ...code.
}
// 或者使用字段自动分配
class Counter {
// 在以这种方式写入时,构造函数中的参数将自动分配给`logger`私有字段。
constructor(private logger: Logger) {}
// ...code.
}
要初始化类实例,我们将使用以下代码:
const counter = new Counter(console);
如果我们想要,比方说,使用 alert 而不是 console,我们会这样改变依赖对象:
// 这就足够确保依赖对象 拥有所有必需的方法,或者实现所需的接口。
const customLogger: Logger = {
log(message: string): void {
alert(message);
}
}
const counter = new Counter(customLogger);
现在,我们的 Counter
类没有使用任何隐式依赖关系。这很好,但是这种注入不方便。
实际上,我们想让它自动化。有一种方法可以做到这一点,它被称为 DI 容器。
总的来说,DI 容器是只做一件事的模块-它为系统中的其他每个模块提供依赖关系。容器确切地知道模块需要哪些依赖项,并在需要时注入它们。这样我们就解放了其他模块来解决这个问题,然后控制到一个特殊的地方。这是 SOLID 在 SRP 和 DIP 原则中描述的行为。
在实践中,为了使其工作,我们需要另一层抽象接口。(Typescript 有这个概念,Javascript 没有)这里的接口是不同模块之间的链接。
容器知道模块需要什么样的行为,知道哪些模块实现它,当创建一个对象时,它会自动提供对它们的访问。
在伪代码中,它看起来像这样:
// 嘿,容器!
// 当你被问到一个实现 `SomeInterface` 的对象时,你应该给访问 `SomeClass` 的一个实例。
container.register(SomeInterface, SomeClass);
尽管这段代码不是真实的,但它离现实并不遥远。
TypeScript 有很棒的工具,它可以做我们上面描述的事情。它们都是使用泛型函数来绑定接口和实现。
当然,在前端有强大框架 Angular,它有核心特性就是依赖注入。在后端也有强大框架 Nest,它有核心特性也是依赖注入。Nest 依赖注入也是参考 Angular 实现。
Angular 爱好者把依赖注入特性从 Angular 的 ReflectiveInjector 中提取出来的,创建一个独立库 injection-js。这意味着它设计得很好,功能齐全,快速、可靠,而且经过了很好的测试。有很多库内部使用 injection-js
,最有名当属将库编译为 Angular 包格式 ng-packagr(官方 Angular CLI 的一部分)。
在这里使用一个简单的 DI 库,使用此工具的代码如下所示:
import {DIContainer} from '@wessberg/di';
// 创建 DI 容器
const container = new DIContainer();
// 创建注入接口
interface Logger {
log(message: string): void;
}
// 实现注入接口
export class ConsoleLogger implements Logger {
public log = (message: LogEntry): void => console.log(message);
}
// 声明当有模块访问一个实现 `Logger` 接口的对象容器时,它应该返回 `ConsoleLogger` 类的一个实例。
container.registerSingleton<Logger, ConsoleLogger>();
// `<Logger, ConsoleLogger>` 语法是一个泛型函数。它使用类型参数将 `Logger` 类型与 `ConsoleLogger` 类型绑定。
// `Logger` 是一个抽象接口,`ConsoleLogger` 是一个更具体的类。
// 由于 TypeScript 将它们都视为类型,所以我们可以在泛型函数中将它们用作类型参数。
现在,如果我们想访问 Counter
类中的依赖项,我们可以通过编写下面的代码来实现:
class Counter {
constructor(private logger: Logger) {}
private log = (): void => {
this.logger.log(this.state);
}
// ... code.
}
container.registerSingleton<Counter>();
最后一行在容器本身中注册 Counter
类。这样容器就知道 Counter
可以从中寻求依赖关系。
首先,我们现在只需改变一行就可以改变整个项目的实现。
例如,如果我们想在每个使用它的地方更改 Logger
实现,只需更改模块注册就足够了:
// 自定义日志实现
class CustomLogger implements Logger {
public log = (message: LogEntry): void => alert(message);
}
// 替换旧的 `ConsoleLogger` 我们只更改下面一行的注册:
container.registerSingleton<Logger, CustomLogger>();
此外,我们不必手动传递依赖项,我们不必再保持依赖项的顺序,因此模块之间的耦合会变得更少。
这个容器的杀手锏是它不使用装饰器(如果喜欢装饰器,可以使用 inverse.js)。类型参数注册使得区分基础结构代码和生产代码更加容易。
单例和临时是对象的生命状态类型。
registerSingleton
只创建一个对象,之后它会传递到每个需要它的地方。registerTransient
每次都会创建一个新对象。
临时对象用于处理一些独特的场景,比如每次都应该从头创建的网络请求对象。当我们可以使用相同的实例(例如,用于记录日志)时,就使用单例对象。
我写了一个小应用程序,点击时候 alert 提示唯一ID,此外,它每 5 秒在控制台显示一条 Hello world
日志。
export class Application {
constructor(
private dateTimeSource: DateTimeSource,
private idGenerator: UuidGenerator,
private clickHandler: EventHandler<MouseEvent>,
private logger: Logger,
private timer: Timer,
private env: Window
) {}
private greet = (): void => this.logger.log('Hello world!');
private setupTimer = (): void => this.timer.invokeEvery(this.greet, 5000);
private registerClicks = (): void => this.clickHandler.on('click', this.handleClick);
private handleClick = (e: MouseEvent): void => {
const position = [e.pageX, e.pageY];
const datetime = this.dateTimeSource.toString();
const eventId = this.idGenerator.generate();
this.env.alert(`${eventId}, ${datetime}: Mouse was clicked at ${position} `);
};
public init = (): void => {
this.setupTimer();
this.registerClicks();
};
}
container.registerSingleton<Application>();
所有有趣的东西都在类构造函数中。在那里,我们向一个容器请求所有依赖项。
这些是主要模块取决于的依赖关系:
为了访问日期和时间,我们使用 BrowserDateTimeSource
,它被注册为 DateTimeSource
的实现。请注意,当我们要求这种依赖时,我们使用了接口,因为接口是所有东西都应该依赖于抽象的关键点。
export interface DateTimeSource {
source: Date;
toString: () => string;
valueOf: () => string;
}
export class BrowserDateTimeSource implements DateTimeSource {
get source() {
return new Date();
}
public toString = (): UtcDateTimeString => this.source.toUTCString();
public valueOf = (): TimeStamp => this.source.getTime();
}
container.registerSingleton<DateTimeSource, BrowserDateTimeSource>();
唯一的 ID 生成器是第三方的适配器。注意,我们只在注册适配器时引用这个第三方模块一次。如果我们决定用另一个 UUID 生成器可以随时替换,这是很方便的。
export interface UuidGenerator {
generate:() => string;
}
export class IdGenerator implements UuidGenerator {
constructor(private adaptee: ThirdPartyGenerator) {}
generate = () => this.adaptee();
}
container.registerSingleton<ThirdPartyGenerator>(() => uuid);
container.registerSingleton<UuidGenerator, IdGenerator>();
事件处理程序使用通用接口 EventHandler<MouseEvent>
。稍后从容器中请求这种依赖关系是很重要的。如果在这个接口中传递另一个类型参数,容器将搜索使用该参数注册的模块。当我们处理类似的对象类型时,这是很方便的。
export class ClickHandler implements EventHandler<MouseEvent> {
constructor(private env: Window) {}
public on = (event: EventKind, callback: EventCallback<MouseEvent>): () => void {
this.env.addEventListener(event, callback);
return () => {
this.env.removeEventListener(event, callback);
}
}
public off = (event: EventKind, callback: EventCallback<MouseEvent>): void =>
this.env.removeEventListener(event, callback);
}
container.registerSingleton<EventHandler<MouseEvent>, ClickHandler>();
这个我们已经实现过了:
export class ConsoleLogger implements Logger {
public log = (message: LogEntry): void => console.log(message);
}
container.registerSingleton<Logger, ConsoleLogger>();
模拟一个定时器,在间隔时间内执行回调函数。
export interface Timer {
invokeEvery:(fn: (...args: any[]) => void, delay: number) => () => void;
}
export class BrowserTimer implements Timer {
constructor() {}
invokeEvery = (fn: (...args: any[]) => void, delay: number) => () => void{
let timer = setInterval(fn, delay);
() => {
clearInterval(timer);
timer = null;
}
}
}
container.registerSingleton<Timer, BrowserTimer>();
它们是依赖项的依赖项,例如,ClickHandler
类中的 env
或 IdGenerator
中的 adaptee
。
对于容器来说,依赖于什么级别并不重要。容器可以毫无问题地提供所有依赖项。(除非有循环依赖,那是另外一个值得深入探讨的话题)
// 对于 `idgenerator`,我们注册了依赖项,如:
container.registerSingleton<ThirdPartyGenerator>(() => nanoid);
// 对于“ClickHandler”(需要 `Window`)
container.registerSingleton<Window>(() => window);
DI 容器的主要问题是,当使用它时,必须注册那里的所有依赖项。它有时并不像我们想要的那样灵活。
另一个缺点是只能从容器访问入口点,这可能看起来有点脏代码。(不过,对于入口点来说,这是可以接受的)
const app= container.get<Application>();
app.init();
今天就到这里吧,伙计们,玩得开心,祝你好运。
TypeScript 对于许多 Javascript 开发人员来说是难以理解的。引起麻烦的一个领域是高级类型。这个领域的一部分是 TypeScript 中内置的实用程序类型。它们可以帮助我们从现有类型中创建新的类型。在本文中,我们将了解其中一些实用工具类型如何工作以及如何使用它们。
TypeScript 为处理类型提供了一个强大的系统。这里有一些基本类型我们已经从 JavaScript 中了解。例如,数据类型如 number
,string
,boolean
,obejct
,symbol
,null
,undefined
。这并不是 TypeScript 提供的所有功能。在这些类型之上还有一些内置实用工具类型。
有时候,这些实用工具类型也是最难以理解的。当初次看到这些类型时尤为明显。好消息是,如果你理解一个重要的事情,这些类型实际上并不困难。
所有这些内置实用工具类型实际上都是简单的函数,能看这篇文章,说明你已经从 JavaScript 中知道的函数。这里的主要区别是,工具函数处理业务,这些实用工具类型,只处理类型。这些工具类型所做的就是将类型从一种类型转换为另一种类型。
这个输入是开始时使用的某种类型。还可以提供多种类型。接下来,该工具类型将转换该输入并返回适当的输出。所发生的转换类型取决于使用的实用工具类型。
Typescipt 内置了 16 个工具类型 和 4 个字符串类型(只能在字符串中使用,这里暂时不介绍它们),接下来我们就来:
Let's learn them one by one!
TypeScript 中的所有实用工具类型都使用类似的语法。这将使我们更容易学习和记住它。如果我们尝试将这些类型看作类似于函数的东西,也会使它更容易。这通常有助于更快地理解语法,有时要快得多。关于语法。
每个实用工具类型都以类型的名称开始。这个名字总是以大写字母开头。名称后面是左尖和右尖的尖括号,小于和大于符号(<>)。括号之间是参数。这些参数是我们正在使用的类型,即输入。Typescipt 把这种语法叫泛型 GenericType<SpecificType>
。
仔细想想,使用实用程序类型就像调用一个函数并传递一些东西作为参数。这里的一个不同之处在于该函数始终以大写字母开头。第二个区别是,在函数名之后没有使用圆括号,而是使用尖括号。函数调用:fn(a, b)
有些类型需要一个参数,有些则需要两个或者更多。与 JavaScript 函数参数类似,这些参数由冒号(,)分割,并在尖括号之间传递。下面这个简单的例子说明了普通函数和 TypeScript 实用工具类型之间的相似性。
// 在JavaScript中调用函数
myFunction('one argument');
myFunction('one argument', 'two argument');
myFunction('one argument', 'some argument');
// 在TypeScript中使用内置类型
UtilityType<'one type'>;
UtilityType<'one type', 'two type'>;
UtilityType<'one type', 'some type'>;
我们将要学习的类型在 TypeScript 4.0 及以上版本中全部可用。确保你使用这个版本。否则,下面的一些类型可能无法工作,或者没有一些额外的包就无法工作。
Partial<Type>
type Partial<T> = {
[P in keyof T]?: T[P];
};
创建 type
或 interface
时,所有在内部定义的类型都需要作为默认值。如果我们想将某些标记为可选的,我们可以使用 ?
并将其放在属性名称之后。这将使该属性成为可选的。
// 一个可选的年龄的用户接口
interface Person {
name: string;
age?: number;
}
// 创建一个用户
const user: Person = {
name: 'jack'
}
如果希望所有属性都是可选的,那么必须将所有属性都加上 ?
。我们可以这样做:
// 一个可选的年龄的用户接口
interface Person {
name?: string;
age?: number;
}
// 创建一个用户
const user: Person = {}
它也会对与该 interface
一起工作的一切产生影响。我们可以使用的另一个选项是 Partial<Type>
。该类型接受一个参数,即希望设置为可选的类型。它返回相同的类型,但其中所有先前必需的属性现在都是可选的。
// 创建一个接口
interface Person {
name: string;
age: number;
jobTitle: string;
hobbies: string[];
}
// 使用 Person 接口创建对象
const jack: Person = {
name: 'Jack',
age: 33,
jobTitle: 'CTO',
hobbies: ['reading']
}
// 这是 ok,因为 “jack”对象包含在 Person 接口中指定的所有属性。
// 使用 Person 接口创建新对象
const lucy: Person = {
name: 'Lucy',
age: 18,
}
// TS error: Type '{ name: string; age: number; }' is missing the following properties from type 'Person': jobTitle, hobbies
// 使用 Partial<Type> 和 Person 接口使 Person 接口中的所有属性都是可选的
const lucy: Partial<Person> = {
name: 'Lucy',
age: 18,
}
// 这也会有效:
const alan: Partial = {}
// Partial 之后的 Person 接口:
// interface Person {
// name?: string;
// age?: number;
// jobTitle?: string;
// hobbies?: string[];
// }
Required<Type>
type Required<T> = {
[P in keyof T]-?: T[P];
};
Required<Type>
与 Partial<Type>
正好相反。如果 Partial<Type>
使所有属性都是可选的,则 Required<Type>
使它们都是必需的、不可选的。Required<Type>
的语法与 Partial<Type>
相同。唯一的区别是实用工具类型的名称。
// 创建一个接口
interface Cat {
name: string;
age: number;
hairColor: string;
owner?: string; // <= 使“owner”属性可选
}
// 这将有效:
const suzzy: Cat = {
name: 'Suzzy',
age: 2,
hairColor: 'white',
}
// 使用 Required<Type> 连同 Cat 接口使 Cat 接口中的所有属性都是必需的:
const suzzy: Required<Cat> = {
name: 'Suzzy',
age: 2,
hairColor: 'white',
}
// TS error: Property 'owner' is missing in type '{ name: string; age: number; hairColor: string; }' but required in type 'Required<Cat>'.
// Required<Cat> 之后的 Cat 接口:
// interface Cat {
// name: string;
// age: number;
// hairColor: string;
// owner: string;
// }
Readonly<Type>
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
有时我们希望使某些数据不可变,防止他们被修改了。Readonly<Type>
类型可以帮助我们对整个类型进行这种更改。例如,可以将接口中的所有属性设置为只读。当我们在某个对象中使用该接口,并试图改变某个对象的属性时,TypeScript 会抛出一个错误。
// 创建一个接口:
interface Book {
title: string;
author: string;
numOfPages: number;
}
// 创建一个使用 Book 接口的对象
const book: Book = {
title: 'Javascript',
author: 'Brendan Eich',
numOfPages: 1024
}
// 尝试改变属性
book.title = 'Typescript'
book.author = 'Anders Hejlsberg'
book.numOfPages = 2048
// 打印 book的值:
console.log(book)
// Output:
// {
// "title": "Typescript",
// "author": "Anders Hejlsberg",
// "numOfPages": 2048
// }
// 将 Book 的所有属性设置为只读:
const book: Readonly<Book> = {
title: 'Javascript',
author: ' Brendan Eich',
numOfPages: 1024
}
// 尝试改变属性
sevenPowers.title = 'Typescript'
// TS error: Cannot assign to 'title' because it is a read-only property.
Record<Keys, Type>
type Record<K extends keyof any, T> = {
[P in K]: T;
};
假设我们有一组属性名和属性值。也就是我们常常在 Javascript 中使用的 {key: value}
。基于此数据,Record<Keys, Type>
允许我们创建键值对的记录。Record<Keys, Type>
通过将 keys
参数指定的所有属性类型与 Type
参数指定的值类型进行映射,基本上创建了一个新接口。
// 创建Table类型
type Table = Record<'width' | 'height' | 'length', number>;
// type Table is basically ('width' | 'height' | 'length' are keys, number is a value):
// interface Table {
// width: number;
// height: number;
// length: number;
// }
// 根据Table类型创建新对象:
const smallTable: Table = {
width: 50,
height: 40,
length: 30
}
// 根据Table类型创建新对象:
const mediumTable: Table = {
width: 90,
length: 80
}
// TS error: Property 'height' is missing in type '{ width: number; length: number; }' but required in type 'Table'.
// 创建类型与一些字符串键:
type PersonKeys = 'firstName' | 'lastName' | 'hairColor'
// 创建一个使用 Personkeys 类型:
type Person = Record<PersonKeys, string>
// type Person is basically (personKeys are keys, string is a value):
// interface Person {
// firstName: string;
// lastName: string;
// hairColor: string;
// }
const jane: Person = {
firstName: 'Jane',
lastName: 'Doe',
hairColor: 'brown'
}
const james: Person = {
firstName: 'James',
lastName: 'Doe',
}
// TS error: Property 'hairColor' is missing in type '{ firstName: string; lastName: string; }' but required in type 'Person'.
type Titles = 'Javascript' | 'Typescript' | 'Python'
interface Book {
title: string;
author: string;
}
const books: Record<Titles, Book> = {
Javascript: {
title: 'Javascript',
author: 'Brendan Eich'
},
Typescript: {
title: 'Typescript',
author: 'Anders Hejlsberg'
},
Python: {
title: 'Python',
author: 'Guido Van Rossum'
},
}
// Record<Titles, Book> 基本上相当于:
Javascript: { // <= "Javascript" 键是指定的 "Titles".
title: string,
author: string,
}, // <= "Javascript" 值是指定的 "Book".
Typescript: { // <= "Typescript" 键是指定的 "Titles".
title: string,
author: string,
}, // <= "Typescript" 值是指定的 "Book".
Python: { // <= "Python" 键是指定的 "Titles".
title: string,
author: string,
} // <= "Python" 值是指定的 "Book".
Pick<Type, Keys>
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
假设我们只想使用现有接口的一些属性。可以做的一件事是创建新接口,只使用这些属性。另一个选项是使用 Pick<Type, Keys>
。Pick
类型允许我们获取现有类型(type),并从中只选择一些特定的键(keys),而忽略其余的。这个类型和 lodash.pick 工具函数功能一样,如果你理解这个函数,那么对于这个类型理解就很轻松了。
// 创建一个 Beverage 接口:
interface Beverage {
name: string;
taste: string;
color: string;
temperature: number;
additives: string[] | [];
}
// 创建一个仅使用“name”,“taste”和“color”属性 Beverage 类型:
type SimpleBeverage = Pick<Beverage, 'name' | 'taste' | 'color'>;
// 把 Basically 转化成:
// interface SimpleBeverage {
// name: string;
// taste: string;
// color: string;
// }
// 使用 SimpleBeverage 类型创建新对象:
const water: SimpleBeverage = {
name: 'Water',
taste: 'bland',
color: 'transparent',
}
Omit<Type, Keys>
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
Omit<Type, Keys>
基本上是一个相反的 Pick<Type, Keys>
。我们指定某些类型作为 type
的参数,但不是选择我们想要的属性,而是选择希望从现有类型中省略的属性。这个类型和 lodash.omit 工具函数功能一样,如果你理解这个函数,那么对于这个类型理解就很轻松了。
// 创建一个 Car 接口:
interface Car {
model: string;
bodyType: string;
numOfWheels: number;
numOfSeats: number;
color: string;
}
// 基于 Car 接口创建 Boat 类型,但省略 “numOfWheels” 和 “bodyType” 属性
type Boat = Omit<Car, 'numOfWheels' | 'bodyType'>;
// 把 Boat 转化成:
// interface Boat {
// model: string;
// numOfSeats: number;
// color: string;
// }
// 基于 Car 创建新对象:
const tesla: Car = {
model: 'S',
bodyType: 'sedan',
numOfWheels: 4,
numOfSeats: 5,
color: 'grey',
}
// 基于 Boat 创建新对象:
const mosaic: Boat = {
model: 'Mosaic',
numOfSeats: 6,
color: 'white'
}
Exclude<Type, ExcludedUnion>
type Exclude<T, U> = T extends U ? never : T;
初次使用 Exclude<Type, ExcludedUnion>
可能有点令人困惑。这个实用工具类型所做的是,用于从类型 Type
中取出不在 ExcludedUnion
类型中的成员。
// 创建 Colors 类型:
type Colors = 'white' | 'blue' | 'black' | 'red' | 'orange' | 'grey' | 'purple';
type ColorsWarm = Exclude<Colors, 'white' | 'blue' | 'black' | 'grey'>;
// 把 ColorsWarm 转化成:
// type ColorsWarm = "red" | "orange" | "purple";
type ColorsCold = Exclude<Colors, 'red' | 'orange' | 'purple'>;
// 把 ColorsCold 转化成:
// type ColorsCold = "white" | "blue" | "black" | "grey"
// 创建 varmColor:
const varmColor: ColorsWarm = 'red'
// 创建 coldColor:
const coldColor: ColorsCold = 'blue'
// 尝试混合:
const coldColorTwp: ColorsCold = 'red'
// TS error: Type '"red"' is not assignable to type 'ColorsCold'.
Extract<Type, Union>
type Extract<T, U> = T extends U ? T : never;
Extract<Type, Union>
类型执行与 Exclude<Type, ExcludedUnion>
类型相反的操作。用于从类型 Type
中取出可分配给 Union
类型的成员。有点类似集合里的交集概念。使用 Extract
之后,返回 Type
和 Union
交集。
type Food = 'banana' | 'pear' | 'spinach' | 'apple' | 'lettuce' | 'broccoli' | 'avocado';
type Fruit= Extract<Food, 'banana' | 'pear' | 'apple'>;
// 把 Fruit 转换成:
// type Fruit = "banana" | "pear" | "apple";
type Vegetable = Extract<Food, 'spinach' | 'lettuce' | 'broccoli' | 'avocado'>;
// 把 Vegetable 转换成:
// type Vegetable = "spinach" | "lettuce" | "broccoli" | "avocado";
// 创建 someFruit:
const someFruit: Fruit = 'pear'
// 创建 someVegetable:
const someVegetable: Vegetable = 'lettuce'
// 尝试混合:
const notReallyAFruit: Fruit = 'avocado'
// TS error: Type '"avocado"' is not assignable to type 'Fruit'.
NonNullable<Type>
type NonNullable<T> = T extends null | undefined ? never : T;
NonNullable
实用工具类型的工作原理与 Exclude
类似。它接受指定的某种类型,并返回该类型,但不包括所有 null
和 undefined
类型。
// 创建类型:
type prop = string | number | string[] | number[] | null | undefined;
// 基于以前的类型创建新类型,不包括 null 和 undefined:
type validProp = NonNullable<prop>
// 把 validProp 转换成:
// type validProp = string | number | string[] | number[]
// 这是有效的:
let use1: validProp = 'Jack'
let use2: validProp = null
// TS error: Type 'null' is not assignable to type 'validProp'.
Parameters<Type>
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
Parameters
类型返回一个 Tuple 类型,其中包含作为 Type
传递的形参函数的类型。这些参数的返回顺序与它们在函数中出现的顺序相同。注意,Type
参数,对于 this
和以下类型,是一个函数 ((…args) =>type)
,而不是一个类型,比如 string
。
// 声明函数类型:
declare function myFunc(num1: number, num2: number): number;
// 使用 Parameter<type> 从 myFunc 函数的参数创建新的 Tuple 类型:
type myFuncParams = Parameters<typeof myFunc>;
// 把 myFuncParams 转换成:
// type myFuncParams = [num1: number, num2: number];
// 这是有效的:
let someNumbers: myFuncParams = [13, 15];
let someMix: myFuncParams = [9, 'Hello'];
// TS error: Type 'string' is not assignable to type 'number'.
// 使用 Parameter<type> 从函数的参数创建新的 Tuple 类型:
type StringOnlyParams = Parameters<(foo: string, fizz: string) => void>;
// 把 StringOnlyParams 转换成:
// type StringOnlyParams = [foo: string, fizz: string];
// 这是有效的:
let validNamesParams: StringOnlyParams = ['Jill', 'Sandy'];
let invalidNamesParams: StringOnlyParams = [false, true];
// TS error: Type 'boolean' is not assignable to type 'string'.
ConstructorParameters<Type>
type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;
ConstructorParameters
类型与 Parameters
类型非常相似。这两者之间的区别在于, Parameters
从函数参数中获取类型,而ConstructorParameters
从作为 Type
参数传递的构造函数(Constructor)中获取类型。
// 创建一个 class:
class Human {
public name
public age
public gender
constructor(name: string, age: number, gender: string) {
this.name = name;
this.age = age;
this.gender = gender;
}
}
// 创建基于 Human 构造函数类型:
type HumanTypes = ConstructorParameters<typeof Human>
// 把 HumanTypes 转换成:
// type HumanTypes = [name: string, age: number, gender: string]
const joe: HumanTypes = ['Joe', 33, 'male']
const sandra: HumanTypes = ['Sandra', 41, 'female']
const thomas: HumanTypes = ['Thomas', 51]
// TS error: Type '[string, number]' is not assignable to type '[name: string, age: number, gender: string]'.
// Source has 2 element(s) but target requires 3.
// 创建基于 String 构造函数类型:
type StringType = ConstructorParameters<StringConstructor>
// 把 StringType 转换成:
// type StringType = [value?: any]
ReturnType<Type>
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
ReturnType
也类似于 Parameters
类型。这里的不同之处在于,ReturnType
提取作为 type
参数传递的函数的返回类型。
// 声明函数类型:
declare function myFunc(name: string): string;
// 使用 ReturnType<Type> 从 myFunc 类型中提取返回类型:
type MyFuncReturnType = ReturnType<typeof myFunc>;
// 把 MyFuncReturnType 转换成:
// type MyFuncReturnType = string;
// 这是有效的:
let name1: MyFuncReturnType = 'Types';
// 这是有效的:
let name2: MyFuncReturnType = 42;
// TS error: Type 'number' is not assignable to type 'string'.
type MyReturnTypeBoolean = ReturnType<() => boolean>
// 把 MyReturnTypeBoolean 转换成:
// type MyReturnTypeBoolean = boolean;
type MyReturnTypeStringArr = ReturnType<(num: number) => string[]>;
// 把 MyReturnTypeStringArr 转换成:
// type MyReturnTypeStringArr = string[];
type MyReturnTypeVoid = ReturnType<(num: number, word: string) => void>;
// 把 MyReturnTypeVoid 转换成:
// type MyReturnTypeVoid = void;
InstanceType<Type>
type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;
Instancetype
有点复杂。它所做的就是从作为 Type
参数传递的构造函数的实例类型创建一个新类型。如果使用一个常规类来处理类,则可能不需要此实用工具类型。可以只使用类名来获取所需的实例类型。
// 创建一个 class:
class Dog {
name = 'Sam'
age = 1
}
type DogInstanceType = InstanceType<typeof Dog>
// 把 DogInstanceType 转换成:
// type DogInstanceType = Dog
// 类似于使用 class 声明:
type DogType = Dog
// 把 DogType 转换成:
// type DogType = Dog
ThisParameterType<Type>
type ThisParameterType<T> = T extends (this: infer U, ...args: any[]) => any ? U : unknown;
ThisParameterType
提取了作为 Type
参数传递的函数的 this
形参的使用类型。如果函数没有这个参数,实用工具类型将返回unknown
。
// 创建一个使用 this 参数函数:
function capitalize(this: String) {
return this[0].toUpperCase + this.substring(1).toLowerCase()
}
// 创建基于 this 参数的 capitalize函数类型:
type CapitalizeStringType = ThisParameterType<typeof capitalize>
// 把 CapitalizeStringType 转换成:
// type CapitalizeStringType = String
// 创建一个不使用 this 参数函数:
function sayHi(name: string) {
return `Hello, ${name}.`
}
// 创建基于不带 this 参数的 printUnknown 函数类型:
type SayHiType = ThisParameterType<typeof sayHi>
// 把 SayHiType 转换成:
// type SayHiType = unknown
OmitThisParameter<Type>
type OmitThisParameter<T> = unknown extends ThisParameterType<T> ? T : T extends (...args: infer A) => infer R ? (...args: A) => R : T;
OmitThisParameter
实用类型执行与前面类型相反的操作。它通过 Type
接受一个函数类型作为参数,并返回不带 this
形参的函数类型。
// 创建一个使用 this 参数函数:
function capitalize(this: String) {
return this[0].toUpperCase + this.substring(1).toLowerCase()
}
// 根据 capitalize 函数创建类型:
type CapitalizeType = OmitThisParameter<typeof capitalize>
// 把 CapitalizeStringType 转换成:
// type CapitalizeType = () => string
// 创建一个不使用 this 参数函数:
function sayHi(name: string) {
return `Hello, ${name}.`
}
// 根据 Sayhi 函数创建类型:
type SayHiType = OmitThisParameter<typeof sayHi>
// 把 SayHiType 转换成:
// type SayHiType = (name: string) => string
ThisType<Type>
interface ThisType<T> { }
ThisType
实用工具类型允许显式地设置 this
上下文。可以使用它为整个对象字面量或仅为单个函数设置此值。在尝试此操作之前,请确保启用了编译器标志 --noImplicitThis
。
// 创建 User 对象接口:
interface User {
username: string;
email: string;
isActivated: boolean;
printUserName(): string;
printEmail(): string;
printStatus(): boolean;
}
// 创建用户对象,并将 ThisType 设置为 user interface:
const userObj: ThisType<User> = {
username: 'Jiayi',
email: '[email protected]',
isActivated: false,
printUserName() {
return this.username;
},
printEmail() {
return this.email;
},
printStatus() {
return this.isActivated;
}
}
TypeScript 内置实用工具类型的一个好处是,我们可以自由组合它们。可以将一种实用工具类型与另一种实用工具类型组合。还可以将一种实用工具类型与其他类型组合。例如,可以将类型与联合或交集类型组合。
// 创建 User 接口:
interface User {
username: string;
password: string;
}
// 创建 SuperUser 接口:
interface SuperUser {
clearanceLevel: string;
accesses: string[];
}
// 结合 User 和 SuperUser 创建 RegularUser 类型
// 让 User 属性必需的和 SuperUser 属性可选的:
type RegularUser = Required<User> & Partial<SuperUser>
// 这是有效的:
const jack: RegularUser = {
username: 'Jack',
password: 'some_secret_password_unlike_123456',
}
// 这是有效的:
const jason: RegularUser = {
username: 'Jason',
password: 'foo_bar_usually-doesnt_work-that_well',
clearanceLevel: 'A'
}
// 这将抛出异常:
const jim: RegularUser = {
username: 'Jim'
}
// TS error: Type '{ username: string; }' is not assignable to type 'RegularUser'.
// Property 'password' is missing in type '{ username: string; }' but required in type 'Required<User>'
虽然上面的内置实用工具类型令人惊叹,但它们并没有涵盖所有的用例,这就是提供更多实用工具的库填补空白的地方。此类库的一个很好的例子是 type-fest,它提供了更多的实用程序。
在本文中,我们学习了 Typescript 实用工具类型,以及它们如何帮助我们从现有的类型中自动创建类型,而不会导致重复,从而无需保持相关类型的同步。我们列举一些内置的实用工具类型,我认为它们在我作为开发人员的日常工作中特别有用。在此基础上,我们推荐了 type-fest
,这是一个包含许多扩展内置类型的实用程序类型的库。
今天就到这里吧,伙计们,玩得开心,祝你好运。
2017开始在Github写博客,记录工作总结,学习经验。
2017计划2个开源项目。
数组是最常见的数据结构之一,我们需要绝对自信地使用它。在这里,我将列出 JavaScript
中最重要的几个数组常用操作片段,包括数组长度、替换元素、去重以及许多其他内容。
大多数人都知道可以像这样得到数组的长度:
const arr = [1, 2, 3];
console.log(arr.length); // 3
有趣的是,我们可以手动修改长度。这就是我所说的:
const arr = [1, 2, 3];
arr.length = 2;
arr.forEach(i => console.log(i)); // 1 2
甚至创建指定长度的新数组:
const arr = [];
arr.length = 100;
console.log(arr) // [undefined, undefined, undefined ...]
这不是一个很好的实践,但是值得了解。
我们常常需要清空数组时候会使用:
const arr = [1, 2];
arr.length = 0;
console.log(arr) // []
如果 arr
的值是共享的,并且所有参与者都必须看到清除的效果,那么这就是你需要采取的方法。但是,JavaScript
语义规定,如果减少数组的长度,则必须删除新长度及以上的所有元素。而且这需要花费时间(除非引擎对设置长度为零的特殊情况进行了优化)。实际上,一个性能测试表明,在所有当前的 JavaScript
引擎上,这种清除方法更快。
有几种方法可以解决这个问题。如果需要替换指定索引处的元素,请使用 splice
:
const arr = [1, 2, 3];
arr.splice(2, 1, 4); // 将索引 2 开始的 1 元素更改为 4
console.log(arr); // [1, 2, 4]
arr.splice(0, 2, 5, 6) // 将索引 0 开始的 2 个元素更改为 5 和 6
console.log(arr); // [5, 6, 4]
splice
在数组删除有更多的说明
如果你需要根据项目的内容替换项目,或者必须创建一个新数组,请使用 map
:
const arr = [1, 2, 3, 4, 5, 6];
// 所有奇数的平方
const arr2 = arr.map(item => item % 2 == 0 ? item : item*item);
console.log(arr2); // [1, 2, 9, 4, 25, 6];
map
接受函数作为其参数。它将对数组中的每个元素调用该函数一次,并生成一个新的函数返回的项数组。
关于
map
有个经典的面试题:['1', '2', '3', '4', '5'].map(parseInt)
=> ?
在某些情况下,你需要删除数组中的某些元素,然后创建一个新的元素。在这种情况下,使用在ES5中引入的很棒的 filter
方法:
const arr = [1, 2, 3, 4, 5, 6, 7];
// 过滤掉所有奇数
const arr2 = arr.filter(item => item % 2 == 0);
console.log(arr2); // [2, 4, 6];
filter
的工作原理与 map
非常相似。向它提供一个函数,filter
将在数组的每个元素上调用它。如果要在新数组中包含此特定元素,则函数必须返回 true
,否则返回 false
。
如果你想将多个数组合并为一个数组,有两种方法。
Array
提供了 concat
方法:
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const arr3 = arr1.concat(arr2);
console.log(arr3 ); // [1, 2, 3, 4, 5, 6]
ES6
中引入了 spread operator
,一种更方便的方法:
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const arr3 = [...arr1, ...arr2];
console.log(arr3 ); // [1, 2, 3, 4, 5, 6]
还有一种比较奇特方法:
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
Array.prototype.push.apply(arr1, arr2);
console.log(arr1); // [1, 2, 3, 4, 5, 6]
上面 2 种通用的方法,都不会改变原数组,最后一种奇特方法,会改变 push
的原数组,谨慎使用。
Array.prototype.push.apply
和 concat
对比:
Array.prototype.push.apply
数组长度有限制,不同浏览器不同,一般不能超过十万, concat
无限制Array.prototype.push.apply
会改变原数组, concat
不会正常情况下我们都应该使用 concat
和 spread operator
,有种情况下可以使用,如果频繁合并数组可以用 Array.prototype.push.apply
。
总所周知,定义数组变量存储不是数组值,而只是存储引用。 这是我的意思:
const arr1 = [1, 2, 3];
const arr2 = arr1;
arr2[0] = 4;
arr2[1] = 2;
arr2[2] = 0;
console.log(arr1); // [4, 2, 0]
因为 arr2
持有对 arr1
的引用,所以对 arr2
的任何更改都是对 arr1
的更改。
const arr1 = [1, 2, 3];
const arr2 = arr1.slice(0);
arr2[0] = 4;
arr2[1] = 2;
arr2[2] = 0;
console.log(arr1); // [1, 2, 3]
console.log(arr2); // [4, 2, 0]
我们也可以使用 ES6
的 spread operator
:
const arr1 = [1, 2, 3];
const arr2 = [...arr1];
arr2[0] = 4;
arr2[1] = 2;
arr2[2] = 0;
console.log(arr1); // [1, 2, 3]
console.log(arr2); // [4, 2, 0]
也可以使用前面合并使用的 concat
方法
const arr1 = [1, 2, 3];
const arr2 = [].concat(arr1);
arr2[0] = 4;
arr2[1] = 2;
arr2[2] = 0;
console.log(arr1); // [1, 2, 3]
console.log(arr2); // [4, 2, 0]
注意:如果想要了解更多的数组复制,请查询
数组深拷贝和浅拷贝
相关资料,这里只实现了浅拷贝。
数组去重是面试经常问的,数组去重方式很多,这里介绍比较简单直白的三种方法:
可以使用 filter
方法帮助我们删除重复数组元素。filter
将接受一个函数并传递 3 个参数:当前项、索引和当前数组。
const arr1 = [1, 1, 2, 3, 1, 5, 9, 4, 2];
const arr2 = arr1.filter((item, index, arr) => arr.indexOf(item) == index);
console.log(arr2); // [1, 2, 3, 5, 9, 4]
可以使用 reduce
方法从数组中删除所有重复项。然而,这有点棘手。reduce
将接受一个函数并传递 2 个参数:数组的当前值和累加器。累加器在项目之间保持相同,并最终返回:
const arr1 = [1, 1, 2, 3, 1, 5, 9, 4, 2];
const arr2 = arr1.reduce(
(acc, item) => acc.indexOf(item) == -1 ? [...acc, item]: acc,
[] // 初始化当前值
);
console.log(arr2); // [1, 2, 3, 5, 9, 4]
可以使用 ES6
中引入的新数据结构 set
和 spread operator
:
const arr1 = [1, 1, 2, 3, 1, 5, 9, 4, 2];
const arr2 = [...(new Set(arr1))];
console.log(arr2); // [1, 2, 3, 5, 9, 4]
还有很多其他去重方式,比如使用 {}
+ for
。
有时我们必须将一些其它数据结构,如集合或字符串转换为数组。
类数组:函数参数,DOM 集合
Array.prototype.slice.call(arguments);
Array.prototype.concat.apply([], arguments);
字符串:
console.log('string'.split('')); // ["s", "t", "r", "i", "n", "g"]
console.log(Array.from('string')); // ["s", "t", "r", "i", "n", "g"]
集合:
console.log(Array.from(new Set(1,2,3))); // [1,2,3]
console.log([...(new Set(1,2,3))]); // [1,2,3]
数组遍历方式很多,有底层的,有高阶函数式,我们就来介绍几种:
for:
const arr = [1, 2, 3];
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
// 1 2 3
for-in:
const arr = [1, 2, 3];
for (let i in arr) {
if(arr.hasOwnProperty(i)) {
console.log(arr[i]);
}
}
// 1 2 3
for-of:
const arr = [1, 2, 3];
for (let i of arr) {
console.log(i);
}
// 1 2 3
forEach:
[1, 2, 3].forEach(i => console.log(i))
// 1 2 3
while:
const arr = [1,2,3];
let i = -1;
const length = arr.length;
while(++i < length) {
console.log(arr[i])
}
// 1 2 3
迭代辅助语句:break
和 continue
break
语句是跳出当前循环,并执行当前循环之后的语句continue
语句是终止当前循环,并继续执行下一次循环上面方式中,除了
forEach
不支持跳出循环体,其他都支持。高阶函数式方式都类似forEach
。
性能对比:
while > for > for-of > forEach > for-in
如果是编写一些库或者大量数据遍历,推荐使用 while
。有名的工具库 lodash 里面遍历全是 while
。正常操作,for-of
或者 forEach
已经完全满足需求。
下面介绍几种高级函数式,满足条件为 true
立即终止循环,否则继续遍历到整个数组完成的方法:
// ES5
[1, 2, 3].some((i) => i == 1);
// ES6
[1, 2, 3].find((i) => i == 1);
[1, 2, 3].findIndex((i) => i == 1);
其他高阶函数式方法,例如 forEach map filter reduce reduceRight every sort
等,都是把整个数组遍历。
这个功能说不是很常用,但是有时候又会用到:
二维数组:
const arr1 = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
const arr2 = [].concat.apply([], arr1);
console.log(arr2); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
三维数组:
const arr1 = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [[1, 2, 3], [4, 5, 6], [7, 8, 9]]];
const arr2 = [].concat.apply([], arr1);
console.log(arr2); // [1, 2, 3, 4, 5, 6, 7, 8, 9, [1, 2, 3], [4, 5, 6], [7, 8, 9]]
concat.apply
方式只能扁平化二维数组,在多了就需要递归操作。
function flatten(arr) {
return arr.reduce((flat, toFlatten) => {
return flat.concat(Array.isArray(toFlatten) ? flatten(toFlatten) : toFlatten);
}, []);
}
const arr1 = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [[1, 2, 3], [4, 5, 6], [7, 8, 9]]];
const arr2 = flatten(arr1);
console.log(arr2); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9]
ES6+(ES2019)
给我们提供一个 flat
方法:
const arr1 = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
const arr2 = arr1.flat();
console.log(arr2); // [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
默认只是扁平化二维数组,如果想要扁平化多维,它接受一个参数 depth
,如果想要展开无限的深度使用 Infinity
:
const arr1 = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [[1, 2, 3], [4, 5, 6], [7, 8, 9]]];
const arr2 = arr1.flat(Infinity);
console.log(arr2); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9]
还有一种面试扁平化二维数组方式:
const arr1 = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [[1, 2, 3], [4, 5, 6], [7, 8, 9]]];
const arr2 = arr1.toString().split(',').map(n => parseInt(n, 10));
console.log(arr2); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9]
如何从数组中添加元素?
我们可以使用 push
从数组末尾添加元素,使用 unshift
从开头添加元素,或者使用 splice
从中间添加元素。 concat
方法可创建带有所需项目的新数组,这是一种添加元素的更高级的方法。
从数组的末尾添加元素:
const arr = [1, 2, 3, 4, 5, 6];
arr.push(7)
console.log( arr ); // [1, 2, 3, 4, 5, 6, 7]
从数组的开头添加元素:
const arr = [1, 2, 3, 4, 5, 6];
arr.unshift(0)
console.log( arr ); // [0, 1, 2, 3, 4, 5, 6]
push
方法的工作原理与unshift
方法非常相似,方法都没有参数,都是返回数组更新的length
属性。它修改调用它的数组。
使用 splice
添加数组元素:
只需要把 splice
,第二个参数设为 0
即可,splice
在数组删除有更多的说明
const arr = [1, 2, 3, 4, 5];
arr.splice(1, 0, 10)
console.log(arr); // [1, 10, 2, 3, 4, 5]
使用 concat
添加数组元素:
const arr1 = [1, 2, 3, 4, 5];
const arr2 = arr1.concat(6);
console.log(arr2); // [1, 2, 3, 4, 5, 6]
11. 数组删除
数组允许我们对值进行分组并对其进行遍历。 我们可以通过不同的方式添加和删除数组元素。 不幸的是,没有简单的 Array.remove
方法。
那么,如何从数组中删除元素?
除了 delete
方式外,JavaScript
数组还提供了多种清除数组值的方法。
我们可以使用 pop
从数组末尾删除元素,使用 shift
从开头删除元素,或者使用 splice
从中间删除元素。 filter
方法可创建带有所需项目的新数组,这是一种删除不需要的元素的更高级的方法。
从数组的末尾删除元素:
通过将 length
属性设置为小于当前数组长度,可以从数组末尾删除数组元素。 索引大于或等于新长度的任何元素都将被删除。
const arr = [1, 2, 3, 4, 5, 6];
arr.length = 4;
console.log( arr ); // [1, 2, 3, 4]
pop
方法删除数组的最后一个元素,返回该元素,并更新length
属性。 pop
方法会修改调用它的数组,这意味着与使用 delete
不同,最后一个元素被完全删除并且数组长度减小。
const arr = [1, 2, 3, 4, 5, 6];
arr.pop();
console.log( arr ); // [1, 2, 3, 4, 5]
从数组的开头删除元素:
shift
方法的工作原理与 pop
方法非常相似,只是它删除了数组的第一个元素而不是最后一个元素。
const arr = [1, 2, 3, 4, 5, 6];
arr.shift();
console.log( arr ); // [2, 3, 4, 5, 6]
shift
和pop
方法都没有参数,都是返回已删除的元素,更新剩余元素的索引,并更新length
属性。它修改调用它的数组。如果没有元素,或者数组长度为0
,该方法返回undefined
。
使用 splice
删除数组元素:
splice
方法可用于从数组中添加、替换或删除元素。
splice
方法接收至少三个参数:
splice
可以实现添加、替换或删除。
删除:
如果 deleteCount 大于 start 之后的元素的总数,则从 start 后面的元素都将被删除(含第 start 位)。
如果 deleteCount 被省略了,或者它的值大于等于array.length - start(也就是说,如果它大于或者等于start之后的所有元素的数量),那么start之后数组的所有元素都会被删除。
如果 deleteCount 是 0 或者负数,则不移除元素。这种情况下,至少应添加一个新元素。
const arr1 = [1, 2, 3, 4, 5];
arr1.splice(1);
console.log(arr1); // [1];
const arr2 = [1, 2, 3, 4, 5];
arr2.splice(1, 2)
console.log(arr2); // [1, 4, 5]
const arr3 = [1, 2, 3, 4, 5];
arr3.splice(1, 1)
console.log(arr3); // [1,3, 4, 5]
添加:
添加只需要把 deleteCount
设置为 0,items
就是要添加的元素。
const arr = [1, 2, 3, 4, 5];
arr.splice(1, 0, 10)
console.log(arr); // [1, 10, 2, 3, 4, 5]
替换:
添加只需要把 deleteCount
设置为和 items
个数一样即可,items
就是要添加的元素。
const arr = [1, 2, 3, 4, 5];
arr.splice(1, 1, 10)
console.log(arr); // [1, 10, 3, 4, 5]
注意:
splice
方法实际上返回两个数组,即原始数组(现在缺少已删除的元素)和仅包含已删除的元素的数组。如果循环删除元素或者多个相同元素,最好使用倒序遍历
。
使用 delete
删除单个数组元素:
使用 delete
运算符不会影响 length
属性。它也不会影响后续数组元素的索引。数组变得稀疏,这是说删除的项目没有被删除而是变成 undefined
的一种奇特的方式。
const arr = [1, 2, 3, 4, 5];
delete arr[1]
console.log(arr); // [1, empty, 3, 4, 5]
实际上没有将元素从数组中删除的原因是
delete
运算符更多的是释放内存,而不是删除元素。 当不再有对该值的引用时,将释放内存。
使用数组 filter
方法删除匹配的元素:
与 splice
方法不同,filter
创建一个新数组。
filter
接收一个回调方法,回调返回 true
或 false
。返回 true
的元素被添加到新的经过筛选的数组中。
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
const filtered = arr.filter((value, index, arr) => value > 5);
console.log(filtered); // [6, 7, 8, 9]
console.log(arr); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
清除或重置数组:
最简单和最快的技术是将数组变量设置为空数组
let arr = [1,2,3];
arr = [];
清除数组的一个简单技巧是将其 length
属性设置为 0
。
let arr = [1,2,3];
arr.length = 0;
使用 splice
方法,不传递第二个参数。这将返回原始元素的一个副本,这对于我们的有些场景可能很方便。也是一种数组复制方法技巧。
let arr = [1,2,3];
arr.splice(0);
使用 while
循环,这不是一种常用清除数组的方法,但它确实有效,而且可读性强。一些性能测试也显示这是最快的技术。
const arr = [1, 2, 3, 4, 5, 6];
while (arr.length) { arr.pop(); }
console.log(arr); // []
剔除假值:
[1, false, '', NaN, 0, [], {}, '123'].filter(Boolean) // [1, [], {}, '123']
是否有一个真值:
[1, false, '', NaN, 0, [], {}, '123'].some(Boolean) // true
是否全部都是真值:
[1, false, '', NaN, 0, [], {}, '123'].every(Boolean) // false
补零:
Array(6).join('0'); // '00000' 注意:如果要补5个0,要写6,而不是5。
Array(5).fill('0').join('') // '00000'
数组最大值和最小值:
Math.max.apply(null, [1, 2, 3, 4, 5]) // 5
Math.min.apply(null, [1, 2, 3, 4, 5]) // 1
判断回文字符串:
const str1 = 'string';
const str2 = str1.split('').reverse().join('');
console.log(str1 === str2); // false
数组模拟队列:
队列先进先出:
const arr = [1];
// 入队
arr.push(2);
console.log('入队元素:', arr[arr.length -1]); // 2
// 出队
console.log('出队元素:', arr.shift()); // 1
获取数组最后一个元素:
像我们平常都是这样来获取:
const arr = [1, 2, 3, 4, 5];
console.log(arr[arr.length - 1]); // 5
感觉很麻烦,不过 ES
有了提案,未来可以通过 arr[-1]
这种方式来获取,Python
也有这种风*的操作:
目前我们可以借助 ES6
的 Proxy
对象来实现:
const arr1 = [1, 2, 3, 4, 5];
function createNegativeArrayProxy(array) {
if (!Array.isArray(array)) {
throw new TypeError('Expected an array');
}
return new Proxy(array, {
get: (target, prop, receiver) => {
prop = +prop;
return Reflect.get(target, prop < 0 ? target.length + prop : prop, receiver);;
}
})
}
const arr2 = createNegativeArrayProxy(arr1);
console.log(arr1[-1]) // undefined
console.log(arr1[-2]) // undefined
console.log(arr2[-1]) // 5
console.log(arr2[-2]) // 4
注意:这样方式虽然有趣,但是会引起性能问题,50万次循环下,在Chrome浏览器,代理数 组的执行时间大约为正常数组的50倍,在Firefox浏览器大约为20倍。在大量循环情况下,请慎用。无论是面试还是学习,你都应该掌握
Proxy
用法。
谢谢阅读,希望你喜欢我的文章。如果有疑问或者你有更好更有趣的数组方法,欢迎留言评论。
喜欢这篇文章吗?如果是的话,欢迎订阅我的 Blog
。
使用Angular CLI开发Angular应用程序是一个非常愉快的体验!Angular团队
为我们提供了惊人的CLI
工具,支持大部分开箱即用的构建项目所需要的功能。
标准化的项目结构,具有全面的测试功能(单元测试和e2e测试),代码脚手架,支持使用环境特定配置的生产级构建。这是一个强大功能的脚手架,为我们每个新项目节省了大量的时间。这里要感谢Angular团队
!
虽然Angular CLI
从一开始就非常好用,也有很多吐槽点(吐槽在最后),但是我们可以利用一些潜在的配置改进和最佳实践来使我们的项目更加完善!
安装nodejs
官网下载即可,注意:下载v8.x版本
安装 Angular CLI
npm install -g @angular/cli
注意:Windows下面安装angular-cli有两个典型的坑,一个是node-sass被墙了,第二个就是node-gyp依赖于某些API,必备需要安装:python2.7(一定要2.7)、Visual Studio(包含VB,C++等,不过有点大10g左右)
如果安装不成功可以使用以下方式:
npm i -g cnpm
cnpm i -g @angular/cli
创建新项目
ng new 项目名称
cd 项目名称 (自动去npm install 安装angular/cli提供的依赖包)
ng serve
注意:ng new my-app 失败?npm-gyp没安装,环境不行- Environment setup and configuration
我们使用Angular CLI生成了新的项目,那现在呢?我们应该继续生成我们的服务和组件到一些随机的文件夹。接下来该如何构建我们的项目?
一个好的设计方式是把我们的应用程序分成至少三个不同的模块 - 核心模块,共享模块和特性模块(尽管我们可能需要多个特性模块)【特性模块就是我们常说每个功能页面】,如果我们不想使用第三方UI组件库,那我们还需要一个UI模块来修饰。
在Angular官网模块部分也专门指出用核心模块,只在应用启动时导入它一次,而不会在其它地方导入它。
在所有每个应用程序(单例服务)上必须有且仅有一个实例的服务应该在这里实现。典型的例子可以是认证服务或用户服务。我们来看一个CoreModule实现的例子。
core.module.ts
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
/* 我们自己的定制全局服务 */
import { UserService } from './user/user.service';
@NgModule({
imports: [
HttpClientModule
],
providers: [
/* 我们自己的定制全局服务 */
SomeSingletonService
]
})
export class CoreModule {
/* 确保CoreModule只能在AppModule导入 */
constructor (
@Optional() @SkipSelf() parentModule: CoreModule
) {
if (parentModule) {
throw new Error('CoreModule is already loaded. Import only in AppModule');
}
}
}
在Angular官网模块部分也专门指出用共享模块,只在特性模块里导入它一次,而不需要再去导入其他Angular核心模块和第三方模块,我们自定义组件,指令,管道。
所有的应用组件,指令和管道应该在这里管理。这些组件不会在其构造函数中从核心或其他功能导入和注入服务。他们应该通过使用它们的组件模板中的属性来接收所有的数据。这一切都归结于SharedModule对我们的应用程序的其余部分没有任何依赖性的事实。这也是导入和导出UI组件,业务通用组件的理想场所。
shared.module.ts
/* Angular核心模块 */
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
/* 第三方组件 */
import { MdButtonModule } from '@angular/material';
/* our own custom components */
import { SomeCustomComponent } from './some-custom/some-custom.component';
@NgModule({
imports: [
/* Angular核心模块 */
CommonModule,
FormsModule,
/* 第三方组件 */
MdButtonModule,
],
declarations: [
ListComponent
],
exports: [
/* Angular核心模块 */
CommonModule,
FormsModule,
/* 第三方组件 */
MdButtonModule,
/* 自定义组件 */
ListComponent
]
})
export class SharedModule { }
Module属性 | CoreModule | SharedModule |
---|---|---|
imports | 必须 | 必须 |
providers | 必须 | 禁止 |
declarations | 禁止 | 必须 |
exports | 禁止 | 必须 |
总结:CoreModule 只有导入没有导出,SharedModule有导入导出,却没有服务依赖注入管理,这个需要在 CoreModule 里面操作。
我们可以在创建新项目后立即生成Core和Shared模块。这样,我们将准备从一开始就生成额外的组件和服务。
运行ng generate module core
可以生成模块核心。具体规则可以看这里ng generate
然后在core
文件夹中创建index.ts
文件,并重新导出CoreModule本身。
代码:export * from './core.module';
在进一步开发的过程中,我们会再出口更多的公共服务,这些服务应该在index.ts
提供。
core/index.ts
export * from './user/user.service';
export * from './core.module';
如何访问:
app.module.ts
import { CoreModule } from './core';
好处我不需要关心里面的CoreModule 所在文件位置,只需要关心我对于的导出依赖名称即可。
同样,我们可以为共享模块做同样的事情。
在Angular官网模块部分也专门指出用特性模块,特性模块是带有@NgModule装饰器及其元数据的类,就像根模块一样。 特性模块的元数据和根模块的元数据的属性是一样的。根模块和特性模块还共享着相同的执行环境。 它们共享着同一个依赖注入器,这意味着某个模块中定义的服务在所有模块中也都能用。
它们在技术上有两个显著的不同点:
将为我们的应用程序的每个独立功能创建多个功能模块。功能模块应该只从CoreModule导入服务。
如果功能模块A需要从功能模块B导入服务,则考虑将该服务移入CoreModule。
在某些情况下,需要仅由某些功能共享的服务,将它们转移到核心并不合理。在这种情况下,我们可以创建特殊的共享功能模块,不依赖于CoreModule提供的服务和SharedModule提供的组件的任何其他功能的功能。
这将保持我们的代码清洁,易于维护和扩展的新功能。这也减少了重构所需的工作量。如果正确执行,我们将确信更改一个功能不会影响或破坏我们的应用程序的其余部分。
懒加载需要配合路由完成. 可以看伪代码
app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
{
path: 'user',
loadChildren: 'app/user/user.module#UserModule'
},
{
path: '**',
redirectTo: 'user'
}
];
@NgModule({
// useHash支持带#号的url地址,
imports: [RouterModule.forRoot(routes, { useHash: true })],
exports: [RouterModule]
})
export class AppRoutingModule {}
我们应该尽可能延迟加载我们的功能模块。理论上,应用程序启动期间只能同步加载一个功能模块以显示初始内容。在用户触发导航之后,每个其他功能模块应该被延迟加载。
附上一张自己YY的应用架构图
别名我们的应用程序和环境文件夹将使我们能够实施干净的入口,这将在整个应用程序中保持一致。
考虑假设的,但通常的情况。我们正在研究一个功能A中位于三个文件夹深处的组件,并且我们要从位于两个文件夹深处的核心导入服务。这将导致导入语句看起来像import {SomeService} from '../../../core/user/user-settings/user-settings.service'这样。
是不是很不爽。。。
更不爽的是,任何时候我们想改变这两个文件中的任何一个的位置,我们的导入语句就会中断,需要重新去改引入url地址,有些编辑器可以帮我们重构这个问题,例如:Webstorm。
曾经看过vue-cli,在它里面构建里面路径是‘@/xxx’ 如果没有记错,(ps:因为快一年没有使用了)。应该是这样的,@代表src,这个是webpack里面设置的别名功能。
在Angular-cli去改Webpack配置是一个很麻烦的事情,我们可以修改tsconfig.json
配置。
为了能够使用别名,我们必须添加baseUrl
和paths
属性到我们的tsconfig.json文件中,像这样...
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@app/*": ["app/*"],
"@env/*": ["environments/*"]
}
}
}
注意:如果报错找不到@app/xxxx模块,需要把 "baseUrl": "src"
换成"baseUrl": "./src"
。
我们还添加了@env别名,以便能够使用
import { environment } from "@env/environment"
从我们的应用程序的任何位置轻松访问环境变量。它将适用于所有指定的环境,因为它会根据传递给ng build命令的--env标志自动解析正确的环境文件。
随着我们的路径,我们现在可以导入像这样的环境和服务...
user.module.ts
/* Angular核心模块 */
import { NgModule } from '@angular/core';
/* 共享模块 */
import { SharedModule } from '@app/shared';
import { environment } from '@env/environment';
@NgModule({
imports: [
/* 共享模块 */
SharedModule
],
providers: [
{
provide: 'USER_API', useValue: environment.production ? '/api/test':'/api'
}
]
})
export class UserModule { }
您可能已经注意到我们直接从@app/core
而不是@app/core/user/user .service
导入实体(如上例中的UserService)。这是可能的,这要归功于重新导出主index.ts
文件中的每个公共实体。我们创建一个index.ts文件每个包(文件夹),他们看起来像这样...
export * from './core.module';
export * from './user/user.service';
在大多数应用程序中,特定功能模块的组件和服务通常只需要访问来自CoreModule的服务和来自SharedModule的组件即可。有时这可能不足以解决特定的业务案例,我们还需要某种“共享功能模块”,为其他功能模块的有限子集提供功能。
在这种情况下,我们将最终得到来自import { FeatureService } from '@app/shared-feature';
因此与核心类似,也使用@app别名访问共享特征。
Sass是一个样式预处理器,它支持像变量这样的强大的东西(即使css也会很快得到变量),函数,mixins 等,你把它命名为...
Sass还需要有效地使用官方Angular Material Components库以及其广泛的主题功能。假设使用Sass是大多数项目的默认选择是安全的。
要使用Sass,我们必须用--style scss标志来使用Angular CLI生成我们的项目,或者在defaults和styleExt来设置。默认情况下,没有添加的是stylePreprocessorOptions和includePaths,我们可以使用强制性的根“./”和可选的“./themes”值来设置。
angular-cli.json
{
"apps": [
{
...
"stylePreprocessorOptions": {
"includePaths": ["./", "./themes"]
}
"defaults": {
"styleExt": "scss"
}
}
]
}
常用的UI组件:
想要快速熟悉Angular,推荐自己撸一套UI组件库。
Angular CLI生成的项目只带有一个非常简单的ng build脚本。要生成生产级的工件,我们必须自己做一些定制。
我们在package.json脚本中添加“build:prod”:“ng build --target production --build-optimizer --vendor-chunk”
。
这是一个设置开关,它使代码缩小和默认情况下很多有用的构建标志。这相当于使用以下...
--environment prod
使用environment.prod.ts
文件的环境变量--aot
启用前期编译。这是目前版本的Angular CLI的默认设置。如果你使用低版本,必须手动启用它--extract-css true
将所有的CSS提取到独立的样式表文件中--sourcemaps false
禁用压缩文件对应map的生成--named-chunks false
禁用使用人类可读名称的块和使用数字--build-optimizer
新功能,导致更小的捆绑,但更长的构建时间,所以谨慎使用!(也应该在未来默认启用)--vendor-chunk
将所有第三方依赖(库)代码提取到单独的块中另外检查其他可用的配置标志官方文档,这可能在您的个人项目中有用。
PhantomJS是一个非常知名的headless browser(ps: 无头浏览器 很恐怖),它实际上是用于在CI服务器和许多开发机器上运行前端测试的解决方案。
虽然还算不错,但对现代ECMAScript功能的支持还是比较滞后的。更为严重的是,这些非标准的行为在本地没有问题地通过测试的时候就曾多次引起头痛,但是仍然打破了CI的环境。
幸运的是,我们不必再处理它了!
正如官方文件所说...
Headless Chrome正在使用Chrome 59.这是在无头环境下运行Chrome浏览器的一种方式。本质上,在没有Chrome的情况下运行Chrome!它将Chromium和Blink渲染引擎提供的所有现代Web平台功能带到命令行。
那么我们如何在Angular CLI项目中使用它?
我们在项目的package.json
添加如下代码...
package.json
"scripts": {
"test": "npm run lint && ng test --single-run",
"watch": "ng test --browsers ChromeHeadless --reporters spec",
},
可以看这里我的这篇文章GET新技能之Git commit message。
快速总结一下我们感兴趣的项目的新功能和缺陷修复是非常好的。
让我们为用户提供相同的便利!
手动编写更改CHANGELOG.md
将是极其繁琐的容易出错的任务,因此最好是自动执行该过程。
有很多可用的工具Conventional Commits specification可以完成这项工作,但我们只关注标准版本。
常规提交定义了强制类型,可选(范围):后跟提交消息。也可以添加可选的正文和页脚,两者都用空行分隔。通过查看angular-cli的完整提交消息的示例,我们来看看在实践中该如何实现。
feat(@angular/cli): move angular-cli to @angular/cli (#4328)
由于BREAKING CHANGE关键字在提交主体中的存在,标准版本将正确地冲击项目的MAJOR版本。
生成的CHANGELOG.md将会看起来像这样...
v1.6.0-beta.2
Bug Fixes
@ngtools/webpack: fix elide removing whole imports on single match (62f3454), closes #8518
v1.5.2
Bug Fixes
@ngtools/webpack: fix elide removing whole imports on single match (62f3454), closes #8518
看起来是不是很酷呀!那么我们怎样才能在我们的项目中使用这个?
我们首先安装npm install -D standard-version
将其保存在我们的devDependencies中,并将“release”:“standard-version”
添加到我们的package.json
scripts中。
package.json
"scripts": {
"release": "standard-version"
},
我们还可以添加git push
和npm publish
来自动完成整个过程。
在这种情况下,我们需要改进脚本为下面例子
package.json
"scripts": {
"release": "standard-version && git push --follow-tags origin master && npm publish"
},
注意:我们使用
&&
并将依赖于平台的命令链在基于Unix的系统上(因此也在Windows上使用Cygwin、Gitbash或Linux的新Win10子系统)。
目前开发都是前后分离式的开发,前端使用CLI启动服务端口一般都是4200,后台也有API端的,nodejs一般常用都是3000,如果直接去请求3000端口的数据就好出现跨域请求。
简单理解跨域:不同的协议(http|https),不同的IP,不同的端口
本地开发ip都一样,不一样的是端口号,这样跨域浏览器会有
这样的报错信息,那么我们需要用代理,代理方式有很多。这里不介绍其他就说CLI里面如何配置的。
proxy.conf.json
,这个文件名字不需要固定(我为了适应不同场景,还是做不同代理配置,proxy-dev.conf.json,proxy-test.conf.json,,因为后台很多,有时候需要直接去联调他们电脑服务)假设:跨域请求地址是 http://localhost:8000/api/user/123
{
"/api": { // 这个是必须的,相当一个标识 target地址下一级文件夹目录 上面跨域请求是api 那么这里就是`/api`
"target": "http://127.0.0.1:8000", // 你需要代理的地址 注意:只需要ip和端口号就好了
"secure": false, // 安全,自己联调,可以关了
"changeOrigin": true, // 如果不是代理本机需要设为true,不然可以不设置
"logLevel": "debug" // 这是调试,如果代理成功,命令行会出现每次请求的地址
}
}
Angular里面使用 (默认服务是http://localhost:4200)
this.http.get('http://localhost:4200/api/user/123')
package.json
文件里的scripts
也需要配置代理之前的:
"serve": "ng serve --open"
代理之后的:
"serve": "ng serve --proxy-config proxy.conf.json --open"
npm start
或者npm run serve
就可以运行代理了注意:
/api
一定要有,不然就会报错,鉴于这样的情况我写了一个本地代理,来转发。遇到神一样队友,没有办法。
附上nodejs转发API源码:
新建一个dev.js
// 获取依赖包
const express = require('express');
const bodyParser = require('body-parser');
const superagent = require('superagent');
const path = require('path');
// 实例化express
const app = express();
// 解析json
app.use(bodyParser.urlencoded({
extended: false
}));
app.use(bodyParser.json());
/**
* 获取代理地址
*/
// 连接代理API地址 默认 测试代理地址
const baseUrl = `http://xxx.xxx.xxx.xxx:4000/`;
// 错误处理默认返回
const error = {
"code": "0003",
"data": null,
"field": null,
"msg": null
};
// 代理请求处理
app.post(`/api/*`, (req, res, next) => { // 我的神队友只有一个请求方式,请求方式参考`express` 路由
superagent
.post(baseUrl + req.params[0])
.send(req.body)
.set('Accept', 'application/json')
.end(function(err, results) {
// 如果出错就直接返回默认错误json
if (err) {
console.log('superagent error:------------------------------------')
console.log(JSON.stringify(err))
return res.json(error);
}
// 因为JSON.parse解析非法json会抛出异常,需要用try catch来捕获,如果出错了就直接跑错返回
try {
const data = JSON.parse(results.text);
return res.json(data);
} catch (error) {
console.log('results error:------------------------------------')
console.log(JSON.stringify(err))
return res.json(error);
}
});
});
app.use(function(err, req, res, next) {
console.error(err.stack);
res.send(500, 'Something broke!');
});
app.listen(8000, () => console.log('Express server listening on http://localhost:8000 proxy url ', baseUrl));
解释:
CLI默认带express
相关的包,自己去下载一个superagent
请求包,还有一个并行处理npm命令的包concurrently
这里可以修改一下脚本命令:
"start": "concurrently \"npm run serve:dev\" \"npm run serve\"",
"serve": "ng serve --proxy-config proxy.conf.json --open",
"serve:dev": "node dev.js dev",
就可以一个命令来控制。
git commit message书写规范提示模板
ng4/5非常不错简写提示插件
Prefix | Description |
---|---|
ng- | Angular Snippets |
fx- | Angular Flex Layout Snippets |
ngrx- | Angular NgRx Snippets |
m- | Angular Material Design Snippets |
md- | Angular Material Design Snippets for all versions before 2.0.0-beta.11 |
rx- | RxJS Snippets for both TypeScript and JavaScript |
如果只想做简单演示,模拟API请求,熟悉HTTP请求,又不想起一个本地后台服务器或者模拟mack服务器,那怎么办?
.angular-cli.json
"assets": [
"api",
"assets",
"favicon.ico"
],
src
文件夹下创建一个api
文件夹,你需要本地数据都可以丢里面。为什么是src,因为脚手架里面配置有一句
"root": "src"
,表示根路径。
test.json
,写点假数据吧,那我们怎么去请求了?constructor(private http: HttpClient) { }
getTest() {
this.http.get('/api/test.json').subscribe((data) => {
console.log(data);
});
}
注意:/api就是前面创建的src/api
文件夹,因为src
是根目录,所以我们只需要/api
即可
Observable是Rxjs核心
Observable关联2个设计模式: 观察者模式(Observer Pattern)和迭代器模式(Iterator Pattern)。
一句话描述:观察者模式是如何在(事件(event)跟监听者(listener)或者发布者(Publisher)跟订阅者(Subscriber))的互动中做到去解耦。也叫发布订阅模式
实现一个观察者模式
function Producer() {
// 这个 if 只是避免使用者不小心把 Producer 当作函式来调用
if(!(this instanceof Producer)) {
throw new Error('请用 new Producer()!');
// 仿 ES6 行为: throw new Error('Class constructor Producer cannot be invoked without 'new'')
}
this.listeners = [];
}
// 加入监听的方法
Producer.prototype.addListener = function(listener) {
if(typeof listener === 'function') {
this.listeners.push(listener)
} else {
throw new Error('listener 必须是 function')
}
}
// 移除监听的方法
Producer.prototype.removeListener = function(listener) {
this.listeners.splice(this.listeners.indexOf(listener), 1)
}
// 发送通知的方法
Producer.prototype.notify = function(message) {
this.listeners.forEach(listener => {
listener(message);
})
}
var egghead = new Producer();
// new 出一个 Producer 实例叫 egghead
function listener1(message) {
console.log(message + 'from listener1');
}
function listener2(message) {
console.log(message + 'from listener2');
}
egghead.addListener(listener1); // 注册监听
egghead.addListener(listener2);
egghead.notify('A new course!!') // 当某件事情方法时,执行
一句话描述:Iterator是一个迭代器,它的就像是一个指针(pointer),指向一个数据结构并产生一个数组,这个数组会保存数据结构中的所有元素。
实现一个迭代器模式
function IteratorFromArray(arr) {
if(!(this instanceof IteratorFromArray)) {
throw new Error('请用 new IteratorFromArray()!');
}
this._array = arr;
this._cursor = 0;
}
IteratorFromArray.prototype.next = function() {
return this._cursor < this._array.length ?
{ value: this._array[this._cursor++], done: false } :
{ done: true };
}
var iterator = new IteratorFromArray([1,2,3]);
iterator.next();
// { value: 1, done: false }
iterator.next();
// { value: 2, done: false }
iterator.next();
// { value: 3, done: false }
iterator.next();
// { done: ture }
迭代器模式
虽然很简单,但同时带来了两个优势,第一它渐进式取得数据的特性可以拿来做延迟运算(Lazy evaluation),让我们能用它来处理大数据结构。第二因为迭代器是数组,所以可以使用所有数组的运算方法像map, filter... 等!
延迟运算是一种运算策略,简单来说我们延迟一个表达式的运算时机直到真正需要它的值在做运算。迭代器模式并没有马上运算,必须等到我们执行next()时,才会真的做运算。
观察者模式
跟迭代器模式
有个共通的特性,就是他们都是渐进式(progressive)的取得数据,差别只在于观察者模式
是生产者(Producer)推送数据(push ),而迭代器模式
是消费者(Consumer)获取数据(pull)!
Observable其实就是这两个模式**的结合,Observable具备生产者推送数据的特性,同时能像序列,拥有数组处理数据的方法(map, filter...)!
Observable 同时可以处理同步与非同步的行为!
Observable
可以被订阅(subscribe),或说可以被观察,而订阅Observable
的对象又称为观察者(Observer)。观察者是一个具有三个方法(method)的对象,每当Observable
发生事件时,便会执行观察者相对应的方法。
观察者的三个方法(method):
next:每当Observable
发送出新的值,next
方法就会执行。
complete:在Observable
没有其他的数据可以取得时,complete
方法就执行,在complete
被调用之后,next
方法就不会再起作用。
error:每当Observable
内发生错误时,error
方法就会执行。
complete方法
var observable = Rx.Observable
.create(function(observer) {
observer.next('Jerry');
observer.next('Anna');
observer.complete();
observer.next('not work');
})
// 一个观察者,具备 next, error, complete 三个方法
var observer = {
next: function(value) {
console.log(value);
},
error: function(error) {
console.log(error)
},
complete: function() {
console.log('complete')
}
}
// 用我们定义好的观察者,来订阅这个 observable
observable.subscribe(observer)
// console输出
// Jerry
// Anna
// complete
error方法
var observable = Rx.Observable
.create(function(observer) {
try {
observer.next('Jerry');
observer.next('Anna');
throw 'some exception';
} catch(e) {
observer.error(e)
}
});
// 一个观察者,具备 next, error, complete 三个方法
var observer = {
next: function(value) {
console.log(value);
},
error: function(error) {
console.log('Error: ', error)
},
complete: function() {
console.log('complete')
}
}
// 用我们定义好的观察者,来订阅这个 observable
observable.subscribe(observer)
// console输出
// Jerry
// Anna
// Error: some exception
我们也可以直接把next, error, complete三个function依序传入observable.subscribe中,
observable.subscribe(
value => { console.log(value); },
error => { console.log('Error: ', error); },
() => { console.log('complete') }
)
observable.subscribe会在内部自动组成observer对象来操作。
Observable
可以同时处理同步跟非同步行为
Observer
是一个对象,这个对象具有三个方法,分别是next , error , complete
订阅一个Observable
就像是执行一个function
create
of
from
fromEvent
fromEventPattern
fromPromise
never
empty
throw
interval
timer
create将subscribe函数转换为实际的Observable。 这相当于调用Observable构造函数。 编写subscribe函数,使其作为一个Observable:它应该调用订阅者的next,error和complate方法,遵循Observable约束;良好的Observable必须调用Subscriber的complate方法一次或其error方法一次,然后再不会调用之后的next。
var source = Rx.Observable
.create(function(observer) {
observer.next('Jerry');
observer.next('Anna');
observer.complete();
});
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});
// Jerry
// Anna
// complete!
大多数时候,您不需要使用create,因为现有的创建操作符(以及实例组合运算符)允许您为大多数用例创建一个Observable。 但是,create是低级的,并且能够创建任何Observable。
of用于创建一个简单的Observable,只发出给定的参数,然后发出完整的通知。 它可以用于与其他Observable组合,如concat。
var source = Rx.Observable.of('Jerry', 'Anna');
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});
// Jerry
// Anna
// complete!
默认情况下,它使用一个空的调度程序,这意味着next通知是同步发送,虽然使用不同的调度程序,可以确定这些通知何时将被交付。
from将一个数组、类数组(字符串也可以),Promise、可迭代对象,类可观察对象、转化为一个Observable, 可将几乎所有的东西转化一个可观察对象。
var arr = ['Jerry', 'Anna', 2016, 2017, '30 days']
var source = Rx.Observable.from(arr);
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});
// Jerry
// Anna
// 2016
// 2017
// 30 days
// complete!
如果我们传入Promise对象实例,当正常返回时,就会执行next,并立即完成,如果有错误则会执行error。也可以用fromPromise,会有相同的结果。
注意:
of
和from
接受的参数有些不同,of
接受数组形式的参数,但返回还是一个数组。from
只能接收一个参数,如果多个参数就会走error。
fromEvent将一个DOM元素上的事件转化为一个Observable
var source = Rx.Observable.fromEvent(document.body, 'click');
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});
fromEvent的第一个参数要传入DOM 对象,第二个参数传入要监听的事件名称。next的参数返回就是第一个事件Event对象。
要用Event来建立Observable实例还有另一个方法fromEventPattern,这个方法是给自定义事件使用。所谓的自定义事件就是指其行为跟事件相像,同时具有注册监听及移除监听两种行为,就像DOM Event有addEventListener及removeEventListener一样!
function addClickHandler(handler) {
document.addEventListener('click', handler);
}
function removeClickHandler(handler) {
document.removeEventListener('click', handler);
}
var clicks = Rx.Observable.fromEventPattern(
addClickHandler,
removeClickHandler
);
clicks.subscribe(event => console.log(event));
fromPromise转化一个Promise为一个Obseervable
var promise = new Promise(function (resolve, reject) {
resolve(42);
});
var source1 = Rx.Observable.fromPromise(promise);
var subscription1 = source1.subscribe(
function (x) {
console.log('Next: ' + x);
},
function (err) {
console.log('Error: ' + err);
},
function () {
console.log('Completed');
});
// => Next: 42
// => Completed
将ES2015 Promise转换为Observable。 如果Promise为成功状态,则Observable会将成功的值作为next发出,然后complate。 如果Promise被失败,则输出Observable发出相应的错误。
never会给我们一个无穷的observable,如果我们订阅它又会发生什么事呢?...什么事都不会发生,它就是一个一直存在但却什么都不做的observable。
var source = Rx.Observable.never();
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});
这个静态操作符对需要创建一个不发射next值、error错误、也不发射complate的简单Observable很有用。 它可以用于测试或与其他Observable组合。 请不要说,从不发出一个完整的通知,这个Observable保持订阅不被自动处置。 订阅需要手动处理。
empty会给我们一个空的observable,如果我们订阅这个observable会发生什么事呢?它会立即送出complete的消息!
var source = Rx.Observable.empty();
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log(error)
}
});
// complete!
可以直接把empty想成没有做任何事,但它至少会告诉你它没做任何事。该操作符创建一个仅发射‘complete’的通知。通常用于和其他操作符一起组合使用。
throw创建一个只发出error通知的Observable。
var source = Rx.Observable.throw('Oop!');
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log('Throw Error: ' + error)
}
});
// Throw Error: Oop!
throw操作符对于创建一个只发出error通知的Observable非常有用。它可以用于与其他Observable合并,如在mergeMap中。
interval返回一个以周期性的、递增的方式发射值的Observable, 类似在JS中我们可以用setInterval来建立一个持续的行为一样
var source = Rx.Observable.interval(1000);
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log('Throw Error: ' + error)
}
});
interval返回一个Observable,它发出一个递增的无限整数序列。第一个参数为时间间隔。 需要注意的是,第一发射不立即发送,而是在第一个周期过去之后发送。 第二个参数,默认情况下,interval使用异步调度程序提供时间概念,但可以将任何调度程序传递给它。
类似于interval,但是第一个参数用来设置发射第一个值得延迟时间
var source = Rx.Observable.interval(1000);
source.subscribe({
next: function(value) {
console.log(value)
},
complete: function() {
console.log('complete!');
},
error: function(error) {
console.log('Throw Error: ' + error)
}
});
timer返回一个Observable,发出一个无限的上升整数序列,第二个参数为时间的间隔。 第一次发射发生在指定的延迟之后。 初始延迟可以是日期。 默认情况下,此运算符使用异步Schuduler提供时间概念,但可以将任何schuduler传递给它。 如果未指定period,则输出Observable仅发出一个值0,否则将发出无限序列。
如果只传递第一个参数,类似在JS中我们可以用setTimeout来建立一个持续的行为一样
什么是订阅?订阅是一个表示一次性资源的对象,通常是一个可观察对象的执行。订阅对象有一个重要的方法:unsubscribe,该方法不需要参数,仅仅去废弃掉可观察对象所持有的资源。在以往的RxJS的版本中,"Subscription订阅"被称为"Disposable"。
var observable = Rx.Observable.interval(1000);
var subscription = observable.subscribe(x => console.log(x));
// Later:
// This cancels the ongoing Observable execution which
// was started by calling subscribe with an Observer.
subscription.unsubscribe();
订阅对象有一个unsubscribe()方法用来释放资源或者取消可观察对象的执行
程序员是一个千变万化但是又不离其中的职业,能够实现各种各样的功能,实现的方法也是各种各样,而最佳实践又是很多程序员比较认可和遵守的一些规则,规范可能并不会带来直接的利好,但是随着工程的扩大,团队多人协作,这些良好的习惯可能会带来很好的优势,Eslint是目前最受欢迎前端js规范风格检查工具之一。只从用了它,我就爱上了它。
我总是在思考一个问题,怎么规范git提交消息的格式,怎么去做一个提交版本日志。
以前没有怎么注意Angular的github那些commit message信息,因为英文渣渣,反正也看不懂写啥。经常需要提交Git commit message,每次commit message很头疼,经常只是简单描述一下,或者直接偷懒写当前时间了,回去看自己commit message,真是不知道在说什么。一直想找一个规范来规矩一下自己,发现Angular
的Git commit message很特别,它是这样的,举例:
build: switch from npm to yarn (#19328)
docs: add 'bazel' as an Angular component (#19346)
refactor(compiler): bump metadata version to 4 (#19338)
feat(tooling): Add a .clang-format for automated JavaScript formatting.
我当时也看不懂,大概以为就是这样的格式功能:描述(#xxx【不知道说啥的】)
;
然后我就根据这个猜想写了一个自己草稿规范:
add: 提交
update:更新
remove:移动
delete:删除
feature: 功能
change:修改
fix:修复bug
大概就这几个,每次提交都是这样结构add 添加一个新文件
,好像没有毛病。
直到国庆在家没事做,点到Angular
的github的CONTRIBUTING.md,才发现我好方。
在一家公司开始只有我一个做前端开发,后来陆续有了其他多人,这就是所谓开发团队了。一个人开发做事可以随心所欲,啥都可以无所谓,但是遇到一起合作的团队了,就需要正视一些问题,比如代码规范,编码风格,命名规范,注释说明等等,都不是一个人那么随便了。因为你的一举一动,会影响你的同学开发效率。你的同学一举一动,会影响你的开发效率。一段莫名其妙的代码让程序挂了,一段没有注释代码,你看着懵逼。vscode一个不错的编辑器,里面有个插件【Git Lens】很神一样存在,你可以看清楚是哪个二货提交这段垃圾代码,哪个大牛写这段神奇的代码。
那么提交一个标准消息的格式目的是什么:
每个提交消息由title(标题),body(正文)和footer(页脚)组成。标题具有特殊格式,包括type(类型),scope(范围)和subject(主题):
<type>(<scope>): <subject>
// 空一行
<body>
// 空一行
<footer>
标题是必需的,标题的范围是可选的。
任何一行的提交信息都不能超过100
个字符!这样可以让消息在GitHub以及各种git工具中更容易阅读。
必须是以下之一:
范围应该是受影响的npm包的名称(由人们阅读从提交消息生成的更改日志所感知的范围。
以下是支持的范围的列表:(这里也指业务模块)
主题包含对变更的简明描述:
常用表述语:
页脚应包含对问题的结束引用(如果有)。请参照以下格式:
Example1:
关闭`issue`编号`123`问题
Closes #123
Example2:
关闭并修理`issue`编号`45`问题
Fixes #45
Example3:
解决`issue`编号`55`bug
Resolves #55
还有一种特殊情况,如果当前 commit 用于撤销以前的 commit,则必须以 revert: 开头,后面跟着被撤销 Commit 的 Header。
revert: feat(pencil): add 'graphiteWidth' option
This reverts commit 667ecc1654a317a13331b17617d973392f415f02.
Body 部分的格式是固定的,必须写成 `This reverts commit <hash>.`,其中的 `hash` 是被撤销 commit 的 SHA 标识符。
如果当前 commit 与被撤销的 commit,在同一个发布(release)里面,那么它们都不会出现在 Change log 里面。如果两者在不同的发布,那么当前 commit,会出现在 Change log 的 `Reverts` 小标题下面。
标题:描述主要变更内容(建议50个字符以内)。
主体内容:更详细的说明文本(建议72个字符以内)。
需要描述的信息包括:
为什么这个变更是必须的? 它可能是用来修复一个bug,增加一个feature,提升性能、可靠性、稳定性等等
他如何解决这个问题? 具体描述解决问题的步骤
是否存在副作用、风险?
尾部:如果需要的化可以添加一个链接到issue地址或者其它文档,或者关闭某个issue。
Example:
feat(user): add 用户搜索
用户反馈,不能搜索用户。增加搜索功能,可以让用户快速定位某一个用户进行关注或其他后续操作
resolves #55
主体内容和尾部是可选的,标题的范围也是可选的,其他必填的
基本原则:master为保护分支,不直接在master上进行代码修改和提交。
开发日常需求或者项目时,从master分支上checkout一个feature分支进行开发或者bugfix分支进行bug修复,功能测试完毕并且项目发布上线后,将feature分支合并到主干master,并且打Tag发布,最后删除开发分支。分支命名规范:
Tag包括3位版本,前缀使用v。比如v1.2.31。Tag命名规范:
- v2.0.0-alpha-1
- v2.0.0-belta-2
软件的生命周期中一般分4个版本:
版本正式发布前需要生成changelog文档,然后再发布上线。
npm install -D conventional-changelog-cli
or
npm install -g conventional-changelog-cli
这不会覆盖任何以前的更改日志。以上生成基于自上一个符合“特征”,“修复”,“性能改进”或“突破更改”的模式的最后一个标记之后的提交的更改日志。
如果你第一次使用这个工具,想要生成所有以前的更改日志,你可以做
conventional-changelog -p angular -i CHANGELOG.md -s -r 0
也可以添加到package.json
的scripts
:
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"
然后
npm run changelog
随着nodejs流行,npm伴随nodejs逐步成长起来,安装nodejs以后就自动安装了npm。
npm 是 nodejs 的包管理和分发工具。它 可以让 javascript 开发者能够更加轻松的共享代码和共用代码片段,并且通过 npm 管理你分享的代码也很方便快捷和简单。
npm install npm -g
npm -v
npm 提供了两种包的安装形式:局部安装和全局安装。你可以通过你的项目使用情况选择如何安装。如果你的项目依赖于某个包,那么建议将改包安装到局部。其他其他条件下,比如你要在命令行工具中使用这个包,可选择在全局安装。
全局安装示例:
npm install @angular/cli -g
一般这样安装都是以工具,命令行形存在
这样使用
ng new demo
这个ng就是@angular/cli提供的一个命令行命令
全局安装的包默认都存在C:\Users\Administrator\AppData\Roaming\npm下,这是windows版的,其他版本不懂。
局部安装示例:
dependencies
npm install jquery --save
devDependencies
npm install lodash --save-dev
这是两种局部包安装
一个node package常用有两种依赖,一种是dependencies一种是devDependencies,其中前者依赖的项该是正常运行该包时所需要的依赖项,而后者则是开发的时候需要的依赖项。
上面说有2种的形式很官方,简单说一下区别,dependencies是开发和生产运行需要的包依赖,例如前端jqeury, angular,lodash,Bootstrap等这样的开发需要的库和框架,devDependencies是一些开发辅助包,例如前端的webpack,gulp,bower,等这样的工具。
--save是自动帮你安装包添加依赖关系到 package.json的dependencies下
--save-dev是自动帮你安装包添加依赖关系到 package.json的devDependencies下
除了上面2个比较常用的外,还可以简写:
简写 | 命令 | 说明 |
---|---|---|
-S | --save | 添加依赖关系到dependencies下 |
-D | --save-dev | 添加依赖关系到devDependencies下 |
-O | --save-optional | 添加依赖关系到optionalDependencies下 |
optionalDependencies一般用的不多。可选的依赖
npm install jquery --save
npm install jquery@latest --save
npm install [email protected] --save
npm install jquery@">=2.0.0 <2.2.0" --save
因为有些包没有添加到npm里面,但在github上面,我们可以使用以下方式来安装
指定安装某个版本
npm install git+ssh://[email protected]:npm/npm.git#v1.0.27
直接安装
npm install git+https://[email protected]/npm/npm.git
指定安装某个版本
npm install git://github.com/npm/npm.git#v1.0.27
删除全局包
npm uninstall -g @angular/cli
删除局部包
npm uninstall jquery
怎么安装就怎么删除,不用跟版本号。
上面说了怎么安装删除,现在说一个npm管理依赖的文件package.json。
创建一个文件夹,在当前文件夹里打开命令行,输入npm init
然后就会出现以下一些提示要你填写:
name:填写包的名字,默认是你这个文件夹的名字。
如果你这个东西将来要做成一个npm包发布(后面说怎么发布一个npm包),就需要注意了。你需要去npm上找一下,有没有同名的包,npm search 包名,如果没有,恭喜你可以注册。如果存在,那你只能自己改名了。没办法,先到先得。
version:包的版本,默认是1.0.0
description:用一句话描述你的包是干嘛用的,随便写点啥也可以不写直接回车了
entry point:入口文件,默认是index.js,就是引入这个包就可以运行的。
index.js
需要些一行这个代码
module.exports=require('./lib') 这个就是你需要用包地址
test command:测试命令。一般都用不上跳过了
git repository:这个是git仓库地址,如果你的包是先放到github上或者其他git仓库里,这时候你的文件夹里面会存在一个隐藏的.git目录,npm会读到这个目录作为这一项的默认值。如果没有的话,直接回车继续。
keyword:这个是一个重点,这个关系到有多少人会搜到你的npm包。尽量使用贴切的关键字作为这个包的索引。里面是一个字符串数组
author:作者
license:开源协议
然后它就会问你Are you ok?
回车就好了。
然后你当前文件都会生成一个package.json文件。
然后我们就可以安装依赖了,参照上面npm依赖安装
engines:依赖node和npm版本
"engines": {
"node": ">= 6.9.0",
"npm": ">= 3.0.0"
}
如果小于这个版本就好抛错。
做开源的东西,尽量加上这个,node更新很快,每个版本功能都有些差异。
scripts:npm运行命令
默认生成的
"scripts": {
test: “echo \”Error: no test specified\" && exit 1"
}
我们可以在命令行运行npm run test
接着就会输出Error: no test specified
目前比较火的webpack都是使用npm来管理命令行,然后运行npm run xxx
在你要发布的项目文件夹里面打开命令输入npm publish
如果版本未修改发布会报错,你需要去修改一下version。
每次修改都需要npm publish,记得修改version
有发布就有删除,删除好像有个限制,如果大于24小时,需要联系npm管理员删除。
npm unpublish 包名
就完了
npm install 安装模块
npm uninstall 卸载模块
npm update 更新模块
npm outdated 检查模块是否已经过时
npm ls 查看安装的模块
npm init 在项目中引导创建一个package.json文件
npm help 查看某条命令的详细帮助
npm root 查看包的安装路径
npm config 管理npm的配置路径
npm cache 管理模块的缓存
npm start 启动模块
npm stop 停止模块
npm restart 重新启动模块
npm test 测试模块
npm version 查看模块版本
npm view 查看模块的注册信息
npm adduser 用户登录
npm publish 发布模块
npm access 在发布的包上设置访问级别
npm cache verify
注意: if npm version is < 5 then use npm cache clean
npm list
该命令会显示所有模块:(安装的)模块,子模块以及子模块的子模块等。可以限制输出的模块层级:
npm list --depth=0
该命令会为模块在全局目录下创建一个符号链接。可以通过下面的命令查看模块引用:
npm list -g --depth=0
一般版本是1.0.0(大.中.小)
会匹配最近的小版本依赖包,比如1.2.3会匹配所有1.2.x版本,但是不包括1.3.0
^会匹配最新的中版本依赖包,比如^1.2.3会匹配所有1.x.x的包,包括1.3.0,但是不包括2.0.0
“jquery”: “2.0.1” 等于当前版本
“jquery”: “>=1.0.2 <2.1.2” 大于等于version 小于version
“jquery”: “>1.0.2 <=2.3.4” 大于version 小于等于version
“jquery”: “<2.3.4” 小于version
“jquery”: “<=2.3.4” 小于等于version
“jquery”: “>2.3.4” 大于version
“jquery”: “>=2.3.4” 大于等于version
“jquery”: “<1.0.0 || >=2.3.1 <2.4.5 || >=2.5.2 <3.0.0” 三选一version
“jquery”: “~2.3.4” ~1.2.3会匹配所有1.2.x版本,但是不包括1.3.0
“jquery”: “^2.3.4” ^1.2.3会匹配所有1.x.x的包,包括1.3.0,但是不包括2.0.0
常用就这些,一般就用~和^或者直接写版本
以上就是npm常用一些小知识。
题目:我们在过马路会遇到红绿灯,模仿一个红绿灯切换效果。
要求:
Angular5自从2017-11-01发布正式版以来,很多相关依赖模块也做了相应的升级,比如谷歌官方的UI组件库模块[Material2]也千呼万唤始出来,发布正式版5.0,版本和angular5对应。Angular5最大的改变是把HttpClientModule
小三转正,鼓励开发者去掉原来的HttpModule
。
相信很多Angular开发者同学在开发Angular应用时候,发现一个很坑的事情,使用HttpModule去做统一加Token认证信息是一件很头疼事情,需要自己去包装一下http请求,然后用包装后http请求,捕获一些http状态错误也是一样,常见的错误401未授权
、403没有权限
、500服务端错误
等等错误状态,我们需要集中处理,同样需要包装。是不是很不爽,为什么不能像Angularjs一样,有拦截器Interceptor,这样的好用的功能了?
Angular4.3它带来了一个全新的一套有用的功能的HTTP工具--[HttpClientModule]。它功能和HttpModule
没有太大什么差别,有2个突出的变化,后面一样一样来说它们。第一个也许最期待已久的功能是httpinterinterceptor
接口。到Angular4.3之前,还没有办法全局拦截和修改http请求。这在Angularjs中一直都存在的,事实上它的缺少,是Angular2+开发人员的一个难点。
那么为什么http拦截器有用呢?有很多用法,但是一个常见的使用方法是自动添加Token
信息到请求头里。这可以采取几种不同的形式,但最常见的做法是将JSON Web Token(或其他形式的访问令牌)作为Authorization
与Bearer
方案相连接。
常见写法:
Headers{
Authorization: 'Bearer xxxxxx.xxx.xx'
}
让我们来看看如何使用Angular的httpinterceptor接口来进行认证的http请求。
当在Angular应用程序中处理身份验证时,通常最好将您需要的所有内容都放在一个专门的服务中。这个认证服务应该包含至少有允许用户登录和注销的基本方法。它还应该包括一个方法,从客户端存储的任何地方获取一个JSON Web Token
,来确定用户是否被认证的方法。
从刚接触Angular到现在,自己也是一路摸滚打爬过来的,虽不是什么高手,单对于如何学习Angular,还是有一些个人的见解,拿出来与大家共勉。
学习Angular从入门到放弃,大致有6个过程或者说是6个层次:
对于刚接触Angular
的新手来说,第一步无非是打基础,也是最重要的一步,决定你要不要继续。(Angular
学习门槛略高,不是有意吓你的)
在学习之前你要弄明白以下事情:
Angular
是什么?Angular
与AngularJs的区别是什么?Angular
版本差异?如何选择合适的版本?Angular
适用场景?Angular
不适用的场景?Angular
的基本语法。
Angular
的特性:
Angular
的八个主要构造块:
RxJs
是什么?RxJs
的基本使用,不一样编程方式zone.js
是什么?给Angular
带来什么flex-layout
是什么?你在使用flex吗ngRx
又是个什么鬼,为什么没有听过其实上面的内容,大部分Angular的文档都有介绍。基本了解Angular
后,我们可以参考文档的快速上手,写一个Hello world
程序。
PS:
你也许会想,前端和UI界面打交道最多,需要有一整套完善的UI组件库,那Angular有吗?别担心,Github目前有很多UI组件库,官网的资源集合, 其中我所熟知UI组件有:
另外Github以ngx-开头或者(ng2,ng4等)的都是Angular相关的资源模块,可以挑选自己喜欢的,进行使用吧。
繁荣的生态,才是一个框架的活力所在。当你对 Angular 已经了解的差不多了,并且按耐不住跃跃欲试了。这个时候,我们不妨用 Angular 的第三方UI组件和其他依赖模块做些好玩的事情:
Angular并不是只能做以上的事情,几乎其他框架能做的事情Angular都能做,而且有些情况下能做的更好。
当前,学习Js框架不能只会简单的用,这个时候,我们需要回头深入了解下Angular核心API用法。说白了,就是好好看 Angular 官网的 API文档。看文档是必备技能。
坚持第四步。 在使用Angular时,发现没有合适的模块选择或者选择的模块功能不尽人意,这个时候你可以尝试创建一个模块或者修改你认为不爽的模块,并且开源自己模块或者给该模块的 Github 上提 PR
学习其他技术,前端框架也是类似步骤,了解 -> 入门 -> 掌握 -> 熟练 -> 玩转 -> 未知 ?放弃 : 精通
是放弃还是精通,只是取决你自己有多坚持,成功没有捷径。
一直用别人配置好的东西,经常看别人写教程来写简单的单元测试,闲来无事自己也来撸个配置玩玩。说干就干,从开始到运行成功差不多5个小时。遇到各种问题,主要是各种模块的配置版本问题。
Karma是Testacular的新名字,在2012年google开源了Testacular,2013年Testacular改名为Karma。Karma是一个让人感到非常神秘的名字,表示佛教中的缘分,因果报应,比Cassandra这种名字更让人猜不透!
Karma是一个基于Node.js的JavaScript测试执行过程管理工具(Test Runner)。该工具可用于测试所有主流Web浏览器,也可集成到CI(Continuous integration)工具,也可和其他代码编辑器一起使用。这个测试工具的一个强大特性就是,它可以监控(Watch)文件的变化,然后自行执行,通过console.log显示测试结果。
Jasmine是单元测试框架,我将用Karma让Jasmine测试自动化完成。jasmine提出行为驱动【BDD(Behavior Driven Development)】,测试先行理念,Jasmine的官网
istanbul是一个单元测试代码覆盖率检查工具,可以很直观地告诉我们,单元测试对代码的控制程度。(ps:这个玩意浪费我好久时间,后面详细说怎么配置)
webpack 是一个现代 JavaScript 应用程序的模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成少量的 bundle - 通常只有一个,由浏览器加载。(引用webpack中文网介绍)
ps: 我的环境:nodejs v8.2.1 npm v5.3.0
随着项目越来越大,现在模块已经有100多个了,一开始我想着把它们拆开,打算使用angular-cli
提供的自定义库的功能(ng generate library my-lib),发现有个bug,我自己摸索一下,算是解决这个bug,下次把它贴出给大家遛遛。
我们还是要解决暂时的问题,打包慢,还有打包会失败的问题:
我们使用的是jenkins自动打包。
在我本地打包了5次成功了1次,全是这个错误。
其实这个问题也是算是nodejs的锅了。那我们需要解决,不然怎么继续愉快的玩耍。
找问题得到答案:修改node --max_old_space_size=size
这个size
随意,大概就是1024*n
,推荐4和8
,看你自己系统的内存吧,合理选择,我电脑16g,选择的是8。
最后结果就是:node --max_old_space_size=8192
。(数学问题不用我教了)
因为angular-cli
是我们每次npm install
都会自动安装,如果我直接去改.bi/ng
这个命令不是很靠谱,一次重装你就回到解放前了。
在写Angular
时候,满屏的装饰器,也有一个对应的设计模式叫装饰器模式,允许向一个现有的对象添加新的功能,同时又不改变其结构。简单理解是不改变原有的功能,给它去添加新功能。我们需要这样思路。
我们可以最简单的去复制.bi/ng
,这个玩意叫shell命令,其实我不会玩。
话不多说,开始干活。
在项目的根创建一个scripts
文件夹,以后需要写脚本都可以丢进去。
在scripts
创建一个ng.sh
文件。
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -x "$basedir/node" ]; then
"$basedir/node" --max_old_space_size=8192 "./node_modules/@angular/cli/bin/ng" "$@"
ret=$?
else
node --max_old_space_size=8192 "./node_modules/@angular/cli/bin/ng" "$@"
ret=$?
fi
exit $ret
这是.bin/ng
的代码:
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -x "$basedir/node" ]; then
"$basedir/node" "$basedir/../@angular/cli/bin/ng" "$@"
ret=$?
else
node "$basedir/../@angular/cli/bin/ng" "$@"
ret=$?
fi
exit $ret
其实没有太大的改变,这个意思先告诉nodejs
设置--max_old_space_size=8192
,然后再去运行angular-cli
命令。
怎么使用:
你创建的一个新项目或者正在使用的项目,package.json
画风应该是这样的:
{
...
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
...
}
这里ng
指向就是.bin/ng
。
这里我们需要去修改一下代码:
{
...
"scripts": {
"ng": "ng",
"start": "bash ./scripts/ng.sh serve",
"build": "bash ./scripts/ng.sh build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
...
}
同样的代码规模模块,同样的操作,我们来做下对比:
开发状态:
bash ./scripts/ng.sh serve
运行一次需要56s
ng serve
运行一次需要62s
发布状态:
bash ./scripts/ng.sh build
运行一次需要1分55秒
每次都成功ng build
运行一次需要5分35秒
打包三次才成功相信很多人会卡在75%
和92%
这个2个点上面。现在大家看到了吧,没有对比就没有伤害。
最后:build
一定要加,serve
根据自己需求吧。差别也不大,6s
左右。赶紧给你build
提速吧,再也不用看到<------ JS stacktrace ------>
错误啦。这个不光适用于angular-cli
,react
的cli
也有个这个问题。看了上面你应该也会改了。
重要:发现一个bug,上面
ng.sh
代码本身在windows上面没有毛病,去jenkins
就报错了,我让后台帮我修复一下错误。克隆或者下载项目,使用scripts/ng.sh
即可。
当然也可以使用更简单的方式: 传送门
"scripts": {
"ng-high-memory": "node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng",
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
创建型模式,就是创建对象的模式,抽象了实例化的过程。它帮助一个系统独立于如何创建、组合和表示它的那些对象。关注的是对象的创建,创建型模式将创建对象的过程进行了抽象,也可以理解为将创建对象的过程进行了封装,作为客户程序仅仅需要去使用对象,而不再关系创建对象过程中的逻辑。
社会化的分工越来越细,自然在软件设计方面也是如此,因此对象的创建和对象的使用分开也就成为了必然趋势。因为对象的创建会消耗掉系统的很多资源,所以单独对对象的创建进行研究,从而能够高效地创建对象就是创建型模式要探讨的问题。这里有6个具体的创建型模式可供研究,它们分别是:
简单工厂模式不是GoF总结出来的23种设计模式之一
结构型模式是为解决怎样组装现有的类,设计它们的交互方式,从而达到实现一定的功能目的。结构型模式包容了对很多问题的解决。例如:扩展性(外观、组成、代理、装饰)、封装(适配器、桥接)。
在解决了对象的创建问题之后,对象的组成以及对象之间的依赖关系就成了开发人员关注的焦点,因为如何设计对象的结构、继承和依赖关系会影响到后续程序的维护性、代码的健壮性、耦合性等。对象结构的设计很容易体现出设计人员水平的高低,这里有7个具体的结构型模式可供研究,它们分别是:
行为型模式涉及到算法和对象间职责的分配,行为模式描述了对象和类的模式,以及它们之间的通信模式,行为模式刻划了在程序运行时难以跟踪的复杂的控制流可分为行为类模式和行为对象模式。1. 行为类模式使用继承机制在类间分派行为。2. 行为对象模式使用对象聚合来分配行为。一些行为对象模式描述了一组对等的对象怎样相互协作以完成其中任何一个对象都无法单独完成的任务。
在对象的结构和对象的创建问题都解决了之后,就剩下对象的行为问题了,如果对象的行为设计的好,那么对象的行为就会更清晰,它们之间的协作效率就会提高,这里有11个具体的行为型模式可供研究,它们分别是:
创建型模式提供生存环境,结构型模式提供生存理由,行为型模式提供如何生存。
通过上篇学习,我们已经完成注册登录退出,一套基本用户体系,接下来我们需要完善它们。
这篇主要内容:
这是计划,如果文章太长,后2个内容会新开一个补充来专门介绍它们。
先看首页效果图:
总共分为这么几大块:
我们现在可以实现是1,3,4,6。因为没有主题系统,所以我们无法实现它相关的功能。
我们先一步一步来,实现2和4,其实这个我们在上一篇时候已经实现的,我们现在只需要去完善它们即可。
找到我们通用模板sidebar.html
,
发表主题按钮:
<% if (current_user) { %>
<div class="panel">
<div class='inner'>
<a href='/topic/create' id='create_topic_btn'>
<span class='span-success'>发布话题</span>
</a>
</div>
</div>
<% } %>
只有登录才能显示这个按钮。
后面大概都是和cnode
一样。直接拷贝它的模板,遇到报错,先把屏蔽删除,后面再修改。
2和4未登录的样子:
然后会报一些错误,比如没有config,helper等,我们先去处理一下它们,写一个locals.middleware.ts
中间件就行了,把它申明到AppModule
(和上篇的当前用户中间件一样)
import { Injectable, NestMiddleware, MiddlewareFunction } from '@nestjs/common';
import * as loader from 'loader';
import { ConfigService, EnvConfig } from '../../config';
import { APP_CONFIG } from '../constants';
@Injectable()
export class LocalsMiddleware implements NestMiddleware {
constructor(private readonly configService: ConfigService<EnvConfig>) {}
resolve(...args: any[]): MiddlewareFunction {
let assets = {};
if (this.configService.get('MINI_ASSETS')) {
try {
assets = require('../../../assets.json');
} catch (e) {
// tslint:disable-next-line:no-console
console.error(
'You must execute `make build` before start app when mini_assets is true.',
);
throw e;
}
}
return (req, res, next) => {
// 应用配置
res.locals.config = APP_CONFIG;
// 加载文件
res.locals.Loader = loader;
// 静态资源
res.locals.assets = assets;
// 工具助手
res.locals.helper = {};
next();
};
}
}
后面再去完善它们,保证它们正确性。这里先保证页面不会报错,能运行。
一番捣鼓以后,登录成功以后显示:
完美,接下来去先要处理一个比较重要东西,就是我们刚刚写的中间件。大多数模板报错都和它们有关。
我们之前犯了一个错误,我们说配置说系统配置,应用配置组成,系统配置和我们开发生产部署,息息相关。应用配置和我们应用相关,页面显示一些信息。我们现在需要把它们想办法合并,获取只会只需要一个入口即可。
我们先思考一个我们需要怎么样去获取:
原则:
一个常规网站应用系统,它该有哪些配置,环境变量(运维部署配置),配置信息(应用信息),用户偏好配置等。
我们还是使用.env
来存储环境变量,创建一个config
文件夹存储,配置信息和一些快捷配置(比如:根据环境变量拼接新的配置信息方便使用,数据库配置等)
那么我们需要改写config
模块了。这里不花篇幅介绍了,代码里面有注释,使用只需要config.get('xxx')
, 或者config.get('xxx.xxx')
。
locals.middleware
我们之前直接使用的cnode
的模板,所以没有什么问题,实际需要一些本地变量提供给页面的,我们把这些变量全部存到locals.middleware
里面,便于维护,
写完以后我们开始替换模板。哪里报错改哪里。
接下来时间都是无聊改模板,创建模块,差不多和cnode
一样。
这是除了user
模块外最重要的一个模块,博客系统最主要也是主题帖子。
在shared/mongodb/
创建topic
一套文件,和user
一样,这里不细展开说明。
字段类型和cnode
一样,这个不用再叙述。不明白看前面user
模块
我们已经有了用户注册和登录,那么我们主题模块顺序标准增删改查,至少需要先添加主题,显示主题列表页面,有主题显示的详情页面,优先完成增和查。
登录以后首页就会出现发布话题
按钮,接下来我们就去完善它,这里没有其他难点,唯一一个复杂就是图片上传。
在 feature
创建 topic
模块,完成一套服务、控制器、dto等模块功能。
代码已经上传 传送门
很长时间工作忙,一直没有在填这个坑,本来以为国内时候来弄,哪知道家里网感人肺腑,压根打不开,现在享受福利,在家办公,就来把这个坑填完,看评论小伙伴说现在官网都是v6版本了,我现在代码有没有问题,v6版本主要在cli工程化有很大的改变,借鉴angular6以上的版本,nx的**,提供了一个工作区(Workspaces)概念,支持了多application和library,这主要借助 monorepo 概念。在angular6之前,github的angular生态模块千奇百怪,angular6标准了library构建打包上传,nest也支持这个做法,我们前面的写的修改ConfigModule
,现在官方已经支持了,我们接下来升级以后多用官方自带的模块,会改写邮件模块为library,让大家感受一下。(ps:nest-cli无法直接升级,我们需要升级本地cli,新建工程,把现有的项目移动进去,我把旧的项目放在old分支,方便大家查阅变更对比。)
node -v
// v8.11.1
npm install -g @nestjs/cli
//等待安装
mkdir temp
// 当前项目创建一个临时文件夹
cd temp
// 进入文件创建项目
nest new nest-cnode
// 等待创建完成
cd nest-cnode
npm run start
// 运行启动
cli功能:
.?
和 ??
特性(不了解的可以去看看ECMA介绍)我会尽量发挥新的cli特性,修改部分代码,修改过程中关键点我会代码里面注释。如果无法注释,我会在这里说明。
几点修改:
public
,为了保证统一改成assets
@Inject(REQUEST)
代替控制器的@Request()
。auth模块还是保留之前写法,让大家可以看到对比。大的修改就这些。
这里主要是图片上传,头像上传和主题图片,评论图片,主要就这两种。
我们不需要额外引入包,但是需要引入@types
npm install --save-dev @types/multer
使用nestjs内置的的上传模块MulterModule
,这样有官方文档
....
import { MulterModule } from '@nestjs/platform-express';
@Module({
imports: [
...
MulterModule.register()
],
})
export class CoreModule {
}
这样就导入模块和配置模块。
写一个upload控制器
一般来说上传都是独立的服务或者模块,因为它在线上一般都是用oss这样付费图片管理服务,只需要本地去写获取秘钥的接口即可。
我们这是测试也是自己玩,就自己上传的到自己服务即可,其实我想用七牛云存储的,免费的10g,好长时间不用,忘记账号密码,后面来填这个坑。
controllers/upload
控制器就一个接口
upload.controller.ts
@Post('/upload')
@UseInterceptors(FileInterceptor('file', multerOptions))
async upload(@UploadedFile() file: FileDto) {
this.logger.log(file);
const assets = multerConfig.assets;
const filename = file.path.split(sep).join('/').split(assets);
return {
success: true,
url: assets + filename[1],
};
}
interface FileDto {
fieldname: string,
originalname: string,
encoding: string,
mimetype: string,
destination: string,
filename: string,
path: string,
size: number
}
这里的multerOptions是重点,multerOptions是multer配置,参考文档。
这里就不贴代码了,代码里面有了说明注释,查看代码
为了满足这个上传需求,还改前端上传js代码,下次更新主题创建一并上传。
一个重定向问题:
创建一个主题成功以后需要重定向到这个主题展示页,
你可能会使用res.redirect
,因为重定向的主题id是动态的,
/** 发布话题 */
@Post('/create')
@Render(ViewsPath.TopicCreate)
@UseGuards(new UserGuard())
async create(@Body() create: CreateDto, @Res() res: Response) {
const topic = await this.topicService.create(create);
res.redirect(`/topic/${topic.id}`);
}
如果这样些就会抛出一个错误:
UnhandledPromiseRejectionWarning: Error: Can't set headers after they are sent.
/** 发布话题 */
@Post('/create')
@Redirect('/topic')
@UseGuards(new UserGuard())
async create(@Body() create: CreateDto) {
const topic = await this.topicService.create(create);
return { url: `/topic/${topic.id}` };
}
我们使用重定向装饰器@Redirect(url: string, statusCode?: number)
,url
参数是必填的,我们需要动态,那么返回时候直接返回{url}
即可。
注意:如果同时使用
@Render()
和@Redirect()
,动态的url将不生效,只会以@Redirect()
的参数为准。
已经完成了主题,回复,收藏关注,用户中心等大部分功能。具体查看源码
接下来的内容我整理在项目中遇到的坑和解决方案。
未完,待续...
Angular开发中,有时候有些错误让人一脸懵逼,不知道该如何下手,接下来我就介绍一下我在我使用angular中遇到的问题和解决方案(欢迎你留下你的问题和解决方案,让我们angular开发更轻松容易):
经常看有人在群里问下面这张图是什么问题,
上面问题解答是AppComponent
依赖NameService
服务,NameService
却没有申明。
解决方案去申明注册:(注意:服务注册位置决定服务作用域)
全局申明:(一般用于全局数据共享使用,如果是注册到全局,推荐第一种方式,因为它对打包会有优化)
@Injectable({
providedIn: 'root',
})
export class NameService {
}
@NgModule({
declarations: [AppComponent],
imports: [
],
providers: [NameService],
bootstrap: [AppComponent]
})
export class AppModule { }
模块申明:(一般用于该模块下数据共享使用,你也可以导出给其他模块使用)
@NgModule({
imports: [
],
providers: [NameService],
exports: [NameService]
})
export class coreModule { }
组件申明1:(一般用于该组件下数据共享使用,它会携带一个OnDestroy
生命周期)
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
providers: [NameService]
})
export class AppComponent {
title = 'data-analysis';
constructor(private nameService: NameService) {
}
}
组件申明2:(一般用于该组件下数据共享使用,它会携带一个OnDestroy
生命周期)
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
viewProviders: [NameService]
})
export class AppComponent {
title = 'data-analysis';
constructor(private nameService: NameService) {
}
}
注意: 在父组件用
viewProviders
注册的provider
,对contentChildren
是不可见的。而使用providers
注册的provider
,对viewChildren
和contentChildren
都可见!
补充说明:组件会逐级向上寻找provider,直到找到为止,否则就会抛出错误。
什么是contentChildren
,就是<ng-content></ng-content>
的内容。
private
基本很多栗子都是这样的来写依赖注入:
export class NameComponent {
constructor(private nameService: NameService) { }
}
有人就奇怪为什么我一定要写一个private
,可以不写么,可以,但是会报错,如果不写ts只是当
他是类参数,其实constructor(private nameService: NameService) { }
是一个语法糖。
如果不写private
:
export class NameComponent {
constructor(nameService: NameService) { }
}
编辑器会提示:类型“AppComponent”上不存在属性“nameService”
试想一下ES6的class怎么写的:
class NameComponent {
constructor(nameService) {
this.nameService = nameService;
}
}
constructor里的参数,ts看来它是构造参数,不是一个类的属性,如果要实现属性功能,你需要这样来写:
private nameService: NameService;
constructor(nameService: NameService) {
this.nameService = nameService;
}
private
关键字表示这个是私有,你还可以写public
公开,protected
对继承的子类公开。
最后总结:你在ts写方法和属性时候公开可以不用写public
关键字,但是在constructor
里写依赖注入时如果需要写成公开时候一定要写public
关键词。
CommonModule
。angular使用中一定要注意,组件,指令,管道,服务都是封装的在模块里,如果想要给其他模块里面组件使用,一定要导出。如果当前模块想要使用别人模块一定要导入。
CommonModule
里面携带angular自带组件,指令,管道等,如果你带上它不能使用*ngIf
,*ngFor
等。
最后总结,记住两点,你可以轻松玩转angular模块:
exports
imports
这是一个什么沙雕错误,翻译成中文app-xxx
不是一个已知的元素,再说简单点就不是一个标准的HTML标签,算一个自定义标签,angular不认识它。
问题找到根源了,出现这个错误有个原因:
技巧:如果你用了
vs code
推荐安装Angular Language Service
。
这里是vs code
提示错误,翻译里面3句话比较重要:
组件是Angular应用最基本的UI构建块。一个Angular应用包含一个Angular组件树。
Angular组件是指令的子集,总是与模板相关联。与其他指令不同,模板中的每个元素只能实例化一个组件。
一个组件必须属于一个NgModule,以便它对另一个组件或应用程序可用。要使它成为NgModule的成员,请在`@NgModule`元数据的`declarations`中申明它。
一个组件只能在一个NgModule
申明,不能重复申明,如果A和B模块都申明一个c组件,那么就会报错,提醒你写一个D模块去申明c组件,A和B模块去引用D模块。这就是传说的共享模块思路来源。
注意:组件、指令和管道都需要在
declarations
中申明它。
<div *ngIf="true"></div>
这是内置的angular指令,如果你当前模块没有导入CommonModule
,就会报错Can't bind to 'ngIf' since it isn't a known property of 'div'.
解决方案只需要导入CommonModule
,使用其他模块组件,指令,管道也是一样的道理。
Angular Language Service
提示:
这是angular开发神器,还有更多开发功能,期待你去发现吧。。。
这又是一个什么沙雕错误,翻译成中文appCode
不是一个div上面一个属性,再说简单点就不是一个标准的HTML标签标准属性,算一个自定义属性,angular不认识它。
需要先去了解一下HTML attribute 与 DOM property 的对比
重点:模板绑定是通过 property 和事件来工作的,而不是 attribute。
这里只能推测你有2个意图:
那你需要这样去操作:使用attr.xxxx
<div [attr.appCode]="123"></div>
技巧:我们常用的html5的自定义data,需要这样来绑定
[attr.data-xxx]="xxx"
@Input
属性。这种情况也分2种,一直是你没有申明,或者导入。
这个参照'app-xxx' is not a known element解决。
意思是你没有在组件或指令使用@Input()
装饰器申明它,或者你属性名写错了。
解决方案请正确书写和申明。
在本文中,我将使用Nest.js
构建一个CNode。
为什么这篇文章?我喜欢NodeJs
,虽然我的NodeJs
水平一般。但我还是用它来记录一下我学习过程。
最近,我发现了Nest.js框架,它有效地解决了Nodejs项目中的一个难题:体系结构。Nest
旨在提供开箱即用的应用程序,可以轻松创建高度可测试,可扩展,松散耦合且易于维护的应用程序。Nest.js
将TypeScript
引入Node.js
中并基于Express
封装。所以,我想用Nest.js
尝试写一个CNode。(ps:目前CNode采用Egg编写)我没有找到关于这个话题的快速入门,所以我会给你我的实践,你可以轻松地扩展到你的项目。
本文的目的不是介绍Nest.js。对于那些不熟悉Nest.js的人:它是构建Node.js Web应用程序的框架。尽管Node.js已经包含很多用于开发Web应用程序的库,但它们都没有有效地解决最重要的主题之一:体系结构。
现在,请系好安全带,我们要发车了。
Nest
是一个强大的Node web
框架。它可以帮助您轻松地构建高效、可伸缩的应用程序。它使用现代JavaScript
,用TypeScript
构建,结合了OOP
(面向对象编程)和FP
(函数式编程)的最佳概念。
它不仅仅是另一个框架。你不需要等待一个大的社区,因为Nest
是用非常棒的、流行的知名库——Express
和socket.io
构建的!这意味着,您可以快速开始使用框架,而不必担心第三方插件。
作者Kamil Myśliwiec初衷:
JavaScript is awesome. Node.js gave us a possibility to use this language also on the server side. There are a lot of amazing libraries, helpers and tools on this platform, but non of them do not solve the main problem – the architecture. This is why I decided to create Nest framework.
重要:
Nest
受到Java Spring
和Angular
的启发。如果你用过Java Spring
或Angular
就会学起来非常容易,我本人一直使用Angular
。
Nest的核心概念是提供一种体系结构,它帮助开发人员实现层的最大分离,并在应用程序中增加抽象。
Nest
采用了ES6
和ES7
的特性(decorator
, async/await
)。如果想使用它们,需要用到Babel
或TypeScript
进行转换成 es5
。
Nest
默认使用的是TypeScript,也可以直接使用JavaScript
,不过那样就没什么意义了。
如果你使用过Angular,你来看这篇文章会觉得非常熟悉的感觉,因为它们大部分写法类似。如果你没有用过也没有关系,我将带领你一起学习它们。
使用Nest
,您可以很自然地将代码拆分为独立的和可重用的模块。Nest
模块是一个带有@Module()
装饰器的类。这个装饰器提供元数据,框架使用元数据来组织应用程序结构。
每个 Nest
应用都有一个根模块,通常命名为 AppModule
。根模块提供了用来启动应用的引导机制。 一个应用通常会包含很多功能模块。
像 JavaScript
模块一样,@Module
也可以从其它 @Module
中导入功能,并允许导出它们自己的功能供其它 @Module
使用。 比如,要在你的应用中使用nest
提供的mongoose
操作功能,就需要导入MongooseModule
。
把你的代码组织成一些清晰的功能模块,可以帮助管理复杂应用的开发工作并实现可复用性设计。 另外,这项技术还能让你使用动态加载,MongooseModule
就是使用这项技术。
@Module
装饰器接受一个对象,该对象的属性描述了模块:
属性 | 描述 |
---|---|
providers |
由Nest 注入器实例化的服务,可以在这个模块之间共享。 |
controllers |
存放创建的一组控制器。 |
imports |
导入此模块中所需的提供程序的模块列表。 |
exports |
导出这个模块可以其他模块享用providers 里的服务。 |
@Module
为一个控制器集声明了编译的上下文环境,它专注于某个应用领域、某个工作流或一组紧密相关的能力。 @Module
可以将其控制器和一组相关代码(如服务)关联起来,形成功能单元。
怎么组织一个模块结构图
AppModule 根模块
在Nest
中,模块默认是单例的,因此可以在多个模块之间共享任何提供者的同一个实例。共享模块毫不费力。
整体看起来比较干净清爽,这也是我在Angular
项目中一直使用的模块划分。
如果你有更好建议,欢迎和我一起交流改进。
控制器负责处理客户端传入的请求参数并向客户端返回响应数据,说的通俗点就是路由Router
。
为了创建一个基本的控制器,我们使用@Controller
装饰器。它们将类与基本的元数据相关联,因此Nest
知道如何将控制器映射到相应的路由。
@Controller
它是定义基本控制器所必需的。@Controller('Router Prefix')
是类中注册的每个路由的可选前缀。使用前缀可以避免在所有路由共享一个公共前缀时重复使用自己。
@Controller('user')
export class UserController {
@Get()
findAll() {
return [];
}
@Get('/admin')
admin() {
return {};
}
}
// findAll访问就是 xxx/user
// admin访问就是 xxx/user/admin
控制器是一个比较核心功能,所有的业务都是围绕它来开展。Nest
也提供很多相关的装饰器,接下来一一介绍他们,这里只是简单说明,后面实战会介绍他们的使用。
请求对象表示HTTP请求,并具有请求查询字符串、参数、HTTP标头等属性,但在大多数情况下,不需要手动获取它们。我们可以使用专用的decorator
,例如@Body()
或@Query()
,它们是开箱即用的。下面是decorator
与普通Express
对象的比较。
先说方法参数装饰器:
装饰器名称 | 描述 |
---|---|
@Request() |
对应Express 的req ,也可以简写@req |
@Response() |
对应Express 的res ,也可以简写@res |
@Next() |
对应Express 的next |
@Session() |
对应Express 的req.session |
@Param(param?: string) |
对应Express 的req.params |
@Body(param?: string) |
对应Express 的req.body |
@Query(param?: string) |
对应Express 的req.query |
@Headers(param?: string) |
对应Express 的req.headers |
先说方法装饰器:
装饰器名称 | 描述 |
---|---|
@Post() |
对应Express 的Post 方法 |
@Get() |
对应Express 的Get 方法 |
@Put() |
对应Express 的Put 方法 |
@Delete() |
对应Express 的Delete 方法 |
@All() |
对应Express 的All 方法 |
@Patch() |
对应Express 的Patch 方法 |
@Options() |
对应Express 的Options 方法 |
@Head() |
对应Express 的Head 方法 |
@Render() |
对应Express 的res.render 方法 |
@Header() |
对应Express 的res.header 方法 |
@HttpCode() |
对应Express 的res.status 方法,可以配合HttpStatus 枚举 |
以上基本都是控制器装饰器,一些常用的HTTP请求参数需要使用对应的方法装饰器和参数来配合使用。
关于返回响应数据,Nest
也提供2种解决方案:
直接返回一个JavaScript
对象或数组时,它将被自动解析为JSON
。当我们返回一个字符串时,Nest
只发送一个字符串,而不尝试解析它。默认情况下,响应的状态代码总是200
,
但POST
请求除外,它使用201
。可以使用@HttpCode(HttpStatus.xxxx)
装饰器可以很容易地改变这种行为。
我们可以使用库特定的响应对象,我们这里可以使用@res()修饰符在函数签名中注入该对象,
res.status(HttpStatus.CREATED).send()
或者res.status(HttpStatus.OK).json([])
等Express
的res
方法。
注意:禁止同时使用这两种方法,如果2个都使用,那么会出现这个路由不工作的情况。如果你在使用时候发现路由不响应,请检查有没有出现混用的情况,如果是正常情况下,推荐第一种方式返回。
控制器必须注册到该模块元数据的
controllers
里才能正常工作。
关于控制器异常处理,在后面过滤器讲解。
服务是一个广义的概念,它包括应用所需的任何值、函数或特性。狭义的服务是一个明确定义了用途的类。它应该做一些具体的事,并做好。
Nest
把控制器和服务区分开,以提高模块性和复用性。
通过把控制器中和逻辑有关的功能与其他类型的处理分离开,你可以让控制器类更加精简、高效。 理想情况下,控制器的工作只管申明装饰器和响应数据,而不用顾及其它。 它应该提供请求和响应桥梁,以便作为视图(由模板渲染)和应用逻辑(通常包含一些模型的概念)的中介者。
控制器不需要定义任何诸如从客户端获取数据、验证用户输入或直接往控制台中写日志等工作。 而要把这些任务委托给各种服务。通过把各种处理任务定义到可注入的服务类中,你可以让它可以被任何控制器使用。 通过在不同的环境中注入同一种服务的不同提供商,你还可以让你的应用更具适应性。
Nest
不会强制遵循这些原则。它只会通过依赖注入让你能更容易地将应用逻辑分解为服务,并让这些服务可用于各个控制器中。
控制器是服务的消费者,也就是说,你可以把一个服务注入到控制器中,让控制器类得以访问该服务类。
那么服务就是提供者,基本上,几乎所有事情都可以看作是提供者—服务、存储库、工厂、助手等等。它们都可以通过构造函数注入依赖关系,这意味着它们可以彼此创建各种关系。
在 Nest
中,要把一个类定义为服务,就要用 @Injectable
装饰器来提供元数据,以便让 Nest
可以把它作为依赖注入到控制器中。
同样,也要使用 @Injectable
装饰器来表明一个控制器或其它类(比如另一个服务、模块等)拥有一个依赖。 依赖并不必然是服务,它也可能是函数或值等等。
依赖注入(通常简称 DI)被引入到 Nest
框架中,并且到处使用它,来为新建的控制器提供所需的服务或其它东西。
注入器是主要的机制。你不用自己创建 Nest
注入器。Nest
会在启动过程中为你创建全应用级注入器。
该注入器维护一个包含它已创建的依赖实例的容器,并尽可能复用它们。
提供者是创建依赖项的配方。对于服务来说,它通常就是这个服务类本身。你在应用中要用到的任何类都必须使用该应用的注入器注册一个提供商,以便注入器可以使用它来创建新实例。
关于依赖注入,前端框架Angular
应该是最出名的,可以看这里介绍。
// 用户服务
import { Injectable } from '@nestjs/common';
interface User {}
@Injectable()
export class UserService {
private readonly user: User[] = [];
create(cat: User) {
this.user.push(User);
}
findAll(): User[] {
return this.user;
}
}
// 用户控制器
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
async create(@Body() createUserDto: CreateUserDto) {
this.userService.create(createUserDto);
}
@Get()
async findAll(): Promise<User[]> {
return this.userService.findAll();
}
}
自定义服务
我们不光可以使用@Injectable()
来定义服务,还可以使用其他三种方式:value
、class
、factory
。
这个和Angular一样,默认@Injectable()
来定义服务就是class
。
使用value
:
const customObject = {};
@Module({
controllers: [ UsersController ],
components: [
{ provide: UsersService, useValue: customObject }
],
})
注意:
useValue
可以是任何值,在这个模块中,Nest
将把customObject
与UsersService
相关联,你还可以使用做测试替身(单元测试)。
使用class
:
import { UserService } from './user.service';
const customObject = {};
@Module({
controllers: [ UsersController ],
components: [
{ provide: UsersService, useClass: UserService }
OR
UserService
],
})
注意:只需要在本模块中使用选定的、更具体的类,
useClass
可以是和provide
一样,如果不一样就相当于useClass
替换provide
。简单理解换方法,不换方法名,常用处理不同环境依赖注入。
使用factory
:
@Module({
controllers: [ UsersController ],
components: [
ChatService,
{
provide: UsersService,
useFactory: (chatService) => {
return Observable.of('customValue');
},
inject: [ ChatService ]
}
],
})
注意:希望提供一个值,该值必须使用其他组件(或自定义包特性)计算,希望提供异步值(只返回可观察的或承诺的值),例如数据库连接。
inject
依赖服务,provide
注册名,useFactory
处理方式,useFactory
参数和inject
注入数组顺序一样。
如果我们provide
注册名不是一个服务怎么办,是一个字符串key
,也是很常用的。
@Module({
controllers: [ UsersController ],
components: [
{ provide: 'isProductionMode', useValue: false }
],
})
要用选择的自定义字符串key
,您必须告诉Nest,需要用到@Inject()
装饰器,就像这样:
import { Component, Inject } from 'nest.js';
@Component()
class SampleComponent {
constructor(@Inject('isProductionMode') private isProductionMode: boolean) {
console.log(isProductionMode); // false
}
}
还有一个循环依赖的坑,后面实战会介绍怎么避免和解决这个坑。
服务必须注册到该模块元数据的
providers
里才能正常工作。如果需要给其他模块使用,需要添加到exports
中。
中间件是在路由处理程序之前调用的函数。中间件功能可以访问请求和响应对象,以及应用程序请求-响应周期中的下一个中间件功能。下一个中间件函数通常由一个名为next
的变量表示。在Express
中的中间件是非常出名的。
默认情况下,Nest
中间件相当于表示Express
中间件。和Express
中间件功能类似,中间件功能可以执行以下任务
next()
将控制权传递给下一个中间件函数。否则,请求将被挂起。简单理解Nest
中间件就是把Express
中间件进行了包装。那么好处就是只要你想用中间件,可以立马搜索Express
中间件,拿来即可使用。是不是很方便。
Nest
中间件要么是一个函数,要么是一个带有@Injectable()
装饰器的类。类应该实现NestMiddleware
接口,而函数却没有任何特殊要求。
// 实现一个带有`@Injectable()`装饰器的类打印中间件
import { Injectable, NestMiddleware, MiddlewareFunction } from '@nestjs/common';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
resolve(...args: any[]): MiddlewareFunction {
return (req, res, next) => {
console.log('Request...');
next();
};
}
}
怎么使用,有两种方式:
async function bootstrap() {
// 创建Nest.js实例
const app = await NestFactory.create(AppModule, application, {
bodyParser: true,
});
// 注册中间件
app.use(LoggerMiddleware());
// 监听3000端口
await app.listen(3000);
}
bootstrap();
export class CnodeModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.with('ApplicationModule')
.exclude(
{ path: 'user', method: RequestMethod.GET },
{ path: 'user', method: RequestMethod.POST },
)
.forRoutes(UserController);
}
}
// or
export class CnodeModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('*');
}
}
// 1. with是提供数据,resolve里可以获取,exclude指定的路由,forRoutes注册路由,
// 2. forRoutes传递'*'表示作用全部路由
注意:他们注册地方不一样,影响的路由也不一样,全局注册影响全部路由,局部注册只是影响当前路由下的路由。
异常过滤器层负责在整个应用程序中处理所有抛出的异常。当发现未处理的异常时,最终用户将收到适当的用户友好响应。
默认显示响应JSON
信息
{
"statusCode": 500,
"message": "Internal server error"
}
使用底层过滤器
@Post()
async create(@Body() createCatDto: CreateCatDto) {
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}
HttpException 接受2个参数:
{status: 状态码,error:错误消息}
每次写这么多很麻烦,那么过滤器也支持扩展和定制快捷过滤器对象。
export class ForbiddenException extends HttpException {
constructor() {
super('Forbidden', HttpStatus.FORBIDDEN);
}
}
就可以直接使用了:
@Post()
async create(@Body() createCatDto: CreateCatDto) {
throw new ForbiddenException('Forbidden');
}
是不是,方便很多了。
Nest
给我们提供很多这样快捷常用的HTTP状态错误:
异常处理程序基础很好,但有时你可能想要完全控制异常层,例如,添加一些日志记录或使用一个不同的JSON
模式基于一些选择的因素。前面说了,Nest
给我们内置返回响应模板,这个不能接受的,我们要自定义怎么办了,Nest
给我们扩展空间。
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { HttpException } from '@nestjs/common';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception.getStatus();
response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
它返回是一个Express
的方法response
,来定制自己的响应异常格式。
怎么使用,有四种方式:
@UseFilters()
装饰器里面使用,作用当前这条路由的响应结果@Post()
@UseFilters(HttpExceptionFilter | new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
throw new ForbiddenException();
}
@UseFilters()
装饰器里面使用,作用当前控制器路由所有的响应结果@UseFilters(HttpExceptionFilter | new HttpExceptionFilter())
export class CatsController {}
useGlobalFilters
,作用整个项目。过滤器这种比较通用推荐全局注册。async function bootstrap() {
const app = await NestFactory.create(ApplicationModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
管道可以把你的请求参数根据特定条件验证类型、对象结构或映射数据。管道是一个纯函数,不应该从数据库中选择或调用任何服务操作。
定义一个简单管道:
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class ValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
return value;
}
}
管道是用@Injectable()
装饰器注释的类。应该实现PipeTransform
接口,具体代码在transform
实现,这个和Angular
很像。
Nest
处理请求数据验证,在数据不正确时可以抛出异常,使用过滤器来捕获。
Nest
为我们内置了2个通用的管道,一个数据验证ValidationPipe
,一个数据转换ParseIntPipe
。
使用ValidationPipe
需要配合class-validator class-transformer
,如果你不安装它们 ,你使用ValidationPipe
会报错的。
提示:
ValidationPipe
不光可以验证请求数据也做数据类型转换,这个可以看官网。
怎么使用,有四种方式
@Body()
装饰器里面使用,只作用当前body这个参数// 用户控制器
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
async create(@Body(ValidationPipe | new ValidationPipe()) createUserDto: CreateUserDto) {
this.userService.create(createUserDto);
}
}
@UsePipes()
装饰器里面使用,作用当前这条路由所有的请求参数// 用户控制器
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@UsePipes(ValidationPipe | new ValidationPipe())
async create(@Body() createUserDto: CreateUserDto) {
this.userService.create(createUserDto);
}
}
@UsePipes()
装饰器里面使用,作用当前控制器路由所有的请求参数// 用户控制器
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('user')
@UsePipes(ValidationPipe | new ValidationPipe())
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
async create(@Body() createUserDto: CreateUserDto) {
this.userService.create(createUserDto);
}
}
useGlobalPipes
,作用整个项目。这个管道比较通用推荐全局注册。async function bootstrap() {
const app = await NestFactory.create(ApplicationModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
那么createUserDto
怎么玩了,后面实战教程会讲解,这里不展开。
@Get(':id')
async findOne(@Param('id', ParseIntPipe | new ParseIntPipe()) id) {
return await this.catsService.findOne(id);
}
ParseIntPipe
使用也很简单,就是把一个字符串转换成数字。也是比较常用的,特别是你的id是字符串数字的时候,用get
,put
,patch
,delete
等请求,有id时候特别好用了。
还可以做分页处理,后面实战中用到,具体在讲解。
守卫可以做权限认证,如果你没有权限可以拒绝你访问这个路由,默认返回403
错误。
定义一个简单管道:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return validateRequest(request);
}
}
守卫是用@Injectable()
装饰器注释的类。应该实现CanActivate
接口,具体代码在canActivate
方法实现,返回一个布尔值,true就表示有权限,false抛出异常403错误。这个写法和Angular
很像。
怎么使用,有两种方式
@UseGuards()
装饰器里面使用,作用当前控制器路由所有的请求参数@Controller('cats')
@UseGuards(RolesGuard | new RolesGuard())
export class CatsController {}
useGlobalGuards
,作用整个项目。const app = await NestFactory.create(ApplicationModule);
app.useGlobalGuards(new RolesGuard());
如果你不做权限管理相关的身份验证操作,基本用不上这个功能。不过还是很有用抽象功能。我们这个实战项目也会用到这个功能。
拦截器是一个比较特殊强大功能,类似于AOP面向切面编程,前端编程中也尝尝使用这样的技术,比如各种http请求库都提供类似功能。有名的框架Angular
框架HTTP模块。有名的库有老牌的jquery
和新潮的axios
等。
定义一个简单拦截器:
import { Injectable, NestInterceptor, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
call$: Observable<any>,
): Observable<any> {
console.log('Before...');
const now = Date.now();
return call$.pipe(
tap(() => console.log(`After... ${Date.now() - now}ms`)),
);
}
}
拦截器是用@Injectable()
装饰器注释的类。应该实现NestInterceptor
接口,具体代码在intercept
方法实现,返回一个Observable
,这个写法和Angular
很像。
拦截器可以做什么:
怎么使用,有三种方式
@UseInterceptors()
装饰器里面使用,作用当前路由,还可以传参数,需要特殊处理,写成高阶函数,也可以使用依赖注入。@Post('upload')
@UseInterceptors(FileFieldsInterceptor | FileFieldsInterceptor([
{ name: 'avatar', maxCount: 1 },
{ name: 'background', maxCount: 1 },
]))
uploadFile(@UploadedFiles() files) {
console.log(files);
}
@UseInterceptors()
装饰器里面使用,作用当前控制器路由,这个不能传参数,可以使用依赖注入@UseInterceptors(LoggingInterceptor | new LoggingInterceptor())
export class CatsController {}
useGlobalInterceptors
,作用整个项目。const app = await NestFactory.create(ApplicationModule);
app.useGlobalInterceptors(new LoggingInterceptor());
拦截器可以做很多功能,比如缓存处理,响应数据转换,异常捕获转换,响应超时跑错,打印请求响应日志。我们这个实战项目也会用到这个功能。
模块是按业务逻辑划分基本单元,包含控制器和服务。控制器是处理请求和响应数据的部件,服务处理实际业务逻辑的部件。
中间件是路由处理Handler前的数据处理层,只能在模块或者全局注册,可以做日志处理中间件、用户认证中间件等处理,中间件和express的中间件一样,所以可以访问整个request、response的上下文,模块作用域可以依赖注入服务。全局注册只能是一个纯函数或者一个高阶函数。
管道是数据流处理,在中间件后路由处理前做数据处理,可以控制器中的类、方法、方法参数、全局注册使用,只能是一个纯函数。可以做数据验证,数据转换等数据处理。
守卫是决定请求是否可以到达对应的路由处理器,能够知道当前路由的执行上下文,可以控制器中的类、方法、全局注册使用,可以做角色守卫。
拦截器是进入控制器之前和之后处理相关逻辑,能够知道当前路由的执行上下文,可以控制器中的类、方法、全局注册使用,可以做日志、事务处理、异常处理、响应数据格式等。
过滤器是捕获错误信息,返回响应给客户端。可以控制器中的类、方法、全局注册使用,可以做自定义响应异常格式。
中间件、过滤器、管道、守卫、拦截器,这是几个比较容易混淆的东西。他们有个共同点都是和控制器挂钩的中间抽象处理层,但是他们的职责却不一样。
全局管道、守卫、过滤器和拦截器和任何模块松散耦合。他们不能依赖注入任何服务,因为他们不属于任何模块。
可以使用控制器作用域、方法作用域或辅助作用域仅由管道支持,其他除了中间件是模块作用域,都是控制器作用域和方法作用域。
重点:在示例给出了它们的写法,注意全局管道、守卫、过滤器和拦截器,只能new,全局中间件是纯函数,全局管道、守卫、过滤器和拦截器,中间件都不能依赖注入。中间件模块注册也不能用new,可以依赖注入。管道、守卫、过滤器和拦截器局部注册可以使用new和类名,除了管道以为其他都可以依赖注入。拦截器和守卫可以写成高阶方法来传参,达到定制目的。
管道、过滤器、拦截器守卫都有各自的具体职责。拦截器和守卫与模块结合在一起,而管道和过滤器则运行在模块区域之外。管道任务是根据特定条件验证类型、对象结构或映射数据。过滤器任务是捕获各种错误返回给客户端。管道不是从数据库中选择或调用任何服务的适当位置。另一方面来说,拦截器不应该验证对象模式或修饰数据。如果需要重写,则必须由数据库调用服务引起。守卫决定了哪些路由可以访问,它接管你的验证责任。
那你肯定最关心他们执行顺序是什么:
客户端请求 ---> 中间件 ---> 守卫 ---> 拦截器之前 ---> 管道 ---> 控制器处理并响应 ---> 拦截器之后 ---> 过滤器
我们来看2张图,
请求返回响应结果:
请求返回响应异常:
学习一门语言一门技术都是从 Hello World
开始,我们也是从零到Hello World
开启学习Nest
之旅
推荐nvm
来管理nodejs
版本,根据自己电脑下载对应版本吧。
vs code
推荐插件:(其他插件自己随意)
nest-cli
是一个 nest
项目脚手架。为我们提供一个初始化模块,可以让我们快速完成Hello World
功能。
npm i -g @nestjs/cli
$ nest new my-awesome-app
OR
$ nest n my-awesome-app
创建一个users服务文件
$ nest generate service users
OR
$ nest g s users
注意:
必须
在项目根目录
下创建,(默认创建在src/)。(不能在当前文件夹里面创建,不然会自动生成xxx/src/xxx。吐槽:这个没有Angular-cli智能)优先
新建模块,不然创建的非模块以外的服务,控制器等就会自动注入更新到上级的模块里面打印当前系统,使用nest核心模块版本,供你去官方提交issues
| \ | | | | |_ |/ ___|/ __ \| | |_ _|
| \| | ___ ___ | |_ | |\ `--. | / \/| | | |
| . ` | / _ \/ __|| __| | | `--. \| | | | | |
| |\ || __/\__ \| |_ /\__/ //\__/ /| \__/\| |_____| |_
\_| \_/ \___||___/ \__|\____/ \____/ \____/\_____/\___/
[System Information]
OS Version : Windows 10
NodeJS Version : v8.11.1
NPM Version : 5.6.0
[Nest Information]
microservices version : 5.1.0
websockets version : 5.1.0
testing version : 5.1.0
common version : 5.1.0
core version : 5.1.0
最后,整体功能和
Angular-cli
类似,比较简单实用功能。构建项目,生成文件,打印版本信息。
目前Nest.js
支持 express
和 fastify
, 对 fastify
不熟,本文选择express
。
注意: 其他中间件模块,只要支持
express
和都可以使用。
nest-cnode
nest new nest-cnode
其中提交的你的description
, 初始化版本version
, 作者author
, 以及一个package manager
选择node_modules
安装方式 npm
或者 yarn
。
cd nest-cnode
// 启动命令
npm run start // 预览
npm run start:dev // 开发
npm run prestart:prod // 编译成js
npm run start:prod // 生产
// 测试命令
npm run test // 单元测试
npm run test:cov // 单元测试+覆盖率生成
npm run test:e2e // E2E测试
文件 | 说明 |
---|---|
node_modules | npm包 |
src | 源码 |
logs | 日志 |
test | E2E测试 |
views | 模板 |
public | 静态资源 |
nodemon.json | nodemon配置(npm run start:dev启动) |
package.json | npm包管理 |
README.md | 说明文件 |
tsconfig.json | Typescript配置文件(Typescript必备) |
tslint.json | Typescript风格检查文件(Typescript必备) |
webpack.config.js | 热更新(npm run start:hmr启动) |
.env | 配置文件 |
开发代码都在
src
里,生成代码在dist
(打包自动编译),typescript
打包只会编译ts
到dist
下,静态文件public
和模板views
不会移动,所以需要放到根目录下。
我们打开浏览器,访问http://localhost:3000
,您应该看到一个页面,上面显示Hello World
文字。
我们上篇已经到此为止,请看我们下篇项目实战--Nest-CNode
变量是具有唯一名称的命名容器,用于存储数据值。 以下语句声明了一个名称为“name”的变量:
let name;
console.log(name); // undefined
在JavaScript中,变量在创建时被初始化为undefined。在声明变量时,可以使用赋值运算符(=)将值赋给变量:
let name = 'jiayi';
console.log(name); // "jiayi"
在使用变量之前一定要初始化它们,否则会出现错误:
console.log(name); // ReferenceError: name is not defined
let name = 'jiayi';
ECMAScript 2015(或ES6)引入了两种声明变量的新方法:let
和 const
。使用新关键字的原因是var
的函数作用域令人困惑。这是JavaScript bug的主要来源之一。
从范围上讲,我们正在讨论运行时代码不同区域中变量的可见性。换句话说,代码的哪些区域可以访问和修改变量。
在JavaScript中,作用域有两种:
现代JavaScript (ES6+)所改变的是我们在局部作用域中使用变量的方式。
在函数外部声明的变量成为全局变量。这意味着可以在代码中的任何地方访问和修改它。
我们可以在全局作用域中声明常量:
const COLS = 10;
const ROWS = 20;</span>
我们可以从代码的所有区域访问它们
在局部作用域中声明的变量不能从局部作用域中外部访问。相同的变量名可以在不同的作用域中使用,因为它们被绑定到各自的作用域中。
局部作用域因所使用的变量声明而不同。
用var
关键字声明的变量成为函数的局部变量。可以从函数内部访问它们。
function printColor() {
if(true) {
console.log(color); // undefined
var color = "pink";
console.log(color); // "pink"
}
console.log(color); // "pink"
}
printColor();
console.log(color); // ReferenceError: color is not defined
我们可以看到,即使我们在声明之前访问color
,也不会出错。使用var
关键字声明的变量会被提升到函数顶部,并在代码运行之前用undefined进行初始化。通过提升,即使在声明之前,也可以在其封闭范围内访问它们。
你能看出这是如何让人困惑的吗?
在ES6中引入了块作用域的概念,以及声明变量const
和let
的新方法。这意味着变量可以在两个大括号{}
之间访问。例如在if
或for
里面。
function printColor() {
if(true) {
console.log(color); // ReferenceError: Cannot access 'color' before initialization
let color = "pink";
console.log(color); // "pink"
}
console.log(color); // ReferenceError: color is not defined
}
printColor();
console.log(color); // ReferenceError: color is not defined
let
和const
变量只有在定义求值后才初始化。它们不会像函数范围中那样被提升。在初始化之前访问它们会导致ReferenceError
。
我希望你们能看到这是如何更容易推理的。
因此,既然我们知道应该使用 let
和 const
,那么什么时候应该使用它呢?
两者的不同之处在于 const
的值不能通过重新赋值改变,也不能被重新声明。因此,如果我们不需要重新赋值,我们应该使用const
。这也使得代码更加清晰,因为我们用一个变量来表示一个不可变的值。
甚至可以始终将变量声明为const,直到看到需要重新分配变量然后更改为let为止。 何时需要让我们进入循环的一个示例:
for(let i = 0; i < 10; i++) {
console.log(i); // 1, 2, 3, 4 …
}
我们为什么要使用let
,而不是var
。
我们写一个循环,最终符合预期输出:
for(var i = 0; i < 10; i++) {
console.log(i); // 1, 2, 3, 4 …
}
这是很常见的操作,看不出什么毛病,那么我们修改一下写法:
for(var i = 0; i < 10; i++) {
setTimeout(function(){
console.log(i); // 1, 2, 3, 4 …
}, 1000)
}
我们本以为得到期望输出,但是结果却是 10 个 10
。
为什么会这样呢。
for
循环会先执行完再执行 setTimeout
回调函数(同步优先于异步优先于回调)for
循环和 setTimeout
回调函数不在一个作用域。 setTimeout
回调函数属于函数级的作用域,不属于 for
循环体,属于全局。for
循环结束,i
已经等于 10
了,这个时候再执行 setTimeout
的五个回调函数,里面的 i
去向上找作用域,只能找到全局下的 i
,即 10
。所以输出都是 10
。那么我们需要需要let
来救赎:
for(let i = 0; i < 10; i++) {
setTimeout(function(){
console.log(i); // 1, 2, 3, 4 …
}, 1000)
}
除此之外,也可以通过闭包来解决问题,这是一道常见的经典面试题
const
和 let
使用存在于两个大括号 {}
之间的块作用域const
用于其值永远不会改变的变量let
var
以避免混淆五一假期过后,Angular6发布正式版,相关联的UI组件库Material 和 脚手架CLI,也一并发布6。
升级核心依赖:
工作中已经完成一个Angular6项目,这里来写一个简单的Angular6教程。
古语云:君子谋而后动,三思而后行。我们做一个功能,先规划功能细节。
在本文中,我们将构建一个Angular6 Todo web应用程序,允许用户:
这是一个演示应用程序,我们将一步步从零构建它们。
这里所有的代码都是公开的,所以你可以使用这些代码。
这是一个在线编辑器预览
让我们开始吧!
这里看官网文档 快速开始。详细安装指南,这里不在一一介绍。
修改一下package.json
"scripts": {
"start": "ng serve --open",
...
},
这样可以直接使用npm start
启动开发服务器并且自动打开默认浏览器并访问 http://localhost:4200/
。
现在我们有了Angular-CLI
,我们可以使用它来生成Todo应用程序:
这里是生成项目的文档
ng new angular-todolist --style=scss
说明:生成一个angular-todolist
项目,css预处理器用scss。
满足我们的Todo应用程序的需要,我们需要:
我们所有相关应用都放在todoApp组件,在app组件里面使用todoApp组件。
这里是生成文件的文档
生成组件
ng g c todo-app
这样在app文件夹里面就出现todo-app文件夹
生成服务
ng g s todo-app/todo
注意:默认生成的文件都是以app
为开始路径,我们需要放在todo-app
里,所以是todo-app/todo
生成类
ng g cl todo-app/todo
注意:生成组件是c
,生成类是cl
。
生成指令
ng g d todo-app/after-view-focus
我们现在的todo-app
文件夹里结构应该是:
after-view-focus.directive.spec.ts
after-view-focus.directive.ts
todo-app.component.html
todo-app.component.scss
todo-app.component.spec.ts
todo-app.component.ts
todo.service.spec.ts
todo.service.ts
todo.ts
因为我们使用TypeScript,我们可以使用一个类来表示Todo项目。
让我们打开src/app/todo.ts
并将其内容替换为:
export class Todo {
id: number;
value: string;
done: boolean = false;
edit: boolean = false;
constructor(values: Object = {}) {
Object.assign(this, values);
}
}
我们需要设计数据结构,每个Todo项有三个属性:
构造函数逻辑允许我们在实例化过程中指定属性值:
let todo = new Todo({
title: 'The first todos',
done: false
});
我们可以测试一下,Angular-CLI
提供单元测试和e2e测试,默认生成类文件不会带测试文件,我们需要手动创建一个todo.spec.ts
文件。
import { Todo } from './todo';
describe('Todo', () => {
it('应该创建一个实例', () => {
expect(new Todo()).toBeTruthy();
});
it('应该在构造函数中接受值', () => {
const todo = new Todo({
value: 'hello',
done: true
});
expect(todo.value).toEqual('hello');
expect(todo.done).toEqual(true);
expect(todo.edit).toEqual(false);
});
});
为了保证不受干扰,删除
app.component.spec.ts
文件,把todo-app.component.spec.ts
文件里面代码都注释起来。
为了验证我们的代码是否按预期工作,我们现在可以运行单元测试:
npm test
这将执行业力来运行所有单元测试。如果单元测试失败,可以联系我。
现在我们有了一个Todo类,让我们创建一个Todo服务来为我们管理所有的Todo项。
TodoService将负责管理我们的Todo项目。
在以后的文章中,我们将看到如何与REST API
通信,但是现在我们将把所有数据存储在内存存储中。
现在,我们可以将todo管理逻辑添加到src/app/todo.services.ts
中的TodoService中
import { Injectable } from '@angular/core';
import { Todo } from './todo';
@Injectable()
export class TodoService {
// Placeholder for todo's
todos: Todo[] = [];
/** Used to generate unique ID's */
nextId = 0;
constructor() { }
// Simulate POST /todos
addTodo(todo: Todo): TodoService {
todo.id = Date.now();
this.todos.push(todo);
return this;
}
// Simulate DELETE /todos/:id
deleteTodoById(id: number): TodoService {
this.todos = this.todos
.filter(todo => todo.id !== id);
return this;
}
// Simulate POST /todos/delete
deleteAllTodo(): TodoService {
this.todos = this.todos
.filter(todo => !todo.done);
return this;
}
// Simulate PUT /todos/:id
updateTodoById(id: number, values: Object = {}): Todo {
const todo = this.getTodoById(id);
if (!todo) {
return null;
}
Object.assign(todo, values);
return todo;
}
// Simulate GET /todos
getAllTodos(): Todo[] {
return this.todos;
}
// Simulate GET /todos/done
getAllDoneTodos(): Todo[] {
return this.todos.filter(todo => todo.done);
}
// Simulate GET /todos/:id
getTodoById(id: number): Todo {
return this.todos
.filter(todo => todo.id === id)
.pop();
}
// Toggle todo done
toggleTodoDone(todo: Todo) {
const updatedTodo = this.updateTodoById(todo.id, {
done: !todo.done
});
return updatedTodo;
}
}
我们已经完成必备的服务,实际的实现细节的方法不是本文的目的所必需的。这是主要表达意思, 我们的业务逻辑集中在服务。
确保我们的逻辑是预期,我们将单元测试添加到src/app/todo.service.spec
中。
Angular-cli已为我们生成测试模板,我们只需要关心如何实现测试:
import {
inject, TestBed
} from '@angular/core/testing';
import { Todo } from './todo';
import { TodoService } from './todo.service';
describe('Todo Service', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [TodoService]
});
});
describe('#getAllTodos()', () => {
it('应该默认返回一个空数组', inject([TodoService], (service: TodoService) => {
expect(service.getAllTodos()).toEqual([]);
}));
it('应该返回所有待办事项', inject([TodoService], (service: TodoService) => {
const todo1 = new Todo({ value: 'Hello 1', done: false });
const todo2 = new Todo({ value: 'Hello 2', done: true });
service.addTodo(todo1);
service.addTodo(todo2);
expect(service.getAllTodos()).toEqual([todo2, todo1]);
}));
});
describe('#save(todo)', () => {
it('应该自动分配一个时间戳的ID', inject([TodoService], (service: TodoService) => {
const todo1 = service.addTodo(new Todo({ value: 'Hello 1', done: false }));
const todo2 = service.addTodo(new Todo({ value: 'Hello 2', done: true }));
expect(service.getTodoById(todo1.id)).toEqual(todo1);
expect(service.getTodoById(todo2.id)).toEqual(todo2);
}));
});
describe('#deleteTodoById(id)', () => {
it('应该删除相应ID的待办事项', inject([TodoService], (service: TodoService) => {
const todo1 = service.addTodo(new Todo({ value: 'Hello 1', done: false }));
const todo2 = service.addTodo(new Todo({ value: 'Hello 2', done: true }));
expect(service.getAllTodos()).toEqual([todo2, todo1]);
service.deleteTodoById(todo1.id);
expect(service.getAllTodos()).toEqual([todo2]);
service.deleteTodoById(todo2.id);
expect(service.getAllTodos()).toEqual([]);
}));
it('如果没有找到使用相应ID的待办事项,则不应删除任何内容', inject([TodoService], (service: TodoService) => {
const todo1 = service.addTodo(new Todo({ value: 'Hello 1', done: false }));
const todo2 = service.addTodo(new Todo({ value: 'Hello 2', done: true }));
expect(service.getAllTodos()).toEqual([todo2, todo1]);
service.deleteTodoById(3);
expect(service.getAllTodos()).toEqual([todo2, todo1]);
}));
});
describe('#updateTodoById(id, values)', () => {
it('应该返回相应ID和更新的数据todo', inject([TodoService], (service: TodoService) => {
const todo = service.addTodo(new Todo({ value: 'Hello 1', done: false }));
const updatedTodo = service.updateTodoById(todo.id, {
value: 'new value'
});
expect(updatedTodo.value).toEqual('new value');
}));
it('如果未找到待办事项应该返回null', inject([TodoService], (service: TodoService) => {
const todo = service.addTodo(new Todo({ value: 'Hello 1', done: false }));
const updatedTodo = service.updateTodoById(2, {
value: 'new value'
});
expect(updatedTodo).toEqual(null);
}));
});
describe('#toggleTodoDone(todo)', () => {
it('应该返回更新后的待办事项与完成状态', inject([TodoService], (service: TodoService) => {
const todo = new Todo({ value: 'Hello 1', done: false });
service.addTodo(todo);
const updatedTodo = service.toggleTodoDone(todo);
expect(updatedTodo.done).toEqual(true);
service.toggleTodoDone(todo);
expect(updatedTodo.done).toEqual(false);
}));
});
});
检查我们的业务逻辑是否有效,我们运行单元测试:
npm test
好了,现在我们有一个可以通过测试的TodoService,是时候实现应用程序的主要部分了。
组件是Angular
最小的单元了,整个Angular
应用就是一个颗组件树构成。
我们生成项目时候,angular-cli默认为我们创建了app-root
根组件,我们现在生产的app-todo-app
,放到app.component.html
里面,删除其他html。
一个组件是有3部分组建成:
模板和样式也可以内联脚本文件中指定。Angular-CLI默认创建单独的文件,所以在本文中我们将使用单独的文件。
import { Component } from '@angular/core';
@Component({
selector: 'app-todo-app',
templateUrl: './todo-app.component.html',
styleUrls: ['./todo-app.component.scss']
})
export class TodoAppComponent implements OnInit {
constructor() { }
}
我们先来添加组件的视图todo-app.component.html
<header class="header">
<h1>Todos</h1>
<form class="todo-form" (ngSubmit)="addTodo()">
<input class="add-todo" [(ngModel)]="newTodo" name="first" placeholder="What needs to be done?" required="required" autocomplete="off">
<button type="submit" class="add-btn" *ngIf="newTodo.length">+</button>
</form>
</header>
<main class="main" *ngIf="todos.length">
<input class="toggle-all" type="checkbox" [(ngModel)]="allDone" (ngModelChange)="toggleAllTodoDone($event)">
<ul class="todo-list">
<li *ngFor="let todo of todos" [class.completed]="todo.done" (dblclick)="editingTodo(todo)">
<div class="view" *ngIf="!todo.edit">
<input class="toggle" type="checkbox" [checked]="todo.done" (click)="toggleDoneTodo(todo)">
<label>{{ todo.value }}</label>
<button class="destroy" (click)="destroyTodo(todo)"></button>
</div>
<input class="edit" *ngIf="todo.edit" appAfterViewFocus [value]="todo.value" #edit (blur)="cancelEditingTodo(todo)" placeholder="What do you need to write?" (keyup.enter)="editedTodo(todo, edit)">
</li>
</ul>
</main>
<footer class="footer" *ngIf="todos.length">
<span class="todo-count">
<strong>{{ todoCount }}</strong>
<span> items left</span>
</span>
<button class="clear-completed" (click)="destroyAllTodo()" [class.clear-operate]="clearCount">
<span>Clear </span>
<strong>{{ clearCount }}</strong>
<span> done items</span>
</button>
</footer>
来简单说一下Angular模板语法:
更多的Angular模板语法,你应该阅读官方的文档模板的语法。
让我们一一介绍:
整个模板分为3个结构块: header,main,footer
;
先说输入创建一个新的待办事项:
<form class="todo-form" (ngSubmit)="addTodo()">
<input class="add-todo" [(ngModel)]="newTodo" name="first" placeholder="What needs to be done?" required="required" autocomplete="off">
<button type="submit" class="add-btn" *ngIf="newTodo.length">+</button>
</form>
不要担心newTodo或addTodo()从哪里来,我们很快就会讲到那里,现在只需要试着去理解的模板语法。
接下来是一段显示待办事项:
<main class="main" *ngIf="todos.length"></main>
在这个部分中,我们循环一个元素来显示每个待办事项:
<li *ngFor="let todo of todos" [class.completed]="todo.done" (dblclick)="editingTodo(todo)"></li>
最后我们显示待办事项的细节为每个ngFor中的待办事项:
<div class="view" *ngIf="!todo.edit">
<input class="toggle" type="checkbox" [checked]="todo.done" (click)="toggleDoneTodo(todo)">
<button class="destroy" (click)="destroyTodo(todo)"></button>
</div>
<input class="edit" *ngIf="todo.edit" appAfterViewFocus [value]="todo.value" #edit (blur)="cancelEditingTodo(todo)" placeholder="What do you need to write?" (keyup.enter)="editedTodo(todo, edit)">
appAfterViewFocus是angular属性型指令,在 Angular 中有三种类型的指令:
注意: 为什么要写这个指令,它作用是什么?它作用是当编辑时,input出现时候,自动获取焦点,不用用户再次去点击输入框,触发获取焦点事件,还有一个更重要的原因,如果没有焦点,失去焦点事件就无法执行,这样输入就不会被隐藏。它的写法很简单:
import { Directive, AfterViewInit, ElementRef } from '@angular/core';
@Directive({
selector: '[appAfterViewFocus]'
})
export class AfterViewFocusDirective implements AfterViewInit {
constructor(private elementRef: ElementRef) { }
ngAfterViewInit() {
this.elementRef.nativeElement.focus();
}
}
.nativeElement
拿到就是一个HTMLElement
, 这里是input这个dom。#edit
是angular模板引用变量;
注意: 这里拿到就是input这个dom,我们可以直接操作获取它上面的属性和方法。
为什么不用双向绑定[(ngModel)]
?
什么是双向绑定: 数据模型(Module)和视图(View)之间的双向绑定。
如果使用双向绑定,我们修改以后,我们数据就直接跟着一起被修改,那么我们要操作取消操作怎么办,增加一个临时的属性来记录它,取消时候就直接回滚,确认就直接清除这个临时属性。
如果不使用双向绑定,我们先赋值给视图,视图修改以后,我们的数据还没有变,取消操作直接取消就行,确认操作,拿到dom引用,把dom的值去更新数据。
关于全选效果
<input class="toggle-all" type="checkbox" [(ngModel)]="allDone" (ngModelChange)="toggleAllTodoDone($event)">
我们来说最后一块统计结构:
<footer class="footer" *ngIf="todos.length">
<span class="todo-count">
<strong>{{ todoCount }}</strong>
<span> items left</span>
</span>
<button class="clear-completed" (click)="destroyAllTodo()" [class.clear-operate]="clearCount">
<span>Clear </span>
<strong>{{ clearCount }}</strong>
<span> done items</span>
</button>
</footer>
clear-operate
模板我们已经介绍完,关于css不是我们重点,这里忽略讲解。
接下来我们该介绍todo-app.component.ts
:
首先需要引入依赖
import { TodoService } from './todo.service';
import { Todo } from './todo';
接下来就是angular特色之一依赖注入,这里不过多介绍。
@Component({
selector: 'app-todo-app',
templateUrl: './todo-app.component.html',
styleUrls: ['./todo-app.component.scss'],
providers: [TodoService]
})
export class TodoAppComponent {
newTodo: string = '';
constructor(
private todoService: TodoService
) { }
注意:providers可以在模块下,也可以在组件里,这也限定他们使用范围。模块里面注册,适用于该模块下所有的组件,服务,指令等;组件里面注册,只适用于当前组件和子组件。
每当视图中输入值的变化,更新组件实例的价值。当组件实例中的值改变,视图中输入元素中的值的变化。
接下来,我们实现我们在视图中使用的所有方法。
/**
* add todo
* @memberof TodoAppComponent
*/
addTodo(): void {
if (!this.newTodo) {
return alert('What do you need to write?');
}
this.todoService.addTodo(new Todo({
value: this.newTodo
}));
this.newTodo = '';
}
/**
* destroy todo
* @memberof TodoAppComponent
*/
destroyTodo(todo: Todo): void {
this.todoService.deleteTodoById(todo.id);
}
/**
* destroy done todo
* @memberof TodoAppComponent
*/
destroyAllTodo(): void {
if (!this.clearCount) {
return;
}
if (!confirm('Do you need to delete the selected one?')) {
return;
}
this.todoService.deleteAllTodo();
}
/**
* toggle todo done
* @memberof TodoAppComponent
*/
toggleDoneTodo(todo: Todo): void {
this.todoService.toggleTodoDone(todo);
}
/**
* toggle all todo done
* @memberof TodoAppComponent
*/
toggleAllTodoDone(event: boolean): void {
this.todos.forEach(item => item.done = event);
}
/**
* editing todo
* @memberof TodoAppComponent
*/
editingTodo(todo: Todo): void {
if (!todo.done) {
todo.edit = true;
}
}
/**
* cancel editing todo
* @memberof TodoAppComponent
*/
cancelEditingTodo(todo: Todo): void {
todo.edit = false;
}
/**
* edited todo
* @memberof TodoAppComponent
*/
editedTodo(todo: Todo, input: HTMLInputElement): void {
todo.value = input.value;
todo.edit = false;
}
/**
* get todos
* @memberof TodoAppComponent
*/
get todos(): Todo[] {
return this.todoService.getAllTodos();
}
/**
* get todos all done be get todos
* @memberof TodoAppComponent
*/
get allDone(): boolean {
const todos = this.todos;
return todos.length && todos.filter(item => item.done).length === todos.length;
}
/**
* get todos all not done number
* @memberof TodoAppComponent
*/
get todoCount(): number {
return this.todos.filter(item => !item.done).length;
}
/**
* get todos all done number
* @memberof TodoAppComponent
*/
get clearCount(): number {
return this.todos.filter(item => item.done).length;
}
newTodo
属性值这里有4个
get
,在Typescript
叫存取器
, 通过getters/setters来截取对对象成员的访问。 它能帮助我们有效的控制对对象成员的访问。这里只要控制器里面值发送变化,模板就会更着改变,很方便。
注意:无论是服务还是组件里,都是需要熟练使用原生数据操作方法,比如数组,对象,字符串等。这里主要使用数组相关方法,如果你对这些还不熟,请赶紧去提升一下。es6以后又新增很多方法,操作数据会更方便。angular是数据驱动,如果不会玩转操作,基本很难继续下去。
功能很小,应该不言自明todoService我们代表所有的业务逻辑。
委派业务逻辑服务是良好的编程实践,因为它能让我们集中管理和测试业务逻辑。
我们还为大家编写一个E2E
测试用例,可以查阅e2e
文件里面文件,运行命名npm run e2e
即可。
github给我们每个项目都运行有一个预览页面,我们叫它github-pages
。
ng build --prod --base-href https://jiayisheji.github.io/angular-todolist/
注意:github-pages预览地址是 你的用户名.github.io/你的项目名/
--base-href:修改html里面的base
的href
属性,如果有路由必须要使用的。
gh-pages
分支git add -f dist && git commit -n -m \"(release): git-pages\" && git subtree push --prefix dist origin gh-pages
注意:就是打包以后的目录,需要特别注意一下,angular-cli6是一个多工程的脚手架,打包后生成的是dist/angular-todolist
,我们最终需要上传是这个文件夹里面的内容,那么就需要改脚本。
git add -f dist && git commit -n -m \"(release): git-pages\" && git subtree push --prefix dist/angular-todolist origin gh-pages
git push --follow-tags origin master
我在所有项目里面都会用到它
"_github": "ng build --prod --base-href https://jiayisheji.github.io/angular-todolist/",
"_publish": "git add -f dist && git commit -n -m \"(release): git-pages\" && git subtree push --prefix dist/angular-todolist origin gh-pages",
"git-pages": "npm run _github && npm run _publish"
"release":"git push --follow-tags origin master"
运行命令
npm run git-pages
npm run release
代码提交需要去github,项目下设置里面开启github-pages
.
GitHub Pages
gh-pages branch
save
.就好出现你的
Github Pages
链接,你可以做代码演示,分享给其他小伙伴观看,也可以做静态blog
。
注意:一旦启用就不能再选择none,只能你删除项目。你删除分支,访问就会出现404。
毫无疑问,Angular是一个平台。一个非常强大前端框架!
我们讨论了许多让我们回顾所学在本文中:
麻雀虽小,五脏俱全,Todo应用看起来,功能很简单,其实它里面功能可以做很多衍生,都是我们平常业务需要的,比如购物车, 全选等。
这个有个类似的变种需求功能:我也不知道叫什么名字,antd里面叫穿梭框
。这就留个大家一个作业吧。
如果你不知道如何下手,可以跟我交流。
建立现代前端项目的一个重要任务是为每个不同的编程体验定义一个可伸缩的、长期的、不受未来影响的文件夹结构和命名准则。
尽管有人认为这是一个简单而次要的方面,但它往往隐藏着比看起来复杂的多问题。即使大多数时候并没有完美的解决方案,我们也可以探索一些行业最佳实践,以及我认为最有意义的一些东西。
自从Angular 4
发布以来,我一直使用Angular
在企业中开发,大大小小项目开发10余个,一点一滴摸索和实践,不断优化调整项目结构。最终参考 风格指南,绘制一张比较满意的文件夹结构图,一直在项目中实践运用。
在本文中,我将介绍:
Angular
和Typescript
实体在建立新代码库时,我经常做的第一件事是思考和定义构成我的项目的编程实体。作为Angular的开发者,我们已经非常了解其中的一些了:
正如框架的文档所建议的,每次我们创建这些实体时,我们都会在文件名后面加上实体的名称。命名规范
因此,如果我们创建一个类名为filterPipe
的管道,我们将把它的文件命名为filter.pipe
。如果我们有一个叫ButtonComponent
的组件,我们想要它的文件button.component.ts
,button.component.html
和button.component.scss
。
如果不首先讨论Angular模块,我们就不能讨论Angular项目的结构。在Angular里主要靠模块来管理维护依赖关系。
在Angular环境中,模块是一种对相关组件、管道和服务进行分组的方式。这个模块集被分组来组成应用程序,是的,就像它是乐高积木一样。模块可以隐藏或导出组件(管道、服务等)。导出的组件可以被其他模块使用,被模块隐藏的组件只能被自己使用。
在Angular中,这种模块化称为NgModule
。 每个应用程序均由至少一个NgModule
类组成,该类是应用程序的根模块。 根模块默认情况下称为AppModule
。
由于Angular的应用程序是由导入其他的模块组成的,它们自然会成为构成Angular项目的根文件夹。每个模块将包含所有其他Angular实体,这些实体都包含在它们自己的文件夹中。
假设我们正在构建一个电子商务应用程序,并创建了一个购物车功能模块,它的结构如下所示:
正如您可能注意到的,我倾向于区分容器(智能)(containers\smart)和组件(愚蠢)(components \dumb),所以我将它们放在不同的文件夹中,但这并不是我所提倡的
但是,如果某个东西需要在其他地方重用怎么办?
在本例中,我们创建了一个共享模块SharedModule,它将托管所有共享实体,这些实体将被提供给项目的每个模块。
SharedModule通常由一些实体组成,这些实体在一个项目中不同的模块之间共享,但在项目外部通常不需要它们。当我们遇到可以跨不同团队和项目重用的服务或组件时,并且这些服务或组件在理想情况下不会经常更改,那么我们可能需要构建一个Angular库。
在Angular里面有几个比较重要模块归纳:
AppModule
HttpClientModule
等模块、单例服务、单实例组件),核心模块还可以用于导出根模块中需要的任何第三方模块,这个想法主要是让根模块尽可能的精简。Angular 典型的模块结构
以前版本会有一些篇幅去强调核心模块重要性,因为angular单例服务原因,核心模块主要做全局服务申明,angular6以后版本注册服务使用
providedIn
更方便提供全局服务。核心模块依旧是一个很好实践,把管理初始化应用的工作交给核心模块吧,减轻根模块的工作。
如果你在Angular中使用Typescript(ps:早期Angular文档可以使用Typescript、Javascript、Dart,现在只剩Typescript,主推Typescript),我想你也会这样做,你也必须考虑到Typescript自身强大的实体,我们可以利用它们来构建一个结构化的、写得很好的代码库。
这里有一个Typescript实体的列表,你将在你的项目中使用最多:
我建议为每个后端实体创建一个匹配的Typescript文件,它包括:
小技巧:Typescript里classes也是可以直接作为类型,还可以直接new,如果你这个类不new,尽量不要用了,占地方。
我喜欢将这些实体放到一个特性模块中,当然它们也可以有自己的文件夹,可以其称为core
,但这在很大程度上取决于你和你的团队。
有时,我们将针对公司内多个团队共享的微服务进行开发,或者多个特性模块需要共享实体。在类似的情况下,我认为构建一个Angular库来托管匹配的类、接口和枚举是有意义的,而不是在本地开发模块。
当您使用高度可重用的服务或组件(可以分为服务模块和窗口小部件模块)时,您可能希望将这些模块构建为Angular库,可以在它们自己的存储库中创建,也可以在更大的monorepo
中创建。
多亏了强大的Angular CLI,我们可以用这个简单的命令轻松生成Angular库,这些库将构建在一个名为projects的文件夹中
ng generate library my-lib
有关Angular库的完整描述,请参阅Angular.cn的官方文档。
与本地模块相比,使用库具有一些优势:
当然,也有一些缺陷:
NPM
发布并在项目外部构建的,则需要保持项目与库的最新版本同步例如:假设ButtonComponent
使用所有团队都使用的按钮UI库,我们可能希望共享我们的抽象,以避免许多库实际上在做通常的基础工作。
因此,我们创建了一个称为按钮UI库,并将其作为@ui-kit/button
发布到NPM。
我们在Github上面看到的很多优秀开源Angular资源库,大部分都是使用这种方式完成的。
但是,monorepos
和microfrontends
呢?
这可能需要较长的文章,但是如果不提及其他两种方式,我们就不能谈论企业级项目。
我们这里可以简单介绍一下它们:
未完待续...
你可能见过术语 抽象语法树
,AST(Abstract Syntax Trees)
,或者甚至在计算机科学课程中了解过它们。
表面上与我们前端工程师需要做的工作的内容无关,其实相反,抽象语法树在前端生态系统中无处不在。理解抽象语法树并不是成为一名高效或成功的前端工程师的必要条件,但它可以解锁一套在前端开发中具有许多实际应用的新技能。
在最简单的形式中,抽象语法树是一种表示代码以便计算机能够理解的方法。我们编写的代码是一个巨大的字符串,只有在计算机能够理解和解释这些代码的情况下,它们才能发挥作用。
抽象语法树是一种树状的数据结构。树数据结构从根值开始。然后,根可以指向其他值,这些值又指向其他值,以此类推。这就开始创建一个隐式的层次结构,而且这也是一种很好的方式来表示源代码,计算机可以很容易地解释它。
例如,假设我们有代码片段 2 +(4 * 10)
。要计算此代码,首先执行乘法,然后执行加法。由于添加是这个层次结构中发生的最后一件事或最高的一件事,所以它将是根。然后它指向另外两个值,左边是数字 2
,右边是另一个方程。这次是乘法,它也有两个值,左边是 4
,右边是 10
。
使用抽象语法树的一个常见例子是在编译器中。编译器接受源代码作为输入,然后输出另一种语言。这通常是从高级编程语言到低级编程语言,比如机器代码。前端开发中的一个常见示例是转译,其中现代 JavaScript 被转译为旧版本的 JavaScript 语法。
首先,我们可能每天都依赖于构建在抽象语法树之上的工具。一些常见的依赖抽象语法树的前端构建工具或编译器的例子有 webpack, babel 和 swc,然而,它们并不是独立地构建工具。像 Pretier(代码格式化器)、ESLint(代码检查器)或 Jscodesshift(代码转换器)这样的工具有不同的目的,但它们都依赖抽象语法树,因为它们都需要直接理解和与源代码一起工作。
在不理解抽象语法树的情况下,可以使用这些工具中的大多数,但是有些工具希望我们理解 AST
以用于更高级的用途。这使得能够以可靠和自动的方式与代码交互,并且前面提到的许多工具在内部使用这些或类似的工具。这允许在静态分析/审核代码中创建完全自定义功能,使动态代码转换,或者我们可能在大代码库中解决任何问题。
虽然理解抽象语法树并不是成为一名高效和成功的前端工程师的必要条件,但具备基本的理解可以提升您维护持续发展的大型代码库的能力,并更容易地与依赖它们的常用工具进行交互。抽象语法树能够“大规模地”与代码库交互。
用 JSX
编写的 React
应用程序。使用 SASS
编写的 style
。使用 Pug
编写的 E-mail
模板。这类项目包含一个编译步骤,该步骤将使用浏览器无法理解的语言编写的源代码转换为浏览器可以解析和执行的 HTML/CSS/JavaScript
代码。
每当我们告诉编译器构建一个 React 应用程序时,我们都希望编译器使用 React 函数调用将 JSX 源代码处理并转换为纯 JavaScript 代码。通常,我们将编译器视为一个黑盒,很少查看它的内部,看看它究竟如何执行这种转换。编译器背后的魔力在于它用来传达源代码结构模式的数据结构:抽象语法树(AST)。
通过分析源代码的语法并将其分解为其组成的标记(例如关键字、字面量、操作符等),我们可以将代码表示为树状数据结构。能够使用抽象语法树泛化源代码的构造和规则,为编译器在转换代码时提供了一个高级模型。这取决于编译器来遍历抽象语法树(通过深度优先搜索遍历算法),从它中提取信息和等效功能的输出代码,但以不同的语言编写或针对性能进行优化。在某些方面,我们可能会说编译器的抽象语法树的作用类似于算法的伪代码。
抽象语法树并不局限于编译器。事实上,它们可以应用于各种用例中,例如:
function
而不是 fucntion
)。switch/case
。下面,我将介绍实战案例:
React 中的 JSX 通过使用类似于 HTML 标记的语法来描述组件的 UI。与其他基于 JavaScript 的模板语言一样(Ejs,Handlerbars 和 Pug)。JSX 需要一个编译器将代码编译为 JavaScript,以便在浏览器上运行。
例如,浏览器无法识别这样的代码:
<ul>
{props.items.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
浏览器只支持JavaScript语言的语法(即ECMAScript规范的官方特性)。因此,JavaScript 代码中的标记语法是无效的,并将导致运行时错误。为了避免这个问题,我们必须将上面的代码编译成浏览器能够理解的纯 JavaScript 代码:
React.createElement("ul", {}, props.items.map(({ id, name }) => (
React.createElement("li", { key: id }, name)
)));
JSX
充当 React.createElement
方法的语法糖。对于具有大量嵌套元素的较大组件,JSX 提供了更强的可读性和清晰度。编译器要将JSX转换为JavaScript,原始源代码必须是:
如果我们想预览由不同解析器生成的抽象语法树,那么我们可以在 AST Explorer 编辑器中输入上面的 JSX 代码。
大多数基于javascript的抽象语法树都遵循 ESTree 规范,它定义了属性的语法分类。下面是由JSX代码的解析器生成的抽象语法树(JSON格式)的精简版本。
[
{
"type": "ExpressionStatement",
"expression": {
"type": "JSXElement",
"openingElement": {
"type": "JSXOpeningElement",
"name": {
"type": "JSXIdentifier",
"name": "ul"
}
},
"closingElement": {
"type": "JSXClosingElement",
"name": {
"type": "JSXIdentifier",
"name": "ul"
}
},
"children": [
{
"type": "JSXExpressionContainer",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "props"
},
"property": {
"type": "Identifier",
"name": "items"
}
},
"property": {
"type": "Identifier",
"name": "map"
}
},
"arguments": [
{
"type": "ArrowFunctionExpression",
"params": [
{
"type": "ObjectPattern",
"properties": [
{
"type": "ObjectProperty",
"key": {
"type": "Identifier",
"name": "id"
},
"value": {
"type": "Identifier",
"name": "id"
}
},
{
"type": "ObjectProperty",
"key": {
"type": "Identifier",
"name": "name"
},
"value": {
"type": "Identifier",
"name": "name"
}
}
]
}
],
"body": {
"type": "JSXElement",
"openingElement": {
"type": "JSXOpeningElement",
"name": {
"type": "JSXIdentifier",
"name": "li"
},
"attributes": [
{
"type": "JSXAttribute",
"name": {
"type": "JSXIdentifier",
"name": "key"
},
"value": {
"type": "JSXExpressionContainer",
"expression": {
"type": "Identifier",
"name": "id"
}
}
}
]
},
"closingElement": {
"type": "JSXClosingElement",
"name": {
"type": "JSXIdentifier",
"name": "li"
}
},
"children": [
{
"type": "JSXExpressionContainer",
"expression": {
"type": "Identifier",
"name": "name"
}
}
]
}
}
]
}
}
]
}
}
]
在这里查看完整的抽象语法树。
下面是这个抽象语法树的可视化:
注意:叶节点代表代码本身的实际标识符、关键字和文字。其余的父节点表示解析器发现的令牌类型。
下面的图表说明了编译器是如何工作的:
负责为 JSX 代码输出上述抽象语法树的解析器是 Babel 解析器,它为 Babel 编译器解析源代码。Babel 使用了基于 ESTree 规范的抽象语法树。使用 JavaScript Next
和 JSX 编写的 React 应用程序依赖于 Babel 编译器将代码转换为与我们选择的目标浏览器兼容的 JavaScript。
通过插件,Babel 将特定的代码转换应用到源代码中。例如,如果要使用箭头函数语法,但需要我们的应用程序在 Internet Explorer
中运行,然后在配置文件中启用 Babel 插件 @Babe/Plugin-Transform-arrow-functions。每当 Babel 遇到箭头函数语法的任何示例时,它都会使用此插件转换它们。
对于 JSX 代码,使用 Babel 插件 @babel/plugin-transform-react-jsx 解析并将 JSX 代码转换为 React 函数调用。
为了理解插件是如何工作的,我们必须先看看 Babel 是如何解析 JSX 代码的。Babel 不仅仅是一个独立包。相反,它的核心功能分为几个不同的包:
Babel 语法分析器 @babel/parser
提供了一种 parse
方法,可读取源代码(作为字符串),并从中生成抽象语法树。
下面是一个简单的程序,用于打印 JSX 代码片段的抽象语法树(JSON格式):
const { parse } = require("@babel/parser");
const code = `
<ul>
{props.items.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
`;
const ast = parse(code, {
plugins: ["jsx"]
});
console.log(JSON.stringify(ast, null, 2));
注意:需要 npm install @babel/parser -D
。
解析器选项插件告诉 Babel 解析器("jsx," "flow" 或 "typescript")启用。Babel 解析器直接支持 JSX、Flow 和 TypeScript。
Babel 插件 @babel/plugin-transform-react-jsx
通过这种确切的方式解析 JSX 代码。它委托将 JSX 代码解析为 @babel/plugin-syntax-jsx:
import { declare } from "@babel/helper-plugin-utils";
export default declare(api => {
api.assertVersion(7);
return {
name: "syntax-jsx",
manipulateOptions(opts, parserOpts) {
if (
parserOpts.plugins.some(
p => (Array.isArray(p) ? p[0] : p) === "typescript",
)
) {
return;
}
parserOpts.plugins.push("jsx");
},
};
});
这个插件所做的就是修改解析器的选项。declare
辅助方法保持了与 Babel v6
的向后兼容性。该插件会检查 TypeScript 插件(@babel/plugin-transform-typescript)是否已经运行。如果是这样,那么插件什么也不做,因为代码(大概是用 TSX
, JSX 的 TypeScript 变体) 已经被 TypeScript 插件解析和转换过了。如果没有 TypeScript插件,那么该插件就会像前面的例子一样,简单地把 jsx
插件添加到解析器的插件选项中。
@babel/plugin-transform-react-jsx
和 @babel/plugin-syntax-jsx
插件包含在 @babel/preset-react 解析器中,这是最常用的 Babel 预设,用于编译 React 应用程序。
注意:
preset
是插件的集合。你只需要列出一个包含这些插件的preset
,而不是手动列出你想为 Babel 编译器启用的单个插件。
实际上,create-react-app 使用这个 preset
来编译React应用程序。
使用 babel 插件 @babel/plugin-transform-react-jsx
将 JSX 代码转换:
npm install --save-dev @babel/core @babel/cli
@babel/plugin-transform-react-jsx
或 @babel/preset-react
,其中包括此插件:npm install --save-dev @babel/plugin-transform-react-jsx # Or @babel/preset-react.
.babelrc.json
文件,使用插件配置 Babel。{
// Uncomment the below if you use the preset.
// "presets": [
// "@babel/preset-react"
// ],
"plugins": [
"@babel/plugin-transform-react-jsx"
]
}
package.json
中,添加一个 npm scripts
以编译 JSX 代码。{
"scripts": {
"build": "babel <file_with_jsx_code.jsx> > build.js"
}
}
就这样,完了。
当你为下面的 JSX 代码运行这个 npm run build
时:
/*#__PURE__*/
React.createElement("ul", null, props.items.map(({
id,
name
}) => /*#__PURE__*/React.createElement("li", {
key: id
}, name)));
随着 JavaScript 语言的不断发展和新的规范和建议的引入,Babel 不断推出了新的插件来支持这些特性,即使目标浏览器中尚未存在,新插件也会添加支持这些功能。
考虑我们可以使用抽象语法树来自动执行代码审核和语法检查的方法。
探索 Babel 的插件和预设的生态系统,以便在我们的项目中使用令人兴奋的新功能,无论浏览器支持如何,我们都可以在项目中的可选链操作符。
今天就到这里吧,伙计们,玩得开心,祝你好运。
最近一个公司官网需要做后台管理,自告奋勇伸出手接下这活。我本来计划技术栈是 Nestjs
+ MongoDB
,看我的github的人应该发现,我只会这个。和运维一番沟通后,他说不支持 MongoDB
,仅支持 Mysql
。
第一次使用
Mysql
这是一段神奇的开始...
在 nestjs 官网文档有个专门的 database 板块。
首推就是 Typeorm ,这篇也算是一个入门教程。(ps:里面也有无尽的坑)
nestjs 也有其他几个操作数据库的的 orm:
以上都是操作 Mysql
的特有 orm,有些 nestjs 做了专门集成封装模块,方便使用。
既然官网教程首推 Typeorm
,那我们就用上。
我电脑里面装了一个 Navicat Premium
,可以可视化多种数据的图形化界面。
关于 Mysql
,你可以选择 Docker
安装,也可以直接下载安装文件安装。推荐 Docker
。
本来我也打算 Docker
安装的,运维给我了一个服务器的 Mysql
的地址和账号密码。那就直接连接就行了。
因为不会 Mysql
语句,那就傻瓜式图形界面创建数据库吧。
也不知道怎么创建,好歹公司后台都是Java,用的全是 Mysql
,找个人问下,就解决问题。
图形化界面可以自动生成 Mysql
语句:
CREATE DATABASE `test` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_unicode_ci';
连接远程 Mysql
搞定,创建数据库搞定,接下来就是程序连接和建表操作。
根据 nestjs 官网文档,一顿操作下来完美连接运行。
关于 Mysql
的表,在 Typeorm
对应叫 Entity
。 Entity
里面字段列和数据库里面的是一一对应的。
换句话来说,在数据库里面建表,要么手动建,设计表结构,另外一种就是 Typeorm
帮我们自动建。
手动建,我肯定搞不懂,自动建那就比较简单,只需要看
Typeorm
文档即可。
Typeorm
载入 Entity
有三种方式:
import { User } from './user/user.entity';
TypeOrmModule.forRoot({
//...
entities: [User],
}),
用到哪些实体,就逐一在此处引入。缺点就是我们每写一个实体就要引入一次否则使用实体时会报错。
这里需要说一下,我用的 Nx 这个工具,它做
nodejs
打包用的是webpack
,意思就是说会打包到一个main.js
。我只能使用这种模式。
TypeOrmModule.forRoot({
//...
autoLoadEntities: true,
}),
自动加载我们的实体,每个通过 TypeOrmModule.forFeature()
注册的实体都会自动添加到配置对象的 entities
数组中, TypeOrmModule.forFeature()
就是在某个 service
中的 imports
里面引入的,这个是比较推荐。
TypeOrmModule.forRoot({
//...
entities: ['dist/**/*.entity{.ts,.js}'],
}),
这是官方推荐的方式。
自动建表还有一个配置需要设置:
TypeOrmModule.forRoot({
//...
entities: ['dist/**/*.entity{.ts,.js}'],
synchronize: true,
}),
问题就处在 synchronize: true
上,自动建表,你修改 Entity
里面字段,或者 *.entity{.ts,.js}
的名字,都会自动帮你修改。
警告:线上一定要关了,不然直接提桶跑路,别挣扎了。
正确姿势是使用 typerom migration
方案:
migrations 会每次记录数据库更改的版本及内容,以及如何回滚,对于数据处理的更多策略就需要团队根据需求去开发。同时修改的entity 保证新的开发人员可以无需 migrations 即可直接使用。
nestjs 使用 migration 很麻烦,所以官网文档里面都没有写,migrations,大写的懵逼。
把放在 TypeOrmModule.forRoot
里的配置独立出来 ormconfig.ts
//
export const config: TypeOrmModuleOptions = {
type: 'mysql',
host: process.env.host,
port: parseInt(process.env.port),
username: process.env.username,
password: process.env.password,
database: process.env.schema,
entities: [User], // 也可以使用: [__dirname + '/**/*.entity.{ts, js}']
// 根据自己的需求定义,migrations
migrations: [UserInitialState],// 也可以使用: ['src/migration/*{.ts,.js}']
cli: {
migrationsDir: 'src/migration'
},
synchronize: true,
}
注意:这里不能使用
@nestjs/config
模块动态获取,需要使用process.env
去获取。
建立 cli 配置 ormconfig-migrations.ts
import {config} from './ormconfig';
export = config;
TypeOrmModule.forRoot
里引入 ormconfig.ts
配置
import {config} from './ormconfig';
TypeOrmModule.forRoot(config);
在 package.json
里面增加 scripts
:
...
"typeorm:cli": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli -f ./ormconfig-migrations.ts",
"migration-generate": "npm run typeorm:cli -- migration:generate -n"
"migration-run": "npm run typeorm:cli -- migration:run -n"
然后就可以愉快的玩耍了。
在 Typeorm
提供的主键的装饰器 PrimaryGeneratedColumn
,里面支持四种模式:
基本所有教程文章都是用默认的 increment
。
然后问题就出现了,使用 increment
在插入数据会出现错误:
Typeorm error 'Cannot update entity because entity id is not set in the entity.'
这个问题困扰我很久,搜索这个问题,也没有得到最终的解答
一开始找到的答案是 .save(entity, {reload: false})
满心欢喜插入了数据库,发现数据库里面的数据 id
是 0
。
一开始不懂为什么,按道理我设置自增id,起始位置1开始,那么第一条应该是1才对,应该这个是不对的。
我又插入一条数据:
Mysql error ‘Duplicate entry '0' for key 'PRIMARY'
问题原因:我用的 int
,它的默认值就是 0。为什么每次会插入默认值。
带着这个疑惑,寻找解决方案,配置里面有个 logging: true,
我把它打开,可以输出执行的 Mysql
语句。
然后使用 .save(entity, {reload: false})
插入数据:
INSERT INTO `users`(`id`, `username`, `password`, `created_at`, `updated_at`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) --PARAMETERS: ["jiayi", "123456"]
虽然看不懂是什么,大概理解一下,第一个括号插入的字段名,第二括号就是对应的值,DEFAULT
就是 Mysql
默认值,也就是我们设置的 default
属性。?
就和后面的参数一一对应。
既然 Typeorm
插入有问题,那我是不是可以直接用 Mysql
语句插入,就算玩挂了,也就是一个删库跑路。
使用 Navicat Premium
执行 Mysql
,网上找了一下简单的 Mysql
语句:
show databases;
use test
# Database changed 表示成功
和
MongoDB
操作差不多。
然后我在执行插入语句:
INSERT INTO `users`(`id`, `username`, `password`, `created_at`, `updated_at`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) --PARAMETERS: ["jiayi", "123456"]
还是一样报错 ‘Duplicate entry '0' for key 'PRIMARY'
思考:id 是自增的应该不需要传递 id,这个字段吧。带着个这个猜想:
INSERT INTO `users`(`username`, `password`, `created_at`, `updated_at`) VALUES (?, ?, DEFAULT, DEFAULT) --PARAMETERS: ["jiayi", "123456"]
成功插入数据,真是激动万分。
这锅就是 Typeorm
的坑了。
那需要解决问题, Typeorm
提供的可以直接写语句的 query
,对于我这种完全不会人肯定无法搞定,那就换个思路解决。
Typeorm
会自动给 id
一个默认值 DEFAULT
, Mysql
就会给它默认一个 0。那如果我不设置默认, Mysql
应该没有 undefined
,这种玩意,但是有一个 null
,和 js
意思一样,都表示空,那我给 id
设置 null
。
INSERT INTO `users`(`id`, `username`, `password`, `created_at`, `updated_at`) VALUES (null, ?, ?, DEFAULT, DEFAULT) --PARAMETERS: ["jiayi", "123456"]
又成功插入数据。
意思就是说我在 .save(entity, {reload: false})
插入数据之前,设置 entity.id = null
即可。
每次创建都是去设置太麻烦了,
@Entity('users')
export class User {
@PrimaryGeneratedColumn({
type: 'int',
})
id: number = null;
...
}
给 Entity
类型,设置默认值,这个默认值和数据库 default
是有区别的,这是实例属性值。
最后发现设置默认值 null
,不光解决 Mysql
语句重复添加问题,还解决了 Typeorm
报错问题。
Typeorm
插入最终都会 https://github.com/typeorm/typeorm/blob/master/src/query-builder/ReturningResultsEntityUpdator.ts 里的 ReturningResultsEntityUpdator.insert
方法:
这是错误来源代码:
const entityIds = entities.map((entity) => {
const entityId = metadata.getEntityIdMap(entity)!
// We have to check for an empty `entityId` - if we don't, the query against the database
// effectively drops the `where` clause entirely and the first record will be returned -
// not what we want at all.
if (!entityId)
throw new TypeORMError(
`Cannot update entity because entity id is not set in the entity.`,
)
return entityId
})
通过 https://github.com/typeorm/typeorm/blob/master/src/metadata/EntityMetadata.ts 里的 EntityMetadata.getValueMap()
静态方法获取。
在通过 https://github.com/typeorm/typeorm/blob/master/src/metadata/ColumnMetadata.ts 里的 ColumnMetadata.getEntityValueMap()
实例方法:
if() {}
else {
if () {}
else {
// 如果不设置 null ,默认就直接 undefined
if (entity[this.propertyName] !== undefined && (returnNulls === false || entity[this.propertyName] !== null))
return { [this.propertyName]: entity[this.propertyName] };
return undefined;
}
}
设置默认值实例属性
id = null
最终就解决报错问题。
无论使用什么技术都没有一帆风顺的,总是有无尽的坑需要填,各方面原因凑在一起就引起未知的坑,我们需要掌握排坑技巧,不断提升解决问题的能力。
今天就到这里吧,伙计们,玩得开心,祝你好运。
在说postcss-cssnext之前一定要说一下postcss。
PostCSS 是使用 javascript 插件转换 CSS 的后处理器。PostCSS 本身不会对你的 CSS 做任何事情,你需要安装一些 plugins 才能开始工作。这不仅使其模块化,同时功能也会更强。
它的工作原理就是解析 CSS 并将其转换成一个 CSS 的节点树,这可以通过 javascript 来控制(也就是插件发挥作用)。然后返回修改后的树并保存。它与 Sass(一种预处理器)的工作原理不同,你基本上是用一种不同的语言来编译 CSS 。
官网解释: PostCSS cssnext是一个postcss插件,帮助你今天使用的是最新的CSS语法。它将新的CSS规格转换成更兼容的CSS,所以你不需要等待浏览器的支持。可以逐字写将来证明CSS,忘记旧的预处理器特定语法。
简单说它是什么,它就是Polyfill,写js的同学都非常了解了。
cssnext口号就是Use tomorrow’s CSS syntax, today.
你没有看错,这不是js定义变量的var,这是css4的提案。他和js用法不一样,js是定义一个变量,它是引用。他需要借助一个:root选择器,相信看过css3都懂的。
栗子:
写法:
:root {
--mainColor: red;
}
a {
color: var(--mainColor);
}
编译后:
a {
color: red;
}
比较简单,和scss,less定义变量都是一样的。
那么可以计算吗?可以,不过不能直接需要用到后面的一个功能。后面介绍
这个是可以定义一个代码块,来循环引用。也要借助:root选择器
栗子:
写法:
:root {
--box: {
margin: 0;
padding: 0;
}
}
body {
@apply --box;
}
编译后:
body {
margin: 0;
padding: 0;
}
可以循环引用,
栗子:
写法:
:root {
--bg: #fff;
--box: {
margin: 0;
padding: 0;
};
--reset: {
@apply --box;
background-color: var(--bg);
}
}
body {
@apply --reset;
}
编译后:
body {
margin: 0;
padding: 0;
background-color: #fff;
}
注意:
:root
里面的定义的属性和值结尾和其他css样式属性和值一样,要以分号结尾
相信做过响应式的同学都玩过media queries,如果没有玩过它,我只能呵呵了,它的用法就不解释了,省略500字。
先看栗子:
写法:
@custom-media --small-viewport (max-width: 30em);
@media (--small-viewport) {
}
编译后:
@media (max-width: 30em) {
}
看基本用法和自定义属性差不多。如果就这么写好像很弱鸡的功能呀,还可以写更牛掰的功能。
写法:
@custom-media --only-medium-screen (width >= 500px) and (width <= 1200px);
@media (--only-medium-screen) {
}
编译后:
@media (min-width: 500px) and (max-width: 1200px) {
/* your styles */
}
其实和你原生写法没有啥差别,唯一差别是自定义属性一样把查询规则都提取出来了
如果没有记错,在jq里面可以自定义选择器,好像也是一个很牛瓣的功能。
先看栗子:
写法:
:root {
--bg: #fff;
--bg-enter: #ccc;
}
@custom-selector :--button button, .button;
@custom-selector :--enter :hover, :focus;
:--button {
background-color: var(--bg);
}
:--button:--enter {
background-color: var(--bg-enter);
}
编译后:
button,
.button {
background-color: #fff;
}
button:hover,
.button:hover,
button:focus,
.button:focus {
background-color: #ccc;
}
发现对比以后,就知道功能是干什么用的,其实这个功能就是处理 群组选择器
帮我们省了不少事。
工作中这个栗子用的最多:
@custom-selector :--heading h1, h2, h3, h4, h5, h6;
:--heading {
font-weight: bold;
}
编译结果就不说了。
构建一个功能齐全的自定义表单控件,兼容模板驱动和响应式表单,以及所有内置和自定义表单验证器。
Angular Forms 提供 FormsModule
和 ReactiveFormsModule
模块自带了一系列内置指令,这些指令使得将标准 HTML
表单元素(如 input
、select
、textarea
等)绑定到表单组变得非常简单。
除了这些标准的 HTML
表单元素,我们可能还想使用自定义表单控件,比如下拉框、选择框、切换按钮、滑块或许多其他类型的常用自定义表单组件。
在本文中,我们将学习如何使用现有的自定义表单控件组件,并使其与 Angular Forms API
完全兼容,以便该组件能够参与父表单验证和值跟踪机制。
这意味着:
ngModel
把自定义组件插入到表单中formControlName
或 formControl
将自定义组件添加到表单中我们将在本文中构建一个简单的数量选择器组件,它可以用来增加或减少一个值。该组件将成为表单的一部分,如果计数器不匹配有效范围,该组件将被标记为错误。
新的自定义表单控件将完全兼容所需的 Angular
内置表单验证器(required
,max
),以及任何其他内置或自定义验证器。
我们还将在本文中学习如何创建可重用的嵌套表单,这些表单部分可以在许多不同的表单中重用。
我们还将在本文中构建一个嵌套表单的简单示例:包含地址子表单。通过学习如何创建可重用的嵌套表单,这些表单可以在许多不同的表单中重用。
因此,废话不多说,让我们开始学习如何创建自定义表单控件。
为了了解如何构建自定义表单控件,我们需要首先了解 Angular
内置表单控件是如何工作的。
Angular
内置表单控件主要针对原生 HTML
表单元素,例如 input
、select
、textarea
、checkbox
等。
下面是一个简单表单的示例,其中有几个普通的 HTML 表单字段:
<div [formGroup]="form">
标题:<input placeholder="输入标题" formControlName="name">
<label>是否发布<input type="checkbox" formControlName="publish"></label>
描述:<textarea placeholder="输入描述" formControlName="description"></textarea>
</div>
正如我们所看到的,我们在这里有几个标准的表单控件,并使用了 formControlName
属性。这就是 Angular
表单绑定到标准 HTML
表单元素的方式。
每当用户与表单输入交互时,表单值和有效性状态将自动重新计算。
那么,这一切是如何运作的呢?
在底层,Angular
表单模块会给每个原生 HTML
元素应用一个内置的 Angular
指令,该指令将负责跟踪字段的值,并将其反馈给父表单。
这种类型的特殊指令被称为控制值访问器指令(control value accessor directive)。
以上面表单的复选框字段为例。响应式表单模块中有一个内置指令,专门用来跟踪复选框的值。
下面是该指令的简化代码: checkbox_value_accessor
@Directive({
selector:
'input[type=checkbox][formControlName],
input[type=checkbox][formControl],
input[type=checkbox][ngModel]',
})
export class CheckboxControlValueAccessor implements ControlValueAccessor {
....
}
正如我们从选择器中看到的,这个值跟踪指令只针对 HTML
中 input
元素 checkbox
类型,但只有当 ngModel
、formControl
或formControlName
属性应用于它时才适用。
如果这个指令只针对复选框,那么其他类型的表单控件,比如 input
或 textarea
呢?
每一种控制类型都有自己的值访问指令,它不同于 CheckboxControlValueAccessor,其中 input
和 textarea
使用 DefaultValueAccessor。
所有这些指令都是内置在 Angular Forms 模块中的,只涉及标准的 HTML 表单控件。
这意味着,如果我们想要实现我们自己的自定义表单控件,我们将不得不为它实现一个自定义 ControlValueAccessor
。
假设我们想要构建一个自定义表单控件,该控件表示一个带有增加和减少按钮的数字计数器,类似于 <input type="number" />
一样,我们用于选择订单数量。
创建一个自定义表单组件:
import { Component, Input } from '@angular/core';
@Component({
selector: 'my-counter',
standalone: true,
imports: [],
template: `
<button type="button" (click)="decrement()">-</button><span>{{count}}</span><button type="button" (click)="increment()">+</button>
`,
})
export class Counter {
count = 0;
@Input()
step: number = 1;
increment() {
this.count += this.step;
}
decrement() {
this.count -= this.step;
}
}
在当前的形式下,该组件既不兼容模板驱动的表单,也不兼容响应式表单。
我们希望能够像在表单中添加标准 HTML
表单 input
元素一样,通过添加 formControlName
或 ngModel
指令来添加这个组件。
我们还希望该组件与内置验证器兼容,将它们设置必填字段并设置最大值。
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, Counter, FormsModule, ReactiveFormsModule],
template: `
<h1>Hello from {{name}}!</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div><my-counter [step]="2" formControlName="count" /></div>
<button type="submit">提交</button>
</form>
`,
})
export class App {
name = 'Angular';
form: FormGroup = new FormGroup({
count: new FormControl(0, [Validators.required, Validators.max(100)]),
});
onSubmit() {
console.log(this.form.value);
}
}
但是在控件的当前版本中,如果我们尝试这样做,就会得到一个错误:
ERROR
Error: NG01203: No value accessor for form control name: 'count'. Find more at https://angular.io/errors/NG01203
为了修复这个错误,并使 my-counter
组件与 Angular Forms
兼容,我们需要给这个表单控件一个 ControlValueAccessor
,就像原生 HTML 元素的情况一样,比如 input
、textarea
等。
为了做到这一点,我们将使组件实现 ControlValueAccessor
接口。
让我们回顾一下 ControlValueAccessor
接口的方法。请记住,它们并不是要通过我们的代码直接调用,因为它们是框架回调。
所有这些方法都只能由表单模块在运行时调用,它们的作用是促进表单控件和父表单之间的通信。
下面是这个接口的方法,以及它们是如何工作的:
writeValue
:表单模块调用此方法将值写入表单控件中registerOnChange
:当由于用户输入而变化表单值时,我们需要将值报告回父表单。这是通过调用回调来完成的,该回调最初使用registerOnChange
方法在控件中注册的registerOnTouched
:当用户第一次与表单控件交互时,会认为该控件已经 touched
状态,这对于样式美化很有用。为了向父表单报告控件被触碰,我们需要使用 registerOnToched
方法注册的回调setDisabledState
:可以使用 Forms API
的 enabled
和 disabled
控件表单禁用状态。这个状态可以通过 setDisabledState
方法传递给表单控件那我们就给组件实现了 ControlValueAccessor
接口:
import { Component, Input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'my-counter',
standalone: true,
imports: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: Counter,
},
],
template: `
<button type="button" (click)="decrement()">-</button><span>{{value}}</span><button type="button" (click)="increment()">+</button>
`,
})
export class Counter implements ControlValueAccessor {
_value = 0;
set value(value: any) {
this._value = value;
this.notifyValueChange();
}
get value(): any {
return this._value;
}
@Input()
step: number = 1;
onChange: ((value: number) => {}) | undefined;
onTouched: (() => {}) | undefined;
touched = false;
disabled = false;
writeValue(value: number) {
this._value = value;
}
registerOnChange(onChange: (count: number) => {}) {
this.onChange = onChange;
}
registerOnTouched(onTouched: () => {}) {
this.onTouched = onTouched;
}
/**
* 通知父表单子控件被触碰
*/
markAsTouched() {
if (!this.touched) {
if (this.onTouched) {
this.onTouched();
}
this.touched = true;
}
}
/**
* 通知父表单值发生变化
*/
notifyValueChange(): void {
if (this.onChange) {
this.onChange(this.value);
}
}
setDisabledState(disabled: boolean) {
this.disabled = disabled;
}
increment() {
this.markAsTouched();
this.value += this.step;
}
decrement() {
this.markAsTouched();
this.value -= this.step;
}
}
现在让我们逐一解释每个方法,看它们是如何实现的。
每当父表单想要在子控件中设置一个值时,Angular
表单模块就会调用 writeValue
方法。
在我们的组件中,我们将获取该值并将其直接赋值给内部 count
属性
writeValue(value: number) {
this._value = value;
}
注意:这里不能直接赋值给
value
,这样会触发registerOnChange
注册的onChange
回调方法。
父表单可以使用 writeValue
在子控件中设置一个值,但是反过来呢?
如果用户与表单控件交互并增加或减少计数器值,则需要将新值传递回父表单。
第一步是让父表单向子控件注册回调函数,但要使用 registerOnChange
方法
onChange: ((value: number) => {}) | undefined;
registerOnChange(onChange: (count: number) => {}) {
this.onChange = onChange;
}
正如我们所看到的,当调用这个方法时,我们将接收回调函数,然后将其保存在成员变量中。
onChange
成员变量被声明为一个函数,并用一个空函数初始化,这意味着一个具有空函数体的函数。
这样,如果我们的程序由于某种原因在 registerOnChange
调用之前调用了该函数,我们就不会遇到任何错误。
当通过单击自增或自减按钮改变计数器的值时,我们需要通知父表单有一个新值可用。
我们将通过调用回调函数并报告新值来实现这一点:
increment() {
this.value += this.step;
}
decrement() {
this.value -= this.step;
}
除了向父表单报告新值外,我们还需要在子控件被用户触碰时通知父表单。
初始化表单时,每个表单控件(以及表单组)都被认为处于未触碰状态,并且 ng-untouched
的 CSS 类应用于表单组及其每个子控件。
这些 ng-touched
/ ng-untouched
的 CSS 类对于表单中的错误消息样式化非常重要,因此我们的自定义表单控件也需要支持这些。
像前面一样,我们需要注册一个回调,以便子控件可以将其触碰状态报告给父表单:
onTouched: (() => {}) | undefined;
registerOnTouched(onTouched: () => {}) {
this.onTouched = onTouched;
}
现在,我们需要在控件被触碰时调用这个回调函数,只要用户至少单击一次增量或减量按钮,就会调用这个回调函数:
touched = false;
increment() {
this.markAsTouched();
this.count += this.step;
this.onChange(this.count);
}
decrement() {
this.markAsTouched();
this.count -= this.step;
this.onChange(this.count);
}
markAsTouched() {
if (!this.touched) {
this.onTouched();
this.touched = true;
}
}
正如我们所看到的,当两个按钮中的一个第一次被点击时,我们将调用 ontouch
回调一次,并且表单控件现在将被父表单认为被触摸了。
自定义表单控件将像预期的那样应用 ng-touched
的 CSS类
<my-app ng-version="15.1.2">
<h1>Hello from Angular!</h1>
<form novalidate="" ng-reflect-form="[object Object]" class="ng-valid ng-touched ng-dirty">
<div><my-counter formcontrolname="count" ng-reflect-name="count" ng-reflect-step="2" class="ng-valid ng-touched ng-dirty"><button type="button">-</button><span>2</span><button type="button">+</button></my-counter></div>
<button type="submit">提交</button>
</form>
</my-app>
父表单也可以通过调用 setDisabledState
方法来启用或禁用它的任何子控件。我们可以在成员变量 disabled
中保持禁用状态,并使用它来打开和关闭自增/自减功能:
disabled = false;
setDisabledState(disabled: boolean) {
this.disabled = disabled;
}
increment() {
this.markAsTouched();
if(!this.disabled) {
this.value += this.step;
}
}
decrement() {
this.markAsTouched();
if(!this.disabled) {
this.value -= this.step;
}
}
最后,正确实现 ControlValueAccessor
接口的最后一个难题是将自定义表单控件注册为依赖注入系统中的已知值访问器:
@Component({
selector: 'my-counter',
standalone: true,
imports: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: Counter,
},
],
template: `
<button type="button" (click)="decrement()">-</button><span>{{value}}</span><button type="button" (click)="increment()">+</button>
`,
})
export class Counter implements ControlValueAccessor {
}
如果没有这个配置,我们的自定义表单控件将无法正常工作。
那么这种配置是什么?我们正在将组件添加到已知值访问器列表中,这些列表都是用 NG_VALUE_ACCESSOR
唯一依赖注入键(也称为注入令牌)注册的。
注意,multi
标志设置为 true
,这意味着该依赖项提供了一个值列表,而不仅仅是一个值。这很正常,因为除了我们自己的外,Angular
表单中还注册了很多值访问器。
例如,所有用于标准 input
、textarea
等的内置值访问器也在 NG_VALUE_ACCESSOR
下注册。
每当 Angular
表单模块需要所有可用值访问器的完整列表时,它所要做的就是注入 NG_VALUE_ACCESSOR
。
这样,我们的组件现在就能够在表单中设置属性的值了。
不仅如此,该组件现在能够参与表单验证过程,并且已经与内置的 required
和 max
验证器完全兼容。
但是,如果组件需要具有自己的内置验证规则,而这些规则总是在组件的每个实例中都活跃,而不是表单配置独立于表单呢?
在我们的自定义表单控件的情况下,我们希望它确保数量是正的。如果不是,那么表单字段应该被标记为错误,并且对于组件的所有实例都应该始终为 true
。
为了实现这个逻辑,我们将让组件实现 Validator
接口。这个接口只包含两个方法:
validate
:此方法用于验证表单控件的当前值。每当向父表单报告新值时,将调用此方法。如果没有发现错误,该方法需要返回 null
,或者返回一个 ValidationErrors
对象,该对象包含正确地向用户显示有意义的错误消息所需的所有细节。registerOnValidatorChange
:这将注册一个回调,允许我们根据需要触发自定义控件的验证。当发出新值时,我们不需要这样做,因为在这种情况下已经触发了验证。只有当影响 validate
行为的其他输入发生变化时,我们才需要调用这个方法。现在让我们来看看如何实现这个接口,并做一个组件的最后演示。
我们必须实现的 Validator
的唯一方法是 validate
方法:
validate(control: AbstractControl): ValidationErrors | null {
if (control.value < 0) {
return {
mustBePositive: {
actual: control.value,
},
};
}
return null;
}
在此实现中,如果值有效,则返回 null
,并返回一个包含有关错误的所有详细信息的 ValidationErrors
对象。
在我们的组件中,我们不需要实现 registerOnValidatorChange
,因为实现这个方法是可选的。
例如,如果我们的组件有可配置的验证规则,依赖于某些组件输入,我们只需要这个方法。如果是这样的话,在其中一个验证输入发生变化时,我们可以根据需要触发一个新的验证。
为了使 Validator
接口正常工作,我们还需要用 NG_VALIDATORS
注入令牌注册我们的自定义组件:
@Component({
selector: 'my-counter',
standalone: true,
imports: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: Counter,
},
{
provide: NG_VALIDATORS,
multi: true,
useExisting: Counter,
},
],
template: `
<button type="button" (click)="decrement()">-</button><span>{{value}}</span><button type="button" (click)="increment()">+</button>
`,
})
export class Counter implements ControlValueAccessor, Validator {
...
}
注意:如果没有在
NG_VALIDATORS
中正确注册这个类,将永远不会调用validate
方法。
有了 ControlValueAccessor
和 Validator
这两个接口,我们现在就有了一个功能齐全的自定义表单控件,它既兼容响应式表单,也兼容模板驱动表单,既能设置表单属性的值,又能参与表单验证过程。
这是最终代码:
import { Component, Input } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
} from '@angular/forms';
@Component({
selector: 'my-counter',
standalone: true,
imports: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: Counter,
},
{
provide: NG_VALIDATORS,
multi: true,
useExisting: Counter,
},
],
template: `
<button type="button" (click)="decrement()">-</button><span>{{value}}</span><button type="button" (click)="increment()">+</button>
`,
})
export class Counter implements ControlValueAccessor, Validator {
_value = 0;
set value(value: any) {
this._value = value;
this.notifyValueChange();
}
get value(): any {
return this._value;
}
@Input()
step: number = 1;
onChange: ((value: number) => {}) | undefined;
onTouched: (() => {}) | undefined;
touched = false;
disabled = false;
validate(control: AbstractControl): ValidationErrors | null {
if (control.value < 0) {
return {
mustBePositive: {
actual: control.value,
},
};
}
return null;
}
writeValue(value: number) {
this._value = value;
}
registerOnChange(onChange: (count: number) => {}) {
this.onChange = onChange;
}
registerOnTouched(onTouched: () => {}) {
this.onTouched = onTouched;
}
/**
* 通知父表单子控件被触碰
*/
markAsTouched() {
if (!this.touched) {
if (this.onTouched) {
this.onTouched();
}
this.touched = true;
}
}
/**
* 通知父表单值发生变化
*/
notifyValueChange(): void {
if (this.onChange) {
this.onChange(this.value);
}
}
setDisabledState(disabled: boolean) {
this.disabled = disabled;
}
increment() {
this.markAsTouched();
if (!this.disabled) {
this.value += this.step;
}
}
decrement() {
this.markAsTouched();
if (!this.disabled) {
this.value -= this.step;
}
}
}
现在让我们在运行时测试这个组件,方法是将它添加到一个带有两个标准验证器的表单中:
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Counter } from './form';
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, Counter, FormsModule, ReactiveFormsModule],
template: `
<h1>Hello from {{name}}!</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div><my-counter [step]="2" formControlName="count" /></div>
<button type="submit">提交</button>
</form>
`,
})
export class App {
name = 'Angular';
form = new FormGroup({
count: new FormControl(60, [Validators.required, Validators.max(100)]),
});
onSubmit() {
console.log(this.form);
}
}
正如我们所看到的,我们将该字段设置为比填的,并将最大值设置为100。控件的初始值为60,这是一个有效值。
但是如果我们将值设为110会发生什么呢?然后,表单将变得无效,my-counter
控件将有一个与之关联的错误。
我们可以通过检查 form.controls['count'].errors
属性的值来查看错误:
{
"max": {
"max": 100,
"actual": 110
}
}
正如我们所看到的,Validators.max(100)
内置验证器启动并将自定义表单控件标记为错误。
但是,如果相反,我们将数量值设置为例如负值 -10 呢?下面是我们控件的 errors
属性:
{
"mustBePositive": {
"actual": -10
}
}
正如我们所看到的,现在验证方法创建了一个 ValidationErrors
对象,然后将其设置为表单控件的错误的一部分。
我们现在有了一个功能齐全的自定义表单控件,它兼容模板驱动表单、响应式表单和所有内置验证器。
一个非常常见的表单用例是嵌套表单组,它可以跨多个表单重用。
地址表单就是一个很好的例子,它包含了所有常见的地址字段:
注意:这里为了体现嵌套表单组功能,实际项目当作,我们更希望用户选择我们提供的省市区选项,你只需要把输入框换成下拉选择框即可,这里为了例子看起来不那么复杂,重点关注嵌套表单,这里采用输入框形式。
现在假设我们的应用程序有许多需要地址的不同表单。我们不希望在每个表单中重复显示和验证这些字段所需的所有代码。
相反,我们想做的是在 Angular
组件的表单下创建一个可重用的表单部分,然后我们可以将其插入多个表单中,类似于嵌套的可重用子表单。
下面是我们如何使用这样一个地址表单组件:
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div><my-counter [step]="2" formControlName="count" /></div>
<div><my-address formControlName="address" legend="地址" /></div>
<button type="submit">提交</button>
</form>
如我们所见,我们希望使我们的 my-address
组件与 Angular form
完全兼容,这意味着它应该支持 ngModel
,formControl
和formControlName
指令,并能够参与父表单的验证。
听起来很熟悉?
实际上,我们所要做的就是实现 ControlValueAccessor
和 Validator
接口之前所做的那样。那么这是如何运作的呢?
首先,我们需要定义嵌套地址表单组件:
import { Component, Input, OnDestroy } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormControl,
FormGroup,
FormsModule,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
ValidationErrors,
Validator,
Validators,
} from '@angular/forms';
import { Subscription } from 'rxjs';
export type AddressForm = {
province: string | null;
city: string | null;
district: string | null;
street: string | null;
zipCode: string | null;
};
@Component({
selector: 'my-address',
standalone: true,
imports: [FormsModule, ReactiveFormsModule],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: Address,
},
{
provide: NG_VALIDATORS,
multi: true,
useExisting: Address,
},
],
template: `
<fieldset [formGroup]="form">
<legend>{{legend}}</legend>
<div>
<label for="province">省/直辖市:</label>
<input type="text" id="province" formControlName="province" placeholder="输入省/直辖市" (blur)="markAsTouched()" />
</div>
<div>
<label for="city">市:</label>
<input type="text" id="city" formControlName="city" placeholder="输入市" (blur)="markAsTouched()" />
</div>
<div>
<label for="district">区:</label>
<input type="text" id="district" formControlName="district" placeholder="输入区" (blur)="markAsTouched()" />
</div>
<div>
<label for="street">街道门牌号:</label>
<input type="text" id="street" formControlName="street" placeholder="输入街道门牌号" (blur)="markAsTouched()" />
</div>
<div>
<label for="zipCode">邮政编码:</label>
<input type="text" id="zipCode" formControlName="zipCode" placeholder="输入邮政编码" (blur)="markAsTouched()" />
</div>
</fieldset>
`,
})
export class Address implements ControlValueAccessor, Validator, OnDestroy {
@Input()
legend!: string;
form = new FormGroup({
province: new FormControl<string | null>(null, [Validators.required]),
city: new FormControl<string | null>(null, [Validators.required]),
district: new FormControl<string | null>(null, [Validators.required]),
street: new FormControl<string | null>(null, [Validators.required]),
zipCode: new FormControl<string | null>(null, [Validators.required]),
});
onChangeSubs: Subscription[] = [];
onTouched: (() => {}) | undefined;
touched = false;
ngOnDestroy() {
for (let sub of this.onChangeSubs) {
sub.unsubscribe();
}
}
validate(control: AbstractControl): ValidationErrors | null {
if (this.form.valid) {
return null;
}
// from parent form `Validators.required`
if (control.hasValidator(Validators.required)) {
const errors: ValidationErrors = {};
Object.entries(this.form.controls).reduce(
(error, [controlName, control]) => {
if (control.errors) {
error[controlName] = control.errors;
}
return error;
},
errors
);
return errors;
}
return null;
}
writeValue(value: AddressForm) {
if (value) {
this.form.setValue(value, { emitEvent: false });
}
}
registerOnChange(onChange: (value: Partial<AddressForm>) => void) {
this.onChangeSubs.push(this.form.valueChanges.subscribe(onChange));
}
registerOnTouched(onTouched: () => {}) {
this.onTouched = onTouched;
}
/**
* 通知父表单子控件被触碰
*/
markAsTouched() {
if (!this.touched) {
if (this.onTouched) {
this.onTouched();
}
this.touched = true;
}
}
setDisabledState(disabled: boolean) {
if (disabled) {
this.form.disable();
} else {
this.form.enable();
}
}
}
正如我们所看到的,我们嵌套的地址表单本身也是一个 FormGroup
,它也在内部使用 Angular form
来收集每个地址字段的值并验证它们的值。
表单对象已经包含了关于此子表单的值和有效性状态的所有信息。我们现在可以使用这些信息来快速实现 ControlValueAccessor
和Validator
接口。
以下是关于此实现的一些重要注意事项:
form
对象中获取所需的所有信息form
实现 writeValue.setValue
,我们通过 form.enable()
和 form.disable()
实现setDisabledState
。valueChanges
订阅来知道地址 form
何时发出了一个新值,并调用 onChange
回调来通知父表单。valueChanges
时,我们需要使用 OnDestroy
钩子取消订阅,以避免内存泄漏ValidationErrors
对象来实现 validate
方法control.hasValidator(Validators.required)
方法来判断父表单是否设置必填验证器如果我们尝试填写地址表单的值,我们将看到它们被报告回父表单,并显示在 address
属性下。
在输入地址后,这是包含 my-address
的父表单的 value
属性:
{
...
address: {
province: '省',
city: ‘市’,
district: '区',
street: '街道'
zipCode: ‘100000’
}
}
每个表单控件都链接到一个控件值访问器,该访问器负责表单控件与父表单之间的交互。
这包括所有标准的 HTML 表单控件,如 input
、select
、textarea
等, FormsModule
为此提供了内置的控件值访问器。
对于自定义表单控件,我们必须通过实现 ControlValueAccessor
接口来构建我们自己的控件值访问器,如果我们希望控件执行自定义值验证,那么我们需要实现 Validator
接口。
我们还可以使用相同的技术来实现嵌套表单组(如地址子表单),这些表单组可以跨多个表单重用。
当你有一组输入框需要验证时,那该如何操作,这就需要 FormArray
闪亮登场。有机会我们下次介绍它们。
谢谢你读到这里。下面是你接下来可以做的一些事情:
什么是类?
类是一组相似对象的规范。
什么是对象?
对象是一组对封装的数据元素进行操作的功能。或者更确切地说,对象是一组对隐含数据元素进行操作的功能。
什么是隐含的数据元素?
对象的功能意味着某些数据元素的存在。 但是该数据无法直接访问,也无法在对象外部看到。
数据不是在对象内部吗?
它可能是,但没有规则说必须如此。 从用户的角度来看,一个对象不过是一组功能。 这些功能所依据的数据必须存在,但是用户不知道该数据的位置。
花开两朵,各表一枝。
什么是数据结构?
数据结构是一组内聚的数据元素。或者,换句话说,数据结构是由隐含功能操作的一组数据元素。
数据结构未指定对数据结构进行操作的功能,但是数据结构的存在意味着某些操作必须存在。
那么,现在关于这两个定义你注意到了什么?
它们彼此相反。确实,它们是彼此的补充。
所以对象不是数据结构,对象是数据结构的对立面。
DTO(数据传输对象)是对象吗?
DTO是数据结构。
数据库表是对象吗?
数据库包含数据结构,而不是对象
那么,ORM和对象关系映射器不是将数据库表映射到对象吗?
当然不是,数据库表和对象之间没有映射。 数据库表是数据结构,而不是对象。
什么是ORM,ORM会做什么?
对象关系映射(Object Relational Mapping,简称ORM)是通过使用描述对象和数据库之间映射的元数据,将面向对象语言程序中的对象自动持久化到关系数据库中。ORM会提取业务对象所操作的数据。 该数据包含在ORM加载的数据结构中。
例如数据库模式的设计与业务对象的设计,业务对象定义业务行为的结构。 数据库模式定义业务数据的结构。 这两个结构受到非常不同的力的约束, 业务数据的结构不一定是业务行为的最佳结构。
为什么会这样呢,可以这样想。数据库模式不只为一个应用程序进行调优;它必须服务于整个企业。因此,数据的结构是许多不同应用程序之间的折衷。
也许现在考虑每个单独的应用程序,每个应用程序的对象模型描述了这些应用程序的行为结构。每个应用程序都有不同的对象模型,并根据该应用程序的行为进行调优。
那么,由于数据库模式是所有各种应用程序的折衷,所以该模式不符合任何特定应用程序的对象模型。
对象和数据结构受到非常不同的约束,他们很少排成一起。人们把这种情况称为对象/关系阻抗不匹配。
ORM可以解决了阻抗不匹配的问题,因为对象和数据结构是互补的,而不是同构的,所以没有阻抗失配。
那又怎样?
它们是对立的,不是相似的实体。
对立?
是的,以一种非常有趣的方式。对象和数据结构意味着完全相反的控制结构。
考虑一组对象类,它们都符合一个公共接口。例如,想象一下表示二维形状的类,它们都具有计算形状的面积和周长的函数。
为什么每个软件示例都涉及形状呢?
我们只考虑两种不同的类型:正方形和圆形。 应该清楚的是,这两类的面积和周长函数在不同的隐式数据结构上运行。 还应该清楚的是,调用这些操作的方式是通过动态多态性进行的。
对象知道其方法的实现,现在让我们将这些对象转换为数据结构。
在我们的例子中,这只是两个不同的数据结构。 一个用于Square
,另一个用于Circle
。Circle
数据结构具有中心点和数据元素的半径。 它还有一个类型代码,可将其标识为圆形。
你是说像枚举?
当然,Square
数据结构具有左上角和边的长度。 它还具有类型鉴别符–枚举。
带有类型代码的两个数据结构,现在考虑面积函数。 它要有一个switch
语句,不是吗?
当然,对于两种不同的情况。 一个用于Square,另一个用于Circle。 周长功能将需要类似的
switch
语句
现在想想这两个场景的结构。在对象场景中,area函数的两个实现彼此独立,并且属于(某种意义上的)类型。正方形面积函数属于正方形,圆形面积函数属于圆形。
那么,处理方法。 在数据结构方案中,area函数的两个实现在同一函数中在一起,他们不会“属于”该类型。
如果要将Triangle
类型添加到对象方案中,必须更改哪些代码?
无需更改代码。 您只需创建新的
Triangle
类。
所以当你添加一个新类型时,几乎没有更改。现在假设您想要添加一个新函数,比如center
函数。
那么,我们必须将其添加到所有三种类型(圆形,正方形和三角形)中。因此添加新功能很困难,我们必须更改每个类。
但是数据结构却有所不同,为了添加Triangle
,必须更改每个函数以将三角形案例添加到switch
语句。
所以,添加新类型非常困难,我们必须更改每个功能。
但是当我们添加新的center
函数时,什么也不需要改变。
添加新函数很容易吗?
哈哈,恰恰相反。
那当然是。 我们来回顾一下:
它们以一种有趣的方式对立。如果你知道你要给一组类型添加新的函数,想要你使用数据结构。但是如果你知道你将添加新的类型,那么希望你使用类。
但是,我们还有最后一件事要考虑。 数据结构和类的对立还有另一种方式。 它与依赖关系有关。
依赖关系?
源代码依赖关系的方向。
有什么区别吗?
考虑数据结构的情况,每个函数都有一个switch语句,该语句根据所区分的并集内的类型代码选择适当的实现。
那又怎样呢?
考虑对area
函数的调用。调用者依赖于area
函数,而area
函数依赖于每个特定的实现。
所说的“依赖”是什么意思?
想象一下,区域的每个实现都被写入了自己的功能中。 所以有circleArea
和squareArea
以及triangleArea
。
所以switch
语句仅调用那些函数。
想象一下,这些功能在不同的源文件中。然后,带有switch
语句的源文件必须导入、使用或包含所有这些源文件。
这是一个源代码依赖项。一个源文件依赖于另一个源文件。这种依赖的方向是什么?
带有switch语句的源文件取决于包含所有实现的源文件。
那么area
函数的调用者呢?
area
函数的调用者依赖于带有switch
语句的源文件,它依赖于所有的实现。
从调用者到实现,所有源文件依赖性都指向调用的方向。 因此,如果你对其中的一种实现进行了微小的更改……
你该目标我的意思, 对任何一种实现的更改都会导致重新编译带有switch
语句的源文件,这将导致每个调用该switch
语句的人(在本例中为area函数)都被重新编译。
至少对于依赖源文件日期来确定应该编译哪些模块的语言系统来说是这样的。
这需要大量的重新编译,还有大量的重新部署。
但是这在类的情况下是相反的吗?
是的,因为
area
函数的调用者依赖于接口,而实现函数也依赖于接口。
Square
类的源文件导入、使用或包含Shape
接口的源文件。
实现的源文件指向调用的相反方向。 他们从实现指向调用者。 至少对于静态类型的语言是这样。 对于动态类型的语言,区域函数的调用者完全不依赖任何内容。 链接在运行时确定。
因此,如果你对其中一个实现做了改变...
只有更改后的文件需要重新编译或重新部署,这是因为源文件之间的依赖关系指向调用的方向。
这种方式我们称为依赖倒置。
最后让我们总结一下, 类和数据结构在至少三种不同的方式上是相反的:
这些都是每个优秀的软件设计人员和架构师都需要牢记的问题。
我发现比我想象要长,打算把实战部分拆分成中和下来讲解。
通过上篇学习,相信大家对 Nest
有大概印象,但是你还是看不出它有什么特别的地方,下篇将为你介绍项目实战中Nest
如何使用各种特性和一些坑和解决方案。源码
这篇主要内容:
一个好的文件结构约定,会让我们开发合作、维护管理,节省很多不必要沟通。
这里我scr
文件规划:
文件 | 说明 |
---|---|
main.ts | 入口 |
main.hmr.ts | 热更新入口 |
app.service.ts | APP服务(选择) |
app.module.ts | APP模块(根模块,必须) |
app.controller.ts | APP控制器(选择) |
app.controller.spec.ts | APP控制器单元测试用例(选择) |
config | 配置模块 |
core | 核心模块(申明过滤器、管道、拦截器、守卫、中间件、全局模块) |
feature | 特性模块(主要业务模块) |
shared | 共享模块(共享mongodb、redis封装服务、通用服务) |
tools | 工具(提供一些小工具函数) |
这是我参考我
Angular
项目的结构,写了几个nest
项目后发现这个很不错。把mongodb
服务和业务模块分开,还有一个好处就是减少nest
循环依赖注入深坑,后面会讲怎么解决它。
打开main.ts
文件
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
NestFactory
创建一个app实例,监听3000
端口。
/**
* Creates an instance of the NestApplication
* @returns {Promise}
*/
create(module: any): Promise<INestApplication & INestExpressApplication>;
create(module: any, options: NestApplicationOptions): Promise<INestApplication & INestExpressApplication>;
create(module: any, httpServer: FastifyAdapter, options?: NestApplicationOptions): Promise<INestApplication & INestFastifyApplication>;
create(module: any, httpServer: HttpServer, options?: NestApplicationOptions): Promise<INestApplication & INestExpressApplication>;
create(module: any, httpServer: any, options?: NestApplicationOptions): Promise<INestApplication & INestExpressApplication>;
create
方法有1-3参数,第一个是入口模块AppModule
, 第二个是一个httpServer
,如果要绑定Express
中间件,需要传递Express
实例。第三个全局配置:
app
带方法有哪些
INestApplication
下
socket.io
库。INestExpressApplication
下express.set()
方法的包装函数。express.engine()
方法的包装函数。express.enable()
方法的包装函数。express.disable()
方法的包装函数。express.static(path, options)
方法的包装函数。express.set('views', path)
方法的包装函数。express.set('view engine', engine)
方法的包装函数。因为目前CNode采用Egg
编写,里面大量使用与Egg
集成的egg-xxx
包,这里我把相关的连对应的依赖都一一来出来。
Egg-CNode
使用egg-view-ejs
,本项目使用ejs
包,唯一缺点没有layout
功能,可以麻烦点,在每个文件引入头和尾即可,也有另外一个包ejs-mate
,它有layout
功能,后面会介绍它怎么使用。
Egg-CNode
使用egg-redis
操作redis
,其实它是包装的ioredis
包,我也一直在nodejs里使用这个包,需要安装生产ioredis
和开发@types/ioredis
Egg-CNode
使用egg-mongoose
操作mongodb
,Nest
提供了@nestjs/mongoose
,需要安装生产mongoose
和开发@types/mongoose
Egg-CNode
使用egg-passport、egg-passport-github、egg-passport-local
做身份验证,Nest
提供了@nestjs/passport
,需要安装生产passport、passport-github、passport-local
其他依赖在后面用到时候在详细介绍,这几个是比较重要的依赖。
CNode
使用的是egg-ejs
,为了简单点,减少静态文件编写,我也用ejs
。发现区别就是少了layout
功能,需要我们拆分layout/header.ejs
和layout/footer.ejs
在使用了。
但是有一个包可以做到类似的功能ejs-mate
,这个是@JacksonTian 朴灵大神的作品。
新建模板存放views
文件夹(root/views)和静态资源存放public
文件夹(root/public)
注意:nest-cli
默认只处理src
里面的ts文件,如有其他文件需要自己写脚本处理,gulp
或者webpack
都可以,这里就简单一点,直接把views
和public
放在src
平级的根目录里面了。后面会说怎么处理它们设置问题。
安装ejs-mate
依赖:
npm install ejs-mate --save
用法很简单了,关于文件名后缀,默认使用.ejs
,.ejs
虽然会让它语法高亮,有个坑就html
标签不会自动补全提示。那需要换成.html
后缀。
设置模板引擎:
import { join } from 'path';
import * as ejsMate from 'ejs-mate';
async function bootstrap() {
....
// 获取根目录 nest-cnode
const rootDir = join(__dirname, '..');
// 指定视图引擎 处理.html后缀文件
app.engine('html', ejsMate);
// 视图引擎
app.set('view engine', 'html');
// 配置模板(视图)的基本目录
app.setBaseViewsDir(join(rootDir, 'views'));
...
}
注意:当前启动程序是
src/main.ts
,因为views
和public
在根目录,所有我们就需要去获取获取根目录。其他注释已经说明,就不再赘述。
使用模板引擎:
我们在views
文件夹里面新建一个layout.html
和一个index.html
。
写通用的layout.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我是layout模板</title>
</head>
<body>
<%- body -%>
</body>
index.html
<% layout('layout') -%>
<h1>我是首页</h1>
import { Get, Controller, Render } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@Render('index')
root() {
return {};
}
}
注意:@Render()
里面一定要写模板文件名(可以省略后缀),不然访问页面显示是json
对象。
访问首页http://localhost:3000/
看结果。
ejs-mate
语法:
ejs-mate
兼容ejs语法,语法很简单,这里顺便带一下:
说几个常用的写法:
<% 直接写js代码,不输出:%>
<ul>
<% users.forEach(function(user){ %>
<%- include('user/show', {user: user}); %>
<% }); %>
</ul>
<%# 输出变量:%>
<%= '变量' %>
<%# 输出HTML:%>
<%- '<h1>标题</h1>' %>
<%# 引入其他ejs文件(注意:2个参数,第一个是路径:相对于当前模板路径中的模板片段包含进来;第二个是传递数据对象。):%>
<%- include('user/show', {user: user}); %>
说明:
注意:以上语法基本一样,有一样不一样,include
需要用partial
代替。他们俩用法一模一样。
layout
功能,需要在引用的页面,比如index.html
里面使用<% layout('layout') -%>
,注意:这里'layout'
是指layout.html
。
还有一个比较重要的功能是block
。它是在指定的位置插入自定义内容。类似于angularjs
的transclude
,angular
的<ng-content select="[xxx]"></ng-content>
,vue
的<slot></slot>
。
slot
写法:
<%- block('head').toString() %>
block('head')
,是一个占位标识符,toString
是合并所有的插入使用join
转成字符串。
使用:
<% block('head').append('<link type="text/css" href="/append.css">') %>
<% block('head').prepend('<link type="text/css" href="/prepend.css">') %>
append
和prepend
是插入的顺序,append
总是插槽位置插入在最后,prepend
总是插槽位置插入在最前。
我们来验证一下。
现在layout.html
的head
里面写上
<head>
...
<link type="text/css" href="/style.css">
<%- block('head').toString() %>
</head>
index.html
的结尾写上
...
<% block('head').append('<link type="text/css" href="/append.css">') %>
<% block('head').prepend('<link type="text/css" href="/prepend.css">') %>
<% block('head').prepend('<link type="text/css" href="/prepend2.css">') %>
<% block('head').append('<link type="text/css" href="/append2.css">') %>
访问首页http://localhost:3000/
看结果。
注意:index.html
里书写block('head').append
的位置不影响它显示插槽的位置,只受定义插槽<%- block('head').toString() %>
还有一个方法replace
,没看懂怎么用的,文档里面也没有说明,基本append
、prepend
、toString
就够用了。
总结:toString
是定义插槽位置,append
、prepend
往插槽插入指定的内容。他们主要做什么了,layout
载入公共的css
、js
,如果有的页面有不一样地方,就需要插入当前页面的js了,那么一来这个插槽功能就有用,如果使用layout
功能插入,就会包含在layout
位置,无论是语义还是加载都是不合理的。就有了block
的功能,在另一款模板引擎Jade
里面也有同样的功能也叫block
功能。
public
文件夹里面内容直接拷贝egg-cnode
下的public
的静态资源
还需要安装几个依赖:
npm i --save loader loader-connect loader-builder
这几个模块是加载css和js使用,也是@JacksonTian 朴灵大神的作品。
main.ts配置
import { join } from 'path';
import * as loaderConnect from 'loader-connect';
async function bootstrap() {
...
// 根目录 nest-cnode
const rootDir = join(__dirname, '..');
// 注意:这个要在express.static之前调用,loader2.0之后要使用loader-connect
// 自动转换less为css
if (isDevelopment) {
app.use(loaderConnect.less(rootDir));
}
// 所有的静态文件路径都前缀"/public", 需要使用“挂载”功能
app.use('/public', express.static(join(rootDir, 'public')));
// 官方指定是这个 默认访问根目录
// app.useStaticAssets(join(__dirname, '..', 'public'));
...
}
注意:如果静态文件路径都前缀/public
,需要使用use
去挂载express.static
路径。只有express
是这样的
useStaticAssets(path: string, options: ServeStaticOptions) {
return this.use(express.static(path, options));
}
它的源码是这样写的,如果这样的,你的静态资源路径就是从根目录开始,如果需要加前缀/public
,就需要express
提供的方式
测试我们静态资源路径设置是否正常工作
在index.html
里面引入public/images/logo.png
图片
...
<img src="/public/images/logo.png" alt="logo">
...
如果有问题,请找原因,路径是否正确,设置是否正确,如果都ok,还是不能访问,可以联系我。
关于loader
使用:
<!-- style -->
<%- Loader('/public/stylesheets/index.min.css')
.css('/public/libs/bootstrap/css/bootstrap.css')
.css('/public/stylesheets/common.css')
.css('/public/stylesheets/style.less')
.css('/public/stylesheets/responsive.css')
.css('/public/stylesheets/jquery.atwho.css')
.css('/public/libs/editor/editor.css')
.css('/public/libs/webuploader/webuploader.css')
.css('/public/libs/code-prettify/prettify.css')
.css('/public/libs/font-awesome/css/font-awesome.css')
.done(assets, config.site_static_host, config.mini_assets)
%>
<!-- scripts -->
<%- Loader('/public/index.min.js')
.js('/public/libs/jquery-2.1.0.js')
.js('/public/libs/lodash.compat.js')
.js('/public/libs/jquery-ujs.js')
.js('/public/libs/bootstrap/js/bootstrap.js')
.js('/public/libs/jquery.caret.js')
.js('/public/libs/jquery.atwho.js')
.js('/public/libs/markdownit.js')
.js('/public/libs/code-prettify/prettify.js')
.js('/public/libs/qrcode.js')
.js('/public/javascripts/main.js')
.js('/public/javascripts/responsive.js')
.done(assets, config.site_static_host, config.mini_assets)
%>
Loader
可以加载.js
方法也可以加载.coffee
、.es
类型的文件,.css
方法可以加载.less
、.styl
文件。Loader('/public/index.min.js')
是合并后名字.js('/public/libs/jquery-2.1.0.js')
是加载每一个文件地址.done(assets, config.site_static_host, config.mini_assets)
是处理文件,第一个参数合并压缩后的路径(后面讲解),第二个参数静态文件服务器地址,第三个参数是否压缩assets
从哪里来
在package.json
的scripts
配置
{
...
"assets": "loader /views /"
}
loader的写法是:loader <views_dir> <output_dir>
。views_dir
是模板引擎目录,output_dir
是assets.json
文件输出的目录,/
表示根目录。
npm run assets
直接运行会报错,这个问题在egg-node
有人提issues
主要是静态资源css
引用的背景图片和字体地址有错误,需要修改哪些文件:
错误信息:
no such file or directory, open 'E:\github\nest-cnode\E:\public\img\glyphicons-halflings.png'
谁引用了它 Error! File:/public/libs/bootstrap/css/bootstrap.css
/public/libs/bootstrap/css/bootstrap.css
...
[class^="icon-"],
[class*=" icon-"] {
display: inline-block;
width: 14px;
height: 14px;
margin-top: 1px;
*margin-right: .3em;
line-height: 14px;
vertical-align: text-top;
background-image: url("/public/libs/bootstrap/img/glyphicons-halflings.png");
background-position: 14px 14px;
background-repeat: no-repeat;
}
...
.icon-white,
.nav-pills > .active > a > [class^="icon-"],
.nav-pills > .active > a > [class*=" icon-"],
.nav-list > .active > a > [class^="icon-"],
.nav-list > .active > a > [class*=" icon-"],
.navbar-inverse .nav > .active > a > [class^="icon-"],
.navbar-inverse .nav > .active > a > [class*=" icon-"],
.dropdown-menu > li > a:hover > [class^="icon-"],
.dropdown-menu > li > a:focus > [class^="icon-"],
.dropdown-menu > li > a:hover > [class*=" icon-"],
.dropdown-menu > li > a:focus > [class*=" icon-"],
.dropdown-menu > .active > a > [class^="icon-"],
.dropdown-menu > .active > a > [class*=" icon-"],
.dropdown-submenu:hover > a > [class^="icon-"],
.dropdown-submenu:focus > a > [class^="icon-"],
.dropdown-submenu:hover > a > [class*=" icon-"],
.dropdown-submenu:focus > a > [class*=" icon-"] {
background-image: url("/public/libs/bootstrap/img/glyphicons-halflings-white.png");
}
...
大约2296
和2320
行位置,你可以用查找搜索glyphicons-halflings.png
,默认是background-image: url("../img/glyphicons-halflings.png");
, 替换为上面写法。
/public/stylesheets/style.less
...
.navbar .search-query {
-webkit-box-shadow: none;
-moz-box-shadow: none;
background: #888 url('/public/images/search.png') no-repeat 4px 4px;
padding: 3px 5px 3px 22px;
color: #666;
border: 0px;
margin-top: 2px;
&:hover {
background-color: white;
}
transition: all 0.5s;
&:focus, &.focused {
background-color: white;
}
}
...
大约850
行位置
简单解释就是换成相对于根目录的路径,后面错误就类似。
打包成功以后会输出一个assets.json
在根目录。assets
指的就是这个json文件,后面我们会讲如果把它们关联起来。
我们上面已经配置好了模板引擎和静态资源,我们先要去扩展他们,先让页面好看点。
打开cnode,然后右键查看源代码。把里面内容复制,拷贝到index.html
里去。
访问http://localhost:3000/
就可以瞬间看到和cnode
首页一样的内容了。
有模板以后,我们需要改造他们:
DOCTYPE
申明<!DOCTYPE html>
<html lang="zh-CN">
body
标签之外到layout.html
浏览cnode
所有页面head
内容,除了title
标签内容其他一样
基础layout.html
模板
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我是layout模板</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>
<body>
<%- body -%>
</body>
</html>
把index.html
里的head
标签内容都移动到layout.html
的head
,同名的直接替换。
替换之后的layout.html
模板
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>CNode:Node.js专业中文社区</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name='description' content='CNode:Node.js专业中文社区'>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="keywords" content="nodejs, node, express, connect, socket.io" />
<!-- see http://smerity.com/articles/2013/where_did_all_the_http_referrers_go.html -->
<meta name="referrer" content="always">
<meta name="author" content="EDP@TaoBao" />
<meta property="wb:webmaster" content="617be6bd946c6b96" />
<meta content="_csrf" name="csrf-param">
<meta content="vlUgGvkx-SgmuzendL9gAP3DHXVS3834IpC4" name="csrf-token">
<link title="RSS" type="application/rss+xml" rel="alternate" href="/rss" />
<link rel="icon" href="//o4j806krb.qnssl.com/public/images/cnode_icon_32.png" type="image/x-icon" />
<!-- style -->
<link rel="stylesheet" href="//o4j806krb.qnssl.com/public/stylesheets/index.min.23a5b1ca.min.css" media="all" />
<%- block('styles').toString() %>
</head>
<body>
<%- body -%>
<!-- scripts -->
<script src="//o4j806krb.qnssl.com/public/index.min.f7c13f64.min.js"></script>
<%- block('scripts').toString() %>
</body>
</html>
style放头部,script放底部,并且利用模板引擎做了2个插槽,一个
styles
和scripts
body
标签之内到layout.html
浏览cnode
所有页面内容,发现头部黑色部分和底部白色部分都是一样的。那么我们需要把它们提取出来。
cnode
模板
...
<body>
<div class='navbar'></div>
<div id='main'></div>
<div id='backtotop'></div>
<div id='footer'></div>
<div id='sidebar-mask'></div>
</body>
backtotop
和sidebar-mask
是2个和js相关的功能标签,直接保留它们。navbar
对应到header
标签main
对应到main
标签footer
对应到footer
标签main
标签之外内容都放到对应的标签里面改版后的layout.html
模板
...
<body>
<header id="navbar">...</header>
<main id="main">
<%- body -%>
</main>
<footer id="footer">...</footer>
<div id="backtotop">...</div>
<div id="sidebar-mask">...</div>
...
</body>
把剩下index.html
里面的styles
和scripts
使用
<% block('styles').append(``) %>
<% block('scripts').append(``) %>
最好是写成script
和style
文件。
main
标签之内到sidebar.html
浏览cnode
所有主体内容,发现右边侧边栏除了api
页面没有,注册登录找回密码,是另外一种模板内容,其他页面都是一样。
当前index.html
模板
...
<% layout('layout') -%>
<div id='sidebar'>...</div>
<div id='content'>...</div>
...
替换后的index.html
模板
...
<% layout('layout') -%>
<%- partial('./sidebar.html') %>
<article id="content">...</article>
...
这样我们首页模板已经完成了。
系统配置是系统级别的配置,如数据配置,端口,host,签名,加密keys等
应用配置是应用级别的配置,如网站标题,关键字,描述等
系统配置使用.env
文件,大部分语言都有这个文件,我们需要用dotenv
读取它们里面的内容。
dotenv
支持的.env
语法:
# 测试单行注释
KEY=
KEY=''
KEY=value
KEY='value'
KEY={"foo": "bar"}
KEY='{"foo": "bar"}'
KEY=["foo", "bar"]
KEY='["foo", "bar"]'
KEY=true
KEY=0
KEY='0'
KEY=null
KEY='null'
.env
语法非常简单,key
只能是字符串(ps:最好大写带下划线分割单词),value
可以是空、字符串、数字、布尔值、字典对象、数组,dotenv
最后获取也是字符串,需要你做相应处理。
注意:.env
文件主要的作用是存储环境变量,也就是会随着环境变化的东西,比如数据库的用户名、密码、静态文件的存储路径之类的,因为这些信息应该是和环境绑定的,不应该随代码的更新而变化,所以一般不会把 .env
文件放到版本控制中;
我们需要在.gitignore
文件中排除它们:
# dotenv environment variables file
*.env
.env
.env
配置文件,关于隐私配置,可以看README.md
说明。.env
文件模板
当我们使用process global
对象时,很难保持测试的干净,因为测试类可能直接使用它。另一种方法是创建一个抽象层,即一个ConfigModule
,它公开了一个装载配置变量的ConfigService
。
关于配置模块,官网有详细的栗子,这里也是基本类似。这里说一些关键点:
npm i --save dotenv // 用来解析`.env`配置文件
npm install --save joi // 用来验证`.env`配置文件
npm install --save-dev @types/joi
.env
配置文件 development.env 开发配置
production.env 生产配置
test.env 测试配置
.env.tmp .env配置文件模板
NODE_ENV
windows
和mac
不一样
windows设置
"scripts": {
"start:dev": "set NODE_ENV=development&& nodemon",
"start:prod": "set NODE_ENV=production&& node dist/main.js",
"test": "set NODE_ENV=test&& jest",
}
mac设置
"scripts": {
"start:dev": "export NODE_ENV=development&& nodemon",
"start:prod": "export NODE_ENV=production&& node dist/main.js",
"test": "export NODE_ENV=test&& jest",
}
你会发现这个很麻烦,有没有什么方便地方了,可以通过cross-env
来解决问题,它就是解决跨平台设置NODE_ENV的问题,默认情况下,windows不支持NODE_ENV=development的设置方式,加上cross-env就可以跨平台。
安装cross-env
依赖
npm i --save-dev cross-env
cross-env
设置
"scripts": {
"start:dev": "cross-env NODE_ENV=development nodemon",
"start:prod": "cross-env NODE_ENV=production node dist/main.js",
"test": "cross-env NODE_ENV=test jest",
}
config
模块:$ nest generate module config
OR
$ nest g mo config
import { Module, DynamicModule, Global } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigurationToken } from './config.constants';
import { EnvConfig } from './config.interface';
@Global()
@Module({})
export class ConfigModule {
static forRoot<T = EnvConfig>(filePath?: string, validator?: (envConfig: T) => T): DynamicModule {
return {
module: ConfigModule,
providers: [
{
provide: ConfigService,
useValue: new ConfigService(filePath || `${process.env.NODE_ENV || 'development'}.env`, validator),
},
{
provide: ConfigToken,
useFactory: () => new ConfigService(filePath || `${process.env.NODE_ENV || 'development'}.env`, validator),
},
],
exports: [
ConfigService,
ConfigToken,
],
};
}
}
<T = EnvConfig>
是一种什么写法,T
是一个泛型,EnvConfig
是一个默认值,如果使用者不传递就是默认类型,作用类似于函数默认值。
默认用2种注册服务的写法,一种是类,一种是工厂。前面基础篇已经提及了,后面讲怎么使用它们。
config
服务:$ nest generate service config/config
OR
$ nest g s config/config
首先,让我们写ConfigService
类。
import * as fs from 'fs';
import { parse } from 'dotenv';
import { EnvConfig } from './config.interface';
export class ConfigService<T = EnvConfig> {
// 系统配置
private readonly envConfig: T;
constructor(filePath: string, validator?: (envConfig: T) => T) {
// 解析配置文件
const configFile: T = parse(fs.readFileSync(filePath));
// 验证配置参数
if (typeof validator === 'function') {
const envConfig: T = validator(configFile);
if (typeof envConfig !== 'object') {
throw Error('validator return value is not object');
}
this.envConfig = envConfig;
} else {
this.envConfig = configFile;
}
}
/**
* 获取配置
* @param key
* @param defaultVal
*/
get(key: string, defaultVal?: any): string {
return process.env[key] || this.envConfig[key] || defaultVal;
}
/** 获取系统配置 */
getKeys(keys: string[]): any {
return keys.reduce((obj, key: string) => {
obj[key] = this.get(key);
return obj;
}, {});
}
/**
* 获取数字
* @param key
*/
getNumber(key: string): number {
return Number(this.get(key));
}
/**
* 获取布尔值
* @param key
*/
getBoolean(key: string): boolean {
return Boolean(this.get(key));
}
/**
* 获取字典对象和数组
* @param key
*/
getJson(key: string): { [prop: string]: any } | null {
try {
return JSON.parse(this.get(key));
} catch (error) {
return null;
}
}
/**
* 检查一个key是否存在
* @param key
*/
has(key: string): boolean {
return this.get(key) !== undefined;
}
/** 开发模式 */
get isDevelopment(): boolean {
return this.get('NODE_ENV') === 'development';
}
/** 生产模式 */
get isProduction(): boolean {
return this.get('NODE_ENV') === 'production';
}
/** 测试模式 */
get isTest(): boolean {
return this.get('NODE_ENV') === 'test';
}
}
解析数据都存在envConfig
里,封装一些获取并转义value
的方法。
传递2个参数,一个是.env
文件路径,一个是验证器,配合Joi
使用,nest
官网文档把配置服务和验证字段放在一起,我觉得这样不是很科学。
我在.env
加一个配置就需要去修改ConfigService
类,它本来就是不需要修改的,我就把验证部分提取出来,这样就不用关心验证问题了。ConfigService
只关心取值问题。
上面模块里面还有一个ConfigToken
服务,它是做什么的了,它叫做令牌。
$ touch src/config/config.constants.ts
OR
编辑器新建文件config.constants.ts
里面写入常量configToken
并导出
export const ConfigToken = 'ConfigToken';
ConfigModule
的configToken
也是它。
$ touch src/config/config.decorators.ts
OR
编辑器新建文件config.decorators.ts
import { Inject } from '@nestjs/common';
import { ConfigToken } from './config.constants';
export const InjectConfig = () => Inject(ConfigToken);
使用Inject
依赖注入器注入令牌对应的服务
InjectConfig
是一个装饰器。装饰器在nest
、angular
有大量实践案例,各种装饰器,让你眼花缭乱。
简单科普一下装饰器:
写法:(总共四种:类,属性,方法,方法参数)
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
执行顺序:
config
2种方式:
// 装饰器依赖注入
constructor(
@InjectConfig() private readonly config: ConfigService<EnvConfig>,
) {
this.name = this.config.get('name');
}
// 普通依赖注入
constructor(
private readonly config: ConfigService<EnvConfig>,
) {
this.name = this.config.get('name');
}
// 通过app实例取
const config: ConfigService<EnvConfig> = app.get(ConfigService);
...
if (config.isDevelopment) {
app.use(loaderConnect.less(rootDir));
}
...
await app.listen(config.getNumber('PORT'));
普通依赖注入就够玩了,这里用装饰器依赖注入有些画蛇添足,只是说明装饰器和注入器注入令牌用法。
通过app实例取,一般用于系统启动初始化配置,后面还要其他的获取方式,用到在介绍。
应用配置对比系统配置就没有这么麻烦了,大多数数据都可以写死就行了。
$ touch src/core/constants/config.constants.ts
OR
编辑器新建文件config.constants.ts
参考cnode-egg
的config/config.default.js
export const Config = {
// 网站名字、标题
name: 'CNode技术社区',
// 网站关键词
keywords: 'nodejs, node, express, connect, socket.io',
// 网站描述
description: 'CNode:Node.js专业中文社区',
// logo
logo: '/public/images/cnodejs_light.svg',
// icon
icon: '/public/images/cnode_icon_32.png',
// 版块
tabs: [['all', '全部'], ['good', '精华'], ['share', '分享'], ['ask', '问答'], ['job', '招聘'], ['test', '测试']],
// RSS配置
rss: {
title: this.description,
link: '/',
language: 'zh-cn',
description: this.description,
// 最多获取的RSS Item数量
max_rss_items: 50,
},
// 帖子配置
topic: {
// 列表分页20
list_count: 20,
// 每天每用户限额计数10
perDayPerUserLimitCount: 10,
},
// 用户配置
user: {
// 每个 IP 每天可创建用户数
create_user_per_ip: 1000,
},
// 默认搜索方式
search: 'baidu', // 'google', 'baidu', 'local'
};
哪里需要直接导入就行了,这个比较简单。
系统配置和应用配置告一段落了,那么接下来需要配置数据。
关于mongoDB
安装,创建数据库,连接认证等操作,这里就展开了,这里有篇文章
在.env
文件里面,我们已经配置mongoDB
相关数据。
$ nest generate module core
OR
$ nest g mo core
核心模块,只会注入到AppModule
,不会注入到feature
和shared
模块里面,专门做初始化配置工作,不需要导出任何模块。
它里面包括:守卫,管道,过滤器、拦截器、中间件、全局模块、常量、装饰器
其中全局中间件和全局模块需要模块里面注入和配置。
ConfigModule
前面我们已经定义好了ConfigModule
,现在把它添加到CoreModule
中
import { Module } from '@nestjs/common';
import { ConfigModule, EnvConfig } from '../config';
import { ConfigValidate } from './config.validate';
@Module({
imports: [
ConfigModule.forRoot<EnvConfig>(null, ConfigValidate.validateInput),
],
})
export class CoreModule {
}
ConfigValidate.validateInput
是一个验证 .env
方法,nest
和官网文档一样.
mongooseModule
nest
为我们提供了@nestjs/mongoose
。
安装依赖:
$ npm install --save @nestjs/mongoose mongoose
$ npm install --save-dev @types/mongoose
配置模块:文档
...
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
...
MongooseModule.forRoot(url, config)
],
})
export class CoreModule {
}
MongooseModule
提供了2个静态方法:
Mongoose.connect()
方法useFactory
返回对应的Mongoose.connect()
方法参数,imports
依赖模块,inject
依赖服务mongoose.model()
方法@InjectModel
获取mongoose.model
,参数和forFeature
的name
一样。根模块使用: (forRoot和forRootAsync,只能注入一次,所以要在根模块导入)
这里我们需要借助配置模块里面获取配置,需要用到forRootAsync
...
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
...
MongooseModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
uri: configService.get('MONGODB_URI'),
useNewUrlParser: true,
}),
inject: [ConfigService],
})
],
})
export class CoreModule {
}
如果要写MongooseOptions
怎么办
直接在uri后面写,有个必须的配置要写:
DeprecationWarning: current URL string parser is deprecated, and will be removed in a future version. To use the new parser, pass option { useNewUrlParser: true } to MongoClient.connect.
其他配置根据自己需求来添加
如果启动失败会显示:
MongoError: Authentication failed.
请检查uri是否正确,如果启动验证,账号是否验证通过,数据库名是否正确等等。
数据库连接成功,我们进行下一步,定义用户表。
建立数据模型为后面控制器提供服务
shared
模块$ nest generate module shared
OR
$ nest g mo shared
mongodb
模块$ nest generate module shared/mongodb
OR
$ nest g mo shared/mongodb
user
模块$ nest generate module shared/mongodb/user
OR
$ nest g mo shared/mongodb/user
user
服务$ nest generate service shared/mongodb/user
OR
$ nest g s shared/mongodb/user
user
的interface
、schema
、index
。这三个文件无法用命令创建需要自己手动创建。
$ touch src/shared/mongodb/user/user.interface.ts
$ touch src/shared/mongodb/user/user.schema.ts
$ touch src/shared/mongodb/user/index.ts
OR
编辑器新建文件`user.interface.ts`
编辑器新建文件`user.schema.ts`
编辑器新建文件`index.ts`
interface
是ts
接口定义schema
是定义mongodb
的schema
最后完整的user
文件夹是:
index.ts
user.module.ts
user.service.ts
user.schema.ts
user.interface.ts
基本所有的
mongodb
模块都是这样的结构,后面不在介绍生成文件
这项。
默认生产的模块文件
import { Injectable } from '@nestjs/common';
@Injectable()
export class UserService {
constructor() { }
}
在正式写UserService
之前,我们先思考一个问题,因为操作数据库服务基本都类似,常用几个方法如:
一个基本表应该有增删改查这样8个快捷操作方法,如果每个表都写一个这样的,就比较多余了。Typescript
给我们提供一个抽象类,我们可以把这些公共方法写在里面,然后用其他服务来继承。那我们开始写base.service.ts
:
base.service.ts
/**
* 抽象CRUD操作基础服务
* @export
* @abstract
* @class BaseService
* @template T
*/
export abstract class BaseService<T extends Document> {
constructor(private readonly _model: Model<T>) {}
/**
* 获取指定条件全部数据
* @param {*} conditions
* @param {(any | null)} [projection]
* @param {({
* sort?: any;
* limit?: number;
* skip?: number;
* populates?: ModelPopulateOptions[] | ModelPopulateOptions;
* [key: string]: any;
* })} [options]
* @returns {Promise<T[]>}
* @memberof BaseService
*/
findAll(conditions: any, projection?: any | null, options?: {
sort?: any;
limit?: number;
skip?: number;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: any;
}): Promise<T[]> {
const { option, populates } = options;
const docsQuery = this._model.find(conditions, projection, option);
return this.populates<T[]>(docsQuery, populates);
}
/**
* 获取带分页数据
* @param {*} conditions
* @param {(any | null)} [projection]
* @param {({
* sort?: any;
* limit?: number;
* offset?: number;
* page?: number;
* populates?: ModelPopulateOptions[] | ModelPopulateOptions;
* [key: string]: any;
* })} [options]
* @returns {Promise<Paginator<T>>}
* @memberof BaseService
*/
async paginator(conditions: any, projection?: any | null, options?: {
sort?: any;
limit?: number;
offset?: number;
page?: number;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: any;
}): Promise<Paginator<T>> {
const result: Paginator<T> = {
data: [],
total: 0,
limit: options.limit ? options.limit : 10,
offset: 0,
page: 1,
pages: 0,
};
const { offset, page, option } = options;
if (offset !== undefined) {
result.offset = options.offset;
options.skip = offset;
} else if (page !== undefined) {
result.page = page;
options.skip = (page - 1) * result.limit;
result.pages = Math.ceil(result.total / result.limit) || 1;
} else {
options.skip = 0;
}
result.data = await this.findAll(conditions, projection, option);
result.total = await this.count(conditions);
return Promise.resolve(result);
}
/**
* 获取单条数据
* @param {*} conditions
* @param {*} [projection]
* @param {({
* lean?: boolean;
* populates?: ModelPopulateOptions[] | ModelPopulateOptions;
* [key: string]: any;
* })} [options]
* @returns {(Promise<T | null>)}
* @memberof BaseService
*/
findOne(conditions: any, projection?: any, options?: {
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: any;
}): Promise<T | null> {
const { option, populates } = options;
const docsQuery = this._model.findOne(conditions, projection, option);
return this.populates<T>(docsQuery, populates);
}
/**
* 根据id获取单条数据
* @param {(any | string | number)} id
* @param {*} [projection]
* @param {({
* lean?: boolean;
* populates?: ModelPopulateOptions[] | ModelPopulateOptions;
* [key: string]: any;
* })} [options]
* @returns {(Promise<T | null>)}
* @memberof BaseService
*/
findById(id: any | string | number, projection?: any, options?: {
lean?: boolean;
populates?: ModelPopulateOptions[] | ModelPopulateOptions;
[key: string]: any;
}): Promise<T | null> {
const { option, populates } = options;
const docsQuery = this._model.findById(this.toObjectId(id), projection, option);
return this.populates<T>(docsQuery, populates);
}
/**
* 获取指定查询条件的数量
* @param {*} conditions
* @returns {Promise<number>}
* @memberof UserService
*/
count(conditions: any): Promise<number> {
return this._model.countDocuments(conditions).exec();
}
/**
* 创建一条数据
* @param {T} docs
* @returns {Promise<T>}
* @memberof BaseService
*/
async create(docs: Partial<T>): Promise<T> {
return this._model.create(docs);
}
/**
* 删除指定id数据
* @param {string} id
* @returns {Promise<T>}
* @memberof BaseService
*/
async delete(id: string, options: {
/** if multiple docs are found by the conditions, sets the sort order to choose which doc to update */
sort?: any;
/** sets the document fields to return */
select?: any;
}): Promise<T | null> {
return this._model.findByIdAndRemove(this.toObjectId(id), options).exec();
}
/**
* 更新指定id数据
* @param {string} id
* @param {Partial<T>} [item={}]
* @returns {Promise<T>}
* @memberof BaseService
*/
async update(id: string, update: Partial<T>, options: ModelFindByIdAndUpdateOptions = { new: true }): Promise<T | null> {
return this._model.findByIdAndUpdate(this.toObjectId(id), update, options).exec();
}
/**
* 删除所有匹配条件的文档集合
* @param {*} [conditions={}]
* @returns {Promise<WriteOpResult['result']>}
* @memberof BaseService
*/
async clearCollection(conditions = {}): Promise<WriteOpResult['result']> {
return this._model.deleteMany(conditions).exec();
}
/**
* 转换ObjectId
* @private
* @param {string} id
* @returns {Types.ObjectId}
* @memberof BaseService
*/
private toObjectId(id: string): Types.ObjectId {
return Types.ObjectId(id);
}
/**
* 填充其他模型
* @private
* @param {*} docsQuery
* @param {*} populates
* @returns {(Promise<T | T[] | null>)}
* @memberof BaseService
*/
private populates<R>(docsQuery, populates): Promise<R | null> {
if (populates) {
[].concat(populates).forEach((item) => {
docsQuery.populate(item);
});
}
return docsQuery.exec();
}
}
这里说几个上面没有提到的属性和方法:
那么我们接下来的UserService
就简单多了
user.service.ts
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base.service';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { User } from './user.interface';
@Injectable()
export class UserService extends BaseService<User> {
constructor(
@InjectModel('User') private readonly userModel: Model<User>,
) {
super(userModel);
}
}
BaseService
是一个泛型,泛型是什么,简单理解就是你传什么它就是什么。T
需要把我们User
类型传进去,返回都是User
类型,使用@InjectModel('User')
注入模型实例,最后赋值给_model
。
我们现在数据库UserService
就已经完成了,接下来就需要定义schema
和interface
。
有了上面服务的经验,现在是不是你会说schema
有没有公用的,当然可以呀。
我们定一个base.schema.ts
,思考一下需要抽出来,好像唯一可以抽出来就是:
这2个我们可以用抽出来,可以使用schema
配置参数里面的timestamps
属性,可以开启它,它默认createdAt
和updatedAt
。我们修改它们字段名,使用它们好处,创建自动赋值,修改时候自动更新。
注意:它们的存的时间和本地时间相差8小时,这个后面说怎么处理。
那么我们最终的配置就是:
export const schemaOptions: SchemaOptions = {
toJSON: {
virtuals: true,
getters: true,
},
timestamps: {
createdAt: 'create_at',
updatedAt: 'update_at',
},
};
toJSON
是做什么的,我们需要开启显示virtuals
虚拟数据,getters
获取数据。
关于schema定义
在创建表之前我们需要跟大家说一下mongoDB的数据类型,具体数据类型如下:
MongoDB
中的字符串必须为UTF-8
。BSON
元素进行比较。ctimestamp
当文档被修改或添加时,可以方便地进行录制。mongoose
使用Schema
所定义的数据模型,再使用mongoose.model(modelName, schema)
将定义好的Schema
转换为Model
。
在Mongoose
的设计理念中,Schema
用来也只用来定义数据结构,具体对数据的增删改查操作都由Model
来执行
import { Schema } from 'mongoose';
export const UserSchema = new Schema({
// 定义你的Schema
});
UserSchema.index() // 索引
UserSchema.virtual() // 虚拟值
UserSchema.pre() // 中间件
UserSchema.methods.xxx = function(){} // 实例方法
UserSchema.statics.xxx = function(){} // 静态方法
UserSchema.query.xxx = function(){} // 查询助手
UserSchema.query.xxx = function(){} // 查询助手
注意:这里面都要使用普通函数
function(){}
,不能使用()=>{}
,原因你懂的。
user.schema.ts
// 引入mongoose包
import { Schema } from 'mongoose';
// 一个工具包,使用MD5方法加密
import * as utility from 'utility';
// 引入user接口
import { User } from './user.interface';
// 定义schema并导出
export const UserSchema = new Schema({
name: { type: String },
loginname: { type: String },
pass: { type: String },
email: { type: String },
url: { type: String },
profile_image_url: { type: String },
location: { type: String },
signature: { type: String },
profile: { type: String },
weibo: { type: String },
avatar: { type: String },
githubId: { type: String },
githubUsername: { type: String },
githubAccessToken: { type: String },
is_block: { type: Boolean, default: false },
...
}, schemaOptions);
// 设置索引
UserSchema.index({ loginname: 1 }, { unique: true });
UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ score: -1 });
UserSchema.index({ githubId: 1 });
UserSchema.index({ accessToken: 1 });
// 设置虚拟属性
UserSchema.virtual('avatar_url').get(function() {
let url =
this.avatar ||
`https://gravatar.com/avatar/${utility.md5(this.email.toLowerCase())}?size=48`;
// www.gravatar.com 被墙
url = url.replace('www.gravatar.com', 'gravatar.com');
// 让协议自适应 protocol,使用 `//` 开头
if (url.indexOf('http:') === 0) {
url = url.slice(5);
}
// 如果是 github 的头像,则限制大小
if (url.indexOf('githubusercontent') !== -1) {
url += '&s=120';
}
return url;
});
...
注意:这里面使用
utility
工具包,需要安装一下,npm install utility --save
。
因为有些公共的字段,我们在定义interface
时候也需要抽离出来。使用base.interface.ts
base.interface.ts
import { Document, Types } from 'mongoose';
export interface BaseInterface extends Document {
_id: Types.ObjectId; // mongodb id
id: Types.ObjectId; // mongodb id
create_at: Date; // 创建时间
update_at: Date; // 更新时间
}
interface
文件内容和 schema
的基本一样,只需要字段名和类型就好了。
user.interface.ts
import { BaseInterface } from '../base.interface';
export interface User extends BaseInterface {
name: string; // 显示名字
loginname: string; // 登录名
pass: string; // 密码
age: number; // 年龄
email: string; // 邮箱
active: boolean; // 是否激活
collect_topic_count: number; // 收集话题数
topic_count: number; // 发布话题数
score: number; // 积分
is_star: boolean; //
is_block: boolean; // 是否黑名单
...
}
注意:如果是
schema
里面不是定义必填或者有默认值的字段,需要这样写is_admin?: boolean;
,?
表示该字段可选的。最好在interface
里面写上每个字段加上注释,方便查看。
默认生产的模块文件
import { Module } from '@nestjs/common';
@Module({
imports: [],
providers: [],
exports: [],
})
export class UserModule {}
上面schema
和service
,都定义好了,接下来我们需要在模块里面注册。
user.module.ts
import { Module } from '@nestjs/common';
// 引入 nestjs 提供的 mongoose 模块
import { MongooseModule } from '@nestjs/mongoose';
// 引入自己写的 schema 和 service 在模块里面注册
import { UserSchema } from './user.schema';
import { UserService } from './user.service';
@Module({
imports: [
MongooseModule.forFeature([{ name: 'User', schema: UserSchema }]),
],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
forFeature([{ name: 'User', schema: UserSchema }])
就是MongooseModule
为什么提供的mongoose.model(modelName, schema)
操作
注意:
providers
是注册服务,如果想要给其他模块使用,需要在exports
导出。
index.ts
export * from './user.module';
export * from './user.interface';
export * from './user.service';
注意:不是所有的文件都需要导出的,一些关键的文件,其他模块需要使用的,如果
interface
、service
都是需要导出的。
其他文件访问
xxx.service.ts
import { UserService , User } from './user';
是不是很方便。
mongodb
模块是管理所有mongodb
文件夹里模块导入导出
mongodb.module.ts
import { Module } from '@nestjs/common';
import { UserModule } from './user';
@Module({
imports: [UserModule],
exports: [UserModule],
})
export class MongodbModule { }
建立索引文件
index.ts
导出mongodb
文件夹下所有文件夹
shared
模块是管理所有shared
文件夹里模块导入导出
shared.module.ts
import { Module } from '@nestjs/common';
import { MongodbModule } from './mongodb';
@Module({
imports: [MongodbModule],
exports: [MongodbModule],
})
export class SharedModule { }
建立索引文件
index.ts
导出shared
文件夹下所有文件夹
到这里我们user
数据表模块就基本完成了,接下来就需要使用它们。我们也可以运行npm run start:dev
,不会出现任何错误,如果有错,请检查你的文件是否正确。如果找不到问题,可以联系我。
注意:后面我们搭建数据库就不再如此详细说明,只是一笔带过,大家可以看源码。
node-mailer
发送邮件如果有用户模块功能,登陆注册应该说是必备的入门功能。
先说一下我们登陆注册逻辑:
passport、passport-github、passport-local
这三个模块,做身份认证。github
第三方认证登陆(后面会介绍github认证登陆怎么玩)session
和cookie
,30天内免登陆session
和cookie
这里注册、登录、登出、找回密码都放在这个模块里面
feature
模块$ nest generate module feature
OR
$ nest g mo feature
auth
模块$ nest generate module feature/auth
OR
$ nest g mo feature/auth
auth
服务$ nest generate service feature/auth
OR
$ nest g s feature/auth
auth
控制器$ nest generate controller feature/auth
OR
$ nest g co feature/auth
auth
的dto
dto是字段参数验证的验证类,需要配合各种功能,等下会讲解。
最后完整的auth
文件夹是:
index.ts
auth.module.ts
auth.service.ts
auth.controller.ts
dto
基本所有的
feature
模块都是这样的结构,后面不在介绍生成文件
这项。
ES7
发布async/await
,也算是异步的解决又一种方案,
看一个简单的栗子:
const sleep = (time) => {
return new Promise( (resolve)=> {
setTimeout( () => {
resolve();
}, time);
})
};
const start = async () => {
// 在这里使用起来就像同步代码那样直观
console.log('start');
await sleep(3000);
console.log('end');
};
const startFor = async function () {
for (var i = 1; i <= 10; i++) {
console.log(`当前是第${i}次等待..`);
await sleep(1000);
}
};
start();
// startFor();
控制台先输出
start
,稍等3
秒后,输出了end
。
看栗子也能知道async/await
基本使用规则和条件
async
表示这是一个async
函数,await
只能用在这个函数里面await
表示在这里等待promise
返回结果了,再继续执行。await
等待的虽然是promise
对象,但不必写.then(..)
,直接可以得到返回值。try catch
语法捕捉错误await
可以写在for循环里,不必担心以往需要闭包
才能解决的问题 (注意不能使用forEach
,只可以用for/for-of
)注意:
await
必须在async
函数的上下文中
在开始之前,前面数据操作有基础服务抽象类,这里控制器和服务也可以抽象出来。是可以抽象出来,但是本项目不决定这么来做,但会做一些抽象的辅助工具。
auth.module.ts
import { Module } from '@nestjs/common';
// 引入共享模块 访问user数据库
import { SharedModule } from 'shared';
// 引入控制和服务进行在模块注册
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
@Module({
imports: [
SharedModule,
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule { }
注意:
feature
模块尽量不要导出服务,避免循环依赖。
feature.module.ts
import { Module } from '@nestjs/common';
// 引入Auth模块导入导出
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
AuthModule,
],
exports: [
AuthModule,
],
})
export class FeatureModule { }
注意:
feature
模块功能就是导入导出所以的业务模块。
如果是按我顺序用命令行创建的文件,feature
模块会自动添加到 APP
模块里面,
如果不是,需要手动把 feature
模块引入到 APP
模块里面。
app.module.ts
import { Module } from '@nestjs/common';
// 引入核心模块 只能在AppModule导入,nest 没有 angular 模块检查机制,只能自觉遵守吧。
import { CoreModule } from './core/core.module';
// 引入特性模块
import { FeatureModule } from 'feature';
@Module({
imports: [
CoreModule,
FeatureModule,
],
})
export class AppModule { }
注意:
APP
模块不需要引入shared
模块,shared
模式给业务模块引用的,APP
模块只需要引入CoreModule
,feature
模块就可以了。
默认控制器文件
import { Controller } from '@nestjs/common';
@Controller()
export class AuthController {
}
要想登录,就要先注册,那我们先从注册开始。
auth.controller.ts
import {
...
Get,
Render
} from '@nestjs/common';
@Controller()
export class AuthController {
@Get('/register')
@Render('auth/register')
async registerView() {
return { pageTitle: '注册' };
}
}
前面介绍控制器时候已经介绍了Get
,那么Render
是什么,渲染模板,对应是Express
的res.render('xxxx');
方法。
提示:
关于控制器方法命名方式,因为本项目是服务的渲染的,所有会有模板页面和页面请求。模板页面统一加上View
后缀
模板页面请求都是get
,返回数据会带一个必须字段pageTitle
,当前页面的title
标签使用。
页面请求方法命名根据实际情况来。
现在就可以运行开发启动命令看看效果,百分之两百的会报错,为什么?因为找不到模板auth/register.ejs
文件。
那我们就去views
下去创建一个auth/register.ejs
,随便写的什么,在运行就可以了,浏览器访问:http://localhost:3000/register
。
我们需要完善里面的内容了,因为cnode
屏蔽注册功能,全部走github
第三方认证登录,所以看不到https://cnodejs.org/signin
这个页面,那么我们可以在源码找到这个页面结构,直接拷贝div#content
里的内容过来。
一刷新就页面报错了:
{
"statusCode": 500,
"message": "Internal server error"
}
查看命令行提示:
[Nest] 22132 - 2018-9-4 16:21:11 [ExceptionsHandler] E:\github\nest-cnode\views\auth\register.html:61
59| <% } %>
60| </div>
>> 61| </div>
62| <input type='hidden' name='_csrf' value='<%= csrf %>' />
63|
64| <div class='form-actions'>
csrf is not defined
提示我们csrf
这个变量找不到。csrf
是什么,
跨站请求伪造(CSRF或XSRF)是一种恶意利用的网站,未经授权的命令是传播从一个web应用程序的用户信任。
减轻这种攻击可以使用csurf
包。这里有篇文章浅谈cnode社区如何防止csrf攻击
安装所需的包:
$ npm i --save csurf
在入口文件启动函数里面使用它。
import * as csurf from 'csurf';
async function bootstrap() {
const app = await NestFactory.create(AppModule, application);
...
// 防止跨站请求伪造
app.use(csurf({ cookie: true }));
...
}
直接这么写肯定有问题,刷新页面控制台报错Error: misconfigured csrf
下面来说个我经常解决问题方法:
github
的开源依赖包,我们把这个错误复制到它的issues
的搜索框里,如果有类似的问题,就进去看看,能不能找到解决方案,如果没有一个问题,你就可以提issues
。把你的问题的和环境依赖、最好有示例代码,越详细越好,运气好马上有人给你解决问题。
搜索引擎解决问题比如:谷歌、必应、百度。如果有条件首选谷歌,没条件优先必应,其次百度。也是把问题直接复制到输入框,回车就好有一些类似的答案。
就是去一些相关社区提问,和1
一样,把问题描述清楚。
使用必应搜索,发现结果第一个就是问题,和我们一模一样的。
点击链接进去的,有人回复一个收到好评最高,说app.use(csurf())
要在app.use(cookieParser())
和app.use(session({...})
之后执行。
其实我们的这个问题,在csurf说明文档里面已经有写了,使用之前必须依赖cookieParser
和session
中间件。
session
中间件可以选择express-session和cookie-session
我们需要安装2个中间件:
$ npm i --save cookie-parser express-session connect-redis
在入口文件启动函数里面使用它。
import * as cookieParser from 'cookie-parser';
import * as expressSession from 'express-session';
import * as connectRedis from 'connect-redis';
import * as csurf from 'csurf';
async function bootstrap() {
const app = await NestFactory.create(AppModule, application);
...
const RedisStore = connectRedis(expressSession);
const secret = config.get('SESSION_SECRET');
// 注册session中间件
app.use(expressSession({
name: 'jiayi',
secret, // 用来对sessionid 相关的 cookie 进行签名
store: new RedisStore(getRedisConfig(config)), // 本地存储session(文本文件,也可以选择其他store,比如redis的)
saveUninitialized: false, // 是否自动保存未初始化的会话,建议false
resave: false, // 是否每次都重新保存会话,建议false
}));
// 注册cookies中间件
app.use(cookieParser(secret));
// 防止跨站请求伪造
app.use(csurf({ cookie: true }));
...
}
里面有注释,这里就不解释了。
现在刷新还是一样报错csrf is not defined
。
上面已经ok,现在是没有这个变量,我们去registerView
方法返回值里面加上
async registerView() {
return { pageTitle: '注册', csrf: '' };
}
key是csrf
,value随便写,返回最后都会被替换的。
如果每次都要写一个那就比较麻烦了,需要写一个中间件来解决问题。
在入口文件启动函数里面使用它。
async function bootstrap() {
const app = await NestFactory.create(AppModule, application);
...
// 设置变量 csrf 保存csrfToken值
app.use((req: any, res, next) => {
res.locals.csrf = req.csrfToken ? req.csrfToken() : '';
next();
});
...
}
在刷新又报了另外一个错误:ForbiddenError: invalid csrf token
。验证token
失败。
文档里面也有,读取令牌从以下位置,按顺序:
req.body._csrf
- typically generated by the body-parser
module.req.query._csrf
- a built-in from Express.js to read from the URL query string.req.headers['csrf-token']
- the CSRF-Token HTTP request header.req.headers['xsrf-token']
- the XSRF-Token HTTP request header.req.headers['x-csrf-token']
- the X-CSRF-Token HTTP request header.req.headers['x-xsrf-token']
- the X-XSRF-Token HTTP request header.前端向后端提交数据,常用有2种方式,form
和ajax
。ajax
无刷新,这个比较常用,基本是主流操作了。form
是服务端渲染使用比较多,不需要js处理直接提交,我们项目大部分都是form
直接提交。
一般服务端渲染常用就2种请求,get
打开一个页面,post
直接form
提交。
post
提交都是把数据放在body
体里面,Express
,解析body
需要借助中间件body-parser
。
nest
已经自带body-parser
配置。但是我发现好像有bug,原因不明,给作者提issues
作者回复速度很快,需要调用app.init()
初始化才行。
还有一个重要的东西layout.html
模板需要加上csrf
这个变量。
<meta content="<%= csrf %>" name="csrf-token">
接下来要写表单验证了:
我们在dto
文件夹里面创建一个register.dto.ts
和index.ts
文件
$ touch src/feature/auth/dto/register.dto.ts
$ touch src/feature/auth/dto/index.ts
OR
编辑器新建文件register.dto.ts
编辑器新建文件index.ts
register.dto.ts
是一个导出的类,typescript类型,可以是class
,可以interface
,推荐class
,因为它不光可以定义类型,还可以初始化数据。
export class RegisterDto {
readonly loginname: string;
readonly email: string;
readonly pass: string;
readonly re_pass: string;
readonly _csrf: string;
}
什么叫dto
, 全称数据传输对象(DTO)(Data Transfer Object),简单来说DTO
是面向界面UI
,是通过UI
的需求来定义的。通过DTO
我们实现了控制器与数据验证转化解耦。
dto
中定义属性就是我们要提交的数据,控制器里面这样获取他们。
@Post('/register')
@Render('auth/register')
async register(@Body() register: RegisterDto) {
return await this.authService.register(register);
}
这样是不是很普通,也没有太大用处。如果真的是这样的,我就不会写出来了。如果我提交数据之前需要验证字段合法性怎么办。nest
也为我们想到了,使用官方提供的ValidationPipe
,并安装2个必须的依赖:
npm i --save class-validator class-transformer
因为数据验证是非常通用的,我们需要在入口文件里全局去注册管道。
async function bootstrap() {
const app = await NestFactory.create(AppModule, application);
...
// 注册并配置全局验证管道
app.useGlobalPipes(new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
skipMissingProperties: false,
forbidUnknownValues: true,
}));
...
}
配置信息官网都有介绍,说一个重点,
transform
是转换数据,配合class-transformer
使用。
开始写验证规则,对于这些装饰器使用方法,可以看文档也可以看.d.ts
文件。
...
@IsNotEmpty({
message: '用户名不能为空',
})
@Matches(/^[a-zA-Z0-9\-_]{5, 20}$/i, {
message: '用户名不合法',
})
@Transform(value => value.toLowerCase(), { toClassOnly: true })
readonly loginname: string;
@IsNotEmpty({
message: '邮箱不能为空',
})
@IsEmail({}, {
message: '邮箱不合法',
})
@Transform(value => value.toLowerCase(), { toClassOnly: true })
readonly email: string;
@IsNotEmpty({
message: '密码不能为空',
})
@IsByteLength(6, 18, {
message: '密码长度不是6-18位',
})
readonly pass: string;
@IsNotEmpty({
message: '确认密码不能为空',
})
readonly re_pass: string;
@IsOptional()
readonly _csrf?: string;
...
IsNotEmpty
不能为空Matches
使用正则表达式Transform
转化数据,这里把英文转成小写。发现一个问题,默认的提供的NotEquals、Equals
只能验证一个写死的值,那么我验证确认密码怎么办,这是动态的。我想到一个简单粗暴的方式:
@Transform((value, obj) => {
if (obj.pass === value) {
return value;
}
return 'PASSWORD_INCONSISTENCY';
}, { toClassOnly: true })
@NotEquals('PASSWORD_INCONSISTENCY', {
message: '两次密码输入不一致。',
})
先用转化装饰器,去判断,obj
拿到就当前实例类,然后去取它对应属性和当前的值对比,如果是相等就直接返回,如果不是就返回一个标识,再用NotEquals
去判断。
这样写不是很友好,我们需要自定义一个装饰器来完成这个功能。
在core新建decorators
文件夹下建validator.decorators.ts
文件
import { registerDecorator, ValidationOptions, ValidationArguments, Validator } from 'class-validator';
import { get } from 'lodash';
const validator = new Validator();
export function IsEqualsThan(property: string[] | string, validationOptions?: ValidationOptions) {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'IsEqualsThan',
target: object.constructor,
propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments): boolean{
// 拿到要比较的属性名或者路径 参考`lodash#get`方法
const [comparativePropertyName] = args.constraints;
// 拿到要比较的属性值
const comparativeValue = get(args.object, comparativePropertyName);
// 返回false 验证失败
return validator.equals(value, comparativeValue);
},
},
});
};
}
官方文字里面有栗子:直接拷贝过来就行了,改改就好。我们需要改的就是name
和validate
函数里面的内容,
validate
函数返回true验证成功,false验证失败,返回错误消息。
...
@IsNotEmpty({
message: '确认密码不能为空',
})
@IsEqualsThan('pass', {
message: '两次密码输入不一致。',
})
readonly re_pass: string;
...
注意:
IsEqualsThan
第一个参数参考[lodash#get(https://lodash.com/docs/4.17.10#get)方法
验证规则搞定了,现在又有2个新问题了,
Render
方法,可以实现数据显示,但是拿不到当前错误控制器的模板地址。这个是比较致命的问题,其他问题都好解决。解决这个问题,我纠结了很久,想到了2个方法来解决问题。
ValidationPipe
+HttpExceptionFilter
实现借助class-validator
配置参数的context
字段。
我们可以在上面写2个字段,一个是render
,一个是locals
。
在实现render
功能之前,我们需要借助typescript
的一个功能enum
枚举。
Nest
里面HttpStatus
状态码就是enum
。
我们把所有的视图模板都存在enum
里面,枚举好处就是映射,类似于key-value
对象。
// js 模拟 enum 写法
const Enum = {
a: 'a',
b: 'b'
}
// 取值
Enum[Enum.a]
// 'a'
// 字符串赋值
enum Enum {
a = 'a',
b = 'b'
}
// 取值
Enum.a
// 'a'
// 索引赋值
enum Enum {
a,
b
}
// 取值
Enum.a
// 0
typescript
转成javascript
,枚举取值Enum[Enum.a]
就是这样的。
创建视图模板路径枚举
$ touch src/core/enums/views-path.ts
OR
编辑器新建文件views-path.ts
在里面写上:
export enum ViewsPath {
Register = 'auth/register',
}
auth.controller.ts换上枚举:
...
@Post('/register')
@Render(ViewsPath.Register)
async register(@Body() register: RegisterDto, @Res() res) {
return await this.authService.register(register);
}
...
解决问题之前,我们先看,ValidationPipe
源码,验证失败之后干了些什么:
...
const errors = await classValidator.validate(entity, this.validatorOptions);
if (errors.length > 0) {
throw new BadRequestException(
this.isDetailedOutputDisabled ? undefined : errors,
);
}
...
返回是一个ValidationError[]
,那ValidationError
里面有什么:
class ValidationError {
target?: Object; // 目标对象,就是我们定义验证规则那个对象。这里是`RegisterDto`
property: string; // 当前字段
value?: any; // 当前的值
constraints: { // 验证规则错误提示,我们定义的装饰 @IsNotEmpty,显示的key是 isNotEmpty,value是定义配置里的`message`,定义多少显示多少。如果想一次只显示一个错误怎么办,后面讲怎么处理
[type: string]: string;
};
children: ValidationError[]; // 嵌套
contexts?: { // 装饰器里面配置定义的`context`内容,key是 isNotEmpty ,value是 context内容
[type: string]: any;
};
toString(shouldDecorate?: boolean, hasParent?: boolean, parentPath?: string): string; // 这玩意就不解释了。
}
最开始我想到是使用context
来配置3个字段:
// context定义内容
interface context {
render: string; // 视图模板路径
locals: boolean; // 字段是否显示
priority: number; // 验证规则显示优先级
}
// Render需要参数
interface Render {
view: string; // 视图模板路径
locals: { // 模板显示的变量
error: string; // 必须有的错误消息
[key: string]: any;
};
}
折腾一遍,功能实现了,就是太麻烦了。每个规则验证装饰器里面都要写context
一坨。
能不能简便一点了。如果我在这个类里面只定义一次是不是好点。
就想到了在RegisterDto
里写个私有属性,把相关的字段存进去,改进了context
配置:
export interface ValidatorFilterContext {
render: string;
locals: { [key: string]: boolean };
priority: { [key: string]: string[] };
}
就变成这样的:
...
__validator_filter__: {
render: ViewsPath.Register,
locals: {
loginname: true,
pass: false,
re_pass: false,
email: true,
},
priority: {
loginname: ['IsNotEmpty', 'Matches'],
pass: ['IsNotEmpty', 'IsByteLength'],
re_pass: ['IsNotEmpty', 'IsEqualsThan'],
email: ['IsNotEmpty', 'IsEmail'],
},
}
...
这样就比每个规则验证装饰器写context
配置好了很多,但是这样又有一个问题,会在target
里面多一个__validator_filter__
,有点多余了。
需要改进一下,我就想到类装饰器。
export const VALIDATOR_FILTER = '__validator_filter__';
export function ValidatorFilter(context: ValidatorFilterContext): ClassDecorator {
return (target: any) => Reflect.defineMetadata(VALIDATOR_FILTER, context, target);
}
类装饰器前面已经说过了,它是装饰器里面最后执行的,用来装饰类。这里有个比较特殊的Reflect。
Reflect
翻译叫反射,应该说叫映射靠谱点。为什么了,它基本就是类似此功能。
defineMetadata
定义元数据,有3个参数:第一个是标识key,第二个是存储的数据(获取就是它),第三个就是一个对象。
翻译过来就是在 a 对象里面定一个标识 b 的数据为c。有定义就有获取
getMetadata
获取元数据,有2个参数:第一个是标识key,第三个就是一个对象。
翻译过来就是在 a 对象里去查一个b 标识,如果有就返回原数据,如果没有就是Undefined。或者是b标识里面去查找a对象。理解差不多。目的是2个都匹配就返回数据。
这玩意简单理解Reflect
是一个全局对象,defineMetadata
定一个特定标识的数据,getMetadata
根据特定标识获取数据。这里Reflect
用的比较简单就不深入了,Reflect
是es6
新特性一部分。
在Nest
的装饰器大量使用Reflect
。在nodejs
使用,需要借助reflect-metadata
,引入方式import 'reflect-metadata';
。
处理完了,dot问题,那么我们接下来要处理异常捕获过滤器问题了。
前面也说,Nest
执行顺序:客户端请求 ---> 中间件 ---> 守卫 ---> 拦截器之前 ---> 管道 ---> 控制器处理并响应 ---> 拦截器之后 ---> 过滤器
。
因为ValidationPipe
源码里,只要验证错误就直接抛异常new BadRequestException()
,然后就直接跳过控制器处理并响应,走拦截器之后和过滤器了。
那么我们需要在过滤器来处理这些问题,这是为什么要这么麻烦原因。
Nest
已经提供一个自定义HttpExceptionFilter
的栗子,我们需要改良一下这个栗子。
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response: Response = ctx.getResponse();
const request: Request = ctx.getRequest();
const status = exception.getStatus();
// 如果错误码 400
if (status === HttpStatus.BAD_REQUEST) {
const render = validationErrorMessage(exception.message.message);
return response.render(render.view, render.locals);
}
}
}
render
接受3个参数,平常只用前个,第一个是模板路径或者模板,第二个提供给模板显示的数据。
这里核心地方在validationErrorMessage
里:
function validationErrorMessage(messages: ValidationError[]): Render {
const message: ValidationError = messages[0];
const metadata: ValidatorFilterContext = Reflect.getMetadata(VALIDATOR_FILTER, message.target.constructor);
if (!metadata) {
throw Error('context is not undefined, use @ValidatorFilter(context)');
}
// 处理错误消息显示
const priorities = metadata.priority[message.property] || [];
let error = '';
const notFound = priorities.some((key) => {
key = key.replace(/\b(\w)(\w*)/g, ($0, $1, $2) => {
return $1.toLowerCase() + $2;
});
if (!!message.constraints[key]) {
error = message.constraints[key];
return true;
}
});
// 没有找到对应错误消息,取第一个
if (!notFound) {
error = message.constraints[Object.keys(message.constraints)[0]];
}
// 处理错误以后显示数据
const locals = Object.keys(metadata.locals).reduce((obj, key) => {
if (metadata.locals[key]) {
obj[key] = message.target[key];
}
return obj;
}, {});
return {
view: metadata.render,
locals: {
error,
...locals,
},
};
}
messages
是一个数组,我们每次只显示一个错误消息,总是取第一个即可metadata
是我们根据标识获取的元数据,如果找不到,就抛出异常。注意:message.target
是一个{}
,我们需要获取它的constructor
才行。priorities
获取当前错误字段显示错误提取的优先级列表priority
里面没有配置获取配置[]
, 就直接返回验证规则第一个。提示:这也是{}
坑,默认按字母顺序排列属性的位置。locals
直接去判断配置的locals
,哪些key
可以显示哪些key
不能显示。render
使用。ViewValidationPipe
实现装饰器部分就不用说了,和上面一样,虽然不需要但是后面有用。
ViewValidationPipe
实现:
import { Injectable, Optional, ArgumentMetadata, PipeTransform } from '@nestjs/common';
import * as classTransformer from 'class-transformer';
import * as classValidator from 'class-validator';
import { ValidatorOptions } from '@nestjs/common/interfaces/external/validator-options.interface';
import { isNil } from 'lodash';
import { ValidationError } from 'class-validator';
import { VALIDATOR_FILTER } from '../constants/validator-filter.constants';
import { ValidatorFilterContext } from '../decorators';
export interface ValidationPipeOptions extends ValidatorOptions {
transform?: boolean;
disableErrorMessages?: boolean;
}
@Injectable()
export class ViewValidationPipe implements PipeTransform<any> {
protected isTransformEnabled: boolean;
protected isDetailedOutputDisabled: boolean;
protected validatorOptions: ValidatorOptions;
constructor(@Optional() options?: ValidationPipeOptions) {
options = Object.assign({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
skipMissingProperties: false,
forbidUnknownValues: true,
}, options || {});
const { transform, disableErrorMessages, ...validatorOptions } = options;
this.isTransformEnabled = !!transform;
this.validatorOptions = validatorOptions;
this.isDetailedOutputDisabled = disableErrorMessages;
}
public async transform(value, metadata: ArgumentMetadata) {
const { metatype } = metadata;
if (!metatype || !this.toValidate(metadata)) {
return value;
}
const entity = classTransformer.plainToClass(
metatype,
this.toEmptyIfNil(value),
);
const errors = await classValidator.validate(entity, this.validatorOptions);
// 重点实现 start
if (errors.length > 0) {
return validationErrorMessage(errors).locals;
}
// 重点实现 end
return this.isTransformEnabled
? entity
: Object.keys(this.validatorOptions).length > 0
? classTransformer.classToPlain(entity)
: value;
}
private toValidate(metadata: ArgumentMetadata): boolean {
const { metatype, type } = metadata;
if (type === 'custom') {
return false;
}
const types = [String, Boolean, Number, Array, Object];
return !types.some(t => metatype === t) && !isNil(metatype);
}
toEmptyIfNil<T = any, R = any>(value: T): R | {} {
return isNil(value) ? {} : value;
}
}
我们这里把validationErrorMessage
函数直接拿过来了。
控制器就需要这么写:
@Post('/register')
@Render(ViewsPath.Register)
async register(@Body(new ViewValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
skipMissingProperties: false,
forbidUnknownValues: true,
})) register: RegisterDto) {
if ((register as any).view) {
return register.locals;
}
return await this.authService.register(register);
}
pipe
转换后的结果view
表示出错了,就直接返回locals
,如果没有就接着处理服务逻辑。注意:(register as any).view
这个view
是不靠谱的,需要返回一个特殊标识,不然页面出现一个view
字段,就挂了。
这里我们使用第一种,接着实现服务逻辑。
...
async register(register: RegisterDto) {
const { loginname, email } = register;
// 检查用户是否存在,查询登录名和邮箱
const exist = await this.userService.count({
$or: [
{ loginname },
{ email },
],
});
// 返回1存在,0不存在
if (exist) {
return {
error: '用户名或邮箱已被使用。',
loginname,
email,
};
}
// hash加密密码,不能明文存储到数据库
const passhash = hashSync(register.pass, 10);
// 错误捕获 async/await 科普已经说明
try {
// 保存用户到数据库
await this.userService.create({ loginname, email, pass: passhash });
// 预留发送激活邮箱实现
// 返回注册成功信息
return {
success: `欢迎加入 ${Config.name}!我们已给您的注册邮箱发送了一封邮件,请点击里面的链接来激活您的帐号。`,
};
} catch (error) {
throw new InternalServerErrorException(error);
}
}
里面注释也说明的我们要操作的步骤,注册逻辑还是比较简单:
做登录之前完成邮箱激活的功能。
前面基础已经介绍过nest
模块,这里邮箱模块是一个通用的功能模块,我们需要抽离出来写成可配置的动态模块。nest
目前没有提供发邮箱的功能模块,我们只能自己动手写了,nodejs
发送邮件最出名使用node-mailer。我们这里也把node-mailer
封装一下。
对于一个没有写过动态模块的我,是一脸懵逼,还好作者写很多包装的功能模块:
既然不会写我们可以copy一个来仿写,实现我们要功能就ok了,卷起袖子就是干。
通过观察上面几个模块他们文件结构都是这样的:
index.ts // 导出快捷文件
mailer-options.interface.ts // 定义配置接口
mailer.constants.ts // 定义常量
mailer.providers.ts // 定义供应商
mailer.module.ts // 定义导出模块
mailer.decorators.ts // 定义装饰器
我们也来新建一个这样的结构,core/mailer
建文件就不说了。
这一个模块,就需要先从模块开始:
这是我们要实现的2个重要功能,作者写的模块基本是这个套路,有些东西我们不会写,可以先模仿。
import { DynamicModule, Module, Provider, Global } from '@nestjs/common';
import { MailerModuleAsyncOptions, MailerOptionsFactory } from './mailer-options.interface';
import { MailerService } from './mailer.service';
import { MAILER_MODULE_OPTIONS } from './mailer.constants';
import { createMailerClient } from './mailer.provider';
@Module({})
export class MailerModule {
/**
* 同步引导邮箱模块
* @param options 邮箱模块的选项
*/
static forRoot<T>(options: T): DynamicModule {
return {
module: MailerModule,
providers: [
{ provide: MAILER_MODULE_OPTIONS, useValue: options },
createMailerClient<T>(),
MailerService,
],
exports: [MailerService],
};
}
/**
* 异步引导邮箱模块
* @param options 邮箱模块的选项
*/
static forRootAsync<T>(options: MailerModuleAsyncOptions<T>): DynamicModule {
return {
module: MailerModule,
imports: options.imports || [],
providers: [
...this.createAsyncProviders(options),
createMailerClient<T>(),
MailerService,
],
exports: [MailerService],
};
}
}
forRoot
配置同步模块forRootAsync
配置异步模块我们先说和node-mailer
相关的,node-mailer
主要分2块:
node-mailer
实例,node-mailer
新版解决很多问题,自动去识别不同邮件配置,这对我们来说是一个非常好的消息,不用去做各种适配配置了,只需要按官网的相关配置即可。node-mailer
实例,set
设置配置和use
注册插件,sendMail
发送邮件创建在createMailerClient
方法里面完成
import { MAILER_MODULE_OPTIONS, MAILER_TOKEN } from './mailer.constants';
import { createTransport } from 'nodemailer';
export const createMailerClient = <T>() => ({
provide: MAILER_TOKEN,
useFactory: (options: T) => {
return createTransport(options);
},
inject: [MAILER_MODULE_OPTIONS],
});
这个方法是一个工厂方法,在介绍这个方法之前,先要回顾一下,nest
依赖注入自定义服务:
const connectionProvider = {
provide: 'Connection',
useValue: connection,
};
值服务:这个一般作为配置,定义全局常量使用,单纯key-value
形式
const configServiceProvider = {
provide: ConfigService,
useClass: process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
}
类服务:这个比较常用,默认就是类服务,如果provide
和useClass
一样,直接注册在providers
数组里即可。我们只关心provide
注入是谁,不关心useClass
依赖谁。
const connectionFactory = {
provide: 'Connection',
useFactory: (optionsProvider: OptionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
工厂服务:这个比较高级,一般需要依赖其他服务,来创建当前服务的时候,操作使用。定制服务经常用到。
我们在回过头来说上面这个createMailerClient
方法
本来我们可以直接写出一个Use factory
例子一样的,考虑它需要forRoot
和forRootAsync
都需要使用,我们写成一个函数,使用时候直接调用即可,也可以写成一个对象形式。
provide
引入我们定义的常量,至于这个常量是什么,我们不需要关心,如果它变化这个注入者也发生变化,这里不需要改任何代码。也算是配置和程序分离,一种比较好编程方式。
inject
依赖其他服务,这里依赖是一个useValue
服务,我们把邮箱配置传递给MAILER_MODULE_OPTIONS
,然后把它放到inject
,这样我们在useFactory
方法里面就可以取到依赖列表。
注意:inject
是一个数组,useFactory
参数和inject
一一对应,简单理解,useFactory
是形参,inject
数组是实参。
在useFactory
里面,我们可以根据参数做相关的操作,这里我们直接获取这个服务即可,然后使用nodemailer
提供的邮件创建方法createTransport
即可。
依赖注入和服务重点,我不关心依赖者怎么处理,我只关心注入者给我提供什么。
我们在来说上面这个MAILER_MODULE_OPTIONS
值服务
MAILER_MODULE_OPTIONS
在forRoot
里是一个值服务{ provide: MAILER_MODULE_OPTIONS, useValue: options }
,保存传递的参数。
MAILER_MODULE_OPTIONS
在forRootAsync
里是一个特殊处理...this.createAsyncProviders(options)
,后面会讲解这个函数。
注意:因为createMailerClient
依赖它,所以一定要在createMailerClient
方法完成注册。
说完通用的创建服务,来说forRootAsync
里的createAsyncProviders
方法:
createAsyncProviders
主要完成的工作是把邮箱配置和邮箱动态模块配置剥离开来,然后根据给定要求分别去处理。
createAsyncProviders
方法
/**
* 根据给定的模块选项返回异步提供程序
* @param options 邮箱模块的选项
*/
private static createAsyncProviders<T>(
options: MailerModuleAsyncOptions<T>,
): Provider[] {
if (options.useFactory) {
return [this.createAsyncOptionsProvider<T>(options)];
}
return [
this.createAsyncOptionsProvider(options),
{
provide: options.useClass,
useClass: options.useClass,
},
];
}
/**
* 根据给定的模块选项返回异步邮箱选项提供程序
* @param options 邮箱模块的选项
*/
private static createAsyncOptionsProvider<T>(
options: MailerModuleAsyncOptions<T>,
): Provider {
if (options.useFactory) {
return {
provide: MAILER_MODULE_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
};
}
return {
provide: MAILER_MODULE_OPTIONS,
useFactory: async (optionsFactory: MailerOptionsFactory<T>) => await optionsFactory.createMailerOptions(),
inject: [options.useClass],
};
}
解释这个函数之前,先看配置参数有接口:
export interface MailerModuleAsyncOptions<T> extends Pick<ModuleMetadata, 'imports'> {
/**
* 模块的名称
*/
name?: string;
/**
* 应该用于提供MailerOptions的类
*/
useClass?: Type<T>;
/**
* 工厂应该用来提供MailerOptions
*/
useFactory?: (...args: any[]) => Promise<T> | T;
/**
* 应该注入的提供者
*/
inject?: any[];
}
这里面支持2种写法,一种是自定义类,然后使用useClass
, 一种是自定义工厂,然后使用useFactory
。
使用在MailerService
服务里面完成并且把它导出给其他模块使用
import { Inject, Injectable, Logger } from '@nestjs/common';
import { MAILER_TOKEN } from './mailer.constants';
import * as Mail from 'nodemailer/lib/mailer';
import { Options as MailMessageOptions } from 'nodemailer/lib/mailer';
import { from, Observable } from 'rxjs';
import { tap, retryWhen, scan, delay } from 'rxjs/operators';
const logger = new Logger('MailerModule');
@Injectable()
export class MailerService {
constructor(
@Inject(MAILER_TOKEN) private readonly mailer: Mail,
) { }
// 注册插件
use(name: string, pluginFunc: (...args) => any): ThisType<MailerService> {
this.mailer.use(name, pluginFunc);
return this;
}
// 设置配置
set(key: string, handler: (...args) => any): ThisType<MailerService> {
this.mailer.set(key, handler);
return this;
}
// 发送邮件配置
async send(mailMessage: MailMessageOptions): Promise<any> {
return await from(this.mailer.sendMail(mailMessage))
.pipe(handleRetry(), tap(() => {
logger.log('send mail success');
this.mailer.close();
}))
.toPromise();
}
}
export function handleRetry(
retryAttempts = 5,
retryDelay = 3000,
): <T>(source: Observable<T>) => Observable<T> {
return <T>(source: Observable<T>) => source.pipe(
retryWhen(e =>
e.pipe(
scan((errorCount, error) => {
logger.error(`Unable to connect to the database. Retrying (${errorCount + 1})...`);
if (errorCount + 1 >= retryAttempts) {
logger.error('send mail finally error', JSON.stringify(error));
throw error;
}
return errorCount + 1;
}, 0),
delay(retryDelay),
),
),
);
}
@Inject
是一个注入器,接受一个provide
标识、令牌,这里我们拿到了node-mailer
实例
send
方法使用rxjs
写法,this.mailer.sendMail(mailMessage)
返回是一个Promise
,Promise
有一些缺陷,rxjs
可以去弥补一下这些缺陷。
比如这里使用是rxjs作用就是,handleRetry()
去判断发送有没有错误,如果有错误,就去重试,默认重试5次,如果还错误就直接抛出异常。tap()
类似一个console
,不会去改变数据流。
有2个参数,第一个是无错误的处理函数,第二个是有错误的处理函数。如果发送成功我们需要关闭连接。toPromise
就更简单了,看名字也知道,把rxjs
转成Promise
。
介绍完这个这个模块,那么接下来要说一下怎么使用它们:
模块注册:我们需要在核心模块里面imports
,因为邮件需要一些配置信息,比如邮件地址,端口号,发送邮件的用户和授权码,如果不知道邮箱配置可参考nodemailer官网。
MailerModule.forRootAsync<SMTPTransportOptions>({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
const mailer = configService.getKeys(['MAIL_HOST', 'MAIL_PORT', 'MAIL_USER', 'MAIL_PASS']);
return {
host: mailer.MAIL_HOST, // 邮箱smtp地址
port: mailer.MAIL_PORT * 1, // 端口号
secure: true,
secureConnection: true,
auth: {
user: mailer.MAIL_USER, // 邮箱账号
pass: mailer.MAIL_PASS, // 授权码
},
ignoreTLS: true,
};
},
inject: [ConfigService],
}),
先使用注入依赖ConfigService
,拿到配置服务,根据配置服务获取对应的配置。进行邮箱配置即可。
在页面怎么使用它们,因为本项目比较简单,只有2个地方需要使用邮箱,注册成功和找回密码时候,单独写一个mail.services
服务去处理它们,并且模板里面内容除了用户名,token等特定的数据是动态的,其他都是写死的。
mail.services
/**
* 激活邮件
* @param to 激活人邮箱
* @param token token
* @param username 名字
*/
sendActiveMail(to: string, token: string, username: string){
const name = this.name;
const subject = `${name}社区帐号激活`;
const html = `<p>您好:${username}</p>
<p>我们收到您在${name}社区的注册信息,请点击下面的链接来激活帐户:</p>
<a href="${this.host}/active_account?key=${token}&name=${username}">激活链接</a>
<p>若您没有在${name}社区填写过注册信息,说明有人滥用了您的电子邮箱,请删除此邮件,我们对给您造成的打扰感到抱歉。</p>
<p>${name}社区 谨上。</p>`;
this.mailer.send({
from: this.from,
to,
subject,
html,
});
}
这里是实现激活邮件方法,前面写的mailer
模块,服务里面提供的send
方法,接受四个最基本的参数。
this.name
是配置里面获取的name
this.from
是配置里面获取的数据,拼接而成,具体看源码this.host
是配置里面获取的数据,拼接而成,具体看源码from
邮件发起者,to
邮件接收者,subject
显示在邮件列表的标题,html
邮件内容。我们在注册成功时候直接去调用它就好了。
注意:我在本地测试,使用163邮箱作为发送者,用qq注册,就会被拦截,出现在垃圾邮箱里面。
我们实现了发现邮箱的功能,接下来就来尝试验证走注册的功能及验证邮箱验证完成注册。
因为我只要一个发送邮箱的账号,和一个测试邮箱的的账号,我需要去数据库把我之前注册的账号删除了,从新完成注册。
填写信息,点击注册,就会发送一封邮件,是这个样子的:
点击激活链接
链接跳回来激活账号:
接下来我们就来实现active_account
路由的逻辑
创建一个account.dto
@ValidatorFilter({
render: ViewsPath.Notify,
locals: {
name: true,
key: true,
},
priority: {
name: ['IsNotEmpty'],
key: ['IsNotEmpty'],
},
})
export class AccountDto {
@IsNotEmpty({
message: 'name不能为空',
})
@Transform(value => value.toLowerCase(), { toClassOnly: true })
readonly name: string;
@IsNotEmpty({
message: 'key不能为空',
})
readonly key: string;
}
这个很简单理解:需要2个参数,一个name,一个key,name是用户名,key是注册时候我们创建的标识,邮箱,密码,自定义盐混合一起加密。
通用消息模板:
<% layout('layout') -%>
<article id="content">
<div class='panel'>
<div class='header'>
<ul class='breadcrumb'>
<li><a href='/'>主页</a><span class='divider'>/</span></li>
<li class='active'>通知</li>
</ul>
</div>
<div class='inner'>
<% if (typeof error !== 'undefined' && error) { %>
<div class="alert alert-error">
<strong><%= error %></strong>
</div>
<% } %>
<% if (typeof success !== 'undefined' && success) { %>
<div class="alert alert-success">
<strong><%= success %></strong>
</div>
<% } %>
<a href="<%- typeof referer !== 'undefined' ? referer : '/' %>"><span class="span-common">返回</span></a>
</div>
</div>
</article>
这模板直接拿cnode
的页面。
接下来就是控制器:
@Controller()
export class AuthController {
constructor(
private readonly authService: AuthService,
) {}
....
/** 激活账号 */
@Get('/active_account')
@Render(ViewsPath.Notify)
async activeAccount(@Query() account: AccountDto) {
return await this.authService.activeAccount(account);
}
}
我们需要获取url
的?
后面的参数,需要用到@Query()
装饰器,配合参数验证,最后拿到数据参数,丢给对应的服务去处理业务逻辑。
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name, true);
constructor(
private readonly userService: UserService,
private readonly config: ConfigService,
private readonly mailService: MailService,
) { }
...
/** 激活账户 */
async activeAccount({ name, key }: AccountDto) {
const user = await this.userService.findOne({
loginname: name,
});
// 检查用户是否存在
if (!user) {
return { error: '用户不存在' };
}
// 对比key是否正确
if (!user || utility.md5(user.email + user.pass + this.config.get('SESSION_SECRET')) !== key) {
return { error: '信息有误,帐号无法被激活。' };
}
// 检查用户是否激活过
if (user.active) {
return { error: '帐号已经是激活状态。', referer: '/login' };
}
// 如果没有激活,就激活操作
user.active = true;
await user.save();
return { success: '帐号已被激活,请登录', referer: '/login' };
}
}
注释已经写的很清晰的,就不在叙述的问题。接下来讲我们这篇文章的最后一个问题登录,在讲到登录之前需要简单科普一下怎么才算登录,它的凭证是什么?
目前来说比较常用有2种一种是session+cookie
,一种是JSON Web Tokens
。
session+cookie是比较常见前后端一起那种。它是流程大概是这样的:
注意:以上操作都是合法操作,如果个人过失暴露 cookie 给其他人,属于用户个人的行为,比如你在网吧里登录 QQ,服务端没有办法不允许这样操作。而客户端的人应有安全意识,在公共场所及时清空 cookie,或者停止使用一切 [不随 session 关闭而 cookie 失效] 的应用。
JSON Web Tokens是比较常见前后分离那种。它是流程大概是这样的:
注意:前端是无设防的,不可以信任; 全部的校验都由后端完成
我们这里是前后端一体的,当然选择session+cookie
。这里有篇文章介绍还行,传送门。
我们这里登录需要实现2个,一个是本地登录,一个是第三方github登录。
nestjs
已经帮我们封装好了@nestjs/passport
,我们前面已经说了需要下载相关包。本地登录使用passport-local
完成。
新写个模板,需要去定义一个枚举ViewsPath 登录地址
@Controller()
export class AuthController {
constructor(
private readonly authService: AuthService,
) {}
....
/** 登录模板 */
@Get('/login')
@Render(ViewsPath.Login)
async loginView(@Req() req: TRequest) {
const error: string = req.flash('loginError')[0];
return { pageTitle: '登录', error};
}
}
和正常注册模板控制器一样,这里多了一项req.flash('loginError')[0]
,其实它是connect-flash
中间件。其实我们自己写一个也完全没有问题,本身就没有几行代码,既然有轮子就用呗,它是做什么,就是帮我们去session
记录消息,然后去获取,绑定在Request
上。你需要安装它npm install connect-flash -S
。
模板直接拷贝cnode
的登录模板,改了一下请求地址。
/** 本地登录提交 */
@Post('/login')
@UseGuards(AuthGuard('local'))
async passportLocal(@Req() req: TRequest, @Res() res: TResponse) {
this.logger.log(JSON.stringify(req.user));
this.verifyLogin(req, res, req.user);
}
/** 验证登录 */
private verifyLogin(@Req() req, @Res() res, user: User) {
// id 存入 Cookie, 用于验证过期.
const auth_token = user._id + '$$$$'; // 以后可能会存储更多信息,用 $$$$ 来分隔
// 配置 Cookie
const opts = {
path: '/',
maxAge: 1000 * 60 * 60 * 24 * 30,
signed: true,
httpOnly: true,
};
res.cookie(this.config.get('AUTH_COOKIE_NAME'), auth_token, opts); // cookie 有效期30天
// 调用 passport 的 login方法 传递 user信息
req.login(user, () => {
// 重定向首页
res.redirect('/');
});
}
这里使用守卫,AuthGuard
首页是@nestjs/passport
。verifyLogin是登录以后操作。为什么封装一个方法,等下github登录成功也是一样的操作。login
方法是passport
的方法,user
就是我们拿到的用户信息。
注意:这里的passport-local
是网上的栗子实现有差别,网上栗子都可以配置,重定向的功能,
这是passport文档里面的栗子。
app.post('/login',
passport.authenticate('local',
{
successRedirect: '/',
failureRedirect: '/login',
}),
function(req, res) {
res.redirect('/');
});
这个坑我也捣鼓很久,无论成功还是失败重定向都需要手动去处理它。成功就是上面我那个login
。
我们需要新增一个passport
文件夹,里面放passport相关的业务。
新建一个local.strategy.ts
,处理passport-local
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
constructor(private readonly authService: AuthService) {
super({
usernameField: 'name',
passwordField: 'pass',
passReqToCallback: false,
});
}
// tslint:disable-next-line:ban-types
async validate(username: string, password: string, done: Function) {
await this.authService.local(username, password)
.then(user => done(null, user))
.catch(err => done(err, false));
}
}
这里就比较简单,就这么几行代码,自定义一个本地策略,去继承@nestjs/passport
一个父类,super需要传递是new LocalStrategy('配置对象')
,validate
是一个抽象方法,我们必须要去实现的,因为@nestjs/passport
也不知道我们是怎么样查询用户是否存在,这个验证方法暴露给我们的去实现。done
就相当于是callback
,标准nodejs回调函数参数,第一个是表示错误,第二个是用户信息。
放到AuthModule
里面去做服务申明。
@Module({
imports: [SharedModule],
providers: [
AuthService,
AuthSerializer,
LocalStrategy,
],
controllers: [AuthController],
})
export class AuthModule {}
AuthSerializer也是和passport
相关的,它里面需要实现2个方法serializeUser
,deserializeUser
。
import { PassportSerializer } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class AuthSerializer extends PassportSerializer {
/**
* 序列化用户
* @param user
* @param done
*/
serializeUser(user: any, done: (error: null, user: any) => any) {
done(null, user);
}
/**
* 反序列化用户
* @param payload
* @param done
*/
async deserializeUser(payload: any, done: (error: null, payload: any) => any) {
done(null, payload);
}
constructor() {
super();
}
}
我们这里先简单粗暴把所有信息全部存到session
,先实现功能,其他后面再优化。
接下来去服务实现local
方法:
// Validation methods
const validator = new Validator();
@Injectable()
export class AuthService {
...
async local(username: string, password: string) {
// 处理用户名和密码前后空格,用户名全部小写 保证和注册一致
username = username.trim().toLowerCase();
password = password.trim();
// 验证用户名
// 可以用户名登录 /^[a-zA-Z0-9\-_]\w{4,20}$/
// 可以邮箱登录 标准邮箱格式
// 做一个验证用户名适配器
const verifyUsername = (name: string) => {
// 如果输入账号里面有@,表示是邮箱
if (name.indexOf('@') > 0) {
return validator.isEmail(name);
}
return validator.matches(name, /^[a-zA-Z0-9\-_]\w{4,20}$/);
};
if (!verifyUsername(username)) {
throw new UnauthorizedException('用户名格式不正确。');
}
// 验证密码 密码长度是6-18位
if (!validator.isByteLength(password, 6, 18)) {
throw new UnauthorizedException('密码长度不是6-18位。');
}
// 做一个获取用户适配器
const getUser = (name: string) => {
// 如果输入账号里面有@,表示是邮箱
if (name.indexOf('@') > 0) {
return this.userService.getUserByMail(name);
}
return this.userService.getUserByLoginName(name);
};
const user = await getUser(username);
// 检查用户是否存在
if (!user) {
throw new UnauthorizedException('用户不存在。');
}
const equal = compareSync(password, user.pass);
// 密码不匹配
if (!equal) {
throw new UnauthorizedException('用户密码不匹配。');
}
// 用户未激活
if (!user.active) {
// 发送激活邮件
const token = utility.md5(user.email + user.pass + this.config.get('SESSION_SECRET'));
this.mailService.sendActiveMail(user.email, token, user.loginname);
throw new UnauthorizedException('此帐号还没有被激活,激活链接已发送到 ' + user.email + ' 邮箱,请查收。');
}
// 验证通过
return user;
}
}
上面都有注释,这里说明一下为什么需要在这里去验证字段信息,这也是使用@nestjs/passport
坑。
验证使用class-validator
提供的验证器类Validator
,其他验证方法和我们注册保持一致。注释都已经一一说明。
错误都使用throw new UnauthorizedException('错误信息');
这样的方式去抛出,这也是在AuthGuard
源码里面,有个处理请求方法:
handleRequest(err, user, info): TUser {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
只要有错误,就回去走错误,这个错误就被ExceptionFilter
捕获,我们有自定义的HttpExceptionFilter
,等下就来讲它。
只有没有错误,成功才会返回user,这时候去走,serializeUser
, deserializeUser
, passportLocal
最后重定向到首页。
注意:抛出异常一定要用throw
,不用使用return
。用return
就直接走serializeUser
,然后报错了。
错误处理,因为这个身份认证只要出错返回都是401,那么我们需要去捕获处理一下,
...
case HttpStatus.UNAUTHORIZED: // 如果错误码 401
request.flash('loginError', exception.message.message || '信息不全。');
response.redirect('/login');
break;
...
默认handleRequest
返回是一个空的,exception.message.message
是undefined
,这是passport
返回,只要用户名或者密码没有填,都会返回这个错误信息,对应我们来捕获错误也是一脸懵逼,我看cndoe
是直接返回信息不全。
,这里就一样简单粗暴处理了。
说多了都是眼泪,这个地方卡了我很久。这篇文章卡壳,它需要付50%责任,因为网上没有关于
@nestjs/passport
的passport-local
的栗子。大多数都是jwt
栗子,比较折腾,试过各种方法方式。
这个玩意就本地登录简单多了。先说下流程:
我们网站叫nest-cnode
接下来我们就去实现一下:
先github申请一个认证,应用登记。
一个应用要求 OAuth 授权,必须先到对方网站登记,让对方知道是谁在请求。
所以,我们要先去 GitHub 登记一下。这是免费的。
访问这个网址,填写登记表。
应用的名称随便填,主页 URL 填写http://localhost:3000
,跳转网址填写 http://localhost:3000/github/callback
。
提交表单以后,GitHub 应该会返回客户端 ID(client ID)和客户端密钥(client secret),这就是应用的身份识别码。
我们创建一个github.strategy.ts
@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy) {
constructor(private readonly config: ConfigService) {
super({
clientID: config.get('GITHUB_CLIENT_ID'),
clientSecret: config.get('GITHUB_CLIENT_SECRET'),
callbackURL: `${config.get('HOST')}:${config.get('PORT')}/github/callback`,
});
}
// tslint:disable-next-line:ban-types
async validate(accessToken, refreshToken, profile: GitHubProfile, done: Function) {
profile.accessToken = accessToken;
done(null, profile);
}
}
需要配置clientID
, clientSecret
, callbackURL
, 这3个东西,我们上面图里面都有。把它申明到模块里面去。
github2个必备的路由:
/** github登录提交 */
@Get('/github')
@UseGuards(AuthGuard('github'))
async github() {
return null;
}
@Get('/github/callback')
async githubCallback(@Req() req: TRequest, @Res() res: TResponse) {
this.logger.log(JSON.stringify(req.user));
const existUser = await this.authService.github(req.user);
this.verifyLogin(req, res, existUser);
}
我们需要github登录时候就去请求/github
路由,使用守卫,告诉守卫使用github
策略。这个方法随便写,返回都会重定向到github.com,填完登录信息,就会自动跳转到githubCallback
方法里面,req.user
返回就是github给我们提供的所有信息。我们需要去和我们用户系统做关联。
服务github方法:
async github(profile: GitHubProfile) {
if (!profile) {
throw new UnauthorizedException('您 GitHub 账号的 认证失败');
}
// 获取用户的邮箱
const email = profile.emails && profile.emails[0] && profile.emails[0].value;
// 根据 githubId 查找用户
let existUser = await this.userService.getUserByGithubId(profile.id);
// 用户不存在则创建
if (!existUser) {
existUser = new this.userService.getMode();
existUser.githubId = profile.id;
existUser.active = true;
existUser.accessToken = profile.accessToken;
}
// 用户存在,更新字段
existUser.loginname = profile.username;
existUser.email = email || existUser.email;
existUser.avatar = profile._json.avatar_url;
existUser.githubUsername = profile.username;
existUser.githubAccessToken = profile.accessToken;
// 保存用户到数据库
try {
await existUser.save();
// 返回用户
return existUser;
} catch (error) {
// 获取MongoError错误信息
const errmsg = error.errmsg || '';
// 处理邮箱和用户名重复问题
if (errmsg.indexOf('duplicate key error') > -1) {
if (errmsg.indexOf('email') > -1) {
throw new UnauthorizedException('您 GitHub 账号的 Email 与之前在 CNodejs 注册的 Email 重复了');
}
if (errmsg.indexOf('loginname') > -1) {
throw new UnauthorizedException('您 GitHub 账号的用户名与之前在 CNodejs 注册的用户名重复了');
}
}
throw new InternalServerErrorException(error);
}
}
注意:profile
返回信息可能是个undefined
,因为认证可能会失败,需要去处理一下,不然后面代码全挂了。O(∩_∩)O哈哈~。
登录功能基本完成了,需要判断用户登录。
我们需要写一个中间件,current_user.middleware.ts
import { Injectable, NestMiddleware, MiddlewareFunction } from '@nestjs/common';
@Injectable()
export class CurrentUserMiddleware implements NestMiddleware {
constructor() { }
resolve(...args: any[]): MiddlewareFunction {
return (req, res, next) => {
res.locals.current_user = null;
const { user } = req;
if (!user) {
return next();
}
res.locals.current_user = user;
next();
};
}
}
因为passport
登录成功以后,会自动给req
添加一个属性user
,我们只需要去判断它就可以了。
注意:nestjs
中间件和express
中间件有区别:
express定义的中间件,如果全局可以直接通过express.use(中间件)
去申明使用。
nestjs定义的中间件不能这么玩,需要在模块里面去申明使用。
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(CurrentUserMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}
我们把全局的中间件都丢到AppModule
,里面去申明使用。
修改一下AppController
首页:
@Get()
@Render('index')
root() {
return {};
}
登录前:
登录后:
在弄个退出就完美了:它就更简单了:
@Controller()
export class AuthController {
/** 登出 */
@All('/logout')
async logout(@Req() req: TRequest, @Res() res: TResponse) {
// 销毁 session
req.session.destroy();
// 清除 cookie
res.clearCookie(this.config.get('AUTH_COOKIE_NAME'), { path: '/' });
// 调用 passport 的 logout方法
req.logout();
// 重定向到首页
res.redirect('/');
}
}
就是一波清空操作,调用passport
的logout
方法。
代码已更新,传送门。
欲知后事如何,请听下回分解。
中篇就到此为止了,最后感谢大家暴力吹更,让我坚持不懈的把它写完。后面就比较容易了。Typeorm比较火,等我把全部业面写完了,会更新
typeorm
版操作MongoDB
。回馈大家不离不弃的关注,再次感谢大家阅读。
本篇包含了 TypeScript 4.4 的特性和功能。
大型应用程序开发最有趣的语言之一是 Microsoft 的 TypeScript。TypeScript 的独特之处在于它是 JavaScript 的超集,具有可选类型,接口,泛型等等。与其他编译成 JavaScript 的语言不同,TypeScript 不会试图将 JavaScript 变成一种新的语言。相反,TypeScript 团队谨慎地将语言的额外功能尽可能与 JavaScript 中可用的功能(包括当前功能和草案功能)保持一致。正因为如此,TypeScript 开发人员能够利用 JavaScript 语言中最新的功能,以及一个强大的类型系统来编写更好组织的代码,同时还能利用静态类型语言提供的高级工具。
工具支持是 TypeScript 真正闪耀的地方。模块化代码和静态类型允许更好地架构项目,更容易维护。随着 JavaScript 项目规模的增长(无论是代码行数还是项目开发人员数量),这一点至关重要。TypeScript 具有快速、准确的完成、重构能力和即时反馈,这使它成为大规模 JavaScript 项目的理想语言。
开始使用TypeScript很容易。由于普通 JavaScript 是没有类型注释的 TypeScript,所以现有项目的大部分或全部可以立即使用,然后随着时间的推移进行更新,以充分利用 TypeScript 提供的功能完善整个项目迁移。
虽然自从本指南发布以后,TypeScript 的文档有了版本更新,但如果你对 JavaScript 有一定的了解,这篇指南仍然提供了对TypeScript 关键特性的最好概述之一。该指南会定期更新,提供关于 TypeScript 最新版本的新信息。(本人学习记录,仅供参考)
安装 TypeScript 只需要运行:
npm install typeScript
一旦安装完毕,TypeScript 编译器就可以通过运行 npx tsc
来使用。
如果你想在浏览器中尝试 TypeScript,TypeScript Playground 可以让你在一个完整的代码编辑器中体验 TypeScript,但有不能使用模块的限制。当然你也可以使用在线编辑器:
本指南中的大多数示例都可以直接粘贴到
Playground
中,以便快速了解 TypeScript 是如何编译成易于阅读的 JavaScript 的。
从命令行中看出,编译器可以在几种不同的模式下运行,可通过编译器选项进行选择。只要调用可执行文件就可以构建当前项目。调用 --noEmit
将使用类型检查项目,但不会编译出任何代码。添加 --watch
选项将启动一个服务器进程,该进程将持续监视项目,并在文件更改时增量地重新构建项目,这比从头开始执行完整的编译要快得多。TS 3.4 中添加了 --incremental
标志,允许编译器将一些编译器状态保存到文件中,从而使后续的完整编译速度更快(尽管不如基于监视的重建速度快)。
TypeScript编译器是高度可配置的,允许用户定义源文件的位置、它们应该如何编译、是否应该处理标准JavaScript文件以及以及类型检查器的严格程度。tsconfig.json 文件向TypeScript编译器标识一个项目,并包含用于构建TS项目的设置,比如编译器标志。大多数配置选项也可以直接传递给 tsc
命令。
这是 Angular 项目的框架包中的 tsconfig.json:
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@env": ["src/environments/environment.ts"],
"@app/*": ["src/app/*"],
},
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "es2017",
"module": "es2020",
"lib": ["es2018", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
tsconfig.app.json:
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.d.ts"
]
}
extends
属性表示该文件正在扩展另一个 tsconfi.json
文件,就像扩展类一样,被扩展的文件中的设置被用作默认设置,而执行扩展的文件中的设置被覆盖。angularCompilerOptions
属性表明项目可以使用 Angular-cli 优化配置。include 选项告诉编译器要在编译中包含哪些文件。
TypeScript 提供了许多选项来控制编译器的工作方式,比如放宽类型检查的严格性,或者允许处理普通的 JavaScript 文件。这是TypeScript 最好的部分之一,它允许将 TypeScript 添加到现有的项目中,而不需要将整个项目转换为完整类型的 TypeScript。例如,当noImplicitAny
设置为 false
时,将阻止编译器发出有关没有型变量的警告。随着时间的推移,项目可以禁用这一功能,并启用更严格的处理选项,从而允许团队逐步完善整个类型的代码。对于新的 TypeScript 项目,建议从一开始就启用 strict 标志,以获得 TypeScript 的全部好处收益。
TypeScript 支持当前的 JavaScript 语法(通过ES2021),以及许多语言提案草案。在大多数情况下,即使在使用新特性时,TypeScript 也能编译出与旧 JavaScript 运行时兼容的代码,这使得开发者可以使用仍然可以在旧环境中运行的现代JS特性编写代码。
TypeScript 支持的建议 JavaScript 特性包括:
target: es2015
)TypeScript 文件使用 .ts
文件扩展名,每个文件通常代表一个模块,与 AMD,CommonJs 和 JavaScript模块(ESM)文件类似。TypeScript 使用了一个宽松版的 JavaScript import
API 来从模块中导入和导出资源:
import myModule from './myModule';
与标准 ESM 导入的主要区别在于,TypeScript 在引用模块时不需要绝对 url 和文件扩展名。它将采用 .ts
或 .js
文件扩展名,并使用两个不同的模块解析策略来定位模块。
对于 AMD、SystemJS 和 ES2015 模块,TypeScript 默认采用 classic
策略。对于任何其他模块类型,它默认其为 node
策略。可以使用 moduleResolution 配置选项手动设置策略。
在使用 classic
策略时,相对模块id将相对于包含引用模块的目录进行解析。对于绝对模块 ID,编译器遍历文件系统,从包含引用模块的目录开始,查找 .ts
,然后查找 .d.ts
。在每个父目录中,直到找到匹配。
node
策略使用 node
的模块解析逻辑。相对模块 ID 是相对于包含引用模块的目录进行解析的,它将考虑 package.json
中的 main
字段是否存在。首先在本地 node_modules
目录中查找被引用的模块来解析绝对模块 ID,然后通过遍历目录层次结构,在 node_modules
目录中查找模块。
在这两种策略中,baseUrl、paths 和 rootDirs 选项都可以用于进一步配置编译器查找绝对引用模块的位置。
TypeScript 在处理 CommonJS 等遗留模块格式时,可以模拟 ESM 的默认导入语义。启用 esModuleInterop 标志将使编译器发出代码,允许默认导入在技术上没有默认导出的遗留模块上工作。
类型是 TypeScript 引以为傲的特性。TS 编译器为程序中的每个值(变量、函数参数、返回值等)确定一个类型,并将这些类型用于一系列特性,从提示何时使用错误的输入调用函数到允许 IDE 自动完成类属性名。
如果没有额外的类型提示,TypeScript 中的所有变量都有 any 类型,这意味着它们可以包含任何类型的数据,就像 JavaScript 变量一样。在 TypeScript 中向代码添加类型约束的基本语法,如下所示:
function toNumber(numberString: string): number {
const num: number = parseFloat(numberString);
return num;
}
上面代码中类型提示表明,toNumber
接受一个字符串参数,并返回一个数字。变量 num
也可以显式声明为一个数字。注意,在很多情况下,显式类型提示是不需要的(尽管提供它们可能还是有好处的),因为TypeScript可以从代码本身推断它们。例如,number
类型可以在 num
声明中去掉,因为 TS 编译器知道 parseFloat
返回一个数字。同样,也不需要数字返回类型,因为编译器知道函数总是返回一个数字。
TypeScript 提供的原始类型与 JavaScript 本身的原始类型相匹配:
在大多数情况下,对于编译器检测到不可访问代码的函数,never
被推断出来,因此开发人员通常不会直接使用 never
。例如,如果一个函数只抛出异常,它的返回类型将是 never
。
unknown
是 any
的类型安全对应物。任何东西都可以赋值给 unknown
变量,但是在没有类型断言或类型受限制的情况下,unknown
值只能赋值给 any
变量以外的任何东西。
编写表达式时(函数调用、算术运算等),还可以使用类型断言显式地声明表达式的结果类型,当调用一个 TypeScript 无法自动计算出返回类型的函数时,这是必要的。例如:
function numberStringSwap(value: any, radix: number = 10): any {
if (typeof value === 'string') {
return parseInt(value, radix);
} else if (typeof value === 'number') {
return String(value);
}
}
const num = numberStringSwap('1234') as number;
// <> 断言容易和 tsx 中的 React 产生冲突 推荐使用 as
const str = <string>numberStringSwap(1234);
在本例中,numberStringSwap
的返回值被声明为 any
,因为该函数可能返回多个类型。为了消除歧义,赋给 num
的表达式的类型由调用 numberStringSwap
之后的 as number
修饰符显式声明。
类型断言必须是兼容的类型。如果 TypeScript 知道 numberStringSwap('1234')
返回了一个字符串,那么试图断言该值是一个数字将导致编译器错误('Cannot convert string to number'),因为已知这两种类型是不兼容的。
还有一种使用尖括号(<>)进行类型转换的遗留语法,如上面所示。使用尖括号的语义与使用 as
的语义相同。这曾经是默认语法,但由于与 JSX 语法冲突,它被 as 替换了。
当用TypeScript编写代码时,当无法推断类型时,显式地向变量和函数添加类型是一种很好的实践,或者当想要确保某种类型(例如函数返回类型),或者只是为了文档。当变量没有注释且无法推断其类型时,会隐式地给出 any
类型。可以在 tsconfig.json
或命令行中设置 noImplicitAny 编译器选项,将防止意外 any
隐式类型潜入你的代码。
TypeScript 也支持字符串字面值类型。例如,当知道参数的值可以匹配字符串列表中的一个时,这些参数非常有用,例如:
let easing: "ease-in" | "ease-out" | "ease-in-out";
编译器将检查任何对 easing
的赋值是否具有以下三个值之一: ease-in
、ease-out
或 ease-in-out
。
模板字面量类型是在 TypeScript 4.1 中添加的,它建立在字符串字面量类型的基础上。虽然字符串字面量类型必须由固定字符串表示,但模板字面量类型可以使用与模板字面量非常相似的语法派生它们的值。考虑描述一个元素与另一个元素对齐的场景。为了充分描述这些可能性,必须同时处理 horizontal
和 vertical
方向。这可能导致以下类型:
type VerticalAlignment = "top" | "middle" | "bottom";
type HorizontalAlignment = "left" | "center" | "right";
模板文字类型允许定义一个函数,该函数只能接受连接到 HorizontalAlignment
类型的 VerticalAlignment
类型,并用破折号分隔两个值,如下所示:
declare function setAlignment(value: `${VerticalAlignment}-${HorizontalAlignment}`): void
上面声明的 setAlignment
函数只接受有效的字符串,而不需要显式地列出水平对齐和垂直对齐的9种可能组合。
除了原始类型之外,TypeScript 还允许在类型约束中轻松定义和使用复杂类型(比如对象和函数)。正如在 JavaScript 中,对象字面量是大多数对象定义的根,在 TypeScript 中,对象类型字面量也是大多数对象类型定义的根。在其最基本的形式中,它看起来非常类似于普通的 JavaScript 对象字面量:
let point: {
x: number;
y: number;
};
在本例中,point
变量被定义为接受任何具有数字 x
和 y
属性的对象。注意,与普通的对象字面值不同,对象类型字面值使用分号而不是逗号分隔字段。
TypeScript还包括一个 object
类型,它表示任何非原始类型(例如,不是数字、字符串等)。这个类型不同于 Object
, Object
可以表示任何JavaScript类型(包括原始类型)。例如,Object.create
的第一个参数必须是一个对象(非原始包装对象)或 null
。如果这个参数是 Object
类型的,TypeScript 会允许将原始类型值传递给 ``Object.create
,这会导致运行时错误。当参数被类型化为对象时,TypeScript 将只允许使用非原始类型值。对象类型也不同于对象类型字面量,因为它不指定对象的任何结构。
当 TypeScript 比较两种不同的对象类型来决定它们是否匹配时,它是在结构上这样做的。这意味着编译器不像许多其他语言中的类型检查那样检查两个值是否都继承自共享祖先类型,而是比较每个对象的属性,看它们是否兼容。如果被赋值的对象具有对被赋值的变量的约束所要求的所有属性,并且属性类型是兼容的,则认为这两种类型是兼容的:
let point: { x: number; y: number; };
// OK, 属性匹配
point = { x: 0, y: 0 };
// Error, x 属性类型错误
point = { x: 'zero', y: 0 };
// Error, 缺少所需属性 y
point = { x: 0 };
// Error, 对象字面量只能指定已知的属性
point = { x: 0, y: 0, z: 0 };
const otherPoint = { x: 0, y: 0, z: 0 };
// OK, 与非字面量赋值无关的额外属性
point = otherPoint;
请注意在为带有额外属性的文字对象赋值时的错误。字面量值比非字面量值更严格地被检查。为了减少类型重复,可以使用typeof
操作符引用值的类型。例如,如果我们要加一个 point2
变量,而不是写这个:
let point: { x: number; y: number; };
let point2: { x: number; y: number; };
我们可以简单地使用 typeof
引用 point
的类型:
let point: { x: number; y: number; };
let point2: typeof point;
这种机制有助于减少引用相同类型所需的代码量,但在TypeScript中还有另一个更强大的抽象来重用对象类型:interface
。interface
本质上是命名对象类型字面量。将前面的示例更改为使用接口,如下所示:
interface Point {
x: number;
y: number;
}
let point: Point;
let point2: Point;
此更改允许在代码中的多个位置使用 Point
类型,而不必一遍又一遍地重新定义类型的详细信息。interface
还可以使用
extends
关键字扩展其他 interface
或 class
,以便用简单类型组成更复杂的类型:
interface Point3d extends Point {
z: number;
}
在本例中,得到的 Point3d
类型将由 Point
的类型的 x
和 y
属性以及新的 z
属性组成。
对象上的方法和属性也可以指定为可选的,就像函数参数可以被指定为可选的一样:
interface Point {
x: number;
y: number;
z?: number;
}
这里,我们不是为一个三维点指定一个单独的 interface
,而是简单地让 interface
的 z
属性是可选的,得到的类型检查如下所示:
let point: Point;
// OK, 属性匹配
point = { x: 0, y: 0, z: 0 };
// OK, 属性匹配,可选属性缺失
point = { x: 0, y: 0 };
// Error, `z` 属性类型错误
point = { x: 0, y: 0, z: 'zero' };
到目前为止,我们已经研究了带有属性的对象类型,但还没有指定如何向对象添加方法。因为函数是 JavaScript 中的一级对象,它们可以像任何其他对象属性一样进行类型化(我们将在后面详细讨论函数):
interface Point {
x: number;
y: number;
z?: number;
toGeo: () => Point;
}
在这里,我们用类型 () => Point
(一个不接受参数并返回一个 Point
的函数)声明了 Point
上的一个 toGeo
属性。TypeScript 还提供了用于指定方法的简写语法,这在以后开始处理 class
时非常方便:
interface Point {
x: number;
y: number;
z?: number;
toGeo(): Point;
}
与属性一样,方法也可以通过在方法名后加一个问号来实现可选:
interface Point {
// ...
toGeo?(): Point;
}
默认情况下,可选属性被视为具有 [最初的类型]|undefined
的类型。因此,在前面的例子中,toGeo
的类型是 Point | undefined
。这意味着你可以像这样定义一个 Point
对象:
const p: Point = {
toGeo: undefined
}
通常情况下,这是可以的,但是一些内置的函数,如 Object.assign
和 Object.keys
,无论属性是否存在(且 undefined
),其行为都是不同的。从 TypeScript 4.4 开始,你现在可以使用选项 exactOptionalPropertyTypes
来告诉 TypeScript 在这些情况下不允许未定义的值。
可以为打算用作哈希映射或有序列表的对象提供索引签名,从而允许在对象上定义任意键:
interface HashMapOfPoints {
[key: string]: Point;
}
在本例中,我们定义了一个类型,只要指定的值是 Point
类型,就可以在其中设置任意字符串键。在 TypeScript 4.4 之前,就像在 JavaScript 中一样,只能使用 string
或 number
作为索引签名的类型。然而,从 TypeScript 4.4 开始,索引签名也可以包括 Symbol
和模板字符串模式。
const serviceUrl = Symbol("ServiceUrl");
const servicePort = Symbol("ServicePort");
interface Configuration {
[key: symbol]: string | number;
[key: `service-${string}`]: string | number;
}
const config: Configuration = {};
config[serviceUrl] = "my-url";
config[servicePort] = 8080;
config["service-host"] = "host";
config["service-port"] = 8080;
config["host"] = "host"; // error
对于没有索引签名的对象类型,TypeScript 只允许设置显式定义在类型上的属性。如果你试图给一个不存在的属性赋值,你会得到一个编译错误。但是,偶尔,确实希望向没有索引签名的对象添加动态属性。要做到这一点,你可以简单地使用数组表示法来设置对象的属性:a['foo'] = 'foo'
。但是,请注意,使用这个解决方案会使这些属性的类型系统失效,所以只有在最后才这样做。
interface
属性也可以使用常量值来命名,类似于普通对象上的计算属性名称。计算值必须是常量 string
、number
或Symbol
:
const Foo = 'Foo';
const Bar = 'Bar';
const Baz = Symbol();
interface MyInterface {
[Foo]: number;
[Bar]: string;
[Baz]: boolean;
}
虽然 JavaScript 本身没有 元组 ,TypeScript 使得使用数组模拟类型化元组成为可能。如果想将一个点存储为(x, y, z)元组而不是对象,可以通过在变量上指定元组类型来实现:
let point: [ number, number, number ] = [ 0, 0, 0 ];
TypeScript 3.0 通过允许它们与 rest
和 spread
表达式一起使用,以及允许可选元素,改进了对元组类型的支持。
function draw(...point: [ number, number, number? ]): void {
const [ x, y, z ] = point;
console.log('point', ...point);
}
draw(100, 200); // logs: point 100, 200
draw(100, 200, 75); // logs: point 100, 200, 75
draw(100, 200, 75, 25); // Error: Expected 2-3 arguments but got 4
在上面的例子中,draw
函数可以接受 x
、y
和可选的 z
值。TypeScript 4.0 通过允许可变长度的元组类型和带标签的元组元素进一步增强了元组类型。
let point: [x: number, y: number, z: number] = [0,0,0];
function concat<T, U>(arr1: T[], arr2: U[]): Array<T | U> {
return [...arr1, ...arr2];
}
上面的示例使用标记元组使 point
类型更具可读性,并展示了使用可变元组类型为处理一般元组类型的函数编写更简洁类型的示例。
TypeScript 4.2 通过允许 ...rest
声明元组类型中任意位置的剩余元素。
let bar: [boolean, ...string[], boolean];
注意:使用限制。
rest
元素后面不能跟另一个rest
元素或可选元素。元组类型中只能有一个rest
元素。
函数类型通常使用箭头语法定义:
let printPoint: (point: Point) => string;
这里的变量 printPoint
被描述为接受一个函数,该函数接受一个 Point
参数并返回一个字符串。同样的语法用于向另一个函数描述一个函数参数:
let printPoint: (getPoint: () => Point) => string;
注意,使用箭头(=>)来定义函数的返回类型。这与在函数声明中编写返回类型的方式不同,在函数声明中使用冒号(:):
function printPoint(point: Point): string { ... }
const printPoint = (point: Point): string => { ... }
这一点起初可能有点令人困惑,但当你使用 TypeScript 时,你会发现很容易知道什么时候应该使用其中一个。例如,在最初的printPoint
示例中,使用冒号看起来是错误的,因为它将在约束内直接导致两个冒号:
let printPoint: (point: Point): string
同样,使用带有箭头函数的箭头看起来是错误的:
const printPoint = (point: Point) => string => { ... }
函数也可以使用对象字面量语法来描述:
let printPoint: { (point: Point): string; };
这有效地将 printPoint
描述为一个可调用对象(这就是JavaScript函数)。
通过将 new
关键字放在函数类型之前,可以将函数类型定义为构造函数:
let Point: { new (): Point; };
let Point: new () => Point;
在本例中,任何分配给 Point
的函数都需要是创建 Point
对象的构造函数。
因为对象字面量语法允许我们将对象定义为函数,所以也可以用静态属性或方法定义函数类型(比如 JavaScript 的 String
函数,它也有一个静态方法 String.fromcharcode
):
let Point: {
new (): Point;
fromLinear(point: Point): Point;
fromGeo(point: Point): Point;
};
在这里,我们将 Point
定义为一个构造函数,它也需要具有静态的 Point.fromlinear
和 Point.fromgeo
方法。真正做到这一点的唯一方法是定义一个实现 Point
并具有静态 fromLinear
和 fromGeo
方法的类。在后面深入讨论 class
时,我们将了解如何做到这一点。
从 TypeScript 3.1 开始,静态字段也可以通过简单的赋值方式添加到函数中:
function createPoint(x: number, y: number) {
return new Point(x, y);
}
createPoint.print(point: Point): string {
console.log(point);
}
let p: Point = createPoint(1, 2);
createPoint.print(p); // logs point
在前面,我们创建了一个示例 numberStringSwap
函数,用于在数字和字符串之间进行转换:
function numberStringSwap(value: any, radix: number): any {
if (typeof value === 'string') {
return parseInt(value, radix);
} else if (typeof value === 'number') {
return String(value);
}
}
我们知道,当传递给该函数一个数字时,它返回一个字符串,当传递给它一个字符串时,它返回一个数字。然而,调用签名并没有指出这一点,因为 any
用于值和返回类型,TypeScript 不知道哪些特定类型的值是可以接受的,或者将返回什么类型的值。我们可以使用函数重载让编译器更多地了解函数的实际工作方式。
正确处理输入的一种方法是编写上述函数:
function numberStringSwap(value: string, radix?: number): number;
function numberStringSwap(value: number): string;
function numberStringSwap(value: any, radix: number = 10): any {
if (typeof value === 'string') {
return parseInt(value, radix);
} else if (typeof value === 'number') {
return String(value);
}
}
有了上面的类型,TypeScript 现在知道该函数可以用两种方式调用:使用字符串和可选的基数或者使用数字。如果用数字调用它,它将返回一个字符串,反之亦然。在某些情况下,还可以使用联合类型来代替函数重载,这将在后面讨论。
非常重要的一点是要记住,具体的函数实现必须有一个与所有重载签名最低公共指定者的接口匹配。这意味着如果一个参数接受多个类型,就像这里的 value
一样,具体实现必须指定包含所有可能选项的类型。在 numberStringSwap
的情况下,因为 string
和 number
,value
的类型必须是 any
(或联合类型)。
同样,如果不同的重载接受不同数量的参数,则所有重载签名中不存在的参数在具体实现中必须是可选的。对于numberStringSwap
,这意味着我们必须在具体实现中将 radix
参数设置为可选的。这是通过为 radix
指定一个默认值来实现的。
没有遵循这些规则将导致 Overload signature is not compatible with function definition
错误。
请注意,即使我们完全定义的函数使用了 value
申明为 any
类型,试图为这个参数传递另一个类型(比如 boolean
类型)会导致TypeScript 抛出一个错误,因为只有重载的签名才会用于类型检查。在多个签名将匹配给定调用的情况下,源代码中列出的第一个重载将获胜:
function numberStringSwap(value: any): any;
function numberStringSwap(value: number): string;
numberStringSwap('1234');
在这里,尽管第二个重载签名更具体,但是会使用第一个。这意味着总是需要确保你的源代码是有序的,以便更具体的重载不会被更一般的重载所掩盖。
函数重载也可以在对象类型字面量、interface 和 class 中工作:
let numberStringSwap: {
(value: number, radix?: number): string;
(value: string): number;
};
注意,因为我们定义的是一个类型而不是创建一个实际的函数声明,所以省略了 numberStringSwap
的具体实现。
TypeScript 还允许你在给函数提供一个精确的字符串作为参数时指定不同的返回类型。例如,可以这样输入 DOM createElement 方法:
createElement(tagName: 'a'): HTMLAnchorElement;
createElement(tagName: 'abbr'): HTMLElement;
createElement(tagName: 'address'): HTMLElement;
createElement(tagName: 'area'): HTMLAreaElement;
// ...
createElement(tagName: string): HTMLElement;
这将让TypeScript知道当 createElement('video')
被调用时,返回值将是一个 HTMLVideoElement
,而当 createElement('a')
被调用时,返回值将是一个 HTMLAnchorElement
。更多 HTMLElement 类型
默认情况下,TypeScript 在检查函数类型参数时有点宽松。考虑以下示例:
class Animal { breathe() { } }
class Dog extends Animal { bark() {} }
class Cat extends Animal { meow() {} }
let f1: (x: Animal) => void = (x: Animal) => x.breathe();
let f2: (x: Dog) => void = (x: Dog) => x.bark();
let f3: (x: Cat) => void = (x: Cat) => x.meow();
f1 = f2;
const c = new Cat();
f1(c); // 运行时错误
Dog
是继承 Animal
,所以赋值 f1 = f2
是有效的。然而,现在 f1
是一个只能接受 Dog
的函数,尽管它的类型说它可以接受任何 Animal
类型。尝试在 Cat
上调用 f1
会在函数尝试调用 bark
时产生运行时错误。
TypeScript允许这种情况,因为TypeScript中的函数参数是双变量的,这是不合理的(就类型而言)。可以启用 strictFunctionTypes
编译器选项来标记这种不合理的赋值。
有些函数可能接受未指定数量的参数。TypeScript 允许使用 rest 参数来表示这些参数。例如,Array.push
接受一个或多个与数组类型相同的参数。下面的示例显示了这个函数的类型。
interface Array<T> {
push(...args: T[]): number;
}
如果不使用 rest 形参,将需要为函数需要接受的每个参数数量编写一个重载。
TypeScript 包含了泛型类型的概念,可以大致被认为是一种必须包含或引用另一种类型才能完整的类型。可能已经你使用过的两种泛型类型是 Array
和 Promise
。
泛型值类型的语法是 GenericType<SpecificType>
。例如,字符串类型的数组将是 array<string>
,而解析为数字类型的Promise
将是 Promise<number>
。泛型类型可能需要不止一个特定类型,如 Converter<TInput, TOutput>
,但这并不常见。尖括号内的占位符类型称为类型参数。
要解释如何创建自己的泛型类型,请考虑如何类型化类 Array
:
interface Arrayish<T> {
map<U>(
callback: (value: T, index: number, array: Arrayish<T>) => U,
thisArg?: any
): Array<U>;
}
在本例中,Arrayish
被定义为具有单个 map
方法的泛型类型,该方法对应于来自 ECMAScript 5 的 Array#map
方法。map
方法有自己的类型参数 U
,用于声明回调函数的返回类型需要与 map
调用的返回类型相同。
实际上使用这种类型会看起来像这样:
const arrayOfStrings: Arrayish<string> = [ 'a', 'b', 'c' ];
const arrayOfCharCodes: Arrayish<number> =
arrayOfStrings.map(value => value.charCodeAt(0));
在这里,arrayOfStrings
被定义为包含字符串的 Arrayish
,而 arrayOfCharCodes
被定义为包含数字的 Arrayish
。我们在字符串数组上调用 map
,传递一个返回数字的回调函数。如果回调函数返回的是字符串而不是数字,编译器将引发类型不兼容的错误,因为 arrayOfCharCodes
是显式类型。
因为数组是一种非常常见的泛型类型,TypeScript 提供了一个简写符号: SpecificType[]
。但是请注意,在使用这种速记法时,偶尔会出现歧义。例如,type () => boolean[]
是一个返回布尔值的函数数组,还是一个返回布尔值数组的函数?答案是后者,要表示前者,通常可以写入 (()=> boolean)[]
。
TypeScript 还允许通过在类型参数中使用 extends 关键字将类型参数约束为特定类型,比如 interface PointPromise
。在本例中,只有结构上匹配 Point
的类型才能用于 T
。尝试使用其他东西,比如字符串,会导致类型错误。
interface PointPromise<T extends Promise> {
}
泛型类型可以设置默认值,这在很多情况下可以减少样板。例如,如果我们想要一个函数,它根据传递的参数创建 Arrayish
,但在没有传递参数时默认为 string
,我们将编写:
interface Arrayish<T = string> {
map<U>(
callback: (value: T, index: number, array: Arrayish<T>) => U,
thisArg?: any
): Array<U>;
}
function createArrayish(...args: T[]): Arrayish {
return args;
}
联合类型允许一个参数或变量支持多个类型。例如,如果你想要一个方便的函数,比如可以接受字符串 ID 或 document.getElementById()
,如byId函数,可以使用联合类型来实现这一点:
function byId(element: string | Element): Element {
if (typeof element === 'string') {
return document.getElementById(element);
} else {
return element;
}
}
TypeScript 足够智能,可以根据上下文将 if
块中的 element
变量输入为 string
类型,将 else
块中的 element
类型。用于缩小类型的代码被称为类型守卫,本文后面将更详细地讨论这些问题。
TypeScript 4.4 增强了类型保护,使得 instanceof
和 typeof
检查现在可以被赋给常量值。例如:
function performSomeWork(someId: number | undefined) {
const hasSomeId = typeof someId === "number";
if (hasSomeId) {
// someId 是 number
// code
}
// someId 是 number | undefined
// code
if (hasSomeId) {
// someId 是 number
// code
}
}
联合类型表明一个值可以是一种类型或另一种类型,交集类型表示一个值将是多个类型的组合,它必须满足所有成员类型的约定。例如:
interface Foo {
name: string;
count: number;
}
interface Bar {
name: string;
age: number;
}
export type FooBar = Foo & Bar;
FooBar
类型的值必须具有 name
、count
和 age
属性。
TypeScript 不需要重叠属性来拥有兼容的类型,所以可能会产生不可用的类型:
interface Foo {
count: string;
}
interface Bar {
count: number;
}
export type FooBar2 = Foo & Bar;
FooBar2
中的 count
属性是 never
类型,因为一个值不能既是字符串又是数字,这意味着不能给它赋值。
我们在前面看到,typeof
和 interfaces
是避免在需要的地方编写完整类型的值的两种方法。另一种实现方法是使用类型别名。类型别名只是对特定类型的引用。
import * as foo from './foo';
type Foo = foo.Foo;
type Bar = () => string;
type StringOrNumber = string | number;
type PromiseOrValue<T> = T | Promise<T>;
type BarkingAnimal = Animal & { bark(): void };
类型别名与 interface
非常相似。它们可以使用交集操作符进行扩展,如上面的 BarkingAnimal
类型所示。它们还可以用作
interface
的基类型(联合类型的别名除外)。
与 interface
不同,别名不受声明合并的约束。当在单个作用域中多次定义 interface
时,这些声明将被合并到单个 interface
中。另一方面,类型别名是一个命名实体,就像变量一样。与变量一样,类型声明是块作用域的,不能在同一作用域声明两个具有相同名称的类型。
通过将现有类型的属性映射到新类型,映射类型允许基于现有类型创建新类型。考虑下面的类型 Stringify
,Stringify
将具有与T
相同的属性,但这些属性的值都是 string
类型的。
type Stringify<T> = {
[P in keyof T]: string;
};
interface Point { x: number; y: number; }
type StringPoint = Stringify<Point>;
const pointA: StringPoint = { x: '4', Y: '3' }; // ok
注意,映射类型只影响类型,而不影响值。上面的 Stringify
类型实际上不会将任意值的对象转换为字符串对象。
TypeScript 2.8 增加了添加和删除 readonly 或 ? 映射属性中的修饰符。使用 + 和 - 来声明是否应该添加或删除修饰符。
type MutableRequired<T> = { -readonly [P in keyof T]-?: T[P] };
type ReadonlyPartial<T> = { +readonly [P in keyof T]+?: T[P] };
interface Point { readonly x: number; y: number; }
const pointA: ReadonlyPartial<Point> = { x: 4 };
pointA.y = 3; // Error: readonly
const pointB: MutableRequired<Point> = { x: 4, y: 3 };
pointB.x = 2; // ok
在上面的例子中,MutableRequired
使其源类型的所有属性都是非可选的和可写的,而 ReadonlyPartial
则使所有属性都是可选的和只读的。
TypeScript 3.1 引入了对元组类型进行映射并返回一个新的元组类型的能力。考虑下面的示例,其中定义了元组类型 Point
。假设在某些情况下,Point
实际上是解析为 Point
对象的 promise
。TypeScript 允许从前者创建后者类型:
type ToPromise<T> = { [K in typeof T]: Promise<T[K]> };
type Point = [ number, number ];
type PromisePoint = ToPromise<Point>;
const point: PromisePoint =
[ Promise.resolve(2), Promise.resolve(3) ]; // ok
TypeScript 4.1 通过添加使用 as
关键字将键重新映射到一个新类型的能力,继续扩展了映射类型的能力。该特性允许使用模板文字类型从泛型源的键派生可接受的键。考虑以下接口:
interface Person {
name: string;
age: number;
location: string;
}
通过组合模板文字类型和重新映射,我们可以创建一个 Getter
泛型类型,其 key 表示源类型字段的访问器方法。
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};
type LazyPerson = Getters<Person>;
某些映射类型模式非常常见,以至于它们已经成为TypeScript中的内置类型:
T
中的所有属性设置为可选的类型T
的所有属性都被设置为必填T
的所有属性都被设置为只读更多内置工具类型
条件类型允许根据提供的条件动态设置类型。所有条件类型遵循相同的格式:T extends U ? X : Y
。这可能看起来很熟悉,因为它使用了与 JavaScript 条件(三元)运算符语句 相同的语法。这句话的意思是,如果 T
可赋值给 U
,则将类型设置为 X
。否则,将类型设置为 Y
。
这似乎是一个非常简单的概念,但它可以极大地简化复杂的类型。考虑以下示例,我们希望为接受数字或字符串的函数定义类型。
declare function addOrConcat(x: number | string): number | string;
这里的类型很好,但它们不能真正传达代码的含义或意图。假设,如果参数是一个 number
,那么返回类型也将是 number
,对于 string
也是如此。为了纠正这个问题,我们可以使用函数重载:
declare function addOrConcat(x: string): string;
declare function addOrConcat(x: number): number;
declare function addOrConcat(x: number | string): number | string;
然而,这有点沉冗,将来更改可能会很繁琐。使用条件类型:
declare function addOrConcat<T extends number | string>(x: T): T extends number ? number : string;
这个函数签名是通用的,说明 T
要么是一个 number
,要么是一个 string
。条件类型用于确定返回类型,如果函数参数为 number
,则函数返回类型为 number
,否则为 string
。
在条件类型中,可以使用 infer
关键字来引入一个类型变量,TypeScript 编译器将从它的上下文进行推断。例如,可以编写一个函数,从成员推断元组的类型,并返回第一个元素作为该类型。
function first<T extends [any, any]>(pair: T): T extends [infer U, infer U] ? U : any {
return pair[0];
}
first([3, 'foo']); // 类型将成为 string | number
first([0, 0]); // 类型将成为 number
类型守卫允许在条件块中缩小类型。当处理可能是两个或多个类型的联合的类型时,或者在运行时才知道类型时,这一点非常重要。以一种与将在运行时运行的JavaScript代码兼容的方式来做到这一点,类型系统绑定到 typeof
、instanceof
和 in
(从TS 2.7开始)运算符。在使用这些检查之一的条件块中,可以保证检查的值是指定的类型,并且可以安全地使用该类型上存在的方法。
TypeScript 会使用 JavaScript的 typeof
和 instanceof
运算符作为类型守卫。
function lower(x: string | string[]) {
if (typeof x === 'string') {
// 保证 x 是一个字符串,所以我们可以放心使用 toLowerCase 方法
return x.toLowerCase();
} else {
// x 在这里是一个字符串数组,所以我们才可以放心使用 reduce 方法
return x.reduce(
(val: string, next: string) => val += `, ${next.toLowerCase()}`, '');
}
}
function clearElement(element: string | HTMLElement) {
if (element instanceof HTMLElement) {
// 保证 element 是 HTMLElement,所以我们可以访问其 innerHTML 属性
element.innerHTML = '';
} else {
// element 在这里是一个字符串所以我们可以把它传递给 querySelector 方法
const el = document.querySelector(element);
el && el.innerHTML = '';
}
}
TypeScript 根据 typeof
或 instanceof
检查的结果进行理解,x
的类型必须在 if/else
语句的每个部分中。
通过检查变量上是否存在来缩小条件内的类型守卫。
interface Point {
x: number;
y: number;
}
interface Point3d extends Point {
z: number;
}
function plot(point: Point) {
if ('z' in point) {
// point 是 Point3D
} else {
// point 是 Point
}
}
可以创建返回类型谓词的函数,显式地声明值的类型。
function isDog(animal: Animal): animal is Dog {
return typeof (animal as Dog).bark === 'function';
}
if (isDog(someAnimal)) {
someAnimal.bark(); // valid
}
animal
谓词是 Dog
,表示如果函数返回 true
,则函数的参数显式为 Dog
类型。
在大多数情况下,TypeScript 中的类与标准 JavaScript中 的类相似,但在允许正确类型化方面存在一些差异。
TypeScript 允许显式声明类字段,这样编译器就会知道类的哪些属性是有效的。类字段也可以声明为 protected
和 private
,并且从TS 3.8开始也可以使用 ECMAScript 私有字段。
class Animal {
protected _happy: boolean;
name: string;
#secretId: number;
constructor(name: string) {
this.name = name;
this.#secretId = Math.random();
}
pet(): void {
this._happy = true;
}
}
注意,TypeScript 的 private
修饰符与 ECMAScript 的私有字段没有关系,ECMAScript 的私有字段是用哈希符号表示的(例如,#privateField
)。TypeScript 中私有字段仅在编译期间是私有的,在运行时,它们可以像任何普通的类字段一样被访问。这就是为什么在 TS 代码中仍然经常看到用下划线前缀私有字段的 JavaScript 约定。另一方面,ECMAScript 私有字段具有严格的隐私性,在运行时,在类之外是完全不可访问的。
TypeScript还允许类字段使用 static
修饰符,这表明它们实际上是类本身的属性,而不是实例属性(在类的原型上)。
class Dog extends Animal {
static isDogLike(object: any): object is Dog {
return object.bark && object.pet;
}
}
if (Dog.isDogLike(someAnimal)) {
someAnimal.bark();
}
从 TypeScript 4.4 开始,你可以使用静态块来初始化静态类字段。如果需要在字段上执行一些初始化逻辑,这将特别有用。
class Configuration {
static host: string;
static port: number;
static {
try {
const config = parseConfigFile();
Configuration.host = config["host"];
Configuration.port = config["port"];
} catch (err) {
Configuration.host = "default host";
Configuration.port = 8080;
}
}
}
静态块也是初始化无法在类外部访问的私有静态字段的好方法。
属性可以声明为 readonly
,以标识只能在创建对象时设置它们。这实际上是对象属性的 const
。
class Dog extends Animal {
readonly breed: string;
constructor(name: string, breed: string) {
super(name);
this.breed = breed;
}
}
类还支持属性的 getter
和 setter
。getter
允许计算返回的值作为属性值,而 setter
允许在设置属性时运行任意代码。例如,上面的动物类可以通过 getter status
进行扩展,该 getter status
从其他属性派生状态消息。
class Animal {
protected _happy: boolean;
name: string;
#secretId: number;
constructor(name: string) {
this.name = name;
this.#secretId = Math.random();
}
pet(): void {
this._happy = true;
}
get status(): string {
return `${this.name} ${this._happy ? 'is' : 'is not'} happy`;
}
}
const animal = new Animal('Spike');
const status = animal.status; // status = 'Spike is not happy';
属性也可以在类定义中初始化。属性的初始值可以是任何赋值表达式,而不仅仅是静态值,并且每次创建新实例时都会执行:
class DomesticatedDog extends Dog {
age = Math.random() * 20;
collarType = 'leather';
toys: Toy[] = [];
}
由于为每个新实例执行初始化器,因此对象或数组是在对象原型上指定的,则不必担心它们会在实例之间共享,这减轻了使用 JavaScript 类继承库(在原型上指定属性)的人的常见困惑。
使用构造函数时,可以通过使用访问修饰符或 ``readonly` 的参数来声明并通过构造函数定义声明和初始化属性:
class DomesticatedDog extends Dog {
toys: Toy[] = [];
constructor(
public name: string,
readonly public age: number,
public collarType: string
) { }
}
在这里,name
、age
和 collarType
构造函数参数将成为类属性,并将用参数值进行初始化。
从 TypeScript 4.0 开始,类属性类型也可以从构造函数中的赋值推断出来。下面的例子:
class Animal {
sharpTeeth; // <-- 没有类型
constructor(fangs = 2) {
this.sharpTeeth = fangs;
}
}
在 TypeScript 4.0 之前,这会导致 sharpTeeth
被输入为 any
(如果使用 strict
选项则会输入一个错误)。然而,现在 TypeScript可以推断出 sharpTeeth
是与 fangs
相同的类型,而 fangs
是一个数字。
TypeScript 可以在普通的类方法中推断出 this
的类型。在不能推断它的地方,例如嵌套函数,这将默认为 any
类型。this
的类型可以通过在函数类型中提供假的第一个参数来指定。
class Dog {
name: string;
bark: () => void;
constructor(name: string) {
this.name = name;
this.bark = this.createBarkFunction();
}
createBarkFunction() {
return function(this: Dog) {
console.log(`${this.name} says hi!`);
}
}
}
设置 noImplicitThis
配置将导致 TypeScript 在默认 this
为 any
类型时发出编译错误。
在TypeScript中,接口可以扩展其他接口和类,这在组合复杂类型时非常有用,特别是当你习惯于编写mixin和使用多重继承时:
interface Chimera extends Dog, Lion, Monsterish {}
class MyChimera implements Chimera {
bark: () => string;
roar: () => string;
terrorize(): void {
// ...
}
// ...
}
MyChimera.prototype.bark = Dog.prototype.bark;
MyChimera.prototype.roar = Lion.prototype.roar;
在这个例子中,两个类(Dog
和 Lion
)和一个接口(Monsterish
)被组合成一个新的 Chimera
接口。MyChimera
类实现了该接口,将返回原始类的函数实现。注意,bark
和 roar
方法实际上被定义为属性而不是方法。这允许接口完全由类实现,尽管具体的实现实际上并不存在于类定义中。这是TypeScript中类更高级的用例之一,但如果使用得当,它可以实现非常健壮和高效的代码重用。
TypeScript 还能够处理ES2015 mixin class 的类型。mixin 是一个接受构造函数并返回一个新类(mixin类)的函数,这个新类是构造函数的扩展。
class Dog extends Animal {
name: string;
constructor(name: string) {
this.name = name;
}
}
type Constructor<T = {}> = new (...args: any[]) => T;
function canRollOver<T extends Constructor>(Animal: T) {
return class extends Animal {
rollOver() {
console.log("rolled over");
}
}
}
const TrainedDog = canRollOver(Dog);
const rover = new TrainedDog("Rover");
rover.rollOver(); // valid
rover.rollsOver(); // Error: Property 'rollsOver' does not exist on type ...
rover
的类型将是 Dog & (mixin class)
,这实际上是带有 rollOver
方法的 Dog
。
TypeScript 包含了一个枚举类型,它允许有效地表示一组常量值。例如,从 TypeScript 规范中,应用到文本的可能样式的枚举可能是这样的:
enum Style {
NONE = 0,
BOLD = 1,
ITALIC = 2,
UNDERLINE = 4,
EMPHASIS = Style.BOLD | Style.ITALIC,
HYPERLINK = Style.BOLD | Style.UNDERLINE
}
枚举可以用常量或计算值初始化,也可以自动初始化,或混合初始化。注意,自动初始化的属性必须在使用计算值初始化的属性之前出现。
enum Directions {
North, // 值为 0
South, // 值为 1
East = getDirectionValue(),
West = 10
}
枚举值也可以是字符串或数字和字符串的混合。
enum Color {
Red = "RED",
Green = "GREEN",
Blue = "BLUE"
}
数字枚举是双向映射,因此可以通过在枚举对象中查找枚举值来确定枚举值的名称。这通常不是问题,但对于限制严格的情况,const enum
可能会有帮助。当 const
应用于 enum
时,编译器将在编译时用文字值替换所有 enum
的使用,这样就不会产生运行时成本。注意,const enum
中的所有属性都必须自动初始化,或者用常量表达式初始化(无计算值)。
静态类型代码很棒,但是仍然有一些库不包含类型。TypeScript 可以开箱即用地使用这些代码,但没有类型化代码的全部好处收益。幸运的是,TypeScript还具有将类型添加到遗留和/或外部代码的机制:环境声明。
环境声明描述现有代码的类型或形态,但不提供实现。可以使用关键字 declare
声明各种结构,例如变量,类和函数。例如,jQuery
安装的全局变量定义在 DefinitelyTyped (JavaScript 第三方包的类型公共存储库)上的jQuery类型中为:
declare const jQuery: JQueryStatic;
declare const $: JQueryStatic;
当这些类型被包含在项目中时,TypeScript 就会理解有 jQuery
和 $
全局变量的类型是 JQueryStatic
。
环境类型最常见的用例之一是为整个模块或包提供类型。例如,假设我们有一个 checkUtils
包,它导出了一些对动物应用程序有用的类,如 Animal
和 Dog
。checkUtils
模块的环境模块声明如下所示:
declare module "checkUtils" {
export class Animal {
id: string;
name: string;
constructor(id: string, name: string);
}
export class Dog extends Animal {
bark(): void;
}
}
假设上面的声明是在一个包含在项目中的文件 checkUtils.d.ts
中,当模块从 checkUtils.d.ts
中导入资源时,TypeScript 会在环境声明中使用这些类型。注意 .d.ts
扩展名。这是声明文件的扩展,声明文件只能包含类型,不能包含实际的代码。因为这些文件只包含类型声明,TypeScript 不会为它们产生编译过的代码。
为了让环境声明有用,TypeScript 需要了解它们。有两种方法可以显式地让 TS 编译器知道声明文件。一种方法是在编译过程中直接使用文件包含声明文件,或者在 tsconfig.json
文件中 files
和 include
属性里配置它。另一种方法是在源文件的顶部使用一个引用三斜杠指令:
/// <reference types="jquery" />
/// <reference path="../types/checkUtils" />
这些注释告诉编译器需要加载声明文件。从软件包中查找类型的 types
,类似于模块导入工作的方法。从编译出声明文件的路径提供 path
。在这两种情况下,编译器都将在预处理期间识别指令,并将声明文件添加到编译中。
TS 编译器也会在特定的位置寻找类型声明。默认情况下,它将加载 node_modules/@types
下的任何程序包中环境类型。因此,例如,如果项目包括 @types/node
包,则编译器将为标准节点模块(如 fs
和 path
)以及像 process
的全局值具有类型定义。
TS 查找类型的目录集可以配置为 typeRoots 编译器选项。可以使用类似的types 选项来指定加载哪些类型的包。在这两种情况下,选项都将替换默认行为。如果指定了 typeRoots
,则除非在 typeRoots
中列出,否则不会包含 node_modules/@types
。类似地,如果 types
设置为 ["node"]
,即使在 node_modules/@types
中有更多的可用类型,只会自动加载 node
类型(或者 typeRoots
中的任何目录)
如果你是一个AMD用户,你可能已经习惯使用 Loader Plugins 加载文件(text!和类似的)。TypeScript 不理解插件样式的模块标识符,尽管它可以使用这种类型的模块 ID 发出 AMD 代码,它不能加载和解析引用的模块以进行类型检查,至少没有一些帮助是不行的。最初,这意味着依赖于 amd-dependency
三级斜线指令:
/// <amd-dependency path="text!foo.html" name="foo" />
declare const foo: string;
console.log(foo);
这个指令告诉 TypeScript 它应该在发出的 AMD 代码中添加一个 text!foo.html
依赖项,并且加载的依赖项的名称应该是 foo
。
从 TypeScript 2 开始,处理 AMD 依赖的首选方法是使用通配符模块和导入 .d.ts
文件,通配符模块声明描述了所有通过插件导入的行为。对于 text
插件,导入将会得到一个字符串:
declare module "text!*" {
let text: text;
export default text;
}
任何需要使用插件的文件都可以使用标准的导入语句
import foo from "text!foo.html";
在 React
发布之后不久,TypeScript 就开始流行起来。并且在 1.6 版中获得了对 React 的 JSX 语法的支持(包括类型检查的能力)。要在 TypeScript 中使用JSX语法,代码必须在一个扩展名为 .tsx
的文件中,并且必须启用编译器 jsx 选项。
TypeScript 是一个编译器,默认情况下它会使用 React 将 JSX 转换为标准 JS 使用 React.createElement
和 React.Fragment
APIs。对于不同构建方案中的互操作性,它还可以在 .jsx
文件中编译出 JSX,或者在 .js
文件中编译出 JSX,可以通过JSX选项进行配置。factory
和 fragment
函数也可以使用 jsxFactory 和 jsxFragmentFactory 选项进行更改。
TypeScript 执行控制流分析以捕获常见错误和其他可能导致维护方面的麻烦的问题,包括(但不限于):
虽然让编译器捕捉这种类型的问题可能非常有帮助,但在将 TS 添加到遗留项目时,这可能是一个问题。TS 可以捕捉到的许多问题不会导致代码编译失败,但会使代码更难理解和维护,现有的 JS 代码可能有很多这样的实例。开发人员可能不希望一次性处理所有这些问题,因此 TS 编译器允许使用编译器标志(如:allowUnreachableCode 和 noFallthroughCasesInSwitch )单独禁用这些检查。
为了使迁移遗留代码更容易,可以使用一些特殊注释来控制 TS 如何分析特定文件或文件的部分的方式:
checkJs
编译器选项时,编译器将处理 .js
文件,但不进行类型检查。将此注释添加到 .js
文件的顶部将导致对其进行类型检查。@ts-check
和 @ts-nocheck
注释过去只应用于 .js
文件,但从 TS 3.7 开始,@ts-nocheck
也可以用于 .ts
文件。
@ts-expect-error
注释在 TS 3.9 中是新增的。它在开发人员需要有意使用无效类型的情况下非常有用,比如在单元测试中。例如,验证某些运行时行为的测试可能需要调用具有无效值的函数。使用 @ts-expect-error
注释,测试可以使用无效数据调用函数,而不会生成编译器警告,并且编译器还将验证函数的输入是否正确。
src/util.ts
function checkValue(val: string): boolean {
// ...
}
tests/unit/util.ts
test('checkName with invalid data', () => {
// @ts-expect-error
expect(checkValue(5)).toBeTruthy()
});
@ts-ignore
注释可以用来阻止上面示例中的错误。但是,使用 @ts-expect-error
可以让编译器在 checkValue
的参数类型发生变化时提醒开发人员。例如,如果 checkValue
被更新为接受 string | number
,编译器将为测试代码发出一个错误,因为 checkValue(5)
不再导致预期的类型错误。这将是可操作的信息, checkValue(5)
不再正确地测试无效的数据案例。
默认情况下,catch 语句中捕获的值定义为 any 类型。
try {
throw "error";
} catch (err) {
// err 是 "any" 类型
}
从TypeScript 4开始,你可以将这些值声明为 unknown
类型,因为这种类型比 any
类型都适合。
try {
throw "error";
} catch (err: unknown) {
// err 是 "unknown" 类型
}
在 TypeScript 4.4 中,你可以通过使用 useUnknownInCatchVariables
配置选项默认启用这个选项。当使用 strict
选项时,默认启用此选项。
我将在后续的文章更深入地探讨如何使用 TypeScript 的 class 系统,并探索了一些 TypeScript 的高级特性,比如symbols 和 decorators。
随着 TypeScript 的不断发展,它不仅带来了静态类型,还带来了来自当前和未来 ECMAScript 规范的新特性。这意味着你今天就可以安全地开始使用TypeScript,而不用担心你的代码会在几个月后被彻底修改,或者你需要切换到一个新的编译器来利用最新和最强大的语言特性。每个版本的发布说明和 TypeScript wiki 中都有对任何重大变化的描述。
关于本指南中描述的任何特性的更多细节,TypeScript 语言规范 是关于该语言本身的权威资源。Stack Overflow 也是讨论 TypeScript 和提问的好地方,而官方的 TypeScript Handbook 也可以提供本指南所提供的内容之外的其他内容。
随着过去几年 JavaScript 快速发展,我认为了解 ES2015+ 和 TypeScript 的基础知识比以往任何时候都更重要,这样才能在web应用程序中有效地利用新特性。
今天就到这里吧,伙计们,玩得开心,祝你好运。
Schematics是改变现存文件系统的生成器。有了Schematics我们可以:
总体上,Schematics可以:
在你自己的工程或者在你所在的组织中使用Schematics是具有无限可能的。下面一些例子展现了你或者你的组织或如何从创建一个schematics collection中获益:
Schematics现在是Angular生态圈的一部分,不仅限于Angular工程,你可以生成想要模板。
是的,schematics与Angular CLI紧密集成。你可以在下列的CLI命令中使用schematics:
Collection是一系列的schematic。我们会在工程中collection.json中为每个schematic定义元数据。
首先,使用npm或者yarn安装schematics的CLI:
npm install -g @angular-devkit/schematics-cli
yarn add -g @angular-devkit/schematics-cli
这会创建名为 demo-schema
的文件夹,在其中已经创建了多个文件,如下所示。
schematics blank --name=demo-schema
我们使用 blank 为我们后继的工作打好基础。
collection.json
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"demo-schema": {
"aliases": ["demo"], //需要自己添加
"factory": "./demo-schema/index.ts#demoSchema",
"description": "A blank schematic.",
"schema": "./demo-schema/schema.json" //需要自己添加
}
}
}
关于怎么创建
schema.json
,下面实战项目来说明。
打开src/demo-schema/index.ts
文件,看看内容:
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function demoSchema(_options: any): Rule {
return (tree: Tree, _context: SchematicContext) => {
return tree;
};
}
demoSchema
函数_options
参数,它是命令行参数的键值对对象,和我们定义的schema.json
有关Tree
和SchmaticsContext
对象的函数Tree是变化的待命区域,包含源文件系统和一系列应用到其上面的变化。
我们能使用tree完成这些事情:
Rule是一个根据SchematicContext为一个Tree应用动作的函数,入口函数返回了一个Rule。
declare type Rule = (tree: Tree, context: SchematicContext) => Tree | Observable<Tree> | Rule | void;
要运行我们的示例,首先需要构建它,然后使用schematics命令行工具,将schematic项目目录的路径作为集合。从我们项目的根:
npm run build
# ... 等待构建完成
schematics .:demo-schema --name=test --dry-run
# ...查看在控制台生成创建的文件日志
注意:使用
--dry-run
不会生成文件,在调试时候可以使用它,如果想要创建正在的文件,去掉即可。使用npm run build -- -w
命令,修改index.ts
文件以后自动构建。
使用短连安装:
npm link demo-schema
使用ng generate
运行:
ng generate demo-schema:demo-schema
package.json
的name
最新在公司项目需要把之前的项目的通用组件提取出来,做成一个单独的组件库并且带上demo。创建了一个新的工程。组件库使用ng-packagr
,如果直接使用它去打包,会全部打包到一起,这样就会有问题,加载的时候特别大。ng-packagr
提供的二次入口,可以解决这个问题,但是又会有新问题,必须要要和src
同级目录。如果使用angular-cli
默认去生成都会自动添加到src/lib
里面,虽然可以修改path
,但是问题是一个组件模块里面有一些特定的文件:
大概就这些,如果这些用angular-cli,去生成,需要6次才能完成,也可以写一个shell
,一次性完成。但是发现太麻烦,有些东西无法控制,如果需要定制,那就需要自己来写schematics
。
这里有2部分不一样的实战内容,一种是根据固定内容直接生成模板,一种是生成模板以后修改已有关联的文件。
为什么会有这2个,第一个是为了生成组件模块,第二个是为了生成演示组件模块。
创建一个schematics
schematics blank --name=tools
这时候也会创建src/tools
,我们把它改成ui
,把collection.json
文件里面也修改了。
创建ui/schema.json
文件:
{
"$schema": "http://json-schema.org/schema",
"id": "ui",
"title": "UI Schema",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "生成一个组件模块",
"$default": {
"$source": "argv",
"index": 0
},
"x-prompt": "组件使用什么名称?"
},
"path": {
"type": "string",
"default": "projects/ui",
"description": "生成目标的文件夹路径"
},
"service": {
"type": "boolean",
"default": false,
"description": "标志是否应该生成service"
},
"directive": {
"type": "boolean",
"default": false,
"description": "标志是否应该生成directive"
},
"class": {
"type": "boolean",
"default": false,
"description": "标志是否应该生成class"
},
"types": {
"type": "boolean",
"default": false,
"description": "标志是否应该生成types/interfaces"
}
},
"required": ["name"]
}
这里可以设置你的 schematics
的命令选项,类似于在使用 ng g c --name=user
的--name
命令。
创建ui/schema.ts
文件:
export interface SimpleOptions {
name: string;
path: string;
service?: boolean;
directive?: boolean;
class?: boolean;
types?: boolean;
}
修改ui/index.ts
:
import { strings } from '@angular-devkit/core';
import {
apply,
applyTemplates,
branchAndMerge,
chain,
filter,
mergeWith,
move,
noop,
Rule,
SchematicContext,
Tree,
url,
} from '@angular-devkit/schematics';
import { parseName } from '@schematics/angular/utility/parse-name';
import { SimpleOptions } from './schema';
export function simple(_options: SimpleOptions): Rule {
return (tree: Tree, _context: SchematicContext) => {
...code
}
}
if (!_options.path) {
throw new Error('path不能为空');
}
// 处理路径
const projectPath = `/${_options.path}/${_options.name}`;
const parsedPath = parseName(projectPath, _options.name);
_options.name = parsedPath.name;
_options.path = parsedPath.path;
const templateSource = apply(url('./files'), [
applyTemplates({
...strings,
..._options,
}),
move(_options.path),
]);
我们模块在files
文件下,applyTemplates
把我们的配置转换成模板可以使用的变量并生成文件,move
把生成好的文件移动到目标路径下。
const rule = chain([branchAndMerge(chain([mergeWith(templateSource)]))]);
return rule(tree, _context);
我们前面也也介绍了,入口函数总是要返回一个rule
。chain
验证我们的配置规则。
整体看起来比较简单,这样就已经完成的整个的生成命令,下面就是关键模块定义:
files
文件下.template
后缀结尾__变量__
方式__变量@方法名__
举例:
__name@dasherize__.class.ts.template
将name变量驼峰式写法转换为连字符的写法。
模板里面如何使用,语法和EJS
一样
标签含义:
<%
'脚本' 标签,用于流程控制,无输出。<%_
删除其前面的空格符<%=
输出数据到模板(输出是转义 HTML 标签)<%-
输出非转义的数据到模板<%#
注释标签,不执行、不输出内容<%%
输出字符串 '<%'%>
一般结束标签-%>
删除紧随其后的换行符_%>
将结束标签后面的空格符删除语法示例:
<%# 变量 %>
<%= classify(name) %>
<%# 流程判断 %>
<% if (user) { %>
<h2><%= user.name %></h2>
<% } %>
<%# 循环 %>
<ul>
<% users.forEach(function(user){ %>
<%- include('user/show', {user: user}); %>
<% }); %>
</ul>
会大量使用变量和少量的流程判断,变量一般都会使用内置的模板方法来配合使用:
内置的模板变量方法:
更多模板变量:
node_modules\@angular-devkit\core\src\utils\strings.d.ts
内置的模板方法主要命名转换,如果不能满足你需求,可以自己定义:
const utils = {
...自定义方法
}
`applyTemplates({utils: utils})`。
举个几个例子:
name@dasherize.module.ts.template
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { Sim<%= classify(name) %>Component } from './<%= dasherize(name) %>.component';
@NgModule({
declarations: [Sim<%= classify(name) %>Component],
imports: [CommonModule],
exports: [Sim<%= classify(name) %>Component],
providers: [],
})
export class Sim<%= classify(name) %>Module {}
name@dasherize.component.ts.template
import {
ChangeDetectionStrategy,
Component,
ElementRef,
HostBinding,
HostListener,
Input,
OnDestroy,
OnInit,
ViewEncapsulation,
} from '@angular/core';
@Component({
selector: 'sim-<%= dasherize(name) %>',
templateUrl: './<%= dasherize(name) %>.component.html',
styleUrls: ['./<%= dasherize(name) %>.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Sim<%= classify(name) %>Component implements OnInit, OnDestroy {
constructor(protected elementRef: ElementRef) {}
public ngOnInit(): void {
}
public ngOnDestroy(): void {
}
}
index.ts.template
export * from './<%= dasherize(name) %>.component';
export * from './<%= dasherize(name) %>.module';
我们可以构建试一下:
cd tools
npm run build
schematics .:ui --name=test --dry-run
基本已经完成我们想要的,还有几个文件生成是可选的,我们需要配置处理一下:
const templateSource = apply(url('./files'), [
_options.service ? noop() : filter(path => !path.endsWith('.service.ts.template')),
_options.class ? noop() : filter(path => !path.endsWith('.class.ts.template')),
_options.directive ? noop() : filter(path => !path.endsWith('.directive.ts.template')),
_options.types ? noop() : filter(path => !path.endsWith('.type.ts.template')),
applyTemplates({
...strings,
..._options,
}),
move(_options.path),
]);
如果是true,就忽略,如果是false,就排除这个后缀结尾文件。
schematics .:ui --name=test --dry-run --service
注意:不需要写=true
。
npm link tools
ng g tools:ui --name="test" --dry-run
ng g tools:ui --name="test"
基本已经完成了,下面介绍一个进阶实战。
我们想要创建多个schematics
,需要手动添加,我们需要文件:
src/demo/index.ts
src/demo/index_spec.ts
src/demo/schema.json
src/demo/schema.ts
src/demo/files
然后在src/collection.json
里添加申明:
"schematics": {
...
"demo": {
"description": "A blank schematic.",
"factory": "./demo/index#demo",
"schema": "./demo/schema.json"
}
}
配置schema.json
:
{
"$schema": "http://json-schema.org/schema",
"id": "demo",
"title": "simple demo Schema",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "生成一个组件模块",
"$default": {
"$source": "argv",
"index": 0
},
"x-prompt": "组件使用什么名称?"
},
"path": {
"type": "string",
"default": "/projects/demo",
"description": "生成目标的文件夹路径"
}
},
"required": ["name"]
}
先保留这些Schema
,后面来丰富。
我们把前面介绍的入口函数拷贝到src/demo/index.ts
里,构建编译:
cd tools
npm run build
schematics .:demo --name=test --dry-run
# ...Nothing to be done.
这个实战和前面实战有些不一样,前面的只是一个替换生成,相当于一个入门级的,很容易学会,现在介绍一个高级点的,不光要替换生成,还要去改变已有文件的依赖关系。
使用angular-cli的时候,创建组件以后,会自动去关联的模块里面去申明,这个是怎么做到的?
我们就需要实现一个类似的功能,有一个功能需要去展示UI组件的demo,每次创建都是相当于有一套对应的模板,但是每次创建以后都是一个新的的页面,也需要一个路由规则需要添加,如果我们单纯创建一套demo组件的文件,还需要去手动添加路由,这样就比较麻烦,现在就需要自动完成这个功能。我们一起来实现它吧。
这里我们就用上beginUpdate
和commitUpdate
2个方法来实现
先介绍一下需要实现的功能:
我有三个文件夹:
guides 快速指南
experimental 实验功能
components 组件库
书写路由时候,每个ui组件,都是一个独立模块,使用懒加载模块方式,这样所有的懒加载路由都是平级的。
举个栗子:
const routes: Routes = [
{
path: '',
component: ComponentsComponent,
children: [
{
path: '',
redirectTo: 'button',
pathMatch: 'full',
},
{
path: 'button',
loadChildren: './button/button.module#ButtonModule',
},
{
path: 'card',
loadChildren: './card/card.module#CardModule',
},
{
path: 'divider',
loadChildren: './divider/divider.module#DividerModule',
},
],
},
];
其实angular也有自带添加路由依赖方法,但是只能添加一级路由,不能添加子路由,我们这个需求就是需要添加子路由。
if (!_options.path) {
throw new Error('path不能为空');
}
// 处理路径
const parsedPath = parseName(_options.path, _options.name);
_options.name = parsedPath.name;
_options.path = parsedPath.path;
const templateSource = apply(url('./files'), [
applyTemplates({
...strings,
..._options,
}),
move(parsedPath.path),
]);
模板这块就不在说明了,和实战1是一样处理的,去files文件夹里面创建对应的模板即可。
const rule = chain([addDeclarationToNgModule(_options), mergeWith(templateSource)]);
return rule(tree, _context);
其他没有什么好说明的,addDeclarationToNgModule
是我们需要重点说明的,也是这个实战的核心。
function addDeclarationToNgModule(options: DemoOptions): Rule {
return (host: Tree) => {
// 路由模块路径
const modulePath = `${options.path}/${options.module}/${options.module}-routing.module.ts`;
// 懒加载模块名字
const namePath = strings.dasherize(options.name);
// 需要刷新AST,因为我们需要覆盖目标文件。
const source = readIntoSourceFile(host, modulePath);
// 获取更新文件
const routesRecorder = host.beginUpdate(modulePath);
// 获取变更信息
const routesChanges = addRoutesToModule(source, modulePath, buildRoute(options, namePath)) as InsertChange;
// 在多少行位置插入指定内容
routesRecorder.insertLeft(routesChanges.pos, routesChanges.toAdd);
// 更新文件
host.commitUpdate(routesRecorder);
};
}
这里有3个依赖方法:
ts.createSourceFile
为我们解析文件源 AST说实话,我对
AST
这个玩意不熟,之前使用ng-packagr
时候出现一个bug,通过源码拿到AST
,修复这个bug,一直自用,不过后来ng-packagr
已经通过其他方式修复了。
// 这个是一个工具方法
function readIntoSourceFile(host: Tree, modulePath: string): ts.SourceFile {
// 先试着用Tree方法读文件
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);
}
// 我现在还用的angular7,懒加载路由还是老的写法,在等angular9更新。
function buildRoute(options: DemoOptions, modulePath: string) {
const moduleName = `${strings.classify(options.name)}Module`;
const loadChildren = normalize(`'./${modulePath}/${modulePath}.module#${moduleName}'`);
return `{ path: '${modulePath}', loadChildren: ${loadChildren} }`;
}
addRoutesToModule
内容太多,创建一个utils.ts
文件来处理它。
大部分也是借鉴angular-cli的addRouteDeclarationToModule方法,改成我们想要。
export function addRoutesToModule(source: ts.SourceFile, fileToAdd: string, routeLiteral: string): Change {
const routerModuleExpr = getRouterModuleDeclaration(source);
if (!routerModuleExpr) {
throw new Error(`Couldn't find a route declaration in ${fileToAdd}.`);
}
const scopeConfigMethodArgs = (routerModuleExpr as ts.CallExpression).arguments;
if (!scopeConfigMethodArgs.length) {
const { line } = source.getLineAndCharacterOfPosition(routerModuleExpr.getStart());
throw new Error(`The router module method doesn't have arguments ` + `at line ${line} in ${fileToAdd}`);
}
let routesArr: ts.ArrayLiteralExpression | undefined;
const routesArg = scopeConfigMethodArgs[0];
// 检查路由声明数组是RouterModule的内联参数还是独立变量
if (ts.isArrayLiteralExpression(routesArg)) {
routesArr = routesArg;
} else {
const routesVarName = routesArg.getText();
let routesVar;
if (routesArg.kind === ts.SyntaxKind.Identifier) {
routesVar = source.statements
.filter((s: ts.Statement) => s.kind === ts.SyntaxKind.VariableStatement)
.find((v: ts.VariableStatement) => {
return v.declarationList.declarations[0].name.getText() === routesVarName;
}) as ts.VariableStatement | undefined;
}
if (!routesVar) {
const { line } = source.getLineAndCharacterOfPosition(routesArg.getStart());
throw new Error(`No route declaration array was found that corresponds ` + `to router module at line ${line} in ${fileToAdd}`);
}
routesArr = findNodes(routesVar, ts.SyntaxKind.ArrayLiteralExpression, 1)[0] as ts.ArrayLiteralExpression;
}
const occurrencesCount = routesArr.elements.length;
const text = routesArr.getFullText(source);
let route: string = routeLiteral;
let insertPos = routesArr.elements.pos;
if (occurrencesCount > 0) {
// 不一样的开始
// 获取最后一个element
const lastRouteLiteral = [...routesArr.elements].pop() as ts.Expression;
// 从当前元素的属性里面获取`children`属性token信息
const children = (ts.isObjectLiteralExpression(lastRouteLiteral) &&
lastRouteLiteral.properties.find(n => {
return ts.isPropertyAssignment(n) && ts.isIdentifier(n.name) && n.name.text === 'children';
})) as ts.PropertyAssignment;
if (!children) {
throw new Error('"children" does not exist.');
}
// 处理路由字符串
const indentation = text.match(/\r?\n(\r?)\s*/) || [];
const routeText = `${indentation[0] || ' '}${routeLiteral}`;
// 获取当前`children`结束位置
insertPos = (children.initializer as ts.ArrayLiteralExpression).elements.end;
// 拼接路由信息
route = `${routeText},`;
// 不一样的结束
}
return new InsertChange(fileToAdd, insertPos, route);
}
注意:这里有些代码相当于写死了,因为我本身都是固定的。
那些一样都不过多的解释,你需要知道最终拿到的是:const routes: Routes = []
即可。
所以按angular自带的addRouteDeclarationToModule方法,操作总是routes.push(newRoute)
这样的操作,而我们需要的操作是routes[0].children.push(newRoute)
,就需要自己弄了。
我们拿到lastRouteLiteral
,注意:其实这个有个bug,如果我们路由里面改了,这个就挂了。
NodeObject {
pos: 193,
end: 276,
flags: 0,
transformFlags: undefined,
parent:
NodeObject {
pos: 191,
end: 280,
flags: 0,
transformFlags: undefined,
parent:
NodeObject {
pos: 174,
end: 280,
flags: 0,
transformFlags: undefined,
parent: [Object],
kind: 235,
name: [Object],
type: [Object],
initializer: [Circular],
_children: [Array] },
kind: 185,
multiLine: true,
elements: [ [Circular], pos: 193, end: 277, hasTrailingComma: true ],
_children: [ [Object], [Object], [Object] ] },
kind: 186,
multiLine: true,
properties:
[ NodeObject {
pos: 198,
end: 212,
flags: 0,
transformFlags: undefined,
parent: [Circular],
kind: 273,
decorators: undefined,
modifiers: undefined,
name: [Object],
questionToken: undefined,
exclamationToken: undefined,
initializer: [Object],
_children: [Array] },
NodeObject {
pos: 213,
end: 251,
flags: 0,
transformFlags: undefined,
parent: [Circular],
kind: 273,
decorators: undefined,
modifiers: undefined,
name: [Object],
questionToken: undefined,
exclamationToken: undefined,
initializer: [Object],
_children: [Array] },
NodeObject {
pos: 252,
end: 270,
flags: 0,
transformFlags: undefined,
parent: [Circular],
kind: 273,
decorators: undefined,
modifiers: undefined,
name: [Object],
questionToken: undefined,
exclamationToken: undefined,
initializer: [Object],
_children: [Array] },
pos: 198,
end: 271,
hasTrailingComma: true ],
_children:
[ TokenObject { pos: 193, end: 198, flags: 0, parent: [Circular], kind: 17 },
NodeObject {
pos: 198,
end: 271,
flags: 0,
transformFlags: undefined,
parent: [Circular],
kind: 304,
_children: [Array] },
TokenObject { pos: 271, end: 276, flags: 0, parent: [Circular], kind: 18 } ] }
这里拿的对应信息就是:
{
path: '',
component: ComponentsComponent,
children: []
}
lastRouteLiteral.properties
是一个数组,我们这个{}
里面有几项,就会有几个数组。我们只关心children
属性,就通过find查找目标,它有可能是undefined
,需要处理一下。
我们来打印children
:
我大家演示2个不一样的:
路由配置里children
空的
NodeObject {
pos: 252,
end: 270,
flags: 0,
transformFlags: undefined,
parent:
NodeObject {
pos: 193,
end: 276,
flags: 0,
transformFlags: undefined,
parent:
NodeObject {
pos: 191,
end: 280,
flags: 0,
transformFlags: undefined,
parent: [Object],
kind: 185,
multiLine: true,
elements: [Array],
_children: [Array] },
kind: 186,
multiLine: true,
properties:
[ [Object],
[Object],
[Circular],
pos: 198,
end: 271,
hasTrailingComma: true ],
_children: [ [Object], [Object], [Object] ] },
kind: 273,
decorators: undefined,
modifiers: undefined,
name:
IdentifierObject {
pos: 252,
end: 266,
flags: 0,
parent: [Circular],
escapedText: 'children' },
questionToken: undefined,
exclamationToken: undefined,
initializer:
NodeObject {
pos: 267,
end: 270,
flags: 0,
transformFlags: undefined,
parent: [Circular],
kind: 185,
elements: [ pos: 269, end: 269 ],
_children: [ [Object], [Object], [Object] ] },
_children:
[ IdentifierObject {
pos: 252,
end: 266,
flags: 0,
parent: [Circular],
escapedText: 'children' },
TokenObject { pos: 266, end: 267, flags: 0, parent: [Circular], kind: 56 },
NodeObject {
pos: 267,
end: 270,
flags: 0,
transformFlags: undefined,
parent: [Circular],
kind: 185,
elements: [Array],
_children: [Array] } ] }
一个路由配置里children
有的
NodeObject {
pos: 239,
end: 655,
flags: 0,
transformFlags: undefined,
parent:
NodeObject {
pos: 185,
end: 660,
flags: 0,
transformFlags: undefined,
parent:
NodeObject {
pos: 183,
end: 663,
flags: 0,
transformFlags: undefined,
parent: [Object],
kind: 185,
multiLine: true,
elements: [Array],
_children: [Array] },
kind: 186,
multiLine: true,
properties:
[ [Object],
[Object],
[Circular],
pos: 189,
end: 656,
hasTrailingComma: true ],
_children: [ [Object], [Object], [Object] ] },
kind: 273,
decorators: undefined,
modifiers: undefined,
name:
IdentifierObject {
pos: 239,
end: 252,
flags: 0,
parent: [Circular],
escapedText: 'children' },
questionToken: undefined,
exclamationToken: undefined,
initializer:
NodeObject {
pos: 253,
end: 655,
flags: 0,
transformFlags: undefined,
parent: [Circular],
kind: 185,
multiLine: true,
elements:
[ [Object],
[Object],
[Object],
[Object],
pos: 255,
end: 649,
hasTrailingComma: true ],
_children: [ [Object], [Object], [Object] ] },
_children:
[ IdentifierObject {
pos: 239,
end: 252,
flags: 0,
parent: [Circular],
escapedText: 'children' },
TokenObject { pos: 252, end: 253, flags: 0, parent: [Circular], kind: 56 },
NodeObject {
pos: 253,
end: 655,
flags: 0,
transformFlags: undefined,
parent: [Circular],
kind: 185,
multiLine: true,
elements: [Array],
_children: [Array] } ] }
这里给大家科普几个数据就好了:
主要看elements
变化,空里面只有2个[pos, end],如果不是空里面就会有子节点,你现在是不是可以干点其他事情了。(ps:如果想批量更新之前内容是不是想想也容易了)
注意:如果你要调试,一定要用npm link
安装,根目录使用ng g tools:demo --name=test
。
先试试默认的路由添加:
ng g tools:demo --name=test
ng g tools:demo --name=test --module=experimental
大功告成,欢迎交流,发现更多好玩的东西。
前端发展很快,angular1前几年还很火,虽然现在很多新框架都出来了,angular2,angular4,react,vue。angular1市场是存在,angular做管理系统还是很不错的一个选择。angular里面有很多功能。指令,控制器,服务,过滤器,双向绑定,表单验证等,今天要说就是表单验证。
表单验证,angular提供丰富的内置指令也很实用的指令,我们需要去使用它来做验证。唯一有个不足的地方,angular验证是边输入边验证,不是失去焦点验证。如果需要失去焦点验证需要自己去做些处理。后面回讲一些。
说到表单一定要说form标签,表单元素都是包裹在里面,我们写个表单以后,怎么和angular去关联。
<form id="login">
用户名:<input type="type" name=“username” id="username"> <br />
密码:<input type="password" name="password" id="password"> <br />
<button type="submit">提交</button>
</form>
传统做法,获取login这个id,然后监听onsubmit事件,把username和password传给后台,需要验证username和password正确性,前端可以做一层简单拦截,用户体验一种,如果用户输入不是预期就给提示警告,直到正确为止,才让用户提交,当然后台也会做相同的验证。不是我们研究的重点。
<form name="login" novalidate ng-submit="submitForm(login.$valid)">
用户名:<input type="type" name=“username” ng-model="vm.username"> <br />
密码:<input type="password" name="password" ng-model="vm.password"> <br />
<button type="submit">提交</button>
</form>
看到angular里面会出现一些奇怪东西。先不管奇怪的东西。我们来一一解释。
上面和我们验证有什么关系了。接着继续。。。
formName就是我们上面栗子的form的name值
inputfieldName就是我们上面栗子的input的name值
如果我们需要访问username,就需要如此如此这般这般, login.username
布尔值属性,表示用户是否修改了表单。如果为ture,表示没有修改过;false表示修改过:
formName.inputFieldName.$pristine;
布尔型属性,当且仅当用户实际已经修改的表单。不管表单是否通过验证:
formName.inputFieldName.$dirty
布尔型属性,它指示表单是否通过验证。如果表单当前通过验证,他将为true:
formName.inputFieldName.$valid
布尔值属性,表示用户没有通过验证。如果为ture,表示没有通过;false表示通过:
formName.inputFieldName.$invalid
同样这个这个状态也适用于formNam,回去看栗子submitForm(login.$valid)就是写的第三个,通过验证了才能提交。
那你肯定要问了这些状态根据什么判断,凭什么说我没有通过验证。下面就来说说说angular提交表单验证几个常用的指令。
以上4个是关于验证信息,来改变验证状态。
以上就是angular表单验证基本介绍,接下来会介绍怎么做验证处理,和验证相关的属性。
最近一直研究 Angular-cli 多工程的特性,传送门,随着公司项目越来越复杂度增加,开始考虑模块化拆分问题,这就涉及很多工程配置,模块越多配置越麻烦。因为公司代码都在公司内部搭建的gitlab
里面,虽然现在github
有私有项目,但是私人对公司项目不是很有好,原因你懂的。
废话就不多说了,赶紧上车吧~~~
gitlab
环境你项目的地址,登录进去创建项目。
需要创建2个项目,一个是源码代码项目,一个发布编译后项目。
举个栗子:
我现在要造个轮子,需要创建一个UI组件库,起个简单的名字就叫simple-ui
。
那就我在gitlab
里面创建一个工程,名字叫simple-ui
。
在创建一个编译后的工程,这个是重点,名字叫simple-ui-builds
。这个是你项目需要引用的地址
gitlab
gitlab.com/jiayi/simple-ui-builds/settings/repository
Deploy Tokens
, 点击Expand
。注意:我画红色框的地方需要注意,这个是很重要的认证,那个小本本把它记录下来,因为你一关闭这个页面或者刷新,这个玩意就没有了。
有些一般克隆项目都是git clone http://gitlab.com/jiayi/simple-ui.git
,这样没什么毛病也是正确,但是有个问题是如果你没有设置全局邮箱和用户名,就会让你每次 pull
和 push
操作都提示你输入用户名和密码。这样很烦。
那就这样来克隆地址:git clone http://${username}:${password}@gitlab.com/jiayi/simple-ui.git
。
到这里,我们的工作区里面应该有2个git目录了
gitlab
--- simple-ui
--- simple-ui-builds
npm i -g @angular/cli
angular
项目,如果你当前在gitlab
文件夹里 ng new sim-simple --directory=simple-ui
选择路由,yes
选择css预处理器,scss/sass
等待安装依赖
simple-ui
文件夹, ng start // 开始运行项目。
src
当我们simple-ui
的文档项目界面,也算是测试界面,因为生成的library
,就相当于一个js文件,你压根不知道它是样子的。 ng g library simple-ui --prefix=sim
然后开始尽情玩耍你的UI组件库。
ng build simple-ui
这样就好自定义发布到dist/simple-ui
,
simple-ui-builds
里面。 npm run publish:lib
思考一下我们需要什么哪些操作?
simple-ui
提交记录 生成CHANGELOG.md
文件dist/simple-ui
文件拷贝到simple-ui-builds
里面package.json
里的version
+1,这里需要做个配置,不然会一直累加git add .
git commit -m "release: cut the vxx.xx.xx release"
git push --follow-tags origin master
扩展功能:需不需要去通知使用者更新。
这里需要写脚本来支持,我们先放在一边,后面慢慢来说明。
我们正常使用npm
安装npm
上面的包,都是没什么问题,这个比较特殊。
我们需要这样来安装:
npm i --save-dev git+http://${gitlab+deploy-token-username}:${token}@gitlab.com/jiayi/simple-ui-builds
Use this username as a login.
绿色提示文字输入框里面的内容。Use this token as a password. Make sure you save it - you won't be able to access it again.
红色提示框里面的内容。这样就把你的依赖安装的你的项目里面,就可以和其他npm包一样的使用了。
...待续
Angular提供了许多事件类型来与你的应用进行通信。 Angular中的事件可帮助你在特定条件下触发操作,例如单击,滚动,悬停,聚焦,提交等。
通过事件,可以在Angular应用中触发组件的逻辑。
Angular 组件和 DOM 元素通过事件与外部进行通信, Angular 事件绑定语法对于组件和 DOM 元素来说是相同的 - (eventName)="expression"
。
DOM 元素触发的一些事件通过 DOM 层级结构传播。这种传播过程称为事件冒泡。事件首先由最内层的元素开始,然后传播到外部元素,直到它们到根元素。DOM 事件冒泡与 Angular 可以无缝工作。
Angular事件分为原生事件和自定义事件:
Angular Events 常用列表
(click)="myFunction()"
(dblclick)="myFunction()"
(submit)="myFunction()"
(blur)="myFunction()"
(focus)="myFunction()"
(scroll)="myFunction()"
(cut)="myFunction()"
(copy)="myFunction()"
(paste)="myFunction()"
(keyup)="myFunction()"
(keypress)="myFunction()"
(keydown)="myFunction()"
(mouseup)="myFunction()"
(mousedown)="myFunction()"
(mouseenter)="myFunction()"
(drag)="myFunction()"
(drop)="myFunction()"
(dragover)="myFunction()"
默认处理的事件应从原始HTML DOM
组件的事件映射:
关于原生事件有哪些,可以参照W3C标准事件。
只需删除on
前缀即可。
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
template: '<button (click)="myFunction($event)">Click Me</button>',
styleUrls: ['./app.component.css']
})
export class AppComponent {
myFunction(event) {
console.log('Hello World');
}
}
当我们点击按钮时候,控制台就会打印Hello World
。
Angular 允许开发者通过 @Output()
装饰器和 EventEmitter
自定义事件。它不同于 DOM 事件,因为它不支持事件冒泡。
@Component({
selector: 'my-selector',
template: `
<div>
<button (click)="callSomeMethodOfTheComponent()">Click</button>
<sub-component (my-event)="callSomeMethodOfTheComponent($event)"></sub-component>
</div>
`,
directives: [SubComponent]
})
export class MyComponent {
callSomeMethodOfTheComponent() {
console.log('callSomeMethodOfTheComponent called');
}
}
@Component({
selector: 'sub-component',
template: `
<div>
<button (click)="myEvent.emit()">Click (from sub component)</button>
</div>
`
})
export class SubComponent {
@Output()
myEvent: EventEmitter;
constructor() {
this.myEvent = new EventEmitter();
}
}
自定义事件写法和原生Dom事件一样。那么它们需要注意:
stopPropagation()
方法解决问题,但实际工作中,不建议这样使用。on
前缀,方法名可以使用on
开头,参考风格指南-不要给输出属性加前缀$event
返回是 DOM Events$event
返回是 EventEmitter.emit()
传递的值,也可以使用 EventEmitter.next()
。在实际项目中,我们经常需要在事件处理器中调用 preventDefault()
或 stopPropagation()
方法。
还有一个比较少用功能比较强大,
stopPropagation
增强版stopImmediatePropagation()
。
preventDefault()
最常见的例子就是 <a>
阻止标签跳转链接
<a id="link" href="https://www.baidu.com">baidu</a>
<script>
document.getElementById('link').onclick = function(ev) {
ev.preventDefault(); // 阻止浏览器默认动作 (页面跳转)
// 处理一些其他事情
window.open(this.href); // 在新窗口打开页面
};
</script>
在Angular中使用:
preventDefault()
页面直接使用:
<a id="link" href="https://www.baidu.com" (click)="$event..preventDefault(); myFunction()">baidu</a>
还可以使用:
```html
<a id="link" href="https://www.baidu.com" (click)="myFunction($event); false">baidu</a>
stopPropagation()
页面直接使用:
<a id="link" href="https://www.baidu.com" (click)="$event.stopPropagation(); myFunction($event)">baidu</a>
在事件处理方法里面使用和原生一样。
myFunction(e: Event) {
e.stopPropagation();
e.preventDefault();
// ...code
return false;
}
看完 Angular 提供写法,写法太麻烦。
项目中最常用当属stopPropagation()
,懒惰的程序员就想到各种方法:
方法1:
import {Directive, HostListener} from "@angular/core";
@Directive({
selector: "[click-stop-propagation]"
})
export class ClickStopPropagation
{
@HostListener("click", ["$event"])
public onClick(event: any): void
{
event.stopPropagation();
}
}
弄一个阻止冒泡的指令
<div click-stop-propagation>Stop Propagation</div>
方法2:
import { Directive, EventEmitter, Output, HostListener } from '@angular/core';
@Directive({
selector: '[appClickStop]'
})
export class ClickStopDirective {
@Output() clickStop = new EventEmitter<MouseEvent>();
constructor() { }
@HostListener('click', ['$event'])
clickEvent(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
this.clickStop.emit(event);
}
}
弄一个阻止冒泡的自定义事件指令
<div appClickStop (clickStop)="testClick()"></div>
看起来很不错,就是支持click
事件,我要支持多种事件,我需要些更多的指令。
用过 Vue - 事件修饰( Event modifiers ) 的同学,一定让使用 Angular 的同学很羡慕。
<button v-on:click="add(1)"></button> # 普通事件
<button v-on:click.once="add(1)"></button> # 这里只监听一次
<a v-on:click.prevent="click" href="http://google.com">click me</a> # 阻止默认事件
<div class="parent" v-on:click="add(1)">
<div class="child" v-on:click.stop="add(1)">click me</div> # 阻止冒泡
</div>
那 Angular 可以实现吗?当然
import { Directive, EventEmitter, Output, HostListener, OnDestroy, OnInit, Input } from '@angular/core';
import { Subject, } from 'rxjs';
import { takeUntil, throttleTime} from 'rxjs/operators';
@Directive({
selector: '[click.stop]',
})
export class ClickStopDirective implements OnInit ,OnDestroy{
@Output('click.stop') clickStop = new EventEmitter<MouseEvent>();
/// 自定义间隔
@Input() throttleTime = 1000;
click$: Subject<MouseEvent> = new Subject<MouseEvent>()
onDestroy$ = new Subject();
@HostListener('click', ['$event'])
clickEvent(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
this.click$.next(event);
}
constructor() { }
ngOnInit() {
this.click$.pipe(
takeUntil(this.onDestroy$),
throttleTime(this.throttleTime)
).subscribe((event) => {
this.clickStop.emit(event);
})
}
ngOnDestroy() {
/// 销毁并取消订阅
this.onDestroy$.next();
this.onDestroy$.complete();
}
}
扩展一个原生事件指令
<div class="parent" (click)="add(1)">
<div class="child" (click.stop)="add(1)">click me</div>
</div>
看起来很美好,还支持防抖*操作,缺点还是支持一个事件,如果需要多种事件需要写更多的事件指令。
Angular 不支持 (事件名.修饰) 这种语法吗?
如果你用过键盘事件,你就会发现,Angular 提供一系列的快捷操作:
当绑定到Angular模板中的keyup或keydown事件时,可以指定键名。 这使得仅在按下特定键时才很容易触发事件。
<input (keydown.enter)="onKeydown($event)">
还可以将按键组合在一起以仅在触发按键组合时触发事件。 在以下示例中,仅当同时按下Control和1键时才会触发事件:
<input (keyup.control.1)="onKeydown($event)">
此功能适用于特殊键和修饰键,例如Enter
,Esc
,Shift
,Alt
,Tab
,Backspace
和Command
,但它也适用于字母,数字,方向箭头和F键(F1-F12)。
<input (keydown.enter)="...">
<input (keydown.a)="...">
<input (keydown.esc)="...">
<input (keydown.shift.esc)="...">
<input (keydown.control)="...">
<input (keydown.alt)="...">
<input (keydown.meta)="...">
<input (keydown.9)="...">
<input (keydown.tab)="...">
<input (keydown.backspace)="...">
<input (keydown.arrowup)="...">
<input (keydown.shift.arrowdown)="...">
<input (keydown.shift.control.z)="...">
<input (keydown.f4)="...">
这个看起来很不错呀,和 Vue 那个事件修饰写法一致。这种可以 Angular 原生实现,那一定有方法可以做到。
在源码里面由一个突出的导入:
import {EventManagerPlugin} from './event_manager';
而的实现,
export class KeyEventsPlugin extends EventManagerPlugin {}
就是继承了这个抽象类
export abstract class EventManagerPlugin {
constructor(private _doc: any) {}
manager!: EventManager;
abstract supports(eventName: string): boolean;
abstract addEventListener(element: HTMLElement, eventName: string, handler: Function): Function;
addGlobalEventListener(element: string, eventName: string, handler: Function): Function {
const target: HTMLElement = getDOM().getGlobalEventTarget(this._doc, element);
if (!target) {
throw new Error(`Unsupported event target ${target} for event ${eventName}`);
}
return this.addEventListener(target, eventName, handler);
}
}
抽象类里面我们需要实现supports
和addEventListener
方法。
Dom.addEventListener()
方法。默认使用冒泡在 DomEventsPlugin
的类实现:
addEventListener(element: HTMLElement, eventName: string, handler: Function): Function {
element.addEventListener(eventName, handler as EventListener, false);
return () => this.removeEventListener(element, eventName, handler as EventListener);
}
在我们使用 Renderer2.listen
绑定事件时候:如果需要销毁事件
// 绑定事件
const fn = Renderer2.listen();
// 销毁事件
fn();
这种操作就是源码是这样的实现的。
listen(target: 'window'|'document'|'body'|any, event: string, callback: (event: any) => boolean):
() => void {
NG_DEV_MODE && checkNoSyntheticProp(event, 'listener');
if (typeof target === 'string') {
return <() => void>this.eventManager.addGlobalEventListener(
target, event, decoratePreventDefault(callback));
}
return <() => void>this.eventManager.addEventListener(
target, event, decoratePreventDefault(callback)) as () => void;
}
关于 Angular Events Plugin 的文章介绍很少,所以很多人不知道可以有以下的*操作。
我们也来实现事件修饰符:
Renderer2.listen
绑定事件实现stopPropagation()
实现。preventDefault()
实现。新建三个文件:
once.plugin.ts
stop.plugin.ts
prevent.plugin.ts
先从常用的 .stop
开始:
注意:EventManagerPlugin
是一个内部抽象类,所以我们无法扩展它
import { Injectable, Inject } from '@angular/core';
import { EventManager } from '@angular/platform-browser';
const MODIFIER = '.stop';
@Injectable()
export class StopEventPlugin {
manager: EventManager;
supports(eventName: string): boolean {
return eventName.indexOf(MODIFIER) !== -1;
}
addEventListener(
element: HTMLElement,
eventName: string,
handler: Function
): Function {
const stopped = (event: Event) => {
event.stopPropagation();
handler(event);
}
return this.manager.addEventListener(
element,
eventName.replace(MODIFIER, ''),
stopped,
);
}
addGlobalEventListener(element: string, eventName: string, handler: Function): Function {
const stopped = (event: Event) => {
event.stopPropagation();
handler(event);
}
return this.manager.addGlobalEventListener(
element,
eventName.replace(MODIFIER, ''),
stopped,
);
}
}
我们这里使用先去supports
查询,只有事件名里面有.stop
,才会执行StopEventPlugin
。
addEventListener里面调用的EventManager.addEventListener
,我们只需要对事件处理函数进行包装一下即可:
const stopped = (event: Event) => {
event.stopPropagation();
handler(event);
}
在把包装之后的处理函数返还给EventManager.addEventListener
,并且去掉.stop
,防止死循环。
.prevent
基本和.stop
一模一样:
import { Injectable, Inject } from '@angular/core';
import { EventManager } from '@angular/platform-browser';
const MODIFIER = '.prevent';
@Injectable()
export class PreventEventPlugin {
manager: EventManager;
supports(eventName: string): boolean {
return eventName.indexOf(MODIFIER) !== -1;
}
addEventListener(
element: HTMLElement,
eventName: string,
handler: Function
): Function {
const prevented = (event: Event) => {
event.preventDefault();
handler(event);
}
return this.manager.addEventListener(
element,
eventName.replace(MODIFIER, ''),
prevented,
);
}
addGlobalEventListener(element: string, eventName: string, handler: Function): Function {
const prevented = (event: Event) => {
event.preventDefault();
handler(event);
}
return this.manager.addGlobalEventListener(
element,
eventName.replace(MODIFIER, ''),
prevented,
);
}
}
.once
有点特殊:
import { Injectable, Inject } from '@angular/core';
import { EventManager } from '@angular/platform-browser';
const MODIFIER = '.once';
@Injectable()
export class OnceEventPlugin {
manager: EventManager;
supports(eventName: string): boolean {
return eventName.indexOf(MODIFIER) !== -1;
}
addEventListener(
element: HTMLElement,
eventName: string,
handler: Function
): Function {
const fn = this.manager.addEventListener(
element,
eventName.replace(MODIFIER, ''),
(event: Event) => {
handler(event);
fn();
},
);
return () => {};
}
addGlobalEventListener(element: string, eventName: string, handler: Function): Function {
const fn = this.manager.addGlobalEventListener(
element,
eventName.replace(MODIFIER, ''),
(event: Event) => {
handler(event);
fn();
},
);
return () => {};
}
}
fn 返回的就是 return () => this.removeEventListener(element, eventName, handler as EventListener);
,.once
操作就是使用一次就注销事件操作。所以我们先把fn获取到,然后事件调用完成以后取消绑定即可。最后要返回一个空函数,不然手动销毁事件就会抛出错误。
在根模块注册插件:
import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser';
import { PreventEventPlugin } from './prevent.plugin';
import { StopEventPlugin } from './stop.plugin';
import { OnceEventPlugin } from './once.plugin';
@NgModule({
imports: [BrowserModule, FormsModule],
declarations: [
AppComponent,
],
providers: [
....,
{
provide: EVENT_MANAGER_PLUGINS,
useClass: PreventEventPlugin,
multi: true,
},
{
provide: EVENT_MANAGER_PLUGINS,
useClass: StopEventPlugin,
multi: true,
},
{
provide: EVENT_MANAGER_PLUGINS,
useClass: OnceEventPlugin,
multi: true,
},
],
bootstrap: [AppComponent]
})
export class AppModule { }
这样我们就可以正常使用了
<a href="https://www.baidu.com" (click.prevent)="onConsole($event)">baidu</a>
<div (click)="onConsole($event)">
标题
<div (click.stop)="onConsole($event)">内容</div>
</div>
<form (submit.stop)="onConsole($event)">
<input name="username">
<button type="submit">提交</button>
</form>
<div (click)="onConsole($event)">
标题
<div (click.once)="onConsole($event)">内容</div>
</div>
事件处理函数:
onConsole($event: Event) {
console.log('onConsole',$event.target)
}
我们已经实现普遍版本的事件修饰,如果想要加上防抖,节流更风*的操作我们该如何做了,这个留个大家一个悬念,可以思考一下,欢迎和我交流心得。
最后:我们不光可以做事件修饰插件还可以做事件打印日志插件,你看完上面的例子,应该很简单操作了。如果不知道怎么下手,欢迎和我交流心得。
今天就到这里吧,伙计们,玩得开心,祝你好运
最近在捣鼓一个仿简书的开源项目,从前端到后台,一战撸到底。就需要数据支持,最近mock数据,比较费劲。简书的很多数据都是后台渲染的,很难快速抓api请求数据,本人又比较懒,就想到用写个简易爬虫系统。
安装nodejs,官网, 中文网。根据自己系统安装,这里跳过,表示你已经安装了nodejs。
选择一款顺手拉风的编辑器,用来写代码。推荐webstorm最近版。
webstorm创建一个工程,起一个喜欢的名字。创建一个package.json文件,webstorm快捷创建package.json非常简单。还是用命令行创建,打开Terminal,默认当前项目根目录,npm init,一直下一步。
可以看这里npm常用你应该懂的使用技巧
这是2个npm包,我们先下载在接着继续,下载需要时间的。
npm install superagent cheerio --save
接下啦简单说说这2个是啥东西
superagent是nodejs里一个非常方便的客户端请求代码模块,superagent是一个轻量级的,渐进式的ajax API,可读性好,学习曲线低,内部依赖nodejs原生的请求API,适用于nodejs环境下。
语法:request(RequestType, RequestUrl).end(callback(err, res));
写法:
request
.get('/login')
.end(function(err, res){
// code
});
设置方式:
1.
request
.get('/login')
.set('Content-Type', 'application/json');
2.
request
.get('/login')
.type('application/json');
3.
request
.get('/login')
.accept('application/json');
以上三种方效果一样。
设置请求参数,可以写json对象或者字符串形式。
可以写多组key,value
request
.get('/login')
.query({
username: 'jiayi',
password: '123456'
});
可以写多组key=value,需要用&隔开
request
.get('/login')
.query('username=jiayi&password=123456');
设置请求参数,可以写json对象或者字符串形式。
可以写多组key,value
request
.get('/login')
.send({
username: 'jiayi',
password: '123456'
});
可以写多组key=value,需要用&隔开
request
.get('/login')
.send('username=jiayi&password=123456');
上面两种方式可以使用在一起
request
.get('/login')
.query({
id: '100'
})
.send({
username: 'jiayi',
password: '123456'
});
Response.text包含未解析前的响应内容,一般只在mime类型能够匹配text/json、x-www-form-urlencoding的情况下,默认为nodejs客户端提供,这是为了节省内存,因为当响应以文件或者图片大内容的情况下影响性能。
Response.header包含解析之后的响应头数据,键值都是node处理成小写字母形式,比如res.header('content-length')。
Content-Type响应头字段是一个特列,服务器提供res.type来访问它,默认res.charset是空的,如果有的化,则自动填充,例如Content-Type值为text/html;charset=utf8,则res.type为text/html;res.charset为utf8。
cheerio是一个node的库,可以理解为一个Node.js版本的jquery,用来从网页中以 css selector取数据,使用方式和jquery基本相同。
需要先loading一个需要加载html文档,后面就可以jQuery一样使用操作页面了。
const cheerio = require('cheerio');
const $ = cheerio.load('<ul id="fruits">...</ul>');
$('#fruits').addClass('newClass');
基本所有选择器基本和jQuery一样,就不一一列举。具体怎么使用看官网。
上面已经基本把我们要用到东西有了基本的了解了,我们用到比较简单,接下来就开始写代码了,爬数据了哦。
开发 Angular 就不能不知道 Angular-CLI 这个超级好用的命令行工具,有了这个工具,原本混沌的开发环境,顿时清晰,许多繁琐的琐事,一个命令就搞定。
Angular-cli
从2015年发布到现在已经经历很多版本,主要有2个大版本变化,一个单工程,一个是多工程。
单工程是1.x
版本,多工程是6.x+
版本,最新版是7.x
。如果使用Angular-cli
开发Angular
应用,当前版本是Angular6
以下的,最好不要直接ng update
,会有很多坑等你,最保险也是最安全的方式是,先升级全局angular-cli
,再用它ng new project
,将之前项目scr
目录内容拷贝进去,修改package.json
和angular.json
(注:1.x里面叫.angular.json
)。安装第三方依赖包,然后运行,修改飚红的错误即可。这个升级最大错误是rxjs
问题。当前版本是Angular6
的,可以直接升级Angular7
。如果你在升级过程中遇到问题,可以联系我寻求帮助。
多工程是angular-cli 6x
一个核心亮点,这个是借鉴@angular/router
作者写的一个angular-cli
增强工具nrwl,目的多个工程共享一个node_modules
。
其实我认为还有2个目的,这也是本文的重点,这里简单描述一下。一个是angular-cli
随着工程增大,编译越来越慢,这个时候拆模块就很重要的。一个是可以直接发布npm
包,打造自己组件库。
node V8 + (可以用nvm做版本管理,最好选用node 10)
npm install -g @angular/cli
ng new project
如果使用
ng new project
命令,默认就是出现在当前目录。
有2个常用携带选择命令:
Would you like to add Angular routing? (y/N)
Yapp-routing.module.ts
,并且在相关文件注入,这个表示根路由。Which stylesheet format would you like to use? (Use arrow keys)
SassSass
。选择完成以后自动npm install
安装package.json
所需要依赖。
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {},
"defaultProject": "angular-multiple-projects"
}
``
`$schema` 里面包含所有的`angular.json`配置
`version` 这个不解释
`newProjectRoot` 这个后面讲解多工程放置目录
`projects` 所有项目配置
`defaultProject` 默认配置,这个只能是`application`,不能是`library`,就可以直接使用以下命令
```bash
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
默认创建
{
...
"projects": {
"angular-multiple-projects": {
},
"angular-multiple-projects-e2e": {
}
},
...
}
angular-multiple-projects
项目配置
angular-multiple-projects-e2e
项目e2e测试配置
{
...
"angular-multiple-projects": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {
},
"architect": {}
},
...
}
root
项目根目录,默认就是当前根目录,这个最好不要修改,会影响很多配置和功能
sourceRoot
开发源文件地址
projectType
项目类型 application
和 library
prefix
创建组件和指令的前缀 默认组件是 app-component
, 默认指令是 [appDirective]
schematics
这个配置对应 ng generate 里的各个配置
architect
这个配置是整开发,生成配置核心,重点讲解
例如:创建项目选择组件css
{
...
"schematics": {
"@schematics/angular:component": {
"style": "sass"
}
},
...
}
组件生成配置:ng generate component
那常用配置有哪些,具体可以参考./node_modules/@angular/cli/lib/config/schema.json#schematicOptions
这里我们拿组件来举例子:
"@schematics/angular:component": {
"type": "object",
"properties": {
"changeDetection": {
"description": "Specifies the change detection strategy.",
"enum": ["Default", "OnPush"],
"type": "string",
"default": "Default",
"alias": "c"
},
"entryComponent": {
"type": "boolean",
"default": false,
"description": "Specifies if the component is an entry component of declaring module."
},
"export": {
"type": "boolean",
"default": false,
"description": "Specifies if declaring module exports the component."
},
"flat": {
"type": "boolean",
"description": "Flag to indicate if a dir is created.",
"default": false
},
"inlineStyle": {
"description": "Specifies if the style will be in the ts file.",
"type": "boolean",
"default": false,
"alias": "s"
},
"inlineTemplate": {
"description": "Specifies if the template will be in the ts file.",
"type": "boolean",
"default": false,
"alias": "t"
},
"module": {
"type": "string",
"description": "Allows specification of the declaring module.",
"alias": "m"
},
"prefix": {
"type": "string",
"format": "html-selector",
"description": "The prefix to apply to generated selectors.",
"alias": "p"
},
"selector": {
"type": "string",
"format": "html-selector",
"description": "The selector to use for the component."
},
"skipImport": {
"type": "boolean",
"description": "Flag to skip the module import.",
"default": false
},
"spec": {
"type": "boolean",
"description": "Specifies if a spec file is generated.",
"default": true
},
"styleext": {
"description": "The file extension to be used for style files.",
"type": "string",
"default": "css"
},
"style": {
"description": "The file extension or preprocessor to use for style files.",
"type": "string",
"default": "css",
"enum": [
"css",
"scss",
"sass",
"less",
"styl"
]
},
"viewEncapsulation": {
"description": "Specifies the view encapsulation strategy.",
"enum": ["Emulated", "Native", "None", "ShadowDom"],
"type": "string",
"alias": "v"
}
}
description
描述
enum
可选择的值
type
类型
format
文件书写格式
alias
使用配置可使用的别名
default
默认值
{
...
"architect": {
"build": {},
"serve": {},
"extract-i18n": {},
"test": {},
"lint": {}
},
...
}
build
生产发布配置
serve
开发环境配置
extract-i18n
多语言配置
test
单元测试配置
lint
代码风格检查配置
{
...
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {},
"configurations": {}
},
...
}
builder
编译脚本serve
开发脚本{
...
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {},
"configurations": {}
},
...
}
我们首先运行一下效果再来介绍它们:
npm start or npm run start
等待编译完成运行
如果使用sass
,编译出错ERROR in ./src/styles.scss
原因找不到Error: Cannot find module 'node-sass'
。
这里主要是Windows
解决方案:
1. 单独安装一次 `npm install node-sass`
2. 找同伴去拷贝一份`node_modules`
3. 安装`python v2.7` 和 `vs`(**注意** 不是`vs code`)需要`vb`等依赖
npm start
实际运行的是ng serve
相信很多之前都会看到其他人的文章,都会由这样例子,比如编译完成自动打开默认浏览器
在package.json
里面配置
{
...
"scripts": {
...
"start": "ng serve --open",
...
},
...
}
在现在版本里面完全不用这么麻烦了,直接在options
添加即可。然后直接"start": "ng serve"
即可。
这里说几个和我们开发息息相关的重要配置:注意:每次修改配置,需要重启
开发时候本地开发最容易出现端口号被占用,默认是4200
{
...
"options": {
"port": 4201, // 修改后 访问 http://localhost:4201
}
...
}
angular-multiple-projects-e2e
项目e2e测试配置
但凡创建的
application
都会这样的格式
"application": {
},
"application-e2e": {
}
但凡创建的
library
都会这样的格式
"library": {
},
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.