为什么 JavaScript 正在吞噬 HTML

Avatar of Mike Turley
Mike Turley

DigitalOcean 提供适合您旅程各个阶段的云产品。立即开始使用 $200 免费信用额度!

Web 开发一直在变化。最近,一种趋势变得非常流行,它从根本上违背了关于如何制作网页的传统智慧。这对一些人来说很令人兴奋,但对另一些人来说却令人沮丧,而导致这两种结果的原因很难解释。

网页传统上由三个独立的部分组成,它们分别负责:**HTML 代码**定义页面内容的结构和含义,**CSS 代码**定义其外观,**JavaScript 代码**定义其行为。在拥有专门的设计师、HTML/CSS 开发人员和 JavaScript 开发人员的团队中,这种关注点分离与工作角色完美契合:设计师确定页面的视觉效果和用户交互,HTML 和 CSS 开发人员在 Web 浏览器中复制这些视觉效果,JavaScript 开发人员添加用户交互以将所有内容整合在一起并“使它工作”。人们可以专注于一个部分,而无需参与所有三个部分。

近年来,JavaScript 开发人员意识到,通过在 JavaScript 中而不是在 HTML 中定义页面的结构(使用React等框架),他们可以简化用户交互代码的开发和维护,否则这些代码的构建将变得更加复杂。当然,当你告诉某人他们编写的 HTML 需要被分割并与他们一无所知的 JavaScript 混合在一起时,他们可能会(可以理解地)感到沮丧并开始质疑我们到底从中学到了什么。

作为跨职能团队中的 JavaScript 开发人员,我偶尔会遇到这个问题,并且经常难以回答。我发现的所有关于此主题的材料都是针对已经熟悉 JavaScript 的受众编写的,这对那些专注于 HTML 和 CSS 的人来说并不太有用。但是,这种 **HTML-in-JS** 模式(或提供相同好处的其他模式)很可能在一段时间内存在,因此我认为这是参与 Web 开发的每个人都应该理解的重要内容。

本文将包括代码示例,供有兴趣的人参考,但我的目标是通过一种无需代码也能理解的方式解释这个概念。

背景:HTML、CSS 和 JavaScript

为了尽可能扩大本文的受众范围,我想简要介绍一下创建网页所涉及的代码类型及其传统作用。如果您对此有经验,可以直接跳过。

HTML 用于结构和语义含义

HTML(超文本标记语言)代码定义页面内容的结构和含义。例如,本文的 HTML 包含您现在正在阅读的文本,以及它位于段落中,以及它位于标题之后和 CodePen 之前的事实。

假设我们想要构建一个简单的购物清单应用程序。我们可能从一些 HTML 代码开始,如下所示

我们可以将此代码保存到一个文件中,在 Web 浏览器中打开它,浏览器将显示渲染结果。如您所见,此示例中的 HTML 代码表示页面的一部分,其中包含一个标题“购物清单(2 项)”,一个文本输入框,一个标题为“添加项目”的按钮,以及一个包含两项“鸡蛋”和“黄油”的列表。在传统的网站中,用户会导航到 Web 浏览器中的某个地址,然后浏览器会从服务器请求此 HTML 代码,加载并显示它。如果列表中已经存在项目,服务器可以提供包含这些项目的 HTML 代码,就像它们在这个示例中一样。

尝试在输入框中输入一些内容,然后单击“添加项目”按钮。您会注意到什么都没有发生。该按钮没有连接到任何可以更改 HTML 代码的代码,而 HTML 代码无法自行更改。我们稍后会讲到这一点。

CSS 用于外观

CSS(层叠样式表)代码定义页面的外观。例如,本文的 CSS 包含您正在阅读的文本的字体、间距和颜色。

您可能已经注意到,我们的购物清单示例看起来非常简单。HTML 无法指定间距、字体大小和颜色等内容。这就是 CSS(层叠样式表)发挥作用的地方。在与上述 HTML 代码相同的页面中,我们可以添加 CSS 代码来稍微美化一下

如您所见,此 CSS 更改了字体大小和权重,并为该部分提供了不错的背景颜色(设计师,请不要 @ 我;我知道这仍然很难看)。开发人员可以编写这样的样式规则,并且它们将一致地应用于任何 HTML 结构:如果我们在该页面中添加更多 <section><button><ul> 元素,它们将应用相同的字体更改。

但是,该按钮仍然不起作用:这就是 JavaScript 的作用。

JavaScript 用于行为

JavaScript 代码定义页面上交互式或动态元素的行为。例如,本文中嵌入的 CodePen 示例由 JavaScript 提供支持。

