JavaScript 框架历史(中)

155 阅读14分钟

JavaScript 框架历史(中)

本文是人工翻译的国外文章,原文链接:History of JavaScript Frameworks (Nicklas Envall) ,限于译者水平,难免出现错误,请多多包涵。
原文内容较长,因此拆分为几部分进行翻译发布,本文是第二部分。
传送门:JavaScript 框架历史(上)

新三剑客

新框架解决了许多维护性问题。开发人员已经体验到新框架的好处并期待着创造更多的交互式网络应用,而另一方面当时的框架太过强调架构和模型而不令人满意。

因此,新的三剑客出现了,包括 React、Vue 和一个全新的 AngularJS 称为 Angular。Angular 是一个成熟的框架,而 Vue 和 React 主要关注在视图层。例如,React 能帮你更高效地开发用户界面,但诸如路由、状态管理和 API 交互等问题需要借助相应的社区生态解决。

其次,以视图层为核心的库不仅可以用于新项目,也可以用在旧项目中,作为增量更新现有代码的方法。例如,通过一次添加一个 React 组件,就可以慢慢地迁移到 React。使用 React 一类的库需要注意,状态管理的库就有许多不同的选择,不同公司、不同项目之间的体系结构可能有很大不同。

React

2013年,Facebook 发布了 React。尽管很多人经常称它为框架,但它其实是一个库。React 是 MVC 中的 V,只处理视图层。视图采用 JSX (JavaScript XML) 编写,可以非常方便地在 HTML 中编写 JS。

React 中最大的特点是数据单向传输。这里有 状态(State) 、 视图(View)事件(Actions) ,视图触发一个更新 状态动作 ,当状态更新时,视图将用新状态重新渲染。这样的好处是 数据单一来源 ,换言之,不必担心多事件处理。

graph LR
  A["动作(Actions)"]-->S
  S["状态(State)"]-->V
  V["视图(View)"]-->A

React 早期依赖类组件:

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      price: 10,
    };

    this.increasePrice = this.increasePrice.bind(this);
  }

  increasePrice() {
    this.setState((prevState) => ({
      price: prevState.price + 1,
    }));
  }

  render() {
    return (
      <div>
        <p>The price is {this.state.price}</p>
        <button onClick={this.increasePrice}>Increase the price</button>
      </div>
    );
  }
}

在后来的 React v16.8.0 中,随着 hook 的出现,引入了有状态组件。Hook 是在函数组件中用于连接 React 状态和生命周期方法的函数。

export default function App() {
  const [price, setPrice] = useState(10);

  return (
    <div>
      <p>The price is {price}</p>
      <button onClick={() => setPrice(price + 1)}>Increase the price</button>
    </div>
  );
}

React 还有一个叫 协调器(reconciliation) 的算法。目的是减少代价高昂的 DOM 操作。它包括一个所谓的 虚拟 DOM(VirtualDOM, VDOM) ,它是内存中的镜像 DOM。当发生更新时(比如使用 setState) ,协调器 创建一个新的 VDOM,并将其与前一个 VDOM 进行比较,然后批量更新的变化就会发送给 React 渲染器

React 16 中,还发布了 React Fiber,重构了 React 核心并支持了增量渲染。简言之,它可以把渲染过程拆分成几块。

最后,尽管 React 没有提供其他如路由等事项,但它提供了一个巨大的社区和丰富的生态。

Vue

2014 年,前谷歌员工尤雨溪发布了 Vue。Vue 的核心是 MVVM 模式中的 ViewModel。Vue 可以更容易的创建 ViewModel,为视图和模型使用双向绑定。对于视图,Vue 就像 Angular 一样,通过添加前缀为 v- 的属性扩展了 HTML 的功能。

Vue 组件使用 watcher 知道何时根据更新的属性重新渲染。这得益于 Vue 的响应式系统设计。Vue 2 与 Vue 3 的实现不同。Vue 2 通过使用 Object.defineProperty 定义 getter/setter 实现响应式。而 Vue 3 使用 ES6 的 Proxy 实现响应式,它在其中拦截设置操作,以知晓何时重新渲染组件。

为了帮助 Vue 知道将什么对象转换成代理,我们从 data() 方法返回一个对象,该方法在组件初始化之前被调用。我们在对象内部添加的属性被称为 依赖项,代理 (Proxy) 知道我们何时获取或设置它们。然后,我们可以使用诸如 computed 属性之类的东西来使用响应式的能力。

<div id="app">
  <!-- 单向绑定 -->
  <p>{{ total }}</p>
  <p>{{ msg }}</p>
  <p>{{ price }}</p>
  <!-- 事件绑定 -->
  <button v-on:click="increasePrice">Increase the price</button>
  <!-- 双向绑定 -->
  <input type="text" v-model="msg" />
