带 React Hooks 和 Styled Components 的汉堡菜单

Avatar of Maks Akymenko
Maks Akymenko 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

我们都知道什么是 汉堡菜单,对吧?当这种模式开始进入网页设计时,它既被嘲笑也被赞扬,因为它极简主义的设计可以让主菜单隐藏在屏幕外,尤其是在移动设备上,每个像素的空间都很重要。

CSS-Tricks 致力于提供双倍内容。

无论喜欢与否,汉堡菜单都已存在,并且可能会持续一段时间。问题是如何实现它们。当然,它们看起来简单直观,但实际上并非如此。例如,是否应该与标签配对?它们在屏幕的左侧还是右侧更有效?我们如何处理关闭这些菜单,是通过点击还是触摸?图标应该是一个 SVG、字体、Unicode 字符还是纯 CSS?如何处理 无肉选项

我想构建其中一个,但找不到简单的解决方案。大多数解决方案都基于库,例如 reactjs-popupreact-burger-menu。它们很棒,但更适合复杂的解决方案。那么,一个简单的三线菜单的核心用例是什么呢?它在点击时从屏幕侧面滑出一个面板,然后在再次点击时将面板滑回?

我决定自己构建一个简单的汉堡菜单,配有侧边栏。没有泡菜、洋葱或番茄酱。只有肉、面包和一些菜单项。

你准备好和我一起创建它了吗?

以下是我们将要制作的内容

查看 Maks Akymenko (@maximakymenko) 在 CodePen 上发布的 Pen
使用 React Hooks 和 Styled Components 制作的汉堡菜单

CodePen 上。

我们将在本教程中使用 React 进行构建,因为它似乎是一个很好的用例:我们获得了一个可重用的组件和一组可以扩展的 Hooks 来处理点击功能。

启动一个新的 React 项目

让我们使用 create-react-app 启动一个新项目,切换到该文件夹目录并添加 styled-components 来为 UI 设置样式

npx create-react-app your-project-name
cd your-project-name
yarn add styled-components

添加基本样式

在您最喜欢的代码编辑器中打开新创建的项目,并开始使用 styled-components 添加基本样式。在您的 src 目录中,创建一个名为 global.js 的文件。它将包含整个应用程序的样式。您可以编写自己的样式,或者复制我最终使用的样式

// global.js
import { createGlobalStyle } from 'styled-components';

export const GlobalStyles = createGlobalStyle`
  html, body {
    margin: 0;
    padding: 0;
  }
  *, *::after, *::before {
    box-sizing: border-box;
  }
  body {
    align-items: center;
    background: #0D0C1D;
    color: #EFFFFA;
    display: flex;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
    height: 100vh;
    justify-content: center;
    text-rendering: optimizeLegibility;
  }
  `

这只是全局样式的一部分,其余部分可以在 此处 找到。

CreateGlobalStyle 函数通常用于创建全局样式,这些样式会暴露给整个应用程序。我们将导入它,以便在需要时可以访问这些样式。

下一步是添加一个包含所有变量的主题文件。在 src 目录中创建一个 theme.js 文件并添加以下内容

// theme.js
export const theme = {
  primaryDark: '#0D0C1D',
  primaryLight: '#EFFFFA',
  primaryHover: '#343078',
  mobile: '576px',
}

添加布局、菜单和汉堡组件 🍔

转到您的 App.js 文件。我们将清空其中的所有内容,并为我们的应用程序创建主模板。以下是我所做的。您当然可以创建自己的模板。

// App.js
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { GlobalStyles } from './global';
import { theme } from './theme';

function App() {
  return (
    <ThemeProvider theme={theme}>
      <>
        <GlobalStyles />
        <div>
          <h1>Hello. This is burger menu tutorial</h1>
          <img src="https://image.flaticon.com/icons/svg/2016/2016012.svg" alt="burger icon" />
          <small>Icon made by Freepik from www.flaticon.com</small>
        </div>
      </>
    </ThemeProvider>
  );
}
export default App;

不要忘记添加包含 small 标签的行。这是我们感谢 flaticon.comhttp://flaticon.com) 作者提供的图标的方式。

到目前为止,我们已经完成了以下工作

让我稍微解释一下。我们导入了 ThemeProvider,它是一个包装组件,在幕后使用 Context API 使我们的 theme 变量可用于整个组件树。

我们还导入了 GlobalStyles 并将其作为组件传递给我们的应用程序,这意味着我们的应用程序现在可以访问所有全局样式。如您所见,我们的 GlobalStyles 组件位于 ThemeProvider 内部,这意味着我们已经可以对其进行一些小的更改。

转到 global.js 并更改 backgroundcolor 属性以使用我们定义的变量。这有助于我们实现主题,而不是使用难以更改的固定值。

