普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月31日掘金 前端

【节点】[VertexID节点]原理解析与实际应用

作者 SmalBox
2026年1月31日 17:51

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity的可编程渲染管线中,Shader Graph为开发者提供了可视化编写着色器的能力,而Vertex ID节点则是其中一个功能强大但常被忽视的重要工具。Vertex ID节点允许着色器访问当前处理的顶点或片元的唯一标识符,为各种高级渲染技术提供了基础支持。

Vertex ID节点概述

Vertex ID节点的核心功能是输出当前正在处理的顶点或片元在网格中的索引值。这个索引值从0开始,按照网格顶点缓冲区的顺序递增。在顶点着色器阶段,它代表顶点的索引;在片元着色器阶段,它代表生成该片元的顶点的索引。

工作原理与底层机制

Vertex ID的实现依赖于GPU的顶点着色器输入语义。在HLSL中,这通常对应着SV_VertexID系统值语义。当Unity提交绘制调用时,GPU会为每个处理的顶点分配一个唯一的ID,这个ID基于顶点在顶点缓冲区中的位置。

在传统的编写着色器代码方式中,开发者会这样声明和使用Vertex ID:

HLSL

truct appdata
{
    uint vertexID : SV_VertexID;
};

而在Shader Graph中,这个过程被简化为简单地添加和连接Vertex ID节点,大大降低了使用门槛。

节点特性与限制

Vertex ID节点有几个重要特性需要注意:

  • 输出值为浮点数类型,范围从0到网格顶点数减1
  • 在顶点着色器和片元着色器中均可使用
  • 值在单个绘制调用中保持唯一性和连续性
  • 不受网格变形或动画影响,始终反映原始网格的顶点顺序

同时也有一些使用限制:

  • 不能用于计算着色器
  • 在某些移动设备上可能有限制或性能考虑
  • 对于动态批处理的物体,Vertex ID可能不会按预期工作

Vertex ID节点的应用场景

Vertex ID节点在Shader Graph中有着广泛的应用场景,从简单的效果到复杂的渲染技术都能发挥作用。

顶点级动画与变形

利用Vertex ID可以实现基于顶点索引的动画效果,比如波浪效果、随机偏移等。由于每个顶点都有唯一的ID,可以基于ID计算不同的变换参数。

HLSL

// 伪代码示例:基于Vertex ID的波浪动画
float wave = sin(_Time.y * _WaveSpeed + vertexID * _WaveDensity);
float3 offset = float3(0, wave * _WaveHeight, 0);
position.xyz += offset;

程序化纹理坐标生成

当网格缺乏合适的UV坐标时,可以使用Vertex ID来生成程序化的纹理映射。这在处理程序化生成的几何体时特别有用。

HLSL

// 伪代码示例:基于Vertex ID生成UV
float2 uv = float2(frac(vertexID * _UVScale), floor(vertexID * _UVScale) / _GridSize);

实例化与批量渲染优化

在GPU实例化场景中,Vertex ID可以与其他系统值(如Instance ID)结合使用,实现高效的批量渲染和数据索引。

调试与可视化工具

Vertex ID是强大的调试工具,可以用于:

  • 可视化顶点分布和顺序
  • 检测顶点缓冲区问题
  • 理解网格拓扑结构

实际应用示例

下面通过几个具体的Shader Graph设置示例,展示Vertex ID节点的实际应用。

波浪地形效果

创建一个基于Vertex ID的波浪地形效果:

  • 首先在Shader Graph中创建Vertex ID节点
  • 将输出连接到Custom Function节点进行波浪计算
  • 使用Time节点提供动画参数
  • 将计算结果连接到Position节点的偏移量

关键节点设置:

  • Vertex ID → Custom Function (波浪计算) → Add to Position
  • Time → Multiply (控制速度) → Custom Function
  • 参数输入:波浪幅度、频率、传播速度

这种设置可以实现流畅的波浪动画,每个顶点基于其ID产生相位偏移,形成自然的波浪传播效果。

顶点颜色渐变

使用Vertex ID创建沿着顶点顺序的颜色渐变:

  • Vertex ID节点输出除以网格顶点总数,归一化到[0,1]范围
  • 将归一化值输入到Gradient节点
  • 将Gradient输出连接到Base Color

这种方法特别适合线框渲染或几何可视化,可以清晰展示顶点的顺序和分布。

程序化网格变形

结合Vertex ID和数学节点创建复杂的网格变形:

  • 使用Vertex ID作为噪声函数的输入种子
  • 通过不同的数学运算(sin、cos、fract等)创建各种变形模式
  • 将变形结果应用到顶点位置

这种技术可以创建有机的、程序化的形状变化,无需额外的纹理或顶点数据。

性能优化与最佳实践

正确使用Vertex ID节点需要考虑性能因素和最佳实践。

性能考虑

  • 在移动平台上,尽量减少基于Vertex ID的复杂计算
  • 避免在片元着色器中使用Vertex ID进行每帧重计算
  • 考虑使用顶点着色器计算并将结果传递给片元着色器

兼容性处理

  • 使用Shader Graph的节点功能检查目标平台的兼容性
  • 为不支持Vertex ID的平台提供fallback方案
  • 测试在不同图形API下的行为一致性

调试技巧

  • 使用Vertex ID可视化来理解网格结构
  • 结合RenderDoc等工具分析实际的Vertex ID分布
  • 创建调试着色器来验证Vertex ID的预期行为

高级应用技巧

与其他系统值的结合

Vertex ID可以与其他系统值结合使用,创造更复杂的效果:

  • 结合Instance ID实现每实例的顶点变形
  • 与Primitive ID配合实现基于图元的特效
  • 和Screen Position结合创建屏幕相关的顶点动画

自定义函数封装

对于复杂的Vertex ID应用,可以创建自定义HLSL函数节点:

HLSL

void VertexIDAnimation_float(float VertexID, float Time, float Amplitude, float Frequency, out float3 Offset)
{
    float phase = VertexID * Frequency + Time;
    Offset = float3(0, sin(phase) * Amplitude, 0);
}

这样可以在多个Shader Graph中重用复杂的Vertex ID逻辑。

数据驱动的方法

将Vertex ID与外部数据结合:

  • 使用Compute Buffer存储每顶点的动画参数
  • 通过MaterialPropertyBlock传递顶点级别的数据
  • 结合Scriptable Renderer Features实现更高级的渲染管线集成

故障排除与常见问题

Vertex ID输出异常

当Vertex ID不按预期工作时,可能的原因包括:

  • 网格被动态批处理,改变了顶点顺序
  • 使用了不支持的渲染路径
  • 图形API限制

解决方案:

  • 禁用动态批处理
  • 检查目标平台的图形API支持
  • 使用Shader Variant收集器确保所有需要的变体都被编译

性能问题

基于Vertex ID的效果导致性能下降时的优化策略:

  • 将计算从片元着色器移到顶点着色器
  • 使用LOD系统在远距离简化效果
  • 预计算静态效果到顶点颜色或纹理中

平台兼容性

处理不同平台的兼容性问题:

  • 为OpenGL ES 2.0等老旧平台提供简化版本
  • 使用Shader Graph的Keyword系统管理平台特定代码
  • 进行充分的跨平台测试

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Nest 的中间件 Middleware ?

作者 前端付豪
2026年1月31日 17:30

新建项目

nest new middleware-demo

创建一个中间件

nest g middleware aaa --no-spec --flat

image.png

加下打印

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class AaaMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: () => void) {
    console.log('brefore');
    next();
    console.log('after');
  }
}

在 Module 里这样使用

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AaaMiddleware } from './aaa.middleware';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(AaaMiddleware).forRoutes('*');
  }
}

跑起来看看

image.png

可以指定更精确的路由,添加几个 handler

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('hello')
  getHello(): string {
    console.log('hello');
    return this.appService.getHello();
  }

  @Get('hello2')
  getHello2(): string {
    console.log('hello2');
    return this.appService.getHello();
  }

  @Get('hi')
  getHi(): string {
    console.log('hi');
    return this.appService.getHello();
  }

  @Get('hi1')
  getHi1(): string {
    console.log('hi1');
    return this.appService.getHello();
  }
}

module 匹配更新下

import { AaaMiddleware } from './aaa.middleware';
import {
  MiddlewareConsumer,
  Module,
  NestModule,
  RequestMethod,
} from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AaaMiddleware)
      .forRoutes({ path: 'hello/*path', method: RequestMethod.GET });
    consumer
      .apply(AaaMiddleware)
      .forRoutes({ path: 'hi1', method: RequestMethod.GET });
  }
}

image.png

Nest 为什么要把 Middleware 做成 class 呢?

为了依赖注入!

通过 @Inject 注入 AppService 到 middleware 里

import { AppService } from './app.service';
import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class AaaMiddleware implements NestMiddleware {
  @Inject(AppService)
  private readonly appService: AppService;

  use(req: Request, res: Response, next: () => void) {
    console.log('brefore');
    console.log('-------' + this.appService.getHello());
    next();
    console.log('after');
  }
}

image.png

这就是 Nest 注入的依赖

Vue2(三)——模板语法

2026年1月31日 17:18

v2.cn.vuejs.org/v2/guide/sy…

Vue.js 使用了基于 HTML 的模板语法,允许开发者声明式地将 DOM 绑定至底层 Vue 实例的数据。所有 Vue.js 的模板都是合法的 HTML,所以能被遵循规范的浏览器和 HTML 解析器解析。

在底层的实现上,Vue 将模板编译成虚拟 DOM 渲染函数。结合响应系统,Vue 能够智能地计算出最少需要重新渲染多少组件,并把 DOM 操作次数减到最少。

如果你熟悉虚拟 DOM 并且偏爱 JavaScript 的原始力量,你也可以不用模板,直接写渲染 (render) 函数,使用可选的 JSX 语法。


上面这段原封不动,来自官方,我比较菜,看不懂,下面AI的分析

第一层:写什么?—— 声明式模板 (Declarative Templates)

原文: “Vue.js 使用了基于 HTML 的模板语法... 声明式地将 DOM 绑定至数据... 所有模板都是合法的 HTML。”

这讲的是 Vue 的**“门面”**。

  1. 声明式 (Declarative) vs 命令式 (Imperative):
    • 命令式(如 jQuery): 你需要一步步告诉浏览器怎么做(“找到 id 为 app 的 div,清空内容,插入一个 span,设置 span 的文字...”)。
    • 声明式(Vue): 你只告诉 Vue 你想要什么结果(“这里要显示 {{ message }}”),具体的脏活累活(DOM 操作)交给 Vue 去处理。
  1. 基于 HTML:
    • 这大大降低了学习门槛。设计师或后端开发人员也能看懂 Vue 代码,因为它长得就像普通的 HTML。
    • 这也意味着现有的 HTML 解析器都能处理它,不会像某些非标语法那样导致编辑器报错。

第二层:怎么变?—— 编译与虚拟 DOM (Compilation & VDOM)

原文: “在底层的实现上,Vue 将模板编译成虚拟 DOM 渲染函数。”

这讲的是 Vue 的**“转换过程”**。浏览器其实看不懂 v-if{{ }},所以 Vue 在代码运行前(或运行时)做了一次“翻译”。

  1. 编译 (Compile):

Vue 有一个编译器,它会把你的 HTML 模板字符串“翻译”成一段 JavaScript 代码。这段代码就叫 渲染函数 (Render Function)

  1. 虚拟 DOM (Virtual DOM):

渲染函数执行后,不会直接去动真的 DOM(因为操作真 DOM 很慢),而是生成一个 JavaScript 对象树,这个对象树就是虚拟 DOM。它就像是真实 DOM 的一份“轻量级蓝图”。

举个例子:

  • 你的模板:

HTML

<div :id="dynamicId">Hello</div>
  • Vue 编译后的渲染函数 (伪代码):

JavaScript

function render() {
  return h('div', { id: this.dynamicId }, 'Hello')
}

第三层:怎么跑?—— 响应式与智能更新 (Reactivity & Diffing)

原文: “结合响应系统,Vue 能够智能地计算出... 把 DOM 操作次数减到最少。”

这讲的是 Vue 的**“超能力”**。

  1. 响应系统 (Reactivity):

Vue 会“监视”你的数据。当数据变化时,它不仅知道数据变了,还精确地知道哪个组件依赖了这个数据。

  1. Diff 算法 (最小化更新):

当数据变化,渲染函数会重新执行,生成新的虚拟 DOM 树。

Vue 会拿着 新树旧树 做对比(Diff)。

    • Vue 发现: “哦,只有这个 div class 变了,其他都没变。”
    • 结果:Vue 只去更新真实 DOM 里那个 divclass,其他不动。

这就是为什么 Vue 即使在处理庞大页面时依然很快的原因。


第四层:给高手的“后门” —— Render 函数 & JSX

原文: “如果你熟悉虚拟 DOM 并且偏爱 JavaScript 的原始力量... 直接写渲染 (render) 函数...”

这段话是说,模板虽然好用,但有时候不够灵活。

  • 模板的局限: 比如你要写一个组件,根据 props 动态生成 h1h6 标签。用模板写,你可能得写 6 个 v-if
  • JS 的力量: 如果用渲染函数,你只需要写一行 JS:return h('h' + this.level, ...)

Vue 并不强迫你用模板,它完全支持你像 React 那样写代码(JSX),这给了高级开发者极大的灵活性。

总结分析

这段话其实揭示了 Vue 的架构分层

层次 作用 对应原文
顶层 (API) 易用性 基于 HTML 的模板,声明式绑定
中间层 (Compiler) 转化 模板 \rightarrow 渲染函数 \rightarrow 虚拟 DOM
底层 (Engine) 性能 响应式系统 + 智能 Diff 计算
扩展层 (Flexibility) 灵活性 可选 JSX / Render 函数

插值

文本

双大括号 Mustache

<span>Message: {{ msg }}</span>

Mustache 标签将会被替代为对应数据对象上 msg property 的值。无论何时,绑定的数据对象上 msg property 发生了改变,插值处的内容都会更新。

可以使用v-once ,只执行一次插值,后面值变化不会更新

<span v-once>这个将不会改变: {{ msg }}</span>

原始HTML

v-html

<p>Using mustaches: {{ rawHtml }}</p>
<p>Using v-html directive: <span v-html="rawHtml"></span></p>

整个span 的内容 会被替换成 property 值 rawHtml直接作为 HTML

注意:XSS 攻击,并且只支持常规html,不支持解析自定义组件

Attribute

v-bind 执行 绑定 Attribute

<div v-bind:id="dynamicId"></div>

