阅读视图

发现新文章,点击刷新页面。

⏰前端周刊第 451 期(2026年1月25日-1月31日)

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:frontendweekly.cn/

banner-raw.png


💬 推荐语

本期主题偏向“平台新能力落地 + 工程工具链升级”。Web 开发部分重点关注 HTML Invoker Commands 在主流浏览器达成 baseline 支持,以及 Chrome Canary 的文本缩放试验;工具链方面则有 Yarn 6 预览、Rolldown 1.0 RC 与面向“前端考古”的 ReliCSS。无障碍栏目从 AI 驱动的诉讼风险谈到如何更主动地把可访问性做进流程,并补上一条关于原生 dialog 是否需要“强制焦点陷阱”的实践纠偏。最后在 WebGPU 与图形方向,既有流体模拟与文字溶解特效的完整拆解,也有 mrdoob 用 Three.js 复刻 1996 年《Quake》的硬核项目。CSS 侧补齐 Reset、层叠上下文、纯 CSS 手风琴、::search-text 等新伪元素与断点设计思路;JavaScript/TypeScript 则围绕 2026 框架生态趋势、TanStack Start 的并发更新策略与 async/await 的工程化写法。


🗂 本期精选目录

🧭 Web 开发

🛠 工具

♿️ 无障碍访问

✨ 演示/特效

🎨 CSS

💡 JavaScript

🧷 TypeScript

当前前端领域的新能力和工具链的升级,带来了更简化的开发流程和更高效的工程实践。例如,HTML Invoker Commands 在浏览器中的 baseline 支持减少了样板 JS 代码,Yarn 6 的预览版则进一步提升了工作流兼容性。然而,快速落地时,如何在复杂项目中高效整合这些新技术仍然是团队面临的一大挑战,尤其是在跨平台与多工具链协调时。借助 RollCode 低代码平台私有化部署自定义组件静态页面发布(SSG + SEO),可以帮助开发者更轻松地管理和落地这些工程化工具。

Vue-从内置指令到自定义指令实战

前言

在 Vue 的开发世界里,“指令(Directives)”是连接模板与底层 DOM 的桥梁。除了官方提供的强大内置指令外,Vue 还允许我们根据业务需求自定义指令。本文将带你一次性梳理 Vue 指令体系,并手把手实现一个高频实用的“一键复制”指令。

一、 Vue 内置指令全家桶

在深入自定义指令之前,我们先复习一下这些每天都在用的“老朋友”。内置指令以 v- 开头,是 Vue 预设的特殊属性。

指令 作用描述 核心要点
v-bind 响应式地更新 HTML 属性 简写为 :,如 :src:class
v-on 绑定事件监听器 简写为 @,如 @click
v-model 在表单及组件上创建双向绑定 它是 v-bindv-on 的语法糖
v-if / v-else 根据条件渲染/销毁元素 真正的条件渲染(销毁与重建)
v-show 根据条件切换元素的显示 基于 CSS 的 display: none 切换
v-for 基于源数据多次渲染元素 建议必须绑定唯一的 :key
v-html 更新元素的 innerHTML 注意:易导致 XSS 攻击,慎用
v-once 只渲染元素和组件一次 随后的重新渲染将跳过该部分,用于优化性能

二、 自定义指令:像 v-model 一样强大

1. 核心概念

自定义指令主要用于提高代码复用性。当你发现自己在多个组件中都在操作同一个 DOM 逻辑时,就该考虑将其封装为指令了。

2. 生命周期(钩子函数)

Vue 3 重构了指令钩子,使其与组件生命周期完美对齐:

Vue 3 钩子 Vue 2 对应 执行时机
beforeMount bind 指令第一次绑定到元素时调用
mounted inserted 绑定元素插入父节点时调用
beforeUpdate update 元素所在组件 VNode 更新前
updated componentUpdated 组件及子组件全部更新后调用
unmounted unbind 指令与元素解绑且元素已卸载

3. 钩子函数参数

