普通视图

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

2025 年 HTML 年度调查报告公布!好多不知道!

作者 冴羽
2026年1月14日 14:12

前言

近日,「State of HTML 2025」年度调查报告公布。

这份报告收集了全球数万名开发者的真实使用经验和反馈,堪称是 Web 开发领域的“年度风向标”。

让我们看看 2025 年,大家都用了 HTML 的哪些功能。

注:State of JS 2025 还未公布,欢迎关注公众号:冴羽,第一时间获取报告结果。

特性 Top 5

开发者使用最多的特性 Top 5 分别是:

  1. 地标元素(Landmark Elements)

其实就是<aside>, <article>, <main>, <nav>, <section>这些,想必你也经常使用。

  1. tabindex 属性:使 HTML 元素可聚焦,允许或阻止它们按顺序获得焦点。
<div role="button" tabindex="0">I’m Tabbable</div>
  1. <svg>(内联 SVG)
<svg>
  <circle cx="50" cy="50" r="50" />
</svg>
  1. <canvas>
<canvas width="200" height="200"></canvas>
  1. loading="lazy":懒加载
<img src="picture.jpg" loading="lazy" /> <iframe src="supplementary.html" loading="lazy"></iframe>

表单 Top 5

开发者使用最多的表单功能 Top 5 分别是:

  1. <input type="color">:颜色选择器
<input type="color" />
  1. <datalist>:供用户选择的表单控件
<input name="country" list="countries" />
<datalist id="countries">
  <option>Afghanistan</option>
  ...
</datalist>
  1. input.showPicker():打开具有选择器的表单控件(颜色选择器、日期输入框等)
<input id="dateInput" type="date" /> <button onclick="dateInput.showPicker()">Select date</button>
  1. contenteditable="plaintext-only":允许编辑元素的原始文本,但不允许进行富文本格式设置
<h2 class="title" contenteditable="plaintext-only"></h2>
  1. Customizable Select:可自定义样式和样式的下拉控件
select,
::picker(select) {
  appearance: base-select;
}

图形和多媒体 Top 5

开发者使用最多的图形和多媒体功能 Top 5 分别是:

  1. <svg>(内联 SVG)
<svg>
  <circle cx="50" cy="50" r="50" />
</svg>
  1. <canvas>
<canvas width="200" height="200"></canvas>
  1. ctx.drawElement():使开发者可以在 HTML 元素上绘制 <canvas>
<canvas id="canvas" layoutsubtree="true">
  <p>Hello world!</p>
</canvas>
<script type="module">
  const ctx = canvas.getContext("2d");
  const text = canvas.querySelector("p");
  ctx.drawElement(text, 30, 0);
</script>
  1. WebGL:一个基于 OpenGL 的底层 API
const gl = canvas.getContext("webgl");
  1. colorSpace:设置图形的颜色空间
const ctx = canvas.getContext("2d", {colorSpace: "display-p3"});

内容 Top 5

开发者使用最多的图形和多媒体功能 Top 5 分别是:

  1. 内容安全策略 (CSP):网站向浏览器发出的一组指令,用于帮助检测和缓解 XSS 攻击
Content-Security-Policy: script-src 'self';
  1. <template>:内容在加载页面时不会呈现,但随后可以在运行时使用 JavaScript 实例化
<template id="counter">
  <div class="counter">Clicked {{ times }} times</div>
</template>
  1. Intl.LocaleAPI:国际化 API
const us = new Intl.Locale("en-US");
  1. HTML 模块:通过 JS imports 导入 HTML 文件,并访问其元素和 JS 导出
<script type="module">
  import { TabList } from "./tablist.html" with { type: 'html' };
  customElements.define("tab-list", TabList);
</script>
  1. Sanitizer API:element.setHTML() 以及 Document.parseHTML() API,通过清理 HTML 中不受信任的字符串来防止 XSS 攻击。
greeting.setHTML('Hello ' + nameInput.value);

交互 Top 5

开发者使用最多的交互功能 Top 5 分别是:

  1. <details><summary>:隐藏或显示内容
<details>
  <summary>Details</summary>
  Longer content
</details>
  1. <dialog>:对话框
<dialog id="confirm">
  <form method="dialog">
    Are you sure?
    <button value="1">Yes</button>
    <button value="0">No</button>
  </form>
</dialog>
  1. <details name>:手风琴效果
<details open name="sidebar_panel">
  <summary>Main info</summary>
  <!-- controls -->
</details>
<details name="sidebar_panel">
  <summary>Style</summary>
  <!-- controls -->
</details>
  1. popover:弹出窗口
<button popovertarget="foo">Toggle the popover</button>
<div id="foo" popover>Popover content</div>
  1. element.before():将一个元素移动到另一个元素之前的 DOM 方法
referenceElement.before(newElement);

性能 Top 5

开发者使用最多的性能功能 Top 5 分别是:

  1. loading="lazy":懒加载
<img src="picture.jpg" loading="lazy" /> <iframe src="supplementary.html" loading="lazy"></iframe>
  1. srcset 和 sizes 属性:提供多个源图像,以帮助浏览器选择正确的图像
<img srcset="fairy-med.jpg 480w, fairy-large.jpg 800w" sizes="(max-width: 600px) 480px, 800px" src="fairy-large.jpg" alt="Elva dressed as a fairy" />
  1. fetchpriority 属性:浏览器优先获取该资源
<img src="logo.svg" fetchpriority="high" />
  1. <img sizes="auto" loading="lazy">sizes="auto" 属性会在图像加载之前为其预留布局空间,从而避免一些布局偏移
<img sizes="auto" loading="lazy" />
  1. blocking="render":阻止渲染(但不阻止解析),直到某些资源加载完毕
<script blocking="render" async src="async-script.js"></script>

Web 组件 Top 5

开发者使用最多的 Web 组件功能 Top 5 分别是:

  1. 自定义元素
<my-switch start="On" end="Off">Wi-Fi</my-switch>
  1. 定义自定义元素
class MyElement extends HTMLElement { … }
customElements.define("my-element", MyElement);
  1. Shadow DOM:将外部不可见的元素封装起来,并使用不影响页面其余部分的 CSS 对其进行样式设置
this.shadowRoot = this.attachShadow({ mode: "open" });
  1. slot 属性:将组件 UI 中预定义的部分替换为自己的元素
<my-switch>
  Wi-Fi
  <i slot="start" class="icon-on">On</i>
  <i slot="end" class="icon-off">Off</i>
</my-switch>
  1. 声明 Shadow DOM:使用 HTML 定义 Shadow 树,例如在服务器端渲染 Web 组件时
<host-element>
  <template shadowrootmode="open">
    <!-- Shadow content -->
  </template>
</host-element>

系统功能 Top 5

开发者使用最多的系统功能 Top 5 分别是:

  1. Web Share API:将内容共享给用户选择的各种目标的机制
navigator.share(shareData);
  1. 文件系统访问 API:访问用户本地设备上的文件和目录,并创建可写文件,以便进行更新
const handle = await window.showSaveFilePicker();
const writable = await handle.createWritable();
await writable.write("Hello, world!");
await writable.close();
  1. SpeechRecognition:将麦克风输入转换为文本的 API
const rec = new SpeechRecognition();
rec.lang = "en-US";
rec.addEventListener("result", (e) => console.log(e.results[0][0].transcript));
rec.start();
  1. share_target manifest field:允许 PWA 通过系统共享对话框接收来自其他应用程序共享的数据(文本、文件、URL)
"share_target": {
  "action": "/share",
  "method": "POST",
  "enctype": "multipart/form-data",
  "params": {
    "title": "title",
    "text": "text",
    "url": "url",
    "files": [{ "name": "image", "accept": ["image/*"] }]
  }
}
  1. 文件处理 API:允许 PWA 将自身注册为某些文件类型的处理程序
"file_handlers": [{
  "action": "/open-file",
  "accept": {
    "image/svg+xml": ".svg",
    "image/png": ".png"
  }
}]

无障碍 Top 5

开发者使用最多的无障碍功能 Top 5 分别是:

  1. 地标元素
  2. tabindex 属性:使 HTML 元素可聚焦,允许或阻止它们按顺序获得焦点
<div role="button" tabindex="0">I’m Tabbable</div>
  1. <search>:用于封装搜索用户界面的语义元素
<search>
  <form action="search.php">
    <label>Find: <input name="q" type="search" /></label>
    <button>Go!</button>
  </form>
</search>
  1. focusgroup 属性:使用键盘方向键在可聚焦元素之间进行键盘焦点导航
<div focusgroup="wrap horizontal">
  <!-- child elements -->
</div>

最后

我们通常认为最炫酷的功能会最吸引开发者,比如人工智能 API、3D/XR/AR 或设备 API。

然而,年复一年,最终脱颖而出的却往往是那些看似平淡无奇的功能,甚至是一些非常普通的功能:下拉菜单、组合框、弹出框、对话框、表单验证、文件加载和保存、模板、安全地显示用户生成内容、图标等等。

有人可能会问:“这些功能不是早就有了吗?”

确实是,但问题在于 ——当用户界面无法自定义或设置样式时,它实际上就等于无法使用。

于是你不得不重复造轮子,拼一堆第三方库。结果明明是基础需求,却搞得像 “高端操作”。

但好消息是:HTML 正在变好!

2023 年还在讨论的功能,现在已经在主流浏览器上线了;之前没法用的 Popover API,现在所有主流浏览器都支持了。

虽然开发者的信任要滞后很多……

