前言

想把Vuejs 组件事件与 emit 的实现讲清楚,我先要把组件事件和emit的应用讲清楚,这样大家会更清楚的理解组件事件和emit的实现。

对于组件事件,我们其实之前就已经提到过了浏览器的事件机制:

浏览器事件回顾

浏览器事件是指在 Web 页面中发生的各种交互和状态变化的信号。这些事件可以由用户的操作、浏览器的状态变化或其他一些因素触发。以下是一些常见的 DOM 事件和它们的简介:

鼠标事件:

  • click: 当鼠标点击一个元素时触发。对于触摸屏设备,也会在触摸屏上进行点击时触发。
  • contextmenu: 当鼠标右键点击一个元素时触发。
  • mouseover / mouseout: 当鼠标指针移入或离开一个元素时触发。
  • mousedown / mouseup: 当在元素上按下或释放鼠标按钮时触发。
  • mousemove: 当鼠标移动时触发。

键盘事件:

  • keydown 和 keyup: 当按下和松开一个按键时触发。

表单(form)元素事件:

  • submit: 当访问者提交了一个 <form> 时触发。
  • focus: 当访问者聚焦于一个元素时触发,例如聚焦于一个 <input>

Document 事件:

  • DOMContentLoaded: 当 HTML 的加载和处理均完成,DOM 被完全构建完成时触发。

CSS 事件:

  • transitionend: 当一个 CSS 动画完成时触发。

这些事件可以通过 JavaScript 中的事件监听器来捕获和处理。例如,通过给元素添加事件监听器,可以在特定事件发生时执行相应的代码。事件是 Web 开发中交互和响应的基础。

那我们所说的组件事件和浏览器事件有什么区别呢?

对于事件这一个定义,我们可以这样理解:事件是指在特定的时间点上发生的事情,在某个时间点产生了一个信号,而信号可以被接收处理,这就是事件。

下面我们所说的组件事件,其实相对于浏览器事件来说更为宏观,它是指在Vue组件中,组件之间的通信方式,而这种通信方式是通过事件来实现的,这里的事件就是我们所说的组件事件。

Vue 组件之间通过事件进行通信的主要机制是通过 emit 和 on 方法。当子组件需要向父组件通信时,它会触发一个事件,并通过 emit 方法将事件发送出去;而父组件则通过在模板中使用 v-on 指令监听这个事件,从而执行相应的逻辑。

<!-- 子组件 ChildComponent.vue -->
<template>
  <button @click="sendMessage">点击通知父组件</button>
</template>

<script>
export default {
  methods: {
    sendMessage() {
      // 使用 emit 发送自定义事件
      this.$emit('child-event', 'Hello from child!');
    }
  }
}
</script>
<!-- 父组件 ParentComponent.vue -->
<template>
  <div>
    <child-component @child-event="handleChildEvent"></child-component>
    <p>从子组件接收的消息: {{ messageFromChild }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent
  },
  data() {
    return {
      messageFromChild: ''
    };
  },
  methods: {
    handleChildEvent(message) {
      // 使用 v-on 监听子组件的事件
      this.messageFromChild = message;
    }
  }
}
</script>

Vue 的事件机制是通过发布/订阅模式实现的。$emit 用于发布(触发)事件,而 v-on 则用于订阅(监听)事件。这使得不同组件之间可以相互通信,实现了组件之间的解耦。

首先,我们会注意到,子组件通过 this.$emit(‘child-event’, ‘Hello from child!’) 发送了一个自定义事件 child-event,而父组件则通过 @child-event=”handleChildEvent” 监听了这个事件。 这里的 child-event 就是我们自定义的事件名,而 handleChildEvent 则是父组件中定义的一个方法,用于处理子组件发送过来的消息。

当使用一个自定义的MyComponent组件时,我们可以监听由 emit 函数发射的自定义事件

可以看到,自定义事件 change 被编译成名为 onChange 的属性,并存储在 props 数据对象中。这实际上是一种约定。作为框架设计者,也可以按照自己期望的方式来设计事件的编译结果。在具体的实现上,发射自定义事件的本质就是根据事件名称去props 数据对象中寻找对应的事件处理函数并执行,如下面的代码所示:

emit的实现

// MyComponent 组件定义
const MyComponent = {
  name: 'MyComponent',
  setup(props, { emit }) {
    // 发射 change 事件,并传递给事件处理函数两个参数
    emit('change', 1, 2);

    return () => {
      // 组件的渲染逻辑
      return /* ... */;
    };
  }
};

上面这一段代码会被编译成一个节点 CompNode,它的类型为 MyComponent 组件,同时会将 emit 函数传递给组件的 setup 函数,如下所示:

// 定义一个 Vue 组件节点 CompNode
const CompNode = {
  // 指定节点的类型为 MyComponent 组件
  type: MyComponent,

  // 为组件传递的 props 对象
  props: {
    // 在 MyComponent 组件中,会有一个 onChange 的事件处理函数,其值为 handler
    onChange: handler
  }
};

可以看到,自定义事件 change 被编译成名为 onChange 的属性,并存储在 props 数据对象中。这实际上是一种约定。作为框架设计者,也可以按照自己期望的方式来设计事件的编译结果。

在具体的实现上,发射自定义事件的本质就是根据事件名称去props 数据对象中寻找对应的事件处理函数并执行,如下面的代码所示:

// 挂载组件
function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  const instance = {
    state, // 状态
    props: shallowReactive(props), // 响应式处理 props
    isMounted: false,
    subTree: null
  };

  // 定义 emit 函数,它接收两个参数
  // event: 事件名称
  // payload: 传递给事件处理函数的参数
  function emit(event, ...payload) {
    // 根据约定对事件名称进行处理,例如 change --> onChange
    const eventName = `on${event[0].toUpperCase()}${event.slice(1)}`;

    // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
    const handler = instance.props[eventName];

    if (handler) {
      // 调用事件处理函数并传递参数
      handler(...payload);
    } else {
      console.error('事件不存在');
    }
  }

  // 将 emit 函数添加到 setupContext 中
  const setupContext = { attrs, emit };

  // 省略部分代码
}

// 解析 props 数据的函数,对事件类型的 props 做特殊处理
function resolveProps(options, propsData) {
  const props = {};
  const attrs = {};

  for (const key in propsData) {
    // 以字符串 'on' 开头的 props 添加到 props 数据中,否则添加到 attrs 中
    if (key in options || key.startsWith('on')) {
      props[key] = propsData[key];
    } else {
      attrs[key] = propsData[key];
    }
  }

  return [props, attrs];
}

emit 的实现原理主要涉及两个方面:setupContext 的注入和事件名称的约定转换。

  1. setupContext 的注入: 在 Vue 的组件中,setup 函数接收两个参数:propscontextcontext 中包含了一系列属性和方法,其中之一就是 emit 函数。Vue 3 的组件中,setup 函数返回的对象中,可以将 emit 函数添加到 setupContext 中,以便用户在组件内通过 setupContext.emit 访问。

    下面是一个简单的例子,展示了如何在 setup 函数中添加 emitsetupContext

    setup(props, context) {
      // 添加 emit 到 setupContext
      context.emit = emit;
    
      // 其他 setup 逻辑
      // ...
    
      // 返回 setup 返回的对象
      return {};
    }

    这样,在组件内部,用户就可以通过 setupContext.emit 来调用 emit 函数了。

  2. 事件名称的约定转换:emit 函数内部,为了匹配组件模板中的事件处理函数,需要对事件名称进行约定转换。Vue 使用了一种约定,将事件名称转换为驼峰命名的形式。例如,change 事件会转换为 onChange。这样,用户在组件模板中监听事件时,可以使用驼峰命名的方式。

    下面是一个简单的例子,展示了事件名称的约定转换:

    function emit(event, ...payload) {
      // 根据约定对事件名称进行处理,例如 change --> onChange
      const eventName = `on${event[0].toUpperCase()}${event.slice(1)}`;
    
      // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
      const handler = instance.props[eventName];
    
      if (handler) {
        // 调用事件处理函数并传递参数
        handler(...payload);
      } else {
        console.error('事件不存在');
      }
    }

    这里的 emit 函数会将事件名称转换为以 “on” 开头并使用驼峰命名的形式,例如 change 转换为 onChange。然后,它会在 instance.props 中查找对应的事件处理函数并执行。

