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

查看原文

赞 27 收藏 20 评论 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 是一个神奇的文档网站生成器。可以快速帮你生成文档网站。不同于 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 插件来创建滑块组件。有时,我们需要一种简单的方法来快速将元素制作成可滚动的容器。现在,多亏了 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非常兴奋,它使滚动更加自然。现在,让我们深入研究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时,请确保可访问性。 这是滚动对齐的一种不好用法,它阻止用户自由滚动内容以读取内容。

.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 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。

关注 1499

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

图片描述

(注:项目中素材来源于网络,仅供交流学习使用,切勿商用!)

展望

这里只实现了一个一镜到底H5的主要效果部分,距离完成还有很多工作:

  • 微信分享设置及引导
  • 添加一个加载界面
  • 添加音乐音效(用过howler,感觉不错)
  • 可能需要的生成海报(html2canvas,生成海报easy job)
  • ...

结语

这次没有用太多篇幅铺开来讲细节,主要是运用几个库组合来实现,而实际操作上还有很多地方要注意的,例如帧动画的实现,滑动的速度控制,滑到顶部、底部的处理等等。实际应用上还要继续打磨,毕竟一个漂亮的H5,是要花很多精力去构思,去优化体验的。

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

email: vincent@shikehuyu.com

查看原文

赞 84 收藏 60 评论 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封/月),以提高送达率。基于我们的技术栈是 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

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封/月),以提高送达率。基于我们的技术栈是 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,对业务方和平台方都是必要的。

参考

查看原文

jump__jump 发布了文章 · 2020-12-08

组织和管理 CSS

在项目开发的过程中,基于有限的时间内保质保量的完成开发任务无疑是一场挑战。在这场挑战中我们不但要快速处理自己的问题,还需要与别人协同合作,以避免两者之间的冲突。

针对于大型项目的开发,CSS 如何组织和管理才能让我们用更少的时间写出更强大的功能。这篇文章来表述一些我对 CSS 组织和管理的理解。当然,对于 ToC(面向个人) 应用,出于细节和动画的把控。再加上这种网页生命周期较短,往往复用性较差,但是针对于 ToB(面向企业) 应用,统一风格往往会赢得客户的青睐。行列间距,主题样式等都应该结合统一,而不是每个页面不同设计。基于此,我们需要组织与管理我们的 css,而不仅仅只是是靠 css in js 来为每个组件单独设计。

BEM 命名约定

BEM 是一种相当知名的命名约定,BEM 的意思就是块(block)、元素(element)、修饰符(modifier),是由 Yandex 团队提出的一种前端命名方法论。这种巧妙的命名方法让你的CSS类对其他开发者来说更加透明而且更有意义。BEM 命名约定更加严格,而且包含更多的信息,它用于一个团队开发一个耗时的大项目。

如 我们在书写伙伴卡片组件 代码风格如下:

.partner-card {
}

.partner-card__name {
}

.partner-card__content {
}

.partner-card__content {
}

.partner-card__content--md {
}

根据上述代码,我们很容易看出当前开发的意图,同时也很难遇到代码重复的问题。当然,我们可以利用 LessSassStylus 这些 css 处理器辅助开发,这里不再赘述。

计算科学中最难的两件事之一就是命名,日常开发中如果没有一些约定,就很容易发生命名冲突,BEM 恰恰解决了这一痛点,我们只需要外层样式名是一个有意义且独立唯一,就无需考虑太多。

与 BEM 相对应的还有 OOCSS SMACSS。而这两种不是直接可见的命名约定,而是提供了一系列的目标规则。这里不再详细阐述,大家如果想要了解,可以去看一看 值得参考的css理论:OOCSS、SMACSS与BEM。当然了,真正的组织与管理必然也离不开这些目标规则。

同时,使用 BEM 而不是 CSS 后代选择器进行项目也会有一定性能优势(可以忽略不计),这是因为浏览器解析 css 时是逆向解析,之前对 css 解析有一定误区,由于书写是从前往后,所以认为解析也一定是从前往后,但是大部分情况下,css 解析都是从后往前。

如果规则正向解析,例如「div div p em」,我们首先就要检查当前元素到 html 的整条路径,找到最上层的 div,再往下找,如果遇到不匹配就必须回到最上层那个 div,往下再去匹配选择器中的第一个 div,回溯若干次才能确定匹配与否,效率很低。逆向匹配则不同,如果当前的 DOM 元素是 div,而不是 selector 最后的 em 元素,那只要一步就能排除。只有在匹配时,才会不断向上找父节点进行验证与过滤。无需回溯,效率较高。。

Atomic CSS

ACSS 表示的是原子化 CSS(Atomic CSS),是 Yahoo 提出来的一种独特的 CSS 代码组织方式,应用在 Yahoo 首页和其他产品中。ACSS 的独特性在于它的理念与一般开发人员的理解有很大的不同,并挑战了传统意义上编写 CSS 的最佳实践,也就是关注点分离原则。ACSS 认为关注点分离原则会导致冗余、繁琐和难以维护的 CSS 代码。

代码风格如下所示:

/** 背景色 颜色 内边距 */
<div class="Bgc(#0280ae.5) C(#fff) P(20px)">
    Lorem ipsum
</div>

<div>
   <div class="Bgc(#0280ae.5) H(90px) IbBox W(50%) foo_W(100%)"></div>
   <div class="Bgc(#0280ae) H(90px) IbBox W(50%) foo_W(100%)"></div>
</div>

<hr>

<div class="foo">
   <div class="Bgc(#0280ae.5) H(90px) IbBox W(50%) foo_W(100%)"></div>
   <div class="Bgc(#0280ae) H(90px) IbBox W(50%) foo_W(100%)"></div>
</div>

计算科学中最难的两件事之一就是命名,而 Atomic CSS 直接舍弃了命名。一个类只做一件事。yahoo 利用这种方案减轻了很多代码。

很多人会抗拒 Atomic CSS,原因在于需要记住一堆的类名,感觉会看起来比较乱。但是事实上,你不需要考虑它的结构等因素,而是它需要什么样式就直接提供好了。把脑力运动变成机械的体力运动。

原子 CSS 的优势的确有很多:

  • 变化是可以预见的由于单一职责原则(一个类==一种样式),很容易预测删除或添加一个类会做什么。
  • CSS是精益的,几乎没有冗余,也没有自重(所有样式都与项目相关)。
  • 范围有限,不依赖于后代/上下文选择器-样式是在“ 特异性层 ” 内部完成的。
  • 初学者友好,原子 CSS 在设计好的情况下,甚至不需要编写样式表。 对于 css 不够擅长的开发人员更友好(这个也不一定是一件好事,css 学习是必需的)
  • 越大型的系统,对当前设计越熟悉,对库开发越熟练的开发人员,编写代码的时间和代码量就会越少。

如果一件事情只有利好而没有弊病那也是不可能的:

  • 需要记住一堆没有意义的原子类,对于不同的团队,类名难以重用。
  • 对于初学者有一定影响,可能只会记得原子类
  • 没有结合设计意图,原子类太细。

tailwind

如果 ACSS 这个框架令人难以接受,那么不久前拿到 200w 投资的 tailwind 框架则是更优秀的选择。虽然该库也是原子化 CSS,但是它更具可读性。

<div class="md:flex bg-white rounded-lg p-6">
  <img class="h-16 w-16 md:h-24 md:w-24 rounded-full mx-auto md:mx-0 md:mr-6" data-original="avatar.jpg">
  <div class="text-center md:text-left">
    <h2 class="text-lg">Erin Lindford</h2>
    <div class="text-purple-500">Product Engineer</div>
    <div class="text-gray-600">erinlindford@example.com</div>
    <div class="text-gray-600">(555) 765-4321</div>
  </div>
</div>