// global.js
background: ${({ theme }) => theme.primaryDark};
color: ${({ theme }) => theme.primaryLight};

我们从 props 中解构 theme。因此,我们使用了一堆 **方括号** 而不是每次都编写 props.theme。我再说一遍:theme 可用是因为我们将全局样式用 ThemeProvider 包裹了起来。

创建汉堡和菜单组件

src 目录内创建一个 components 文件夹,并在其中添加两个文件夹:MenuBurger,以及一个 index.js 文件。

index.js 将用于一个目的:允许我们从一个文件中导入组件,这非常方便,尤其是在您有很多组件时。

现在让我们创建我们的组件。每个文件夹将包含三个文件。

所有这些文件有什么作用?您很快就会看到可扩展结构的好处。它在我的几个项目中运行良好,但这里有一个很好的建议 如何创建可扩展结构

转到 Burger 文件夹,为我们的布局创建 Burger.js。然后添加 Burger.styled.js,它将包含样式,以及 index.js,它将导出该文件。

// index.js
export { default } from './Burger';

随意以您想要的方式设置汉堡切换按钮的样式,或者只粘贴这些样式

// Burger.styled.js
import styled from 'styled-components';

export const StyledBurger = styled.button`
  position: absolute;
  top: 5%;
  left: 2rem;
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  width: 2rem;
  height: 2rem;
  background: transparent;
  border: none;
  cursor: pointer;
  padding: 0;
  z-index: 10;
  
  &:focus {
    outline: none;
  }
  
  div {
    width: 2rem;
    height: 0.25rem;
    background: ${({ theme }) => theme.primaryLight};
    border-radius: 10px;
    transition: all 0.3s linear;
    position: relative;
    transform-origin: 1px;
  }
`;

transform-origin 属性将在稍后用于为菜单在打开和关闭状态之间切换时设置动画。

添加样式后,转到 Burger.js 并添加布局

// Burger.js
import React from 'react';
import { StyledBurger } from './Burger.styled';

const Burger = () => {
  return (
    <StyledBurger>
      <div />
      <div />
      <div />
    </StyledBurger>
  )
}

export default Burger;

之后查看左上角。你看到了吗?

现在对 Menu 文件夹执行相同的操作

// Menu -> index.js
export { default } from './Menu';

// Menu.styled.js
import styled from 'styled-components';

export const StyledMenu = styled.nav`
  display: flex;
  flex-direction: column;
  justify-content: center;
  background: ${({ theme }) => theme.primaryLight};
  height: 100vh;
  text-align: left;
  padding: 2rem;
  position: absolute;
  top: 0;
  left: 0;
  transition: transform 0.3s ease-in-out;
  
  @media (max-width: ${({ theme }) => theme.mobile}) {
    width: 100%;
  }

  a {
    font-size: 2rem;
    text-transform: uppercase;
    padding: 2rem 0;
    font-weight: bold;
    letter-spacing: 0.5rem;
    color: ${({ theme }) => theme.primaryDark};
    text-decoration: none;
    transition: color 0.3s linear;
    
    @media (max-width: ${({ theme }) => theme.mobile}) {
      font-size: 1.5rem;
      text-align: center;
    }

    &:hover {
      color: ${({ theme }) => theme.primaryHover};
    }
  }
`;

接下来,让我们为点击汉堡菜单时显示的菜单项添加布局

// Menu.js
import React from 'react';
import { StyledMenu } from './Menu.styled';

const Menu = () => {
  return (
    <StyledMenu>
      <a href="/">
        <span role="img" aria-label="about us">&#x1f481;&#x1f3fb;&#x200d;&#x2642;&#xfe0f;</span>
        About us
      </a>
      <a href="/">
        <span role="img" aria-label="price">&#x1f4b8;</span>
        Pricing
        </a>
      <a href="/">
        <span role="img" aria-label="contact">&#x1f4e9;</span>
        Contact
        </a>
    </StyledMenu>
  )
}
export default Menu;

我们这里有一些不错的表情符号,最佳实践是通过将每个表情符号包装在一个 span 中并添加几个属性来使其可访问:role="img"aria-label="your label"。您可以在 此处 阅读更多相关信息。

现在将我们的新组件导入到 App.js 文件中

// App.js
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { GlobalStyles } from './global';
import { theme } from './theme';
import { Burger, Menu } from './components';

// ...

让我们看看我们得到了什么

看看这个漂亮的导航栏!但是我们这里有一个问题:它是打开的,而我们希望它最初是关闭的。我们只需要在 Menu.styled.js 中添加一行即可解决它

// Menu.styled.js
transform: translateX(-100%);

我们正在努力将这个汉堡制作完成!但首先…

