0%

现代 JavaScript 教程任务题解一

现代 JavaScript 教程任务题解一

字符串

任务

  1. 首字母大写

    写一个函数 ucFirst(str),并返回首字母大写的字符串 str,例如:

    ucFirst("john") == "John";
    function ucFirst(str) {
      if (!str) return str;
      return str[0].toUpperCase() + str.slice(1);
    }
  2. 检查 spam

    写一个函数 checkSpam(str),如果 str 包含 viagraXXX 就返回 true,否则返回 false

    函数必须不区分大小写:

    checkSpam('buy ViAgRA now') == true
    checkSpam('free xxxxx') == true
    checkSpam("innocent rabbit") == false
    function checkSpam(str) {
      if (str.toLowerCase().includes('viagra') || str.toUpperCase().includes('XXX')) return true;
      return false;
    }
  3. 截断文本

    创建函数 truncate(str, maxlength) 来检查 str 的长度,如果超过 maxlength —— 应使用 "…" 来代替 str 的结尾部分,长度仍然等于 maxlength

    函数的结果应该是截断后的文本(如果需要的话)。

    例如:

    truncate("What I'd like to tell on this topic is:", 20) = "What I'd like to te…"
    
    truncate("Hi everyone!", 20) = "Hi everyone!"
    function truncate(str, maxlength) {
      if (str.length > maxlength) {
        return str.slice(0, maxlength - 1) + '…';
      }
      return str;
    }
  4. 提取货币

    我们有以 "$120" 这样的格式表示的花销。意味着:先是美元符号,然后才是数值。

    创建函数 extractCurrencyValue(str) 从字符串中提取数值并返回。

    例如:

    alert( extractCurrencyValue('$120') === 120 ); // true
    function extractCurrencyValue(str) {
      return +str.slice(1);
    }

数组

任务

  1. 数组被拷贝了吗?

    下面的代码将会显示什么?

    let fruits = ["Apples", "Pear", "Orange"];
    
    // 在“副本”里 push 了一个新的值
    let shoppingCart = fruits;
    shoppingCart.push("Banana");
    
    // fruits 里面是什么?
    alert( fruits.length ); // ?

    数组的赋值是引用赋值,所以输出是 4 。

  2. 数组操作

    我们试试下面的 5 个数组操作。

    1. 创建一个数组 styles,里面存储有 “Jazz” 和 “Blues”。
    2. 将 “Rock-n-Roll” 从数组末端添加进去。
    3. 用 “Classics” 替换掉数组最中间的元素。查找数组最中间的元素的代码应该适用于任何奇数长度的数组。
    4. 去掉数组的第一个值并显示它。
    5. 在数组前面添加 RapReggae

    过程中的数组:

    Jazz, Blues
    Jazz, Bues, Rock-n-Roll
    Jazz, Classics, Rock-n-Roll
    Classics, Rock-n-Roll
    Rap, Reggae, Classics, Rock-n-Roll。
    let styles = ['Jazz', 'Blues'];
    styles.push('Rock-n-Roll');
    styles[Math.floor((styles.length - 1) / 2)] = 'Classics';
    console.log(styles.shift());
    styles.unshift('Rap', 'Reggae');
  3. 在数组上下文调用

    结果是什么?为什么?

    let arr = ["a", "b"];
    
    arr.push(function() {
      alert( this );
    })
    
    arr[2](); // ?

    数组对象。获取该函数的形式类似于对象的方法引用,然后调用方法。

  4. 输入数字求和

    写出函数 sumInput(),要求如下:

    • 使用 prompt 向用户索要值,并存在数组中。
    • 当用户输入了非数字、空字符串或者点击“取消”按钮的时候,问询结束。
    • 计算并返回数组所有项之和。

    P.S. 0 是有效的数字,不要因为是 0 就停止问询。

    function sumInput() {
      let numbers = [];
      while (true) {
        let value = prompt("A number please?", 0);
        // 应该结束了吗?
        if (value === "" || value === null || !isFinite(value)) break;
        numbers.push(+value);
      }
      let sum = 0;
      for (let number of numbers) {
        sum += number;
      }
      return sum;
    }
    alert( sumInput() );
  5. 最大子数组

    输入是以数字组成的数组,例如 arr = [1, -2, 3, 4, -9, 6].

    任务是:找出所有项的和最大的 arr 数组的连续子数组。

    写出函数 getMaxSubSum(arr),用其找出并返回最大和。

    例如:

    getMaxSubSum([-1, 2, 3, -9]) == 5(高亮项的加和)
    getMaxSubSum([2, -1, 2, 3, -9]) == 6
    getMaxSubSum([-1, 2, 3, -9, 11]) == 11
    getMaxSubSum([-2, -1, 1, 2]) == 3
    getMaxSubSum([100, -9, 2, -3, 5]) == 100
    getMaxSubSum([1, 2, 3]) == 6(所有项的和)

    如果所有项都是负数,那就一个项也不取(子数组是空的),所以返回的是 0:

    getMaxSubSum([-1, -2, -3]) = 0

    请尝试想出一个快速的解决方案:复杂度可以是 O(n2),有能力达到 O(n) 则更好。

    function getMaxSubSum(arr) {
      let len = arr.length;
      let result = 0;
      for (let i = 0; i < len; i++) {
        for (let j = i + 1; j <= len; j++) {
          let tmp = arr.slice(i, j);
          let sum = tmp.reduce((item, prev) => prev + item);
          if (sum > result) result = sum;
        }
      }
      return result;
    }
    function getMaxSubSum(arr) {
      let result = 0;
      let tmp = 0;
      for (let item of arr) {
        tmp += item;
        if (tmp > result) result = tmp;
        if (tmp < 0) tmp = 0;
      }
      return result;
    }

数组方法

