使用 Nuxt 和 Supabase 构建多用户博客应用

Avatar of Nader Dabit
Nader Dabit

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

Nuxt 是一个 JavaScript 框架,它扩展了 Vue.js 的现有功能,提供诸如服务器端渲染、静态页面生成、基于文件的路由和自动代码分割等功能。

我一直很喜欢使用 NuxtNext 等框架,因为它们不仅提供了更多功能,而且比单独使用底层库具有更好的性能和开发者体验,而且无需学习很多新概念。因此,许多开发者在创建新项目时开始默认使用这些框架,而不是它们最初为其成功铺平道路的单页面应用 (SPA) 祖先。

本着这些抽象的精神,我非常喜欢无服务器/托管服务,它们承担了构建后端功能的大部分工作,例如身份验证、文件存储、数据、计算和 API 层。像 SupabaseFirebaseNetlifyAWS AmplifyHasura 这样的服务和工具使传统的前端开发者能够扩展其个人能力和技能,添加这些各种重要的后端功能,而无需成为后端开发者本身。

在本教程中,我们将使用 Nuxt 和 Supabase 从头开始构建一个多用户应用,同时引入 Tailwind CSS 进行样式设置。

为什么我越来越喜欢 Supabase

Supabase 是 Firebase 的一个开源替代方案,它允许您在几分钟内创建实时后端。在撰写本文时,Supabase 支持以下功能:文件存储、实时 API + Postgres 数据库、身份验证,以及即将推出的 无服务器函数

Postgres

我喜欢 Supabase 的原因之一是它易于设置。此外,它提供 Postgres 作为其数据层。

我已经构建了 10 年的应用程序。我在 NoSQL 后端即服务 (BaaS) 产品中遇到的最大限制之一是,开发者很难扩展其应用并取得成功。使用 NoSQL,在您开始构建应用后,对数据建模、进行迁移和修改数据访问模式要困难得多。在 NoSQL 世界中,启用诸如关系之类的功能也更加难以理解。

Supabase 利用 Postgres 实现了开箱即用的极其丰富的、高性能的查询功能,而无需编写任何额外的后端代码。实时也是默认内置的。

身份验证

在特定表上设置授权规则非常容易,能够轻松实现授权和细粒度的访问控制。

创建项目时,Supabase 会自动为您提供一个 Postgres SQL 数据库、用户身份验证和一个 API 端点。从那里您可以轻松实现其他功能,例如实时订阅和文件存储。

多个身份验证提供程序

我喜欢 Supabase 的另一件事是它提供了多种身份验证提供程序,这些提供程序开箱即用。Supabase 支持以下所有类型的身份验证机制

  • 用户名和密码
  • 神奇邮件链接
  • 谷歌
  • 脸书
  • 苹果
  • Discord
  • GitHub
  • 推特
  • Azure
  • GitLab
  • Bitbucket

应用程序组件

大多数应用程序,虽然在实现细节方面具有不同的特性,但通常会利用一组类似的功能。这些通常包括

  • 用户身份验证
  • 客户端身份管理
  • 路由
  • 文件存储
  • 数据库
  • API 层
  • API 授权

了解如何构建一个实现所有这些功能的全栈应用程序,为开发者奠定了基础,让他们可以继续构建许多其他依赖于相同或类似功能集的不同类型的应用程序。我们在本教程中构建的应用程序实现了其中的大部分功能。

未经身份验证的用户可以在列表中查看其他人的帖子,然后通过点击并导航到该单个帖子来查看帖子详细信息。用户可以使用其电子邮件地址注册,并收到一个神奇链接以登录。登录后,他们能够查看创建和编辑自己帖子的链接。我们还将提供一个个人资料视图,供用户查看其用户个人资料和退出登录。

现在我们已经回顾了应用程序,让我们开始构建吧!

启动我们的 Supabase 应用程序

我们需要做的第一件事是创建 Supabase 应用程序。前往 Supabase.io 并点击**启动您的项目**。进行身份验证,并在帐户中提供的组织下创建一个新项目。

