Giter VIP home page Giter VIP logo

blog's People

Contributors

jiayisheji avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

使用 Angular6 创建一个 CRUD 应用程序--Todolist

五一假期过后,Angular6发布正式版,相关联的UI组件库Material 和 脚手架CLI,也一并发布6。
升级核心依赖:

工作中已经完成一个Angular6项目,这里来写一个简单的Angular6教程。

古语云:君子谋而后动,三思而后行。我们做一个功能,先规划功能细节。

先看个效果图:
todolist

在本文中,我们将构建一个Angular6 Todo web应用程序,允许用户:

  • 快速创建新的todo,使用输入框输入内容并按回车键
  • 切换todo是完成的或不完成的
  • 删除不再需要的todo
  • 双击修改未完成的todo
  • 批量删除已完成todo
  • 全选标记所有已完成或者取消全选标记所有未完成

这是一个演示应用程序,我们将一步步从零构建它们。

这里所有的代码都是公开的,所以你可以使用这些代码

这是一个在线编辑器预览

让我们开始吧!

快速开始

这里看官网文档 快速开始。详细安装指南,这里不在一一介绍。

修改一下package.json

  "scripts": {
    "start": "ng serve --open",
    ...
  },

这样可以直接使用npm start启动开发服务器并且自动打开默认浏览器并访问 http://localhost:4200/

生成我们的Todo应用程序

现在我们有了Angular-CLI,我们可以使用它来生成Todo应用程序:

生成我们的Todo应用程序

这里是生成项目的文档

ng new angular-todolist --style=scss

说明:生成一个angular-todolist项目,css预处理器用scss。

生成文件

满足我们的Todo应用程序的需要,我们需要:

  • Todo 类 代表个人待办事项
  • TodoService 服务 创建、更新和删除待办事项
  • TodoApp 组件 显示界面
  • AfterViewFocus 指令 输入框自动获取焦点

我们所有相关应用都放在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

创建Todo类

因为我们使用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项有三个属性:

  • id : number, todo项的唯一ID(可以用全局变量自增,也可以用时间戳,这里用时间戳)
  • value : string, 待办事项的内容
  • done : boolean, todo项是否完成
  • edit : boolean, 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项。

创建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,是时候实现应用程序的主要部分了。

创建TodoApp组件

组件是Angular最小的单元了,整个Angular应用就是一个颗组件树构成。

我们生成项目时候,angular-cli默认为我们创建了app-root根组件,我们现在生产的app-todo-app,放到app.component.html里面,删除其他html。

一个组件是有3部分组建成:

  1. 模板结构 todo-app.component.html
  2. 样式美化 todo-app.component.scss
  3. 交互行为 todo-app.component.ts

模板和样式也可以内联脚本文件中指定。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模板语法:

  • [property]="expression" : 属性设置为表达式的结果
  • (event)="statement" : 事件发生时执行语句
  • [(property)]="expression" : 创建双向绑定表达式
  • [class.special]="expression" : 表达式为真的时候添加特殊的CSS类元素
  • [style.color]="expression" : 设置CSS的颜色属性为表达式的结果

更多的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>
  • [(ngModel)]="newTodo" : 添加一个输入值和newTodo之间的双向绑定
  • (ngSubmit)="addTodo()": 按enter键和点击+按钮时,使用ngSubmit告诉angular执行addTodo(),提交输入的值
  • *ngIf="newTodo.length":newTodo不为空的时候才显示+号按钮

不要担心newTodo或addTodo()从哪里来,我们很快就会讲到那里,现在只需要试着去理解的模板语法。

接下来是一段显示待办事项:

<main class="main" *ngIf="todos.length"></main>
  • *ngIf="todos.length" : 当至少有1个todo才显示待办事项容器

在这个部分中,我们循环一个元素来显示每个待办事项:

<li *ngFor="let todo of todos" [class.completed]="todo.done" (dblclick)="editingTodo(todo)"></li>
  • *ngFor="let todo of todos" : 遍历所有待办事项,为当前的待办事项分配给一个变量为todo
  • [class.completed]="todo.done":当todo.done为真时,给当前li元素添加一个CSS类
  • (dblclick)="editingTodo(todo)":双击li元素时,执行editingTodo(),并把当前todo当做参数传递给控制器使用。

最后我们显示待办事项的细节为每个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)">
  • *ngIf="!todo.edit" : 当前todo不在编辑中
  • [checked]="todo.done": 给input元素绑定checked属性
  • (click)="toggleDoneTodo(todo)": 单击复选框时执行toggleDoneTodo(todo)
  • (click)="destroyTodo(todo)": 单击销毁按钮执行destroyTodo(todo)
  • *ngIf="todo.edit" : 当前todo正在编辑中
  • [value]="todo.value": 给input元素绑定value属性
  • (blur)="cancelEditingTodo(todo)":失去焦点取消编辑执行cancelEditingTodo(todo)
  • (keyup.enter)="editedTodo(todo, edit)": 回车确认编辑执行editedTodo(todo, edit)

appAfterViewFocus是angular属性型指令,在 Angular 中有三种类型的指令:

  1. 组件 — 拥有模板的指令
  2. 结构型指令 — 通过添加和移除 DOM 元素改变 DOM 布局的指令
  3. 属性型指令 — 改变元素、组件或其它指令的外观和行为的指令。

注意: 为什么要写这个指令,它作用是什么?它作用是当编辑时,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();
  }

}
  • ngAfterViewInit: 指令生命周期钩子,,初始化完组件视图及其子视图之后调用。 简单理解就是该dom已经出现在页面上了,js可以正常操作了。
  • ElementRef: 简单解释,允许直接访问DOM,这里是宿主,也是使用者。 .nativeElement拿到就是一个HTMLElement, 这里是input这个dom。

