普通视图

发现新文章,点击刷新页面。
昨天 — 2025年6月8日首页

纯css实现一个沙漏动画

作者 JYeontu
2025年6月8日 00:37

说在前面

沙漏大家都见过吧,使用js的话相信大家都能很轻易地实现一个沙漏动画吧,今天我们纯css来实现一个试试。

在线体验

码上掘金

codePen

codepen.io/yongtaozhen…

代码实现

html

<div class="container">
  <div class="hourglass">
    <div class="frame"></div>
    <div class="glass"></div>
    <div class="sand-top-stroke"></div>
    <div class="sand-top"></div>
    <div class="sand-bottom-stroke"></div>
    <div class="sand-bottom"></div>
    <div class="sand-flow"></div>
    <div class="sand-drop"></div>
    <div class="glass-reflection"></div>
  </div>
</div>
  • container:包裹整个沙漏,调整整个沙漏的定位
  • hourglass:沙漏的主容器
  • frame:沙漏的外框,一个木架子

  • sand-top-strokesand-bottom-stroke:存放沙子的玻璃容器

  • sand-topsand-bottom:上下部分的沙子

  • sand-flow:连接上下两部分沙漏的管道

  • sand-drop:滴落的沙子

  • glass-reflection:添加一个玻璃反光效果

css

通用变量

:root {
  --rotateTime: 10s;
}

定义沙漏动画时间,在动画中需要用到。

沙漏翻转

