jump__jump

jump__jump 查看完整檔案

上海編輯  |  填寫畢業院校華宇  |  前端 編輯 github.com/wsafight/personBlog 編輯
編輯

獵奇者...未來戰士...very vegetable

個人動態

jump__jump 發布了文章 · 2月17日

手寫一個基于 Proxy 的緩存庫

兩年前,我寫了一篇關于業務緩存的博客 前端 api 請求緩存方案, 這篇博客反響還不錯,其中介紹了如何緩存數據,Promise 以及如何超時刪除(也包括如何構建修飾器)。如果對此不夠了解,可以閱讀博客進行學習。

但之前的代碼和方案終歸還是簡單了些,而且對業務有很大的侵入性。這樣不好,于是筆者開始重新學習與思考代理器 Proxy。

Proxy 可以理解成,在目標對象之前架設一層“攔截”,外界對該對象的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫。Proxy 這個詞的原意是代理,用在這里表示由它來“代理”某些操作,可以譯為“代理器”。關于 Proxy 的介紹與使用,建議大家還是看阮一峰大神的 ECMAScript 6 入門 代理篇。

項目演進

任何項目都不是一觸而就的,下面是關于 Proxy 緩存庫的編寫思路。希望能對大家有一些幫助。

proxy handler 添加緩存

當然,其實代理器中的 handler 參數也是一個對象,那么既然是對象,當然可以添加數據項,如此,我們便可以基于 Map 緩存編寫 memoize 函數用來提升算法遞歸性能。

type TargetFun<V> = (...args: any[]) => V

function memoize<V>(fn: TargetFun<V>) {
  return new Proxy(fn, {
    // 此處目前只能略過 或者 添加一個中間層集成 Proxy 和 對象。
    // 在對象中添加 cache
    // @ts-ignore
    cache: new Map<string, V>(),
    apply(target, thisArg, argsList) {
      // 獲取當前的 cache
      const currentCache = (this as any).cache
      
      // 根據數據參數直接生成 Map 的 key
      let cacheKey = argsList.toString();
      
      // 當前沒有被緩存,執行調用,添加緩存
      if (!currentCache.has(cacheKey)) {
        currentCache.set(cacheKey, target.apply(thisArg, argsList));
      }
      
      // 返回被緩存的數據
      return currentCache.get(cacheKey);
    }
  });
}
  

我們可以嘗試 memoize fibonacci 函數,經過了代理器的函數有非常大的性能提升(肉眼可見):

const fibonacci = (n: number): number => (n <= 1 ? 1 : fibonacci(n - 1) + fibonacci(n - 2));
const memoizedFibonacci = memoize<number>(fibonacci);

for (let i = 0; i < 100; i++) fibonacci(30); // ~5000ms
for (let i = 0; i < 100; i++) memoizedFibonacci(30); // ~50ms

自定義函數參數

我們仍舊可以利用之前博客介紹的的函數生成唯一值,只不過我們不再需要函數名了:

const generateKeyError = new Error("Can't generate key from function argument")

// 基于函數參數生成唯一值
export default function generateKey(argument: any[]): string {
  try{
    return `${Array.from(argument).join(',')}`
  }catch(_) {
    throw generateKeyError
  }
}

雖然庫本身可以基于函數參數提供唯一值,但是針對形形色色的不同業務來說,這肯定是不夠用的,需要提供用戶可以自定義參數序列化。

// 如果配置中有 normalizer 函數,直接使用,否則使用默認函數
const normalizer = options?.normalizer ?? generateKey

return new Proxy<any>(fn, {
  // @ts-ignore
  cache,
  apply(target, thisArg, argsList: any[]) {
    const cache: Map<string, any> = (this as any).cache
    
    // 根據格式化函數生成唯一數值
    const cacheKey: string = normalizer(argsList);
    
    if (!cache.has(cacheKey))
      cache.set(cacheKey, target.apply(thisArg, argsList));
    return cache.get(cacheKey);
  }
});

添加 Promise 緩存

在之前的博客中,提到緩存數據的弊端。同一時刻多次調用,會因為請求未返回而進行多次請求。所以我們也需要添加關于 Promise 的緩存。

if (!currentCache.has(cacheKey)){
  let result = target.apply(thisArg, argsList)
  
  // 如果是 promise 則緩存 promise,簡單判斷! 
  // 如果當前函數有 then 則是 Promise
  if (result?.then) {
    result = Promise.resolve(result).catch(error => {
      // 發生錯誤,刪除當前 promise,否則會引發二次錯誤
      // 由于異步,所以當前 delete 調用一定在 set 之后,
      currentCache.delete(cacheKey)
    
      // 把錯誤衍生出去
      return Promise.reject(error)
    })
  }
  currentCache.set(cacheKey, result);
}
return currentCache.get(cacheKey);

此時,我們不但可以緩存數據,還可以緩存 Promise 數據請求。

添加過期刪除功能

我們可以在數據中添加當前緩存時的時間戳,在生成數據時候添加。

// 緩存項
export default class ExpiredCacheItem<V> {
  data: V;
  cacheTime: number;

  constructor(data: V) {
    this.data = data
    // 添加系統時間戳
    this.cacheTime = (new Date()).getTime()
  }
}

// 編輯 Map 緩存中間層,判斷是否過期
isOverTime(name: string) {
  const data = this.cacheMap.get(name)

  // 沒有數據(因為當前保存的數據是 ExpiredCacheItem),所以我們統一看成功超時
  if (!data) return true

  // 獲取系統當前時間戳
  const currentTime = (new Date()).getTime()

  // 獲取當前時間與存儲時間的過去的秒數
  const overTime = currentTime - data.cacheTime

  // 如果過去的秒數大于當前的超時時間,也返回 null 讓其去服務端取數據
  if (Math.abs(overTime) > this.timeout) {
    // 此代碼可以沒有,不會出現問題,但是如果有此代碼,再次進入該方法就可以減少判斷。
    this.cacheMap.delete(name)
    return true
  }

  // 不超時
  return false
}

// cache 函數有數據
has(name: string) {
  // 直接判斷在 cache 中是否超時
  return !this.isOverTime(name)
}

到達這一步,我們可以做到之前博客所描述的所有功能。不過,如果到這里就結束的話,太不過癮了。我們繼續學習其他庫的功能來優化我的功能庫。

添加手動管理

