使用 SolidJS 构建用户管理应用
本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,以及 行级别安全,以保护数据并确保用户只能访问他们自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase Storage - 允许用户上传个人资料照片。

如果您在完成本指南时遇到问题,请参考 GitHub 上的完整示例。
项目设置#
在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。
创建项目#
- 在 Supabase 控制面板中创建一个新项目。
- 输入您的项目详细信息。
- 等待新的数据库启动。
设置数据库 schema#
现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。
获取 API 详细信息#
现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。
为此,您需要从 项目 连接 对话框 获取项目 URL 和密钥。
API 密钥的更改
Supabase 正在更改密钥的工作方式,以提高项目安全性和开发人员体验。您可以 阅读完整的公告,但在过渡期间,您可以使用当前的 anon 和 service_role 密钥以及新的可发布密钥,格式为 sb_publishable_xxx,它将取代旧的密钥。
在大多数情况下,您可以从 项目 连接 对话框 获取正确的密钥,但如果您想要特定的密钥,您可以在 项目设置页面的 API 密钥部分 找到所有密钥。
- 对于旧版密钥,从 旧版 API 密钥 选项卡中复制
anon密钥用于客户端操作,并复制service_role密钥用于服务器端操作。 - 对于新密钥,打开 API 密钥 选项卡,如果您还没有可发布密钥,请单击 创建新的 API 密钥,并复制 可发布密钥 部分中的值。
阅读 API 密钥文档 以全面了解所有密钥类型及其用途。
构建应用#
从头开始构建 SolidJS 应用。
初始化 SolidJS 应用#
您可以使用 degit 初始化一个名为 supabase-solid 的应用
1npx degit solidjs/templates/ts supabase-solid2cd supabase-solid然后安装唯一的附加依赖项:supabase-js
1npm install @supabase/supabase-js最后,将环境变量保存在一个 .env 文件中,其中包含您之前复制的 API URL 和密钥 。
1VITE_SUPABASE_URL=https://your-project-ref.supabase.co2VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key现在您已经准备好了 API 凭据,创建一个辅助文件来初始化 Supabase 客户端。这些变量将在浏览器中公开,这完全没问题,因为您在数据库上启用了 行级别安全。
1import { createClient } from '@supabase/supabase-js'2import { Database } from './schema'34const supabaseUrl = import.meta.env.VITE_SUPABASE_URL5const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY67export const supabase = createClient(supabaseUrl, supabasePublishableKey)应用样式 (可选)#
一个可选步骤是更新 CSS 文件 src/index.css 以使应用看起来更好。您可以在 此处 找到此文件的完整内容。
设置登录组件#
设置一个 SolidJS 组件来管理登录和注册,使用 Magic Links,以便用户无需使用密码即可使用他们的电子邮件登录。
1import { Component, createSignal } from 'solid-js'2import { supabase } from './supabaseClient'34const Auth: Component = () => {5 const [loading, setLoading] = createSignal(false)6 const [email, setEmail] = createSignal('')78 const handleLogin = async (e: SubmitEvent) => {9 e.preventDefault()1011 try {12 setLoading(true)13 const { error } = await supabase.auth.signInWithOtp({ email: email() })14 if (error) throw error15 alert('Check your email for the login link!')16 } catch (error) {17 if (error instanceof Error) {18 alert(error.message)19 }20 } finally {21 setLoading(false)22 }23 }2425 return (26 <div class="row flex-center flex">27 <div class="col-6 form-widget" aria-live="polite">28 <h1 class="header">Supabase + SolidJS</h1>29 <p class="description">Sign in via magic link with your email below</p>30 <form class="form-widget" onSubmit={handleLogin}>31 <div>32 <label for="email">Email</label>33 <input34 id="email"35 class="inputField"36 type="email"37 placeholder="Your email"38 value={email()}39 onChange={(e) => setEmail(e.currentTarget.value)}40 />41 </div>42 <div>43 <button type="submit" class="button block" aria-live="polite">44 {loading() ? <span>Loading</span> : <span>Send magic link</span>}45 </button>46 </div>47 </form>48 </div>49 </div>50 )51}5253export default Auth账户页面#
用户登录后,允许他们编辑他们的个人资料详细信息并管理他们的帐户。
为此创建一个名为 Account.tsx 的新组件。
1import { Component, createEffect, createSignal } from 'solid-js'23// ...45import { supabase } from './supabaseClient'67interface Props {8 userId: string9 userEmail: string | null10}1112const Account: Component<Props> = ({ userId, userEmail }) => {13 const [loading, setLoading] = createSignal(true)14 const [username, setUsername] = createSignal<string | null>(null)15 const [website, setWebsite] = createSignal<string | null>(null)16 const [avatarUrl, setAvatarUrl] = createSignal<string | null>(null)1718 createEffect(() => {19 getProfile()20 })2122 const getProfile = async () => {23 try {24 setLoading(true)2526 let { data, error, status } = await supabase27 .from('profiles')28 .select(`username, website, avatar_url`)29 .eq('id', userId)30 .single()3132 if (error && status !== 406) {33 throw error34 }3536 if (data) {37 setUsername(data.username)38 setWebsite(data.website)39 setAvatarUrl(data.avatar_url)40 }41 } catch (error) {42 if (error instanceof Error) {43 alert(error.message)44 }45 } finally {46 setLoading(false)47 }48 }4950 const updateProfile = async (e: Event) => {51 e.preventDefault()5253 try {54 setLoading(true)5556 const updates = {57 id: userId,58 username: username(),59 website: website(),60 avatar_url: avatarUrl(),61 updated_at: new Date().toISOString(),62 }6364 let { error } = await supabase.from('profiles').upsert(updates)6566 if (error) {67 throw error68 }69 } catch (error) {70 if (error instanceof Error) {71 alert(error.message)72 }73 } finally {74 setLoading(false)75 }76 }7778 return (79 <div aria-live="polite">80 <form onSubmit={updateProfile} class="form-widget">8182 {/* ... */}8384 <div>Email: {userEmail}</div>85 <div>86 <label for="username">Name</label>87 <input88 id="username"89 type="text"90 value={username() || ''}91 onChange={(e) => setUsername(e.currentTarget.value)}92 />93 </div>94 <div>95 <label for="website">Website</label>96 <input97 id="website"98 type="text"99 value={website() || ''}100 onChange={(e) => setWebsite(e.currentTarget.value)}101 />102 </div>103 <div>104 <button type="submit" class="button primary block" disabled={loading()}>105 {loading() ? 'Saving ...' : 'Update profile'}106 </button>107 </div>108 <button type="button" class="button block" onClick={() => supabase.auth.signOut()}>109 Sign Out110 </button>111 </form>112 </div>113 )114}115116export default Account启动!#
现在您已经准备好了所有组件,请更新 App.tsx
1import { Component, createEffect, createSignal } from 'solid-js'2import { supabase } from './supabaseClient'3import Account from './Account'4import Auth from './Auth'56const App: Component = () => {7 const [userId, setUserId] = createSignal<string | null>(null)8 const [userEmail, setUserEmail] = createSignal<string | null>(null)910 const syncClaims = async () => {11 const { data } = await supabase.auth.getClaims()12 setUserId((data?.claims.sub as string) ?? null)13 setUserEmail((data?.claims.email as string) ?? null)14 }1516 createEffect(() => {17 syncClaims()1819 supabase.auth.onAuthStateChange(() => {20 syncClaims()21 })22 })2324 return (25 <div class="container" style={{ padding: '50px 0 100px 0' }}>26 {!userId() ? <Auth /> : <Account userId={userId()!} userEmail={userEmail()} />}27 </div>28 )29}3031export default App完成此操作后,在终端窗口中运行以下命令
1npm start然后在浏览器中打开 localhost:3000,您应该会看到完成的应用。

