zl程序教程

您现在的位置是:首页 >  前端

当前栏目

[译]Angular vs React:谁更适合前端开发

AngularvsReact 适合 前端开发
2023-09-27 14:27:54 时间
大家总在写文章争论,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 库


相对单向数据流来说,数据绑定可能更适合入门。当然,也可以使用完全相反的做法(指单向数据流),比如使用 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:


如果想阅读本项目的完整源代码,可以从如下地址下载:

贴吧源码 Angular 版 贴吧源码 React 版

你瞧,我们同样使用 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 更好只是单纯的个人喜好。

连接(Links)与命令式导航

现在有两种方法来进行页面跳转。声明式的方法,使用超链接  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,为其加载适当的 routerLinkActiveCSS 样式,方便在页面中凸显。

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 homeStore.increment} Click! /button 

 /div 

}

HomeComponent 使用 @observer 装饰器监听被 @observable 装饰器修饰的属性变化。

其底层机制很有趣,所以我们简单的介绍一下。@observable 装饰器通过替换对象中(被观察)属性的 getter 和 setter 方法,拦截对该属性的调用。当被 @observer 修饰的组件调用其渲染函数(render function)时,这些属性的 getter 方法也会被调用,getter 方法会将对属性的引用保存在调用它们的组件上。

然后,当 setter 方法被调用、这些属性的值也改变的时候,上一次渲染这些属性的组件会(再次)调用其渲染函数。这样被改变过的属性会在界面上更新,然后整个周期会重新开始(译者注:其实就是典型的观察者模式啊...)。

这是一个非常简单的机制,也是很棒的特性。更深入的解释在这里.

@inject 装饰器用来将 appStore 和 homeStore 的实例注入进 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 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" 

 button md-fab 

 md-icon add /md-icon 

 /button 

 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 

 {{post.message}}

 /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 文件)的样式, 。

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)

 name,

 title,

 message

`

比起传统的 REST 风格的接口,GraphQL 是一种为了提供函数性富集合的查询语言。让我们分析一下这个特定的查询。


allPosts 是最重要的部分:它是查询所有帖子数据函数的引用。这是 Graphcool 创建的名字。


orderBy 和 first 是 allPost 的参数,createdAt 是帖子数据模型的一个属性。first: 5 意思是返回查询结果的前 5 条数据。


id、name、title、以及 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

`

修改(mutations,GraphQL 术语)的目的是为了创建或者更新数据。在修改中声明一些变量是十分有益的,因为这其实是传递数据的方式。我们有 name、title、以及 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 中用表单达到以下目标:


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 的库是这么工作的:对于每一个表单域,需要定义一个 FieldState。FieldState的参数是表单域的初始值。validators 属性接受一个函数做参数,如果表单域的值有效就返回 false;如果表单域的值非法,那么就弹出一条提示信息。通过使用check、checkRequired 这两个辅助函数,可以使得声明部分的代码看起来很漂亮。

为了对整个表单进行验证,最好使用另一个 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 实例拥有 value、onChange以及 error 三个属性,可以非常方便的在前端组件中使用。

 Button

 label=Cancel

 formStore.goBack}

 raised

 accent

 / nbsp;

 Button

 label=Submit

 formStore.submit}

 raised

 disabled={postFormState.form.hasError}

 primary

 / 

当 form.hasError 的返回值是 true 的时候,我们让按钮控件保持禁用状态。提交按钮发送表单数据到之前编写的 GraphQL 修改(mutation)上。

Angular

在 Angular 中,我们会使用 @angular/formspackage 中的 FormService 和 FormBuilder。

@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 

 md-input-container 

 textarea mdInput placeholder="Message" formControlName="message" /textarea 

 md-error {{validationErrors[message]}} /md-error 

 /md-input-container 

 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 

 /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 的所有源代码。

或者,如果你喜欢大一点的真实项目:

RealWorld Angular 4+ RealWorld React/MobX 先选择自己的编程习惯

使用 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,并且已经获得转载授权。 原文发布时间为:2017年9月3日 本文来自云栖社区合作伙伴掘金,了解相关信息可以关注掘金网站。
掌握React的基本使用,重塑前端开发 React change the way that Web apps should be build. UI - Web apps 四步: Break The UI Into A Component Hierarchy 将UI结构拆解成组件结构 Build A Static Version in React It s best to decouple these processes because building a static version requires a lot of typing and no thinking, and adding interactivity requi
带你读《React+Redux前端开发实战》之一: React入门 本书是一本React入门书,也是一本React实践书,更是一本React企业级项目开发指导书。全书系统地介绍了以React.js为中心的各种前端开发技术,可以帮助前端开发人员系统地掌握这些知识,提升自己的开发水平。
带你读《React+Redux前端开发实战》之二:React的组件 本书是一本React入门书,也是一本React实践书,更是一本React企业级项目开发指导书。全书系统地介绍了以React.js为中心的各种前端开发技术,可以帮助前端开发人员系统地掌握这些知识,提升自己的开发水平。