前端框架自欺欺人,TypeScript全无必要?

前端框架的复杂度最近一段时间频频遭到质疑,引发了一些吐槽,甚至有一篇文章提到:『前端所有主流的框架,都是在自欺欺人』。本文主要是向前端的初学者介绍前端框架的发展历程及设计思想,比如为何要引入这样那样的“复杂度”?这样『设计』有什么好处?是为了解决什么问题?了解其背后的原因,我们或许就不会那么多抱怨了。

目录

  • 1 前端框架怎么你了?

  • 2 前端框架的发展史

  • 3 前端框架为何要引入“复杂度”

  • 4 为什么当下这么多主流框架?

  • 5 知其然知其所以然

  • 6 题外话:Typescript 引入复杂度了吗

  • 7 总结

01 前端框架怎么你了?

最近 Rich Harris 介绍了 Svelte 5 的新特性 —— runes。这引发了一位外国小伙的不满,小伙发表了一篇文章《前端所有主流的框架,都是在自欺欺人》对各种主流前端框架进行了一番吐槽。

到底这个 Svelte 5 的 runes 是设计得多“反人类”?以至于外国小伙这么无法忍受。

为此,笔者仔细地阅读了 Svelte 5 发布新特性的文章 《Introducing runes》。下面一个小节将讲讲这个 Svelte 5 的新特性。https://svelte.dev/blog/runes

1.1 探秘 Svelte 的新特性 runes

原来,这个 runes 的特性是 Svelte 的响应式编程的强化版本。众所周知, Svelte 是目前使用人数最多的编译型前端框架。与 Vue, React 基于运行时的数据管理方式不同,Svelte 的响应式是在编译期间处理的,这么做让 Svelte 的渲染性能有比较明显的优势,跟原生 JS 性能接近。

大致的原理是这样的:Svelte 通过魔改了 JavaScript 编译器,让 JavaScript 的赋值语句带有响应式的能力。让我们看看下面这段 Svelte 的代码:

1
2
3
4
5
6
7
<script>  
let count = 0;
const handleClick = () => {
count += 1;
}
</script>
<div> count: {count} <button on:click={handleClick}>inc</button></div>

上面的代码,点击按钮就能让 count 递增,进而让页面显示最新的 count 的值。这种让赋值语句带有响应式的魔法,正是因为 Svelte 的编译器识别了 “count += 1” 是一个赋值语句,为其生成了响应式的逻辑。

但目前版本的 Svelte 框架还存在一些问题需要解决。比如,当我们把 “let count = 0;”放到一个函数内部, Svelte 就不会给他加上响应式的逻辑了。这就与 Svelte 一开始给我们的变量自动带有响应式的开发体验相悖,导致了语句的歧义,从而提升了开发的心智负担。我们在开发 Svelte 要时刻提醒自己,只有把变量定义在最外层,才具备响应式。

而 Svelte 5 的 runes 实际上就是为了消除这些心智负担而设计的。

在 Svelte 5 中,只要把 “let count = 0;” 改为 “let count = $state(0);”,数据就具备响应式。这种新的写法让所有响应式的写法统一起来,不管你把语句写在哪里,只要加上 $state 即可。这实际上是给开发者进行减负,消除了响应式定义的歧义。

 ##### 1.2 外国小伙的槽点

而正因为这个改动,变成一个导火索,点燃了前文说到的外国小伙,发表了一篇文章对所有主流的前端框架进行批判。

大致看了下外国小伙的文章,他有以下一些槽点:

    1. HTML 不是前端框架最佳的选项;
    1. 前端框架引入了复杂度问题;
    1. 前端框架编造出的模板语法完全没必要,用 DOM API 更好;
    1. 不同框架的模板语法不统一。

这也是目前前端新人会吐槽的点,随着前端不断引入新的框架、打包工具、概念等,很多人表示『学不动了』。

接下来我们重点从前端框架来看下复杂度增加的原因及利弊,在此之前我们先了解下前端框架的发展历史,弄清楚前端为什么会发展到现在这样。

