SAMPAN

SAMPAN 查看完整档案

上海编辑华中农业大学  |  广告学 编辑  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

SAMPAN 收藏了文章 · 2020-07-16

五个大型项目实践总结,解密React Hooks最佳实践

SAMPAN 关注了专栏 · 2020-02-29

终身学习者

我要先坚持分享20年,大家来一起见证吧。

关注 49181

SAMPAN 赞了文章 · 2019-12-31

学习JavaScript数据结构与算法 — AVL树

AVL树

普通二叉搜索树可能出现一条分支有多层,而其他分支却只有几层的情况,如图1所示,这会导致添加、移除和搜索树具有性能问题。因此提出了自平衡二叉树的概念,AVL树(阿德尔森-维尔斯和兰迪斯树)是自平衡二叉树的一种,AVL树的任一子节点的左右两侧子树的高度之差不超过1,所以它也被称为高度平衡树。

图1

clipboard.png

要将不平衡的二叉搜索树转换为平衡的AVL树需要对树进行一次或多次旋转,旋转方式分为左单旋、右单旋、左-右双旋、右-左双旋。

左单旋

对某一节点B(图2)做左单旋,处理过程相当于,断开B与父节点A的连接,将B的右子节点D与A连接,将B作为D的左子节点,将D的左子节点E作为B的右子节点。以图1的二叉树为例,对键值为15的节点做左单旋,首先断开15与11的连接,再将20与11连接,将15作为20的左子节点,最后将18作为15的右子节点;可以想象为以15为中心做了一定的逆时针旋转。结果如图3。

图2

clipboard.png

图3

clipboard.png

再看图2,根据搜索二叉树的性质,肯定有D>B>A,E>B,因此旋转过后,能够保证 右子节点 > 父节点 > 左子节点,不会破坏树的结构。
可以看到,一次左单旋将右侧子树的高度减小了1,而左侧子树的高度增加了1。实现代码如下:

function roateLeft(AvlNode) {
        var node = AvlNode.right; // 保存右子节点
        AvlNode.right = node.left; // node的左子节点连接到AvlNode成为其右子节点
        node.left = AvlNode; // AvlNode连接到node成为其左子节点
        return node; // 返回node,连接到AvlNode最初的父节点
}

右单旋

右单旋与左单选类似,以某一节点B(图4)做右单旋,首先断开B与其父节点A的连接,将B的左子节点C与A连接,将C的右子节点F作为B的左子节点。同样的,因为有C>A,B>F>C,因此旋转过后,不会破坏树的结构。可以看到,一次右单旋使节点的左侧子树高度减小了1,而右侧子树的高度增加了1。

图4

clipboard.png

实现代码如下:

function roateRight(AvlNode) {
        var node = AvlNode.left; // 保存左子节点
        AvlNode.left = node.right; // 将node的右子节点连接到AvlNode成为其左子节点
        node.right = AvlNode; // AvlNode连接到node,成为其右子节点
        return node; // 返回node连接到AvlNode最初的父节点
}

左-右双旋

左单旋、右单旋在某些情况下是不能达到平衡树的目的的。如图4,对B进行右单旋,需要左子树C的右子树F的高度小于等于左子树E的高度,否则不能达到平衡的效果,只是把不平衡性从左边转移到了右边。图5演示了这种情况。同样的,左单旋也有这个问题。

图5

clipboard.png

因此为了达到目的,需要先对旋转节点的左子节点做左单旋,再对旋转节点做右单旋。如图6所示,先对节点B的左子节点C做左单旋,可以看到,这个操作,相当于将节点C的不平衡性从右侧转移到了左侧,从而满足了上述右单旋的条件;最后再对B节点做右单旋操作,最终达到了平衡的目的。

图6

clipboard.png

实现代码如下:

function roateLeftRight(AvlNode) {
        AvlNode.right = roateLeft(AvlNode.right); // 对右子节点做左单旋
        return roateRight(AvlNode); // 做右单旋
}

右-左双旋

同理,如图2,对B进行左单旋时,需要右子树D的右子树F的高度大于等于左子树E的高度,否则需要进行双旋;即先对B的右子节点D做右单旋,再对B做左单旋。实现代码如下:

function roateRightLeft(AvlNode) {
        AvlNode.left = roateRight(AvlNode.left); // 对左子节点做右单旋
        return roateLeft(AvlNode); // 做左单旋
}

实现树的平衡

首先实现获取树高度的函数:

function getAvlTreeHeight(node) {
        if (node == null) {
            // node不存在返回0
            return 0;
        } else {
            var leftHeight = getAvlTreeHeight(node.left);
            var rightHeight = getAvlTreeHeight(node.right);
            // 返回左子树、右子树中的最大高度
            return (leftHeight > rightHeight ? leftHeight : rightHeight) + 1;
        }
}

实现平衡树的函数:

function balance(node) {
    if (node == null) {
        return node;
    }
    // 左子树高度比右子树高度大1以上
    if (getAvlTreeHeight(node.left) - getAvlTreeHeight(node.right) > 1) {
        if (getAvlTreeHeight(node.left.left) >= getAvlTreeHeight(node.left.right)) {
            // 如果左子树的左子树高度大于等于左子树的右子树高度
            // 直接进行右单旋
            node = roateRight(node);
        } else {
            // 否则需要右-左双旋
            node = roateRightLeft(node);
        }
        // 右子树高度比左子树高度大1以上
    } else if (getAvlTreeHeight(node.right) - getAvlTreeHeight(node.left) > 1) {
        if (getAvlTreeHeight(node.right.right) >= getAvlTreeHeight(node.right.left)) {
            // 如果右子树的右子树高度大于等于右子树的左子树高度
            // 直接进行左单旋
            node = roateLeft(node);
        } else {
            // 否则需要左-右双旋
            node = roateLeftRight(node);
        }
    }
    return node;
}

二叉搜索树的基础上,每次插入节点,都需要做一次树的平衡处理:

var insertNode = function(node, newNode){
    if (newNode.key < node.key){
        if (node.left === null){
            node.left = newNode;
            // 插入节点后,做树的平衡处理
            node.left = balance(node.left);
        } else {
            insertNode(node.left, newNode);
        }
    } else {
        if (node.right === null){
            node.right = newNode;
            // 插入节点后,做树的平衡处理
            node.right = balance(node.right);
        } else {
            insertNode(node.right, newNode);
        }
    }
}

综上,一颗自平衡AVL树的原理及实现就完成了。

查看原文

赞 6 收藏 6 评论 8

SAMPAN 赞了文章 · 2019-12-17

webpack使用-详解DllPlugin

前言

(时光飞逝,转眼又偷懒了一个多月)

什么是DLL

DLL(Dynamic Link Library)文件为动态链接库文件,在Windows中,许多应用程序并不是一个完整的可执行文件,它们被分割成一些相对独立的动态链接库,即DLL文件,放置于系统中。当我们执行某一个程序时,相应的DLL文件就会被调用。

举个例子:很多产品都用到螺丝,但是工厂在生产不同产品时,不需要每次连带着把螺丝也生产出来,因为螺丝可以单独生产,并给多种产品使用。在这里螺丝的作用就可以理解为是dll。

为什么要使用Dll

通常来说,我们的代码都可以至少简单区分成业务代码第三方库。如果不做处理,每次构建时都需要把所有的代码重新构建一次,耗费大量的时间。然后大部分情况下,很多第三方库的代码并不会发生变更(除非是版本升级),这时就可以用到dll:把复用性较高的第三方模块打包到动态链接库中,在不升级这些库的情况下,动态库不需要重新打包,每次构建只重新打包业务代码

还是上面的例子:把每次构建,当做是生产产品的过程,我们把生产螺丝的过程先提取出来,之后我们不管调整产品的功能或者设计(对应于业务代码变更),都不必重复生产螺丝(第三方模块不需要重复打包);除非是产品要使用新型号的螺丝(第三方模块需要升级),才需要去重新生产新的螺丝,然后接下来又可以专注于调整产品本身。

基本用法

