[譯]Angular vs React:誰更適合前端開發

龍騎將楊影楓發表於2017-09-03

Angular vs React:誰更適合前端開發

大家總在寫文章爭論,Angular 與 React 哪一個才是前端開發的更好選擇(譯者:在中國還要加上 vue :P)。我們還需要另一個嗎?

我之所以寫這篇文章,是因為這些的文章 —— 雖然它們包含不錯的觀點 —— 並沒有深入討論:作為一個實際的前端開發者,應該選取哪種框架來滿足自己的需求。

在本文中,我會介紹 Angular 與 React 如何用不同的哲♂學理念解決相同的前端問題,以及選擇哪種框架基本上是看個人喜好。為了方便進行比較,我準備編寫同一個 app 兩次,一次使用 Angular 一次使用 React。

Angular 之殤

兩年前,我寫了一篇有關 React 生態系統的文章。在我看來,Angular 是“預釋出時就跪了”的倒黴蛋(victim of “death by pre-announcement”)。那個時候,任何不想讓自己專案跑在過時框架上的開發者很容易在 Angular 和 React 之間做出選擇。Angular 1 就是被時代拋棄的框架,(原本的)Angular 2 甚至沒有活到 alpha 版本。

不過事後證明,這種擔心是多多少少有合理性的。Angular 2 進行了大幅度的修改,甚至在最終釋出前對主要部分進行了重寫。

兩年後,我們有了相對穩定的 Angular 4。

怎麼樣?

Angular vs React:風馬牛不相及 (Comparing Apples and Oranges)

把 React 和 Angular 拿來比較是件很沒意義的事情(校對逆寒: Comparing Apples and Oranges 是一種俚語說法,比喻把兩件完全不同的東西拿來相提並論)。因為 React 只是一個處理介面(view)的庫,而 Angular 是一個完整齊備的全家桶框架。

當然,大部分 React 開發者會新增一系列的庫,使得 React 成為完整的框架。但是這套完整框架的工作流程又一次和 Angular 完全不同,所以其可比性也很有限。

兩者最大的差別是對狀態(state)的管理。Angular 通過資料繫結(data-binding)來將狀態綁在資料上,而 React 如今通常引入 Redux 來提供單向資料流、處理不可變的資料(譯者:我個人理解這句話的意思是 Angular 的資料和狀態是互相影響的,而 React 只能通過切換不同的狀態來顯示不同的資料)。這是剛好互相對立的解決問題方法,而開發者們則不停的爭論可變的/資料繫結模式不可變的/單向的資料流兩者間誰更優秀。

公平競爭的環境

既然 React 更容易理解,為了便於比較,我決定編寫一份 React 與 Angular 的對應表,來合理的並排比較兩者的程式碼結構。

Angular 中有但是 React 沒有預設自帶的特性有:

特性 — Angular 包 — React 庫

  • 資料繫結,依賴注入(DI)—— @angular/core — MobX

  • 計算屬性 —— rxjs— MobX

  • 基於元件的路由 —— @angular/router— React Router v4

  • Material design 的元件 —— @angular/material— React Toolbox

  • CSS 元件作用域 —— @angular/core — CSS modules

  • 表單驗證 —— @angular/forms — FormState

  • 程式生產器(Project generator)—— @angular/cli — React Scripts TS

資料繫結

相對單向資料流來說,資料繫結可能更適合入門。當然,也可以使用完全相反的做法(指單向資料流),比如使用 React 中的 Redux 或者 mobx-state-tree,或者使用 Angular 中的 ngrx。不過那就是另一篇文章所要闡述的內容了。

計算屬性(Computed properties)

“除儲存屬性外,類、結構體和列舉可以定義計算屬性,計算屬性不直接儲存值,而是提供一個 getter 來獲取值,一個可選的 setter
來間接設定其他屬性或變數的值。”

摘錄來自: Unknown. “The Swift Programming Language 中文版”。 iBooks.

考慮到效能問題,Angular 中簡單的 getters 每次渲染時都被呼叫,所以被排除在外。這次我們使用 RsJS 中的 BehaviorSubject 來處理此類問題。

在 React 中,可以使用 MobX 中的 @computed 來達成相同的效果,而且此 api 會更方便一些。

依賴注入

依賴注入有一定的爭議性,因為它與當前 React 推行的函數語言程式設計/資料不可變性理念背道而馳。事實證明,某種程度的依賴注入是資料繫結環境中必不可少的部分,因為它可以幫助沒有獨立資料層的結構解耦(這樣做更便於使用模擬資料和測試)。

另一項依賴注入(Angular 中已支援)的優點是可以在(app)不同的生命週期中保有不同的資料倉儲(store)。目前大部分 React 範例使用了對映到不同元件的全域性狀態(global app state)。但是依我的經驗來看,當元件解除安裝(unmount)的時候清理全域性狀態很容易產生 bug。

在元件載入(mount)的時候建立一個獨立的資料倉儲(同時可以無縫傳遞給此元件的子元件)非常方便,而且是一項很容易被忽略的概念。

Angular 中開箱即用的做法,在 MobX 中也很容易重現。

路由

元件依賴的路由允許元件管理自身的子路由,而不是配置一個大的全域性路由。這種方案終於在 react-router 4 裡實現了。

Material Design

使用高階元件(higher-level components)總是很棒的,而 material design 已經成為即便是在非谷歌的專案中也被廣泛接受的選擇。

我特意選擇了 React Toolbox 而不是通常推薦的 Material UI,因為 Material UI 有一系列公開承認的行內 css 效能問題,而它的開發者們計劃在下個版本解決這些問題。

此外,React Toolbox 中已經開始使用即將取代 Sass/LESS 的 PostCSS/cssnext

帶有作用域的 CSS