没有 JavaScript,要使示例中的“添加项目”按钮正常工作,就需要使用特殊的 HTML 代码来使其将数据提交回服务器(<form action="...">,如果您好奇的话)。然后浏览器将丢弃整个页面并重新加载整个 HTML 文件的更新版本。如果此购物清单是更大页面的一个部分,用户正在执行的任何其他操作都会丢失。向下滚动了吗?您回到了顶部。正在观看视频吗?它重新开始。这就是所有 Web 应用程序在很长一段时间内的工作方式:每当用户与网页交互时,就好像他们关闭了 Web 浏览器并再次打开了它一样。对于这个简单的示例来说,这没什么大不了,但对于一个大型复杂的页面(可能需要一段时间才能加载),这对浏览器或服务器来说效率不高。

**如果我们想要在网页上进行任何更改而无需重新加载整个页面,就需要 JavaScript**(不要与 Java 混淆,Java 是一种完全不同的语言……不要让我开始讲这些)。让我们尝试添加一些

现在,当我们在框中输入一些文本并单击“添加项目”按钮时,我们的新项目将添加到列表中,并且顶部的项目计数也会更新!在实际应用中,我们还会添加一些代码,以便在后台将新项目发送到服务器,以便下次加载页面时它仍然会显示出来。

在这个简单的示例中,将 JavaScript 与 HTML 和 CSS 分开是有意义的。传统上,甚至更复杂的交互也会以这种方式添加:HTML 被加载并显示,JavaScript 随后运行以添加内容并更改它。但是,随着事情变得更加复杂,我们开始需要更好地跟踪 JavaScript 中的内容。

如果我们继续构建这个购物清单应用程序,接下来我们可能会添加用于编辑或从列表中删除项目的按钮。假设我们编写了用于删除项目的按钮的 JavaScript 代码,但忘记添加更新页面顶部项目总数的代码。突然之间,我们遇到了一个错误:用户删除项目后,页面上的总数与列表不符!一旦我们注意到这个错误,我们就通过将“添加项目”代码中的相同 totalText.innerHTML 行添加到“删除项目”代码中来修复它。现在,我们在多个地方重复了相同的代码。稍后,假设我们想要更改该代码,以便页面顶部不再显示“(2 项)”,而是显示“项目:2”。我们将不得不确保在所有三个地方更新它:在 HTML 中,在“添加项目”按钮的 JavaScript 中,以及在“删除项目”按钮的 JavaScript 中。如果我们没有这样做,我们就会遇到另一个错误,即该文本在用户交互后突然更改。

在这个简单的示例中,我们已经可以看出这些事情很快就会变得混乱。有一些方法可以组织我们的 JavaScript 来使这种问题更容易处理,但随着事情变得更加复杂,我们将需要不断地重构和重写代码来跟上。只要 HTML 和 JavaScript 保持分离,就可能需要花费很多努力来确保它们之间的同步。这就是为什么像 React 这样的新 JavaScript 框架获得了吸引力的原因之一:它们旨在显示 HTML 和 JavaScript 之间的关系。要了解它是如何工作的,我们首先需要了解一点计算机科学知识。

两种编程方式

这里要理解的关键概念涉及两种常见编程风格之间的区别。(当然,还有其他编程风格,但我们这里只讨论其中两种。)大多数编程语言都适合其中一种风格,而有些语言则可以在两种风格中使用。为了从 JavaScript 开发人员的角度理解 HTML-in-JS 的主要优势,掌握这两种风格非常重要。

  • 命令式编程:这里的“命令式”意味着命令计算机执行某项操作。命令式代码行很像英语中的命令句:它向计算机发出一个特定的指令。在命令式编程中,我们必须告诉计算机如何执行我们需要它执行的每一件事。在 Web 开发中,这开始被认为是“旧方法”,并且是使用原生 JavaScript 或像 jQuery 这样的库时所做的事情。上面购物清单示例中的 JavaScript 就是命令式代码。
    • 命令式:“执行 X,然后执行 Y,然后执行 Z”。
    • 示例:当用户选择此元素时,向其添加 .selected 类;当用户取消选择时,从其移除 .selected 类。
  • 声明式编程:这是高于命令式编程的更抽象一层。我们不是向计算机发出指令,而是“声明”我们希望计算机执行某项操作后的结果。我们的工具(例如 React)会自动找出“如何”执行。这些工具内部使用命令式代码构建,我们不需要从外部关注它们。
    • 声明式:“结果应该是 XYZ。执行任何需要做的操作来实现这一点。”
    • 示例:如果用户选择了此元素,则它具有 .selected 类。