注意与 HTML的不同 ( https://juejin.cn/post/7598447519823101993 里面的布尔属性提到了)

对于布尔 attribute (它们只要存在就意味着值为 true),v-bind 有不同,在这个例子中:

<button v-bind:disabled="isButtonDisabled">Button</button>

如果 isButtonDisabled 的值是 nullundefinedfalse,则 disabled attribute 甚至不会被包含在渲染出来的 <button> 元素中。

使用 JavaScript 表达式

插值模板还支持三目运算符和数学表达式,以及库函数操作

{{ number + 1 }}

{{ ok ? 'YES' : 'NO' }}

{{ message.split('').reverse().join('') }}

<div v-bind:id="'list-' + id"></div>

错误例子

<!-- 这是语句,不是表达式 -->
{{ var a = 1 }}

<!-- 流控制也不会生效,请使用三元表达式 -->
{{ if (ok) { return message } }}

模板表达式都被放在沙盒中,只能访问全局变量的一个白名单,如 MathDate 。你不应该在模板表达式中试图访问用户定义的全局变量。

大白话就是指定访问 new Vue({}) 时传入的对象里面的 data,methods computed等,以及库函数,外部定义的变量,只要没传入 new Vue({}) 里面就不能访问

指令

指令 (Directives) 是带有 v- 前缀的特殊 attribute。

指令 attribute 的值预期是单个 JavaScript 表达式 (v-for 是例外情况,稍后我们再讨论)。

当表达式的值改变时,将影响DOM

即 Model 影响 -> DOM

<p v-if="seen">现在你看到我了</p>

v-if 指令将根据表达式 seen 的值的真假来插入/移除 <p> 元素

参数

一些指令能够接收一个“参数”,在指令名称之后以冒号表示。

例如,v-bind 指令可以用于响应式地更新 HTML attribute:

<a v-bind:href="url">...</a>

在这里 href 是参数,告知 v-bind 指令将该元素的 href attribute 与表达式 url 的值绑定。

另一个例子是 v-on 指令,它用于监听 DOM 事件:

<a v-on:click="doSomething">...</a>

在这里参数是监听的事件名。我们也会更详细地讨论事件处理。

动态参数

用方括号括起来的 JavaScript 表达式作为一个指令的参数:

<!--
注意,参数表达式的写法存在一些约束,如之后的“对动态参数表达式的约束”章节所述。
-->
<a v-bind:[attributeName]="url"> ... </a>

这里的 attributeName 会被作为一个 JavaScript 表达式进行动态求值,求得的值将会作为最终的参数来使用。例如,如果你的 Vue 实例有一个 data property attributeName,其值为 "href",那么这个绑定将等价于 v-bind:href

使用动态参数为一个动态的事件名绑定处理函数:

<a v-on:[eventName]="doSomething"> ... </a>

在这个示例中,当 eventName 的值为 "focus" 时,v-on:[eventName] 将等价于 v-on:focus

动态参数值的约束

  • 预期是求出一个字符串
  • 异常情况为null,null表示移除该绑定
  • 动态参数表达式有一些语法约束,因为某些字符,如空格和引号,放在 HTML attribute 名里是无效的。
<!-- 这会触发一个编译警告 -->
<a v-bind:['foo' + bar]="value"> ... </a>

使用没有空格或引号的表达式,或者计算属性替代。

在 DOM 中使用模板时 (直接在一个 HTML 文件里撰写模板),还需要避免使用大写字符来命名键名,因为浏览器会把 attribute 名全部强制转为小写:

<!--
在 DOM 中使用模板时这段代码会被转换为 `v-bind:[someattr]`。
除非在实例中有一个名为“someattr”的 property,否则代码不会工作。
-->
<a v-bind:[someAttr]="value"> ... </a>

例子如下

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <!-- 开发环境版本,包含了有帮助的命令行警告 -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  </head>
  <body>
    <div id="app">
      <a v-bind:[someAttr]="value">掘金</a>
    </div>
  </body>
  <script>
    var app = new Vue({
      el: "#app",
      data: {
        value: "https://https://juejin.cn/",
        someAttr: "href",
      },
    });
  </script>
</html>

修改之后的代码

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <!-- 开发环境版本,包含了有帮助的命令行警告 -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  </head>
  <body>
    <div id="app">
      <a v-bind:[someAttr]="value">掘金</a>
    </div>
  </body>
  <script>
    var app = new Vue({
      el: "#app",
      data: {
        value: "https://https://juejin.cn/",
        someattr: "href",
      },
    });
  </script>
</html>

修饰符

修饰符 (modifier) 是以半角句号 . 指明的特殊后缀

可以用于阻止事件冒泡或者阻止表单提交

例如,.prevent 修饰符告诉 v-on 指令对于触发的事件调用 event.preventDefault()

<form v-on:submit.prevent="onSubmit">...</form>

缩写

缩写不是必须的

v-bind 缩写

<!-- 完整语法 -->
<a v-bind:href="url">...</a>

<!-- 缩写 -->
<a :href="url">...</a>

<!-- 动态参数的缩写 (2.6.0+) -->
<a :[key]="url"> ... </a>

v-on 缩写

<!-- 完整语法 -->
<a v-on:click="doSomething">...</a>

<!-- 缩写 -->
<a @click="doSomething">...</a>

<!-- 动态参数的缩写 (2.6.0+) -->
<a @[event]="doSomething"> ... </a>

CSS动画的灵动之吻:从代码到情感的生动演绎

作者 Lee川
2026年1月31日 17:01

CSS动画的灵动之吻:从代码到情感的生动演绎

在Web前端的世界里,CSS不仅仅是定义颜色和布局的工具,它更是一门能够赋予静态元素生命与情感的艺术。今天,我们将通过一个名为“双球亲吻”的生动案例,深入剖析如何利用CSS关键帧动画(@keyframes)和精细的类名设计,让两个简单的圆形化身为含情脉脉的男女主角,上演一场温馨的互动。

一、 动画蓝图:整体构思与HTML结构

任何精彩的动画都始于一个清晰的故事板。我们的故事很简单:画面中央,代表“女主”的白色圆球(#l-ball)在原地微微靠近又远离,而代表“男主”的圆球(#r-ball)则会主动“亲吻”女主。

HTML结构(文档1)体现了“面向对象CSS(OOCSS)”的思想,这是代码严谨性的基石:

<div class="container">
    <!-- 女主 -->
    <div class="ball" id="l-ball">...</div>
    <!-- 男主 -->
    <div class="ball" id="r-ball">...</div>
</div>

设计解析:

  • 容器居中.container使用了经典的“绝对定位 + transform: translate”方法实现水平垂直居中,这是一种与元素尺寸无关的优雅居中方案。
  • 类名复用:两个球都使用了 .ball基类,定义了它们共有的样式:尺寸、圆形边框、白色背景等。这遵循了“不要重复自己(DRY)”原则,避免了代码冗余。
  • ID与角色:通过 #l-ball#r-ball这两个ID,我们为两个角色赋予了独立的“身份”,以便后续为它们定制不同的动画行为。

二、 角色塑造:面相与情绪的CSS实现

角色的“面部”细节(眼睛、嘴巴、腮红)是传递情绪的关键。这里大量运用了CSS伪元素和“多态”类名。

1. 面部基类与“腮红”特效

.face类是所有面部元素的定位上下文。其最巧妙的设计在于使用 ::before::after伪元素来创建腮红。

.face::after, .face::before {
    content: ""; /* 必须存在 */
    position: absolute;
    width: 18px;
    height: 8px;
    background-color: #badc58; /* 腮红颜色 */
    top: 20px;
    border-radius: 50%;
}
  • 为什么用伪元素? 腮红是纯粹的装饰性内容,不应污染HTML结构。伪元素完美解决了这个问题,使得HTML保持简洁。
  • 定位:通过 right: -8pxleft: -5px将两个腮红定位在面部元素之外,模拟出球体上的红晕。

2. 眼睛与嘴巴的“多态”

眼睛(.eye)通过边框(border-bottom)巧妙地画出了下半圆,营造出可爱的感觉。而男主的眼睛需要表现出与女主不同的神态(比如挑眉),这里通过“多态”类名 .eye-r-p来实现。

.eye {
    border-bottom: 5px solid; /* 默认向下看 */
}
.eye-r-p {
    border-top: 5px solid; /* 改为向上看 */
    border-bottom: 0px solid;
}

设计解析.eye是基类,定义了眼睛的基本形状。.eye-r-p是一个修饰类,通过覆盖边框样式,改变了眼睛的方向。这种设计使得我们只需在HTML中为男主的眼睛添加这个类,就能轻松改变其神态,这正是“多态”的威力。

三、 生命注入:核心动画的时序与协作逻辑

整个动画的精髓在于四个核心动画的精密配合,它们共享一个4秒(4s)的周期并无限循环(infinite),但各自的节奏(关键帧百分比)不同。

1. 女主的矜持:靠近与停留(#l-ball.face-l

  • 身体动画 (#l-ball- close)

    • 0%-20%: 从原点平滑向右移动20px(向男主靠近)。
    • 20%-35%在右侧停留。这个停留至关重要,它为男主的亲吻动作创造了“目标”和“时机”。
    • 35%-55%: 平滑返回原点。
    • 55%-100%: 在原点长时间停留,等待下一个周期。
  • 面部动画 (.face-l- face)

    这个动画让女主的头部在移动时伴有轻微的转动(rotate),增加了拟人化的生动感。其关键帧与身体动画同步,在20%35%(即身体停留时)触发头部微调,仿佛在害羞地调整姿态。

为什么这样设计? 将身体和面部的动画分离,符合“单一职责原则”。#l-ball控制宏观位移,.face-l控制细微表情。两者协同,共同塑造出一个完整、生动的角色。

2. 男主的主动:亲吻的爆发(#r-ball及其组件)

男主的动画是故事的高潮,由三个部分精密协作完成:

  • 身体冲刺 (#r-ball- kiss)

    • 40%-50%: 在女主停留的期间(20%-35%之后),男主迅速向右移动并旋转,做出“探头亲吻”的动作。
    • 50%-60%: 快速向左回弹(translate(-33px)),这个回弹的幅度甚至超过了初始位置,模拟出亲吻的冲击力。
    • 67%-77%: 缓慢移回原点。
  • 嘴巴消失与爱心出现(.mouth-r.kiss-m

    这是最精彩的部分,通过透明度(opacity 的切换实现“变脸”。

    • 嘴巴 (.mouth-r - mouth-m) : 在 55%关键帧瞬间变为透明(opacity: 0),在 66%关键帧瞬间恢复。这意味着在亲吻动作发生时,男主的嘴巴“消失”了。
    • 爱心 (.kiss-m - kiss-m) : 它的显示(opacity: 1)时间被精确地设置在 66%,仅持续0.1%的时间(约0.004秒)。同时,嘴巴在 66%恢复。

为什么这样设计? 这创造了一个视觉魔术:在 55%66%之间,嘴巴消失,爱心出现。虽然爱心只闪现了一瞬间,但由于人眼的“视觉暂留”效应,我们会感觉在亲吻的刹那,男主的嘴巴变成了一颗爱心。这种通过极短时间显示替代元素来制造特效的手法,在CSS动画中非常经典且高效。

四、 深度优化:Z轴与时序的严谨性

  1. 层级控制 (Z-index) : 为 #l-ball设置了较大的 z-index: 100,确保女主始终在前景。这样在男主亲吻回弹时,不会出现不合理的重叠穿帮,保证了视觉逻辑的正确。
  2. 时序的严谨性: 所有动画的 animation-timing-function都设置为 ease,这使得动作的开始和结束更平滑自然,符合真实物体的运动规律。关键帧百分比的设定需要反复调试,以达到动作间无缝衔接的效果。

总结与复习指南

这个“双球亲吻”动画是一个绝佳的CSS动画综合练习案例。要复习此项目,您可以遵循以下步骤:

  1. 重构HTML: 根据文档1,仅凭记忆写出结构清晰的HTML,注意基类与修饰类的应用。

  2. 还原静态样式: 先实现两个球的静态样式,包括居中、基本形状、面部五官。重点练习伪元素(腮红)和边框画图法(眼睛)。

  3. 分步添加动画

    • 第一步:实现女主球的水平移动动画(close)。
    • 第二步:为女主添加面部微动画(face),注意与身体动画的同步。
    • 第三步:实现男主球的冲刺与回弹动画(kiss)。
    • 第四步(关键) :实现嘴巴和爱心的透明度切换动画,仔细体会 mouth-mkiss-m中关键帧百分比的设计意图,理解“视觉暂留”特效的实现原理。

通过这样的分解与重构,您不仅能牢固掌握这个动画的制作过程,更能深刻理解CSS动画设计的核心思想:将复杂动作拆解为独立的、可复用的属性变化,并通过精确的时序控制将它们组合起来,最终赋予元素生命。

从远程组件到极致性能:一次低代码架构的再思考

作者 小蜗1号
2026年1月31日 16:21

前段时间突然回顾了一下之前做过的一件事:上一份工作的核心任务之一,其实就是一个可视化 / 低代码平台。

当时受限于时间和复杂度,整体方案基本是基于 vue-sfc-playground 这一套思路,通过 iframe + 浏览器端编译的方式来实现远程组件的扩展能力。虽然最终把功能跑通了,但在真实使用过程中,这套方案逐渐暴露出不少问题。

比较典型的有:

  1. iframe 性能较差,通信成本高,调试也不友好
  2. 引入的模块必须支持 ESM,且兼容性受限
  3. 模块之间存在前置依赖,需要人工维护依赖关系
  4. 代码补全、Lint、插件能力受限,开发体验远不如本地 IDE
  5. 以及一系列零散但很消耗心智的问题

站在现在这个时间点回看,我觉得这个问题本身并不复杂,只是当时的实现方式并不优雅——它其实是有更好的解法的。

需求假设与目标拆解

为了方便后续讨论,我们先假定一个明确的需求:

低代码平台需要支持挂载任意自定义组件,用来组合实现业务功能,而不是只能使用平台内置的物料。

在这个前提下,我给自己定了几个明确的目标:

  • 开发阶段:本地 IDE 编写组件,修改后可以快速预览
  • 生产环境:极致的运行性能和尽可能小的包体积
  • 整体策略:放弃“纯在线编写组件”,全部基于本地开发来解决问题

原因也很现实:
在线写组件这条路,优化成本太高了,一旦引入复杂依赖、真实业务代码,维护难度会指数级上升。

开发阶段:追求极致的反馈速度

开发阶段整体的数据流和职责关系如下:

flowchart LR
  IDE[本地 IDE]
  IDE -->|文件变更| Rsbuild
  Rsbuild -->|HMR / WS| Renderer
  Renderer -->|defineAsyncComponent| VueRuntime

开发阶段的核心诉求其实很简单:

我在本地改代码,页面要立刻有反馈。

如果你看过 Vue 3 的官方文档,会发现它提供了一个非常关键的能力:defineAsyncComponent,用于异步加载组件。

import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // 从服务端获取组件
    resolve(/* 组件对象 */);
  });
});

推荐阅读: 给我 5 分钟,保证教会你在 Vue3 中动态加载远程组件 这篇文章可以帮助理解如何结合本地 Node 服务来挂载远程组件。

需要注意的一点是: resolve 返回的并不是字符串形式的源码,而是经过编译后的组件对象,本质上类似于 vue-loader 处理后的产物。

compiler-remote-comp

到这里,背景铺垫就结束了。

开发态整体架构设计

在开发阶段,我选择 Rsbuild 作为构建核心,整体思路大致如下:

  1. 维护一个模板工程(template) 用于提前约定好组件开发所需的基础配置。
  2. 编写 Rsbuild 插件
    • 插件会结合用户的 API KeyProject ID
    • 在本地开发阶段,通过 HMR / WebSocket 将组件的最新编译结果推送到平台面板
    • 在生产阶段,则负责将源码和构建产物上传到 OSS
  3. 渲染器(低代码核心)
    • 渲染器内部会固定一个 Vue 版本,作为所有组件的公共依赖
    • 接收由 Rsbuild 编译后的 JS 模块
    • 再通过 defineAsyncComponent 动态注入组件

约定与约束

除此之外,还需要提前定义一些结构和规范,例如:

  • global.css:用于声明全局样式
  • composes/:用于存放多个自定义组件

dev 模式启动后,插件会:

  • 开启 CORS
  • 向渲染器推送当前可用的组件列表及版本号
  • 文件变更后重新计算版本,并通知渲染器刷新

两个关键限制

这里有两个非常重要的点:

  1. 样式约束 在 Vue SFC 中禁止书写全局 style,一旦检测到直接报错,防止出现难以审查和回滚的样式污染。
  2. 依赖处理策略 在开发阶段,仅将 vue 本身 external 掉。 其他依赖(如 dayjs、UI 框架等)直接打进包里。

虽然这样会导致 JS 体积偏大,但这是开发态,为了效率和稳定性,这个代价是完全可以接受的。

为什么不用 vue3-sfc-loader

一个常见的问题是: 为什么不直接使用 vue3-sfc-loader 在浏览器端加载 SFC?

核心原因在于依赖管理。

vue3-sfc-loader 需要手动维护 moduleCache,而一旦涉及真实项目,就必然要引入大量第三方包。这就意味着你需要结合 importmap 来管理依赖关系和版本冲突。

例如:

<script type="importmap">
  {
    "imports": {
      "vue": "https://play.vuejs.org/vue.runtime.esm-browser.js",
      "vue/server-renderer": "https://play.vuejs.org/server-renderer.esm-browser.js"
    }
  }
</script>

这种方式在 Demo 场景下还可以接受,但在真实低代码平台中,维护成本会非常高,几乎不可控。

Build 阶段:为极致性能服务

flowchart TB
  subgraph Dev[开发阶段]
    IDE --> Rsbuild
    Rsbuild --> Renderer
    Renderer --> Browser
  end

  subgraph Prod[生产阶段]
    Config[JSON / DSL]
    Config --> Monorepo
    Monorepo --> Build
    Build --> OSS
    OSS --> Browser
  end

生产环境的目标只有一个:性能。

整体思路是结合 Monorepo,将低代码平台中的配置还原为一个真实可构建的工程。

工程还原策略

flowchart TB
  Root[monorepo]
  Root --> Composes[composes/* 组件包]
  Root --> Apps[apps/platform]
  Apps --> Pages[页面还原]

  • 每一个自定义组件,都被放置在 components/ 目录下,作为 Monorepo 的子包
  • apps/platform 中,根据平台生成的 JSON 配置:
    • 还原页面结构
    • 将对应的子包注册到 package.json 依赖中

随后直接执行 build

由于使用的是基于 Rust 的构建工具(如 Rsbuild / Rspack),即使是全量构建,耗时也基本控制在 30 秒以内

带来的收益

  • Tree Shaking:未使用代码会被自动移除
  • 更小的包体积
  • 更好的缓存命中率:结合分包策略,可最大化利用 HTTP 缓存

多页面与路由加载

对于支持多页面的低代码平台来说,结合这种拆分方式,每一个页面本质上只包含自己的业务代码。

const routes = [
  {
    path: "/remote-js",
    component: () => import("https://my-server.com/assets/RemoteComponent.js"),
  },
];

浏览器原生已经支持通过 import() 加载远程 ESM 模块,只要返回的是一个 Promise,Vue Router 就可以正常工作。

参考文档: Vue Router 路由懒加载

最后

这套方案更多是一次架构思路的延展,以及一些关键落地点的总结。

真正落地时,依然会有很多细节需要打磨,例如:

  • 沙箱与安全隔离
  • 组件版本管理
  • 发布与回滚策略

不过整体主线是清晰的:

开发态为体验让路,生产态为性能让路。

如果你对其中某些设计有不同的想法,或者有类似的实践经验,也欢迎交流一波。

构建无障碍组件之Breadcrumb Pattern

作者 anOnion
2026年1月31日 16:32

Breadcrumb Pattern 详解:构建无障碍面包屑导航

面包屑导航是 Web 页面中不可或缺的导航组件,它以层级链接的形式展示当前页面在网站结构中的位置,帮助用户快速了解自己所处的位置并轻松返回上级页面。根据 W3C WAI-ARIA Breadcrumb Pattern 规范,正确实现的面包屑导航不仅要提供清晰的层级导航路径,更要确保所有用户都能顺利使用,包括依赖屏幕阅读器等辅助技术的用户。本文将深入探讨 Breadcrumb Pattern 的核心概念、实现要点以及最佳实践。

一、面包屑导航的定义与核心功能

面包屑导航(Breadcrumb Trail)是由一系列指向父级页面的链接组成的导航路径,按照层级顺序展示当前页面在网站架构中的位置。它的主要功能是帮助用户了解自己在网站中的位置,并在需要时快速返回上级页面。面包屑导航通常水平放置在页面主体内容之前,为用户提供清晰的导航参考。

在实际应用中,面包屑导航广泛应用于内容层级较深的网站,例如电商平台的产品分类页、博客文章的分类归档页、企业官网的产品介绍页等。一个设计良好的面包屑导航能够显著提升用户体验,降低用户的迷失感,同时也有利于搜索引擎更好地理解网站的结构层次。

二、键盘交互规范

面包屑导航的键盘交互具有特殊性。由于面包屑导航仅由静态链接组成,用户不需要进行任何特殊的键盘操作来与之交互。链接本身已经支持标准的键盘导航行为,用户可以通过 Tab 键在各个链接之间切换,通过 Enter 键激活链接进行页面跳转。这种标准的行为已经满足了键盘可访问性的要求,无需额外的键盘事件处理。

因此,Breadcrumb Pattern 规范明确指出,面包屑导航不需要特殊的键盘交互支持。开发者只需要确保链接元素是标准的 HTML 元素,即可获得完整的键盘可访问性支持。这一特点使得面包屑导航的实现相对简单,重点应放在正确的语义标记和 ARIA 属性使用上。

三、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍面包屑导航的技术基础。虽然面包屑导航不涉及复杂的交互逻辑,但正确的语义标记对于屏幕阅读器用户理解导航结构至关重要。

3.1 导航容器标记

面包屑导航必须放置在导航地标区域(Navigation Landmark)内。这可以通过使用 nav 元素或为其他元素添加 role="navigation" 来实现。导航地标区域需要通过 aria-labelaria-labelledby 进行标记,以便屏幕阅读器向用户描述这个导航区域的用途。

示例:使用 nav 元素包裹面包屑导航:

<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/products/">产品</a></li>
    <li><a href="/products/electronics/">电子产品</a></li>
    <li aria-current="page">笔记本电脑</li>
  </ol>
</nav>

示例:使用 role 属性定义导航地标:

<div
  role="navigation"
  aria-label="面包屑导航">
  <ul>
    <li><a href="/">首页</a></li>
    <li><a href="/blog/">博客</a></li>
    <li aria-current="page">2025 年技术趋势</li>
  </ul>
</div>

3.2 当前页面状态标记

面包屑导航中的当前页面链接需要使用 aria-current 属性来标识。aria-current="page" 明确告诉辅助技术当前元素代表的是用户正在浏览的页面。这一属性对于屏幕阅读器用户理解面包屑导航的结构非常重要,使他们能够区分可导航的父级页面和当前所在页面。

值得注意的是,如果代表当前页面的元素不是链接(例如使用 span 或其他元素呈现),那么 aria-current 属性是可选的。但为了保持一致性,建议始终为当前页面元素添加 aria-current="page"。

示例:当前页面为链接时的标记:

<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/docs/">文档</a></li>
    <li><a href="/docs/guides/">指南</a></li>
    <li>
      <a
        href="/docs/guides/getting-started/"
        aria-current="page"
        >快速入门</a
      >
    </li>
  </ol>
</nav>

示例:当前页面为非链接元素时的标记:

<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/shop/">商店</a></li>
    <li><a href="/shop/clothing/">服装</a></li>
    <li aria-current="page">春季新品</li>
  </ol>
</nav>

四、完整示例

以下是使用不同方式实现面包屑导航的完整示例,展示了标准的 HTML 结构、ARIA 属性应用以及样式设计:

4.1 基础面包屑导航实现

<nav aria-label="面包屑导航">
  <ol class="breadcrumb">
    <li><a href="/">首页</a></li>
    <li><a href="/products/">所有产品</a></li>
    <li><a href="/products/electronics/">电子产品</a></li>
    <li aria-current="page">智能手表</li>
  </ol>
</nav>

4.2 带分隔符的面包屑导航

<nav
  aria-label="面包屑导航"
  class="breadcrumb-nav">
  <ol class="breadcrumb-list">
    <li class="breadcrumb-item">
      <a
        href="/"
        class="breadcrumb-link"
        >首页</a
      >
    </li>
    <li
      class="breadcrumb-separator"
      aria-hidden="true">
      /
    </li>
    <li class="breadcrumb-item">
      <a
        href="/categories/"
        class="breadcrumb-link"
        >分类</a
      >
    </li>
    <li
      class="breadcrumb-separator"
      aria-hidden="true">
      /
    </li>
    <li class="breadcrumb-item">
      <a
        href="/categories/books/"
        class="breadcrumb-link"
        >图书</a
      >
    </li>
    <li
      class="breadcrumb-separator"
      aria-hidden="true">
      /
    </li>
    <li
      class="breadcrumb-item"
      aria-current="page">
      <span class="breadcrumb-current">编程指南</span>
    </li>
  </ol>
</nav>

4.3 电商网站产品页面包屑导航

<nav
  aria-label="商品位置"
  class="product-breadcrumb">
  <ol>
    <li><a href="https://www.example.com/">首页</a></li>
    <li><a href="https://www.example.com/electronics/">家用电器</a></li>
    <li><a href="https://www.example.com/electronics/kitchen/">厨房电器</a></li>
    <li>
      <a href="https://www.example.com/electronics/kitchen/coffee/">咖啡机</a>
    </li>
    <li aria-current="page">全自动咖啡机 X2000</li>
  </ol>
</nav>

五、最佳实践

5.1 语义化 HTML 结构

面包屑导航应使用语义化的 HTML 元素构建。使用 nav 元素定义导航区域,使用 ol 或 ul 元素创建有序或无序列表,使用 li 元素包含各个导航项。这种结构不仅对搜索引擎友好,也便于屏幕阅读器向用户传达导航的层级关系。

在列表的选择上,ol 元素更适合面包屑导航,因为它能够传达各个项之间的顺序关系。但如果网站没有强制的层级顺序要求,ul 元素同样可以接受。无论选择哪种列表元素,都要确保列表项之间的逻辑顺序与面包屑导航的层级结构保持一致。

<!-- 推荐做法:使用语义化元素 -->
<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li><a href="/docs/">文档</a></li>
    <li aria-current="page">当前页面</li>
  </ol>
</nav>

5.2 正确使用 ARIA 属性

面包屑导航的 ARIA 属性使用相对简单,但有几个关键点需要注意。首先,导航容器必须使用 aria-labelaria-labelledby 进行标记,以便用户了解这个导航区域的用途。其次,当前页面项必须使用 aria-current="page" 进行标记。最后,确保分隔符元素使用 aria-hidden="true",以避免辅助技术用户听到不必要的标点符号朗读。

<!-- ARIA 属性使用规范 -->
<nav aria-label="面包屑导航">
  <ol>
    <li><a href="/">首页</a></li>
    <li aria-hidden="true">/</li>
    <li><a href="/blog/">博客</a></li>
    <li aria-hidden="true">/</li>
    <li aria-current="page">文章标题</li>
  </ol>
</nav>

5.3 响应式设计考虑

面包屑导航在移动设备上可能面临空间受限的挑战。对于层级较深的导航,可以考虑以下策略:一是使用省略号隐藏中间层级,只保留首页和最后几级;二是允许用户展开查看完整路径;三是将面包屑导航改为垂直布局。无论采用哪种策略,都要确保用户能够访问完整的导航信息。

/* 响应式面包屑导航 */
@media (max-width: 768px) {
  .breadcrumb-list {
    flex-direction: column;
    gap: 4px;
  }

  .breadcrumb-separator {
    transform: rotate(90deg);
  }
}

六、面包屑导航与主导航的区别

面包屑导航和主导航栏虽然都是网站导航的重要组成部分,但它们服务于不同的目的,理解这种区别对于正确使用这两种导航组件至关重要。

面包屑导航的主要作用是展示用户在网站层级结构中的当前位置,它反映的是网站的逻辑结构,而非用户的浏览历史。面包屑导航通常只出现在层级较深的页面中,帮助用户理解当前位置与网站整体结构的关系。相比之下,主导航栏提供的是网站的整体架构概览,允许用户直接跳转到任何主要版块,而不考虑当前的层级位置。

从实现角度来看,面包屑导航强调的是层级关系,因此通常使用有序列表(ol)来体现这种顺序性。而主导航栏更强调功能分区的划分,可能使用无序列表(ul)或更复杂的布局结构。两者在 ARIA 属性使用上也不同:面包屑导航需要明确标记当前页面(aria-current="page"),而主导航栏通常不需要这种标记。

七、总结

构建无障碍的面包屑导航需要关注语义化结构、ARIA 属性应用和视觉样式三个层面的细节。从语义化角度,应使用 nav 元素定义导航区域,使用 ol 或 ul 元素创建列表结构。从 ARIA 属性角度,需要使用 aria-label 为导航区域提供标签,使用 aria-current="page" 标记当前页面。从视觉样式角度,应确保链接易于识别,分隔符清晰,当前页面状态明确。

WAI-ARIA Breadcrumb Pattern 为我们提供了清晰的指导方针,遵循这些规范能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的面包屑导航组件,都是提升用户体验和网站可访问性的重要一步。

CSS伪元素:给HTML穿上"隐形斗篷"的魔法

作者 Lee川
2026年1月31日 15:46

CSS伪元素:给HTML穿上"隐形斗篷"的魔法

引言:一场看不见的化妆舞会

想象一下,你在参加一场化妆舞会,但规则很特别:你不能给自己戴上面具或穿上戏服,只能请一位"魔法化妆师"(CSS)在你身上直接绘制妆容。这位化妆师手法高超,他能凭空在你耳边画上耳环,在额头点上装饰——而这一切都不需要你真正佩戴任何实物。

这就是CSS伪元素的魔法!它们就像HTML元素的"隐形斗篷",让我们在不修改HTML结构的情况下,为页面添加各种装饰效果。

第一章:认识"双胞胎幽灵"——::before和::after

伪元素中最常用的就是这对"双胞胎兄弟":::before::after。它们不是HTML中真实存在的元素,而是CSS创造的"幽灵元素"。

/* 魔法咒语的基本格式 */
.元素::before {
    content: ""; /* 魔法的核心:必须念出咒语 */
    /* 其他样式属性 */
}

.元素::after {
    content: ""; /* 同样需要咒语 */
    /* 其他样式属性 */
}

魔法规则第一条:content咒语

content属性是开启伪元素魔法的钥匙。即使你想创建空的内容,也必须写上 content: "",就像念咒语一样,不念就没魔法!

第二章:伪元素的实战魔法——创建笑脸表情

让我们回到文章开头的代码,看看这个"笑脸魔法"是如何实现的:

/* 基础的脸部 */
.face {
    width: 100px;
    height: 100px;
    background-color: #ffdd59; /* 阳光黄色 */
    border-radius: 50%; /* 变成圆形 */
    position: relative; /* 重要:为伪元素设置舞台 */
}

/* 召唤两只"幽灵眼睛" */
.face::after, .face::before {
    content: ""; /* 念咒语:创造存在 */
    position: absolute; /* 让它们漂浮在脸部上 */
    width: 18px;  /* 眼睛宽度 */
    height: 8px;  /* 眼睛高度 */
    background-color: #badc58; /* 清新的黄绿色 */
    top: 20px; /* 从头顶往下20像素 */
    border-radius: 50%; /* 椭圆形的眼睛 */
}

/* 右眼:调皮地往右看 */
.face::before {
    right: -8px; /* 从右边往左偏移8px,稍微突出 */
}

/* 左眼:俏皮地往左看 */
.face::after {
    left: -5px; /* 从左边往右偏移5px,稍微突出 */
}

魔法效果可视化:

👁️          😊         👁️
      (::before)   (.face)    (::after)
       右眼幽灵      笑脸主体    左眼幽灵
       向右突出                向左突出

这个笑脸最妙的地方在于:HTML只需要一个简单的 <div class="face"></div>,所有的眼睛装饰都由CSS凭空创造!

第三章:为什么不用真实元素?——魔法的智慧

你可能会问:"为什么不用真实的 <span>元素来当眼睛呢?"

让我们对比一下两种方式:

方式A:使用真实元素(麻瓜方法)

<div class="face">
    <span class="eye left"></span>
    <span class="eye right"></span>
</div>

方式B:使用伪元素(巫师方法)

<div class="face"></div>

巫师的三大理由:

  1. HTML保持纯净:HTML应该只关心内容结构,而不是表现装饰。眼睛是装饰,不是内容。
  2. 维护更容易:想改变眼睛样式?只需修改CSS,完全不用碰HTML。
  3. 性能更优:伪元素不会增加真实的DOM节点,浏览器渲染起来更轻快。

第四章:伪元素的更多魔法应用

伪元素的魔法远不止画眼睛!它们在Web设计中无处不在:

魔法1:优雅的引用标记

blockquote::before {
    content: "“"; /* 左引号 */
    font-size: 3em;
    color: #ccc;
}

blockquote::after {
    content: "”"; /* 右引号 */
    font-size: 3em;
    color: #ccc;
}

魔法2:自定义列表图标

li::before {
    content: "✨"; /* 星星图标 */
    margin-right: 10px;
}

魔法3:工具提示的小箭头

.tooltip::after {
    content: "";
    position: absolute;
    /* 绘制三角形箭头 */
    border: 10px solid transparent;
    border-top-color: #333;
}

第五章:伪元素的工作原理——魔法的科学

虽然叫"伪元素",但它们在浏览器中表现得像真实元素一样:

<!-- 浏览器眼中的伪元素 -->
<div class="face">
    <!-- 这是::before -->
    😉
    <!-- 这是元素的实际内容(如果有的话) -->
    😊
    <!-- 这是::after -->
    😉
</div>

位置关系三兄弟:

/* 源代码顺序 */
.元素::before { /* 大哥:在最前面 */ }
.元素 { /* 二弟:实际内容 */ }
.元素::after { /* 三弟:在最后面 */ }

第六章:伪元素 vs 伪类——不要搞混的魔法

新手常把伪元素和伪类搞混,记住这个简单区别:

  • 伪元素::before::after::first-line

    • 创造新的虚拟元素
    • 双冒号 ::(CSS3规范,兼容单冒号:
  • 伪类:hover:focus:nth-child()

    • 选择元素的特定状态
    • 单冒号 :
/* 伪类:鼠标悬停时变色 */
button:hover {
    background-color: blue;
}

/* 伪元素:添加装饰性内容 */
button::before {
    content: "👉 ";
}

第七章:魔法的局限与禁忌

虽然伪元素很强大,但也不是万能的:

可以用伪元素(装饰性内容):

✅ 装饰性图标、角标

✅ 工具提示的箭头

✅ 引用符号

✅ 清除浮动的空元素

我们的笑脸眼睛

不要用伪元素(重要内容):

❌ 重要的导航链接

❌ 需要被搜索引擎收录的文字

❌ 需要JavaScript交互的元素

❌ 对可访问性重要的内容

第八章:现代魔法进阶

组合魔法:多重伪元素

/* 创建更复杂的装饰 */
.icon::before {
    /* 背景层 */
}
.icon::after {
    /* 前景层 */
}

动态魔法:结合CSS变量

.face::before {
    content: "";
    width: var(--eye-size, 18px);
    height: calc(var(--eye-size, 18px) * 0.444);
}

结语:成为CSS魔法师

CSS伪元素就像Web开发中的"隐形画笔",让我们能够:

  1. 保持HTML的语义纯净——内容就是内容,装饰交给CSS
  2. 实现优雅的视觉效果——无需污染DOM结构
  3. 提高代码维护性——所有样式集中管理
  4. 优化页面性能——减少不必要的DOM节点

记住这个魔法口诀:

"content不可少,定位要记牢,装饰用伪元,内容用标签"

下次当你想要添加一些装饰性元素时,先问问自己:"这可以用伪元素实现吗?" 很多时候,答案都是肯定的!

现在,拿起你的CSS魔杖(键盘),开始用伪元素创造更多神奇的Web魔法吧!🎩✨


魔法小测验:你能只用CSS伪元素创建一个完整的太阳系动画吗?提示:一个div代表太阳,伪元素代表行星... 无限创意等着你!

uni-appD4(uni-forms学习与回顾)

2026年1月31日 15:33

1.补充小程序路由跳转方式

·wx.navigateTo: 按照顺序向页面栈中放入打开过的页面

image.png·wx.redierctTo: 先把上一个页面删掉,再把自己页面放入栈内

image.png

·wx.reLaunch: 清空页面栈

image.png

2. uni-file-picker:实现文件上传功能

2.1 开通uniCloud Web云存储空间

dev.dcloud.net.cn/

image.png

2.2 在项目中与云空间关联

先创建云开发环境 image.png 再点击关联

image.pngimage.png

2.3 使用uni-file-picker

uni-app官网 上传结果可以使用v-model获取

image.png

2.3.1 验证

image.png
上面的数组和下方的数组必须至少都要有一个单元,否则‘提交’按钮不高亮

2.3.1.1 定义上凭证与下凭证数组

image.png做判断,当两个数组都有数据时才不禁用button image.png

2.4 接口只需要url,所以要处理掉其他的字段

image.pngimage.png

image.png

image.png

2.5 当用户点击提交的时候,调用接口,提交数组的url

2.5.1 封装接口

image.png

2.5.2 监听按钮

image.png

image.png

3 在途

注意:是一对一运送,所以在途最多只能有一条任务

image.png 实现组件一加载就执行逻辑 获取接口数据 image.png渲染数据

image.png

image.png

4 异常上报

image.png

4.1 异常时间

使用到uni-datetime-picker和uni-list组件

image.png

image.png

image.png

4.2 上报位置

需要调用wx.chooseLocation和wx.chooseLocation

image.png 去腾讯申请key

image.png

image.png 拿到用户选择的地址,渲染到页面上 image.png

4.3 异常类型

使用change事件监听用户有没有选择或者有没有取消

image.png

image.png 监听事件

image.png

image.png

image.png

image.png

4.4 异常描述

使用v-model获取即可

image.png

image.png

4.5 异常图片

image.png

image.png

4.6 调用接口用于提交数据

image.png

image.png

4.7 用户选择异常类型,并且回显到页面

image.png渲染回显 image.png

image.png

image.png

4.8 调用接口

image.png

image.png

image.png 其中传过去的id要从url中获取,所以又要使用onLoad,来获取id(这里的id对应的参数名为transportTaskId)

image.png

image.png

image.png

5 异常信息存在就显示,没有则隐藏

渲染数据 image.png

vue视频播放器:基于vue-video-player的自定义视频播放器实现

作者 zhaodan105
2026年1月31日 15:32

引言

目标

实现类似el-image组件的视频查看器,支持预览和切换。但 element -ui 中没有封装对于视频的查看组件,在多方调研后,引入vue-video-player实现这一功能。

功能介绍

vue-video-player 是一个基于 Video.js 封装的 Vue 组件库,旨在为 Vue 开发者提供一套简洁、可复用的视频播放器集成方案。其本质是将 Video.js 的强大功能(如 HLS 支持、字幕加载、全屏控制等)通过 Vue 的组件化机制进行封装,从而实现声明式调用和响应式更新。

官方文档

  1. vue-video-player:github.com/surmon-chin…
  2. video.js:docs.videojs.com/docs/api/pl…

安装

版本兼容性

随着 Vue3 的发布及其 Composition API 的普及,vue-video-player 的维护团队逐步将开发重心转向 Vue3 生态。对于新版本有如下改变:

  • 6.x 及以上版本开始依赖 Vue3 的 runtime-core 和新的组件模型;
  • 不再支持Vue.use()这种全局注册方式;
  • 使用了 Vue3 特有的响应式系统(Proxy 代替 defineProperty);
  • 构建工具链升级至 Vite,导致与 Vue2 项目的 webpack 配置存在冲突风险。

版本选择策略

需求场景 建议版本 安装命令 引入方式
Vue2 项目 ^5.0.2 npm install vue-video-player@^5.0.2 Vue.use(VueVideoPlayer)
Vue3 项目 ^6.0.0 npm install vue-video-player@latest app.use(VueVideoPlayer)

错误使用案例

  1. 在安装依赖时未注意版本约束,导致运行时报错:
[Vue warn]: Unknown custom element: <video-player>
Did you register the component correctly?

2. Vue2 版本最佳实践建议:

// package.json 中显式锁定版本
"dependencies": {
  "vue-video-player": "^5.0.2",
  "video.js": "^7.10.2"
}

// main.js 中正确引入
import Vue from 'vue'
import VueVideoPlayer from 'vue-video-player'
import 'vue-video-player/node_modules/video.js/dist/video-js.css'

Vue.use(VueVideoPlayer)

使用

基本用法

  1. 属性配置

我们可以通过playerOptions配置自定义属性,关键属性包括src(视频地址)、:controls(是否显示控制栏)、:autoplay(自动播放)、:loop(循环播放)以及:volume(音量设置)等。

<template>
  <div v-if="visible" class="video-mask-wrapper" tabindex="0">
    <div class="viewer-wrapper" @click.self="handleClose">
         ……
      <div class="video-player-wrapper" :style="videoBoxStyle">
        <video-player
          :key="`${index}-${viewerData.subLink}`"
          ref="videoPlayer"
          class="video-player"
          :options="playerOptions"
        />
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    ……
  },
  computed: {
    playerOptions() {
      return {
        autoplay: true, // 自动播放
        controls: true, // 显示播放控制条
        preload: 'metadata',
        fluid: false, // 自适应容器,设为false,使用自定义css样式控制
        sources: [{ src: this.viewerData.subLink, type: 'video/mp4' }],
        controlBar: {
          volumePanel: { inline: false }, // 音量面板,inline置为false时:点击音量图标时弹出独立的垂直滑块
          playToggle: true, // 控制条的播放暂停按钮
        },
        bigPlayButton: false, // 隐藏大播放按钮
      };
    },
  },
};
</script>

<style scoped lang="scss">
.video-mask-wrapper {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 2000;
  outline: none;
  .viewer-wrapper {
    position: absolute;
    inset: 0;
    z-index: 2002;
    display: flex;
    align-items: center;
    justify-content: center;
    .video-player-wrapper {
      position: relative;
      max-width: 1000px;
      max-height: 850px;
      .video-player {
        width: 100%;
        height: 100%;
      }
    }
  }
}
::v-deep .video-js {
  width: 100% !important;
  height: 100% !important;
}
::v-deep .video-js .vjs-tech {
  width: 100% !important;
  height: 100% !important;
  object-fit: contain;
  background-color: transparent;
}
</style>

2. 方法

const player = this.$refs.videoPlayer && this.$refs.videoPlayer.player;

player.pause();
player.load();
player.src([{ src: newSrc, type: 'video/mp4' }]);
  • addTextTrack():向音频/视频添加新的文本轨道。
  • canPlayType():检测浏览器是否能播放指定的音频/视频类型。
  • load():重新加载音频/视频元素。
  • play():开始播放音频/视频。
  • pause():暂停当前播放的音频/视频。
  • ……
  1. 事件
  • waiting:当视频由于需要缓冲下一帧而停止时触发。
  • canplay:当浏览器可以开始播放音频/视频时触发。
  • error:当在音频/视频加载期间发生错误时触发。
  • loadedmetadata:当浏览器已加载音频/视频的元数据时触发。
  • ……
  1. 支持的视频格式
  • 可参考文档【测试说明】部分,cloud.tencent.com/developer/a…
  • 若要播放m3u8视频流:1、需要引入video.js并绑定到window上;2、安装依赖videojs-contrib-hls并引入;3、sources 要指定type: application/x-mpegURL

二次封装

基于用户操作习惯,我们需要对播放器进行二次封装,主要包括:

播放器动态宽高

  1. 解决什么问题
    • 播放器配置项中自带的fluid 属性,可以调整视频比例来自适应容器大小,但这会导致与原始比例严重失调,比如在网页上通常是宽〉高,但如果视频是竖屏的,这时就会压缩视频高度适应容器,视觉效果大打折扣。
    • 视频不足以撑满整个容器时,会存在黑边
  2. 解决方案:如下流程图所示,基于当前传入的视频原始尺寸、视窗宽高、设定的最大宽高,动态计算当前视频下播放器的宽高,实现在设定的最大宽高范围内:
    • 视频宽或者高大于设定最大宽高,基于比例缩放视频宽高
    • 视频宽和高都不超过设定的最大宽高,使用原始视频宽高
  3. 实现效果
    • 视频宽高和播放器宽高完全一致,避免存在黑边的现象
    • 缩放后依然保持视频原始比例,保证视觉效果
    • 通过CSS 样式调整,可以将播放器背景设置为transparent,当视频加载时,就不会一直呈现黑色背景

动态计算播放器尺寸.jpg 4. 部分代码

 computed: {
    // 将动态计算的视频宽高绑定到播放容器
    getVideoBoxStyle() {
      const w = this.boxWidth || 0;
      const h = this.boxHeight || 0;
      const style = {};

      if (w > 0 && h > 0) {
        style.width = w + 'px';
        style.height = h + 'px';
      }

      return style;
    },
  },    
methods:{
    // 基于原始尺寸和当前可用空间,计算播放器尺寸    
    handleBoxSizeResize() {
      const { w, h } = this.naturalVideo || {};
      const { LIMIT_W, LIMIT_H } = this;

      // 取视窗宽高与设置的最大宽高的最小值,作为播放器的最大宽高
      const maxW = Math.min(window.innerWidth, LIMIT_W);
      const maxH = Math.min(window.innerHeight, LIMIT_H);

      // 取宽度、高度缩放比例的最小值,保证视频完整显示
      const scale = Math.min(1, Math.min(maxW / w, maxH / h));

      this.boxWidth = Math.max(0, Math.round(w * (isFinite(scale) ? scale : 1)));
      this.boxHeight = Math.max(0, Math.round(h * (isFinite(scale) ? scale : 1)));
    },

    // 基于最大宽高,动态计算视频宽高
    getVideoContainerSize() {
      const player = this.$refs.videoPlayer.player;
      const node = player.el().querySelector('video');

      const compute = () => {
        const originVideoWidth = node.videoWidth;
        const originVideoHeight = node.videoHeight;
        this.naturalVideo = { w: originVideoWidth, h: originVideoHeight };
        this.handleBoxSizeResize();
      };

      // 若已加载元数据,直接计算;否则监听到加载后,执行compute
      if (node && node.readyState >= 1) {
        compute();
      } else if (node) {
        node.addEventListener('loadedmetadata', compute, { once: true });
      }
    },
}

多视频切换播放

  1. 解决什么问题:当前业务背景下,多个视频在弹窗内按顺序排列,如果要查看其他视频,需要退出当前视频后,再点击另一个视频查看,操作麻烦
  2. 解决方案:
    • 在视频预览页面增加左、右箭头icon,绑定click事件,基于当前视频索引,当切换上一条视频时,父组件将index-1索引的视频信息传入播放器组件,播放器重新渲染;切换下一条视频时,同理。
    • 监听keyDown事件,按下键盘左箭头、右箭头时,同上面逻辑。
    • 增加watch监听,当监听到视频数据 viewerData 更新时,重置视频预览数据,同时进行视频切源
  3. 实现效果
    • 点击左右箭头,支持上一条/下一条切换视频
    • 监听键盘事件,支持键盘左右箭头事件来切换视频
  4. 部分代码
// 重置视频状态
    handleVideoStateReset() {
      this.boxWidth = 0;
      this.boxHeight = 0;
      this.naturalVideo = { w: 0, h: 0 };
      this.isLoading = true;
      this.loadError = false;
    },
    // 视频切源
    handleVideoCutResource() {
      const player = this.$refs.videoPlayer && this.$refs.videoPlayer.player;
      const newSrc = this.viewerData && this.viewerData.subLink;

      if (player && newSrc) {
        player.pause();
        player.src([{ src: newSrc, type: 'video/mp4' }]);
        player.load();

        // 监听视频事件
        this.bindVideoEvents(player);

        player.one('loadedmetadata', () => {
          this.getVideoContainerSize();
        });
      }
    },    
    // 上一个视频
    handlePreVideoChange(index) {
      this.handleVideoSwitch(index, -1, this.dialogData.carveUrlList.length);
    },
    // 下一个视频
    handleNextVideoChange(index) {
      this.handleVideoSwitch(index, 1, this.dialogData.carveUrlList.length);
    },
    // 键盘左右箭头切换视频
    handleVideoKeyDown(event) {
      const length = this.dialogData.carveUrlList.length;
      const index = Number(this.videoSafeAreaViewer.index) || 0;

    // 视频查看器未渲染、视频列表为空、只有一个视频时,不执行切换事件
      if (!this.videoSafeAreaViewer.visible || !length || length === 1) {
        return;
      }

      if (event.key === 'ArrowRight') {
        this.handleVideoSwitch(index, 1, length);
      } else if (event.key === 'ArrowLeft') {
        this.handleVideoSwitch(index, -1, length);
      }
    },
    // 切换视频
    handleVideoSwitch(index, step, len) {
      const videoList = this.dialogData.carveUrlList || [];
      const idx = (index + step + len) % len;

      this.handleVideoPreview(videoList[idx], idx, this.dialogData.carveUrlList);
    },

视频加载提示

  1. 解决什么问题
    • 视频加载时,页面没有内容显示,用户对视频加载无感知
  2. 解决方案:在视频播放器容器中,增加提示块
    • 视频加载中,设置 isLoading: true,渲染提示块,提示内容:视频正在加载中,请稍后……
    • 视频加载失败,设置 loadingError: true,渲染提示块,提示内容:视频加载失败;增加el-icon-refresh-left图标,绑定click事件支持重新加载
    • 重新加载事件包括:1、重置预览数据,2、视频切源
    • 视频加载完成,提示块不可见,播放视频
  3. 实现效果
    • 视频加载中、加载失败提示,用户可感知视频加载进度
    • 加载失败时支持重新加载,避免因偶发网络原因导致的失败,用户无需刷新/退出就能再次尝试加载视频
  4. 部分代码
 <div class="video-player-wrapper" :style="getVideoBoxStyle">
        <!-- 视频加载提示 -->
        <div v-show="isLoading || loadingError" class="video-status-tip">
          <div class="status-content">
            <!-- 加载中 -->
            <div v-if="isLoading && !loadingError" class="loading-state">
              <i class="el-icon-loading status-icon"></i>
              <span class="status-text">视频正在加载中,请稍后...</span>
            </div>
            <!-- 加载失败 -->
            <div v-else-if="loadingError" class="error-state">
              <span class="status-text" style="color: #f56c6c"
                >视频加载失败<i class="el-icon-refresh-left" @click.stop="handleVideoReload"></i
              ></span>
            </div>
          </div>
        </div>
        <!-- 视频播放器 -->
        <video-player
          :key="`${index}-${viewerData.subLink}`"
          ref="videoPlayer"
          class="video-player"
          :options="playerOptions"
          @dblclick.native="toggleFullscreen"
          @waiting="handleVideoWaiting"
          @canplay="handleVideoCanPlay"
          @loadeddata="handleVideoLoadedData"
          @error="handleVideoError"
        />
      </div>

双击进入/退出全屏

  1. 解决什么问题:video-player组件未显示配置双击进入/退出全屏事件,需要手动绑定dbclick事件
  2. 解决方案:为视频播放器绑定dbclick事件
  3. 实现效果:非全屏状态下双击全屏播放,反之退出全屏状态
  4. 部分代码
  // 双击切换全屏
    toggleFullscreen() {
      const videoPlayer = this.$refs.videoPlayer;
      const player = videoPlayer && videoPlayer.player;

      if (!player) {
        console.warn('[MKVideoSafeAreaViewer] toggleFullscreen: player not ready');
        return;
      }

      if (player.isFullscreen()) {
        player.exitFullscreen();
      } else {
        player.requestFullscreen();
      }
    },

什么是"阻塞渲染"?如何避免 JavaScript 代码阻塞页面渲染?

作者 Smilezyl
2026年1月31日 15:24

什么是"阻塞渲染"?如何避免 JavaScript 代码阻塞页面渲染?

核心答案

阻塞渲染是指浏览器在解析 HTML 构建 DOM 树的过程中,遇到 <script> 标签时会暂停 DOM 解析,等待脚本下载并执行完毕后才继续解析。这是因为 JavaScript 可能会修改 DOM 结构(如 document.write),浏览器必须确保 DOM 的正确性。

避免阻塞的核心方法:

  1. 使用 async 属性:脚本异步下载,下载完立即执行
  2. 使用 defer 属性:脚本异步下载,DOM 解析完成后按顺序执行
  3. 将脚本放在 </body>
  4. 动态创建 script 标签

深入解析

浏览器渲染流程

HTML → DOM Tree
                  → Render Tree → Layout → Paint
CSS  → CSSOM

阻塞机制详解

1. JavaScript 阻塞 DOM 解析

HTML解析 → 遇到<script> → 暂停解析 → 下载JS → 执行JS → 继续解析

2. CSS 也会间接阻塞

  • CSS 本身不阻塞 DOM 解析,但阻塞渲染
  • 如果 JS 在 CSS 之后,JS 会等待 CSSOM 构建完成(因为 JS 可能访问样式)

async vs defer 的区别

特性 async defer
下载 异步,不阻塞解析 异步,不阻塞解析
执行时机 下载完立即执行 DOM 解析完成后执行
执行顺序 不保证顺序 保证顺序
适用场景 独立脚本(统计、广告) 有依赖关系的脚本

常见误区

  1. 误区:async 和 defer 可以同时使用

    • 实际:同时存在时,现代浏览器优先使用 async
  2. 误区:内联脚本可以使用 async/defer

    • 实际:async/defer 只对外部脚本有效
  3. 误区:放在 body 底部就不会阻塞

    • 实际:仍会阻塞,只是此时 DOM 已基本解析完成,影响较小

代码示例

<!-- 1. 阻塞渲染(默认行为) -->
<script src="app.js"></script>

<!-- 2. async:异步下载,下载完立即执行 -->
<script async src="analytics.js"></script>

<!-- 3. defer:异步下载,DOM 解析后按顺序执行 -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>  <!-- 保证在 vendor.js 之后执行 -->

<!-- 4. 动态加载脚本 -->
<script>
  const script = document.createElement('script');
  script.src = 'lazy-module.js';
  script.async = false; // 保证顺序执行
  document.body.appendChild(script);
</script>

<!-- 5. 模块脚本(默认 defer 行为) -->
<script type="module" src="app.mjs"></script>

<!-- 6. 预加载关键资源 -->
<link rel="preload" href="critical.js" as="script">

现代优化方案

// 使用 Intersection Observer 懒加载脚本
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const script = document.createElement('script');
      script.src = entry.target.dataset.src;
      document.body.appendChild(script);
      observer.unobserve(entry.target);
    }
  });
});