02 前端框架的发展史

前端框架从无到有,其实是伴随着前端开发的复杂度从简单到复杂的发展历程而演变的。

最早的网页,由于浏览器功能的局限,在网页端并没有逻辑,是纯展示。包含业务逻辑,展示逻辑在内的所有逻辑都在后端处理。这个时候前端的职责非常薄。

后来 Netscape 公司发明了革命性的 JavaScript ,让网页可以运行程序,这就让程序员可以把一部分交互逻辑放到网页端。当时存在一个问题是,不同浏览器厂商对外暴露的 BOM 接口和 JavaScript 语法在细节上是有差异的,并且能力上并不对齐。导致当时的 web 程序员在开发同一个逻辑时,不得不适配多个浏览器的接口。

当时每个程序员都在重复这样的工作。这个时候,jQuery 和 Prototype.js 呼之欲出。它们把底层对浏览器的接口调用都封装了一层,在不同浏览器上可以采用同样的写法,解决了程序员反复开发浏览器兼容代码的问题。

这应该算得上是最早期的前端框架的形态,但其更像是工具函数的封装,它的诞生主要是为了解决浏览器兼容性的问题。

直到 DOM 标准, ECMAScript 标准的制定,各个浏览器内核开始遵循标准,最终趋于统一,差异问题逐渐消除,这类前端框架才退出历史舞台。

随着前端项目不断复杂化,面临着项目规模不断变大带来的模块管理困难的问题,这时候支持模块管理的工具库应运而生,比如 SeaJS 还有 RequireJS。后面又出现了基于编译的模块构建工具,如 fis、webpack、vite 等等,进一步优化模块加载、分包等问题。

同时通过 MV* (MVC,MVP,MVVM)设计模式降低复杂度的框架也不断涌现,如 Backbone、Ember、Knockout、Angular、React、Vue 等等。

后面随着浏览器能力不断提升,前端被赋予的职责也越来越多,而开发的复杂度也随之提升。伴随而来的是,复杂度产生的可维护性低问题。基于直接操作 DOM,BOM 的开发模式,没有运用一定的设计模式,必然会随着需求的迭代凸显维护性低的问题。

所以这个时候诞生的框架,就是为了提升可维护性而产生的。它们带来了组件的概念,响应式数据的概念,模板渲染的概念。这些设计模式,帮助我们开发出封装性更好,复用性更强,隐藏了 DOM 的操作的底层细节,这些特性都大幅降低了项目的复杂度。

纵观前端框架发展史,我们可以看到,每个框架的出现都是为了解决当下的一个痛点,当然框架本身会引入一定的复杂度,但整体来说是利远大于弊。

03 前端框架为何要引入“复杂度”

接下来,我们聊聊现代前端框架带来的“复杂度”。为什么要引入这些“复杂度”,以及这些设计带来的好处是什么?

 ##### 3.1 HTML 模板:隐藏实现细节,降低开发难度

我们知道现代的前端框架基本都采用了类似 HTML 的语法来开发界面。并或多或少对这种语法进行扩展,支持条件渲染,循环渲染,组件渲染等等。可能刚开始接触会觉得稍微有点理解成本。

下文将对比原生的写法,来找出这种设计的必要性。

松散 VS 结构化

我们知道直接使用 DOM 开发,通常是使用 document.createElement,appendChild, removeChild 对 DOM 树进行操作,通过 setAttribue 修改 DOM 的属性。

使用这种原始的 API,我们需要时刻关注很多 DOM 的增删改查的细节,处理起来比较繁琐,也不够优雅。我们写出来的,可能是一堆松散的 DOM API 调用。

比如我们要实现这么一个功能:界面上有一个方块和一个按钮,每按下按钮,当方块是显示状态,则隐藏方块,当方块是隐藏状态,则显示方块。

