首頁 > web前端 > js教程 > 主體

帶你深入聊聊Angular中的變化檢測

青灯夜游
發布: 2022-02-14 10:17:59
轉載
2103 人瀏覽過

這篇文章帶大家深入聊聊Angular中的變化檢測,介紹一下Angular 如何訂閱非同步事件執行變化檢測,聊聊變化檢測的策略,希望對大家有所幫助!

帶你深入聊聊Angular中的變化檢測

變化偵測是前端框架中很有趣的一部分內容,各個前端的框架也都有自己的一套方案,一般情況下我們不太需要過多的了解變化檢測,因為框架已經幫我們完成了大部分的工作。不過隨著我們深入的使用框架,我們會發現我們很難避免的要去了解變化檢測,了解變化檢測可以幫助我們更好的理解框架、排查錯誤、進行效能優化等等。 【相關教學推薦:《angular教學》】

什麼是變化偵測?

簡單的來說,變化偵測就是透過偵測視圖與狀態之間的變化,在狀態發生了變化後,幫助我們更新視圖,這種將視圖和我們的資料同步的機制就叫變化檢測。

變化偵測觸發時機

我們了解了什麼是變化偵測,那何時觸發變化偵測呢?我們可以看看下面這兩個簡單的Demo

Demo1:

一個計數器元件,點選按鈕Count會一直加1

@Component({
  selector: "app-counter",
  template: `
    Count:{{ count }}
    <br />
    <button (click)="increase()">Increase</button>
  `,
})
export class CounterComponent {
  count = 0;
 
  constructor() {}
 
  increase() {
    this.count = this.count + 1;
  }
}
登入後複製

Demo2:

一個Todo List的元件,透過Http取得資料後渲染到頁面

  @Component({
    selector: "app-todos",
    template: ` <li *ngFor="let item of todos">{{ item.titme }}</li> `,
  })
  export class TodosComponent implements OnInit {
    public todos: TodoItem[] = [];

    constructor(private http: HttpClient) {}

    ngOnInit() {
      this.http.get<TodoItem[]>("/api/todos").subscribe((todos: TodoItem[]) => {
        this.todos = todos;
      });
    }
  }
登入後複製

從上面的兩個Demo 中我們發現,在兩種情況下觸發了變化偵測:

  • 點擊事件發生時

  • 透過http 要求遠端資料時

在仔細思考下,這兩種觸發的方式有什麼共同點呢? 我們發現這兩種方式都是非同步操作,所以我們可以得出一個結論: 只要發生了非同步操作,Angular 就會認為有狀態可能發生變化了,然後就會進行變化檢測。

此時大家可能會想到  setTimeout  setInterval   ,是的,它們也會觸發變更偵測。

@Component({
  selector: "app-counter",
  template: `
    Count:{{ count }}
    <br />
    <button (click)="increase()">Increase</button>
  `,
})
export class CounterComponent implements OnInit {
  count = 0;

  constructor() {}
  
  ngOnInit(){
    setTimeout(()=>{
       this.count= 10;
    });
  }

  increase() {
    this.count = this.count + 1;
  }
}
登入後複製

簡而言之,如果發生以下事件之一,Angular 將觸發變更偵測:

  • 任何瀏覽器事件(click、keydown 等)

  • setInterval()  與  #setTimeout()

  • ##HTTP 透過  

    XMLHttpRequest  進行請求

Angular 如何訂閱非同步事件執行變更偵測?

剛才我們了解到,只要發生了非同步操作,Angular 就會進行變化檢測,那 Angular 又是如何訂閱到非同步事件的狀態,從而觸發變化檢測的呢?這裡我們就要聊一聊 zone.js 了。

Zone.js

Zone.js 提供了一種稱為** 區域(Zone) ** 的機制,用於封裝和攔截瀏覽器中的非同步活動、它還提供 

非同步生命週期的鉤子 和 統一的非同步錯誤處理機制。

Zone.js 是透過 

Monkey Patching(猴子補丁) 的方式來對瀏覽器中的常見方法和元素進行攔截,例如 setTimeout 和HTMLElement.prototype.onclick 。 Angular 在啟動時會利用 zone.js 修補幾個低階瀏覽器 API,從而實現非同步事件的捕獲,並在捕獲時間後呼叫變化檢測。