任务

  1. 将 border-left-width 转换成 borderLeftWidth

    编写函数 camelize(str) 将诸如 “my-short-string” 之类的由短划线分隔的单词变成骆驼式的 “myShortString”。

    即:删除所有短横线,并将短横线后的每一个单词的首字母变为大写。

    示例:

    camelize("background-color") == 'backgroundColor';
    camelize("list-style-image") == 'listStyleImage';
    camelize("-webkit-transition") == 'WebkitTransition';

    提示:使用 split 将字符串拆分成数组,对其进行转换之后再 join 回来。

    function camelize(str) {
      let arr = str.split('-');
      for (let i in arr) {
        if (i === '0') continue;
        arr[i] = arr[i][0].toUpperCase() + arr[i].slice(1);
      }
      return arr.join('');
    }
  2. 过滤范围

    写一个函数 filterRange(arr, a, b),该函数获取一个数组 arr,在其中查找数值大小在 ab 之间的元素,并返回它们的数组。

    该函数不应该修改原数组。它应该返回新的数组。

    例如:

    let arr = [5, 3, 8, 1];
    
    let filtered = filterRange(arr, 1, 4);
    
    alert( filtered ); // 3,1(匹配值)
    
    alert( arr ); // 5,3,8,1(未修改)
    function filterRange(arr, a, b) {
      return arr.filter(item => item <= b && item >= a);
    }
  3. 原位 ( in place ) 过滤范围

    写一个函数 filterRangeInPlace(arr, a, b),该函数获取一个数组 arr,并删除其中介于 ab 区间以外的所有值。检查:a ≤ arr[i] ≤ b

    该函数应该只修改数组。它不应该返回任何东西。

    例如:

    let arr = [5, 3, 8, 1];
    
    filterRangeInPlace(arr, 1, 4); // 删除了范围在 1 到 4 之外的所有值
    
    alert( arr ); // [3, 1]
    function filterRangeInPlace(arr, a, b) {
      arr.forEach((item, index) => {
        if (item > b || item < a) {
          arr.splice(index, 1);
        }
      });
    }
  4. 降序排列

    let arr = [5, 2, 1, -10, 8];
    
    // ……你的代码以降序对其进行排序
    
    alert( arr ); // 8, 5, 2, 1, -10
    let arr = [5, 2, 1, -10, 8];
    
    arr.sort((a, b) => b - a);
    
    console.log(arr); // 8, 5, 2, 1, -10
  5. 复制和排序数组

    我们有一个字符串数组 arr。我们希望有一个排序过的副本,但保持 arr 不变。

    创建一个函数 copySorted(arr) 返回这样一个副本。

    let arr = ["HTML", "JavaScript", "CSS"];
    
    let sorted = copySorted(arr);
    
    alert( sorted ); // CSS, HTML, JavaScript
    alert( arr ); // HTML, JavaScript, CSS (no changes)
    function copySorted(arr) {
      return arr.map(item => item).sort();
    }
  6. 创建一个可扩展的 calculator

    创建一个构造函数 Calculator,以创建“可扩展”的 calculator 对象。

    该任务由两部分组成。

    1. 首先,实现 calculate(str) 方法,该方法接受像 "1 + 2" 这样格式为“数字 运算符 数字”(以空格分隔)的字符串,并返回结果。该方法需要能够理解加号 + 和减号 -

      用法示例:

      let calc = new Calculator;
      
      alert( calc.calculate("3 + 7") ); // 10
    2. 然后添加方法 addMethod(name, func),该方法教 calculator 进行新操作。它需要运算符 name 和实现它的双参数函数 func(a,b)

      例如,我们添加乘法 *,除法 / 和求幂 **

      let powerCalc = new Calculator;
      powerCalc.addMethod("*", (a, b) => a * b);
      powerCalc.addMethod("/", (a, b) => a / b);
      powerCalc.addMethod("**", (a, b) => a ** b);
      
      let result = powerCalc.calculate("2 ** 3");
      alert( result ); // 8
    • 此任务中没有括号或复杂的表达式。
    • 数字和运算符之间只有一个空格。
    • 你可以自行选择是否添加错误处理功能。
    function Calculator() {
      this.method = {
        '+': (a, b) => a + b,
        '-': (a, b) => a - b
      };
      this.calculate = function (str) {
        let arr = str.split(' ');
        let op = arr[1];
        let result = this.method[op](+arr[0], +arr[2]);
        return result;
      };
      this.addMethod = function (name, func) {
        this.method[name] = func;
      };
    }
    // 参考解决方案
    function Calculator() {
    
      this.methods = {
        "-": (a, b) => a - b,
        "+": (a, b) => a + b
      };
    
      this.calculate = function(str) {
    
        let split = str.split(' '),
          a = +split[0],
          op = split[1],
          b = +split[2]
    
        if (!this.methods[op] || isNaN(a) || isNaN(b)) {
          return NaN;
        }
    
        return this.methods[op](a, b);
      }
    
      this.addMethod = function(name, func) {
        this.methods[name] = func;
      };
    }
  7. 映射到 names

    你有一个 user 对象数组,每个对象都有 user.name。编写将其转换为 names 数组的代码。

    例如:

    let john = { name: "John", age: 25 };
    let pete = { name: "Pete", age: 30 };
    let mary = { name: "Mary", age: 28 };
    
    let users = [ john, pete, mary ];
    
    let names = /* ... your code */
    
    alert( names ); // John, Pete, Mary
    let names = users.map(item => item.name);
  8. 映射到对象

    你有一个 user 对象数组,每个对象都有 namesurnameid

    编写代码以该数组为基础,创建另一个具有 idfullName 的对象数组,其中 fullNamenamesurname 生成。

    例如:

    let john = { name: "John", surname: "Smith", id: 1 };
    let pete = { name: "Pete", surname: "Hunt", id: 2 };
    let mary = { name: "Mary", surname: "Key", id: 3 };
    
    let users = [ john, pete, mary ];
    
    let usersMapped = /* ... your code ... */
    
    /*
    usersMapped = [
      { fullName: "John Smith", id: 1 },
      { fullName: "Pete Hunt", id: 2 },
      { fullName: "Mary Key", id: 3 }
    ]
    */
    
    alert( usersMapped[0].id ) // 1
    alert( usersMapped[0].fullName ) // John Smith

    所以,实际上你需要将一个对象数组映射到另一个对象数组。在这儿尝试使用箭头函数 => 来编写。

    let usersMapped = users.map(item => ({
      id: item.id,
      fullName: item.name + ' ' + item.surname
    }));
  9. 按年龄对用户排序

    编写函数 sortByAge(users) 获得对象数组的 age 属性,并根据 age 对这些对象数组进行排序。

    例如:

    let john = { name: "John", age: 25 };
    let pete = { name: "Pete", age: 30 };
    let mary = { name: "Mary", age: 28 };
    
    let arr = [ pete, john, mary ];
    
    sortByAge(arr);
    
    // now: [john, mary, pete]
    alert(arr[0].name); // John
    alert(arr[1].name); // Mary
    alert(arr[2].name); // Pete
    function sortByAge(arr) {
      arr.sort((a, b) => a.age - b.age);
    }
  10. 随机排列数组

    编写函数 shuffle(array) 来随机排列数组的元素。

    多次运行 shuffle 可能导致元素顺序的不同。例如:

    let arr = [1, 2, 3];
    
    shuffle(arr);
    // arr = [3, 2, 1]
    
    shuffle(arr);
    // arr = [2, 1, 3]
    
    shuffle(arr);
    // arr = [3, 1, 2]
    // ...

    所有元素顺序应该具有相等的概率。例如,可以将 [1,2,3] 重新排序为 [1,2,3][1,3,2][3,1,2] 等,每种情况的概率相等。

    function shuffle(arr) {
      arr.sort((a, b) => (Math.random() > 0.5 ? 1 : -1));
    }
  11. 获取平均年龄

    编写 getAverageAge(users) 函数,该函数获取一个具有 age 属性的对象数组,并返回平均年龄。

    平均值的计算公式是 (age1 + age2 + ... + ageN) / N

    例如:

    let john = { name: "John", age: 25 };
    let pete = { name: "Pete", age: 30 };
    let mary = { name: "Mary", age: 29 };
    
    let arr = [ john, pete, mary ];
    
    alert( getAverageAge(arr) ); // (25 + 30 + 29) / 3 = 28
    function getAverageAge(arr) {
      return arr.reduce((prev, item) => prev + item.age, 0) / arr.length;
    }
  12. 数组去重

    arr 是一个数组。

    创建一个函数 unique(arr),返回去除重复元素后的数组 arr

    例如:

    function unique(arr) {
      /* your code */
    }
    
    let strings = ["Hare", "Krishna", "Hare", "Krishna",
      "Krishna", "Krishna", "Hare", "Hare", ":-O"
    ];
    
    alert( unique(strings) ); // Hare, Krishna, :-O
    function unique(arr) {
      return Array.from(new Set(arr));
    }
  13. 从数组创建键 ( 值 ) 对象

    假设我们收到了一个用户数组,形式为:{id:..., name:..., age... }

    创建一个函数 groupById(arr) 从该数组创建对象,以 id 为键(key),数组项为值。

    例如:

    let users = [
      {id: 'john', name: "John Smith", age: 20},
      {id: 'ann', name: "Ann Smith", age: 24},
      {id: 'pete', name: "Pete Peterson", age: 31},
    ];
    
    let usersById = groupById(users);
    
    /*
    // 调用函数后,我们应该得到:
    
    usersById = {
      john: {id: 'john', name: "John Smith", age: 20},
      ann: {id: 'ann', name: "Ann Smith", age: 24},
      pete: {id: 'pete', name: "Pete Peterson", age: 31},
    }
    */

    处理服务端数据时,这个函数很有用。

    在这个任务里我们假设 id 是唯一的。没有两个具有相同 id 的数组项。

    请在解决方案中使用数组的 .reduce 方法。

    function groupById(users) {
      return users.reduce((obj, item) => {
        obj[item.id] = item;
        return obj;
      }, {});
    }

类继承

任务

  1. 创建实例时出错

    这里有一份 Rabbit 扩展 Animal 的代码。

    不幸的是,Rabbit 对象无法被创建。是哪里出错了呢?请解决它。

    class Animal {
    
      constructor(name) {
        this.name = name;
      }
    
    }
    
    class Rabbit extends Animal {
      constructor(name) {
        this.name = name;
        this.created = Date.now();
      }
    }
    
    let rabbit = new Rabbit("White Rabbit"); // Error: this is not defined
    alert(rabbit.name);

    没有调用 super

  2. 扩展 clock

    我们获得了一个 Clock 类。到目前为止,它每秒都会打印一次时间。

    class Clock {
      constructor({ template }) {
        this.template = template;
      }
    
      render() {
        let date = new Date();
    
        let hours = date.getHours();
        if (hours < 10) hours = '0' + hours;
    
        let mins = date.getMinutes();
        if (mins < 10) mins = '0' + mins;
    
        let secs = date.getSeconds();
        if (secs < 10) secs = '0' + secs;
    
        let output = this.template
          .replace('h', hours)
          .replace('m', mins)
          .replace('s', secs);
    
        console.log(output);
      }
    
      stop() {
        clearInterval(this.timer);
      }
    
      start() {
        this.render();
        this.timer = setInterval(() => this.render(), 1000);
      }
    }

    创建一个继承自 Clock 的新的类 ExtendedClock,并添加参数 precision — 每次 “ticks” 之间间隔的毫秒数,默认是 1000(1 秒)。

    • 你的代码应该在 extended-clock.js 文件里。
    • 不要修改原有的 clock.js。请扩展它。
    class ExtendedClock extends Clock {
      constructor({ template, precision=1000 }) {
        super({ template });
        this.precision = precision;
      }
      start() {
        this.render();
        this.timer = setInterval(() => this.render(), this.precision);
      }
    }

Promise

任务

  1. 基于 promise 的延时

    内建函数 setTimeout 使用了回调函数。请创建一个基于 promise 的替代方案。

    函数 delay(ms) 应该返回一个 promise。这个 promise 应该在 ms 毫秒后被 resolve,所以我们可以向其中添加 .then,像这样:

    function delay(ms) {
      // 你的代码
    }
    
    delay(3000).then(() => alert('runs after 3 seconds'));
    function delay(ms) {
      return new Promise((resolve, reject) => {
        setTimeout(resolve, ms);
      });
    }

Async/await

