0%

现代 JavaScript 教程任务题解二

现代 JavaScript 教程任务题解二

鼠标拖放事件

任务

  1. 滑动条

    创建一个滑动条(slider):

    用鼠标拖动蓝色的滑块(thumb)并移动它。

    重要的细节:

    • 当鼠标按钮被按下时,在滑动过程中,鼠标指针可能会移动到滑块的上方或下方。此时滑块仍会继续移动(方便用户)。
    • 如果鼠标非常快地向左边或者向右边移动,那么滑块应该恰好停在边缘。

    打开一个任务沙箱。

    const slider = document.querySelector('#slider');
    const thumb = document.querySelector('.thumb');
    const thumbWidth = thumb.clientWidth;
    const sliderLeft = slider.offsetLeft;
    const sliderWidth = slider.clientWidth;
    console.log(slider.clientWidth);
    thumb.onmousedown = function () {
      function moveAt(x) {
        let res = x - sliderLeft;
        if (x > sliderWidth) res = sliderWidth - thumbWidth;
        if (x < sliderLeft) res = sliderLeft - thumbWidth;
        thumb.style.left = res + 'px';
      }
      function moveHandle(event) {
        moveAt(event.pageX);
      }
      document.addEventListener('mousemove', moveHandle);
      document.onmouseup = function () {
        console.log('here');
        document.removeEventListener('mousemove', moveHandle);
        document.onmouseup = null;
      };
    };
    thumb.ondragstart = function () {
      return false;
    };
    // 参考解法
    let thumb = slider.querySelector('.thumb');
    thumb.onmousedown = function(event) {
      event.preventDefault(); // prevent selection start (browser action)
      let shiftX = event.clientX - thumb.getBoundingClientRect().left;
      // shiftY not needed, the thumb moves only horizontally
      document.addEventListener('mousemove', onMouseMove);
      document.addEventListener('mouseup', onMouseUp);
    
      function onMouseMove(event) {
        let newLeft = event.clientX - shiftX - slider.getBoundingClientRect().left;
        // the pointer is out of slider => lock the thumb within the bounaries
        if (newLeft < 0) {
          newLeft = 0;
        }
        let rightEdge = slider.offsetWidth - thumb.offsetWidth;
        if (newLeft > rightEdge) {
          newLeft = rightEdge;
        }
        thumb.style.left = newLeft + 'px';
      }
    
      function onMouseUp() {
        document.removeEventListener('mouseup', onMouseUp);
        document.removeEventListener('mousemove', onMouseMove);
      }
    
    };
    
    thumb.ondragstart = function() {
      return false;
    };
  2. 将超级英雄放置在足球场周围

    这个任务可以帮助你检查你对拖放和 DOM 的一些方面的理解程度。

    使所有元素都具有类 draggable —— 可拖动。就像本章中的球一样。

    要求:

    • 使用事件委托来跟踪拖动的开始:一个在 document 上的用于 mousedown 的处理程序。
    • 如果元素被拖动到了窗口的顶端/末端 —— 页面会向上/向下滚动以允许进一步的拖动。
    • 没有水平滚动(这使本任务更简单,但添加水平滚动也很简单)。
    • 即使在快速移动鼠标后,可拖动元素或该元素的部分也绝不应该离开窗口。

    这个示例太大了,不适合放在这里,所以在下面给出了示例链接。

    在新窗口中演示

    打开一个任务沙箱。

    // 参考解法
    let isDragging = false;
    
    document.addEventListener('mousedown', function(event) {
    
      let dragElement = event.target.closest('.draggable');
    
      if (!dragElement) return;
    
      event.preventDefault();
    
      dragElement.ondragstart = function() {
          return false;
      };
    
      let coords, shiftX, shiftY;
    
      startDrag(dragElement, event.clientX, event.clientY);
    
      function onMouseUp(event) {
        finishDrag();
      };
    
      function onMouseMove(event) {
        moveAt(event.clientX, event.clientY);
      }
    
      // 在拖动开始时:
      //   记住初始的移位
      //   将元素设置为 position:fixed,并将此元素移动到作为 body 的直接子元素
      function startDrag(element, clientX, clientY) {
        if(isDragging) {
          return;
        }
    
        isDragging = true;
    
        document.addEventListener('mousemove', onMouseMove);
        element.addEventListener('mouseup', onMouseUp);
    
        shiftX = clientX - element.getBoundingClientRect().left;
        shiftY = clientY - element.getBoundingClientRect().top;
    
        element.style.position = 'fixed';
    
        moveAt(clientX, clientY);
      };
    
      // 在最后,转换到绝对(absolute)坐标,以将元素固定在文档中
      function finishDrag() {
        if(!isDragging) {
          return;
        }
    
        isDragging = false;
    
        dragElement.style.top = parseInt(dragElement.style.top) + window.pageYOffset + 'px';
        dragElement.style.position = 'absolute';
    
        document.removeEventListener('mousemove', onMouseMove);
        dragElement.removeEventListener('mouseup', onMouseUp);
      }
    
      function moveAt(clientX, clientY) {
        // 新的窗口相对坐标
        let newX = clientX - shiftX;
        let newY = clientY - shiftY;
    
        // 检查新坐标是否在底部窗口边缘以下
        let newBottom = newY + dragElement.offsetHeight; // new bottom
    
        // 在窗口边缘以下?让我们滚动此页面
        if (newBottom > document.documentElement.clientHeight) {
          // 文档末端的窗口相对坐标
          let docBottom = document.documentElement.getBoundingClientRect().bottom;
    
          // 将文档向下滚动 10px 有一个问题
          // 它可以滚动到文档末尾之后
          // Math.min(how much left to the end, 10)
          let scrollY = Math.min(docBottom - newBottom, 10);
    
          // 计算是不精确的,可能会有舍入误差导致页面向上滚动
          // 这是不应该出现,我们在这儿解决它
          if (scrollY < 0) scrollY = 0;
    
          window.scrollBy(0, scrollY);
    
          // 快速移动鼠标将指针移至文档末端的外面
          // 如果发生这种情况 ——
          //  使用最大的可能距离来限制 newY(就是文档末端到顶端的距离)
          newY = Math.min(newY, document.documentElement.clientHeight - dragElement.offsetHeight);
        }
    
        // 检查新坐标是否在顶部窗口边缘上方(类似的逻辑)
        if (newY < 0) {
          // scroll up
          let scrollY = Math.min(-newY, 10);
          if (scrollY < 0) scrollY = 0; // 检查精度损失
    
          window.scrollBy(0, -scrollY);
          // 快速移动鼠标可以使指针超出文档的顶端
          newY = Math.max(newY, 0); // newY 不得小于 0
        }
    
        // 将 newX 限制在窗口范围内
        // 这里没有滚动,所以它很简单
        if (newX < 0) newX = 0;
        if (newX > document.documentElement.clientWidth - dragElement.offsetWidth) {
          newX = document.documentElement.clientWidth - dragElement.offsetWidth;
        }
    
        dragElement.style.left = newX + 'px';
        dragElement.style.top = newY + 'px';
      }
    });