下面用一段簡化的程式碼來模擬一下取代setTimeout 的過程:

function setTimeoutPatch() {
  // 存储原始的setTimeout
  var originSetTimeout = window[&#39;setTimeout&#39;];
  // 对浏览器原生方法的包裹封装
  window.setTimeout = function () {
      return global[&#39;zone&#39;][&#39;setTimeout&#39;].apply(global.zone, arguments);
  };
  // 创建包裹方法,提供给上面重写后的setTimeout使用Ï
  Zone.prototype[&#39;setTimeout&#39;] = function (fn, delay) {
    // 先调用原始方法
    originSetTimeout.apply(window, arguments);
    // 执行完原始方法后就可以做其他拦截后需要进行的操作了
    ...
   };
}
登入後複製

NgZone

Zone.js 提供了一個全域區域,可以被fork 和擴展以進一步封裝/隔離異步行為,Angular 透過創建一個fork並使用自己的行為擴展它,通常來說, 在Angular APP 中,每個Task 都會在Angular 的Zone 中運行,這個Zone 稱為  

NgZone 。一個 Angular APP 中只存在一個 Angular Zone, 而變更偵測只會由執行於這個 ** **NgZone** ** 中的非同步操作觸發 。

簡單的理解是: 

Angular 透過 Zone.js 創建了一個自己的區域並稱為 NgZone,Angular 應用中所有的非同步操作都運行在這個區域中。

變化偵測是如何運作的?

我們了解 Angular 的核心是 

元件化 ,元件的巢狀會讓最後形成一棵 元件樹 。

Angular 在生成组件的同时,还会为每一个组件生成一个变化检测器 changeDetector ,用来记录组件的数据变化状态,由于一个 Component 会对应一个 changeDetector ,所以changeDetector 同样也是一个树状结构的组织。

在组件中我们可以通过注入 ChangeDetectorRef 来获取组件的 changeDetector

@Component({
  selector: "app-todos",
  ...
})
export class TodosComponent{
  constructor(cdr: ChangeDetectorRef) {}
}
登入後複製

我们在创建一个 Angular 应用 后,Angular 会同时创建一个 ApplicationRef 的实例,这个实例代表的就是我们当前创建的这个 Angular 应用的实例。 ApplicationRef 创建的同时,会订阅 ngZone 中的 onMicrotaskEmpty 事件,在所有的微任务完成后调用所有的视图的detectChanges() 来执行变化检测。

下是简化的代码:

class ApplicationRef {
  // ViewRef 是继承于 ChangeDetectorRef 的
  _views: ViewRef[] = [];
  constructor(private _zone: NgZone) {
    this._zone.onMicrotaskEmpty.subscribe({
      next: () => {
        this._zone.run(() => {
          this.tick();
        });
      },
    });
  }

  // 执行变化检测
  tick() {
    for (let view of this._views) {
      view.detectChanges();
    }
  }
}
登入後複製

单向数据流

什么是单向数据流?

刚才我们说了每次触发变化检测,都会从根组件开始,沿着整棵组件树从上到下的执行每个组件的变更检测,默认情况下,直到最后一个叶子 Component 组件完成变更检测达到稳定状态。在这个过程中,一但父组件完成变更检测以后,在下一次事件触发变更检测之前,它的子孙组件都不允许去更改父组件的变化检测相关属性状态的,这就是单向数据流。

我们看一个示例:

@Component({
  selector: "app-parent",
  template: `
    {{ title }}
    <app-child></app-child>
  `, 
})
export class ParentComponent {
  title = "我的父组件";
}

@Component({
  selector: "app-child",
  template: ``, 
})
export class ChildComponent implements AfterViewInit {
  constructor(private parent: ParentComponent) {}

  ngAfterViewInit(): void {
    this.parent.title = "被修改的标题";
  }
}
登入後複製

为什么出现这个错误呢?

这是因为我们违反了单向数据流,ParentComponent 完成变化检测达到稳定状态后,ChildComponent 又改变了 ParentComponent 的数据使得 ParentComponent 需要再次被检查,这是不被推荐的数据处理方式。在开发模式下,Angular 会进行二次检查,如果出现上述情况,二次检查就会报错: ExpressionChangedAfterItHasBeenCheckedError ,在生产环境中,则只会执行一次检查。

并不是在所有的生命周期去调用都会报错,我们把刚才的示例修改一下:

@Component({
  selector: "app-child",
  template: ``, 
})
export class ChildComponent implements OnInit {
  constructor(private parent: ParentComponent) {}

  ngOnInit(): void {
    this.parent.title = "被修改的标题";
  }
}
登入後複製

修改后的代码运行正常,这是为什么呢?这里要说一下Angular检测执行的顺序:

  • 更新所有子子组件绑定的属性

  • 调用所有子组件生命周期的钩子 OnChanges, OnInit, DoCheck ,AfterContentInit

  • 更新当前组件的DOM

  • 调用子组件的变换检测

  • 调用所有子组件的生命周期钩子 ngAfterViewInit

ngAfterViewInit 是在变化检测之后执行的,在执行变化检测后我们更改了父组件的数据,在Angular执行开发模式下的第二次检查时,发现与上一次的值不一致,所以报错,而ngOnInit 的执行在变化检测之前,所以一切正常。

这里提一下AngularJS,AngularJS采用的是双向数据流,错综复杂的数据流使得它不得不多次检查,使得数据最终趋向稳定。理论上,数据可能永远不稳定。AngularJS的策略是,脏检查超过10次,就认为程序有问题,不再进行检查。

变化检测的性能

刚才我们聊了变化检测的工作流程,接下来我想说的是变化检测的性能, 默认情况下,当我们的组件中某个值发生了变化触发了变化检测,那么Angular会从上往下检查所有的组件。 不过Angular对每个组件进行更改检测的速度非常快,因为它可以使用 内联缓存 在几毫秒内执行数千次检查,其中内联缓存可生成对 VM 友好代码。

尽管 Angular 进行了大量优化,但是遇到了大型应用,变化检测的性能仍然会下降,所以我们还需要用一些其他的方式来优化我们的应用。

变化检测的策略

Angular 提供了两种运行变更检测的策略:

  • Default

  • OnPush

Default 策略

默认情况下,Angular 使用 ChangeDetectionStrategy.Default 变更检测策略,每次事件触发变化检测(如用户事件、计时器、XHR、promise 等)时,此默认策略都会从上到下检查组件树中的每个组件。这种对组件的依赖关系不做任何假设的保守检查方式称为 脏检查 ,这种策略在我们应用组件过多时会对我们的应用产生性能的影响。

OnPush 策略

Angular 还提供了一种 OnPush 策略,我们可以修改组件装饰器的 changeDetection 来更改变化检测的策略

@Component({
    selector: &#39;app-demo&#39;,
    // 设置变化检测的策略
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: ...
})
export class DemoComponent {
    ...
}
登入後複製

设置为 OnPush 策略后,Angular 每次触发变化检测后会跳过该组件和该组件的所以子组件变化检测

OnPush模式下变化检测流程

OnPush 策略下,只有以下这几种情况才会触发组件的变化检测:

  • 输入值(@Input)更改

  • 当前组件或子组件之一触发了事件

  • 手动触发变化检测

  • 使用 async 管道后, observable 值发生了变化

输入值(@Input)更改

在默认的变更检测策略中,Angular 将在 @Input() 数据发生更改或修改时执行变化检测,使用该 OnPush 时,传入 @Input() 的值 必须是一个新的引用 才会触发变化检测。

JavaScript有两种数据类型,值类型和引用类型,值类型包括:number、string、boolean、null、undefined,引用类型包括:Object、Arrary、Function,值类型每次赋值都会分配新的空间,而引用类型比如Object,直接修改属性是引用是不会发生变化的,只有赋一个新的对象才会改变引用。

var a= 1;
var b = a;
b = 2;
console.log(a==b); // false

var obj1 = {a:1};
var obj2 = obj1;
obj2.a = 2;
console.log(obj1); // {a:2}
console.log(obj1 === obj2); //true

obj2= {...obj1};
console.log(obj1 === obj2); //false
登入後複製

当前组件或子组件之一触发了事件

如果 OnPush 组件或其子组件之一触发事件,例如 click,则将触发变化检测(针对组件树中的所有组件)。

需要注意的是在 OnPush 策略中,以下操作不会触发变化检测:

  • setTimeout()

  • setInterval()

  • Promise.resolve().then()

  • this.http.get('...').subscribe()

手动触发变化检测

有三种手动触发更改检测的方法:

  • **detectChanges(): ** 它会触发当前组件和子组件的变化检测

  • markForCheck(): 它不会触发变化检测,但是会把当前的OnPush组件和所以的父组件为OnPush的组件 ** 标记为需要检测状态** ,在当前或者下一个变化检测周期进行检测

  • ApplicationRef.tick() : 它会根据组件的变化检测策略,触发整个应用程序的更改检测

可以通过 在线Demo ,更直观的了解这几种触发变化检测的方式

使用 async 管道

内置的 AsyncPipe 订阅一个 observable 并返回它发出的最新值。

每次发出新值时的内部 AsyncPipe 调用 markForCheck

private _updateLatestValue(async: any, value: Object): void {
  if (async === this._obj) {
    this._latestValue = value;
    this._ref.markForCheck();
  }
}
登入後複製

减少变化检测次数

刚才我们聊了变化检测的策略,我们可以使用 OnPush 的策略来优化我们的应用,那么这就够了吗? 在我们实际的开发中还会有很多的场景,我们需要通过一些其他的方式来继续优化我们的应用。

场景1:

假如我们在实现一个回车搜索的功能:

@Component({
  selector: "app-enter",
  template: `<input #input type="text" />`,
})
export class EnterComponent implements AfterViewInit {
  @ViewChild("input", { read: ElementRef })
  private inputElementRef: any;

  constructor() {}

  ngAfterViewInit(): void {
    this.inputElementRef.nativeElement.addEventListener(
      "keydown",
      (event: KeyboardEvent) => {
        const keyCode = event.which || event.keyCode;
        if (keyCode === 13) {
          this.search();
        }
      }
    );
  }

  search() {
    // ...
  }
}
登入後複製

大家从上面的示例中可以发现什么问题呢?

我们知道事件会触发Angular的变化检测,在示例中绑定 keydown 事件后,每一次键盘输入都会触发变化检测,而这些变化检测大多数都是多余的检测,只有当按键为 Enter 时,才需要真正的进行变化检测。

在这种情况下,我们就可以利用 NgZone.runOutsideAngular() 来减少变化检测的次数。

@Directive({
    selector: &#39;[enter]&#39;
})
export class ThyEnterDirective implements OnInit {
    @Output() enter = new EventEmitter();

    constructor(private ngZone: NgZone, private elementRef: ElementRef<HTMLElement>) {}

    ngOnInit(): void {
        // 包裹代码将运行在Zone区域之外
        this.ngZone.runOutsideAngular(() => {
            this.elementRef.nativeElement.addEventListener(&#39;keydown&#39;, (event: KeyboardEvent) => {
                const keyCode = event.which || event.keyCode;
                if (keyCode === 13) {
                    this.ngZone.run(() => {
                        this.enter.emit(event);
                    });
                }
            });
        });
    }
}
登入後複製

场景2:

假如我们使用 WebSocket 将大量数据从后端推送到前端,则相应的前端组件应仅每 10 秒更新一次。在这种情况下,我们可以通过调用 detach() 和手动触发它来停用更改检测detectChanges()

constructor(private cdr: ChangeDetectorRef) {
    cdr.detach(); // 停用变化检测
    setInterval(() => {
      this.cdr.detectChanges(); // 手动触发变化检测
    }, 10 * 1000);
  }
登入後複製

当然使用 ngZone.runOutsideAngular() 也可以处理这种场景。

脱离 Zone.js 开发

之前我们说了Angular 可以自动帮我们进行变化检测,这主要是基于Zone.js来实现,那么很多人潜意识会任务Zone.js 就是 Angular 是一部分,Angular的 应用程序必须基于Zone.js,其实不然,如果我们对应用有极高的性能要求时,我们可以选择移除 Zone.js,移除Zone.js 将会提升应用的性能和打包的体积,不过带来的后果就是我们需要主要去调用变化检测。

如何移除 Zone.js?

手动调用变化检测

在 Ivy 之后,我们有一些新的API可以更方便的调用变化检测

**ɵmarkDirty: ** 标记一个组件为 dirty 状态 (需要重新渲染) 并将在未来某个时间点安排一个变更检测

ɵdetectChanges: 因为某些效率方面的原因,内部文档不推荐使用 ɵdetectChanges 而推荐使用 ɵmarkDirtyɵdetectChanges 会触发组件以子组件的变更检测。

移除后的性能

移除Zone.js后变化检测由应用自己来控制,极大的减少了不必要的变化检测次数,同时打包后的提及也减少了 36k

移除前:

移除后:

测试与变化检测

组件绑定

我们先来看一个组件绑定的例子:

按我们正常开发组件的想法,当看到这个示例的时候一定认为这个Case是Ok的,但是在运行测试后我们发现这个Case失败了。

在生产环境中,当 Angular 创建一个组件,就会自动进行变更检测。 但是在测试中,**TestBed.createComponent()** 并不会进行变化检测,需要我们手动触发。

修改一下上面的Case:

origin-url0.00KB

origin-url0.00KB

从上面的示例中可以了解到,我们必须通过调用 fixture.detectChanges() 来告诉 TestBed 执行数据绑定。

如果我们在测试中动态改变了绑定值,同样也需要调用 fixture.detectChanges()

it("should update title", () => {
    component.title = &#39;Test Title&#39;;
    fixture.detectChanges();
    const h1 = fixture.nativeElement.querySelector("h1");
    expect(h1.textContent).toContain(&#39;Test Title&#39;);
});
登入後複製

自动变更检测

我们发现写测试过程中需要频繁的调用 fixture.detectChanges() ,可能会觉得比较繁琐,那 Angular 可不可以在测试环境中自动运行变化检测呢?

我们可以通过配置 ComponentFixtureAutoDetect 来实现

TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
});
登入後複製