添加打开和关闭功能

我们希望在点击汉堡图标时打开侧边栏,所以让我们开始吧。打开 App.js 并向其中添加一些状态。我们将使用 useState Hook 来实现。

// App.js
import React, { useState } from 'react';

导入它后,让我们在 App 组件内部使用它。

// App.js
const [open, setOpen] = useState(false);

我们将初始状态设置为 false,因为在渲染应用程序时我们的菜单应该隐藏。

我们的切换按钮和侧边栏菜单都需要了解状态,因此将其作为 prop 传递给每个组件。现在您的 App.js 应该如下所示

// App.js
import React, { useState } from 'react';
import { ThemeProvider } from 'styled-components';
import { GlobalStyles } from './global';
import { theme } from './theme';
import { Burger, Menu } from './components';

function App() {
  const [open, setOpen] = useState(false);
  return (
    <ThemeProvider theme={theme}>
      <>
        <GlobalStyles />
        <div>
          <h1>Hello. This is burger menu tutorial</h1>
          <img src="https://media.giphy.com/media/xTiTnwj1LUAw0RAfiU/giphy.gif" alt="animated burger" />
        </div>
        <div>
          <Burger open={open} setOpen={setOpen} />
          <Menu open={open} setOpen={setOpen} />
        </div>
      </>
    </ThemeProvider>
  );
}
export default App;

请注意,我们正在将组件包装在一个 div 中。这在稍后添加点击屏幕任何位置关闭菜单的功能时将很有帮助。

处理组件中的 props

我们的 BurgerMenu 知道状态,所以我们只需要在内部处理它并相应地添加样式即可。转到 Burger.js 并处理传递下来的 props

// Burger.js
import React from 'react';
import { bool, func } from 'prop-types';
import { StyledBurger } from './Burger.styled';
const Burger = ({ open, setOpen }) => {
  return (
    <StyledBurger open={open} onClick={() => setOpen(!open)}>
      <div />
      <div />
      <div />
    </StyledBurger>
  )
}
Burger.propTypes = {
  open: bool.isRequired,
  setOpen: func.isRequired,
};
export default Burger;

我们将 opensetOpen 属性解构,并将它们分别传递给我们的 StyledBurger 以添加每个属性的样式。此外,我们添加了 onClick 处理程序来调用我们的 setOpen 函数并切换 open 属性。在文件末尾,我们添加了类型检查,这被认为是将参数与预期数据对齐的最佳实践。

您可以通过转到您的react-dev-tools来检查它是否有效。在您的 Chrome DevTools 中转到“组件”选项卡,然后单击“汉堡包”选项卡。

现在,当您单击“汉堡包”组件时(不要与选项卡混淆),您应该会看到 open 复选框正在更改其状态。

转到 Menu.js 并执行几乎相同的操作,尽管在这里我们只传递 open 属性。

// Menu.js
import React from 'react';
import { bool } from 'prop-types';
import { StyledMenu } from './Menu.styled';
const Menu = ({ open }) => {
  return (
    <StyledMenu open={open}>
      <a href="/">
        <span role="img" aria-label="about us">&#x1f481;&#x1f3fb;&#x200d;&#x2642;&#xfe0f;</span>
        About us
      </a>
      <a href="/">
        <span role="img" aria-label="price">&#x1f4b8;</span>
        Pricing
        </a>
      <a href="/">
        <span role="img" aria-label="contact">&#x1f4e9;</span>
        Contact
        </a>
    </StyledMenu>
  )
}
Menu.propTypes = {
  open: bool.isRequired,
}
export default Menu;

下一步是将 open 属性传递给我们的样式化组件,以便我们可以应用过渡。打开 Menu.styled.js 并将以下内容添加到我们的 transform 属性中。

transform: ${({ open }) => open ? 'translateX(0)' : 'translateX(-100%)'};

这将检查我们的样式化组件 open 属性是否为 true,如果是,则添加 translateX(0) 以将我们的导航移回屏幕上。您已经可以在本地测试它了!

等等!

您在检查时有没有注意到什么问题?我们的“汉堡包”与“菜单”的背景颜色相同,这使得它们融合在一起。让我们更改它,并稍微动画化图标以使其更有趣。我们已经将 open 属性传递给了它,因此我们可以使用它来应用更改。

打开 Burger.styled.js 并编写以下内容。