任务

  1. 用 async/await 来重写

    重写下面这个来自 Promise 链 一章的示例代码,使用 async/await 而不是 .then/catch

    function loadJson(url) {
      return fetch(url)
        .then(response => {
          if (response.status == 200) {
            return response.json();
          } else {
            throw new Error(response.status);
          }
        });
    }
    
    loadJson('no-such-user.json')
      .catch(alert); // Error: 404
    async function loadJson(url) {
      let response = await fetch(url);
      if (response.status == 200) {
        let json = await response.json();
        return json;
      }
      return throw new Error(response.status);
    }
    
    loadJson('no-such-user.json').catch(alert); // Error: 404
  2. 使用 async/await 重写 “rethrow”

    下面你可以看到 “rethrow” 的例子。让我们来用 async/await 重写它,而不是使用 .then/catch

    同时,我们可以在 demoGithubUser 中使用循环以摆脱递归:在 async/await 的帮助下很容易实现。

    class HttpError extends Error {
      constructor(response) {
        super(`${response.status} for ${response.url}`);
        this.name = 'HttpError';
        this.response = response;
      }
    }
    
    function loadJson(url) {
      return fetch(url)
        .then(response => {
          if (response.status == 200) {
            return response.json();
          } else {
            throw new HttpError(response);
          }
        });
    }
    
    // 询问用户名,直到 github 返回一个合法的用户
    function demoGithubUser() {
      let name = prompt("Enter a name?", "iliakan");
    
      return loadJson(`https://api.github.com/users/${name}`)
        .then(user => {
          alert(`Full name: ${user.name}.`);
          return user;
        })
        .catch(err => {
          if (err instanceof HttpError && err.response.status == 404) {
            alert("No such user, please reenter.");
            return demoGithubUser();
          } else {
            throw err;
          }
        });
    }
    
    demoGithubUser();
    // ...
    
    async function loadJson(url) {
      let response = await fetch(url);
      if (response.status == 200) {
        let json = await response.json();
        return json;
      } else {
        throw new HttpError(response);
      }
    }
    // 询问用户名,直到 github 返回一个合法的用户
    async function demoGithubUser() {
      let user;
      while (true) {
        let name = prompt('Enter a name?', 'iliakan');
        try {
          user = await loadJson(`https://api.github.com/users/${name}`);
          break;
        } catch (err) {
          if (err instanceof HttpError && err.response.status == 404) {
            alert('No such user, please reenter.');
          } else {
            throw err;
          }
        }
      }
    }
    
    // ...
  3. 在非 async 函数中调用 async 函数

    我们有一个名为 f 的“普通”函数。你会怎样调用 async 函数 wait() 并在 f 中使用其结果?

    async function wait() {
      await new Promise(resolve => setTimeout(resolve, 1000));
    
      return 10;
    }
    
    function f() {
      // ……这里你应该怎么写?
      // 我们需要调用 async wait() 并等待以拿到结果 10
      // 记住,我们不能使用 "await"
    }

    P.S. 这个任务其实很简单,但是对于 async/await 新手开发者来说,这个问题却很常见。

    function f() {
      wait().then(res => console.log(res));
    }

Generator

任务

  1. 伪随机 generator

    在很多地方我们都需要随机数据。

    其中之一就是测试。我们可能需要随机数据:文本,数字等,以便很好地进行测试。

    在 JavaScript 中,我们可以使用 Math.random()。但是如果什么地方出现了问题,我们希望能使用完全相同的数据进行重复测试。

    为此,我们可以使用所谓的“种子伪随机(seeded pseudo-random)generator”。它们将“种子(seed)”作为第一个值,然后使用公式生成下一个值。以便相同的种子(seed)可以产出(yield)相同的序列,因此整个数据流很容易复现。我们只需要记住种子并重复它即可。

    这样的公式的一个示例如下,它可以生成一些均匀分布的值:

    next = previous * 16807 % 2147483647

    如果我们使用 1 作为种子,生成的值将会是:

    1. 16807
    2. 282475249
    3. 1622650073
    4. ……等……

    这里的任务是创建一个 generator 函数 pseudoRandom(seed),它将 seed 作为参数并使用此公式创建 generator。

    使用范例:

    let generator = pseudoRandom(1);
    
    alert(generator.next().value); // 16807
    alert(generator.next().value); // 282475249
    alert(generator.next().value); // 1622650073
    function* pseudoRandom(seed) {
      let previous = seed;
      while (true) {
        previous = (previous * 16807) % 2147483647;
        yield previous;
      }
    }

Proxy 和 Reflect

任务

  1. 读取不存在的属性时出错

    通常,尝试读取不存在的属性会返回 undefined

    创建一个代理,在尝试读取不存在的属性时,该代理抛出一个错误。

    这可以帮助及早发现编程错误。

    编写一个函数 wrap(target),该函数接受一个 target 对象,并返回添加此方面功能的代理(proxy)。

    其工作方式应如下:

    let user = {
      name: "John"
    };
    
    function wrap(target) {
      return new Proxy(target, {
          /* 你的代码 */
      });
    }
    
    user = wrap(user);
    
    alert(user.name); // John
    alert(user.age); // ReferenceError: Property doesn't exist: "age"
    function wrap(target) {
      return new Proxy(target, {
        get(target, prop, receiver) {
          if (prop in target) {
            return Reflect.get(...arguments);
          } else {
            throw new ReferenceError(`Property doesn't exist: "${prop}"`);
          }
        }
      });
    }
  2. 访问 array[-1]

    在某些编程语言中,我们可以使用从尾端算起的负值索引访问数组元素。

    像这样:

    let array = [1, 2, 3];
    
    array[-1]; // 3,最后一个元素
    array[-2]; // 2,从尾端开始向前移动一步
    array[-3]; // 1,从尾端开始向前移动两步

    换句话说,array[-N]array[array.length - N] 相同。

    创建一个 proxy 来实现该行为。

    其工作方式应如下:

    let array = [1, 2, 3];
    
    array = new Proxy(array, {
      /* 你的代码 */
    });
    
    alert( array[-1] ); // 3
    alert( array[-2] ); // 2
    
    // 其他数组功能应保持“原样”
    array = new Proxy(array, {
      get(target, prop, receiver) {
        if (prop < 0) {
          prop = +target.length + +prop;
        }
        return Reflect.get(target, prop, receiver);
      }
    });
  3. 可观察的 (Observable)

    创建一个函数 makeObservable(target),该函数通过返回一个代理“使得对象可观察”。

    其工作方式如下:

    function makeObservable(target) {
      /* 你的代码 */
    }
    
    let user = {};
    user = makeObservable(user);
    
    user.observe((key, value) => {
      alert(`SET ${key}=${value}`);
    });
    
    user.name = "John"; // alerts: SET name=John

    换句话说,makeObservable 返回的对象就像原始对象一样,但是具有 observe(handler) 方法,该方法可以将 handler 函数设置为在任何属性被更改时,都会被调用的函数。

    每当有属性被更改时,都会使用属性的名称和属性值调用 handler(key, value) 函数。

    P.S. 在本任务中,你可以只关注属性写入。其他的操作可以通过类似的方式实现。

    function makeObservable(target) {
      const cbList = [];
      let res = new Proxy(target, {
        set(target, prop, value, receiver) {
          cbList.forEach(cb => cb(prop, value));
          return Reflect.set(target, prop, value, receiver);
        }
      });
      res.observe = function (cb) {
        cbList.push(cb);
      };
      return res;
    }
    // 参考解法
    let handlers = Symbol('handlers');
    
    function makeObservable(target) {
      // 1. 初始化 handler 存储
      target[handlers] = [];
    
      // 将 handler 函数存储到数组中,以便于之后调用
      target.observe = function(handler) {
        this[handlers].push(handler);
      };
    
      // 2. 创建一个 proxy 以处理更改
      return new Proxy(target, {
        set(target, property, value, receiver) {
          let success = Reflect.set(...arguments); // 将操作转发给对象
          if (success) { // 如果在设置属性时没有出现 error
            // 调用所有 handler
            target[handlers].forEach(handler => handler(property, value));
          }
          return success;
        }
      });
    }

节点属性:type,tag 和 content

任务

  1. 计数后代

    这里有一个树结构嵌套的 ul/li

    编写代码,为每个 <li> 显示:

    1. 里面的文本内容是什么(没有子树)
    2. 嵌套的 <li> 的数量 — 所有后代,包括深层嵌套的后代。

    在新窗口中演示

    // 本人稀烂的解法
    function isLi(el) {
      const tagName = el.tagName;
      if (tagName && tagName.toLowerCase() === 'li') return true;
      return false;
    }
    function countNumOfLi(el) {
      if (!el) return 0;
      let children = el.children;
      let count = 0;
      for (let i = 0; i < children.length; i++) {
        let item = children[i];
        if (isLi(item)) {
          count++;
        }
        count += countNumOfLi(item);
      }
      return count;
    }
    function handler(list) {
      for (let i = 0; i < list.length; i++) {
        let item = list[i];
        let count = countNumOfLi(item);
        // console.dir(item);
        if (isLi(item)) {
          console.log(item.childNodes[0].data.trim() + count);
        }
        if (item.children) {
          handler(item.children);
        }
      }
    }
    let ul = document.querySelector('ul');
    let lis = ul.children;
    handler(lis);
    for (let li of document.querySelectorAll('li')) {
      // get the title from the text node
      let title = li.firstChild.data;
    
      title = title.trim(); // remove extra spaces from ends
    
      // get the descendants count
      let count = li.getElementsByTagName('li').length;
    
      alert(title + ': ' + count);
    }

特性和属性(Attributes and properties)

任务

  1. 将外部链接设为橙色

    通过修改 style 属性,将所有外部链接变为橙色。

    如果一个链接是外部的:

    • href 中包含 ://
    • 但不是以 http://internal.com 开头。

    例如:

    <a name="list">the list</a>
    <ul>
      <li><a href="http://google.com">http://google.com</a></li>
      <li><a href="/tutorial">/tutorial.html</a></li>
      <li><a href="local/path">local/path</a></li>
      <li><a href="ftp://ftp.com/my.zip">ftp://ftp.com/my.zip</a></li>
      <li><a href="http://nodejs.org">http://nodejs.org</a></li>
      <li><a href="http://internal.com/test">http://internal.com/test</a></li>
    </ul>
    
    <script>
      // 为单个链接设置样式
      let link = document.querySelector('a');
      link.style.color = 'orange';
    </script>

    结果应该是:

    image-20210206122607264

    // 1
    let links = document.querySelectorAll('a');
    
    for (let link of links) {
      let href = link.getAttribute('href');
      if (!href) continue; // 没有特性
    
      if (!href.includes('://')) continue; // 没有协议
    
      if (href.startsWith('http://internal.com')) continue; // 内部的
    
      link.style.color = 'orange';
    }
    // 2
    // 查找所有 href 中包含 :// 的链接
    // 但 href 不是以 http://internal.com 开头
    let selector = 'a[href*="://"]:not([href^="http://internal.com"])';
    let links = document.querySelectorAll(selector);
    
    links.forEach(link => link.style.color = 'orange');

