普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月20日掘金 前端

“就跑一下”的代码,不该为它单独开一台服务器!直接上亚马逊云 Lambda!

2025年5月20日 09:04

很多人第一次用云服务,脑子里装的是“架个服务、跑个 API、调个模型”,于是打开 EC2,跟着教程买了一台虚拟机,从此踏上了无休止的运维之路。

但是请冷静想一想,你真的需要 7x24 的服务吗?

我们平时要跑的很多任务,说白了就是“写完跑一下”:

  • 写个 webhook,接收 GitHub 的 Push 回调
  • 每天凌晨处理 S3 里新上传的文件,入库、通知一下
  • 写个 AI agent,帮你分析报表,执行一次就够

这种需求压根没必要常驻服务、维护端口、配置环境,更不值得你为它开台 EC2 或部署容器集群。真的,是 不值当

而 Amazon Lambda,正是为这种“碎片化、轻量级、按需触发”的任务设计的。


🧠 为什么 Lambda 正好兜住这类需求?

因为它做了一件事:把“运维责任”彻底抽走了

你不再需要:

  • 考虑服务该放在哪儿跑(Lambda 自带执行环境)
  • 管理运行时状态(调用完自动释放资源)
  • 挂接口、配安全组、启监听(触发器接上就好)

而且它默认就和 亚马逊云科技 生态联动紧密: S3、EventBridge、API Gateway、SNS、Bedrock……通通支持作为触发来源。你只要写好函数逻辑,剩下的“怎么调用”都有人兜底。

说得再直白点:Lambda 就是 函数即服务(Function as a Service)的标杆实现。你写逻辑,它帮你跑。


🧾 成本呢?0元也能跑

你没听错。Amazon Lambda 提供的是永久性的免费额度:

  • 每月 100 万次请求
  • 每月 40 万 GB·s 运行时长

不是试用期,不是隐藏条款,是每个账户都自带的“Always Free”额度5-1。

你写个 webhook,平时每小时触发一次,一年都用不完这额度。

你跑个日报生成服务,每天执行一次脚本,也能在免费范围内轻松搞定。

你甚至可以连模型调用逻辑一并托管,用 Lambda 处理 prompt,再打通 Bedrock 或 SageMaker。


⚙️ 一个真实例子:我只想接个表格,处理一下,然后走人

这是我几周前在项目中写的一个 Lambda 逻辑:

  • 用户上传了一个 Excel 到 S3
  • EventBridge 监听到上传事件,触发 Lambda
  • Lambda 调用 Pandas 分析表格、提取内容、写入数据库
  • 分析结果推送到企业微信机器人通知

目录:

project/
├── lambda_function.py        # 核心逻辑
├── requirements.txt          # Pandas 依赖
└── deploy.zip                # 打包上传

依赖库:

pandas
openpyxl
boto3
requests

py脚本:

import json
import boto3
import pandas as pd
import requests
import os
from io import BytesIO

dynamodb = boto3.resource('dynamodb')
s3 = boto3.client('s3')

# 环境变量提前设置
TABLE_NAME = os.environ.get("TABLE_NAME")
WECHAT_WEBHOOK = os.environ.get("WECHAT_WEBHOOK")

def lambda_handler(event, context):
    # 1. 从 S3 事件中提取 Bucket 和 Key
    record = event['Records'][0]['s3']
    bucket = record['bucket']['name']
    key = record['object']['key']
    
    print(f"Processing file: s3://{bucket}/{key}")
    
    # 2. 下载 Excel 文件
    response = s3.get_object(Bucket=bucket, Key=key)
    content = response['Body'].read()
    df = pd.read_excel(BytesIO(content), engine='openpyxl')
    
    print(f"Loaded DataFrame with shape: {df.shape}")
    
    # 3. 简单分析示例(比如统计每个“部门”的数量)
    summary = df['部门'].value_counts().to_dict()
    print(f"Summary: {summary}")
    
    # 4. 写入 DynamoDB(每个部门写一行)
    table = dynamodb.Table(TABLE_NAME)
    for department, count in summary.items():
        table.put_item(Item={
            'Department': department,
            'Count': count,
            'UploadedFile': key
        })
    
    # 5. 推送到企业微信机器人
    content_lines = [f"{dept}: {cnt}人" for dept, cnt in summary.items()]
    msg = {
        "msgtype": "text",
        "text": {
            "content": f"📊 新文件分析完成:{key}\n\n" + "\n".join(content_lines)
        }
    }
    r = requests.post(WECHAT_WEBHOOK, json=msg)
    print(f"WeChat notification sent: {r.status_code}")
    
    return {
        'statusCode': 200,
        'body': json.dumps('Excel 分析完成,通知已发送')
    }

打包上传:

pip install -r requirements.txt -t .
zip -r deploy.zip .
# 上传到 AWS Lambda 控制台

这整个流程的服务端,没有服务器,没有 Docker,没有定时器,也没有守护进程。

只有 Lambda 一行一行执行着我写的函数,执行完就自动退出,什么都不留。 它不是“常驻应用”,它更像一个“轻触即发”的服务弹簧。


🤖 AI Agent 的最佳容器?

我现在越来越多地把一些“函数型 AI agent”也挂在 Lambda 上:

  • 它们只是根据 prompt 推理、做个判断、输出一段话
  • 每次触发一次,跑完就销毁,不需要上下文
  • 数据流接 S3、DynamoDB、API Gateway 都方便

甚至连安全问题都考虑进去了。比如你可以配合 Amazon Bedrock 的 Guardrails 做内容审查,或者设置角色权限只允许读取某个特定资源。

AI 工程不等于部署大模型服务,有时候更像是 orchestrate 几个“用完即走”的 AI 小模块。Lambda 就非常适合当这个 glue layer。


📦 小结:不是所有任务都配得上“服务器”

Lambda 给我们的启发不只是“便宜”,而是一个理念上的转变:

有些任务,不该为了“能执行”就搞一个服务,而是该被“托管执行”。

我们开发者大多数时候不是在跑系统,而是在跑函数。Lambda 把这个过程变得极致简单:

  • 没有服务,只写逻辑
  • 没有常驻,调用即走
  • 没有费用,跑着还免费

哪怕你不做 AI、也不想用 亚马逊云科技 生态,Lambda 都是一个值得一试的工具。它适合跑任何“用完即走”的逻辑,像是现代编程世界的“if this then that”,只不过你自己能决定逻辑、能接模型、能连数据。


🧪 综上,我的建议很简单:下一次你想“跑个脚本看看”的时候,别再打开本地终端了。试试 Lambda,你会喜欢上那种“无负担”的开发感。

如果你已经在用了,也欢迎回来告诉我你怎么用它的;如果你在部署 AI 应用,也可以聊聊用 Lambda 的痛点,我这边也有踩过一些坑。

简单盘点下前端日志采集的几种方式

2025年5月20日 09:00

作者:前端开发爱好者

前端日志采集,说简单也简单,说复杂也复杂,取决于业务想要什么粒度的数据,以及开发者能接受多少侵入性、延迟和兼容性问题。今天我们就来盘一盘常见的几种前端上报方式,以及各自的优劣势和适用场景。

1. + GIF / Pixel 上报

原理:   通过在页面上动态创建一个Image对象,把要上报的数据编码到请求 URL 的 query 参数中,然后加载一个 1x1 的透明 GIF 图片(当然不需要真的返回一张图,后端 204 也行),重点是通过src请求资源的同时,让服务端记录.gif后的数据。

代码示例:

const img = new Image();
img.src = `https://logserver.com/collect?event=click&userId=123`;

优点:

  • 兼容性超好,从古早 IE 到现代浏览器都能用。
  • 不受 CORS 限制,因为图片加载天然跨域
  • 简单,几乎不会影响页面性能。

缺点:

  • 请求量小,受 URL 长度限制(一般 2KB 左右)。
  • 只能 GET,不能 POST。
  • 无法拿到发送成功或失败的准确回调。

适用场景:

  • 简单 PV/UV 打点,曝光上报。
  • 无需保证必达,只要发出请求就行。

2. fetch / XMLHttpRequest 上报

原理:   使用fetch或者XMLHttpRequest直接发 HTTP 请求,数据格式可以是 JSON 或表单数据,GET/POST 都行。

代码示例:

js
 体验AI代码助手
 代码解读
复制代码
// fetch版
fetch('https://logserver.com/collect', {
  method'POST',
  headers: { 'Content-Type''application/json' },
  body: JSON.stringify({ event'click', userId: 123 })
});

优点:

  • 可以 POST,支持大体积数据。
  • 可以拿到请求成功或失败的反馈。
  • 灵活,能带复杂头部,比如认证信息。

缺点:

  • 受 CORS 限制,需要服务器支持。
  • 发送过程中可能阻塞主线程,尤其是同步 XHR。
  • 如果页面关闭得太快,可能请求还没发出去(不过可以用keepalive选项优化)。

适用场景:

  • 错误日志、性能埋点。
  • 需要可靠上报、需要认证或者复杂参数时。

3. navigator.sendBeacon

原理:   专门为这种场景设计的 API,可以在页面卸载(比如跳转、关闭)时,异步且可靠地把数据发送到服务器,不阻塞页面卸载流程。

代码示例:

navigator.sendBeacon('https://logserver.com/collect', JSON.stringify({ event'unload', userId: 123 }));

优点:

  • 适合页面关闭前发送数据,不容易丢包。
  • 不阻塞 unload 流程,不卡界面。
  • 支持 POST,发送简单。

缺点:

  • 兼容性略差(但现在主流浏览器基本都支持了)。
  • 不支持自定义请求头。
  • 只支持 Content-Type 是application/x-www-form-urlencodedtext/plain的请求体。

适用场景:

  • 页面 unload 时的上报,比如用户行为日志、退出日志。

4. WebSocket 上报

原理:   建立长连接,把日志实时推送到服务器。

优点:

  • 实时性超强。
  • 理论上吞吐量高,连接一旦建立数据传输非常轻量。

缺点:

  • 建连、保活有成本,移动端或弱网环境下容易掉线。
  • 服务端也要有能力管理大量持久连接。
  • 不适合小流量、轻量应用。

适用场景:

  • 高实时要求的埋点,比如游戏、IM、股票类应用。

总结

方式 优势 劣势 常用场景
+ GIF 简单兼容 只能 GET,小数据量 曝光打点
fetch/XHR 灵活可靠 受 CORS 限制 错误日志、性能上报
sendBeacon 页面关闭也能发 兼容性略差,简单数据 离开页面上报
WebSocket 实时性强 成本高 游戏、IM 实时上报

实际开发里,我们会根据业务场景,多种方式结合使用:普通打点用fetch,页面 unload 用sendBeacon,曝光用Image兜底,再加一些重试机制,做到不丢、不卡、可靠。

CSS平移函数完全指南与三大应用

作者 云浪
2025年5月20日 08:50

平移函数(Translate functions)的作用是在二维或三维空间中,对元素进行平移操作,改变元素的位置。

目前平移函数有 translateX()translateY()translateZ()translate()translate3d()

translateX()

translateX() 用于水平移动一个元素。

例子如下:

<div>Static</div>
<div class="moved">Moved</div>
<div>Static</div>
div {
  width: 60px;
  height: 60px;
  background-color: skyblue;
}

.moved {
  transform: translateX(10px); /* Equal to translate(10px) */
  background-color: pink;
}

pic1.png

更多可见 translateX()

translateY()

垂直移动一个元素。

例子:

<div>Static</div>
<div class="moved">Moved</div>
<div>Static</div>
div {
  width: 60px;
  height: 60px;
  background-color: skyblue;
}

.moved {
  transform: translateY(10px);
  background-color: pink;
}

pic2.png

更多可见 translateY()

translateZ()

沿 z 轴平移一个元素。

translateZ(tz) 等同于 translate3d(0, 0, tz)

tz 为正值,则使元素朝向观察者移动;负值,则朝远离观察者方向移动

在这个示例中,创建了两个盒子。第一个盒子在页面上正常定位,完全未进行平移。第二个盒子则通过应用透视(perspective)来创建 3D 空间,然后向用户方向移动。

html:

<div>Static</div>
<div class="moved">Moved</div>

css:

div {
  position: relative;
  width: 60px;
  height: 60px;
  left: 100px;
  background-color: skyblue;
}

.moved {
  transform: perspective(500px) translateZ(200px);
  background-color: pink;
}

在类 moved 中,使用 perspective() 函数将观察者相对于 z=0 所在的平面(本质上是屏幕表面)进行定位。值为 500px 意味着用户位于 z=0 处图像的前方 500 像素位置。

然后,translateZ() 函数将元素从屏幕 “向外” 移动 200 像素,朝向用户方向。这会产生以下效果:在 2D 显示器上观看时,元素显得更大;在使用 VR 头显或其他 3D 显示设备时,元素则显得更近。

需要注意的是,如果 perspective() 的值小于 translateZ() 的值(例如 transform: perspective(200px) translateZ(300px);),变换后的元素将不可见,因为它超出了用户视口的范围。透视值与 translateZ() 值的差值越小,用户离元素就越近,平移后的元素看起来就越大。

使用变换函数的组合时,变换函数的顺序很重要,通常来说,需要将 perspective() 放在 translateZ() 前面。 如果类 moved 中将 translateZ() 放在 perspective() 前面,元素就不具备放大效果。

此例子的结果:

pic3.png

更多可见 translateZ()

translate()

在二维平面上,可同时对一个元素进行水平或垂直移动。

