框架实战指南-动态 HTML

45 阅读13分钟

本文是系列文章的一部分:框架实战指南 - 基础知识

之前,我们学习了如何为文件应用程序创建组件。这些组件包括创建组件树、向每个组件添加输入以传递数据,以及将数据输出返回到父组件。

在我们上次停止的地方,我们手动输入了一个文件列表,其中包括文件名和日期button。让我们回顾一下现有的文件组件:

<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const inputDate = new Date();// `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 }}		<FileDate :inputDate="inputDate" />	</button></template>

这对于组件来说是一个坚实的基础,无需进行太多更改。

我们很想添加查看文件夹和文件的功能。虽然我们可以——而且应该——添加一个组件,用于复制/粘贴组件中的代码File来创建新Folder组件,但让我们复用现有的组件吧!

为此,我们将创建一个名为的新属性isFolder,当设置为 true 时,它将隐藏日期。

条件渲染

我们可以隐藏date不显示用户的一种方法是重复使用我们在上一章的挑战中介绍的 HTML 属性:hidden

<div hidden="true">	<!-- This won't display to the user -->	<FileDate /></div>

这种方法虽然有效,但却带来了一个潜在的问题:虽然内容没有显示给用户(并且同样对屏幕阅读器隐藏),但它们仍然存在于 DOM 中。

这意味着大量被标记为 的 HTML 元素hidden仍将存在于 DOM 中。它们仍然会影响性能和内存使用,就像它们正在显示给用户一样。

这乍一听可能有悖常理,但内存中未显示的 UI 元素有其用武之地;在构建以视觉方式过渡项目进出视图的动画系统时,它们特别有用。

为了避免这些性能问题,React、Angular 和 Vue 都提供了一个基于布尔值“有条件地渲染”HTML 元素的方法。这意味着,如果你传递false,它将从 DOM 中完全移除子 HTML 元素。

让我们看看它的使用情况:

<script setup>const props = defineProps(["bool"]);</script><template>	<div><p v-if="bool">Text here</p></div></template>

与 Angular 不同,在 Angular 中需要导入有条件渲染元素的能力,而 Vue 则将其视为v-if可以添加到任何元素或组件的全局属性。

在这个例子中,当我们传递boolas时true,组件的 HTML 被呈现为:

<div><p>Text here</p></div>

但是当bool设置为时false,它会呈现以下 HTML:

<div></div>

这是可能的,因为 React、Angular 和 Vue 控制着屏幕上渲染的内容。利用这一点,它们只需一个布尔指令就可以删除或添加渲染到 DOM 的 HTML。

了解了这一点,让我们为我们的应用程序添加条件渲染。

条件渲染我们的日期

现在,我们有一个文件列表要呈现给用户。但是,如果我们回顾一下我们的模型,我们会注意到我们想要在文件旁边列出文件夹。

image.png

幸运的是,我们的File组件已经管理了我们想要在潜在Folder组件中实现的大部分行为。例如,就像文件一样,我们希望在用户点击文件夹时选中它,以便我们可以一次选择多个文件和文件夹。

然而,与文件不同,文件夹没有创建日期,因为“上次修改”日期对于文件夹的含义可能存在歧义。上次修改日期是文件夹重命名的日期吗?还是该文件夹中某个文件的上次修改日期?这不太明确,所以我们会把它砍掉。

尽管功能上存在差异,我们仍然可以将File组件复用到文件夹上。如果我们知道显示的是文件夹而不是文件,我们可以通过有条件地渲染日期来复用此组件。

让我们向我们的File组件添加一个名为的输入isFolder,如果该输入设置为“true”,则阻止日期呈现。