需要注意的是,事件类型的 props 在 instance.props 中是找不到的,因此它们会存储在 attrs 中。为了解决这个问题,在解析 props 数据时,需要对事件类型的 props 做特殊处理,确保它们被正确添加到 props 中,而不是 attrs。这样,emit 函数就能够在 instance.props 中正确地找到对应的事件处理函数。

前言

我们在前面讨论过了vue渲染器渲染器主要负责将虚拟 DOM 渲染为真实 DOM ,我们只需要使用虚拟 DOM来描述最终呈现的内容即可。

但当我们编写比较复杂的页面时,用来描述页面结构的虚拟 DOM 的代码量会变得越来越多,或者说页面模板会变得越来越大。这时,我们就需要组件化的能力。

有了组件,我们就可以将一个大的页面拆分为多个部分,每一个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件化的实现同样需要渲染器的支持

渲染组件

从用户的角度来看,一个有状态组件就是一个选项对象,如下面的代码所示:

// MyComponent 是一个组件,它的值是一个选项对象
const MyComponent = {
name: 'MyComponent',
  data(){ 
  return  {foo: 1 }
  }
}

但是,如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟 DOM 节点。例如,为了描述普通标签,我们用虚拟节点的vnode.type 属性来存储标签名称,如下面的代码所示:

 // 该 vnode 用来描述普通标签
const vnode = {
type: 'div'
// ...
}

为了描述片段,我们让虚拟节点的 vnode.type 属性的值为Fragment, 而为了描述文本,我们让虚拟节点的 vnode.type 属性的值为Text,

还记得我们之前在渲染器讨论过的patch函数吗?

在patch函数中,Vue会根据新旧虚拟DOM节点的差异进行不同的处理逻辑。 如果是首次渲染,会执行首次渲染的逻辑;如果是更新渲染,会根据差异更新实际的DOM节点。

function patch(n1, n2, container, anchor) {
  // 02. 如果新旧节点类型不同,执行卸载操作
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  // 07. 获取节点类型
  const { type } = n2;

  // 09. 判断节点类型
  if (typeof type === 'string') {
    // 10. 处理普通元素
    // TODO: 处理普通元素的逻辑
  } else if (type === Text) {
    // 11. 处理文本节点
    // TODO: 处理文本节点的逻辑
  } else if (type === Fragment) {
    // 13. 处理片段
    // TODO: 处理片段的逻辑
  }

  // 返回新节点的实际DOM元素
  return n2.el;
}

可以看到,渲染器会使用虚拟节点的 type 属性来区分其类型。对于不同类型的节点,需要采用不同的处理方法来完成挂载和更新。

实际上,对于组件来说也是一样的。为了使用虚拟节点来描述组件,我们可以用虚拟节点的 vnode.type 属性来存储组件的选项对象

function patch(n1, n2, container, anchor) {
  // 02. 如果新旧节点类型不同,执行卸载操作
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  // 07. 获取节点类型
  const type = n2.type;

  // 09. 判断节点类型
  if (typeof type === 'string') {
    // 10. 处理普通元素
    // TODO: 处理普通元素的逻辑
  } else if (type === Text) {
    // 11. 处理文本节点
    // TODO: 处理文本节点的逻辑
  } else if (type === Fragment) {
    // 13. 处理片段
    // TODO: 处理片段的逻辑
  } else if (typeof type === 'object') {
    // 16. vnode.type 的值是选项对象,作为组件来处理
    if (!n1) {
      // 17. 挂载组件
      mountComponent(n2, container, anchor);
    } else {
      // 21. 更新组件
      patchComponent(n1, n2, anchor);
    }
  }

  // 返回新节点的实际DOM元素
  return n2.el;
}

在上面这段代码中,我们新增了一个 else if 分支,用来处理虚拟节点的 vnode.type 属性值为对象的情况,即将该虚拟节点作为组件的描述来看待,并调用 mountComponent 和 patchComponent 函数来完成组件的挂载和更新。

上面我们回顾了有关于组件的一些基本概念,组件的挂载和更新是如何实现的,其实我们更关注的还应该是用户应该如何编写组件?组件的选项对象必须包含哪些内容?以及组件拥有哪些能力?