比如 Popover API 明明已经全支持了,却还是开发者投诉 “浏览器不支持” 最多的功能 —— 不是浏览器没跟上,是我们还没反应过来 “这个功能已经能用了”。

此外,AI 也拖了后腿。

按理说,AI 懂现代 Web 功能,应该能帮我们更快应用新特性,但实际情况是 —— AI 太保守了,推荐的都是 “老办法”,反而让新功能的普及变慢了。

总的来说,HTML 的未来方向很清晰:更灵活、更能表达需求、更贴合开发者实际开发习惯。

最后使用报告中的一句话:

“Web 的进步很少轰轰烈烈,但都是累积的。每多一个基本功能,就少用一次变通方案、少依赖一个库、少写一个脆弱的 hack。等这些基础都到位了,整个 Web 开发都会变轻松。”

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

鸿蒙应用的“任意门”:Deep Linking 与 App Linking 的相爱相杀

作者 SameX
2026年1月14日 13:50

写在前面:本文基于 HarmonyOS Next 的摸爬滚打经验总结。技术这东西更新快,如果哪里说得不对,或者你有更骚的操作,欢迎在评论区拍砖交流。转载请注明出处,谢啦。

做移动端开发,最烦的是什么?是应用像一个个孤岛,互相都不通气。

用户在微信里点个链接,想跳到你的 App 里看详情,结果要么没反应,要么跳出一堆甚至都没听说过的 App 让你选。这就很尴尬了。为了解决这个问题,鸿蒙系统给咱们提供了两把钥匙:一把叫 Deep Linking,一把叫 App Linking

很多兄弟容易搞混,觉得这俩不是一回事吗?确实,目的都是为了“跳转”,但手段和段位可大不一样。今天咱们就来扒一扒这两者的底裤。


一、 Deep Linking:简单粗暴的“土法炼钢”

Deep Linking 说白了,就是利用自定义协议(Scheme)来实现跳转。这招在移动开发界属于“老兵”了。

它是怎么工作的?

你想让别人通过暗号找到你,你就得先起个暗号。比如你做个地图 App,你可以跟系统喊一嗓子:“以后只要有人喊 geo:// 开头的,都归我管!”

这就是 Deep Linking 的核心:自定义 Scheme

  • 优点:门槛低,随便定义。my-super-app://,想怎么写怎么写。
  • 缺点:太随意了。万一隔壁老王也定义了 geo:// 怎么办?这时候系统就懵圈了,只能弹个窗让用户自己选。这一选,用户体验就断档了。而且这玩意儿不安全,谁都能冒充。

怎么配置?

在鸿蒙里,你得在 module.json5 里通过 skills 标签去“抢注”这个暗号。

// module.json5
{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "skills": [
          {
            "uris": [
              {
                "scheme": "mychat", // 你的暗号
                "host": "talk.com", // 具体的接头地点
                "path": "room"      // 具体的房间号
              }
            ]
          }
        ]
      }
    ]
  }
}

这一下,只要有链接是 mychat://talk.com/room,系统就会把目光投向你。


二、 App Linking:持证上岗的“正规军”

华为现在大力推的是 App Linking。为啥?因为它正规、安全、体验好。

它强在哪?

App Linking 不再用那些乱七八糟的自定义协议,而是直接用标准的 HTTPS 链接(比如 https://www.example.com)。

这里有个核心逻辑:域名校验。 系统会去验证:“你这个 App 到底是不是这个域名的亲儿子?”。

  • 如果验证通过:用户点击链接,直接拉起 App,没有任何弹窗干扰,丝般顺滑。
  • 如果没安装 App:既然是 HTTPS,那就直接用浏览器打开网页。这就叫“进可攻退可守”,用户永远不会看到 404 或者无响应。

这就特别适合做社交分享、广告引流,或者短信召回老用户。

怎么配置?(重点来了,这里稍微繁琐点)

这玩意儿需要“双向奔赴”:App 端要认领域名,服务器端要认领 App。

  1. 服务器端搞个“介绍信”: 你得在你的网站服务器根目录下,创建一个 .well-known/applinking.json 文件。这文件里写啥?写你 App 的身份证号(APP ID)。 这是为了告诉全天下:这个 App 是我罩着的。
  2. App 端开启“雷达”: 在 AGC 控制台开通服务后,你得在 module.json5 里也配上 skills,不过这次 scheme 必须是 https注意:在 AGC 后台(增长 > App Linking)记得把“域名校验”的开关打开,不然系统懒得去查。

三、 实战:到底怎么跳?

配置好了,怎么触发跳转呢?咱们看代码。

场景 A:我想拉起别人(发起方)

鸿蒙提供了 openLink 接口,这比传统的 startAbility 更适合处理链接跳转。

import { common } from '@ohos.app.ability.common';

// 比如在一个按钮点击事件里
function jumpToTarget(context: common.UIAbilityContext) {
  // 目标链接
  const targetLink = "https://www.example.com/programs?action=showall"; 
  
  const options: common.OpenLinkOptions = {
    // 重点!这里有个开关
    // true: 只要 App Linking(没安装App就可能没反应或者走浏览器逻辑,看系统实现)
    // false: 兼容 Deep Linking 模式,哪怕没校验过域名的 scheme 也能试着跳
    appLinkingOnly: false 
  };

  try {
    context.openLink(targetLink, options).then(() => {
      console.info('跳转成功,走你!');
    }).catch((err) => {
      console.error(`跳转翻车了: ${JSON.stringify(err)}`);
    });
  } catch (paramError) {
    console.error(`参数都有问题: ${JSON.stringify(paramError)}`);
  }
}

如果你非要用 Deep Linking 的那种 geo: 协议,用 startAbility 也是可以的,构建一个 Want 对象就行,但这在 API 12 里显得有点“复古”了。

场景 B:别人拉起我(接收方)

不管是 Deep Linking 还是 App Linking,进了你的门,处理逻辑是一样的。都是在 AbilityonCreate 或者 onNewWant 里接客。

import { UIAbility, Want, AbilityConstant } from '@ohos.app.ability.common';
import { url } from '@ohos.arkts';

export default class EntryAbility extends UIAbility {
  
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleLink(want);
  }

  // 如果 App 已经在后台活着,会走这里
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleLink(want);
  }

  handleLink(want: Want) {
    const uri = want?.uri;
    if (!uri) return; // 没带链接?那是误触吧

    console.info(`收到链接请求: ${uri}`);

    // 解析 URL,这就跟前端解析 location.href 一个德行
    try {
      const urlObject = url.URL.parseURL(uri);
      const action = urlObject.params.get('action');
      
      if (action === "showall") {
        // 路由跳转逻辑:带大伙去“所有节目”页面
        // router.pushUrl(...) 
      }
    } catch (e) {
      console.error("这链接格式不对啊,老铁");
    }
  }
}


四、 总结:该选哪一个?

说了一大堆,最后给兄弟们来个“防纠结指南”:

特性 Deep Linking (土法) App Linking (正规军)
链接长相 myapp://detail https://www.myapp.com/detail
安全性 低 (谁都能用) 高 (域名校验,防伪冒)
没安装App时 报错或无响应 自动打开浏览器网页,体验无缝衔接
唯一性 不保证 (可能弹窗选App) 保证 (唯一归属,一键直达)
适用场景 App 内部页面互跳、非公网环境 外部引流、营销短信、二维码、社交分享

血泪建议: 如果是做对外推广、H5 唤醒 App,无脑上 App Linking。它是未来的主流,而且不用担心“应用未安装”的尴尬。 如果是 App 内部自己跳自己,或者公司内部几个 App 互通,不想搞服务器域名那一套,那 Deep Linking 依然是个轻量级的好选择。

行了,关于鸿蒙的“连接艺术”今天就聊到这。代码写完了记得多测测,别到时候用户点开链接一脸懵逼,那就尴尬了。

vue使用h函数封装dialog组件,以命令的形式使用dialog组件

2026年1月14日 12:30

场景

有些时候我们的页面是有很多的弹窗
如果我们把这些弹窗都写html中会有一大坨
因此:我们需要把弹窗封装成命令式的形式

命令式弹窗

