教程:FullStackOpen2022/Part 5

本文中以 bloglist 应用为例。对应练习 5.1-5.10。

1. 实现登录登出功能

登录表单组件解耦

React 官方建议将共享状态提升到最近的共同父组件中去。因此,我们将登录表单的相关 state 移动到相应的组件中,组件只剩一个 props,即 login 函数,登录时,表单将调用该函数。

components/LoginForm.js:

import React, { useState } from 'react'

const LoginForm = ({ login }) => {
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')

  const handleLogin = (e) => {
    e.preventDefault()
    login({
      username,
      password,
    })
    setUsername('')
    setPassword('')
  }

  return (
    <>
      <h2>log in to application</h2>
      <form onSubmit={handleLogin}>
        <div>
          username
          <input
            type="text"
            value={username}
            name="Username"
            onChange={({ target }) => setUsername(target.value)}
          />
        </div>
        <div>
          password
          <input
            type="password"
            value={password}
            name="Password"
            onChange={({ target }) => setPassword(target.value)}
          />
        </div>
        <button type="submit">login</button>
      </form>
    </>
  )
}

export default LoginForm

完成登录请求

通过 api/login 这个 HTTP POST 请求完成登录。将这部分代码解耦到 services/login.js 模块中。

services/login.js:

import axios from 'axios'
const baseUrl = '/api/login'

const login = async (credentials) => {
  const response = await axios.post(baseUrl, credentials)
  return response.data
}

export default { login }

在 App.js 中添加实现前端登录功能的组件,并按照条件渲染。

App.js:

import React, { useState, useEffect, useRef } from 'react'

import Blog from './components/Blog'
import Notification from './components/Notification'
import Togglable from './components/Togglable'
import BlogForm from './components/BlogForm'
import LoginForm from './components/LoginForm'

import loginService from './services/login'

const App = () => {
  const [user, setUser] = useState(null)
  const [errorMessage, setErrorMessage] = useState(null)

	//...

  const handleLogin = async (credentials) => {
    try {
      const user = await loginService.login(credentials)
      setUser(user)
    } catch (error) {
      console.error(error)
      setErrorMessage('Wrong credentials')
      setTimeout(() => {
        setErrorMessage(null)
      }, 5000)
      return
    }
  }

  const handleLogout = () => {
    setUser(null)
  }

  const Blogs = () => (
    <>
      <h2>blogs</h2>
      <p>
        {user.name} logged in.
      </p>
      //...
    </>
  )

  return (
    <div>
      <Notification message={errorMessage} />
      {user ? Blogs() : <LoginForm login={handleLogin} />}
    </div>
  )
}

export default App

如果登录成功,服务器响应的 response(包括 token 和用户信息)被存储到 state 的 user 字段 。

如果登录失败,或者执行 loginService.login 时产生了错误,则会通知用户。

将登录信息保存到本地

目前,当应用页面重新渲染时,user 的登录信息就没了。这个问题,可以通过将登录信息以 key-value 的形式存储到本地浏览器的 local storage 中解决。

存储到 local storage 的值称为 DOMstrings,不能存储一个 Javascript 对象。因此对象首先要通过 JSON.stringify 方法转换成 JSON。相应的,从 local storage 读取 JSON 对象时,也要使用 JSON.parse 来将其解析回 Javascript。

修改代码,使登录信息在第一次登录时存储到本地,让用户可以保持登录状态,并添加登出功能。

App.js:

const App = () => {
  const [user, setUser] = useState(null)
  const [errorMessage, setErrorMessage] = useState(null)

  useEffect(() => {
    //...
    const loggedUserJSON = window.localStorage.getItem('loggedBloglistUser')
    if (loggedUserJSON) {
      const user = JSON.parse(loggedUserJSON)
      setUser(user)
    }
  }, [])

  const handleLogin = async (credentials) => {
    try {
      const user = await loginService.login(credentials)
      console.log(user)
      window.localStorage.setItem('loggedBloglistUser', JSON.stringify(user))
      setUser(user)
    } catch (error) {
      console.error(error)
      setErrorMessage('Wrong credentials')
      setTimeout(() => {
        setErrorMessage(null)
      }, 5000)
      return
    }
  }

  const handleLogout = () => {
    window.localStorage.removeItem('loggedBloglistUser')
    setUser(null)
  }

  const Blogs = () => (
    <>
      <h2>blogs</h2>
      <p>
        {user.name} logged in.
        <button type="button" onClick={() => handleLogout()}>
          logout
        </button>
      </p>
      //...
    </>
  )

  return (
    <div>
      <Notification message={errorMessage} />
      {user ? Blogs() : <LoginForm login={handleLogin} />}
    </div>
  )
}

