三、原始之地——现代网络

自 ECMAScript 2015 标准发布以来,JavaScript 语言的前景发生了很大的变化。 现在有许多新特性使 JavaScript 成为适合所有类型开发的一流语言。 现在使用这种语言变得容易多了,我们甚至可以看到其中的一些语法糖。

从 ECMAScript 2015 标准及以后,我们已经收到了类、模块、更多声明变量的方法、作用域的变化等等。 所有这些特性以及更多的特性将在本章的其余部分进行解释。 如果你不熟悉这门语言,或者你只是想了解一些不熟悉的特性,这是一个非常好的章节。 我们还将了解一些使用 DOM 查询的 web 旧部分,以及如何利用它们来替代我们目前可能使用的无关库,如 jQuery。

本章将涵盖以下主题:

  • 深入了解现代 JavaScript
  • 理解类和模块
  • 使用 DOM
  • 理解 Fetch API

技术要求

以下是本章的先决条件:

深入了解现代 JavaScript

正如引言中所述,语言在很多方面都变得更好了。 我们现在有了适当的作用域,更好地处理async操作,更多的集合类型,甚至还有元编程特性,如反射和代理。 所有这些特性导致语言更加复杂,但它们也导致更有效的解决问题。 我们将着眼于新标准的一些最佳项目,以及它们在我们的代码中可以用于什么。

接下来还需要注意的一点是,所显示的任何 JavaScript 代码都可以以以下方式运行:

  1. 通过点击键盘上的F12将其添加到开发控制台
  2. 利用开发人员控制台中可以在 Sources 选项卡上看到的代码片段,在左侧面板中有一个选项应该称为 snippets
  3. 编写添加了脚本元素的基础关卡index.html

让/const 和 block 作用域

在 ECMAScript 2015 之前,我们只使用var关键字定义变量。 关键字的生命周期是从函数声明到函数结束。 这可能导致相当多的问题。 下面的代码展示了使用var关键字可能遇到的问题之一:

var fun = function() {
    for(var i = 0; i < 10; i++) {
        state['this'] += 'what';
    }
    console.log('i', i);
}
fun();

控制台会打印出什么? 在大多数语言中,我们会猜测这是一个错误,或者它会打印null。 然而,JavaScript 的var关键字是函数作用域,所以变量i将是10。 这导致了许多错误的出现,因为意外地忘记将它声明为一个变量,甚至是可怕的switch语句错误(这些仍然发生在letconst)。 switch语句错误示例如下:

var x = 'a';
switch(x) {
    case 'a':
        y = 'z';
        break;
    case 'b':
        y = 'y';
        break;
    default:
        y = 'b';
}
console.log(y);

在前面的switch语句中,我们期望ynull,但是因为var不是块作用域的,所以它是字母z。 我们必须始终保持在变量的顶端,并确保我们没有使用在作用域之外声明的内容并更改它,或者确保我们重新声明变量以防止发生泄漏。

对于letconst,我们得到了块作用域。 这意味着花括号告诉我们变量应该存在多长时间。 这里有一个例子:

let x = 10;
let fun2 = function() {
    {
        let x = 20;
        console.log('inner scope', x);
    }
    console.log('outer scope', x);
    x += 10;
}
fun2();
console.log('this should be 20', x);

当我们查看变量x的打印输出时,可以看到我们首先在函数外将其声明为10。 在函数内部,我们用花括号创建了一个新的作用域,并将x重新声明为20。 在区块内,代码将打印出inner scope 20。 但是,在fun2里面的块外面,我们打印出x,它就是10let关键字跟随这个块作用域。 如果我们将变量声明为var,那么当我们第二次打印它时,它将保持为20。 最后,在x外加上10,就可以看出x就是20

除了获得块作用域外,const关键字还提供了一些不可变性。 如果我们正在处理的类型是值类型,则不能更改该值。 如果有引用类型,则可以更改引用内部的值,但不能更改引用本身。 这将带来一些不错的功能。

一种很棒的编码风格是尽可能多地使用const,只有当我们需要在基本级别上改变某些东西(如循环)时才使用let。 由于对象、数组或函数的值可以改变,我们可以将它们设置为const。 这样做的唯一缺点是它们不能被空掉,但它仍然在可能的性能增益之上增加了相当多的安全性,编译器可以利用知道值是不可变的这一点。

箭头功能

该语言的另一个值得注意的变化是增加了箭头函数。 有了这个,我们现在可以不用借助各种语言修改this了。 这方面的一个例子如下:

const create = function() {
    this.x = 10;
    console.log('this', this);
    const innerFun = function() {
        console.log('inner this', this);
    }
    const innerArrowFun = () => {
        console.log('inner arrow this', this);
    }
    innerFun();
    innerArrowFun();
}
const item = new create();

我们正在为一个新对象创建一个构造函数。 我们有两个内部函数,一个是基本函数调用,另一个是箭头函数。 当我们打印这个时,我们注意到基本函数打印窗口的作用域。 当输出内部箭头函数的作用域时,我们得到父函数的作用域。

我们可以用几种方法来解决这个基本的内部函数。 首先,我们可以在父函数中声明一个变量,并将其用于内部函数。 此外,当我们运行函数时,我们可以使用 call 或apply来实际运行函数。

然而,这两个都不是好主意,特别是当我们现在有箭头函数的时候。 需要记住的一个关键点是,箭头函数接受父函数的作用域,所以无论this指向父函数,我们现在都将在箭头函数中执行同样的操作。 现在,我们总是可以通过在箭头函数上使用apply来改变它,但最好只使用apply,这是出于部分应用的原因,而不是通过更改this关键字来调用函数。

集合类型

数组和对象是 JavaScript 开发人员很长一段时间以来使用的两种主要类型。 但是,我们现在有另外两种集合类型来帮助我们做一些我们以前用其他类型来做的事情。 这些是 set 和 map。 集合是指独特物品的无序集合。 这意味着,如果我们试图把一些东西放到已经存在的集合中,我们会注意到,我们只有一项。 我们可以像这样简单地用数组模拟集合:

const set = function(...items) {
   this._arr = [...items];
   this.add = function(item) {
       if( this._arr.includes(item) ) return false;
       this._arr.push(item);
       return true;
   }
   this.has = function(item) {
       return this._arr.includes(item);
   }
   this.values = function() {
       return this._arr;
   }
   this.clear = function() {
       this._arr = [];
   }
}

既然我们现在有了 set 系统,我们就可以使用那个 API 了。 我们还可以访问for of循环,因为 set 是一个可迭代项(如果我们将迭代器附加到 set 上,也可以使用 next 语法)。 当我们处理大型数据集时,集合还具有比数组更快的读取访问的优势。 下面的例子说明了这一点:

const data = new Array(10000000);
for(let i = 0; i < data.length; i++) {
    data[i] = i;
}
const setData = new Set();
for(let i = 0; i < data.length; i++) {
    setData.add(i);
}
data.includes(5000000);
setData.has(5000000);

虽然这个集合需要更长的时间来创建,但当涉及到寻找物品甚至抓取它们时,这个集合的执行速度将比数组快近 100 倍。 这主要是由于数组查找条目的方式。 由于数组是纯线性的,它必须遍历每个元素来检查,而集合则是简单的常数时间检查。

A set can be implemented in different ways depending on the engine. A set in the V8 engine is built utilizing hash dictionaries for the lookup. We will not go over the internals of this, but essentially, the lookup time is considered constant, or O(1), for computer science folk, whereas the array lookup time is linear, or O(n).

除此之外,我们还有地图。 我们可以把它们看作是对象,但它们有几个很好的属性:

  • 首先,我们可以使用任何值作为键,甚至是对象。 这对于添加我们不想直接绑定到对象的其他数据(会想到私有值)非常有用。
  • 除此之外,map 也是可迭代的,所以我们可以像使用 set 一样使用for of循环。
  • 最后,在大数据集的情况下,当键和值都是相同类型时,map 可以给我们带来比普通旧对象更好的性能。

下面的例子强调了许多地图通常比普通物体更好的地方,以及物体曾经被使用的地方:

const map = new Map();
for(let i = 0; i < 10000; i++) {
    map.set(`${i}item`, i);
}
map.forEach((val, key) => console.log(val));
map.size();
map.has('0item');
map.clear();

在这两项之上,我们还有它们的弱版本。 弱版本有一个主要的限制:值必须是对象。 一旦我们理解了WeakSetWeakMap的作用,这就说得通了。 它们存储对这些项的引用。 这意味着,虽然它们存储的项在周围,但我们可以执行这些接口提供的方法。 一旦垃圾收集器决定收集它们,这些引用将从弱版本中删除。 我们可能会想,我们为什么要使用这些?