键盘:keydown 和 keyup

任务

  1. 扩展热键

    创建一个 runOnKeys(func, code1, code2, ... code_n) 函数,在同时按下 code1, code2, ... code_n 键时运行函数 func

    例如,当按键 "Q""W" 被一起按下时(任何语言中,无论是否 CapsLock),下面的代码将显示 alert

    runOnKeys(
      () => alert("Hello!"),
      "KeyQ",
      "KeyW"
    );

    在新窗口中演示

    // 参考解法
    function runOnKeys(func, ...codes) {
      let pressed = new Set();
      document.addEventListener('keydown', function(event) {
        pressed.add(event.code);
        for (let code of codes) { // 所有的按键都在集合中?
          if (!pressed.has(code)) {
            return;
          }
        }
        // 是的
        // 在 alert 期间,如果访客松开了按键,
        // JavaScript 就不会获得 "keyup" 事件
        // 那么集合 pressed 会保持假设这些按键是被按下的状态
        // 因此,为避免“粘滞”键,我们对状态进行了重置
        // 如果用户想再次运行热键 —— 他们需要再次按下所有键
        pressed.clear();
        func();
      });
      document.addEventListener('keyup', function(event) {
        pressed.delete(event.code);
      });
    }
    runOnKeys(
      () => alert("Hello!"),
      "KeyQ",
      "KeyW"
    );

滚动

任务

  1. 无限的页面

    创建一个无限的页面。当访问者滚动到页面末端时,它会自动将当期日期时间附加到文本中(以便访问者可以滚动更多内容)。

    像这样:

    请注意滚动的两个重要特性:

    1. 滚动是“弹性的”。在某些浏览器/设备中,我们可以在文档的顶端或末端稍微多滚动出一点(超出部分显示的是空白区域,然后文档将自动“弹回”到正常状态)。
    2. 滚动并不精确。当我们滚动到页面末端时,实际上我们可能距真实的文档末端约 0-50px。

    因此,“滚动到末端”应该意味着访问者离文档末端的距离不超过 100px。

    P.S. 在现实生活中,我们可能希望显示“更多信息”或“更多商品”。

    打开一个任务沙箱。

    function createDate() {
      const p = document.createElement('p');
      p.innerHTML = new Date();
      document.body.append(p);
    }
    function addScroll() {
      let {
        scrollTop,
        scrollHeight,
        clientHeight
      } = document.documentElement;
      while (scrollTop + clientHeight >= scrollHeight - 100) {
        createDate();
        ({
          scrollTop,
          scrollHeight,
          clientHeight
        } = document.documentElement); // 添加元素后要更新相应的值,否则会造成死循环。
      }
    }
    addScroll();
    document.addEventListener('scroll', event => {
      addScroll();
    });
    // 参考解法
    function populate() {
      while(true) {
        let windowRelativeBottom = document.documentElement.getBoundingClientRect().bottom;
        if (windowRelativeBottom > document.documentElement.clientHeight + 100) break;
          document.body.insertAdjacentHTML("beforeend", `<p>Date: ${new Date()}</p>`);
        }
    }
    window.addEventListener('scroll', populate);
    populate(); // init document
  2. Up/down 按钮

    创建一个“到顶部”按钮来帮助页面滚动。

    它应该像这样运行:

    • 页面向下滚动的距离没有超过窗口高度时 —— 按钮不可见。
    • 当页面向下滚动距离超过窗口高度时 —— 在左上角出现一个“向上”的箭头。如果页面回滚回去,箭头就会消失。
    • 单击箭头时,页面将滚动到顶部。

    像这样(左上角,滚动查看):

    打开一个任务沙箱。

    #arrowTop {
      // ...
      display: none;
    }
    // ...
    #arrowTop.show {
      display: block;
    }
    const arrow = document.querySelector('#arrowTop');
    function checkArrow() {
      const { scrollTop, clientHeight } = document.documentElement;
      if (scrollTop >= clientHeight) arrow.classList.add('show');
      else arrow.classList.remove('show');
    }
    arrow.addEventListener('click', event => {
      document.documentElement.scrollTo(0, 0);
    });
    document.addEventListener('scroll', checkArrow);
    // 参考解法
    arrowTop.onclick = function() {
      window.scrollTo(pageXOffset, 0);
      // after scrollTo, there will be a "scroll" event, so the arrow will hide automatically
    };
    window.addEventListener('scroll', function() {
      arrowTop.hidden = (pageYOffset < document.documentElement.clientHeight);
    });
  3. 加载可视化图像

    假设我们有一个速度较慢的客户端,并且希望节省它们在移动端的流量。

    为此,我们决定不立即显示图像,而是将其替换为占位符,如下所示:

    <img src="placeholder.svg" width="128" height="128" data-src="real.jpg">

    因此,最初所有图像均为 placeholder.svg。当页面滚动到用户可以看到图像位置时 —— 我们就会将 src 更改为 data-srcsrc,从而加载图像。

    这是在 iframe 中的一个示例:

    滚动它可以看到图像是“按需”加载的。

    要求:

    • 加载页面时,屏幕上的那些图像应该在滚动之前立即加载。
    • 有些图像可能是常规图像,没有 data-src。代码不应该改动它们。
    • 一旦图像被加载,它就不应该在滚动进/出时被重新加载。

    P.S. 如果你有能力,可以创建一个更高级的解决方案,以“预加载”当前位置下方/之后一页的图像。

    P.P.S. 仅处理垂直滚动,不处理水平滚动。

    打开一个任务沙箱。

    /**
     * Tests if the element is visible (within the visible part of the page)
     * It's enough that the top or bottom edge of the element are visible
     */
    function isVisible(elem) {
      // todo: your code
      const { offsetTop, height } = elem;
      const { scrollTop, clientHeight } = document.documentElement;
      console.log(offsetTop, height, scrollTop, clientHeight);
      return offsetTop + height <= scrollTop + clientHeight;
    }
    
    function showVisible() {
      for (let img of document.querySelectorAll('img')) {
        let realSrc = img.dataset.src;
        if (!realSrc) continue;
    
        if (isVisible(img)) {
          // disable caching
          // this line should be removed in production code
          realSrc += '?nocache=' + Math.random();
          img.src = realSrc;
          img.dataset.src = '';
        }
      }
    }
    
    window.addEventListener('scroll', showVisible);
    showVisible();
    // 参考解法 1
    function isVisible(elem) {
      let coords = elem.getBoundingClientRect();
      let windowHeight = document.documentElement.clientHeight;
      // top elem edge is visible OR bottom elem edge is visible
      let topVisible = coords.top > 0 && coords.top < windowHeight;
      let bottomVisible = coords.bottom < windowHeight && coords.bottom > 0;
      return topVisible || bottomVisible;
    }
    // 参考解法 2
    function isVisible(elem) {
      let coords = elem.getBoundingClientRect();
      let windowHeight = document.documentElement.clientHeight;
      let extendedTop = -windowHeight;
      let extendedBottom = 2 * windowHeight;
      // top visible || bottom visible
      let topVisible = coords.top > extendedTop && coords.top < extendedBottom;
      let bottomVisible = coords.bottom < extendedBottom && coords.bottom > extendedTop;
      return topVisible || bottomVisible;
    }

