Angular 18+ 高階教程 – Component 元件 の @let Template Local Variables

兴杰發表於2024-06-27

前言

Angular 在 v18.1 推出了 Template 新語法 @let

這個 @let 和上一篇教的 Control Flow @if, @for, @swtich, @defer 語法上類似,但是用途卻差很多。

如果要給它們分類的話,@if @for @switch 可以算一類,@defer 獨立一類,@let 又是獨立一類,總之大家不要混淆就是了。

沒有 @let 的日子

我們先來看看沒有 @let 的日子裡,我們都遇到了哪些不方便。

Long path access

App 元件

export class AppComponent {
  person = signal({
    name: 'Derrick',
    address: {
      country: 'Malaysia',
      state: 'Johor',
      postalCode: '81300'
    }
  })
}

有一個 person 物件,它裡面又有一個 address 物件。

App Template

<p>{{ person().name }}</p>
<p>{{ person().address.country }}</p>
<p>{{ person().address.state }}</p>
<p>{{ person().address.postalCode }}</p>

上述程式碼最大的問題就是 person().address 一直重複,程式碼很長,很醜。

倘若在 JS 裡的話,我們一定會 declare 一個 variable 把 address 物件裝起來,像這樣訪問

const address = person.address;
console.log(address.country);
console.log(address.state);
console.log(address.postalCode);

沒有一直重複 person.address 乾淨多了。

但是在 Template 裡,我們無法 declare variable,能 declare variable 的只有元件

export class AppComponent {
  person = signal({
    name: 'Derrick',
    address: {
      country: 'Malaysia',
      state: 'Johor',
      postalCode: '81300'
    }
  });
  address = computed(() => this.person().address);
}

App Template

<p>{{ person().name }}</p>
<p>{{ address().country }}</p>
<p>{{ address().state }}</p>
<p>{{ address().postalCode }}</p>

雖然 path 是短了,但是元件卻為了 View 而多了一個 property,這樣的職責分配合理嗎?

再說,如果是在 @for 裡面

@for (person of people(); track person.name) {
  <p>{{ person.name }}</p>
  <p>{{ person.address.country }}</p>
  <p>{{ person.address.state }}</p>
  <p>{{ person.address.postalCode }}</p>
}

元件 property 也無法解決這個問題丫。

the hacking way

有些人會用 hacking way 來做到這一點,像這樣

@for (person of people(); track person.name) {
  <p>{{ person.name }}</p>

  @if (person.address; as address) {
    <p>{{ address.country }}</p>
    <p>{{ address.state }}</p>
    <p>{{ address.postalCode }}</p>
  } 
}

雖然 path 是短了,但是 @if 是這樣用的嗎?這種不順風水的用法,隨時會掉坑裡的,忌諱啊。

Async pipe

App 元件

export class AppComponent {
  value$ = of('hello world');
}

有一個 value stream。

App Template

<header>
  <p>{{ value$ | async }}</p>
</header>
<main>
  <p>body</p>
</main>
<footer>
  <p>{{ value$ | async }}</p>
</footer>

我要在 header 和 footer 顯示這個 value。

上述程式碼有兩個問題:

  1. 兩次 pipe async 會導致 value stream 被 subscribe 兩次。

    如果這個 value 需要發 ajax,那它就會發 2 次 ajax。

  2. 不管是使用什麼 pipe 都好,每一次使用 value 都要重複同樣的 pipe,這就不合理。

    而,倘若我們把職責交給元件,那元件就需要使用 pipe,這樣也不合理。

    總之,怎樣都不合理,這就是一個設計失誤。

總結

Angular 對 Template 限制太多了,以至於許多簡單的 View 邏輯無法在 Template 裡完成,必須轉交給不合適的元件,或者大費周章的指令去完成。

這也是為什麼這個 issue 能常年霸榜

幸好,在經歷了 2657 個日夜🙄,Angular 團隊終於解決了上述這些問題。這就是本篇的主角 -- @let。

@let 的使用方式

@let 允許我們在 Template 裡 declare variables,就這麼一個簡簡單單的功能。

@let value = 'hello world';
<h1>{{ value }}</h1>

效果

@let 的語法

@let 開頭

跟著一個 variable name

然後等於 =

然後一個 Angular Template 支援的 expression。

最後 ends with ; 分號

@let 解決 long path access 問題