使用dll时,可以把构建过程分成dll构建过程和主构建过程(实质也就是如此),所以需要两个构建配置文件,例如叫做webpack.config.jswebpack.dll.config.js

1. 使用DLLPlugin打包需要分离到动态库的模块

DllPluginwebpack内置的插件,不需要额外安装,直接配置webpack.dll.config.js文件:

module.exports = {=
  entry: {
    // 第三方库
    react: ['react', 'react-dom', 'react-redux']
  },
  output: {
    // 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称,
    filename: '[name].dll.js',
    path: resolve('dist/dll'),
    // library必须和后面dllplugin中的name一致 后面会说明
    library: '[name]_dll_[hash]'
  },
  plugins: [
  // 接入 DllPlugin
    new webpack.DllPlugin({
      // 动态链接库的全局变量名称,需要和 output.library 中保持一致
      // 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
      name: '[name]_dll_[hash]',
      // 描述动态链接库的 manifest.json 文件输出时的文件名称
      path: path.join(__dirname, 'dist/dll', '[name].manifest.json')
    }),
  ]
}

我们先来看看,这一步到底做了什么。执行:webpack --config webpack.dll.config,然后到指定的输出文件夹查看输出:

  1. react.dll文件里是使用数组保存的模块,索引值就作为id;
  2. react.manifest.json文件里,是用来描述对应的dll文件里保存的模块

里暴露出刚刚构建的所有模块,如下:

{
  "name":"react_dll_553e24e2c44987d2578f",
  "content":{
    "./node_modules/webpack/node_modules/process/browser.js":{"id":0,"meta":{}},"./node_modules/react/node_modules/fbjs/lib/invariant.js":{"id":1,"meta":{}},"./node_modules/react/lib/Object.assign.js":{"id":2,"meta":{}},"./node_modules/react/node_modules/fbjs/lib/warning.js":{"id":3,"meta":{}}
    //省略相似代码
  }
}

2. 在主构建配置文件使用动态库文件

webpack.config中使用dll要用到DllReferencePlugin,这个插件通过引用 dll 的 manifest 文件来把依赖的名称映射到模块的 id 上,之后再在需要的时候通过内置的 webpack_require 函数来 require 他们.

  new webpack.DllReferencePlugin({
    context: __dirname,
    manifest: require('./dist/dll/react.manifest.json')
  }),

第一步产出的manifest文件就用在这里,给主构建流程作为查找dll的依据:DllReferencePlugin去 manifest.json 文件读取 name 字段的值,把值的内容作为在从全局变量中获取动态链接库中内容时的全局变量名,因此:在 webpack_dll.config.js 文件中,DllPlugin 中的 name 参数必须和 output.library 中保持一致。

3. 在入口文件引入dll文件。

生成的dll暴露出的是全局函数,因此还需要在入口文件里面引入对应的dll文件。

<body>
  <div id="app"></div>
  <!--引用dll文件-->
  <script data-original="../../dist/dll/react.dll.js" ></script>
</body>

作用

首先从前面的介绍,至少可以看出dll的两个作用

  1. 分离代码,业务代码和第三方模块可以被打包到不同的文件里,这个有几个好处:

    • 避免打包出单个文件的大小太大,不利于调试
    • 将单个大文件拆成多个小文件之后,一定情况下有利于加载(不超出浏览器一次性请求的文件数情况下,并行下载肯定比串行快)
  2. 提升构建速度。第三方库没有变更时,由于我们只构建业务相关代码,相比全部重新构建自然要快的多。

注意事项

从前面可以看到dll带来的优点,但并不意味着我们就应该把除业务代码外的所有代码全部都丢到dll中,举一个例子:
1.对于lodash这种第三方库,正确的用法是只去import所需的函数(用什么引什么),例如:

// 正确用法
import isPlainObject from 'lodash/isPlainObject'

//错误用法
import { isPlainObject } from 'lodash'

这两种写法的差别在于,打包时webpack会根据引用去打包依赖的内容,所以第一种写法,webpack只会打包lodash的isPlainObject库,第二种写法却会打包整个lodash。现在假设在项目中只是用到不同模块对lodash里的某几个函数并且没有对于某个函数重复使用非常多次,那么这时候把lodash添加到dll中,带来的收益就并不明显,反而导致2个问题:

  1. 由于打包了整个lodash,而导致打包后的文件总大小(注意是总大小)比原先还要大
  2. 在dll打包太多内容也需要耗费时间,虽然我们一般只在第三方模块更新之后才进行重新预编译(就是dll打包的过程),但是如果这个时间太长的话体验也不好、

实践与反思

放一张自己在一个比较大的项目中单纯使用dll之后的收益,提取的内容是 react相关的第三方库,和fish组件,构建时间从120s降低到80s左右(当然这个时间还是有点恐怖),构建前appjs的大小是680kb,拆分业务代码和第三方代码分别是400kb和380kb(这就是拆分后大小大于拆分前大小的例子),从这一点来看,对于常见第三方库是否要放进dll可能比较明确(比如react系列打包一般肯定不亏),但是还有一些就要结合具体的项目内容来进行判断和取舍。(强烈推荐使用webpack-bundle-analyzer插件进行性能分析)
图片描述
图片描述
图片描述

总结

本文介绍了Dllplugin的思想,基本用法和应用场景(关于使用的部分更详细的内容可以看官方文档),结合个人的一些实践经验,对于常见第三方库是否要放进dll可能比较明确(比如react系列打包一般肯定不亏),但是还有一些就要结合具体的项目内容来判断,例如我上面的实践的例子就说明目前的拆分还不够好。这一块也欢迎大家一起探讨。如果内容有错误的地方欢迎指出(觉得看着不理解不舒服想吐槽也完全没问题);如果对你有帮助,欢迎点赞和收藏,转载请征得同意后著明出处,如果有问题也欢迎私信交流,主页有邮箱地址

查看原文

赞 35 收藏 18 评论 1

SAMPAN 赞了文章 · 2019-12-06

源码学习记录: tapable

前言

上一遍博文中,我们谈到了tapable的用法,现在我们来深入一下tap究竟是怎么运行的, 怎么处理,控制 tap 进去的钩子函数,拦截器又是怎么运行的.

先从同步函数开始分析,异步也就是回调而已;

tap

这里有一个例子

let SyncHook = require('./lib/SyncHook.js')

let h1 = new SyncHook(['options']);

h1.tap('A', function (arg) {
  console.log('A',arg);
  return 'b'; // 除非你在拦截器上的 register 上调用这个函数,不然这个返回值你拿不到.
})

h1.tap('B', function () {
  console.log('b')
})
h1.tap('C', function () {
  console.log('c')
})
h1.tap('D', function () {
  console.log('d')
})

h1.intercept({
  call: (...args) => {
    console.log(...args, '-------------intercept call');
  },
  //
  register: (tap) => {
  console.log(tap, '------------------intercept register');

    return tap;
  },
  loop: (...args) => {
    console.log(...args, '-------------intercept loop')
  },
  tap: (tap) => {
    console.log(tap, '-------------------intercept tap')

  }
})
h1.call(6);

new SyncHook(['synchook'])

首先先创建一个同步钩子对象,那这一步会干什么呢?

这一步会先执行超类Hook的初始化工作

// 初始化
constructor(args) {
  // 参数必须是数组
  if (!Array.isArray(args)) args = [];
  // 把数组参数赋值给 _args 内部属性, new 的时候传进来的一系列参数.
  this._args = args;
  // 绑定taps,应该是事件
  this.taps = [];
  // 拦截器数组
  this.interceptors = [];
  // 暴露出去用于调用同步钩子的函数
  this.call = this._call;
  // 暴露出去的用于调用异步promise函数
  this.promise = this._promise;
  // 暴露出去的用于调用异步钩子函数
  this.callAsync = this._callAsync;
  // 用于生成调用函数的时候,保存钩子数组的变量,现在暂时先不管.
  this._x = undefined;
}

第二部 .tap()

现在我们来看看调用了tap() 方法后发生了什么