translate(10px)translateX(10px)translate(10px, 0) 作用一样。

更多可见 translate()

translate3d()

在三维空间中平移一个元素。

这种变换由三维向量 [tx, ty, tz] 构成,该向量的坐标确定了元素在各个方向上的移动距离。

translate3d(tx, ty, tz)

tx 为 x 轴方向的移动距离;ty 为 y 轴方向的移动距离;tz 为 z 轴方向的移动距离。可以是长度值或百分比。

如下面的例子,html 为

<div>Static</div>
<div class="moved">Moved</div>
<div>Static</div>

css 为

div {
  width: 60px;
  height: 60px;
  background-color: skyblue;
}

.moved {
  /* Equivalent to perspective(500px) translateX(10px) */
  transform: perspective(500px) translate3d(10px, 0, 0px);
  background-color: pink;
}

最终结果为

pic4.png

更多可见 translate3d()

平移函数的应用

避免布局抖动

使用平移函数移动元素,元素不会脱离文档流,可以保证页面布局稳定,元素移动后,不挤占或释放原位置空间,周围元素的布局完全不受影响。。

如这个例子,实现悬浮按钮,按钮向下移动 5px

<button>按钮</button>
<div>内容</div>
button:hover {
  margin-top: 5px; /* 下方元素下移 */
}

👆 此例子使用 margin 实现,由于 margin 调整元素外间距后,会挤占其他元素的空间,从而导致按钮下面的元素也向下移动:

gif1.gif

<button>按钮</button>
<div>内容</div>
button:hover {
  position: absolute;
  top: 5px;
}

👆 此例子使用绝对定位,将按钮元素向下移动 5px ,由于绝对定位会使元素脱离文档流,使元素的占位消失,所以按钮下面的元素会占据原按钮的位置,造成页面布局的破坏:

gif2.gif

<button>按钮</button>
<div>内容</div>
button:hover {
  transform: translateY(5px);
}

👆这个例子使用 translateY 使元素向下移动 5px ,由于平移函数不会脱离文档流,不改变元素在文档流中的原始位置,仅改变视觉渲染位置。因此按钮下面的内容不会跟着向下移动,保证页面布局的稳定:

gif3.gif

在开发类似于 tooltips(提示框)的组件时,可借助平移函数进行定位,避免底层内容被推开,保持页面结构稳定。

性能优化

translate() 属于 CSS 变换(transform),其渲染由 GPU 独立处理,不会触发浏览器的回流

若通过修改 margintop/left(定位)移动元素,改变了元素的几何位置,会导致浏览器重新计算布局(回流),性能开销较大(尤其在复杂页面中)。

平移函数仅改变视觉位置,不影响布局树,因此性能更优,适合高频动画(如轮播图滑动、列表项平移)。

元素居中

如果使用绝对定位 + 负 margin 实现元素居中,需要提前知道子元素的宽高和手动计算并硬编码负 margin 值

<div class="container">
  <div class="centered-box">我是固定尺寸的居中元素!</div>
</div>
.container {
  position: relative;  /* 关键:定位基准 */
  width: 80vw;   
  height: 300px;
  border: 2px dashed #e5e7eb;
  margin: 50px auto;
  background: #f8f9fa;
}
/* 子元素:需要居中的目标元素(需已知宽高) */
.centered-box {
  position: absolute;  /* 绝对定位,基于父容器 */
  left: 50%;           /* X轴:左上角移至父容器水平中心 */
  top: 50%;            /* Y轴:左上角移至父容器垂直中心 */
  
  /* 关键:负margin 调整 */
  width: 200px;        /* 子元素固定宽度 */
  height: 100px;       /* 子元素固定高度 */
  margin-left: -100px; /* 向左移动自身宽度的一半(200px/2) */
  margin-top: -50px;   /* 向上移动自身高度的一半(100px/2) */

  background: #3b82f6;
  color: white;
  border-radius: 8px;
  display: flex;
  align-items: center; 
  justify-content: center;
  font-size: 18px;
}

效果如下:

pic5.png

如果使用平移函数,则不需要提前知道自身的尺寸,代码更加简洁:

<div class="container">
  <div class="centered-box">
    我是动态居中的元素!<br>
    无论内容多少,都会始终居中。<br>
    试试调整我的文字长度或父容器尺寸~
  </div>
</div>
.container {
  position: relative;  /* 关键:定位基准 */
  width: 80vw;        
  height: 400px;      
  border: 2px dashed #e5e7eb; 
  margin: 50px auto;  
  background: #f8f9fa;
}
/* 子元素:需要居中的目标元素 */
.centered-box {
  position: absolute;  /* 绝对定位,基于父容器 */
  left: 50%;           /* X轴:左上角移至父容器水平中心 */
  top: 50%;            /* Y轴:左上角移至父容器垂直中心 */
  transform: translate(-50%, -50%);  /* 关键:基于自身尺寸偏移 */

  /* 子元素样式(动态可变,无需固定宽高) */
  padding: 24px 32px;  /* 内边距 */
  background: #3b82f6; /* 背景色 */
  color: white;        /* 文字颜色 */
  border-radius: 8px;  /* 圆角 */
  font-size: 18px;     /* 文字大小 */
  max-width: 80%;      /* 最大宽度不超过父容器的80% */
}

效果如下:

pic6.png

总结

平移函数可以方便的移动元素的位置,并且可以触发硬件加速,减少浏览器的回流,性能更优。由于平移函数不会脱离文档流,布局更加稳定,同时实现元素居中也方便,代码简洁。

pic7.png

这个Web新API让任何内容都能画中画!

2025年5月20日 08:44

作者:沉浸式趣谈

“画中画”(Picture-in-Picture, PiP)说白了,就是让你能一边看视频,一边干别的,互不耽误。就像给你的桌面贴了个能播放视频的“便利贴”,你可以随便拖动它,调整大小,它还总在最前面,贼方便。

图片

现在主流的搞法:老朋友 requestPictureInPicture()

其实,想让 <video> 元素实现画中画,现在已经有挺成熟的方法了,那就是直接在视频元素上调用 requestPictureInPicture() 这个 API。

用起来也挺简单,基本上就是:

    1. 先搞个 <video> 标签,放上你的视频。
    1. 找个时机(比如用户点个按钮),用 JavaScript 拿到这个 video 元素,然后调用 video.requestPictureInPicture()

搞定!

给个简单的代码片段:

<video id="myVideo" src="your_video.mp4" controls width="300"></video>
<button id="pipButton">开启画中画</button>

<script>
    const video = document.getElementById('myVideo');
    const pipButton = document.getElementById('pipButton');

    pipButton.addEventListener('click'async () => {
        // 先检查浏览器支不支持,是个好习惯
        if (document.pictureInPictureEnabled) {
            try {
                // 如果视频没在画中画模式,就请求进入
                if (document.pictureInPictureElement !== video) {
                    await video.requestPictureInPicture();
                } else {
                    // 如果已经在画中画了,就退出
                    awaitdocument.exitPictureInPicture();
                }
            } catch (error) {
                console.error('操作画中画失败:', error);
            }
        } else {
            console.log('你的浏览器不支持画中画功能。');
        }
    });

    // 还可以监听进入和退出的事件,搞点事情
    video.addEventListener('enterpictureinpicture'() => {
        console.log('进入画中画啦!');
        pipButton.textContent = '退出画中画';
    });

    video.addEventListener('leavepictureinpicture'() => {
        console.log('退出画中画了。');
        pipButton.textContent = '开启画中画';
    });
</script>

大部分现代浏览器(Chrome, Edge, Firefox, Safari 这些)对这个 API 支持得都还不错(当然,细节上可能有点小差异,用的时候最好还是查查 MDN 或者 Can I Use)。

那 documentPictureInPicture.requestWindow 是个啥?

window.documentPictureInPicture.requestWindow 更像是个“升级版”或者说“野心更大”的亲戚。

requestPictureInPicture() 这个老朋友,它的目标很明确,就是把  <video> 元素 扔进画中画窗口。

而 documentPictureInPicture.requestWindow() 这个新来的呢,它的目标是 把任意的 HTML 内容(理论上是这样的,比如一个 <div>,里面可以包含视频、按钮、文字等等)放进那个悬浮的小窗口里!

小结一下

  • • 目前最常用、最稳妥的实现方式是针对 <video> 元素的 requestPictureInPicture() API。兼容性相对较好,用起来也直接。
  • • 那个新出的 documentPictureInPicture.requestWindow API 呢,目标更宏大,想让任意 HTML 都能 PiP。

对这个新技术感兴趣的朋友,可以去翻翻官方文档(下面附了链接),了解下最新进展。

不过动手实践的话,还是先从老朋友 requestPictureInPicture() 开始吧,至少不会被兼容性搞得头秃,哈哈。

继 Vite 之后,ESLint 也接入了 AI!

2025年5月20日 08:41

作者:前端开发爱好者

在之前的文章中有分享过  🔗Vite 是首个接入 AI 能力的构建工具,而现在 ESLint 也紧随其后,使用 AI 来管理代码规范!

作为一名前端开发,咱们平时写代码的时候,代码规范这事儿一直挺让人头疼的吧。

不过现在好消息来了!ESLint 这工具居然也开始用 AI 了,以后咱们可以让 AI 帮着管代码规范了!

ESLint 为啥要用 AI ?

ESLint 大家都熟悉吧,就是用来检查 JavaScript 代码问题的工具。

图片

现在它支持 Model Context Protocol(MCP)了,简单来说,就是能让 AI 模型跟它配合起来。

这样 AI 就能在咱们常用的开发工具,比如 IDE 里直接用 ESLint,帮咱们更智能地检查代码分析问题

特别是用 GitHub Copilot 这类 AI 编程助手的小伙伴,这功能太实用了!

以后写代码的时候,AI 就能在旁边帮着盯着代码规范,及时提醒哪儿写得不对,减少出错的可能。

怎么在 VS Code 和 Cursor 里用上 ESLint 的 AI 功能?

在 VS Code 里设置

先得安装个 Copilot Chat 扩展。然后跟着这几步走:

建个配置文件

在项目根目录下新建个文件,叫 .vscode/mcp.json,内容写成这样:

{
    "servers": {
        "ESLint": {
            "type": "stdio",
            "command": "npx",
            "args": ["eslint", "--mcp"]
        }
    }
}

这配置就是告诉 VS Code 怎么启动 ESLint 的 AI 功能。

全局启用(可选)

如果想在所有项目里都能用这个功能,就选 “用户设置”  ,把上面的配置加到 settings.json 里。

这样不管在哪个项目里,AI 都能帮着管代码规范。

在 Cursor 里设置

步骤也简单:

建配置文件

在项目目录里建个 .cursor/mcp.json,内容是:

{
    "mcpServers": {
        "eslint": {
            "command": "npx",
            "args": ["eslint", "--mcp"],
            "env": {}
        }
    }
}

全局配置(可选)

要是想在所有 Cursor 项目里都用上,就在主目录建个 ~/.cursor/mcp.json 文件,内容跟上面一样。

检查工具是否可用

配置好了以后,去 Cursor 的 MCP 设置页看看“可用工具”里有没有 ESLint MCP 服务器。

要是有了,就说明设置成功了,AI 就能帮你检查代码规范了。

用 AI 管代码规范有什么好处?

用上 ESLint 的 AI 功能,好处可不少:

  • 提高开发效率:AI 能快速发现代码里的问题,咱们不用花太多时间自己去找,写代码速度自然就上去了。
  • 提升代码质量:AI 检查代码比人更细致,能确保代码符合规范,减少潜在问题。
  • 团队协作更顺畅:一个团队里大家代码风格不一致很让人头疼。AI 帮着管代码规范,大家写出来的代码风格更统一,协作起来也更轻松。

ESLint 接入 AI 真的是开发流程里的一大进步。

以后写代码,有 AI 帮着管规范,咱们可以更专注于实现功能。

还没试过的小伙伴赶紧在自己的项目里试试吧!相信用过之后,你会越来越依赖这个强大的功能,让 AI 成为你开发过程中的得力助手!

  • ESLint MCP 官方文档https://eslint.org/docs/latest/use/mcp

7 款让人“上头”的开源小游戏

作者 HelloGitHub
2025年5月20日 08:30

好久没发开源游戏集合了,接下来我们就来盘点一下「js13kGames」比赛 2024、2023 年的获奖小游戏。

也许有些朋友是第一次听说「js13kGames」——这是一个每年举办的网页游戏编程竞赛,参赛者需要在一个月内开发一款大小不超过 13KB 的网页游戏。规则如下:

  1. 文件大小限制:提交的游戏压缩包(zip 格式),包含所有代码和资源,不得超过 13KB。
  2. 禁止外部依赖:不允许使用任何外部库、服务或资源,全部内容都要打包进 zip 文件。
  3. 入口文件:压缩包内必须包含一个 index.html 文件,解压后直接打开就能玩。
  4. 源码分享:鼓励参赛者在 GitHub 上开源游戏代码。

地址:js13kgames.com

每年的 Js13k 比赛都能看到许多有趣、好玩的创意小游戏,下面就让我们一起「玩一玩」过去两年里那些令人惊艳的游戏吧!

一、令人害怕的数字 13

2024 年的主题是「Triskaidekaphobia」,意为“对数字 13 的恐惧或回避”。让我们一起来看看,围绕这个概念的 13KB 游戏长什么样吧!

1.1 13th Floor(第十三层)