对于一个WeakMap,有一些用例:

  • 首先,如果我们没有私有变量,我们可以利用WeakMap在对象上存储值,而不需要实际将属性附加到它们上。 现在,当对象最终被垃圾回收时,这个私有引用也会被回收。
  • 我们还可以利用弱映射将属性或数据附加到 DOM,而不必实际向 DOM 添加属性。 我们在不扰乱 DOM 的情况下获得了数据属性的所有好处。
  • 最后,如果我们希望将引用数据存储到旁边,但在数据消失时让它消失,这是另一个用例。

总而言之,当我们想要将某种数据绑定到该对象而不需要紧密耦合时,就会使用WeakMap。 我们将会看到如下内容:

const items = new WeakMap();
const container = document.getElementById('content');
for(let i = 0; i < 50000; i++) {
    const el = document.createElement('li');
    el.textContent = `we are element ${i}`;
    el.onclick = function(ev) {
        console.log(items.get(el));
    }
    items.set(el, i);
    container.appendChild(el);
}
const removeHalf = function() {
    const amount = Math.floor(container.children.length / 2);
    for(let i = 0; i < amount; i++) {
        container.removeChild(container.firstChild); 
    }
}

首先,我们创建一个WeakMap来存储我们所创建的 DOM 元素所需的数据。 接下来,我们获取无序列表,并在每个迭代中添加一个列表元素。 然后我们通过WeakMap将所指向的数字绑定到 DOM 元素上。 这样,onclick处理程序就可以获取项目,并返回我们存储在它上面的数据。

这样,我们就可以单击任何元素并取回数据。 这很酷,因为我们过去是直接在 DOM 中向 HTML 元素添加数据属性的。 现在我们可以用WeakMap。 但是,我们还得到了一个我们已经讨论过的好处。 如果我们在命令行中运行removeHalf函数并进行垃圾收集,我们可以查看WeakMap中有多少项。 如果我们这样做,并检查WeakMap中有多少元素,我们会注意到它存储的元素数量可以从 25,000 到我们开始时的 50,000 个元素。 原因如上所述; 一旦引用被垃圾回收,WeakMap将不再存储它。 它有一个弱引用。

The amount to be collected by the garbage collector is going to be up to the system that we are running. On some systems, the garbage collector may decide to not collect anything from the list. This all depends on how the V8 garbage collection has been set up for Chrome or Node.js.

我们可以很容易地看到,如果我们把WeakMap替换为常规的。 让我们继续做这个小的改变。 有了这个更改,请执行前面相同的步骤。 我们会注意到地图里仍然有 5 万件物品。 这就是我们说某物有强引用或弱引用的意思。 弱引用将允许垃圾回收器清理该项,而强引用则不会。 WeakMaps非常适合这些类型的数据链接到另一个数据源。 如果我们想要的物品装饰或链接被清理时,主要对象被清理,一个WeakMap是一个 go-to 项目。

一个WeakSet有一个更有限的用例。 一个很好的用例是检查对象属性或图中的无限循环。 如果我们将所有访问的节点存储在一个WeakSet中,我们就能够检查是否有这些项,但是一旦检查完成,我们也不需要清除集合。 这意味着,一旦收集了数据,存储在WeakSet中的所有引用也将被收集。 总的来说,当我们需要标记一个对象或引用时,应该使用WeakSet。 这意味着,如果我们需要看看我们是否有它,或者它是否被访问过,一个WeakSet是这个工作的一个可能的候选人。

我们可以利用上一章的深度复制示例。 有了它,我们仍然会遇到一个我们没有想到的用例。 如果一个项目指向对象中的另一个项目,而该项目又决定指向原来的项目,会发生什么? 这可以在以下代码中看到:

const a = {item1 : b};
const b = {item1 : a};

当这些元素相互指向时,我们就会遇到循环引用的问题。 解决这个问题的方法是使用WeakSet。 我们可以保存所有访问过的节点,如果我们访问到一个已经访问过的节点,我们就从函数返回。 这可以在修改后的代码中看到:

const state = {};
(function(scope) {
    const _state = {},
          _held = new WeakSet(),
          checkPrimitives = function(item) {
              return item === null || typeof item === 'string' || typeof 
               item === 'number' || typeof item === 'boolean' ||
               typeof item === 'undefined';
          },
          cloneFunction = function(fun, scope=null) {
              return fun.bind(scope);
          },
          cloneObject = function(obj) {
              const newObj = {},
              const keys = Object.keys(obj);
              for(let i = 0; i < keys.length; i++) {
                  const key = keys[i];
                  const item = obj[key];
                  newObj[key] = runUpdate(item);
              }
              return newObj;
          },
          cloneArray = function(arr) {
              const newArr = new Array(arr.length);
              for(let i = 0; i < arr.length; i++) {
                  newArr[i] = runUpdate(arr[i]);
              }
              return newArr;
          },
          runUpdate = function(item) {
              if( checkPrimitives(item) ) {
                  return item;
              }
              if( typeof item === 'function' ) {
                  return cloneFunction(item);
              }
              if(!_held.has(item) ) {
                  _held.add(item);
                  if( item instanceof Array ) {
                      return cloneArray(item);
                  } else {
                      return cloneObject(item);
                  }
              }
          };
    scope.update = function(obj) {
        const x = Object.keys(obj);
        for(let i = 0; i < x.length; i++) {
            _state[x[i]] = runUpdate(obj[x[i]]);
        }
        _held = new WeakSet();
    }
})(state);
Object.freeze(state);

正如我们所看到的,我们已经添加了一个新的_held变量,它将保存我们所有的引用。 然后,对runUpdate函数进行了修改,以确保当一个项不是原始类型或函数时,检查held列表中是否已经有它。 如果我们做了,那么我们就跳过这一项,否则,我们就继续。 最后,我们用一个新的WeakSet替换_held变量,因为clear方法在WeakSets上不再可用。

这并不保留循环引用,这可能是一个问题,但它确实解决了系统因为对象相互引用而进入无限循环的问题。 除了这个用例,也许还有一些更高级的想法,对于一个WeakSet没有很多其他的需求。 最重要的是我们是否需要追踪某个东西的存在。 如果我们需要这样做,WeakSet对我们来说是完美的用例。

Most developers will not find a need for WeakSets or WeakMaps. These will likely be utilized by library authors. However, the conventions mentioned previously may come up in some cases so it is nice to know the reason for these items and why they are there. If we do not have a reason to use something, then we should most likely not use it, this is definitely the case with these two items since they have really specific use cases and one of the major use cases for WeakMaps is being delivered to us in the ECMAScript standard (private variables).

反射和代理

我们将要讨论的 ECMAScript 标准的最后一个主要部分是两个元编程对象。 元编程是一种让代码生成代码的技术。 这可能是用于编译器或解析器之类的东西。 它也可以用于自我更改的代码。 它甚至可以用于另一种语言的运行时评估(解释),并以此来做一些事情。 虽然这可能是反射和代理提供给我们的主要特性,但它也使我们能够监听对象上的事件。

在前一章中,我们讨论了侦听事件,并创建了一个CustomEvent来侦听对象上的事件。 我们可以改变代码,并利用代理来实现这种行为。 下面是一些处理对象上的基本事件的基本代码:

const item = new Proxy({}, {
    get: function(obj, prop) {
        console.log('getting the following property', prop);
        return Reflect.has(obj, prop) ? obj[prop] : null;
    },
    set: function(obj, prop, value) {
        console.log('trying to set the following prop with the following 
         value', prop, value);
        if( typeof value === 'string' ) {
            obj[prop] = value;
        } else {
            throw new Error('Value type is not a string!');
        }
    }
});
item.one = 'what';
item.two = 'is';
console.log(item.one);
console.log(item.three);
item.three = 12;

我们所做的是为这个对象的getset方法添加一些基本的日志记录。 我们扩展了这个对象的功能,使set方法只接受字符串值。 这样,我们就创建了一个可以被监听的对象,我们可以对这些事件做出响应。

Proxies are currently slower than adding a CustomEvent to the system. As stated previously, even though proxies were in the ECMAScript 2015 standard, their adoption has been slow, so browsers need some more time to optimize them. Also, it should be noted that we would not want to run the logging directly here. We would, instead, opt for the system to queue messages and utilize something called requestIdleCallback to run our logging code once the browser notices downtime in our application. This is still an experimental technology but should be added to all browsers soon.

代理的另一个有趣的属性是可撤销的方法。 这是一个代理,我们最终可以说它被撤销了,当我们试图在这个方法调用之后使用它时,它将抛出一个TypeError。 这对于任何试图用对象实现 RAII 模式的人来说都非常有用。 与其尝试nullout 引用,我们可以撤销代理,我们将不再能够使用它。

This pattern of RAII will differ slightly from the null reference. Once we revoke a proxy, all references will no longer be able to use it. This may become an issue, but it would also give us the added benefit of failing fast, which is always a great property to have in code development. This means that when we are in development, it will throw a TypeError instead of just passing a null value. In this case, only try-catch blocks would allow this code to keep going instead of just simple null checks. Failing fast is a great way to protect ourselves in development and to catch bugs earlier.

这里展示了一个例子,上面代码的修改版本:

const isPrimitive = function(item) {
    return typeof item === 'string' || typeof item === 'number' || typeof 
     item === 'boolean';
}
const item2 = Proxy.revocable({}, {
    get: function(obj, prop) {
        return Reflect.has(obj, prop) ? obj[prop] : null
    },
    set: function(obj, prop, value) {
        if( isPrimitive(value) ) {
            obj[prop] = value;
        } else {
            throw new Error('Value type is not a primitive!');
        }
    }
});
const item2Proxy = item2.proxy;
item2Proxy.one = 'this';
item2Proxy.two = 12;
item2Proxy.three = true;
item2.revoke();
(function(obj) {
    console.log(obj.one);
})(item2Proxy);

现在,除了在集合上抛出TypeErrors之外,一旦代理被撤销,我们还将抛出TypeError。 当我们决定编写能够保护自身的代码时,这对我们非常有用。 在使用对象时,我们也不再需要在代码中编写一堆保护子句。 如果我们使用代理和可撤销,我们就能够保护我们的集合。

We did not go into the terminology of the proxy system. Technically, the methods we add in the handler for the proxies are called traps, similar to operating system traps, but we can really just think of them as simple events. Sometimes, the terminology can add a bit of confusion to things and is usually not needed.

除了代理之外,Reflect API 是一组静态方法,它们反映了代理处理程序。 我们可以用它们来代替一些熟悉的系统,如Function.prototype.apply方法。 我们可以使用Reflect.apply方法,这样在编写代码时会更清晰一些。 看起来如下所示:

Math.max.apply(null, [1, 2, 3]);
Reflect.apply(Math.max, null, [1, 2, 3]);
item3 = {};
if( Reflect.set(item3, 'yep', 12) {
    console.log('value was set correctly!');
} else {
    console.log('value was not set!');
}
Reflect.defineProperty(item3, 'readonly', {value : 42});
if( Reflect.set(item3, 'readonly', 'nope') ) {
    console.log('we set the value');
} else {
    console.log('value should not be set!');
}

正如我们所看到的,我们第一次在对象上设置了一个值,它是成功的。 但是,第二个属性首先被定义,并且它被设置为不可写(使用defineProperty时的默认值),所以我们不能在它上设置值。

使用这两种 api,我们可以编写一些很好的访问对象的功能,甚至使更改尽可能安全。 通过这两个 api,我们可以很容易地利用 RAII 模式,甚至还可以使用它进行一些很酷的元编程。

其他值得注意的变化

有很多的变化,进步的 ECMAScript 标准我们可以一章都是讲的所有这些变化,但我们将更多的在这里编写的代码中可以看到这本书,最有可能看到的地方。

传播算子

扩展操作符允许我们分离数组、可迭代集合(如 set 或 maps),甚至是最新标准中的对象。 这给了我们一个更好的语法来执行一些常见的操作,比如这里的:

// working with a variable amount of arguments
const oldVarArgs = function() {
    console.log('old variable amount of arguments', arguments);
}
const varArgs = function(...args) {
    console.log('variable amount of arguments', args);
}
// transform HTML list into a basic array so we have access to array
// operations
const domArr = [...document.getElementsByTagName('li')];
// clone array
const oldArr = [1, 2, 3, 4, 5];
const clonedArr = [...oldArr];
// clone object
const oldObj = {item1 : 'that', item2 : 'this'};
const cloneObj = {...oldObj};

过去是for循环和其他版本的迭代,现在是简单的一行代码。 另外,第一个项很好,因为它向代码的读者展示了我们将函数用作变量参数函数。 我们可以从代码中看到这一点,而不需要文档。

When dealing with arguments, if we are going to mutate them at all in the function, create a copy and then mutate. Certain de-optimizations happen if we decide to mutate the arguments directly.

解构

析构是将数组或对象以一种更简单的方式传递给要赋值的变量的过程。 这可以通过下面的代码看到:

//object
const desObj = {item1 : 'what', item2 : 'is', item3 : 'this'};
const {item1, item2} = desObj;
console.log(item1, item2);

//array
const arr = [1, 2, 3, 4, 5];
const [a, ,b, ...c] = arr;
console.log(a, b, c);

这两个例子都展示了一些很酷的属性。 首先,我们可以从对象中挑选我们想要的物品。 如果需要的话,我们也可以把这个值重新赋给左边的其他值。 在此之上,我们甚至可以进行嵌套对象和解构。

对于数组,我们能够挑选和选择所有的项,一些项,甚至使用rest语法,将数组的其余部分放在一个变量中。 在上面的例子中,a将保存1b将保存3c将是一个包含45的数组。 我们跳过了 2,把这个空间空了。 在其他语言中,我们会使用像_这样的东西来展示它,但在这里我们可以跳过它。 同样,所有这些都是语法上的糖,可以编写更紧凑、更干净的代码。

电力运营商

这里没有太多要说的,除了我们不再需要使用Math.pow()函数这一事实; 我们现在有了幂运算符或**,可以得到更清晰的代码和更美观的数学方程。

参数默认值

当调用函数时,如果没有对应位置的值,我们可以使用默认值。 这可能如下所示:

const defParams = function(arg1, arg2=null, arg3=10) {
    if(!arg2 ) {
        console.log('nothing was passed in or we passed in a falsy value');
    }
    const pow = arg3;
    if( typeof arg1 === 'number' ) {
        return arg1 ** pow;
    } else {
        throw new TypeError('argument 1 was not a number!');
    }
}

关于参数默认值需要注意的一点是,一旦我们开始在参数链中使用默认值,我们就不能停止使用默认值。 在前面的例子中,如果我们给参数 2 一个默认值,我们就必须给参数 3 一个默认值,即使我们只是传递 undefined 或null给它。 同样,这有助于代码的清晰性,并确保在查看数组参数时不再需要创建默认情况。

A lot of code still utilizes the arguments section of a function. There are even other properties of a function that we can get at such as the caller. If we are in strict mode, a lot of this behavior will break. Strict mode is a way to not allow access to certain behaviors in the JavaScript engine. A good description of this can be found at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode. In addition to this, we should no longer be using the arguments section of the function since we have plenty of helpful alternatives with the new standard.

字符串模板

字符串模板允许我们传入任意代码,这些代码将对字符串或具有toString函数的对象进行求值。 这让我们能够编写更清晰的代码,而不是创建一堆连接的字符串。 它还允许我们在不创建转义序列的情况下编写多行字符串。 这可以看到如下:

const literal = `This is a string literal. It can hold multiple lines and
variables by denoting them with curly braces and prepended with the dollar 
sign like so \$\{\}.
here is a value from before ${a}. We can also pass an arbitrary expression 
that evaluates ${a === 1 ? b : c}.
`
console.log(literal);

只要记住,即使我们可以做一些事情,做一些事情可能不是最好的主意。 具体来说,我们可以传递任意的表达式来求值,但是我们应该尽量保持它们的简洁和简单,以使代码更具可读性。

类型的数组

我们将在以后的章节中详细讨论这些,但是类型化数组是表示系统中任意字节的方法。 这使我们能够使用较低级别的功能,例如编码器和解码器,甚至直接处理fetch调用的字节流,而不是必须将 blob 转换为数字或字符串。

这些通常以一个ArrayBuffer开始,然后我们在它上面创建一个视图。 这看起来像以下内容:

const arrBuf = new ArrayBuffer(16);
const uint8 = new Uint8Array(arrBuf);
uint8[0] = 255;

如我们所见,我们首先创建一个数组缓冲区。 把它看作一个低级实例。 它只保存原始字节。 然后我们必须在它上面创建一个视图。 很多时候,我们将利用Uint8Array,因为我们需要处理任意字节,但我们可以一直使用视图BigInt。 这些通常在低级系统中使用,如 3D 画布代码、WebAssembly 或来自服务器的原始流。

长整型数字

BigInt为任意长整数。 JavaScript 中的数字存储为 64 位的浮点双精度浮点数。 这意味着,即使我们只有一个普通整数,我们仍然只能得到 53 位的精度。 我们只能在变量中存储 9 千万亿以内的数字。 任何大于此值的内容通常会导致系统进入未定义的行为。 为了弥补这一点,我们现在可以利用 JavaScript 中的BigInt特性。 看起来如下所示:

const bigInt = 100n;
console.log('adding two big ints', 100n + 250n);
console.log('add a big int and a regular number', 100n + BigInt(250));

我们会注意到,bigint后面加了n。 如果我们想在常规操作中使用正则数,我们还需要强制将其转换为bigint。 现在我们有了大整数,我们可以处理非常大的数字,这在 3D、金融和科学应用中非常有用。

不要试图强制bigint返回正则数字。 这里有一些未定义的行为,如果我们尝试这样做,可能会失去精度。 最好的方法是,如果我们需要使用bigint,那么就使用bigint

国际化

最后,我们来谈谈国际化。 以前,我们需要将日期、数字格式甚至货币等国际化。 我们将使用特殊的查找或转换器来为我们完成这项工作。 在 ECMAScript 的新版本中,我们通过内置的Intl对象获得了对获取这些新格式的支持。 一些用例可以如下所示:

const amount = 1478.99;
console.log(new Intl.NumberFormat('en-UK', {style : 'currency', currency : 'EUR'}).format(amount));
console.log(new Intl.NumberFormat('de-DE', {style : 'currency', currency : 'EUR'}).format(amount));
const date = new Date(0);
console.log(new Intl.DateTimeFormat('en-UK').format(date));
console.log(new Intl.DateTimeFormat('de-DE').format(date));

有了这个,我们现在可以根据用户所在的位置或他们在应用开始时选择的语言来国际化我们的系统。

This will only perform conversions of the numbers to the stylings for that country code; it will not try to convert the actual values since options such as currency change within the course of the day. If we need to perform such conversions, we will need to use an API. On top of this, if we want to translate something, we will still need to have separate lookups for what we need to put in text since there is no direct translation between languages.

在 ECMAScript 标准中添加了这些令人惊叹的特性之后,现在让我们转向一种将函数和数据封装在一起的方法。 为此,我们将使用类和模块。

理解类和模块

新 ECMAScript 标准,我们得到了新类的语法有一种面向对象编程(OOP),之后,我们还得到了模块,导入和导出的用户定义函数和对象的集合。 这两个系统都使我们能够删除我们构建在系统中的某些 hack,并删除对模块化代码库非常重要的某些库。****

****首先,我们需要了解 JavaScript 是什么类型的语言。 JavaScript 是一种多范式语言。 这意味着我们可以利用许多不同编程风格的想法,并将它们纳入我们的代码库。 我们在前几章中提到的一种编程风格是函数式编程。

在纯函数编程中,我们有纯函数,或执行一个操作而没有副作用的函数(做函数应该做的事情之外的事情)。 当我们用这种方式写的时候,我们可以创建广义函数,并把它们放在一起来创建一系列简单的想法,这些想法可以处理复杂的想法。 我们还将函数视为语言中的一等公民。 这意味着函数可以赋值给变量并传递给其他函数。 我们也可以组合这些函数,正如我们在前几章中看到的那样。 这是思考问题的一种方式。

另一种流行的编程风格是面向对象编程。 这种风格表明,可以用类和对象的层次结构来描述程序,这些类和对象可以一起构建和使用,以创建这种复杂的思想。 这个观点可以在大多数流行的语言中看到。 我们构建的基类具有一些通用的功能或一些特定版本需要合并的定义。 我们从这个基类继承并添加我们自己的特定功能,然后创建这些对象。 一旦我们把所有这些东西放在一起,我们就可以研究我们需要的复杂想法。

对于 JavaScript,我们可以同时得到这两种想法,但 JavaScript 中的 OOP 设计有点不同。 我们有所谓的原型继承。 这就意味着,这些被称为的抽象概念实际上是没有概念的。 JavaScript 中只有对象。 我们继承一个对象的原型,它具有所有具有相同原型的对象共享的方法和数据,但它们都是实例化的实例。

当我们在 JavaScript 中谈论类语法时,我们谈论的是构造函数和方法/数据的语法糖,我们要添加到它们的原型中。 考虑这种类型的继承的另一种方法是注意 JavaScript 中没有抽象的概念,只有具体的对象。 如果这看起来有点深奥或令人困惑,下面的代码应该澄清这些语句的含义:

const Item = funciton() {
    this.a = 1;
    this.b = 'this';
    this.c = function() {
        console.log('this is going to be a new function each time');
    }
}
Item.prototype.d = function() {
    console.log('this is on the prototype so it will only be here 
     once');
}
const item1 = new Item();
const item2 = new Item();

item1.c === item2.c; //false
item1.d === item2.d; //true

const item3 = new (Object.getPrototypeOf(item1)).constructor();
item3.d === item2.d ;//true
Object.getPrototypeOf(item1).constructor === Item; //true

我们已经用这个例子展示了一些东西。 首先,这是创建构造函数的旧方法。 构造函数是一个函数,它设置对象实例化时直接可用的范围和所有函数。 在本例中,我们将abc作为Item构造函数的实例变量。 其次,我们在道具原型中添加了一些东西。 当我们在构造函数的原型上声明一些东西时,我们让构造函数的所有实例都可以使用它。

从这里开始,我们声明了两个基于Item构造函数的项。 这意味着它们都将获得abc变量的独立实例,但它们将共享d函数。 我们可以从下面两个语句中看到这一点。 这表明,如果我们直接向构造函数的this范围添加一些内容,它将创建该项目的全新实例,但如果我们将一些内容添加到原型中,这些项目将共享它。

最后,我们可以看到,item3是一个新的Item,但我们以一种迂回的方式获得了构造函数。 有些浏览器支持条目上的__proto__属性,但是这个函数应该在所有浏览器中都可用。 我们获取原型,并注意到有一个构造函数。 这与我们在顶部声明的函数完全相同,所以我们能够利用它来创建一个新项目。 我们可以看到,它也在与其他项相同的原型上,并且原型上的构造函数与我们声明的item变量完全相同。

所有这些都应该说明这样一个事实:JavaScript 完全是由对象构成的。 在其他语言中没有像真正的类那样的抽象类型。 如果我们使用新的语法,最好理解我们所做的一切都是利用语法糖来做我们过去可以用原型做的事情。 也就是说,下一个例子将展示完全相同的行为,但一个将是老式的基于原型的,另一个将利用新的类语法:

class newItem {
    constructor() {
        this.c = function() {
            console.log('this is going to be a new function each time!);
        }
    }
    a = '1';
    b = 'this';
    d() {
        console.log('this is on the prototype so it will only be here 
         once');
    }
}
const newItem1 = new newItem();
const newItem2 = new newItem();

newItem1.c === newItem2.c //false
newItem1.d === newItem2.d //true

newItem === Object.getPrototypeOf(newItem1).constructor; //true

正如我们在这个例子中所看到的,在创建与原型版本相同的对象时,我们得到了一些更清晰的语法。 构造函数与声明Item为函数时是一样的。 我们可以传入任何参数并在这里进行设置。 类的一个有趣之处在于,我们能够在类内部创建实例变量,就像我们在原型示例的this上声明它们一样。 我们还可以看到,d的声明被放在了原型上。 我们将在下面探讨类语法的更多方面,但需要花一些时间来研究这两段代码。 当我们试图编写高性能代码时,理解 JavaScript 是如何基于原型的非常有帮助。

The public variables being inside the class is rather new (Chrome 72). If we do not have access to a newer browser, we will have to utilize Babel to transpile our code back down to a version that the browser will understand. We will also be taking a look at another feature that is only in Chrome and is experimental, but it should come to all browsers within the year.

其他值得注意的特性

JavaScript 类为我们提供了许多很棒的特性,使我们编写的代码简洁明了,同时也能以直接编写原型的速度执行。 一个很好的特性是包含静态成员变量和静态成员函数。

虽然没有太大的区别,但它确实允许我们编写成员函数无法访问的函数(它们仍然可以访问,但要困难得多),而且它可以提供一个很好的工具,将实用函数分组到特定的类中。 静态函数和变量的例子如下:

class newItem {
    static e() {
        console.log(this);
    }
    static f = 10;
}

newItem1.e() //TypeError
newItem.e() //give us the class
newItem.f //10

这两个静态定义被添加到newItem类中,然后我们将展示哪些是可用的。 通过函数e和静态变量f,我们可以看到,它们并不包含在我们从newItem创建的对象中,但我们可以在直接访问newItem时访问它们。 在此之上,我们可以看到静态函数内部的this指向类。 静态成员和变量对于创建实用函数,甚至在 JavaScript 中创建单例模式非常有用。

如果我们想要在旧风格中创造同样的体验,它将会是如下所示:

Item.e = function() {
    console.log(this);
}
Item.f = 10;

我们可以看到,我们必须把这些定义放在Item的第一个定义之后。 这意味着,我们必须相对谨慎地尝试将所有代码按旧样式分组以定义类,而类语法允许我们将所有代码分组。

在静态变量和函数的基础上,我们有为类中的变量编写 getter 和 setter 的简写。 这可以看到如下:

get g() {
    return this._g;
}
set g(val) {
    if( typeof val !== 'string' ) {
        return;
    }
    this._g = val;
}

有了这个 getter 和 setter,当有人或东西试图访问这个变量时,我们能够在这些函数中做各种事情。 就像我们在更改时设置事件代理一样,我们可以用 getter 和 setter 做类似的事情。 我们也可以在这里设置日志记录。 当我们想要访问一个属性名而不是像getGsetG这样的东西时,这种语法非常好。

最后,在 Chrome 76 中出现了新的私有变量。 虽然这仍处于候选推荐阶段,但仍将进行讨论,因为它最有可能发挥作用。 很多时候,我们想要尽可能多地暴露信息。 然而,有时我们想要使用内部变量来保存状态,或者通常不被对象外部访问。 在这种情况下,JavaScript 社区提出了_解决方案。 任何带有_的变量都被认为是私有变量。 但是,用户仍然可以访问和操作这些变量。 更糟糕的是,恶意用户可能会发现这些私有变量中的漏洞,并能够对系统进行有利于自己的操作。 在旧系统中创建私有变量的一种技术如下所示:

const Public = (function() {
    let priv = 0;
    const Private = function() {}
    Private.prototype.add1 = function() {
        priv += 1;
    }
    Private.prototype.getVal = function() {
        return priv;
    }
    return Private;
})();

这样,除了实现者,没有人可以访问priv变量。 这就给了我们一个面向公众的系统,不需要访问那个私有变量。 然而,这个系统仍然有一个问题:如果我们创建另一个Public对象,我们仍然会影响相同的priv变量。 还有其他方法可以确保我们在创建新对象时获得新变量,但这些都是我们试图创建的系统的变通方法。 相反,我们现在可以使用以下语法:

class Public {
    #h = 10;
    get h() {
        return this.#h;
    }
}

这个井号表示这是一个私有变量。 如果我们试图从任何一个实例中访问它,它将返回 undefined。 这在 getter 和 setter 接口上工作得很好,因为我们将能够控制对变量的访问,甚至在需要时修改它们。

最后看一下extendsuper关键词。 有了extend,我们就可以通过类来实现这一点。 让我们使用我们的newItem类并扩展它的功能。 这看起来像以下内容:

class extendedNewItem extends newItem {
    constructor() {
        super();
        console.log(this.c());
    }
    get super_h() {
        return super.h;
    }
    static e() {
        super.e();
        console.log('this came from our extended class');
    }
}
const extended = new extendedNewItem();

在这个例子中,我们有一些有趣的行为。 首先,如果我们在扩展对象上运行Object.getPrototypeOf,我们将看到原型是我们所期望的extendedNewItem。 现在,如果我们得到它的原型,我们会看到它是newItem。 我们已经创建了一个原型链,就像许多内置对象一样。

其次,我们可以使用super从类内部访问父类的方法。 这本质上是对父类原型的引用。 如果我们想要对所有的原型进行检查,我们就无法将这些连接起来。 我们必须利用Object.getPrototypeOf之类的东西。 我们还可以看到,通过检查扩展对象,我们得到了父系统中保存的所有成员变量。

这使我们能够将类组合在一起,并创建基类或抽象类,这些类给我们一些已定义的行为,然后我们可以创建扩展类,这些类给我们想要的特定行为。 稍后我们将看到更多的代码,利用类和许多概念,我们已经在这里,但是记住,类是语法糖的原型系统,很好地理解仍有很长一段路要走到了解 JavaScript 是一种语言。

关于 JavaScript 生态系统的类接口有许多很棒的东西,而且在未来可能还会出现其他一些很棒的想法,比如装饰器。 关注Mozilla Developer Network(MDN)页面,看看有什么新内容,以及未来可能会出现什么内容,总是一个好主意。 现在,我们将看看模块以及它们如何在我们的系统中工作,以编写干净和快速的代码。

A good rule of thumb is to not extend any class more than one, maybe two levels deep. If we go any further, we can start to create a maintenance nightmare, on top of potential objects getting heavy with information that they don't need. Thinking ahead will always be the best bet when we are creating our systems, and trying to minimize the impact of our classes is one way of reducing memory use.

模块

在 ECMAScript 2015 之前,除了利用脚本标签,我们没有加载代码的概念。 我们提出了许多模块概念和库,如RequireJSAMD,但它们都不是内置在语言中。 随着模块的出现,我们现在有了一种创建高度模块化代码的方法,这些代码可以很容易地打包并导入到代码的其他部分。 我们还在我们的系统上获得了范围锁,以前我们必须利用 IIFEs 来获得这种行为。

首先,在开始使用模块之前,我们需要一个静态服务器来承载所有内容。 即使我们让 Chrome 允许访问本地文件系统,模块系统也会感到不安,因为它不会将它们作为文本/JavaScript 服务。 为了解决这个问题,我们可以安装节点包node-static。 我们将把这个包添加到一个静态目录中。 我们可以执行以下命令:npm install node-static。 一旦完成到static目录的下载,我们就可以从存储库中的Chapter03文件夹中获取app.js文件并运行node app.js。 这将启动静态服务器,并从我们的static目录中的files目录提供服务。 然后,我们可以将任何想要提供服务的文件放在那里,并能够从我们的代码中获取它们。

现在,我们可以按照以下方式编写一个基本模块,并将其保存为lib.js:

export default function() {
    console.log('this is going to be our simple lib');
}

然后我们可以从 HTML 文件中导入这个模块,如下所示:

<script type="module'>
    import lib from './lib.js';
</script>

通过这个基本示例,我们可以了解模块在浏览器中的工作方式。 首先,脚本的类型需要是一个模块。 这告诉浏览器,我们将加载模块,我们将把这段代码当作一个模块来处理。 这给我们带来了几个好处。 首先,当我们使用模块时,我们会自动进入严格模式。 第二,我们被自动限定了模块的作用域。 这意味着我们刚刚导入的lib不能作为全局变量使用。 如果我们以文本/JavaScript 的形式加载内容,并将变量放在全局路径上,那么我们将自动拥有它们; 这就是为什么我们通常要利用生活。 最后,我们得到了加载 JavaScript 文件的良好语法。 我们仍然可以使用旧的方式来加载一堆脚本,但我们也可以只导入基于模块的脚本。

接下来,我们可以看到模块本身使用了exportdefault关键词。 export表示我们希望该项在此作用域或文件之外可用。 现在我们可以在当前文件之外找到这个项目。 默认值意味着,如果我们加载模块时没有定义我们想要的内容,我们将自动获得该项目。 这可以在下面的例子中看到:

const exports = {
    this : 'that',
    that : 'this'
}

export { exports as Item };

首先,我们定义了一个名为exports的对象。 这是我们想要作为导出项添加的对象。 其次,我们将该项目添加到export声明中,并将其重命名。 这是模块的一个优点。 在导出或导入端,我们可以重命名想要导出的项。 现在,在我们的 HTML 文件中,我们有如下声明:

import { Item } from './lib.js';

如果声明周围没有括号,我们将尝试引入默认导出。 因为我们有花括号,它将在lib.js中查找一个名为Item的项。 如果它找到了它,就会引入与之相关的代码。

现在,正如我们从导出列表中重命名导出一样,我们也可以重命名导入。 让我们把它改成如下:

import { Item as _item } from './lib.js';

我们现在可以像往常一样使用该项目,但将其作为变量_item而不是Item。 这对于名称冲突非常有用。 我们能想到的变量名只有这么多,因此,我们不需要在单独的库中更改变量,而只需在加载它们时更改它们。

一个好的样式约定是在顶部声明所有的导入。 然而,在某些情况下,由于某些类型的用户交互或其他事件,我们可能需要动态加载模块。 如果出现这种情况,我们可以利用动态导入来实现这一点。 其内容如下:

document.querySelector('#loader').addEventListener('click', (ev) => {
    if(!('./lib2.js' in imported)) {
        import('./lib2.js')
        .then((module) => {
            imported['./lib2.js'] = module;
            module.default();
        });
    } else {
        imported['./lib2.js'].default();
    }
});

我们已经添加了一个按钮,当单击该按钮时,我们将尝试将模块加载到系统中。 这不是在我们的系统中缓存模块的最佳方式,大多数浏览器也会为我们做一些缓存,但这种方式相当简单,并展示了动态导入系统。 导入函数是基于承诺的,所以我们尝试抓取它,如果成功,我们将它添加到导入的对象中。 然后调用默认方法。 我们可以得到模块为我们导出的任何项,但这是最容易得到的项之一。

看到 JavaScript 是如何发展的已经是惊人的。 所有这些新特性为我们提供了以前必须依赖于第三方的功能。 对于 DOM 的更改也可以这么说。 现在我们来看看这些变化。

使用 DOM

文档对象模型(DOM)并非总是最容易使用的技术。 我们使用的是古老的 api,大多数时候,它们在不同的浏览器之间并不一致。 但是,在过去的几年里,我们已经获得了一些很好的 api 来完成以下任务:轻松地获取元素,为快速附件构建内存层次结构,以及使用 DOM 阴影的模板。 所有这些都为更改底层节点和创建大量富前端提供了丰富的环境,而无需使用 jQuery 等库。 在接下来的部分中,我们将看到使用这些新 api 是如何帮助我们的。

查询选择器

在我们拥有这个 API 之前(或者我们试图尽可能地跨浏览器),我们依赖于像getElementByIdgetElementsByClassName这样的系统。 每一个都提供了一种获取 DOM 元素的方法,如下面的例子所示:

<p>This is a paragraph element</p>
<ul id="main">
    <li class="hidden">1</li>
    <li class="hidden">2</li>
    <li>3</li>
    <li class="hidden">4</li>
    <li>5</li>
</ul>
<script type="module">
    const main = document.getElementById('main');
    const hidden = document.getElementsByClassName('hidden');
</script>

这个旧 API 与新的querySelectorquerySelectorAll之间的一个区别是,旧 API 将 DOM 节点集合实现为HTMLCollection,而新 API 将它们实现为NodeList。 虽然这似乎不是一个主要的区别,但NodeListAPI 确实为我们提供了一个已经内置在系统中的forEach。 否则,我们将不得不将这两个集合都更改为常规的 DOM 节点数组。 前面的例子,在新的 API 中实现,如下所示:

const main = document.querySelector('#main');
const hidden = document.querySelectorAll('.hidden');

当我们想要在选择过程中添加其他功能时,这就变得更好了。

假设我们现在有一些输入我们想要抓取所有文本类型的输入。 这在旧 API 中会是什么样子? 如果需要的话,我们可以将一个类附加到所有这些类上,但这将污染我们对类的使用,而且可能不是处理此信息的最佳方式。

另一种获取数据的方法是使用一个旧的 API 方法,然后检查这些元素的输入属性是否设置为text。 这看起来像以下内容:

const allTextInput = Array.from(document.getElementsByTagName('input'))
    .filter(item => item.getAttribute('type') === "text");

但是我们现在有了某种程度的不必要的冗长。 相反,我们可以通过使用 CSS 选择器来抓取它们,使用选择器 API 如下:

const alsoTextInput = doucment.querySelectorAll('input[type="text"]');

这意味着我们应该能够利用 CSS 语法访问任何 DOM 节点,就像 jQuery 那样。 我们甚至可以从另一个元素开始,这样就不需要解析整个 DOM,如下所示:

const hidden = document.querySelector('#main').querySelectorAll('.hidden');

Selectors API 的另一个好处是,如果我们没有使用正确的 CSS 选择器,它将抛出一个错误。 这为系统运行检查提供了额外的好处。 虽然新的选择器 API 已经出现了,但由于需要将 Internet Explorer 包含在所支持的 web 浏览器中,所以它还没有被大量使用。 强烈建议开始使用新的 Selector API,因为它不那么冗长,而且我们可以用它比我们的旧系统做更多的事情。

jQuery is a library that gives us a nicer API to utilize than the base system had. Most of the changes that jQuery supported have now become obsolete, with many of the new web APIs that we have been talking about taking over. For most new applications, they will no longer need to utilize jQuery.

文档片段

我们在前面的章节中已经看到了这些,但是在这里稍微提一下是很好的。 文档片段是可重用的容器,我们可以在其中一次性创建 DOM 层次结构并附加所有节点。 这导致更快的绘制时间和更少的重新绘制时,利用。

下面的示例展示了使用直接到 dom 的添加和片段添加附加一系列列表元素的两种方法:

const num = 10000;
const container = document.querySelector('#add');
for(let i = 0; i < num; i++) {
    const temp = document.createElement('li');
    temp.textContent = `item ${i}`;
    container.appendChild(temp);
}
while(container.firstChild) {
    container.removeChild(container.firstChild);
}
const fragment = document.createDocumentFragment();
for(let i = 0; i < num; i++) {
    const temp = document.createElement('li');
    temp.textContent = `item ${i}`;
    fragment.appendChild(temp);
}
container.appendChild(fragment);

虽然这两者之间的时间是很小的,发生的重新油漆的数量是不。 在第一个示例中,每次我们直接向文档添加元素时,文档都会重新绘制,而第二个示例只重写 DOM 一次。 这是文档片段的优点; 它使添加 DOM 变得简单,同时也只使用最小的重绘。

影子 DOM

阴影 DOM 通常与模板和 web 组件配对,但它也可以自己使用。 shadow DOM 允许我们为应用的特定部分封装标记和样式。 如果我们想为页面的某个部分设置特定的样式,但又不想将其传播到页面的其他部分,那么这是非常好的。

我们可以很容易地利用 shadow DOM 的 API,如下所示:

const shadow = document.querySelector('#shadowHolder').attachShadow({mode : 'open'});
const style = document.createElement('style');
style.textContent = `<left out to shorten code snippet>`;
const frag = document.createDocumentFragment();
const header = document.createElement('h1');
const par = document.createElement('p');
header.textContent = 'this is a header';
par.textContent = 'Here is some text inside of a paragraph element. It is going to get the styles we outlined above';

frag.appendChild(header);
frag.appendChild(par);
shadow.appendChild(style);
shadow.appendChild(frag);

首先,我们给一个元素附加一个 shadow DOM,在这个例子中,就是我们的shadowHolder元素。 有一个模式选项,允许我们说,我们是否可以通过 JavaScript 在阴影环境之外访问内容,但它已经被发现,我们可以很容易地绕过这一点,所以建议只是保持开放。 接下来,我们创建一些元素,其中一个是一些样式属性。 然后我们将它们附加到文档片段,最后附加到影子根。

所有这些都解决了,我们可以看一下,并注意到阴影 DOM 受到样式属性的影响,这些属性是放在里面的,而不是放在主文档顶部的。 如果我们在文档的顶部放置一个阴影样式没有的样式会发生什么? 它仍然不会受到影响。 这样,我们现在就可以创建可以单独设置样式的组件,而不需要使用类。 这就引出了 DOM 的最后一个主题。

Web 组件

Web 组件 API 允许我们创建自定义元素,这些元素只使用浏览器 API 定义行为。 这与 Bootstrap 甚至 Vue 等框架不同,因为我们能够利用浏览器中存在的所有技术。

Chrome and Firefox have all of these APIs supported. Safari has most of them, and if this is a browser that we want to support, we will only be able to utilize some of the APIs. Edge does not have support for the Web Component APIs, but with it moving to a chromium base, we will see another browser able to utilize this technology.

让我们创建一个基本的tooltip元素。 首先,我们需要扩展我们班的基础HTMLElement。 然后,我们需要附加一些属性,以便放置元素并给出需要使用的文本。 最后,我们需要向我们的系统注册这个组件,以确保它能够识别我们的自定义元素。 下面的代码创建了这个自定义元素(从https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements修改):

class Tooltip extends HTMLElement {
    constructor() {
        super();
        this.text = this.getAttribute('text');
        this.type = this.getAttribute('type');
        this.typeMap = new Map(Object.entries({
            'success' : "&#x2714",
            'error' : "&#x2716",
            'info' : "&#x2755",
            'default' : "&#x2709"
        }));

        this.shadow = this.attachShadow({mode : 'open'});
        const container = document.createElement('span');
        container.classList.add('wrapper');
        container.classList.add('hidden');
        const type = document.createElement('span');
        type.id = 'icon';
        const el = document.createElement('span');
        el.id = 'main';
        const style = document.createElement('style');
        el.textContent = this.text;
        type.innerHTML = this.getType(this.type);

        style.innerText = `<left out>`
        this.shadow.append(style);
        this.shadow.append(container);
        container.append(type);
        contianer.append(el);
    }
    update() {
        const x = this.getAttribute('x');
        const y = this.getAttribute('y');
        const type = this.getAttribute('type');
        const text = this.getAttribute('text');
        const show = this.getAttribute('show');
        const wrapper = this.shadow.querySelector('.wrapper');
        if( show === "true" ) {
            wrapper.classList.remove('hidden');
        } else {
            wrapper.classList.add('hidden');
        }
        this.shadow.querySelector('#icon').innerHTML = this.getType(type);
        this.shadow.querySelector('#main').innerText = text;
        wrapper.style.left = `${x}px`;
        wrapper.style.top = `${y}px`;
    }
    getType(type) {
        return type ?
            this.typeMap.has(type) ?
                this.typeMap.get(type) :
                this.typeMap.get('default') :
            this.typeMap.get('default');
    }
    connectCallback() {
        this.update(this);
    }
    attributeChangedCallback(name, oldValue, newValue) {
        this.update(this);
    }
    static get observedAttributes() {
        return ['x', 'y', 'type', 'text', 'show'];
    }
}

customElements.define('our-tooltip', Tooltip);

首先,我们有一个属性列表,我们将用于样式和定位我们的tooltip。 它们分别称为xytypetextshow。 接下来,我们为一些基于表情的文本创建一个地图,这样我们就可以使用图标,而无需引入一个成熟的库。 然后我们在阴影容器中设置可重用对象。 我们还将阴影根设置在对象上,这样我们就可以很容易地访问它。 update方法将在元素的第一次创建和属性的任何后续更改时触发。 我们可以在最后三个函数中看到这一点。 一旦我们连接到 DOM,connectedCallback将被触发。 attributeChangedCallback将提醒我们发生的任何属性更改。 这与代理 API 非常相似。 最后一部分让对象知道我们特别关心哪些属性,在本例中是xytypetextshow。 最后,我们用customElements.define方法注册自定义组件,给它一个名称和类,当这些对象之一被创建时,我们想要运行它们。

现在,如果我们创建我们的tooltip,我们可以利用这些不同的属性,使一个可重用的系统tooltip甚至警报。 下面的代码演示了这一点:

<our-tooltip show="true" x="100" y="100" icon="success" text="here is our tooltip"></our-tooltip>

我们应该会看到一个带有复选框的浮动框,这里是我们的工具提示。 通过使用 Web 组件 API 附带的模板系统,我们可以使这个tooltip更容易阅读。

模板

现在,我们有一个很好的可重用的tooltip元素,但我们也有相当多的代码与我们的风格标签,这是完全由模板字符串组成。 最好的是,我们可以把这个语义标记放在其他地方,并在我们的 web 组件中像现在这样执行逻辑。 这就是模板发挥作用的地方。 元素<template>不会显示在页面上,但我们仍然可以很容易地通过给它一个 ID 来获取它。 所以,重构当前代码的一种方法是:

<template id="tooltip">
    <style>
        /* left out */
    </style>
    <span class="wrapper hidden" x="0" y="0" type="default" show="false">
        <span id="icon">&#2709</span>
        <span id="main">This is some default text</span>
    </span>
</template>

我们的 JavaScript 类构造函数现在应该是这样的:

constructor() {
    super();
    this.type = this.getAttribute('type');
    this.typeMap = // same as before
    const template = document.querySelector('#tooltip').content;
    this.shadow = this.attachShadow({mode : 'open'});
    this.shadow.appendChild(template.cloneNode(true));
}

这更容易阅读,更容易推理。 现在我们获取模板并获取其内容。 我们创建一个shadow对象并添加我们的模板。 我们需要确保克隆我们的模板节点,否则我们将共享我们决定创建的所有元素之间的相同引用! 您会注意到,我们现在无法通过属性控制文本。 虽然看到这种行为很有趣,但我们真的希望把这些信息留给我们的tooltip的创建者。 我们可以通过<slot>元素来做到这一点。

槽为我们提供了一个区域,我们可以将 HTML 放在该位置。 我们可以利用它来允许tooltip的用户在那个槽中放入他们想要的标记。 我们可以给它们一个如下所示的模板:

<span class="wrapper hidden" x="0" y="0" type="default" show="false">
    <span id="icon">&#2709</span>
    <span id="main"><slot name="main_text">This is default text</slot></span>
</span>

我们的实现可能如下所示:

<our-tooltip show="true" x="100" y="100" type="success">
    <span slot="main_text">That was a successful operation!</span>
</our-tooltip>

正如我们所看到的,shadow DOM 的使用,连同浏览器中的 web 组件和模板系统,允许我们创建丰富的元素,而不需要外部库,如 Bootstrap 或 Foundation。

We may still need these libraries to provide some base-level styling, but we should not need them to the extent we used to. The best-case scenario is that we can write all of our own components with styling and not need to utilize external libraries. But, seeing as how these systems are relatively new, if we are not able to control what our users use, we may be stuck polyfilling.

理解 Fetch API

在使用 Fetch API 之前,我们必须使用XMLHttpRequest系统。 为了创建服务器数据的请求,我们必须编写如下内容:

const oldReq = new XMLHttpRequest();
oldReq.addEventListener('load', function(ev) {
    document.querySelector('#content').innerHTML = 
     JSON.stringify(ev.target.response);
});
oldReq.open('GET', 'http://localhost:8081/sample');
oldReq.setRequestHeader('Accept', 'application/json');
oldReq.responseType = 'json';
oldReq.send();

首先,您将注意到对象类型称为XMLHttpRequest。 原因在于发明它的人以及它背后的原因。 微软最初是为 Outlook 网络访问产品开发这种技术的。 最初,他们来回传输 XML 文档,因此他们根据构建对象的目的来命名对象。 其他浏览器供应商(主要是 Mozilla)采用了它之后,他们决定保留这个名称,尽管它的目的已经从仅仅发送 XML 文档转变为从服务器向客户机发送任何类型的响应。

其次,我们向对象添加一个事件监听器。 因为这是一个普通的对象,并且不基于 promise,所以我们用老式的方法添加了一个监听器addEventListener方法。 这意味着一旦事件监听器被使用,我们还将清理它。 接下来,我们打开请求,传递我们想要发送的方法和我们想要发送它到的地方。 然后我们可以设置一堆请求头(具体来说,我们规定我们想要应用/JSON 数据,我们将responseType设置为json,以便它将被浏览器正确地转换)。 最后,我们发送请求。

一旦我们实现了响应,我们的事件将被触发,我们可以从事件的目标检索响应。 一旦我们开始发布数据,就会变得更加麻烦。 这就是为什么要使用 jQuery 的$.ajax之类的方法。 它使工作与XMLHttpRequest对象更容易。 这个响应在 Fetch API 中是什么样的呢? 这一完全相同的要求如下:

fetch('http://localhost:8081/sample')
.then((res) => res.json())
.then((res) => {
    document.querySelector('#content').innerHTML = JSON.stringify(res);
});

我们可以看到,这是相当容易阅读和理解。 首先,我们设置想要点击的 URL。 如果我们没有将操作传递给fetch调用,它将自动假定我们正在创建一个GET请求。 接下来,我们得到响应,并确保我们得到它json。 响应总是会返回为promise(稍后再详细介绍),因此我们想将其转换为我们想要的格式,在本例中是json。 从这里,我们得到能够设置为内容的innerHTML的最终对象。 正如我们从这两个对象的基本示例中所看到的,Fetch API 具有与XMLHttpRequest几乎完全相同的功能,但它是一种更容易理解的格式,我们可以轻松地使用该 API。

承诺

正如我们在前面的fetch例子中看到的,我们使用了一个叫做 promise 的东西。 一个简单的方法来思考承诺是我们在未来想要的价值,而返回给我们的是一份合同,上面写着我稍后将把它交给你。 承诺是基于回调的概念。 如果我们看一个回调的例子,我们可以把XMLHttpRequest包裹起来,我们可以看到它是如何作为一个承诺的:

const makeRequest = function(loc, success, failure) {
    const oldReq = new XMLHttpRequest();
    oldReq.addEventListener('load', function(ev) {
        if( ev.target.status === 200 ) {
            success(ev.target.response);
        } else {
            failure(ev.target.response);
        }
    }, { once : true });
    oldReq.open('GET', loc);
    oldReq.setRequestHeader('Accept', 'application/json');
    oldReq.responseType = 'json';
    oldReq.send();
}

有了这个,我们得到几乎相同的功能,我们有一个承诺,但利用回调或函数,我们想要运行,当事情发生。 回调系统的问题被称为回调地狱。 这是高度异步代码总是有回调的想法,这意味着如果我们想要利用它,我们会有一个很棒的回调树视图。 这看起来像以下内容:

const fakeFetchRequest(url, (res) => {
    res.json((final) => {
        document.querySelector('#content').innerHTML = 
         JSON.stringify(final);
    });
});

如果fetch的 API 不是基于承诺的,那么这个fetch的假版本将是。 首先,我们要传递 URL。 我们还需要在响应返回时提供一个回调。 然后,我们需要将该响应传递给json方法,该方法还需要一个回调来将响应数据转换为json。 最后,我们将得到结果并将其放入 DOM 中。

正如我们所看到的,回调会导致相当多的问题。 相反,我们有这个承诺。 promise 在创建时接受一个参数,函数有两个参数:resolve 和 reject。 有了这些,我们可以通过resolve函数给调用者一个成功的返回,也可以使用reject函数出错。 这将允许我们通过then调用和catch调用将这些承诺链接在一起,就像我们在fetch示例中看到的那样。

然而,这些也会导致另一个问题。 我们可以得到一个巨大的承诺链,看起来比回调好一点,但也差不了多少。 然后我们得到async/await系统。 我们可以使用await来利用响应,而不是不断地将承诺与then链接起来。 然后我们可以将我们的fetch调用转换成如下所示:

(async function() {
    const res = await fetch('http://localhost:8081/sample');
    const final = await res.json();
    document.querySelector('#content').innerHTML = JSON.stringify(final);
})();

函数前面的async描述符告诉我们这是一个async函数。 如果我们没有这些,我们就不能利用await。 接下来,不用将then函数链接在一起,只需await函数即可。 其结果将被包装在我们的resolve函数中。 现在,我们有一些读起来很不错的东西。

我们确实需要小心async/await系统。 它确实会等待,所以如果我们把这个放到主线程中或者没有把它包装在别的东西中,它会阻塞主线程,导致我们锁住。 此外,如果我们有一堆想要同时运行的任务,而不是一次等待它们一个(使我们的代码顺序),我们可以使用Promise.all()。 这允许我们将一堆承诺放在一起,并允许它们异步运行。 等他们都回来了,我们就可以继续执行。

async/await系统的一个优点是,它实际上可以比使用通用承诺更快。 许多浏览器都对这些特定的关键字进行了优化,所以我们应该抓住每一个机会使用它们。

It has been stated before, but browser vendors are constantly making improvements to their implementations of the ECMAScript standard. This means that new technologies will be slow at first, but once they are in widespread use or they are agreed upon by all vendors, they will start to optimize and usually make them faster than their counterparts. When possible, utilize the newer technologies that browser vendors are giving us!

回到取回

现在我们已经看到了一个fetch请求的样子,我们应该看看如何抓取底层的可读流。 fetch系统已经增加了不少功能,其中两个是管道和流。 这可以在最近的许多 web api 中看到,可以观察到浏览器供应商已经注意到 Node.js 是如何利用流的。

如前一章所述,流是一种每次处理数据块的方法。 它也确保了我们不需要一次抓住整个有效载荷,相反,我们可以慢慢地增加有效载荷。 这意味着,如果我们必须转换数据,我们可以在数据块进入时进行动态转换。 这也意味着我们可以处理不常见的数据类型,比如 JSON 和纯文本。

我们将编写一个基本的TransformStream示例,该示例接受我们的输入,并对它进行简单的 ROT13 编码(ROT13 是一种非常基本的编码器,它接受我们得到的第 13 个字母后的第 13 个字母)。 我们将在后面更详细地介绍流(这些将是 Node.js 版本,但概念相对相似)。 示例如下所示:

class Rot13Transform {
    constructor() {
    }
    async transform(chunk, controller) {
        const _chunk = await chunk;
        const _newChunk = _chunk.map((item) => ((item - 65 + 13) % 26) + 
         65);
        controller.enqueue(_newChunk);
        return;
    }
}

fetch('http://localhost:8081/rot')
.then(response => response.body)
.then(res => res.pipeThrough(new TransformStream(new Rot13Transform())))
.then(res => new Response(res))
.then(response => response.text())
.then(final => document.querySelector('#content').innerHTML = final)
.catch(err => console.error(err));

让我们将这个示例分解为实际的TransformStream,然后是利用它的代码。 首先,我们创建一个类来存放我们的旋转代码。 然后我们需要一个名为transform的方法,它接受两个参数:块和控制器。 chunk 是我们将要得到的数据。

记住,这不会一次得到所有的数据,所以如果我们需要构建对象或类似的,我们需要为以前的数据创建一个可能的保存位置,如果当前块没有给我们想要的一切。 在我们的例子中,我们只是在底层字节上运行一个旋转方案,因此不需要一个临时占有者。

接下来,控制器是流控制和声明数据准备从(可读或转换流)读取或写入(可写流)的底层系统。 接下来我们等待一些数据,并将其放入一个临时变量中。 然后我们对每个字节运行一个简单的映射表达式,并将它们向右旋转 13,然后对它们进行 26 的修改。

ASCII convention has all the uppercase characters starting at 65. This is the reason for some of the math involved here as we are trying to get the number between 0 and 26 first, do the operations, and then move it back into the normal ASCII range.

一旦我们旋转了输入,我们就在控制器上对它进行排队。 这意味着数据可以从另一个流读取。 接下来,我们可以看看发生的一系列承诺。 首先,我们得到数据。 然后,通过获取fetch请求的主体,从ReadableStream请求中获取底层的ReadableStream。 然后我们使用一种叫做pipeThrough的方法。 管道机制自动为我们处理流量控制,所以它使我们的生活更容易时,与流体。

Flow control is vital to making streams work. It essentially tells other streams if we are backed up, don't send us any more data, or that we are good to keep receiving data. If we did not have this mechanism, we would constantly be having to keep our streams in check, which can be a real pain when we just want to focus on the logic that we want to incorporate.

我们将数据输送到一个新的TransformStream,该TransformStream采用我们的旋转逻辑。 这将把响应中的所有数据输送到我们的转换代码中,并确保它经过转换后输出。 然后我们将我们的ReadableStream包装在一个新的Response中,这样我们就可以像使用fetch请求中的任何其他Response对象一样使用它。 然后我们将数据作为普通文本获取并将其放入 DOM 中。

正如我们所看到的,这个例子展示了我们可以用流媒体系统做很多很酷的事情。 虽然 DOM API 仍在不断变化,但这些概念与 Node.js 中的流接口类似。 它还演示了如何为可能通过网络传输的更复杂的二进制类型(如 smile 格式)编写解码器。

停止获取请求

当发出请求时,我们可能想要做的一个动作是停止请求。 这可能有很多原因,比如:

  • 首先,如果我们在后台发出请求,并让用户更新POST请求的参数,我们可能想要停止当前的请求,并让他们发出新的请求。
  • 其次,请求可能花费的时间太长,我们希望确保停止请求,而不是挂起应用或使其进入未知状态。
  • 最后,我们可能有一个缓存机制,一旦我们缓存了大量的数据,我们想要使用它。 如果发生这种情况,我们希望停止任何挂起的请求,并让它切换到那个源。

这些原因中的任何一个都可以很好地停止请求,现在我们有一个 API 可以做到这一点。 AbortController系统允许我们停止这些请求。 结果是AbortController具有signal性质。 我们将这个signal附加到fetch请求上,当我们调用abort方法时,它会告诉fetch请求,我们不想让它继续执行请求。 它非常简单和直观。 示例如下:

(async function() {
    const controller = new AbortController();
    const signal = controller.signal;
    document.querySelector('#stop').addEventListener('click', (ev) => {
        controller.abort();
    });
    try {
        const res = await fetch('http://localhost:8081/longload', 
         {signal});
        const final = await res.text();
        document.querySelector('#content').innerHTML = final;
    } catch(e) {
        console.error('failed to download', e);
    }
})();

我们可以看到,我们已经建立了一个AbortController系统,并抓住了它的signal属性。 然后我们设置一个按钮,当单击该按钮时,将运行abort方法。 接下来,我们看到了典型的fetch请求,但是在选项内部,我们传递了signal。 现在,当我们单击按钮时,我们将看到请求停止时出现了一个 DOM 错误。 我们还看到了一些关于async/await的错误处理。 aysnc/await可以利用基本的try-catch语句来获取错误,这只是async/awaitAPI 使代码比回调和基于承诺的版本更具可读性的另一种方式。

This is another API that is experimental and will most likely have changes in the future. But, we did have this same type of idea in XMLHttpRequest and it makes sense that the Fetch API will be getting it also. Just note that the MDN website is the best place to get up-to-date information on what browsers support and more documentation on any of the experimental APIs that we have discussed and will discuss in future chapters.

fetch和 promise 系统是从服务器获取数据和展示处理异步通信的新方法的好方法。 虽然我们过去不得不使用回调和一些难看的对象,但我们现在有一个很好的流线型 API,非常容易使用。 尽管 API 的某些部分在不断变化,但请注意,这些系统很可能会以某种方式就位。

总结

在本章中,我们见证了浏览器环境在过去 5 年中发生了多大的变化。 有了增强了我们编码方式的新 api,通过 DOM api,我们可以编写带有内置控件的富 ui,现在我们的应用可以变得尽可能普通。 这包括获取外部数据的使用,以及新的异步 api,如 promise 和async/await系统。

在下一章中,我们将关注一个库,它专注于输出普通的 JavaScript,并为我们提供一个无运行时的应用环境。 在讨论节点和工作人员时,我们还将在书的其余部分结合大多数现代 api。 尝试一下这些系统并适应它们,因为我们才刚刚起步。****