FE

前言

其实浏览器内置了很多的事件。

而许多事件会自动触发浏览器执行某些行为。

例如:

  • 点击一个链接 —— 触发导航(navigation)到该 URL。
  • 点击表单的提交按钮 —— 触发提交到服务器的行为。
  • 在文本上按下鼠标按钮并移动 —— 选中文本。

如果我们使用 JavaScript 处理一个事件,那么我们通常不希望发生相应的浏览器行为。而是想要实现其他行为进行替代。

阻止浏览器行为

有两种方式来告诉浏览器我们不希望它执行默认行为:

  • 主流的方式是使用 event 对象。有一个 event.preventDefault() 方法。
  • 如果处理程序是使用 on<event>(而不是 addEventListener)分配的,那返回 false 也同样有效。

在下面这个示例中,点击链接不会触发导航(navigation),浏览器不会执行任何操作:

<a href="/" onclick="return false">Click here</a>
or
<a href="/" onclick="event.preventDefault()">here</a>

Click here
or
here


请注意的是,处理程序是使用 on<event>分配的,并进行return返回false并不是一个好的实践。

从处理程序返回 false 是一个例外
事件处理程序返回的值通常会被忽略。

唯一的例外是从使用 on<event> 分配的处理程序中返回的 return false。

在所有其他情况下,return 值都会被忽略。并且,返回 true 没有意义。

处理程序选项 “passive”

addEventListener 的可选项 passive: true 向浏览器发出信号,表明处理程序将不会调用 preventDefault()。

为什么需要这样做?

移动设备上会发生一些事件,例如 touchmove(当用户在屏幕上移动手指时),默认情况下会导致滚动,但是可以使用处理程序的 preventDefault() 来阻止滚动。

因此,当浏览器检测到此类事件时,它必须首先处理所有处理程序,然后如果没有任何地方调用 preventDefault,则页面可以继续滚动。但这可能会导致 UI 中不必要的延迟和“抖动”。

passive: true 选项告诉浏览器,处理程序不会取消滚动。然后浏览器立即滚动页面以提供最大程度的流畅体验,并通过某种方式处理事件。

对于某些浏览器(Firefox,Chrome),默认情况下,touchstart 和 touchmove 事件的 passive 为 true。

event.defaultPrevented

如果默认行为被阻止,那么 event.defaultPrevented 属性为 true,否则为 false。

这儿有一个有趣的用例。

你还记得我们在 冒泡和捕获 一章中讨论过的 event.stopPropagation(),以及为什么停止冒泡是不好的吗?

有时我们可以使用 event.defaultPrevented 来代替,来通知其他事件处理程序,该事件已经被处理。

我们来看一个实际的例子。

默认情况下,浏览器在 contextmenu 事件(单击鼠标右键)时,显示带有标准选项的上下文菜单。我们可以阻止它并显示我们自定义的菜单,就像这样:

<button>Right-click shows browser context menu</button>

<button oncontextmenu="alert('Draw our menu'); return false">
  Right-click shows our context menu
</button>


现在,除了该上下文菜单外,我们还想实现文档范围的上下文菜单。

右键单击时,应该显示最近的上下文菜单:

<p>Right-click here for the document context menu</p>
<button id="elem">Right-click here for the button context menu</button>

<script>
  elem.oncontextmenu = function(event) {
    event.preventDefault();
    alert("Button context menu");
  };

  document.oncontextmenu = function(event) {
    event.preventDefault();
    alert("Document context menu");
  };
</script>

Right-click here for the document context menu


问题是,当我们点击 elem 时,我们会得到两个菜单:按钮级和文档级(事件冒泡)的菜单。

如何修复呢?其中一个解决方案是:“当我们在按钮处理程序中处理鼠标右键单击事件时,我们阻止其冒泡”,使用 event.stopPropagation():

<p>Right-click for the document menu</p>
<button id="elem">Right-click for the button menu (fixed with event.stopPropagation)</button>

<script>
  elem.oncontextmenu = function(event) {
    event.preventDefault();
    event.stopPropagation();
    alert("Button context menu");
  };

  document.oncontextmenu = function(event) {
    event.preventDefault();
    alert("Document context menu");
  };
</script>

Right-click for the document menu


现在按钮级菜单如期工作。但是代价太大,我们拒绝了任何外部代码对右键点击信息的访问,包括收集统计信息的计数器等。这是非常不明智的。

另一个替代方案是,检查 document 处理程序是否阻止了浏览器的默认行为?如果阻止了,那么该事件已经得到了处理,我们无需再对此事件做出反应。

<p>Right-click for the document menu (added a check for event.defaultPrevented)</p>
<button id="elem">Right-click for the button menu</button>

<script>
  elem.oncontextmenu = function(event) {
    event.preventDefault();
    alert("Button context menu");
  };

  document.oncontextmenu = function(event) {
    if (event.defaultPrevented) return;

    event.preventDefault();
    alert("Document context menu");
  };
</script>

Right-click for the document menu (added a check for event.defaultPrevented)