// 使用弹窗的组件
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
function openMask(){
  // 第1个参数:表示的是组件,你写弹窗中的组件
  // 第2个参数:表示的组件属性,比如:确认按钮的名称等
  // 第3个参数:表示的模态框的属性。比如:模态宽的宽度,标题名称,是否可移动
  renderDialog(childTest,{},{title:'测试弹窗'})
}
</script>
// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog } from "element-plus";
export function renderDialog(component:any,props:any, modalProps:any){
 const dialog  = h(
    ElDialog,   // 模态框组件
    {
      ...modalProps, // 模态框属性
      modelValue:true, // 模态框是否显示
    }, // 因为是模态框组件,肯定是模态框的属性
    {
      default:()=>h(component, props ) // 插槽,el-dialog下的内容
    }
  )
 console.log(dialog)
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
//childTest.vue 组件
<template>
  <div>
    <span>It's a modal Dialog</span>
    <el-form :model="form" label-width="auto" style="max-width: 600px">
    <el-form-item label="Activity name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="Activity zone">
      <el-select v-model="form.region" placeholder="please select your zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
  </el-form>
  </div>
</template>
<script setup lang="ts">
import { ref,reactive } from 'vue'
const dialogVisible = ref(true)
const form = reactive({
  name: '',
  region: '',
})
const onSubmit = () => {
  console.log('submit!')
}
</script>

01

为啥弹窗中的表单不能够正常展示呢?

在控制台会有下面的提示信息:
Failed to resolve component:
el-form If this is a native custom element,
make sure to exclude it from component resolution via compilerOptions.isCustomElement
翻译过来就是
无法解析组件:el-form如果这是一个原生自定义元素,
请确保通过 compilerOptions.isCustomElement 将其从组件解析中排除

02

其实就是说:我重新创建了一个新的app,这个app中没有注册组件。
因此会警告,页面渲染不出来。

// 我重新创建了一个app,这个app中没有注册 element-plus 组件。
const app = createApp(dialog)

现在我们重新注册element-plus组件。
准确的说:我们要注册 childTest.vue 组件使用到的东西

给新创建的app应用注册childTest组件使用到的东西

我们将会在这个命令式弹窗中重新注册需要使用到的组件

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog } from "element-plus";
// 引入组件和样式
import ElementPlus from "element-plus";
// import "element-plus/dist/index.css";
export function renderDialog(component:any,props:any, modalProps:any){
 const dialog  = h(
    ElDialog,   // 模态框组件
    {
      ...modalProps, // 模态框属性
      modelValue:true, // 模态框显示
    }, // 因为是模态框组件,肯定是模态框的属性
    {
      default:()=>h(component, props ) // 插槽,el-dialog下的内容
    }
  )
 console.log(dialog)
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

03

现在我们发现可以正常展示弹窗中的表单了。因为我们注册了element-plus组件。
但是我们发现又发现了另外一个问题。
弹窗底部没有取消和确认按钮。
需要我们再次通过h函数来创建

关于使用createApp创建新的应用实例

在Vue 3中,我们可以使用 createApp 来创建新的应用实例
但是这样会创建一个完全独立的应用
它不会共享主应用的组件、插件等。
因此我们需要重新注册

弹窗底部新增取消和确认按钮

我们将会使用h函数中的插槽来创建底部的取消按钮

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
    },
    {
      // 主要内容插槽
      default: () => h(component, props),
      // 底部插槽
      footer:() =>h(
        'div',
        { class: 'dialog-footer' },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('取消')
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

04

点击关闭弹窗时,需要移除之前创建的div

卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div。
2个地方需要移除:1,点击确认按钮。 2,点击其他地方的关闭
05

关闭弹窗正确销毁相关组件

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  console.log('111')
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽
      default: () => h(component, props),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

06

点击确认按钮时验证规则

有些时候,我们弹窗中的表单是需要进行规则校验的。
我们下面来实现这个功能点
传递的组件

<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="ruleForm"
    :rules="rules"
    label-width="auto"
  >
    <el-form-item label="Activity name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    <el-form-item label="Activity zone" prop="region">
      <el-select v-model="ruleForm.region" placeholder="Activity zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
    
    <el-form-item label="Activity time" required>
      <el-col :span="11">
        <el-form-item prop="date1">
          <el-date-picker
            v-model="ruleForm.date1"
            type="date"
            aria-label="Pick a date"
            placeholder="Pick a date"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col class="text-center" :span="2">
        <span class="text-gray-500">-</span>
      </el-col>
      <el-col :span="11">
        <el-form-item prop="date2">
          <el-time-picker
            v-model="ruleForm.date2"
            aria-label="Pick a time"
            placeholder="Pick a time"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-form-item>

    <el-form-item label="Resources" prop="resource">
      <el-radio-group v-model="ruleForm.resource">
        <el-radio value="Sponsorship">Sponsorship</el-radio>
        <el-radio value="Venue">Venue</el-radio>
      </el-radio-group>
    </el-form-item>
    <el-form-item label="Activity form" prop="desc">
      <el-input v-model="ruleForm.desc" type="textarea" />
    </el-form-item>

  </el-form>
</template>

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

import type { FormInstance, FormRules } from 'element-plus'

interface RuleForm {
  name: string
  region: string
  date1: string
  date2: string
  resource: string
  desc: string
}
const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
  name: 'Hello',
  region: '',
  date1: '',
  date2: '',
  resource: '',
  desc: '',
})
const rules = reactive<FormRules<RuleForm>>({
  name: [
    { required: true, message: 'Please input Activity name', trigger: 'blur' },
    { min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
  ],
  region: [
    {
      required: true,
      message: 'Please select Activity zone',
      trigger: 'change',
    },
  ],
  date1: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a date',
      trigger: 'change',
    },
  ],
  date2: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a time',
      trigger: 'change',
    },
  ],
  resource: [
    {
      required: true,
      message: 'Please select activity resource',
      trigger: 'change',
    },
  ],
  desc: [
    { required: true, message: 'Please input activity form', trigger: 'blur' },
  ],
})

const submitForm = async () => {
  if (!ruleFormRef.value) {
    console.error('ruleFormRef is not initialized')
    return false
  }
  try {
    const valid = await ruleFormRef.value.validate()
    if (valid) {
      console.log('表单校验通过', ruleForm)
      return Promise.resolve(ruleForm)
    }
  } catch (error) {
    // 为啥submitForm中,valid的值是false会执行catch ?
    // el-form 组件的 validate 方法的工作机制导致的。 validate 方法在表单验证失败时会抛出异常
    console.error('err', error)
    return false
    /**
     * 下面这样写为啥界面会报错呢?
     * return Promise.reject(error)
     * 当表单验证失败时,ruleFormRef.value.validate() 会抛出一个异常。
     * 虽然你用了 try...catch 捕获这个异常,并且在 catch 块中通过 return Promise.reject(error) 返回了一个被拒绝的 Promise
     * 但如果调用 submitForm 的地方没有正确地处理这个被拒绝的 Promise(即没有使用 .catch() 或者 await 来接收错误),
     * 那么浏览器控制台就会显示一个 "Uncaught (in promise)" 错误。
     * 在 catch 中再次 return Promise.reject(error) 是多余的, 直接return false
     * */ 
    /**
     * 如果你这样写
     * throw error 直接抛出错误即可
     * 那么就需要再调用submitForm的地方捕获异常
     * */  
  }
}

defineExpose({
  submitForm:submitForm
})
</script>
// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                })
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

07 关键的点:通过ref拿到childTest组件中的方法,childTest要暴露需要的方法

如何把表单中的数据暴露出去

可以通过回调函数的方式把数据暴露出去哈。

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据, 如验证失败,res 的值有可能是一个false。
                  onConfirm(res)
                  // 怎么把这个事件传递出去,让使用的时候知道点击了确认并且知道验证通过了
                }).catch((error: any) => {
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
  })
}
</script>

08

点击确定时,业务完成后关闭弹窗

现在想要点击确定,等业务处理完成之后,才关闭弹窗。 需要在使用完成业务的时候返回一个promise,让封装的弹窗调用这个promise 这样就可以知道什么时候关闭弹窗了

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                    // 注意这里的finally,这样写在服务出现异常的时候会有问题,这里是有问题的,需要优化
                    // 注意这里的finally,这样写在服务出现异常的时候会有问题,这里是有问题的,需要优化
                    callbackResult.finally(() => { 
                      // 弹窗关闭逻辑
                      app.unmount()
                      document.body.removeChild(div)
                    });
                  } else {
                    // 如果不是 Promise,立即关闭弹窗
                    app.unmount()
                    document.body.removeChild(div)
                  }
                }).catch((error: any) => {
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
    // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
    return fetch("https://dog.ceo/api/breed/pembroke/images/random")
     .then((res) => {
       return res.json();
     })
     .then((res) => {
        console.log('获取的图片地址为:', res.message);
     });
  })
}
</script>

09

优化业务组件

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 关闭弹窗,避免重复代码
  const closeDialog = () => {
    // 成功时关闭弹窗
    app.unmount();
    // 检查div是否仍然存在且为body的子元素,否者可能出现异常
    if (div && div.parentNode) {
      document.body.removeChild(div)
    }
  }
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                   
                     callbackResult.then(() => {
                      if(res){
                        console.log('111')
                        closeDialog()
                      }
                    }).catch(error=>{
                      console.log('222')
                      console.error('回调函数执行出错,如:网络错误', error);
                      // 错误情况下也关闭弹窗
                      closeDialog()
                    });
                  } else {
                    // 如果不是 Promise,并且验证时通过了的。立即关闭弹窗
                    console.log('333', res)
                    if(res){
                      closeDialog()
                    }
                  }
                }).catch((error: any) => {
                  console.log('44444')
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>
<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
      // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
      return fetch("https://dog.ceo/api/breed/pembroke/images/random")
      .then((res) => {
        return res.json();
      })
      .then((res) => {
          console.log('获取的图片地址为:', res.message);
      });
  })
}
</script>

眼尖的小伙伴可能已经发现了这一段代码。 1,验证不通过会也会触发卸载弹窗 2,callbackResult.finally是不合适的

image

10