然后再回头看看刚才的示例:

上面的示例我们并没有调用 fixture.detectChanges() ,但是测试依然通过了,这是因为我们开启了自动变化检测。

再看一个示例:

 上面的範例中,我們在測試程式碼中動態修改了title 的值,測試運行失敗,這是因為Angular 並不知道測試改變了元件, ComponentFixtureAutoDetect  只對非同步操作進行自動變化偵測,例如Promise、setTimeout、click 等DOM事件等,如果我們手動更改了綁定值,我們依然還需要呼叫 fixture.detectChanges()  來執行變化偵測。

常見的坑

ngModel

 上面這個範例,綁定值修改後調用了 fixture.detectChanges() , 但是運行測試後仍然報錯,這是為什麼呢?

 查看Angular原始碼後我們發現** ngModel 的值是透過非同步更新的** ,執行fixture.detectChanges() 後雖然觸發了變化檢測,但是值還並未修改成功。

修改一下測試:

 修改後我們將斷言包裹在了 fixture.whenStable()  中,然後測試通過,那 whenStable()  是什麼呢?

whenStable(): Promise : 當夾具穩定時解析的承諾 當事件已觸發非同步活動或非同步變更偵測後,可用此方法繼續執行測試。

#當然除了用 fixture.whenStable()  我們也可以用 tick()  來解決這個問題