指令对象的钩子函数中都带有如下参数:

  • el: 绑定的真实 DOM。

  • binding: 对象,包含

    • name:指令名,不包括 v- 前缀。
    • value:指令的绑定值,例如:v-my-directive="1 + 1" 中,绑定值为 2
    • oldValue:指令绑定的前一个值,仅在 ``update/beforeUpdate 和 componentUpdated/updated` 钩子中可用。无论值是否改变都可用。
    • expression:字符串形式的指令表达式。例如 v-my-directive="1 + 1" 中,表达式为 "1 + 1"
    • arg:传给指令的参数,可选。例如 v-my-directive:foo 中,参数为 "foo"
    • modifiers:一个包含修饰符的对象。例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true }
  • vnodeVue 编译生成的虚拟节点

  • oldVnode:上一个虚拟节点,仅在 update/beforeUpdate 和 componentUpdated/updated 钩子中可用


三、 实战:实现“一键复制”指令 v-copy

1. 指令逻辑实现 (/libs/directives/copy.ts)

import { Directive, DirectiveBinding } from 'vue';

export const copyDirective: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    el.style.cursor = 'copy';
    
    // 绑定点击事件
    el.addEventListener('click', () => {
      const textToCopy = binding.value;
      
      if (!textToCopy) {
        console.warn('v-copy: 无复制内容');
        return;
      }

      // 现代浏览器 API
      if (navigator.clipboard && window.isSecureContext) {
        navigator.clipboard.writeText(String(textToCopy))
          .then(() => alert('复制成功!'))
          .catch(() => alert('复制失败'));
      } else {
        // 兼容降级方案
        const textarea = document.createElement('textarea');
        textarea.value = String(textToCopy);
        textarea.style.position = 'fixed';
        textarea.style.left = '-9999px';
        document.body.appendChild(textarea);
        textarea.select();
        try {
          document.execCommand('copy');
          alert('复制成功!');
        } catch (err) {
          console.error('复制失败', err);
        }
        document.body.removeChild(textarea);
      }
    });
  }
};

2. 全局注册与使用

注册 (main.ts):

import { createApp } from 'vue';
import App from './App.vue';
import { copyDirective } from './libs/directives/copy';

const app = createApp(App);
app.directive('copy', copyDirective); // 全局注册
app.mount('#app');

使用:

<template>
  <button v-copy="'这是要复制的内容'">点击复制</button>
</template>

四、 总结

  1. 内置指令覆盖了 90% 的开发场景,应熟练掌握其简写与区别(如 v-if vs v-show)。

  2. 自定义指令是操作 DOM 的最后防线,通过 mountedupdated 钩子可以实现极其灵活的逻辑。

  3. 注意规范:在 Vue 3 + TS 环境下,务必为指令和参数标记类型,以确保代码的健壮性。

Vue-深度解析“组件”与“插件”的区别与底层实现

前言

在 Vue 的生态系统中,“组件(Component)”和“插件(Plugin)”是构建应用的两大基石。虽然它们都承载着逻辑复用的使命,但在设计模式、注册方式和职责边界上却截然不同。本文将带你从底层原理出发,理清二者的核心差异。

一、 核心概念对比

1. 组件 (Component)

组件是 Vue 应用的最小构建单元,通常是一个 .vue 后缀的文件。

  • 本质:可复用的 UI 实例。
  • 职责:封装 HTML 结构、CSS 样式和 TS 交互逻辑。

2. 插件 (Plugin)

插件是用于扩展 Vue 全局功能的工具库。

  • 本质:一个包含 install 方法的对象或函数。
  • 职责:为 Vue 添加全局方法、全局指令、全局组件或注入全局属性(如 vue-routerpinia)。

二、 关键区别总结

特性 组件 (Component) 插件 (Plugin)
功能范围 局部的 UI 渲染与交互 全局的功能扩展
代码形式 .vue 文件(SFC)或渲染函数 暴露 install 方法的 JS/TS 对象
注册方式 app.component() 或局部引入 app.use()
使用场景 按钮、弹窗、列表等 UI 单元 路由管理、状态管理、全局水印指令等

三、 编写形式

1. 编写一个组件

组件的编写我们非常熟悉,通常使用 DefineComponent<script setup>

<template>
  <button class="my-btn"><slot /></button>
</template>

<script setup lang="ts">
// 组件内部逻辑
</script>

2. 编写一个插件 (Vue 3 写法)

在 Vue 3 中,插件的 install 方法第一个参数变为 app (应用实例) ,而不再是 Vue 构造函数。

// myPlugin.ts
import type { App, Plugin } from 'vue';

export const MyPlugin: Plugin = {
  install(app: App, options: any) {
    // 1. 添加全局方法或属性 (通过 config.globalProperties)
    app.config.globalProperties.$myGlobalMethod = () => {
      console.log('执行全局方法');
    };

    // 2. 注册全局指令
    app.directive('my-highlight', {
      mounted(el: HTMLElement, binding) {
        el.style.backgroundColor = binding.value || 'yellow';
      }
    });

    // 3. 全局混入 (慎用)
    app.mixin({
      created() {
        // console.log('插件注入的生命周期');
      }
    });

    // 4. 注册全局组件
    // app.component('GlobalComp', MyComponent);

    // 5. 提供全局数据 (Provide / Inject)
    app.provide('plugin-config', options);
  }
};

四、 注册方式的演进

1. 组件注册

  • 全局注册app.component('MyBtn', MyButton)
  • 局部注册:在父组件中直接 import导入。

2. 插件注册

在 Vue 3 中,使用应用实例的 use 方法。

// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { MyPlugin } from './plugins/myPlugin';

const app = createApp(App);

// 安装插件,可以传入可选配置
app.use(MyPlugin, {
  debug: true
});

app.mount('#app');

五、 总结与注意事项

  1. Vue 3 的变化:Vue 3 移除了 Vue.prototype,改为使用 app.config.globalProperties 来挂载全局方法。

  2. 职责分离:如果你的代码是为了在页面上显示一段内容,请写成组件;如果你是为了给所有的组件提供某种“超能力”(如统一处理错误、多语言支持),请写成插件

  3. 插件的 install 机制app.use 内部会自动调用插件的 install 方法。如果插件本身就是一个函数,它也会被直接当做 install 函数执行。

一次 Agent Skill 的实战体验,以及 MCP 和 Skill 的区别

本周通过一个小需求尝试了下 Agent Skill,效果还不错。

比如你要做一个网站,以前没装技能的时候,AI 生成的代码又是那个熟悉的:

蓝紫渐变色 + 千篇一律的布局 + 明显的 AI 审美(不同的模型,产生的结果不同)

而通过 Agent Skill 的形式,可以提前配置好:

  • 配色体系
  • 字体
  • 布局风格

当然,rules 和 prompt 也能做到这一点。
但 Agent Skill 的优势在于:把 Prompt 打包成一个文件夹,让 AI 按需读取和使用

虽然本质上没啥区别,都是 prompt,但 Skill 的形态更工程化、更灵活。

image.png

MCP 和 Skill:经常一起出现,但不是一回事

现在用 AI Agent 工具(Claude Code、Cursor)时,经常会遇到两个概念:

  • MCP
  • Skill

我觉得有必要区分清楚:两者各有侧重,是互补关系,而不是替代关系。

Anthropic 官方的说法:

MCP connects Claude to external services and data sources.
Skills provide procedural knowledge—instructions for how to complete specific tasks or workflows.

翻成一句话就是:

MCP 让 AI 能拿到数据,Skill 教 AI 怎么处理数据。

MCP 在做什么?

MCP 的三个核心组成:

  • Tools(工具)
  • Resources(资源)
  • Prompts(提示)

LLM(大语言模型)本身并不执行函数,在 Agent 架构中,通常是由规划层(Planner / System Prompt) 决定“要做什么”。

Function Calling 负责在推理过程中,表达模型想要调用某个工具的意图。MCP 构建在 Function Calling 之上,进一步规范工具的描述方式、发现机制与调用协议。可以理解为:

  • 规划层:决定做什么
  • Function Calling:表达要调用哪个工具
  • MCP:规范这个工具从哪里来、如何被发现、如何被调用

MCP 更关注的是:AI 与外部世界的连接能力

同时需要注意:

MCP 本身并不提供推理能力,
它解决的是连接与通信的标准化问题。
是否正确使用工具、如何组合工具,仍然取决于模型能力与上层 Agent 设计。

Skill 在做什么?

Skill 可以以文件夹(Prompt 资产)形式存在,里面包含:

  • 指令
  • 脚本
  • 资源

但 Skill 的价值并不在于“文件夹本身”,而在于:

这些 Prompt 资产能够被 Agent 识别、发现、加载和组合使用。

在架构层级上:Skill 是「提示 / 知识层」、MCP 是「集成层」

Skill 通常分三层加载:

  1. 元数据(始终加载)
  2. 核心指令(按需加载)
  3. 支持文件(按需加载)

它解决的是:如何把经验、规范、做事方法沉淀下来并复用

同时需要明确:

从能力本质上看,Skill 并不是新的模型能力,
而是对 Prompt 的工程化封装与组织升级
提升的是稳定性与可维护性,而不是智能本身的跃迁。

什么时候用 MCP?什么时候用 Skill?

  • 用 MCP
    • 获取外部数据
    • 调接口
    • 操作系统、文件、数据库
  • 用 Skill
    • 内部规范
    • 标准化实践经验
    • 固定工作方式
    • 代码风格 / 设计风格约束

网上也有人提到 Skill 可以用于指定工作流程,这块我还没有深入实践,后面有时间会再尝试。

一个对照式实战示例:同一个需求,不同方式怎么做?

假设现在有一个需求:从接口获取用户数据,并生成一个用户列表页面。

只用 Prompt

你可能会这样写:

请从接口 api.xxx.com/users 获取用户数据,并使用 Vue3 生成一个简洁风格的用户列表页面。

特点:

  • 每次都要重复写
  • 输出风格不稳定

因此,这种方式更适用于一次性需求。

使用 Skill 固化“页面生成方式”

创建一个 Skill,例如:

/frontend-ui-skill
  ├── metadata.json
  ├── instructions.md
  ├── style-guide.md

instructions.md:

所有页面使用:

  • 浅色背景
  • 中性色配色
  • 卡片式布局
  • Vue3 + Composition API

之后你只需要说:

生成用户列表页面

AI 会自动套用该 Skill 的规则。适用于前端规范化输出的场景。

使用 MCP 获取真实数据

通过 MCP 暴露一个工具:

getUsers()

你对 AI 说:

调用 getUsers

AI 会通过 MCP 获取接口数据。

MCP + Skill 组合

流程:

  1. MCP:调用 getUsers()
  2. Skill:规定页面结构和风格
  3. AI:生成页面代码

你只需要说:“生成用户列表页面”,背后完成了:

  • 拿数据
  • 套规范
  • 产出代码

一个更大的共性

不管是 MCP、Prompt 还是 Skill,本质目标都一致:

降低模型幻觉,提高稳定性,提高效率。

但也必须明确:

MCP、Prompt、Skill 都无法从根本上消除模型幻觉,
它们能做的是:降低出错概率、提高一致性、减少不确定性。

因此,完全脱离人工审核的流程化自动生成,在工程上仍然是不可靠的

它们更合理的定位是:

放大工程师能力的工具,而不是替代工程师。

Vue-实例从 createApp 到真实 DOM 的挂载全历程

前言

无论是 Vue 2 的 new Vue() 还是 Vue 3 的 createApp(),将组件配置转化为页面上可见的真实 DOM,中间经历了一系列复杂的转换。理解这一过程,不仅能帮我们更好地掌握生命周期,更是理解响应式原理的基础。

一、 挂载过程总览

Vue 实例的挂载过程,本质上是将组件配置转化为虚拟 DOM,最终映射为真实 DOM,并建立响应式双向绑定的过程。


二、 核心挂载步骤详解

1. 初始化阶段 (Initialization)

在 Vue 3 中,通过 createApp 开始。

  • 创建实例:根据传入的根组件配置创建一个应用上下文(vue实例),接着进行数据初始化。

  • 初始化数据:这是最关键的一步。Vue 会依次初始化 Props、Methods、Setup (Vue 3)、Mixins、Data、Computed

    • 校验:Vue 会校验 propsdata 中的变量名是否重复。
    • 响应式绑定:Vue 3 使用 Proxy(Vue 2 使用 Object.defineProperty)对数据进行劫持,建立依赖收集机制。

2. 模板编译阶段 (Template Compile)

这一步将“肉眼可见”的 HTML 模板转化为机器高效执行的 JavaScript 代码。

  • 解析 (Parser) :将 template 字符串解析为 抽象语法树 (AST)
  • 转换 (Transformer) :对 AST 进行静态提升、补丁标记(Patch Flags)等优化。
  • 生成 (Generator) :将 AST 转换成 render 渲染函数 字符串。

3. 生成虚拟 DOM (VNode)

  • Vue 调用生成的 render 函数。
  • render 函数根据Template执行后会返回一个 虚拟 DOM 树 (Virtual DOM) 。它是对真实 DOM 的一种轻量级 JavaScript 对象描述。

4. 挂载与 Patch (Mounting & Patching)

  • 调用 Mount:执行组件的挂载方法。
  • 渲染真实 DOM:渲染器(Renderer)遍历虚拟 DOM 树,递归创建真实的 HTML 元素。
  • 更新页面:将生成的真实 DOM 插入到指定的容器(如 #app)中,替换掉原本的内容。

5. 完成挂载

  • 一旦真实 DOM 渲染完毕,Vue 会触发 mounted(组合式 API 为 onMounted)生命周期钩子,此时开发者可以安全地访问 DOM 节点。

三、 Vue 3 挂载示例

在 Vue 3 项目中,挂载通常发生在 main.ts

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 1. 创建应用实例
const app = createApp(App)

// 2. 挂载到指定 DOM 容器
// 挂载过程中会执行编译、数据拦截、生成 VNode 并渲染
app.mount('#app')

四、 总结

  1. AST 与 VNode 的区别

    • AST:是对 HTML 语法的描述,用于代码编译阶段。
    • VNode:是对 DOM 节点的描述,用于运行时渲染和 Diff 算法。
  2. 双向绑定的建立时机:在 data 初始化阶段,Vue 就已经通过响应式 API 拦截了数据。当 render 函数读取数据时,会自动触发依赖收集。

  3. 重新挂载:如果响应式数据发生变化,Vue 不会重新走一遍完整的挂载过程,而是通过 Diff 算法 对比新旧 VNode,仅更新发生变化的真实 DOM 部分。

Vue-路由懒加载与组件懒加载

前言

在构建大型单页应用(SPA)时,JavaScript 包体积(Bundle Size)往往会随着业务增长而膨胀,导致首屏加载缓慢、白屏时间长。懒加载(Lazy Loading) 是解决这一问题的核心方案。其本质是将代码分割成多个小的 chunk,仅在需要时才从服务器下载。

一、 路由懒加载:按需拆分页面

1. 为什么需要路由懒加载?

如果不使用懒加载,所有路由对应的组件都会被打包进同一个 app.js 中。用户访问首页时,浏览器不得不下载整个应用的逻辑,造成严重的性能浪费。

2. 实现方式:ES import()

利用动态导入语法,打包工具(如 Vite 或 Webpack)会自动进行 代码分割(Code Splitting)

// router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';

// 静态导入:会随着主包一起加载,适合首页
import Home from '@/views/Home.vue';

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // 动态导入:只有访问 /about 路径时,浏览器才会请求该组件对应的 JS 文件
    component: () => import('@/views/About.vue')
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

二、 组件懒加载:细粒度控制

有些组件(如弹窗、复杂的图表、第三方重型库)并不需要在页面初次渲染时立即存在。

1. Vue 3 的 defineAsyncComponent

在 Vue 3 中,异步组件必须使用 defineAsyncComponent 进行显式声明。

示例

<template>
  <div>
    <h1>主页面</h1>
    <button @click="showChart = true">加载并显示报表</button>
    
    <AsyncChart v-if="showChart" />
  </div>
</template>

<script setup lang="ts">
import { ref, defineAsyncComponent } from 'vue';

const showChart = ref<boolean>(false);

// 显式定义异步组件
const AsyncChart = defineAsyncComponent(() =>
  import('@/components/BigChart.vue')
);

// 高级配置(可选):带加载状态
const AsyncComponentWithConfig = defineAsyncComponent({
  loader: () => import('./components/MyComponent.vue'),
  loadingComponent: LoadingComponent, // 加载过程中显示的组件
  errorComponent: ErrorComponent,     // 加载失败时显示的组件
  delay: 200,                         // 展示加载组件前的延迟时间
  timeout: 3000                       // 超时时间
});
</script>

2. Vue 2 中直接使用import函数声明异步组件

export default {
  components: {
    // 定义一个异步组件
    'MyLazyComponent': () => import('./components/MyLazyComponent.vue')
  }
}

三、 底层原理与分包策略

1. 打包工具的配合

当你使用 import() 时:

  • Vite/Rollup:会自动将该组件及其依赖提取到一个独立的 .js 文件中。

  • Webpack:会生成一个 chunk,你可以通过“魔法注释”自定义 chunk 的名称:

    const About = () => import(/* webpackChunkName: "about-group" */ './About.vue')
    

四、 总结

  1. 首屏优化:建议首页(Home)使用静态导入,而其他非核心路径、非首屏展示的弹窗/插件全部使用懒加载。

  2. 用户体验:使用异步组件时,建议配合 loadingComponent,避免加载过程中组件区域出现突兀的空白 。

Vue-异步更新机制与 nextTick 的底层执行逻辑

前言

在 Vue 开发中,你是否遇到过“修改了数据但立即获取 DOM 元素,拿到的却是旧值”的情况?这背后涉及 Vue 的异步更新策略。理解 nextTick,就是理解 Vue 如何与浏览器的事件循环(Event Loop)“握手”。

一、 为什么需要 nextTick?

1. 概念定义

nextTick 的核心作用是:在修改数据之后立即使用这个方法,获取更新后的 DOM。因为在vue里面当监听到我们的数据发送变化时,vue会开启一个异步更新队列,视图需要等待队列里面的所有数据变化完成后,再进行统一的更新。

2. Vue 的异步更新策略

Vue 的响应式并不是数据一变,DOM 就立刻变。

  • 当数据发生变化时,Vue 会开启一个异步更新队列
  • 如果同一个 watcher 被多次触发,只会被推入队列一次(去重优化)。
  • 这种机制避免了在一次同步操作中,因为多次修改数据而导致的重复渲染,极大的提高了性能。

二、 核心原理:基于事件循环(Event Loop)

nextTick 的实现逻辑紧密依赖于 JavaScript 的执行机制。

1. 任务调度逻辑

  1. 数据变更:修改响应式数据,Vue 将 DOM 更新任务推入一个异步队列(微任务)。
  2. 注册回调:调用 nextTick(callback),Vue 将该回调推入一个专用的 callbacks 队列。
  3. 执行时机:Vue 优先尝试创建一个微任务(Microtask) ,通常使用 Promise.then。如果环境不支持,则降级为宏任务(如 setTimeout)。
  4. 顺序保证:Vue 内部通过代码执行顺序,确保 DOM 更新任务先于 nextTick 的回调任务 执行。

2. 宏任务与微任务的演进

  • 优先选择Promise.thenMutationObserver(微任务)。
  • 降级选择:如果上述不可用,则降级为宏任务 setImmediatesetTimeout(fn, 0)

三、 使用示例:

1. 在setup中操作 DOM

setup 阶段,组件尚未挂载,DOM 不存在。只有在onMounted中才会创建, 所以无法直接操作,需要通过nextTick()来完成。

<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue';

const message = ref<string>('初始内容');
const divRef = ref<HTMLElement | null>(null);

// 模拟 setup 阶段(相当于 Vue 2 的 created)
nextTick(() => {
  // 此时 DOM 可能已挂载(取决于具体执行时机),但在 setup 同步代码中无法直接访问
  console.log('setup 中的 nextTick 回调');
});
</script>

2. 数据更新后获取最新的视图信息

这是最常见的场景:例如根据动态内容计算容器高度。

<template>
  <div ref="listRef" class="list">
    <div v-for="item in list" :key="item">{{ item }}</div>
  </div>
  <button @click="addItem">新增条目</button>
</template>

<script setup lang="ts">
import { ref, nextTick } from 'vue';

const list = ref<string[]>(['Item 1', 'Item 2']);
const listRef = ref<HTMLElement | null>(null);

const addItem = async () => {
  list.value.push(`Item ${list.value.length + 1}`);
  
  // ❌ 此时获取的高度是更新前的
  console.log('更新前高度:', listRef.value?.offsetHeight);

  // ✅ 等待 DOM 更新
  await nextTick();

  // 此时可以获取到新增条目后的真实高度
  console.log('更新后高度:', listRef.value?.offsetHeight);
};
</script>

四、 总结:nextTick 的“避坑”锦囊

  • 同步逻辑 vs 异步逻辑:修改数据是同步的,但 DOM 变化是异步的。所有紧随数据修改后的 DOM 操作,都应该放进 nextTick

  • Promise 语法糖:在 Vue 3 中,nextTick 返回一个 Promise。你可以使用 await nextTick() 代替传统的 nextTick(() => { ... }),使代码更具可读性。

  • 性能注意:虽然 nextTick 很好用,但不要滥用。频繁的 DOM 查询依然会带来性能开销,能通过数据驱动(数据绑定)解决的问题,尽量不要手动操作 DOM。

闭包:从「能跑」到「可控」——用执行上下文把它讲透,再把泄漏风险压住

引言:为什么团队里闭包总是「会用但讲不清」

闭包几乎是每个前端都“用过”的能力:回调、事件处理、节流防抖、柯里化、状态缓存……到处都是它。但一到排查线上内存飙升、解释“为什么变量没被回收”、或者评审里讨论“这个写法会不会泄漏”,就容易陷入两种极端:

  • 把闭包当玄学:只记住“函数套函数 + 引用外部变量”,但不知道底层到底发生了什么。
  • 把闭包当洪水猛兽:遇到闭包就怕泄漏,动不动“全局变量/单例就是坏”。

这篇文章的目标很明确:用“执行上下文 + AO/GO + 可达性”把闭包拆开讲清楚,再落到工程实践:哪些写法会导致内存常驻、怎么定位、怎么释放、怎么验证。很多内容需要配合内存图理解(图片必须保留),建议边看边对照图。


目录

    1. 脉络:为什么闭包在 JS 里如此关键
    1. 闭包到底是什么:定义、自由变量与词法绑定
    1. 从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)
    1. 闭包形成的关键:[[scope]] / parentScope 为什么能“跨出上下文”
    1. 内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收
    1. 闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”
    1. 如何释放:最小化作用域、解除引用、弱引用思路
    1. 性能与排查:用浏览器工具定位闭包导致的内存/耗时
    1. 引擎优化:闭包里“没用到的变量”会怎样(V8 优化)
    1. 进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行
    1. 实战建议:团队落地清单 & 指标验证
    1. 总结:关键结论 + 下一步建议

f 脉络探索:闭包为什么值得被「认真对待」

闭包是 JavaScript 中一个非常容易让人迷惑的知识点:它既是语言表达力的源泉,也可能成为内存与可维护性的风险点。很多经典资料对它评价极高——因为它背后牵扯的是一整套“词法作用域 + 执行上下文 + 垃圾回收”的体系。

** 图7-1 《你不知道的JavaScript》上卷中对闭包的评语**

把这张图放在开头的意义是:闭包不是一个“语法点”,而是理解 JS 运行机制的入口。当你能把闭包讲清楚,很多“JS 为什么这样设计”的问题都会连起来。

本章小结(可迁移的经验)

  • 闭包不是“函数套函数”的表象,而是 词法作用域如何在运行时被保留
  • 闭包相关的争论,往往不是“对错”,而是 讨论口径(广义/狭义)不同
  • 真正工程风险来自:闭包让某些对象变成“长期可达” ,从而影响 GC。

一、闭包到底是什么:定义、自由变量与词法绑定

1.1 闭包的概念定义:把“感觉”变成“可证明”

闭包并不是 JavaScript 独有。计算机科学中,闭包(Closure)又称词法闭包(Lexical Closure),是一种在支持头等函数的语言中实现词法绑定的技术:闭包在实现上可以理解为“函数 + 关联环境(自由变量的绑定) ”的组合结构。

在 JavaScript 语境下,可以用更工程化的表达:

闭包 = 一个函数 + 该函数在定义时可访问的外层作用域引用(自由变量所在的词法环境)。

这里最关键的是两个词:

  • 自由变量:跨出自己作用域、来自外层作用域的变量(“不是我家里的变量,但我能用”)。
  • 词法解析阶段确定:函数“能访问哪些变量”,在**代码写出来那一刻(定义时)**就决定了,而不是调用时随机决定。

一个常用的工作记忆法: “函数定义时就把外层环境‘锁’住了” 。后续你把函数拿到哪里调用,它都沿着当初锁定的链去找变量。

1.2 广义 vs 狭义:团队沟通时要先统一口径

社区对“什么算闭包”常见两种口径:

  • 广义:JS 里“函数”几乎都带着词法作用域信息,因此都可以叫闭包。
  • 狭义(更严谨) :只有当函数实际捕获并使用外层变量,才讨论“闭包带来的效果”(比如变量驻留)。

下面这段代码就体现了差异点:它能访问 name,但是否把它视为“闭包”(严格意义)看你站哪种口径:

// 可以访问 name:test 算闭包(广义)
// 有访问到 name:test 算闭包(更严谨,讨论“闭包效果”更有意义)
var name = "放寒假了";
function test() {
  console.log(name);
}
test();

本章小结(落地清单)

  • 团队讨论闭包前,先明确口径:讨论“闭包机制”还是“闭包效果(变量驻留)”
  • 记住闭包核心:定义时锁定词法环境,不是调用时决定。
  • 所谓“自由变量”,本质就是:跨作用域访问的变量

二、从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)

闭包很容易被讲成“概念”,但工程上要做到“可控”,一定要落到执行过程:调用栈如何创建执行上下文?AO/GO 什么时候创建?引用链怎样形成可达性?

2.1 先看一个最小高阶函数:返回函数指针发生了什么

function foo() {
  // bar 预解析,前面有讲过
  function bar() {
    console.log("小吴");
  }
  return bar;
}

var fn = foo();

fn();
// 小吴

关键点:调用函数会创建执行上下文(Execution Context) 。执行上下文创建前,会先创建对应的 AO(Activation Object) :用于存放形参、局部变量、函数声明等。

2.2 内存图:从“栈/堆”视角理解 fn = foo() 的意义

建议把这一段当成闭包全篇的“底座”,后面所有闭包/泄漏/回收都在重复这个结构。

** 图7-2 高阶函数执行前**

** 图7-3 foo函数调用阶段内存图**

** 图7-4 foo函数中的bar函数调用阶段内存图**

把图 7-2 ~ 7-4 用一句话串起来:

  • foo() 调用时创建 foo 的执行上下文与 AO;
  • bar 函数对象存在于堆上,foo 的 AO 里保存了它的引用;
  • return bar 让全局 fn 指向 bar 的函数对象地址;
  • 后续 fn() 本质是在执行 bar()

你可以把它想象成:return 返回的不是函数体,而是“函数对象的指针”fn 接住这个指针后,就和 bar 的生命周期绑定了。

本章小结(落地清单)

  • AO 是“函数即将执行前”创建的,不是定义函数时就创建(避免无谓开销)。
  • return function 的本质是:返回堆上函数对象的引用
  • 后续 fn() 执行的是“那块函数对象”,而不是重新生成一份。

三、闭包形成的关键:为什么 [[scope]] / parentScope 能让变量跨出上下文

上一章只是“返回了函数”。闭包真正“神奇”的点在于:外层函数执行完了,内层函数还能访问外层变量

来看这个例子

function foo() {
  var name = "why";
  function bar() {
    console.log("小吴", name);
  }
  return bar;
}

var fn = foo();

fn();
// 小吴 why

如果只背概念会说:“bar 引用了 foo 的变量 name,所以形成闭包”。但工程上更重要的是 “它凭什么引用得到?” ——答案是:函数对象内部会保存定义时的外层作用域引用(常被描述为 [[scope]] / parentScope

3.1 发生了什么:把“访问外层变量”写成一条可执行的查找链

bar() 执行时要查 name

  1. 先查自己的 VO/AO(bar 的活动对象)——没有。
  2. 沿着函数对象记录的 parentScope(也就是 foo 的 AO)继续查——找到了。
  3. 输出 why

也就是说,闭包并不是“让变量不销毁”的魔法,而是:

bar 的函数对象握住了 foo 的 AO 引用,使得这块 AO 对 GC 来说一直是“可达的”。

** 图7-5 bar函数中的name形成闭包内存图**

3.2 常见误区:闭包 ≠ 执行上下文永远不销毁

一个容易混淆的点:执行上下文(FEC)会销毁,但 AO 是否可回收 取决于有没有被外界引用链保持可达。

  • foo() 的执行上下文从调用栈弹出,这是必然的;
  • foo 的 AO 如果被 barparentScope 引用着,并且 bar 又被 fn 引用着,那么它就仍然可达,无法回收;
  • “闭包效果”来自这条引用链,而不是来自“执行上下文不销毁”。

本章小结(落地清单)

  • 闭包的底层抓手是:函数对象持有 parentScope(词法环境引用)
  • “变量没被回收”不是因为执行上下文不弹栈,而是因为 对象仍可达
  • 解释闭包时,把“查找链”讲出来,团队沟通会更一致。

四、内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收

4.1 普通函数:执行完就“自由变量不自由”了

function foo() {
  var name = "xiaowu";
  var age = 20;
}

function test() {
  console.log("test");
}

foo();
test();

** 图7-6 foo与test函数执行前的初始化表现**

这张图强调:全局 GO 中保存的是函数对象引用;函数对象里保存了 parentScope 指向 GO;调用时创建执行上下文与 AO。

** 图7-7 foo函数和test函数的内存图执行过程**

** 图7-8 foo的执行上下文销毁前后对比**

关键结论在图 7-8:foo 执行结束,AO 里 name/age 没有被任何外部引用链持有,于是变为不可达,被回收。这就是“自由变量没能真的自由”。

4.2 闭包函数:AO 被外部引用链锁住,变量驻留

function foo() {
  var name = "xiaowu";
  var age = 20;

  function bar() {
    // 引用了外层变量,形成闭包
    console.log("这是我的名字", name);
    console.log("这是我的年龄", age);
  }

  return bar;
}

var fn = foo();
fn();
// 这是我的名字 xiaowu
// 这是我的年龄 20

** 图7-9 闭包执行前内存图**

** 图7-10 foo函数执行内存图**

** 图7-11 bar的函数执行上下文**

** 图7-12 bar脱离捕捉时的上下文,自由变量依旧存在**

图 7-12 是闭包“可解释”的关键画面:

  • fn -> bar函数对象(全局根对象可达)
  • bar函数对象 -> parentScope -> foo 的 AO
  • 因为这条链存在,所以 foo AO 仍可达,name/age 仍可达

本章小结(落地清单)

  • 普通函数执行完:AO 通常不可达 → 回收。
  • 闭包能驻留变量:本质是 AO 被函数对象的 parentScope 引用,并且函数对象又被根对象引用
  • 是否回收,归根到底看:从根对象出发是否可达(标记清除的核心判断)。

五、闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”

闭包会让变量驻留,但驻留 ≠ 泄漏。工程上判断泄漏的标准非常朴素:

本该释放、却因为不必要的引用链而长期可达的内存,占用不断增长或长时间不下降。

在闭包语境里,常见泄漏模式就是:返回的函数被长期保存(全局数组/缓存/事件回调/定时器),导致其捕获的外层 AO 一直可达


六、如何释放:最小化闭包作用域、解除引用、弱引用思路

6.1 解决策略三件套

  1. 最小化闭包作用域:只捕获必要数据(不要把整坨对象/大数组/DOM 节点顺手闭包进去)。
  2. 解除引用:用完就断开引用链,让对象从根不可达。
  3. 弱引用(WeakMap/WeakSet) :对“缓存类”场景非常有效(不会阻止 GC)。

6.2 “解除引用”的标准写法:把 fn 指向 null

// 内存泄漏解决方法
function foo() {
  var name = "xiaowu";
  var age = 20;

  function test() {
    console.log("这是我的名字", name);
    console.log("这是我的年龄", age);
  }

  return test;
}

var fn = foo();
fn();

// 解除引用:断开 root -> fn -> 函数对象 -> AO 的链
fn = null; // 注意:置 null 不会立刻回收,会在后续 GC 周期中回收

** 图7-13 fn指向bar的指针**

** 图7-14 fn指向bar的指针置为null**

图 7-14 的“孤岛”是你需要在脑子里形成的肌肉记忆:
只要根对象到不了这块内存,它迟早会被回收(标记清除的可达性判断)。

本章小结(落地清单)

  • 释放闭包的核心动作:断开根对象到函数对象的引用链(常见是置 null / 移除监听 / 清理数组缓存)。
  • 设计闭包时先问一句: “我真的需要捕获整个对象/大数组/DOM 吗?”
  • 缓存场景优先考虑:WeakMap/WeakSet(避免“缓存越用越大”)。

七、闭包泄漏案例:大对象被闭包捕获,内存与耗时如何爆炸

为了把问题讲“刺痛”,用一个极端但很真实的例子:闭包捕获一个大数组。

function createFnArray() {
  // 创建一个长度为1024*1024的数组,往里面每个位置填充1.观察占了多少的内存空间(int类型,整数1占4个字节byte)
  // 4byte*1024=4kb,再*1024为4mb,占据的空间是4M × 100 + 其他的内存 = 400M+
  // 在js里面不管是整数类型还是浮点数类型,看起来都是数字类型,这个时候占据的都是8字节,但是js引擎为了提高空间的利用率,对很多小的数字是用不到8个字节(byte)的,8字节 = 2的64次方,所以8字节是很大的,现在的js引擎大多数都会进行优化,对小的数字类型,在V8中称为Smi,小数字 2的32次方
  var arr = new Array(1024 * 1024).fill(1);

  return function () {
    console.log(arr.length);
  };
}

var arrayFn = createFnArray();

** 图7-15 闭包泄露案例**

如果你把 createFnArray() 创建出来的函数持续保存(比如 push 进数组),引用链会不断叠加:

** 图7-16 引用叠加,闭包无法释放**

7.1 用性能工具看“泄漏”长什么样

在浏览器 Performance 面板勾选 Memory,刷新/执行后,你会看到脚本耗时显著升高:

** 图7-17 闭包的性能检测**

7.2 释放后的对比:不一定立刻回收,但趋势会回来

function createFnArray() {
  var arr = new Array(1024 * 1024).fill(1);

  return function () {
    console.log(arr.length);
  };
}

var arrayFns = [];
for (var i = 0; i < 100; i++) {
  // createFnArray() // 不接收就会很快变成不可达
  arrayFns.push(createFnArray());
}

setTimeout(() => {
  arrayFns = null; // 关键:断开引用链
}, 2000);

** 图7-18 性能提升效果**

你还能通过调用树看到耗时主要来源于闭包相关逻辑:

** 图7-19 闭包耗时来源**

本章小结(落地清单)

  • 闭包泄漏常见触发器:闭包捕获大对象 + 长期保存闭包引用(数组/缓存/事件/定时器)。
  • Performance 勾选 Memory:关注 脚本耗时 + 内存曲线是否持续上升
  • “置 null”不保证立刻回收,但能保证:后续 GC 周期具备回收条件

八、引擎优化:闭包里“没用到的变量”会怎样(V8)

一个很实用的问题:闭包让外层 AO 不回收,那 AO 里没用到的属性会不会也一直占着?

例子:闭包只用 name,没有用 age

function foo() {
  var name = "why";
  var age = 18;

  function bar() {
    debugger;
    console.log(name);
  }

  return bar;
}

var fn = foo();
fn();

** 图7-20 V8引擎优化效果(未使用变量被销毁)**

继续在 debugger 暂停时验证:name 能访问,age 可能因为未使用被优化掉:

** 图7-21 debugger检测未使用的age变量是否真被回收**

这点对工程实践的启示非常直接:

  • 规范上你可以认为“闭包会保留整个 AO”;
  • 但引擎实现会做逃逸分析/变量提升优化等,减少无用变量占用
  • 不要依赖这种优化写代码:它是实现细节,不是稳定契约(尤其跨引擎/跨版本)。

本章小结(落地清单)

  • V8 可能回收闭包外层 AO 中“未被使用的变量”(实现优化)。
  • 工程判断别靠“引擎可能帮我优化”,仍以 引用链是否可达 为主。
  • 评审时更关注:闭包是否捕获了不必要的大对象/DOM/业务上下文

九、进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行

9.1 多个闭包实例:互不影响,释放也只释放自己的那份

同一个 foo() 调两次,得到的是两套独立的 AO 与函数对象:

function foo() {
  var name = "小吴";
  var age = 18;

  function bar() {
    console.log(name);
    console.log(age);
  }

  return bar;
}

var fn = foo();
fn();

var baz = foo();

fn = null; // 只会释放 fn 对应的那一套引用链
baz();

这条结论在工程里特别重要:你清理了一个引用,不代表全局都释放了。如果你把闭包存进多个地方(例如多个数组、多个事件回调、多个缓存),就需要逐个断链。

9.2 为什么 null 可以解除引用,而 undefined 不行?

这里有一个值得思考的问题:为什么 null 可以解除引用,而 undefined 不行?

从“引用链”的角度看:

  • null 是一个明确的“空值”,把变量指向空处,等价于 把这条引用边砍掉
  • undefined 更多表达“未初始化/缺省值”,它依然是一个值;更关键的是,在很多语义下它并不被用作“主动断链”的表达(团队代码规范也通常不推荐用 undefined 表达释放)。

工程建议:释放引用请用 null(语义清晰、团队共识强、便于 code review 与静态检查)。

本章小结(落地清单)

  • 每次调用外层函数,都会创建一套新的 AO/函数对象:闭包实例彼此独立。
  • 释放引用只影响对应那条链:你清理一个,不会自动清理所有
  • 断链用 null:表达“我主动释放”,比 undefined 更清晰。

十、实战建议:把“闭包可控”落到团队工程规范里

下面给一份可以直接放进团队“代码评审 checklist / 性能排查 SOP”的清单。

10.1 评审 Checklist(闭包相关)

  • 捕获内容最小化:闭包里只引用必要字段,避免把整个 props/state/context/大对象 捕获进去。
  • 避免捕获 DOM 节点:尤其是长生命周期的闭包(事件回调/单例缓存)捕获 DOM,会让节点难以回收。
  • 长生命周期容器要可清理:全局数组、Map 缓存、事件总线、定时器回调——都要有对应的清理路径。
  • 组件/页面卸载必须断链:移除事件监听、取消订阅、清理定时器、清空缓存引用(= null)。
  • 缓存优先 WeakMap:key 是对象的缓存(如 DOM 节点、组件实例)优先 WeakMap,减少“缓存常驻”。

10.2 排查 SOP(内存/性能)

  1. Performance 勾选 Memory:复现操作,观察内存曲线是否持续上升(不回落)。
  2. 录制并看调用树:定位高耗时函数是否来自闭包创建/大对象捕获。
  3. 缩小复现:把闭包引用容器(数组/缓存)逐步置 null,观察趋势变化(不是立刻回收,但趋势会变)。
  4. 检查引用链:谁在持有闭包?(全局变量、单例模块、事件总线、定时器、DOM 监听器最常见)

10.3 指标验证(建议团队共用)

  • 内存指标:关键页面操作 5 分钟后,JS Heap 是否可稳定回落到阈值区间
  • 性能指标:关键交互的 Long Task 次数/总耗时是否下降
  • 回归验证:增加“卸载/切页/重复进入”压测脚本,验证引用链不会累积

总结:关键结论 + 团队落地建议

关键结论(背下来就够用)

  • 闭包的本质是:函数对象持有定义时的外层作用域引用(parentScope/词法环境) ,从而让外层 AO 继续可达。
  • 是否回收不看“函数执行没执行完”,只看 从根对象出发是否可达(标记清除的核心)。
  • 闭包造成的风险不是“用了闭包”,而是:闭包捕获了不该长期驻留的对象,并且闭包引用被长期持有
  • 释放闭包的关键动作是:断开引用链(置 null、移除监听、清空容器、取消订阅等)。
  • 引擎可能优化未使用变量(如 V8),但工程上不要依赖实现细节,仍以引用链分析为准。

下一步建议(怎么在团队里真正落地)

  1. 把“闭包评审 checklist”加入 PR 模板:涉及事件、缓存、定时器、订阅时必须勾选清理项。
  2. 建立 1~2 个“典型泄漏 demo”用于 onboarding:让新人用 Performance/Memory 亲手看见“可达性”是什么。
  3. 在关键业务页引入定期压测(重复进入/退出/滚动/筛选等),用指标验证“内存可回落”。
  4. 对缓存策略做统一约束:对象 key 的缓存优先 WeakMap;全局数组缓存必须提供清理 API。

只要团队能把闭包从“语法点”升级成“引用链与可达性”的共识,闭包就会从“玄学”变成“可控工具”。

用 CSS 取代 React:一行代码解决复杂交互

你有没有想过,网页上那些看似简单的交互效果,背后可能需要多复杂的代码?

比如,你把鼠标移到一个按钮上,整个卡片的样式都变了。或者,你选中了一个选项,整个列表的颜色都跟着改变。这些效果,我们通常需要用 JavaScript 来实现,在 React 这样的框架里,可能就要写不少 state 管理的代码。

前端开发似乎越来越复杂了。我们为了实现一些界面效果,不得不引入庞大的框架和工具链。但有时候,我忍不住会想,有没有更简单的方法?

最近,我发现 CSS 有了一个新的功能,叫做 :has 选择器。它非常强大,强大到可以让我们用一行 CSS 代码,就取代掉原来需要几十行 JavaScript 才能实现的功能。今天,我就想和大家聊聊这个神奇的 :has 选择器。

一、什么是 :has 选择器?

学习 CSS 的第一天,我们就知道,它的选择器是从上到下,从外到内的。比如,div p 会选中 div 元素里面的所有 p 元素。我们从来没法“反过来”,让子元素去影响父元素。

这就好比一条单行道,你只能往前开,不能掉头。想让一个段落 p 根据它内部是否有图片 img 来改变自己的样式,在过去是办不到的,只能求助于 JavaScript。

但是,:has 选择器的出现,彻底改变了这一点。它就像给 CSS 开了一扇“后门”,让我们可以选择一个元素的“父元素”或者“前一个兄弟元素”。

举个例子,我们想让所有包含图片的卡片(.card)都有一个特殊的边框,可以这么写:

.card:has(img) {
  border: 2px solid blue;
}

这行代码的意思是:如果一个 .card 元素“拥有”(has)一个 img 子元素,那么就给这个 .card 元素加上蓝色的边框。是不是非常直观?

这个小小的改变,却为我们打开了一个全新的世界。很多以前必须用 JavaScript 解决的交互问题,现在只用 CSS 就能轻松搞定。

二、告别复杂的焦点管理

我们来看一个实际的例子。假设我们正在做一个任务看板,上面有很多卡片,每个卡片上都有“打开”和“删除”两个按钮。

为了让键盘用户也能方便地操作,我们希望当用户通过 Tab 键选中某个按钮时,这张卡片能“弹出来”一点,并且根据选中的是“删除”还是“打开”按钮,显示不同的颜色边框。同时,其他未被选中的卡片会变灰,突出显示当前卡片。

如果用 React 来实现,思路大概是这样的:

  1. 我们需要在父组件中维护一个 state,记录当前哪个卡片的哪个按钮被选中了。
  2. 通过 onFocusonBlur 事件来更新这个 state
  3. state 改变时,父组件会重新渲染,给所有卡片传递新的 props,告诉它们应该显示什么样式。

可以想象,为了这么一个效果,代码会变得很复杂,而且每次按 Tab 键,都可能导致所有卡片重新渲染,造成性能问题。这也许就是为什么,我们很少在实际产品中看到这么精细的交互效果。

但是有了 :has 选择器,一切都变得简单了。

首先,我们给按钮加上 data- 属性,方便选中:

<button data-action="open">打开</button>
<button data-action="delete">删除</button>

然后,我们用 :has 来改变包含“已选中”按钮的卡片的样式:

/* 选中“删除”按钮时,卡片边框变红 */
.card:has([data-action='delete']:focus-visible) {
  transform: scale(1.02);
  border-top: 5px solid #f7bccb;
}

/* 选中“打开”按钮时,卡片边框变绿 */
.card:has([data-action='open']:focus-visible) {
  transform: scale(1.02);
  border-top: 5px solid #c3dccf;
}

:focus-visible 是一个伪类,它只在用户通过键盘(比如 Tab 键)获得焦点时才生效,鼠标点击则不会,非常适合做无障碍优化。

接下来,是最神奇的部分,如何让其他卡片变灰?我们同样可以用 :has 来选中“不包含”已选中按钮的卡片。

/* 当任意一个卡片被选中时,让其他所有卡片变灰 */
.cards-container:has(.card:focus-within) .card:not(:focus-within) {
    filter: grayscale(80%);
    opacity: 0.8;
}

就这样,我们没有写一行 JavaScript,就实现了一个非常优雅且高性能的键盘交互效果。代码不仅更简单,而且因为是浏览器原生支持的 CSS,性能也比 React 的方案好得多。

三、更智能的表单

:has 选择器在表单中的应用也非常广泛。

比如,我们希望在一个输入框被禁用(disabled)时,它旁边的标签(label)和描述文字也一起变灰。在以前,这需要 JavaScript 监听状态变化,然后手动给标签添加或移除一个类名。

现在,我们可以这样做:

/* 如果 fieldset 内部有一个被禁用的 input */
fieldset:has(input:disabled) label,
fieldset:has(input:disabled) .description {
  color: #d6d6d6;
}

同样,当一个列表项里的复选框被选中(:checked)时,我们可以轻松地改变整行的背景颜色:

li:has(input:checked) {
  background: #e8f0fe;
}

这些在过去需要用 JavaScript 脚本和状态管理才能实现的效果,现在都变成了纯粹的 CSS 声明。代码的可读性和可维护性都大大提高了。

四、一些思考

:has 选择器的出现,让我重新思考了前端开发中 CSS 和 JavaScript 的边界。

我们似乎已经习惯了用 JavaScript 来处理一切与“状态”和“交互”相关的事情。但实际上,很多所谓的“状态”,只是 DOM 元素自身的状态(比如 :focus, :checked, :disabled),它们完全可以在 CSS 内部被消化掉。

过度依赖 JavaScript,不仅让我们的代码变得更复杂,也可能带来不必要的性能开销。有时候,返璞归真,用最简单、最直接的方式去解决问题,反而效果更好。

当然,这并不是说 CSS 可以完全取代 React 或其他框架。React 在管理复杂应用状态、组件化开发等方面依然有巨大优势。但是,对于那些纯粹的、局部的 UI 交互,我们或许可以更多地求助于现代 CSS 的能力。

下一次,当你准备写一个 useState 来控制某个元素的样式时,不妨先停下来想一想:这个问题,能不能只用 CSS 来解决?

JavaScript 继承的进阶之路:从原型链到圣杯模式的架构思考

在面向对象编程的设计哲学中,继承的本质是为了解决两个核心问题:数据的独立性与行为的共享性。对于 JavaScript 这种基于原型的动态语言而言,实现继承的过程,实际上就是不断在“构造函数”与“原型链”之间寻找平衡点的过程。

本文将基于底层原理,剖析从基础的构造函数借用到成熟的圣杯模式(寄生组合式继承)的演进逻辑,揭示其背后的架构思考。

一、 引言:属性与方法的二元对立

JavaScript 的对象包含属性(State)和方法(Behavior)。在继承关系中,这二者有着截然不同的需求:

  1. 属性需要私有化:子类实例必须拥有独立的属性副本。例如,每一只 Cat 都应该有自己独立的 name 和 color,修改一只猫的名字不应影响另一只。
  2. 方法需要复用:父类的方法(如 species 属性或公共函数)应当存在于内存的某一处,供所有子类实例引用,而非在每个实例中重复创建。

为了解决这一矛盾,JavaScript 引入了 call/apply 来处理属性拷贝,利用 prototype 来处理方法复用。

二、 构造函数的借用:属性的物理拷贝

在最早期的继承尝试中,我们首先解决的是属性继承的问题。通过在子类构造函数中强行执行父类构造函数,我们可以“窃取”父类的属性初始化逻辑。

JavaScript

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = '动物';

function Cat(name, age, color) {
    // 核心逻辑:构造函数借用
    // 将 Animal 的 this 指向当前的 Cat 实例
    Animal.apply(this, [name, age]); 
    this.color = color;
    console.log(this, '////');
}

架构分析

Animal.apply(this, [name, age]) 的底层逻辑在于,它将 Animal 当作一个普通函数执行,并将执行上下文(Context)强制绑定到当前正在创建的 Cat 实例上。这实际上是一次物理拷贝——父类中定义的 this.name 和 this.age 被直接赋值到了子类实例上。

致命缺陷

这种模式仅解决了“属性私有化”,却完全丢失了“行为复用”。
由于 Cat 的原型链并未指向 Animal 的原型,因此定义在 Animal.prototype 上的 species 属性和任何共有方法,对于 Cat 实例来说都是不可见的。

image.png

三、 原型链的连接:简单粗暴的代价

为了让子类能访问父类原型上的方法,最直观的做法是将子类的原型对象指向父类的一个实例。这也是早期很多教程中的标准写法:

JavaScript

// 组合继承的雏形
Cat.prototype = new Animal(); 
Cat.prototype.constructor = Cat;

架构思考与缺陷

这行代码虽然打通了原型链(cat.proto 指向了 Animal 实例,而该实例的 proto 指向 Animal.prototype),但它引入了严重的副作用,这种副作用在大型应用中是不可接受的:

  1. 父类构造函数执行了两次

    • 第一次:new Animal() 赋值给原型时。
    • 第二次:Cat 实例化时内部调用的 Animal.apply。
    • 如果 Animal 初始化逻辑中包含昂贵的操作(如 DOM 绑定、大量计算),这种双重开销是极大的浪费。
  2. 属性冗余与内存污染

    • Cat.prototype 是 Animal 的一个实例,因此它不可避免地拥有了 name 和 age 属性(虽然是 undefined)。
    • 同时,Cat 实例本身通过 apply 也拥有了 name 和 age。
    • 实例属性遮蔽了原型上的同名属性,原型上的这些属性不仅毫无意义,还占用了内存空间。

四、 完美的中间层:圣杯模式(寄生组合式继承)

如何既能继承 Animal.prototype,又不执行 Animal 构造函数从而避免副作用?
答案是引入一个纯净的中间层。这就是所谓的“圣杯模式”或“寄生组合式继承”。

JavaScript

function extend(Child, Parent) {
    // 1. 创建中介函数 F
    var F = function() {}; 
    
    // 2. 将中介的原型指向父类原型
    F.prototype = Parent.prototype;
    
    // 3. 子类原型指向中介的实例
    Child.prototype = new F(); 
    
    // 4. 修正构造函数指针
    Child.prototype.constructor = Child;
    
    // 5. 可选:保存父类原型的引用(Uber/Super)
    Child.prototype.uber = Parent.prototype;
}

核心解构:为何引入空对象 F?

F 在这里充当了一个缓冲带(Buffer)代理(Proxy)的角色。

  1. 性能无损:F 是一个空函数,执行 new F() 几乎不消耗任何 CPU 资源,也不会产生任何多余的实例属性(内存纯净)。
  2. 链条维持:new F() 产生的对象,其 proto 依然指向 F.prototype(即 Parent.prototype)。因此,原型链依然是通畅的:
    Cat实例 -> F实例(空) -> Animal.prototype -> Object.prototype
  3. 隔离副作用:我们成功绕过了 new Animal(),从而避免了父类构造函数的执行。

关于 Constructor 的修正

重写 Child.prototype 会导致 constructor 属性丢失(或指向 Parent)。虽然这对 JS 引擎的运行影响不大,但为了保持原型链的完整性和可追溯性,手动修正 Child.prototype.constructor = Child 是架构设计中的必要规范。

image.png

五、 封装与现代视角

将上述逻辑封装后,我们得到了一个通用的继承辅助函数。在现代 JavaScript 开发中,这一模式极其重要。

JavaScript

function extend(Parent, Child) {
  var F = function() {}; 
  F.prototype = Parent.prototype;
  Child.prototype = new F(); 
  Child.prototype.constructor = Child;
}

extend(Animal, Cat);
const cat = new Cat('加菲猫', 2, '橘色');

ES6 Class 的本质

ES6 引入的 class extends 语法,本质上就是上述“圣杯模式”的语法糖。
在 ES5 中我们手动创建的 F 实例,在规范层面对应了 Object.create(Parent.prototype)。

JavaScript

// 现代写法的等价逻辑
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Object.create 内部的 Polyfill 实现,正是利用了临时的空构造函数来创建一个新对象并关联原型,这与我们手动编写的 F 异曲同工。

六、 总结

JavaScript 的继承机制并非简单的“复制粘贴”,而是一场关于内存管理与引用关系的博弈。

从直接修改原型链导致的副作用,到引入空对象 F 作为隔离层,圣杯模式的核心价值在于:它在保持原型链引用(实现方法复用)的同时,彻底切断了与父类构造函数实体的直接耦合(实现状态解耦与性能优化)。

理解这一模式,不仅能让你掌握 JavaScript 继承的终极方案,更能深刻理解动态语言中“原型”这一概念的灵活性与本质。

函数为何能“统治”JavaScript:一等公民与高阶函数的工程化用法

引言:为什么团队里“会用函数”和“用好函数”差距这么大

在业务迭代快、多人协作密集的前端项目里,你一定见过两类代码:

  • 一类是“能跑就行”的循环 + if + push:逻辑散在各处,重复多、难复用、改动风险大。
  • 另一类是“把规则抽成函数”的写法:筛选、映射、查找、聚合清晰可读,功能像积木一样组合。

差距的根源,不是你记不记得 API,而是你是否真正把函数当成语言的核心抽象单位来使用:能传、能返回、能组合,进而让代码更模块化、更可维护、更适合协作。

这篇文章会用五个最常用的数组高阶函数(filter/map/forEach/find/reduce)串起一条清晰主线:函数为什么是一等公民 → 高阶函数如何替代手写循环 → 工程上怎么选、怎么读文档、怎么避免坑 → 如何在团队落地


一、函数为什么是一等公民

1.1 什么是“一等公民”

在编程语言里,“一等公民(First-class Citizen)”不是“更高级”,而是地位与能力等同于语言里的其他基础类型(数字、字符串、布尔等)。换句话说:它能像普通数据一样被存储、传递、返回、赋值。

更工程化的判定方式是看它是否具备这些能力:

  1. 可以被存储在数据结构中(数组、对象、Map…)
  2. 可以作为参数传递给另一个函数
  3. 可以作为返回值返回
  4. 可以赋值给变量/常量/属性

满足这些特性,就称之为“一等公民”。在 JavaScript 里,函数是典型代表。


1.2 为什么说它重要

把函数当一等公民,会直接带来两类能力:

  • 抽象能力:把“变化的部分”抽成函数,把“稳定的框架”固化下来。你会写出更少重复、更易扩展的代码。
  • 组合能力:高阶函数与闭包等特性,使函数天然适合模块化与函数式编程;在现代框架(例如组件化、Hooks、状态管理)里,这种能力几乎无处不在。

一句话总结:团队协作里最怕“写死流程”,最需要“抽离规则”。函数一等公民就是这套抽离机制的基础。


1.3 用代码把抽象变具体

1.3.1 参数传递 + 返回函数 + 赋值:三件事连起来

下面的例子同时覆盖:函数可以作为返回值、可以赋值给标识符、也展示了函数内嵌定义的灵活性(很多语言并不允许这样自由嵌套)。

// 作为另一个函数的参数,JS 语法允许函数内部再定义函数
function foo() {
  function bar() {
    console.log("小吴的bar");
  }
  // 返回值:返回的是函数本身(引用),不是执行结果
  return bar;
}

// 赋值给其他标识符
var fn = foo();
fn();

// 小吴的bar

这里有个非常“工程化”的隐喻:

  • foo() 返回 bar 的引用,就像 new Class() 返回实例对象。
    你拿到的是一个“可调用/可使用的实体”,而不是一次性的结果。

1.3.2 作为参数传递:把“行为”塞进另一个函数

直接把函数传入另一个函数,在业务里并不少见(例如事件回调、拦截器、策略模式)。

// 也可以作为另外一个函数的返回值来使用
function foo(aaaa) {
  console.log(aaaa);
}

foo(123);

// 也可以参数传递
function bar(bbbb) {
  return bbbb + "刚吃完午饭";
}

// 将函数作为参数传达进去调用(这里传的是 bar 的执行结果)
foo(bar("小吴"));
// 123
// 小吴刚吃完午饭

注意区分两种传法:

  • 传“函数本身”:foo(bar)(把行为交给 foo 决定何时执行)
  • 传“函数执行结果”:foo(bar("小吴"))(先执行 bar,再把结果交给 foo)

很多 Bug 就出在把这两者弄混。


1.3.3 封装小案例:把“算法框架”固定,把“策略”注入

这是最值得在团队内推广的写法之一:把“可变的计算逻辑”抽成参数传入。

// 封装小案例:calc 固定流程,calcFn 注入策略
function calc(num1, num2, calcFn) {
  console.log(calcFn(num1, num2));
}

function add(num1, num2) {
  return num1 + num2;
}

function sub(num1, num2) {
  return num1 - num2;
}

function mul(num1, num2) {
  return num1 * num2;
}

calc(10, 10, add);
calc(10, 10, sub);
calc(10, 10, mul);
// 20
// 0
// 100

这类写法在工程里有很多“马甲”:

  • 表单校验:把规则函数注入校验器
  • 权限控制:把策略函数注入拦截器
  • 数据转换:把转换函数注入 pipeline
  • UI 渲染:把 render 函数/回调注入组件

核心思想:把“变化”关在函数里,把“框架”稳定下来。


本章小结(一)

  • 函数是一等公民,关键不在“厉害”,而在能像数据一样被传递与组合
  • 工程化抽象的第一步:固定流程(框架)+ 注入策略(函数)
  • 传参时要区分:传“函数引用” vs 传“函数执行结果”,这是常见坑。
  • 这套能力是高阶函数、闭包、以及现代框架设计(Hooks/中间件/拦截器)的基础。

二、高阶函数:把“步骤”交给框架,把“规则”留给你

2.1 什么是高阶函数

在 JavaScript 里,高阶函数指至少满足以下一个条件的函数:

  1. 接收一个或多个函数作为参数
  2. 返回一个函数

你刚才看到的 calc(num1, num2, calcFn),已经是高阶函数:它接收 calcFn 作为参数。


2.2 同一个需求的三种写法:挑选偶数

2.2.1 普通方式:手写四步走

思路很直白,但通用逻辑(遍历、收集)全部你自己写,容易重复与出错。

// 普通使用
var nums = [2, 4, 5, 8, 12, 45, 23];

var newNums = [];
for (var i = 0; i < nums.length; i++) {
  var num = nums[i];
  if (num % 2 === 0) {
    newNums.push(num);
  }
}
console.log(newNums);
// [ 2, 4, 8, 12 ]

2.2.2 filter:你只写“规则”,遍历与收集由它完成

filter 的含义非常明确:过滤。回调返回 true 表示保留,false 表示丢弃。

  • 语法:array.filter(callback(item[, index[, array]])[, thisArg])
  • 回调参数(常用前两个):item, index
  • 返回:新数组(不会修改原数组)
// filter:对数组进行过滤,返回新数组
var nums = [2, 4, 5, 8, 12, 45, 23];

// 方式1:明显的函数特征(匿名函数)
var evenNumbers = nums.filter(function (number) {
  return number % 2 === 0;
});

// 方式2:箭头函数(保留完整参数,便于理解)
var newNums = nums.filter((item, index, array) => {
  return item % 2 === 0;
});

console.log(newNums);
// [ 2, 4, 8, 12 ]

当回调只有一个参数、且函数体只有一行表达式时,可以进一步精简:

var nums = [1, 2, 3, 4, 5, 6];
// 方式3:省略小括号 + 省略大括号与 return
var newNums = nums.filter((n) => n % 2 === 0);
console.log(newNums); // 输出: [2, 4, 6]

这里有个很关键的“可读性权衡”:

  • 精简写法适合表达式很短且语义直观的场景
  • 一旦逻辑复杂(多分支、多行),就别硬省略 {},可读性优先

2.2.3 map:把每个元素“映射”为新值

map映射:输入一个数组,输出一个等长的新数组。它关心的是“把元素变成什么”。

// map:映射
var nums = [1, 2, 3, 4, 5, 6];
var newNums2 = nums.map((i) => (i % 2 === 0 ? "偶数是女生" : "基数是男生"));
console.log(newNums2);
// [ '基数是男生', '偶数是女生', '基数是男生', '偶数是女生', '基数是男生', '偶数是女生' ]

工程上最常见的用途:

  • 接口数据 → UI 需要的结构(字段重命名、格式化、补默认值)
  • 列表渲染前的展示层转换(但注意别在 render 中做重计算)

2.2.4 forEach:只遍历、不返回,用于副作用

forEach 更像“命令式遍历”:做日志、打点、埋点、累计写入外部变量等。

// forEach:迭代,没有返回值,通常就用来打印一些东西
var nums = [2, 4, 5, 8, 12, 45, 23];
nums.forEach((i) => console.log(i));
// 2
// 4
// 5
// 8
// 12
// 45
// 23

一个常见误区:

  • 想用 forEach 生成新数组 —— 不对,它没有返回值
    要生成新数组,优先考虑 map/filter/reduce

2.2.5 find:找“第一个满足条件”的元素

find 的语义是查找:返回第一个满足条件的元素;找不到是 undefined

// find:查找,有返回值
var nums = [2, 4, 5, 8, "小吴", 12, 45, 23];

// 找到内容则返回内容
var item = nums.find((i) => i === "小吴");
console.log(item);
// 小吴

// 找不到则返回 undefined
var item2 = nums.find((i) => i === "coderwhy");
console.log(item2);
// undefined

在对象数组中,find/findIndex 非常好用:

// 模拟数据
var friend = [
  { name: "小吴", age: 18 },
  { name: "coderwhy", age: 35 },
];

const findFriend = friend.find((i) => i.name === "小吴");
console.log(findFriend);
// { name: '小吴', age: 18 }

// findIndex:找到对象在数组中的索引
const findFriend2 = friend.findIndex((i) => i.name === "小吴");
console.log(findFriend2);
// 0

2.2.6 reduce:把一组元素“聚合”为一个结果

reduce聚合/归约:把数组变成一个值(数字、对象、Map、甚至另一个数组都可以)。

先看普通实现:

// 普通实现方式
var nums = [2, 4, 5, 8, 12, 45, 23];
var total = 0;
for (var i = 0; i < nums.length; i++) {
  total += nums[i];
}
console.log(total);
// 99

reduce

// reduce:对数组进行累加或统计等操作
var nums = [2, 4, 5, 8, 12, 45, 23];

// preValue:上一次累加结果;item:当前值;0 是初始值
var num = nums.reduce((preValue, item) => preValue + item, 0);
console.log(num);
// 99

工程上 reduce 的典型用途:

  • 列表求和/计数/求最大最小
  • 把数组转成对象:按 key 分组、构建索引表(id -> item
  • 复杂 pipeline 的聚合处理(但要注意可读性,别写成“谜语”)

2.4 Function 与 Method:名字不同,边界要清楚

这点在 code review 里经常要统一口径:

  • 函数(Function) :独立存在的函数
  • 方法(Method) :当一个函数挂在对象上,作为对象的成员时,称为方法
obj = {
  foo: function () {},
};

// 这个 foo 就是一个属于 obj 对象的方法
obj.foo();

// 函数调用(不属于某个对象)
foo();

为什么数组高阶函数常被称为“方法”?因为它们挂在 Array.prototype 上,通过 arr.xxx() 调用。


本章小结(二)

  • 高阶函数的价值:你只写规则(回调),通用步骤(遍历/收集/聚合)交给 API
  • filter:筛选;map:映射;forEach:副作用遍历;find:找第一个;reduce:聚合成一个结果。
  • 精简写法要守住底线:逻辑复杂就别省略 {},可读性优先。
  • forEach 不返回新数组,生成新数组请用 map/filter/reduce

三、怎么系统学习与记忆高阶函数

3.1 别背 API:按“数据流”建立心智模型

高阶函数看起来很多,但本质都绕不开“对数据做事”。更好用的记忆方式是把它们按数据流形态归类:

  • 筛选型filter(输入 N 个,输出 ≤N 个)
  • 变换型map(输入 N 个,输出 N 个)
  • 查找型find/findIndex(输入 N 个,输出 1 个或索引)
  • 遍历副作用forEach(输入 N 个,输出无)
  • 聚合型reduce(输入 N 个,输出 1 个“聚合结果”)

再进一步,你可以用“增删改查”的视角去理解:

  • 查:find
  • 删(过滤掉):filter
  • 改(映射成新结构):map
  • 聚合(统计/汇总):reduce

这样你遇到新 API(例如 some/every/flatMap)也能快速定位它属于哪一类,应该在什么场景用。


3.2 高阶函数的好处与代价

好处(工程收益)

  • 少写重复代码:遍历/收集/累计这些“通用步骤”被封装起来了
  • 降低冲突风险:中间变量更少,作用域更小,命名冲突概率降低
  • 更强的可组合性:回调就是“规则模块”,能复用、能拼装

代价(需要主动管理)

  • 初学时不直观:你看不到循环过程,容易“脑补出错”
  • 过度链式会变难读:arr.filter(...).map(...).reduce(...) 一长串要谨慎
  • 性能不是绝对优势:链式会产生中间数组;超大数据量时要考虑优化策略(合并步骤、或用单次 reduce)

工程上建议的取舍:

  • 优先可读性与正确性,其次才是微优化
  • 当性能成为问题,用数据与指标说话(见结尾“下一步建议”)

本章小结(三)

  • 记忆高阶函数别靠背,靠“数据流分类”:筛选/变换/查找/副作用/聚合。
  • 高阶函数让“规则”模块化,减少重复与冲突,更适合团队协作。
  • 链式调用要节制:可读性是第一生产力;性能优化要用指标驱动。

四、读懂文档里的语法:方括号与逗号到底在表达什么

很多同学看文档时会被这种写法劝退:

array.find(callback(element[, index[, array]])[, thisArg])

其实核心就两点:方括号 []逗号 ,


4.1 单层方括号与嵌套方括号

单层方括号 []

表示可选参数:可以传,也可以不传。

比如 [index] 表示 index 可选。

嵌套方括号 [[]]

表示可选参数的依赖关系:外层出现了,内层才可能出现。

比如 element[, index[, array]] 的含义是:

  • element 必须有
  • 如果你要传 index,必须先有 element
  • 如果你要传 array,必须先有 index(以及 element)

4.2 逗号在方括号里:前置条件的表达

当你看到 [, thisArg] 这种形式,逗号出现在方括号里,意思是:

  • thisArg 是可选的
  • 但它依赖前一个参数存在(也就是必须先传 callback,才可能有 thisArg)

换句话说:逗号前面是前置条件,逗号后面才轮得到


4.3 拆解示例:以 find 为例

我们把它拆成两段看:

array.find(callback(element[, index[, array]])[, thisArg])

  1. callback(...):必传(因为外层是小括号)
  2. [, thisArg]:可选(方括号),但依赖 callback 存在(逗号表达前置条件)

然后回调内部也同理:

  • element 必传
  • index 可选,但依赖 element
  • array 可选,但依赖 index(以及 element)

当你形成这种拆解习惯,看任何 API 都会很稳。

** 图6-1 API语法参数分类**

这张图可以当作团队内的“文档阅读速查表”:看到 [] 先判断可选,看到嵌套 [] 再判断依赖关系。


本章小结(四)

  • [] 表示可选;嵌套 [] 表示“可选但有依赖链”。
  • [, x] 的逗号表达:x 的出现以“前一个参数存在”为前提。
  • 拆 API 时优先拆外层,再拆回调参数,顺序能让理解稳定下来。
  • 熟悉这种语法后,看 MDN / IDE 提示都会更快、更不容易误解。

五、复盘与下一步:如何在团队里落地

5.1 关键结论复盘

  • 函数是一等公民:能像数据一样被传递与返回,让“抽象与复用”成为可能。
  • 高阶函数的核心价值:把通用步骤封装,把可变规则显式化
  • 五个高频函数的工程语义要记住:
    filter(筛)/ map(变)/ find(找)/ forEach(做副作用)/ reduce(聚合)
  • 读文档的关键:[](可选)+ 嵌套(依赖)+ 逗号(前置条件)。

5.2 团队落地建议(可直接执行)

建议 1:统一“选择指南”,减少风格争论

  • 生成新数组:优先 map/filter
  • 查第一个:用 find,查索引用 findIndex
  • 聚合统计:用 reduce(必要时拆解变量提升可读性)
  • 副作用(日志/埋点/外部写入):用 forEach

建议 2:在 Code Review 里抓两个点

  • 是否把“变化逻辑”抽成回调/策略函数(减少重复)
  • 链式调用是否过长导致可读性下降(超过 2~3 段就考虑拆开)

建议 3:用指标验证“可维护性”收益

  • 重复代码段数量(循环模板是否减少)
  • 单测覆盖的可测单元数量(策略函数更易测)
  • 代码变更影响范围(抽象后改动是否更局部)

5.3 下一步建议:把能力延伸到闭包

高阶函数带来的“灵活”往往伴随着“难度”的上升,而最关键的那块难度通常来自闭包:

  • 函数返回函数时,外层变量如何被捕获?
  • 为什么闭包可能造成内存驻留?
  • 什么时候它是利器,什么时候是隐患?

把闭包掌握住,你对函数的理解会从“会用 API”跃迁到“会设计抽象”。


Vue-Vue2中的Mixin 混入机制

前言

在开发中,当多个组件拥有相同的逻辑(如分页、导出、权限校验)时,重复编写代码显然不够优雅。Vue 2 提供的 Mixin(混入)正是为了解决逻辑复用而生。本文将从基础用法出发,带你彻底理清 Mixin 的执行机制及其优缺点。

一、 什么是 Mixin?

Mixin 是一种灵活的分发 Vue 组件中可复用功能的方式。它本质上是一个 JS 对象,它将组件的可复用逻辑或者数据提取出来,哪个组件需要用到时,直接将提取的这部分混入到组件内部就行。类似于react和vue3中hooks。


二、 Mixin 的实战用法

1. 定义混入文件

我们通常新建一个文件(如 useUser.ts),文件中包含data、methods、created等属性(和vue文件中script部分一致),导出这个逻辑对象。

// src/mixins/index.ts
export const myMixin = {
  data() {
    return {
      msg: "我是来自 Mixin 的数据",
    };
  },
  created() {
    console.log("执行:Mixin 中的 created 生命周期");
  },
  mounted() {
    console.log("执行:Mixin 中的 mounted 生命周期");
  },
  methods: {
    clickMe(): void {
      console.log("执行:Mixin 中的点击事件");
    },
  },
};

2. 组件内引入(局部混入)

在 Vue 2 的选项式语法中通过 mixins 属性引入。

<script lang="ts">
import { defineComponent } from 'vue';
import { myMixin } from "./mixin/index";

export default defineComponent({
  name: "App",
  mixins: [myMixin], // 注入混入逻辑
  created() {
    // 此时可以正常访问 mixin 中的 msg
    console.log("组件访问 Mixin 数据:", this.msg);
  },
  mounted() {
    console.log("执行:组件自身的 mounted 生命周期");
  }
});
</script>

三、 Mixin 的关键特性与优先级

在使用 Mixin 时,必须清楚其底层合并策略:

  1. 独立性:在多个组件中引入同一个 Mixin,各组件间的数据是不共享的。一个组件改动了 Mixin 里的数据,不会影响到其他组件。

  2. 生命周期合并

    • Mixin 的钩子会与组件自身的钩子合并。
    • 执行顺序:Mixin 的钩子总是先于组件钩子执行。
  3. 冲突处理

    • 如果 Mixin 与组件定义了同名的 data 属性或 methods 方法,组件自身的内容会覆盖 Mixin 的内容
  4. 全局混入

    • main.js 中通过 Vue.mixin() 引入。这会影响之后创建的所有 Vue 实例(不推荐,容易污染全局环境)。

四、 进阶思考:Mixin 的局限性

虽然 Mixin 解决了复用问题,但在大型项目中存在明显的弊端,这也是为什么 Vue 3 转向了 Composition API (Hooks)

  • 命名冲突:多个 Mixin 混入时,容易发生变量名冲突,且难以追溯。
  • 来源不明:在模板中使用一个变量,很难一眼看出它是来自哪个 Mixin,增加了维护成本。
  • 隐式依赖:Mixin 之间无法方便地相互传参或共享状态。

五、 Vue 3 的更优选:组合式函数 (Hooks)

如果你正在使用 Vue 3,建议使用更现代的语法来复用逻辑:

// src/composables/useCount.ts
import { ref, onMounted } from 'vue'

export function useCount() {
  const count = ref<number>(0)
  const msg = ref<string>("我是 Vue 3 Hook 数据")

  const increment = () => count.value++

  onMounted(() => {
    console.log("Hook 中的 mounted")
  })

  return { count, msg, increment }
}

Vue-插槽 (Slot) 的多种高级玩法

前言

在组件化开发中,插槽 (Slot) 是实现内容分发(Content Distribution)的核心机制。它允许我们将组件的“外壳”与“内容”解耦,让组件具备极高的扩展性。

一、 什么是插槽?

插槽是子组件提供给父组件的 “占位符” ,用 <slot></slot> 标签表示。父组件传递的任何模板代码(HTML、组件等)都会替换子组件中的 <slot> 标签。


二、 插槽的三大类型

1. 默认插槽 (Default Slot)

最基础的插槽,不需要定义 name 属性。

  • 特点:一个子组件通常只建议使用一个默认插槽。

示例:

 <!-- 子组件 -->
  <template>
    <div class="card">
      <div class="card-title">通用卡片标题</div>
      <div class="card-content">
        <slot> 这里是默认的填充文本 </slot>
      </div>
    </div>
  </template>
 <!-- 父组件 -->
  <template>
    <div class="app">
      <MyCard> 这是我传递给卡片的具体内容。 </MyCard>
    </div>
  </template>

2. 具名插槽 (Named Slots)

当子组件需要多个占位符时,通过 name 属性来区分。

  • 语法糖v-slot:header 可以简写为 #header

示例:

 <!-- 子组件:LayoutComponent.vue -->
<template>
  <div class="layout">
    <header class="header">
      <slot name="header"></slot>
    </header>
    
    <main class="content">
      <slot></slot> 
    </main>
    
    <footer class="footer">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

<script setup lang="ts">
 <!-- Vue 3 Composition API 模式下,逻辑部分可以保持简洁 -->
</script>
 <!-- 父组件使用示例 -->
<template>
  <LayoutComponent>
    <template #header>
      <h1>页面标题</h1>
      <nav>导航菜单</nav>
    </template>
    
    <p>这是主体内容,将填充到默认插槽中...</p>
    
    <template #footer>
      <p>版权信息 &copy; 2026</p>
    </template>
  </LayoutComponent>
</template>

<script setup lang="ts">
import LayoutComponent from './LayoutComponent.vue';
</script>

3. 作用域插槽 (Scoped Slots)

核心价值“子传父” 的特殊形式。子组件将内部数据绑定在 <slot> 上,父组件在填充内容时可以接收并使用这些数据。

示例:

 <!-- 子组件:`UserList.vue` -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot :user="user" :index="user.id">
        {{ user.name }}
      </slot>
    </li>
  </ul>
</template>

<script setup lang="ts">
interface User {
  id: number;
  name: string;
  role: string;
}

const users: User[] = [
  { id: 1, name: '张三', role: '管理员' },
  { id: 2, name: '李四', role: '开发者' }
];
</script>
 <!-- 父组件使用示例 -->
<template>
  <UserList>
    <template #default="{ user }">
      <span :style="{ color: user.role === '管理员' ? 'red' : 'blue' }">
        {{ user.name }} - 【{{ user.role }}】
      </span>
    </template>
  </UserList>
</template>

三、 补充:插槽的默认内容

在子组件中,你可以在 <slot> 标签内部放置内容。如果父组件没有提供任何插槽内容,则会渲染这些“后备内容”;如果提供了,则会被覆盖。

<slot>这是如果没有内容时显示的默认文本</slot>

四、 总结:如何选择插槽?

插槽类型 使用场景
默认插槽 组件只有一个扩展点时使用。
具名插槽 组件有多个固定区域(如 Header/Main/Footer)需要自定义时使用。
作用域插槽 需要根据子组件的内部数据来决定父组件渲染样式的场景(如列表展示)。

Vue-Key唯一标识作用

前言

在开发 Vue 列表渲染时,编辑器总是提醒我们“必须绑定 key”。很多人习惯性地填入 index。但你是否思考过:key 到底在底层起到了什么作用?为什么不合理的 key 会导致组件状态错乱甚至性能崩溃?

一、 :key 的核心作用:虚拟 DOM 的“导航仪”

在 Vue 更新 DOM 时,其核心算法是 Diff 算法key 的主要作用是更高效地更新虚拟 DOM

1. 节点复用的关键

Vue 会通过判断两个节点是否为“相同节点”,从而决定是销毁重建还是原地复用。 判断相同节点的必要条件包括:

  • 元素类型Key 值 :Vue判断两个节点是否相同时,主要判断两者的key和元素类型是否相等,因此如果不设置key且元素类型相同的话,它的值就是undefined(而undefined恒等于undefined),则vue可能永远认为这是两个相同节点,只能去做更新操作,从而尝试“原地复用”它们。

提示:虚拟Dom与diff算法会在后续单独讲解


二、 为什么要绑定 Key?

1. 不带 key(原地复用策略)

当列表顺序被打乱时,Vue 不会移动 DOM 元素来匹配列表项的顺序,而是就地更新每个元素。

  • 弊端:如果列表项包含有状态的子组件或受控输入框(如 <input>),原本属于 A 项的输入框内容会“残留”在 B 项的位置上,造成 UI 错乱。
  • 性能:导致频繁的属性更新和 DOM 操作,效率低下。

2. 带有 key(精准匹配策略)

有了 key 作为唯一标识,Vue 能根据 key 精准找到旧节点树中对应的节点。

  • 优势:Vue 会移动元素而非重新渲染,极大减少了不必要的 DOM 操作,显著提升性能。

三、为什么不推荐使用 Index 作为 Key?

这使用 index 在进行增删、排序操作时,如果在列表头部添加一个新子项时,原列表所有的子项index都会+1,这会让vue认为列表全改变了,需要全部重新生成,从而造成性能损耗。

示例:

<script setup lang="ts">
import { ref } from 'vue'

interface User {
  id: number;
  name: string;
}

const users = ref<User[]>([
  { id: 1, name: '张三' },
  { id: 2, name: '李四' }
])

const insertUser = () => {
  // 在头部插入一条数据
  users.value.unshift({ id: Date.now(), name: '新同学' })
}
</script>

<template>
  <div>
    <button @click="insertUser">头部插入数据</button>
    <ul>
      <li v-for="(item, index) in users" :key="index">
        {{ item.name }} <input type="text" placeholder="输入评价" />
      </li>
      
      <hr />

      <li v-for="item in users" :key="item.id">
        {{ item.name }} <input type="text" placeholder="输入评价" />
      </li>
    </ul>
  </div>
</template>

四、 总结

  1. 唯一性key 必须在当前循环层级中是唯一的,不能重复。
  2. 稳定性:不要使用 Math.random() 作为 key,否则每次渲染都会强制销毁重建所有节点,性能极其低效。
  3. undefined 陷阱:如果不设置 key,它的值就是 undefined。在 Diff 对比时,Vue 会认为两个 undefined 节点是“相同”的,这正是导致频繁更新、影响性能的根源。

Vue-Computed 与 Watch 深度解读与选型指南

前言

在 Vue 的响应式世界里,computed(计算属性)和 watch(侦听器)是我们处理数据联动最常用的两把利器。虽然它们都能响应数据变化,但背后的设计哲学和应用场景却大相径庭。本文将结合 Vue 3 组合式 API 与 TypeScript,带你理清两者的本质区别。

一、 Computed:智能的“数据加工厂”

computed 的核心在它是一个计算属性。它会根据所依赖的数据动态计算结果,并具备强大的缓存机制。

1. 核心特性

  • 具备缓存性:只有当它依赖的响应式数据发生变化时,才会重新计算。否则,无论多少次访问该属性,都会立即返回上次缓存的结果。
  • 必须有返回值:它必须通过 return 返回计算后的结果。
  • 惰性求值:只有在被读取时才会执行计算。

2. Vue 3 + TS 示例

<script setup lang="ts">
import { ref, computed } from 'vue';

const count = ref<number>(1);

// computedValue1 为计算出的新属性
const computedValue1 = computed<number>(() => {
  console.log('正在执行计算...'); // 只有 count 改变时才会打印
  return count.value + 1;
});
</script>

<template>
  <div>原值: {{ count }} | 计算值: {{ computedValue1 }}</div>
  <button @click="count++">增加</button>
</template>

二、 Watch:敏锐的“数据监控员”

watch 的核心在于响应副作用。当监听的值发生改变时执行特定的回调函数。

1. 核心特性

  • 无缓存性:它不是为了产生新值,而是为了在值变化时执行逻辑。

  • 无返回值:回调函数中通常处理的是异步操作、修改 DOM 或更改其他状态。

  • 配置灵活

    • immediate:设置为 true 时,在初始化时立即执行一次。
    • deep:设置为 true 时,可以深度监听对象内部属性的变化。

2. Vue 3 + TS 示例

<script setup lang="ts">
import { ref, watch } from 'vue';

interface UserInfo {
  name: string;
  age: number;
}

const user = ref<UserInfo>({ name: '张三', age: 25 });

// 监听对象深度变化
watch(
  user,
  (newVal, oldVal) => {
    // 注意:由于是引用类型,newVal 和 oldVal 指向的是同一个对象,只有开启deep: true才能监听到
    console.log('用户信息变了', newVal.age);
  },
  { 
    deep: true,      // 开启深度监听
    immediate: false // 初始化时不立即执行
  }
);
</script>

三、 扩展:Vue 3 中的 WatchEffect

在 Vue 3 中,除了 watch,还有一个更自动化的 watchEffect

  • 区别watchEffect 不需要手动指定监听哪个属性,它会自动收集回调函数中用到的所有响应式变量。
  • 场景:当你需要在一个函数里用到多个响应式数据,且不关心旧值时,watchEffect 代码更简洁。
<script setup lang="ts">
import { ref, watchEffect } from 'vue';

const user = ref({ name: '张三', age: 25 });

// watchEffect 会自动追踪依赖
watchEffect(() => {
  console.log('watchEffect 监听 age:', user.value.age);
  // 自动收集 user.value.age 作为依赖
  // 当 age 变化时会自动执行
});
</script>

四、 深度对比:我该选哪一个?

特性 Computed (计算属性) Watch (侦听器)
主要功能 生成一个新属性(派生状态) 响应数据变化并执行代码(副作用)
缓存 有缓存,依赖不变不计算 无缓存,变化即触发
异步 不支持异步逻辑 支持异步操作(如接口请求)
代码结构 必须有 return 不需要 return
使用场景 格式化数据、多值组合、性能优化 异步数据请求、手动操作 DOM、监听路由变化

Vue-深度拆解 v-if 、 v-for 、 v-show

前言

在 Vue 模板开发中,指令的优先级和渲染机制直接决定了应用的性能。尤其是 v-ifv-for 的“爱恨情仇”,在 Vue 2 和 Vue 3 中经历了完全相反的变革。本文将带你从底层逻辑出发,看透这些指令的本质。

一、 v-if 与 v-for 的优先级之战

1. Vue 2 时代:v-for 称王

在 Vue 2 中,v-for 的优先级高于 v-if

这意味着如果你在同一个元素上同时使用它们,Vue 会先执行循环,再对循环出的每一个项进行条件判断。

  • 后果:即使 v-iffalse,循环依然会完整执行,造成极大的性能浪费。

2. Vue 3 时代:v-if 反超

在 Vue 3 中,v-if 的优先级高于 v-for

此时,如果两者并列,v-if 会先执行。但由于此时循环尚未开始,v-if 无法访问到 v-for 循环中的变量,会导致报错。

3. 最佳实践:永远不要同台竞技

无论哪个版本,永远不要把v-if和v-for同时用在同一个元素上。如果非要一起使用可以通过如下方式:

  • 方案 A:外层包裹 template(推荐)

    如果判断条件与循环项无关,先判断再循环。

    <template v-if="isShow">
      <div v-for="item in items" :key="item.id">{{ item.name }}</div>
    </template>
    
  • 方案 B:使用计算属性 computed(推荐)

    如果需要根据条件过滤列表项,先过滤再循环。

    <script setup lang="ts">
    import { computed } from 'vue';
    const activeItems = computed(() => items.value.filter(item => item.isActive));
    </script>
    
    <template>
      <div v-for="item in activeItems" :key="item.id">{{ item.name }}</div>
    </template>
    

二、 v-if 与 v-show:隐藏背后的玄机

两者都能控制显隐,但“手段”截然不同。

1. 核心区别对照表

特性 v-if v-show
手段 真正的数据驱动,动态添加/删除 DOM 元素 CSS 驱动,切换 display: none 属性
本质 组件的销毁与重建 元素的显示与隐藏
初始渲染 若初始为 false,则完全不渲染 无论真假,都会渲染并保留 DOM
切换消耗 较高(涉及生命周期与 DOM 增删) 较低(仅改变 CSS)
生命周期 切换时触发完整生命周期 不触发生命周期钩子

2. 生命周期触发逻辑(Vue 3 + TS 视角)

由于 v-if 是真实的销毁与重建,它会完整走一遍生命周期。

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';

// 假设这是一个被 v-if 控制的子组件
onMounted(() => {
  console.log('子组件已创建并挂载 (v-if 为 true)');
});

onUnmounted(() => {
  console.log('子组件已卸载并销毁 (v-if 为 false)');
});
</script>
  • v-if 切换

    • false -> true:触发 onBeforeMount, onMounted 等。
    • true -> false:触发 onBeforeUnmount, onUnmounted 等。
  • v-show 切换

    • 不会触发上述任何钩子,因为组件实例始终保存在内存中。

三、 总结:如何选型?

  • 选择 v-show:如果元素在页面上频繁切换(如 Tab 标签、折叠面板),v-show 的性能表现更优。
  • 选择 v-if:如果运行条件下改变较少,或者该部分包含大量复杂的子组件,使用 v-if 可以保证初始渲染的轻量化,并在不需要时彻底释放内存。

既然有了 defer,我们还需要像以前那样把 <script>标签放到 <body>的最底部吗?

既然有了 defer,我们还需要像以前那样把 <script> 标签放到 <body> 的最底部吗?如果我把带 defer 的脚本放在 <head> 里,会有性能问题吗?

核心答案

不需要了。 使用 defer 属性后,把 <script> 放在 <head> 里不仅没有性能问题,反而是更优的做法

原因:

  1. defer 脚本会并行下载,不阻塞 HTML 解析
  2. 脚本执行会延迟到 DOM 解析完成后,但在 DOMContentLoaded 事件之前
  3. 放在 <head> 里可以让浏览器更早发现并开始下载脚本

深入解析

浏览器解析机制

传统 <script>(无 defer/async):
HTML 解析 ──▶ 遇到 script ──▶ 暂停解析 ──▶ 下载脚本 ──▶ 执行脚本 ──▶ 继续解析

defer 脚本:
HTML 解析 ────────────────────────────────────────────▶ DOM 解析完成 ──▶ 执行脚本
     └──▶ 并行下载脚本 ──────────────────────────────────────────────────┘

为什么 <head> 里的 defer 更好?

位置 发现脚本时机 开始下载时机
<head> 解析开始时 立即
<body> 底部 解析接近完成时 较晚

放在 <head> 里,浏览器可以在解析 HTML 的同时下载脚本,充分利用网络带宽

常见误区

误区 1: "defer 脚本放 <head> 会阻塞渲染"

  • 错误。defer 脚本的下载和 HTML 解析是并行的

误区 2: "放 <body> 底部更保险"

  • 这是 defer 出现之前的最佳实践,现在已过时
  • 放底部反而会延迟脚本的发现和下载

误区 3: "defer 和放底部效果一样"

  • 不一样。放底部时,脚本下载要等到 HTML 解析到那里才开始
  • defer 在 <head> 里可以更早开始下载

defer vs async vs 传统方式

                    下载时机        执行时机              执行顺序
传统 script         阻塞解析        下载完立即执行         按文档顺序
async              并行下载        下载完立即执行         不保证顺序
defer              并行下载        DOM 解析完成后        按文档顺序

代码示例

<!-- ✅ 推荐:defer 脚本放在 <head> -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>页面标题</title>
  <!-- 浏览器立即发现并开始下载,但不阻塞解析 -->
  <script defer src="vendor.js"></script>
  <script defer src="app.js"></script>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <!-- HTML 内容 -->
</body>
</html>

<!-- ❌ 过时做法:放在 body 底部 -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>页面标题</title>
</head>
<body>
  <!-- HTML 内容 -->

  <!-- 要等 HTML 解析到这里才开始下载 -->
  <script src="vendor.js"></script>
  <script src="app.js"></script>
</body>
</html>

验证下载时机的方法

打开 Chrome DevTools → Network 面板,观察脚本的下载开始时间:

  • <head> 里的 defer 脚本:在 HTML 下载初期就开始
  • <body> 底部的脚本:在 HTML 解析接近完成时才开始

面试技巧

可能的追问方向

  1. "defer 和 async 有什么区别?"

    • async 下载完立即执行,不保证顺序
    • defer 等 DOM 解析完才执行,保证顺序
  2. "多个 defer 脚本的执行顺序是怎样的?"

    • 按照在文档中出现的顺序执行
    • 即使后面的脚本先下载完,也会等前面的
  3. "defer 脚本和 DOMContentLoaded 的关系?"

    • defer 脚本在 DOM 解析完成后、DOMContentLoaded 触发前执行
  4. "什么情况下还是要放 body 底部?"

    • 需要兼容不支持 defer 的古老浏览器(IE9 以下)
    • 现代开发中基本不需要考虑

展示深度的回答方式

"defer 放 <head> 不仅没有性能问题,反而是更优的选择。因为浏览器的预加载扫描器(Preload Scanner)可以在解析 HTML 的早期就发现这些脚本并开始下载,充分利用网络带宽。而放在 <body> 底部的话,脚本的发现时机会延后,相当于浪费了并行下载的机会。"

一句话总结

defer 脚本放 <head> 是现代最佳实践:更早发现、并行下载、不阻塞解析、按序执行。

如果一个脚本既有 async 又有 defer 属性,会发生什么情况?

如果一个脚本既有 async 又有 defer 属性,会发生什么情况?

核心答案

async 优先级更高,defer 会被忽略。 当一个 <script> 标签同时具有 asyncdefer 属性时,浏览器会按照 async 的行为执行——脚本并行下载,下载完成后立即执行,不保证执行顺序。

这是 HTML 规范明确定义的行为,defer 在这种情况下作为降级回退存在,用于兼容不支持 async 的老旧浏览器。

深入解析

HTML 规范中的优先级

根据 HTML Living Standard,浏览器处理 <script> 标签的逻辑如下:

if (脚本有 src 属性) {
    if (async 属性存在) {
         使用 async 模式
    } else if (defer 属性存在) {
         使用 defer 模式
    } else {
         使用传统阻塞模式
    }
}

关键点:async 的判断在 defer 之前,所以 async 优先。

为什么要这样设计?

这是一个优雅降级的设计:

浏览器支持情况 行为
支持 async 使用 async(忽略 defer)
不支持 async,支持 defer 使用 defer
都不支持 传统阻塞加载

在 async 刚推出时(约 2010 年),老版本 IE(IE9 及以下)不支持 async 但支持 defer。同时写两个属性可以让:

  • 现代浏览器使用 async
  • 老浏览器回退到 defer

三种模式对比

                    下载        执行时机              顺序保证    阻塞解析
无属性              阻塞        下载完立即执行                  
async              并行        下载完立即执行                  
defer              并行        DOM 解析完成后                 
async + defer      并行        下载完立即执行                  

常见误区

误区 1: "两个属性会产生某种组合效果"

  • 错误。不存在 "async-defer" 混合模式,只会选择其中一个

误区 2: "defer 会覆盖 async"

  • 错误。恰恰相反,async 优先级更高

误区 3: "现代开发中同时写两个属性有意义"

  • 基本没有意义了。async 的浏览器支持率已经非常高(IE10+),不需要 defer 作为回退

内联脚本的特殊情况

<!-- async 和 defer 对内联脚本无效 -->
<script async defer>
  console.log('我是内联脚本,async 和 defer 都被忽略');
</script>

asyncdefer 只对外部脚本(有 src 属性)有效。

代码示例

<!-- 同时有 async 和 defer -->
<script async defer src="script.js"></script>

<!-- 等价于(在现代浏览器中) -->
<script async src="script.js"></script>

验证行为的测试代码

<!DOCTYPE html>
<html>
<head>
  <script async defer src="a.js"></script> <!-- 输出 A -->
  <script async defer src="b.js"></script> <!-- 输出 B -->
  <script async defer src="c.js"></script> <!-- 输出 C -->
</head>
<body>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      console.log('DOMContentLoaded');
    });
  </script>
</body>
</html>

<!--
可能的输出顺序(取决于下载速度):
B, A, C, DOMContentLoaded
或
A, C, B, DOMContentLoaded
或其他任意顺序

如果是纯 defer,输出一定是:
A, B, C, DOMContentLoaded
-->

实际应用场景

<!-- 2010-2015 年的兼容性写法 -->
<script async defer src="analytics.js"></script>

<!-- 现代写法:直接用 async 或 defer -->
<!-- 独立脚本(如统计、广告)用 async -->
<script async src="analytics.js"></script>

<!-- 有依赖关系的脚本用 defer -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>

面试技巧

可能的追问方向

  1. "为什么 async 优先级更高?"

    • 这是 HTML 规范的设计,目的是让 defer 作为 async 的降级回退
    • 体现了渐进增强/优雅降级的设计思想
  2. "现在还需要同时写两个属性吗?"

    • 基本不需要。async 支持率已经很高
    • 如果要兼容 IE9,应该用其他方案(如 polyfill 或条件注释)
  3. "module 类型的脚本呢?"

    • <script type="module"> 默认就是 defer 行为
    • 可以加 async 变成 async 行为
    • 不需要显式写 defer
  4. "动态创建的脚本呢?"

    • 动态创建的 <script> 默认是 async 行为
    • 可以设置 script.async = false 来改变

展示深度的回答方式

"当 async 和 defer 同时存在时,async 优先,defer 被忽略。这是 HTML 规范明确定义的行为,设计初衷是让 defer 作为 async 的降级回退——在 async 刚推出时,老版本 IE 不支持 async 但支持 defer,同时写两个属性可以实现优雅降级。不过在现代开发中,这种写法已经没有必要了。"

一句话总结

async + defer = async;defer 只是 async 的降级回退,现代开发中无需同时使用。

❌