HTML 是一种声明式语言

暂时忘掉 JavaScript。以下是一个重要的事实:HTML 本身是一种声明式语言。在 HTML 文件中,您可以声明类似以下内容

<section>
  <h1>Hello</h1>
  <p>My name is Mike.</p>
</section>

当 Web 浏览器读取此 HTML 时,它会为您找出这些命令式步骤并执行它们

  1. 创建一个 section 元素
  2. 创建一个级别为 1 的标题元素
  3. 将标题元素的内部文本设置为“Hello”
  4. 将标题元素放置到 section 元素中
  5. 创建一个段落元素
  6. 将段落元素的内部文本设置为“My name is Mike”
  7. 将段落元素放置到 section 元素中
  8. 将 section 元素放置到文档中
  9. 在屏幕上显示文档

作为一名 Web 开发人员,浏览器如何执行这些操作的细节无关紧要;重要的是它确实执行了它们。这是这两种编程风格之间区别的完美示例。简而言之,HTML 是一种声明式的抽象,它包装在 Web 浏览器的命令式显示引擎中。它负责“如何”,因此您只需要担心“什么”。您可以享受用声明式 HTML 编写代码的乐趣,因为 Mozilla、Google 或 Apple 的优秀人员在构建您的 Web 浏览器时为您编写了命令式代码。

JavaScript 是一种命令式语言

我们已经在上面的购物清单示例中看到了命令式 JavaScript 的简单示例,并且我提到了应用程序功能的复杂性如何对实现它们所需的努力和该实现中出现错误的可能性产生连锁反应。现在让我们看一个稍微复杂一点的功能,并看看如何使用声明式方法来简化它。

想象一个包含以下内容的网页

  • 一个带标签的复选框列表,每行在选中时会变为不同的颜色
  • 底部有“1 of 4 selected”之类的文本,当复选框发生变化时应更新
  • 一个“Select All”按钮,如果所有复选框都已选中,则该按钮应被禁用
  • 一个“Select None”按钮,如果所有复选框都未选中,则该按钮应被禁用

以下是使用纯 HTML、CSS 和命令式 JavaScript 实现此功能的方法

这里没有太多 CSS 代码,因为我使用了很棒的 PatternFly 设计系统,它为我的示例提供了大多数 CSS。我在 CodePen 设置中导入了它们的 CSS 文件。

所有的小细节

为了使用命令式 JavaScript 实现此功能,我们需要向浏览器发出多个细粒度的指令。以下是用英语描述的上面示例中代码的等效内容

  • 在我们的 HTML 中,我们声明了页面的初始结构
    • 有四个 row 元素,每个元素包含一个复选框。第三个框被选中。
    • 有一些摘要文本,内容为“1 of 4 selected”。
    • 有一个“Select All”按钮,该按钮已启用。
    • 有一个“Select None”按钮,该按钮已禁用。
  • 在我们的 JavaScript 中,我们编写了关于发生以下事件时要更改什么的指令
    • 当一个复选框从未选中变为选中时
      • 找到包含复选框的 row 元素,并向其添加 .selected CSS 类。
      • 找到列表中的所有复选框元素,并计算选中和未选中的复选框数量。
      • 找到摘要文本元素,并使用选中的数量和总数更新它。
      • 找到“Select None”按钮元素,如果它已禁用,则启用它。
      • 如果所有复选框现在都已选中,则找到“Select All”按钮元素并禁用它。
    • 当一个复选框从选中变为未选中时
      • 找到包含复选框的 row 元素,并从其移除 .selected 类。
      • 找到列表中的所有复选框元素,并计算选中和未选中的复选框数量。
      • 找到摘要文本元素,并使用选中的数量和总数更新它。
      • 找到“Select All”按钮元素,如果它已禁用,则启用它。
      • 如果所有复选框现在都未选中,则找到“Select None”按钮元素并禁用它。
    • 当单击“Select All”按钮时
      • 找到列表中的所有复选框元素,并选中它们。
      • 找到列表中的所有 row 元素,并向它们添加 .selected 类。
      • 找到摘要文本元素并更新它。
      • 找到“Select All”按钮并禁用它。
      • 找到“Select None”按钮并启用它。
    • 当单击“Select None”按钮时
      • 找到列表中的所有复选框元素,并取消选中它们。
      • 找到列表中的所有 row 元素,并从它们移除 .selected 类。
      • 找到摘要文本元素并更新它。
      • 找到“Select All”按钮并启用它。
      • 找到“Select None”按钮并禁用它。

