本文是系列文章的一部分:框架实战指南 - 基础知识
在深入研究众多前端框架的工作原理之前,我们需要先了解一些基础知识。如果您已经熟悉 DOM 如何表示树状结构以及浏览器如何获取和使用这些信息,那就太好了!您可以继续阅读了!否则,强烈建议您先阅读我们之前的文章,其中介绍了理解本文一些基础知识所需的概念。
您可能听说过现代前端开发人员用来构建大型应用程序的各种框架和库。这些框架包括 Angular、React 和 Vue。虽然每个库都有各自的优缺点,但它们之间有许多核心概念是共享的。
本书将概述这三个框架之间共享的核心概念,以及如何在代码中实现它们。本书将为学习这些框架(即使没有先修知识)提供良好的参考,也适用于学习其他框架(需要其他框架的先修知识)。
首先,让我们解释一下为什么 Angular、React 或 Vue 等框架与之前的其他库(如 jQuery)不同。
所有这些都归结为一个核心概念:组件化。
那么,什么是应用程序?
在深入探讨技术层面之前,让我们先从高层次思考一下应用程序由什么组成。
考虑以下应用。

我们的应用由许多部分组成。例如,包含导航链接的侧边栏、供用户浏览的文件列表,以及有关用户所选文件的详细信息窗格。
而且,应用程序的每个部分都需要不同的东西。
侧边栏可能不需要复杂的编程逻辑,但我们可能希望在用户悬停时为其添加漂亮的颜色和高亮效果。同样,文件列表可能包含复杂的逻辑来处理用户右键单击、拖放文件的操作。
分解开来,应用程序的每个部分都有三个主要关注点:
- 逻辑------该应用程序的作用是什么?
- 样式------应用程序的视觉效果如何?
- 结构------应用程序如何布局?
虽然上面的模型在视觉上展示了不错的内容,但让我们看看应用程序的结构是什么样的:

这里,每个部分都无需任何额外的样式即可布局:只需一个页面的线框,每个部分都包含以非常直观的方式布局的块。这就是 HTML 帮助我们构建的。
现在我们了解了结构,接下来让我们添加一些功能。首先,我们将在每个部分添加一小段文字来概述我们的目标。之后,我们会将这些内容用作"验收"标准。这就是我们的逻辑将提供给应用的内容。
太棒了!现在,让我们返回并添加样式来重新创建我们之前的模型!
我们可以将这个过程的每个步骤想象成我们正在添加一种新的编程语言。
- HTML 用于添加应用程序的结构。
<nav>
例如,侧边导航可能是一个标签。 - JavaScript 在结构之上添加了应用程序的逻辑。
- CSS 使一切看起来很漂亮,并可能增加一些小的 UX 改进。
我通常这样看待这三项技术:
HTML 就像建筑蓝图。它能让你看到最终效果的总体轮廓。它定义了房屋的墙壁、门和流程。
JavaScript 就像房子里的电路、管道和电器。它们让你能够以有意义的方式与建筑互动。
CSS 就像家里的油漆和其他装饰品一样,它们让房子显得温馨宜人。当然,如果没有家里的其他部分,CSS 的装饰就没什么用,但如果没有装饰,那体验就很糟糕了。
应用程序的组成部分
既然我们已经介绍了应用程序的外观,让我们回顾一下。还记得我说过每个应用程序都是由各个部分组成的吗?让我们将应用程序的模型分解成更小的部分,并更深入地研究它们。
在这里,我们可以更清楚地看到应用程序的每个部分如何拥有自己的结构、样式和逻辑。
例如,文件列表包含每个文件作为其自身项目的结构、关于哪些按钮执行哪些操作的逻辑以及一些使其看起来引人入胜的 CSS。
本节的代码可能看起来像这样:
html
<section> <button id="addButton"><span class="icon">plus</span></button> <!-- ... --></section><ul> <li> <a href="/file/file_one">File one<span>12/03/21</span></a> </li> <!-- ... --> <ul> <script> var addButton = document.querySelector("#addButton"); addButton.addEventListener("click", () => { // ... }); </script> </ul></ul>
我们可能有一个心理模型来将每个部分分解成更小的部分。如果我们用伪代码来表示我们对实际代码库的心理模型,它可能看起来像这样:
html
<files-buttons> <add-button /></files-buttons><files-list> <file name="File one" /></files-list>
幸运的是,通过使用框架,这种心理模型可以反映在真实的代码中!
让我们看看<file>
每个框架中可能是什么样子的:
html
<!-- File.vue --><template> <div> <a href="/file/file_one">File one<span>12/03/21</span></a> </div></template>
这是一个特殊命名的.vue
文件,它定义了一个名为"File"的 Vue 组件,并有一个模板,当使用该组件时应该显示该 HTML。
与其他需要您明确命名组件的框架不同,Vue 使用文件的名称
.vue
来定义组件的名称。
每个 Vue 组件都使用一个单独的.vue
文件来包含其布局、样式和逻辑。因此,这些.vue
文件通常被称为"单文件组件",简称 SFC。
虽然这个 SFC 看起来与标准 HTML 完全一样,没有添加任何特殊内容,但随着我们对 Vue 的了解越来越多,这种情况很快就会改变。
这些被称为"组件"。组件具有多种方面,我们将在本书的整个过程中进行学习。
我们可以看到每个框架都有自己的语法来显示这些组件,但它们通常比你想象的有更多相似之处。
现在我们已经定义了组件,那么有一个问题:如何在 HTML 中使用这些组件?
渲染应用程序
虽然这些组件看起来像简单的 HTML,但它们却能够实现更高级的用法。因此,每个框架实际上都使用 JavaScript 在屏幕上"绘制"这些组件。
这个"绘制"的过程被称为"渲染"。 然而,渲染并非一次性完成。组件在屏幕上使用的过程中可能会多次渲染,尤其是在需要更新屏幕上显示的数据时;我们将在本章后面详细学习。
传统上,当您仅使用 HTML 构建网站时,您会定义index.html
如下文件:
html
<!-- index.html --><html> <body> <!-- Your HTML here --> </body></html>
类似地,所有使用 React、Angular 和 Vue 构建的应用程序都从一个index.html
文件开始。
html
<!-- index.html --><html> <body> <div id="root"></div> </body></html>
然后,在 JavaScript 中,您将组件"渲染"为一个元素,该元素充当框架的"根"注入站点,以围绕该站点构建 UI。
由于 Vue 的所有组件都位于专用的.vue
SFC 中,因此我们必须使用两个不同的文件来渲染基本的 Vue 应用。我们从File.vue
组件开始:
html
<!-- File.vue --><template> <div> <a href="/file/file_one">File one<span>12/03/21</span></a> </div></template>
然后,我们可以将其导入到我们的主要 JavaScript 文件中:
js
// main.js
import { createApp } from "vue";import File from "./File.vue";createApp(File).mount("#root");
一旦组件被渲染,您就可以用它做更多的事情!
例如,就像 DOM 中的节点有关系一样,组件也有关系。
孩子、兄弟姐妹等等,天哪!
虽然我们的File
组件目前包含 HTML 元素,但组件也可能包含其他组件!
.vue
正如我们之前提到的,一个SFC中只能有一个组件。这里,我们有一个现有的File
组件:
html
<!-- File.vue --><template> <div> <a href="/file/file_one">File one<span>12/03/21</span></a> </div></template>
我们可以将其import
放入另一个组件中使用它:
html
<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template> <ul> <li><File /></li> </ul></template>
我们可以import
立即使用我们的组件,因为我们在其中公开的任何变量<script setup>
都会自动在<template>
我们的 SFC 部分中可用。
注意,我们的
script
标签有一个setup
属性!没有它,我们的代码就无法正常工作!
我们必须导入父组件中要用到的所有组件!否则,Vue 会抛出错误:
无法解析组件:文件
仔细查看我们的File
组件,我们会注意到我们在一个组件内渲染了多个元素。有趣的是,这还有一个有趣的副作用:我们也可以在父组件内渲染多个组件。
html
<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template> <ul> <li><File /></li> <li><File /></li> <li><File /></li> </ul></template>
这是组件的一个便捷功能。它允许你重用结构的各个方面(以及样式和逻辑,但我有点跑题了),而无需重复工作。它允许一个非常 DRY 的架构,你的代码只需声明一次,就可以在其他地方重用。
它代表"不要重复自己",并经常被誉为代码质量的黄金标准!
值得记住的是,我们使用"父级"一词来指代FileList
组件与组件之间的关系File
。这是因为,就像 DOM 树一样,每个框架的组件集合都反映了一棵树。
这意味着相关
File
组件彼此是"兄弟",每个组件都有一个"父级" FileList
。
我们可以将这种层次关系扩展到"孙子"及更高级别:
html
<!-- FileDate.vue -->
<template> <span>12/03/21</span></template>
html
<!-- File.vue -->
<script setup>import FileDate from "./FileDate.vue";</script>
<template> <div> <a href="/file/file_one">File one<FileDate /></a> </div></template>
html
<!-- FileList.vue -->
<script setup>import File from "./File.vue";</script><template> <ul> <li><File /></li> <li><File /></li> <li><File /></li> </ul></template>
逻辑
然而,HTML 并不是组件唯一可以存储的内容!正如我们之前提到的,应用程序(以及相应应用程序的每个部分)需要三个部分:
- 结构(HTML)
- 样式(CSS)
- 逻辑(JS)
组件可以处理所有这三种情况!
让我们看看如何通过file-date
显示当前日期而不是静态日期来在组件中声明逻辑。
我们首先添加一个变量,该变量以人类可读的字符串形式包含当前日期MM/DD/YY
。
html
<!-- FileDate.vue -->
<script setup>const dateStr = `${ new Date().getMonth() + 1}/${new Date().getDate()}/${new Date().getFullYear()}`;</script><template> <span>12/03/21</span></template>
我们暂时还未使用这个新
dateStr
变量。这是有意为之;我们很快就会用到它。
虽然设置此变量的逻辑有效,但它有点冗长(并且由于重新创建Date
对象三次而很慢) - 让我们将其分解为组件中包含的方法。
js
function formatDate() { const today = new Date(); // Month starts at 0, annoyingly const monthNum = today.getMonth() + 1; const dateNum = today.getDate(); const yearNum = today.getFullYear(); return monthNum + "/" + dateNum + "/" + yearNum;}
html
<!-- FileDate.vue --><script setup>function formatDate() { const today = new Date(); // Month starts at 0, annoyingly const monthNum = today.getMonth() + 1; const dateNum = today.getDate(); const yearNum = today.getFullYear(); return monthNum + "/" + dateNum + "/" + yearNum;}const dateStr = formatDate();</script><template> <span>12/03/21</span></template>
副作用简介
让我们formatDate
通过告诉组件"一旦你在屏幕上呈现,console.log
该数据的值"来验证我们的方法是否输出正确的值。
html
<!-- FileDate.vue --><script setup>import { onMounted } from "vue";function formatDate() { const today = new Date(); // Month starts at 0, annoyingly const monthNum = today.getMonth() + 1; const dateNum = today.getDate(); const yearNum = today.getFullYear(); return monthNum + "/" + dateNum + "/" + yearNum;}const dateStr = formatDate();onMounted(() => { console.log(dateStr);});</script><template> <span>12/03/21</span></template>
dateStr
在这里,我们告诉每个相应的框架在组件第一次渲染时将值记录到控制台。
等一下,"第一次?"
是的!React、Angular 和 Vue 都可以在需要时更新(或"重新渲染")。
例如,假设你想dateStr
向用户显示 ,但当天晚些时候,时间切换了。虽然你必须处理代码来跟踪时间,但相应的框架会注意到你修改了 的值,dateStr
并重新渲染组件以显示新的值。
虽然每个框架用来判断何时重新渲染的方法不同,但它们都有一个高度稳定的方法。
考虑支持
这个特性可以说是使用这些框架之一构建应用程序的最显著优势。
这种跟踪数据变化的能力依赖于处理"副作用"的概念。我们将在后续章节 "副作用"中更详细地讨论这个问题,你可以将"副作用"理解为对组件数据所做的任何更改:无论是通过用户的输入还是组件的输出变化。
说到在屏幕上更新数据 - 让我们看看如何在页面上动态显示数据。
展示
虽然在控制台中显示值对于调试很有帮助,但对用户来说帮助不大。毕竟,你的用户很可能根本不知道控制台是什么。让我们dateStr
在屏幕上显示
html
<!-- FileDate.vue --><script setup>import { onMounted } from "vue";function formatDate() { const today = new Date(); // Month starts at 0, annoyingly const monthNum = today.getMonth() + 1; const dateNum = today.getDate(); const yearNum = today.getFullYear(); return monthNum + "/" + dateNum + "/" + yearNum;}const dateStr = formatDate();</script><template> <span>{{ dateStr }}</span></template>
在这里,我们利用了这样的事实:其中的每个变量<script setup>
都会自动暴露给我们的<template>
代码。
这里,我们使用了每个框架将状态注入组件的方法。对于 React,我们将使用{}
语法将 JavaScript 插入模板,而 Vue 和 Angular 都依赖于{{}}
语法。
实时更新
但是如果我们dateStr
事后更新会发生什么?假设我们有一个setTimeout
调用,在 5 分钟后将日期更新为明天的日期。
让我们想想该代码可能是什么样子:
js
// This is non-framework-specific pseudocode
setTimeout(() => { // 24 hours, 60 minutes, 60 seconds, 1000 milliseconds const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000); const tomorrowDate = formatDate(tomorrow); dateStr = tomorrowDate; // This is not a real method in any of these frameworks // But the idea of re-rendering after data has changed IS // an integral part of these frameworks. They just do it differently rerender();}, 5000);
让我们看看每个框架在实践中是什么样的:
useState
与 React在组件中设置数据的方式类似,Vue 引入了一个名为的 API ref
,以便数据更新触发重新渲染。
html
<!-- FileDate.vue --><script setup>import { ref, onMounted } from "vue";function formatDate(inputDate) { // Month starts at 0, annoyingly const monthNum = inputDate.getMonth() + 1; const dateNum = inputDate.getDate(); const yearNum = inputDate.getFullYear(); return monthNum + "/" + dateNum + "/" + yearNum;}const dateStr = ref(formatDate(new Date()));onMounted(() => { setTimeout(() => { // 24 hours, 60 minutes, 60 seconds, 1000 milliseconds const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000); dateStr.value = formatDate(tomorrow); }, 5000);});</script><template> <span>{{ dateStr }}</span></template>
注意,我们使用
.value
来更新 内部的值,<script>
但没有.value
在 内部使用<template>
。这并不是错误------这只是 Vue 的ref
工作方式!
如果您在这些屏幕上坐一会儿,您会看到它们自动更新!
这种数据更新触发其他代码的想法被称为"反应性" ,是这些框架的核心部分。
虽然各个框架检测响应式变化的底层机制不同,但它们都会帮你处理 DOM 的更新。这样一来,你就可以专注于更新屏幕内容的逻辑,而不是更新 DOM 本身的代码。
这一点至关重要,因为高效地更新 DOM 需要耗费大量的精力。事实上,其中两个框架(React 和 Vue)将 DOM 的完整副本存储在内存中,以尽可能降低更新的难度。在本系列丛书的第三本《内部原理》中,我们将学习其底层工作原理,以及如何构建我们自己的 DOM 镜像版本。
属性绑定
然而,文本并不是框架能够实时更新的唯一内容!
就像每个框架都有一种方法将状态呈现为屏幕上的文本一样,它也可以更新元素的 HTML 属性。
目前,我们的组件对屏幕阅读器来说date
读起来不太友好,因为它只能读出数字。让我们通过在组件中添加一个人类可读的日期来改变这种情况。aria-label``date
html
<!-- FileDate.vue --><script setup>// ...const dateStr = ref(formatDate(new Date()));</script><template> <span aria-label="January 10th, 2023">{{ dateStr }}</span></template>
现在,当我们使用屏幕阅读器时,它会读出"1 月 10 日"而不是"10"。
不过,虽然在动态格式化之前这种方法可能有效date
,但在一年中的大部分时间里都不会准确。(幸运的是,坏掉的钟每天至少会有一次是准确的。)
让我们通过添加一个formatReadableDate
方法来纠正这个问题,并在属性中反映出来:
html
<!-- FileDate.vue --><script setup>// ...function formatReadableDate(inputDate) { const months = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const monthStr = months[inputDate.getMonth()]; const dateSuffixStr = dateSuffix(inputDate.getDate()); const yearNum = inputDate.getFullYear(); return monthStr + " " + dateSuffixStr + "," + yearNum;}function dateSuffix(dayNumber) { const lastDigit = dayNumber % 10; if (lastDigit == 1 && dayNumber != 11) { return dayNumber + "st"; } if (lastDigit == 2 && dayNumber != 12) { return dayNumber + "nd"; } if (lastDigit == 3 && dayNumber != 13) { return dayNumber + "rd"; } return dayNumber + "th";}const dateStr = ref(formatDate(new Date()));const labelText = ref(formatReadableDate(new Date()));// ...</script><template> <span v-bind:aria-label="labelText">{{ dateStr }}</span></template>
在 Vue 中,
v-bind
有一个更简洁的语法来实现相同的功能。如果你删除v-bind
并保留:
,效果是一样的。这意味着:
css<span v-bind:aria-label="labelText">{{dateStr}}</span>
和:
ruby<span :aria-label="labelText">{{dateStr}}</span>
两者都可以绑定到 HTML 中的属性。
这段代码可能与您在生产环境中看到的不太一样。如果您打算编写生产代码,您可能需要研究派生值,以便将labelText
和 的date
值直接基于同一个Date
对象。这样可以避免调用new Date
两次,但我有点跑题了------我们将在后面的部分讨论派生值。
太棒了!现在,它应该能正确地将文件日期读到屏幕阅读器上了!
输入
我们的文件列表看起来不错!话虽如此,一个重复包含相同文件的文件列表显然不够完整。理想情况下,我们希望将文件名传递给File
组件,以增加一些变化。
幸运的是,组件就像函数一样接受参数!在组件世界中,这些参数通常被称为"输入"或"属性"(简称为"props")。
让我们将文件名作为我们File
组件的输入:
html
<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const props = defineProps(["fileName"]);</script><template> <div> <a href="/file/file_one">{{ props.fileName }}<FileDate /></a> </div></template>
html
<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template> <ul> <li><File :fileName="'File one'" /></li> <li><File :fileName="'File two'" /></li> <li><File :fileName="'File three'" /></li> </ul></template>
我们不需要导入
defineProps
,相反,Vue 使用一些编译器魔法将其作为全局可访问的方法提供。在这里,我们需要在组件上声明每个属性
defineProps
;否则,输入值将无法用于组件的其余部分。另外,我们在讨论属性绑定时提到
:
过 是 的简写v-bind:
。这里也一样。你也可以这样写:
ini<File v-bind:fileName="'File three'" />
在这里,我们可以看到每个都File
以其自己的名字呈现。
将属性传递给组件的一种思路是将数据"向下传递"给我们的子组件。记住,这些组件彼此之间是父子关系。
我们取得的进展真是令人兴奋!但是哦不------链接仍然是静态的!每个文件都拥有href
与上一个文件相同的属性。让我们解决这个问题!
多个属性
与函数类似,组件可以接受任意数量的属性。让我们添加另一个 for href
:
html
<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const props = defineProps(["fileName", "href"]);</script><template> <div> <a :href="props.href">{{ props.fileName }}<FileDate /></a> </div></template>
html
<!-- FileList.vue --><script setup>import File from "./File.vue";</script><template> <ul> <li><File :fileName="'File one'" :href="'/file/file_one'" /></li> <li><File :fileName="'File two'" :href="'/file/file_two'" /></li> <li><File :fileName="'File three'" :href="'/file/file_three'" /></li> </ul></template>
对象传递
虽然我们一直使用字符串将值传递给组件作为输入,但情况并非总是如此。
输入属性可以是任何 JavaScript 类型。这可以包括对象、字符串、数字、数组、类实例或介于两者之间的任何类型!
Date
为了展示这一点,让我们添加将类实例传递给组件的功能file-date
。毕竟,文件列表中的每个文件都可能在不同的时间创建。
html
<!-- FileDate.vue --><script setup>// ...const props = defineProps(["inputDate"]);const dateStr = ref(formatDate(props.inputDate));const labelText = ref(formatReadableDate(props.inputDate));// ...</script><template> <span :aria-label="labelText">{{ dateStr }}</span></template>
html
<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const props = defineProps(["fileName", "href"]);const inputDate = new Date();</script><template> <div> <a :href="props.href" >{{ props.fileName }} <FileDate :inputDate="inputDate" /> </a> </div></template>
再次强调,我必须在此代码示例旁边添加一个小星号。目前,如果您
inputDate
在初始渲染后更新值,它将不会在 中显示新的日期字符串。这是因为我们只设置了和FileDate
的值一次,并且没有更新这些值。dateStr``labelText
正如我们通常所期望的那样,每个框架都有一种通过使用派生值来实时更新此值的方法,但我们将在以后的部分中讨论这一点。
道具规则
虽然组件属性确实可以传递 JavaScript 对象,但是在涉及对象 props 时必须遵循一条规则:
您不能改变组件 prop 值。
例如,以下是一些无法按预期工作的代码:
html
<!-- GenericList.vue --><script setup>import { onMounted } from "vue";const props = defineProps(["inputArray"]);onMounted(() => { // This is NOT allowed and will break things props.inputArray.push("some value");});</script><!-- ... -->
您不应该改变属性,因为这会破坏带有组件的应用程序架构的两个关键概念:
事件绑定
将值绑定到 HTML 属性是控制 UI 的有效方法,但这只是故事的一半。向用户显示信息是一回事,但您还必须对用户的输入做出响应。
实现此目的的一种方法是绑定由用户行为发出的 DOM 事件。
在我们之前看到的模型中,文件列表具有悬停状态。但是,当用户点击某个文件时,它应该更加清晰地突出显示。

让我们isSelected
向组件添加一个属性file
,以有条件地添加悬停样式,然后在用户单击它时更新它。
既然如此,让我们将File
组件迁移到使用button
而不是div
。毕竟,使用语义元素来指示 DOM 中的哪个元素是哪个元素,对于可访问性和 SEO 来说很重要。
html
<!-- File.vue --><script setup>import { ref } from "vue";const props = defineProps(["fileName"]);const inputDate = new Date();const isSelected = ref(false);function selectFile() { isSelected.value = !isSelected.value;}</script><template> <button v-on:click="selectFile()" :style=" isSelected ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ props.fileName }} <FileDate :inputDate="inputDate" /> </button></template>
这里,我们style
使用 Vue 的绑定来绑定属性。你可能会注意到,通过 进行绑定时style
,我们使用的是对象符号来设置样式,而不是通常的字符串。
我们还使用三元语句(condition ? trueVal : falseVal
)作为单行if
语句来决定使用哪种样式。
我们可以使用v-on
bind 前缀将方法绑定到任何事件。这支持任何内置的浏览器事件名称。
还有一个简写语法,就像属性绑定一样。v-on:
我们可以使用@
符号来代替 。
这意味着:
html
<button v-on:click="selectFile()"></button>
可以重写为:
html
<button @click="selectFile()"></button>
输出
组件不仅限于从其父级接收值;您还可以从子组件将值发送回父级。
通常的做法是通过自定义事件向上传递数据,就像浏览器触发的事件一样。就像我们的事件绑定使用了一些新的语法和熟悉的概念一样,我们对事件触发也做了同样的处理。
click
虽然在只有一个文件的情况下,组件中监听事件File
可以正常工作,但当文件数量较多时,它会导致一些奇怪的行为。也就是说,它允许我们通过点击一次选择多个文件。我们假设这不是预期的行为,而是发出一个selected
自定义事件,允许一次只选择一个文件。
Vue 使用全局函数引入了发出事件的想法defineEmits
:
html
<!-- File.vue --><script setup>// ...// `href` is temporarily unusedconst props = defineProps(["isSelected", "fileName", "href"]);const emit = defineEmits(["selected"]);</script><template> <button v-on:click="emit('selected')" :style=" isSelected ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName }} <!-- ... --> </button></template>
该
defineEmits
函数不需要从 导入vue
,因为 Vue 的编译器会为我们处理。
html
<!-- FileList.vue --><script setup>import { ref } from "vue";import File from "./File.vue";const selectedIndex = ref(-1);function onSelected(idx) { if (selectedIndex.value === idx) { selectedIndex.value = -1; return; } selectedIndex.value = idx;}</script><template> <ul> <li> <File @selected="onSelected(0)" :isSelected="selectedIndex === 0" fileName="File one" href="/file/file_one" /> </li> <li> <File @selected="onSelected(1)" :isSelected="selectedIndex === 1" fileName="File two" href="/file/file_two" /> </li> <li> <File @selected="onSelected(2)" :isSelected="selectedIndex === 2" fileName="File three" href="/file/file_three" /> </li> </ul></template>
请注意:此代码尚未完全 投入生产。此代码存在一些可访问性问题,可能需要
aria-selected
修复类似 等问题。
这里,我们使用一个简单的基于数字的索引作为id
每个文件的排序方式。这使我们能够跟踪当前选中或未选中的文件。同样,如果用户选择了已经被选中的索引,我们会将该isSelected
索引设置为一个没有关联文件的数字。
你可能注意到了,我们还isSelected
从组件中移除了状态和逻辑file
。这是因为我们遵循了"提升状态"的做法。
挑战
现在我们已经掌握了组件的基础知识,让我们自己构建一些吧!
也就是说,我希望我们创建以下内容的原始版本:

为此,让我们:
- 创建侧边栏组件
- 添加带有侧边栏项目名称的按钮列表
- 制作一个
ExpandableDropdown
组件 name
向下拉菜单中添加输入并显示它expanded
向下拉菜单中添加输入并显示它- 使用输出切换
expanded
输入 - 使我们的
expanded
财产发挥功能
创建我们的第一个组件
让我们通过创建我们的和一个要渲染的基本组件来开始这个过程index.html
:
html
<!-- index.html --><html> <body> <div id="root"></div> </body></html>
js
// main.js
import { createApp } from "vue";import Sidebar from "./Sidebar.vue";createApp(Sidebar).mount("#root");
html
<!-- Sidebar.vue --><template> <p>Hello, world!</p></template>
现在我们已经为组件建立了初始测试平台,让我们添加一个带有侧边栏列表项名称的按钮列表:
html
<!-- Sidebar.vue --><template> <div> <h1>My Files</h1> <div><button>Movies</button></div> <div><button>Pictures</button></div> <div><button>Concepts</button></div> <div><button>Articles I'll Never Finish</button></div> <div><button>Website Redesigns v5</button></div> <div><button>Invoices</button></div> </div></template>
这种重复的div
组合button
让我想到我们应该将每个项目提取到一个组件中,因为我们想要:
- 重复使用 HTML 布局
- 扩展当前功能
首先将div
和提取button
到它们自己的组件中,我们将其称为ExpandableDropdown
。
html
<!-- Sidebar.vue --><script setup>import ExpandableDropdown from "./ExpandableDropdown.vue";</script><template> <div> <h1>My Files</h1> <ExpandableDropdown name="Movies" /> <ExpandableDropdown name="Pictures" /> <ExpandableDropdown name="Concepts" /> <ExpandableDropdown name="Articles I'll Never Finish" /> <ExpandableDropdown name="Website Redesigns v5" /> <ExpandableDropdown name="Invoices" /> </div></template>
html
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name"]);</script><template> <div> <button> {{ props.name }} </button> </div></template>
我们现在应该看到一个按钮列表,每个按钮都有一个关联名称!
使我们的组件发挥作用
现在我们已经创建了组件的初始结构,让我们努力使它们发挥作用。
首先,我们将:
expanded
为每个按钮创建一个属性expanded
使用输入传递属性- 显示组件
expanded
内部的值ExpandableDropdown
html
<!-- Sidebar.vue --><script setup>import { ref } from "vue";import ExpandableDropdown from "./ExpandableDropdown.vue";// Just to show that the value is displaying properlyconst moviesExpanded = ref(true);const picturesExpanded = ref(false);const conceptsExpanded = ref(false);const articlesExpanded = ref(false);const redesignExpanded = ref(false);const invoicesExpanded = ref(false);</script><template> <div> <h1>My Files</h1> <ExpandableDropdown name="Movies" :expanded="moviesExpanded" /> <ExpandableDropdown name="Pictures" :expanded="picturesExpanded" /> <ExpandableDropdown name="Concepts" :expanded="conceptsExpanded" /> <ExpandableDropdown name="Articles I'll Never Finish" :expanded="articlesExpanded" /> <ExpandableDropdown name="Website Redesigns v5" :expanded="redesignExpanded" /> <ExpandableDropdown name="Invoices" :expanded="invoicesExpanded" /> </div></template>
html
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name", "expanded"]);</script><template> <div> <button> {{ props.name }} </button> <div> {{ props.expanded ? "Expanded" : "Collapsed" }} </div> </div></template>
expanded
记得在 里面添加新的属性名称defineProps
!否则,此组件将无法正确绑定值。
现在让我们添加一个输出以允许我们的组件切换expanded
输入。
html
<!-- Sidebar.vue --><script setup>import { ref } from "vue";import ExpandableDropdown from "./ExpandableDropdown.vue";// Just to show that the value is displaying properlyconst moviesExpanded = ref(true);const picturesExpanded = ref(false);const conceptsExpanded = ref(false);const articlesExpanded = ref(false);const redesignExpanded = ref(false);const invoicesExpanded = ref(false);</script><template> <div> <h1>My Files</h1> <ExpandableDropdown name="Movies" :expanded="moviesExpanded" @toggle="moviesExpanded = !moviesExpanded" /> <ExpandableDropdown name="Pictures" :expanded="picturesExpanded" @toggle="picturesExpanded = !picturesExpanded" /> <ExpandableDropdown name="Concepts" :expanded="conceptsExpanded" @toggle="conceptsExpanded = !conceptsExpanded" /> <ExpandableDropdown name="Articles I'll Never Finish" :expanded="articlesExpanded" @toggle="articlesExpanded = !articlesExpanded" /> <ExpandableDropdown name="Website Redesigns v5" :expanded="redesignExpanded" @toggle="redesignExpanded = !redesignExpanded" /> <ExpandableDropdown name="Invoices" :expanded="invoicesExpanded" @toggle="invoicesExpanded = !invoicesExpanded" /> </div></template>
xml
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name", "expanded"]);const emit = defineEmits(["toggle"]);</script><template> <div> <button @click="emit('toggle')"> {{ props.name }} </button> <div> {{ props.expanded ? "Expanded" : "Collapsed" }} </div> </div></template>
最后,我们可以更新组件,使用名为"hidden"的 HTML 属性ExpandableDropdown
来隐藏和显示下拉菜单的内容。当此属性为 时,它将隐藏内容;当 时,它将显示内容。true``false
html
<!-- ExpandableDropdown.vue --><script setup>const props = defineProps(["name", "expanded"]);const emit = defineEmits(["toggle"]);</script><template> <div> <button @click="emit('toggle')"> {{ expanded ? "V" : ">" }} {{ name }} </button> <div :hidden="!expanded">More information here</div> </div></template>