使用原生的 API 实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
<div class="block">a block</div>
<button class="toggle-button">toggle block</button>
<script>
const block = document.querySelector('.block');
const toggleButton = document.querySelector('.toggle-button');
let blockVisible = true;
toggleButton.addEventListener('click', () => {  
blockVisible = !blockVisible;  
block.style.display = blockVisible ? 'block' : 'none';  
});
</script>

从代码可以看到,我们需要对每个要操作的 DOM 定义类名,方便我们拿到他们的引用,需要获取对 DOM 节点的引用:document.querySelector('.block'),对 DOM 事件进行绑定: toggleButton.addEventListener('click', () => {})

我们开发过程中,不希望去关注这些重复的细节,我们需要更直观的写法。我们希望能直观地从模板就看出我们这个程序的意图,比如按钮点击了要去执行什么逻辑,某个 div 是否有显示隐藏的状态变化。我们看看前端框架(Vue) 是怎么实现:

1
2
3
4
5
6
7
8
9
10
11
<template>
<div v-if="blockVisible">a block</div>
<button @click="handleClick">toggle block</button>
</template>

<script setup>
const blockVisible = ref(true);
const handleClick = () => {
blockVisible.value = !blockVisible.value;
}
</script>

上面的代码看起来就很简洁了,也更结构化了。

只需要改变 v-if 的值,Vue 就会帮我们处理了 DOM 节点的“显示”和“隐藏”。

在 DOM 版本代码里的三个步骤,定义类名、获取引用、绑定事件,在 Vue 里只剩下绑定事件需要我们做,而 Vue 这种绑定事件的写法也更加简洁。

框架帮我们监听了状态的变化,并自动更新了视图,比如上面例子里 的 blockVisible,我们只要对它赋值,Vue 就会知道更新哪里的视图,不需要我们记住这个变量关联了哪个 DOM 节点。

@click 帮我们绑定了事件,让我们直观的知道按钮按下,就要去执行一个叫 handleClick 的方法。

整个开发过程,我们不需要关注 DOM 节点是怎么操作的,符合对隐藏细节的封装原则。

设想如果我们在开发业务的过程,还要不断地考虑怎么操作 DOM,DOM 和数据之间怎么关联,其实是不符合职责的解耦原则,我们首要关注的是我们开发的业务逻辑, DOM 操作,UI 状态流转交给框架。

所以这个“复杂度”其实是降低了我们开发的难度,是我们可以更加专注在业务逻辑,而且代码看起来更加结构化了,使得代码更易开发、维护成本都得到了大幅提升。

注:这里用 v-if 会直接挂载或删除 DOM 节点,如果要一比一还原 DOM API 版本的代码,只需要改为 v-show

3.2 组件:提升复用性

相信没有一个现代的前端框架,能够脱离组件的概念。那为何要引入组件这个“复杂度”呢?

直接用 DOM 铺开来写,可不可以呢?

答案当然是不行的。就跟我们写通用的代码逻辑一样,必不可少的就是封装。如果我们不对重复的逻辑加以封装,那代码将会变得冗余,导致难以维护。

我们开发过程中,都会对重复的逻辑进行封装,变成函数,或者类,通过不断的拆分、封装、解耦,让我们的代码时刻保持在一个可维护的状态。

但早期的 DOM 规范是没有组件的概念的(注:直到 Web Component 的诞生),所有组件复用的逻辑,都需要自己封装。

比如我们需要编写一个经典的 Todo list。如果我们使用原生 DOM,是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<div class="todo-list">
<div>
new item 1
<button>X</button>
</div>
<div>
new item 2
<button>X</button>
</div>
</div>
<button>add item</button>

<script>
const todoList = document.querySelector('.todo-list');
const addButton = document.querySelector('button');

addButton.addEventListener('click', () => {
const todoItem = document.createElement('div');
const deleteButton = document.createElement('button');
todoItem.appendChild(deleteButton);
todoItem.innerText = 'new item';
todoList.appendChild(todoItem);
});
</script>

