动态

详情 返回 返回

Astro + NextUI 搭建個人博客(導航組件篇) - 动态 详情

Astro 簡介

由於我之前的個人博客是Vue3+Quasar+Koa+MySql搭建的,整體就是SPA的思路,作為練手倒是可以鍛鍊前後端各方面的能力。但考慮到後期的遷移和更新等,實在過於麻煩,個人博客其實使用SSR或SSG之類的框架就行了,比如Nextjs,Nuxtjs,Remix等等。於是我接觸到了Astro這個框架,它厲害的是不與任何前端框架進行強行綁定,比如Nextjs是與React強綁定的,Nuxtjs則是與Vue強綁定的。而Astro則可以使用任何流行的前端框架,甚至可以混用,並且同時支持SSR和SSG。

另一點是Astro對markdown的支持也相對不錯,作為博客放文章也是挺不錯的。

其次是Astro的官方文檔寫得很詳盡,中文文檔也比較友好,油管上的教程也挺多的。

-> Astro官網

配置

為了美觀和效率,我選擇了NextUI和React來作為快速構建博客的依賴。NextUI是基於tailwindCSS的,所以對於樣式構建更加方便和快速。

-> NextUI官網

接下來的步驟非常簡單,我們只需要安裝好依賴就行了。Astro有個配置文件Astro.config.mjs

import { defineConfig } from 'astro/config';
import react from "@astrojs/react";
import node from '@astrojs/node';
import tailwind from "@astrojs/tailwind";
import remarkToc from 'remark-toc';
import { remarkReadingTime } from './plugins/remark-reading-time.mjs';


// https://astro.build/config
export default defineConfig({
  output: 'server',
  prefetch: true,
  integrations: [react(), tailwind()],
  adapter: node({
    mode: "standalone"
  }),
  experimental: {
    contentCollectionCache: true,
  },
  server: {
      port: 3000,
      host: true,
      serverEntry: 'entry.mjs'
  },
  devToolbar: {
    enabled: false
  },
  markdown: {
    // 示例:在 Markdown 中使用 prism 進行語法高亮顯示
    syntaxHighlight: 'prism',
    remarkPlugins: [remarkToc, remarkReadingTime],
    gfm: true,
  }
});

tailwind同樣有個配置文件tailwind.config.mjs

/** @type {import('tailwindcss').Config} */

import { nextui } from "@nextui-org/react";
import typography from '@tailwindcss/typography';
import remark from 'remark';

export default {
    content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}"],
    darkMode: "class",
    theme: {
        extend: {},
        screens: {
            'xs': '370px',
            'sm': '640px',
            'md': '768px',
            'lg': '1024px',
            'xl': '1280px',
            '2xl': '1536px',
        },
    },
    plugins: [nextui({
        themes: {
            light: {
                colors: {
                    background: "#e6e9e9", // or DEFAULT
                    foreground: "#000000", // or 50 to 900 DEFAULT
                    primary: {
                        //... 50 to 900
                        foreground: "#7828C8",
                        DEFAULT: "#7828C8",
                    },
                },
                //... rest of the colors
            },
            dark: {
                colors: {
                    background: "#22272e", // or DEFAULT
                    foreground: "#ECEDEE", // or 50 to 900 DEFAULT
                    primary: {
                        //... 50 to 900
                        foreground: "#7828C8",
                        DEFAULT: "#7828C8",
                    },
                },
                // ... rest of the colors
            }
        }
    }),
    typography(({ theme }) => ({
        dark: {
          css: {
            '--tw-prose-body': theme('colors.pink[800]'),
            '--tw-prose-headings': theme('colors.pink[900]'),
            '--tw-prose-lead': theme('colors.pink[700]'),
            '--tw-prose-links': theme('colors.pink[900]'),
            '--tw-prose-bold': theme('colors.pink[900]'),
            '--tw-prose-counters': theme('colors.pink[600]'),
            '--tw-prose-bullets': theme('colors.pink[400]'),
            '--tw-prose-hr': theme('colors.pink[300]'),
            '--tw-prose-quotes': theme('colors.pink[900]'),
            '--tw-prose-quote-borders': theme('colors.pink[300]'),
            '--tw-prose-captions': theme('colors.pink[700]'),
            '--tw-prose-code': theme('colors.pink[900]'),
            '--tw-prose-pre-code': theme('colors.pink[100]'),
            '--tw-prose-pre-bg': theme('colors.pink[900]'),
            '--tw-prose-th-borders': theme('colors.pink[300]'),
            '--tw-prose-td-borders': theme('colors.pink[200]'),
            '--tw-prose-invert-body': theme('colors.pink[200]'),
            '--tw-prose-invert-headings': theme('colors.white'),
            '--tw-prose-invert-lead': theme('colors.pink[300]'),
            '--tw-prose-invert-links': theme('colors.white'),
            '--tw-prose-invert-bold': theme('colors.white'),
            '--tw-prose-invert-counters': theme('colors.pink[400]'),
            '--tw-prose-invert-bullets': theme('colors.pink[600]'),
            '--tw-prose-invert-hr': theme('colors.pink[700]'),
            '--tw-prose-invert-quotes': theme('colors.pink[100]'),
            '--tw-prose-invert-quote-borders': theme('colors.pink[700]'),
            '--tw-prose-invert-captions': theme('colors.pink[400]'),
            '--tw-prose-invert-code': theme('colors.white'),
            '--tw-prose-invert-pre-code': theme('colors.pink[300]'),
            '--tw-prose-invert-pre-bg': 'rgb(0 0 0 / 50%)',
            '--tw-prose-invert-th-borders': theme('colors.pink[600]'),
            '--tw-prose-invert-td-borders': theme('colors.pink[700]'),
          },
        },
      }))
    ],
}