表单属性和方法

任务

  1. 在 select 元素中添加一个选项

    下面是一个 <select> 元素:

    <select id="genres">
      <option value="rock">Rock</option>
      <option value="blues" selected>Blues</option>
    </select>

    使用 JavaScript 来实现:

    1. 显示所选选项的值和文本。
    2. 添加一个选项:<option value="classic">Classic</option>
    3. 使之变为可选的。

    请注意,如果你已正确完成所有事项,那么 alert 应该显示 blues

    // 解决方案
    // 1)
    let selectedOption = genres.options[genres.selectedIndex];
    alert( selectedOption.value );
    
    // 2)
    let newOption = new Option("Classic", "classic");
    genres.append(newOption);
    
    // 3)
    newOption.selected = true;

聚焦:focus/blur

任务

  1. 可编辑的 div

    创建一个 <div>,它在被点击后变成 <textarea>

    文本区域(textarea)允许我们编辑 <div> 里的 HTML。

    当用户按下 Enter 键,或者 <textarea> 失去焦点时,<textarea> 会变回 <div>,并且 <textarea> 中的内容会变成 <div> 中的 HTML。

    在新窗口中演示

    打开一个任务沙箱。

    const view = document.querySelector('#view');
    view.addEventListener('click', () => {
      const textarea = document.createElement('textarea');
      textarea.value = view.innerHTML;
      textarea.classList.add('edit');
      textarea.addEventListener('blur', () => {
        remove(textarea);
      });
      textarea.addEventListener('keydown', e => {
        if (e.keyCode !== 13) return;
        textarea.blur();
      });
      document.body.append(textarea);
      textarea.focus();
      view.style.display = 'none';
    });
    function remove(textarea) {
      view.style.display = 'block';
      view.innerHTML = textarea.value;
      textarea.remove();
    }
    // 解决方案
    let area = null;
    let view = document.getElementById('view');
    view.onclick = function() {
      editStart();
    };
    function editStart() {
      area = document.createElement('textarea');
      area.className = 'edit';
      area.value = view.innerHTML;
      area.onkeydown = function(event) {
        if (event.key == 'Enter') {
          this.blur();
        }
      };
      area.onblur = function() {
        editEnd();
      };
      view.replaceWith(area);
      area.focus();
    }
    function editEnd() {
      view.innerHTML = area.value;
      area.replaceWith(view);
    }
  2. 点击即可编辑单元格

    使单元格在点击时可编辑。

    • 点击时 —— 单元格应该变成“可编辑的”(在里面会出现文本区域),我们修改其中的 HTML。在这不调整单元格大小,所有几何形状保持不变。
    • OK 和 CANCEL 按钮会出现在单元格的下面,用以完成/取消编辑。
    • 同一时刻只有一个单元格可被编辑。当一个 <td> 处于“编辑模式”时,在其它单元格上的点击会被忽略。
    • 该表格可能有很多单元格。请使用事件委托。

    示例:

    打开一个任务沙箱。

    .edit-td {
      position: relative;
      padding: 0;
    }
    .edit {
      display: block;
      overflow: auto;
      width: 170px;
      height: 104px;
      padding: 0;
      margin: 0;
      outline: none;
      border: none;
      resize: none;
    }
    .btn-group {
      position: absolute;
      top: 100%;
    }
    let table = document.getElementById('bagua-table');
    let edit = null;
    let tdContent = '';
    
    table.addEventListener('click', e => {
      const target = e.target.closest('td');
      if (!target) return;
      edit || editStart(target);
    });
    function editStart(td) {
      edit = document.createElement('textarea');
      edit.value = td.innerHTML;
      edit.classList.add('edit');
      const btn = `
      <button data-type="1">ok</button>
      <button data-type="2">cancel</button>
      `;
      const div = document.createElement('div');
      div.addEventListener('click', e => {
        const target = e.target;
        const type = target.dataset.type;
        if (type === '1') {
          confirmEdit(td, edit);
        } else {
          cancelEdit(td);
        }
      });
      div.classList.add('btn-group');
      div.innerHTML = btn;
      tdContent = td.innerHTML;
      td.innerHTML = '';
      td.append(edit);
      td.append(div);
      td.classList.add('edit-td');
    }
    function cancelEdit(td) {
      td.innerHTML = tdContent;
      tdContent = '';
      edit = null;
    }
    function confirmEdit(td, edit) {
      td.innerHTML = edit.value;
      tdContent = '';
      edit = null;
    }

    参考解法:

    .edit-td .edit-area {
      border: none;
      margin: 0;
      padding: 0;
      display: block;
      /* remove resizing handle in Firefox */
      resize: none;
      /* remove outline on focus in Chrome */
      outline: none;
      /* remove scrollbar in IE */
      overflow: auto;
    }
    .edit-controls {
      position: absolute;
    }
    .edit-td {
      position: relative;
      padding: 0;
    }
    let table = document.getElementById('bagua-table');
    let editingTd;
    table.onclick = function(event) {
      // 3 possible targets
      let target = event.target.closest('.edit-cancel,.edit-ok,td');
      if (!table.contains(target)) return;
      if (target.className == 'edit-cancel') {
        finishTdEdit(editingTd.elem, false);
      } else if (target.className == 'edit-ok') {
        finishTdEdit(editingTd.elem, true);
      } else if (target.nodeName == 'TD') {
        if (editingTd) return; // already editing
        makeTdEditable(target);
      }
    };
    function makeTdEditable(td) {
      editingTd = {
        elem: td,
        data: td.innerHTML
      };
      td.classList.add('edit-td'); // td is in edit state, CSS also styles the area inside
      let textArea = document.createElement('textarea');
      textArea.style.width = td.clientWidth + 'px';
      textArea.style.height = td.clientHeight + 'px';
      textArea.className = 'edit-area';
      textArea.value = td.innerHTML;
      td.innerHTML = '';
      td.appendChild(textArea);
      textArea.focus();
      td.insertAdjacentHTML("beforeEnd",
        '<div class="edit-controls"><button class="edit-ok">OK</button><button class="edit-cancel">CANCEL</button></div>'
      );
    }
    function finishTdEdit(td, isOk) {
      if (isOk) {
        td.innerHTML = td.firstChild.value;
      } else {
        td.innerHTML = editingTd.data;
      }
      td.classList.remove('edit-td');
      editingTd = null;
    }
  3. 键盘移动老鼠

    聚焦在老鼠上。然后使用键盘的方向键移动它:

    在新窗口中演示

    P.S. 除了 #mouse 元素外,不要在任何地方放置事件处理程序。 P.P.S. 不要修改 HTML/CSS,这个方法应该是通用的,可以用于任何元素。

    打开一个任务沙箱。

    const mouse = document.querySelector('#mouse');
    mouse.tabIndex = 0;
    mouse.onclick = function () {
      mouse.focus();
      mouse.style.position = 'fixed';
      mouse.style.left = mouse.offsetLeft + 'px';
      mouse.style.top = mouse.offsetTop + 'px';
      mouse.onkeydown = function (event) {
        switch (event.key) {
          case 'ArrowLeft':
            this.style.left =
              parseInt(this.style.left) - this.offsetWidth + 'px';
            return false;
          case 'ArrowUp':
            this.style.top =
              parseInt(this.style.top) - this.offsetHeight + 'px';
            return false;
          case 'ArrowRight':
            this.style.left =
              parseInt(this.style.left) + this.offsetWidth + 'px';
            return false;
          case 'ArrowDown':
            this.style.top =
              parseInt(this.style.top) + this.offsetHeight + 'px';
            return false;
        }
      };
    };