上面的代码,基本没有什么复用性可言, todoItem 的逻辑完全跟 todoList 耦合了。

或许我们可以封装一下,让 todoItem 不与 todoList 耦合。于是我们把代码改成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<div class="todo-list">
</div>
<button>add item</button>

<script>
class TodoItem {
constructor(content) {
const todoItem = document.createElement('div');
const deleteButton = document.createElement('button');
todoItem.appendChild(deleteButton);
todoItem.innerText = content;
this.dom = todoItem;
}
appendTo(parent) {
return parent.appendChild(this.dom);
}
}

const todoList = document.querySelector('.todo-list');
const addButton = document.querySelector('button');

new TodoItem('new item 1').appendTo(todoList);
new TodoItem('new item 2').appendTo(todoList);

addButton.addEventListener('click', () => {
new TodoItem().appendTo(todoList);
});
</script>

这样可能会好一些,我们把跟 TodoItem 相关的逻辑都封装到 TodoItem 类里。这样,我们不仅可以把 TodoItem 用在 todoList 的场景,也可以用在其他场景。

如果用现有的前端框架,组件的功能已经原生内建了,我们可以开箱即用,编写起来更简洁更优雅。

我们是可以按照上面原生的方式去封装,但实际的情况远没有一个 demo 这么简单,我们需要考虑样式隔离,组件生命周期等等,现代框架 React、Vue.js 非常好的解决了这些问题。

我们看看如果用 Vue 怎么写同样的逻辑:

todo-item.vue:

1
2
3
4
5
6
7

<template>
<div>
new item
<button>delete</button>
</div>
</template>

todo-list.vue:

1
2
3
4
5
6
7
8
9
10
11
12
<div class="todo-list">
<todo-item v-for="item in todoItems" />
</div>
<button @click="handleClick">add item</button>

<script setup>
import TodoItem from './todo-item.Vue';
const todoItems = [];
const handleClick = () => {
todoItems.push({});
};
</script>

这么实现,代码量直接减少 10 行,同时我们获得了数据响应式,DOM 节点复用(v-for),样式隔离等等好处。

这里 Vue demo 单独定义了 一个 todo-item.Vue 的组件,可以直接在 todo-list 组件里引用,通过 v-for 语句,可以遍历插入 todo-item 组件。我们只需要修改 todoItems 数组的值,对应的视图就会更新。

在这些框架里,我们可以把一个组件当成一个 “HTML 标签”来使用,其实这也是 web components 的思想。

这样写出来的代码,通过看 HTML 模板的代码,就可以很清楚的看出组件的层级关系。

3.3 VDOM/ 编译器机制:跨平台

假如我们用原生的 DOM API 写了一个网页应用,但我们需要进一步开拓我们的应用市场,我们的应用需要作为一个独立的 App 或者小程序进行发布。这个时候我们第一时间想到的方法有两种:第一是重新用 Java,Swift 和小程序框架重写我们的应用,第二种是我们把网页作为一个内嵌页,嵌入对应的外壳里。

第一种研发成本非常大,第二种无论性能还是体验都比较一般。那是不是鱼和熊掌不能兼得呢?

既要研发成本低,又要性能体验好,其实使用现代前端框架是一个合适的方案。现代的前端框架,底层基本都是大同小异的设计思路,不是虚拟 DOM 就是编译机制。无论是怎么样的形式,它们都做到把面向开发者的接口与面向底层的细节隔离开了。这种设计的好处是,我们开发的代码可以具备跨平台的能力。

比如 React 有 React Native,Taro, Vue 有 Weex,Uni-App 等原生运行时,我们几乎可以用最小的改动成本,将我们面向网页开发的应用迁移到移动端或者小程序上面。

我们可以看看下面这个图:

就像上面的示意图,框架面向开发者提供了一套抽象的接口,开发者基于这套接口开发应用,不会接触到平台底层的细节。