@for (person of people(); track person.name) {
  <p>{{ person.name }}</p>

  @let address = person.address;

  <p>{{ address.country }}</p>
  <p>{{ address.state }}</p>
  <p>{{ address.postalCode }}</p>
}

瞬間乾淨了😊

@let 解決 pipe async 問題

@let value = value$ | async;

<header>
  <p>{{ value }}</p>
</header>
<main>
  <p>body</p>
</main>
<footer>
  <p>{{ value }}</p>
</footer>

只有一次 pipe async,所以只會 subscribe 一次,完美😊

@let 的特性

@let 的語法和意圖都很簡單,所以使用上是沒有什麼問題的,只是有一些特性大家還是需要知道,避免掉坑。

Must delcare in Template top layer

下面這樣直接報錯

<div class="container">
  @let value = 'Hello World';
  <p>{{ value }}</p>
</div>

因為 @let 只能 declare 在 Template 的最上層。但不要誤會哦,最上層的意思不是說要在第一行,只要不是被 element wrap 起來就可以了。

下面這樣是 ok 的

<h1>Hello World</h1>

@let value = 'Hello World';
<p>{{ value }}</p>

Immuable

雖然它叫 @let,但是它是不可以被賦值的。

下面這樣直接報錯

<h1>Hello World</h1>

@let value = 'Hello World';
<p>{{ value }}</p>

<button (click)="value = 'new value'">update value</button>

不要問我為什麼 svelte 叫 @const 🤷‍♂️

Angular 團隊給出的解釋

const 代表這個值永遠不會變,但是如果我們這樣 declare

@let fullName = firstName() + ' ' + lastName();

每一次 renderView 後,fullName 的 value 都有可能不一樣,所以他們認為叫 @let 比較合理。

關鍵不是我們能不能改變這個 variable,而是這個 variable 的值會不會被改變,大家看的角度不同。

@let = signal 可以賦值?

如果 @let 的值是 signal,我們當然可以呼叫 WritableSignal.set 方法給 signal 賦值。

因為這不是賦值給 @let 而是賦值給 signal。

App 元件

export class AppComponent {
  $value = signal('default value');
}

App Template

@let value = $value;
<p>{{ value() }}</p>
<button (click)="value.set('new value')">update value</button>

效果

但是!請不要妄想利用這個特性讓 @let 實現 mutable。

App 元件

export class AppComponent {
  signal = signal;
}

把 signal 函式傳給 Template

App Template

@let value = signal('default value');
<p>{{ value() }}</p>
<button (click)="value.set('new value')">update value</button>

表明上看,只要把 signal 函式傳給 Template,在 Template @let 時使用 signal,這樣 @let 就好像等同於 mutable 了。

效果

雖然成功顯示了 default value,但是點選 button 並不會修改成 new value。why?

原理我們留到下面逛原始碼時解答。

Duplicated declare

同樣的 variable name 不可以重複 declare,下面這樣直接報錯。

@let value = 'value';
@let value = 'new value';

也不應該撞 Template Variables

@let value = 'hello world';
<input #value>
{{ value }}

最終會使用 @let 還是 input ref 我不曉得,目前 18.1.0-next.4 好像有 Bug

也不應該撞元件屬性

export class AppComponent {
  value = 'component value';
}
@let value = 'hello world';

{{ value }}

雖然它不會報錯,優先顯示的是 @let 的值,但是撞名字始終不是好現象,能免則免吧。

Template as scope

@let 是有 scope 概念的,每一個 Template 都可以 declare 屬於自己 scope 的 @let

@let value1 = 'one';

<ng-template #template>
  @let value2 = 'two';

  <p>{{ value1 }}</p>
  <p>{{ value2 }}</p>
</ng-template>

<ng-container [ngTemplateOutlet]="template" />

效果

裡面有幾個知識點:

  1. 子層可以訪問父層的 @let

    比如,在 ng-template 裡可以使用 value1。

    當然,反過來就不行,ng-temaplte 外不可以使用 value2。

  2. 父層和子層可以 declare 相同的 variable name,子層會勝出。

    @let value1 = 'one';
    <ng-template #template>
      @let value1 = 'two';
      <p>{{ value1 }}</p> <!-- will be two -->
    </ng-template>

    注:目前 18.1.0-next.4 好像有 Bug

When will @let be updated?

@let 屬於 LView,當 LView refresh 時,它就會被重新整理。下一 part 逛原始碼時會看到這一點。

逛一逛 @let 原始碼