哇,很多吧?嗯,我们最好记住为所有这些事情编写代码。如果我们忘记或弄乱了任何这些指令,最终会导致错误,例如总数与复选框不匹配,或者单击时没有执行任何操作的按钮已启用,或者行最终显示了错误的颜色,或者其他我们没有想到的事情,直到用户抱怨才会发现。

这里最大的问题是没有单一事实来源,用于应用程序的状态,在这种情况下是“哪些复选框被选中?”当然,复选框知道它们是否被选中,但是,行的样式也必须知道,摘要文本也必须知道,每个按钮也必须知道。此信息的五个副本分别存储在整个 HTML 中,并且当它在这些地方中的任何一个地方发生变化时,JavaScript 开发人员需要捕获它并编写命令式代码以保持其他副本同步。

这仍然只是一个页面的小组件的简单示例。如果这听起来很麻烦,想象一下当您需要以这种方式编写整个应用程序时,应用程序会变得多么复杂和脆弱。对于许多复杂的现代 Web 应用程序来说,它不是一个可扩展的解决方案。

迈向单一事实来源

像 React 这样的工具允许我们以声明式的方式使用 JavaScript。正如 HTML 是包装在 Web 浏览器显示指令中的声明式抽象一样,React 是包装在 JavaScript 中的声明式抽象。

还记得 HTML 如何让我们专注于页面的结构,而不是浏览器如何显示该结构的细节吗?嗯,当我们使用 React 时,我们可以再次专注于结构,通过基于存储在单一位置的数据来定义结构。当该事实来源发生变化时,React 将自动为我们更新页面的结构。它会在幕后处理命令式步骤,就像 Web 浏览器对 HTML 所做的那样。(虽然这里 React 被用作示例,但这个概念并非 React 独有,而是被其他框架(例如 Vue)使用。)

让我们回到上面示例中的复选框列表。在这种情况下,我们关心的真相很简单:哪些复选框被选中?页面上的其他细节(例如摘要显示的内容、行的颜色、按钮是否启用)是根据该相同真相推断出来的效果。那么,为什么它们需要拥有自己的信息副本?它们应该只使用单一事实来源作为参考,页面上的所有内容都应该“自动知道”哪些复选框被选中,并相应地进行操作。您可能会说 row 元素、摘要文本和按钮应该能够自动响应复选框被选中或取消选中。(你明白我的意思吗?)

告诉我你想要什么(你真正想要的)

为了用 React 实现这个页面,我们可以用一些简单的声明来代替列表。

  • 有一个名为 checkboxValues 的真假值列表,它表示哪些框被选中。
    • 例如: checkboxValues = [false, false, true, false]
    • 这个列表表示事实:我们有四个复选框,第三个复选框被选中。
  • 对于 checkboxValues 中的每个值,都有一个行元素,它
    • 如果值为真,则具有名为 .selected 的 CSS 类,并且
    • 包含一个复选框,如果值为真,则选中复选框。
  • 有一个摘要文本元素,它包含文本“{x} of {y} selected”,其中 {x}checkboxValues 中真值的个数,而 {y}checkboxValues 中值的总数。
  • 有一个“全选”按钮,如果 checkboxValues 中存在任何假值,则启用。
  • 有一个“全不选”按钮,如果 checkboxValues 中存在任何真值,则启用。
  • 单击复选框时,它在 checkboxValues 中更改其对应值。
  • 单击“全选”按钮时,它将 checkboxValues 中的所有值设置为真。
  • 单击“全不选”按钮时,它将 checkboxValues 中的所有值设置为假。

你会注意到最后三项仍然是命令式指令(“当发生这种情况时,执行那件事”),但这是我们唯一需要编写的命令式代码。只有三行代码,它们都更新了单一的事实来源。其余的项目是声明(“有一个……”),现在已经直接内置到页面的结构定义中。为了做到这一点,我们用 React 提供的一种特殊的 JavaScript 语法 JSX 来编写元素,它类似于 HTML,但可以包含 JavaScript 逻辑。这使我们能够将“if”和“for each”之类的逻辑与 HTML 结构混合在一起,因此结构可以根据 checkboxValues 的内容在任何时间点而有所不同。

以下是上面提到的相同购物清单示例,这次使用 React 实现。

JSX 绝对很奇怪。我第一次遇到它时,感觉不对劲。我的第一反应是,“这是什么?HTML 不应该出现在 JavaScript 中!”我不孤单。也就是说,它不是 HTML,而是 装扮成 HTML 的 JavaScript。它也非常强大。

