构建一个即时消息应用(七):Access页面

本文是构建个即该系列的第七篇。

第一篇: 模式 第二篇: OAuth 第三篇: 对话 第四篇: 消息 第五篇: 实时消息 第六篇: 仅用于开发的时消登录

现在我们已经完成了后端,让我们转到前端。用As页 我将采用单页应用程序方案。构建个即

首先,时消我们创建一个 static/index.html 文件,用As页内容如下。构建个即

<!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 负责呈现正确的用As页页面。

因此,构建个即让我们将注意力转到 main.go 片刻,时消然后在 main() 函数中添加以下路由:

router.Handle("GET",用As页 "/...", 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。用As页我把样式留给你自由发挥。

让我们移动到 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 page screenshot

让我们从 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 page screenshot

因此,亿华云计算当用户登录时,将显示 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 page with login form screenshot

在上一篇文章中,我们为编写了一个登录代码。让我们在 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 并重新加载页面。

记住在前端完成后删除此表单。

这就是这篇文章的全部内容。在下一篇文章中,我们将继续使用主页添加一个表单来开始对话,并显示包含最新对话的列表。

源代码
滇ICP备2023000592号-31