tap(options, fn) {
  // 下面是一些参数的限制,第一个参数必须是字符串或者是带name属性的对象,
  // 用于标明钩子,并把钩子和名字都整合到 options 对象里面
  if (typeof options === "string") options = { name: options };
  if (typeof options !== "object" || options === null)
    throw new Error(
      "Invalid arguments to tap(options: Object, fn: function)"
    );
  options = Object.assign({ type: "sync", fn: fn }, options);
  if (typeof options.name !== "string" || options.name === "")
    throw new Error("Missing name for tap");
  // 注册拦截器
  options = this._runRegisterInterceptors(options);
  // 插入钩子
  this._insert(options);
}
  • 现在我们来看看如何注册拦截器
_runRegisterInterceptors(options) {
  // 现在这个参数应该是这个样子的{fn: function..., type: sync,name: 'A' }
// 遍历拦截器,有就应用,没有就把配置返还回去
for (const interceptor of this.interceptors) {
  if (interceptor.register) {
    // 把选项传入拦截器注册,从这里可以看出,拦截器的register 可以返回一个新的options选项,并且替换掉原来的options选项,也就是说可以在执行了一次register之后 改变你当初 tap 进去的方法
    const newOptions = interceptor.register(options);
    if (newOptions !== undefined) options = newOptions;
  }
}
return options;
}

注意: 这里执行的register拦截器是有顺序问题的, 这个执行在tap()里面,也就是说,你这个拦截器要在调用tap(),之前就调用 intercept()添加的.

那拦截器是怎么添加进去的呢,来看下intercept()

intercept(interceptor) {
  // 重置所有的 调用 方法,在教程中我们提到了 编译出来的调用方法依赖的其中一点就是 拦截器. 所有每添加一个拦截器都要重置一次调用方法,在下一次编译的时候,重新生成.
  this._resetCompilation();
  // 保存拦截器 而且是复制一份,保留原本的引用
  this.interceptors.push(Object.assign({}, interceptor));
  // 运行所有的拦截器的register函数并且把 taps[i],(tap对象) 传进去.
  // 在intercept 的时候也会遍历执行一次当前所有的taps,把他们作为参数调用拦截器的register,并且把返回的 tap对象(tap对象就是指 tap函数里面把fn和name这些信息整合起来的那个对象) 替换了原来的 tap对象,所以register最好返回一个tap, 在例子中我返回了原来的tap, 但是其实最好返回一个全新的tap
  if (interceptor.register) {
    for (let i = 0; i < this.taps.length; i++)
      this.taps[i] = interceptor.register(this.taps[i]);
  }
}

注意: 也就是在调用tap() 之后再传入的拦截器,会在传入的时候就为每一个tap 调用register方法

  • 现在我们来看看_insert
_insert(item) {
  // 重置资源,因为每一个插件都会有一个新的Compilation
  this._resetCompilation();
  // 顺序标记, 这里联合 __test__ 包里的Hook.js一起使用
  // 看源码不懂,可以看他的测试代码,就知道他写的是什么目的.
  // 从测试代码可以看到,这个 {before}是插件的名字.
  let before;
  // before 可以是单个字符串插件名称,也可以是一个字符串数组插件.
  if (typeof item.before === "string") {
    before = new Set([item.before]);
  }
  else if (Array.isArray(item.before)) {
    before = new Set(item.before);
  }
  // 阶段
  // 从测试代码可以知道这个也是一个控制顺序的属性,值越小,执行得就越在前面
  // 而且优先级低于 before
  let stage = 0;
  if (typeof item.stage === "number") stage = item.stage;
  let i = this.taps.length;
  // 遍历所有`tap`了的函数,然后根据 stage 和 before 进行重新排序.
  // 假设现在tap了 两个钩子  A B  `B` 的配置是  {name: 'B', before: 'A'}
  while (i > 0) {// i = 1, taps = [A]
    i--;// i = 0 首先-- 是因为要从最后一个开始
    const x = this.taps[i];// x = A
    this.taps[i + 1] = x;// i = 0, taps[1] = A  i+1 把当前元素往后移位,把位置让出来
    const xStage = x.stage || 0;// xStage = 0
    if (before) {// 如果有这个属性就会进入这个判断
      if (before.has(x.name)) {// 如果before 有x.name 就会把这个插件名称从before这个列表里删除,代表这个钩子位置已经在当前的钩子之前
        before.delete(x.name);
        continue;// 如果before还有元素,继续循环,执行上面的操作
      }
      if (before.size > 0) {
        continue;// 如果before还有元素,那就一直循环,直到第一位.
      }
    }
    if (xStage > stage) {// 如果stage比当前钩子的stage大,继续往前挪
      continue;
    }
    i++;
    break;
  }
  this.taps[i] = item;// 把挪出来的位置插入传进来的钩子
}

这其实就是一个排序算法, 根据before, stage 的值来排序,也就是说你可以这样tap进来一个插件

h1.tap({
  name: 'B',
  before: 'A'
  }, () => {
    console.log('i am B')
  })

发布订阅模式

发布订阅模式是一个在前后端都盛行的一个模式,前端的promise,事件,等等都基于发布订阅模式,其实tapable 也是一种发布订阅模式,上面的tap 只是订阅了钩子函数,我们还需要发布他,接下来我们谈谈h1.call(),跟紧了,这里面才是重点.

我们可以在初始化中看到this.call = this._call,那我们来看一下 this._call() 是个啥

Object.defineProperties(Hook.prototype, {
  _call: {
    value: createCompileDelegate("call", "sync"),
    configurable: true,
    writable: true
  },
  _promise: {
    value: createCompileDelegate("promise", "promise"),
    configurable: true,
    writable: true
  },
  _callAsync: {
    value: createCompileDelegate("callAsync", "async"),
    configurable: true,
    writable: true
  }
});

结果很明显,这个函数是由createCompileDelegate(),这个函数返回的,依赖于,函数的名字以及钩子的类型.

createCompileDelegate(name, type)

function createCompileDelegate(name, type) {
  return function lazyCompileHook(...args) {
    // 子类调用时,this默认绑定到子类
    // (不明白的可以了解js this指向,一个函数的this指向调用他的对象,没有就是全局,除非使用call apply bind 等改变指向)
    // 在我们的例子中,这个 this 是 SyncHook
    this[name] = this._createCall(type);
    // 用args 去调用Call
    return this[name](...args);
  };
}

在上面的注释上可以加到,他通过闭包保存了nametype的值,在我们这个例子中,这里就是this.call = this._createCall('sync');然后把我们外部调用call(666) 时 传入的参数给到他编译生成的方法中.

注意,在我们这个例子当中我在call的时候并没有传入参数.

这时候这个call方法的重点就在_createCall方法里面了.

_createCall()

_createCall(type) {

  // 传递一个整合了各个依赖条件的对象给子类的compile方法
  return this.compile({
    taps: this.taps,
    interceptors: this.interceptors,
    args: this._args,
    type: type
  });
}

从一开始,我们就在Hook.js上分析,我们来看看Hook上的compile

compile(options) {
  throw new Error("Abstract: should be overriden");
}

清晰明了,这个方法一定要子类复写,不然报错,上面的_createCompileDelegate的注释也写得很清楚,在当前的上下文中,this指向的是,子类,在我们这个例子中就是SyncHook

来看看SyncHook 的compile

compile(options) {
  // 现在options 是由Hook里面 传到这里的
  // options
  // {
  //  taps: this.taps, tap对象数组
  //  interceptors: this.interceptors, 拦截器数组
  //  args: this._args,
  //  type: type
  // }
  // 对应回教程中的编译出来的调用函数依赖于的那几项看看,是不是这些,钩子的个数,new SyncHook(['arg'])的参数个数,拦截器的个数,钩子的类型.
  factory.setup(this, options);

  return factory.create(options);
}

好吧 现在来看看setup, 咦? factory 怎么来的,原来

const factory = new SyncHookCodeFactory();

是new 出来的

现在来看看SyncHookCodeFactory 的父类 HookCodeFactory

constructor(config) {

  // 这个config作用暂定.因为我看了这个文件,没看到有引用的地方,
  // 应该是其他子类有引用到
  this.config = config;
  // 这两个不难懂, 往下看就知道了
  this.options = undefined;
  this._args = undefined;
}

