天天看點

Hooks + TS 搭建一個任務管理系統(一)-- 登入注冊頁面

Hooks + TS 搭建一個任務管理系統(一)-- 登入注冊頁面

大家好,我是小丞同學,一名大二的前端愛好者

這個系列文章是實戰 jira 任務管理系統的一個學習總結

非常感謝你的閱讀,不對的地方歡迎指正

願你忠于自己,熱愛生活

前言

這篇文章是這個專欄中的第一篇文章,是以就寫點前言吧~,簡單的介紹一下吧

最近剛學完 React 的一些基本内容,教學視訊已經看完了,然後也學習了一下 TS 這門強類型的語言,對前端開發簡直就是利器。同時也了解了一下 Hooks 的一些内容,但是對這部分掌握的不是很好,是以跟着視訊利用 Hooks + TS4 + Router6做了一個任務管理系統練練手。在做這個 hooks 的項目之前,也有跟着做過一個基于 React 16.4 版本 + Redux 實作的簡書部落格平台,對 Redux 也有一定的了解。

扯這麼多,來說說這個項目吧!

這個項目是跟着視訊做的并不是完全由我創新的 ,是以如果文章有侵權行為的話,麻煩聯系一下删除(應該不會吧,畢竟文章是我自己寫的)

這個項目采用的技術棧是 React Hooks + TS4

主要實作的功能有 :使用者登入注冊,項目清單的展示,項目的 CRUD,項目詳情展示,看闆及任務組管理…

接下來的系列更文,将會圍繞實作這些功能,以及在項目中遇到的難題,提出一些問題和解決方案。

強迫自己開啟這個專欄是想要更加深入的了解,自己寫的代碼是什麼意思,能夠如何優化,了解更多代碼上的細節,而不是跟着老師敲完代碼就算了…,是以這個專欄會盡我所能将知識點囊括齊全!

高能預警:本項目采用了很多的 custom hook ,真的非常不錯

下面開始今天的主題,實作登入注冊頁面

Hooks + TS 搭建一個任務管理系統(一)-- 登入注冊頁面

一、用狀态驅動頁面更新

為什麼第一個要講“用狀态驅動頁面更新”呢?

我們需要通過目前的登入狀态,來展示不一樣的頁面。通過狀态來做很多的事情…

首先我們需要通過 useState ,來建立兩個狀态,一個是 isRegister 用來辨別是展示登入界面還是注冊界面,當 isRegister 為 true 時展示注冊頁面

第二個狀态是錯誤狀态,用來接收登入頁面的錯誤資訊,當有錯誤發生時,都會丢到這個變量當中

// 辨別目前是注冊還是登入,false 表示目前是登入狀态
const [isRegister, setIsRegister] = useState(false);
const [error, setError] = useState<Error | null>(null)      

在上面的兩行代碼中,值得注意的是,通過 useState 建立的變量類型預設會是初始化時的類型

也就是說 isRegister 的類型會因為我們初始化時傳的 false 變成 boolean 類型

而對于 error 而已,在不加泛型的情況下,它預設會是 null 類型,是以,在後面對它指派 Error 對象類型時,會發生錯誤,是以在這裡我們需要定義泛型 Error | null 這樣 error 就能接收 Error 類型了~

現在我們的狀态設定好了,接下來看看如何驅動頁面更新呢,那一個例子講講

<Button type={'link'} onClick={() => setIsRegister(!isRegister)}>{isRegister ? '已經有賬号了?直接登入' : '沒有賬号?注冊新賬号'}</Button>      

這個是登入和注冊切換的按鈕,當點選這個按鈕時,會觸發 setIsRegister 改變 isRegister 的值,我們通過這個值的 true or false 來判斷展示的内容

{/* 判斷展示登入頁面還是注冊頁面 */}
{
    isRegister ? <RegisterScreen onError={setError} /> : <LoginScreen onError={setError} />
}      

當為 true 的時候展示注冊頁面,在這裡我們将兩個頁面抽象出了兩個元件,将邏輯分開來,我們通過 props 向這兩個元件傳遞了 onError 方法,在元件中可以通過調用這個方法來設定 error 狀态的值,再展示到頁面上

在這裡值得我們注意的是,和類式元件不同,函數式元件會預設的接收 props 參數,是以我們不需要顯式的去使用 props 我們可以直接在參數清單中解構出來,這樣我們整個項目開發完成都不會見到一個 props

二、通過 Antd 布局頁面

關于布局方面采用的是 flex 布局,主要是通過 Antd 元件來實作的

<ShadowCard>
    <Title>
        {
            isRegister ? '請注冊' : "請登入"
        }
    </Title>
    <ErrorBox error={error} />
    {/* 判斷展示登入頁面還是注冊頁面 */}
    {
        isRegister ? <RegisterScreen onError={setError} /> : <LoginScreen onError={setError} />
    }
    <Divider />
    {/* 點選切換狀态 */}
    <Button type={'link'} onClick={() => setIsRegister(!isRegister)}>{isRegister ? '已經有賬号了?直接登入' : '沒有賬号?注冊新賬号'}</Button>
</ShadowCard>      

這裡的 ShadowCard 其實是對 Antd 中的 Card 元件進行了加工,讓它有了一些陰影,同時對它進行了一定的布局

// 元件加樣式,給Card元件更改樣式
const ShadowCard = styled(Card)`
    width: 40rem;
    min-height: 56rem;
    padding: 3.2rem 4rem;
    box-shadow: rgba(0,0,0,0.1) 0 0 10px;
    text-align: center;
`      

在 emotion 中,想要個 Antd 元件添加樣式,我們隻需要用 styled(元件名) 即可

對于登入和注冊頁面,采用的是 Antd 中的 Form 表單實作的,在控制好盒子大小後,基本不需要過多的布局

<Form onFinish={handleSubmit}>
    <Form.Item name={'username'} rules={[{ required: true, message: '請輸入使用者名' }]}>
        <Input placeholder={"使用者名"} type="text" id={"username"} />
    </Form.Item>
    <Form.Item name={'password'} rules={[{ required: true, message: '請輸入密碼' }]}>
        <Input placeholder={"密碼"} type="password" id={"password"} />
    </Form.Item>
    <LongButton loading={isLoading} htmlType={"submit"} type={"primary"}>登入</LongButton>
</Form>      

對于登入注冊的關鍵就是,通過前台認證之後,發送請求開啟認證即可,關鍵就在于這個認證如何實作,當然如果隻是簡單的發請求是非常簡單的,但是往後想想,我們會有很多個請求,如果我們每次都寫一遍那串代碼,那代碼的備援程度可想而知。

是以我們想在這裡抽象出兩個 custom hook ,一個用來擷取資料,一個用來處理異步請求,寫這兩個之前,我們先寫一個專門用來發送請求的檔案,我們将我們關于登入注冊的請求全部寫在這個檔案當中,再暴露出去,這樣代碼看起來思路更加清晰

三、編寫 auth-provider 檔案

我們在這個檔案中來處理我們需要發送的相關請求,首先,由于我們需要實作重新整理後仍保持登入狀态的效果,我們需要設定 token ,并且對于 token 資料我們是放在本地存儲當中的

// 儲存本地存儲中的 token 鍵
const localStorageKey = "__auth_provider_token__";
// 擷取 token 的值
export const getToken = () => window.localStorage.getItem(localStorageKey);      

通過封裝一個函數用來擷取我們本地的 token 值

export const handleUserResponse = ({ user }: { user: User }) => {
  window.localStorage.setItem(localStorageKey, user.token || "");
  return user; 
};      

通過這個函數來設定本地 token ,在登入注冊後調用

處理登入請求

export const login = (data: { username: string; password: string }) => {
  return fetch(`${apiUrl}/login`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data),
  }).then(async (response) => {
    if (response.ok) {
      return handleUserResponse(await response.json());
    } else {
      throw Promise.reject(await response.json());
    }
  });
};      

當我們在其他檔案中調用這個 login 時就會傳回這個 fetch 能夠發送登入的請求,當成功傳回結果時,就會調用前面的函數來設定一個本地的 token 值,用來儲存使用者的登入狀态

這裡有個比較重要的點:由于我們的請求都是異步的是以我們在 then 中需要采用 async await 的方式,優雅的解決這個由于異步造成的 undefined 的問題,對于其他注冊和登出的請求也是如此

在編寫好幾個請求函數之後,我們需要編寫一個 useAsync 函數用來專門處理異步請求

四、編寫 useAsync 發送異步請求

我們已經能夠發送請求擷取登入資訊了,為什麼我們還需要再編寫一個這樣的 custom hook 呢?

首先,我們在上面确實是能夠滿足我們最基本的業務需求了,我們編寫這個 custom hook 能夠幫我們将這個異步函數給具體化,什麼是具體化呢?

我們先來看看這個 custom hook 傳回的結果

return {
    // 請求狀态
    isIdle: state.stat === 'idle', 
    isLoading: state.stat === 'loading',
    isError: state.stat === 'error',
    isSuccess: state.stat === 'success',
    // run 接收一個promise 對象,傳回執行結果
    run,
    setData,
    setError,
    // retry 被調用重新執行 run,讓state 更新
    retry,
    ...state
}      

看到這些傳回的結果,相信已經有了一定的想法,我們可以通過這個 hook 來直視到異步函數的執行過程,而且又能将過程抽象在這個 hook 當中,在外部,我們隻需要 run 一下,就能得到結果,這不正是我們想要的嗎?

我們不想關注異步的細節,什麼 then 啊,async 啊,這些我們都不想關心,我們想要的是,執行後的結果,是以這個 hook 需要幫我們解決這些問題!這在優化我們代碼中起着非常重要的作用

對于這個 hook 的實作,比較複雜,類型複雜,

interface State<D> {
    error: Error | null;
    // 傳回的資料
    data: D | null;
    // 請求過程狀态
    stat: 'idle' | 'loading' | 'error' | 'success'
}      

首先我們定義初始化狀态的接口

初始化我們的初始狀态

// 初始狀态
const defaultInitialState: State<null> = {
    stat: 'idle',
    data: null,
    error: null
}      

我們先寫一個 hook 來幫我們判斷元件是否解除安裝

// 用這個dispatch 會幫我們判斷 mountedRef 元件是否被解除安裝
const useSafeDispatch = <T>(dispatch: (...args: T[]) => void) => {
    const mountedRef = useMountedRef()
    return useCallback((...args: T[]) => (mountedRef.current ? dispatch(...args) : void 0), [dispatch, mountedRef])
}      

當我們使用這個 hook 時,将會接收到目前元件的狀态,當元件被解除安裝後,我們就不需要再将資料傳回了,如果傳回的話,就會造成資料無法渲染的情況進而報錯,是以,我們編寫這個 hook 也是出于這樣的考慮

我們通過監聽 safeDispatch 的變化來該判斷目前的狀态,同時我們可以通過 setData 來傳遞傳回的資料,再通過 safeDispatch 來發送 dispatch 設定響應

const safeDispatch = useSafeDispatch(dispatch)
// 正常響應時的資料處理
const setData = useCallback((data: D) => safeDispatch({
    data,
    stat: 'success',
    error: null
}), [safeDispatch])
// 發生錯誤時的錯誤處理
const setError = useCallback((error: Error) => safeDispatch({
    error,
    stat: 'error',
    data: null
}), [safeDispatch])      

當然還有一些其他的狀态也需要這樣編寫,基本一緻

在這裡我們開始編寫我們的 run 函數,這個函數是主入口,用于觸發異步請求,首先從我們的調用上來看 run(login(values)) 我們隻想傳遞一個 promise 對象就能獲得所有的結果,

