使用Nuxt和Supabase做一个多用户的博客应用

1,395 阅读11分钟

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
  • 推特
  • 蔚蓝
  • 淘宝网
  • 比特巴克

应用程序的成分

大多数应用程序,虽然在实施细节上有不同的特点,但往往利用一组类似的功能捆绑在一起。这些通常是:。

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

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

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

现在,我们已经审查了该应用程序,让我们开始构建吧!

开始我们的Supabase应用程序

我们需要做的第一件事是创建Supabase应用程序。前往Supabase.io并点击开始你的项目。认证并在你的账户中提供给你的组织下创建一个新项目。

给该项目一个名称密码,然后点击创建新项目。你的项目将需要大约两分钟的时间来启动。

创建表格

一旦项目准备好了,我们就为我们的应用程序创建表,以及我们需要的所有权限。要做到这一点,点击左侧菜单中的SQL链接。

点击 "打开查询"下的 "查询-1",将以下SQL查询粘贴到提供的文本区,然后点击 "运行"。

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项目来识别和使用这些工具。

尾风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编辑器和分析器,它允许用户用格式化和富文本编辑功能来写博客文章。我们使用markedVue的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 的globals,使其包括simplemde 的CSS以及plugins数组。

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>

用户简介

现在让我们用一个新的profile.vue 文件来创建个人资料页面,在pages ,代码如下。

<!-- 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 。我们要使用帖子ID123 来获取该帖子的数据。在应用程序中,我们可以通过引用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。也有一个删除帖子的按钮和一个编辑帖子的按钮。如果一个帖子被删除,我们会重新获取帖子来更新用户界面。如果一个用户想编辑一个帖子,我们就把他们重定向到我们接下来要创建的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启动了一个新的应用,通过一些依赖关系、一些配置和一些模板,我们做出了一个功能齐全的应用,让人们可以创建和管理博客文章--有一个支持认证、身份管理和路由的后端!我们拥有的是基本功能。

我们所拥有的是基线功能,但你也许可以看到在这里做更多的事情是多么高的上限。我也希望你能做到这一点。有了所有正确的成分,你可以利用我们做的东西,用你自己的改进和风格来扩展它。