表单:事件和方法提交

任务

  1. 模态框表单

    创建一个函数 showPrompt(html, callback),该函数显示一个表单,里面有消息 html,一个 input 字段和 OK/CANCEL 按钮。

    • 用户应该在文本字段中输入一些内容,然后按下 Enter 键或点击 OK 按钮,然后 callback(value) 就会被调用,参数为输入的值。
    • 否则,如果用户按下 Esc 键或点击 CANCEL 按钮,那么 callback(null) 就会被调用。

    在这两种情况下,输入过程都会结束,并移除表单。

    要求:

    • 表单应该在窗口的正中心。
    • 表单是 模态框(modal)。换句话说,在用户关闭模态框之前,用户无法与页面的其它部分进行任何交互。
    • 当表单显示后,焦点应该在用户需要进行输入的 <input> 输入框中。
    • 按键 Tab/Shift+Tab 应该能在表单字段之间切换焦点,不允许焦点离开表单字段到页面的其它元素上。

    使用示例:

    showPrompt("Enter something<br>...smart :)", function(value) {
      alert(value);
    });

    使用 iframe 嵌入的一个示例:

    P.S. 源文档有给表单设定了固定位置的 HTML/CSS,但是做成模态框的方式取决于你。

    打开一个任务沙箱。

    .hidden {
      display: none;
    }
    <button id="test">click</button>
    <div id="prompt-form-container" class="hidden">
      <!-- ... -->  
    </div>
    const test = document.querySelector('#test');
    const message = document.querySelector('#prompt-message');
    const promtpFormContainer = document.querySelector(
      '#prompt-form-container'
    );
    const promptForm = document.querySelector('#prompt-form');
    promptForm.onsubmit = function () {
      return false;
    };
    test.onclick = function () {
      showPrompt('Enter something<br>...smart', function (value) {
        alert(value);
      });
      // promtpFormContainer.classList.remove('hidden');
    };
    function showPrompt(text, callback) {
      promtpFormContainer.classList.remove('hidden');
      message.innerHTML = text;
      promptForm.ok.onclick = function () {
        callback(promptForm.text.value);
        promtpFormContainer.classList.add('hidden');
      };
      promptForm.cancel.onclick = function () {
        callback(null);
        promtpFormContainer.classList.add('hidden');
      };
    }

    参考解法:

    #cover-div {
      position: fixed;
      top: 0;
      left: 0;
      z-index: 9000;
      width: 100%;
      height: 100%;
      background-color: gray;
      opacity: 0.3;
    }
      <h2>Click the button below</h2>
      <input type="button" value="Click to show the form" id="show-button">
    // Show a half-transparent DIV to "shadow" the page
    // (the form is not inside, but near it, because it shouldn't be half-transparent)
    function showCover() {
      let coverDiv = document.createElement('div');
      coverDiv.id = 'cover-div';
      // make the page unscrollable while the modal form is open
      document.body.style.overflowY = 'hidden';
      document.body.append(coverDiv);
    }
    function hideCover() {
      document.getElementById('cover-div').remove();
      document.body.style.overflowY = '';
    }
    function showPrompt(text, callback) {
      showCover();
      let form = document.getElementById('prompt-form');
      let container = document.getElementById('prompt-form-container');
      document.getElementById('prompt-message').innerHTML = text;
      form.text.value = '';
      function complete(value) {
        hideCover();
        container.style.display = 'none';
        document.onkeydown = null;
        callback(value);
      }
      form.onsubmit = function() {
        let value = form.text.value;
        if (value == '') return false; // ignore empty submit
    
        complete(value);
        return false;
      };
      form.cancel.onclick = function() {
        complete(null);
      };
      document.onkeydown = function(e) {
        if (e.key == 'Escape') {
          complete(null);
        }
      };
      let lastElem = form.elements[form.elements.length - 1];
      let firstElem = form.elements[0];
      lastElem.onkeydown = function(e) {
        if (e.key == 'Tab' && !e.shiftKey) {
          firstElem.focus();
          return false;
        }
      };
      firstElem.onkeydown = function(e) {
        if (e.key == 'Tab' && e.shiftKey) {
          lastElem.focus();
          return false;
        }
      };
      container.style.display = 'block';
      form.elements.text.focus();
    }
    document.getElementById('show-button').onclick = function() {
      showPrompt("Enter something<br>...smart :)", function(value) {
        alert("You entered: " + value);
      });
    };