// Burger.styled.js
import styled from 'styled-components';
export const StyledBurger = styled.button`
  position: absolute;
  top: 5%;
  left: 2rem;
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  width: 2rem;
  height: 2rem;
  background: transparent;
  border: none;
  cursor: pointer;
  padding: 0;
  z-index: 10;

  &:focus {
    outline: none;
  }

  div {
    width: 2rem;
    height: 0.25rem;
    background: ${({ theme, open }) => open ? theme.primaryDark : theme.primaryLight};
    border-radius: 10px;
    transition: all 0.3s linear;
    position: relative;
    transform-origin: 1px;

    :first-child {
      transform: ${({ open }) => open ? 'rotate(45deg)' : 'rotate(0)'};
    }

    :nth-child(2) {
      opacity: ${({ open }) => open ? '0' : '1'};
      transform: ${({ open }) => open ? 'translateX(20px)' : 'translateX(0)'};
    }

    :nth-child(3) {
      transform: ${({ open }) => open ? 'rotate(-45deg)' : 'rotate(0)'};
    }
  }
`;

这是一大块 CSS 代码,但它实现了动画效果。我们检查 open 属性是否为 true 并相应地更改样式。我们旋转、平移,然后隐藏菜单图标的线条,同时更改颜色。很漂亮,不是吗?

好了,朋友们!到目前为止,您应该知道如何创建一个简单的汉堡包图标和菜单,其中包含响应性和流畅的动画。恭喜!

但还有一件事我们应该考虑……

通过单击菜单外部关闭菜单

这部分看起来像是一个小奖励,但它是一个重大的 UX 提升,因为它允许用户通过单击页面上的任何其他位置来关闭菜单。这有助于用户避免必须重新定位菜单图标并精确点击它。

我们将使用更多 React hook 来实现这一点!在 src 目录中创建一个名为 hooks.js 的文件并打开它。对于这个,我们将转向在 React 18 中引入的 useEffect hook。

// hooks.js
import { useEffect } from 'react';

在编写代码之前,让我们考虑一下此 hook 背后的逻辑。当我们在页面上的某个位置单击时,我们需要检查单击的元素是否是我们的当前元素(在我们的例子中,它是 Menu 组件),或者单击的元素是否包含当前元素(例如,我们的 div 包含我们的菜单和汉堡包图标)。如果是,我们什么也不做,否则,我们调用一个函数,我们将其命名为 handler

我们将使用 ref 来检查单击的元素,并且每次有人在页面上单击时都会这样做。

// hooks.js
import { useEffect } from 'react';

export const useOnClickOutside = (ref, handler) => {
  useEffect(() => {
    const listener = event => {
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }
      handler(event);
    };
    document.addEventListener('mousedown', listener);
    return () => {
      document.removeEventListener('mousedown', listener);
    };
  },
  [ref, handler],
  );
};

不要忘记从 useEffect 返回函数。这就是所谓的“清理”,基本上,它代表在组件卸载时删除事件监听器。它是 componentWillUnmount 生命周期方法的替代。

现在让我们连接 hook

我们的 hook 已准备就绪,因此是时候将其添加到应用程序中了。转到 App.js 文件,并导入两个 hook:新创建的 useOnClickOutside 以及 useRef。我们将需要后者来获取对元素的引用。

// App.js
import React, { useState, useRef } from 'react';
import { useOnClickOutside } from './hooks';

为了在当前元素中访问这些内容,我们需要访问 DOM 节点。这就是我们使用 useRef 的地方,此外,名称 node 完美地反映了此变量的用途。

从那里,我们将 node 作为第一个参数传递。我们将传递关闭菜单的函数作为第二个参数。

// App.js
const node = useRef(); 
useOnClickOutside(node, () => setOpen(false));

最后,我们需要将我们的 ref 传递给 DOM 元素。在我们的例子中,它将是包含 BurgerMenu 组件的 div

// App.js
<div ref={node}>
  <Burger open={open} setOpen={setOpen} />
  <Menu open={open} setOpen={setOpen} />
</div>

您的 App.js 应该类似于此。

// App.js
import React, { useState, useRef } from 'react';
import { ThemeProvider } from 'styled-components';
import { useOnClickOutside } from './hooks';
import { GlobalStyles } from './global';
import { theme } from './theme';
import { Burger, Menu } from './components';
function App() {
  const [open, setOpen] = useState(false);
  const node = useRef();
  useOnClickOutside(node, () => setOpen(false));
  return (
    <ThemeProvider theme={theme}>
      <>
        <GlobalStyles />
        <div>
          <h1>Hello. This is burger menu tutorial</h1>
          <img src="https://media.giphy.com/media/xTiTnwj1LUAw0RAfiU/giphy.gif" alt="animated burger" />
        </div>
        <div ref={node}>
          <Burger open={open} setOpen={setOpen} />
          <Menu open={open} setOpen={setOpen} />
        </div>
      </>
    </ThemeProvider>
  );
}
export default App;

看看!它按预期工作,并且功能齐全且具有响应性。

恭喜大家!你们做得很好!祝您编码愉快!

在 GitHub 上查看