首先我們需要先判斷一下,傳入的對象是不是 promise 對象,如果不是則直接抛出錯誤

當進入 run 函數後,我們需要将 stat 狀态置為 loading 狀态,這樣我們可以通過這個值來實作請求 loading 的效果,

最後我們傳回一個 promise 對象的執行結果,在這個傳回當中有很多值得探讨的地方

為了擷取到傳入的 promise 對象抛出的錯誤,我需要使用 then 中的第二個參數來接收這 錯誤對象,再傳回這個錯誤,才能使用 catch 擷取,正常情況下,catch 擷取不到這個錯誤

// run是主入口,觸發異步請求
// 采用useCallback,隻有依賴中的資料發生變化的時候,run才會被重新定義
const run = useCallback((promise: Promise<D>, runConfig?: { retry: () => Promise<D> }) => {
    // 如果傳入的不是 promise,直接 throw
    if (!promise || !promise.then) {
        throw new Error('請傳入 Promise 類型資料')
    }
    // 定義重新重新整理一次,傳回一個有上一次 run 執行時的函數
    setRetry(() => () => {
        if (runConfig?.retry) {
            run(runConfig?.retry(), runConfig)
        }
    })
    // 如果是 promise 則設定狀态,開始 loading
    safeDispatch({ stat: 'loading' })
    // 傳回一個promise對象處理資料        
    return promise
        .then(data => {
            // 成功則處理stat
            // 判斷元件狀态
            setData(data)
            return data
        }, async (err) => {
            // 接收到扔來的錯誤,再扔一下
            return Promise.reject(await err)
        })
        .catch(error => {
            // 錯誤抛出了,但是接不住
            setError(error)
            if (config.throwOnError) {
                return Promise.reject(error)
            }
            return Promise.reject(error)
        })
}, [config.throwOnError, safeDispatch, setData, setError])      

在這個 hook 中有太多值得我們學習的地方

首先當我們的 custom hook 傳回的值是一個函數時,我們最好用 useCallback 來包一下,這樣能解決無限循環的問題

在我們的請求當中需要對異步情況做出特别的處理,利用 async 來解決這些問題

對于資料的類型,需要我們對泛型有很清晰的認識

五、編寫 useAuth 擷取使用者資訊

在編寫好 useAsync hook 後,我們需要 通過 useAuth 來擷取使用者的資訊,主要是依賴于 useAsync ,這也能展現出 useAsync 的巨大威力

在這個 custom hook 當中,我們會采用 useAsync 暴露的方法,同時也會采用到 react-query 處理緩存,利用 context 來實作資料共享

export const useAuth = () => {
    // 由于在使用 context 時,需要在子節點中聲明一下這個 context
    const context = React.useContext(AuthContext)
    // 如果這個 context 不存在
    if (!context) {
        throw new Error('useAuth必須在 context 中使用')
    }
    // 傳回這個 context 資料中心
    return context
}      

當我們調用這個 hook 的時候,就會傳回這個 context 對象 ,AuthContext ,當然不會這麼簡單,關鍵在于我們如何将這些資料存儲在 context 當中

我們編寫一個 AuthProvider 方法

export const AuthProvider = ({ children }: { children: ReactNode }) => {
    // 設定一個user變量 ,由于user 的類型由初始化的類型而定,但不能是 null ,我們需要進行類型斷言
    // const [user, setUser] = useState<User | null>(null)
    const { data: user, error, isLoading, isIdle, isError, run, setData: setUser } = useAsync<User | null>()
    const queryClient = useQueryClient()
    // 設定三個函數 登入 注冊 登出
    // setUser 是一個簡寫的方式 原先是:user => setUser(user)
    const login = (form: AuthForm) => auth.login(form).then(setUser)
    const register = (form: AuthForm) => auth.register(form).then(setUser)
    const logout = () => auth.logout().then(() => {
        setUser(null)
        // 清除資料緩存
        queryClient.clear()
    })
    // 當元件挂載時,初始化 user
    useMount(() => {
        run(bootstrapUser())
    })
        // 當初始化和加載中的時候顯示loading
        if (isIdle || isLoading) {
            return <FullPageLoading />
        }
        if (isError) {
            return <FullPageErrorFallback error={error} />
        }
    // 傳回一個 context 容器
    return <AuthContext.Provider children={children} value={{ user, login, logout, register }} />
}      

