普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月27日技术

AI 四格笑话爆火,我做了什么?

2025年12月27日 12:15

0. 前言

在 2025年的尾巴上,发生了一件非常有趣的事,我在微信公众号上的 AI 四格漫画 意外爆火。之前公众号上发的技术文章,基本上阅读量不过 300,每天广告收益也就几毛钱。目前最火的美杜莎,浏览量已经达到惊人的 5W。这样让我不禁感叹:

十年技术无人问,一曲漫笑广人闻。


火爆之后,带来的最直接价值就是迎来了泼天富贵。从未想过有一天,我的日广告收益能达到 250+ ,目前已经连续三天高位。除了金钱,自己的作品受到欢迎,以及大家在评论区的吐槽、讨论,也为我带来了很大的情绪价值。

- -

1. 缘起

先简单介绍一下:我是一个籍籍无名的编程技术小博主,全网统一名号 张风捷特烈编程之王 是我维护的公众号,一直是输出编程技术的文章,主要以 Flutter 技术为主。
但技术文章更新的不是非常频繁,而公众号每天有一篇发文的机会。本着 不想浪费 的优良传统,在 AI 重塑一切的浪潮中,我想用 AI 画些四格漫画的笑话试试。于是开启了 慧心一笑 专栏, 《小火柴的倒霉日常》 就是第一篇,现在还没火。大家也可以点开看看,内容非常精简,就是一幅图+提示词。

这个系列整体是诙谐幽默的,下面是第一篇的内容:

一开始我是用自然语言的提示词,感觉效果并不是太好,四格漫画有着连续的信息和一致性的人物、场景等。由于编程出身,在 结构一致性 方面有着天然的敏锐嗅觉。于是基于 yaml 文件来定义统一的场景、角色、样式、色调等信息:

comic_info:
  type: "四格漫画"
  style: "手绘简笔画、柔软线条、轻松冷幽默、统一角色"
  color_scheme: "暖黄主色调,红橙色点缀,柔和明暗层次"
  character:
    name: "小火柴"
    appearance: "细长圆柱身体、红色火柴头、两根短竖眉毛、圆点眼睛、呆萌可爱"
    personality: "迷糊、天真、略倒霉"
  background_style: "白色简约背景,搭配少量手绘街景或物件增强生活感"

面板列表放在 panels 节点下,每个宫格由 panel[x] 固定场景内容。包括描述、场景、动作、表情、细节、文本等:

panels:
  panel1:
    description: "第一格:日常铺垫"
    scene: "温暖的手绘街道:地面为淡黄色纹理,简单的路灯、几株小草、远处一座小房子,空气里飘着幾颗小亮点"
    action: "小火柴双手背在身后,踩着轻快的小步子前进"
    expression: "轻松微笑,眼睛微弯"
    details: "路灯用细线勾勒,小草三两稀疏点缀,天空加几朵柔软的白云"
    text: "今天天气真好呀~"

定义完结构,一个 yaml 文件就对应了一个四格故事,把这个内容丢给 AI 生图的工具,就能得到对应的图片。


2. 关于 AI 生图工具与质量

我的理念是: 文本是一种序列的约定:

它可以视为一个四格漫画的 基因,而 AI 工具会将基因 实例化 为个体。

所以,生成图的好坏取决于两个因素:基因序列成长环境。也就是提示词好不好,以及 AI 工具厉不厉害。 AI 生图的工具有很多,单目前大多数,对于标准的四格漫画都无法准确输出,下面列举几个:

  • 即梦 AI

  • 豆包

  • Nano Banana

目前来看,国产的 AI 仍有很大的进步空间,Nano Banana 能符合我对图片产品的预期。但是 AI 正在蓬勃发展中, AI 生图也是最近一两年才逐渐可用的,我对他们的未来持有乐观的态度,包括我们国产的大模型。所以如果 成长环境 将会越来越好,那么 基因序列 本身将会成为非常重要的因素。
目前我只是简单设计了一下 yaml,按照版本控制,称为 v0.0.1 吧,后续随着创作需求的升级,我也会逐步迭代整体结构,设计更合理的 DNA 结构 😁


3. 选定方向? Flow Heart

有人问我,你是怎么想到这些稀奇古怪的方向的,而且你是怎么坚持下来的。

对于一个创作者来说,拓宽自己的边界是一个很必要的事。特别是对一个编程创作者,广泛涉猎是家常便饭。使用一切手段,解决自己遇到的问题;没有问题时就去发展自己,在新的领域中寻找问题。至于坚持嘛,遵循内心的指引,做自己喜欢的事,是不需要坚持的,就像你每天都要喝水一样自然。

可能有人会问,如果 AI 的笑话漫画没有火,你还会坚持下去吗?刚做前两个漫画文章时,还没有火,一天收入 1 块钱,我已经觉得很美滋滋了。投入的产出符合我的预期,毕竟只需要准备个笑话雏形,其他都交给 AI 写就行了。我还和女朋友炫耀:

- -

最后还是想强调一点:如果一件事,对社会、对他人没有危害,自己做着觉得开心,起来没有负担和压力,就会大胆去做。反之,可以在其他方面继续延伸,找到自己喜欢的那个领域。AI 工具的加持,让个体拥有了前所未有的能力,个人的边界可以极度拓宽。


4. 为什么会火?

第一次感觉会火,是因为擎天柱 这篇,浏览量异常上升:

从数据统计来看,发布第一天只有 102 个浏览量,和往常没什么区别。持续一周,没有任何波澜,突然在 12-20 号,增加了近 5000 的浏览量,第二天持续上涨过万,然后逐渐平息:


在第一篇爆火的后一天,慧心一笑#03 | 爸爸去钓鱼~ 数据开始上升,感觉像是连带效应:


为了验证一下是不是偶然火爆,我在 20号和 21 号又发表了两篇小笑话。结果不温不火,似乎感觉也不是必然的。 在 23 号,我发布了 慧心一笑#06 | 被美杜莎石化...,这篇在当晚直接火爆,

从数据来看,第二天浏览量直接过 2.6W,后面还有持续几天的流量:

至于为什么火爆,从阅读渠道构成来看 98.7% 的阅读量来自于公众号推荐。只能说是老天喂饭吃 ~


5. 小结一下

接下来几天的 慧心一笑#07 | 爸爸回来了...慧心一笑#09 | 农夫与蛇 也阅读过 3万。目前慧心一笑系列发布了 9 篇,阅读量超过 2.5W 的爆款有 5 篇,比例算是很高了。

感觉微信公众号的推荐阅读机制应该有所变化。另外也不是每篇都会火爆,应该和作品本身质量、流传度也有关系。这个有趣的现象让我非常欣喜,后续我还会继续创作更有意思的四格漫画,来继续验证数据。大家也可以关注 《编程之王》 公众号和我一起见证。等到第 30 篇后,我会再写一个复盘报告,和大家分享。

另外可能会有人问,你发这个就不怕别人也抄你的模式,跟你竞争吗。我只想说:


更多文章和视频知识资讯,大家可以关注我的公众号、掘金和 B 站 。让我们一起成长,变得更强。我们下次再见~

Vue3与iframe通信方案详解:本地与跨域场景

作者 小杨梅君
2025年12月27日 11:42

ps:本项目使用的vue3技术栈

Vue3与iframe通信方案详解:本地与跨域场景

本文详细介绍了在Vue3项目中,与内嵌iframe(包括本地HTML文件和服务端跨域HTML)进行双向通信的完整解决方案。核心通信方式为postMessage API,并针对不同场景提供了安全可靠的代码示例。

1. iframe加载本地HTML文件

1.1 Vue端通信代码

<template>
...
    <iframe
        ref="iframe"
        name="iframe-html"
        src="./index.html"
        width="100%"
        height="100%"
        frameborder="0"
    ></iframe>
...
</template

如何在vue端跟iframe端加载的.html文件进行通讯呢,看下面的代码

// vue端
...
const sendMsg2iframe = (msg) => {
    window["iframe-html"].sendMsg2iframe(msg);
}
...
// index.html
...
window.sendMsg2iframe = function (msg) {
    // 接收到vue端发来的消息
}
...

1.2 iframe端(index.html)通信代码

// index.html
function sendMessageToVue(messageData) {
    // 发送消息到父窗口
    window.parent.postMessage(messageData, window.location.origin);
}

// vue端
// 组件挂载时开始监听消息
onMounted(() => {
  window.addEventListener('message', handleReceiveMessage);
});

// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
  window.removeEventListener('message', handleReceiveMessage);
});

// 接收来自iframe消息的处理函数
const handleReceiveMessage = (event) => {
  // 重要:在实际应用中,应验证event.origin以确保安全
  // if (event.origin !== '期望的源') return;
  
  console.log('Vue组件收到来自iframe的消息:', event.data);
  // 在这里处理接收到的数据
};

2. iframe加载服务器HTML(跨域场景)

其实还是通过window的postMessage进行通讯,只不过是涉及到了跨域问题,下面是具体的代码,关键在于postMessage的第二个参数上

2.1 html端通信代码

// .html
...
// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get('parentOrigin') || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener('message', function (event) {
    if (event.origin === parentOrigin) {
        console.log('收到来自父窗口的消息:', event.data);
        if(event.data.type === 'sendJSON2Unity'){
            window.SendJSON2Unity(event.data.data);
        }
    }
});
function sendMessageToVue(messageData) {
    // 发送消息到父窗口
    window.parent.postMessage(messageData, parentOrigin);
}
...

2.2 Vue端通信代码

// .vue
...
<iframe
    ref="iframeRef"
    name="unity-home"
    :src="violationDocumentURL"
    width="100%"
    height="100%"
    frameborder="0"
    @load="onIframeLoad">
</iframe>
...
// 这里把自己的origin通过URL参数传给iframe
const violationDocumentURL = import.meta.env.VITE_U3D_SERVICE + "具体路径" + "?parentOrigin=" + encodeURIComponent(window.location.origin);

const iframeRef = ref(null);
const iframeOrigin = ref(import.meta.env.VITE_U3D_SERVICE.replace(/\/$/, ""));  // iframe加载的资源的origin
const sendToUnity = (data) => {
    iframeRef.value.contentWindow.postMessage(
        data,
        iframeOrigin.value
    );
};

// 组件挂载时开始监听消息
onMounted(() => {
  window.addEventListener('message', handleReceiveMessage);
});

// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
  window.removeEventListener('message', handleReceiveMessage);
});
// 接收来自iframe的消息
const handleMessageFromIframe = (event) => {
    // 确保消息来自可信的来源
    if (event.origin === iframeOrigin.value) {
        if (event.data) {
            // do something
        }
    }
};

ok基本就是这样的

3 服务器HTML端(Unity WebGL示例)

因为我们是加载的unity的webgl包,所以最后附赠一下打出的webgl包的index.html的代码(ps:是不压缩版的)

<!DOCTYPE html>
<html lang="en-us" style="width: 100%; height: 100%">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Unity WebGL Player | NanDingGDS</title>
</head>
<body id="unity3d-body" style="text-align: center; padding: 0; border: 0; margin: 0; width: 100%; height: 100%; overflow: hidden">
<canvas id="unity-canvas" style="background: #231f20"></canvas>
<script>
/** unity的web包加载逻辑开始 */
const canvas = document.getElementById("unity-canvas");
const body = document.getElementById("unity3d-body");
const { clientHeight, clientWidth } = body;

if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
var meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes";
document.getElementsByTagName("head")[0].appendChild(meta);
container.className = "unity-mobile";
canvas.className = "unity-mobile";
} else {
canvas.width = clientWidth;
canvas.height = clientHeight;
}

const baseUrl = "Build/webgl";
var loaderUrl = baseUrl + ".loader.js";
var myGameInstance = null;
var script = document.createElement("script");
script.src = loaderUrl;
var config = {
dataUrl: baseUrl + ".data",
frameworkUrl: baseUrl + ".framework.js",
codeUrl: baseUrl + ".wasm",
streamingAssetsUrl: "StreamingAssets",
companyName: "DefaultCompany",
productName: "FanWeiZhang",
productVersion: "0.1.0",
};
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {}).then((unityInstance) => {
myGameInstance = unityInstance;
sendMessageToVue({
type: "unityLoaded",
message: "Unity3D加载完成",
});
});
};
document.body.appendChild(script);
/** unity的web包加载逻辑结束 */

// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get("parentOrigin") || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener("message", function (event) {
if (event.origin === parentOrigin) {
console.log("收到来自父窗口的消息:", event.data);
if (event.data.type === "sendJSON2Unity") {
window.SendJSON2Unity(event.data.data);
}
}
});
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, parentOrigin);
}

window.SendJSON2Unity = function (str) {
console.log("发送到Unity的JSON字符串:", str);
myGameInstance.SendMessage("WebController", "receiveJSONByWeb", str);
};

window.QuiteUnity = function () {
console.log("退出Unity3D");
sendMessageToVue({
type: "quitUnity",
message: "退出Unity3D",
});
};
// window.js2Unity = function (str) {
// // 第一个参数是unity中物体的名称,第二是要调用的方法名称,第三个参数是unity中接收到的参数
// // myGameInstance.SendMessage('Main Camera', 'TestRotation', '')
//     console.log(str);
// }
</script>
</body>
</html>


css和图片主题色“提取”

作者 hello_Code
2025年12月27日 11:33

这个想法是来源于「性能优化」中的骨架屏: 在图片居多的站点中,这将是非常nice的体验 —— 图片加载通常是比较让人难受的,好的骨架中一般占位图就是低像素的图片,即大体配色和变化是和实际内容一致的。 有时候比如图片不固定的,那可以使用算法获取图片的主体颜色(至少得是同色系的吧),使用纯色块占位。

再进一步想到,在一些“轻松”的场景下,我们可以让背景色/页面主题色跟随轮播图改变。至于效果嘛......你们可以想一下网易云音乐滑动切歌时的背景效果。

因为是不固定图片,所以我想到了四种方法:

  • tensorflow.js 图像色彩分析
  • canvas对图片主基调进行分析,取大概值
  • css高斯模糊
  • 上传图片时后端对图片分析处理,返回时直接返回一张低像素图片

第一种方式目前还在我的实践中,以后会单独出一篇文章;最后一种方式个人不太建议首选:首先后端处理也需要时间,另一方面毕竟也是以图片进行传输的...yee~(而且后端可能也不太建议你首选🤣)

想看实际效果的推荐自己动手试下,因为我发现本文中用QQ截屏截取的图片怎么都这么暗啊,实际展示的还是挺漂亮的。

第三种方式看起来是纯css实现的,怎么获取呢?这就要说到css中的filter: blur(); 简单来说,利用模糊滤镜及进一步拉伸,可以近似地拿到一张图片的主题色:

<div></div>
div {
background: url(图片地址);
background-size: cover;
filter: blur(50px);
}

你看,通过比较大的一个模糊滤镜,将图片高斯模糊50px,模糊后的图片是不是有点内味了, ruawaba

不过还不行,存在一些模糊边缘,我们可以利用overflow进行剪裁。

接下来,我们需要去掉模糊的边角,以及通过transform: scale()放大效果,将颜色进一步聚焦: 这里就很推荐使用伪元素进行操作了

div {
position: relative;
width: xx;
height: xx;
overflow: hidden;
}
div::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url(图片地址);
background-size: cover;
filter: blur(50px);
transform: scale(2); //自行更改
transform-origin: center center;
}

ruawaba2

这样就拿到图片的主色调了。当然是要进行其他处理的。

再来说说第二种方法 —— canvas。其实也不建议,因为本身就是JS操作,而在图片又不固定又有些多的情况下单线程的js处理这种“一级事件”造成的性能和体验感的损失是不可想象的。但本文笔者还是要分享一下,因为这是我当初研究的第一个被应用的成果(有情怀了嘿嘿)

首先,canvas中的getImageData()方法可以获取图片的像素集合:

function getImagePixel(canvas, img) {
const context = canvas.getContext("2d");
context.drawImage(img, 0, 0);
return context.getImageData(0, 0, canvas.width, canvas.height).data;
}

这里对使用canvas不熟悉的同学提个醒:img是异步加载的,所有对图片的操作都要放在 img 的 onload 中进行 —— 你可以考虑用 promise 做这件事。

rgba

调用这个函数会拿到一个数组 —— 它是rgba值,也就是说,处理时四个数据为“一组”,更通俗地说,for循环中i+=4!来处理一下数据:

function getCountArr(pData) {
let colorList = [], rgba = [], rgbaStr = '';
for(let i=0; i<pData.length; i+=4) {
rgba[0] = pData[i];
rgba[1] = pData[i+1];
rgba[2] = pData[i+2];
rgba[3] = pData[i+3];
if(rgba.indexOf(undefined)!==-1 || pData[i+3] === 0) {
continue;
}
rgbaStr = rgba.join(',');
if(rgbaStr in colorList) {
++colorList[rgbaStr];
}else {
colorList[rgbaStr] = 1;
}
}
return colorList;
}

这个时候,得到的就是每组数据(色值)出现的次数了。 然后改写刚刚的getImagePixel函数:

return getCountArr(pixelData);

至此,我们将其排序并取出第一个值/或者取出某些标志项的平均值,基本上就可以将其作为 background 值了!


峰回路转!

你难道真觉得canvas的这种方法只是鸡肋?那试想这样一种场景:在弱网情况下,图片必定贼慢才能加载出来。这时候我们通过js拿到图片的主色调并填充到图片的位置中。这是不是一个“模糊渐变加载”的绝佳场景! 而且,笔者曾经遇到这样一个场景:往图片上添加文字。这时候你就需要注意一个问题,图片主色调。用canvas分析图片的主要颜色或平均色可以在深色调时添加白色文字在浅色调时添加黑色文字!

笔者前段时间弄了一个微信公众号:前端Code新谈。里面暂时有webrtc、前端面试和用户体验系列文章,欢迎关注!希望能够帮到大家,也希望能互相交流!共同进步

Echarts常用配置

作者 小白x
2025年12月27日 11:25
title设置字体

textStyle

option = {
  title: {
    text: "Main Title",
    subtext: "Sub Title",
    left: "center",
    top: "center",
    textStyle: {
      fontSize: 30,
      fontWeight:'bolder'
    },
    subtextStyle: {
      fontSize: 20
    }
  }
}
控制图表边距

grid: { top: '20%',botton:'20%',left:'10%',right:'10%' },

X轴坐标系标签,旋转角度
       xAxis: [
          {
            type: 'category',
            data: data,
            axisPointer: {
              type: 'shadow'
            },
            axisLabel: { // 坐标轴刻度标签的相关设置。
              rotate: '20' // x轴数据标签旋转角度
            }
          }
        ],
限制柱状图最大宽度
    series: [
          {
            name: '数量',
            type: 'bar',
            barMaxWidth: 50, // 最大宽度
            data: data
          }]
柱状图渐变色
series里面
    itemStyle: {
              color: new echarts.graphic.LinearGradient(
                0, 0, 0, 1, // 渐变方向从左上角到右下角
                [
                  { offset: 0, color: 'rgb(128,100,162)' }, // 0% 处的颜色
                  { offset: 1, color: '#fff' } // 100% 处的颜色
                ]
              )
            },
柱状图文字显示

直接在取消柱子上方显示具体数据信息,以及自定义信息,比如100%,数字后面加一个百分号 1)show,显示节点上的文本信息 2)position,文本位置,可以根据需要调整为 ‘top’, ‘bottom’, ‘inside’, ‘insideTop’, 等 top,表示在节点上方

series: [
    {
      data: [150, 230, 224, 218, 135, 147, 260],
      type: 'bar',
      label:{
        show:true,
        position:'top',
        formatter:function(data){
          return data.value+'件'
        }
      }
    }
  ]
