![Search icon](https://hackernoon.imgix.net/search-new.png?w=19&h=19)
早在 2013 年,我就着手构建一套用于开发 Web 应用程序的极简工具。也许这个过程产生的最好的东西就是gotoB ,一个用 2000 行代码编写的客户端纯 JS 前端框架。
在阅读了许多非常成功的前端框架作者撰写的有趣文章后,我萌生了撰写本文的念头:
这些文章让我兴奋的是,它们谈论了他们所构建内容背后的想法的演变;实施只是使它们成为现实的一种方式,所讨论的特征只是那些对于代表想法本身至关重要的特征。
到目前为止,gotoB 最有趣的方面是面对构建它的挑战而产生的想法。这就是我想在这里介绍的内容。
因为我从头开始构建了框架,并且试图实现简约和内部一致性,所以我解决了四个问题,我认为这种方式与大多数框架解决相同问题的方式不同。
这四个想法就是我现在想与你们分享的。我这样做不是为了说服你们使用我的工具(尽管你们可以这么做!),而是希望你们对这些想法本身感兴趣。
任何 Web 应用程序都需要根据应用程序的状态动态创建标记(HTML)。
最好用一个例子来解释这一点:在一个非常简单的待办事项列表应用程序中,状态可以是待办事项列表: ['Item 1', 'Item 2']
。因为您正在编写应用程序(而不是静态页面),所以待办事项列表必须能够更改。
由于状态发生变化,构成应用程序 UI 的 HTML 也必须随状态而变化。例如,要显示待办事项,您可以使用以下 HTML:
<ul> <li>Item 1</li> <li>Item 2</li> </ul>
如果状态发生变化并且添加了第三个项目,那么您的状态现在将如下所示: ['Item 1', 'Item 2', 'Item 3']
;然后,您的 HTML 应如下所示:
<ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>
基于应用程序状态生成 HTML 的问题通常用模板语言解决,该语言将编程语言结构(变量、条件和循环)插入伪 HTML,然后扩展为实际 HTML。
例如,以下是在不同的模板工具中可以实现此目的的两种方法:
// Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>
我从来都不喜欢这些将逻辑带入 HTML 的语法。意识到模板需要编程,并且想要避免为其使用单独的语法,我决定使用对象文字将 HTML 带入 js。因此,我可以简单地将我的 HTML 建模为对象文字:
['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]
如果我想使用迭代来生成列表,我可以简单地写:
['ul', items.map ((item) => ['li', item])]
然后使用一个函数将此对象文字转换为 HTML。这样,所有模板都可以在 JS 中完成,而无需任何模板语言或转译。我使用名称liths来描述这些表示 HTML 的数组。
据我所知,没有其他 JS 框架以这种方式实现模板。我做了一些挖掘,发现了JSONML ,它使用几乎相同的结构在 JSON 对象中表示 HTML(与 JS 对象文字几乎相同),但没有发现围绕它构建的框架。
Mithril和Hyperapp与我使用的方法非常接近,但它们仍然对每个元素使用函数调用。
// Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])
使用对象文字的方法对于 HTML 来说效果很好,所以我将其扩展到 CSS,现在也通过对象文字生成所有 CSS。
如果由于某种原因,您处于无法转换 JSX 或使用模板语言的环境中,并且您不想连接字符串,则可以使用此方法。
我不确定 Mithril/Hyperapp 方法是否比我的方法更好;我确实发现,在编写表示 lith 的长对象文字时,我有时会忘记某个地方的逗号,而有时很难找到。除此之外,真的没有什么可抱怨的。我喜欢这样一个事实:HTML 的表示既是 1) 数据,又是 2) JS。这种表示实际上可以充当虚拟 DOM,当我们讨论到想法 #4 时就会看到。
附加细节:如果您想从对象文字生成 HTML,您只需解决以下两个问题:
我从来都不喜欢组件。围绕组件构建应用程序需要将属于组件的数据放在组件本身内。这使得与应用程序的其他部分共享数据变得困难甚至不可能。
在我从事的每个项目中,我发现我总是需要在相距很远的组件之间共享应用程序状态的某些部分。一个典型的例子是用户名:您可能需要在帐户部分以及标题中使用它。那么用户名应该放在哪里呢?
因此,我决定早点创建一个简单的数据对象 ( {}
) 并将我的所有状态都放在那里。我把它称为store 。store 保存着应用程序所有部分的状态,因此任何组件都可以使用它。
这种方法在 2013 年至 2015 年期间显得有些异端,但自那时起便逐渐流行,甚至占据了主导地位。
我认为仍然相当新颖的是,我使用路径来访问存储中的任何值。例如,如果存储是:
{ user: { firstName: 'foo' lastName: 'bar' } }
我可以使用路径来访问(比如说) lastName
,方法是编写B.get ('user', 'lastName')
。如您所见, ['user', 'lastName']
是'bar'
的路径。 B.get
是一个访问存储并返回其特定部分的函数,由您传递给该函数的路径指示。
与上述方法相反,访问反应性属性的标准方法是通过 JS 变量引用它们。例如:
// Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');
但是,这要求您在需要该值的任何位置保留对firstName
和lastName
(或userStore
)的引用。我使用的方法仅要求您有权访问存储(它是全局的并且随处可用),并且允许您对其进行细粒度访问,而无需为它们定义 JS 变量。
Immutable.js 和 Firebase 实时数据库做的事情与我做的更接近,尽管它们处理不同的对象。但您可以使用它们将所有内容存储在一个可以精细寻址的位置。
// Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });
将我的数据放在可通过路径进行细粒度访问的全局可访问存储中是一种非常有用的模式。每当我写const [count, setCount] = ...
或类似的东西时,感觉都是多余的。我知道只要我需要访问它,我就可以执行B.get ('count')
,而不必声明和传递count
或setCount
。
如果说想法 2(可通过路径访问的全局存储)将数据从组件中解放出来,那么想法 3 就是我将代码从组件中解放出来的方法。对我来说,这是本文中最有趣的想法。就这么办!
我们的状态是可变的数据(对于使用不可变性的人来说,论点仍然成立:即使保留了状态旧版本的快照,您仍然希望状态的最新版本能够改变)。我们如何改变状态?
我决定使用事件。我已经有了商店的路径,因此事件可以简单地由动词(如set
、 add
或rem
)和路径组合而成。因此,如果我想更新user.firstName
,我可以这样写:
B.call ('set', ['user', 'firstName'], 'Foo')
这绝对比以下写法更冗长:
user.firstName = 'Foo';
但它允许我编写响应user.firstName
变化的代码。这是关键思想:在 UI 中,有不同的部分依赖于状态的不同部分。例如,你可以有这些依赖关系:
user
和currentView
user
items
我面临的最大问题是:当user
更改时,如何更新标题和帐户部分,但在items
更改时不更新?我如何管理这些依赖关系,而不必进行updateHeader
或updateAccountSection
等特定调用?这些类型的特定调用代表了“jQuery 编程”最难以维护的一面。
对我来说更好的主意是做这样的事情:
B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });
因此,如果为user
调用了set
事件,事件系统将通知所有对该更改B.respond
的视图(标题和帐户部分),同时不干扰其他视图(待办事项列表)。B.respond 是我用来注册响应器(通常称为“事件侦听器”或“反应”)的函数。请注意,响应器是全局的,不绑定到任何组件;但是,它们只监听特定路径上的set
事件。
那么首先如何调用change
事件呢?我是这样操作的:
B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });
我稍微简化了一下,但这基本上就是 gotoB 的工作方式。
事件系统比单纯的函数调用更强大的原因是,事件调用可以执行 0、1 或多段代码,而函数调用始终只调用一个函数。在上面的例子中,如果您调用B.call ('set', ['user', 'firstName'], 'Foo');
,则会执行两段代码:更改标题的代码和更改帐户视图的代码。请注意,更新firstName
的调用并不“关心”谁在监听它。它只是做自己的事情,让响应者接收更改。
事件非常强大,根据我的经验,它们可以取代计算值和反应。换句话说,它们可以用来表达应用程序中需要发生的任何变化。
计算值可以用事件响应器来表达。例如,如果您想要计算fullName
但又不想在 store 中使用它,则可以执行以下操作:
B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });
类似地,反应也可以通过响应器来表达。考虑一下:
B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });
如果您暂时忽略生成 HTML 时令人畏缩的字符串连接,您在上面看到的是一个响应器正在执行“副作用”(在本例中是更新 DOM)。
(旁注:在 Web 应用程序的上下文中,副作用的良好定义是什么?对我来说,它归结为三件事:1)对应用程序状态的更新;2)对 DOM 的更改;3)发送 AJAX 调用)。
我发现确实没有必要使用单独的生命周期来更新 DOM。在 gotoB 中,有一些响应器函数在某些辅助函数的帮助下更新 DOM。因此,当user
更改时,任何依赖于它的响应器(或更准确地说,视图函数,因为这是我为负责更新部分 DOM 的响应器起的名字)都将执行,从而产生最终更新 DOM 的副作用。
我让事件系统按相同的顺序逐个运行响应器函数,从而使其可预测。异步响应器仍可以同步运行,而“之后”的响应器将等待它们。
更复杂的模式,你需要更新状态而不更新 DOM(通常出于性能目的),可以通过添加静音动词(如mset )来添加,它会修改存储但不会触发任何响应者。此外,如果你需要在重绘发生后对 DOM 执行某些操作,你可以简单地确保该响应者具有低优先级并在所有其他响应者之后运行:
B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });
上述方法使用动词和路径以及一组由某些事件调用匹配(执行)的全局响应器来构建事件系统,这种方法还有另一个优点:每个事件调用都可以放在一个列表中。然后,您可以在调试应用程序时分析此列表,并跟踪状态变化。
在前端上下文中,事件和响应者允许的内容如下:
根据我的经验,这是他们允许的,不需要做的:
这些实际上都只是事件调用和响应者,一些响应者只关心视图,其他响应者则关心其他操作。框架的所有内部结构都只使用用户空间。
如果你对 gotoB 中的工作原理感到好奇,你可以查看这个详细解释。
双向数据绑定现在听起来已经过时了。但是如果你乘坐时光机回到 2013 年,从第一原理出发解决状态改变时重新绘制 DOM 的问题,还有什么听起来更合理呢?
事实上,选项 2,即从状态到 DOM 的单向数据流,听起来更加复杂,而且效率低下。
现在让我们将其具体化:对于焦点所在的交互式<input>
或<textarea>
,您需要在用户每次击键时重新创建 DOM 的各个部分!如果您使用单向数据流,则输入中的每个更改都会触发状态更改,然后重新绘制<input>
以使其与应有的状态完全匹配。
这对 DOM 更新设定了一个很高的标准:更新速度要快,并且不能妨碍用户与交互元素的交互。这不是一个容易解决的问题。
那么,为什么从状态到 DOM(JS 到 HTML)的单向数据会获胜呢?因为它更容易推理。如果状态发生变化,那么这种变化来自哪里并不重要(可能是从服务器带来数据的 AJAX 回调,可能是用户交互,可能是计时器)。状态总是以相同的方式变化(或者更确切地说是变异)。并且来自状态的变化总是流入 DOM。
那么,如何才能高效地执行 DOM 更新,同时又不妨碍用户交互呢?这通常归结为执行完成任务所需的最少 DOM 更新。这通常称为“diffing”,因为您要列出需要将旧结构(现有 DOM)转换为新结构(状态更新后的新 DOM)的差异列表。
当我在 2016 年左右开始研究这个问题时,我偷懒看了 React 的做法。他们给了我一个关键的见解,那就是没有通用的、线性性能的算法来比较两棵树(DOM 是一棵树)。但是,尽管我固执己见,我仍然想要一个通用算法来执行比较。我特别不喜欢 React(或者几乎任何框架)的一点是,它坚持要求对连续元素使用键:
function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }
对我来说, key
指令是多余的,因为它与 DOM 没有任何关系;它只是对框架的一个提示。
然后我想到尝试在树的扁平版本上使用文本差异算法。如果我将两棵树(我拥有的旧 DOM 片段和我想要替换它的新 DOM 片段)都扁平化,并计算其diff
(最小编辑集),这样我就可以以更少的步骤从旧树转到新树,结果会怎样?
因此,我采用了Myers 算法(每次运行git diff
时都会使用的算法),并将其应用于我的扁平树。让我们用一个例子来说明:
var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];
如您所见,我没有使用 DOM,而是使用我们在想法 1 中看到的对象文字表示。现在,您会注意到我们需要在列表末尾添加一个新的<li>
。
扁平化的树木看起来像这样:
var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];
O
代表“打开标签”, L
代表“文字”(在本例中为一些文本), C
代表“关闭标签”。请注意,每棵树现在都是一个字符串列表,并且不再有任何嵌套数组。这就是我所说的扁平化。
当我对每个元素运行 diff 时(将数组中的每个项目视为一个单元),我得到:
var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];
您可能已经猜到了,我们保留了列表的大部分内容,并在列表末尾添加了一个<li>
。这些就是您看到的add
条目。
如果我们现在将第三个<li>
的文本从Item 3
更改为Item 4
,并对其运行 diff,我们将获得:
var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];
我不知道这种方法在数学上有多低效,但在实践中效果很好。只有在对存在大量差异的大型树进行差异分析时,它才会表现不佳;当这种情况偶尔发生时,我会使用 200 毫秒的超时来中断差异分析,并简单地完全替换 DOM 中有问题的部分。如果我不使用超时,整个应用程序会停滞一段时间,直到差异分析完成。
使用 Myers diff 的一个幸运优势是它优先删除而不是插入:这意味着如果在删除项目和添加项目之间有同样有效的选择,算法将首先删除项目。实际上,这允许我获取所有被删除的 DOM 元素,并能够在 diff 的稍后需要它们时回收它们。在最后一个示例中,最后一个<li>
被回收,方法是将其内容从Item 3
更改为Item 4
。通过回收元素(而不是创建新的 DOM 元素),我们可以将性能提高到用户没有意识到 DOM 正在不断重绘的程度。
如果您想知道实现这种将更改应用于 DOM 的扁平化和差异化机制有多复杂,我设法用 500 行 ES5 javascript 实现了它,它甚至可以在 Internet Explorer 6 中运行。但不可否认,这可能是我写过的最难的代码。固执是有代价的。
以上就是我想提出的四个想法!它们并非完全原创,但我希望它们对某些人来说既新颖又有趣。谢谢阅读!