为项目命名并设置**密码**,然后点击**创建新项目**。您的项目大约需要两分钟才能启动。

创建表

项目准备就绪后,我们将为我们的应用程序创建表,以及我们需要的全部权限。为此,请点击左侧菜单中的**SQL** 链接。

点击**打开查询**下的**查询 1**,将以下 SQL 查询粘贴到提供的文本区域中,然后点击**R****un**

CREATE TABLE posts (
  id bigint generated by default as identity primary key,
  user_id uuid references auth.users not null,
  user_email text,
  title text,
  content text,
  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);

alter table posts enable row level security;

create policy "Individuals can create posts." on posts for
  insert with check (auth.uid() = user_id);

create policy "Individuals can update their own posts." on posts for
  update using (auth.uid() = user_id);

create policy "Individuals can delete their own posts." on posts for
  delete using (auth.uid() = user_id);

create policy "Posts are public." on posts for
  select using (true);

这将为我们应用程序的数据库创建`posts` 表。它还对数据库启用了某些行级权限

  • 任何用户都可以查询帖子列表或单个帖子。
  • 只有已登录的用户才能创建帖子。授权规则规定,他们的用户 ID 必须与传递到参数中的用户 ID 相匹配。
  • 只有帖子的所有者才能更新或删除它。

现在,如果我们点击**表编辑器**链接,我们应该能够看到我们的新表已使用正确的架构创建。

这就是我们对 Supabase 项目所需要的全部!我们可以继续使用 Nuxt 构建前端。

项目设置

让我们开始构建前端。在空目录中打开终端,并创建 Nuxt 应用程序

yarn create nuxt-app nuxt-supabase

在这里,我们会收到以下问题的提示

? Project name: nuxt-supabase
? Programming language: JavaScript
? Package manager: (your preference)
? UI framework: Tailwind CSS
? Nuxt.js modules: n/a
? Linting tools: n/a
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Server (Node.js hosting)
? Development tools: n/a
? What is your GitHub username? (your username)
? Version control system: Git

项目创建完成后,切换到新目录

cd nuxt-supabase

配置和依赖项

项目初始化完成后,我们需要安装一些依赖项,包括 Supabase 和 Tailwind CSS。我们还需要配置 Nuxt 项目来识别和使用这些工具。

Tailwind CSS

让我们从 Tailwind 开始。使用 npm 或 Yarn 安装 Tailwind 依赖项

npm install -D tailwindcss@latest postcss@latest autoprefixer@latest @tailwindcss/typography

接下来,运行以下命令创建一个 tailwind.config.js 文件

npx tailwind init

接下来,在项目目录中添加一个名为 assets/css 的新文件夹,并在其中添加一个名为 tailwind.css 的文件。以下是我们可以在其中添加的代码,用于导入我们需要的 Tailwind 内容

/* assets/css/tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

接下来,将 @nuxtjs/tailwindcss 模块添加到 nuxt.config.js 文件的 buildModules 部分(这可能已经由 Tailwind CLI 更新了)

buildModules: [
  '@nuxtjs/tailwindcss'
],

Tailwind 现在已设置好,我们可以直接在 HTML 中使用这些实用程序类!🎉

Markdown 编辑器和解析器

接下来,让我们安装和配置一个 Markdown 编辑器和解析器,它允许用户使用格式化和富文本编辑功能编写博客文章。我们正在使用 marked 以及 Vue SimpleMDE 库来实现这一点。

npm install vue-simplemde marked

接下来,我们需要定义一个新的 Vue 组件,以便在 HTML 中使用新的 Markdown 编辑器。因此,创建一个新的 plugins 文件夹,并在其中添加一个名为 simplemde.js 的新文件。以下是我们在其中需要添加的代码,用于导入我们需要的部分

/* plugins/simplemde.js */
import Vue from 'vue'
import VueSimplemde from 'vue-simplemde'
import 'simplemde/dist/simplemde.min.css'
Vue.component('vue-simplemde', VueSimplemde)