资源加载:onload, onerror

任务

  1. 使用回调函数加载图片

    通常,图片在被创建时才会被加载。所以,当我们向页面中添加 <img> 时,用户不会立即看到图片。浏览器首先需要加载它。

    为了立即显示一张图片,我们可以“提前”创建它,像这样:

    let img = document.createElement('img');
    img.src = 'my.jpg';

    浏览器开始加载图片,并将其保存到缓存中。以后,当相同图片出现在文档中时(无论怎样),它都会立即显示。

    创建一个函数 preloadImages(sources, callback),来加载来自数组 source 的所有图片,并在准备就绪时运行 callback

    例如,这段代码将在图片加载完成后显示一个 alert

    function loaded() {
      alert("Images loaded")
    }
    
    preloadImages(["1.jpg", "2.jpg", "3.jpg"], loaded);

    如果出现错误,函数应该仍假定图片已经“加载完成”。

    换句话说,当所有图片都已加载完成,或出现错误输出时,将执行 callback

    例如,当我们计划显示一个包含很多图片的可滚动图册,并希望确保所有图片都已加载完成时,这个函数很有用。

    在源文档中,你可以找到指向测试图片的链接,以及检查它们是否已加载完成的代码。它应该输出 300

    打开一个任务沙箱。

    function preloadImages(sources: string[], callback: () => void) {
      const list = sources.map((src) => {
        return new Promise((resolve, reject) => {
          let img = document.createElement('img');
          img.src = src;
          img.onload = resolve;
          img.onerror = reject;
        });
      });
      Promise.all(list).then(callback).catch(callback);
    }
    // 参考解法
    function preloadImages(sources, callback) {
      let counter = 0;
      function onLoad() {
        counter++;
        if (counter == sources.length) callback();
      }
      for(let source of sources) {
        let img = document.createElement('img');
        img.onload = img.onerror = onLoad;
        img.src = source;
      }
    }

ArrayBuffer,二进制数组

任务

  1. 拼接类型化数组

    给定一个 Uint8Array 数组,请写一个函数 concat(arrays),将数组拼接成一个单一数组并返回。

    打开带有测试的沙箱。

    function concat(arrays) {
      // ...your code...
      const len = arrays.reduce((previous, current) => {
        return previous + current.length;
      }, 0);
      const res = new Uint8Array(len);
      arrays.forEach((value, index) => {
        res.set(value, index * value.length);
      });
      return res;
    }
    // 参考解法
    function concat(arrays) {
      // sum of individual array lengths
      let totalLength = arrays.reduce((acc, value) => acc + value.length, 0);
    
      if (!arrays.length) return null;
    
      let result = new Uint8Array(totalLength);
    
      // for each array - copy it over result
      // next array is copied right after the previous one
      let length = 0;
      for(let array of arrays) {
        result.set(array, length);
        length += array.length;
      }
    
      return result;
    }

Fetch

