普通视图

发现新文章,点击刷新页面。
昨天以前首页

Vue 事件绑定机制

2025年8月17日 14:40

Vue 将事件系统拆分为原生 DOM 事件与自定义组件事件两套正交实现,前者对接浏览器事件循环,后者基于发布–订阅模型。本文以 v-on(缩写 @)为线索,结合运行时源码路径,给出端到端的实现剖析。

一、架构概览

Vue 的事件绑定分为两条主线:

  • 原生事件绑定

    通过 @clickv-on:click 直接作用于普通 DOM 元素,最终调用浏览器的 addEventListener

  • 组件事件绑定

    通过 @click 作用于子组件标签时,实际上是父组件监听子组件的自定义事件,由子组件通过 $emit 触发,不经过 DOM。

二、原生事件绑定:从 AST 到 addEventListener

1.编译阶段

模板中的 @click="handler" 经模板编译器解析后,生成 AST,最终转化为 VNode 的 data.on = { click: handler }

2.运行时挂载

首次渲染时,patch 过程会调用 createElm,为真实 DOM 节点执行 invokeCreateHooks,其中 cbs.create 包含 updateDOMListeners(位于 src/platforms/web/runtime/modules/events.js)。

updateDOMListeners 的职责:

  • 归一化事件名,处理 IE 兼容性差异。
  • 生成包裹函数,处理 .once.passive.capture 等修饰符。
  • 调用 updateListenersaddtarget.addEventListener(type, wrappedHandler, useCapture)

3.更新阶段

当组件更新时,patch 再次调用 updateDOMListeners,通过 sameVnode 判断事件差异,按需移除旧事件并重新绑定新事件。

三、组件事件绑定:on + events + emit

1.父组件编译

<Child @click="handleClick" /> 编译后,VNode 的 componentOptions.listeners = { click: handleClick },不会出现在 DOM 属性上。

2.子组件初始化

子组件实例化时:

  • initInternalComponent 将父级 listeners 注入到 vm.$options._parentListeners
  • initEvents 创建 _events = Object.create(null) 作为事件中心。
  • _parentListeners 非空,执行 updateComponentListeners(vm, _parentListeners),内部通过 $on 注册事件:
function add(event, fn) {
  vm.$on(event, fn)
}

3.手动触发

子组件内部调用 this.$emit('click', payload) 时,执行:

const cbs = vm._events[event]
if (cbs) {
  cbs.forEach(cb => cb(payload))
}

整个过程与浏览器事件体系完全隔离,因此可跨层级通信,且参数可控。

四、.native:在组件根节点强制使用原生事件

<Child @click.native="handler" /> 编译为 nativeOn 而非 on,运行时由 updateDOMListeners 读取 nativeOn,流程与原生事件一致,绑定在组件根 DOM 上。

五、事件修饰符实现细节

  • .stop:包裹函数内调用 e.stopPropagation()
  • .prevent:包裹函数内调用 e.preventDefault()
  • .once:绑定后立即移除监听器,并标记 _withOnce
  • .passive:调用 addEventListener(type, fn, { passive: true })
  • .capture:第三个参数传入 useCapture: true

六、性能与内存考量

  • 原生事件由浏览器托管,Vue 仅在 VNode 销毁时执行 removeEventListener,无额外开销。
  • 组件事件存储在 JS 对象,组件销毁时统一 $off,防止内存泄漏。

结论

Vue 事件系统通过“编译期转换 + 运行时调度”实现高度抽象:

  • 原生事件:AST → VNode → patch → addEventListener,完全对齐浏览器。
  • 组件事件:父子间通过 VNode.listeners → vm.events → emit,脱离 DOM,实现跨组件通信。

理解这一分层设计,有助于在复杂场景(服务端渲染、微前端、自定义渲染器)中精准定位事件相关问题。

Vue SSR原理

2025年8月16日 08:44

当搜索引擎的爬虫访问我们的站点时,如果只看到一句冷冰冰的 <div id="app"></div>,SEO 基本就凉了。Vue SSR(Server-Side Rendering,服务端渲染)正是为了解决这个问题:让首屏 HTML 在服务器上生成,既能被爬虫读懂,又能让用户以最短的时间看到内容。

一、两份入口文件,一个共享的“根”

Vue 项目的传统 SPA 只有一个 main.js,而 SSR 需要两个入口:

  • app.js

    这是“纯粹”的 Vue 根实例工厂,既不挂载 DOM,也不关心运行在哪个环境。它返回一个干净的 new Vue(),被客户端和服务端共同引用。

  • client-entry.js

    拿到 app.js 返回的实例后,直接 mount('#app'),把静态标记激活成可交互的 SPA。

  • server-entry.js

    在 Node 环境里执行,职责有三件:

    1. 调用 app.js 创建根实例;
    2. 根据请求 URL 做路由匹配,找到需要渲染的组件;
    3. 执行组件暴露的 asyncDatafetch,把数据预取到 Vuex store。

这样设计让“渲染”与“激活”解耦,同一份业务代码跑在两端。

二、Webpack 打出两份 Bundle

构建时,Webpack 会跑两次:

  • Client Bundle

    打包所有客户端代码,输出到 dist/client,浏览器下载后负责激活静态 HTML。

  • Server Bundle

    打包所有服务端代码,输出到 dist/server,Node 进程通过 vue-server-renderer 读取这份 Bundle,生成首屏 HTML。

两份 Bundle 都包含业务组件,但前者带浏览器运行时,后者只保留渲染逻辑,体积更小。

三、服务器收到请求,一条流水线干活

当用户或爬虫发来 GET /article/42

  1. Node 进程加载 server-entry.js,创建一个新的 Vue 实例。
  2. 路由匹配到 Article.vue,触发 asyncData 钩子,拉取文章详情并写入 store。
  3. vue-server-renderer 把组件树渲染成字符串,插入到模板中的 <!--vue-ssr-outlet--> 占位符里。
  4. 为了让客户端“无缝续命”,服务器把 store 状态序列化成一段脚本:
   <script>window.__INITIAL_STATE__ = {...}</script>
  1. 最终拼好的 HTML 响应给浏览器,首屏直出完成。

四、浏览器“激活”静态标记

浏览器拿到 HTML 后,做了三件事:

  1. 解析 DOM 并立即渲染,用户瞬间看到文章标题与正文。

  2. 加载 Client Bundle,执行 client-entry.js,创建同构的 Vue 实例。

  3. 通过 __INITIAL_STATE__ 恢复 store 数据,再调用 hydrate 而非 mount

    hydrate 会对比服务端返回的 DOM 与客户端虚拟 DOM,复用已有节点、绑定事件,把“死”的 HTML 激活成“活”的 SPA。

这一步叫 客户端激活(Client Hydration),只有 DOM 结构与数据完全一致才能成功,否则 Vue 会整段替换,带来性能损耗。

五、交互回归 SPA 常态

激活完成后,所有路由跳转、数据更新都由浏览器接管,退化为普通单页应用。

SSR 只在首屏出场一次,之后不再参与。这样既享受了 SEO 与首屏性能,又保留了 SPA 的流畅体验。

六、总结

VUE SSR 的核心思想是让服务器先跑一次组件的渲染函数,把结果 HTML 交给浏览器,浏览器再用同一份代码激活它。

❌
❌