// 使用 requestIdleCallback 在空闲时加载
requestIdleCallback(() => {
  const script = document.createElement('script');
  script.src = 'non-critical.js';
  document.body.appendChild(script);
});

面试技巧

可能的追问方向

  1. "async 和 defer 的执行时机具体是什么?"

    • async:下载完成后立即执行,可能在 DOMContentLoaded 之前或之后
    • defer:在 DOMContentLoaded 事件之前执行
  2. "CSS 会阻塞 JS 执行吗?"

    • 会。如果 <script><link> 之后,JS 会等待 CSSOM 构建完成
  3. "如何检测和量化阻塞时间?"

    • Performance API、Lighthouse、Chrome DevTools Performance 面板
  4. "type="module" 的脚本有什么特点?"

    • 默认 defer 行为、严格模式、独立作用域、支持 import/export

展示深度的回答技巧

  • 提及浏览器的预解析器(Preload Scanner)会提前扫描并下载资源
  • 讨论 Critical Rendering Path 优化策略
  • 结合实际项目经验,如 Webpack 的代码分割、动态 import

一句话总结

JS 阻塞 DOM 解析是因为可能修改 DOM;用 defer 保顺序、async 求速度、动态加载最灵活。

在 HTML 中引入 JavaScript 有哪几种方式?它们各自的优缺点是什么?

