从babel的编译结果学习ES6的扩展运算符

1,619 阅读4分钟

1、前言

ES6作为ES的一个划时代的版本,使得JS这门语言在编写大型且健壮的应用程序更进一步。ES6主要增加了很多语法糖,这些语法糖为我们的开发提效,减少实际开发中的bug功不可没。但是,这些语法糖是如何化腐朽为神奇的,其底层到底是怎么工作的呢,我觉得对于一个有追求的前端程序员来说还是有必要搞懂JS引擎到底为我们做了多少工作,做到,知其然,也知其所以然。可以训练我们在实际的项目开发中快速定位问题的能力。本文主要阐述...扩展运算符)在实际开发中的应用场景,以及分析对应场景的编译结果,阐述扩展运算符的运行原理。

2、常见用法及编译结果分析

2.1 对象属性处理

取出某些键,将剩余的键收集到一个对象中,在实际开发中,可能你不想处理某些键(有点儿类似lodashomit等操作)。或者说,需要对键值,需要分开处理的场景。

const obj = { a: 1, b: 2, c: 3 };
const { a, ...rest } = obj;
console.log(a, obj);

编译后的结果:

/**
 * 移除source对象中包含exclude的键(包含Symbol)
 * @param {Object} source 源对象
 * @param {Object} excluded 用来匹配需要删除的键的对象
 * @returns {Object}
 */
function _objectWithoutProperties(source, excluded) {
  if (source == null) return {};
  var target = _objectWithoutPropertiesLoose(source, excluded);
  var key, i;
  // 如果当前环境支持Symbol,Symbol类型的key也可以拷贝,但是对于不能枚举的Symbol属性则不拷贝
  if (Object.getOwnPropertySymbols) {
    var sourceSymbolKeys = Object.getOwnPropertySymbols(source);
    for (i = 0; i < sourceSymbolKeys.length; i++) {
      key = sourceSymbolKeys[i];
      if (excluded.indexOf(key) >= 0) continue;
      // 不处理不可枚举的Symbol
      if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue;
      target[key] = source[key];
    }
  }
  return target;
}
/**
 * 宽松的移除source对象中包含exclude的键
 * @param {Object} source 源对象
 * @param {Object} excluded 用来匹配需要删除的键的对象
 * @returns {Object}
 */
function _objectWithoutPropertiesLoose(source, excluded) {
  if (source == null) return {};
  var target = {};
  // 不对当前对象原型上的属性进行处理
  var sourceKeys = Object.keys(source);
  var key, i;
  for (i = 0; i < sourceKeys.length; i++) {
    key = sourceKeys[i];
    if (excluded.indexOf(key) >= 0) continue;
    target[key] = source[key];
  }
  return target;
}
var obj = {
  a: 1,
  b: 2,
  c: 3,
};
var a = obj.a,
  rest = _objectWithoutProperties(obj, ["a"]);
console.log(a, obj);

通过分析这个编译结果,我惊讶的发现,在对象做展开收集的时候,竟然可以能把Symbol也能处理到,学到了呀。

2.2 多个对象合并

这种场景,我个人认为是Object.assign在对象合并时的简化写法。

const obj1 = {
  a: 1,
  b: 2,
  c: 3,
};
const obj2 = {
  a: "xxx",
  d: "ddd",
  k: 2,
};
const newObj = {
  ...obj1,
  ...obj2,
};
console.log(newObj);

编译后:

"use strict";
/**
 * 获取一个对象上包含Symbol在内的所有key
 * @param {Object} object 目标对象
 * @param {boolean} enumerableOnly 在获取Symbol类型的key时,是否只获取可枚举的
 * @returns
 */
function ownKeys(object, enumerableOnly) {
  var keys = Object.keys(object);
  if (Object.getOwnPropertySymbols) {
    var symbols = Object.getOwnPropertySymbols(object);
    // 过滤掉Symbol的key中不可枚举的
    enumerableOnly &&
      (symbols = symbols.filter(function (sym) {
        return Object.getOwnPropertyDescriptor(object, sym).enumerable;
      })),
      keys.push.apply(keys, symbols);
  }
  return keys;
}
/**
 * 对象展开,将除了target以外的所有key全部合并到target中
 * @param {Object} target
 * @returns
 */