接下来,打开 nuxt.config.js 并更新 css 全局变量,以便它们包含 simplemde CSS 以及插件数组

css: [
  'simplemde/dist/simplemde.min.css',
],
plugins: [
  { src: '~plugins/simplemde.js', mode: 'client' },
],

现在,我们可以在任何需要使用该组件的地方直接在 HTML 中使用 vue-simplemde

配置 Supabase

我们需要配置的最后一件事是 Supabase 客户端。这是我们用于与 Supabase 后端进行交互的 API,用于身份验证和数据访问。

首先,安装 Supabase JavaScript 库

npm install @supabase/supabase-js

接下来,让我们创建一个新的插件,它将 $supabase 变量注入到我们应用程序的范围中,这样我们就可以在任何需要的地方访问它。我们需要获取项目的 API 端点和公共 API 密钥,这些信息可以从 Supabase 仪表盘的“设置”选项卡中获取。

单击 Supabase 菜单中的“设置”图标,然后选择“API”以查找信息。

现在,让我们在 plugins 文件夹中创建一个名为 client.js 的新文件,并在其中添加以下代码

/* plugins/client.js */
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
  "https://yoururl.supabase.co",
  "your-api-key"
)
export default (_, inject) => {
  inject('supabase', supabase)
}

现在,我们可以使用新的插件更新 nuxt.config.js 中的 plugins 数组

plugins: [
  { src: '~plugins/client.js' },
  { src: '~plugins/simplemde.js', mode: 'client' },
],

这是我们设置项目所需的最后一步。现在我们可以开始编写代码了!

创建布局

我们的应用程序需要一个好的布局组件来容纳导航,以及一些将应用于所有其他页面的基本样式。

为了使用布局,Nuxt 在一个 layouts 目录中寻找一个 default 布局,该布局应用于所有页面。如果需要自定义一些特定内容,我们可以在页面级别覆盖布局。在本教程中,为了简单起见,我们将坚持使用所有内容的默认布局。

我们需要那个 layouts 文件夹,因此将其添加到项目目录中,并在其中添加一个 default.vue 文件,其中包含以下默认布局的标记

<!-- layouts/default.vue -->
<template>
  <div>
    <nav class="p-6 border-b border-gray-300">
      <NuxtLink to="/" class="mr-6">
        Home
      </NuxtLink>
      <NuxtLink to="/profile" class="mr-6">
        Profile
      </NuxtLink>
      <NuxtLink to="/create-post" class="mr-6" v-if="authenticated">
        Create post
      </NuxtLink>
      <NuxtLink to="/my-posts" class="mr-6" v-if="authenticated">
        My Posts
      </NuxtLink>
    </nav>
    <div class="py-8 px-16">
      <Nuxt />
    </div>
  </div>
</template>
<script>
export default {
  data: () => ({
    authenticated: false,
    authListener: null
  }),
  async mounted() {
    /* When the app loads, check to see if the user is signed in */
    /* also create a listener for when someone signs in or out */
    const { data: authListener } = this.$supabase.auth.onAuthStateChange(
      () => this.checkUser()
    )
    this.authListener = authListener
    this.checkUser()
  },
  methods: {
    async checkUser() {
      const user = await this.$supabase.auth.user()
      if (user) {
        this.authenticated = true 
      } else {
        this.authenticated = false
      }
    }
  },
  beforeUnmount() {
    this.authListener?.unsubscribe()
  }
}
</script>

该布局默认显示两个链接,另外两个链接仅在用户登录时显示。

为了随时获取登录用户(或查看他们是否已通过身份验证),我们正在使用 supabase.auth.user() 方法。如果用户已登录,则返回其个人资料。如果他们没有登录,则返回值为 null

主页

接下来,让我们更新主页。当用户打开应用程序时,我们想显示一个帖子列表,并允许他们单击并导航到阅读帖子。如果没有任何帖子,我们将向他们显示一条消息。