因此,一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口,如下面的代码所示:

// MyComponent 组件定义
const MyComponent = {
  // 组件名称,可选
  name: 'MyComponent',

  // 组件的渲染函数,其返回值必须为虚拟 DOM,一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM
  render() {
    // 返回虚拟 DOM
    return {
      type: 'div',
      children: ['text content']
    };
  }
};

// 用来描述组件的 vnode 对象,type 属性值为组件的选项对象
const CompNode = {
  type: MyComponent
};

// 调用渲染器来渲染组件
renderer.render(CompNode, document.querySelector('#app'));

// 渲染器中真正完成组件渲染任务的是 mountComponent 函数
function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type;

  // 获取组件的渲染函数 render
  const render = componentOptions.render;

  // 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
  const subTree = render();

  // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
  patch(null, subTree, container, anchor);
}

总结

我们现在来回顾一下组件的基础,我们显式声明了一个组件的对象实例,该组件的选项对象必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM,之后我们再调用render和patch进行组件的更新和替换。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口,而渲染器的作用就是将组件的渲染函数的返回值渲染为真实 DOM。

前言

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

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

例如:

  • 点击一个链接 —— 触发导航(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中使用即可。

前言

2023 年 12 ⽉ 31 ⽇后,功能仍然可⽤,但不再提供更新,包括
• 安全更新
• 浏览器兼容

Evan 宣布 Vue 3 的第一个 RC将于 7 月中旬发布。这篇文章建议库/插件作者开始迁移对 Vue 3 的支持。但是由于 API 和行为发生了很大变化,是否有可能使我们的库同时支持 Vue 2 和 3 ?

通用代码

最简单的方法是编写适用于两个版本的通用代码 ,无需任何额外的修改,就像人们对Python 2 和 3所做的那样。简单并不意味着容易。编写此类组件需要避免Vue 3 中新引入的内容 以及Vue 2 中弃用的内容 。换句话说,您不能使用:

  • 合成API
  • .sync .native修饰语
  • 过滤器
  • 3rd 方供应商对象

使用分支

核心团队成员对此问题的回复建议使用不同的分支来分隔对每个定位版本的支持。我认为这对于现有和成熟的库来说是一个很好的解决方案,因为它们的代码库通常更稳定,并且版本目标优化可能需要它们具有更好的代码隔离。

这样做的缺点是您需要维护两个代码库,这会使您的工作量增加一倍。对于小型库或想要支持两个版本的新库来说,进行两次错误修复或功能补充是不理想的。我不建议在项目一开始就使用这种方法。

构建脚本

在VueUse中,编写了一些构建脚本,以便在构建时从目标版本的 API 导入代码。之后,我需要发布两个标签vue2 vue3来区分不同版本的支持。有了这个,我可以编写一次代码并使库支持两个 Vue 版本。它的问题是我需要在每个版本上构建两次并引导用户安装相应的插件版本(@vue/composition-api对于Vue 2则需要手动安装)。

Vue 2/3 共存开发的思路

同时⽀持 Vue 2/3 项⽬

Vue 2/3 项⽬存在的可能场景

渐进式迁移: 如果有一个较大的 Vue 2 项目,但是想要逐步迁移到 Vue 3,可以选择在项目中同时引入 Vue 3,然后逐步将 Vue 2 组件迁移到 Vue 3。

依赖库和插件兼容性: 如果项目依赖于一些 Vue 2 的插件或库,而这些插件或库还没有升级到 Vue 3,可能需要同时使用 Vue 2 和 Vue 3 以确保兼容性。

新功能采用 Vue 3: 可能希望项目中使用 Vue 3 来利用其新功能和性能优势,同时保留 Vue 2 用于旧的组件或功能。

项⽬融合者: 公司内部基于体验要求,需要 Vue 2/3项⽬呈现在同⼀⻚⾯中

内部组件资产维护者: 需要在 Vue 2/3 的项⽬都⽀持,且能⼒必须⼀致

⽼项⽬应⽤开发者: 需要⽤到⼀个第三⽅图表组件,但只有 Vue 3 版本,⽽⾮ Vue 2 版本

解决⽅案

1. Vue 2/3 项⽬共存

vue-5

直接通过 Vue 3 的 createApp 创建⼀个 Vue 3 的根实例,然后通过 Vue 2 的 mount ⽅法挂载到 Vue 2 的根实例上,这样就可以在 Vue 2 的项⽬中使⽤ Vue 3 的组件。

相关的代码仓库贴在这里,大家自取:vue5

vue-5

// Vue 3 项⽬
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#vue3')
// Vue 2 项⽬
import Vue from 'vue2'
import App from './App.vue'

new Vue({
  render: h => h(App as any),
}).$mount('#vue2')

这一个思路重要的是我们采用配置vite.config.ts解决不同模块的编译问题:编写了一些构建脚本,以便在构建时从目标版本的 API 导入代码。之后,我需要发布两个标签vue2 vue3来区分不同版本的支持。但是它的问题其实是需要在每个版本上引导用户安装相应的插件版本。这对于开发者处理包冲突问题并不是很友好。

import path from 'path'
import { defineConfig } from 'vite'
import Vue2 from '@vitejs/plugin-vue2'
import Vue3 from '@vitejs/plugin-vue'
import Inspect from 'vite-plugin-inspect'
import compiler from 'vue2/compiler-sfc'

const src = path.resolve(__dirname, 'src')

export default defineConfig({
  plugins: [
    Vue3({
      include: [/vue3[/\\].*\.vue$/],
    }),
    Vue2({
      include: [/vue2[/\\].*\.vue$/],
      compiler: compiler as any,
    }),
    Inspect(),
  ],
})

这样我们属于是将Vue2和Vue3单独做成了两个独立的包,然后在vite.config.ts中配置了不同的编译规则,这样就可以在同一个页面中使用Vue2和Vue3。

2. JessicaSachs/petite 方案

先来简单介绍一下petite

Petite是一个为Vue组件作者构建的主观GitHub模板。 它设置了开发、文档和测试通用SFC组件所需的工具,并与Vue 2.7运行时向后兼容。

这是通过一些运行时辅助函数和一个非常主观的单体库结构实现的。

Petite设置了Vite、Volar、Linting、Vitepress、TypeScript和Testing,这样您就可以选择编写Vue 3风格的代码,同时轻松保持对Vue 2.x用户的向后兼容性。

而这也意味着您将在 npm 上发布软件包的两个版本,而不是为了支持 Vue 2 或 Vue 3 而中断主要版本。

这样做的缺点是您的用户在升级和更改导入时需要安装新版本。 好处是您可以更轻松地编写向后兼容的代码并为用户提供定期升级。此外,您还可以拆分仅 Vue 2 和仅 Vue 3 的依赖项。

如果您在通用代码中使用lodash,您将需要在工作区根目录中运行后pnpm build,每个包 ( lib-vue3、lib-vue2) 应独立部署。

3. vue-bridge 方案

4. vue-demi 方案

仓库实例:vue-demi

Vue Demi是一个开发实用程序,允许您为 Vue 2 和 3 编写通用 Vue 库。无需担心用户安装的版本。

当您要创建 Vue 插件/库时,只需安装vue-demi为依赖项并从中导入与 Vue 相关的任何内容即可。像往常一样发布你的插件/库,你的包将变得通用!

{
  "dependencies": {
    "vue-demi": "latest"
  }
}
import Vue, { reactive, ref } from 'vue-demi'

在底层,它使用了postinstallnpm hook。安装所有包后,脚本将开始检查已安装的 Vue 版本,并将导出重定向到基于本地 Vue 版本。使用 Vue 2 时,@vue/composition-api如果未安装,它也会自动安装。

所需要注意的有关于库/组件的点:

库/组件

  • 单仓库 - 多个包构建
  • 依赖管理
  • alias 别名配置
    • npm 包名
    • 构建工具配置

Vue 2 应⽤中引⼊ Vue 3 组件

会有组件互操作的限制

  • context 共享
  • scoped slots
  • 事件

Vue 2 应⽤中引⼊ Vue 3 组件的思路

  • Vue 3 可以有多个全局实例
  • 前提:Vue 2 升级到 2.7、Vue CLI 移除部分过时插件
  • 互操作层:Custom Elements
  • 构建⼯具:Vite

「人的一切痛苦,本质上都是对自己无能的愤怒」

“你不爱我的时候 我可能还是爱你 同时爱那个曾经的自己
他又可爱 执迷不悟 又不可复制 其实谢谢你的体贴 这些日子 天又蓝又晴”

——《陪安东尼度过漫长岁月》

引言

自从上周去开了个前端的会议之后,对于前端的一些新技术有了一些新的认识,其中就有关于Chrome本地代码调试的一些新的认识,所以这里就简单的记录一下。这边先贴一个链接:youtube-ChromeDevs

这边要先感谢下chrome的devtools团队,他们的工作真的是太棒了,但是在 jecfish 的前端会议技术分享之前我却并不知道有这么多友好的web调试工具和性能测试手段,所以这里就简单的记录一下,大家要是有兴趣的话可以去看看上面的链接,里面有很多有用的东西,也欢迎给他们团队贴贴关注。

下面将先给出本章的大纲,然后再逐步展开。

ModernWeb

本地代码调试

这边的内容将是有关于本地代码调试,主要是有关于source-map的一些内容,这边将会有一些简单的介绍,然后再给出一些简单的例子。

对源代码映射source-map的需求

这边先给出一个链接:source-map

这个链接是jecfish写的有关于source-map的一些内容要是大家有兴趣的话,可以去里面看看,要是文章太长大家看不下去的话,可以直接看下面的内容。

今天,我们要讨论的是源代码映射,这是一种现代 Web 开发中的重要工具,可大幅简化调试工作。在本文中,我们将探讨源代码映射的基础知识、源代码映射的生成方式以及源代码映射如何改善调试体验。

好嘟,要进入source-map的学习认知,我们得先要对Modern Web有一个最基本的认识。那我们来看看当我说到Modern Web的时候我们该聊些什么?

ModernWeb

我们可以看到控制台打印了一些BaseOn Css样式的一些内容,但我们今天虽然并不准备着重讲这个console的Css样式,但你依然可以在console定义一些样式让你的console打印的更漂亮!

ModernWeb

ModernWeb

时隔15年的时间,我们可以看到chrome开发者模式的调试工具从原来的寥寥几种,到现在的丰富多彩,这其中的变化是非常大的,所以我们今天就来聊聊这32种调试工具在chrome之中具体的应用。

ModernWeb

我们知道浏览器只认识HTML </> CSS {;} JavaScript (,) Wasm(当然是现在浏览器引擎默认都内置了对Wasm模块的支持) 这几种语言,但是我们的前端框架却异常的丰富 ,涉及到的语言:ts、less、sass,框架:Vue、React,Meta 框架:nust.js、next.js,这些框架都是在我们的前端代码中使用的,但是浏览器并不认识这些语言,所以我们需要将这些语言转换成浏览器认识的语言。

我们正在构建更复杂的 Web 应用,您的开发工作流可能会涉及到使用各种工具。例如:

  • 模板语言和 HTML 预处理器:Pug、Nunjucks、Markdown。
  • CSS 预处理器:SCSS、LESS、PostCSS。
  • JavaScript 框架:Angular、React、Vue、Svelte。
  • JavaScript 元框架:Next.js、Nuxt、Astro。
  • 高级编程语言:TypeScript、Dart、CoffeeScript。
    等等。这个名单还在不断加长!

ModernWeb

这些工具需要一个构建流程,以将代码转译为浏览器可以理解的标准 HTML、JavaScript 和 CSS。此外,为了优化性能,通常的做法是压缩(例如,使用 Terser 缩减和破坏 JavaScript)和合并这些文件,以缩减其大小并提高 Web 效率。

而将这众多的模板语言和预处理器、元框架转化成浏览器可以看得懂的 HTML、JavaScript 和 CSS,这个过程就是编译,而编译的过程中就会产生一些中间代码,这些中间代码就是我们的源代码映射,这个 源代码映射 就是我们今天要讲的内容。

例如,使用构建工具,我们可以将以下 TypeScript 文件转译并压缩为一行 JavaScript。这个demo在github上面也有:parcel-demo

/* A TypeScript demo: example.ts */

document.querySelector('button')?.addEventListener('click', () => {
  const num: number = Math.floor(Math.random() * 101);
  const greet: string = 'Hello';
  (document.querySelector('p') as HTMLParagraphElement).innerText = `${greet}, you are no. ${num}!`;
  console.log(num);
});

压缩版本如下所示:

/* A compressed JavaScript version of the TypeScript demo: example.min.js  */

document.querySelector("button")?.addEventListener("click",(()=>{const e=Math.floor(101*Math.random());document.querySelector("p").innerText=`Hello, you are no. ${e}!`,console.log(e)}));

不过,这项优化会增加调试难度。如果压缩代码将所有内容放在一行中,并且变量名称较短,则很难查明问题的根源。这正是源映射的用武之地,它们会将编译后的代码映射回原始代码。

ModernWeb

我们现在来具体看一个例子,这个例子是一个用ts代码写的简单的click事件触发的xhr,其中报了404错误。

ModernWeb

哎?发生什么事了,注意看红色箭头处,看来我们浏览器看得懂ts代码?

ModernWeb

ModernWeb

其实不然,看着这贴着的这张图,其实这个ts是从main.js里面解析过来的

了解源代码映射source-map

ModernWeb

这些源映射文件包含关于已编译代码如何映射到原始代码的基本信息,让开发者能够轻松地进行调试。下面是一个源映射的示例。

{
  "mappings": "AAAAA,SAASC,cAAc,WAAWC, ...",
  "sources": ["src/script.ts"],
  "sourcesContent": ["document.querySelector('button')..."],
  "names": ["document","querySelector", ...],
  "version": 3,
  "file": "example.min.js.map"
}

要了解其中每个字段,您可以阅读源映射规范或这篇关于源映射剖析的经典文章。

源映射最重要的方面是 mappings 字段。它使用 VLQ base 64 编码字符串将已编译文件中的行和位置映射到对应的原始文件。可使用 source-map-visualization来源映射可视化等来源映射可视化工具直观呈现此映射。

ModernWeb

左侧的生成的列会显示压缩内容,而原始列会显示原始来源。

可视化工具会以颜色代码对 original 列中的每一行以及 generated 列中的相应代码进行颜色编码。

mapping 部分显示了已解码的代码映射。例如,条目 65-> 2:2 表示:

  • 生成的代码:const 一词在压缩内容中的位置 65 处开始。
  • 原始代码:const 一词从原始内容中的第 2 行和第 2 列开始。

这样一来,开发者便可以快速识别缩减后的代码与原始代码之间的关系,从而使调试过程更加顺畅。

浏览器开发者工具应用这些源代码映射,帮助您直接在浏览器中更快地查明调试问题。

ModernWeb

ModernWeb

ModernWeb

DevTools 怎么知道该隐藏啥?Source maps

ModernWeb

ModernWeb

ModernWeb

实用的chrome调试技巧

请求

ModernWeb

你可以在Network面板中查看请求的详细信息,包括请求头、响应头、请求体、响应体、Cookies、Timing等等。

同时,devTools devtools/overrides 也提供了通过本地覆盖,可以覆盖 HTTP 响应标头和Web 内容(包括XHR 和获取请求)来模拟远程资源,即使您无权访问它们。这使您可以对更改进行原型设计,而无需等待后端支持它们。本地覆盖还允许您在页面加载期间保留在 DevTools 中所做的更改。

这个东西在什么情况特别有用呢?

比如说,前端向后端发起的请求,后端返回的结果,可能还未进行跨域的处理(当然跨域的处理在后端做),那前端拿到的这个结果其实已经访问成功了,拿到了正确的数据,但是由于浏览器的安全策略,将此文件报告为不可信赖,这个时候我们就可以通过本地覆盖,来模拟后端返回的结果,这样就可以在前端进行调试了。

或者说有些数据在后端还未修改,前端拿到的数据是旧的,那我要干巴巴跟后端工程师小眼瞪大眼等着他们把数据改好了我们再进行工作嘛?这样实在是有点蠢hhhhh,我们可以通过重写content进行修改,这样就可以在前端进行调试了。

ModernWeb

那这个devtools/overrides确实是有点牛逼的,那它是怎么运行的:

  • 当您在 DevTools 中进行更改时,DevTools 会将修改后的文件的副本保存到您指定的文件夹中。
  • 当您重新加载页面时,DevTools 会提供本地修改后的文件,而不是网络资源。

覆盖网页内容

设置文件夹
  • 设置本地覆盖。
  • 对文件进行更改并将其保存在 DevTools 中。

例如,您可以在“源”中编辑文件,或在“元素” > “样式”中编辑 CSS,除非 CSS 位于HTML 文件中。

DevTools 保存修改后的文件,在Sources > Overrides 中列出它们,并在相关面板和窗格中显示已保存。被覆盖文件旁边的图标:Elements > Styles、NetworkSources > Overrides

覆盖 XHR 或获取请求以模拟远程资源

通过本地覆盖,您不需要访问后端,也不必等待它支持您的更改。即时模拟和实验:

  • 设置本地覆盖。
  • 在Network中,过滤XHR/fetch requests,找到您需要的请求,右键单击它,然后选择Override content。
  • 对获取的数据进行更改并保存文件。
  • 刷新。 刷新页面并观察应用的更改。

要了解此工作流程,请观看以下视频:

覆盖 HTTP 响应标头

在“网络”面板中,您可以覆盖 HTTP 响应标头,而无需访问 Web 服务器。

通过响应标头覆盖,您可以在本地对各种标头进行原型修复,包括但不限于:

  • 跨源资源共享 (CORS) 标头
  • 权限-策略标头
  • 跨源隔离标头
  • 要覆盖响应标头:

设置本地覆盖并检查。

转到Network,找到请求,右键单击它,然后选择Override headers。DevTools 将带您进入标头>响应标头编辑器

ModernWeb

录制器,有利于调试和测试

基于Chrome DevTools Recorder 的自定义和自动化用户流

ModernWeb

编写自动化测试并不是开发人员生活中最有趣的事情。作为开发人员,事实上确实需要功能、修复错误并改善世界!然而,当我们的工作流程中没有自动化测试时,从长远来看,事情可能会变得相当“错误”。所以,我们也认为编写自动化测试很重要。

使用Chrome DevTools 中的Recorder 面板,您可以录制和重放用户流程,通过不同的第三方扩展和库将其导出为各种格式(例如测试脚本),使用 Puppeteer Replay 库自定义用户流程,并将其与您现有的工作流程。

在这篇博文中,我们将讨论:

  • 如何以编程方式导出和重放用户流。
  • 如何借助 Puppeteer Replay 自定义用户流程。
  • 如何与您的CI/CD工作流程集成。

以编程方式导出用户流并重播

默认情况下,Recorder 使您能够将这些录音导出为PuppeteerPuppeteer Replay脚本,或者导出为纯 JSON 文件。

ModernWeb

使用 Puppeteer Replay 进行重播

将用户流导出为 JSON 文件后,您可以选择将其导入回记录器面板并重播,或使用外部库来重播。Puppeteer Replay库是可用的库之一。

重播扩展允许扩展向 DevTools 添加面板来配置重播并显示重播结果。

Puppeteer Replay 是一个库,可帮助您重播用户流程。它是一个基于 Puppeteer 的库,它允许您在浏览器中重播用户流程,而无需编写任何代码。您可以使用 Puppeteer Replay 重播您的用户流程,以确保您的应用程序在不同的环境中运行良好。

与 CI/CD 管道集成

有多种方法可以做到这一点,并且有很多工具。以下是使用GitHub Actions自动执行此过程的示例:

# .github/node.js.yml

name: Replay recordings

on:
  push:
    branches: [ "main" ]
  schedule:
    - cron: '30 12 * * *' # daily 12:30pm

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js
      uses: actions/setup-node@v3
      with:
        node-version: 18.x
        cache: 'npm'
    - run: npm install puppeteer
    - run: npm run replay-all
    - run: npm run start

在此示例中,我们将在以下情况下重播用户流:

新的变更推送到main分支
每天中午 12:30
除了 GitHub Actions 之外,您还可以与您最喜欢的云提供商集成。