function _objectSpread(target) {
  // 从第一个参数开始,分别对后面的参数进行处理
  for (var i = 1; i < arguments.length; i++) {
    var source = null != arguments[i] ? arguments[i] : {};
    /* 这段是babel编译的结果,但是看起来不是那么直观,也于是决定对其进行改写 */
    // i % 2
    //   ? ownKeys(Object(source), !0).forEach(function (key) {
    //       _defineProperty(target, key, source[key]);
    //     })
    //   : Object.getOwnPropertyDescriptors
    //   ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source))
    //   : ownKeys(Object(source)).forEach(function (key) {
    //       Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
    //     });
    /* 为什么对 序号为偶数的key才去单独处理,没看懂这样的意义是什么? */
    if (i % 2) {
      ownKeys(Object(source), !0).forEach(function (key) {
        _defineProperty(target, key, source[key]);
      });
    } else {
      if (Object.getOwnPropertyDescriptors) {
        Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
      } else {
        ownKeys(Object(source)).forEach(function (key) {
          Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
        });
      }
    }
  }
  return target;
}
/**
 * 以兼容的方式定义对象的属性和值
 * @param {object} obj
 * @param {string} key
 * @param {any} value
 * @returns
 */
function _defineProperty(obj, key, value) {
  if (key in obj) {
    Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true });
  } else {
    obj[key] = value;
  }
  return obj;
}

var obj1 = {
  a: 1,
  b: 2,
  c: 3,
};
var obj2 = {
  a: "xxx",
  d: "ddd",
  k: 2,
};
var newObj = _objectSpread(_objectSpread({}, obj1), obj2);
console.log(newObj);

其中_objectSpread辅助函数完成的功能则和Object.assign相同,也证明了我之前的观点。因此,在实际开发中这个场景下的注意点就变成了对Object.assign的使用了,用...显然更简洁。但是上述代码有个地方没太看懂,为什么要对序号为偶数的key只获取可枚举的Symbol,出于性能考虑吗?目前还不得而知。

2.3 浅拷贝

const obj = {
  a: 1,
  b: {},
};
const copy = { ...obj };

编译后的代码和多个对象合并相似,此处不再赘述。

2.4 数组合并

const arr = [1, 2, 3];
const b = [3, ...arr];
console.log(b);

编译后:

var arr = [1, 2, 3];
var b = [3].concat(arr);
console.log(b);

数组合并利用的是concat,是真的简洁啊,也没有什么好说的。

2.5 简化函数参数传递

对于某些函数接受一堆参数,如:

Math.min(...values: number[]): number;

或者不想或者不能(严格模式,或者箭头函数场景)使用arguments获取参数。

Math.min(...arr);

function D(...args) {
  console.log(args);
}

function E(A, B, ...others) {
  console.log(A, B, others);
}

编译后:

"use strict";
function _toConsumableArray(arr) {
  return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread();
}
/**
 * 对不含[Symbol.iterator]接口调用时报错
 */
function _nonIterableSpread() {
  throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
}
/**
 * 将其它类型转化成数组
 * @param {any} o
 * @param {number} minLen
 * @returns
 */
function _unsupportedIterableToArray(o, minLen) {
  if (!o) return;
  if (typeof o === "string") return _arrayLikeToArray(o, minLen);
  var n = Object.prototype.toString.call(o).slice(8, -1);
  if (n === "Object" && o.constructor) n = o.constructor.name;
  if (n === "Map" || n === "Set") return Array.from(o);
  // 字节数组或者arguments对象
  if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen);
  /* 需要注意的是,这儿对于一个普通对象是没有任何操作的哟 */
}
/**
 * 把包含Symbol.iterator接口的对象转成数组
 * @param {{ [Symbol.iterator]: any }} iter
 * @returns
 */