如果你重度使用 Bootstrap,那么我认为直接上手 tailwind 没有什么问题。 对比于 BootStrap,他做的更少,不会提供组件,仅仅提供样式。

  • 自适应前置,我们在使用其他 UI 库书写自适应前端网页时,往往会携带 -md -xs 诸如此类的类。而 Tailwind 则以 md:text-left lg:bg-teal-500 开头布局响应式风格。在书写时候,让代码更加自然。
  • 代码量可控,虽然 Tailwind CSS 的开发版本未压缩为1680.1K,但是它可以轻易与 webpack 结合剔出没有使用的 css。
  • 结合设计意图
  // tailwind 配置项
  module.exports = {
    theme: {
      screens: {
        sm: '640px',
        md: '768px',
        lg: '1024px',
        xl: '1280px',
      },
      fontFamily: {
        display: ['Gilroy', 'sans-serif'],
        body: ['Graphik', 'sans-serif'],
      },
      borderWidth: {
        default: '1px',
        '0': '0',
        '2': '2px',
        '4': '4px',
      },
      extend: {
        colors: {
          cyan: '#9cdbff',
        },
        spacing: {
          '96': '24rem',
          '128': '32rem',
        }
      }
    }
  }

如果将来某一天个人需要从头编写自己的组件库,我一定会使用它。相信该库会给我带来更好的开发体验。

MVP.css

Mvp.css 是一个简约的 HTML 元素样式表库,虽然它不属于 css 组织与管理,但当开发 ToC 项目时候,我们也可以把这种做法看作一种简约的模式。

这个微型库结合 css 变量进行项目开发。不过也许有人会认为他是一种前端反模式,因为他的样式挂在在 元素之上。不过如果面对项目较小,且需要整齐的风格体验,也可能是一个不错的方案。

:root {
  --border-radius: 5px;
  --box-shadow: 2px 2px 10px;
  --color: #118bee;
  --color-accent: #118bee0b;
  --color-bg: #fff;
  /* 其他变量 */
}

/* Layout */
article aside {
  background: var(--color-secondary-accent);
  border-left: 4px solid var(--color-secondary);
  padding: 0.01rem 0.8rem;
}

body {
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-family);
  line-height: var(--line-height);
  margin: 0;
  overflow-x: hidden;
  padding: 1rem 0;
}

关于 css 变量的一系列知识点,可以查看我之前的博客 玩转 CSS 变量。该文章详解了 css 变量的利弊以及新奇玩法。

工程实践

实际开发往往不止需要考虑某一方面,只考虑自己手上要做的东西,需要更高维度查看项目乃至整个开发体系。

团队合作永远是统一高于一切

针对于项目团队,任何一样事务能生存下来,都有其自己的优势,当然万物有得就必有失。这是相互的,至于我们前端人员,或者一个团队如何取舍,还是需要从自已或团队力量出发,有利用之,无利就不用了。我认为我最近看的一篇博客 《程序员修炼之道》中的一段废稿 可以表述正交性问题,事实上,无论式团队还是一段代码,正交性越差就越难以治理。

最后,在这里介绍一些本文没有介绍的工具。

  • Design token 辅助库 theo 来编写多端变量。
  • 去除未使用的 Css 样式 uncss
  • 通过 url 提取关键 CSS minimalcss ,提升初始渲染速度
  • 高性能的 CSS-in-JS 库 Emotion

参考资料

值得参考的css理论:OOCSS、SMACSS与BEM

ACSS

tailwind

Mvp.css

玩转 CSS 变量

theo

uncss

minimalcss

Emotion

《程序员修炼之道》中的一段废稿

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。
博客地址

查看原文

赞 4 收藏 3 评论 2

jump__jump 发布了文章 · 2020-11-15

总结对象安全访问处理方案

前端数据不可信,后端数据不可知

在前端项目开发与生产的过程中,“cannot read property of undefined”是一个常见的错误。从不可知得到一个空数据问题在所难免。面对这种问题我们该怎么办呢?

针对于特定的业务和数据,前端即使预知数据不存在,也无法处理,任其崩溃即可。但大部分情况下,如果因为列表某条数据出现错误而导致整个页面无法访问,用户将对我们系统健壮性产生信任危机。

为了能够在对象( JavaScript 中数组也是对象)中安全的取值,需要验证对象中数据项是否存在,if 语句判断当然是没有问题的,但是如果数据路径较深,代码就过于冗余了,而常见的处理方案有如下几种。

短路运算符号嗅探

JavaScript 中我们可以通过 && 来实现获取员工的地址邮编代码

const result = (staff.address && staff.address[0] && staff.address[0].zip) || ''

原理解析

这种解析方式和 JavaScript 异于其他语言的判断机制有关。大部分语言都仅有 true 和 false, JavaScript 有 truthy 概念,即在某些场景下会被推断为 true

当然以下数据会被解析为 false:

  • null
  • undefined
  • NaN
  • 0
  • 空字符串

除此之外,都会被解析为 true,即使空数组, 空对象(注: Python 空字典,空列表,空元组均在判断中会被解析为 false)也不例外。

同时 && || 不仅仅返回 true 和 false,而是数据项。