这是一款潜行恐怖(Stealth Horror)游戏,玩家需要时刻保持警觉,巧妙利用阴影进行躲藏。游戏开始,你带着一把钥匙出现在第 13 层,需要找到对应房间,获取下一把钥匙,逐步解锁新房间,直到最终抵达终点房间——1313。

在探索过程中,你可以使用手电筒(按 F 键)照亮前路,搜寻物品。但当“它”出现时,务必小心:关闭手电,避免暴露自己,或迅速奔跑寻找藏身之处。建议戴着耳机玩,沉浸感更强,不过要做好被吓到的准备哦!

操作说明:

  • 移动:WASD
  • 视角/转向:鼠标
  • 互动:E 键或鼠标左键
  • 手电筒:F 键

试玩:play.js13kgames.com/13th-floor/

源码:github.com/js13kGames/…

1.2 Coup Ahoo(阿胡起义)

这是一款操作简单、轻松上手的小游戏,全程仅需鼠标或触屏(支持移动端)。玩家将扮演一位发动叛变的船长,逐一挑战并击败 13 名手下。在冒险途中,还会遇到商人、船匠、想加入你的船员,助你一举登顶海上霸主之位。

游戏中的骰子“货物”至关重要——所有骰子的点数总和既代表你的总 HP(生命值),也决定了你在战斗中能造成的伤害。然而,务必小心避开“13”这个不祥的数字,否则可能招致不利后果。

试玩:play.js13kgames.com/coup-ahoo/

源码:github.com/js13kGames/…

1.3 Ghosted(幽灵)

这是一款类似“推箱子”的解谜游戏,共有 12 个关卡,通关后还会解锁一个隐藏关卡(第 13 关)。玩家将扮演一个外星人,目标是收集地图上的所有金币。有趣的是,只有在幽灵状态下才能收集金币,但在幽灵状态下无法推动石块,也不能原路返回。

操作说明:

  • 移动:方向键 ↑ ↓ ← →
  • 暂停:ESC 或回车
  • 重新开始:R 键
  • 撤回:Z 键或 Delete 键

试玩:play.js13kgames.com/ghosted/

源码:github.com/js13kGames/…

二、十三世纪

2023 年的主题是「13th Century」(十三世纪),满满的怀旧骑士风扑面而来,让我们看看有哪些有趣的小游戏吧!

2.1 Path to Glory(光荣之路)

这是一款制作精良的格斗游戏,拥有令人印象深刻的操作手感与战斗体验。你将扮演一名孤独的士兵,迎战一波又一波的敌人,直至最终 Boss 登场。

游戏细节丰富:角色进入水中会被减速,风、雨、摇曳的草丛与闪电等环境元素为战场增添了沉浸感。击败敌人时的慢动作特写,更是令人热血沸腾。视野范围的控制机制也极为实用,尤其适合 2D 动作砍杀类 roguelike 游戏,令人回味无穷。

试玩:play.js13kgames.com/path-to-glo…

源码:github.com/js13kGames/…

2.2 Casual Crusade(休闲十字军)

这是一款轻松有趣的休闲小游戏。你需要用手里的地块牌,一步步铺出一条通向所有土地的道路,顺手拿点战利品,强化自己的牌组。偶尔还能获得强力技能,让你的冒险之路更加精彩!

试玩:play.js13kgames.com/casual-crus…

源码:github.com/js13kGames/…

2.3 Knight Dreams(骑士之梦)

这是一款无尽的跑酷 2D 动作类游戏,特别是直升机头盔非常有趣。玩法简单,你只需要做两件事收集宝石、一直向前。

操作说明:

  • 移动:← → 或 AD
  • 跳跃/飞行:↑ 或 W
  • 空格:长枪攻击
  • 暂停:回车

试玩:play.js13kgames.com/knight-drea…

源码:github.com/js13kGames/…

2.4 Tiny Yurts(小小蒙古包)

这是一款受《Mini Motorways》启发的休闲策略游戏,将路径规划与资源管理巧妙结合。你只需用触摸或鼠标点击拖拽,为你的蒙古包(yurts)和各类农场铺设连接道路,让动物们幸福生活。轻松上手乐趣无穷,快来挑战吧!

游戏机制与小技巧:

  • 游戏支持随时暂停,利用暂停时间优化路线。
  • 起始农场自带的路径可以删除,自由调整布局。
  • 水面无法铺路,连接水中渔场需通过浮桥。
  • 路径距离影响效率,合理布局能更好地满足农场需求。
  • 定居者迷路时需重新铺路,帮助他们回家后才能继续工作。
  • 对角线路可节省路段,但移动时间会增加。
  • 不同动物对农场的需求不同,难度也会逐步提升。

试玩:play.js13kgames.com/tiny-yurts/

源码:github.com/js13kGames/…

三、最后

篇幅有限,今天的 7 款开源小游戏就先“开箱”到这里!在 13KB 的极限挑战下,它们的创意和完成度着实让人印象深刻。

希望这次的分享能让你找到心仪的小游戏,度过一段快乐时光。如果你也对这些“方寸之间显乾坤”的作品背后的技术实现感兴趣,别忘了它们都是开源的哦!好奇!仅 13kB 大小的游戏,源码长啥样?

哪款游戏最让你拍案叫绝?有没有哪一款让你一玩就停不下来?又或者,在这些游戏中发现了哪些让你眼前一亮的设计或技术?欢迎在评论区留言分享,一起交流这些 13KB 的奇迹!

☀️在cesium中使用动图的几种方式

作者 贾斯克
2025年5月20日 08:15

前言

最近在做cesium的时候遇到一个需求, 需要在地图上面展示动图。

经过查阅资料,大致实现方式如下:

  1. 通过第三方库将动图分解成每一帧
  2. 通过CallbackProperty方法,实现轮播每帧图片

网络的动图格式很多,在日常中主要使用的还是gif格式和apng格式。这两种,所以下面也主要介绍怎么在cesium中加载这两种动图点位。

gif图片

这边用libgif.js这个第三方库来帮助我们解析gif文件

引入libgif

libgif地址:libgif.js

这个库目前没有找到好用的库,只要用第三方引入的方式进行引入。

image-20250519210306663

将这个文件下载下来,在index.html中引入

image-20250519210353885

这里我准备了一张gif动图进行演示

sum.gif

image-20250519211609564

实现过程

根据官网的使用例子,我们需要一个img标签,并且new一个SuperGif

// 加载gif动图
const loadGifImg = () => {
  const map = getMap() // 获取地图对象
  let gifImg = document.createElement('img')
  gifImg.setAttribute('rel:animated_src', '/img/sum.gif')
  gifImg.setAttribute('rel:auto_play', '1')
  const imgDiv = document.createElement('div')
  imgDiv.appendChild(gifImg)
  const superGif = new SuperGif({ gif: gifImg })
}

上面的代码中,我创建了一个div和一个img并且设置了两个属性

其中rel:animated_src是必须的,SuperGif内部会读取这个属性

rel:auto_play则是是否自动播放,不加也没关系 最后new了一个superGif

 superGif.load(function () {
    map.entities.add({
      position: Cesium.Cartesian3.fromDegrees(120.9677706, 30.7985748),
      billboard: {
        image: new Cesium.CallbackProperty(() => {
          return superGif.get_canvas().toDataURL()
        }, false),
        scale: 0.25
      }
    })
  })

superGif有一个load事件表示加载完毕,像上面我加载完毕之后创建了一个点位实体,并且imgnew了一个CallbackProperty对象。

image-20250519212943954

完整代码

完整代码如下:

// 加载gif动图
const loadGifImg = () => {
  const map = getMap()
  let gifImg = document.createElement('img')
  gifImg.setAttribute('rel:animated_src', '/img/sum.gif')
  gifImg.setAttribute('rel:auto_play', '1')
  const imgDiv = document.createElement('div')
  imgDiv.appendChild(gifImg)
  const superGif = new SuperGif({ gif: gifImg })
  superGif.load(function () {
    map.entities.add({
      position: Cesium.Cartesian3.fromDegrees(120.9677706, 30.7985748),
      billboard: {
        image: new Cesium.CallbackProperty(() => {
          return superGif.get_canvas().toDataURL()
        }, false),
        scale: 0.25
      }
    })
  })
}

效果如下:

1.gif

apng图片

gif一样,apng也需要一个第三方库来进行解析

apng-js 是一个用于解析和渲染 APNG(动画 PNG)文件的 JavaScript 库。它提供了丰富的 API,可以让开发者完全控制 APNG 动画的播放、暂停、复位等操作。

这个库直接用npm下载就好了

安装依赖

// pnpm
pnpm add apng-js
// npm 
npm install apng-js

代码实现

  • 将图片文件转化为ArrayBuffer属性对象
 let blob = await fetch('/img/wind.png').then(res => res.blob())
  const reader = new FileReader()
  let canvas = document.createElement('canvas')
  let ctx = canvas.getContext('2d')
  reader.readAsArrayBuffer(blob)
  reader.onload = async () => {
    console.log('👉 ~ reader.onload= ~ reader.result:', reader.result)
 }

image-20250519214930428

  • 使用apng- js进行解析
import parseAPNG from 'apng-js'
 let apng = reader.result
let player = await apng.getPlayer(ctx)
player.play()
  • 创建图片点位
map.entities.add({
  position: Cesium.Cartesian3.fromDegrees(120.9677706, 30.7985748),
  billboard: {
    image: new Cesium.CallbackProperty(() => {
      return player.currentFrame.imageElement
    }, false)
  }
})

完整代码

// 加载apng动图
const loadApngImg = async () => {
  const map = getMap()
  let blob = await fetch('/img/wind.png').then(res => res.blob())
  const reader = new FileReader()
  let canvas = document.createElement('canvas')
  let ctx = canvas.getContext('2d')
  reader.readAsArrayBuffer(blob)
  reader.onload = async () => {
    let apng = parseAPNG(reader.result)
    let player = await apng.getPlayer(ctx)
    player.play()
    map.entities.add({
      position: Cesium.Cartesian3.fromDegrees(120.9677706, 30.7985748),
      billboard: {
        image: new Cesium.CallbackProperty(() => {
          return player.currentFrame.imageElement
        }, false)
      }
    })
  }
}

效果如下:

2

这边的apng图片,是我在网上别人的例子里面找到的。如果在用的过程中,有发现各种报错,可以检查一下图片的格式是不是不对

结尾

cesium中并没有办法直接使用动图,如果想要用动图或者视频,思路基本都是一样的,通过解析动图,得到每一帧图片,再通过更新图片的方式来实现动态。

520表白神器

2025年5月20日 07:55

大家好,我是晓凡

520来了,给大家做了一个浪漫的网页表白工具,帮助你向心爱的人表达爱意。

image-20250519215821562

需要源码的小伙伴直接跳转到文章末尾获取。

功能介绍

  • 浪漫启动页:显示相识天数和小时数,营造浪漫氛围

  • 动态情书系统:打字机效果展示情书内容,支持自定义内容

  • 趣味互动游戏

    • 爱心捕捉:点击飘动的爱心获取分数
    • 记忆拼图:上传照片创建拼图游戏
  • 时光相册墙:上传和展示珍贵照片,支持本地存储

  • 终极表白仪式:生成爱情证书,烟花特效,礼物二维码展示

项目配置

基本配置

编辑 js/config.js 文件,可以自定义以下内容:

const CONFIG = {
    // 初始相识日期(格式:年-月-日)
    firstMeetDate: '2023-05-20',
    
    // 收件人姓名
    recipientName: '未来的您',
    
    // 情书内容 - 可自定义多条消息
    loveLetters: [
        '从遇见你的那一刻起,我的世界就开始变得不一样。',
        // 可添加更多内容...
    ],
    
    // 爱情证书信息
    certificate: {
        lover1: '我的名字',
        lover2: '对方名字'
    },
    
    // 礼物二维码链接 - 可以是任何图片URL
    giftQRCodeUrl: '你的二维码图片URL',
    
    // 服务器端口
    serverPort: 3008
};

自定义音乐

assets/music/ 目录下放置MP3音乐文件,并在 config.js 中更新音乐列表:

musicList: [
    {
        title: '歌曲名称',
        artist: '歌手名',
        src: 'assets/music/你的音乐文件.mp3'
    }
]

自定义图片

  • 爱心图片:替换 assets/images/heart.png
  • 证书背景:替换 assets/images/certificate-bg.jpg
  • 玫瑰图片:替换 assets/images/rose.png

部署方法

本地部署

  1. 环境准备

    • 安装 Node.js(建议v14.0.0或更高版本)
  2. 启动服务器

    方法一:使用Node.js直接启动

    node server.js
    

    方法二:使用NPM启动

    npm start
    

    方法三:Windows用户可双击运行 start.bat 文件

  3. 访问网站

线上部署

  1. 使用云服务器

    • 将整个项目上传到云服务器

    • 安装Node.js环境

    • 使用PM2等工具保持服务运行:

      npm install -g pm2
      pm2 start server.js --name "love520"
      
  2. 使用Vercel/Netlify等静态网站托管

    • 注册VercelNetlify账号
    • 连接你的GitHub仓库或直接上传项目
    • 按照平台指引完成部署
  3. 使用GitHub Pages

    • 创建GitHub仓库并上传项目
    • 在仓库设置中启用GitHub Pages
    • 注意:需要修改服务器逻辑,改为纯静态网站

注意事项

  • 照片和数据保存在浏览器本地存储中,清除浏览器数据会导致数据丢失
  • 音乐自动播放可能受到浏览器策略限制,需要用户交互后才能播放
  • 为获得最佳体验,建议使用Chrome、Edge或Firefox最新版本浏览器