现在可以来看一下setup了

setup(instance, options) {
  // 这里的instance 是syncHook 实例, 其实就是把tap进来的钩子数组给到钩子的_x属性里.
  instance._x = options.taps.map(t => t.fn);
}

OK, 到create了

这个create有点长, 看仔细了,我们现在分析同步的部分.

create(options) {
  // 初始化参数,保存options到本对象this.options,保存new Hook(["options"]) 传入的参数到 this._args
  this.init(options);
  let fn;
  // 动态构建钩子,这里是抽象层,分同步, 异步, promise
  switch (this.options.type) {
    // 先看同步
    case "sync":
      // 动态返回一个钩子函数
      fn = new Function(
        // 生成函数的参数,no before no after 返回参数字符串 xxx,xxx 在
        // 注意这里this.args返回的是一个字符串,
        // 在这个例子中是options
        this.args(),
        '"use strict";\n' +
          this.header() +
          this.content({
            onError: err => `throw ${err};\n`,
            onResult: result => `return ${result};\n`,
            onDone: () => "",
            rethrowIfPossible: true
          })
      );
      break;
    case "async":
      fn = new Function(
        this.args({
          after: "_callback"
        }),
        '"use strict";\n' +
          this.header() +
          // 这个 content 调用的是子类类的 content 函数,
          // 参数由子类传,实际返回的是 this.callTapsSeries() 返回的类容
          this.content({
            onError: err => `_callback(${err});\n`,
            onResult: result => `_callback(null, ${result});\n`,
            onDone: () => "_callback();\n"
          })
      );
      break;
    case "promise":
      let code = "";
      code += '"use strict";\n';
      code += "return new Promise((_resolve, _reject) => {\n";
      code += "var _sync = true;\n";
      code += this.header();
      code += this.content({
        onError: err => {
          let code = "";
          code += "if(_sync)\n";
          code += `_resolve(Promise.resolve().then(() => { throw ${err}; }));\n`;
          code += "else\n";
          code += `_reject(${err});\n`;
          return code;
        },
        onResult: result => `_resolve(${result});\n`,
        onDone: () => "_resolve();\n"
      });
      code += "_sync = false;\n";
      code += "});\n";
      fn = new Function(this.args(), code);
      break;
  }
  // 把刚才init赋的值初始化为undefined
  // this.options = undefined;
  // this._args = undefined;
  this.deinit();

  return fn;
}

到了这个方法,一切我们都一目了然了(看content的参数), 在我们的例子中他是通过动态的生成一个call方法,根据的条件有,钩子是否有context 属性(这个是根据header的代码才能知道), 钩子的个数, 钩子的类型,钩子的参数,钩子的拦截器个数.

注意,这上面有关于 fn这个变量的函数,返回的都是字符串,不是函数不是方法,是返回可以转化成代码执行的字符串,思维要转变过来.

现在我们来看看header()

header() {
  let code = "";
  // this.needContext() 判断taps[i] 是否 有context 属性, 任意一个tap有 都会返回 true
  if (this.needContext()) {
    // 如果有context 属性, 那_context这个变量就是一个空的对象.
    code += "var _context = {};\n";
  } else {
    // 否则 就是undefined
    code += "var _context;\n";
  }
  // 在setup()中 把所有tap对象的钩子 都给到了 instance ,这里的this 就是setup 中的instance _x 就是钩子对象数组
  code += "var _x = this._x;\n";
  // 如果有拦截器,在我们的例子中,就有一个拦截器
  if (this.options.interceptors.length > 0) {
    // 保存taps 数组到_taps变量, 保存拦截器数组 到变量_interceptors
    code += "var _taps = this.taps;\n";
    code += "var _interceptors = this.interceptors;\n";
  }
  // 如果没有拦截器, 这里也不会执行.一个拦截器只会生成一次call
  // 在我们的例子中,就有一个拦截器,就有call
  for (let i = 0; i < this.options.interceptors.length; i++) {
    const interceptor = this.options.interceptors[i];
    if (interceptor.call) {
      // getInterceptor 返回的 是字符串 是 `_interceptors[i]`
      // 后面的before 因为我们的拦截器没有context 所以返回的是undefined 所以后面没有跟一个空对象
      code += `${this.getInterceptor(i)}.call(${this.args({
        before: interceptor.context ? "_context" : undefined
      })});\n`;
    }
  }
  return code;
  // 注意 header 返回的不是代码,是可以转化成代码的字符串(这个时候并没有执行).
  /**
    * 此时call函数应该为:
    * "use strict";
    * function (options) {
    *   var _context;
    *   var _x = this._x;
    *   var _taps = this.taps;
    *   var _interterceptors = this.interceptors;
    * // 我们只有一个拦截器所以下面的只会生成一个
    *   _interceptors[0].call(options);
    *}
    */
}

现在到我们的this.content()了,仔细一看,this.content()方法并不在HookCodeFactory上,很明显这个content是由子类来实现的,往回看看这个create是由谁调用的?没错,是SuncHookCodeFactory的石料理,我们来看看SyncHook.js上的SyncHookCodeFactory实现的content

在看这个content实现之前,先来回顾一下父类的create()给他传了什么参数.

this.content({
  onError: err => `throw ${err};\n`,
  onResult: result => `return ${result};\n`,
  onDone: () => "",
  rethrowIfPossible: true
})

注意了,这上面不是抛出错误,不是返回值. 这里面的回调执行了以后返回的是一个字符串,不要搞混了代码与可以转化成代码的字符串.

content({ onError, onResult, onDone, rethrowIfPossible }) {
  return this.callTapsSeries({
    // 可以在这改变onError 但是这里的 i 并没有用到,这是什么操作...
    // 注意这里并没有传入onResult
    onError: (i, err) => onError(err),
    onDone,
    // 这个默认为true
    rethrowIfPossible
  });
}

这个函数返回什么取决于this.callTapSeries(), 那接下来我们来看看这个函数(这层层嵌套,其实也是有可斟酌的地方.看源码不仅要看实现,代码的组织也是很重要的编码能力)

刚才函数的头部已经出来了,头部做了初始化的操作,与生成执行拦截器代码.content很明显,要开始生成执行我们的tap对象的代码了(如果不然,我们的tap进来的函数在哪里执行呢? 滑稽:).

callTapsSeries({ onError, onResult, onDone, rethrowIfPossible }) {
  // 如果 taps 钩子处理完毕,执行onDone,或者一个tap都没有 onDone() 返回的是一个字符串.看上面的回顾就知道了.
  if (this.options.taps.length === 0) return onDone();
  // 如果由异步钩子,把第一个异步钩子的下标,如果没有这个返回的是-1
  const firstAsync = this.options.taps.findIndex(t => t.type !== "sync");
  // 定义一个函数 接受一个 number 类型的参数, i 应该是taps的index
  // 从这个函数的命名来看,这个函数应该会递归的执行
  // 我们先开最后的return语句,发现第一个传进来的参数是0
  const next = i => {
    // 如果 大于等于钩子函数数组长度, 返回并执行onDone回调,就是tap对象都处理完了
    // 跳出递归的条件
    if (i >= this.options.taps.length) {
      return onDone();
    }
    // 这个方法就是递归的关键,看见没,逐渐往上遍历
    // 注意这里只是定义了方法,并没有执行
    const done = () => next(i + 1);
    // 传入一个值 如果是false 就执行onDone true 返回一个 ""
    // 字面意思,是否跳过done 应该是增加一个跳出递归的条件
    const doneBreak = skipDone => {
      if (skipDone) return "";
      return onDone();
    };
    // 这里就是处理单个taps对象的关键,传入一个下标,和一系列回调.
    return this.callTap(i, {
      // 调用的onError 是 (i, err) => onError(err) , 后面这个onError(err)是 () => `throw ${err}`
      // 目前 i done doneBreak 都没有用到
      onError: error => onError(i, error, done, doneBreak),
      // 这里onResult 同步钩子的情况下在外部是没有传进来的,刚才也提到了
      // 这里onResult是 undefined
      onResult:
        onResult &&
        (result => {
          return onResult(i, result, done, doneBreak);
        }),
      // 没有onResult 一定要有一个onDone 所以这里就是一个默认的完成回调
      // 这里的done 执行的是next(i+1), 也就是迭代的处理完所有的taps
      onDone:
        !onResult &&
        (() => {return done();}),
      // rethrowIfPossible 默认是 true 也就是返回后面的
      // 因为没有异步函数 firstAsync = -1.
      // 所以返回的是 -1 < 0,也就是true, 这个可以判断当前的是否是异步的tap对象
      //  这里挺妙的 如果是 false 那么当前的钩子类型就不是sync,可能是promise或者是async
      // 具体作用要看callTaps()如何使用这个.
      rethrowIfPossible:
        rethrowIfPossible && (firstAsync < 0 || i < firstAsync)
    });
  };
  
  return next(0);
}