作者 Smilezyl
2026年1月31日 15:22

在 HTML 中引入 JavaScript 有哪几种方式?它们各自的优缺点是什么?

核心答案

在 HTML 中引入 JavaScript 有 3 种主要方式

方式 语法 主要场景
1. 行内式 <div onclick="alert('hi')"> 极少使用,不推荐
2. 内嵌式 <script>alert('hi')</script> 小型脚本、单页应用
3. 外链式 <script src="app.js"></script> 生产环境首选

核心原则:生产环境优先使用外链式,配合 deferasync 优化加载性能。


深入解析

1. 三种方式详解

方式一:行内式(Inline)
<!-- 直接在 HTML 属性中写 JS -->
<button onclick="alert('点击了!')">点击我</button>
<a href="javascript:void(0)" onmouseover="console.log('悬停')">链接</a>

优点:

  • 快速测试、简单直观

缺点:

  • ❌ HTML 和 JS 强耦合,难以维护
  • ❌ 无法复用逻辑
  • ❌ 代码混乱,可读性差
  • ❌ 存在 XSS 安全风险
  • ❌ 无法利用浏览器缓存

方式二:内嵌式(Internal / Embedded)
<!DOCTYPE html>
<html>
<head>
    <script>
        // JS 代码写在 <script> 标签内
        function init() {
            console.log('页面初始化');
        }
    </script>