源码地址:pan.quark.cn/s/5a0dfc6ef… 提取码:23Hc

我是晓凡,再小的帆也能远航~

希望本篇文章能帮助到您~

我们下期再见 ヾ(•ω•`)o (●'◡'●)

vue3中的form表单层级嵌套问题

2025年5月19日 23:52

先上代码

parent.vue

<script setup>
import { ref, reactive } from "vue";
import TaskList from "./ChildForm.vue";

const formRef = ref();
const formData = reactive({
  projectName: "",
  manager: "",
  tasks: [],
});

const rules = reactive({
  projectName: [
    { required: true, message: "项目名称不能为空", trigger: "blur" },
    { min: 3, max: 50, message: "长度在3到50个字符", trigger: "blur" },
  ],
  manager: [{ required: true, message: "负责人不能为空", trigger: "change" }],
});

const validateTasks = (rule, value, callback) => {
  if (formData.tasks.length === 0) {
    callback(new Error("至少需要添加一个任务"));
  } else {
    callback();
  }
};

const submit = () => {
  formRef.value.validate((valid) => {
    if (valid) {
      console.log("提交数据:", formData);
    }
  });
};
</script>

<template>
  <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
    <el-form-item label="项目名称" prop="projectName">
      <el-input v-model="formData.projectName" />
    </el-form-item>

    <el-form-item label="负责人" prop="manager">
      <el-select v-model="formData.manager">
        <el-option label="张三" value="zhangsan" />
        <el-option label="李四" value="lisi" />
      </el-select>
    </el-form-item>

    <el-form-item prop="tasks" :rules="[{ validator: validateTasks }]">
      <TaskList v-model="formData.tasks" />
    </el-form-item>

    <el-button type="primary" @click="submit">提交</el-button>
  </el-form>
</template>

child.vue

<script setup>
import { ref, computed, reactive } from "vue";

const props = defineProps({
  modelValue: {
    type: Array,
    default: () => [],
  },
});

const emit = defineEmits(["update:modelValue"]);

const taskRules = reactive({
  name: [{ required: true, message: "任务名称不能为空", trigger: "blur" }],
  priority: [{ required: true, message: "请选择优先级", trigger: "change" }],
});

const tasks = computed({
  get: () => props.modelValue,
  set: (value) => emit("update:modelValue", value),
});

const addTask = () => {
  tasks.value.push({ name: "", priority: "medium" });
};

const removeTask = (index) => {
  tasks.value.splice(index, 1);
};
</script>

<template>
  <div class="task-list">
    <el-button @click="addTask">添加任务</el-button>

    <el-form
      v-for="(task, index) in tasks"
      :key="index"
      :model="task"
      :rules="taskRules"
      class="task-form"
    >
      <el-form-item prop="name">
        <el-input v-model="task.name" placeholder="任务名称" />
      </el-form-item>

      <el-form-item prop="priority">
        <el-select v-model="task.priority">
          <el-option label="高" value="high" />
          <el-option label="中" value="medium" />
          <el-option label="低" value="low" />
        </el-select>
      </el-form-item>

      <el-button @click="removeTask(index)">删除</el-button>
    </el-form>
  </div>
</template>

<style scoped>
.task-form {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 10px;
}
</style>

效果如下:

image.png

一、组件结构设计原理

  1. 数据流向设计父组件通过v-model实现数据双向绑定:
// ProjectForm.vue
const formData = reactive({
  tasks: [] // 自动同步到子组件
})

// TaskList.vue
const tasks = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v)
})

2. 验证责任划分

  • 父组件:验证任务列表非空
  • 子组件:验证单个任务字段

二、分步实现流程

步骤1:父组件基础验证

// ProjectForm.vue
const validateTasks = (_, __, callback) => {
  formData.tasks.length === 0 
    ? callback(new Error('至少需要1个任务')) 
    : callback()
}

步骤2:子组件独立验证

// TaskList.vue
const taskRules = {
  name: { 
    required: true, 
    trigger: 'blur',
    message: '任务名称必填' 
  }
}

步骤3:动态prop绑定

<el-form-item 
  :prop="`tasks[${index}].name`"
  :rules="taskRules.name">
  <el-input v-model="item.name"/>
</el-form-item>

三、验证联动机制

  1. 提交时联合验证
// ProjectForm.vue
const submit = () => {
  formRef.value.validate().then(() => {
    taskListRef.value.validate().then(() => {
      // 全部验证通过
    })
  })
}

2. 实时错误反馈

// TaskList.vue
watch(() => props.modelValue, () => {
  formRef.value?.validate()
}, { deep: true })

四、异常处理方案

// 统一错误捕获
try {
  await Promise.all([
    formRef.value.validate(),
    taskListRef.value.validate()
  ])
} catch (errors) {
  console.error('验证失败:', errors)
}

一文讲清什么是A记录,CNAME记录,NS记录?

2025年5月19日 23:40

什么是A记录(Address Record)

简而言之,A记录是域名到ip地址的映射关系

例如,如果有一个网站example.sun.com,可能需要将其指向服务器的IP地址192.0.1.1。在DNS配置中添加一条A记录,将example.sun.com指向192.0.1.1即可实现

什么是CNAME记录(Canonical Name Record

也称为别名记录。它允许您为一个域名创建别名。

例如,如果您设置了example.sun.com作为www.dns.com的CNAME,这意味着所有访问example.sun.com的请求实际上都会被路由到www.dns.com。

为什么需要CNAME

简化了DNS记录的管理

例如,如果有好多个域名同时指向一个ip地址

www.cc.com → 1.1.1.1
www.xx.com → 1.1.1.1
www.kk.com → 1.1.1.1

那麽。当换服务器ip的时候,就需要对每个域名重新配置

而如果用CNAME的话呢

www.cc.com → www.cxk.com → 1.1.1.1
www.xx.com → www.cxk.com → 1.1.1.1
www.kk.com → www.cxk.com → 1.1.1.1

那么就只需要改动www.cxk.com映射的ip地址

CNAME的应用场景是什么

CDN是CNAME的主要应用场景

随着网站访问量越来越多,服务器顶不住了,就需要找CDN提供商购买CDN加速服务,这个时候他们要求你的域名做个CNAME指向他们给你的一个域名叫www.dd.cdn.com

www.dd.com → www.dd.cdn.com

当用户访问www.dd.com的时候,本地DNS会获得CDN提供的CNAME域名:www.dd.com,然后再次向DNS调度系统发出请求,通过DNS调度系统的智能解析,把离客户端地理位置最近的(或者相对负载低的,主要看CDN那边智能解析的策略)CDN提供商的服务器IP返回给本地DNS,然后再由本地DNS回给客户端,让用户就近取到想要的资源(如访问网站),大大降低了延迟。

3d66c87c40b30616357b79c0dbe75ed.jpg

本地运营商的DNS服务器怎么知道一个域名的授权(权威)服务器是哪台?这个域名应该在哪里取解析呢?

首先公司会去找运营商买域名,比如CDN公司买了cdn.com这个一级域名,那么本地运营商会做一个NS记录,即匹配到这个cdn.com后缀的域名都会到CDN服务提供商的DNS服务器做解析,即到权威服务器做解析。

什么是 NS 记录?

NS 记录,即域名服务器记录,用于指定域名应该由哪个 DNS 服务器来进行解析。简单来说,当用户在浏览器中输入一个域名时,NS 记录会告诉互联网中的 DNS 查询系统,应该向哪个 DNS 服务器询问解析结果。NS 记录中的 IP 地址即为负责解析该域名的 DNS 服务器的 IP 地址。

参考文章

Step - 3

作者 烛阴
2025年5月19日 23:33

Task

Using the max and step functions, paint only those pixels whose normalized x-coordinate is less than 0.25 or greater than 0.75.

使用maxstep函数,仅绘制那些标准化坐标下 x 坐标小于0.25或大于等于0.75

Requirements

The shader should avoid using branching or conditional statements in its code, and instead rely on the step and max functions to determine the color of each pixel.

着色器应避免在其代码中使用分支或条件语句,而是依靠stepmax函数来确定每个像素的颜色。

Theory

GLSL 中的函数max用于返回两个输入值中的最大值。它接受两个参数,并返回两个值中较大的一个。以下是该max函数在 GLSL 中的详细解释:

函数

float max(float x, float y);

  • 如果x大于或等于y,则函数返回x
  • 如果y大于x,则函数返回y

示例用法

float a = 5.0;

float b = 3.0;

float result = max(a, b); // will be 5.0

Answer

uniform vec2 iResolution;

void main() {
  // Normalized pixel coordinates (from 0 to 1)
  vec2 uv = gl_FragCoord.xy / iResolution.xy;

  vec3 color = vec3(1.0, 0.3, 0.3);
  float t1 = 1.0 - step(0.25, uv.x);
  float t2 = step(0.75, uv.x);

  gl_FragColor = vec4(color * max(t1, t2), 1.0);
}

效果

image.png

练习

Step

最后

如果你觉得这篇文章有用,记得点赞、关注、收藏,学Shader更轻松!!

抛弃form中的rule验证,利用原生js,来实现表单的验证

2025年5月19日 23:33

先上代码

<script setup>
import { reactive, computed } from "vue";

const formData = reactive({
  student: {
    name: "",
    gender: "",
    age: null,
    isValid: computed(() => {
      return (
        formData.student.name &&
        formData.student.gender &&
        formData.student.age >= 6 &&
        formData.student.age <= 25
      );
    }),
  },
  parent: {
    name: "",
    phone: "",
    isValid: computed(() => {
      return (
        formData.parent.name && /^1[3-9]\d{9}$/.test(formData.parent.phone)
      );
    }),
  },
});

const errors = reactive({
  student: {
    name: "",
    gender: "",
    age: "",
  },
  parent: {
    name: "",
    phone: "",
  },
});

const validateStudentName = () => {
  if (!formData.student.name) {
    errors.student.name = "姓名不能为空";
  } else if (formData.student.name.length < 2) {
    errors.student.name = "姓名至少2个字符";
  } else {
    errors.student.name = "";
  }
};

const validateStudentAge = () => {
  if (!formData.student.age) {
    errors.student.age = "年龄不能为空";
  } else if (formData.student.age < 6) {
    errors.student.age = "年龄不能小于6岁";
  } else if (formData.student.age > 25) {
    errors.student.age = "年龄不能大于25岁";
  } else {
    errors.student.age = "";
  }
};

const validateParentPhone = () => {
  if (!formData.parent.phone) {
    errors.parent.phone = "手机号不能为空";
  } else if (!/^1[3-9]\d{9}$/.test(formData.parent.phone)) {
    errors.parent.phone = "请输入有效的手机号";
  } else {
    errors.parent.phone = "";
  }
};

const submit = () => {
  validateStudentName();
  validateStudentAge();
  validateParentPhone();

  if (formData.student.isValid && formData.parent.isValid) {
    alert("表单验证通过");
  } else {
    alert("请检查表单填写");
  }
};
</script>

<template>
  <form @submit.prevent="submit">
    <fieldset>
      <legend>学生基本信息</legend>

      <div class="form-group">
        <label for="student-name">学生姓名:</label>
        <input
          id="student-name"
          v-model="formData.student.name"
          @blur="validateStudentName"
          placeholder="请输入学生姓名"
        />
        <span class="error">{{ errors.student.name }}</span>
      </div>

      <div class="form-group">
        <label for="student-gender">学生性别:</label>
        <select id="student-gender" v-model="formData.student.gender">
          <option value="">请选择性别</option>
          <option value="male"></option>
          <option value="female"></option>
        </select>
        <span class="error">{{ errors.student.gender }}</span>
      </div>

      <div class="form-group">
        <label for="student-age">学生年龄:</label>
        <input
          id="student-age"
          type="number"
          v-model.number="formData.student.age"
          @blur="validateStudentAge"
          placeholder="6-25岁"
        />
        <span class="error">{{ errors.student.age }}</span>
      </div>
    </fieldset>

    <fieldset>
      <legend>家长联系信息</legend>

      <div class="form-group">
        <label for="parent-name">家长姓名:</label>
        <input
          id="parent-name"
          v-model="formData.parent.name"
          placeholder="请输入家长姓名"
        />
        <span class="error">{{ errors.parent.name }}</span>
      </div>

      <div class="form-group">
        <label for="parent-phone">联系电话:</label>
        <input
          id="parent-phone"
          v-model="formData.parent.phone"
          @blur="validateParentPhone"
          placeholder="11位手机号码"
        />
        <span class="error">{{ errors.parent.phone }}</span>
      </div>
    </fieldset>

    <button type="submit">提交表单</button>
  </form>
</template>

<style scoped>
form {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}
fieldset {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 15px;
  margin-bottom: 20px;
}
legend {
  padding: 0 10px;
  font-weight: bold;
}
.form-group {
  margin-bottom: 15px;
}
label {
  display: inline-block;
  width: 100px;
  text-align: right;
  margin-right: 10px;
}
input,
select {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  width: 200px;
}
.error {
  color: red;
  font-size: 12px;
  margin-left: 110px;
  display: block;
}
button {
  padding: 10px 20px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:hover {
  background: #369f6b;
}
</style>

1747669149520.jpg

本文将基于提供的学生信息表单代码,详细讲解如何使用Vue3原生方式实现表单验证功能。这个实现方案不依赖任何第三方验证库,完全使用Vue3的响应式特性和原生JavaScript实现。

表单验证的核心设计

1. 数据结构设计

表单数据使用Vue3的reactive创建响应式对象,分为学生信息和家长信息两个部分:

const formData = reactive({
  student: {
    name: "",
    gender: "",
    age: null,
    isValid: computed(() => { /* 验证逻辑 */ })
  },
  parent: {
    name: "",
    phone: "",
    isValid: computed(() => { /* 验证逻辑 */ })
  }
})

每个字段都有对应的isValid计算属性,用于实时判断该部分数据是否有效。

2. 错误信息管理

单独定义errors对象来存储验证错误信息:

const errors = reactive({
  student: {
    name: "",
    gender: "",
    age: "",
  },
  parent: {
    name: "",
    phone: "",
  }
})

这种结构与表单数据保持一致的层级关系,便于管理和访问。

验证函数实现

1. 学生姓名验证

const validateStudentName = () => {
  if (!formData.student.name) {
    errors.student.name = "姓名不能为空";
  } else if (formData.student.name.length < 2) {
    errors.student.name = "姓名至少2个字符";
  } else {
    errors.student.name = "";
  }
};

验证逻辑:

  • 非空检查
  • 最小长度检查
  • 验证通过时清空错误信息

2. 学生年龄验证

const validateStudentAge = () => {
  if (!formData.student.age) {
    errors.student.age = "年龄不能为空";
  } else if (formData.student.age < 6) {
    errors.student.age = "年龄不能小于6岁";
  } else if (formData.student.age > 25) {
    errors.student.age = "年龄不能大于25岁";
  } else {
    errors.student.age = "";
  }
};

验证逻辑:

  • 非空检查
  • 最小值检查
  • 最大值检查
  • 验证通过时清空错误信息

3. 家长手机号验证

const validateParentPhone = () => {
  if (!formData.parent.phone) {
    errors.parent.phone = "手机号不能为空";
  } else if (!/^1[3-9]\d{9}$/.test(formData.parent.phone)) {
    errors.parent.phone = "请输入有效的手机号";
  } else {
    errors.parent.phone = "";
  }
};

验证逻辑:

  • 非空检查
  • 正则表达式验证手机号格式
  • 验证通过时清空错误信息

表单提交处理

const submit = () => {
  validateStudentName();
  validateStudentAge();
  validateParentPhone();

  if (formData.student.isValid && formData.parent.isValid) {
    alert("表单验证通过");
  } else {
    alert("请检查表单填写");
  }
};

提交时执行所有验证函数,并检查各部分数据的isValid状态。

模板结构

模板使用标准的HTML表单元素,结合Vue指令:

<form @submit.prevent="submit">
  <fieldset>
    <legend>学生基本信息</legend>
    <!-- 表单字段 -->
  </fieldset>
  
  <fieldset>
    <legend>家长联系信息</legend>
    <!-- 表单字段 -->
  </fieldset>
  
  <button type="submit">提交表单</button>
</form>

每个表单字段都绑定到对应的数据属性和验证函数:

<input
  id="student-name"
  v-model="formData.student.name"
  @blur="validateStudentName"
  placeholder="请输入学生姓名"
/>
<span class="error">{{ errors.student.name }}</span>

样式设计

样式部分使用scoped CSS,确保只影响当前组件:

.form-group {
  margin-bottom: 15px;
}
.error {
  color: red;
  font-size: 12px;
  margin-left: 110px;
  display: block;
}
/* 其他样式... */

实现优势

  1. 响应式验证‌:利用Vue3的响应式系统,数据变化自动触发界面更新
  2. 即时反馈‌:通过@blur事件在用户离开输入框时立即验证
  3. 结构化设计‌:数据和错误信息采用相同结构,便于维护
  4. 计算属性‌:使用computed实现自动验证状态计算
  5. 原生实现‌:不依赖第三方库,减少项目体积和依赖

我开源了一个基于 Tiptap 实现一个和功能丰富的协同编辑器 🚀🚀🚀

作者 Moment
2025年5月19日 22:35

一个基于 TiptapNext.js 构建的现代化协同文档编辑器,集成了丰富的编辑能力与多人实时协作功能,支持插件扩展、主题切换与持久化存储。适合团队写作、教育笔记、在线文档平台等场景。

无论你是想学习或者想参与开发,你都可以添加我微信 yunmz777,我拉你进交流群中进行学习交流,我们还有很多其他不同的开源项目。

近期开始准备出一个 前端工程化实战 类的课程,如果你对前端技术迷茫,那么学习前端工程化是最好的一个进阶方案,以下是相关的实战内容大纲:

20250519222445

如果你感兴趣想参与的,可以添加我微信进行更详细的了解。

🚀 功能特性

  • 📄 富文本编辑:标题、列表、表格、代码块、数学公式、图片、拖拽等

  • 👥 实时协作:使用 Yjs + @hocuspocus/provider 实现高效协同

  • 🧩 插件丰富:基于 Tiptap Pro 多种增强功能(如表情、详情组件等)

  • 🧰 完善工具链:支持 Prettier、ESLint、Husky、Vitest 等开发工具

📦 技术栈

前端技术栈

技术 说明
Next.js 构建基础框架,支持 SSR / SSG
Tiptap 富文本编辑器,基于 ProseMirror
Yjs 协同编辑核心,CRDT 数据结构
@hocuspocus Yjs 的服务端与客户端 Provider
React 19 UI 框架,支持 Suspense 等新特性
Tailwind CSS 原子化 CSS,集成动画、表单样式等
Socket.io 协同通信通道
Prettier/ESLint 代码风格统一
Vitest/Playwright 单元测试与端到端测试支持

20250519183256

后端技术栈

分类 技术 / 工具 说明
应用框架 NestJS 现代化 Node.js 框架,支持模块化、依赖注入、装饰器和类型安全等特性
HTTP 服务 Fastify 高性能 Web 服务引擎,替代 Express,默认集成于 NestJS 中
协同编辑服务 @hocuspocus/server, yjs 提供文档协同编辑的 WebSocket 服务与 CRDT 算法实现
数据库 ORM Prisma 类型安全的数据库访问工具,自动生成 Schema、支持迁移与种子数据
数据验证 class-validator, class-transformer 请求数据验证与自动转换,配合 DTO 使用
用户鉴权 @nestjs/passport, passport, JWT, GitHub 支持本地登录、JWT 认证与 GitHub OAuth 登录
缓存与状态 ioredis 用于缓存数据、实现限流、协同会话管理或 Pub/Sub 消息推送
对象存储 minio 私有化部署的 S3 兼容存储服务,支持图片与附件上传
图像处理 sharp 图像压缩、格式转换、缩略图等操作
日志系统 winston, winston-daily-rotate-file 支持多种格式、日志分级、自动归档的日志方案
服务监控 @nestjs/terminus, prom-client 提供 /health 健康检查和 /metrics Prometheus 指标暴露接口
监控平台 Prometheus, Grafana 采集与可视化服务运行指标(已内置 Docker 部署配置)
接口文档 @nestjs/swagger 基于代码注解自动生成 Swagger UI 文档
安全中间件 @fastify/helmet, @fastify/rate-limit 添加 HTTP 安全头部、限制请求频率、防止暴力攻击等安全保护
文件上传 @fastify/multipart, @webundsoehne/nest-fastify-file-upload 支持文件流式上传,集成 Fastify 与 NestJS 的多文件上传处理

20250519183049

🚀 快速开始

1. 克隆仓库

git clone https://github.com/xun082/DocFlow.git
cd DocFlow

安装依赖

建议使用 pnpm:

pnpm install

启动本地开发环境

pnpm dev

如何部署

确保已安装以下环境:

  • Docker

  • 推荐:Linux/macOS 或启用 WSL 的 Windows 环境

1️⃣ 构建镜像

docker build -t doc-flow .

2️⃣ 启动容器

docker run -p 6001:6001 doc-flow

启动完成之后访问地址:

http://localhost:6001

🔧 常用脚本

脚本命令 作用说明
pnpm dev 启动开发服务器
pnpm build 构建生产环境代码
pnpm start 启动生产环境服务(端口 6001)
pnpm lint 自动修复所有 ESLint 报错
pnpm format 使用 Prettier 格式化代码
pnpm type-check 运行 TypeScript 类型检查
pnpm test 启动测试(如配置)

🧰 开发规范

  • 使用 Prettier 和 ESLint 保证代码风格统一

  • 配置了 Husky + lint-staged 进行 Git 提交前检查

  • 使用 Commitizen + cz-git 管理提交信息格式(支持语义化发布)

初始化 Git 提交规范:

pnpm commit

📌 未来规划(Roadmap)

项目目前已具备基础协作编辑能力,未来将持续完善并拓展更多功能,进一步提升产品的实用性与专业性:

✅ 近期目标

  • 完善现有功能体验

    • 优化协同冲突解决策略
    • 更细粒度的权限管理(只读 / 可评论 / 可编辑)
    • 增强拖拽体验与文档结构导航(大纲视图)
  • 增强文档组件系统

    • 重构基础组件体系:标题、表格、代码块等更智能、模块化
    • 增加工具栏、快捷键提示和 Markdown 快速输入支持
  • 丰富文档类型与节点支持

    • 支持更多 自定义 Tiptap 节点,如:

      • 引用评论块(Comment Block)
      • 自定义警告框 / 提示框(Tip/Warning)
      • UML/流程图嵌入(如支持 Mermaid)
      • 数据展示组件(如 TableChart、Kanban)

🚀 中期目标

  • 引入音视频实时会议能力

    • 集成 LiveKitDaily 实现嵌入式音视频会议
    • 支持多人语音 / 视频通话,结合文档协同,提升远程会议效率
    • 集成会议内共享笔记区、AI 摘要、会议录制等功能
  • 集成 AI 能力

    • 智能语法纠错、改写建议
    • 语义搜索与问答(支持上下文理解)
    • AI 总结 / 摘要生成
  • 多平台同步支持

    • PWA 支持,适配移动端和桌面离线编辑
    • 跨设备自动同步与版本恢复

🧠 长期方向

  • 插件生态系统建设

    • 引入用户可安装的第三方插件体系
    • 提供插件开发文档与市场入口
  • 文档协作平台化

    • 支持文档团队空间、多人组织结构
    • 文档看板与团队活动看板集成
  • 权限与审计系统

    • 支持操作日志记录、文档编辑历史审查
    • 审批流、编辑建议、协同讨论区等功能

License

本项目采用 MIT 开源协议发布,但包含部分 Tiptap Pro 模板代码除外

Tiptap Pro 模板版权归 Tiptap GmbH 所有,并根据 Tiptap Pro 授权协议进行授权。
详见:tiptap.dev/pro/license

如需使用本项目中涉及 Tiptap Pro 的部分,必须拥有有效的 Tiptap Pro 订阅授权。

📬 联系方式

有更多的问题或者想参与开源,可以添加我微信 yunmz777,我们这还有很多开源项目:

20250519222610

昨天 — 2025年5月19日掘金 前端

「译文」我买不起奔驰 🚗,但是奔驰买得起 Oxlint ⚓️ 和 Rolldown ⚡️!

2025年5月19日 21:08

前言

今天刷推特看到的一篇博客,作者是奔驰技术团队!

尤雨溪转发的推特

往期精彩推荐

正文

在当今快速发展的 JavaScript 生态系统中,现代工具发挥着关键作用,不仅影响我们的编码方式,还影响我们花费在编码上的时间和金钱。

JavaScript 生态系统中工作时,我们可以利用各种工具来提升质量、一致性和效率。今天,我们将重点介绍两款出色的工具,它们在实现重大里程碑的同时,仍然是大多数用例的直接替代品。

我们将讨论 捆绑器代码检查器,因为它们可能会成为开发或生产构建的瓶颈。

JavaScript 开发中,捆绑器 采用单个代码模块(无论是以 ESM 还是 CommonJS 编写),并将它们组装成一个有凝聚力的、更大的输出,例如完整的应用程序或可重用的库。

对于 Web 应用而言,即使使用 HTTP/2,此过程也能提升加载速度和运行时效率。对于库而言,提前打包可以减少使用方项目重新打包的需要,并提升执行性能。

相反,JavaScript 中的 linting 涉及检查代码以捕获潜在的错误、风格不一致以及不良的编码习惯。linter 是一种根据一组定义的规则或标准扫描代码库的工具,它有助于确保代码一致性、增强可读性,并在常见错误成为实际问题之前将其预防。

它是如何变化的?

多年来,各种构建工具层出不穷,包括 WebpackTurbopackRspackVite。其中,Vite 凭借其灵活性、用户友好性以及与各种项目的无缝兼容性,在 JavaScript 社区中得到了广泛的采用。

Vite 底层使用了两个打包器:esbuildRollup。您可以在这里找到更多详细信息,因此我们在此不再赘述。Vite 优秀的开源团队在 VoidZero 的支持下,推出了一款基于 Rust 的全新打包器,旨在取代 esbuildRollup。这款新的打包器保留了 Rollup 的 API,同时显著提升了性能并进行了许多其他改进。

在代码检查方面,ESLint 一直以来都是 JavaScript 代码中发现问题的首选工具。然而,VoidZero 也推出了一款基于 Rust 的替代方案 Oxlint,它的性能得到了提升,运行速度比 ESLint 快 50 到 100 倍。

补充一点,需要记住的是,仅仅迁移到 Rust 并不会自动提升速度。许多此类工具也借此机会考察了其他开源项目的架构,找出了设计瓶颈,并根据我们目前所了解的现实情况,为未来做出更可持续的决策。

它对我们有何影响?

这些新工具更令人印象深刻的是,它们可以直接作为替代品。无需重构代码,也无需花时间思考如何集成所有功能。

Mercedes-Benz.io,我们的前端本质上是微前端,所以我们无法仅仅测试完整构建版本并查看其改进程度。尽管如此,我仍然好奇这些工具会给我们带来多少时间和金钱上的影响,而金钱不一定是机器时间,主要是工程时间。

我从一些团队中挑选了几个代码库,一些团队拥有较多的微前端,而另一些团队拥有较少的微前端,并运行了 4 种类型的测试:

  1. 使用 Rolldown 进行直接替换
  2. 使用 SASS-EmbeddedRolldown 进行直接替换
  3. 使用 SASS-Embedded + Lightning CSSRolldown 进行直接替换
  4. 使用 Oxlint 替代 ESLint

由于我已经在测试它,所以我决定采用两个可用于生产的替代品,如上所示。

Sass-EmbeddedSass 包的替代方案。它支持与 Sass 相同的 JS API,并且由同一团队维护。然而,Sass-Embedded 是一个 JavaScript 封装器,封装了原生 Dart 可执行文件。这意味着它通常速度更快,尤其是在处理大型 Sass 编译时。

LightningCSS 是一款用 Rust 编写的超快速 CSS 解析器、转换器、打包器和压缩器。它是一款能够编译、优化和转换 CSS 的工具,旨在比其他传统工具更快、更高效。

让我们来看看数字

在您测试的所有代码库中,平均结果显示:

  1. 仅使用 Rolldown,构建时间减少了 23%,最高可达 33%。
  2. 使用 Rolldown + Sass-Embedded,构建时间减少了 25%,最高可达 33%。
  3. 使用 Rolldown + Sass-Embedded + Lightning CSS,构建时间减少了 34%,最高可达 38%。

在 linting 方面,转向 Oxlint 后,平均减少了 71%,最高减少了 97%。

这只是这些工具的开始。Rolldown 仍在开发中,但已经经过社区的大量测试,而 Oxlint 目前处于 Beta 阶段。这两款工具都计划进行更多改进。

这真是太神奇了:我们谈论的不是重构代码,也不是花时间理解和尝试适应新工具。我们谈论的是一些可以让我们的 CI 流程减少 64% 的嵌入式替换(仅指构建 + linting)。

假设大约有 100 个项目,每个项目平均每年有 600 次提交,每次提交都需要构建和 lint。这意味着每年可以节省大约 500 个小时,差不多 21 天。由于机器成本难以计算,因此不计入机器成本,我们可以想象工程成本,包括生产力、专注度,以及能否顺利进入流程。

这种节省不仅能显著提高开发效率,还能让团队有更多时间专注于功能开发和创新,从而提升整体项目的质量和响应速度。

最后

原文地址:www.mercedes-benz.io/blog/2025-0…

原推特地址:x.com/boshen_c/st…

往期精彩推荐

一次痛苦的内存泄露排查经历

2025年5月19日 20:36

前言

这阵子接手的一个需求就是排查项目是否存在内存泄露问题,找到并解决它

目前是发现了四处问题,一处是 el-select 组件存在泄露问题,这个有做出挣扎解决,但是最终并没有彻底解决,无果;一处是项目封装的 v-delegate 指令存在闭包问题,这个最后是组长帮忙发现的,问题很隐蔽;另一个是有个 timer 没有 clear。最后一个还是闭包,这个就是本篇要讲的,排查过程比较煎熬

前端排查内存泄漏还是非常痛苦的,尤其是面对复杂项目

组里的项目是 黑盒语音 客户端,就是大家熟知的小黑盒旗下的一款语音产品,语音项目本身就对内存占用要求比较苛刻

其实排查内存泄漏,定位到是哪些交互其实还好,痛苦的是找到了交互过后,如何定位到具体代码,这次就是一个异步 + 闭包导致的内存泄露问题,开篇前先介绍下如何利用 performancememory 选项卡定位可疑的交互

如何排查(先 performancememory

该项目是 electron + vue2

比如这次碰到的问题是,语聊房的房间设置页面存在多个 tab,我针对所有 tab 依次往下点击然后回到最初的第一个 tab 看 performance 是否存在内存上涨

这里最好记得先把所有的 tab 点击加载一遍,防止有动态加载的组件存在,或者是缓存,异步函数等等。总之最好第一次先点击加载消除某些不可控的影响

这里提一嘴,排查内存泄漏最好是打包之后,或者保证自己的项目不存在 console.log(感觉手动剔除也不现实) ,因为你开启了 console 台后,log 会保存你的变量,这些变量会留在 windows 中导致内存泄露,打包之后一般 tree-shaking 会帮我们将项目的 log 给自动删除

好,现在我们来利用 performance 进行排查

从 第一个 tab 开始切换下面的所有 tab,然后回到第一个 tab

1.gif

在录制之前保证点击了所有的 tab 后,开始录制时记得勾选 memory ,这个就是我们要看的 内存 信息,交互前先点击 🧹 图标,最后回到第一个 tab 后结束录制前也记得 点击 🧹 图标

最后我们来注意整个交互区间的 Nodes 上下浮动范围,从图中可以看出,节点从最初的 7000 个增长到了 8000 个左右,明显有内存泄露问题

这也就意味着这么多 tab ,存在一个或者多个导致了内存泄漏,正常来讲我回到页面最初起点,页面的 dom 数量也只会是最初的,增长了也就意味着 dom 可能被某些数据引用了,成了游离 dom,前端内存泄漏最大的问题其实就是游离 dom

接下来的分析方向就比较清晰了,我们需要排查究竟是哪个 tab 导致了内存泄露,后面的定位会比较繁琐,因为你要挨个排查,挨个排查你就得控制好变量,比如我怀疑第一个 tab,那么我就需要将其余 tab 的组件代码的 代码(template + script) 清空,然后针对第一个 tab 来回切换

中间的步骤这里不会展示,这里直接说结论了,就是第一个 tab 有问题,为了再次证明是这个 tab 的问题,我们接下来可以利用 memory 选项卡进行内存快照分析

为了有一个 tab 可以辅助切换,但是又不能有这个 tab 的影响,我就需要将其 template + Script 部分代码置空,比如这里我将 第二个 tab 代码置空

我们现在进行 memory 分析

2.gif

可以看到第一个tab在与第二个空tab来回频繁切换 10 次后,js heap 上涨了将近 20M,这还是非常恐怖的数据

这个时候我们就可以去定位到是第一个 tab 这个组件的问题所在了,当然我们其实还是可以去继续留意 memory 的变化

1.png

我们会发现有两个 很奇怪的 constructor 增长了,一个 t 一个 a,我将这个项目放到 web 上去观察反而没有这两个变量,我们随机展开一个 t 看内部结构

2.png

会发现这应该是 vnode,这应该是因为 electron 跑的是代码压缩后的结果,还有个 a 应该是 VueComponent

其实内存快照的 comparison 这里也只有 t 好去分析,也就是 vnode,因为一个 vnode 会对应一个 dom 节点,我们可以看看究竟是哪里多出的 游离节点

其实当我们定位到某个组件的时候,我们还需要进一步分析,因为有些组件可能是由多个封装组件进一步封装的,或者会有多个同级 div,这个时候我们又需要去做一个控制变量分析,依次保留当前组件的某部分 template 然后去拍 内存快照,有时候可能是 js 问题,那又要控制 js 代码。

因为第一个 tab 组件比较复杂,所以这一步废了挺多时间去排查某个具体部分

其实定位到组件的时候,我们可以先目测观察下当前组件是否存在一些没有 off 掉的事件或者没有 clear 的定时器,然而事实却是 on 的事件都有对应的 off ,定时器也都有 clear,这就加大了排查难度

最后是定位到了一个 异步 methods,这个 methods 大致如下

3.png

其实这个 initData 中间还有很多逻辑,这里只展示了重点。乍这么一看好像也没啥问题,最后我定位的过程中,发现就是 judge 有问题,当我在 judge 中直接 return true 时没问题,只要一引用了 vuex 的值就会有泄露

这里的 channel_list 就是一个 vuexstore 值,我若是切换切得很快,这个 异步函数 在组件卸载时可能还没有执行完毕,后面的 initChannels 就会排队执行,这个 initChannels 里面的 judge 又是个 闭包函数,并且通过 vuex 引用了 thisvuex 本身就是全局唯一的状态管理库,这个值若牵扯到了 this,也就是 vue 实例,就会引起内存泄漏问题

所以怎么解决这个问题呢,我们可以在 await 后添加一个 逻辑,若组件卸载了就直接 return,不让继续执行后面的逻辑

4.png

这个泄露 bug 排查最后还是组长点醒我的

其实后面排查的过程中,因为用了组件库 element-ui,其中还有个 tab 用到了 el-select 组件,这个组件也存在内存泄露问题,好像 element-ui 但凡涉及到 popover 的组件都存在内存泄露问题,大家使用这个库的时候还是谨慎点

最后

当我们使用 performance 或者 memory 选项卡定位到了某个组件存在内存泄漏问题时,首先应该去判断组件是否存在某些事件没有清除,或者定时器没有 clear,这个是最重要的,若肉眼难以看出来,那就进一步去怀疑是否存在闭包导致的内存泄漏,然后去通过注释代码的方式去验证想法,过程还是非常麻烦的

再也不怕接口格式变来变去!用自定义请求钩子封死后端奇葩接口

2025年5月19日 19:37

在日常中后台开发中,我们经常需要实现各种数据列表页,包括查询条件表单、数据表格、分页等功能。然而,不同业务的列表页虽然样式和交互相似,但背后的后端接口格式可能千差万别。一旦后端接口格式发生变化,前端列表页的代码就可能需要大幅修改,给维护带来不少麻烦。本文将介绍一套通用的可复用列表页组件方案,通过灵活的配置和技巧来应对各种“奇葩”后端接口。

为什么需要通用的列表页组件?

在中后台项目中,列表页通常由查询条件表单和数据列表展示组成,是重复率很高的功能模块。如果每个列表页都各自实现,会产生大量重复代码,也不利于统一维护。构建一个通用的列表页组件可以带来诸多好处:

  • 减少重复代码:将列表页的通用逻辑(如分页处理、表格渲染、查询表单)封装起来,在不同页面复用,避免每次从零编写。
  • 统一交互与样式:统一列表页的查询交互(如“展开/收起”高级查询)、表格样式和空数据提示等,提升系统的一致性。
  • 应对需求变更:当需要对列表页功能做修改(比如新增导出按钮、调整分页逻辑)时,只需在组件内部修改一次,所有使用该组件的页面都会同步更新。
  • 屏蔽后端差异:通过配置来适配不同后端接口的请求和响应格式,列表组件内部消化这些差异,页面使用方无需感知接口的特殊性。

综上,封装通用列表页组件既是工程复用的需要,也是提高开发效率和代码健壮性的有效手段。

requestConfig.buildPayload:适配不同后端接口格式

后端的列表接口往往有不同的请求入参规范。例如,有的接口期望请求体直接提供查询条件,有的则要求将查询条件嵌套在 modelpageBean.model 下,还有的分页参数字段名各不相同。如果我们在每个页面手动拼装不同格式的请求,无疑增加了重复劳动和出错概率。

解决方案:在通用列表页组件中引入 requestConfig.buildPayload() 钩子,用于根据统一的查询参数对象构建不同格式的请求载荷。组件对外暴露 requestConfig 配置,使用方可以传入自定义的 buildPayload 函数来自定义请求格式:

// 使用通用列表组件时传入配置
<CommonList
  :requestConfig="{
    url: '/api/getListData',
    method: 'POST',
    // 自定义请求载荷构建逻辑
    buildPayload: (queryParams, pagination) => {
      // 例:将查询参数包裹在 pageBean.model 中,并添加分页信息
      return {
        pageBean: {
          page: pagination.currentPage,
          size: pagination.pageSize,
          model: queryParams
        }
      }
    }
  }"
  ... />

在组件内部,每当需要发起列表请求时,会调用 requestConfig.buildPayload(formData, pagination) 来获取最终的请求体。例如:

  • 直接使用 model 包装:某些接口希望所有查询条件都放在 model 字段下,那么 buildPayload 可以返回 { pageNum, pageSize, model: { ...查询条件 } }
  • 嵌套在 pageBean.model:对于要求分页信息和查询条件一起封装的接口,则返回 { pageBean: { page, size, model: { ...查询条件 } } }
  • 无特殊包装:如果后端直接接受平铺的查询参数,那么 buildPayload 直接返回 { page, size, ...查询条件 } 即可。

通过这种钩子机制,我们实现了请求格式的适配层。无论后端接口多么“奇葩”,我们都能在不改动组件核心代码的前提下,通过定制 buildPayload 轻松应对。这极大提升了组件的适应性,也让接口变更对前端的影响降到最低。

优雅实现查询表单的展开/收起

高级查询条件往往很多,我们通常提供“展开/收起”按钮来在界面上隐藏部分条件。当用户点击“展开”时显示所有字段,“收起”则只显示基础字段。如何实现这个功能,同时保证表单字段的状态不丢失,是我们要解决的关键问题。

常规做法的问题

一些常见但不理想的实现方式包括:

  • 替换表单规则:通过切换不同的表单字段列表(form rule)来控制显示哪些字段。例如收起时使用一套精简字段数组,展开时替换成完整字段数组。然而这样做会导致组件的销毁和重建,已填写的数据会丢失。尤其是在使用表单生成器如 form-create 时,动态增删字段会重置部分已选值。频繁切换规则也增加了实现复杂度。
  • v-if 条件渲染:对每个可收起字段加上 v-if="showAll" 来决定渲染与否。这种方式同样会在收起时移除 DOM 元素,字段状态可能会丢,而且需要在展开时重新挂载组件。类似地,表单验证状态也会被重置。

用隐藏属性控制显示

推荐做法是利用隐藏属性来控制字段显隐,而非移除节点或替换整个规则。在 form-create 中,我们有两种隐藏方式:

  • 隐藏字段(无 DOM) :通过调用 fApi.hidden(true, fieldName) 可以隐藏指定字段,隐藏后完全不渲染对应的组件,DOM 节点将移除。这样做适合初始就不需要渲染大量高级字段的场景,减少 DOM 开销。但要注意,字段隐藏后表单验证也不会触发。
  • 隐藏组件(保留 DOM) :调用 fApi.display(false, fieldName) 则会将组件通过 CSS 隐藏(display:none),但组件实例仍然保留在 DOM 中。优点是字段的绑定值和验证状态都不会丢失,再次显示时能保持原有状态。

在实际实现中,我们可以结合两种方式。例如初始进入页面时将高级字段使用 hidden 隐藏以减轻渲染负担;当用户点击“展开”按钮时,再将这些字段用 display 显示出来。收起时,则仅隐藏(display:none)而不销毁组件,以便保留用户可能已输入的内容。

具体代码逻辑示例:

data() {
  return {
    showAll: false,              // 控制展开/收起的状态
    advancedFields: ['age', 'address', 'company']  // 需要隐藏的高级查询字段name列表
  }
},
methods: {
  toggleFields() {
    this.showAll = !this.showAll;
    if (this.showAll) {
      // 展开:显示所有字段
      this.fApi.display(true, this.advancedFields);
    } else {
      // 收起:隐藏高级字段(保留其值和状态)
      this.fApi.display(false, this.advancedFields);
    }
  }
}

通过这种方式,“展开/收起”查询表单非常流畅:组件状态不重建不重置,用户在高级字段中已输入的值在收起后虽然不可见,但再次展开时还能看到,避免了反复输入。同时,隐藏的字段也不会影响布局,表单其余部分不会因为移除节点而闪烁。

隐藏字段的查询与重置处理

实现字段隐藏后,还需要处理两个细节问题:查询时隐藏字段不参与、重置时隐藏字段也要处理。否则可能出现隐藏字段的值误参与查询,或重置操作无法清空隐藏字段等情况。

跳过隐藏字段参与查询

当用户收起高级查询后,再点击查询按钮时,我们不应将隐藏字段的值提交给后端,否则会造成意外的筛选。即使之前用户在高级字段填过值,收起状态下也应视为暂不使用。为此,可以在构造请求参数时过滤掉所有当前隐藏的字段:

const formData = this.fApi.formData();  // 获取表单所有字段的当前值
for (const field of this.advancedFields) {
  if (!this.showAll) {
    // 收起状态下,直接移除高级字段的参数
    delete formData[field];
  }
}
const payload = this.requestConfig.buildPayload(formData, this.pagination);

如上,我们利用 fApi.formData() 获取所有字段的值,然后根据 showAll 状态剔除 advancedFields 列表中的字段。这样生成的查询参数就只包含可见的查询条件,保证后台只按用户期望的条件筛选数据。

当然,更严谨的做法是利用 form-create 提供的 hiddenStatus 接口动态判断字段是否隐藏:

Object.keys(formData).forEach(field => {
  if (this.fApi.hiddenStatus(field)) {
    delete formData[field];  // 隐藏状态则剔除
  }
});

这在多处使用隐藏字段的场景下更加通用。

确保重置清空所有字段

点击“重置”按钮或执行表单清空时,我们期望所有查询条件都被清空,包括那些当前隐藏的高级字段。然而,如果直接使用 Element UI 提供的 this.$refs.form.resetFields() 或 form-create 的 fApi.resetFields(),需要注意默认行为是否覆盖隐藏字段。

在 form-create 中,fApi.resetFields() 会重置表单的所有字段值(也可以选择特定字段)。但是这里的“重置”往往是指恢复初始值:如果某些字段设置了初始值,reset 后会回到初始值而非空。因此,为实现“彻底清空”,我们可能需要做额外处理:

  • 未设置初始值的字段:reset 后本来就是空的,可直接使用 resetFields() 清空。
  • 有默认初始值的字段:reset 会回到默认值,而我们希望清空为“无”。对于这类字段,可以在重置后调用 setValue 将其设为空字符串或空数组等。
  • 隐藏字段:确保在重置时也包含隐藏字段。一种简单方式是直接调用 fApi.resetFields() 不传参,让它重置所有字段。如果我们之前对隐藏字段做了剔除查询等操作,resetFields 仍会把它们复位到初始值。若想完全清除其值,可以在 resetFields 之后再主动将高级字段对应的值设置为 ''undefined

综合考虑,最佳实践是封装一个清空方法,既使用 resetFields 恢复默认,又针对有默认值或特殊需求的字段做定制处理。例如:

resetAllFields() {
  // 重置所有字段到初始值
  this.fApi.resetFields();
  // 清空隐藏字段的值(覆盖默认初始值的情况)
  this.advancedFields.forEach(field => {
    this.fApi.setValue(field, '');  // 将值设为空(或相应类型的初始空值)
  });
}

这样,无论字段当前是否显示,我们都能确保查询条件彻底被清空,不会遗留上次的状态。

internalRule:避免直接改动组件属性

在使用 form-create 构建动态表单时,我们经常需要更新字段的属性或状态,例如切换字段的 disabled、修改占位提示文字等。如果不借助正确的方法,直接修改 form-create 内部生成的组件属性,可能导致不可预期的结果,甚至丢失字段状态。我们通过一个内部规则(internalRule)机制来避免这些问题。

避免直接修改表单项属性

直接操作表单项的 props 可能遇到以下坑:

  • 修改不生效:form-create 对传入的规则进行了封装,直接更改 rule.props.x 有时不会触发视图更新,因为内部可能没有观测这些深层变化。
  • 状态丢失或重置:粗暴地替换整个规则对象会导致对应字段被重新创建,之前填写的值或校验状态丢失。这和前文提到的增删规则类似,会清空已有输入。

一个典型案例是,动态增删字段或修改其属性后,需要保留用户已填写的数据。form-create 官方建议使用其提供的 API 方法来操作,例如使用 fApi.setValue() 给字段赋值,以及避免直接操作规则数组。正如前述,当我们调用 this.rule.push()新增规则时,其他字段可能重置;而使用 fApi.append()prepend()等方法就能避免这种情况。

internalRule 思路

所谓 internalRule,指的是在组件内部维护一份表单规则的源数据副本或额外的配置,用于记录和控制字段属性变化。要点如下:

  • 初始规则克隆:在创建表单时,将传入的 formRule 深拷贝一份保存在组件内部(例如 this.internalRule = deepClone(props.formRule))。后续所有对表单结构的调整,都基于 internalRule 来进行。
  • 统一通过 API 更新:当需要修改字段属性(比如隐藏、禁用某字段)时,不直接操作 props.formRule,而是通过 fApi 提供的方法或更新 internalRule 来实现。例如,要禁用名为 "status" 的字段,我们优先选择 this.fApi.disabled(true, 'status'),这会由 form-create 内部去处理 DOM 和状态同步,而不是手动改 rule.props.disabled
  • 确保状态不丢失:由于我们保留了 internalRule,哪怕外部传入的规则在父组件因条件变化而重新计算,我们仍可以根据 internalRule 判断哪些字段之前的状态需要恢复。比如在展开高级查询时,我们知道哪些字段应该处于什么状态,而不依赖于外部重新给我们的规则(因为外部可能不知道用户中途对字段的改动)。

举个例子,假设我们的组件接受一个 formRule 列表作为 Prop。我们在内部保存 internalRule,并使用它生成 form-create 表单。当父组件可能出于某些原因重新传入一个新 formRule 时,我们可以智能对比 newProps 和 internalRule,仅对差异部分更新,而用户在界面上交互产生的状态(选中的值、隐藏显示状态等)在 internalRule 中有记录,不会无故被覆盖。

通过 internalRule,我们相当于构建了一个单一数据源来管理表单结构和状态的变化,避免了外部频繁调整导致的冲突。这种模式下,组件内部对 form-create 有完全的掌控力,确保了字段状态的稳定与延续。

彻底清空 vs. resetFields:清理查询条件的最佳实践

在实际项目中,用户有时希望“一键清空”所有查询条件,恢复到一个完全空白的初始状态(可能与默认初始值不同)。结合我们前面的讨论,总结出几条最佳实践建议:

  • 使用组件方法优于手动操作 DOM:无论是隐藏字段还是清空表单,都应优先使用 form-create 提供的 API(如 hidden/displayresetFieldssetValue 等)来操作。避免通过操作 DOM 或组件实例属性的方式清理数据,这样更稳健也更易维护。
  • 区分重置和清空:resetFields倾向于恢复初始值,而“清空”通常指把用户填写的数据全部清除。根据需求选择合适的方法,必要时组合多种手段。例如先 reset 恢复默认,再二次清理默认值字段。
  • 隐藏字段特殊处理:对于暂时隐藏的字段,查询时过滤、清空时仍要重置。这保证了隐藏即忽略,但一旦显现又是干净的新状态,不会把收起时残留的数据误用到下一次查询。
  • 保存用户输入体验:在展开/收起切换中,不轻易销毁用户已经输入的内容,而通过隐藏来软控制。这给了用户更好的体验(展开后还能找回之前输入的条件),同时对开发来说也减少了状态管理的复杂度。

最后,经过这套方案改造后的列表页组件,无论面对怎样的后端接口格式变更或需求调整,都能从容应对——后端奇葩接口再也无法威胁我们的前端代码稳定

总结:构建通用列表页组件时,既要考虑适配各种接口格式(通过 buildPayload 等钩子灵活封装),又要注重前端交互细节(如查询表单的展开收起实现)。利用 form-create 等工具的特性,我们可以优雅地隐藏和显示表单项,避免直接改 DOM 或规则造成的数据丢失。同时,要善用其 API 进行状态管理和表单重置,确保每次查询和重置都符合预期。按照以上最佳实践,就能封死各种后端奇葩接口对前端的影响,稳健地提升列表页的可维护性和用户体验。

Three.js 完全学习指南(二)场景、相机、渲染器基础

作者 鲫小鱼
2025年5月19日 18:27

场景、相机、渲染器基础

场景(Scene)详解

场景是 Three.js 中所有 3D 对象的容器,它定义了整个 3D 空间。让我们深入了解场景的配置和使用。

场景示例

图 2.1: 包含多个几何体的场景示例

1. 场景基础配置

import * as THREE from 'three';

// 创建场景
const scene = new THREE.Scene();

// 设置背景色
scene.background = new THREE.Color(0x000000); // 黑色背景

// 添加雾效果
scene.fog = new THREE.Fog(0x000000, 10, 100); // 颜色、近平面、远平面

// 设置场景环境
scene.environment = new THREE.CubeTextureLoader().load([
    'px.jpg', 'nx.jpg',
    'py.jpg', 'ny.jpg',
    'pz.jpg', 'nz.jpg'
]);

场景环境贴图

图 2.2: 使用环境贴图的场景效果

2. 场景管理

// 添加对象到场景
scene.add(mesh);

// 从场景中移除对象
scene.remove(mesh);

// 获取场景中的所有对象
const objects = scene.children;

// 遍历场景中的对象
scene.traverse((object) => {
    if (object.isMesh) {
        // 处理网格对象
    }
});
graph TD
    A[场景] --> B[几何体]
    A --> C[光源]
    A --> D[相机]
    A --> E[辅助对象]
    B --> F[网格]
    B --> G[线条]
    C --> H[环境光]
    C --> I[平行光]
    E --> J[网格辅助]
    E --> K[坐标轴]

图 2.3: 场景对象层级结构

3. 场景优化

// 设置场景自动更新
scene.autoUpdate = true;

// 手动更新场景
scene.updateMatrixWorld(true);

// 清理场景
function disposeScene() {
    scene.traverse((object) => {
        if (object.geometry) {
            object.geometry.dispose();
        }
        if (object.material) {
            if (Array.isArray(object.material)) {
                object.material.forEach(material => material.dispose());
            } else {
                object.material.dispose();
            }
        }
    });
}

相机(Camera)详解

Three.js 提供了多种相机类型,每种类型都有其特定的用途。

1. 透视相机(PerspectiveCamera)

透视相机模拟人眼视角,是最常用的相机类型。

透视相机效果

图 2.4: 透视相机的渲染效果

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
    75, // 视角(FOV)
    window.innerWidth / window.innerHeight, // 宽高比
    0.1, // 近平面
    1000 // 远平面
);

// 设置相机位置
camera.position.set(0, 5, 10);

// 设置相机朝向
camera.lookAt(0, 0, 0);

// 更新相机参数
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();

2. 正交相机(OrthographicCamera)

正交相机没有透视效果,适合用于 2D 场景或等距视图。

正交相机效果转存失败,建议直接上传图片文件

图 2.5: 正交相机的渲染效果

// 创建正交相机
const camera = new THREE.OrthographicCamera(
    -10, // 左
    10,  // 右
    10,  // 上
    -10, // 下
    0.1, // 近平面
    1000 // 远平面
);

// 设置相机位置
camera.position.set(0, 0, 10);
camera.lookAt(0, 0, 0);

3. 相机控制

使用 OrbitControls 实现相机控制:

相机控制效果

图 2.6: 使用 OrbitControls 的相机控制效果

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

// 创建控制器
const controls = new OrbitControls(camera, renderer.domElement);

// 配置控制器
controls.enableDamping = true; // 启用阻尼效果
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 10;
controls.maxDistance = 500;
controls.maxPolarAngle = Math.PI / 2;

// 在动画循环中更新控制器
function animate() {
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
}

渲染器(Renderer)详解

渲染器负责将场景和相机的内容绘制到屏幕上。

1. 基础配置

// 创建渲染器
const renderer = new THREE.WebGLRenderer({
    antialias: true, // 抗锯齿
    alpha: true,     // 透明背景
    precision: 'highp' // 精度
});

// 设置渲染器尺寸
renderer.setSize(window.innerWidth, window.innerHeight);

// 设置像素比
renderer.setPixelRatio(window.devicePixelRatio);

// 设置输出编码
renderer.outputEncoding = THREE.sRGBEncoding;

// 启用阴影
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

渲染器效果转存失败,建议直接上传图片文件

图 2.7: 不同渲染效果的对比

2. 高级配置

// 设置渲染器参数
renderer.setClearColor(0x000000, 1); // 设置清除颜色
renderer.setClearAlpha(1); // 设置清除透明度

// 配置阴影
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

// 配置后期处理
const composer = new THREE.EffectComposer(renderer);
const renderPass = new THREE.RenderPass(scene, camera);
composer.addPass(renderPass);

后期处理效果

图 2.8: 使用后期处理的效果

3. 性能优化

// 设置渲染器参数
renderer.powerPreference = 'high-performance';
renderer.precision = 'highp';

// 自动清理
function disposeRenderer() {
    renderer.dispose();
    renderer.forceContextLoss();
    renderer.domElement.remove();
}

// 处理窗口大小变化
window.addEventListener('resize', () => {
    const width = window.innerWidth;
    const height = window.innerHeight;

    camera.aspect = width / height;
    camera.updateProjectionMatrix();

    renderer.setSize(width, height);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

实战:创建一个完整的 3D 场景

让我们结合以上知识,创建一个完整的 3D 场景:

完整场景示例

图 2.9: 完整的 3D 场景示例

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);

// 创建相机
const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.set(5, 5, 5);
camera.lookAt(0, 0, 0);

// 创建渲染器
const renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
document.getElementById('app').appendChild(renderer.domElement);

// 创建控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// 添加网格
const gridHelper = new THREE.GridHelper(10, 10);
scene.add(gridHelper);

// 添加环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

// 添加平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(5, 5, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);

// 创建一个立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
    color: 0x00ff00,
    metalness: 0.3,
    roughness: 0.4
});
const cube = new THREE.Mesh(geometry, material);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);

// 创建地面
const planeGeometry = new THREE.PlaneGeometry(10, 10);
const planeMaterial = new THREE.MeshStandardMaterial({
    color: 0x808080,
    side: THREE.DoubleSide
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotation.x = -Math.PI / 2;
plane.position.y = -0.5;
plane.receiveShadow = true;
scene.add(plane);

// 动画循环
function animate() {
    requestAnimationFrame(animate);

    // 更新控制器
    controls.update();

    // 旋转立方体
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    // 渲染场景
    renderer.render(scene, camera);
}

// 开始动画
animate();

性能优化建议

  1. 场景优化

    • 使用适当的几何体复杂度
    • 及时清理不需要的对象
    • 使用对象池管理频繁创建的对象
  2. 相机优化

    • 设置合适的视锥体范围
    • 使用适当的相机类型
    • 优化控制器参数
  3. 渲染器优化

    • 使用适当的像素比
    • 启用必要的渲染特性
    • 及时释放资源

练习

  1. 实现相机的自动旋转
  2. 添加多个光源并观察效果
  3. 实现场景的昼夜变化
  4. 添加后期处理效果

下一步学习

在下一章中,我们将学习:

  • 几何体的创建和使用
  • 材质系统的详细配置
  • 纹理的应用
  • 对象的变换和组合

JavaScript作用域和作用域链

2025年5月19日 18:26

在JavaScript中,作用域和作用域链是理解代码执行和变量访问的关键概念。它们决定了变量和函数在代码中的可见性和生命周期。

一、作用域(Scope)

(一)什么是作用域?

作用域是在运行时代码中的某些特定部分中变量、函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。

作用域的主要作用是隔离变量,防止不同作用域下的同名变量发生冲突。例如:

function outFun2() {
    var inVariable = "内层变量2";
}
outFun2();
console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined

在上面的例子中,变量inVariable在全局作用域中没有声明,因此在全局作用域下访问它会报错。

(二)全局作用域和函数作用域

1. 全局作用域

全局作用域是指在代码中任何地方都能访问到的对象。以下几种情形拥有全局作用域:

  • 最外层函数和在最外层函数外面定义的变量拥有全局作用域。
  • 所有未定义直接赋值的变量自动声明为拥有全局作用域。
  • 所有window对象的属性拥有全局作用域。
var outVariable = "我是最外层变量"; // 最外层变量
function outFun() { // 最外层函数
    var inVariable = "内层变量";
    function innerFun() { // 内层函数
        console.log(inVariable);
    }
    innerFun();
}
console.log(outVariable); // 我是最外层变量
outFun(); // 内层变量
console.log(inVariable); // inVariable is not defined

全局作用域的弊端是容易污染全局命名空间,引起命名冲突。因此,通常建议将代码封装在函数中,避免全局变量的滥用。

2. 函数作用域

函数作用域是指声明在函数内部的变量,这些变量只能在函数内部访问。例如:

function doSomething() {
    var stuName = "zhangsan";
    function innerSay() {
        console.log(stuName);
    }
    innerSay();
}
console.log(stuName); // 脚本错误
innerSay(); // 脚本错误

函数作用域的一个重要特点是内层作用域可以访问外层作用域的变量,但外层作用域不能访问内层作用域的变量。

(三)块级作用域

ES6引入了块级作用域,通过letconst关键字声明的变量具有块级作用域。块级作用域在以下情况被创建:

  1. 在一个函数内部。
  2. 在一个代码块(由一对花括号包裹)内部。

块级作用域的特点包括:

  • 声明变量不会提升到代码块顶部。
  • 禁止重复声明。
  • 循环中的绑定块作用域的妙用。
for (let i = 0; i < 10; i++) {
    console.log(i); // i 在循环内部有效
}
console.log(i); // ReferenceError: i is not defined

二、作用域链

(一)什么是自由变量?

自由变量是指在当前作用域中没有定义的变量。例如:

var a = 100;
function fn() {
    var b = 200;
    console.log(a); // 这里的 a 是一个自由变量
    console.log(b);
}
fn();

fn函数中,a是一个自由变量,因为它在fn函数的作用域中没有定义。

(二)什么是作用域链?

作用域链是指当访问一个变量时,编译器会从当前作用域开始,逐层向上查找,直到找到该变量或到达全局作用域。例如:

var a = 100;
function f1() {
    var b = 200;
    function f2() {
        var c = 300;
        console.log(a); // 100
        console.log(b); // 200
        console.log(c); // 300
    }
    f2();
}
f1();

f2函数中,ab是自由变量,它们的值通过作用域链从外层作用域中获取。

(三)关于自由变量的取值

自由变量的值是在函数定义时确定的,而不是在函数调用时确定的。例如:

var x = 10;
function fn() {
    console.log(x);
}
function show(f) {
    var x = 20;
    (function () {
        f(); // 输出 10,而不是 20
    })();
}
show(fn);

fn函数中,x的值是在fn函数定义时确定的,因此输出的是全局作用域中的x,而不是show函数中的x

三、作用域与执行上下文

许多开发人员经常混淆作用域和执行上下文的概念。虽然它们都与变量的访问和函数的执行有关,但它们是不同的概念。

  • 作用域:作用域是在函数定义时确定的,它决定了变量的可见性和生命周期。
  • 执行上下文:执行上下文是在函数执行时创建的,它包括变量对象、作用域链和this的指向。

(一)执行上下文的生命周期

执行上下文的生命周期分为两个阶段:

  1. 创建阶段:当代码执行进入一个环境时,会创建一个执行上下文。在这个阶段,执行上下文会进行以下操作:

    • 创建变量对象(Variable Object,VO):包括函数的形参、arguments对象、函数声明和变量声明。
    • 确定this的指向。
    • 确定作用域链。
  2. 执行阶段:在执行阶段,代码开始执行,变量被赋值,函数被调用,其他代码按顺序执行。

四、总结

理解作用域和作用域链的工作原理和实际应用,可以帮助你更好地理解代码的执行流程和变量的访问机制。如果你对本文的内容有任何疑问或补充,欢迎在评论区留言讨论。

Flutter核心机制图解说明

作者 90后晨仔
2025年5月19日 18:19

一、三棵树协作流程详解

1. 架构关系示意图
[用户代码] → Widget树(声明式配置)
       ↓ 创建
Element树(生命周期管理)
       ↓ 绑定
RenderObject树(布局/绘制)
2. 协作流程步骤
  1. 初始化阶段

    • runApp() 触发根Widget创建
    • 生成对应的根Element(RenderObjectElement
    • Element创建关联的RenderObject
  2. 构建阶段

    • Widget树通过build()方法递归构建
    • Element树通过inflateWidget方法逐层创建子元素
    • RenderObject树执行createRenderObject初始化渲染对象
  3. 更新阶段

    • 当Widget发生变更时: a. Element树对比新旧Widget类型 b. 类型相同 → 更新现有Element配置(update()) c. 类型不同 → 销毁旧Element,创建新Element
  4. 布局阶段

    • RenderObject执行layout()方法
    • 父节点向子节点传递约束条件(Constraints)
    • 子节点返回布局尺寸(Size)
  5. 绘制阶段

    • 生成Layer树提交给Skia引擎
    • 通过OpenGL/Vulkan进行GPU渲染

二、Platform Channel架构解析

1. 通信层级结构
[Flutter层] - Dart代码
   │
   ├── MethodChannel (方法调用)
   ├── EventChannel (事件流)
   └── BasicMessageChannel (基础消息)
           │
           |
[Native层] - 平台原生代码
   │
   ├── Android (Java/Kotlin)
   └── iOS (Objective-C/Swift)
2. 数据流向示意图
Flutter → 序列化为二进制 → 平台通道 → 反序列化为原生类型 → Native处理
       ← 序列化返回数据 ←          ← 原生返回结果 ←
3. 核心组件说明表
组件 功能特点 典型使用场景
MethodChannel 支持异步方法调用与返回值 调用相机/获取地理位置
EventChannel 建立持续事件流(类似观察者模式) 传感器数据监听/实时定位更新
BasicMessageChannel 基础消息传递(支持自定义编解码器) 简单数据交换/二进制传输

三、Key机制工作原理图示

1. LocalKey复用逻辑
Widget树重建前:
Item1(Key:A) - Item2(Key:B) - Item3(Key:C)

Widget树重建后:
Item2(Key:B) - Item3(Key:C) - Item1(Key:A)

Element树保持:
ElementB ↔ ElementC ↔ ElementA(仅位置变化)
2. GlobalKey定位原理
           ┌───────────┐
           │ GlobalKey │
           └─────┬─────┘
                 │
           ┌─────▼─────┐
           │ Element树  │
           └─────┬─────┘
                 │
           ┌─────▼─────┐
           │ 获取RenderObject │
           └───────────┘

四、状态管理数据流模型

1. Provider架构模型
[ChangeNotifier] ← 数据更新
       │
       ├─── notifyListeners()
       │
[Consumer] → 局部刷新
       │
[Selector] → 精准刷新
2. GetX响应式流程
[Rx变量] → 数据变更
       │
       ├─── 自动触发更新
       │
[Obx组件] → 重建依赖部件
       │
[GetBuilder] → 手动控制刷新

五、混合开发通信时序图

1. MethodChannel调用流程
Flutter端               Native端
  │                        │
  │  invokeMethod('getInfo')│
  │───────────────────────>│
  │                        ├── 执行原生代码
  │                        │
  │     result(data)       │
  │<───────────────────────│
  │                        │
2. EventChannel事件流
Flutter端               Native端
  │                        │
  │   receiveBroadcast()   │
  │───────────────────────>│
  │                        ├── 注册监听器
  │                        │
  │     event(data)        │
  │<───────────────────────│(持续推送)
  │                        │

六、性能优化关键路径

1. 渲染优化路线
减少Widget重建 → 优化Element复用 → 降低RenderObject计算 → 精简Layer树
      ↑               ↑                  ↑
   const构造      Key精准控制        布局边界标记(RepaintBoundary)
2. 内存管理策略
图片缓存控制 → 及时销毁监听 → 避免闭包泄漏 → 使用Isolate计算
   ↑               ↑              ↑             ↑
LRU策略       dispose()清理    DevTools检测   compute()函数

通过以上文字图解,开发者可以建立清晰的架构认知:

  1. 三棵树机制:理解声明式UI的核心工作原理
  2. 平台交互:掌握混合开发的数据通信脉络
  3. 状态管理:构建可维护的响应式架构
  4. 性能优化:定位关键瓶颈实施精准优化

建议结合Flutter DevTools的以下功能进行验证:

  • Widget Inspector:实时查看三棵树状态
  • Timeline:分析渲染流水线性能
  • Memory:检测内存泄漏与溢出
❌
❌