修改文档 (document)

任务

  1. 从对象创建树

    编写一个函数 createTree,从嵌套对象创建一个嵌套的 ul/li 列表(list)。

    例如:

    let data = {
      "Fish": {
        "trout": {},
        "salmon": {}
      },
    
      "Tree": {
        "Huge": {
          "sequoia": {},
          "oak": {}
        },
        "Flowering": {
          "apple tree": {},
          "magnolia": {}
        }
      }
    };

    语法:

    let container = document.getElementById('container');
    createTree(container, data); // 将树创建在 container 中

    结果(树)看起来像这样:

    选择下面两种方式中的一种,来完成这个任务:

    1. 为树创建 HTML,然后将它们赋值给 container.innerHTML
    2. 创建节点树,并使用 DOM 方法将它们附加(append)上去。

    如果这两种方式你都做,那就太好了。

    P.S. 树上不应该有“多余”的元素,例如空的 <ul></ul> 叶子节点。

    function createTree(container, data) {
      const ul = document.createElement('ul');
      for (let key in data) {
        const value = data[key];
        const li = document.createElement('li');
        li.append(key);
        createTree(li, value);
        ul.append(li);
      }
      container.append(ul);
      return ul;
    }

    参考解法:

    // innerHTML
    function createTree(container, obj) {
      container.innerHTML = createTreeText(obj);
    }
    
    function createTreeText(obj) { // standalone recursive function
      let li = '';
      let ul;
      for (let key in obj) {
        li += '<li>' + key + createTreeText(obj[key]) + '</li>';
      }
      if (li) {
        ul = '<ul>' + li + '</ul>'
      }
      return ul || '';
    }
    // DOM
    function createTree(container, obj) {
      container.append(createTreeDom(obj));
    }
    
    function createTreeDom(obj) {
      // if there's no children, then the call returns undefined
      // and the <ul> won't be created
      if (!Object.keys(obj).length) return;
      let ul = document.createElement('ul');
      for (let key in obj) {
        let li = document.createElement('li');
        li.innerHTML = key;
    
        let childrenUl = createTreeDom(obj[key]);
        if (childrenUl) {
          li.append(childrenUl);
        }
        ul.append(li);
      }
      return ul;
    }
  2. 在树中显示后代

    这里有一棵由嵌套的 ul/li 组成的树。

    编写代码,为每个 <li> 添加其后代数量。跳过叶子节点(没有子代的节点)。

    结果:

    const lis = document.querySelectorAll('li');
    for (let li of lis) {
      const childLen = li.getElementsByTagName('li').length;
      if (childLen === 0) continue;
      const first = li.firstChild;
      const text = first.textContent.trim() + `[${childLen}]`;
      first.data = text;
    }

    参考解法:

    let lis = document.getElementsByTagName('li');
    
    for (let li of lis) {
      // get the count of all <li> below this <li>
      let descendantsCount = li.getElementsByTagName('li').length;
      if (!descendantsCount) continue;
    
      // add directly to the text node (append to the text)
      li.firstChild.data += ' [' + descendantsCount + ']';
    }
  3. 创建一个日历

    编写一个函数 createCalendar(elem, year, month)

    对该函数的调用,应该使用给定的 year/month 创建一个日历,并将创建的日历放入 elem 中。

    创建的日历应该是一个表格(table),其中每一周用 <tr> 表示,每一天用 <td> 表示。表格顶部应该是带有星期名的 <th>:第一天应该是 Monday,依此类推,直到 Sunday。

    例如,createCalendar(cal, 2012, 9) 应该在元素 cal 中生成如下所示的日历:

    P.S. 在这个任务中,生成一个日历就可以了,不需要有点击交互的功能。

    参考解法:

    function createCalendar(elem, year, month) {
    
      let mon = month - 1; // months in JS are 0..11, not 1..12
      let d = new Date(year, mon);
    
      let table = '<table><tr><th>MO</th><th>TU</th><th>WE</th><th>TH</th><th>FR</th><th>SA</th><th>SU</th></tr><tr>';
    
      // spaces for the first row
      // from Monday till the first day of the month
      // * * * 1  2  3  4
      for (let i = 0; i < getDay(d); i++) {
        table += '<td></td>';
      }
    
      // <td> with actual dates
      while (d.getMonth() == mon) {
        table += '<td>' + d.getDate() + '</td>';
    
        if (getDay(d) % 7 == 6) { // sunday, last day of week - newline
          table += '</tr><tr>';
        }
    
        d.setDate(d.getDate() + 1);
      }
    
      // add spaces after last days of month for the last row
      // 29 30 31 * * * *
      if (getDay(d) != 0) {
        for (let i = getDay(d); i < 7; i++) {
          table += '<td></td>';
        }
      }
    
      // close the table
      table += '</tr></table>';
    
      elem.innerHTML = table;
    }
    
    function getDay(date) { // get day number from 0 (monday) to 6 (sunday)
      let day = date.getDay();
      if (day == 0) day = 7; // make Sunday (0) the last day
      return day - 1;
    }
  4. 使用 setInterval 的彩色时钟

    创建一个像这样的彩色时钟:

    使用 HTML/CSS 进行样式设计,JavaScript 仅用来更新元素中的时间。

    <div id="clock">
      <span class="hour">hh</span>:<span class="min">mm</span>:<span class="sec">ss</span>
    </div>
   function update() {
     let clock = document.getElementById('clock');
     let date = new Date(); // (*)
     let hours = date.getHours();
     if (hours < 10) hours = '0' + hours;
     clock.children[0].innerHTML = hours;

     let minutes = date.getMinutes();
     if (minutes < 10) minutes = '0' + minutes;
     clock.children[1].innerHTML = minutes;

     let seconds = date.getSeconds();
     if (seconds < 10) seconds = '0' + seconds;
     clock.children[2].innerHTML = seconds;
   }
   let timerId;

   function clockStart() { // 运行时钟
     timerId = setInterval(update, 1000);
     update(); // (*)
   }

   function clockStop() {
     clearInterval(timerId);
     timerId = null;
   }
  1. 对表格进行排序

    下面是一个表格:

    <table>
    <thead>
      <tr>
        <th>Name</th><th>Surname</th><th>Age</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>John</td><td>Smith</td><td>10</td>
      </tr>
      <tr>
        <td>Pete</td><td>Brown</td><td>15</td>
      </tr>
      <tr>
        <td>Ann</td><td>Lee</td><td>5</td>
      </tr>
      <tr>
        <td>...</td><td>...</td><td>...</td>
      </tr>
    </tbody>
    </table>

    可能会有更多行。

    编写代码,按 "name" 列对其进行排序。

    let sortedRows = Array.from(table.tBodies[0].rows) // 1
      .sort((rowA, rowB) => rowA.cells[0].innerHTML.localeCompare(rowB.cells[0].innerHTML));
    
    table.tBodies[0].append(...sortedRows); // (3)

样式和类

任务

  1. 创建一个通知

    编写一个函数 showNotification(options):该函数创建一个带有给定内容的通知 <div class="notification">。该通知应该在 1.5 秒后自动消失。

    参数:

    // 在窗口的右上角附近显示一个带有文本 "Hello" 的元素
    showNotification({
      top: 10, // 距窗口顶部 10px(默认为 0px)
      right: 10, // 距窗口右边缘 10px(默认为 0px)
      html: "Hello!", // 通知中的 HTML
      className: "welcome" // div 的附加类(可选)
    });

    在新窗口中演示

    使用 CSS 定位在给定的 top/right 坐标处显示元素。源文档已经提供了必要的样式。

    打开一个任务沙箱。

    function showNotification({ top = 0, right = 0, className, html }) {
      const toast = document.createElement('div');
      toast.style.top = top + 'px';
      toast.style.right = right + 'px';
      toast.innerHTML = html;
      toast.classList.add('notification');
      toast.classList.add(className);
      document.body.append(toast);
      setTimeout(() => {
        toast.remove();
      }, 1500);
    }

    参考解法:

    function showNotification({top = 0, right = 0, className, html}) {
      let notification = document.createElement('div');
      notification.className = "notification";
      if (className) {
        notification.classList.add(className);
      }
    
      notification.style.top = top + 'px';
      notification.style.right = right + 'px';
    
      notification.innerHTML = html;
      document.body.append(notification);
    
      setTimeout(() => notification.remove(), 1500);
    }

元素大小和滚动

任务

  1. 将小球置于区域(filed)中心

    源文件的效果如下:

    区域(field)的中心坐标是多少?

    计算它们,并将小球置于绿色的区域(field)中心:

    • 该元素应该通过 JavaScript 移动,而不是 CSS。
    • 该代码应该适用于任何大小的球(102030 像素)以及任意大小的区域(field),而不应该绑定到给定值。

    P.S. 当然了,置于中心的操作通过 CSS 也可以完成,但是这里我们需要通过 JavaScript 完成。此外,当必须使用 JavaScript 时,我们可能会遇到其他话题以及更加复杂的情况,这里我们只是做一个“热身”。

    const field = document.querySelector('#field');
    const ball = document.querySelector('#ball');
    ball.onload = function () {
      const fieldWidth = field.clientWidth;
      const fieldHeight = field.clientHeight;
      const ballWidth = ball.offsetWidth;
      console.log(fieldWidth, fieldHeight, ballWidth);
      ball.style.left = fieldWidth / 2 - ballWidth / 2 + 'px';
      ball.style.top = fieldHeight / 2 - ballWidth / 2 + 'px';
    };

    此处调用 ball.onload 原因为 ball 元素为图片,在未加载完成前无法取得宽高,需要在图片加载完成后再进行读取计算。或者给 img 显式设置宽高。

坐标