最终的代码

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 关闭弹窗,避免重复代码
  const closeDialog = () => {
    // 成功时关闭弹窗
    app.unmount();
    // 检查div是否仍然存在且为body的子元素,否者可能出现异常
    if (div && div.parentNode) {
      document.body.removeChild(div)
    }
  }
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  const isLoading = ref(false)
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        isLoading.value = false
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽,noShowFooterBool是true,不显示; false的显示底部 
      footer: props.noShowFooterBool ? null : () =>h(
        'div',
        { 
          class: 'dialog-footer',
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => props.cancelText || '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              loading: isLoading.value,
              onClick: () => {
                isLoading.value = true
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  if(!res){
                    isLoading.value = false
                  }
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                     callbackResult.then(() => {
                      if(res){
                        console.log('111')
                        closeDialog()
                      }else{
                        isLoading.value = false
                      }
                    }).catch(error=>{
                      console.log('222')
                      console.error('回调函数执行出错,如:网络错误', error);
                      // 错误情况下也关闭弹窗
                      closeDialog()
                    });
                  } else {
                    // 如果不是 Promise,并且验证时通过了的。立即关闭弹窗
                    console.log('333', res)
                    if(res){
                      closeDialog()
                    }else{
                      isLoading.value = false
                    }
                  }
                }).catch((error: any) => {
                  console.log('44444')
                   isLoading.value = false
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => props.confirmText ||  '确定'
          )
        ]
      ) 
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  const otherProps =  {cancelText:'取消哈', confirmText: '确认哈',showFooterBool:true }
  const dialogSetObject = {title:'测试弹窗哈', width: '700', draggable: true}
  renderDialog(childTest,otherProps,dialogSetObject, (res)=>{
    console.log('通过回调函数返回值', res)
    // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
    return fetch("https://dog.ceo/api/breed/pembroke/images/random")
    .then((res) => {
      return res.json();
    })
    .then((res) => {
        console.log('获取的图片地址为:', res.message);
    });
  })
}
</script>

<style lang="scss" scoped>

</style>
<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="ruleForm"
    :rules="rules"
    label-width="auto"
  >
    <el-form-item label="Activity name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    <el-form-item label="Activity zone" prop="region">
      <el-select v-model="ruleForm.region" placeholder="Activity zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
    
    <el-form-item label="Activity time" required>
      <el-col :span="11">
        <el-form-item prop="date1">
          <el-date-picker
            v-model="ruleForm.date1"
            type="date"
            aria-label="Pick a date"
            placeholder="Pick a date"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col class="text-center" :span="2">
        <span class="text-gray-500">-</span>
      </el-col>
      <el-col :span="11">
        <el-form-item prop="date2">
          <el-time-picker
            v-model="ruleForm.date2"
            aria-label="Pick a time"
            placeholder="Pick a time"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-form-item>

  
    <el-form-item label="Resources" prop="resource">
      <el-radio-group v-model="ruleForm.resource">
        <el-radio value="Sponsorship">Sponsorship</el-radio>
        <el-radio value="Venue">Venue</el-radio>
      </el-radio-group>
    </el-form-item>
    <el-form-item label="Activity form" prop="desc">
      <el-input v-model="ruleForm.desc" type="textarea" />
    </el-form-item>

  </el-form>
</template>

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

import type { FormInstance, FormRules } from 'element-plus'

interface RuleForm {
  name: string
  region: string

  date1: string
  date2: string


  resource: string
  desc: string
}


const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
  name: 'Hello',
  region: '',
  date1: '',
  date2: '',
  resource: '',
  desc: '',
})



const rules = reactive<FormRules<RuleForm>>({
  name: [
    { required: true, message: 'Please input Activity name', trigger: 'blur' },
    { min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
  ],
  region: [
    {
      required: true,
      message: 'Please select Activity zone',
      trigger: 'change',
    },
  ],
  date1: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a date',
      trigger: 'change',
    },
  ],
  date2: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a time',
      trigger: 'change',
    },
  ],
  resource: [
    {
      required: true,
      message: 'Please select activity resource',
      trigger: 'change',
    },
  ],
  desc: [
    { required: true, message: 'Please input activity form', trigger: 'blur' },
  ],
})

const submitForm = async () => {
  if (!ruleFormRef.value) {
    console.error('ruleFormRef is not initialized')
    return false
  }
  try {
    const valid = await ruleFormRef.value.validate()
    if (valid) {
      // 验证通过后,就会可以把你需要的数据暴露出去
      return Promise.resolve(ruleForm)
    }
  } catch (error) {
    // 为啥submitForm中,valid的值是false会执行catch ?
    // el-form 组件的 validate 方法的工作机制导致的。 validate 方法在表单验证失败时会抛出异常
    console.error('err', error)
    return false
    /**
     * 下面这样写为啥界面会报错呢?
     * return Promise.reject(error)
     * 当表单验证失败时,ruleFormRef.value.validate() 会抛出一个异常。
     * 虽然你用了 try...catch 捕获这个异常,并且在 catch 块中通过 return Promise.reject(error) 返回了一个被拒绝的 Promise
     * 但如果调用 submitForm 的地方没有正确地处理这个被拒绝的 Promise(即没有使用 .catch() 或者 await 来接收错误),
     * 那么浏览器控制台就会显示一个 "Uncaught (in promise)" 错误。
     * 在 catch 中再次 return Promise.reject(error) 是多余的, 直接return false
     * */ 
    /**
     * 如果你这样写
     * throw error 直接抛出错误即可
     * 那么就需要再调用submitForm的地方捕获异常
     * */  
  }
}

defineExpose({
  submitForm:submitForm
})
</script>

前端即导演:用纯 CSS3 原力复刻《星球大战》经典开场

作者 NEXT06
2026年1月14日 12:12

🌌 致敬经典:用纯 CSS3 导演一场“星球大战”开场秀

“前端是代码界的导演。”

我们不需要摄像机,只需要 HTML 构建骨架,CSS 渲染光影。今天,我们就用几行 CSS3 代码,复刻经典的《星球大战》开场 3D 特效。

Video Project.gif

🎬 剧本规划(HTML 结构)

为了还原电影海报的经典站位,我们将结构分为三层:顶部的 "STAR",底部的 "WARS",以及中间那一排神秘的副标题。

codeHtml

<div class="starwars">
    <img src="./star.svg" alt="star" class="star">
    <img src="./wars.svg" alt="wars" class="wars">
    <h2 class="byline">
        <!-- 每个字母单独包裹,为了后续的翻转动画 -->
        <span>T</span><span>h</span><span>e</span>...
    </h2>
</div>

这里有一个细节:副标题 h2 中的每个字母都用 span 包裹,这是为了让每个字母能独立进行 3D 旋转表演。

🎥 搭建舞台(核心 CSS)

1. 完美的绝对居中

在全屏黑背景下,我们需要让 logo 稳稳地悬浮在宇宙中心。这里使用了经典的“绝对定位 + Transform”大法:

CSS

.starwars {
    width: 34em;
    height: 17em;
    position: absolute;
    top: 50%;
    left: 50%;
    /* 自身宽高的一半向回移动,实现精准居中 */
    transform: translate(-50%, -50%);
}

2. 开启上帝视角(3D 景深)

这是本案例的灵魂所在。普通的平面动画无法表现星战字幕“飞向深空”的震撼。我们需要在父容器上开启 3D 空间:

CSS

.starwars {
    /* 视距:模拟人眼距离屏幕 800px 的位置 */
    perspective: 800px;
    /* 保持子元素的 3D 空间关系 */
    transform-style: preserve-3d;
}
  • perspective: 决定了“近大远小”的程度,数值越小,透视感越强烈。
  • transform-style: preserve-3d: 确保子元素在 3D 空间中变换,而不是被压扁在 2D 平面里。

🎞️ 动作设计(关键帧动画)

Step 1: 巨物消逝(Logo 动画)

STAR 和 WARS 两张图片需要经历:透明 -> 出现 -> 缩小复位 -> 飞向深渊 的过程。

我们利用 translateZ 来控制 Z 轴距离,负值越大,离我们越远。

CSS

@keyframes star {
  0% {
    opacity: 0;
    transform: scale(1.5) translateY(-0.75em); /* 初始放大且位置靠上 */
  }
  20% { opacity: 1; } /* 显形 */
  89% {
    opacity: 1;
    transform: scale(1); /* 恢复正常大小 */
  }
  100% {
    opacity: 0;
    transform: translateZ(-1000em); /* 瞬间飞向宇宙深处! */
  }
}

Step 2: 文字起舞(副标题动画)

中间的 The Force Awake 需要有一种“翻转浮现”的神秘感。

注意:span 默认是行内元素,无法应用 Transform,所以必须设置为 display: inline-block。

CSS

.byline span {
  display: inline-block;
  animation: spin-letters 10s linear infinite;
}

@keyframes spin-letters {
  0%, 100% {
    opacity: 0;
    transform: rotateY(90deg); /* 侧身 90 度,相当于隐身 */
  }
  30% { opacity: 1; }
  70% {
    transform: rotateY(0); /* 正对观众 */
    opacity: 1;
  }
}

配合父容器 .byline 的 Z 轴推进动画,文字不仅在自转,还在向镜头推进,层次感瞬间拉满。

🏁 杀青

通过 perspective 构建空间,利用 translateZ 制造纵深,再配合 rotateY 增加动感。不需要复杂的 JS 库,几十行 CSS 就能致敬经典。

前端开发的乐趣,往往就在这些像素的腾挪转移之间。愿原力与你的代码同在!May the code be with you.

源代码

HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>html5&css3星球大战</title>
    <link rel="stylesheet" href="./style.css">
</head>
<body>
    <div class="starwars">
        <img src="./star.png" alt="star" class="star">
        <img src="./wars.png" alt="wars" class="wars">
        <h2 class="byline" id="byline">
            <span>T</span>
            <span>H</span>
            <span>E</span>
            <span>F</span>
            <span>O</span>
            <span>R</span>
            <span>C</span>
            <span>E</span>
            <span>A</span>
            <span>W</span>
            <span>A</span>
            <span>K</span>
            <span>E</span>
        </h2>
        </div>
    </div>
</body>
</html>

CSS