折线图变平滑

series属性中使用smooth: true语句让折线图变成平滑折线图

echart柱状图最小间隔

var option = {
    // ... 其他配置项
    yAxis: {
        type: 'value',
        // 设置Y轴的最小间隔
        minInterval: 1 // 示例值,根据实际需求进行调整
    },
    // ... 其他配置项
};

立体柱状图

var xData2 = ['容城谷庄']
var data1 = [50]
option = {
    backgroundColor: 'rgba(0,0,0,0)',
    grid: {
        left: 0,
        bottom: 15,
        top: 15,
        right: 80
    },
    xAxis: {
        data: xData2,
        axisTick: {
            show: false
        },
        axisLine: {
            show: false
        },
        axisLabel: {
            show: false
        }
    },
    yAxis: {
        splitLine: {
            show: false
        },
        axisTick: {
            show: false
        },
        axisLine: {
            show: false
        },
        axisLabel: {
            // textStyle: {
            //     color: '#fff',
            //     fontSize: 20,
            // },
            // 不显示Y轴数值
            formatter: function () {
                return ''
            }
        }
    },
    series: [
        // 数据低下的圆片
        {
            name: '',
            type: 'pictorialBar',
            symbolSize: [41, 15],
            symbolOffset: [0, 8],
            z: 12,
            symbol: 'circle', // 修改为圆形
            itemStyle: {
                opacity: 1,
                color: function (params) {
                    return new echarts.graphic.LinearGradient(
                        1,
                        // 深色#2BA9ED 浅色 #34EDF2
                        0,
                        0,
                        0,
                        [
                            {
                                offset: 0,
                                color: '#E1DC53' // 0% 处的颜色
                            },
                            {
                                offset: 1,
                                color: '#E1DC53' // 100% 处的颜色
                            }
                        ],
                        false
                    )
                }
                // color: 'transparent'
            },
            data: [1]
        },
        // 数据的柱状图
        {
            name: '',
            type: 'bar',
            barWidth: 41,
            itemStyle: {
                // lenged文本
                opacity: 1, // 这个是 透明度
                color: function (params) {
                    return new echarts.graphic.LinearGradient(
                        0,
                        1,
                        0,
                        0,
                        [
                            {
                                offset: 0,
                                color: '#E1DC53' // 0% 处的颜色
                            },
                            {
                                offset: 1,
                                color: '#E8AE62' // 100% 处的颜色
                            }
                        ],
                        false
                    )
                }
            },

            data: data1
        },
        // 替代柱状图 默认不显示颜色,是最下方柱图(邮件营销)的value值 - 20
        {
            type: 'bar',
            symbol: 'circle', // 修改为圆形
            barWidth: 43,
            itemStyle: {
                color: 'transparent'
            },
            data: data1
        },
        // 数据顶部的样式
        {
            name: '',
            type: 'pictorialBar',
            symbol: 'circle', // 修改为圆形
            symbolSize: [41, 15],
            symbolOffset: [0, -8],
            z: 12,
            itemStyle: {
                normal: {
                    opacity: 1,
                    color: function (params) {
                        return new echarts.graphic.LinearGradient(
                            0,
                            0,
                            1,
                            0,
                            [
                                {
                                    offset: 0,
                                    color: '#E1DC53' // 0% 处的颜色
                                },
                                {
                                    offset: 1,
                                    color: '#E8AE62' // 100% 处的颜色
                                }
                            ],
                            false
                        )
                    },
                    label: {
                        show: true, // 开启显示
                        position: 'top', // 在上方显示
                        textStyle: {
                            // 数值样式
                            color: '#FFFFFF',
                            fontSize: 20,
                            top: 50
                        },
                        formatter: function (param) {
                            return param.data + '%'
                        }
                    }
                }
            },
            symbolPosition: 'end',
            data: data1
        },

        // 阴影的顶部
        {
            name: '', // 头部
            type: 'pictorialBar',
            symbol: 'circle', // 修改为圆形
            symbolSize: [41, 15],
            symbolOffset: [0, -8],
            z: 17,
            symbolPosition: 'end',
            itemStyle: {
                color: 'rgba(24,78,134,0.3)',
                opacity: 0.3,
                borderWidth: 1,
                borderColor: '#526558'
            },
            data: [100]
        },
        // 后面的背景
        {
            name: '2019',
            type: 'bar',
            barWidth: 41,
            barGap: '-100%',
            z: 0,
            itemStyle: {
                color: 'rgba(24,78,134,0.1)'
            },
            data: [100]
        }
    ]
}
Echarts给柱状图上面增加小横杠
 option = {
      title: {
        text: '世界人口统计',
        left: 'center',
        textStyle: {
          fontSize: 24,
          fontWeight: 'bold',
          color: '#333'
        }
      },

      xAxis: {
        type: 'value',
        boundaryGap: [0, 0.01],
        name: '人口 (万)',
        nameLocation: 'middle',
        nameGap: 30,
        axisLabel: {
          formatter: function(value) {
            if (value >= 10000) {
              return (value / 10000) + '亿';
            }
            return value;
          }
        }
      },
      yAxis: {
        type: 'category',
        data: ['巴西',  '中国', '世界'],
        axisLabel: {
          color: '#666',
          fontSize: 14
        }
      },
      series: [
        {
          name: '2011',
          type: 'bar',
          data: [10,20],
          itemStyle: {
            borderRadius: [0, 4, 4, 0],
            color: '#36A2EB'
          },
          label: {
            show: true,
            position: 'right',
            formatter: function(params) {
              return params.value.toLocaleString();
            }
          },
          markPoint: {
             symbol: 'rect',
             symbolSize: [4, 20],
            data: [
              {
                // 标记巴西 (2011)
                name: '',
                coord: [10, '巴西'],
                itemStyle: { color: '#FF6384' }
              },
              {
                // 标记中国 (2011)
                name: '',
                coord: [20, '中国'],
                itemStyle: { color: '#FF6384' }
              }
            ]
          }
        }
      ]
    };


javascript

markPoint: { symbol: 'rect', // 标记点形状为矩形 symbolSize: [4, 20], // 标记点大小 data: [ { name: '', coord: [2, 36], value: 36 } // 关键配置 ] }


  


假设这是一个**折线图或柱状图**(直角坐标系):

  


-   **`coord: [2, 36]`**  的含义:

    -   **`2`**:在 X 轴上,对应**第 3 个类目**(索引从 0 开始,例如 `['一月', '二月', '三月', ...]` 中的 `'三月'`)。
    -   **`36`**:在 Y 轴上的数值位置(例如 Y 轴范围是 0~100,标记点位于 Y=36 的高度)。

-   **效果**:在 X 轴第 3 个类目(`'三月'`)与 Y 轴数值 36 的交叉点处,绘制一个 4×20 大小的矩形标记
柱状图文字太多不显示

优化 X 轴标签显示

若标签文字过长或过多,即使调整柱子间距仍可能显示不全,需进一步配置 axisLabel

1. 强制显示所有标签(避免省略)

xAxis: {
  axisLabel: {
    interval: 0, // 强制显示所有标签(默认自动隐藏部分标签)
    // 或使用 formatter 换行(适用于长标签)
    formatter: function (value) {
      return value.split('').join('\n'); // 按字符换行(示例)
      // 或根据字数换行:return value.substr(0, 4) + '\n' + value.substr(4);
    }
  }
}

2. 旋转标签文字

通过 rotate 调整文字角度,避免重叠:

xAxis: {
  axisLabel: {
    rotate: 45, // 旋转角度(建议 30°~60°,避免垂直显示)
    margin: 10 // 标签与轴的间距,防止被柱子遮挡
  }
}

3. 自适应隐藏部分标签

若必须显示部分标签,可通过 interval 控制显示间隔(如每隔 N 个显示 1 个):

xAxis: {
  axisLabel: {
    interval: 1 // 0=全部显示,1=隔 1 个显示 1 个,2=隔 2 个显示 1 个,依此类推
  }
}
取消Y轴分割线
yAxis: {
          type: 'value',
          splitLine: {
            show: false
          }
        },
y轴上方标题
option = {
  yAxis: {
    name: '数量\n(个)',  // 名称和单位分行
    nameLocation: 'end',
    nameGap: 5,
    nameTextStyle: {
      color: '#333',       // 深色文本
      align: 'left',       // 左对齐
      lineHeight: 16,      // 行高控制间距
      padding: [0, 0, 0, -8]  // 往左偏移更多
    },
    // 其他配置...
  },
  // 其他配置...
};
设置柱状图间隔(象形图)

image.png

echart分组柱状图没数组不展示
  // 原始数据源(模拟有缺失数据的场景)
      let tufang = [100, 200, 150, 80, 70, 110, 10];
      let qiaoliang = [100, 80, 90, 0, 60, 0, 150];
      let suidao = [0, 90, 150, 80, 70, 0, 10];
      let lumian = [0, 0, 10, 80, 70, 0, 0];
      let jidian = [90, 190, 150, 0, 70, 0, 10];
      const option = {
        tooltip: {},
        title: {
          show: true,
          text: '不符合常理的柱状图表实现',
          textStyle: {
            fontSize: 14,
            lineHeight: 18,
            width: 10
          }
        },
        xAxis: [
          {
            type: 'category',
            axisLabel: {
              align: 'center',
              hideOverlap: true
            },
            data: this.specificKeys
          }
        ],
        yAxis: [
          {
            type: 'value'
          }
        ],
        series: [
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: tufang
          },
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: qiaoliang
          },
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: suidao
          },
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: lumian
          },
          {
            type: 'custom',
            renderItem: function (params, api) {
              return getRect(params, api);
            },
            data: jidian
          }
        ]
      }

      function getRect (params, api) {
        let dataSeries = [
          tufang,
          qiaoliang,
          suidao,
          lumian,
          jidian
        ]; // 确保这里有5个数据系列
        const { seriesIndex } = params;
        let categoryIndex = api.value(0); // x轴序列
        let vald = api.value(1); // 数据值
        // 如果数据为0,则不渲染柱子
        if (vald === 0) {
          return;
        }
        let start = api.coord([categoryIndex, vald]);
        let height = api.size([0, vald]);
        // 柱子宽度和间距
        let barWidth = 30; // 单个柱子的固定宽度
        let barGap = 3; // 柱子之间的间距
        // 计算当前系列的偏移量
        let xOffset = dataSeries.slice(0, seriesIndex).reduce((sum, currentSeries, index) => {
          return sum + (currentSeries[categoryIndex] !== undefined && currentSeries[categoryIndex] !== 0 ? barWidth + barGap : 0);
        }, 0);
        // 计算当前系列的x位置
        let x = start[0] - barWidth / 2 + xOffset - 10; // 柱子的中心位置 再减20是因为让其起点靠中间左边点
        return {
          type: 'rect',
          shape: {
            x: x, // 当前柱子的x位置
            y: start[1],
            width: barWidth,
            height: height[1]
          },
          style: api.style()
        };
      }
      option && this[myChart].setOption(option, true)

React 19 源码揭秘(二):useState 的实现原理

作者 借个火er
2025年12月27日 10:42

React 19 源码揭秘(二):useState 的实现原理

本文深入 React 源码,带你彻底搞懂 useState 从调用到更新的完整流程。

前言

useState 可能是你用得最多的 Hook,但你知道它背后是怎么工作的吗?

const [count, setCount] = useState(0);
setCount(count + 1);  // 这行代码背后发生了什么?

本文将从源码角度,完整解析 useState 的实现原理。

一、Hook 的数据结构

首先,我们需要了解 Hook 在 React 内部是如何存储的。

Hook 节点

每个 Hook 调用都会创建一个 Hook 对象:

type Hook = {
  memoizedState: any,    // 存储的状态值
  baseState: any,        // 基础状态(用于更新计算)
  baseQueue: Update | null,  // 基础更新队列
  queue: UpdateQueue | null, // 更新队列
  next: Hook | null,     // 指向下一个 Hook
};

Hook 链表

多个 Hook 以链表形式存储在 Fiber 节点的 memoizedState 上:

Fiber.memoizedState
        │
        ▼
    ┌───────┐     ┌───────┐     ┌───────┐
    │ Hook1 │ ──► │ Hook2 │ ──► │ Hook3 │ ──► null
    │useState│     │useEffect│    │useMemo│
    └───────┘     └───────┘     └───────┘

这就是为什么 Hook 不能在条件语句中调用——React 依赖调用顺序来匹配 Hook。

二、首次渲染:mountState

当组件首次渲染时,useState 会调用 mountState

// 源码位置:react-reconciler/src/ReactFiberHooks.js

function mountState(initialState) {
  // 1. 创建 Hook 节点,加入链表
  const hook = mountWorkInProgressHook();
  
  // 2. 处理初始值(支持函数式初始化)
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  
  // 3. 保存初始状态
  hook.memoizedState = hook.baseState = initialState;
  
  // 4. 创建更新队列
  const queue = {
    pending: null,           // 待处理的更新
    lanes: NoLanes,
    dispatch: null,          // setState 函数
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;
  
  // 5. 绑定 dispatch 函数(就是 setState)
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;
  
  // 6. 返回 [state, setState]
  return [hook.memoizedState, dispatch];
}

mountWorkInProgressHook

这个函数负责创建 Hook 节点并维护链表:

function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

  if (workInProgressHook === null) {
    // 第一个 Hook,挂载到 Fiber
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 追加到链表末尾
    workInProgressHook = workInProgressHook.next = hook;
  }
  
  return workInProgressHook;
}

三、触发更新:dispatchSetState

当你调用 setCount(1) 时,实际执行的是 dispatchSetState

function dispatchSetState(fiber, queue, action) {
  // 1. 获取更新优先级
  const lane = requestUpdateLane(fiber);
  
  // 2. 创建更新对象
  const update = {
    lane,
    action,              // 新值或更新函数
    hasEagerState: false,
    eagerState: null,
    next: null,
  };
  
  // 3. 性能优化:Eager State(提前计算)
  if (fiber.lanes === NoLanes) {
    const currentState = queue.lastRenderedState;
    const eagerState = basicStateReducer(currentState, action);
    update.hasEagerState = true;
    update.eagerState = eagerState;
    
    // 如果新旧状态相同,跳过更新!
    if (Object.is(eagerState, currentState)) {
      return;  // Bailout!
    }
  }
  
  // 4. 将更新加入队列
  enqueueConcurrentHookUpdate(fiber, queue, update, lane);
  
  // 5. 调度更新
  scheduleUpdateOnFiber(root, fiber, lane);
}

Eager State 优化

这是一个重要的性能优化:

const [count, setCount] = useState(0);

// 点击按钮
setCount(0);  // 状态没变,React 会跳过这次更新!

React 会在调度之前就计算新状态,如果和旧状态相同(通过 Object.is 比较),直接跳过整个更新流程。

四、更新渲染:updateState

当组件重新渲染时,useState 会调用 updateState

function updateState(initialState) {
  // useState 本质上是预设了 reducer 的 useReducer
  return updateReducer(basicStateReducer, initialState);
}

// 基础 reducer:支持值或函数
function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

updateReducer

这是处理更新的核心逻辑:

function updateReducer(reducer, initialArg) {
  // 1. 获取当前 Hook
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  
  // 2. 获取待处理的更新
  const pending = queue.pending;
  
  // 3. 计算新状态
  let newState = hook.baseState;
  if (pending !== null) {
    let update = pending.first;
    do {
      const action = update.action;
      newState = reducer(newState, action);
      update = update.next;
    } while (update !== null);
  }
  
  // 4. 保存新状态
  hook.memoizedState = newState;
  
  // 5. 返回新状态和 dispatch
  return [hook.memoizedState, queue.dispatch];
}

updateWorkInProgressHook

更新时,需要从 current 树复制 Hook:

function updateWorkInProgressHook() {
  // 从 current Fiber 获取对应的 Hook
  let nextCurrentHook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    nextCurrentHook = current.memoizedState;
  } else {
    nextCurrentHook = currentHook.next;
  }
  
  currentHook = nextCurrentHook;
  
  // 复制 Hook 到 workInProgress
  const newHook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  };
  
  // 加入链表...
  return newHook;
}

五、完整流程图

┌─────────────────────────────────────────────────────────┐
│                    首次渲染 (Mount)                      │
├─────────────────────────────────────────────────────────┤
│  useState(0)                                            │
│      │                                                  │
│      ▼                                                  │
│  mountState(0)                                          │
│      │                                                  │
│      ├──► 创建 Hook 节点                                │
│      ├──► 初始化 memoizedState = 0                      │
│      ├──► 创建 UpdateQueue                              │
│      ├──► 绑定 dispatch = dispatchSetState              │
│      │                                                  │
│      ▼                                                  │
│  返回 [0, setCount]                                     │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    触发更新                              │
├─────────────────────────────────────────────────────────┤
│  setCount(1)                                            │
│      │                                                  │
│      ▼                                                  │
│  dispatchSetState(fiber, queue, 1)                      │
│      │                                                  │
│      ├──► 获取优先级 lane                               │
│      ├──► 创建 Update 对象                              │
│      ├──► Eager State: 计算新状态                       │
│      ├──► 比较新旧状态,相同则 Bailout                   │
│      ├──► 入队更新                                      │
│      │                                                  │
│      ▼                                                  │
│  scheduleUpdateOnFiber() ──► 调度重新渲染               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    重新渲染 (Update)                     │
├─────────────────────────────────────────────────────────┤
│  useState(0)  // 初始值被忽略                           │
│      │                                                  │
│      ▼                                                  │
│  updateState(0)                                         │
│      │                                                  │
│      ▼                                                  │
│  updateReducer(basicStateReducer, 0)                    │
│      │                                                  │
│      ├──► 获取对应的 Hook                               │
│      ├──► 处理 UpdateQueue 中的更新                     │
│      ├──► 计算新状态 = 1                                │
│      │                                                  │
│      ▼                                                  │
│  返回 [1, setCount]                                     │
└─────────────────────────────────────────────────────────┘

六、Dispatcher 切换

React 如何区分 mount 和 update?答案是 Dispatcher 切换

// renderWithHooks 中
ReactSharedInternals.H = 
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount   // 首次渲染
    : HooksDispatcherOnUpdate; // 更新渲染

// 两个 Dispatcher 的 useState 指向不同函数
const HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  // ...
};

const HooksDispatcherOnUpdate = {
  useState: updateState,
  useEffect: updateEffect,
  // ...
};

渲染完成后,切换到 ContextOnlyDispatcher,禁止在组件外调用 Hook:

// 渲染完成后
ReactSharedInternals.H = ContextOnlyDispatcher;

const ContextOnlyDispatcher = {
  useState: throwInvalidHookError,  // 抛出错误
  // ...
};

七、为什么 Hook 不能条件调用?

现在你应该明白了:

// ❌ 错误
if (condition) {
  const [a, setA] = useState(0);  // Hook 1
}
const [b, setB] = useState(0);    // Hook 2 或 Hook 1?

// ✅ 正确
const [a, setA] = useState(0);    // 始终是 Hook 1
const [b, setB] = useState(0);    // 始终是 Hook 2

React 通过遍历链表来匹配 Hook,如果顺序变了,状态就乱了。

八、调试技巧

想要亲自验证?在这些位置打断点:

// 首次渲染
mountState          // react-reconciler/src/ReactFiberHooks.js

// 触发更新
dispatchSetState    // react-reconciler/src/ReactFiberHooks.js

// 重新渲染
updateReducer       // react-reconciler/src/ReactFiberHooks.js

用 Counter 组件测试:

const Counter = () => {
  const [count, setCount] = useState(0);  // 断点这里
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

小结

本文深入分析了 useState 的实现原理:

  1. 数据结构:Hook 以链表形式存储在 Fiber.memoizedState
  2. 首次渲染:mountState 创建 Hook 和 UpdateQueue
  3. 触发更新:dispatchSetState 创建 Update,调度渲染
  4. Eager State:提前计算,相同状态跳过更新
  5. 重新渲染:updateReducer 处理更新队列,计算新状态
  6. Dispatcher:通过切换实现 mount/update 的区分

下一篇我们将分析 useEffect 的实现原理,看看副作用是如何被调度和执行的。


📦 配套源码:github.com/220529/reac…

上一篇:React 19 源码全景图

下一篇:useEffect 的实现原理

如果觉得有帮助,欢迎点赞收藏 👍

React 19 源码全景图:从宏观到微观

作者 借个火er
2025年12月27日 10:41

React 19 源码全景图:从宏观到微观

本文是 React 源码系列的总览篇,帮你建立完整的知识框架,后续文章将逐一深入。

一、React 是什么?

一句话:React 是一个将状态映射为 UI 的函数

UI = f(state)

当状态变化时,React 会:

  1. 计算新的 UI(Reconciler)
  2. 调度更新任务(Scheduler)
  3. 将变化应用到 DOM(Renderer)

二、三大核心模块

┌─────────────────────────────────────────────────────────┐
│                        你的代码                          │
│            <App /> → useState → setState                │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                   Scheduler 调度器                       │
│                                                         │
│  • 优先级管理(用户交互 > 动画 > 数据请求)               │
│  • 时间切片(5ms 一片,避免卡顿)                        │
│  • 任务队列(最小堆实现)                                │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                  Reconciler 协调器                       │
│                                                         │
│  • Fiber 架构(可中断的链表结构)                        │
│  • Diff 算法(最小化 DOM 操作)                          │
│  • Hooks 系统(状态和副作用管理)                        │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                   Renderer 渲染器                        │
│                                                         │
│  • ReactDOM(Web)                                      │
│  • React Native(移动端)                               │
│  • React Three Fiber3D)                              │
└─────────────────────────────────────────────────────────┘

三、核心概念速览

1. Fiber

Fiber 是 React 的核心数据结构,每个组件对应一个 Fiber 节点:

FiberNode {
  // 类型信息
  tag,              // 组件类型(函数组件=0,类组件=1,DOM=5)
  type,             // 组件函数或 DOM 标签
  
  // 树结构
  return,           // 父节点
  child,            // 第一个子节点
  sibling,          // 兄弟节点
  
  // 状态
  memoizedState,    // Hooks 链表
  memoizedProps,    // 上次的 props
  
  // 副作用
  flags,            // 标记(插入、更新、删除)
  
  // 双缓冲
  alternate,        // 另一棵树的对应节点
}

2. Lane(优先级)

React 19 使用 31 位二进制数表示优先级:

SyncLane           = 0b0000000000000000000000000000010  // 同步(最高)
InputContinuousLane = 0b0000000000000000000000000001000  // 连续输入
DefaultLane        = 0b0000000000000000000000000100000  // 默认
TransitionLane     = 0b0000000000000000000000010000000  // 过渡
IdleLane           = 0b0010000000000000000000000000000  // 空闲(最低)

3. 双缓冲

React 维护两棵 Fiber 树:

  • current:当前屏幕显示的
  • workInProgress:正在构建的

更新完成后一行代码切换:root.current = workInProgress

四、渲染流程

完整流程图

setState() 
    │
    ▼
scheduleUpdateOnFiber()     ← 标记更新
    │
    ▼
ensureRootIsScheduled()     ← 确保调度
    │
    ▼
scheduleCallback()          ← Scheduler 调度
    │
    ▼
performConcurrentWorkOnRoot() ← 开始渲染
    │
    ├─────────────────────────────────────┐
    │         Render 阶段(可中断)         │
    │                                     │
    │  workLoopConcurrent()               │
    │      │                              │
    │      ▼                              │
    │  performUnitOfWork() ←──┐           │
    │      │                  │           │
    │      ▼                  │           │
    │  beginWork()            │ 循环      │
    │      │                  │           │
    │      ▼                  │           │
    │  completeWork() ────────┘           │
    │                                     │
    └─────────────────────────────────────┘
    │
    ▼
    ├─────────────────────────────────────┐
    │        Commit 阶段(不可中断)        │
    │                                     │
    │  commitBeforeMutationEffects()      │
    │      │                              │
    │      ▼                              │
    │  commitMutationEffects()  ← DOM操作 │
    │      │                              │
    │      ▼                              │
    │  root.current = finishedWork        │
    │      │                              │
    │      ▼                              │
    │  commitLayoutEffects()              │
    │                                     │
    └─────────────────────────────────────┘
    │
    ▼
flushPassiveEffects()       ← useEffect(异步)

Render 阶段

beginWork(向下递归):

  • 根据组件类型处理(函数组件、类组件、DOM 元素)
  • 调用组件函数,执行 Hooks
  • Diff 子节点,创建/复用 Fiber

completeWork(向上回溯):

  • 创建 DOM 节点
  • 收集副作用标记
  • 冒泡 subtreeFlags

Commit 阶段

三个子阶段:

阶段 时机 主要工作
Before Mutation DOM 操作前 getSnapshotBeforeUpdate
Mutation DOM 操作 增删改 DOM
Layout DOM 操作后 useLayoutEffect、componentDidMount

五、Hooks 原理

Hooks 以链表形式存储在 Fiber 的 memoizedState 上:

Fiber.memoizedState → useState → useEffect → useMemo → null

useState 流程

Mount:  mountState() → 创建 Hook → 初始化状态 → 返回 [state, setState]
Update: updateState() → 获取 Hook → 处理更新队列 → 返回 [newState, setState]

useEffect 流程

Mount:  mountEffect() → 创建 Effect → 标记 Passive
Commit: flushPassiveEffects() → 执行销毁函数 → 执行创建函数

六、Diff 算法

React Diff 的三个策略:

  1. 同层比较:不跨层级移动节点
  2. 类型判断:类型不同直接替换
  3. key 标识:通过 key 识别节点

单节点 Diff

key 相同 && type 相同 → 复用
key 相同 && type 不同 → 删除重建
key 不同 → 删除,继续找

多节点 Diff(三轮遍历)

第一轮:从左到右,处理更新
第二轮:处理新增或删除
第三轮:处理移动(Map 查找)

七、Scheduler 调度

优先级

ImmediatePriority   // -1ms,立即执行
UserBlockingPriority // 250ms,用户交互
NormalPriority      // 5000ms,普通更新
LowPriority         // 10000ms,低优先级
IdlePriority        // 永不过期,空闲执行

时间切片

function workLoop() {
  while (task && !shouldYield()) {  // 5ms 检查一次
    task = performTask(task);
  }
  if (task) {
    scheduleCallback(task);  // 还有任务,继续调度
  }
}

八、源码目录

packages/
├── react/                    # React API
│   └── src/ReactHooks.js     # Hooks 入口
│
├── react-reconciler/         # 协调器(核心)
│   └── src/
│       ├── ReactFiber.js           # Fiber 定义
│       ├── ReactFiberWorkLoop.js   # 工作循环
│       ├── ReactFiberBeginWork.js  # 递阶段
│       ├── ReactFiberCompleteWork.js # 归阶段
│       ├── ReactFiberHooks.js      # Hooks 实现
│       ├── ReactFiberCommitWork.js # Commit
│       ├── ReactFiberLane.js       # 优先级
│       └── ReactChildFiber.js      # Diff 算法
│
├── react-dom/                # DOM 渲染器
│
└── scheduler/                # 调度器
    └── src/
        ├── Scheduler.js          # 调度逻辑
        └── SchedulerMinHeap.js   # 最小堆

九、系列文章导航

序号 主题 核心内容
00 调试环境搭建 项目介绍、快速开始
01 架构总览(本文) 三大模块、核心概念
02 useState 原理 Hook 链表、更新队列
03 useEffect 原理 Effect 链表、执行时机
04 Fiber 工作循环 beginWork、completeWork
05 Diff 算法 单节点、多节点 Diff
06 Scheduler 调度器 优先级、时间切片
07 Commit 阶段 三个子阶段、DOM 操作

十、学习建议

  1. 先跑起来:clone react-debug,打断点调试
  2. 从 useState 开始:最简单也最核心
  3. 画流程图:边看边画,加深理解
  4. 写测试组件:验证你的理解

📦 配套源码:github.com/220529/reac…

上一篇:React 源码调试环境搭建

下一篇:useState 的实现原理

Cursor Visual Editor:前端样式调试的新利器

作者 bytemanx
2025年12月27日 03:05

作为前端开发者,你一定经历过这样的场景:为了调整一个渐变的角度、修改一个元素的行高,反复在代码和浏览器之间切换,改一行代码、保存、刷新、看效果、再改……

这种"盲调"的方式效率低下,尤其是在调试 CSS 动画这类需要精细控制的效果时,更是让人抓狂。

好消息是,Cursor 2.2 带来了一个令人兴奋的新功能——Visual Editor。它将你的 Web 应用、代码库和可视化编辑工具整合到同一个窗口中,让界面调试变得前所未有的直观。

今天,我们就通过两个炫酷的 CSS 动画案例,来体验一下这个可视化编辑器的强大之处。

认识 Visual Editor

首先,在 Cursor 中选择 Open Browser 即可打开内置浏览器小窗口:

20251227024633_rec_-convert.gif

根据 Cursor 官方博客 的介绍,Visual Editor 提供了四大核心能力:

1. 拖拽重排(Drag-and-drop)

直接在渲染好的页面上拖动元素,调整布局结构。你可以交换按钮顺序、旋转区块位置、测试不同的网格配置——所有操作都不需要切换上下文。当视觉设计符合预期后,让 Agent 帮你更新底层代码。

2. 组件状态测试(Test component states)

对于 React 应用,Visual Editor 可以在侧边栏直接显示组件的 props,让你方便地切换不同的组件状态变体。

3. 属性可视化调整(Visual controls)

这是最实用的功能之一。侧边栏提供了滑块、颜色选择器等可视化控件,支持实时预览。你可以精确调整颜色、布局、字体等属性,所有改动即时生效。

4. 点击 + 提示(Point and prompt)

选中界面上的任意元素,用自然语言描述你想要的修改。比如点击一个元素说"把这个变大",点击另一个说"改成红色"——多个 Agent 会并行执行,几秒钟内完成修改。

实战案例一:渐变流动文字

让我们用一个渐变流动文字效果来体验 Visual Editor 的威力。

效果展示

先来看看最终效果:

可视化调试体验

在 Visual Editor 中打开这个页面后,点击文字元素即可选中它:

image.png

选中后,侧边栏会显示该元素的所有可调整属性。比如我们想调整渐变的角度,只需要拖动滑块即可实时预览效果:

20251226200440_rec_.gif

想象一下,如果用传统方式调试这个角度参数:修改代码 → 保存 → 等待热更新 → 查看效果 → 不满意再改……而现在只需要拖动滑块,所见即所得!

核心原理

这个渐变流动效果的实现原理其实很简单,核心代码如下:

.text {
    /* 多色线性渐变 */
    background: linear-gradient(
        90deg,
        rgba(48, 207, 208, 1) 0%,
        rgba(102, 166, 255, 1) 22%,
        rgba(136, 136, 136, 1) 40%,
        rgba(255, 154, 139, 1) 60%,
        rgba(51, 8, 103, 1) 81%,
        rgba(48, 207, 208, 1) 100%
    );
    /* 背景宽度设为元素的 2 倍 */
    background-size: 200% auto;
    /* 将背景裁剪到文字形状 */
    -webkit-background-clip: text;
    background-clip: text;
    /* 文字颜色透明,露出背景 */
    color: transparent;
    /* 应用流动动画 */
    animation: gradient-flow 3s linear infinite;
}

@keyframes gradient-flow {
    0% {
        background-position: 0% center;
    }
    100% {
        background-position: 200% center;
    }
}

原理解析:

  1. linear-gradient:创建一个多色渐变背景,首尾颜色相同以实现无缝循环
  2. background-size: 200%:让背景宽度是元素的两倍,为动画提供移动空间
  3. background-clip: text:将背景裁剪到文字轮廓内
  4. animation:通过改变 background-position 从 0% 到 200%,让渐变"流动"起来

实战案例二:立体透视文字

接下来看一个更有意思的效果——立体透视文字。

效果展示

可视化调试体验

这个效果的视觉呈现高度依赖于 line-heightclip-height 等参数的精确配合。使用 Visual Editor,我们可以直观地调整这些数值:

20251227021832_rec_-convert.gif

通过可视化调整,你可以直观地看到参数变化对立体效果的影响,快速找到最佳的视觉平衡点。

核心原理

这个立体透视效果的核心在于 CSS 变换的巧妙组合:

:root {
    --clip-height: 90px;
    --line-height: 85px;
    --left-offset: 50px;
}

.Words-line {
    height: var(--clip-height);
    overflow: hidden;
    position: relative;
}

/* 奇数行:倾斜 + 压缩 */
.Words-line:nth-child(odd) {
    transform: skew(60deg, -30deg) scaleY(0.66667);
}

/* 偶数行:倾斜 + 拉伸 */
.Words-line:nth-child(even) {
    transform: skew(0deg, -30deg) scaleY(1.33333);
}

/* 每行递增的左偏移,形成阶梯效果 */
.Words-line:nth-child(1) { left: calc(var(--left-offset) * 1); }
.Words-line:nth-child(2) { left: calc(var(--left-offset) * 2); }
.Words-line:nth-child(3) { left: calc(var(--left-offset) * 3); }
/* ... */

原理解析:

  1. skew() 倾斜变换:通过不同的倾斜角度,让奇偶行形成视觉上的"折叠"效果
  2. scaleY() 垂直缩放:奇数行压缩(0.66667),偶数行拉伸(1.33333),配合倾斜创造 3D 透视错觉
  3. 递增的 left 偏移:每行向右偏移,形成阶梯状的立体层次
  4. overflow: hidden:裁剪超出的内容,确保每行只显示固定高度

hover 时的文字切换动画则通过 translate3d 实现:

p {
    transition: all 0.4s ease-in-out;
}

.Words:hover p {
    transform: translate3d(0, calc(var(--clip-height) * -1), 0);
}

总结

Cursor Visual Editor 的出现,真正实现了"设计即代码"的理念:

  • 所见即所得:告别反复保存刷新的低效循环,样式调整即时生效
  • 降低心智负担:不再需要脑补参数变化的效果,可视化控件让调试更直观
  • 设计与代码统一:在同一窗口完成视觉调整和代码修改,无缝衔接

这个功能特别适合以下场景:

  1. 样式微调:颜色、间距、字体大小等参数的精细调整
  2. 布局实验:快速测试不同的布局方案
  3. 动画调试:实时预览动画参数的变化效果

正如 Cursor 官方所说,他们看到了一个未来:Agent 与 Web 应用开发深度融合,人们通过更直观的界面将想法转化为代码。Visual Editor 正是朝着这个方向迈出的重要一步。

如果你还没有尝试过这个功能,强烈建议打开 Cursor,用你自己的项目体验一下——相信你会爱上这种"所见即所得"的开发方式!

Tailwind CSS:原子化 CSS 的现代开发实践

作者 Tzarevich
2025年12月27日 00:26

Tailwind CSS:原子化 CSS 的现代开发实践

在当今快速迭代的前端开发环境中,如何高效、一致且可维护地构建用户界面,成为每个团队必须面对的核心问题。传统 CSS 的命名困境、样式冗余和复用难题,催生了一种新的解决方案——原子化 CSS(Atomic CSS)。而 Tailwind CSS,正是这一理念最成功的实践者。本文将结合实际代码与开发场景,深入解析 Tailwind CSS 的核心思想、优势及最佳实践。


一、什么是原子化 CSS?

传统 CSS 倾向于“语义化命名”:我们为组件起一个名字(如 .card-title),然后为其编写样式。这种方式常被称为“面向对象 CSS”(OOCSS),它试图通过封装基类、组合多态来提升复用性。例如:

.btn { padding: 8px 16px; border-radius: 6px; }
.btn-primary { background: skyblue; color: white; }

但实践中,样式往往带有太多业务属性,导致在一个或少数类名下,样式几乎无法跨项目复用,最终演变为“一次性 CSS”。

原子化 CSS 则反其道而行之:它将样式拆解为最小、单一职责的“原子类”,每个类只控制一个具体的样式属性。例如:

  • p-4padding: 1rem
  • bg-blue-500background-color: #3b82f6
  • text-centertext-align: center

这些类名直接描述样式本身,而非内容语义。通过组合这些原子类,我们可以在 HTML 中直接构建 UI,无需离开模板文件。

将我们的 CSS 规则拆分成原子 CSS,会有大量的基类,好复用、好维护,不会重复。


二、Tailwind CSS:原子化理念的集大成者

Tailwind CSS 是一个功能优先(Utility-First)的 CSS 框架,它不提供预设组件(如 Bootstrap 的 .btn),而是提供一套完整的工具类系统。

示例:构建一个按钮

<button className="px-4 py-2 bg-[skyblue] text-white rounded-lg hover:bg-blue-200">
  提交
</button>
  • px-4 py-2:设置内边距,表示水平方向内边距1rem,垂直方向内边距0.5rem;
  • bg-[skyblue]:背景色;
  • rounded-lg:圆角,lg为large大号圆角(0.5rem = 8px);
  • hover:bg-blue-200:悬停效果,鼠标悬停时背景色变为蓝色系200深度颜色,hover为悬停伪类前缀。

所有样式一目了然,无需查阅 CSS 文件。

🤖 LLM 时代的理想搭档

随着大语言模型(LLM)的普及,用自然语言生成 UI 代码成为可能。而 Tailwind 的类名具有高度语义化、结构化、可预测的特点:

  • 开发者只需描述:“一个带圆角、蓝色背景、白色文字的按钮”
  • LLM 即可输出:<button class="px-4 py-2 bg-blue-500 text-white rounded">

相比之下,传统 CSS 需要模型同时生成 HTML 和 CSS,并保证类名匹配,难度更高。Tailwind 让“Prompt → UI” 的路径更短、更可靠


三、快速上手:基于 Vite 的项目配置

要在项目中使用 Tailwind,只需几步:

  1. 创建 Vite 项目:

    npm init vite
    
  2. 安装 Tailwind 及官方 Vite 插件:

    npm install -D tailwindcss @tailwindcss/vite
    npx tailwindcss init
    
  3. 配置 vite.config.js

    import { defineConfig } from 'vite'
    import tailwindcss from '@tailwindcss/vite' // tailwind插件
    import react from '@vitejs/plugin-react' // react插件
    
    // https://vite.dev/config/
    export default defineConfig({
    plugins: [react(), tailwindcss()],
    })
    
  4. 在入口 CSS 文件(如 index.css)中引入:

    @import "tailwindcss";
    

至此,所有原子类即可在 JSX/HTML 中直接使用,几乎无需再手写 CSS

四、性能与工程化:DocumentFragment 与 React Fragment

高效的 UI 不仅关乎视觉,也涉及性能。在动态渲染大量 DOM 节点时,减少重排/重绘至关重要。

原生优化:DocumentFragment

const fragment = document.createDocumentFragment();
fragment.appendChild(p1);
fragment.appendChild(p2);
container.appendChild(fragment); // 仅触发一次 DOM 更新

通过 DocumentFragment,我们将多个节点在内存中组装后一次性插入,显著提升性能。

React 场景:Fragment 解决单根限制

React 要求组件返回单一根节点。若需返回多个同级元素,传统做法是包裹一个无意义的 <div>,但这会污染 DOM 结构。

Tailwind + React 的最佳实践是使用 Fragment

export default function App() {
  return (
    <>
      <h1>111</h1>
      <h2>222</h2>
      <ArticleCard /> {/* 自定义卡片组件 */}
    </>
  )
}

<>...</>(即 <React.Fragment>)允许我们返回多个元素,不产生额外 DOM 节点,保持结构纯净,同时也便于一次性插入整个 UI 片段,提升渲染性能。

五、响应式设计:Mobile First 的优雅实现

Tailwind 内置响应式前缀,完美支持“移动端优先”开发策略。

基础布局(移动端垂直堆叠):

<div className="flex flex-col gap-4">
  <main className="bg-blue-100 p-4">主内容</main>
  <aside className="bg-green-100 p-4">侧边栏</aside>
</div>

增强至桌面端(水平排列):

<div className="flex flex-col md:flex-row gap-4">
  <main className="bg-blue-100 p-4 md:w-2/3">主内容</main>
  <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
</div>
  • 小屏:flex-col(垂直)
  • 中屏及以上:md:flex-row(水平)

这种“渐进增强”的方式,确保了在所有设备上都有良好体验。

六、为什么选择 Tailwind?

  1. 开发效率高:样式即代码,无需上下文切换;
  2. 设计一致性:基于预设的设计系统(间距、颜色、字体等);
  3. 高度可定制:通过 tailwind.config.js 扩展主题、断点、插件;
  4. 极致性能:JIT 模式仅生成用到的 CSS,体积极小;
  5. 未来友好:与 React、Vue、Svelte 等现代框架无缝集成;
  6. AI 友好:类名结构清晰,易于 LLM 理解与生成。

七、结语

Tailwind CSS 不仅仅是一个 CSS 框架,更是一种UI 开发哲学。它通过原子化、功能优先的设计,将 CSS 从“命名的艺术”转变为“组合的科学”。正如我们在文章中所见,无论是简单的按钮、复杂的卡片,还是响应式布局,Tailwind 都能以简洁、直观的方式实现。

更重要的是,在 AI 编程时代,Tailwind 的结构化、语义化类名使其成为自然语言生成 UI 的理想载体。对于追求效率、一致性和可维护性的现代前端团队而言,Tailwind CSS 无疑是值得拥抱的利器。

“不用离开 HTML 写 CSS 了,所有的样式都在类名中。”
—— 这或许是对 Tailwind 最精炼的赞美。

参考资料

JavaScript 有哪些数据类型?它们在内存里是怎么存的?

作者 刘大华
2025年12月27日 09:45

有时候对 JavaScript 的代码很是疑惑,我写过这样一段代码:

let user1 = { name: '小明' };
let user2 = user1;
user2.name = '小红';
console.log(user1.name); // 结果居然是 “小红”?

我当时愣住了:我只是改了 user2,为什么 user1 也跟着变了?
而换成数字时,却完全不是这样:

let a = 10;
let b = a;
b = 20;
console.log(a); // 还是 10,没变

明明都是赋值,行为怎么差这么多?

后来我才明白,问题的关键并不是代码的写法问题,而是在 JavaScript 的数据类型和它们在内存中的存储方式

搞懂这一点,很多看似奇怪的行为就都顺理成章了。


一、JavaScript 有几种数据类型?

根据 ECMAScript 标准,JavaScript 的数据类型分为两大类:

1. 原始类型——共 7 种

这些类型的特点是:值不可变、直接表示数据本身

类型 示例 说明
number 42, 3.14, NaN 所有数字,包括整数、浮点数、特殊值
string 'hello', "JS" 字符串,不可变
boolean true, false 布尔值
undefined let a; console.log(a); 变量已声明但未赋值
null let b = null; 表示空或无值(注意:它是原始类型!)
symbol Symbol('id') ES6 引入,唯一且不可变的标识符
bigint 123n, 9007199254740991n ES2020 引入,用于表示任意大的整数

typeof null 返回 "object" 是 JavaScript 的一个历史性 bug,至今未修复。但从语言设计上,null 属于原始类型。


2. 引用类型——统称对象

除了上述 7 种,其他所有值都是对象,属于引用类型,包括:

  • 普通对象:{ name: 'Tom' }
  • 数组:[1, 2, 3]
  • 函数:function() {}
  • 日期:new Date()
  • 正则:/abc/
  • 甚至 MapSetPromise

引用类型的核心特点是:可变、通过引用来访问实际数据


二、它们在内存中怎么存储?

这是理解一切行为差异的根源!

计算机内存大致分为两个区域:

  • 栈(Stack):速度快,空间小,用于存储简单、固定大小的数据。
  • 堆(Heap):空间大,速度稍慢,用于存储复杂、动态大小的数据。

原始类型:直接存在栈里

let age = 25;
let isStudent = true;

这两个变量就像两个小抽屉,值本身直接放在栈中

栈(Stack)
┌─────────────────┐
 age: 25         
 isStudent: true 
└─────────────────┘

当你复制一个原始值:

let myAge = age; // 把 25 复制一份
myAge = 30;
console.log(age); // 仍然是 25

因为 myAge 拿到的是 全新的副本,和 age 完全无关。

所以原始类型是按值进行传递(Pass by Value)。


引用类型:栈存地址,堆存真实对象

let user = { name: 'Alice', age: 25 };

这时发生了两件事:

  1. 对象 { name: 'Alice', age: 25 } 被创建,存放在堆中。
  2. 变量 user 并不直接包含这个对象,而是保存一个指向堆中对象的引用地址,这个地址放在栈里。

图示如下:

栈(Stack)              堆(Heap)
┌──────────────┐      ┌──────────────────────────┐
│ user: [地址A] ├─────→│ { name: 'Alice', age: 25 } │
└──────────────┘      └──────────────────────────┘

当你赋值给另一个变量:

let admin = user; // 复制的是地址,不是对象!
admin.name = 'Bob';
console.log(user.name); // 输出 "Bob"!

因为 adminuser 指向同一个堆中的对象

栈(Stack)              堆(Heap)
┌──────────────┐      
 user: [地址A] ├──┐   
├──────────────┤     ┌──────────────────────────┐
 admin: [地址A] ├──┼──→│ { name: 'Bob', age: 25 }  
└──────────────┘     └──────────────────────────┘
                  
                  └───┘

所以引用类型是按共享的方式进行传递(Pass by Sharing)

注意:不是按引用传递,JS 中没有真正的按引用传递


三、深入对比

特性 原始类型 引用类型(对象)
存储位置 栈(Stack) 栈(存引用地址) + 堆(存实际对象)
赋值行为 复制值本身 复制引用地址(多个变量共享同一对象)
是否可变 不可变(操作生成新值) 可变(可直接修改属性)
比较方式 比较值是否相等 比较引用地址是否相同
内存占用 固定、较小 动态、可能很大
典型场景 数字、字符串、布尔值 对象、数组、函数

举个比较的例子:

// 原始类型比较
5 === 5; // true
'hi' === 'hi'; // true

// 引用类型比较
{} === {}; // false!两个不同对象
let a = {}; let b = a;
a === b; // true!同一个对象

四、常见问题

为什么字符串很长,也算原始类型?

虽然字符串可能很长,但 JS 引擎会做内部优化(比如使用指针),但从语言语义上,字符串是不可变的原始值。任何“修改”都会生成新字符串。

let s = 'hello';
s = s + ' world'; // 创建了新字符串,原 'hello' 未被修改

如何真正复制一个对象,而不是共享?

浅拷贝:只复制第一层属性

let newObj = { ...oldObj };
// 或
let newObj = Object.assign({}, oldObj);

深拷贝:递归复制所有层级(需注意循环引用等问题)

let deepCopy = JSON.parse(JSON.stringify(obj)); // 有局限(不能拷贝函数、undefined 等)
// 推荐使用 Lodash 的 _.cloneDeep()

五、总结

  1. 原始类型:值直接存在栈里,赋值是复制值,互不影响。
  2. 引用类型:栈里存地址,堆里存对象,赋值是复制地址,共享同一对象。
  3. 理解存储机制,是掌握 JS 赋值、函数传参、状态管理、性能优化的基础!

下次当你看到:

let a = obj;
a.x = 10;
console.log(obj.x); // 为什么变了?

你就知道:因为 a 和 obj 指向的是同一个对象!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《代码里全是 new 对象,真的很 Low 吗?我认真想了一晚》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 5 个冷门 HTML 标签,让我直接删了100 行 JS 代码》

《Vue 组件通信的 8 种最佳实践,你知道几种?》

五年前端,我凌晨三点的电脑屏幕前终于想通了这件事

作者 destinying
2025年12月26日 21:57

五年前端开发:那些加班到深夜的日子里,我终于找到了答案

转眼间,做前端已经五年了。回想起这些年的点点滴滴,有为了一个像素对不齐而折腾到凌晨的执着,也有终于解决了一个性能问题后的欣喜若狂。

💻 那些让我抓狂的瞬间

一个padding搞了我一晚上

记得刚入行的时候,有个布局问题让我头疼了一整晚。就是两个div之间的间距,怎么调都不对。那时候我还不知道浏览器默认样式这回事,对着Chrome开发者工具一遍遍地试,各种margin、padding组合,结果第二天早上一问资深同事,人家轻描淡写地说:"reset.css加了么?"

那一刻我才明白,很多你以为的技术难题,其实只是知识盲区而已

"这个需求很简单"背后的深坑

产品经理说:"这个需求很简单,就是加个拖拽排序功能。"

我:"好的,应该一天就够了。"

然后我才发现,拖拽排序要考虑:

  • 移动端的手势识别
  • PC端的鼠标事件
  • 不同浏览器的事件兼容性
  • 拖拽过程中的视觉反馈
  • 边界处理和碰撞检测
  • 性能优化(防止频繁重绘)
  • 可访问性支持

三天后,我终于交出了"看似简单"的功能。从那以后,我再也不轻易相信"这个需求很简单"这种话了。

🌱 那些让我成长的时刻

第一次重构老项目

接手一个三年前的老项目,代码里到处都是document.getElementById,jQuery和原生JS混用,全局变量满天飞。重构过程中,我发现了一些有意思的"黑历史":

// 当年的前辈们是怎么写代码的
function getData() {
  if (data1 == null) {
    data1 = [];
    for (var i = 0; i < 100; i++) {
      data1.push(i);
    }
  }
  return data1;
}

// 还有这种神奇的操作
$("#button").click(function() {
  setTimeout(function() {
    location.reload();
  }, 100);
});

重构那段时间,每天都在跟历史代码搏斗,但也正是这个过程,让我真正理解了什么叫"代码可维护性"。

学会了说"不"

以前刚入行时,产品提什么需求我都说"行"。直到有一次,为了赶一个不合理的deadline,我熬了好几个通宵,最后上线的版本还出了bug。

后来我学聪明了,开始跟产品和沟通:

  • 这个需求的技术复杂度是多少
  • 需要多少开发时间
  • 如果一定要提前,哪些功能可以砍掉
  • 当前技术方案的风险点在哪里

学会评估和沟通,比学会写代码更重要

🤔 程序员的日常思考

关于加班的那些事

刚开始工作的时候,我觉得加班=努力。后来慢慢发现:

  • 有效的时间管理比长时间工作更重要
  • 会写代码不等于会解决问题
  • 健康比KPI重要得多

我现在尽量不加班,不是因为懒,而是我学会了:

  • 提前评估工作量
  • 及时沟通风险
  • 拒绝不合理的需求
  • 保持专注,减少无效加班

关于技术焦虑

前端技术更新太快,Vue还没学完,React又出了新特性,CSS框架层出不穷。前两年我很焦虑,怕被淘汰。

现在我想通了:

  • 基础永远是王道:HTML/CSS/JavaScript的核心不会变
  • 学习要讲方法:不要追着新技术跑,要有选择地学
  • 项目驱动学习:在实际项目中学习新技术效果最好
  • 保持输出:写博客、做分享是最好的学习方式

💪 真正的成长是什么

从技术思维到产品思维

刚开始我只关心代码写得爽不爽,后来我开始思考:

  • 用户真的需要这个功能吗?
  • 这个交互体验够好吗?
  • 性能优化能带来什么价值?
  • 我的代码对团队协作友好吗?

技术是工具,不是目的。真正的前端开发,是用技术为用户创造价值。

找到了自己的节奏

现在的我:

  • 不再盲目追新技术,而是选择适合自己的技术栈
  • 重视代码质量,但不执着于完美
  • 会主动沟通需求,而不是被动接受
  • 保持学习的热情,但不焦虑
  • 知道什么时候该努力,什么时候该休息

🎯 给自己的一些话

五年下来,我想对自己说:

  1. 保持好奇,但不要盲目跟风
  2. 写代码很重要,但解决问题更重要
  3. 技术要精进,但生活也要平衡
  4. 多分享,多交流,多思考
  5. 记住,你首先是一个人,其次才是程序员

✨ 下一个五年

技术这条路很长,但我不急了。慢慢地学习,稳稳地成长,踏实做好每一个项目。

毕竟,最好的代码不是最复杂的,而是最合适的。最好的程序员不是最聪明的,而是最懂得平衡的。

愿我们都能在这条路上,找到属于自己的节奏和答案。


你在前端路上有什么难忘的经历?欢迎在评论区分享你的故事。

#前端开发 #程序员成长 #技术感悟 #职场经验 #真实感受

Vue3-插槽slot

作者 YaeZed
2025年12月26日 21:47

插槽是 Vue 组件中一个非常核心的概念,它允许你以一种灵活的方式将内容“插入”到子组件的指定位置,极大地提高了组件的复用性和灵TA性。插槽允许组件只负责渲染一个“框架”(比如边框、阴影),而把“内容”的决定权交给使用它的父组件。

1.默认插槽

最简单的插槽,子组件中只有一个未命名的 <slot> 出口。

子组件

<template>
  <div class="card">
    <h3>卡片标题</h3>
    <slot></slot> </div>
</template>

<style scoped>
.card {
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 16px;
  max-width: 300px;
}
</style>

父组件

<template>
  <BaseCard>
    <p>这是一父组件</p>
    <img src="./assets/logo.png" alt="Vue Logo" style="width: 100px;">
  </BaseCard>
</template>

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

2.具名插槽

当子组件需要多个“坑位”时(例如,一个用于头部,一个用于底部),就需要使用具名插槽。

子组件:使用 name 属性来区分不同的插槽。

<template>
  <div class="modal">
    <header class="modal-header">
      <slot name="header"></slot> </header>

    <main class="modal-body">
      <slot></slot> </main>

    <footer class="modal-footer">
      <slot name="footer"></slot> </footer>
  </div>
</template>

<style scoped>
.modal { background: #fff; border: 1px solid #ddd; }
.modal-header, .modal-footer { padding: 10px; background: #f4f4f4; }
.modal-body { padding: 20px; }
</style>

父组件:使用 <template> 标签和 v-slot 指令(或其简写 #)来指定要填充的插槽。

<template>
  <ModalLayout>
    <template v-slot = "header">
      <h2>这是一个模态框标题</h2>
    </template>

    <p>这是模态框的主要内容...</p>

    <template #footer>
      <button>取消</button>
      <button>确认</button>
    </template>
  </ModalLayout>
</template>

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

3.作用域插槽

这是插槽最强大的功能。它允许子组件向父组件的插槽内容传递数据。这在处理列表渲染时非常有用,子组件负责数据迭代,而父组件负责定义每一项的渲染样式。

子组件:子组件通过在 <slot> 标签上绑定属性,来将数据"暴露"给父组件。

<template>
  <div class="user-list">
    <p>用户列表:</p>
    <ul>
      <li v-for="user in users" :key="user.id">
        <slot :user="user" :isAdmin="user.name === 'Alice'"></slot>
      </li>
    </ul>
  </div>
</template>

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

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

const users = ref<User[]>([
  { id: 1, name: 'Alice', age: 30 },
  { id: 2, name: 'Bob', age: 25 },
]);
</script>

父组件:父组件通过 v-slot (或 #) 接收子组件传递的数据,并且可以立即为这些数据添加 TypeScript 类型

<template>
  <UserList>
    <template #default="{ user, isAdmin }: { user: User, isAdmin: boolean }">
      <span> 
        {{ user.name }} ({{ user.age }}岁)
      </span>
      <span v-if="isAdmin" style="color: red; margin-left: 10px;">[管理员]</span>
    </template>
  </UserList>
</template>

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

// 我们可以在父组件中也定义这个类型,以便复用
interface User {
  id: number;
  name: string;
  age: number;
}
</script>

4.自定义插槽(Vue 3.3+)

在 Vue 3.3 及更高版本中, <script setup> 提供了 defineSlots 宏,这是在子组件中为插槽提供类型的官方方式。这极大地改善了开发体验,父组件不再需要手动声明类型,因为 TS 可以自动从子组件推断它们。

子组件:使用 defineSlots 来声明插槽及其期望的 props 类型。

<template>
  <div class="list">
    <ul>
      <li v-for="(item, index) in items" :key="item.id">
        <slot :item="item" :index="index"></slot>
      </li>
    </ul>
  </div>
</template>

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

// 1. 定义数据和类型
interface Item {
  id: string;
  text: string;
}
const items = ref<Item[]>([
  { id: 'a', text: '第一项' },
  { id: 'b', text: '第二项' },
]);

// 2. (重点) 使用 defineSlots 
// 这是一个宏,无需导入
// 它定义了 'default' 插槽会接收一个对象,该对象包含一个 'item' 属性 (类型为 Item)
// 和一个 'index' 属性 (类型为 number)
defineSlots<{
  default(props: { item: Item; index: number }): any;
  // 如果有具名插槽,也可以在这里定义,比如:
  // header(props: { title: string }): any;
}>();
</script>

父组件:父组件的 slotProps (或解构的变量) 会被自动推断出正确的类型

<template>
  <TypedList>
    <template #default="{ item, index }">
      <strong>{{ index + 1 }}.</strong> {{ item.text.toUpperCase() }}
      </template>
  </TypedList>
</template>

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

5.总结

  • 布局组件 (Layout.vue):

    • 场景: 定义网站的通用布局,如侧边栏、顶部导航和内容区域。
    • 用法: 使用 header, sidebar, main 等具名插槽,让不同页面填充自己的内容。
  • 可复用 UI 元素 (Modal.vue, Card.vue, Dropdown.vue):

    • 场景: 封装通用的交互和样式,但允许内容高度自定义。
    • 用法: Modal 组件提供 header (标题), body (内容), footer (按钮) 插槽。
  • 列表渲染器 (DataList.vue, ProductGrid.vue):

    • 场景: 组件负责获取和迭代数据(如 API 请求、分页),但把如何渲染每一项的控制权交给父组件。
    • 用法: (核心) 使用作用域插槽,将 item (当前项数据) 传递给父组件。这是最灵活的模式。
  • 提供者组件 (Toggle.vue, MouseTracker.vue):

    • 场景: 组件管理某个状态(如 isOn)或逻辑(如鼠标位置),并通过作用域插槽将这些状态暴露出去,让父组件来决定如何渲染。
    • 用法: 子组件 <slot :isOn="isOn" :toggle="toggleFunction"></slot>

esp32 小智AI 项目

2025年12月26日 21:16

#一、接入 MAX98357 MAX98357 是一款非常流行的 I2S 数字音频放大器模块,与 ESP32 搭配是制作数字音频项目的经典组合。控制它的核心在于:正确配置 ESP32 的 I2S 音频接口,并向其发送数字音频数据。为了方便你理解从代码到声音的完整流程,下图清晰地展示了控制 MAX98357 的核心工作流:

image.png

🔌 硬件连接(接线)

连接非常简单,因为 MAX98357 使用标准的 I2S 接口:

ESP32 引脚 MAX98357 引脚 作用
3.3V VIN 电源(3.3V-5V均可,与ESP32共用3.3V最方便)
GND GND 接地(必须共地)
GPIO 25 (或其它) DIN 串行数据输入,这是最重要的数据线。
GPIO 26 (或其它) BCLK 位时钟,用于同步每一位数据。
GPIO 27 (或其它) LRC 左右声道时钟,用于切换左右声道。
(可选)GPIO(如15) GAIN 增益控制。不接时默认为高增益(15dB) 。如需低增益(9dB),将此脚接地。
(不连接) SD 关断引脚,MAX98357 内部已下拉,正常工作时无需连接。

引脚选择提示:ESP32 有多个 I2S 引脚,你可以使用上表的引脚,也可以使用其他支持 I2S 的引脚(如 GPIO 5, 17, 18, 19, 21, 22, 23 等),只要在代码中统一即可。

🛠️ 软件配置与编程(PlatformIO)

在 PlatformIO 项目中,最便捷的方法是使用一个优秀的音频库:AudioTools

  1. 安装库
    在 PlatformIO 的 “Library Manager” 中搜索 AudioTools by pschatzmann 并安装。这个库几乎囊括了所有音频功能,非常适合入门。
  2. 编写基础程序
    以下代码演示如何播放一个简单的 440Hz 正弦波(标准音A) ,这是测试音频系统的“Hello World”。

cpp

/**
 * ESP32 + MAX98357 基础测试
 * 播放一个440Hz的正弦波
 */
#include <Arduino.h>
#include "AudioTools.h"

// 1. 定义I2S输出接口,并指定引脚
// 参数解释:I2SStream(int data_pin, int clock_pin, int lr_pin)
I2SStream i2s;
const int data_pin = 25;  // DIN 连接的GPIO
const int clock_pin = 26; // BCLK 连接的GPIO
const int lr_pin = 27;    // LRC 连接的GPIO

// 2. 定义音频信号源:一个440Hz的正弦波
SineWaveGenerator<int16_t> sineWave(32000); // 生成16位有符号整数格式的正弦波,振幅32000
GeneratedSoundStream<int16_t> sound(sineWave); // 将正弦波包装成音频流
StreamCopy copier(i2s, sound); // 音频数据复制器:将声音源的数据复制到I2S输出

void setup() {
  Serial.begin(115200);
  
  // 3. 配置I2S音频参数
  auto config = i2s.defaultConfig();
  config.pin_data = data_pin;
  config.pin_bck = clock_pin;
  config.pin_ws = lr_pin;
  config.sample_rate = 44100; // 标准采样率
  config.bits_per_sample = 16; // 16位采样深度
  config.channels = 2; // 立体声
  config.i2s_format = I2S_STD_FORMAT; // 标准I2S格式
  
  // 4. 初始化I2S
  i2s.begin(config);
  
  // 5. 配置正弦波参数:440Hz,采样率与I2S一致
  sineWave.begin(config.channels, config.sample_rate, 440);
  
  Serial.println("开始播放 440Hz 正弦波(标准音A)...");
}

void loop() {
  // 不断将生成的音频数据复制到I2S接口
  copier.copy();
}

🎵 播放实际音频文件(如WAV)

要播放SD卡或SPIFFS中的音频文件,你需要:

  1. 安装额外库:在 PlatformIO 中安装 AudioCodecs by pschatzmann
  2. 准备音频文件:将其转换为 单声道或立体声、16位、采样率不超过44100Hz的WAV文件,并上传到ESP32的SPIFFS或SD卡中。

以下是播放SPIFFS中test.wav文件的示例代码框架:

cpp

#include <Arduino.h>
#include "AudioTools.h"
#include "AudioLibs/AudioSourceSPIFFS.h" // SPIFFS音频源
#include "AudioCodecs/CodecWAV.h" // WAV解码器

I2SStream i2s;
SPIFFSStream file; // SPIFFS文件流
WAVDecoder dec; // WAV解码器
EncodedAudioStream decoder(&i2s, &dec); // 解码后的音频流指向I2S
StreamCopy copier(decoder, file); // 将文件->解码->I2S

void setup() {
  Serial.begin(115200);
  
  // 初始化文件系统(需要先通过PlatformIO的“Upload Filesystem Image”上传文件)
  if (!SPIFFS.begin()) {
    Serial.println("SPIFFS初始化失败!");
    while(1);
  }

  // 配置I2S(同上例,省略)
  auto i2sConfig = i2s.defaultConfig();
  i2sConfig.pin_data = 25;
  // ... 其他配置
  i2s.begin(i2sConfig);

  // 配置音频流
  file.begin("/test.wav"); // 打开文件
  decoder.begin(); // 开始解码

  Serial.println("开始播放WAV文件...");
}

void loop() {
  if (copier.copy()) {
    // 正常播放中
  } else {
    Serial.println("播放结束或出错");
    delay(2000);
    // 可在此循环播放或播放下一首
  }
}

🔄 与之前的ST7789屏幕项目结合

你可以轻松地将音频控制和屏幕显示结合在一个ESP32项目中,因为两者使用不同的硬件接口(I2S vs. SPI)且库不冲突。例如,在屏幕上显示当前播放的歌曲名、音量或音频频谱。只需将两部分的setup()loop()逻辑合理整合即可。

接入 SD卡

基于我们之前的讨论,以下是使用 ESP32 通过 SD 卡读取并播放 WAV 音频文件 的完整实现方案。这涵盖了硬件连接、PlatformIO 配置和可运行的代码。

📁 完整项目实现指南

1. 硬件连接 (ESP32 + SD卡模块 + MAX98357)

请确保你的硬件按此连接。电源稳定是关键,建议使用外部5V电源为音频部分独立供电,并与ESP32共地。

ESP32 GPIO 引脚 SD 卡模块引脚 MAX98357 模块引脚
3.3V VCC VIN (可单独供电)
GND GND GND (必须共地)
GPIO 23 MOSI --
GPIO 19 MISO --
GPIO 18 SCK --
GPIO 5 (示例) CS (片选) --
GPIO 25 -- DIN
GPIO 26 -- BCLK
GPIO 27 -- LRC
2. PlatformIO 项目配置 (platformio.ini)

创建或修改项目根目录下的 platformio.ini 文件。

ini

[env:esp32-dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200

; 核心依赖库
lib_deps = 
    pschatzmann/ESP32-AudioTools @ ^1.0.8  # 主音频库
    pschatzmann/arduino-audio-tools @ ^1.1.9
    pschatzmann/arduino-audiocodecs @ ^1.0.6 # WAV解码器
    ; SD卡库已包含在框架中

; 优化构建
board_build.flash_mode = dio
build_flags = 
    -Wl,-Teagle.flash.4m32m.ld
3. 主程序代码 (src/main.cpp)

将以下代码复制到 src/main.cpp 中,并根据你的引脚定义进行修改。

cpp

/**
 * ESP32 SD卡 WAV音频播放器
 * 依赖:AudioTools库
 */

#include <Arduino.h>
#include "AudioTools.h"
#include "AudioLibs/AudioSourceSD.h" // SD卡音频源
#include "AudioCodecs/CodecWAV.h"    // WAV解码器
#include <SD.h>                      // SD卡驱动

// ==================== 用户配置区域 ====================
// 1. SD卡引脚配置(根据实际接线修改!)
#define SD_CS_PIN    5   // SD卡模块的片选引脚

// 2. I2S引脚配置(根据实际接线修改!)
#define I2S_DIN_PIN  25  // MAX98357的DIN
#define I2S_BCLK_PIN 26  // MAX98357的BCLK
#define I2S_LRC_PIN  27  // MAX98357的LRC

// 3. 音频文件设置
const char* audioFilePath = "/test.wav"; // 放在SD卡根目录的测试文件
// ====================================================

// 创建音频对象
I2SStream i2s;                 // I2S输出流
SDStream file(SD_CS_PIN);      // SD卡文件流,传入CS引脚号
WAVDecoder dec;                // WAV解码器
EncodedAudioStream decoder(&i2s, &dec); // 解码后管道连接到I2S
StreamCopy copier(decoder, file);       // 负责数据复制的“引擎”

void printAudioInfo(AudioInfo info) {
  Serial.println("=== 音频信息 ===");
  Serial.printf("采样率: %d Hz\n", info.sample_rate);
  Serial.printf("声道数: %d\n", info.channels);
  Serial.printf("位深度: %d-bit\n", info.bits_per_sample);
  Serial.println("================");
}

void setup() {
  Serial.begin(115200);
  while (!Serial); // 等待串口连接(仅用于调试)
  delay(500);
  Serial.println("\n\nESP32 SD卡音频播放器启动...");

  // === 第一步:初始化SD卡 ===
  Serial.print("初始化SD卡...");
  if (!SD.begin(SD_CS_PIN)) {
    Serial.println("失败!请检查:");
    Serial.println("  1. SD卡是否插入?");
    Serial.println("  2. 引脚连接是否正确?");
    Serial.println("  3. SD卡格式是否为FAT32?");
    while (true) { // 挂起
      delay(100);
    }
  }
  Serial.println("成功!");

  // 可选:列出根目录文件
  Serial.println("SD卡根目录内容:");
  File root = SD.open("/");
  while (File entry = root.openNextFile()) {
    Serial.print("  ");
    Serial.println(entry.name());
    entry.close();
  }
  root.close();

  // === 第二步:配置并初始化I2S ===
  Serial.print("配置I2S音频接口...");
  auto config = i2s.defaultConfig();
  config.pin_data = I2S_DIN_PIN;
  config.pin_bck = I2S_BCLK_PIN;
  config.pin_ws = I2S_LRC_PIN;
  config.sample_rate = 44100; // 初始采样率,解码后会根据文件自动调整
  config.bits_per_sample = 16;
  config.channels = 2;
  config.i2s_format = I2S_STD_FORMAT;
  // config.buffer_size = 1024; // 若出现爆音可尝试增大
  // config.buffer_count = 8;

  if (!i2s.begin(config)) {
    Serial.println("I2S初始化失败!请检查引脚。");
    while (true);
  }
  Serial.println("成功!");

  // === 第三步:尝试打开并解码音频文件 ===
  Serial.printf("尝试打开文件: %s\n", audioFilePath);
  
  if (!file.begin(audioFilePath)) {
    Serial.println("文件打开失败!请检查路径和文件名。");
    while (true);
  }

  // 设置解码器输出信息回调(可选)
  decoder.setInfoCallback(printAudioInfo);
  
  Serial.println("开始解码并播放...");
  decoder.begin();

  // 可选:设置音量(0.0静音 ~ 1.0最大)
  // i2s.setVolume(0.7);
}

void loop() {
  // 核心:将文件数据复制到解码器,再送至I2S
  if (copier.copy()) {
    // 数据正在稳定传输中,可以在此添加播放状态指示(如点亮LED)
  } else {
    // 播放结束或发生错误
    Serial.println("播放结束。");
    
    // 简单示例:等待3秒后重新播放
    delay(3000);
    Serial.println("重新播放...");
    
    // 重置文件流到开始位置
    file.begin(audioFilePath);
    decoder.begin();
  }
  
  // 可以在此处加入其他控制逻辑,如按键检测切换歌曲
}

🚀 如何运行

  1. 准备SD卡:格式化为FAT32,将转换好的16位、单声道/立体声、44100Hz的WAV文件(如 test.wav)复制到根目录
  2. 连接硬件:按上述表格连接好所有线路,仔细检查电源和地线
  3. 上传代码:在VS Code中,点击PlatformIO底部的 ✔️(编译) ,然后点击 ➡️(上传)  按钮。
  4. 查看日志:打开串口监视器(波特率115200),查看初始化状态和播放信息。

接入 INMP 441 麦克风

将 INMP441 数字麦克风模块连接到 ESP32 是进行高质量音频采集的经典方案。INMP441 是一款通过 I2S 接口 输出的底部进音 MEMS 麦克风,与 ESP32 的 I2S 外设完美兼容。

🔌 硬件连接

INMP441 需要标准的 I2S 连接,但与之前的 MAX98357(从设备)不同,INMP441 工作在“主模式” ,这意味着它负责生成主时钟(BCLK)和左右时钟(LRCLK)。因此,连接方式有特定要求。

请按以下表格连接(这是最常见的接法):

ESP32 GPIO 引脚 INMP441 模块引脚 信号说明
3.3V VDD 电源 (必须为 3.3V,5V会损坏麦克风)
GND GND 接地
GPIO 32 (或其它) SD 串行数据输出 (数据线,从麦克风到ESP32)
GPIO 14 WS 字选择 (左右声道时钟)由麦克风输出给ESP32
GPIO 15 SCK 串行时钟 (位时钟)由麦克风输出给ESP32
(可选) GPIO 13 L/R 声道选择。接GND为左声道,接VDD为右声道。通常悬空或接地即可。

关键说明

  1. 时钟方向:INMP441 是“主设备”,SCK 和 WS 是它的输出,必须连接到 ESP32 的对应 I2S 输入引脚。ESP32 在此配置中作为“从设备”接收时钟和数据。
  2. 引脚灵活性:上表中的 GPIO 32、14、15 是 ESP32 的默认 I2S 从设备接收引脚,推荐使用。理论上其他支持 I2S 的引脚也可用,但需要修改代码的引脚映射。
  3. 电源:务必使用 3.3V 供电。

📦 PlatformIO 配置 (platformio.ini)

继续使用强大的 AudioTools 库,它同样简化了录音流程。

ini

[env:esp32-dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200

lib_deps = 
    pschatzmann/ESP32-AudioTools @ ^1.0.8
    pschatzmann/arduino-audio-tools @ ^1.1.9

💻 基础录音与串口绘图程序 (src/main.cpp)

以下代码将初始化 INMP441,连续采集音频数据,并将原始数据通过串口发送。你可以用 Arduino IDE 或 PlatformIO 的串口绘图器查看波形。

cpp

/**
 * ESP32 + INMP441 基础录音测试
 * 将音频原始数据通过串口输出,可用于绘图器查看波形
 */

#include <Arduino.h>
#include "AudioTools.h"

// ==================== 配置 ====================
// 定义I2S引脚 (根据你的实际连接调整!)
#define I2S_SD_IN   32  // INMP441的SD -> ESP32的GPIO 32
#define I2S_WS_IN   14  // INMP441的WS -> ESP32的GPIO 14
#define I2S_SCK_IN  15  // INMP441的SCK -> ESP32的GPIO 15

// 定义音频参数
const int sampleRate = 44100; // 采样率 (Hz)
const int bufferSize = 1024;   // 缓冲区大小

// 创建I2S流对象用于输入(录音)
I2SStream i2sInput;
int16_t buffer[bufferSize]; // 用于存储音频样本的缓冲区(16位有符号整数)

void printAudioInfo(AudioInfo info) {
  Serial.println("=== I2S麦克风初始化成功 ===");
  Serial.printf("采样率: %d Hz\n", info.sample_rate);
  Serial.printf("声道数: %d\n", info.channels);
  Serial.printf("位深度: %d-bit\n", info.bits_per_sample);
  Serial.println("开始采集音频...");
  Serial.println("打开串口绘图器(Tools -> Serial Plotter)查看波形。");
}

void setup() {
  Serial.begin(115200);
  while (!Serial);
  delay(500);

  Serial.println("启动 INMP441 麦克风测试...");

  // 配置I2S输入参数
  auto i2sConfig = i2sInput.defaultConfig(RX_MODE);
  i2sConfig.pin_data = I2S_SD_IN;
  i2sConfig.pin_ws = I2S_WS_IN;
  i2sConfig.pin_bck = I2S_SCK_IN;
  i2sConfig.sample_rate = sampleRate;
  i2sConfig.bits_per_sample = 16;
  i2sConfig.channels = 1;          // INMP441 单声道
  i2sConfig.i2s_format = I2S_STD_FORMAT;
  i2sConfig.is_master = false;     // ESP32 作为从设备,使用麦克风提供的时钟!
  i2sConfig.port_no = 0;           // 使用I2S端口0

  // 开始I2S输入
  if (!i2sInput.begin(i2sConfig)) {
    Serial.println("错误:I2S麦克风初始化失败!请检查接线和引脚定义。");
    while (1); // 停止
  }
  printAudioInfo(i2sInput.audioInfo());
}

void loop() {
  // 从I2S流读取一帧音频数据到缓冲区
  size_t numSamplesRead = i2sInput.readBytes((uint8_t*)buffer, sizeof(buffer));

  // 将读取到的每个16位样本通过串口发送,用于绘图
  // 注意:对于串口绘图器,一次只需发送一个值(单声道)
  for (int i = 0; i < numSamplesRead / sizeof(int16_t); i++) {
    Serial.println(buffer[i]); // 将样本值直接打印
  }
  // 注意:高采样率下,Serial.print可能成为瓶颈,此代码仅用于演示。
  // 实际应用时应处理或存储数据,而非全部打印。
}

🔬 如何测试与验证

  1. 上传代码:确保接线正确后,编译并上传代码到 ESP32。
  2. 打开串口监视器:波特率设为115200,查看初始化信息。
  3. 打开串口绘图器:在 PlatformIO 或 Arduino IDE 中,找到 Serial Plotter 功能并打开。你应该能看到随环境声音变化的实时波形图。对着麦克风说话或制造声音,观察波形变化。

📝 进阶应用:将录音保存到 SD 卡(WAV格式)

录制音频并保存是常见需求。以下是一个简化的框架,展示如何将 AudioTools 库的录音数据通过 WAVEncoder 保存为 WAV 文件到 SD 卡。

前提:你已经按之前指南接好 SD 卡模块并安装了所需库。

cpp

// 注意:此为高级示例框架,可能需要调整才能完全运行
#include <Arduino.h>
#include "AudioTools.h"
#include "AudioLibs/AudioSourceSD.h" // 用于SD卡写入
#include "AudioCodecs/CodecWAV.h"
#include <SD.h>

// ... 引脚定义和I2S输入配置与上文相同 ...

SDStream sd_out(5); // SD卡CS引脚为5
WAVEncoder wav_enc;
EncodedAudioStream encoder(&sd_out, &wav_enc); // 将WAV编码流指向SD卡
StreamCopy copier(encoder, i2sInput); // 将I2S输入复制到编码器

void setup() {
    // ... 初始化串口、SD卡、I2S输入 ...
    // 初始化SD卡输出流
    if (!SD.begin(5)) { /* 错误处理 */ }
    sd_out.begin("/recording.wav", FILE_WRITE); // 打开文件
    encoder.begin(i2sInput.audioInfo()); // 以输入音频参数开始编码
    wav_enc.begin(encoder); // 开始WAV编码
    Serial.println("开始录音到SD卡...");
}

void loop() {
    copier.copy(); // 持续录音
    // 可以通过按钮或其他条件触发停止录音:encoder.end(); sd_out.end();
}

⚠️ 常见问题

问题 排查要点
没有数据/全是噪声 1. 时钟模式:确认 i2sConfig.is_master = false。 2. 引脚:最可能!反复检查 SCK, WS, SD 三条数据线是否接对。 3. 电源:确保是 3.3V,且 GND 已共地。
声音波形很小 INMP441 灵敏度较高。尝试增大声源音量或调整代码中的增益(可对 buffer[i] 乘以一个系数后再发送)。
编译错误 确保 platformio.ini 中的 lib_deps 已正确添加 AudioTools 库。
程序运行不稳定 降低采样率(如改为 16000 Hz)或增加 bufferSize

成功连接并采集到音频数据后,你可以将其用于语音唤醒、环境声分析、实时传输或与之前的播放功能结合实现回音消除等。如果你在具体实现中遇到问题,可以提供串口输出的错误信息,以便进一步诊断。

如何实现划词效果

作者 june18
2025年12月26日 18:41

最近做了一个划词的需求,想和大家分享一下。划词在富文本编辑器里比较常用,效果图如下:

划词效果

本篇文章将一步一步带大家实现这个效果。

实现字数统计

前置知识

window.getSelection

window.getSelection 可获取选中文本信息和选择内容,示例代码如下:

document.addEventListener("mouseup", function () {
  const selection = window.getSelection();
  if (selection) {
    console.log('selection', selection)
    const selectedText = selection.toString();
    console.log(selectedText);
  }
});

执行结果截图:

window.getSelection

实现输入框

主组件 TextCustom,包括两个组件:输入框和浮动工具栏。 这里的输入框是通过 div 元素模拟的,实现起来也很简单,只需要为 div 添加 contentEditable 属性 。

const TextCustom = () => {
  return (
    <div>
      {/* 模拟输入框 */}
      <div
        id="editor" // 后面会用
        contentEditable={true}
        suppressContentEditableWarning={true} // suppressContentEditableWarning  React 框架中的一个特殊属性用于抑制contentEditable 元素触发的常见警告
      />
    </div>
  );
};

实现浮动工具栏组件

浮动工具栏组件实现

浮动工具栏组件是通过固定定位实现的,工具栏的位置是跟随选中文本的位置改变而改变的,主要属性是 topleft,默认值都先设置成 0。

浮动工具栏组件的内容是选中文本的字数统计,先硬编码成“x 字”。

const FloatingToolbar = () => {
  return (
    <div
      style={{
        position: "fixed",
        top: 0, // 在选中文本上方显示
        left: 0, // 居中显示
        transform: "translateX(-10%)", // 微调水平位置
        background: "#fff",
        border: "1px solid red",
        zIndex: 10000,
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div>
        📝 x 字
      </div>
    </div>
  );
};

引入到主组件

const TextCustom = () => {
  return (
    <div>
      {/* 模拟输入框 */}
      <div
        id="editor" // 后面会用
        contentEditable={true}
        suppressContentEditableWarning={true} // suppressContentEditableWarning  React 框架中的一个特殊属性用于抑制contentEditable 元素触发的常见警告
      />

      <FloatingToolbar />
    </div>
  );
};

实现自定义 hook

封装自定义 hook,可命名为 useTextSelection,入参是输入框 id,返回 selection 和 toolbarRef。 其中 selection 是用来获取文本选择信息,包括选中文本的位置和选中的文本内容;toolbarRef 是工具栏的引用,后面的鼠标事件会用到,大概结构如下。

const useTextSelection = (target) => {
  // 存储文本选择信息
  const [selection, setSelection] = useState(null);
  
  // 标记用户是否正在与工具栏交互,比如点击工具栏
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     * 当用户在页面上选择文本时触发
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect(); // 获取选中文本的位置和尺寸

        // 获取选中文本的锚点节点(选择的起始位置)
        const anchorNode = selection.anchorNode;
        // 获取目标编辑器元素
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,        // 选中文本的位置信息
            selectedText: selectedText, // 选中的文本内容
          });
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     * 用于检测用户是否点击了工具栏区域
     * @param {MouseEvent} e - 鼠标事件对象
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true; // 用户正在与工具栏交互
      } else {
        isInToolbarRef.current = false; // 用户点击了工具栏外部
      }
    };

    /**
     * 处理鼠标抬起事件
     * 用户完成选择操作后,延迟检查选择内容
     */
    const handleMouseUp = () => {
      // 延迟 100ms 执行,确保浏览器已完成选择操作
      setTimeout(() => {
        // 如果用户没有与工具栏交互,处理选择变化
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     * 当用户点击工具栏和编辑器外部时,隐藏工具栏
     * @param {MouseEvent} e - 鼠标事件对象
     */
    const handleClickOutside = (e) => {
      // 检查点击是否发生在工具栏外部
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        // 检查点击是否也发生在编辑器外部
        if (targetElement && !targetElement.contains(e.target)) {
          // 重置选择信息,隐藏工具栏
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange); // 监听文本选择变化
    document.addEventListener("mousedown", handleMouseDown); // 监听鼠标按下
    document.addEventListener("mouseup", handleMouseUp); // 监听鼠标抬起
    document.addEventListener("mousedown", handleClickOutside); // 监听外部点击

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target]); // 依赖项:当 target 变化时重新运行 effect

  return {
    selection,    // 当前的选择信息
    toolbarRef,   // 工具栏的 ref 引用
  };
}

主组件修改如下:

const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息和工具栏引用
  const { selection, toolbarRef } = useTextSelection("#editor");

  return (
    <div>
      <div
        id="editor"
        contentEditable={true}
        className={styles.editor}
        suppressContentEditableWarning={true}
      />

      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
      />
    </div>
  );
};

浮动工具栏修改如下:

const FloatingToolbar = ({
  selection,
  toolbarRef,
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect) return null;

  return (
    <div
      ref={toolbarRef}
      style={{
        position: "fixed",
        top: clientRect.top - 65, // 在选中文本上方显示65 不是固定的可以自行调整
        left: clientRect.left + clientRect.width / 2, // 居中显示
        transform: "translateX(-10%)", // 微调水平位置
        background: "#fff",
        border: "1px solid red",
        zIndex: 10000,
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div>
        📝 {selectedText.length} 字
      </div>
    </div>
  );
};

实现加粗

前置知识

document.execCommand

document.execCommand 可对选中文本进行操作,比如加粗、斜体、下划线等,举个简单的例子。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>文本编辑器示例</title>
  <style>
   .editor {
      border: 1px solid #ccc;
      padding: 10px;
      min-height: 200px;
    }
  </style>
</head>
<body>
  <div contentEditable="true" class="editor">
    请在这里输入文本,然后选中一部分文本并点击相应的按钮。
  </div>
  <button onclick="handleBold()">加粗</button>
  <button onclick="handleItalic()">斜体</button>
  <button onclick="handleUnderline()">下划线</button>

  <script>
    function handleBold() {
      document.execCommand("bold", false, null);
    }

    function handleItalic() {
      document.execCommand("italic", false, null);
    }

    function handleUnderline() {
      document.execCommand("underline", false, null);
    }
  </script>
</body>
</html>

文本编辑器示例

document.queryCommandState("bold")

document.queryCommandState("bold") 可获取加粗状态,返回布尔值。当选择的文本,只有部分加粗时也会返回 true。

修改主组件

从自定义 Hook 中获取 isBoldActivehandleBoldClick,并传给浮动工具栏组件。

const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    handleBoldClick 
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        isBoldActive={isBoldActive}
      />
    </div>
  );
};

修改浮动工具栏

const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  isBoldActive = false,
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>
    </div>
  );
};

修改自定义 Hook

const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前加粗状态
          setIsBoldActive(getBoldState());  // 回显加粗使用
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    setIsBoldActive(getBoldState());
  }, [getBoldState]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

实现斜体

加粗理解了,斜体就简单了,流程是一模一样的。

修改主组件

从自定义 Hook 中获取 isItalicActivehandleItalicClick,并传给浮动工具栏组件。

const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    isItalicActive,
    handleBoldClick,
    handleItalicClick
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        onItalicClick={handleItalicClick}
        isBoldActive={isBoldActive}
        isItalicActive={isItalicActive}
      />
    </div>
  );
};

修改浮动工具栏

增加斜体按钮。

const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  onItalicClick,
  isBoldActive = false,
  isItalicActive = false,
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>

      {/* 斜体按钮 */}
      <button
        onClick={() => onItalicClick && onItalicClick(range)}
        title="斜体"
        style={{
          background: isItalicActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          fontStyle: "italic",
        }}
      >
        I
      </button>
    </div>
  );
};

修改自定义 Hook

主要有两处改动:

  1. 封装了 updateFormattingStates 函数用于更新所有格式化状态,包括加粗和斜体。
  2. 增加处理斜体操作。
const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  const [isItalicActive, setIsItalicActive] = useState(false);
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  // 获取斜体状态
  const getItalicState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("italic");
  }, []);

  // 更新所有格式化状态
  const updateFormattingStates = useCallback(() => {
    setIsBoldActive(getBoldState());
    setIsItalicActive(getItalicState());
  }, [getBoldState, getItalicState]);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前格式化状态
          updateFormattingStates();
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理斜体操作
  const handleItalicClick = useCallback((range) => {
    if (!range) return;

    // 执行斜体命令
    document.execCommand("italic", false, null);

    // 恢复选择范围并更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

实现下划线

修改主组件

从自定义 Hook 中获取 isUnderlineActivehandleUnderlineClick,并传给浮动工具栏组件。

const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    isItalicActive,
    isUnderlineActive,
    handleBoldClick,
    handleItalicClick,
    handleUnderlineClick,
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        onItalicClick={handleItalicClick}
        onUnderlineClick={handleUnderlineClick}
        isBoldActive={isBoldActive}
        isItalicActive={isItalicActive}
        isUnderlineActive={isUnderlineActive}
      />
    </div>
  );
};

修改浮动工具栏

增加下划线按钮。

const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  onItalicClick,
  onUnderlineClick,
  isBoldActive = false,
  isItalicActive = false,
  isUnderlineActive = false,
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>

      {/* 斜体按钮 */}
      <button
        onClick={() => onItalicClick && onItalicClick(range)}
        title="斜体"
        style={{
          background: isItalicActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          fontStyle: "italic",
        }}
      >
        I
      </button>

      {/* 下划线按钮 */}
      <button
        onClick={() => onUnderlineClick && onUnderlineClick(range)}
        title="下划线"
        style={{
          background: isUnderlineActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          textDecoration: "underline",
        }}
      >
        U
      </button>
    </div>
  );
};

修改自定义 Hook

主要有两处改动:

  1. 增加获取下划线状态
  2. 增加处理下划线操作
const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  const [isItalicActive, setIsItalicActive] = useState(false);
  const [isUnderlineActive, setIsUnderlineActive] = useState(false);
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  // 获取斜体状态
  const getItalicState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("italic");
  }, []);

  // 获取下划线状态
  const getUnderlineState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("underline");
  }, []);

  // 更新所有格式化状态
  const updateFormattingStates = useCallback(() => {
    setIsBoldActive(getBoldState());
    setIsItalicActive(getItalicState());
    setIsUnderlineActive(getUnderlineState());
  }, [getBoldState, getItalicState, getUnderlineState]);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前格式化状态
          updateFormattingStates();
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理斜体操作
  const handleItalicClick = useCallback((range) => {
    if (!range) return;

    // 执行斜体命令
    document.execCommand("italic", false, null);

    // 恢复选择范围并更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理下划线操作
  const handleUnderlineClick = useCallback((range) => {
    if (!range) return;

    // 执行下划线命令
    document.execCommand("underline", false, null);

    // 更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

实现字号

修改主组件

从自定义 Hook 中获取 fontSizehandleFontSizeChange,并传给浮动工具栏组件。

const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    isItalicActive,
    isUnderlineActive,
    fontSize,
    handleBoldClick,
    handleItalicClick,
    handleUnderlineClick,
    handleFontSizeChange,
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        onItalicClick={handleItalicClick}
        onUnderlineClick={handleUnderlineClick}
        onFontSizeChange={handleFontSizeChange}
        isBoldActive={isBoldActive}
        isItalicActive={isItalicActive}
        isUnderlineActive={isUnderlineActive}
        fontSize={fontSize}
      />
    </div>
  );
};

修改浮动工具栏

主要增加字号选择器。

const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  onItalicClick,
  onUnderlineClick,
  onFontSizeChange,
  isBoldActive = false,
  isItalicActive = false,
  isUnderlineActive = false,
  fontSize = "3",
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>

      {/* 斜体按钮 */}
      <button
        onClick={() => onItalicClick && onItalicClick(range)}
        title="斜体"
        style={{
          background: isItalicActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          fontStyle: "italic",
        }}
      >
        I
      </button>

      {/* 下划线按钮 */}
      <button
        onClick={() => onUnderlineClick && onUnderlineClick(range)}
        title="下划线"
        style={{
          background: isUnderlineActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          textDecoration: "underline",
        }}
      >
        U
      </button>

      {/* 字号选择器 */}
      <select
        value={fontSize}
        onChange={(e) => onFontSizeChange && onFontSizeChange(e.target.value, range)}
        onMouseDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
        style={{
          background: "#999",
          color: "white",
          border: "1px solid #999",
          borderRadius: "4px",
          padding: "6px 10px",
          cursor: "pointer",
          fontSize: "12px",
          width: "90px",
          appearance: "auto",
        }}
      >
        <option value="1">12px</option>
        <option value="2">14px</option>
        <option value="3">16px</option>
        <option value="4">18px</option>
        <option value="5">24px</option>
        <option value="6">32px</option>
        <option value="7">48px</option>
      </select>

      {/* 颜色选择器 */}
      <input
        type="color"
        value={color}
        onChange={(e) => onColorChange && onColorChange(e.target.value, range)}
        title="文字颜色"
        style={{
          width: "36px",
          height: "36px",
          border: "2px solid #4a627a",
          borderRadius: "6px",
          cursor: "pointer",
          padding: "0",
          backgroundColor: color || "#ffffff",
        }}
        onMouseDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
      />
    </div>
  );
};

修改自定义 Hook

字号和后面的颜色都是通过增加嵌套 span 标签实现的。

const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  const [isItalicActive, setIsItalicActive] = useState(false);
  const [isUnderlineActive, setIsUnderlineActive] = useState(false);
  const [fontSize, setFontSize] = useState("3");
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  // 获取斜体状态
  const getItalicState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("italic");
  }, []);

  // 获取下划线状态
  const getUnderlineState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("underline");
  }, []);

  // 获取当前选择的字号
  const getFontSizeState = useCallback(() => {
    const selection = window.getSelection();
    if (!selection.rangeCount) return "3";

    const range = selection.getRangeAt(0);

    let element = range.startContainer;
    if (element.nodeType !== Node.ELEMENT_NODE) {
      element = element.parentElement;
    }

    // 遍历父元素查找字体大小
    while (element && element !== document.querySelector(target)) {
      if (element.nodeType === Node.ELEMENT_NODE) {
        const computedStyle = window.getComputedStyle(element);
        const computedFontSize = computedStyle.fontSize;

        if (computedFontSize) {
          const sizeInPx = parseInt(computedFontSize);
          if (sizeInPx >= 42) return "7";
          else if (sizeInPx >= 28) return "6";
          else if (sizeInPx >= 22) return "5";
          else if (sizeInPx >= 17) return "4";
          else if (sizeInPx >= 15) return "3";
          else if (sizeInPx >= 13) return "2";
          else return "1";
        }
      }
      element = element.parentElement;
    }

    return "3";
  }, [target]);

  // 更新所有格式化状态
  const updateFormattingStates = useCallback(() => {
    setIsBoldActive(getBoldState());
    setIsItalicActive(getItalicState());
    setIsUnderlineActive(getUnderlineState());
    setFontSize(getFontSizeState());
  }, [getBoldState, getItalicState, getUnderlineState, getFontSizeState]);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前格式化状态
          updateFormattingStates();
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 设置字体大小
  const setFontSizeCommand = useCallback((sizeValue, range) => {
    if (!range) return;

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);

    const newRange = selection.getRangeAt(0);
    const span = document.createElement("span");

    const sizeMap = {
      "1": "12px",
      "2": "14px",
      "3": "16px",
      "4": "18px",
      "5": "24px",
      "6": "32px",
      "7": "48px",
    };

    span.style.fontSize = sizeMap[sizeValue] || "16px";

    try {
      // 检查选择范围是否已折叠(即没有选中文本,只有一个光标位置)
      // 如果是折叠状态,不执行字体大小设置操作
      if (newRange.collapsed) return;

      const clonedRange = newRange.cloneRange();
      const fragment = clonedRange.extractContents();
      span.appendChild(fragment);
      clonedRange.insertNode(span);

      // 清除当前选择,然后重新选中刚刚插入的span元素内容
      // 这样用户可以继续对同一段文本进行其他操作
      selection.removeAllRanges();
      const newSelectionRange = document.createRange();
      newSelectionRange.selectNodeContents(span);
      selection.addRange(newSelectionRange);
    } catch (error) {
      console.error("设置字体大小失败:", error);
    }

    // 更新状态
    setFontSize(sizeValue);
  }, []);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理斜体操作
  const handleItalicClick = useCallback((range) => {
    if (!range) return;

    // 执行斜体命令
    document.execCommand("italic", false, null);

    // 恢复选择范围并更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理下划线操作
  const handleUnderlineClick = useCallback((range) => {
    if (!range) return;

    // 执行下划线命令
    document.execCommand("underline", false, null);

    // 更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理字号变化
  const handleFontSizeChange = useCallback((sizeValue, range) => {
    if (!range) return;

    setFontSizeCommand(sizeValue, range);
    updateFormattingStates();
  }, [setFontSizeCommand, updateFormattingStates]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

实现颜色

修改主组件

从自定义 Hook 中获取 colorhandleColorChange,并传给浮动工具栏组件。

const TextCustom = () => {
  // 使用自定义 Hook 获取文本选择信息
  const { 
    selection, 
    toolbarRef, 
    isBoldActive, 
    isItalicActive,
    isUnderlineActive,
    fontSize,
    handleBoldClick,
    handleItalicClick,
    handleUnderlineClick,
    handleFontSizeChange,
  } = useTextSelection("#editor");

  return (
    <div>
      {/* 可编辑的文本区域 */}
      <div
        id="editor"
        contentEditable={true}
        suppressContentEditableWarning={true}
      />

      {/* 浮动工具栏组件 */}
      <FloatingToolbar
        selection={selection}
        toolbarRef={toolbarRef}
        onBoldClick={handleBoldClick}
        onItalicClick={handleItalicClick}
        onUnderlineClick={handleUnderlineClick}
        onFontSizeChange={handleFontSizeChange}
        onColorChange={handleColorChange}
        isBoldActive={isBoldActive}
        isItalicActive={isItalicActive}
        isUnderlineActive={isUnderlineActive}
        fontSize={fontSize}
        color={color}
      />
    </div>
  );
};

修改浮动工具栏

const FloatingToolbar = ({
  selection,
  toolbarRef,
  onBoldClick,
  onItalicClick,
  onUnderlineClick,
  onFontSizeChange,
  onColorChange,
  isBoldActive = false,
  isItalicActive = false,
  isUnderlineActive = false,
  fontSize = "3",
  color = "#000000",
}) => {
  // 如果没有选择信息,不渲染工具栏
  if (!selection) return null;

  const { clientRect, selectedText, range } = selection;
  
  // 如果没有选中文本或位置信息,不渲染工具栏
  if (!selectedText || !clientRect || !range) return null;

  return (
    <div
      ref={toolbarRef}
      className="floating-toolbar"
      style={{
        position: "fixed",
        top: clientRect.top - 65,
        left: clientRect.left + clientRect.width / 2,
        transform: "translateX(-10%)",
        background: "#fff",
        zIndex: 10000,
        minWidth: "180px",
      }}
    >
      {/* 显示选中文本的字符数量 */}
      <div style={{ fontSize: "12px", fontWeight: "bold" }}>
        📝 {selectedText.length} 字
      </div>

      {/* 加粗按钮 */}
      <button
        onClick={() => onBoldClick && onBoldClick(range)}
        title="加粗"
        style={{
          background: isBoldActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
        }}
      >
        <strong>B</strong>
      </button>

      {/* 斜体按钮 */}
      <button
        onClick={() => onItalicClick && onItalicClick(range)}
        title="斜体"
        style={{
          background: isItalicActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          fontStyle: "italic",
        }}
      >
        I
      </button>

      {/* 下划线按钮 */}
      <button
        onClick={() => onUnderlineClick && onUnderlineClick(range)}
        title="下划线"
        style={{
          background: isUnderlineActive ? "#3498db" : "#999",
          border: "none",
          color: "white",
          width: "36px",
          height: "36px",
          borderRadius: "6px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: "14px",
          textDecoration: "underline",
        }}
      >
        U
      </button>

      {/* 字号选择器 */}
      <select
        value={fontSize}
        onChange={(e) => onFontSizeChange && onFontSizeChange(e.target.value, range)}
        onMouseDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
        style={{
          background: "#999",
          color: "white",
          border: "1px solid #999",
          borderRadius: "4px",
          padding: "6px 10px",
          cursor: "pointer",
          fontSize: "12px",
          width: "90px",
          appearance: "auto",
        }}
      >
        <option value="1">12px</option>
        <option value="2">14px</option>
        <option value="3">16px</option>
        <option value="4">18px</option>
        <option value="5">24px</option>
        <option value="6">32px</option>
        <option value="7">48px</option>
      </select>

      {/* 颜色选择器 */}
      <input
        type="color"
        value={color}
        onChange={(e) => onColorChange && onColorChange(e.target.value, range)}
        title="文字颜色"
        style={{
          width: "36px",
          height: "36px",
          border: "2px solid #4a627a",
          borderRadius: "6px",
          cursor: "pointer",
          padding: "0",
          backgroundColor: color || "#ffffff",
        }}
        onMouseDown={(e) => e.stopPropagation()}
        onClick={(e) => e.stopPropagation()}
      />
    </div>
  );
};

修改自定义 Hook

在看具体实现前,先认识一个 API,document.createTreeWalke 是一个用于 深度优先遍历 DOM 树 的接口。

基本语法

const walker = document.createTreeWalker(
  root,          // 遍历的起始节点
  whatToShow,    // 要显示哪些类型的节点
  filter         // 可选的过滤器函数
);

简单示例

<div id="container">
  <h1>标题</h1>
  <p>段落 <span>文本</span></p>
  <ul>
    <li>项目1</li>
    <li>项目2</li>
  </ul>
</div>

<script>
const container = document.getElementById('container');

// 创建 TreeWalker: 从 container 开始,只遍历元素节点
const walker = document.createTreeWalker(
  container,
  NodeFilter.SHOW_ELEMENT,  // 只显示元素节点
  null  // 不过滤
);

const nodes = [];
let node;
while (node = walker.nextNode()) {
  nodes.push(node.tagName);
}

console.log(nodes); // 输出: ["H1", "P", "SPAN", "UL", "LI", "LI"] 深度优先遍历顺序
</script>
const useTextSelection = (target) => {
  const [selection, setSelection] = useState(null);
  const [isBoldActive, setIsBoldActive] = useState(false);
  const [isItalicActive, setIsItalicActive] = useState(false);
  const [isUnderlineActive, setIsUnderlineActive] = useState(false);
  const [fontSize, setFontSize] = useState("3");
  const [color, setColor] = useState("#000000");
  
  // 标记用户是否正在与工具栏交互
  const isInToolbarRef = useRef(false);
  
  // 工具栏 DOM 元素的引用
  const toolbarRef = useRef(null);

  // RGB 转十六进制辅助函数
  const rgbToHex = useCallback((rgb) => {
    // 如果已经是十六进制格式,直接返回
    if (rgb.startsWith("#")) return rgb;

    // 匹配 RGB 或 RGBA 格式:rgb(255, 255, 255) 或 rgba(255, 255, 255, 0.5)
    const match = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
    if (match) {
      const r = parseInt(match[1]);
      const g = parseInt(match[2]);
      const b = parseInt(match[3]);

      // 将 RGB 值转换为十六进制,并确保两位显示
      return (
        "#" +
        r.toString(16).padStart(2, "0") +
        g.toString(16).padStart(2, "0") +
        b.toString(16).padStart(2, "0")
      );
    }

    // 无法解析的颜色,返回默认黑色
    return "#000000";
  }, []);

  // 获取加粗状态
  const getBoldState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("bold");
  }, []);

  // 获取斜体状态
  const getItalicState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("italic");
  }, []);

  // 获取下划线状态
  const getUnderlineState = useCallback(() => {
    if (!document.queryCommandState) return false;
    return document.queryCommandState("underline");
  }, []);

  // 获取当前选择的字号
  const getFontSizeState = useCallback(() => {
    const selection = window.getSelection();
    if (!selection.rangeCount) return "3";

    const range = selection.getRangeAt(0);

    let element = range.startContainer;
    if (element.nodeType !== Node.ELEMENT_NODE) {
      element = element.parentElement;
    }

    // 遍历父元素查找字体大小
    while (element && element !== document.querySelector(target)) {
      if (element.nodeType === Node.ELEMENT_NODE) {
        const computedStyle = window.getComputedStyle(element);
        const computedFontSize = computedStyle.fontSize;

        if (computedFontSize) {
          const sizeInPx = parseInt(computedFontSize);
          if (sizeInPx >= 42) return "7";
          else if (sizeInPx >= 28) return "6";
          else if (sizeInPx >= 22) return "5";
          else if (sizeInPx >= 17) return "4";
          else if (sizeInPx >= 15) return "3";
          else if (sizeInPx >= 13) return "2";
          else return "1";
        }
      }
      element = element.parentElement;
    }

    return "3";
  }, [target]);

  // 获取当前选择的颜色
  const getColorState = useCallback(() => {
    const selection = window.getSelection();
    if (!selection.rangeCount) return "#000000";

    const range = selection.getRangeAt(0);
    const commonAncestor = range.commonAncestorContainer;

    let element = range.startContainer;
    if (element.nodeType !== Node.ELEMENT_NODE) {
      element = element.parentElement;
    }

    // 遍历父元素查找颜色
    while (element && element !== document.querySelector(target)) {
      if (element.nodeType === Node.ELEMENT_NODE) {
        const computedStyle = window.getComputedStyle(element);
        const computedColor = computedStyle.color;

        // 检查颜色是否为有效值(非透明、非默认黑色)
        if (
          computedColor &&
          computedColor !== "rgba(0, 0, 0, 0)" &&
          computedColor !== "transparent" &&
          !computedColor.startsWith("rgba(0, 0, 0, ")
        ) {
          return rgbToHex(computedColor);
        }
      }
      element = element.parentElement;
    }

    // 如果没有找到,检查选择范围内的元素
    const walker = document.createTreeWalker(
      commonAncestor,
      NodeFilter.SHOW_ELEMENT,
      {
        acceptNode: (node) =>
          range.intersectsNode(node)
            ? NodeFilter.FILTER_ACCEPT
            : NodeFilter.FILTER_REJECT,
      },
    );

    // 当向上遍历找不到颜色时,它会深度遍历选择范围内的所有元素,找到第一个有效的文本颜色。
    while ((element = walker.nextNode())) {
      const computedStyle = window.getComputedStyle(element);
      const computedColor = computedStyle.color;

      if (
        computedColor &&
        computedColor !== "rgba(0, 0, 0, 0)" &&
        computedColor !== "transparent" &&
        !computedColor.startsWith("rgba(0, 0, 0, ")
      ) {
        return rgbToHex(computedColor);
      }
    }

    return "#000000";
  }, [target, rgbToHex]);

  // 更新所有格式化状态
  const updateFormattingStates = useCallback(() => {
    setIsBoldActive(getBoldState());
    setIsItalicActive(getItalicState());
    setIsUnderlineActive(getUnderlineState());
    setFontSize(getFontSizeState());
    setColor(getColorState());
  }, [getBoldState, getItalicState, getUnderlineState, getFontSizeState, getColorState]);

  useEffect(() => {
    /**
     * 处理文本选择变化事件
     */
    const handleSelectionChange = () => {
      // 如果用户正在与工具栏交互,不处理选择变化
      if (isInToolbarRef.current) return;

      const selection = window.getSelection();
      const selectedText = selection.toString().trim();

      // 确保有选中文本并且选择范围有效
      if (selectedText && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        const anchorNode = selection.anchorNode;
        const targetElement = document.querySelector(target);
        
        // 检查选择是否发生在目标编辑器内
        const isInTarget =
          targetElement &&
          (targetElement.contains(anchorNode) || targetElement === anchorNode);

        if (isInTarget) {
          // 保存选择范围
          // 更新选择信息,显示工具栏
          setSelection({
            clientRect: rect,
            selectedText: selectedText,
            range: range,
          });

          // 获取当前格式化状态
          updateFormattingStates();
        } else {
          // 选择不在编辑器内,隐藏工具栏
          setSelection(null);
        }
      } else {
        // 没有选中文本,隐藏工具栏
        setSelection(null);
      }
    };

    /**
     * 处理鼠标按下事件
     */
    const handleMouseDown = (e) => {
      // 检查点击是否发生在工具栏区域内
      if (toolbarRef.current && toolbarRef.current.contains(e.target)) {
        isInToolbarRef.current = true;
      } else {
        isInToolbarRef.current = false;
      }
    };

    /**
     * 处理鼠标抬起事件
     */
    const handleMouseUp = () => {
      // 延迟执行,确保浏览器已完成选择操作
      setTimeout(() => {
        if (!isInToolbarRef.current) {
          handleSelectionChange();
        }
      }, 100);
    };

    /**
     * 处理点击外部事件
     */
    const handleClickOutside = (e) => {
      if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
        const targetElement = document.querySelector(target);
        if (targetElement && !targetElement.contains(e.target)) {
          setSelection(null);
        }
      }
    };

    // 添加事件监听器
    document.addEventListener("selectionchange", handleSelectionChange);
    document.addEventListener("mousedown", handleMouseDown);
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleClickOutside);

    // 清理函数:组件卸载时移除事件监听器
    return () => {
      document.removeEventListener("selectionchange", handleSelectionChange);
      document.removeEventListener("mousedown", handleMouseDown);
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [target, getBoldState]);

  // 设置字体大小
  const setFontSizeCommand = useCallback((sizeValue, range) => {
    if (!range) return;

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);

    const newRange = selection.getRangeAt(0);
    const span = document.createElement("span");

    const sizeMap = {
      "1": "12px",
      "2": "14px",
      "3": "16px",
      "4": "18px",
      "5": "24px",
      "6": "32px",
      "7": "48px",
    };

    span.style.fontSize = sizeMap[sizeValue] || "16px";

    try {
      // 检查选择范围是否已折叠(即没有选中文本,只有一个光标位置)
      // 如果是折叠状态,不执行字体大小设置操作
      if (newRange.collapsed) return;

      const clonedRange = newRange.cloneRange();
      const fragment = clonedRange.extractContents();
      span.appendChild(fragment);
      clonedRange.insertNode(span);

      // 清除当前选择,然后重新选中刚刚插入的span元素内容
      // 这样用户可以继续对同一段文本进行其他操作
      selection.removeAllRanges();
      const newSelectionRange = document.createRange();
      newSelectionRange.selectNodeContents(span);
      selection.addRange(newSelectionRange);
    } catch (error) {
      console.error("设置字体大小失败:", error);
    }

    // 更新状态
    setFontSize(sizeValue);
  }, []);

  // 设置文本颜色
  const setTextColor = useCallback((colorValue, range) => {
    if (!range) return;

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);

    const newRange = selection.getRangeAt(0);
    const span = document.createElement("span");

    span.style.color = colorValue;

    try {
      if (newRange.collapsed) return;

      const clonedRange = newRange.cloneRange();
      const fragment = clonedRange.extractContents();
      span.appendChild(fragment);
      clonedRange.insertNode(span);

      selection.removeAllRanges();
      const newSelectionRange = document.createRange();
      newSelectionRange.selectNodeContents(span);
      selection.addRange(newSelectionRange);
    } catch (error) {
      console.error("设置颜色失败:", error);
    }

    setColor(colorValue);
  }, []);

  // 处理加粗操作
  const handleBoldClick = useCallback((range) => {
    if (!range) return;

    // 执行加粗命令
    document.execCommand("bold", false, null);

    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理斜体操作
  const handleItalicClick = useCallback((range) => {
    if (!range) return;

    // 执行斜体命令
    document.execCommand("italic", false, null);

    // 恢复选择范围并更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理下划线操作
  const handleUnderlineClick = useCallback((range) => {
    if (!range) return;

    // 执行下划线命令
    document.execCommand("underline", false, null);

    // 更新状态
    updateFormattingStates();
  }, [updateFormattingStates]);

  // 处理字号变化
  const handleFontSizeChange = useCallback((sizeValue, range) => {
    if (!range) return;

    setFontSizeCommand(sizeValue, range);
    updateFormattingStates();
  }, [setFontSizeCommand, updateFormattingStates]);

  return {
    selection,
    toolbarRef,
    isBoldActive,
    handleBoldClick,
  };
};

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

作者 SmalBox
2025年12月27日 08:29

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

法线混合技术概述

在实时渲染中,法线贴图混合是增强表面细节表现的重要技术。Unity URP管线内置的NormalBlend节点通过数学运算实现两张法线贴图的平滑过渡,同时确保法线向量的物理正确性。该技术广泛应用于角色装备切换、地形材质融合、动态形变效果等场景,是现代游戏开发中不可或缺的材质处理工具。

节点核心功能解析

混合模式选择

NormalBlend节点提供两种混合算法:

  1. Default模式:采用分量混合策略,对法线贴图的RG通道进行加法混合,B通道进行乘法混合,最后通过标准化处理确保输出为单位向量。适用于简单表面细节的叠加,例如角色装备纹理的混合。
  2. Reoriented模式:通过重新定向算法维持法线方向一致性,采用齐次坐标系转换与向量投影计算,确保混合结果符合物理光照模型。适用于复杂表面处理,如布料模拟与动态形变效果。

端口与参数配置

  • 输入端口
    • A:接收第一张法线贴图数据(Vector3类型)
    • B:接收第二张法线贴图数据(Vector3类型)
  • 输出端口
    • Out:输出混合后的标准化法线向量(Vector3类型)
  • 控件参数
    • Mode:混合模式选择器(Default/Reoriented)

技术实现原理

法线混合数学基础

法线向量是表示表面朝向的数学实体,其核心属性包括:

  • 单位向量性质:长度必须保持为1
  • 插值特性:在片段着色器中由顶点法线插值获得
  • 空间转换:可通过矩阵运算在不同坐标系间转换

标准化处理流程

混合后的法线向量必须经过标准化处理,以确保:

  1. 光照计算的准确性
  2. 阴影生成的正确性
  3. 表面交互的真实性

坐标空间转换机制

NormalBlend节点自动处理切线空间到世界空间的转换:

  • 输入法线默认为切线空间坐标
  • 输出法线根据材质设置自动转换至目标空间
  • 支持对象空间、视图空间、世界空间和切线空间输出

典型应用场景与实现

角色装备法线混合

实现步骤

  1. 准备角色基础法线贴图(A)
  2. 准备装备法线贴图(B)
  3. 使用Default模式进行混合
  4. 通过材质参数控制混合强度

优化技巧

  • 使用纹理采样节点控制混合区域
  • 结合遮罩贴图实现非均匀混合
  • 在关键区域采用Reoriented模式维持方向一致性

地形法线混合

实现步骤

  1. 准备两种地形材质法线贴图(A和B)
  2. 创建混合遮罩纹理
  3. 根据遮罩值动态调整混合比例
  4. 使用Reoriented模式处理复杂过渡

优化技巧

  • 使用渐变纹理控制混合区域
  • 结合高度图实现物理正确的混合
  • 在斜坡区域增强混合强度

动态变形法线处理

实现步骤

  1. 准备基础法线贴图(A)
  2. 准备变形影响法线贴图(B)
  3. 根据变形参数动态调整混合强度
  4. 使用Reoriented模式保持方向一致性

优化技巧

  • 结合顶点动画参数控制混合
  • 使用噪声纹理丰富细节
  • 在形变剧烈区域增加混合强度

性能优化策略

模式选择优化

  • 优先使用Default模式:性能开销较小,适合简单混合
  • 复杂表面使用Reoriented模式:维持方向一致性
  • 混合强度控制:通过材质参数或遮罩贴图动态调整

计算资源优化

  • 限制混合区域:使用遮罩贴图约束混合范围
  • 简化混合模式:在非关键区域采用Default模式
  • 预计算混合:在材质编辑器中预先计算部分结果

平台兼容性优化

  • URP与HDRP差异:URP采用简化光照模型,HDRP支持物理精确材质
  • 版本兼容性:不同Unity版本对ShaderGraph节点的支持可能存在差异
  • 目标平台:移动端优先选用Default模式以降低计算量

常见问题解决方案

混合后出现伪影

原因

  • 混合区域边界处理不当
  • 法线方向不一致
  • 混合强度过高

解决方案

  • 使用遮罩贴图平滑过渡
  • 在关键区域切换至Reoriented模式
  • 降低混合强度或扩展混合区域

性能下降明显

原因

  • 混合区域过大
  • 采用复杂混合模式
  • 在移动端使用高精度混合

解决方案

  • 缩小混合区域
  • 在非关键区域使用Default模式
  • 针对移动端优化混合参数

光照表现异常

原因

  • 混合后法线未正确标准化
  • 混合模式选择不当
  • 法线贴图格式有误

解决方案

  • 确保输出法线经过标准化处理
  • 根据表面复杂度选择合适的混合模式
  • 检查法线贴图格式与生成方式

进阶应用案例

多层级法线混合

实现方法

  1. 构建多个混合层级
  2. 使用遮罩贴图控制各层级混合区域
  3. 逐层混合法线贴图

优势

  • 实现更复杂的表面细节
  • 可调控不同区域的混合强度
  • 提升材质表现力

动态法线混合系统

实现方法

  1. 依据动画参数动态调整混合强度
  2. 使用噪声纹理增添动态细节
  3. 结合顶点动画实现物理正确的混合

应用场景

  • 角色表情变化
  • 布料模拟
  • 动态环境变化

材质系统集成方案

实现方法

  1. 将混合参数暴露给材质系统
  2. 创建材质参数集合以控制混合行为
  3. 实现动态材质切换

优势

  • 增强材质系统的灵活性
  • 支持运行时动态调整
  • 简化美术工作流程

最佳实践总结

  1. 模式选择原则:简单表面使用Default模式,复杂表面使用Reoriented模式
  2. 性能优化优先级:移动端优先考虑性能,PC端可适度增加细节
  3. 质量保障措施:使用标准化工具验证混合结果,确保法线方向正确
  4. 迭代开发流程:从简单混合起步,逐步提升复杂度,并持续验证效果

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

Next.js第十七章(Script脚本)

作者 小满zs
2025年12月27日 06:01

Script组件

Next.js允许我们使用Script组件去加载js脚本(外部/本地脚本),并且他还对Script组件进行优化。

基本使用

局部引入

src/app/home/page.tsx

在home路由引入一个远程的js脚本,他只会在切换到home路由时才会加载,并且只会加载一次,然后纳入缓存。

import Script from 'next/script' //引入Script组件
export default function HomePage() {
    return (
        <div>
            <Script src="https://unpkg.com/vue@3/dist/vue.global.js" />
        </div>
    )
}

他的底层原理会把这个Script组件转换成<script>标签,然后插入到<head>标签中。

head.png

全局引入

src/app/layout.tsx

全局引入直接在app/layout.tsx中引入,他会自动在所有页面中引入,并且只会加载一次,然后纳入缓存。

import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html>
            <head>
                <Script src="https://unpkg.com/vue@3/dist/vue.global.js" />
            </head>
            <body>
                {children}
            </body>
        </html>
    )
}
加载策略

Next.js允许我们通过strategy属性来控制Script组件的加载策略。

  • beforeInteractive: 在代码和页面之前加载会阻塞页面渲染
  • afterInteractive(默认值): 在页面渲染到客户端之后加载。
  • lazyOnload: 在浏览器空闲时稍后加载脚本。
  • worker(实验性特性): 暂时不建议使用。
<Script id="VGUBHJMK1" strategy="beforeInteractive" src="https://unpkg.com/vue@3/dist/vue.global.js" />
<Script id="VGUBHJMK2" strategy="afterInteractive" src="https://unpkg.com/vue@3/dist/vue.global.js" />
<Script id="VGUBHJMK3" strategy="lazyOnload" src="https://unpkg.com/vue@3/dist/vue.global.js" />
<Script id="VGUBHJMK4" strategy="worker" src="https://unpkg.com/vue@3/dist/vue.global.js" />

webWorker模式 尚不稳定,谨慎使用,小提示可以给Script组件添加id,Next.js会追踪优化。

内联脚本

即使不从外部文件载入脚本,Next.js也支持我们通过{}直接在Script组件编写代码。

import Script from "next/script";
export default function RootLayout({
  children
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <head>
        <Script id="VGUBHJMK5" strategy="beforeInteractive" src="https://unpkg.com/vue@3/dist/vue.global.js"></Script>
      </head>
      <body>
        {children}
        <div id="app"></div>
        <Script id="VGUBHJMK6"
         strategy="afterInteractive">
        {
           `
            const {createApp} = Vue
            createApp({
              template: '<h1>{{ message }}</h1>',
              setup() {
                return {
                  message: 'Next.js + Vue.js'
                }
              }
            }).mount('#app')
          `
        }
        </Script>
      </body>
    </html>
  );
}

第二种写法使用 dangerouslySetInnerHTML 属性来设置内联脚本。

<Script dangerouslySetInnerHTML={{__html: `
    const {createApp} = Vue
    createApp({
        template: '<h1>{{ message }}</h1>',
        setup() {
        return {
            message: 'Next.js + Vue.js'
        }
        }
    }).mount('#app')
    ` }} strategy="afterInteractive">
</Script>
事件监听
  • onload: 脚本加载完成时触发。
  • onReady: 脚本加载完成后,且组件每次挂载的时候都会触发。
  • onError: 脚本加载失败时触发。

Script组件只有在导入客户端的时候才会生效,所以需要使用'use client'声明这是一个客户端组件。

'use client'
 
import Script from 'next/script'
 
export default function Page() {
  return (
    <>
      <Script
        src="https://example.com/script.js"
        onLoad={() => {
          console.log('Script has loaded')
        }}
      />
    </>
  )
}

Next.js第十六章(font字体)

作者 小满zs
2025年12月27日 05:54

font字体

next/font模块,内置了字体优化功能,其目的是防止CLS布局偏移。font模块主要分为两部分,一部分是内置的Google Fonts字体,另一部分是本地字体。

基本用法

Goggle字体

在使用google字体的时候,Google字体和css文件会在构建的时候下载到本地,可以与静态资源一起托管到服务器,所以不会向Google发送请求。

  1. 基本使用
import { BBH_Sans_Hegarty } from 'next/font/google' //引入字体库
const bbhSansHegarty = BBH_Sans_Hegarty({
  weight: '400', //字体粗细
  display: 'swap', //字体显示方式
})
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body className={bbhSansHegarty.className}> {/** bbhSansHegarty会返回一个类名,用于加载字体 */}
        {children}
        sdsadasdjsalkdjasl
        你好
      </body>
    </html>
  );
}