CSS 的類比較像是全域性變數一類的東西。有許多方法來組織 CSS 以避免互相起衝突(包括 BEM),但是當前的趨勢是使用庫輔助處理 CSS 以避免衝突,而不是需要前端開發者煞費苦心的設計精密的 CSS 命名系統。

表單校驗

表單校驗是非常重要而且使用廣泛的特性,使用相關的庫可以有效避免冗餘程式碼和 bug。

程式生成器(Project Generator,也就是命令列工具)

使用一個命令列工具來建立專案比從 Github 上下載樣板檔案要方便的多。

分別使用 React 與 Angular 實現同一個 app

那麼我們準備使用 React 和 Anuglar 編寫同一個 app。這個 app 並不複雜,只是一個可以供任何人釋出帖子的公共貼吧(Shoutboard)。

你可以在這裡體驗到這個 app:

如果想閱讀本專案的完整原始碼,可以從如下地址下載:

你瞧,我們同樣使用 TypeScript 編寫 React app,因為能夠使用型別檢查的優勢還是很讚的。作為一種處理引入更優秀的方式,async/await 以及 rest spread 如今終於可以在 TypeScript2 裡使用,這樣就不需要 Babel/ES7/Flow 了(leaves Babel/ES7/Flow in the dust)。

薛定諤的貓:babel 的擴充套件很強大的。ts 不支援的 babel 都可以通過外掛支援(stage0~stage4)。

同樣,我們為兩者新增了 Apollo Client,因為我希望使用 GraphQL 風格的介面。我的意思是,REST 風格的介面確實不錯,但是經過十幾年的發展後,它已經跟不上時代了。

啟動與路由

首先,讓我們看一下兩者的入口檔案:

Angular

// 路由配置
const appRoutes: Routes = [
  { path: 'home', component: HomeComponent },
  { path: 'posts', component: PostsComponent },
  { path: 'form', component: FormComponent },
  { path: '', redirectTo: '/home', pathMatch: 'full' }
]

@NgModule({
  // 專案中使用元件的宣告
  declarations: [
    AppComponent,
    PostsComponent,
    HomeComponent,
    FormComponent,
  ],
  // 引用的第三方庫
  imports: [
    BrowserModule,
    RouterModule.forRoot(appRoutes),
    ApolloModule.forRoot(provideClient),
    FormsModule,
    ReactiveFormsModule,
    HttpModule,
    BrowserAnimationsModule,
    MdInputModule, MdSelectModule, MdButtonModule, MdCardModule, MdIconModule
  ],
  // 與整個 app 生命週期關聯的服務(service)
  providers: [
    AppService
  ],
  // 啟動時最先訪問的元件
  bootstrap: [AppComponent]
})

@Injectable()
export class AppService {
  username = 'Mr. User'
}複製程式碼

基本上,希望使用的元件要寫在 declarations 中,需要引入的第三方庫要寫在 imports 中,希望注入的全域性性資料倉儲(global store)要寫在 providers 中。子元件可以訪問到已宣告的變數,而且有機會可以新增一些自己的東西。

React

const appStore = AppStore.getInstance()
const routerStore = RouterStore.getInstance()

const rootStores = {
  appStore,
  routerStore
}

ReactDOM.render(
  <Provider {...rootStores} >
    <Router history={routerStore.history} >
      <App>
        <Switch>
          <Route exact path='/home' component={Home as any} />
          <Route exact path='/posts' component={Posts as any} />
          <Route exact path='/form' component={Form as any} />
          <Redirect from='/' to='/home' />
        </Switch>
      </App>
    </Router>
  </Provider >,
  document.getElementById('root')
)複製程式碼

<Provider/> 元件在 MobX 中被用來依賴注入。它將資料倉儲儲存在上下文(context)中,這樣 React 元件可以稍後進行注入。是的,React 上下文可以(大概)保證使用的安全性

export class AppStore {
  static instance: AppStore
  static getInstance() {
    return AppStore.instance || (AppStore.instance = new AppStore())
  }
  @observable username = 'Mr. User'
}複製程式碼

React 版本的入口檔案相對要簡短一些,因為不需要做那麼多模組宣告 —— 通常的情況下,只要匯入就可以使用了。有時候這種硬依賴很麻煩(比如測試的時候),所以對於全域性單例來說,我只好使用老式的(decades-old) GoF 模式

Angular 的路由是已注入的,所以可以在程式的任何地方使用,並不僅僅是元件中。為了在 React 中達到相同的功能,我們使用
mobx-react-router 並注入routerStore

總結:兩個 app 的啟動檔案都非常直觀。React 看起來更簡單一點的,使用 import 代替了模組的載入。不過接下來我們會看到,雖然在入口檔案中載入模組有點囉嗦,但是之後使用起來會很便利;而手動建立一個單例也有自己的麻煩。至於路由建立時的語法問題,是 JSON 更好還是 JSX 更好只是單純的個人喜好。

現在有兩種方法來進行頁面跳轉。宣告式的方法,使用超連結 <a href...> 標籤;命令式的方法,直接呼叫 routing (以及 location)API。

Angular

<h1> Shoutboard Application </h1>
<nav>
  <a routerLink="/home" routerLinkActive="active">Home</a>
  <a routerLink="/posts" routerLinkActive="active">Posts</a>
</nav>
<router-outlet></router-outlet>複製程式碼

Angular Router 自動檢測處於當前頁面的 routerLink,為其載入適當的 routerLinkActive CSS 樣式,方便在頁面中凸顯。

router 使用特殊的 <router-outlet> 標籤來渲染當前路徑對應的檢視(不管是哪種)。當 app 的子元件巢狀的比較深的時候,便可以使用很多 <router-outlet> 標籤。