/*
  标准 CSS Reset
  基于 Eric Meyer 的 Reset 并结合现代浏览器特性
*/

/* 所有元素应用 border-box 模型,方便布局 */
*,
*::before,
*::after {
  box-sizing: border-box;
}

/* 重置所有元素的内外边距、边框、字体等 */
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 100%;
  font: inherit;
  vertical-align: baseline;
}

/* HTML5 语义化元素设为块级 */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
  display: block;
}

/* 重置列表样式 */
ol,
ul {
  list-style: none;
}

/* 重置表格样式 */
table {
  border-collapse: collapse;
  border-spacing: 0;
}

/* 重置图片、视频等替换元素 */
img,
video,
canvas,
audio,
svg {
  display: block;
  max-width: 100%;
}

/* 重置表单元素 */
button,
input,
select,
textarea {
  /* 继承字体和颜色 */
  font: inherit;
  color: inherit;
  /* 移除默认边框和轮廓 */
  border: none;
  outline: none;
  /* 清除默认样式 */
  background: transparent;
  /* 统一垂直对齐 */
  vertical-align: middle;
}

/* 链接重置 */
a {
  text-decoration: none;
  color: inherit; /* 继承父元素颜色 */
}

/* 防止字体缩放 */
body {
  line-height: 1;
  -webkit-text-size-adjust: 100%;
}

/* 清除浮动(可选) */

.clearfix::after {
  content: "";
  display: table;
  clear: both;
}

/* 业务代码 */
body {
  height: 100vh;
  background:#000 url(./bg.jpg);
}
.starwars {
    /* 声明 支持3D */
  perspective: 800px;
  /* 保持3D 变换 */
  transform-style: preserve-3d;
  /* 相对单位,相对于自身的字体大小 
    默认字体大小是16
  */
  width: 34em;
  height: 17em;
  /* 绝对定位 */
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  /* css 调试手法, 背景颜色调试大法 */
  /* background-color: red; */
}
img {
    /* 高度等比例缩放 */
  width: 100%;
}
.star, .wars, .byline {
  position: absolute;
}
.star {
  top: -0.75em;
}
.wars {
  bottom: -0.5em;
}
.byline {
  left: -2em;
  right: -2em;
  top: 45%;
  /* background: green; */
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.3em;
  font-size: 1.6em;
  color: white;
}
.star{
    /* 动画属性 
    star 动作脚本
    10s animation-duration
    ease-out animation-timing-function
    */
    animation: star 10s ease-out infinite;
}
.wars{
    animation: wars 10s ease-out infinite;
}
.byline{
    animation: move-byline 10s linear infinite;
}
.byline span{
    display: inline-block;
    animation: spin-letters 10s linear infinite;
}

/* 设计动作 动画的关键帧 */
@keyframes star {
    /* 每个关键帧写它的属性 */
    0%{
        opacity: 0;
        transform: scale(1.5) translateY(-0.75em);
    }
    20%{
        opacity: 1;

    }
    89%{
        opacity: 1;
        transform: scale(1);
    }
    100%{
        opacity: 0;
        transform: translateZ(-1000em);
    }
    
}
@keyframes wars{
    0%{
        opacity: 0;
        transform: scale(1.5) translateY(0.5em);
    }
    20%{
        opacity: 1;

    }
    /* 模拟真实效果 不同步 更像是人在操控飞船 */
    90%{
        opacity: 1;
        transform: scale(1);
    }
    100%{
        opacity: 0;
        transform: translateZ(-1000em);
    }
}
@keyframes spin-letters {
   0%,10%{
        opacity: 0;
        /* 钢管舞 */
        transform: rotateY(90deg);
   }
  30%{
        opacity: 1;
      
  }
  70%,86%{
    transform: rotateY(0deg);
    opacity: 1;
  }
  95%,100%{
    opacity: 0;
  }
}
@keyframes move-byline {
    0%{
        transform: translateZ(5em);
    }
    100%{
        transform: translateZ(0);
    }
}

bg.jpg

别再只会 console.log 了!这 15 个 Console 调试技巧,让你的 Debug 效率翻倍

2026年1月14日 11:39

"console.log('到这了')" "console.log('到这了 2')" "console.log('到这了 3')" "console.log('为什么不执行???')" —— 每个程序员的日常

前言:你真的会用 console 吗?

让我猜猜你的调试方式:

console.log(data)
console.log("data:", data)
console.log("========== 分割线 ==========")
console.log(data)
console.log("到这了")
console.log("到这了2")
console.log("为什么不进来???")

如果你中枪了,别担心,你不是一个人。

但是,console 对象其实有超过 20 个方法,而大多数人只会用 console.log

今天,我要带你解锁 console 的全部潜力。

学完这篇文章,你的调试效率至少提升 50%。


第一章:基础进阶——让 console.log 更好用

1.1 技巧一:用对象包裹变量名

这是最简单但最有用的技巧。

❌ 不好的写法:

const userName = "张三"
const userAge = 25
const userEmail = "zhangsan@example.com"

console.log(userName)
console.log(userAge)
console.log(userEmail)

// 输出:
// 张三
// 25
// zhangsan@example.com
// 问题:你根本不知道哪个是哪个!

✅ 好的写法:

const userName = "张三"
const userAge = 25
const userEmail = "zhangsan@example.com"

console.log({ userName, userAge, userEmail })

// 输出:
// { userName: '张三', userAge: 25, userEmail: 'zhangsan@example.com' }
// 清清楚楚,一目了然!

这个技巧利用了 ES6 的对象简写语法,变量名自动成为属性名。

1.2 技巧二:给输出加上 emoji 标签

当你的控制台输出很多的时候,找到你要的那条信息就像大海捞针。

解决方案:用 emoji 做视觉标记!

// 🔍 调试信息
console.log("🔍 正在查找用户:", userId)

// ✅ 成功信息
console.log("✅ 用户登录成功:", user.name)

// ❌ 错误信息
console.log("❌ 登录失败:", error.message)

// ⚠️ 警告信息
console.log("⚠️ 用户权限不足")

// 🚀 性能相关
console.log("🚀 API 响应时间:", responseTime, "ms")

// 📦 数据相关
console.log("📦 接收到的数据:", data)

// 🔄 状态变化
console.log("🔄 状态更新:", oldState, "->", newState)

在一堆黑白文字中,emoji 会非常显眼,让你一眼就能找到关键信息。

1.3 技巧三:使用模板字符串格式化

const user = { name: "张三", age: 25 }
const action = "登录"
const timestamp = new Date().toLocaleTimeString()

// ❌ 拼接字符串(丑陋且难读)
console.log("用户 " + user.name + " 在 " + timestamp + " 执行了 " + action)

// ✅ 模板字符串(清晰优雅)
console.log(`[${timestamp}] 用户 ${user.name} 执行了 ${action}`)

// 输出:[14:30:25] 用户 张三 执行了 登录

1.4 技巧四:console.log 的 CSS 样式

是的,你可以给 console.log 加 CSS 样式!

// 基础用法
console.log("%c这是红色文字", "color: red")
console.log("%c这是大号蓝色文字", "color: blue; font-size: 20px")

// 高级用法:多种样式组合
console.log(
  "%c 成功 %c 操作已完成",
  "background: #4CAF50; color: white; padding: 2px 6px; border-radius: 3px",
  "color: #4CAF50"
)

// 实用示例:创建一个漂亮的日志函数
const prettyLog = {
  success: (msg) =>
    console.log(
      `%c ✓ SUCCESS %c ${msg}`,
      "background: #4CAF50; color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold",
      "color: #4CAF50"
    ),
  error: (msg) =>
    console.log(
      `%c ✗ ERROR %c ${msg}`,
      "background: #f44336; color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold",
      "color: #f44336"
    ),
  warning: (msg) =>
    console.log(
      `%c ⚠ WARNING %c ${msg}`,
      "background: #ff9800; color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold",
      "color: #ff9800"
    ),
  info: (msg) =>
    console.log(
      `%c ℹ INFO %c ${msg}`,
      "background: #2196F3; color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold",
      "color: #2196F3"
    ),
}

// 使用
prettyLog.success("用户登录成功")
prettyLog.error("网络请求失败")
prettyLog.warning("API 即将废弃")
prettyLog.info("当前版本: 2.0.0")

第二章:数据展示——让复杂数据一目了然

2.1 技巧五:console.table() —— 表格展示数据

这可能是最被低估的 console 方法。

当你有一个对象数组时,console.log 的输出是这样的:

const users = [
  { id: 1, name: "张三", age: 25, city: "北京" },
  { id: 2, name: "李四", age: 30, city: "上海" },
  { id: 3, name: "王五", age: 28, city: "广州" },
]

console.log(users)
// 输出一堆难以阅读的嵌套对象...

但如果你用 console.table()

console.table(users)

// 输出一个漂亮的表格:
// ┌─────────┬────┬────────┬─────┬────────┐
// │ (index) │ id │  name  │ age │  city  │
// ├─────────┼────┼────────┼─────┼────────┤
// │    0    │ 1  │ '张三' │ 25  │ '北京' │
// │    1    │ 2  │ '李四' │ 30  │ '上海' │
// │    2    │ 3  │ '王五' │ 28  │ '广州' │
// └─────────┴────┴────────┴─────┴────────┘

还可以只显示特定列:

console.table(users, ["name", "city"])

// 只显示 name 和 city 列
// ┌─────────┬────────┬────────┐
// │ (index) │  name  │  city  │
// ├─────────┼────────┼────────┤
// │    0    │ '张三' │ '北京' │
// │    1    │ '李四' │ '上海' │
// │    2    │ '王五' │ '广州' │
// └─────────┴────────┴────────┘