在这个组件中,我们第一次调用 Supabase 后端来获取数据 - 在这种情况下,我们调用一个包含所有帖子的数组。请查看 Supabase API 如何与您的数据交互,对我来说,这非常直观

/* example of how to fetch data from Supabase */   
const { data: posts, error } = await this.$supabase
  .from('posts')
  .select('*')

Supabase 提供 过滤器修饰符,可以简化各种数据访问模式和数据选择集的实现。例如,如果我们想更新上一个查询,只查询具有特定用户 ID 的用户,我们可以这样做

const { data: posts, error } = await this.$supabase
  .from('posts')
  .select('*')
  .filter('user_id', 'eq', 'some-user-id')

使用以下标记更新主页的模板文件 pages/index.vue,并查询以显示帖子循环

<!-- pages/index.vue -->
<template>
  <main>
    <div v-for="post in posts" :key="post.id">
      <NuxtLink key={post.id} :to="`/posts/${post.id}`">
        <div class="cursor-pointer border-b border-gray-300 mt-8 pb-4">
          <h2 class="text-xl font-semibold">{{ post.title }}</h2>
          <p class="text-gray-500 mt-2">Author: {{ post.user_email }}</p>
        </div>
      </NuxtLink>
    </div>
    <h1 v-if="loaded && !posts.length" class="text-2xl">No posts...</h1>
  </main>
</template>
<script>
export default {
  async created() {
    const { data: posts, error } = await this.$supabase
      .from('posts')
      .select('*')
    this.posts = posts
    this.loaded = true
  },
  data() {
    return {
      loaded: false,
      posts: []
    }
  }
}
</script>

用户个人资料

现在,让我们使用 pages 中的一个名为 profile.vue 的新文件创建个人资料页面,其中包含以下代码

<!-- pages/profile.vue -->
<template>
  <main class="m-auto py-20" style="width: 700px">
    <!-- if the user is not signed in, show the sign in form -->
    <div v-if="!profile && !submitted" class="flex flex-col">
      <h2 class="text-2xl">Sign up / sign in</h2>
      <input v-model="email" placeholder="Email" class="border py-2 px-4 rounded mt-4" />
      <button
        @click="signIn"
        class="mt-4 py-4 px-20 w-full bg-blue-500 text-white font-bold"
      >Submit</button>
    </div>
    <!-- if the user is signed in, show them their profile -->
    <div v-if="profile">
      <h2 class="text-xl">Hello, {{ profile.email }}</h2>
      <p class="text-gray-400 my-3">User ID: {{ profile.id }}</p>
      <button
        @click="signOut"
        class="mt-4 py-4 px-20 w-full bg-blue-500 text-white font-bold"
      >Sign Out</button>
    </div>
    <div v-if="submitted">
      <h1 class="text-xl text-center">Please check your email to sign in</h1>
    </div>
  </main>
</template>
<script>
export default {
  data: () => ({
    profile: null,
    submitted: false,
    email: ''
  }),
  methods: {
    async signOut() {
      /* signOut deletes the user's session */
      await this.$supabase.auth.signOut()
      this.profile = null
    },
    async signIn() {
      /* signIn sends the user a magic link */
      const { email } = this
      if (!email) return
      const { error, data } = await this.$supabase.auth.signIn({
        email
      })
      this.submitted = true
    },
  },
  async mounted() {
    /* when the component loads, fetch the user's profile */
    const profile = await this.$supabase.auth.user()
    this.profile = profile
  }
}
</script>

在模板中,我们有几个不同的视图状态

  1. 如果用户未登录,则向他们显示登录表单。
  2. 如果用户已登录,则向他们显示其个人资料信息和一个退出按钮。
  3. 如果用户已提交登录表单,则向他们显示一条消息,让他们查看邮件。