<!-- File.vue --><script setup>import FileDate from "./FileDate.vue";const inputDate = new Date();const props = defineProps(["isSelected", "isFolder", "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 }}		<FileDate v-if="!isFolder" :inputDate="inputDate" />	</button></template>
<!-- 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="Folder one" href="/file/folder_one/" :isFolder="true" />		</li>	</ul></template>

条件分支

我们现在可以根据isFolder布尔值有条件地向用户显示上次修改日期。但是,由于我们尚未向用户清晰地显示这些信息,用户可能仍然不清楚什么是文件夹,什么是文件。

让我们使用条件渲染来根据布尔值显示所显示的项目类型isFolder

<div>	<span v-if="isFolder">Type: Folder</span>	<span v-if="!isFolder">Type: File</span></div>

在处理这个问题时,我们可能会清楚地发现我们正在有效地重建一个if ... else语句,类似于 JavaScript 中的以下逻辑。

if (isFolder) return "Type: Folder";else return "Type: File";

与这些框架运行的 JavaScript 环境一样,它们也else为此目的实现了类似风格的 API。

<div>	<span v-if="isFolder">Type: Folder</span>	<span v-else>Type: File</span></div>

这里,Vue 的if...else语法看起来与我们上面显示的 JavaScript 伪语法非常相似。

值得注意的是,v-else标签必须紧跟v-if标签;否则,它将不起作用。

扩展分支

如果您只需要检查一个布尔值,那么它if ... else会非常有效,但您通常需要检查多个条件分支。

例如,如果我们添加isImage布尔值来区分图像和其他文件类型会怎样?

我们可以回到if针对每个条件的简单语句:

<span v-if="isFolder">Type: Folder</span><span v-if="!isFolder && isImage">Type: Image</span><span v-if="!isFolder && !isImage">Type: File</span>

如果一行中有多个条件语句,阅读起来会比较困难。因此,这些框架提供了一些工具,可以帮助你提高代码的可读性。

正如 Vue 的v-if/v-else属性与 JavaScript 的if...else语法匹配一样,我们可以重用与 JavaScript 类似的逻辑:

function getType() {	if (isFolder) return "Folder";	else if (isImage) return "Image";	else return "File";}

使用 Vue 的v-else-if属性:

<div>	<span v-if="isFolder">Type: Folder</span>	<span v-else-if="isImage">Type: Image</span>	<span v-else>Type: File</span></div>

再次强调,v-else-ifv-else标签必须彼此跟随才能按预期工作。

渲染列表

虽然我们在本章中主要关注File组件的改进,但让我们再看一下原始FileList组件。

<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"				:isFolder="false"			/>		</li>		<li>			<File				@selected="onSelected(1)"				:isSelected="selectedIndex === 1"				fileName="File two"				href="/file/file_two"				:isFolder="false"			/>		</li>		<li>			<File				@selected="onSelected(2)"				:isSelected="selectedIndex === 2"				fileName="File three"				href="/file/file_three"				:isFolder="false"			/>		</li>	</ul></template>

File再看一眼,你可能会立刻被这些代码示例的长度所吸引!有趣的是,这主要是因为我们的组件是重复复制粘贴的。

而且,这种硬编码文件组件的方法意味着我们无法在 JavaScript 中创建新文件并将其显示在 DOM 中。

让我们通过用循环和数组替换复制粘贴的组件来解决这个问题。

Vue 提供了一个v-for全局属性,它列出了v-if条件渲染的功能:

<!-- FileList.vue --><script setup>import { ref } from "vue";import File from "./File.vue";const filesArray = [	{		fileName: "File one",		href: "/file/file_one",		isFolder: false,	},	{		fileName: "File two",		href: "/file/file_two",		isFolder: false,	},	{		fileName: "File three",		href: "/file/file_three",		isFolder: false,	},];const selectedIndex = ref(-1);function onSelected(idx) {	if (selectedIndex.value === idx) {		selectedIndex.value = -1;		return;	}	selectedIndex.value = idx;}</script><template>	<ul>		<!-- This will throw a warning, more on that soon -->		<li v-for="(file, i) in filesArray">			<File				@selected="onSelected(i)"				:isSelected="selectedIndex === i"				:fileName="file.fileName"				:href="file.href"				:isFolder="file.isFolder"			/>		</li>	</ul></template>

在我们的内部v-for,我们访问项目的值(file)和循环项目的索引(i)。

如果我们查看渲染的输出,我们可以看到所有三个文件都按预期列出!

使用此代码作为基础,我们可以通过将另一个项目添加到硬编码列表中来将此文件列表扩展为任意数量的文件filesArray;无需更改模板代码!

按键

无论使用哪种框架,您都可能在前面的代码示例中遇到过类似下面的错误:

迭代中的元素需要有“v-bind:key”指令

这是因为,在这些框架中,您需要传递一个称为key(或track在 Angular 中)的特殊属性,相应的框架使用该属性来跟踪哪个项目是哪个。

考虑支持

如果没有此keyprop,框架就无法知道哪些元素没有被更改,因此每次列表重新渲染时都必须销毁并重新创建数组中的每个元素。这可能会导致严重的性能问题和稳定性问题。

如果你感到困惑,不用担心——最后一段有很多技术性术语。继续阅读,了解这在实际中意味着什么,读完本章后,别担心,可以回头重读本节。

假设您有以下内容:

<!-- WordList.vue --><script setup>import { ref } from "vue";const wordDatabase = [	{ word: "who", id: 1 },	{ word: "what", id: 2 },	{ word: "when", id: 3 },	{ word: "where", id: 4 },	{ word: "why", id: 5 },	{ word: "how", id: 6 },];function getRandomWord() {	return wordDatabase[Math.floor(Math.random() * wordDatabase.length)];}const words = ref([]);function addWord() {	const newWord = getRandomWord();	// Remove ability for duplicate words	if (words.value.includes(newWord)) return;	words.value.push(newWord);}function removeFirst() {	words.value.shift();}</script><template>	<div>		<button @click="addWord()">Add word</button>		<button @click="removeFirst()">Remove first word</button>		<ul>			<li v-for="word in words">				{{ word.word }}				<input type="text" />			</li>		</ul>	</div></template>

如果不使用某种keyprop(或者,当您track obj在 Angular 中使用没有属性的 prop 时),您的列表将在每次运行时被销毁并重新创建addWord

input您可以通过在 中输入一些文本并按下按钮来演示"Remove first word"。当您这样做时,输入的文本会表现出奇怪的行为。

在 Angular 中,输入文本会直接消失。然而,在 React 和 Vue 中,文本会移动到你最初输入的单词的下一行。

这两种行为都很奇怪——我们似乎没有修改li包含input有问题的;为什么它的内容会移动或被完全删除?

输入文本发生变化的原因是框架无法检测到数组中的哪些项发生了变化,因此会将所有 DOM 元素标记为“过时”。这些“过时”的元素随后会被框架销毁,但会立即重建, 以确保向用户显示最新的信息。

image.png

相反,我们可以通过与每个列表项关联的唯一“键”来告诉框架哪个列表项是哪个。这个键可以让框架智能地防止在列表数据更改时未更改的项目被破坏。

image.png

让我们看看如何在每个框架中做到这一点。

<!-- WordList.vue --><template>	<div>		<button @click="addWord()">Add word</button>		<ul>			<li v-for="word in words" :key="word.id">{{ word.word }}</li>		</ul>	</div></template><!-- ... -->

在这里,我们使用属性通过的唯一字段key告诉 Vue 哪个li与哪个相关。word``word``id

现在,当我们重新渲染列表时,框架能够准确地知道哪些项目已更改,哪些项目未更改。

因此,它只会重新渲染新项目,而保留旧的和未更改的 DOM 元素。

按键作为渲染提示

正如我们之前提到的,key框架使用该属性来确定哪个元素是哪个元素。更改key给定元素的此属性,它将被销毁并重新创建,就像它是一个新节点一样。

虽然这最适用于列表内,但在列表外也是如此;将 a 分配key给元素并更改它,它将从头开始重新创建。

例如,假设我们有一个基本功能input,希望在按下按钮时能够重置。

为此,我们可以key为 分配一个属性input,并更改 said 的值key以强制重新创建input

<!-- KeyExample.vue --><script setup>import { ref } from "vue";const num = ref(0);function increase() {	num.value++;}</script><template>	<input :key="num" />	<button @click="increase()">Increase</button>	<p>{{ num }}</p></template>

此刷新有效,因为我们没有持久化 的inputvalue因此,当key更新并input在其位置呈现新的 时,内存中的 DOM 值将被重置并且不会再次绑定。

input此重置是导致按下按钮后屏幕变黑的原因。

元素的“引用”到框架对元素的理解的想法可能有点令人困惑。

在未来的章节中,我们将详细了解每个框架如何在底层处理这些引用。

投入生产

既然我们现在了解了为列表提供键的稳定性和性能优势,那么让我们将它们添加到我们的FileList组件中。

<!-- FileList.vue --><script setup>import { ref } from "vue";import File from "./File.vue";const filesArray = [	{		fileName: "File one",		href: "/file/file_one",		isFolder: false,		id: 1,	},	{		fileName: "File two",		href: "/file/file_two",		isFolder: false,		id: 2,	},	{		fileName: "File three",		href: "/file/file_three",		isFolder: false,		id: 3,	},];const selectedIndex = ref(-1);function onSelected(idx) {	if (selectedIndex.value === idx) {		selectedIndex.value = -1;		return;	}	selectedIndex.value = idx;}</script><template>	<ul>		<li v-for="(file, i) in filesArray" :key="file.id">			<File				@selected="onSelected(i)"				:isSelected="selectedIndex === i"				:fileName="file.fileName"				:href="file.href"				:isFolder="file.isFolder"			/>		</li>	</ul></template>

一起使用

让我们使用新获得的条件和列表渲染知识并将它们结合到我们的应用程序中。

假设我们的用户希望过滤显示FileList内容,只显示文件而不显示文件夹。我们可以在模板循环中添加条件语句来启用此功能!

<!-- FileList.vue --><script setup>import { ref } from "vue";// ...const onlyShowFiles = ref(false);function toggleOnlyShow() {	onlyShowFiles.value = !onlyShowFiles.value;}</script><template>	<div>		<button @click="toggleOnlyShow()">Only show files</button>		<ul>			<li v-for="(file, i) in filesArray" :key="file.id">				<File					v-if="onlyShowFiles ? !file.isFolder : true"					@selected="onSelected(i)"					:isSelected="selectedIndex === i"					:fileName="file.fileName"					:href="file.href"					:isFolder="file.isFolder"				/>			</li>		</ul>	</div></template>

虽然这段代码可以正常工作,但其中存在一个看似无声却致命的 bug。我们会在“部分 DOM 应用”一章中解释这个 bug,不过我先给你个提示:它与条件渲染File组件(而不是li元素)有关。

挑战

在上一章的挑战中,我们开始创建下拉文件结构侧边栏组件。

image.png

我们通过将每个组件硬编码ExpandableDropdown为单独的标签来实现这一点:

<!-- 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>
<!-- Sidebar.vue --><script setup>import { ref } from "vue";import ExpandableDropdown from "./ExpandableDropdown.vue";const moviesExpanded = ref(false);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>

更重要的是,我们使用hiddenHTML 属性来直观地隐藏折叠的内容。

让我们运用本章所学的知识来改进这两个挑战。在本挑战中,我们将:

  1. 使用列表而不是单独ExpandableDropdown进行硬编码
  2. 使用对象映射来跟踪每个下拉菜单的expanded属性
  3. 将属性的使用迁移hidden到有条件渲染

将硬编码元素迁移到列表

让我们首先创建一个字符串数组,我们可以使用它来呈现每个下拉菜单。

暂时不要担心expanded功能,现在让我们硬编码expanded并将false切换功能指向一个空函数。

我们很快会再讨论这个问题。

<!-- Sidebar.vue --><script setup>import ExpandableDropdown from "./ExpandableDropdown.vue";const categories = [	"Movies",	"Pictures",	"Concepts",	"Articles I'll Never Finish",	"Website Redesigns v5",	"Invoices",];const onToggle = () => {};</script><template>	<div>		<h1>My Files</h1>		<ExpandableDropdown			v-for="cat of categories"			:key="cat"			:name="cat"			:expanded="false"			@toggle="onToggle()"		/>	</div></template>

现在我们已经有了下拉菜单渲染的初始列表,让我们继续重新启用该expanded功能。

为此,我们将使用一个对象映射,该映射使用类别名称作为键,expanded使用状态作为键的值:

({	// This is expanded	"Articles I'll Never Finish": true,	// These are not	Concepts: false,	Invoices: false,	Movies: false,	Pictures: false,	"Website Redesigns v5": false,});

为了创建这个对象映射,我们可以创建一个名为的函数,objFromCategories该函数接受我们的字符串数组并从上面构造上述内容:

function objFromCategories(categories) {	let obj = {};	for (let cat of categories) {		obj[cat] = false;	}	return obj;}

让我们看看它的用法:

<!-- Sidebar.vue --><script setup>import { ref } from "vue";import ExpandableDropdown from "./ExpandableDropdown.vue";const categories = [	"Movies",	"Pictures",	"Concepts",	"Articles I'll Never Finish",	"Website Redesigns v5",	"Invoices",];const expandedMap = ref(objFromCategories(categories));const onToggle = (cat) => {	expandedMap.value[cat] = !expandedMap.value[cat];};function objFromCategories(categories) {	let obj = {};	for (let cat of categories) {		obj[cat] = false;	}	return obj;}</script><template>	<h1>My Files</h1>	<ExpandableDropdown		v-for="cat of categories"		:key="cat"		:name="cat"		:expanded="expandedMap[cat]"		@toggle="onToggle(cat)"	/></template>

有条件地渲染隐藏内容

现在我们已经将下拉菜单迁移为使用列表而不是对每个组件实例进行硬编码,让我们将下拉菜单的折叠内容迁移为有条件地渲染而不是使用hiddenHTML 属性。

<!-- 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 v-if="expanded">More information here</div>	</div></template>