#edit是angular模板引用变量;

  • 模板引用变量使用井号(#)来声明引用变量。
  • 模板引用变量通常用来引用模板中的某个DOM元素,它可以引用Angular组件或指令或 Web Component。
  • 我们可以在当前模板的任何地方使用模板引用变量。

注意: 这里拿到就是input这个dom,我们可以直接操作获取它上面的属性和方法。

为什么不用双向绑定[(ngModel)]?

什么是双向绑定: 数据模型(Module)和视图(View)之间的双向绑定。

如果使用双向绑定,我们修改以后,我们数据就直接跟着一起被修改,那么我们要操作取消操作怎么办,增加一个临时的属性来记录它,取消时候就直接回滚,确认就直接清除这个临时属性。

如果不使用双向绑定,我们先赋值给视图,视图修改以后,我们的数据还没有变,取消操作直接取消就行,确认操作,拿到dom引用,把dom的值去更新数据。

关于全选效果

<input class="toggle-all" type="checkbox" [(ngModel)]="allDone" (ngModelChange)="toggleAllTodoDone($event)">
  • [(ngModel)]="allDone" : 这里使用双向绑定,来监听allDone变化,如果是true,就选中,如果false就不勾选。
  • (ngModelChange)="toggleAllTodoDone($event)":每次allDone变化都会执行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>
  • *ngIf="todos.length" : 当至少有1个todo才显示待办统计容器
  • {{ todoCount }}:显示当前有多少未完成的todos(这是angular模板表达式绑定和上面介绍属性表达式绑定一样)
  • {{ clearCount }}:显示当前有多少已完成的todos
  • (click)="destroyAllTodo()":单击按钮时执行destroyAllTodo()
  • [class.clear-operate]="clearCount":如果clearCount不为0时候,给当前按钮添加一个类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可以在模块下,也可以在组件里,这也限定他们使用范围。模块里面注册,适用于该模块下所有的组件,服务,指令等;组件里面注册,只适用于当前组件和子组件。

  • newTodo 提供一个属性变量,供模板双向绑定使用,绑定输入新的todo值。

每当视图中输入值的变化,更新组件实例的价值。当组件实例中的值改变,视图中输入元素中的值的变化。

接下来,我们实现我们在视图中使用的所有方法。

  /**
   * 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;
  }
  • addTodo:添加操作,判断用户有没有输入,只有输入才添加,添加完成清空newTodo属性值
  • destroyTodo:删除操作,直接调用服务对应方法即可
  • destroyAllTodo:批量清除已完成项,先判断,二次确认,在调用服务对应方法即可
  • editingTodo:启动编辑,如果当前todo已完成状态,不能编辑
  • cancelEditingTodo:取消编辑,隐藏输入框
  • editedTodo:确认编辑,获取dom值修改todo值
  • todos:获取服务方法
  • allDone:获取是不是全选状态
  • todoCount:获取未完成todo个数
  • clearCount:获取已完成todo个数

这里有4个 get,在Typescript存取器, 通过getters/setters来截取对对象成员的访问。 它能帮助我们有效的控制对对象成员的访问。这里只要控制器里面值发送变化,模板就会更着改变,很方便。

注意:无论是服务还是组件里,都是需要熟练使用原生数据操作方法,比如数组,对象,字符串等。这里主要使用数组相关方法,如果你对这些还不熟,请赶紧去提升一下。es6以后又新增很多方法,操作数据会更方便。angular是数据驱动,如果不会玩转操作,基本很难继续下去。

功能很小,应该不言自明todoService我们代表所有的业务逻辑。

委派业务逻辑服务是良好的编程实践,因为它能让我们集中管理和测试业务逻辑。

我们还为大家编写一个E2E测试用例,可以查阅e2e文件里面文件,运行命名npm run e2e即可。

部署到GitHub页面

github给我们每个项目都运行有一个预览页面,我们叫它github-pages

提交代码

  1. 先打包本地代码
ng build --prod --base-href https://jiayisheji.github.io/angular-todolist/

注意:github-pages预览地址是 你的用户名.github.io/你的项目名/

--base-href:修改html里面的basehref属性,如果有路由必须要使用的。

  1. 提交dist文件夹的内容到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
  1. 提交本地代码到远程master并打tags
git push --follow-tags origin master

我在所有项目里面都会用到它

  1. 写成npm命令
"_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

  1. 打开你的项目,点击设置

gq 6u 0 f 2w s ghh6z4

  1. ctrl+f 搜索 GitHub Pages

4zd_h1d 4 2 h 0 7 2a

  1. 点击设置分支,默认是none,选择gh-pages branch

7tpn yli ne amuuyl 1 7

  1. 点击save.

hx8q8f4 p5u6vf 81r8ey7t

就好出现你的Github Pages链接,你可以做代码演示,分享给其他小伙伴观看,也可以做静态blog

注意:一旦启用就不能再选择none,只能你删除项目。你删除分支,访问就会出现404。

总结

毫无疑问,Angular是一个平台。一个非常强大前端框架!

我们讨论了许多让我们回顾所学在本文中:

  • 我们学习了如何安装Angular-CLI和它节省了多少时间为我们创建新的应用程序或功能添加到现有的应用程序。
  • 我们学会了如何实现业务逻辑的Angular服务和如何测试我们的业务逻辑使用单元测试。
  • 我们学习了如何使用一个组件与用户交互以及如何委派逻辑服务使用依赖注入。
  • 我们学习了Angular模板语法的基本知识,并简要介绍了Angular依赖注入是如何工作的。
  • 我们学习了如何编写单元测试和E2E测试。
  • 最后,我们学会了如何快速部署应用程序到GitHub页面。

麻雀虽小,五脏俱全,Todo应用看起来,功能很简单,其实它里面功能可以做很多衍生,都是我们平常业务需要的,比如购物车, 全选等。

这个有个类似的变种需求功能:我也不知道叫什么名字,antd里面叫穿梭框。这就留个大家一个作业吧。
k 69ahw_ 3q3w8m wksqwp

如果你不知道如何下手,可以跟我交流

10分钟教你撸一个nodejs爬虫系统

最近在捣鼓一个仿简书的开源项目,从前端到后台,一战撸到底。就需要数据支持,最近mock数据,比较费劲。简书的很多数据都是后台渲染的,很难快速抓api请求数据,本人又比较懒,就想到用写个简易爬虫系统。

项目初始化

安装nodejs,官网中文网。根据自己系统安装,这里跳过,表示你已经安装了nodejs。

选择一款顺手拉风的编辑器,用来写代码。推荐webstorm最近版。

webstorm创建一个工程,起一个喜欢的名字。创建一个package.json文件,webstorm快捷创建package.json非常简单。还是用命令行创建,打开Terminal,默认当前项目根目录,npm init,一直下一步。

可以看这里npm常用你应该懂的使用技巧

主要技术栈

  • superagent 页面数据下载
  • cheerio 页面数据解析

这是2个npm包,我们先下载在接着继续,下载需要时间的。

npm install superagent cheerio --save

接下啦简单说说这2个是啥东西

superagent 页面数据下载

superagent是nodejs里一个非常方便的客户端请求代码模块,superagent是一个轻量级的,渐进式的ajax API,可读性好,学习曲线低,内部依赖nodejs原生的请求API,适用于nodejs环境下。

请求方式

  • get (默认)
  • post
  • put
  • delete
  • head

语法:request(RequestType, RequestUrl).end(callback(err, res));

写法:

request
    .get('/login')
    .end(function(err, res){
        // code
    });

设置Content-Type

  • application/json (默认)
  • form
  • json
  • png
  • xml
  • ...

设置方式:

1. 
request
    .get('/login')
    .set('Content-Type', 'application/json');
2. 
request
    .get('/login')
    .type('application/json');
3. 
request
    .get('/login')
    .accept('application/json');

以上三种方效果一样。

设置参数

  • query
  • send

query

设置请求参数,可以写json对象或者字符串形式。

json对象{key,value}

可以写多组key,value


request
    .get('/login')
    .query({
        username: 'jiayi',
        password: '123456'
    });

字符串形式key=value

可以写多组key=value,需要用&隔开


request
    .get('/login')
    .query('username=jiayi&password=123456');

sned

设置请求参数,可以写json对象或者字符串形式。

json对象{key,value}

可以写多组key,value


request
    .get('/login')
    .send({
        username: 'jiayi',
        password: '123456'
    });

字符串形式key=value

可以写多组key=value,需要用&隔开


request
    .get('/login')
    .send('username=jiayi&password=123456');

上面两种方式可以使用在一起


request
    .get('/login')
    .query({
        id: '100'
    })
    .send({
          username: 'jiayi',
          password: '123456'
      });

响应属性Response

Response text

Response.text包含未解析前的响应内容,一般只在mime类型能够匹配text/json、x-www-form-urlencoding的情况下,默认为nodejs客户端提供,这是为了节省内存,因为当响应以文件或者图片大内容的情况下影响性能。

Response header fields

Response.header包含解析之后的响应头数据,键值都是node处理成小写字母形式,比如res.header('content-length')。

Response Content-Type

Content-Type响应头字段是一个特列,服务器提供res.type来访问它,默认res.charset是空的,如果有的化,则自动填充,例如Content-Type值为text/html;charset=utf8,则res.type为text/html;res.charset为utf8。

Response status

http响应规范

cheerio 页面数据解析

cheerio是一个node的库,可以理解为一个Node.js版本的jquery,用来从网页中以 css selector取数据,使用方式和jquery基本相同。

  • 相似的语法:Cheerio 包括了 jQuery 核心的子集。Cheerio 从jQuery库中去除了所有 DOM不一致性和浏览器尴尬的部分,揭示了它真正优雅的API。
  • 闪电般的块:Cheerio 工作在一个非常简单,一致的DOM模型之上。解析,操作,呈送都变得难以置信的高效。基础的端到端的基准测试显示Cheerio 大约比JSDOM快八倍(8x)。
  • 巨灵活: Cheerio 封装了兼容的htmlparser。Cheerio 几乎能够解析任何的 HTML 和 XML document。

需要先loading一个需要加载html文档,后面就可以jQuery一样使用操作页面了。

const cheerio = require('cheerio');
const $ = cheerio.load('<ul id="fruits">...</ul>');
$('#fruits').addClass('newClass');

基本所有选择器基本和jQuery一样,就不一一列举。具体怎么使用看官网

上面已经基本把我们要用到东西有了基本的了解了,我们用到比较简单,接下来就开始写代码了,爬数据了哦。

学习 TypeScript 中的内置实用工具类型

TypeScript 对于许多 Javascript 开发人员来说是难以理解的。引起麻烦的一个领域是高级类型。这个领域的一部分是 TypeScript 中内置的实用程序类型。它们可以帮助我们从现有类型中创建新的类型。在本文中,我们将了解其中一些实用工具类型如何工作以及如何使用它们。

实用工具类型简要介绍

TypeScript 为处理类型提供了一个强大的系统。这里有一些基本类型我们已经从 JavaScript 中了解。例如,数据类型如 numberstringbooleanobejctsymbolnullundefined。这并不是 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

  • 语法:Partial<Type>
  • 发布:2.1 版本
  • 实现:
type Partial<T> = {
    [P in keyof T]?: T[P];
};

创建 typeinterface 时,所有在内部定义的类型都需要作为默认值。如果我们想将某些标记为可选的,我们可以使用 ? 并将其放在属性名称之后。这将使该属性成为可选的。

// 一个可选的年龄的用户接口
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

  • 语法:Required<Type>
  • 发布:2.8 版本
  • 实现:
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

  • 语法:Readonly<Type>
  • 发布:2.1 版本
  • 实现:
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

  • 语法:Record<Keys, Type>
  • 发布:2.1 版本
  • 实现:
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

  • 语法:Pick<Type, Keys>
  • 发布:2.1 版本
  • 实现:
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

  • 语法:Omit<Type, Keys>
  • 发布:3.5 版本
  • 实现:
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

  • 语法:Exclude<Type, ExcludedUnion>
  • 发布:2.8 版本
  • 实现:
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

  • 语法:Extract<Type, Union>
  • 发布:2.8 版本
  • 实现:
type Extract<T, U> = T extends U ? T : never;

Extract<Type, Union> 类型执行与 Exclude<Type, ExcludedUnion> 类型相反的操作。用于从类型 Type 中取出可分配给 Union 类型的成员。有点类似集合里的交集概念。使用 Extract 之后,返回 TypeUnion 交集。

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

  • 语法:NonNullable<Type>
  • 发布:2.8 版本
  • 实现:
type NonNullable<T> = T extends null | undefined ? never : T;

NonNullable 实用工具类型的工作原理与 Exclude 类似。它接受指定的某种类型,并返回该类型,但不包括所有 nullundefined 类型。

// 创建类型:
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

  • 语法:Parameters<Type>
  • 发布:3.1 版本
  • 实现:
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

  • 语法:ConstructorParameters<Type>
  • 发布:3.1 版本
  • 实现:
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

  • 语法:ReturnType<Type>
  • 发布:2.8 版本
  • 实现:
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

  • 语法:InstanceType<Type>
  • 发布:2.8 版本
  • 实现:
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

  • 语法:ThisParameterType<Type>
  • 发布:3.3 版本
  • 实现:
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

  • 语法:OmitThisParameter<Type>
  • 发布:3.3 版本
  • 实现:
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

  • 语法:ThisType<Type>
  • 发布:2.3 版本
  • 实现:
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,这是一个包含许多扩展内置类型的实用程序类型的库。

今天就到这里吧,伙计们,玩得开心,祝你好运。

从一道面试题,到 “我的前端成长历程”

今天想谈谈一道前端面试题

题目:我们在过马路会遇到红绿灯,模仿一个红绿灯切换效果。

要求:

  1. 用html布局,css美化,js一段脚本。
  2. 每隔2秒切换一次。
  3. 切换顺序:红之后是绿,绿后是黄灯,然后是红灯。循环切换效果

使用Angular-cli多工程实践应用

angular多工程探索.md

开发 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.jsonangular.json(注:1.x里面叫.angular.json)。安装第三方依赖包,然后运行,修改飚红的错误即可。这个升级最大错误是rxjs问题。当前版本是Angular6的,可以直接升级Angular7。如果你在升级过程中遇到问题,可以联系我寻求帮助。

多工程

多工程是angular-cli 6x一个核心亮点,这个是借鉴@angular/router作者写的一个angular-cli增强工具nrwl,目的多个工程共享一个node_modules
其实我认为还有2个目的,这也是本文的重点,这里简单描述一下。一个是angular-cli随着工程增大,编译越来越慢,这个时候拆模块就很重要的。一个是可以直接发布npm包,打造自己组件库。

准备

环境

  1. 依赖环境

node V8 + (可以用nvm做版本管理,最好选用node 10)

  1. 安装cli(注意一定要安装cli)
npm install -g @angular/cli

创建项目

ng new project

如果使用ng new project命令,默认就是出现在当前目录。

有2个常用携带选择命令:

  • Would you like to add Angular routing? (y/N) Y
    这个会默认生存一个app-routing.module.ts,并且在相关文件注入,这个表示根路由。
  • Which stylesheet format would you like to use? (Use arrow keys) Sass
    CSS (.css )
    Sass (.scss) [ http://sass-lang.com ]
    Less (.less) [ http://lesscss.org ]
    Stylus (.styl) [ http://stylus-lang.com ]
    这个是选择默认样式文件的选项,相对支持最好的css预处理器是Sass

选择完成以后自动npm install安装package.json所需要依赖。

angular.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配置

默认创建

{
    ...
    "projects": {
        "angular-multiple-projects": {
        },
        "angular-multiple-projects-e2e": {
        }
    },
    ...
}

angular-multiple-projects 项目配置

angular-multiple-projects-e2e 项目e2e测试配置

application 配置

{
    ...
    "angular-multiple-projects": {
      "root": "",
      "sourceRoot": "src",
      "projectType": "application",
      "prefix": "app",
      "schematics": {
      },
      "architect": {}
    },
    ...
}

root 项目根目录,默认就是当前根目录,这个最好不要修改,会影响很多配置和功能

sourceRoot 开发源文件地址

projectType 项目类型 applicationlibrary

prefix 创建组件和指令的前缀 默认组件是 app-component, 默认指令是 [appDirective]

schematics 这个配置对应 ng generate 里的各个配置

architect 这个配置是整开发,生成配置核心,重点讲解

schematics 配置

例如:创建项目选择组件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 配置
{
    ...
    "architect": {
        "build": {},
        "serve": {},
        "extract-i18n": {},
        "test": {},
        "lint": {}
    },
    ...
}

build 生产发布配置

serve 开发环境配置

extract-i18n 多语言配置

test 单元测试配置

lint 代码风格检查配置

build 配置
{
    ...
    "build": {
        "builder": "@angular-devkit/build-angular:browser",
        "options": {},
        "configurations": {}
    },
    ...
}
  1. builder 编译脚本
  • @angular-devkit/build-angular:app-shell
  • @angular-devkit/build-angular:browser // application 打包
  • @angular-devkit/build-angular:dev-server // application 开发
  • @angular-devkit/build-angular:extract-i18n // application 多语言
  • @angular-devkit/build-angular:protractor // application e2e
  • @angular-devkit/build-angular:server // server 开发
  • @angular-devkit/build-angular:karma // application | library 单元测试
  • @angular-devkit/build-angular:tslint // application | library 代码风格
  • @angular-devkit/build-ng-packagr:build // library 打包
  1. 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"即可。

这里说几个和我们开发息息相关的重要配置:注意:每次修改配置,需要重启

  1. 端口号:

开发时候本地开发最容易出现端口号被占用,默认是4200

{
    ...
    "options": {
        "port": 4201,  // 修改后 访问 http://localhost:4201
    }
    ...
}

angular-multiple-projects-e2e 项目e2e测试配置

但凡创建的application都会这样的格式


"application": {
},
"application-e2e": {
}

但凡创建的library都会这样的格式


"library": {
},

使用多工程

TypeScript 指南

本篇包含了 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 的全部好处收益。

语法和 JavaScript 支持

TypeScript 支持当前的 JavaScript 语法(通过ES2021),以及许多语言提案草案。在大多数情况下,即使在使用新特性时,TypeScript 也能编译出与旧 JavaScript 运行时兼容的代码,这使得开发者可以使用仍然可以在旧环境中运行的现代JS特性编写代码。

TypeScript 支持的建议 JavaScript 特性包括:

导入和导出

TypeScript 文件使用 .ts 文件扩展名,每个文件通常代表一个模块,与 AMDCommonJsJavaScript模块(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 目录中查找模块。

在这两种策略中,baseUrlpathsrootDirs 选项都可以用于进一步配置编译器查找绝对引用模块的位置。

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

unknownany 的类型安全对应物。任何东西都可以赋值给 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-inease-outease-in-out

模板文字类型

模板字面量类型是在 TypeScript 4.1 中添加的,它建立在字符串字面量类型的基础上。虽然字符串字面量类型必须由固定字符串表示,但模板字面量类型可以使用与模板字面量非常相似的语法派生它们的值。考虑描述一个元素与另一个元素对齐的场景。为了充分描述这些可能性,必须同时处理 horizontalvertical 方向。这可能导致以下类型:

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 变量被定义为接受任何具有数字 xy 属性的对象。注意,与普通的对象字面值不同,对象类型字面值使用分号而不是逗号分隔字段。

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中还有另一个更强大的抽象来重用对象类型:interfaceinterface 本质上是命名对象类型字面量。将前面的示例更改为使用接口,如下所示:

interface Point {
  x: number;
  y: number;
}

let point: Point;
let point2: Point;

此更改允许在代码中的多个位置使用 Point 类型,而不必一遍又一遍地重新定义类型的详细信息。interface 还可以使用
extends 关键字扩展其他 interfaceclass,以便用简单类型组成更复杂的类型:

interface Point3d extends Point {
  z: number;
}

在本例中,得到的 Point3d 类型将由 Point 的类型的 xy 属性以及新的 z 属性组成。

对象上的方法和属性也可以指定为可选的,就像函数参数可以被指定为可选的一样:

interface Point {
  x: number;
  y: number;
  z?: number;
}

这里,我们不是为一个三维点指定一个单独的 interface ,而是简单地让 interfacez 属性是可选的,得到的类型检查如下所示:

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.assignObject.keys,无论属性是否存在(且 undefined),其行为都是不同的。从 TypeScript 4.4 开始,你现在可以使用选项 exactOptionalPropertyTypes 来告诉 TypeScript 在这些情况下不允许未定义的值。

可以为打算用作哈希映射或有序列表的对象提供索引签名,从而允许在对象上定义任意键:

interface HashMapOfPoints {
  [key: string]: Point;
}

在本例中,我们定义了一个类型,只要指定的值是 Point 类型,就可以在其中设置任意字符串键。在 TypeScript 4.4 之前,就像在 JavaScript 中一样,只能使用 stringnumber 作为索引签名的类型。然而,从 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 属性也可以使用常量值来命名,类似于普通对象上的计算属性名称。计算值必须是常量 stringnumberSymbol

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 通过允许它们与 restspread 表达式一起使用,以及允许可选元素,改进了对元组类型的支持。

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 函数可以接受 xy 和可选的 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.fromlinearPoint.fromgeo 方法。真正做到这一点的唯一方法是定义一个实现 Point 并具有静态 fromLinearfromGeo 方法的类。在后面深入讨论 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 的情况下,因为 stringnumbervalue 的类型必须是 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 包含了泛型类型的概念,可以大致被认为是一种必须包含或引用另一种类型才能完整的类型。可能已经你使用过的两种泛型类型是 ArrayPromise

泛型值类型的语法是 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 增强了类型保护,使得 instanceoftypeof 检查现在可以被赋给常量值。例如:

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 类型的值必须具有 namecountage 属性。

TypeScript 不需要重叠属性来拥有兼容的类型,所以可能会产生不可用的类型:

interface Foo {
  count: string;
}

interface Bar {
  count: number;
}

export type FooBar2 = Foo & Bar;

FooBar2 中的 count 属性是 never 类型,因为一个值不能既是字符串又是数字,这意味着不能给它赋值。

类型别名

我们在前面看到,typeofinterfaces 是避免在需要的地方编写完整类型的值的两种方法。另一种实现方法是使用类型别名。类型别名只是对特定类型的引用。

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 中。另一方面,类型别名是一个命名实体,就像变量一样。与变量一样,类型声明是块作用域的,不能在同一作用域声明两个具有相同名称的类型。

映射类型

通过将现有类型的属性映射到新类型,映射类型允许基于现有类型创建新类型。考虑下面的类型 StringifyStringify 将具有与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中的内置类型:

  • Partial 构造一个类型,将 T 中的所有属性设置为可选的类型
  • Required 构造一个类型,其中 T 的所有属性都被设置为必填
  • Readonly 构造一个类型,其中 T 的所有属性都被设置为只读
  • Record<K, T> 构造一个具有 K 属性名的类型,其中每个属性具有类型 T
  • Pick<T, K> 构造一个只包含 K 指定的 T 属性的类型
  • Omit<T, K> 构造一个具有除 K 指定的属性外的所有 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代码兼容的方式来做到这一点,类型系统绑定到 typeofinstanceofin (从TS 2.7开始)运算符。在使用这些检查之一的条件块中,可以保证检查的值是指定的类型,并且可以安全地使用该类型上存在的方法。

typeof 和 instanceof

TypeScript 会使用 JavaScript的 typeofinstanceof 运算符作为类型守卫。

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 根据 typeofinstanceof 检查的结果进行理解,x 的类型必须在 if/else 语句的每个部分中。

in

通过检查变量上是否存在来缩小条件内的类型守卫。

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 类型。

class

在大多数情况下,TypeScript 中的类与标准 JavaScript中 的类相似,但在允许正确类型化方面存在一些差异。

TypeScript 允许显式声明类字段,这样编译器就会知道类的哪些属性是有效的。类字段也可以声明为 protectedprivate,并且从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;
  }
}

类还支持属性的 gettersettergetter 允许计算返回的值作为属性值,而 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
  ) { }
}

在这里,nameagecollarType 构造函数参数将成为类属性,并将用参数值进行初始化。

从 TypeScript 4.0 开始,类属性类型也可以从构造函数中的赋值推断出来。下面的例子:

class Animal {
 sharpTeeth; // <-- 没有类型
 constructor(fangs = 2) {
  this.sharpTeeth = fangs;
 }
}

在 TypeScript 4.0 之前,这会导致 sharpTeeth 被输入为 any (如果使用 strict 选项则会输入一个错误)。然而,现在 TypeScript可以推断出 sharpTeeth 是与 fangs 相同的类型,而 fangs 是一个数字。

this 类型

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 在默认 thisany 类型时发出编译错误。

多继承和mixins

在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;

在这个例子中,两个类(DogLion)和一个接口(Monsterish)被组合成一个新的 Chimera 接口。MyChimera 类实现了该接口,将返回原始类的函数实现。注意,barkroar 方法实际上被定义为属性而不是方法。这允许接口完全由类实现,尽管具体的实现实际上并不存在于类定义中。这是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 包,它导出了一些对动物应用程序有用的类,如 AnimalDogcheckUtils 模块的环境模块声明如下所示:

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 文件中 filesinclude 属性里配置它。另一种方法是在源文件的顶部使用一个引用三斜杠指令:

/// <reference types="jquery" />
/// <reference path="../types/checkUtils" />

这些注释告诉编译器需要加载声明文件。从软件包中查找类型的 types,类似于模块导入工作的方法。从编译出声明文件的路径提供 path 。在这两种情况下,编译器都将在预处理期间识别指令,并将声明文件添加到编译中。

TS 编译器也会在特定的位置寻找类型声明。默认情况下,它将加载 node_modules/@types 下的任何程序包中环境类型。因此,例如,如果项目包括 @types/node 包,则编译器将为标准节点模块(如 fspath)以及像 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";

JSX 支持

React 发布之后不久,TypeScript 就开始流行起来。并且在 1.6 版中获得了对 React 的 JSX 语法的支持(包括类型检查的能力)。要在 TypeScript 中使用JSX语法,代码必须在一个扩展名为 .tsx 的文件中,并且必须启用编译器 jsx 选项。

TypeScript 是一个编译器,默认情况下它会使用 React 将 JSX 转换为标准 JS 使用 React.createElementReact.Fragment APIs。对于不同构建方案中的互操作性,它还可以在 .jsx 文件中编译出 JSX,或者在 .js 文件中编译出 JSX,可以通过JSX选项进行配置。factoryfragment 函数也可以使用 jsxFactoryjsxFragmentFactory 选项进行更改。

控制流程分析

TypeScript 执行控制流分析以捕获常见错误和其他可能导致维护方面的麻烦的问题,包括(但不限于):

  • 执行不到的代码
  • 未使用的标签
  • 隐式返回
  • case 子句失败
  • 严格的null检查

虽然让编译器捕捉这种类型的问题可能非常有帮助,但在将 TS 添加到遗留项目时,这可能是一个问题。TS 可以捕捉到的许多问题不会导致代码编译失败,但会使代码更难理解和维护,现有的 JS 代码可能有很多这样的实例。开发人员可能不希望一次性处理所有这些问题,因此 TS 编译器允许使用编译器标志(如:allowUnreachableCodenoFallthroughCasesInSwitch )单独禁用这些检查。

编译器注释

为了使迁移遗留代码更容易,可以使用一些特殊注释来控制 TS 如何分析特定文件或文件的部分的方式:

  • // @ts-nocheck 顶部有此注释的文件将不会进行类型检查
  • // @ts-check 当没有设置 checkJs 编译器选项时,编译器将处理 .js 文件,但不进行类型检查。将此注释添加到 .js 文件的顶部将导致对其进行类型检查。
  • // @ts-ignore 禁止下一行代码行的任何类型检查错误
  • // @ts-expect-error 禁止出现以下代码行的类型检查错误。如果下一行没有类型检查错误,则引发编译错误。

@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) 不再正确地测试无效的数据案例。

在 try/catch 语句的类型

默认情况下,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应用程序中有效地利用新特性。

今天就到这里吧,伙计们,玩得开心,祝你好运。

postcss-cssnext 面向未来的CSS(css4)

在说postcss-cssnext之前一定要说一下postcss。

PostCSS是什么?

PostCSS 是使用 javascript 插件转换 CSS 的后处理器。PostCSS 本身不会对你的 CSS 做任何事情,你需要安装一些 plugins 才能开始工作。这不仅使其模块化,同时功能也会更强。

它的工作原理就是解析 CSS 并将其转换成一个 CSS 的节点树,这可以通过 javascript 来控制(也就是插件发挥作用)。然后返回修改后的树并保存。它与 Sass(一种预处理器)的工作原理不同,你基本上是用一种不同的语言来编译 CSS 。

cssnext是什么?

官网解释: PostCSS cssnext是一个postcss插件,帮助你今天使用的是最新的CSS语法。它将新的CSS规格转换成更兼容的CSS,所以你不需要等待浏览器的支持。可以逐字写将来证明CSS,忘记旧的预处理器特定语法。

简单说它是什么,它就是Polyfill,写js的同学都非常了解了。

cssnext口号就是Use tomorrow’s CSS syntax, today.

cssnext主要功能

自动添加浏览器前缀(autoprefixer)

基本自定义功能

自定义属性(var)

你没有看错,这不是js定义变量的var,这是css4的提案。他和js用法不一样,js是定义一个变量,它是引用。他需要借助一个:root选择器,相信看过css3都懂的。

栗子:

写法:
:root {
  --mainColor: red;
}

a {
  color: var(--mainColor);
}
编译后:
a {
  color: red;
}

比较简单,和scss,less定义变量都是一样的。
那么可以计算吗?可以,不过不能直接需要用到后面的一个功能。后面介绍

自定义属性集(@apply

这个是可以定义一个代码块,来循环引用。也要借助: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)

相信做过响应式的同学都玩过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常见错误及解决方案

Angular开发中,有时候有些错误让人一脸懵逼,不知道该如何下手,接下来我就介绍一下我在我使用angular中遇到的问题和解决方案(欢迎你留下你的问题和解决方案,让我们angular开发更轻松容易):

关于依赖注入问题

经常看有人在群里问下面这张图是什么问题,

image

上面问题解答是AppComponent依赖NameService服务,NameService却没有申明。

解决方案去申明注册:(注意:服务注册位置决定服务作用域)

全局申明:(一般用于全局数据共享使用,如果是注册到全局,推荐第一种方式,因为它对打包会有优化)

  1. 直接在服务里面申明作用域
@Injectable({
  providedIn: 'root',
})
export class NameService {
}
  1. 根模块注册到providers里
@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,对viewChildrencontentChildren都可见!
补充说明:组件会逐级向上寻找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关键词。

为什么创建angular组件模块都需要带上CommonModule

FP{ $7J7(RZEKF3KSA`RG{J

angular使用中一定要注意,组件,指令,管道,服务都是封装的在模块里,如果想要给其他模块里面组件使用,一定要导出。如果当前模块想要使用别人模块一定要导入。

CommonModule里面携带angular自带组件,指令,管道等,如果你带上它不能使用*ngIf*ngFor等。

最后总结,记住两点,你可以轻松玩转angular模块:

  1. 想要暴露出去给其他模块使用就要exports
  2. 想要使用别人模块的功能先要imports

'app-xxx' is not a known element

image

这是一个什么沙雕错误,翻译成中文app-xxx不是一个已知的元素,再说简单点就不是一个标准的HTML标签,算一个自定义标签,angular不认识它。

问题找到根源了,出现这个错误有个原因:

技巧:如果你用了vs code 推荐安装 Angular Language Service

  1. 你在当前模块写了组件没有去申明。

image

这里是vs code 提示错误,翻译里面3句话比较重要:

组件是Angular应用最基本的UI构建块。一个Angular应用包含一个Angular组件树。
Angular组件是指令的子集,总是与模板相关联。与其他指令不同,模板中的每个元素只能实例化一个组件。
一个组件必须属于一个NgModule,以便它对另一个组件或应用程序可用。要使它成为NgModule的成员,请在`@NgModule`元数据的`declarations`中申明它。

一个组件只能在一个NgModule申明,不能重复申明,如果A和B模块都申明一个c组件,那么就会报错,提醒你写一个D模块去申明c组件,A和B模块去引用D模块。这就是传说的共享模块思路来源。

注意:组件、指令和管道都需要在declarations中申明它。

  1. 你使用其他模块的组件、指令和管道
<div *ngIf="true"></div>

这是内置的angular指令,如果你当前模块没有导入CommonModule,就会报错Can't bind to 'ngIf' since it isn't a known property of 'div'.

解决方案只需要导入CommonModule,使用其他模块组件,指令,管道也是一样的道理。

Angular Language Service提示:

image

image

这是angular开发神器,还有更多开发功能,期待你去发现吧。。。

Can't bind to 'appCode' since it isn't a known property of 'div'

image

这又是一个什么沙雕错误,翻译成中文appCode不是一个div上面一个属性,再说简单点就不是一个标准的HTML标签标准属性,算一个自定义属性,angular不认识它。

需要先去了解一下HTML attribute 与 DOM property 的对比

重点:模板绑定是通过 property 和事件来工作的,而不是 attribute。

这里只能推测你有2个意图:

  1. 想绑定一个自定义属性:

那你需要这样去操作:使用attr.xxxx

<div [attr.appCode]="123"></div>

技巧:我们常用的html5的自定义data,需要这样来绑定[attr.data-xxx]="xxx"

  1. 你写了一个指令,给它绑定一些@Input属性。

这种情况也分2种,一直是你没有申明,或者导入。

这个参照'app-xxx' is not a known element解决。

意思是你没有在组件或指令使用@Input()装饰器申明它,或者你属性名写错了。

解决方案请正确书写和申明。

使用Angular-cli多工程配合做gitlab私有仓库

最近一直研究 Angular-cli 多工程的特性,传送门,随着公司项目越来越复杂度增加,开始考虑模块化拆分问题,这就涉及很多工程配置,模块越多配置越麻烦。因为公司代码都在公司内部搭建的gitlab里面,虽然现在github有私有项目,但是私人对公司项目不是很有好,原因你懂的。

废话就不多说了,赶紧上车吧~~~

准备

gitlab环境你项目的地址,登录进去创建项目。

需要创建2个项目,一个是源码代码项目,一个发布编译后项目。

举个栗子:

我现在要造个轮子,需要创建一个UI组件库,起个简单的名字就叫simple-ui

那就我在gitlab里面创建一个工程,名字叫simple-ui

在创建一个编译后的工程,这个是重点,名字叫simple-ui-builds。这个是你项目需要引用的地址

获取 token

  1. 登录gitlab
  2. 进入你项目 gitlab.com/jiayi/simple-ui-builds/settings/repository
  3. 最下面的Deploy Tokens, 点击Expand
  4. 开始创建token

image

  1. 生成token

image

注意:我画红色框的地方需要注意,这个是很重要的认证,那个小本本把它记录下来,因为你一关闭这个页面或者刷新,这个玩意就没有了。

克隆项目

有些一般克隆项目都是git clone http://gitlab.com/jiayi/simple-ui.git,这样没什么毛病也是正确,但是有个问题是如果你没有设置全局邮箱和用户名,就会让你每次 pullpush 操作都提示你输入用户名和密码。这样很烦。

那就这样来克隆地址:git clone http://${username}:${password}@gitlab.com/jiayi/simple-ui.git

  • ${username} 你的用户名
  • ${password} 你的登录密码

到这里,我们的工作区里面应该有2个git目录了

gitlab
     ---  simple-ui
     ---  simple-ui-builds

开发项目

  1. 安装开发必备依赖(nodejs就不必说了)
   npm i -g @angular/cli
  1. 生成angular项目,如果你当前在gitlab文件夹里
   ng new sim-simple --directory=simple-ui
  选择路由,yes
  选择css预处理器,scss/sass
  等待安装依赖
  1. 进入simple-ui文件夹,
   ng start // 开始运行项目。
  1. 选择src当我们simple-ui的文档项目界面,也算是测试界面,因为生成的library,就相当于一个js文件,你压根不知道它是样子的。
   ng g library simple-ui --prefix=sim

然后开始尽情玩耍你的UI组件库。

  1. 发布编译后的UI库
   ng build simple-ui

这样就好自定义发布到dist/simple-ui

  1. 我们要做最重要的一步,发布到simple-ui-builds里面。
  npm run publish:lib

思考一下我们需要什么哪些操作?

  1. 版本日志 根据simple-ui提交记录 生成CHANGELOG.md文件
  2. dist/simple-ui文件拷贝到simple-ui-builds里面
  3. 修改版本号 把package.json里的version+1,这里需要做个配置,不然会一直累加
  4. 自动提交本地文件 git add .
  5. 自动写提交日志 git commit -m "release: cut the vxx.xx.xx release"
  6. 不需要拉代码,没有git pull一说,
  7. 自动提交代码并打tag 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
  • ${gitlab+deploy-token-username} 就是前面要你用小本本记录的,Use this username as a login.绿色提示文字输入框里面的内容。
  • ${token} Use this token as a password. Make sure you save it - you won't be able to access it again.红色提示框里面的内容。

这样就把你的依赖安装的你的项目里面,就可以和其他npm包一样的使用了。

自动发布

...待续

Angular 事件绑定扩展增强 - Angular Events Plugin

Angular提供了许多事件类型来与你的应用进行通信。 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前缀即可。

  • onclick ---> (click)
  • onkeypress ---> (keypress)
  • 等等
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事件一样。那么它们需要注意:

  1. DOM 事件冒泡机制,允许在父元素监听由子元素触发的 DOM 事件
  2. Angular 支持 DOM 事件冒泡机制,但不支持自定义事件的冒泡。
  3. 自定义事件名称与 DOM 事件的名称如 (click,change,select,submit) 同名,可能会导致问题。虽然可以使用 stopPropagation()方法解决问题,但实际工作中,不建议这样使用。
  4. 自定义事件不要使用on前缀,方法名可以使用on开头,参考风格指南-不要给输出属性加前缀
  5. 原生事件的$event返回是 DOM Events
  6. 自定义事件的$event返回是 EventEmitter.emit() 传递的值,也可以使用 EventEmitter.next()

事件修饰

在实际项目中,我们经常需要在事件处理器中调用 preventDefault()stopPropagation() 方法。

还有一个比较少用功能比较强大,stopPropagation 增强版 stopImmediatePropagation()

  • preventDefault() - 如果事件可取消,则取消该事件,意味着该事件的所有默认动作都不会发生。需要注意的是该方法不会阻止该事件的冒泡。
  • stopPropagation() - 阻止当前事件在 DOM 树上冒泡。
  • 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)">

此功能适用于特殊键和修饰键,例如EnterEscShiftAltTabBackspaceCommand,但它也适用于字母,数字,方向箭头和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 原生实现,那一定有方法可以做到。

我们去查看源码:https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/key_events.ts

在源码里面由一个突出的导入:

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);
  }
}

抽象类里面我们需要实现supportsaddEventListener方法。

  • 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 的文章介绍很少,所以很多人不知道可以有以下的*操作。

我们也来实现事件修饰符:

  • once - 只绑定一次,调用完成即销毁。 使用 Renderer2.listen 绑定事件实现
  • stop - 阻止冒泡。使用stopPropagation() 实现。
  • prevent - 阻止默认事件。使用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)
  }

我们已经实现普遍版本的事件修饰,如果想要加上防抖,节流更风*的操作我们该如何做了,这个留个大家一个悬念,可以思考一下,欢迎和我交流心得。

最后:我们不光可以做事件修饰插件还可以做事件打印日志插件,你看完上面的例子,应该很简单操作了。如果不知道怎么下手,欢迎和我交流心得。

今天就到这里吧,伙计们,玩得开心,祝你好运

Angular认证之使用HttpClient和http拦截器

前言

Angular5自从2017-11-01发布正式版以来,很多相关依赖模块也做了相应的升级,比如谷歌官方的UI组件库模块[Material2]也千呼万唤始出来,发布正式版5.0,版本和angular5对应。Angular5最大的改变是把HttpClientModule小三转正,鼓励开发者去掉原来的HttpModule

HttpClient由来

相信很多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(或其他形式的访问令牌)作为AuthorizationBearer方案相连接。

常见写法:
Headers{
   Authorization: 'Bearer xxxxxx.xxx.xx'
}

让我们来看看如何使用Angular的httpinterceptor接口来进行认证的http请求。

创建一个认证服务拦截器

当在Angular应用程序中处理身份验证时,通常最好将您需要的所有内容都放在一个专门的服务中。这个认证服务应该包含至少有允许用户登录和注销的基本方法。它还应该包括一个方法,从客户端存储的任何地方获取一个JSON Web Token,来确定用户是否被认证的方法。

npm常用你应该懂的使用技巧

随着nodejs流行,npm伴随nodejs逐步成长起来,安装nodejs以后就自动安装了npm。

npm是什么?

npm 是 nodejs 的包管理和分发工具。它 可以让 javascript 开发者能够更加轻松的共享代码和共用代码片段,并且通过 npm 管理你分享的代码也很方便快捷和简单。

npm升级

npm install npm -g

查看当前版本

 npm -v 

安装方式(全局 or 局部)

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

指定安装@2.1.1版本

npm install [email protected] --save

指定安装大于等于@2.0.0小于2.2.0版本

npm install jquery@">=2.0.0 <2.2.0" --save

安装github包

因为有些包没有添加到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初始化

上面说了怎么安装删除,现在说一个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

注册账号

  1. 先需要去npm注册
  2. 在搜索框右边sign up or log in

npm验证

  1. 打开命令行npm login
  2. 输入你的username 回车
  3. 输入你的password 这个看不到输入 输完就回车
  4. 输入你的email
    以上信息都是你注册填写的。

npm发布

在你要发布的项目文件夹里面打开命令输入npm publish

如果版本未修改发布会报错,你需要去修改一下version。
每次修改都需要npm publish,记得修改version

npm包删除

有发布就有删除,删除好像有个限制,如果大于24小时,需要联系npm管理员删除。

npm unpublish 包名

就完了

npm其他常用命名

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常用一些小知识。

GET新技能之Git commit message

前言

程序员是一个千变万化但是又不离其中的职业,能够实现各种各样的功能,实现的方法也是各种各样,而最佳实践又是很多程序员比较认可和遵守的一些规则,规范可能并不会带来直接的利好,但是随着工程的扩大,团队多人协作,这些良好的习惯可能会带来很好的优势,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】很神一样存在,你可以看清楚是哪个二货提交这段垃圾代码,哪个大牛写这段神奇的代码。

那么提交一个标准消息的格式目的是什么:

  • 统一团队Git commit日志标准,便于后续代码review,版本发布以及日志自动化生成等等。
  • 统一团队的Git工作流,包括分支使用、tag规范、issue等
  • 统一团队风格,看上去像是一个人写的

提交消息格式

每个提交消息由title(标题),body(正文)和footer(页脚)组成。标题具有特殊格式,包括type(类型),scope(范围)和subject(主题):

<type>(<scope>): <subject>
// 空一行
<body>
// 空一行
<footer>

标题

标题是必需的,标题的范围是可选的。

任何一行的提交信息都不能超过100个字符!这样可以让消息在GitHub以及各种git工具中更容易阅读。

类型

必须是以下之一:

  • build:影响构建系统或外部依赖关系的更改(示例范围:gulp, broccoli, npm)
  • ci: 更改我们的配置文件和脚本(示例范围:Travis, Circle, BrowserStack, SauceLabs)
  • docs: 仅文档更改,比如README, CHANGELOG, CONTRIBUTE等等
  • feat: 一个新功能
  • fix: 一个错误修复
  • perf: 一个改进性能的代码更改,比如提升性能、体验
  • refactor: 代码更改,既不修复错误也不添加功能
  • style: 不改变代码逻辑,仅仅修改代码风格(空格,格式化,分号分号等)
  • test: 添加缺失测试或更正现有测试(测试用例,包括单元测试、集成测试等)
  • revert: 回滚到某一个版本(带上版本号)

范围

范围应该是受影响的npm包的名称(由人们阅读从提交消息生成的更改日志所感知的范围。
以下是支持的范围的列表:(这里也指业务模块)

  • user 用户
  • pay 支付
  • product 产品
  • article 文章
  • core 核心
  • router 路由
  • api 接口
  • doc 文档
  • upgrade 升级计划
  • ...等

主题

主题包含对变更的简明描述:

  • 以动词开头,使用第一人称现在时:"change"(改变 动作) 不是 "changed"(改变 过去式) 也不是 "changes"(改变 三单形式)
  • 不要大写第一个字母
  • 最后没有点(。)

常用表述语:

  • add 添加
  • change 改变
  • update 更新
  • remove 移动
  • delete 删除

正文

  • 使用第一人称现在时,比如使用:"change"(改变 动作) 不是 "changed"(改变 过去式) 也不是 "changes"(改变 三单形式) 。
  • 正文应该包括变化的动机,并与之前的行为进行对比。

页脚

  • 如果当前 commit 针对某个issue,那么可以在 Footer 部分关闭这个 issue 。
  • 如果当前代码与上一个版本不兼容,则 Footer 部分以 BREAKING CHANGE 开头,后面是对变动的描述、以及变动理由和迁移方法。

页脚应包含对问题的结束引用(如果有)。请参照以下格式:

  • close 关闭(动作)
  • closes 关闭(三单形式)
  • closed 关闭(过去式)
  • fix 修理(动作)
  • fixes 修理(三单形式)
  • fixed 修理(过去式)
  • resolve 解决(动作)
  • resolves 解决(三单形式)
  • resolved 解决(过去式)

Example1:

关闭`issue`编号`123`问题

Closes #123

Example2:

关闭并修理`issue`编号`45`问题

Fixes #45

Example3:

解决`issue`编号`55`bug

Resolves #55

Revert

还有一种特殊情况,如果当前 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

主体内容和尾部是可选的,标题的范围也是可选的,其他必填的

Git分支与版本发布规范

基本原则:master为保护分支,不直接在master上进行代码修改和提交。

开发日常需求或者项目时,从master分支上checkout一个feature分支进行开发或者bugfix分支进行bug修复,功能测试完毕并且项目发布上线后,将feature分支合并到主干master,并且打Tag发布,最后删除开发分支。分支命名规范:

  • 分支版本命名规则:分支类型 分支发布时间 分支功能。比如:feature_20170401_fairy_flower
  • 分支类型包括:feature、 bugfix、refactor三种类型,即新功能开发、bug修复和代码重构
  • 时间使用年月日进行命名,不足2位补0
  • 分支功能命名使用snake case命名法,即下划线命名。

Tag包括3位版本,前缀使用v。比如v1.2.31。Tag命名规范:

  1. 新功能开发使用第2位版本号,bug修复使用第3位版本号
  2. 核心基础库或者Node中间件,可以在大版本发布,请使用灰度版本号,在版本后面加上后缀,用中划线分隔。alpha或者beta后面加上次数,即第几次alpha:
- v2.0.0-alpha-1
- v2.0.0-belta-2

软件的生命周期中一般分4个版本:

  1. alpha版:内部测试版。
  2. beta版:公开测试版。
  3. rc版:全写:Release Candidate(候选版本)。
  4. stable版:正式发布稳定版。

版本正式发布前需要生成changelog文档,然后再发布上线。

生成changelog文档

install

npm install -D conventional-changelog-cli
or 
npm install -g conventional-changelog-cli

这不会覆盖任何以前的更改日志。以上生成基于自上一个符合“特征”,“修复”,“性能改进”或“突破更改”的模式的最后一个标记之后的提交的更改日志。

run

如果你第一次使用这个工具,想要生成所有以前的更改日志,你可以做

conventional-changelog -p angular -i CHANGELOG.md -s -r 0

也可以添加到package.jsonscripts:

"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"

然后

npm run changelog

推荐工作流程

  1. 修改代码
  2. 提交代码
  3. 更新package.json版本号
  4. conventionalChangelog生成changelog文档
  5. 提交package.json和CHANGELOG.md文件
  6. 打Tag
  7. Push代码

2017开博客了

开博客了

2017开始在Github写博客,记录工作总结,学习经验。

2017计划

2017计划2个开源项目。

  1. 仿简书nodejs+express+mongodb+vue2+angular2(链接
  2. 计划翻译一本英文书《Eloquent JavaScript》,也算对自己学英语一个检验。

在NestJS中使用Typegoose分享

今天,我将与你分享在使用NestJS和MongoDB时一直在使用的工作流程/技术。 此工作流程利用了Typegoose的功能。

背景:最近在升级 nest-cnode 项目,之前使用的是
Mongoose,它的操作和Typescript有点冲突,创建schemainterface要写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/typegoosetypegoose/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.tsapp.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;
    }
}

先来理解一下上面的代码:

  1. 创建一个抽象通用基础类,遵守Typescript规范,抽象类不能直接new,必须继承才能使用。
  2. created_at,`updated_at和id是我所有领域模型的三个字段(你还可以添加更多通用属性或Schema字段)。timestamps启用映射我们创建时间和更新时间自动更新。id实际上是_id的一个getter,所以它总是在那里,但是为了能够在与.lean()或.toJSON()匹配时获取id,我们需要设置toJSON:{…}选项,如代码所示
  3. @prop() 将字段注释为Schema的一部分。在typegoose了解更多
  4. static get schema() 神奇的地方就在这里,typegoose暴露诸如getModelForClass()buildSchema()之类的函数,我们只需要创建纯就可以通过这些方法和typegoose做绑定关联。为什么需要buildSchema(),那是因为我们使用@nestjs/mongoose,通过MongooseModule.forFeature注册mongoose.model,需要2个必须属性:nameSchema
    这里buildSchema()就是生成Schema的方法。它的工作原理是我们调用buildSchema()并传入this。在这种情况下,在静态方法中,实际的类本身调用schema getter,这使得将get schema()放在抽象类上成为可能,这样我们就可以在这里通用处理一下。在此之前,我们需要编写方法来获取每个领域模型类的模式和模型名,这有点像样板文件。
  5. 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;
    }
}

这个代码很长。我们在说明一下做了什么:

  1. 首先一样,创建一个抽象类,携带泛型,它接受类型参数T extends BaseModel。这就是TypeScript的高级类型。在这里,我们明确地说 T extends BaseModel,这样我们只能将实际的领域模型类传递给这个基本服务,这是一个安全行为,你可以通过TypeScript来防止将ANY作为类型参数传递。
  2. 声明一个名为model的受保护字段,其类型为ReturnModelType<AnyParamConstructor<T>>。 实际上,ReturnModelType<AnyParamConstructor<T>>只是mongoose.model()将返回的类型。 那么是不是Model<T> ?是的。 但是Model<T>期望T extends mongoose.Document的接口。 使用typegoose,这里的所有内容都是类,因此我们无法真正使用Model提供的方法。 另一个想法是使模型受到保护,这意味着只有子类才能访问此字段,因此我们不会在应用程序的任何其他层(例如控制器层)中公开userService.model`
  3. 设置一些通用的方法来包裹mongoose.Model的方法并返回适当的类型。你可能已经注意到,每种方法都有两个版本。 第一个版本返回一个DocumentQuery,它使你可以链接方法以进一步:filter, project, 和一些其他东西,比如populatelean。 第2版​​(异步版)可在你不关心任何其他可链接方法而只想快速获取数据的情况下提供帮助。异步版本还将具有错误处理程序,我们将在其中引发MongoErrorInternalServerErrorException。 使用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;
}
  1. 这里大致说一下@prop(options: object)
  • required:boolean 必填
  • index: boolean 索引
  • unique: boolean 唯一
  • default: any 默认值
  • _id: boolean 为子文档创建标识
  • type: mongoose.Schema.Types 类型
  • select: boolean 要检索不带此属性的数据
  • ref: Class | string 用于引用的类
  • ...还有很多属性,基本上都是和mongoose配置一样
  1. @arrayProp设置数组
  2. 设置虚拟属性,直接使用getter/setter对应的就是get/set
  3. 设置createIndex使用@Index,用法和Schema.index一样
  4. 使用@pre设置Schema.pre (不能使用箭头函数)
  5. 使用@post设置Schema.post (不能使用箭头函数)
  6. 使用@plugin设置Schema.plugin

打开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,书写特定的快捷方法即可,并导出UserEntityUserModel类型,模型名称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)

今天就到这里吧,伙计们,玩得开心,祝你好运

让我们用Nestjs来重写一个CNode(下)

通过上篇学习,我们已经完成注册登录退出,一套基本用户体系,接下来我们需要完善它们。

这篇主要内容:

  • 完善首页
  • 完善其他静态页面
  • 完善主题系统和用户权限
  • 完善找回密码修改用户信息
  • 完善用户系统
  • 编写测试
  • Typeorm重写取代Mongoose数据库

这是计划,如果文章太长,后2个内容会新开一个补充来专门介绍它们。

首页

先看首页效果图:

image

总共分为这么几大块:

  1. 主题分类
  2. 主题列表
  3. 登录用户信息
  4. 发表主题按钮
  5. 无人回复的话题
  6. 积分榜

我们现在可以实现是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未登录的样子:

image

然后会报一些错误,比如没有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();
    };
  }
}

后面再去完善它们,保证它们正确性。这里先保证页面不会报错,能运行。

一番捣鼓以后,登录成功以后显示:

image

完美,接下来去先要处理一个比较重要东西,就是我们刚刚写的中间件。大多数模板报错都和它们有关。

修改ConfigModule

我们之前犯了一个错误,我们说配置说系统配置,应用配置组成,系统配置和我们开发生产部署,息息相关。应用配置和我们应用相关,页面显示一些信息。我们现在需要把它们想办法合并,获取只会只需要一个入口即可。

我们先思考一个我们需要怎么样去获取:

  • get('key') => string | number | boolean | {} | []
  • get('key.subkey') => string | number | boolean | {} | []
  • getBoolean('key') => boolean
  • getJson('key') => {} | []
  • getNumber('key') => number
  • getSystem() => 所有的系统配置
  • getApp(key) => 所有的应用配置 如果有多个应用配置,文件名就是key
  • has(key) => boolean
  • isDevelopment => boolean 是否开发环境
  • isProduction => boolean 是否生产环境
  • isTest => boolean 是否测试环境

原则:

  1. 尽量保证我们配置属性层级扁平化。
  2. 系统配置使用大写
  3. 应用配置使用小写

一个常规网站应用系统,它该有哪些配置,环境变量(运维部署配置),配置信息(应用信息),用户偏好配置等。

我们还是使用.env来存储环境变量,创建一个config文件夹存储,配置信息和一些快捷配置(比如:根据环境变量拼接新的配置信息方便使用,数据库配置等)

那么我们需要改写config模块了。这里不花篇幅介绍了,代码里面有注释,使用只需要config.get('xxx'), 或者config.get('xxx.xxx')

完善locals.middleware

我们之前直接使用的cnode的模板,所以没有什么问题,实际需要一些本地变量提供给页面的,我们把这些变量全部存到locals.middleware里面,便于维护,

  • config 应用配置
  • Loader 资源加载器
  • assets 静态资源映射地址
  • helper 工具助手(里面有各种函数)

写完以后我们开始替换模板。哪里报错改哪里。

接下来时间都是无聊改模板,创建模块,差不多和cnode一样。

主题模块 topic

这是除了user模块外最重要的一个模块,博客系统最主要也是主题帖子。

shared/mongodb/创建topic一套文件,和user一样,这里不细展开说明。

字段类型和cnode一样,这个不用再叙述。不明白看前面user模块

我们已经有了用户注册和登录,那么我们主题模块顺序标准增删改查,至少需要先添加主题,显示主题列表页面,有主题显示的详情页面,优先完成增和查。

登录以后首页就会出现发布话题按钮,接下来我们就去完善它,这里没有其他难点,唯一一个复杂就是图片上传。

feature 创建 topic 模块,完成一套服务、控制器、dto等模块功能。

代码已经上传 传送门

版本升级v6

很长时间工作忙,一直没有在填这个坑,本来以为国内时候来弄,哪知道家里网感人肺腑,压根打不开,现在享受福利,在家办公,就来把这个坑填完,看评论小伙伴说现在官网都是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功能:

  1. nest start 运行
  2. nest build 打包
  3. nest add 管理npm包依赖
  4. nest update 更新 package.json的"dependencies"
  5. eslint代替tslint(作者已经宣布废弃申明,具体可以看它的github)
  6. 支持可以创建Angular项目
  7. monorepo模式
  8. nest library 创建生态模块(规范生态圈)
  9. cype file 拷贝资源文件 compilerOptions.assets
  10. spec禁用配置 generateOptions.spec
  11. ts v3.7.4 可以直接使用 .??? 特性(不了解的可以去看看ECMA介绍)

我会尽量发挥新的cli特性,修改部分代码,修改过程中关键点我会代码里面注释。如果无法注释,我会在这里说明。

几点修改:

  1. 这里升级使用typegoose替代mongoose直接书写方式,简单挖坑指南
  2. 使用assets功能,我们之前的静态文件夹叫public,为了保证统一改成assets
  3. views和assets文件夹放到src里
  4. 数据库模型移到src/models
  5. feature更名为controllers,不在有模块,直接到AppModule里面导入
  6. 使用@nestjs/config替代自己写的config模块
  7. bootstrap里面东西移到core/setup,清爽了许多
  8. 使用@nest-modules/mailer代替自己写的mailer模块
  9. 使用@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
  • upload.module.ts
  • upload.config.ts

控制器就一个接口

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],
        };
    }
  • FileInterceptor 是nestjs提供的文件上传拦截器 multerOptions
  • UploadedFile 是nestjs提供管道
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 4发布以来,我一直使用Angular在企业中开发,大大小小项目开发10余个,一点一滴摸索和实践,不断优化调整项目结构。最终参考 风格指南,绘制一张比较满意的文件夹结构图,一直在项目中实践运用。

image

在本文中,我将介绍:

  • 在文件夹中分配我们的AngularTypescript实体
  • monorepos vs libraries
  • 状态管理作为服务模块的集合

Angular 实体

在建立新代码库时,我经常做的第一件事是思考和定义构成我的项目的编程实体。作为Angular的开发者,我们已经非常了解其中的一些了:

  • modules 模块
  • components 组件
  • directives 指令
  • services 服务
  • pipes 管道
  • guards 守卫

正如框架的文档所建议的,每次我们创建这些实体时,我们都会在文件名后面加上实体的名称。命名规范

因此,如果我们创建一个类名为filterPipe的管道,我们将把它的文件命名为filter.pipe。如果我们有一个叫ButtonComponent的组件,我们想要它的文件button.component.tsbutton.component.htmlbutton.component.scss

如果不首先讨论Angular模块,我们就不能讨论Angular项目的结构。在Angular里主要靠模块来管理维护依赖关系。

在Angular环境中,模块是一种对相关组件、管道和服务进行分组的方式。这个模块集被分组来组成应用程序,是的,就像它是乐高积木一样。模块可以隐藏或导出组件(管道、服务等)。导出的组件可以被其他模块使用,被模块隐藏的组件只能被自己使用。

在Angular中,这种模块化称为NgModule。 每个应用程序均由至少一个NgModule类组成,该类是应用程序的根模块。 根模块默认情况下称为AppModule

由于Angular的应用程序是由导入其他的模块组成的,它们自然会成为构成Angular项目的根文件夹。每个模块将包含所有其他Angular实体,这些实体都包含在它们自己的文件夹中。

假设我们正在构建一个电子商务应用程序,并创建了一个购物车功能模块,它的结构如下所示:

1_ehG_arBxpW0L2_MfzLC11w

正如您可能注意到的,我倾向于区分容器(智能)(containers\smart)和组件(愚蠢)(components \dumb),所以我将它们放在不同的文件夹中,但这并不是我所提倡的

但是,如果某个东西需要在其他地方重用怎么办?

在本例中,我们创建了一个共享模块SharedModule,它将托管所有共享实体,这些实体将被提供给项目的每个模块。

1_4yJiLhCEV4RNKN_7dw5PBA

SharedModule通常由一些实体组成,这些实体在一个项目中不同的模块之间共享,但在项目外部通常不需要它们。当我们遇到可以跨不同团队和项目重用的服务或组件时,并且这些服务或组件在理想情况下不会经常更改,那么我们可能需要构建一个Angular库。

在Angular里面有几个比较重要模块归纳:

  • 根模块:作为入口启动模块,一个项目至少有一个根模块AppModule
  • 特性模块:特性模块应该是Angular核心,我们上面说的Angular实体,其实就是特性模块,特性模块扮演重要角色,特性模块的分类不同功能也不同。
  • 核心模块:核心模块包含的代码将用于实例化应用程序并加载一些核心功能(只实例化一次模块比如:HttpClientModule等模块、单例服务、单实例组件),核心模块还可以用于导出根模块中需要的任何第三方模块,这个想法主要是让根模块尽可能的精简。
  • 共享模块:共享模块同样包含了应用程序和功能模块之间使用的代码。但是不同之处在于,需要根据将这个共享模块导入到特性模块中。您不需要将共享模块导入主根模块或核心模块。共享模块应该包含通用的组件/管道/指令,并且还导出常用的Angular模块(例如@ ngular / common的* ngIf指令的CommonModule),更应该在共享模块中具有“哑组件(dumb components)”。

Angular 典型的模块结构

image

以前版本会有一些篇幅去强调核心模块重要性,因为angular单例服务原因,核心模块主要做全局服务申明,angular6以后版本注册服务使用providedIn更方便提供全局服务。核心模块依旧是一个很好实践,把管理初始化应用的工作交给核心模块吧,减轻根模块的工作。

Typescript 实体

如果你在Angular中使用Typescript(ps:早期Angular文档可以使用Typescript、Javascript、Dart,现在只剩Typescript,主推Typescript),我想你也会这样做,你也必须考虑到Typescript自身强大的实体,我们可以利用它们来构建一个结构化的、写得很好的代码库。

这里有一个Typescript实体的列表,你将在你的项目中使用最多:

  • classes 类
  • enums 枚举
  • interfaces 接口
  • types 类型

我建议为每个后端实体创建一个匹配的Typescript文件,它包括:

  • enum 枚举
  • dto(Data Transfer Object) 用于请求和响应接口
  • data classes 数据类

小技巧:Typescript里classes也是可以直接作为类型,还可以直接new,如果你这个类不new,尽量不要用了,占地方。

我喜欢将这些实体放到一个特性模块中,当然它们也可以有自己的文件夹,可以其称为core,但这在很大程度上取决于你和你的团队。

有时,我们将针对公司内多个团队共享的微服务进行开发,或者多个特性模块需要共享实体。在类似的情况下,我认为构建一个Angular库来托管匹配的类、接口和枚举是有意义的,而不是在本地开发模块。

Libraries, Monorepos and Microfrontends

当您使用高度可重用的服务或组件(可以分为服务模块和窗口小部件模块)时,您可能希望将这些模块构建为Angular库,可以在它们自己的存储库中创建,也可以在更大的monorepo中创建。

多亏了强大的Angular CLI,我们可以用这个简单的命令轻松生成Angular库,这些库将构建在一个名为projects的文件夹中

ng generate library my-lib

有关Angular库的完整描述,请参阅Angular.cn的官方文档

与本地模块相比,使用库具有一些优势:

  • 我们在考虑和构建这些模块时会考虑可重用性
  • 我们可以很容易地与其他团队/项目发布和共享这些库

当然,也有一些缺陷:

  • 你需要将你的库链接到你的项目中,并为每次更改重新构建它
  • 如果是通过NPM发布并在项目外部构建的,则需要保持项目与库的最新版本同步

例如:假设ButtonComponent使用所有团队都使用的按钮UI库,我们可能希望共享我们的抽象,以避免许多库实际上在做通常的基础工作。

因此,我们创建了一个称为按钮UI库,并将其作为@ui-kit/button发布到NPM。

我们在Github上面看到的很多优秀开源Angular资源库,大部分都是使用这种方式完成的。

但是,monoreposmicrofrontends呢?

这可能需要较长的文章,但是如果不提及其他两种方式,我们就不能谈论企业级项目。

我们这里可以简单介绍一下它们:

未完待续...

抽象语法树 - 编译器背后的魔法

你可能见过术语 抽象语法树AST(Abstract Syntax Trees),或者甚至在计算机科学课程中了解过它们。

表面上与我们前端工程师需要做的工作的内容无关,其实相反,抽象语法树在前端生态系统中无处不在。理解抽象语法树并不是成为一名高效或成功的前端工程师的必要条件,但它可以解锁一套在前端开发中具有许多实际应用的新技能。

什么是抽象语法树

在最简单的形式中,抽象语法树是一种表示代码以便计算机能够理解的方法。我们编写的代码是一个巨大的字符串,只有在计算机能够理解和解释这些代码的情况下,它们才能发挥作用。

抽象语法树是一种树状的数据结构。树数据结构从根值开始。然后,根可以指向其他值,这些值又指向其他值,以此类推。这就开始创建一个隐式的层次结构,而且这也是一种很好的方式来表示源代码,计算机可以很容易地解释它。

20211002181937846_tree

例如,假设我们有代码片段 2 +(4 * 10)。要计算此代码,首先执行乘法,然后执行加法。由于添加是这个层次结构中发生的最后一件事或最高的一件事,所以它将是根。然后它指向另外两个值,左边是数字 2,右边是另一个方程。这次是乘法,它也有两个值,左边是 4,右边是 10

20211002180608211_example-ast

使用抽象语法树的一个常见例子是在编译器中。编译器接受源代码作为输入,然后输出另一种语言。这通常是从高级编程语言到低级编程语言,比如机器代码。前端开发中的一个常见示例是转译,其中现代 JavaScript 被转译为旧版本的 JavaScript 语法。

作为一个前端工程师,为什么要关心抽象语法树

首先,我们可能每天都依赖于构建在抽象语法树之上的工具。一些常见的依赖抽象语法树的前端构建工具或编译器的例子有 webpack, babelswc,然而,它们并不是独立地构建工具。像 Pretier(代码格式化器)、ESLint(代码检查器)或 Jscodesshift(代码转换器)这样的工具有不同的目的,但它们都依赖抽象语法树,因为它们都需要直接理解和与源代码一起工作。

在不理解抽象语法树的情况下,可以使用这些工具中的大多数,但是有些工具希望我们理解 AST 以用于更高级的用途。这使得能够以可靠和自动的方式与代码交互,并且前面提到的许多工具在内部使用这些或类似的工具。这允许在静态分析/审核代码中创建完全自定义功能,使动态代码转换,或者我们可能在大代码库中解决任何问题。

虽然理解抽象语法树并不是成为一名高效和成功的前端工程师的必要条件,但具备基本的理解可以提升您维护持续发展的大型代码库的能力,并更容易地与依赖它们的常用工具进行交互。抽象语法树能够“大规模地”与代码库交互。

实用抽象语法树

JSX 编写的 React 应用程序。使用 SASS 编写的 style。使用 Pug 编写的 E-mail 模板。这类项目包含一个编译步骤,该步骤将使用浏览器无法理解的语言编写的源代码转换为浏览器可以解析和执行的 HTML/CSS/JavaScript 代码。

每当我们告诉编译器构建一个 React 应用程序时,我们都希望编译器使用 React 函数调用将 JSX 源代码处理并转换为纯 JavaScript 代码。通常,我们将编译器视为一个黑盒,很少查看它的内部,看看它究竟如何执行这种转换。编译器背后的魔力在于它用来传达源代码结构模式的数据结构:抽象语法树(AST)。

通过分析源代码的语法并将其分解为其组成的标记(例如关键字、字面量、操作符等),我们可以将代码表示为树状数据结构。能够使用抽象语法树泛化源代码的构造和规则,为编译器在转换代码时提供了一个高级模型。这取决于编译器来遍历抽象语法树(通过深度优先搜索遍历算法),从它中提取信息和等效功能的输出代码,但以不同的语言编写或针对性能进行优化。在某些方面,我们可能会说编译器的抽象语法树的作用类似于算法的伪代码。

抽象语法树并不局限于编译器。事实上,它们可以应用于各种用例中,例如:

  • 代码审核(静态分析) - 了解源代码的当前状态。例如,在运行代码之前,是否有拼写错误或拼错关键字(例如,函数是 function 而不是 fucntion)。
  • 代码转换(代码插件) - 将源代码从一种状态转换为另一种状态。例如,压缩变量名或将其改写为一种完全不同的语言。
  • 语法检查(规则) - 防止某些模式出现在源代码中。例如,避免无限循环或重复的 switch/case

下面,我将介绍实战案例:

  • JSX代码如何变成抽象语法树。
  • 如何使用 Babel 健壮的插件系统和包将不同的语法转化为有效的 JavaScript 语法。

将 JSX 代码转换为抽象语法树

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,原始源代码必须是:

  1. 被编译器的前端扫描并解析。它对 JSX 源代码进行标记,对标记进行排序,并将每个标记分类为标识符、操作符等。一旦它完成了对源代码的分析,前端将标记序列表示为一个抽象语法树。语法的不相关部分被“抽象”掉了,因为这些细节已经可以从树结构(即层次结构)中暗示出来。
  2. 由编译器的后端以所需的语言输出。它接受抽象语法树作为输入,并基于抽象语法树生成 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"
                      }
                    }
                  ]
                }
              }
            ]
          }
        }
      ]
    }
  }
]

在这里查看完整的抽象语法树。

下面是这个抽象语法树的可视化:

gfds8g7e12g1297gsdfagf

注意:叶节点代表代码本身的实际标识符、关键字和文字。其余的父节点表示解析器发现的令牌类型。

下面的图表说明了编译器是如何工作的:

m1m0dfs8fa8fh3h2

负责为 JSX 代码输出上述抽象语法树的解析器是 Babel 解析器,它为 Babel 编译器解析源代码。Babel 使用了基于 ESTree 规范的抽象语法树。使用 JavaScript Next 和 JSX 编写的 React 应用程序依赖于 Babel 编译器将代码转换为与我们选择的目标浏览器兼容的 JavaScript。

用 Babel 插件转换 JSX 代码

通过插件,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

Screen Shot 2021-10-08 at 5 51 42 PM

解析器选项插件告诉 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 代码转换:

  1. 安装 Babel CLI 工具和 Babel 编译器核心。
npm install --save-dev @babel/core @babel/cli
  1. 安装 @babel/plugin-transform-react-jsx@babel/preset-react ,其中包括此插件:
npm install --save-dev @babel/plugin-transform-react-jsx # Or @babel/preset-react.
  1. 通过在 Project 目录的根目录处创建 .babelrc.json 文件,使用插件配置 Babel。
{
  // Uncomment the below if you use the preset.
  // "presets": [
  //   "@babel/preset-react"
  // ],
  "plugins": [
    "@babel/plugin-transform-react-jsx"
  ]
}
  1. 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 的插件和预设的生态系统,以便在我们的项目中使用令人兴奋的新功能,无论浏览器支持如何,我们都可以在项目中的可选链操作符

今天就到这里吧,伙计们,玩得开心,祝你好运。

ES6之变量和作用域

变量

变量是具有唯一名称的命名容器,用于存储数据值。 以下语句声明了一个名称为“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)引入了两种声明变量的新方法:letconst。使用新关键字的原因是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中引入了块作用域的概念,以及声明变量constlet的新方法。这意味着变量可以在两个大括号{}之间访问。例如在iffor里面。

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

letconst变量只有在定义求值后才初始化。它们不会像函数范围中那样被提升。在初始化之前访问它们会导致ReferenceError

我希望你们能看到这是如何更容易推理的。

Const vs Let

因此,既然我们知道应该使用 letconst,那么什么时候应该使用它呢?

两者的不同之处在于 const 的值不能通过重新赋值改变,也不能被重新声明。因此,如果我们不需要重新赋值,我们应该使用const。这也使得代码更加清晰,因为我们用一个变量来表示一个不可变的值。

甚至可以始终将变量声明为const,直到看到需要重新分配变量然后更改为let为止。 何时需要让我们进入循环的一个示例:

for(let i = 0; i < 10; i++) {
  console.log(i); // 1, 2, 3, 4 …
}

Var vs Let

我们为什么要使用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

为什么会这样呢。

  1. for 循环会先执行完再执行 setTimeout 回调函数(同步优先于异步优先于回调)
  2. for 循环和 setTimeout 回调函数不在一个作用域。 setTimeout 回调函数属于函数级的作用域,不属于 for 循环体,属于全局。
  3. 等到 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)
}

除此之外,也可以通过闭包来解决问题,这是一道常见的经典面试题

总结

  • 声明在全局作用域中的变量在代码中的任何地方都可以访问
  • 在局部作用域中声明的变量不能从局部作用域中外部访问
  • constlet 使用存在于两个大括号 {} 之间的块作用域
  • 通常将 const 用于其值永远不会改变的变量
  • 对于其他的申明使用 let
  • 不要使用 var 以避免混淆

让我们用Nestjs来重写一个CNode(上)

背景

在本文中,我将使用Nest.js构建一个CNode

为什么这篇文章?我喜欢NodeJs,虽然我的NodeJs水平一般。但我还是用它来记录一下我学习过程。

最近,我发现了Nest.js框架,它有效地解决了Nodejs项目中的一个难题:体系结构。Nest旨在提供开箱即用的应用程序,可以轻松创建高度可测试,可扩展,松散耦合且易于维护的应用程序。Nest.jsTypeScript引入Node.js中并基于Express封装。所以,我想用Nest.js尝试写一个CNode。(ps:目前CNode采用Egg编写)我没有找到关于这个话题的快速入门,所以我会给你我的实践,你可以轻松地扩展到你的项目。

本文的目的不是介绍Nest.js。对于那些不熟悉Nest.js的人:它是构建Node.js Web应用程序的框架。尽管Node.js已经包含很多用于开发Web应用程序的库,但它们都没有有效地解决最重要的主题之一:体系结构。

现在,请系好安全带,我们要发车了。

什么是 Nest

nest_logo

Nest是一个强大的Node web框架。它可以帮助您轻松地构建高效、可伸缩的应用程序。它使用现代JavaScript,用TypeScript构建,结合了OOP(面向对象编程)和FP(函数式编程)的最佳概念。

它不仅仅是另一个框架。你不需要等待一个大的社区,因为Nest是用非常棒的、流行的知名库——Expresssocket.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 SpringAngular 的启发。如果你用过 Java SpringAngular 就会学起来非常容易,我本人一直使用 Angular

Nest 核心概念

Nest的核心概念是提供一种体系结构,它帮助开发人员实现层的最大分离,并在应用程序中增加抽象。

架构概览

Nest采用了ES6ES7的特性(decorator, async/await)。如果想使用它们,需要用到BabelTypeScript进行转换成 es5

Nest默认使用的是TypeScript,也可以直接使用JavaScript,不过那样就没什么意义了。

如果你使用过Angular,你来看这篇文章会觉得非常熟悉的感觉,因为它们大部分写法类似。如果你没有用过也没有关系,我将带领你一起学习它们。

模块 Module

使用Nest,您可以很自然地将代码拆分为独立的和可重用的模块。Nest模块是一个带有@Module()装饰器的类。这个装饰器提供元数据,框架使用元数据来组织应用程序结构。

每个 Nest 应用都有一个根模块,通常命名为 AppModule。根模块提供了用来启动应用的引导机制。 一个应用通常会包含很多功能模块。

JavaScript 模块一样,@Module 也可以从其它 @Module 中导入功能,并允许导出它们自己的功能供其它 @Module 使用。 比如,要在你的应用中使用nest提供的mongoose操作功能,就需要导入MongooseModule

把你的代码组织成一些清晰的功能模块,可以帮助管理复杂应用的开发工作并实现可复用性设计。 另外,这项技术还能让你使用动态加载,MongooseModule就是使用这项技术。

@Module 装饰器接受一个对象,该对象的属性描述了模块:

属性 描述
providers Nest注入器实例化的服务,可以在这个模块之间共享。
controllers 存放创建的一组控制器。
imports 导入此模块中所需的提供程序的模块列表。
exports 导出这个模块可以其他模块享用providers里的服务。

@Module 为一个控制器集声明了编译的上下文环境,它专注于某个应用领域、某个工作流或一组紧密相关的能力。 @Module 可以将其控制器和一组相关代码(如服务)关联起来,形成功能单元。

怎么组织一个模块结构图

AppModule 根模块

  • CoreModule 核心模块(注册中间件,过滤器,管道,守卫,拦截器,装饰器等)
  • SharedModule 共享模块(注册服务,mongodb,redis等)
  • ConfigModule 配置模块(系统配置)
  • FeatureModule 特性模块(业务模块,如用户模块,产品模块等)

Nest中,模块默认是单例的,因此可以在多个模块之间共享任何提供者的同一个实例。共享模块毫不费力。

整体看起来比较干净清爽,这也是我在Angular项目中一直使用的模块划分。

如果你有更好建议,欢迎和我一起交流改进。

控制器 Controller

控制器负责处理客户端传入的请求参数并向客户端返回响应数据,说的通俗点就是路由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() 对应Expressreq,也可以简写@req
@Response() 对应Expressres,也可以简写@res
@Next() 对应Expressnext
@Session() 对应Expressreq.session
@Param(param?: string) 对应Expressreq.params
@Body(param?: string) 对应Expressreq.body
@Query(param?: string) 对应Expressreq.query
@Headers(param?: string) 对应Expressreq.headers

先说方法装饰器:

装饰器名称 描述
@Post() 对应ExpressPost方法
@Get() 对应ExpressGet方法
@Put() 对应ExpressPut方法
@Delete() 对应ExpressDelete方法
@All() 对应ExpressAll方法
@Patch() 对应ExpressPatch方法
@Options() 对应ExpressOptions方法
@Head() 对应ExpressHead方法
@Render() 对应Expressres.render方法
@Header() 对应Expressres.header方法
@HttpCode() 对应Expressres.status方法,可以配合HttpStatus枚举

以上基本都是控制器装饰器,一些常用的HTTP请求参数需要使用对应的方法装饰器和参数来配合使用。

关于返回响应数据,Nest也提供2种解决方案:

  1. 直接返回一个JavaScript对象或数组时,它将被自动解析为JSON。当我们返回一个字符串时,Nest只发送一个字符串,而不尝试解析它。默认情况下,响应的状态代码总是200,
    POST请求除外,它使用201。可以使用@HttpCode(HttpStatus.xxxx)装饰器可以很容易地改变这种行为。

  2. 我们可以使用库特定的响应对象,我们这里可以使用@res()修饰符在函数签名中注入该对象,
    res.status(HttpStatus.CREATED).send()或者res.status(HttpStatus.OK).json([])Expressres方法。

注意:禁止同时使用这两种方法,如果2个都使用,那么会出现这个路由不工作的情况。如果你在使用时候发现路由不响应,请检查有没有出现混用的情况,如果是正常情况下,推荐第一种方式返回。

控制器必须注册到该模块元数据的controllers里才能正常工作。

关于控制器异常处理,在后面过滤器讲解。

服务与依赖注入 Provider Dependency injection

服务是一个广义的概念,它包括应用所需的任何值、函数或特性。狭义的服务是一个明确定义了用途的类。它应该做一些具体的事,并做好。

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()来定义服务,还可以使用其他三种方式:valueclassfactory
这个和Angular一样,默认@Injectable()来定义服务就是class

使用value

const customObject = {};
@Module({
    controllers: [ UsersController ],
    components: [
        { provide: UsersService, useValue: customObject }
    ],
})

注意useValue可以是任何值,在这个模块中,Nest将把customObjectUsersService相关联,你还可以使用做测试替身(单元测试)。

使用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中。

中间件 Middleware

中间件是在路由处理程序之前调用的函数。中间件功能可以访问请求和响应对象,以及应用程序请求-响应周期中的下一个中间件功能。下一个中间件函数通常由一个名为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();
    };
  }
}

怎么使用,有两种方式:

  1. 中间件可以全局注册
async function bootstrap() {
  // 创建Nest.js实例
  const app = await NestFactory.create(AppModule, application, {
    bodyParser: true,
  });
  // 注册中间件
  app.use(LoggerMiddleware());
  // 监听3000端口
  await app.listen(3000);
}
bootstrap();
  1. 中间件可以模块里局部注册
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传递'*'表示作用全部路由

注意:他们注册地方不一样,影响的路由也不一样,全局注册影响全部路由,局部注册只是影响当前路由下的路由。

过滤器 Exception filter

异常过滤器层负责在整个应用程序中处理所有抛出的异常。当发现未处理的异常时,最终用户将收到适当的用户友好响应。

默认显示响应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状态错误:

  • BadRequestException 400
  • UnauthorizedException 401
  • ForbiddenException 403
  • NotFoundException 404
  • NotAcceptableException 406
  • RequestTimeoutException 408
  • ConflictException 409
  • GoneException 410
  • PayloadTooLargeException 413
  • UnsupportedMediaTypeException 415
  • UnprocessableEntityException 422
  • InternalServerErrorException 500
  • NotImplementedException 501
  • BadGatewayException 502
  • ServiceUnavailableException 503
  • GatewayTimeoutException 504

异常处理程序基础很好,但有时你可能想要完全控制异常层,例如,添加一些日志记录或使用一个不同的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,来定制自己的响应异常格式。

怎么使用,有四种方式:

  1. 直接@UseFilters()装饰器里面使用,作用当前这条路由的响应结果
@Post()
@UseFilters(HttpExceptionFilter | new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}
  1. 直接@UseFilters()装饰器里面使用,作用当前控制器路由所有的响应结果
@UseFilters(HttpExceptionFilter | new HttpExceptionFilter())
export class CatsController {}
  1. 在全局注册使用内置实例方法useGlobalFilters,作用整个项目。过滤器这种比较通用推荐全局注册。
async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

管道 Pipe

管道可以把你的请求参数根据特定条件验证类型、对象结构或映射数据。管道是一个纯函数,不应该从数据库中选择或调用任何服务操作。

定义一个简单管道:

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不光可以验证请求数据也做数据类型转换,这个可以看官网。

怎么使用,有四种方式

  1. 直接@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);
  }
}
  1. @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);
  }
}
  1. @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);
  }
}
  1. 在全局注册使用内置实例方法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是字符串数字的时候,用getputpatchdelete等请求,有id时候特别好用了。
还可以做分页处理,后面实战中用到,具体在讲解。

守卫 Guard

守卫可以做权限认证,如果你没有权限可以拒绝你访问这个路由,默认返回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很像。

怎么使用,有两种方式

  1. 直接@UseGuards()装饰器里面使用,作用当前控制器路由所有的请求参数
@Controller('cats')
@UseGuards(RolesGuard | new RolesGuard())
export class CatsController {}
  1. 在全局注册使用内置实例方法useGlobalGuards,作用整个项目。
const app = await NestFactory.create(ApplicationModule);
app.useGlobalGuards(new RolesGuard());

如果你不做权限管理相关的身份验证操作,基本用不上这个功能。不过还是很有用抽象功能。我们这个实战项目也会用到这个功能。

拦截器 Interceptor

拦截器是一个比较特殊强大功能,类似于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很像。

拦截器可以做什么:

  • 在方法执行之前/之后绑定额外的逻辑
  • 转换从函数返回的结果
  • 转换从函数抛出的异常
  • 扩展基本的函数行为
  • 完全覆盖一个函数取决于所选择的条件(例如缓存)

怎么使用,有三种方式

  1. 直接@UseInterceptors()装饰器里面使用,作用当前路由,还可以传参数,需要特殊处理,写成高阶函数,也可以使用依赖注入。
@Post('upload')
@UseInterceptors(FileFieldsInterceptor | FileFieldsInterceptor([
  { name: 'avatar', maxCount: 1 },
  { name: 'background', maxCount: 1 },
]))
uploadFile(@UploadedFiles() files) {
  console.log(files);
}
  1. 直接@UseInterceptors()装饰器里面使用,作用当前控制器路由,这个不能传参数,可以使用依赖注入
@UseInterceptors(LoggingInterceptor | new LoggingInterceptor())
export class CatsController {}
  1. 在全局注册使用内置实例方法useGlobalInterceptors,作用整个项目。
const app = await NestFactory.create(ApplicationModule);
app.useGlobalInterceptors(new LoggingInterceptor());

拦截器可以做很多功能,比如缓存处理,响应数据转换,异常捕获转换,响应超时跑错,打印请求响应日志。我们这个实战项目也会用到这个功能。

总结

模块是按业务逻辑划分基本单元,包含控制器和服务。控制器是处理请求和响应数据的部件,服务处理实际业务逻辑的部件。

中间件是路由处理Handler前的数据处理层,只能在模块或者全局注册,可以做日志处理中间件、用户认证中间件等处理,中间件和express的中间件一样,所以可以访问整个request、response的上下文,模块作用域可以依赖注入服务。全局注册只能是一个纯函数或者一个高阶函数。

管道是数据流处理,在中间件后路由处理前做数据处理,可以控制器中的类、方法、方法参数、全局注册使用,只能是一个纯函数。可以做数据验证,数据转换等数据处理。

守卫是决定请求是否可以到达对应的路由处理器,能够知道当前路由的执行上下文,可以控制器中的类、方法、全局注册使用,可以做角色守卫。

拦截器是进入控制器之前和之后处理相关逻辑,能够知道当前路由的执行上下文,可以控制器中的类、方法、全局注册使用,可以做日志、事务处理、异常处理、响应数据格式等。

过滤器是捕获错误信息,返回响应给客户端。可以控制器中的类、方法、全局注册使用,可以做自定义响应异常格式。

中间件、过滤器、管道、守卫、拦截器,这是几个比较容易混淆的东西。他们有个共同点都是和控制器挂钩的中间抽象处理层,但是他们的职责却不一样。

全局管道、守卫、过滤器和拦截器和任何模块松散耦合。他们不能依赖注入任何服务,因为他们不属于任何模块。
可以使用控制器作用域、方法作用域或辅助作用域仅由管道支持,其他除了中间件是模块作用域,都是控制器作用域和方法作用域。

重点:在示例给出了它们的写法,注意全局管道、守卫、过滤器和拦截器,只能new,全局中间件是纯函数,全局管道、守卫、过滤器和拦截器,中间件都不能依赖注入。中间件模块注册也不能用new,可以依赖注入。管道、守卫、过滤器和拦截器局部注册可以使用new和类名,除了管道以为其他都可以依赖注入。拦截器和守卫可以写成高阶方法来传参,达到定制目的。

管道、过滤器、拦截器守卫都有各自的具体职责。拦截器和守卫与模块结合在一起,而管道和过滤器则运行在模块区域之外。管道任务是根据特定条件验证类型、对象结构或映射数据。过滤器任务是捕获各种错误返回给客户端。管道不是从数据库中选择或调用任何服务的适当位置。另一方面来说,拦截器不应该验证对象模式或修饰数据。如果需要重写,则必须由数据库调用服务引起。守卫决定了哪些路由可以访问,它接管你的验证责任。

那你肯定最关心他们执行顺序是什么:

客户端请求 ---> 中间件 ---> 守卫 ---> 拦截器之前 ---> 管道 ---> 控制器处理并响应 ---> 拦截器之后 ---> 过滤器

我们来看2张图,

请求返回响应结果:

hdvo ug9_58 g 9o_n n 7o

请求返回响应异常:

nmzgsgsc5ynm_ghfsxzl5jh

Hello World

学习一门语言一门技术都是从 Hello World 开始,我们也是从零到Hello World开启学习Nest之旅

准备必备开发环境和工具

推荐nvm来管理nodejs版本,根据自己电脑下载对应版本吧。

  1. 准备环境: Nodejs v8+ (目前版本v10+, 必须8以上,对es2015支持率很高)
  2. 准备数据库:mongodb v3+ (目前版本v4+)
  3. 准备数据库:redis v3+ (目前版本v3+)
  4. 准备编辑器: vs code 最新版即可(本机 windows v1.26)
  5. vs code推荐插件:(其他插件自己随意)
    • Debugger for Chrome -- 调试
    • ejs -- ejs文件高亮
    • Beautify -- 代码格式化
    • DotENV -- .env文件高亮
    • Jest -- nest默认测试框架支持
    • TSLint -- ts语法检查
    • TypeScript Hero -- ts提示
    • vscode-icons -- icons
  6. 推荐几个好用的工具:
    • Postmen -- API测试神器
    • Robomongo -- mongodb图形化工具
    • Redis Desktop Manager -- Redis图形化工具
    • Cmder -- Windows命令行神器

Nest相关资源

  1. 官网:https://nestjs.com
  2. 文档:https://docs.nestjs.com
  3. 中文文档:https://docs.nestjs.cn
  4. Github:https://github.com/nestjs/nest
  5. 版本:目前稳定版v5.1.0
  6. CLI:https://github.com/nestjs/nest-cli

nest-cli

nest-cli 是一个 nest 项目脚手架。为我们提供一个初始化模块,可以让我们快速完成Hello World功能。

安装

npm i -g @nestjs/cli

常用命令:

new(简写:n) 构建新项目
$ nest new my-awesome-app
OR
$ nest n my-awesome-app
generate(简写:g) 生成文件
  • class (简写: cl) 类
  • controller (简写: co) 控制器
  • decorator (简写: d) 装饰器
  • exception (简写: e) 异常捕获
  • filter (简写: f) 过滤器
  • gateway (简写: ga) 网关
  • guard (简写: gu) 守卫
  • interceptor (简写: i) 拦截器
  • middleware (简写: mi) 中间件
  • module (简写: mo) 模块
  • pipe (简写: pi) 管道
  • provider (简写: pr) 供应商
  • service (简写: s) 服务

创建一个users服务文件

$ nest generate service users
OR
$ nest g s users

注意

  1. 必须在项目根目录下创建,(默认创建在src/)。(不能在当前文件夹里面创建,不然会自动生成xxx/src/xxx。吐槽:这个没有Angular-cli智能)
  2. 需要优先新建模块,不然创建的非模块以外的服务,控制器等就会自动注入更新到上级的模块里面
info(简写:i) 打印版本信息

打印当前系统,使用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内置功能

目前Nest.js支持 expressfastify, 对 fastify 不熟,本文选择express

核心模块

  • @nestjs/common 提供很多装饰器,log服务等
  • @nestjs/core 核心模块处理底层框架兼容
  • @nestjs/microservices 微服务支持
  • @nestjs/testing 测试套件
  • @nestjs/websockets websocket支持

可选模块

  • @nestjs/typeorm 还没玩过
  • @nestjs/graphql 还没玩过
  • @nestjs/cqrs 还没玩过
  • @nestjs/passport 身份验证(v5版支持,不向下兼容)
  • @nestjs/swagger swagger UI API
  • @nestjs/mongoose mongoose模块

注意: 其他中间件模块,只要支持express和都可以使用。

构建项目

  1. 创建项目nest-cnode
nest new nest-cnode

nest_cli

其中提交的你的description, 初始化版本version, 作者author, 以及一个package manager选择node_modules安装方式 npm 或者 yarn

  1. 项目启动
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测试
  1. 项目文件介绍
文件 说明
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打包只会编译tsdist 下,静态文件public和模板views不会移动,所以需要放到根目录下。

nest_start

我们打开浏览器,访问http://localhost:3000,您应该看到一个页面,上面显示Hello World文字。

j _ 2k2q0pqpjt 03o t16u

我们上篇已经到此为止,请看我们下篇项目实战--Nest-CNode

RxJS学习笔记

RxJS

Observable是Rxjs核心

Observable

Observable关联2个设计模式: 观察者模式(Observer Pattern)和迭代器模式(Iterator Pattern)。

观察者模式(Observer Pattern)和迭代器模式(Iterator Pattern)

观察者模式(Observer 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 Pattern)

一句话描述: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一个核心三个重点

  • Operators(操作符) 【核心】
  • Observer(观察者) 【重点】
  • Subject(服从者) 【重点】
  • Schedulers(调用者) 【重点】

Observable 同时可以处理同步与非同步的行为!

观察者Observer

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几个重要的观念:

  • Observable可以同时处理同步跟非同步行为

  • Observer是一个对象,这个对象具有三个方法,分别是next , error , complete

  • 订阅一个Observable就像是执行一个function

创建一个Observable

创建操作符(Operator)

  • create

  • of

  • from

  • fromEvent

  • fromEventPattern

  • fromPromise

  • never

  • empty

  • throw

  • interval

  • timer

create

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

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

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,会有相同的结果。

注意:offrom接受的参数有些不同,of接受数组形式的参数,但返回还是一个数组。from只能接收一个参数,如果多个参数就会走error。

fromEvent

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对象。

fromEventPattern

要用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

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

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

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

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

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使用异步调度程序提供时间概念,但可以将任何调度程序传递给它。

timer

类似于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来建立一个持续的行为一样

Subscription订阅

什么是订阅?订阅是一个表示一次性资源的对象,通常是一个可观察对象的执行。订阅对象有一个重要的方法: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()方法用来释放资源或者取消可观察对象的执行

类与数据结构

类与数据结构

什么是类?

类是一组相似对象的规范。

什么是对象?

对象是一组对封装的数据元素进行操作的功能。或者更确切地说,对象是一组对隐含数据元素进行操作的功能。

什么是隐含的数据元素?

对象的功能意味着某些数据元素的存在。 但是该数据无法直接访问,也无法在对象外部看到。

数据不是在对象内部吗?

它可能是,但没有规则说必须如此。 从用户的角度来看,一个对象不过是一组功能。 这些功能所依据的数据必须存在,但是用户不知道该数据的位置。

花开两朵,各表一枝。

什么是数据结构?

数据结构是一组内聚的数据元素。或者,换句话说,数据结构是由隐含功能操作的一组数据元素。

数据结构未指定对数据结构进行操作的功能,但是数据结构的存在意味着某些操作必须存在。

那么,现在关于这两个定义你注意到了什么?

它们彼此相反。确实,它们是彼此的补充。

  • 对象是对隐式数据元素进行操作的一组功能。
  • 数据结构是由隐含功能操作的一组数据元素。

所以对象不是数据结构,对象是数据结构的对立面。

DTO(数据传输对象)是对象吗?

DTO是数据结构。

数据库表是对象吗?

数据库包含数据结构,而不是对象

那么,ORM和对象关系映射器不是将数据库表映射到对象吗?

当然不是,数据库表和对象之间没有映射。 数据库表是数据结构,而不是对象。

什么是ORM,ORM会做什么?

对象关系映射(Object Relational Mapping,简称ORM)是通过使用描述对象和数据库之间映射的元数据,将面向对象语言程序中的对象自动持久化到关系数据库中。ORM会提取业务对象所操作的数据。 该数据包含在ORM加载的数据结构中。

例如数据库模式的设计与业务对象的设计,业务对象定义业务行为的结构。 数据库模式定义业务数据的结构。 这两个结构受到非常不同的力的约束, 业务数据的结构不一定是业务行为的最佳结构。

为什么会这样呢,可以这样想。数据库模式不只为一个应用程序进行调优;它必须服务于整个企业。因此,数据的结构是许多不同应用程序之间的折衷。

也许现在考虑每个单独的应用程序,每个应用程序的对象模型描述了这些应用程序的行为结构。每个应用程序都有不同的对象模型,并根据该应用程序的行为进行调优。

那么,由于数据库模式是所有各种应用程序的折衷,所以该模式不符合任何特定应用程序的对象模型。

对象和数据结构受到非常不同的约束,他们很少排成一起。人们把这种情况称为对象/关系阻抗不匹配。

ORM可以解决了阻抗不匹配的问题,因为对象和数据结构是互补的,而不是同构的,所以没有阻抗失配。

那又怎样?

它们是对立的,不是相似的实体。

对立?

是的,以一种非常有趣的方式。对象和数据结构意味着完全相反的控制结构。

考虑一组对象类,它们都符合一个公共接口。例如,想象一下表示二维形状的类,它们都具有计算形状的面积和周长的函数。

为什么每个软件示例都涉及形状呢?

我们只考虑两种不同的类型:正方形和圆形。 应该清楚的是,这两类的面积和周长函数在不同的隐式数据结构上运行。 还应该清楚的是,调用这些操作的方式是通过动态多态性进行的。

对象知道其方法的实现,现在让我们将这些对象转换为数据结构。

在我们的例子中,这只是两个不同的数据结构。 一个用于Square,另一个用于CircleCircle数据结构具有中心点和数据元素的半径。 它还有一个类型代码,可将其标识为圆形。

你是说像枚举?

当然,Square数据结构具有左上角和边的长度。 它还具有类型鉴别符–枚举。

带有类型代码的两个数据结构,现在考虑面积函数。 它要有一个switch语句,不是吗?

当然,对于两种不同的情况。 一个用于Square,另一个用于Circle。 周长功能将需要类似的switch语句

现在想想这两个场景的结构。在对象场景中,area函数的两个实现彼此独立,并且属于(某种意义上的)类型。正方形面积函数属于正方形,圆形面积函数属于圆形。

那么,处理方法。 在数据结构方案中,area函数的两个实现在同一函数中在一起,他们不会“属于”该类型。

如果要将Triangle类型添加到对象方案中,必须更改哪些代码?

无需更改代码。 您只需创建新的Triangle类。

所以当你添加一个新类型时,几乎没有更改。现在假设您想要添加一个新函数,比如center函数。

那么,我们必须将其添加到所有三种类型(圆形,正方形和三角形)中。因此添加新功能很困难,我们必须更改每个类。

但是数据结构却有所不同,为了添加Triangle,必须更改每个函数以将三角形案例添加到switch语句。

所以,添加新类型非常困难,我们必须更改每个功能。

但是当我们添加新的center函数时,什么也不需要改变。

添加新函数很容易吗?

哈哈,恰恰相反。

那当然是。 我们来回顾一下:

  • 向一组类中添加新功能很困难,必须更改每个类。
  • 向一组数据结构添加新函数很容易,只需添加该函数,其他内容都不变。
  • 向一组类添加新类型很容易,只需添加新类即可。
  • 向一组数据结构添加新类型很困难,必须更改每个函数。

它们以一种有趣的方式对立。如果你知道你要给一组类型添加新的函数,想要你使用数据结构。但是如果你知道你将添加新的类型,那么希望你使用类。

但是,我们还有最后一件事要考虑。 数据结构和类的对立还有另一种方式。 它与依赖关系有关。

依赖关系?

源代码依赖关系的方向。

有什么区别吗?

考虑数据结构的情况,每个函数都有一个switch语句,该语句根据所区分的并集内的类型代码选择适当的实现。

那又怎样呢?

考虑对area函数的调用。调用者依赖于area函数,而area函数依赖于每个特定的实现。

所说的“依赖”是什么意思?

想象一下,区域的每个实现都被写入了自己的功能中。 所以有circleAreasquareArea以及triangleArea

所以switch语句仅调用那些函数。

想象一下,这些功能在不同的源文件中。然后,带有switch语句的源文件必须导入、使用或包含所有这些源文件。

这是一个源代码依赖项。一个源文件依赖于另一个源文件。这种依赖的方向是什么?

带有switch语句的源文件取决于包含所有实现的源文件。

那么area函数的调用者呢?

area函数的调用者依赖于带有switch语句的源文件,它依赖于所有的实现。

从调用者到实现,所有源文件依赖性都指向调用的方向。 因此,如果你对其中的一种实现进行了微小的更改……

你该目标我的意思, 对任何一种实现的更改都会导致重新编译带有switch语句的源文件,这将导致每个调用该switch语句的人(在本例中为area函数)都被重新编译。

至少对于依赖源文件日期来确定应该编译哪些模块的语言系统来说是这样的。

这需要大量的重新编译,还有大量的重新部署。

但是这在类的情况下是相反的吗?

是的,因为area函数的调用者依赖于接口,而实现函数也依赖于接口。

Square类的源文件导入、使用或包含Shape接口的源文件。

实现的源文件指向调用的相反方向。 他们从实现指向调用者。 至少对于静态类型的语言是这样。 对于动态类型的语言,区域函数的调用者完全不依赖任何内容。 链接在运行时确定。

因此,如果你对其中一个实现做了改变...

只有更改后的文件需要重新编译或重新部署,这是因为源文件之间的依赖关系指向调用的方向。

这种方式我们称为依赖倒置。

最后让我们总结一下, 类和数据结构在至少三种不同的方式上是相反的:

  • 类使功能可见,同时保持隐含数据。 数据结构使数据可见,同时保持隐含功能。
  • 类使得添加类型很容易,但是添加函数很困难。数据结构使得添加函数很容易,但是添加类型却很困难。
  • 数据结构将调用者暴露给重新编译和重新部署。类将调用者与重新编译和重新部署隔离开来。

这些都是每个优秀的软件设计人员和架构师都需要牢记的问题。

第一次使用Typeorm的挖坑总结

最近一个公司官网需要做后台管理,自告奋勇伸出手接下这活。我本来计划技术栈是 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 对应叫 EntityEntity 里面字段列和数据库里面的是一一对应的。

换句话来说,在数据库里面建表,要么手动建,设计表结构,另外一种就是 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,大写的懵逼。

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 (默认)
  • uuid(Typeorm 帮我们自动添加)
  • rowid
  • identity

基本所有教程文章都是用默认的 increment

然后问题就出现了,使用 increment 在插入数据会出现错误:

Typeorm error 'Cannot update entity because entity id is not set in the entity.'

这个问题困扰我很久,搜索这个问题,也没有得到最终的解答

一开始找到的答案是 .save(entity, {reload: false})

满心欢喜插入了数据库,发现数据库里面的数据 id0

一开始不懂为什么,按道理我设置自增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 语句:

  1. 显示所有数据表
show databases;
  1. 切换指定数据表
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 一个默认值 DEFAULTMysql 就会给它默认一个 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 最终就解决报错问题。

写在最后

无论使用什么技术都没有一帆风顺的,总是有无尽的坑需要填,各方面原因凑在一起就引起未知的坑,我们需要掌握排坑技巧,不断提升解决问题的能力。

今天就到这里吧,伙计们,玩得开心,祝你好运。

学习Angular从入门到放弃

学习Angular从入门到放弃

从刚接触Angular到现在,自己也是一路摸滚打爬过来的,虽不是什么高手,单对于如何学习Angular,还是有一些个人的见解,拿出来与大家共勉。

学习Angular从入门到放弃,大致有6个过程或者说是6个层次:

第一步 了解

对于刚接触Angular的新手来说,第一步无非是打基础,也是最重要的一步,决定你要不要继续。(Angular学习门槛略高,不是有意吓你的)

在学习之前你要弄明白以下事情:

  • TypeScript的特性和语法。假如你对 TypeScript 还不熟悉的话,推荐以下途径快速上手:
  • Angular是什么?Angular与AngularJs的区别是什么?
  • Angular版本差异?如何选择合适的版本?
  • Angular适用场景?Angular不适用的场景?
  • Angular的基本语法。
    • Angular的特性:
      • 双向数据绑定
      • 跨平台
      • 开发渐进式应用
      • 统一平台SSR支持
      • 代码拆分 按需加载
      • 提高生产率 Angular CLI
      • 各种IDE支持 推荐VS Code
      • 测试支持
      • 拥抱W3C标准(动画、组件,表单验证等)
      • 等等
    • Angular的八个主要构造块:
      • 模块
      • 组件
      • 模板
      • 元数据
      • 数据绑定
      • 指令
      • 服务
      • 依赖注入
  • RxJs是什么?RxJs的基本使用,不一样编程方式
  • zone.js是什么?给Angular带来什么
  • flex-layout是什么?你在使用flex吗
  • ngRx又是个什么鬼,为什么没有听过
  • 等等

其实上面的内容,大部分Angular的文档都有介绍。基本了解Angular后,我们可以参考文档的快速上手,写一个Hello world程序。

PS:

  1. Angular重度依赖和推荐使用TypeScript,到今天为止,前端各大框架,后端NodeJs框架都提供了对TypeScript支持,剩下就自己想想吧。
  2. Rx也是Angular重度依赖和推荐使用,Rx有多个语言版本,学习它核心**和概念,可以无缝切换其他语言。

第二步 入门

你也许会想,前端和UI界面打交道最多,需要有一整套完善的UI组件库,那Angular有吗?别担心,Github目前有很多UI组件库,官网的资源集合, 其中我所熟知UI组件有:

另外Github以ngx-开头或者(ng2,ng4等)的都是Angular相关的资源模块,可以挑选自己喜欢的,进行使用吧。

繁荣的生态,才是一个框架的活力所在。当你对 Angular 已经了解的差不多了,并且按耐不住跃跃欲试了。这个时候,我们不妨用 Angular 的第三方UI组件和其他依赖模块做些好玩的事情:

  • 写一个TodoList (我学新的框架第一件事,就是写它,它是麻雀虽小五脏俱全,看起来简单,涉及很多知识点,也可以快速熟悉一个框架的简单应用)
  • 搭建一个个人博客网站 (使用表单验证系统完成登陆注册,搭配路由模块,Http模块等,做第一个的完整的SPA应用程序)
  • 搭建一个完整的后台管理系统 (后台管理系统涉及内容比较多,Ant Design Pro 是个不错高仿对象,它是目前预览版使用react写的,可以配合阿里的 NG-ZORRO 使用 (虽然功能没有react版强大,但是够用了))
  • 配合Universal,使用Angular SSR改写个人博客网站
  • 调用一些网站的API做PWA开发
  • 等等

Angular并不是只能做以上的事情,几乎其他框架能做的事情Angular都能做,而且有些情况下能做的更好。

第三步 掌握

当前,学习Js框架不能只会简单的用,这个时候,我们需要回头深入了解下Angular核心API用法。说白了,就是好好看 Angular 官网的 API文档。看文档是必备技能。

第四步 熟练

  • 多实践。不管是用核心模块还是外部模块,尝试用Angular解决问题替换以前用其他框架写过的代码。
  • 读源码。这里说的读源码不是去读Angular核心代码。这个时候,挑一些UI框架的源码来读,选择一些特定的功能,这种模块代码通常都不多,职责分明,你也可以通过学习源码,更好组织你代码结构,涨不少姿势。比如:
    • CDK(Angular Material2 里面封装的一系列好用的功能,NG-ZORRO也依赖它,你也可以用它造属于自己的轮子)
    • 对比我举例的几种UI框架某一个功能的实现,去对比他们实现的差别。(可以看到一些API实践应用)
    • 等等

第五步 玩转

坚持第四步。 在使用Angular时,发现没有合适的模块选择或者选择的模块功能不尽人意,这个时候你可以尝试创建一个模块或者修改你认为不爽的模块,并且开源自己模块或者给该模块的 Github 上提 PR

第六步 放弃

  • 多实践。成功三步:坚持,不要脸,坚持不要脸。写代码也是坚持多实践。
  • 读Angular核心代码,设计模式,面向对象,数据结构与算法,框架设计等在Angular的实际应用。提高必备
  • 多关注下 Github 上牛人

写在最后

学习其他技术,前端框架也是类似步骤,了解 -> 入门 -> 掌握 -> 熟练 -> 玩转 -> 未知 ?放弃 : 精通

是放弃还是精通,只是取决你自己有多坚持,成功没有捷径。

少年加油!

使用angular schematics快速生成代码

使用angular schematics快速生成代码

什么是Schematics?

Schematics是改变现存文件系统的生成器。有了Schematics我们可以:

  • 创建文件
  • 重构现存文件,或者
  • 到处移动文件

Schematics能做什么?

总体上,Schematics可以:

  • 为Angular工程添加库
  • 升级Angular工程中的库
  • 生成代码

在你自己的工程或者在你所在的组织中使用Schematics是具有无限可能的。下面一些例子展现了你或者你的组织或如何从创建一个schematics collection中获益:

  • 在应用中生成通用UI模板
  • 使用预先定义的模板或布局生成组织指定的组件
  • 强制使用组织内架构

Schematics现在是Angular生态圈的一部分,不仅限于Angular工程,你可以生成想要模板。

CLI集成?

是的,schematics与Angular CLI紧密集成。你可以在下列的CLI命令中使用schematics:

  • ng add
  • ng generate
  • ng update

什么是Collection?

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 为我们后继的工作打好基础。

image

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 => 定义该 collection 架构的 url 地址.
  • schematics => 这是你的 schematics 定义.
    • demo-schema => 以后使用这个 schematics 的 cli 名称.
    • aliases => 别名.
    • factory => 定义代码.
    • description => 简单的说明.
    • schema => 你的 schema 的设置. 这个文件的内容应该如下所示。我们在其中定义了多个自定义的选项,在使用这个 Schematics 的时候,可以通过这些选项来设置生成的内容。

关于怎么创建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;
  };
}
  • 我们export了会作为"entry function"调用的demoSchema函数
  • 函数接受一个_options参数,它是命令行参数的键值对对象,和我们定义的schema.json有关
  • 这个函数是一个高阶函数,接受或者返回一个函数引用。此处,这个函数返回一个接受TreeSchmaticsContext对象的函数

什么是Tree?

Tree是变化的待命区域,包含源文件系统和一系列应用到其上面的变化。

我们能使用tree完成这些事情:

  • read(path: string): Buffer | null: 读取指定的路径
  • exists(path: string): boolean: 确定路径是否存在
  • create(path: string, content: Buffer | string): void: 在指定路径使用指定内容创建新文件
  • beginUpdate(path: string): UpdateRecorder: 为在指定路径的文件返回一个新的UpdateRecorder实例
  • commitUpdate(record: UpdateRecorder): void: 提交UpdateRecorder中的动作,简单理解就是更新指定文件内容(实战中会用到)

什么是Rule?

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文件以后自动构建。

如何使用在angular项目中

使用短连安装:

npm link demo-schema

使用ng generate运行:

ng generate demo-schema:demo-schema
  • 第一个demo-schema => 是package.jsonname
  • 第二个demo-schema => 是我们运行的schematics

实战项目

最新在公司项目需要把之前的项目的通用组件提取出来,做成一个单独的组件库并且带上demo。创建了一个新的工程。组件库使用ng-packagr,如果直接使用它去打包,会全部打包到一起,这样就会有问题,加载的时候特别大。ng-packagr提供的二次入口,可以解决这个问题,但是又会有新问题,必须要要和src同级目录。如果使用angular-cli默认去生成都会自动添加到src/lib里面,虽然可以修改path,但是问题是一个组件模块里面有一些特定的文件:

  • module
  • component(包含css,html,ts)
  • service
  • directive
  • class
  • types

大概就这些,如果这些用angular-cli,去生成,需要6次才能完成,也可以写一个shell,一次性完成。但是发现太麻烦,有些东西无法控制,如果需要定制,那就需要自己来写schematics

这里有2部分不一样的实战内容,一种是根据固定内容直接生成模板,一种是生成模板以后修改已有关联的文件。

为什么会有这2个,第一个是为了生成组件模块,第二个是为了生成演示组件模块。

创建一个schematics

schematics blank --name=tools

这时候也会创建src/tools,我们把它改成ui,把collection.json文件里面也修改了。

实战1

创建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
 }
}
  1. 先处理路径:
    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;
  1. 获取模板源:
    const templateSource = apply(url('./files'), [
      applyTemplates({
        ...strings,
        ..._options,
      }),
      move(_options.path),
    ]);

我们模块在files文件下,applyTemplates把我们的配置转换成模板可以使用的变量并生成文件,move把生成好的文件移动到目标路径下。

  1. 返回rule
    const rule = chain([branchAndMerge(chain([mergeWith(templateSource)]))]);

    return rule(tree, _context);

我们前面也也介绍了,入口函数总是要返回一个rulechain验证我们的配置规则。

整体看起来比较简单,这样就已经完成的整个的生成命令,下面就是关键模块定义:

  1. 模板放在files文件下
  2. .template后缀结尾
  3. 要替换变量使用__变量__方式
  4. 需要处理变量要以__变量@方法名__

举例:

__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>

会大量使用变量和少量的流程判断,变量一般都会使用内置的模板方法来配合使用:

内置的模板变量方法:

  • classify:连字符写法转换为大驼峰式的写法
  • dasherize:驼峰式写法转换为连字符的写法

更多模板变量: 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

image

  • index.ts 二次入口打包入口
  • package.json 二次入口打包必备配置
  • README.md 组件说明

基本已经完成我们想要的,还有几个文件生成是可选的,我们需要配置处理一下:

    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 

image

注意:不需要写=true

npm link tools
 ng g tools:ui --name="test" --dry-run

image

 ng g tools:ui --name="test"

image

image

image

基本已经完成了,下面介绍一个进阶实战。

实战2

我们想要创建多个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组件的文件,还需要去手动添加路由,这样就比较麻烦,现在就需要自动完成这个功能。我们一起来实现它吧。

这里我们就用上beginUpdatecommitUpdate2个方法来实现

先介绍一下需要实现的功能:

我有三个文件夹:

guides 快速指南
experimental  实验功能
components 组件库
  • 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也有自带添加路由依赖方法,但是只能添加一级路由,不能添加子路由,我们这个需求就是需要添加子路由。

  1. 验证路径
    if (!_options.path) {
      throw new Error('path不能为空');
    }
    // 处理路径
    const parsedPath = parseName(_options.path, _options.name);
    _options.name = parsedPath.name;
    _options.path = parsedPath.path;
  1. 生成模板
    const templateSource = apply(url('./files'), [
      applyTemplates({
        ...strings,
        ..._options,
      }),
      move(parsedPath.path),
    ]);

模板这块就不在说明了,和实战1是一样处理的,去files文件夹里面创建对应的模板即可。

  1. 返回rule
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个依赖方法:

  • readIntoSourceFile: 读取ts文件,使用ts.createSourceFile为我们解析文件源 AST
  • addRoutesToModule:获取变更信息重要方法
  • buildRoute:组装新的路由信息

说实话,我对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] } ] }

这里给大家科普几个数据就好了:

  • pos:起始位置
  • end: 结束位置
  • parent:父节点
  • initializer:自己
  • elements:子节点

主要看elements变化,空里面只有2个[pos, end],如果不是空里面就会有子节点,你现在是不是可以干点其他事情了。(ps:如果想批量更新之前内容是不是想想也容易了)

注意:如果你要调试,一定要用npm link安装,根目录使用ng g tools:demo --name=test

先试试默认的路由添加:

ng g tools:demo --name=test

image

image

ng g tools:demo --name=test  --module=experimental

image

image

大功告成,欢迎交流,发现更多好玩的东西。

愉快使用Angular-cli构建项目

前言

使用Angular CLI开发Angular应用程序是一个非常愉快的体验!Angular团队为我们提供了惊人的CLI工具,支持大部分开箱即用的构建项目所需要的功能。

标准化的项目结构,具有全面的测试功能(单元测试和e2e测试),代码脚手架,支持使用环境特定配置的生产级构建。这是一个强大功能的脚手架,为我们每个新项目节省了大量的时间。这里要感谢Angular团队

虽然Angular CLI从一开始就非常好用,也有很多吐槽点(吐槽在最后),但是我们可以利用一些潜在的配置改进和最佳实践来使我们的项目更加完善!

我们需要了解什么?

  1. 如何规划我们的模块
  2. 如何使用应用程序和环境文件夹的别名来支持快捷导入
  3. 如何使用Sass和Angular Material(或者NG-ZORRO,或者自己打造UI组件库)
  4. 如何建立良好的生产构建
  5. 如何不使用Phantom JS,而使用Headless Chrome代替测试
  6. 如何通过自动生成的更新日志和正确的版本号来管理我们的项目
  7. 如何通过CLI配置代理API请求

安装和使用(需要科学上网,不然以下安装会有各种问题)

安装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

1. 规划模块--基于核心模块,共享模块和特性模块与懒加载的模块结构的最佳实践

我们使用Angular CLI生成了新的项目,那现在呢?我们应该继续生成我们的服务和组件到一些随机的文件夹。接下来该如何构建我们的项目?

一个好的设计方式是把我们的应用程序分成至少三个不同的模块 - 核心模块,共享模块和特性模块(尽管我们可能需要多个特性模块)【特性模块就是我们常说每个功能页面】,如果我们不想使用第三方UI组件库,那我们还需要一个UI模块来修饰。

CoreModule【核心模块】

在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');
    }
  }
}

SharedModule 【共享模块】

在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 { }

CoreModule和SharedModule区别

Module属性 CoreModule SharedModule
imports 必须 必须
providers 必须 禁止
declarations 禁止 必须
exports 禁止 必须

总结:CoreModule 只有导入没有导出,SharedModule有导入导出,却没有服务依赖注入管理,这个需要在 CoreModule 里面操作。

使用Angular CLI构建项目结构

我们可以在创建新项目后立即生成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 所在文件位置,只需要关心我对于的导出依赖名称即可。

同样,我们可以为共享模块做同样的事情。

FeatureModule 【特性模块】

在Angular官网模块部分也专门指出用特性模块,特性模块是带有@NgModule装饰器及其元数据的类,就像根模块一样。 特性模块的元数据和根模块的元数据的属性是一样的。根模块和特性模块还共享着相同的执行环境。 它们共享着同一个依赖注入器,这意味着某个模块中定义的服务在所有模块中也都能用。
它们在技术上有两个显著的不同点:

  • 我们引导根模块来启动应用,但导入特性模块来扩展应用。
  • 特性模块可以对其它模块暴露或隐藏自己的实现。
    特性模块用来提供了内聚的功能集合。 聚焦于应用的某个业务领域、用户工作流、某个基础设施(表单、HTTP、路由),或一组相关的工具集合。

将为我们的应用程序的每个独立功能创建多个功能模块。功能模块应该只从CoreModule导入服务。
如果功能模块A需要从功能模块B导入服务,则考虑将该服务移入CoreModule。

在某些情况下,需要仅由某些功能共享的服务,将它们转移到核心并不合理。在这种情况下,我们可以创建特殊的共享功能模块,不依赖于CoreModule提供的服务和SharedModule提供的组件的任何其他功能的功能。

这将保持我们的代码清洁,易于维护和扩展的新功能。这也减少了重构所需的工作量。如果正确执行,我们将确信更改一个功能不会影响或破坏我们的应用程序的其余部分。

LazyLoading 【懒加载模块】

懒加载需要配合路由完成. 可以看伪代码
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的应用架构图

image

2. 为应用程序和环境文件夹设置别名

别名我们的应用程序和环境文件夹将使我们能够实施干净的入口,这将在整个应用程序中保持一致。

考虑假设的,但通常的情况。我们正在研究一个功能A中位于三个文件夹深处的组件,并且我们要从位于两个文件夹深处的核心导入服务。这将导致导入语句看起来像import {SomeService} from '../../../core/user/user-settings/user-settings.service'这样。

是不是很不爽。。。

更不爽的是,任何时候我们想改变这两个文件中的任何一个的位置,我们的导入语句就会中断,需要重新去改引入url地址,有些编辑器可以帮我们重构这个问题,例如:Webstorm。

曾经看过vue-cli,在它里面构建里面路径是‘@/xxx’ 如果没有记错,(ps:因为快一年没有使用了)。应该是这样的,@代表src,这个是webpack里面设置的别名功能。

在Angular-cli去改Webpack配置是一个很麻烦的事情,我们可以修改tsconfig.json配置。
为了能够使用别名,我们必须添加baseUrlpaths属性到我们的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别名访问共享特征。

3. 使用Sass 和第三方UI组件

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组件库。

4. 如何建立良好的生产构建

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 将所有第三方依赖(库)代码提取到单独的块中

另外检查其他可用的配置标志官方文档,这可能在您的个人项目中有用。

5. 使用Headless Chrome代替Phantom JS

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",
  },

6. 使用标准的提交消息

可以看这里我的这篇文章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 pushnpm publish来自动完成整个过程。
在这种情况下,我们需要改进脚本为下面例子
package.json

"scripts": {
    "release": "standard-version && git push --follow-tags origin master && npm publish"
  },

注意:我们使用 && 并将依赖于平台的命令链在基于Unix的系统上(因此也在Windows上使用Cygwin、Gitbash或Linux的新Win10子系统)。

7. 如何通过CLI配置代理API请求

目前开发都是前后分离式的开发,前端使用CLI启动服务端口一般都是4200,后台也有API端的,nodejs一般常用都是3000,如果直接去请求3000端口的数据就好出现跨域请求。

跨域请求(同源策略)

简单理解跨域:不同的协议(http|https),不同的IP,不同的端口

本地开发ip都一样,不一样的是端口号,这样跨域浏览器会有
p 3 58h7_4app z6jc6 _5r
这样的报错信息,那么我们需要用代理,代理方式有很多。这里不介绍其他就说CLI里面如何配置的。

CLI如何配置

  1. 在根目录下新建一个proxy.conf.json,这个文件名字不需要固定(我为了适应不同场景,还是做不同代理配置,proxy-dev.conf.json,proxy-test.conf.json,,因为后台很多,有时候需要直接去联调他们电脑服务)
  2. 代理配置信息

假设:跨域请求地址是 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')
  1. package.json文件里的scripts也需要配置
代理之前的:
"serve": "ng serve --open"
代理之后的:
"serve": "ng serve --proxy-config proxy.conf.json --open"
  1. 在启动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",

就可以一个命令来控制。

其他相关技巧(待续,不断完善中...)

推荐vscode插件

  1. Visual Studio Code Commitizen Support

git commit message书写规范提示模板

ig 2_3n 5s3 nvr jkc 9p

  1. Angular5 Snippets - TypeScript, Html, Angular Material, ngRx, RxJS & Flex Layout

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请求数据

如果只想做简单演示,模拟API请求,熟悉HTTP请求,又不想起一个本地后台服务器或者模拟mack服务器,那怎么办?

  1. 我们需要修改angular-cli脚手架核心配置

.angular-cli.json

"assets": [
            "api",
            "assets",
            "favicon.ico"
        ],
  1. src文件夹下创建一个api文件夹,你需要本地数据都可以丢里面。

为什么是src,因为脚手架里面配置有一句"root": "src",表示根路径。

  1. 在服务里面写HTTP请求
    在api文件里创建了一个test.json,写点假数据吧,那我们怎么去请求了?
constructor(private http: HttpClient) { }

  getTest() {
    this.http.get('/api/test.json').subscribe((data) => {
      console.log(data);
    });
  }

注意:/api就是前面创建的src/api文件夹,因为src是根目录,所以我们只需要/api即可

设计模式分类(创建型模式、结构型模式、行为模式)

1.创建型模式

创建型模式,就是创建对象的模式,抽象了实例化的过程。它帮助一个系统独立于如何创建、组合和表示它的那些对象。关注的是对象的创建,创建型模式将创建对象的过程进行了抽象,也可以理解为将创建对象的过程进行了封装,作为客户程序仅仅需要去使用对象,而不再关系创建对象过程中的逻辑。

社会化的分工越来越细,自然在软件设计方面也是如此,因此对象的创建和对象的使用分开也就成为了必然趋势。因为对象的创建会消耗掉系统的很多资源,所以单独对对象的创建进行研究,从而能够高效地创建对象就是创建型模式要探讨的问题。这里有6个具体的创建型模式可供研究,它们分别是:

  • 简单工厂模式(Simple Factory)
  • 工厂方法模式(Factory Method)
  • 抽象工厂模式(Abstract Factory)
  • 创建者模式(Builder)
  • 原型模式(Prototype)
  • 单例模式(Singleton)

简单工厂模式不是GoF总结出来的23种设计模式之一

2.结构型模式

结构型模式是为解决怎样组装现有的类,设计它们的交互方式,从而达到实现一定的功能目的。结构型模式包容了对很多问题的解决。例如:扩展性(外观、组成、代理、装饰)、封装(适配器、桥接)。

在解决了对象的创建问题之后,对象的组成以及对象之间的依赖关系就成了开发人员关注的焦点,因为如何设计对象的结构、继承和依赖关系会影响到后续程序的维护性、代码的健壮性、耦合性等。对象结构的设计很容易体现出设计人员水平的高低,这里有7个具体的结构型模式可供研究,它们分别是:

  • 外观模式/门面模式(Facade门面模式)
  • 适配器模式(Adapter)
  • 代理模式(Proxy)
  • 装饰模式(Decorator)
  • 桥梁模式/桥接模式(Bridge)
  • 组合模式(Composite)
  • 享元模式(Flyweight)

3.行为型模式

行为型模式涉及到算法和对象间职责的分配,行为模式描述了对象和类的模式,以及它们之间的通信模式,行为模式刻划了在程序运行时难以跟踪的复杂的控制流可分为行为类模式和行为对象模式。1. 行为类模式使用继承机制在类间分派行为。2. 行为对象模式使用对象聚合来分配行为。一些行为对象模式描述了一组对等的对象怎样相互协作以完成其中任何一个对象都无法单独完成的任务。

在对象的结构和对象的创建问题都解决了之后,就剩下对象的行为问题了,如果对象的行为设计的好,那么对象的行为就会更清晰,它们之间的协作效率就会提高,这里有11个具体的行为型模式可供研究,它们分别是:

  • 模板方法模式(Template Method)
  • 观察者模式(Observer)
  • 状态模式(State)
  • 策略模式(Strategy)
  • 职责链模式(Chain of Responsibility)
  • 命令模式(Command)
  • 访问者模式(Visitor)
  • 调停者模式(Mediator)
  • 备忘录模式(Memento)
  • 迭代器模式(Iterator)
  • 解释器模式(Interpreter)

三者之间的区别和联系

创建型模式提供生存环境,结构型模式提供生存理由,行为型模式提供如何生存。

  1. 创建型模式为其他两种模式使用提供了环境。
  2. 结构型模式侧重于接口的使用,它做的一切工作都是对象或是类之间的交互,提供一个门。
  3. 行为型模式顾名思义,侧重于具体行为,所以概念中才会出现职责分配和算法通信等内容。

从零构建一个 Monorepo 项目工程

image

去年圣诞节格外冷,第二天要上班,早早洗洗睡了。半夜 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 的浏览器)。

发现一个问题:

  1. 有些域名有 pcm 两个文件夹,不说相信大家都应该懂了,都是做前端的。
  2. 包含重复图片,比如:logo,icon
  3. 包含重复代码,比如:用了一个第三方的移动端安装 app 的服务 js SDK,基本用的域名下面都有这个代码。还有就是处理 rem 的,也是每个域名文件都有这个代码。css 就更不要举例了,html 重复一样。
  4. 重复协议 html 文件

问题外的思考:

  1. 每次谷歌修改代码、推广 SEO 相关等非核心代码修改,都需要拉代码,修改代码,提交发布。这繁琐的操作需要开发人员来做吗?
  2. 推广里面下载链接因为域名问题导致用户下载全是旧版本 app,这些操作也需要运维和开发人同时来做?

结合问题外的思考,我和运维同事捣鼓一个后台管理,使用 Nestjs 搭建,终于体验一把全干工程师。发布部署都是运维搞定,运维把他们的域名相关东西也放到这个管理系统中,这样就解决 2 问题。

通过定时任务去每天检查域名是否过期,定时去检查下载域名是否正常访问。对于异常域名通过钉钉发生给运维去处理。

一直在思考问题 1 怎么解决,那就做一个推广页管理,推广页和域名直接强制关联,自动分配下载地址,开发上传页面模板(接了下的重点),推广管理推广相关的内容。修改对应参数(这些参数都是文字变量,对于图片相关处理,替换图片这种需求不是很常见,开发改代码更快),直接点击发布即可。

每次下载链接自动被替换,都会通过钉钉机器人发给测试去确定。

通过这个小项目也学到一些 nodejs 平常用的比较少的功能,及数据库设计等相关后台知识。

关于页面模板,这个也让我思考很久,最终决定使用 EJS,语法简单,通用性广。lodash.template 也是使用类似语法。前端开发需要上传对于的文件模板,比如 pcm 两个文件夹,需要上传 2 个 zip 包,只有一个只需要上传一个。

在服务端使用 node-stream-zip 解压 zip 包,使用 ejs.render 把模板和推广相关数据编译成 html。 运维又要求给他生成一些运维相关配置,比如 nginx 配置和 httpsssl 证书,最后执行运维提供 shell 脚本,做到一键部署。这些操作过程中,我又把每步操作实时返回给前端页面,有点类似 Jenkins 发布那个界面。

正所谓万事俱备,只欠东方,其他准备都已经完成,现在只缺模板 zip 包。

Monorepo

在构建这个模板项目时,前面的思考已经让我有了一些想法,使用 Monorepo 来构建项目。长时间使用 Angular 开发,对于 Monorepo 并不陌生,并且经常使用这个特性完成开发工作。

关于 Monorepo 这里有篇博客介绍 what-is-monorepo

在前端有个比较有名 JavaScriptMonorepo 包管理器 Lerna,一些耳熟能详开源项目都是使用它,例如: BabelJest 等开源项目。

Lerna 是一个快速的现代构建系统,用于管理和发布来自同一存储库的多个 JavaScript/Typescript 软件包。

如果想要构建 Monorepo 项目,使用 Lerna 肯定是不够的,那么接下来我们就来从零开始构建一个 Monorepo 项目 CLI 工具。

Monorepo CLi

解析命令参数

Node.js 为我们提供了 process.argv 来读取命令行参数,作为一个工具,我们不应该手动解析这些参数,有 2 个包 commandercac 推荐,这里我使用 cac

其他相关工具:

  • inquirer:交互命令输入插件
  • chalk: 美化命令行的模块
  • ora:优雅的终端加载提示器
  • shelljs:Node.js 执行 Unix shell 命令
  • fs-extra:Node.js 的 fs 增强版
  • lodash:Node.js 的工具库

还有一些其他好用工具,这里暂时不一一列举了,后面介绍时用上在科普。

创建一个 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

  • serve: 开发运行
  • build: 打包编译
  • generate: 生成项目
  • release: 发布上线
// 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.jscli.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 完成整个工程构建过程。

JSON Schema

project.json 里面该写点什么,怎么保证里面配置符合预期。这个引入 json-schema 概念。关于 schema 有哪些,你可以点击下载查看,关于 json-schema-validation 标准介绍。

JSON Schema 是用于验证 JSON 数据结构的强大工具,Schema 可以理解为模式或者规则。

如果你对 json-schema 没有印象,那你一定用过 webpack,它里面的 loaderplugin 输入参数配置验证就是采用 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?因为一个项目可能包含 pcm 2 个子项目,默认推荐响应式一站式。
  • css 强制使用 scss 预处理器处理,包括 postcss 等处理。
  • 正常情况下一个 project 只会有一个 index.html,有些 project 为了应对审查(谷歌广告)需要有多余的免责申明,隐私政策等页面。
  • 关于 postcssbabelbrowserslist 等配置是全局共享。

定义 json-schema 规范,那就该验证输入数据是否靠谱。我们采用:

npm install -D ajv ajv-keywords

ajv 自带 ajv-formats 拓展一些字符串格式限制属性,比如常见的:dateurihostname等。
ajv-keywords 是辅助 Ajv 自定义验证属性,一些常用的属性,比如常见的:typeofinstanceofrangeregexp等。

关于验证 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 配置,主要开发和生产,正好对应 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 运行,哪些在 productionwebpack 配置里面很多都是数组,需要使用 [].filter(Boolean) 来过滤 undefined,从而避免 webpack 读取配置时报错。configFactory() 函数拿到配置不是最终配置,只是一个基准配置,后面可以根据 validateSchema 处理之后 project.json 配置来做合并,这样一来,每个项目就可以定制不同的配置。

对外包装 2 个 run 方法:

  • runWebpackconfigFactory('production') 生成配置,调用 Webpack(Config).run() 运行
  • runWebpackDevServerconfigFactory('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 合理拆分:

  • common:基础配置(包括 js)
  • style: 处理 css 配置
  • html: 处理 html 配置
  • image: 处理 img 配置
  • dev-server: 开发 DevServerOptions 配置

这里就不贴代码了,太长了,主要参考 angular-cli 里面一些配置,精简一些不需要。

  • common
  • style
  • dev-server
  • image 这个没有参考,只是参考 webpack 这个图片压缩的插件,跟上面 3 个写法类似
  • html 这个就没有参考了,如果做单页应用,就一个 html,我这个需要特殊处理一下,开发时候是需要编译成 .html,打包的时候还是保留 .ejs,方便服务端处理。

简单理解就是把大函数拆分成小函数,这样就方便组合使用。现在需要提取 2 个新的方法来组合这些配置:

  • buildWebpack:组合之后调用 runWebpack
  • serveWebpack:组合之后调用 runWebpackDevServer

buildWebpackserveWebpack 区别就是,是否使用 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 工具功能实现

核心的构建功能已经完成,接下来就该完成 CLi 工具

serve

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 获取当前运行项目的配置。

其他注释都已经说明了,

这里重点说一下:getProjectwebpackConfiguration

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>

build

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);
      }
    });
};

buildserve 一样,唯一区别, serve 一次只能运行一个 project(这也是为什么需要 platform 参数的原因),build 需要打包 projects 所有的项目

addBuildReleaseZip 就是把 dist 文件夹里项目打包成 zip 文件,方便上传。

npm run build -- --name=<name>

generate

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,是为了方便处理 pcmobile 同时存在,有时候又只需要一个,方便处理。

npm run generate -- --name=<name>

release

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 一样,借助 axiosform-datadist.zip 传到服务器上。

主要方便项目开发者使用,config 自动更新模板变量到数据库,publish 自动发布该项目。

npm run release -- --name=<name>

我的想法是能程序自动处理,就自动处理。这是我对 nodejs 仅停留在做个小工具,方便小伙伴早下班。

Nx 一个现代 Monorepo 工具

前面我们做了很多事情,主要还原我想要做一个 Monorepo 项目工具,最基础需要哪些东西:

  • 一个配置文件(项目之间互不影响)
  • 一整套构建脚本运行命令
  • 方便自定义扩展

前面 2 个,我上面都已经实现了,自定义扩展方便确暂时无法实现,原因我的构建和 CLI 完全耦合,无法分离,我想老项目使用 webpack, 新项目使用新潮流 vite 按照现在设计完全不可能。

接下来我们介绍 Nx,它将完全实现这个不可能。

Nx 一开始的 Angular-cli 的扩展,它的作者成员也是 Angular 核心开发者。

我从 Nx v8 开始使用它,一度放弃 Angular-cli,因为它包含 Angular-cli,还支持 ReactNestjsNextjs,暂不支持 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,这玩意就可以方便实现自定义扩展,想要切换 webpackvite,那就一行代码事情。

{
  ...
  {
  -  "executor": "@nrwl/web:dev-server",  
  +  "executor": "@nrwl/vite:dev-server",
  }

}

Nx 强大之处,nx-plugin 可以让你自己写 executorgenerateNx 虽然不支持 Vue,但是有人写了插件

Nx插件组织里面有几类:

  • 基础构建插件:executor,例如:webpack,esbuild,vite 等构建工具
  • 辅助功能插件:generate,例如:nest,next 等生成工具
  • 包装构建插件:executorgenerate,例如 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
  );
}

这是简单的自定义功能,如果想要借助别的更底层 executorgenerate 呢,我这里一篇定制 nest-mvc 的插件,有兴趣可以看一下,如果有疑问,欢迎跟我交流。

写到最后

说起 Angular,很多人都不喜欢,可以不用 Angular,但是它的工程化**,可以借鉴学习,在目前前端界,说二没有敢说一,也为你以后做构建轮子提供思路,你不想折腾,那只能呵呵。


今天就到这里吧,伙计们,玩得开心,祝你好运

谢谢你读到这里。下面是你接下来可以做的一些事情:

  • 找到错字了?下面评论
  • 如果有问题吗?下面评论
  • 对你有用吗?表达你的支持并分享它。

angular1表单验证

前端发展很快,angular1前几年还很火,虽然现在很多新框架都出来了,angular2,angular4,react,vue。angular1市场是存在,angular做管理系统还是很不错的一个选择。angular里面有很多功能。指令,控制器,服务,过滤器,双向绑定,表单验证等,今天要说就是表单验证。

表单验证

表单验证,angular提供丰富的内置指令也很实用的指令,我们需要去使用它来做验证。唯一有个不足的地方,angular验证是边输入边验证,不是失去焦点验证。如果需要失去焦点验证需要自己去做些处理。后面回讲一些。

form标签

说到表单一定要说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正确性,前端可以做一层简单拦截,用户体验一种,如果用户输入不是预期就给提示警告,直到正确为止,才让用户提交,当然后台也会做相同的验证。不是我们研究的重点。

使用angular.js该如何玩呢?

<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里面会出现一些奇怪东西。先不管奇怪的东西。我们来一一解释。

  1. from里面的name这个是重点,它是和angular绑定唯一标识,一个页面不要重名,重名就被覆盖了。后面验证就需要用到它。很关键的东西,不要忽略。
  2. novalidate 屏蔽浏览器对表单的默认验证行为,忽略html5表单验证,html5提供常用表单验证规范,angular也是基于它,和它类似。使用方法。我们想用angular表单验证,就要把它忽略,禁用。
  3. ng-submit 是绑定提交事件。submitForm(login.$valid)控制器里面绑定一个方法,当用户点提交时候就会去调用它。
  4. ng-model 是一个表单双向绑定指令,表单里面很常用。其他标签也可以使用。ng-model是一个很复杂的东西,可以做很多功能,后面会单独列出来。
  5. 为什么需要绑定到一个vm上,类似一个哈希对象写法,很多栗子都是直接绑定一个值,这样的写一个工作实践。提交给后台时候只需要把vm这个对象放在ajax参数里面就好了,如果在多一个重置按钮,直接把vm设为空对象就好。也是一些小技巧。

上面和我们验证有什么关系了。接着继续。。。

表单验证常用属性

表单对象 formName

formName就是我们上面栗子的form的name值

表单元素对象 inputfieldName

inputfieldName就是我们上面栗子的input的name值

如果我们需要访问username,就需要如此如此这般这般, login.username

表单元素常用的4个状态

未修改过的表单

布尔值属性,表示用户是否修改了表单。如果为ture,表示没有修改过;false表示修改过:

formName.inputFieldName.$pristine;

修改的表单

布尔型属性,当且仅当用户实际已经修改的表单。不管表单是否通过验证:

formName.inputFieldName.$dirty

经过验证的表单

布尔型属性,它指示表单是否通过验证。如果表单当前通过验证,他将为true:

formName.inputFieldName.$valid

未通过验证的表单

布尔值属性,表示用户没有通过验证。如果为ture,表示没有通过;false表示通过:

formName.inputFieldName.$invalid

同样这个这个状态也适用于formNam,回去看栗子submitForm(login.$valid)就是写的第三个,通过验证了才能提交。

常用的内置4个验证指令

那你肯定要问了这些状态根据什么判断,凭什么说我没有通过验证。下面就来说说说angular提交表单验证几个常用的指令。

  • ngRequired: 这个和html5的那个required有些差别,这个可以接收一个表达式,动态去计算布尔值,是否需要计算,required需要做处理才能做到。有提供干嘛不用呢
  • ngMinlength:看名字也可以看出,判断最小长度的。不解释
  • ngMaxlength:同上,这个判断输入长度,但是有个问题, maxlength会限制输入字符,到了就不能输入了,但是这个不会,你需要吧maxlength也写上,才能起效果。这样一来这个指令就有点鸡肋。还不如写maxlength实在。
  • ngPattern:接受一个正则表达式,需要一个完整的正则表达式,例如/^[a-zA-Z]*\d$/

以上4个是关于验证信息,来改变验证状态。

下面是几个常用的事件。

  • ngChange:有 ng-model的指令就可以使用这个事件,当输入元素通过用户交互方式发生输入变化时会执行这个事件
  • ngBlur : 只存在表单元素,当输入元素通过用户交互方式发生失去焦点时会执行这个事件
  • ngFocus :只存在表单元素,当输入元素通过用户交互方式发生获取焦点时会执行这个事件
  • ngSubmit:表单提交事件。这个事件有以下几点说明:
  1. 需要绑定form元素上
  2. form里面必须有一个按钮或类型为submit的input字段(input[type=submit])
  3. 为了防止处理程序的双重执行,你只能使用 ngSubmit 或 ngClick 指令的其中一个
  4. 如果表单只有一个input字段,在这个字段按回车将触发表单提交(ngSubmit)
  5. 如果表单有两个以上input字段并且没有按钮或没有input[type=submit] ,按回车不会触发提交
  6. 如果表单有一个或多个input字段,并且有一个或多个按钮,或有input[type=submit],在任意input字段中按回车后将触发 第一个 按钮或 input[type=submit]上的click事件 (ngClick) 和 整个表单的submit事件 (ngSubmit)

以上就是angular表单验证基本介绍,接下来会介绍怎么做验证处理,和验证相关的属性。

让我们用Nestjs来重写一个CNode(中)

我发现比我想象要长,打算把实战部分拆分成中和下来讲解。

通过上篇学习,相信大家对 Nest 有大概印象,但是你还是看不出它有什么特别的地方,下篇将为你介绍项目实战中Nest如何使用各种特性和一些坑和解决方案。源码

这篇主要内容:

  • 项目架构规划
  • 入口文件配置说明
  • 依赖安装
  • 配置模板引擎和静态文件
  • 静态模板
  • 系统配置和应用配置
  • 数据库之用户表
  • 注册
  • 使用node-mailer发送邮件
  • 登录和第三方认证github登录
  • session和cookie
  • 找回密码和登出

项目架构规划设计

一个好的文件结构约定,会让我们开发合作、维护管理,节省很多不必要沟通。

这里我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实例。第三个全局配置:

  • logger 打印日志
  • cors 跨域配置
  • bodyParser post和put解析body中间件配置
  • httpsOptions https配置

app 带方法有哪些
INestApplication

  • init 初始化应用程序,直接调用此方法并非强制。(效果不明)
  • use 注册中间件
  • enableCors 启用CORS(跨源资源共享)
  • listen 启动应用程序。
  • listenAsync 同步启动应用程序。
  • setGlobalPrefix 注册每个HTTP路由路径的前缀
  • useWebSocketAdapter 安装将在网关内部使用的Ws适配器。使用时覆盖,默认socket.io库。
  • connectMicroservice 将微服务连接到NestApplication实例。 将应用程序转换为混合实例。
  • getMicroservices 返回连接到NestApplication的微服务的数组。
  • getHttpServer 返回基础的本地HTTP服务器。
  • startAllMicroservices 异步启动所有连接的微服务
  • startAllMicroservicesAsync 同步启动所有连接的微服务
  • useGlobalFilters 将异常过滤器注册为全局过滤器(将在每个HTTP路由处理程序中使用)
  • useGlobalPipes 将管道注册为全局管道(将在每个HTTP路由处理程序中使用)
  • useGlobalInterceptors 将拦截器注册为全局拦截器(将在每个HTTP路由处理器中使用)
  • useGlobalGuards 注册警卫作为全局警卫(将在每个HTTP路由处理程序中使用)
  • close 终止应用程序(包括NestApplication,网关和每个连接的微服务)
    INestExpressApplication
  • set 围绕本地express.set()方法的包装函数。
  • engine 围绕本地express.engine()方法的包装函数。
  • enable 围绕本地express.enable()方法的包装函数。
  • disable 围绕本地express.disable()方法的包装函数。
  • useStaticAssets 为静态资源设置基础目录。围绕本地express.static(path, options)方法的包装函数。
  • setBaseViewsDir 设置模板(视图)的基本目录。围绕本地express.set('views', path)方法的包装函数。
  • setViewEngine 为模板(视图)设置视图引擎。围绕本地express.set('view engine', engine)方法的包装函数。

依赖安装

核心依赖

因为目前CNode采用Egg编写,里面大量使用与Egg集成的egg-xxx包,这里我把相关的连对应的依赖都一一来出来。

模板引擎

Egg-CNode使用egg-view-ejs,本项目使用ejs包,唯一缺点没有layout功能,可以麻烦点,在每个文件引入头和尾即可,也有另外一个包ejs-mate,它有layout功能,后面会介绍它怎么使用。

redis

Egg-CNode使用egg-redis操作redis,其实它是包装的ioredis包,我也一直在nodejs里使用这个包,需要安装生产ioredis和开发@types/ioredis

mongoose

Egg-CNode使用egg-mongoose操作mongodbNest提供了@nestjs/mongoose,需要安装生产mongoose和开发@types/mongoose

passport

Egg-CNode使用egg-passport、egg-passport-github、egg-passport-local做身份验证,Nest提供了@nestjs/passport,需要安装生产passport、passport-github、passport-local

其他依赖在后面用到时候在详细介绍,这几个是比较重要的依赖。

配置 Views 视图模板和 public 静态资源

CNode 使用的是egg-ejs,为了简单点,减少静态文件编写,我也用ejs。发现区别就是少了layout功能,需要我们拆分layout/header.ejslayout/footer.ejs在使用了。
但是有一个包可以做到类似的功能ejs-mate,这个是@JacksonTian 朴灵大神的作品。

新建模板存放views文件夹(root/views)和静态资源存放public文件夹(root/public)

注意nest-cli默认只处理src里面的ts文件,如有其他文件需要自己写脚本处理,gulp或者webpack都可以,这里就简单一点,直接把viewspublic放在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,因为viewspublic在根目录,所有我们就需要去获取获取根目录。其他注释已经说明,就不再赘述。

使用模板引擎:

  1. 我们在views文件夹里面新建一个layout.html和一个index.html

  2. 写通用的layout.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>我是layout模板</title>
</head>
<body>
    <%- body -%>
</body>
  1. 写的index.html
<% layout('layout') -%>
<h1>我是首页</h1>  
  1. 渲染模板引擎
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/看结果。

3nc4l2 l_nbp_di0fkomamc

ejs-mate语法:

ejs-mate兼容ejs语法,语法很简单,这里顺便带一下:

  • <% '脚本' 标签,用于流程控制,无输出。
  • <%_ 删除其前面的空格符
  • <%= 输出数据到模板(输出是转义 HTML 标签)
  • <%- 输出非转义的数据到模板
  • <%# 注释标签,不执行、不输出内容
  • <%% 输出字符串 '<%'
  • %> 一般结束标签
  • -%> 删除紧随其后的换行符
  • _%> 将结束标签后面的空格符删除

说几个常用的写法:

<% 直接写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。它是在指定的位置插入自定义内容。类似于angularjstranscludeangular<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">') %>

appendprepend是插入的顺序,append总是插槽位置插入在最后,prepend总是插槽位置插入在最前。

我们来验证一下。

现在layout.htmlhead里面写上

<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/看结果。

7d kp 0rh t 7 i4

注意index.html里书写block('head').append的位置不影响它显示插槽的位置,只受定义插槽<%- block('head').toString() %>

还有一个方法replace,没看懂怎么用的,文档里面也没有说明,基本appendprependtoString就够用了。

总结:toString是定义插槽位置,appendprepend往插槽插入指定的内容。他们主要做什么了,layout载入公共的cssjs,如果有的页面有不一样地方,就需要插入当前页面的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">
...

l8p0 psc xxuk6 zyea0nvs

如果有问题,请找原因,路径是否正确,设置是否正确,如果都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.jsonscripts配置

{
    ...
    "assets": "loader /views /"
}

loader的写法是:loader <views_dir> <output_dir>views_dir是模板引擎目录,output_dirassets.json文件输出的目录,/表示根目录。

npm run assets

直接运行会报错,这个问题在egg-node有人提issues

z90d4zb w t t26e_2 l

主要是静态资源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");
}
...

大约22962320行位置,你可以用查找搜索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行位置

简单解释就是换成相对于根目录的路径,后面错误就类似。

45 7el q a5ph tf g84r 0

打包成功以后会输出一个assets.json在根目录。assets指的就是这个json文件,后面我们会讲如果把它们关联起来。

静态模板

我们上面已经配置好了模板引擎和静态资源,我们先要去扩展他们,先让页面好看点。

打开cnode,然后右键查看源代码。把里面内容复制,拷贝到index.html里去。

访问http://localhost:3000/就可以瞬间看到和cnode首页一样的内容了。

1

有模板以后,我们需要改造他们:

  1. 使用HTML5推荐的DOCTYPE申明
<!DOCTYPE html>
<html lang="zh-CN">
  1. 拆分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.htmlhead,同名的直接替换。

替换之后的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个插槽,一个stylesscripts

  1. 拆分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>
  • backtotopsidebar-mask是2个和js相关的功能标签,直接保留它们。
  • classnavbar对应到header标签
  • idmain对应到main标签
  • idfooter对应到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里面的stylesscripts使用

<% block('styles').append(``) %>
<% block('scripts').append(``) %>

最好是写成scriptstyle文件。

  1. 拆分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文件模板

ConfigModule(配置模块)

当我们使用process global对象时,很难保持测试的干净,因为测试类可能直接使用它。另一种方法是创建一个抽象层,即一个ConfigModule,它公开了一个装载配置变量的ConfigService

关于配置模块,官网有详细的栗子,这里也是基本类似。这里说一些关键点:

  1. 需要用到依赖:
npm i --save dotenv  // 用来解析`.env`配置文件

npm install --save joi  // 用来验证`.env`配置文件
npm install --save-dev @types/joi
  1. 需要创建.env配置文件
 development.env  开发配置
 production.env  生产配置
 test.env  测试配置
 .env.tmp  .env配置文件模板
  1. 怎么设置NODE_ENV

windowsmac不一样

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",
}
  1. 创建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种注册服务的写法,一种是类,一种是工厂。前面基础篇已经提及了,后面讲怎么使用它们。

  1. 创建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服务,它是做什么的了,它叫做令牌。

  1. 我们创建一个常量文件:
$ touch src/config/config.constants.ts
OR
编辑器新建文件config.constants.ts

里面写入常量configToken并导出

export const ConfigToken = 'ConfigToken';

ConfigModuleconfigToken也是它。

  1. 我们创建一个装饰器文件:
$ 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是一个装饰器。装饰器在nestangular有大量实践案例,各种装饰器,让你眼花缭乱。

简单科普一下装饰器:

写法:(总共四种:类,属性,方法,方法参数)

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;

执行顺序:

  • 类装饰器总是最后执行。
  • 有多个方法参数装饰器时:从最后一个参数依次向前执行。
  • 方法参数装饰器中参数装饰器先执行,方法参数装饰器执行完以后,方法装饰器执。
  • 方法和属性装饰器,谁在前面谁先执行。(ps:方法参数属于方法一部分,参数会一直紧紧挨着方法执行。)
  1. 如何使用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实例取,一般用于系统启动初始化配置,后面还要其他的获取方式,用到在介绍。

Config(应用配置)

应用配置对比系统配置就没有这么麻烦了,大多数数据都可以写死就行了。

$ touch src/core/constants/config.constants.ts
OR
编辑器新建文件config.constants.ts

参考cnode-eggconfig/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'
};

哪里需要直接导入就行了,这个比较简单。

系统配置和应用配置告一段落了,那么接下来需要配置数据。

mongoose连接

关于mongoDB安装,创建数据库,连接认证等操作,这里就展开了,这里有篇文章

.env文件里面,我们已经配置mongoDB相关数据。

  1. 创建核心模块
$ nest generate module core
OR
$ nest g mo core

核心模块,只会注入到AppModule,不会注入到featureshared模块里面,专门做初始化配置工作,不需要导出任何模块。

它里面包括:守卫,管道,过滤器、拦截器、中间件、全局模块、常量、装饰器

其中全局中间件和全局模块需要模块里面注入和配置。

  1. 配置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和官网文档一样.

  1. 配置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个静态方法:

  • forRoot(url, config): 对应的Mongoose.connect()方法
  • forRootAsync({
    imports,
    useFactory,
    inject
    }): useFactory返回对应的Mongoose.connect()方法参数,imports依赖模块,inject依赖服务
  • forFeature([{ name, schema }]): 对应的mongoose.model()方法
  • constructor(@InjectModel('Cat') private readonly catModel: Model) {}:@InjectModel获取mongoose.model,参数和forFeaturename一样。

根模块使用: (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是否正确,如果启动验证,账号是否验证通过,数据库名是否正确等等。

数据库连接成功,我们进行下一步,定义用户表。

用户数据库模块

建立数据模型为后面控制器提供服务

生成文件

  1. 创建shared模块
$ nest generate module shared
OR
$ nest g mo shared
  1. 创建mongodb模块
$ nest generate module shared/mongodb
OR
$ nest g mo shared/mongodb
  1. 创建user模块
$ nest generate module shared/mongodb/user
OR
$ nest g mo shared/mongodb/user
  1. 创建user服务
$ nest generate service shared/mongodb/user
OR
$ nest g s shared/mongodb/user
  1. 创建userinterfaceschemaindex

这三个文件无法用命令创建需要自己手动创建。

$ 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`
  • interfacets接口定义
  • schema是定义mongodbschema

最后完整的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之前,我们先思考一个问题,因为操作数据库服务基本都类似,常用几个方法如:

  • findAll 获取指定条件全部数据
  • paginator 带分页结构数据
  • findOne 获取一个数据
  • findById 获取指定id数据
  • count 获取指定条件个数
  • create 创建数据
  • delete 删除数据
  • update 更新数据

一个基本表应该有增删改查这样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();
    }
}

这里说几个上面没有提到的属性和方法:

  • _model:当前模型的实例,我们使用它去扩展其他方法,如果上面方法不满足我们需求,我们可以随时自定义
  • clearCollection:删除所有匹配条件的文档集合
  • toObjectId:字符串 id 转换ObjectId

那么我们接下来的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就已经完成了,接下来就需要定义schemainterface

定义schema

有了上面服务的经验,现在是不是你会说schema有没有公用的,当然可以呀。

我们定一个base.schema.ts,思考一下需要抽出来,好像唯一可以抽出来就是:

  • create_at:创建时间
  • update_at: 更新时间

这2个我们可以用抽出来,可以使用schema配置参数里面的timestamps属性,可以开启它,它默认createdAtupdatedAt。我们修改它们字段名,使用它们好处,创建自动赋值,修改时候自动更新。
注意:它们的存的时间和本地时间相差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
  • 整型 - 此类型用于存储数值。 整数可以是32位或64位,具体取决于服务器。
  • 布尔类型 - 此类型用于存储布尔值(true / false)值。
  • 双精度浮点数 - 此类型用于存储浮点值。
  • 最小/最大键 - 此类型用于将值与最小和最大BSON元素进行比较。
  • 数组 - 此类型用于将数组或列表或多个值存储到一个键中。
  • 时间戳 - ctimestamp当文档被修改或添加时,可以方便地进行录制。
  • 对象 - 此数据类型用于嵌入式文档。
  • 对象 - 此数据类型用于嵌入式文档。
  • Null - 此类型用于存储Null值。
  • 符号 - 该数据类型与字符串相同; 但是,通常保留用于使用特定符号类型的语言。
  • 日期 - 此数据类型用于以UNIX时间格式存储当前日期或时间。您可以通过创建日期对象并将日,月,年的日期进行指定自己需要的日期时间。
  • 对象ID - 此数据类型用于存储文档的ID。
  • 二进制数据 - 此数据类型用于存储二进制数据。
  • 代码 - 此数据类型用于将JavaScript代码存储到文档中。
  • 正则表达式 - 此数据类型用于存储正则表达式。

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

因为有些公共的字段,我们在定义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 {}

上面schemaservice,都定义好了,接下来我们需要在模块里面注册。

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';

注意:不是所有的文件都需要导出的,一些关键的文件,其他模块需要使用的,如果interfaceservice都是需要导出的。

其他文件访问

xxx.service.ts

import { UserService , User } from './user';

是不是很方便。

shared 模块和 mongodb 模块

mongodb模块

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文件夹里模块导入导出

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发送邮件

如果有用户模块功能,登陆注册应该说是必备的入门功能。

先说一下我们登陆注册逻辑:

  1. 我们主要使用passport、passport-github、passport-local这三个模块,做身份认证。
  2. 支持本地注册登陆和github第三方认证登陆(后面会介绍github认证登陆怎么玩)
  3. 使用sessioncookie,30天内免登陆
  4. 退出后清除sessioncookie
  5. 支持电子邮箱找回密码

这里注册、登录、登出、找回密码都放在这个模块里面

生成文件

  1. 创建feature模块
$ nest generate module feature
OR
$ nest g mo feature
  1. 创建auth模块
$ nest generate module feature/auth
OR
$ nest g mo feature/auth
  1. 创建auth服务
$ nest generate service feature/auth
OR
$ nest g s feature/auth
  1. 创建auth控制器
$ nest generate controller feature/auth
OR
$ nest g co feature/auth
  1. 创建authdto

dto是字段参数验证的验证类,需要配合各种功能,等下会讲解。

最后完整的auth文件夹是:

index.ts
auth.module.ts
auth.service.ts
auth.controller.ts
dto

基本所有的feature模块都是这样的结构,后面不在介绍生成文件这项。

科普知识:async/await

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基本使用规则和条件

  1. async 表示这是一个async函数,await只能用在这个函数里面
  2. await 表示在这里等待promise返回结果了,再继续执行。
  3. await 等待的虽然是promise对象,但不必写.then(..),直接可以得到返回值。
  4. 捕捉错误可以直接用标准的try catch语法捕捉错误
  5. 循环多个await 可以写在for循环里,不必担心以往需要闭包才能解决的问题 (注意不能使用forEach,只可以用for/for-of)

注意await必须在async函数的上下文中

在开始之前,前面数据操作有基础服务抽象类,这里控制器和服务也可以抽象出来。是可以抽象出来,但是本项目不决定这么来做,但会做一些抽象的辅助工具。

auth模块

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模块

feature.module.ts

import { Module } from '@nestjs/common';
// 引入Auth模块导入导出
import { AuthModule } from './auth/auth.module';

@Module({
    imports: [
        AuthModule,
    ],
    exports: [
        AuthModule,
    ],
})
export class FeatureModule { }

注意feature 模块功能就是导入导出所以的业务模块。

app模块

如果是按我顺序用命令行创建的文件,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 模块就可以了。

auth控制器

默认控制器文件

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是什么,渲染模板,对应是Expressres.render('xxxx');方法。

提示

  1. 关于控制器方法命名方式,因为本项目是服务的渲染的,所有会有模板页面和页面请求。模板页面统一加上View后缀

  2. 模板页面请求都是get,返回数据会带一个必须字段pageTitle,当前页面的title标签使用。

  3. 页面请求方法命名根据实际情况来。

现在就可以运行开发启动命令看看效果,百分之两百的会报错,为什么?因为找不到模板auth/register.ejs文件。

那我们就去views下去创建一个auth/register.ejs,随便写的什么,在运行就可以了,浏览器访问:http://localhost:3000/register

2

我们需要完善里面的内容了,因为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

下面来说个我经常解决问题方法:

  1. 首先如果我们用的github的开源依赖包,我们把这个错误复制到它的issues的搜索框里,如果有类似的问题,就进去看看,能不能找到解决方案,如果没有一个问题,你就可以提issues

把你的问题的和环境依赖、最好有示例代码,越详细越好,运气好马上有人给你解决问题。

  1. 搜索引擎解决问题比如:谷歌、必应、百度。如果有条件首选谷歌,没条件优先必应,其次百度。也是把问题直接复制到输入框,回车就好有一些类似的答案。

  2. 就是去一些相关社区提问,和1一样,把问题描述清楚。

使用必应搜索,发现结果第一个就是问题,和我们一模一样的。

3

点击链接进去的,有人回复一个收到好评最高,说app.use(csurf())要在app.use(cookieParser())app.use(session({...})之后执行。

其实我们的这个问题,在csurf说明文档里面已经有写了,使用之前必须依赖cookieParsersession中间件。

session中间件可以选择express-sessioncookie-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随便写,返回最后都会被替换的。

4

如果每次都要写一个那就比较麻烦了,需要写一个中间件来解决问题。

在入口文件启动函数里面使用它。

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种方式,formajaxajax无刷新,这个比较常用,基本是主流操作了。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.tsindex.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);
                },
            },
        });
    };
}

官方文字里面有栗子:直接拷贝过来就行了,改改就好。我们需要改的就是namevalidate函数里面的内容,

validate函数返回true验证成功,false验证失败,返回错误消息。

...
@IsNotEmpty({
    message: '确认密码不能为空',
})
@IsEqualsThan('pass', {
    message: '两次密码输入不一致。',
})
readonly re_pass: string;
...

注意IsEqualsThan第一个参数参考[lodash#get(https://lodash.com/docs/4.17.10#get)方法

验证规则搞定了,现在又有2个新问题了,

  1. 默认返回全部错误格式是数组json,我们需要格式化自定义错误。
  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用的比较简单就不深入了,Reflectes6新特性一部分。

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封装一下。

对于一个没有写过动态模块的我,是一脸懵逼,还好作者写很多包装的功能模块:

  • graphql
  • typeorm
  • terminus
  • passport
  • elasticsearch
  • mongoose
  • jwt
  • cqrs

既然不会写我们可以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依赖注入自定义服务:

  • Use value
const connectionProvider = {
  provide: 'Connection',
  useValue: connection,
};

值服务:这个一般作为配置,定义全局常量使用,单纯key-value形式

  • Use class
const configServiceProvider = {
  provide: ConfigService,
  useClass: process.env.NODE_ENV === 'development'
    ? DevelopmentConfigService
    : ProductionConfigService,
}

类服务:这个比较常用,默认就是类服务,如果provideuseClass一样,直接注册在providers数组里即可。我们只关心provide注入是谁,不关心useClass依赖谁。

  • Use factory
const connectionFactory = {
  provide: 'Connection',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider],
};

工厂服务:这个比较高级,一般需要依赖其他服务,来创建当前服务的时候,操作使用。定制服务经常用到。

我们在回过头来说上面这个createMailerClient方法

本来我们可以直接写出一个Use factory例子一样的,考虑它需要forRootforRootAsync都需要使用,我们写成一个函数,使用时候直接调用即可,也可以写成一个对象形式。

provide引入我们定义的常量,至于这个常量是什么,我们不需要关心,如果它变化这个注入者也发生变化,这里不需要改任何代码。也算是配置和程序分离,一种比较好编程方式。

inject依赖其他服务,这里依赖是一个useValue服务,我们把邮箱配置传递给MAILER_MODULE_OPTIONS,然后把它放到inject,这样我们在useFactory方法里面就可以取到依赖列表。

注意inject是一个数组,useFactory参数和inject一一对应,简单理解,useFactory是形参,inject数组是实参。

useFactory里面,我们可以根据参数做相关的操作,这里我们直接获取这个服务即可,然后使用nodemailer提供的邮件创建方法createTransport即可。

依赖注入和服务重点,我不关心依赖者怎么处理,我只关心注入者给我提供什么。

我们在来说上面这个MAILER_MODULE_OPTIONS值服务

MAILER_MODULE_OPTIONSforRoot里是一个值服务{ provide: MAILER_MODULE_OPTIONS, useValue: options },保存传递的参数。
MAILER_MODULE_OPTIONSforRootAsync里是一个特殊处理...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)返回是一个PromisePromise有一些缺陷,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注册,就会被拦截,出现在垃圾邮箱里面。

验证注册邮箱

我们实现了发现邮箱的功能,接下来就来尝试验证走注册的功能及验证邮箱验证完成注册。

因为我只要一个发送邮箱的账号,和一个测试邮箱的的账号,我需要去数据库把我之前注册的账号删除了,从新完成注册。

填写信息,点击注册,就会发送一封邮件,是这个样子的:

I1A1)WG%(TW 532FZ)(AME9

点击激活链接链接跳回来激活账号:

2BAJ14WL_L}K{Z GZE{Q7`2

接下来我们就来实现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

session+cookie是比较常见前后端一起那种。它是流程大概是这样的:

  1. 前端发起 http 请求时有携带 cookie
  2. 后端拿到此 cookie 对比服务器 session,有登陆则放过此请求,无登录,redirect 到登录页面
  3. 前端登录,后端比对用户名密码,成功则生成唯一标识符,放在 session,并且存入浏览器 cookie
  4. 用户可以拿到自己的 cookie,就可以发起任何的客户端 http 请求

注意:以上操作都是合法操作,如果个人过失暴露 cookie 给其他人,属于用户个人的行为,比如你在网吧里登录 QQ,服务端没有办法不允许这样操作。而客户端的人应有安全意识,在公共场所及时清空 cookie,或者停止使用一切 [不随 session 关闭而 cookie 失效] 的应用。

JSON Web Tokens

JSON Web Tokens是比较常见前后分离那种。它是流程大概是这样的:

  1. 登录时候,客户端通过用户名与密码请求登录
  2. 服务端收到请求区验证用户名与密码
  3. 验证通过,服务端会签发一个Token,再把这个Token发给客户端.
  4. 客户端收到Token,存储到本地,如Cookie,SessionStorage,LocalStorage.
  5. 客户端每次像服务器请求API接口时候,都要带上Token.
  6. 服务端收到请求,验证Token,如果通过就返回数据,否则提示报错信息.

注意:前端是无设防的,不可以信任; 全部的校验都由后端完成

我们这里是前后端一体的,当然选择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

  • serializeUser:将用户信息序列化后存进 session 里面,一般需要精简,只保存个别字段
  • deserializeUser:反序列化后把用户信息从 session 中取出来,反查数据库拿到完整信息
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.messageundefined,这是passport返回,只要用户名或者密码没有填,都会返回这个错误信息,对应我们来捕获错误也是一脸懵逼,我看cndoe是直接返回信息不全。,这里就一样简单粗暴处理了。

说多了都是眼泪,这个地方卡了我很久。这篇文章卡壳,它需要付50%责任,因为网上没有关于@nestjs/passportpassport-local的栗子。大多数都是jwt栗子,比较折腾,试过各种方法方式。

github登录

这个玩意就本地登录简单多了。先说下流程:

我们网站叫nest-cnode

  1. nest-cnode 网站让用户跳转到 GitHub。
  2. GitHub 要求用户登录,然后询问"nest-cnode 网站要求获得 xx 权限,你是否同意?"
  3. 用户同意,GitHub 就会重定向回 nest-cnode 网站,同时发回一个授权码。
  4. nest-cnode 网站使用授权码,向 GitHub 请求令牌。
  5. GitHub 返回令牌.
  6. nest-cnode 网站使用令牌,向 GitHub 请求用户数据。

接下来我们就去实现一下:

先github申请一个认证,应用登记。

一个应用要求 OAuth 授权,必须先到对方网站登记,让对方知道是谁在请求。

所以,我们要先去 GitHub 登记一下。这是免费的。

访问这个网址,填写登记表。

%V}9$D4LD_4YLFKXRTVJ7QP

应用的名称随便填,主页 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 {};
  }

登录前:

KZWPT O)_GKL`$6 RAISBOX

登录后:

0SJTB2VL`C)C7P6F34KT6V5

在弄个退出就完美了:它就更简单了:

@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('/');
    }
}

就是一波清空操作,调用passportlogout方法。

代码已更新,传送门

欲知后事如何,请听下回分解。

中篇就到此为止了,最后感谢大家暴力吹更,让我坚持不懈的把它写完。后面就比较容易了。Typeorm比较火,等我把全部业面写完了,会更新typeorm版操作MongoDB。回馈大家不离不弃的关注,再次感谢大家阅读。

TypeScript 实现依赖注入

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 函数有两个参数:minmax。如果我们不通过其中一个,函数将抛出一个错误。我们可以得出结论,这个函数取决于这些参数。

然而,这个函数不仅取决于这两个参数,而且依赖于 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 函数不仅使用 minmax,还有随机数生成器。这类函数将被这样调用:

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。理想情况下,它还应该是明确的,或者换句话说,注入式的。

类中的依赖注入

可以使用 setterconstructor 在类中注入一个依赖项。我们使用 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);

自动注入和 DI 容器

现在,我们的 Counter 类没有使用任何隐式依赖关系。这很好,但是这种注入不方便。

  • 我们必须手动注入每个依赖项
  • 注入时,我们必须保持依赖关系的顺序

实际上,我们想让它自动化。有一种方法可以做到这一点,它被称为 DI 容器

总的来说,DI 容器是只做一件事的模块-它为系统中的其他每个模块提供依赖关系。容器确切地知道模块需要哪些依赖项,并在需要时注入它们。这样我们就解放了其他模块来解决这个问题,然后控制到一个特殊的地方。这是 SOLID 在 SRPDIP 原则中描述的行为。

在实践中,为了使其工作,我们需要另一层抽象接口。(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

单例和临时是对象的生命状态类型。

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>();

所有有趣的东西都在类构造函数中。在那里,我们向一个容器请求所有依赖项。

一级依赖项

这些是主要模块取决于的依赖关系:

  • DateTimeSource
  • UuidGenerator
  • EventHandler
  • Logger
  • Timer

DateTimeSource

为了访问日期和时间,我们使用 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>();

UuidGenerator

唯一的 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

事件处理程序使用通用接口 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>();

Logger

这个我们已经实现过了:

export class ConsoleLogger implements Logger {
  public log = (message: LogEntry): void => console.log(message);
}

container.registerSingleton<Logger, ConsoleLogger>();

Timer

模拟一个定时器,在间隔时间内执行回调函数。

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 类中的 envIdGenerator 中的 adaptee

对于容器来说,依赖于什么级别并不重要。容器可以毫无问题地提供所有依赖项。(除非有循环依赖,那是另外一个值得深入探讨的话题)

// 对于 `idgenerator`,我们注册了依赖项,如:
container.registerSingleton<ThirdPartyGenerator>(() => nanoid);

// 对于“ClickHandler”(需要 `Window`)
container.registerSingleton<Window>(() => window);

缺陷

DI 容器的主要问题是,当使用它时,必须注册那里的所有依赖项。它有时并不像我们想要的那样灵活。

另一个缺点是只能从容器访问入口点,这可能看起来有点脏代码。(不过,对于入口点来说,这是可以接受的)

const app= container.get<Application>();
app.init();

今天就到这里吧,伙计们,玩得开心,祝你好运。

JavaScript中最常用的几种数组操作

数组是最常见的数据结构之一,我们需要绝对自信地使用它。在这里,我将列出 JavaScript 中最重要的几个数组常用操作片段,包括数组长度、替换元素、去重以及许多其他内容。

1. 数组长度

大多数人都知道可以像这样得到数组的长度:

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 引擎上,这种清除方法更快。

2. 替换数组元素

有几种方法可以解决这个问题。如果需要替换指定索引处的元素,请使用 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) => ?

3. 过滤数组

在某些情况下,你需要删除数组中的某些元素,然后创建一个新的元素。在这种情况下,使用在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

4. 合并数组

如果你想将多个数组合并为一个数组,有两种方法。

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.applyconcat 对比:

  • 数据上万情况下,两者性能相差毫秒个位数级别
  • Array.prototype.push.apply 数组长度有限制,不同浏览器不同,一般不能超过十万, concat 无限制
  • Array.prototype.push.apply 会改变原数组, concat 不会

正常情况下我们都应该使用 concatspread operator,有种情况下可以使用,如果频繁合并数组可以用 Array.prototype.push.apply

5. 复制数组

总所周知,定义数组变量存储不是数组值,而只是存储引用。 这是我的意思:

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]

我们也可以使用 ES6spread 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]

注意:如果想要了解更多的数组复制,请查询 数组深拷贝和浅拷贝 相关资料,这里只实现了浅拷贝。

6. 数组去重

数组去重是面试经常问的,数组去重方式很多,这里介绍比较简单直白的三种方法:

可以使用 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 中引入的新数据结构 setspread 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

7. 转换为数组

有时我们必须将一些其它数据结构,如集合或字符串转换为数组。

类数组:函数参数,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]

8. 数组遍历

数组遍历方式很多,有底层的,有高阶函数式,我们就来介绍几种:

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

迭代辅助语句:breakcontinue

  • 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 等,都是把整个数组遍历。

9. 扁平化多维数组

这个功能说不是很常用,但是有时候又会用到:

二维数组:

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]

10. 数组添加

如何从数组中添加元素?

我们可以使用 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]

shiftpop 方法都没有参数,都是返回已删除的元素,更新剩余元素的索引,并更新 length 属性。它修改调用它的数组。如果没有元素,或者数组长度为 0,该方法返回 undefined

使用 splice 删除数组元素:

splice 方法可用于从数组中添加、替换或删除元素。

splice 方法接收至少三个参数:

  • start:在数组中开始删除元素的位置
  • deleteCount:删除多少个元素(可选)
  • items...:添加元素(可选)

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 接收一个回调方法,回调返回 truefalse。返回 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); // []

12. 其他方法

剔除假值:

[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 也有这种风*的操作:

目前我们可以借助 ES6Proxy 对象来实现:

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

关于js代码方面的,我现在调用

大神我有个问题,
我现在使用的lodash.js
只是使用了里面的几个方法,
我要做一个单页面的html给客户
客户在无网情况下使用的
我看着js的方法都是调用层次太多,
有没有什么好的方法或者工具直接提取这个方法的所有实现代码
谢谢大神指点

Angular 自定义表单控件

构建一个功能齐全的自定义表单控件,兼容模板驱动和响应式表单,以及所有内置和自定义表单验证器。

Angular Forms 提供 FormsModuleReactiveFormsModule 模块自带了一系列内置指令,这些指令使得将标准 HTML 表单元素(如 inputselecttextarea等)绑定到表单组变得非常简单。

除了这些标准的 HTML 表单元素,我们可能还想使用自定义表单控件,比如下拉框、选择框、切换按钮、滑块或许多其他类型的常用自定义表单组件。

在本文中,我们将学习如何使用现有的自定义表单控件组件,并使其与 Angular Forms API 完全兼容,以便该组件能够参与父表单验证和值跟踪机制。

这意味着:

  • 如果我们使用的是模板驱动表单,我们就可以通过使用 ngModel 把自定义组件插入到表单中
  • 在响应式表单中,我们可以使用 formControlNameformControl 将自定义组件添加到表单中

我们将在本文中构建一个简单的数量选择器组件,它可以用来增加或减少一个值。该组件将成为表单的一部分,如果计数器不匹配有效范围,该组件将被标记为错误。

新的自定义表单控件将完全兼容所需的 Angular 内置表单验证器(requiredmax),以及任何其他内置或自定义验证器。

我们还将在本文中学习如何创建可重用的嵌套表单,这些表单部分可以在许多不同的表单中重用。

我们还将在本文中构建一个嵌套表单的简单示例:包含地址子表单。通过学习如何创建可重用的嵌套表单,这些表单可以在许多不同的表单中重用。

因此,废话不多说,让我们开始学习如何创建自定义表单控件。

标准表单控件是如何工作的?

为了了解如何构建自定义表单控件,我们需要首先了解 Angular 内置表单控件是如何工作的。

Angular 内置表单控件主要针对原生 HTML 表单元素,例如 inputselecttextareacheckbox 等。

下面是一个简单表单的示例,其中有几个普通的 HTML 表单字段:

<div [formGroup]="form">
    标题:<input placeholder="输入标题" formControlName="name">
    <label>是否发布<input type="checkbox" formControlName="publish"></label>
    描述:<textarea placeholder="输入描述" formControlName="description"></textarea>
</div>

正如我们所看到的,我们在这里有几个标准的表单控件,并使用了 formControlName 属性。这就是 Angular 表单绑定到标准 HTML 表单元素的方式。

每当用户与表单输入交互时,表单值和有效性状态将自动重新计算。

那么,这一切是如何运作的呢?

什么是 ControlValueAccessor

在底层,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 {
....
}

正如我们从选择器中看到的,这个值跟踪指令只针对 HTMLinput 元素 checkbox 类型,但只有当 ngModelformControlformControlName 属性应用于它时才适用。

如果这个指令只针对复选框,那么其他类型的表单控件,比如 inputtextarea 呢?

每一种控制类型都有自己的值访问指令,它不同于 CheckboxControlValueAccessor,其中 inputtextarea 使用 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 元素一样,通过添加 formControlNamengModel 指令来添加这个组件。

我们还希望该组件与内置验证器兼容,将它们设置必填字段并设置最大值。

@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 元素的情况一样,比如 inputtextarea 等。

为了做到这一点,我们将使组件实现 ControlValueAccessor 接口。

了解 ControlValueAccessor 接口

让我们回顾一下 ControlValueAccessor 接口的方法。请记住,它们并不是要通过我们的代码直接调用,因为它们是框架回调。

所有这些方法都只能由表单模块在运行时调用,它们的作用是促进表单控件和父表单之间的通信。

下面是这个接口的方法,以及它们是如何工作的:

  • writeValue:表单模块调用此方法将值写入表单控件中
  • registerOnChange:当由于用户输入而变化表单值时,我们需要将值报告回父表单。这是通过调用回调来完成的,该回调最初使用registerOnChange 方法在控件中注册的
  • registerOnTouched:当用户第一次与表单控件交互时,会认为该控件已经 touched 状态,这对于样式美化很有用。为了向父表单报告控件被触碰,我们需要使用 registerOnToched 方法注册的回调
  • setDisabledState:可以使用 Forms APIenableddisabled 控件表单禁用状态。这个状态可以通过 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;
  }
}

现在让我们逐一解释每个方法,看它们是如何实现的。

实现 ControlValueAccessor 接口

实现 writeValue

每当父表单想要在子控件中设置一个值时,Angular 表单模块就会调用 writeValue 方法。

在我们的组件中,我们将获取该值并将其直接赋值给内部 count 属性

 writeValue(value: number) {
    this._value = value;
 }

注意:这里不能直接赋值给 value,这样会触发 registerOnChange 注册的 onChange 回调方法。

实现 registerOnChange

父表单可以使用 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;
  }

实现 registerOnTouched

除了向父表单报告新值外,我们还需要在子控件被用户触碰时通知父表单。

初始化表单时,每个表单控件(以及表单组)都被认为处于未触碰状态,并且 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

父表单也可以通过调用 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 的依赖注入配置

最后,正确实现 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 表单中还注册了很多值访问器。

例如,所有用于标准 inputtextarea 等的内置值访问器也在 NG_VALUE_ACCESSOR 下注册。

每当 Angular 表单模块需要所有可用值访问器的完整列表时,它所要做的就是注入 NG_VALUE_ACCESSOR

这样,我们的组件现在就能够在表单中设置属性的值了。

不仅如此,该组件现在能够参与表单验证过程,并且已经与内置的 requiredmax 验证器完全兼容。

但是,如果组件需要具有自己的内置验证规则,而这些规则总是在组件的每个实例中都活跃,而不是表单配置独立于表单呢?

实现 Validator 接口

在我们的自定义表单控件的情况下,我们希望它确保数量是正的。如果不是,那么表单字段应该被标记为错误,并且对于组件的所有实例都应该始终为 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 方法。

一个功能齐全的自定义表单控件的示例

有了 ControlValueAccessorValidator 这两个接口,我们现在就有了一个功能齐全的自定义表单控件,它既兼容响应式表单,也兼容模板驱动表单,既能设置表单属性的值,又能参与表单验证过程。

这是最终代码:

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 对象,然后将其设置为表单控件的错误的一部分。

我们现在有了一个功能齐全的自定义表单控件,它兼容模板驱动表单、响应式表单和所有内置验证器。

如何实现嵌套表单组

一个非常常见的表单用例是嵌套表单组,它可以跨多个表单重用。

地址表单就是一个很好的例子,它包含了所有常见的地址字段:

  • province:省/直辖市
  • city:市
  • district:区
  • street:街道门牌号
  • zip code:邮政编码

注意:这里为了体现嵌套表单组功能,实际项目当作,我们更希望用户选择我们提供的省市区选项,你只需要把输入框换成下拉选择框即可,这里为了例子看起来不那么复杂,重点关注嵌套表单,这里采用输入框形式。

现在假设我们的应用程序有许多需要地址的不同表单。我们不希望在每个表单中重复显示和验证这些字段所需的所有代码。

相反,我们想做的是在 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 完全兼容,这意味着它应该支持 ngModelformControlformControlName 指令,并能够参与父表单的验证。

听起来很熟悉?

实际上,我们所要做的就是实现 ControlValueAccessorValidator 接口之前所做的那样。那么这是如何运作的呢?

首先,我们需要定义嵌套地址表单组件

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 来收集每个地址字段的值并验证它们的值。

表单对象已经包含了关于此子表单的值和有效性状态的所有信息。我们现在可以使用这些信息来快速实现 ControlValueAccessorValidator 接口。

以下是关于此实现的一些重要注意事项:

  • 表单地址组件在内部使用一个表单,使用一系列内置验证器来完成所有的表单验证
  • 我们在这里尽可能地使用委托原则。例如,我们从 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 表单控件,如 inputselecttextarea 等, FormsModule 为此提供了内置的控件值访问器。

对于自定义表单控件,我们必须通过实现 ControlValueAccessor 接口来构建我们自己的控件值访问器,如果我们希望控件执行自定义值验证,那么我们需要实现 Validator 接口。

我们还可以使用相同的技术来实现嵌套表单组(如地址子表单),这些表单组可以跨多个表单重用。

当你有一组输入框需要验证时,那该如何操作,这就需要 FormArray 闪亮登场。有机会我们下次介绍它们。


今天就到这里吧,伙计们,玩得开心,祝你好运

谢谢你读到这里。下面是你接下来可以做的一些事情:

  • 找到错字了?下面评论
  • 如果有问题吗?下面评论
  • 对你有用吗?表达你的支持并分享它。

Karma+Jasmine+istanbul+webpack自动化单元测试

Karma+Jasmine+istanbul+webpack自动化单元测试

前言

一直用别人配置好的东西,经常看别人写教程来写简单的单元测试,闲来无事自己也来撸个配置玩玩。说干就干,从开始到运行成功差不多5个小时。遇到各种问题,主要是各种模块的配置版本问题。

简单介绍一下要用到东西是什么

Karma的介绍

Karma是Testacular的新名字,在2012年google开源了Testacular,2013年Testacular改名为Karma。Karma是一个让人感到非常神秘的名字,表示佛教中的缘分,因果报应,比Cassandra这种名字更让人猜不透!

Karma是一个基于Node.js的JavaScript测试执行过程管理工具(Test Runner)。该工具可用于测试所有主流Web浏览器,也可集成到CI(Continuous integration)工具,也可和其他代码编辑器一起使用。这个测试工具的一个强大特性就是,它可以监控(Watch)文件的变化,然后自行执行,通过console.log显示测试结果。

Jasmine的介绍

Jasmine是单元测试框架,我将用Karma让Jasmine测试自动化完成。jasmine提出行为驱动【BDD(Behavior Driven Development)】,测试先行理念,Jasmine的官网

istanbul的介绍

istanbul是一个单元测试代码覆盖率检查工具,可以很直观地告诉我们,单元测试对代码的控制程度。(ps:这个玩意浪费我好久时间,后面详细说怎么配置)

webpack的介绍

webpack 是一个现代 JavaScript 应用程序的模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成少量的 bundle - 通常只有一个,由浏览器加载。(引用webpack中文网介绍

构建Test工程,开始新生上路

  1. 创建一个文件test-demo

  2. 进入test-demo,在当前文件夹里打开命令行,输入npm init -y;
    image

  3. 自动生成package.json文件。
    image

ps: 我的环境:nodejs v8.2.1 npm v5.3.0

一招让Angular-cli速度增强

随着项目越来越大,现在模块已经有100多个了,一开始我想着把它们拆开,打算使用angular-cli提供的自定义库的功能(ng generate library my-lib),发现有个bug,我自己摸索一下,算是解决这个bug,下次把它贴出给大家遛遛。

我们还是要解决暂时的问题,打包慢,还有打包会失败的问题:

Q3MV6X5R@I$XR(4@70)QMS

我们使用的是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命令,其实我不会玩。

话不多说,开始干活。

  1. 在项目的根创建一个scripts文件夹,以后需要写脚本都可以丢进去。

  2. 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-clireactcli也有个这个问题。看了上面你应该也会改了。

重要:发现一个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"
},

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.