.hourglass {
  position: relative;
  width: 120px;
  height: 200px;
  margin: 0 auto;
  animation: rotate var(--rotateTime) linear infinite;
  transform-origin: center 100px;
}
@keyframes rotate {
  0% {
    transform: rotate(0deg);
  }
  45% {
    transform: rotate(0deg);
  }
  50% {
    transform: rotate(180deg);
  }
  99% {
    transform: rotate(180deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

计时结束(沙漏沙子漏完)后需要将整个沙漏框架翻转,在rotateTime时间内沙漏需要翻转2次,也就是说沙漏漏完一次的时间是rotateTime

沙堆减少和增加

.sand-top {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 90px;
  background-color: #f5d76e;
  clip-path: polygon(0 0, 100% 0, 51% 100%, 49% 100%);
  border-top-left-radius: 10px;
  border-top-right-radius: 10px;
  animation: sand-top var(--rotateTime) linear infinite;
}
.sand-bottom {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 0;
  background-color: #f5d76e;
  clip-path: polygon(49% 0, 51% 0, 100% 100%, 0 100%);
  animation: sand-bottom var(--rotateTime) linear infinite;
  border-bottom-left-radius: 10px;
  border-bottom-right-radius: 10px;
}
/* 上半部分沙子减少动画 */
@keyframes sand-top {
  0% {
    height: 90px;
    width: 100%;
  }
  49% {
    height: 0;
    width: 0;
    left: 50%;
    top: 90px;
  }
  50% {
    height: 0px;
    width: 100%;
    top: 0;
    left: 0;
  }
  99% {
    width: 100%;
    height: 90px;
  }
  100% {
    width: 100%;
    height: 90px;
  }
}

/* 下半部分沙子增加动画 */
@keyframes sand-bottom {
  0% {
    height: 0;
    width: 100%;
    bottom: 0;
    left: 0;
  }
  50% {
    height: 90px;
    width: 100%;
    bottom: 0;
    left: 0;
  }
  51% {
    height: 90px;
    width: 100%;
    bottom: 0;
    left: 0;
  }
  99% {
    height: 0px;
    width: 0;
    bottom: 90px;
    left: 50%;
  }
  100% {
    height: 0px;
    width: 0;
    bottom: 90px;
    left: 50%;
  }
}

上半部分沙堆需要先减少后增加,下半部分沙堆需要先增加后减少;通过改变高度、宽度和位置,模拟沙子流动的过程。

管道沙子滴落

@keyframes sand-drop {
  0% {
    opacity: 1;
    transform: translate(-50%, 0);
  }
  4.9%,
  9.9%,
  14.9%,
  ……,
  44.9%,
  49.9% {
    opacity: 0;
    transform: translate(-50%, 15px);
  }
  5%,
  10%,
  15%,
  ……,
  40%,
  45% {
    opacity: 0;
    transform: translate(-50%, 0);
  }
  5.1%,
  10.1%,
  ……,
  90.1%,
  95.1% {
    opacity: 1;
  }
  50%,
  55%,
  ……,
  100% {
    opacity: 0;
    transform: translate(-50%, -15px);
  }
  54.9%,
  59.9%,
  64.9%,
  ……,
  94.9%,
  99.9% {
    opacity: 0;
    transform: translate(-50%, -30px);
  }
}

通过控制透明度和位移,模拟沙子滴落的连贯动作,每5%的动画时间完成一个滴落动作,以10s为例的话也就是每0.5s完成一个滴落动作;因为沙子漏完之后整个沙漏需要翻转180°,所以沙子滴落的动画应该分为前后两段,前半段是从上往下滴( transform: translate(-50%, 15px) ),后半段是从下往上滴( transform: translate(-50%, -15px) )。

源码

gitee

gitee.com/zheng_yongt…

github

github.com/yongtaozhen…


  • 🌟 觉得有帮助的可以点个 star~
  • 🖊 有什么问题或错误可以指出,欢迎 pr~
  • 📬 有什么想要实现的功能或想法可以联系我~

公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

昨天以前首页

使用js方法实现阻止按钮的默认点击事件&触发默认事件

作者 hang_bro
2025年6月6日 18:41

功能需求

  1.谷歌浏览器插件需要实现在用户提交表单的时候触发阻止默认行为
  2.对表单数据进行分析,提交给ai分析
  3.ai对用户表单进行分析并返回结果
  4.有问题回显对应文案
  5.没问题则提交表单的默认点击事件

实现思路

测试表单

<!doctype html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite + React + TS + chrome</title>
  <link rel="stylesheet" href="/form.css">
  <!-- <script src="//g.alicdn.com/chatui/icons/2.6.2/index.js"></script> -->
</head>

<body>
  <form onsubmit="return false" action="#">
    <!-- <input name="name" id="name" type="text" class="feedback-input" placeholder="Name" />
    <input name="email" id="email" type="text" class="feedback-input" placeholder="Email" /> -->
    <textarea name="text" id="text" class="feedback-input" placeholder="Comment"></textarea>
    <input type="submit" id="submitBtn" value="SUBMIT" />
  </form>
  <div id="root"></div>
  <script>
    window.onload = function () {
      const submitBtn = document.getElementById('submitBtn')
      submitBtn.addEventListener('click', onsubmit)
      function onsubmit(e) {
        const text = document.getElementById('text').value
       alert(text)
      }
    }

  </script>
  <script type="module" src="/src/main.tsx"></script>
</body>

</html>

插件代码

  useEffect(() => {
    const submitBtn = document.getElementById('submitBtn')! as HTMLButtonElement
    const overlayId = uuid()
    // 创建覆盖层
    const createOverlay = () => {
      const overlay = document.createElement('div')
      overlay.id = overlayId
      // 获取按钮位置和大小
      const updateOverlayPosition = () => {
        const rect = submitBtn.getBoundingClientRect()
        overlay.style.position = 'fixed'
        overlay.style.top = `${rect.top}px`
        overlay.style.left = `${rect.left}px`
        overlay.style.width = `${rect.width}px`
        overlay.style.height = `${rect.height}px`
        overlay.style.backgroundColor = 'transparent'
        overlay.style.zIndex = '100'
        overlay.style.cursor = 'pointer'
        overlay.style.pointerEvents = 'auto'
      }
      // 初始定位
      updateOverlayPosition()
      // 添加到DOM
      document.body.appendChild(overlay)
      // 监听窗口变化,更新位置
      window.addEventListener('resize', updateOverlayPosition)
      window.addEventListener('scroll', updateOverlayPosition)
      // 返回清理函数
      return () => {
        window.removeEventListener('resize', updateOverlayPosition)
        window.removeEventListener('scroll', updateOverlayPosition)
        if (overlay.parentNode) overlay.parentNode.removeChild(overlay)
      }
    }
    // 创建覆盖层并获取清理函数
    const removeOverlay = createOverlay()

    // 为覆盖层添加点击事件
    const overlay = document.getElementById(overlayId)! as HTMLDivElement
    overlay.addEventListener('click', handleClick)
    function handleClick() {
      const fn = () => {
        const name = (document.getElementById('name')! as HTMLInputElement)?.value
        const email = (document.getElementById('email')! as HTMLInputElement)?.value
        const text = (document.getElementById('text')! as HTMLInputElement)?.value
        return {
          name,
          email,
          text,
        }
      }
      if (isThinkingtRef.current) return
      setIsThinking(true)
      const { name, email, text } = fn()
      console.log(`text ==>`, text)
      if (text.trim() !== '') {
        onSend('text', `用户输入:\n${text}\n帮我分析一下输入是否通顺`)
      }
    }

    // 添加清理函数
    return () => {
      console.log(`清理函数==>`)
      overlay.removeEventListener('click', handleClick)
      removeOverlay()
    }
  }, [isThinkingtRef.current]) // 添加activeButton为依赖项


实现思路

创建一个div,将它覆盖到原有的按钮上,并添加点击事件,触发原本方法则使用获取dom,触发点击事件
ok!

【HTML篇】HTML 语义化标签:构建更清晰的网页结构

作者 LuckySusu
2025年6月6日 18:23

在前端开发的世界里,HTML 语义化不仅仅是一个编码习惯,它是一种能够提升代码可读性、SEO 性能以及无障碍访问的技术实践。本文将深入探讨 HTML 语义化的概念、重要性,并介绍一些常见的语义化标签及其应用场景。


📌 一、什么是 HTML 语义化?

HTML 语义化是指使用合适的 HTML 标签来标记内容,使 HTML 文档不仅对浏览器友好,而且对开发者和搜索引擎爬虫等“机器”也更加友好。简而言之,就是让 HTML 标签表达其应有的含义,而不是单纯地为了样式或布局效果选择标签。

示例:

<header></header>
<nav></nav>
<section></section>
<main></main>
<article></article>
<aside></aside>
<footer></footer>

这些标签不仅仅是视觉上的区分,它们传达了页面各部分的实际意义,有助于更好地理解文档结构。


📌 二、为什么需要 HTML 语义化?

✅ 对机器友好:

  • SEO(搜索引擎优化):搜索引擎爬虫通过分析页面中的标签来判断页面的内容质量。使用语义化标签可以帮助爬虫更好地识别关键信息,从而提高网站的搜索排名。
  • 辅助技术的支持:屏幕阅读器等辅助工具依赖于 HTML 标签来为视障用户提供网页内容的语音描述。正确的语义化标签可以让这些用户更容易理解和浏览网页。

✅ 对开发者友好:

  • 增强可读性和维护性:清晰的语义化标签使得代码更易于阅读,团队成员之间可以更快地理解彼此的意图,减少沟通成本。
  • 简化 CSS 和 JavaScript 操作:利用 HTML5 提供的丰富语义化标签,可以减少对 divspan 的过度依赖,使得样式表和脚本编写更为简洁高效。

📌 三、常见 HTML 语义化标签及其应用

1. <header> - 页面头部

通常包含站点标题、标志、导航链接等信息。

<header>
    <h1>我的博客</h1>
    <nav>
        <ul>
            <li><a href="#home">首页</a></li>
            <li><a href="#about">关于我们</a></li>
        </ul>
    </nav>
</header>

2. <nav> - 导航栏

专门用于定义页面内的主要导航区域。

<nav>
    <ul>
        <li><a href="#blog">博客文章</a></li>
        <li><a href="#contact">联系我们</a></li>
    </ul>
</nav>

3. <section> - 区块

用于分隔页面的不同部分,每个部分都有独立的主题。

<section>
    <h2>最新文章</h2>
    <!-- 文章列表 -->
</section>

4. <main> - 主要内容区

代表文档的主要内容,一个页面只能有一个 <main> 标签。

<main>
    <p>这里是主要内容...</p>
</main>

5. <article> - 独立的文章或帖子

适用于博客文章、新闻报道等内容。

<article>
    <h2>如何学习编程</h2>
    <p>编程是现代科技的核心技能之一...</p>
</article>

6. <aside> - 侧边栏

用来放置与主内容相关但不直接相关的补充信息。

<aside>
    <h3>相关链接</h3>
    <ul>
        <li><a href="#">前端教程</a></li>
        <li><a href="#">后端教程</a></li>
    </ul>
</aside>

7. <footer> - 底部

通常包含版权信息、联系方式等。

<footer>
    <p>&copy; 2025 我的公司 版权所有</p>
</footer>

📌 四、最佳实践建议

  1. 优先考虑语义化标签

    • 尽量避免滥用 divspan,除非确实没有合适的语义化标签可用。
  2. 保持一致性

    • 在整个项目中保持相同的命名规则和标签使用习惯,便于团队协作。
  3. 合理嵌套

    • 注意标签之间的层次关系,确保结构清晰,例如 <article> 内可以包含多个 <section>,而 <section> 可以包含 <article>
  4. 注意 ARIA 属性

    • 当原生 HTML 标签不足以表达内容时,可以结合 ARIA(Accessible Rich Internet Applications)属性来增强无障碍支持。

📊 五、总结

HTML 语义化不仅是前端开发的一项基本功,更是打造高质量网页的基础。通过正确使用语义化标签,我们可以创建出既美观又实用的网页,同时还能显著提升 SEO 效果和用户体验。

无论是初学者还是有经验的开发者,都应该重视 HTML 语义化的实践,因为它不仅能帮助你写出更好的代码,也能让你的作品更具专业性和竞争力。

从Web1.0到WebLLM:前端如何通过HTTP API拥抱大模型?

2025年6月5日 23:25

从Web1.0到WebLLM:前端如何通过HTTP API拥抱大模型?

在前端开发的20余年发展中,技术的每一次突破都围绕一个核心目标——让用户体验更智能、开发过程更高效。探讨大模型时代前端开发的新范式:通过HTTP API调用远程大模型(如DeepSeek),让前端从“页面渲染工具”升级为“智能交互入口”。


一、大模型时代的前端新命题:如何“调用”AI能力?

传统前端的核心任务是“将数据渲染到页面”,但在AI时代,前端需要承担更复杂的角色:作为用户与大模型交互的第一入口。例如,一个聊天机器人页面需要实时接收用户输入,调用大模型生成回复,再动态更新到界面——这一切都依赖前端通过HTTP API与远程大模型通信。

readme.md中明确提到:“大模型在远程,通过HTTP API请求”。这意味着前端开发者无需关心大模型的部署细节(如服务器集群、计算资源),只需像调用普通API一样,通过fetch发送请求,即可获取大模型的智能能力。这种“远程化+API化”的设计,彻底降低了大模型的使用门槛。


二、从“页面渲染”到“智能交互”:前端交互的三次进化

1. Web1.0时代:服务器主导的“死板”交互

在Web1.0阶段(如早期门户网站),前端的作用是“展示服务器返回的内容”。用户输入URL或点击链接后,浏览器向服务器发送请求,服务器通过Java/Node等后端语言查询数据库、生成HTML字符串,最终返回完整的页面。
痛点:每次交互都需刷新页面,用户体验“卡顿”;前端仅作为“展示层”,无法主动参与数据逻辑。

2. Web2.0时代:fetch驱动的“动态”体验

2005年,XMLHttpRequest(现代fetch的前身)的普及开启了Web2.0时代。前端可以通过fetch主动向服务器发送请求,获取数据后动态更新DOM(如滚动加载更多内容、点赞后更新计数)。
readme.md中提到:“滚动到底部后,加载更多数据——不需要刷新页面,主动去服务端取一次,DOM更新页面”。这种“无刷新交互”彻底提升了用户体验,前端从“被动接收者”变为“主动请求者”。

3. WebLLM时代:大模型赋能的“智能”前端

AI大模型的出现,让前端的“主动请求”从“获取数据”升级为“获取智能能力”。例如:

  • 智能客服:前端收集用户提问,通过fetch调用大模型API,生成自然语言回复;
  • 内容生成:前端输入“写一篇产品推广文案”,大模型返回结构化内容,前端渲染展示;
  • 智能推荐:前端上传用户行为数据,大模型实时计算推荐结果,前端动态调整页面布局。

readme.md强调“fetch赋予了JS新的生命”——在AI时代,fetch不仅是获取数据的工具,更是连接前端与大模型的“智能桥梁”。


三、HTTP请求:前端与大模型通信的“技术基石”

要实现前端与大模型的交互,必须理解HTTP请求的核心结构(readme.md中详细拆解了HTTP请求的三要素):

1. 请求行:确定“交互方式”与“目标”

请求行包含两个关键信息:

  • 方法(Method):常用GET(获取数据)和POST(提交数据)。调用大模型时通常用POST,因为需要传递复杂的请求体(如对话上下文)。
  • URL:大模型API的端点地址(如https://api.deepseek.com/chat/completions),指定请求的目标服务。

2. 请求头:传递“关键元信息”

请求头是键值对形式的元数据,常见字段包括:

  • Content-Type:指定请求体的格式(如application/json表示JSON格式),大模型API通常要求此头;
  • Authorization:携带认证凭证(如Bearer sk-xxx),确保只有授权用户可调用大模型;
  • User-Agent:标识请求来源(如浏览器类型),用于服务端统计或限制。

readme.md中特别提到:“请求头设置各种头部信息,如Content-TypeAuthorization”——这些头信息是保证请求被正确处理的关键。

3. 请求体:承载“交互核心内容”

GET请求无请求体(数据通过URL参数传递),而POST请求的请求体可携带复杂数据。调用大模型时,请求体通常包含:

  • model:指定使用的模型(如deepseek-chat);
  • messages:对话上下文数组(包含system/user/assistant角色,描述交互历史);
  • temperature:控制生成内容的随机性(可选参数)。

例如,调用DeepSeek生成回复的请求体可能如下:

{
  "model": "deepseek-chat",
  "messages": [
    {"role": "system", "content": "你是一个技术博客助手"},
    {"role": "user", "content": "解释HTTP请求的结构"}
  ]
}

四、实践:如何将DeepSeek引入前端?

结合readme.md中的“WebLLM”思路,以下是将DeepSeek大模型集成到前端的步骤示例:

1. 准备前端页面

创建一个包含输入框和回复区的HTML页面:

<!-- index.html -->
<div class="container">
  <input type="text" id="user-input" placeholder="输入你的问题">
  <button id="submit">发送</button>
  <div id="reply"></div>
</div>

2. 编写JS逻辑(调用大模型API)

使用fetch发送POST请求,处理大模型返回的回复:

// app.js
const DEEPSEEK_API = "https://api.deepseek.com/chat/completions";
const API_KEY = "sk-8eb7865c110e4328aec1c9fbcd20a0e1"; // 替换为有效密钥

document.getElementById('submit').addEventListener('click', async () => {
  const userInput = document.getElementById('user-input').value;
  const response = await fetch(DEEPSEEK_API, {
    method: 'POST',
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${API_KEY}`
    },
    body: JSON.stringify({
      model: "deepseek-chat",
      messages: [
        { role: "system", content: "你是一个专业助手" },
        { role: "user", content: userInput }
      ]
    })
  });
  const data = await response.json();
  document.getElementById('reply').innerHTML += `<p>${data.choices[0].message.content}</p>`;
});

3. 测试与优化

  • 认证问题:确保API_KEY有效,否则会收到401未授权错误;
  • 错误处理:添加try/catch捕获网络错误或大模型返回的异常;
  • 用户体验:添加加载动画(如“正在生成...”),避免用户等待时困惑。

五、结语:智能前端,未来已来

从Web1.0的“页面展示”到WebLLM的“智能交互”,前端开发的核心始终是“提升用户体验”。大模型的远程API化,让前端开发者无需掌握复杂的AI训练技术,只需通过fetch调用即可为页面注入智能能力。正如readme.md所言:“fetch取来大模型的能力——智能前端时代”,这不仅是技术的进步,更是开发思维的升级:前端不再是“界面工程师”,而是“用户体验与智能交互的设计者”。

未来,随着大模型的持续演进(如更小的体积、更低的延迟),前端与大模型的结合将更加紧密。无论是智能客服、内容生成还是个性化推荐,前端都将成为大模型能力落地的“最后一公里”——这,或许就是AI时代前端开发者的新使命。

从Web 1.0到LLM AI时代:前端如何直接调用大模型服务

2025年6月5日 23:19

前言

在这个AI浪潮席卷全球的时代,你是否还在疑惑"大模型在哪?"如何在自己的项目中集成AI能力?今天我们就来聊聊如何用最简单的方式,让你的前端页面直接调用大模型服务,告别复杂的后端配置,拥抱LLM AI时代的开发新范式。

大模型在哪?从疑惑到实践

很多开发者在初次接触AI开发时都会问: "大模型在哪?"

实际上,大模型就在云端!像DeepSeek、OpenAI、Claude等厂商都提供了基于HTTP API的LLM服务,我们只需要通过简单的API调用就能获得强大的AI能力。

Web开发的三个时代

让我们先回顾一下Web开发的演进历程:

Web 1.0时代:静态页面的黄金年代

// Web 1.0时代:HTML/CSS/JS 服务器端Java返回的JS只做简单的交互
// 页面主要由服务器渲染,JS只负责简单的DOM操作

在这个时代,JavaScript主要用于表单验证、简单的DOM交互,页面内容基本都是服务器端渲染好的静态内容。

Web 2.0时代:动态交互的崛起

// Web 2.0时代:JS主动的请求后端服务器,动态页面
// 异步请求成为主流,SPA应用兴起
fetch('https://api.github.com/users/user.name/repos')
  .then(res => res.json())
  .then(data => {
    console.log(data);
    document.querySelector('#reply').innerHTML += data.map(repo =>`
    <ul>
      <li>${repo.name}</li>
    </ul> 
    `).join('')
  })

Web 2.0时代最大的特点就是JS主动发起HTTP请求,实现了真正的动态页面。Ajax技术让我们可以在不刷新页面的情况下获取数据,极大提升了用户体验。

LLM AI时代:智能化的全新篇章

现在我们迎来了LLM AI时代,前端可以直接调用大模型服务,让网页具备真正的"智能":

// LLM AI时代:直接调用大模型API
const endpoint = "https://api.deepseek.com/chat/completions"
const headers = {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer YOUR_API_KEY'
}

const payload = {
    model: 'deepseek-chat',
    messages: [
        {role: 'system', content: 'You are a helpful assistant.'},
        {role: 'user', content: '你好 Deepseek'}
    ]
}

WebLLM项目:最简单的AI集成方案

image.png

image.png

让我们看看如何用最简洁的代码实现AI功能。这个WebLLM项目展示了一个纯前端的AI应用架构:

项目结构

webllm/
├── index.html      # 主页面文件
├── css/
│   └── main.css    # 全局样式文件
└── js/
    └── main.js     # 主逻辑脚本

核心实现

HTML结构(简洁至上):

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebLLM</title>
    <link rel="stylesheet" href="./css/main.css">
</head>
<body>
    <h1>Hello Deepseek</h1>
    <div id="reply"></div>
    <script src="./js/main.js"></script>
</body>
</html>

样式设计(现代简约):

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    max-width: 800px;
    margin: 2rem auto;
    padding: 0 1rem;
    line-height: 1.6;
}
h1 {
    color: #2c3e50;
    border-bottom: 2px solid #3498db;
    padding-bottom: 0.5rem;
}

fetch请求:连接AI服务的桥梁

关键的JavaScript代码展示了如何通过fetch请求调用LLM服务:

fetch(endpoint, {
    method: 'POST',
    headers: headers,
    body: JSON.stringify(payload)
}).then(res => res.json())
.then(data => {
    console.log(data);
    document.querySelector('#reply').innerHTML += data.choices[0].message.content
})

这段代码的精妙之处在于:

  1. HTTP请求:使用标准的HTTP POST请求调用AI API
  2. JSON格式:请求和响应都采用JSON格式,简单易处理
  3. 异步处理:利用Promise链式调用处理异步响应
  4. DOM操作:直接将AI返回的内容渲染到页面

LLM服务的标准化接口

现代LLM服务基本都遵循OpenAI的API标准:

const payload = {
    model: 'deepseek-chat',           // 指定模型
    messages: [                       // 对话历史
        {role: 'system', content: 'You are a helpful assistant.'},
        {role: 'user', content: '你好 Deepseek'}
    ]
}

服务器端返回的标准格式:

{
    "choices": [
        {
            "message": {
                "role": "assistant",
                "content": "你好!我是DeepSeek,很高兴为您服务..."
            }
        }
    ]
}

技术优势与应用场景

技术优势

  1. 零后端依赖:直接从前端调用AI服务,无需搭建后端
  2. 快速部署:一个HTML文件即可运行
  3. 成本控制:按API调用量付费,避免服务器维护成本
  4. 技术门槛低:使用原生HTML/CSS/JS,无需学习复杂框架

应用场景

  • AI助手网页:快速搭建个人AI助手
  • 原型验证:验证AI功能的可行性
  • 教学演示:展示AI API的使用方法
  • 小工具开发:开发简单的AI小工具

注意事项与最佳实践

安全考虑

// 生产环境中不要在前端暴露API密钥
// 建议使用环境变量或后端代理
const headers = {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer YOUR_API_KEY' // 实际项目中需要保护
}

错误处理

fetch(endpoint, {
    method: 'POST',
    headers: headers,
    body: JSON.stringify(payload)
})
.then(res => {
    if (!res.ok) {
        throw new Error(`HTTP error! status: ${res.status}`);
    }
    return res.json();
})
.then(data => {
    document.querySelector('#reply').innerHTML += data.choices[0].message.content;
})
.catch(error => {
    console.error('Error:', error);
    document.querySelector('#reply').innerHTML += '请求失败,请稍后重试';
});

总结

从Web 1.0的静态页面,到Web 2.0的动态交互,再到如今的LLM AI时代,HTTP请求始终是连接前后端的核心技术。WebLLM项目展示了一个重要趋势:AI能力的民主化

不再需要复杂的机器学习知识,不再需要昂贵的GPU资源,仅仅通过几行JavaScript代码,我们就能让网页拥有AI的能力。这就是LLM AI时代的魅力所在。

大模型在哪?就在一个API调用的距离。

CSS选择器完全手册:精准控制网页样式的艺术

作者 Dream耀
2025年6月5日 23:08

CSS选择器完全指南:从基础到高级应用

CSS选择器是前端开发中最基础也是最重要的概念之一,它决定了样式规则将应用于哪些HTML元素。本文将全面介绍CSS选择器的各种类型和使用方法,帮助您掌握精确控制页面样式的技巧。

一、CSS基础概念回顾

在深入探讨选择器之前,让我们先回顾几个CSS基础概念:

  1. 声明(Declaration) :一个属性与值的键值对,如color: red;
  2. 声明块(Declaration Block) :由大括号{}包围的一组声明
  3. 选择器(Selector) :指定样式规则将应用于哪些HTML元素
  4. 规则集(Ruleset) :选择器加上声明块的完整组合

css

/* 这是一个完整的规则集 */
h1 {
  color: blue;      /* 声明 */
  font-size: 24px;  /* 声明 */
}

二、基本选择器类型

2.1 元素选择器(类型选择器)

元素选择器直接使用HTML标签名来选择元素,是最简单的选择器类型。

css

/* 选择所有<p>元素 */
p {
  color: #333;
}

/* 选择所有<h1>元素 */
h1 {
  font-size: 2em;
}

2.2 类选择器

类选择器以点号(.)开头,选择具有特定class属性的元素。

html

<p class="warning">这是一条警告信息</p>

css

.warning {
  color: red;
  font-weight: bold;
}

一个元素可以有多个类,类名用空格分隔:

html

<p class="warning urgent">紧急警告!</p>

2.3 ID选择器

ID选择器以井号(#)开头,选择具有特定id属性的元素。ID在文档中应该是唯一的。

html

<div id="header">网站标题</div>

css

#header {
  background-color: #f0f0f0;
  padding: 20px;
}

2.4 通配选择器

通配选择器(*)匹配任何元素,常用于重置样式。

css

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

三、组合选择器

组合选择器可以将多个简单选择器组合在一起,实现更精确的选择。

3.1 后代选择器(空格)

选择某个元素内部的所有特定后代元素,不论嵌套层级。

css

/* 选择<div>内所有的<p> */
div p {
  line-height: 1.6;
}

3.2 子元素选择器(>)

只选择直接子元素,不选择更深层级的后代。

css

/* 只选择<ul>的直接<li>子元素 */
ul > li {
  list-style-type: square;
}

3.3 相邻兄弟选择器(+)

选择紧接在另一个元素后的兄弟元素,且二者有相同的父元素。

css

/* 选择紧跟在<h1>后的<p> */
h1 + p {
  margin-top: 0;
}

3.4 通用兄弟选择器(~)

选择某个元素之后的所有同级元素。

css

/* 选择<h1>之后的所有<p>兄弟元素 */
h1 ~ p {
  color: #666;
}

四、属性选择器

属性选择器根据元素的属性及属性值来选择元素。

4.1 基本属性选择器

css

/* 选择有title属性的元素 */
[title] {
  border-bottom: 1px dotted #999;
}

/* 选择type属性值为"text"的<input>元素 */
input[type="text"] {
  border: 1px solid #ccc;
}

4.2 属性值匹配选择器

css

/* 选择href属性以"https"开头的<a>元素 */
a[href^="https"] {
  color: green;
}

/* 选择src属性以".png"结尾的<img>元素 */
img[src$=".png"] {
  border: 2px solid blue;
}

/* 选择class属性包含"logo"的元素 */
[class*="logo"] {
  opacity: 0.8;
}

五、伪类选择器

伪类选择器用于定义元素的特殊状态。

5.1 链接相关伪类

css

/* 未访问的链接 */
a:link {
  color: blue;
}

/* 已访问的链接 */
a:visited {
  color: purple;
}

/* 鼠标悬停状态 */
a:hover {
  color: red;
  text-decoration: underline;
}

/* 激活状态(鼠标按下未释放) */
a:active {
  color: green;
}

5.2 表单相关伪类

css

/* 获得焦点的输入框 */
input:focus {
  outline: 2px solid orange;
}

/* 被禁用的表单元素 */
input:disabled {
  background-color: #eee;
}

/* 被选中的复选框或单选按钮 */
input:checked {
  border-color: blue;
}

5.3 结构伪类

css

/* 选择第一个子元素 */
li:first-child {
  font-weight: bold;
}

/* 选择最后一个子元素 */
li:last-child {
  border-bottom: none;
}

/* 选择第3个子元素 */
li:nth-child(3) {
  color: red;
}

/* 选择奇数子元素 */
tr:nth-child(odd) {
  background-color: #f9f9f9;
}

/* 选择偶数子元素 */
tr:nth-child(even) {
  background-color: #fff;
}

/* 选择唯一的子元素 */
div:only-child {
  margin: 0 auto;
}

六、伪元素选择器

伪元素选择器用于选择元素的特定部分而不是元素本身。

6.1 ::before和::after

css

/* 在每个<p>前插入内容 */
p::before {
  content: "→ ";
  color: green;
}

/* 在每个<p>后插入内容 */
p::after {
  content: " ←";
  color: green;
}

6.2 ::first-letter和::first-line

css

/* 选择第一个字母 */
p::first-letter {
  font-size: 2em;
  float: left;
}

/* 选择第一行 */
p::first-line {
  font-weight: bold;
}

6.3 ::selection

css

/* 选择用户选中的文本 */
::selection {
  background-color: yellow;
  color: black;
}

七、选择器优先级与特异性

当多个选择器应用于同一个元素时,CSS有一套优先级规则决定哪个样式生效。

7.1 特异性计算规则

特异性由四个部分组成:[内联样式, ID选择器, 类/属性/伪类选择器, 元素/伪元素选择器]

  • 内联样式:1,0,0,0
  • ID选择器:0,1,0,0
  • 类/属性/伪类选择器:0,0,1,0
  • 元素/伪元素选择器:0,0,0,1

7.2 优先级示例

css

*               /* 0,0,0,0 - 最低 */
li              /* 0,0,0,1 */
ul li           /* 0,0,0,2 */
ul ol+li        /* 0,0,0,3 */
h1 + [rel=up]   /* 0,0,1,1 */
ul ol li.red    /* 0,0,1,3 */
li.red.level    /* 0,0,2,1 */
#header         /* 0,1,0,0 */
style=""        /* 1,0,0,0 - 最高 */

7.3 !important规则

!important可以覆盖所有其他规则,但应谨慎使用。

css

p {
  color: red !important;
}

八、实用选择器技巧

8.1 组合使用选择器

css

/* 选择class为"btn"且disabled的按钮 */
.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* 选择表格中除了第一行外的所有奇数行 */
tr:not(:first-child):nth-child(odd) {
  background-color: #f5f5f5;
}

8.2 选择空元素

css

/* 选择内容为空的<div> */
div:empty {
  display: none;
}

8.3 选择特定语言的元素

html

<p lang="en">Hello!</p>
<p lang="fr">Bonjour!</p>

css

/* 选择法语内容 */
p:lang(fr) {
  font-style: italic;
}

九、现代CSS选择器新特性

9.1 :is()和:where()伪类

css

/* 传统写法 */
header h1, 
header h2, 
header h3 {
  margin-bottom: 0;
}

/* 使用:is()简化 */
header :is(h1, h2, h3) {
  margin-bottom: 0;
}

/* :where()特异性总是0 */
:where(header) h1 {
  margin-bottom: 0;  /* 特异性=0,0,0,1 */
}

9.2 :has()伪类(父选择器)

css

/* 选择包含<img>的<a> */
a:has(img) {
  border: 1px solid #ccc;
}

/* 选择包含至少一个<li>的<ul> */
ul:has(> li) {
  padding-left: 20px;
}

9.3 焦点相关伪类

css

/* 任何获得焦点的元素 */
:focus {
  outline: 2px solid blue;
}

/* 当浏览器认为元素应该显示焦点状态时 */
:focus-visible {
  outline: 2px dashed green;
}

/* 当元素及其后代获得焦点时 */
:focus-within {
  background-color: #f0f8ff;
}

十、选择器最佳实践

  1. 保持简洁:避免过于复杂的选择器
  2. 避免过度使用ID选择器:特异性太高难以覆盖
  3. 合理使用类选择器:可复用性高
  4. 注意性能:浏览器从右向左解析选择器
  5. 使用有意义的命名:如.btn-primary而非.blue-btn

css

/* 不推荐 - 过于具体 */
div#main-content ul.nav li a.active {
  color: red;
}

/* 推荐 - 更简洁 */
.nav-link.active {
  color: red;
}

结语

CSS选择器是前端开发中的强大工具,掌握各种选择器的使用方法可以让你更精确地控制页面样式。从简单的基础选择器到复杂的组合选择器,再到现代的伪类选择器,合理运用这些工具可以大幅提高你的开发效率和样式控制能力。

记住,好的CSS不仅仅是让页面看起来漂亮,还要考虑可维护性、性能和可访问性。选择器的合理使用是实现这些目标的关键之一。

使用 HTML +JavaScript 从零构建视频帧提取器

作者 技术小丁
2025年6月5日 22:42

在视频编辑、内容分析和多媒体处理领域,常常需要从视频中提取关键帧。手动截取不仅效率低下,还容易遗漏重要画面。本文介绍的视频帧提取工具通过 HTML5 技术栈实现了一个完整的浏览器端解决方案,用户可以轻松选择视频文件并进行手动或自动帧捕获。

效果演示

image-20250605221946526

image-20250605222328111

核心功能

手动帧捕获

用户可以通过点击“捕获帧”按钮,在视频播放过程中随时抓取当前帧。捕获的画面会实时显示在预览区域,并生成可下载的 PNG 图像。

自动帧捕获

支持设定时间间隔(如每秒一张)自动捕获帧的功能,适用于批量提取视频中的关键画面。进度条实时反映当前处理进度,增强用户体验。

帧管理

  • 缩略图展示:所有捕获的帧以网格形式展示,鼠标悬停时显示操作按钮。
  • 下载与删除:每个帧都支持独立下载和删除,方便用户整理和导出所需内容。
  • 预览切换:点击缩略图即可在主画布上查看高清版本。

空状态提示

当没有任何帧被捕获时,提供友好的空状态提示,提升交互体验。

页面结构

视频上传与播放区域

用户选择本地视频文件,上传后在 video 中播放。

<div class="file-input-wrapper">
    <button class="file-input-button" id="uploadButton">
        <svg class="icon" viewBox="0 0 24 24">
            <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
        </svg>
        选择视频文件
    </button>
    <input type="file" id="videoUpload" class="file-input" accept="video/*">
</div>
<div class="video-container">
    <video id="videoElement" controls></video>
</div>

操作控制区域

【捕获帧】按钮用于手动截取当前视频画面;【自动捕获】按钮开启定时连续提取帧的功能;【停止】按钮用于终止自动捕获过程;下方的输入框用于设置自动捕获时每帧之间的时间间隔(单位为秒)。 整体提供了用户与视频帧提取功能交互的主要控件。

<div class="controls">
    <button id="captureBtn" class="btn btn-primary" disabled>
        <svg class="icon" viewBox="0 0 24 24">
            <path d="M4 8h4V4h12v16H8v-4H4V8zm12 6v2h2v-2h-2zm-4-3v5h2v-5h-2zm-4-3v8h2V8h-2z"/>
        </svg>
        捕获帧
    </button>
    <button id="autoCaptureBtn" class="btn btn-primary" disabled>
        <svg class="icon" viewBox="0 0 24 24">
            <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.2 3.2.8-1.3-4.5-2.7V7z"/>
        </svg>
        自动捕获
    </button>
    <button id="stopAutoCaptureBtn" class="btn btn-danger" disabled>
        <svg class="icon" viewBox="0 0 24 24">
            <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM8 8h8v8H8z"/>
        </svg>
        停止
    </button>
    <div class="input-group">
        <input type="number" id="frameInterval" class="number-input" value="1" min="0.1" step="0.1">
        <span class="input-label">秒/帧</span>
    </div>
</div>

帧预览与导出区域

显示当前捕获帧的预览区域,用户可以查看具体画面;提供“下载当前帧”按钮,支持将当前预览帧保存为图片文件;展示已捕获帧的缩略图列表,方便浏览和管理。

<div class="panel">
    <h2 class="panel-title">
        <svg class="icon" viewBox="0 0 24 24">
            <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/>
            <path d="M8.5 15H10V9H7v1.5h1.5zM13.5 12.75L15.25 15H17l-2.25-3L17 9h-1.75l-1.75 2.25V9H12v6h1.5z"/>
        </svg>
        帧预览与导出
    </h2>
    <div class="preview-container">
        <div class="canvas-wrapper">
            <canvas id="canvasElement"></canvas>
        </div>
        <button id="downloadBtn" class="btn btn-primary" disabled>
            <svg class="icon" viewBox="0 0 24 24">
                <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
            </svg>
            下载当前帧
        </button>
    </div>
    <h3 class="panel-title" style="margin-top: 20px; font-size: 16px;">
        <svg class="icon" viewBox="0 0 24 24">
            <path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
        </svg>
        已捕获的帧 (共<span id="frameCount">0</span>张)
    </h3>
    <div class="thumbnails-container" id="thumbnails">
        <div class="empty-state" id="emptyState">
            <svg class="icon" viewBox="0 0 24 24">
                <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
            </svg>
            <p>尚未捕获任何帧</p>
        </div>
    </div>
</div>

核心功能实现

初始化与事件绑定

使用 FrameExtractor 类封装所有逻辑,构造函数中初始化 DOM 元素和状态变量:

this.elements = {
    videoUpload: document.getElementById('videoUpload'),
    uploadButton: document.getElementById('uploadButton'),
    videoElement: document.getElementById('videoElement'),
    canvasElement: document.getElementById('canvasElement'),
    captureBtn: document.getElementById('captureBtn'),
    autoCaptureBtn: document.getElementById('autoCaptureBtn'),
    stopAutoCaptureBtn: document.getElementById('stopAutoCaptureBtn'),
    downloadBtn: document.getElementById('downloadBtn'),
    frameInterval: document.getElementById('frameInterval'),
    thumbnailsContainer: document.getElementById('thumbnails'),
    emptyState: document.getElementById('emptyState'),
    frameCount: document.getElementById('frameCount'),
    progressBar: document.getElementById('progressBar'),
    progress: document.getElementById('progress')
};
this.state = {
    autoCaptureInterval: null,
    capturedFrames: [],
    isAutoCapturing: false,
    captureStartTime: 0
};
this.elements.videoUpload.addEventListener('change', (e) => this.handleVideoUpload(e));
this.elements.uploadButton.addEventListener('click', () => this.elements.videoUpload.click());

// 按钮事件
this.elements.captureBtn.addEventListener('click', () => this.captureFrame());
this.elements.autoCaptureBtn.addEventListener('click', () => this.toggleAutoCapture());
this.elements.stopAutoCaptureBtn.addEventListener('click', () => this.stopAutoCapture());
this.elements.downloadBtn.addEventListener('click', () => this.downloadCurrentFrame());

视频上传与播放

用户选择视频后,通过 URL.createObjectURL 创建临时链接加载视频。

handleVideoUpload(e) {
    const file = e.target.files[0];
    if (file) {
        const videoURL = URL.createObjectURL(file);
        this.elements.videoElement.src = videoURL;
        // 启用按钮
        this.elements.captureBtn.disabled = false;
        this.elements.autoCaptureBtn.disabled = false;
        // 重置状态
        this.resetCaptureState();
    }
}

手动帧捕获

将当前视频帧绘制到 canvas 上,供用户查看和下载。

captureFrame() {
    if (this.elements.videoElement.readyState === 0) return;
    const ctx = this.elements.canvasElement.getContext('2d');
    // 设置canvas尺寸与视频帧相同
    this.elements.canvasElement.width = this.elements.videoElement.videoWidth;
    this.elements.canvasElement.height = this.elements.videoElement.videoHeight;
    // 绘制视频帧到canvas
    ctx.drawImage(this.elements.videoElement, 0, 0,
                  this.elements.canvasElement.width, this.elements.canvasElement.height);
    // 启用下载按钮
    this.elements.downloadBtn.disabled = false;
    // 创建缩略图
    this.createThumbnail(this.elements.canvasElement.toDataURL('image/jpeg', 0.8));
    // 更新进度条(自动捕获时)
    if (this.state.isAutoCapturing) {
        const currentTime = this.elements.videoElement.currentTime;
        const duration = this.elements.videoElement.duration;
        const progress = (currentTime / duration) * 100;
        this.elements.progress.style.width = `${progress}%`;
    }
}

自动帧捕获

根据用户设置的时间间隔启动定时任务:

startAutoCapture() {
    const interval = parseFloat(this.elements.frameInterval.value) * 1000;
    if (interval > 0) {
        this.state.isAutoCapturing = true;
        this.state.captureStartTime = this.elements.videoElement.currentTime;
        this.elements.stopAutoCaptureBtn.disabled = false;
        this.elements.autoCaptureBtn.textContent = '暂停捕获';
        this.elements.autoCaptureBtn.classList.add('btn-danger');
        // 显示进度条
        this.elements.progressBar.style.display = 'block';
        this.elements.progress.style.width = '0%';
        // 先捕获一帧
        this.captureFrame();
        // 设置定时器
        this.state.autoCaptureInterval = setInterval(() => {
            this.captureFrame();
            // 检查是否到达视频末尾
            if (this.elements.videoElement.currentTime >= this.elements.videoElement.duration - 0.1) {
                this.stopAutoCapture();
            }
        }, interval);
    }
}

缩略图与交互

每次捕获的帧都会生成缩略图,并添加下载和删除功能:

createThumbnail(dataURL) {
    const thumbnailDiv = document.createElement('div');
    thumbnailDiv.className = 'thumbnail';
    // ...
    downloadBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        const link = document.createElement('a');
        link.download = `frame_${frameId}.png`;
        link.href = dataURL;
        link.click();
    });
    // ...
    this.elements.thumbnailsContainer.appendChild(thumbnailDiv);
}

技术亮点

  • Canvas 操作:利用 HTML5 Canvas 实现图像捕获与动态渲染。
  • 对象 URL:通过 URL.createObjectURL 高效加载本地视频资源。
  • 响应式设计:使用 CSS Grid 和 Flexbox 构建灵活布局,适配不同屏幕尺寸。
  • 模块化结构:将功能封装在类中,提高代码组织清晰度和可维护性。
  • 性能优化:自动捕获时限制帧率,避免过度消耗资源。

扩展建议

  • 支持多视频格式:目前仅支持 video/*,未来可扩展支持更多格式如 .mkv.avi,并通过 FFmpeg WASM 解码。

  • 添加帧过滤功能:允许用户对已捕获帧进行筛选,例如按时间范围、相似度去重等。

  • 导出为 ZIP 文件:集成 JSZip 库,一键打包所有帧为 ZIP 文件,便于批量下载。

  • 云端存储:集成云存储 API(如 Firebase 或阿里云 OSS),实现帧图片的持久化保存与分享。

  • AI 关键帧识别:引入机器学习模型(如 TensorFlow.js),自动识别视频中的关键帧进行智能提取。

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>视频帧提取工具</title>
    <style>
        :root {
            --primary-color: #4361ee;
            --secondary-color: #3f37c9;
            --accent-color: #4895ef;
            --danger-color: #f72585;
            --light-color: #f8f9fa;
            --dark-color: #212529;
            --border-radius: 8px;
            --box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            --transition: all 0.3s ease;
        }

        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
            color: var(--dark-color);
            background-color: #f5f7fa;
            padding: 20px;
        }

        .app-container {
            max-width: 1200px;
            margin: 0 auto;
            background-color: white;
            border-radius: var(--border-radius);
            box-shadow: var(--box-shadow);
            overflow: hidden;
        }

        .app-header {
            background-color: var(--primary-color);
            color: white;
            padding: 20px;
            text-align: center;
        }

        .app-header h1 {
            margin-bottom: 10px;
            font-weight: 600;
        }

        .app-header p {
            opacity: 0.9;
        }

        .main-content {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 20px;
            padding: 20px;
        }

        @media (max-width: 768px) {
            .main-content {
                grid-template-columns: 1fr;
            }
        }

        .panel {
            background-color: white;
            border-radius: var(--border-radius);
            box-shadow: var(--box-shadow);
            padding: 20px;
            display: flex;
            flex-direction: column;
        }

        .panel-title {
            font-size: 18px;
            font-weight: 600;
            margin-bottom: 15px;
            color: var(--secondary-color);
            display: flex;
            align-items: center;
            gap: 10px;
            padding-bottom: 10px;
            border-bottom: 1px solid #eee;
        }

        .panel-title svg {
            width: 20px;
            height: 20px;
        }

        .video-container {
            position: relative;
            width: 100%;
            background-color: black;
            border-radius: var(--border-radius);
            overflow: hidden;
            margin-bottom: 15px;
            aspect-ratio: 16/9;
        }

        video {
            width: 100%;
            height: 100%;
            object-fit: contain;
            display: block;
        }

        .file-input-wrapper {
            margin-bottom: 15px;
        }

        .file-input-button {
            width: 100%;
            padding: 12px;
            background-color: var(--primary-color);
            color: white;
            border: none;
            border-radius: var(--border-radius);
            cursor: pointer;
            transition: var(--transition);
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            font-weight: 500;
        }

        .file-input-button:hover {
            background-color: var(--secondary-color);
        }

        .file-input {
            display: none;
        }

        .controls {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 10px;
            margin-top: 10px;
        }

        .btn {
            padding: 10px;
            border: none;
            border-radius: var(--border-radius);
            cursor: pointer;
            transition: var(--transition);
            font-weight: 500;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 6px;
        }

        .btn-primary {
            background-color: var(--primary-color);
            color: white;
        }

        .btn-primary:hover {
            background-color: var(--secondary-color);
        }

        .btn-danger {
            background-color: var(--danger-color);
            color: white;
        }

        .btn-danger:hover {
            opacity: 0.9;
        }

        .btn:disabled {
            background-color: #ddd;
            color: #999;
            cursor: not-allowed;
        }

        .input-group {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-top: 10px;
        }

        .input-label {
            font-size: 14px;
            color: #555;
            white-space: nowrap;
        }

        .number-input {
            padding: 8px 12px;
            border: 1px solid #ddd;
            border-radius: var(--border-radius);
            width: 100%;
            text-align: center;
        }

        .preview-container {
            flex-grow: 1;
            display: flex;
            flex-direction: column;
            min-height: 0;
        }

        .canvas-wrapper {
            flex-grow: 1;
            display: flex;
            align-items: center;
            justify-content: center;
            background-color: #f0f0f0;
            border-radius: var(--border-radius);
            overflow: hidden;
            margin-bottom: 15px;
            position: relative;
        }

        canvas {
            max-width: 100%;
            max-height: 100%;
            display: block;
            background-color: white;
        }

        .thumbnails-container {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
            gap: 10px;
            margin-top: 15px;
            max-height: 300px;
            overflow-y: auto;
            padding: 5px;
        }

        .thumbnail {
            position: relative;
            border-radius: var(--border-radius);
            overflow: hidden;
            box-shadow: var(--box-shadow);
            transition: var(--transition);
            aspect-ratio: 16/9;
        }

        .thumbnail:hover {
            transform: translateY(-3px);
            box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
        }

        .thumbnail img {
            width: 100%;
            height: 100%;
            object-fit: cover;
            display: block;
        }

        .thumbnail-actions {
            position: absolute;
            top: 5px;
            right: 5px;
            display: flex;
            gap: 5px;
            opacity: 0;
            transition: var(--transition);
        }

        .thumbnail:hover .thumbnail-actions {
            opacity: 1;
        }

        .thumbnail-btn {
            width: 28px;
            height: 28px;
            border-radius: 50%;
            border: none;
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            transition: var(--transition);
        }

        .thumbnail-btn:hover {
            background-color: rgba(0, 0, 0, 0.9);
            transform: scale(1.1);
        }

        .thumbnail-btn.download {
            background-color: rgba(67, 97, 238, 0.7);
        }

        .thumbnail-btn.download:hover {
            background-color: rgba(67, 97, 238, 0.9);
        }

        .thumbnail-btn.delete {
            background-color: rgba(247, 37, 133, 0.7);
        }

        .thumbnail-btn.delete:hover {
            background-color: rgba(247, 37, 133, 0.9);
        }

        .empty-state {
            text-align: center;
            padding: 20px;
            color: #999;
            grid-column: 1 / -1;
        }

        .empty-state svg {
            width: 50px;
            height: 50px;
            margin-bottom: 10px;
            opacity: 0.5;
        }

        .progress-bar {
            width: 100%;
            height: 6px;
            background-color: #eee;
            border-radius: 3px;
            margin-top: 10px;
            overflow: hidden;
            display: none;
        }

        .progress {
            height: 100%;
            background-color: var(--primary-color);
            width: 0%;
            transition: width 0.3s ease;
        }

        ::-webkit-scrollbar {
            width: 8px;
            height: 8px;
        }

        ::-webkit-scrollbar-track {
            background: #f1f1f1;
            border-radius: 4px;
        }

        ::-webkit-scrollbar-thumb {
            background: #ccc;
            border-radius: 4px;
        }

        ::-webkit-scrollbar-thumb:hover {
            background: #aaa;
        }

        .icon {
            width: 18px;
            height: 18px;
            vertical-align: middle;
            fill: currentColor;
        }
    </style>
</head>
<body>
<div class="app-container">
    <header class="app-header">
        <h1>视频帧提取工具</h1>
        <p>轻松从视频中提取关键帧并保存为图片</p>
    </header>

    <div class="main-content">
        <div class="panel">
            <h2 class="panel-title">
                <svg class="icon" viewBox="0 0 24 24">
                    <path d="M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm6 10h-4V5h4v14zm4-2h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z"/>
                </svg>
                视频控制
            </h2>

            <div class="file-input-wrapper">
                <button class="file-input-button" id="uploadButton">
                    <svg class="icon" viewBox="0 0 24 24">
                        <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
                    </svg>
                    选择视频文件
                </button>
                <input type="file" id="videoUpload" class="file-input" accept="video/*">
            </div>

            <div class="video-container">
                <video id="videoElement" controls></video>
            </div>

            <div class="controls">
                <button id="captureBtn" class="btn btn-primary" disabled>
                    <svg class="icon" viewBox="0 0 24 24">
                        <path d="M4 8h4V4h12v16H8v-4H4V8zm12 6v2h2v-2h-2zm-4-3v5h2v-5h-2zm-4-3v8h2V8h-2z"/>
                    </svg>
                    捕获帧
                </button>

                <button id="autoCaptureBtn" class="btn btn-primary" disabled>
                    <svg class="icon" viewBox="0 0 24 24">
                        <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.2 3.2.8-1.3-4.5-2.7V7z"/>
                    </svg>
                    自动捕获
                </button>

                <button id="stopAutoCaptureBtn" class="btn btn-danger" disabled>
                    <svg class="icon" viewBox="0 0 24 24">
                        <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM8 8h8v8H8z"/>
                    </svg>
                    停止
                </button>

                <div class="input-group">
                    <input type="number" id="frameInterval" class="number-input" value="1" min="0.1" step="0.1">
                    <span class="input-label">秒/帧</span>
                </div>
            </div>

            <div class="progress-bar" id="progressBar">
                <div class="progress" id="progress"></div>
            </div>
        </div>

        <div class="panel">
            <h2 class="panel-title">
                <svg class="icon" viewBox="0 0 24 24">
                    <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/>
                    <path d="M8.5 15H10V9H7v1.5h1.5zM13.5 12.75L15.25 15H17l-2.25-3L17 9h-1.75l-1.75 2.25V9H12v6h1.5z"/>
                </svg>
                帧预览与导出
            </h2>

            <div class="preview-container">
                <div class="canvas-wrapper">
                    <canvas id="canvasElement"></canvas>
                </div>

                <button id="downloadBtn" class="btn btn-primary" disabled>
                    <svg class="icon" viewBox="0 0 24 24">
                        <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
                    </svg>
                    下载当前帧
                </button>
            </div>
            <h3 class="panel-title" style="margin-top: 20px; font-size: 16px;">
                <svg class="icon" viewBox="0 0 24 24">
                    <path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
                </svg>
                已捕获的帧 (共<span id="frameCount">0</span>张)
            </h3>
            <div class="thumbnails-container" id="thumbnails">
                <div class="empty-state" id="emptyState">
                    <svg class="icon" viewBox="0 0 24 24">
                        <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
                    </svg>
                    <p>尚未捕获任何帧</p>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
    class FrameExtractor {
        constructor() {
            // 初始化DOM元素
            this.elements = {
                videoUpload: document.getElementById('videoUpload'),
                uploadButton: document.getElementById('uploadButton'),
                videoElement: document.getElementById('videoElement'),
                canvasElement: document.getElementById('canvasElement'),
                captureBtn: document.getElementById('captureBtn'),
                autoCaptureBtn: document.getElementById('autoCaptureBtn'),
                stopAutoCaptureBtn: document.getElementById('stopAutoCaptureBtn'),
                downloadBtn: document.getElementById('downloadBtn'),
                frameInterval: document.getElementById('frameInterval'),
                thumbnailsContainer: document.getElementById('thumbnails'),
                emptyState: document.getElementById('emptyState'),
                frameCount: document.getElementById('frameCount'),
                progressBar: document.getElementById('progressBar'),
                progress: document.getElementById('progress')
            };

            // 状态变量
            this.state = {
                autoCaptureInterval: null,
                capturedFrames: [],
                isAutoCapturing: false,
                captureStartTime: 0
            };
            // 初始化事件监听
            this.initEventListeners();
        }

        initEventListeners() {
            // 视频上传处理
            this.elements.videoUpload.addEventListener('change', (e) => this.handleVideoUpload(e));
            this.elements.uploadButton.addEventListener('click', () => this.elements.videoUpload.click());

            // 按钮事件
            this.elements.captureBtn.addEventListener('click', () => this.captureFrame());
            this.elements.autoCaptureBtn.addEventListener('click', () => this.toggleAutoCapture());
            this.elements.stopAutoCaptureBtn.addEventListener('click', () => this.stopAutoCapture());
            this.elements.downloadBtn.addEventListener('click', () => this.downloadCurrentFrame());
        }

        handleVideoUpload(e) {
            const file = e.target.files[0];
            if (file) {
                const videoURL = URL.createObjectURL(file);
                this.elements.videoElement.src = videoURL;
                // 启用按钮
                this.elements.captureBtn.disabled = false;
                this.elements.autoCaptureBtn.disabled = false;
                // 重置状态
                this.resetCaptureState();
                // 监听视频元数据加载
                // this.elements.videoElement.onloadedmetadata = () => {
                //     this.elements.videoElement.play().catch(e => console.log("自动播放被阻止:", e));
                // };
            }
        }

        captureFrame() {
            if (this.elements.videoElement.readyState === 0) return;
            const ctx = this.elements.canvasElement.getContext('2d');
            // 设置canvas尺寸与视频帧相同
            this.elements.canvasElement.width = this.elements.videoElement.videoWidth;
            this.elements.canvasElement.height = this.elements.videoElement.videoHeight;
            // 绘制视频帧到canvas
            ctx.drawImage(this.elements.videoElement, 0, 0,
                this.elements.canvasElement.width, this.elements.canvasElement.height);

            // 启用下载按钮
            this.elements.downloadBtn.disabled = false;

            // 创建缩略图
            this.createThumbnail(this.elements.canvasElement.toDataURL('image/jpeg', 0.8));

            // 更新进度条(自动捕获时)
            if (this.state.isAutoCapturing) {
                const currentTime = this.elements.videoElement.currentTime;
                const duration = this.elements.videoElement.duration;
                const progress = (currentTime / duration) * 100;
                this.elements.progress.style.width = `${progress}%`;
            }
        }

        toggleAutoCapture() {
            if (this.state.isAutoCapturing) {
                this.stopAutoCapture();
            } else {
                this.startAutoCapture();
            }
        }

        startAutoCapture() {
            const interval = parseFloat(this.elements.frameInterval.value) * 1000;
            if (interval > 0) {
                this.state.isAutoCapturing = true;
                this.state.captureStartTime = this.elements.videoElement.currentTime;

                this.elements.stopAutoCaptureBtn.disabled = false;
                this.elements.autoCaptureBtn.textContent = '暂停捕获';
                this.elements.autoCaptureBtn.classList.add('btn-danger');

                // 显示进度条
                this.elements.progressBar.style.display = 'block';
                this.elements.progress.style.width = '0%';

                // 先捕获一帧
                this.captureFrame();

                // 设置定时器
                this.state.autoCaptureInterval = setInterval(() => {
                    this.captureFrame();

                    // 检查是否到达视频末尾
                    if (this.elements.videoElement.currentTime >= this.elements.videoElement.duration - 0.1) {
                        this.stopAutoCapture();
                    }
                }, interval);
            }
        }

        stopAutoCapture() {
            if (this.state.autoCaptureInterval) {
                clearInterval(this.state.autoCaptureInterval);
                this.state.autoCaptureInterval = null;
            }
            this.state.isAutoCapturing = false;
            this.elements.stopAutoCaptureBtn.disabled = true;
            this.elements.autoCaptureBtn.textContent = '自动捕获';
            this.elements.autoCaptureBtn.classList.remove('btn-danger');

            // 隐藏进度条
            this.elements.progressBar.style.display = 'none';
        }

        downloadCurrentFrame() {
            if (this.elements.canvasElement.width > 0) {
                const link = document.createElement('a');
                link.download = `frame_${new Date().getTime()}.png`;
                link.href = this.elements.canvasElement.toDataURL('image/png');
                link.click();
            }
        }

        createThumbnail(dataURL) {
            // 隐藏空状态
            if (this.elements.emptyState) {
                this.elements.emptyState.style.display = 'none';
            }

            const frameId = Date.now();
            this.state.capturedFrames.push({id: frameId, dataURL});

            // 更新帧计数
            this.elements.frameCount.textContent = this.state.capturedFrames.length;

            const thumbnailDiv = document.createElement('div');
            thumbnailDiv.className = 'thumbnail';
            thumbnailDiv.dataset.id = frameId;

            const img = document.createElement('img');
            img.src = dataURL;
            img.alt = `Captured frame ${frameId}`;

            const actionsDiv = document.createElement('div');
            actionsDiv.className = 'thumbnail-actions';

            const downloadBtn = document.createElement('button');
            downloadBtn.className = 'thumbnail-btn download';
            downloadBtn.title = '下载此帧';
            downloadBtn.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>';
            downloadBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                const link = document.createElement('a');
                link.download = `frame_${frameId}.png`;
                link.href = dataURL;
                link.click();
            });

            const deleteBtn = document.createElement('button');
            deleteBtn.className = 'thumbnail-btn delete';
            deleteBtn.title = '删除此帧';
            deleteBtn.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M19 4h-3.5l-1-1h-5l-1 1H5v2h14zM6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12z"/></svg>';
            deleteBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                thumbnailDiv.remove();
                this.state.capturedFrames = this.state.capturedFrames.filter(frame => frame.id !== frameId);
                this.elements.frameCount.textContent = this.state.capturedFrames.length;

                // 如果没有帧了,显示空状态
                if (this.state.capturedFrames.length === 0 && this.elements.emptyState) {
                    this.elements.emptyState.style.display = 'block';
                }
            });

            // 点击缩略图预览大图
            thumbnailDiv.addEventListener('click', () => {
                this.elements.canvasElement.width = 0;
                this.elements.canvasElement.height = 0;

                const img = new Image();
                img.onload = () => {
                    this.elements.canvasElement.width = img.width;
                    this.elements.canvasElement.height = img.height;
                    const ctx = this.elements.canvasElement.getContext('2d');
                    ctx.drawImage(img, 0, 0);
                    this.elements.downloadBtn.disabled = false;
                };
                img.src = dataURL;
            });

            actionsDiv.appendChild(downloadBtn);
            actionsDiv.appendChild(deleteBtn);
            thumbnailDiv.appendChild(img);
            thumbnailDiv.appendChild(actionsDiv);
            this.elements.thumbnailsContainer.appendChild(thumbnailDiv);
            // 滚动到底部
            this.elements.thumbnailsContainer.scrollTop = this.elements.thumbnailsContainer.scrollHeight;
        }

        resetCaptureState() {
            // 停止自动捕获
            this.stopAutoCapture();
            // 清除画布
            const ctx = this.elements.canvasElement.getContext('2d');
            ctx.clearRect(0, 0, this.elements.canvasElement.width, this.elements.canvasElement.height);
            this.elements.canvasElement.width = 0;
            this.elements.canvasElement.height = 0;
            // 禁用下载按钮
            this.elements.downloadBtn.disabled = true;
            // 清除所有缩略图
            this.elements.thumbnailsContainer.innerHTML = '';
            this.state.capturedFrames = [];
            this.elements.frameCount.textContent = '0';
            // 显示空状态
            if (this.elements.emptyState) {
                this.elements.emptyState.style.display = 'block';
            }
            // 隐藏进度条
            this.elements.progressBar.style.display = 'none';
        }
    }
    // 初始化应用
    document.addEventListener('DOMContentLoaded', () => {
        new FrameExtractor();
    });
</script>
</body>
</html>

ES6字符串模板:告别拼接,拥抱优雅的字符串艺术!

2025年6月4日 23:58

ES6字符串模板:告别拼接,拥抱优雅的字符串艺术!

在JavaScript的进化史上,ES6无疑是一场革命性的变革。今天,我们将聚焦其中一个看似简单却极具威力的特性——字符串模板(Template Strings),它将彻底改变你处理字符串的方式!

一、ES6带来的全新世界

ES6(ECMAScript 2015)为JavaScript注入了强大的新功能:

  • 箭头函数:简洁的函数语法
  • 解构赋值:优雅的数据提取
  • 类(Class):面向对象编程更规范
  • 模块化:更好的代码组织
  • Promise:异步编程新标准
  • 以及我们今天的主角——字符串模板

二、传统字符串拼接的痛点 😩

在ES6之前,拼接字符串简直是一场噩梦:

const name = "旺财";
const age = 2;

// 传统拼接方式
const sentence = "My dog " + name + " is " + (age * 7) + " years old";

// 多行HTML拼接更是灾难
const html = '<div class="card">' +
             '<h2>' + name + '</h2>' +
             '<p>Age: ' + age + '</p>' +
             '</div>';

这种拼接方式存在诸多问题:

  1. 引号混乱,容易出错
  2. 拼接运算符+过多,代码冗长
  3. 多行内容难以维护
  4. 表达式需要额外括号包裹

三、字符串模板:优雅的解决方案 ✨

1. 基础用法

使用反引号(`)包裹字符串,变量用${}嵌入:

const name = "旺财";
const age = 2;

// 使用字符串模板
const sentence = `My dog ${name} is ${age * 7} years old`;
console.log(sentence); // "My dog 旺财 is 14 years old"

2. 多行字符串的神奇魅力

字符串模板天然支持多行内容,完美保持格式:

const poem = `
玫瑰是红的,
紫罗兰是蓝的,
糖是甜的,
你也是甜的。
`;

console.log(poem);

3. 表达式自由嵌入

${}中可以放置任意有效的JavaScript表达式:

const a = 5;
const b = 10;

console.log(`计算结果是:${a + b} 
下一结果是:${a * b}`);

四、字符串模板在DOM操作中的绝佳应用 🎨

字符串模板特别适合生成HTML内容,结合数组的map方法,可以优雅地实现数据到DOM的转换:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>好友列表</title>
    <style>
        /* 这里可以添加一些漂亮的样式 */
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background-color: #f5f7fa;
            padding: 20px;
        }
        ul {
            list-style: none;
            padding: 0;
            max-width: 400px;
            margin: 0 auto;
        }
        li {
            background: white;
            margin-bottom: 10px;
            padding: 15px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            transition: transform 0.2s;
        }
        li:hover {
            transform: translateY(-3px);
            box-shadow: 0 4px 8px rgba(0,0,0,0.15);
        }
        i {
            color: #6c5ce7;
            font-style: normal;
            font-weight: bold;
        }
        .header {
            text-align: center;
            color: #2d3436;
            margin-bottom: 30px;
        }
    </style>
</head>
<body>
    <h1 class="header">🌈 我的好友列表</h1>
    <ul id="friends"></ul>

    <script>
        // 好友数据数组
        const friends = [
            {name: '小a', hometown: '抚州'},
            {name: '小b', hometown: '南昌'},
            {name: '小c', hometown: '赣州'},
            {name: '小d', hometown: '九江'},
            {name: '小e', hometown: '景德镇'}
        ];

        // 获取DOM元素
        const ul = document.getElementById('friends');
        
        // 使用map和字符串模板生成HTML
        ul.innerHTML = friends.map(friend => `
            <li>
                <strong>${friend.name}</strong> 来自
                <i>${friend.hometown}</i>
            </li>
        `).join(''); // 将数组连接成字符串
    </script>
</body>
</html>

代码解析:

  1. map方法:遍历数组中的每个元素,并返回由返回值组成的新数组
  2. 箭头函数friend => ... 简洁的函数语法
  3. 字符串模板:使用反引号包裹多行HTML,${}插入变量
  4. join(''):将数组元素连接成一个字符串,空字符作为连接符
1、map方法:数组变形大师
什么是map方法?

map() 是数组对象的一个高阶函数方法,它遍历数组中的每个元素,对每个元素执行指定操作,并返回由这些操作结果组成的新数组

const numbers = [1, 2, 3, 4];
const doubled = numbers.map(num => num * 2);
console.log(doubled); // [2, 4, 6, 8]
map的核心特点:
  1. 不改变原数组:纯函数特性,返回新数组
  2. 链式调用:可与其他数组方法配合使用
  3. 高效转换:简化数据格式转换过程

工作原理图解:

原始数组: [🍎, 🍐, 🍊]
      ↓ map(水果 => 果汁)
新数组: [🍹, 🍹, 🍹]
实际应用场景:
// 用户数据转换
const users = [
  { id: 1, name: 'Alice', age: 25 },
  { id: 2, name: 'Bob', age: 30 }
];

// 转换为只包含名字和年龄的新数组
const userProfiles = users.map(user => ({
  username: user.name,
  userAge: user.age
}));
2、箭头函数:简洁的函数语法
什么是箭头函数?

箭头函数(=>)是ES6引入的一种更简洁的函数表达式语法,它解决了传统函数中this指向的问题。

// 传统函数
const addTraditional = function(a, b) {
  return a + b;
};

// 箭头函数
const addArrow = (a, b) => a + b;
箭头函数的核心特点:
  1. 简洁语法

    • 单参数可省略括号:x => x * 2
    • 单行表达式可省略return和花括号
  2. 没有自己的this:继承外层作用域的this

  3. 不能作为构造函数:不能使用new调用

  4. 没有arguments对象:使用剩余参数(...args)替代

实际应用场景:
// 配合数组方法使用
const numbers = [1, 2, 3, 4];

// 传统方式
const squaresTraditional = numbers.map(function(num) {
  return num * num;
});

// 箭头函数方式
const squaresArrow = numbers.map(num => num * num);
3、join(''):数组连接魔术手
什么是join()方法?

join()方法将数组的所有元素连接成一个字符串,并返回该字符串。

const fruits = ['苹果', '香蕉', '橙子'];

console.log(fruits.join());      // "苹果,香蕉,橙子"
console.log(fruits.join(''));    // "苹果香蕉橙子"
console.log(fruits.join(' - ')); // "苹果 - 香蕉 - 橙子"
join('')的特殊用法:

当使用空字符串''作为参数时:

  • 数组元素直接拼接,没有任何分隔符
  • 特别适合将HTML字符串数组转换为单个字符串

实际应用场景:

const menuItems = ['首页', '产品', '关于', '联系'];

// 生成导航菜单
const navHTML = `
  <nav>
    <ul>
      ${menuItems.map(item => `<li><a href="#">${item}</a></li>`).join('')}
    </ul>
  </nav>
`;

// 如果不使用join(''),结果将是:
// <li>...</li>,<li>...</li>,<li>...</li>
4、DOM编程:网页的动态灵魂
什么是DOM编程?

DOM(Document Object Model)编程是指通过JavaScript操作HTML文档结构的编程方式。

DOM的核心概念:
  1. 节点树:HTML文档被解析为树形结构

    • 文档节点:整个文档(document)
    • 元素节点:HTML标签(如<div>
    • 文本节点:标签内的文本内容
    • 属性节点:标签的属性(如classid
  2. DOM操作基本步骤

    获取DOM元素

    操作元素

    修改内容

    修改样式

    修改属性

    添加/删除子元素

常见DOM操作方法:

方法 描述 示例
getElementById() 通过id获取元素 document.getElementById('header')
querySelector() CSS选择器获取元素 document.querySelector('.btn')
createElement() 创建新元素 document.createElement('div')
appendChild() 添加子元素 container.appendChild(newDiv)
innerHTML 获取/设置元素HTML内容 div.innerHTML = '<p>内容</p>'
classList 操作元素类 btn.classList.add('active')

五、更强大的用法进阶

1. 嵌套模板

const isLarge = true;
const message = `
    欢迎来到我们的${isLarge ? '大型' : '小型'}活动!
    您将体验${`${isLarge ? '豪华' : '舒适'}`}的服务。
`;

2. 标签模板(Tagged Templates)

更高级的用法,可以自定义字符串处理:

function highlight(strings, ...values) {
    return strings.reduce((result, str, i) => 
        `${result}${str}<mark>${values[i] || ''}</mark>`, '');
}

const name = "旺财";
const age = 2;

const sentence = highlight`My dog ${name} is ${age * 7} years old`;
// 输出:My dog <mark>旺财</mark> is <mark>14</mark> years old

六、与传统拼接的性能对比 ⚡

虽然字符串模板在可读性和开发体验上完胜,但在性能敏感的场景下需要注意:

方法 10,000次拼接耗时 可读性 多行支持
传统拼接 5ms ★★☆☆☆
字符串模板 8ms ★★★★★ 优秀

结论:在大多数应用场景中,字符串模板的性能差异可以忽略不计,其带来的开发效率提升远超微小的性能差异。

七、实际应用场景 🚀

  1. 动态HTML生成:如示例中的好友列表
  2. 多语言国际化:复杂的翻译字符串
  3. SQL查询构建:清晰的可读性
  4. 正则表达式:复杂的模式匹配
  5. 代码生成工具:自动生成代码片段

八、浏览器兼容性 🌐

现代浏览器(Chrome 41+、Firefox 34+、Edge 12+、Safari 9+)都已支持字符串模板。对于旧版浏览器,可以使用Babel等工具进行转译。

结语:拥抱字符串模板,开启优雅编程之旅

字符串模板不仅仅是语法糖,它代表了一种更优雅、更直观的编程方式。告别繁琐的字符串拼接,拥抱模板字符串带来的清晰与简洁,让你的代码像诗歌一样优雅!

"好的代码本身就是最好的文档。" —— Steve McConnell

拓展思考:你能在项目中找到哪些可以替换为字符串模板的拼接代码?立即动手重构吧!


CSS 实现九宫格缩放(9-slice scaling 不变形拉伸)

作者 德莱厄斯
2025年6月4日 18:07

前言

一些游戏中的窗口通常都有一些炫酷的效果

比如这样的

image.png

这样的

image.png

还有这样的

image.png

哎等等,你发现没有,后面两张的窗口长得很相似,只是第二张比较矮,第三张比较高,那么是不是要做两张素材来支持这种形式呢?

当然不用,在游戏开发中,通常会使用一种叫做 “9宫格缩放(9-slice scaling) ” 的技术,这是一种非常常见且极其实用的图形技术,9-slice scaling 是一种对位图图像进行缩放的方法,它将图像划分成 9 个区域(3 行 × 3 列),像这样

╔════╦═══════╦════╗
║ TL ║  TopTR ║
╠════╬═══════╬════╣
║ L  ║Center ║ R  ║
╠════╬═══════╬════╣
║ BL ║ Bottom║ BR ║
╚════╩═══════╩════╝

四个角(TL, TR, BL, BR) :固定尺寸,不缩放,用来保持 UI 的边角样式(圆角、高光、装饰等)。

四条边(Top, Bottom, Left, Right) :只在一个方向拉伸:

  • Top/Bottom 水平拉伸
  • Left/Right 垂直拉伸

中心区域(Center) :在 水平和垂直方向都可以拉伸,填满剩下的空间。

不得不说,发明九宫格缩放的人就是个天才,这大大减少了美术资源用量。

聪明的你肯定联想到了你那睿智的 UI 同事,他也总是给你一些让你抓耳挠腮的背景图,让你在使用时总是有图片拉伸变形的问题。

那么前端有没有类似的用法呢?

有的兄弟有的

哦?你以为我又要介绍某个 auto 系列插件是吗?

image.png

还真让你猜错了,今天我要说的是几个 CSS 属性。它们分别是:

  • border-image-source
  • border-image-slice
  • border-image-repeat

通过这三个属性,就可以做到让图片做到不变形的拉伸效果。

现在拿一个图片举例

image.png

可以看到,我拿红线将在此图上花了一个井字形,这个井字可不是随便画的,它大有来头,它标记了我允许它形变的地方,和保持不变的地方。

image.png

这样四个角将会保持我们目前看到的样子(原始比例),当图片宽度或高度改变时,只会改变可拉伸区域的表现。

这张图片的原始尺寸是 1064*141,且上面的截图是以原始尺寸展示的。从此图来看(梯形),它大概率要承受比它更长或更短的内容,目测一下,可以看到右上角、左上角的不允许形变区域大概是 50px、而左右不允许形变区域大概是 200px、由于这张梯形图且不考虑上下拉伸,所以下面则不能设置固定部分,否则会使左右两边发生断层(有时斜率不一致)。

所以我们这么写

      .frame-container {
        /* 1. 先设定边框宽度(上 右 下 左)*/
        border-width: 50px 200px 0px 200px;
        border-style: solid;
        border-color: transparent; /* 透明即可,边框颜色会被 image 覆盖 */
      }

我们将正常的边框属性按照刚刚目测的数值写成这样,现在看起来无事发生,因为边框是透明的。

那么我们接着写:

      .frame-container {
        /* 1. 先设定边框宽度(上 右 下 左)*/
        border-width: 50px 200px 0 200px;
        border-style: solid;
        border-color: transparent; /* 透明即可,边框颜色会被 image 覆盖 */

        /* 2. 指定 border-image 源图片 */
        border-image-source: url('./a.png');
        /* 为了演示,就给它一个动态宽度和高度 */
        width: 1064px;
        height: 141px;
        /* 3. 指定 slice,也就是按 30px / 10px / 20px / 15px 把图片分成 9 块 */
        border-image-slice: 50 200 0 200 fill;
      }

这里为了方便演示,我们将容器大小设置为图片的原始大小,目前的效果看起来有点奇怪,左右两边看起来被折断了。

      .frame-container {
        /* 1. 先设定边框宽度(上 右 下 左)*/
        border-width: 50px 200px 0 200px;
        border-style: solid;
        border-color: transparent; /* 透明即可,边框颜色会被 image 覆盖 */

        /* 2. 指定 border-image 源图片 */
        border-image-source: url('./a.png');
        /* 为了演示,就给它一个动态宽度和高度 */
        width: 1064px;
        height: 141px;
        /* 3. 指定 slice,也就是按 30px / 10px / 20px / 15px 把图片分成 9 块 */
        border-image-slice: 50 200 0 200 fill;
        /* 4. 指定拉伸(stretch)—— 
         上边/下边 拉伸水平;左边/右边 拉伸垂直;中间那块自由拉伸/填充 */
        border-image-repeat: stretch;
        transition: 1s;

        /* 5. 你可以给中间区域再加个背景,比如纯白或者另一张图 */
        background-color: white;
        /* 如果想用内容图,也可以启用下面这行: */
        /* background: url("content.png") no-repeat center/cover; */
        box-sizing: border-box; /* 确保 width/height 包括 border */
      }

加上 box-sizing: border-box; 图片看起来正常多了,我还加了其他必须的代码,现在这张图已经具备我们开头说的能力了。

加一个 hover 简单看一下:

      /* 鼠标移进去改变尺寸,看看效果,模拟内容变少 */
      .frame-container:hover {
        width: 400px;
        height: 141px;
      }

GIF 2025-6-4 17-53-17.gif

总结

通过上面的演示,可以发现,通过这种方式可以实现横向纵向(取决于图片异形,有的只能横向,有的只能纵向,有的横纵都可)的不变形拉伸。

学会了这招,再也不用向 UI 要多张切图、拆分切图了,还不谢谢我。

❌
❌