#tick() :為fakeAsync Zone 中的計時器模擬非同步時間流逝在此函數開始時以及執行任何計時器回呼之後,微任務佇列就會耗盡

#測試OnPush 元件

 上面這個範例,我們在修改屬性後呼叫了 fixture.detectChanges()  ,但是測試未通過,這是為什麼呢?我們發現這個範例與第一個範例唯一的差異就是這個元件是一個 OnPush 元件,之前我們說過預設變更偵測會跳過 OnPush 元件的,只有在特定的幾種情況下才會觸發變化檢測的,遇到這種情況該如何解決呢?

 我們可以手動取得元件的 ChangeDetectorRef  來主動觸發變更偵測。

延伸

虛擬DOM與增量DOM

#Angular Ivy 是新的Angular 渲染器,它與我們在主流框架中看到的任何東西都截然不同,因為它使用增量DOM。增量DOM是什麼呢?它與虛擬Dom有什麼不同呢?

虛擬DOM

先說虛擬DOM,我們要了解在瀏覽器中,直接操作Dom就是十分損耗效能的,而虛擬DOM 的主要概念是將UI的虛擬表示保存在記憶體中,透過Diff 操作對比當前記憶體和上次記憶體中視圖的差異,從而減少不必要的Dom操作,只針對差異的Dom進行更改。