参数搞明白了,现在,我们可以进入callTap() 了.

callTap挺长的,因为他也分了3种类型分别处理,像create()一样.

/** tapIndex 下标
  * onError:() => onError(i,err,done,skipdone) ,
  * onReslt: undefined
  * onDone: () => {return: done()} //开启递归的钥匙
  * rethrowIfPossible: false 说明当前的钩子不是sync的.
  */
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
  let code = "";
  // hasTapCached 是否有tap的缓存, 这个要看看他是怎么做的缓存了
  let hasTapCached = false;
  // 这里还是拦截器的用法,如果有就执行拦截器的tap函数
  for (let i = 0; i < this.options.interceptors.length; i++) {
    const interceptor = this.options.interceptors[i];
    if (interceptor.tap) {
      if (!hasTapCached) {
        // 这里getTap返回的是 _taps[0] _taps[1]... 的字符串
        // 这里生成的代码就是 `var _tap0 = _taps[0]`
        // 注意: _taps 变量我们在 header 那里已经生成了
        code += `var _tap${tapIndex} = ${this.getTap(tapIndex)};\n`;
        // 可以看到这个变量的作用就是,如果有多个拦截器.这里也只会执行一次.
        // 注意这句获取_taps 对象的下标用的是tapIndex,在一次循环中,这个tapIndex不会变
        // 就是说如果这里执行多次,就会生成多个重复代码,不稳定,也影响性能.
        // 但是你又要判断拦截器有没有tap才可以执行,或许有更好的写法
        // 如果你能想到,那么你就是webpack的贡献者了.不过这样写,似乎也没什么不好.
        hasTapCached = true;
      }
      // 这里很明显跟上面的getTap 一样 返回的都是字符串
      // 我就直接把这里的code 分析出来了,注意 这里还是在循坏中.
      // code += _interceptor[0].tap(_tap0);
      // 由于我们的拦截器没有context,所以没传_context进来.
      // 可以看到这里是调用拦截器的tap方法然后传入tap0对象的地方
      code += `${this.getInterceptor(i)}.tap(${
        interceptor.context ? "_context, " : ""
      }_tap${tapIndex});\n`;
    }
  }
  // 跑出了循坏
  // 这里的getTapFn 返回的也是字符串 `_x[0]`
  // callTap用到的这些全部在header() 那里生成了,忘记的回头看一下.
  // 这里的code就是: var _fn0 = _x[0]
  code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;
  const tap = this.options.taps[tapIndex];
  // 开始处理tap 对象
  switch (tap.type) {
    case "sync":
      // 全是同步的时候, 这里不执行, 如果有异步函数,那么恭喜,有可能会报错.所以他加了个 try...catch
      if (!rethrowIfPossible) {
        code += `var _hasError${tapIndex} = false;\n`;
        code += "try {\n";
      }
      // 前面分析了 同步的时候 onResult 是 undefined
      // 我们也分析一下如果走这里会怎样
      // var _result0 = _fn0(option)
      // 可以看到是调用tap 进来的钩子并且接收参数
      if (onResult) {
        code += `var _result${tapIndex} = _fn${tapIndex}(${this.args({
          before: tap.context ? "_context" : undefined
        })});\n`;
      } else {
        // 所以会走这里
        // _fn0(options) 额... 我日 有就接受一下结果
        code += `_fn${tapIndex}(${this.args({
          before: tap.context ? "_context" : undefined
        })});\n`;
      }
      // 把 catch 补上,在这个例子中没有
      if (!rethrowIfPossible) {
        code += "} catch(_err) {\n";
        code += `_hasError${tapIndex} = true;\n`;
        code += onError("_err");
        code += "}\n";
        code += `if(!_hasError${tapIndex}) {\n`;
      }
      // 有onResult 就把结果给传递出去. 目前没有
      if (onResult) {
        code += onResult(`_result${tapIndex}`);
      }
      // 有onDone() 就调用他开始递归,还记得上面的next(i+1) 吗?
      if (onDone) {
        code += onDone();
      }
      // 这里是不上上面的if的大括号,在这个例子中没有,所以这里也不执行
      if (!rethrowIfPossible) {
        code += "}\n";
      }
      // 同步情况下, 这里最终的代码就是
      // var _tap0 = _taps[0];
      // _interceptors[0].tap(_tap0);
      // var _fn0 = _x[0];
      // _fn0(options);
      // 可以看到,这里会递归下去
      // 因为我们tap了4个钩子
      // 所以这里会从复4次
      // 最终长这样
      // var _tap0 = _taps[0];
      // _interceptors[0].tap(_tap0);
      // var _fn0 = _x[0];
      // _fn0(options);
      // var _tap1 = _taps[1];
      // _interceptors[1].tap(_tap1);
      // var _fn1 = _x[1];
      // _fn1(options);
      // ......
      break;
    case "async":
      let cbCode = "";
      if (onResult) cbCode += `(_err${tapIndex}, _result${tapIndex}) => {\n`;
      else cbCode += `_err${tapIndex} => {\n`;
      cbCode += `if(_err${tapIndex}) {\n`;
      cbCode += onError(`_err${tapIndex}`);
      cbCode += "} else {\n";
      if (onResult) {
        cbCode += onResult(`_result${tapIndex}`);
      }
      if (onDone) {
        cbCode += onDone();
      }
      cbCode += "}\n";
      cbCode += "}";
      code += `_fn${tapIndex}(${this.args({
        before: tap.context ? "_context" : undefined,
        after: cbCode
      })});\n`;
      break;
    case "promise":
      code += `var _hasResult${tapIndex} = false;\n`;
      code += `var _promise${tapIndex} = _fn${tapIndex}(${this.args({
        before: tap.context ? "_context" : undefined
      })});\n`;
      code += `if (!_promise${tapIndex} || !_promise${tapIndex}.then)\n`;
      code += `  throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise${tapIndex} + ')');\n`;
      code += `_promise${tapIndex}.then(_result${tapIndex} => {\n`;
      code += `_hasResult${tapIndex} = true;\n`;
      if (onResult) {
        code += onResult(`_result${tapIndex}`);
      }
      if (onDone) {
        code += onDone();
      }
      code += `}, _err${tapIndex} => {\n`;
      code += `if(_hasResult${tapIndex}) throw _err${tapIndex};\n`;
      code += onError(`_err${tapIndex}`);
      code += "});\n";
      break;
  }
  return code;
}

好了, 到了这里 我们可以把compile 出来的call 方法输出出来了

"use strict";
function (options) {
  var _context;
  var _x = this._x;
  var _taps = this.taps;
  var _interterceptors = this.interceptors;
// 我们只有一个拦截器所以下面的只会生成一个
  _interceptors[0].call(options);

  var _tap0 = _taps[0];
  _interceptors[0].tap(_tap0);
  var _fn0 = _x[0];
  _fn0(options);
  var _tap1 = _taps[1];
  _interceptors[1].tap(_tap1);
  var _fn1 = _x[1];
  _fn1(options);
  var _tap2 = _taps[2];
  _interceptors[2].tap(_tap2);
  var _fn2 = _x[2];
  _fn2(options);
  var _tap3 = _taps[3];
  _interceptors[3].tap(_tap3);
  var _fn3 = _x[3];
  _fn3(options);
}

