譯文 | Angular中的AoT編譯

前兩天,Jigsaw七巧板上來了個issue https://github.com/rdkmaster/jigsaw/issues/113(https://github.com/jackjoy) 在issue中提到了一篇介紹Angular AoT的文章,我看了一下,覺得講的非常好,還涉及到一些Angular編譯原理的內容。于是打算翻譯一下,讓大伙都能夠讀一讀,多了解一點AoT知識。

文中的第一人稱“我”均指代作者本人(http://blog.mgechev.com)

原文地址是 http://blog.mgechev.com/2016/08/14/ahead-of-time-compilation-angular-offline-precompilation/

最近我給angular-seed增加了對Ahead-of-Time(AoT)編譯的支持,這引來了不少關于這個新特性的問題。我們從下面這些話題開始來回答這些問題:

  • 為什么Angular需要編譯?
  • 什么東西會被編譯?
  • 他們是如何被編譯的?
  • 編譯發(fā)生在什么時候?JiT vs AoT
  • 我們從AoT中獲得了什么?
  • AoT編譯是如何工作的?
  • 我們使用AoT和JiT的代價是什么?

為什么Angular需要編譯?

這個問題的簡短回答是:編譯可以讓Angular應用達到更高層度的運行效率,我所說的效率,主要是指的性能提升,但也包括電池節(jié)能和節(jié)省流量。

AngularJs1.x 有一個實現渲染和變化檢測的很動態(tài)的方式,比如AngularJs1.x的編譯器非常通用,它被設計為任何模板實現一系列的動態(tài)計算,雖然它在通常情況下運行的很好,但是JS虛擬機的動態(tài)特性讓一些低層次的計算優(yōu)化變得很困難。由于js虛擬機無法理解那些我們作為臟檢查的上下文對象(術語為scope)的形態(tài),虛擬機的內聯緩存常常不精確,這導致了運行效率的下降

譯者:scope是AngularJs1.x中的一個重要對象,他是AngularJs1.x用于計算模板的上下文。

Angular2+采用了一個不同的方式。在給每個組件做渲染和變化檢測的時候,它不再使用同一套邏輯,框架在運行時或者編譯時會生成對js虛擬機友好的代碼。這些友好的代碼可以讓js虛擬機在屬性訪問的緩存,執(zhí)行變化檢查,進行渲染的邏輯執(zhí)行的快的多。

舉個例子,看看下面的代碼:

// ...
Scope.prototype.$digest = function () {
  'use strict';
  var dirty, watcher, current, i;
  do {
    dirty = false;
    for (i = 0; i < this.$$watchers.length; i += 1) {
      watcher = this.$$watchers[i];
      current = this.$eval(watcher.exp);
      if (!Utils.equals(watcher.last, current)) {
        watcher.last = Utils.clone(current);
        dirty = true;
        watcher.fn(current);
      }
    }
  } while (dirty);
  for (i = 0; i < this.$$children.length; i += 1) {
    this.$$children[i].$digest();
  }
};
// ...

這個代碼片段來自《輕量級angularJs1.x實現》一文。這些代碼實現了對scope樹做深度優(yōu)先搜索,目的是為了尋找綁定數據的變化,這個方法對任何指令都生效。這些代碼顯然比下面這些直接指定檢查的代碼慢:

// ...
var currVal_6 = this.context.newName;
if (import4.checkBinding(throwOnChange, this._expr_6, currVal_6)) {
    this._NgModel_5_5.model = currVal_6;
    if ((changes === null)) {
        (changes = {});
    }
    changes['model'] = new import7.SimpleChange(this._expr_6, currVal_6);
    this._expr_6 = currVal_6;
}
this.detectContentChildrenChanges(throwOnChange);
// ...

譯者:

《輕量級angularJs1.x實現》的地址是 <https://github.com/mgechev/light-angularjs/blob/master/lib/Scope.js#L61-L79

這里一下子提及了angularJs1.x的好幾個概念,包括scope,數據綁定,指令。不熟悉angularJs1.x的同學理解起來費勁,想弄懂的話,自行搜索吧。個人認為可以無視,畢竟這個文章的重點不是在這里。你就認為Angular2+的處理方式比angularJs1.x牛逼很多就好了,哈哈。

上面代碼包含了一個來自angular-seed的某個編譯后的組件的代碼,這些代碼是由編譯器生成的,包含了一個 detectChangesInternal 方法的實現。Angular框架通過直接屬性訪問的方式讀取了數據綁定中的某些值,并且采用了最高效的方式與新的值做比較。一旦Angular框架發(fā)現這些值發(fā)生了變化,它就立即更新只受這些數據波及的DOM元素。

在回答了“為什么Angular需要編譯”這個問題的同時,我們同時也回答了“什么東西會被編譯”這個問題。我們希望把組件的模板編譯成一個JS類,這些類包含了在綁定的數據中檢測變化和渲染UI的邏輯。通過這個方式,我們和潛在的平臺解耦了。換句話說,通過對渲染器采取了不同的實現,我們在不對代碼做任何的修改的前提下,就可以對同一個以AoT方式編譯的組件做不同的渲染。比如,上述代碼中的組件還可以直接用在NativeScript中,這是由于這些不同的渲染器都能夠理解編譯后的組件。

編譯發(fā)生在什么時候?JiT 還是 AoT

Angular編譯器最cool的一點是它可以在頁面運行時(例如在用戶的瀏覽器內)啟動,也可以作為構建的一個步驟在頁面的編譯時啟動。這主要得益于Angular的可移植性:我們可以在任何的平臺的JS虛擬機上運行Angular,所以我們完全可以在瀏覽器和NodeJs中運行它。

JiT編譯模式的流程

一個典型的非AoT應用的開發(fā)流程大概是:

  • 使用TypeScript開發(fā)Angular應用
  • 使用tsc來編譯這個應用的ts代碼
  • 打包
  • 壓縮
  • 部署

一旦把app部署好了,并且用戶在瀏覽器中打開了這個app,下面這些事情會逐一進行:

  • 瀏覽器下載js代碼
  • Angular啟動
  • Angular在瀏覽器中開始JiT編譯的過程,例如生成app中各個組件的js代碼
  • 應用頁面得以渲染

AoT編譯模式的流程

相對的,使用AoT模式的應用的開發(fā)流程是:

  • 使用TypeScript開發(fā)Angular應用
  • 使用ngc來編譯應用
    • 使用Angular編譯器對模板進行編譯,生成TypeScript代碼
    • TypesScript代碼編譯為JavaScript代碼
  • 打包
  • 壓縮
  • 部署

雖然前面的過程稍稍復雜,但是用戶這一側的事情就變簡單了:

  • 下載所以代碼
  • Angular啟動
  • 應用頁面得以渲染

如你所見,第三步被省略掉了,這意味著頁面打開更快,用戶體驗也更好。類似Angular-cli和Angular-seed這樣的工具可以讓整個編譯過程變的非常的自動化。

概括起來,Angular中的Jit和AoT的主要區(qū)別是:

  • 編譯過程發(fā)生的時機
  • JiT生成的是JS代碼,而AoT生成的是TS代碼。這主要是因為JiT是在瀏覽器中進行的,它完全沒必要生成TS代碼,而是直接生產了JS代碼。

你可以在我的Github賬號中找到一個最小的AoT編譯demo,鏈接在這里 https://github.com/mgechev/angular2-ngc-rollup-build

深入AoT編譯

這個小節(jié)回答了這些問題:

  • AoT編譯過程產生了什么文件?
  • 這些產生的文件的上下文是什么?
  • 如何開發(fā)出AoT友好又有良好封裝的代碼?

@angular/compiler的代碼一行一行的解釋沒太大意義,因此我們僅僅來快速過一下編譯的過程。如果你對編譯器的詞法分析過程,解析和生成代碼過程等感興趣,你可以讀一讀Tobias Bosch的《Angular2編譯器》一文,或者它的膠片。

譯者:

《Angular2編譯器》一文鏈接 https://www.youtube.com/watch?v=kW9cJsvcsGo

它的膠片鏈接 https://speakerdeck.com/mgechev/angular-toolset-support?slide=69

Angular模板編譯器收到一個組件和它的上下文(可以這認為是組件在組件樹上的位置)作為輸入,并產生了如下文件:

  • *.ngfactory.ts 我們在說明組件上下文的小節(jié)會仔細看看這些文件
  • *.css.shim.ts 樣式作用范圍被隔離后的css文件,根據組件所設置的 ViewEncapsulation 模式不同而會有不同
  • *.metadata.json 當前組件/模塊的裝飾器元數據信息,這些數據可以被想象成以json格式傳遞給 @Component @NgModule 裝飾器的信息。

* 是一個文件名占位符,例如對于hero.component.ts這樣的組件,編譯器生成的文件是 hero.component.ngfactory.ts, hero.component.css.shim.tshero.component.metadata.json*.css.shim.ts和我們討論的主題關系不大,因此不會對它詳細描述。如果你希望多了解 *.metadata.json 文件,你可以看看“AoT和第三方模塊”小節(jié)。

*.ngfactory.ts 的內部結構

它包含了如下的定義:

  • _View_{COMPONENT}_Host{COUNTER} 我們稱之為internal host component
  • _View_{COMPONENT}{COUNTER} 我們稱之為 internal component

以及下面兩個函數

  • viewFactory_{COMPONENT}_Host{COUNTER}
  • viewFactory_{COMPONENT}{COUNTER}

其中的 {COMPONENT} 是組件的控制器名字,而 {COUNTER} 是一個無符號整數。他們都繼承了 AppView,并且實現了下面的方法:

  • createInternal 組件的渲染器
  • destroyInternal 執(zhí)行事件監(jiān)聽器等的清理
  • detectChangesInternal 以內聯緩存優(yōu)化后的邏輯執(zhí)行變化檢測

上述這些工廠函數只在生成的AppView實例中才存在。

我前面說過,detectChangesInternal中的代碼是JS虛擬機友好的。

<div>{{newName}}</div>
<input type="text" [(ngModel)]="newName">

我們來看看編譯后這個模板的代碼,detectChangesInternal方法的代碼看起來像是這樣的:

// ...
var currVal_6 = this.context.newName;
if (import4.checkBinding(throwOnChange, this._expr_6, currVal_6)) {
    this._NgModel_5_5.model = currVal_6;
    if ((changes === null)) {
        (changes = {});
    }
    changes['model'] = new import7.SimpleChange(this._expr_6, currVal_6);
    this._expr_6 = currVal_6;
}
this.detectContentChildrenChanges(throwOnChange);
// ...

假設currVal_6的值是3,this_expr_6的值是1,我們來跟蹤看看這個方法的執(zhí)行。對于這樣的一個調用 import4.checkBinding(1, 3),在生產環(huán)境下,checkBinding 執(zhí)行的是下面的檢查:

1 === 3 || typeof 1 === 'number' && typeof 3 === 'number' && isNaN(1) && isNaN(3);

上述表達式返回false,因此我們將把變化保持下來,以及直接更新 NgModel 的屬性 model 的值,在這之后,detectContentChildrenChanges 方法會被調用,它將為整個模板內容的子級調用 detectChangesInternal。一旦 NgModel 指令發(fā)現了 model 屬性發(fā)生了變化,它就會(幾乎)直接調用渲染器來更新對應的DOM元素。

目前為止,我們還沒有碰到任何特殊的,或者特別復雜的邏輯。

context 屬性

也許你已經注意到了在internal component內部訪問了 this.context 屬性。

譯者:internal component 指的前一小節(jié)的 _View_{COMPONENT}{COUNTER} 函數

internal component中的 context 是這個組件的控制器的實例,例如這樣的一個組件:

@Component({
  selector: 'hero-app',
  template: '<h1>{{ hero.name }}</h1>'
})
class HeroComponent {
  hero: Hero;
}

this.context 就是 new HeroComponent(),這意味著如果在 detectChangesInternal 中我們需要訪問 this.context.name 的話,就帶來了一個問題:如果我們使用AoT模式編譯組件的模板,由于這個模式會生成TypeScript代碼,因此我們要確保在組件的模板中只訪問 this.context 中的public成員。這是為何?由于TypeScript的類屬性有訪問控制,強制類外部只能訪問類(及其父類)中的public成員,因此在internal component內部我們無法訪問 this.context 的任何私有成員。因此,下面這個組件:

@Component({
  selector: 'hero-app',
  template: '<h1>{{ hero.name }}</h1>'
})
class HeroComponent {
  private hero: Hero;
}

以及這個組件

class Hero {
  private name: string;
}

@Component({
  selector: 'hero-app',
  template: '<h1>{{ hero.name }}</h1>'
})
class HeroComponent {
  hero: Hero;
}

在生成出來的 *.ngfactory.ts 中,都會拋出編譯錯誤。第一個組件代碼,internal component無法訪問到在 HeroComponent 類中被聲明為 private 的 hero 屬性。第二個組件代碼中,internal component無法訪問到 hero.name 屬性,因為它在 Hero 類中被聲明為private。

AoT與封裝

好吧,我們只能在組件模板中綁定public屬性,以及調用public方法。但是,如何確保組件的封裝性?在開始的時候,這可能不是一個大問題,但是想象一下下面這個場景:

// component.ts
@Component({
  selector: 'third-party',
  template: `
    {{ _initials }}
  `
})
class ThirdPartyComponent {
  private _initials: string;
  private _name: string;

  @Input()
  set name(name: string) {
    if (name) {
      this._initials = name.split(' ').map(n => n[0]).join('. ') + '.';
      this._name = name;
    }
  }
}

這個組件有一個屬性 name,它只能寫入而無法讀取。在 name 屬性的 setter 方法中,計算了 _initials 屬性的值。

我們可以用類似下面的方式使用這個組件:

@Component({
  template: '<third-party [name]="name"></third-party>'
  // ...
})
// ...

在JiT編譯模式下,一切正常,因為JiT模式只生成JavaScript代碼。每次 name 屬性的值發(fā)生變化,_initials 就會被重新計算。但是,這個組件卻不是AoT友好的,必須改為:

// component.ts
@Component({
  selector: 'third-party',
  template: `
    {{ initials }}
  `
})
class ThirdPartyComponent {
  initials: string;
  private _name: string;

  @Input()
  set name(name: string) {...}
}

codelyzer 這個工具可以確保你在模板中每次都能訪問到public成員。

這讓組件的使用者可以這樣做:

import {ThirdPartyComponent} from 'third-party-lib';

@Component({
  template: '<third-party [name]="name"></third-party>'
  // ...
})
class Consumer {
  @ViewChild(ThirdPartyComponent) cmp: ThirdPartyComponent;
  name = 'Foo Bar';

  ngAfterViewInit() {
    this.cmp.initials = 'M. D.';
  }
}

對public屬性 initials 的直接修改導致了組件處于不一致性的狀態(tài):組件的 _name 的值是 Foo Bar,但是它的 initials 的值是 M. D.,而非 F. B.。

在Angular的源碼中,我們可以找到解決的辦法,使用TypeScript的 /** @internal */ 注釋聲明,就能夠達到既保證組件代碼對AoT友好,又能夠確保組件的封裝良好的目的。

// component.ts
@Component({
  selector: 'third-party',
  template: `
    {{ initials }}
  `
})
class ThirdPartyComponent {
  /** @internal */
  initials: string;
  private _name: string;

  @Input()
  set name(name: string) {...}
}

initials 屬性仍然是public的。我們在使用 tsc 編譯這個組件時,設置 --stripInternal--declarations參數,initials 屬性就會從組件的類型定義文件(即 .d.ts 文件)中被刪掉。這樣我們就可以做到在我們的類庫內部使用它,但是我們的組件使用者無法使用它。

ngfactory.ts 概要

我們來對幕后所發(fā)生的一切做一些概要描述。拿我們前面的例子中的 HeroComponent 為例,Angular編譯器會生成兩個類:

  • _View_HeroComponent_Host1 這是 internal host component
  • _View_HeroComponent1 這是 internal component

_View_HeroComponent1 負責渲染這個組件的模板,以及進行變化檢測。在執(zhí)行變化檢測時,它會對 this.context.hero.name之前保存的值和當前值做比較,一旦發(fā)現這兩個值不一致,<h1/>元素就會被更新,這意味著我們必須保持 this.context.herohero.name 是public的。這一點可以通過 codelyzer 這個工具來輔助確保。

另外,_View_HeroComponent_Host1 則負責 <hero-app></hero-app>_View_HeroComponent1 本身的渲染。

這個例子可以以下面這個圖來總結:

AoT vs JiT 開發(fā)體驗

這個小結,我們來討論使用AoT開發(fā)和JiT開發(fā)的另一種體驗。

可能使用JiT對開發(fā)體驗的沖擊最大的就是JiT模式為internal componentinternal host component生成的是JavaScript代碼,這意味著組件的控制器中的屬性都是public的,因此我們不會得到任何編譯錯誤。

在JiT模式下,一旦我們啟動了應用,根組件的根注入器和所有的指令就已經準備就緒了(他們被包含在 BrowserModule 和其他所有我們在根模塊中引入的模塊中了)。元數據信息會傳遞給編譯器,用于對根組件的模板的編譯。一旦編譯器生成了JiT下的代碼,編譯器就擁有了用于生成各個子組件的所有元數據信息。由于編譯器此時不僅知道了當前層級的組件有那些provider可用,還可以知道那些指令是可見的,因此它可以給所有的組件生成代碼。

這一點讓編譯器在訪問了模板中的一個元素時,知道該怎么工作。根據是否有選擇器是 bar-baz 的指令/組件,<bar-baz></bar-baz> 這樣的一個元素就有了兩種不同的解釋。編譯器在創(chuàng)建了 <bar-baz></bar-baz> 這樣的一個元素的同時,是否還同時初始化 bar-baz 對應的組件類的實例,則完全取決于當前階段的編譯過程的元數據信息。

這里有一個問題,在編譯階段,我們如何知道指令在整個組件樹上是否可訪問?得益于Angular框架的良好設計,我們通過靜態(tài)代碼分析就可以做到。Chuck Jazdzewski 和 Alex Eagle 在這個方向上做出了令人驚嘆的成果,他們實現了 MetadataCollector 和相關的模塊。MetadataCollector 所做的事情就是通過遍歷組件樹來獲取每個組件和NgModule的元數據信息,這個過程中,很多牛逼的技術被用到,可惜這些技術超出了本文的范疇。

AoT與第三方模塊

為了編譯組件的模板,編譯器需要組件的元數據信息,我們來假設我們的應用使用到了一些第三方組件,Angular的AoT編譯器是如何獲取這些已經被編譯成JavaScript代碼的組件的元數據信息的?這些組件庫必須連帶發(fā)布對應的 *.metadata.json 文件,這樣才能夠對一個引用了它的頁面進行AoT編譯。

如果你想了解如何使用Angular編譯器,例如如何編譯你自定義庫使得他們能夠被用于以AoT編譯的應用,那請訪問這個鏈接 https://github.com/angular/mobile-toolkit/blob/master/app-shell/gulpfile.ts#L52-L54

我們從AoT中獲得了什么?

你可能已經想到了,AoT給我們帶來了性能的提升。以AoT方式開發(fā)的Angular應用的初次渲染性能要比JiT的高的多,這是由于JS虛擬機需要的計算量大大減少了。我們只在開發(fā)過沖中,將組件的模板編譯成js代碼,在此之后,用戶不需要等待再次編譯。

下面這個圖中,可以看出JiT渲染方式在初始化過程中所消耗的時間:

下面這個圖你可以看出AoT方式初始化在初始化過程中所消耗的時間:

Angular編譯器不僅能夠生產JavaScript,還能夠生成TypeScript,這一點還帶給我們要給非常棒的特性:在模板中進行類型檢查。

由于應用的模板是純JavaScript/TypeScript,我們可以精確的知道哪些東西在哪被用到了,這一點讓我們可以對代碼進行有效的搖樹操作,它能夠把所有的未使用過的指令/模塊從我們生產環(huán)境中的應用代碼包中給刪除掉。這首要的一點是,我們的應用程序代碼包中,再也無需包含 @angular/compiler 這個模塊,因為我們在應用的運行時根本就用不到它。

有一點需要注意的是,一個中大型的應用代碼包,在進行AoT編譯過之后,可能會比使用JiT方式編譯的代碼包要大一些。這是因為 ngc 生成的對JS虛擬機友好的代碼比基于HTML模板的代碼要冗長一些,并且這些代碼還包含了臟檢查邏輯。如果你想降低你的應用代碼的尺寸,你可以通過懶加載的方式來實現,Angular內建的路由已經支持這一點了。

在某些場合,JiT模式的編譯根本就無法進行,這是由于JiT在瀏覽器中,不僅生成代碼,它還使用 eval 來運行它們,瀏覽器的內容安全策略以及特定的環(huán)境不允許這些被生成的代碼被動態(tài)的運行。

最后一個但不是唯一的:節(jié)能!在接受到的是編譯后的代碼時,用戶的設備可以花更少的時間運行他們,這節(jié)約了電池的電力。節(jié)能的量有多少呢?下面是我做的一些有趣的計算的結果:

基于《Who Killed My Battery: Analyzing Mobile Browser Energy Consumption》這偏文章的結論,訪問Wikipedia時,下載和解析jQuery的過程大約需要消耗4焦耳的能量。這個文章沒有提及所使用的jQuery的確切版本,基于文章發(fā)表的日期,我估計版本號是1.8.x。Wikipedia采用了gzip對靜態(tài)資源做壓縮,這意味著jQuery1.8.3的尺寸約33k。而被最小化并且gzip壓縮后的 @angular/compiler 包的尺寸在103k,這意味著對這些代碼的下載和解析需要消耗12.5焦的能量(我們可以忽略JiT的運算還會增加能耗的事實,這是因為jQuery和@angular/compiler這兩個場景,都是使用了要給單一的TCP鏈接,這從是最大的能耗所在)。

iPhone6s的電池容量是6.9Wh,即24840焦。基于AngularJs1.x官網的月訪問量可以得知現在大約有一百萬名Angular開發(fā)者,平均每位開發(fā)者構建了5個應用,每個應用每天約100名用戶。5個app * 1m * 100用戶 = 500m,在使用JiT編譯這些應用,他們就需要下載 @angular/compiler 包,這將給地球帶來 500m * 12.5J = 6250000000J焦(=1736.111111111千瓦時)的能量消耗。根據Google搜索結果,1千瓦時約等于12美分,這意味著我們每天需要消耗約 210 美元。注意到我們還沒進一步對代碼做搖樹操作,這可能會讓我們的應用程序代碼降低至少一半!

結論

Angular的編譯器利用了JS虛擬機的內聯緩存機制,極大的提升了我們的應用程序的性能。首要的一點是我們把編譯過程作為構建應用的一個環(huán)節(jié),這不僅解決了非法eval的問題,還允許我們對代碼做高效的搖樹,降低了首次渲染的時間。

不在運行時編譯是否讓我們失去是什么嗎?在一些非常極端的場景下,我們會按需生成組件的模板,這就需要我們價值一個未編譯的組件,并在瀏覽器中執(zhí)行編譯的過程,在這樣的場景下,我們就需要在我們的應用代碼包中包含 @angular/compiler 模塊。AoT編譯的另一個潛在缺點是,它會造成中大型應用的代碼包尺寸變大。由于生成的組件模板的JavaScript代碼比組件模板本身的尺寸更大,這就可能造成最終代碼包的尺寸更大一些。

總的來說,AoT編譯是一個很好的技術,現在它已經被集成到了Angular-seed和angular-cli中,所以,你今天就可以去使用它了。

參考資料

題外話

這些文章都是我們在研發(fā)Jigsaw七巧板過程中的技術總結,如果你喜歡這個文章,請幫忙到 Jigsaw七巧板的工程上點個星星鼓勵我們一下(點擊閱讀原文看直達 https://github.com/rdkmaster/jigsaw),這樣我們會更有動力寫出類似高質量的文章。Jigsaw七巧板現在處于起步階段,非常需要各位的呵護。

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • core package 概要:Core是所有其他包的基礎包.它提供了大部分功能包括metadata,templa...
    LOVE小狼閱讀 2,857評論 0 3
  • 史上最簡單Angular2教程,大叔都學會了 作者:王芃 wpcfan@gmail.com 第一節(jié):初識Angul...
    接灰的電子產品閱讀 59,104評論 76 248
  • 為什么需要編譯 Angular應用中包含的組件、HTML模板(比如:@Directive、@Component、@...
    OnePiece索隆閱讀 3,021評論 3 4
  • 感恩安拉,感恩萬物,感恩一切的恩賜。感恩一切的流動。 感恩每一位老師對我們學校工作的付出。 感恩小馬對我的這份支持...
    黛兒微笑閱讀 281評論 0 0
  • 彩霞舞不若舞女, 君生死與其共進。 江山不過白發(fā)翁, 惺惺相惜天涯路。
    薄荷菇涼心微涼i閱讀 297評論 0 0

友情鏈接更多精彩內容