运算符说明
逻辑与,AND(&&若第一项可转换为 true,则返回第二项;否则,返回第一项目。
逻辑或,OR若第一项可转换为 true,则返回第一项;否则,返回第二项目。
逻辑非,NOT(!若当前项可转换为 true,则返回 false;否则,返回 true

|| 单元保底值

既然可以通过 && 来对数据进行嗅探,那么我们可以退一步,如果当前没有项目数据,利用 || 返回空对象或者空数组。

const EMPTY_OBJ = {}

const result = (((staff || EMPTY_OBJ).address || EMPTY_OBJ)[0] || EMPTY_OBJ).zip || ''

对比上一个方案,虽然相比上述代码更为复杂。 但是如果针对拥有完整数据的数据项目而言,对数据的访问次数较少(. 的使用率),而上一个方案针对完善数据的访问会多不少。而大部分数据无疑是正确与完整的。

try catch

该方法无需验证对象中数据项是否存在,而是通过错误处理直接处理。

let result = ''
try {
  result = staff.address[0].zip
} catch {
  // 错误上报
}

try catch 方案更适合必要性数据缺失作为上报的情况。但如果发生了必要性内容数据缺失,前端界面崩溃反而是一件好事。所以 try catch 不太适合处理对象安全访问这种问题,仅仅作为可选方案。

链判断运算符

上述处理方式都很痛苦,因此 ES2020 引入了“链判断运算符”(optional chaining operator)?.,简化上面的写法。

const reuslt = staff?.address?.[0]?.zip || ''

简单来解释:

a?.b
// 等同于
a == null ? undefined : a.b

a?.[x]
// 等同于
a == null ? undefined : a[x]

a?.b()
// 等同于
a == null ? undefined : a.b()

a?.()
// 等同于
a == null ? undefined : a()

如果你想要详细了解,可以参考阮一峰 ECMAScript 6 入门 链判断运算符 一篇。

手写路径获取

某些情况下,我们需要传递路径来动态获取数据,如 'staff.address[0].zip', 这里手写了一个处理代码。传入对象和路径,得到对象,对象 key 以及 value。

/**
 * 根据路径来获取 对象内部属性
 * @param obj 对象
 * @param path 路径 a.b[1].c
 */
function getObjPropByPath(obj: Record<string, any>, path: string) {
  let tempObj = obj
  const keyArr = path.split('.').map(x => x.trim())
  let i: number = 0
  for (let len = keyArr.length; i <len - 1; ++i) {
    let key = keyArr[i]
    // 简单判断是否是数组数据,如果 以 ] 结尾的话
    const isFormArray = key.endsWith(']')
    let index: number = 0
    if (isFormArray) {
      const data = key.split('[') ?? []
      key = data[0] ?? ''
      // 对于 parseInt('12]') => 12
      index = parseInt(data[1], 10)
    }
    
    if (key in tempObj) {
      tempObj = tempObj[key]
      if (isFormArray && Array.isArray(tempObj)) {
        tempObj = tempObj[index]
        if (!tempObj) {
          return {}
        }
      }
    } else {
      return {}
    }
  }

  if (!tempObj) {
    return {}
  }
  
  return  {
    o: tempObj,
    k: keyArr[i],
    v: tempObj[keyArr[i]]
  }
}

不过笔者写的方案较为粗糙,但 lodash 对象模块中也有该功能,感兴趣的可以参考其实现方式。lodash get


// 根据 object对象的path路径获取值。 如果解析 value 是 undefined 会以 defaultValue 取代。
// _.get(object, path, [defaultValue])

var object = { 'a': [{ 'b': { 'c': 3 } }] };

_.get(object, 'a[0].b.c');
// => 3

_.get(object, ['a', '0', 'b', 'c']);
// => 3

_.get(object, 'a.b.c', 'default');
// => 'default'

其他

Null 判断运算符

当然,我们大部分情况下使用 || 都没有问题,但是由于 falsy 的存在. || 对于 false 和 undefined 是一样的。但是某些情况下,false 是有意义的,true, false, undefined 均代表一种含义,这时候,我们还需要对数据进行其他处理,使用 in 或者 hasOwnProperty 进行存在性判断。

针对于这种情况,ES2020 引入了一个新的 Null 判断运算符 ??。它的行为类似||,但是只有运算符左侧的值为 nullundefined 时,才会返回右侧的值。如

const result = staff.address && staff.address[0] && staff.address[0].zip ?? ''

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。
博客地址

参考资料

ES6 入门教程

查看原文

赞 1 收藏 1 评论 0

jump__jump 发布了文章 · 2020-11-12

命令行错误提示—谈谈模糊集

在开发的过程中,我们会使用各种指令。有时候,我们由于这样或者那样的原因,写错了某些指令。此时,应用程序往往会爆出错误。

Unrecognized option 'xxx' (did you mean 'xxy'?)

可以看到,当前代码不仅仅提示了当前你输入的配置错误。同时还提供了类似当前输入的近似匹配指令。非常的智能。此时,我们需要使用算法来计算,即模糊集。

事实上,模糊集其实可以解决一些现实的问题。例如我们有一个“高个子”集合 A,定义 1.75m 为高个子。那么在通用逻辑中我们会认为某一个元素隶属或者不隶属该集合。也就是 1.78 就是高个子,而 1.749 就不是高个子,即使它距离 1.75 米只差里一毫米。该集合被称为(two-valued 二元集),与此相对的,模糊集合则没有这种问题。

在模糊集合中,所有人都是集合 A 的成员,所不同的仅仅是匹配度而已。我们可以通过计算匹配度来决定差异性。

如何运行

言归正转,我们回到当前实现。对于模糊集的实现,我们可以参考 fuzzyset.js (注: 该库需要商业许可) 和 fuzzyset.js 交互式文档 进行学习。

在这里,我仅仅只介绍基本算法,至于数据存储和优化在完整实现中。

通过查看交互式文档,我们可以算法是通过余弦相似度公式去计算。

在直角坐标系中,相似度公式如此计算。

cos = (a b) / (|a| |b| ). => 等同于

( (x1, y1) (x2,y2)) / (Math.sqrt(x1 2 + y1 2) Math.sqrt(x2 2 + y2 2))

而相似度公式是通过将字符串转化为数字矢量来计算。如果当前的字符串分别为 “smaller” 和 “smeller”。我们需要分解字符串子串来计算。

当前可以分解的字符串子串可以根据项目来自行调整,简单起见,我们这里使用 2 为单位。

两个字符串可以被分解为:

const smallSplit: string[] = [
  '-s',
  'sm',
  'ma',
  'al',
  'll',
  'l-'
]
const smelllSplit: string[] = [
  '-s',
  'sm',
  'me',
  'el',
  'll',
  'll',
  'l-'
]

我们可以根据当前把代码变为如下向量:

const smallGramCount = {
  '-s': 1,
  'sm': 1,
  'ma': 1,
  'al': 1,
  'll': 1,
  'l-': 1
}

const smallGramCount = {
  '-s': 1,
  'sm': 1,
  'me': 1,
  'el': 1,
  'll': 2,
  'l-': 1
}
const _nonWordRe = /[^a-zA-Z0-9\u00C0-\u00FF, ]+/g;

/**
 * 可以直接把 'bal' 变为 ['-b', 'ba', 'al', 'l-']
 */
function iterateGrams (value: string, gramSize: number = 2) {
  // 当前 数值添加前后缀 '-'
  const simplified = '-' + value.toLowerCase().replace(_nonWordRe, '') + '-'

  // 通过计算当前子字符串长度和当前输入数据长度的差值
  const lenDiff = gramSize - simplified.length

  // 结果数组
  const results = []

  // 如果当前输入的数据长度小于当前长度
  // 直接添加 “-” 补差计算
  if (lenDiff > 0) {
    for (var i = 0; i < lenDiff; ++i) {
      value += '-';
    }
  }

  // 循环截取数值并且塞入结果数组中
  for (var i = 0; i < simplified.length - gramSize + 1; ++i) {
    results.push(simplified.slice(i, i + gramSize));
  }
  return results;
}

/**
 * 可以直接把 ['-b', 'ba', 'al', 'l-'] 变为 {-b: 1, 'ba': 1, 'al': 1, 'l-': 1}
 */
function gramCounter(value: string, gramSize: number = 2) {
  const result = {}
  // 根据当前的
  const grams = _iterateGrams(value, gramSize)
  for (let i = 0; i < grams.length; ++i) {
    // 根据当前是否有数据来进行数据增加和初始化 1
    if (grams[i] in result) {
      result[grams[i]] += 1;
    } else {
      result[grams[i]] = 1;
    }
  }
  return result;
}

然后我们可以计算 small \* smell 为:

small gramsmall countsmell gramsmell gram
-s1\*-s1
sm1\*sm1
ma1\*ma0
me0\*me1
al1\*al0
el0\*el1
ll1\*ll1
l-1\*l-1
sum4
function calcVectorNormal() {
  // 获取向量对象
  const small_counts = gramCounter('small', 2)
  const smell_counts = gramCOunter('smell', 2)

  // 使用 set 进行字符串过滤
  const keySet = new Set()

  // 把两单词组共有的字符串塞入 keySet
  for (let key in small_counts) {
    keySet.add(key)
  }

  for (let key in smell_counts) {
    keySet.add(key)
  }

  let sum: number = 0

  // 计算 small * smell
  for(let key in keySet.keys()) {
    sum += (small_count[key] ?? 0) * (smell_count[key] ?? 0)
  }

  return sum
}

同时我们可以计算 |small|\*|smell| 为:

small GramSmAll CountCount \\ 2
-s11
sm11
ma11
al11
ll11
l-11
sum6
sqrt2.449

同理可得当前 smell sqrt 也是 2.449。

最终的计算为: 4 / (2.449 \* 2.449) = 0.66 。

计算方式为

// ... 上述代码

function calcVectorNormal() {
  // 获取向量对象
  const gram_counts = gramCounter(normalized_value, 2);
  // 计算
  let sum_of_square_gram_counts = 0;
  let gram;
  let gram_count;

  for (gram in gram_counts) {
    gram_count = gram_counts[gram];
    // 乘方相加
    sum_of_square_gram_counts += Math.pow(gram_count, 2);
  }

  return Math.sqrt(sum_of_square_gram_counts);
}

则 small 与 smell 在子字符串为 2 情况下匹配度为 0.66。

当然,我们看到开头和结束添加了 - 也作为标识符号,该标识是为了识别出 sell 与 llse 之间的不同,如果使用

const sellSplit = [
  '-s',
  'se',
  'el',
  'll',
  'l-'
]
const llseSplit = [
  '-l',
  'll',
  'ls',
  'se',
  'e-'
]

我们可以看到当前的相似的只有 'll' 和 'se' 两个子字符串。

完整代码

编译型框架 svelte 项目代码中用到此功能,使用代码解析如下:

const valid_options = [
  'format',
  'name',
  'filename',
  'generate',
  'outputFilename',
  'cssOutputFilename',
  'sveltePath',
  'dev',
  'accessors',
  'immutable',
  'hydratable',
  'legacy',
  'customElement',
  'tag',
  'css',
  'loopGuardTimeout',
  'preserveComments',
  'preserveWhitespace'
];

// 如果当前操作不在验证项中,才会进行模糊匹配
if (!valid_options.includes(key)) {
  // 匹配后返回 match 或者 null
  const match = fuzzymatch(key, valid_options);
  let message = `Unrecognized option '${key}'`;
  if (match) message += ` (did you mean '${match}'?)`;

  throw new Error(message);
}

实现代码如下所示:

export default function fuzzymatch(name: string, names: string[]) {
  // 根据当前已有数据建立模糊集,如果有字符需要进行匹配、则可以对对象进行缓存
  const set = new FuzzySet(names);
  // 获取当前的匹配
  const matches = set.get(name);
  // 如果有匹配项,且匹配度大于 0.7,返回匹配单词,否则返回 null
  return matches && matches[0] && matches[0][0] > 0.7 ? matches[0][1] : null;
}

// adapted from https://github.com/Glench/fuzzyset.js/blob/master/lib/fuzzyset.js
// BSD Licensed

// 最小子字符串 2
const GRAM_SIZE_LOWER = 2;
// 最大子字符串 3
const GRAM_SIZE_UPPER = 3;

//  进行 Levenshtein 计算,更适合输入完整单词的匹配
function _distance(str1: string, str2: string) {
  if (str1 === null && str2 === null)
    throw 'Trying to compare two null values';
  if (str1 === null || str2 === null) return 0;
  str1 = String(str1);
  str2 = String(str2);

  const distance = levenshtein(str1, str2);
  if (str1.length > str2.length) {
    return 1 - distance / str1.length;
  } else {
    return 1 - distance / str2.length;
  }
}

// Levenshtein距离,是指两个字串之间,由一个转成另一个所需的最少的编辑操作次数。
function levenshtein(str1: string, str2: string) {
  const current: number[] = [];
  let prev;
  let value;

  for (let i = 0; i <= str2.length; i++) {
    for (let j = 0; j <= str1.length; j++) {
      if (i && j) {
        if (str1.charAt(j - 1) === str2.charAt(i - 1)) {
          value = prev;
        } else {
          value = Math.min(current[j], current[j - 1], prev) + 1;
        }
      } else {
        value = i + j;
      }
      prev = current[j];
      current[j] = value;
    }
  }
  return current.pop();
}

// 正则匹配除单词 字母 数字以及逗号和空格外的数据
const non_word_regex = /[^\w, ]+/;

// 上述代码已经介绍
function iterate_grams(value: string, gram_size = 2) {
  const simplified = '-' + value.toLowerCase().replace(non_word_regex, '') + '-';
  const len_diff = gram_size - simplified.length;
  const results = [];

  if (len_diff > 0) {
    for (let i = 0; i < len_diff; ++i) {
      value += '-';
    }
  }
  for (let i = 0; i < simplified.length - gram_size + 1; ++i) {
    results.push(simplified.slice(i, i + gram_size));
  }
  return results;
}

// 计算向量,上述代码已经介绍
function gram_counter(value: string, gram_size = 2) {
  const result = {};
  const grams = iterate_grams(value, gram_size);
  let i = 0;

  for (i; i < grams.length; ++i) {
    if (grams[i] in result) {
      result[grams[i]] += 1;
    } else {
      result[grams[i]] = 1;
    }
  }
  return result;
}

// 排序函数
function sort_descending(a, b) {
  return b[0] - a[0];
}

class FuzzySet {
  // 数据集合,记录所有的可选项目
  // 1.优化初始化时候,相同的可选项数据,同时避免多次计算相同向量
  // 2.当前输入的值与可选项相等,直接返回,无需计算
  exact_set = {};
  // 匹配对象存入,存储所有单词的向量
  // 如 match_dist['ba'] = [
  //     第2个单词,有 3 个
  //     {3, 1}
  //     第5个单词,有 2 个
  //     {2, 4}
  //   ]
  //   后面单词匹配时候,可以根据单词索引进行匹配然后计算最终分数
  match_dict = {};
  // 根据不同子字符串获取不同的单词向量,最终有不同的匹配度
  // item[2] = [[2.6457513110645907, "aaab"]]
  items = {};

  constructor(arr: string[]) {
    // 当前选择 2 和 3 为子字符串匹配
    // item = {2: [], 3: []}
    for (let i = GRAM_SIZE_LOWER; i < GRAM_SIZE_UPPER + 1; ++i) {
      this.items[i] = [];
    }

    // 添加数组
    for (let i = 0; i < arr.length; ++i) {
      this.add(arr[i]);
    }
  }

  add(value: string) {
    const normalized_value = value.toLowerCase();

    // 如果当前单词已经计算,直接返回
    if (normalized_value in this.exact_set) {
      return false;
    }

    // 分别计算 2 和 3 的向量
    for (let i = GRAM_SIZE_LOWER; i < GRAM_SIZE_UPPER + 1; ++i) {
      this._add(value, i);
    }
  }

  _add(value: string, gram_size: number) {
    const normalized_value = value.toLowerCase();
    // 获取 items[2]
    const items = this.items[gram_size] || [];
    // 获取数组的长度作为索引
    const index = items.length;

    // 没有看出有实际的用处?实验也没有什么作用?不会影响
    items.push(0);

    // 获取 向量数据
    const gram_counts = gram_counter(normalized_value, gram_size);
    let sum_of_square_gram_counts = 0;
    let gram;
    let gram_count;

    // 同上述代码,只不过把所有的匹配项目和当前索引都加入 match_dict 中去
    // 如 this.match_dict['aq'] = [[1, 2], [3,3]]
    for (gram in gram_counts) {
      gram_count = gram_counts[gram];
      sum_of_square_gram_counts += Math.pow(gram_count, 2);
      if (gram in this.match_dict) {
        this.match_dict[gram].push([index, gram_count]);
      } else {
        this.match_dict[gram] = [[index, gram_count]];
      }
    }
    const vector_normal = Math.sqrt(sum_of_square_gram_counts);
    // 添加向量  如: this.items[2][3] = [4.323, 'sqaaaa']
    items[index] = [vector_normal, normalized_value];
    this.items[gram_size] = items;
    // 设置当前小写字母,优化代码
    this.exact_set[normalized_value] = value;
  }

  // 输入当前值,获取选择项
  get(value: string) {
    const normalized_value = value.toLowerCase();
    const result = this.exact_set[normalized_value];

    // 如果当前值完全匹配,直接返回 1,不必计算
    if (result) {
      return [[1, result]];
    }

    let results = [];
    // 从多到少,如果多子字符串没有结果,转到较小的大小
    for (
      let gram_size = GRAM_SIZE_UPPER;
      gram_size >= GRAM_SIZE_LOWER;
      --gram_size
    ) {
      results = this.__get(value, gram_size);
      if (results) {
        return results;
      }
    }
    return null;
  }

  __get(value: string, gram_size: number) {
    const normalized_value = value.toLowerCase();
    const matches = {};
    // 获得当前值的向量值
    const gram_counts = gram_counter(normalized_value, gram_size);
    const items = this.items[gram_size];
    let sum_of_square_gram_counts = 0;
    let gram;
    let gram_count;
    let i;
    let index;
    let other_gram_count;

    // 计算得到较为匹配的数据
    for (gram in gram_counts) {
      // 获取 向量单词用于计算
      gram_count = gram_counts[gram];
      sum_of_square_gram_counts += Math.pow(gram_count, 2);
      // 取得当前匹配的 [index, gram_count]
      if (gram in this.match_dict) {
        // 获取所有匹配当前向量单词的项目,并且根据 索引加入 matches
        for (i = 0; i < this.match_dict[gram].length; ++i) {
          // 获得当前匹配的索引 === 输入单词[index]
          index = this.match_dict[gram][i][0];
          // 获得匹配子字符串的值
          other_gram_count = this.match_dict[gram][i][1];
          // 单词索引添加,注:只要和当前子字符串匹配的 索引都会加入 matches
          if (index in matches) {
            matches[index] += gram_count * other_gram_count;
          } else {
            matches[index] = gram_count * other_gram_count;
          }
        }
      }
    }


    const vector_normal = Math.sqrt(sum_of_square_gram_counts);
    let results = [];
    let match_score;

    // 构建最终结果 [分数, 单词]
    for (const match_index in matches) {
      match_score = matches[match_index];
      results.push([
        // 分数
        match_score / (vector_normal * items[match_index][0]),
        // 单词
        items[match_index][1]
      ]);
    }

    // 虽然所有的与之匹配子字符串都会进入,但我们只需要最高的分数
    results.sort(sort_descending);

    let new_results = [];

    // 如果匹配数目很大,只取的前 50 个数据进行计算
    const end_index = Math.min(50, results.length);

    // 由于是字符类型数据,根据当前数据在此计算 levenshtein  距离
    for (let i = 0; i < end_index; ++i) {
      new_results.push([
        _distance(results[i][1], normalized_value), results[i][1]
      ]);
    }
    results = new_results;
    // 在此排序
    results.sort(sort_descending);

    new_results = [];
    for (let i = 0; i < results.length; ++i) {
      // 因为 第一的分数是最高的,所有后面的数据如果等于第一个
      // 也可以进入最终选择
      if (results[i][0] == results[0][0]) {
        new_results.push([results[i][0], this.exact_set[results[i][1]]]);
      }
    }

    // 返回最终结果
    return new_results;
  }
}

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。

博客地址

参考资料

fuzzyset.js 交互式文档

svelte fuzzymatch

查看原文

赞 1 收藏 1 评论 0

jump__jump 发布了文章 · 2020-10-20

利用 XState(有限状态机) 编写易于变更的代码

目前来说,无论是 to c 业务,还是 to b 业务,对于前端开发者的要求越来越高,各种绚丽的视觉效果,复杂的业务逻辑层出不穷。针对于业务逻辑而言,贯穿后端业务和前端交互都有一个关键点 —— 状态转换。

当然了,这种代码实现本身并不复杂,真正的难点在于如何快速的进行代码的修改。

在实际开发项目的过程中,ETC 原则,即 Easier To Change,易于变更是非常重要的。为什么解耦很好? 为什么单一职责很有用? 为什么好的命名很重要?因为这些设计原则让你的代码更容易发生变更。ETC 甚至可以说是其他原则的基石,可以说,我们现在所作的一切都是为了更容易变更!!特别是针对于初创公司,更是如此。

例如:项目初期,当前的网页有一个模态框,可以进行编辑,模态框上有两个按钮,保存与取消。这里就涉及到模态框的显隐状态以及权限管理。随着时间的推移,需求和业务发生了改变。当前列表无法展示该项目的所有内容,在模态框中我们不但需要编辑数据,同时需要展示数据。这时候我们还需要管理按钮之间的联动。仅仅这些就较为复杂,更不用说涉及多个业务实体以及多角色之间的细微控制。

重新审视自身代码,虽然之前我们做了大量努力利用各种设计原则,但是想要快速而安全的修改散落到各个函数中的状态修改,还是非常浪费心神的,而且还很容易出现“漏网之鱼”。

这时候,我们不仅仅需要依靠自身经验写好代码,同时也需要一些工具的辅助。

有限状态机

有限状态机是一个非常有用的数学计算模型,它描述了在任何给定时间只能处于一种状态的系统的行为。当然,该系统中只能够建立出一些有限的、定性的“模式”或“状态” ,并不描述与该系统相关的所有(可能是无限的)数据。例如,水可以是四种状态中的一种: 固体(冰)、液体、气体或等离子体。然而,水的温度可以变化,它的测量是定量的和无限的。

总结来说,有限状态机的三个特征为:

  • 状态总数(state)是有限的。
  • 任一时刻,只处在一种状态之中。
  • 某种条件下,会从一种状态转变(transition)到另一种状态。

在实际开发中,它还需要:

  • 初始状态
  • 触发状态变化的事件和转换函数
  • 最终状态的集合(有可能是没有最终状态)

先看一个简单的红绿灯状态转换:

const light = {
  currentState: 'green',
  
  transition: function () {
    switch (this.currentState) {
      case "green":
        this.currentState = 'yellow'
        break;
      case "yellow":
        this.currentState = 'red'
        break;
      case "red": 
        this.currentState = 'green'
        break;
      default:
        break;
    }
  }
}

有限状态机在游戏开发中大放异彩,已经成为了一种常用的设计模式。用这种方式可以使每一个状态都是独立的代码块,与其他不同的状态分开独立运行,这样很容易检测遗漏条件和移除非法状态,减少了耦合,提升了代码的健壮性,这么做可以使得游戏的调试变得更加方便,同时也更易于增加新的功能。

对于前端开发来说,我们可以从其他工程领域中多年使用的经验学习与再创造。

XState 体验

实际上开发一个 简单的状态机并不是特别复杂的事情,但是想要一个完善,实用性强,还具有可视化工具的状态机可不是一个简单的事。

这里我要推荐 XState,该库用于创建、解释和执行有限状态机和状态图。

简单来说:上述的代码可以这样写。

import { Machine } from 'xstate'

const lightMachine = Machine({
  // 识别 id, SCXML id 必须唯一
  id: 'light',
  // 初始化状态,绿灯
  initial: 'green',
  
  // 状态定义 
  states: {
    green: {
      on: {
        // 事件名称,如果触发 TIMRE 事件,直接转入 yellow 状态
        TIMRE: 'yellow'
      }
    },
    yellow: {
      on: {
        TIMER: 'red'
      }
    },
    red: {
      on: {
        TIMER: 'green'
      }
    }
  }
})

// 设置当前状态
const currentState = 'green'

// 转换的结果
const nextState = lightMachine.transition(currentState, 'TIMER').value 
// => 'yellow'

// 如果传入的事件没有定义,则不会发生转换,如果是严格模式,将会抛出错误
lightMachine.transition(currentState, 'UNKNOWN').value 

其中 SCXML 是状态图可扩展标记语言, XState 遵循该标准,所以需要提供 id。当前状态机也可以转换为 JSON 或 SCXML。

虽然 transition 是一个纯函数,非常好用,但是在真实环境使用状态机,我们还是需要更强大的功能。如:

  • 跟踪当前状态
  • 执行副作用
  • 处理延迟过度以及时间
  • 与外部服务沟通

XState 提供了 interpret 函数,

import { Machine,interpret } from 'xstate'

// 。。。 lightMachine 代码

// 状态机的实例成为 serivce
const lightService = interpret(lightMachine)
   // 当转换时候,触发的事件(包括初始状态)
  .onTransition(state => {
    // 返回是否改变,如果状态发生变化(或者 context 以及 action 后文提到),返回 true 
    console.log(state.changed) 
    console.log(state.value)
  })
  // 完成时候触发
  .onDone(() => {
    console.log('done')
  })

// 开启
lightService.start()

// 将触发事件改为 发送消息,更适合状态机风格
// 初始化状态为 green 绿色
lightService.send('TIMER') // yellow
lightService.send('TIMER') // red

// 批量活动
lightService.send([
  'TIMER',
  'TIMER'
])

// 停止
lightService.stop()

// 从特定状态启动当前服务,这对于状态的保存以及使用更有作用
lightService.start(previousState)

我们也可以结合其他库在 Vue React 框架中使用,仅仅只用几行代码就实现了我们想要的功能。

import lightMachine from '..'
// react hook 风格
import { useMachine } from '@xstate/react'

function Light() {
  const [light, send] = useMachine(lightMachine)
  
  return <>
    // 当前状态 state 是否是绿色
    <span>{light.matches('green') && '绿色'}</span>    
    // 当前状态的值
    <span>{light.value}</span>  
    // 发送消息
    <button onClick={() => send('TIMER')}>切换</button>
  </>
}

当前的状态机也是还可以进行嵌套处理,在红灯状态下添加人的行动状态。

import { Machine } from 'xstate';

const pedestrianStates = {
  // 初识状态 行走
  initial: 'walk',
  states: {
    walk: {
      on: {
        PED_TIMER: 'wait'
      }
    },
    wait: {
      on: {
        PED_TIMER: 'stop'
      }
    },
    stop: {}
  }
};

const lightMachine = Machine({
  id: 'light',
  initial: 'green',
  states: {
    green: {
      on: {
        TIMER: 'yellow'
      }
    },
    yellow: {
      on: {
        TIMER: 'red'
      }
    },
    red: {
      on: {
        TIMER: 'green'
      },
      ...pedestrianStates
    }
  }
});

const currentState = 'yellow';

const nextState = lightMachine.transition(currentState, 'TIMER').value;

// 返回级联对象 
// => {
//   red: 'walk'
// }

// 也可以写为 red.walk
lightMachine.transition('red.walk', 'PED_TIMER').value;

// 转化后返回
// => {
//   red: 'wait'
// }

// TIMER 还可以返回下一个状态
lightMachine.transition({ red: 'stop' }, 'TIMER').value;
// => 'green'

当然了,既然有嵌套状态,我们还可以利用 type: 'parallel' ,进行串行和并行处理。

除此之外,XState 还有扩展状态 context 和过度防护 guards。这样的话,更能够模拟现实生活

// 是否可以编辑
functions canEdit(context: any, event: any, { cond }: any) {
  console.log(cond)
  // => delay: 1000
  
  // 是否有某种权限 ???
  return hasXXXAuthority(context.user)
}


const buttonMachine = Machine({
  id: 'buttons',
  initial: 'green',
  // 扩展状态,例如 用户等其他全局数据
  context: {
    // 用户数据
    user: {}
  },
  states: {
    view: {
      on: {
        // 对应之前 TIMRE: 'yellow'
        // 实际上 字符串无法表达太多信息,需要对象表示
        EDIT: {
          target: 'edit',
          // 如果没有该权限,不进行转换,处于原状态
          // 如果没有附加条件,直接 cond: searchValid
          cond: {
            type: 'searchValid',
            delay: 3
          }
        }, 
      }
    }
  }
}, {
  // 守卫
  guards: {
    canEdit,
  }
})


// XState 给予了更加合适的 API 接口,开发时候 Context 可能不存在
// 或者我们需要在不同的上下文 context 中复用状态机,这样代码扩展性更强
const buttonMachineWithDelay = buttonMachine.withContext({
  user: {},
  delay: 1000
})

// withContext 是直接替换,不进行浅层合并,但是我们可以手动合并
const buttonMachineWithDelay = buttonMachine.withContext({
  ...buttonMachine.context,
  delay: 1000
})

我们还可以通过瞬时状态来过度,瞬态状态节点可以根据条件来确定机器应从先前的状态真正进入哪个状态。瞬态状态表现为空字符串,即 '',如

const timeOfDayMachine = Machine({
  id: 'timeOfDay',
  // 当前不知道是什么状态
  initial: 'unknown',
  context: {
    time: undefined
  },
  states: {
    // Transient state
    unknown: {
      on: {
        '': [
          { target: 'morning', cond: 'isBeforeNoon' },
          { target: 'afternoon', cond: 'isBeforeSix' },
          { target: 'evening' }
        ]
      }
    },
    morning: {},
    afternoon: {},
    evening: {}
  }
}, {
  guards: {
    isBeforeNoon: //... 确认当前时间是否小于 中午 
    isBeforeSix: // ... 确认当前时间是否小于 下午 6 点
  }
});

const timeOfDayService = interpret(timeOfDayMachine
  .withContext({ time: Date.now() }))
  .onTransition(state => console.log(state.value))
  .start();

timeOfDayService.state.value 
// 根据当前时间,可以是 morning afternoon 和 evening,而不是 unknown 转态

到这里,我觉得已经介绍 XState 很多功能了,篇幅所限,不能完全介绍所有功能,不过当前的功能已经足够大部分业务需求使用了。如果有其他更复杂的需求,可以参考 XState 文档

这里列举一些没有介绍到的功能点:

  • 进入和离开某状态触发动作(action 一次性)和活动(activity 持续性触发,直到离开某状态)
  • 延迟事件与过度 after
  • 服务调用 invoke,包括 promise 以及 两个状态机之间相互交互
  • 历史状态节点,可以通过配置保存状态并且回退状态

当然了,对比于 x-state 这种,还有其他的状态机工具,如 javascript-state-machine , Ego 等。大家可以酌情考虑使用。

总结

对于现代框架而言,无论是如火如荼的 React Hook 还是渐入佳境的 Vue Compoistion Api,其本质都想提升状态逻辑的复用能力。但是考虑大部分场景下,状态本身的切换都是有特定约束的,如果仅仅靠良好的编程习惯,恐怕还是难以写出抑郁修改的代码。而 FSM 以及 XState 无疑是一把利器。

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。

博客地址

参考

XState 文档

JavaScript与有限状态机

查看原文

赞 4 收藏 3 评论 0

jump__jump 发布了文章 · 2020-10-07

根据背景色自适应文本颜色

针对企业服务来说,最终用户往往需要更加细化的信息分类方式,而打标签无疑是非常好的解决方案。

如果标签仅仅只提供几种颜色可能无法满足各个用户的实际需求。那么系统就需要为用户提供颜色选择。事实上我们完全无法预知用户选择了何种颜色,那么如果当前用户选择了黑色作为背景色,同时当前的字体颜色也是黑色,该标签就无法使用。如果配置背景色的同时还要求用户配置文字颜色,那么这个标签功能未免有些鸡肋。让用户觉得我们的开发水平有问题。

所以需要寻找一种解决方案来搞定这个问题。

问题解析

对于彩色转灰度,有一个著名的公式。我们可以把十六进制的代码分成3个部分,以获得单独的红色,绿色和蓝色的强度。用此算法逐个修改像素点的颜色可以将当前的彩色图片变为灰色图像。

gray = r * 0.299 + g * 0.587 + b * 0.114

但是针对明亮和阴暗的颜色,经过公式的计算后一定会获得不同的数值,而针对当前不同值,我们取反就可以得到当前的文本颜色。即:

const textColor = (r * 0.299 + g * 0.587 + b * 0.114) > 186 ? '#000' : '#FFF'    

当然了,186 并不是一个确定的数值,你可以根据自己的需求调整一个新的数值。通过该算法,传入不同的背景色,就可以得到白色和黑色,或者自定义出比较合适的文本颜色。

完善代码

当然,虽然解决的方法非常简单,但是中间还是涉及了一些进制转换问题,这里简单传递数值如下所示。

/**
 * @param backgroundColor 字符串 传入  #FFFBBC | FBC | FFBBCC 均可
 */
export function contrastTextColor(backgroundHexColor: string) {
  let hex = backgroundHexColor
  
  // 如果当前传入的参数以 # 开头,去除当前的
  if (hex.startsWith('#')) {
    hex = hex.substring(1);
  }
  // 如果当前传入的是 3 位小数值,直接转换为 6 位进行处理
  if (hex.length === 3) {
    hex = [hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]].join('')
  }

  if (hex.length !== 6) {
    throw new Error('Invalid background color.' + backgroundHexColor);
  }

  const r = parseInt(hex.slice(0, 2), 16)
  const g = parseInt(hex.slice(2, 4), 16)
  const b = parseInt(hex.slice(4, 6), 16)
  
  if ([r,g,b].some(x => Number.isNaN(x))) {
     throw new Error('Invalid background color.' + backgroundHexColor);
  }

  const textColor = (r * 0.299 + g * 0.587 + b * 0.114) > 186 ? '#000' : '#FFF'
  return textColor
}

我们还可以在其中添加 rgb 颜色,以及转换逻辑。

/**
 * @param backgroundColor 字符串
 */
export function contrastTextColor(backgroundHexColor: string) {
  // 均转换为 hex 格式, 可以传入 rgb(222,33,44)。
  // 如果当前字符串参数长度大于 7 rgb(,,) 最少为 8 个字符,则认为当前传入的数值为 rgb,进行转换
  const backgroundHexColor = backgroundColor.length > 7 ? convertRGBToHex(backgroundColor) : backgroundColor
  
  // ... 后面代码
}

/** 获取背景色中的多个值,即 rgb(2,2,2) => [2,2,2] */
const rgbRegex = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/

/** 转换 10 进制为 16 进制, 
  * 计算完成后时字符串前面加 0,同时取后两位数值。使得返回的数值一定是 两位数
  * 如 E => 0E  |  FF => 0FF => FF
  */
const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2);

function convertRGBToHex(rgb: string): string {
  const bg = rgb.match(rgbRegex);
  
  if (!bg) {
    // 返回空字符串,在后面判断长度为 6 时候会报错。不在此处进行操作
    return ''
  }
  
  return ("#" + hex(bg[1]) + hex(bg[2]) + hex(bg[3])).toUpperCase();
}

当然了,我们也可以在其中添加缓存代码,以便于减少计算量。

// 使用 map 来缓存 
const colorByBgColor = new Map()
// 缓存错误字符串
const CACHE_ERROR = 'error'

export function contrastTextColor(backgroundColor: string) {
  // 获取缓存
  const cacheColor = colorByBgColor.get(backgroundColor)
  if (cacheColor) {
    // 当前缓存错误,直接报错
    if (cacheColor === CACHE_ERROR) {
      throw new Error('Invalid background color.' + backgroundColor);
    }
    return colorByBgColor.get(backgroundColor)
  }
  
  // ...
  if (hex.length !== 6) {
    // 直接缓存错误
    colorByBgColor.set(backgroundColor, CACHE_ERROR)
    throw new Error('Invalid background color.' + backgroundColor);
  }
  
  // ...
  
  if ([r,g,b].some(x => Number.isNaN(x))) {
    // 直接缓存错误
    colorByBgColor.set(backgroundColor, CACHE_ERROR)
    throw new Error('Invalid background color.' + backgroundColor);
  }

  const textColor = (r * 0.299 + g * 0.587 + b * 0.114) > 186 ? '#000' : '#FFF'
  // 缓存数据
  colorByBgColor.set(backgroundColor, textColor)
  return textColor
}

完整代码可以在代码库中 转换问题颜色 中看到。

当然了,如果你不需要严格遵循 W3C 准则,当前代码已经足够使用。但是如果你需要严格遵循你可以参考 http://stackoverflow.com/a/39... 以及 https://www.w3.org/TR/WCAG20/

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。
博客地址

参考资料

stackoverflow 问题

查看原文

赞 5 收藏 5 评论 0

jump__jump 报名了系列讲座 · 2020-10-03

前端进阶系列视频课程合集

前端入门到进阶系列教程,让您快速成长,找到学习的方向,从前端小白进阶为高级前端、前端专家。 备注:早期录制的课程,很多做的不好的地方,欢迎您提出宝贵建议,我们后期会优化。 课程内容 ---- 前端进阶组合课程包含: ####《Virtual DOM原理解析与编码实现》 - 学习基础Virtual DOM原理和实现,是现有前端框架比如React和Vue等基础原理,用到了许多很基础的JavaScript函数,作为前端基础课程。 ####《前端JavaScript函数式编程学以致用》 - 函数式编程是必学课,特别是了解下高阶函数,高阶组件。了解纯函数,副作用,科里化等等,对编程能力是很大的提高。也是很多高级JavaScript特性的实现原理基础知识。 ####《前端DSL与AST深入浅出》 - DSL和AST侧重于对编程语言有整体的理解和把握,了解前端如何实现代码编译,脚本转换的。特别是了解Babel和Webpack的原理和机制,可以用来做一些提效的事情。很多时候,提供另一种解决方案和视角。 ####《前端性能监控与用户体验优化实战》 - 用户体验和性能优化是前端开发必须要特别敏感的事情。关注用户体验,是业务的基础要求,也是做好的优秀的产品必备技能。性能优化和性能监控能及时帮助开发者发现问题,及时解决问题,减少不必要的损失。