</div>

<script>
  new Vue({
    el: "#app",
    data: {
      price: 10,
      tax: 1.25,
      msg: "hello",
    },
    methods: {
      increasePrice: function () {
        this.price = this.price + 1;
      },
    },
    computed: {
      total() {
        return this.price * this.tax;
      },
    },
  });
</script>

在上面的代码示例中,我们看到 totalprice 乘以 tax。因此,每次单击按钮更新 price 时,total 将根据其预定义的公式重新计算。

此外,你可以使用 Vue Single-File Component (SFC) 将 Vue 组件的模板、逻辑和样式封装在一个 *.vue 文件中:

<template>
  <p>hello {{ price }}</p>
  <button @click="increasePrice">Increase the price</button>
</template>

<script>
  export default {
    name: "App",
    data() {
      return {
        price: 10,
      };
    },
    methods: {
      increasePrice() {
        this.price++;
      },
    },
  };
</script>

Vue 的 API 深受 AngularJS、 KnockoutJS、 Ractive.js 和 Rivets.js 的影响。尤雨溪一直在使用 Backbone.js 和 AngularJS,但发现它们不适合那些强交互的项目。所以他需要别的东西,于是他创造了一个小工具来解决这个问题。后来,这个工具最终变为了今天的 Vue 库。它最初的名字是 Seed.js,但是在 npm 上有重名的,尤雨溪把 view 翻译成了法语,然后 Vue 诞生了。

在 Vue 的早期阶段,尤雨溪离开谷歌并作为一名开发人员在 MeteorJs 工作,工作之余仍在 Vue 上投入。直到 Laravel 的创始人 Taylor Otwell 在推特上表示 Vue 使用很方便,Vue 才开始受到特别关注。到今天,它被称为“渐进式框架”,因为核心库关注于视图层,你就可以渐进地集成其他项目代码以扩展项目功能。在我看来,直到 Vue 第 2 版的文档 ,他们才开始称之为渐进式框架。

Angular

2016年,AngularJS 进行了全面重构。它面向移动,提供了 CLI,并且删除 “JS” 重新命名为 “Angular” 。这意味着 AngularJS 是版本 1,Angular 是版本 2/3/4/…… 然而,许多 AngularJS 开发人员对这种变化并不满意,因为 Angular 与 AngularJS 几乎没有相似之处,而且缺乏向前兼容。

Angular 根据 AngularJS 和互联网的历史经验改进了许多功能。AngularJS 是用 ES5 构建的,但现在 Angular 可以使用 ES6 的特性,并且默认使用 TypeScript 而不是 JavaScript。AngularJS 使用 scopecontrollers ,而新的 Angular 依赖于组件。通用指令的名称也有变化,例如 ng-repeat 被改成了 ngFor。最后,Angular 还有很多改进,比如底层的改进。

Angular 还建议在 观察者模式 的下使用 响应式编程 。Angular 使用 RxJS (JavaScript 的响应式扩展) 来实现模式。该库使用可观察的内容,并允许我们方便地创建异步回调。Angular 表示,他们将使用 RxJS 除非 JavaScript 能原生支持。

所有这些变化确保了 Angular 在面对 Vue 和 React 的崛起时仍可以保持相关性(听起来很残酷,但这是真的)。最后,与 React 和 Vue 不同,Angular 不使用虚拟 DOM(Virtual DOM),因为它使用的是增量 DOM(Incremental DOM)

探析数据绑定

数据绑定是连接数据和 UI 的操作。 JavaScript 框架以不同的方式解决数据绑定。现在我们将深入地研究框架在进行数据绑定时所面临的挑战。

理解数据绑定是快速学习新框架的基础。因此,我们将从学习三种框架数据绑定方法的基础开始。然后,我们将通过研究双向数据绑定和单向数据绑定之间的区别来结束本节。

框架实践

现在我们将从实践来了解 Knockout, Vue, and Angular。

Knockout.js 实践

Knockout 有三个核心特性:

  • 可观察和依赖跟踪
  • 声明式绑定
  • 模板

为确保 Konckout 检测到 ViewModel 的属性变化,我们需要使用 ko.observable 函数声明它们为 可观察值(observables)

const viewModel = {
  message: ko.observable("first message"),
};

不要为可观察属性赋值,因为这会覆盖可观察属性。应该使用以下规则:

viewModel.message("new message"); // SET - OK
viewModel.message(); // GET - OK
viewModel.message = "new message"; // SET - NO
viewModel.message; // GET - NO