@Injectable()
export class FormService {
  constructor(private router: Router) { }
  goBack() {
    this.router.navigate(['/posts'])
  }
}複製程式碼

路由模組可以注入進任何服務(一半是因為 TypeScript 是強型別語言的功勞),private 的宣告修飾可以將路由儲存在元件的例項上,不需要再顯式宣告。使用 navigate 方法便可以切換路徑。

React

import * as style from './app.css'
// …
  <h1>Shoutboard Application</h1>
  <div>
    <NavLink to='/home' activeClassName={style.active}>Home</NavLink>
    <NavLink to='/posts' activeClassName={style.active}>Posts</NavLink>
  </div>
  <div>
    {this.props.children}
  </div>複製程式碼

React Router 也可以通過 activeClassName 來設定當前連線的 CSS 樣式。

然而,我們不能直接使用 CSS 樣式的名稱,因為經過 CSS 模組編譯後(CSS 樣式的名字)會變得獨一無二,所以必須使用 style 來進行輔助。稍後會詳細解釋。

如上面所見,React Router 在 <App> 標籤內使用 <Switch> 標籤。因為 <Switch> 標籤只是包裹並載入當前路由,這意味著當前元件的子路由就是 this.props.children。當然這些子元件也是這麼組成的。

export class FormStore {
  routerStore: RouterStore
  constructor() {
    this.routerStore = RouterStore.getInstance()
  }
  goBack = () => {
    this.routerStore.history.push('/posts')
  }
}複製程式碼

mobx-router-store 也允許簡單的注入以及導航。

總結:兩種方案都相當類似。Angular 看起來更直觀,React 的組合更簡單。

依賴注入

事實證明,將資料層與展示層分離開是非常有必要的。我們希望通過依賴注入讓資料邏輯層的元件(這裡的叫法是 model/store/service)關聯上表示層元件的生命週期,這樣就可以創造一個或多個的資料層元件例項,不需要干擾全域性狀態。同時,這麼做更容易相容不同的資料與視覺化層。

這篇文章的例子非常簡單,所有的依賴注入的東西看起來似乎有點畫蛇添足。但是隨著 app 業務的增加,這種做法會很方便的。

Angular

@Injectable()
export class HomeService {
  message = 'Welcome to home page'
  counter = 0
  increment() {
    this.counter++
  }
}複製程式碼

任何類(class)均可以使用 @injectable 的裝飾器進行修飾,這樣它的屬性與方法便可以在其他元件中呼叫。

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  providers: [
    HomeService // 註冊在這裡
  ]
})

export class HomeComponent {
  constructor(
    public homeService: HomeService,
    public appService: AppService,
  ) { }
}複製程式碼

通過將 HomeService 註冊進元件的 providers,此元件獲得了一個獨有的 HomeService。它不是單例,但是每一個元件在初始化的時候都會收到一個新的 HomeService 例項化物件。這意味著不會有之前 HomeService 使用過的過期資料。

相對而言,AppService 被註冊進了 app.module 檔案(參見之前的入口檔案),所以它是駐留在每一個元件中的單例,貫穿整個 app 的生命週期。能夠從元件中控制服務的宣告週期是一項非常有用、而且常被低估的概念。

依賴注入通過在 TypeScript 型別定義的元件建構函式(constructor)內分配服務(service)的例項來起作用(譯者:也就是上面程式碼中的 public homeService: HomeService)。此外,public 的關鍵詞修飾的引數會自動賦值給 this 的同名變數,這樣我們就不必再編寫那些無聊的 this.homeService = homeService 程式碼了。

<div>
  <h3>Dashboard</h3>
  <md-input-container>
    <input mdInput placeholder='Edit your name' [(ngModel)]='appService.username' />
  </md-input-container>
  <br/>
  <span>Clicks since last visit: {{homeService.counter}}</span>
  <button (click)='homeService.increment()'>Click!</button>
</div>複製程式碼

Angular 的模板語法被證明相當優雅(譯者:其實這也算是個人偏好問題),我喜歡 [()] 的縮寫,這樣就代表雙向繫結(2-way data binding)。但是其本質上(under the hood)是屬性繫結 + 事件驅動。就像(與元件關聯後)服務的生命週期所規定的那樣,homeService.counter 每次離開 /home 頁面的時候都會重置,但是 appService.username 會保留,而且可以在任何頁面訪問到。

React

import { observable } from 'mobx'

export class HomeStore {
  @observable counter = 0
  increment = () => {
    this.counter++
  }
}複製程式碼

如果希望通過 MobX 實現同樣的效果,我們需要在任何需要監聽其變化的屬性上新增 @observable 裝飾器。

@observer
export class Home extends React.Component<any, any> {

  homeStore: HomeStore
  componentWillMount() {
    this.homeStore = new HomeStore()
  }

  render() {
    return <Provider homeStore={this.homeStore}>
      <HomeComponent />
    </Provider>
  }
}複製程式碼

為了正確的控制(資料層的)生命週期,開發者必須比 Angular 例子多做一點工作。我們用 Provider 來包裹 HomeComponent ,這樣在每次載入的時候都獲得一個新的 HomeStore 例項。

interface HomeComponentProps {
  appStore?: AppStore,
  homeStore?: HomeStore
}

@inject('appStore', 'homeStore')
@observer
export class HomeComponent extends React.Component<HomeComponentProps, any> {
  render() {
    const { homeStore, appStore } = this.props
    return <div>
      <h3>Dashboard</h3>
      <Input
        type='text'
        label='Edit your name'
        name='username'
        value={appStore.username}
        onChange={appStore.onUsernameChange}
      />
      <span>Clicks since last visit: {homeStore.counter}</span>
      <button onClick={homeStore.increment}>Click!</button>
    </div>
  }
}複製程式碼