虛擬DOM執行流程:

  • 當 UI 變更時,將整個 UI 渲染到 Virtual DOM 中。

  • 計算先前和目前虛擬 DOM 表示之間的差異。

  • 使用更改更新真實的 DOM。

虛擬 DOM 的優點:

  • ##高效率的 Diff 演算法。

  • 簡單且有助於提高效能。

  • 沒有React 也可以使用

  • 夠輕量

  • 允許建立應用程式且不考慮狀態轉換

增量DOM

增量Dom的主要概念是將元件編譯成一系列的指令,這些指令去建立DOM樹並在資料更改時就地的更新它們。

 例如:

@Component({
  selector: &#39;todos-cmp&#39;,
  template: `
    <p *ngFor="let t of todos|async">
        {{t.description}}
    </p>
  `
})
class TodosComponent {
  todos: Observable<Todo[]> = this.store.pipe(select(&#39;todos&#39;));
  constructor(private store: Store<AppState>) {}
}
登入後複製

编译后:

var TodosComponent = /** @class */ (function () {
  function TodosComponent(store) {
    this.store = store;
    this.todos = this.store.pipe(select(&#39;todos&#39;));
  }

  TodosComponent.ngComponentDef = defineComponent({
    type: TodosComponent,
    selectors: [["todos-cmp"]],
    factory: function TodosComponent_Factory(t) {
      return new (t || TodosComponent)(directiveInject(Store));
    },
    consts: 2,
    vars: 3,
    template: function TodosComponent_Template(rf, ctx) {
      if (rf & 1) { // create dom
        pipe(1, "async");
        template(0, TodosComponent_p_Template_0, 2, 1, null, _c0);
      } if (rf & 2) { // update dom
        elementProperty(0, "ngForOf", bind(pipeBind1(1, 1, ctx.todos)));
      }
    },
    encapsulation: 2
  });

  return TodosComponent;
}());
登入後複製

增量DOM的优点:

  • 渲染引擎可以被Tree Shakable,降低编译后的体积

  • 占用较低的内存

为什么可渲染引擎可以被 Tree Shakable?

Tree Shaking 是指在编译目标代码时移除上下文中未引用的代码 ,增量 DOM 充分利用了这一点,因为它使用了基于指令的方法。正如示例所示,增量 DOM 在编译之前将每个组件编译成一组指令,这有助于识别未使用的指令。在 Tree Shakable 过程中,可以将这些未使用的的指令删除掉。

减少内存的使用

与虚拟 DOM 不同,增量 DOM 在重新呈现应用程序 UI 时不会生成真实 DOM 的副本。此外,如果应用程序 UI 没有变化,增量 DOM 就不会分配任何内存。大多数情况下,我们都是在没有任何重大修改的情况下重新呈现应用程序 UI。因此,按照这种方法可以极大的减少设备内存使用。

总结

至此,Angular 变化检测相关的内容就介绍完了,这是我在公司内部 2个小时的分享内容,在准备的过程中参考了很多优秀的资料,自己也学习到了更深层,更细节的一些技术点。如果大家有不理解的,欢迎在评论区沟通,如果有需要改正的地方,也欢迎大家指出,希望这篇文章可以帮助大家更好的理解Angular的变化检测。

更多编程相关知识,请访问:编程学习!!

以上是帶你深入聊聊Angular中的變化檢測的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:csdn.net
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板
關於我們 免責聲明 Sitemap
PHP中文網:公益線上PHP培訓,幫助PHP學習者快速成長!