奖励:个人资料照片#
每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。
创建一个上传小部件#
为用户创建一个头像,以便他们可以上传个人资料照片。首先创建一个新组件
1import { Component, createEffect, createSignal, JSX } from 'solid-js'2import { supabase } from './supabaseClient'34interface Props {5 size: number6 url: string | null7 onUpload: (event: Event, filePath: string) => void8}910const Avatar: Component<Props> = (props) => {11 const [avatarUrl, setAvatarUrl] = createSignal<string | null>(null)12 const [uploading, setUploading] = createSignal(false)1314 createEffect(() => {15 if (props.url) downloadImage(props.url)16 })1718 const downloadImage = async (path: string) => {19 try {20 const { data, error } = await supabase.storage.from('avatars').download(path)21 if (error) {22 throw error23 }24 const url = URL.createObjectURL(data)25 setAvatarUrl(url)26 } catch (error) {27 if (error instanceof Error) {28 console.log('Error downloading image: ', error.message)29 }30 }31 }3233 const uploadAvatar: JSX.EventHandler<HTMLInputElement, Event> = async (event) => {34 try {35 setUploading(true)3637 const target = event.currentTarget38 if (!target?.files || target.files.length === 0) {39 throw new Error('You must select an image to upload.')40 }4142 const file = target.files[0]43 const fileExt = file.name.split('.').pop()44 const fileName = `${Math.random()}.${fileExt}`45 const filePath = `${fileName}`4647 let { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)4849 if (uploadError) {50 throw uploadError51 }5253 props.onUpload(event, filePath)54 } catch (error) {55 if (error instanceof Error) {56 alert(error.message)57 }58 } finally {59 setUploading(false)60 }61 }6263 return (64 <div style={{ width: `${props.size}px` }} aria-live="polite">65 {avatarUrl() ? (66 <img67 src={avatarUrl()!}68 alt={avatarUrl() ? 'Avatar' : 'No image'}69 class="avatar image"70 style={{ height: `${props.size}px`, width: `${props.size}px` }}71 />72 ) : (73 <div74 class="avatar no-image"75 style={{ height: `${props.size}px`, width: `${props.size}px` }}76 />77 )}78 <div style={{ width: `${props.size}px` }}>79 <label class="button primary block" for="single">80 {uploading() ? 'Uploading ...' : 'Upload avatar'}81 </label>82 <span style="display:none">83 <input84 type="file"85 id="single"86 accept="image/*"87 onChange={uploadAvatar}88 disabled={uploading()}89 />90 </span>91 </div>92 </div>93 )94}9596export default Avatar添加新的小部件#
然后将小部件添加到账户页面
1import { Component, createEffect, createSignal } from 'solid-js'2import Avatar from './Avatar'3import { supabase } from './supabaseClient'45 // ...67 return (8 <div aria-live="polite">9 <form onSubmit={updateProfile} class="form-widget">10 <Avatar11 url={avatarUrl()}12 size={150}13 onUpload={(e: Event, url: string) => {14 setAvatarUrl(url)15 updateProfile(e)16 }}17 />18 <div>Email: {userEmail}</div>19 <div>2021 // ...此时,您已经拥有一个功能齐全的应用!