</head>
<body>
    <h1>内嵌式示例</h1>
</body>
</html>

优点:

  • ✓ 适合单页应用或小型项目
  • ✓ HTML 和 JS 在同一文件,便于调试
  • ✓ 可以访问页面中的所有元素

缺点:

  • ❌ HTML 文件体积变大
  • ❌ 无法被浏览器缓存(每次加载 HTML 都要重新加载 JS)
  • ❌ 多个页面无法共享同一份 JS 代码
  • ❌ 不符合关注点分离原则

方式三:外链式(External)⭐ 推荐
<!-- 基础用法 -->
<script src="js/app.js"></script>

<!-- 推荐用法:配合 defer -->
<script src="js/app.js" defer></script>

<!-- 或者 async(取决于场景) -->
<script src="js/analytics.js" async></script>

优点:

  • HTML 与 JS 分离,结构清晰
  • 可复用:多个页面共享同一个 JS 文件
  • 可缓存:浏览器缓存 JS 文件,提升加载速度
  • 便于维护:代码独立管理
  • 支持模块化:方便团队协作

缺点:

  • ⚠️ 需要额外的 HTTP 请求(但可通过缓存和打包优化)

2. <script> 标签的关键属性

deferasync 的区别
页面解析流程对比:

无属性(默认):
HTML解析 → 遇到script → 停止解析 → 下载JS → 执行JS → 继续解析HTML
                ↑ 阻塞页面渲染 ↑

defer:
HTML解析 → 并行下载JSHTML解析完成 → 按顺序执行JSDOMContentLoaded
            ↓ 不阻塞解析 ↓

async:
HTML解析 → 并行下载JS → 下载完立即执行 → 继续解析HTML
            ↓ 执行时机不确定 ↓
属性 执行时机 顺序保证 适用场景
无属性 立即执行,阻塞解析 ✅ 按顺序
defer HTML 解析完成后,DOMContentLoaded ✅ 按顺序 DOM 操作脚本
async 下载完成后立即执行 ❌ 无顺序保证 独立脚本(如统计、广告)
<!-- defer 推荐用法 -->
<script src="main.js" defer></script>
<script src="utils.js" defer></script>
<!-- 保证:utils.js 一定在 main.js 之前执行 -->

<!-- async 用法 -->
<script src="analytics.js" async></script>
<script src="ads.js" async></script>
<!-- 不保证执行顺序,谁先下载完谁先执行 -->
其他重要属性
属性 作用 示例
type 指定脚本类型 type="module"(ES 模块)
crossorigin CORS 配置 crossorigin="anonymous"
integrity SRI(子资源完整性校验) integrity="sha384-..."
nomodule 不支持模块的浏览器才执行 <script nomodule src="legacy.js"></script>

3. 底层机制:浏览器如何加载和执行脚本

┌─────────────────────────────────────────────────────────┐
│                    浏览器渲染流程                         │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  1. HTML Parser ──→ 构建 DOM 树                          │
│         ↓                                                 │
│  2. CSS Parser ──→ 构建 CSSOM 树                         │
│         ↓                                                 │
│  3. 合并 ──→ 渲染树(Render Tree)                        │
│         ↓                                                 │
│  4. Layout(布局)                                        │
│         ↓                                                 │
│  5. Paint(绘制)                                         │
│                                                          │
└─────────────────────────────────────────────────────────┘

遇到 <script> 时:

默认行为:
┌─────────┐
│ 停止解析 │ ← 阻塞 DOM 构建
└────┬────┘
     ↓
┌─────────┐
│ 下载 JS │ ← 如果是外链脚本
└────┬────┘
     ↓
┌─────────┐
│ 执行 JS │ ← 阻塞渲染
└────┬────┘
     ↓
┌─────────┐
│ 继续解析 │
└─────────┘

使用 defer/async:
┌─────────┐      ┌─────────┐
 │继续解析 │ ←→   │并行下载 │  ← 不阻塞
└─────────┘      └─────────┘

4. 最佳实践

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>最佳实践示例</title>

    <!-- CSS 放在 head 中 -->
    <link rel="stylesheet" href="styles.css">

    <!-- 预加载关键脚本 -->
    <link rel="preload" href="critical.js" as="script">
</head>
<body>
    <!-- 页面内容 -->

    <!-- 方案1:现代浏览器推荐 -->
    <script src="main.js" defer></script>
    <script src="app.js" defer></script>

    <!-- 方案2:需要立即执行的脚本(如 polyfill) -->
    <script>
        // 同步执行的小型脚本
    </script>

    <!-- 方案3:独立第三方脚本 -->
    <script src="analytics.js" async></script>

    <!-- 方案4:ES 模块 -->
    <script type="module" src="module.js"></script>

    <!-- 方案5:模块降级方案 -->
    <script type="module" src="modern.js"></script>
    <script nomodule src="legacy.js"></script>
</body>
</html>

5. 常见误区

误区1deferasync 功能一样

纠正defer 保证顺序且在 DOMContentLoaded 前执行,async 不保证顺序

误区2:把所有 <script> 都放在 <head>

纠正:传统放 </body> 前,现代用 defer 可放 head

误区3defer 的脚本一定在 DOMContentLoaded 前执行

纠正:大部分情况是的,但如果脚本很大或网络慢,可能在之后

误区4:多个 async 脚本按书写顺序执行

纠正async 脚本按下载完成顺序执行,顺序不可控


代码示例

示例1:三种引入方式对比

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>JS 引入方式对比</title>

    <!-- 方式1:行内式(不推荐) -->
    <button onclick="handleClick()">行内式按钮</button>

    <!-- 方式2:内嵌式 -->
    <script>
        function handleClick() {
            console.log('内嵌式函数被调用');
        }

        // 内嵌式可以直接操作页面
        document.addEventListener('DOMContentLoaded', function() {
            console.log('DOM 加载完成');
        });
    </script>

    <!-- 方式3:外链式(推荐) -->
    <script src="js/utils.js" defer></script>
</head>
<body>
    <h1>三种引入方式</h1>

    <!-- 行内式的完整示例 -->
    <div onmouseover="this.style.background='yellow'"
         onmouseout="this.style.background='white'">
        鼠标悬停变色
    </div>
</body>
</html>

示例2:defer vs async 实际效果

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>defer vs async</title>
</head>
<body>
    <h1>页面标题</h1>
    <p>内容...</p>

    <script>
        // 同步脚本:阻塞后续渲染
        console.log('1. 同步脚本开始');
        // 模拟耗时操作
        const start = Date.now();
        while (Date.now() - start < 2000) {}
        console.log('2. 同步脚本结束(阻塞了2秒)');
    </script>

    <p>这行内容被延迟显示了</p>

    <!-- defer 脚本 -->
    <script src="defer1.js" defer></script>
    <script src="defer2.js" defer></script>
    <!-- 保证:defer1.js 在 defer2.js 之前执行 -->

    <!-- async 脚本 -->
    <script src="async1.js" async></script>
    <script src="async2.js" async></script>
    <!-- 不保证:谁先下载完谁先执行 -->

    <script>
        document.addEventListener('DOMContentLoaded', function() {
            console.log('3. DOMContentLoaded 触发');
        });

        window.addEventListener('load', function() {
            console.log('4. 页面完全加载完成');
        });
    </script>
</body>
</html>

示例3:现代项目的标准引入方式

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>现代项目</title>

    <!-- 预连接到 CDN -->
    <link rel="preconnect" href="https://cdn.example.com">

    <!-- 预加载关键资源 -->
    <link rel="preload" href="critical.css" as="style">
    <link rel="preload" href="critical.js" as="script">

    <!-- 关键 CSS -->
    <link rel="stylesheet" href="critical.css">

    <!-- Polyfill:需要立即执行且不依赖 DOM -->
    <script>
        // 检测和添加必要的 polyfill
        if (!window.Promise) {
            document.write('<script src="polyfills/promise.js"><\/script>');
        }
    </script>
</head>
<body>
    <div id="app"></div>

    <!-- 主要应用脚本:使用 defer -->
    <script src="vendors.js" defer></script>
    <script src="main.js" defer></script>

    <!-- 第三方统计:使用 async -->
    <script src="analytics.js" async></script>

    <!-- ES 模块 + 降级方案 -->
    <script type="module" src="modern-app.js"></script>
    <script nomodule src="legacy-app.js"></script>
</body>
</html>

示例4:动态加载脚本

// 动态创建 script 标签
function loadScript(url, options = {}) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = url;

        // 设置属性
        if (options.async) script.async = true;
        if (options.defer) script.defer = true;
        if (options.type) script.type = options.type;

        // 事件监听
        script.onload = () => resolve(script);
        script.onerror = () => reject(new Error(`Failed to load ${url}`));

        document.head.appendChild(script);
    });
}

// 使用示例
async function initApp() {
    try {
        await loadScript('/utils.js', { defer: true });
        await loadScript('/main.js', { defer: true });
        console.log('所有脚本加载完成');
    } catch (error) {
        console.error('脚本加载失败:', error);
    }
}

