構建一個即時消息應用(七):Access 頁面
本文是該系列的第七篇。
現在我們已經完成了後端,讓我們轉到前端。 我將採用單頁應用程序方案。
首先,我們創建一個 static/index.html
文件,內容如下。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Messenger</title>
<link rel="shortcut icon" href="data:,">
<link rel="stylesheet" href="/styles.css">
<script src="/main.js" type="module"></script>
</head>
<body></body>
</html>
這個 HTML 文件必須為每個 URL 提供服務,並且使用 JavaScript 負責呈現正確的頁面。
因此,讓我們將注意力轉到 main.go
片刻,然後在 main()
函數中添加以下路由:
router.Handle("GET", "/...", http.FileServer(SPAFileSystem{http.Dir("static")}))
type SPAFileSystem struct {
fs http.FileSystem
}
func (spa SPAFileSystem) Open(name string) (http.File, error) {
f, err := spa.fs.Open(name)
if err != nil {
return spa.fs.Open("index.html")
}
return f, nil
}
我們使用一個自定義的文件系統,因此它不是為未知的 URL 返回 404 Not Found
,而是轉到 index.html
。
路由器
在 index.html
中我們載入了兩個文件:styles.css
和 main.js
。我把樣式留給你自由發揮。
讓我們移動到 main.js
。 創建一個包含以下內容的 static/main.js
文件:
import { guard } from './auth.js'
import Router from './router.js'
let currentPage
const disconnect = new CustomEvent('disconnect')
const router = new Router()
router.handle('/', guard(view('home'), view('access')))
router.handle('/callback', view('callback'))
router.handle(/^/conversations/([^/]+)$/, guard(view('conversation'), view('access')))
router.handle(/^//, view('not-found'))
router.install(async result => {
document.body.innerHTML = ''
if (currentPage instanceof Node) {
currentPage.dispatchEvent(disconnect)
}
currentPage = await result
if (currentPage instanceof Node) {
document.body.appendChild(currentPage)
}
})
function view(pageName) {
return (...args) => import(`/pages/${pageName}-page.js`)
.then(m => m.default(...args))
}
如果你是這個博客的關注者,你已經知道它是如何工作的了。 該路由器就是在 這裡 顯示的那個。 只需從 @nicolasparada/router 下載並保存到 static/router.js
即可。
我們註冊了四條路由。 在根路由 /
處,我們展示 home
或 access
頁面,無論用戶是否通過身份驗證。 在 /callback
中,我們展示 callback
頁面。 在 /conversations/{conversationID}
上,我們展示對話或 access
頁面,無論用戶是否通過驗證,對於其他 URL,我們展示一個 not-found
頁面。
我們告訴路由器將結果渲染為文檔主體,並在離開之前向每個頁面調度一個 disconnect
事件。
我們將每個頁面放在不同的文件中,並使用新的動態 import()
函數導入它們。
身份驗證
guard()
是一個函數,給它兩個函數作為參數,如果用戶通過了身份驗證,則執行第一個函數,否則執行第二個。它來自 auth.js
,所以我們創建一個包含以下內容的 static/auth.js
文件:
export function isAuthenticated() {
const token = localStorage.getItem('token')
const expiresAtItem = localStorage.getItem('expires_at')
if (token === null || expiresAtItem === null) {
return false
}
const expiresAt = new Date(expiresAtItem)
if (isNaN(expiresAt.valueOf()) || expiresAt <= new Date()) {
return false
}
return true
}
export function guard(fn1, fn2) {
return (...args) => isAuthenticated()
? fn1(...args)
: fn2(...args)
}
export function getAuthUser() {
if (!isAuthenticated()) {
return null
}
const authUser = localStorage.getItem('auth_user')
if (authUser === null) {
return null
}
try {
return JSON.parse(authUser)
} catch (_) {
return null
}
}
isAuthenticated()
檢查 localStorage
中的 token
和 expires_at
,以判斷用戶是否已通過身份驗證。getAuthUser()
從 localStorage
中獲取經過身份驗證的用戶。
當我們登錄時,我們會將所有的數據保存到 localStorage
,這樣才有意義。
Access 頁面
讓我們從 access
頁面開始。 創建一個包含以下內容的文件 static/pages/access-page.js
:
const template = document.createElement('template')
template.innerHTML = `
<h1>Messenger</h1>
<a href="/api/oauth/github" onclick="event.stopPropagation()">Access with GitHub</a>
`
export default function accessPage() {
return template.content
}
因為路由器會攔截所有鏈接點擊來進行導航,所以我們必須特別阻止此鏈接的事件傳播。
單擊該鏈接會將我們重定向到後端,然後重定向到 GitHub,再重定向到後端,然後再次重定向到前端; 到 callback
頁面。
Callback 頁面
創建包括以下內容的 static/pages/callback-page.js
文件:
import http from '../http.js'
import { navigate } from '../router.js'
export default async function callbackPage() {
const url = new URL(location.toString())
const token = url.searchParams.get('token')
const expiresAt = url.searchParams.get('expires_at')
try {
if (token === null || expiresAt === null) {
throw new Error('Invalid URL')
}
const authUser = await getAuthUser(token)
localStorage.setItem('auth_user', JSON.stringify(authUser))
localStorage.setItem('token', token)
localStorage.setItem('expires_at', expiresAt)
} catch (err) {
alert(err.message)
} finally {
navigate('/', true)
}
}
function getAuthUser(token) {
return http.get('/api/auth_user', { authorization: `Bearer ${token}` })
}
callback
頁面不呈現任何內容。這是一個非同步函數,它使用 URL 查詢字元串中的 token 向 /api/auth_user
發出 GET 請求,並將所有數據保存到 localStorage
。 然後重定向到 /
。
HTTP
這裡是一個 HTTP 模塊。 創建一個包含以下內容的 static/http.js
文件:
import { isAuthenticated } from './auth.js'
async function handleResponse(res) {
const body = await res.clone().json().catch(() => res.text())
if (res.status === 401) {
localStorage.removeItem('auth_user')
localStorage.removeItem('token')
localStorage.removeItem('expires_at')
}
if (!res.ok) {
const message = typeof body === 'object' && body !== null && 'message' in body
? body.message
: typeof body === 'string' && body !== ''
? body
: res.statusText
throw Object.assign(new Error(message), {
url: res.url,
statusCode: res.status,
statusText: res.statusText,
headers: res.headers,
body,
})
}
return body
}
function getAuthHeader() {
return isAuthenticated()
? { authorization: `Bearer ${localStorage.getItem('token')}` }
: {}
}
export default {
get(url, headers) {
return fetch(url, {
headers: Object.assign(getAuthHeader(), headers),
}).then(handleResponse)
},
post(url, body, headers) {
const init = {
method: 'POST',
headers: getAuthHeader(),
}
if (typeof body === 'object' && body !== null) {
init.body = JSON.stringify(body)
init.headers['content-type'] = 'application/json; charset=utf-8'
}
Object.assign(init.headers, headers)
return fetch(url, init).then(handleResponse)
},
subscribe(url, callback) {
const urlWithToken = new URL(url, location.origin)
if (isAuthenticated()) {
urlWithToken.searchParams.set('token', localStorage.getItem('token'))
}
const eventSource = new EventSource(urlWithToken.toString())
eventSource.onmessage = ev => {
let data
try {
data = JSON.parse(ev.data)
} catch (err) {
console.error('could not parse message data as JSON:', err)
return
}
callback(data)
}
const unsubscribe = () => {
eventSource.close()
}
return unsubscribe
},
}
這個模塊是 fetch 和 EventSource API 的包裝器。最重要的部分是它將 JSON web 令牌添加到請求中。
Home 頁面
因此,當用戶登錄時,將顯示 home
頁。 創建一個具有以下內容的 static/pages/home-page.js
文件:
import { getAuthUser } from '../auth.js'
import { avatar } from '../shared.js'
export default function homePage() {
const authUser = getAuthUser()
const template = document.createElement('template')
template.innerHTML = `
<div>
<div>
${avatar(authUser)}
<span>${authUser.username}</span>
</div>
<button id="logout-button">Logout</button>
</div>
<!-- conversation form here -->
<!-- conversation list here -->
`
const page = template.content
page.getElementById('logout-button').onclick = onLogoutClick
return page
}
function onLogoutClick() {
localStorage.clear()
location.reload()
}
對於這篇文章,這是我們在 home
頁上呈現的唯一內容。我們顯示當前經過身份驗證的用戶和註銷按鈕。
當用戶單擊註銷時,我們清除 localStorage
中的所有內容並重新載入頁面。
Avatar
那個 avatar()
函數用於顯示用戶的頭像。 由於已在多個地方使用,因此我將它移到 shared.js
文件中。 創建具有以下內容的文件 static/shared.js
:
export function avatar(user) {
return user.avatarUrl === null
? `<figure class="avatar" data-initial="${user.username[0]}"></figure>`
: `<img class="avatar" src="${user.avatarUrl}" alt="${user.username}'s avatar">`
}
如果頭像網址為 null
,我們將使用用戶的姓名首字母作為初始頭像。
你可以使用 attr()
函數顯示帶有少量 CSS 樣式的首字母。
.avatar[data-initial]::after {
content: attr(data-initial);
}
僅開發使用的登錄
在上一篇文章中,我們為編寫了一個登錄代碼。讓我們在 access
頁面中為此添加一個表單。 進入 static/ages/access-page.js
,稍微修改一下。
import http from '../http.js'
const template = document.createElement('template')
template.innerHTML = `
<h1>Messenger</h1>
<form id="login-form">
<input type="text" placeholder="Username" required>
<button>Login</button>
</form>
<a href="/api/oauth/github" onclick="event.stopPropagation()">Access with GitHub</a>
`
export default function accessPage() {
const page = template.content.cloneNode(true)
page.getElementById('login-form').onsubmit = onLoginSubmit
return page
}
async function onLoginSubmit(ev) {
ev.preventDefault()
const form = ev.currentTarget
const input = form.querySelector('input')
const submitButton = form.querySelector('button')
input.disabled = true
submitButton.disabled = true
try {
const payload = await login(input.value)
input.value = ''
localStorage.setItem('auth_user', JSON.stringify(payload.authUser))
localStorage.setItem('token', payload.token)
localStorage.setItem('expires_at', payload.expiresAt)
location.reload()
} catch (err) {
alert(err.message)
setTimeout(() => {
input.focus()
}, 0)
} finally {
input.disabled = false
submitButton.disabled = false
}
}
function login(username) {
return http.post('/api/login', { username })
}
我添加了一個登錄表單。當用戶提交表單時。它使用用戶名對 /api/login
進行 POST 請求。將所有數據保存到 localStorage
並重新載入頁面。
記住在前端完成後刪除此表單。
這就是這篇文章的全部內容。在下一篇文章中,我們將繼續使用主頁添加一個表單來開始對話,並顯示包含最新對話的列表。
via: https://nicolasparada.netlify.com/posts/go-messenger-access-page/
作者:Nicolás Parada 選題:lujun9972 譯者:gxlct008 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive