Git Product home page Git Product logo

blogs's Introduction

Hi there, I'm ssh 👋

Now I'm working at Bytedance Ltd as a web frontend developer.

掘金:ssh / 知乎:ssh / 公众号:前端从进阶到入院 / LeetCode:ssh

Languages and Tools:

blogs's People

Contributors

sl1673495 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blogs's Issues

Vue源码学习 观察属性watch

上一篇介绍computed的文章讲到了,良好的设计对于功能的实现非常有帮助,computed的核心实现原理是计算watcher,那么watch其实也是基于watcher来实现的,我们还是从initWatch初始化看起。

initWatch

function initWatch (vm, watch) {
  for (var key in watch) {
    // 遍历用户定义的watch属性 
    var handler = watch[key];
   // 如果watch是数组 就循环createWatcher
    if (Array.isArray(handler)) {
      for (var i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i]);
      }
    } else {
      // 否则直接createWatcher
      createWatcher(vm, key, handler);
    }
  }
}

我们可以看到,对于用户定义的单个watch属性,最终vue调用了createWatcher方法

createWatcher

function createWatcher (
  vm,
  expOrFn,
  handler,
  options
) {
  if (isPlainObject(handler)) {
    options = handler;
    handler = handler.handler;
  }
  if (typeof handler === 'string') {
    handler = vm[handler];
  }
  return vm.$watch(expOrFn, handler, options)
}

这段代码的开头对参数进行了规范化,因为watch是可以支持多种形式的。

{
   key: function() {}
}
{
   key: {
      handle: function() {},
      deep: true,
  }
}

最终调用了$watch,第一个参数是要观测的key或者'a.b.c'这样的表达式,handler是用户定义的回调函数,options是{deep: true}这样的watch配置

 vm.$watch(expOrFn, handler, options)

$watch

在vue中以$开头的api一般也提供给用户在外部使用,所以我们在外部也可以通过函数的方式去调用$watch, 比如

this.$watch(
  'a', 
  function() {}, 
  { deep: true }
)

接下来我们来看看$watch的实现

Vue.prototype.$watch = function (
    expOrFn,
    cb,
    options
  ) {
    var vm = this;
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    // 把options的user属性设为true,让watcher内部使用
    options = options || {};
    options.user = true;
    // 调用Watcher
    var watcher = new Watcher(vm, expOrFn, cb, options);
    if (options.immediate) {
      cb.call(vm, watcher.value);
    }
    return function unwatchFn () {
      watcher.teardown();
    }
  };

可以看到, 在把options的user设为true以后,
调用了

var watcher = new Watcher(vm, expOrFn, cb, options);

我们看看这段函数进入Watcher以后会做什么

Watcher

进入了watcher的构造函数以后

 if (options) {
    this.deep = !!options.deep;
    this.user = !!options.user;
    this.computed = !!options.computed;
    this.sync = !!options.sync;
    this.before = options.before;
  }
this.cb = cb;

这个watcher示例的user属性会被设置为true,
sync属性也会被设置为用户定义的sync 表示这个watcher的update函数会同步执行。

if (typeof expOrFn === 'function') {
    this.getter = expOrFn;
  } else {
    this.getter = parsePath(expOrFn);
    if (!this.getter) {
      this.getter = function () {};
      process.env.NODE_ENV !== 'production' && warn(
        "Failed watching path: \"" + expOrFn + "\" " +
        'Watcher only accepts simple dot-delimited paths. ' +
        'For full control, use a function instead.',
        vm
      );
    }
  }

这时候我们的expOrFn应该是个key 或者 'a.b.c'这样的访问路径,所以会进入else逻辑。
首先看

this.getter = parsePath(expOrFn);

parsePath

var bailRE = /[^\w.$]/;
function parsePath (path) {
  if (bailRE.test(path)) {
    return
  }
  var segments = path.split('.');
  return function (obj) {
    for (var i = 0; i < segments.length; i++) {
      if (!obj) { return }
      obj = obj[segments[i]];
    }
    return obj
  }
}

我们还是以a.b.c这个路径为例,
segments被以.号分隔成['a','b','c']这样的数组,
然后返回一个函数

function (obj) {
    for (var i = 0; i < segments.length; i++) {
      if (!obj) { return }
      obj = obj[segments[i]];
    }
    return obj
}

这个函数接受一个对象 然后会依次去访问对象的.a 再去访问.a.b 再去访问.a.b.c,
其实这个的目的就是在访问的过程中为这些属性下挂载的dep去收集依赖。

回到我们的watcher的初始化,接下来执行的是

if (this.computed) {
    this.value = undefined;
    this.dep = new Dep();
  } else {
    this.value = this.get();
  }

显然我们会走else逻辑,我们继续看this.get()

Watcher.prototype.get

Watcher.prototype.get = function get () {
  // 将全局的Dep.target设置成这个watch属性的watcher
  pushTarget(this);
  var value;
  var vm = this.vm;
  try {
    // 调用刚刚生成的getter函数,就是parsePath返回的那个函数
    // 这里把vm作为obj传入,所以会依次去读取vm.a vm.a.b vm.a.b.c 并且为这几个元素都收集了依赖。
    value = this.getter.call(vm, vm);
  } catch (e) {
    if (this.user) {
      handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      // 如果watch的options里设置了deep,就递归的去收集依赖。
      traverse(value);
    }
    // 收集完毕,将Dep.target弹出栈
    popTarget();
    this.cleanupDeps();
  }
  return value
};

至此为止,我们vm下的a a下的b b下的c都收集了这个watcher作为依赖,
那么当这些值中的任意值进行改变, 会触发他们内部dep.notify()

dep.notify

Dep.prototype.notify = function notify () {
  // stabilize the subscriber list first
  var subs = this.subs.slice();
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
};

subs[i].update()其实就是调用了watcher的update方法,再回到watcher

watcher.update()

Watcher.prototype.update = function update () {
   // 省略多余逻辑
   if (this.sync) {
    this.run();
   } else {
    queueWatcher(this);
  }
};

这个update是省略掉其他逻辑的,我们之前说过 如果watch的sync设置为true,
那么就会直接执行 this.run();

watcher.run

Watcher.prototype.run = function run () {
  if (this.active) {
    this.getAndInvoke(this.cb);
  }
};

这里调用了getAndInvoke(this.cb),将我们定义的watch回调函数传入

watcher.getAndInvoke

Watcher.prototype.getAndInvoke = function getAndInvoke (cb) {
  var value = this.get();
  if (
    value !== this.value ||
    isObject(value) ||
    this.deep
  ) {
    if (this.user) {
      try {
        cb.call(this.vm, value, oldValue);
      } catch (e) {
        handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\""));
      }
    }
  }
};

其实就是做了个判断,如果上一次的值和这次的值不相等,或者deep为true,都会直接出发cb.call(this.vm),并且将新值和旧值传入,这就是我们可以在watch的回调函数里获取新值和旧值的来源。

至此watch函数的实现就分析完毕了,再次感叹一下,良好的设计是成功的开端啊!

TypeScript从零实现基于Proxy的响应式库 基于函数劫持实现Map和Set的响应式

前言

在本系列的上一篇文章

带你彻底搞懂Vue3的响应式原理!TypeScript从零实现基于Proxy的响应式库。中,

我们详细的讲解了普通对象和数组实现响应式的原理,但是Proxy可以做的远不止于此,对于es6中新增的MapSetWeakMapWeakSet也一样可以实现响应式的支持。

但是对于这部分的劫持,代码中的逻辑是完全独立的一套,这篇文章就来看一下如何基于函数劫持实现实现这个需求。

阅读本篇需要的一些前置知识:
Proxy
WeakMap
Reflect
Symbol.iterator (会讲解)

为什么特殊

在上一篇文章中,假设我们通过data.a去读取响应式数据data的属性,则会触发Proxy的劫持中的get(target, key)

target就是data对应的原始对象,key就是a

我们可以在这时候给key: a注册依赖,然后通过Reflect.get(data, key)去读到原始数据返回出去。

回顾一下:

/** 劫持get访问 收集依赖 */
function get(target: Raw, key: Key, receiver: ReactiveProxy) {
  const result = Reflect.get(target, key, receiver)
  
  // 收集依赖
  registerRunningReaction({ target, key, receiver, type: "get" })

  return result
}

而当我们的响应式对象是一个Map数据类型的时候,想象一下这个场景:

const data = reactive(new Map([['a', 1]]))

observe(() => data.get('a'))

data.set('a', 2)

读取数据的方式变成了data.get('a')这种形式,如果还是用上一篇文章中的get,会发生什么情况呢?

get(target, key)中的target是map原始对象,key是get

通过Reflect.get返回的是map.get这个方法,注册的依赖也是通过get这个key注册的,而我们想要的效果是通过a这个key来注册依赖。

所以这里的办法就是函数劫持,就是把对于MapSet的所有api的访问(比如has, get, set, add)全部替换成我们自己写的方法,让用户无感知的使用这些api,但是内部却已经被我们自己的代码劫持了。

实现

我们把上篇文章中的目录结构调整成这样:

src/handlers
// 数组和对象的handlers
├── base.ts
// map和set的handlers
├── collections.ts
// 统一导出
└── index.ts

入口

首先看一下handlers/index.ts入口的改造

import { collectionHandlers } from "./collections"
import { baseHandlers } from "./base"
import { Raw } from "types"

// @ts-ignore
// 根据对象的类型 获取Proxy的handlers
export const handlers = new Map([
  [Map, collectionHandlers],
  [Set, collectionHandlers],
  [WeakMap, collectionHandlers],
  [WeakSet, collectionHandlers],
  [Object, baseHandlers],
  [Array, baseHandlers],
  [Int8Array, baseHandlers],
  [Uint8Array, baseHandlers],
  [Uint8ClampedArray, baseHandlers],
  [Int16Array, baseHandlers],
  [Uint16Array, baseHandlers],
  [Int32Array, baseHandlers],
  [Uint32Array, baseHandlers],
  [Float32Array, baseHandlers],
  [Float64Array, baseHandlers],
])

/** 获取Proxy的handlers */
export function getHandlers(obj: Raw) {
  return handlers.get(obj.constructor)
}

这里定义了一个Map: handlers,导出了一个getHandlers方法,根据传入数据的类型获取Proxy的第二个参数handlers

baseHandlers在第一篇中已经进行了详细讲解。

这篇文章主要是讲解collectionHandlers

collections

先看一下collections的入口:

// 真正交给Proxy第二个参数的handlers只有一个get
// 把用户对于map的get、set这些api的访问全部移交给上面的劫持函数
export const collectionHandlers = {
  get(target: Raw, key: Key, receiver: ReactiveProxy) {
    // 返回上面被劫持的api
    target = hasOwnProperty.call(instrumentations, key)
      ? instrumentations
      : target
    return Reflect.get(target, key, receiver)
  },
}

我们所有的handlers只有一个get,也就是用户对于map或者set上所有api的访问(比如has, get, set, add),都会被转移到我们自己定义的api上,这其实就是函数劫持的一种应用。

那关键就在于instrumentations这个对象上,我们对于这些api的自己的实现。

劫持api的实现

get和set

export const instrumentations = {
  get(key: Key) {
    // 获取原始数据
    const target = proxyToRaw.get(this)
    // 获取原始数据的__proto__ 拿到原型链上的方法
    const proto: any = Reflect.getPrototypeOf(this)
    // 注册get类型的依赖
    registerRunningReaction({ target, key, type: "get" })
    // 调用原型链上的get方法求值 然后对于复杂类型继续定义成响应式
    return findReactive(proto.get.apply(target, arguments))
  },
  set(key: Key, value: any) {
    const target = proxyToRaw.get(this)
    const proto: any = Reflect.getPrototypeOf(this)
    // 是否是新增的key
    const hadKey = proto.has.call(target, key)
    // 拿到旧值
    const oldValue = proto.get.call(target, key)
    // 求出结果
    const result = proto.set.apply(target, arguments)
    if (!hadKey) {
      // 新增key值时以type: add触发观察函数
      queueReactionsForOperation({ target, key, value, type: "add" })
    } else if (value !== oldValue) {
      // 已存在的key的值发生变化时以type: set触发观察函数
      queueReactionsForOperation({ target, key, value, oldValue, type: "set" })
    }
    return result
  },
}

/** 对于返回值 如果是复杂类型 再进一步的定义为响应式 */
function findReactive(obj: Raw) {
  const reactiveObj = rawToProxy.get(obj)
  // 只有正在运行观察函数的时候才去定义响应式
  if (hasRunningReaction() && isObject(obj)) {
    if (reactiveObj) {
      return reactiveObj
    }
    return reactive(obj)
  }
  return reactiveObj || obj
}

核心的getset方法和上一篇文章中的实现就几乎一样了,get返回的值通过findReactive确保进一步定义响应式数据,从而实现深度响应。

至此,这样的用例就可以跑通了:

const data = reactive(new Map([['a', 1]]))
observe(() => console.log('a', data.get('a')))

data.set('a', 5)
// 重新打印出a 5

接下来再针对一些特有的api进行实现:

has

  has (key) {
    const target = proxyToRaw.get(this)
    const proto = Reflect.getPrototypeOf(this)
    registerRunningReactionForOperation({ target, key, type: 'has' })
    return proto.has.apply(target, arguments)
  },

add

add就是典型的新增key的流程,会触发循环相关的观察函数。

  add (key: Key) {
    const target = proxyToRaw.get(this)
    const proto: any  = Reflect.getPrototypeOf(this)
    const hadKey = proto.has.call(target, key)
    const result = proto.add.apply(target, arguments)
    if (!hadKey) {
      queueReactionsForOperation({ target, key, value: key, type: 'add' })
    }
    return result
  },

delete

delete也和上一篇中的deleteProperty的实现大致相同,会触发循环相关的观察函数。

  delete (key: Key) {
    const target = proxyToRaw.get(this)
    const proto: any = Reflect.getPrototypeOf(this)
    const hadKey = proto.has.call(target, key)
    const result = proto.delete.apply(target, arguments)
    if (hadKey) {
      queueReactionsForOperation({ target, key, type: 'delete' })
    }
    return result
  },

clear

  clear () {
    const target: any = proxyToRaw.get(this)
    const proto: any = Reflect.getPrototypeOf(this)
    const hadItems = target.size !== 0
    const result = proto.clear.apply(target, arguments)
    if (hadItems) {
      queueReactionsForOperation({ target, type: 'clear' })
    }
    return result
  },

在触发观察函数的时候,针对clear这个type做了一些特殊处理,也是触发循环相关的观察函数。

export function getReactionsForOperation ({ target, key, type }) {
  const reactionsForTarget = connectionStore.get(target)
  const reactionsForKey = new Set()

+  if (type === 'clear') {
+    reactionsForTarget.forEach((_, key) => {
+      addReactionsForKey(reactionsForKey, reactionsForTarget, key)
+    })
  } else {
    addReactionsForKey(reactionsForKey, reactionsForTarget, key)
  }

 if (
    type === 'add' 
    || type === 'delete' 
+   || type === 'clear'
) {
    const iterationKey = Array.isArray(target) ? 'length' : ITERATION_KEY
    addReactionsForKey(reactionsForKey, reactionsForTarget, iterationKey)
  }

  return reactionsForKey
}

clear的时候,把每一个key收集到的观察函数都给拿到,并且把循环的观察函数也拿到,可以说是触发最全的了。

逻辑也很容易理解,clear的行为每一个key都需要关心,只要在observe函数中读取了任意的key,clear的时候也需要重新执行这个observe的函数。

forEach

  forEach (cb, ...args) {
    const target = proxyToRaw.get(this)
    const proto = Reflect.getPrototypeOf(this)
    registerRunningReaction({ target, type: 'iterate' })
    const wrappedCb = (value, ...rest) => cb(findObservable(value), ...rest)
    return proto.forEach.call(target, wrappedCb, ...args)
  },

到了forEach的劫持 就稍微有点难度了。

首先registerRunningReaction注册依赖的时候,用的key是iterate,这个很容易理解,因为这是遍历的操作。

这样用户后续对集合数据进行新增或者删除、或者使用clear操作的时候,会重新触发内部调用了forEach的观察函数

重点看下接下来这两段代码:

const wrappedCb = (value, ...rest) => cb(findObservable(value), ...rest)
return proto.forEach.call(target, wrappedCb, ...args)

wrappedCb包裹了用户自己传给forEach的cb函数,然后传给了集合对象原型链上的forEach,这又是一个函数劫持。用户传入的是map.forEach(cb),而我们最终调用的是map.forEach(wrappedCb)。

在这个wrappedCb中,我们把cb中本应该获得的原始值value通过findObservable定义成响应式数据交给用户,这样用户在forEach中进行的响应式操作一样可以收集到依赖了,不得不赞叹这个设计的巧妙。

keys && size

  get size () {
    const target = proxyToRaw.get(this)
    const proto = Reflect.getPrototypeOf(this)
    registerRunningReaction({ target, type: 'iterate' })
    return Reflect.get(proto, 'size', target)
  },
  keys () {
    const target = proxyToRaw.get(this)
    const proto: any = Reflect.getPrototypeOf(this)
    registerRunningReaction({ target, type: 'iterate' })
    return proto.keys.apply(target, arguments)
  },

由于keyssize返回的值不需要定义成响应式,所以直接返回原值就可以了。

values

再来看一个需要做特殊处理的典型

  values () {
    const target = proxyToRaw.get(this)
    const proto: any = Reflect.getPrototypeOf(this)
    registerRunningReaction({ target, type: 'iterate' })
    const iterator = proto.values.apply(target, arguments)
    return patchIterator(iterator, false)
  },

这里有一个知识点需要注意一下,就是集合对象的values方法返回的是一个迭代器对象Map.values

这个迭代器对象每一次调用next()都会返回Map中的下一个值

,为了让next()得到的值也可以变成响应式proxy,我们需要用patchIterator劫持iterator

// 把iterator劫持成响应式的iterator
function patchIterator (iterator) {
  const originalNext = iterator.next
  iterator.next = () => {
    let { done, value } = originalNext.call(iterator)
    if (!done) {
      value = findReactive(value)
    }
    return { done, value }
  }
  return iterator
}

也是经典的函数劫持逻辑,把原有的{ done, value }值拿到,把value值定义成响应式proxy

理解了这个概念以后,剩下相关几个handler也好理解了

entries

  entries () {
    const target = proxyToRaw.get(this)
    const proto: any = Reflect.getPrototypeOf(this)
    registerRunningReaction({ target, type: 'iterate' })
    const iterator = proto.entries.apply(target, arguments)
    return patchIterator(iterator, true)
  },

对应entries也有特殊处理,把迭代器传给patchIterator的时候需要特殊标记一下这是entries,看一下patchIterator的改动:

/** 把iterator劫持成响应式的iterator */ 
function patchIterator (iterator, isEntries) {
  const originalNext = iterator.next
  iterator.next = () => {
    let { done, value } = originalNext.call(iterator)
    if (!done) {
+      if (isEntries) {
+        value[1] = findReactive(value[1])
      } else {
        value = findReactive(value)
      }
    }
    return { done, value }
  }
  return iterator
}

entries操作的每一项是一个[key, val]的数组,所以通过下标[1],只把值定义成响应式,key不需要特殊处理。

Symbol.iterator

  [Symbol.iterator] () {
    const target = proxyToRaw.get(this)
    const proto: any = Reflect.getPrototypeOf(this)
    registerRunningReaction({ target, type: 'iterate' })
    const iterator = proto[Symbol.iterator].apply(target, arguments)
    return patchIterator(iterator, target instanceof Map)
  },

这里又是一个比较特殊的处理了,[Symbol.iterator]这个内置对象会在for of操作的时候被触发,具体可以看本文开头给出的mdn文档。所以也要用上面的迭代器劫持的思路。

patchIterator的第二个参数,是因为对Map数据结构使用for of操作的时候,返回的是entries结构,所以也需要进行特殊处理。

总结

本文的代码都在这个仓库里
https://github.com/sl1673495/proxy-reactive

函数劫持的思路在各种各样的前端库中都有出现,这几乎是进阶必学的一种技巧了,希望通过本文的学习,你可以理解函数劫持的一些强大的作用。也可以想象Vue3里用proxy来实现响应式能力有多么强。

前端高级进阶指南

前言

我曾经一度很迷茫,在学了 Vue、React 的实战开发和应用以后,好像遇到了一些瓶颈,不知道该怎样继续深入下去。相信这也是很多一两年经验的前端工程师所遇到共同问题,这篇文章,笔者想结合自己的一些成长经历整理出一些路线,帮助各位初中级前端工程师少走一些弯路。

这篇文章会提到非常非常多的学习路线和链接,如果你还在初中级的阶段,不必太焦虑,可以把这篇文章作为一个进阶的路线图,在未来的时日里朝着这个方向努力就好。
我也并不是说这篇文章是进阶高级工程师的唯一一条路线,如果你在业务上做的精进,亦或是能在沟通上八面玲珑,配合各方面力量把项目做的漂漂亮亮,那你也一样可以拥有这个头衔。本文只是我自己的一个成长路线总结。

本篇文章面对的人群是开发经验1到3年的初中级前端工程师,希望能和你们交个心。

已经晋升高级前端的同学,欢迎你在评论区留下你的心得,补充我的一些缺失和不足。

笔者本人 17 年毕业于一所普通的本科学校,20 年 6 月在三年经验的时候顺利通过面试进入大厂,职级是高级前端开发。

我的 github 地址,欢迎 follow,我会持续更新一些值得你关注的项目。

我的 blog 地址,这里会持续更新,点个 star 不失联!✨

基础能力

我整理了一篇中级前端的必备技术栈能力,写给女朋友的中级前端面试秘籍 。这篇文章里的技术栈当然都是需要扎实掌握的,(其实我自己也有一些漏缺,偷偷补一下)。

当然了,上进心十足的你不会一直满足于做中级前端,我们要继续向上,升职加薪,迎娶白富美!

JavaScript

原生 js 系列

冴羽大佬的这篇博客里,除了 undescore 的部分,你需要全部都能掌握。并且灵活的运用到开发中去。
JavaScript 深入系列、JavaScript 专题系列、ES6 系列

完全熟练掌握 eventLoop。

tasks-microtasks-queues-and-schedules

Promise

  1. 你需要阅读 Promise A+规范,注意其中的细节,并且灵活的运用到开发当中去。
    Promise A+ 英文文档

  2. 你需要跟着精品教程手写一遍 Promise,对里面的细节深入思考,并且把其中异步等待、错误处理等等细节融会贯通到你的开发**里去。
    剖析 Promise 内部结构,一步一步实现一个完整的、能通过所有 Test case 的 Promise 类

  3. 最后,对于 promise 的核心,异步的链式调用,你必须能写出来简化版的代码。
    最简实现 Promise,支持异步链式调用(20 行)

题外话,当时精炼这 20 行真的绕了我好久 😂,但是搞明白了会有种恍然大悟的感觉。这种异步队列的技巧要融会贯通。

async await

对于 Promise 我们非常熟悉了,进一步延伸到 async await,这是目前开发中非常非常常用的异步处理方式,我们最好是熟悉它的 babel 编译后的源码。

手写 async await 的最简实现(20 行搞定)
babel 对于 async await 配合 generator 函数,做的非常巧妙,这里面的**我们也要去学习,如何递归的处理一个串行的 promise 链?

这个技巧在axios 的源码里也有应用。平常经常用的拦截器,本质上就是一串 promise 的串行执行。

当然,如果你还有余力的话,也可以继续深入的去看 generator 函数的 babel 编译源码。不强制要求,毕竟 generator 函数在开发中已经用的非常少了。
ES6 系列之 Babel 将 Generator 编译成了什么样子

异常处理

你必须精通异步场景下的错误处理,这是高级工程师必备的技能,如果开发中的异常被你写的库给吞掉了,那岂不是可笑。
Callback Promise Generator Async-Await 和异常处理的演进

插件机制

你需要大概理解前端各个库中的插件机制是如何实现的,在你自己开发一些库的时候也能融入自己适合的插件机制。
Koa 的洋葱中间件,Redux 的中间件,Axios 的拦截器让你迷惑吗?实现一个精简版的就彻底搞懂了。

设计模式

对于一些复杂场景,你的开发不能再是if else嵌套一把梭了,你需要把设计模式好好看一遍,在合适的场景下选择合适的设计模式。这里就推荐掘金小册吧,相信这篇小册会让你的工程能力得到质的飞跃,举例来说,在 Vue 的源码中就用到了观察者模式发布订阅模式策略模式适配器模式发布订阅模式工厂模式组合模式代理模式门面模式等等。

而这些设计模式如果你没学习过可能很难想到如何应用在工程之中,但是如果你学习过,它就变成了你内在的工程能力,往大了说,也可以是架构能力的一部分。

在《设计模式》这本小册中我们提到过,即使是在瞬息万变的前端领域,也存在一些具备“一次学习,终生受用”特性的知识。从工程的角度看,我推荐大家着重学习的是设计模式。 -修言

这里推荐掘金修言的设计模式小册

开发**

有时候组合是优于继承的,不光是面向对象编程可以实现复用,在某些场景下,组合的**可能会更加简洁优雅。

https://medium.com/javascript-scene/master-the-javascript-interview-what-s-the-difference-between-class-prototypal-inheritance-e4cd0a7562e9

“…the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.” ~ Joe Armstrong — “Coders at Work”

面向对象语言的问题在于它们带来了所有这些隐含的环境。
你想要一个香蕉,但你得到的是拿着香蕉和整个丛林的大猩猩。

代码规范

你需要熟读 clean-code-javascript,并且深入结合到日常开发中,结合你们小组的场景制定自己的规范。
clean-code-javascript

算法

算法这里我就不推荐各种小册,笔记,博文了。因为从我自己学习算法的经验来看,在没有太多的算法基础的情况下,文章基本上是很难真正的看进去并理解的,这里只推荐慕课网 bobo 老师的 LeetCode 真题课程,在这个课程里算法大牛 bobo 老师会非常细心的把各个算法做成动图,由浅入深给你讲解各种分类的 LeetCode 真题。这是我最近学到的最有收获的一门课程了。

由于这门课程是 C++ 为主要语言的(不影响理解课程),我也针对此课程维护了一个对应的 JavaScript 版题解仓库,在 Issue 里也根据标签分类整理了各个题型的讲解,欢迎 Star ✨。

算法对于前端来说重要吗?也许你觉得做题没用,但是我个人在做题后并且分门别类的整理好各个题型的思路和解法后,是能真切的感觉到自己的代码能力在飞速提高的。

对于很多觉得自己不够聪明,不敢去学习算法的同学来说,推荐 bobo 老师的这篇《天生不聪明》,也正是这篇文章激励我开始了算法学习的旅程。

在这里列一下前端需要掌握的基础算法知识,希望能给你一个路线:

  1. 算法的复杂度分析。
  2. 排序算法,以及他们的区别和优化。
  3. 数组中的双指针、滑动窗口**。
  4. 利用 Map 和 Set 处理查找表问题。
  5. 链表的各种问题。
  6. 利用递归和迭代法解决二叉树问题。
  7. 栈、队列、DFS、BFS。
  8. 回溯法、贪心算法、动态规划。

算法是底层的基础,把地基打扎实后,会让你在后续的职业生涯中大受裨益的。

这里也推荐我的整合文章 前端算法进阶指南

框架篇

对于高级工程师来说,你必须要有一个你趁手的框架,它就像你手中的一把利剑,能够让你披荆斩棘,斩杀各种项目于马下。

下面我会分为VueReact两个方面深入去讲。

Vue

Vue 方面的话,我主要是师从黄轶老师,跟着他认真走,基本上在 Vue 这方面你可以做到基本无敌。

熟练运用

  1. 对于 Vue 你必须非常熟练的运用,官网的 api 你基本上要全部过一遍。并且你要利用一些高级的 api 去实现巧妙的封装。举几个简单的例子。

  2. 你要知道怎么用slot-scope去做一些数据和 ui 分离的封装。
    vue-promised这个库为例。
    Promised 组件并不关注你的视图展示成什么样,它只是帮你管理异步流程,并且通过你传入的slot-scope,在合适的时机把数据回抛给你,并且帮你去展示你传入的视图。

<template>
  <Promised :promise="usersPromise">
    <!-- Use the "pending" slot to display a loading message -->
    <template v-slot:pending>
      <p>Loading...</p>
    </template>
    <!-- The default scoped slot will be used as the result -->
    <template v-slot="data">
      <ul>
        <li v-for="user in data">{{ user.name }}</li>
      </ul>
    </template>
    <!-- The "rejected" scoped slot will be used if there is an error -->
    <template v-slot:rejected="error">
      <p>Error: {{ error.message }}</p>
    </template>
  </Promised>
</template>
  1. 你需要熟练的使用Vue.extends,配合项目做一些命令式api的封装。并且知道它为什么可以这样用。(需要具备源码知识)
    confirm 组件
export const confirm = function (text, title, onConfirm = () => {}) {
  if (typeof title === "function") {
    onConfirm = title;
    title = undefined;
  }
  const ConfirmCtor = Vue.extend(Confirm);
  const getInstance = () => {
    if (!instanceCache) {
      instanceCache = new ConfirmCtor({
        propsData: {
          text,
          title,
          onConfirm,
        },
      });
      // 生成dom
      instanceCache.$mount();
      document.body.appendChild(instanceCache.$el);
    } else {
      // 更新属性
      instanceCache.text = text;
      instanceCache.title = title;
      instanceCache.onConfirm = onConfirm;
    }
    return instanceCache;
  };
  const instance = getInstance();
  // 确保更新的prop渲染到dom
  // 确保动画效果
  Vue.nextTick(() => {
    instance.visible = true;
  });
};
  1. 你要开始使用JSX来编写你项目中的复杂组件了,比如在我的网易云音乐项目中,我遇到了一个复杂的音乐表格需求,支持搜索文字高亮、动态隐藏列等等。
    当然对于现在版本的 Vue,JSX 还是不太好用,有很多属性需要写嵌套对象,这会造成很多不必要的麻烦,比如没办法像 React 一样直接把外层组件传入的 props 透传下去,Vue3 的 rfc 中提到会把 vnode 节点的属性进一步扁平化,我们期待得到接近于 React 的完美 JSX 开发体验吧。

  2. 你要深入了解 Vue 中 nextTick 的原理,并且知道为什么要用微任务队列优于宏任务队列,结合你的 eventloop 知识深度思考。最后融入到你的异步合并优化的知识体系中去。
    Vue 源码详解之 nextTick:MutationObserver 只是浮云,microtask 才是核心!

  3. 你要能理解 Vue 中的高阶组件。关于这篇文章中为什么 slot-scope 不生效的问题,你不能看他的文章讲解都一头雾水。(需要你具备源码知识)
    探索 Vue 高阶组件 | HcySunYang

  4. 推荐一下我自己总结的 Vue 高阶组件文章,里面涉及到了一些进阶的用法。
    Vue 进阶必学之高阶组件 HOC

  5. 对于 Vuex 的使用必须非常熟练,知道什么时候该用 Vuex,知道怎么根据需求去编写 Vuex 的 plugin,合理的去使用 Vuex 的 subscribe 功能完成一些全局维度的封装,比如我对于 Vuex 中 action 的错误处理懒得一个个去try catch,就封装了一个vuex-error-plugin。代码很简单,重要的是去理解为什么能这样做。这里用了 monkey patch 的做法,并不是很好的实践,仅以此作为引子。

  6. 对于 vue-router 的使用必须非常熟练,知道什么需求需要利用什么样的 router 钩子,这样才能 hold 住一个大型的项目,这个我觉得官方仓库里的进阶中文文档其实很好,不知道为什么好像没放在官网。
    vue-router-advanced

  7. 理解虚拟 DOM 的本质,虚拟 DOM 一定比真实 DOM 更快吗?这篇是尤雨溪的回答,看完这个答案,相信你会对虚拟 DOM 有更进一步的认识和理解。
    网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?

源码深入

  1. 你不光要熟练运用 Vue,由于 Vue 的源码写的非常精美,而且阅读难度不是非常大,很多人也选择去阅读 Vue 的源码。视频课这里推荐黄轶老师的 Vue 源码课程。这里也包括了 Vuex 和 vue-router 的源码。
    Vue.js 源码全方位深入解析 (含 Vue3.0 源码分析)

  2. 推荐 HcySunYang 大佬的 Vue 逐行分析,需要下载 git 仓库,切到 elegant 分支自己本地启动。
    Vue 逐行级别的源码分析

  3. 当然,这个仓库的 master 分支也是宝藏,是这个作者的渲染器系列文章,脱离框架讲解了 vnode 和 diff 算法的本质
    组件的本质

Vue3 展望

  1. Vue3 已经发布了 Beta 版本,你可以提前学习Hook相关的开发模式了。这里推荐一下我写的这篇 Vue3 相关介绍:
    Vue3 究竟好在哪里?(和 React Hook 的详细对比)

Vue3 源码

对于响应式部分,如果你已经非常熟悉 Vue2 的响应式原理了,那么 Vue3 的响应式原理对你来说应该没有太大的难度。甚至在学习之中你会相互比较,知道 Vue3 为什么这样做更好,Vue2 还有哪部分需要改进等等。

Vue3 其实就是把实现换成了更加强大的 Proxy,并且把响应式部分做的更加的抽象,甚至可以,不得不说,Vue3 的响应式模型更加接近响应式类库的核心了,甚至react-easy-state等 React 的响应式状态管理库,也是用这套类似的核心做出来的。

再次强调,非常非常推荐学习 Vue3 的@vue/reactivity这个分包。

推一波自己的文章吧,细致了讲解了 Vue3 响应式的核心流程。

  1. 带你彻底搞懂 Vue3 的 Proxy 响应式原理!TypeScript 从零实现基于 Proxy 的响应式库。

  2. 带你彻底搞懂 Vue3 的 Proxy 响应式原理!基于函数劫持实现 Map 和 Set 的响应式

  3. 深度解析:Vue3 如何巧妙的实现强大的 computed

在学习之后,我把@vue/reactivity包轻松的集成到了 React 中,做了一个状态管理的库,这也另一方面佐证了这个包的抽象程度:
40 行代码把 Vue3 的响应式集成进 React 做状态管理

React

React 已经进入了 Hook 为主的阶段,社区的各个库也都在积极拥抱 Hook,虽然它还有很多陷阱和不足,但是这基本上是未来的方向没跑了。这篇文章里我会减少 class 组件的开发技巧的提及,毕竟好多不错的公司也已经全面拥抱 Hook 了。

熟练应用

  1. 你必须掌握官网中提到的所有技巧,就算没有使用过,你也要大概知道该在什么场景使用。

  2. 推荐 React 小书,虽然书中的很多 api 已经更新了,但是核心的设计**还是没有变
    React.js 小书

  3. 关于熟练应用,其实掘金的小册里有几个宝藏

    1. 诚身大佬(悄悄告诉你,他的职级非常高)的企业级管理系统小册,这个项目里的代码非常深入,而且在抽象和优化方面也做的无可挑剔,自己抽象了acl权限管理系统和router路由管理,并且引入了reselect做性能优化,一年前我初次读的时候,很多地方懵懵懂懂,这一年下来我也从无到有经手了一套带acl权限路由的管理系统后,才知道他的抽象能力有多强。真的是

      初闻不知曲中意,再闻已是曲中人。

      React 组合式开发实践:打造企业管理系统五大核心模块

    2. 三元大佬的 React Hooks 与 Immutable 数据流实战,深入浅出的带你实现一个音乐播放器。三元大家都认识吧?那是神,神带你们写应用项目,不学能说得过去吗?
      React Hooks 与 Immutable 数据流实战

  4. 深入理解 React 中的key
    understanding-reacts-key-prop

    react 中为何推荐设置 key

  5. React 官方团队成员对于派生状态的思考:
    you-probably-dont-need-derived-state

React Hook

你必须熟练掌握 Hook 的技巧,除了官网文档熟读以外:

  1. 推荐 Dan 的博客,他就是 Hook 的代码实际编写者之一,看他怎么说够权威了吧?这里贴心的送上汉化版。
    useEffect 完整指南
    看完这篇以后,进入dan 的博客主页,找出所有和 Hook 有关的,全部精读!

  2. 推荐黄子毅大佬的精读周刊系列
    096.精读《useEffect 完全指南》.md
    注意!不是只看这一篇,而是这个仓库里所有有关于 React Hook 的文章都去看一遍,结合自己的**分析。

  3. Hook 陷阱系列
    还是 Dan 老哥的文章,详细的讲清楚了所谓闭包陷阱产生的原因和设计中的权衡。
    函数式组件与类组件有何不同?

  4. 去找一些社区的精品自定义 hook,看看他们的开发和设计思路,有没有能融入自己的日常开发中去的。
    精读《Hooks 取数 - swr 源码》
    Umi Hooks - 助力拥抱 React Hooks
    React Hooks 的体系设计之一 - 分层

React 性能优化

React 中优化组件重渲染,这里有几个隐含的知识点。
optimize-react-re-renders

如何对 React 函数式组件进行性能优化?这篇文章讲的很详细,值得仔细阅读一遍。
如何对 React 函数式组件进行优化

React 单元测试

  1. 使用@testing-library/react测试组件,这个库相比起 enzyme 更好的原因在于,它更注重于站在用户的角度去测试一个组件,而不是测试这个组件的实现细节
    Introducing The React Testing Library
    Testing Implementation Details

  2. 使用@testing-library/react-hooks测试自定义 Hook
    how-to-test-custom-react-hooks

React 和 TypeScript 结合使用

  1. 这个仓库非常详细的介绍了如何把 React 和 TypeScript 结合,并且给出了一些进阶用法的示例,非常值得过一遍!
    react-typescript-cheatsheet

  2. 这篇文章是蚂蚁金服数据体验技术部的同学带来的,其实除了这里面的技术文章以外,蚂蚁金服的同学也由非常生动给我们讲解了一个高级前端同学是如何去社区寻找方案,如何思考和落地到项目中的,由衷的佩服。
    React + Typescript 工程化治理实践

  3. 微软的大佬带你写一个类型安全的组件,非常深入,非常过瘾...
    Writing Type-Safe Polymorphic React Components (Without Crashing TypeScript)

  4. React + TypeScript 10 个需要避免的错误模式。
    10-typescript-pro-tips-patterns-with-or-without-react

React 代码抽象思考

  1. 何时应该把代码拆分为组件?
    when-to-break-up-a-component-into-multiple-components

  2. 仔细思考你的 React 应用中,状态应该放在什么位置,是组件自身,提升到父组件,亦或是局部 context 和 redux,这会有益于提升应用的性能和可维护性。
    state-colocation-will-make-your-react-app-faster

  3. 仔细思考 React 组件中的状态应该如何管理,优先使用派生状态,并且在适当的时候利用 useMemo、reselect 等库去优化他们。
    dont-sync-state-derive-it

  4. React Hooks 的自定义 hook 中,如何利用 reducer 的模式提供更加灵活的数据管理,让用户拥有数据的控制权。
    the-state-reducer-pattern-with-react-hooks

TypeScript

自从 Vue3 横空出世以来,TypeScript 好像突然就火了。这是一件好事,推动前端去学习强类型语言,开发更加严谨。并且第三方包的 ts 类型支持的加入,让我们甚至很多时候都不再需要打开文档对着 api 撸了。

关于 TypeScript 学习,其实几个月前我还对于这门 JavaScript 的超集一窍不通,经过两三个月的静心学习,我能够去理解一些相对复杂的类型了,

可以说 TypeScript 的学习和学一个库或者学一个框架是完全不同的,

入门

  1. 除了官方文档以外,还有一些比较好的中文入门教程。
    TypeScript Handbook 入门教程

  2. TypeScript Deep Dive 非常高质量的英文入门教学。
    TypeScript Deep Dive

  3. 工具泛型在日常开发中都非常的常用,必须熟练掌握。
    TS 一些工具泛型的使用及其实现

  4. 视频课程,还是黄轶大佬的,并且这个课程对于单元测试、前端手写框架、以及网络请求原理都非常有帮助。
    基于 TypeScript 从零重构 axios

进阶

  1. 这五篇文章里借助非常多的案例,为我们讲解了 ts 的一些高级用法,请务必反复在 ide 里尝试,理解,不懂的概念及时回到文档中补习。
    巧用 TypeScript 系列 一共五篇

  2. TS 进阶非常重要的一点,条件类型,很多泛型推导都需要借助它的力量。
    conditional-types-in-typescript

  3. 以及上面那个大佬博客中的所有 TS 文章。
    https://mariusschulz.com

实战

  1. 一个参数简化的实战,涉及到的高级知识点非常多。

    1. 🎉TypeScript 的高级类型(Advanced Type)
    2. 🎉Conditional Types (条件类型)
    3. 🎉Distributive conditional types (分布条件类型)
    4. 🎉Mapped types(映射类型)
    5. 🎉 函数重载
      TypeScript 参数简化实战
  2. 实现一个简化版的 Vuex,同样知识点结合满满。

    1. 🎉TypeScript 的高级类型(Advanced Type
    2. 🎉TypeScript 中利用泛型进行反向类型推导。(Generics)
    3. 🎉Mapped types(映射类型)
    4. 🎉Distributive Conditional Types(条件类型分配)
    5. 🎉TypeScript 中 Infer 的实战应用(Vue3 源码里 infer 的一个很重要的使用
      TS 实现智能类型推导的简化版 Vuex

刻意训练

它几乎是一门新的语言(在类型世界里来说),需要你花费很大的精力去学好它。

我对于 TypeScript 的学习建议其实就是一个关键词:刻意训练,在过基础概念的时候,不厌其烦的在vscode中敲击,理解,思考。在基础概念过完以后去寻找实践文章,比如我上面进阶实战部分推荐的几篇,继续刻意训练,一定要堆积代码量,学习一门新的语言是不可能靠看文档获得成功的。

我会建立一个仓库,专门记录我遇到的TypeScript 的有趣代码,自己动手敲一遍,并且深入理解。

能力分级

其实 TypeScript 的能力也是两级分化的,日常写业务来说,你定义一些 interface,配合 React.FC 这种官方内置的类型也就跑通了,没什么特别难的点。

但是如果是造轮子呢?如果你自己写了一个工具库,并且类型比较复杂,你能保证推导出来吗?亦或者就拿 Vue3 来说,ref 是一个很复杂的嵌套类型,

假如我们这样定义一个值const value = ref(ref(2)),对于嵌套的 ref,Vue3 会做一层拆包,也就是说其实ref.value会是 2,

那么它是如何让 ts 提示出 value 的类型是 number 的呢?

如果你看到源码里的这段代码,你只有基础的话,保证懵逼。
Vue3 跟着尤雨溪学 TypeScript 之 Ref 类型从零实现

// Recursively unwraps nested value bindings.
export type UnwrapRef<T> = {
  cRef: T extends ComputedRef<infer V> ? UnwrapRef<V> : T
  ref: T extends Ref<infer V> ? UnwrapRef<V> : T
  array: T
  object: { [K in keyof T]: UnwrapRef<T[K]> }
}[T extends ComputedRef<any>
  ? 'cRef'
  : T extends Array<any>
    ? 'array'
    : T extends Ref | Function | CollectionTypes | BaseTypes
      ? 'ref' // bail out on types that shouldn't be unwrapped
      : T extends object ? 'object' : 'ref']
业务开发人员

如果短期内你对自己的要求是能上手业务,那么你理解 TypeScript 基础的interfacetype编写和泛型的普通使用(可以理解为类型系统里的函数传参)也已经足够。

框架开发人员

但是长期来看,如果你的目的是能够自己编写一些类型完善的库或框架,或者说你在公司扮演前端架构师轮子专家等等角色,经常需要写一些偏底层的库给你的小伙伴们使用,那么你必须深入学习,这样才能做到给你的框架使用用户完美的类型体验。

面试题

TypeScript 相关的面试题我见得不多,不过力扣**的面试题算是难度偏高的,其中有一道 TS 的面试题,可以说是实用性和难度都有所兼顾,简单来说就是解包。

// 解开参数和返回值中的Promise
asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>
 
asyncMethod<T, U>(input: T): Action<U>

// 解开参数中的Action
syncMethod<T, U>(action: Action<T>): Action<U>
 
syncMethod<T, U>(action: T): Action<U>

我在高强度学习了两三个月 TS 的情况下,已经能把这道题目相对轻松的解出来,相信这也是说明我的学习路线没有走偏(题解就不放了,尊重面试题,其实就是考察了映射类型infer的使用)。
力扣面试题

代码质量

代码风格

  1. 在项目中集成 Prettier + ESLint + Airbnb Style Guide
    integrating-prettier-eslint-airbnb-style-guide-in-vscode

  2. 在项目中集成 ESLint with Prettier, TypeScript

高质量架构

  1. 如何重构一个过万 Star 开源项—BetterScroll,是由滴滴的大佬嵇智所带来的,无独有偶的是,这篇文章除了详细的介绍一个合格的开源项目应该做到的代码质量保证,测试流程,持续集成流程以外,也体现了他的一些思考深度,非常值得学习。
    如何重构一个过万 Star 开源项目—BetterScroll

Git 提交信息

  1. 很多新手在提交 Git 信息的时候会写的很随意,比如fixtest修复,这么糊弄的话是会被 leader 揍的!

    [译]如何撰写 Git 提交信息

    Git-Commit-Log 规范(Angular 规范)

    commitizen规范流程的 commit 工具,规范的 commit 格式也会让工具帮你生成友好的changelog

构建工具

  1. webpack 基础和优化
    深入浅出 webpack
  2. 滴滴前端工程师的 webpack 深入源码分析系列,非常的优秀。
    webpack 系列之一总览

性能优化

  1. 推荐修言大佬的性能优化小册,这个真的是讲的深入浅出,从webpack网络dom操作,全方位的带你做一些性能优化实战。这本小册我当时看的时候真的是完全停不下来,修言大佬的风格既轻松又幽默。但是讲解的东西却能让你受益匪浅。

  2. 谷歌开发者性能优化章节,不用多说了吧?很权威了。左侧菜单栏里还有更多相关内容,可以按需选择学习。
    user-centric-performance-metrics

  3. 详谈合成层,合成层这个东西离我们忽远忽近,可能你的一个不小心的操作就造成层爆炸,当然需要仔细关注啦。起码,在性能遇到瓶颈的时候,你可以打开 chrome 的layer面板,看看你的页面到底是怎么样的一个层分布。
    详谈层合成(composite)

  4. 刘博文大佬的性能优化指南,非常清晰的讲解了网页优化的几个重要的注意点。
    让你的网页更丝滑

社区讨论

作为一个合格的前端工程师,一定要积极的深入社区去了解最新的动向,比如在twitter上关注你喜欢的技术开发人员,如 Dan、尤雨溪。

另外 Github 上的很多 issue 也是宝藏讨论,我就以最近我对于 Vue3 的学习简单的举几个例子。

为什么 Vue3 不需要时间切片?

尤雨溪解释关于为什么在 Vue3 中不加入 React 时间切片功能?并且详细的分析了 React 和 Vue3 之间的一些细节差别,狠狠的吹了一波 Vue3(爱了爱了)。
Why remove time slicing from vue3?

Vue3 的composition-api到底好在哪?

Vue3 的 functional-api 相关的 rfc,尤大舌战群儒,深入浅出的为大家讲解了 Vue3 的设计思路等等。
Amendment proposal to Function-based Component API

Vue3composition-api的第一手文档

vue-composition-api 的 rfc 文档,在国内资料还不齐全的情况下,我去阅读了
vue-composition-api-rfc 英文版文档,对于里面的设计思路叹为观止,学到了非常非常多尤大的**。

总之,对于你喜欢的仓库,都可以去看看它的 issue 有没有看起来感兴趣的讨论,你也会学到非常多的东西。并且你可以和作者保持思路上的同步,这是非常难得的一件事情。

关于 Hook 的一些收获

我在狠狠的吸收了一波尤大对于 Vue3 composition-api的设计思路的讲解,新旧模式的对比以后,这篇文章就是我对 Vue3 新模式的一些见解。
Vue3 Composition-Api + TypeScript + 新型状态管理模式探索。

在 Vue2 里,可以通过plugin先体验composition-api,截取这篇文章对应的实战项目中的一小部分代码吧:

<template>
  <Books :books="booksAvaluable" :loading="loading" />
</template>

<script lang="ts">
import { createComponent } from '@vue/composition-api';
import Books from '@/components/Books.vue';
import { useAsync } from '@/hooks';
import { getBooks } from '@/hacks/fetch';
import { useBookListInject } from '@/context';
export default createComponent({
  name: 'books',
  setup() {
    const { books, setBooks, booksAvaluable } = useBookListInject();
    const loading = useAsync(async () => {
      const requestBooks = await getBooks();
      setBooks(requestBooks);
    });
    return { booksAvaluable, loading };
  },
  components: {
    Books,
  },
});
</script>

<style>
.content {
  max-width: 700px;
  margin: auto;
}
</style>

本实战对应仓库:

vue-bookshelf

并且由于它和React Hook在很多方面的**也非常相近,这甚至对于我在React Hook上的使用也大有裨益,比如代码组织的思路上,

在第一次使用Hook开发的时候,大部分人可能还是会保留着以前的**,把state集中起来定义在代码的前一大段,把computed集中定义在第二段,把mutation定义在第三段,如果不看尤大对于设计**的讲解,我也一直是在这样做。

但是为什么 Logical Concerns 优于 Vue2 和 React Class Component 的 Option Types?看完detailed-design这个章节你就全部明白了,并且这会融入到你日常开发中去。

总之,看完这篇以后,我果断的把公司里的首屏组件的一坨代码直接抽成了 n 个自定义 hook,维护效率提升简直像是坐火箭。

当然,社区里的宝藏 issue 肯定不止这些,我只是简单的列出了几个,但就是这几个都让我的技术视野开阔了很多,并且是真正的融入到公司的业务实战中去,是具有业务价值的。希望你养成看 issue,紧跟英文社区的习惯,Github issue 里单纯的技术探讨氛围,真的是国内很少有社区可以媲美的。

function AppInner({ children }) {
  const [menus, setMenus] = useState({});

  // 用户信息
  const user = useUser();

  // 主题能力
  useTheme();

  // 权限获取
  useAuth({
    setMenus,
  });

  // 动态菜单也需要用到菜单的能力
  useDynamicMenus({
    menus,
    setMenus,
  });

  return (
    <Context.Provider value={user}>
      <Layout routers={backgrounds}>{children}</Layout>
    </Context.Provider>
  );
}

可以看到,Hook在代码组织的方面有着得天独厚的优势,甚至各个模块之间值的传递都是那么的自然,仅仅是函数传参而已。
总之,社区推出一些新的东西,它总归是解决了之前的一些痛点。我们跟着大佬的思路走,一定有肉吃。

Tree Shaking 的 Issue

相学长的文章你的 Tree-Shaking 并没什么卵用中,也详细的描述了他对于副作用的一些探寻过程,在UglifyJS 的 Issue中找到了最终的答案,然后贡献给中文社区,这些内容最开始不会在任何中文社区里出现,只有靠你去探寻和发现。

学习方法的转变

从初中级前端开始往高级前端进阶,有一个很重要的点,就是很多情况下国内社区能找到的资料已经不够用了,而且有很多优质资料也是从国外社区二手、三手翻译过来的,翻译质量也不能保证。

这就引申出我们进阶的第一个点,开始接受英文资料

这里很多同学说,我的英文能力不行啊,看不懂。其实我想说,笔者的英语能力也很一般,从去年开始我立了个目标,就是带着划词翻译插件也要开始艰难的看英文文章和资料,遇到不懂的单词就划出来看两眼(没有刻意去背),第五六次遇见这个单词的时候,就差不多记得它是什么意思了。

半年左右的时间下来,(大概保持每周 3 篇以上的阅读量)能肉眼可见的感觉自己的英语能力在进步,很多时候不用划词翻译插件,也可以完整的阅读下来一段文章。

这里是我当时阅读英文优质文章的一些记录,

英文技术文章阅读

后面英文阅读慢慢成了一件比较自然的事情,也就没有再刻意去记录,前期可以用这种方式激励自己。

推荐两个英文站点吧,有很多高质量的前端文章。

dev.to
medium

medium 可能需要借助一些科学工具才能查看,但是里面的会员付费以及作者激励机制使得文章非常的优质。登录自己的谷歌账号即可成为会员,前期可能首页不会推荐一些前端相关的文章,你可以自己去搜索关键字如VueReactWebpack,任何你兴趣的前端技术栈,不需要过多久你的首页就会出现前端的推荐内容。好好享受这个高质量的英文社区吧。

关于实践

社区有很多大佬实力很强,但是对新手写的代码嗤之以鼻,认为有 any 的就不叫 TypeScript、认为没有单元测试就没资格丢到 Github 上去。这种言论其实也不怪他们,他们也只是对开源软件的要求高到偏执而已。但是对于新手学习来说,这种言论很容易对大家造成打击,导致不敢写 ts,写的东西不敢放出来。其实大可不必,工业聚 对于这些观点就发表了一篇很好的看法,让我觉得深受打动,也就是这篇文章开始,我慢慢的把旧项目用 ts 改造起来,慢慢的进步。

Vue 3.0 公开代码之后……

总结

本篇文章是我在这一年多的学习经历抽象中总结出来,还有很多东西我会陆续加入到这篇文章中去。

希望作为初中级前端工程师的你,能够有所收获。如果能够帮助到你就是我最大的满足。

未完待续... 持续更新中。

❤️ 感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

Vue 和 React 对于组件的更新粒度有什么区别?

前言

我们都知道 Vue 对于响应式属性的更新,只会精确更新依赖收集的当前组件,而不会递归的去更新子组件,这也是它性能强大的原因之一。

例子

举例来说 这样的一个组件:

<template>
   <div>
      {{ msg }}
      <ChildComponent />
   </div>
</template>

我们在触发 this.msg = 'Hello, Changed~'的时候,会触发组件的更新,视图的重新渲染。

但是 <ChildComponent /> 这个组件其实是不会重新渲染的,这是 Vue 刻意而为之的。

在以前的一段时间里,我曾经认为因为组件是一棵树,所以它的更新就是理所当然的深度遍历这棵树,进行递归更新。本篇就从源码的角度带你一起分析,Vue 是怎么做到精确更新的。

React的更新粒度

而 React 在类似的场景下是自顶向下的进行递归更新的,也就是说,React 中假如 ChildComponent 里还有十层嵌套子元素,那么所有层次都会递归的重新render(在不进行手动优化的情况下),这是性能上的灾难。(因此,React 创造了Fiber,创造了异步渲染,其实本质上是弥补被自己搞砸了的性能)。

他们能用收集依赖的这套体系吗?不能,因为他们遵从Immutable的设计**,永远不在原对象上修改属性,那么基于 Object.definePropertyProxy 的响应式依赖收集机制就无从下手了(你永远返回一个新的对象,我哪知道你修改了旧对象的哪部分?)

同时,由于没有响应式的收集依赖,React 只能递归的把所有子组件都重新 render一遍,然后再通过 diff算法 决定要更新哪部分的视图,这个递归的过程叫做 reconciler,听起来很酷,但是性能很灾难。

Vue的更新粒度

那么,Vue 这种精确的更新是怎么做的呢?其实每个组件都有自己的渲染 watcher,它掌管了当前组件的视图更新,但是并不会掌管 ChildComponent 的更新。

具体到源码中,是怎么样实现的呢?

patch 的过程中,当组件更新到ChildComponent的时候,会走到
patchVnode,那么这个方法大致做了哪些事情呢?

patchVnode

执行 vnodeprepatch 钩子。

注意,只有 组件vnode 才会有 prepatch 这个生命周期,

这里会走到updateChildComponent方法,这个 child 具体指什么呢?

  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    // 注意 这个child就是ChildComponent组件的 vm 实例,也就是咱们平常用的 this
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
  },

其实看传入的参数也能猜到大概了,就是做了:

  1. 更新props(后续详细讲)
  2. 更新绑定事件
  3. 对于slot做一些更新(后续详细讲)

如果有子节点的话,对子节点进行 diff。

比如这样的场景:

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
<ul>

要对于 ul 中的三个 li 子节点 vnode 利用 diff 算法来更新,本篇略过。

然后到此为止,patchVnode 就结束了,并没有像常规思维中的那样去递归的更新子组件树。

这也就说明了,Vue 的组件更新确实是精确到组件本身的

props的更新如何触发重渲染?

那么有同学可能要问了,如果我们把 msg 这个响应式元素通过props传给 ChildComponent,此时它怎么更新呢?

其实,msg 在传给子组件的时候,会被保存在子组件实例的 _props 上,并且被定义成了响应式属性,而子组件的模板中对于 msg 的访问其实是被代理到 _props.msg 上去的,所以自然也能精确的收集到依赖,只要 ChildComponent 在模板里也读取了这个属性。

这里要注意一个细节,其实父组件发生重渲染的时候,是会重新计算子组件的 props 的,具体是在 updateChildComponent 中的:

  // update props
  if (propsData && vm.$options.props) {
    toggleObserving(false)
    // 注意props被指向了 _props
    const props = vm._props
    const propKeys = vm.$options._propKeys || []
    for (let i = 0; i < propKeys.length; i++) {
      const key = propKeys[i]
      const propOptions: any = vm.$options.props // wtf flow?
      // 就是这句话,触发了对于 _props.msg 的依赖更新。
      props[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    // keep a copy of raw propsData
    vm.$options.propsData = propsData
  }

那么,由于上面注释标明的那段代码,msg 的变化通过 _props 的响应式能力,也让子组件重新渲染了,到目前为止,都只有真的用到了 msg 的组件被重新渲染了。

正如官网 api 文档中所说:

vm.$forceUpdate:迫使 Vue 实例重新渲染。注意它仅仅影响实例本身和插入插槽内容的子组件,而不是所有子组件。
—— vm-forceUpdate文档

我们需要知道一个小知识点,vm.$forceUpdate 本质上就是触发了渲染watcher的重新执行,和你去修改一个响应式的属性触发更新的原理是一模一样的,它只是帮你调用了 vm._watcher.update()(只是提供给你了一个便捷的api,在设计模式中叫做门面模式

slot是怎么更新的?

注意这里也提到了一个细节,也就是 插入插槽内容的子组件

举例来说

假设我们有父组件parent-comp

<div>
  <slot-comp>
     <span>{{ msg }}</span>
  </slot-comp>
</div>

子组件 slot-comp

<div>
   <slot></slot>
</div>

组件中含有 slot的更新 ,是属于比较特殊的场景。

这里的 msg 属性在进行依赖收集的时候,收集到的是 parent-comp 的`渲染watcher。(至于为什么,你看一下它所在的渲染上下文就懂了。)

那么我们想象 msg 此时更新了,

<div>
  <slot-comp>
     <span>{{ msg }}</span>
  </slot-comp>
</div>

这个组件在更新的时候,遇到了一个子组件 slot-comp,按照 Vue 的精确更新策略来说,子组件是不会重新渲染的。

但是在源码内部,它做了一个判断,在执行 slot-compprepatch 这个hook的时候,会执行 updateChildComponent 逻辑,在这个函数内部会发现它有 slot 元素。

  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    // 注意 这个child就是 slot-comp 组件的 vm 实例,也就是咱们平常用的 this
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
  },

updateChildComponent 内部

  const hasChildren = !!(
    // 这玩意就是 slot 元素
    renderChildren ||               // has new static slots
    vm.$options._renderChildren ||  // has old static slots
    parentVnode.data.scopedSlots || // has new scoped slots
    vm.$scopedSlots !== emptyObject // has old scoped slots
  )

然后下面走一个判断

  if (hasChildren) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context)
    vm.$forceUpdate()
  }

这里调用了 slot-comp 组件vm实例上的 $forceUpdate,那么它所触发的渲染watcher就是属于slot-comp渲染watcher了。

总结来说,这次 msg 的更新不光触发了 parent-comp 的重渲染,也进一步的触发了拥有slot的子组件 slot-comp 的重渲染。

它也只是触发了两层渲染,如果 slot-comp 内部又渲染了其他组件 slot-child,那么此时它是不会进行递归更新的。(只要 slot-child 组件不要再有 slot 了)。

比起 React 的递归更新,是不是还是好上很多呢?

赠礼 一个小issue

有人给 Vue 2.4.2 版本提了一个issue,在下面的场景下会出现 bug。

let Child = {
  name: "child",
  template:
    '<div><span>{{ localMsg }}</span><button @click="change">click</button></div>',
  data: function() {
    return {
      localMsg: this.msg
    };
  },
  props: {
    msg: String
  },
  methods: {
    change() {
      this.$emit("update:msg", "world");
    }
  }
};

new Vue({
  el: "#app",
  template: '<child :msg.sync="msg"><child>',
  beforeUpdate() {
    alert("update twice");
  },
  data() {
    return {
      msg: "hello"
    };
  },
  components: {
    Child
  }
});

具体的表现是点击 click按钮,会 alert 出两次 update twice。 这是由于子组件在执行 data 这个函数初始化组件的数据时,会错误的再收集一遍 Dep.target (也就是渲染watcher)。

由于数据初始化的时机是 beforeCreated -> created 之间,此时由于还没有进入子组件的渲染阶段, Dep.target 还是父组件的渲染watcher

这就导致重复收集依赖,重复触发同样的更新,具体表现可以看这里:https://jsfiddle.net/sbmLobvr/9

怎么解决的呢?很简单,在执行 data 函数的前后,把 Dep.target 先设置为 null 即可,在 finally 中再恢复,这样响应式数据就没办法收集到依赖了。

export function getData (data: Function, vm: Component): any {
  const prevTarget = Dep.target
+ Dep.target = null
  try {
    return data.call(vm, vm)
  } catch (e) {
    handleError(e, vm, `data()`)
    return {}
+ } finally {
+   Dep.target = prevTarget
  }
}

后记

如果你对于 Dep.target渲染watcher等概念还不太理解,可以看我写的一篇最简实现 Vue 响应式的文章,欢迎阅读:

手把手带你实现一个最精简的响应式系统来学习Vue的data、computed、watch源码

本文也存放在我的Github博客仓库中,欢迎订阅和star。

鸣谢

感谢 嵇智 大佬对于本文一些细节的纠正。

Vue源码学习 计算属性computed

上一篇讲解(摘抄)了Vue响应式实现的原理,良好的设计为很多看似复杂的功能奠定了基础,使得这些功能的实际实现变得很简单。

我们先得出个结论,Watcher这个类即可以用做渲染函数的watcher, 也可以用作计算属性的Watcher,这两者在初始化和部分函数的分支都是不同的, watcher的更新核心方法是update,可以说计算属性的update是为了驱动渲染watcher的update,而渲染watcher的update是为了重新调用vm._update(vm._render())方法去更新真正的页面。

首先来看初始化函数的简化版本

initComputed

function initComputed (vm: Component, computed: Object) {
  const watchers = vm._computedWatchers = Object.create(null)

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get

   watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
    )

    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    }
  }
}

vm就是vue实例,computed就是用户定义的computed对象。

首先定义了watchers数组和vm.__computedWatchers为一个空对象

  const watchers = vm._computedWatchers = Object.create(null)

接下来遍历用户传入的computed对象,computed里面可以是

key: {
  get: ...,
  set: ...
}

的形式,也可以是

key: function() {}

的形式, 所以先取到这个getter函数,

const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get

然后为每个computed的key生成一个watcher观察者, getter就是用户传入的计算函数

watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
 )

computedWatcherOptions其实就是{ computed: true }这个对象,这会使得watcher被初始化为计算属性的watcher(下文简称计算watcher),

在watcher构造函数里有这么一段,
可以看到计算watcher的value被初始化为undefined,这说明了计算属性是惰性求值,并且计算watcher的实例下定义了this.dep = new Dep()。

if (this.computed) {
      this.value = undefined
      this.dep = new Dep()
    } else {
      this.value = this.get()
    }
defineComputed(vm, key, userDef)

在这之后调用了defineComputed把计算属性的key代理到了this下面,getter就定义为createComputedGetter(key),先看看createComputedGetter做了什么。

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      watcher.depend()
      return watcher.evaluate()
    }
  }
}

条件判断语句中有两句关键的代码,我们分开来看

 watcher.depend()
 return watcher.evaluate()

watcher.depend()

这个getter函数会在渲染模板遇到{{ computedValue }}这样的值的时候触发。
这时会先取到key对应的计算watcher, 并且调用watcher的depend()方法收集依赖。

  /**
   * Depend on this watcher. Only for computed property watchers.
   */
  depend () {
    if (this.dep && Dep.target) {
      this.dep.depend()
    }
  }

this.dep就是在初始化时为watcher生成的,可以思考一下在这个时候调用dep的depend会收集到什么,我们来看看dep的depend

 depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

因为正在根据template生成对应的真实dom,所以这个时候的Dep.target一定是当前组件的渲染watcher,那么其实这个dep收集到的就是渲染watcher。

到这个时候,依赖收集完成了。 那我们接下来看

return watcher.evaluate()

  evaluate () {
    if (this.dirty) {
      this.value = this.get()
      this.dirty = false
    }
    return this.value
  }

这个其实是专为计算watcher设计的求值函数,this.dirty一定是在计算watcher的情况下才为true,
这时候会把this.value调用this.get()去求值,我们来看看this.get做了什么。

  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

首先调用pushTarget(this), 把计算watcher设置为现在的全局Dep.target,这样其他的dep收集依赖就会收集到计算watcher了, 然后

 value = this.getter.call(vm, vm)

这个时候的getter就会调用用户自定义的计算函数 比如

computed: {
  sum() {
     return this.a + this. b
  }
}

那么此时的getter会去调用return this.a + this. b,
而在求这个值的过程中, 又会触发a和b的dep的depend, 这个时候a和b都会收集到这个计算watcher作为依赖

那么我们之后再一些methods里写this.a = 2 这样去改变a的值, 会触发a的dep去通知计算watcher去做update, 计算watcher的update方法又会去

this.dep.notify()

触发watcher的dep的notify, 这个dep收集了渲染watcher, 这样会驱动渲染watcher去执行update()就会去重新渲染页面, 这样就达成了修改a属性去触发依赖a的视图和依赖sum的视图重新进行渲染。

 update () {
    queueWatcher(this)
  }

queueWatcher会在nextTick执行watcher.run()

run () {
    if (this.active) {
      this.getAndInvoke(this.cb)
    }
  }

此时的this.cb 是渲染watcher的cb 也就是vm._update(vm._render())
这样页面就会重新渲染,更新视图

前端算法进阶指南

前言

最近国内大厂面试中,出现 LeetCode 真题考察的频率越来越高了。我也观察到有越来越多的前端同学开始关注算法这个话题。

但是算法是一个门槛很高的东西,在一个算法新手的眼里,它的智商门槛要求很高。事实上是这个样子的吗?如果你怀疑自己的智商不够去学习算法,那么你一定要先看完这篇文章:《天生不聪明》,也正是这篇文章激励了我开始了算法之路。

这篇文章,我会先总结几个必学的题目分类,给出这个分类下必做例题的详细题解,并且在文章的末尾给出每个分类下必刷的题目的获取方式。

一定要耐心看到底,会有重磅干货。

心路

我从 5 月份准备离职的时候开始学习算法,在此之前对于算法我是零基础,在最开始我对于算法的感受也和大家一样,觉得自己智商可能不够,望而却步。但是看了一些大佬对于算法和智商之间的关系,我发现算法好像也是一个通过练习可以慢慢成长的学科,而不是只有智商达到了某个点才能有入场券,所以我开始了我的算法之路。通过视频课程 + 分类刷题 + 总结题解 + 回头复习的方式,我在两个月的时间里把力扣的解题数量刷到了200题。对于一个算法新人来说,这应该算是一个还可以的成绩,这篇文章,我把我总结的一些经典题解分享给大家。

学习方式

  1. 分类刷题:很多第一次接触力扣的同学对于刷题的方法不太了解,有的人跟着题号刷,有的人跟着每日一题刷,但是这种漫无目的的刷题方式一般都会在中途某一天放弃,或者刷了很久但是却发现没什么沉淀。这里不啰嗦,直接点明一个所有大佬都推荐的刷题方法:把自己的学习阶段分散成几个时间段去刷不同分类的题型,比如第一周专门解链表相关题型,第二周专门解二叉树相关题型。这样你的知识会形成一个体系,通过一段时间的刻意练习把这个题型相关的知识点强化到你的脑海中,不容易遗忘。

  2. 适当放弃:很多同学遇到一个难题,非得埋头钻研,干他 2 个小时。最后挫败感十足,久而久之可能就放弃了算法之路。要知道算法是个沉淀了几十年的领域,题解里的某个算法可能是某些教授研究很多年的心血,你想靠自己一个新手去想出来同等优秀的解法,岂不是想太多了。所以要学会适当放弃,一般来说,比较有目的性(面试)刷题的同学,他面对一道新的题目毫无头绪的话,会在 10 分钟之内直接放弃去看题解,然后记录下来,反复复习,直到这个解法成为自己的知识为止。这是效率最高的学习办法。

  3. 接受自己是新手:没错,说的难听一点,接受自己不是天才这个现实。你在刷题的过程中会遇到很多困扰你的时候,比如相同的题型已经看过例题,稍微变了条件就解不出来。或者对于一个 easy 难度的题毫无头绪。或者甚至看不懂别人的题解(没错我经常)相信我,这很正常,不能说明你不适合学习算法,只能说明算法确实是一个博大精深的领域,把自己在其他领域的沉淀抛开来,接受自己是新手这个事实,多看题解,多请教别人。

分类大纲

  1. 算法的复杂度分析。
  2. 排序算法,以及他们的区别和优化。
  3. 数组中的双指针、滑动窗口**。
  4. 利用 Map 和 Set 处理查找表问题。
  5. 链表的各种问题。
  6. 利用递归和迭代法解决二叉树问题。
  7. 栈、队列、DFS、BFS。
  8. 回溯法、贪心算法、动态规划。

题解

接下来我会放出几个分类的经典题型,以及我对应的讲解,当做开胃菜,并且在文章的末尾我会给出获取每个分类推荐你去刷的题目的合集,记得看到底哦。

查找表问题

两个数组的交集 II-350

给定两个数组,编写一个函数来计算它们的交集。

示例 1:

输入: nums1 = [1,2,2,1], nums2 = [2,2]
输出: [2,2]
示例 2:

输入: nums1 = [4,9,5], nums2 = [9,4,9,8,4]
输出: [4,9]

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/intersection-of-two-arrays-ii
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


为两个数组分别建立 map,用来存储 num -> count 的键值对,统计每个数字出现的数量。

然后对其中一个 map 进行遍历,查看这个数字在两个数组中分别出现的数量,取出现的最小的那个数量(比如数组 1 中出现了 1 次,数组 2 中出现了 2 次,那么交集应该取 1 次),push 到结果数组中即可。

/**
 * @param {number[]} nums1
 * @param {number[]} nums2
 * @return {number[]}
 */
let intersect = function (nums1, nums2) {
  let map1 = makeCountMap(nums1)
  let map2 = makeCountMap(nums2)
  let res = []
  for (let num of map1.keys()) {
    const count1 = map1.get(num)
    const count2 = map2.get(num)

    if (count2) {
      const pushCount = Math.min(count1, count2)
      for (let i = 0; i < pushCount; i++) {
        res.push(num)
      }
    }
  }
  return res
}

function makeCountMap(nums) {
  let map = new Map()
  for (let i = 0; i < nums.length; i++) {
    let num = nums[i]
    let count = map.get(num)
    if (count) {
      map.set(num, count + 1)
    } else {
      map.set(num, 1)
    }
  }
  return map
}

双指针问题

最接近的三数之和-16

给定一个包括  n 个整数的数组  nums  和 一个目标值  target。找出  nums  中的三个整数,使得它们的和与  target  最接近。返回这三个数的和。假定每组输入只存在唯一答案。

示例:

输入:nums = [-1,2,1,-4], target = 1
输出:2
解释:与 target 最接近的和是 2 (-1 + 2 + 1 = 2) 。

提示:

3 <= nums.length <= 10^3
-10^3 <= nums[i] <= 10^3
-10^4 <= target <= 10^4

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/3sum-closest

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


先按照升序排序,然后分别从左往右依次选择一个基础点 i0 <= i <= nums.length - 3),在基础点的右侧用双指针去不断的找最小的差值。

假设基础点是 i,初始化的时候,双指针分别是:

  • lefti + 1,基础点右边一位。
  • right: nums.length - 1 数组最后一位。

然后求此时的和,如果和大于 target,那么可以把右指针左移一位,去试试更小一点的值,反之则把左指针右移。

在这个过程中,不断更新全局的最小差值 min,和此时记录下来的和 res

最后返回 res 即可。

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
let threeSumClosest = function (nums, target) {
  let n = nums.length
  if (n === 3) {
    return getSum(nums)
  }
  // 先升序排序 此为解题的前置条件
  nums.sort((a, b) => a - b)

  let min = Infinity // 和 target 的最小差
  let res

  // 从左往右依次尝试定一个基础指针 右边至少再保留两位 否则无法凑成3个
  for (let i = 0; i <= nums.length - 3; i++) {
    let basic = nums[i]
    let left = i + 1 // 左指针先从 i 右侧的第一位开始尝试
    let right = n - 1 // 右指针先从数组最后一项开始尝试

    while (left < right) {
      let sum = basic + nums[left] + nums[right] // 三数求和
      // 更新最小差
      let diff = Math.abs(sum - target)
      if (diff < min) {
        min = diff
        res = sum
      }
      if (sum < target) {
        // 求出的和如果小于目标值的话 可以尝试把左指针右移 扩大值
        left++
      } else if (sum > target) {
        // 反之则右指针左移
        right--
      } else {
        // 相等的话 差就为0 一定是答案
        return sum
      }
    }
  }

  return res
}

function getSum(nums) {
  return nums.reduce((total, cur) => total + cur, 0)
}

滑动窗口问题

无重复字符的最长子串-3

给定一个字符串,请你找出其中不含有重复字符的   最长子串   的长度。

示例  1:

输入: "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:

输入: "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

输入: "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/longest-substring-without-repeating-characters
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


这题是比较典型的滑动窗口问题,定义一个左边界 left 和一个右边界 right,形成一个窗口,并且在这个窗口中保证不出现重复的字符串。

这需要用到一个新的变量 freqMap,用来记录窗口中的字母出现的频率数。在此基础上,先尝试取窗口的右边界再右边一个位置的值,也就是 str[right + 1],然后拿这个值去 freqMap 中查找:

  1. 这个值没有出现过,那就直接把 right ++,扩大窗口右边界。
  2. 如果这个值出现过,那么把 left ++,缩进左边界,并且记得把 str[left] 位置的值在 freqMap 中减掉。

循环条件是 left < str.length,允许左边界一直滑动到字符串的右界。

/**
 * @param {string} s
 * @return {number}
 */
let lengthOfLongestSubstring = function (str) {
  let n = str.length
  // 滑动窗口为s[left...right]
  let left = 0
  let right = -1
  let freqMap = {} // 记录当前子串中下标对应的出现频率
  let max = 0 // 找到的满足条件子串的最长长度

  while (left < n) {
    let nextLetter = str[right + 1]
    if (!freqMap[nextLetter] && nextLetter !== undefined) {
      freqMap[nextLetter] = 1
      right++
    } else {
      freqMap[str[left]] = 0
      left++
    }
    max = Math.max(max, right - left + 1)
  }

  return max
}

链表问题

两两交换链表中的节点-24

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。

你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。

示例:

给定 1->2->3->4, 你应该返回 2->1->4->3.

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/swap-nodes-in-pairs

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


这题本意比较简单,1 -> 2 -> 3 -> 4 的情况下可以定义一个递归的辅助函数 helper,这个辅助函数对于节点和它的下一个节点进行交换,比如 helper(1) 处理 1 -> 2,并且把交换变成 2 -> 1 的尾节点 1next继续指向 helper(3)也就是交换后的 4 -> 3

边界情况在于,如果顺利的作了两两交换,那么交换后我们的函数返回出去的是 交换后的头部节点,但是如果是奇数剩余项的情况下,没办法做交换,那就需要直接返回 原本的头部节点。这个在 helper函数和主函数中都有体现。

let swapPairs = function (head) {
  if (!head) return null
  let helper = function (node) {
    let tempNext = node.next
    if (tempNext) {
      let tempNextNext = node.next.next
      node.next.next = node
      if (tempNextNext) {
        node.next = helper(tempNextNext)
      } else {
        node.next = null
      }
    }
    return tempNext || node
  }

  let res = helper(head)

  return res || head
}

深度优先遍历问题

二叉树的所有路径-257

给定一个二叉树,返回所有从根节点到叶子节点的路径。

说明:  叶子节点是指没有子节点的节点。

示例:

输入:

   1
 /   \
2     3
 \
  5

输出: ["1->2->5", "1->3"]

解释: 所有根节点到叶子节点的路径为: 1->2->5, 1->3

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/binary-tree-paths

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


用当前节点的值去拼接左右子树递归调用当前函数获得的所有路径。

也就是根节点拼上以左子树为根节点得到的路径,加上根节点拼上以右子树为根节点得到的所有路径。

直到叶子节点,仅仅返回包含当前节点的值的数组。

let binaryTreePaths = function (root) {
  let res = []
  if (!root) {
    return res
  }

  if (!root.left && !root.right) {
    return [`${root.val}`]
  }

  let leftPaths = binaryTreePaths(root.left)
  let rightPaths = binaryTreePaths(root.right)

  leftPaths.forEach((leftPath) => {
    res.push(`${root.val}->${leftPath}`)
  })
  rightPaths.forEach((rightPath) => {
    res.push(`${root.val}->${rightPath}`)
  })

  return res
}

广度优先遍历(BFS)问题

在每个树行中找最大值-515

https://leetcode-cn.com/problems/find-largest-value-in-each-tree-row

您需要在二叉树的每一行中找到最大的值。

输入:

          1
         / \
        3   2
       / \   \
      5   3   9

输出: [1, 3, 9]


这是一道典型的 BFS 题目,BFS 的套路其实就是维护一个 queue 队列,在读取子节点的时候同时把发现的孙子节点 push 到队列中,但是先不处理,等到这一轮队列中的子节点处理完成以后,下一轮再继续处理的就是孙子节点了,这就实现了层序遍历,也就是一层层的去处理。

但是这里有一个问题卡住我了一会,就是如何知道当前处理的节点是哪个层级的,在最开始的时候我尝试写了一下二叉树求某个 index 所在层级的公式,但是发现这种公式只能处理「平衡二叉树」。

后面看题解发现他们都没有专门维护层级,再仔细一看才明白层级的思路:

其实就是在每一轮 while 循环里,再开一个 for 循环,这个 for 循环的终点是「提前缓存好的 length 快照」,也就是进入这轮 while 循环时,queue 的长度。其实这个长度就恰好代表了「一个层级的长度」。

缓存后,for 循环里可以安全的把子节点 push 到数组里而不影响缓存的当前层级长度。

另外有一个小 tips,在 for 循环处理完成后,应该要把 queue 的长度截取掉上述的缓存长度。一开始我使用的是 queue.splice(0, len),结果速度只击败了 33%的人。后面换成 for 循环中去一个一个shift来截取,速度就击败了 77%的人。

/**
 * @param {TreeNode} root
 * @return {number[]}
 */
let largestValues = function (root) {
  if (!root) return []
  let queue = [root]
  let maximums = []

  while (queue.length) {
    let max = Number.MIN_SAFE_INTEGER
    // 这里需要先缓存length 这个length代表当前层级的所有节点
    // 在循环开始后 会push新的节点 length就不稳定了
    let len = queue.length
    for (let i = 0; i < len; i++) {
      let node = queue[i]
      max = Math.max(node.val, max)

      if (node.left) {
        queue.push(node.left)
      }
      if (node.right) {
        queue.push(node.right)
      }
    }

    // 本「层级」处理完毕,截取掉。
    for (let i = 0; i < len; i++) {
      queue.shift()
    }

    // 这个for循环结束后 代表当前层级的节点全部处理完毕
    // 直接把计算出来的最大值push到数组里即可。
    maximums.push(max)
  }

  return maximums
}

栈问题

有效的括号-20

给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。

有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。
  • 注意空字符串可被认为是有效字符串。

示例 1:

输入: "()"
输出: true

示例 2:

输入: "()[]{}"
输出: true

示例 3:

输入: "(]"
输出: false

示例 4:

输入: "([)]"
输出: false

示例 5:

输入: "{[]}"
输出: true

https://leetcode-cn.com/problems/valid-parentheses


提前记录好左括号类型 (, {, [和右括号类型), }, ]的映射表,当遍历中遇到左括号的时候,就放入栈 stack 中(其实就是数组),当遇到右括号时,就把 stack 顶的元素 pop 出来,看一下是否是这个右括号所匹配的左括号(比如 () 是一对匹配的括号)。

当遍历结束后,栈中不应该剩下任何元素,返回成功,否则就是失败。

/**
 * @param {string} s
 * @return {boolean}
 */
let isValid = function (s) {
  let sl = s.length
  if (sl % 2 !== 0) return false
  let leftToRight = {
    "{": "}",
    "[": "]",
    "(": ")",
  }
  // 建立一个反向的 value -> key 映射表
  let rightToLeft = createReversedMap(leftToRight)
  // 用来匹配左右括号的栈
  let stack = []

  for (let i = 0; i < s.length; i++) {
    let bracket = s[i]
    // 左括号 放进栈中
    if (leftToRight[bracket]) {
      stack.push(bracket)
    } else {
      let needLeftBracket = rightToLeft[bracket]
      // 左右括号都不是 直接失败
      if (!needLeftBracket) {
        return false
      }

      // 栈中取出最后一个括号 如果不是需要的那个左括号 就失败
      let lastBracket = stack.pop()
      if (needLeftBracket !== lastBracket) {
        return false
      }
    }
  }

  if (stack.length) {
    return false
  }
  return true
}

function createReversedMap(map) {
  return Object.keys(map).reduce((prev, key) => {
    const value = map[key]
    prev[value] = key
    return prev
  }, {})
}

递归与回溯

直接看我写的这两篇文章即可,递归与回溯甚至是平常业务开发中最常见的算法场景之一了,所以我重点总结了两篇文章。

《前端电商 sku 的全排列算法很难吗?学会这个套路,彻底掌握排列组合。》

前端「N 皇后」递归回溯经典问题图解

动态规划

打家劫舍 - 198

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
  偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:

输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
  偷窃到的最高金额 = 2 + 9 + 1 = 12 。

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/house-robber
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


动态规划的一个很重要的过程就是找到「状态」和「状态转移方程」,在这个问题里,设 i 是当前屋子的下标,状态就是 以 i 为起点偷窃的最大价值

在某一个房子面前,盗贼只有两种选择:偷或者不偷

  1. 偷的话,价值就是「当前房子的价值」+「下两个房子开始盗窃的最大价值」
  2. 不偷的话,价值就是「下一个房子开始盗窃的最大价值」

在这两个值中,选择最大值记录在 dp[i]中,就得到了i 为起点所能偷窃的最大价值。

动态规划的起手式,找基础状态,在这题中,以终点为起点的最大价值一定是最好找的,因为终点不可能再继续往后偷窃了,所以设 n 为房子的总数量, dp[n - 1] 就是 nums[n - 1],小偷只能选择偷窃这个房子,而不能跳过去选择下一个不存在的房子。

那么就找到了动态规划的状态转移方程:

// 抢劫当前房子
robNow = nums[i] + dp[i + 2] // 「当前房子的价值」 + 「i + 2 下标房子为起点的最大价值」

// 不抢当前房子,抢下一个房子
robNext = dp[i + 1] //「i + 1 下标房子为起点的最大价值」

// 两者选择最大值
dp[i] = Math.max(robNow, robNext)

,并且从后往前求解。

function (nums) {
  if (!nums.length) {
    return 0;
  }
  let dp = [];

  for (let i = nums.length - 1; i >= 0; i--) {
    let robNow = nums[i] + (dp[i + 2] || 0)
    let robNext = dp[i + 1] || 0

    dp[i] = Math.max(robNow, robNext)
  }

  return dp[0];
};

最后返回 以 0 为起点开始打劫的最大价值 即可。

贪心算法问题

分发饼干-455

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i ,都有一个胃口值  gi ,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j ,都有一个尺寸 sj 。如果 sj >= gi ,我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。

注意:

你可以假设胃口值为正。
一个小朋友最多只能拥有一块饼干。

示例 1:

输入: [1,2,3], [1,1]

输出: 1

解释:
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1。
示例 2:

输入: [1,2], [1,2,3]

输出: 2

解释:
你有两个孩子和三块小饼干,2个孩子的胃口值分别是1,2。
你拥有的饼干数量和尺寸都足以让所有孩子满足。
所以你应该输出2.

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/assign-cookies
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


把饼干和孩子的需求都排序好,然后从最小的饼干分配给需求最小的孩子开始,不断的尝试新的饼干和新的孩子,这样能保证每个分给孩子的饼干都恰到好处的不浪费,又满足需求。

利用双指针不断的更新 i 孩子的需求下标和 j饼干的值,直到两者有其一达到了终点位置:

  1. 如果当前的饼干不满足孩子的胃口,那么把 j++ 寻找下一个饼干,不用担心这个饼干被浪费,因为这个饼干更不可能满足下一个孩子(胃口更大)。
  2. 如果满足,那么 i++; j++; count++ 记录当前的成功数量,继续寻找下一个孩子和下一个饼干。
/**
 * @param {number[]} g
 * @param {number[]} s
 * @return {number}
 */
let findContentChildren = function (g, s) {
  g.sort((a, b) => a - b)
  s.sort((a, b) => a - b)

  let i = 0
  let j = 0

  let count = 0
  while (j < s.length && i < g.length) {
    let need = g[i]
    let cookie = s[j]

    if (cookie >= need) {
      count++
      i++
      j++
    } else {
      j++
    }
  }

  return count
}

必做题目

其实写了这么多,以上分类所提到的题目,只是当前分类下比较适合作为例题来讲解的题目而已,在整个 LeetCode 学习过程中只是冰山一角。这些题可以作为你深入这个分类的一个入门例题,但是不可避免的是,你必须去下苦功夫刷每个分类下的其他经典题目

如果你信任我,你也可以点击这里 获取我总结的各个分类下必做题目的详细题解,还有我推荐给你的一个视频课程

算法这种逻辑复杂的东西,其实看文章也只是能做个引子,如果有老师耐心的讲解,配合动图演示过程,学习效率是翻倍都不止的。不瞒你说,我个人就是把上面推荐的那个视频课程完全跟着走了一遍,能感觉到比起看文章来说,效率是翻倍都不止的。因为有大牛老师耐心的带着你从零开始,由浅入深的配合动图去图文并茂的抽丝剥茧的讲清楚一道题,我拿不到任何回扣,甚至连那个老师的微信都没有,但我真心实意的推荐你去学这门课程。

总结

关于算法在工程方面有用与否的争论,已经是一个经久不衰的话题了。这里不讨论这个,我个人的观念是绝对有用的,只要你不是一个甘于只做简单需求的人,你一定会在后续开发架构、遇到难题的过程中或多或少的从你的算法学习中受益。

再说的功利点,就算是为了面试,刷算法能够进入大厂也是你职业生涯的一个起飞点,大厂给你带来的的环境、严格的 Code Review、完善的导师机制和协作流程也是你作为工程师所梦寐以求的。

希望这篇文章能让你不再继续害怕算法面试,跟着我一起攻下这座城堡吧,大家加油!

❤️ 感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

用jsx封装Vue中的复杂组件(网易云音乐实战项目需求)

背景介绍

最近在做vue高仿网易云音乐的项目,在做的过程中发现音乐表格这个组件会被非常多的地方复用,而且需求比较复杂的和灵活。

预览地址

源码地址

图片预览

  • 歌单详情
    歌单详情
  • 播放列表
    播放列表
  • 搜索高亮
    搜索高亮

需求分析

它需要支持:

  • hideColumns参数, 自定义需要隐藏哪些列。

  • highLightText,传入字符串,数据中命中的字符串高亮。

首先 看一下我们平常的table写法。

  <el-table
      :data="tableData"
      style="width: 100%">
      <el-table-column
        prop="index"
        label=" "
        width="180">
      </el-table-column>
      <el-table-column
        prop="name"
        label="音乐标题"
        width="180">
      </el-table-column>
      <el-table-column
        prop="artistsText"
        label="歌手">
      </el-table-column>
    </el-table>

这是官网的写法,假设我们传入了 hideColumns: ['index', 'name'],我们需要在模板里隐藏的话```

  <el-table
    :data="tableData"
    style="width: 100%">
    <el-table-column
  +++ v-if="!hideColumns.includes('index')"
      prop="index"
      label=" "
      width="180">
    </el-table-column>
    <el-table-column
  +++ v-if="!hideColumns.includes('name')"
      prop="name"
      label="音乐标题"
      width="180">
    </el-table-column>
    <el-table-column
  +++ v-if="!hideColumns.includes('address')"
      prop="artistsText"
      label="歌手">
    </el-table-column>
  </el-table>

这种代码非常笨,所以我们肯定是接受不了的,我们很自然的联想到平常用v-for循环,能不能套用在这个需求上呢。
首先在data里定义columns

data() {
    return {
      columns: [{
        prop: 'index',
        label: '',
        width: '50'
      }, {
        prop: 'artistsText',
        label: '歌手'
      }, {
        prop: 'albumName',
        label: '专辑'
      }, {
        prop: 'durationSecond',
        label: '时长',
        width: '100',
      }]
    }
}

然后我们在computed中计算hideColumns做一次合并

  computed: {
    showColumns() {
      const { hideColumns } = this
      return this.columns.filter(column => {
        return !this.hideColumns.find((prop) => prop === column.prop)
      })
    },
  },

那么模板里我们就可以简写成

<el-table
    :data="songs"
  >
    <template v-for="(column, index) in showColumns">
      <el-table-column
        :key="index"
        // 混入属性
        v-bind="column"
      >
      </el-table-column>
    </template>
  </el-table>

注意 v-bind="column"这行, 相当于把column中的所有属性混入到table-column中去,是一个非常简便的方法。

script配合template的解决方案

这样需求看似解决了,很美好。


但是我们忘了非常重要的一点,slotScopes这个东西!

比如音乐时长我们需要format一下,

 <el-table-column>
     <template>
        <span>{{ $utils.formatTime(scope.row.durationSecond) }}</span>
     </template>
 </el-table-column>

但是我们现在把columns都写到script里了,和template分离开来了,我暂时还不知道有什么方法能把sciprt里写的模板放到template里用,所以先想到一个可以解决问题的方法。就是在template里加一些判断。

<el-table
    v-bind="$attrs"
    v-if="songs.length"
    :data="songs"
    @row-click="onRowClick"
    :cell-class-name="tableCellClassName"
    style="width: 99.9%"
  >
    <template v-for="(column, index) in showColumns">
      <!-- 需要自定义渲染的列 -->
      <el-table-column
        v-if="['durationSecond'].includes(column.prop)"
        :key="index"
        v-bind="column"
      >
          <!-- 时长 -->
          <template v-else-if="column.prop === 'durationSecond'">
            <span>{{ $utils.formatTime(scope.row.durationSecond) }}</span>
          </template>
      </el-table-column>

      <!-- 普通列 -->
      <el-table-column
        v-else
        :key="index"
        v-bind="column"
      >
      </el-table-column>
    </template>
  </el-table>

又一次的需求看似解决了,很美好。

高亮文字匹配需求分析


但是新需求又来了!!根据传入的 highLightText 去高亮某些文字,我们分析一下需求

鸡你太美这个歌名,我们在搜索框输入鸡你
我们需要把

<span>鸡你太美</span>

转化为

  <span>
    <span class="high-light">鸡你</span>
    太美
 </span>

我们在template里找到音乐标题这行,写下这端代码:

<template v-else-if="column.prop === 'name'">
  <span>{{this.genHighlight(scope.row.name)}}</span>
</template>
methods: {
    genHighlight(text) {
       return <span>xxx</span>
    }
}

我发现无从下手了, 因为jsx最终编译成的是return vnode的方法,genHighlight执行以后返回的是vnode,但是你不能直接把vnode放到template里去。

jsx终极解决方案

所以我们要统一环境,直接使用jsx渲染我们的组件,文档可以参照


babel-plugin-transform-vue-jsx


vuejs/jsx

import ElTable from 'element-ui/lib/table'

data() {
    const commonHighLightSlotScopes = {
      scopedSlots: {
        default: (scope) => {
          return (
            <span>{this.genHighlight(scope.row[scope.column.property])}</span>
          )
        }
      }
    }
    return {
      columns: [{
        prop: 'name',
        label: '音乐标题',
        ...commonHighLightSlotScopes
      }, {
        prop: 'artistsText',
        label: '歌手',
         ...commonHighLightSlotScopes
      }, {
        prop: 'albumName',
        label: '专辑',
        ...commonHighLightSlotScopes
      }, {
        prop: 'durationSecond',
        label: '时长',
        width: '100',
        scopedSlots: {
          default: (scope) => {
            return (
              <span>{this.$utils.formatTime(scope.row.durationSecond)}</span>
            )
          }
        }
      }]
    }
  },
  methods: {
    genHighlight(title = '') {
      ...省去一些细节
      const titleSpan = matchIndex > -1 ? (
        <span>
          {beforeStr}
          <span class="high-light-text">{hitStr}</span>
          {afterStr}
        </span>
      ) : title;
      return titleSpan;
    },
  },
 render() {
    const elTableProps = ElTable.props
    // 从$attrs里提取作为prop的值
    // 这里要注意的点是驼峰命名法(camelCase)和短横线命名法(kebab-case)
    // 都是可以被组件接受的,虽然elTable里的prop定义的属性叫cellClassName
    // 但是我们要把cell-class-name也解析进prop里
    const { props, attrs } = genPropsAndAttrs(this.$attrs, elTableProps)
    
    const tableAttrs = {
      attrs,
      on: {
        ...this.$listeners,
        ['row-click']: this.onRowClick,
      },
      props: {
        ...props,
        cellClassName: this.tableCellClassName,
        data: this.songs,
      },
      style: { width: '99.9%' }
    }
    return this.songs.length ? (
      <el-table
        {...tableAttrs}
      >
        {this.showColumns.map((column, index) => {
          const { scopedSlots, ...columnProps } = column
          return (
            <el-table-column key={index} props={columnProps} scopedSlots={scopedSlots} >
            </el-table-column>
          )
        })}
      </el-table>
    ) : null
  }

attrs: this.$attrs 注意这句话,我们在template里可以通过
v-bind="$attrs"去透传外部传进来的所有属性,
但是在jsx中我们必须分类清楚传给el-table的attrsprops
比如el-table接受data这个prop,如果你放在attrs里传进去,那么就失效了。

这个我暂时也没找到特别好的解决方法,只能先引用去拿elTable上的props去进行比对$attrs,取交集。

import ElTable from 'element-ui/lib/table'
// 从$attrs里提取作为prop的值
// 这里要注意的点是驼峰命名法(camelCase)和短横线命名法(kebab-case)
// 都是可以被组件接受的,虽然elTable里的prop定义的属性叫cellClassName
// 但是我们要把cell-class-name也解析进prop里
const { props, attrs } = genPropsAndAttrs(this.$attrs, elTableProps)

可以看到代码中模板的部分少了很多重复的判断,维护性和扩展性都更强了,jsx可以说是复杂组件的终极解决方案,但是要真正的封装好一个高阶组件,要做的还非常多。

中文技术文章阅读

记录一些让自己受益匪浅的文章。

https://developers.google.com/web/fundamentals/performance/why-performance-matters
谷歌性能优化文章

https://juejin.im/post/5c8a518ee51d455e4d719e2e
巧用 TypeScript系列

https://zhongsp.gitbooks.io/typescript-handbook/content/
TypeScript Handbook(中文版)
翻译好,内容全面,优于下面的教程。

https://github.com/joye61/typescript-tutorial
浅显易懂的TypeScript入门教程

https://juejin.im/post/5d9eff686fb9a04de04d8367
Vue3响应式系统源码解析-Ref篇

https://juejin.im/post/5da9d7ebf265da5bbb1e52b7
Vue3响应式源码解析-Reactive篇

https://juejin.im/post/5db1d965f265da4d4a305926
Vue3响应式源码解析-Effect篇

https://zhuanlan.zhihu.com/p/40311981
TS 一些工具泛型的使用及其实现
这篇文章记录了一些我对TS的偏门语法的疑惑点,非常推荐!

https://segmentfault.com/a/1190000015326439
TypeScript + React 实现一些复杂类型的React组件
对于TypeScript在React中的进阶用法受益良多。

https://juejin.im/post/5dc820a3e51d4509320d084d
在React中实现vue-composition-api

https://zhuanlan.zhihu.com/p/92211533
React Hooks完全上手指南
从源码层面对Hooks进行了解读

https://www.cnblogs.com/wyaocn/p/5802447.html
前端IoC理念入门

https://zhuanlan.zhihu.com/p/84862214
WeakMap解析:到底什么是弱引用,node调试垃圾回收,以及WeakMap的实践应用。

https://zhuanlan.zhihu.com/p/62401626
使用ESLint+Prettier规范React+Typescript项目(成功整合进 tiny-react-redux 项目中)

通过实现一个最精简的响应式系统来学习Vue的data、computed、watch。

导读

记得初学Vue源码的时候,在defineReactiveObserverDepWatcher等等内部设计源码之间跳来跳去,发现再也绕不出来了。Vue发展了很久,很多fix和feature的增加让内部源码越来越庞大,太多的边界情况和优化设计掩盖了原本精简的代码设计,让新手阅读源码变得越来越困难,但是面试的时候,Vue的响应式原理几乎成了Vue技术栈的公司面试中高级前端必问的点之一。

这篇文章通过自己实现一个响应式系统,尽量还原和Vue内部源码同样结构,但是剔除掉和渲染、优化等等相关的代码,来最低成本的学习Vue的响应式原理。

预览

源码地址:
https://github.com/sl1673495/vue-reactive

预览地址:
https://sl1673495.github.io/vue-reactive/

reactive

Vue最常用的就是响应式的data了,通过在vue中定义

new Vue({
    data() {
        return {
            msg: 'Hello World'
        }
    }
})

在data发生改变的时候,视图也会更新,在这篇文章里我把对data部分的处理单独提取成一个api:reactive,下面来一起实现这个api。

要实现的效果:

const data = reactive({
  msg: 'Hello World',
})

new Watcher(() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
})

在data.msg发生改变的时候,我们需要这个app节点的innerHTML同步更新,这里新增加了一个概念Watcher,这也是Vue源码内部的一个设计,想要实现响应式的系统,这个Watcher是必不可缺的。

在实现这两个api之前,我们先来理清他们之间的关系,reactive这个api定义了一个响应式的数据,其实大家都知道响应式的数据就是在它的属性某个属性(比如例中的data.msg)被读取的时候,记录下来这时候是谁在读取他,读取他的这个函数肯定依赖它。
在本例中,下面这段函数,因为读取了data.msg并且展示在页面上,所以可以说这段渲染函数依赖了data.msg

document.getElementById('app').innerHTML = `msg is ${data.msg}`

这也就解释清了,为什么我们需要用new Watcher来传入这段渲染函数,我们已经可以分析出来Watcher内部是需要记录下来这段渲染函数,并且在帮我们执行这段渲染函数的时候需要开启收集依赖的一个开关。

在js引擎执行渲染函数的途中,突然读到了data.msgdata已经被定义成了响应式数据,读取data.msg时所触发的get函数已经被我们劫持,这个get函数中我们去记录下data.msg被这个渲染函数所依赖,然后再返回data.msg的值。

这样下次data.msg发生变化的时候,Watcher内部所做的一些逻辑就会通知到渲染函数去重新执行。这不就是响应式的原理嘛。

下面开始实现代码

import Dep from './dep'
import { isObject } from '../utils'

// 将对象定义为响应式
export default function reactive(data) {
  if (isObject(data)) {
    Object.keys(data).forEach(key => {
      defineReactive(data, key)
    })
  }
  return data
}

function defineReactive(data, key) {
  let val = data[key]
  // 收集依赖
  const dep = new Dep()

  Object.defineProperty(data, key, {
    get() {
      dep.depend()
      return val
    },
    set(newVal) {
      val = newVal
      dep.notify()
    }
  })

  if (isObject(val)) {
    reactive(val)
  }
}

代码很简单,就是去遍历data的key,在defineReactive函数中对每个key进行get和set的劫持,Dep是一个新的概念,它主要用来做上面所说的dep.depend()去收集当前正在运行的渲染函数和dep.notify() 触发渲染函数重新执行。

可以把dep看成一个收集依赖的小筐,每当运行渲染函数读取到data的某个key的时候,就把这个渲染函数丢到这个key自己的小筐中,在这个key的值发生改变的时候,去key的筐中找到所有的渲染函数再执行一遍。

Dep

export default class Dep {
  constructor() {
    this.deps = new Set()
  }

  depend() {
    if (Dep.target) {
      this.deps.add(Dep.target)
    }
  }

  notify() {
    this.deps.forEach(watcher => watcher.update())
  }
}

// 正在运行的watcher
Dep.target = null

这个类很简单,利用Set去做存储,在depend的时候把Dep.target加入到deps集合里,在notify的时候遍历deps,触发每个watcher的update。

没错Dep.target这个概念也是Vue中所引入的,它是一个挂在Dep类上的全局变量,js是单线程运行的,所以在渲染函数如:

document.getElementById('app').innerHTML = `msg is ${data.msg}`

运行之前,先把全局的Dep.target设置为存储了这个渲染函数的watcher,也就是:

new Watcher(() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
})

这样在运行途中data.msg就可以通过Dep.target找到当前是哪个渲染函数的watcher正在运行,这样也就可以把自身对应的依赖所收集起来了。

这里划重点:Dep.target一定是一个Watcher的实例。

又因为渲染函数可以是嵌套运行的,比如在Vue中每个组件都会有自己用来存放渲染函数的一个watcher,那么在下面这种组件嵌套组件的情况下:

// Parent组件

<template>
  <div>
    <Son组件 />
  </div>
</template>

watcher的运行路径就是: 开始 -> ParentWatcher -> SonWatcher -> ParentWatcher -> 结束。

是不是特别像函数运行中的入栈出栈,没错,Vue内部就是用了栈的数据结构来记录watcher的运行轨迹。

// watcher栈
const targetStack = []

// 将上一个watcher推到栈里,更新Dep.target为传入的_target变量。
export function pushTarget(_target) {
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target
}

// 取回上一个watcher作为Dep.target,并且栈里要弹出上一个watcher。
export function popTarget() {
  Dep.target = targetStack.pop()
}

有了这些辅助的工具,就可以来看看Watcher的具体实现了

import Dep, { pushTarget, popTarget } from './dep'

export default class Watcher {
  constructor(getter) {
    this.getter = getter
    this.get()
  }

  get() {
    pushTarget(this)
    this.value = this.getter()
    popTarget()
    return this.value
  }

  update() {
     this.get()
  }
}

回顾一下开头示例中Watcher的使用。

const data = reactive({
  msg: 'Hello World',
})

new Watcher(() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
})

传入的getter函数就是

() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
}

在构造函数中,记录下getter函数,并且执行了一遍get

  get() {
    pushTarget(this)
    this.value = this.getter()
    popTarget()
    return this.value
  }

在这个函数中,this就是这个watcher实例,在执行get的开头先把这个存储了渲染函数的watcher设置为当前的Dep.target,然后执行this.getter()也就是渲染函数

在执行渲染函数的途中读取到了data.msg,就触发了defineReactive函数中劫持的get:

Object.defineProperty(data, key, {
    get() {
      dep.depend()
      return val
    }
  })

这时候的dep.depend函数:

  depend() {
    if (Dep.target) {
      this.deps.add(Dep.target)
    }
  }

所收集到的Dep.target,就是在get函数开头中pushTarget(this)所收集的

new Watcher(() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
})

这个watcher实例了。

此时我们假如执行了这样一段赋值代码:

data.msg = 'ssh'

就会运行到劫持的set函数里:

  Object.defineProperty(data, key, {
    set(newVal) {
      val = newVal
      dep.notify()
    }
  })

此时在控制台中打印出dep这个变量,它内部的deps属性果然存储了一个Watcher的实例。
dep

运行了dep.notify以后,就会触发这个watcher的update方法,也就会再去重新执行一遍渲染函数了,这个时候视图就刷新了。

computed

在实现了reactive这个基础api以后,就要开始实现computed这个api了,这个api的用法是这样:

const data = reactive({
  number: 1
})

const numberPlusOne = computed(() => data.number + 1)

// 渲染函数watcher
new Watcher(() => {
  document.getElementById('app2').innerHTML = `
    computed: 1 + number 是 ${numberPlusOne.value}
  `
})

vue内部是把computed属性定义在vm实例上的,这里我们没有实例,所以就用一个对象来存储computed的返回值,用.value来拿computed的真实值。

这里computed传入的其实还是一个函数,这里我们回想一下Watcher的本质,其实就是存储了一个需要在特定时机触发的函数,在Vue内部,每个computed属性也有自己的一个对应的watcher实例,下文中叫它computedWatcher

先看渲染函数:

// 渲染函数watcher
new Watcher(() => {
  document.getElementById('app2').innerHTML = `
    computed: 1 + number 是 ${numberPlusOne.value}
  `
})

这段渲染函数执行过程中,读取到numberPlusOne的值的时候

首先会把Dep.target设置为numberPlusOne所对应的computedWatcher

computedWatcher的特殊之处在于

  1. 渲染watcher只能作为依赖被收集到其他的dep筐子里,而computedWatcher实例上有属于自己的dep,它可以收集别的watcher作为自己的依赖。
  2. 惰性求值,初始化的时候先不去运行getter。
export default class Watcher {
  constructor(getter, options = {}) {
    const { computed } = options
    this.getter = getter
    this.computed = computed

    if (computed) {
      this.dep = new Dep()
    } else {
      this.get()
    }
  }
}

其实computed实现的本质就是,computed在读取value之前,Dep.target肯定此时是正在运行的渲染函数的watcher

先把当前正在运行的渲染函数的watcher作为依赖收集到computedWatcher内部的dep筐子里。

把自身computedWatcher设置为 全局Dep.target,然后开始求值:

求值函数会在运行

() => data.number + 1

的途中遇到data.number的读取,这时又会触发'number'这个key的劫持get函数,这时全局的Dep.target是computedWatcher,data.number的dep依赖筐子里丢进去了computedWatcher

此时的依赖关系是 data.number的dep筐子里装着computedWatchercomputedWatcher的dep筐子里装着渲染watcher

此时如果更新data.number的话,会一级一级往上触发更新。会触发computedWatcherupdate,我们肯定会对被设置为computed特性的watcher做特殊的处理,这个watcher的筐子里装着渲染watcher,所以只需要触发 this.dep.notify(),就会触发渲染watcher的update方法,从而更新视图。

下面来改造代码:

// Watcher
import Dep, { pushTarget, popTarget } from './dep'

export default class Watcher {
  constructor(getter, options = {}) {
    const { computed } = options
    this.getter = getter
    this.computed = computed

    if (computed) {
      this.dep = new Dep()
    } else {
      this.get()
    }
  }

  get() {
    pushTarget(this)
    this.value = this.getter()
    popTarget()
    return this.value
  }

  // 仅为computed使用
  depend() {
    this.dep.depend()
  }

  update() {
    if (this.computed) {
      this.get()
      this.dep.notify()
    } else {
      this.get()
    }
  }
}

computed初始化:

// computed
import Watcher from './watcher'

export default function computed(getter) {
  let def = {}
  const computedWatcher = new Watcher(getter, { computed: true })
  Object.defineProperty(def, 'value', {
    get() {
      // 先让computedWatcher收集渲染watcher作为自己的依赖。
      computedWatcher.depend()
      return computedWatcher.get()
    }
  })
  return def
}

这里的逻辑比较绕,如果没理清楚的话可以把代码下载下来一步步断点调试,data.number被劫持的set触发以后,可以看一下number的dep到底存了什么。

dep

watch

watch的使用方式是这样的:

watch(
  () => data.msg,
  (newVal, oldVal) => {
    console.log('newVal: ', newVal)
    console.log('old: ', oldVal)
  }
)

传入的第一个参数是个函数,里面需要读取到响应式的属性,确保依赖能被收集到,这样下次这个响应式的属性发生改变后,就会打印出对饮的新值和旧值。

分析一下watch的实现原理,这里依然是利用Watcher类去实现,我们把用于watch的watcher叫做watchWatcher,传入的getter函数也就是() => data.msgWatcher在执行它之前还是一样会把自身(也就是watchWatcher)设为Dep.target,这时读到data.msg,就会把watchWatcher丢进data.msg的依赖筐子里。

如果data.msg更新了,则就会触发watchWatcherupdate方法

直接上代码:

// watch
import Watcher from './watcher'

export default function watch(getter, callback) {
  new Watcher(getter, { watch: true, callback })
}

没错又是直接用了getter,只是这次传入的选项是{ watch: true, callback },接下来看看Watcher内部进行了什么处理:

export default class Watcher {
  constructor(getter, options = {}) {
    const { computed, watch, callback } = options
    this.getter = getter
    this.computed = computed
    this.watch = watch
    this.callback = callback
    this.value = undefined

    if (computed) {
      this.dep = new Dep()
    } else {
      this.get()
    }
  }
}

首先是构造函数中,对watch选项和callback进行了保存,其他没变。

然后在update方法中。

  update() {
    if (this.computed) {
     ...
    } else if (this.watch) {
      const oldValue = this.value
      this.get()
      this.callback(oldValue, this.value)
    } else {
      ...
    }
  }

在调用this.get去更新值之前,先把旧值保存起来,然后把新值和旧值一起通过调用callback函数交给外部,就这么简单。

我们仅仅是改动寥寥几行代码,就轻松实现了非常重要的api:watch

总结。

有了精妙的Watcher和Dep的设计,Vue内部的响应式api实现的非常简单,不得不再次感叹一下尤大真是厉害啊!

前端瀑布流布局如何应用动态规划和贪心算法

前言

瀑布流布局是前端领域中一个很常见的需求,由于图片的高度是不一致的,所以在多列布局中默认布局下很难获得满意的排列。

我们的需求是,图片高度不规律的情况下,在两列布局中,让左右两侧的图片总高度尽可能的接近,这样的布局会非常的美观。

注意,本文的目的仅仅是讨论算法在前端中能如何运用,而不是说瀑布流的最佳解法是动态规划,可以仅仅当做学习拓展来看。

本文的图片节选自知乎问题《有个漂亮女朋友是种怎样的体验?》,我先去看美女了,本文到此结束。(逃

预览

分析

从预览图中可以看出,虽然图片的高度是不定的,但是到了这个布局的最底部,左右两张图片是正好对齐的,这就是一个比较美观的布局了。

那么怎么实现这个需求呢?从头开始拆解,现在我们能拿到一组图片数组 [img1, img2, img3],我们可以通过一些方法得到它对应的高度 [1000, 2000, 3000],那么现在我们的目标就是能够计算出左右两列 left: [1000, 2000]right: [3000] 这样就可以把一个左右等高的布局给渲染出来了。

准备工作

首先准备好小姐姐数组 SISTERS

let SISTERS = [
  'https://pic3.zhimg.com/v2-89735fee10045d51693f1f74369aaa46_r.jpg',
  'https://pic1.zhimg.com/v2-ca51a8ce18f507b2502c4d495a217fa0_r.jpg',
  'https://pic1.zhimg.com/v2-c90799771ed8469608f326698113e34c_r.jpg',
  'https://pic1.zhimg.com/v2-8d3dd83f3a419964687a028de653f8d8_r.jpg',
  ... more 50 items
]

准备好一个工具方法 loadImages,这个方法的目的就是把所有图片预加载以后获取对应的高度,放到一个数组里返回。并且要对外通知所有图片处理完成的时机,有点类似于 Promise.all 的思路。

这个方法里,我们把图片按照 宽高比 和屏幕宽度的一半进行相乘,得到缩放后适配屏宽的图片高度。

let loadImgHeights = (imgs) => {
  return new Promise((resolve, reject) => {
    const length = imgs.length
    const heights = []
    let count = 0
    const load = (index) => {
      let img = new Image()
      const checkIfFinished = () => {
        count++
        if (count === length) {
          resolve(heights)
        }
      }
      img.onload = () => {
        const ratio = img.height / img.width
        const halfHeight = ratio * halfInnerWidth
        // 高度按屏幕一半的比例来计算
        heights[index] = halfHeight
        checkIfFinished()
      }
      img.onerror = () => {
        heights[index] = 0
        checkIfFinished()
      }
      img.src = imgs[index]
    }
    imgs.forEach((img, index) => load(index))
  })
}

有了图片高度以后,我们就开始挑选适合这个需求的算法了。

贪心算法

在人的脑海中最直观的想法是什么样的?在每装一个图片前都对比一下左右数组的高度和,往高度较小的那个数组里去放入下一项。

这就是贪心算法,我们来简单实现下:

let greedy = (heights) => {
  let leftHeight = 0
  let rightHeight = 0
  let left = []
  let right = []

  heights.forEach((height, index) => {
    if (leftHeight >= rightHeight) {
      right.push(index)
      rightHeight += height
    } else {
      left.push(index)
      leftHeight += height
    }
  })

  return { left, right }
}

我们得到了 leftright 数组,对应左右两列渲染图片的下标,并且我们也有了每个图片的高度,那么渲染到页面上就很简单了:

<div class="wrap" v-if="imgsLoaded">
  <div class="half">
    <img
      class="img"
      v-for="leftIndex in leftImgIndexes"
      :src="imgs[leftIndex]"
      :style="{ width: '100%', height: imgHeights[leftIndex] + 'px' }"
    />
  </div>
  <div class="half">
    <img
      class="img"
      v-for="rightIndex in rightImgIndexes"
      :src="imgs[rightIndex]"
      :style="{ width: '100%', height: imgHeights[rightIndex] + 'px' }"
    />
  </div>
</div>

效果如图:

预览地址:
https://sl1673495.github.io/dp-waterfall/greedy.html

可以看出,贪心算法只寻求局部最优解(只在考虑当前图片的时候找到一个最优解),所以最后左右两边的高度差还是相对较大的,局部最优解很难成为全局最优解。

再回到文章开头的图片去看看,对于同样的一个图片数组,那个预览图里的高度差非常的小,是怎么做到的呢?

动态规划

和局部最优解对应的是全局最优解,而说到全局最优解,我们很难不想到动态规划这种算法。它是求全局最优解的一个利器。

如果你还没有了解过动态规划,建议你看一下海蓝大佬的 一文搞懂动态规划,也是这篇文章让我入门了最基础的动态规划。

动态规划中有一个很著名的问题:「01 背包问题」,题目的意思是这样的:

有 n 个物品,它们有各自的体积和价值,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?

关于 01 背包问题的题解,网上不错的教程似乎不多,我推荐看慕课网 bobo 老师的玩转算法面试 从真题到思维全面提升算法思维 中的第九章,会很仔细的讲解背包问题,对于算法思维有很大的提升,这门课的其他部分也非常非常的优秀。

我也有在我自己维护的题解仓库中对老师的 01 背包解法做了一个js 版的改写

那么 01 背包问题和这个瀑布流算法有什么关系呢?这个思路确实比较难找,但是我们仔细想一下,假设我们有 [1, 2, 3] 这 3 个图片高度的数组,我们怎么通过转化成 01 背包问题呢?

由于我们要凑到的是图片总高度的一半,也就是 (1 + 2 + 3) / 2 = 3,那么我们此时就有了一个 容量为3 的背包,而由于我们装进左列中的图片高度需要低于总高度的一半,待装进背包的物体的总重量和高度是相同的 [1, 2, 3]

那么这个问题也就转化为了,在 容量为3的背包 中,尽可能的从重量为 [1, 2, 3],并且价值也为 [1, 2, 3] 的物品中,尽可能的挑选出总价值最大的物品集合装进背包中。

也就是 总高度为3,在 [1, 2, 3] 这几种高度的图片中,尽可能挑出 总和最大,但是又小于3 的图片集合,装进数组中。

可以分析出 状态转移方程

dp[heights][height] = max(
  // 选择当前图片放入列中
  currentHeight + dp[heights - 1][height - currnetHeight], 
  // 不选择当前图片
  dp[heights - 1][height]
)

注意这里的纵坐标命名为 heights,代表它的意义是「可选择图片的集合」,比如 dp[0] 意味着只考虑第一张图片,dp[1] 则意味着既考虑第一张图片又考虑第二张图片,以此类推。

二维数组结构

我们构建的二维 dp 数组

纵坐标 y 是:当前可以考虑的图片,比如 dp[0] 是只考虑下标为 0 的图片,dp[1] 是考虑下标为 0 的图片,并且考虑下标为 1 的图片,以此类推,取值范围是 0 ~ 图片数组的长度 - 1

横坐标 x 是:用当前考虑的图片集合,去尽可能凑到总高度为 y 时,所能凑成的最大高度 max,以及当前所使用的图片下标集合 indexes,取值范围是 0 ~ 高度的一半

小问题拆解

就以 [1, 4, 5, 4] 这四张图片高度为例,高度的一半是 7,用肉眼可以看出最接近 7 的子数组是[1, 5],我们来看看动态规划是怎么求出这个结果的。

我们先看纵坐标为 0,也就是只考虑图片 1 的情况:

  1. 首先去尝试凑高度 1:我们知道图片 1 的高度正好是 1,所以此时dp[0][0]所填写的值是 { max: 1, indexes: [0] },也就代表用总高度还剩 1,并且只考虑图片 1 的情况下,我们的最优解是选用第一张图片。

  2. 凑高度2 ~ 7:由于当前只有 1 可以选择,所以最优解只能是选择第一张图片,它们都是 { max: 1, indexes: [0] }

高度       1  2  3  4  5  6  7
图片1(h=1) 1  1  1  1  1  1  1

这一层在动态规划中叫做基础状态,它是最小的子问题,它不像后面的纵坐标中要考虑多张图片,而是只考虑单张图片,所以一般来说都会在一层循环中单独把它求解出来。

这里我们还要考虑第一张图片的高度大于我们要求的总高度的情况,这种情况下需要把 max 置为 0,选择的图片项也为空。

let mid = Math.round(sum(heights) / 2)
let dp = []
// 基础状态 只考虑第一个图片的情况
dp[0] = []
for (let cap = 0; cap <= mid; cap++) {
  dp[0][cap] =
    heights[0] > cap
      ? { max: 0, indexes: [] }
      : { max: heights[0], indexes: [0] }
}

有了第一层的基础状态后,我们就可以开始考虑多张图片的情况了,现在来到了纵坐标为 1,也就是考虑图片 1 和考虑图片 2 时求最优解:

高度       1  2  3  4  5  6  7
图片1(h=1) 1  1  1  1  1  1  1
图片2(h=2)

此时问题就变的有些复杂了,在多张图片的情况下,我们可以有两种选择:

  1. 选择当前图片,那么假设当前要凑的总高度为 3,当前图片的高度为 2,剩余的高度就为 1,此时我们可以用剩余的高度去「上一个纵坐标」里寻找「只考虑前面几种图片」的情况下,高度为 1 时的最优解。并且记录 当前图片的高度 + 前几种图片凑剩余高度的最优解max1
  2. 不选择当前图片,那么就直接去「只考虑前面几种图片」的上一个纵坐标里,找到当前高度下的最优解即可,记为 max2
  3. 比较 max1max2,找出更大的那个值,记录为当前状态下的最优解。

有了这个前置知识,来继续分解这个问题,在纵坐标为 1 的情况下,我们手上可以选择的图片有图片 1 和图片 2:

  1. 凑高度 1:由于图片 2 的高度为 2,相当于是容量超了,所以这种情况下不选择图片 2,而是直接选择图片 1,所以 dp[1][0] 可以直接沿用 dp[0][0]的最优解,也就是 { max: 1, indexes: [0] }
  2. 凑高度 2:
    1. 选择图片 2,图片 2 的高度为 4,能够凑成的高度为 4,已经超出了当前要凑的高度 2,所以不能选则图片 2。
    2. 不选择图片 2,在只考虑图片 1 时的最优解数组 dp[0] 中找到高度为 2 时的最优解: dp[0][2],直接沿用下来,也就是 { max: 1, indexes: [0] }
    3. 这种情况下只能不选择图片 2,而沿用只选择图片 1 时的解, { max: 1, indexes: [0] }
  3. 省略凑高度 3 ~ 4 的情况,因为得出的结果和凑高度 2 是一样的。
  4. 凑高度 5:高度为 5 的情况下就比较有意思了:
    1. 选择图片 2,图片 2 的高度为 4,能够凑成的高度为 4,此时剩余高度是 1,再去只考虑图片 1 的最优解数组 dp[0]中找高度为 1 时的最优解dp[0][1],发现结果是 { max: 1, indexes: [0] },这两个高度值 4 和 1 相加后没有超出高度的限制,所以得出最优解:{ max: 5, indexes: [0, 1] }
    2. 不选择图片 2,在图片 1 的最优解数组中找到高度为 5 时的最优解: dp[0][5],直接沿用下来,也就是 { max: 1, indexes: [0] }
    3. 很明显选择图片 2 的情况下,能凑成的高度更大,所以 dp[1][2] 的最优解选择 { max: 5, indexes: [0, 1] }

仔细理解一下,相信你可以看出动态规划的过程,从最小的子问题 只考虑图片1出发,先求出最优解,然后再用子问题的最优解去推更大的问题 考虑图片1、2考虑图片1、2、3的最优解。

画一下[1,4,5,4]问题的 dp 状态表吧:

可以看到,和我们刚刚推论的结果一致,在考虑图片 1 和图片 2 的情况下,凑高度为 5,也就是dp[1][5]的位置的最优解就是 5。

最右下角的 dp[3][7] 就是考虑所有图片的情况下,凑高度为 7 时的全局最优解

dp[3][7] 的推理过程是这样的:

  1. 用最后一张高度为 4 的图片,加上前三张图片在高度为 7 - 4 = 3 时的最优解也就是 dp[2][3],得到结果 4 + 1 = 5。
  2. 不用最后一张图片,直接取前三张图片在高度为 7 时的最优解,也就是 dp[2][7],得到结果 6。
  3. 对比这两者的值,得到最优解 6。

至此我们就完成了整个动态规划的过程,得到了考虑所有图片的情况下,最大高度为 7 时的最优解:6,所需的两张图片的下标为 [0, 2],对应高度是 15

给出代码:

// 尽可能选出图片中高度最接近图片总高度一半的元素
let dpHalf = (heights) => {
  let mid = Math.round(sum(heights) / 2)
  let dp = []

  // 基础状态 只考虑第一个图片的情况
  dp[0] = []
  for (let cap = 0; cap <= mid; cap++) {
    dp[0][cap] =
      heights[0] > cap
        ? { max: 0, indexes: [] }
        : { max: heights[0], indexes: [0] }
  }

  for (
    let useHeightIndex = 1;
    useHeightIndex < heights.length;
    useHeightIndex++
  ) {
    if (!dp[useHeightIndex]) {
      dp[useHeightIndex] = []
    }
    for (let cap = 0; cap <= mid; cap++) {
      let usePrevHeightDp = dp[useHeightIndex - 1][cap]
      let usePrevHeightMax = usePrevHeightDp.max
      let currentHeight = heights[useHeightIndex]
      // 这里有个小坑 剩余高度一定要转化为整数 否则去dp数组里取到的就是undefined了
      let useThisHeightRestCap = Math.round(cap - heights[useHeightIndex])
      let useThisHeightPrevDp = dp[useHeightIndex - 1][useThisHeightRestCap]
      let useThisHeightMax = useThisHeightPrevDp
        ? currentHeight + useThisHeightPrevDp.max
        : 0

      // 是否把当前图片纳入选择 如果取当前的图片大于不取当前图片的高度
      if (useThisHeightMax > usePrevHeightMax) {
        dp[useHeightIndex][cap] = {
          max: useThisHeightMax,
          indexes: useThisHeightPrevDp.indexes.concat(useHeightIndex),
        }
      } else {
        dp[useHeightIndex][cap] = {
          max: usePrevHeightMax,
          indexes: usePrevHeightDp.indexes,
        }
      }
    }
  }

  return dp[heights.length - 1][mid]
}

有了一侧的数组以后,我们只需要在数组中找出另一半,即可渲染到屏幕的两列中:

this.leftImgIndexes = dpHalf(imgHeights).indexes
this.rightImgIndexes = omitByIndexes(this.imgs, this.leftImgIndexes)

得出效果:

优化 1

由于纵轴的每一层的最优解都只需要参考上一层节点的最优解,因此可以只保留两行。通过判断除 2 取余来决定“上一行”的位置。此时空间复杂度是 O(n)。

优化 2

由于每次参考值都只需要取上一行和当前位置左边位置的值(因为减去了当前高度后,剩余高度的最优解一定在左边),因此 dp 数组可以只保留一行,把问题转为从右向左求解,并且在求解的过程中不断覆盖当前的值,而不会影响下一次求解。此时空间复杂度是 O(n),但是其实占用的空间进一步缩小了。

并且在这种情况下对于时间复杂度也可以做优化,由于优化后,求当前高度的最优解是倒序遍历的,那么当发现求最优解的高度小于当前所考虑的那个图片的的高度时,说明本次求解不可能考虑当前图片了,此时左边的高度的最优解一定是「上一行的最优解」。

代码地址

预览地址

完整代码地址

总结

算法**在前端中的应用还是可以见到不少的,本文只是为了演示动态规划在求解最优解问题时的威力,并不代表这种算法适用于生产环境(实际上性能非常差)。

在实际场景中我们可能一定需要最优解,而只是需要左右两侧的高度不要相差的过大就好,那么这种情况下简单的贪心算法完全足够。

在业务工程中,我们需要结合当前的人力资源,项目周期,代码可维护性,性能等各个方面,去选择最适合业务场景的解法,而不一定要去找到那个最优解。

但是算法对于前端来说还是非常重要的,想要写出 bug free 的代码,在复杂的业务场景下也能游刃有余的想出优化复杂度的方法,学习算法是一个非常棒的途径,这也是工程师必备的素养。

推荐

我维护了一个 LeetCode 的题解仓库,这里会按照标签分类记录我平常刷题时遇到的一些比较经典的问题,并且也会经常更新 bobo 老师的力扣算法课程中提到的各个分类的经典算法,把他 C++ 的解法改写成 JavaScript 解法。欢迎关注,我会持续更新。

参考资料

一文搞懂动态规划

玩转算法面试 从真题到思维全面提升算法思维

❤️ 感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

使用React Hooks + 自定义Hook封装一步一步打造一个完善的小型应用。

前言

Reack Hooks自从16.8发布以来,社区已经有相当多的讨论和应用了,不知道各位在公司里有没有用上这个酷炫的特性~

今天分享一下利用React Hooks实现一个功能相对完善的todolist。

特点:

  • 利用自定义hook管理请求
  • 利用hooks做代码组织和逻辑分离

界面预览

预览

体验地址

https://codesandbox.io/s/react-hooks-todo-dh3gx?fontsize=14

代码详解

界面

首先我们引入antd作为ui库,节省掉无关的一些逻辑,快速的构建出我们的页面骨架

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  const [activeTab, setActiveTab] = useState(TAB_ALL);
  
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <Input />
        <TodoList />
      </div>
    </>
  );
}

数据获取

有了界面以后,接下来就要获取数据。

模拟api

这里我新建了一个api.js专门用来模拟接口获取数据,这里面的逻辑大概看一下就好,不需要特别在意。

const todos = [
  {
    id: 1,
    text: "todo1",
    finished: true
  },
  {
    id: 2,
    text: "todo2",
    finished: false
  },
  {
    id: 3,
    text: "todo3",
    finished: true
  },
  {
    id: 4,
    text: "todo4",
    finished: false
  },
  {
    id: 5,
    text: "todo5",
    finished: false
  }
];

const delay = time => new Promise(resolve => setTimeout(resolve, time));
// 将方法延迟1秒
const withDelay = fn => async (...args) => {
  await delay(1000);
  return fn(...args);
};

// 获取todos
export const fetchTodos = withDelay(params => {
  const { query, tab } = params;
  let result = todos;
  // tab页分类
  if (tab) {
    switch (tab) {
      case "finished":
        result = result.filter(todo => todo.finished === true);
        break;
      case "unfinished":
        result = result.filter(todo => todo.finished === false);
        break;
      default:
        break;
    }
  }

  // 带参数查询
  if (query) {
    result = result.filter(todo => todo.text.includes(query));
  }

  return Promise.resolve({
    tab,
    result
  });
});

这里我们封装了个withDelay方法用来包裹函数,模拟异步请求接口的延迟,这样方便我们后面演示loading功能。

基础数据获取

获取数据,最传统的方式就是在组件中利用useEffect来完成请求,并且声明依赖值来在某些条件改变后重新获取数据,简单写一个:

import { fetchTodos } from './api'

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  const [activeTab, setActiveTab] = useState(TAB_ALL);
  
  
  // 获取数据
  const [loading, setLoading] = useState(false)
  const [todos, setTodos] = useState([])
  useEffect(() => {
    setLoading(true)
    fetchTodos({tab: activeTab})
        .then(result => {
            setTodos(todos)
        })
        .finally(() => {
            setLoading(false)
        })
  }, [])
  
  
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <Input />
        <Spin spinning={loading} tip="稍等片刻~">
          <!--把todos传递给组件-->
          <TodoList todos={todos}/>
        </Spin>
      </div>
    </>
  );
}

这样很好,在公司内部新启动的项目里我的同事们也都是这么写的,但是这样的获取数据有几个小问题。

  • 每次都要用useState建立loading的的状态
  • 每次都要用useState建立请求结果的状态
  • 对于请求如果有一些更高阶的封装的话,不太好操作。

所以这里要封装一个专门用于请求的自定义hook。

自定义hook(数据获取)

忘了在哪看到的说法,自定hook其实就是把useXXX方法执行以后,把方法体里的内容平铺到组件内部,我觉得这种说法对于理解自定义hook很友好。

useTest() {
    const [test, setTest] = useState('')
    setInterval(() => {
        setTest(Math.random())
    }, 1000)
    return {test, setTest}
}

function App() {
    const {test, setTest} = useTest()
    
    return <span>{test}</span>
}

这段代码等价于:

function App() {
    const [test, setTest] = useState('')
    setInterval(() => {
        setTest(Math.random())
    }, 1000)
    
    return <span>{test}</span>
}

是不是瞬间感觉自定hook很简单了~ 基于这个思路,我们来封装一下我们需要的useRequest方法。

export const useRequest = (fn, dependencies) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  
  // 请求的方法 这个方法会自动管理loading
  const request = () => {
    setLoading(true);
    fn()
      .then(setData)
      .finally(() => {
        setLoading(false);
      });
  };

  // 根据传入的依赖项来执行请求
  useEffect(() => {
    request()
  }, dependencies);
    
  return {
      // 请求获取的数据
      data,
      // loading状态
      loading,
      // 请求的方法封装
      request
  };
};

有了这个自定义hook,我们组件内部的代码又可以精简很多。

import { fetchTodos } from './api'
import { useRequest } from './hooks'

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  const [activeTab, setActiveTab] = useState(TAB_ALL);
  
  // 获取数据
  const {loading, data: todos} = useRequest(() => {
      return fetchTodos({ tab: activeTab });
  }, [activeTab]) 
  
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <Input />
        <Spin spinning={loading} tip="稍等片刻~">
          <!--把todos传递给组件-->
          <TodoList todos={todos}/>
        </Spin>
      </div>
    </>
  );
}

果然,样板代码少了很多,腰不酸了腿也不痛了,一口气能发5个请求了!

消除tab频繁切换产生的脏数据

在真实开发中我们特别容易遇到的一个场景就是,tab切换并不改变视图,而是去重新请求新的列表数据,在这种情况下我们可能就会遇到一个问题,以这个todolist举例,我们从全部tab切换到已完成tab,会去请求数据,但是如果我们在已完成tab的数据还没请求完成时,就去点击待完成的tab页,这时候就要考虑一个问题,异步请求的响应时间是不确定的,很可能我们发起的第一个请求已完成最终耗时5s,第二个请求待完成最终耗时1s,这样第二个请求的数据返回,渲染完页面以后,过了几秒第一个请求的数据返回了,但是这个时候我们的tab是停留在对应第二个请求待完成上,这就造成了脏数据的bug。

这个问题其实我们可以利用useEffect的特性在useRequest封装解决。

export const useRequest = (fn, dependencies, defaultValue = []) => {
  const [data, setData] = useState(defaultValue);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(false);

  const request = () => {
    // 定义cancel标志位
    let cancel = false;
    setLoading(true);
    fn()
      .then(res => {
        if (!cancel) {
          setData(res);
        } else {
          // 在请求成功取消掉后,打印测试文本。
          const { tab } = res;
          console.log(`request with ${tab} canceled`);
        }
      })
      .catch(() => {
        if (!cancel) {
          setError(error);
        }
      })
      .finally(() => {
        if (!cancel) {
          setLoading(false);
        }
      });

    // 请求的方法返回一个 取消掉这次请求的方法
    return () => {
      cancel = true;
    };
  };

  // 重点看这段,在useEffect传入的函数,返回一个取消请求的函数
  // 这样在下一次调用这个useEffect时,会先取消掉上一次的请求。
  useEffect(() => {
    const cancelRequest = request();
    return () => {
      cancelRequest();
    };
    // eslint-disable-next-line
  }, dependencies);

  return { data, setData, loading, error, request };
};

其实这里request里实现的取消请求只是我们模拟出来的取消,真实情况下可以利用axios等请求库提供的方法做不一样的封装,这里主要是讲思路。
useEffect里返回的函数其实叫做清理函数,在每次新一次执行useEffect时,都会先执行清理函数,我们利用这个特性,就能成功的让useEffect永远只会用最新的请求结果去渲染页面。

可以去预览地址快速点击tab页切换,看一下控制台打印的结果。

主动请求的封装

现在需要加入一个功能,点击列表中的项目,切换完成状态,这时候useRequest好像就不太合适了,因为useRequest其实本质上是针对useEffect的封装,而useEffect的使用场景是初始化和依赖变更的时候发起请求,但是这个新需求其实是响应用户的点击而去主动发起请求,难道我们又要手动写setLoading之类的冗余代码了吗?答案当然是不。
我们利用高阶函数的**封装一个自定义hook:useWithLoading

useWithLoading代码实现

export function useWithLoading(fn) {
  const [loading, setLoading] = useState(false);

  const func = (...args) => {
    setLoading(true);
    return fn(...args).finally(() => {
      setLoading(false);
    });
  };

  return { func, loading };
}

它本质上就是对传入的方法进行了一层包裹,在执行前后去更改loading状态。
使用:

 // 完成todo逻辑
  const { func: onToggleFinished, loading: toggleLoading } = useWithLoading(
    async id => {
      await toggleTodo(id);
    }
  );
  
<TodoList todos={todos} onToggleFinished={onToggleFinished} />
      

代码组织

加入一个新功能,input的placeholder根据tab页的切换去切换文案,注意,这里我们先提供一个错误的示例,这是刚从Vue2.x和React Class Component转过来的人很容易犯的一个错误。

❌错误示例

import { fetchTodos } from './api'
import { useRequest } from './hooks'

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  // state放在一起
  const [activeTab, setActiveTab] = useState(TAB_ALL);
  const [placeholder, setPlaceholder] = useState("");
  const [query, setQuery] = useState("");
  
  // 副作用放在一起
  const {loading, data: todos} = useRequest(() => {
      return fetchTodos({ tab: activeTab });
  }, [activeTab]) 
  useEffect(() => {
    setPlaceholder(`在${tabMap[activeTab]}内搜索`);
  }, [activeTab]);
  const { func: onToggleFinished, loading: toggleLoading } = useWithLoading(
    async id => {
      await toggleTodo(id);
    }
  );
  
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <Input />
        <Spin spinning={loading} tip="稍等片刻~">
          <!--把todos传递给组件-->
          <TodoList todos={todos}/>
        </Spin>
      </div>
    </>
  );
}

注意,在之前的vue和react开发中,因为vue代码组织的方式都是 based on options(基于选项如data, methods, computed组织),
React 也是state在一个地方统一初始化,然后class里定义一堆一堆的xxx方法,这会导致新接手代码的人阅读逻辑十分困难。

所以hooks也解决了一个问题,就是我们的代码组织方式可以 based on logical concerns(基于逻辑关注点组织)了
不要再按照往常的思维把useState useEffect分门别类的组织起来,看起来整齐但是毫无用处 !!

这里上一张vue composition api介绍里对于@vue/ui库中一个组件的对比图

对比图
颜色是用来区分功能点的,哪种代码组织方式更利于维护,一目了然了吧。

Vue composition api 推崇的代码组织方式是把逻辑拆分成一个一个的自定hook function,这点和react hook的思路是一致的。

export default {
  setup() { // ...
  }
}

function useCurrentFolderData(nextworkState) { // ...
}

function useFolderNavigation({ nextworkState, currentFolderData }) { // ...
}

function useFavoriteFolder(currentFolderData) { // ...
}

function useHiddenFolders() { // ...
}

function useCreateFolder(openFolder) { // ...
}

✔️正确示例

import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
import TodoInput from "./todo-input";
import TodoList from "./todo-list";
import { Spin, Tabs } from "antd";
import { fetchTodos, toggleTodo } from "./api";
import { useRequest, useWithLoading } from "./hook";

import "antd/dist/antd.css";
import "./styles/styles.css";
import "./styles/reset.css";

const { TabPane } = Tabs;

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  const [activeTab, setActiveTab] = useState(TAB_ALL);

  // 数据获取逻辑
  const [query, setQuery] = useState("");
  const {
    data: { result: todos = [] },
    loading: listLoading
  } = useRequest(() => {
    return fetchTodos({ query, tab: activeTab });
  }, [query, activeTab]);

  // placeHolder
  const [placeholder, setPlaceholder] = useState("");
  useEffect(() => {
    setPlaceholder(`在${tabMap[activeTab]}内搜索`);
  }, [activeTab]);

  // 完成todo逻辑
  const { func: onToggleFinished, loading: toggleLoading } = useWithLoading(
    async id => {
      await toggleTodo(id);
    }
  );

  const loading = !!listLoading || !!toggleLoading;
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <TodoInput placeholder={placeholder} onSetQuery={setQuery} />
        <Spin spinning={loading} tip="稍等片刻~">
          <TodoList todos={todos} onToggleFinished={onToggleFinished} />
        </Spin>
      </div>
    </>
  );
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

总结

React Hook提供了一种新思路让我们去更好的组织组件内部的逻辑代码,使得功能复杂的大型组件更加易于维护。并且自定义Hook功能十分强大,在公司的项目中我也已经封装了很多好用的自定义Hook比如UseTable, useTreeSearch, useTabs等,可以结合各自公司使用的组件库和ui交互需求把一些逻辑更细粒度的封装起来,发挥你的想象力!useYourImagination!

在React中引入Vue3的reactivity分包来实现最强大的状态管理。

前言

React的状态管理是一个缤纷繁杂的大世界,光我知道的就不下数十种,其中有最出名immutable阵营的redux,有mutable阵营的mobxreact-easy-state,在hooks诞生后还有极简主义的unstated-next,有蚂蚁金服的大佬出品的hoxhoox

其实社区诞生这么多种状态管理框架,也说明状态管理库之间都有一些让人不满足的地方。

rxv是我依据这些痛点,并且直接引入了Vue3的package: @vue/reactivity去做的一个React状态管理框架,下面先看一个简单的示例:

示例

// store.ts
import { reactive, computed, effect } from '@vue/reactivity';

export const state = reactive({
  count: 0,
});

const plusOne = computed(() => state.count + 1);

effect(() => {
  console.log('plusOne changed: ', plusOne);
});

const add = () => (state.count += 1);

export const mutations = {
  // mutation
  add,
};

export const store = {
  state,
  computed: {
    plusOne,
  },
};

export type Store = typeof store;
// Index.tsx
import { Provider, useStore } from 'rxv'
import { mutations, store, Store } from './store.ts'
function Count() {
  const countState = useStore((store: Store) => {
    const { state, computed } = store;
    const { count } = state;
    const { plusOne } = computed;

    return {
      count,
      plusOne,
    };
  });

  return (
    <Card hoverable style={{ marginBottom: 24 }}>
      <h1>计数器</h1>
      <div className="chunk">
        <div className="chunk">store中的count现在是 {countState.count}</div>
        <div className="chunk">computed值中的plusOne现在是 {countState.plusOne.value}</div>
         <Button onClick={mutations.add}>add</Button>
      </div>
    </Card>
  );
}

export default () => {
  return (
    <Provider value={store}>
       <Count />
    </Provider>
  );
};

可以看出,store的定义只用到了@vue/reactivity,而rxv只是在组件中做了一层桥接,连通了Vue3和React,正如它名字的含义:React x Vue。

一些痛点

根据我自己的看法,我先简单的总结一下现有的状态管理库中或多或少存在的一些不足之处:

  1. redux为代表的,语法比较冗余,样板文件比较多。
  2. mobx很好,但是也需要单独的学一套api,对于react组件的侵入性较强,装饰器语法不稳定。
  3. unstated-next是一个极简的框架,对于React Hook做了一层较浅的封装。
  4. react-easy-state引入了observe-util,这个库对于响应式的处理很接近Vue3,我想要的了。

下面展开来讲:

options-based的痛点

Vuex和dva的options-based的模式现在看来弊端多多。具体的可以看尤大在vue-composition-api文档中总结的。

简单来说就是一个组件有好几个功能点,但是这几个功能点在分散在data,methods,computed中,形成了一个杂乱无章的结构。

当你想维护一个功能,你不得不先完整的看完这个配置对象的全貌。

心惊胆战的去掉几行,改掉几行,说不定会遗留一些没用的代码,也或者隐藏在computed选项里的某个相关的函数悄悄的坑了你...

而hook带来的好处是更加灵活的代码组织方式。

redux

直接引入dan自己的吐槽吧,要学的概念太多,写一个简单的功能要在五个文件之间跳来跳去,好头疼。redux的弊端在社区被讨论也不是一天两天了,相信写过redux的你也是深有同感。
redux

unstated-next

unstated-next其实很不错了,源码就40来行。最大程度的利用了React Hook的能力,写一个model就是写一个自定义hook。但是极简也带来了一些问题:

  1. 模块之间没有相互访问的能力。
  2. Context的性能问题,让你需要关注模块的划分。(具体可以看我这篇文章的性能章节
  3. 模块划分的问题,如果全放在一个Provider,那么更新的粒度太大,所有用了useContext的组件都会重复渲染。如果放在多个Provider里,那么就会回到第一条痛点,这些模块之间是相互独立的,没法互相访问。
  4. hook带来的一些心智负担的问题。React Hooks 你真的用对了吗?

react-easy-state

这个库引入的observe-util其实和Vue3 reactivity部分的核心实现很相似,关于原理解析也可以看我之前写的两篇文章:
带你彻底搞懂Vue3的Proxy响应式原理!TypeScript从零实现基于Proxy的响应式库。
带你彻底搞懂Vue3的Proxy响应式原理!基于函数劫持实现Map和Set的响应式。

那其实转而一想,Vue3 reactivity其实是observe-util的强化版,它拥有了更多的定制能力,如果我们能把这部分直接接入到状态管理库中,岂不是完全拥有了Vue3的响应式能力。

原理分析

vue-next是Vue3的源码仓库,Vue3采用lerna做package的划分,而响应式能力@vue/reactivity被划分到了单独的一个package中

从这个包提供的几个核心api来分析:

effect

effect其实是响应式库中一个通用的概念:观察函数,就像Vue2中的Watcher,mobx中的autorunobserver一样,它的作用是收集依赖

它接受的是一个函数,这个函数内部对于响应式数据的访问都可以收集依赖,那么在响应式数据更新后,就会触发响应的更新事件。

reactive

响应式数据的核心api,这个api返回的是一个proxy,对上面所有属性的访问都会被劫持,从而在get的时候收集依赖(也就是正在运行的effect),在set的时候触发更新。

ref

对于简单数据类型比如number,我们不可能像这样去做:

let data = reactive(2)
// 😭oops
data = 5

这是不符合响应式的拦截规则的,没有办法能拦截到data本身的改变,只能拦截到data身上的属性的改变,所以有了ref。

const data = ref(2)
// 💕ok
data.value= 5

computed

计算属性,依赖值更新以后,它的值也会随之自动更新。其实computed内部也是一个effect。

拥有在computed中观察另一个computed数据、effect观察computed改变之类的高级特性。

实现

从这几个核心api来看,只要effect能接入到React系统中,那么其他的api都没什么问题,因为它们只是去收集effect的毅力,去通知effect触发更新。

effect接受的是一个函数,而且effect还支持通过传入schedule参数来自定义依赖更新的时候需要触发什么函数,

rxv的核心api: useStore接受的也是一个函数selector,它会让用户自己选择在组件中需要访问的数据。

那么思路就显而易见了:

  1. selector包装在effect中执行,去收集依赖。
  2. 指定依赖发生更新时,需要调用的函数是当前正在使用useStore的这个组件的forceUpdate强制渲染函数。

这样不就实现了数据变化,组件自动更新吗?

简单的看一下核心实现

export const useStore = <T, S>(selector: Selector<T, S>): S => {
  const forceUpdate = useForceUpdate();
  const store = useStoreContext();

  const effection = useEffection(() => selector(store), {
    scheduler: forceUpdate,
    lazy: true,
  });

  const value = effection();
  return value;
};
  1. 先通过useForceUpdate在当前组件中注册一个强制更新的函数。
  2. 通过useContext读取用户从Provider中传入的store。
  3. 再通过Vue的effect去帮我们执行selector(store),并且指定scheduler为forceUpdate,这样就完成了依赖收集。

就简单的几行代码,就实现了在React中使用@vue/reactivity中的所有能力。

优点:

  1. 直接引入@vue/reacivity,完全使用Vue3的reactivity能力,拥有computed, effect等各种能力,并且对于Set和Map也提供了响应式的能力。后续也会随着这个库的更新变得更加完善的和强大。
  2. vue-next仓库内部完整的测试用例。
  3. 完善的TypeScript类型支持。
  4. 完全复用@vue/reacivity实现超强的全局状态管理能力。
  5. 状态管理中组件级别的精确更新。
  6. Vue3总是要学的嘛,提前学习防止失业!

缺点:

  1. 由于需要精确的收集依赖全靠useStore,所以selector函数一定要精确的访问到你关心的数据。甚至如果你需要触发数组内部某个值的更新,那你在useStore中就不能只返回这个对象本身。

源码地址

https://github.com/sl1673495/react-composition-api

如果你喜欢这个库,欢迎给出你的star✨,你的支持就是我最大的动力~

前端动画必知必会:React 和 Vue 都在用的 FLIP **实战

前言

在 Vue 的官网中的过渡动画章节中,可以看到一个很酷炫的动画效果

乍一看,让我们手写出这个逻辑应该是非常复杂的,先看看本文最后要实现的效果吧,和这个案例是非常类似的。

预览

也可以直接进预览网址里看:

http://sl1673495.gitee.io/flip-animation

图片素材依然引用自知乎问题《有个漂亮女朋友是种怎样的体验?》,侵删。

分析需求

拿到了这个需求,第一直觉是怎么做?假设第一行第一个图片移动到了第二行第三列,是不是要计算出第一行的高度,再计算出第二行前两个元素的宽度,然后从初始的坐标点通过 CSS 或者一些动画 API 移动过去?这样做是可以,但是在图片不定高不定宽,并且一次要移动很多图片情况下,这个计算方法就非常复杂了。并且这种情况下,图片的坐标都需要我们手动管理,非常不利于维护和扩展。

换种思路,能不能直接很自然的把 DOM 元素通过原生 API 添加到 DOM 树中,然后让浏览器帮我们好这个终点值,最后我们再动画位移过去?

在文档里我们发现一个名词:FLIP,这给了我们一个线索,是不是用这个玩意就可以写出这个动画呢?

答案是肯定的,顺着这个线索找到 Aerotwist 社区里的一篇文章:flip-your-animations,以这篇文章为切入点,一步步来实现一个类似的效果。

FLIP

FLIP 究竟是什么东西呢?先看下它的定义:

First

即将做动画的元素的初始状态(比如位置、透明度等等)。

Last

即将做动画的元素的最终状态。

Invert

这一步比较关键,假设我们图片的初始位置是 左: 0, 上:0,元素动画后的最终位置是 左:100, 上100,那么很明显这个元素是向右下角运动了 100px

但是,此时我们不按照常规思维去先计算它的最终位置,然后再命令元素从 0, 0 运动到 100, 100,而是先让元素自己移动过去(比如在 Vue 中用数据来驱动,在数组前面追加几个图片,之前的图片就自己移动到下面去了)。

这里有一个关键的知识点要注意了,也是我在之前的文章《深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调》中提到过的:

DOM 元素属性的改变(比如 leftrighttransform 等等),会被集中起来延迟到浏览器的下一帧统一渲染,所以我们可以得到一个这样的中间时间点:DOM 状态(位置信息)改变了,而浏览器还没渲染

有了这个前置条件,我们就可以保证先让 Vue 去操作 DOM 变更,此时浏览器还未渲染,我们已经能得到 DOM 状态变更后的位置了。

说的具体点,假设我们的图片是一行两个排列,图片数组初始化的状态是 [img1, img2,此时我们往数组头部追加两个元素 [img3, img4, img1, img2],那么 img1img2 就自然而然的被挤到下一行去了。

假设 img1 的初始位置是 0, 0,被数据驱动导致的 DOM 改变挤下去后的位置是 100, 100,那么此时浏览器还没有渲染,我们可以在这个时间点把 img1.style.transform = translate(-100px, -100px),让它 先 Invert 倒置回位移前的位置。

Play

倒置了以后,想要让它做动画就很简单了,再让它回到 0, 0 的位置即可,本文会采用最新的 Web Animation API 来实现最后的 Play

MDN 文档:Web Animation

实现

首先图片渲染很简单,就让图片通过简单的排成 4 列即可:

.wrap {
  display: flex;
  flex-wrap: wrap;
}

.img {
  width: 25%;
}

<div v-else class="wrap">
  <div class="img-wrap" v-for="src in imgs" :key="src">
    <img ref="imgs" class="img" :src="src" />
  </div>
</div>

那么关键点就在于怎么往这个 imgs 数组里追加元素后,做一个流畅的路径动画。

我们来实现追加图片的方法 add

async add() {
  const newData = this.getSister()
  await preload(newData)
}

首先随机的取出几张图片作为待放入数组的元素,利用 new Image 预加载这些图片,防止渲染一堆空白图片到屏幕上。

然后定义一个计算一组 DOM 元素位置的函数 getRects,利用 getBoundingClientRect 可以获得最新的位置信息,这个方法在接下来获取图片元素旧位置和新位置时都要使用。

function getRects(doms) {
  return doms.map((dom) => {
    const rect = dom.getBoundingClientRect()
    const { left, top } = rect
    return { left, top }
  })
}

// 当前已有的图片
const prevImgs = this.$refs.imgs.slice()
const prevPositions = getRects(prevImgs)

记录完图片的旧位置后,就可以向数组里追加新的图片了:

this.imgs = newData.concat(this.imgs)

随后就是比较关键的点了,我们知道 Vue 是异步渲染的,也就是改变了这个 imgs 数组后不会立刻发生 DOM 的变动,此时我们要用到 nextTick 这个 API,这个 API 把你传入的回调函数放进了 microTask 队列,正如上文提到的事件循环的文章里所说,microTask队列的执行一定发生在浏览器重新渲染前。

由于先调用了 this.imgs = newData.concat(this.imgs) 这段代码,触发了 Vue 的响应式依赖更新,此时 Vue 内部会把本次 DOM 更新的渲染函数先放到 microTask队列中,此时的队列是[changeDOM]

调用了 nextTick(callback) 后,这个callback函数也会被追加到队列中,此时的队列是 [changeDOM, callback]

这下聪明的你肯定就明白了,为什么 nextTick的回调函数里一定能获取到最新的 DOM 状态。

由于我们之前保存了图片元素节点的数组 prevImgs,所以在 nextTick 里调用同样的 getRect 方法获取到的就是旧图片的最新位置了。

async add() {
  // 最新 DOM 状态
  this.$nextTick(() => {
    // 再调用同样的方法获取最新的元素位置
    const currentPositions = getRects(prevImgs)
  })
},

此时我们已经拥有了 Invert 步骤的关键信息,新位置和旧位置,那么接下来就很简单了,把图片数组循环做一个倒置后 Play的动画即可。

prevImgs.forEach((imgRef, imgIndex) => {
  const currentPosition = currentPositions[imgIndex]
  const prevPosition = prevPositions[imgIndex]

  // 倒置后的位置,虽然图片移动到最新位置了,但你先给我回去,等着我来让你做动画。
  const invert = {
    left: prevPosition.left - currentPosition.left,
    top: prevPosition.top - currentPosition.top,
  }

  const keyframes = [
    // 初始位置是倒置后的位置
    {
      transform: `translate(${invert.left}px, ${invert.top}px)`,
    },
    // 图片更新后本来应该在的位置
    { transform: "translate(0)" },
  ]

  const options = {
    duration: 300,
    easing: "cubic-bezier(0,0,0.32,1)",
  }

  // 开始运动!
  const animation = imgRef.animate(keyframes, options)
})

此时一个非常流畅的路径动画效果就完成了。

完整实现如下:

async add() {
  const newData = this.getSister()
  await preload(newData)

  const prevImgs = this.$refs.imgs.slice()
  const prevPositions = getRects(prevImgs)

  this.imgs = newData.concat(this.imgs)

  this.$nextTick(() => {
    const currentPositions = getRects(prevImgs)

    prevImgs.forEach((imgRef, imgIndex) => {
      const currentPosition = currentPositions[imgIndex]
      const prevPosition = prevPositions[imgIndex]

      const invert = {
        left: prevPosition.left - currentPosition.left,
        top: prevPosition.top - currentPosition.top,
      }

      const keyframes = [
        {
          transform: `translate(${invert.left}px, ${invert.top}px)`,
        },
        { transform: "translate(0)" },
      ]

      const options = {
        duration: 300,
        easing: "cubic-bezier(0,0,0.32,1)",
      }

      const animation = imgRef.animate(keyframes, options)
    })
  })
},

乱序

现在我们想要实现官网 demo 中的 shuffle 效果,有了追加图片逻辑的铺垫,是不是已经觉得思路如泉涌了?没错,即使图片被打乱的再厉害,只要我们有「图片开始时的位置」和「图片结束时的位置」,那就可以轻松做到路径动画。

现在我们需要做的是把动画的逻辑抽离出来,我们分析一下整条链路:

保存旧位置 -> 改变数据驱动视图更新 -> 获得新位置 -> 利用 FLIP 做动画

其实外部只需要传入一个 update 方法告诉我们如何去更新图片数组,就可以把这个逻辑完全抽象到一个函数里去。

scheduleAnimation(update) {
  // 获取旧图片的位置
  const prevImgs = this.$refs.imgs.slice()
  const prevSrcRectMap = createSrcRectMap(prevImgs)
  // 更新数据
  update()
  // DOM更新后
  this.$nextTick(() => {
    const currentSrcRectMap = createSrcRectMap(prevImgs)
    Object.keys(prevSrcRectMap).forEach((src) => {
      const currentRect = currentSrcRectMap[src]
      const prevRect = prevSrcRectMap[src]

      const invert = {
        left: prevRect.left - currentRect.left,
        top: prevRect.top - currentRect.top,
      }

      const keyframes = [
        {
          transform: `translate(${invert.left}px, ${invert.top}px)`,
        },
        { transform: "" },
      ]
      const options = {
        duration: 300,
        easing: "cubic-bezier(0,0,0.32,1)",
      }

      const animation = currentRect.img.animate(keyframes, options)
    })
  })
}

那么追加图片和乱序的函数就变得非常简单了:

// 追加图片
async add() {
  const newData = this.getSister()
  await preload(newData)
  this.scheduleAnimation(() => {
    this.imgs = newData.concat(this.imgs)
  })
},
// 乱序图片
shuffle() {
  this.scheduleAnimation(() => {
    this.imgs = shuffle(this.imgs)
  })
}

源码地址

https://github.com/sl1673495/flip-animation

总结

FLIP

FLIP 不光可以做位置变化的动画,对于透明度、宽高等等也一样可以很轻松的实现。

比如电商平台中经常会出现一个动画,点击一张商品图片后,商品从它本来的位置慢慢的放大成了一张完整的页面。

FLIP的思路掌握后,只要你知道元素动画前的状态和元素动画后的状态,你都可以轻松的通过「倒置状态」后,让它们做一个流畅的动画后到达目的地,并且此时的 DOM 状态是很干净的,而不是通过大量计算的方式强迫它从 0, 0 位移到 100, 100,并且让 DOM 样式上留下 transform: translate(100px, 100px) 类似的字样。

Web Animation

利用 Web Animation API 可以让我们用 JavaScript 更加直观的描述我们需要元素去做的动画,想象一下这个需求如果用 CSS 来做,我们大概会这样去完成这个需求:

const currentImgStyle = currentRect.img.style
currentImgStyle.transform = `translate(${invert.left}px, ${invert.top}px)`
currentImgStyle.transitionDuration = "0s"

this._reflow = document.body.offsetHeight

currentRect.img.classList.add("move")

currentImgStyle.transform = currentRect.img.style.transitionDuration = ""

currentRect.img.addEventListener("transitionend", () => {
  currentRect.img.classList.remove("move")
})

这也是 Vue 内部 transition-group 组件实现 FLIP 动画的大致思路,Vue 应该是为了兼容性和代码体积等一些方面的权衡,还是选择用比较原生的方式去实现 FLIP 动画,这段代码让我觉得不舒服的点在于:

  1. 需要通过 class 的增加和删除来和 CSS 来进行交互,整体流程不太符合直觉。
  2. 需要监听动画完成事件,并且做一些清理操作,容易遗漏。
  3. 需要利用 document.body.offsetHeight 这样的方式触发 强制同步布局,比较 hack 的知识点。
  4. 需要利用 this._reflow = document.body.offsetHeight 这样的方式向元素实例上增加一个没有意义的属性,防止被 Rollup 等打包工具 tree-shaking 误删。 比较 hack 的知识点 +1。

而利用 Web Animation API 的代码则变得非常符合直觉和易于维护:

const keyframes = [
  {
    transform: `translate(${invert.left}px, ${invert.top}px)`,
  },
  { transform: "" },
]
const options = {
  duration: 300,
  easing: "cubic-bezier(0,0,0.32,1)",
}

const animation = currentRect.img.animate(keyframes, options)

关于兼容性问题,W3C 已经提供了 Web Animation API Polyfill,可以放心大胆的使用。

期待在不久的未来,我们可以抛弃旧的动画模式,迎接这种更新更好的 API。

希望这篇文章能让对动画发愁的你有一些收获,谢谢!

❤️ 感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

cube-ui源码学习 swipe组件

swipe组件预览地址(手机模式可体验)
作者:黄轶老师

先吹一波黄老,昨天体验swipe组件的时候感受到了什么叫丝滑,这可以说是东半球移动端最好用的swipe组件了吧。

先来一段文档中的用法的简化版:

<cube-swipe>
    <li class="swipe-item-wrapper" v-for="(data,index) in swipeData" :key="data.item.id">
      <cube-swipe-item
          ref="swipeItem"
          :btns="data.btns"
          :index="index"
          @btn-click="onBtnClick">
           <div></div>
      </cube-swipe-item>
   </li>      
 </cube-swipe>

在cube-ui的项目的src/components/swipe目录下,我们可以看到swipe组件被分为swipe.vue和swipe-item.vue。
其实swipe就是列表的外层容器组件,负责处理一些全列表的事件。
swipe-item就是列表中循环出来的某一项元素的组件,负责处理手势等细节。
我们先从swipe.vue入手:

swipe.vue

<template>
  <div class="cube-swipe">
    <slot>
      <transition-group name="cube-swipe" tag="ul">
        <li v-for="(item, index) in data" :key="item.item.value">
          <cube-swipe-item
            :btns="item.btns"
            :item="item.item"
            :index="index"
            :auto-shrink="autoShrink" />
        </li>
      </transition-group>
    </slot>
  </div>
</template>

我们先从template部分入手, 可以看到结构非常简单,就是一个div中给了一个slot子元素,并且slot有个默认值,
如果用户不传slot的话就默认的带transition-group动效循环出一段cube-swipe-item列表,不使用slot的情况下用户可以传入

swipeData: [{
        item: {
          text: '测试1',
          value: 1
        },
        btns: [
          {
            action: 'clear',
            text: '不再关注',
            color: '#c8c7cd'
          },
          {
            action: 'delete',
            text: '删除',
            color: '#ff3a32'
          }
        ]
      }, {
        item: {
          text: '测试2',
          value: 2
        },
        btns: [
          {
            action: 'clear',
            text: '不再关注',
            color: '#c8c7cd'
          },
          {
            action: 'delete',
            text: '删除',
            color: '#ff3a32'
          }
        ]
      }, {
        item: {
          text: '测试3',
          value: 3
        },
        btns: [
          {
            action: 'clear',
            text: '不再关注',
            color: '#c8c7cd'
          },
          {
            action: 'delete',
            text: '删除',
            color: '#ff3a32'
          }
        ]
      }]

这样一段大而全的json数组,渲染出一个列表,不过这种方式比较不灵活。

<script type="text/ecmascript-6">
  import CubeSwipeItem from './swipe-item.vue'
  const COMPONENT_NAME = 'cube-swipe'
  const EVENT_ITEM_CLICK = 'item-click'
  const EVENT_BTN_CLICK = 'btn-click'
  export default {
    name: COMPONENT_NAME,
    provide() {
      return {
        swipe: this
      }
    },
    props: {
      data: {
        type: Array,
        default() {
          return []
        }
      },
      autoShrink: {
        type: Boolean,
        default: false
      }
    },
    created() {
      this.activeIndex = -1
      this.items = []
    },
    methods: {
      addItem(item) {
        this.items.push(item)
      },
      removeItem(item) {
        const index = this.items.indexOf(item)
        this.items.splice(index, 1)
        if (index <= this.activeIndex) {
          this.activeIndex -= 1
        }
      },
      onItemClick(item, index) {
        this.$emit(EVENT_ITEM_CLICK, item, index)
      },
      onBtnClick(btn, index) {
        const item = this.data[index]
        this.$emit(EVENT_BTN_CLICK, btn, index, item)
      },
      onItemActive(index) {
        if (index === this.activeIndex) {
          return
        }
        if (this.activeIndex !== -1) {
          const activeItem = this.items[this.activeIndex]
          activeItem.shrink()
        }
        this.activeIndex = index
      }
    },
    components: {
      CubeSwipeItem
    }
  }
</script>

script的data和methods里提供了很多东西,但是在template里却没有使用到,那么我们猜测这些都是提供给子组件使用的,
provider里把自身实例提供给了子组件

 provide() {
      return {
        swipe: this
      }
    },

那么我们接下来就去探究swipe-item组件。

swipe-item

<template>
  <div ref="swipeItem"
       @transitionend="onTransitionEnd"
       @touchstart="onTouchStart"
       @touchmove="onTouchMove"
       @touchend="onTouchEnd"
       class="cube-swipe-item">
    <slot>
      <div @click="clickItem" class="cube-swipe-item-inner border-bottom-1px">
        <span>{{item.text}}</span>
      </div>
    </slot>
    <ul class="cube-swipe-btns">
      <li ref="btns"
          v-for="btn in btns"
          class="cube-swipe-btn"
          :style="genBtnStyl(btn)"
          @click.prevent="clickBtn(btn)">
        <span class="text">{{btn.text}}</span>
      </li>
    </ul>
  </div>
</template>

<style lang="stylus" rel="stylesheet/stylus">
  @require "../../common/stylus/variable.styl"
  .cube-swipe-item
    position: relative
  .cube-swipe-item-inner
    height: 60px
    line-height: 60px
    font-size: $fontsize-large
    padding-left: 20px
  .cube-swipe-btn
    display: flex
    align-items: center
    position: absolute
    top: 0
    left: 100%
    height: 100%
    text-align: left
    font-size: $fontsize-large
    .text
      flex: 1
      padding: 0 20px
      white-space: nowrap
      color: $swipe-btn-color
</style>

可以看到swipe-item的结构也非常简单, 也提供了slot插槽定制子组件的元素
并且在子组件的旁边有个初始隐藏的ul结构 用来循环btns来生成侧滑出来的按钮
.cube-swipe-btn这个类是绝对定位并且left 100% 也就是相对于父relative容器
.cube-swipe-item的宽度偏移 正好隐藏到边缘外。

接下来我们看一下script部分

<script type="text/ecmascript-6">
  import {
    getRect,
    prefixStyle
  } from '../../common/helpers/dom'
  import { easeOutQuart, easeOutCubic } from '../../common/helpers/ease'
  import { getNow } from '../../common/lang/date'
  const COMPONENT_NAME = 'cube-swipe-item'
  const EVENT_ITEM_CLICK = 'item-click'
  const EVENT_BTN_CLICK = 'btn-click'
  const EVENT_SCROLL = 'scroll'
  const EVENT_ACTIVE = 'active'
  const DIRECTION_LEFT = 1
  const DIRECTION_RIGHT = -1
  const STATE_SHRINK = 0
  const STATE_GROW = 1
  const easingTime = 600
  const momentumLimitTime = 300
  const momentumLimitDistance = 15
  const directionLockThreshold = 5
  const transform = prefixStyle('transform')
  const transitionProperty = prefixStyle('transitionProperty')
  const transitionDuration = prefixStyle('transitionDuration')
  const transitionTimingFunction = prefixStyle('transitionTimingFunction')
  export default {
    name: COMPONENT_NAME,
    inject: ['swipe'],
    props: {
      item: {
        type: Object,
        default() {
          return {}
        }
      },
      btns: {
        type: Array,
        default() {
          return []
        }
      },
      index: {
        type: Number,
        index: -1
      },
      autoShrink: {
        type: Boolean,
        default: false
      }
    },
    watch: {
      btns() {
        this.$nextTick(() => {
          this.refresh()
        })
      }
    },
    created() {
      this.x = 0
      this.state = STATE_SHRINK
      this.swipe.addItem(this)
    },
    mounted() {
      this.scrollerStyle = this.$refs.swipeItem.style
      this.$nextTick(() => {
        this.refresh()
      })
      this.$on(EVENT_SCROLL, this._handleBtns)
    },
    methods: {
      _initCachedBtns() {
        this.cachedBtns = []
        const len = this.$refs.btns.length
        for (let i = 0; i < len; i++) {
          this.cachedBtns.push({
            width: getRect(this.$refs.btns[i]).width
          })
        }
      },
      _handleBtns(x) {
        /* istanbul ignore if */
        if (this.btns.length === 0) {
          return
        }
        const len = this.$refs.btns.length
        let delta = 0
        let totalWidth = -this.maxScrollX
        for (let i = 0; i < len; i++) {
          const btn = this.$refs.btns[i]
          let rate = (totalWidth - delta) / totalWidth
          let width
          let translate = rate * x - x
          if (x < this.maxScrollX) {
            width = this.cachedBtns[i].width + rate * (this.maxScrollX - x)
          } else {
            width = this.cachedBtns[i].width
          }
          delta += this.cachedBtns[i].width
          btn.style.width = `${width}px`
          btn.style[transform] = `translate(${translate}px)`
          btn.style[transitionDuration] = '0ms'
        }
      },
      _isInBtns(target) {
        let parent = target
        let flag = false
        while (parent && parent.className.indexOf('cube-swipe-item') < 0) {
          if (parent.className.indexOf('cube-swipe-btns') >= 0) {
            flag = true
            break
          }
          parent = parent.parentNode
        }
        return flag
      },
      _calculateBtnsWidth() {
        let width = 0
        const len = this.cachedBtns.length
        for (let i = 0; i < len; i++) {
          width += this.cachedBtns[i].width
        }
        this.maxScrollX = -width
      },
      _translate(x, useZ) {
        let translateZ = useZ ? ' translateZ(0)' : ''
        this.scrollerStyle[transform] = `translate(${x}px,0)${translateZ}`
        this.x = x
      },
      _transitionProperty(property = 'transform') {
        this.scrollerStyle[transitionProperty] = property
      },
      _transitionTimingFunction(easing) {
        this.scrollerStyle[transitionTimingFunction] = easing
      },
      _transitionTime(time = 0) {
        this.scrollerStyle[transitionDuration] = `${time}ms`
      },
      _getComputedPositionX() {
        let matrix = window.getComputedStyle(this.$refs.swipeItem, null)
        matrix = matrix[transform].split(')')[0].split(', ')
        let x = +(matrix[12] || matrix[4])
        return x
      },
      _translateBtns(time, easing, extend) {
        /* istanbul ignore if */
        if (this.btns.length === 0) {
          return
        }
        const len = this.$refs.btns.length
        let delta = 0
        let translate = 0
        for (let i = 0; i < len; i++) {
          const btn = this.$refs.btns[i]
          if (this.state === STATE_GROW) {
            translate = delta
          } else {
            translate = 0
          }
          delta += this.cachedBtns[i].width
          btn.style[transform] = `translate(${translate}px,0) translateZ(0)`
          btn.style[transitionProperty] = 'all'
          btn.style[transitionTimingFunction] = easing
          btn.style[transitionDuration] = `${time}ms`
          if (extend) {
            btn.style.width = `${this.cachedBtns[i].width}px`
          }
        }
      },
      refresh() {
        if (this.btns.length > 0) {
          this._initCachedBtns()
          this._calculateBtnsWidth()
        }
        this.endTime = 0
      },
      shrink() {
        this.stop()
        this.state = STATE_SHRINK
        this.$nextTick(() => {
          this.scrollTo(0, easingTime, easeOutQuart)
          this._translateBtns(easingTime, easeOutQuart)
        })
      },
      grow() {
        this.state = STATE_GROW
        const extend = this.x < this.maxScrollX
        let easing = easeOutCubic
        this.scrollTo(this.maxScrollX, easingTime, easing)
        this._translateBtns(easingTime, easing, extend)
      },
      scrollTo(x, time, easing) {
        this._transitionProperty()
        this._transitionTimingFunction(easing)
        this._transitionTime(time)
        this._translate(x, true)
        if (time) {
          this.isInTransition = true
        }
      },
      genBtnStyl(btn) {
        return `background: ${btn.color}`
      },
      clickItem() {
        this.swipe.onItemClick(this.item, this.index)
        this.$emit(EVENT_ITEM_CLICK, this.item, this.index)
      },
      clickBtn(btn) {
        this.swipe.onBtnClick(btn, this.index)
        this.$emit(EVENT_BTN_CLICK, btn, this.index)
        if (this.autoShrink) {
          this.shrink()
        }
      },
      stop() {
        if (this.isInTransition) {
          this.isInTransition = false
          let x = this.state === STATE_SHRINK ? 0 : this._getComputedPositionX()
          this._translate(x)
          this.$emit(EVENT_SCROLL, this.x)
        }
      },
      onTouchStart(e) {
        this.swipe.onItemActive(this.index)
        this.$emit(EVENT_ACTIVE, this.index)
        this.stop()
        this.moved = false
        this.movingDirectionX = 0
        const point = e.touches[0]
        this.pointX = point.pageX
        this.pointY = point.pageY
        this.distX = 0
        this.distY = 0
        this.startX = this.x
        this._transitionTime()
        this.startTime = getNow()
        if (this.state === STATE_GROW && !this._isInBtns(e.target)) {
          this.shrinkTimer = setTimeout(() => {
            this.shrink()
          }, 300)
        }
      },
      onTouchMove(e) {
        if (this.moved) {
          clearTimeout(this.shrinkTimer)
          e.stopPropagation()
        }
        /* istanbul ignore if */
        if (this.isInTransition) {
          return
        }
        e.preventDefault()
        const point = e.touches[0]
        let deltaX = point.pageX - this.pointX
        let deltaY = point.pageY - this.pointY
        this.pointX = point.pageX
        this.pointY = point.pageY
        this.distX += deltaX
        this.distY += deltaY
        let absDistX = Math.abs(this.distX)
        let absDistY = Math.abs(this.distY)
        if (absDistX + directionLockThreshold <= absDistY) {
          return
        }
        let timestamp = getNow()
        if (timestamp - this.endTime > momentumLimitTime && absDistX < momentumLimitDistance) {
          return
        }
        this.movingDirectionX = deltaX > 0 ? DIRECTION_RIGHT : deltaX < 0 ? DIRECTION_LEFT : 0
        let newX = this.x + deltaX
        if (newX > 0) {
          newX = 0
        }
        if (newX < this.maxScrollX) {
          newX = this.x + deltaX / 3
        }
        if (!this.moved) {
          this.moved = true
        }
        this._translate(newX, true)
        if (timestamp - this.startTime > momentumLimitTime) {
          this.startTime = timestamp
          this.startX = this.x
        }
        this.$emit(EVENT_SCROLL, this.x)
      },
      onTouchEnd() {
        if (!this.moved) {
          return
        }
        if (this.movingDirectionX === DIRECTION_RIGHT) {
          this.shrink()
          return
        }
        this.endTime = getNow()
        let duration = this.endTime - this.startTime
        let absDistX = Math.abs(this.x - this.startX)
        if ((duration < momentumLimitTime && absDistX > momentumLimitDistance) || this.x < this.maxScrollX / 2) {
          this.grow()
        } else {
          this.shrink()
        }
      },
      onTransitionEnd() {
        this.isInTransition = false
        this._transitionTime()
        this._translate(this.x)
      }
    },
    beforeDestroy() {
      this.swipe.removeItem(this)
    }
  }
</script>

首先看到inject: ['swipe'], 使得父swipe组件实例自身可以通过this.swipe访问到,
接下来看

  props: {
      item: {
        type: Object,
        default() {
          return {}
        }
      },
      btns: {
        type: Array,
        default() {
          return []
        }
      },
      index: {
        type: Number,
        index: -1
      },
      autoShrink: {
        type: Boolean,
        default: false
      }
    },

组件接受四个props,item是在不使用slot自定义子组件元素的情况下使用的,我们可以先不看。
btns就是描述按钮的数组,形如

btns: [
            {
              action: 'clear',
              text: '不再关注',
              color: '#c8c7cd'
            },
            {
              action: 'delete',
              text: '删除',
              color: '#ff3a32'
            }
          ]

index 接受在外层v-for拿到的index传递给swipe-item组件 便于标识这个swipe-item在swipe容器中的序号。
autoShrink用于当点击滑块的按钮后,是否需要自动收缩滑块,如果使用自定义插槽,则直接给 cube-swipe-item 传递此值即可。

看完了props 我们可以按生命周期流程开始看了,先看created周期

    created() {
      this.x = 0
      this.state = STATE_SHRINK
      this.swipe.addItem(this)
    },

this.x用来记录滑动偏移的量,
this.state用来记录状态,默认是缩起,
this.swipe.addItem(this) 调用父组件的addItem方法把自身实例push到父组件的
this.items数组里收集起来。

初始化完了我们来看

mounted() {
      this.scrollerStyle = this.$refs.swipeItem.style
      this.$nextTick(() => {
        this.refresh()
      })
      this.$on(EVENT_SCROLL, this._handleBtns)
    },

首先通过把这个组件的dom节点的style用this.scrollerStyle记录起来 便于后续操作
接着调用了this.refresh

refresh() {
        if (this.btns.length > 0) {
          this._initCachedBtns()
          this._calculateBtnsWidth()
        }
        this.endTime = 0
      },

可以看到 我们做了两个初始化工作_initCachedBtns和_calculateBtnsWidth,并且把endTime标识为0
我们先看_initCachedBtns

_initCachedBtns() {
        this.cachedBtns = []
        const len = this.$refs.btns.length
        for (let i = 0; i < len; i++) {
          this.cachedBtns.push({
            width: getRect(this.$refs.btns[i]).width
          })
        }
      },

this.cachedBtns记录按钮宽度大小,
最后生成形如[ {width: 50}, {width: 50 } ] 这样的记录,
再来看_calculateBtnsWidth

_calculateBtnsWidth() {
        let width = 0
        const len = this.cachedBtns.length
        for (let i = 0; i < len; i++) {
          width += this.cachedBtns[i].width
        }
        this.maxScrollX = -width
      },

其实就是计算出按钮的总长度
然后记录在this.maxScrollX变量上,用于标识向左滑动的最大距离。

mounted的最后this.$on(EVENT_SCROLL, this._handleBtns)
注册了EVENT_SCROLL事件的回调函数为 this._handleBtns, 我们先记下来 等到触发的时候再详细去讲。

初始化的流程到这就结束了, 那么接下来我们就可以看这个组件的核心 touch事件了,touch事件全部注册在最外层的dom节点上

 <div ref="swipeItem"
       @transitionend="onTransitionEnd"
       @touchstart="onTouchStart"
       @touchmove="onTouchMove"
       @touchend="onTouchEnd"
       class="cube-swipe-item">

我们顺着流程onTouchStart - onTouchMove - onTouchEnd - onTransitionEnd一步一步来走。

onTouchStart(e) {
        this.swipe.onItemActive(this.index)
        this.$emit(EVENT_ACTIVE, this.index)
        this.stop()
        this.moved = false
        this.movingDirectionX = 0
        const point = e.touches[0]
        this.pointX = point.pageX
        this.pointY = point.pageY
        this.distX = 0
        this.distY = 0
        this.startX = this.x
        this._transitionTime()
        this.startTime = getNow()
        if (this.state === STATE_GROW && !this._isInBtns(e.target)) {
          this.shrinkTimer = setTimeout(() => {
            this.shrink()
          }, 300)
        }
      },

this.swipe.onItemActive(this.index)
首先通知父组件“我被触摸了”, 这里调用父swipe组件的onItemActive方法

 onItemActive(index) {
        if (index === this.activeIndex) {
          return
        }
        if (this.activeIndex !== -1) {
          const activeItem = this.items[this.activeIndex]
          activeItem.shrink()
        }
        this.activeIndex = index
      }

如果父元素中有已经被触摸左滑展开的swipe-item记录 并且和这个新的swipe-item不是同一个 就通知上一个子组件shrink() 收起, 并且在swipe组件中记录this.activeIndex = index新的子组件序号
this.pointX = point.pageX
this.pointY = point.pageY
this.distX = 0
this.distY = 0
this.startX = this.x
记录了这个点的xy值 把dist当前手指的触碰距离值置为0,把this.x的值赋值给this.startX
调用this._transitionTime()

      _transitionTime(time = 0) {
        this.scrollerStyle[transitionDuration] = `${time}ms`
      },

把style的transitionDuration置为0 手指触摸的时候不需要transitionDuration来帮我们完成动画过渡效果的,所以先把这个过渡关闭

this.startTime = getNow() // 记录触摸开始的时间
if (this.state === STATE_GROW && !this._isInBtns(e.target)) {
          this.shrinkTimer = setTimeout(() => {
            this.shrink()
          }, 300)
        }

这段代码做了一个判断 如果当前的状态是展开 并且点击的位置不在btn内部
就设置了一个定时器 如果touchstart过了300ms 就会把这个swipe-item收起
总结起来就是一系列初始化值的设置,接下来看onTouchMove
onTouchMove的方法比较长 也是滑动动画的核心,我们跟着注释一行一行来解读

onTouchMove(e) {
        if (this.moved) {
          // 如果moved变量为true 也就是正在移动中, 就把300ms后自动缩进的定时器清空掉
          clearTimeout(this.shrinkTimer)
         // 并且阻止事件冒泡
          e.stopPropagation()
        }
        /* istanbul ignore if */

       // 如果已经在进行动画 就直接return 
       // 展开动画和缩起动画的过程中这个值都是true
        if (this.isInTransition) {
          return
        }

        // 阻止浏览器默认touch行为,比如页面滚动
        e.preventDefault()
        const point = e.touches[0]

        // 相对于上次触发touchmove时候横向的偏移量deltaX
        let deltaX = point.pageX - this.pointX
        // 相对于上次触发touchmove时候竖直方向的偏移量deltaY
        let deltaY = point.pageY - this.pointY

        // 记录最新的pointX和Y
        this.pointX = point.pageX
        this.pointY = point.pageY

        // 本次从touchstart事件开始移动的横向总距离
        this.distX += deltaX

         // 本次从touchstart事件开始移动的纵向总距离
        this.distY += deltaY

       // distX和distY的绝对值
        let absDistX = Math.abs(this.distX)
        let absDistY = Math.abs(this.distY)

        // 如果横向距离 加directionLockThreshold(被设置成了5) 
        // 小与纵向移动的距离 就判定成上下滑动 不做任何行为
        //这其实就是稍微大于45度角的角度以内的滑动会被识别为侧滑
        if (absDistX + directionLockThreshold <= absDistY) {
          return
        }

        let timestamp = getNow()
        // momentumLimitTime和momentumLimitDistance
        // 定义两次动画的最小间隔事件和最小间隔移动距离
        // 距离上次touchend 300ms内并且 横向移动小于15的move事件会被无视
        if (timestamp - this.endTime > momentumLimitTime && absDistX < momentumLimitDistance) {
          return
        }

        // movingDirectionX 滑动的方向, 如果deltaX大于0 则是向右滑动-1 
        // 如果deltaX小于0则是左滑-1 如果等于0 则记录为0
        this.movingDirectionX = deltaX > 0 ? DIRECTION_RIGHT : deltaX < 0 ? DIRECTION_LEFT : 0
        // this.x在执行_translate动画的之后会被更新成当前的translateX值, 

       // newX拿到了到上次move为止偏移的x值 
       // 加上本次move偏移的deltaX值
       // 计算出newX也就是下一次应该_translate到x位置值,
       // 这个值一定是负数,因为我们的按钮组一定是向左做偏移translateX(-x)
       //  当然这个值不能直接交给_translate方法 我们要做一些边界值处理
        let newX = this.x + deltaX
        // 不能大于0的边界限制, 保证向右滑动不能超出边缘
        if (newX > 0) {
          newX = 0
        }
        // 如果X的值比最大的maxScrollX值还小
        // maxScrollX的值在refresh中
        // 被设置成了按钮组的总width的负值
        // 用比较好理解的方法 就是向左拉到了极限值
        // 那么你下次再拉30px 只会向左做10px的动画
        // 给你一种有阻力的感觉
        if (newX < this.maxScrollX) {
          newX = this.x + deltaX / 3
        }

       // 如果moved是false 记录为true
        if (!this.moved) {
          this.moved = true
        }
       // 调用_translate 真正去操作dom左偏移的行为
        this._translate(newX, true)

       // 如果这次move的事件减去开始事件小于momentumLimitTime边界值300ms
       // 就把这次move手指所在的值定义为下次计算的开始值,好做到手指短暂离开屏幕 动画也可以衔接上
        if (timestamp - this.startTime > momentumLimitTime) {
          // 重置startTime为当前时间
          this.startTime = timestamp
          // 重置startX为当前的偏移值x
          this.startX = this.x
        }
        // 触发EVENT_SCROLL事件 带出当前的x值。
        this.$emit(EVENT_SCROLL, this.x)
      },

总结touchmove事件 核心就是根据当前手指的x值和start时的x值 调用_translate让dom去做一些偏移

      _translate(x, useZ) {
        let translateZ = useZ ? ' translateZ(0)' : ''
        this.scrollerStyle[transform] = `translate(${x}px,0)${translateZ}`
        this.x = x
      },

_translate很简单 把x值写入dom样式里 并且translateZ(0)开启硬件加速
然后更新实例上的this.x 最后还要触发一个EVENT_SCROLL
我们在created里看到了这个EVENT_SCROLL事件注册的回调是_handleBtns
其实就是在touchmove的时候也驱动按钮组做一些动画
_handleBtns

      // 根据当前的x值驱动每个按钮去做向左滑动动画
      // 并且如果超出了最大x距离 还要让按钮变长
      // 让用户有种按钮有弹性拉动的感觉
     _handleBtns(x) {
        /* istanbul ignore if */
        if (this.btns.length === 0) {
          return
        }
        const len = this.$refs.btns.length
        let delta = 0
        let totalWidth = -this.maxScrollX
        for (let i = 0; i < len; i++) {
          const btn = this.$refs.btns[i]
          let rate = (totalWidth - delta) / totalWidth
          let width
          let translate = rate * x - x
          if (x < this.maxScrollX) {
            width = this.cachedBtns[i].width + rate * (this.maxScrollX - x)
          } else {
            width = this.cachedBtns[i].width
          }
          delta += this.cachedBtns[i].width
          btn.style.width = `${width}px`
          btn.style[transform] = `translate(${translate}px)`
          btn.style[transitionDuration] = '0ms'
        }
      },
onTouchEnd() {
       // 如果moved变量为false 什么也不做
        if (!this.moved) {
          return
        }
       
        // 如果是向右滑动 调用shrink缩起滑块
        if (this.movingDirectionX === DIRECTION_RIGHT) {
          this.shrink()
          return
        }
        // this.endTime设置为当前时间
        this.endTime = getNow()

        // 从开始滑动到结束的时间间隔
        let duration = this.endTime - this.startTime
        // 本次滑动的总距离
        let absDistX = Math.abs(this.x - this.startX)

        
        if ((duration < momentumLimitTime && absDistX > momentumLimitDistance) || this.x < this.maxScrollX / 2) {
          // 时间间隔<300ms 滑动距离>15 或者滑动距离x比最大滑动距离的一半要小 就展开
          this.grow()
        } else {
          //  否则收起
          this.shrink()
        }

touchend的核心逻辑就是根据记录的一些变量判断是要调用展开还是收起
展开grow

      grow() {
        // 状态记录为展开状态
        this.state = STATE_GROW
        // extend记录为x是否比最大滑动距离要小
        const extend = this.x < this.maxScrollX
        // 展开的贝塞尔曲线描述
        let easing = easeOutCubic
        // 调用scrollTo,值定义为完全展开的x值
        this.scrollTo(this.maxScrollX, easingTime, easing)
        // 调用_translateBtns让按钮组做动画
        this._translateBtns(easingTime, easing, extend)
      },

我们来看看scrollTo方法如何让容器偏移到最大滑动距离

     scrollTo(x, time, easing) {
        // 设定transform-property为'transform'
        this._transitionProperty()
        // 设定transform过渡动画为easing贝塞尔曲线
        this._transitionTimingFunction(easing)
        // 设定过渡时间
        this._transitionTime(time)
        // 设定transformX值 开始执行动画
        this._translate(x, true)
        // 有过渡时间的情况下 把isInTransition变量置为true
        if (time) {
          this.isInTransition = true
        }
      },

其实scrollTo就是给容器设定了一系列的transform的css值,让css帮我们做动画
再看_translateBtns

_translateBtns(time, easing, extend) {
        /* istanbul ignore if */
        // 如果没有btns 就啥也不做
        if (this.btns.length === 0) {
          return
        }

        // 遍历btn组的dom节点,
        // 给按钮也设置一系列css transform 让按钮一个个做对应的动画
        // 并且如果extend为true 证明此时按钮被拉到超出最大距离 width被变长了
        // 要重置为之前的width
        const len = this.$refs.btns.length
        let delta = 0
        let translate = 0
        for (let i = 0; i < len; i++) {
          const btn = this.$refs.btns[i]
          if (this.state === STATE_GROW) {
            translate = delta
          } else {
            translate = 0
          }
          delta += this.cachedBtns[i].width
          btn.style[transform] = `translate(${translate}px,0) translateZ(0)`
          btn.style[transitionProperty] = 'all'
          btn.style[transitionTimingFunction] = easing
          btn.style[transitionDuration] = `${time}ms`
          if (extend) {
            btn.style.width = `${this.cachedBtns[i].width}px`
          }
        }
      },

再来看缩起shrink

      shrink() {
        this.stop()
        this.state = STATE_SHRINK
        this.$nextTick(() => {
          this.scrollTo(0, easingTime, easeOutQuart)
          this._translateBtns(easingTime, easeOutQuart)
        })
      },

先调用了stop
stop中先把this.isInTransition置为false
在touchstart时候也会调用stop 所以要根据state判断目标值
如果状态已经是缩起状态STATE_SHRINK, 则目标值是0
然后_translate过渡到x位置
并且通过EVENT_SCROLL事件通知按钮组也过渡到x位置

      stop() {
        if (this.isInTransition) {
          this.isInTransition = false
          let x = this.state === STATE_SHRINK ? 0 : this._getComputedPositionX()
          this._translate(x)
          this.$emit(EVENT_SCROLL, this.x)
        }
      },

最后在nextTick里调用scrollTo和_translateBtns分别把容器dom和按钮组动画移动到缩起状态原位
因为此时state已经是STATE_SHRINK了 所以_translateBtns内部会判定x的目标值为0

至此touch事件三剑客都分析完毕了,内部有些细节实现的很精巧
在动画结束的时候会调用onTransitionEnd,做一些状态的重置。

      onTransitionEnd() {
        this.isInTransition = false
        this._transitionTime()
        this._translate(this.x)
      }

另外在按钮上点击会触发clickBtn方法,驱动‘btn-click’事件的触发
并且判断autoShrink的情况下自动收缩起按钮组

      clickBtn(btn) {
        this.swipe.onBtnClick(btn, this.index)
        this.$emit(EVENT_BTN_CLICK, btn, this.index)
        if (this.autoShrink) {
          this.shrink()
        }
      },

react-component源码学习(1) rc-form

rc-form作为ant-design系列实现表单组件的底层组件, 通用性和强大的功能兼得,这得益于它底层的精妙实现,rc-form是典型的高阶组件(higher-order component)

下面从一个官方的简单示例说起。

import { createForm, formShape } from 'rc-form';

class Form extends React.Component {
  static propTypes = {
    form: formShape,
  };

  submit = () => {
    this.props.form.validateFields((error, value) => {
      console.log(error, value);
    });
  }

  render() {
    let errors;
    const { getFieldProps, getFieldError } = this.props.form;
    return (
      <div>
        <input {...getFieldProps('normal')}/>
        <input {...getFieldProps('required', {
          onChange(){}, // have to write original onChange here if you need
          rules: [{required: true}],
        })}/>
        {(errors = getFieldError('required')) ? errors.join(',') : null}
        <button onClick={this.submit}>submit</button>
      </div>
    );
  }
}

export createForm()(Form);

可以看到在最后用createForm这个函数执行返回的函数包裹了Form组件,
正因为如此在render中才可以从props里拿到from, 这是rc-form提供给我们的,接下来看看这个form是如何注入进去的。

createForm.js

import createBaseForm from './createBaseForm';

export const mixin = {
  getForm() {
    return {
      getFieldsValue: this.fieldsStore.getFieldsValue,
      getFieldValue: this.fieldsStore.getFieldValue,
      getFieldInstance: this.getFieldInstance,
      setFieldsValue: this.setFieldsValue,
      setFields: this.setFields,
      setFieldsInitialValue: this.fieldsStore.setFieldsInitialValue,
      getFieldDecorator: this.getFieldDecorator,
      getFieldProps: this.getFieldProps,
      getFieldsError: this.fieldsStore.getFieldsError,
      getFieldError: this.fieldsStore.getFieldError,
      isFieldValidating: this.fieldsStore.isFieldValidating,
      isFieldsValidating: this.fieldsStore.isFieldsValidating,
      isFieldsTouched: this.fieldsStore.isFieldsTouched,
      isFieldTouched: this.fieldsStore.isFieldTouched,
      isSubmitting: this.isSubmitting,
      submit: this.submit,
      validateFields: this.validateFields,
      resetFields: this.resetFields,
    };
  },
};

function createForm(options) {
  return createBaseForm(options, [mixin]);
}

export default createForm;

这是我们在render中调用的createForm 可以看到mixin中的getForm里的属性和我们使用的很相似,其实这就是最终注入的props.form属性, 对外暴露的createForm方法最终调用了createBaseForm并将mixin传入。

createBaseForm.js

function createBaseForm(option = {}, mixins = []) {
  const {
    validateMessages,
    onFieldsChange,
    onValuesChange,
    mapProps = identity,
    mapPropsToFields,
    fieldNameProp,
    fieldMetaProp,
    fieldDataProp,
    formPropName = 'form',
    name: formName,
    // @deprecated
    withRef,
  } = option;

  return function decorate(WrappedComponent) {
    const Form = createReactClass({
      mixins,
      .......,
      render() {
        const { wrappedComponentRef, ...restProps } = this.props;
        const formProps = {
          [formPropName]: this.getForm(),
        };
        if (withRef) {
          if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
            warning(
              false,
              '`withRef` is deprecated, please use `wrappedComponentRef` instead. ' +
                'See: https://github.com/react-component/form#note-use-wrappedcomponentref-instead-of-withref-after-rc-form140'
            );
          }
          formProps.ref = 'wrappedComponent';
        } else if (wrappedComponentRef) {
          formProps.ref = wrappedComponentRef;
        }
        const props = mapProps.call(this, {
          ...formProps,
          ...restProps,
        });
        return <WrappedComponent {...props}/>;
      },
    });

    return argumentContainer(Form, WrappedComponent);
  };
}

可以看出createBaseForm是一个典型的高阶函数,接受options和mixin作为参数,返回一个装饰器decorate函数, 这个decorate函数接受一个react component作为参数,所以我们在外部调用可以使用

createForm()(Form);

这样去获得一个注入了props的组件, 接下来看render中的实现

        const formProps = {
          [formPropName]: this.getForm(),
        };
        return <WrappedComponent {...props}/>;

formPropName在defaultProps中默认被设置为'form', getForm是从mixin中注入的,
其实就相当于注入了

{
  form: {
      getFieldsValue: this.fieldsStore.getFieldsValue,
      getFieldValue: this.fieldsStore.getFieldValue,
      getFieldInstance: this.getFieldInstance,
      setFieldsValue: this.setFieldsValue,
      setFields: this.setFields,
      setFieldsInitialValue: this.fieldsStore.setFieldsInitialValue,
      getFieldDecorator: this.getFieldDecorator,
      getFieldProps: this.getFieldProps,
      getFieldsError: this.fieldsStore.getFieldsError,
      getFieldError: this.fieldsStore.getFieldError,
      isFieldValidating: this.fieldsStore.isFieldValidating,
      isFieldsValidating: this.fieldsStore.isFieldsValidating,
      isFieldsTouched: this.fieldsStore.isFieldsTouched,
      isFieldTouched: this.fieldsStore.isFieldTouched,
      isSubmitting: this.isSubmitting,
      submit: this.submit,
      validateFields: this.validateFields,
      resetFields: this.resetFields,
  }
}

看源码先从主流程看起, 知道了form是如何注入以后,我们就从示例入手, 先看看

<input {...getFieldProps('normal')}/>

中的getFieldProps是如何实现。

getFieldProps

getFieldProps(name, usersFieldOption = {}) {
        if (!name) {
          throw new Error('Must call `getFieldProps` with valid name string!');
        }
        if (process.env.NODE_ENV !== 'production') {
          warning(
            this.fieldsStore.isValidNestedFieldName(name),
            'One field name cannot be part of another, e.g. `a` and `a.b`.'
          );
          warning(
            !('exclusive' in usersFieldOption),
            '`option.exclusive` of `getFieldProps`|`getFieldDecorator` had been remove.'
          );
        }

        delete this.clearedFieldMetaCache[name];

        const fieldOption = {
          name,
          trigger: DEFAULT_TRIGGER,
          valuePropName: 'value',
          validate: [],
          ...usersFieldOption,
        };

        const {
          rules,
          trigger,
          validateTrigger = trigger,
          validate,
        } = fieldOption;

        const fieldMeta = this.fieldsStore.getFieldMeta(name);
        if ('initialValue' in fieldOption) {
          fieldMeta.initialValue = fieldOption.initialValue;
        }

        const inputProps = {
          ...this.fieldsStore.getFieldValuePropValue(fieldOption),
          ref: this.getCacheBind(name, `${name}__ref`, this.saveRef),
        };
        if (fieldNameProp) {
          inputProps[fieldNameProp] = formName ? `${formName}_${name}` : name;
        }

        const validateRules = normalizeValidateRules(validate, rules, validateTrigger);
        const validateTriggers = getValidateTriggers(validateRules);
        validateTriggers.forEach((action) => {
          if (inputProps[action]) return;
          inputProps[action] = this.getCacheBind(name, action, this.onCollectValidate);
        });

        // make sure that the value will be collect
        if (trigger && validateTriggers.indexOf(trigger) === -1) {
          inputProps[trigger] = this.getCacheBind(name, trigger, this.onCollect);
        }

        const meta = {
          ...fieldMeta,
          ...fieldOption,
          validate: validateRules,
        };
        this.fieldsStore.setFieldMeta(name, meta);
        if (fieldMetaProp) {
          inputProps[fieldMetaProp] = meta;
        }

        if (fieldDataProp) {
          inputProps[fieldDataProp] = this.fieldsStore.getField(name);
        }

        return inputProps;
      },

这个函数接受name,和usersFieldOption两个参数

const fieldOption = {
          name,
          trigger: DEFAULT_TRIGGER, // onChange
          valuePropName: 'value',
          validate: [],
          ...usersFieldOption,
};

 const fieldMeta = this.fieldsStore.getFieldMeta(name);
 if ('initialValue' in fieldOption) {
    fieldMeta.initialValue = fieldOption.initialValue;
 }

先是一波简单的合并配置, 将usersFieldOption混入fiedOption中,
然后从this.fieldsStore中根据name提取出fieldMeta, 将initialValue填入。
fieldsStore是一个存储类,form组件内部有大量的数据需要存储和读取,所以实现了一个fieldsStore类去处理数据的流转。

class FieldsStore {
  constructor(fields) {
    this.fields = this.flattenFields(fields);
    this.fieldsMeta = {};
  }
  ...
  getFieldMeta(name) {
    this.fieldsMeta[name] = this.fieldsMeta[name] || {};
    return this.fieldsMeta[name];
  }
}

因为初始化的this.fieldsStore应该是空的, 所以这里也只是读取到了一个空对象,继续往下走。

const inputProps = {
   ...this.fieldsStore.getFieldValuePropValue(fieldOption),
   ref: this.getCacheBind(name, `${name}__ref`, this.saveRef),
};

inputProps中先是通过fieldsStore实例的getFieldValuePropValue方法传入fieldOption拿到一些属性,
在初始化的时候其实就是{ value: undefined }

  getFieldValuePropValue(fieldMeta) {
    // 对应示例中 name: 'normal', valuePropName: 'value'
    const { name, getValueProps, valuePropName } = fieldMeta;
   // 得到 {  name: 'normal'  }, 初始化的时候fields还为空
    const field = this.getField(name);
   // field中没有value, 所以去取initialValue, 示例中未传入,同样为空 
    const fieldValue = 'value' in field ?
      field.value : fieldMeta.initialValue;
    if (getValueProps) {
      return getValueProps(fieldValue);
    }
   // 初始化的时候就返回 { value: undefined }
    return { [valuePropName]: fieldValue };
  }

  getField(name) {
    return {
      ...this.fields[name],
      name,
    };
  }

ref则是通过this.cacheBind的缓存方法去取缓存了的表单元素ref
此时inputProps = {
value: undefined,
ref: component,
}

接下来是处理有关表单验证的逻辑,

const validateRules = normalizeValidateRules(validate, rules, validateTrigger);
const validateTriggers = getValidateTriggers(validateRules);
validateTriggers.forEach((action) => {
   if (inputProps[action]) return;
   inputProps[action] = this.getCacheBind(name, action, this.onCollectValidate);
});

normalizeValidateRules方法接受的validate在示例未传入,是空数组,rules是 [{required: true}], validateTrigger是默认的onChange, 看normalizeValidateRules的实现:

export function normalizeValidateRules(validate, rules, validateTrigger) {
  const validateRules = validate.map((item) => {
    const newItem = {
      ...item,
      trigger: item.trigger || [],
    };
    if (typeof newItem.trigger === 'string') {
      newItem.trigger = [newItem.trigger];
    }
    return newItem;
  });
  if (rules) {
    validateRules.push({
      trigger: validateTrigger ? [].concat(validateTrigger) : [],
      rules,
    });
  }
  return validateRules;
}

我们发现其实返回了

validateRules: [{
  trigger: ['onChange'],
  rules: [{required: true}]
}]

在看getValidateTriggers 将上面的数组传入

export function getValidateTriggers(validateRules) {
  return validateRules
    .filter(item => !!item.rules && item.rules.length)
    .map(item => item.trigger)
    .reduce((pre, curr) => pre.concat(curr), []);
}

其实就是简单的把rules为空的项过滤掉, 因为每个rule的trigger可能有多个 所以reduce的目的是拉平成一维数组, 最后返回['onChange']这样的数组

  validateTriggers = ['onChange']

最后对validateTriggers进行循环,循环体内

if (inputProps[action]) return;
inputProps[action] = this.getCacheBind(name, action, this.onCollectValidate);

其实就是把onChange: onCollectValidate 这样的校验触发逻辑混入inputProps,关于表单校验的逻辑其实是用了heyiming大大写的async-validator这个库,使用非常广泛,有空的话也可以深入研究一下,可以另开一篇了~

接下来就是合并新的meta对象,并且存入fieldsStore中对应的name存储空间。

const meta = {
   ...fieldMeta, // 初始化不存在
   ...fieldOption, // 外部传入和内部默认合并后的options
   validate: validateRules, // 上文已经给出示例中结果
};
this.fieldsStore.setFieldMeta(name, meta);

setFieldMeta实现就是一个简单的赋值,这样fieldStore内部name这个key就可以读取到存储的数据了。

 setFieldMeta(name, meta) {
    this.fieldsMeta[name] = meta;
  }

最后返回inputProps对象 混入input组件,

 return inputProps;

//大概的格式是 
{
   name: 'required',
   onChange(){}, 
   rules: [{required: true}],
}

示例中的onSubmit函数调用了validateFields,
抛开表单校验的逻辑不看,可以看到这个方法内部这句。

if (callback) {
   callback(null, this.fieldsStore.getFieldsValue(fieldNames));
}

最终整合成{
key: value
}
这样的结果给外部做表单提交。

手把手教你用神器nextjs一键导出你的github博客文章生成静态html!

相信有不少小伙伴和我一样用github issues记录自己的blog,但是久而久之也发现了一些小问题,比如

  • 国内访问速度比较慢
  • 不能自定义主题样式等等
  • 不能在博客中加入自己想要的功能

正好最近又在学nextjs,react做ssr的神器,nextjs提供了next export这个命令,如果不熟悉next小伙伴可以先去官网阅读一下
https://nextjs.org/docs#static-html-export

nextjs的教程,推荐一下技术胖的免费视频教程
http://jspang.com/posts/2019/09/01/react-nextjs.html#p02%EF%BC%9Acreact-next-app%E5%BF%AB%E9%80%9F%E5%88%9B%E5%BB%BAnext-js%E9%A1%B9%E7%9B%AE

这个命令可以把react项目导出成静态html页面,这样在性能和seo方面考虑都是最优解。配合这个命令我就有了个折腾的想法,能不能把github issues导入到项目里,然后配合这个命令生成我的静态html博客呢。

目标

配合nextjs实现一个命令把自己的github issues里的文章导出成自己的博客html页面。
这样的好处是

  • 可以折腾
  • 可以折腾
  • 可以折腾

开玩笑的,真正的好处是

  • 编写博客时可以利用github完善的编辑器。
  • 可以把github issues作为自己的数据存储服务,不用担心数据丢失和维护。
  • 可以在自己的博客内加入自己想要的任何功能。
  • 可以利用react的完整能力,完善的第三方生态。
  • 生成的博客是html格式的页面,回归原始,回归本心,seo和性能最优化。

尝鲜使用

项目地址

https://github.com/sl1673495/next-blog 先clone到本地。

运行

安装依赖:

yarn

开发环境:

yarn dev

导出博客(会放在out目录下,导出后请进入out目录后启动anywhere或者http-server类似的静态服务然后访问):

yarn all

说明

只需要在config.js里改掉repo的owner和name两个字段,
分别对应你的github用户名和博客仓库名,
然后执行yarn all
就可以在out目录下生成静态博客目录。
config中填写client_id和client_secret可以用于取消请求限制。

(可选)使用now部署

进入out目录,然后执行now,页面就会自动部署了。

预览地址

对应的github博客: https://github.com/sl1673495/blogs/issues

自动生成的博客 http://blog.shanshihao.cn

可以先访问一下生成博客的效果,可以看到静态html页面的速度是非常快的,体验在某些方面可以说比起spa和ssr都要好。

代码解析

想要实现上面所说的功能,需要先把功能拆解一下。

  1. 发起请求拉取自己github仓库里的博客,获取文章存成md格式在本地。
  2. 根据nextjs的约定,把生成的md文章改写成jsx,写入到pages目录下。(这样nextjs就会识别成为一个个路由)
  3. 根据自定的规则生成首页jsx,写入pages文件夹。
  4. 使用next export导出博客。

首先先用next脚手架生成一个项目,然后在项目下建立builder文件夹,用来编写逻辑。

全局配置

全局的一些配置我放在了config.js中,拉取我项目的小伙伴只需要更改里面的配置,就可以一键生成你自己的静态博客了。

const path = require('path')

const mdDir = path.resolve(__dirname, './md')

module.exports = {
  mdDir,
  // 用于更改标题上的用户信息
  user: {
    name: 'ssh',
  },
  // 用于同步github的博客
  repo: {
    owner: 'sl1673495',
    name: 'blogs',
  },
  // 可选 如果申请了github Oauth app的话
  // 可以填写用于取消github请求限制
  client_id: '',
  client_secret: '',
}

repo字段中的信息决定了请求会去哪个仓库下拉取issues生成博客,user下的字段定义了首页显示的用户名,client_idclient_secret的作用后面会讲。

同步博客

builder/sync.js

/**
 * 同步github上的blogs
 */
const axios = require('axios')
const fs = require('fs')
const path = require('path')
const { rebuild } = require('./utils')
const {
  repo: { owner, name }, mdDir,
} = require('../config')

const GITHUB_BASE_URL = 'https://api.github.com'
module.exports = async () => {
  // 清空md文件夹
  rebuild(mdDir)

  try {
    // 请求github博客内容
    const { data: blogs } = await axios.get(
      `${GITHUB_BASE_URL}/repos/${owner}/${name}/issues`,
    )

    // 创建md文件
    blogs.forEach((blog) => {
      fs.writeFileSync(path.join(mdDir, `${blog.id}.md`), blog.body, 'utf8')
    })

    return blogs
  } catch (e) {
    console.error('仓库拉取失败,请检查您的用户名和仓库名')
    throw e
  }
}

其中rebuild函数就是用node的fs模块把文件夹删除再重新创建,

这个函数的作用就是把github仓库里的issues拉取下来,并且写入到我们自己定义的存放md的文件夹中。

把博客转为jsx写入pages目录

builder/page-builder.js

/**
 * 生成nextjs识别的pages
 */
const fs = require('fs')
const path = require('path')
const MarkdownIt = require('markdown-it')
const axios = require('axios')
const {
  mdDir,
} = require('../config')
const { rebuild, copyFolder } = require('./utils')

const md = new MarkdownIt({
  html: true,
  linkify: true,
})

const handleMarkdownBody = (body) => {
  return encodeURIComponent(md.render(body))
}

const pageTemplateDir = path.resolve(__dirname, '../pages-template')
const pageDir = path.join(__dirname, './pages')

module.exports = async (blogs) => {
  // 清空pages文件夹
  rebuild(pageDir)
  // 把pages-template目录的模板拷贝到pages下
  await copyFolder(pageTemplateDir, pageDir)

  // 读取md文件夹下的所有md文件的名字(其实就是issue的id)
  const mdPaths = fs.readdirSync(mdDir)
  const convertMdToJSX = async (mdPath) => {
    const mdContent = fs.readFileSync(path.join(mdDir, mdPath)).toString()
    // pages下的页面根据id命名
    const mdId = Number(mdPath.replace('.md', ''))
    const blog = blogs.find(({ id }) => id === mdId)

    if (blog) {
      // body已经在md文件夹内了 不需要了
      const { body, ...restBlog } = blog
      const { comments_url } = restBlog

      // 获取评论信息
      const { data: comments } = await axios.get(comments_url)
        .catch((err) => {
          console.error('评论生成失败,', err)
        })

      // 处理评论的markdown文本 并且写入到html字段中
      comments.forEach(({ body: commentBody }, index) => {
        const commentHtml = handleMarkdownBody(commentBody)
        comments[index].html = commentHtml
      })

      // 页面的jsx
      const pageContent = `
      import Page from '../components/Page'

      const pageProps = {
        blog: ${JSON.stringify(restBlog)},
        comments: ${JSON.stringify(comments)},
        html: \`${handleMarkdownBody(mdContent)}\`,
      }

      export default () => <Page {...pageProps}/>
    `
      // 写入文件
      fs.writeFileSync(path.join(pageDir, `${mdId}.jsx`), pageContent, 'utf8')
    }
  }

  const tasks = mdPaths.map(convertMdToJSX)
  await Promise.all(tasks)
}

这个函数需要接受我们刚刚请求到的issues数据,用来生成标题,因为在上一步中使用了issue的id去命名博客,所以可以在这一步中读取md文件夹下的所有issue id,就可以在这个blogs数组中找到对应的issue信息,这个issue对象中有github api给我们提供的comments_url,可以用来请求这个issue下的所有评论,这里也把它一起请求到。

  // 把pages-template目录的模板拷贝到pages下
  await copyFolder(pageTemplateDir, pageDir)

函数刚开始这一步的作用是因为每次执行这个函数都需要用rebuild函数清空pages文件夹,防止同步不同账号的数据以后产生数据混乱,但是nextjs中我们可能会自定义_document.js或者_app.js,这玩意也不需要动态生成,所以我们就先在pages-template文件夹下提前存放好这些组件,然后执行的时候直接拷贝过去就好了。
pages-template

convertMdToJSX这个方法就是把md文件转为nextjs可以识别的jsx格式,

`
      import Page from '../components/Page'

      const pageProps = {
        blog: ${JSON.stringify(restBlog)},
        comments: ${JSON.stringify(comments)},
        html: \`${handleMarkdownBody(mdContent)}\`,
      }

      export default () => <Page {...pageProps}/>
    `

其实就是这么个格式,注意写入的时候要用JSON格式化一下,否则写入的会是[Object object]这样的文字。

另外我们在这一步就要配合markdown-it插件把md内容转成html格式,并且通过encodeURIComponent转义后再写入我们的jsx内,否则会出现很多格式错误。

最后利用Promise.all把convertMdToJSX这个异步方法批量执行一下。

这一步结束后,我们的pages目录大概是这个样子
pages

点开其中的一个jsx

jsx

这已经是react可以渲染的jsx文件了,快要成功了~

生成首页

builder/page-builder.js

/**
 * 生成博客首页
 */
const fs = require('fs')
const path = require('path')

const indexPath = path.resolve(__dirname, '../pages/index.jsx')

module.exports = (blogs) => {
  const injectBlogs = JSON.stringify(
    blogs.map(({ body, ...restBlog }) => restBlog),
  )

  // 把blog数据注入到首页中
  const indexJsx = `
    import React from 'react'
    import Link from 'next/link'
    import Layout from '../components/Layout'
    import Main from '../components/Main'
    
    const blogs = ${injectBlogs}
    const Home = () => (
      <Layout>
        <Main blogs={blogs} />
      </Layout>
    )
    
    export default Home
  `
  fs.writeFileSync(indexPath, indexJsx, 'utf8')
}

这一步没啥好说的,一样的套路,写入jsx生成首页。

执行入口

最后我们在入口把这些方法串起来。

const { withOra, initAxios } = require('./utils')
const syncBlogs = require('./sync')
const pageBuilder = require('./page-builder')
const indexBuilder = require('./index-builder')

const start = async () => {
  initAxios()

  // 同步github上的blogs到md文件夹
  const blogs = await withOra(
    syncBlogs,
    '正在同步博客中...',
  )

  // 抓取评论,生成pages下的博客页面。
  await withOra(
    () => pageBuilder(blogs),
    '正在生成博客页面中...',
  )

  // 生成首页
  indexBuilder(blogs)
}
start()

initAxios这个函数目的是在请求的时候可以带上github的client_idclient_secret信息,如果你在github申请了OAuth app就会拿到俩个东西,带上的话就可以更频繁的请求api,否则github会限制同一个ip下请求调用的次数。

function initAxios() {
  axios.default.interceptors.request.use((axiosConfig) => {
    if (client_id) {
      if (!axiosConfig.params) {
        axiosConfig.params = {}
      }
      axiosConfig.params.client_id = client_id
      axiosConfig.params.client_secret = client_secret
    }
    return axiosConfig
  })
}

在本项目中,client_idclient_secret定义在了配置文件config.js中。

ora是一个命令行提示加载中的插件,可以让我们在异步生成这些内容的时候得到更友好的提示,withOra就是封装了一层,在传入函数的调用前后去启动、暂停ora的提示。

async function withOra(fn, tip = 'loading...') {
  const spinner = ora(tip).start();

  try {
    const result = await fn()
    spinner.stop()
    return result
  } catch (error) {
    spinner.stop()
    throw error
  }
}

然后在package.json中写入自定义script

"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "export": "next export",
    "sync": "node builder/index.js",
    "all": "npm run sync && npm run build && npm run export"
},

这样,npm run sync命令可以执行上面编写的builder逻辑,拉取github blogs生成pages,可以方便调试。

npm run all命令则是在sync命令调用后再去执行npm run buildnpm run export,让nextjs去生成out文件夹下的静态html页面,这样就大功告成了。

本地调试

最终pages

到了这一步,npm run dev后就可以开始调试你的博客了,注意生成的jsx都是尽量把内容最小化,把动态变化的内容都放到组件中去渲染,比如生成的page jsx里的Page组件,定义在components/Page.jsx中,在里面可以根据你的喜好去利用react任意发挥,并且调试支持热更新,可以说是非常友好了。

components目录组件:

components目录
Header.jsx: 对应首页中头部的部分。
Layout.jsx:首页、博文详情页的布局组件,包含了Header.jsx
Main.jsx:首页。
Markdown.jsx:渲染markdown html文本的组件,本项目中利用了react-highlight库去高亮显示代码。
Page.jsx:博客详情页,评论区也是在里面实现的。

生成html

本地开发完成后,执行npm run all,(或者不需要再同步博客的情况执行npm run build + npm run export),就会在out目录下看到静态html页面了。

out
里面的内容是这样的:
html
把out目录部署到服务器上,就可以通过
http://blog.shanshihao.cn/474922327 这样的路径去访问博客内容了。

到此我们就完成了手动生成自己的静态博客,nodejs真的是很强大,nextjs也是ssr的神器,在这里也推荐一下jocky老师的nextjs课程 https://coding.imooc.com/class/334.html ,我在这个课程中也学习到了非常多的东西。

Vue3 + TypeScript + 新型状态管理模式,手把手带你实现小型应用。

前言

Vue3的热度还没过去,React Hook在社区的发展也是如火如荼。

一时间大家都觉得Redux很low,都在研究各种各样配合hook实现的新形状态管理模式。

在React社区中,Context + useReducer的新型状态管理模式广受好评。

这篇文章就从Vue3的角度出发,探索一下未来的Vue状态管理模式。

vue-composition-api-rfc:
https://vue-composition-api-rfc.netlify.com/api.html

vue官方提供的尝鲜库:
https://github.com/vuejs/composition-api

api

Vue3中有一对新增的api,provideinject,熟悉Vue2的朋友应该明白,

在上层组件通过provide提供一些变量,在子组件中可以通过inject来拿到,但是必须在组件的对象里面声明,使用场景的也很少,所以之前我也并没有往状态管理的方向去想。

但是Vue3中新增了Hook,而Hook的特征之一就是可以在组件外去写一些自定义Hook,所以我们不光可以在.vue组件内部使用Vue的能力,
在任意的文件下(如context.ts)下也可以,

如果我们在context.ts中

  1. 自定义并export一个hook叫useProvide,并且在这个hook中使用provide并且注册一些全局状态,

  2. 再自定义并export一个hook叫useInject,并且在这个hook中使用inject返回刚刚provide的全局状态,

  3. 然后在根组件的setup函数中调用useProvide

  4. 就可以在任意的子组件去共享这些全局状态了。

顺着这个思路,先看一下这两个api的介绍,然后一起慢慢探索这对api。

import { provide, inject } from 'vue'

const ThemeSymbol = Symbol()

const Ancestor = {
  setup() {
    provide(ThemeSymbol, 'dark')
  }
}

const Descendent = {
  setup() {
    const theme = inject(ThemeSymbol, 'light' /* optional default value */)
    return {
      theme
    }
  }
}

开始

项目介绍

这个项目是一个简单的图书管理应用,功能很简单:

  1. 查看图书
  2. 增加已阅图书
  3. 删除已阅图书

项目搭建

首先使用vue-cli搭建一个项目,在选择依赖的时候手动选择,这个项目中我使用了TypeScript,各位小伙伴可以按需选择。

然后引入官方提供的vue-composition-api库,并且在main.ts里注册。

import VueCompositionApi from '@vue/composition-api';
Vue.use(VueCompositionApi);

context编写

按照刚刚的思路,我建立了src/context/books.ts

import { provide, inject, computed, ref, Ref } from '@vue/composition-api';
import { Book, Books } from '@/types';

type BookContext = {
  books: Ref<Books>;
  setBooks: (value: Books) => void;
};

const BookSymbol = Symbol();

export const useBookListProvide = () => {
  // 全部图书
  const books = ref<Books>([]);
  const setBooks = (value: Books) => (books.value = value);

  provide(BookSymbol, {
    books,
    setBooks,
  });
};

export const useBookListInject = () => {
  const booksContext = inject<BookContext>(BookSymbol);

  if (!booksContext) {
    throw new Error(`useBookListInject must be used after useBookListProvide`);
  }

  return booksContext;
};

全局状态肯定不止一个模块,所以在context/index.ts下做统一的导出

import { useBookListProvide, useBookListInject } from './books';

export { useBookListInject };

export const useProvider = () => {
  useBookListProvide();
};

后续如果增加模块的话,就按照这个套路就好。

然后在main.ts的根组件里使用provide,在最上层的组件中注入全局状态。

new Vue({
  router,
  setup() {
    useProvider();
    return {};
  },
  render: h => h(App),
}).$mount('#app');

在组件view/books.vue中使用:

<template>
  <Books :books="books" :loading="loading" />
</template>

<script lang="ts">
import { createComponent } from '@vue/composition-api';
import Books from '@/components/Books.vue';
import { useAsync } from '@/hooks';
import { getBooks } from '@/hacks/fetch';
import { useBookListInject } from '@/context';

export default createComponent({
  name: 'books',
  setup() {
    const { books, setBooks } = useBookListInject();

    const loading = useAsync(async () => {
      const requestBooks = await getBooks();
      setBooks(requestBooks);
    });

    return { books, loading };
  },
  components: {
    Books,
  },
});
</script>

这个页面需要初始化books的数据,并且从inject中拿到setBooks的方法并调用,之后这份books数据就可以供所有组件使用了。

在setup里引入了一个useAsync函数,我编写它的目的是为了管理异步方法前后的loading状态,看一下它的实现。

import { ref, onMounted } from '@vue/composition-api';

export const useAsync = (func: () => Promise<any>) => {
  const loading = ref(false);

  onMounted(async () => {
    try {
      loading.value = true;
      await func();
    } catch (error) {
      throw error;
    } finally {
      loading.value = false;
    }
  });

  return loading;
};

可以看出,这个hook的作用就是把外部传入的异步方法funconMounted生命周期里调用
并且在调用的前后改变响应式变量loading的值,并且把loading返回出去,这样loading就可以在模板中自由使用,从而让loading这个变量和页面的渲染关联起来。

Vue3的hooks让我们可以在组件外部调用Vue的所有能力,
包括onMounted,ref, reactive等等,

这使得自定义hook可以做非常多的事情,
并且在组件的setup函数把多个自定义hook组合起来完成逻辑,

这恐怕也是起名叫composition-api的初衷。

最终的books模块context

import { provide, inject, computed, ref, Ref } from '@vue/composition-api';
import { Book, Books } from '@/types';

type BookContext = {
  books: Ref<Books>;
  setBooks: (value: Books) => void;
  finishedBooks: Ref<Books>;
  addFinishedBooks: (book: Book) => void;
  booksAvailable: Ref<Books>;
};

const BookSymbol = Symbol();

export const useBookListProvide = () => {
  // 待完成图书
  const books = ref<Books>([]);
  const setBooks = (value: Books) => (books.value = value);

  // 已完成图书
  const finishedBooks = ref<Books>([]);
  const addFinishedBooks = (book: Book) => {
    if (!finishedBooks.value.find(({ id }) => id === book.id)) {
      finishedBooks.value.push(book);
    }
  };
  const removeFinishedBooks = (book: Book) => {
    const removeIndex = finishedBooks.value.findIndex(({ id }) => id === book.id);
    if (removeIndex !== -1) {
      finishedBooks.value.splice(removeIndex, 1);
    }
  };

  // 可选图书
  const booksAvailable = computed(() => {
    return books.value.filter(book => !finishedBooks.value.find(({ id }) => id === book.id));
  });

  provide(BookSymbol, {
    books,
    setBooks,
    finishedBooks,
    addFinishedBooks,
    removeFinishedBooks,
    booksAvailable,
  });
};

export const useBookListInject = () => {
  const booksContext = inject<BookContext>(BookSymbol);

  if (!booksContext) {
    throw new Error(`useBookListInject must be used after useBookListProvide`);
  }

  return booksContext;
};

最终的books模块就是这个样子了,可以看到在hooks的模式下,

代码不再按照state, mutation和actions区分,而是按照逻辑关注点分隔,

这样的好处显而易见,我们想要维护某一个功能的时候更加方便的能找到所有相关的逻辑,而不再是在选项和文件之间跳来跳去。

总结

本文相关的所有代码都放在

https://github.com/sl1673495/vue-bookshelf

这个仓库里了,感兴趣的同学可以去看,

在之前刚看到composition-api,还有尤大对于Vue3的Hook和React的Hook的区别对比的时候,我对于Vue3的Hook甚至有了一些盲目的崇拜,但是真正使用下来发现,虽然不需要我们再去手动管理依赖项,但是由于Vue的响应式机制始终需要非原始的数据类型来保持响应式,所带来的一些心智负担也是需要注意和适应的。

举个简单的例子

  setup() {
    const loading = useAsync(async () => {
      await getBooks();
    });

    return {
      isLoading: !!loading.value
    }
  },

这一段看似符合直觉的代码,却会让isLoading这个变量失去响应式,但是这也是性能和内部实现设计的一些取舍,我们选择了Vue,也需要去学习和习惯它。

总体来说,Vue3虽然也有一些自己的缺点,但是带给我们React Hook几乎所有的好处,而且还规避了React Hook的一些让人难以理解坑,在某些方面还优于它,期待Vue3正式版的发布!

英文技术文章阅读。

https://medium.com/@martin_hotell/10-typescript-pro-tips-patterns-with-or-without-react-5799488d6680
React + TypeScript 10个需要避免的错误模式。

https://medium.com/scrum-ai/4-testing-koa-server-with-jest-week-5-8e980cd30527
单元测试TypeScript + Koa的实践

https://kentcdodds.com/blog/profile-a-react-app-for-performance
React使用DevTools分析性能的一些注意事项

https://kentcdodds.com/blog/optimize-react-re-renders
React中优化组件重渲染,这里有几个隐含的知识点。

  1. React组件每次createElement,会生成一份新的props引用。
  2. 如果React在re-render中发现一个组件的type和props都保持了相同的引用,就会跳过这个组件的重渲染。
    这篇文章中提到的具体的优化策略是把
function Counter() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      <Logger label="counter" />
    </div>
  )
}

改成

function Counter(props) {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      {props.logger}
    </div>
  )
}

然后把Logger组件的创建提到外层,而不要放在setCount会影响到的作用域下,这样logger组件就不会重新渲染了。

https://kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks
React Hooks的自定义hook中,如何利用reducer的模式提供更加灵活的数据管理,让用户拥有数据的控制权。

https://mariusschulz.com/blog/const-assertions-in-literal-expressions-in-typescript
TypeScript中的const常量声明和let变量声明的类型区别,以及as const的应用。

https://github.com/piotrwitek/react-redux-typescript-guide#react---type-definitions-cheatsheet
React-Redux + TypeScript 的备忘录。

https://github.com/typescript-cheatsheets/react-typescript-cheatsheet
React + TypeScript 进阶用法备忘录。

https://blog.echobind.com/integrating-prettier-eslint-airbnb-style-guide-in-vscode-47f07b5d7d6a
在项目中集成Prettier + ESLint + Airbnb Style Guide

https://levelup.gitconnected.com/setting-up-eslint-with-prettier-typescript-and-visual-studio-code-d113bbec9857
在项目中集成ESLint with Prettier, TypeScript

Vue项目的热更新怎么辣么好用啊?原来200行代码就搞定(深度解析)

大家都用过Vue-CLI创建vue应用,在开发的时候我们修改了vue文件,保存了文件,浏览器上就自动更新出我们写的组件内容,非常的顺滑流畅,大大提高了开发效率。想知道这背后是怎么实现的吗,其实代码并不复杂。

这个功能的实现底层用了vue-hot-load-api这个库,得益于vue的良好设计,热更新的实现总共就一个js文件,200行代码,绰绰有余。

而在这个库里涉及到的技巧又非常适合我们去深入了解vue内部的一些机制,所以赶快来和我一起学习吧。

提要

本文单纯的从vue-hot-load-api这个库出发,在浏览器的环境运行Vue的热更新示例,主要测试的组件是普通的vue组件而不是functional等特殊组件,以最简单的流程搞懂热更新的原理。
在源码解析中贴出的代码会省略掉一些不太相关的流程,更便于理解。

示例

学习一个库当然还是先从示例看起,github页面上的示例结合了webpack的一些机制,有点偏离本文的重点,所以我简化了一个例子,先给大家饱饱眼福,使用起来就是这么简单。

import api from 'vue-hot-reload-api'
import Vue from 'vue'

// 初始化
api.install(Vue, true)

// 建立一个vue组件
const appOptions = {
  render: h => h('div', 'foo')
}

// 建立一个id -> vue组件映射
// my-app是这个组件的唯一id
api.createRecord('my-app', appOptions)

new Vue(appOptions).$mount('#app')

// 2秒后热更新这个组件
setTimeout(() => {
  api.rerender('my-app', {
    render: h => h('div', 'bar')
  })
}, 2000);

解析

从github仓库示例入手

进入了这个github仓库以后,最先开始看的肯定是Readme的里的示例,在看示例的时候作者给出的注释就非常重要了,他会标注出每一个重要的环节。并且我们要结合自己的一些经验排除掉和这个库无关的代码。(在这个示例中,webpack的相关代码就可以先不去过多关注)

第一步需要调用install方法,传入Vue构造函数,根据注释来看,这一步是要知道这个库与Vue版本之间是否兼容。

  // make the API aware of the Vue that you are using.
  // also checks compatibility.
  api.install(Vue)

接下来的这段注释告诉我们,每个需要热更新的组件选项对象,我们都需要为它建立一个独一无二的id,并且这段代码需要在初始化的时候完成。

 if (初始化) {
    // for each component option object to be hot-reloaded,
    // you need to create a record for it with a unique id.
    // do this once on startup.
    api.createRecord('very-unique-id', myComponentOptions)
  }

最后就是激动人心的热更新时间了,
根据注释来看,这个库的使用分为两种情况。

  • rerender 只有template或者render函改变的情况下使用。
  • reload 如果template或者render为改变,则这个函数需要调用reload方法先销毁然后重新创建(包括它的子组件)。
    // if a component has only its template or render function changed,
    // you can force a re-render for all its active instances without
    // destroying/re-creating them. This keeps all current app state intact.
    api.rerender('very-unique-id', myComponentOptions)

    // --- OR ---

    // if a component has non-template/render options changed,
    // it needs to be fully reloaded. This will destroy and re-create all its
    // active instances (and their children).
    api.reload('very-unique-id', myComponentOptions)

从这个简单的示例里面可以看出,这个库的核心流程就是:

  1. api.install 检测兼容性。
  2. api.createRecord 为组件对象通过一个独一无二的id建立一个记录。
  3. api.rerender api.reload 进行组件的热更新。

什么,Readme的示例到此就结束了?这个very-unique-id到底是个什么东西,myComponentOptions又是什么样的。

因为这个仓库可能并不是面向广大开发者的,所以它的文档写的非常的简略。其实看完了这个简短的示例,大家肯定还是一脸懵逼的。

在看一个你没有熟练使用的库的源码的时候,其实还有一个很关键的步骤,那就是看测试用例。

探索测试用例

测试用例

上面我们总结出两个关键api rerenderreload 之后,就带着目的性的去看测试用例。

const Vue = require('vue')
const api = require('../src')

// 初始化
api.install(Vue)

// 这个方法接受id和组件选项对象,
// 通过createRecord去记录组件
// 然后返回一个vue组件实例。
function prepare (id, Comp) {
  api.createRecord(id, Comp)
  return new Vue({
    render: h => h(Comp)
  })
}

rerender用例

const id0 = 'rerender: mounted'
test(id0, done => {
  
  // 用'rerender: mounted'作为这个组件对象的id,
  // 这个组件的内容应该是 <div>foo</div>
  // 调用$mount生成dom节点
  const app = prepare(id0, {
    render: h => h('div', 'foo')
  }).$mount()
  
  // $el就是组件生成的dom元素,期望textContent文字内容为foo
  expect(app.$el.textContent).toBe('foo')

  // rerender 后dom节点变成 <div>bar</div>
  api.rerender(id0, {
    render: h => h('div', 'bar')
  })
  
  // 通过nextTick保证dom节点已经更新
  // 期望textContent文字内容为bar
  Vue.nextTick(() => {
    expect(app.$el.textContent).toBe('bar')
    done()
  })
})

reload用例

const id1 = 'reload: mounted'
test(id1, done => {
  // 通过一个count来计数
  let count = 0
  
  // app组件会在created的时候让count + 1
  // destroyed的时候让count - 1
  const app = prepare(id1, {
    created () {
      count++
    },
    destroyed () {
      count--
    },
    data: () => ({ msg: 'foo' }),
    render (h) {
      return h('div', this.msg)
    }
  }).$mount()
  // 确保内容正确
  expect(app.$el.textContent).toBe('foo')
  // 确保created周期执行 此时的count是1
  expect(count).toBe(1)

  // 调用created 传入新组件的created时 count会-1
  api.reload(id1, {
    created () {
      count--
    },
    data: () => ({ msg: 'bar' }),
    render (h) {
      return h('div', this.msg)
    }
  })
  
  Vue.nextTick(() => {
    // 确保内容正确
    expect(app.$el.textContent).toBe('bar')
    // 在reload之前 count是1
    // 调用reload之后 会先调用前一个组件的destory生命周期 此时count是0
    // 接下来调用新组建的created生命周期 此时count是-1
    expect(count).toBe(-1)
    done()
  })
})

具体流程已经在注释里分析了,果然和示例代码的注释里写的一样,而且现在我们也更清楚这个api到底该怎么用了。

总结一个最简单的可用demo

import api from 'vue-hot-reload-api'
import Vue from 'vue'

// 初始化
api.install(Vue, true)

const appOptions = {
  render: h => h('div', 'foo')
}

api.createRecord('my-app', appOptions)

new Vue(appOptions).$mount('#app')

setTimeout(() => {
  api.rerender('my-app', {
    render: h => h('div', 'bar')
  })
}, 2000);

这个demo(源码)是直接在浏览器可用的,效果如下:
效果

源码分析

源码地址

全局变量

进入js文件的入口,首先定义了一些变量

// Vue构造函数
let Vue // late bind
// Vue版本
let version
// createRecord方法保存id -> 组件映射关系的对象
const map = Object.create(null)
if (typeof window !== 'undefined') {
  // 将map对象存储在window上
  window.__VUE_HOT_MAP__ = map
}
// 是否已经安装过
let installed = false
// 这个变量暂时没用
let isBrowserify = false
// 初始化生命周期的名字 默认是Vue的beforeCreate生命周期
let initHookName = 'beforeCreate'

其实看到window对象的出现,我们就已经可以确定这个api可以在浏览器端调用。

install

exports.install = function (vue, browserify) {
  // 如果安装过了就不再重复安装
  if (installed) { return }
  installed = true
  
  // 兼容es modules模块
  Vue = vue.__esModule ? vue.default : vue
  // 把vue的版本如2.6.3分隔成[2, 6, 3] 这样的数组
  version = Vue.version.split('.').map(Number)
  isBrowserify = browserify

  // compat with < 2.0.0-alpha.7
  // 兼容2.0.0-alpha.7以下版本
  if (Vue.config._lifecycleHooks.indexOf('init') > -1) {
    initHookName = 'init'
  }

  // 只有Vue在2.0以上的版本才支持这个库。
  exports.compatible = version[0] >= 2
  if (!exports.compatible) {
    console.warn(
      '[HMR] You are using a version of vue-hot-reload-api that is ' +
        'only compatible with Vue.js core ^2.0.0.'
    )
    return
  }
}

可以看出install方法很简单,就是帮你看一下Vue的版本是否在2.0以上,确认一下兼容性,关于初始化生命周期,在这篇文章里我们就不考虑2.0.0-alpha.7以下版本,可以认为这个库的初始化工作就是在beforeCreate这个生命周期进行。

createRecord

/**
 * Create a record for a hot module, which keeps track of its constructor
 * and instances
 *
 * @param {String} id
 * @param {Object} options
 */

exports.createRecord = function (id, options) {
  // 如果已经存储过了就return
  if(map[id]) { return }

  // 关键流程 下一步解析
  makeOptionsHot(id, options)
  
  // 将记录存储在map中
  // instances变量应该不难猜出是vue的实例对象。
  map[id] = {
    options: options,
    instances: []
  }
}

这一步在把id和对应的options对象存进map后,就没做啥了,关键步骤肯定在于makeOptionsHot这个方法。

/**
 * Make a Component options object hot.
 * 让一个组件对象变得性感...哦不,是支持热更新。
 *
 * @param {String} id
 * @param {Object} options
 */

function makeOptionsHot(id, options) {
    // options 就是我们传入的组件对象
    // initHookName 就是'beforeCreate'
    injectHook(options, initHookName, function() {
      // 在这个函数内部
      // this已经是vue的实例对象了
      // 想象一下平时写vue在生命周期里调用this
      const record = map[id]
      if (!record.Ctor) {
        // 把组件的构造函数赋值给record的Ctor属性。
        record.Ctor = this.constructor
      }
      // 在instances里存储这个实例。
      record.instances.push(this)
    })
    // 在组件销毁的时候把上面存储的instance删除掉。
    injectHook(options, 'beforeDestroy', function() {
      const instances = map[id].instances
      instances.splice(instances.indexOf(this), 1)
    })
}

// 往生命周期里注入某个方法
function injectHook(options, name, hook) {
  const existing = options[name]
  options[name] = existing
    ? Array.isArray(existing) ? existing.concat(hook) : [existing, hook]
    : [hook]
}

看完了这几个函数以后,我们对createRecord应该有个清晰的认识了。
比如上面我们的例子中这段代码

const appOptions = {
  render: h => h('div', 'foo')
}

api.createRecord('my-app', appOptions)
  1. 在map中创建一个记录,这个记录有options字段也就是上面传入的组件对象,还有instances用于记录活动组件的实例,Ctor用来记录组件的构造函数。
// map
{
    my-app: {
        options: appOptions,
        instances: [],
        Ctor: null
    }
}
  1. 在appOptions中,混入生命周期方法beforeCreate,在组件的这个生命周期中,把组件自身的示例push到map里对应instances数组中,并且记录自己的构造函数在Ctor字段上。
    beforeCreate执行完了以后的map对象长这样。
    map

其中Ctor我们暂时也不需要去具体关心,因为正常情况下的组件的构造函数都是Vue函数。

接下来进入关键的rerender函数。

rerender

exports.rerender = (id, options) => {
  const record = map[id]
  if (!options) {
    // 如果没传第二个参数 就把所有实例调用$forceUpdate
    record.instances.slice().forEach(instance => {
      instance.$forceUpdate()
    })
    return
  }
  record.instances.slice().forEach(instance => {
    // 将实例上的$options上的render直接替换为新传入的render函数
    instance.$options.render = options.render
    // 执行$forceUpdate更新视图
    instance.$forceUpdate()
  })
}

其实这个原函数很长,但是简化以后核心的更改视图的方法就是这些,平常我们在写vue单文件组件的时候都会像下面这样写:

<template>
    <span>{{ msg }}</span>
</template>

<script>
export default {
  data() {
      return {
          msg: 'Hello World'
      }
  }  
}
</script>

这样的.vue文件,会被vue-loader编译成单个的组件选项对象,template中的部分会被编译成render函数挂到组件上,最终生成的对象是类似于:

export default {
  data() {
      return {
          msg: 'Hello World'
      }
  },
  render(h) {
      return h('span', this.msg)
  }
}

而在运行时,组件实例(也就是生命周期或者methods中访问的this对象)会通过$option保存render这个函数,而通过上面的源码我们不难猜出vue在渲染组件的时候也是通过调用$option.render去实现的。我们可以去vue的源码里验证一下我们的猜想。

_render

而在$forceUpdate的时候,vue内部会重新调用_render这个方法去生成vnode,然后patch到界面上,在此之前rerender把$options.render给替换成新的render方法了,这个时候再调用$forceUpdate,不就渲染新传入的render了吗?这个运行时的偷天换日我不得不佩服~

reload

reload的讲解我们基于这样一个示例:
一开始会显示foo的文本,一秒以后会显示成bar。

function prepare(id, Comp) {
  api.createRecord(id, Comp)
  return new Vue({
    render: h => h(Comp)
  })
}

const id1 = 'reload: mounted'
const app = prepare(id1, {
  data: () => ({ msg: 'foo' }),
  render(h) {
    return h('div', this.msg)
  }
}).$mount('#app')

// reload
setTimeout(() => {
  api.reload(id1, {
    data: () => ({ msg: 'bar' }),
    render(h) {
      return h('div', this.msg)
    }
  })
}, 1000)

reload的情况会更加复杂,涉及到很多Vue内部的运行原理,这里只能简单的描述一下。

exports.reload = function(id, options) {
  const record = map[id]
  if (options) {
    // reload的情况下 传入的options会当做一个新的组件
    // 所以要用makeOptionsHot重新做一下记录
    makeOptionsHot(id, options)
    const newCtor = record.Ctor.super.extend(options)
    
    newCtor.options._Ctor = record.options._Ctor
    record.Ctor.options = newCtor.options
    record.Ctor.cid = newCtor.cid
    record.Ctor.prototype = newCtor.prototype
  }
  record.instances.slice().forEach(function(instance) {
    instance.$vnode.context.$forceUpdate()
  })
}

这段代码关键的点开始于

const newCtor = record.Ctor.super.extend(options)

利用新传入的配置生成了一个新的组件构造函数
然后对record上的Ctor进行了一系列的赋值

 newCtor.options._Ctor = record.options._Ctor
 record.Ctor.options = newCtor.options
 record.Ctor.cid = newCtor.cid
 record.Ctor.prototype = newCtor.prototype

注意第一次调用reload时,这里的record.Ctor还是最初传入的Ctor,是由

const app = prepare(id1, {
  data: () => ({ msg: 'foo' }),
  render(h) {
    return h('div', this.msg)
  }
}).$mount('#app')

这个配置对象所生成的构造函数,但是构造函数的options、cid和prototype被替换成了由

api.reload(id1, {
    data: () => ({ msg: 'bar' }),
    render(h) {
      return h('div', this.msg)
    }
})

这个配置对象所生成的构造函数上的options、cid和prototype,此时的cid肯定是不同的。

也就是说,构造函数的cid变了!,这个点记住后面要考!

继续看源码

  record.instances.slice().forEach(function(instance) {
    instance.$vnode.context.$forceUpdate()
  })

此时的instance只有一个,就是在reload之前运行的那个msg为foo的实例,它的$vnode.context是什么呢?
context
直接在放上控制台打印出来的截图,这个context是一个vue实例,注意这个options里的render函数,是不是非常眼熟,没错,这个vue实例其实就是我们的prepare函数中

new Vue({
  render: h => h(Comp)
})

返回的vm实例。

那么这个函数的$forceUpdate必然会触发 render: h => h(Comp) 这个函数,看到此时我们似乎还是没看出来这些操作为什么会销毁旧组件,创建新组件。那么此时只能探究一下这个h到底做了什么,这个h就是对应着 $createElement方法。

$createElement方法

$createElement在创建vnode的时候,最底层会调用一个createComponent方法,

这个方法把Comp对象当做Ctor,然后调用Vue.extend这个api创造出构造函数,

默认情况下第一次h(Comp) 会生成类似于vue-component-${cid}作为组件的tag,

在本例中最开始渲染msg为foo的组件时,tag为vue-component-1,

并且会把这个构造函数缓存在_Ctor这个变量上,这样下次渲染再执行到createComponent的时候就不需要重新生成一次构造函数了,

Vue在选择更新策略时调用一个sameVnode方法

来决定是要进行打补丁,还是彻底销毁重建,这个sameVnode如下:

function sameVnode (a, b) {
  return (
   // 省略其他...
    a.tag === b.tag
  )
}

其中很关键的一个对比就是a.tag === b.tag

但是reload方法偷梁换柱把Ctor的cid换成了2,

生成的vnode的tag是就vue-component-2

后续再调用context.$forceUpdate的时候,会发现两个组件的tag不一样,所以就走了销毁 -> 重新创建的流程。

总结

这个库里面还是能看出很多尤大的编程风格,很适合进行学习,只是reload方法必须要深入了解Vue源码才有可能搞懂生效的原理。

rerender这个方法相对来说还是比较好理解的,但是reload方法是怎么生效的就非常让人难以理解了,我一步一步断点调试了大概六七个小时,才渐渐得出结论,只能说好用的api后面潜藏着作者用心良苦的设计啊!想要彻底深入的理解vue的原理,强烈推荐黄轶老师的这门课程:

Vue.js源码全方位深入解析 (含Vue3.0源码分析)

TypeScript 参数简化实战(进阶知识点conditional types)

TypeScript中有一项相当重要的进阶特性:conditional types,这个功能出现以后,很多积压已久的TypeScript功能都可以轻而易举的实现了。

那么本篇文章就会通过一个简单的功能:把

distribute({
    type: 'LOGIN',
    email: string
})

这样的函数调用方式给简化为:

distribute('LOGIN', {
    email: string
})

没错,它只是节省了几个字符串,但是却是一个非常适合我们深入学习条件类型的实战。

通过这篇文章,你可以学到以下特性在实战中是如何使用的:

  1. 🎉TypeScript的高级类型(Advanced Type
  2. 🎉Conditional Types (条件类型)
  3. 🎉Distributive conditional types (分布条件类型)
  4. 🎉Mapped types(映射类型)
  5. 🎉函数重载

conditional types的第一次使用

先简单的看一个条件类型的示例:

function process<T extends string | null>(
  text: T
): T extends string ? string : null {
  ...
}
A extends B ? C : D

这样的语法就叫做条件类型,A, B, CD可以是任何类型表达式。

可分配性

这个extends关键字是条件类型的核心。 A extends B恰好意味着可以将类型A的任何值安全地分配给类型B的变量。在类型系统术语中,我们可以说“ A可分配给B”。

从结构上来讲,我们可以说A extends B,就像“ A是B的超集”,或者更确切地说,“ A具有B的所有特性,也许更多”。

举个例子来说 { foo: number, bar: string } extends { foo: number }是成立的,因为前者显然是后者的超集,比后者拥有更具体的类型。

分布条件类型

官方文档中,介绍了一种操作,叫 Distributive conditional types

简单来说,传入给T extends U中的T如果是一个联合类型A | B | C,则这个表达式会被展开成

(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

条件类型让你可以过滤联合类型的特定成员。 为了说明这一点,假设我们有一个称为Animal的联合类型:

type Animal = Lion | Zebra | Tiger | Shark

再假设我们要编写一个类型,来过滤出Animal中属于“猫”的那些类型

type ExtractCat<A> = A extends { meow(): void } ? A : never

type Cat = ExtractCat<Animal>
// => Lion | Tiger

接下来,Cat的计算过程会是这样子的:

type Cat =
  | ExtractCat<Lion>
  | ExtractCat<Zebra>
  | ExtractCat<Tiger>
  | ExtractCat<Shark>

然后,它被计算成联合类型

type Cat = Lion | never | Tiger | never

然后,联合类型中的never没什么意义,所以最后的结果的出来了:

type Cat = Lion | Tiger

记住这样的计算过程,记住ts这个把联合类型如何分配给条件类型,接下来的实战中会很有用。

分布条件类型的真实用例

举一个类似redux中的dispatch的例子。

首先,我们有一个联合类型Action,用来表示所有可以被dispatch接受的参数类型:

type Action =
  | {
      type: "INIT"
    }
  | {
      type: "SYNC"
    }
  | {
      type: "LOG_IN"
      emailAddress: string
    }
  | {
      type: "LOG_IN_SUCCESS"
      accessToken: string
    }

然后我们定义这个dispatch方法:

declare function dispatch(action: Action): void

// ok
dispatch({
  type: "INIT"
})

// ok
dispatch({
  type: "LOG_IN",
  emailAddress: "[email protected]"
})

// ok
dispatch({
  type: "LOG_IN_SUCCESS",
  accessToken: "038fh239h923908h"
})

这个API是类型安全的,当TS识别到type为LOG_IN的时候,它会要求你在参数中传入emailAddress这个参数,这样才能完全满足联合类型中的其中一项。

到此为止,我们可以去和女朋友约会了,此文完结。

等等,我们好像可以让这个api变得更简单一点:

dispatch("LOG_IN_SUCCESS", {
  accessToken: "038fh239h923908h"
})

好,推掉我们的约会,打电话给我们的女朋友!取消!

参数简化实现

首先,利用方括号选择出Action中的所有type,这个技巧很有用。

type ActionType = Action["type"]
// => "INIT" | "SYNC" | "LOG_IN" | "LOG_IN_SUCCESS"

但是第二个参数的类型取决于第一个参数。 我们可以使用类型变量来对该依赖关系建模。

declare function dispatch<T extends ActionType>(
  type: T,
  args: ExtractActionParameters<Action, T>
): void

注意,这里就用到了extends语法,规定了我们的入参type必须是ActionType中一部分。

注意这里的第二个参数args,用ExtractActionParameters<Action, T>这个类型来把type和args做了关联,

来看看ExtractActionParameters是如何实现的:

type ExtractActionParameters<A, T> = A extends { type: T } ? A : never

在这次实战中,我们第一次运用到了条件类型,ExtractActionParameters<Action, T>会按照我们上文提到的分布条件类型,把Action中的4项依次去和{ type: T }进行比对,找出符合的那一项。

来看看如何使用它:

type Test = ExtractActionParameters<Action, "LOG_IN">
// => { type: "LOG_IN", emailAddress: string }

这样就筛选出了type匹配的一项。

接下来我们要把type去掉,第一个参数已经是type了,因此我们不想再额外声明type了。

// 把类型中key为"type"去掉
type ExcludeTypeField<A> = { [K in Exclude<keyof A, "type">]: A[K] }

这里利用了keyof语法,并且利用内置类型Excludetype这个key去掉,因此只会留下额外的参数。

type Test = ExcludeTypeField<{ type: "LOG_IN", emailAddress: string }>
// { emailAddress: string }

到此为止,我们就可以实现上文中提到的参数简化功能:

// ok
dispatch({
  type: "LOG_IN",
  emailAddress: "[email protected]"
})

利用重载进一步优化

到了这一步为止,虽然带参数的Action可以完美支持了,但是对于"INIT"这种不需要传参的Action,我们依然要写下面这样代码:

dispatch("INIT", {})

这肯定是不能接受的!所以我们要利用TypeScript的函数重载功能。

// 简单参数类型
function dispatch<T extends SimpleActionType>(type: T): void

// 复杂参数类型
function dispatch<T extends ComplexActionType>(
  type: T,
  args: ExtractActionParameters<Action, T>,
): void

// 实现
function dispatch(arg: any, payload?: any) {}

那么关键点就在于SimpleActionTypeComplexActionType要如何实现了,

SimpleActionType顾名思义就是除了type以外不需要额外参数的Action类型,

type SimpleAction = ExtractSimpleAction<Action>

我们如何定义这个ExtractSimpleAction条件类型?

如果我们从这个Action中删除type字段,并且结果是一个空的接口,

那么这就是一个SimpleAction。 所以我们可能会凭直觉写出这样的代码:

type ExtractSimpleAction<A> = ExcludeTypeField<A> extends {} ? A : never

但这样是行不通的,几乎所有的类型都可以extends {},因为{}太宽泛了。

我们应该反过来写:

type ExtractSimpleAction<A> = {} extends ExcludeTypeField<A> ? A : never

现在,如果ExcludeTypeField <A>为空,则extends表达式为true,否则为false。

但这仍然行不通! 因为分布条件类型仅在extends关键字左侧是类型变量时发生。

分布条件件类型仅发生在如下场景:

type Blah<Var> = Var extends Whatever ? A : B

而不是:

type Blah<Var> = Foo<Var> extends Whatever ? A : B
type Blah<Var> = Whatever extends Var ? A : B

但是我们可以通过一些小技巧绕过这个限制:

type ExtractSimpleAction<A> = A extends any
  ? {} extends ExcludeTypeField<A>
    ? A
    : never
  : never

A extends any是一定成立的,这只是用来绕过ts对于分布条件类型的限制,而我们真正想要做的条件判断被放在了中间,因此Action联合类型中的每一项又能够分布的去匹配了。

那么我们就可以简单的筛选出所有不需要额外参数的type

type SimpleAction = ExtractSimpleAction<Action>
type SimpleActionType = SimpleAction['type']

再利用Exclude取反,找到复杂类型:

type ComplexActionType = Exclude<ActionType, SimpleActionType>

到此为止,我们所需要的功能就完美实现了:

// 简单参数类型
function dispatch<T extends SimpleActionType>(type: T): void
// 复杂参数类型
function dispatch<T extends ComplexActionType>(
  type: T,
  args: ExtractActionParameters<Action, T>,
): void
// 实现
function dispatch(arg: any, payload?: any) {}

// ok
dispatch("SYNC")

// ok
dispatch({
  type: "LOG_IN",
  emailAddress: "[email protected]"
})

总结

本文的实战示例来自国外大佬的博客,我结合个人的理解整理成了这篇文章。

中间涉及到的一些进阶的知识点,如果小伙伴们不太熟悉的话,可以参考各类文档中的定义去反复研究,相信你会对TypeScript有更深一步的了解。

参考资料

https://artsy.github.io/blog/2018/11/21/conditional-types-in-typescript/

源码

https://github.com/sl1673495/typescript-codes/blob/master/src/dispatch-conditional-types-with-builtin-types.ts

babel7的配置与优化。

网上关于babel7的文章很多,但是大多都没有实践,很多讲的模棱两可。
本文将手把手的带你看各种配置下的输入输出转换,彻底让你了解babel7到底该怎么去配置和优化。

首先我们知道进入了babel7的时代,stage-0这种已经作为不推荐使用的present了,最流行的应该是@babel/present-env 顾名思义让babel拥有根据你的环境来编译不同代码的需求。

targets

我们先配置最基础的.babelrc配置

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58",
          "ie": "10"
        }
      },
    ]
  ],
}

targets配置的意思就是让babel根据你写入的兼容平台来做代码转换,这里我们指定ie10为我们要兼容的最低版本,来看下面es6代码的输出。

输入: src/main.js

const a = () => {}

输出: dist/main.js

var a = function a() {};

这里因为ie10是不支持es6语法的,所以代码被全部转换,如果我们把ie10这条去掉,因为高版本的chrome是支持es6大部分语法的,所以代码就不会被做任何转换了。

browserlist 这里是具体的可配置列表,可以根据你自己项目的兼容性要求来配置。

useBuiltIns

首先我们来看一行简单的代码

a.includes(1);

includes作为数组的实例方法,在某些浏览器其实是不支持的,babel默认的转换对于这种场景并不会做处理,同样不会处理的包括WeakMap, WeakSet, Promise等es6新引入的类,所以我们需要babel-polyfill为我们这些实例方法等等打上补丁。

在很多项目中我们会看到项目的main.js入口顶部require了babel-polyfill包, 或者指定webpack的entry为数组,第一项引入babel-polyfill包,这样的确没问题而且很保险,但是很多场景下我们可能只是使用了少量需要polyfill的api,这个时候全量引入这个包就显得很不划算,babel给我们提供了很好的解决方案,那就是useBuiltIns 这个配置,下面来看实例。

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",
        "targets": {
          "chrome": "58",
          "ie": "10"
        }
      },
    ]
  ],
}

输入: src/main.js

a.includes(1)
Promise.reject()

输出: dist/main.js

require("core-js/modules/es6.promise");

require("core-js/modules/es7.array.includes");

require("core-js/modules/es6.string.includes");

a.includes(1);
Promise.reject();

babel帮我们做好了代码分析,在需要用到polyfill的地方再帮你引入这个单独的补丁,这样就实现了按需引入~

@babel/plugin-transform-runtime

这个插件是帮我们把一些babel的辅助方法由直接写入代码专为按需引入模块的方式引用,
我们先来看不使用这个插件时候,我们对于es6 class的转换。

输入: src/main.js

class A {}

输出: dist/main.js

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var A = function A() {
  _classCallCheck(this, A);
};

看似没问题,转换的很好,但是如果在很多模块都用了class语法的情况下呢?辅助函数_classCallCheck就会被重复写入多次,占用无意义的空间。
解决方法就是引入@babel/plugin-transform-runtime
.babelrc

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",
        "targets": {
          "chrome": "58",
          "ie": "10"
        }
      },
    ]
  ],
  "plugins": [
    "@babel/plugin-transform-runtime",
  ]
}

输入: src/main.js

class A {}

输出: dist/main.js

var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));

var A = function A() {
  (0, _classCallCheck2.default)(this, A);
};

这样就解决了辅助函数重复写入的问题了。

总结

babel7的版本下,利用present-env做按需转换,利用useBuiltIn做babel-polyfill的按需引入,利用transform-runtime做babel辅助函数的按需引入。

React Hook + TypeScript 深入浅出实现一个购物车(陷阱、性能优化、自定义hook)

前言

本文由一个基础的购物车需求展开,一步一步带你深入理解React Hook中的坑和优化

通过本篇文章你可以学到:

✨React Hook + TypeScript编写业务组件的实践

✨如何利用React.memo优化性能

✨如何避免Hook带来的闭包陷阱

✨如何抽象出简单好用的自定义hook

预览地址

https://sl1673495.github.io/react-cart

代码仓库

本文涉及到的代码已经整理到github仓库中,用cra搭建了一个示例工程,关于性能优化的部分可以打开控制台查看重渲染的情况。

https://github.com/sl1673495/react-cart

需求分解

作为一个购物车需求,那么它必然涉及到几个需求点:

  1. 勾选、全选与反选。
  2. 根据选中项计算总价。

gif1

需求实现

获取数据

首先我们请求到购物车数据,这里并不是本文的重点,可以通过自定义请求hook实现,也可以通过普通的useState + useEffect实现。

const getCart = () => {
  return axios('/api/cart')
}
const { 
  // 购物车数据
  cartData,
  // 重新请求数据的方法
  refresh 
} = useRequest<CartResponse>(getCart)

勾选逻辑实现

我们考虑用一个对象作为映射表,通过checkedMap这个变量来记录所有被勾选的商品id:

type CheckedMap = {
  [id: number]: boolean
}
// 商品勾选
const [checkedMap, setCheckedMap] = useState<CheckedMap>({})
const onCheckedChange: OnCheckedChange = (cartItem, checked) => {
  const { id } = cartItem
  const newCheckedMap = Object.assign({}, checkedMap, {
    [id]: checked,
  })
  setCheckedMap(newCheckedMap)
}

计算勾选总价

再用reduce来实现一个计算价格总和的函数

  // cartItems的积分总和
 const sumPrice = (cartItems: CartItem[]) => {
    return cartItems.reduce((sum, cur) => sum + cur.price, 0)
 }

那么此时就需要一个过滤出所有选中商品的函数

// 返回已选中的所有cartItems
const filterChecked = () => {
  return (
    Object.entries(checkedMap)
      // 通过这个filter 筛选出所有checked状态为true的项
      .filter(entries => Boolean(entries[1]))
      // 再从cartData中根据id来map出选中列表
      .map(([checkedId]) => cartData.find(({ id }) => id === Number(checkedId)))
  )
}

最后把这俩函数一组合,价格就出来了:

  // 计算礼享积分
  const calcPrice = () => {
    return sumPrice(filterChecked())
  }

有人可能疑惑,为什么一个简单的逻辑要抽出这么几个函数,这里我要解释一下,为了保证文章的易读性,我把真实需求做了简化。

在真实需求中,可能会对不同类型的商品分别做总价计算,因此filterChecked这个函数就不可或缺了,filterChecked可以传入一个额外的过滤参数,去返回勾选中的商品的子集,这里就不再赘述。

全选反选逻辑

有了filterChecked函数以后,我们也可以轻松的计算出派生状态checkedAll,是否全选:

// 全选
const checkedAll = cartData.length !== 0 && filterChecked().length === cartData.length

写出全选和反全选的函数:

const onCheckedAllChange = newCheckedAll => {
  // 构造新的勾选map
  let newCheckedMap: CheckedMap = {}
  // 全选
  if (newCheckedAll) {
    cartData.forEach(cartItem => {
      newCheckedMap[cartItem.id] = true
    })
  }
  // 取消全选的话 直接把map赋值为空对象
  setCheckedMap(newCheckedMap)
}

如果是

  • 全选 就把checkedMap的每一个商品id都赋值为true。
  • 反选 就把checkedMap赋值为空对象。

渲染商品子组件

{cartData.map(cartItem => {
  const { id } = cartItem
  const checked = checkedMap[id]
  return (
      <ItemCard
        key={id}
        cartItem={cartItem}
        checked={checked}
        onCheckedChange={onCheckedChange}
      />
  )
})}

可以看出,是否勾选的逻辑就这样轻松的传给了子组件。

React.memo性能优化

到了这一步,基本的购物车需求已经实现了。

但是现在我们有了新的问题。

这是React的一个缺陷,默认情况下几乎没有任何性能优化。

我们来看一下动图演示:

gif2

购物车此时有5个商品,看控制台的打印,每次都是以5为倍数增长每点击一次checkbox,都会触发所有子组件的重新渲染。

如果我们有50个商品在购物车中,我们改了其中某一项的checked状态,也会导致50个子组件重新渲染。

我们想到了一个api: React.memo,这个api基本等效于class组件中的shouldComponentUpdate,如果我们用这个api让子组件只有在checked发生改变的时候再重新渲染呢?

好,我们进入子组件的编写:

// memo优化策略
function areEqual(prevProps: Props, nextProps: Props) {
  return (
    prevProps.checked === nextProps.checked
  )
}

const ItemCard: FC<Props> = React.memo(props => {
  const { checked, onCheckedChange } = props
  return (
    <div>
      <checkbox 
        value={checked} 
        onChange={(value) => onCheckedChange(cartItem, value)} 
      />
      <span>商品</span>
    </div>
  )
}, areEqual)

在这种优化策略下,我们认为只要前后两次渲染传入的props中的checked相等,那么就不去重新渲染子组件。

React Hook的陈旧值导致的bug

到这里就完成了吗?其实,这里是有bug的。

我们来看一下bug还原:

gif3

如果我们先点击了第一个商品的勾选,再点击第二个商品的勾选,你会发现第一个商品的勾选状态没了。

在勾选了第一个商品后,我们此时的最新的checkedMap其实是

{ 1: true }

而由于我们的优化策略,第二个商品在第一个商品勾选后没有重新渲染,

注意React的函数式组件,在每次渲染的时候都会重新执行,从而产生一个闭包环境。

所以第二个商品拿到的onCheckedChange还是前一次渲染购物车这个组件的函数闭包中的,那么checkedMap自然也是上一次函数闭包中的最初的空对象。

  const onCheckedChange: OnCheckedChange = (cartItem, checked) => {
    const { id } = cartItem
    // 注意,这里的checkedMap还是最初的空对象!!
    const newCheckedMap = Object.assign({}, checkedMap, {
      [id]: checked,
    })
    setCheckedMap(newCheckedMap)
  }

因此,第二个商品勾选后,没有按照预期的计算出正确的checkedMap

{ 
  1: true, 
  2: true
} 

而是计算出了错误的

{ 2: true }

这就导致了第一个商品的勾选状态被丢掉了。

这也是React Hook的闭包带来的臭名昭著陈旧值的问题。

那么此时有一个简单的解决方案,在父组件中用React.useRef把函数通过一个引用来传递给子组件。

由于ref在React组件的整个生命周期中只存在一个引用,因此通过current永远是可以访问到引用中最新的函数值的,不会存在闭包陈旧值的问题。

  // 要把ref传给子组件 这样才能保证子组件能在不重新渲染的情况下拿到最新的函数引用
  const onCheckedChangeRef = React.useRef(onCheckedChange)
  // 注意要在每次渲染后把ref中的引用指向当次渲染中最新的函数。
  useEffect(() => {
    onCheckedChangeRef.current = onCheckedChange
  })
  
  return (
    <ItemCard
      key={id}
      cartItem={cartItem}
      checked={checked}
+     onCheckedChangeRef={onCheckedChangeRef}
    />
  )

子组件

// memo优化策略
function areEqual(prevProps: Props, nextProps: Props) {
  return (
    prevProps.checked === nextProps.checked
  )
}

const ItemCard: FC<Props> = React.memo(props => {
  const { checked, onCheckedChangeRef } = props
  return (
    <div>
      <checkbox 
        value={checked} 
        onChange={(value) => onCheckedChangeRef.current(cartItem, value)} 
      />
      <span>商品</span>
    </div>
  )
}, areEqual)

到此时,我们的简单的性能优化就完成了。

自定义hook之useChecked

那么下一个场景,又遇到这种全选反选类似的需求,难道我们再这样重复写一套吗?这是不可接受的,我们用自定义hook来抽象这些数据以及行为。

并且这次我们通过useReducer来避免闭包旧值的陷阱(dispatch在组件的生命周期中保持唯一引用,并且总是能操作到最新的值)。

import { useReducer, useEffect, useCallback } from 'react'

interface Option {
  /** 用来在map中记录勾选状态的key 一般取id */
  key?: string;
}

type CheckedMap = {
  [key: string]: boolean;
}

const CHECKED_CHANGE = 'CHECKED_CHANGE'

const CHECKED_ALL_CHANGE = 'CHECKED_ALL_CHANGE'

const SET_CHECKED_MAP = 'SET_CHECKED_MAP'

type CheckedChange<T> = {
  type: typeof CHECKED_CHANGE;
  payload: {
    dataItem: T;
    checked: boolean;
  };
}

type CheckedAllChange = {
  type: typeof CHECKED_ALL_CHANGE;
  payload: boolean;
}

type SetCheckedMap = {
  type: typeof SET_CHECKED_MAP;
  payload: CheckedMap;
}

type Action<T> = CheckedChange<T> | CheckedAllChange | SetCheckedMap
export type OnCheckedChange<T> = (item: T, checked: boolean) => any

/**
 * 提供勾选、全选、反选等功能
 * 提供筛选勾选中的数据的函数
 * 在数据更新的时候自动剔除陈旧项
 */
export const useChecked = <T extends Record<string, any>>(
  dataSource: T[],
  { key = 'id' }: Option = {}
) => {
  const [checkedMap, dispatch] = useReducer(
    (checkedMapParam: CheckedMap, action: Action<T>) => {
      switch (action.type) {
        case CHECKED_CHANGE: {
          const { payload } = action
          const { dataItem, checked } = payload
          const { [key]: id } = dataItem
          return {
            ...checkedMapParam,
            [id]: checked,
          }
        }
        case CHECKED_ALL_CHANGE: {
          const { payload: newCheckedAll } = action
          const newCheckedMap: CheckedMap = {}
          // 全选
          if (newCheckedAll) {
            dataSource.forEach(dataItem => {
              newCheckedMap[dataItem.id] = true
            })
          }
          return newCheckedMap
        }
        case SET_CHECKED_MAP: {
          return action.payload
        }
        default:
          return checkedMapParam
      }
    },
    {}
  )

  /** 勾选状态变更 */
  const onCheckedChange: OnCheckedChange<T> = useCallback(
    (dataItem, checked) => {
      dispatch({
        type: CHECKED_CHANGE,
        payload: {
          dataItem,
          checked,
        },
      })
    },
    []
  )

  type FilterCheckedFunc = (item: T) => boolean
  /** 筛选出勾选项 可以传入filter函数继续筛选 */
  const filterChecked = useCallback(
    (func: FilterCheckedFunc = () => true) => {
      return (
        Object.entries(checkedMap)
          .filter(entries => Boolean(entries[1]))
          .map(([checkedId]) =>
            dataSource.find(({ [key]: id }) => id === Number(checkedId))
          )
          // 有可能勾选了以后直接删除 此时id虽然在checkedMap里 但是dataSource里已经没有这个数据了
          // 先把空项过滤掉 保证外部传入的func拿到的不为undefined
          .filter(Boolean)
          .filter(func)
      )
    },
    [checkedMap, dataSource, key]
  )
  /** 是否全选状态 */
  const checkedAll =
    dataSource.length !== 0 && filterChecked().length === dataSource.length

  /** 全选反选函数 */
  const onCheckedAllChange = (newCheckedAll: boolean) => {
    dispatch({
      type: CHECKED_ALL_CHANGE,
      payload: newCheckedAll,
    })
  }

  // 数据更新的时候 如果勾选中的数据已经不在数据内了 就删除掉
  useEffect(() => {
    filterChecked().forEach(checkedItem => {
      let changed = false
      if (!dataSource.find(dataItem => checkedItem.id === dataItem.id)) {
        delete checkedMap[checkedItem.id]
        changed = true
      }
      if (changed) {
        dispatch({
          type: SET_CHECKED_MAP,
          payload: Object.assign({}, checkedMap),
        })
      }
    })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dataSource])

  return {
    checkedMap,
    dispatch,
    onCheckedChange,
    filterChecked,
    onCheckedAllChange,
    checkedAll,
  }
}

这时候在组件内使用,就很简单了:

const {
  checkedAll,
  checkedMap,
  onCheckedAllChange,
  onCheckedChange,
  filterChecked,
} = useChecked(cartData)

我们在自定义hook里把复杂的业务逻辑全部做掉了,包括数据更新后的无效id剔除等等。快去推广给团队的小伙伴,让他们早点下班吧。

自定义hook之useMap

有一天,突然又来了个需求,我们需要用一个map来根据购物车商品的id来记录另外的一些东西,我们突然发现,上面的自定义hook把map的处理等等逻辑也都打包进去了,我们只能给map的值设为true / false,灵活性不够。

我们进一步把useMap也抽出来,然后让useCheckedMap基于它之上开发。

useMap

import { useReducer, useEffect, useCallback } from 'react'

export interface Option {
  /** 用来在map中作为key 一般取id */
  key?: string;
}

export type MapType = {
  [key: string]: any;
}

export const CHANGE = 'CHANGE'

export const CHANGE_ALL = 'CHANGE_ALL'

export const SET_MAP = 'SET_MAP'

export type Change<T> = {
  type: typeof CHANGE;
  payload: {
    dataItem: T;
    value: any;
  };
}

export type ChangeAll = {
  type: typeof CHANGE_ALL;
  payload: any;
}

export type SetCheckedMap = {
  type: typeof SET_MAP;
  payload: MapType;
}

export type Action<T> = Change<T> | ChangeAll | SetCheckedMap
export type OnValueChange<T> = (item: T, value: any) => any

/**
 * 提供map操作的功能
 * 在数据更新的时候自动剔除陈旧项
 */
export const useMap = <T extends Record<string, any>>(
  dataSource: T[],
  { key = 'id' }: Option = {}
) => {
  const [map, dispatch] = useReducer(
    (checkedMapParam: MapType, action: Action<T>) => {
      switch (action.type) {
        // 单值改变
        case CHANGE: {
          const { payload } = action
          const { dataItem, value } = payload
          const { [key]: id } = dataItem
          return {
            ...checkedMapParam,
            [id]: value,
          }
        }
        // 所有值改变
        case CHANGE_ALL: {
          const { payload } = action
          const newMap: MapType = {}
          dataSource.forEach(dataItem => {
            newMap[dataItem[key]] = payload
          })
          return newMap
        }
        // 完全替换map
        case SET_MAP: {
          return action.payload
        }
        default:
          return checkedMapParam
      }
    },
    {}
  )

  /** map某项的值变更 */
  const onMapValueChange: OnValueChange<T> = useCallback((dataItem, value) => {
    dispatch({
      type: CHANGE,
      payload: {
        dataItem,
        value,
      },
    })
  }, [])

  // 数据更新的时候 如果map中的数据已经不在dataSource内了 就删除掉
  useEffect(() => {
    dataSource.forEach(checkedItem => {
      let changed = false
      if (
        // map中包含此项
        // 并且数据源中找不到此项了
        checkedItem[key] in map &&
        !dataSource.find(dataItem => checkedItem[key] === dataItem[key])
      ) {
        delete map[checkedItem[key]]
        changed = true
      }
      if (changed) {
        dispatch({
          type: SET_MAP,
          payload: Object.assign({}, map),
        })
      }
    })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dataSource])

  return {
    map,
    dispatch,
    onMapValueChange,
  }
}

这是一个通用的map操作的自定义hook,它考虑了闭包陷阱,考虑了旧值的删除。

在此之上,我们实现上面的useChecked

useChecked

import { useCallback } from 'react'
import { useMap, CHANGE_ALL, Option } from './use-map'

type CheckedMap = {
  [key: string]: boolean;
}

export type OnCheckedChange<T> = (item: T, checked: boolean) => any

/**
 * 提供勾选、全选、反选等功能
 * 提供筛选勾选中的数据的函数
 * 在数据更新的时候自动剔除陈旧项
 */
export const useChecked = <T extends Record<string, any>>(
  dataSource: T[],
  option: Option = {}
) => {
  const { map: checkedMap, onMapValueChange, dispatch } = useMap(
    dataSource,
    option
  )
  const { key = 'id' } = option

  /** 勾选状态变更 */
  const onCheckedChange: OnCheckedChange<T> = useCallback(
    (dataItem, checked) => {
      onMapValueChange(dataItem, checked)
    },
    [onMapValueChange]
  )

  type FilterCheckedFunc = (item: T) => boolean
  /** 筛选出勾选项 可以传入filter函数继续筛选 */
  const filterChecked = useCallback(
    (func?: FilterCheckedFunc) => {
      const checkedDataSource = dataSource.filter(item =>
        Boolean(checkedMap[item[key]])
      )
      return func ? checkedDataSource.filter(func) : checkedDataSource
    },
    [checkedMap, dataSource, key]
  )
  /** 是否全选状态 */
  const checkedAll =
    dataSource.length !== 0 && filterChecked().length === dataSource.length

  /** 全选反选函数 */
  const onCheckedAllChange = (newCheckedAll: boolean) => {
    // 全选
    const payload = !!newCheckedAll
    dispatch({
      type: CHANGE_ALL,
      payload,
    })
  }

  return {
    checkedMap: checkedMap as CheckedMap,
    dispatch,
    onCheckedChange,
    filterChecked,
    onCheckedAllChange,
    checkedAll,
  }
}

总结

本文通过一个真实的购物车需求,一步一步的完成优化、踩坑,在这个过程中,我们对React Hook的优缺点一定也有了进一步的认识。

在利用自定义hook把通用逻辑抽取出来后,我们业务组件内的代码量大大的减少了,并且其他相似的场景都可以去复用。

React Hook带来了一种新的开发模式,但是也带来了一些陷阱,它是一把双刃剑,如果你能合理使用,那么它会给你带来很强大的力量。

感谢你的阅读,希望这篇文章可以给你启发。

Vue3 的响应式和以前有什么区别,Proxy 无敌?

前言

大家都知道,Vue2 里的响应式其实有点像是一个半完全体,对于对象上新增的属性无能为力,对于数组则需要拦截它的原型方法来实现响应式。

举个例子:

let vm = new Vue({
  data() {
    return {
        a: 1
    }
  }
})

// ❌  oops,没反应!
vm.b = 2 
let vm = new Vue({
  data() {
    return {
        a: 1
    }
  },
  watch: {
    b() {
      console.log('change !!')
    }
  }
})

// ❌  oops,没反应!
vm.b = 2

这种时候,Vue 提供了一个 api:this.$set,来使得新增的属性也拥有响应式的效果。

但是对于很多新手来说,很多时候需要小心翼翼的去判断到底什么情况下需要用 $set,什么时候可以直接触发响应式。

总之,在 Vue3 中,这些都将成为过去。本篇文章会带你仔细讲解,proxy 到底会给 Vue3 带来怎么样的便利。并且会从源码级别,告诉你这些都是如何实现的。

响应式仓库

Vue3 不同于 Vue2 也体现在源码结构上,Vue3 把耦合性比较低的包分散在 packages 目录下单独发布成 npm 包。 这也是目前很流行的一种大型项目管理方式 Monorepo

其中负责响应式部分的仓库就是 @vue/rectivity,它不涉及 Vue 的其他的任何部分,是非常非常 「正交」 的一种实现方式。

甚至可以轻松的集成进 React

这也使得本篇的分析可以更加聚焦的分析这一个仓库,排除其他无关部分。

区别

Proxy 和 Object.defineProperty 的使用方法看似很相似,其实 Proxy 是在 「更高维度」 上去拦截属性的修改的,怎么理解呢?

Vue2 中,对于给定的 data,如 { count: 1 },是需要根据具体的 key 也就是 count,去对「修改 data.count 」 和 「读取 data.count」进行拦截,也就是

Object.defineProperty(data, 'count', {
  get() {},
  set() {},
})

必须预先知道要拦截的 key 是什么,这也就是为什么 Vue2 里对于对象上的新增属性无能为力。

而 Vue3 所使用的 Proxy,则是这样拦截的:

new Proxy(data, {
  get(key) { },
  set(key, value) { },
})

可以看到,根本不需要关心具体的 key,它去拦截的是 「修改 data 上的任意 key」 和 「读取 data 上的任意 key」。

所以,不管是已有的 key 还是新增的 key,都逃不过它的魔爪。

但是 Proxy 更加强大的地方还在于 Proxy 除了 get 和 set,还可以拦截更多的操作符。

简单的例子🌰

先写一个 Vue3 响应式的最小案例,本文的相关案例都只会用 reactiveeffect 这两个 api。如果你了解过 React 中的 useEffect,相信你会对这个概念秒懂,Vue3 的 effect 不过就是去掉了手动声明依赖的「进化版」的 useEffect

React 中手动声明 [data.count] 这个依赖的步骤被 Vue3 内部直接做掉了,在 effect 函数内部读取到 data.count 的时候,它就已经被收集作为依赖了。

Vue3:

// 响应式数据
const data = reactive({ 
  count: 1
})

// 观测变化
effect(() => console.log('count changed', data.count))

// 触发 console.log('count changed', data.count) 重新执行
data.count = 2

React:

// 数据
const [data, setData] = useState({
  count: 1
})

// 观测变化 需要手动声明依赖
useEffect(() => {
  console.log('count changed', data.count)
}, [data.count])

// 触发 console.log('count changed', data.count) 重新执行
setData(({
  count: 2
}))

其实看到这个案例,聪明的你也可以把 effect 中的回调函数联想到视图的重新渲染、 watch 的回调函数等等…… 它们是同样基于这套响应式机制的。

而本文的核心目的,就是探究这个基于 Proxy 的 reactive api,到底能强大到什么程度,能监听到用户对于什么程度的修改。

先讲讲原理

先最小化的讲解一下响应式的原理,其实就是在 Proxy 第二个参数 handler 也就是陷阱操作符中,拦截各种取值、赋值操作,依托 tracktrigger 两个函数进行依赖收集和派发更新。

track 用来在读取时收集依赖。

trigger 用来在更新时触发依赖。

track

function track(target: object, type: TrackOpTypes, key: unknown) {
  const depsMap = targetMap.get(target);
  // 收集依赖时 通过 key 建立一个 set
  let dep = new Set()
  targetMap.set(ITERATE_KEY, dep)
  // 这个 effect 可以先理解为更新函数 存放在 dep 里
  dep.add(effect)    
}

target 是原对象。

type 是本次收集的类型,也就是收集依赖的时候用来标识是什么类型的操作,比如上文依赖中的类型就是 get,这个后续会详细讲解。

key 是指本次访问的是数据中的哪个 key,比如上文例子中收集依赖的 key 就是 count

首先全局会存在一个 targetMap,它用来建立 数据 -> 依赖 的映射,它是一个 WeakMap 数据结构。

targetMap 通过数据 target,可以获取到 depsMap,它用来存放这个数据对应的所有响应式依赖。

depsMap 的每一项则是一个 Set 数据结构,而这个 Set 就存放着对应 key 的更新函数。

是不是有点绕?我们用一个具体的例子来举例吧。

const target = { count: 1}
const data = reactive(target)

const effection = effect(() => {
  console.log(data.count)
})

对于这个例子的依赖关系,

  1. 全局的 targetMap 是:
targetMap: {
  { count: 1 }: dep    
}
  1. dep 则是
dep: {
  count: Set { effection }
}

这样一层层的下去,就可以通过 target 找到 count 对应的更新函数 effection 了。

trigger

这里是最小化的实现,仅仅为了便于理解原理,实际上要复杂很多,

其实 type 的作用很关键,先记住,后面会详细讲。

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
) {
  // 简化来说 就是通过 key 找到所有更新函数 依次执行
  const dep = targetMap.get(target)
  dep.get(key).forEach(effect => effect())
}

新增属性

这个上文已经讲了,由于 Proxy 完全不关心具体的 key,所以没问题。

// 响应式数据
const data = reactive({ 
  count: 1
})

// 观测变化
effect(() => console.log('newCount changed', data.newCount))

// ✅ 触发响应
data.newCount = 2

数组新增索引:

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => console.log('data[1] changed', data[1]))

// ✅ 触发响应
data[1] = 5

数组调用原生方法:

const data = reactive([])
effect(() => console.log('c', data[1]))

// 没反应
data.push(1)

// ✅ 触发响应 因为修改了下标为 1 的值
data.push(2)

其实这一个案例就比较有意思了,我们仅仅是在调用 push,但是等到数组的第二项被 push的时候,我们之前关注 data[1] 为依赖的回调函数也执行了,这是什么原理呢?写个简单的 Proxy 就知道了。

const raw = []
const arr = new Proxy(raw, {
  get(target, key) {
    console.log('get', key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', key)
    return Reflect.set(target, key, value)
  }
})

arr.push(1)

在这个案例中,我们只是打印出了对于 raw 这个数组上的所有 get、set 操作,并且调用 Reflect 这个 api 原样处理取值和赋值操作后返回。看看 arr.push(1) 后控制台打印出了什么?

get push
get length
set 0
set length

原来一个小小的 push,会触发两对 get 和 set,我们来想象一下流程:

  1. 读取 push 方法
  2. 读取 arr 原有的 length 属性
  3. 对于数组第 0 项赋值
  4. 对于 length 属性赋值

这里的重点是第三步,对于第 index 项的赋值,那么下次再 push,可以想象也就是对于第 1 项触发 set 操作。

而我们在例子中读取 data[1],是一定会把对于 1 这个下标的依赖收集起来的,这也就清楚的解释了为什么 push 的时候也能精准的触发响应式依赖的执行。

对了,记住这个对于 length 的 set 操作,后面也会用到,很重要。

遍历后新增

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => console.log('data map +1', data.map(item => item + 1))

// ✅ 触发响应 打印出 [2]
data.push(1)

这个拦截很神奇,但是也很合理,转化成现实里的一个例子来看,

假设我们要根据学生 id 的集合 ids, 去请求学生详细信息,那么仅仅是需要这样写即可:

const state = reactive({})
const ids = reactive([1])

effect(async () => {
  state.students = await axios.get('students/batch', ids.map(id => ({ id })))
})

// ✅ 触发响应 
ids.push(2)

这样,每次调用各种 api 改变 ids 数组,都会重新发送请求获取最新的学生列表。

如果我在监听函数中调用了 map、forEach 等 api,

说明我关心这个数组的长度变化,那么 push 的时候触发响应是完全正确的。

但是它是如何实现的呢?感觉似乎很复杂啊。

因为 effect 第一次执行的时候, data 还是个空数组,怎么会 push 的时候能触发更新呢?

还是用刚刚的小测试,看看 map 的时候会发生什么事情。

const raw = [1, 2]
const arr = new Proxy(raw, {
  get(target, key) {
    console.log('get', key)
    return Reflect.get(target, key)
  },
  set(target, key, value) {
    console.log('set', key)
    return Reflect.set(target, key, value)
  }
})

arr.map(v => v + 1)
get map
get length
get constructor
get 0
get 1

和 push 的部分有什么相同的?找一下线索,我们发现 map 的时候会触发 get length,而在触发更新的时候, Vue3 内部会对 「新增 key」 的操作进行特殊处理,这里是新增了 0 这个下标的值,会走到 trigger 中这样的一段逻辑里去:

源码地址

// 简化版
if (isAddOrDelete) {
  add(depsMap.get('length'))
}

把之前读取 length 时收集到的依赖拿到,然后触发函数。

这就一目了然了,我们在 effect 里 map 操作读取了 length,收集了 length 的依赖。

在新增 key 的时候, 触发 length 收集到的依赖,触发回调函数即可。

对了,对于 for of 操作,也一样可行:

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => {
  for (const val of data) {
    console.log('val', val)
  }
})

// ✅ 触发响应 打印出 val 1
data.push(1)

可以按我们刚刚的小试验自己跑一下拦截, for of 也会触发 length 的读取。

length 真是个好同志…… 帮了大忙了。

遍历后删除或者清空

注意上面的源码里的判断条件是 isAddOrDelete,所以删除的时候也是同理,借助了 length 上收集到的依赖。

// 简化版
if (isAddOrDelete) {
  add(depsMap.get('length'))
}
const arr = reactive([1])
  
effect(() => {
  console.log('arr', arr.map(v => v))
})

// ✅ 触发响应 
arr.length = 0

// ✅ 触发响应 
arr.splice(0, 1)

真的是什么操作都能响应,爱了爱了。

获取 keys

const obj = reactive({ a: 1 })
  
effect(() => {
  console.log('keys', Reflect.ownKeys(obj))
})

effect(() => {
  console.log('keys', Object.keys(obj))
})

effect(() => {
  for (let key in obj) {
    console.log(key)
  }
})

// ✅ 触发所有响应 
obj.b = 2

这几种获取 key 的方式都能成功的拦截,其实这是因为 Vue 内部拦截了 ownKeys 操作符。

const ITERATE_KEY = Symbol( 'iterate' );

function ownKeys(target) {
    track(target, "iterate", ITERATE_KEY);
    return Reflect.ownKeys(target);
}

ITERATE_KEY 就作为一个特殊的标识符,表示这是读取 key 的时候收集到的依赖。它会被作为依赖收集的 key。

那么在触发更新时,其实就对应这段源码:

if (isAddOrDelete) {
    add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY));
}

其实就是我们聊数组的时候,代码简化掉的那部分。判断非数组,则触发 ITERATE_KEY 对应的依赖。

小彩蛋:

Reflect.ownKeysObject.keysfor in 其实行为是不同的,

Reflect.ownKeys 可以收集到 Symbol 类型的 key,不可枚举的 key。

举例来说:

var a = {
  [Symbol(2)]: 2,
}

Object.defineProperty(a, 'b', {
  enumerable: false,
})

Reflect.ownKeys(a) // [Symbol(2), 'b']
Object.keys(a) // []

回看刚刚提到的 ownKeys 拦截,

function ownKeys(target) {
    track(target, "iterate", ITERATE_KEY);
    // 这里直接返回 Reflect.ownKeys(target)
    return  Reflect.ownKeys(target);
}

内部直接之间返回了 Reflect.ownKeys(target),按理来说这个时候 Object.keys 的操作经过了这个拦截,也会按照 Reflect.ownKeys 的行为去返回值。

然而最后返回的结果却还是 Object.keys 的结果,这是比较神奇的一点。

删除对象属性

有了上面 ownKeys 的基础,我们再来看看这个例子

const obj = reactive({ a: 1, b: 2})
  
effect(() => {
  console.log(Object.keys(obj))
})

// ✅ 触发响应 
delete obj['b']

这也是个神奇的操作,原理在于对于 deleteProperty 操作符的拦截:

function deleteProperty(target: object, key: string | symbol): boolean {
  const result = Reflect.deleteProperty(target, key)
  trigger(target, TriggerOpTypes.DELETE, key)
  return result
}

这里又用到了 TriggerOpTypes.DELETE 的类型,根据上面的经验,一定对它有一些特殊的处理。

其实还是 trigger 中的那段逻辑:

const isAddOrDelete = type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE
if (isAddOrDelete) {
  add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
}

这里的 target 不是数组,所以还是会去触发 ITERATE_KEY 收集的依赖,也就是上面例子中刚提到的对于 key 的读取收集到的依赖。

判断属性是否存在

const obj = reactive({})

effect(() => {
  console.log('has', Reflect.has(obj, 'a'))
})

effect(() => {
  console.log('has', 'a' in obj)
})

// ✅ 触发两次响应 
obj.a = 1

这个就很简单了,就是利用了 has 操作符的拦截。

function has(target, key) {
  const result = Reflect.has(target, key);
  track(target, "has", key);
  return result;
}

Map 和 Set

其实 Vue3 对于这两种数据类型也是完全支持响应式的,对于它们的原型方法也都做了完善的拦截,限于篇幅原因本文不再赘述。

说实话 Vue3 的响应式部分代码逻辑分支还是有点过多,对于代码理解不是很友好,因为它还会涉及到 readonly 等只读化的操作,如果看完这篇文章你对于 Vue3 的响应式原理非常感兴趣的话,建议从简化版的库入手去读源码。

这里我推荐 observer-util,我解读过这个库的源码,和 Vue3 的实现原理基本上是一模一样!但是简单了很多。麻雀虽小,五脏俱全。里面的注释也很齐全。

当然,如果你的英文不是很熟练,也可以看我精心用 TypeScript + 中文注释基于 observer-util 重写的这套代码:
typescript-proxy-reactive

对于这个库的解读,可以看我之前的两篇文章:

带你彻底搞懂Vue3的Proxy响应式原理!TypeScript从零实现基于Proxy的响应式库。

带你彻底搞懂Vue3的Proxy响应式原理!基于函数劫持实现Map和Set的响应式

在第二篇文章里,你也可以对于 Map 和 Set 可以做什么拦截操作,获得源码级别的理解。

总结

Vue3 的 Proxy 真的很强大,把 Vue2 里我认为心智负担很大的一部分给解决掉了。(在我刚上手 Vue 的时候,我是真的不知道什么情况下该用 $set),它的 composition-api 又可以完美对标 React Hook,并且得益于响应式系统的强大,在某些方面是优胜于它的。精读《Vue3.0 Function API》

希望这篇文章能在 Vue3 正式到来之前,提前带你熟悉 Vue3 的一些新特性。

扩展阅读

Proxy 的拦截器里有个 receiver 参数,在本文中为了简化没有体现出来,它是用来做什么的?国内的网站比较少能找到这个资料:

new Proxy(raw, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  }
})

可以看 StackOverflow 上的问答:what-is-a-receiver-in-javascript

也可以看我的总结
Proxy 和 Reflect 中的 receiver 到底是什么?

求点赞

如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我持续进行创作的动力,让我知道你喜欢看我的文章吧~

❤️感谢大家

关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

前端「N皇后」递归回溯经典问题图解

前言

在我的上一篇文章《前端电商 sku 的全排列算法很难吗?学会这个套路,彻底掌握排列组合。》中详细的讲解了排列组合的递归回溯解法,相信看过的小伙伴们对这个套路已经有了一定程度的掌握(没看过的同学快回头学习~)。

昨晚正好在看字节跳动的招聘直播,弹幕里有一些同学提到了面试时候考到了「N 皇后」问题,他没有答出来。这是一道 LeetCode 上难度为 hard 的题目。

听起来很吓人,但是看过我上一篇文章的同学应该还记得我有提到过,我解决电商 sku 问题用的是排列组合的万能模板,这个万能模板能否用来解决这个经典的计算机问题「N 皇后」呢?答案是肯定的。

问题

先来看问题,其实问题不难理解:

n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

上图为 8 皇后问题的一种解法。

给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。

每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

示例:

输入: 4
输出: [
 [".Q..",  // 解法 1
  "...Q",
  "Q...",
  "..Q."],

 ["..Q.",  // 解法 2
  "Q...",
  "...Q",
  ".Q.."]
]
解释: 4 皇后问题存在两个不同的解法。

提示:

皇后,是国际象棋中的棋子,意味着国王的妻子。皇后只做一件事,那就是“吃子”。当她遇见可以吃的棋子时,就迅速冲上去吃掉棋子。当然,她横、竖、斜都可走一到七步,可进可退。(引用自 百度百科 - 皇后 )

LeetCode 原题地址

思路

乍一看这种选出全部方案的问题有点难找到头绪,但是其实仔细看一下,题目已经限定了皇后之间不能互相攻击,转化成代码思维的语言其实就是说每一行只能有一个皇后,每条对角线上也只能有一个皇后

也就是说:

  1. 在一列上,错。
[
  'Q', 0
  'Q', 0
]
  1. 在左上 -> 右下的对角线上,错。
[
  'Q', 0
   0, 'Q'
]
  1. 在左下 -> 右上的对角线上,错。
[
   0, 'Q'
  'Q', 0
]

那么以这个思路为基准,我们就可以把这个问题转化成一个「逐行放置皇后」的问题,思考一下递归函数应该怎么设计?

对于 n皇后 的求解,我们可以设计一个接受如下参数的函数:

  1. rowIndex 参数,代表当前正在尝试第几行放置皇后。
  2. prev 参数,代表之前的行已经放置的皇后位置,比如 [1, 3] 就代表第 0 行(数组下标)的皇后放置在位置 1,第 1 行的皇后放置在位置 3。

rowIndex === n 即说明这个递归成功的放置了 n 个皇后,一路畅通无阻的到达了终点,每次的放置都顺利的通过了我们的限制条件,那么就把这次的 prev 做为一个结果放置到一个全局的 res 结果数组中。

树状图

这里我尝试用工具画出了 4皇后 的其中的一个解递归的树状图,第一行我直接选择了以把皇后放在2为起点,省略了以 放在1放在3放在4 为起点的树状图,否则递归树太大了图片根本放不下。

注意这里的 放在x,为了方便理解,这个 x 并不是数组下标,而是从 1 开始的计数。

在这次递归之后,就求出了一个结果:[1, 3, 0, 2]

你可以在纸上按照我的这种方式继续画一画尝试以其他起点开始的解法,来看看这个算法的具体流程。

实现

理想总是美好的,虽然目前为止我们的思路很清晰了,但是具体的编码还是会遇到几个头疼的问题的。

当前一行已经落下一个皇后之后,下一行需要判断三个条件:

  1. 在这一列上,之前不能摆放过皇后。
  2. 在对角线 1,也就是「左下 -> 右上」这条对角线上,之前不能摆放过皇后。
  3. 在对角线 2,也就是「右上 -> 左下」这条对角线上,之前不能摆放过皇后。

难点在于判断对角线上是否摆放过皇后了,其实找到规律后也不难了,看图:

对角线1

直接通过这个点的横纵坐标 rowIndex + columnIndex 相加,相等的话就在同在对角线 1 上:

image

对角线2

直接通过这个点的横纵坐标 rowIndex - columnIndex 相减,相等的话就在同在对角线 2 上:

image

所以:

  1. columns 数组记录摆放过的下标,摆放过后直接标记为 true 即可。
  2. dia1 数组记录摆放过的对角线 1下标,摆放过后直接把下标 rowIndex + columnIndex标记为 true 即可。
  3. dia2 数组记录摆放过的对角线 2下标,摆放过后直接把下标 rowIndex - columnIndex标记为 true 即可。
  4. 递归函数的参数 prev 代表每一行中皇后放置的列数,比如 prev[0] = 3 代表第 0 行皇后放在第 3 列,以此类推。
  5. 每次进入递归函数前,先把当前项所对应的列、对角线 1、对角线 2的下标标记为 true,带着标记后的状态进入递归函数。并且在退出本次递归后,需要把这些状态重置为 false ,再进入下一轮循环。

有了这几个辅助知识点,就可以开始编写递归函数了,在每一行,我们都不断的尝试一个坐标点,只要它和之前已有的结果都不冲突,那么就可以放入数组中作为下一次递归的开始值。

这样,如果递归函数顺利的来到了 rowIndex === n 的情况,说明之前的条件全部满足了,一个 n皇后 的解就产生了。把 prev 这个一维数组通过辅助函数恢复成题目要求的完整的「二维数组」即可。

/**
 * @param {number} n
 * @return {string[][]}
 */
let solveNQueens = function (n) {
  let res = []

  // 已摆放皇后的的列下标
  let columns = []
  // 已摆放皇后的对角线1下标 左下 -> 右上
  // 计算某个坐标是否在这个对角线的方式是「行下标 + 列下标」是否相等
  let dia1 = []
  // 已摆放皇后的对角线2下标 左上 -> 右下
  // 计算某个坐标是否在这个对角线的方式是「行下标 - 列下标」是否相等
  let dia2 = []

  // 在选择当前的格子后 记录状态
  let record = (rowIndex, columnIndex, bool) => {
    columns[columnIndex] = bool
    dia1[rowIndex + columnIndex] = bool
    dia2[rowIndex - columnIndex] = bool
  }

  // 尝试在一个n皇后问题中 摆放第index行内的皇后位置
  let putQueen = (rowIndex, prev) => {
    if (rowIndex === n) {
      res.push(generateBoard(prev))
      return
    }

    // 尝试摆第index行的皇后 尝试[0, n-1]列
    for (let columnIndex = 0; columnIndex < n; columnIndex++) {
      // 在列上不冲突
      let columnNotConflict = !columns[columnIndex]
      // 在对角线1上不冲突
      let dia1NotConflict = !dia1[rowIndex + columnIndex]
      // 在对角线2上不冲突
      let dia2NotConflict = !dia2[rowIndex - columnIndex]

      if (columnNotConflict && dia1NotConflict && dia2NotConflict) {
        // 都不冲突的话,先记录当前已选位置,进入下一轮递归
        record(rowIndex, columnIndex, true)
        putQueen(rowIndex + 1, prev.concat(columnIndex))
        // 递归出栈后,在状态中清除这个位置的记录,下一轮循环应该是一个全新的开始。
        record(rowIndex, columnIndex, false)
      }
    }
  }

  putQueen(0, [])

  return res
}

// 生成二维数组的辅助函数
function generateBoard(row) {
  let n = row.length
  let res = []
  for (let y = 0; y < n; y++) {
    let cur = ""
    for (let x = 0; x < n; x++) {
      if (x === row[y]) {
        cur += "Q"
      } else {
        cur += "."
      }
    }
    res.push(cur)
  }
  return res
}

课后练习

对递归回溯的相似 LeetCode 题型感兴趣的同学,可以去我维护的 力扣题解-递归与回溯 这个 Github 仓库分类下查看其它的经典相似题目,先尝试自己用我的两篇递归回溯文章中的思路求解,如果还是答不出来的话,就去看题解总结归纳,直到你能真正的自己做出类似的题型为止。

总结

至此为止,年轻前端的第一道 hard 题就解出来了,是不是有种任督二脉打通的感觉呢?

递归回溯的问题本质上就是,递归进入下一层后,如果发现不满足条件,就通过 return 等方式回溯到上一层递归,继续寻求合适的解。

掌握了这个思路以后,相信你在现实编码中遇到的很多递归难题都可以轻松的降维打击,迎刃而解了。

也祝正在筹备换工作的小伙伴们顺利通过面试笔试的厮杀,拿到理想的 offer,大家加油。

❤️ 感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

Vue中的组件从初始化到挂载经历了什么

一个组件从初始化到挂载经历了什么

下面的所有解析都以这段代码为基准:

new Vue({
  el: "#app",
  render: h => h(AppSon)
});

其中 AppSon 就是组件,它是一个对象:

const AppSon = {
  name: "app-son",
  data() {
    return {
      msg: 123
    };
  },
  render(h) {
    return h("span", [this.msg]);
  }
};

这样一段代码,在 Vue 内部组件化的流程顺序:

  1. $createElement,其实 render 接受的参数 h 就是this.$createElement的别名
  2. createElement,做一下参数的整理,就进入下一步
  3. _createElement,比较关键的一步,在这个方法里会判断组件是span这样的 html 标签,还是用户写的自定义组件。
  4. createComponent,生成组件的 vnode,安装一些 vnode 的生命周期,返回 vnode

其实,render 函数最终返回的就是vnode

流程解析

$createElement

调用createElement方法,第一个参数是 vm 实例自身,剩余的参数原封不动的透传。

vm.$createElement = function(a, b, c, d) {
  return createElement(vm, a, b, c, d, true);
};

createElement

function createElement (
  // 上一步传进来的vm实例,在哪个组件的render里调用,context就是哪个组件的实例。
  context,
  // 在例子中,就是AppSon这个对象
  tag,
  // 可以传入props等交给子组件的选项
  data,
  // 子组件中间的内容
  children,
  ...
)

之后有一个判断

if (typeof tag === "string") {
  // html标签流程
} else {
  // 组件化流程
  vnode = createComponent(tag, data, context, children);
}

createComponent接受的四个参数就是上文的方法传进去的

createComponent

function createComponent(
  // 还是上文中的tag,本文中是AppSon对象
  Ctor,
  // 下面的都一致
  data,
  context,
  children
) {
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor);
  }

  // 给vnode安装一些生命周期函数(注意这里是vnode的生命周期,而不是created那些组件声明周期)
  installComponentHooks(data);

  var vnode = new VNode(
    "vue-component-" + Ctor.cid + (name ? "-" + name : ""),
    data,
    undefined,
    undefined,
    undefined,
    context,
    {
      Ctor: Ctor,
      propsData: propsData,
      listeners: listeners,
      tag: tag,
      children: children
    },
    asyncFactory
  );

  return vnode;
}

下面有一个逻辑

if (isObject(Ctor)) {
  Ctor = baseCtor.extend(Ctor);
}

其中baseCtor.extend(Ctor)就可以暂时理解为 Vue.extend,这是一个全局共用方法,从名字也可以看出它主要是做一些继承,让子组件的也拥有父组件的一些能力,这个方法返回的是一个新的构造函数。

组件对象最终都会用 extend 这个 api 变成一个组件构造函数,这个构造函数继承了父构造函数 Vue 的一些属性

extend 函数具体做了什么呢?

createComponent / Vue.extend

Vue.extend = function(extendOptions) {
  extendOptions = extendOptions || {};
  // this在这个例子其实就是Vue。
  var Super = this;

  // Appson这个组件的构造函数
  var Sub = function VueComponent(options) {
    // 这个_init就是调用的Vue.prototype._init
    this._init(options);
  };

  // 把Vue.prototype生成一个
  // { __proto__: Vue.prototype }这样的对象,
  // 直接赋值给子组件构造函数的prototype
  // 此时子组件构造函数的原型链上就可以拿到Vue的原型链的属性了
  Sub.prototype = Object.create(Super.prototype);
  Sub.prototype.constructor = Sub;

  // 合并Vue.option上的一些全局配置
  Sub.options = mergeOptions(Super.options, extendOptions);
  Sub["super"] = Super;

  // 拷贝静态函数
  Sub.extend = Super.extend;
  Sub.mixin = Super.mixin;
  Sub.use = Super.use;

  // 返回子组件的构造函数
  return Sub;
};

到了这一步,我们一开始定义的 Appson 组件对象,已经变成了一个函数,可以通过 new AppSon()来生成一个组件实例了,并且组件配置对象被合并到了Sub.options这个构造函数的静态属性上。

createComponent / installComponentHooks

installComponentHooks这个方法是为了给 vnode 上加入一些生命周期函数,

其中有一个init生命周期,这个周期后面被调用的时候再讲解。

createComponent / new VNode

可以看出,主要是生成 vnode 的实例,并且赋值给vnode.componentInstance,并且调用$mount方法挂载 dom 节点,注意这个init生命周期此时还没有调用。

到这为止render的流程就讲完了,现在我们拥有了一个vnode节点,它有一些关键的属性

  1. vnode.componentOptions.Ctor: 上一步extend生成的子组件构造函数。
  2. vnode.data.hook: 里面保存了init等 vnode 生命周期方法
  3. vnode.context: 调用$createElement 的是哪个实例,这个 context 就是谁。

$mount

最外层的组件调用了$mount后,组件在初次渲染的时候其实是递归去调用createElm的,而createElm中会去调用组件 vnode 的init钩子。

if (isDef((i = i.hook)) && isDef((i = i.init))) {
  i(vnode);
}

然后就会走进 vnode 的init生命周期的逻辑

const child = (vnode.componentInstance = createComponentInstanceForVnode(
  vnode,
  activeInstance
));
child.$mount(vnode.elm);

createComponentInstanceForVnode:

createComponentInstanceForVnode (
  vnode: any,
  parent: any,
): Component {
  const options: InternalComponentOptions = {
    // 标记这是一个组件节点
    _isComponent: true,
    // Appson组件的vnode
    _parentVnode: vnode,
    // 当前正在活跃的父组件实例,在本例中就是根Vue实例
    // new Vue({
    //   el: "#app",
    //   render: h => h(AppSon)
    // });
    parent
  }

  return new vnode.componentOptions.Ctor(options)
}

可以看出,最终调用组件构造函数,然后调用\_init 方法,它接受到的 options 不再是

{
  data() {

  },
  props: {

  },
  methods() {

  }
}

这样的传统 Vue 对象了,而是

{
    _isComponent: true,
    _parentVnode: vnode,
    parent,
  }

这样的一个对象,然后_init 内部会针对这样特征的对象,调用initInternalComponent做一些特殊的处理,
这里有一个疑惑点,那刚刚子组件声明的 data 那些选项哪去了呢?
其实是被保存在Ctor.options里了。

然后在initInternalComponent中,把子组件构造函数上保存的 options 再转移到vm.$options.__proto__上。

var opts = (vm.$options = Object.create(vm.constructor.options));

之后生成了子组件的实例后,又会调用child.$mount(vnode.elm),继续的去递归这个初始化的过程。

为什么 Vue 中不要用 index 作为 key?(diff 算法详解)

前言

Vue 中的 key 是用来做什么的?为什么不推荐使用 index 作为 key?常常听说这样的问题,本篇文章带你从原理来一探究竟。

另外本文的结论对于性能的毁灭是针对列表子元素顺序会交换的情况,提前说明清楚,喷子绕道。

示例

以这样一个列表为例:

<ul>
  <li>1</li>
  <li>2</li>
</ul>

那么它的 vnode 也就是虚拟 dom 节点大概是这样的。

{
  tag: 'ul',
  children: [
    { tag: 'li', children: [ { vnode: { text: '1' }}]  },
    { tag: 'li', children: [ { vnode: { text: '2' }}]  },
  ]
}

假设更新以后,我们把子节点的顺序调换了一下:

{
  tag: 'ul',
  children: [
+   { tag: 'li', children: [ { vnode: { text: '2' }}]  },
+   { tag: 'li', children: [ { vnode: { text: '1' }}]  },
  ]
}

很显然,这里的 children 部分是我们本文 diff 算法要讲的重点(敲黑板)。

首先响应式数据更新后,触发了 渲染 Watcher 的回调函数 vm._update(vm._render())去驱动视图更新,

vm._render() 其实生成的就是 vnode,而 vm._update 就会带着新的 vnode 去走触发 __patch__ 过程。

我们直接进入 ul 这个 vnodepatch 过程。

对比新旧节点是否是相同类型的节点:

1. 不是相同节点:

isSameNode为false的话,直接销毁旧的 vnode,渲染新的 vnode。这也解释了为什么 diff 是同层对比。

2. 是相同节点,要尽可能的做节点的复用(都是 ul,进入👈)。

会调用src/core/vdom/patch.js下的patchVNode方法。

如果新 vnode 是文字 vnode

就直接调用浏览器的 dom api 把节点的直接替换掉文字内容就好。

如果新 vnode 不是文字 vnode

如果有新 children 而没有旧 children

说明是新增 children,直接 addVnodes 添加新子节点。

如果有旧 children 而没有新 children

说明是删除 children,直接 removeVnodes 删除旧子节点

如果新旧 children 都存在(都存在 li 子节点列表,进入👈)

那么就是我们 diff算法 想要考察的最核心的点了,也就是新旧节点的 diff 过程。

通过

  // 旧首节点
  let oldStartIdx = 0
  // 新首节点
  let newStartIdx = 0
  // 旧尾节点
  let oldEndIdx = oldCh.length - 1
  // 新尾节点
  let newEndIdx = newCh.length - 1

这些变量分别指向旧节点的首尾新节点的首尾

根据这些指针,在一个 while 循环中不停的对新旧节点的两端的进行对比,直到没有节点可以对比。

在讲对比过程之前,要讲一个比较重要的函数:sameVnode

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      )
    )
  )
}

它是用来判断节点是否可用的关键函数,可以看到,判断是否是 sameVnode,传递给节点的 key 是关键。

然后我们接着进入 diff 过程,每一轮都是同样的对比,其中某一项命中了,就递归的进入 patchVnode 针对单个 vnode 进行的过程(如果这个 vnode 又有 children,那么还会来到这个 diff children 的过程 ):

  1. 旧首节点和新首节点用 sameNode 对比。

  2. 旧尾节点和新首节点用 sameNode 对比

  3. 旧首节点和新尾节点用 sameNode 对比

  4. 旧尾节点和新尾节点用 sameNode 对比

  5. 如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射表,然后用新 vnodekey 去找出在旧节点中可以复用的位置。

然后不停的把匹配到的指针向内部收缩,直到新旧节点有一端的指针相遇(说明这个端的节点都被patch过了)。

在指针相遇以后,还有两种比较特殊的情况:

  1. 有新节点需要加入。
    如果更新完以后,oldStartIdx > oldEndIdx,说明旧节点都被 patch 完了,但是有可能还有新的节点没有被处理到。接着会去判断是否要新增子节点。

  2. 有旧节点需要删除。
    如果新节点先patch完了,那么此时会走 newStartIdx > newEndIdx 的逻辑,那么就会去删除多余的旧子节点。

为什么不要以index作为key?

节点reverse场景

假设我们有这样的一段代码:

    <div id="app">
      <ul>
        <item
          :key="index"
          v-for="(num, index) in nums"
          :num="num"
          :class="`item${num}`"
        ></item>
      </ul>
      <button @click="change">改变</button>
    </div>
    <script src="./vue.js"></script>
    <script>
      var vm = new Vue({
        name: "parent",
        el: "#app",
        data: {
          nums: [1, 2, 3]
        },
        methods: {
          change() {
            this.nums.reverse();
          }
        },
        components: {
          item: {
            props: ["num"],
            template: `
                    <div>
                       {{num}}
                    </div>
                `,
            name: "child"
          }
        }
      });
    </script>

其实是一个很简单的列表组件,渲染出来 1 2 3 三个数字。我们先以 index 作为key,来跟踪一下它的更新。

我们接下来只关注 item 列表节点的更新,在首次渲染的时候,我们的虚拟节点列表 oldChildren 粗略表示是这样的:

[
  {
    tag: "item",
    key: 0,
    props: {
      num: 1
    }
  },
  {
    tag: "item",
    key: 1,
    props: {
      num: 2
    }
  },
  {
    tag: "item",
    key: 2,
    props: {
      num: 3
    }
  }
];

在我们点击按钮的时候,会对数组做 reverse 的操作。那么我们此时生成的 newChildren 列表是这样的:

[
  {
    tag: "item",
    key: 0,
    props: {
+     num: 3
    }
  },
  {
    tag: "item",
    key: 1,
    props: {
+     num: 2
    }
  },
  {
    tag: "item",
    key: 2,
    props: {
+     num: 1
    }
  }
];

发现什么问题没有?key的顺序没变,传入的值完全变了。这会导致一个什么问题?

本来按照最合理的逻辑来说,旧的第一个vnode 是应该直接完全复用 新的第三个vnode的,因为它们本来就应该是同一个vnode,自然所有的属性都是相同的。

但是在进行子节点的 diff 过程中,会在 旧首节点和新首节点用 sameNode 对比。 这一步命中逻辑,因为现在新旧两次首部节点key 都是 0了,

然后把旧的节点中的第一个 vnode 和 新的节点中的第一个 vnode 进行 patchVnode 操作。

这会发生什么呢?我可以大致给你列一下:
首先,正如我之前的文章props的更新如何触发重渲染?里所说,在进行 patchVnode 的时候,会去检查 props 有没有变更,如果有的话,会通过 _props.num = 3 这样的逻辑去更新这个响应式的值,触发 dep.notify,触发子组件视图的重新渲染等一套很重的逻辑。

然后,还会额外的触发以下几个钩子,假设我们的组件上定义了一些dom的属性或者类名、样式、指令,那么都会被全量的更新。

  1. updateAttrs
  2. updateClass
  3. updateDOMListeners
  4. updateDOMProps
  5. updateStyle
  6. updateDirectives

而这些所有重量级的操作(虚拟dom发明的其中一个目的不就是为了减少真实dom的操作么?),都可以通过直接复用 第三个vnode 来避免,是因为我们偷懒写了 index 作为 key,而导致所有的优化失效了。

节点删除场景

另外,除了会导致性能损耗以外,在删除子节点的场景下还会造成更严重的错误,

可以看sea_ljf同学提供的这个demo

假设我们有这样的一段代码:

<body>
  <div id="app">
    <ul>
      <li v-for="(value, index) in arr" :key="index">
        <test />
      </li>
    </ul>
    <button @click="handleDelete">delete</button>
  </div>
  </div>
</body>
<script>
  new Vue({
    name: "App",
    el: '#app',
    data() {
      return {
        arr: [1, 2, 3]
      };
    },
    methods: {
      handleDelete() {
        this.arr.splice(0, 1);
      }
    },
    components: {
      test: {
        template: "<li>{{Math.random()}}</li>"
      }
    }
  })
</script>

那么一开始的 vnode列表是:

[
  {
    tag: "li",
    key: 0,
    // 这里其实子组件对应的是第一个 假设子组件的text是1
  },
  {
    tag: "li",
    key: 1,
    // 这里其实子组件对应的是第二个 假设子组件的text是2
  },
  {
    tag: "li",
    key: 2,
    // 这里其实子组件对应的是第三个 假设子组件的text是3
  }
];

有一个细节需要注意,正如我上一篇文章中所提到的为什么说 Vue 的响应式更新比 React 快?,Vue 对于组件的 diff 是不关心子组件内部实现的,它只会看你在模板上声明的传递给子组件的一些属性是否有更新。

也就是和v-for平级的那部分,回顾一下判断 sameNode 的时候,只会判断keytag是否有data的存在(不关心内部具体的值)是否是注释节点是否是相同的input type,来判断是否可以复用这个节点。

<li v-for="(value, index) in arr" :key="index"> // 这里声明的属性
  <test />
</li>

有了这些前置知识以后,我们来看看,点击删除子元素后,vnode 列表 变成什么样了。

[
  // 第一个被删了
  {
    tag: "li",
    key: 0,
    // 这里其实上一轮子组件对应的是第二个 假设子组件的text是2
  },
  {
    tag: "li",
    key: 1,
    // 这里其实子组件对应的是第三个 假设子组件的text是3
  },
];

虽然在注释里我们自己清楚的知道,第一个 vnode 被删除了,但是对于 Vue 来说,它是感知不到子组件里面到底是什么样的实现(它不会深入子组件去对比文本内容),那么这时候 Vue 会怎么 patch 呢?

由于对应的 key使用了 index导致的错乱,它会把

  1. 原来的第一个节点text: 1直接复用。
  2. 原来的第二个节点text: 2直接复用。
  3. 然后发现新节点里少了一个,直接把多出来的第三个节点text: 3 丢掉。

至此为止,我们本应该把 text: 1节点删掉,然后text: 2text: 3 节点复用,就变成了错误的把 text: 3 节点给删掉了。

为什么不要用随机数作为key?

<item
  :key="Math.random()"
  v-for="(num, index) in nums"
  :num="num"
  :class="`item${num}`"
/>

其实我听过一种说法,既然官方要求一个 唯一的key,是不是可以用 Math.random() 作为 key 来偷懒?这是一个很鸡贼的想法,看看会发生什么吧。

首先 oldVnode 是这样的:

[
  {
    tag: "item",
    key: 0.6330715699108844,
    props: {
      num: 1
    }
  },
  {
    tag: "item",
    key: 0.25104533240710514,
    props: {
      num: 2
    }
  },
  {
    tag: "item",
    key: 0.4114769152411637,
    props: {
      num: 3
    }
  }
];

更新以后是:

[
  {
    tag: "item",
+   key: 0.11046018699748683,
    props: {
+     num: 3
    }
  },
  {
    tag: "item",
+   key: 0.8549799545696619,
    props: {
+     num: 2
    }
  },
  {
    tag: "item",
+   key: 0.18674467938937478,
    props: {
+     num: 1
    }
  }
];

可以看到,key 变成了完全全新的 3 个随机数。

上面说到,diff 子节点的首尾对比如果都没有命中,就会进入 key 的详细对比过程,简单来说,就是利用旧节点的 key -> index 的关系建立一个 map 映射表,然后用新节点的 key 去匹配,如果没找到的话,就会调用 createElm 方法 重新建立 一个新节点。

具体代码在这:

// 建立旧节点的 key -> index 映射表
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);

// 去映射表里找可以复用的 index
idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 一定是找不到的,因为新节点的 key 是随机生成的。
if (isUndef(idxInOld)) {
  // 完全通过 vnode 新建一个真实的子节点
  createElm();
}

也就是说,咱们的这个更新过程可以这样描述:
123 -> 前面重新创建三个子组件 -> 321123 -> 删除、销毁后面三个子组件 -> 321

发现问题了吧?这是毁灭性的灾难,创建新的组件和销毁组件的成本你们晓得的伐……本来仅仅是对组件移动位置就可以完成的更新,被我们毁成这样了。

总结

经过这样的一段旅行,diff 这个庞大的过程就结束了。

我们收获了什么?

  1. 用组件唯一的 id(一般由后端返回)作为它的 key,实在没有的情况下,可以在获取到列表的时候通过某种规则为它们创建一个 key,并保证这个 key 在组件整个生命周期中都保持稳定。

  2. 如果你的列表顺序会改变,别用 index 作为 key,和没写基本上没区别,因为不管你数组的顺序怎么颠倒,index 都是 0, 1, 2 这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作。列表顺序不变也尽量别用,可能会误导新人。

  3. 千万别用随机数作为 key,不然旧节点会被全部删掉,新节点重新创建,你的老板会被你气死。

中级前端面试指南

前言

本篇文章,献给我家女朋友,祝她在杭州找一个965的好公司!

题外话:关于中级 -> 高级的进阶,我也写了一篇文章,希望对你有帮助:
写给初中级前端的高级进阶指南

HTML篇

HTML5语义化

html5语义化标签

百度ife的h5语义化文章,讲得很好,很多不错的公司都会问语义化的问题。

CSS篇

CSS常见面试题

50道CSS经典面试题

CSS基础有的公司很重视,在面试前还是需要好好复习一遍的。

能不能讲一讲Flex布局,以及常用的属性?。

阮一峰的flex系列

Flex布局是高频考点,而且是平常开发中最常用的布局方式之一,一定要熟悉。

BFC是什么?能解决什么问题?

什么是BFC?什么条件下会触发?应用场景有哪些?

关于bfc,可以看看三元大佬总结的文章
这篇文章里,顺便也把外边距重叠的问题讲了一下。

JS基础篇

讲讲JS的数据类型?

最新的 ECMAScript 标准定义了 8种数据类型:

  • 6 种原始类型
    • Boolean
    • Undefined
    • Number
    • BigInt
    • String
    • Symbol
  • null
  • Object
  • Function

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures

讲讲Map和Set?

  1. Map的key相比较普通对象来说更为灵活,普通对象的key只能以基础数据类型作为key值,并且所有传入的key值都会被转化成string类型,而Map的key可以是各种数据类型格式。
  2. Set可以讲讲它去重的特性。

WeakMap和Map之间的区别?

WeakMap只能以复杂数据类型作为key,并且key值是弱引用,对于垃圾回收更加友好。

讲讲原型链?

JavaScript深入之从原型到原型链

关于原型链,虽然现在用的不太多了,但是__proto__和prototype之间的关系,以及对于属性的向上查找这些还是一定要清楚的,其余不用看的太细。

讲讲this?

JavaScript中的this

  1. this指向调用者这个关系一定要清楚
  2. 要知道改变this指向的几种方式(call, bind, apply)
  3. 箭头函数中this的特殊性要能讲清楚

浅拷贝和深拷贝的区别

  • 浅拷贝:一般指的是把对象的第一层拷贝到一个新对象上去,比如
var a = { count: 1, deep: { count: 2 } }
var b = Object.assign({}, a)
// 或者
var b = {...a}
  • 深拷贝:一般需要借助递归实现,如果对象的值还是个对象,要进一步的深入拷贝,完全替换掉每一个复杂类型的引用。
var deepCopy = (obj) => {
    var ret = {}
    for (var key in obj) {
        var value = obj[key]
        ret[key] = typeof value === 'object' ? deepCopy(value) : value
    }
    return ret
}

对于同一个用例来说

// 浅拷贝
var a = { count: 1, deep: { count: 2 } }
var b = {...a}

a.deep.count = 5
b.deep.count // 5
var a = { count: 1, deep: { count: 2 } }
var b = deepCopy(a)
a.deep.count = 5
b.deep.count // 2

讲讲事件冒泡和事件捕获以及事件代理?

你真的理解 事件冒泡 和 事件捕获 吗?

框架篇

React

React需要尽可能的保证熟练。因为作为中级工程师来说,公司可能不会让你去写框架,调性能优化,但是一定是会让你保质保量的完成开发任务的,这需要你能熟练掌握框架。

React2019高频面试题

2019年17道高频React面试题及详解

这些题可以先过一下,如果暂时不能理解的就先跳过,不需要死磕。

有没有使用过 React Hooks?

  • 常用的有哪些?都有什么作用?
  • 如何使用hook在依赖改变的时候重新发送请求?
  • 写过自定义hook吗?解决了哪些问题。
  • 讲讲React Hooks的闭包陷阱,你是怎么解决的?

useEffect 完整指南

其实关于Hook的问题,把Dan的博文稍微过一遍,基本上就可以和面试官谈笑风生了。

讲讲React中的组件复用?

【React深入】从Mixin到HOC再到Hook

这篇文章从mixin到HOC到Hook,详细的讲解了React在组件复用中做的一些探索和发展,能把这个好好讲明白,面试官也会对你的React实力刮目相看。
另外这篇文章中的高阶组件Hook本身也是高频考点。

工具

webpack的基础知识

这个系列从基础到优化都有,可以自己选择深入
掘金刘小夕的webpack系列

性能优化

讲讲web各个阶段的性能优化?

React 16 加载性能优化指南

这个很长,很细节,一样不要死磕其中的某一个点,对于你大概知道的点再巩固一下印象就ok。

webpack代码分割是怎么做的?

webpack的代码分割(路由懒加载同理)

路由懒加载和webpack异步加载模块都是这个import()语法,值得仔细看看。

网络

讲讲http的基本结构?

http的基础结构

说说常用的http状态码?

http状态码

浏览器从输入url到渲染页面,发生了什么?

细说浏览器输入URL后发生了什么

讲讲你对cookie的理解?包括SameSite属性。

预测最近面试会考 Cookie 的 SameSite 属性

这篇文章可以主要讲chrome80新增的cookie的SameSite属性,另外对于cookie整体也可以复习和回顾一遍,非常棒~

谈谈https的原理?为什么https能保证安全?

谈谈 HTTPS

https也是一个高频考点,需要过一遍https的加密原理。

谈谈前端的安全知识?XSS、CSRF,以及如何防范。

寒冬求职之你必须要懂的Web安全

安全问题也是很多公司必问的,毕竟谁也不希望自己的前端写的网站漏洞百出嘛。

讲讲http的缓存机制吧,强缓存,协商缓存?

深入理解浏览器的缓存机制

浏览器缓存基本上是必问的,这篇文章非常值得一看。

手写系列

基础

手写各种原生方法

如何模拟实现一个new的效果?
如何模拟实现一个 bind 的效果?
如何实现一个 call/apply 函数?
三元-手写代码系列

说实话我不太喜欢手写代码的面试题,但是很多公司喜欢考这个,有余力的话还是过一遍吧。

进阶

手写Promise 20行

精力有限的情况下,就先别背A+规范的promise手写了,但是如果有时间的话,可以大概过一遍文章,然后如果面试的时候考到了,再用简短的方式写出来。
剖析Promise内部结构,一步一步实现一个完整的、能通过所有Test case的Promise类

❤️感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

关于如何触发浏览器重绘的一些尝试。

我们动态的往body节点上挂一个小球 并且更改它的top值 让它触发动画
(以下代码均在chrome控制台执行)

var div = document.createElement('div')
div.style.cssText = 'position: fixed;width: 30px; height: 30px; left: 0; top: 0; background: red;transition: all 1s'
document.body.appendChild(div)
div.style.top = '500px'

这样写是没用的,因为浏览器很聪明,它会把你在一次task中的样式更改收集起来,再执行渲染的时候再把它一次性改变,但是网上很多人说getComputedStyle这个api可以直接触发重绘,那么我们来试试

var div = document.createElement('div')
div.style.cssText = 'position: fixed;width: 30px; height: 30px; left: 0; top: 0; background: red;transition: all 1s'
document.body.appendChild(div)

// 想触发重绘
getComputedStyle(div)
div.style.top = '500px'

按理说应该会执行动画吧, 可是并没有,这很让人疑惑,我们再这样试试

var div = document.createElement('div')
div.style.cssText = 'position: fixed;width: 30px; height: 30px; left: 0; top: 0; background: red;transition: all 1s'
document.body.appendChild(div)

// 想触发重绘
getComputedStyle(div).top
div.style.top = '500px'

咦,这次终于执行动画了,看来浏览器优化程度到了这一步,就算你去getComputedStyle 它也会惰性的给你返回一个对象, 等到你真正的去读取里面的样式值,才会触发重绘。

再来一个小实验,我们想要让浏览器闪烁两个颜色

document.body.style.background = 'blue'
document.body.style.background = 'red'

显而易见这样是没用的, 那么我们用setTimeout去让中间经历一次浏览器渲染

document.body.style.background = 'blue'
setTimeout(() => { document.body.style.background = 'red' })

这次好像可以了 把这段代码在浏览器里执行多次, 会发现有时候还是会直接变成红色背景,这是为什么呢? 我们继续做个试验

document.body.style.background = 'blue'
setTimeout(() => { document.body.style.background = 'red' }, 16.7)

这段代码再执行n次, 这下每次都会闪烁两种颜色了, 16.7是个什么数字呢,我们一般电脑的屏幕刷新率是60hz,也就是每秒更新60次视图,1000ms / 60 ≈ 16.7 浏览器会根据你的屏幕刷新率去约束渲染线程的执行,去除掉多余无效的渲染。

那牵扯到硬件,假如我们的刷新率只有30hz呢, 或者更多,更少呢?
这也就是为什么浏览器给我们提供了一个api叫requestAnimationFrame,不懂的朋友们可以去查阅一下这个api的用法。
真正保证屏幕一定会闪烁两次的做法

requestAnimationFrame(() => {
  document.body.style.background = 'red' 
  requestAnimationFrame(() => {
    document.body.style.background = 'blue'
  })
})

React Hook + TypeScript 手把手带你打造use-watch自定义Hook,实现Vue中的watch功能。

前言

在Vue中,我们经常需要用watch去观察一个值的变化,通过新旧值的对比去做一些事情。

但是React Hook中好像并没有提供类似的hook来让我们实现相同的事情

不过好在Hook的好处就在于它可以自由组合各种基础Hook从而实现强大的自定义Hook。

本篇文章就带你打造一个简单好用的use-watch hooks。

实现

实现雏形

首先分析一下Vue中watch的功能,就是一个响应式的值发生改变以后,会触发一个回调函数,那么在React中自然而然的就想到了useEffect这个hook,我们先来打造一个基础的代码雏形,把我们想要观察的值作为useEffect的依赖传入。

type Callback<T> = (prev: T | undefined) => void;

function useWatch<T>(dep: T, callback: Callback<T>) {
  useEffect(() => {
   callback();
  }, [dep]);
}

现在我们使用的时候就可以

const App: React.FC = () => {
  const [count, setCount] = useState(0);

  useWatch(count, () => {
    console.log('currentCount: ', count);
  })

  const add = () => setCount(prevCount => prevCount + 1)

  return (
    <div>
      <p> 当前的count是{count}</p>
      {count}
      <button onClick={add} className="btn">+</button>
    </div>
  )
}

实现oldValue

在每次count发生变化的时候,会执行传入的回调函数。

现在我们加入旧值的保存逻辑,以便于在每次调用传进去的回调函数的时候,可以在回调函数中拿到count上一次的值。

什么东西可以在一个组件的生命周期中充当一个存储器的功能呢,当然是useRef啦。

function useWatch<T>(dep: T, callback: Callback<T>) {
  const prev = useRef<T>();

  useEffect(() => {
    callback(prev.current);
    prev.current = dep;
  }, [dep]);

  return () => {
    stop.current = true;
  };
}

这样就在每一次更新prev里保存的值为最新的值之前,先调用callback函数把上一次保留的值给到外部。

现在外部使用的时候 就可以

const App: React.FC = () => {
  const [count, setCount] = useState(0);

  useWatch(count, (oldCount) => {
    console.log('oldCount: ', oldCount);
    console.log('currentCount: ', count);
  })

  const add = () => setCount(prevCount => prevCount + 1)

  return (
    <div>
      <p> 当前的count是{count}</p>
      {count}
      <button onClick={add} className="btn">+</button>
    </div>
  )
}

实现immediate

其实到此为止,已经实现了Vue中watch的主要功能了,

现在还有一个问题是useEffect会在组件初始化的时候就默认调用一次,而watch的默认行为不应该这样。

现在需要在组件初始化的时候不要调用这个callback,还是利用useRef来做,利用一个标志位inited来保存组件是否初始化的标记。

并且通过第三个参数config来允许用户改变这个默认行为。

type Callback<T> = (prev: T | undefined) => void;
type Config = {
  immediate: boolean;
};

function useWatch<T>(dep: T, callback: Callback<T>, config: Config = { immediate: false }) {
  const { immediate } = config;

  const prev = useRef<T>();
  const inited = useRef(false);

  useEffect(() => {
    const execute = () => callback(prev.current);

    if (!inited.current) {
      inited.current = true;
      if (immediate) {
        execute();
      }
    } else {
      execute();
    }
    prev.current = dep;
  }, [dep]);
}

实现stop

还是通过useRef做,只是把控制ref标志的逻辑暴露给外部。

type Callback<T> = (prev: T | undefined) => void;
type Config = {
  immediate: boolean;
};

function useWatch<T>(dep: T, callback: Callback<T>, config: Config = { immediate: false }) {
  const { immediate } = config;

  const prev = useRef<T>();
  const inited = useRef(false);
  const stop = useRef(false);

  useEffect(() => {
    const execute = () => callback(prev.current);

    if (!stop.current) {
      if (!inited.current) {
        inited.current = true;
        if (immediate) {
          execute();
        }
      } else {
        execute();
      }
      prev.current = dep;
    }
  }, [dep]);

  return () => {
    stop.current = true;
  };
}

这样在外部就可以这样去停止本次观察。

const App: React.FC = () => {
  const [prev, setPrev] = useState()
  const [count, setCount] = useState(0);

  const stop = useWatch(count, (prevCount) => {
    console.log('prevCount: ', prevCount);
    console.log('currentCount: ', count);
    setPrev(prevCount)
  })

  const add = () => setCount(prevCount => prevCount + 1)

  return (
    <div>
      <p> 当前的count是{count}</p>
      <p> 前一次的count是{prev}</p>
      {count}
      <button onClick={add} className="btn">+</button>
      <button onClick={stop} className="btn">停止观察旧值</button>
    </div>
  )
}

源码地址:

https://github.com/sl1673495/use-watch-hook

文档地址:

文档是基于docz生成的,配合mdx还可以实现非常好用的功能预览:
https://sl1673495.github.io/use-watch-hook

Vue 的计算属性真的会缓存吗?(原理揭秘)

前言

很多人提起 Vue 中的 computed,第一反应就是计算属性会缓存,那么它到底是怎么缓存的呢?缓存的到底是什么,什么时候缓存会失效,相信还是有很多人对此很模糊。

本文以 Vue 2.6.11 版本为基础,就深入原理,带你来看看所谓的缓存到底是什么样的。

注意

本文假定你对 Vue 响应式原理已经有了基础的了解,如果对于 WatcherDep和什么是 渲染watcher 等概念还不是很熟悉的话,可以先找一些基础的响应式原理的文章或者教程看一下。视频教程的话推荐黄轶老师的,如果想要看简化实现,也可以先看我写的文章:

手把手带你实现一个最精简的响应式系统来学习Vue的data、computed、watch源码

注意,这篇文章里我也写了 computed 的原理,但是这篇文章里的 computed 是基于 Vue 2.5 版本的,和当前 2.6 版本的变化还是非常大的,可以仅做参考。

示例

按照我的文章惯例,还是以一个最简的示例来演示。

<div id="app">
  <span @click="change">{{sum}}</span>
</div>
<script src="./vue2.6.js"></script>
<script>
  new Vue({
    el: "#app",
    data() {
      return {
        count: 1,
      }
    },
    methods: {
      change() {
        this.count = 2
      },
    },
    computed: {
      sum() {
        return this.count + 1
      },
    },
  })
</script>

这个例子很简单,刚开始页面上显示数字 2,点击数字后变成 3

解析

回顾 watcher 的流程

进入正题,Vue 初次运行时会对 computed 属性做一些初始化处理,首先我们回顾一下 watcher 的概念,它的核心概念是 get 求值,和 update 更新。

  1. 在求值的时候,它会先把自身也就是 watcher 本身赋值给 Dep.target 这个全局变量。

  2. 然后求值的过程中,会读取到响应式属性,那么响应式属性的 dep 就会收集到这个 watcher 作为依赖。

  3. 下次响应式属性更新了,就会从 dep 中找出它收集到的 watcher,触发 watcher.update() 去更新。

所以最关键的就在于,这个 get 到底用来做什么,这个 update 会触发什么样的更新。

在基本的响应式更新视图的流程中,以上概念的 get 求值就是指 Vue 的组件重新渲染的函数,而 update 的时候,其实就是重新调用组件的渲染函数去更新视图。

而 Vue 中很巧妙的一点,就是这套流程也同样适用于 computed 的更新。

初始化 computed

这里先提前剧透一下,Vue 会对 options 中的每个 computed 属性也用 watcher 去包装起来,它的 get 函数显然就是要执行用户定义的求值函数,而 update 则是一个比较复杂的流程,接下来我会详细讲解。

首先在组件初始化的时候,会进入到初始化 computed 的函数

if (opts.computed) { initComputed(vm, opts.computed); }

进入 initComputed 看看

var watchers = vm._computedWatchers = Object.create(null);

// 依次为每个 computed 属性定义
for (const key in computed) {
  const userDef = computed[key]
  watchers[key] = new Watcher(
      vm, // 实例
      getter, // 用户传入的求值函数 sum
      noop, // 回调函数 可以先忽视
      { lazy: true } // 声明 lazy 属性 标记 computed watcher
  )

  // 用户在调用 this.sum 的时候,会发生的事情
  defineComputed(vm, key, userDef)
}

首先定义了一个空的对象,用来存放所有计算属性相关的 watcher,后文我们会把它叫做 计算watcher

然后循环为每个 computed 属性生成了一个 计算watcher

它的形态保留关键属性简化后是这样的:

{
    deps: [],
    dirty: true,
    getter: ƒ sum(),
    lazy: true,
    value: undefined
}

可以看到它的 value 刚开始是 undefined,lazy 是 true,说明它的值是惰性计算的,只有到真正在模板里去读取它的值后才会计算。

这个 dirty 属性其实是缓存的关键,先记住它。

接下来看看比较关键的 defineComputed,它决定了用户在读取 this.sum 这个计算属性的值后会发生什么,继续简化,排除掉一些不影响流程的逻辑。

Object.defineProperty(target, key, { 
    get() {
        // 从刚刚说过的组件实例上拿到 computed watcher
        const watcher = this._computedWatchers && this._computedWatchers[key]
        if (watcher) {
          // ✨ 注意!这里只有dirty了才会重新求值
          if (watcher.dirty) {
            // 这里会求值 调用 get
            watcher.evaluate()
          }
          // ✨ 这里也是个关键 等会细讲
          if (Dep.target) {
            watcher.depend()
          }
          // 最后返回计算出来的值
          return watcher.value
        }
    }
})

这个函数需要仔细看看,它做了好几件事,我们以初始化的流程来讲解它:

首先 dirty 这个概念代表脏数据,说明这个数据需要重新调用用户传入的 sum 函数来求值了。我们暂且不管更新时候的逻辑,第一次在模板中读取到 {{sum}} 的时候它一定是 true,所以初始化就会经历一次求值。

evaluate () {
  // 调用 get 函数求值
  this.value = this.get()
  // 把 dirty 标记为 false
  this.dirty = false
}

这个函数其实很清晰,它先求值,然后把 dirty 置为 false。

再回头看看我们刚刚那段 Object.defineProperty 的逻辑,

下次没有特殊情况再读取到 sum 的时候,发现 dirty是false了,是不是直接就返回 watcher.value 这个值就可以了,这其实就是计算属性缓存的概念。

更新

初始化的流程讲完了,相信大家也对 dirty缓存 有了个大概的概念(如果没有,再仔细回头看一看)。

接下来就讲更新的流程,细化到本文的例子中,也就是 count 的更新到底是怎么触发 sum 在页面上的变更。

首先回到刚刚提到的 evalute 函数里,也就是读取 sum 时发现是脏数据的时候做的求值操作。

evaluate () {
  // 调用 get 函数求值
  this.value = this.get()
  // 把 dirty 标记为 false
  this.dirty = false
}

Dep.target 变更为 渲染watcher

这里进入 this.get(),首先要明确一点,在模板中读取 {{ sum }} 变量的时候,全局的 Dep.target 应该是 渲染watcher,这里不理解的话可以到我最开始提到的文章里去理解下。

全局的 Dep.target 状态是用一个栈 targetStack 来保存,便于前进和回退 Dep.target,至于什么时候会回退,接下来的函数里就可以看到。

此时的 Dep.target 是 渲染watcher,targetStack 是 [ 渲染watcher ] 。
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } finally {
    popTarget()
  }
  return value
}

首先刚进去就 pushTarget,也就是把 计算watcher 自身置为 Dep.target,等待收集依赖。

执行完 pushTarget(this) 后,

Dep.target 变更为 计算watcher

此时的 Dep.target 是 计算watcher,targetStack 是 [ 渲染watcher,计算watcher ] 。

getter 函数,上一章的 watcher 形态里已经说明了,其实就是用户传入的 sum 函数。

sum() {
    return this.count + 1
}

这里在执行的时候,读取到了 this.count,注意它是一个响应式的属性,所以冥冥之中它们开始建立了千丝万缕的联系……

这里会触发 countget 劫持,简化一下

// 在闭包中,会保留对于 count 这个 key 所定义的 dep
const dep = new Dep()

// 闭包中也会保留上一次 set 函数所设置的 val
let val

Object.defineProperty(obj, key, {
  get: function reactiveGetter () {
    const value = val
    // Dep.target 此时就是计算watcher
    if (Dep.target) {
      // 收集依赖
      dep.depend()
    }
    return value
  },
})

那么可以看出,count 会收集 计算watcher 作为依赖,具体怎么收集呢

// dep.depend()
depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}

其实这里是调用 Dep.target.addDep(this) 去收集,又绕回到 计算watcheraddDep 函数上去了,这其实主要是 Vue 内部做了一些去重的优化。

// watcher 的 addDep函数
addDep (dep: Dep) {
  // 这里做了一系列的去重操作 简化掉 
  
  // 这里会把 count 的 dep 也存在自身的 deps 上
  this.deps.push(dep)
  // 又带着 watcher 自身作为参数
  // 回到 dep 的 addSub 函数了
  dep.addSub(this)
}

又回到 dep 上去了。

class Dep {
  subs = []

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
}

这样就保存了 计算watcher 作为 count 的 dep 里的依赖了。

经历了这样的一个收集的流程后,此时的一些状态:

sum 的计算watcher

{
    deps: [ count的dep ],
    dirty: false, // 求值完了 所以是false
    value: 2, // 1 + 1 = 2
    getter: ƒ sum(),
    lazy: true
}

count的dep:

{
    subs: [ sum的计算watcher ]
}

可以看出,计算属性的 watcher 和它所依赖的响应式值的 dep,它们之间互相保留了彼此,相依为命。

此时求值结束,回到 计算watchergetter 函数:

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } finally {
    // 此时执行到这里了
    popTarget()
  }
  return value
}

执行到了 popTarget计算watcher 出栈。

Dep.target 变更为 渲染watcher

此时的 Dep.target 是 渲染watcher,targetStack 是 [ 渲染watcher ] 。

然后函数执行完毕,返回了 2 这个 value,此时对于 sum 属性的 get 访问还没结束。

Object.defineProperty(vm, 'sum', { 
    get() {
          // 此时函数执行到了这里
          if (Dep.target) {
            watcher.depend()
          }
          return watcher.value
        }
    }
})

此时的 Dep.target 当然是有值的,就是 渲染watcher,所以进入了 watcher.depend() 的逻辑,这一步相当关键

// watcher.depend
depend () {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

还记得刚刚的 计算watcher 的形态吗?它的 deps 里保存了 count 的 dep。

也就是说,又会调用 count 上的 dep.depend()

class Dep {
  subs = []
  
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}

这次的 Dep.target 已经是 渲染watcher 了,所以这个 count 的 dep 又会把 渲染watcher 存放进自身的 subs 中。

count的dep:

{
    subs: [ sum的计算watcher,渲染watcher ]
}

那么来到了此题的重点,这时候 count 更新了,是如何去触发视图更新的呢?

再回到 count 的响应式劫持逻辑里去:

// 在闭包中,会保留对于 count 这个 key 所定义的 dep
const dep = new Dep()

// 闭包中也会保留上一次 set 函数所设置的 val
let val

Object.defineProperty(obj, key, {
  set: function reactiveSetter (newVal) {
      val = newVal
      // 触发 count 的 dep 的 notify
      dep.notify()
    }
  })
})

好,这里触发了我们刚刚精心准备的 count 的 dep 的 notify 函数,感觉离成功越来越近了。

class Dep {
  subs = []
  
  notify () {
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

这里的逻辑就很简单了,把 subs 里保存的 watcher 依次去调用它们的 update 方法,也就是

  1. 调用 计算watcher 的 update
  2. 调用 渲染watcher 的 update

拆解来看。

计算watcher 的 update

update () {
  if (this.lazy) {
    this.dirty = true
  }
}

wtf,就这么一句话…… 没错,就仅仅是把 计算watcherdirty 属性置为 true,静静的等待下次读取即可。

渲染watcher 的 update

这里其实就是调用 vm._update(vm._render()) 这个函数,重新根据 render 函数生成的 vnode 去渲染视图了。

而在 render 的过程中,一定会访问到 sum 这个值,那么又回回到 sum 定义的 get 上:

Object.defineProperty(target, key, { 
    get() {
        const watcher = this._computedWatchers && this._computedWatchers[key]
        if (watcher) {
          // ✨上一步中 dirty 已经置为 true, 所以会重新求值
          if (watcher.dirty) {
            watcher.evaluate()
          }
          if (Dep.target) {
            watcher.depend()
          }
          // 最后返回计算出来的值
          return watcher.value
        }
    }
})

由于上一步中的响应式属性更新,触发了 计算 watcherdirty 更新为 true。 所以又会重新调用用户传入的 sum 函数计算出最新的值,页面上自然也就显示出了最新的值。

至此为止,整个计算属性更新的流程就结束了。

缓存生效的情况

根据上面的总结,只有计算属性依赖的响应式值发生更新的时候,才会把 dirty 重置为 true,这样下次读取的时候才会发生真正的计算。

这样的话,假设 sum 函数是一个用户定义的一个比较耗费时间的操作,优化就比较明显了。

<div id="app">
  <span @click="change">{{sum}}</span>
  <span @click="changeOther">{{other}}</span>
</div>
<script src="./vue2.6.js"></script>
<script>
  new Vue({
    el: "#app",
    data() {
      return {
        count: 1,
        other: 'Hello'
      }
    },
    methods: {
      change() {
        this.count = 2
      },
      changeOther() {
        this.other = 'ssh'
      }
    },
    computed: {
      // 非常耗时的计算属性
      sum() {
        let i = 100000
        while(i > 0) {
            i--
        }
        return this.count + 1
      },
    },
  })
</script>

在这个例子中,other 的值和计算属性没有任何关系,如果 other 的值触发更新的话,就会重新渲染视图,那么会读取到 sum,如果计算属性不做缓存的话,每次都要发生一次很耗费性能的没有必要的计算。

所以,只有在 count 发生变化的时候,sum 才会重新计算,这是一个很巧妙的优化。

总结

2.6 版本计算属性更新的路径是这样的:

  1. 响应式的值 count 更新
  2. 同时通知 computed watcher渲染 watcher 更新
  3. omputed watcher 把 dirty 设置为 true
  4. 视图渲染读取到 computed 的值,由于 dirty 所以 computed watcher 重新求值。

通过本篇文章,相信你可以完全理解计算属性的缓存到底是什么概念,在什么样的情况下才会生效了吧?

对于缓存和不缓存的情况,分别是这样的流程:

不缓存:

  1. count 改变,先通知到 计算watcher 更新,设置 dirty = true
  2. 再通知到 渲染watcher 更新,视图重新渲染的时候去 计算watcher 中读取值,发现 dirty 是 true,重新执行用户传入的函数求值。

缓存:

  1. other 改变,直接通知 渲染watcher 更新。
  2. 视图重新渲染的时候去 计算watcher 中读取值,发现 dirty 为 false,直接用缓存值 watcher.value,不执行用户传入的函数求值。

展望

事实上这种通过 dirty 标志位来实现计算属性缓存的方式,和 Vue3 中的实现原理是一致的。这可能也说明在各种需求和社区反馈的千锤百炼下,尤大目前认为这种方式是实现 computed 缓存的相对最优解了。

如果对 Vue3 的 computed 实现感兴趣的同学,还可以看我的这篇文章,原理大同小异。只是收集的方式稍有变化。

深度解析:Vue3如何巧妙的实现强大的computed

❤️感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

Vue源码学习 nextTick

vue在视图更新的时候是异步更新,这个很多人已经知道了,这么做的好处有很多,今天我们就来看看vue是如何调度这个异步更新队列去优化性能的。

src/core/util/next-tick.js

/* @flow */
/* globals MessageChannel */

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using (macro) tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use microtask by default, but expose a way to force (macro) task when
// needed (e.g. in event handlers attached by v-on).
let microTimerFunc
let macroTimerFunc
let useMacroTask = false

// Determine (macro) task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// Determine microtask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // in problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

/**
 * Wrap a function so that if any code inside triggers state change,
 * the changes are queued using a (macro) task instead of a microtask.
 */
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

首先这个文件的开头定义了两个全局变量

const callbacks = []
let pending = false

callbacks用来存放我们需要异步执行的函数队列,
pending用来标记是否已经命令callbacks在下个tick全部执行,防止多次调用。

入口

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

我们在外部调用都是nextTick(() => { // doSth })
这样子去使用, 把一个cb函数传入nextTick函数中,
nextTick函数首先

callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })

把我们的cb函数包装了一层,做了判断,这是为了nextTick可以用then方法,我们就暂且当做直接把cb函数push进callbacks队列吧。

我们需要知道的是microTask是在同步方法完成的末尾去执行, macroTask则是直接是到下一个task了,task之间又可能会包含浏览器的重渲染,setTimeout默认的4ms延迟等等...从性能和时效性来看都是microTask更为优先。

关于macroTask和microTask的区别不是本文的重点,如果有需要的小伙伴可以去查阅一下浏览器的eventLoop相关的知识点。

随后如果pending的标志位还没有置为true,就把pending置为true,
并且开始根据useMacroTask这个标志判断 nextTick是通过macroTask实现还是microTask实现,
并且去调用这个task,这样在下一个tick就会去把callbacks里的方法全部执行。

  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }

判断macroTask和microTask该用什么api

回到这个文件的上半部分

let microTimerFunc
let macroTimerFunc
let useMacroTask = false

首先定义了3个全局变量, 可以看到useMacroTask默认为false,接下来就要开始根据浏览器的api兼容性判断,用什么来实现microTimerFunc和macroTimerFunc

接下来vue开始判断如何实现macroTimerFunc

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

这段方法就是判断macroTask优先去使用setImmediate, 其次是MessageChannel,最次是setTimeout。

接下来去判断microTask

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
} else {
  microTimerFunc = macroTimerFunc
}

microTask只有Promise一个选项,如果浏览器没有提供promise的api 就只能降级为上面判断的macroTimerFunc了。

在下个tick执行异步队列

无论是microTask还是macroTask 传入的方法都是flushCallbacks,那这个肯定就是执行callbacks的方法了,我们来看看这个方法的定义

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

这个方法很简短,
把pendding置为false,把callbacks拷贝一份并且把callbacks清空
这是为了在nextTick的方法里再次调用nextTick,能够新开一个异步队列,
然后循环这个拷贝callbacks里的函数, 一次性执行完毕,

vue的异步队列调度就是这样实现的,
希望在大家在工作中也能运用这种**, 把影响性能而且能合并的方法异步合并执行。

TypeScript进阶实现智能类型推导的简化版Vuex

之前几篇讲TypeScript的文章中,我带来了在React中的一些小实践

React + TypeScript + Hook 带你手把手打造类型安全的应用。

React Hook + TypeScript 手把手带你打造use-watch自定义Hook,实现Vue中的watch功能。

这篇文章我决定更进一步,直接用TypeScript实现一个类型安全的简易版的Vuex。

这篇文章适合谁:

  1. 已经学习TypeScript基础,需要一点进阶玩法的你。
  2. 自己喜欢写一些开源的小工具,需要进阶学习TypeScript类型推导。(在项目中一般ts运用的比较浅层,大部分情况在写表面的interface)。
  3. 单纯的想要进阶学习TypeScript。

通过这篇文章,你可以学到以下特性在实战中是如何使用的:

  1. 🎉TypeScript的高级类型(Advanced Type
  2. 🎉TypeScript中利用泛型进行反向类型推导。(Generics)
  3. 🎉Mapped types(映射类型)
  4. 🎉Distributive Conditional Types(条件类型分配)
  5. 🎉TypeScript中Infer的实战应用(Vue3源码里infer的一个很重要的使用

希望通过这篇文章,你可以对TypeScript的高级类型实战应用得心应手,对于未来想学习Vue3源码的小伙伴来说,类型推断和infer的用法也是必须熟悉的。

写在前面:

本文实现的Vuex只有很简单的stateactionsubscribeAction功能,因为Vuex当前的组织模式非常不适合类型推导(Vuex官方的type库目前推断的也很简陋),所以本文中会有一些和官方不一致的地方,这些是刻意的为了类型安全而做的,本文的主要目标是学习TypeScript,而不是学习Vuex,所以请小伙伴们不要嫌弃它代码啰嗦或者和Vuex不一致。 🚀

vuex骨架

首先定义我们Vuex的骨架。

export default class Vuex<S, A> {
  state: S

  action: Actions<S, A>

  constructor({ state, action }: { state: S; action: Actions<S, A> }) {
    this.state = state;
    this.action = action;
  }

  dispatch(action: any) {
  }
}

首先这个Vuex构造函数定了两个泛型SA,这是因为我们需要推出stateaction的类型,由于subscribeAction的参数中需要用到state和action的类型,dispatch中则需要用到action的key的类型(比如dispatch({type: "ADD"})中的type需要由对应 actions: { ADD() {} })的key值推断。

然后在构造函数中,把S和state对应,把Actions<S, A>和传入的action对应。

constructor({ state, action }: { state: S; action: Actions<S, A> }) {
  this.state = state;
  this.action = action;
}

Actions这里用到了映射类型,它等于是遍历了传入的A的key值,然后定义每一项实际上的结构,

export type Actions<S, A> = {
  [K in keyof A]: (state: S, payload: any) => Promise<any>;
};

看看我们传入的actions

const store = new Vuex({
  state: {
    count: 0,
    message: '',
  },
  action: {
    async ADD(state, payload) {
      state.count += payload;
    },
    async CHAT(state, message) {
      state.message = message;
    },
  },
});

是不是类型正好对应上了?此时ADD函数的形参里的state就有了类型推断,它就是我们传入的state的类型。

state

这是因为我们给Vuex的构造函数传入state的时候,S就被反向推导为了state的类型,也就是{count: number, message: string},这时S又被传给了Actions<S, A>, 自然也可以在action里获得state的类型了。

现在有个问题,我们现在的写法里没有任何地方能体现出payload的类型,(这也是Vuex设计所带来的一些缺陷)所以我们也只能写成any,但是我们本文的目标是类型安全。

dispatch的类型安全

下面先想点办法实现store.dispatch的类型安全:

  1. type需要自动提示。
  2. type填写了以后,需要提示对应的payload的type。

所以参考redux的玩法,我们手动定义一个Action Types的联合类型。

const ADD = 'ADD';
const CHAT = 'CHAT';

type AddType = typeof ADD;
type ChatType = typeof CHAT;

type ActionTypes =
  | {
      type: AddType;
      payload: number;
    }
  | {
      type: ChatType;
      payload: string;
    };

Vuex中,我们新增一个辅助Ts推断的方法,这个方法原封不动的返回dispatch函数,但是用了as关键字改写它的类型,我们需要把ActionTypes作为泛型传入:

export default class Vuex<S, A> {
  ... 
  
  createDispatch<A>() {
    return this.dispatch.bind(this) as Dispatch<A>;
  }
}

Dispatch类型的实现相当简单,直接把泛型A交给第一个形参action就好了,由于ActionTypes是联合类型,Ts会严格限制我们填写的action的类型必须是AddType或者ChatType中的一种,并且填写了AddType后,payload的类型也必须是number了。

export interface Dispatch<A> {
  (action: A): any;
}

然后使用它构造dispatch

// for TypeScript support
const dispatch = store.createDispatch<ActionTypes>();

目标达成:

type

payload

action形参中payload的类型安全

此时虽然store.diaptch完全做到了类型安全,但是在声明action传入vuex构造函数的时候,我不想像这样手动声明,

const store = new Vuex({
  state: {
    count: 0,
    message: '',
  },
  action: {
    async [ADD](state, payload: number) {
      state.count += payload;
    },
    async [CHAT](state, message: string) {
      state.message = message;
    },
  },
});  

因为这个类型在刚刚定义的ActionTypes中已经有了,秉着DRY的原则,我们继续折腾吧。

首先现在我们有这些佐料:

const ADD = 'ADD';
const CHAT = 'CHAT';

type AddType = typeof ADD;
type ChatType = typeof CHAT;

type ActionTypes =
  | {
      type: AddType;
      payload: number;
    }
  | {
      type: ChatType;
      payload: string;
    };

所以我想通过一个类型工具,能够传入AddType给我返回number,传入ChatType给我返回message:

它大概是这个样子的:

type AddPayload = PickPayload<ActionTypes, AddType> // number
type ChatPayload = PickPayload<ActionTypes, ChatType> // string

为了实现它,我们需要用到distributive-conditional-types,不熟悉的同学可以好好看看这篇文章。

简单的来说,如果我们把一个联合类型

type A = string | number

传递给一个用了extends关键字的类型工具:

type PickString<T> = T extends string ? T: never

type T1 = PickString<A> // string

它并不是像我们想象中的直接去用string | number直接匹配是否extends,而是把联合类型拆分开来,一个个去匹配。

type PickString<T> = 
| string extends string ? T: never 
| number extends string ? T: never

所以返回的类型是string | never,由由于never在联合类型中没什么意义,所以就被过滤成string

借由这个特性,我们就有思路了,这里用到了infer这个关键字,Vue3中也有很多推断是借助它实现的,它只能用在extends的后面,代表一个还未出现的类型,关于infer的玩法,详细可以看这篇文章:巧用 TypeScript(五)---- infer

export type PickPayload<Types, Type> = Types extends {
  type: Type;
  payload: infer P;
}
  ? P
  : never;

我们用Type这个字符串类型,让ActionTypes中的每一个类型一个个去过滤匹配,比如传入的是AddType:

PickPayload<ActionTypes, AddType>

则会被分布成:

type A = 
  | { type: AddType;payload: number;} extends { type: AddType; payload: infer P }
  ? P
  : never 
  | 
  { type: ChatType; payload: string } extends { type: AddType; payload: infer P }
  ? P
  : never;

注意infer P的位置,被放在了payload的位置上,所以第一项的type在命中后, P也被自动推断为了number,而三元运算符的 ? 后,我们正是返回了P,也就推断出了number这个类型。

这时候就可以完成我们之前的目标了,也就是根据AddType这个类型推断出payload参数的类型,PickPayload这个工具类型应该定位成vuex官方仓库里提供的辅助工具,而在项目中,由于ActionType已经确定,所以我们可以进一步的提前固定参数。(有点类似于函数柯里化)

type PickStorePayload<T> = PickPayload<ActionTypes, T>;

此时,我们定义一个类型安全的Vuex实例所需要的所有辅助类型都定义完毕:

const ADD = 'ADD';
const CHAT = 'CHAT';

type AddType = typeof ADD;
type ChatType = typeof CHAT;

type ActionTypes =
  | {
      type: AddType;
      payload: number;
    }
  | {
      type: ChatType;
      payload: string;
    };

type PickStorePayload<T> = PickPayload<ActionTypes, T>;

使用起来就很简单了:

const store = new Vuex({
  state: {
    count: 0,
    message: '',
  },
  action: {
    async [ADD](state, payload: PickStorePayload<AddType>) {
      state.count += payload;
    },
    async [CHAT](state, message: PickStorePayload<ChatType>) {
      state.message = message;
    },
  },
});

// for TypeScript support
const dispatch = store.createDispatch<ActionTypes>();

dispatch({
  type: ADD,
  payload: 3,
});

dispatch({
  type: CHAT,
  payload: 'Hello World',
});

总结

本文的所有代码都在
https://github.com/sl1673495/tiny-middlewares/blob/master/vuex.ts
仓库里,里面还加上了getters的实现和类型推导。

通过本文的学习,相信你会对高级类型的用法有进一步的理解,也会对TypeScript的强大更加叹服,本文有很多例子都是为了教学而刻意深究,复杂化的,请不要骂我(XD)。

在实际的项目运用中,首先我们应该避免Vuex这种集中化的类型定义,而尽量去拥抱函数(函数对于TypeScript是天然支持),这也是Vue3往函数化api方向走的原因之一。

参考文章

React + Typescript 工程化治理实践(蚂蚁金服的大佬实践总结总是这么靠谱)
https://juejin.im/post/5dccc9b8e51d4510840165e2#comment

TS 学习总结:编译选项 && 类型相关技巧
http://zxc0328.github.io/diary/2019/10/2019-10-05.html

Conditional types in TypeScript(据说比Ts官网讲的好)
https://mariusschulz.com/blog/conditional-types-in-typescript#distributive-conditional-types

Conditional Types in TypeScript(文风幽默,代码非常硬核)
https://artsy.github.io/blog/2018/11/21/conditional-types-in-typescript/

Vue3 TypeScript 之 Ref 类型从零实现

Vue3 中,ref 是一个新出现的 api,不太了解这个 api 的小伙伴可以先看 官方api文档

简单介绍来说,响应式的属性依赖一个复杂类型的载体,想象一下这样的场景,你有一个数字 count 需要响应式的改变。

const count = reactive(2)

// ❌ 什么鬼
count = 3

这样肯定是无法触发响应式的,因为 Proxy 需要对一个复杂类型上的某个属性的访问进行拦截,而不是直接拦截一个变量的改变。

于是就有了 ref 这个函数,它会为简单类型的值生成一个形为 { value: T } 的包装,这样在修改的时候就可以通过 count.value = 3 去触发响应式的更新了。

const count = ref(2)

// ✅ (*^▽^*) 完全可以
count.value = 3

那么,ref 函数所返回的类型 Ref,就是本文要讲解的重点了。

为什么说 Ref 是个比较复杂的类型呢?假如 ref 函数中又接受了一个 Ref 类型的参数呢?Vue3 内部其实是会帮我们层层解包,只剩下最里层的那个 Ref 类型。

它是支持嵌套后解包的,最后只会剩下 { value: number } 这个类型。

const count = ref(ref(ref(ref(2))))

这是一个好几层的嵌套,按理来说应该是 count.value.value.value.value 才会是 number,但是在 vscode 中,鼠标指向 count.value 这个变量后,提示出的类型就是 number,这是怎么做到的呢?

本文尝试给出一种捷径,通过逐步实现这个复杂需求,来倒推出 TS 的高级技巧需要学习哪些知识点。

  1. 泛型的反向推导。
  2. 索引签名
  3. 条件类型
  4. keyof
  5. infer

先逐个拆解这些知识点吧,注意,如果本文中的这些知识点还有所不熟,一定要在代码编辑器中反复敲击调试,刻意练习,也可以在 typescript-playground 中尽情玩耍。

泛型的反向推导

泛型的正向用法很多人都知道了。

type Value<T> = T

type NumberValue = Value<number>

这样,NumberValue 解析出的类型就是 number,其实就类似于类型系统里的传参。

那么反向推导呢?

function create<T>(val: T): T

let num: number

const c= create(num)

在线调试

这里泛型没有传入,居然也能推断出 value 的类型是 number。

因为 create<T> 这里的泛型 T 被分配给了传入的参数 value: T,然后又用这个 T 直接作为返回的类型,

简单来说,这里的三个 T 被关联起来了,并且在传入 create(2) 的那一刻,这个 T 被统一推断成了 number。

function create<2>(value: 2): 2

阅读资料

具体可以看文档里的泛型章节

索引签名

假设我们有一个这样的类型:

type Test = {
  foo: number;
  bar: string
}

type N = Test['foo'] // number

可以通过类似 JavaScript 中的对象属性查找的语法来找出对应的类型。

具体可以看这里的介绍,有比较详细的例子。

条件类型

假设我们有一个这样的类型:

type IsNumber<T> = T extends number ? 'yes' : 'no';

type A = IsNumber<2> // yes
type B = isNumber<'3'> // no

在线调试

这就是一个典型的条件类型,用 extends 关键字配合三元运算符来判断传入的泛型是否可分配给 extends 后面的类型。

同时也支持多层的三元运算符(后面会用到):

type TypeName<T> = T extends string
  ? "string"
  : T extends boolean
      ? "boolean"
      : "object";

type T0 = TypeName<string>; // "string"
type T1 = TypeName<"a">; // "string"
type T2 = TypeName<true>; // "boolean"

阅读资料

具体讲解可以看文档中的 conditional types 部分。

keyof

keyof 操作符是 TS 中用来获取对象的 key 值集合的,比如:

type Obj = {
  foo: number;
  bar: string;
}

type Keys = keyof Obj // "foo" | "bar"

这样就轻松获取到了对象 key 值的联合类型:"foo" | "bar"

它也可以用在遍历中:

type Obj = {
  foo: number;
  bar: string;
}

type Copy = {
  [K in keyof Obj]: Obj[K]
}

// Copy 得到和 Obj 一模一样的类型

在线调试

可以看出,遍历的过程中右侧也可以通过索引直接访问到原类型 Obj 中对应 key 的类型。

阅读资料

index-types

infer

这是一个比较难的点,文档中对它的描述是 条件类型中的类型推断

它的出现使得 ReturnTypeParameters 等一众工具类型的支持都成为可能,是 TypeScript 进阶必须掌握的一个知识点了。

注意前置条件,它一定是出现在条件类型中的。

type Get<T> = T extends infer R ? R: never

注意,infer R 的位置代表了一个未知的类型,可以理解为在条件类型中给了它一个占位符,然后就可以在后面的三元运算符中使用它。

type T = Get<number>

// 经过计算
type Get<number> = number extends infer number ? number: never

// 得到
number

它的使用非常灵活,它也可以出现在泛型位置:

type Unpack<T> = T extends Array<infer R> ? R : T
type NumArr = Array<number>
type U = Unpack<NumArr>

// 经过计算
type Unpack<Array<number>> = Array<number> extends Array<infer R> ? R : T

// 得到
number

在线调试

仔细看看,是不是有那么点感觉了,它就是对于 extends 后面未知的某些类型进行一个占位 infer R,后续就可以使用推断出来的 R 这个类型。

阅读资料

官网文档

巧用 TypeScript(五)-- infer

简化实现

好了,有了这么多的前置知识,我们来摩拳擦掌尝试实现一下这个 Ref 类型。

我们已经了解到,ref 这个函数就是把一个值包裹成 {value: T} 这样的结构:

我们的目的是,让 ref(ref(ref(2))) 这种嵌套用法,也能顺利的提示出 number 类型。

ref

// 这里用到了泛型的默认值语法 <T = any>
type Ref<T = any> = {
  value: T
}

function ref<T>(value: T): Ref<T>

const count = ref(2)

count.value // number

默认情况很简单,结合了我们上面提到的几个小知识点很快就能做出来。

如果传入给函数的 value 也是一个 Ref 类型呢?是不是很快就想到 extends 关键字了。

function ref<T>(value: T): T extends Ref 
  ? T 
  : Ref<UnwarpRef<T>>

先解读 T extends Ref 的情况,如果 valueRef 类型,函数的返回值就原封不动的是这个 Ref 类型。

那么对于 ref(ref(2)) 这种类型来说,内层的 ref(2) 返回的是 Ref<number> 类型,

外层的 ref 读取到 ref(Ref<number>) 这个类型以后,

由于此时的 value 符合 extends Ref 的定义,

所以 Ref<number> 又被原封不动的返回了,这就形成了解包。

那么关键点就在于后半段逻辑,Ref<UnwarpRef<T>> 是怎么实现的,

它用来决定 ref(2) 返回的是 Ref<number>

并且嵌套的对象 ref({ a: 1 }),返回 Ref<{ a: number }>

并且嵌套的对象中包含 Ref 类型也会被解包:

const count = ref({
  foo: ref('1'),
  bar: ref(2)
})

// 推断出
const count: Ref<{
  foo: string;
  bar: number;
}>

那么其实本文的关键也就在于,应该如何实现这个 UnwarpRef 解包函数了。

根据我们刚刚学到的 infer 知识,从 Ref 的泛型中提取出它的泛型类型并不难:

UnwarpRef

type UnwarpRef<T> = T extends Ref<infer R> ? R : T

UnwarpRef<Ref<number>> // number

但这只是单层解包,如果 infer R 中的 R 还是 Ref 类型呢?

我们自然的想到了递归声明这个 UnwarpRef 类型:

// X! Type alias 'UnwarpRef' circularly references itself.ts(2456)
type UnwarpRef<T> = T extends Ref<infer R> 
    ? UnwarpRef<R> 
    : T

报错了,不允许循环引用自己!

递归 UnwarpRef

但是到此为止了吗?当然没有,有一种机制可以绕过这个递归限制,那就是配合 索引签名,并且增加其他的能够终止递归的条件,在本例中就是 other 这个索引,它原样返回 T 类型。

type UnwarpRef<T> = {
  ref: T extends Ref<infer R> ? UnwarpRef<R> : T
  other: T
}[T extends Ref ? 'ref' : 'other']

支持字符串和数字

拆解开来看这个类型,首先假设我们调用了 ref(ref(2)) 我们其实会传给 UnwarpRef 一个泛型:

UnwarpRef<Ref<Ref<number>>>

那么第一次走入 [T extends Ref ? 'ref' : 'other'] 这个索引的时候,匹配到的是 ref 这个字符串,然后它去

type UnwarpRef<Ref<Ref<number>>> = {
  // 注意这里和 infer R 对应位置的匹配 得到的是 Ref<number>
  ref: Ref<Ref<number>> extends Ref<infer R> ? UnwarpRef<R> : T
}['ref']

匹配到了 ref 这个索引,然后通过用 Ref<Ref<number>> 去匹配 Ref<infer R> 拿到 R 也就是解包了一层过后的 Ref<number>

再次传给 UnwarpRef<Ref<number>> ,又经过同样的逻辑解包后,这次只剩下 number 类型传递了。

也就是 UnwarpRef<number>,那么这次就不太一样了,索引签名计算出来是 ['other']

也就是

type UnwarpRef<number> = {
  other: number
}['other']

自然就解包得到了 number 这个类型,终止了递归。

支持对象

考虑一下这种场景:

const count = ref({
  foo: ref(1),
  bar: ref(2)
})

那么,count.value.foo 推断的类型应该是 number,这需要我们用刚刚的遍历索引和 keyof 的知识来做,并且在索引签名中再增加对 object 类型的支持:

type UnwarpRef<T> = {
  ref: T extends Ref<infer R> ? UnwarpRef<R> : T
  // 注意这里
  object: { [K in keyof T]: UnwarpRef<T[K]> }
  other: T
}[T extends Ref 
  ? 'ref' 
  : T extends object 
    ? 'object' 
    : 'other']

这里在遍历 K in keyof T 的时候,只要对值类型 T[K] 再进行解包 UnwarpRef<T[K]> 即可,如果 T[K] 是个 Ref 类型,则会拿到 Refvalue 的原始类型。

简化版完整代码

type Ref<T = any> = {
  value: T
}

type UnwarpRef<T> = {
  ref: T extends Ref<infer R> ? UnwarpRef<R> : T
  object: { [K in keyof T]: UnwarpRef<T[K]> }
  other: T
}[T extends Ref 
  ? 'ref' 
  : T extends object 
    ? 'object' 
    : 'other']

function ref<T>(value: T): T extends Ref ? T : Ref<UnwarpRef<T>>

在线调戏最终版

源码

这里还是放一下 Vue3 里的源码,在源码中对于数组、对象和计算属性的 ref 也做了相应的处理,但是相信经过了上面简化版的实现后,你对于这个复杂版的原理也可以进一步的掌握了吧。

export interface Ref<T = any> {
  [isRefSymbol]: true
  value: T
}

export function ref<T>(value: T): T extends Ref ? T : Ref<UnwrapRef<T>>

export type UnwrapRef<T> = {
  cRef: T extends ComputedRef<infer V> ? UnwrapRef<V> : T
  ref: T extends Ref<infer V> ? UnwrapRef<V> : T
  array: T
  object: { [K in keyof T]: UnwrapRef<T[K]> }
}[T extends ComputedRef<any>
  ? 'cRef'
  : T extends Array<any>
    ? 'array'
    : T extends Ref | Function | CollectionTypes | BaseTypes
      ? 'ref' // bail out on types that shouldn't be unwrapped
      : T extends object ? 'object' : 'ref']

乍一看很劝退,没错,我一开始也被这段代码所激励,开始了为期几个月的 TypeScript 恶补生涯。资料真的很难找,这里面涉及的一些高级技巧需要经过反复的练习和实践,才能学下来并且自如的运用出来。

总结

跟着尤小右学源码只是一个噱头,这个递归类型其实是一位外国人提的一个 pr 去实现的,一开始 TypeScript 不支持递归的时候,尤大写了 9 层手动解包,非常的吓人,可以去这个 pr 里看看,茫茫的一片红。

当然,这也可以看出 TypeScript 是在不断的进步和优化中的,非常期待未来它能够越来越强大。

相信看完本文的你,一定会对上文中提到的一些高级特性有了进一步的掌握。在 Vue3 到来之前,提前学点 TypeScript ,未雨绸缪总是没错的!

关于 TypeScript 的学习路径,我也总结在了我之前的文章 写给初中级前端的高级进阶指南-TypeScript 中给出了很好的资料,大家一起加油吧!

求点赞

如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我持续进行创作的动力,让我知道你喜欢看我的文章吧~

❤️感谢大家

关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

React + TypeScript + Hook 带你手把手打造类型安全的应用。

前言

TypeScript可以说是今年的一大流行点,虽然Angular早就开始把TypeScript作为内置支持了,但是真正在中文社区火起来据我观察也就是没多久的事情,尤其是在Vue3官方宣布采用TypeScript开发以后达到了一个顶点。

社区里有很多TypeScript比较基础的分享,但是关于React实战的还是相对少一些,这篇文章就带大家用React从头开始搭建一个TypeScript的todolist,我们的目标是实现类型安全,杜绝开发时可能出现的任何错误!

本文所使用的所有代码全部整理在了 ts-react-todo 这个仓库里。

本文默认你对于TypeScript的基础应用没有问题,对于泛型的使用也大概理解,如果对于TS的基础还没有熟悉的话,可以看我在上面github仓库的Readme的文末附上的几篇推荐。

实战

创建应用

首先使用的脚手架是create-react-app,根据
https://www.html.cn/create-react-app/docs/adding-typescript/
的流程可以很轻松的创建一个开箱即用的typescript-react-app。

创建后的结构大概是这样的:

my-app/
  README.md
  node_modules/
  package.json
  public/
    index.html
    favicon.ico
  src/
    App.css
    App.ts
    App.test.ts
    index.css
    index.ts
    logo.svg

在src/App.ts中开始编写我们的基础代码

import React, { useState, useEffect } from "react";
import classNames from "classnames";
import TodoForm from "./TodoForm";
import axios from "../api/axios";
import "../styles/App.css";

type Todo = {
  id: number;
  // 名字
  name: string;
  // 是否完成
  done: boolean;
};

type Todos = Todo[];

const App: React.FC = () => {
  const [todos, setTodos] = useState<Todos>([]);
  
  return (
    <div className="App">
      <header className="App-header">
        <ul>
          <TodoForm />
          {todos.map((todo, index) => {
            return (
              <li
                onClick={() => onToggleTodo(todo)}
                key={index}
                className={classNames({
                  done: todo.done,
                })}
              >
                {todo.name}
              </li>
            );
          })}
        </ul>
      </header>
    </div>
  );
};

export default App;

useState

代码很简单,利用type关键字来定义Todo这个类型,然后顺便生成Todos这个类型,用来给React的useState作为泛型约束使用,这样在上下文中,todos这个变量就会被约束为Todos这个类型,setTodos也只能去传入Todos类型的变量。

  const [todos, setTodos] = useState<Todos>([]);

Todos

当然,useState也是具有泛型推导的能力的,但是这要求你传入的初始值已经是你想要的类型了,而不是空数组。

const [todos, setTodos] = useState({
    id: 1,
    name: 'ssh',
    done: false
  });

模拟axios(简单版)

有了基本的骨架以后,就要想办法去拿到数据了,这里我选择自己模拟编写一个axios去返回想要的数据。

  const refreshTodos = () => {
    // 这边必须手动声明axios的返回类型。
    axios<Todos>("/api/todos").then(setTodos);
  };

  useEffect(() => {
    refreshTodos();
  }, []);

注意这里的axios也要在使用时手动传入泛型,因为我们现在还不能根据"/api/todos"这个字符串来推导出返回值的类型,接下来看一下axios的实现。

let todos = [
  {
    id: 1,
    name: '待办1',
    done: false
  },
  {
    id: 2,
    name: '待办2',
    done: false
  },
  {
    id: 3,
    name: '待办3',
    done: false
  }
]

// 使用联合类型来约束url
type Url = '/api/todos' | '/api/toggle' | '/api/add'

const axios = <T>(url: Url, payload?: any): Promise<T> | never => {
  let data
  switch (url) {
    case '/api/todos': {
      data = todos.slice()
      break
    }
  }
 default: {
    throw new Error('Unknown api')
 }

  return Promise.resolve(data as any)
}

export default axios

重点看一下axios的类型描述

const axios = <T>(url: Url, payload?: any): Promise<T> | never

泛型T被原封不动的交给了返回值的Promise,

promise.then((data) => data.xxx)

所以外部axios调用时传入的Todos泛型就被交给了Promise,Ts就可以推断出这个promise去resolve的值的类型是Todos,然后我们把switch-case逻辑中拿到的值用Promise.resolve(data)返回出去。

接下来回到src/App.ts 继续补充点击todo,更改完成状态时候的事件,

const App: React.FC = () => {
  const [todos, setTodos] = useState<Todos>([]);
  const refreshTodos = () => {
    // FIXME 这边必须手动声明axios的返回类型。
    axios<Todos>("/api/todos").then(setTodos);
  };

  useEffect(() => {
    refreshTodos();
  }, []);

  const onToggleTodo = async (todo: Todo) => {
    await axios("/api/toggle", todo.id);
    refreshTodos();
  };

  return (
    <div className="App">
      <header className="App-header">
        <ul>
          <TodoForm refreshTodos={refreshTodos} />
          {todos.map((todo, index) => {
            return (
              <li
                onClick={() => onToggleTodo(todo)}
                key={index}
                className={classNames({
                  done: todo.done,
                })}
              >
                {todo.name}
              </li>
            );
          })}
        </ul>
      </header>
    </div>
  );
};

再来看一下src/TodoForm组件的实现:

import React from "react";
import axios from "../api/axios";

interface Props {
  refreshTodos: () => void;
}

const TodoForm: React.FC<Props> = ({ refreshTodos }) => {
  const [name, setName] = React.useState("");

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  };

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const newTodo = {
      id: Math.random(),
      name,
      done: false,
    };

    if (name.trim()) {
      // FIXME 这边第二个参数没有做类型约束
      axios("/api/add", newTodo);
      refreshTodos();
      setName("");
    }
  };

  return (
    <form className="todo-form" onSubmit={onSubmit}>
      <input
        className="todo-input"
        value={name}
        onChange={onChange}
        placeholder="请输入待办事项"
      />
      <button type="submit">新增</button>
    </form>
  );
};

export default TodoForm;

在axios里加入/api/toggle和/api/add的处理:

  switch (url) {
    case '/api/todos': {
      data = todos.slice()
      break
    }
    case '/api/toggle': {
      const todo = todos.find(({ id }) => id === payload)
      if (todo) {
        todo.done = !todo.done
      }
      break
    }
    case '/api/add': {
      todos.push(payload)
      break
    }
    default: {
      throw new Error('Unknown api')
    }
  }

其实写到这里,一个简单的todolist已经实现了,功能是完全可用的,但是你说它类型安全吗,其实一点也不安全。

再回头看一下axios的类型签名:

const axios = <T>(url: Url, payload?: any): Promise<T> | never

payload这个参数被加上了?可选符,这是因为有的接口需要传参而有的接口不需要,这就会带来一些问题。

这里编写axios只约束了传入的url的限制,但是并没有约束入参的类型,返回值的类型,其实基本也就是anyscript了,举例来说,在src/TodoForm里的提交事件中,我们在FIXME的下面一行稍微改动,把axios的第二个参数去掉,如果以现实情况来说的话,一个add接口不传值,基本上报错没跑了,而且这个错误只有运行时才能发现。

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const newTodo = {
      id: Math.random(),
      name,
      done: false,
    };

    if (name.trim()) {
      // ERROR!! 这边的第二个参数被去掉了
      axios("/api/add");
      refreshTodos();
      setName("");
    }
  };

在src/App.ts的onToggleTodo事件里也有着同样的问题

 const onToggleTodo = async (todo: Todo) => {
    // ERROR!! 这边的第二个参数被去掉了
    await axios("/api/toggle");
    refreshTodos();
  };

另外在获取数据时候axios,必须要手动用泛型来定义好返回类型,这个也很冗余。

axios<Todos>("/api/todos").then(setTodos);

接下来我们用一个严格类型版本的axios函数来解决这个问题。

// axios.strict.ts
let todos = [
  {
    id: 1,
    name: '待办1',
    done: false
  },
  {
    id: 2,
    name: '待办2',
    done: false
  },
  {
    id: 3,
    name: '待办3',
    done: false
  }
]


export enum Urls {
  TODOS = '/api/todos',
  TOGGLE = '/api/toggle',
  ADD = '/api/add',
}

type Todo = typeof todos[0]
type Todos = typeof todos

首先我们用enum枚举定义好我们所有的接口url,方便后续复用,
然后我们用ts的typeof操作符从todos数据倒推出类型。

接下来用泛型条件类型来定义一个工具类型,根据泛型传入的值来返回一个自定义的key

type Key<U> =
  U extends Urls.TOGGLE ? 'toggle': 
  U extends Urls.ADD ? 'add': 
  U extends Urls.TODOS ? 'todos': 
  'other'

这个Key的作用就是,假设我们传入

type K = Key<Urls.TODOS>

会返回todos这个字符串类型,它有什么用呢,接着看就知道了。

现在需要把axios的函数类型声明的更加严格,我们需要把入参payload的类型和返回值的类型都通过传入的url推断出来,这里要利用泛型推导:

function axios <U extends Urls>(url: U, payload?: Payload<U>): Promise<Result<U>> | never

不要被这长串吓到,先一步步来分解它,

  1. <U extends Urls>首先泛型U用extends关键字做了类型约束,它必须是Urls枚举中的一个,
  2. (url: U, payload?: Payload<U>)参数中,url参数和泛型U建立了关联,这样我们在调用axios函数时,就会动态的根据传入的url来确定上下文中U的类型,接下来用Payload<U>把U传入Payload工具类型中。
  3. 最后返回值用Promise<Result<U>>,还是一样的原理,把U交给Result工具类型进行推导。

接下来重要的就是看Payload和Result的实现了。

type Payload<U> = {
  toggle: number
  add: Todo,
  todos: any,
  other: any
}[Key<U>]

刚刚定义的Key<U>工具类型就派上用场了,假设我们调用axios(Urls.TOGGLE),那么U被推断Urls.TOGGLE,传给Payload的就是Payload<Urls.TOGGLE>,那么Key<U>返回的结果就是Key<Urls.TOGGLE>,即为toggle

那么此时推断的结果是

Payload<Urls.TOGGLE> = {
  toggle: number
  add: Todo,
  todos: any,
  other: any
}['toggle']

此时todos命中的就是前面定义的类型集合中第一个toggle: number
所以此时Payload<Urls.TOGGLE>就这样被推断成了number 类型。

Result也是类似的实现:

type Result<U> = {
  toggle: boolean
  add: boolean,
  todos: Todos
  other: any
}[Key<U>]

这时候再回头来看函数类型

function axios <U extends Urls>(url: U, payload?: Payload<U>): Promise<Result<U>> | never 

是不是就清楚很多了,传入不同的参数会推断出不同的payload入参,以及返回值类型。

此时在来到app.ts里,看新版refreshTodos函数

  const refreshTodos = () => {
    axios(Urls.TODOS).then((todos) => {
      setTodos(todos)
    })
  }

axios后面的泛型约束被去掉了,then里面的todos依然被成功的推断为Todos类型。

todos

这时候就完美了吗?并没有,还有最后一点优化。

函数重载

写到这里,类型基本上是比较严格了,但是还有一个问题,就是在调用呢axios(Urls.TOGGLE)这个接口的时候,我们其实是一定要传递第二个参数的,但是因为axios(Urls.TODOS)是不需要传参的,所以我们只能在axios的函数签名把payload?设置为可选,这就导致了一个问题,就是ts不能明确的知道哪些接口需要传参,哪些接口不需要传参。

注意下图中的payload是带?的。
toggle

要解决这个问题,需要用到ts中的函数重载。

首先把需要传参的接口和不需要传参的接口列出来。

type UrlNoPayload =  Urls.TODOS
type UrlWithPayload = Exclude<Urls, UrlNoPayload>

这里用到了TypeScript的内置类型Exclude,用来在传入的类型中排除某些类型,这里我们就有了两份类型,需要传参的Url集合无需传参的Url集合

接着开始写重载

function axios <U extends UrlNoPayload>(url: U): Promise<Result<U>>
function axios <U extends UrlWithPayload>(url: U, payload: Payload<U>): Promise<Result<U>> | never
function axios <U extends Urls>(url: U, payload?: Payload<U>): Promise<Result<U>> | never {
  // 具体实现
}

根据extends约束到的不同类型,来重写函数的入参形式,最后用一个最全的函数签名(一定是要能兼容之前所有的函数签名的,所以最后一个签名的payload需要写成可选)来进行函数的实现。

此时如果再空参数调用toggle,就会直接报错,因为只有在请求todos的情况下才可以不传参数。
toggle严格

后记

到此我们就实现了一个严格类型的React应用,写这篇文章的目的不是让大家都要在公司的项目里去把类型推断做到极致,毕竟一切的技术还是为业务服务的。

但是就算是写宽松版本的TypeScript,带来的收益也远远比裸写JavaScript要高很多,尤其是在别人需要复用你写的工具函数或者组件时。

而且TypeScript也可以在开发时就避免很多粗心导致的错误,详见:
TypeScript 解决了什么痛点? - justjavac的回答 - 知乎
https://www.zhihu.com/question/308844713/answer/574423626

本文涉及到的所有代码都在
https://github.com/sl1673495/ts-react-todo 中,有兴趣的同学可以拉下来自己看看。

react-component源码学习(2) rc-steps

rc-steps是antd的步骤组件所依赖的底层组件,先看官方给的用法示例。

<Steps current={1}>
  <Steps.Step title="first" />
  <Steps.Step title="second" />
  <Steps.Step title="third" />
</Steps>

简洁明了的父子嵌套组件。
先从父组件的源码看起。

Steps.jsx

/* eslint react/no-did-mount-set-state: 0 */
import React, { cloneElement, Children, Component } from 'react';
import PropTypes from 'prop-types';
import { findDOMNode } from 'react-dom';
import classNames from 'classnames';
import debounce from 'lodash/debounce';
import { isFlexSupported } from './utils';

export default class Steps extends Component {
  static propTypes = {
    prefixCls: PropTypes.string,
    className: PropTypes.string,
    iconPrefix: PropTypes.string,
    direction: PropTypes.string,
    labelPlacement: PropTypes.string,
    children: PropTypes.any,
    status: PropTypes.string,
    size: PropTypes.string,
    progressDot: PropTypes.oneOfType([
      PropTypes.bool,
      PropTypes.func,
    ]),
    style: PropTypes.object,
    initial: PropTypes.number,
    current: PropTypes.number,
    icons: PropTypes.shape({
      finish: PropTypes.node,
      error: PropTypes.node,
    }),
  };
  static defaultProps = {
    prefixCls: 'rc-steps',
    iconPrefix: 'rc',
    direction: 'horizontal',
    labelPlacement: 'horizontal',
    initial: 0,
    current: 0,
    status: 'process',
    size: '',
    progressDot: false,
  };
  constructor(props) {
    super(props);
    this.state = {
      flexSupported: true,
      lastStepOffsetWidth: 0,
    };
    this.calcStepOffsetWidth = debounce(this.calcStepOffsetWidth, 150);
  }
  componentDidMount() {
    this.calcStepOffsetWidth();
    if (!isFlexSupported()) {
      this.setState({
        flexSupported: false,
      });
    }
  }
  componentDidUpdate() {
    this.calcStepOffsetWidth();
  }
  componentWillUnmount() {
    if (this.calcTimeout) {
      clearTimeout(this.calcTimeout);
    }
    if (this.calcStepOffsetWidth && this.calcStepOffsetWidth.cancel) {
      this.calcStepOffsetWidth.cancel();
    }
  }
  calcStepOffsetWidth = () => {
    if (isFlexSupported()) {
      return;
    }
    // Just for IE9
    const domNode = findDOMNode(this);
    if (domNode.children.length > 0) {
      if (this.calcTimeout) {
        clearTimeout(this.calcTimeout);
      }
      this.calcTimeout = setTimeout(() => {
        // +1 for fit edge bug of digit width, like 35.4px
        const lastStepOffsetWidth = (domNode.lastChild.offsetWidth || 0) + 1;
        // Reduce shake bug
        if (this.state.lastStepOffsetWidth === lastStepOffsetWidth ||
          Math.abs(this.state.lastStepOffsetWidth - lastStepOffsetWidth) <= 3) {
          return;
        }
        this.setState({ lastStepOffsetWidth });
      });
    }
  }
  render() {
    const {
      prefixCls, style = {}, className, children, direction,
      labelPlacement, iconPrefix, status, size, current, progressDot, initial,
      icons,
      ...restProps,
    } = this.props;
    const { lastStepOffsetWidth, flexSupported } = this.state;
    const filteredChildren = React.Children.toArray(children).filter(c => !!c);
    const lastIndex = filteredChildren.length - 1;
    const adjustedlabelPlacement = !!progressDot ? 'vertical' : labelPlacement;
    const classString = classNames(prefixCls, `${prefixCls}-${direction}`, className, {
      [`${prefixCls}-${size}`]: size,
      [`${prefixCls}-label-${adjustedlabelPlacement}`]: direction === 'horizontal',
      [`${prefixCls}-dot`]: !!progressDot,
    });

    return (
      <div className={classString} style={style} {...restProps}>
        {
          Children.map(filteredChildren, (child, index) => {
            if (!child) {
              return null;
            }
            const stepNumber = initial + index;
            const childProps = {
              stepNumber: `${stepNumber + 1}`,
              prefixCls,
              iconPrefix,
              wrapperStyle: style,
              progressDot,
              icons,
              ...child.props,
            };
            if (!flexSupported && direction !== 'vertical' && index !== lastIndex) {
              childProps.itemWidth = `${100 / lastIndex}%`;
              childProps.adjustMarginRight = -Math.round(lastStepOffsetWidth / lastIndex + 1);
            }
            // fix tail color
            if (status === 'error' && index === current - 1) {
              childProps.className = `${prefixCls}-next-error`;
            }
            if (!child.props.status) {
              if (stepNumber === current) {
                childProps.status = status;
              } else if (stepNumber < current) {
                childProps.status = 'finish';
              } else {
                childProps.status = 'wait';
              }
            }
            return cloneElement(child, childProps);
          })
        }
      </div>
    );
  }
}

首先看到在componentDidMount, componentDidUpdate阶段都调用了calcStepOffsetWidth这个方法,这个方法其实就是计算lastStepOffsetWidth最后一个步骤条的偏移距离 用来调整子组件的间距到正好撑满容器的效果。

calcStepOffsetWidth

在这个方法的开头,我们看到

if (isFlexSupported()) {
   return;
}

如果浏览器支持flex,就直接return,因为flex本身就是弹性自适应布局,

export function isFlexSupported() {
  if (typeof window !== 'undefined' && window.document && window.document.documentElement) {
    const { documentElement } = window.document;
    return 'flex' in documentElement.style ||
      'webkitFlex' in documentElement.style ||
      'Flex' in documentElement.style ||
      'msFlex' in documentElement.style;
  }
  return false;
}

如果不支持flex,
则先用React.findDomNode(this)拿到当前组件的dom节点,然后用了一个类似debouce的处理,利用setTimout在下一个事件循环里处理,并且保证一个事件循环里触发的多次此方法被归并成一次,
拿到children中lastChild的offsetWidth并且赋给state的lastStepOffsetWidth。

render

filteredChildren是利用React.Children.toArray把子节点转成数组且过滤掉空节点,然后拿到lastIndex最后一项的序号,在最后的return中调用React.Children.map循环子节点数组,在这个循环中,stepNumber是props.initial + index,childProps在child原有的props基础上扩展了
stepNumber步骤序号和一系列样式,

if (!flexSupported && direction !== 'vertical' && index !== lastIndex) {
      childProps.itemWidth = `${100 / lastIndex}%`;
      childProps.adjustMarginRight = -Math.round(lastStepOffsetWidth / lastIndex + 1);
}

在不支持flex的情况下继续扩展
itemWidth为 100除以最后一项的下标
adjustMarginRight 是上面计算的lastStepOffsetWidth除以子元素数量并取负。

// fix tail color
   if (status === 'error' && index === current - 1) {
  childProps.className = `${prefixCls}-next-error`;
}

status代表props中传入的当前步骤的状态,如果是错误并且这时候的step是当前步骤的前一个的话,加一个next-error的class

          if (!child.props.status) {
              if (stepNumber === current) {
                childProps.status = status;
              } else if (stepNumber < current) {
                childProps.status = 'finish';
              } else {
                childProps.status = 'wait';
              }
            }

这段是假设用户不传入status的情况下自动计算当前应该的状态,
current之前是finished 之后是wait

 return cloneElement(child, childProps);

最后调用React.cloneElement把child和childProps合并成一个新节点返回。

Step.jsx

import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';

function isString(str) {
  return typeof str === 'string';
}

export default class Step extends React.Component {
  static propTypes = {
    className: PropTypes.string,
    prefixCls: PropTypes.string,
    style: PropTypes.object,
    wrapperStyle: PropTypes.object,
    itemWidth: PropTypes.oneOfType([
      PropTypes.number,
      PropTypes.string,
    ]),
    status: PropTypes.string,
    iconPrefix: PropTypes.string,
    icon: PropTypes.node,
    adjustMarginRight: PropTypes.oneOfType([
      PropTypes.number,
      PropTypes.string,
    ]),
    stepNumber: PropTypes.string,
    description: PropTypes.any,
    title: PropTypes.any,
    progressDot: PropTypes.oneOfType([
      PropTypes.bool,
      PropTypes.func,
    ]),
    tailContent: PropTypes.any,
    icons: PropTypes.shape({
      finish: PropTypes.node,
      error: PropTypes.node,
    }),
  };
  renderIconNode() {
    const {
      prefixCls, progressDot, stepNumber, status, title, description, icon,
      iconPrefix, icons,
    } = this.props;
    let iconNode;
    const iconClassName = classNames(`${prefixCls}-icon`, `${iconPrefix}icon`, {
      [`${iconPrefix}icon-${icon}`]: icon && isString(icon),
      [`${iconPrefix}icon-check`]: !icon && status === 'finish' && (icons && !icons.finish),
      [`${iconPrefix}icon-close`]: !icon && status === 'error' && (icons && !icons.error),
    });
    const iconDot = <span className={`${prefixCls}-icon-dot`}></span>;
    // `progressDot` enjoy the highest priority
    if (progressDot) {
      if (typeof progressDot === 'function') {
        iconNode = (
          <span className={`${prefixCls}-icon`}>
            {progressDot(iconDot, { index: stepNumber - 1, status, title, description })}
          </span>
        );
      } else {
        iconNode = <span className={`${prefixCls}-icon`}>{iconDot}</span>;
      }
    } else if (icon && !isString(icon)) {
      iconNode = <span className={`${prefixCls}-icon`}>{icon}</span>;
    } else if (icons && icons.finish && status === 'finish') {
      iconNode = <span className={`${prefixCls}-icon`}>{icons.finish}</span>;
    } else if (icons && icons.error && status === 'error') {
      iconNode = <span className={`${prefixCls}-icon`}>{icons.error}</span>;
    } else if (icon || status === 'finish' || status === 'error') {
      iconNode = <span className={iconClassName} />;
    } else {
      iconNode = <span className={`${prefixCls}-icon`}>{stepNumber}</span>;
    }

    return iconNode;
  }
  render() {
    const {
      className, prefixCls, style, itemWidth,
      status = 'wait', iconPrefix, icon, wrapperStyle,
      adjustMarginRight, stepNumber,
      description, title, progressDot, tailContent,
      icons,
      ...restProps,
    } = this.props;

    const classString = classNames(
      `${prefixCls}-item`,
      `${prefixCls}-item-${status}`,
      className,
      { [`${prefixCls}-item-custom`]: icon },
    );
    const stepItemStyle = { ...style };
    if (itemWidth) {
      stepItemStyle.width = itemWidth;
    }
    if (adjustMarginRight) {
      stepItemStyle.marginRight = adjustMarginRight;
    }
    return (
      <div
        {...restProps}
        className={classString}
        style={stepItemStyle}
      >
        <div className={`${prefixCls}-item-tail`}>
          {tailContent}
        </div>
        <div className={`${prefixCls}-item-icon`}>
          {this.renderIconNode()}
        </div>
        <div className={`${prefixCls}-item-content`}>
          <div className={`${prefixCls}-item-title`}>
            {title}
          </div>
          {description && <div className={`${prefixCls}-item-description`}>{description}</div>}
        </div>
      </div>
    );
  }
}

子组件里就是根据父组件计算的一些props和本身的props计算出图标和状态进行渲染。

驳《前端常见的Vue面试题目汇总》

本着对社区的小伙伴们负责的态度,有些文章里应付面试用的一些讲解实在是看不下去。

本文针对 @小明同学哟 的 《前端常见的Vue面试题目汇总》 这篇文章,提出一些错误。

先放一张大图,有兴趣的同学可以点开图片看一下原文,简单来说就是写了很多不知道从哪里收集来的劣质总结,然后底下放个公众号骗粉丝。

且不说原文中每个答案都过于简略,并不能达到面试官的要求,其中还有很多错误的地方会误导读者,接下来我重点指出一下错误的地方。

这里不放原文链接的原因是我希望抵制这样的作者,这个作者的掘力值快要 5000 了,而掘金会对掘力值 5000 以上的作者进行文章首页推荐。如果以后首页都是这样的低质量文章,那真的很让人绝望。

另外比较可笑的是,昨天在这篇文章下提出了一些反驳的观点,今早一看这篇文章的评论区,已经被作者删的一干二净,只留下她的「水军号」的一条评论了。不禁唏嘘,直接删掉文章的反对观点来掩耳盗铃。

准备开始

接下来开始针对作者文章中的观点进行逐条的反驳,注意「引用」 中的文字的即是作者原文,错别字我也原样保留了。

请说一下响应式数据的原理

默认Vue在初始化数据时,会给data中的属性使用Object.defineProperty重新定义所有属性,当页面到对应属性时,会进行依赖收集(收集当前组件中的watcher)如果属性发生变化会通知相关依赖进行更新操作

收集当前组件中的watcher,我进一步问你什么叫当前组件的 watcher?我面试时经常听到这种模糊的说法,感觉就是看了些造玩具的文章就说熟悉响应式原理了,起码的流程要清晰一些:

  1. 由于 Vue 执行一个组件的 render 函数是由 Watcher 去代理执行的,Watcher 在执行前会把 Watcher 自身先赋值给 Dep.target 这个全局变量,等待响应式属性去收集它
  2. 这样在哪个组件执行 render 函数时访问了响应式属性,响应式属性就会精确的收集到当前全局存在的 Dep.target 作为自身的依赖
  3. 在响应式属性发生更新时通知 Watcher 去重新调用 vm._update(vm._render()) 进行组件的视图更新

响应式部分,如果你想在简历上写熟悉的话,还是要抽时间好好的去看一下源码中真正的实现,而不是看这种模棱两可的说法就觉得自己熟练掌握了。

为什么Vue采用异步渲染

因为如果不采用异步更新,那么每次更新数据都会对当前租金按进行重新渲染,所以为了性能考虑,Vue会在本轮数据更新后,再去异步更新数据

什么叫本轮数据更新后,再去异步更新数据?

轮指的是什么,在 eventLoop 里的 taskmicroTask,他们分别的执行时机是什么样的,为什么优先选用 microTask ,这都是值得深思的好问题。

建议看看这篇文章: Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!

nextTick实现原理

nextTick方法主要是使用了宏任务和微任务,定义一个异步方法,多次调用nextTick会将方法存在队列中,通过这个异步方法清空当前队列。所以这个nextTick方法就是异步方法

这句话说的很乱,典型的让面试官忍不住想要深挖一探究竟的回答。(因为一听你就不是真的懂)

正确的流程应该是先去 嗅探环境,依次去检测

Promise的then -> MutationObserver的回调函数 -> setImmediate -> setTimeout 是否存在,找到存在的就使用它,以此来确定回调函数队列是以哪个 api 来异步执行。

nextTick 函数接受到一个 callback 函数的时候,先不去调用它,而是把它 push 到一个全局的 queue 队列中,等待下一个任务队列的时候再一次性的把这个 queue 里的函数依次执行。

这个队列可能是 microTask 队列,也可能是 macroTask 队列,前两个 api 属于微任务队列,后两个 api 属于宏任务队列。

简化实现一个异步合并任务队列:

let pending = false
// 存放需要异步调用的任务
const callbacks = []
function flushCallbacks () {
  pending = false
  // 循环执行队列
  for (let i = 0; i < callbacks.length; i++) {
    callbacks[i]()
  }
  // 清空
  callbacks.length = 0
}

function nextTick(cb) {
    callbacks.push(cb)
    if (!pending) {
      pending = true
      // 利用Promise的then方法 在下一个微任务队列中把函数全部执行 
      // 在微任务开始之前 依然可以往callbacks里放入新的回调函数
      Promise.resolve().then(flushCallbacks)
    }
}

测试一下:

// 第一次调用 then方法已经被调用了 但是 flushCallbacks 还没执行
nextTick(() => console.log(1))
// callbacks里push这个函数
nextTick(() => console.log(2))
// callbacks里push这个函数
nextTick(() => console.log(3))

// 同步函数优先执行
console.log(4)

// 此时调用栈清空了,浏览器开始检查微任务队列,发现了 flushCallbacks 方法,执行。
// 此时 callbacks 里的 3 个函数被依次执行。

// 4
// 1
// 2
// 3

Vue优点

虚拟DOM把最终的DOM操作计算出来并优化,由于这个DOM操作属于预处理操作,并没有真实的操作DOM,所以叫做虚拟DOM。最后在计算完毕才真正将DOM操作提交,将DOM操作变化反映到DOM树上

看起来说的很厉害,其实也没说到点上。关于虚拟 DOM 的优缺点,直接看 Vue 作者尤雨溪本人的知乎回答,你会对它有进一步的理解:

网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?

双向数据绑定通过MVVM**实现数据的双向绑定,让开发者不用再操作dom对象,有更多的时间去思考业务逻辑

开发者不操作dom对象,和双向绑定没太大关系。React不提供双向绑定,开发者照样不需要操作dom。双向绑定只是一种语法糖,在表单元素上绑定 value 并且监听 onChange 事件去修改 value 触发响应式更新。

我建议真正想看模板被编译后的原理的同学,可以去尤大开源的vue-template-explorer 网站输入对应的模板,就会展示出对应的 render 函数。

运行速度更快,像比较与react而言,同样都是操作虚拟dom,就性能而言,vue存在很大的优势

为什么快,快在哪里,什么情况下快,有数据支持吗?事实上在初始化数据量不同的场景是不好比较的,React 不需要对数据递归的进行 响应式定义

而在更新的场景下 Vue 可能更快一些,因为 Vue 的更新粒度是组件级别的,而 React 是递归向下的进行 reconcilerReact 引入了 Fiber 架构和异步更新,目的也是为了让这个工作可以分在不同的 时间片 中进行,不要去阻塞用户高优先级的操作。

Proxy是es6提供的新特性,兼容性不好,所以导致Vue3一致没有正式发布让开发者使用

Vue3 没发布不是因为兼容性不好,工作正在有序推进中,新的语法也在不断迭代,并且发布 rfc 征求社区意见。

Object.defineProperty的缺点:无法监控到数组下标的变化,导致直接通过数组的下标给数组设置值,不能实时响应

事实上可以,并且尤大说只是为了性能的权衡才不去监听。数组下标本质上也就是对象的一个属性。

React和Vue的比较

React默认是通过比较引用的方式(diff)进行的,React不精确监听数据变化。

比较引用和 diff 有什么关系,难道 Vue 就不 diff 了吗。

Vue2.0可以通过props实现双向绑定,用vuex单向数据流的状态管理框架

双向绑定是 v-model 吧。

Vue 父组件通过props向子组件传递数据或回调

Vue 虽然可以传递回调,但是一般来说还是通过 v-on:change 或者 @change 的方式去绑定事件吧,这和回调是两套机制。

模板渲染方式不同,Vue通过HTML进行渲染

事实上 Vue 是自己实现了一套模板引擎系统,HTML 可以被利用为模板的而已,你在 .vue 文件里写的 templateHTML 本质上没有关系。

React组合不同功能方式是通过HoC(高阶组件),本质是高阶函数

事实上高阶函数只是社区提出的一种方案被 React 所采纳而已,其他的方案还有 renderProps 和 最近流行的Hook

Vue 也可以利用高阶函数 实现组合和复用。

diff算法的时间复杂度

两个数的完全的diff算法是一个时间复杂度为o(n3),
Vue进行了优化O(n3)复杂度的问题转换成O(n)复杂度的问题(只比较同级不考虑跨级问题)在前端当中,你很少会跨级层级地移动Dom元素,所以Virtual Dom只会对同一个层级地元素进行对比

听这个描述来说,React 没有对 O(n3) 的复杂度进行优化?事实上 React 和 Vue 都只会对 tag 相同的同级节点进行 diff,如果不同则直接销毁重建,都是 O(n) 的复杂度。

谈谈你对作用域插槽的理解

单个插槽当子组件模板只有一个没有属性的插槽时, 父组件传入的整个内容片段将插入到插槽所在的 DOM 位置, 并替换掉插槽标签本身。

跟 DOM 没关系,是在虚拟节点树的插槽位置替换。

如果不加key,那么vue会选择复用节点(Vue的就地更新策略),导致之前节点的状态被保留下来,会产生一系列的bug

不加 key 也不一定就会复用,关于 diff 和 key 的使用,建议大家还是找一些非造玩具的文章真正深入的看一下原理。

为什么 Vue 中不要用 index 作为 key?(diff 算法详解)

组件中的data为什么是函数

因为组件是用来复用的,JS里对象是引用关系,这样作用域没有隔离,而new Vue的实例,是不会被复用的,因此不存在引用对象问题

这句话反正我压根没听懂,事实上如果组件里 data 直接写了一个对象的话,那么如果你在模板中多次声明这个组件,组件中的 data 会指向同一个引用。

此时如果在某个组件中对 data 进行修改,会导致其他组件里的 data 也被污染。 而如果使用函数的话,每个组件里的 data 会有单独的引用,这个问题就可以避免了。

这个问题我同样举个例子来方便理解,假设我们有这样的一个组件,其中的 data 直接使用了对象而不是函数:

var Counter = {
    template: `<span @click="count++"></span>`
    data: {
        count: 0
    }
}

注意,这里的 Counter.data 是一个引用,也就是它是在当前的运行环境下全局唯一的,它在堆内存中占用了一部分空间。

然后我们在模板中调用两次 Counter 组件:

<div>
  <Counter id="a" />
  <Counter id="b" />
</div>

我们从原理出发,先看看它被编译成什么样render 函数:

function render() {
  with(this) {
    return _c('div', [_c('Counter'), _c('Counter')], 1)
  }
}

每一个 Counter 会被 _c 所调用,也就是 createElement,想象一下 createElement 内部会发生什么,它会直接拿着 Counter 上的 data 这个引用去创建一个组件。 也就是所有的 Counter 组件实例上的 data 都指向同一个引用。

此时假如 id 为 a 的 Counter 组件内部调用了 count++,会去对 data 这个引用上的 count 属性赋值,那么此时由于 id 为 b 的 Counter 组件内部也是引用的同一份 data,它也会感觉到变化而更新组件,这就造成了多个组件之间的数据混乱了。

computed和watch有什么区别

计算属性是基于他们的响应式依赖进行缓存的,只有在依赖发生变化时,才会计算求值,而使用 methods,每次都会执行相应的方法

这也是一个一问就倒的回答,依赖变化是计算属性就重新求值吗?中间经历了什么过程,为什么说 computed 是有缓存值的?随便挑一个点深入问下去就站不住。 事实上 computed 会拥有自己的 watcher,它内部有个属性 dirty 开关来决定 computed 的值是需要重新计算还是直接复用之前的值。

以这样的一个例子来说:

computed: {
    sum() {
        return this.count + 1
    }
}

首先明确两个关键字:

「dirty」 从字面意义来讲就是 的意思,这个开关开启了,就意味着这个数据是脏数据,需要重新求值了拿到最新值。

「求值」 的意思的对用户传入的函数进行执行,也就是执行 return this.count + 1

  1. sum 第一次进行求值的时候会读取响应式属性 count,收集到这个响应式数据作为依赖。并且计算出一个值来保存在自身的 value 上,把 dirty 设为 false,接下来在模板里再访问 sum 就直接返回这个求好的值 value,并不进行重新的求值。
  2. count 发生变化了以后会通知 sum 所对应的 watcher 把自身的 dirty 属性设置成 true,这也就相当于把重新求值的开关打开来了。这个很好理解,只有 count 变化了, sum 才需要重新去求值。
  3. 那么下次模板中再访问到 this.sum 的时候,才会真正的去重新调用 sum 函数求值,并且再次把 dirty 设置为 false,等待下次的开启……

后续我会考虑单独出一篇文章进行详细讲解。

Watch中的deep:true是如何实现的

当用户指定了watch中的deep属性为true时,如果当前监控的值是数组类型,会对对象中的每一项进行求值,此时会将当前watcher存入到对应属性的依赖中,这样数组中的对象发生变化时也会通知数据更新。

不光是数组类型,对象类型也会对深层属性进行 依赖收集,比如监听了 obj,假如设置了 deep: true,那么对 obj.a.b.c = 5 这样深层次的修改也一样会触发 watch 的回调函数。本质上是因为 Vue 内部对设置了 deep 的 watch,会进行递归的访问(只要此属性也是响应式属性),而在此过程中也会不断发生依赖收集。

在回答这道题的时候,同样也要考虑到 递归收集依赖 对性能上的损耗和权衡,才是一份合格的回答。

action和mutation区别

mutation是同步更新数据(内部会进行是否为异步方式更新数据的检测)

内部并不能检测到是否异步更新,而是实例上有一个开关变量 _committing

  1. 只有在 mutation 执行之前才会把开关打开,允许修改 state 上的属性。
  2. 并且在 mutation 同步执行完成后立刻关闭。
  3. 异步更新的话由于已经出了 mutation 的调用栈,此时的开关已经是关上的,自然能检测到对 state 的修改并报错。具体可以查看源码中的 withCommit 函数。这是一种很经典对于 js单线程机制 的利用。
Store.prototype._withCommit = function _withCommit (fn) {
  var committing = this._committing;
  this._committing = true;
  fn();
  this._committing = committing;
};

关于重复发文章

此外 @小明同学哟 这个作者和 @小梦哟 这两个作者之间有说不清道不明的关系(之前看好像是情侣头像,然后经常互动,并且两个人分别著有《一个湖北女生的总结》、《一个湖北男生的总结》)。

两个作者之间把同一篇低质量文章来回发,都是那种评论区能指出特别多错误的水文。

来波 diff 算法

这是 @小明同学哟 的 《前端面试大厂手写源码系列(上)》:

《前端面试大厂手写源码系列(上)》

这是 @小梦哟 的 《面试时,你被要求手写常见原理了吗?》

面试时,你被要求手写常见原理了吗?

基本上就是顺序调换一下,内容完全重复的文章,阅读量还不低。

关于发课程文章不注明出处

最开始接触到这个作者,是因为她写了一篇 《Vue仿饿了么app项目总结》,我正好在这个项目的作者黄轶老师的群里,群友非常愤慨的来评论区讨公道后她才在评论区里声明这是和慕课网的黄轶老师学习课程后进行的总结。

我可以理解为如果没人说的话,她就想瞒混过去作为自己的项目了,可惜她不了解行情,这门课早就在几年前就家喻户晓,成为 Vue 面试必备的实战项目了。

申请水军号


他们的文章其实挺难获得好评的,毕竟真的挺水的。但是这个用户却时常在他们的文章下抢沙发。点进去仔细一看,只关注了这俩人,点赞的也全是这俩人……

关于知识变现

我一直觉得知识变现不可耻,这是一个「自媒体」流行的时代,认真输出自己观点并且影响他人的人理应获得自己的收益,我并不觉得这有什么丢人的,

我在 写给初中级前端的高级进阶指南 这篇文章里放了几个掘金小册的推广码,是我认真读过以后真心想推荐给大家的,这也是掘金官方提供的一种变现机制。我真心不觉得这有什么不对。知识是有价值的,前提是你不要输出二手错误百出的知识。甚至在大家的公众号上看到广告的时候,我也是会心一笑,因为这个作者曾经或「原创」或「转载」的优质文章给我带来了很大的收益……

而原作者 @小明同学哟 的水平明显还不足以给社区的新人一些启发,甚至我感觉大概相当于某c字开头的论坛上面充斥着的新手学习笔记,这样子为了变现而影响社区环境的吃相我就接受不了了。

再不济,你还可以学习某不愿提及姓名的「闰土大叔」,写些心灵鸡汤做一个教父,也一样可以赚的盆满钵满,毕竟人家没误导人。只是人家是真的不会技术,那就曲线救国而已。

总结

总而言之,我关注了这个作者和她的搭档 @小梦哟 挺久了,不知道这些作者为什么这么拼命的想火起来,不惜重复发文章,不惜借用别人的课程成果而不声明,这对社区的进步来说没有任何好处。

我坚持在掘金发文章其实有一个原因,就是我也希望中文社区能慢慢发展出类似 medium 那样高质量的前端交流社区(虽然它是付费制的,有难度),而掘金是我前端最开始就接触到的社区,心里也很有感情,看着首页混杂着这种错误百出的低质量文章,我心里真的是百感交集,为什么明明是新手的类似于学习笔记质量的文章也要急着发出来吸引流量呢?

总之,真心希望掘金能少一些不负责任的水文,一些摘抄搬运官方文档的东西。大家都认真的输出自己去证实过,或者真正理解的总结,慢慢的让掘金、甚至国内的前端氛围能够形成一个良性氛围,前端的明天越来越美好。

Vue 进阶必学之高阶组件实战

前言

高阶组件这个概念在 React 中一度非常流行,但是在 Vue 的社区里讨论的不多,本篇文章就真正的带你来玩一个进阶的*操作。

先和大家说好,本篇文章的核心是学会这样的**,也就是 智能组件木偶组件 的解耦合,没听过这个概念没关系,下面会详细说明。

这可以有很多方式,比如 slot-scopes,比如未来的composition-api。本篇所写的代码也不推荐用到生产环境,生产环境有更成熟的库去使用,这篇强调的是 **,顺便把 React 社区的玩法移植过来皮一下。

不要喷我,不要喷我,不要喷我!!,此篇只为演示高阶组件的思路,如果实际业务中想要简化文中所提到的异步状态管理,请使用基于 slot-scopes 的开源库 vue-promised

例子

本文就以平常开发中最常见的需求,也就是异步数据的请求为例,先来个普通玩家的写法:

<template>
    <div v-if="error">failed to load</div>
    <div v-else-if="loading">loading...</div>
    <div v-else>hello {{result.name}}!</div>
</template>

<script>
export default {
  data() {
    return {
        result: {
          name: '',
        },
        loading: false,
        error: false,
    },
  },
  async created() {
      try {
        // 管理loading
        this.loading = true
        // 取数据
        const data = await this.$axios('/api/user')  
        this.data = data
      } catch (e) {
        // 管理error
        this.error = true  
      } finally {
        // 管理loading
        this.loading = false
      }
  },
}
</script>

一般我们都这样写,平常也没感觉有啥问题,但是其实我们每次在写异步请求的时候都要有 loadingerror 状态,都需要有 取数据 的逻辑,并且要管理这些状态。

那么想个办法抽象它?好像特别好的办法也不多,React 社区在 Hook 流行之前,经常用 HOC(high order component) 也就是高阶组件来处理这样的抽象。

高阶组件是什么?

说到这里,我们就要思考一下高阶组件到底是什么概念,其实说到底,高阶组件就是:

一个函数接受一个组件为参数,返回一个包装后的组件

在 React 中

在 React 里,组件是 Class,所以高阶组件有时候会用 装饰器 语法来实现,因为 装饰器 的本质也是接受一个 Class 返回一个新的 Class

在 React 的世界里,高阶组件就是 f(Class) -> 新的Class

在 Vue 中

在 Vue 的世界里,组件是一个对象,所以高阶组件就是一个函数接受一个对象,返回一个新的包装好的对象。

类比到 Vue 的世界里,高阶组件就是 f(object) -> 新的object

智能组件和木偶组件

如果你还不知道 木偶 组件和 智能 组件的概念,我来给你简单的讲一下,这是 React 社区里一个很成熟的概念了。

木偶 组件: 就像一个牵线木偶一样,只根据外部传入的 props 去渲染相应的视图,而不管这个数据是从哪里来的。

智能 组件: 一般包在 木偶 组件的外部,通过请求等方式获取到数据,传入给 木偶 组件,控制它的渲染。

一般来说,它们的结构关系是这样的:

<智能组件>
  <木偶组件 />
</智能组件>

它们还有另一个别名,就是 容器组件ui组件,是不是很形象。

实现

具体到上面这个例子中(如果你忘了,赶紧回去看看,哈哈),我们的思路是这样的,

  1. 高阶组件接受 木偶组件请求的方法 作为参数
  2. mounted 生命周期中请求到数据
  3. 把请求的数据通过 props 传递给 木偶组件

接下来就实现这个思路,首先上文提到了,HOC 是个函数,本次我们的需求是实现请求管理的 HOC,那么先定义它接受两个参数,我们把这个 HOC 叫做 withPromise

并且 loadingerror 等状态,还有 加载中加载错误 等对应的视图,我们都要在 新返回的包装组件 ,也就是下面的函数中 return 的那个新的对象 中定义好。

const withPromise = (wrapped, promiseFn) => {
  return {
    name: "with-promise",
    data() {
      return {
        loading: false,
        error: false,
        result: null,
      };
    },
    async mounted() {
      this.loading = true;
      const result = await promiseFn().finally(() => {
        this.loading = false;
      });
      this.result = result;
    },
  };
};

在参数中:

  1. wrapped 也就是需要被包裹的组件对象。
  2. promiseFunc 也就是请求对应的函数,需要返回一个 Promise

看起来不错了,但是函数里我们好像不能像在 .vue 单文件里去书写 template 那样书写模板了,

但是我们又知道模板最终还是被编译成组件对象上的 render 函数,那我们就直接写这个 render 函数。(注意,本例子是因为便于演示才使用的原始语法,脚手架创建的项目可以直接用 jsx 语法。)

在这个 render 函数中,我们把传入的 wrapped 也就是木偶组件给包裹起来。

这样就形成了 智能组件获取数据 -> 木偶组件消费数据,这样的数据流动了。

const withPromise = (wrapped, promiseFn) => {
  return {
    data() { ... },
    async mounted() { ... },
    render(h) {
      return h(wrapped, {
        props: {
          result: this.result,
          loading: this.loading,
        },
      });
    },
  };
};

到了这一步,已经是一个勉强可用的雏形了,我们来声明一下 木偶 组件。

这其实是 逻辑和视图分离 的一种思路。

const view = {
  template: `
    <span>
      <span>{{result?.name}}</span>
    </span>
  `,
  props: ["result", "loading"],
};

注意这里的组件就可以是任意 .vue 文件了,我这里只是为了简化而采用这种写法。

然后用神奇的事情发生了,别眨眼,我们用 withPromise 包裹这个 view 组件。

// 假装这是一个 axios 请求函数
const request = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ name: "ssh" });
    }, 1000);
  });
};

const hoc = withPromise(view, request)

然后在父组件中渲染它:

<div id="app">
  <hoc />
</div>

<script>
 const hoc = withPromise(view, request)

 new Vue({
    el: 'app',
    components: {
      hoc
    }
 })
</script>

此时,组件在空白了一秒后,渲染出了我的大名 ssh,整个异步数据流就跑通了。

现在在加上 加载中加载失败 视图,让交互更友好点。

const withPromise = (wrapped, promiseFn) => {
  return {
    data() { ... },
    async mounted() { ... },
    render(h) {
      const args = {
        props: {
          result: this.result,
          loading: this.loading,
        },
      };

      const wrapper = h("div", [
        h(wrapped, args),
        this.loading ? h("span", ["加载中……"]) : null,
        this.error ? h("span", ["加载错误"]) : null,
      ]);

      return wrapper;
    },
  };
};

到此为止的代码可以在 效果预览 里查看,控制台的 source 里也可以直接预览源代码。

完善

到此为止的高阶组件虽然可以演示,但是并不是完整的,它还缺少一些功能,比如

  1. 要拿到子组件上定义的参数,作为初始化发送请求的参数。
  2. 要监听子组件中请求参数的变化,并且重新发送请求。
  3. 外部组件传递给 hoc 组件的参数现在没有透传下去。

第一点很好理解,我们请求的场景的参数是很灵活的。

第二点也是实际场景中常见的一个需求。

第三点为了避免有的同学不理解,这里再啰嗦下,比如我们在最外层使用 hoc 组件的时候,可能希望传递一些 额外的props 或者 attrs 甚至是 插槽slot 给最内层的 木偶 组件。那么 hoc 组件作为桥梁,就要承担起将它透传下去的责任。

为了实现第一点,我们约定好 view 组件上需要挂载某个特定 key 的字段作为请求参数,比如这里我们约定它叫做 requestParams

const view = {
  template: `
    <span>
      <span>{{result?.name}}</span>
    </span>
  `,
  data() {
    // 发送请求的时候要带上它
    requestParams: {
      name: 'ssh'
    }  
  },
  props: ["result", "loading"],
};

改写下我们的 request 函数,让它为接受参数做好准备,

并且让它的 响应数据 原样返回 请求参数

// 假装这是一个 axios 请求函数
const request = (params) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(params);
    }, 1000);
  });
};

那么问题现在就在于我们如何在 hoc 组件中拿到 view 组件的值了,

平常我们怎么拿子组件实例的? 没错就是 ref,这里也用它:

const withPromise = (wrapped, promiseFn) => {
  return {
    data() { ... },
    async mounted() {
      this.loading = true;
      // 从子组件实例里拿到数据
      const { requestParams } = this.$refs.wrapped
      // 传递给请求函数
      const result = await promiseFn(requestParams).finally(() => {
        this.loading = false;
      });
      this.result = result;
    },
    render(h) {
      const args = {
        props: {
          result: this.result,
          loading: this.loading,
        },
        // 这里传个 ref,就能拿到子组件实例了,和平常模板中的用法一样。
        ref: 'wrapped'
      };

      const wrapper = h("div", [
        this.loading ? h("span", ["加载中……"]) : null,
        this.error ? h("span", ["加载错误"]) : null,
        h(wrapped, args),
      ]);

      return wrapper;
    },
  };
};

再来完成第二点,子组件的请求参数发生变化时,父组件也要响应式的重新发送请求,并且把新数据带给子组件。

const withPromise = (wrapped, promiseFn) => {
  return {
    data() { ... },
    methods: {
      // 请求抽象成方法
      async request() {
        this.loading = true;
        // 从子组件实例里拿到数据
        const { requestParams } = this.$refs.wrapped;
        // 传递给请求函数
        const result = await promiseFn(requestParams).finally(() => {
          this.loading = false;
        });
        this.result = result;
      },
    },
    async mounted() {
      // 立刻发送请求,并且监听参数变化重新请求
      this.$refs.wrapped.$watch("requestParams", this.request.bind(this), {
        immediate: true,
      });
    },
    render(h) { ... },
  };
};

第二个问题,我们只要在渲染子组件的时候把 $attrs$listeners$scopedSlots 传递下去即可,

此处的 $attrs 就是外部模板上声明的属性,$listeners 就是外部模板上声明的监听函数,

以这个例子来说:

<my-input value="ssh" @change="onChange" />

组件内部就能拿到这样的结构:

{
  $attrs: {
    value: 'ssh'
  },
  $listeners: {
    change: onChange
  }
}

注意,传递 $attrs$listeners 的需求不仅发生在高阶组件中,平常我们假如要对 el-input 这种组件封装一层变成 my-input 的话,如果要一个个声明 el-input 接受的 props,那得累死,直接透传 $attrs$listeners 即可,这样 el-input 内部还是可以照样处理传进去的所有参数。

// my-input 内部
<template>
  <el-input v-bind="$attrs" v-on="$listeners" />
</template>

那么在 render 函数中,可以这样透传:

const withPromise = (wrapped, promiseFn) => {
  return {
    ...,
    render(h) {
      const args = {
        props: {
          // 混入 $attrs
          ...this.$attrs,
          result: this.result,
          loading: this.loading,
        },

        // 传递事件
        on: this.$listeners,

        // 传递 $scopedSlots
        scopedSlots: this.$scopedSlots,
        ref: "wrapped",
      };

      const wrapper = h("div", [
        this.loading ? h("span", ["加载中……"]) : null,
        this.error ? h("span", ["加载错误"]) : null,
        h(wrapped, args),
      ]);

      return wrapper;
    },
  };
};

至此为止,完整的代码也就实现了:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>hoc-promise</title>
  </head>
  <body>
    <div id="app">
      <hoc msg="msg" @change="onChange">
        <template>
          <div>I am slot</div>
        </template>
        <template v-slot:named>
          <div>I am named slot</div>
        </template>
      </hoc>
    </div>
    <script src="./vue.js"></script>
    <script>
      var view = {
        props: ["result"],
        data() {
          return {
            requestParams: {
              name: "ssh",
            },
          };
        },
        methods: {
          reload() {
            this.requestParams = {
              name: "changed!!",
            };
          },
        },
        template: `
          <span>
            <span>{{result?.name}}</span>
            <slot></slot>
            <slot name="named"></slot>
            <button @click="reload">重新加载数据</button>
          </span>
        `,
      };

      const withPromise = (wrapped, promiseFn) => {
        return {
          data() {
            return {
              loading: false,
              error: false,
              result: null,
            };
          },
          methods: {
            async request() {
              this.loading = true;
              // 从子组件实例里拿到数据
              const { requestParams } = this.$refs.wrapped;
              // 传递给请求函数
              const result = await promiseFn(requestParams).finally(() => {
                this.loading = false;
              });
              this.result = result;
            },
          },
          async mounted() {
            // 立刻发送请求,并且监听参数变化重新请求
            this.$refs.wrapped.$watch(
              "requestParams",
              this.request.bind(this),
              {
                immediate: true,
              }
            );
          },
          render(h) {
            const args = {
              props: {
                // 混入 $attrs
                ...this.$attrs,
                result: this.result,
                loading: this.loading,
              },

              // 传递事件
              on: this.$listeners,

              // 传递 $scopedSlots
              scopedSlots: this.$scopedSlots,
              ref: "wrapped",
            };

            const wrapper = h("div", [
              this.loading ? h("span", ["加载中……"]) : null,
              this.error ? h("span", ["加载错误"]) : null,
              h(wrapped, args),
            ]);

            return wrapper;
          },
        };
      };

      const request = (data) => {
        return new Promise((r) => {
          setTimeout(() => {
            r(data);
          }, 1000);
        });
      };

      var hoc = withPromise(view, request);

      new Vue({
        el: "#app",
        components: {
          hoc,
        },
        methods: {
          onChange() {},
        },
      });
    </script>
  </body>
</html>

可以在 这里 预览代码效果。

我们开发新的组件,只要拿 hoc 过来复用即可,它的业务价值就体现出来了,代码被精简到不敢想象。

import { getListData } from 'api'
import { withPromise } from 'hoc'

const listView = {
  props: ["result"],
  template: `
    <ul v-if="result>
      <li v-for="item in result">
        {{ item }}
      </li>
    </ul>
  `,
};

export default withPromise(listView, getListData)

一切变得简洁而又优雅。

组合

注意,这一章节对于没有接触过 React 开发的同学可能很困难,可以先适当看一下或者跳过。

有一天,我们突然又很开心,写了个高阶组件叫 withLog,它很简单,就是在 mounted 声明周期帮忙打印一下日志。

const withLog = (wrapped) => {
  return {
    mounted() {
      console.log("I am mounted!")
    },
    render(h) {
      return h(wrapped)
    },
  }
}

这里我们发现,又要把onscopedSlots 等属性提取并且透传下去,其实挺麻烦的,我们封装一个从 this 上整合需要透传属性的函数:

function normalizeProps(vm) {
  return {
    on: vm.$listeners,
    attr: vm.$attrs,
    // 传递 $scopedSlots
    scopedSlots: vm.$scopedSlots,
  }
}

然后在 h 的第二个参数提取并传递即可。

const withLog = (wrapped) => {
  return {
    mounted() {
      console.log("I am mounted!")
    },
    render(h) {
      return h(wrapped, normalizeProps(this))
    },
  }
}

然后再包在刚刚的 hoc 之外:

var hoc = withLog(withPromise(view, request));

可以看出,这样的嵌套是比较让人头疼的,我们把 redux 这个库里的 compose 函数给搬过来,这个 compose 函数,其实就是不断的把函数给高阶化,返回一个新的函数。

function compose(...funcs) {
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

compose(a, b, c) 返回的是一个新的函数,这个函数会把传入的几个函数 嵌套执行

返回的函数签名:(...args) => a(b(c(...args)))

这个函数对于第一次接触的同学来说可能需要很长时间来理解,因为它确实非常复杂,但是一旦理解了,你的函数式**又更上一层楼了。

但是这也说明我们要改造 withPromise 高阶函数了,因为仔细观察这个 compose,它会包装函数,让它接受一个参数,并且把第一个函数的返回值 传递给下一个函数作为参数。

比如 compose(a, b) 来说,b(arg) 返回的值就会作为 a 的参数,进一步调用 a(b(args))

这需要保证参数只有一个。

那么按照这个思路,我们改造 withPromise,其实就是要进一步高阶化它,让它返回一个只接受一个参数的函数:

const withPromise = (promiseFn) => {
  // 返回的这一层函数 wrap,就符合我们的要求,只接受一个参数
  return function wrap(wrapped) {
    // 再往里一层 才返回组件
    return {
      mounted() {},
      render() {},
    }
  }
}

有了它以后,就可以更优雅的组合高阶组件了:

const compsosed = compose(
    withPromise(request),
    withLog,
)

const hoc = compsosed(view)

以上 compose 章节的完整代码 在这

注意,这一节如果第一次接触这些概念看不懂很正常,这些在 React 社区里很流行,但是在 Vue 社区里很少有人讨论!关于这个 compose 函数,第一次在 React 社区接触到它的时候我完全看不懂,先知道它的用法,慢慢理解也不迟。

真实业务场景

可能很多人觉得上面的代码实用价值不大,但是 vue-router高级用法文档 里就真实的出现了一个用高阶组件去解决问题的场景。

先简单的描述下场景,我们知道 vue-router 可以配置异步路由,但是在网速很慢的情况下,这个异步路由对应的 chunk 也就是组件代码,要等到下载完成后才会进行跳转。

这段下载异步组件的时间我们想让页面展示一个 Loading 组件,让交互更加友好。

Vue 文档-异步组件 这一章节,可以明确的看出 Vue 是支持异步组件声明 loading 对应的渲染组件的:

const AsyncComponent = () => ({
  // 需要加载的组件 (应该是一个 `Promise` 对象)
  component: import('./MyComponent.vue'),
  // 异步组件加载时使用的组件
  loading: LoadingComponent,
  // 加载失败时使用的组件
  error: ErrorComponent,
  // 展示加载时组件的延时时间。默认值是 200 (毫秒)
  delay: 200,
  // 如果提供了超时时间且组件加载也超时了,
  // 则使用加载失败时使用的组件。默认值是:`Infinity`
  timeout: 3000
})

我们试着把这段代码写到 vue-router 里,改写原先的异步路由:

new VueRouter({
    routes: [{
        path: '/',
-        component: () => import('./MyComponent.vue')
+        component: AsyncComponent
    }]
})

会发现根本不支持,深入调试了一下 vue-router 的源码发现,vue-router 内部对于异步组件的解析和 vue 的处理完全是两套不同的逻辑,在 vue-router 的实现中不会去帮你渲染 Loading 组件。

这个肯定难不倒机智的社区大佬们,我们转变一个思路,让 vue-router 先跳转到一个 容器组件,这个 容器组件 帮我们利用 Vue 内部的渲染机制去渲染 AsyncComponent ,不就可以渲染出 loading 状态了?具体代码如下:

由于 vue-router 的 component 字段接受一个 Promise,因此我们把组件用 Promise.resolve 包裹一层。

function lazyLoadView (AsyncView) {
  const AsyncHandler = () => ({
    component: AsyncView,
    loading: require('./Loading.vue').default,
    error: require('./Timeout.vue').default,
    delay: 400,
    timeout: 10000
  })

  return Promise.resolve({
    functional: true,
    render (h, { data, children }) {
      // 这里用 vue 内部的渲染机制去渲染真正的异步组件
      return h(AsyncHandler, data, children)
    }
  })
}
  
const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: () => lazyLoadView(import('./Foo.vue'))
    }
  ]
})

这样,在跳转的时候下载代码的间隙,一个漂亮的 Loading 组件就渲染在页面上了。

compose 拆解原理

这一章来一步步拆解 compose 函数,看看它到底做了什么样的事情,比较脑壳痛。

第一次接触这个函数的小伙伴还是酌情跳过吧。

假设现在是三个高阶组件的组合:

const compsosed = compose(
    withA,
    withB,
    withC
)

const hoc = compsosed(view)
  1. 首先在 reduce 的第一次循环里,awithAbwithB,然后 return 了:
(...args) => withA(withB(...args))

这个 return 的值就会作为 reduce 中下次循环的 a

  1. 下一次循环,那么此时的b 是我们假设的另一个高阶组件 withC,那么就 return 了
(...args2) => (...args) => withA(withB(...args))(withC(...args2))
               这里是a                          ↑这里是(b(args))
  1. 此时我们如果外部传入了 view,上一步中的 args2 就会被消除,这个函数会先归约成这样:
(...args) => withA(withB(...args))(withC(view))

此时 withC(view) 又进一步的作为...args去执行这个函数,进一步归约:

withA(withB(withC(view)))

可以看到,compose 函数不断的把函数高阶包裹,在执行的时候又一层一层的解包,非常巧妙的构思。

总结

本篇文章的所有代码都保存在 Github仓库 中,并且提供预览

谨以此文献给在我源码学习道路上给了我很大帮助的 《Vue技术内幕》 作者 hcysun 大佬,虽然我还没和他说过话,但是在我还是一个工作几个月的小白的时候,一次业务需求的思考就让我找到了这篇文章:探索Vue高阶组件 | HcySunYang

当时的我还不能看懂这篇文章中涉及到的源码问题和修复方案,然后改用了另一种方式实现了业务,但是这篇文章里提到的东西一直在我的心头萦绕,我在忙碌的工作之余努力学习源码,期望有朝一日能彻底看懂这篇文章。

时至今日我终于能理解文章中说到的 $vnodecontext 代表什么含义,但是这个 bug 在 Vue 2.6 版本由于 slot 的实现方式被重写,也顺带修复掉了,现在在 Vue 中使用最新的 slot 语法配合高阶函数,已经不会遇到这篇文章中提到的 bug 了。

❤️感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

深度解析:Vue3如何巧妙的实现强大的computed

前言

Vue中的computed是一个非常强大的功能,在computed函数中访问到的值改变了后,computed的值也会自动改变。

Vue2中的实现是利用了Watcher的嵌套收集,渲染watcher收集到computed watcher作为依赖,computed watcher又收集到响应式数据某个属性作为依赖,这样在响应式数据某个属性发生改变时,就会按照 响应式属性 -> computed值更新 -> 视图渲染这样的触发链触发过去,如果对Vue2中的原理感兴趣,可以看我这篇文章的解析:

手把手带你实现一个最精简的响应式系统来学习Vue的data、computed、watch源码

前置知识

阅读本文需要你先学习Vue3响应式的基本原理,可以先看我的这篇文章,原理和Vue3是一致的:
带你彻底搞懂Vue3的Proxy响应式原理!TypeScript从零实现基于Proxy的响应式库。

在你拥有了一些前置知识以后,默认你应该知道的是:

  1. effect其实就是一个依赖收集函数,在它内部访问了响应式数据,响应式数据就会把这个effect函数作为依赖收集起来,下次响应式数据改了就触发它重新执行。

  2. reactive返回的就是个响应式数据,这玩意可以和effect搭配使用。

举个简单的栗子吧:

// 响应式数据
const data = reactive({ count: 0 })
// 依赖收集
effect(() => console.log(data.count))
// 触发上面的effect重新执行
data.count ++

就这个例子来说,data是一个响应式数据。

effect传入的函数因为内部访问到它上面的属性count了,

所以形成了一个count -> effect的依赖。

下次count改变了,这个effect就会重新执行,就这么简单。

computed

那么引入本文中的核心概念,computed来改写这个例子后呢:

// 1. 响应式数据
const data = reactive({ count: 0 })
// 2. 计算属性
const plusOne = computed(() => data.count + 1)
// 3. 依赖收集
effect(() => console.log(plusOne.value))
// 4. 触发上面的effect重新执行
data.count ++

这样的例子也能跑通,为什么data.count的改变能间接触发访问了计算属性的effect的重新执行呢?

我们来配合单点调试一步步解析。

简化版源码

首先看一下简化版的computed的代码:

export function computed(
  getter
) {
  let dirty = true
  let value: T

  // 这里还是利用了effect做依赖收集
  const runner = effect(getter, {
    // 这里保证初始化的时候不去执行getter
    lazy: true,
    computed: true,
    scheduler: () => {
      // 在触发更新时 只是把dirty置为true 
      // 而不去立刻计算值 所以计算属性有lazy的特性
      dirty = true
    }
  })
  return {
    get value() {
      if (dirty) {
        // 在真正的去获取计算属性的value的时候
        // 依据dirty的值决定去不去重新执行getter 获取最新值
        value = runner()
        dirty = false
      }
      // 这里是关键 后续讲解
      trackChildRun(runner)
      return value
    },
    set value(newValue: T) {
      setter(newValue)
    }
  }
}

可以看到,computed其实也是一个effect。这里对闭包进行了巧妙的运用,注释里的几个关键点决定了计算属性拥有懒加载的特征,你不去读取value的时候,它是不会去真正的求值的。

前置准备

首先要知道,effect函数会立即开始执行,再执行之前,先把effect自身变成全局的activeEffect,以供响应式数据收集依赖。

并且activeEffect的记录是用栈的方式,随着函数的开始执行入栈,随着函数的执行结束出栈,这样就可以维护嵌套的effect关系。

先起几个别名便于讲解

// 计算effect
computed(() => data.count + 1)
// 日志effect
effect(() => console.log(plusOne.value))

从依赖关系来看,
日志effect读取了计算effect
计算effect读取了响应式属性count
所以更新的顺序也应该是:
count改变 -> 计算effect更新 -> 日志effect更新

那么这个关系链是如何形成的呢

单步解读

在日志effect开始执行的时候,

⭐⭐
此时activeEffect是日志effect

此时的effectStack是[ 日志effect ]
⭐⭐

plusOne.value的读取,触发了

 get value() {
      if (dirty) {
        // 在真正的去获取计算属性的value的时候
        // 依据dirty的值决定去不去重新执行getter 获取最新值
        value = runner()
        dirty = false
      }
      // 这里是关键 后续讲解
      trackChildRun(runner)
      return value
},

runner就是计算effect,进入了runner以后
⭐⭐
此时activeEffect是计算effect

此时的effectStack是[ 日志effect, 计算effect ]
⭐⭐
computed(() => data.count + 1)日志effect会去读取count,触发了响应式数据的get拦截:

此时count会收集计算effect作为自己的依赖。

并且计算effect会收集count的依赖集合,保存在自己身上。(通过effect.deps属性)

dep.add(activeEffect)
activeEffect.deps.push(dep)

也就是形成了一个双向收集的关系,

计算effect存了count的所有依赖,count也存了计算effect的依赖。

然后在runner运行结束后,计算effect出栈了,此时activeEffect变成了栈顶的日志effect

⭐⭐
此时activeEffect是日志effect

此时的effectStack是[ 日志effect ]
⭐⭐

接下来进入关键的步骤trackChildRun

trackChildRun(runner)  

function trackChildRun(childRunner: ReactiveEffect) {
  for (let i = 0; i < childRunner.deps.length; i++) {
    const dep = childRunner.deps[i]
    dep.add(activeEffect)
  }
}

这个runner就是计算effect,它的deps上此时挂着count的依赖集合,

trackChildRun中,它把当前的acctiveEffect也就是日志effect也加入到了count的依赖集合中。

此时count的依赖集合是这样的:[ 计算effect, 日志effect ]

这样下次count更新的时候,会把两个effect都重新触发,而由于触发的顺序是先触发computed effect 后触发普通effect,因此就完成了

  1. 计算effect的dirty置为true,标志着下次读取需要重新求值。
  2. 日志effect读取计算effect的value,获得最新的值并打印出来。

总结

不得不承认,computed这个强大功能的实现果然少不了内部非常复杂的实现,这个双向依赖收集的套路相信也会给各位小伙伴带来很大的启发。跟着尤大学习,果然有肉吃!

另外由于@vue/reactivity的框架无关性,我把它整合进了React,做了一个状态管理库,可以完整的使用上述的computed等强大的Vue3能力。

react-composition-api

有兴趣的小伙伴也可以看一下,star一下!

深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调

前言

关于 Event Loop 的文章很多,但是有很多只是在讲「宏任务」、「微任务」,我先提出几个问题:

  1. 每一轮 Event Loop 都会伴随着渲染吗?
  2. requestAnimationFrame 在哪个阶段执行,在渲染前还是后?在 microTask 的前还是后?
  3. requestIdleCallback 在哪个阶段执行?如何去执行?在渲染前还是后?在 microTask 的前还是后?
  4. resizescroll 这些事件是何时去派发的。

这些问题并不是刻意想刁难你,如果你不知道这些,那你可能并不能在遇到一个动画需求的时候合理的选择 requestAnimationFrame,你可能在做一些需求的时候想到了 requestIdleCallback,但是你不知道它运行的时机,只是胆战心惊的去用它,祈祷不要出线上 bug。

这也是本文想要从规范解读入手,深挖底层的动机之一。本文会酌情从规范中排除掉一些比较晦涩难懂,或者和主流程不太相关的概念。更详细的版本也可以直接去读这个规范,不过比较费时费力。

事件循环

我们先依据HTML 官方规范从浏览器的事件循环讲起,因为剩下的 API 都在这个循环中进行,它是浏览器调度任务的基础。

定义

为了协调事件,用户交互,脚本,渲染,网络任务等,浏览器必须使用本节中描述的事件循环。

流程

  1. 从任务队列中取出一个宏任务并执行。

  2. 检查微任务队列,执行并清空微任务队列,如果在微任务的执行中又加入了新的微任务,也会在这一步一起执行。

  3. 进入更新渲染阶段,判断是否需要渲染,这里有一个 rendering opportunity 的概念,也就是说不一定每一轮 event loop 都会对应一次浏览 器渲染,要根据屏幕刷新率、页面性能、页面是否在后台运行来共同决定,通常来说这个渲染间隔是固定的。(所以多个 task 很可能在一次渲染之间执行)

    • 浏览器会尽可能的保持帧率稳定,例如页面性能无法维持 60fps(每 16.66ms 渲染一次)的话,那么浏览器就会选择 30fps 的更新速率,而不是偶尔丢帧。
    • 如果浏览器上下文不可见,那么页面会降低到 4fps 左右甚至更低。
    • 如果满足以下条件,也会跳过渲染:
      1. 浏览器判断更新渲染不会带来视觉上的改变。
      2. map of animation frame callbacks 为空,也就是帧动画回调为空,可以通过 requestAnimationFrame 来请求帧动画。
  4. 如果上述的判断决定本轮不需要渲染,那么下面的几步也不会继续运行

    This step enables the user agent to prevent the steps below from running for other reasons, for example, to ensure certain tasks are executed immediately after each other, with only microtask checkpoints interleaved (and without, e.g., animation frame callbacks interleaved). Concretely, a user agent might wish to coalesce timer callbacks together, with no intermediate rendering updates.
    有时候浏览器希望两次「定时器任务」是合并的,他们之间只会穿插着 microTask的执行,而不会穿插屏幕渲染相关的流程(比如requestAnimationFrame,下面会写一个例子)。

  5. 对于需要渲染的文档,如果窗口的大小发生了变化,执行监听的 resize 方法。

  6. 对于需要渲染的文档,如果页面发生了滚动,执行 scroll 方法。

  7. 对于需要渲染的文档,执行帧动画回调,也就是 requestAnimationFrame 的回调。(后文会详解)

  8. 对于需要渲染的文档, 执行 IntersectionObserver 的回调。

  9. 对于需要渲染的文档,重新渲染绘制用户界面。

  10. 判断 task队列microTask队列是否都为空,如果是的话,则进行 Idle 空闲周期的算法,判断是否要执行 requestIdleCallback 的回调函数。(后文会详解)

对于resizescroll来说,并不是到了这一步才去执行滚动和缩放,那岂不是要延迟很多?浏览器当然会立刻帮你滚动视图,根据CSSOM 规范所讲,浏览器会保存一个 pending scroll event targets,等到事件循环中的 scroll这一步,去派发一个事件到对应的目标上,驱动它去执行监听的回调函数而已。resize也是同理。

可以在这个流程中仔细看一下「宏任务」、「微任务」、「渲染」之间的关系。

多任务队列

task 队列并不是我们想象中的那样只有一个,根据规范里的描述:

An event loop has one or more task queues. For example, a user agent could have one task queue for mouse and key events (to which the user interaction task source is associated), and another to which all other task sources are associated. Then, using the freedom granted in the initial step of the event loop processing model, it could give keyboard and mouse events preference over other tasks three-quarters of the time, keeping the interface responsive but not starving other task queues. Note that in this setup, the processing model still enforces that the user agent would never process events from any one task source out of order.

事件循环中可能会有一个或多个任务队列,这些队列分别为了处理:

  1. 鼠标和键盘事件
  2. 其他的一些 Task

浏览器会在保持任务顺序的前提下,可能分配四分之三的优先权给鼠标和键盘事件,保证用户的输入得到最高优先级的响应,而剩下的优先级交给其他 Task,并且保证不会“饿死”它们。

这个规范也导致 Vue 2.0.0-rc.7 这个版本 nextTick 采用了从微任务 MutationObserver 更换成宏任务 postMessage 而导致了一个 Issue

目前由于一些“未知”的原因,jsfiddle 的案例打不开了。简单描述一下就是采用了 task 实现的 nextTick,在用户持续滚动的情况下 nextTick 任务被延后了很久才去执行,导致动画跟不上滚动了。

迫于无奈,尤大还是改回了 microTask 去实现 nextTick,当然目前来说 promise.then 微任务已经比较稳定了,并且 Chrome 也已经实现了 queueMicroTask 这个官方 API。不久的未来,我们想要调用微任务队列的话,也可以节省掉实例化 Promise 在开销了。

从这个 Issue 的例子中我们可以看出,稍微去深入了解一下规范还是比较有好处的,以免在遇到这种比较复杂的 Bug 的时候一脸懵逼。

下面的章节中咱们来详细聊聊 requestIdleCallbackrequestAnimationFrame

requestAnimationFrame

以下内容中 requestAnimationFrame简称为rAF

在解读规范的过程中,我们发现 requestAnimationFrame 的回调有两个特征:

  1. 在重新渲染前调用。
  2. 很可能在宏任务之后不调用。

我们来分析一下,为什么要在重新渲染前去调用?因为 rAF 是官方推荐的用来做一些流畅动画所应该使用的 API,做动画不可避免的会去更改 DOM,而如果在渲染之后再去更改 DOM,那就只能等到下一轮渲染机会的时候才能去绘制出来了,这显然是不合理的。

rAF在浏览器决定渲染之前给你最后一个机会去改变 DOM 属性,然后很快在接下来的绘制中帮你呈现出来,所以这是做流畅动画的不二选择。下面我用一个 setTimeout的例子来对比。

闪烁动画

假设我们现在想要快速的让屏幕上闪烁 两种颜色,保证用户可以观察到,如果我们用 setTimeout 来写,并且带着我们长期的误解「宏任务之间一定会伴随着浏览器绘制」,那么你会得到一个预料之外的结果。

setTimeout(() => {
  document.body.style.background = "red"
  setTimeout(() => {
    document.body.style.background = "blue"
  })
})

可以看出这个结果是非常不可控的,如果这两个 Task 之间正好遇到了浏览器认定的渲染机会,那么它会重绘,否则就不会。由于这俩宏任务的间隔周期太短了,所以很大概率是不会的。

如果你把延时调整到 17ms 那么重绘的概率会大很多,毕竟这个是一般情况下 60fps 的一个指标。但是也会出现很多不绘制的情况,所以并不稳定。

如果你依赖这个 API 来做动画,那么就很可能会造成「掉帧」。

接下来我们换成 rAF 试试?我们用一个递归函数来模拟 10 次颜色变化的动画。

let i = 10
let req = () => {
  i--
  requestAnimationFrame(() => {
    document.body.style.background = "red"
    requestAnimationFrame(() => {
      document.body.style.background = "blue"
      if (i > 0) {
        req()
      }
    })
  })
}

req()

这里由于颜色变化太快,gif 录制软件没办法截出这么高帧率的颜色变换,所以各位可以放到浏览器中自己执行一下试试,我这边直接抛结论,浏览器会非常规律的把这 10 组也就是 20 次颜色变化绘制出来,可以看下 performance 面板记录的表现:

定时器合并

在第一节解读规范的时候,第 4 点中提到了,定时器宏任务可能会直接跳过渲染。

按照一些常规的理解来说,宏任务之间理应穿插渲染,而定时器任务就是一个典型的宏任务,看一下以下的代码:

setTimeout(() => {
  console.log("sto")
  requestAnimationFrame(() => console.log("rAF"))
})
setTimeout(() => {
  console.log("sto")
  requestAnimationFrame(() => console.log("rAF"))
})

queueMicrotask(() => console.log("mic"))
queueMicrotask(() => console.log("mic"))

从直觉上来看,顺序是不是应该是:

mic
mic
sto
rAF
sto
rAF

呢?也就是每一个宏任务之后都紧跟着一次渲染。

实际上不会,浏览器会合并这两个定时器任务:

mic
mic
sto
sto
rAF
rAF

requestIdleCallback

草案解读

以下内容中 requestIdleCallback简称为rIC

我们都知道 requestIdleCallback 是浏览器提供给我们的空闲调度算法,关于它的简介可以看 MDN 文档,意图是让我们把一些计算量较大但是又没那么紧急的任务放到空闲时间去执行。不要去影响浏览器中优先级较高的任务,比如动画绘制、用户输入等等。

React 的时间分片渲染就想要用到这个 API,不过目前浏览器支持的不给力,他们是自己去用 postMessage 实现了一套。

渲染有序进行

首先看一张图,很精确的描述了这个 API 的意图:

当然,这种有序的 浏览器 -> 用户 -> 浏览器 -> 用户 的调度基于一个前提,就是我们要把任务切分成比较小的片,不能说浏览器把空闲时间让给你了,你去执行一个耗时 10s 的任务,那肯定也会把浏览器给阻塞住的。这就要求我们去读取 rIC 提供给你的 deadline 里的时间,去动态的安排我们切分的小任务。浏览器信任了你,你也不能辜负它呀。

渲染长期空闲


还有一种情况,也有可能在几帧的时间内浏览器都是空闲的,并没有发生任何影响视图的操作,它也就不需要去绘制页面:
这种情况下为什么还是会有 50msdeadline 呢?是因为浏览器为了提前应对一些可能会突发的用户交互操作,比如用户输入文字。如果给的时间太长了,你的任务把主线程卡住了,那么用户的交互就得不到回应了。50ms 可以确保用户在无感知的延迟下得到回应。

MDN 文档中的幕后任务协作调度 API 介绍的比较清楚,来根据里面的概念做个小实验:

屏幕中间有个红色的方块,把 MDN 文档中requestAnimationFrame的范例部分的动画代码直接复制过来。

草案中还提到:

  1. 当浏览器判断这个页面对用户不可见时,这个回调执行的频率可能被降低到 10 秒执行一次,甚至更低。这点在解读 EventLoop 中也有提及。

  2. 如果浏览器的工作比较繁忙的时候,不能保证它会提供空闲时间去执行 rIC 的回调,而且可能会长期的推迟下去。所以如果你需要保证你的任务在一定时间内一定要执行掉,那么你可以给 rIC 传入第二个参数 timeout
    这会强制浏览器不管多忙,都在超过这个时间之后去执行 rIC 的回调函数。所以要谨慎使用,因为它会打断浏览器本身优先级更高的工作。

  3. 最长期限为 50 毫秒,是根据研究得出的,研究表明,人们通常认为 100 毫秒内对用户输入的响应是瞬时的。 将闲置截止期限设置为 50ms 意味着即使在闲置任务开始后立即发生用户输入,浏览器仍然有剩余的 50ms 可以在其中响应用户输入而不会产生用户可察觉的滞后。

  4. 每次调用 timeRemaining() 函数判断是否有剩余时间的时候,如果浏览器判断此时有优先级更高的任务,那么会动态的把这个值设置为 0,否则就是用预先设置好的 deadline - now 去计算。

  5. 这个 timeRemaining() 的计算非常动态,会根据很多因素去决定,所以不要指望这个时间是稳定的。

动画例子

滚动

如果我鼠标不做任何动作和交互,直接在控制台通过 rIC 去打印这次空闲任务的剩余时间,一般都稳定维持在 49.xx ms,因为此时浏览器没有什么优先级更高的任务要去处理。

而如果我不停的滚动浏览器,不断的触发浏览器的重新绘制的话,这个时间就变的非常不稳定了。

通过这个例子,你可以更加有体感的感受到什么样叫做「繁忙」,什么样叫做「空闲」。

动画

这个动画的例子很简单,就是利用rAF在每帧渲染前的回调中把方块的位置向右移动 10px。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #SomeElementYouWantToAnimate {
        height: 200px;
        width: 200px;
        background: red;
      }
    </style>
  </head>
  <body>
    <div id="SomeElementYouWantToAnimate"></div>
    <script>
      var start = null
      var element = document.getElementById("SomeElementYouWantToAnimate")
      element.style.position = "absolute"

      function step(timestamp) {
        if (!start) start = timestamp
        var progress = timestamp - start
        element.style.left = Math.min(progress / 10, 200) + "px"
        if (progress < 2000) {
          window.requestAnimationFrame(step)
        }
      }
      // 动画
      window.requestAnimationFrame(step)

      // 空闲调度
      window.requestIdleCallback(() => {
        alert("rIC")
      })
    </script>
  </body>
</html>

注意在最后我加了一个 requestIdleCallback 的函数,回调里会 alert('rIC'),来看一下演示效果:

alert 在最开始的时候就执行了,为什么会这样呢一下,想一下「空闲」的概念,我们每一帧仅仅是把 left 的值移动了一下,做了这一个简单的渲染,没有占满空闲时间,所以可能在最开始的时候,浏览器就找到机会去调用 rIC 的回调函数了。

我们简单的修改一下 step 函数,在里面加一个很重的任务,1000 次循环打印。

function step(timestamp) {
  if (!start) start = timestamp
  var progress = timestamp - start
  element.style.left = Math.min(progress / 10, 200) + "px"
  let i = 1000
  while (i > 0) {
    console.log("i", i)
    i--
  }
  if (progress < 2000) {
    window.requestAnimationFrame(step)
  }
}

再来看一下它的表现:

其实和我们预期的一样,由于浏览器的每一帧都"太忙了",导致它真的就无视我们的 rIC 函数了。

如果给 rIC 函数加一个 timeout 呢:

// 空闲调度
window.requestIdleCallback(
  () => {
    alert("rID")
  },
  { timeout: 500 },
)

浏览器会在大概 500ms 的时候,不管有多忙,都去强制执行 rIC 函数,这个机制可以防止我们的空闲任务被“饿死”。

总结

通过本文的学习过程,我自己也打破了很多对于 Event Loop 以及 rAF、rIC 函数的固有错误认知,通过本文我们可以整理出以下的几个关键点。

  1. 事件循环不一定每轮都伴随着重渲染,但是如果有微任务,一定会伴随着微任务执行
  2. 决定浏览器视图是否渲染的因素很多,浏览器是非常聪明的。
  3. requestAnimationFrame在重新渲染屏幕之前执行,非常适合用来做动画。
  4. requestIdleCallback在渲染屏幕之后执行,并且是否有空执行要看浏览器的调度,如果你一定要它在某个时间内执行,请使用 timeout参数。
  5. resizescroll事件其实自带节流,它只在 Event Loop 的渲染阶段去派发事件到 EventTarget 上。

另外,本文也是对于规范的解读,规范里的一些术语比较晦涩难懂,所以我也结合了一些自己的理解去写这篇文章,如果有错误的地方欢迎各位小伙伴指出。

参考资料

HTML 规范文档

W3C 标准

Vue 源码详解之 nextTick:MutationObserver 只是浮云,microtask 才是核心!(强烈推荐这篇文章)

❤️ 感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

React-Redux 100行代码简易版探究原理

前言

各位使用react技术栈的小伙伴都不可避免的接触过redux + react-redux的这套组合,众所周知redux是一个非常精简的库,它和react是没有做任何结合的,甚至可以在vue项目中使用。

redux的核心状态管理实现其实就几行代码

function createStore(reducer) {
 let currentState
 let subscribers = []

 function dispatch(action) {
   currentState = reducer(currentState, action);
   subscribers.forEach(s => s())
 }

 function getState() {
   return currentState;
 }
 
 function subscribe(subscriber) {
     subscribers.push(subscriber)
     return function unsubscribe() {
         ...
     }
 }

 dispatch({ type: 'INIT' });

 return {
   dispatch,
   getState,
 };
}

它就是利用闭包管理了state等变量,然后在dispatch的时候通过用户定义reducer拿到新状态赋值给state,再把外部通过subscribe的订阅给触发一下。

那redux的实现简单了,react-redux的实现肯定就需要相对复杂,它需要考虑如何和react的渲染结合起来,如何优化性能。

目标

  1. 本文目标是尽可能简短的实现react-reduxv7中的hook用法部分Provider, useSelector, useDispatch方法。(不实现connect方法)
  2. 可能会和官方版本的一些复杂实现不一样,但是保证主要的流程一致。
  3. 用TypeScript实现,并且能获得完善的类型提示。

预览

redux gif.gif
预览地址:https://sl1673495.github.io/tiny-react-redux

性能

说到性能这个点,自从React Hook推出以后,有了useContextuseReducer这些方便的api,新的状态管理库如同雨后春笋版的冒了出来,其中的很多就是利用了Context做状态的向下传递。

举一个最简单的状态管理的例子

export const StoreContext = React.createContext();

function App({ children }) {
 const [state, setState] = useState({});
 return <StoreContext.Provider value={{ state, setState }}>{children}</StoreContext.Provider>;
}

function Son() {
 const { state } = useContext(StoreContext);
 return <div>state是{state.xxx}</div>;
}

利用useState或者useContext,可以很轻松的在所有组件之间通过Context共享状态。

但是这种模式的缺点在于Context会带来一定的性能问题,下面是React官方文档中的描述:

Context性能问题

想像这样一个场景,在刚刚所描述的Context状态管理模式下,我们的全局状态中有countmessage两个状态分别给通过StoreContext.Provider向下传递

  1. Counter计数器组件使用了count
  2. Chatroom聊天室组件使用了message

而在计数器组件通过Context中拿到的setState触发了count改变的时候,

由于聊天室组件也利用useContext消费了用于状态管理的StoreContext,所以聊天室组件也会被强制重新渲染,这就造成了性能浪费。

虽然这种情况可以用useMemo进行优化,但是手动优化和管理依赖必然会带来一定程度的心智负担,而在不手动优化的情况下,肯定无法达到上面动图中的重渲染优化。

那么react-redux作为社区知名的状态管理库,肯定被很多大型项目所使用,大型项目里的状态可能分散在各个模块下,它是怎么解决上述的性能缺陷的呢?接着往下看吧。

缺陷示例

在我之前写的类vuex语法的状态管理库react-vuex-hook中,就会有这样的问题。因为它就是用了Context + useReducer的模式。

你可以直接在 在线示例 这里,在左侧菜单栏选择需要优化的场景,即可看到上述性能问题的重现,优化方案也已经写在文档底部。

这也是为什么我觉得Context + useReducer的模式更适合在小型模块之间共享状态,而不是在全局。

实现

介绍

本文的项目就上述性能场景提炼而成,由

  1. 聊天室组件,用了store中的count
  2. 计数器组件,用了store中的message
  3. 控制台组件,用来监控组件的重新渲染。

用最简短的方式实现代码,探究react-redux为什么能在count发生改变的时候不让使用了message的组件重新渲染。

redux的定义

redux的使用很传统,跟着官方文档对于TypeScript的指导走起来,并且把类型定义和store都export出去。

import { createStore } from 'redux';

type AddAction = {
  type: 'add';
};

type ChatAction = {
  type: 'chat';
  payload: string;
};

type LogAction = {
  type: 'log';
  payload: string;
};

const initState = {
  message: 'Hello',
  logs: [] as string[],
};

export type ActionType = AddAction | ChatAction | LogAction;
export type State = typeof initState;

function reducer(state: State, action: ActionType): State {
  switch (action.type) {
    case 'add':
      return {
        ...state,
        count: state.count + 1,
      };
    case 'chat':
      return {
        ...state,
        message: action.payload,
      };
    case 'log':
      return {
        ...state,
        logs: [action.payload, ...state.logs],
      };
    default:
      return initState;
  }
}

export const store = createStore(reducer);

在项目中使用

import React, { useState, useCallback } from 'react';
import { Card, Button, Input } from 'antd';
import { Provider, useSelector, useDispatch } from '../src';
import { store, State, ActionType } from './store';
import './index.css';
import 'antd/dist/antd.css';

function Count() {
  const count = useSelector((state: State) => state.count);
  const dispatch = useDispatch<ActionType>();
  // 同步的add
  const add = useCallback(() => dispatch({ type: 'add' }), []);

  dispatch({
    type: 'log',
    payload: '计数器组件重新渲染🚀',
  });
  return (
    <Card hoverable style={{ marginBottom: 24 }}>
      <h1>计数器</h1>
      <div className="chunk">
        <div className="chunk">store中的count现在是 {count}</div>
        <Button onClick={add}>add</Button>
      </div>
    </Card>
  );
}

export default () => {
  return (
    <Provider store={store}>
      <Count />
    </Provider>
  );
};

可以看到,我们用Provider组件里包裹了Count组件,并且把redux的store传递了下去

在子组件里,通过useDispatch可以拿到redux的dispatch, 通过useSelector可以访问到store,拿到其中任意的返回值。

构建Context

利用官方api构建context,并且提供一个自定义hook: useReduxContext去访问这个context,对于忘了用Provider包裹的情况进行一些错误提示:

对于不熟悉自定义hook的小伙伴,可以看我之前写的这篇文章:
使用React Hooks + 自定义Hook封装一步一步打造一个完善的小型应用。

import React, { useContext } from 'react';
import { Store } from 'redux';

interface ContextType {
  store: Store;
}
export const Context = React.createContext<ContextType | null>(null);

export function useReduxContext() {
  const contextValue = useContext(Context);

  if (!contextValue) {
    throw new Error(
      'could not find react-redux context value; please ensure the component is wrapped in a <Provider>',
    );
  }

  return contextValue;
}

实现Provider

import React, { FC } from 'react';
import { Store } from 'redux';
import { Context } from './Context';

interface ProviderProps {
  store: Store;
}

export const Provider: FC<ProviderProps> = ({ store, children }) => {
  return <Context.Provider value={{ store }}>{children}</Context.Provider>;
};

实现useDispatch

这里就是简单的把dispatch返回出去,通过泛型传递让外部使用的时候可以获得类型提示。

泛型推导不熟悉的小伙伴可以看一下之前这篇:
进阶实现智能类型推导的简化版Vuex

import { useReduxContext } from './Context';
import { Dispatch, Action } from 'redux';

export function useDispatch<A extends Action>() {
  const { store } = useReduxContext();
  return store.dispatch as Dispatch<A>;
}

实现useSelector

这里才是重点,这个api有两个参数。

  1. selector: 定义如何从state中取值,如state => state.count
  2. equalityFn: 定义如何判断渲染之间值是否有改变。

在性能章节也提到过,大型应用中必须做到只有自己使用的状态改变了,才去重新渲染,那么equalityFn就是判断是否渲染的关键了。

关键流程(初始化):

  1. 根据传入的selector从redux的store中取值。
  2. 定义一个latestSelectedState保存上一次selector返回的值。
  3. 定义一个checkForceUpdate方法用来控制当状态发生改变的时候,让当前组件的强制渲染。
  4. 利用store.subscribe订阅一次redux的store,下次redux的store发生变化执行checkForceUpdate

关键流程(更新)

  1. 当用户使用dispatch触发了redux store的变动后,store会触发checkForceUpdate方法。
  2. checkForceUpdate中,从latestSelectedState拿到上一次selector的返回值,再利用selector(store)拿到最新的值,两者利用equalityFn进行比较。
  3. 根据比较,判断是否需要强制渲染组件。

有了这个思路,就来实现代码吧:

import { useReducer, useRef, useEffect } from 'react';
import { useReduxContext } from './Context';

type Selector<State, Selected> = (state: State) => Selected;
type EqualityFn<Selected> = (a: Selected, b: Selected) => boolean;

// 默认比较的方法
const defaultEqualityFn = <T>(a: T, b: T) => a === b;
export function useSelector<State, Selected>(
  selector: Selector<State, Selected>,
  equalityFn: EqualityFn<Selected> = defaultEqualityFn,
) {
  const { store } = useReduxContext();
  // 强制让当前组件渲染的方法。
  const [, forceRender] = useReducer(s => s + 1, 0);

  // 存储上一次selector的返回值。
  const latestSelectedState = useRef<Selected>();
  // 根据用户传入的selector,从store中拿到用户想要的值。
  const selectedState = selector(store.getState());

  // 检查是否需要强制更新
  function checkForUpdates() {
    // 从store中拿到最新的值
    const newSelectedState = selector(store.getState());

    // 如果比较相等,就啥也不做
    if (equalityFn(newSelectedState, latestSelectedState.current)) {
      return;
    }
    // 否则更新ref中保存的上一次渲染的值
    // 然后强制渲染
    latestSelectedState.current = newSelectedState;
    forceRender();
  }
  
  // 组件第一次渲染后 执行订阅store的逻辑
  useEffect(() => {
  
    // 🚀重点,去订阅redux store的变化
    // 在用户调用了dispatch后,执行checkForUpdates
    const unsubscribe = store.subscribe(checkForUpdates);
    
    // 组件被销毁后 需要调用unsubscribe停止订阅
    return unsubscribe;
  }, []);
  
  return selectedState;
}

总结

本文涉及到的源码地址:
https://github.com/sl1673495/tiny-react-redux

原版的react-redux的实现肯定比这里的简化版要复杂的多,它要考虑class组件的使用,以及更多的优化以及边界情况。

从简化版的实现入手,我们可以更清晰的得到整个流程脉络,如果你想进一步的学习源码,也可以考虑多花点时间去看官方源码并且单步调试。

Vue 的生命周期之间到底做了什么事清?(源码详解)

前言

相信大家对 Vue 有哪些生命周期早就已经烂熟于心,但是对于这些生命周期的前后分别做了哪些事情,可能还有些不熟悉。

本篇文章就从一个完整的流程开始,详细讲解各个生命周期之间发生了什么事情。

注意本文不涉及 keep-alive 的场景和错误处理的场景。

初始化流程

new Vue

new Vue(options) 开始作为入口,Vue 只是一个简单的构造函数,内部是这样的:

function Vue (options) {
  this._init(options)
}

进入了 _init 函数之后,先初始化了一些属性,然后开始第一个生命周期:

callHook(vm, 'beforeCreate')

beforeCreate被调用完成

beforeCreate 之后

  1. 初始化 inject
  2. 初始化 state
    • 初始化 props
    • 初始化 methods
    • 初始化 data
    • 初始化 computed
    • 初始化 watch
  3. 初始化 provide

所以在 data 中可以使用 props 上的值,反过来则不行。

然后进入 created 阶段:

callHook(vm, 'created')

created被调用完成

调用 $mount 方法,开始挂载组件到 dom 上。

如果使用了 runtime-with-compile 版本,则会把你传入的 template 选项,或者 html 文本,通过一系列的编译生成 render 函数。

  • 编译这个 template,生成 ast 抽象语法树。
  • 优化这个 ast,标记静态节点。(渲染过程中不会变的那些节点,优化性能)。
  • 根据 ast,生成 render 函数。

对应具体的代码就是:

const ast = parse(template.trim(), options)
if (options.optimize !== false) {
  optimize(ast, options)
}
const code = generate(ast, options)

如果是脚手架搭建的项目的话,这一步 vue-cli 已经帮你做好了,所以就直接进入 mountComponent 函数。

那么,确保有了 render 函数后,我们就可以往渲染的步骤继续进行了

beforeMount被调用完成

渲染组件的函数 定义好,具体代码是:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

拆解来看,vm._render 其实就是调用我们上一步拿到的 render 函数生成一个 vnode,而 vm._update 方法则会对这个 vnode 进行 patch 操作,帮我们把 vnode 通过 createElm函数创建新节点并且渲染到 dom节点 中。

接下来就是执行这段代码了,是由 响应式原理 的一个核心类 Watcher 负责执行这个函数,为什么要它来代理执行呢?因为我们需要在这段过程中去 观察 这个函数读取了哪些响应式数据,将来这些响应式数据更新的时候,我们需要重新执行 updateComponent 函数。

如果是更新后调用 updateComponent 函数的话,updateComponent 内部的 patch 就不再是初始化时候的创建节点,而是对新旧 vnode 进行 diff,最小化的更新到 dom节点 上去。具体过程可以看我的上一篇文章:

为什么 Vue 中不要用 index 作为 key?(diff 算法详解)

这一切交给 Watcher 完成:

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

注意这里在before 属性上定义了beforeUpdate 函数,也就是说在 Watcher 被响应式属性的更新触发之后,重新渲染新视图之前,会先调用 beforeUpdate 生命周期。

关于 Watcher 和响应式的概念,如果你还不清楚的话,可以阅读我之前的文章:

手把手带你实现一个最精简的响应式系统来学习Vue的data、computed、watch源码

注意,在 render 的过程中,如果遇到了 子组件,则会调用 createComponent 函数。

createComponent 函数内部,会为子组件生成一个属于自己的构造函数,可以理解为子组件自己的 Vue 函数:

Ctor = baseCtor.extend(Ctor)

在普通的场景下,其实这就是 Vue.extend 生成的构造函数,它继承自 Vue 函数,拥有它的很多全局属性。

这里插播一个知识点,除了组件有自己的生命周期外,其实 vnode 也是有自己的 生命周期的,只不过我们平常开发的时候是接触不到的。

那么子组件的 vnode 会有自己的 init 周期,这个周期内部会做这样的事情:

// 创建子组件
const child = createComponentInstanceForVnode(vnode)
// 挂载到 dom 上
child.$mount(vnode.elm)

createComponentInstanceForVnode 内部又做了什么事呢?它会去调用 子组件 的构造函数。

new vnode.componentOptions.Ctor(options)

构造函数的内部是这样的:

const Sub = function VueComponent (options) {
  this._init(options)
}

这个 _init 其实就是我们文章开头的那个函数,也就是说,如果遇到 子组件,那么就会优先开始子组件的构建过程,也就是说,从 beforeCreated 重新开始。这是一个递归的构建过程。

也就是说,如果我们有 父 -> 子 -> 孙 这三个组件,那么它们的初始化生命周期顺序是这样的:

 beforeCreate 
 create 
 beforeMount 
 beforeCreate 
 create 
 beforeMount 
 beforeCreate 
 create 
 beforeMount 
 mounted 
 mounted 
 mounted 

然后,mounted 生命周期被触发。

mounted被调用完成

到此为止,组件的挂载就完成了,初始化的生命周期结束。

更新流程

当一个响应式属性被更新后,触发了 Watcher 的回调函数,也就是 vm._update(vm._render()),在更新之前,会先调用刚才在 before 属性上定义的函数,也就是

callHook(vm, 'beforeUpdate')

注意,由于 Vue 的异步更新机制,beforeUpdate 的调用已经是在 nextTick 中了。
具体代码如下:

nextTick(flushSchedulerQueue)

function flushSchedulerQueue {
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
     // callHook(vm, 'beforeUpdate')
      watcher.before()
    }
 }
}

beforeUpdate被调用完成

然后经历了一系列的 patchdiff 流程后,组件重新渲染完毕,调用 updated 钩子。

注意,这里是对 watcher 倒序 updated 调用的。

也就是说,假如同一个属性通过 props 分别流向 父 -> 子 -> 孙 这个路径,那么收集到依赖的先后也是这个顺序,但是触发 updated 钩子确是 孙 -> 子 -> 父 这个顺序去触发的。

function callUpdatedHooks (queue) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted) {
      callHook(vm, 'updated')
    }
  }
}

updated被调用完成

至此,渲染更新流程完毕。

销毁流程

在刚刚所说的更新后的 patch 过程中,如果发现有组件在下一轮渲染中消失了,比如 v-for 对应的数组中少了一个数据。那么就会调用 removeVnodes 进入组件的销毁流程。

removeVnodes 会调用 vnodedestroy 生命周期,而 destroy 内部则会调用我们相对比较熟悉的 vm.$destroy()。(keep-alive 包裹的子组件除外)

这时,就会调用 callHook(vm, 'beforeDestroy')

beforeDestroy被调用完成

之后就会经历一系列的清理逻辑,清除父子关系、watcher 关闭等逻辑。但是注意,$destroy 并不会把组件从视图上移除,如果想要手动销毁一个组件,则需要我们自己去完成这个逻辑。

然后,调用最后的 callHook(vm, 'destroyed')

destroyed被调用完成

总结

至此为止,Vue 的生命周期我们就完整的回顾了一遍。知道各个生命周期之间发生了什么事,可以让我们在编写 Vue 组件的过程中更加胸有成竹。

希望这篇文章对你有帮助。

❤️感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

Koa的洋葱中间件,Redux的中间件,Axios的拦截器让你迷惑吗?实现一个精简版的就彻底搞懂了。

前言

前端中的库很多,开发这些库的作者会尽可能的覆盖到大家在业务中千奇百怪的需求,但是总有无法预料到的,所以优秀的库就需要提供一种机制,让开发者可以干预插件中间的一些环节,从而完成自己的一些需求。

本文将从koaaxiosvuexredux的实现来教你怎么编写属于自己的插件机制。

  • 对于新手来说:
    本文能让你搞明白神秘的插件和拦截器到底是什么东西。

  • 对于老手来说:
    在你写的开源框架中也加入拦截器或者插件机制,让它变得更加强大吧!

axios

首先我们模拟一个简单的axios,

const axios = config => {
  if (config.error) {
    return Promise.reject({
      error: 'error in axios',
    });
  } else {
    return Promise.resolve({
      ...config,
      result: config.result,
    });
  }
};

如果传入的config中有error参数,就返回一个rejected的promise,反之则返回resolved的promise。

先简单看一下axios官方提供的拦截器示例:

axios.interceptors.request.use(function (config) {
    // 在发送请求之前做些什么
    return config;
  }, function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
  });

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
    // 对响应数据做点什么
    return response;
  }, function (error) {
    // 对响应错误做点什么
    return Promise.reject(error);
  });

可以看出,不管是request还是response的拦截求,都会接受两个函数作为参数,一个是用来处理正常流程,一个是处理失败流程,这让人想到了什么?

没错,promise.then接受的同样也是这两个参数。

axios内部正是利用了promise的这个机制,把use传入的每一对函数作为一个intercetpor

// 把
axios.interceptors.response.use(func1, func2)

// 注册为
const interceptor = {
    resolved: func1,
    rejected: func2
}

接下来简单实现一下:

// 先构造一个对象 存放拦截器
axios.interceptors = {
  request: [],
  response: [],
};

// 注册请求拦截器
axios.useRequestInterceptor = (resolved, rejected) => {
  axios.interceptors.request.push({ resolved, rejected });
};

// 注册响应拦截器
axios.useResponseInterceptor = (resolved, rejected) => {
  axios.interceptors.response.push({ resolved, rejected });
};

// 运行拦截器
axios.run = config => {
  const chain = [
    {
      resolved: axios,
      rejected: undefined,
    },
  ];

  // 把请求拦截器往数组头部推
  axios.interceptors.request.forEach(interceptor => {
    chain.unshift(interceptor);
  });

  // 把响应拦截器往数组尾部推
  axios.interceptors.response.forEach(interceptor => {
    chain.push(interceptor);
  });

  // 把config也包装成一个promise
  let promise = Promise.resolve(config);

  // 暴力while循环解忧愁 
  // 利用promise.then的能力递归执行所有的拦截器
  while (chain.length) {
    const { resolved, rejected } = chain.shift();
    promise = promise.then(resolved, rejected);
  }

  // 最后暴露给用户的就是响应拦截器处理过后的promise
  return promise;
};

axios.run这个函数看运行时的机制,首先构造一个chain作为promise链,并且把正常的请求也就是我们的请求参数axios也构造为一个拦截器的结构,接下来

  • 把request的interceptor给unshift到函数顶部
  • 把response的interceptor给push到函数尾部

以这样一段调用代码为例:

axios.useRequestInterceptor(resolved1, rejected1); // requestInterceptor1 

axios.useRequestInterceptor(resolved2, rejected2); // requestInterceptor2 

axios.useResponseInterceptor(resolved1, rejected1); // responseInterceptor1 

axios.useResponseInterceptor(resolved2, rejected2); // responseInterceptor2

这样子构造出来的promise链就是这样的chain结构:

[
    requestInterceptor2,
    requestInterceptor1,
    axios,
    responseInterceptor1,
    responseInterceptor2
]

至于为什么requestInterceptor的顺序是反过来的,仔细看看代码就知道 XD。

有了这个chain之后,只需要一句简短的代码:

 let promise = Promise.resolve(config);

  while (chain.length) {
    const { resolved, rejected } = chain.shift();
    promise = promise.then(resolved, rejected);
  }

  return promise;

promise就会把这个链从上而下的执行了。

以这样的一段测试代码为例:

axios.useRequestInterceptor(config => {
  return {
    ...config,
    extraParams1: 'extraParams1',
  };
});

axios.useRequestInterceptor(config => {
  return {
    ...config,
    extraParams2: 'extraParams2',
  };
});

axios.useResponseInterceptor(
  resp => {
    const {
      extraParams1,
      extraParams2,
      result: { code, message },
    } = resp;
    return `${extraParams1} ${extraParams2} ${message}`;
  },
  error => {
    console.log('error', error)
  },
);
  1. 成功的调用

在成功的调用下输出 result1: extraParams1 extraParams2 message1

(async function() {
  const result = await axios.run({
    message: 'message1',
  });
  console.log('result1: ', result);
})();
  1. 失败的调用
(async function() {
  const result = await axios.run({
    error: true,
  });
  console.log('result3: ', result);
})();

在失败的调用下,则进入响应拦截器的rejected分支:

首先打印出拦截器定义的错误日志:
error { error: 'error in axios' }

然后由于失败的拦截器

error => {
  console.log('error', error)
},

没有返回任何东西,打印出result3: undefined

可以看出,axios的拦截器是非常灵活的,可以在请求阶段任意的修改config,也可以在响应阶段对response做各种处理,这也是因为用户对于请求数据的需求就是非常灵活的,没有必要干涉用户的自由度。

vuex

vuex提供了一个api用来在action被调用前后插入一些逻辑:

https://vuex.vuejs.org/zh/api/#subscribeaction

store.subscribeAction({
  before: (action, state) => {
    console.log(`before action ${action.type}`)
  },
  after: (action, state) => {
    console.log(`after action ${action.type}`)
  }
})

其实这有点像AOP(面向切面编程)的编程**。

在调用store.dispatch({ type: 'add' })的时候,会在执行前后打印出日志

before action add
add
after action add

来简单实现一下:

import { Actions, ActionSubscribers, ActionSubscriber, ActionArguments } from './vuex.type';

class Vuex {
  state = {};

  action = {};

  _actionSubscribers = [];

  constructor({ state, action }) {
    this.state = state;
    this.action = action;
    this._actionSubscribers = [];
  }

  dispatch(action) {
    // action前置监听器
    this._actionSubscribers
      .forEach(sub => sub.before(action, this.state));

    const { type, payload } = action;
    
    // 执行action
    this.action[type](this.state, payload).then(() => {
       // action后置监听器
      this._actionSubscribers
        .forEach(sub => sub.after(action, this.state));
    });
  }

  subscribeAction(subscriber) {
    // 把监听者推进数组
    this._actionSubscribers.push(subscriber);
  }
}

const store = new Vuex({
  state: {
    count: 0,
  },
  action: {
    async add(state, payload) {
      state.count += payload;
    },
  },
});

store.subscribeAction({
  before: (action, state) => {
    console.log(`before action ${action.type}, before count is ${state.count}`);
  },
  after: (action, state) => {
    console.log(`after action ${action.type},  after count is ${state.count}`);
  },
});

store.dispatch({
  type: 'add',
  payload: 2,
});

此时控制台会打印如下内容:

before action add, before count is 0
after action add, after count is 2

轻松实现了日志功能。

当然Vuex在实现插件功能的时候,选择性的将 type payload 和 state暴露给外部,而不再提供进一步的修改能力,这也是框架内部的一种权衡,当然我们可以对state进行直接修改,但是不可避免的会得到Vuex内部的警告,因为在Vuex中,所有state的修改都应该通过mutations来进行,但是Vuex没有选择把commit也暴露出来,这也约束了插件的能力。

redux

想要理解redux中的中间件机制,需要先理解一个方法:compose

function compose(...funcs: Function[]) {
  return funcs.reduce((a, b) => (...args: any) => a(b(...args)))
}

简单理解的话,就是compose(fn1, fn2, fn3) (...args) = > fn1(fn2(fn3(...args)))
它是一种高阶聚合函数,相当于把fn3先执行,然后把结果传给fn2再执行,再把结果交给fn1去执行。

有了这个前置知识,就可以很轻易的实现redux的中间件机制了。

虽然redux源码里写的很少,各种高阶函数各种柯里化,但是抽丝剥茧以后,redux中间件的机制可以用一句话来解释:

把dispatch这个方法不端用高阶函数包装,最后返回一个强化过后的dispatch

以logMiddleware为例,这个middleware接受原始的redux dispatch,返回的是

const typeLogMiddleware = (dispatch) => {
    // 返回的其实还是一个结构相同的dispatch,接受的参数也相同
    // 只是把原始的dispatch包在里面了而已。
    return ({type, ...args}) => {
        console.log(`type is ${type}`)
        return dispatch({type, ...args})
    }
}

有了这个思路,就来实现这个mini-redux吧:

function compose(...funcs) {
    return funcs.reduce((a, b) => (...args) => a(b(...args)));
}

function createStore(reducer, middlewares) {
    let currentState;
    
    function dispatch(action) {
        currentState = reducer(currentState, action);
    }
    
    function getState() {
        return currentState;
    }
    // 初始化一个随意的dispatch,要求外部在type匹配不到的时候返回初始状态
    // 在这个dispatch后 currentState就有值了。
    dispatch({ type: 'INIT' });  
    
    let enhancedDispatch = dispatch;
    // 如果第二个参数传入了middlewares
    if (middlewares) {
        // 用compose把middlewares包装成一个函数
        // 让dis
        enhancedDispatch = compose(...middlewares)(dispatch);
    }  
    
    return {
        dispatch: enhancedDispatch,
        getState,
    };
}

接着写两个中间件

// 使用

const otherDummyMiddleware = (dispatch) => {
    // 返回一个新的dispatch
    return (action) => {
        console.log(`type in dummy is ${type}`)
        return dispatch(action)
    }
}

// 这个dispatch其实是otherDummyMiddleware执行后返回otherDummyDispatch
const typeLogMiddleware = (dispatch) => {
    // 返回一个新的dispatch
    return ({type, ...args}) => {
        console.log(`type is ${type}`)
        return dispatch({type, ...args})
    }
}

// 中间件从右往左执行。
const counterStore = createStore(counterReducer, [typeLogMiddleware, otherDummyMiddleware])

console.log(counterStore.getState().count)
counterStore.dispatch({type: 'add', payload: 2})
console.log(counterStore.getState().count)

// 输出:
// 0
// type is add
// type in dummy is add
// 2

koa

koa的洋葱模型想必各位都听说过,这种灵活的中间件机制也让koa变得非常强大,本文也会实现一个简单的洋葱中间件机制。参考(umi-request的中间件机制

洋葱圈

对应这张图来看,洋葱的每一个圈就是一个中间件,它即可以掌管请求进入,也可以掌管响应返回。

它和redux的中间件机制有点类似,本质上都是高阶函数的嵌套,外层的中间件嵌套着内层的中间件,这种机制的好处是可以自己控制中间件的能力(外层的中间件可以影响内层的请求和响应阶段,内层的中间件只能影响外层的响应阶段)

首先我们写出Koa这个类

class Koa {
    constructor() {
        this.middlewares = [];
    }
    use(middleware) {
        this.middlewares.push(middleware);
    }
    start({ req }) {
        const composed = composeMiddlewares(this.middlewares);
        const ctx = { req, res: undefined };
        return composed(ctx);
    }
}

这里的use就是简单的把中间件推入中间件队列中,那核心就是怎样去把这些中间件组合起来了,下面看composeMiddlewares方法:

function composeMiddlewares(middlewares) {
    return function wrapMiddlewares(ctx) {
        // 记录当前运行的middleware的下标
        let index = -1;
        function dispatch(i) {
            // index向后移动
            index = i;
            
            // 找出数组中存放的相应的中间件
            const fn = middlewares[i];
            
            // 最后一个中间件调用next 也不会报错
            if (!fn) {
                return Promise.resolve();
            }
                
            return Promise.resolve(
                fn(
                    // 继续传递ctx
                    ctx, 
                    // next方法,允许进入下一个中间件。
                    () => dispatch(i + 1)
                )
            );
        }
        // 开始运行第一个中间件
        return dispatch(0);
    };
}

简单来说 dispatch(n)对应着第n个中间件的执行,而dispatch(n)又拥有执行dispatch(n + 1)的权力,

所以在真正运行的时候,中间件并不是在平级的运行,而是嵌套的高阶函数:

dispatch(0)包含着dispatch(1),而dispatch(1)又包含着dispatch(2) 在这个模式下,我们很容易联想到try catch的机制,它可以catch住函数以及函数内部继续调用的函数的所有error

那么我们的第一个中间件就可以做一个错误处理中间件:

// 最外层 管控全局错误
app.use(async (ctx, next) => {
    try {
        // 这里的next包含了第二层以及第三层的运行
        await next();
    }
    catch (error) {
        console.log(`[koa error]: ${error.message}`);
    }
});  

在这个错误处理中间件中,我们把next包裹在try catch中运行,调用了next后会进入第二层的中间件:

// 第二层 日志中间件
app.use(async (ctx, next) => {
    const { req } = ctx;
    console.log(`req is ${JSON.stringify(req)}`);
    await next();
    // next过后已经能拿到第三层写进ctx的数据了
    console.log(`res is ${JSON.stringify(ctx.res)}`);
});

在第二层中间件的next调用后,进入第三层,业务逻辑处理中间件

// 第三层 核心服务中间件
// 在真实场景中 这一层一般用来构造真正需要返回的数据 写入ctx中
app.use(async (ctx, next) => {
    const { req } = ctx;
    console.log(`calculating the res of ${req}...`);
    const res = {
        code: 200,
        result: `req ${req} success`,
    };
    // 写入ctx
    ctx.res = res;
    await next();
});

在这一层把res写入ctx后,函数出栈,又会回到第二层中间件的await next()后面

 console.log(`req is ${JSON.stringify(req)}`);
 await next();
 // <- 回到这里
 console.log(`res is ${JSON.stringify(ctx.res)}`);

这时候日志中间件就可以拿到ctx.res的值了。

想要测试错误处理中间件 就在最后加入这个中间件

// 用来测试全局错误中间件
// 注释掉这一个中间件 服务才能正常响应
app.use(async (ctx, next) => {
    throw new Error('oops! error!');
});

最后要调用启动函数:

app.start({ req: 'ssh' });

控制台打印出结果:

req is "ssh"
calculating the res of ssh...
res is {"code":200,"result":"req ssh success"}

总结

  1. axios 把用户注册的每个拦截器构造成一个promise.then所接受的参数,在运行时把所有的拦截器按照一个promise链的形式以此执行。
  • 在发送到服务端之前,config已经是请求拦截器处理过后的结果
  • 服务器响应结果后,response会经过响应拦截器,最后用户拿到的就是处理过后的结果了。
  1. vuex的实现最为简单,就是提供了两个回调函数,vuex内部在合适的时机去调用(我个人感觉大部分的库提供这样的机制也足够了)。
  2. redux的源码里写的最复杂最绕,它的中间件机制本质上就是用高阶函数不断的把dispatch包装再包装,形成套娃。本文实现的已经是精简了n倍以后的结果了,不过复杂的实现也是为了很多权衡和考量,Dan对于闭包和高阶函数的运用已经炉火纯青了,只是外人去看源码有点头秃...
  3. koa的洋葱模型实现的很精妙,和redux有相似之处,但是在源码理解和使用上个人感觉更优于redux的中间件。

中间件机制其实是非框架强相关的,请求库一样可以加入koa的洋葱中间件机制(如umi-request),不同的框架可能适合不同的中间件机制,这还是取决于你编写的框架想要解决什么问题,想给用户什么样的自由度。

希望看了这篇文章的你,能对于前端库中的中间件机制有进一步的了解,进而为你自己的前端库加入合适的中间件能力。

本文所写的代码都整理在这个仓库里了:
https://github.com/sl1673495/tiny-middlewares

代码是使用ts编写的,js版本的代码在js文件夹内,各位可以按自己的需求来看。

TypeScript从零实现基于Proxy的响应式库 普通数据类型

前言

笔者最近在浏览React状态管理库的时候,发现了一些响应式的状态管理库如
hodux,react-easy-state,内部有一个基于proxy实现响应式的基础仓库observer-util,它的代码实现和Vue3中的响应式原理非常相似,这篇文章就从这个仓库入手,一步一步带你剖析响应式的实现。

本文的代码是我参考observer-util用ts的重写的,并且会加上非常详细的注释。

阅读本文可能需要的一些前置知识:

Proxy
WeakMap
Reflect

首先看一下observer-util给出的代码示例:

import { observable, observe } from '@nx-js/observer-util';

const counter = observable({ num: 0 });

// 会在控制台打印出0
const countLogger = observe(() => console.log(counter.num));

// 会在控制台打印出1
counter.num++;

这就是一个最精简的响应式模型了,乍一看好像和Vue2里的响应式系统也没啥区别,那么还是先看一下Vue2和Vue3响应式系统之间的差异吧。

和Vue2的差异

关于Vue2的响应式原理,感兴趣的也可以去看我之前的一篇文章:
实现一个最精简的响应式系统来学习Vue的data、computed、watch源码

其实这个问题本质上就是基于Proxy和基于Object.defineProperty之间的差异,来看Vue2中的一个案例:

Object.defineProperty

<template>
  {{ obj.c }}
</template>
<script>
   export default {
       data: {
           obj: { a: 1 }
       },
       mounted() {
           this.obj.c = 3
       }
   }
</script>

这个例子中,我们对obj上原本不存在的c属性进行了一个赋值,但是在Vue2中,这是不会触发响应式的。

这是因为Object.defineProperty必须对于确定的key值进行响应式的定义,

这就导致了如果data在初始化的时候没有c属性,那么后续对于c属性的赋值都不会触发Object.defineProperty中的劫持。

在Vue2中,这里只能用一个额外的api Vue.set来解决。

Proxy

再看一下Proxy的api,

const raw = {}
const data = new Proxy(raw, {
    get(target, key) { },
    set(target, key, value) { }
})

可以看出来,Proxy在定义的时候并不用关心key值,

只要你定义了get方法,那么后续对于data上任何属性的访问(哪怕是不存在的),

都会触发get的劫持,set也是同理。

这样Vue3中,对于需要定义响应式的值,初始化时候的要求就没那么高了,只要保证它是个可以被Proxy接受的对象或者数组类型即可。

当然,Proxy对于数据拦截带来的便利还不止于此,往下看就知道。

实现

接下来就一步步实现这个基于Proxy的响应式系统:

类型描述

本仓库基于TypeScript重构,所以会有一个类型定义的文件,可以当做接口先大致看一下

https://github.com/sl1673495/typescript-proxy-reactive/blob/master/types/index.ts

思路

首先响应式的思路无外乎这样一个模型:

  1. 定义某个数据为响应式数据,它会拥有收集访问它的函数的能力。
  2. 定义观察函数,在这个函数内部去访问响应式数据

以开头的例子来说

// 响应式数据
const counter = observable({ num: 0 });

// 观察函数
observe(() => console.log(counter.num));

这已经一目了然了,

  • observable包裹的数据叫做响应式数据,
  • observe内部执行的函数叫观察函数

观察函数首先开启某个开关,

访问时

observe函数会帮你去执行console.log(counter.num)

这时候proxyget拦截到了对于counter.num的访问,

这时候又可以知道访问者是() => console.log(counter.num)这个函数,

那么就把这个函数作为num这个key值的观察函数收集在一个地方。

修改时

下次对于counter.num修改的时候,去找num这个key下所有的观察函数,轮流执行一遍。

这样就实现了响应式模型。

reactive的实现(定义响应式数据)

上文中关于observable的api,我换了个名字: reactive,感觉更好理解一些。

// 需要定义响应式的原值
export type Raw = object
// 定义成响应式后的proxy
export type ReactiveProxy = object

export const proxyToRaw = new WeakMap<ReactiveProxy, Raw>()
export const rawToProxy = new WeakMap<Raw, ReactiveProxy>()

function createReactive<T extends Raw>(raw: T): T {
  const reactive = new Proxy(raw, baseHandlers)

  // 双向存储原始值和响应式proxy的映射
  rawToProxy.set(raw, reactive)
  proxyToRaw.set(reactive, raw)

  // 建立一个映射
  // 原始值 -> 存储这个原始值的各个key收集到的依赖函数的Map
  storeObservable(raw)

  // 返回响应式proxy
  return reactive as T
}

首先是定义proxy

const reactive = new Proxy(raw, baseHandlers)

这个baseHandlers里就是对于数据的getset之类的劫持,

这里有两个WeakMap: proxyToRawrawToProxy

可以看到在定义响应式数据为一个Proxy的时候,会进行一个双向的存储,

这样后续无论是拿到原始对象还是拿到响应式proxy,都可以很容易的拿到它们的另一半

之后storeObservable,是用原始对象建立一个map:

const connectionStore = new WeakMap<Raw, ReactionForRaw>()

function storeObservable(value: object) {
  // 存储对象和它内部的key -> reaction的映射
  connectionStore.set(value, new Map() as ReactionForRaw)
}

通过connectionStore的泛型也可以知道,

这是一个Raw -> ReactionForRaw的map。

也就是原始数据 -> 这个数据收集到的观察函数依赖

更清晰的描述可以看Type定义:

// 收集响应依赖的的函数
export type ReactionFunction = Function & {
  cleaners?: ReactionForKey[]
  unobserved?: boolean
}

// reactionForRaw的key为对象key值 value为这个key值收集到的Reaction集合
export type ReactionForRaw = Map<Key, ReactionForKey>

// key值收集到的Reaction集合
export type ReactionForKey = Set<ReactionFunction>

// 收集响应依赖的的函数
export type ReactionFunction = Function & {
  cleaners?: ReactionForKey[]
  unobserved?: boolean
}

那接下来的重点就是proxy的第二个参数baseHandler里的getset

proxy的get

/** 劫持get访问 收集依赖 */
function get(target: Raw, key: Key, receiver: ReactiveProxy) {
  const result = Reflect.get(target, key, receiver)
  
  // 收集依赖
  registerRunningReaction({ target, key, receiver, type: "get" })

  return result
}

关于receiver这个参数,这里可以先简单理解为响应式proxy本身,不影响流程。

这里就是简单的做了一个求值,然后进入了registerRunningReaction函数,

注册依赖

// 收集响应依赖的的函数
type ReactionFunction = Function & {
  cleaners?: ReactionForKey[]
  unobserved?: boolean
}

// 操作符 用来做依赖收集和触发依赖更新
interface Operation {
  type: "get" | "iterate" | "add" | "set" | "delete" | "clear"
  target: object
  key?: Key
  receiver?: any
  value?: any
  oldValue?: any
}

/** 依赖收集栈 */
const reactionStack: ReactionFunction[] = []

/** 依赖收集 在get操作的时候要调用 */
export function registerRunningReaction(operation: Operation) {
  const runningReaction = getRunningReaction()
  if (runningReaction) {
      // 拿到原始对象 -> 观察者的map
      const reactionsForRaw = connectionStore.get(target)
      // 拿到key -> 观察者的set
      let reactionsForKey = reactionsForRaw.get(key)
    
      if (!reactionsForKey) {
        // 如果这个key之前没有收集过观察函数 就新建一个
        reactionsForKey = new Set()
        // set到整个value的存储里去
        reactionsForRaw.set(key, reactionsForKey)
      }
    
      if (!reactionsForKey.has(reaction)) {
        // 把这个key对应的观察函数收集起来
        reactionsForKey.add(reaction)
        // 把key收集的观察函数集合 加到cleaners队列中 便于后续取消观察
        reaction.cleaners.push(reactionsForKey)
      }
  }
}

/** 从栈的末尾取到正在运行的observe包裹的函数 */
function getRunningReaction() {
  const [runningReaction] = reactionStack.slice(-1)
  return runningReaction
}

这里做的一系列操作,就是把用原始数据connectionStore里拿到依赖收集的map,然后在reaction观察函数把对于某个key访问的时候,把reaction观察函数本身增加到这个key的观察函数集合里。

那么这个runningReaction正在运行的观察函数是哪来的呢,剧透一下,当然是observe这个api内部开启观察模式后去做的。

// 此时 () => console.log(counter.num) 会被包装成reaction函数
observe(() => console.log(counter.num));

set

/** 劫持set访问 触发收集到的观察函数 */
function set(target: Raw, key: Key, value: any, receiver: ReactiveProxy) {
  // 拿到旧值
  const oldValue = target[key]
  // 设置新值
  const result = Reflect.set(target, key, value, receiver)
  
  queueReactionsForOperation({
      target,
      key,
      value,
      oldValue,
      receiver,
      type: 'set'
  })

  return result
}

/** 值更新时触发观察函数 */
export function queueReactionsForOperation(operation: Operation) {
  getReactionsForOperation(operation).forEach(reaction => reaction())
}

/**
 *  根据key,type和原始对象 拿到需要触发的所有观察函数
 */
export function getReactionsForOperation({ target, key, type }: Operation) {
  // 拿到原始对象 -> 观察者的map
  const reactionsForTarget = connectionStore.get(target)
  const reactionsForKey: ReactionForKey = new Set()

  // 把所有需要触发的观察函数都收集到新的set里
  addReactionsForKey(reactionsForKey, reactionsForTarget, key)

  return reactionsForKey
}

set赋值操作的时候,本质上就是去检查这个key收集到了哪些reaction观察函数,然后依次触发。

observe 观察函数

observe这个api接受一个用户传入的函数,在这个函数内访问响应式数据才会去收集观察函数作为自己的依赖。

/** 
 * 观察函数
 * 在传入的函数里去访问响应式的proxy 会收集传入的函数作为依赖
 * 下次访问的key发生变化的时候 就会重新运行这个函数
 */
export function observe(fn: Function): ReactionFunction {
  // reaction是包装了原始函数只后的观察函数
  // 在runReactionWrap的上下文中执行原始函数 可以收集到依赖。
  const reaction: ReactionFunction = (...args: any[]) => {
    return runReactionWrap(reaction, fn, this, args)
  }

  // 先执行一遍reaction
  reaction()

  // 返回出去 让外部也可以手动调用
  return reaction
}

核心的逻辑在runReactionWrap里,

/** 把函数包裹为观察函数 */
export function runReactionWrap(
  reaction: ReactionFunction,
  fn: Function,
  context: any,
  args: any[],
) {
  try {
    // 把当前的观察函数推入栈内 开始观察响应式proxy
    reactionStack.push(reaction)
    // 运行用户传入的函数 这个函数里访问proxy就会收集reaction函数作为依赖了
    return Reflect.apply(fn, context, args)
  } finally {
    // 运行完了永远要出栈
    reactionStack.pop()
  }
}

简化后的核心逻辑很简单,

reaction推入reactionStack后开始执行用户传入的函数,

在函数内访问响应式proxy的属性,又会触发get的拦截,

这时候getreactionStack找当前正在运行的reaction,就可以成功的收集到依赖了。

下一次用户进行赋值的时候

const counter = reactive({ num: 0 });

// 会在控制台打印出0
const counterReaction = observe(() => console.log(counter.num));

// 会在控制台打印出1
counter.num = 1;

以这个示例来说,observe内部对于counter的key值num的访问,会收集counterReaction作为num的依赖。

counter.num = 1的操作,会触发对于counter的set劫持,此时就会从key值的依赖收集里面找到counterReaction,再重新执行一遍。

边界情况

以上实现只是一个最基础的响应式模型,还没有实现的点有:

  • 深层数据的劫持
  • 数组和对象新增、删除项的响应

接下来在上面的代码的基础上来实现这两种情况:

深层数据的劫持

在刚刚的代码实现中,我们只对Proxy的第一层属性做了拦截,假设有这样的一个场景

const counter = reactive({ data: { num: 0 } });

// 会在控制台打印出0
const counterReaction = observe(() => console.log(counter.data.num));

counter.data.num = 1;

这种场景就不能实能触发counterReaction自动执行了。

因为counter.data.num其实是对data上的num属性进行赋值,而counter虽然是一个响应式proxy,但counter.data却只是一个普通的对象,回想一下刚刚的proxyget的拦截函数:

/** 劫持get访问 收集依赖 */
function get(target: Raw, key: Key, receiver: ReactiveProxy) {
  const result = Reflect.get(target, key, receiver)
  
  // 收集依赖
  registerRunningReaction({ target, key, receiver, type: "get" })

  return result
}

counter.data只是通过Reflect.get拿到了原始的 { data: {number } }对象,然后对这个对象的赋值不会被proxy拦截到。

那么思路其实也有了,就是在深层访问的时候,如果访问的数据是个对象,就把这个对象也用reactive包装成proxy再返回,这样在进行counter.data.num = 1;赋值的时候,其实也是针对一个响应式proxy赋值了。

/** 劫持get访问 收集依赖 */
function get(target: Raw, key: Key, receiver: ReactiveProxy) {
  const result = Reflect.get(target, key, receiver)
  // 收集依赖
  registerRunningReaction({ target, key, receiver, type: "get" })

+  // 如果访问的是对象 则返回这个对象的响应式proxy
+  if (isObject(result)) {
+    return reactive(result)
+  }

  return result
}

数组和对象新增、删除项的响应

以这样一个场景为例

const data: any = reactive({ a: 1, b: 2})

observe(() => console.log( Object.keys(data)))

data.c = 5

其实在用Object.keys访问data的时候,后续不管是data上的key发生了新增或者删除,都应该触发这个观察函数,那么这是怎么实现的呢?

首先我们需要知道,Object.keys(data)访问proxy的时候,会触发proxy的ownKeys拦截。

那么我们在baseHandler中先新增对于ownKeys的访问拦截:

/** 劫持get访问 收集依赖 */
function get() {}

/** 劫持set访问 触发收集到的观察函数 */
function set() {
}

/** 劫持一些遍历访问 比如Object.keys */
+ function ownKeys (target: Raw) {
+   registerRunningReaction({ target, type: 'iterate' })
+   return Reflect.ownKeys(target)
+ }

还是和get方法一样,调用registerRunningReaction方法注册依赖,但是type我们需要定义成iterate,这个type怎么用呢。我们继续改造registerRunningReaction函数:

+ const ITERATION_KEY = Symbol("iteration key")

export function registerRunningReaction(operation: Operation) {
  const runningReaction = getRunningReaction()
  if (runningReaction) {
+      if (type === "iterate") {
+        key = ITERATION_KEY
+      }
      // 拿到原始对象 -> 观察者的map
      const reactionsForRaw = connectionStore.get(target)
      // 拿到key -> 观察者的set
      let reactionsForKey = reactionsForRaw.get(key)
    
      if (!reactionsForKey) {
        // 如果这个key之前没有收集过观察函数 就新建一个
        reactionsForKey = new Set()
        // set到整个value的存储里去
        reactionsForRaw.set(key, reactionsForKey)
      }
    
      if (!reactionsForKey.has(reaction)) {
        // 把这个key对应的观察函数收集起来
        reactionsForKey.add(reaction)
        // 把key收集的观察函数集合 加到cleaners队列中 便于后续取消观察
        reaction.cleaners.push(reactionsForKey)
      }
  }
}

也就是type: iterate触发的依赖收集,我们会放在key为ITERATION_KEY的一个特殊的set里,那么再来看看触发更新时的set改造:

/** 劫持set访问 触发收集到的观察函数 */
function set(target: Raw, key: Key, value: any, receiver: ReactiveProxy) {
  // 拿到旧值
  const oldValue = target[key]
  // 设置新值
  const result = Reflect.set(target, key, value, receiver)
+  // 先检查一下这个key是不是新增的
+  const hadKey = hasOwnProperty.call(target, key)

+  if (!hadKey) {
+    // 新增key值时触发观察函数
+    queueReactionsForOperation({ target, key, value, receiver, type: 'add' })
  } else if (value !== oldValue) {
    // 已存在的key的值发生变化时触发观察函数
    queueReactionsForOperation({
      target,
      key,
      value,
      oldValue,
      receiver,
      type: 'set'
    })
  }

  return result
}

这里对新增的key也进行了的判断,传入queueReactionsForOperation的type变成了add

/** 值更新时触发观察函数 */
export function queueReactionsForOperation(operation: Operation) {
  getReactionsForOperation(operation).forEach(reaction => reaction())
}

/**
 *  根据key,type和原始对象 拿到需要触发的所有观察函数
 */
export function getReactionsForOperation({ target, key, type }: Operation) {
  // 拿到原始对象 -> 观察者的map
  const reactionsForTarget = connectionStore.get(target)
  const reactionsForKey: ReactionForKey = new Set()

  // 把所有需要触发的观察函数都收集到新的set里
  addReactionsForKey(reactionsForKey, reactionsForTarget, key)

  // add和delete的操作 需要触发某些由循环触发的观察函数收集
  // observer(() => rectiveProxy.forEach(() => proxy.foo))
+  if (type === "add" || type === "delete") {
+    const iterationKey = Array.isArray(target) ? "length" : ITERATION_KEY
+    addReactionsForKey(reactionsForKey, reactionsForTarget, iterationKey)
  }
  return reactionsForKey
}

这里需要注意的是,对于数组新增和删除项来说,如果我们在观察函数中做了遍历操作,也需要触发它的更新,

这里又有一个知识点,对于数组遍历的操作,都会触发它对length的读取,然后把观察函数收集到length这个key的依赖中,比如

observe(() => proxyArray.forEach(() => {}))
// 会访问proxyArray的length。

所以在触发更新的时候,

  1. 如果目标是个数组,那就从length的依赖里收集,
  2. 如果目标是对象,就从ITERATION_KEY的依赖里收集。(也就是对于对象做Object.keys读取时,由ownKeys拦截收集的依赖)。

源码地址

https://github.com/sl1673495/typescript-proxy-reactive

总结

由于篇幅原因,有一些优化的操作我没有在文中写出来,在仓库里做了几乎是逐行注释,而且也可以用npm run dev对example文件夹中的例子进行调试。感兴趣的同学可以自己看一下。

如果读完了还觉得有兴致,也可以直接去看observe-util这个库的源码,里面对于更多的边界情况做了处理,代码也写的非常优雅,值得学习。

从本文里讲解的一些边界情况也可以看出,基于Proxy的响应式方案比Object.defineProperty要强大很多,希望大家尽情的享受Vue3带来的快落吧。

一道蚂蚁金服异步串行面试题

前言

朋友去面试蚂蚁金服,遇到了一道面试题,乍一看感觉挺简单的,但是实现起来发现内部值得一提的点还是挺多的。

先看题目:

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const subFlow = createFlow([() => delay(1000).then(() => log("c"))]);

createFlow([
  () => log("a"),
  () => log("b"),
  subFlow,
  [() => delay(1000).then(() => log("d")), () => log("e")],
]).run(() => {
  console.log("done");
});

// 需要按照 a,b,延迟1秒,c,延迟1秒,d,e, done 的顺序打印

按照上面的测试用例,实现 createFlow

  • flow 是指一系列 effects 组成的逻辑片段。
  • flow 支持嵌套。
  • effects 的执行只需要支持串行。

分析

先以入参分析,createFlow 接受一个数组作为参数(按照题意里面的每一项应该叫做 effect),排除掉一些重复的项,我们把参数数组中的每一项整理归类一下,总共有如下几种类型:

  1. 普通函数:
() => log("a");
  1. 延迟函数(Promise):
() => delay(1000).then(() => log("d"));
  1. 另一个 flow
const subFlow = createFlow([() => delay(1000).then(() => log("c"))]);
  1. 用数组包裹的上述三项。

实现

先把参数浅拷贝一份(编写库函数,尽量不要影响用户传入的参数是个原则),再简单的扁平化 flat 一下。(处理情况 4)

function createFlow(effects = []) {
  let sources = effects.slice().flat();
}

观察题意,createFlow 并不会让方法开始执行,需要 .run() 之后才会开始执行,所以先定义好这个函数:

function createFlow(effects = []) {
  let sources = effects.slice().flat();
  function run(callback) {
    while (sources.length) {
      const task = sources.shift();
    }
    callback?.();
  }
}

这里我选择用 while 循环依次处理数组中的每个 effect,便于随时中断。

对于函数类型的 effect,直接执行它:

function createFlow(effects = []) {
  let sources = effects.slice().flat();
  function run(callback) {
    while (sources.length) {
      const task = sources.shift();
      if (typeof task === "function") {
        const res = task();
      }
    }
    // 在所有任务执行完毕后 执行传入的回调函数
    callback?.();
  }

  return {
    run,
    isFlow: true,
  };
}

这里拿到了函数的返回值 res,有一个情况别忘了,就是 effect 返回的是一个 Promise,比如这种情况:

() => delay(1000).then(() => log("d"));

那么拿到返回值后,这里直接简化判断,看返回值是否有 then 属性来判断它是否是一个 Promise(生产环境请选择更加严谨的方法)。

if (res?.then) {
  res.then(createFlow(sources).run);
  return;
}

这里我选择中断本次的 flow 执行,并且用剩下的 sources 去建立一个新的 flow,并且在上一个 Promise 的 then 方法里再去异步的开启新的 flowrun

这样,上面延迟 1s 后的 Promise 被 resolve 之后,剩下的 sources 任务数组会被新的 flow.run() 驱动,继续执行。

接下来再处理 effect 是另一个 flow 的情况,注意上面编写的大致函数体,我们已经让 createFlow 这个函数返回值带上 isFlow 这个标记,用来判断它是否是一个 flow

// 把callback放到下一个flow的callback时机里执行
const next = () => createFlow(sources).run(callback)
if (typeof task === "function") {
  const res = task();
  if (res?.then) {
    res.then(next);
    return;
  }
} else if (task?.isFlow) {
  task.run(next);
  return;
}

else if 的部分,直接调用传入的 flowrun,把剩下的 sources 创建的新的 flow,并且把这一轮的 callback 放入到新的 flowcallback 位置。在所有的任务都结束后再执行。

定义一个 next 方法,用来在遇到异步任务或者另一个 flow 的时候

这样,参数中传入的 flow 执行完毕后,才会继续执行剩下的任务,并且在最后执行 callback

完整代码

function createFlow(effects = []) {
  let sources = effects.slice().flat();
  function run(callback) {
    while (sources.length) {
      const task = sources.shift();
      // 把callback放到下一个flow的callback时机里执行
      const next = () => createFlow(sources).run(callback)
      if (typeof task === "function") {
        const res = task();
        if (res?.then) {
          res.then(next);
          return;
        }
      } else if (task?.isFlow) {
        task.run(next);
        return;
      }
    }
    callback?.();
  }
  return {
    run,
    isFlow: true,
  };
}
const delay = () => new Promise((resolve) => setTimeout(resolve, 1000));
createFlow([
  () => console.log("a"),
  () => console.log("b"),
  createFlow([() => console.log("c")]),
  [() => delay().then(() => console.log("d")), () => console.log("e")],
]).run();

总结

这道面试题主要的目的是考察对于异步串行流的控制,巧妙的利用自身的递归设计来处理传入的参数也是一个 flow的情况,在编写题目的过程中展示你对 Promise 的熟练运用,一定会让面试官对你刮目相看的~

祝大家在大环境不好的情况下,都能拿到自己满意的 offer,加油。

Vue源码学习 响应式数据

本文内容摘录自vue-design项目。

从initState开始

if (opts.data) {
  initData(vm)
} else {
  observe(vm._data = {}, true /* asRootData */)
}

我们找到 initData 函数,该函数与 initState 函数定义在同一个文件中,即 core/instance/state.js 文件,initData 函数的一开始是这样一段代码:

let data = vm.$options.data
data = vm._data = typeof data === 'function'
  ? getData(data, vm)
  : data || {}

通过getData拿到我们定义的data方法里的对象后,

else if (!isReserved(key)) {
  proxy(vm, `_data`, key)
}

!isReserved(key),该条件的意思是判断定义在 data 中的 key 是否是保留键,
Vue 是不会代理那些键名以 $ 或 _ 开头的字段的,因为 Vue 自身的属性和方法都是以 $ 或 _ 开头的,所以这么做是为了避免与 Vue 自身的属性和方法相冲突。

proxy函数:

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

其实就是把我们的this.xxx的get和set代理到this._data.xxx上去,这也是为什么我们写在data里的数据可以直接通过this访问和修改。(Vue2.0为了兼容性还是不敢用原生Proxy啊~)

接下来来到了一句很关键的代码

// observe data
observe(data, true /* asRootData */)

调用 observe 函数将 data 数据对象转换成响应式的,可以说这句代码才是响应系统的开始,不过在讲解 observe 函数之前我们有必要总结一下 initData 函数所做的事情,通过前面的分析可知 initData 函数主要完成如下工作:

根据 vm.$options.data 选项获取真正想要的数据(注意:此时 vm.$options.data 是函数)
校验得到的数据是否是一个纯对象
检查数据对象 data 上的键是否与 props 对象上的键冲突
检查 methods 对象上的键是否与 data 对象上的键冲突
在 Vue 实例对象上添加代理访问数据对象的同名属性
最后调用 observe 函数开启响应式之路

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

函数开头首先判断如果value不是对象 或者是VNode实例 就什么也不做
接下来 如果value上已经有__ob__这个属性,就直接return value.ob
这其实能看出 调用new Observer(value)最终会在value上定义一个__ob__属性。

在else if中 满足了几个条件进入的才是主流程,
先看这几个条件

  • 第一个条件是 shouldObserve 必须为 true,
    shouldObserve 变量也定义在 core/observer/index.js 文件内,如下:
/**
 * In some cases we may want to disable observation inside a component's
 * update computation.
 */
export let shouldObserve: boolean = true

export function toggleObserving (value: boolean) {
  shouldObserve = value
}

变量的初始值为 true,在 shouldObserve 变量的下面定义了 toggleObserving 函数,该函数接收一个布尔值参数,用来切换 shouldObserve 变量的真假值,我们可以把 shouldObserve 想象成一个开关,为 true 时说明打开了开关,此时可以对数据进行观测,为 false 时可以理解为关闭了开关,此时数据对象将不会被观测。为什么这么设计呢?原因是有一些场景下确实需要这个开关从而达到一些目的,后面我们遇到的时候再仔细来说。

  • 第二个条件是 !isServerRendering() 必须为真,我们讨论的环境非服务端渲染 肯定满足。

  • 第三个条件是 (Array.isArray(value) || isPlainObject(value)) 必须为真
    这个条件很好理解,只有当数据对象是数组或纯对象的时候,才有必要对其进行观测。

  • 第四个条件是 Object.isExtensible(value) 必须为真
    也就是说要被观测的数据对象必须是可扩展的。一个普通的对象默认就是可扩展的,以下三个方法都可以使得一个对象变得不可扩展:Object.preventExtensions()、Object.freeze() 以及 Object.seal()

  • 第五个条件是 !value._isVue 必须为真
    我们知道 Vue 实例对象拥有 _isVue 属性,所以这个条件用来避免 Vue 实例对象被观测。

当一个对象满足了以上五个条件时,就会执行 else...if 语句块的代码,即创建一个 Observer 实例:

ob = new Observer(value)

Observer 构造函数

其实真正将数据对象转换成响应式数据的是 Observer 函数,它是一个构造函数,同样定义在 core/observer/index.js 文件下,如下是简化后的代码:

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that has this object as root $data

  constructor (value: any) {
    // 省略...
  }

  walk (obj: Object) {
    // 省略...
  }
  
  observeArray (items: Array<any>) {
    // 省略...
  }
}

以清晰的看到 Observer 类的实例对象将拥有三个实例属性,分别是 value、dep 和 vmCount 以及两个实例方法 walk 和 observeArray。Observer 类的构造函数接收一个参数,即数据对象。下面我们就从 constructor 方法开始,研究实例化一个 Observer 类时都做了哪些事情。

如下是 constructor 方法的全部代码:

constructor (value: any) {
  this.value = value
  this.dep = new Dep()
  this.vmCount = 0
  def(value, '__ob__', this)
  if (Array.isArray(value)) {
    const augment = hasProto
      ? protoAugment
      : copyAugment
    augment(value, arrayMethods, arrayKeys)
    this.observeArray(value)
  } else {
    this.walk(value)
  }
}

constructor 方法的参数就是在实例化 Observer 实例时传递的参数,即数据对象本身,可以发现,实例对象的 value 属性引用了数据对象:

this.value = value

实例对象的 dep 属性,保存了一个新创建的 Dep 实例对象:

this.dep = new Dep()

那么这里的 Dep 是什么呢?它就是一个收集依赖的“筐”。但这个“筐”并不属于某一个字段,后面我们会发现,这个筐是属于某一个对象或数组的。

实例对象的 vmCount 属性被设置为 0:this.vmCount = 0。

初始化完成三个实例属性之后,使用 def 函数,为数据对象定义了一个 ob 属性,这个属性的值就是当前 Observer 实例对象。其中 def 函数其实就是 Object.defineProperty 函数的简单封装,之所以这里使用 def 函数定义 ob 属性是因为这样可以定义不可枚举的属性,这样后面遍历数据对象的时候就能够防止遍历到 ob 属性。

假设我们的数据对象如下:

const data = {
  a: 1
}

那么经过 def 函数处理之后,data 对象应该变成如下这个样子:

const data = {
  a: 1,
  // __ob__ 是不可枚举的属性
  __ob__: {
    value: data, // value 属性指向 data 数据对象本身,这是一个循环引用
    dep: dep实例对象, // new Dep()
    vmCount: 0
  }
}

响应式数据之纯对象的处理

if (Array.isArray(value)) {
  const augment = hasProto
    ? protoAugment
    : copyAugment
  augment(value, arrayMethods, arrayKeys)
  this.observeArray(value)
} else {
  this.walk(value)
}

该判断用来区分数据对象到底是数组还是一个纯对象,因为对于数组和纯对象的处理方式是不同的,为了更好地理解我们先看数据对象是一个纯对象的情况,这个时候代码会走 else 分支,即执行 this.walk(value) 函数,我们知道这个函数实例对象方法,找到这个方法:

walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i])
  }
}

walk 方法很简单,首先使用 Object.keys(obj) 获取对象属性所有可枚举的属性,然后使用 for 循环遍历这些属性,同时为每个属性调用了 defineReactive 函数。

defineReactive 函数

那我们就看一看 defineReactive 函数都做了什么,该函数也定义在 core/observer/index.js 文件,内容如下:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        // 这里闭包引用了上面的 dep 常量
        dep.depend()
        // 省略...
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      // 省略...

      // 这里闭包引用了上面的 dep 常量
      dep.notify()
    }
  })
}

首先可以看到 方法的开头又有一个dep筐子,这个筐是为对象的每个key建立的一个依赖收集筐,
这个筐被get和set方法闭包引用,

这里大家要明确一件事情,即 每一个数据字段都通过闭包引用着属于自己的 dep 常量

let childOb = !shallow && observe(val)

如果函数的参数shallow字段为假,则递归观测子对象(val是否是对象这个判断observe函数会做)。

被观测后的数据对象的样子

现在我们需要明确一件事情,那就是一个数据对象经过了 observe 函数处理之后变成了什么样子,假设我们有如下数据对象:

const data = {
  a: {
    b: 1
  }
}

observe(data)

数据对象 data 拥有一个叫做 a 的属性,且属性 a 的值是另外一个对象,该对象拥有一个叫做 b 的属性。那么经过 observe 处理之后, data 和 data.a 这两个对象都被定义了 ob 属性,并且访问器属性 a 和 b 的 setter/getter 都通过闭包引用着属于自己的 Dep 实例对象和 childOb 对象:

const data = {
  // 属性 a 通过 setter/getter 通过闭包引用着 dep 和 childOb
  a: {
    // 属性 b 通过 setter/getter 通过闭包引用着 dep 和 childOb
    b: 1
    __ob__: {a, dep, vmCount}
  }
  __ob__: {data, dep, vmCount}
}

需要注意的是,属性 a 闭包引用的 childOb 实际上就是 data.a.ob。而属性 b 闭包引用的 childOb 是 undefined,因为属性 b 是基本类型值,并不是对象也不是数组。

在 get 函数中如何收集依赖

我们回过头来继续查看 defineReactive 函数的代码,接下来是 defineReactive 函数的关键代码,即使用 Object.defineProperty 函数定义访问器属性:

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    // 省略...
  },
  set: function reactiveSetter (newVal) {
    // 省略...
})

get 函数如下:

get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()
    if (childOb) {
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
}

首先判断 Dep.target 是否存在,那么 Dep.target 是什么呢?其实 Dep.target 与我们在中保存的值就是要被收集的依赖(观察者)。所以如果 Dep.target 存在的话说明有依赖需要被收集,这个时候才需要执行 if 语句块内的代码,如果 Dep.target 不存在就意味着没有需要被收集的依赖,所以当然就不需要执行 if 语句块内的代码了。

在 if 语句块内第一句执行的代码就是:dep.depend(),执行 dep 对象的 depend 方法将依赖收集到 dep 这个“筐”中,这里的 dep 对象就是属性的 getter/setter 通过闭包引用的“筐”。

接着又判断了 childOb 是否存在,如果存在那么就执行 childOb.dep.depend(),这段代码是什么意思呢?要想搞清楚这段代码的作用,你需要知道 childOb 是什么,前面我们分析过,假设有如下数据对象:

const data = {
  a: {
    b: 1
  }
}

该数据对象经过观测处理之后,将被添加 ob 属性,如下:

const data = {
  a: {
    b: 1,
    __ob__: {value, dep, vmCount}
  },
  __ob__: {value, dep, vmCount}
}

对于属性 a 来讲,访问器属性 a 的 setter/getter 通过闭包引用了一个 Dep 实例对象,即属性 a 用来收集依赖的“筐”。除此之外访问器属性 a 的 setter/getter 还通过闭包引用着 childOb,且 childOb === data.a.ob 所以 childOb.dep === data.a.ob.dep。也就是说 childOb.dep.depend() 这句话的执行说明除了要将依赖收集到属性 a 自己的“筐”里之外,还要将同样的依赖收集到 data.a.ob.dep 这里”筐“里,为什么要将同样的依赖分别收集到这两个不同的”筐“里呢?其实答案就在于这两个”筐“里收集的依赖的触发时机是不同的,即作用不同,两个”筐“如下:

第一个”筐“是 dep
第二个”筐“是 childOb.dep
第一个”筐“里收集的依赖的触发时机是当属性值被修改时触发,即在 set 函数中触发:dep.notify()。而第二个”筐“里收集的依赖的触发时机是在使用 $set 或 Vue.set 给数据对象添加新属性时触发,我们知道由于 js 语言的限制,在没有 Proxy 之前 Vue 没办法拦截到给对象添加属性的操作。所以 Vue 才提供了 $set 和 Vue.set 等方法让我们有能力给对象添加新属性的同时触发依赖,那么触发依赖是怎么做到的呢?就是通过数据对象的 ob 属性做到的。因为 ob.dep 这个”筐“里收集了与 dep 这个”筐“同样的依赖。假设 Vue.set 函数代码如下:

Vue.set = function (obj, key, val) {
  defineReactive(obj, key, val)
  obj.__ob__.dep.notify()
}

如上代码所示,当我们使用上面的代码给 data.a 对象添加新的属性:

Vue.set(data.a, 'c', 1)

上面的代码之所以能够触发依赖,就是因为 Vue.set 函数中触发了收集在 data.a.ob.dep 这个”筐“中的依赖:

Vue.set = function (obj, key, val) {
  defineReactive(obj, key, val)
  obj.__ob__.dep.notify() // 相当于 data.a.__ob__.dep.notify()
}

Vue.set(data.a, 'c', 1)

所以 ob 属性以及 ob.dep 的主要作用是为了添加、删除属性时有能力触发依赖,而这就是 Vue.set 或 Vue.delete 的原理。

在 childOb.dep.depend() 这句话的下面还有一个 if 条件语句,如下:

if (Array.isArray(value)) {
  dependArray(value)
}

如果读取的属性值是数组,那么需要调用 dependArray 函数逐个触发数组每个元素的依赖收集,为什么这么做呢?那是因为 Observer 类在定义响应式属性时对于纯对象和数组的处理方式是不同,对于上面这段 if 语句的目的等到我们讲解到对于数组的处理时,会详细说明。

在 set 函数中如何触发依赖

在 get 函数中收集了依赖之后,接下来我们就要看一下在 set 函数中是如何触发依赖的,即当属性被修改的时候如何触发依赖。set 函数如下:

set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  /* eslint-disable no-self-compare */
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  /* eslint-enable no-self-compare */
  if (process.env.NODE_ENV !== 'production' && customSetter) {
    customSetter()
  }
  if (setter) {
    setter.call(obj, newVal)
  } else {
    val = newVal
  }
  childOb = !shallow && observe(newVal)
  dep.notify()
}

我们知道 get 函数主要完成了两部分重要的工作,一个是返回正确的属性值,另一个是收集依赖。与 get 函数类似, set 函数也要完成两个重要的事情,第一正确地为属性设置新值,第二是能够触发相应的依赖。

首先 set 函数接收一个参数 newVal,即该属性被设置的新值。在函数体内,先执行了这样一句话:

const value = getter ? getter.call(obj) : val

这句话与 get 函数体的第一句话相同,即取得属性原有的值,为什么要取得属性原来的值呢?很简单,因为我们需要拿到原有的值与新的值作比较,并且只有在原有值与新设置的值不相等的情况下才需要触发依赖和重新设置属性值,否则意味着属性值并没有改变,当然不需要做额外的处理。如下代码:

/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
  return
}

这里就对比了新值和旧值:newVal === value。如果新旧值全等,那么函数直接 return,不做任何处理。但是除了对比新旧值之外,我们还注意到,另外一个条件:

(newVal !== newVal && value !== value)

如果满足该条件,同样不做任何处理,那么这个条件什么意思呢?newVal !== newVal 说明新值与新值自身都不全等,同时旧值与旧值自身也不全等,大家想一下在 js 中什么时候会出现一个值与自身都不全等的?答案就是 NaN:

NaN === NaN // false

所以我们现在重新分析一下这个条件,首先 value !== value 成立那说明该属性的原有值就是 NaN,同时 newVal !== newVal 说明为该属性设置的新值也是 NaN,所以这个时候新旧值都是 NaN,等价于属性的值没有变化,所以自然不需要做额外的处理了,set 函数直接 return 。

再往下又是一个 if 语句块:

if (setter) {
  setter.call(obj, newVal)
} else {
  val = newVal
}

上面这段代码的意图很明显,即正确地设置属性值,首先判断 setter 是否存在,我们知道 setter 常量存储的是属性原有的 set 函数。即如果属性原来拥有自身的 set 函数,那么应该继续使用该函数来设置属性的值,从而保证属性原有的设置操作不受影响。如果属性原本就没有 set 函数,那么就设置 val 的值:val = newVal。

接下来就是 set 函数的最后两句代码,如下:
childOb = !shallow && observe(newVal)
dep.notify()

我们知道,由于属性被设置了新的值,那么假如我们为属性设置的新值是一个数组或者纯对象,那么该数组或纯对象是未被观测的,所以需要对新值进行观测,这就是第一句代码的作用,同时使用新的观测对象重写 childOb 的值。当然了,这些操作都是在 !shallow 为真的情况下,即需要深度观测的时候才会执行。最后是时候触发依赖了,我们知道 dep 是属性用来收集依赖的”筐“,现在我们需要把”筐“里的依赖都执行一下,而这就是 dep.notify() 的作用。

至此 set 函数我们就讲解完毕了。

响应式数据之数组的处理

以上就是响应式数据对于纯对象的处理方式,接下来我们将会对数组展开详细的讨论。回到 Observer 类的 constructor 函数,找到如下代码:

if (Array.isArray(value)) {
  const augment = hasProto
    ? protoAugment
    : copyAugment
  augment(value, arrayMethods, arrayKeys)
  this.observeArray(value)
} else {
  this.walk(value)
}

在 if 条件语句中,使用 Array.isArray 函数检测被观测的值 value 是否是数组,如果是数组则会执行 if 语句块内的代码,从而实现对数组的观测。处理数组的方式与纯对象不同,我们知道数组是一个特殊的数据结构,它有很多实例方法,并且有些方法会改变数组自身的值,我们称其为变异方法,这些方法有:push、pop、shift、unshift、splice、sort 以及 reverse 等。这个时候我们就要考虑一件事,即当用户调用这些变异方法改变数组时需要触发依赖。换句话说我们需要知道开发者何时调用了这些变异方法,只有这样我们才有可能在这些方法被调用时做出反应。

拦截数组变异方法的思路

缓存Array.prototype.push这个函数 然后定义newPush = function() {
...doSomething(),
调用缓存的push函数...
}
这其实是一个很通用也很常见的技巧,而 Vue 正是通过这个技巧实现了对数据变异方法的拦截,即保持数组变异方法原有功能不变的前提下对其进行功能扩展。

/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */

import { def } from '../util/index'

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

methodsToPatch 常量是一个数组,包含了所有需要拦截的数组变异方法的名字。再往下是一个 forEach 循环,用来遍历 methodsToPatch 数组。该循环的主要目的就是使用 def 函数在 arrayMethods 对象上定义与数组变异方法同名的函数,从而做到拦截的目的,如下是简化后的代码:

methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__

    // 省略中间部分...

    // notify change
    ob.dep.notify()
    return result
  })
})

上面的代码中,首先缓存了数组原本的变异方法:

const original = arrayProto[method]

然后使用 def 函数在 arrayMethods 上定义与数组变异方法同名的函数,在函数体内优先调用了缓存下来的数组变异方法:

const result = original.apply(this, args)

并将数组原本变异方法的返回值赋值给 result 常量,并且我们发现函数体的最后一行代码将 result 作为返回值返回。这就保证了拦截函数的功能与数组原本变异方法的功能是一致的。

关键要注意这两句代码:

const ob = this.__ob__

// 省略中间部分...

// notify change
ob.dep.notify()

定义了 ob 常量,它是 this.ob 的引用,其中 this 其实就是数组实例本身,我们知道无论是数组还是对象,都将会被定义一个 ob 属性,并且 ob.dep 中收集了所有该对象(或数组)的依赖(观察者)。所以上面两句代码的目的其实很简单,当调用数组变异方法时,必然修改了数组,所以这个时候需要将该数组的所有依赖(观察者)全部拿出来执行,即:ob.dep.notify()。

注意上面的讲解中我们省略了中间部分,那么这部分代码的作用是什么呢?如下:

def(arrayMethods, method, function mutator (...args) {
  // 省略...
  let inserted
  switch (method) {
    case 'push':
    case 'unshift':
      inserted = args
      break
    case 'splice':
      inserted = args.slice(2)
      break
  }
  if (inserted) ob.observeArray(inserted)
  // 省略...
})

首先我们需要思考一下数组变异方法对数组的影响是什么?无非是 增加元素、删除元素 以及 变更元素顺序。有的同学可能会说还有 替换元素,实际上替换可以理解为删除和增加的复合操作。那么在这些变更中,我们需要重点关注的是 增加元素 的操作,即 push、unshift 和 splice,这三个变异方法都可以为数组添加新的元素,那么为什么要重点关注呢?原因很简单,因为新增加的元素是非响应式的,所以我们需要获取到这些新元素,并将其变为响应式数据才行,而这就是上面代码的目的。下面我们看一下具体实现,首先定义了 inserted 变量,这个变量用来保存那些被新添加进来的数组元素:let inserted。接着是一个 switch 语句,在 switch 语句中,当遇到 push 和 unshift 操作时,那么新增的元素实际上就是传递给这两个方法的参数,所以可以直接将 inserted 的值设置为 args:inserted = args。当遇到 splice 操作时,我们知道 splice 函数从第三个参数开始到最后一个参数都是数组的新增元素,所以直接使用 args.slice(2) 作为 inserted 的值即可。最后 inserted 变量中所保存的就是新增的数组元素,我们只需要调用 observeArray 函数对其进行观测即可:

if (inserted) ob.observeArray(inserted)

TypeScript 中的子类型、逆变、协变是什么?

前言

TypeScript 中有很多地方涉及到子类型 subtype、父类型 supertype、逆变和协变covariance and contravariance的概念,如果搞不清这些概念,那么很可能被报错搞的无从下手,或者在写一些复杂类型的时候看到别人可以这么写,但是不知道为什么他可以生效。(就是我自己没错了)

子类型

比如考虑如下接口:

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark(): void
}

在这个例子中,AnimalDog 的父类,DogAnimal的子类型,子类型的属性比父类型更多,更具体。

  • 在类型系统中,属性更多的类型是子类型。
  • 在集合论中,属性更少的集合是子集。

也就是说,子类型是父类型的超集,而父类型是子类型的子集,这是直觉上容易搞混的一点。

记住一个特征,子类型比父类型更加具体,这点很关键。

可赋值性 assignable

assignable 是类型系统中很重要的一个概念,当你把一个变量赋值给另一个变量时,就要检查这两个变量的类型之间是否可以相互赋值。

let animal: Animal
let dog: Dog

animal = dog // ✅ok
dog = animal // ❌error! animal 实例上缺少属性 'bark'

从这个例子里可以看出,animal 是一个「更宽泛」的类型,它的属性比较少,所以更「具体」的子类型是可以赋值给它的,因为你是知道 animal 上只有 age 这个属性的,你只会去使用这个属性,dog 上拥有 animal 所拥有的一切类型,赋值给 animal 是不会出现类型安全问题的。

反之,如果 dog = animal,那么后续使用者会期望 dog 上拥有 bark 属性,当他调用了 dog.bark() 就会引发运行时的崩溃。

从可赋值性角度来说,子类型是可以赋值给父类型的,也就是 父类型变量 = 子类型变量 是安全的,因为子类型上涵盖了父类型所拥有的的一切属性。

当我初学的时候,我会觉得 T extends {} 这样的语句很奇怪,为什么可以 extends 一个空类型并且在传递任意类型时都成立呢?当搞明白上面的知识点,这个问题也自然迎刃而解了。

在函数中的运用

假设我们有这样的一个函数:

function f(val: { a: number; b: number })

有这样两个变量:

let val1 = { a: 1 }
let val2 = { a: 1, b: 2, c: 3 }

调用 f(val1) 是会报错的,比较显而易见的来看是因为缺少属性 b,而函数 f 中很可能去访问 b 属性并且做一些操作,比如 b.substr(),这就会导致崩溃。

换成上面的知识点来看,val1 对应的类型是{ a: number },它是 { a: number, b: number } 的父类型,调用 f(val1) 其实就相当于把函数定义中的形参 val 赋值成了 val1
把父类型的变量赋值给子类型的变量,这是危险的。

反之,调用 f(val2) 没有任何问题,因为 val2 的类型是 val类型的子类型,它拥有更多的属性,函数有可能使用的一切属性它都有。

假设我现在要开发一个 redux,在声明 dispatch 类型的时候,我就可以这样去做:

interface Action {
  type: string
}

declare function dispatch<T extends Action>(action: T)

这样,就约束了传入的参数一定是 Action 的子类型。也就是说,必须有 type,其他的属性有没有,您随意。

在联合类型中的运用

学习了以上知识点,再看联合类型的可赋值性,乍一看会比较反直觉, 'a' | 'b' | 'c''a' | 'b' 的子类型吗?它看起来属性更多诶?其实正相反,'a' | 'b' | 'c''a' | 'b' 的父类型。因为前者比后者更「宽泛」,后者比前者更「具体」。

type Parent = 'a' | 'b' | 'c'
type Son = 'a' | 'b'

let parent: Parent
let son: Son

parent = son // ✅ok
son = parent // ❌error! parent 有可能是 'c'

这里 son 是可以安全的赋值给 parent 的,因为 son 的所有可能性都被 parent 涵盖了。

而反之则不行,parent 太宽泛了,它有可能是 'c',这是 Son 类型 hold 不住的。

这个例子看完以后,你应该可以理解为什么 'a' | 'b' extends 'a' | 'b' | 'c' 为 true 了,在书写 conditional types的时候更加灵活的运用吧。

逆变和协变

先来段维基百科的定义

协变与逆变(covariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语。

描述的比较晦涩难懂,但是用我们上面的动物类型的例子来解释一波,现在我们还是有 AnimalDog 两个父子类型。

协变(Covariance)

那么想象一下,现在我们分别有这两个子类型的数组,他们之间的父子关系应该是怎么样的呢?没错,Animal[] 依然是 Dog[] 的父类型,对于这样的一段代码,把子类型赋值给父类型依然是安全的:

let animals: Animal[]
let dogs: Dog[]

animals = dogs

animals[0].age // ✅ok

转变成数组之后,对于父类型的变量,我们依然只会去 Dog 类型中一定有的那些属性。

那么,对于 type MakeArray<T> = T[] 这个类型构造器来说,它就是 协变(Covariance) 的。

逆变(Contravariance)

有这样两个函数:

let visitAnimal = (animal: Animal) => void;
let visitDog = (dog: Dog) => void;

animal = dog 是类型安全的,那么 visitAnimal = visitDog 好像也是可行的?其实不然,想象一下这两个函数的实现:

let visitAnimal = (animal: Animal) => {
  animal.age
}

let visitDog = (dog: Dog) => {
  dog.age
  dog.bark()
}

由于 visitDog 的参数期望的是一个更具体的带有 bark 属性的子类型,所以如果 visitAnimal = visitDog 后,我们可能会用一个不带 bark 属性的普通的 animal 类型来传给 visitDog

visitAnimal = visitDog

let animal = { age: 5 }

visitAnimal(animal) // ❌

这会造成运行时错误,animal.bark 根本不存在,去调用这个方法会引发崩溃。

但是反过来,visitDog = visitAnimal 却是完全可行的。因为后续调用方会传入一个比 animal 属性更具体的 dog,函数体内部的一切访问都是安全的。

在对 AnimalDog 类型分别调用如下的类型构造器之后:

type MakeFunction<T> = (arg: T) => void

父子类型关系逆转了,这就是 逆变(Contravariance)

在 TS 中

当然,在 TypeScript 中,由于灵活性等权衡,对于函数参数默认的处理是 双向协变 的。也就是既可以 visitAnimal = visitDog,也可以 visitDog = visitAnimal。在开启了 tsconfig 中的 strictFunctionType 后才会严格按照 逆变 来约束赋值关系。

结语

这篇文章结合我自己最近学习类型相关知识的一些心得整理而成,如果有错误或者疏漏欢迎大家指出。

参考资料

Subsets & Subtypes

TypeScript 官方文档

维基百科-协变与逆变

Vue3 究竟好在哪里?(和 React Hook 的详细对比)

前言

这几天 Vue 3.0 Beta 版本发布了,本以为是皆大欢喜的一件事情,但是论坛里还是看到了很多反对的声音。主流的反对论点大概有如下几点:

  1. 意大利面代码结构吐槽:

“太失望了。杂七杂八一堆丢在 setup 里,我还不如直接用 react”

我的天,3.0 这么搞的话,代码结构不清晰,语义不明确,无异于把 vue 自身优点都扔了

怎么感觉代码结构上没有 2.0 清晰了呢 😂 这要是代码量上去了是不是不好维护啊

  1. 抄袭 React 吐槽:

抄来抄去没自己的个性

有 react 香吗?越来越像 react 了

在我看来,Vue 黑暗的一天还远远没有过去,很多人其实并没有认真的去看 Vue-Composition-Api 文档中的 动机 章节,本文就以这个章节为线索,从 代码结构底层原理 等方面来一一打消大家的一些顾虑。

在文章的开头,首先要标明一下作者的立场,我对于 React 和 Vue 都非常的喜欢。他们都有着各自的优缺点,本文绝无引战之意。两个框架都很棒!只是各有优缺点而已。React 的 Immutable 其实也带来了很多益处,并且 Hook 的思路还是 Facebook 团队的大佬们首创的,真的是很让人赞叹的设计,我对 React 100% 致敬!

设计动机

大如 Vue3 这种全球热门的框架,任何一个 breaking-change 的设计一定有它的深思熟虑和权衡,那么 composition-api 出现是为了解决什么问题呢?这是一个我们需要首先思考明白的问题。

首先抛出 Vue2 的代码模式下存在的几个问题。

  1. 随着功能的增长,复杂组件的代码变得越来越难以维护。 尤其发生你去新接手别人的代码时。 根本原因是 Vue 的现有 API 通过「选项」组织代码,但是在大部分情况下,通过逻辑考虑来组织代码更有意义。
  2. 缺少一种比较「干净」的在多个组件之间提取和复用逻辑的机制。
  3. 类型推断不够友好。

逻辑重用

相信很多接触过 React Hook 的小伙伴已经对这种模式下组件间逻辑复用的简单性有了一定的认知,自从 React 16.7 发布以来,社区涌现出了海量的 Hook 轮子,以及主流的生态库 react-routerreact-redux 等等全部拥抱 Hook,都可以看出社区的同好们对于 Hook 开发机制的赞同。

其实组件逻辑复用在 React 中是经历了很长的一段发展历程的,
mixin -> HOC & render-props -> Hookmixin 是 React 中最早启用的一种逻辑复用方式,因为它的缺点实在是多到数不清,而后面的两种也有着自己的问题,比如增加组件嵌套啊、props 来源不明确啊等等。可以说到目前为止,Hook 是相对完美的一种方案。

当然,我的一贯风格就是上代码对比,我就拿 HOC 来说吧,Github 上的一个真实的开源项目里就出现了这样的场景:

HOC 对比 Hook

class MenuBar extends React.Component {
  // props 里混合着来自各个HOC传入的属性,还有父组件传入的属性。
  handleClickNew() {
    const readyToReplaceProject = this.props.confirmReadyToReplaceProject(
      this.props.intl.formatMessage(sharedMessages.replaceProjectWarning)
    );
    this.props.onRequestCloseFile();
    if (readyToReplaceProject) {
      this.props.onClickNew(this.props.canSave && this.props.canCreateNew);
    }
    this.props.onRequestCloseFile();
  }
  handleClickRemix() {
    this.props.onClickRemix();
    this.props.onRequestCloseFile();
  }
  handleClickSave() {
    this.props.onClickSave();
    this.props.onRequestCloseFile();
  }
  handleClickSaveAsCopy() {
    this.props.onClickSaveAsCopy();
    this.props.onRequestCloseFile();
  }
}

export default compose(
  // 国际化
  injectIntl,
  // 菜单
  MenuBarHOC,
  // react-redux
  connect(mapStateToProps, mapDispatchToProps)
)(MenuBar);

没错,这里用 compose 函数组合了好几个 HOC,其中还有 connect 这种 接受几个参数返回一个接受组件作为函数的函数 这种东西,如果你是新上手(或者哪怕是 React 老手)这套东西的人,你会在 「这个 props 是从哪个 HOC 里来的?」,「这个 props 是外部传入的还是 HOC 里得到的?」这些问题中迷失了大脑,最终走向堕落(误)。

不谈 HOC,我的脑子已经快炸开来了,来看看用 Hook 的方式复用逻辑是怎么样的场景吧?

function MenuBar(props) {
  // props 里只包含父组件传入的属性
  const { show } = props;
  // 菜单
  const { onClickRemix, onClickNew } = useMenuBar();
  // 国际化
  const { intl } = useIntl();
  // react-redux
  const { user } = useSelector((store) => store.user);
}

export default MenuBar;

一切都变得很明朗,我可以非常清楚的知道这个方法的来源,intl 是哪里注入进来的,点击了 useMenuBar 后,就自动跳转到对应的逻辑,维护和可读性都极大的提高了。

当然,这是一个比较「刻意」的例子,但是相信我,我在 React 开发中已经体验过这种收益了。随着组件的「职责」越来越多,只要你掌握了这种代码组织的思路,那么你的组件并不会膨胀到不可读。

常见的请求场景

再举个非常常见的请求场景。

在 Vue2 中如果我需要请求一份数据,并且在loadingerror时都展示对应的视图,一般来说,我们会这样写:

<template>
    <div v-if="error">failed to load</div>
    <div v-else-if="loading">loading...</div>
    <div v-else>hello {{fullName}}!</div>
</template>

<script>
import { createComponent, computed } from 'vue'

export default {
  data() {
    // 集中式的data定义 如果有其他逻辑相关的数据就很容易混乱
    return {
        data: {
            firstName: '',
            lastName: ''
        },
        loading: false,
        error: false,
    },
  },
  async created() {
      try {
        // 管理loading
        this.loading = true
        // 取数据
        const data = await this.$axios('/api/user')
        this.data = data
      } catch (e) {
        // 管理error
        this.error = true
      } finally {
        // 管理loading
        this.loading = false
      }
  },
  computed() {
      // 没人知道这个fullName和哪一部分的异步请求有关 和哪一部分的data有关 除非仔细阅读
      // 在组件大了以后更是如此
      fullName() {
          return this.data.firstName + this.data.lastName
      }
  }
}
</script>

这段代码,怎么样都谈不上优雅,凑合的把功能完成而已,并且对于loadingerror等处理的可复用性为零。

数据和逻辑也被分散在了各个option中,这还只是一个逻辑,如果又多了一些逻辑,多了datacomputedmethods?如果你是一个新接手这个文件的人,你如何迅速的分辨清楚这个method是和某两个data中的字段关联起来的?

让我们把zeit/swr的逻辑照搬到 Vue3 中,

看一下swr在 Vue3 中的表现:

<template>
    <div v-if="error">failed to load</div>
    <div v-else-if="loading">loading...</div>
    <div v-else>hello {{fullName}}!</div>
</template>

<script>
import { createComponent, computed } from 'vue'
import useSWR from 'vue-swr'

export default createComponent({
  setup() {
      // useSWR帮你管理好了取数、缓存、甚至标签页聚焦重新请求、甚至Suspense...
      const { data, loading, error } = useSWR('/api/user', fetcher)
      // 轻松的定义计算属性
      const fullName = computed(() => data.firstName + data.lastName)
      return { data, fullName, loading, error }
  }
})
</script>

就是这么简单,对吗?逻辑更加聚合了。

对了,顺嘴一提, use-swr 的威力可远远不止看到的这么简单,随便举几个它的能力:

  1. 间隔轮询

  2. 请求重复数据删除

  3. 对于同一个 key 的数据进行缓存

  4. 对数据进行乐观更新

  5. 在标签页聚焦的时候重新发起请求

  6. 分页支持

  7. 完备的 TypeScript 支持

等等等等……而这么多如此强大的能力,都在一个小小的 useSWR() 函数中,谁能说这不是魔法呢?

类似的例子还数不胜数。

umi-hooks

react-use

代码组织

上面说了那么多,还只是说了 Hook 的其中一个优势。这其实并不能解决「意大利面条代码」的问题。当逻辑多起来以后,组件的逻辑会糅合在一起变得一团乱麻吗?

从获取鼠标位置的需求讲起

我们有这样一个跨组件的需求,我想在组件里获得一个响应式的变量,能实时的指向我鼠标所在的位置。

Vue 官方给出的自定义 Hook 的例子是这样的:

import { ref, onMounted, onUnmounted } from "vue";

export function useMousePosition() {
  const x = ref(0);
  const y = ref(0);

  function update(e) {
    x.value = e.pageX;
    y.value = e.pageY;
  }

  onMounted(() => {
    window.addEventListener("mousemove", update);
  });

  onUnmounted(() => {
    window.removeEventListener("mousemove", update);
  });

  return { x, y };
}

在组件中使用:

import { useMousePosition } from "./mouse";

export default {
  setup() {
    const { x, y } = useMousePosition();
    // other logic...
    return { x, y };
  },
};

就这么简单,无需多言。在任何组件中我们需要「获取响应式的鼠标位置」,并且和我们的「视图层」关联起来的时候,仅仅需要简单的一句话即可。并且这里返回的 xy 是由 ref 加工过的响应式变量,我们可以用 watch 监听它们,可以把它们传递给其他的自定义 Hook 继续使用。几乎能做到你想要的一切,只需要发挥你的想象力。

从 Vue 官方的例子讲起

上面的例子足够入门和精简,让我们来到现实世界。举一个 Vue CLI UI file explorer 官方吐槽的例子,这个组件是 Vue-CLI 的 gui 中(也就是平常我们命令行里输入 vue ui 出来的那个图形化控制台)的一个复杂的文件浏览器组件,这是 Vue 官方团队的大佬写的,相信是比较有说服力的一个案例了。

这个组件有以下的几个功能:

  1. 跟踪当前文件夹状态并显示其内容

  2. 处理文件夹导航(打开,关闭,刷新...)

  3. 处理新文件夹的创建

  4. 切换显示收藏夹

  5. 切换显示隐藏文件夹

  6. 处理当前工作目录更改

文档中提出了一个尖锐的灵魂之问,你作为一个新接手的开发人员,能够在茫茫的 methoddatacomputed 等选项中一目了然的发现这个变量是属于哪个功能吗?比如「创建新文件夹」功能使用了两个数据属性,一个计算属性和一个方法,其中该方法在距数据属性「一百行以上」的位置定义。

当一个组价中,维护同一个逻辑需要跨越上百行的「空间距离」的时候,即使是让我去维护 Vue 官方团队的代码,我也会暗搓搓的吐槽一句,「这写的什么玩意,这变量干嘛用的!」

尤大很贴心的给出了一张图,在这张图中,不同的色块代表着不同的功能点。

其实已经做的不错了,但是在维护起来的时候还是挺灾难的,比如淡蓝色的那个色块代表的功能。我想要完整的理清楚它的逻辑,需要「上下反复横跳」,类似的事情我已经经历过好多次了。

而使用 Hook 以后呢?我们可以把「新建文件夹」这个功能美美的抽到一个函数中去:

function useCreateFolder(openFolder) {
  // originally data properties
  const showNewFolder = ref(false);
  const newFolderName = ref("");

  // originally computed property
  const newFolderValid = computed(() => isValidMultiName(newFolderName.value));

  // originally a method
  async function createFolder() {
    if (!newFolderValid.value) return;
    const result = await mutate({
      mutation: FOLDER_CREATE,
      variables: {
        name: newFolderName.value,
      },
    });
    openFolder(result.data.folderCreate.path);
    newFolderName.value = "";
    showNewFolder.value = false;
  }

  return {
    showNewFolder,
    newFolderName,
    newFolderValid,
    createFolder,
  };
}

我们约定这些「自定义 Hook」以 use 作为前缀,和普通的函数加以区分。

右边用了 Hook 以后的代码组织色块:

我们想要维护紫色部分功能的逻辑,那就在紫色的部分去找就好了,反正不会有其他「色块」里的变量或者方法影响到它,很快咱就改好了需求,6 点准时下班!

这是 Hook 模式下的组件概览,真的是一目了然。感觉我也可以去维护 @vue/ui 了呢(假的)。

export default {
  setup() {
    // ...
  },
};

function useCurrentFolderData(networkState) {
  // ...
}

function useFolderNavigation({ networkState, currentFolderData }) {
  // ...
}

function useFavoriteFolder(currentFolderData) {
  // ...
}

function useHiddenFolders() {
  // ...
}

function useCreateFolder(openFolder) {
  // ...
}

再来看看被吐槽成「意大利面条代码」的 setup 函数。

export default {
  setup() {
    // Network
    const { networkState } = useNetworkState();

    // Folder
    const { folders, currentFolderData } = useCurrentFolderData(networkState);
    const folderNavigation = useFolderNavigation({ networkState, currentFolderData });
    const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData);
    const { showHiddenFolders } = useHiddenFolders();
    const createFolder = useCreateFolder(folderNavigation.openFolder);

    // Current working directory
    resetCwdOnLeave();
    const { updateOnCwdChanged } = useCwdUtils();

    // Utils
    const { slicePath } = usePathUtils();

    return {
      networkState,
      folders,
      currentFolderData,
      folderNavigation,
      favoriteFolders,
      toggleFavorite,
      showHiddenFolders,
      createFolder,
      updateOnCwdChanged,
      slicePath,
    };
  },
};

这是谁家的小仙女这么美啊!这逻辑也太清晰明了,和意大利面没半毛钱关系啊!

对比

Hook 和 Mixin & HOC 对比

说到这里,还是不得不把官方对于「Mixin & HOC 模式」所带来的缺点整理一下。

  1. 渲染上下文中公开的属性的来源不清楚。 例如,当使用多个 mixin 读取组件的模板时,可能很难确定从哪个 mixin 注入了特定的属性。
  2. 命名空间冲突。 Mixins 可能会在属性和方法名称上发生冲突,而 HOC 可能会在预期的 prop 名称上发生冲突。
  3. 性能问题,HOC 和无渲染组件需要额外的有状态组件实例,这会降低性能。

而 「Hook」模式带来的好处则是:

  1. 暴露给模板的属性具有明确的来源,因为它们是从 Hook 函数返回的值。
  2. Hook 函数返回的值可以任意命名,因此不会发生名称空间冲突。
  3. 没有创建仅用于逻辑重用的不必要的组件实例。

当然,这种模式也存在一些缺点,比如 ref 带来的心智负担,详见drawbacks

React Hook 和 Vue Hook 对比

其实 React Hook 的限制非常多,比如官方文档中就专门有一个章节介绍它的限制:

  1. 不要在循环,条件或嵌套函数中调用 Hook
  2. 确保总是在你的 React 函数的最顶层调用他们。
  3. 遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。这让 React 能够在多次的 useState 和 useEffect 调用之间保持 hook 状态的正确。

而 Vue 带来的不同在于:

  1. 与 React Hooks 相同级别的逻辑组合功能,但有一些重要的区别。 与 React Hook 不同,setup 函数仅被调用一次,这在性能上比较占优。

  2. 对调用顺序没什么要求,每次渲染中不会反复调用 Hook 函数,产生的的 GC 压力较小。

  3. 不必考虑几乎总是需要 useCallback 的问题,以防止传递函数prop给子组件的引用变化,导致无必要的重新渲染。

  4. React Hook 有臭名昭著的闭包陷阱问题(甚至成了一道热门面试题,omg),如果用户忘记传递正确的依赖项数组,useEffect 和 useMemo 可能会捕获过时的变量,这不受此问题的影响。 Vue 的自动依赖关系跟踪确保观察者和计算值始终正确无误。

  5. 不得不提一句,React Hook 里的「依赖」是需要你去手动声明的,而且官方提供了一个 eslint 插件,这个插件虽然大部分时候挺有用的,但是有时候也特别烦人,需要你手动加一行丑陋的注释去关闭它。

我们认可 React Hooks 的创造力,这也是 Vue-Composition-Api 的主要灵感来源。上面提到的问题确实存在于 React Hook 的设计中,我们注意到 Vue 的响应式模型恰好完美的解决了这些问题。

顺嘴一题,React Hook 的心智负担是真的很严重,如果对此感兴趣的话,请参考:

使用 react hooks 带来的收益抵得过使用它的成本吗? - 李元秋的回答 - 知乎
https://www.zhihu.com/question/350523308/answer/858145147

并且我自己在实际开发中,也遇到了很多问题,尤其是在我想对组件用 memo 进行一些性能优化的时候,闭包的问题爆炸式的暴露了出来。最后我用 useReducer 大法解决了其中很多问题,让我不得不怀疑这从头到尾会不会就是 Dan 的阴谋……(别想逃过 reducer

React Hook + TS 购物车实战(性能优化、闭包陷阱、自定义 hook)

原理

既然有对比,那就从原理的角度来谈一谈两者的区别,

在 Vue 中,之所以 setup 函数只执行一次,后续对于数据的更新也可以驱动视图更新,归根结底在于它的「响应式机制」,比如我们定义了这样一个响应式的属性:

Vue

<template>
  <div>
    <span>{{count}}</span>
    <button @click="add"> +1 </button>
  </div>
</template>

export default {
    setup() {
        const count = ref(0)

        const add = () => count.value++

        return { count, add }
    }
}

这里虽然只执行了一次 setup 但是 count 在原理上是个 「响应式对象」,对于其上 value 属性的改动,

是会触发「由 template 编译而成的 render 函数」 的重新执行的。

如果需要在 count 发生变化的时候做某件事,我们只需要引入 effect 函数:

<template>
  <div>
    <span>{{count}}</span>
    <button @click="add"> +1 </button>
  </div>
</template>

export default {
    setup() {
        const count = ref(0)

        const add = () => count.value++

        effect(function log(){
            console.log('count changed!', count.value)
        })

        return { count, add }
    }
}

这个 log 函数只会产生一次,这个函数在读取 count.value 的时候会收集它作为依赖,那么下次 count.value 更新后,自然而然的就能触发 log 函数重新执行了。

仔细思考一下这之间的数据关系,相信你很快就可以理解为什么它可以只执行一次,但是却威力无穷。

实际上 Vue3 的 Hook 只需要一个「初始化」的过程,也就是 setup,命名很准确。它的关键字就是「只执行一次」。

React

同样的逻辑在 React 中,则是这样的写法:

export default function Counter() {
  const [count, setCount] = useState(0);

  const add = () => setCount((prev) => prev + 1);

  // 下文讲解用
  const [count2, setCount2] = useState(0);

  return (
    <div>
      <span>{count}</span>
      <button onClick={add}> +1 </button>
    </div>
  );
}

它是一个函数,而父组件引入它是通过 <Counter /> 这种方式引入的,实际上它会被编译成 React.createElement(Counter) 这样的函数执行,也就是说每次渲染,这个函数都会被完整的执行一次。

useState 返回的 countsetCount 则会被保存在组件对应的 Fiber 节点上,每个 React 函数每次执行 Hook 的顺序必须是相同的,举例来说。 这个例子里的 useState 在初次执行的时候,由于执行了两次 useState,会在 Fiber 上保存一个 { value, setValue } -> { value2, setValue2 } 这样的链表结构。

而下一次渲染又会执行 count 的 useStatecount2 的 useState,那么 React 如何从 Fiber 节点上找出上次渲染保留下来的值呢?当然是只能按顺序找啦。

第一次执行的 useState 就拿到第一个 { value, setValue },第二个执行的就拿到第二个 { value2, setValue2 }

这也就是为什么 React 严格限制 Hook 的执行顺序和禁止条件调用。

假如第一次渲染执行两次 useState,而第二次渲染时第一个 useState 被 if 条件判断给取消掉了,那么第二个 count2 的 useState 就会拿到链表中第一条的值,完全混乱了。

如果在 React 中,要监听 count 的变化做某些事的话,会用到 useEffect 的话,那么下次 render

之后会把前后两次 render 中拿到的 useEffect 的第二个参数 deps 依赖值进行一个逐项的浅对比(对前后每一项依次调用 Object.is),比如

export default function Counter() {
  const [count, setCount] = useState(0);

  const add = () => setCount((prev) => prev + 1);

  useEffect(() => {
    console.log("count updated!", count);
  }, [count]);

  return (
    <div>
      <span>{count}</span>
      <button onClick={add}> +1 </button>
    </div>
  );
}

那么,当 React 在渲染后发现 count 发生了变化,会执行 useEffect 中的回调函数。(细心的你可以观察出来,每次渲染都会重新产生一个函数引用,也就是 useEffect 的第一个参数)。

是的,React 还是不可避免的引入了 依赖 这个概念,但是这个 依赖 是需要我们去手动书写的,实时上 React 社区所讨论的「心智负担」也基本上是由于这个 依赖 所引起的……

由于每次渲染都会不断的执行并产生闭包,那么从性能上和 GC 压力上都会稍逊于 Vue3。它的关键字是「每次渲染都重新执行」。

关于抄袭 React Hook

其实前端开源界谈抄袭也不太好,一种新的模式的出现的值得框架之间相互借鉴和学习的,毕竟框架归根结底的目的不是为了「标榜自己的特立独行」,而是「方便广大开发者」。这是值得思考的一点,很多人似乎觉得一个框架用了某种模式,另一个框架就不能用,其实这对于框架之间的进步和发展并没有什么好处。

这里直接引用尤大在 17 年回应「Vue 借鉴虚拟 dom」的一段话吧:

再说 vdom。React 的 vdom 其实性能不怎么样。Vue 2.0 引入 vdom 的主要原因是 vdom 把渲染过程抽象化了,从而使得组件的抽象能力也得到提升,并且可以适配 DOM 以外的渲染目标。这一点是借鉴 React 毫无争议,因为我认为 vdom 确实是个好**。但要分清楚的是 Vue 引入 vdom 不是因为『react 有所以我们也要有』,而是因为它确实有技术上的优越性。社区里基于 vdom **造的轮子海了去了,而 ng2 的渲染抽象层和 Ember Glimmer 2 的模板 -> opcode 编译也跟 vdom 有很多**上的相似性。

这段话如今用到 Hook 上还是一样的适用,程序员都提倡开源精神,怎么到了 Vue 和 React 之间有些人又变得小气起来了呢?说的难听点,Vue 保持自己的特立独行,那你假如换了一家新公司要你用 Vue,你不是又得从头学一遍嘛。

更何况 React 社区也一样有对 Vue 的借鉴,比如你看 react-router@6 的 api,你会发现很多地方和 vue-router 非常相似了。比如 useRoutes 的「配置式路由」,以及在组件中使子路由的代码结构等等。当然这只是我浅显的认知,不对的地方也欢迎指正。

扩展阅读

对于两种 Hook 之间的区别,想要进一步学习的同学还可以看黄子毅大大的好文:

精读《Vue3.0 Function API》

尤小右在官方 issue 中对于 React Hook 详细的对比看法:

Why remove time slicing from vue3?

总结

其实总结下来,社区中还是有一部分的反对观点是由于「没有好好看文档」造成的,那本文中我就花费自己一些业余时间整理社区和官方的一些观点作为一篇文章,至于看完文章以后你会不会对 Vue3 的看法有所改观,这并不是我能决定的,只不过我很喜欢 Vue3,我也希望能够尽自己的一点力量,让大家能够不要误解它。

对于意大利面代码:

  1. 提取共用的自定义 Hook(在写 React 购物车组件的时候,我提取了 3 个以上可以全局复用的 Hook)。
  2. 基于「逻辑功能」去组织代码,而不是 state 放在一块,method 放在一块,这样和用 Vue2 没什么本质上的区别(很多很多新人在用 React Hook 的时候犯这样的错误,包括我自己)。

对于心智负担:

  1. 更强大的能力意味着更多的学习成本,但是 Vue3 总体而言我觉得已经把心智负担控制的很到位了。对于 ref 这个玩意,确实是需要仔细思考一下才能理解。
  2. React Hook 的心智负担已经重的出名了,在我实际的开发过程中,有时候真的会被整到头秃…… 尤其是抽了一些自定义 Hook,deps 依赖会层层传递的情况下(随便哪一层的依赖错了,你的应用就爆炸了)。
  3. 不学习怎么能升职加薪,迎娶白富美,走向人生巅峰呢!(瞎扯)

Vue3 有多香呢?甚至《React 状态管理与同构实战》的作者、React 的忠实粉丝Lucas HC在这篇 Vue 和 React 的优点分别是什么? 中都说了这样的一句话:

我不吐槽更多了:一个 React 粉丝向 Vue3.0 致敬!

Vue3 目前也已经有了 Hook 的一些尝试:

https://github.com/u3u/vue-hooks

总之,希望看完这篇文章的你,能够更加喜欢 Vue3,对于它的到来我已经是期待的不行了。

最后再次强调一下作者的立场,我对于 React 和 Vue 都非常的喜欢。他们都有着各自的优缺点,本文绝无引战之意。两个框架都很棒!只是各有优缺点而已。React 的 Immutable 其实也带来了很多益处,并且 Hook 的思路还是 Facebook 团队的大佬们首创的,真的是很让人赞叹的设计,我对 React 100% 致敬!

本文的唯一目的就是想消除一些朋友对于 Vue 3.0 的误解,绝无他意,如有冒犯敬请谅解~

求点赞

如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我持续进行创作的动力,让我知道你喜欢看我的文章吧~

❤️ 感谢大家

关注公众号「前端从进阶到入院」,有机会抽取「掘金小册 5 折优惠码」

关注公众号加好友,拉你进「前端进阶交流群」,大家一起共同交流和进步。

前端电商 sku 全排列的递归回溯算法实战

前言

前段时间在掘金看到一个热帖 今天又懒得加班了,能写出这两个算法吗?带你去电商公司写商品中心,里面提到了一个比较有意思故事,大意就是一个看似比较简单的电商 sku 的全排列组合算法,但是却有好多人没能顺利写出来。有一个毕业生小伙子在面试的时候给出了思路,但是进去以后还是没写出来,羞愧跑路~

其实排列组合是一个很经典的算法,也是对递归回溯法的一个实践运用,本篇文章就以带你学习一个标准「排列组合求解模板」,耐心看完,你会有更多收获。

需求

需求描述起来很简单,有这样三个数组:

let names = ["iPhone X", "iPhone XS"]

let colors = ["黑色", "白色"]

let storages = ["64g", "256g"]

需要把他们的所有组合穷举出来,最终得到这样一个数组:

[
  ["iPhone X", "黑色", "64g"],
  ["iPhone X", "黑色", "256g"],
  ["iPhone X", "白色", "64g"],
  ["iPhone X", "白色", "256g"],
  ["iPhone XS", "黑色", "64g"],
  ["iPhone XS", "黑色", "256g"],
  ["iPhone XS", "白色", "64g"],
  ["iPhone XS", "白色", "256g"],
]

由于这些属性数组是不定项的,所以不能简单的用三重的暴力循环来求解了。

思路

如果我们选用递归回溯法来解决这个问题,那么最重要的问题就是设计我们的递归函数。

思路分解

以上文所举的例子来说,比如我们目前的属性数组就是:namescolorsstorages,首先我们会处理 names 数组,很显然对于每个属性数组,都需要去遍历它,然后一个一个选择后再去和 下一个数组的每一项进行组合。

我们设计的递归函数接受两个参数:

  • index 对应当前正在处理的下标,是 names 还是 colors 或是 storage
  • prev 上一次递归已经拼接成的结果,比如 ['iPhone X', '黑色']

进入递归函数:

  1. 处理属性数组的下标0:假设我们在第一次循环中选择了 iPhone XS,那此时我们有一个未完成的结果状态,假设我们叫它 prev,此时 prev = ['iPhone XS']

  2. 处理属性数组的下标1:那么就处理到 colors 数组的了,并且我们拥有 prev,在遍历 colors 的时候继续递归的去把 prev 拼接成 prev.concat(color),也就是 ['iPhone XS', '黑色'] 这样继续把这个 prev 交给下一次递归。

  3. 处理属性数组的下标2:那么就处理到 storages 数组的了,并且我们拥有了 name + colorprev,在遍历 storages 的时候继续递归的去把 prev 拼接成 prev.concat(storage),也就是 ['iPhone XS', '黑色', '64g'],并且此时我们发现处理的属性数组下标已经到达了末尾,那么就放入全局的结果变量 res 中,作为一个结果。

编码实现

let names = ["iPhone X", "iPhone XS"]

let colors = ["黑色", "白色"]

let storages = ["64g", "256g"]

let combine = function (...chunks) {
  let res = []

  let helper = function (chunkIndex, prev) {
    let chunk = chunks[chunkIndex]
    let isLast = chunkIndex === chunks.length - 1
    for (let val of chunk) {
      let cur = prev.concat(val)
      if (isLast) {
        // 如果已经处理到数组的最后一项了 则把拼接的结果放入返回值中
        res.push(cur)
      } else {
        helper(chunkIndex + 1, cur)
      }
    }
  }

  // 从属性数组下标为 0 开始处理
  // 并且此时的 prev 是个空数组
  helper(0, [])

  return res
}

console.log(combine(names, colors, storages))

递归树图

画出以 iPhone X 这一项为起点的递归树图,当然这个问题是一个多个根节点的树,请自行脑补 iPhone XS 为起点的树,子结构是一模一样的。

万能模板

为什么说这种接法是排列组合的「万能模板呢」?来看一下 LeetCode 上的 77. 组合 问题,这是一道难度为 medium 的问题,其实算是比较有难度的问题了:

问题

给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。

示例:

输入: n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

解答

let combine = function (n, k) {
  let ret = []

  let helper = (start, prev) => {
    let len = prev.length
    if (len === k) {
      ret.push(prev)
      return
    }

    for (let i = start; i <= n; i++) {
      helper(i + 1, prev.concat(i))
    }
  }
  helper(1, [])
  return ret
}

可以看出这题和我们求解电商排列组合的代码竟然如此相似。只需要设计一个接受 start排列起始位置、prev上一次拼接结果为参数的递归 helper函数,

然后对于每一个起点下标 start,先拼接上 start位置对应的值,再不断的再以其他剩余的下标作为起点去做下一次拼接。当 prev 这个中间状态的拼接数组到达题目的要求长度 k后,就放入结果数组中。

剪枝

在这个解法中,有一些递归分支是明显不可能获取到结果的,我们每次递归都会循环尝试 <= n的所有项去作为start,假设我们要求的数组长度 k = 3,最大值 n = 4

而我们以 prev = [1],再去以 n = 4start 作为递归的起点,那么显然是不可能得到结果的,因为 n = 4 的话就只剩下 4这一项可以拼接了,最多也就拼接成 [1, 4],不可能满足 k = 3 的条件。

所以在进入递归之前,就果断的把这些“废枝”给减掉。

let combine = function (n, k) {
  let ret = []

  let helper = (start, prev) => {
    let len = prev.length
    if (len === k) {
      ret.push(prev)
      return
    }

    // 还有 rest 个位置待填补
    let rest = k - prev.length
    for (let i = start; i <= n; i++) {
      if (n - i + 1 < rest) {
        continue
      }
      helper(i + 1, prev.concat(i))
    }
  }
  helper(1, [])
  return ret
}

相似题型

当然,力扣中可以套用这个模板的相似题型还有很多,而且大多数难度都是 medium的,比如快手的面试题子集 II-90,可以看出排列组合的递归解法还是有一定的难度的。

我在维护的 LeetCode 题解仓库 中已经按标签筛选好 「递归与回溯」类型的几道题目和解答了,感兴趣的小伙伴也可以一起攻破它们。

总结

排列组合问题并不是空中楼阁,在实际工作中也会经常遇到这种场景,掌握了递归回溯的标准模板当然不是为了让你死记硬背套公式,而是真正的理解它。遇到需要递归解决的问题。

  1. 画出递归树状图,找出递归公式。
  2. 对于不可能达成条件的分支递归,进行合理的「剪枝」。

希望阅读完本篇文章的你,能对递归和排列组合问题有进一步的理解和收获。

❤️ 感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

Vue3中不止composition-api,其他的提案(RFC)也很精彩。

最近一段时间,Vue3带来的新能力composition-api带来了比较大的轰动,虽然是灵感是源React Hook,但是在很多方面却超越了它。但是除了composition-api,其他的改动却比较少有人讨论,本篇文章就由vuejs/rfcs 这个仓库来看看其他比较让人振奋的RFC。

RFC其实就是(Request For Comments)征求修正意见书,它不代表这个api一定会正式通过,但是却可以让社区知道vuejs团队正在进行的一些工作,和一些新想法。

Vue的RFC分为四个阶段:

  1. Pending:当RFC作为PR提交时。

  2. Active:当RFC PR正在合并时。

  3. Landed:当RFC提出的更改在实际发行版中发布时。

  4. Rejected:关闭RFC PR而不合并时。

本篇讨论的RFC都在Active阶段

删除filters的支持

<!-- before -->
{{ msg | format }}

<!-- after -->
{{ format(msg) }}

动机:

  1. 过滤器的功能可以轻松地通过方法调用或计算的属性来复制,因此它主要提供语法而不是实用的价值。

  2. 过滤器需要一种自定义的微语法,该语法打破了表达式只是“ JavaScript”的假设-这增加了学习和实现成本。 实际上,它与JavaScript自己的按位或运算符(|)冲突,并使表达式解析更加复杂。

  3. 过滤器还会在模板IDE支持中增加额外的复杂性(由于它们不是真正的JavaScript)。

替代:

  1. 可以简单的利用method替换filter的能力,统一语法,Vue.filter全局注册的能力也可以用Vue.prototype全局挂载方法来实现。

  2. 目前有一个stage-1的提案pipeline-operator 可以优雅的实现方法组合。

let transformedMsg = msg |> uppercase |> reverse |> pluralize

render函数的改变

原文:
https://github.com/vuejs/rfcs/blob/master/active-rfcs/0008-render-function-api-change.md

概览:

  1. h现在已全局导入,而不是传递给渲染函数作为参数

  2. 渲染函数参数已更改,并使stateful组件和functional组件之间保持一致

  3. VNode现在具有拉平的props结构

基本示例:

// globally imported `h`
import { h } from 'vue'

export default {
  render() {
    return h(
      'div',
      // flat data structure
      {
        id: 'app',
        onClick() {
          console.log('hello')
        }
      },
      [
        h('span', 'child')
      ]
    )
  }
}

动机:
在2.x中,VNode是特定于上下文的-这意味着创建的每个VNode都绑定到创建它的组件实例(“上下文”),

在2.x中,这样的一段代码:

{
    render(h) {
        return h('div')
    }
}

h其实是通过render中的形参传入的,这是因为它需要关心是哪个组件实例在调用它,在3.x中,文章中介绍说vnode将会成为context free的,这意味着更加灵活的组件声明位置(不止在.vue文件中,不需要到处传递h参数)。

并且如果context free真的实现,那么在2.x中Vue高阶组件的一些诟病也可以一同解决掉了,如果对context带来的高阶组件的bug感兴趣的话,可以查看HcySunYang大大的这篇文章:
https://segmentfault.com/p/1210000012743259/read

另外本篇中还提到了一个vnode的属性拉平,

// before
{
  class: ['foo', 'bar'],
  style: { color: 'red' },
  attrs: { id: 'foo' },
  domProps: { innerHTML: '' },
  on: { click: foo },
  key: 'foo'
}

// after
{
  class: ['foo', 'bar'],
  style: { color: 'red' },
  id: 'foo',
  innerHTML: '',
  onClick: foo,
  key: 'foo'
}

目前看来,由于jsx最终会被编译成生成vnode的方法,这个改动可能会让vue中书写jsx变得更加容易,现在的一些写法可以看我写的这篇文章:
手把手教你用jsx封装Vue中的复杂组件(网易云音乐实战项目需求)

在这篇文章中可以看出,目前嵌套的vnode结构会让jsx的书写也变得很困难。

由于render函数的一些另外的细微变动,Vue3中理想的functional component的书写方式是这样的:

import { inject } from 'vue'
import { themeSymbol } from './ThemeProvider'

const FunctionalComp = props => {
  const theme = inject(themeSymbol)
  return h('div', `Using theme ${theme}`)
}

是不是很像React,哈哈。

全局方法的导入方式

为了更好的支持tree-shaking,Vue3把2.x中统一导出Vue的方式更改为分散导出,这样只有项目中用到的方法会被打包进bundle中,有效的减少了包的大小。

import { nextTick, observable } from 'vue'

nextTick(() => {})

const obj = observable({})

简单的来说,如果你项目中只用到了observablenextTick,那么例如usereactive等这些另外的api就不会被打包进你的项目中。

关于tree-shaking,我特别喜欢的作者相学长有一篇文章可以看一下:

https://zhuanlan.zhihu.com/p/32831172

总结

在这个仓库中,还有一些提案大家也可以自行去看一下,剩下的都是一些细节的优化,这些优化或多或少的会让Vue3更好用一些,非常期待Vue3的到来。

另外由于plugin的存在,我已经在2.x中用Vue3的composition-api做了一些尝鲜,不得不说真香

Vue3 Composition-Api + TypeScript + 新型状态管理模式探索

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.