任务

  1. 从 GitHub fetch 用户信息

    创建一个异步函数 getUsers(names),该函数接受 GitHub 登录名数组作为输入,查询 GitHub 以获取有关这些用户的信息,并返回 GitHub 用户数组。

    带有给定 USERNAME 的用户信息的 GitHub 网址是:https://api.github.com/users/USERNAME

    沙箱中有一个测试用例。

    重要的细节:

    1. 对每一个用户都应该有一个 fetch 请求。
    2. 请求不应该相互等待。以便能够尽快获取到数据。
    3. 如果任何一个请求失败了,或者没有这个用户,则函数应该返回 null 到结果数组中。

    打开带有测试的沙箱。

    async function getUsers(names) {
      const list = names.map(name => {
        return fetch(`https://api.github.com/users/${name}`);
      });
      const res = await Promise.all(list);
      return await Promise.all(res.map(item => {
          if (item.status === 404) return null;
            const data = item.json();
          return data;
      }));
    }
    // 参考解法
    async function getUsers(names) {
      let jobs = [];
    
      for(let name of names) {
        let job = fetch(`https://api.github.com/users/${name}`).then(
          successResponse => {
            if (successResponse.status != 200) {
              return null;
            } else {
              return successResponse.json();
            }
          },
          failResponse => {
            return null;
          }
        );
        jobs.push(job);
      }
    
      let results = await Promise.all(jobs);
    
      return results;
    }
    /**
    请注意:.then 调用紧跟在 fetch 后面,这样,当我们收到响应时,它不会等待其他的 fetch,而是立即开始读取 .json()。
    如果我们使用 await Promise.all(names.map(name => fetch(...))),并在 results 上调用 .json() 方法,那么它将会等到所有 fetch 都获取到响应数据才开始解析。通过将 .json() 直接添加到每个 fetch 中,我们就能确保每个 fetch 在收到响应时都会立即开始以 JSON 格式读取数据,而不会彼此等待。
    这个例子表明,即使我们主要使用 async/await,低级别的 Promise API 仍然很有用。
    */

LocalStorage, sessionStorage

任务

  1. 自动保存表单字段

    创建一个 textarea 字段,每当其值发生变化时,可以将其“自动保存”。

    因此,如果用户不小心关闭了页面,然后重新打开,他会发现之前未完成的输入仍然保留在那里。

    像这样:

    打开一个任务沙箱。

    area.value = localStorage.getItem('area');
    area.oninput = () => {
      localStorage.setItem('area', area.value)
    };

CSS 动画

任务

  1. 让飞机动起来 (CSS)

    生成如下图的动画(点击显示):

    • 点击后,图片会从 40x24px 变为 400x240px (变大十倍)。
    • 动画持续三秒。
    • 在动画结束后,输出:“Done!”。
    • 动画过程中,如果飞机被点击,这些操作不应该打断动画。

    打开一个任务沙箱。

    #flyjet.animate {
      width: 400px;
      height: 240px;
      transition: width 3s, height 3s;
    }
    flyjet.onclick = function () {
      flyjet.classList.add('animate');
    }
    flyjet.addEventListener('transitionend', function () {
      alert('done')
    })

    transitionend 是根据属性来触发,设置了两个属性所以会触发两次。

    // 参考解法
    flyjet.onclick = function() {
    
      let ended = false;
    
      flyjet.addEventListener('transitionend', function() {
        if (!ended) {
          ended = true;
          alert('Done!');
        }
      });
    
      flyjet.classList.add('growing');
    }
  2. 为飞机生成动画 (CSS)

    修改前一个的任务 让飞机动起来(CSS) 的解决方案,让飞机超过原有的大小 400x240px(跳脱出来),然后再回到之前的大小。

    这里是效果演示(点击飞机):

    在前一个解决方案的基础上做修改。

    #flyjet.animate {
      width: 400px;
      height: 240px;
      transition: width 3s, height 3s;
      transition-timing-function: cubic-bezier(0, 0, 0.75, 2);
    }
    /* 参考解法 */
    #flyjet {
      width: 40px;
      height: 24px;
      transition: all 3s cubic-bezier(0.25, 1.5, 0.75, 1.5);
    }
  3. 圆圈动画

    创建一个函数:showCircle(cx, cy, radius),来显示一个不断变大的圆。

    • cx,cy 为圆心相对于窗口的位置。
    • radius 为圆的半径。

    点击下方的按钮以演示效果:

    源文件中提供了一个具有合适样式的圆样例,因此你需要做的就是创建合适的动画。

    打开一个任务沙箱。

    <button onclick="showCircle(150, 150, 100)">showCircle(150, 150, 100)</button>
    .circle {
      transition-property: width, height, margin-left, margin-top;
      transition-duration: 2s;
      position: fixed;
      transform: translateX(-50%) translateY(-50%);
      background-color: red;
      border-radius: 50%;
      width: 0;
      height: 0;
    }
    .circle.grow {
      width: 200px;
      height: 200px;
      top: 150px;
      left: 150px;
    }
    function showCircle() {
      document.querySelector('.circle').classList.add('grow')
    }
    // 参考解法
    function showCircle(cx, cy, radius) {
      let div = document.createElement('div');
      div.style.width = 0;
      div.style.height = 0;
      div.style.left = cx + 'px';
      div.style.top = cy + 'px';
      div.className = 'circle';
      document.body.append(div);
    
      setTimeout(() => {
        div.style.width = radius * 2 + 'px';
        div.style.height = radius * 2 + 'px';
      }, 0);
    }

JavaScript 动画

任务

  1. 为弹跳的球设置动画

    做一个弹跳的球。点击查看应有的效果:

    打开一个任务沙箱。

    // 参考解法
    function makeEaseOut(timing) {
      return function(timeFraction) {
        return 1 - timing(1 - timeFraction);
      }
    }
    function bounce(timeFraction) {
      for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }
    ball.onclick = function() {
      let to = field.clientHeight - ball.clientHeight;
      animate({
        duration: 2000,
        timing: makeEaseOut(bounce),
        draw(progress) {
          ball.style.top = to * progress + 'px'
        }
      });
    };
  2. 设置动画使球向右移动

    让球向右移动。像这样:

    编写动画代码。终止时球到左侧的距离是 100px

    从前一个任务 为弹跳的球设置动画 的答案开始。

    // 参考解法
    function makeEaseOut(timing) {
      return function(timeFraction) {
        return 1 - timing(1 - timeFraction);
      }
    }
    function bounce(timeFraction) {
      for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }
    function quad(timeFraction) {
      return Math.pow(timeFraction, 2);
    }
    ball.onclick = function() {
      let height = field.clientHeight - ball.clientHeight;
      let width = 100;
      animate({
        duration: 2000,
        timing: makeEaseOut(bounce),
        draw: function(progress) {
          ball.style.top = height * progress + 'px'
        }
      });
      animate({
        duration: 2000,
        timing: makeEaseOut(quad),
        draw: function(progress) {
          ball.style.left = width * progress + "px"
        }
      });
    }