jump__jump 报名了系列讲座 · 2020-09-25

Rust 系列课程一:“CITA 的工程实践”

Rust 是一门非常棒的语言,它可以把很多已有的东西整合起来变得非常好用,如果用起来,一定会上瘾~我们会在这里对 Rust 做系列分享,欢迎一起学习讨论。 第一期内容主要分为以下几个方面: 1.CITA 简介 2.CITA 为什么用 RUST 3.使用 Rust 的感受及建议 4.CITA 项目的工程实践 5.代码演示

jump__jump 报名了系列讲座 · 2020-09-25

Rust 系列课程二: “Rust 详细介绍及 CLI 实践”

继 Rust 第一期项目介绍之后,本期内容由罗超(漂流大佬)来和大家聊一聊 Rust 的详细介绍及应用。 Rust 第二期内容: 1. Rust 的起源与发展历史 2. Rust 的特性与特色 3. Rust 入门常见问题 4. Rust 在 CLI 中的实践

jump__jump 赞了文章 · 2020-09-10

try catch引发的性能优化深度思考

image

关键代码拆解成如下图所示(无关部分已省略):

demo

起初我认为可能是这个 getRowDataItemNumberFormat 函数里面某些方法执行太慢,从 formatData.replaceunescape(已废弃,官方建议使用 decodeURI 或者 decodeURIComponent 替代) 方法都怀疑了一遍,发现这些方法都不是该函数运行慢的原因。为了深究原因,我给 style.formatData 传入了不同的值,发现这个函数的运行效率出现不同的表现。开始有点疑惑为什么 style.formatData 的值导致这个函数的运行效率差别如此之大。