然后我们使用 Knokout 的绑定功能将 ViewModel 链接到 UI。在 HTML 元素上,我们添加一个名为 data-bind 的属性,在该属性中,我们将一个名称绑定到一个类似于 name: value 的值:

The message is: <span data-bind="text: message"></span>

我们还可以通过用逗号分隔来为一个元素添加多个绑定:

The message is: <span data-bind="text: message, visible: true"></span>

最后,使用 ko.applyBindings 来使绑定生效:

ko.applyBindings(viewModel);

Knockout 还提供了很多像可观察数组、计算属性以及订阅能力。最后,当发生改变时,Knockout 只会更新受影响的那部分 DOM。

Vue 实践 Vue in Action

为了能清楚的理解 Vue,我们需要理解 指令。指令是以 v- 为前缀的属性,用以为库标识可以在 DOM 元素上做一些处理。这有一些需要认识的指令:

  • v-bind: 用于绑定属性,可缩写为: :attribute="method".
  • v-on: 用于绑定事件,可缩写为: @event="method" (也可以是 @event.modifier)
  • v-model: 用于创建 双向数据绑定.
  • v-if: 条件渲染
  • v-for: 列表渲染

不要忘记 生命周期函数componentsdata()methodscomputed。你可以给导出的对象上增加这些属性。例如, components 用于注册导入的组件,data() 是组件的状态,methods 包含组件的方法。最后, computed 和 React 的 useEffect 相似,只在依赖更新时更新。

下面的例子展示了 Vue 中的数据绑定的工作过程。在 Vue 中,原生 JavaScript 对象表示 modelsv-model 指令为 <textarea>, <radio>, <select>, <input> 等元素提供了一个简单的创建双向绑定的方法。

<template>
  <div>
    <p>{{ inputText }}</p>
    <input v-model="inputText" />
  </div>
</template>

<script>
  export default {
    data() {
      return {
        inputText: "",
      };
    },
  };
</script>

Vue 在组件内部使用双向绑定,但在值变化后向父级触发事件。因此,prop 永远不会发生突变(mutate),如果开发者错误地实现了父子双向绑定,可能会导致混乱的结果。在比较其他框架时,我们将看到更多使用 Vue 的双向绑定。

Angular 实践

Angular 中,组件与定义视图的模板相关联,组件的逻辑在 class 中。Angular 坚持使用装饰器,如已被广泛使用的用于定义组件信息的 @Component 装饰器。一个类只有使用了 @Component 装饰器才能称为一个 组件类 (component class)

一个 Angular 组件的核心构成:

  1. Template: 一段渲染相关组件的 HTML
  2. Class: 定义组件的行为
  3. Selector: Angular 选择初始化元素的选择器
  4. 可选的私有样式 (特定组件内生效)

如下代码,@Component 装饰器提供了三个属性(不限于)定义了 selectortemplateUrlstyleUrls

import { Component } from "@angular/core";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  title = "My app";
}

选择器(selector)是 Angular 用于在另一个组件中寻找元素以初始化组件。基于上述例子,HTML 的元素应如下:

<app-root></app-root>

我们可以增加更多的组件逻辑、行为:

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  title = "My app";
  username = "";

  alertWorld() {
    alert("Hello world");
  }
}

然后,可以用组件属性以及指令链接到模板

<div>
  <h1>Welcome to {{ title }}!</h1>
  <p>Username: {{ username }}</p>

  <form>
    <!-- 双向绑定 -->
    <input [(ngModel)]="username" />
  </form>

  <button type="button" (click)="alertWorld()">hello</button>
</div>

指令有 *ngIf, *ngFor 等等。或许,更有趣的是,Angular 为类之间低耦合实现了 依赖注入 设计模式。使用 @Injectable 装饰器,就可以告知 Angular 这个类应该是 DI 系统。

了解这些之后,我们可以创建一个日志服务。

// logger.service.ts
import { Injectable } from "@angular/core";

@Injectable()
export class Logger {
  save(text: string) {
    console.warn(text);
    // do something..
  }
}

为了把服务注入到我们的 AppComponent 中,我们需要在构造器中提供一个依赖类型的参数,如下:

// app.component.ts
import { Component } from "@angular/core";
import { Logger } from "./logger.service";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
  providers: [Logger]
})
export class AppComponent {
  title = "My app";
  username = "";

  constructor(private logger: Logger) {}

  alertWorld() {
    this.logger.save("hello world executed");
    alert("Hello world");
  }
}

最后,值得注意的是 Angular 在测试上有很多投入。

双向数据绑定 vs 单向数据绑定

首先,用 Knockout 写一个示例来展示双向数据绑定与单向数据绑定的差异:

<!-- 双向数据绑定. 在初始后,会在两个方向进行同步 -->
<p>Value 1: <input data-bind="value: value1" /></p>