Custom elements

任务

  1. 计时器元素实例

    我们已经创建了 <time-formatted> 元素用于展示格式化好的时间。

    创建一个 <live-timer> 元素用于展示当前时间:

    1. 这个元素应该在内部使用 <time-formatted>,不要重复实现这个元素的功能。
    2. 每秒钟更新。
    3. 每一秒钟都应该有一个自定义事件 tick 被生成,这个事件的 event.detail 属性带有当前日期。(参考章节 创建自定义事件 )。

    使用方式:

    <live-timer id="elem"></live-timer>
    
    <script>
      elem.addEventListener('tick', event => console.log(event.detail));
    </script>

    例子:

    打开一个任务沙箱。

    请注意:

    1. 在元素被从文档移除的时候,我们会清除 setInterval 的 timer。这非常重要,否则即使我们不再需要它了,它仍然会继续计时。这样浏览器就不能清除这个元素占用和被这个元素引用的内存了。
    2. 我们可以通过 elem.date 属性得到当前时间。类所有的方法和属性天生就是元素的方法和属性。

    使用沙箱打开解决方案。

词边界:\b

任务

  1. 查找时间

    时间的格式是:hours:minutes。小时和分钟都是两位数,如 09:00

    编写正则表达式在字符串 Breakfast at 09:00 in the room 123:456. 中查找时间。

    P.S. 在这个任务里没有必要校验时间的正确性,所以 25:99 也可算做有效的结果。

    P.P.S. 正则表达式不能匹配 123:456

    const str = 'Breakfast at 09:00 in the room 123:456.';
    const reg = /\b\d\d:\d\d\b/g;
    const res = str.match(reg);
    console.log(res);

集合和范围 […]

任务

  1. Java[^script]

    我们有一个正则表达式 /Java[^script]/

    它会和字符串 Java 中的任何一部分匹配吗?会和字符串 JavaScript 任何一部分匹配吗?

    不匹配 Java ,匹配 JavaS

  2. 找到 hh: mm 或者 hh-mm 形式的时间字符串

    时间可以通过 hours:minutes 或者 hours-minutes 格式来表示。小时和分钟都有两个数字:09:00 或者 21-30

    写一个正则表达式来找到时间:

    let reg = /your regexp/g;
    alert( "Breakfast at 09:00. Dinner at 21-30".match(reg) ); // 09:00, 21-30

    附:在这个任务中,我们假设时间总是正确的,并不需要过滤掉像 “45:67” 这样错误的时间字符串。稍后我们也会处理这个问题。

    let reg = /\d\d[:-]\d\d/g;

量词 +,*,?{n}

任务

  1. 如何找到省略号 “…”?

    创建一个正则表达式来查找省略号:连续 3(或更多)个点。

    例如:

    let reg = /\.{3,}/g;
    alert( "Hello!... How goes?.....".match(reg) ); // ..., .....
  2. 针对 HTML 颜色的正则表达式

    创建一个正则表达式来搜寻格式为 #ABCDEF 的 HTML 颜色值:首个字符 # 以及接下来的六位十六进制字符。

    一个例子:

    let reg = /#[0-9a-f]{6}\b/gi;
    
    let str = "color:#121212; background-color:#AA00ef bad-colors:f#fddee #fd2 #12345678";
    
    alert( str.match(reg) )  // #121212,#AA00ef

    P.S. 在这个任务中,我们不需要其他的颜色格式,比如 #123rgb(1,2,3) 等。

贪婪量词和惰性量词

任务

  1. 对于 /d+? d+?/ 的匹配

    以下匹配的结果是什么?

    alert( "123 456".match(/\d+? \d+?/g) ); // ?

    结果是:123 4

    首先,懒惰模式 \d+? 尝试去获取尽可能少的字符,但当它检测到空格,就得出匹配结果 123

    然后,第二个 \d+? 就只获取一个字符,因为这就已足够了。

  2. 查找 HTML 注释

    找出文本中的所有注释:

    let reg = /<!--[\s\S]*?-->/g;
    
    let str = `... <!-- My -- comment
     test --> ..  <!----> ..
    `;
    
    alert( str.match(reg) ); // '<!-- My -- comment \n test -->', '<!---->'
  3. 寻找 HTML 标签

    创建一个正则表达式语句来寻找所有具有其属性的(闭合或非闭合)HTML 标签。

    用例:

    let reg = /<[^<>]+>/g; // 参考解法
    let reg = /<\w.*?>/g;
    
    let str = '<> <a href="/"> <input type="radio" checked> <b>';
    
    alert( str.match(reg) ); // '<a href="/">', '<input type="radio" checked>', '<b>'

    假设不包含 <>(也包括引号),这将会简单许多。

捕获组