function _iterableToArray(iter) {
  if ((typeof Symbol !== "undefined" && iter[Symbol.iterator] != null) || iter["@@iterator"] != null) {
    return Array.from(iter);
  }
}
/**
 * 将数组充满,比如这类数组 new Array(100),但是如果直接用forEach遍历,会跳过100个元素,经过这个操作这后会变成100个undefined,相当于填满了数组上的洞
 * @param {Array<any>} arr
 * @returns
 */
function _arrayWithoutHoles(arr) {
  if (Array.isArray(arr)) {
    return _arrayLikeToArray(arr);
  }
}
/**
 * 将类数组对象转成数组
 * @param {any} arr 类数组对象
 * @param {number} len 类数组对象的长度
 * @returns
 */
function _arrayLikeToArray(arr, len) {
  if (len == null || len > arr.length) len = arr.length;
  for (var i = 0, arr2 = new Array(len); i < len; i++) {
    arr2[i] = arr[i];
  }
  return arr2;
}
Math.min.apply(Math, _toConsumableArray(arr));
function D() {
  for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
    args[_key] = arguments[_key];
  }
  console.log(args);
}

function E(A, B) {
  for (var _len2 = arguments.length, others = new Array(_len2 > 2 ? _len2 - 2 : 0), _key2 = 2; _key2 < _len2; _key2++) {
    others[_key2 - 2] = arguments[_key2];
  }

  console.log(A, B, others);
}

2.6 类数组对象,Map,Set,String的处理。

const str = "23333";
const chars = [...str];
[...new Set([1, 1, 2])];
[...new Map()];
[...document.querySelectorAll("div")];
const func = function () {
  const args = [...arguments];
  console.log(args);
};

编译后,都调用上述的_toConsumableArray辅助函数,此处不再赘述。

3、总结

扩展运算符的用法主要分三类:

  • 1、对象处理: 主要是对于对象的属性进行操作,提取,合并等操作。对于采用扩展运算符进行浅克隆,底层有类似于Object.assign的逻辑,因此实际开发中这类场景可以多采用...用以简化代码。
  • 2、数组处理 主要是通过concat操作,并返回一个新数组。
  • 3、对含有Symbol.iterator对象转数组的处理。 主要是通过使用Array.from将其转化为数组,有兴趣的读者可以参看一下Array.frompolyfill,或者某些场景下使用ES5及以前的Array.prototype.slice,若不含有Symbol.iterator是不能使用扩展运算符进行展开的。这儿有一个易错点。 在React中,我们很有可能会看到这种代码
class MyButton extends Component {
    render() {
        const props = { ...this.props, name: '我自己封装的Button'};
        return <Button {...props} />
    }
}

这个扩展运算符对对象的props进行批量传递,是React生态自己的支持(相当于是人家多了一个插件去转码这个语法,而原生JS是不支持这种语法的)。各位读者需要注意一下区别。

另外:

interface Console {
    // 已省略无关代码
    log(...data: any[]): void;
}

看起来,好像跟我们的之前Math.min函数参数的定义形式差不多,那我们是否就可以console.log(...obj)了呢?

答案是不可以的,因为Object是没有定义Symbol.iterator的,如果你执意这样做,我们可以借用一下ArraySymbol.iterator,即:

const obj = { a: 1, b: 2, c: 3};
obj[Symbol.iterator] = Array.prototype[Symbol.iterator];
console.log(...obj);

同样,这类操作扩展运算符还是相对简洁的。

综上所述,在合理的场景下,我们可以尽量的多用扩展运算符以简化我们的代码(有些读者可能会说,babel在转化的过程中定义了这么多辅助函数,代码量会好多好多,这个不比担心,在生产环境下,我们都会用它的@babel/plugin-transform-runtime插件,将这些辅助函数全部抽离到一个包里面去,这样就不用担心代码的重复定义的问题了),这样可读性也更好了,后来的开发者维护起来也不用那么头疼了。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。