以上的文件配置內容照着Astro和NextUI官方文檔都能完成。

組件

接下來我們寫一個導航組件,如下圖所示:

image.png

我們直接使用NextUI的Navbar組件,此時我們寫的是一個Header.tsx文件,也就是react組件。

import { NextUIProvider, Navbar, Link, Button, NavbarBrand, NavbarContent, NavbarItem } from "@nextui-org/react";
import { getRouter } from '../../config';


/**
 * Navbar component
 * @returns 
 */
export default function Header(props) {
    return (<NextUIProvider>
        <Navbar className="nav-bar" maxWidth="lg">
            {
                props.nav
            }
            {/* <NavbarMenuToggle
                className="sm:hidden"
            /> */}
            <NavbarBrand className="sm:hidden">
                {
                    props.logo
                }
            </NavbarBrand>

            <NavbarContent className="hidden sm:flex gap-6" justify="center">
                <NavbarBrand>
                    {
                        props.logo
                    }
                </NavbarBrand>

                {
                    Object.values(getRouter()).map((v, i) => <NavbarItem key={i}><Link color="foreground" href={`/${v.path}`}>
                        {v.name}
                    </Link></NavbarItem>)
                }
            </NavbarContent>

            <NavbarContent className="sm:flex gap-4" justify="end">
                <NavbarItem>
                    <Button>Dashboard</Button>
                </NavbarItem>
                <NavbarItem className="lg:flex">
                    {
                        props.theme
                    }
                </NavbarItem>
            </NavbarContent>
        </Navbar>

    </NextUIProvider>)
}

這個組件的渲染方式在Astro中默認是靜態渲染的,也就是説,組件內部的任何交互方法都是不生效的,比如onClick等事件。此時需要在Astro組件中引用該react組件的地方,給組件加上client:load/visible/only指令,告訴Astro這個React組件是否需要在客户端渲染。

由於測試效果發現,客户端渲染的組件在渲染速度上會變慢,所以我們選擇不使用客户端渲染,導航中只有切換主題的按鈕和手機端的導航按鈕需要交互事件。那麼我們就將這兩個按鈕的位置設置為props傳進來,這兩個組件我們就直接使用Astro組件為其添加鼠標交互事件,而不寫成react組件, 保證其渲染速度最快化。同樣的,由於Astro本身自帶的Image組件對圖片有優化效果,所以我們把logo的位置也通過props傳進來。

image.png

image.png

最後在我們的Astro組件中,引入Header.tsx組件,然後將有交互事件的組件,都通過props傳進去即可。

<Header>
        <Nav slot="nav" />
        <ThemeBtn slot="theme" />
        <Image src={logo} alt="logo" slot="logo" class="w-12" />
</Header>

而交互事件則使用比較原始的方式,通過事件監聽addEventListener來完成,這也是Astro官方推薦的方式。

---
import { getRouter } from "../../config";
---