现在一切都可以正常工作了。如果我们有嵌套的元素,并且每个元素都有自己的上下文菜单,那么这也是可以运行的。只需确保检查每个 contextmenu 处理程序中的 event.defaultPrevented。

event.stopPropagation() 和 event.preventDefault()
正如我们所看到的,event.stopPropagation() 和 event.preventDefault()(也被认为是 return false)是两个不同的东西。它们之间毫无关联。

嵌套的上下文菜单结构
还有其他实现嵌套上下文菜单的方式。其中之一是拥有一个具有 document.oncontextmenu 处理程序的全局对象,以及使我们能够在其中存储其他处理程序的方法。
该对象将捕获任何右键单击,浏览存储的处理程序并运行适当的处理程序。

但是,每段需要上下文菜单的代码都应该了解该对象,并使用它的帮助,而不是使用自己的 contextmenu 处理程序。

总结

有很多默认的浏览器行为:

  • mousedown —— 开始选择(移动鼠标进行选择)。
  • <input type="checkbox"> 上的 click —— 选中/取消选中的 input。
  • submit —— 点击<input type="submit">或者在表单字段中按下 Enter 键会触发该事件,之后浏览器将提交表单。
  • keydown —— 按下一个按键会导致将字符添加到字段,或者触发其他行为。
  • contextmenu —— 事件发生在鼠标右键单击时,触发的行为是显示浏览器上下文菜单。
  • ……还有更多……

如果我们只想通过 JavaScript 来处理事件,那么所有默认行为都是可以被阻止的。

想要阻止默认行为 —— 可以使用 event.preventDefault() return false。第二个方法只适用于通过 on<event> 分配的处理程序。

addEventListener passive: true 选项告诉浏览器该行为不会被阻止。这对于某些移动端的事件(像 touchstart 和 touchmove)很有用,用以告诉浏览器在滚动之前不应等待所有处理程序完成。

如果默认行为被阻止,event.defaultPrevented 的值会变成 true,否则为 false。

前言

捕获和冒泡允许我们实现最强大的事件处理模式之一,即 事件委托 模式。

这个想法是,如果我们有许多以类似方式处理的元素,那么就不必为每个元素分配一个处理程序 —— 而是将单个处理程序放在它们的共同祖先上。

在处理程序中,我们获取 event.target 以查看事件实际发生的位置并进行处理。

事件委托简介

Bagua Chart: Direction, Element, Color, Meaning
Northwest
Metal
Silver
Elders
North
Water
Blue
Change
Northeast
Earth
Yellow
Direction
West
Metal
Gold
Youth
Center
All
Purple
Harmony
East
Wood
Blue
Future
Southwest
Earth
Brown
Tranquility
South
Fire
Orange
Fame
Southeast
Wood
Green
Romance

该表格有 9 个单元格(cell),但可以有 99 个或 9999 个单元格,这都不重要。

我们的任务是在点击时高亮显示被点击的单元格 <td>

与其为每个 <td>(可能有很多)分配一个 onclick 处理程序 —— 我们可以在<table>元素上设置一个“捕获所有”的处理程序。

它将使用 event.target 来获取点击的元素并高亮显示它。

代码如下:

let selectedTd;

table.onclick = function(event) {
  let target = event.target; // 在哪里点击的?

  if (target.tagName != 'TD') return; // 不在 TD 上?那么我们就不会在意

  highlight(target); // 高亮显示它
};

function highlight(td) {
  if (selectedTd) { // 移除现有的高亮显示,如果有的话
    selectedTd.classList.remove('highlight');
  }
  selectedTd = td;
  selectedTd.classList.add('highlight'); // 高亮显示新的 td
}

此代码不会关心在表格中有多少个单元格。我们可以随时动态添加/移除 <td>,高亮显示仍然有效。

尽管如此,但还是存在缺陷。

Event-Bubbling

点击可能不是发生在 <td> 上,而是发生在其内部。

在我们的例子中,如果我们看一下 HTML 内部,我们可以看到 <td> 内还有嵌套的标签,例如 <strong>

在处理程序 table.onclick 中,我们应该接受这样的 event.target,并确定该点击是否在 <td> 内。

下面是改进后的代码:

table.onclick = function(event) {
  let td = event.target.closest('td'); // (1)

  if (!td) return; // (2)

  if (!table.contains(td)) return; // (3)

  highlight(td); // (4)
};

解释:

  • elem.closest(selector) 方法返回与 selector 匹配的最近的祖先。在我们的例子中,我们从源元素开始向上寻找 <td>

  • 如果 event.target 不在任何 <td> 中,那么调用将立即返回,因为这里没有什么事儿可做。

  • 对于嵌套的表格,event.target 可能是一个 <td>,但位于当前表格之外。因此我们需要检查它是否是我们的表格中的 <td>。如果是的话,就高亮显示它。

委托示例:标记中的行为

事件委托还有其他用途。(译注:本节标题中的“标记中的行为”即 action in markup)

例如,我们想要编写一个有“保存”、“加载”和“搜索”等按钮的菜单。并且,这里有一个具有 save、load 和 search 等方法的对象。如何匹配它们?