任务

  1. Check MAC-address

    MAC-address of a network interface consists of 6 two-digit hex numbers separated by a colon.

    For instance: '01:32:54:67:89:AB'.

    Write a regexp that checks whether a string is MAC-address.

    Usage:

    let regexp = /([0-9a-f]{2}:){5}[0-9a-f]{2}/i;
    let regexp = /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/i;
    alert( regexp.test('01:32:54:67:89:AB') ); // true
    
    alert( regexp.test('0132546789AB') ); // false (no colons)
    
    alert( regexp.test('01:32:54:67:89') ); // false (5 numbers, must be 6)
    
    alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ ad the end)
  2. Find color in the format #abc or #abcdef

    Write a RegExp that matches colors in the format #abc or #abcdef. That is: # followed by 3 or 6 hexadecimal digits.

    Usage example:

    let regexp = /#([0-9a-f]{3}){1,2}\b/gi;
    
    let str = "color: #3f3; background-color: #AA00ef; and: #abcd";
    
    alert( str.match(regexp) ); // #3f3 #AA00ef

    P.S. This should be exactly 3 or 6 hex digits. Values with 4 digits, such as #abcd, should not match.

  3. Find all numbers

    Write a regexp that looks for all decimal numbers including integer ones, with the floating point and negative ones.

    An example of use:

    let regexp = /-?\d+(\.\d+)?/g;
    
    let str = "-1.5 0 2 -123.4.";
    
    alert( str.match(regexp) ); // -1.5, 0, 2, -123.4
  4. Parse an expression

    An arithmetical expression consists of 2 numbers and an operator between them, for instance:

    • 1 + 2
    • 1.2 * 3.4
    • -3 / -6
    • -2 - 2

    The operator is one of: "+", "-", "*" or "/".

    There may be extra spaces at the beginning, at the end or between the parts.

    Create a function parse(expr) that takes an expression and returns an array of 3 items:

    1. The first number.
    2. The operator.
    3. The second number.

    For example:

    let [a, op, b] = parse("1.2 * 3.4");
    
    alert(a); // 1.2
    alert(op); // *
    alert(b); // 3.4
    
    function parse(expression) {
      const reg = /\s*(?<first>\d+(\.\d+)?)\s*(?<op>[*+-/])\s*(?<second>\d+(\.\d+)?)\s*/;
      const { groups } = expression.match(reg);
      return [groups.first, groups.op, groups.second];
    }
    
    // Groups that contain decimal parts (number 2 and 4) (.\d+) can be excluded by adding ?: to the beginning: (?:\.\d+)?.
    function parse(expr) {
      let regexp = /(-?\d+(?:\.\d+)?)\s*([-+*\/])\s*(-?\d+(?:\.\d+)?)/;
    
      let result = expr.match(regexp);
    
      if (!result) return [];
      result.shift();
    
      return result;
    }

选择 (OR) |

任务

  1. 查找编程语言

    有许多编程语言,例如 Java, JavaScript, PHP, C, C++。

    构建一个正则式,用来匹配字符串 Java JavaScript PHP C++ C 中包含的编程语言:

    let reg = /Java(Script)?|PHP|C(\+\+)?/g;
    
    alert("Java JavaScript PHP C++ C".match(reg)); // Java JavaScript PHP C++ C
  2. 查找 bbtag 对

    “bb-tag” 形如 [tag]...[/tag]tag 匹配 burlquote 其中之一。

    例如:

    [b]text[/b]
    [url]http://google.com[/url]

    BB-tags 可以嵌套。但标签不能自嵌套,比如:

    可行:
    [url] [b]http://google.com[/b] [/url]
    [quote] [b]text[/b] [/quote]
    
    不可行:
    [b][b]text[/b][/b]

    标签可以包含换行,通常为以下形式:

    [quote]
      [b]text[/b]
    [/quote]

    构造一个正则式用于查找所有 BB-tags 和其内容。

    举例:

    let reg = /your regexp/g;
    
    let str = "..[url]http://google.com[/url]..";
    alert( str.match(reg) ); // [url]http://google.com[/url]

    如果标签嵌套,那么我们需要记录匹配的外层标签(如果希望继续查找匹配的标签内容的话):

    let reg = /\[(b|url|quote)\][\s\S]*?\[\/\1\]/g;
    
    let str = "..[url][b]http://google.com[/b][/url]..";
    alert( str.match(reg) ); // [url][b]http://google.com[/b][/url]
  3. 查询引用字符串

    构建一个正则表达式用于匹配双引号内的字符串 "..."

    最重要的部分是字符串应该支持转义,正如 JavaScript 字符串的行为一样。例如,引号可以插入为 \",换行符为 \n,斜杠本身为 \\

    let str = "Just like \"here\".";

    对我们来说,重要的是转义的引号 \" 不会结束字符串匹配。

    所以,我们应该匹配两个引号之间的内容,且忽略中间转义的引号。

    这是任务的关键部分,否则这个任务就没什么意思了。

    匹配字符串示例:

    .. "test me" ..
    .. "Say \"Hello\"!" ... (escaped quotes inside)
    .. "\\" ..  (double slash inside)
    .. "\\ \"" ..  (double slash and an escaped quote inside)

    在 JavaScript 中,双斜杠用于把斜杠转义为字符串,如下所示:

    let reg = /"(\\.|[^"\\])*"/g;
    let str = ' .. "test me" .. "Say \\"Hello\\"!" .. "\\\\ \\"" .. ';
    
    alert( str.match(reg) ); // "test me","Say \"Hello\"!","\\ \""
  4. 查找完整标签

    写出一个正则表达式,用于查找 <style...> 标签。它应该匹配完整的标签:该标签可能是没有属性的标签 <style> 或是有很多属性的标签 <style type="..." id="...">

    …同时正则表达式不应该匹配 <styler>

    举例如下:

    let reg = /<style(>|\s.*?>)/g;
    
    alert( '<style> <styler> <style test="...">'.match(reg) ); // <style>, <style test="...">

前瞻断言与后瞻断言

任务

  1. Find non-negative integers

    There’s a string of integer numbers.

    Create a regexp that looks for only non-negative ones (zero is allowed).

    An example of use:

    let regexp = /\b(?<!-)\d+\b/g;
    let regexp = /(?<![-\d])\d+/g;
    
    let str = "0 12 -5 123 -18";
    
    alert( str.match(regexp) ); // 0, 12, 123
  2. Вставьте после фрагмента

    Есть строка с HTML-документом.

    Вставьте после тега <body> (у него могут быть атрибуты) строку <h1>Hello</h1>.

    Например:

    let regexp = /ваше регулярное выражение/;
    
    let str = `
    <html>
      <body style="height: 200px">
      ...
      </body>
    </html>
    `;
    
    str = str.replace(regexp, `<h1>Hello</h1>`);

    После этого значение str:

    <html>
      <body style="height: 200px"><h1>Hello</h1>
      ...
      </body>
    </html>

    实在看不懂是啥……