// 条件加载
if ('IntersectionObserver' in window) {
    // 支持,加载现代版本
    loadScript('/modern-image-lazy-load.js');
} else {
    // 不支持,加载 polyfill
    loadScript('/polyfills/intersection-observer.js')
        .then(() => loadScript('/legacy-image-lazy-load.js'));
}

面试技巧

面试官可能的追问

  1. "为什么传统建议把 <script> 放在 </body> 之前?"

    • 避免阻塞页面渲染,让用户先看到内容
  2. "现在有了 defer,还需要放 </body> 前吗?"

    • 不需要,defer 可放 head,效果相同且更早开始下载
  3. "什么情况下用 async?"

    • 独立脚本:统计代码、广告脚本、不依赖其他代码的库
  4. "多个 defer 脚本的执行顺序?"

    • 按在 HTML 中的出现顺序执行
  5. "deferDOMContentLoaded 的关系?"

    • defer 脚本在 DOMContentLoaded 之前执行
  6. "什么是脚本阻塞(render blocking)?"

    • 解释浏览器解析 HTML 时遇到 <script> 停止渲染的机制

如何展示深度理解

  1. 谈性能优化

    • 关键渲染路径优化
    • 资源预加载(preload/prefetch)
    • 代码分割(code splitting)
  2. 谈实际项目经验

    • 如何处理第三方脚本(如 Google Analytics)
    • 如何优化首屏加载时间
    • 使用过哪些构建工具的优化
  3. 谈浏览器兼容性

    • defer/async 的浏览器支持情况
    • 如何为老浏览器做降级处理
  4. 谈安全

    • SRI(Subresource Integrity)
    • nonce/CSP(Content Security Policy)

一句话总结

外链式 + defer 是现代网页引入 JavaScript 的最佳实践,它实现了代码分离、可缓存、不阻塞渲染的完美平衡。

type-challenges(ts类型体操): 15 - 最后一个元素

作者 fxss
2026年1月31日 13:49

15 - 最后一个元素

by Anthony Fu (@antfu) #中等 #array

题目

在此挑战中建议使用TypeScript 4.0

实现一个Last<T>泛型,它接受一个数组T并返回其最后一个元素的类型。

例如

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type tail1 = Last<arr1> // 应推导出 'c'
type tail2 = Last<arr2> // 应推导出 1

在 Github 上查看:tsch.js.org/15/zh-CN

代码

/* _____________ 你的代码 _____________ */

type Last<T extends any[]> = T extends [...infer _, infer R] ? R : never

关键解释:

  • T extends [...infer _, infer R]:通过 infer 提取数组的最后一个元素 R
  • ? R : never:如果数组非空,返回 R;否则返回 never

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

infer

infer 是 TypeScript 在条件类型中提供的关键字,用于声明一个 待推导的类型变量(类似给类型起一个临时名字),只能在 extends 子句中使用。它的核心作用是:从已有类型中提取 / 推导我们需要的部分,而无需手动硬编码类型。

infer 必须配合条件类型使用,语法结构如下:

// 基础结构:推导 T 的类型为 U,若能推导则返回 U,否则返回 never
type InferType<T> = T extends infer U ? U : never;

type Example = InferType<string>; // Example 类型为 string
type Example2 = InferType<number[]>; // Example2 类型为 number[]

高频使用场景:

1. 提取函数的返回值类型
// 定义类型工具:提取函数的返回值类型
type GetReturnType<Fn> = Fn extends (...args: any[]) => infer R ? R : never;

// 测试用函数
const add = (a: number, b: number): number => a + b;
const getUser = () => ({ name: "张三", age: 20 });

// 使用类型工具
type AddReturn = GetReturnType<typeof add>; // AddReturn 类型为 number
type UserReturn = GetReturnType<typeof getUser>; // UserReturn 类型为 { name: string; age: number }
2. 提取数组的元素类型
// 定义类型工具:提取数组元素类型
type GetArrayItem<T> = T extends (infer Item)[] ? Item : never;

// 测试
type NumberArray = GetArrayItem<number[]>; // NumberArray 类型为 number
type StringArray = GetArrayItem<string[]>; // StringArray 类型为 string
type MixedArray = GetArrayItem<[string, number]>; // MixedArray 类型为 string | number
3. 提取 Promise 的泛型参数类型
// 定义类型工具:提取 Promise 的泛型类型
type GetPromiseValue<T> = T extends Promise<infer Value> ? Value : never;

// 测试
type PromiseString = GetPromiseValue<Promise<string>>; // PromiseString 类型为 string
type PromiseUser = GetPromiseValue<Promise<{ id: number }>>; // PromiseUser 类型为 { id: number }
4. 提取函数的参数类型
// 定义类型工具:提取函数参数类型
type GetFunctionParams<Fn> = Fn extends (...args: infer Params) => any ? Params : never;

// 测试
const fn = (name: string, age: number): void => {};
type FnParams = GetFunctionParams<typeof fn>; // FnParams 类型为 [string, number]

// 进一步:提取第一个参数的类型
type FirstParam = GetFunctionParams<typeof fn>[0]; // FirstParam 类型为 string

never

never 表示永不存在的类型

  1. 没有任何类型能赋值给 never(除了 never 自身);
  2. never 可以赋值给任意类型(因为它是所有类型的子类型);
  3. 不会有任何实际值属于 never 类型。
let n: never;
let num: number = 123;
let u: unknown = "hello";
let v: void = undefined;

// 1. 任何类型都不能赋值给 never(除了自身)
n = num;   // ❌ 报错:number 不能赋值给 never
n = u;     // ❌ 报错:unknown 不能赋值给 never
n = v;     // ❌ 报错:void 不能赋值给 never
n = undefined; // ❌ 报错:undefined 也不行
n = n;     // ✅ 仅自身可赋值

// 2. never 可以赋值给任意类型
num = n;   // ✅ 正常
u = n;     // ✅ 正常
v = n;     // ✅ 正常
  1. 泛型的边界约束: 通过泛型约束让不满足条件的泛型类型变为 never,从而达到限制类型范围的目的。
// 定义泛型:仅允许 T 为 string 类型,否则 T 为 never
type OnlyString<T> = T extends string ? T : never;

// 满足条件:T 为 string,结果正常
type Str1 = OnlyString<"hello">; // Str1 = "hello"
type Str2 = OnlyString<string>;  // Str2 = string

// 不满足条件:T 为非 string,结果为 never
type Num = OnlyString<number>;   // Num = never
type Bool = OnlyString<boolean>; // Bool = never
type Unk = OnlyString<unknown>;  // Unk = never

// 实际使用:强制函数参数只能是 string 类型
function printStr<T>(val: OnlyString<T>) {
  console.log(val);
}

printStr("hello"); // ✅ 正常
printStr(123);     // ❌ 报错:number 不能赋值给 never

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<Last<[]>, never>>,
  Expect<Equal<Last<[2]>, 2>>,
  Expect<Equal<Last<[3, 2, 1]>, 1>>,
  Expect<Equal<Last<[() => 123, { a: string }]>, { a: string }>>,
]

相关链接

分享你的解答:tsch.js.org/15/answer/z… 查看解答:tsch.js.org/15/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

type-challenges(ts类型体操): 14 - 第一个元素

作者 fxss
2026年1月31日 13:44

14 - 第一个元素

by Anthony Fu (@antfu) #简单 #array

题目

实现一个First<T>泛型,它接受一个数组T并返回它的第一个元素的类型。

例如:

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type head1 = First<arr1> // 应推导出 'a'
type head2 = First<arr2> // 应推导出 3

在 Github 上查看:tsch.js.org/14/zh-CN

代码

/* _____________ 你的代码 _____________ */

type First<T extends unknown[]> = T extends [] ? never : T[0]

关键解释:

  1. T extends unknown[] 用于约束 T 必须是一个数组类型。
  2. T extends [] 用于判断数组是否为空。
  3. T[0] 用于获取数组的第一个元素。
  4. never 用于表示空数组的情况。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

unknown

作用是替代 any 处理 类型未知 的场景,同时保证类型检查的安全性。

  1. 所有类型(基本类型、对象、函数、数组等)都可以赋值给 unknown 类型的变量;

  2. unknown 类型的变量不能随意赋值给其他类型(仅能赋值给 unknownany);

  3. 也不能直接操作 unknown 类型的变量(比如调用方法、访问属性、做算术运算),必须先通过类型收窄确定其具体类型,这是它比 any 安全的关键。

  4. any 的区别

any任意类型,会关闭 TypeScript 的类型检查,而 unknown未知类型,保留类型检查,仅允许在确定类型后操作。两者的规则对比如下:

规则 unknown any
所有类型可赋值给它 ✅ 支持 ✅ 支持
它可赋值给其他类型 ❌ 仅能赋值给 unknown/any ✅ 可赋值给任意类型(无限制)
直接操作变量(调用方法 / 访问属性) ❌ 不允许(必须类型收窄) ✅ 允许(关闭类型检查)
// 1. 所有类型都能赋值给 unknown/any
let u: unknown = 123;
u = "hello";
u = [1,2,3];

let a: any = 123;
a = "hello";
a = [1,2,3];

// 2. unknown 仅能赋值给 unknown/any(赋值给其他类型报错)
let num: number = u; // ❌ 报错:Type 'unknown' is not assignable to type 'number'
let u2: unknown = u; // ✅ 正常
let a2: any = u;     // ✅ 正常

// any 可赋值给任意类型(无报错,即使类型不匹配)
let num2: number = a; // ✅ 无报错(但运行时可能出问题,类型不安全)

// 3. 直接操作 unknown 报错,操作 any 无限制
u.toFixed(); // ❌ 报错:Object is of type 'unknown'
a.toFixed(); // ✅ 无报错(即使 a 可能是字符串,TS 不检查)
  1. 类型收窄

2.1 typeof检查(适用于基本类型:number/string/boolean/undefined/null/symbol/bigint

function handleUnknown(val: unknown) {
  // 先通过 typeof 收窄为数字类型
  if (typeof val === "number") {
    console.log(val.toFixed(2)); // ✅ 正常:val 已确定是 number
  }
  // 收窄为字符串类型
  else if (typeof val === "string") {
    console.log(val.toUpperCase()); // ✅ 正常:val 已确定是 string
  }
  // 收窄为布尔类型
  else if (typeof val === "boolean") {
    console.log(val ? "真" : "假"); // ✅ 正常:val 已确定是 boolean
  }
}

handleUnknown(123.456); // 输出 123.46
handleUnknown("hello"); // 输出 HELLO
handleUnknown(true);    // 输出 真

2.2 instanceof检查(适用于引用类型:数组 / 类实例 / RegExp/Date 等)

function handleUnknown2(val: unknown) {
  // 收窄为数组类型
  if (val instanceof Array) {
    console.log(val.push(4)); // ✅ 正常:val 已确定是 Array
  }
  // 收窄为 Date 类型
  else if (val instanceof Date) {
    console.log(val.toLocaleString()); // ✅ 正常:val 已确定是 Date
  }
}

handleUnknown2([1,2,3]); // 输出 4(数组长度)
handleUnknown2(new Date()); // 输出当前时间字符串

2.3 类型断言

let u: unknown = "这是一个字符串";

// 断言为 string 类型后操作
let str = u as string;
console.log(str.length); // ✅ 正常:输出 7

// 错误断言(运行时报错)
let num = u as number;
console.log(num.toFixed()); // ❌ 运行时报错:num.toFixed is not a function

never

never 表示永不存在的类型

  1. 没有任何类型能赋值给 never(除了 never 自身);
  2. never 可以赋值给任意类型(因为它是所有类型的子类型);
  3. 不会有任何实际值属于 never 类型。
let n: never;
let num: number = 123;
let u: unknown = "hello";
let v: void = undefined;

// 1. 任何类型都不能赋值给 never(除了自身)
n = num;   // ❌ 报错:number 不能赋值给 never
n = u;     // ❌ 报错:unknown 不能赋值给 never
n = v;     // ❌ 报错:void 不能赋值给 never
n = undefined; // ❌ 报错:undefined 也不行
n = n;     // ✅ 仅自身可赋值

// 2. never 可以赋值给任意类型
num = n;   // ✅ 正常
u = n;     // ✅ 正常
v = n;     // ✅ 正常
  1. 泛型的边界约束: 通过泛型约束让不满足条件的泛型类型变为 never,从而达到限制类型范围的目的。
// 定义泛型:仅允许 T 为 string 类型,否则 T 为 never
type OnlyString<T> = T extends string ? T : never;

// 满足条件:T 为 string,结果正常
type Str1 = OnlyString<"hello">; // Str1 = "hello"
type Str2 = OnlyString<string>;  // Str2 = string

// 不满足条件:T 为非 string,结果为 never
type Num = OnlyString<number>;   // Num = never
type Bool = OnlyString<boolean>; // Bool = never
type Unk = OnlyString<unknown>;  // Unk = never

// 实际使用:强制函数参数只能是 string 类型
function printStr<T>(val: OnlyString<T>) {
  console.log(val);
}

printStr("hello"); // ✅ 正常
printStr(123);     // ❌ 报错:number 不能赋值给 never

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<First<[3, 2, 1]>, 3>>,
  Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<First<[undefined]>, undefined>>,
]

type errors = [
  // @ts-expect-error
  First<'notArray'>,
  // @ts-expect-error
  First<{ 0: 'arrayLike' }>,
]

相关链接

分享你的解答:tsch.js.org/14/answer/z… 查看解答:tsch.js.org/14/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

Vue-组件通信全攻略

2026年1月31日 13:38

前言

在 Vue 开发中,组件通信是构建复杂应用的基础。随着 Vue 3 的普及,通信方式发生了不少变化(如 defineProps 的引入、EventBus 的退场)。本文将对比 Vue 2 与 Vue 3,带你梳理最常用的 5 种通信方案。

一、 父子组件通信:最基础的单向数据流

这是最常用的通信方式,遵循“Props 向下传递,Emit 向上通知”的原则。

1. Vue 2 经典写法

  • 接收:使用 props 选项。
  • 发送:使用 this.$emit

2. Vue 3 + TS 标准写法

在 Vue 3 <script setup> 中,我们使用 definePropsdefineEmits

父组件:Parent.vue

<template>
  <ChildComponent :id="currentId" @childEvent="handleChild" />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import ChildComponent from './Child.vue';

const currentId = ref<string>('001');
const handleChild = (msg: string) => {
  console.log('接收到子组件消息:', msg);
};
</script>

子组件:Child.vue

<script setup lang="ts">
// 使用 TS 类型定义 Props
const props = defineProps<{
  id: string
}>();

// 使用 TS 定义 Emits,具备更好的类型检查
const emit = defineEmits<{
  (e: 'childEvent', args: string): void;
}>();

const sendMessage = () => {
  emit('childEvent', '这是来自子组件的参数');
};
</script>

二、 跨级调用:通过 Ref 访问实例

有时父组件需要直接调用子组件的内部方法。

1. Vue 2 模式

直接通过 this.$refs.childRef.someMethod() 调用。

2. Vue 3 模式(显式暴露)

Vue 3 的组件默认是关闭的。如果父组件想访问子组件的方法,子组件必须使用 defineExpose

子组件:Child.vue

<script setup lang="ts">
const childFunc = () => {
  console.log('子组件方法被调用');
};

// 必须手动暴露,父组件才能访问
defineExpose({
  childFunc
});
</script>

父组件:Parent.vue

<template>
  <Child ref="childRef" />
</template>

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

// 这里的类型定义有助于获得代码提示
const childRef = ref<InstanceType<typeof Child> | null>(null);

onMounted(() => {
  childRef.value?.childFunc();
});
</script>

三、 非父子组件通信:事件总线 (EventBus)

1. Vue 2 做法

利用一个新的 Vue 实例作为中央调度器。

import Vue from 'vue';
export const EventBus = new Vue();

// 组件 A 发送
EventBus.$emit('event', data);
// 组件 B 接收
EventBus.$on('event', (data) => { ... });

2. Vue 3 重要变更

Vue 3 官方已移除了 $on$off$once 方法,因此不再支持直接通过 Vue 实例创建 EventBus。

  • 官方推荐方案:使用第三方库 mitttiny-emitter
  • 补充:如果逻辑简单,可以使用 Vue 3 的 provide / inject 实现跨级通信。

provide / inject 示例:

  1. 祖先组件:提供数据 (App.vue)
<template>
  <div class="ancestor">
    <h1>祖先组件</h1>
    <p>当前主题:{{ theme }}</p>
    <Middle />
  </div>
