本文是系列文章的一部分:框架实战指南 - 基础知识
我们在书中反复使用的组件的核心原则之一是组件输入或属性的概念。
虽然组件输入无疑是有帮助的,但当您需要跨多层组件的同一组数据时,大规模使用它们可能会很困难。
例如,让我们回顾一下我们在整本书中开发的文件应用程序。
这里,我们在屏幕角落处显示了文件列表和用户的个人资料图片。以下是该页面数据的示例:
const APP_DATA = { currentUser: { name: "Corbin Crutchley", profilePictureURL: "https://avatars.githubusercontent.com/u/9100169", }, collection: [ { name: "Movies", type: "folder", ownerName: null, size: 386547056640, }, { name: "Concepts", type: "folder", ownerName: "Kevin Aguillar", size: 0, }, ],};
为了集中注意力于当前主题,这些数据已被缩短。
有了这些数据,我们可以在上面的模型中渲染出大部分 UI。
让我们使用这些数据来构建应用程序的一些基础功能。例如,假设每次看到 of ownerName,我们就用's字段null替换它。currentUser``name
为此,我们需要将我们的collection和currentUser向下传递给每个子组件。
让我们使用一些伪代码来模拟数据从父组件传递到子组件时这些组件的样子:
// This is not real code, but demonstrates how we might structure our data passing// Don't worry about syntax, but do focus on how data is being passed between components
const App = { data: APP_DATA, template: ( <div> <Header currentUser="data.currentUser" /> <Files files="data.collection" currentUser="data.currentUser" /> </div> ),};const Header = { props: ["currentUser"], template: ( <div> <Icon /> <SearchBar /> <ProfilePicture currentUser="props.currentUser" /> </div> ),};const ProfilePicture = { props: ["currentUser"], template: <img src="props.currentUser.profilePictureURL" />,};const Files = { props: ["currentUser", "files"], template: ( <FileTable> {props.files.map((file) => ( <FileItem file="file" currentUser="props.currentUser" /> ))} </FileTable> ),};const FileItem = { props: ["currentUser", "file"], template: ( <tr> <FileName file="props.file" /> <LastModified file="props.file" /> <FileOwner file="props.file" currentUser="props.currentUser" /> <FileType file="props.file" /> <FileSize file="props.file" /> </tr> ),};const FileOwner = { props: ["currentUser", "file"], data: { userNameToShow: props.file.ownerName || props.currentUser.name, }, template: <td>{{ userNameToShow }}</td>,};render(App);
虽然这不是真正的代码,但我们可以通过查看如下布局的代码来发现:我们currentUser几乎将其传递给每一个组件!
如果我们绘制出数据流的样子,我们的currentUser属性将像这样传递:
虽然传递currentUser每个组件的数据很令人讨厌,但我们需要所有这些组件中的数据,所以我们不能简单地删除输入,对吗?
嗯,可以!算是吧……
虽然我们无法完全移除父组件向子组件传递数据的能力,但我们可以 隐式地传递这些组件的数据,而不是显式地传递。这意味着,我们不需要告诉子组件应该接受什么数据,而是直接将数据交给子组件,无论是否需要。之后,子组件就可以主动请求数据了。
可以把这想象成自助餐。食物不是直接送到顾客的餐桌上,而是顾客自己拿着所有食物来到餐桌前,选择自己想吃的,并且对结果同样满意。
我们使用一种称为“依赖注入”的方法来实现这种隐式数据传递。
通过依赖注入提供基本值
当我们谈论依赖注入时,我们指的是通过隐式方式从父组件向子组件提供数据的方法。
使用依赖注入,我们可以改变提供数据的方式,将数据隐式传递给整个应用程序。这样做可以让我们重新设计应用程序,并简化数据的获取方式,如下所示:
这里,从提供的值中获取数据,FileOwner而不必经过每个单独的组件。ProfilePicture``App
React、Angular 和 Vue 都提供了使用依赖注入将数据隐式注入子组件的方法。让我们从最基本的依赖注入方法开始,向子组件提供一些原始值,例如数字或字符串。
Vue 的依赖注入 API 只有两部分:
provide使用一种方法来从父组件提供值。inject使用一种方法来获取子组件中提供的值。
<!-- Parent.vue --><script setup>import { provide } from "vue";import Child from "./Child.vue";provide("WELCOME_MESSAGE", "Hello, world!");</script><template> <Child /></template>
<!-- Child.vue --><script setup>import { inject } from "vue";const welcomeMsg = inject("WELCOME_MESSAGE");</script><template> <p>{{ welcomeMsg }}</p></template>
在这里,我们期望这个组件显示一个<p>渲染出来的标签"Hello, world!"。
虽然这种方式可以方便地将简单的值传递给应用程序的多个部分,但大多数依赖注入的用法往往需要提供更复杂的数据。让我们扩展此逻辑,改为向子组件提供一个对象。
与 React 一样,Vue 的简单依赖注入 API 意味着我们只需要将我们的provide值更改为一个对象,然后就可以开始比赛了!
<!-- Parent.vue --><script setup>import { provide } from "vue";import Child from "./Child.vue";const welcomeObj = { message: "Hello, world!" };provide("WELCOME_MESSAGE", welcomeObj);</script><template> <Child /></template>
<!-- Child.vue --><script setup>import { inject } from "vue";const welcomeMsgObj = inject("WELCOME_MESSAGE");</script><template> <p>{{ welcomeMsgObj.message }}</p></template>
注入后改变值
虽然从父节点向子组件提供值本身就很有用,但如果包含数据操作,它会变得更加强大。
例如,当你的用户想要使用某种重命名功能来更改他们的名字时会发生什么?你应该能够更改依赖注入中数据的存储方式,以便将这些更改立即传播到整个应用程序。
Vue 的最小 API 表面允许我们组合ref和provide使用以提供可以在注入后更改的值。
<!-- Parent.vue --><script setup>import { provide, ref } from "vue";import Child from "./Child.vue";const welcomeMessage = ref("Initial value");provide("WELCOME_MESSAGE", welcomeMessage);function updateMessage() { welcomeMessage.value = "Updated value";}</script><template> <Child /> <button @click="updateMessage()">Update the message</button></template>
<!-- Child.vue --><script setup>import { inject } from "vue";// Worth mentioning, `welcomeMessage` is now _not_ a string, but rather a `ref`// If you needed to use `welcomeMessage` inside of `<script setup>`, you'd// need to use `.value`const welcomeMessage = inject("WELCOME_MESSAGE");</script><template> <p>{{ welcomeMessage }}</p></template>
改变子级注入的值
上一节展示了如何从组件的根组件更改注入的值。但是,如果我们想从子组件而不是根组件更改注入的值,该怎么办呢?
因为依赖注入通常只朝一个方向进行(从父级到子级),所以我们目前还不清楚如何做到这一点。
尽管如此,每个框架都提供了从子组件本身更新注入值的工具。让我们看看如何实现:
在之前的例子中,我们曾经将provide注入ref到子组件中。由于 Vue 的响应式系统,我们可以使用它ref来更改子组件中.value的ref,从而修改整个应用程序的注入值。
<!-- Parent.vue --><script setup>import { provide, ref } from "vue";import Child from "./Child.vue";const welcomeMessage = ref("Initial value");provide("WELCOME_MESSAGE", welcomeMessage);</script><template> <Child /></template>
<!-- Child.vue --><script setup>import { inject } from "vue";const welcomeMessage = inject("WELCOME_MESSAGE");function updateMessage() { welcomeMessage.value = "Updated value";}</script><template> <p>{{ welcomeMessage }}</p> <button @click="updateMessage()">Update the message</button></template>
可选注入值
让我们回想一下本章的开头。引入依赖注入的最初目标是实现在多个组件之间共享用户登录信息。
虽然你可能希望用户的登录信息始终存在,但如果不是呢?如果用户首次创建帐户时选择不输入姓名和头像,该怎么办?即使这看起来不太可能,一个健壮的应用程序也应该处理这样的极端情况。
幸运的是,React、Angular 和 Vue 都可以通过将值标记为“可选”来承受通过依赖注入提供的空值。
inject与 React 的依赖注入系统非常相似,当使用没有父级的 Vue 时provide,inject其默认值为undefined。
<!-- Parent.vue --><script setup>import Child from "./Child.vue";</script><template> <Child /></template>
<!-- Child.vue --><script setup>import { inject } from "vue";const welcomeMessage = inject("WELCOME_MESSAGE");// undefinedconsole.log(welcomeMessage);</script><template> <p v-if="welcomeMessage">{{ welcomeMessage }}</p> <p v-if="!welcomeMessage">There is no welcome message</p></template>
如果您这样做,您可能会看到类似这样的警告
console:
[Vue warn]: injection "WELCOME_MESSAGE" not found.这是正常的和预料之中的——保持冷静,继续编码。
可选值的默认值
虽然我们的代码现在对丢失数据具有更强的弹性,但是当所述数据不存在时,应用程序的某些部分丢失并不是很好的用户体验。
相反,我们决定,当用户没有提供姓名时,在整个应用中提供一个默认值“未知姓名”。为此,我们需要在依赖注入系统中提供该默认值。
Vue 是这三个框架中唯一一个支持内置方式为依赖注入值提供默认值的框架。为此,只需向该inject方法传递第二个参数,它就会被用作默认值。
<!-- Child.vue --><script setup>import { inject } from "vue";const welcomeMessage = inject("WELCOME_MESSAGE", "Default value");// "Default value"console.log(welcomeMessage);</script><template> <p>{{ welcomeMessage }}</p></template>
应用程序范围的提供程序
在我们的示例代码库中,我们构建了一个userData在整个应用程序中使用的数据结构。与其为这些数据注入值,不如在组件树的任何位置访问这些信息。
在 Vue 中,在应用程序根目录下提供值与在代码库的任何其他位置提供值类似。provide在根App组件内添加方法调用;您的数据现在已全局提供。
<!-- App.vue --><script setup>import { provide, ref } from "vue";import Child from "./Child.vue";const welcomeMessage = ref("Hello, world!");provide("WELCOME_MESSAGE", welcomeMessage);</script><template> <Child /></template>
这些全局提供的值也称为“单例”。使用单例时,务必记住数据是在所有组件之间共享的。如果我们有三个组件都使用同一个提供的值,并且该值发生了改变,那么所有使用它的组件都会更新该值。
应用程序范围的提供程序可能会导致性能问题
通常,建议将数据提供程序尽可能靠近目标组件。例如,假设你有以下组件结构:
<App> <PageLayout> <HomePage> <Files> <FileTable> <FileItem> <FileOwner /> <FileType /> </FileItem> </FileTable> </Files> </HomePage> </PageLayout></App>
provide对于数据,您有两种选择:
- 组件结构的根源(
App) - 在距离地点最近的源处(
FileTable)
在这两个选项之间,您通常应该选择使用#2,这会将您的数据注入放置得更接近需要所述数据的组件。
虽然这并不总是可能的,但这样做的理由是,对注入值所做的任何更改都必须向下传播并找到需要重新渲染的组件。
虽然 Vue 等一些框架可以优雅地处理这个问题,并且只重新渲染实际使用注入值的组件,但 React 和 Angular 有所不同。
假设我们使用一个应用程序范围的提供程序。在 React 和 Angular 中,当我们更改提供程序的值时,框架必须搜索整个组件树来找到需要重新渲染的组件。
AppReact 甚至在使用时重新渲染所有子组件useContext来提供变化的数据。
这个问题可以通过外部工具来解决,比如React Redux或Angular Redux,因为它们引入了一种更优化的检测组件数据变化的机制。
幸运的是,您对这些框架的内置依赖注入 API 的了解将在您使用这些外部工具时提供极大的帮助。
覆盖依赖注入的特殊性
大型应用程序很快就会变得复杂。请考虑以下示例:
你有一个应用,它在应用的根组件(App组件)中提供用户数据。但是,你需要在较低级别的组件中替换该用户数据,以用于组件树的其余部分。
对于这些情况,这些较大的应用程序可以替换中间树中的依赖注入值,如下所示:
尽管很少见,但此功能是一项引人注目的功能,您可以在应用程序中利用它。
子组件的依赖注入将从最近的父组件解析。这意味着,如果您有两个提供程序,但其中一个较近,它将从较近的父组件读取。
这意味着如果我们有以下结构:
<App> <Child> <GrandChild> <GreatGrandChild /> </GrandChild> </Child></App>
并且将App和GrandChild注入值到同一个命名上下文中,然后GreatGrandChild将显示来自而GrandChild不是的信息App:
<!-- App.vue --><script setup>import { provide } from "vue";import Child from "./Child.vue";provide("NAME", "Corbin");</script><template> <Child /></template>
<!-- Child.vue --><script setup>import { inject } from "vue";import GrandChild from "./GrandChild.vue";const name = inject("NAME");</script><template> <p>Name: {{ name }}</p> <GrandChild /></template>
<!-- GrandChild.vue --><script setup>import { provide } from "vue";import GreatGrandChild from "./GreatGrandChild.vue";// Notice the new provider here, it will supplement the `App` injected value// for all child components of `GrandChild`provide("NAME", "Kevin");</script><template> <GreatGrandChild /></template>
<!-- GreatGrandChild.vue --><script setup>import { inject } from "vue";const name = inject("NAME");</script><template> <p>Name: {{ name }}</p></template>
之前,我们讨论了依赖注入如何像数据自助餐一样;组件就像顾客从无限量数据自助餐中获取食物。
让我们继续这个类比:
假设你坐在自助餐厅里,那里有三张餐桌。这些餐桌按距离你最近的顺序排列如下:
- 鸡,离你最近
- 鸡,离你第二近
- 鱼,最远的地方
如果您想吃鸡肉,您不太可能走得更远去买同样的食物,而是会选择最近的有鸡肉的桌子来取餐。
这类似于组件如何尝试在组件树中为请求的数据类型找到最近的数据源。
查找特定的注入值
仅仅因为我们可以在整个应用程序中拥有多个提供程序并不意味着您的组件获取特定请求的数据的方式没有顺序。
我们还是用之前那三张自助餐桌的例子来类比。现在假设想点菜的人是个鱼素者;他们不吃肉,除非是鱼。
尽管鸡离它们更近,它们还是会不辞辛劳地去寻找放有鱼的桌子。
同样,如果您的数据提供者托管的数据与您的子组件所寻找的数据完全不相关,它可能无法获取正确的数据。
<!-- App.vue --><script setup>import { provide } from "vue";import Child from "./Child.vue";provide("NAME", "Corbin");</script><template> <Child /></template>
<!-- Child.vue --><script setup>import GrandChild from "./GrandChild.vue";</script><template> <GrandChild /></template>
<!-- GrandChild.vue --><script setup>import { provide } from "vue";import GreatGrandChild from "./GreatGrandChild.vue";provide("FAV_FOOD", "Ice Cream");</script><template> <GreatGrandChild /></template>
<!-- GreatGrandChild.vue --><script setup>import { inject } from "vue";// Despite the `AGE` being closer, this is// specifically looking for the `NAME` and will// go further up in the tree to find that data from `App`const name = inject("NAME");// Meanwhile, this will search for the context that pertains to its nameconst favFood = inject("FAV_FOOD");</script><template> <p>Name: {{ name }}</p> <p>Favorite food: {{ favFood }}</p></template>
依赖注入消费者的这种寻求特异性的行为有助于确保您的数据在注入器站点之间保持其“形状”。
我的数据的“形状”是什么?
提供数据一致性的重要性
数据“形状”的概念是,两段数据共享足够多的相关数据,从而被认为在“形状”上“相似”。
例如,如果您有以下对象:
const obj1 = { a: 1, b: 2 };
它将被认为具有与另一个物体相同的“形状”:
const obj2 = { a: 2, b: 3 };
即使两个对象包含的值略有不同,对象的“形状”也由以下因素定义:
- 属性的名称
- 每个属性中存储的数据类型
- 房产数量
虽然上面的形状相同,但这里有一些形状不同的示例:
const obj1 = { a: 1, b: 2 };const obj2 = { c: 1, d: 2 };isSameShape(obj1, obj2); // false
const obj1 = { a: 1, b: 2 };const obj2 = { a: "1", b: 2 };isSameShape(obj1, obj2); // false
const obj1 = { a: 1, b: 2 };const obj2 = { a: 1, b: 2, c: 3 };isSameShape(obj1, obj2, "exact"); // falseisSameShape(obj1, obj2, "similar"); // true
虽然 #1 和 #2 是严格要求,但属性的数量可能会略有变化,并且仍然被视为相似的“形状”,即使它不是完全匹配。
您可以通过比较两个几何形状来形象化物体的形状:三角形与菱形不同。
为什么这很重要?这与前端框架有什么关系?
依赖注入提供程序之间的“保持形状”的概念至关重要。假设你有以下代码:
<!-- App.vue --><script setup>import { provide } from "vue";import Child from "./Child.vue";provide("USER", { name: "Corbin" });</script><template> <Child /></template>
<!-- Child.vue --><script setup>import GrandChild from "./GrandChild.vue";</script><template> <GrandChild /></template>
<!-- GrandChild.vue --><script setup>import { provide } from "vue";import GreatGrandChild from "./GreatGrandChild.vue";provide("USER", { firstName: "Corbin", lastName: "Crutchley" });</script><template> <GreatGrandChild /></template>
<!-- GreatGrandChild.vue --><script setup>import { inject } from "vue";// Nothing will display, because we switched the user// type halfway through the component treeconst user = inject("USER");</script><template> <p>Name: {{ user.name }}</p></template>
如果我们仔细阅读App组件和GreatGrandChild组件本身,我们不会发现任何问题。但是如果我们查看最终渲染,我们会看到以下标记:
<p>Name:</p>
出现此错误的原因是我们在组件树中途切换了对象的形状。
这种形状的一致性不仅有助于避免错误。在多个函数调用中保持相似的形状,可以通过一种名为“单态内联缓存”的浏览器内部优化,隐式地提升应用的性能。
以上链接旨在帮助
注入数据的差异
注入数据所需的一致性并不意味着每个提供商提供的数据必须完全相同。
就像每个自助餐桌上的同一食物的每个盘子上可能都有不同的调料一样,各个数据提供者也可能为其提供的值注入差异。
例如,虽然注入对象的方法应该接受相同的 props 并且通常应该返回相同的值,但您可以更改注入对象内部的逻辑:
<!-- App.vue --><script setup>import { ref, provide } from "vue";import Child from "./Child.vue";const greeting = ref("");function changeGreeting(val) { greeting.value = val;}provide("MESSAGE", { greeting, changeGreeting });</script><template> <Child /></template>
<!-- Child.vue --><script setup>import GrandChild from "./GrandChild.vue";</script><template> <GrandChild /></template>
<!-- GrandChild.vue --><script setup>import { provide, ref } from "vue";import GreatGrandChild from "./GreatGrandChild.vue";const greeting = ref("✨ Welcome 💯");// New ✨ sparkly ✨ functionality adds some fun! 💯function changeGreeting(newVal) { if (!newVal.includes("✨")) { newVal += "✨"; } if (!newVal.includes("💯")) { newVal += "💯"; } greeting.value = newVal;}provide("MESSAGE", { greeting, changeGreeting });</script><template> <GreatGrandChild /></template>
<!-- GreatGrandChild.vue --><script setup>import { inject } from "vue";const { greeting, changeGreeting } = inject("MESSAGE");</script><template> <div> <p>{{ greeting }}, user!</p> <label> <div>Set a new greeting</div> <input :value="greeting" @input="changeGreeting($event.target.value)" /> </label> </div></template>
这里,我们看到了同一个Greeter注入值的两种变体。一种更严肃一些:“设置值而不改变它”,而另一种注入值则添加了一些表情符号来为你的问候增添趣味!
你可以把它想象成几何形状颜色的变化。如果你有两个三角形,一个是红色,一个是蓝色,你仍然可以识别出它们是同一个形状。
虽然第一组形状与第二组形状并不相同 , 但它们仍然是相同的形状。
挑战
之前,在我们的元素参考和组件参考章节中,我们构建了一个上下文菜单组件来显示用户通过右键单击文件可以采取的其他操作。
该组件具有作为自定义上下文菜单所需的所有关键功能:
- 右键点击打开
- 当用户点击其外部时关闭
- 打开时对焦
虽然到目前为止我们已经对该组件做了很好的工作,但它缺少一些关键的东西:功能。
让我们通过添加一个用户在上下文菜单打开时可以执行的操作列表来解决这个问题。问题在于:用户可以执行的操作取决于他们右键单击的是应用程序的哪个部分。
我们可以通过几种方式来解决这个问题,但它们都归结为根据用户所在的组件树的哪个部分来保存用户可以采取的操作列表。
听起来很熟悉?
让我们使用依赖注入来根据组件树的哪个部分提供不同的操作列表,如下所示:
这将包括多个步骤:
- 创建包含空侧边栏和文件页面的应用程序布局,以便稍后填写
- 在文件页面创建文件列表,在侧边栏创建目录列表
- 将上下文菜单添加到具有静态操作列表的项目列表中
- 更新上下文菜单以从依赖注入节点获取数据
- 使操作列表按预期发挥作用
系紧安全带——这将是一个长期的挑战。到最后,我们将拥有一个可以正常运行的应用程序外壳,其中包含一个依赖注入的真实示例。
步骤 1:创建初始应用程序布局
首先,我们需要一个包含基本侧边栏和主要内容的布局组件。
在 HTML 中可能看起来像这样:
<div style="display: flex; flex-wrap: nowrap; min-height: 100vh "> <div style=" width: 150px; background-color: lightgray; border-right: 1px solid gray; " > <div style="padding: 1rem"> <h1 style="font-size: 1.25rem">Directories</h1> </div> </div> <div style="width: 1px; flex-grow: 1"> <div style="padding: 1rem"> <h1>Files</h1> </div> </div></div>
在小范围内,这在 HTML 中是可读的,但随着我们的成长,这种标记(和逻辑)的复杂性将迅速增加。
让我们将其分为三个不同的部分:
LayoutSidebarFileList
像这样:
<Layout> <Sidebar /> <FileList /></Layout>
Sidebar注入到哪里lightgray
虽然我们之前对大多数代码示例使用了单个文件,但让我们将这些代码分解为单独的文件并利用
import这些export文件之间共享代码。
<!-- App.vue --><script setup>import Layout from "./Layout.vue";import Sidebar from "./Sidebar.vue";import FileList from "./FileList.vue";</script><template> <Layout> <template #sidebar> <Sidebar /> </template> <FileList /> </Layout></template>
<!-- Layout.vue --><script setup></script><template> <div style="display: flex; flex-wrap: nowrap; min-height: 100vh"> <div style=" width: 150px; background-color: lightgray; border-right: 1px solid gray; " > <slot name="sidebar" /> </div> <div style="width: 1px; flex-grow: 1"> <slot /> </div> </div></template>
<!-- Sidebar.vue --><script setup></script><template> <div style="padding: 1rem"> <h1 style="font-size: 1.25rem">Directories</h1> </div></template>
<!-- FileList.vue --><script setup></script><template> <div style="padding: 1rem"> <h1>Files</h1> </div></template>
步骤2:添加文件和目录列表
现在我们已经为目录和文件列表建立了脚手架,让我们添加一些项目来显示给用户!
我们应该有一个包含以下内容的列表:
- 数字ID
- 目录或文件的名称
然后,我们将使用共享组件显示目录和文件File。该File组件需要:
- 循环显示
for(或等效显示) - 该项目的输入
name - 显示每个项目名称的按钮
File让我们首先使用按钮和输入来构建我们的组件name:
<!-- File.vue --><script setup>const props = defineProps(["name"]);</script><template> <button style="display: block; width: 100%; margin-bottom: 1rem"> {{ props.name }} </button></template>
然后,我们可以将此组件放入我们的Sidebar和FileList组件中以显示目录和文件的静态列表:
<!-- FileList.vue --><script setup>import File from "./File.vue";const files = [ { name: "Testing.wav", id: 1, }, { name: "Secrets.txt", id: 2, }, { name: "Other.md", id: 3, },];</script><template> <div style="padding: 1rem"> <h1>Files</h1> <File v-for="file of files" :key="file.id" :name="file.name" :id="file.id" /> </div></template>
<!-- Sidebar.vue --><script setup>import File from "./File.vue";const directories = [ { name: "Movies", id: 1, }, { name: "Documents", id: 2, }, { name: "Etc", id: 3, },];</script><template> <div style="padding: 1rem"> <h1 style="font-size: 1.25rem">Directories</h1> <File v-for="directory of directories" :key="directory.id" :name="directory.name" :id="directory.id" /> </div></template>
步骤 3:添加带有静态操作的上下文菜单
接下来,我们将添加一个上下文菜单。首先,我们将从“组件参考”一章中获取上下文菜单,并根据我们的需求进行调整。也就是说,我们将上下文菜单在之前的版本上进行以下更改:
- 一次只允许打开一个上下文菜单
- 在上下文菜单中添加用户可以执行的操作列表
我们可以通过添加一些类似于以下内容的 JavaScript 来完成第一件事:
const closeIfContextMenu = () => { if (!isDialogOpen) return; closeDialog();};document.addEventListener("contextmenu", closeIfContextMenu);
这段代码将允许我们在打开新上下文菜单实例时关闭其他上下文菜单实例。
然后,对于操作列表,我们将首先将数组硬编码到ContextMenu组件中。
该数组应包括最终用户可见的标签和采取行动时应运行的函数。
最后,Vue。同样,我们将从ContextMenu之前的内容中添加"contextmenu"事件和操作数组:
<!-- ContextMenu.vue --><script setup>import { ref, onMounted, onUnmounted, watch } from "vue";const props = defineProps(["isOpen", "x", "y", "data"]);const emit = defineEmits(["close"]);const actions = [ { label: "Copy", fn: (data) => alert(`Copied ${data}`), }, { label: "Delete", fn: (data) => alert(`Deleted ${data}`), },];const contextMenuRef = ref(null);function closeIfOutside(e) { const contextMenuEl = contextMenuRef.value; if (!contextMenuEl) return; const isClickInside = contextMenuEl.contains(e.target); if (isClickInside) return; emit("close");}// Only allow one context menu to be opened at a timeconst closeIfContextMenu = () => { if (!props.isOpen) return; emit("close");};// This must live in a watch, as `onMounted` will run whether the `isOpen` boolean is set or notwatch( () => props.isOpen, (_, __, onCleanup) => { // Inside a timeout to make sure the initial context menu does not close the menu setTimeout(() => { document.addEventListener("contextmenu", closeIfContextMenu); }, 0); onCleanup(() => document.removeEventListener("contextmenu", closeIfContextMenu), ); },);onMounted(() => { document.addEventListener("click", closeIfOutside);});onUnmounted(() => { document.removeEventListener("click", closeIfOutside);});function focusMenu() { contextMenuRef.value.focus();}defineExpose({ focusMenu,});</script><template> <div v-if="props.isOpen" ref="contextMenuRef" tabIndex="0" :style="` position: fixed; top: ${props.y}px; left: ${props.x}px; background: white; border: 1px solid black; border-radius: 16px; padding: 1rem; `" > <button @click="emit('close')">X</button> <ul> <li v-for="action of actions"> <button @click=" action.fn(data); emit('close', false); " > {{ action.label }} </button> </li> </ul> </div></template>
在组件中使用这个新组件File:
<!-- File.vue --><script setup>import { ref } from "vue";import ContextMenu from "./ContextMenu.vue";const props = defineProps(["name", "id"]);const mouseBounds = ref({ x: 0, y: 0,});const isOpen = ref(false);const setIsOpen = (v) => (isOpen.value = v);const contextMenu = ref();function onContextMenu(e) { e.preventDefault(); isOpen.value = true; mouseBounds.value = { x: e.clientX, y: e.clientY, }; setTimeout(() => { contextMenu.value.focusMenu(); }, 0);}</script><template> <button @contextmenu="onContextMenu($event)" style="display: block; width: 100%; margin-bottom: 1rem" > {{ props.name }} </button> <ContextMenu :data="props.id" ref="contextMenu" :isOpen="isOpen" @close="setIsOpen(false)" :x="mouseBounds.x" :y="mouseBounds.y" /></template>
最后,我们需要确保传递file.id给<File :id="file.id"/>组件:
<!-- FileList.vue --><script setup>// ...</script><template> <div style="padding: 1rem"> <h1>Files</h1> <File v-for="file of files" :key="file.id" :name="file.name" :id="file.id" /> </div></template>
<!-- Sidebar.vue --><script setup>// ...</script><template> <div style="padding: 1rem"> <h1 style="font-size: 1.25rem">Directories</h1> <File v-for="directory of directories" :key="directory.id" :name="directory.name" :id="directory.id" /> </div></template>
步骤4:向上下文菜单添加依赖注入
现在我们已经ContextMenu建立了组件,让我们继续为组件添加依赖注入。
为此,我们需要:
- 创建所有先决条件依赖项提供程序文件
- 提供来自我们的
FileList和Sidebar组件的操作 - 将值注入到我们的
ContextMenu组件中
首先,我们在 Vue 中设置一个provider,它添加了一个要传递给 File 实现的操作数组:
<!-- Sidebar.vue --><script setup>// ...provide("ContextMenu", { actions: [ { label: "Copy directory name", fn: (data) => alert(`You copied ${data}`), }, ],});</script><template> <!-- ... --></template>
<!-- FileList.vue --><script setup>// ...provide("ContextMenu", { actions: [ { label: "Rename", fn: (data) => alert(`You renamed ${data}`), }, { label: "Delete", fn: (data) => alert(`You deleted ${data}`), }, ],});</script><template> <!-- ... --></template>
然后,我们可以在我们的ContextMenu组件中使用这个提供的值:
<!-- ContextMenu.vue --><script setup>// ...const context = inject("ContextMenu");const actions = computed(() => { if (!context) return []; return context.actions;});// ...</script><template> <!-- ... --></template>
步骤5:向上下文菜单添加功能
最后但同样重要的一点是,我们需要确保我们的上下文菜单能够真正做一些事情。
让我们将以下操作添加到我们的项目中:
| 类型 | 行动 |
|---|---|
| 目录 | 副本名称 |
| 文件 | 重命名 |
| 文件 | 删除 |
为此,我们仍然可以利用数组来存储目录和文件,但向actions数组中添加函数来更新文件/目录数组并触发重新渲染。
对于“复制名称”操作,我们将使用浏览器的navigator.clipboardAPI将文本复制到用户的剪贴板中:
navigator.clipboard.writeText("Text to copy to clipboard");
<!-- Sidebar.vue --><script setup>// ...const directories = [ { name: "Movies", id: 1, }, { name: "Documents", id: 2, }, { name: "Etc", id: 3, },];const getDirectoryById = (id) => { return directories.find((dir) => dir.id === id);};const onCopy = (id) => { const dir = getDirectoryById(id); // Some browsers still do not support this if (navigator?.clipboard?.writeText) { navigator.clipboard.writeText(dir.name); alert("Name is copied"); } else { alert("Unable to copy directory name due to browser incompatibility"); }};provide("ContextMenu", { actions: [ { label: "Copy directory name", fn: onCopy, }, ],});</script><template> <!-- ... --></template>
<!-- FileList.vue --><script setup>// ...const files = ref([ { name: "Testing.wav", id: 1, }, { name: "Secrets.txt", id: 2, }, { name: "Other.md", id: 3, },]);const getFileIndexById = (id) => { return files.value.findIndex((file) => file.id === id);};const onRename = (id) => { const fileIndex = getFileIndexById(id); const file = files.value[fileIndex]; const newName = prompt( `What do you want to rename the file ${file.name} to?`, ); if (!newName) return; files.value[fileIndex] = { ...file, name: newName, };};const onDelete = (id) => { const fileIndex = getFileIndexById(id); files.value.splice(fileIndex, 1);};provide("ContextMenu", { actions: [ { label: "Rename", fn: onRename, }, { label: "Delete", fn: onDelete, }, ],});</script><template> <!-- ... --></template>