比如,当我们使用 Vue 开发一个应用时,我们通常是不需要关注如何操作 DOM,如何绑定样式的。只要框架在内部对接了多套平台的 API,开发者开发的应用就可以运行在多个平台上,并且做到一次开发到处运行。

其实我们不需要深入探讨每个框架是怎么实现的,只需要知道,在框架的设计中,有这么一套对底层平台的抽象:把 UI 元素的创建,更新,删除等接口抽象出来,然后再针对不同平台实现对应的操作。

下面的伪代码描述了框架是如何做到:

面向底层的抽象接口:

1
interface IUIOperations {

面向浏览器端的实现:

1
2
3
4
5
6
7
interface IUIOperations {
createElement(type: ElementType): Element;
removeElement(ele: Element);
updateElement(ele: Element);
setProperty(ele: Element, propName: string; propValue: string);
removeProperty(...
}

面向原生平台的实现:

1
2
3
4
5
6
7
8
9
10
11
class DOMUIOperations implements IUIOperations {
createElement(type: ElementType): Element {
return new DOMElement(document.createElement(type.getName()));
}
removeElement(ele: Element) {
...
}
updateElement(ele: Element)
...

}

网上有一些文章说虚拟 DOM 的作用之一是提供这种跨平台的特性,实际上按照我们上面所画的框架架构图,内部实现是怎么样的对是否能跨平台其实并没有太大影响,只要做好对底层平台的抽象就可以了。这也就是为什么现在编译型的框架,虽然它并没有虚拟 DOM,照样也能跨平台。

有了这个机制,就可以实现一套代码同时跑在移动端 App、PC 应用程序、H5 页面等多端。

3.4 数据响应式:降低数据管理复杂度

早期,我们使用 DOM 开发应用时,遇到数据与视图之间的状态同步场景,通常免不了手忙脚乱对 DOM 的一顿操作,开发过程中要时刻关注每个数据关联的 DOM 节点。

而数据响应式的诞生,让我们开发中,不需要关注这些细节。我们只需要操作数据,框架可以让视图可以自动更新。

假设我们需要在按钮按下时,将一段文本反转过来,并显示到页面上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div>
</div>
<button>reverse</button>

<script>
const div = document.querySelector('div');
const button = document.querySelector('button');
let msg = 'hello, world';
div.innerText = msg;
button.addEventListener('click', () => {
msg = msg.split('').reverse().join('');
div.innerText = msg;
});
</script>

每当需要往视图上更新数据时,我们都需要对 DOM 进行显式的修改。

我们再看看如果通过框架的数据响应式,上面的程序会是怎么写的:

1
2
3
4
5
6
7
8
9
10
11
<div>
{msg}
</div>
<button on:click="handleClick">reverse</button>

<script>
let msg = 'hello, world';
const handleClick = () => {
msg = msg.split('').reverse().join('');
};
</script>

我们发现,我们并没有看到哪一行代码是显式在修改视图的,数据与视图唯一有关联的地方,就是在视图模板里加入了数据变量的引用:

{msg}

当我们对数据修改时,框架就可以感知这种修改,并对数据所关联的视图进行刷新。

具体是怎么做到的呢?每个框架的实现都不尽相同。

这里以 Vue 的实现简单说一下,当 Vue 按照模板首次渲染时,会收集模板和数据变量的关联关系,相当于视图订阅了数据变量变化的事件,一旦数据发生变化,就会根据这个关联关系,找到对应的视图,并调用它的更新函数。

有了框架帮我们处理好数据和视图的关联,我们就不需要自己显式的管理和操作数据关联的 DOM。我们的心智负担进一步降低,这让我们有更多的精力去开发上层的交互和业务逻辑。

04 为什么当下这么多主流框架?

上面讲了现代框架引入复杂度的好处,那是否可以一个框架就够了呢?这些框架做得都是大同小异的事,为何还需要重复造轮子呢?

有 React,Vue,Angular,近期又有了 Svelte, solid,最近又出现了 Qwik,Astro。

其实每个框架的诞生也有其背景和其想解决的问题。

接下来我们重点聊下现代框架的发展历程,及每个框架的设计哲学和对应的受众。

4.1 React 诞生的意义

首先聊聊 React,2011 年前后, Facebook 的业务快速发展,产生大量需求。

这里需要注意一下当时的背景,这个时期主流的开发方式还是通过 jQuery 直接操作 DOM,手动管理 UI 的状态,自行确保视图和数据之间的状态同步。

随着需求的不断增多,如果继续采用这种传统的开发方式开发 UI 交互,必然会带来后续维护困难的问题。

在这种背景下,要继续叠加需求,只能通过不断加入开发人员,不断产生大量的冗余的交互逻辑和错乱的状态管理。这没有根本解决问题,这个问题需要从设计层面上来优化。

React 就是在这样的背景下诞生,最初只是为了解决 Facebook 内部开发的可维护性低问题。在内部实践取得成功后,再逐步对外推广,在确实解决了其他开发者同样面临的痛点后,最终才成为一个主流的框架。

React 框架的设计理念之一是极简主义

从语法角度上看, React 在传统的前端技术栈上,只引入了 jsx,用于表达虚拟 DOM 的构造过程,其他的一切都是原生的 JavaScript。这样设计的好处是,降低了使用者的学习成本和心智负担,让框架的灵活性极高。比如,我们可以使用原生 Javascript 的 if else 语句表达视图的条件显示,用 for,map 等表达视图中的循环列表,而不需要使用特殊的语法。

从库的职责上看,React 的核心只有 UI,不包含 store,路由等功能,开发者可以自行选择合适的第三方库搭配使用。

React 的另一个设计理念是函数式编程

React 强调把视图的渲染更新当做是一个纯函数,尽量在一部分组件里避免副作用。这样带来的好处是,在代码组织上,组件的状态管理更为内聚清晰,在测试上,组件的可测性更强。

React 的设计理念让 React 使用起来极为的灵活。灵活的好处就是可定制性强,代价缺少约束。所以使用 React 的开发用户要求更高,还有需要配套搭建前端工程化,建立适合自身述求的开发约束。

4.2 为何还有 Vue

既然 React 已经解决了当下的问题,为什么 Vue 还有市场呢?让我们看看 Vue 是怎么走出一条自己的路来的。

早在 2013 年,Vue 是作为尤雨溪的个人实验作品诞生的。发布之后,很快得到一些开发者的认可。比如,PHP 框架 Laravel 的作者 Taylor Otwell 表示,他学习 Vue 的原因是 React 太难学了, Vue 很好入门,使用起来也很简单。这个观点可以代表一部分最早接触 Vue 的人。

正如前面所说,React 当时的设计理念是极简主义,大部分操作都通过 JavaScript 来编写,并且不官方捆绑像状态管理,路由等配套的库。这对于初学者来说并不友好。

早期,web 分工很细,有专门切图的,有专门写 html 的,专门写 css,还有专门写 JavaScript 逻辑的。

所以 React 的设计对于一些不太熟悉 JavaScript 的 Web 开发者来说,并不友好,他们更愿意接受类似 HTML 的写法。

而 Vue 的设计正好符合他们的口味,他们从传统的项目,过渡到 Vue 远远要比过渡到 React 来的简单得多。

这就要讲到 Vue 的设计理念之一,渐进式的开发理念。大白话就是,让框架的初学者更容易接受。

Vue 采用了跟传统 HTML 开发接近的语法,在同一个文件里,通过 template 标签定义模板,script 标签定义 JavaScript 逻辑,在 style 标签内定义样式。初学者从传统 HTML 开发转过来,开发思想的惯性得到了保持,开发 Vue 就像在开发 HTML 一样。

再一个是,Vue 保留了前后端未分离时期,后端模板渲染的那一套,也就是在 HTML 的基础上扩展条件渲染,循环渲染的语法。这让从旧时代后端模板渲染的那些开发者感到格外亲切,也更容易接受。

Vue 的另一个设计理念,开箱即用,俗称 Vue 全家桶。

这是 Vue 的另外一个杀手锏,通过捆绑官方的状态管理 Vuex,路由 Vue-router,让用户免去这些功能的选型困扰,做到开箱即用。这样做的好处是,让一部分没法自行做出合理技术选型的用户,可以在官方的推荐下,被动做出正确的技术选型。

除此之外,Vue 还很贴心的设计了提供了数据响应式的设计,使用者不需要关注数据驱动视图的细节。官网提供非常完善友好的文档,并翻译成多国语言等等。

Vue 的核心设计理念可以总结为:初学者友好向的框架。正是 Vue 的设计者意识到,一部分框架虽然设计思想很先进,但学习成本却比较高,阻挡了一部分用户。所以 Vue 从易用性角度设计框架,不出意外获得大批被其他框架劝退的开发者。

4.3 Svelte

随着 React,Vue 的广泛使用,基于虚拟 DOM 构建前端框架已经成为一种主流的方式。早期对虚拟 DOM 的宣传是,可以减少对 DOM 的操作次数,优化渲染性能。现在这一说法被推翻了,按现在的说法是,虚拟 DOM 是封装了 DOM 的操作细节,降低开发的复杂度。

那虚拟 DOM 是唯一的抽象方式吗?

答案是否定的。Svelte 就是那个推翻虚拟 DOM 垄断的框架。Svelte 创新的提出了基于编译的方式,来解决对 DOM 操作的封装。

为什么 Svelte 要采用编译来解决这一问题呢?

这里需要讲到 Svelte 的设计理念之一:性能优先。正是因为 Svelte 设计的初衷就是做一个轻量级,注重性能的框架,使它抛弃了虚拟 DOM 的方式。虚拟 DOM 需要重复生成虚拟 DOM 树,进行 diff 比对,DOM patch 等操作,这些都是运行时的性能损耗。Svelte 的解决之道是,通过把这些操作提前到编译期来处理,通过编译,生成对应的命令式语句,直接对 DOM 进行更新,有效的把计算从运行时转移到编译期。在 Svelte 的内部,为了追求性能,还通过位运算做变量的变更标记。由于 Svelte 没有传统意义上的运行时,其框架体积也非常小,有利于首屏加载。

Svelte 的另一个设计理念是降低心智负担,具体体现在 Svelte 对数据响应式的设计上。传统的数据响应式,都需要利用到语言的一些 hack 方法来模拟,使用起来其实不太直观,存在一定的心智负担,比如 Vue3 的 ref,需要通过 .value 来取值。而 Svelte 通过编译技术,很好的规避了这个问题。在 Svelte 里,变量定义自然就会获得数据响应的能力,这是因为,在编译时,Svelte 会识别 JavaScript 的赋值语法,并针对这个语法额外生成响应式的代码。这样设计的好处是,开发者可以开发符合他们认知的 JavaScript,并且额外获得数据的响应式,而背后的细节由 Svelte 框架帮忙处理,很好地转移了复杂度。

4.4 各有千秋

前端框架都有自己标榜的核心设计理念,比如 React 不断在复用角度深挖,发明了 hooks 的概念。而 Vue 不断在易用性的角度深挖,发明了 setup 写法,让定义响应式数据,就跟编写普通的 JavaScript 一样简单。而后起之秀 Svelte 和 Solid,则是改变了前端框架在处理 DOM 的常规手段,提出了使用编译的方式来处理数据的响应式,来获得比虚拟 DOM 方式更好的性能。

他们各有优劣,都解决了一部分人的痛点,大家可以结合自己团地和业务的实际情况,选择适合自己的框架。