任务

  1. 查找区域的窗口坐标

    在下面的 iframe 中,你可以看到一个带有绿色区域(field)的文档。

    使用 JavaScript 查找带箭头指向的角的窗口坐标。

    为了方便起见,已经在文档中实现了一个小功能。在任何地方点击都会显示那里的坐标。

    你的代码应该使用 DOM 来获取以下窗口坐标:

    1. 左上的外角(这很简单)。
    2. 右下的外角(也挺简单)。
    3. 左上的内角(这有点难)。
    4. 右下的内角(有几种方式,选择其中一种)。

    你计算得到的坐标,应该与点击鼠标返回的坐标相同。

    P.S. 如果元素具有其他大小(size)和边框(border),且未绑定任何固定的值,你写的代码也应该起作用。

    let coords = elem.getBoundingClientRect();
    
    let answer1 = [coords.left, coords.top];
    let answer2 = [coords.right, coords.bottom];
    let answer3 = [coords.left + field.clientLeft, coords.top + field.clientTop];
    let answer4 = [
      coords.right - parseInt(getComputedStyle(field).borderRightWidth),
      coords.bottom - parseInt(getComputedStyle(field).borderBottomWidth)
    ];
    // or
    let answer4 = [
      coords.left + elem.clientLeft + elem.clientWidth,
      coords.top + elem.clientTop + elem.clientHeight
    ];
  2. 在元素旁显示一个 note

    创建一个函数 positionAt(anchor, position, elem) 来定位 elem,具体取决于 anchor 元素附近的 position

    position 必须具有下列三个字符串中的一个:

    • "top" — 将 elem 定位在 anchor 上方
    • "right" — 将 elem 定位在 anchor 右侧
    • "bottom" — 将 elem 定位在 anchor 下方

    position 被用在函数 showNote(anchor, position, html) 内,该函数使用给定的 html 创建一个 “note” 元素,并将其显示在 anchor 附近的 position 处。

    这是一个演示示例:

    function positionAt(anchor, position, elem) {
      let pos = { x: 0, y: 0 };
      const data = anchor.getBoundingClientRect();
      const self = elem.getBoundingClientRect();
      switch (position) {
        case 'top':
          pos.x = data.x;
          pos.y = data.y - self.height;
          break;
        case 'right':
          pos.x = data.right;
          pos.y = data.y;
          break;
        case 'bottom':
          pos.x = data.x;
          pos.y = data.bottom;
          break;
        default:
          break;
      }
      elem.style.left = pos.x + 'px';
      elem.style.top = pos.y + 'px';
    }
    // 参考解法
    function positionAt(anchor, position, elem) {
      let anchorCoords = anchor.getBoundingClientRect();
      switch (position) {
        case "top":
          elem.style.left = anchorCoords.left + "px";
          elem.style.top = anchorCoords.top - elem.offsetHeight + "px";
          break;
        case "right":
          elem.style.left = anchorCoords.left + anchor.offsetWidth + "px";
          elem.style.top = anchorCoords.top + "px";
          break;
        case "bottom":
          elem.style.left = anchorCoords.left + "px";
          elem.style.top = anchorCoords.top + anchor.offsetHeight + "px";
          break;
      }
    }
  3. 在元素旁 (absolute) 显示一个 note

    修改 上一个任务 的解决方案,让 note 元素使用 position:absolute 来替代 position:fixed

    这可以防止页面滚动时元素的“失控”。

    以上一个任务的解决方案为基础。为了测试页面滚动,请添加样式 <body style="height: 2000px">

    function getCoords(elem) {
      let box = elem.getBoundingClientRect();
      return {
        top: box.top + window.pageYOffset,
        left: box.left + window.pageXOffset
      };
    }
    
    function positionAt(anchor, position, elem) {
      let anchorCoords = getCoords(anchor);
      switch (position) {
        case "top":
          elem.style.left = anchorCoords.left + "px";
          elem.style.top = anchorCoords.top - elem.offsetHeight + "px";
          break;
        case "right":
          elem.style.left = anchorCoords.left + anchor.offsetWidth + "px";
          elem.style.top = anchorCoords.top + "px";
          break;
        case "bottom":
          elem.style.left = anchorCoords.left + "px";
          elem.style.top = anchorCoords.top + anchor.offsetHeight + "px";
          break;
      }
    }
  4. 把 note 放在元素内部 (absolute)

    扩展上一个任务 在元素旁(absolute)显示一个 note:教函数 positionAt(anchor, position, elem)elem 插入到 anchor 内部。

    position 的新值:

    • top-outright-outbottom-out — 和之前一样工作,它们把 elem 插入到 anchor 的上方/右侧/下方。
    • top-inright-inbottom-in — 把 elem 插入到 anchor 内部:将其粘贴到上/右/下边缘。

    例如:

    // 在 blockquote 上方显示 note
    positionAt(blockquote, "top-out", note);
    
    // 在 blockquote 内部的上边缘显示 note
    positionAt(blockquote, "top-in", note);

    结果:

    可以以上一个任务 在元素旁(absolute)显示一个 note 的解决方案为基础。

    function getCoords(elem) {
      let box = elem.getBoundingClientRect();
      return {
        top: box.top + window.pageYOffset,
        left: box.left + window.pageXOffset
      };
    }
    
    function positionAt(anchor, position, elem) {
      let anchorCoords = getCoords(anchor);
      switch (position) {
        case 'top-in':
          elem.style.left = anchorCoords.left + 'px';
          elem.style.top = anchorCoords.top + 'px';
          break;
        case 'top-out':
          elem.style.left = anchorCoords.left + 'px';
          elem.style.top = anchorCoords.top - elem.offsetHeight + 'px';
          break;
        case 'right-in':
          elem.style.left =
            anchorCoords.left + anchor.offsetWidth - elem.offsetWidth + 'px';
          elem.style.top = anchorCoords.top + 'px';
          break;
        case 'right-out':
          elem.style.left = anchorCoords.left + anchor.offsetWidth + 'px';
          elem.style.top = anchorCoords.top + 'px';
          break;
        case 'bottom-in':
          elem.style.left = anchorCoords.left + 'px';
          elem.style.top =
            anchorCoords.top + anchor.offsetHeight - elem.offsetHeight + 'px';
          break;
        case 'bottom-out':
          elem.style.left = anchorCoords.left + 'px';
          elem.style.top = anchorCoords.top + anchor.offsetHeight + 'px';
          break;
      }
    }

浏览器事件简介

任务

  1. 点击隐藏

    button 添加 JavaScript 代码,使得 <div id="text"> 在我们点击该按钮时消失。

    示例:

    hider.onclick = function () {
      text.remove()
    }
    // 参考解法
    document.getElementById('hider').onclick = function() {
      document.getElementById('text').hidden = true;
    }
  2. 隐藏自己

    创建一个按钮,在被点击时,隐藏自己。

    hider.onclick = function () {
      this.hidden = true;
    }
  3. 哪个处理程序会运行?

    在变量中有一个按钮。它上面没有处理程序。

    执行以下代码之后,哪些处理程序会在按钮被点击时运行?会显示哪些 alert?

    button.addEventListener("click", () => alert("1"));
    
    button.removeEventListener("click", () => alert("1"));
    
    button.onclick = () => alert(2);

    第 1 行和第 5 行的处理程序会运行。

  4. 让球在球场中移动

    点击球场中任意一点,让球在球场中移动。就像这样:

    要求:

    • 球的中心应该恰好在点击时鼠标指针位置的下方(如果在球不越过球场边缘的情况下,能实现的话)。
    • 最好添加一些 CSS 动画。
    • 球不能越过场地边界。
    • 页面滚动时,不会有任何中断。

    注意:

    • 代码还应该适用于不同大小的球和球场,而不应该绑定到任何固定值。
    • 使用 event.clientX/event.clientY 属性来获取点击坐标。
    #ball {
      left: 0;
      top: 0;
      position: relative;
      transition: all 0.5s;
    }
    const field = document.querySelector('#field');
    const ball = document.querySelector('#ball');
    field.addEventListener('click', e => {
      const x = e.offsetX;
      const y = e.offsetY;
      const width = ball.width;
      ball.style.left = x - width / 2 + 'px';
      ball.style.top = y - width / 2 + 'px';
    });
    // 参考解法
    #field {
      width: 200px;
      height: 150px;
      border: 10px solid black;
      background-color: #00FF00;
      position: relative;
      overflow: hidden;
      cursor: pointer;
    }
    #ball {
      position: absolute;
      left: 0;
      top: 0;
      width: 40px;
      height: 40px;
      transition: all 1s;
    }
    field.onclick = function(event) {
      // window-relative field coordinates
      let fieldCoords = this.getBoundingClientRect();
    
      // the ball has position:absolute, the field: position:relative
      // so ball coordinates are relative to the field inner left-upper corner
      let ballCoords = {
        top: event.clientY - fieldCoords.top - field.clientTop - ball.clientHeight / 2,
        left: event.clientX - fieldCoords.left - field.clientLeft - ball.clientWidth / 2
      };
    
      // prevent crossing the top field boundary
      if (ballCoords.top < 0) ballCoords.top = 0;
    
      // prevent crossing the left field boundary
      if (ballCoords.left < 0) ballCoords.left = 0;
    
    
 // // prevent crossing the right field boundary
 if (ballCoords.left + ball.clientWidth > field.clientWidth) {
   ballCoords.left = field.clientWidth - ball.clientWidth;
 }

 // prevent crossing the bottom field boundary
 if (ballCoords.top + ball.clientHeight > field.clientHeight) {
   ballCoords.top = field.clientHeight - ball.clientHeight;
 }

 ball.style.left = ballCoords.left + 'px';
 ball.style.top = ballCoords.top + 'px';

}