HomeComponent 使用 @observer 裝飾器監聽被 @observable 裝飾器修飾的屬性變化。

其底層機制很有趣,所以我們簡單的介紹一下。@observable 裝飾器通過替換物件中(被觀察)屬性的 getter 和 setter 方法,攔截對該屬性的呼叫。當被 @observer 修飾的元件呼叫其渲染函式(render function)時,這些屬性的 getter 方法也會被呼叫,getter 方法會將對屬性的引用儲存在呼叫它們的元件上。

然後,當 setter 方法被呼叫、這些屬性的值也改變的時候,上一次渲染這些屬性的元件會(再次)呼叫其渲染函式。這樣被改變過的屬性會在介面上更新,然後整個週期會重新開始(譯者注:其實就是典型的觀察者模式啊...)。

這是一個非常簡單的機制,也是很棒的特性。更深入的解釋在這裡.

@inject 裝飾器用來將 appStorehomeStore 的例項注入進 HomeComponent 的屬性。這種情況下,每一個資料倉儲(也)具有不同的生命週期。appStore 的生命週期同樣也貫穿整個 app,而 homeStore 在每次進入 "/home" 頁面的時候重新建立。

這麼做的好處,是不需要手動清理屬性。如果所有的資料倉儲都是全域性變數,每次詳情頁想展示不同的資料就會很崩潰(譯者:因為每次都要手動擦掉上一次的遺留資料)。

總結:因為自帶管理生命週期的特性,Angular 的依賴注入更容易獲得預期的效果。React 版本的做法也很有效,但是會涉及到更多的引用。

計算屬性

React

這次我們先講 React,它的做法更直觀一些。

import { observable, computed, action } from 'mobx'

export class HomeStore {
import { observable, computed, action } from 'mobx'

export class HomeStore {
  @observable counter = 0
  increment = () => {
    this.counter++
  }
  @computed get counterMessage() {
    console.log('recompute counterMessage!')
    return `${this.counter} ${this.counter === 1 ? 'click' : 'clicks'} since last visit`
  }
}複製程式碼

這樣我們就將計算屬性繫結到 counter 上,同時返回一段根據點選數量來確定的資訊。counterMessage 被放在快取中,只有當 counter 屬性被改變的時候才重新進行處理。

<Input
  type='text'
  label='Edit your name'
  name='username'
  value={appStore.username}
  onChange={appStore.onUsernameChange}
/>
<span>{homeStore.counterMessage}</span>
<button onClick={homeStore.increment}>Click!</button>複製程式碼

然後我們在 JSX 模版中引用此屬性(以及 increment 方法)。再將使用者的姓名資料繫結在輸入框上,通過 appStore 的一個方法處理使用者的(輸入)事件。

Angular

為了在 Angular 中實現相同的結果,我們必須另闢蹊徑。

import { Injectable } from '@angular/core'
import { BehaviorSubject } from 'rxjs/BehaviorSubject'

@Injectable()
export class HomeService {
  message = 'Welcome to home page'
  counterSubject = new BehaviorSubject(0)
  // Computed property can serve as basis for further computed properties
  // 初始化屬性,可以作為進一步屬性處理的基礎
  counterMessage = new BehaviorSubject('')
  constructor() {
    // Manually subscribe to each subject that couterMessage depends on
    // 手動訂閱 couterMessage 依賴的方法
    this.counterSubject.subscribe(this.recomputeCounterMessage)
  }

  // Needs to have bound this
  // 需要設定約束
  private recomputeCounterMessage = (x) => {
    console.log('recompute counterMessage!')
    this.counterMessage.next(`${x} ${x === 1 ? 'click' : 'clicks'} since last visit`)
  }

  increment() {
    this.counterSubject.next(this.counterSubject.getValue() + 1)
  }
}複製程式碼

我們需要初始化所有計算屬性的值,也就是所謂的 BehaviorSubject。計算屬性自身同樣也是 BehaviorSubject ,因為每次計算後屬性都是另一個計算屬性的基礎。

當然,RxJs 可以做的遠不於此,不過還是留待另一篇文章去詳細講述吧。在簡單的情況下強行使用 Rxjs 處理計算屬性的話反而會比 React 例子要麻煩一點,而且程式設計師必須手動去訂閱(就像在建構函式中做的那樣)。

<md-input-container>
  <input mdInput placeholder='Edit your name' [(ngModel)]='appService.username' />
</md-input-container>
<span>{{homeService.counterMessage | async}}</span>
<button (click)='homeService.increment()'>Click!</button>複製程式碼

注意,我們可以通過 | async 的管道(pipe)來引用 RxJS 專案。這是一個很棒的做法,比在元件中訂閱要簡短一些。使用者姓名與輸入框則通過 [(ngModel)] 實現了雙向繫結。儘管看起來很奇怪,但這麼做實際上相當優雅。就像一個資料繫結到 appService.username 的語法糖,而且自動相應使用者的輸入事件。

總結:計算屬性在 React/MobX 比在 Angular/RxJ 中更容易實現,但是 RxJS 可以提供一些有用的函式式響應程式設計(FRP)的、不久之後會被人們所稱讚的新特性。

模板與 CSS

為了演示兩者的模版棧是多麼的相愛相殺(against each other),我們來編寫一個展示帖子列表的元件。

Angular

@Component({
  selector: 'app-posts',
  templateUrl: './posts.component.html',
  styleUrls: ['./posts.component.css'],
  providers: [
    PostsService
  ]
})

export class PostsComponent implements OnInit {
  // 譯者:請注意這裡的 implements OnInit
  // 這是 Angular 4 為了實現控制元件生命週期而提供的鉤子(hook)介面
  constructor(
    public postsService: PostsService,
    public appService: AppService
  ) { }