<style>
    @keyframes menuAnimationStart {
        0% {
            height: 0px;
        }

        20% {
            height: 100px;
        }

        40% {
            height: 200px;
        }

        60% {
            height: 400px;
        }

        80% {
            height: 600px;
        }

        100% {
            height: calc(100vh - var(--navbar-height) - 1px);
        }
    }

    @keyframes menuAnimationStop {
        0% {
            height: calc(100vh - var(--navbar-height) - 1px);
        }

        20% {
            height: 600px;
        }

        40% {
            height: 400px;
        }

        60% {
            height: 200px;
        }

        80% {
            height: 100px;
        }

        100% {
            height: 0px;
        }
    }

    .menu-open {
        animation: menuAnimationStart 0.5s linear backwards;
    }

    .menu-close {
        animation: menuAnimationStop 0.5s linear backwards;
    }
</style>

<script>
    let isOpen = false;
    const clickEvent = () => {
        isOpen = false;
        const btn = document.querySelector("#sm-nav-btn");
        const menu = document.querySelector("#sm-nav-menu");
        btn.addEventListener("click", () => {
            isOpen = !isOpen;
            btn.setAttribute("data-open", String(isOpen));

            if (isOpen) {
                menu.classList.replace("hidden", "flex");
                menu.classList.remove("menu-close");
                menu.classList.add("menu-open");
                menu.style.height = "calc(100vh - var(--navbar-height) - 1px)";
            } else {
                menu.classList.replace("menu-open", "menu-close");
                menu.style.height = "0px";
                setTimeout(() => menu.classList.replace("flex", "hidden"), 400);
            }
        });
    };
    clickEvent();

    window.addEventListener("resize", () => {
        isOpen = false;
        const btn = document.querySelector("#sm-nav-btn");
        const menu = document.querySelector("#sm-nav-menu");
        btn.setAttribute("data-open", String(isOpen));
        menu.classList.replace("menu-open", "menu-close");
        menu.style.height = "0px";
        setTimeout(() => menu.classList.replace("flex", "hidden"), 400);
    });
    document.addEventListener("astro:after-swap", clickEvent);
</script>

<button
    id="sm-nav-btn"
    class="group flex items-center justify-center w-6 h-full rounded-small tap-highlight-transparent outline-none data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 sm:hidden"
    type="button"
    aria-pressed="false"
    ><span class="sr-only">open navigation menu</span><span
        class="w-full h-full pointer-events-none flex flex-col items-center justify-center text-inherit group-data-[pressed=true]:opacity-70 transition-opacity before:content-[''] before:block before:h-px before:w-6 before:bg-current before:transition-transform before:duration-150 before:-translate-y-1 before:rotate-0 group-data-[open=true]:before:translate-y-px group-data-[open=true]:before:rotate-45 after:content-[''] after:block after:h-px after:w-6 after:bg-current after:transition-transform after:duration-150 after:translate-y-1 after:rotate-0 group-data-[open=true]:after:translate-y-0 group-data-[open=true]:after:-rotate-45"
    ></span></button
>

<ul
    id="sm-nav-menu"
    class="sm:hidden hidden z-30 px-6 pt-2 fixed max-w-full top-[var(--navbar-height)] inset-x-0 bottom-0 w-screen flex-col gap-2 overflow-y-auto backdrop-blur-xl backdrop-saturate-150 bg-background/90"
    style="--navbar-height: 4rem;"
>
    {
        Object.values(getRouter()).map((v) => (
            <li
                class="text-large data-[active=true]:font-semibold"
                data-open="true"
            >
                <a
                    color="foreground"
                    class="relative inline-flex items-center tap-highlight-transparent outline-none data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 text-large text-foreground no-underline hover:opacity-80 active:opacity-disabled transition-opacity w-full"
                    tabindex="0"
                    role="link"
                    href={`/${v.path}`}
                >
                    {v.name}
                </a>
            </li>
        ))
    }
</ul>

需要注意的是,我使用了視圖過渡的效果,在Astro中,提供了基於瀏覽器原生效果的View Transition API。Astro在路由切換到新的頁面後,我們的事件效果會被移除掉,此時需要在Astro提供的鈎子函數astro:after-swap中,來添加交互事件。個人感覺這是相對麻煩的地方。

user avatar tianmiaogongzuoshi_5ca47d59bef41 头像 dingtongya 头像 grewer 头像 cyzf 头像 alibabawenyujishu 头像 haoqidewukong 头像 front_yue 头像 littlelyon 头像 inslog 头像 banana_god 头像 hard_heart_603dd717240e2 头像 xiaoxxuejishu 头像
点赞 148 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.