Next.js 服務端操作Cookie

語言: CN / TW / HK

前言

最近使用next.js開發過程中發現服務端set-cookie返回設定到瀏覽器不成功,於是研究了一下如何處理,分享給大家。

操作

前後臺cookie如何相互傳遞?

1、前端如何傳遞cookie到後臺?

前端通過axios(或者fetch也可以)呼叫後臺介面的時候通過request請求頭header的cookie屬性(前端是你的瀏覽器中存在Cookie)帶到後臺,前提是要同源,如:前端地址是: www.baidu.com ,後臺是 :www.baiud.com/api 或者 api.baidu.com ,這樣的才能訪問瀏覽器中的cookie。

2、後臺如何傳遞cookie到前端?

後臺通過response請求頭header的set-cookie屬性帶到前端瀏覽器,自動就能寫到指定域名下。

瞭解next.js的執行過程

const pageA = (props) => {
    return <div> this is Page A</div>
}

export async function getServerSideProps(context) {
  
  const res = await axios({ url: "http://www.baidu.com/api/getUserList", data: xxx });
  const data = res?.data;

  return {
    props: {
      data
    }
  }
}

export default pageA;

上面是一段非常簡單的next.js頁面的程式碼,它分為兩部分,頁面pageA和服務端獲取介面資料getServerSideProps,當重新整理頁面或者首次開啟頁面時首先執行的是getServerSideProps方法,執行完成 之後才到pageA方法體中,是這樣一個執行過程。

這也是SSR渲染最核心的地方,先從後臺返回資料再渲染出頁面,減少SPA解釋js的等待時間。

注意: getServerSideProps 方法只有在頁面第一次渲染的時候才執行(或者重新整理頁面、跳轉頁面),後面就不會再進來了。

next.js客戶端如何傳遞cookie到後臺?

首先我們看一個問題就是上面的 http://www.baidu.com/api/getUserList 介面是沒辦法傳遞cookie到後臺的,為什麼不能把cookie傳遞到後臺呢?怎麼才能傳遞cookie到後臺呢?

首先回答第一個問題,因為 getServerSideProps 執行在服務端所以是拿不到瀏覽器裡面的cookie的,這時候需要通過next.js的context屬性拿到。

回答第二個問題,只有通過下面的程式碼手動指定,這也是SSR特殊的地方。

axios.defaults.headers.cookie = context.req.headers.cookie || null

當然如果頁面已經渲染完,這時候你通過頁面控制介面的訪問的時候就不用這麼麻煩,因為瀏覽器會自動幫你把cookie帶到介面的請求頭:request header cookie上。

next.js服務端如何傳遞cookie到客戶端瀏覽器?

已經瞭解如何將cookie從客戶端傳遞到服務端之後 ,我們再來解決如何將cookie從服務端傳遞到客戶端瀏覽器中,上面已經講過後臺是通過介面中返回的response請求頭中的set-cookie屬性傳遞過來的,如果是SPA那麼直接就可以設定到Cookie中,但是我們是SSR是next.js當然沒那麼簡單了,那麼我們如何設定呢?

要做兩步操作:

1、對axios返回請求頭做設定

2、 getServerSideProps 方法中再次設定set-cookie

const axiosInstance = axios.create({
  baseURL: `http://www.baidu.com/api`,
  withCredentials: true,
});


axiosInstance.interceptors.response.use(function (response) {
    axiosInstance.defaults.headers.setCookie = response.headers['set-cookie']
    return response;
}, function (error) {

  // 超出 2xx 範圍的狀態碼都會觸發該函式。
  // 對響應錯誤做點什麼
  return Promise.reject(error);
});

export default axiosInstance;

上面的 axiosInstance.defaults.headers.setCookie = response.headers['set-cookie'] 程式碼就是把後臺返回的set-cookie屬性賦值給 axiosInstance.defaults.headers.setCookie ,然後,回到 getServerSideProps 方法中,再在最後返回給瀏覽器中,如下所示:

const pageA = (props) => {
    return <div> this is Page A</div>
}

