Background
最近一周多,在学习 SvelteKit,并且做了一个小项目,以便把学到的东西串联起来。目前我觉得需要整理、消化一下目前的已有成果,做一个阶段性的总结。
Tech stack
- ui:skeleton, tailwindcss, tailwindcss/forms, tailwindcss/typography, lucide
- auth: firebase, firebase-admin
- form: zod, sveltekit-superforms
vscode 插件:sveltekit-snippets
这个插件对于新手来说太好了,很多时间不熟练咋写一些片段,可以帮助快速生成一些样板代码。
Skeleton
UI 框架选择了 skeleton。我比较了 Daisyui,最后选择了 Skeleton。体验很不错,开箱即用,并且预设主题也很好看,快速做原型的话,可以节省很多在 UI 上的时间。
AppShell 和 AppBar 很适合做页面布局。
<AppShell>
<svelte:fragment slot="header">
<AppBar class="mx-auto max-w-5xl" background="">
<svelte:fragment slot="lead">
<Logo />
</svelte:fragment>
<svelte:fragment slot="trail">
<header />
</svelte:fragment>
</AppBar>
</svelte:fragment>
<section class="mx-auto h-full max-w-5xl">
<slot />
</section>
<svelte:fragment slot="footer">
<footer />
</svelte:fragment>
</AppShell>
我很喜欢 LightSwitch 这个组件,零配置,太省事了。
<LightSwitch />
还有像 card 和 Tabs 这样的样式和组件,可以极大地提高开发效率。
<div class="flex flex-1 flex-col justify-center max-w-md mx-auto h-full">
<div class="card px-6 pt-6 pb-8">
<User2 class="mx-auto mb-5" size="42" />
<TabGroup justify="justify-center">
<Tab bind:group={tabSet} name="signInTab" value={'signIn'}>Sign In</Tab>
<Tab bind:group={tabSet} name="signUpTab" value={'signUp'}>Sign Up</Tab>
<!-- Tab Panels --->
<svelte:fragment slot="panel">
{#if tabSet === 'signIn'}
<Signin />
{:else if tabSet === 'signUp'}
<Signup />
{/if}
</svelte:fragment>
</TabGroup>
</div>
</div>
我是第一次使用 Skeleton,以不多的个人经验来判断,我觉得我会成为粉丝。
Zod
一看就懂的表单验证。
import { z } from 'zod'
const userSchema = z.object({
email: z.string().trim().email({ message: 'Email is invalid' }),
password: z
.string()
.trim()
.min(6, { message: 'Password must be at least 6 characters long' }),
})
export function validateUser(user) {
return userSchema.safeParse(user)
}
zod 返回的错误是一个列表,需要自己处理一下。
const msg = {}
const { success, error } = validateUser({ email, password })
if (success) {
// validate success
} else {
error.issues.forEach((issue) => {
msg[issue.path[0]] = issue.message
})
}
Firebase auth
Firebase 让我又爱又恨,最终只能归结为自己太菜。我花了很多时间才搞懂这是一个客户端为主的组件。
客户端初始化:
import { initializeApp, getApps } from 'firebase/app'
import { getAuth } from 'firebase/auth'
const firebaseConfig = {
apiKey: '',
// something else
}
export const app =
getApps().length === 0
? initializeApp(firebaseConfig, 'Client')
: getApps()[0]
export const auth = getAuth(app)
邮箱密码登录主要使用了 signInWithEmailAndPassword
和 createUserWithEmailAndPassword
、onAuthStateChanged
方法。
服务端初始化:
import { initializeApp, cert, getApps } from 'firebase-admin/app'
import { credential } from './firebase-admin-json'
export function adminApp() {
if (getApps().length === 0) {
initializeApp({ credential: cert(credential) })
}
}
Signin form
<script>
import { validateUser } from '$lib/auth/user-schema'
import { AlertTriangle } from 'lucide-svelte'
import { signInWithEmailAndPassword } from 'firebase/auth'
import { auth } from '$lib/auth/firebase'
import { goto } from '$app/navigation'
import { authStore } from '$lib/auth/auth-store'
let email = ''
let password = ''
let msg = {}
async function handleSubmit() {
const { success, error } = validateUser({ email, password })
if (success) {
try {
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
)
const user = userCredential.user
authStore.set({
isSignined: true,
user: {
accessToken: user.accessToken,
refreshToken: user.refreshToken,
uid: user.uid,
email: user.email,
emailVerified: user.emailVerified,
displayName: user.displayName,
photoURL: user.photoURL,
createdAt: user.metadata.createdAt,
lastLoginAt: user.metadata.lastLoginAt,
},
})
goto('/')
} catch (error) {
msg = {}
const code = error.code
if (code === 'auth/email-already-in-use') {
msg.email = 'Email already in use'
} else if (code === 'auth/wrong-password') {
msg.password = 'Wrong password'
} else if (code === 'auth/user-not-found') {
msg.email = 'User not found'
} else {
msg.password = error.code
}
}
} else {
error.issues.forEach((issue) => {
msg[issue.path[0]] = issue.message
})
}
}
</script>
<form
method="POST"
on:submit|preventDefault="{handleSubmit}"
class="mx-auto flex flex-col gap-5"
>
<div>
<label for="email" class="label">
<input
id="email"
bind:value="{email}"
name="email"
type="email"
autocomplete="email"
placeholder="Email"
class="input"
required
/>
</label>
{#if msg.email}
<div class="text-error-500 ml-1 mt-3 flex items-center gap-1 text-sm">
<AlertTriangle size="20" />
{msg.email}
</div>
{/if}
</div>
<div>
<label for="password" class="label">
<input
id="password"
bind:value="{password}"
name="password"
type="password"
autocomplete="new-password"
placeholder="Password"
class="input"
required
minlength="6"
/>
</label>
{#if msg.password}
<div class="text-error-500 ml-1 mt-3 flex items-center gap-1 text-sm">
<AlertTriangle size="20" />
{msg.password}
</div>
{/if}
</div>
<div>
<button type="submit" class="btn variant-filled-primary w-full">
Sign In
</button>
</div>
</form>
登录以后将用户信息存储在 authStore
中,这样在其他页面就可以使用了。
Protected route
对于需要保护的路由,在 load 函数中判断用户状态,对未登录做重定向。
// +page.js
import { redirect } from '@sveltejs/kit'
import { authStore } from '$lib/auth/auth-store'
export function load() {
let isSignined
const unsubscribe = authStore.subscribe((value) => {
isSignined = value.isSignined
})
unsubscribe()
if (!isSignined) throw redirect(307, '/signin')
return {}
}
在+layout.svelte 中,每个页面挂载时,监听用户状态的变化。
<script>
import { onAuthStateChanged } from 'firebase/auth'
import { auth } from '$lib/auth/firebase'
import { authStore } from '$lib/auth/auth-store'
import { onMount } from 'svelte'
onMount(() => {
onAuthStateChanged(auth, (user) => {
if (user) {
authStore.set({
isSignined: user !== null,
user: {
accessToken: user.accessToken,
refreshToken: user.refreshToken,
uid: user.uid,
email: user.email,
emailVerified: user.emailVerified,
displayName: user.displayName,
photoURL: user.photoURL,
createdAt: user.metadata.createdAt,
lastLoginAt: user.metadata.lastLoginAt,
},
})
} else {
authStore.set({
isSignined: false,
user: null,
})
}
})
})
</script>
Signout
在全局导航中,可以自动订阅 authStore,根据用户状态显示不同的导航。相比 React 里面的 useState,Skelte 里面的 store 简直太方便了。
在 signOut 退出事件中,从 Firebase 中退出用户,重设 authStore,最后重定向到登录页面。
<script>
// Header.svelte
import { page } from '$app/stores';
import { auth } from '$lib/auth/firebase';
import { signOut } from 'firebase/auth';
import { goto } from '$app/navigation';
import { authStore } from '$lib/auth/auth-store';
async function handelSignOut() {
try {
await signOut(auth);
authStore.set({ isSignined: false, user: null });
} catch (error) {
console.log(error);
}
goto('/signin');
}
</script>
<ul class="flex flex-row gap-4 text-lg font-semibold">
{#if $authStore.isSignined}
<li><button on:click={handelSignOut}>Sign Out</button></li>
{:else}
<li>
<a href="/signin"
class={$page.url.pathname === '/signin' && 'text-primary-600'}>
Sign In
</a>
</li>
{/if}
</ul>
Wrapping up
目前实现了一个简单的客户端身份验证功能,但是不好的是页面刷新或者打开新的标签页,用户状态会丢失。接下来会改造为服务端的身份验证方案。