通常來說,這些緩存庫都會有手動管理的功能,所以這里我也提供了手動管理緩存以便業務管理。這里我們使用 Proxy get 方法來攔截屬性讀取。

 return new Proxy(fn, {
  // @ts-ignore
  cache,
  get: (target: TargetFun<V>, property: string) => {
    
    // 如果配置了手動管理
    if (options?.manual) {
      const manualTarget = getManualActionObjFormCache<V>(cache)
      
      // 如果當前調用的函數在當前對象中,直接調用,沒有的話訪問原對象
      // 即使當前函數有該屬性或者方法也不考慮,誰讓你配置了手動管理呢。
      if (property in manualTarget) {
        return manualTarget[property]
      }
    }
   
    // 當前沒有配置手動管理,直接訪問原對象
    return target[property]
  },
}


export default function getManualActionObjFormCache<V>(
  cache: MemoizeCache<V>
): CacheMap<string | object, V> {
  const manualTarget = Object.create(null)
  
  // 通過閉包添加 set get delete clear 等 cache 操作
  manualTarget.set = (key: string | object, val: V) => cache.set(key, val)
  manualTarget.get = (key: string | object) => cache.get(key)
  manualTarget.delete = (key: string | object) => cache.delete(key)
  manualTarget.clear = () => cache.clear!()
  
  return manualTarget
}

當前情況并不復雜,我們可以直接調用,復雜的情況下還是建議使用 Reflect 。

添加 WeakMap

我們在使用 cache 時候,我們同時也可以提供 WeakMap ( WeakMap 沒有 clear 和 size 方法),這里我提取了 BaseCache 基類。

export default class BaseCache<V> {
  readonly weak: boolean;
  cacheMap: MemoizeCache<V>

  constructor(weak: boolean = false) {
    // 是否使用 weakMap
    this.weak = weak
    this.cacheMap = this.getMapOrWeakMapByOption()
  }

  // 根據配置獲取 Map 或者 WeakMap
  getMapOrWeakMapByOption<T>(): Map<string, T> | WeakMap<object, T>  {
    return this.weak ? new WeakMap<object, T>() : new Map<string, T>()
  }
}

之后,我添加各種類型的緩存類都以此為基類。

添加清理函數

在緩存進行刪除時候需要對值進行清理,需要用戶提供 dispose 函數。該類繼承 BaseCache 同時提供 dispose 調用。

export const defaultDispose: DisposeFun<any> = () => void 0

export default class BaseCacheWithDispose<V, WrapperV> extends BaseCache<WrapperV> {
  readonly weak: boolean
  readonly dispose: DisposeFun<V>

  constructor(weak: boolean = false, dispose: DisposeFun<V> = defaultDispose) {
    super(weak)
    this.weak = weak
    this.dispose = dispose
  }

  // 清理單個值(調用 delete 前調用)
  disposeValue(value: V | undefined): void {
    if (value) {
      this.dispose(value)
    }
  }

  // 清理所有值(調用 clear 方法前調用,如果當前 Map 具有迭代器)
  disposeAllValue<V>(cacheMap: MemoizeCache<V>): void {
    for (let mapValue of (cacheMap as any)) {
      this.disposeValue(mapValue?.[1])
    }
  }
}

當前的緩存如果是 WeakMap,是沒有 clear 方法和迭代器的。個人想要添加中間層來完成這一切(還在考慮,目前沒有做)。如果 WeakMap 調用 clear 方法時,我是直接提供新的 WeakMap 。

clear() {
  if (this.weak) {
    this.cacheMap = this.getMapOrWeakMapByOption()
  } else {
    this.disposeAllValue(this.cacheMap)
    this.cacheMap.clear!()
  }
}

添加計數引用

在學習其他庫 memoizee 的過程中,我看到了如下用法:

memoized = memoize(fn, { refCounter: true });

memoized("foo", 3); // refs: 1
memoized("foo", 3); // Cache hit, refs: 2
memoized("foo", 3); // Cache hit, refs: 3
memoized.deleteRef("foo", 3); // refs: 2
memoized.deleteRef("foo", 3); // refs: 1
memoized.deleteRef("foo", 3); // refs: 0,清除 foo 的緩存
memoized("foo", 3); // Re-executed, refs: 1

于是我有樣學樣,也添加了 RefCache。

export default class RefCache<V> extends BaseCacheWithDispose<V, V> implements CacheMap<string | object, V> {
    // 添加 ref 計數
  cacheRef: MemoizeCache<number>

  constructor(weak: boolean = false, dispose: DisposeFun<V> = () => void 0) {
    super(weak, dispose)
    // 根據配置生成 WeakMap 或者 Map
    this.cacheRef = this.getMapOrWeakMapByOption<number>()
  }
  

  // get has clear 等相同。不列出
  
  delete(key: string | object): boolean {
    this.disposeValue(this.get(key))
    this.cacheRef.delete(key)
    this.cacheMap.delete(key)
    return true;
  }


  set(key: string | object, value: V): this {
    this.cacheMap.set(key, value)
    // set 的同時添加 ref
    this.addRef(key)
    return this
  }

  // 也可以手動添加計數
  addRef(key: string | object) {
    if (!this.cacheMap.has(key)) {
      return
    }
    const refCount: number | undefined = this.cacheRef.get(key)
    this.cacheRef.set(key, (refCount ?? 0) + 1)
  }

  getRefCount(key: string | object) {
    return this.cacheRef.get(key) ?? 0
  }

  deleteRef(key: string | object): boolean {
    if (!this.cacheMap.has(key)) {
      return false
    }

    const refCount: number = this.getRefCount(key)

    if (refCount <= 0) {
      return false
    }

    const currentRefCount = refCount - 1
    
    // 如果當前 refCount 大于 0, 設置,否則清除
    if (currentRefCount > 0) {
      this.cacheRef.set(key, currentRefCount)
    } else {
      this.cacheRef.delete(key)
      this.cacheMap.delete(key)
    }
    return true
  }
}

同時修改 proxy 主函數:

if (!currentCache.has(cacheKey)) {
  let result = target.apply(thisArg, argsList)

  if (result?.then) {
    result = Promise.resolve(result).catch(error => {
      currentCache.delete(cacheKey)
      return Promise.reject(error)
    })
  }
  currentCache.set(cacheKey, result);

  // 當前配置了 refCounter
} else if (options?.refCounter) {
  // 如果被再次調用且當前已經緩存過了,直接增加       
  currentCache.addRef?.(cacheKey)
}

添加 LRU

LRU 的英文全稱是 Least Recently Used,也即最不經常使用。相比于其他的數據結構進行緩存,LRU 無疑更加有效。

這里考慮在添加 maxAge 的同時也添加 max 值 (這里我利用兩個 Map 來做 LRU,雖然會增加一定的內存消耗,但是性能更好)。

如果當前的此時保存的數據項等于 max ,我們直接把當前 cacheMap 設為 oldCacheMap,并重新 new cacheMap。

set(key: string | object, value: V) {
  const itemCache = new ExpiredCacheItem<V>(value)
  // 如果之前有值,直接修改
  this.cacheMap.has(key) ? this.cacheMap.set(key, itemCache) : this._set(key, itemCache);
  return this
}

private _set(key: string | object, value: ExpiredCacheItem<V>) {
  this.cacheMap.set(key, value);
  this.size++;

  if (this.size >= this.max) {
    this.size = 0;
    this.oldCacheMap = this.cacheMap;
    this.cacheMap = this.getMapOrWeakMapByOption()
  }
}

重點在與獲取數據時候,如果當前的 cacheMap 中有值且沒有過期,直接返回,如果沒有,就去 oldCacheMap 查找,如果有,刪除老數據并放入新數據(使用 _set 方法),如果都沒有,返回 undefined.

get(key: string | object): V | undefined {
  // 如果 cacheMap 有,返回 value
  if (this.cacheMap.has(key)) {
    const item = this.cacheMap.get(key);
    return this.getItemValue(key, item!);
  }

  // 如果 oldCacheMap 里面有
  if (this.oldCacheMap.has(key)) {
    const item = this.oldCacheMap.get(key);
    // 沒有過期
    if (!this.deleteIfExpired(key, item!)) {
      // 移動到新的數據中并刪除老數據
      this.moveToRecent(key, item!);
      return item!.data as V;
    }
  }
  return undefined
}


private moveToRecent(key: string | object, item: ExpiredCacheItem<V>) {
  // 老數據刪除
  this.oldCacheMap.delete(key);
  
  // 新數據設定,重點?。。?!如果當前設定的數據等于 max,清空 oldCacheMap,如此,數據不會超過 max
  this._set(key, item);
}

private getItemValue(key: string | object, item: ExpiredCacheItem<V>): V | undefined {
  // 如果當前設定了 maxAge 就查詢,否則直接返回
  return this.maxAge ? this.getOrDeleteIfExpired(key, item) : item?.data;
}
  
  
private getOrDeleteIfExpired(key: string | object, item: ExpiredCacheItem<V>): V | undefined {
  const deleted = this.deleteIfExpired(key, item);
  return !deleted ? item.data : undefined;
}
  
private deleteIfExpired(key: string | object, item: ExpiredCacheItem<V>) {
  if (this.isOverTime(item)) {
    return this.delete(key);
  }
  return false;
}  

整理 memoize 函數

事情到了這一步,我們就可以從之前的代碼細節中解放出來了,看看基于這些功能所做出的接口與主函數。

// 面向接口,無論后面還會不會增加其他類型的緩存類
export interface BaseCacheMap<K, V> {
  delete(key: K): boolean;

  get(key: K): V | undefined;

  has(key: K): boolean;

  set(key: K, value: V): this;

  clear?(): void;

  addRef?(key: K): void;

  deleteRef?(key: K): boolean;
}

// 緩存配置
export interface MemoizeOptions<V> {
  /** 序列化參數 */
  normalizer?: (args: any[]) => string;
  /** 是否使用 WeakMap */
  weak?: boolean;
  /** 最大毫秒數,過時刪除 */
  maxAge?: number;
  /** 最大項數,超過刪除  */
  max?: number;
  /** 手動管理內存 */
  manual?: boolean;
  /** 是否使用引用計數  */
  refCounter?: boolean;
  /** 緩存刪除數據時期的回調 */
  dispose?: DisposeFun<V>;
}

// 返回的函數(攜帶一系列方法)
export interface ResultFun<V> extends Function {
  delete?(key: string | object): boolean;

  get?(key: string | object): V | undefined;

  has?(key: string | object): boolean;

  set?(key: string | object, value: V): this;

  clear?(): void;

  deleteRef?(): void
}

最終的 memoize 函數其實和最開始的函數差不多,只做了 3 件事

  • 檢查參數并拋出錯誤
  • 根據參數獲取合適的緩存
  • 返回代理
export default function memoize<V>(fn: TargetFun<V>, options?: MemoizeOptions<V>): ResultFun<V> {
  // 檢查參數并拋出錯誤
  checkOptionsThenThrowError<V>(options)

  // 修正序列化函數
  const normalizer = options?.normalizer ?? generateKey

  let cache: MemoizeCache<V> = getCacheByOptions<V>(options)

  // 返回代理
  return new Proxy(fn, {
    // @ts-ignore
    cache,
    get: (target: TargetFun<V>, property: string) => {
      // 添加手動管理
      if (options?.manual) {
        const manualTarget = getManualActionObjFormCache<V>(cache)
        if (property in manualTarget) {
          return manualTarget[property]
        }
      }
      return target[property]
    },
    apply(target, thisArg, argsList: any[]): V {

      const currentCache: MemoizeCache<V> = (this as any).cache

      const cacheKey: string | object = getKeyFromArguments(argsList, normalizer, options?.weak)

      if (!currentCache.has(cacheKey)) {
        let result = target.apply(thisArg, argsList)

      
        if (result?.then) {
          result = Promise.resolve(result).catch(error => {
            currentCache.delete(cacheKey)
            return Promise.reject(error)
          })
        }
        currentCache.set(cacheKey, result);
      } else if (options?.refCounter) {
        currentCache.addRef?.(cacheKey)
      }
      return currentCache.get(cacheKey) as V;
    }
  }) as any
}

完整代碼在 memoizee-proxy 中。大家自行操作與把玩。

下一步

測試

測試覆蓋率不代表一切,但是在實現庫的過程中,JEST 測試庫給我提供了大量的幫助,它幫助我重新思考每一個類以及每一個函數應該具有的功能與參數校驗。之前的代碼我總是在項目的主入口進行校驗,對于每個類或者函數的參數沒有深入思考。事實上,這個健壯性是不夠的。因為你不能決定用戶怎么使用你的庫。

Proxy 深入

事實上,代理的應用場景是不可限量的。這一點,ruby 已經驗證過了(可以去學習《ruby 元編程》)。

開發者使用它可以創建出各種編碼模式,比如(但遠遠不限于)跟蹤屬性訪問、隱藏屬性、阻止修改或刪除屬性、函數參數驗證、構造函數參數驗證、數據綁定,以及可觀察對象。

當然,Proxy 雖然來自于 ES6 ,但該 API 仍需要較高的瀏覽器版本,雖然有 proxy-pollfill ,但畢竟提供功能有限。不過已經 2021,相信深入學習 Proxy 也是時機了。

深入緩存

緩存是有害的!這一點毋庸置疑。但是它實在太快了!所以我們要更加理解業務,哪些數據需要緩存,理解那些數據可以使用緩存。

當前書寫的緩存僅僅只是針對與一個方法,之后寫的項目是否可以更細粒度的結合返回數據?還是更往上思考,寫出一套緩存層?

小步開發

在開發該項目的過程中,我采用小步快跑的方式,不斷返工。最開始的代碼,也僅僅只到了添加過期刪除功能那一步。

但是當我每次完成一個新的功能后,重新開始整理庫的邏輯與流程,爭取每一次的代碼都足夠優雅。同時因為我不具備第一次編寫就能通盤考慮的能力。不過希望在今后的工作中,不斷進步。這樣也能減少代碼的返工。

其他

函數創建

事實上,我在為當前庫添加手動管理時候,考慮過直接復制函數,因為函數本身是一個對象。同時為當前函數添加 set 等方法。但是沒有辦法把作用域鏈拷貝過去。

雖然沒能成功,但是也學到了一些知識,這里也提供兩個創建函數的代碼。

我們在創建函數時候基本上會利用 new Function 創建函數,但是瀏覽器沒有提供可以直接創建異步函數的構造器,我們需要手動獲取。

AsyncFunction = (async x => x).constructor

foo = new AsyncFunction('x, y, p', 'return x + y + await p')

foo(1,2, Promise.resolve(3)).then(console.log) // 6

對于全局函數,我們也可以直接 fn.toString() 來創建函數,這時候異步函數也可以直接構造的。

function cloneFunction<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
  return new Function('return '+ fn.toString())();
}