这个应用程序利用了神奇链接身份验证,因为它非常简单。没有单独的注册和登录流程。用户只需提交他们的电子邮件地址,他们就会收到一个登录链接。当他们点击链接时,Supabase 会在他们的浏览器中设置一个会话,然后他们会被重定向到应用程序。

Once the user is able to sign in, they can create a new post!

创建帖子

接下来,让我们创建包含表单的页面,该表单允许用户创建和保存新帖子。这意味着在 pages 目录中创建一个名为 create-post.vue 的新文件,其中包含一些帖子编辑器的代码

<!-- pages/create-post.vue -->
<template>
  <main>
    <div id="editor">
      <h1 class="text-3xl font-semibold tracking-wide mt-6">Create new post</h1>
      <input
        name="title"
        placeholder="Title"
        v-model="post.title"
        class="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      />
      <client-only>
        <vue-simplemde v-model="post.content"></vue-simplemde>
      </client-only>
      <button
        type="button"
        class="mb-4 w-full bg-blue-500 text-white font-semibold px-8 py-4"
        @click="createPost"
      >Create Post</button>
    </div>
  </main>
</template>
<script>
export default {
  data() {
    return {
      post: {}
    }
  },
  methods: {
    async createPost() {
      const {title, content} = this.post
      if (!title || !content) return
      const user = this.$supabase.auth.user()
      const { data } = await this.$supabase
        .from('posts')
        .insert([
            { title, content, user_id: user.id, user_email: user.email }
        ])
        .single()
      this.$router.push(`/posts/${data.id}`)
    }
  }
}
</script>

此代码使用的是我们之前作为插件注册的 vue-simplemde 组件!它被包装在一个 client-only 组件中,该组件仅在客户端渲染该组件 - vue-simplemde 是一个仅限客户端的插件,因此它无需出现在服务器上。

createPost 函数在 Supabase 数据库中创建一个新帖子,然后将我们重定向到一个我们还没有创建的页面,用于查看单个帖子。现在让我们创建它!

用于查看单个帖子的动态路由

为了在 Nuxt 中创建动态路由,我们需要在文件名(或目录名)之前的 .vue 前添加一个下划线。

如果用户导航到一个页面,例如 /posts/123。我们想使用帖子 ID 123 来获取帖子的数据。在应用程序中,我们然后可以通过引用 route.params 来访问页面中的路由参数。

因此,让我们添加另一个新文件夹 pages/posts,并在其中添加一个名为 _id.vue 的新文件

<!-- pages/posts/_id.vue -->
<template>
  <main>
    <div>
      <h1 class="text-5xl mt-4 font-semibold tracking-wide">{{ post.title }}</h1>
      <p class="text-sm font-light my-4">by {{ post.user_email }}</p>
      <div class="mt-8 prose" >
        <div v-html="compiledMarkdown"></div>
      </div>
    </div>
  </main>
</template>
<script>
import marked from 'marked'
export default {
  computed: {
    compiledMarkdown: function () {
      return marked(this.post.content, { sanitize: true })
    }
  },
  async asyncData({ route, $supabase }) {
    /* use the ID from the route parameter to fetch the post */
    const { data: post } = await $supabase
      .from('posts')
      .select()
      .filter('id', 'eq', route.params.id)
      .single()
    return {
      post
    }
  }
}
</script>

当页面加载时,路由参数用于获取帖子元数据。

管理帖子

我们想要实现的最后一个功能是允许用户编辑和删除他们自己的帖子,但为了做到这一点,我们应该为他们提供一个页面,该页面显示他们自己的帖子,而不是所有人的帖子。

没错,我们需要另一个新文件,这次名为 my-posts.vue,位于 pages 目录中。它将只获取当前已验证用户的帖子