第一个想法可能是为每个按钮分配一个单独的处理程序。但是有一个更优雅的解决方案。 我们可以为整个菜单添加一个处理程序,并为具有方法调用的按钮添加 data-action 特性(attribute):

<button data-action="save">Click to Save</button>

处理程序读取特性(attribute)并执行该方法。工作示例如下:

<div id="menu">
  <button data-action="save">Save</button>
  <button data-action="load">Load</button>
  <button data-action="search">Search</button>
</div>

<script>
  class Menu {
    constructor(elem) {
      this._elem = elem;
      elem.onclick = this.onClick.bind(this); // (*)
    }

    save() {
      alert('saving');
    }

    load() {
      alert('loading');
    }

    search() {
      alert('searching');
    }

    onClick(event) {
      let action = event.target.dataset.action;
      if (action) {
        this[action]();
      }
    };
  }

  new Menu(menu);
</script>

请注意,this.onClick 在 (*) 行中被绑定到了 this。这很重要,因为否则内部的 this 将引用 DOM 元素(elem),而不是 Menu 对象,那样的话,this[action] 将不是我们所需要的。

那么,这里的委托给我们带来了什么好处?

  • 我们不需要编写代码来为每个按钮分配一个处理程序。只需要创建一个方法并将其放入标记(markup)中即可。
  • HTML 结构非常灵活,我们可以随时添加/移除按钮。

我们也可以使用 .action-save,.action-load 类,但 data-action 特性(attribute)在语义上更好。我们也可以在 CSS 规则中使用它。

“行为”模式

我们还可以使用事件委托将“行为(behavior)”以 声明方式 添加到具有特殊特性(attribute)和类的元素中。

行为模式分为两个部分:

  • 我们将自定义特性添加到描述其行为的元素。
  • 用文档范围级的处理程序追踪事件,如果事件发生在具有特定特性的元素上 —— 则执行行为(action)。

行为:计数器

例如,这里的特性 data-counter 给按钮添加了一个“点击增加”的行为。

<script>
  document.addEventListener('click', function(event) {

    if (event.target.dataset.counter != undefined) { // 如果这个特性存在...
      event.target.value++;
    }

  });
</script>
Counter: One more counter:

总结

事件委托真的很酷!这是 DOM 事件最有用的模式之一。

它通常用于为许多相似的元素添加相同的处理,但不仅限于此。

算法

  • 在容器(container)上放一个处理程序。
  • 在处理程序中 —— 检查源元素 event.target。
  • 如果事件发生在我们感兴趣的元素内,那么处理该事件。

好处

  • 简化初始化并节省内存:无需添加许多处理程序。
  • 更少的代码:添加或移除元素时,无需添加/移除处理程序。
  • DOM 修改 :我们可以使用 innerHTML 等,来批量添加/移除元素。

事件委托也有其局限性

首先,事件必须冒泡。而有些事件不会冒泡。此外,低级别的处理程序不应该使用 event.stopPropagation()
其次,委托可能会增加 CPU 负载,因为容器级别的处理程序会对容器中任意位置的事件做出反应,而不管我们是否对该事件感兴趣。但是,通常负载可以忽略不计,所以我们不考虑它。

前言

让我们从一个示例开始。

处理程序(handler)被分配给了 <div>,但是如果你点击任何嵌套的标签(例如 <em><code>),该处理程序也会运行:

<div onclick="alert('The handler!')">
  <em>If you click on <code>EM</code>, the handler on <code>DIV</code> runs.</em>
</div>

So, if you click on EM, the handler on DIV runs.

冒泡

冒泡(bubbling)原理很简单。

当一个事件发生在一个元素上,它会首先运行在该元素上的处理程序,然后运行其父元素上的处理程序,然后一直向上到其他祖先上的处理程序。

假设我们有 3 层嵌套 FORM > DIV > P ,它们各自拥有一个处理程序:

<style>
  body * {
    margin: 10px;
    border: 1px solid blue;
  }
</style>

<form onclick="alert('form')">FORM
  <div onclick="alert('div')">DIV
    <p onclick="alert('p')">P</p>
  </div>
</form>

现在,如果你点击 <p>,那么会发生什么?

  1. 首先,处理程序在 <p> 上运行。

  2. 然后,它在 <div> 上运行。

  3. 然后,它在 <form> 上运行。

  4. 最后,它在 document 上运行。

这种行为被称为“事件冒泡”,因为它像气泡一样从元素冒出来。

因此,如果我们点击 <p>,那么我们将看到 3 个 alert:p → div → form。

event.target

父元素上的处理程序始终可以获取事件实际发生位置的详细信息。

引发事件的那个嵌套层级最深的元素被称为目标元素,可以通过 event.target 访问。

注意与 this(=event.currentTarget)之间的区别:

  • this 是在处理程序运行时的“当前”元素,它始终相同。

  • event.target 是在事件发生时的“目标”元素,它可以是任何元素,它在冒泡过程中改变。