 4.5 整体来看

前端框架之间的关系,如果我们把它们合在一起,作为前端发展历程来看,我们会发现,它们并不是相互排斥的,而是相互借鉴,共同进步的。从整体来看,它们是一个进化的共同体,互相吸收彼此好的东西,摒弃自身不好的东西,最后发展是趋同的。

在这个过程中,跟不上队伍的那个,只能被无情的抛弃,而不断创新的,不断满足开发者诉求,解决痛点的,会继续进化下去。

前端框架除了解决软件设计上的问题外,也跟浏览器的发展密切。

那些炫酷特性的实现,离不开浏览器的发展。比如,Vue 从最开始使用 defineProperty 来实现响应式,到现在使用 proxy ,让响应式的能力更强。

如果一个前端框架仅仅满足于弥补浏览器的不足而存在,那可能在浏览器快速发展的趋势下,会被很快遗忘。比如用来适配浏览器选择器 API 的 jQuery,又或者是某个只有组件封装功能的前端框架,也会被 Web Component 所替代。

只有具备自己的设计理念,并且不满足于这种底层基础的封装的前端框架,才能有机会加入前端框架的竞争之中。

05 知其然知其所以然

所以我们遇到任何事情,都应该知其然知其所以然,了解其背后的原因,了解其实现原理,这样即可以提升我们的认知,也可以帮助我们更好的用好工具,让各种前端框架为我们服务,解决我们实际场景的问题。

比如知道了框架的原理,可以让我们写出更健壮的代码。

很多时候,框架都会给出一些教程,如果我们只是学习了教程,确实是可以开始写代码干活了。但假设我们不知道框架的设计思想,我们不会知道为何要这么写,为何不能那么写。如果遇到教程里没有的,我们就无法变通。

只有深入去理解框架的设计思想,我们才能在开发中化繁为简,轻松驾驭各种开发问题的解法。

06 题外话:Typescript 引入复杂度了吗

最近一段时间,还有一个话题很热,就是探讨 TypeScript 是否有必要,是不是引入了过多的复杂度,甚至觉得写类型比写代码还更难。

TypeScript 确实引入了一定的复杂度, 但却是前端往严谨项目开发的必然趋势。

TypeScript 通过给 JavaScript 加上了类型系统,将 JavaScript 中的语言中弱类型带来的陷阱大部分都规避了,大幅提升了系统健壮性和可维护性。

通常我们使用 TypeScript 会有两种场景,一种是开发业务需求,另一种是开发库 / 框架。

那开发业务需求有必要引入 TypeScript 吗?还是要看情况,如果是严谨正规的长周期维护项目,建议是使用,可以避免大量的弱类型语言陷阱,大幅提升系统的可维护性。如果是比较临时,生命周期极短的项目,比如临时开发的简单小需求,不需要持久迭代的,短期内就会下线的,那可以不需要 。

实际上,日常开发业务,我们通常只会使用类型定义,顶多用到泛型函数,类型定义和简单的类型推导,并不会使用到“Typescript 的类型体操”这种模板元编程的程度。如果因为学不会类型体操,而否定 Typescript 在项目里的作用,就有些过了,它们并没有因果关系。

再说说 Typescript 在开发库 / 框架的场景,毋庸置疑,主流的项目基本都采用 Typescript 来开发了。库 / 框架本身就是一种严谨的项目,你开发的东西是要面向广大的开发者的,你有必要保障项目的质量。可能有的人会说,也有的库是用 JavaScript 写的,用其他工具来静态检测不就可以了。确实是可以,不过相比之下,Typescript 的类型系统足够强大,开箱即用,不是很特殊的理由,是不建议去折腾其他的方案。

07 总结

本文因一篇国外的吐槽文而起,里面的观点错得比较普遍、典型,笔者感觉有必要为前端框架做一下澄清,于是写了这篇文章。全文讲述了笔者对前端框架的前世今生的发展历程,特别重点阐述了现代前端框架的诞生的背景和设计理念,并说明其引入复杂度的原因及收益,如有不同观点,欢迎交流探讨。

原文:https://new.qq.com/rain/a/20231220A058CV00