App Template

@let value = 'Hello World'
<p>{{ value }}</p>

compile

yarn run ngc -p tsconfig.json

app.component.js

在 renderView 階段,有一個 ɵɵdeclareLet。

在 refreshView 階段 declare variable with 'Hello World',然後把 variable 傳給 ɵɵtextInterpolate。

所有工作 compiler 做完了。runtime 執行 refreshView 就可以了。

我們這個例子太過簡單,ɵɵdeclareLet 其實可有可無,可以看到 refreshView 裡的程式碼已經足夠渲染出正確的畫面了。

我們來一個比較複雜的例子

@let name = person().name;
<p>{{ name }}</p>

<ng-template>
  <p>{{ name }}</p>
</ng-template>

主要是多了一個 ng-template,在 ng-template 內使用到了父層的 @let name。

app.component.js

首先是 renderView 階段 ɵɵdeclareLet 函式的原始碼在 let_declaration.ts

store 函式的原始碼 storage.ts

renderView 階段主要做了兩件事,

第一件是建立 TNode 插入到 App TView.data 裡。

第二件是插入一個初始值到 App LView 裡。

接著到 refreshView 階段

ɵɵstoreLet 函式的原始碼在 let_declaration.ts

這時會把 App 元件例項的值寫入 LView @let 的位置。

我們看回 App template 方法 refreshView 的部分

name_r2 的 value 就是 'Derrick',然後它被傳給了 ɵɵtextInterpolate。

也就是說,到目前位置,上面記入到 TView LView 裡的資料都沒有人在用。

正真會從 LView 裡取出 @let value 來使用的人是子層 ng-template 裡的 {{ name }}

我們看看它 compile 後的 template 方法。

renderView 階段和 @let 毫無關係。

refreshView 階段,ɵɵnextContext 函式我們以前講解過。

它主要是設定了當前 LView (ng-template LView) 的 contextLView

這個 contextLView 就是 ng-template LView 的 declaration view 也就是 App LView。

接著是 ɵɵreadContextLet 函式,它的原始碼在 let_declaration.ts

簡單說就是去父層 LView 拿出 @let value。

總結

  1. 在 App Template 裡 declare @let。

  2. renderView 階段

    它會建立 @let TNode,存放在 App TView.data 裡。

    它會建立 @let initial value,存放到 App LView 裡。

  3. refreshView 階段

    它會從 App 例項獲取到 @let value,然後 update App LView 裡的 @let value。(注:從這裡也可以看出,假如 @let = complex formula 是會影響效能的,因為每一次 refreshView 它都會重跑 formula)

  4. LView 裡的 @let value 並不是給當前 LView binding 使用的,當前 binding 會直接用 const variable。

  5. LView 裡的 @let 是給子層 LView binding 使用的。

    子層 LView 在 refreshView 時會透過 contextLView 來到父層 LView,然後取出 @let value。

解答 @let = signal 可以賦值?

上一 part 我們留了一道問題。

App 元件

export class AppComponent {
  signal = signal;
}

把 signal 函式傳給 Template

App Template

@let value = signal('default value');
<p>{{ value() }}</p>
<button (click)="value.set('new value')">update value</button>

為什麼點選 button 後沒有變成 'new value'?

其實很好理解:

  1. 當 refreshView 的時候 @let value 會獲得一個新的 signal,value 是 'default value'。

  2. {{ value() }} 會渲染出 'default value'。

  3. 點選以後 value signal 會被賦值 'new value'。

  4. 同時會觸發新一輪的 refreshView。

  5. 結果新一輪的 refreshView 又重新給 @let value 一個新的 signal,value 是 'default value'。

  6. 這樣就輪迴了,所以 {{ value() }} 永遠不可能渲染出 'new value'。

總結

本篇簡單的介紹了 Angular v18.1 推出的 @let 新模板語法。它主要的功能是讓我們能在 Template declare variables,這能讓程式碼變得更乾淨,職責管理更分明。

它主要是靠 compiler 實現的,runtime 中,它只是在 LView 做一些記入,然後使用它,這樣而已。

目錄

上一篇 Angular 18+ 高階教程 – Component 元件 の Control Flow

下一篇 Angular 18+ 高階教程 – NgModule

想檢視目錄,請移步 Angular 18+ 高階教程 – 目錄

喜歡請點推薦👍,若發現教程內容以新版脫節請評論通知我。happy coding 😊💻

相關文章