export async function getServerSideProps(context) {

  // 1、獲取cookie並儲存到axios請求頭cookie中
  axios.defaults.headers.cookie = ctx.req.headers.cookie || null
  
  const res = await axios({ url: "http://www.baidu.com/api/getUserList", data: xxx });
  const data = res?.data;

  // 2、判斷請求頭中是否有set-cookie,如果有,則儲存並同步到瀏覽器中
  if(axios.defaults.headers.setCookie){
    ctx.res.setHeader('set-cookie', axios.defaults.headers.setCookie)
    delete axios.defaults.headers.setCookie
  }
  return {
    props: {
      data
    }
  }
}

export default pageA;

這樣就完成了後臺set-cookie同步cookie到客戶端Cookie中,但是,這裡還有個問題,就是 getServerSideProps 方法中如果你請求多於一個介面時,set-cookie只有最後一個起使用,什麼意思呢?

const res1 = await axios({ url: "http://www.baidu.com/api/getUserList1", data: xxx });
const res2 = await axios({ url: "http://www.baidu.com/api/getUserList2", data: xxx });
const res3 = await axios({ url: "http://www.baidu.com/api/getUserList3", data: xxx });

上面三個方法執行之後,只有 getUserList3 這個介面的set-cookie儲存到客戶端Cookie中,這是為什麼呢?我們再來看看這段程式碼:

axiosInstance.defaults.headers.setCookie = response.headers['set-cookie']

上面這段程式碼每次執行完之後 axiosInstance.defaults.headers.setCookie 都會被 response.headers['set-cookie'] 直接覆蓋了,所以當代碼從 getUserList1 執行到 getUserList3 之後, set-cookie 就是最後一個方法的 set-cookie 了。

看到上面的問題你是不是已經想到了,對,就是合併把三個方法裡面的 set-cookie 合併到 axiosInstance.defaults.headers.setCookie 中,所以我們再來修改下程式碼:

// 新增響應攔截器
axiosInstance.interceptors.response.use(function (response) {

  // 目標:合併setCookie
  // A、將response.headers['set-cookie']合併到axios.defaults.headers.setCookie中
  // B、將axios.defaults.headers.setCookie合併到axios.defaults.headers.cookie中,目的是:每次請求axios請求頭中的cookie都是最新的

  // 注意:set-cookie格式和cookie格式區別
  /** axios.defaults.headers.setCookie和response.headers['set-cookie']格式如下
   *
   *  axios.defaults.headers.setCookie = [
   *    'name=Justin; Path=/; Max-Age=365; Expires=Mon, 15 Aug 2022 13:35:08 GMT; Secure; HttpOnly; SameSite=None'
   *  ]
   *
   * **/

  /** axios.defaults.headers.cookie 格式如下
   *
   *  axios.defaults.headers.cookie = name=Justin;age=18;sex=男
   *
   * **/
  // A1、判斷是否是服務端,並且返回請求頭中有set-cookie
  if(typeof window === 'undefined' && response.headers['set-cookie']){
    // A2、判斷axios.defaults.headers.setCookie是否是陣列
    // A2.1、如果是,則將response.headers['set-cookie']合併到axios.defaults.headers.setCookie
    // 注意:axios.defaults.headers.setCookie預設是undefined,而response.headers['set-cookie']預設是陣列
    if(Array.isArray(axiosInstance.defaults.headers.setCookie) ){

      // A2.1.1、將後臺返回的set-cookie字串和axios.defaults.headers.setCookie轉化成物件陣列
      // 注意:response.headers['set-cookie']可能有多個,它是一個數組

      /** setCookie.parse(response.headers['set-cookie'])和setCookie.parse(axios.defaults.headers.setCookie)格式如下
       *
       setCookie.parse(response.headers['set-cookie']) = [
          {
            name: 'userName',
            value: 'Justin',
            path: '/',
            maxAge: 365,
            expires: 2022-08-16T07:56:46.000Z,
            secure: true,
            httpOnly: true,
            sameSite: 'None'
          }
       ]
       * **/
      const _resSetCookie = setCookie.parse(response.headers['set-cookie'])
      const _axiosSetCookie = setCookie.parse(axiosInstance.defaults.headers.setCookie)
      // A2.1.2、利用reduce,合併_resSetCookie和_axiosSetCookie物件到result中(有則替換,無則新增)
      const result = _resSetCookie.reduce((arr1, arr2)=>{
        // arr1第一次進來是等於初始化化值:_axiosSetCookie
        // arr2依次是_resSetCookie中的物件
        let isFlag = false
        arr1.forEach(item => {
          if(item.name === arr2.name){
            isFlag = true
            item = Object.assign(item, arr2)
          }
        })
        if(!isFlag){
          arr1.push(arr2)
        }
        // 返回結果值arr1,作為reduce下一次的資料
        return arr1
      }, _axiosSetCookie)

      let newSetCookie = []
      result.forEach(item =>{
        // 將cookie物件轉換成cookie字串
        // newSetCookie = ['name=Justin; Path=/; Max-Age=365; Expires=Mon, 15 Aug 2022 13:35:08 GMT; Secure; HttpOnly; SameSite=None']
        newSetCookie.push(cookie.serialize(item.name, item.value, item))
      })
      // A2.1.3、合併完之後,賦值給axios.defaults.headers.setCookie
      axiosInstance.defaults.headers.setCookie = newSetCookie
    }else{
      // A2.2、如果否,則將response.headers['set-cookie']直接賦值
      axiosInstance.defaults.headers.setCookie = response.headers['set-cookie']
    }


    // B1、因為axios.defaults.headers.cookie不是最新的,所以要同步這樣後續的請求的cookie都是最新的了
    // B1.1、將axios.defaults.headers.setCookie轉化成key:value物件陣列
    const _parseSetCookie = setCookie.parse(axiosInstance.defaults.headers.setCookie)
    // B1.2、將axios.defaults.headers.cookie字串轉化成key:value物件
    /** cookie.parse(axiosInstance.defaults.headers.cookie)格式如下
     *
     *  {
     *    userName: Justin,
     *    age: 18,
     *    sex: 男
     *  }
     *
     * **/
    const _parseCookie = cookie.parse(axiosInstance.defaults.headers.cookie)

    // B1.3、將axios.defaults.headers.setCookie賦值給axios.defaults.headers.cookie(有則替換,無則新增)
    _parseSetCookie.forEach(cookie => {
      _parseCookie[cookie.name] = cookie.value
    })
    // B1.4、將賦值後的key:value物件轉換成key=value陣列
    // 轉換成格式為:_resultCookie = ["userName=Justin", "age=19", "sex=男"]
    let _resultCookie = []
    for (const key in _parseCookie) {
      _resultCookie.push(cookie.serialize(key, _parseCookie[key]))
    }
    // B1.5、將key=value的cookie陣列轉換成key=value;字串賦值給axiosInstance.defaults.headers.cookie
    // 轉換成格式為:axios.defaults.headers.cookie = "userName=Justin;age=19;sex=男"
    axiosInstance.defaults.headers.cookie = _resultCookie.join(';')
  }
  return response;
}, function (error) {
  // 超出 2xx 範圍的狀態碼都會觸發該函式。
  // 對響應錯誤做點什麼
  return Promise.reject(error);
});


export default axiosInstance;

有點多?不要怕,把註釋去掉就一點點東西,大家可以通過註釋看看上面程式碼如何實現的,當然你可以直接複製過去也行,上面程式碼完成兩個目標:

A、將response.headers['set-cookie']合併到axios.defaults.headers.setCookie中

最後,這樣就完成了Cookie在客戶端和服務端傳遞了。

最後我還想說一下,這種情況僅限出現在第一次渲染頁面時 getServerSideProps 方法中遇到的問題,而當頁面渲染完成之後,就不用這麼麻煩了。

總結

1、如果想了解如何解決跨域問題的可以看這篇文章

2、next.js頁面第一次渲染頁面只有在 getServerSideProps 方法中通過context獲取和設定cookie。