2. 设置请求的认证 token

成功登录后,token 被 response 返回并存储到了 user 的 token 中。

blogService 模块包含一个私有变量 token。它的值可以通过 setToken 函数来改变,这个函数通过模块对外开放。通过 tokensetToken 函数,可以把登录成功用户的 token 放到 HTTP 请求的认证头中。

services/blogService.js:

import axios from 'axios'
const baseUrl = '/api/blogs'

let token = null

const setToken = (newToken) => {
  token = `bearer ${newToken}`
}

//...

const create = (newObject) => {
  const config = {
    headers: { Authorization: token },
  }

  const request = axios.post(baseUrl, newObject, config)
  return request.then((response) => response.data)
}

//...

export default { setToken, getAll, ... }

登录成功时,应用执行 blogService.setToken(user.token)

App.js:

const App = () => {
  //...

  useEffect(() => {
    //...
		const loggedUserJSON = window.localStorage.getItem('loggedBloglistUser')
    if (loggedUserJSON) {
      //...
      blogService.setToken(user.token)
    }
  }, [])

  const handleLogin = async (credentials) => {
    try {
      const user = await loginService.login(credentials)
      //...
      blogService.setToken(user.token)
    } catch (error) {
      //...
    }
  }

  //...
}

如果想更安全一些,最好的方式是不将用户认证信息保存在本地存储。

一种办法是将用户认证保存成 httpOnly cookies,这样 JavaScript 代码就不会有任何访问到 token 的可能。这种方法的缺点是实现SPA 应用会有一些复杂,需要至少为登录实现一个单独的页面。

但不管使用哪种解决方案,最重要的事情是应对 XSS(Cross Site Scripting)攻击时来最小化风险(参考:DOM based XSS Prevention Cheat Sheet)。

3. 使用 ref 的 组件

实现一个新的 Togglable 组件,任何想要打开或关闭的组件都可以通过 Togglable 进行包裹。

为了用户可以从外部父组件控制 Togglable 组件的可见性,我们使用 React 的 ref 机制,提供组件的引用。

components/Togglable.js:

import React, { useState, useImperativeHandle } from 'react'

const Togglable = React.forwardRef((props, ref) => {
  const [visible, setVisible] = useState(false)

  const hideWhenVisible = { display: visible ? 'none' : '' }
  const showWhenVisible = { display: visible ? '' : 'none' }

  const toggleVisibility = () => {
    setVisible(!visible)
  }

  useImperativeHandle(ref, () => {
    return {
      toggleVisibility
    }
  })

  return (
    <div>
      <div style={hideWhenVisible}>
        <button onClick={toggleVisibility}>{props.buttonLabel}</button>
      </div>
      <div style={showWhenVisible} className="togglable-content">
        {props.children}
        <button onClick={toggleVisibility}>cancel</button>
      </div>
    </div>
  )
})

export default Togglable

props.children 用来引用组件的子组件,在这里子组件是我们想要控制开启和关闭的组件。子组件不像之前的普通属性,props.children 由 React 自动添加,并始终存在。

创建组件的函数被包裹在了 forwardRef 函数调用中,以此访问赋给它的引用。组件继而利用 useImperativeHandle Hook 使 toggleVisibility 函数能够被外部组件访问到。

在 App.js 中用 useRef 方法创建对该组件的引用。

App.js:

import React, { useState, useEffect, useRef } from 'react'
import Togglable from './components/Togglable'
import BlogForm from './components/BlogForm'
import blogService from './services/blogs'

const App = () => {
  //...

  const blogFormRef = useRef()

  //...

  const createNewBlog = (newBlog) => {
    blogService
      .create(newBlog)
      .then((returnedBlog) => {
        setBlogs(blogs.concat(returnedBlog))
        blogFormRef.current.toggleVisibility()
      })
      .catch((error) => {
        //...
      })
  }

  const Blogs = () => (
    <>
      //...
      <Togglable buttonLabel="new blog" ref={blogFormRef}>
        <BlogForm createBlog={createNewBlog} />
      </Togglable>
      //...
    </>
  )

  //...
}

现在可以在 Blog 创建后,通过调用 blogFormRef.current.toggleVisibility() 控制表单的可见性了。