  // 這裡是對 OnInit 的具體實現,必須寫成 ngOnInit
  // ngOnInit 方法在元件初始化的時候會被呼叫
  // 以達到和 React 中 componentWillMount 相同的作用
  // Angular 4 還提供了很多用於控制生命週期鉤子
  // 結果譯者都沒記住(捂臉跑)
  ngOnInit() {
    this.postsService.initializePosts()
  }
}複製程式碼

本元件(指 post.component.ts 檔案)連線了此元件(指具體的帖子元件)的 HTML、CSS,而且在元件初始化的時候通過注入過的服務從 API 讀取帖子的資料。AppService 是一個定義在 app 入口檔案中的單例,而 PostsService 則是暫時的、每次建立元件時都會重新初始化的一個例項(譯者:又是不同生命週期的不同資料倉儲)。CSS 被引用到元件內,以便於將作用域限定在本元件內 —— 這意味著它不會影響元件外的東西。

<a routerLink="/form" class="float-right">
  <button md-fab>
    <md-icon>add</md-icon>
  </button>
</a>
<h3>Hello {{appService.username}}</h3>
<md-card *ngFor="let post of postsService.posts">
  <md-card-title>{{post.title}}</md-card-title>
  <md-card-subtitle>{{post.name}}</md-card-subtitle>
  <md-card-content>
    <p>
      {{post.message}}
    </p>
  </md-card-content>
</md-card>複製程式碼

在 HTML 模版中,我們從 Angular Material 引用了大部分元件。為了保證其正常使用,必須把它們包含在 app.module 的 import 裡(參見上面的入口檔案)。*ngFor 指令用來迴圈使用 md-card 輸出每一個帖子。

Local CSS:

.mat-card {
  margin-bottom: 1rem;
}複製程式碼

這段區域性 CSS 只在 md-card 元件中起作用

Global CSS:

.float-right {
  float: right;
}複製程式碼

這段 CSS 類定義在全域性樣式檔案 style.css 中,這樣所有的元件都可以用標準的方法使用它(指 style.css 檔案)的樣式,class="float-right"。

Compiled CSS:

.float-right {
  float: right;
}
.mat-card[_ngcontent-c1] {
    margin-bottom: 1rem;
}複製程式碼

在編譯後的 CSS 檔案中,我們可以發現區域性 CSS 的作用域通過新增 [_ngcontent-c1] 的屬性選擇器被限定在本元件中。每一個已渲染的 Angular 元件都會產生一個用作確定 CSS 作用域的類。

這種機制的優勢是我們可以正常的引用 CSS 樣式,而 CSS 的作用域在後臺被處理了(is handled “under the hood”)。

React

import * as style from './posts.css'
import * as appStyle from '../app.css'

@observer
export class Posts extends React.Component<any, any> {

  postsStore: PostsStore
  componentWillMount() {
    this.postsStore = new PostsStore()
    this.postsStore.initializePosts()
  }

  render() {
    return <Provider postsStore={this.postsStore}>
      <PostsComponent />
    </Provider>
  }
}複製程式碼

在 React 中,開發者又一次需要使用 Provider 來使 PostsStore 的 依賴“短暫(transient)”。我們同樣引入 CSS 樣式,宣告為 style 以及 appStyle ,這樣就可以在 JSX 語法中使用 CSS 的樣式了。

interface PostsComponentProps {
  appStore?: AppStore,
  postsStore?: PostsStore
}

@inject('appStore', 'postsStore')
@observer
export class PostsComponent extends React.Component<PostsComponentProps, any> {
  render() {
    const { postsStore, appStore } = this.props
    return <div>
      <NavLink to='form'>
        <Button icon='add' floating accent className={appStyle.floatRight} />
      </NavLink>
      <h3>Hello {appStore.username}</h3>
      {postsStore.posts.map(post =>
        <Card key={post.id} className={style.messageCard}>
          <CardTitle
            title={post.title}
            subtitle={post.name}
          />
          <CardText>{post.message}</CardText>
        </Card>
      )}
    </div>
  }
}複製程式碼

當然,JSX 的語法比 Angular 的 HTML 模版更有 javascript 的風格,是好是壞取決於開發者的喜好。我們使用高階函式 map 來代替 *ngFor 指令迴圈輸出帖子。

如今,Angular 也許是使用 TypeScript 最多的框架,但是實際上 JSX 語法才是 TypeScript 能真正發揮作用的地方。通過新增 CSS 模組(在頂部引入),它能夠讓模版編碼的工作成為依靠外掛進行程式碼補全的享受(it really turns your template coding into code completion zen)。每一個事情都是經過型別檢驗的。元件、屬性甚至 CSS 類(appStyle.floatRight 以及 style.messageCard 見下)。當然,JSX 語法的單薄特性比起 Angular 的模版更鼓勵將程式碼拆分成元件和片段(fragment)。

Local CSS:

.messageCard {
  margin-bottom: 1rem;
}複製程式碼

Global CSS:

.floatRight {
  float: right;
}複製程式碼

Compiled CSS:

.floatRight__qItBM {
  float: right;
}

.messageCard__1Dt_9 {
    margin-bottom: 1rem;
}複製程式碼

如你所見,CSS 模組載入器通過在每一個 CSS 類之後新增隨機的字尾來保證其名字獨一無二。這是一種非常簡單的、可以有效避免命名衝突的辦法。(編譯好的)CSS 類隨後會被 webpack 打包好的物件引用。這麼做的缺點之一是不能像 Angular 那樣只建立一個 CSS 檔案來使用。但是從另一方面來說,這也未嘗不是一件好事。因為這種機制會強迫你正確的封裝 CSS 樣式。

總結:比起 Angular 的模版,我更喜歡 JSX 語法,尤其是支援程式碼補全以及型別檢查。這真是一項殺手鐗(really is a killer feature)。Angular 現在採用了 AOT 編譯器,也有一些新的東西。大約有一半的情況能使用程式碼補全,但是不如 JSX/TypeScript 中做的那麼完善。

GraphQL — 載入資料

那麼我們決定使用 GraphQL 來儲存本 app 的資料。在服務端建立 GraphQL 風格的介面的簡單方法之一就是使用後端即時服務(Baas),比如說 Graphcool。其實,我們就是這麼做的。基本上,開發者只需要定義資料模型和屬性,隨後就可以方便的進行增刪改查了。

通用程式碼

因為很多 GraphQL 相關的程式碼實現起來完全相同,那麼我們不必重複編寫兩次:

const PostsQuery = gql`
  query PostsQuery {
    allPosts(orderBy: createdAt_DESC, first: 5)
    {
      id,
      name,
      title,
      message
    }
  }
`複製程式碼

比起傳統的 REST 風格的介面,GraphQL 是一種為了提供函式性富集合的查詢語言。讓我們分析一下這個特定的查詢。