进一步最终定位发现如果 style.formatData 为 undefined 的时候,效率骤降,如果 style.formatData 为合法的字符串的时候,效率是正常值。我开始意识到这个问题的原因在那里了,把目光转向了 try catch 代码块,这是一个很可疑的地方,在很早之前曾经听说过不合理的 try catch 是会影响性能的,但是之前从没遇到过,结合了一些资料,我发现比较少案例去探究这类代码片段的性能,我决定写代码去验证下:

window.a = 'a';
window.c = undefined;
function getRowDataItemNumberFormatTryCatch() {
    console.time('getRowDataItemNumberFormatTryCatch');
    for (let i = 0; i < 3000; i++) {
        try {
            a.replace(/%022/g, '"');
        }
        catch (error) {
        }
    }
    console.timeEnd('getRowDataItemNumberFormatTryCatch');
}

我尝试把 try catch 放入一个 for 循环中,让它运行 3000 次,看看它的耗时为多少,我的电脑执行该代码的时间大概是 0.2 ms 左右,这是一个比较快的值,但是这里 a.replace 是正常运行的,也就是 a 是一个字符串能正常运行 replace 方法,所以这里的耗时是正常的。我对他稍微做了一下改变,如下:

function getRowDataItemNumberFormatTryCatch2() {
    console.time('getRowDataItemNumberFormatTryCatch');
    for (let i = 0; i < 3000; i++) {
        try {
            c.replace(/%022/g, '"');
        }
        catch (error) {
        }
    }
    console.timeEnd('getRowDataItemNumberFormatTryCatch');
}