當我們這個方法傳回了一個 provider 容器,這需要我們對 context 有一定的了解,我們需要使用 provider 來包裹資料共享的範圍,隻有在這個範圍内的元素才能使用這些資料

這裡的意思是,所有的子元素都能夠使用這個 context 容器 ,我們在使用的時候

<AuthProvider>
    {children}
</AuthProvider>      

這樣所有的子元素都能共享它的 context 容器

接下來我們看看這個函數都寫了什麼,首先我們調用 useAsync 解構出了它的部分傳回結果,這些都是我們後面可能會用到的

在這裡我們對目前的狀态進行了判斷

// 當初始化和加載中的時候顯示loading
    if (isIdle || isLoading) {
        return <FullPageLoading />
    }
    if (isError) {
        return <FullPageErrorFallback error={error} />
    }      

當狀态為 loading 時我們展示一個加載框,當 error 時,展示一個錯誤提示框

// 當元件挂載時,初始化 user
    useMount(() => {
        run(bootstrapUser())
    })      

在元件剛挂載時,我們先檢查是否存在 token 如果有,我們就對他進行自動登入

// 保持使用者登入狀态,在元件挂載的時候就調用
const bootstrapUser = async () => {
    let user = null
    // 從本地取出 token
    const token = auth.getToken()
    if (token) {
        // 如果有值,就去發送請求獲得 user 資訊
        const data = await http('me', { token })
        user = data.user
    }
    // 傳回 user
    return user
}      

同時我們還将 auth-provider 中編寫的三個方法,一同存放到了 context 容器當中,這樣我們可以在外部調用

<AuthContext.Provider children={children} value={{ user, login, logout, register }} />      

這裡的 value 設定的就是它的 context 容器中的值

通過編寫這個 custom hook 我們對 useAsync 有了更好的了解,同時也學會了如何使用 context 來進行資料的共享

六、按鈕觸發函數執行

在編寫完了前面的幾個 custom hook 之後,我們已經将資料接口轉到了 context 當中,是以我們在調用裡面的内容時,隻需要調用 useAuth 來解構出對應的資料即可

// login.tsx
const { login } = useAuth()
// 采用 useAsync 來封裝異步請求,添加loading
const { run, isLoading } = useAsync(undefined, { throwOnError: true })      

我們得到了 login 函數,同時也得到了 isLoading 的狀态

當表單送出時,會觸發 Form 元件中的 onFinish 事件,我們給他綁定了一個 handleSubmit 方法,用于發送請求

const handleSubmit = async (values: { username: string, password: string }) => {
    // 采用 antd 元件庫後代碼優化
    // 這裡的catch 會捕獲錯誤,調用 onError 這個函數相當于是 error => onError(error) 
    // 由于在index中傳入的props是,onError={setError} 是以就相當于 setError(error)
    run(login(values)).catch(onError)
}      

就這樣我們就能夠成功的發送請求,并且傳回結果,當有錯誤發生時,會觸發 catch 中的 onError 設定 index 中的 error 狀态,顯示在頁面當中

總結

在這個登入注冊頁面當中,我們可以學到以下幾點

1.context 狀态管理

2.custom hook 在 react 中的強大威力

3.當 custom hook 傳回函數時,需要使用 useCallback 包裹

4.多利用解構指派,來優化代碼

5,.useState 設定的變量,類型會跟随初始值的類型

6.對于不同的事務,我們最好能分離出來寫,這樣我們的主檔案思路會非常清晰

7.利用 CSS in JS 解決樣式混亂的問題