  • PostsQuery 只是該查詢被隨後引用的名稱,可以任意起名。

  • allPosts 是最重要的部分:它是查詢所有帖子資料函式的引用。這是 Graphcool 建立的名字。

  • orderByfirst 是 allPost 的引數,createdAt 是帖子資料模型的一個屬性。first: 5 意思是返回查詢結果的前 5 條資料。

  • idnametitle、以及 message 是我們希望在返回的結果中包含帖子的資料屬性,其他的屬性會被過濾掉。

你瞧,這真的太棒了。仔細閱讀這個頁面的內容來熟悉更多有關 GraphQL 查詢的東西。

interface Post {
  id: string
  name: string
  title: string
  message: string
}

interface PostsQueryResult {
  allPosts: Array<Post>
}複製程式碼

然後,作為 TypeScript 的模範市民,我們通過建立介面來處理 GraphQL 的結果。

Angular

@Injectable()
export class PostsService {
  posts = []

  constructor(private apollo: Apollo) { }

  initializePosts() {
    this.apollo.query<PostsQueryResult>({
      query: PostsQuery,
      fetchPolicy: 'network-only'
    }).subscribe(({ data }) => {
      this.posts = data.allPosts
    })
  }
}複製程式碼

GraphQL 查詢結果集是一個 RxJS 的被觀察者類(observable),該結果集可供我們訂閱。它有點像 Promise,但並不是完全一樣,所以我們不能使用 async/await。當然,確實有 toPromise 方法(將其轉化為 Promise 物件),但是這種做法並不是 Angular 的風格(譯者:那為啥 Angular 4 的入門 demo 用的就是 toPromise...)。我們通過設定 fetchPolicy: 'network-only' 來保證在這種情況不進行快取操作,而是每次都從服務端獲取最新資料。

React

export class PostsStore {
  appStore: AppStore

  @observable posts: Array<Post> = []

  constructor() {
    this.appStore = AppStore.getInstance()
  }

  async initializePosts() {
    const result = await this.appStore.apolloClient.query<PostsQueryResult>({
      query: PostsQuery,
      fetchPolicy: 'network-only'
    })
    this.posts = result.data.allPosts
  }
}複製程式碼

React 版本的做法差不多一樣,不過既然 apolloClient 使用了 Promise,我們就可以體會到 async/await 語法的優點了(譯者:async/await 語法的優點便是用寫同步程式碼的模式處理非同步情況,不必在使用 Promose 的 then 回撥,邏輯更清晰,也更容易 debug)。React 中有其他做法,便是在高階元件中“記錄” GraphQL 查詢結果集,但是對我來說這麼做顯得資料層和展示層耦合度太高了。

總結:RxJS 中的訂閱以及 async/await 其實有著非常相似的觀念。

GraphQL — 儲存資料

通用程式碼

同樣的,這是 GraphQL 相關的程式碼:

const AddPostMutation = gql`
  mutation AddPostMutation($name: String!, $title: String!, $message: String!) {
    createPost(
      name: $name,
      title: $title,
      message: $message
    ) {
      id
    }
  }
`複製程式碼

修改(mutations,GraphQL 術語)的目的是為了建立或者更新資料。在修改中宣告一些變數是十分有益的,因為這其實是傳遞資料的方式。我們有 nametitle、以及 message 這些變數,型別為字串,每次呼叫本修改的時候都會為其賦值。createPost 函式,又一次是由 Graphcool 來定義的。我們指定 Post 資料模型的屬性會從修改(mutation)對應的屬性裡獲得屬性值,而且希望每建立一條新資料的時候都會返回一個新的 id。

Angular

@Injectable()
export class FormService {
  constructor(
    private apollo: Apollo,
    private router: Router,
    private appService: AppService
  ) { }

  addPost(value) {
    this.apollo.mutate({
      mutation: AddPostMutation,
      variables: {
        name: this.appService.username,
        title: value.title,
        message: value.message
      }
    }).subscribe(({ data }) => {
      this.router.navigate(['/posts'])
    }, (error) => {
      console.log('there was an error sending the query', error)
    })
  }

}複製程式碼

當呼叫 apollo.mutate 方法的時候,我們會傳入一個希望的修改(mutation)以及修改中所包含的變數值。然後在訂閱的回撥函式中獲得返回結果,使用注入的路由來跳轉帖子列表頁面。

React

export class FormStore {
  constructor() {
    this.appStore = AppStore.getInstance()
    this.routerStore = RouterStore.getInstance()
    this.postFormState = new PostFormState()
  }