那我们来举个例子吧!

例如,如果我们有一个处理程序 form.onclick,那么它可以“捕获”表单内的所有点击。无论点击发生在哪里,它都会冒泡到 <form> 并运行处理程序。

注意哈!这边是将所有的事件都绑定到了 form 上,而不是每个元素上。

在 form.onclick 处理程序中:

this(=event.currentTarget)是 <form> 元素,因为处理程序在它上面运行。
event.target 是表单中实际被点击的元素。

A click shows both event.target and this to compare:

FORM
DIV

P

停止冒泡

冒泡事件从目标元素开始向上冒泡。通常,它会一直上升到 <html>,然后再到 document 对象,有些事件甚至会到达 window,它们会调用路径上所有的处理程序。

但是任意处理程序都可以决定事件已经被完全处理,并停止冒泡。

用于停止冒泡的方法是 event.stopPropagation()。

例如,如果你点击 <button>,这里的 body.onclick 不会工作:

<body onclick="alert(`the bubbling doesn't reach here`)">
  <button onclick="event.stopPropagation()">Click me</button>
</body>

捕获

事件处理的另一个阶段被称为“捕获(capturing)”。

DOM 事件标准描述了事件传播的 3 个阶段:

  1. 捕获阶段(Capturing phase)—— 事件(从 Window)向下走近元素。
  2. 目标阶段(Target phase)—— 事件到达目标元素。
  3. 冒泡阶段(Bubbling phase)—— 事件从元素上开始冒泡。

下面是在表格中点击 <td> 的图片,摘自规范:

Event-Bubbling

也就是说:点击 <td>,事件首先通过祖先链向下到达元素(捕获阶段),然后到达目标(目标阶段),最后上升(冒泡阶段),在途中调用处理程序。

之前,我们只讨论了冒泡,因为捕获阶段很少被使用。通常我们看不到它。

使用 on<event> 属性或使用 HTML 特性(attribute)或使用两个参数的 addEventListener(event, handler) 添加的处理程序,对捕获一无所知,它们仅在第二阶段和第三阶段运行。

总结

当一个事件发生时 —— 发生该事件的嵌套最深的元素被标记为“目标元素”(event.target)。

  • 然后,事件从文档根节点向下移动到 event.target,并在途中调用分配了 addEventListener(…, true) 的处理程序(true 是 {capture: true} 的一个简写形式)。
  • 然后,在目标元素自身上调用处理程序。
  • 然后,事件从 event.target 冒泡到根,调用使用 on<event>、HTML 特性(attribute)和没有第三个参数的,或者第三个参数为 false/{capture:false} 的addEventListener 分配的处理程序。

每个处理程序都可以访问 event 对象的属性:

  • event.target —— 引发事件的层级最深的元素。
  • event.currentTarget(=this)—— 处理事件的当前元素(具有处理程序的元素)
  • event.eventPhase —— 当前阶段(capturing=1,target=2,bubbling=3)。

任何事件处理程序都可以通过调用 event.stopPropagation() 来停止事件,但不建议这样做,因为我们不确定是否确实不需要冒泡上来的事件,也许是用于完全不同的事情。

捕获阶段很少使用,通常我们会在冒泡时处理事件。这背后有一个逻辑。

事件处理程序也是如此。在特定元素上设置处理程序的代码,了解有关该元素最详尽的信息。特定于 的处理程序可能恰好适合于该 ,这个处理程序知道关于该元素的所有信息。所以该处理程序应该首先获得机会。然后,它的直接父元素也了解相关上下文,但了解的内容会少一些,以此类推,直到处理一般性概念并运行最后一个处理程序的最顶部的元素为止。

前言

Web 组件是一组 Web 原生 API 的总称,允许开发人员创建可重用的自定义元素。

Vue 和 Web Components 主要是互补技术。无论是将自定义元素集成到现有的 Vue 应用程序中,还是使用 Vue 构建和分发自定义元素,Vue 对使用和创建自定义元素都有出色的支持。

什么是Custom Elements

Web 组件的一个关键特性是创建自定义元素:即由 Web 开发人员定义行为的 HTML 元素,扩展了浏览器中可用的元素集。

Custom Elements有两种类型的自定义元素:

  • 自定义内置元素(Customized built-in element) 继承自标准的 HTML 元素,例如 HTMLImageElement 或 HTMLParagraphElement。它们的实现定义了标准元素的行为。
  • 独立自定义元素(Autonomous custom element) 继承自 HTML 元素基类 HTMLElement。你必须从头开始实现它们的行为。

自定义元素生命周期回调

当然Custom Elements也有自定义元素生命周期回调

一旦你的自定义元素被注册,当页面中的代码以特定方式与你的自定义元素交互时,浏览器将调用你的类的某些方法。 通过提供这些方法的实现,规范称之为生命周期回调,你可以运行代码来响应这些事件。