适用场景:

  • API 返回的数据列表
  • 数据库查询结果
  • 配置项对比
  • 任何数组或对象的可视化

2.2 技巧六:console.dir() —— 查看对象的完整结构

console.log 打印 DOM 元素时,显示的是 HTML 结构。

console.dir 打印 DOM 元素时,显示的是对象属性。

const element = document.querySelector("#app")

console.log(element)
// 输出:<div id="app">...</div>(HTML 结构)

console.dir(element)
// 输出:div#app 的所有属性和方法(对象结构)
// 包括:className, id, innerHTML, style, onclick...

当你需要查看一个对象有哪些属性和方法时,用 console.dir

2.3 技巧七:console.dirxml() —— 查看 XML/HTML 结构

const element = document.querySelector("#app")

console.dirxml(element)
// 以 XML/HTML 树形结构展示元素及其子元素

第三章:分组与层级——让输出更有组织

3.1 技巧八:console.group() —— 分组输出

当你有一堆相关的日志时,可以把它们分组:

console.group("用户信息")
console.log("姓名: 张三")
console.log("年龄: 25")
console.log("城市: 北京")
console.groupEnd()

console.group("订单信息")
console.log("订单号: 12345")
console.log("金额: ¥99.00")
console.log("状态: 已支付")
console.groupEnd()

// 输出:
// ▼ 用户信息
//     姓名: 张三
//     年龄: 25
//     城市: 北京
// ▼ 订单信息
//     订单号: 12345
//     金额: ¥99.00
//     状态: 已支付

3.2 技巧九:console.groupCollapsed() —— 默认折叠的分组

console.groupCollapsed("详细调试信息(点击展开)")
console.log("这是一些详细的调试信息...")
console.log("通常不需要看,但需要时可以展开")
console.log("比如:完整的请求参数、响应数据等")
console.groupEnd()

// 输出:
// ▶ 详细调试信息(点击展开)  ← 默认是折叠的

适用场景:

  • 详细的调试信息(平时不看,出问题时展开)
  • 大量的数据输出
  • 嵌套的对象结构

3.3 技巧十:嵌套分组

console.group("🛒 购物车")

console.group("商品列表")
console.log("iPhone 15 Pro - ¥8999")
console.log("AirPods Pro - ¥1899")
console.groupEnd()

console.group("优惠信息")
console.log("满减: -¥500")
console.log("优惠券: -¥100")
console.groupEnd()

console.log("💰 总计: ¥10298")

console.groupEnd()

// 输出:
// ▼ 🛒 购物车
//     ▼ 商品列表
//         iPhone 15 Pro - ¥8999
//         AirPods Pro - ¥1899
//     ▼ 优惠信息
//         满减: -¥500
//         优惠券: -¥100
//     💰 总计: ¥10298

第四章:性能分析——找出代码瓶颈

4.1 技巧十一:console.time() —— 测量代码执行时间

这是性能调试的神器!

console.time("数据处理")

// 模拟一些耗时操作
const data = []
for (let i = 0; i < 100000; i++) {
  data.push({ id: i, value: Math.random() })
}

console.timeEnd("数据处理")
// 输出:数据处理: 45.123ms

可以同时计时多个操作:

console.time("总耗时")

console.time("获取数据")
const response = await fetch("/api/users")
const users = await response.json()
console.timeEnd("获取数据")
// 输出:获取数据: 234.56ms

console.time("处理数据")
const processedUsers = users.map((user) => ({
  ...user,
  fullName: `${user.firstName} ${user.lastName}`,
}))
console.timeEnd("处理数据")
// 输出:处理数据: 12.34ms

console.time("渲染")
renderUsers(processedUsers)
console.timeEnd("渲染")
// 输出:渲染: 89.01ms

console.timeEnd("总耗时")
// 输出:总耗时: 335.91ms

4.2 技巧十二:console.timeLog() —— 中间计时

console.time("多步骤操作")

await step1()
console.timeLog("多步骤操作", "步骤1完成")
// 输出:多步骤操作: 100.00ms 步骤1完成

await step2()
console.timeLog("多步骤操作", "步骤2完成")
// 输出:多步骤操作: 250.00ms 步骤2完成

await step3()
console.timeEnd("多步骤操作")
// 输出:多步骤操作: 400.00ms

4.3 技巧十三:console.count() —— 计数器

想知道某段代码执行了多少次?

function handleClick() {
  console.count("按钮点击")
  // 其他逻辑...
}

// 点击3次后:
// 按钮点击: 1
// 按钮点击: 2
// 按钮点击: 3

// 重置计数器
console.countReset("按钮点击")

实用场景:

function render(component) {
  console.count(`${component} 渲染次数`)
  // 渲染逻辑...
}

// 检查组件是否有不必要的重复渲染
render("Header") // Header 渲染次数: 1
render("Header") // Header 渲染次数: 2  ← 为什么渲染了两次?
render("Header") // Header 渲染次数: 3  ← 可能有性能问题!

第五章:错误追踪——快速定位问题

5.1 技巧十四:console.trace() —— 打印调用栈

当你想知道"这个函数是从哪里被调用的"时:

function functionA() {
  functionB()
}

function functionB() {
  functionC()
}

function functionC() {
  console.trace("调用栈追踪")
}

functionA()

// 输出:
// 调用栈追踪
//     at functionC (script.js:10)
//     at functionB (script.js:6)
//     at functionA (script.js:2)
//     at script.js:13

这在调试复杂的回调链或事件处理时特别有用。

5.2 技巧十五:console.assert() —— 条件断言

只有当条件为 false 时才输出:

const user = { name: "张三", age: 25 }

// 如果条件为 true,什么都不输出
console.assert(user.name, "用户名不能为空")

// 如果条件为 false,输出错误信息
console.assert(user.age >= 18, "用户必须成年")
console.assert(user.email, "用户邮箱不能为空")
// 输出:Assertion failed: 用户邮箱不能为空

适用场景:

function processOrder(order) {
  console.assert(order, "订单不能为空")
  console.assert(order.items?.length > 0, "订单必须包含商品")
  console.assert(order.totalAmount > 0, "订单金额必须大于0")

  // 如果所有断言都通过,继续处理...
}

第六章:实战技巧——日常开发中的最佳实践

6.1 创建一个增强版 Logger

// logger.js - 一个实用的日志工具

const isDev = process.env.NODE_ENV === "development"

const logger = {
  // 基础日志(只在开发环境输出)
  log: (...args) => {
    if (isDev) console.log("📝", ...args)
  },

  // 信息日志
  info: (...args) => {
    if (isDev)
      console.log(
        "%c ℹ️ INFO ",
        "background: #2196F3; color: white; border-radius: 3px",
        ...args
      )
  },

  // 成功日志
  success: (...args) => {
    if (isDev)
      console.log(
        "%c ✅ SUCCESS ",
        "background: #4CAF50; color: white; border-radius: 3px",
        ...args
      )
  },

  // 警告日志
  warn: (...args) => {
    console.warn("⚠️", ...args) // 警告在生产环境也输出
  },

  // 错误日志
  error: (...args) => {
    console.error("❌", ...args) // 错误在生产环境也输出
  },

  // 分组日志
  group: (label, fn) => {
    if (!isDev) return fn()
    console.group(`📦 ${label}`)
    const result = fn()
    console.groupEnd()
    return result
  },

  // 计时日志
  time: async (label, fn) => {
    if (!isDev) return fn()
    console.time(`⏱️ ${label}`)
    const result = await fn()
    console.timeEnd(`⏱️ ${label}`)
    return result
  },

  // 表格日志
  table: (data, columns) => {
    if (isDev) console.table(data, columns)
  },
}

export default logger

// 使用示例
import logger from "./logger"

logger.info("应用启动")
logger.success("用户登录成功", { userId: 123 })
logger.warn("API 即将废弃")
logger.error("网络请求失败", error)

logger.group("用户数据", () => {
  logger.log("姓名:", user.name)
  logger.log("年龄:", user.age)
})

await logger.time("数据加载", async () => {
  return await fetchData()
})

6.2 调试 API 请求

// 创建一个 API 调试拦截器
const debugFetch = async (url, options = {}) => {
  const requestId = Math.random().toString(36).substr(2, 9)

  console.groupCollapsed(
    `🌐 API 请求 [${requestId}] ${options.method || "GET"} ${url}`
  )

  console.log("📤 请求参数:", {
    url,
    method: options.method || "GET",
    headers: options.headers,
    body: options.body ? JSON.parse(options.body) : undefined,
  })

  console.time(`⏱️ 响应时间 [${requestId}]`)

  try {
    const response = await fetch(url, options)
    const data = await response.clone().json()

    console.timeEnd(`⏱️ 响应时间 [${requestId}]`)
    console.log("📥 响应状态:", response.status, response.statusText)
    console.log("📦 响应数据:", data)

    if (!response.ok) {
      console.error("❌ 请求失败")
    } else {
      console.log("✅ 请求成功")
    }

    console.groupEnd()
    return response
  } catch (error) {
    console.timeEnd(`⏱️ 响应时间 [${requestId}]`)
    console.error("❌ 请求异常:", error.message)
    console.groupEnd()
    throw error
  }
}

// 使用
const response = await debugFetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "张三" }),
})

6.3 调试 React 组件渲染