  submit = async () => {
    await this.postFormState.form.validate()
    if (this.postFormState.form.error) return
    const result = await this.appStore.apolloClient.mutate(
      {
        mutation: AddPostMutation,
        variables: {
          name: this.appStore.username,
          title: this.postFormState.title.value,
          message: this.postFormState.message.value
        }
      }
    )
    this.goBack()
  }

  goBack = () => {
    this.routerStore.history.push('/posts')
  }
}複製程式碼

和上面 Angular 的做法非常相似,差別就是有更多的“手動”依賴注入,更多的 async/await 的做法。

總結:又一次,並沒有太多不同。訂閱與 async/await 基本上就那麼點差異。

表單:

我們希望在 app 中用表單達到以下目標:

  • 將表單作用域繫結至資料模型

  • 為每個表單域進行校驗,有多條校驗規則

  • 支援檢查整個表格的值是否合法

React

export const check = (validator, message, options) =>
  (value) => (!validator(value, options) && message)

export const checkRequired = (msg: string) => check(nonEmpty, msg)

export class PostFormState {
  title = new FieldState('').validators(
    checkRequired('Title is required'),
    check(isLength, 'Title must be at least 4 characters long.', { min: 4 }),
    check(isLength, 'Title cannot be more than 24 characters long.', { max: 24 }),
  )
  message = new FieldState('').validators(
    checkRequired('Message cannot be blank.'),
    check(isLength, 'Message is too short, minimum is 50 characters.', { min: 50 }),
    check(isLength, 'Message is too long, maximum is 1000 characters.', { max: 1000 }),
  )
  form = new FormState({
    title: this.title,
    message: this.message
  })
}複製程式碼

formstate 的庫是這麼工作的:對於每一個表單域,需要定義一個 FieldStateFieldState 的引數是表單域的初始值。validators 屬性接受一個函式做引數,如果表單域的值有效就返回 false;如果表單域的值非法,那麼就彈出一條提示資訊。通過使用 checkcheckRequired 這兩個輔助函式,可以使得宣告部分的程式碼看起來很漂亮。

為了對整個表單進行驗證,最好使用另一個 FormState 例項來包裹這些欄位,然後提供整體有效性的校驗。

@inject('appStore', 'formStore')
@observer
export class FormComponent extends React.Component<FormComponentProps, any> {
  render() {
    const { appStore, formStore } = this.props
    const { postFormState } = formStore
    return <div>
      <h2> Create a new post </h2>
      <h3> You are now posting as {appStore.username} </h3>
      <Input
        type='text'
        label='Title'
        name='title'
        error={postFormState.title.error}
        value={postFormState.title.value}
        onChange={postFormState.title.onChange}
      />
      <Input
        type='text'
        multiline={true}
        rows={3}
        label='Message'
        name='message'
        error={postFormState.message.error}
        value={postFormState.message.value}
        onChange={postFormState.message.onChange}
      />複製程式碼

FormState 例項擁有 valueonChange以及 error 三個屬性,可以非常方便的在前端元件中使用。

<Button
    label='Cancel'
    onClick={formStore.goBack}
    raised
    accent
  /> &nbsp;
<Button
    label='Submit'
    onClick={formStore.submit}
    raised
    disabled={postFormState.form.hasError}
    primary
  />複製程式碼

form.hasError 的返回值是 true 的時候,我們讓按鈕控制元件保持禁用狀態。提交按鈕傳送表單資料到之前編寫的 GraphQL 修改(mutation)上。

Angular

在 Angular 中,我們會使用 @angular/formspackage 中的 FormServiceFormBuilder

@angular/formspackage.

@Component({
  selector: 'app-form',
  templateUrl: './form.component.html',
  providers: [
    FormService
  ]
})
export class FormComponent {
  postForm: FormGroup
  validationMessages = {
    'title': {
      'required': 'Title is required.',
      'minlength': 'Title must be at least 4 characters long.',
      'maxlength': 'Title cannot be more than 24 characters long.'
    },
    'message': {
      'required': 'Message cannot be blank.',
      'minlength': 'Message is too short, minimum is 50 characters',
      'maxlength': 'Message is too long, maximum is 1000 characters'
    }
  }複製程式碼

首先,讓我們定義校驗資訊。

constructor(
    private router: Router,
    private formService: FormService,
    public appService: AppService,
    private fb: FormBuilder,
  ) {
    this.createForm()
  }複製程式碼
createForm() {
this.postForm = this.fb.group({
  title: ['',
    [Validators.required,
    Validators.minLength(4),
    Validators.maxLength(24)]
  ],
  message: ['',
    [Validators.required,
    Validators.minLength(50),
    Validators.maxLength(1000)]
  ],
})
}複製程式碼

使用 FormBuilder,很容易建立表格結構,甚至比 React 的例子更出色。

get validationErrors() {
    const errors = {}
    Object.keys(this.postForm.controls).forEach(key => {
      errors[key] = ''
      const control = this.postForm.controls[key]
      if (control && !control.valid) {
        const messages = this.validationMessages[key]
        Object.keys(control.errors).forEach(error => {
          errors[key] += messages[error] + ' '
        })
      }
    })
    return errors
  }複製程式碼

為了讓繫結的校驗資訊在正確的位置顯示,我們需要做一些處理。這段程式碼源自官方文件,只做了一些微小的變化。基本上,在 FormService 中,表單域保有根據校驗名識別的錯誤,這樣我們就需要手動配對資訊與受影響的表單域。這並不是一個完全的缺陷,而是更容易國際化(譯者:即指的方便的對提示語進行多語言翻譯)。

onSubmit({ value, valid }) {
    if (!valid) {
      return
    }
    this.formService.addPost(value)
  }

  onCancel() {
    this.router.navigate(['/posts'])
  }
}複製程式碼

和 React 一樣,如果表單資料是正確的,那麼資料可以被提交到 GraphQL 的修改。

<h2> Create a new post </h2>
<h3> You are now posting as {{appService.username}} </h3>
<form [formGroup]="postForm" (ngSubmit)="onSubmit(postForm)" novalidate>
  <md-input-container>
    <input mdInput placeholder="Title" formControlName="title">
    <md-error>{{validationErrors['title']}}</md-error>
  </md-input-container>
  <br>
  <br>
  <md-input-container>
    <textarea mdInput placeholder="Message" formControlName="message"></textarea>
    <md-error>{{validationErrors['message']}}</md-error>
  </md-input-container>
  <br>
  <br>
  <button md-raised-button (click)="onCancel()" color="warn">Cancel</button>
  <button
    md-raised-button
    type="submit"
    color="primary"
    [disabled]="postForm.dirty && !postForm.valid">Submit</button>
  <br>
  <br>
</form>複製程式碼

最重要的是引用我們通過 FormBuilder 建立的表單組,也就是 [formGroup]="postForm" 分配的資料。表單中的表單域通過 formControlName 的屬性來限定表單的資料。當然,還得在表單資料驗證失敗的時候禁用 “Submit” 按鈕。順便還需要新增髒資料檢查,因為這種情況下,髒資料可能會引起表單校驗不通過。我們希望每次初始化 button 都是可用的。

總結:對於 React 以及 Angular 的表單方面來說,表單校驗和前端模版差別都很大。Angular 的方法是使用一些更“魔幻”的做法而不是簡單的繫結,但是從另一方面說,這麼做的更完整也更徹底。

編譯檔案大小

Oh, one more thing. The production minified JS bundle sizes, with default settings from the application generators: notably Tree Shaking in React and AOT compilation in Angular.

啊,還有一件事。那就是使用程式預設設定進行打包後 bundle 檔案的大小:特指 React 中的 Tree Shaking 以及 Angular 中的 AOT 編譯。

  • Angular: 1200 KB
  • React: 300 KB

嗯,並不意外,Angular 確實是個巨無霸。

使用 gzip 進行壓縮的後,兩者的大小分別會降低至 275kb 和 127kb。

請記住,這還只是主要的庫。相比較而言真正處理邏輯的程式碼是很小的部分。在真實的情況下,這部分的比率大概是 1:2 到 1:4 之間。同時,當開發者開始在 React 中引入一堆第三方庫的時候,檔案的體積也會隨之快速增長。

庫的靈活性與框架的穩定性

那麼,看起來我們還是無法(再一次)對 “Angular 與 React 中何者才是更好的前端開發框架”給出明確的答案。

事實證明,React 與 Angular 中的開發工作流程可以非常相似(譯者:因為用的是 mobx 而不是 redux),而這其實和使用 React 的哪一個庫有關。當然,這還是一個個人喜好問題。

如果你喜歡現成的技術棧,牛逼的依賴注入而且計劃體驗 RxJS 的好處,那麼選擇 Angular 吧。

如果你喜歡自由定製自己的技術棧,喜歡 JSX 的直觀,更喜歡簡單的計算屬性,那麼就用 React/MobX 吧。

當然,你可以從這裡以及這裡獲得本文 app 的所有原始碼。

或者,如果你喜歡大一點的真實專案:

先選擇自己的程式設計習慣

使用 React/MobX 實際上比起 React/Redux 更接近於 Angular。雖然在模版以及依賴管理中有一些顯著的差異,但是它們有著相似的可變/資料繫結的風格。

React/Redux 與它的不可變/單向資料流的模式則是完全不同的另一種東西。

不要被 Redux 庫的體積迷惑,它也許很嬌小,但確實是一個框架。如今大部分 Redux 的優秀做法關注使用相容 Redux 的庫,比如用來處理非同步程式碼以及獲取資料的 Redux Saga,用來管理表單的 Redux Form,用來記錄選擇器(Redux 計算後的值)的Reselect,以及用來管理元件生命週期的 Recompose。同時 Redux 社群也在從 Immutable.js 轉向 lodash/fp,更專注於處理普通的 JS 物件而不是轉化它們。

React Boilerplate是一個非常著名的使用 Redux 的例子。這是一個強大的開發棧,但是如果你仔細研究的話,會發現它與到目前為止本文提到的東西非常、非常不一樣。

我覺得主流 JavaScript 社群一直對 Angular 抱有某種程度的偏見(譯者:我也有這種感覺,作為全公司唯一會 Angular 的稀有動物每次想在組內推廣 Angular 都會遇到無窮大的阻力)。大部分對 Angular 表達不滿的人也許還無法欣賞到 Angular 中老版本與新版本之間的巨大改變。以我的觀點來看,這是一個非常整潔高效的框架,如果早一兩年出現肯定會在世界範圍內掀起一陣 Angular 的風潮(譯者:可惜早一兩年出的是 Angular 1.x)。

當然,Angular 還是獲得了一個堅實的立足點。尤其是在大型企業中,大型團隊需要標準化和長期化的支援。換句話說,Angular 是谷歌工程師們認為前端開發應有的樣子,如果它終究能有所成就的話(amounts to anything)。

對於 MobX 來說,處境也差不多。十分優秀,但是受眾不多。

結論是:在選擇 React 與 Angular 之前,先選擇自己的程式設計習慣(譯者:這結論等於沒結論)。

是可變的/資料繫結,還是不可變的/單向資料流?看起來真的很難抉擇。

> 我希望你能喜歡這篇客座文章。這篇文章最初發表在 Toptal,並且已經獲得轉載授權。

❤ 如果你喜歡這篇文章,輕輕扎一下小藍心吧老鐵


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章