</template>

<script setup lang="ts">
import { ref, provide } from 'vue';
import Middle from './Middle.vue';

// 1. 定义响应式数据
const theme = ref<'light' | 'dark'>('light');

// 2. 定义修改数据的方法(推荐在提供者内部定义,保证数据流向清晰)
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light';
};

// 3. 注入 key 和对应的值/方法
provide('theme', theme);
provide('toggleTheme', toggleTheme);
</script>
  1. 中间组件:无需操作 (Middle.vue)

    中间组件不需要显式接收 theme,直接透传即可

  2. 后代组件:注入并使用 (DeepChild.vue)

<template>
  <div class="descendant">
    <h3>深层子组件</h3>
    <p>接收到的主题:{{ theme }}</p>
    <button @click="toggleTheme">切换主题</button>
  </div>
</template>

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

// 使用 inject 获取,第二个参数为默认值(可选)
const theme = inject('theme');
const toggleTheme = inject<() => void>('toggleTheme');
</script>

四、 集中式状态管理:Vuex 与 Pinia

当应用变得庞大,组件间的关系交织成网时,我们需要一个“单一事实来源”。

  • Vuex:Vue 2 时代的标准。基于 Mutation(同步)和 Action(异步)。

  • Pinia:Vue 3 的官方推荐。

    • 优势:更完美的 TS 支持、没有 Mutation 的繁琐逻辑、极其轻量。
    • 核心stategettersactions

Pinia 示例:

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    age: 18
  }),
  actions: {
    updateName(newName: string) {
      this.name = newName;
    }
  }
});

五、 总结与纠错

  1. 安全性建议:在使用 defineExpose 时,尽量只暴露必要的接口,遵循最小暴露原则。
  2. EventBus 警示:Vue 3 开发者请注意,不要再尝试使用 new Vue() 来做事件总线,应当转向 Pinia 或全局状态。

type-challenges(ts类型体操): 12 - 可串联构造器

作者 fxss
2026年1月31日 13:38

12 - 可串联构造器

by Anthony Fu (@antfu) #中等 #application

题目

在 JavaScript 中我们经常会使用可串联(Chainable/Pipeline)的函数构造一个对象,但在 TypeScript 中,你能合理的给它赋上类型吗?

在这个挑战中,你可以使用任意你喜欢的方式实现这个类型 - Interface, Type 或 Class 都行。你需要提供两个函数 option(key, value)get()。在 option 中你需要使用提供的 key 和 value 扩展当前的对象类型,通过 get 获取最终结果。

例如

declare const config: Chainable

const result = config
  .option('foo', 123)
  .option('name', 'type-challenges')
  .option('bar', { value: 'Hello World' })
  .get()

// 期望 result 的类型是:
interface Result {
  foo: number
  name: string
  bar: {
    value: string
  }
}

你只需要在类型层面实现这个功能 - 不需要实现任何 TS/JS 的实际逻辑。

你可以假设 key 只接受字符串而 value 接受任何类型,你只需要暴露它传递的类型而不需要进行任何处理。同样的 key 只会被使用一次。

在 Github 上查看:tsch.js.org/12/zh-CN

代码

/* _____________ 你的代码 _____________ */

/**
 * 定义可串联构造器的类型
 * @template T 当前构造器对象的状态,默认为空对象
 */
type Chainable<T = {}> = {
  /**
   * 用于扩展或修改当前对象的方法
   * @template K 要添加或修改的键,必须是字符串类型
   * @template V 要添加或修改的值的类型
   * @param key 要添加或修改的键,根据情况可能为 never 或 K
   * @param value 要添加或修改的值
   * @returns 一个新的 Chainable 实例,包含更新后的对象状态
   */
  option: <K extends string, V>(key: K extends keyof T ? V extends T[K] ? never : K : K, value: V) => Chainable<Omit<T, K> & Record<K, V>>
  /**
   * 获取当前构造器对象的最终状态
   * @returns 当前对象的状态
   */
  get(): T
}

关键解释:

  • Chainable<T>:泛型参数,代表当前构造器对象的状态,默认为空对象;
  • option(key, value):方法,用于扩展或修改当前对象的状态;
    • K extends string:约束 K 必须是字符串类型;
    • V:要添加或修改的值的类型;
    • key: K extends keyof T ? V extends T[K] ? never : K : K:约束 key 必须是 T 中不存在的属性名,或者 value 类型与 T[K] 不同的属性名;
    • value: V:要添加或修改的值;
    • Chainable<Omit<T, K> & Record<K, V>>:返回一个新的 Chainable 实例,包含更新后的对象状态;
  • get():方法,用于获取当前构造器对象的最终状态;
    • T:当前构造器对象的状态。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

keyof

keyof 运算符用于获取一个类型(接口、类型别名、对象类型等)的所有公共属性名,并返回这些属性名组成的联合类型。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoKeys = keyof Todo // "title" | "description" | "completed"

Omit

Omit<T, K> 用于从类型 T 中排除 K 中的属性,返回一个新类型。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = Omit<Todo, 'description'>
// TodoPreview 类型为:
// {
//   title: string
//   completed: boolean
// }

&

& 交叉类型运算符用于将多个类型合并为一个新类型,它会将所有属性合并到新类型中。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = Omit<Todo, 'description'> & {
  time: Date
}
// TodoPreview 类型为:
// {
//   title: string
//   completed: boolean
//   time: Date
// }

基础类型的交叉,只有类型完全一致时才会保留原类型,类型不一致时会得到 never

type A = number & string // never
type B = number & boolean // never
type C = number & symbol // never
type D = string & boolean // never
type E = string & symbol // never
type F = boolean & symbol // never

同名属性的类型冲突时,会得到 never

interface A {
  x: string; // 同名属性,类型 string
}
interface B {
  x: number; // 同名属性,类型 number
}

type C = A & B;
// C 的 x 类型为 string & number → never
const c: C = {
  x: 123, // 报错:类型 number 不能赋值给 never
  x: "abc" // 同样报错
};

Record

Record<K, T> 是用于定义键值对结构对象类型,能快速指定对象的键类型和值的统一类型。

第一个参数 K(键类型):必须是 string | number | symbol 及其子类型(比如字符串字面量、数字字面量、联合类型),否则会报错。 第二个参数 T(值类型):可以是任意类型(基础类型、对象类型、函数类型等)。

  1. 字符串键 + 基础类型值
// 用 Record 定义:键是 string,值是 number
type ScoreMap = Record<string, number>;
// 等价于手动写索引签名:{ [key: string]: number }
type ScoreMap2 = { [key: string]: number };

// 正确使用:所有键的值必须是数字
const studentScores: ScoreMap = {
  2: 90, // 数字字面量键会自动转为字符串,合法
  "李四": 85,
  wangwu: 95
};
  1. 字面量联合键 + 基础类型值:用字符串 / 数字字面量联合类型作为键,定义固定键、统一值类型的映射表(如状态码、枚举映射、地区编码),TS 会严格校验键的合法性(只能是联合类型中的值)
// 固定键:联合类型(字符串字面量)
type UserRole = "admin" | "editor" | "visitor";
// Record 定义:键只能是 UserRole 中的值,值是 string(角色描述)
type RoleDesc = Record<UserRole, string>;

// 正确使用:必须包含所有固定键,值为字符串
const roleDescription: RoleDesc = {
  admin: "超级管理员,拥有所有权限",
  editor: "内容编辑,可修改文章",
  visitor: "游客,仅可查看内容"
};

// 错误示例1:缺少键(editor)→ TS 报错
const err1: RoleDesc = { admin: "xxx", visitor: "xxx" };
// 错误示例2:多余键(test)→ TS 报错
const err2: RoleDesc = { admin: "xxx", editor: "xxx", visitor: "xxx", test: "xxx" };
// 错误示例3:值类型错误(数字)→ TS 报错
const err3: RoleDesc = { admin: 123, editor: "xxx", visitor: "xxx" };

Record + Partial → 固定键,值类型可选(部分赋值)

type UserRole = "admin" | "editor" | "visitor";
// 需求:固定角色键,允许部分赋值(不是所有角色都需要写描述)
type PartialRoleDesc = Partial<Record<UserRole, string>>;

// 正确使用:可包含任意数量的键(0个、1个、多个、全部)
const emptyDesc: PartialRoleDesc = {}; // 正常
const partialDesc: PartialRoleDesc = { admin: "超级管理员" }; // 正常
const fullDesc: PartialRoleDesc = { admin: "xxx", editor: "xxx", visitor: "xxx" }; // 正常

测试用例

/* _____________ 测试用例 _____________ */
import type { Alike, Expect } from '@type-challenges/utils'

declare const a: Chainable

const result1 = a
  .option('foo', 123)
  .option('bar', { value: 'Hello World' })
  .option('name', 'type-challenges')
  .get()

const result2 = a
  .option('name', 'another name')
  // @ts-expect-error
  .option('name', 'last name')
  .get()

const result3 = a
  .option('name', 'another name')
  .option('name', 123)
  .get()

type cases = [
  Expect<Alike<typeof result1, Expected1>>,
  Expect<Alike<typeof result2, Expected2>>,
  Expect<Alike<typeof result3, Expected3>>,
]

type Expected1 = {
  foo: number
  bar: {
    value: string
  }
  name: string
}

type Expected2 = {
  name: string
}

type Expected3 = {
  name: number
}

相关链接

分享你的解答:tsch.js.org/12/answer/z… 查看解答:tsch.js.org/12/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

《实时渲染》第2章-图形渲染管线-2.5像素处理

作者 charlee44
2026年1月31日 13:33

实时渲染

2. 图形渲染管线

2.5 像素处理

这个阶段是所有先前阶段组合的结果,并且已经找到了在三角形或其他图元内被考虑的所有像素。像素处理阶段分为像素着色和合并,如图2.8右侧所示。像素处理是对图元内部的像素或样本执行逐像素或逐样本计算和操作的阶段。

2.5.1 像素着色

此处执行的任何逐像素着色计算,是使用内插着色数据作为输入的。最终结果是将一种或多种颜色传递到下一阶段。与通常由专用的,硬连线的芯片执行的三角形设置和遍历阶段不同,像素着色阶段由可编程GPU内核执行。为此,程序员为像素着色器(或在OpenGL中称为片元着色器)提供了一个程序,该程序可以包含任何所需的计算。这里可以使用多种技术,其中最重要的一种是纹理贴图。纹理在第6章中进行了更详细的论述。简单地说,纹理对象意味着将一个或多个图像“粘合”到该对象上,用于各种目的。图2.9描述了此过程的一个简单示例。图像可以是一维、二维或三维,其中二维图像最为常见。最简单的情况是,最终产品是每个片元的颜色值,这些值被传递到下一个子阶段。

图2.9. 没有纹理的龙模型显示在左上角。图像纹理中的片段“粘”在龙上,结果显示在左下角。

2.5.2 合并

每个像素的信息存储在颜色缓冲区中,它是一个矩形的颜色数组(每种颜色具有红色、绿色和蓝色分量)。合并阶段的职责是将像素着色阶段产生的片元颜色与当前存储在缓冲区中的颜色相结合。此阶段也称为ROP,表示“(管线)光栅操作”或“渲染输出单元”,具体取决于你访问的对象。与着色阶段不同,执行此阶段的GPU子单元通常不是完全可编程的。但是,它是高度可配置的,可以实现各种效果。

此阶段还负责解决可见性问题。这意味着当整个场景被渲染后,颜色缓冲区应该包含场景中从相机的角度可见的图元的颜色。对于大多数甚至所有图形硬件,这是通过z缓冲区(也称为深度缓冲区)算法完成的[238]。z缓冲区的大小和形状与颜色缓冲区相同,并且对于每个像素,它将z值存储到当前最接近的图元。这意味着当一个图元被渲染到某个像素时,该图元在该像素上的z值被计算并与同一像素的z缓冲区的内容进行比较。如果新的z值小于z缓冲区中的 z 值,则正在渲染的图元比之前在该像素处最靠近相机的图元更靠近相机。因此,该像素的z值和颜色将使用正在绘制的图元的z值和颜色进行更新。如果计算出的z值大于z缓冲区中的z值,则颜色缓冲区和z缓冲区保持不变。z缓冲区算法很简单,具有O(n)收敛性(其中n是正在渲染的图元数量),并且可以适用于为每个(相关)像素计算z值的任何绘图图元。另请注意,该算法允许以任何顺序呈现大多数图元,这是其流行的另一个原因。但是,z 缓冲区仅在屏幕上的每个点存储单个深度,因此它不能用于部分透明的图元。透明图元必须在所有不透明基元之后渲染,并以从后到前的顺序呈现,或使用单独的与顺序无关的算法(第5.5节)。透明度是基本z缓冲区算法的主要弱点之一。

我们已经提到颜色缓冲区用于存储颜色,而z缓冲区存储每个像素的z值。但是,还有其他通道和缓冲区可用于过滤和捕获片元信息。Alpha通道与颜色缓冲区相关联,并为每个像素存储相关的不透明度值(第5.5节)。在较旧的API中,alpha通道还用于通过alpha测试功能有选择地丢弃像素。如今,可以将丢弃操作插入到像素着色器程序中,并且可以使用任何类型的计算来触发丢弃。此类测试可用于确保完全透明的片段不会影响z缓冲区(第6.6节)。

模板缓冲区是一个离屏缓冲区,用于记录渲染图元的位置。它通常包含每像素 8 位。可以使用各种函数将图元渲染到模板缓冲区中,然后可以使用缓冲区的内容来控制渲染到颜色缓冲区和z缓冲区中。例如,假设一个实心圆已被绘制到模板缓冲区中。这可以与允许将后续图元渲染到仅存在圆圈的颜色缓冲区中的运算符结合使用。模板缓冲区可以成为生成某些特殊效果的强大工具。管线末端的所有这些功能都称为光栅操作 (ROP) 或混合操作。可以将当前在颜色缓冲区中的颜色与三角形内正在处理的像素的颜色混合。这可以启用诸如透明度或颜色样本累积等效果。如前所述,混合操作通常可以使用API进行配置,而不是完全可编程的。但是,某些API支持光栅顺序视图,也称为像素着色器排序,可实现可编程混合功能。

帧缓冲区通常由系统上的所有缓冲区组成。

当图元到达并通过光栅化阶段时,从相机的角度上看,这些可见的图元将会显示在屏幕上。屏幕显示颜色缓冲区的内容。为了避免让人类观察者在被光栅化并发送到屏幕时看到图元,使用了双缓冲。这意味着场景的渲染发生在屏幕外的后台缓冲区中。在后台缓冲区中渲染场景后,后台缓冲区的内容将与之前显示在屏幕上的前台缓冲区的内容交换。交换通常发生在垂直重描期间,这是安全的时候。

有关不同缓冲区和缓冲方法的更多信息,请参阅第5.4.223.623.7节。

纯 CSS 实现拟人化亲吻动画:布局居中与关键帧协同设计详解

2026年1月31日 12:30

纯 CSS 实现拟人化亲吻动画:布局居中与关键帧协同设计详解

在现代 Web 开发中,CSS 不仅用于样式控制,更成为实现丰富交互动画的重要工具。本文将深入剖析一段精巧的纯 CSS 动画代码——一个由两个圆形头像组成的“亲吻”互动场景。该动画无需 JavaScript,仅通过 CSS 布局、伪元素和 @keyframes 关键帧即可实现生动拟人效果。我们将从居中布局策略动画分层设计逻辑以及关键帧协同机制三大维度展开技术解析,揭示其背后的设计哲学与工程技巧。


一、精准居中:两种主流布局方法对比

动画容器 .container 需要精确位于视口中央,这是视觉表现的基础。当前代码采用的是经典的 绝对定位 + transform 平移法