google.png

  1. 可变字体

可变字体是一种可以适应不同字重和样式的字体,它可以在不同的设备上自动调整字体大小和样式,以适应不同的屏幕大小和分辨率。

import { Roboto } from 'next/font/google'
const roboto = Roboto({
  weight: ['400', '700'], //字体粗细 (不是所有字体都支持可变字体)
  style: ['normal', 'italic'], //字体样式   
  subsets: ['latin'],
  display: 'swap',
})

如何选择其他字体?可以参考Google Fonts

web.png

import { Inter,BBH_Sans_Bartle,Roboto_Slab,Rubik,Montserrat } from 'next/font/google' //引入其他字体库

API 参考

配置选项

属性 Google 本地 类型 必填 说明
src String/Array 字体文件路径
weight String/Array 可选 字体粗细,如 '400'
style String/Array - 字体样式,如 'normal'
subsets Array - 字符子集
axes Array - 可变字体轴
display String - 显示策略
preload Boolean - 是否预加载
fallback Array - 备用字体
adjustFontFallback Boolean/String - 调整备用字体
variable String - CSS 变量
declarations Array - 自定义声明
style

字体样式,如 'normal' 'italic(斜体)' 'oblique(倾斜)' 等。

weight

字体粗细,如 '400' '700' '900' 等。

display

auto:浏览器默认(通常为 block)

block:空白 3s → 备用字体 → 自定义字体

swap:备用字体 → 自定义字体

fallback:空白 100ms → 备用字体,3s 内加载完成则切换

optional:空白 100ms,100ms 内加载完成则使用,否则用备用字体

本地字体

字体下载地址:免费可商用字体

本地字体需要通过src属性指定字体文件路径,字体文件路径可以是单个文件,也可以是多个文件。

import localFont from 'next/font/local'
const local = localFont({
  src:'./font/zydtt.ttf', //本地字体文件路径
  display: 'swap', //字体显示方式
})
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body className={local.className}>
        {children}
        sdsadasdjsalkdjasl
        你好
      </body>
    </html>
  );
}

local.png

LeetCode 热题100 --- 双指针专区

2025年12月27日 01:36

283. 移动零 - 力扣(LeetCode)

题目分析:

题目要求将数组 nums 中所有 0 移动至数组末尾,同时保持其他非零元素的相对顺序不变,并且要求在原数组上进行操作。

核心要求:

  • 0 要移动至数组末尾
  • 非零元素相对位置不变
  • 在原数组上进行操作

解法一(暴力使用数组方法)

遍历数组将其中所有为 0 的数直接使用splice删除并且记录 0 的个数,最后通过push填入“移动”的 0

var moveZeroes = function(nums) {
    let n = 0;
    for(let i = 0; i < nums.length;){
        if(nums[i] == 0){
            n++;
            nums.splice(i, 1);
        } else i++;
    }
    for(let j = 0; j < n; j++){
        nums.push(0);
    }
    return nums;
};

⏱️ 时间复杂度分析

  • splice(i, 1) 的代价
    在数组中间删除一个元素,需要将 i 后面的所有元素向前移动一位
    --> 时间复杂度为 O(k) ,其中 k 是从 i 到末尾的元素个数。

  • 最坏的情况:假设数组中有 z 个 0,且它们分布在前面:

    • 第 1 个 0 被删时,移动 n-1 个元素
    • 第 2 个 0 被删时,移动 n-2 个元素
    • ...
    • 最坏情况总移动次数 ≈ (n-1) + (n-2) + ... + (n-z) ≈ O(n²)

    (例如输入 [0,0,0,...,0,1]

  • push(0)的代价: 是 O(z),可忽略。

最坏时间复杂度:O(n²)

注:

JavaScript 数组在底层通常是连续内存,而splice(i, 1) 会触发 大量元素的内存拷贝

⚠️ 尽量避免在循环中使用 splice 删除元素,尤其是在处理大数组时,性能会急剧下降

解法二(双指针 + 交换)

题目的问题本质上是将所有非零的元素按照原先的相对顺序排在数组的最前方,那么不妨通过一个指针安排非零元素的位置,而另一个指针来查找所有非零的元素并且通过交换来将其放置在正确的位置。

var moveZeroes = function(nums) {
    let j = 0;
    for(let i = 0; i < nums.length; i++) {
        if (nums[i] != 0) {
            let temp = nums[j];
            nums[j] = nums[i];
            nums[i] = temp;
            // [灵茶山艾府] 灵神做法:
            // [nums[j],nums[i]] = [nums[i],nums[j]]; (更简洁、更现代)
            j++;
        }
    }
};

⏱️ 时间复杂度分析

  • 单次遍历
    使用 for 循环从 i = 0i = nums.length - 1 遍历数组一次 → O(n)
  • 交换操作
    每次遇到非零元素时,执行一次交换(通过临时变量或解构赋值),该操作为 O(1)
    由于 i 单调递增、j 不回退,每个元素最多被访问和交换一次 → 总交换开销为 O(n)
  • 总时间复杂度:O(n) + O(n) = O(n)

11. 盛最多水的容器 - 力扣(LeetCode)

题目分析:

题目要求从给定的整数数组 height 中选择两条垂线,使得它们与 x 轴共同构成的容器可以容纳最多的水,并返回该最大水量。

核心要求:

  • 容器不能倾斜
  • 较短的一条决定容器的高度
  • 容器的宽度为索引之差
  • 目标是使容器的面积(水量)最大化

本题本质上是在所有可能的垂线对 (i, j)(其中 i < j)中,寻找使面积

area=(ji)×min(height[i],height[j])\text{area} = (j - i) \times \min(\text{height}[i], \text{height}[j])

最大的组合。

解法(双指针)

题目本意就是要找到两个尽可能远且尽可能高的数据,那么不妨先把两个指针放置最远,再通过逐一减小距离来找到尽可能高的数据来平衡距离的缩减

优化: 由于距离在缩减,所以如果数据还减小的话,那么总量一定减小,所以只能牺牲更小的一边向中心查找是否能找到更高的数据。

var maxArea = function(height) {
    let left = 0, right = height.length - 1;
    // 总面积
    let m = 0;
    
    while(left < right) {
        // 计算当前面积并比较大小
        area = (right - left) * Math.min(height[left], height[right])
        m = Math.max(area, m);
        // 判断哪方更小,哪方移动
        if (height[left] < height[right]) left++;
        else right--;
    }
    return m;
}

15. 三数之和 - 力扣(LeetCode)

题目分析:

题目要求从给定的整数数组 nums 中找出所有下标不重复的三元组,使得三个数的和为 0,并且每组不同。

核心要求:

  • 三元组中的三个数之和必须等于 0
  • 结果中不能包含重复的三元组
  • 三元组内的元素可以按任意顺序排列
  • 每个三元组中的元素必须来自数组中不同的位置(但值可以相同)

本题本质上是在数组中寻找所有满足
nums[i]+nums[j]+nums[k]=0\text{nums}[i] + \text{nums}[j] + \text{nums}[k] = 0 \quad
的三元组,并确保结果集合中无重复。


解法(排序 + 双指针)

暴力枚举需要三重循环,这样时间复杂度将会飙升到 O(n³),效率极其低下,所以我们采用排序 + 固定一个数 + 双指针的优化策略。

关键思想:

  1. 先对数组排序,后续可以通过大小关系移动指针。当到达分界点 0时,后续所有值无论如何增加总值都不会为零(优化点);

  2. 固定第一个数 nums[i] ,将其转化为“在剩余数组中找两数之和为 -nums[i]”的问题;

  3. 使用双指针 left = i+1right = n-1 向中间收缩,根据当前和与目标值的大小关系移动指针;

  4. 跳过重复值

    • nums[i] == nums[i-1],跳过重复;
    • 找到一组解后,继续跳过 leftright 的重复值,防止重复答案。
var threeSum = function(nums) {
    // 先排序:升序排列,使用JS内置的 sort 方法
    // sort() 根据返回值决定 a 和 b 谁排在前面
    // a - b <= 0 ==> 顺序为 a, b
    // a - b >  0 ==> 顺序为 b, a
    nums.sort((a, b) => a - b);
    const res = [];

    // 固定第一个数 nums[i]
    for (let i = 0; i < nums.length - 2; i++) {
        // 最小值已大于 0,后续不可能有解
        if (nums[i] > 0) break;

        // 跳过重复的起始值,避免重复三元组
        if (i > 0 && nums[i] === nums[i - 1]) continue;
        // 双指针
        let left = i + 1;
        let right = nums.length - 1;

        while (left < right) {
            const sum = nums[i] + nums[left] + nums[right];
            if (sum === 0) {
                res.push([nums[i], nums[left], nums[right]]);
                
                // 移动双指针,继续查找
                left++;
                right--;
                // 跳过重复组
                while (left < right && nums[left] === nums[left - 1]) left++;
                while (left < right && nums[right] === nums[right + 1]) right--;
            } else if (sum < 0) {
                // 和太小,需增大 → 左指针右移
                left++;
            } else {
                // 和太大,需减小 → 右指针左移
                right--;
            }
        }
    }
    return res;
};

⏱️ 时间复杂度分析

  • 排序操作
    调用 nums.sort((a, b) => a - b) 对数组进行升序排序,JavaScript 引擎通常采用高效排序算法,时间复杂度 --> O(n log n)
  • 外层循环
    for 循环遍历 i0nums.length - 3,时间复杂度 --> O(n)
  • 内层双指针扫描
    对于每个固定的 ileftright 从两端向中间移动,每个元素在该轮中最多被访问一次,因此每轮内层循环的时间复杂度 --> O(n)
    由于外层循环执行 O(n) 次,本应当为 O(n²),但是并非每轮都走完整个数组,所以最坏情况下 --> O(n²)
  • 总时间复杂度:排序 O(n log n) + 双指针主逻辑 O(n²) = O(n²)

42. 接雨水 - 力扣(LeetCode)

题目分析:

题目要求计算在给定的非负整数数组 height 所表示的柱状图中,能接住多少单位的雨水。每个元素 height[i] 表示位置 i 处柱子的高度,宽度均为 1。

核心要求:

  • 雨水只能积在“凹陷”区域,即两侧有更高柱子的位置;
  • 每个位置能接的雨水量 = min(左侧最高柱, 右侧最高柱) - 当前高度(若结果为正,否则为 0);
  • 不能倾斜容器,雨水垂直下落并被两侧柱子围住
  • 目标是返回整个结构能储存的总雨水量

本题本质上是:对每个位置 i,快速确定其左侧最大高度右侧最大高度,从而计算该位置的积水。


解法(双指针 + 动态边界)

暴力解法需对每个位置遍历左右求最大值,时间复杂度 O(n²)。即使使用额外数组预处理左右最大值可优化至 O(n),但是空间占用还是过大。

所以解法采用双指针从两端向中间收缩,再利用贪心思想实现 O(1) 空间、O(n) 时间 的最优解。

关键思想:

  1. 维护两个变量 lmaxrmax,分别表示当前 left 指针左侧(含自身)的最大高度,以及 right 指针右侧(含自身)的最大高度;

  2. 比较 lmaxrmax

    • lmax < rmax,说明 left 位置的积水由左侧最大值决定(因为右侧存在更高的边界),此时可安全计算 left 处的积水;
    • 否则,right 位置的积水由右侧最大值决定,计算 right 处的积水;
  3. 每次只移动较矮一侧的指针,确保另一侧始终存在足够高的“挡板”,从而保证当前积水计算的正确性。

  4. 并且两个指针相遇的位置一定为最高的柱子,这个柱子是无法装水的。

var trap = function(height) {
    // 创建双指针 
    let left = 0;
    let right = height.length - 1;

    let m = 0;              // 总雨水量
    let lmax = 0, rmax = 0; // 当前左右侧最大高度

    while (left < right) {
        // 更新左右侧最大高度
        lmax = Math.max(lmax, height[left]);
        rmax = Math.max(rmax, height[right]);

        if (lmax < rmax) {
            // 左侧最大值更小 → left 位置的积水由 lmax 决定
            m += lmax - height[left];
            left++;
        } else {
            // 右侧最大值更小或相等 → right 位置的积水由 rmax 决定
            m += rmax - height[right];
            right--;
        }
    }
    return m;
};

⏱️ 时间复杂度分析

  • 双指针遍历
    leftright 从两端向中间移动,每个元素最多被访问一次,循环执行 O(n) 次。
  • 每轮操作
    包括 Math.max、比较、加法和指针移动,均为 O(1) 常数时间操作。
  • 总时间复杂度O(n)
  • 空间复杂度:仅使用常数个变量(left, right, m, lmax, rmax)→ O(1)

每日一题-会议室 III🔴

2025年12月27日 00:00

给你一个整数 n ,共有编号从 0n - 1n 个会议室。

给你一个二维整数数组 meetings ,其中 meetings[i] = [starti, endi] 表示一场会议将会在 半闭 时间区间 [starti, endi) 举办。所有 starti 的值 互不相同

会议将会按以下方式分配给会议室:

  1. 每场会议都会在未占用且编号 最小 的会议室举办。
  2. 如果没有可用的会议室,会议将会延期,直到存在空闲的会议室。延期会议的持续时间和原会议持续时间 相同
  3. 当会议室处于未占用状态时,将会优先提供给原 开始 时间更早的会议。

返回举办最多次会议的房间 编号 。如果存在多个房间满足此条件,则返回编号 最小 的房间。

半闭区间 [a, b)ab 之间的区间,包括 a 不包括 b

 

示例 1:

输入:n = 2, meetings = [[0,10],[1,5],[2,7],[3,4]]
输出:0
解释:
- 在时间 0 ,两个会议室都未占用,第一场会议在会议室 0 举办。
- 在时间 1 ,只有会议室 1 未占用,第二场会议在会议室 1 举办。
- 在时间 2 ,两个会议室都被占用,第三场会议延期举办。
- 在时间 3 ,两个会议室都被占用,第四场会议延期举办。
- 在时间 5 ,会议室 1 的会议结束。第三场会议在会议室 1 举办,时间周期为 [5,10) 。
- 在时间 10 ,两个会议室的会议都结束。第四场会议在会议室 0 举办,时间周期为 [10,11) 。
会议室 0 和会议室 1 都举办了 2 场会议,所以返回 0 。 

示例 2:

输入:n = 3, meetings = [[1,20],[2,10],[3,5],[4,9],[6,8]]
输出:1
解释:
- 在时间 1 ,所有三个会议室都未占用,第一场会议在会议室 0 举办。
- 在时间 2 ,会议室 1 和 2 未占用,第二场会议在会议室 1 举办。
- 在时间 3 ,只有会议室 2 未占用,第三场会议在会议室 2 举办。
- 在时间 4 ,所有三个会议室都被占用,第四场会议延期举办。 
- 在时间 5 ,会议室 2 的会议结束。第四场会议在会议室 2 举办,时间周期为 [5,10) 。
- 在时间 6 ,所有三个会议室都被占用,第五场会议延期举办。 
- 在时间 10 ,会议室 1 和 2 的会议结束。第五场会议在会议室 1 举办,时间周期为 [10,12) 。 
会议室 1 和会议室 2 都举办了 2 场会议,所以返回 1 。 

 

提示:

  • 1 <= n <= 100
  • 1 <= meetings.length <= 105
  • meetings[i].length == 2
  • 0 <= starti < endi <= 5 * 105
  • starti 的所有值 互不相同

n<=100,代码可以写得简单(比赛时想复杂了)

作者 newhar
2022年9月4日 12:21

由于 $n\le 100$,可以直接按照题目模拟:

  1. 按开始时间排序,依次安排 meetings

  2. 维护每个会议室的 最早可用时间 $t$。每次安排会议$[start, end)$时,将那些 $t$ 早于 $start$ 的会议室的 $t$ 设为 $start$。这样处理后只需从中选择 $t$ 最早的会议室即可(如果有相等的选下标最小的)。

  3. 同时维护 $cnt$ 数组,遍历完成后按要求返回答案即可

###python

class Solution:
    def mostBooked(self, n: int, meetings: List[List[int]]) -> int:
        cnt, t = [0] * n, [0] * n
        
        for [s, e] in sorted(meetings):
            t = list(map(lambda x : max(x, s), t))
            choice = t.index(min(t))
            t[choice], cnt[choice] = t[choice] + e - s, cnt[choice] + 1
            
        return cnt.index(max(cnt))
❌
❌