鼓勵一下

如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 博客下幫忙 star 一下。

博客地址

參考資料

前端 api 請求緩存方案

ECMAScript 6 入門 代理篇

memoizee

memoizee-proxy

查看原文

贊 31 收藏 22 評論 2

jump__jump 贊了文章 · 2月8日

Docsify v4.12.0 發布,神奇的文檔網站生成工具

此版本更新內容包括:

修復

  • 修復 Vue 的兼容性 (#1271)
  • 修復側邊欄標題錯誤 (#1360)
  • 修復無法讀取未定義的'startWith'屬性 (#1358)
  • 修復側邊欄水平滾動條 (#1362)
  • 修復高亮代碼缺少的參數 (#1365)
  • 修復無法讀取未定義的屬性級別 (#1357)
  • 修復無法搜索列表內容 (#1361)
  • 修復滾動事件結束值 (04bf1ea)
  • 修復 eslint 警告 (#1388)
  • 修復無法搜索主頁內容 (#1391)
  • 修復側邊欄鏈接到另一個網站 (#1336)
  • 修復包含忽略字符的搜索標題 (#1395)
  • 修復側邊欄鏈接存在 html 標簽時的標題錯誤 (#1404)
  • 修復側邊欄中存在/README/時的重復搜索內容 (#1403)
  • 修復當標題包含 html 時,slugs 仍然被破壞 (#1443)
  • 修復側邊欄 active 和 expand 在 markdown 文件名中使用空格時不能正常工作的問題 (#1454)
  • 修復標題中代碼的字體大小變化 (#1456)
  • 修復防止通過 URL 哈希加載遠程內容 (#1489)
  • 修復無法關閉/.../index.html (#1372)
  • 為 IE11 使用與傳統兼容的方法 (#1495)

增強

  • 添加 Jest + Playwright 測試 (#1276)
  • 添加 Vue 組件、掛載選項、全局選項和 V3 支持 (#1409)
  • 支持搜索忽略重音符 (#1434)

依賴

  • 將 node-fetch 從 2.6.0 升級到 2.6.1 (#1370)
  • 將 docsify 從 4.11.4 升級到 4.11.6 (#1373)
  • 將 debug 從 4.1.1 升級到 4.3.0 (#1390)
  • 將 dompurify 從 2.0.17 升級到 2.1.0 (#1397)
  • 將 dompurify 從 2.1.0 升級到 2.1.1 (#1402)
  • 將 marked 從 1.2.0 升級到 1.2.2 (#1425)
  • 將 dompurify 從 2.1.1 升級到 2.2.2 (#1419)
  • 將 prismjs 從 1.21.0 升級到 1.22.0 (#1415)
  • 將 marked 從 1.2.2 升級到 1.2.3(#1430)
  • 將 marked 從 1.2.3 升級到 1.2.4 (#1441)
  • 將 debug 從 4.3.0 升級到 4.3.1 (#1446)
  • 將 ini 從 1.3.5 升級到 1.3.7 (#1445)
  • 將 dompurify 從 2.2.2 升級到 2.2.3 (#1457)
  • 將 debug 從 4.3.1 升級到 4.3.2 調試 (#1463)
  • 將 axios 從 0.20.0 升級到 0.21.1 (#1471)
  • 將 prismjs 從 1.22.0 升級到 1.23.0 (#1481)
  • 將 dompurify 從 2.2.3 升級到 2.2.6 (#1482)
  • 將 dompurify 從 2.2.2 升級到 2.2.6 (#1483)
  • 升級 playwright 到 1.8.0 (#1487)
  • 將 marked 從 1.2.4 升級到 1.2.9 (#1486)

項目介紹

Docsify 是一個神奇的文檔網站生成器??梢钥焖賻湍闵晌臋n網站。不同于 GitBook、Hexo 的地方是它不會生成靜態的 .html 文件,所有轉換工作都是在運行時。如果你想要開始使用它,只需要創建一個 index.html 就可以開始編寫文檔并直接部署在 GitHub Pages 等地方。

無論開源項目還是內部項目,在開發項目時,想要使用 Markdown 構建文檔,將文檔統一管理,docsify 是不錯的選擇。無需構建和編譯,寫完 Markdown 就可以直接發布。

GitHub:https://github.com/docsifyjs/... ?? 點個Star~
Gitee:https://gitee.com/docsifyjs/d...

查看原文

贊 3 收藏 0 評論 1

jump__jump 發布了文章 · 1月25日

聊聊不可變數據結構

三年前,我接觸了 Immutable 庫,體會到了不可變數據結構的利好。

Immutable 庫具有兩個最大的優勢: 不可修改以及結構共享。

  • 不可修改(容易回溯,易于觀察。減少錯誤的發生)
let obj = { a: 1 };

handleChange(obj);

// 由于上面有 handleChange,無法確認 obj 此時的狀態
console.log(obj)
  • 結構共享( 復用內存,節省空間,也就意味著數據修改可以直接記錄完整數據,其內存壓力也不大,這樣對于開發復雜交互項目的重做等功能很有用)

當然,由于當時還在重度使用 Vue 進行開發,而且 受益于 Vue 本身的優化以及業務抽象和系統的合理架構,項目一直保持著良好的性能。同時該庫的侵入性和難度都很大,貿然引入項目也未必是一件好事。

雖然 Immutable 庫沒有帶來直接的收益,但從中學到一些思路和優化卻陪伴著我。

淺拷貝 assign 勝任 Immutable

當我們不使用任何庫,我們是否就無法享受不可變數據的利好?答案是否定的。

當面臨可變性數據時候,大部分情況下我們會使用深拷貝來解決兩個數據引用的問題。

const newData = deepCopy(myData);
newData.x.y.z = 7;
newData.a.b.push(9);

不幸的是,深度拷貝是昂貴的,在有些情況下更是不可接受的。深拷貝占用了大量的時間,同時兩者之間沒有任何結構共享。但我們可以通過僅復制需要更改的對象和重用未更改的對象來減輕這種情況。如 Object.assign 或者 ... 來實現結構共享。

大多數業務開發中,我們都是先進行深拷貝,再進行修改。但是我們真的需要這樣做嗎?事實并非如此。從項目整體出發的話,我們只需要解決一個核心問題 “深層嵌套對象”。當然,這并不意味著我們把所有的數據都放在第一層。只需要不嵌套可變的數據項即可。

const staffA = {
  name: 'xx',
  gender: 'man',
  company: {},
  authority: []
}

const staffB = {...staffA}

staffB.name = 'YY'

// 不涉及到 復雜類型的修改即可
staffA.name // => 'xx'

const staffsA = [staffA, staffB]

// 需要對數組內部每一項進行淺拷貝
const staffsB = staffsA.map(x => ({...x}))

staffsB[0].name = 'gg'

staffsA[0].name // => 'xx'

如此,我們就把深拷貝變為了淺拷貝。同時實現了結構共享 (所有深度嵌套對象都被復用了) 。但有些情況下,數據模型并不是容易修改的,我們還是需要修改深度嵌套對象。那么就需要這樣修改了。

const newData = Object.assign({}, myData, {
  x: Object.assign({}, myData.x, {
    y: Object.assign({}, myData.x.y, {z: 7}),
  }),
  a: Object.assign({}, myData.a, {b: myData.a.b.concat(9)})
});

這對于絕大部份的業務場景來說是相當高效的(因為它只是淺拷貝,并重用了其余的部分) ,但是編寫起來卻非常痛苦。

immutability-helper 庫輔助開發

immutability-helper (語法受到了 MongoDB 查詢語言的啟發 ) 這個庫為 Object.assign 方案提供了簡單的語法糖,使得編寫淺拷貝代碼更加容易:

import update from 'immutability-helper';

const newData = update(myData, {
  x: {y: {z: {$set: 7}}},
  a: {b: {$push: [9]}}
});

const initialArray = [1, 2, 3];
const newArray = update(initialArray, {$push: [4]}); // => [1, 2, 3, 4]
initialArray // => [1, 2, 3]

可用命令

  • $push (類似于數組的 push,但是提供的是數組)
  • $unshift (類似于數組的 unshift,但是提供的是數組)
  • $splice (類似于數組的 splice, 但提供數組是一個數組, $splice: [ [1, 1, 13, 14] ] )

注意:數組中的項目是順序應用的,因此順序很重要。目標的索引可能會在操作過程中發生變化。

  • $toggle (字符串數組,切換目標對象的布爾數值)
  • $set (完全替換目標節點, 不考慮之前的數據,只用當前指令設置的數據)
  • $unset (字符串數組,移除 key 值(數組或者對象移除))
  • $merge (合并對象)
const obj = {a: 5, b: 3};
const newObj = update(obj, {$merge: {b: 6, c: 7}}); // => {a: 5, b: 6, c: 7}
  • $add(為 Map 添加 [key,value] 數組)
  • $remove (字符串對象,為 Map 移除 key)
  • $apply (應用函數到節點)
const obj = {a: 5, b: 3};
const newObj = update(obj, {b: {$apply: function(x) {return x * 2;}}});
// => {a: 5, b: 6}
const newObj2 = update(obj, {b: {$set: obj.b * 2}});
// => {a: 5, b: 6}

后面我們解析源碼時,可以看到不同指令的實現。

擴展命令

我們可以基于當前業務去擴展命令。如添加稅值計算:

import update, { extend } from 'immutability-helper';

extend('$addtax', function(tax, original) {
  return original + (tax * original);
});
const state = { price: 123 };
const withTax = update(state, {
  price: {$addtax: 0.8},
});
assert(JSON.stringify(withTax) === JSON.stringify({ price: 221.4 }));

如果您不想弄臟全局的 update 函數,可以制作一個副本并使用該副本,這樣不會影響全局數據:

import { Context } from 'immutability-helper';

const myContext = new Context();

myContext.extend('$foo', function(value, original) {
  return 'foo!';
});

myContext.update(/* args */);

源碼解析

為了加強理解,這里我來解析一下源代碼,同時該庫代碼十分簡潔強大:

先是工具函數(保留核心,環境判斷,錯誤警告等邏輯去除):

// 提取函數,大量使用時有一定性能優勢,且簡明(更重要)
const hasOwnProperty = Object.prototype.hasOwnProperty;
const splice = Array.prototype.splice;
const toString = Object.prototype.toString;

// 檢查類型
function type<T>(obj: T) {
  return (toString.call(obj) as string).slice(8, -1);
}

// 淺拷貝,使用 Object.assign 
const assign = Object.assign || /* istanbul ignore next */ (<T, S>(target: T & any, source: S & Record<string, any>) => {
  getAllKeys(source).forEach(key => {
    if (hasOwnProperty.call(source, key)) {
      target[key] = source[key] ;
    }
  });
  return target as T & S;
});

// 獲取對象 key
const getAllKeys = typeof Object.getOwnPropertySymbols === 'function'
  ? (obj: Record<string, any>) => Object.keys(obj).concat(Object.getOwnPropertySymbols(obj) as any)
  /* istanbul ignore next */
  : (obj: Record<string, any>) => Object.keys(obj);

// 所有數據的淺拷貝
function copy<T, U, K, V, X>(
  object: T extends ReadonlyArray<U>
    ? ReadonlyArray<U>
    : T extends Map<K, V>
      ? Map<K, V>
      : T extends Set<X>
        ? Set<X>
        : T extends object
          ? T
          : any,
) {
  return Array.isArray(object)
    ? assign(object.constructor(object.length), object)
    : (type(object) === 'Map')
      ? new Map(object as Map<K, V>)
      : (type(object) === 'Set')
        ? new Set(object as Set<X>)
        : (object && typeof object === 'object')
          ? assign(Object.create(Object.getPrototypeOf(object)), object) as T
          /* istanbul ignore next */
          : object as T;
}

然后是核心代碼(同樣保留核心) :

export class Context {
  // 導入所有指令
  private commands: Record<string, any> = assign({}, defaultCommands);

  // 添加擴展指令
  public extend<T>(directive: string, fn: (param: any, old: T) => T) {
    this.commands[directive] = fn;
  }
  
  // 功能核心
  public update<T, C extends CustomCommands<object> = never>(
    object: T,
    $spec: Spec<T, C>,
  ): T {
    // 增強健壯性,如果操作命令是函數,修改為 $apply
    const spec = (typeof $spec === 'function') ? { $apply: $spec } : $spec;

    // 數組(數組) 檢查,報錯
      
    // 返回對象(數組) 
    let nextObject = object;
    // 遍歷指令
    getAllKeys(spec).forEach((key: string) => {
      // 如果指令在指令集中
      if (hasOwnProperty.call(this.commands, key)) {
        // 性能優化,遍歷過程中,如果 object 還是當前之前數據
        const objectWasNextObject = object === nextObject;
        
        // 用指令修改對象
        nextObject = this.commands[key]((spec as any)[key], nextObject, spec, object);
        
        // 修改后,兩者使用傳入函數計算,還是相等的情況下,直接使用之前數據
        if (objectWasNextObject && this.isEquals(nextObject, object)) {
          nextObject = object;
        }
      } else {
        // 不在指令集中,做其他操作
        // 類似于 update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
        // 解析對象規則后繼續遞歸調用 update, 不斷遞歸,不斷返回
        // ...
      }
    });
    return nextObject;
  }
}

最后是通用指令:

const defaultCommands = {
  $push(value: any, nextObject: any, spec: any) {
    // 數組添加,返回 concat 新數組
    return value.length ? nextObject.concat(value) : nextObject;
  },
  $unshift(value: any, nextObject: any, spec: any) {
    return value.length ? value.concat(nextObject) : nextObject;
  },
  $splice(value: any, nextObject: any, spec: any, originalObject: any) {
    // 循環 splice 調用
    value.forEach((args: any) => {
      if (nextObject === originalObject && args.length) {
        nextObject = copy(originalObject);
      }
      splice.apply(nextObject, args);
    });
    return nextObject;
  },
  $set(value: any, _nextObject: any, spec: any) {
    // 直接替換當前數值
    return value;
  },
  $toggle(targets: any, nextObject: any) {
    const nextObjectCopy = targets.length ? copy(nextObject) : nextObject;
    // 當前對象或者數組切換
    targets.forEach((target: any) => {
      nextObjectCopy[target] = !nextObject[target];
    });

    return nextObjectCopy;
  },
  $unset(value: any, nextObject: any, _spec: any, originalObject: any) {
    // 拷貝后循環刪除
    value.forEach((key: any) => {
      if (Object.hasOwnProperty.call(nextObject, key)) {
        if (nextObject === originalObject) {
          nextObject = copy(originalObject);
        }
        delete nextObject[key];
      }
    });
    return nextObject;
  },
  $add(values: any, nextObject: any, _spec: any, originalObject: any) {
    if (type(nextObject) === 'Map') {
      values.forEach(([key, value]) => {
        if (nextObject === originalObject && nextObject.get(key) !== value) {
          nextObject = copy(originalObject);
        }
        nextObject.set(key, value);
      });
    } else {
      values.forEach((value: any) => {
        if (nextObject === originalObject && !nextObject.has(value)) {
          nextObject = copy(originalObject);
        }
        nextObject.add(value);
      });
    }
    return nextObject;
  },
  $remove(value: any, nextObject: any, _spec: any, originalObject: any) {
    value.forEach((key: any) => {
      if (nextObject === originalObject && nextObject.has(key)) {
        nextObject = copy(originalObject);
      }
      nextObject.delete(key);
    });
    return nextObject;
  },
  $merge(value: any, nextObject: any, _spec: any, originalObject: any) {
    getAllKeys(value).forEach((key: any) => {
      if (value[key] !== nextObject[key]) {
        if (nextObject === originalObject) {
          nextObject = copy(originalObject);
        }
        nextObject[key] = value[key];
      }
    });
    return nextObject;
  },
  $apply(value: any, original: any) {
    // 傳入函數,直接調用函數修改
    return value(original);
  },
};

就這樣,作者寫了一個簡潔而強大的淺拷貝輔助庫。

優秀的 Immer 庫

Immer 是一個非常優秀的不可變數據庫,利用 proxy 來解決問題。不需要學習其他 api,開箱即用 ( gzipped 3kb )

import produce from "immer"

const baseState = [
  {
    todo: "Learn typescript",
 done: true
 },
 {
    todo: "Try immer",
 done: false
 }
]

// 直接修改,沒有任何開發負擔,心情美美噠
const nextState = produce(baseState, draftState => {
  draftState.push({todo: "Tweet about it"})
  draftState[1].done = true
})

關于 immer 性能優化請參考 immer performance。

核心代碼分析

該庫的核心還是在 proxy 的封裝,所以不全部介紹,僅介紹代理功能。

export const objectTraps: ProxyHandler<ProxyState> = {
  get(state, prop) {
    // PROXY_STATE是一個symbol值,有兩個作用,一是便于判斷對象是不是已經代理過,二是幫助proxy拿到對應state的值
    // 如果對象沒有代理過,直接返回
    if (prop === DRAFT_STATE) return state

    // 獲取數據的備份?如果有,否則獲取元數據
    const source = latest(state)

    // 如果當前數據不存在,獲取原型上數據
    if (!has(source, prop)) {
      return readPropFromProto(state, source, prop)
    }
    const value = source[prop]

    // 當前代理對象已經改回了數值或者改數據是 null,直接返回
    if (state.finalized_ || !isDraftable(value)) {
      return value
    }
    // 創建代理數據
    if (value === peek(state.base_, prop)) {
      prepareCopy(state)
      return (state.copy_![prop as any] = createProxy(
        state.scope_.immer_,
        value,
        state
      ))
    }
    return value
  },
  // 當前數據是否有該屬性
  has(state, prop) {
    return prop in latest(state)
  },
  set(
    state: ProxyObjectState,
    prop: string /* strictly not, but helps TS */,
    value
  ) {
    const desc = getDescriptorFromProto(latest(state), prop)

    // 如果當前有 set 屬性,意味當前操作項是代理,直接設置即可
    if (desc?.set) {
      desc.set.call(state.draft_, value)
      return true
    }

    // 當前沒有修改過,建立副本 copy,等待使用 get 時創建代理
    if (!state.modified_) {
      const current = peek(latest(state), prop)

      const currentState: ProxyObjectState = current?.[DRAFT_STATE]
      if (currentState && currentState.base_ === value) {
        state.copy_![prop] = value
        state.assigned_[prop] = false
        return true
      }
      if (is(value, current) && (value !== undefined || has(state.base_, prop)))
        return true
      prepareCopy(state)
      markChanged(state)
    }

    state.copy_![prop] = value
    state.assigned_[prop] = true
    return true
  },
  defineProperty() {
    die(11)
  },
  getPrototypeOf(state) {
    return Object.getPrototypeOf(state.base_)
  },
  setPrototypeOf() {
    die(12)
  }
}

// 數組的代理,把當前對象的代理拷貝過去,再修改 deleteProperty 和 set
const arrayTraps: ProxyHandler<[ProxyArrayState]> = {}
each(objectTraps, (key, fn) => {
  // @ts-ignore
  arrayTraps[key] = function() {
    arguments[0] = arguments[0][0]
    return fn.apply(this, arguments)
  }
})
arrayTraps.deleteProperty = function(state, prop) {
  if (__DEV__ && isNaN(parseInt(prop as any))) die(13)
  return objectTraps.deleteProperty!.call(this, state[0], prop)
}
arrayTraps.set = function(state, prop, value) {
  if (__DEV__ && prop !== "length" && isNaN(parseInt(prop as any))) die(14)
  return objectTraps.set!.call(this, state[0], prop, value, state[0])
}

其他

開發過程中,我們往往會在 React 函數中使用 useReducer 方法,但是 useReducer 實現較為復雜,我們可以用 useMethods 簡化代碼。useMethods 內部就是使用 immer (代碼十分簡單,我們直接拷貝 index.ts 即可)。

不使用 useMethods 情況下:

const initialState = {
  nextId: 0,
  counters: []
};

const reducer = (state, action) => {
  let { nextId, counters } = state;
  const replaceCount = (id, transform) => {
    const index = counters.findIndex(counter => counter.id === id);
    const counter = counters[index];
    return {
      ...state,
      counters: [
        ...counters.slice(0, index),
        { ...counter, count: transform(counter.count) },
        ...counters.slice(index + 1)
      ]
    };
  };

  switch (action.type) {
    case "ADD_COUNTER": {
      nextId = nextId + 1;
      return {
        nextId,
        counters: [...counters, { id: nextId, count: 0 }]
      };
    }
    case "INCREMENT_COUNTER": {
      return replaceCount(action.id, count => count + 1);
    }
    case "RESET_COUNTER": {
      return replaceCount(action.id, () => 0);
    }
  }
};

對比使用 useMethods :

import useMethods from 'use-methods';    

const initialState = {
  nextId: 0,
  counters: []
};

const methods = state => {
  const getCounter = id => state.counters.find(counter => counter.id === id);

  return {
    addCounter() {
      state.counters.push({ id: state.nextId++, count: 0 });
    },
    incrementCounter(id) {
      getCounter(id).count++;
    },
    resetCounter(id) {
      getCounter(id).count = 0;
    }
  };
};

鼓勵一下

如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 博客下幫忙 star 一下。

博客地址

參考資料

immutability-helper

Immer

useMethods

查看原文

贊 3 收藏 2 評論 0

jump__jump 贊了文章 · 1月13日

【干貨】使用 CSS Scroll Snap 優化滾動,提升用戶體驗!

作者:Ahmad
譯者:前端小智
來源:ishadee
點贊再看,微信搜索大遷世界,B站關注【前端小智】這個沒有大廠背景,但有著一股向上積極心態人。本文 GitHubhttps://github.com/qq44924588... 上已經收錄,文章的已分類,也整理了很多我的文檔,和教程資料。

最近開源了一個 Vue 組件,還不夠完善,歡迎大家來一起完善它,也希望大家能給個 star 支持一下,謝謝各位了。

github 地址:https://github.com/qq44924588...

你是否經常希望有一個CSS特性可以輕松創建一個可滾動的容器? CSS scroll snap 可以做到這一點。在早期的前端開發中,我依靠 JS 插件來創建滑塊組件。有時,我們需要一種簡單的方法來快速將元素制作成可滾動的容器?,F在,多虧了 CSSS scroll snap ,我們可以簡單做到這一點。

為什么要使用 CSS Scroll Snap

隨著移動設備和平板設備的興起,我們需要設計和構建可以輕觸的組件。 以圖庫組件為例。 用戶可以輕松地向左或向右滑動以查看更多圖像,而不是分層結構。

clipboard.png

根據CSS規范,為開發者提供良好控制的滾動體驗是引入 CSS scroll snap的主要原因之一。它增強了用戶體驗,并使其更容易實現滾動體驗。

滾動容器的基礎知識

要創建一個滾動容器,以下是我們需要做的基本內容

  • 使用 overflow
  • 一種將項目彼此相鄰顯示(內聯)的方法

舉個例子:

<div class="section">
  <div class="section__item">Item 1</div>
  <div class="section__item">Item 2</div>
  <div class="section__item">Item 3</div>
  <div class="section__item">Item 4</div>
  <div class="section__item">Item 5</div>
</div>
.section {
  white-space: nowrap;
  overflow-x: auto;
}

多年來,使用white-space: nowrap是一種流行的CSS解決方案,用于強制元素保持內聯。不過,現在我們基本都使用 Flexbox :

.section {
  display: flex;
  overflow-x: auto;
}

clipboard.png

這是創建滾動容器的基本方法。然而,這還不夠,這不是一個可用的滾動容器。

滾動容器有什么問題

問題是,與滑動相比,它們并不能提供良好的體驗。在觸摸屏上滑動手勢的主要好處是,我們可以用一根手指水平或垂直滾動。

圖片描述

實際上需要將每個項目移動到它自己的位置。這并不是滑動,這是一種非常糟糕的體驗,通過使用CSS scroll snap,我們可以通過簡單地定義snap points來解決這個問題,它將使用戶更容易地水平或垂直滾動。

接著,我們來看看如何使用CSS scroll snap。

CSS Scroll Snap 簡介

要在容器上使用scroll snap,它的子項目應該內聯顯示,這可以用我上面解釋的方法之一來實現。我選擇CSS flexbox:

<div class="section">
  <div class="section__item">Item 1</div>
  <div class="section__item">Item 2</div>
  <div class="section__item">Item 3</div>
  <div class="section__item">Item 4</div>
  <div class="section__item">Item 5</div>
</div>
.section {
  display: flex;
  overflow-x: auto;
}

了這個,我們需要添加另外兩個屬性來讓scroll snap工作。我們應該在哪里添加它們?

首先,我們需要將scroll-snap-type添加到滾動容器中。 在我們的示例中,是.section元素。 然后,我們需要向子項(即.section__item)添加scrolln-snap-align。

.section {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

.section__item {
  scroll-snap-align: start;
}

這里你可能想知道x mandatorystart是干嘛用的。 不用擔心,這是本文的核心,下面會對其進行深入的講解。

圖片描述

這一刻,我對CSS scroll snap非常興奮,它使滾動更加自然?,F在,讓我們深入研究scroll snap 屬性。

Scroll Snap Type

根據CSS規范,scroll-snap-type 屬性定義在滾動容器中的一個臨時點(snap point)如何被嚴格的執行。

滾動容器的軸線

滾動容器的軸表示滾動方向,它可以是水平或垂直的,x值表示水平滾動,而y表示垂直滾動。

/* 水平*/
.section {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x;
}

/* 垂直*/
.section {
  height: 250px;
  overflow-y: auto;
  scroll-snap-type: y;
}

clipboard.png

Scroll Snap 容器的嚴格性

我們不僅可以定義Scroll Snap的方向,還可以定義它的嚴格程度。這可以通過使用scroll-snap-type值的andatory | proximity來實現。

mandatory:如果它當前沒有被滾動,這個滾動容器的可視視圖將靜止在臨時點上。意思是當滾動動作結束,如果可能,它會臨時在那個點上。如果內容被添加、移動、刪除或者重置大小,滾動偏移將被調整為保持靜止在臨時點上。

mandatory關鍵字意味著瀏覽器必須捕捉到每個滾動點。假設roll-snap-align屬性有一個start值。這意味著,滾動必須對齊到滾動容器的開始處。

在下圖中,每次用戶向右滾動時,瀏覽器都會將項目捕捉到容器的開頭。

clipboard.png

.section {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

.section__item {
  scroll-snap-align: start;
}

圖片描述

試著在下面的演示中向右滾動。如果你使用的是手機或平板電腦,可以向右移動滾動條或使用觸摸。應該能感受到每個項目是如何從其容器的開始抓取的。

演示地址:https://codepen.io/shadeed/pe...

但是,如果該值是proximity,則瀏覽器將完成這項工作,它可能會吸附到定義的點(在我們的例子中start)。注意,proximity 是默認值,但是為了清晰起見,我們這里還是聲明一下它。

clipboard.png

.section {
  display: flex;
  overflow-x: auto;
  /* proximity is the default value, I added it for clarity reasons */
  scroll-snap-type: x proximity;
}

圖片描述

Scroll Snapping Alignment

滾動容器的子項目需要一個對齊點,它們可以對齊到這個點。我們可以用start, centerend。

為了更容易理解,下面是它的工作原理。

clipboard.png

假設我們在滾動容器上有一塊磁鐵,這將有助于我們控制捕捉點。 如果scroll-snap-type是垂直的,則對齊對齊將是垂直的。 參見下圖:

clipboard.png

滾動容器的 start

子項目將吸附到其水平滾動容器的開始處。

圖片描述

滾動容器的 center

子項目將吸附到其滾動容器的中心。

圖片描述

滾動容器的 end

子項將對齊到其滾動容器的末尾。

圖片描述

使用 Scroll-Snap-Stop

有時,我們可能需要一種方法來防止用戶在滾動時意外跳過一些重要的項。如果用戶滾動太快,就有可能跳過某些項。

.section__item {
  scroll-snap-align: start;
  scroll-snap-stop: normal;
}

法動太快可能會跳過三個或四個項目,如下所示:

圖片描述

scroll-snap-stop的默認值是normal,要強制滾動捕捉到每個可能的點,應使用always。

.section__item {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

圖片描述

這樣,用戶可以一次滾動到一個捕捉點,這種方式有助于避免跳過重要內容。 想象每個停止點都有一個停止標志,參見下面的動畫:

圖片描述

演示地址:https://codepen.io/shadeed/pe...

Scroll Snap Padding

scroll-padding設置所有側面的滾動邊距,類似于padding屬性的工作方式。 在下圖中,滾動容器的左側有50px的內邊距。 結果,子元素將從左側邊緣捕捉到50px

clipboard.png

直滾動也是如此。參見下面的示例:

.section {
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  scroll-padding: 50px 0 0 0;
}

clipboard.png

Scroll Snap Margin

scroll-margin設置滾動容器的子項之間的間距。 在向元素添加邊距時,滾動將根據邊距對齊。 參見下圖:

clipboard.png

.item-2具有scroll-margin-left: 20px。 結果,滾動容器將在該項目之前對齊到20px。 請注意,當用戶再次向右滾動時,.item-3會捕捉到滾動容器的開頭,這意味著僅具有邊距的元素將受到影響。

CSS Scroll Snap 用例

圖片列表

scroll snap 的一個很好的用例是圖像列表,使用 scroll snap 提供更好的滾動體驗。

clipboard.png

.images-list {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x;
  gap: 1rem;
  -webkit-overflow-scrolling: touch; /* Important for iOS devices */
}

.images-list img {
  scroll-snap-align: start;
}

注意,我使用x作為scroll-snap-type的值。

圖片描述

事例地址:https://codepen.io/shadeed/pe...

好友清單

滾動捕捉的另一個很好的用例是朋友列表。 下面的示例摘自Facebook(一個真實的示例)。

clipboard.png

.list {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  gap: 1rem;
  scroll-padding: 48px;
  padding-bottom: 32px;
  -webkit-overflow-scrolling: touch;
}

.list-item {
  scroll-snap-align: start;
}

請注意,滾動容器的padding-bottom:32px。 這樣做的目的是提供額外的空間,以便box-shadow可以按預期顯示。

clipboard.png

頭像列表

對于此用例,我感興趣的是將center作為scroll-snap-align的值。

clipboard.png

.list {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
}

.list-item {
  scroll-snap-align: center;
}

這在一個角色列表中是很有用的,角色在滾動容器的中間是很重要的

圖片描述

演示地址:https://codepen.io/shadeed/pe...

全屏展示

使用scroll snap也可以用于垂直滾動,全屏展示就是一個很好的例子。

clipboard.png

<main>
  <section class="section section-1"></section>
  <section class="section section-2"></section>
  <section class="section section-3"></section>
  <section class="section section-4"></section>
  <section class="section section-5"></section>
</main>
main {
  height: 100vh;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  -webkit-overflow-scrolling: touch;
}

.section {
  height: 100vh;
  scroll-snap-align: start;
}

圖片描述

塊和內聯

值得一提的是,對于scroll-snap-type,可以使用inlineblock邏輯值。參見下面的示例

main {
  scroll-snap-type: inline mandatory;
}

可讀性

使用 CSS scroll snap時,請確??稍L問性。 這是滾動對齊的一種不好用法,它阻止用戶自由滾動內容以讀取內容。

.wrapper {
  scroll-snap-type: y mandatory;
}

h2 {
  scroll-snap-align: start;
}

clipboard.png

圖片描述

請務必不要這樣做。

總結

這是我剛剛學到的一個新的CSS特性的長篇文章。我希望它對你有用。

我是小智,我們下期在見!


代碼部署后可能存在的BUG沒法實時知道,事后為了解決這些BUG,花了大量的時間進行log 調試,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。

原文:https://ishade.com/article/cs...

交流

文章每周持續更新,可以微信搜索 【大遷世界 】 第一時間閱讀,回復 【福利】 有多份前端視頻等著你,本文 GitHub https://github.com/qq449245884/xiaozhi 已經收錄,歡迎Star。

查看原文

贊 26 收藏 17 評論 2

jump__jump 贊了文章 · 1月13日

一個20年技術老兵的 2020 年度技術總結

大家好!我是 go-zero 作者 Kevin。充滿驚嚇的 2020 快要過去了,看到掘金上的技術人年度征文,忍不住文字記錄一下艱辛而又充滿收獲的 2020 ??

疫情開始

春節假期疫情突然升級,我們面臨著自身平臺的轉型升級。作為曉黑板CTO,有兩個重點工作:

  • 保證大規模使用場景下平臺的穩定性
  • 保證轉型所需的新業務能夠快速交付

團隊壓力巨大的同時也感受到了前所未有的戰斗熱情,養兵千日用兵一時,不經歷戰與火的洗禮,怎么知道團隊的技術能力是否能夠經受得住流量洪峰的考驗。

戰斗開始,迅速落實業務團隊進行急需功能的開發,并行安排架構團隊進行技術隱患排查、演練、攻關。

在大概兩個月的時間里,我們基本一日三餐都在電腦桌前,困了就睡覺,醒來寫代碼(當然還有必要的開會),這真是人生一段非常難忘的特殊經歷。。。

開始踩坑

隨著所需功能的極速上線,我們馬上開始了大規模壓測,大坑如下:

  • 大量請求失敗,然而服務端壓力一切正常,一頓排查,發現原來是進到內網的請求被 nginx 轉發時又打到外網了,而外網我們是啟動了 WAF(Web Access Firewall),WAF 會認為所有用戶都來自我們內網的那些 IP,這“明顯”是攻擊嘛,于是 drop 了大量請求,由此,我們指定了規則:進到內網的請求不允許轉發到外網。
  • 為了快速實現功能,有同學用 nodejs 實現了部分功能,部署到 k8s 集群里,流量一起來,nodejs pod 立馬扛不住,再加上難以控制的內存泄露,讓我們迅速決定不再允許使用 nodejs 做后端,使用 nodejs 純屬“意外”。
  • 某云廠商 oss 存儲用的 LSM Tree 方式實現,在小文件突發增加時無法及時分裂,導致我們訪問量大時出現兩次 oss 訪問故障。后來我們自己多申請了幾個 bucket 來從代碼層分散文件存儲請求。

實戰效果

經過前后一個月開發、壓測和開學前演練,我們的系統基本滿足開學需求了,接下來就是接受實戰檢驗了。

開學第一天,我們遇到的第一個問題部分服務供應商無法承載流量壓力,雖然我們之前盤算過,也充分交流過,但還是未能預料到洪峰流量的兇猛,服務商緊急增加資源得以解決。

然后我們消息分類服務的 ElasticSearch 集群壓力過大,擴容的同時,發現調用代碼未加熔斷保護,直接把 ElasticSearch 集群壓死了,里面加上熔斷保護,幾行代碼就好了,自適應熔斷保護工具包見 這里。

經過第一周的密集爆發式流量的考驗,我們總體很穩定。為此還得到了有關部門的感謝信,相比友商,我們的服務穩定性還是相當不錯的。后續服務穩定性上基本可以用波瀾不驚來形容。至此,go-zero (雖然此時還不叫 go-zero)算是經受了充分的實戰檢驗 ??

走向開源

7月份在跟集團技術通道老師的交流過程中得到了充分的肯定,集團開源通道推動和幫助我把底層微服務支撐框架對外開源。

在8.7日深夜,我完成了 github 代碼的第一次提交,此時文檔僅有我臨時寫出來的一頁 readme,為啥只有一頁 readme 就選擇開源了呢?我覺得萬事開頭難,如果決定把文檔都寫完再開源出來的話,可能這事就擱置了,所以還是先讓球滾起來吧!

一經開源,社區立馬給了我們比較熱烈的反饋,更推動了我們去快速完成文檔。我們在一個周末就補充了大量的使用文檔,提供了比較完整的示例 shorturlbookstore。后面大部分開發者都通過這兩個例子感受到了 go-zero 的便捷和工程效率。感謝大家給了我們很多對示例的改進意見。

8月16日,go夜讀的分享 系統的講述了 go-zero 背后的故事和設計思考,獲得了很多觀眾的留言認可。至今依然有不少人針對這個視頻給我積極的反饋。感謝大家的認可!

8月24日,gocn報道,讓 gopherchina 社區第一次大規模的了解了 go-zero。社區開始有大量gopher的加入,微信群人數迅速增長。

9月開始,go-zero 多次出現在 github Go 語言日榜月榜頂部,如圖:

日榜月榜
daymonth

同時不少家公司將 go-zero 用于生產,并跟我反饋上線后一直平穩運行,其中不乏日活過百萬的平臺。

10月獲得了 gitee 最有價值項目(GVP),并接著獲得了開源中國年度 最佳人氣項目獎項。

11月22日,我在 gopherchina 大會做了『云原生go-zero微服務框架的設計思考』的主題分享,現場氣氛非常熱烈,據說門口堵滿了進不來了,獲得了很多資深開發者的認可,知乎評論見 這里,其中提到的我的年齡不對哈??,部分現場圖如下:

分享觀眾
talkingaudience

12月20日,應邀參加騰訊云開發者大會,做了『轉型之后 - 面對流量洪峰,微服務架構如何進行彈性設計?』的分享,如下:

開始大綱
talkingaudience

在掘金發了 20+ 篇 go-zero 系列文章,跟用戶詳細分享了微服務框架設計的原理和實現,詳見 這里。

社區的認可

近 3000 人的微信社區,每天熱烈的技術討論和用戶之間的相互幫助,已經形成了良好的社區氛圍。我們也從中獲得很多的用戶反饋,為我們進一步加強 go-zero 指明了方向!??

github star 正常每月增長 1000 左右,平均每天 33+ stars,現在 5300+,增長曲線如下:

image.png

再次復盤

  1. 用戶到底想要什么樣的框架?

    • 首先,能夠寫更少代碼解決業務需求。更少的代碼意味著更快的產出,更少的bug。
    • 其次,框架是否穩定,有沒經過實戰檢驗。畢竟很少人愿意當小白鼠的。
    • 再次,社區是否活躍,遇到問題是否能夠快速得到解決。
  2. 用戶為什么喜歡 go-zero?

    • 全面的微服務治理能力
    • 內置 goctl 工具幫助用戶盡可能只關注業務代碼
    • go-zero 經過了我們線上海量并發實戰檢驗
    • 活躍的社區,用戶的互相解答,go-zero 團隊的及時跟進

2021年技術展望

  • 研發團隊工程效率帶上新臺階,期望讓大家產出更高的同時也能有更好的能力提升
  • 期望進一步加強 go-zero 的工程效率提升,讓開發者編寫更少的代碼(業務代碼)就能擁有穩定的微服務系統
  • 一個小目標:一年一萬星 ??

項目地址

https://github.com/tal-tech/go-zero

歡迎大家使用 go-zerostar 支持我們!??

致謝

真心感謝一直支持我們的大佬們,以及眾多使用 go-zero 的 gopher 們,之所以不列名單,實在是幫助過我們的人太多了,生怕一不小心就遺漏了某位大佬 ??

項目地址:
https://github.com/tal-tech/go-zero
查看原文

贊 11 收藏 2 評論 3

jump__jump 關注了用戶 · 1月13日

kevinwan @kevinwan

go-zero作者

關注 2636

jump__jump 關注了用戶 · 1月13日

政采云前端團隊 @zhengcaiyunqianduantuandui

Z 是政采云拼音首字母,oo 是無窮的符號,結合 Zoo 有生物圈的含義。寄望我們的前端 ZooTeam 團隊,不論是人才梯隊,還是技術體系,都能各面兼備,成長為一個生態,卓越且持續卓越。

政采云前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬于政采云產品研發部,Base 在風景如畫的杭州。團隊現有 50 余個前端小伙伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自于阿里、網易的“老”兵,也有浙大、中科大、杭電等校的應屆新人。團隊在日常的業務對接之外,還在物料體系、工程平臺、搭建平臺、性能體驗、云端應用、數據分析及可視化等方向進行技術探索和實戰,推動并落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。

關注 1599

jump__jump 發布了文章 · 1月13日

使用 AVIF 圖片格式

文字需要翻譯,圖片不用。在圖片的世界,不管是中國人、印度人、美國人、英國人的笑,全世界的人都能明白那是在笑。圖片所承載的情感是全球通明的。

眾所周知,一圖勝千言,圖片對于視覺的沖擊效果遠大于文字。但對于我們的互聯網而言,傳輸與解析一張圖片的代價要遠比"千言"大的多的多(目前上億像素已經成為主流)。

面對動輒 10 多 M 的大型圖片,使用優化的圖像來節省帶寬和加載時間無疑是性能優化中的重頭戲,無論對于用戶還是公司都有巨大的意義。因為對于用戶來說,可以更早的看到圖片,對于公司而言,更加省錢。

在不使用用戶提供的圖片時,最簡單就可以使用 tinypng 網站針對各個圖片進行圖像壓縮與優化。在減少了近 50% 大小的同時做到了肉眼無法區分,收益是非常大的。

AVIF 介紹

當然,目前最值得關注的新型圖片格式是 AVIF(AV1 Image File Format,AV1圖像文件格式,是業界最新的開源視頻編碼格式AV1的關鍵幀衍生而來的一種新的圖像格式。AVIF 來自于 Netflix(著名的流媒體影視公司), 在 2020 年情人節公布。

當遇到新的技術時候,我們總是要考慮兼容問題,話不多說,我們打開 caniuse 。

image

就這?就這?是的,雖然當前的瀏覽器支持情況堪憂,但是開發者為了瀏覽器提供了 4kb 的 polyfill:

在使用 avif 后,我們可以使用的瀏覽器版本:

  • Chrome 57+
  • Firefox 53+
  • Edge 17+
  • Safari 11+

該格式的優勢在于:

  • 權威
    AVIF 圖片格式由開源組織 AOMedia 開發,Netflix、Google 與 Apple 均是該組織的成員, 所以該格式的未來也是非常明朗的。
  • 壓縮能力強
    在對比中發現 AVIF 圖片格式壓縮很棒,基本上大小比 JPEG 小 10 倍左右且具有相同的圖像質量。
  • polyfill
    面對之前瀏覽器無力情況提供 polyfill,為當前狀況下提供了可用性

如果是技術性網站或某些 Saas 產品就可以嘗試使用。

使用 Sharp 生成 AVIF

Sharp 是一個轉換格式的 node 工具庫, 最近該庫提供了對 AVIF 的支持。

我們可以在 node 中這樣使用:

const sharp = require("sharp");
const fs = require("fs");

fs.readFile("xxx.jpeg", (err, inputBuffer) => {
  if (err) {
    console.error(err);
    return;
  }

  // WebP
  sharp(inputBuffer)
    .webp({ quality: 50, speed: 1 })
    .toFile("xxx.webp");

  // AVIF 轉換, 速度很慢
  sharp(inputBuffer)
    .avif({quality: 50, speed: 1})
    .toFile("xxx.avif");
});

在后端傳入 jpg,png 等通用格式,這樣我們便可以在瀏覽器中直接使用 AVIF。

雖然 AVIF 是面向未來的圖片格式,但是就目前來說,在開發需要大量圖片的業務時,使用專業的 OSS 服務和 CDN 才是更好的選擇。

由于 OSS 服務支持jpg、png、bmp、gif、webp、tiff等格式的轉換,以及縮略圖、剪裁、水印、縮放等多種操作,這樣就可以更簡單的根據不同設備(分辨率)提供不同的圖片。同時 CDN 也可以讓用戶更快的獲取圖片。

鼓勵一下

如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 博客下幫忙 star 一下。
博客地址

參考資料

node-avif

tinypng

Sharp

查看原文

贊 4 收藏 3 評論 0

jump__jump 贊了文章 · 1月8日

從零到一:實現通用一鏡到底H5

圖片描述

寫在前面

整個2018年都被工作支配,文章自17年就斷更了,每次看到有消息提示過往的文章被收藏,或者有人點贊,都不勝唏噓。不過,凡事要始終保持積極的心態,現在回歸為時未晚。最近有項目要做一鏡到底,那就稍作研究吧。

一鏡到底是什么?

百度百科-一鏡到底
一鏡到底,是指拍攝中沒有cut情況,運用一定技巧將作品一次性拍攝完成。

那么運用到H5上面,是怎樣的表現?網上案例也有很多,這里推薦數英的一篇文章,應用盡有:

一鏡到底H5大合集:一口氣看盡一個H5的套路

主要表現形式為以下幾類:

  • 畫中畫(例如:網易-《娛樂畫傳》)
  • 時空穿梭(例如:天貓-《穿越時空的邀請函》)
  • 滾動動畫(例如:網易-《愛的形狀》)
  • 視頻(這個好像沒什么好說的...跟本文無關)

體驗方式主要有:

  • 按住
  • 滑動

技術需求分析

圖片描述

如上圖的《愛的形狀》,用戶滑動屏幕,方塊滾動,并且用戶能控制播放進度;期間方塊上面的紋理一直在變化,意味著動畫一直在播放。

提取要點,要實現一個一鏡到底H5,通常會有以下技術需求:

  1. 繪制畫面:這里我們一般選用基于canvas的庫,性能會更好,也方便實現效果。
  2. 添加動畫:其中包括過渡、幀動畫,因此需要一個動畫庫。
  3. 進度控制:要實現通過用戶操作,來控制整個H5的前進、后退、暫停等,我們會需要進度控制相關的庫。
  4. 用戶操作:一鏡到底的H5一般都需要用戶操作以“播放”,按住或滑動,按住比較簡單,用原生事件實現就好,滑動相對復雜一點,涉及阻尼運動,因此需要一個滑動相關的庫。

有了需求,我們就可以相應去找解決方案了。繪圖有用3D的threejs的,動畫有人用anime.js,各有所好,能實現需求就行。

最終針對以上需求,我選用了AlloyTouch、TimelineMax、Pixi.js、TweenMax這幾個庫來實現通用的一鏡到底。各個框架的優點這里就不贅述了,想了解詳情的可以自行搜索,幾乎都有中文資料,也很容易使用。

實現步驟要點

  1. 用Pixi創建場景,加入到想要加入的DOM容器當中。
  2. 用Pixi.loader加載精靈圖。
  3. 將精靈圖、背景及文本等元素繪制出來。
  4. 用TimelineMax創建一個總的Timeline,初始設置paused為true,可以設定整條Timeline的長度為1。
  5. 用TweenMax創建好過渡動畫,然后將TweenMax加入到Timeline中,duration比如是占10%的話,就設定為0.1,從滾動到30%開始播放動畫的話,delay就設置為0.3。
  6. 用AlloyTouch創建滾動監聽,并設置好滾動高度,例如1000。
  7. 監聽AlloyTouch的change事件,用當前滾動值 / 滾動高度 得到當前頁面的進度。
  8. 調用總Timeline的seek方法,尋找到當前頁面進度的地方,可以簡單理解成撥動視頻播放器的進度條滑塊。
  9. 至此就可以通過用戶滑動操作,控制頁面元素的動畫播放、后退了。

你可能會問那怎樣實現上面說的幾種類型的一鏡到底?實際上,幾種類型的不同只是動畫變換方式不一樣而已。

  • 畫中畫(縮放同時平移)
  • 時空穿梭(以中心縮放)
  • 滾動動畫(平移為主,期間播放其他動畫)

示例項目

簡單寫了個demo,如果感興趣的朋友可以拉下來自己把玩一下。

點此查看倉庫

點此查看demo

圖片描述

(注:項目中素材來源于網絡,僅供交流學習使用,切勿商用?。?/p>

展望

這里只實現了一個一鏡到底H5的主要效果部分,距離完成還有很多工作:

  • 微信分享設置及引導
  • 添加一個加載界面
  • 添加音樂音效(用過howler,感覺不錯)
  • 可能需要的生成海報(html2canvas,生成海報easy job)
  • ...

結語

這次沒有用太多篇幅鋪開來講細節,主要是運用幾個庫組合來實現,而實際操作上還有很多地方要注意的,例如幀動畫的實現,滑動的速度控制,滑到頂部、底部的處理等等。實際應用上還要繼續打磨,畢竟一個漂亮的H5,是要花很多精力去構思,去優化體驗的。

最后也希望能認識到更多在H5領域有研究的小伙伴,可以互相交流,甚至一起工作!

email: vincent@shikehuyu.com

查看原文

贊 85 收藏 61 評論 39

jump__jump 贊了文章 · 2020-12-31

如何校驗 email 地址以提高郵件送達率

背景

在發送 email 的時候,如果郵件收件人是一個不存在的 email 賬號、或者收件人賬號存在問題、收件箱無法接收 email, 那么 email server 就會將該無法接收的信息響應回來, 這種情況稱之為 bounce email,對應的衡量指標是 bounce 率。bounce email 是影響郵件送達率(email delivery rate)的一個重要因素。根據 Sendgrid 統計結果, bounce 率在 5% 以上,送達率為71%;但如果 bounce 率在2%或以下,平均送達率可以提高到91%。

目前我們平臺每個月郵件發送量在千萬封左右,包括通知類和營銷類郵件,其中 marketing campaigns 占了大部分。 因為 marketing campaigns 會讓客戶自定義 contacts,這部分是潛在 bounce email 的一個風險,所以在發送郵件前檢測收件人 email 地址是否可送達,過濾掉其中的垃圾和無效的 email 地址,可以有效減少 bounce rate。這篇文章我們會詳細介紹如何通過校驗 email 地址以及最佳實踐 , 來提高郵件送達率。

為什么 Bounce 影響 email 送達率

上面 Sendgrid 對 bounce email 的統計數據, 可以較明顯地看出 bounce 率和送達率的相關關系。但其中的相關性不僅僅只是 bounce 占了總發送郵件數的比例大才影響送達率,而是 bounce 率高會進而影響到正常用戶的郵件送達。

每一個 email 賬號都有一個發件人信譽的分數(reputation),來幫助收件人的 email 服務提供商(ESP)決定郵件的質量。分數越高,郵件的送達率也會越高,反之亦然。如果頻繁的 bounce ,會導致收件的 Email Server “質疑” 發件人郵箱賬號是否為真實賬號,當到達一定程度, 該 sender 賬號會被列入各種 ESP 的垃圾郵件索引,最后發送給其他用戶就會被 blocked。并且 bounce 會影響發件人 domain 和 ip 的 reputation。

所以 email bounces 可以說是 marketing campaigns 的一個“噩夢”。校驗 email 地址有助于將郵件發送給正確的收件人,同時使 email 帳戶保持可用狀態,并提高 reputation。對業務來說,也會提升 email campaign 的質量。

如何校驗 email 地址

完整的 email 地址的校驗過程主要包括以下4個維度:

  1. 語法檢查
  2. 檢查是否為一次性郵箱(disposable)
  3. 確認 domain 對應 DNS 記錄是否存在
  4. Ping 收件人郵箱

語法檢查

拼寫的語法錯誤是 email 地址檢查最常見的問題之一。 根據常用的 email 地址正則表達式,可以確認出地址是否有格式問題。一般檢查的表達式類似于 abc@aftership.com, 包括3個部分: local part 、@分隔符 和 domain。

較重點檢查的是 local part 部分,由以下3部分組成:

  • 字母數字 – A 到 Z(包括大小寫), 0-9
  • 可打印字符 – !#$%&'*+-/=?^_~`
  • 一個標點符號. – 但是 local part 不能以 . 開頭或結尾、或者連續使用,比如 example..dot@aftership.com是一個非法的 email 地址。

不同的 email 服務提供商對 local part 有不同的規定,這里 mailgun 提供了一份常見 ESP 的 校驗規則。

domain 跟對域名的命名約定是一致的:包括只使用字母數字和-符號且不能連續使用。

除了根據正則表達式對 email 地址做檢查,還需要考慮的一些點是 IETF 標準non-ASCII domains。

檢查是否為一次性郵箱

一次性郵箱是指那些小段時間內有效的 email 地址,被稱作 disposable email。 disposable email 是一個正確語法的地址,且可以接收和發送 email, 正常只能用一次,一般是用來注冊新賬號繞過登錄、發送惡意郵件等場景。

常見的 disposable email 提供商包括 Nada 和 Mailinator 等。識別它們的方法是判斷 domain 是否為disposable domain。目前開源社區有維護一些實時更新的 disposable domain 列表, 通過在列表里搜索 domian 的方式快速過濾掉 diposable email。

確認 domain 對應 DNS 記錄是否存在

DNS 查詢是指向 DNS 服務器請求 DNS 記錄的過程。DNS 記錄包括多種 domain 記錄,這里我們主要確認 MX record(_mail exchanger record, 郵件交換記錄_)。該解析記錄用來指定 email server 接收特定域名的 email。舉個例子,我們對 aftership.com 查詢 DNS 記錄如下:
image

可以看到 aftership.com對應有4條 MX 記錄。MX 記錄存在表示 domain 對應的 ESP 是存在, 否則不是一個有效的 email 地址。

Ping 收件人郵箱

確認完 MX record 記錄存在, 可以通過與 SMTP server 建立連接,來完成對 email 地址有效性的校驗。如上一步所示,MX records 一般會有多條(_有的 SMTP server 會設置 record 的權重值_),SMTP server 的地址是: MX 記錄 + SMTP Relay 端口。 Ping 收件人郵箱的原理是使用 SMTP ,連接到其中有效的 SMTP server,并請求收件人地址,請求后 server 會有響應, 根據響應信息來判斷地址是否存在。

如果 SMTP server 響應 250 OK, 那么這個 email 地址是可能就是一個有效地址;如果返回 550-5.1.1 類似錯誤那么就是一個無效的地址。

example@aftership.com 這個 email 地址為例, 下面是一個完整的 SMTP 連接的驗證過程。
image

首先 telnet 連上收件人的 SMTP server, 通過 ehlo 標識發件人的身份,mail 設置 email 的發件人,最后 rcpt 設置 email 的收件人, rcpt只能設置 SMTP domain 的 email 地址, 否則分類器(SMTP rewriter)會重寫郵件中的電子郵件地址,使其與主 SMTP 地址相匹配 。如果 rcpt 沒有拒絕該請求,表明 SMTP server 校驗通過該地址,將會把收件人添加到郵件列表。下圖是一張使用 SMTP 協議發送 email 的全流程圖:
image
SMTP Ping 收件人地址的方法,是整個 email 地址校驗過程可能最有效的一環,SMTP server 能幫你確認收件人是否存在和可達。需要注意到這里所說的“可能”,比如 example@aftership.com其實是一個無效的地址, rcpt 響應250,email 地址不一定是可達的。

這里又涉及一個概念,是 Catch-All Email Server,也叫 accept-all server,指那些被配置為接受所有發送到 domain 的 email 的 SMTP server,無論指定的 email 地址是否存在。catch-all 會將錯誤地址重定向到一個通用的郵箱,以定期審查,但是帶來的問題是提高 bounce 率的風險且 catch-all 地址無法被正確校驗。( 比如 Gmail 是一個 catch-all email server )
所以 Ping 收件人郵箱來校驗地址有效性, 需要確保對方的 SMTP server 是非 catch-all email server, rcpt命令響應250,才能說明地址是 deliverable,否則無法校驗可達性。

更多關于連上 SMTP 服務器后校驗過程的其他細節,比如為什么是用 rcpt 而不是其他命令來驗證地址,可以參考 Email box pinging。

什么時候需要校驗 email 地址

校驗 email 地址可以不是一個經常性的過程, 建議有下面幾種情況時必須進行校驗:

  1. 新增的 email 地址: 正如上面提到的, 在進行 marketing campaign 時必須對 contacts 新增的收件人列表校驗 email 地址,過濾無效和非法收件人賬號
  2. 超過一個月未重新校驗過的 email 地址
  3. bounce 率達到或者超過 2%: 設置 bounce 率閾值來確保郵件送達率, 提高 sender 的 reputation
  4. 統計到的 email 事件,email 被打開的概率比較低

本地驗證 emil 地址 vs 使用第三方 email 驗證服務

經過以上步驟來完整地校驗 email 列表,哪怕只有一個地址的驗證也要多花不少時間。但是也可以不必進行手動驗證,因為有許多第三方的 email 校驗服務,一般有提供 API 來完成對地址的校驗。調研了幾個類似服務(比如 emailchecker),它們提供的功能主要包括以下幾點:

  1. domain 校驗
  2. 單個 email 地址校驗
  3. 批量 email 地址校驗
  4. 語法檢查
  5. SMTP 校驗 (Ping收件人郵箱)
  6. 提供校驗 API
  7. bounce email 檢測
  8. GDPR 數據保護

所以這兩種驗證方案哪一個是更好的選擇呢?本地驗證 email 地址無疑是首選, 因為自行校驗其實更快,更好地支持批量校驗郵件列表;要注意的是很多較好的第三方驗證服務是付費的,在線驗證時需要確認服務是否有 GDPR 數據保護以確保不會與第三方共享用戶個人數據,或者存在安全問題,但是第三方校驗不會有各種限制(多數 ISP 禁止在 25 端口上建立出站連接),且不存在影響 IP 段 和域名 reputation 的風險。

email-verifier

如果是本地驗證 email 地址,目前社區其實有一些開源的驗證 email 地址的工具, 其中 stars 數最多是 trumail 項目,它提供了地址校驗 API。但是這個項目有兩個問題, 一是校驗慢,性能有些問題;二是不支持 disposable domain 的校驗,且該項目 archived, 已不再維護。

在開發和維護 Messages Platform 上,作為平臺方,我們除了對業務提供高可用、簡單易接入的 email 消息通道服務外,降低 bounce 率和提高郵件送達率也是我們重要的指標之一。所以我們需要有一個高效的郵件校驗服務,過濾非法 email 地址(平臺郵件平均發送量在1000+w封/月),以提高送達率?;谖覀兊募夹g棧是 Go, 在調研了 git 社區其他開源的 email 驗證工具后,發現 Go 項目對 email verifier 這一塊建設是相對缺失,暫時還沒有一個既提供多維度的 email 檢查(包括 diposable domain, 和 Role Account 等)且校驗地址可達性的工具。

由于 trumail 已不再維護,所以我們內部實現了一個新的 email 校驗庫 – email-verifier, 目前已經在線上環境上運行。對比 trumail, 校驗 email 地址的效率更加明顯,檢查維度更多。

相比于現有其他的 email 地址校驗工具, 我們提供高性能、多維度、高準確性的免費 email 地址校驗方案,來解決在不同場景下對 email 地址校驗的痛點問題。 期待 email-verifier 也能在更好地幫助到社區解決類似問題。

總結

本文主要從 bounce email 引入,詳細介紹了如何在不發送郵件情況下來校驗 email 地址,同時給出合適時間點校驗 email 地址的幾個建議;對比本地校驗和第三方校驗服務兩者的優缺點以及為什么我們會選擇自建校驗服務的原因。最后是我們在這一過程中,基于校驗原理孵化的一個檢測工具。

一般來說, Marketing Campaigns 展開之后,肯定會遇到 bounce email 影響 campaigns 質量的問題,這個時候在發郵件前校驗地址有效性的優點就不難理解:一是提高郵件送達率;二是維護和提高 sender 賬號的 reputation,對業務方和平臺方都是必要的。

參考

查看原文

贊 19 收藏 17 評論 2

認證與成就

  • 獲得 378 次點贊
  • 獲得 9 枚徽章 獲得 1 枚金徽章, 獲得 1 枚銀徽章, 獲得 7 枚銅徽章

擅長技能
編輯

開源項目 & 著作
編輯

  • react-range-selector

    基于 React 的移動端范圍選擇器。 該組件可以通過拖拽修改一定范圍內的跨度。

  • 前端實用工具集

    本來想記錄業務算法,但大部分算法實際效用較微,所以本庫修改為記錄和書寫有用的業務工具。

  • memoizee-proxy

    基于 Proxy 的備忘錄,支持 promise,weakmap 等新特性的代碼

  • little-virtual-computer

    這是一個非常有趣的項目,目標是使用 JavaScript 構建一個模擬計算機。

注冊于 2018-05-03
個人主頁被 4.7k 人瀏覽

一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕 <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>