.container {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

1.1 绝对定位 + Transform 法(当前方案)

此方法的核心在于:

  • 利用 top: 50%left: 50% 将元素左上角锚定在视口中心;
  • 再通过 transform: translate(-50%, -50%) 将元素自身向左、向上平移其宽高的一半,从而实现几何中心对齐。

优势

  • 兼容性极佳(IE9+ 支持 transform);
  • 适用于任意尺寸元素,无需预知宽高;
  • 不影响文档流,适合叠加层或独立组件。

局限

  • 若父容器非视口(如嵌套在其他定位元素中),需确保定位上下文正确;
  • 在极端缩放或高 DPI 屏幕下可能出现亚像素渲染偏差(通常可忽略)。

1.2 Flexbox 居中法(现代替代方案)

作为对比,现代开发更推荐使用 Flexbox:

body {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  margin: 0;
}
.container {
  /* 无需额外定位 */
}

优势

  • 语义清晰,代码简洁;
  • 自动响应容器尺寸变化;
  • 支持多子元素对齐(如需扩展)。

适用场景

  • 主体内容居中;
  • 响应式布局项目。

结论:本例选择绝对定位法,因其轻量、独立,且不依赖修改 body 布局,符合“组件化”思维——动画模块可无缝嵌入任何页面。


二、动画架构:分层角色与时间轴编排

整个动画时长为 4 秒,循环播放(animation: ... infinite)。设计者将动画拆解为多个角色(左侧球、右侧球、眼睛、嘴巴、亲吻印记),每个角色拥有独立但协同的动画轨道,形成完整的叙事节奏:

时间段 动作描述 触发元素
0–0.8s 左脸靠近 #l-ball 右移
0.8–1.4s 停留等待 #l-ball 静止
1.4–2.2s 左脸返回 #l-ball 左移
2.0s 右脸惊讶后退 #r-ball 右移 + 旋转
2.4s 右脸猛冲亲吻 #r-ball 左移33px
2.64s 亲吻印记闪现 .kiss-m opacity=1
2.64s 嘴巴暂时隐藏 .mouth-r opacity=0

这种分镜式时间轴设计,使得静态图形具备了“情绪”与“故事性”。


三、关键帧动画详解

3.1 左侧球体:主动靠近(close 动画)

@keyframes close {
  0%   { transform: translateX(0); }
  20%  { transform: translateX(20px); }
  35%  { transform: translateX(20px); }
  55%  { transform: translateX(0); }
  100% { transform: translateX(0); }
}
  • 0–20%(0–0.8s):平滑右移 20px,模拟靠近;
  • 20–35%(0.8–1.4s):保持位置,制造“凝视”停顿;
  • 35–55%(1.4–2.2s):缓慢返回,体现犹豫或试探。

配合 ease 缓动函数,运动符合“启动快、停止缓”的自然惯性。

3.2 左侧脸部细节:头部微倾(face 动画)

@keyframes face {
  20%  { transform: translateX(5px) rotate(-2deg); }
  28%  { transform: none; }
  35%  { transform: translateX(5px) rotate(-2deg); }
  50%  { transform: none; }
}

在靠近瞬间(20% 和 35%),头部轻微右倾(rotate(-2deg) 表示顺时针旋转),模拟人类“歪头示好”的微表情,极大增强拟人感。

3.3 右侧球体:戏剧性回应(kiss 动画)

@keyframes kiss {
  40% { transform: none; }
  50% { transform: translateX(30px) rotate(20deg); } /* 惊讶后退 */
  60% { transform: translateX(-33px); }              /* 猛冲亲吻 */
  67% { transform: translateX(-33px); }              /* 保持接触 */
  77% { transform: none; }                           /* 弹回原位 */
}

此动画是全片高潮:

  • 50%(2.0s):先夸张后退并抬头(rotate(20deg)),表现“惊喜”;
  • 60%(2.4s):突然左移 33px,远超左球位置,制造“主动亲吻”错觉;
  • 67–77%:短暂停留后弹性回弹,模拟物理反弹效果。

这种“先退后进”的反差设计,是动画趣味性的核心来源。

3.4 嘴巴与亲吻印记:状态切换魔法

(1)嘴巴消失(mouth-m 动画)
@keyframes mouth-m {
  54.9% { opacity: 1; }
  55%   { opacity: 0; }
  66%   { opacity: 0; }
  66.1% { opacity: 1; }
}

在亲吻前(55%)瞬间隐藏嘴巴,避免视觉冲突。

(2)亲吻印记闪现(kiss-m 动画)
@keyframes kiss-m {
  66%   { opacity: 0; }
  66.1% { opacity: 1; }
  66.2% { opacity: 0; }
}

利用 0.1% 的时间窗口(约 4 毫秒)闪现一个心形/唇印图形(通过 .kiss 的边框与圆角模拟),人眼会捕捉到这一瞬态画面,形成“亲到了”的心理暗示——这是典型的视觉暂留应用。

技巧提示:此类“瞬时效果”常用于点击反馈、消息提示等场景,成本低、效果强。


四、工程启示:CSS 动画的最佳实践

  1. 模块化角色设计
    每个动画元素职责单一(移动、旋转、显隐),便于调试与复用。

  2. 时间轴对齐
    所有动画共享同一时长(4s),通过百分比关键帧精确同步事件,避免时间漂移。

  3. 缓动函数选择
    ease 适用于大多数自然运动;若需弹性效果,可结合 cubic-bezier() 自定义。

  4. 性能考量
    仅使用 transformopacity(触发合成层,GPU 加速),避免 width/height/margin 等触发布局重排的属性。

  5. 无障碍兼容
    可通过 prefers-reduced-motion 媒体查询禁用动画,提升可访问性:

    @media (prefers-reduced-motion: reduce) {
      .ball { animation: none !important; }
    }
    

结语

这段不足百行的 CSS 代码,展示了前端动画的无限可能:无需框架、不依赖脚本,仅凭对布局、变换与时间的理解,即可创造出富有情感的交互体验。它不仅是技术实现,更是一种用代码讲故事的艺术。在追求极致性能与用户体验的今天,掌握此类纯 CSS 动画技巧,将为开发者提供轻量、高效且富有创意的解决方案。

基于uniapp和 Android Studio实现安卓离线打包

2026年1月31日 10:22

一、准备工作

二、 创建 uniapp 工程

创建工程,选择** uni-app** ,选择默认模板或者 hello uni-app 等。

不要勾选 uni-app x,因为 uni-app x 暂时不支持快速安心打包;

三、uni-app 项目快速安心打包

打包简单、代码不用上传,但是需要等,因为是在云端打包;花钱可以快速打包;

四、修改 Android 离线 SDK 的配置文件

查看通过HBuilder X创建的项目

使用 Android Studio 打开 Android 离线SDK(解压)HBuilder-Integrate-AS 工程;

修改三个文件,build.gradle、dcloud_control.xml、AndroidManifest.xml;

build.gradle 文件修改包名和配置的签名信息,签名信息需要和准备工作的第三步保持一致;

dcloud_control.xml 修改 appid,这是基于 Hbuilder X 应用自动生成的;

修改AndroidManifest.xml 文件的 AppKey,如何生成和获取,请看第五步;

五、生成 AppKey

在我的应用里面,双击项目名称进入,点击各平台信息,点击新增按钮,会看到以下界面;

其中 应用签名SHA1 和 应用签名SHA256 是在准备工作第三步生成;

点击创建离线 Key 后,在点击查看离线 Key,就可以看到 AppKey 了。

六、离线打包

生成 uniapp 编译文件

将编译好的文件拷贝

七、Android Studio 创建设备

八、设置自适应图标、应用名称和编译后的安装包名称

使用 Image Asset 工具生成(最推荐)

不要手动去切几十张不同尺寸的图片,Android Studio 自带的工具能一键搞定:

  1. 打开工具:在 Android Studio 项目的 res 文件夹上点击 右键 -> New -> Image Asset
  2. 设置前景 (Foreground Layer)
    • Asset TypeImage
    • Path 选择你高清的 Logo 原图。
    • 调整 Resize 滑块:观察预览窗口中的圆圈,确保 Logo 完全在圈内。
  3. 设置背景 (Background Layer)
    • 你可以选一种颜色(Color),也可以选一张背景图(Image)。
  4. 设置预览 (Options)
    • Name 建议保持默认的 ic_launcher
  5. 生成:点击 Next -> Finish。它会自动在 res/mipmap-xxxx 下生成所有分辨率的图片。

检查清单文件 (AndroidManifest.xml)

生成好图片后,确保你的 AndroidManifest.xml 指向了这些文件:

<application
  android:icon="@mipmap/ic_launcher"
  android:roundIcon="@mipmap/ic_launcher_round"
  ...>
</application>

Vue2(二)——创建一个Vue实例

2026年1月31日 10:15

Vue 实例学习笔记

来源:v2.cn.vuejs.org/v2/guide/in…

一、创建一个 Vue 实例

1.1 基本语法

每个 Vue 应用都是通过用 Vue 函数创建一个新的 Vue 实例开始的:

var vm = new Vue({
  // 选项对象
})

关于变量名 vm:虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例。

1.2 选项对象

当创建一个 Vue 实例时,你可以传入一个选项对象。这篇教程主要描述的就是如何使用这些选项来创建你想要的行为。

⭐ 选项对象是什么?

选项对象就是在 new Vue({ ... }) 时传入的那个大括号包裹的对象,包含各种配置项,例如:

var vm = new Vue({
  el: '#app',        // 选项1:挂载点
  data: {            // 选项2:数据
    message: 'Hello'
  },
  created: function() {  // 选项3:生命周期钩子
    console.log('实例已创建')
  }
})

在这个例子中,eldatacreated 都是选项 property(选项属性)


二、Vue 应用的结构(重点理解)

2.1 根实例 + 组件树

原文:"一个 Vue 应用由一个通过 new Vue 创建的根 Vue 实例,以及可选的嵌套的、可复用的组件树组成。"

这句话的意思是:

  1. 根 Vue 实例:每个 Vue 应用有且只有一个根实例,通过 new Vue() 创建
  2. 组件树:在这个根实例下面,可以嵌套多个子组件,这些组件形成树状结构

用一个形象的比喻:

  • 根 Vue 实例 = 树的(只有一个)
  • 组件树 = 树的枝叶(可以有很多层,可以重复使用)

2.2 组件树示例

一个 todo 应用的组件树可以是这样的:

根实例 (Root Vue Instance - 通过 new Vue() 创建)
 └─ TodoList (组件)
    ├─ TodoItem (组件)
    │  ├─ TodoButtonDelete (组件)
    │  └─ TodoButtonEdit (组件)
    └─ TodoListFooter (组件)
       ├─ TodosButtonClear (组件)
       └─ TodoListStatistics (组件)

2.3 代码示例

// 1. 创建根实例(唯一的入口)
var vm = new Vue({
  el: '#app',
  data: {
    todos: [...]
  }
})

// 2. 在这个根实例中,可以嵌套多个组件
// 每个组件也是 Vue 实例,但不是通过 new Vue() 创建,而是通过 Vue.component() 定义

2.4 重要概念

所有的 Vue 组件都是 Vue 实例,并且接受相同的选项对象(一些根实例特有的选项除外)。

  • 根实例:通过 new Vue() 创建
  • 子组件:通过 Vue.component() 或组件选项创建

三、数据与方法

3.1 响应式系统

当一个 Vue 实例被创建时,它将 data 对象中的所有的 property 加入到 Vue 的响应式系统中。当这些 property 的值发生改变时,视图将会产生"响应",即匹配更新为新的值。

// 我们的数据对象
var data = { a: 1 }

// 该对象被加入到一个 Vue 实例中
var vm = new Vue({
  data: data
})

// 获得这个实例上的 property
// 返回源数据中对应的字段
vm.a == data.a // => true

// 设置 property 也会影响到原始数据
vm.a = 2
data.a // => 2

// ……反之亦然
data.a = 3
vm.a // => 3

3.2 响应式的限制

只有当实例被创建时就已经存在于 data 中的 property 才是响应式的。

如果你添加一个新的 property,比如:

vm.b = 'hi'

那么对 b 的改动将不会触发任何视图的更新。

解决方法:如果你知道你会在晚些时候需要一个 property,但是一开始它为空或不存在,那么你仅需要设置一些初始值:

data: {
  newTodoText: '',
  visitCount: 0,
  hideCompletedTodos: false,
  todos: [],
  error: null
}

3.3 使用 Object.freeze()

使用 Object.freeze() 会阻止修改现有的 property,也意味着响应系统无法再追踪变化。

var obj = {
  foo: 'bar'
}

Object.freeze(obj)

new Vue({
  el: '#app',
  data: obj
})
<div id="app">
  <p>{{ foo }}</p>
  <!-- 这里的 `foo` 不会更新! -->
  <button v-on:click="foo = 'baz'">Change it</button>
</div>

3.4 实例 Property 与方法

除了数据 property,Vue 实例还暴露了一些有用的实例 property 与方法。它们都有前缀 $,以便与用户定义的 property 区分开来。

var data = { a: 1 }
var vm = new Vue({
  el: '#example',
  data: data
})

vm.$data === data // => true
vm.$el === document.getElementById('example') // => true

// $watch 是一个实例方法
vm.$watch('a', function (newValue, oldValue) {
  // 这个回调将在 `vm.a` 改变后调用
})

四、实例生命周期钩子

4.1 什么是生命周期钩子

每个 Vue 实例在被创建时都要经过一系列的初始化过程——例如,需要设置数据监听、编译模板、将实例挂载到 DOM 并在数据变化时更新 DOM 等。同时在这个过程中也会运行一些叫做生命周期钩子的函数,这给了用户在不同阶段添加自己的代码的机会。

4.2 常用的生命周期钩子

  • created:实例创建完成后调用
  • mounted:实例挂载到 DOM 后调用
  • updated:数据更新导致视图重新渲染后调用
  • destroyed:实例销毁后调用

示例:

new Vue({
  data: {
    a: 1
  },
  created: function () {
    // `this` 指向 vm 实例
    console.log('a is: ' + this.a)
  }
})
// => "a is: 1"

4.3 ⭐ 避免使用箭头函数(重要)

不要在选项 property 或回调上使用箭头函数,比如:

// ❌ 错误写法
created: () => console.log(this.a)

vm.$watch('a', newValue => this.myMethod())

⭐ 什么是"选项 property"?

选项 property 就是在创建 Vue 实例时传入的选项对象的各个属性,例如:

new Vue({
  data: { ... },      // data 是一个选项 property
  created: function() {},  // created 是一个选项 property
  methods: { ... },   // methods 是一个选项 property
  computed: { ... }   // computed 是一个选项 property
})

为什么不能使用箭头函数?

因为箭头函数并没有 thisthis 会作为变量一直向上级词法作用域查找,直至找到为止,经常导致以下错误:

  • Uncaught TypeError: Cannot read property of undefined
  • Uncaught TypeError: this.myMethod is not a function

✅ 正确写法

new Vue({
  data: {
    a: 1
  },
  created: function () {
    // 使用普通函数,this 指向 Vue 实例
    console.log(this.a)
  },
  methods: {
    myMethod: function() {
      console.log(this.a)
    }
  }
})

// 或者使用简写形式(ES6)
new Vue({
  data: {
    a: 1
  },
  created() {
    console.log(this.a)
  },
  methods: {
    myMethod() {
      console.log(this.a)
    }
  }
})

简单记忆

  • 选项 property(如 datacreatedmethodscomputed)的值如果是函数,必须用普通函数,不能用箭头函数
  • 这样 this 才能正确指向 Vue 实例

五、生命周期图示

下图展示了实例的生命周期:

     创建 Vue 实例
          ↓
    beforeCreate(实例创建前)
          ↓
    created(实例创建后)← 可以在这里访问 data、methods
          ↓
    beforeMount(挂载前)
          ↓
    mounted(挂载后)← DOM 已经渲染完成
          ↓
      [运行中]
          ↓
    beforeUpdate(数据更新前)
          ↓
    updated(数据更新后)
          ↓
    beforeDestroy(销毁前)
          ↓
    destroyed(销毁后)

六、总结

6.1 核心概念

  1. 每个 Vue 应用通过 new Vue() 创建一个根实例
  2. 根实例下可以嵌套多个组件,形成组件树
  3. 所有 Vue 组件都是 Vue 实例

6.2 重要注意事项

  1. 响应式限制:只有在创建实例时存在于 data 中的 property 才是响应式的
  2. 避免箭头函数:在选项 property 中不要使用箭头函数,否则 this 指向会出错
  3. 实例 property:使用 $ 前缀的属性和方法,如 vm.$datavm.$elvm.$watch()

6.3 选项对象结构

new Vue({
  // 挂载点
  el: '#app',

  // 数据
  data: { ... },

  // 方法(不要用箭头函数)
  methods: {
    methodName() { ... }
  },

  // 生命周期钩子(不要用箭头函数)
  created() { ... },
  mounted() { ... },

  // 计算属性
  computed: { ... }
})
❌
❌