还记得上面那份 20 个命令式指令的列表吗?现在我们只有三个。为了将 HTML 定义在 JavaScript 中,其余的指令都是免费的。React 只要 checkboxValues 发生变化,就会自动完成这些操作

使用此代码,摘要与复选框不匹配、行的颜色错误或按钮在应该禁用时启用这些错误现在不可能发生。现在我们应用程序中有一类错误是不可能发生的:事实来源不同步。所有内容都从单一的事实来源向下流动,我们开发人员可以编写更少的代码,并且可以睡得更香。至少,JavaScript 开发人员可以…

这是一个权衡

随着 Web 应用程序变得越来越复杂,维护 HTML 和 JavaScript 之间的经典关注点分离的成本越来越高。HTML 最初是为静态文档设计的,为了在这些文档中添加更复杂的交互式功能,命令式 JavaScript 必须跟踪更多内容,并变得更加混乱和脆弱。

好处:可预测性、可重用性和组合性

使用单一事实来源的能力是这种模式最重要的优势,但权衡也为我们带来了其他好处。将页面的元素定义为 JavaScript 代码意味着我们可以将它的块转换成可重用组件,从而防止我们在多个地方复制粘贴相同的 HTML。如果我们需要更改组件,我们可以在一个地方进行更改,它将在应用程序中的所有地方(或在多个应用程序中,如果我们将可重用组件发布到其他团队)更新。

我们可以将这些简单的组件组合在一起,就像乐高积木一样,创建更复杂、更有用的组件,而不会使它们难以使用。而且,如果我们使用他人构建的组件,我们可以轻松地在他们发布改进或修复错误时更新这些组件,而无需重写我们的代码。

缺点:它一直都是 JavaScript

所有这些好处都伴随着成本。人们重视将 HTML 和 JavaScript 分开是有充分理由的,为了获得这些好处,我们需要将它们合并成一个。正如我之前提到的,从简单的 HTML 文件迁移会使以前不需要担心 JavaScript 的人员的工作流程变得复杂。这可能意味着以前可以独立更改应用程序的人员现在必须学习额外的复杂技能来保持这种自主性。

也可能存在技术方面的缺点。例如,一些工具(如 linter 和解析器)期望使用常规 HTML,一些第三方命令式 JavaScript 插件可能变得更难使用。此外,JavaScript 不是设计最好的语言;它只是我们恰好在 Web 浏览器中拥有的语言。较新的工具和功能正在使它变得更好,但它仍然存在一些在有效使用之前需要了解的缺陷。

另一个潜在问题是,当页面的语义结构被分解成抽象组件时,开发人员很容易停止考虑最后生成的实际 HTML 元素是什么。特定的 HTML 标签(如 <section><aside>)具有特定的语义含义,这些含义在使用通用标签(如 <div><span>)时会丢失,即使它们在页面上视觉上看起来相同。这对可访问性尤为重要。例如,这些选择会影响屏幕阅读器软件为视障用户执行的操作。这可能不是最令人兴奋的部分,但 JavaScript 开发人员应该始终记住,语义 HTML 是 网页最重要的部分

如果它对你有帮助,就使用它,而不是因为它“现在很流行”

开发人员在每个项目中都使用框架已成为一种趋势。有些人认为将 HTML 和 JavaScript 分开已经过时,但这并不正确。对于一个不需要太多用户交互的简单静态网站来说,这并不值得麻烦。更热情的 React 粉丝可能会不同意我的观点,但如果你的所有 JavaScript 都只是在创建一个非交互式网页,那么你不应该使用 JavaScript。JavaScript 的加载速度不如常规 HTML 快,因此,如果你没有获得明显的开发体验或代码可靠性改进,那么它会弊大于利。

你也不必在 React 中构建整个网站!或者 Vue!或者其他任何东西!许多人不知道这一点,因为所有教程都展示了如何在整个网站中使用 React。如果你在一个简单的网站上只有一个复杂的微件,你可以将 React 用于那个组件。你并不总是需要担心 webpack 或 Redux 或 Gatsby 或者其他人们会告诉你对 React 应用程序“最佳实践”的任何其他内容。

对于足够复杂的应用程序来说,声明式编程绝对值得付出努力。它是一种改变游戏规则的技术,它赋予了世界各地的开发人员能力,让他们充满信心地构建出令人惊叹、健壮且可靠的软件,而不必担心琐碎的事情。React 本身是解决这些问题的最佳方案吗?不是的。它会被下一代技术取代吗?最终会。但声明式编程不会消失,下一代技术可能会做得更好。

我听说过 CSS-in-JS,那是什么?

我不会触碰那个。