5. 创建滑动菜单

   创建一个在点击时打开/折叠的菜单:

   <iframe class="code-result__iframe" data-trusted="1" src="https://zh.js.cx/task/sliding-menu/solution/" style="display: block; border: 0px; width: 798px; height: 100px;"></iframe>

   P.S. 源文档的 HTML/CSS 将被修改。

   ```html
   <!DOCTYPE html>
   <html>
     <head>
       <meta charset="utf-8" />
       <style>
         .hidden {
           display: none;
         }
         .arrow {
           color: green;
         }
       </style>
     </head>
     <body>
       <div id="toggler">
         <span id="open" class="arrow">▶</span
         ><span id="close" class="arrow hidden">▼</span> Sweeties (click me)!
       </div>
       <ul id="content" class="hidden">
         <li>Cake</li>
         <li>Donut</li>
         <li>Honey</li>
       </ul>
       <script>
         const toggler = document.querySelector('#toggler');
         const content = document.querySelector('#content');
         const open = document.querySelector('#open');
         const close = document.querySelector('#close');
         let isOpen = false;
         toggler.onclick = function () {
           close.classList.toggle('hidden');
           open.classList.toggle('hidden');
           content.classList.toggle('hidden');
           isOpen = !isOpen;
         };
       </script>
     </body>
   </html>

参考解法:

   <!DOCTYPE HTML>
   <html>

   <head>
     <meta charset="utf-8">
     <style>
       .menu ul {
         margin: 0;
         list-style: none;
         padding-left: 20px;
         display: none;
       }

       .menu .title {
         font-size: 18px;
         cursor: pointer;
       }

       .menu .title::before {
         content: '▶ ';
         font-size: 80%;
         color: green;
       }

       .menu.open .title::before {
         content: '▼ ';
       }

       .menu.open ul {
         display: block;
       }
     </style>
   </head>

   <body>

     <div id="sweeties" class="menu">
       <span class="title">Sweeties (click me)!</span>
       <ul>
         <li>Cake</li>
         <li>Donut</li>
         <li>Honey</li>
       </ul>

     </div>

     <script>
       let menuElem = document.getElementById('sweeties');
       let titleElem = menuElem.querySelector('.title');

       titleElem.onclick = function() {
         menuElem.classList.toggle('open');
       };
     </script>

   </body>
   </html>

注意点:如果 click me 处设置为 div 会导致整行都可以触发点击事件。

  1. 添加关闭按钮

    有一个消息列表。

    使用 JavaScript 在每条消息的右上角添加一个关闭按钮。

    结果应该如下所示:

    .pane {
      background: #edf5e1;
      padding: 10px 20px 10px;
      border-top: solid 2px #c4df9b;
    + position: relative;
    }
    .pane .remove-button {
      position: absolute;
      right: 10px;
      top: 0;
    }
    const panes = document.querySelectorAll('.pane');
    panes.forEach(pane => {
      const button = createButton();
      button.onclick = function () {
        pane.remove();
      };
      pane.append(button);
    });
    function createButton() {
      const button = document.createElement('button');
      button.classList.add('remove-button');
      button.textContent = '[x]';
      return button;
    }

    参考解法:

    .pane {
      background: #edf5e1;
      padding: 10px 20px 10px;
      border-top: solid 2px #c4df9b;
      position: relative;
    }
    
    .remove-button {
      position: absolute;
      font-size: 110%;
      top: 0;
      color: darkred;
      right: 10px;
      display: block;
      width: 24px;
      height: 24px;
      border: none;
      background: transparent;
      cursor: pointer;
    }
    let panes = document.querySelectorAll('.pane');
    
    for(let pane of panes) {
      pane.insertAdjacentHTML("afterbegin", '<button class="remove-button">[x]</button>');
      // button becomes the first child of pane
      pane.firstChild.onclick = () => pane.remove();
    }
  2. 轮播图

    创建一个“轮播图(carousel)” —— 一条可以通过点击箭头来滚动图像的图像带。

    之后,我们可以为其添加更多功能:无限滚动,动态加载等。

    P.S. 对于这个任务,HTML/CSS 结构实际上占解决方案的 90%。

    .arrow {
      padding: 0;
      background: #ddd;
      border-radius: 15px;
      border: 1px solid gray;
      font-size: 24px;
      line-height: 24px;
      color: #444;
      display: block;
      position: absolute;
      top: 50%;
      transform: translateY(-50%);
    }
    .arrow.left {
      left: 10px;
    }
    .arrow.right {
      right: 10px;
    }
    .arrow:focus {
      outline: none;
    }
    .arrow:hover {
      background: #ccc;
      cursor: pointer;
    }
    #carousel {
      height: 150px;
      width: 450px;
      padding: 10px 30px;
      background-color: #eee;
      border: 1px solid #ccc;
      border-radius: 20px;
      overflow: hidden;
      position: relative;
      box-sizing: border-box;
    }
    ul {
      height: 130px;
      width: 9999px;
      margin: 0;
      padding: 0;
      list-style: none;
      font-size: 0;
      transition: transform 0.3s;
    }
    ul img {
      width: 130px;
      height: 130px;
      display: block; /* removes extra space near images */
    }
    ul li {
      display: inline-block; /* removes extra space between list items */
    }
    <!DOCTYPE html>
    <head>
      <meta charset="utf-8">
      <link rel="stylesheet" href="index.css">
    </head>
    <body>
      <div id="carousel">
        <ul style='transform: translateX(0px);'>
          <li><img src="https://en.js.cx/carousel/1.png"></li>
          <li><img src="https://en.js.cx/carousel/2.png"></li>
          <li><img src="https://en.js.cx/carousel/3.png"></li>
          <li><img src="https://en.js.cx/carousel/4.png"></li>
          <li><img src="https://en.js.cx/carousel/5.png"></li>
          <li><img src="https://en.js.cx/carousel/6.png"></li>
          <li><img src="https://en.js.cx/carousel/7.png"></li>
          <li><img src="https://en.js.cx/carousel/8.png"></li>
          <li><img src="https://en.js.cx/carousel/9.png"></li>
          <li><img src="https://en.js.cx/carousel/10.png"></li>
        </ul>
        <button class="arrow left"></button>
        <button class="arrow right"></button>
      </div>
      <script>
        // label the images to visually track them, just for convenience,
        // this code can be removed
        let i = 1;
        for(let li of carousel.querySelectorAll('li')) {
          li.style.position = 'relative';
          li.insertAdjacentHTML('beforeend', `<span style="position:absolute;left:0;top:0">${i}</span>`);
          i++;
        }
        let index = 0;
        const arrowLeft = document.querySelector('.arrow.left');
        const arrowRight = document.querySelector('.arrow.right');
        const ul = document.querySelector('#carousel ul');
        arrowRight.onclick = function () {
          index += 3;
          if (index > 6) {
            index = 7;
          }
          move(index)
        }
        arrowLeft.onclick = function () {
          index-=3;
          if (index < 0) {
            index = 0;
          }
          move(index);
        }
        function move(index) {
          const pos = index * -130;
          const text = `translateX(${pos}px)`
          ul.style.transform = text;
        }
      </script>
    
    </body>
    </html>

    参考解法:

    body {
      padding: 10px;
    }
    .carousel {
      position: relative;
      width: 398px;
      padding: 10px 40px;
      border: 1px solid #CCC;
      border-radius: 15px;
      background: #eee;
    }
    .carousel img {
      width: 130px;
      height: 130px;
      /* make it block to remove space around images */
      display: block;
    }
    .arrow {
      position: absolute;
      top: 60px;
      padding: 0;
      background: #ddd;
      border-radius: 15px;
      border: 1px solid gray;
      font-size: 24px;
      line-height: 24px;
      color: #444;
      display: block;
    }
    .arrow:focus {
      outline: none;
    }
    .arrow:hover {
      background: #ccc;
      cursor: pointer;
    }
    .prev {
      left: 7px;
    }
    .next {
      right: 7px;
    }
    .gallery {
      width: 390px;
      overflow: hidden;
    }
    .gallery ul {
      height: 130px;
      width: 9999px;
      margin: 0;
      padding: 0;
      list-style: none;
      transition: margin-left 250ms;
      /* remove white-space between inline-block'ed li */
      /* http://davidwalsh.name/remove-whitespace-inline-block */
      font-size: 0;
    }
    .gallery li {
      display: inline-block;
    }
    <!DOCTYPE html>
    <head>
      <meta charset="utf-8">
      <link rel="stylesheet" href="style.css">
    </head>
    <body>
      <div id="carousel" class="carousel">
        <button class="arrow prev"></button>
        <div class="gallery">
          <ul class="images">
            <li><img src="https://en.js.cx/carousel/1.png"></li>
            <li><img src="https://en.js.cx/carousel/2.png"></li>
            <li><img src="https://en.js.cx/carousel/3.png"></li>
            <li><img src="https://en.js.cx/carousel/4.png"></li>
            <li><img src="https://en.js.cx/carousel/5.png"></li>
            <li><img src="https://en.js.cx/carousel/6.png"></li>
            <li><img src="https://en.js.cx/carousel/7.png"></li>
            <li><img src="https://en.js.cx/carousel/8.png"></li>
            <li><img src="https://en.js.cx/carousel/9.png"></li>
            <li><img src="https://en.js.cx/carousel/10.png"></li>
          </ul>
        </div>
        <button class="arrow next"></button>
      </div>
      <script>
        /* label the images, just for convenience, to visually track them */
        let i = 1;
        for(let li of carousel.querySelectorAll('li')) {
          li.style.position = 'relative';
          li.insertAdjacentHTML('beforeend', `<span style="position:absolute;left:0;top:0">${i}</span>`);
          i++;
        }
        /* configuration */
        let width = 130; // image width
        let count = 3; // visible images count
        let list = carousel.querySelector('ul');
        let listElems = carousel.querySelectorAll('li');
        let position = 0; // ribbon scroll position
        carousel.querySelector('.prev').onclick = function() {
          // shift left
          position += width * count;
          // can't move to the left too much, end of images
          position = Math.min(position, 0)
          list.style.marginLeft = position + 'px';
        };
        carousel.querySelector('.next').onclick = function() {
          // shift right
          position -= width * count;
          // can only shift the ribbbon for (total ribbon length - visible count) images
          position = Math.max(position, -width * (listElems.length - count));
          list.style.marginLeft = position + 'px';
        };
      </script>
    </body>
    </html>

事件委托

任务

  1. 使用委托隐藏消息

    有一个带有移除按钮 [x] 的消息列表。让按钮可以工作。

    就像这样:

    P.S. 在容器上应该只有一个事件监听器,请使用事件委托。

    document.addEventListener('click', event => {
      let classList = event.target.classList;
      if (classList && classList[0] === 'remove-button') {
        let elem = event.target.closest('div');
        elem.remove();
      }
    });
    // 参考解法
    container.onclick = function(event) {
      if (event.target.className != 'remove-button') return;
    
      let pane = event.target.closest('.pane');
      pane.remove();
    };
  2. 树形菜单

    创建一个点击可以显示/隐藏子节点的树形菜单:

    要求:

    • 只能有一个事件处理程序(使用委托)。
    • 对节点标题以外(在空白处)的点击不会做任何处理。
    .hidden {
      display: none;
    }
    document.addEventListener('click', event => {
      let target = event.target;
      if (target.tagName.toLowerCase() === 'li') {
        let elem = target.children;
        if (elem.length && elem[0].tagName.toLowerCase() === 'ul') {
          elem[0].classList.toggle('hidden');
        }
      }
    });

    参考解法:

    .tree span:hover {
      font-weight: bold;
    }
    .tree span {
      cursor: pointer;
    }
    // move all text into <span>
    // they occupy exactly the place necessary for the text,
    for (let li of tree.querySelectorAll('li')) {
      let span = document.createElement('span');
      li.prepend(span);
      span.append(span.nextSibling); // move the text node into span
    }
    // catch clicks on whole tree
    tree.onclick = function(event) {
      if (event.target.tagName != 'SPAN') {
        return;
      }
      let childrenContainer = event.target.parentNode.querySelector('ul');
      if (!childrenContainer) return; // no children
      childrenContainer.hidden = !childrenContainer.hidden;
    }
  3. 可排序的表格

    使表格可排序:点击 <th> 元素,应按对应的列对表格进行排序。

    每个 <th> 的特性(attribute)中都有类型,如下所示:

    <table id="grid">
      <thead>
        <tr>
          <th data-type="number">Age</th>
          <th data-type="string">Name</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>5</td>
          <td>John</td>
        </tr>
        <tr>
          <td>10</td>
          <td>Ann</td>
        </tr>
        ...
      </tbody>
    </table>

    在上面的示例中,第一列为数字,第二列为字符串。排序函数应根据类型进行排序。

    应该只支持 "string""number" 类型。

    运行示例:

    P.S. 表格可以更大,有任意数量的行和列。

    document.addEventListener('click', event => {
      const target = event.target;
      const type = target.dataset.type;
      if (!type) return;
      const index = Array.from(grid.tHead.children[0].children).indexOf(
        target
      );
      const list = Array.from(grid.tBodies[0].children);
      list.sort((a, b) => {
        let fun = null;
        if (type === 'string') fun = String;
        if (type === 'number') fun = Number;
        const one = fun(a.children[index].textContent);
        const two = fun(b.children[index].textContent);
        if (type === 'string') {
          console.log(one, two, two > one);
          return two > one ? 1 : -1;
        }
        return two - one;
      });
      list.forEach(item => {
        grid.tBodies[0].prepend(item);
      });
    });
    // 参考解法
    grid.onclick = function(e) {
      if (e.target.tagName != 'TH') return;
    
      let th = e.target;
      // if TH, then sort
      // cellIndex is the number of th:
      //   0 for the first column
      //   1 for the second column, etc
      sortGrid(th.cellIndex, th.dataset.type);
    };
    
    function sortGrid(colNum, type) {
      let tbody = grid.querySelector('tbody');
    
      let rowsArray = Array.from(tbody.rows);
    
      // compare(a, b) compares two rows, need for sorting
      let compare;
    
      switch (type) {
        case 'number':
          compare = function(rowA, rowB) {
            return rowA.cells[colNum].innerHTML - rowB.cells[colNum].innerHTML;
          };
          break;
        case 'string':
          compare = function(rowA, rowB) {
            return rowA.cells[colNum].innerHTML > rowB.cells[colNum].innerHTML ? 1 : -1;
          };
          break;
      }
    
      // sort
      rowsArray.sort(compare);
    
      tbody.append(...rowsArray);
    }
  4. 工具提示行为

    编写工具提示(tooltip)行为的 JavaScript 代码。

    当鼠标在带有 data-tooltip 的元素的上方时,工具提示应显示在其上方,当鼠标移开时,工具提示将隐藏起来。

    带有注释的 HTML 示例:

    <button data-tooltip="the tooltip is longer than the element">Short button</button>
    <button data-tooltip="HTML<br>tooltip">One more button</button>

    运行效果如下:

    在此任务中,我们假设所有具有 data-tooltip 的元素中都只有文本。尚无嵌套标签。

    详情:

    • 元素和工具提示之间的距离应为 5px
    • 如果可能,工具提示应相对于元素居中。
    • 工具提示不应与窗口边缘交叉。通常,它应该在元素的上方,但是如果元素位于页面顶部,并且没有工具提示的空间,则应该在元素的下方。
    • 工具提示的内容在 data-tooltip 属性中给定。它可以是任意 HTML。

    在这里你将需要两个事件:

    • mouseover 当鼠标指针出现在元素上方时触发。
    • mouseout 当鼠标指针离开元素时触发。

    请使用事件委托:在 document 上设置两个处理程序,以跟踪带有 data-tooltip 的元素中的所有 “over” 和 “out”,并从那里管理工具提示。

    在实现了该行为后,即使不熟悉 JavaScript 的人也可以添加带注释的元素。

    P.S. 一次只能显示一个工具提示。

    let dom_tooltip = null;
    document.addEventListener('mouseover', event => {
      const target = event.target;
      const tooltip = target.dataset.tooltip;
      if (!tooltip) return;
      const rect = target.getBoundingClientRect();
      console.log(rect.bottom);
      dom_tooltip = createToolTip(tooltip);
      const left =
        rect.left + (target.offsetWidth - dom_tooltip.offsetWidth) / 2;
      const top = rect.top - dom_tooltip.offsetHeight - 5;
      dom_tooltip.style.left = (left < 0 ? 0 : left) + 'px';
      dom_tooltip.style.top = (top < 0 ? rect.bottom + 5 : top) + 'px';
      document.body.append(dom_tooltip);
    });
    document.addEventListener('mouseout', () => {
      const target = event.target;
      const tooltip = target.dataset.tooltip;
      if (!tooltip) return;
      dom_tooltip && dom_tooltip.remove();
    });
    function createToolTip(text) {
      const div = document.createElement('div');
      div.innerHTML = text;
      div.classList.add('tooltip');
      document.body.append(div);
      return div;
    }
    // 参考解法
    let tooltipElem;
    
    document.onmouseover = function(event) {
      let target = event.target;
    
      // if we have tooltip HTML...
      let tooltipHtml = target.dataset.tooltip;
      if (!tooltipHtml) return;
    
      // ...create the tooltip element
    
      tooltipElem = document.createElement('div');
      tooltipElem.className = 'tooltip';
      tooltipElem.innerHTML = tooltipHtml;
      document.body.append(tooltipElem);
    
      // position it above the annotated element (top-center)
      let coords = target.getBoundingClientRect();
    
      let left = coords.left + (target.offsetWidth - tooltipElem.offsetWidth) / 2;
      if (left < 0) left = 0; // don't cross the left window edge
    
      let top = coords.top - tooltipElem.offsetHeight - 5;
      if (top < 0) { // if crossing the top window edge, show below instead
        top = coords.top + target.offsetHeight + 5;
      }
    
      tooltipElem.style.left = left + 'px';
      tooltipElem.style.top = top + 'px';
    };
    
    document.onmouseout = function(e) {
    
      if (tooltipElem) {
        tooltipElem.remove();
        tooltipElem = null;
      }
    
    };

浏览器默认行为

任务

  1. 捕获元素中的链接

    使所有包含 id="contents" 的元素内的链接询问用户是否真的要离开。如果用户不想离开,那就不离开。

    像这样:

    细节:

    • 元素内的 HTML 可以被随时动态加载或重新生成,因此,我们无法找到所有链接并为其添加处理程序。这里使用事件委托。
    • 内容中可能有嵌套的标签。链接中也是,例如 <a href=".."><i>...</i></a>
    document.addEventListener('click', e => {
      const tar = e.target;
      const target = tar.closest('a');
      if (!target) return;
      if (target.tagName !== 'A') return;
      const result = confirm('?');
      if (!result) {
        e.preventDefault();
      }
    });
    // 参考解法
    contents.onclick = function(event) {
      function handleLink(href) {
        let isLeaving = confirm(`Leave for ${href}?`);
        if (!isLeaving) return false;
      }
      let target = event.target.closest('a');
      if (target && contents.contains(target)) {
        return handleLink(target.getAttribute('href'));
      }
    };
  2. 图册

    创建一个图册,通过点击缩略图可以更改主图片。

    像这样:

    P.S. 使用事件委托。

    打开一个任务沙箱。

    thumbs.addEventListener('click', e => {
      e.preventDefault();
      const target = e.target;
      if (target.tagName !== 'IMG') return;
      const href = target.parentNode.getAttribute('href');
      console.log(target.parentNode);
      largeImg.setAttribute('src', href);
    });
    // 参考解法
    thumbs.onclick = function(event) {
      let thumbnail = event.target.closest('a');
    
      if (!thumbnail) return;
      showThumbnail(thumbnail.href, thumbnail.title);
      event.preventDefault();
    }
    
    function showThumbnail(href, title) {
      largeImg.src = href;
      largeImg.alt = title;
    }

鼠标事件

任务

  1. 可选列表

    创建一个可以选择元素的列表,例如在文件管理器中。

    • 点击列表元素,只选择该元素(添加 .selected 类),取消选择其他所有元素。
    • 如果点击时,按键 Ctrl(在 Mac 中为 Cmd)是被按下的,则选择会被切换到被点击的元素上,但其他元素不会被改动。

    示例:

    P.S. 对于此任务,我们可以假设列表项是纯文本的。没有嵌套标签。

    P.P.S. 防止点击时浏览器原生的文本选择。

    const ul = document.querySelector('#ul');
    const li = document.querySelectorAll('li');
    ul.addEventListener('click', e => {
      const target = e.target;
      if (target.tagName !== 'LI') return;
      if (!e.ctrlKey && !e.metaKey) {
        li.forEach(l => {
          l.classList.remove('selected');
        });
      }
      target.classList.toggle('selected');
    });
    ul.onmousedown = function () {
      return false;
    };
    // 参考解法
    ul.onclick = function(event) {
      if (event.target.tagName != "LI") return;
      if (event.ctrlKey || event.metaKey) {
        toggleSelect(event.target);
      } else {
        singleSelect(event.target);
      }
    }
    // prevent unneeded selection of list elements on clicks
    ul.onmousedown = function() {
      return false;
    };
    function toggleSelect(li) {
      li.classList.toggle('selected');
    }
    function singleSelect(li) {
      let selected = ul.querySelectorAll('.selected');
      for(let elem of selected) {
        elem.classList.remove('selected');
      }
      li.classList.add('selected');
    }

移动鼠标:mouseover/out,mouseenter/leave

任务

  1. 改进的工具提示行为

    编写 JavaScript,在带有 data-tooltip 特性(attribute)的元素上显示一个工具提示。该特性的值应该成为工具提示的文本。

    与任务 工具提示行为 类似,但这里可以嵌套带有注解(annotated)的元素。并且显示的是嵌套最深的工具提示。

    同一时间只能显示一个工具提示。

    例如:

    <div data-tooltip="Here – is the house interior" id="house">
      <div data-tooltip="Here – is the roof" id="roof"></div>
      ...
      <a href="https://en.wikipedia.org/wiki/The_Three_Little_Pigs" data-tooltip="Read on…">Hover over me</a>
    </div>

    在 iframe 中的结果:

    打开一个任务沙箱。

    const house = document.querySelector('#house');
    let oTooltip = null;
    house.addEventListener('mouseover', e => {
      const target = e.target.closest('[data-tooltip]');
      if (!target) return;
      const tooltip = target.dataset.tooltip;
      oTooltip = createTooltip(tooltip);
    });
    house.addEventListener('mouseout', e => {
      if (oTooltip) {
        oTooltip.remove();
        oTooltip = null;
      }
    });
    function createTooltip(text) {
      const div = document.createElement('div');
      div.classList.add('tooltip');
      div.textContent = text;
      document.body.append(div);
      return div;
    }
    // 参考解法
    let tooltip;
    document.onmouseover = function(event) {
      // important: a fast-moving mouse may "jump" right to a child on an annotated node, skipping the parent
      // so mouseover may happen on a child.
      let anchorElem = event.target.closest('[data-tooltip]');
      if (!anchorElem) return;
      // show tooltip and remember it
      tooltip = showTooltip(anchorElem, anchorElem.dataset.tooltip);
    }
    document.onmouseout = function() {
      // it is possible that mouseout triggered, but we're still inside the element
      // (its target was inside, and it bubbled)
      // but in this case we'll have an immediate mouseover,
      // so the tooltip will be destroyed and shown again
      //
      // luckily, the "blinking" won't be visible,
      // as both events happen almost at the same time
      if (tooltip) {
        tooltip.remove();
        tooltip = false;
      }
    }
    function showTooltip(anchorElem, html) {
      let tooltipElem = document.createElement('div');
      tooltipElem.className = 'tooltip';
      tooltipElem.innerHTML = html;
      document.body.append(tooltipElem);
      let coords = anchorElem.getBoundingClientRect();
      // position the tooltip over the center of the element
      let left = coords.left + (anchorElem.offsetWidth - tooltipElem.offsetWidth) / 2;
      if (left < 0) left = 0;
      let top = coords.top - tooltipElem.offsetHeight - 5;
      if (top < 0) {
        top = coords.top + anchorElem.offsetHeight + 5;
      }
      tooltipElem.style.left = left + 'px';
      tooltipElem.style.top = top + 'px';
      return tooltipElem;
    }
  2. “智能”工具提示

    编写一个函数,该函数仅在访问者将鼠标 移至 元素而不是 移过 元素的情况下,在该元素上显示工具提示。

    换句话说,如果访问者将鼠标移至元素上,并停下来 —— 显示工具提示。如果他们只是将鼠标移过元素,那就没必要显示,谁想要多余的闪烁呢?

    从技术上说,我们可以测量元素上的鼠标移动速度,如果速度很慢,那么我们就假定它 在元素上,并显示工具提示,如果速度很快 —— 那么我们就忽略它。

    为此,我们创建一个通用对象 new HoverIntent(options)

    options

    • elem —— 要跟踪的元素。
    • over —— 鼠标移动到元素上时要调用的函数:即,鼠标在元素上的移动速度很慢,或者停在该元素上。
    • out —— 当鼠标离开元素时调用的函数(如果 over 已经被调用过了)。

    在工具提示中使用此类对象的示例:

    // 一个简单的工具提示
    let tooltip = document.createElement('div');
    tooltip.className = "tooltip";
    tooltip.innerHTML = "Tooltip";
    
    // 该对象将跟踪鼠标,并调用 over/out
    new HoverIntent({
      elem,
      over() {
        tooltip.style.left = elem.getBoundingClientRect().left + 'px';
        tooltip.style.top = elem.getBoundingClientRect().bottom + 5 + 'px';
        document.body.append(tooltip);
      },
      out() {
        tooltip.remove();
      }
    });

    示例:

    如果你将鼠标快速地从“时钟”上移动过去,那么什么都不会发生,如果你使用鼠标在“时钟”上慢慢移动,或者停在“时钟”上,则会出现一个工具提示。

    请注意:当鼠标指针在“时钟”的元素之间移动时,工具提示不会“闪烁”

    打开带有测试的沙箱。

    'use strict';
    // 参考解法
    class HoverIntent {
      constructor({
        sensitivity = 0.1, // speed less than 0.1px/ms means "hovering over an element"
        interval = 100,    // measure mouse speed once per 100ms
        elem,
        over,
        out
      }) {
        this.sensitivity = sensitivity;
        this.interval = interval;
        this.elem = elem;
        this.over = over;
        this.out = out;
        // make sure "this" is the object in event handlers.
        this.onMouseMove = this.onMouseMove.bind(this);
        this.onMouseOver = this.onMouseOver.bind(this);
        this.onMouseOut = this.onMouseOut.bind(this);
        // and in time-measuring function (called from setInterval)
        this.trackSpeed = this.trackSpeed.bind(this);
        elem.addEventListener("mouseover", this.onMouseOver);
        elem.addEventListener("mouseout", this.onMouseOut);
      }
      onMouseOver(event) {
        if (this.isOverElement) {
          // if we're over the element, then ignore the event
          // we are already measuring the speed
          return;
        }
        this.isOverElement = true;
        // after every mousemove we'll be check the distance
        // between the previous and the current mouse coordinates
        // if it's less than sensivity, then the speed is slow
        this.prevX = event.pageX;
        this.prevY = event.pageY;
        this.prevTime = Date.now();
        elem.addEventListener('mousemove', this.onMouseMove);
        this.checkSpeedInterval = setInterval(this.trackSpeed, this.interval);
      }
      onMouseOut(event) {
        // if left the element
        if (!event.relatedTarget || !elem.contains(event.relatedTarget)) {
          this.isOverElement = false;
          this.elem.removeEventListener('mousemove', this.onMouseMove);
          clearInterval(this.checkSpeedInterval);
          if (this.isHover) {
            // if there was a stop over the element
            this.out.call(this.elem, event);
            this.isHover = false;
          }
        }
      }
      onMouseMove(event) {
        this.lastX = event.pageX;
        this.lastY = event.pageY;
        this.lastTime = Date.now();
      }
      trackSpeed() {
        let speed;
        if (!this.lastTime || this.lastTime == this.prevTime) {
          // cursor didn't move
          speed = 0;
        } else {
          speed = Math.sqrt(
            Math.pow(this.prevX - this.lastX, 2) +
            Math.pow(this.prevY - this.lastY, 2)
          ) / (this.lastTime - this.prevTime);
        }
        if (speed < this.sensitivity) {
          clearInterval(this.checkSpeedInterval);
          this.isHover = true;
          this.over.call(this.elem, event);
        } else {
          // speed fast, remember new coordinates as the previous ones
          this.prevX = this.lastX;
          this.prevY = this.lastY;
          this.prevTime = this.lastTime;
        }
      }
      destroy() {
        elem.removeEventListener('mousemove', this.onMouseMove);
        elem.removeEventListener('mouseover', this.onMouseOver);
        elem.removeEventListener('mouseout', this.onMouseOut);
      }
    }