自定义元素生命周期回调包括:

  • connectedCallback():每当元素添加到文档中时调用。规范建议开发人员尽可能在此回调中实现自定义元素的设定,而不是在构造函数中实现。
  • disconnectedCallback():每当元素从文档中移除时调用。
  • adoptedCallback():每当元素被移动到新文档中时调用。
  • attributeChangedCallback():在属性更改、添加、移除或替换时调用。有关此回调的更多详细信息,请参见响应属性变化。

以下是一个记录这些生命周期事件的最小自定义元素示例:

// 为这个元素创建类
class MyCustomElement extends HTMLElement {
  static observedAttributes = ["color", "size"];

  constructor() {
    // 必须首先调用 super 方法
    super();
  }

  connectedCallback() {
    console.log("自定义元素添加至页面。");
  }

  disconnectedCallback() {
    console.log("自定义元素从页面中移除。");
  }

  adoptedCallback() {
    console.log("自定义元素移动至新页面。");
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 已变更。`);
  }
}

customElements.define("my-custom-element", MyCustomElement);

在 Vue 中使用自定义元素​

在 Vue 应用程序中使用自定义元素在很大程度上与使用原生 HTML 元素相同,但需要记住以下几点:

跳过组件解析​

默认情况下,Vue 会尝试将非原生 HTML 标签解析为已注册的 Vue 组件,然后再将其渲染为自定义元素。 这将导致 Vue 在开发过程中发出“无法解析组件”警告。为了让 Vue 知道某些元素应该被视为自定义元素并跳过组件解析,我们可以指定compilerOptions.isCustomElement选项。

如果您使用 Vue 进行构建设置,则该选项应通过构建配置传递,因为它是编译时选项。

浏览器内配置示例:

// Only works if using in-browser compilation.
// If using build tools, see config examples below.
app.config.compilerOptions.isCustomElement = (tag) => tag.includes('-')

Vite 配置示例​

// vite.config.js
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // treat all tags with a dash as custom elements
          isCustomElement: (tag) => tag.includes('-')
        }
      }
    })
  ]
}

Vue CLI 配置示例​

// vue.config.js
module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => ({
        ...options,
        compilerOptions: {
          // treat any tag that starts with ion- as custom elements
          isCustomElement: tag => tag.startsWith('ion-')
        }
      }))
  }
}

使用 Vue 构建自定义元素​

自定义元素的主要好处是它们可以与任何框架一起使用,甚至可以在没有框架的情况下使用。这使得它们非常适合分发最终消费者可能不使用相同前端堆栈的组件,或者当您希望将最终应用程序与其使用的组件的实现细节隔离时。

定义自定义元素​
Vue 支持通过该方法使用完全相同的 Vue 组件 API 创建自定义元素defineCustomElement。该方法接受与 相同的参数defineComponent,但返回一个扩展的自定义元素构造函数HTMLElement:

模板

<my-vue-element></my-vue-element>
import { defineCustomElement } from 'vue'

const MyVueElement = defineCustomElement({
  // normal Vue component options here
  props: {},
  emits: {},
  template: `...`,

  // defineCustomElement only: CSS to be injected into shadow root
  styles: [`/* inlined css */`]
})

// Register the custom element.
// After registration, all `<my-vue-element>` tags
// on the page will be upgraded.
customElements.define('my-vue-element', MyVueElement)

// You can also programmatically instantiate the element:
// (can only be done after registration)
document.body.appendChild(
  new MyVueElement({
    // initial props (optional)
  })
)

当我们谈论自定义元素和Vue组件时,实际上在讨论构建网页应用程序时使用的两种不同方式。自定义元素是一种Web标准,就像HTML元素一样,而Vue组件是Vue.js框架提供的一种更高级的构建方式。

有人认为只使用自定义元素是更“未来”的方式,但这段文字指出这种看法过于简单。它列举了一些原因,说明为什么Vue组件模型更为实用。其中一些关键点包括:

Vue组件提供了更多功能,如方便的模板系统、管理状态的方法,以及在服务器上渲染组件的高效方式。这些功能对于构建复杂的应用程序是必要的。

Vue组件支持强大的组合机制,而自定义元素在这方面有一些局限。这意味着使用Vue,你更容易构建灵活而强大的组件结构。使用Vue,你能够借助一个成熟的框架和庞大的社区,而不必自己构建和维护一套内部框架。

自定义元素和 Vue 组件之间确实存在一定程度的功能重叠:它们都允许我们定义具有数据传递、事件发出和生命周期管理的可重用组件。然而,Web 组件 API 的级别相对较低且简单。要构建实际的应用程序,我们需要一些该平台未涵盖的附加功能:

  • 声明性且高效的模板系统;

  • 反应式状态管理系统,有利于跨组件逻辑提取和重用;

  • 一种在服务器上渲染组件并在客户端 (SSR) 上进行组合的高性能方法,这对于 SEO 和LCP 等 Web Vitals 指标非常重要。原生自定义元素 SSR 通常涉及在 Node.js 中模拟 DOM,然后序列化变异的 DOM,而 Vue SSR 会尽可能编译为字符串连接,这更加高效。

defineCustomElement API Vue 组件转化

使用 defineCustomElement API 将 Vue 组件转化为可以注册的自定义元素类有一些好处:

  1. 跨框架集成: 通过将 Vue 组件转化为自定义元素类,你可以在不同的前端框架和库中使用这个组件。这种方式使得你的组件更具通用性,可以与其他技术栈集成。

  2. 独立使用: 将 Vue 组件注册为自定义元素后,它可以独立于 Vue 应用使用。这意味着你可以在没有整个 Vue 应用的情况下使用该组件,以及在不同的构建系统和模块系统中引入它。

  3. 逐步迁移: 如果你的应用是逐步迁移到 Vue 的,你可以通过将某些组件转化为自定义元素来实现渐进式迁移。这使得你可以逐步地将 Vue 组件引入到一个已经存在的项目中,而无需一次性重写整个应用。

  4. Web Components 标准兼容性: 将 Vue 组件注册为自定义元素使其与 Web Components 标准兼容。这意味着你可以利用 Web Components 生态系统的其他工具和库,使你的组件更具互操作性。

也就是说defineCustomElement API 的作用是将 Vue 组件编译为可以在浏览器中使用的自定义元素(Custom Element)。这意味着你不需要依赖 Vue 编译器在浏览器端实时编译 Vue 组件。

在使用 defineCustomElement API 时,Vue 组件会被提前编译成原生的自定义元素,这样就可以在浏览器中直接使用,而无需在运行时进行编译。

总体而言,通过使用 defineCustomElement API,你可以将 Vue 组件与自定义元素相结合,从而在更广泛的上下文中使用和共享这些组件,提高了组件的可复用性和灵活性。这在跨端组件开发集成上有很大的好处,你大可以先将组件开发成自定义元素,然后再在不同的端中使用。一个很典型的例子是我们之前提到的vue2和vue3的集成实现,这意味着你只需要将vue3的组件编译成自定义元素,然后在vue2中使用即可。

前言

Hot Module Replacement(以下简称 HMR)是 Webpack 发展至今一大特性 ,当你对代码进行修改并保存后,Webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。

例如,在开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐,这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。

模块热替换(hot module replacement)

模块热替换(HMR - hot module replacement)功能会在应用程序运行过程中,替换、添加或删除 模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面期间丢失的应用程序状态。

  • 只更新变更内容,以节省宝贵的开发时间。

  • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。

为什么需要 HMR

在 webpack HMR 功能之前,已经有很多 live reload 的工具或库,比如 live-server,这些库监控文件的变化,然后通知浏览器端刷新页面,那么我们为什么还需要 HMR 呢?答案其实在上文中已经提及一些。

  • live reload 工具并不能够保存应用的状态(states),当刷新页面后,应用之前状态丢失,还是上文中的例子,点击按钮出现弹窗,当浏览器刷新后,弹窗也随即消失,要恢复到之前状态,还需再次点击按钮。而 webapck HMR 则不会刷新浏览器,而是运行时对模块进行热替换,保证了应用状态不会丢失,提升了开发效率。

  • 在古老的开发流程中,我们可能需要手动运行命令对代码进行打包,并且打包后再手动刷新浏览器页面,而这一系列重复的工作都可以通过 HMR 工作流来自动化完成,让更多的精力投入到业务中,而不是把时间浪费在重复的工作上。

  • HMR 兼容市面上大多前端框架或库,比如 React Hot LoaderVue-loader,能够监听 React 或者 Vue 组件的变化,实时将最新的组件更新到浏览器端。Elm Hot Loader 支持通过 webpack 对 Elm 语言代码进行转译并打包,当然它也实现了 HMR 功能。

HMR 的工作原理图解

初识 HMR 的时候觉得其很神奇,一直有一些疑问萦绕在脑海。

  • webpack 可以将不同的模块打包成 bundle 文件或者几个 chunk 文件,但是当我通过 webpack HMR 进行开发的过程中,我并没有在我的 dist 目录中找到 webpack 打包好的文件,它们去哪呢?

  • 通过查看 webpack-dev-server 的 package.json 文件,我们知道其依赖于 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?

  • 使用 HMR 的过程中,通过 Chrome 开发者工具我知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?

  • 浏览器拿到最新的模块代码,HMR 又是怎么将老的模块替换成新的模块,在替换的过程中怎样处理模块之间的依赖关系?

  • 当模块的热替换过程中,如果替换模块失败,有什么回退机制吗?

带着上面的问题,于是决定深入到 webpack 源码,寻找 HMR 底层的奥秘。

webpack-optimization

图一:HMR 工作流程图解

上图是webpack 配合 webpack-dev-server 进行应用开发的模块热更新流程图。

上图底部红色框内是服务端,而上面的橙色框是浏览器端。

绿色的方框是 webpack 代码控制的区域。蓝色方框是 webpack-dev-server 代码控制的区域,洋红色的方框是文件系统,文件修改后的变化就发生在这,而青色的方框是应用本身。

上图显示了我们修改代码到模块热更新完成的一个周期,通过深绿色的阿拉伯数字符号已经将 HMR 的整个过程标识了出来。

  • 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。

  • 第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。

  • 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。

  • 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。

webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpackwebpack/hot/dev-server 的工作就是根据
webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。

HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。

而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。

最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。

运用 HMR 的简单例子

在上一个部分,通过一张 HMR 流程图,简要的说明了 HMR 进行模块热更新的过程。当然你可能感觉还是很迷糊,对上面出现的一些英文名词也可能比较陌生(上面这些英文名词代表着代码仓库或者仓库中的文件模块),没关系,在这一部分,我将通过一个最简单最纯粹的例子,通过分析 wepack及 webpack-dev-server 源码详细说明各个库在 HMR 过程中的具体职责。

这边我通过一个简单的vue例子示例一下,这边贴一个仓库的连接github.com/ikkkp/webpack-vue-demo

在开始这个例子之前简单对这个仓库文件进行下说明,仓库中包含文件如下:

webpack-optimization

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {
    VueLoaderPlugin
} = require('vue-loader');
const webpack = require('webpack'); // 引入 webpack
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const {
    ElementPlusResolver
} = require('unplugin-vue-components/resolvers')

/**
* @description 
* @version 1.0
* @author Huangzl
* @fileName webpack.base.config.js
* @date 2023/11/10 11:00:59
*/

module.exports = {
    entry: {
        main: './src/main',
        //单页应用开发模式禁用多入口
    },
    resolveLoader: {
        modules: [
            'node_modules',
            path.resolve(__dirname, './src/loader')
        ]
    },
    output: {
        filename: '[id].[fullhash].js', // 使用 [fullhash] 替代 [hash],这是新版本 webpack 的写法
        path: path.join(__dirname, 'dist'),
        publicPath: './'
    },
    module: {
        rules: [{
            test: /\.vue$/,
            loader: 'vue-loader'
        },
        {
            test: /\.css$/,
            use: [
                'style-loader',
                {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 1
                    }
                },
                'postcss-loader'
            ]
        }, {
            test: /\.js$/,
            use: ['babel-loader', {
                loader: 'company-loader',
                options: {
                    sign: 'we-doctor@2021',
                },
            },],
            exclude: /node_modules/,
        },
        {
            test: /\.(ico|png|jpg|gif|svg|eot|woff|woff2|ttf)$/,
            loader: 'file-loader',
            options: {
                name: '[name].[ext]?[hash]'
            }
        },

        ]
    },

    plugins: [
        new HtmlWebpackPlugin({
            template: './public/index.html'
        }),
        new VueLoaderPlugin(),
        new webpack.DefinePlugin({
            BASE_URL: JSON.stringify('./') // 这里定义了 BASE_URL 为根路径 '/'
        }),
        AutoImport({
            resolvers: [ElementPlusResolver()],
        }),
        Components({
            resolvers: [ElementPlusResolver()],
        }),
    ],
    optimization: {
        splitChunks: {
            chunks: 'all', // 只处理异步模块
            maxSize: 20000000, // 设置最大的chunk大小为2MB
        },
    },
};

值得一提的是,在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,并且在package.json 文件中添加如下的 script 脚本:

“start”: “webpack-dev-server –hot –open”

添加 —hot 配置项后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin 插件,而不用我们再手动引入了。

上面给的是webpack.base.config.js的内容,我们下面通过修改App.vue的内容进行:

- <div>hello</div> // 将 hello 字符串修改为 hello world
+ <div>hello world</div>

第一步:webpack 对文件系统进行 watch 打包到内存中

webpack-dev-middleware 调用 webpack 的 api 对文件系统 watch,当 hello.js 文件发生改变后,webpack 重新对文件进行编译打包,然后保存到内存中。

// webpack-dev-middleware/lib/Shared.js
if(!options.lazy) {
    var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
    context.watching = watching;
}

你可能会疑问了,为什么 webpack 没有将文件直接打包到 output.path 目录下呢?文件又去了哪儿?原来 webpack 将 bundle.js 文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs,memory-fs 是 webpack-dev-middleware 的一个依赖库,webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem 实例,这样代码就将输出到内存中。webpack-dev-middleware 中该部分源码如下:

// webpack-dev-middleware/lib/Shared.js
var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
    fs = compiler.outputFileSystem;
} else {
    fs = compiler.outputFileSystem = new MemoryFileSystem();
}

首先判断当前 fileSystem 是否已经是 MemoryFileSystem 的实例,如果不是,用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 javascript 对象保存在了内存中,当浏览器请求 bundle.js 文件时,devServer就直接去内存中找到上面保存的 javascript 对象返回给浏览器端。

第二步:devServer 通知浏览器端文件发生改变

在这一阶段,sockjs 是服务端和浏览器端之间的桥梁,在启动 devServer 的时候,sockjs 在服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器,最关键的步骤还是 webpack-dev-server 调用 webpack api 监听 compile的 done 事件,当compile 完成后,webpack-dev-server通过 _sendStatus 方法将编译打包后的新模块 hash 值发送到浏览器端。

// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
  // stats.hash 是最新打包文件的 hash 值
  this._sendStats(this.sockets, stats.toJson(clientStats));
  this._stats = stats;
});
...
Server.prototype._sendStats = function (sockets, stats, force) {
  if (!force && stats &&
  (!stats.errors || stats.errors.length === 0) && stats.assets &&
  stats.assets.every(asset => !asset.emitted)
  ) { return this.sockWrite(sockets, 'still-ok'); }
  // 调用 sockWrite 方法将 hash 值通过 websocket 发送到浏览器端
  this.sockWrite(sockets, 'hash', stats.hash);
  if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); } 
  else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); }      else { this.sockWrite(sockets, 'ok'); }
};

第三步:webpack-dev-server/client 接收到服务端消息做出响应

可能你又会有疑问,我并没有在业务代码里面添加接收 websocket 消息的代码,也没有在 webpack.config.js 中的 entry 属性中添加新的入口文件,那么 bundle.js 中接收 websocket 消息的代码从哪来的呢?原来是 webpack-dev-server 修改了webpack 配置中的 entry 属性,在里面添加了 webpack-dev-client 的代码,这样在最后的 bundle.js 文件中就会有接收 websocket 消息的代码了。

webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作,如下图所示,hash 消息是在 ok 消息之前。

webpack-optimization

在 reload 操作中,webpack-dev-server/client 会根据 hot 配置决定是刷新浏览器还是对代码进行热更新(HMR)。

首先将 hash 值暂存到 currentHash 变量,当接收到 ok 消息后,对 App 进行 reload。如果配置了模块热更新,就调用 webpack/hot/emitter 将最新 hash 值发送给 webpack,然后将控制权交给 webpack 客户端代码。如果没有配置模块热更新,就直接调用 location.reload 方法刷新页面。

第四步:webpack 接收到最新 hash 值验证并请求模块代码

在这一步,其实是 webpack 中三个模块(三个文件,后面英文名对应文件路径)之间配合的结果,首先是 webpack/hot/dev-server(以下简称 dev-server) 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息,调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadUpdateChunkhotDownloadManifest , 第二个方法是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端,而第一个方法是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtimeHMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。

webpack-optimization

值得注意的是,两次请求的都是使用上一次的 hash 值拼接的请求文件名,hotDownloadManifest 方法返回的是最新的 hash 值,hotDownloadUpdateChunk 方法返回的就是最新 hash 值对应的代码块。然后将新的代码块返回给 HMR runtime,进行模块热更新

第五步:HotModuleReplacement.runtime 对模块进行热更新

这一步是整个模块热更新(HMR)的关键步骤,而且模块热更新都是发生在HMR runtime 中的 hotApply 方法中

// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
    // ...
    var idx;
    var queue = outdatedModules.slice();
    while(queue.length > 0) {
        moduleId = queue.pop();
        module = installedModules[moduleId];
        // ...
        // remove module from cache
        delete installedModules[moduleId];
        // when disposing there is no need to call dispose handler
        delete outdatedDependencies[moduleId];
        // remove "parents" references from all children
        for(j = 0; j < module.children.length; j++) {
            var child = installedModules[module.children[j]];
            if(!child) continue;
            idx = child.parents.indexOf(moduleId);
            if(idx >= 0) {
                child.parents.splice(idx, 1);
            }
        }
    }
    // ...
    // insert new code
    for(moduleId in appliedUpdate) {
        if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
            modules[moduleId] = appliedUpdate[moduleId];
        }
    }
    // ...
}

从上面 hotApply 方法可以看出,模块热替换主要分三个阶段,第一个阶段是找出 outdatedModulesoutdatedDependencies,这儿我没有贴这部分代码,有兴趣可以自己阅读源码。第二个阶段从缓存中删除过期的模块和依赖,如下:

delete installedModules[moduleId];
delete outdatedDependencies[moduleId];

第三个阶段是将新的模块添加到 modules 中,当下次调用 webpack_require (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。

模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器,这部分代码在 dev-server 代码中,简要代码如下:

module.hot.check(true).then(function(updatedModules) {
    if(!updatedModules) {
        return window.location.reload();
    }
    // ...
}).catch(function(err) {
    var status = module.hot.status();
    if(["abort", "fail"].indexOf(status) >= 0) {
        window.location.reload();
    }
});

dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。

webpack-optimization

前言

一般來說在學習寫網頁的時候,最先碰到的會是 HTML 與 CSS,負責把版面刻出來以及美化版面,當基礎打穩之後,會開始學習 JavaScript,試著做出一點互動性的效果。而「互動」除了使用者跟瀏覽器的互動以外,別忘了還有 Client 端跟 Server 端的互動,也就是必須要學會從瀏覽器用 JavaScript 跟後端 Server 拿資料,否則你的網頁資料都只能是寫死的。

這篇的主要預設讀者是網頁前端的初學者,希望能讓本來不太理解怎麼跟 Server 交換資料或是怎麼串 APi 的讀者看完之後,能夠更了解該怎麼跟後端串接。

閱讀更多