<!-- 单向数据绑定. 在初始后,会从 <input> 同步数据到 model -->
<p>Value 2: <input data-bind="value: value2" /></p>

<script type="text/javascript">
  var viewModel = {
    value1: ko.observable("First"), // Observable
    value2: "Second", // Not observable
  };
</script>

正如我们看到的,当我们想要从视图向模型更新数据的时候,双向数据绑定非常方便,反之亦然。但我们从两个方向更新数据的时候就需要承担更多责任,因为复杂度会上升。

取决于框架如何处理数据绑定,最终可能会得到一个无限循环或难以推理的代码。例如,设想下面的场景:

  1. 模型 A 更新 模型 B.
  2. 模型 B 更新 视图 B.
  3. 视图 B 更新 模型 B.
  4. 模型 B 更新 模型 C.
  5. 模型 C 更新 视图 C.
  6. 视图 C 更新 模型 A.

双向数据绑定也会降低性能。 AngularJS (v1.x) 有一个 摘要循环(digest cycle) 来确保数据绑定。当使用双大括号 {{ }} 或者使用内置指令(如 ng-if="isValid"ng-repeat)引用变量时,通常会创建一个 观察者(watcher)。 摘要循环(digest cycle)用来检测变更,在摘要循环(digest cycle) 中,AngularJS 将会循环所有观察者,并比较被观察者的前一个值与最新值。

之所以出现这个问题,是因为 AngularJS 支持“真正的”双向绑定。因此,仅仅通过摘要循环进行一次迭代是不够的,因为模型可能会更新视图,反之亦然。因此,当 AngularJS 发现一个更改时,它将触发连接的代码,然后它需要再次循环整个摘要循环(digest cycle)。AngularJS 将重复该过程,直到没有检测到任何更改。

// 触发一个无限的摘要循环
$scope.$watch("counter", function () {
  $scope.counter = $scope.counter + 1;
});

为了解决这个问题,Angular (2.x 及以后版本) 移除了双向绑定,而是使用单向绑定。这可能令人疑惑,因为 Angular 的文档说 [(ngModel)] 支持双向绑定。实际的意思是,它利用单向绑定的机制实现了双向绑定的效果。

<input [ngModel]="username" (ngModelChange)="username = $event" />

<!-- 上述代码的缩写示例 -->
<input [(ngModel)]="username" />

Yet the it's not as simple as two-way binding is evil. Evan You, explained when asked about the two-way binding that Vue and Angular (2.x and up) use "two-way binding" as syntax sugar for working with forms. Evan keenly emphasizes that this two-way binding type is a shortcut for a one-way binding used in conjunction with a change listener that modifies the model. Thus, this approach to form handling should not be confused or compared with data-binding between scopes, models, and components.

尤雨溪在被问到双向绑定问题时解释说, Vue 和 Angular(2.x 及以后版本) 使用的双向绑定只是用在表单中的一个语法糖。尤雨溪特别强调,这种双向绑定是模型与监听器同时使用单向绑定时的一种快捷方式。因此,不应将这种表单处理方法与作用域、模型和组件之间的数据绑定混淆或比较。

也就是说,在 Vue v1.x 中,可以选择支持组件之间的双向绑定:

<!-- 双向 prop 绑定 -->
<my-component :prop.sync="someThing"></my-component>

然而,在 Vue 2 中废弃了 .sync。Vue 的文档也明确声明了像 prop-mutation 这种是反模式的。这样的变化是因为组件间的双向数据绑定会造成维护的麻烦。

在这一点上,Vue 现在强制在组件之间使用单向绑定,因为当父组件重新渲染时,会覆盖所有给子组件参数的变更。下面是一个反例:

<!-- App.vue -->
<template>
  <div id="app">
    {{ badTwoWayInput }}
    <input-form v-bind:badTwoWayInput="badTwoWayInput"></input-form>
  </div>
</template>

<script>
  import InputForm from "./components/InputForm";

  export default {
    name: "App",
    components: {
      InputForm,
    },
    data() {
      return {
        badTwoWayInput: "start value",
      };
    },
  };
</script>
<!-- InputForm.vue -->
<template>
  <input v-model="badTwoWayInput" />
</template>

<script>
  export default {
    props: {
      badTwoWayInput: String,
    },
  };
</script>

注意这里会抛错:

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "badTwoWayInput"

框架和库经常吸取和解决困扰用户的问题,这似乎至少是 Angular 的成功原因。我目前的观点是,在处理表单时,将双向绑定作为语法糖是方便的,可以避免不必要的事件处理程序。同时,组件和模型之间使用双向绑定,事情就变得糟糕了。