// 在 React 组件中使用
function UserProfile({ userId }) {
  console.count(`UserProfile 渲染 (userId: ${userId})`)

  useEffect(() => {
    console.log("🔄 UserProfile useEffect 触发", { userId })

    return () => {
      console.log("🧹 UserProfile 清理", { userId })
    }
  }, [userId])

  console.group("📊 UserProfile 渲染详情")
  console.log("Props:", { userId })
  console.log("渲染时间:", new Date().toLocaleTimeString())
  console.groupEnd()

  return <div>...</div>
}

6.4 调试状态变化

// 创建一个状态变化追踪器
function createStateTracker(initialState, name = "State") {
  let state = initialState

  return {
    get: () => state,
    set: (newState) => {
      console.group(`🔄 ${name} 变化`)
      console.log("旧值:", state)
      console.log("新值:", newState)
      console.trace("调用来源")
      console.groupEnd()

      state = newState
      return state
    },
  }
}

// 使用
const userState = createStateTracker({ name: "", loggedIn: false }, "UserState")

userState.set({ name: "张三", loggedIn: true })
// 输出:
// ▼ 🔄 UserState 变化
//     旧值: { name: '', loggedIn: false }
//     新值: { name: '张三', loggedIn: true }
//     调用来源: (调用栈)

第七章:Console 方法速查表

┌─────────────────────────────────────────────────────────────────────────┐
│                        Console 方法速查表                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  📝 基础输出                                                             │
│  ├─ console.log()      普通日志输出                                     │
│  ├─ console.info()     信息日志(某些浏览器有特殊图标)                  │
│  ├─ console.warn()     警告日志(黄色背景)                              │
│  └─ console.error()    错误日志(红色背景)                              │
│                                                                         │
│  📊 数据展示                                                             │
│  ├─ console.table()    以表格形式展示数组/对象                          │
│  ├─ console.dir()      以对象形式展示(查看属性和方法)                  │
│  └─ console.dirxml()   以 XML/HTML 形式展示 DOM 元素                    │
│                                                                         │
│  📁 分组管理                                                             │
│  ├─ console.group()         创建展开的分组                              │
│  ├─ console.groupCollapsed() 创建折叠的分组                             │
│  └─ console.groupEnd()      结束当前分组                                │
│                                                                         │
│  ⏱️ 性能分析                                                             │
│  ├─ console.time()     开始计时                                         │
│  ├─ console.timeLog()  输出中间时间                                     │
│  ├─ console.timeEnd()  结束计时并输出                                   │
│  └─ console.count()    计数器(统计调用次数)                           │
│                                                                         │
│  🔍 调试追踪                                                             │
│  ├─ console.trace()    打印调用栈                                       │
│  ├─ console.assert()   条件断言(条件为 false 时输出)                  │
│  └─ console.clear()    清空控制台                                       │
│                                                                         │
│  🎨 样式输出                                                             │
│  └─ console.log('%c文字', 'CSS样式')  带样式的输出                      │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

第八章:常见问题与注意事项

8.1 生产环境要移除 console

问题: console 语句会影响性能,也可能泄露敏感信息。

解决方案:

// 方案1:使用环境变量控制
if (process.env.NODE_ENV === 'development') {
  console.log('调试信息')
}

// 方案2:使用构建工具移除
// webpack 配置(使用 terser-webpack-plugin)
optimization: {
  minimizer: [
    new TerserPlugin({
      terserOptions: {
        compress: {
          drop_console: true,  // 移除所有 console
        },
      },
    }),
  ],
}

// 方案3:使用 babel 插件
// babel.config.js
plugins: [
  ['transform-remove-console', { exclude: ['error', 'warn'] }]
]

// 方案4:使用 ESLint 规则
// .eslintrc.js
rules: {
  'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn'
}

8.2 console.log 的异步陷阱

问题: console.log 打印对象时,显示的是引用,不是快照。

const obj = { count: 0 }
console.log(obj) // 可能显示 { count: 1 } 而不是 { count: 0 }
obj.count = 1

解决方案:

// 方案1:使用 JSON.stringify
console.log(JSON.stringify(obj))

// 方案2:使用展开运算符创建浅拷贝
console.log({ ...obj })

// 方案3:使用 JSON.parse + JSON.stringify 创建深拷贝
console.log(JSON.parse(JSON.stringify(obj)))

// 方案4:使用 structuredClone(现代浏览器)
console.log(structuredClone(obj))

8.3 大对象的性能问题

问题: 打印大对象会导致浏览器卡顿。

// ❌ 不好:打印整个大数组
console.log(hugeArray) // 可能有 10000 个元素

// ✅ 好:只打印需要的部分
console.log("数组长度:", hugeArray.length)
console.log("前10个元素:", hugeArray.slice(0, 10))
console.log("第一个元素:", hugeArray[0])

结语:从 console.log 到 console 大师

今天我们学习了 15 个 console 调试技巧:

  1. 用对象包裹变量名 - 自动显示变量名
  2. 用 emoji 标签 - 视觉标记,快速定位
  3. 模板字符串格式化 - 清晰优雅的输出
  4. CSS 样式 - 让日志更醒目
  5. console.table() - 表格展示数据
  6. console.dir() - 查看对象结构
  7. console.dirxml() - 查看 XML/HTML 结构
  8. console.group() - 分组输出
  9. console.groupCollapsed() - 折叠分组
  10. 嵌套分组 - 层级结构
  11. console.time() - 测量执行时间
  12. console.timeLog() - 中间计时
  13. console.count() - 计数器
  14. console.trace() - 调用栈追踪
  15. console.assert() - 条件断言

记住:好的调试习惯能让你的开发效率翻倍。

下次当你想写 console.log('到这了') 的时候,想想有没有更好的方式。


附录:Console 快捷键

浏览器控制台快捷键:

打开控制台:
├─ Windows/Linux: F12  Ctrl + Shift + J
└─ Mac: Cmd + Option + J

控制台内操作:
├─ 清空控制台: Ctrl + L (Windows) / Cmd + K (Mac)
├─ 多行输入: Shift + Enter
├─ 执行代码: Enter
├─ 上一条命令: 
├─ 下一条命令: 
└─ 自动补全: Tab

如果你觉得这篇文章有用,请分享给你那个还在写 console.log('111') 的同事。

也许他需要知道:console 的世界,远比你想象的精彩。 🎨


最后,送给所有程序员一句话:

"调试的艺术,在于问对问题,而不是打更多的 log。" 🔍

愿你的 bug 越来越少,调试越来越快。

Cesium 深入浅出 《一》WGS84、ECEF、经纬高:Cesium 世界坐标到底是什么?

作者 图素
2026年1月8日 20:19

在cesium中存在多种坐标系,因为cesium作为一个空间地理的渲染引擎,需要处理从地理坐标到3D渲染的转换,理解这些坐标之间的联系,是cesium的基础。也是任何一个地理渲染引擎的基础。

这篇文章带大家理解cesium中的坐标系,顺便对比一下three.js的区别。

1、WGS84

WGS 84是全球定位系统(GPS)的基准坐标系统,广泛应用于全球定位和导航。它采用十进制度表示经度和纬度。(这句话是摘录维基百科的介绍)

简而言之这个坐标系统用的是用经纬度来表示位置

2、经纬高

用经纬度和高程来表示空间位置

  • 经度(longitude):范围 [-π, π],单位弧度
  • 纬度(latitude):范围 [-π/2, π/2],单位弧度
  • 高度(height):相对于椭球面的高度,单位米

在 Cesium 中用 Cartographic 表示,注意角度单位为弧度。

3、ECEF(Earth-Centered, Earth-Fixed)

地心地固坐标系,也称为三维笛卡尔坐标

  • 原点:地心
  • X 轴:指向本初子午线与赤道交点
  • Z 轴:指向北极
  • Y 轴:与 X、Z 构成右手系

在 Cesium 中用 Cartesian3 表示,单位为米。这是 Cesium 场景中的世界坐标。

image.png

上图为ECEF坐标系

结论:Cesium的世界坐标系就是ECEF坐标系。所有三维物体都是基于这个坐标系进行摆放,三维物体通过 modelMatrix会把局部坐标系转化为世界坐标系

cesium中常用的坐标转换方法

经纬度->笛卡尔

// 经纬度是角度值时候转为笛卡尔
Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, height, ellipsoid?, result?)
// 弧度制时转为笛卡尔
Cesium.Cartesian3.fromRadians(lonRad, latRad, height, ellipsoid?, result?)
// 通过球体转为笛卡尔
ellipsoid.cartographicToCartesian(cartographic, result?)

我们来根据源码来看看经纬度是如何计算出笛卡尔坐标的:

image.png

/**
 * Returns a Cartesian3 position from longitude and latitude values given in radians.
 *
 * @param {number} longitude The longitude, in radians
 * @param {number} latitude The latitude, in radians
 * @param {number} [height=0.0] The height, in meters, above the ellipsoid.
 * @param {Ellipsoid} [ellipsoid=Ellipsoid.default] The ellipsoid on which the position lies.
 * @param {Cartesian3} [result] The object onto which to store the result.
 * @returns {Cartesian3} The position
 *
 * @example
 * const position = Cesium.Cartesian3.fromRadians(-2.007, 0.645);
 */