<!-- pages/my-posts.vue -->
<template>
  <main>
    <div v-for="post in posts" :key="post.id">
      <div class="cursor-pointer border-b border-gray-300 mt-8 pb-4">
        <h2 class="text-xl font-semibold">{{ post.title }}</h2>
        <p class="text-gray-500 mt-2">Author: {{ post.user_email }}</p>
        <NuxtLink :to="`/edit-post?id=${post.id}`" class="text-sm mr-4 text-blue-500">Edit Post</NuxtLink>
        <NuxtLink :to="`/posts/${post.id}`" class="text-sm mr-4 text-blue-500">View Post</NuxtLink>
        <button
          class="text-sm mr-4 text-red-500"
          @click="deletePost(post.id)"
        >Delete Post</button>
      </div>
    </div>
    <h1 v-if="loaded && !posts.length" class="text-2xl">No posts...</h1>
  </main>
</template>
<script>
export default {
  async created() {
    this.fetchPosts()
  },
  data() {
    return {
      posts: [],
      loaded: false
    }
  },
  methods: {
    async fetchPosts() {
      const user = this.$supabase.auth.user()
      if (!user) return
      /* fetch only the posts for the signed in user */
      const { data: posts, error } = await this.$supabase
        .from('posts')
        .select('*')
        .filter('user_id', 'eq', user.id)
      this.posts = posts
      this.loaded = true
    },
    async deletePost(id) {
      await this.$supabase
        .from('posts')
        .delete()
        .match({ id })
      this.fetchPosts()
    }
  }
}
</script>

此页面中用于获取帖子的查询使用了一个过滤器,传递了已登录用户的用户 ID。还有一个按钮用于删除帖子,还有一个按钮用于编辑帖子。如果删除了帖子,我们将重新获取帖子以更新 UI。如果用户想要编辑帖子,我们将把他们重定向到我们接下来要创建的 edit-post.vue 页面。

编辑帖子

我们要创建的最后一个页面允许用户编辑帖子。此页面与create-post.vue页面非常相似,主要区别在于我们使用从路由参数中检索到的id获取帖子。因此,创建该文件并将其放入pages文件夹,使用以下代码

<!-- pages/edit-post.vue -->
<template>
  <main>
    <div id="editor">
      <h1 class="text-3xl font-semibold tracking-wide mt-6">Create new post</h1>
      <input
        name="title"
        placeholder="Title"
        v-model="post.title"
        class="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      />
      <client-only>
        <vue-simplemde v-model="post.content"></vue-simplemde>
      </client-only>
      <button
        type="button"
        class="mb-4 w-full bg-blue-500 text-white font-semibold px-8 py-4"
        @click="editPost"
      >Edit Post</button>
    </div>
  </main>
</template>
<script>
export default {
  async created() {
    /* when the page loads, fetch the post using the route id parameter */
    const id = this.$route.query.id
    const { data: post } = await this.$supabase
      .from('posts')
      .select()
      .filter('id', 'eq', id)
      .single()
    if (!post) this.$router.push('/')
    this.post = post
  },
  data() {
    return {
      post: {}
    }
  },
  methods: {
    async editPost() {
      /* when the user edits a post, redirect them back to their posts */
      const { title, content } = this.post
      if (!title || !content) return
      await this.$supabase
        .from('posts')
        .update([
            { title, content }
        ])
        .match({ id: this.post.id })
      this.$router.push('/my-posts')
    }
  }
}
</script>

测试它

这就是所有代码,我们应该能够测试它!我们可以使用以下命令在本地进行测试

npm run dev

当应用程序加载时,使用配置文件页面中启用的魔术链接注册一个新帐户。注册后,通过添加、编辑和删除帖子来测试所有功能。

总结

还不错吧?这就是我在本教程开头提到的那种轻松和简单。我们使用 Supabase 启动了一个新的应用程序,并使用一些依赖项、少量配置和少量模板,我们创建了一个功能齐全的应用程序,可以让用户创建和管理博客帖子——完整的后端支持身份验证、身份管理和路由!

我们拥有的是基本功能,但你可能已经看到了这里可以做更多事情的天花板。我希望你能做到!有了所有正确的成分,你可以利用我们所做的事情,并根据自己的增强功能和样式进行扩展。