这段代码跟上面代码唯一的区别是,c.replace 此时应该是会报错的,因为 cundefined,这个错误会被 try catch 捕捉到,而上面的代码耗时出现了巨大的变化,上升到 40 ms,相差了将近 200 倍!并且上述代码和首图的 getRowDataItemNumberFormat 函数代码均出现了 Minor GC,注意这个 Minor GC 也是会耗时的。

demo

这可以解释一部分原因了,我们上面运行的代码是一个性能比较关键的部分,不应该使用 try catch 结构,因为该结构是相当独特的。与其他构造不同,它运行时会在当前作用域中创建一个新变量。每次 catch 执行该子句都会发生这种情况,将捕获的异常对象分配给一个变量。

即使在同一作用域内,此变量也不存在于脚本的其他部分中。它在 catch 子句的开头创建,然后在子句末尾销毁。因为此变量是在运行时创建和销毁的(这些都需要额外的耗时!),并且这是 JavaScript 语言的一种特殊情况,所以某些浏览器不能非常有效地处理它,并且在捕获异常的情况下,将捕获处理程序放在性能关键的循环中可能会导致性能问题,这是我们为什么上面会出现 Minor GC 并且会有严重耗时的原因。

如果可能,应在代码中的较高级别上进行异常处理,在这种情况下,异常处理可能不会那么频繁发生,或者可以通过首先检查是否允许所需的操作来避免。上面的 getRowDataItemNumberFormatTryCatch2 函数示例显示的循环,如果里面所需的属性不存在,则该循环可能引发多个异常,为此性能更优的写法应该如下:

function getRowDataItemNumberFormatIf() {
    console.time('getRowDataItemNumberFormatIf');
    for (let i = 0; i < 3000; i++) {
        if (c) {
            c.replace(/%022/g, '"');
        }
    }
    console.timeEnd('getRowDataItemNumberFormatIf')
}

上面的这段代码语义上跟 try catch 其实是相似的,但运行效率迅速下降至 0.04ms,所以 try catch 应该通过检查属性或使用其他适当的单元测试来完全避免使用此构造,因为这些构造会极大地影响性能,因此应尽量减少使用它们。

如果一个函数被重复调用,或者一个循环被重复求值,那么最好避免其中包含这些构造。它们最适合仅执行一次或仅执行几次且不在性能关键代码内执行的代码。尽可能将它们与其他代码隔离,以免影响其性能。

例如,可以将它们放在顶级函数中,或者运行它们一次并存储结果,这样你以后就可以再次使用结果而不必重新运行代码。

demo

getRowDataItemNumberFormat 在经过上述思路改造后,运行效率得到了质的提升,在实测 300 多次循环中减少的时间如下图,足足优化了将近 2s 多的时间,如果是 3000 次的循环,那么它的优化比例会更高:

demo
demo

由于上面的代码是从项目中改造出来演示的,可能并不够直观,所以我重新写了另外一个相似的例子,代码如下,这里面的逻辑和上面的 getRowDataItemNumberFormat 函数讲道理是一致的,但是我让其发生错误的时候进入 catch 逻辑执行任务。

事实上 plus1plus2 函数的代码逻辑是一致的,只有代码语义是不相同,一个是返回 1,另一个是错误抛出1,一个求和方法在 try 片段完成,另一个求和方法再 catch 完成,我们可以粘贴这段代码在浏览器分别去掉不同的注释观察结果。

我们发现 try 片段中的代码运行大约使用了 0.1 ms,而 catch 完成同一个求和逻辑却执行了大约 6 ms,这符合我们上面代码观察的预期,如果把计算范围继续加大,那么这个差距将会更加明显,实测如果计算 300000 次,那么将会由原来的 60 倍差距扩大到 500 倍,那就是说我们执行的 catch 次数越少折损效率越少,而如果我们执行的 catch 次数越多那么折损的效率也会越多。

所以在不得已的情况下使用 try catch 代码块,也要尽量保证少进入到 catch 控制流分支中。

const plus1 = () => 1;
const plus2 = () => { throw 1 };
console.time('sum');
let sum = 0;
for (let i = 0; i < 3000; i++) {
    try {
        // sum += plus1(); // 正确时候 约 0.1ms
        sum += plus2(); // 错误时候 约 6ms
    } catch (error) {
        sum += error;
    }
}
console.timeEnd('sum');

上面的种种表现进一步引发了我对项目性能的一些思考,我搜了下我们这个项目至少存在 800 多个 try catch,糟糕的是我们无法保证所有的 try catch 是不损害代码性能并且有意义的,这里面肯定会隐藏着很多上述类的 try catch 代码块。

从性能的角度来看,目前 V8 引擎确实在积极的通过 try catch 来优化这类代码片段,在以前浏览器版本中上面整个循环即使发生在 try catch 代码块内,它的速度也会变慢,因为以前浏览器版本会默认禁用 try catch 内代码的优化来方便我们调试异常。

try catch 需要遍历某种结构来查找 catch 处理代码,并且通常以某种方式分配异常(例如:需要检查堆栈,查看堆信息,执行分支和回收堆栈)。尽管现在大部分浏览器已经优化了,我们也尽量要避免去写出上面相似的代码,比如以下代码:

try {
    container.innerHTML = "I'm alloyteam";
}
catch (error) {
    // todo
}

上面这类代码我个人更建议写成如下形式,如果你实际上抛出并捕获了一个异常,它可能会变慢,但是由于在大多数情况下上面的代码是没有异常的,因此整体结果会比异常更快。

这是因为代码控制流中没有分支会降低运行速度,换句话说就是这个代码执行没错误的时候,没有在 catch 中浪费你的代码执行时间,我们不应该编写过多的 try catch 这会在我们维护和检查代码的时候提升不必要的成本,有可能分散并浪费我们的注意力。

当我们预感代码片段有可能出错,更应该是集中注意力去处理 successerror 的场景,而非使用 try catch 来保护我们的代码,更多时候 try catch 反而会让我们忽略了代码存在的致命问题。

if (container) container.innerHTML = "I'm alloyteam";
else // todo

在简单代码中应当减少甚至不用 try catch ,我们可以优先考虑 if else 代替,在某些复杂不可测的代码中也应该减少 try catch(比如异步代码),我们看过很多 asyncawait 的示例代码都是结合 try catch 的,在很多性能场景下我认为它并不合理,个人觉得下面的写法应该是更干净,整洁和高效的。

因为 JavaScript 是事件驱动的,虽然一个错误不会停止整个脚本,但如果发生任何错误,它都会出错,捕获和处理该错误几乎没有任何好处,代码主要部分中的 try catch 代码块是无法捕获事件回调中发生的错误。

通常更合理的做法是在回调方法通过第一个参数传递错误信息,或者考虑使用 Promisereject() 来进行处理,也可以参考 node 中的常见写法如下:

;(async () => {
    const [err, data] = await readFile();
    if (err) {
        // todo
    };
})()

fs.readFile('<directory>', (err, data) => {
    if (err) {
        // todo
    }
});

结合了上面的一些分析,我自己做出一些浅显的总结:

    1. 如果我们通过完善一些测试,尽量确保不发生异常,则无需尝试使用 try catch 来捕获异常。
    1. 非异常路径不需要额外的 try catch,确保异常路径在需要考虑性能情况下优先考虑 if else,不考虑性能情况请君随意,而异步可以考虑回调函数返回 error 信息对其处理或者使用 Promse.reject()
    1. 应当适当减少 try catch 使用,也不要用它来保护我们的代码,其可读性和可维护性都不高,当你期望代码是异常时候,不满足上述1,2的情景时候可考虑使用。

最后,笔者希望这篇文章能给到你我一些方向和启发吧,如有疏漏不妥之处,还请不吝赐教!

附笔记链接,阅读往期更多优质文章可移步查看,希望对你有些许的帮助,你的点赞是对我最大的鼓励:

查看原文

赞 18 收藏 10 评论 4