到了这里可以知道,我们的例子中h1.call()其实调用的就是这个方法.到此我们可以说是知道了这个库的百分之80了.

不知道大家有没有发现,这个生成的函数的参数列表是从哪里来的呢?往回翻到create()方法里面调用的this.args()你就会看见,没错就是this._args. 这个东西在哪里初始化呢? 翻一下就知道,这是在Hook.js这个类里面初始化的,也就是说你h1 = new xxxHook(['options']) 的时候传入的数组有几个值,那么你h1.call({name: 'haha'}) 就能传几个值.看教程的时候他说,这里传入的是一个参数名字的字符串列表,那时候我就纳闷,什么鬼,我传入的不是值吗,怎么就变成了参数名称,现在完全掌握....

好了,最简单的SyncHook 已经搞掂,但是一看tapable内部核心使用的钩子却不是他,而是SyncBailHook,在教程中我们已经知道,bail是只要有一个钩子执行完了,并且返回一个值,那么其他的钩子就不执行.我们来看看他是怎么实现的.

从刚才我们弄明白的synchook,我们知道了他的套路,其实生成的函数的header()都是一样的,这次我们直接来看看bailhook实现的content()方法

content({ onError, onResult, onDone, rethrowIfPossible }) {
  return this.callTapsSeries({
    onError: (i, err) => onError(err),
  // 看回callTapsSeries 就知道这里传入的next 是 done
    onResult: (i, result, next) =>
      `if(${result} !== undefined) {\n${onResult(
        result
      )};\n} else {\n${next()}}\n`,
    onDone,
    rethrowIfPossible
  });
}

看出来了哪里不一样吗? 是的bailhookcallTapsSeries传了onResult属性,我们来看看他这个onResult是啥黑科技

父类传的onResult默认是 (result) => 'return ${result}',那么他这里返回的就是:


// 下面返回的是字符串,
if (xxx !== undefined) {
  // 这里说明,只要有返回值(因为不返回默认是undefined),就会立即return;
  return result;
} else {
  // next(); 这里返回的是一个字符串(因为要生成字符串代码)
  // 我在上面的注释中提到了 next 是 done 就是那个开启递归的门
  // 所以如果tap 一直没返回值, 这里就会一直 if...else.. 的嵌套下去
  
}

回头想想,我们刚刚是不是分析了capTap(),如果我们传了onResult 会怎样? 如果你还记得就知道,如果有传了onResult这个回调,他就会接收这个返回值.并且会调用这个回调把result传出去.

而且还要注意的是,onDonecallTap()的时候是处理过的,我在贴出来一次.

onDone:!onResult && (() => {return done();})

也就是说如果我传了onResult 那么这个onDone就是一个false.

所以递归的门现在从synconDone,变到syncBailonResult

好,现在带着这些变化去看this.capTap(),你就能推出现在这个 call 函数会变成这样.