Cartesian3.fromRadians = function (
  longitude,
  latitude,
  height,
  ellipsoid,
  result,
) {
  //>>includeStart('debug', pragmas.debug);
  Check.typeOf.number("longitude", longitude);
  Check.typeOf.number("latitude", latitude);
  //>>includeEnd('debug');

  height = height ?? 0.0;

  const radiiSquared = !defined(ellipsoid)
    ? Cartesian3._ellipsoidRadiiSquared
    : ellipsoid.radiiSquared;

  const cosLatitude = Math.cos(latitude);
  // 如上图可以求得 从原点指向目标点的向量 scratchN 
  scratchN.x = cosLatitude * Math.cos(longitude);
  scratchN.y = cosLatitude * Math.sin(longitude);
  scratchN.z = Math.sin(latitude);
  // 得到球心指向椭球面的向量
  scratchN = Cartesian3.normalize(scratchN, scratchN);

  // 这个地方cesium 用的计算方法我也没有推导过,数学好的可以自己推导一下
  // 最简单的使用解析几何的椭圆公式和向量的交点,可以求出椭圆上的点
  // cesium这里的方法计算方式更简洁一点,通过计算缩放因子把向量缩放到椭圆上
  Cartesian3.multiplyComponents(radiiSquared, scratchN, scratchK);
  const gamma = Math.sqrt(Cartesian3.dot(scratchN, scratchK));
  // 得到椭圆上的点坐标
  scratchK = Cartesian3.divideByScalar(scratchK, gamma, scratchK);  
  // 得到长度为height的地心方向向量的向量
  scratchN = Cartesian3.multiplyByScalar(scratchN, height, scratchN); 

  if (!defined(result)) {
    result = new Cartesian3();
  }
  // 最后把椭圆上的点再验证地心法线平移height,得到ecef坐标
  return Cartesian3.add(scratchK, scratchN, result);
};

笛卡尔->经纬度

Cesium.Cartographic.fromCartesian(cartesian, ellipsoid?, result?)
ellipsoid.cartesianToCartographic(cartesian, result?)

源码的计算过程:还挺复杂的,用的牛顿迭代法,数学好的朋友可以自行去推导。

/**
 * Creates a new Cartographic instance from a Cartesian position. The values in the
 * resulting object will be in radians.
 *
 * @param {Cartesian3} cartesian The Cartesian position to convert to cartographic representation.
 * @param {Ellipsoid} [ellipsoid=Ellipsoid.default] The ellipsoid on which the position lies.
 * @param {Cartographic} [result] The object onto which to store the result.
 * @returns {Cartographic} The modified result parameter, new Cartographic instance if none was provided, or undefined if the cartesian is at the center of the ellipsoid.
 */
Cartographic.fromCartesian = function (cartesian, ellipsoid, result) {
  const oneOverRadii = defined(ellipsoid)
    ? ellipsoid.oneOverRadii
    : Cartographic._ellipsoidOneOverRadii;
  const oneOverRadiiSquared = defined(ellipsoid)
    ? ellipsoid.oneOverRadiiSquared
    : Cartographic._ellipsoidOneOverRadiiSquared;
  const centerToleranceSquared = defined(ellipsoid)
    ? ellipsoid._centerToleranceSquared
    : Cartographic._ellipsoidCenterToleranceSquared;

  //`cartesian is required.` is thrown from scaleToGeodeticSurface
  const p = scaleToGeodeticSurface(
    cartesian,
    oneOverRadii,
    oneOverRadiiSquared,
    centerToleranceSquared,
    cartesianToCartographicP,
  );

  if (!defined(p)) {
    return undefined;
  }

  let n = Cartesian3.multiplyComponents(
    p,
    oneOverRadiiSquared,
    cartesianToCartographicN,
  );
  n = Cartesian3.normalize(n, n);

  const h = Cartesian3.subtract(cartesian, p, cartesianToCartographicH);

  const longitude = Math.atan2(n.y, n.x);
  const latitude = Math.asin(n.z);
  const height =
    CesiumMath.sign(Cartesian3.dot(h, cartesian)) * Cartesian3.magnitude(h);

  if (!defined(result)) {
    return new Cartographic(longitude, latitude, height);
  }
  result.longitude = longitude;
  result.latitude = latitude;
  result.height = height;
  return result;
};

/**
 * Scales the provided Cartesian position along the geodetic surface normal
 * so that it is on the surface of this ellipsoid.  If the position is
 * at the center of the ellipsoid, this function returns undefined.
 *
 * @param {Cartesian3} cartesian The Cartesian position to scale.
 * @param {Cartesian3} oneOverRadii One over radii of the ellipsoid.
 * @param {Cartesian3} oneOverRadiiSquared One over radii squared of the ellipsoid.
 * @param {number} centerToleranceSquared Tolerance for closeness to the center.
 * @param {Cartesian3} [result] The object onto which to store the result.
 * @returns {Cartesian3} The modified result parameter, a new Cartesian3 instance if none was provided, or undefined if the position is at the center.
 *
 * @function scaleToGeodeticSurface
 *
 * @private
 */
function scaleToGeodeticSurface(
  cartesian,
  oneOverRadii,
  oneOverRadiiSquared,
  centerToleranceSquared,
  result,
) {
  //>>includeStart('debug', pragmas.debug);
  if (!defined(cartesian)) {
    throw new DeveloperError("cartesian is required.");
  }
  if (!defined(oneOverRadii)) {
    throw new DeveloperError("oneOverRadii is required.");
  }
  if (!defined(oneOverRadiiSquared)) {
    throw new DeveloperError("oneOverRadiiSquared is required.");
  }
  if (!defined(centerToleranceSquared)) {
    throw new DeveloperError("centerToleranceSquared is required.");
  }
  //>>includeEnd('debug');

  const positionX = cartesian.x;
  const positionY = cartesian.y;
  const positionZ = cartesian.z;

  const oneOverRadiiX = oneOverRadii.x;
  const oneOverRadiiY = oneOverRadii.y;
  const oneOverRadiiZ = oneOverRadii.z;

  const x2 = positionX * positionX * oneOverRadiiX * oneOverRadiiX;
  const y2 = positionY * positionY * oneOverRadiiY * oneOverRadiiY;
  const z2 = positionZ * positionZ * oneOverRadiiZ * oneOverRadiiZ;

  // Compute the squared ellipsoid norm.
  const squaredNorm = x2 + y2 + z2;
  const ratio = Math.sqrt(1.0 / squaredNorm);

  // As an initial approximation, assume that the radial intersection is the projection point.
  const intersection = Cartesian3.multiplyByScalar(
    cartesian,
    ratio,
    scaleToGeodeticSurfaceIntersection,
  );

  // If the position is near the center, the iteration will not converge.
  if (squaredNorm < centerToleranceSquared) {
    return !isFinite(ratio)
      ? undefined
      : Cartesian3.clone(intersection, result);
  }

  const oneOverRadiiSquaredX = oneOverRadiiSquared.x;
  const oneOverRadiiSquaredY = oneOverRadiiSquared.y;
  const oneOverRadiiSquaredZ = oneOverRadiiSquared.z;

  // Use the gradient at the intersection point in place of the true unit normal.
  // The difference in magnitude will be absorbed in the multiplier.
  const gradient = scaleToGeodeticSurfaceGradient;
  gradient.x = intersection.x * oneOverRadiiSquaredX * 2.0;
  gradient.y = intersection.y * oneOverRadiiSquaredY * 2.0;
  gradient.z = intersection.z * oneOverRadiiSquaredZ * 2.0;

  // Compute the initial guess at the normal vector multiplier, lambda.
  let lambda =
    ((1.0 - ratio) * Cartesian3.magnitude(cartesian)) /
    (0.5 * Cartesian3.magnitude(gradient));
  let correction = 0.0;

  let func;
  let denominator;
  let xMultiplier;
  let yMultiplier;
  let zMultiplier;
  let xMultiplier2;
  let yMultiplier2;
  let zMultiplier2;
  let xMultiplier3;
  let yMultiplier3;
  let zMultiplier3;

  do {
    lambda -= correction;

    xMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredX);
    yMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredY);
    zMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredZ);

    xMultiplier2 = xMultiplier * xMultiplier;
    yMultiplier2 = yMultiplier * yMultiplier;
    zMultiplier2 = zMultiplier * zMultiplier;

    xMultiplier3 = xMultiplier2 * xMultiplier;
    yMultiplier3 = yMultiplier2 * yMultiplier;
    zMultiplier3 = zMultiplier2 * zMultiplier;

    func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0;

    // "denominator" here refers to the use of this expression in the velocity and acceleration
    // computations in the sections to follow.
    denominator =
      x2 * xMultiplier3 * oneOverRadiiSquaredX +
      y2 * yMultiplier3 * oneOverRadiiSquaredY +
      z2 * zMultiplier3 * oneOverRadiiSquaredZ;

    const derivative = -2.0 * denominator;

    correction = func / derivative;
  } while (Math.abs(func) > CesiumMath.EPSILON12);

  if (!defined(result)) {
    return new Cartesian3(
      positionX * xMultiplier,
      positionY * yMultiplier,
      positionZ * zMultiplier,
    );
  }
  result.x = positionX * xMultiplier;
  result.y = positionY * yMultiplier;
  result.z = positionZ * zMultiplier;
  return result;
}

弧度/角度换算

  • Cesium.Math.toRadians(deg)
  • Cesium.Math.toDegrees(rad)

Three.js 中的世界坐标系

最后我们来对比一下Three.js 中的世界坐标系,看下图,

Three.js 世界坐标中心点可以是任意点,通常放在内容的中心。默认右手坐标系,并且约定+Y向上,相机默认朝 -Z 方向看(所以“前方”常被理解为 -Z)向后(+Z)

image (1).png

three.js 世界坐标系

❌
❌