BOM – Cookie 和 LocalStorage

前言

Cookie 和 LocalStorage 是非常基礎的東西. 我是學編程後, 第 3 年纔開始寫博客的, 所以很多在第 1, 2 年學的知識完全都沒有記入下來. (比如 C#, JS 語法等等)

Cookie 和 LocalStorage 也是其中的一個. 今天就補上唄.

 

參考:

面試不再怕:史上最全的cookie知識點詳解

cookie 詳解

 

Cookie 的作用

HTTP 是無狀態的. 前一個請求和後一個請求沒有任何關聯. 服務端無法判斷是同一個 "人" 發出的請求. 這就導致了很多功能沒有辦法實現. 比如用戶登入.

要解決這個問題不能從 HTTP 協議着手, 那隻能靠遊覽器搞一些額外的潛規則了. Cookie 就是這麼一個存在.

 

Cookie 如何實現 "有狀態" 的 HTTP

上面提到了遊覽器的潛規則. 它的過程是這樣的. 

當遊覽器發請求給服務端時, 服務端可以在 response 的 header 里加入一個特別的 header 叫 "Set-Cookie"

當遊覽器接收 response 時會看看有沒有這個特別的 header, 如果有, 那就表示服務器想搞一個 "狀態". 比如: Set-Cookie : "key=value". 遊覽器會把這個 key value 記入起來.

在下一次遊覽器發請求給服務端時, 遊覽器會把之前記入起來的 key value 放入 header "Cookie" 中. 

通過這樣的 "潛規則", 遊覽器和服務端就利用 HTTP 協議的 header 讓原本沒有狀態的 HTTP 變成了 "有狀態".

其實 HTTP 只是一種通信協議. 只要在內容上做出規則. 雙邊是很容易 "認出" 對方的. Cookie 只是遊覽器替我們封裝好的一個方式而已. 

比如 Mobile App 就沒有 Cookie 但依然可以靠 HTTP + bearer token 來實現 OAuth 登入.

 

服務端 "Set Cookie" Header

ASP.NET Core

HttpContext.Response.Cookies.Append("Key", "Value");

效果

這個就是一個最簡單的服務端 response with Cookie

一個 Set-Cookie 表達一個 key value. 如果想返回多個. 那麼就返回多個 header "Set-Cookie".

HttpContext.Response.Cookies.Append("Key", "Value");
HttpContext.Response.Cookies.Append("Key1", "Value1");

 

遊覽器 "Cookie" Header

當遊覽器接收到服務端返回的 Cookie 以後就會記入起來.

下一次發送請求就會把這些 Cookie 發送出去.

 

Cookie 的體積

由於遊覽器每一次請求都會把 Cookie 發到服務端. 所以 Cookie 不可以太大. 不然會影響網速.

不同遊覽器有不同的標準, 但大部分是每個 domain 只能有 1xx 個 Cookie, 每一個最多 4kb.

總之, 儘可能用的少就對了.

 

Cookie 的各種配置

Cookie 本質上就是一個 header value. 也就是一個字符串. 但它是有 format 的. 它裏面其實表達了很多東西. 不僅僅只是 key value. 我們一個一個看.

key & value

最基本的就是 key value.

cookie: Key=Value; Key1=Value1

通過等於 = 把 key value 分開. 通過分號 ; 把多個 key value 分開. 這就是一個基本的 format.

key

key 不可以包含一些特殊符號. 比如 等於, 逗號, 分號, 空格, 等等. 理所當然丫, 不然遊覽器要怎麼 split 呢. 

想要了解詳情的可以看這篇: Stack Overflow – What are allowed characters in cookies?

通常我是建議取這種 key 的名字就要順風水, 不要搞一些奇奇怪怪的符號. 儘可能用 a-z 配 hypen 或 underscore 就好了.

ASP.NET Core 如果放入了不合法的 key name, 它會直接報錯.

value

value 就不可能邊開各種符號了. 它的解決方法是 encode.

在 ASP.NET Core, cookie value 會被 encode

HttpContext.Response.Cookies.Append("Key", "= ,;");

效果

encode 的方式是 JS 的 encodeURIComponent

expires & max-age

Cookie 有一個過期機制. 當 Set-Cookie 時可以指定一個有效期.

當遊覽器發現 Cookie 過期後, 它就會刪除掉. 服務端也是通過這種方式來實現刪除 Cookie 的哦.

如果沒有指定有效期, 遊覽器會在關閉的時候直接刪除掉 Cookie, 所以想 Cookie 持久就必須設定 expires 或 max-age

expires

HttpContext.Response.Cookies.Append("Key", "value", new CookieOptions
{
    Expires = new DateTimeOffset(2023, 1, 9, 5, 55, 0, 0, TimeSpan.FromHours(8))
});

指定 Cookie 過期時間爲 2023年 1月9號 5點 55分 +08:00

雖然 ASP.NET Core 支持 timezone 但其實 Cookie 本身是不支持的, 這裏是 ASP.NET Core 替我們做了轉換.

遊覽器接收的是 UTC 時間. 相等於 JavaScript 的 new Date().toUTCString()

max-age

相比於 expires 提供一個絕對時間, max-age 則是提供一個相對時間. 只是另一個表達手法而已. 遊覽器都明白你的意思.

HttpContext.Response.Cookies.Append("Key", "value", new CookieOptions
{
    MaxAge = TimeSpan.FromSeconds(30)
});

它表示從現在開始 30 秒後這個 Cookie 失效.

注: 它的單位是 second(秒) 哦. 不是 ms(毫秒) 哦 (而已不支持小數點. 所以沒有 100ms 0.1s 這種鼕鼕)

HttpOnly

上面我們都只談到服務端 Set Cookie 和遊覽器 send Cookie. 其實 JavaScript 也是可以讀寫 Cookie 的哦.

如果服務端不希望 Set Cookie 被 JavaScript 讀取, 那麼可以附上一個 "httponly". 這樣 JS 就讀取不到了 (但遊覽器是可以讀取到的...廢話)

Secure

secure 表示, 這個 Cookie 只允許在 HTTPS 加密通信中才可以使用.

Samesite

samesite 是一個比較新的東西 (其實好多年了...), 它是用來防 CSRF 的. 

從前沒有 samesite 機制, 相等於現在設定 samesite=none.

遊覽器在發送跨域請求 (ajax) 時會帶上 Cookie. 這導致了許多安全隱患. 雖然可以用 Anti-Forgery Tokens 來防禦.

但是有很多開發人員不太注重安全就沒有做. 後來遊覽器決定修改這個機制. 就有了 samesite

samesite=lax 是當前默認的. 跨域時只有 GET 請求會附上 cookie. POST 不會.

samesite=strict 表示不管 GET, POST 只要是跨域一概不允許發這個 Cookie.

Path

path=/ 表示任何路徑都附上 Cookie.

path=/about 表示這個 Cookie 只有在請求 /about 這個路徑才附上.

如果沒有聲明, 那就表示是當前 request 路徑.

注: 只能 set parent path 哦, 比如 request 是 domain.com/a/b/c

默認就是 /a/b/c, 我們可以 set 成 /, /a, /a/b. 但是不能 set 成 x/y/z 或者 /a/b/c/d

Domain

服務端當然可以返回任何 Domain 值, 只是遊覽器不會處理而已...哈哈.

domain 是用來設置 subdomain 訪問的.

domain=.example.com 前面加了一個點, 表示這個 Cookie 適用於 example.com 和其下所有 sub domain.

domain=sub.example.com 表示 Cookie 只用於 sub.example.com 訪問.

如果沒有聲明, 那麼 Set-Cookie 就表示當前 request 的 domain.

注: 只能 set parent domain. 比如 request 是 sub.domain.com 可以 set 成 .domain.com

完整版

HttpContext.Response.Cookies.Append("Key", "value", new CookieOptions
{
    Domain = ".example.com",
    Path = "/",
    HttpOnly = true,
    Secure = true,
    SameSite = SameSiteMode.Lax,
    Expires = DateTimeOffset.Now.AddHours(1),
    MaxAge = TimeSpan.FromSeconds(TimeSpan.FromHours(1).TotalSeconds),
});

默認值

 

JavaScript Cookie

上面提到的都是服務端和遊覽器間的 Cookie. 其實 JS 也是可以讀取到這些 Cookie 的. (只要 Cookie 沒有指定 httponly)

Read Cookie

console.log(document.cookie);

效果

它返回的是一個 string. 裏頭包含了所有能訪問到的 Cookie key and value.如果我們只想獲取某些 key 那麼需要自己從字符串中提取.

另外, JS 只能讀取到 key value 而已. 像 expires 這些其它信息是無法讀取到的.

Write Cookie

JS 也是可以創建 Cookie 哦

document.cookie = `key2=value2; max-age=3600; secure; path=/;`;
document.cookie = `key3=value3; max-age=3600; secure; path=/;`;

每一次 document.cookie = 都會創建一個 key value 的 Cookie. 

調用多次就創建多個. 這裏的語法很不直觀. 如果改成 document.cookie.add('...') 才比較符合它的行爲.

如果要刪除 Cookie 就設置 max-age=-1 或者 expires=一個過期的時間 (注: 確保 key, domain, path 相同哦)

value 記得用 encodeURIComponent encode 一下

expires 則用 toUTCString()

附上一個以前寫的讀寫 Cookie

function setCookie(
  key: string,
  value: string,
  config?: {
    domain?: string;
    path?: string;
    expires?: Date;
    maxAge?: number;
    secure?: boolean;
    sameSite?: 'none' | 'lax' | 'strict';
  }
): void {
  const { expires, maxAge, domain, path = '/', secure = true, sameSite } = config ?? {};
  const strings: string[] = [];
  const keyValue = `${key}=${encodeURIComponent(value)}`;
  strings.push(keyValue);
  if (expires) {
    strings.push(`expires=${expires.toUTCString()}`);
  }
  if (maxAge !== undefined) {
    strings.push(`max-age=${maxAge}`);
  }
  if (domain !== undefined) {
    strings.push(`domain=${domain}`);
  }
  strings.push(`path=${path}`);
  if (secure) {
    strings.push('secure');
  }
  if (sameSite !== undefined) {
    strings.push(`samesite=${sameSite}`);
  }
  document.cookie = strings.join('; ');
}

function getAllCookie(): Record<string, string> {
  const allCookie: Record<string, string> = {};
  const cookieString = document.cookie;
  for (const keyValue of cookieString.split(';')) {
    const [key, value] = keyValue.trim().split('=');
    allCookie[key] = decodeURIComponent(value);
  }
  return allCookie;
}

function getCookie(key: string): string | null {
  const allCookie = getAllCookie();
  return allCookie[key] ?? null;
}

function deleteCookie(key: string, config?: { domain?: string; path?: string }): void {
  const { domain, path = '/' } = config ?? {};
  const strings: string[] = [];
  const keyValue = `${key}=value`;
  strings.push(keyValue);
  strings.push(`max-age=-1`);
  if (domain !== undefined) {
    strings.push(`domain=${domain}`);
  }
  strings.push(`path=${path}`);
  document.cookie = strings.join('; ');
}
View Code

 

第三方 Cookie

參考: 知乎 – 當瀏覽器全面禁用三方 Cookie

做 marketing 的人唯一可能聽過跟技術相關的詞就是第三方 Cookie. 因爲前幾年蘋果爲了用(進)戶(軍)隱(廣)私(告) 決定靜止第三方 Cookie.

什麼是第三方 Cookie 呢?

上面有提到, 如果服務端返回 Set-Cookie 其它 domain 遊覽器是不理的

JS document.cookie = 其它 domain 也是無效的.

但是如果有一個 <img src="其它 domain" > 而這個請求有 Set-Cookie 其它 domain 確實可以的.

這個就是所謂的第三方 Cookie 了. 在 a.com 請求 b.com 得到 b.com 的 Cookie. 這個 Cookie 就是第三方的.

爲什麼要第三方 Cookie?

第三方 Cookie 是用來做廣告的. 上面的例子

a.com 要想識別 "一個人" 就給他 Cookie 咯. 這個叫第一方

b.com 是廣告公司, 它也想識別 "一個人" 在 a.com, 那麼它也需要 Cookie 咯. 

b.com 無法使用 a.com 的 Cookie, LocalStorage 等等. 所以它只能想辦法讓這個人訪問 b.com 這樣才能返回 b.com 的 Cookie. 

所以就用到了 img src 這類的方式.

蘋果禁止了第三方 Cookie

蘋果禁止第三方 Cookie 後, b.com (廣告公司) 就無法通過 img src 在 a.com 創建出 b.com 的 Cookie 了.

整個 tracking 就失敗了. 而爲了解決這個問題, 目前各大廣告公司會要求網站創建 a.com 的 Cookie 並且把這個數據發送到廣告公司的服務器.

本來是前端乾的事, 變成了服務端... 當然這對於網站安全是比較危險的. 畢竟前端插入廣告公司的代碼不會有非常大的安全隱患. 但如果是服務端必須安全廣告公司的 dll 就比較擔憂了.

 

LocalStorage 介紹

Cookie 體積小, 不適合存放大數據. 於是 LocalStorage 就誕生了.

它和 Cookie 本質上是不同的東西, 也不是互相替代的.只是它倆有點雷同, 所以經常會放到一起聊.

和 Cookie 的雷同和區別

1. LocalStorage 不會發送到服務端, 服務端也無法創建 LocalStorage. 它完全就是前端的東西.

2. 它們都是用 key value 來存資料

3. Cookie 體積很小, LocalStorage 很大 (好像是 5mb)

4. 它們都是跨域保護, 不同 domain, subdomain 都不可以訪問到 LocalStorage

5. Cookie 有 expires, LocalStorage 沒有

 

LocalStorage 使用

set key value

localStorage.setItem('key', 'value');

value 必須是 string. 不需要 encode.

get value

localStorage.getItem('key');

找不到返回 null 而不是 undefined 哦

remove key

localStorage.removeItem('key');

Cookie 通過 set expires 來實現刪除. LocalStorage 則有刪除接口

remove all

localStorage.clear()

一個方便的接口, 直接刪除所有 key

get all keys

const keys = Object.keys(localStorage);
for (const [key, value] of Object.entries(localStorage)) {}

get value by index

const value = localStorage.key(0);

這個接口最好是不要用, 因爲 localStorage 的順序是不可靠

localStorage.key(0);
// 相等於
localStorage.getItem(Object.keys()[0]);

get length

console.log(localStorage.length);

返回當前有多少 keys.

把 localstorage 當 object 調用

get

const value = localStorage['key']; // 找不到返回 undefined 而不是 null 哦
// 相等於
const value = localStorage.getItem('key') ?? undefined;

set

localStorage.key = true; 
// 相等於
localStorage.setItem('key', String(true));

自動強轉成 string

delete

delete localStorage.key;
// 相等於
localStorage.removeItem(key);

 

LocalStorage with Expiration

LocalStorage 最大的不方便就是它沒有過期機制.

一個常見的 workaround 是把 expires 寫入 value 裏. 比如

localStorage.setItem('key', 'value; max-age=3600')

當然, 這完全是個人的實現, 要用什麼規範都可以. 你可以模擬 Cookie 的方式, 也可以把 value 做成 JSON

localStorage.setItem('key', JSON.stringify({
  value: 'value',
  expires : new Date()
}))

重點是在 getItem 時需要從 value 中取出時間並且檢查過期與否等後續的操作.

這裏附上我以前寫的一個版本, 支持 expires

interface ExpirableLocalStorage extends Storage {
  setItem(key: string, value: string, expireDate?: Date): void;
}

interface Data {
  expirationDate: Date;
  value: string;
}
type FromParseJsonObject<T> = {
  [P in keyof T]: T[P] extends Date
    ? string
    : keyof T[P] extends never
    ? FromParseJsonObject<T[P]>
    : T[P];
};

function isData(value: object): value is FromParseJsonObject<Data> {
  return Object.keys(value).length === 2 && 'expirationDate' in value && 'value' in value;
}

const internalExpirableLocalStorage: ExpirableLocalStorage = {
  setItem(key: string, value: string, expirationDate?: Date): void {
    if (!expirationDate) {
      localStorage.setItem(key, value);
    } else {
      const data: Data = {
        expirationDate,
        value,
      };
      const jsonValue = JSON.stringify(data);
      localStorage.setItem(key, jsonValue);
    }
  },

  getItem(key: string): string | null {
    const maybeJsonValue = localStorage.getItem(key);
    if (maybeJsonValue === null) return null;
    try {
      const maybeData = JSON.parse(maybeJsonValue);
      if (isData(maybeData)) {
        if (new Date(maybeData.expirationDate) <= new Date()) {
          this.removeItem(key);
          return null;
        }
        return maybeData.value;
      } else {
        return maybeJsonValue;
      }
    } catch {
      return maybeJsonValue;
    }
  },

  get length(): number {
    return Object.keys(localStorage).filter(key => this.getItem(key) !== null).length;
  },

  key(index: number): string | null {
    return (
      Object.keys(localStorage)
        .map(key => this.getItem(key))
        .filter(v => v !== null)[index] ?? null
    );
  },

  removeItem(key: string): void {
    localStorage.removeItem(key);
  },
  clear() {
    localStorage.clear();
  },
};

const standardKeys = ['setItem', 'getItem', 'length', 'key', 'removeItem', 'clear'];
export const expirableLocalStorage = new Proxy(internalExpirableLocalStorage, {
  get(target, prop: string) {
    if (standardKeys.includes(prop)) return target[prop];
    return target.getItem(prop) ?? undefined;
  },
  set(target, prop: string, value) {
    if (standardKeys.includes(prop)) {
      target[prop] = value;
      return true;
    }
    target.setItem(prop, String(value));
    return true;
  },
  deleteProperty(target, key: string) {
    target.removeItem(key);
    return true;
  },
});
View Code

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章