"use strict";
function (options) {
  var _context;
  var _x = this._x;
  var _taps = this.taps;
  var _interterceptors = this.interceptors;
// 我们只有一个拦截器所以下面的只会生成一个
  _interceptors[0].call(options);

  var _tap0 = _taps[0];
  _interceptors[0].tap(_tap0);
  var _fn0 = _x[0];
  var _result0 = _fn0(options);

  if (_result0 !== undefined) {
    // 这里说明,只要有返回值(因为不返回默认是undefined),就会立即return;
    return _result0
  } else {
    var _tap1 = _taps[1];
    _interceptors[1].tap(_tap1);
    var _fn1 = _x[1];
    var _result1 = _fn1(options);
    if (_result1 !== undefined) {
      return _result1
    } else {
      var _tap2 = _taps[2];
      _interceptors[2].tap(_tap2);
      var _fn2 = _x[2];
      var _result2 = _fn2(options);
      if (_result2 !== undefined) {
        return _result2
      } else {
        var _tap3 = _taps[3];
        _interceptors[3].tap(_tap3);
        var _fn3 = _x[3];
        _fn3(options);
      }
    }
  }

到如今,tapable库 已经删除了 tapable.js文件(可能做了一些整合,更细分了),只留下了钩子文件.但不影响功能,webpack 里的compilecompilation 等一众重要插件,都是基于 tapable库中的这些钩子.

现在我们require('tapable')得到的对象是这样的:

{
    SyncHook: function(...){},
    SyncBailHook: function(...){},
    ...
}

到此,关于tapable的大部分我都解剖了一遍,还有其他类型的hook 如果你们愿意,相信你们去研究一下,也能够游刃有余.

那个,写得有些随性,可能会让你们觉得模糊,但是...我真尽力了,这篇改了几遍,历时一个星期...,不懂就在那个评论区问我.我看到会回复的.共勉.

后记:
本来以为会很难,但是越往下深入的时候发现,大神之所以成为大神,不是他的代码写得牛,是他的思维牛,没有看不懂的代码,只有跟不上的思路,要看懂他如何把call 函数组织出来不难,难的是,他居然能想到这样来生成函数,还可以考虑到,拦截器钩子,和context 属性,以及他的 onResultonDone 回调的判断,架构的设计,等等,一步接一步.先膜拜吧...

路漫漫其修远兮, 吾将上下而求索.

查看原文

赞 15 收藏 6 评论 6

SAMPAN 赞了文章 · 2019-11-27

vue自定义指令--directive

Vue中内置了很多的指令,如v-model、v-show、v-html等,但是有时候这些指令并不能满足我们,或者说我们想为元素附加一些特别的功能,这时候,我们就需要用到vue中一个很强大的功能了—自定义指令。

在开始之前,我们需要明确一点,自定义指令解决的问题或者说使用场景是对普通 DOM 元素进行底层操作,所以我们不能盲目的胡乱的使用自定义指令。

如何声明自定义指令?

就像vue中有全局组件和局部组件一样,他也分全局自定义指令和局部指令。

let Opt = {
    bind:function(el,binding,vnode){ },
    inserted:function(el,binding,vnode){ },
    update:function(el,binding,vnode){ },
    componentUpdated:function(el,binding,vnode){ },
    unbind:function(el,binding,vnode){ },
}

对于全局自定义指令的创建,我们需要使用 Vue.directive接口

Vue.directive('demo', Opt)

对于局部组件,我们需要在组件的钩子函数directives中进行声明

Directives: {
    Demo:     Opt
}

Vue中的指令可以简写,上面Opt是一个对象,包含了5个钩子函数,我们可以根据需要只写其中几个函数。如果你想在 bind 和 update 时触发相同行为,而不关心其它的钩子,那么你可以将Opt改为一个函数。

let Opt = function(el,binding,vnode){ }

如何使用自定义指令?

对于自定义指令的使用是非常简单的,如果你对vue有一定了解的话。

我们可以像v-text=”’test’”一样,把我们需要传递的值放在‘=’号后面传递过去。

我们可以像v-on:click=”handClick” 一样,为指令传递参数’click’。

我们可以像v-on:click.stop=”handClick” 一样,为指令添加一个修饰符。

我们也可以像v-once一样,什么都不传递。

每个指令,他的底层封装肯定都不一样,所以我们应该先了解他的功能和用法,再去使用它。

自定义指令的 钩子函数

上面我们也介绍了,自定义指令一共有5个钩子函数,他们分别是:bind、inserted、update、componentUpdate和unbind。

对于这几个钩子函数,了解的可以自行跳过,不了解的我也不介绍,自己去官网看,没有比官网上说的更详细的了:钩子函数

项目中的bug

在项目中,我们自定义一个全局指令my-click

Vue.directive('my-click',{
    bind:function(el, binding, vnode, oldVnode){
        el.addEventListener('click',function(){
            console.log(el, binding.value)
        })
    }
})

同时,有一个数组arr:[1,2,3,4,5,6],我们遍历数组,生成dom元素,并为元素绑定指令:

<ul>
    <li v-for="(item,index) in arr" :key="index" v-my-click="item">{{item}}</li>
</ul>

click

可以看到,当我们点击元素的时候,成功打印了元素,以及传递过去的数据。

可是,当我们把最后一个元素动态的改为8之后(6 --> 8),点击元素,元素是对的,可是打印的数据却仍然是6.

click

或者,当我们删除了第一个元素之后,点击元素

click

这是为什么呢????带着这个疑问,我去看了看源码。在进行下面的源码分析之前,先来说结论:

组件进行初始化的时候,也就是第一次运行指令的时候,会执行bind钩子函数,我们所传入的参数(binding)都进入到了这里,并形成了一个闭包。

当我们进行数据更新的时候,vue虚拟dom不会销毁这个组件(如果说删除某个数据,会从后往前销毁组件,前面的总是最后销毁),而是进行更新(根据数据改变),如果指令有update钩子会运行这个钩子函数,但是对于元素在bind中绑定的事件,在update中没有处理的话,他不会消失(依然引用初始化时形成的闭包中的数据),所以当我们更改数据再次点击元素后,看到的数据还是原数据。

源码分析

函数执行顺序:createElm/initComponent/patchVnode --> invokeCreateHooks (cbs.create) --> updateDirectives --> _update

在createElm方法和initComponent方法和更新节点patchVnode时会调用invokeCreateHooks方法,它会去遍历cbs.create中钩子函数进行执行,cbs.create中的钩子函数如下图所示共8个。我们所需要看的就是updateDirectives这个函数,这个函数会继续调用_update函数,vue中的指令操作就都在这个_update函数中了。

_update

下面我们就来详细看下这个_update函数。

function _update(oldVnode, vnode) {
    //判断旧节点是不是空节点,是的话表示新建/初始化组件
    var isCreate = oldVnode === emptyNode;
    //判断新节点是不是空节点,是的话表示销毁组件
    var isDestroy = vnode === emptyNode;
    //获取旧节点上的所有自定义指令
    var oldDirs = normalizeDirectives$1(oldVnode.data.directives, oldVnode.context);
    //获取新节点上的所有自定义指令
    var newDirs = normalizeDirectives$1(vnode.data.directives, vnode.context);

    //保存inserted钩子函数
    var dirsWithInsert = [];
    //保存componentUpdated钩子函数
    var dirsWithPostpatch = [];

    var key, oldDir, dir;
    
    //这里先说下callHook$1函数的作用
    //callHook$1有五个参数,第一个参数是指令对象,第二个参数是钩子函数名称,第三个参数新节点,
    //第四个参数是旧节点,第五个参数是是否为注销组件,默认为undefined,只在组件注销时使用
    //在这个函数里,会根据我们传递的钩子函数名称,运行我们自定义组件时,所声明的钩子函数,
    
    //遍历所有新节点上的自定义指令
    for(key in newDirs) {
        oldDir = oldDirs[key];
        dir = newDirs[key];
        //如果旧节点中没有对应的指令,一般都是初始化的时候运行
        if(!oldDir) {
            //对该节点执行指令的bind钩子函数
            callHook$1(dir, 'bind', vnode, oldVnode);
            //dir.def是我们所定义的指令的五个钩子函数的集合
            //如果我们的指令中存在inserted钩子函数
            if(dir.def && dir.def.inserted) {
                //把该指令存入dirsWithInsert中
                dirsWithInsert.push(dir);
            }
        } else { 
            //如果旧节点中有对应的指令,一般都是组件更新的时候运行
            //那么这里进行更新操作,运行update钩子(如果有的话)
            //将旧值保存下来,供其他地方使用(仅在 update 和 componentUpdated 钩子中可用)
            dir.oldValue = oldDir.value;
            //对该节点执行指令的update钩子函数
            callHook$1(dir, 'update', vnode, oldVnode);
            //dir.def是我们所定义的指令的五个钩子函数的集合
            //如果我们的指令中存在componentUpdated钩子函数
            if(dir.def && dir.def.componentUpdated) {
                //把该指令存入dirsWithPostpatch中
                dirsWithPostpatch.push(dir);
            }
        }
    }
    
    //我们先来简单讲下mergeVNodeHook的作用
    //mergeVNodeHook有三个参数,第一个参数是vnode节点,第二个参数是key值,第三个参数是回函数
    //mergeVNodeHook会先用一个函数wrappedHook重新封装回调,在这个函数里运行回调函数
    //如果该节点没有这个key属性,会新增一个key属性,值为一个数组,数组中包含上面说的函数wrappedHook
    //如果该节点有这个key属性,会把函数wrappedHook追加到数组中
    
    //如果dirsWithInsert的长度不为0,也就是在初始化的时候,且至少有一个指令中有inserted钩子函数
    if(dirsWithInsert.length) {
        //封装回调函数
        var callInsert = function() {
            //遍历所有指令的inserted钩子
            for(var i = 0; i < dirsWithInsert.length; i++) {
                //对节点执行指令的inserted钩子函数
                callHook$1(dirsWithInsert[i], 'inserted', vnode, oldVnode);
            }
        };
        if(isCreate) {
            //如果是新建/初始化组件,使用mergeVNodeHook绑定insert属性,等待后面调用。
            mergeVNodeHook(vnode, 'insert', callInsert);
        } else {
            //如果是更新组件,直接调用函数,遍历inserted钩子
            callInsert();
        }
    }
    
    //如果dirsWithPostpatch的长度不为0,也就是在组件更新的时候,且至少有一个指令中有componentUpdated钩子函数
    if(dirsWithPostpatch.length) {
        //使用mergeVNodeHook绑定postpatch属性,等待后面子组建全部更新完成调用。
        mergeVNodeHook(vnode, 'postpatch', function() {
            for(var i = 0; i < dirsWithPostpatch.length; i++) {
                //对节点执行指令的componentUpdated钩子函数
                callHook$1(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode);
            }
        });
    }
    
    //如果不是新建/初始化组件,也就是说是更新组件
    if(!isCreate) {
        //遍历旧节点中的指令
        for(key in oldDirs) {
            //如果新节点中没有这个指令(旧节点中有,新节点没有)
            if(!newDirs[key]) {
                //从旧节点中解绑,isDestroy表示组件是不是注销了
                //对旧节点执行指令的unbind钩子函数
                callHook$1(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy);
            }
        }
    }
}

callHook$1函数

function callHook$1(dir, hook, vnode, oldVnode, isDestroy) {
    var fn = dir.def && dir.def[hook];
    if(fn) {
        try {
            fn(vnode.elm, dir, vnode, oldVnode, isDestroy);
        } catch(e) {
            handleError(e, vnode.context, ("directive " + (dir.name) + " " + hook + " hook"));
        }
    }
}

解决

看过了源码,我们再回到上面的bug,我们应该如何去解决呢?

1、事件解绑,重新绑定

我们在bind钩子中绑定了事件,当数据更新后,会运行update钩子,所以我们可以在update中先解绑再重新进行绑定。因为bind和update中的内容差不多,所以我们可以把bind和update合并为同一个函数,在用自定义指令的简写方法写成下面的代码:

Vue.directive('my-click', function(el, binding, vnode, oldVnode){
    //点击事件的回调挂在在元素myClick属性上
    el.myClick && el.removeEventListener('click', el.myClick);
    el.addEventListener('click', el.myClick = function(){
        console.log(el, binding.value)
    })
})

click

可以看到,数据已经变成我们想要的数据了。

2、把binding挂在到元素上,更新数据后更新binding

我们已经知道了,造成问题的根本原因是初始化运行bind钩子的时候为元素绑定事件,事件内获取的数据是初始化的时候传递过来的数据,因为形成了闭包,那么我们不使用能引起闭包的数据,把数据存到某一个地方,然后去更新这个数据。

Vue.directive('my-click',{
    bind: function(el, binding, vnode, oldVnode){
        el.binding = binding
        el.addEventListener('click', function(){
            var binding = this.binding
            console.log(this, binding.value)
        })
    },
    update: function(el, binding, vnode, oldVnode){
        el.binding = binding
    }
})

这样也能达到我们想要的效果。

3、更新父元素

如果我们为父元素ul绑定一个变化的key值,这样,当数据变更的时候就会更新父元素,从而重新创建子元素,达到重新绑定指令的效果。

<ul :key="Date.now()">
    <li v-for="(item,index) in arr" :key="index" v-my-click="item">{{item}}</li>
</ul>

这样也能达到我们想要的效果。

查看原文

赞 33 收藏 22 评论 0

SAMPAN 收藏了文章 · 2019-11-18

你不知道的Virtual DOM(一):Virtual Dom介绍

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

目前最流行的两大前端框架,React和Vue,都不约而同的借助Virtual DOM技术提高页面的渲染效率。那么,什么是Virtual DOM?它是通过什么方式去提升页面渲染效率的呢?本系列文章会详细讲解Virtual DOM的创建过程,并实现一个简单的Diff算法来更新页面。本文的内容脱离于任何的前端框架,只讲最纯粹的Virtual DOM。敲单词太累了,下文Virtual DOM一律用VD表示。

这是VD系列文章的开篇,以下是本系列其它文章的传送门:
你不知道的Virtual DOM(一):Virtual Dom介绍
你不知道的Virtual DOM(二):Virtual Dom的更新
你不知道的Virtual DOM(三):Virtual Dom更新优化
你不知道的Virtual DOM(四):key的作用
你不知道的Virtual DOM(五):自定义组件
你不知道的Virtual DOM(六):事件处理&异步更新

二、VD是什么

本质上来说,VD只是一个简单的JS对象,并且最少包含tag、props和children三个属性。不同的框架对这三个属性的命名会有点差别,但表达的意思是一致的。它们分别是标签名(tag)、属性(props)和子元素对象(children)。下面是一个典型的VD对象例子:

{
    tag: "div",
    props: {},
    children: [
        "Hello World", 
        {
            tag: "ul",
            props: {},
            children: [{
                tag: "li",
                props: {
                    id: 1,
                    class: "li-1"
                },
                children: ["第", 1]
            }]
        }
    ]
}

VD跟dom对象有一一对应的关系,上面的VD是由以下的HTML生成的

<div>
    Hello World
    <ul>
        <li id="1" class="li-1">
            第1
        </li>
    </ul>
</div>

一个dom对象,比如li,由tag(li), props({id: 1, class: "li-1"})children(["第", 1])三个属性来描述。

三、为什么需要VD

VD 最大的特点是将页面的状态抽象为 JS 对象的形式,配合不同的渲染工具,使跨平台渲染成为可能。如 React 就借助 VD 实现了服务端渲染、浏览器渲染和移动端渲染等功能。

此外,在进行页面更新的时候,借助VD,DOM 元素的改变可以在内存中进行比较,再结合框架的事务机制将多次比较的结果合并后一次性更新到页面,从而有效地减少页面渲染的次数,提高渲染效率。我们先来看下页面的更新一般会经过几个阶段。
clipboard.png

从上面的例子中,可以看出页面的呈现会分以下3个阶段:

  • JS计算
  • 生成渲染树
  • 绘制页面

这个例子里面,JS计算用了691毫秒,生成渲染树578毫秒,绘制73毫秒。如果能有效的减少生成渲染树和绘制所花的时间,更新页面的效率也会随之提高。
通过VD的比较,我们可以将多个操作合并成一个批量的操作,从而减少dom重排的次数,进而缩短了生成渲染树和绘制所花的时间。至于如何基于VD更有效率的更新dom,是一个很有趣的话题,日后有机会将另写一篇文章介绍。

四、如何实现VD与真实DOM的映射

我们先从如何生成VD说起。借助JSX编译器,可以将文件中的HTML转化成函数的形式,然后再利用这个函数生成VD。看下面这个例子:

function render() {
    return (
        <div>
            Hello World
            <ul>
                <li id="1" class="li-1">
                    第1
                </li>
            </ul>
        </div>
    );
}

这个函数经过JSX编译后,会输出下面的内容:

function render() {
    return h(
        'div',
        null,
        'Hello World',
        h(
            'ul',
            null,
            h(
                'li',
                { id: '1', 'class': 'li-1' },
                '\u7B2C1'
            )
        )
    );
}

这里的h是一个函数,可以起任意的名字。这个名字通过babel进行配置:

// .babelrc文件
{
  "plugins": [
    ["transform-react-jsx", {
      "pragma": "h"    // 这里可配置任意的名称
    }]
  ]
}

接下来,我们只需要定义h函数,就能构造出VD

function flatten(arr) {
    return [].concat.apply([], arr);
}

function h(tag, props, ...children) {
    return {
        tag, 
        props: props || {}, 
        children: flatten(children) || []
    };
}

h函数会传入三个或以上的参数,前两个参数一个是标签名,一个是属性对象,从第三个参数开始的其它参数都是children。children元素有可能是数组的形式,需要将数组解构一层。比如:

function render() {
    return (
        <ul>
            <li>0</li>
            {
                [1, 2, 3].map( i => (
                    <li>{i}</li>
                ))
            }
        </ul>
    );
}

// JSX编译后
function render() {
    return h(
        'ul',
        null,
        h(
            'li',
            null,
            '0'
        ),
        /*
         * 需要将下面这个数组解构出来再放到children数组中
         */
        [1, 2, 3].map(i => h(
            'li',
            null,
            i
        ))
    );
}

继续之前的例子。执行h函数后,最终会得到如下的VD对象:

{
    tag: "div",
    props: {},
    children: [
        "Hello World", 
        {
            tag: "ul",
            props: {},
            children: [{
                tag: "li",
                props: {
                    id: 1,
                    class: "li-1"
                },
                children: ["第", 1]
            }]
        }
    ]
}

下一步,通过遍历VD对象,生成真实的dom

// 创建dom元素
function createElement(vdom) {
    // 如果vdom是字符串或者数字类型,则创建文本节点,比如“Hello World”
    if (typeof vdom === 'string' || typeof vdom === 'number') {
        return doc.createTextNode(vdom);
    }

    const {tag, props, children} = vdom;

    // 1. 创建元素
    const element = doc.createElement(tag);

    // 2. 属性赋值
    setProps(element, props);

    // 3. 创建子元素
    // appendChild在执行的时候,会检查当前的this是不是dom对象,因此要bind一下
    children.map(createElement)
            .forEach(element.appendChild.bind(element));

    return element;
}

// 属性赋值
function setProps(element, props) {
    for (let key in props) {
        element.setAttribute(key, props[key]);
    }
}

createElement函数执行完后,dom元素就创建完并展示到页面上了(页面比较丑,不要介意...)。

clipboard.png

五、总结

本文介绍了VD的基本概念,并讲解了如何利用JSX编译HTML标签,然后生成VD,进而创建真实dom的过程。下一篇文章将会实现一个简单的VD Diff算法,找出2个VD的差异并将更新的元素映射到dom中去:你不知道的Virtual DOM(二):Virtual Dom的更新

P.S.: 想看完整代码见这里,如果有必要建一个仓库的话请留言给我:代码

查看原文

SAMPAN 回答了问题 · 2017-11-24

微信 服务器配置(已启用)打开网页经常出现502

这个是服务自身问题吧,不如看下服务日志!

关注 2 回答 1

SAMPAN 回答了问题 · 2017-11-21

解决socket.io如何作为判断是否监听

房间内的用户应该在server进行一份维护。
每当用户进入时,若用户不存,则为新加入用户,可进行通知;否则,则不用通知。
另外,用户连接应该有一个连接超时断开的机制。

关注 2 回答 2

SAMPAN 回答了问题 · 2017-11-20

解决JavaScript数组对象去重

function unique() {
            var res = [];
            for (var i = 0; i < arr.length; i++) {
                var len = arr.length;
                while(len > i) {
                    if (arr[i].name == arr[len].name) {
                        res.push(arr[len]);
                    }
                    len--;
                }
            }
            return res;
        }

关注 7 回答 6

认证与成就

  • 获得 34 次点赞
  • 获得 32 枚徽章 获得 4 枚金徽章, 获得 10 枚银徽章, 获得 18 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-04-15
个人主页被 1.6k 人浏览