普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月21日掘金 前端

HarmonyOS6 接入分享,原来也是三分钟的事情

作者 万少
2025年12月21日 22:12

HarmonyOS6 接入分享,原来也是三分钟的事情

前言

最近打算给准备开发的应用接入分享功能,考虑到模板市场上已经有现成的模版了,然后结合AI来接入,接入起来,也就是3分钟的事情。

新建工程

如果还没有工程的话,直接新建工程、创建项目

image-20251221211420491

组件市场直接接入

在devEco Studio 上的 组件市场上搜索,通用系统分享,然后直接下载到项目中即可。

image-20251221211738225

成功后,系统会自动同步和构建,这个时候会提示文件找不到  build-profile.json5

image-20251221212416142

这个时候手动删除配置即可

image-20251221212502217

AI工具直接帮你接入

然后使用自己习惯的AI编辑器打开当前工程,万少这边使用的是 Trae 海外版 + Gemini-3-pro 模型

image-20251221212707611

你点击刚才组件市场内的通用分享组件的官网链接,复制这个网址。

developer.huawei.com/consumer/cn…

image-20251221212813537


image-20251221212838563

最后,使用老奶奶也能听懂的自然语言帮你的AI编辑器帮你接入即可,如图所示。

image-20251221212951496

最后效果

image-20251221213721538

注意事项

  1. 如果需要接受比如分享到QQ后到回调
  2. 或者需要配置微博分享

都需要仔细查看组件官网的描述

developer.huawei.com/consumer/cn…

C# 正则表达式:量词与锚点——从“.*”到精确匹配

作者 烛阴
2025年12月21日 21:59

一、量词:告诉引擎“要重复多少次”

量词出现在一个“单元”后面,表示这个单元要重复多少次。

单元可以是:

  • 一个普通字符:a
  • 一个字符类:\d[A-Z]
  • 一个分组:(ab)

1. 常见量词一览

  • ?:0 或 1 次
  • *:0 次或多次
  • +:1 次或多次
  • {n}:恰好 n 次
  • {n,}:至少 n 次
  • {n,m}:n 到 m 次之间

示例:

using System.Text.RegularExpressions;

string pattern = @"^\d{3,5}$"; 
bool ok = Regex.IsMatch("1234", pattern); // True

2. 量词是作用在“前一项”上的

注意:ab+ 只会把 + 作用到 b 上:

  • 模式:ab+
    • 匹配:"ab"、"abb"、"abbbb"...
  • 模式:(ab)+
    • 匹配:"ab"、"abab"、"ababab"...

也就是说,当你想对一“串”东西使用量词,一定要用括号分组。


二、贪婪 vs 懒惰:**? 的根本区别

量词在默认情况下是“贪婪”的:

  • 在不影响匹配成功的前提下,尽可能多地吃字符。

1. 贪婪匹配:.*

string input = "<tag>content</tag><tag>more</tag>";
string pattern = @"<tag>.*</tag>";

Match m = Regex.Match(input, pattern);
Console.WriteLine(m.Value);

匹配结果:

<tag>content</tag><tag>more</tag>
  • .* 会尽可能多地吃字符,直到最后一个满足条件的 </tag>

2. 懒惰匹配:.*?

在量词后面再加一个 ?,就变成“懒惰”(即最多满足一次):

  • *?:尽可能少的 0 次或多次
  • +?:尽可能少的 1 次或多次
  • ??:尽可能少的 0 或 1 次
  • {n,m}?:在 n~m 之间,尽量少

改写上面的例子:

string pattern = @"<tag>.*?</tag>";

Match m = Regex.Match(input, pattern);
Console.WriteLine(m.Value);

匹配结果:

<tag>content</tag>

三、用量词写几个常见“格式”:

1. 简单日期:yyyy-MM-dd

^\d{4}-\d{2}-\d{2}$
  • 不考虑合法性,只看格式

2. 用户名:字母开头,后面 3~15 位字母数字下划线

^[A-Za-z]\w{3,15}$
  • [A-Za-z]:首字符必须是字母
  • \w{3,15}:后面 3~15 个字母数字下划线
  • 总长度:4~16

C#:

string pattern = @"^[A-Za-z]\w{3,15}$";
bool ok = Regex.IsMatch("User_001", pattern);

3. 整数和小数

简单版本的“非负整数或小数”:

^\d+(\.\d+)?$
  • \d+:至少一位数字
  • (\.\d+)?:可选的小数部分(. + 至少一位数字)

匹配:01233.140.5 不匹配:..53.(如果你想放宽,可以调整)。


四、锚点:决定“匹配的是不是整串”

锚点(Anchor)是一类特殊的“零宽”匹配,只匹配“位置”,不消耗字符。

1)^:开头,$:结尾

默认情况下:

  • ^ 匹配字符串的开头;
  • $ 匹配字符串的结尾。

示例:

^abc      // 匹配以 "abc" 开头的字符串
abc$      // 匹配以 "abc" 结尾的字符串
^abc$     // 字符串只能是 "abc"
Regex.IsMatch("abc123", @"^abc");   // True
Regex.IsMatch("123abc", @"abc$");   // True
Regex.IsMatch("xabcx", @"^abc$");   // False

2. 表单校验一定要写 ^$

// 不严谨
Regex.IsMatch("abc2025-12-18xyz", @"\d{4}-\d{2}-\d{2}"); 
// True,只要“包含”符合格式的子串就通过

// 严谨
Regex.IsMatch("abc2025-12-18xyz", @"^\d{4}-\d{2}-\d{2}$");
// False,整个字符串不是完整日期

3. 字符类里的 ^ 意义完全不同

^abc        // 锚点:开头
[^abc]      // 取反:匹配任何不是a、b、c的字符
  • ^[] 外:开头锚点
  • ^[] 里且在首位:表示“取反”,方括号内的字符任意组合的反面

五、单词边界:\b

\b 是“单词边界”(word boundary),匹配“从一个 \w 字符到一个非 \w 字符的边界”。

例子:

string text = "cat scat category";
string pattern = @"\bcat\b";

MatchCollection matches = Regex.Matches(text, pattern);
foreach (Match m in matches)
{
    Console.WriteLine(m.Value);
}

输出:

cat

解析:

  • "cat" 前后都是边界(左边是开头,右边是空格),满足 \b
  • "scat" 中的 cat 左边是 s,属于 \w,不会被 \bcat\b 匹配。
  • "category" 中的 cat 右边是 e,也是 \w,也不符合。

六、多行模式(Multiline)与单行模式(Singleline)

C# 中用 RegexOptions 可以控制 ^ / $. 的行为。

1. RegexOptions.Multiline:多行模式

默认情况:

string text = "first\nsecond\nthird";
string pattern = @"^second$";

Console.WriteLine(Regex.IsMatch(text, pattern)); // False

因为:

  • ^$ 在默认模式下只匹配整个字符串起始和结尾,不会感知行。

多行模式开启后:

bool ok = Regex.IsMatch(
    text,
    pattern,
    RegexOptions.Multiline
);
Console.WriteLine(ok); // True

此时:

  • ^ / $ 会匹配每一行的开头/结尾(以 \n 作为换行)。

2. RegexOptions.Singleline:单行模式 / DOTALL

默认情况下:

  • . 不匹配换行符。
string text = "line1\nline2";
string pattern = @".*";

Match m1 = Regex.Match(text, pattern);
Console.WriteLine(m1.Value);    // "line1"

开启 Singleline 后:

Match m2 = Regex.Match(text, pattern, RegexOptions.Singleline);
Console.WriteLine(m2.Value);    // "line1\nline2"

此时:

  • . 会匹配包括换行在内的任何字符。

总结一下:

  • Multiline:影响 ^ / $,让它们感知“行”
  • Singleline:影响 .,让 . 能匹配换行

它们可以一起用:

var regex = new Regex(
    pattern,
    RegexOptions.Multiline | RegexOptions.Singleline
);

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

前端跨页面通讯终极指南⑧:Cookie 用法全解析

2025年12月21日 20:57

前言

之前介绍了很多前端跨页面通讯的方案,今天介绍下Cookie,Cookie自身有“同源共享”的特性,但因为缺少数据变化的主动通知机制,只能使用“轮询”弥补这一缺陷。

本文将使用Cookie轮询,进行跨页面通讯。

1. Cookie 轮询基本原理

Cookie轮询通过“存储-定期检查-差异处理”的核心逻辑实现跨页面通讯,具体流程为:

  1. 一个页面将消息(如状态、指令等)结构化后存储到Cookie中;
  2. 其他同源页面通过定时任务定期读取目标Cookie;
  3. 对比当前Cookie值与历史基准值,若发现内容变化则读取并处理消息;
  4. 更新基准值,完成一次跨页面通讯闭环。

需特别注意:Cookie的domainpath配置是轮询生效的前提——写入方与轮询方需配置一致的作用域(如根路径/、根域名example.com),否则轮询方无法读取目标Cookie,导致通讯失效。

2. 案例代码

2.1 Cookie 轮询完整实现

代码如下所示:

// 设置 Cookie
function setCookie(name, value, days = 1) {
    const date = new Date();
    date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
    const expires = &#34;expires=&#34; + date.toUTCString();
    document.cookie = name + &#34;=&#34; + encodeURIComponent(value) + &#34;;&#34; + expires + &#34;;path=/&#34;;
    updateCookieDisplay();
}

// 获取 Cookie
function getCookie(name) {
    const nameEQ = name + &#34;=&#34;;
    const ca = document.cookie.split(';');
    for (let i = 0; i < ca.length; i++) {
        let c = ca[i];
        while (c.charAt(0) === ' ') c = c.substring(1, c.length);
        if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length));
    }
    return null;
}

// 删除 Cookie
function deleteCookie(name) {
    document.cookie = name + &#34;=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;&#34;;
    updateCookieDisplay();
}

// 发送消息(Cookie 方式)
function sendMessageByCookie(content) {
    // 确定通讯类型
    let communicationType = '';
    if (isChild) {
        communicationType = '子父通讯';
    } else {
        communicationType = '父子通讯';
    }
    
    const message = {
        id: Date.now(),
        content: content,
        sender: clientId,
        timestamp: Date.now(),
        method: 'cookie',
        communicationType: communicationType
    };
    
    const cookieValue = JSON.stringify(message);
    setCookie(COOKIE_NAME, cookieValue);
    addLog(`通过 Cookie 发送消息: ${content}`, '发送', communicationType);
}

image.png

3.2 总结

Cookie轮询是一种兼容性极强的跨页面通讯方案,无需依赖现代API,可在老旧浏览器中稳定运行。其核心优势是实现简单、配置灵活,适用于低频率消息同步场景(如登录状态、用户偏好设置)。使用时需重点关注Cookie作用域配置和轮询性能平衡,通过结构化消息设计和异常处理提升方案可靠性。

上线前不做 Code Review?你可能正在给团队埋雷!

2025年12月21日 20:15

一位前端同事花一周时间重构了一个核心组件,自测通过、性能优化、UI 完美。
上线 2 小时后,用户反馈“页面白屏”——原来漏掉了空状态处理。
紧急回滚、加班修复、产品信任受损……
而这一切,本可以在一次 15 分钟的 Code Review 中避免。

Code Review(代码审查)从来不是“找茬”,而是给前端团队上了一个最高效的质量保险。它不仅能提前拦截 Bug,更是知识共享、规范落地、新人成长的加速器。

一、什么是 Code Review

1.1 Code Review 的核心价值和目标

Code Review 翻译成中文,就是代码审查。在前端开发过程中,它不仅是保证代码质量的关键环节,更是团队知识共享技术统一工程规范落地的重要机制。团队成员通过规划化的代码审查流程,能够提前发现潜在问题提升代码的可以维护性

1.2 Code Review 发生在什么时候

通常发生在:

  • 开发者完成一个功能、修复一个 bug 或进行重构后
  • 将代码推送到远程仓库并发起 Pull Request(PR)  或 Merge Request(MR)
  • 在代码合并到主干分支(如 master / main之前

二、为什么要 Code Review

代码审查真的不是为了找茬,而是为了让团队能走得更远、更稳的关键一环。想一想,如果花了一周时间,优化了各种性能,写了一大堆代码,结果上线后发现了一个严重 bug,需要紧急修复。这不仅浪费了时间,还可能影响用户对产品团队和开发团队的信任。

下面列举几个 Code Review 的作用:

  • 提前发现缺陷
    在 Code Review 阶段发现的逻辑错误、业务理解偏差、性能隐患等时有发生,可以提前发现问题
  • 提高代码质量
    主要体现在代码健壮性、设计合理性、代码优雅性等方面,持续 Code Review 可以提升团队整体代码质量
  • 统一规范和风格
    集团编码规范自不必说,对于代码风格要不要统一,可能会有不同的看法,个人观点对于风格也不强求。不过代码风格的统一更有助于提升代码的可读性及让继任者快速上手
  • 团队共识
    通过多次讨论与交流,逐步达成团队共识,特别是对架构理解和设计原则的认知,在共识的基础上团队也会更有凝聚力,特别是在较多新人加入时尤为重要

三、怎么做 Code Review

3.1 代码提交者

3.1.1 使用自动化工具
  • ESLint + Prettier

下面是 VS Code 关于 Prettier 的配置示例:

{
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "[vue]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[html]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[jsonc]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[json]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "eslint.codeActionsOnSave.mode": "problems",
    "eslint.validate": [
        "typescript",
        "javascript",
        "javascriptreact",
        "typescriptreact"
    ],
    "editor.codeActionsOnSave": {
        // 指定是否在保存文件时自动整理导入
        "source.organizeImports": "always"
    },
}

利用 Ctrl + S 自动格式化代码

  • Husky + Lint-staged

安装依赖

yarn add -D husky
yarn add -D lint-staged

husky

一个为 git 客户端增加 hook 的工具。安装后,它会自动在仓库中的 .husky/ 目录下增加相应的钩子;比如 pre-commit 钩子就会在你执行 git commit 的触发。我们可以在 pre-commit 中实现一些比如 lint 检查、单元测试、代码美化等操作。

package.json 需要添加 prepare 脚本

{
  "scripts": {
    "prepare": "husky install"
  }
}

做完以上工作,就可以使用 husky 创建一个 hook 了

npx husky add .husky/pre-commit "npx lint-staged"

lint-staged

一个仅仅过滤出 Git 代码暂存区文件(被 git add 的文件)的工具;这个很实用,因为我们如果对整个项目的代码做一个检查,可能耗时很长,如果是老项目,要对之前的代码做一个代码规范检查并修改的话,这可能就麻烦了,可能导致项目改动很大。所以这个 lint-staged,对团队项目和开源项目来说,是一个很好的工具,它是对个人要提交的代码的一个规范和约束。

此时我们已经实现了监听 Git hooks,接下来我们需要在 pre-commit 这个 hook 使用 Lint-staged 对代码进行 prettier 的自动化修复和 ESLint 的检查,如果发现不符合代码规范的文件则直接退出 commit。

并且 Lint-staged 只会对 Git 暂存区(git add 的代码)内的代码进行检查而不是全量代码,且会自动将 prettier 格式化后的代码添加到此次 commit 中。

在 package.json 中配置

{
 "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{ts,tsx,js}": [
      "eslint --fix",
      "prettier --write",
      "git add"
    ]
  },
}

合并代码的时候自动触发执行基于大模型的自动化代码审查工具。可以先根据 AI 提示选择性的整改一波,然后再找评审的人 CR。感兴趣的可以点击链接了解一下。

功能如下:

  1. 🚀 多模型支持
    • 兼容DeepSeek、ZhipuAI、OpenAI、Anthropic、通义千问和Ollama,想用哪个就用哪个。
  2. 📢消息即时主动
    • 结果审查一键直达钉钉、企业微信或飞书,代码问题无处可藏!
  3. 📅自动化日报生成
    • 基于 GitLab & GitHub & Gitea Commit 记录,自动整理每日开发进度,谁在摸鱼、谁在卷,一目了然 😼。
  4. 📊可视化仪表板
    • 集中展示所有代码审查记录,项目统计、开发者统计,数据说话,甩锅无门!
  5. 🎭 评论风格任你选
    • 专业型🤵:严谨严谨,正式专业。
    • 论型😈:毒舌吐槽,专治不服(“这代码是用脚写的吗?”)
    • 绅士型😍:温柔建议,如沐春风(“也许这里可以再优化一下呢~”)
    • 幽默型🤪:搞笑点评,快乐改码(“be if-else比我的相亲经历还曲折!”)
3.1.2 发起 Code Review 时间和代码量
  • 时间:尽量上线前一天发起评审
  • 代码量:最好在 400 行以下。根据数据分析发现,从代码行数来看,超过 400 行的 CR,缺陷发现率会急剧下降;从 CR 速度来看,超过500 行/小时后,Review 质量也会大大降低,一个高质量的 CR 最好控制在一个小时以内。

3.2 代码评审者

3.2.1 评审时重点关注内容

作为前端评审者,可以检查以下维度:

维度 检查点示例
功能正确性 逻辑是否覆盖所有场景?边界条件(空值、错误状态)是否处理?
可读性 & 可维护性 变量/函数命名是否清晰?组件职责是否单一?重复代码是否可复用?
TypeScript 安全 是否滥用 any / @ts-ignore?类型定义是否准确?
框架规范 React:key 是否合理?useEffect 依赖是否完整?Vue:响应式使用是否正确?
样式 & UI 是否避免全局 CSS 污染?是否适配移动端?是否符合设计系统?
性能 是否有不必要的重渲染?图片/资源是否优化?第三方库是否按需引入?
可访问性 (a11y) 是否使用语义化标签?表单是否有 label?键盘导航是否支持?
安全性 用户输入是否转义?是否避免 XSS(如 dangerouslySetInnerHTML)?
测试覆盖 关键逻辑是否有单元测试?用户路径是否有 E2E 测试?
3.2.2 怎么写 Review 评论

首先,不要吝啬你的赞美。代码写的好的地方要表扬!!

区分优先级:

  • 🔴 必须改:Bug、安全漏洞、破坏性变更
  • 🟡 建议改:可读性、性能优化、最佳实践
  • 🟢 可讨论:风格偏好(应由 Lint 工具统一)

用好 “What-Why-How” 法则

✅ 正确示范:

What: 这里直接操作 DOM (`document.getElementById`)  
Why: 在 React 中绕过虚拟 DOM 会导致状态不一致,且难以测试  
How: 建议改用 `useRef` 获取元素引用,或通过状态驱动 UI 更新

❌ 避免:

“这里写得不好” → 太模糊
“你应该用 Hooks 重写” → 没说明原因
“我以前都这么写的” → 主观经验,缺乏依据

总结

好的 Code Review 是团队进步的放大器,它可以:

  • 让新人成长得更快
  • 让系统变得更稳定
  • 让"技术债"更少
  • 让协作更高效

参考文章

感谢

  • 文中如有错误,欢迎在评论区批评指正。
  • 如果本文对你有帮助,就点赞、收藏支持下吧!感谢阅读。

从硬编码到 Schema 推断:前端表单开发的工程化转型

作者 光头老石
2025年12月21日 20:01

一、你的表单,是否正在失控?

想象一个场景,你正在开发一个“企业贷款申请”或“保险理赔”系统。

最初,页面只有 5 个字段,你写得优雅从容。随着业务迭代,表单像吹气球一样膨胀到了 50 多个字段: “如果用户选了‘个体工商户’,不仅要隐藏‘企业法人’字段,还得去动态请求‘经营地’的下拉列表,同时‘注册资本’的校验规则还要从‘必填’变成‘选填’……”

于是,你的 Vue 文件变成了这样:

  • <template> 里塞满了深层嵌套的 v-ifv-show
  • <script> 里到处是监听联动逻辑的 watch 和冗长的 if-else
  • 最痛苦的是: 当后端决定调整字段名,或者公司要求把这套逻辑复用到小程序时,你发现逻辑和 UI 已经像麻绳一样死死缠在一起,拆不开了。

“难道写表单,真的只能靠体力活吗?”

为了摆脱这种低效率重复,我们尝试将 中间件思想 引入 Vue 3,把复杂的业务规则从 UI 框架中剥离出来。今天,我就把这套“一次编写,到处复用”的工程化方案分享给你。


二、 核心思想:让数据自带“说明书”

传统模式下,前端像是一个**“搬运工”:拿到后端数据,手动判断哪个该显、哪个该隐。

而工程化模式下,前端更像是一个“组装厂”**:数据在进入 UI 层之前,先经过一套“中间件流水线”,数据会被自动标注上 UI 描述信息(Schema)。

1. 什么是 Schema 推断?

数据不再是冷冰冰的键值对,而是变成了一个包含“元数据”的对象。通过 TypeScript 的类型推断,我们让数据自己告诉页面:

  • 我应该用什么组件渲染(componentType
  • 我是否应该被显示(visible
  • 我依赖哪些字段(dependencies
  • 我的下拉选项去哪里拉取(request

2. UI 框架只是“皮肤”

既然逻辑都抽离到了框架无关的中间件里,那么 UI 层无论是用 Ant Design 还是 Element Plus,都只是换个“解析器”而已。


三、 实战:构建 Vue 3 自动化渲染引擎

1. 组件注册表

首先,我们要定义一个组件映射表,把抽象的字符串类型映射为具体的 Vue 组件。

TypeScript

// src-vue/components/FormRenderer/componentRegistry.ts
import NumberField from '../FieldRenderers/NumberField.vue'
import SelectField from '../FieldRenderers/SelectField.vue'
import TextField from '../FieldRenderers/TextField.vue'
import ModeToggle from '../FieldRenderers/ModeToggle.vue'

export const componentRegistry = {
  number: NumberField,
  select: SelectField,
  text: TextField,
  modeToggle: ModeToggle,
} as const

2. 组装线:自动渲染器(AutoFormRenderer)

这是我们的核心引擎。它不关心业务,只负责按照加工好的 _fieldOrder_schema 进行遍历。

<template>
  <a-row :gutter="[16,16]">
    <template v-for="key in orderedKeys" :key="key">
      <component
        v-if="shouldRender(key)"
        :is="resolveComponent(key)"
        :value="data[key]"
        :config="schema[key].fieldConfig"
        :dependencies="collectDeps(schema[key])"
        :request="schema[key].request"
        @update:value="onFieldChange(key, $event)"
      />
    </template>
  </a-row>
</template>

<script setup lang="ts">
const props = defineProps<{ data: any }>();
const schema = computed(() => props.data?._schema || {});
const orderedKeys = computed(() => props.data?._fieldOrder || Object.keys(props.data));

// 根据中间件注入的 visible 函数判断显隐
function shouldRender(key: string) {
  const s = schema.value[key];
  if (!s || s.fieldConfig?.hidden) return false;
  return s.visible ? s.visible(props.data) : true;
}

function resolveComponent(key: string) {
  const type = schema.value[key]?.componentType || 'text';
  return componentRegistry[type];
}
</script>

3. 原子化:会“思考”的字段组件

SelectField 为例,它不再是被动等待赋值,而是能感知依赖。当它依赖的字段(如“省份”)变化时,它会自动重新调用 request

<script setup lang="ts">
const props = defineProps(['value', 'dependencies', 'request']);
const options = ref([]);

async function loadOptions() {
  if (props.request) {
    options.value = await props.request(props.dependencies || {});
  }
}

// 深度监听依赖变化,实现联动效果
watch(() => props.dependencies, loadOptions, { deep: true, immediate: true });
</script>

四、 方案的“真香”时刻

1. 逻辑与 UI 的彻底解耦

所有的联动规则、校验逻辑、接口请求都定义在独立于框架的 src/core 下。如果你明天想把项目从 Vue 3 迁到 React,你只需要重写那几个基础字段组件,核心业务逻辑 一行都不用动

2. “洁癖型”提交

很多动态表单方案会将 visibleoptions 等 UI 状态混入业务数据,导致传给后端的 JSON 极其混乱。我们的方案在提交前会运行一次“清洗中间件”:

const cleanPayload = submitCompileOutputs(formData.compileOutputs);
// 自动剔除所有以 _ 开头的辅助字段和临时状态

后端拿到的永远是干净、纯粹的业务模型。

3. 开发体验的飞跃

现在,当后端新增一个字段时,你的工作流变成了:

  1. 在类型推断引擎里加一行规则。

  2. 刷新页面,字段已经按预定的位置和样式长好了。

    你不再需要去 .vue 文件里翻找几百行处的 template 插入 HTML,更不需要担心漏掉了哪个 v-if。


结语:不要为了用框架而用框架

很多时候,我们觉得 Vue 或 React 难维护,是因为我们将过重的业务决策交给了视图层

通过引入中间件和 Schema 推断,我们实际上在 UI 框架之上建立了一个“业务逻辑防火墙”。Vue 只负责监听交互和渲染结果,而变幻莫测的业务规则被关在了纯 TypeScript 编写的沙盒里。

这种“工程化”的思维,不仅是为了今天能快速复刻功能,更是为了明天业务变动时,我们能优雅地“配置升级”,而不是“推倒重来”。


你是如何处理复杂表单联动的?欢迎在评论区分享你的“避坑”指南!

TanStack Router 路径参数(Path Params)速查表

2025年12月21日 19:31

路径参数是 URL 中以 $ 开头的变量,用于捕获动态内容。

功能 语法示例 URL 匹配示例 捕获到的变量
标准参数 $postId /posts/123 { postId: '123' }
带前缀 post-{$id} /posts/post-abc { id: 'abc' }
带后缀 {$name}.pdf /files/cv.pdf { name: 'cv' }
通配符 (Splat) $ /files/a/b/c.txt { _splat: 'a/b/c.txt' }
可选参数 {-$lang} /about/en/about { lang: undefined }'en'

详细功能解析

1. 基础用法 (Standard Usage)

在文件路由中,文件名即路径。使用 $ 声明变量。

  • 获取参数

    • Loader 中:通过参数对象 params 访问。

    • 组件 中:使用 Route.useParams() 钩子。

  • 代码分割技巧:如果组件是单独定义的,可用 getRouteApi('/path').useParams() 来保持类型安全。

2. 前缀与后缀 (Prefixes and Suffixes)

这是 TanStack Router 的一大特色,允许你在动态部分前后添加固定文本。

  • 语法:用花括号 {} 包裹变量名。

  • 场景:比如文件名匹配 {$id}.json,或者带特定标识的 ID user-{$userId}

  • 通配符组合:你甚至可以写 storage-{$}/$ 来匹配极其复杂的路径结构。

3. 可选路径参数 (Optional Path Parameters) ✨ 重点

使用 {-$variable} 语法。这意味着该段路径可以存在也可以不存在

  • 匹配逻辑/posts/{-$category} 既能匹配 /posts(参数为 undefined),也能匹配 /posts/tech

  • 导航:在 Link 中,如果想去掉可选参数,将值设为 undefined 即可。

4. 国际化 (i18n) 应用场景

可选参数最强大的地方在于处理语言前缀。

  • 设计/{-$locale}/about

  • 效果

    • 用户访问 /about -> 默认语言(如中文)。

    • 用户访问 /en/about -> 英文。

  • 这样你不需要为每种语言创建文件夹,只需一个路由逻辑即可搞定。

5. 类型安全 (Type Safety)

TanStack Router 的核心优势。

  • 当你跳转(LinkMaps)到一个带参数的路由时,TypeScript 会强制要求你提供该参数。

  • 如果是可选参数,TS 会自动推断其类型为 string | undefined,提醒你做空值处理。


常见疑问解答

Q: 怎么在组件外获取参数?

使用全局的 useParams({ strict: false })。但建议尽可能在路由内部使用,以获得完整的类型提示。

Q: 参数里能包含特殊字符(如 @)吗?

默认会进行 URL 编码。如果你想让它直接显示,需要在 createRouter 的配置中设置 pathParamsAllowedCharacters: ['@']

Q: 导航时如何保留现有的参数?

在 Link 或 Maps 的 params 中使用函数式写法:

params={(prev) => ({ ...prev, newParam: 'value' })}


一句话总结:

路径参数不仅是 $id 这么简单,通过 前缀/后缀、可选标志 和 强类型校验,你可以极其优雅地处理复杂的 URL 结构(如文件系统预览或多语言站点),且完全不会写错路径。

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

作者 SmalBox
2025年12月21日 19:16

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

GammaToLinearSpaceExact节点是Unity URP Shader Graph中用于色彩空间转换的重要工具,专门处理从伽马空间到线性空间的精确转换。在现代实时渲染管线中,正确的色彩空间管理对于实现物理准确的渲染效果至关重要。

色彩空间基础概念

在深入了解GammaToLinearSpaceExact节点之前,需要理解伽马空间和线性空间的基本概念。伽马空间是指经过伽马校正的非线性色彩空间,而线性空间则是未经校正的、与物理光照计算相匹配的色彩空间。

伽马校正的历史背景

  • 伽马校正最初是为了补偿CRT显示器的非线性响应特性而引入的
  • 人类视觉系统对暗部细节的感知更为敏感,伽马编码可以更有效地利用有限的存储带宽
  • 现代数字图像和纹理通常默认存储在伽马空间中

线性空间的重要性

  • 物理光照计算基于线性关系,使用线性空间可以确保光照计算的准确性
  • 纹理过滤、混合和抗锯齿在线性空间中表现更加正确
  • 现代渲染管线普遍采用线性空间工作流以获得更真实的渲染结果

GammaToLinearSpaceExact节点技术细节

GammaToLinearSpaceExact节点实现了从伽马空间到线性空间的精确数学转换,其核心算法基于标准的伽马解码函数。

转换算法原理

该节点使用的转换公式基于sRGB标准的逆伽马校正:

如果输入值 <= 0.04045 线性值 = 输入值 / 12.92 否则 线性值 = ((输入值 + 0.055) / 1.055)^2.4

这个精确的转换公式确保了从伽马空间到线性空间的数学准确性,与简单的幂函数近似相比,在低亮度区域提供了更高的精度。

### 数值范围处理

GammaToLinearSpaceExact节点设计用于处理标准化的数值范围:

- 输入值通常应在[0,1]范围内,但节点也能处理超出此范围的值
- 输出值保持与输入相同的数值范围,但分布特性发生了变化
- 对于HDR(高动态范围)内容,节点同样适用,但需要注意色调映射的后续处理

## 端口详细说明

![](https://docs.unity.cn/cn/Packages-cn/com.unity.shadergraph@14.0/manual/images/GammaToLinearSpaceExactNodeThumb.png)

### 输入端口(In)

输入端口接受Float类型的数值,代表需要转换的伽马空间数值。这个输入可以是单个标量值,也可以是向量形式的色彩值(当连接到色彩输出时)。

输入数值的特性:

- 通常来自纹理采样、常量参数或其他Shader Graph节点的输出
- 如果输入已经是线性空间的数值,使用此节点会导致不正确的渲染结果
- 支持动态输入,可以在运行时根据不同的条件改变输入源

### 输出端口(Out)

输出端口提供转换后的线性空间数值,类型同样为Float。这个输出可以直接用于后续的光照计算、材质属性定义或其他需要线性空间数据的操作。

输出数值的特性:

- 保持了输入数值的相对亮度关系,但数值分布发生了变化
- 暗部区域的数值被扩展,亮部区域的数值被压缩
- 输出可以直接用于物理光照计算,如漫反射、高光反射等

## 实际应用场景

GammaToLinearSpaceExact节点在URP Shader Graph中有多种重要应用场景,正确使用该节点可以显著提升渲染质量。

### 纹理色彩校正

当使用存储在伽马空间中的纹理时,必须将其转换到线性空间才能进行正确的光照计算。

应用示例步骤:

- 从纹理采样节点获取颜色值
- 将采样结果连接到GammaToLinearSpaceExact节点的输入
- 使用转换后的线性颜色值进行光照计算
- 在最终输出前,可能需要使用LinearToGammaSpaceExact节点转换回伽马空间用于显示

这种工作流程确保了纹理颜色在光照计算中的物理准确性,特别是在处理漫反射贴图、自发光贴图等影响场景光照的纹理时尤为重要。

### 物理光照计算

所有基于物理的渲染计算都应在线性空间中执行,GammaToLinearSpaceExact节点在此过程中扮演关键角色。

光照计算应用:

- 将输入的灯光颜色从伽马空间转换到线性空间
- 处理环境光照和反射探针数据
- 计算漫反射和高光反射时确保颜色值的线性特性

通过在线性空间中执行光照计算,可以避免伽马空间中的非线性叠加导致的光照过亮或过暗问题,实现更加自然的明暗过渡和色彩混合。

### 后期处理效果

在实现屏幕后处理效果时,正确管理色彩空间对于保持效果的一致性至关重要。

后期处理中的应用:

- 色彩分级和色调映射
- 泛光和绽放效果
- 色彩校正和滤镜效果

在这些应用中,需要先将输入图像从伽马空间转换到线性空间进行处理,处理完成后再转换回伽马空间用于显示,确保处理过程中的色彩操作符合线性关系。

## 与其他色彩空间节点的对比

Unity Shader Graph提供了多个与色彩空间相关的节点,了解它们之间的区别对于正确选择和使用至关重要。

### GammaToLinearSpaceExact与GammaToLinearSpace

GammaToLinearSpace节点提供了类似的伽马到线性转换功能,但使用的是近似算法:

线性值 ≈ 输入值^2.2

对比分析:

- GammaToLinearSpaceExact使用精确的sRGB标准转换,在低亮度区域更加准确
- GammaToLinearSpace使用简化近似,计算效率更高但精度稍低
- 在需要最高视觉质量的场合推荐使用Exact版本,在性能敏感的场景可以考虑使用近似版本

### 与LinearToGammaSpaceExact的关系

LinearToGammaSpaceExact节点执行相反的转换过程,将线性空间值转换回伽马空间。

转换关系对应:

- GammaToLinearSpaceExact和LinearToGammaSpaceExact是互逆操作
- 在渲染管线的开始阶段使用GammaToLinearSpaceExact,在最终输出前使用LinearToGammaSpaceExact
- 这种配对使用确保了整个渲染流程的色彩空间一致性

## 性能考量与最佳实践

虽然GammaToLinearSpaceExact节点的计算开销相对较小,但在大规模使用时仍需考虑性能影响。

### 性能优化建议

合理使用GammaToLinearSpaceExact节点可以平衡视觉质量和渲染性能:

- 对于已经在线性空间中的纹理(如HDRi环境贴图),不需要使用此节点
- 在不需要最高精度的场合,可以考虑使用GammaToLinearSpace近似节点
- 避免在片段着色器中重复执行相同的转换,尽可能在顶点着色器或预处理阶段完成
- 利用Unity的纹理导入设置,直接将纹理标记为线性空间,避免运行时转换

### 常见错误与调试

使用GammaToLinearSpaceExact节点时常见的错误和调试方法:

色彩过暗或过亮问题:

- 检查是否重复应用了伽马校正
- 确认输入纹理的正确的色彩空间设置
- 验证整个渲染管线中色彩空间转换的一致性

性能问题诊断:

- 使用Unity的Frame Debugger分析着色器执行开销
- 检查是否有不必要的重复转换操作
- 评估使用纹理导入时预转换的可行性

## 完整示例:实现物理准确的漫反射着色

下面通过一个完整的示例展示GammaToLinearSpaceExact节点在实际着色器中的应用。

### 场景设置

创建一个简单的场景,包含:

- 一个定向光源
- 几个具有不同颜色的物体
- 使用标准URP渲染管线

### 着色器图构建

构建一个使用GammaToLinearSpaceExact节点的基本漫反射着色器:

节点连接流程:

- 使用Texture2D节点采样漫反射贴图
- 将采样结果连接到GammaToLinearSpaceExact节点的输入
- 将转换后的线性颜色连接到URP Lit节点的Base Color输入
- 配置适当的光照和材质参数

### 对比测试

创建两个版本的着色器进行对比:

- 版本A:正确使用GammaToLinearSpaceExact节点
- 版本B:直接使用伽马空间的纹理颜色

观察两个版本在相同光照条件下的表现差异:

- 版本A提供更加真实的光照响应和颜色饱和度
- 版本B可能出现不正确的亮度积累和色彩偏移
- 在高光区域和阴影过渡区域,版本A的表现更加自然

## 高级应用技巧

除了基本用法,GammaToLinearSpaceExact节点还可以与其他Shader Graph功能结合,实现更复杂的效果。

### 与自定义光照模型结合

在实现自定义光照模型时,正确管理色彩空间至关重要:

实现步骤:

- 将所有输入的颜色数据转换到线性空间
- 在线性空间中执行光照计算
- 将最终结果转换回伽马空间输出
- 确保所有中间计算保持线性关系

这种方法确保了自定义光照模型与URP内置光照的一致性,避免了因色彩空间不匹配导致的视觉异常。

### HDR内容处理

处理高动态范围内容时,GammaToLinearSpaceExact节点的使用需要特别注意:

HDR工作流考虑:

- HDR纹理通常已经在线性空间中,不需要额外转换
- 当混合LDR和HDR内容时,需要确保统一的色彩空间
- 在色调映射阶段前,所有计算应在线性空间中进行

通过正确应用GammaToLinearSpaceExact节点,可以确保HDR和LDR内容的无缝融合,实现更高品质的视觉表现。

## 平台兼容性说明

GammaToLinearSpaceExact节点在不同平台和渲染API上的行为基本一致,但仍有少量注意事项。

### 移动平台优化

在移动设备上使用GammaToLinearSpaceExact节点时:

- 大部分现代移动GPU能够高效处理sRGB转换
- 在性能较低的设备上,可以考虑使用近似版本
- 利用移动平台的sRGB纹理格式,可以减少显式转换的需要

### 不同图形API的表现

在各种图形API中,GammaToLinearSpaceExact节点的行为:

- 在支持sRGB帧缓冲的API上(如OpenGL ES 3.0+,Metal,Vulkan),Unity会自动处理帧缓冲的伽马校正
- 在旧版API上,可能需要手动管理最终的色彩空间转换
- 节点本身的数学计算在所有API上保持一致

了解这些平台特性有助于编写跨平台兼容的着色器,确保在不同设备上的一致视觉表现。

## 总结

GammaToLinearSpaceExact节点是Unity URP Shader Graph中实现物理准确渲染的关键组件。通过正确理解和使用该节点,开发者可以:

- 确保纹理和颜色数据在光照计算中的物理准确性
- 实现更加真实和一致的视觉表现
- 避免常见的色彩空间相关渲染问题
- 构建高质量、跨平台兼容的着色器效果

---
> [【Unity Shader Graph 使用与特效实现】](https://blog.csdn.net/chenghai37/category_13074589.html?fromshare=blogcolumn&sharetype=blogcolumn&sharerId=13074589&sharerefer=PC&sharesource=chenghai37&sharefrom=from_link)**专栏-直达**
(欢迎*点赞留言*探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Flutter 勇闯2D像素游戏之路(四):与哥布林战斗的滑步魔法师

作者 _大学牲
2025年12月20日 18:53

Flutter 勇闯2D像素游戏之路(一):一个 Hero 的诞生
Flutter 勇闯2D像素游戏之路(二):绘制加载游戏地图
Flutter 勇闯2D像素游戏之路(三):人物与地图元素的交互
Flutter 勇闯2D像素游戏之路(四):与哥布林战斗的滑步魔法师

前言

在上篇文章中,我们完成了和 地图元素 的交互,开箱、开门、被刺扎 无所不精。
那么本章给大家介绍,一款游戏的精髓 对战 相关元素的实现。
这一元素类比 谈恋爱,可以说是,给大多数玩家的第一印象 了。

对战元素的重要性

维度 对战元素的作用 对游戏的影响
玩家存在感 操作能立刻产生命中、受伤、击杀得到正反馈 强化游戏世界 的真实感
游戏节奏 形成紧张(战斗)与放松(探索/叙事)的结合 避免节奏单一,降低疲劳感
决策决断 引入风险与回报(打/绕、进/退、用/留) 从执行操作升级为策略选择
重复可玩性 敌人组合、走位、战况具有不确定性 提高重玩价值,延长游戏寿命
情绪驱动 胜利、失败、逆转带来情绪波动 增强成就感与沉浸感

总结
对战元素并不只是 战斗机制,而是连接玩家行为、系统设计与情绪体验的 核心枢纽,决定了一款游戏是否真正具备持续吸引力。

github源码游戏在线体验地址(体验版为网页,可能手感较差,推荐源码安装至手机体验) 皆在文章最后。

Myhero

一. 本章目标

二. 实现 HUD 面板

HUD(Heads-Up Display,抬头显示)是始终 固定在屏幕上 的游戏界面层,用于向玩家展示信息并接收操作。
例如 血条、技能按钮、摇杆和暂停按钮,它不参与世界碰撞、不随地图或相机移动,只负责 显示与输入

1. 素材

大家可以去下面 两个网站 中,找找自己心仪的,或者直接使用我上面的图片(在 仓库 中)。

爱给网: www.aigei.com/
itch : itch.io/

2. 人物血条

观察上述 心型血条 的精灵图,我们可以得到思路:

  • 从满血到空血,一共是四个阶段,我们就姑且让 每颗 ❤️ 承载 4点血

    // 每个心跳组件包含的生命值
    final int hpPerHeart = 4;
    
  • 因此,将每颗 ❤️,单独作为一个 HeartComponent,只负责单颗 ❤️ 扣血或加血的图片变化

    class HeartComponent extends SpriteComponent {
      final List<Sprite> sprites;
    
      HeartComponent(this.sprites) {
        sprite = sprites.last; // 默认满血
      }
    
      void setHpStage(int stage) {
        sprite = sprites[stage.clamp(0, sprites.length - 1)];
      }
    }
    
  • 最后通过 HeroHpHud 统一管理:

    • 添加管理 HeartComponent: 计算人物总心数 (❤️总颗数 = 总血量 / 每颗 ❤️血量),动态生成 component。
       // 心跳组件
       final List<HeartComponent> hearts = [];
      
       // 每个心跳组件的精灵图
       late final List<Sprite> heartSprites;
      
      ...
      
      for (int i = 0; i < hero.maxHp ~/ hpPerHeart; i++) {
        final double heartSize = 24;
        final double heartSpacing = heartSize + 1;
        final heart = HeartComponent(heartSprites)
          ..size = Vector2(heartSize, heartSize)
          ..position = Vector2(i * heartSpacing, 0);
        hearts.add(heart);
        add(heart);
      }
      
    • 动态更新血条:每帧更新时,获取人物血量,计算得出每颗 ❤️该展示的阶段。
      void update(double dt) {
          super.update(dt);
          final totalHearts = hearts.length;
          final clampedHp = hero.hp.clamp(0, hero.maxHp);
          for (int i = 0; i < totalHearts; i++) {
            final start = i * hpPerHeart;
            final filled = (clampedHp - start).clamp(0, hpPerHeart);
            hearts[i].setHpStage(filled);
          }
        }
      

3. 怪物血条

相对于 人物血条 的精心展示,怪物血条 可就太简单了,接下来我们就简单阐述一下步骤:

  • 绘制血条背景:绘制一个 半透明黑色的canvas ,帮助用户直观的感受怪物血量的减少。
     // 背景色
     final bgPaint = Paint()
        ..color = Colors.black.withOpacity(0.6);
    
      // 背景
      canvas.drawRect(
        Rect.fromLTWH(0, 0, size.x, size.y),
        bgPaint,
      );
    
  • 绘制真实血量条:在黑色背景相同位置,绘制一个真实血量条,且在不同 血量比例 动态变化颜色。
        // 血条色
        final hpPaint = Paint()
          ..color = _hpColor();
    
        // 当前血量
        final ratio = currentHp / maxHp;
        canvas.drawRect(
          Rect.fromLTWH(0, 0, size.x * ratio, size.y),
          hpPaint,
        );
    
        ...
    
      // 不同血量比例动态变化颜色
      Color _hpColor() {
        final ratio = currentHp / maxHp;
        if (ratio > 0.6) return Colors.green;
        if (ratio > 0.3) return Colors.orange;
        return Colors.red;
      }
    
  • 每帧更新血量变化
      void updateHp(int hp) {
        currentHp = hp.clamp(0, maxHp);
      }
    

4. 攻击技能按钮

像手机游戏中 攻击按钮的布局,大多数都是和 王者荣耀 大差不差的,因此我们也来实现一下:

(1)实现单个技能按钮 AttackButton
  • 构造参数
    • HeroComponent hero:传入按钮的使用者
    • String icon:传入按钮的图标
    • VoidCallback onPressed:传入按钮的执行函数
      AttackButton({
        required this.hero,
        required String icon,
        required VoidCallback onPressed,
      }) : iconName = icon,
           super(
             onPressed: onPressed,
             size: Vector2.all(72),
             anchor: Anchor.center,
           );
    
  • 加载icon ,绘制外边框:
    Future<void> onLoad() async {
        await super.onLoad();
        button = await Sprite.load(iconName);
    
        // 添加外部圆圈
        add(
          CircleComponent(
            radius: 36,
            position: size / 2,
            anchor: Anchor.center,
            paint: Paint()
              ..color = Colors.white38
              ..style = PaintingStyle.stroke
              ..strokeWidth = 4,
          ),
        );
      }
    
  • 重写 render , 裁剪 ⭕️ 多余部分:
    @override
    void render(Canvas canvas) {
      canvas.save();
      final path = Path()..addOval(size.toRect());
      canvas.clipPath(path);
      super.render(canvas);
      canvas.restore();
    }
    
(2)创建按钮组 AttackHud
  • 创建一个 buttonGroup 容器
        buttonGroup = PositionComponent()
          ..anchor = Anchor.center
          ..position = Vector2.zero();
    
  • 获取技能数量
        final attacks = hero.cfg.attack;
        final count = attacks.length;
    
  • 定义了一个 左下 → 正左 的扇形
    final radius = buttonSize + 32.0;
    final startDeg = 270.0;
    final endDeg = 180.0;
    
  • 动态创建按钮:
    • 第一个普通攻击放中间
    • 其他按钮靠扇形均匀分布
    • 创建 AttackButton 并挂载
    for (int i = 0; i < count; i++) {
            Vector2 position;
    
            // 普通攻击放中间
            if (i == 0) {
              position = Vector2.zero();
            } else {
              final skillIndex = i - 1;
              final skillCount = count - 1;
              
              // 均匀分布
              final t = skillCount <= 1 ? 0.5 : skillIndex / (skillCount - 1);
              final deg = startDeg + (endDeg - startDeg) * t;
              
              // 极坐标 → 屏幕坐标
              final rad = deg * math.pi / 180.0;
              position = Vector2(math.cos(rad), math.sin(rad)) * radius;
            }
    
            buttonGroup.add(
              AttackButton(
                hero: hero,
                icon: attacks[i].icon!,
                onPressed: () => _attack(i),
              )..position = position,
            );
          }
    
  • 调用人物攻击
    void _attack(int index) {
        hero.attack(index, MonsterComponent);
      }
    

三. 人物组件的抽象继承

1. 创建角色配置文件

将角色参数硬编码 在组件中,会导致组件与具体角色 强耦合,一旦涉及多角色体系或怪物规模化生成,代码将迅速💥🥚。
因此我们引入角色配置层,通过配置文件描述角色的 动画、属性与碰撞信息,由 角色基类 在运行时统一加载。
这样就使角色系统从 代码驱动 ➡ 数据驱动,提升了扩展性与维护性。

/// 角色配置
/// id 角色id
/// spritePath 角色sprite路径
/// cellSize 角色sprite单元格大小
/// componentSize 角色组件大小
/// maxHp 最大生命值
/// attackValue 攻击值
/// speed 移动速度
/// detectRadius 检测半径
/// attackRange 攻击范围
/// hitbox 人物体型碰撞框
/// animations 动画
/// attack 攻击列表
class CharacterConfig {
  final String id;
  final String spritePath;
  final Vector2 cellSize;
  final Vector2 componentSize;
  final int maxHp;
  final int attackValue;
  final double speed;
  final double detectRadius;
  final double attackRange;
  final HitboxSpec hitbox;
  final Map<Object, AnimationSpec> animations;
  final List<AttackSpec> attack;

  const CharacterConfig({
    required this.id,
    required this.spritePath,
    required this.cellSize,
    required this.componentSize,
    required this.maxHp,
    required this.attackValue,
    required this.speed,
    this.detectRadius = 500,
    this.attackRange = 60,
    required this.hitbox,
    required this.animations,
    required this.attack,
  });

  static CharacterConfig? byId(String id) => _characterConfigs[id];
}

有了这份驱动数据后,对驴画马 将原来的 HeroComponent 中的角色通用数据和方法,集中到 CharacterComponent,在单独继承实现HeroComponent 和其他扩展类,也是简简单单。

因此,人物拆分内容就不多赘述,仅作介绍,具体实现查看 仓库源码

2. 抽象角色组件 CharacterComponent

模块分类 功能点 已实现内容 说明
基础定义 角色基础组件 继承 SpriteAnimationComponent 具备精灵动画、位置、尺寸、朝向能力
游戏引用 HasGameReference<MyGame> 可访问 world、blockers、camera 等
配置系统 角色配置加载 CharacterConfig.byId(characterId) 角色属性、攻击配置数据驱动
贴图资源 spritePath / cellSize 统一从配置加载动画资源
基础属性 生命值系统 maxHp / hp / loseHp() 提供完整生命管理与死亡判定
攻击数值 attackValue 基础攻击力字段
移动速度 speed 用于位移 / 冲刺
状态系统 状态枚举 CharacterState idle / run / attack / hurt / dead
状态锁 isActionLocked 攻击 / 受伤 / 死亡期间禁止操作
状态切换 setState() 同步动画与状态
动画系统 动画加载 loadAnimations() 从 SpriteSheet 构建状态动画
攻击动画 playAttackAnimation() 播放攻击动画并自动回 idle
朝向控制 水平朝向 facingRight 统一攻击 / 移动方向
翻转逻辑 faceLeft / faceRight() 精灵水平翻转
攻击系统 攻击入口 attack(index, targetType) 角色统一攻击接口
Hitbox 解耦 AttackHitboxFactory.create() 攻击判定完全工厂化
攻击动画驱动 攻击前播放动画 动画与判定分离
碰撞体系 主体碰撞体 RectangleHitbox hitbox 用于世界实体碰撞
矩形碰撞检测 collidesWith(Rect) 提供矩形级碰撞判断
碰撞纠正 resolveOverlaps(dt) 解决人物卡死
移动系统 碰撞移动 moveWithCollision() 支持滑动的阻挡碰撞移动
回退机制 X/Y 分轴处理 防止角色卡死
环境交互 地形阻挡 game.blockers 墙体 / 障碍物阻挡
门交互 DoorComponent.attemptOpen() 带条件的交互碰撞
角色交互 角色间阻挡 与其他 CharacterComponent 碰撞 防止角色重叠
召唤物AI逻辑 死亡处理 updateSummonAI(dt) 寻找敌人、攻击、跟随主人、待机
生命周期 死亡处理 onDead()(抽象) 子类实现具体死亡行为
扩展能力 抽象基类 abstract class Hero / Monster / NPC 统一父类

3. 实现 HeroComponent

功能模块 已实现作用 说明
角色身份 明确为玩家角色 Hero 是可输入控制的 Character
钥匙系统 管理玩家持有的钥匙集合 keys 用于门、机关等条件交互
道具反馈 获取钥匙时 UI 提示 UiNotify.showToast 属于玩家反馈
动画初始化 加载并绑定角色动画 使用配置表中的 animations
初始状态设置 初始为 idle 状态 Hero 出生即待机
出生位置设置 设置初始坐标 通常只由 Hero 决定
碰撞体创建 创建并挂载角色 Hitbox Hero 的物理形态
相机绑定 相机跟随玩家 game.camera.follow(this)
输入处理 读取摇杆输入 Hero 独有,怪物不会有
状态切换 idle / run 状态管理 基于玩家输入
受击反馈 播放受击音效与动画 玩家专属体验反馈
受击状态恢复 受击后回到 idle 保证操作连贯性
死亡表现 播放死亡动画 与怪物死亡逻辑不同
重开流程 显示 Restart UI 只属于玩家死亡逻辑
UI 交互 与 HUD / Overlay 联动 Hero 是 UI 的核心数据源

4. 实现 MonsterComponent

功能模块 已实现作用 说明
角色身份 明确为怪物角色 由 AI 控制的 Character
出生点管理 固定出生坐标 birthPosition 决定怪物初始位置
怪物类型标识 monsterId 用于读取配置、区分怪物
动画初始化 加载并绑定怪物动画 来自 cfg.animations
初始状态设置 初始为 idle 出生即待机
碰撞体创建 创建并挂载 Hitbox 怪物物理边界
血条组件 头顶血条显示 MonsterHpBarComponent
血量同步 实时更新血条 每帧 hpBar.updateHp
简单AI 探测距离、追逐、攻击玩家和自主游荡 detectRadius、attackRange
状态切换 idle / run / hurt 由 AI 决定
受击反馈 播放受击动画 无 UI 提示
受击恢复 受击后回到 idle 保证 AI 连贯
死亡表现 播放死亡动画 不显示 UI
销毁逻辑 死亡后移除实体 removeFromParent()

四. 碰撞类攻击的实现

完成了 人物 那个基础要点,接下来免不了的就是 攻击逻辑 了,这是大多数游戏的核心。
而游戏的 人物攻击 一样都离不开 碰撞,甚至后者更甚之。

1.思路

无论是 近战、远程 和 冲刺,造成 伤害 第一要点,就是攻击产生的 矩形 碰撞到目标敌人了。
其次,在手机肉鸽游戏中,近战和远程 的攻击总会自动索敌,这也是一个 通用点
因此,得出上述逻辑之后,我们必然将共性点抽象为 基类,其他任意 碰撞产生伤害的攻击 继承实现 即可。

2. 攻击判定基类 AbstractAttackRect

(1) 基础属性管理
  • damage:伤害
  • owner:归属者
  • targetType:目标类型
  • duration:持续时长
  • removeOnHit:是否穿透
  • maxLockDistance:最大距离
(2) 命中检测机制
  • 提供 getAttackRect 接口支持自定义几何区域判定(如扇形、多边形)。

      /// 返回该组件用于判定的几何区域
      ui.Rect getAttackRect();
    
  • 内置目标去重机制 _hitTargets,防止单次攻击多段伤害异常。

      final Set<PositionComponent> _hitTargets = {};
    
  • 集成 CollisionCallbacks 支持物理引擎碰撞。

    @override
      void onCollisionStart(
        Set<Vector2> intersectionPoints,
        PositionComponent other,
      ) {
        super.onCollisionStart(intersectionPoints, other);
        _applyHit(other);
      }
    
      @override
      void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
        super.onCollision(intersectionPoints, other);
        _applyHit(other);
      }
    

    ⚠️ 注意

    如果这里只依靠 FlameCollisionCallbacks 判断命中,是有问题的:

    • 已经站在攻击矩形里的敌人 ❌ 不会触发
    • 攻击生成瞬间就重叠 ❌ 不一定触发

    这就是 砍刀贴脸 = 没伤害 的经典 bug 来源

    究其原因,Flame 的碰撞模型核心是 发生碰撞 → 触发回调,只能告诉你 两个碰撞体是否接触
    但它不能可靠地回答: 当前攻击区域内 有哪些 目标

    因此,我们需要在 update 中手动判断,CollisionCallbacks只能作为辅助判断。

          @override
          void update(double dt) {
            super.update(dt);
            final ui.Rect attackRect = getAttackRect();
             if (targetType == HeroComponent) {
               for (final h in game.world.children.query<HeroComponent>()) {
                 if (h == owner) continue;
                 final ui.Rect targetRect = h.hitbox.toAbsoluteRect();
                 if (_shouldDamage(attackRect, targetRect)) {               _applyHit(h);
               }
             }
            } else if (targetType == MonsterComponent) {
              for (final m in game.world.children.query<MonsterComponent>()) {
                if (m == owner) continue;
                final ui.Rect targetRect = m.hitbox.toAbsoluteRect();
                if (_shouldDamage(attackRect, targetRect)) {               _applyHit(m);
                }
              }
            }
          }
    
(3) 智能索敌系统
  • autoLockNearestTarget:自动筛选最近的有效目标(排除自身、过滤距离)。
      /// 子类实现:当找到最近目标时的处理
      void onLockTargetFound(PositionComponent target);
    
      /// 子类实现:当未找到目标时的处理(如跟随摇杆方向)
      void onNoTargetFound();
    
      /// 自动锁定最近目标
      void autoLockNearestTarget() {
        final PositionComponent? target = _findNearestTarget();
        if (target != null) {
          onLockTargetFound(target);
        } else {
          onNoTargetFound();
        }
      }
    
  • angleToTarget:计算精准的攻击朝向。
      /// 计算到目标的朝向角度(弧度)
      double angleToTarget(PositionComponent target, Vector2 from) {
        final Vector2 origin = from;
        final Vector2 targetPos = target.position.clone();
        return math.atan2(targetPos.y - origin.y, targetPos.x - origin.x);
      }
    
(4) 生命周期控制
  • 支持命中即销毁 (removeOnHit) 或穿透模式。(子类通过传递参数控制)
      if (removeOnHit) {
        removeFromParent();
      }
    
  • 基于时间的自动销毁机制。
(5) 扩展说明
  • 所有攻击判定体(近战、子弹、AOE等)均应继承此类。
  • 子类需实现 getAttackRect 以定义具体的攻击区域形状。

3. 普通近战攻击组件 MeleeHitbox

(1) 矩形判定区域
  • 使用 RectangleHitbox 作为物理碰撞检测区域。
      @override
      ui.Rect getAttackRect() => hitbox.toAbsoluteRect();
    
  • 默认配置为被动碰撞类型 (CollisionType.passive)。
       hitbox = RectangleHitbox()..collisionType = CollisionType.passive;
    

    👉 表示:这个碰撞体只接收碰撞,不主动推动或阻挡别人

(2) 自动索敌转向
@override
  void onLockTargetFound(PositionComponent target) {
    final ui.Rect rect = getAttackRect();
    final Vector2 center = Vector2(
      rect.left + rect.width / 2,
      rect.top + rect.height / 2,
    );
    angle = angleToTarget(target, center);
  }
  • 重写 onLockTargetFound 实现攻击方向自动对准最近目标。
  • 通过调整组件旋转角度 (angle) 来指向目标中心。
(3) 生命周期管理
 double _timer = 0;

 @override
  void update(double dt) {
    ...

    _timer += dt;
    if (_timer >= duration) {
      removeFromParent();
    }
  }
  • 使用内部计时器 _timer 精确控制攻击持续时间。
  • 超时自动销毁,模拟瞬间挥砍效果。
(4) 位置修正
 // 将传入的左上角坐标转换为中心坐标以便旋转
 position: position + size / 2,
  • 构造时自动将左上角坐标转换为中心坐标,确保旋转围绕中心点进行。
(5) 适用场景
  • 刀剑挥砍、拳击等 短距离瞬间攻击
  • 需要自动吸附或转向目标的近身攻击。

4. 远程投射物攻击组件 BulletHitbox

(1) 直线弹道运动
  • 基于 directionconfig.speed 进行每帧位移。
  • 记录飞行距离 _distanceTraveled,超过射程 config.maxRange 自动销毁。
      @override
      void update(double dt) {
        super.update(dt);
    
        ...
    
        final moveStep = direction * config.speed * dt;
        position += moveStep;
        _distanceTraveled += moveStep.length;
    
        if (_distanceTraveled >= config.maxRange) {
          removeFromParent();
        }
      }
    
(2) 智能索敌与方向锁定
  • onLockTargetFound:发射时若检测到敌人,自动锁定方向朝向敌人。

      @override
      void onLockTargetFound(PositionComponent target) {
        // 设置从人物到最近敌人的直线方向
        final Vector2 origin = position.clone();
        final Vector2 targetPos = target.position.clone();
        direction = (targetPos - origin).normalized();
        _locked = true;
      }
    
  • onNoTargetFound:若无敌人,优先使用摇杆方向,否则保持初始方向。

  • _locked 机制:确保子弹一旦发射,方向即被锁定,不会随玩家后续操作改变轨迹。

    bool _locked = false;
    
    @override
      void onNoTargetFound() {
        // 子弹攻击:若无目标,且尚未锁定方向,则尝试使用摇杆方向
        // 如果摇杆也无输入,保持初始 direction
        if (!_locked && !game.joystick.delta.isZero()) {
          direction = game.joystick.delta.normalized();
        }
        // 无论是否使用了摇杆方向,只要进入这里(说明没找到敌人),就锁定方向。
        // 防止后续飞行中因为摇杆变动而改变方向。
        _locked = true;
      }
    
(3) 视觉表现

  • 支持静态贴图或帧动画 (SpriteAnimationComponent)。

      @override
      Future<void> onLoad() async {
        super.onLoad();
        if (config.spritePath != null) {
          final image = await game.images.load(config.spritePath!);
    
          if (config.animation != null) {
            final sheet = SpriteSheet(
              image: image,
              srcSize: config.textureSize ?? config.size,
            );
            final anim = sheet.createAnimation(
              row: config.animation!.row,
              stepTime: config.animation!.stepTime,
              from: config.animation!.from,
              to: config.animation!.to,
              loop: config.animation!.loop,
            );
            add(SpriteAnimationComponent(animation: anim, size: size));
          } else {
            final sprite = Sprite(image);
            add(SpriteComponent(sprite: sprite, size: size));
          }
        }
      }
    
(4) 碰撞特性
  • 使用 RectangleHitbox 并设为 CollisionType.active 主动检测碰撞。
    RectangleHitbox()..collisionType = CollisionType.active;
    
  • 支持穿透属性 (config.penetrate),决定命中后是否立即销毁。
    removeOnHit: !config.penetrate,
    

(5) 适用场景
  • 弓箭、魔法球、枪械子弹等远程攻击。
  • 需要直线飞行且射程受限的投射物。

5. 冲刺攻击组件 DashHitbox

滑步 其实就是游戏中常见的 冲撞技能
因此,我们依旧继承我们的攻击判定基类 AbstractAttackRect,并在此基础上实现位移就行了。

(1) 位移与物理运动
  • 直接驱动归属者 owner 进行高速位移。
  • 集成物理碰撞检测 moveWithCollision,防止穿墙。
  • 持续同步位置 position.setFrom(owner.position),确保攻击判定跟随角色。
if (_locked && !direction.isZero()) {
      final delta = direction * speed * dt;
      if (owner is CharacterComponent) {
         final char = owner as CharacterComponent;
         char.moveWithCollision(delta);

         if (delta.x > 0) char.faceRight();
         if (delta.x < 0) char.faceLeft();
      } else {
         owner.position += delta;
      }
    }

position.setFrom(owner.position);
(2) 摇杆操作与方向锁定
  • onNoTargetFound:优先使用摇杆方向,否则沿当前朝向冲刺。
  • _locked 机制:确保冲刺过程中方向恒定,不受中途操作影响。
@override
  void onNoTargetFound() {
    if (_locked) return;
    
    if (!game.joystick.delta.isZero()) {
      direction = game.joystick.delta.normalized();
    } else {
       if (owner is CharacterComponent) {
         direction = Vector2((owner as CharacterComponent).facingRight ? 1 : -1, 0);
       } else {
         direction = Vector2(1, 0); 
       }
    }
    _locked = true;
  }
(3) 持续伤害判定
  • removeOnHit: false:冲刺不会因命中敌人而停止 (穿透效果)。
  • 在持续时间 duration 内,对路径上接触的所有有效目标造成伤害。
_elapsedTime += dt;
if (_elapsedTime >= duration) {
  removeFromParent();
  return;
}
(4) 生命周期管理
  • 基于时间 _elapsedTime 控制冲刺时长,结束后自动销毁组件。
(5) 适用场景
  • 战士冲锋、刺客突进等位移技能。
  • 需要同时兼顾位移和伤害的技能机制。

五. 游戏音效

截屏2025-12-20 16.58.12.png

在游戏体验中,音效并不是装饰品,而是反馈系统的一部分
无论是攻击命中、角色受伤,还是场景交互,如果音效分散写在各个组件中,往往会造成资源重复加载、逻辑混乱、难以统一管理音量与状态的问题。

因此,我们对游戏音频进行统一封装,引入一个 AudioManager,集中负责 BGM 与音效(SFX)的加载、播放、暂停与语义化调用,让游戏逻辑只关心 发生了什么,而不关心音效怎么放

1. 封装 AudioManager


class AudioManager {
  static bool _inited = false;
  static String? _currentBgm;

  static double bgmVolume = 0.8;
  static double sfxVolume = 1.0;

  /// 必须在游戏启动时调用
  static Future<void> init() async {
    ...
  }

  // ================== SFX ==================

  static Future<void> playSfx(String file, {double? volume}) async {
    ...
  }

  // ================== BGM ==================

  static Future<void> playBgm(String file, {double? volume}) async {
    ...
  }

  static Future<void> stopBgm() async {
    ...
  }

  static Future<void> pauseBgm() async {
    ...
  }

  static Future<void> resumeBgm() async {
    ...
  }

  // ================== 语义化封装 ==================

  static Future<void> playDoorOpen() => playSfx('door_open.wav');
  static Future<void> playSwordClash() => playSfx('sword_clash_2.wav');
  static Future<void> playFireLighting() => playSfx('fire_lighting.wav');
  static Future<void> startBattleBgm() => playBgm('Goblins_Dance_(Battle).wav');
  static Future<void> startRegularBgm() => playBgm('Goblins_Den_(Regular).wav');
  static Future<void> playDoorKnock() => playSfx('door_knock.wav');
  static Future<void> playWhistle() => playSfx('whistle.wav');
  static Future<void> playHurt() => playSfx('Hurt.wav');
  static Future<void> playLaserGun() => playSfx('Laser_Gun.wav');
}

2. 音效使用

  • mygame 中初始化,并开始播放 bgm
      @override
      Future<void> onLoad() async {
        // 加载游戏资源
        super.onLoad();
        // 初始化音频管理器
        await AudioManager.init();
        // 播放BGM
        AudioManager.startRegularBgm();
        // 加载地图
        await _loadLevel();
      }
    
  • BulletHitbox子弹音效
  • DoorComponent: 敲门与关门音效
  • HeroComponent: 受击音效
  • ...

大家去网上找 音频 后,保存在 assets/aduio/ 下,在 AudioManager 中加载使用, 就可以在你想要的地方添加了。

六. 召唤术

在上述,有了 近战、远程和冲刺 的矩形判断之后,我们的小人就掌握了 普攻火球法术滑步
但是,我觉得那些还不够有意思,因为他是 魔法师
于是乎,会 召唤 小弟的滑步魔法师,他来了。

1. 构思

一开始,我打算新建一个 GenerateComponent 继承 CharacterComponent
这很简单,依葫芦画瓢 很快也就实现了,但是到了召唤物攻击逻辑时,就头疼了。
因为,我们之前所有逻辑都是围绕两个阵营的 hero 🆚 monster,新增第三方,就要重构了。
但是转念一想,其实这个召唤物和其他两个类没什么不同,索性哪个人物召唤的,召唤物就用哪个人物的类创建就行了。
所有逻辑都不需要改变了,对战逻辑完全符合,仅仅需要新增一段 召唤物AI逻辑 就可以了。

2. 实现

  • 新建召唤物生成工厂类 GenerateFactory
  • 所需属性
    • game:游戏容器,用于添加召唤物
    • center: 人物中心点
    • generateId: 召唤物id,用于定位配置资源
    • owner: 召唤者
    • enemyType: 敌对类型
    • count: 召唤物数量
    • radius: 角度
    • followDistance: 跟随距离
  • 确定生成物相对于人物的位置
    final step = 2 * math.pi / count;
    final start = -math.pi / 2;
    final list = <CharacterComponent>[];
    for (int i = 0; i < count; i++) {
      final angle = start + i * step;
      final pos = Vector2(
        center.x + radius * math.cos(angle),
        center.y + radius * math.sin(angle),
      );     
    
  • 生成所有召唤物
    // 根据拥有者类型决定生成物类型
    // Hero生成HeroComponent作为随从
    // Monster生成MonsterComponent作为随从
    if (owner is HeroComponent) {
     comp = HeroComponent(
       heroId: generateId, 
       birthPosition: position,
     );
    } else {
     comp = MonsterComponent(position, generateId);
    }
    
    // 设置召唤物通用属性
    comp.position = position;
    comp.isGenerate = true;
    comp.summonOwner = owner;
    comp.followDistance = followDistance;
    
    
    ...
    
    list.add(
       create(
         position: pos,
         generateId: generateId,
         owner: owner,
         enemyType: enemyType,
         followDistance: followDistance,
       ),
     );
    

七. 人机逻辑

在游戏中,敌人是否 像个人 ,很大程度上取决于人机逻辑(AI)。
咱们的 AI 并不追求复杂,而是要做到 感知、判断和反馈
能发现敌人、能决定行动、也能在不同状态之间自然切换。

1. 怪物索敌逻辑

  • 首先,寻找最近的 HeroComponent 作为目标
        PositionComponent? target;
        double distance = double.infinity;
    
        for (final h in monster.game.world.children.query<HeroComponent>()) {
          final d = (h.position - monster.position).length;
          if (d < distance) {
            distance = d;
            target = h;
          }
        }
    
  • 然后,如果超出 感知范围 或未找到 目标,则 游荡
    if (target == null || distance > monster.detectRadius) {
         if (monster.wanderDuration > 0) {
           monster.setState(CharacterState.run);
           final delta = monster.wanderDir * monster.speed * dt;
           monster.moveWithCollision(delta);
           monster.wanderDuration -= dt;
           monster.wanderDir.x >= 0 ? monster.faceRight() : monster.faceLeft();
         } else {
           monster.wanderCooldown -= dt;
           if (monster.wanderCooldown <= 0) {
             final angle = monster.rng.nextDouble() * 2 * math.pi;
             monster.wanderDir = Vector2(math.cos(angle), math.sin(angle));
             monster.wanderDuration = 0.6 + monster.rng.nextDouble() * 1.2;
             monster.wanderCooldown = 1.0 + monster.rng.nextDouble() * 2.0;
           } else {
             monster.setState(CharacterState.idle);
           }
         }
         return;
       }
    
  • 其次,如果在 感知范围内,就判断是否在可以发起 攻击攻击范围内
      // 进入攻击范围
      if (distance <= monster.attackRange) {
        monster.attack(0, HeroComponent);
        return;
      }
    
  • 最后,如果不在 攻击范围内,则 追逐
     // 追逐
     monster.setState(CharacterState.run);
    
     final toTarget = target!.position - monster.position;
     final direction = toTarget.normalized();
     final delta = direction * monster.speed * dt;
    
     monster.moveWithCollision(delta);
    
     direction.x >= 0 ? monster.faceRight() : monster.faceLeft();
    

2. 召唤物运行逻辑

  • 确定敌对类型:如果自己是 HeroComponent,则敌人是 MonsterComponent,反之亦然
      final bool isHero = component is HeroComponent;
    
  • 寻找最近的敌人
      PositionComponent? target;
      if (isHero) {
        // 寻找最近的Monster
        for (final m in component.game.world.children.query<MonsterComponent>()) {
          if (m == component.summonOwner) continue; // 排除主人(如果是)
          if (target == null ||
              (m.position - component.position).length <
                  (target!.position - component.position).length) {
            target = m;
          }
        }
      } else {
        // 寻找最近的Hero
        for (final h in component.game.world.children.query<HeroComponent>()) {
          if (h == component.summonOwner) continue;
          if (target == null ||
              (h.position - component.position).length <
                  (target!.position - component.position).length) {
            target = h;
          }
        }
      }
    
  • 如果在 攻击范围内,则发起 攻击追逐
    final toEnemy = target.position - component.position;
    final enemyDistance = toEnemy.length;
    if (enemyDistance <= detectRadius) {
      // 进入攻击范围
      if (enemyDistance <= attackRange) {
        component.attack(0, isHero ? MonsterComponent : HeroComponent);
        return;
      }
    
      // 追击敌人
      component.setState(CharacterState.run);
      final direction = toEnemy.normalized();
      final delta = direction * component.speed * dt;
      component.moveWithCollision(delta);
      direction.x >= 0 ? component.faceRight() : component.faceLeft();
      return;
    }
    
  • 如果附近没敌人,就跟随 召唤者
     if (component.summonOwner != null && component.summonOwner!.parent != null) {
       final toOwner = component.summonOwner!.position - component.position;
       final ownerDistance = toOwner.length;
       final double deadZone = 8.0;
    
       if (ownerDistance > component.followDistance + deadZone) {
         component.setState(CharacterState.run);
         final direction = toOwner.normalized();
         final delta = direction * component.speed * dt;
         component.moveWithCollision(delta);
         direction.x >= 0 ? component.faceRight() : component.faceLeft();
         return;
       }
     }
    

八. 游戏逻辑

196b61c4c7e648abaab520444fdd2f55.gif

终于终于,将本期内容介绍的差不多了,那么简单设计一下 体验版demo 的逻辑,完结基础篇吧。

1. 绘图

在图中,新增 名为 spawn_pointsobject layer图层,设置四个 怪物出生点一个胜利的终点

  • 胜利点属性
    • type:类型 goal
  • 怪物出生点属性
    • type :类型 monster_spawn

    • monsterId:怪物类型id

    • maxCount:该怪物点,最大怪物存活数量

    • perCount:该怪物点,每次生成怪物数量

    • productSpeed:该怪物点,生成怪物速度

2. 新增怪物出生点

/// 怪物生成点组件
///
/// - 支持定时按批次生成怪物
/// - 支持最大数量限制与开始/停止控制
/// - 位置与大小由关卡配置决定(用于调试显示)
class SpawnPointComponent extends PositionComponent
    with HasGameReference<MyGame> {
  /// 场景允许存在的最大怪物总数
  final int maxCount;

  /// 要生成的怪物类型 ID(与现有代码一致,使用字符串)
  final String monsterId;

  /// 每次生成的怪物数量
  final int perCount;

  /// 每次生成的时间间隔
  final Duration productSpeed;

  bool _running = false;
  double _timeSinceLastSpawn = 0;
  final Set<MonsterComponent> _spawned = {};

  SpawnPointComponent({
    required Vector2 position,
    required Vector2 size,
    required this.maxCount,
    required this.monsterId,
    required this.perCount,
    required this.productSpeed,
    Anchor anchor = Anchor.center,
    int priority = 0,
  }) : super(
          position: position,
          size: size,
          anchor: anchor,
          priority: priority,
        );

  @override
  Future<void> onLoad() async {
    debugMode = true;
  }

  /// 开始生成
  void start() {
    _running = true;
  }

  /// 停止生成并重置计时
  void stop() {
    _running = false;
    _timeSinceLastSpawn = 0;
  }

  @override
  void update(double dt) {
    super.update(dt);
    if (!_running) return;

    _timeSinceLastSpawn += dt;
    final intervalSeconds = productSpeed.inMicroseconds / 1e6;

    // 按间隔生成,避免长帧遗漏
    while (_timeSinceLastSpawn >= intervalSeconds) {
      _timeSinceLastSpawn -= intervalSeconds;
      _spawnBatch();
    }
  }

  void _spawnBatch() {
    // 仅统计由该生成点产生、且仍存在于场景中的怪物数量
    _spawned.removeWhere((m) => m.parent == null);
    final currentCount = _spawned.length;
    final allowance = maxCount - currentCount;
    if (allowance <= 0) return;

    final batch = math.min(perCount, allowance);
    for (int i = 0; i < batch; i++) {
      final monster = MonsterComponent(position.clone(), monsterId);
      monster.debugMode = true;
      game.world.add(monster);
      _spawned.add(monster);
    }
  }
}

3. 新增通关点

class GoalComponent extends SpriteAnimationComponent
    with HasGameReference<MyGame>, CollisionCallbacks {
  GoalComponent({required Vector2 position, required Vector2 size})
    : super(position: position, size: size);

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    final image = await game.images.load('flag.png');
    final sheet = SpriteSheet(image: image, srcSize: Vector2(60, 60));
    animation = sheet.createAnimation(
      row: 0,
      stepTime: 0.12,
      from: 0,
      to: 4,
      loop: true,
    );
    add(RectangleHitbox());
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is HeroComponent) {
      AudioManager.playWhistle();
      UiNotify.showToast(game, '恭喜你完成了游戏!');
      other.onDead();
    }
  }
}

4. demo流程

graph TD
    Start[启动游戏] --> Init[初始化: 加载资源/音乐/地图]
    Init --> Spawn[生成: 英雄, 怪物, 道具]
    Spawn --> Loop{游戏循环}
    
    Loop --> Input[玩家输入: 摇杆/攻击]
    Input --> Update[状态更新: 移动/战斗/物理]
    Update --> Check{检测状态}
    
    Check -- &#34;HP <= 0&#34; --> Dead[死亡: 游戏结束]
    Check -- &#34;获得钥匙&#34; --> OpenDoor[交互: 开启门/宝箱]
    Check -- &#34;到达终点&#34; --> Win[胜利: 通关]
    Check -- &#34;继续&#34; --> Loop
    
    OpenDoor --> Loop
    
    Dead --> Restart[显示重开按钮]
    Win --> Restart
    Restart --> Init

九. 总结与展望

总结

本章主要介绍了 Flutter&Flame 开发 2D像素游戏 关于 攻击逻辑 的基础实践。
通过上述步骤,我们完成了人物hud界面、近战、远程、冲刺和召唤 这几类常见攻击元素的实现。

截至目前为止,游戏主要包括了以下内容:

  • 角色与动画:使用精灵图 (SpriteSheet) 创建角色,支持 idle/run 等动画状态切换。
  • 玩家交互:通过摇杆控制角色移动,并根据方向翻转动画。
  • 地图加载:通过 Tiled 绘制并在 Flame 中加载的 2d像素地图
  • 地图交互:通过组件化模式,新建了多个可供交互的组件如(门、钥匙、宝箱、地刺),为游戏增加了互动性。
  • 统一碰撞区检测:将角色与 需要产生碰撞 的物体统一管理,并实现碰撞时的 平滑侧移
  • 统一人物配置创建: 通过将角色数据配置为文件,达到以动态数据驱动模型的目的。
  • HUD界面: 包括 人物血量条技能按钮
  • 完善的攻击逻辑:通过统一基类实现近战、远程、冲刺 的攻击方式 和 独特 召唤 技能。

展望

  • 思考 🤔 一个有趣的游戏机制ing ...

  • 进阶这个demo版

  • 支持局域网多玩家联机功能。

🎮 MyHero 在线体验
🚪 github 源码
💻 个人门户网站


之前尝试的Demo预览

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(六)

2025年12月21日 18:31

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(六)

Flutter: 3.35.6

前面有人提到在元素内部的那块判断怎么那么写的,看来对知识渴望的小伙伴还是有,这样挺好的。不至于说牢记部分知识,只需要大致了解一下有个印象后面如果哪里再用到了就可以根据这个印象去查阅资料。

接下来我们说一下判断原理。当我们知晓矩形的四个顶点坐标(包括任意旋转后),可以使用向量叉乘法来判断是否在矩形内部。

向量叉乘法的核心思想就是:如果一个点在凸多边形内部,那么它应该始终位于该多边形每条边的同一侧。

注:凸多边形定义为所有内角均小于180度,并且任意两点之间的连线都完全位于多边形内部或边界。

所以我们利用向量叉乘法来判断点位于线的哪一侧。假设矩形顶点按顺时针逆时针顺序为 A,B,C,D:

  • 对于边 AB,计算向量 AB 和 AP 的叉积。
  • 对于边 BC,计算向量 BC 和 BP 的叉积。
  • 对于边 CD,计算向量 CD 和 CP 的叉积。
  • 对于边 DA,计算向量 DA 和 DP 的叉积。

如果点 P 在所有边的‌同一侧‌(即所有叉积结果的符号相同),那么点 P 就在矩形内部。我们假设矩形某条边的顶点为(x1, y1), (x2, y2), 判断的点坐标为(x, y),那么就有:

(x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) > 0

这样就可以判断在某侧,如果其他三条边也满足,那就是内侧了,要转换为下面代码中的形式,那就做一下加减乘除就行了:

  1. (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) > 0: 初始
  2. (x2 - x1) * (y - y1) / (y2 - y1) - (x - x1) > 0: 两边同时除以(y2 - y1)
  3. (x2 - x1) * (y - y1) / (y2 - y1) - x + x1 > 0: 展开括号
  4. (x2 - x1) * (y - y1) / (y2 - y1) + x1 > x: 将x移项

这样就得到了代码中的判断依据,至于循环遍历顶点的写法,就是为了获取相邻两个顶点,这个就可以带入square坐标和循环去算一下就行了,保证每次循环都是相邻的两个顶点。

我们使用的顶点坐标顺序是顺时针,第一次循环 i = 0,j = 3,那么i就是左上顶点,j就是左下顶点,两个顶点刚好构成矩形左边;第二次循环 j = i++,此时 j = 0,i = 1后续喜欢以此类推即可。

这样判断在内侧差不多就解释完了。接下来开始我们今天正文。前面我们就简单完成了多个元素的相应操作,剩下的就是一些优化和一些简单的扩展功能。

既然是多个元素,那么肯定就涉及到新增和删除,之前的新增都是在列表里面直接添加,现在我们单独提取一个方法用于新增。至于删除功能我们就定义在元素左上角为删除区域,触发方式为点击。之前我们对热区的数据模型中添加了 trigger 字段,用于表示当前区域触发操作的方式是什么,所以我们得对点击方法进行优化,并且在临时中间变量上面存储 trigger 字段,用于判断:

class ResponseAreaModel {
  // 其他省略...

  /// 当前响应操作的触发方式
  final TriggerMethod trigger;
}

/// 新增返回Records,用于记录状态和触发方式
(ElementStatus, TriggerMethod)? _onDownZone({
  required double x,
  required double y,
  required ElementModel item,
}) {
  // 先判断是否在响应对应操作的区域
  final (ElementStatus, TriggerMethod)? areaStatus = _getElementZone(x: x, y: y, item: item);
  if (areaStatus != null) {
    return areaStatus;
  } else if (_insideElement(x: x, y: y, item: item)) {
    // 因为加入旋转,所以单独抽取落点是否在元素内部的方法
    return (ElementStatus.move, TriggerMethod.move);
  }

  return null;
}

/// 新增返回Records,用于记录状态和触发方式
(ElementStatus, TriggerMethod)? _getElementZone({
  required double x,
  required double y,
  required ElementModel item,
}) {
  // 新增Records记录返回的状态和触发方式
  (ElementStatus, TriggerMethod)? tempStatus;

  for (var i = 0; i < ConstantsConfig.baseAreaList.length; i++) {
    // 其他省略...

    if (
      x >= dx - areaCW &&
      x <= dx + areaCW &&
      y >= dy - areaCH &&
      y <= dy + areaCH
    ) {
      tempStatus = (currentArea.status, currentArea.trigger);
      break;
    }
  }

  return tempStatus;
}

这样触发方式和状态都记录了,我们就开始实现删除功能,依然按照之前的步骤快速实现:

// 新增删除
ResponseAreaModel(
  areaWidth: 20,
  areaHeight: 20,
  xRatio: 0,
  yRatio: 0,
  status: ElementStatus.deleteStatus,
  icon: 'assets/images/icon_delete.png',
  trigger: TriggerMethod.down,
),
/// 处理删除元素
void _onDelete() {
  if (_currentElement == null) return;

  _elementList.removeWhere((item) => item.id == _currentElement?.id);
}
/// 按下事件
void _onPanDown(DragDownDetails details) {
  // 其他省略...

  // 遍历判断当前点击的位置是否落在了某个元素的响应区域
  for (var item in _elementList) {
    // 新增Records数据,存储元素状态和触发方式
    final (ElementStatus, TriggerMethod)? status = _onDownZone(x: dx, y: dy, item: item);

    if (status != null) {
      currentElement = item;
      temp = temp.copyWith(status: status.$1, trigger: status.$2);
      break;
    }
  }

  // 新增判断
  // 如果当前有选中的元素且和点击区域的currentElement是一个元素
  // 并且 temp 的 status对应的触发方式为点击,那么就响应对应的点击事件
  if (currentElement?.id == _currentElement?.id && temp.trigger == TriggerMethod.down) {
    if (temp.status == ElementStatus.deleteStatus) {
      _onDelete();
      // 因为是删除,就置空选中,让下面代码执行最后的清除
      currentElement = null;
    }
  }

  // 其他省略...
}

运行效果:

image01.gif

这样就简单实现了元素的删除功能。到此操作区域常用的功能差不多就完成,接下来我们考虑一些区域的自定义;例如我希望旋转的区域在右下角(现在在右上角),并且不使用缩放功能,还想自定义一个区域,这时候该如何实现呢?

允许传递配置,通过这份配置来决定元素应该有什么响应区域并且是否使用这些响应区域,然而操作这些内置的我们可以使用之前定义的 final ElementStatus status; 字段来确定要修改哪个区域,毕竟一个操作应该是对应一个区域;对于自定义区域,我们的ElementStatus是个枚举类型且为必传,这就限制了自定义区域,所以我们的改造一下,用户传递的自定义区域,status为自行设置的字符串,我们内部也同时更改为字符串(涉及更改的地方有一些,这里不做过多的说明,后续可以查阅源码):

/// 元素当前操作状态
/// 更改新增字符串的value属性
enum ElementStatus {
  move(value: 'move'),
  rotate(value: 'rotate'),
  scale(value: 'scale'),
  deleteStatus(value: 'deleteStatus'),;

  final String value;

  const ElementStatus({required this.value});
}

/// 大致说一些需要更改的地方
/// TemporaryModel 的 status 更改为字符串类型
/// ResponseAreaModel 的 status 更改为字符串类型
/// _onDownZone 方法中 Records 第一项也返回 String
/// _getElementZone 方法中 Records 第一项也返回 String

接下来我们确定自定义区域配置中需要的字段:

  • status:用于映射内置的区域,方便做更改(String 必须)
  • use:用于确定该 status 对应的内置区域是否使用(bool 非必须)
  • xRatio:用于确定区域位置(double 非必须,如果非内置的默认就是0)
  • yRatio:用于确定区域位置(double 非必须,如果非内置的默认就是0)
  • trigger:用于确定区域的触发方式(TriggerMethod 非必须,默认TriggerMethod.down)
  • icon:用于确定操作区域的展示icon(String 如果是内置的 status 就是非必须,如果不是内置的就是必须)
  • fn:自定义区域需要执行的方法(Function({required double x, required double y}) 如果是内置的就是非必须,如果不是内置的就必须)

基于上述开始进行编码:

/// 新增自定义区域配置
class CustomAreaConfig {
  const CustomAreaConfig({
    required this.status,
    this.use,
    this.xRatio,
    this.yRatio,
    this.trigger = TriggerMethod.down,
    this.icon,
    this.fn,
  });

  /// 区域的操作状态字符串,可以是内置的,如果是内置的就覆盖内置的属性
  final String status;
  /// 是否启用
  final bool? use;
  /// 自定义位置
  final double? xRatio;
  final double? yRatio;
  /// 区域响应操作的触发方式
  final TriggerMethod trigger;
  /// 自定义区域就是必传
  final String? icon;
  /// 自定义区域就是必传,点击对应的响应区域就执行自定义的方法
  final Function({required double x, required double y})? fn;
}
/// 新增自定义区域配置
final List<CustomAreaConfig> _customAreaList = [
  // 不使用缩放区域
  CustomAreaConfig(
    status: ElementStatus.scale.value,
    use: false,
  ),
  // 将旋转移到右下角
  CustomAreaConfig(
    status: ElementStatus.rotate.value,
    xRatio: 1,
    yRatio: 1,
  ),
];
/// 容器响应操作区域,之前是直接使用的常量里面的配置
List<ResponseAreaModel> _areaList = [];

/// 初始化响应区域
void _initArea() {
  List<ResponseAreaModel> areaList = [];

  for (var area in ConstantsConfig.baseAreaList) {
    final int index = _customAreaList.indexWhere((item) => item.status == area.status);

    if (index > -1) {
      final CustomAreaConfig customArea = _customAreaList[index];

      // 如果是不使用,则跳出本次循环
      if (customArea.use == false) {
        continue;
      }

      areaList.add(area.copyWith(
        xRatio: customArea.xRatio,
        yRatio: customArea.yRatio,
        icon: customArea.icon,
        fn: customArea.fn,
      ));
    } else {
      areaList.add(area);
    }
  }

  setState(() {
    _areaList = areaList;
  });
}
// 其他省略...

/// 抽取渲染的元素
class TransformItem extends StatelessWidget {
  const TransformItem({
    // 其他省略...

    required this.areaList,
  });

  // 其他省略...

  final List<ResponseAreaModel> areaList;

  @override
  Widget build(BuildContext context) {
    return Positioned(
      left: elementItem.x,
      top: elementItem.y,
      // 新增旋转功能
      child: Transform.rotate(
        angle: elementItem.rotationAngle,
        child: Container(
          // 其他省略...

          // 新增区域的渲染
          child: selected ? Stack(
            clipBehavior: Clip.none,
            children: [
              // 修改从外界传递区域列表
              ...areaList.map((item) => Positioned(
                // 其他省略...
              )),
            ],
          ) : null,
        ),
      )
    );
  }
}

其他编码不算核心,就不再展示了,反正就一个,之前从 ConstantsConfig.baseAreaList 拿的数据现在都直接使用 _areaList。

运行效果:

image02.gif

可以看到,我们将旋转区域移到右下角了,并且不使用缩放区域,这样就简单完成了区域自定义的配置。

感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~

今天的分享就到此结束了,感谢阅读~拜拜~

TypeScript和JavaScript到底有什么区别?

2025年12月21日 17:54

两种语言的本质关系:从自由奔放到严谨规划

首先,让我们澄清一个最常见的误解:TypeScript不是JavaScript的替代品,而是它的超集

简单来说,所有合法的JavaScript代码都是合法的TypeScript代码。但TypeScript在此基础上,添加了强大的类型系统和其他现代语言特性。

想象一下:

  • JavaScript 就像一个自由奔放的艺术家,可以在画布上随意挥洒,不拘一格
  • TypeScript 则像是这位艺术家决定遵循一套建筑蓝图,让创作更加结构化和可预测

核心区别:类型系统的魔力

无类型 vs 静态类型

让我们通过一个简单的例子来看看这两者的核心区别:

// JavaScript版本:自由但危险
function calculateTotal(price, quantity) {
    return price * quantity;
}

// 这行代码能正常运行,但结果可能不是你想要的
const total = calculateTotal("100", 3); // 返回 "100100100"
// TypeScript版本:明确且安全
function calculateTotal(price: number, quantity: number): number {
    return price * quantity;
}

// 下面这行代码会在编译时就报错
const total = calculateTotal("100", 3); // 编译错误:Argument of type 'string' is not assignable to parameter of type 'number'

看出区别了吗?TypeScript在你写代码的时候就会检查类型错误,而JavaScript要到运行时才会发现问题。

接口和类型定义

TypeScript最强大的特性之一就是接口(Interface):

// 定义用户接口
interface User {
    id: number;
    name: string;
    email: string;
    age?: number; // 可选属性
}

// 使用接口
function createUser(userData: User): User {
    return {
        id: Date.now(),
        name: userData.name,
        email: userData.email,
        age: userData.age || 25
    };
}

// TypeScript会检查我是否提供了所有必需的属性
const newUser = createUser({
    name: "张三",
    email: "zhangsan@example.com"
});

开发体验对比:写代码时的不同感受

智能提示和自动补全

使用TypeScript时,IDE能够提供极其精准的智能提示:

interface Product {
    id: number;
    name: string;
    price: number;
    inStock: boolean;
}

const product: Product = {
    id: 1,
    name: "笔记本电脑",
    price: 7999,
    inStock: true
};

// 当我输入"product."时,IDE会提示所有可用的属性
console.log(product.name); // IDE知道这是string类型
console.log(product.price); // IDE知道这是number类型
// console.log(product.description); // 编译错误:属性'description'不存在

重构时的安心感

在大项目中重构代码时,TypeScript提供的安全保障是无价的:

// 假设我要修改API响应结构
interface ApiResponse<T> {
    success: boolean;
    data: T;
    timestamp: string;
    // 新增字段
    requestId?: string;
}

// TypeScript会告诉我所有需要更新的地方
function processResponse(response: ApiResponse<User>) {
    if (response.success) {
        console.log(response.data.name);
        console.log(response.requestId); // 我知道这是可选字段
    }
}

实战场景:从JavaScript迁移到TypeScript

让我分享一个实际的迁移经验。我曾经维护一个中型电商项目,最初使用纯JavaScript开发:

// 原来的JavaScript代码
function addToCart(item, quantity) {
    // item是什么结构?quantity应该是数字吗?
    cart.items.push({ item, quantity });
    updateCartTotal();
}

迁移到TypeScript后:

// TypeScript版本
interface CartItem {
    id: number;
    name: string;
    price: number;
    category: string;
}

interface CartEntry {
    item: CartItem;
    quantity: number;
    addedAt: Date;
}

function addToCart(item: CartItem, quantity: number): void {
    const cartEntry: CartEntry = {
        item,
        quantity,
        addedAt: new Date()
    };
    
    cart.items.push(cartEntry);
    updateCartTotal();
}

迁移过程虽然需要一些额外工作,但回报是巨大的:

  1. 代码错误减少了约40%
  2. 新成员上手时间缩短了50%
  3. 重构时的信心大大增强

学习曲线和成本考量

TypeScript的学习成本

是的,TypeScript需要学习一些新概念:

  • 类型注解
  • 接口和类型别名
  • 泛型
  • 枚举
  • 装饰器(可选)

但对于有JavaScript基础的开发者来说,这些概念并不难掌握。我建议从简单的类型注解开始,逐步深入。

项目中的成本效益分析

在中小型项目中,纯JavaScript可能更快速灵活。但在以下场景中,TypeScript的优势明显:

  1. 团队协作项目:类型系统作为文档,减少沟通成本
  2. 大型复杂应用:提前发现潜在错误
  3. 长期维护项目:代码可读性和可维护性更好
  4. 公共库开发:提供更好的开发者体验

我的实用建议

经过多年的实践,我总结了一些建议:

  1. 新项目优先考虑TypeScript,特别是团队项目
  2. 老项目逐步迁移,可以从配置文件开始,逐个模块转换
  3. 不要过度使用高级特性,保持代码简洁易懂
  4. 结合具体业务场景选择,简单的工具脚本可能不需要TypeScript

总结:选择合适的工具

回到最初的问题:TypeScript和JavaScript有什么区别?我认为最核心的区别是:

JavaScript给你自由,TypeScript给你安全和结构。

作为一名开发者,我的选择通常是:

  • 小型脚本、原型验证、学习实验 → JavaScript
  • 企业级应用、团队协作、长期维护项目 → TypeScript

两种语言各有适用场景,理解它们的差异不是为了争论孰优孰劣,而是为了在合适的场景使用合适的工具。

在2024年的前端开发中,TypeScript已经成为许多团队的标准选择。但这并不意味着JavaScript会消失——它们将长期共存,各自在适合的领域发光发热。

真正的技术选择,不在于追求最新最热,而在于找到最适合当前问题和团队的工具。  这就是我从六年开发经验中学到的最重要的一课。


小杨的实践心得:无论选择哪种语言,保持代码清晰、可维护才是最重要的。技术只是工具,解决问题才是目的。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Vue3 项目:宠物照片变身冰球运动员的 AI 应用

作者 ouma_syu
2025年12月21日 17:53

Vue3 项目:宠物照片变身冰球运动员的 AI 应用

这是一个有趣的 AI 图像生成应用,使用 Vue 3 构建前端,用户上传宠物(或任何)照片,选择冰球运动员的队服颜色、编号、位置、持杆手和风格,然后调用 Coze 平台的 AI 工作流生成一张宠物穿冰球装备的趣味图像。

整个业务逻辑清晰、模块化,主要分为图片预览参数配置文件上传AI 生成四个核心部分。

1. 应用整体流程

  1. 用户选择本地图片文件。
  2. 前端立即预览图片(提升用户体验,避免上传等待时感到空白)。
  3. 用户配置冰球运动员参数(队服颜色、编号、位置、持杆、风格)。
  4. 点击“生成”按钮:
    • 先将图片上传到 Coze 服务器,获取文件 ID(file_id)。
    • 然后调用 Coze 工作流 API,传入 file_id 和其他参数。
    • Coze 工作流内部使用 AI 模型(可能是图像融合或文生图变体)生成新图片。
    • 返回生成的图片 URL,前端显示。
  5. 过程中通过状态文本提示用户进度(上传中 → 生成中 → 成功/失败)。

2. 图片预览模块:本地即时预览

  • 为什么需要预览?
    图片上传可能需要时间(尤其是大文件),即时预览让用户知道“操作已生效”,提升体验。同时避免用户误以为卡顿。

  • 实现原理

    • 使用 HTML <input type="file"> 监听 @change 事件。
    • 获取选中的文件后,使用浏览器内置的 FileReader API 将文件读取为 Base64 编码的 Data URL(字符串形式)。
    • 将这个字符串赋值给响应式变量 imgPreview,绑定到 <img :src="imgPreview"> 上即可显示。
  • 关键代码逻辑

    const updateImageData = () => {
      const file = uploadImage.value.files[0];
      const reader = new FileReader();
      reader.readAsDataURL(file);  // 异步读取
      reader.onload = (e) => {
        imgPreview.value = e.target.result;  // Base64 字符串,直接作为 src
      };
    };
    
  • 优点:纯前端操作,无需上传服务器,速度快。

3. 参数配置:响应式表单数据

  • 使用 Vue 3 的 ref 定义响应式变量:
    • uniform_number(编号,默认 7)
    • uniform_color(颜色,默认 “白”)
    • position(位置:0-守门员、1-前锋、2-后卫)
    • shooting_hand(持杆:0-左手、1-右手)
    • style(风格:写实、乐高、国漫 等)
  • 通过 v-model 双向绑定到表单控件,用户修改即时更新这些变量。
  • 这些参数后续会作为工作流输入,直接影响 AI 生成的图像效果。

4. 文件上传到 Coze:获取 file_id

  • 为什么先上传文件?
    Coze 工作流不支持直接接收文件字节流,而是需要一个文件 ID(file_id)。Coze 会自动将 file_id 转换为内部可访问的 URL 供 AI 使用。

  • API 详情

    • 端点:https://api.coze.cn/v1/files/upload
    • 方法:POST
    • Headers:Authorization: Bearer <PAT_TOKEN>
    • Body:FormData,键为 file,值为文件对象
    • 成功响应:{ code: 0, data: { id: "文件ID" } }
  • 实现逻辑

    • 创建 FormData,append 用户选择的文件。
    • 使用 fetch 发送请求。
    • 检查 code === 0,返回 file_id,否则显示错误消息。
  • 状态更新:上传前设置 status = "图片上传中...",成功后更新为“上传成功,正在生成中...”。

5. 调用 Coze 工作流:生成图像

  • API 详情
    • 端点:https://api.coze.cn/v1/workflow/run
    • 方法:POST
    • Headers:
      • Authorization: Bearer <PAT_TOKEN>
      • Content-Type: application/json
    • Body:
      {
        "workflow_id": "固定工作流 ID",
        "parameters": {
          "picture": "{\"file_id\": \"上传得到的ID\"}",
          "style": "用户选择的风格",
          "position": ...,
          // 其他参数
        }
      }
      
    • 注意:图片参数必须是 JSON 字符串化的对象 { "file_id": "xxx" },Coze 会自动解析为文件 URL。
  • 响应处理
    • 成功:ret.code === 0ret.data 是字符串(工作流输出),需 JSON.parse(ret.data) 获取实际数据,通常包含生成的图像 URL(如 data.data)。
    • 设置 imgUrl.value = imageUrl,前端显示新图片。
    • 更新状态为“生成成功”或错误消息。
  • 安全与配置
    • PAT Token(Personal Access Token)通过环境变量 VITE_PAT_TOKEN 注入,避免硬编码。
    • 工作流 ID 固定硬编码(实际项目可配置化)。

6. 用户体验优化点

  • 状态提示status 变量实时反馈进度和错误。
  • 错误处理:统一检查 API 的 code 字段,非 0 时显示 msg
  • 布局:左侧输入区(上传 + 参数),右侧输出区(预览 + 生成结果),响应式友好。
  • 扩展性:参数易增减,适合类似“宠物变身其他角色”的变体应用。

总结

这个项目巧妙结合了 Vue 3 的响应式系统、前端文件处理能力和 Coze 的 AI 工作流 API,实现了从上传到生成的完整闭环。

面试常考的最长递增子序列(LIS),到底该怎么想、怎么写?

作者 栀秋666
2025年12月21日 17:40

🎯 开场灵魂拷问:你真的会“找最长递增子序列”吗?

面试官轻描淡写地抛出一句:

“给定一个数组,求它的最长递增子序列长度。”

你以为这是送分题?
结果一写代码才发现——

  • 暴力枚举?超时!
  • 动态规划?能过但慢如蜗牛!
  • 贪心 + 二分?脑子里一团浆糊:“这玩意儿怎么还能用二分?”

别慌,这不是你菜,而是你还没看到这场算法进化的“全貌”。

今天,我们就来拆解 LeetCode 第 300 题 —— 「最长递增子序列(LIS)」,带你从 暴力直觉 → 动态规划 → 贪心艺术,一步步攀登算法高峰。

✅ 学完你会明白:

  • 为什么 DP 是“基础课”,而贪心是“研究生选修”?
  • 为什么“末尾越小越好”能决定整个序列的命运?
  • 以及——二分查找,居然也能用来“维护梦想”?

🧩 第一章:题目解析 —— 先搞清楚“我们在找什么”

🔍 题目定义(LeetCode 300)

image.png

给你一个整数数组 nums,找出其中最长严格递增子序列的长度

📌 注意关键词:

  • 子序列 ≠ 子数组:可以不连续,但必须保持原顺序。
  • 严格递增[2,3,3] 不算,[2,3,4] 才行。
  • 只需要返回长度,不要求输出具体序列(除非面试官坏笑说:“那你打印一下?”)

🎯 示例:

输入: nums = [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长递增子序列为 [2,3,7,18] 或 [2,5,7,101],长度为4

💡 思考起点:
我们不是要“列出所有可能”,而是要在 时间与空间的夹缝中,找到最优路径


🏗️ 方法一:动态规划(DP)—— 暴力中的优雅

⏱️ 时间复杂度:O(n²)
💾 空间复杂度:O(n)
🧠 适合初学者理解本质,也适合面试保底

🤔 核心思想:以我为中心,我能接谁?

我们换个视角思考:

对于每个位置 i,如果我能成为某个递增子序列的“结尾”,那这个序列最长能有多长?

于是我们定义状态:

dp[i] = nums[i] 结尾的最长递增子序列的长度

为什么这样定义?因为:

  • 子序列有“延续性”:... → a → b 成立的前提是 a < b
  • 所以 dp[i] 的值,取决于前面所有比 nums[i] 小的元素的 dp[j]

🔄 状态转移方程

对每个 i,遍历 j ∈ [0, i)

if nums[j] < nums[i]:
    dp[i] = max(dp[i], dp[j] + 1)

初始值:每个元素自己就是一个长度为 1 的子序列 → dp[i] = 1


✅ 代码实现(带注释版)

image.png


🧪 执行过程可视化(手把手教学)

nums = [10,9,2,5,3,7,101,18] 为例:

i nums[i] 可接的 j dp[i] 计算过程 最终 dp[i]
0 10 - 初始化为 1 1
1 9 <9 的前驱 1
2 2 同上 1
3 5 j=2 (2<5) dp[2]+1 = 2 2
4 3 j=2 (2<3) dp[2]+1 = 2 2
5 7 j=2,3,4 max(dp[j])+1 = 3 3
6 101 前面都小 max(dp[j])+1 = 4 4
7 18 前面都小 但只能接最长链 4

✅ 最终答案:max(dp) = 4


📉 复杂度分析

  • 时间:O(n²) —— 双重循环,适合 n ≤ 10³
  • 空间:O(n) —— dp 数组

🟢 优点:逻辑清晰,容易想到,适合面试“先写个能跑的” 🔴 缺点:数据一大就超时,比如 n = 10⁵?直接凉透


🚀 方法二:贪心 + 二分查找 —— 当算法开始“动脑筋”

⏱️ 时间复杂度:O(n log n)
💾 空间复杂度:O(n)
🧠 适合高手秀操作,也是大厂面试加分项

🤯 关键洞察:我们要的不是“当前最长”,而是“未来潜力最大”

动态规划的问题在于:它太“实在”了。
它记录的是“以某点结尾的最长长度”,但并不关心“这个序列将来能不能继续增长”。

而贪心策略的核心哲学是:

对于相同长度的递增子序列,末尾越小,未来的路就越宽!

举个例子:

  • 序列 A:[2,5,7],末尾是 7
  • 序列 B:[2,3,7],末尾也是 7,但中间更小

现在来了一个新数字 4

  • A 无法接(7 > 4)
  • B 虽然也不能直接接,但如果把 7 换成 4,就变成了 [2,3,4],未来还能接 5, 6...

所以,我们应该保留那些“末尾尽可能小”的候选序列


🛠️ 引入神器:tails 数组

我们定义一个辅助数组:

tails[k] = 长度为 k+1 的递增子序列中,最小的末尾元素

例如:

tails = [2, 3, 7, 18]

表示:

  • 有长度为1的 LIS,最小末尾是 2
  • 有长度为2的 LIS,最小末尾是 3
  • ...
  • 有长度为4的 LIS,最小末尾是 18

🎯 目标:维护这个 tails 数组,让它始终保持“末尾最小化”。


🔁 算法流程:见招拆招

遍历每个 num

  1. 如果 num > tails[last]
    → 说明它可以扩展当前最长序列!直接追加到末尾。

  2. 否则(num <= tails[last]
    → 它不能扩展最长序列,但可以“优化”某个较短序列的末尾。
    → 使用二分查找,找到第一个 ≥ num 的位置,把它替换掉。

🧠 替换的意义:我们用 num 更新了某个长度的“最佳末尾”,让未来更容易扩展。


✅ 代码实现(含详细注释)

image.png


🎬 执行过程动画演示(文字版)

nums = [10,9,2,5,3,7,101,18]

num tails 变化 说明
10 [10] 初始
9 [9] 9 < 10,替换第一个 ≥9 的元素
2 [2] 同上
5 [2,5] 5 > 2,可扩展,追加
3 [2,3] 3 < 5,替换第一个 ≥3 的位置(索引1)
7 [2,3,7] 7 > 3,追加
101 [2,3,7,101] 继续追加
18 [2,3,7,18] 18 < 101,替换最后一个

✅ 最终长度为 4,正确!


🧠 为什么 tails 是有序的?(关键证明)

很多人疑惑:为什么能用二分查找?

👉 因为 tails严格递增的!

反证法:

假设存在 i < jtails[i] >= tails[j]
那么长度为 j+1 的 LIS 的前 i+1 个元素,构成了一个长度为 i+1 的递增子序列,其末尾 < tails[j] ≤ tails[i]
但这与 tails[i] 是“长度为 i+1 的最小末尾”矛盾!

✅ 所以 tails 必然递增,可用二分!


📉 复杂度分析

  • 时间:O(n log n) —— 每个元素做一次 O(log n) 的二分
  • 空间:O(n) —— tails 最多存 n 个元素

🟢 优势:大规模数据也能轻松应对(n=10⁵ 也没问题) 🔴 劣势:tails 数组并不是真正的 LIS,只是长度正确(想还原序列?得额外记录)


🆚 两种方法对比:一场“稳扎稳打” vs “灵巧突袭”的较量

维度 动态规划(DP) 贪心 + 二分
🧠 思维难度 简单直观,易理解 抽象跳跃,需顿悟
⏱️ 时间复杂度 O(n²) O(n log n)
💾 空间复杂度 O(n) O(n)
✅ 是否稳定 是,适合保底 是,但难调试
🖨️ 能否还原子序列 ✅ 可通过 prev 数组还原 tails 不是真实序列
🎯 适用场景 小数据、教学、面试保底 大数据、性能敏感、进阶展示

💡 高阶技巧与拓展思考

✅ 技巧1:如何还原具体的 LIS?

  • DP 方案:额外维护 parent[i] 记录前驱节点,最后倒推路径
  • 贪心方案:不行(tails 是虚拟状态),但可通过“路径回溯 + 栈”模拟

✅ 技巧2:最长非递减子序列?

只需将条件改为 nums[j] <= nums[i],或在二分查找时找 第一个 > num 的位置进行替换。


✅ 技巧3:结合实际业务?

  • 用户行为轨迹中最长“正向成长”路径
  • 股票价格中的最长上涨周期
  • 文本相似度匹配中的最长公共递增子序列(LCIS)

🧩 面试视角:如果你是面试官,你会怎么问?

  1. “先说个你能想到的方法。” → 你答 DP,稳妥。
  2. “有没有更优解?” → 你答贪心 + 二分,加分。
  3. “为什么 tails 可以用二分?” → 你开始讲单调性,面试官点头。
  4. “那怎么还原具体序列?” → 你微微一笑:“我有备而来。”

🎯 这就是算法面试的“阶梯式挑战”:先活下来,再赢下来


🏁 终极总结:从“复制粘贴”到“创造思维”

方法 代表能力 适合阶段
动态规划 基础建模能力 初级 → 中级
贪心 + 二分 优化思维 & 数学直觉 中级 → 高级

🌟 编程的本质,是从“解决问题”到“优雅地解决问题”的进化。

下次当你看到“最长递增子序列”,不要再害怕:

  • 先写 DP 保命,
  • 再秀贪心翻盘,

💬 互动时间

欢迎在评论区留下你的想法:

  1. 你是怎么第一次理解“贪心 + 二分”这个思路的?
  2. 你在项目中遇到过类似 LIS 的实际问题吗?
  3. 你觉得 tails 数组像不像“人生梦想清单”?越早把目标调低,未来越容易实现 😂

👇 点赞 + 收藏 + 分享,让更多人告别“只会暴力”的时代!


从 useState 到 useEffect:React Hooks 核心机制详解

作者 有意义
2025年12月21日 17:19

react hook 介绍

一、React Hooks 是什么?

React Hooks 是 React 16.8 引入的一组以 use 开头的函数,让函数组件也能拥有状态(state)、生命周期和副作用处理能力——这些原本只有类组件才能做到。

🧑‍🍳 生活类比
以前,只有“有经验的厨师”(类组件)才能记住锅里煮了几分钟、盐放了多少;
现在,“新手厨师”(函数组件)只要学会用 useStateuseEffect 这些工具,也能轻松掌勺!

Hooks 的出现,不仅简化了组件逻辑,还避免了类组件中常见的 this 绑定问题,让代码更简洁、复用性更强。


二、useState:给组件一个“记忆”

useState 是最常用的 Hook,用于在函数组件中声明和更新状态。

const [count, setCount] = useState(0);
  • count:当前状态值(初始为 0
  • setCount:更新状态的函数

📦 生活例子
就像给一个计数器装上“数字记忆”——它记得现在是几,并且能通过按钮加一或重置。

关键要点:

  1. 初始化必须是同步的
    useState(initialValue) 的参数必须是立即可用的值。

    不能写 useState(fetchData())(异步不行)
    必须写 useState(0)useState({}) 等同步值
    类比:“今天几号?”你得马上回答,不能说“等我查下日历”。

  2. 更新状态有两种方式

    • 直接赋值setCount(5) → 把状态设为 5

    • 基于前值更新setCount(prev => prev + 1) → 在旧值基础上计算新值

      ⚠️ 当新状态依赖于旧状态时(比如多次快速点击),务必使用函数式更新,避免状态丢失。


三、纯函数 vs 副作用:React 组件的理想与现实

纯函数(Pure Function)

  • 相同输入 → 相同输出
  • 不修改外部变量或 DOM
  • 无网络请求、无时间依赖
  • 例子:add(1, 2) 永远返回 3

理想中的 React 组件就是一个纯函数:

(props) => <div>Hello, {props.name}!</div>

副作用(Side Effects)

但真实应用离不开“不纯”的操作:

  • 发起 API 请求
  • 订阅事件
  • 手动操作 DOM
  • 设置定时器

这些都属于 副作用——它们会“影响外部世界”或“依赖外部状态”。

useEffect:专门处理副作用的 Hook

useEffect(() => {
  // 副作用逻辑写在这里
}, [dependencies]);
  • 第一个参数:副作用函数
  • 第二个参数(可选):依赖数组,控制何时重新执行

🛠️ 作用:把副作用从渲染逻辑中抽离,让组件更接近“纯函数”,同时安全地处理异步或外部交互。


四、用 useEffect 实现异步数据请求

由于 useState 不能直接接收异步结果,我们采用“两步走”策略:

// 1. 先准备一个“空盒子”(同步初始化)
const [data, setData] = useState(null);

// 2. 组件挂载后,用 useEffect “去拿东西”
useEffect(() => {
  fetch('/api/user')
    .then(res => res.json())
    .then(setData);
}, []); // 空依赖数组 → 只在组件首次渲染时执行一次

📦 类比理解

  • useState(null) = 桌上先放一个空盒子
  • useEffect = 派人去仓库取货,回来后把东西放进盒子
  • 用户看到的是:先空白 → 稍后显示内容(可配合 loading 状态优化体验)

💡 最佳实践

  • 初始状态可设为 null[] 或 {},便于后续判断是否加载完成
  • 添加错误处理和 loading 状态,提升健壮性

五、React 与 Vue 的响应式哲学对比

维度 React Vue
状态更新 手动调用 setState / setXxx 自动追踪依赖,响应式系统自动更新
心智模型 “我告诉 UI 该变了” “数据变了,UI 自动跟着变”
类比 手动开关灯 声控灯(数据一动,视图就亮)
  • React 更显式:每一步更新都由开发者主动触发,逻辑清晰、可控性强。
  • Vue 更隐式:依赖收集 + 响应式代理自动完成更新,开发效率高,但调试复杂场景时可能不够透明。

两者没有绝对优劣,关键在于理解其背后的设计哲学:
React 强调“可预测性”Vue 追求“开发幸福感”


useEffect 和 useState

在现代前端开发中,React 函数式组件因其简洁、可读性强、逻辑复用便利等优势,已成为主流开发范式。而让函数组件具备状态管理副作用处理能力的核心,正是 React Hooks

本文将带你从最基础的 useState 入手,逐步理解为何需要 useEffect,以及如何正确使用它来构建健壮的交互逻辑。


一、“记忆”从何而来?——useState 如何赋予函数组件状态

在类组件时代,我们通过 this.state 管理内部状态;而在函数组件中,useState Hook 提供了同等能力。

基础用法

import { useState } from 'react';

export default function App() {
  const [num, setNum] = useState(1);
  
  return (
    <div onClick={() => setNum(num + 1)}>
      {num}
    </div>
  );
}
  • useState(1) 接收一个初始值(1),返回一个包含两个元素的数组:

    • 第一个元素 num:当前状态值;
    • 第二个元素 setNum:用于更新状态的函数。
  • 通过解构赋值,我们将其命名为 num 和 setNum,语义清晰。

🔄 数据流闭环
用户触发事件(如点击) → 调用 setNum 更新状态 → React 重新渲染组件 → 页面显示新值。
这形成了“事件 → 状态 → 渲染”的三角关系,是 React 响应式更新的核心机制。

💡 注意:这里的“事件”不仅指点击,还包括表单输入、定时器、API 回调等任何能触发状态变更的操作。


进阶用法:惰性初始化与函数式更新

import { useState } from 'react';

export default function App() {
  // 惰性初始化:仅在首次渲染时执行
  const [num, setNum] = useState(() => {
    const num1 = 1 + 2;
    const num2 = 2 + 3;
    return num1 + num2; // 返回 6
  });

  return (
    <div onClick={() => setNum(prev => {
      console.log('上一次的值:', prev);
      return prev + 1;
    })}>
      {num}
    </div>
  );
}

1. 惰性初始化(Lazy Initialization)

当初始状态需要复杂计算时,可传入一个纯函数作为参数。React 仅在组件首次渲染时调用它,避免重复计算。

必须是纯函数
纯函数要求相同输入必得相同输出,且无副作用(如不发起网络请求、不修改外部变量)。
❌ 错误示例:

const [data] = useState(() => fetch('/api').then(res => res.json())); // 异步不可用!

2. 函数式更新(Functional Update)

当新状态依赖于前一个状态时(如多次快速点击),应使用函数形式:

setNum(prev => prev + 1);

这能避免因闭包捕获旧值而导致的状态“滞后”问题。


二、为什么需要 useEffect?——副作用的必然引入

useState 只负责声明状态触发更新,但它无法处理副作用(Side Effects)。

什么是副作用?

副作用 = 任何在组件渲染之外发生的、影响或依赖外部系统的行为。

例如:

  • 发起 API 请求
  • 订阅 WebSocket
  • 操作 DOM
  • 设置/清除定时器
  • 修改全局变量

这些操作不能放在渲染逻辑中(会导致无限循环或性能问题),但又必须在特定时机执行。

纯函数的理想 vs 现实的妥协

理想中的 React 组件是一个纯函数

(props) => <div>Hello, {props.name}</div>

给定相同的 propsstate,总是返回相同的 JSX。

但现实应用离不开副作用。于是,useEffect 应运而生——它是 React 专门用于安全处理副作用的 Hook。


三、“行动”何时发生?——useEffect 的三种典型场景

useEffect 接收两个参数:

  1. 副作用函数:包含你要执行的逻辑;
  2. 依赖数组(可选) :控制该副作用何时重新执行。

场景 1:组件挂载时执行(模拟 componentDidMount

import { useState, useEffect } from 'react';

async function queryData() {
  return new Promise(resolve => {
    setTimeout(() => resolve(666), 2000);
  });
}

export default function App() {
  const [num, setNum] = useState(0);

  useEffect(() => {
    console.log('useEffect 执行');
    queryData().then(data => setNum(data));
  }, []); // 空依赖数组 → 仅在挂载时执行一次

  console.log('render 执行');

  return <div onClick={() => setNum(n => n + 1)}>{num}</div>;
}

🔍 执行顺序
控制台先输出 'render 执行',再输出 'useEffect 执行'
这是因为 React 优先完成 DOM 渲染,再异步执行副作用,避免阻塞 UI

此模式常用于初始化数据加载


场景 2:依赖项变化时执行(类似 Vue 的 watch

useEffect(() => {
  console.log('num 变化了:', num);
}, [num]); // 依赖 num

每当 num 更新,副作用函数就会重新执行。适用于监听状态变化并作出响应。


场景 3:清理副作用(防止内存泄漏)

考虑以下代码:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num); // 注意:这里会打印旧的 num!
  }, 1000);
}, [num]);

问题:每次 num 变化,都会创建一个新的定时器,而旧的未被清除 → 多个定时器同时运行 → 内存泄漏!

解决方案:返回清理函数

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);

  // 返回清理函数
  return () => {
    clearInterval(timer);
  };
}, [num]);

🧹 清理函数的作用

  • 下一次副作用执行前,清除上一次的资源;
  • 组件卸载时,自动调用以释放资源。

这对于定时器、事件监听、WebSocket 连接等场景至关重要。

验证卸载清理

return (
  <>
    <div onClick={() => setNum(n => n + 1)}>{num}</div>
    {num % 2 === 0 && <Demo />}
  </>
);

num 为奇数时,<Demo /> 被卸载,其内部的 useEffect 清理函数会自动执行。


结语:Hooks 的哲学 —— 显式优于隐式

React 通过 useStateuseEffect,将状态与副作用显式地暴露在函数组件中。虽然需要开发者手动管理更新与清理,但换来的是更高的可预测性与调试能力

  • useState 赋予函数“记忆”;
  • useEffect 赋予函数“行动力”;
  • 两者结合,让函数组件不再“无状态”,而是简洁、组合、强大的现代 React 开发基石。

掌握这两个 Hook,你就已经站在了 React Hooks 世界的大门之内。下一步,可以探索 useCallbackuseMemo、自定义 Hooks 等高级模式,构建更高效、可维护的应用。

手写 `instanceof`:深入理解 JavaScript 原型链与继承机制

作者 Zyx2007
2025年12月21日 17:07

在 JavaScript 的面向对象编程中,instanceof 是一个用于判断对象是否为某个构造函数实例的关键运算符。它不像类型检查那样关注数据的表面形式,而是深入到对象的原型链中,验证是否存在“血缘关系”。这种机制不仅支撑了 JavaScript 的继承体系,也为大型项目中的类型判断提供了可靠依据。本文将从原型链原理出发,手写一个 instanceof 实现,并探讨其在不同继承模式下的表现。


原型链:JavaScript 继承的基石

JavaScript 并没有传统意义上的“类”,而是通过原型(prototype)实现对象之间的继承关系。每个对象都有一个内部属性 [[Prototype]](可通过 __proto__ 访问),指向其构造函数的 prototype 对象。而 prototype 本身也是一个对象,它也可能拥有自己的原型,由此形成一条原型链,直到 null 为止。

const arr = [];
console.log(arr.__proto__ === Array.prototype); // true
console.log(arr.__proto__.__proto__ === Object.prototype); // true

这段代码展示了数组的原型链:arrArray.prototypeObject.prototypenull。正是这条链,使得数组可以调用 pushtoString 等方法。


instanceof 的工作原理

A instanceof B 的本质是:检查 B.prototype 是否出现在 A 的原型链上。如果存在,则返回 true,否则 false

基于这一逻辑,我们可以手动实现 instanceof

function isInstanceOf(A, B) {
  let proto = A.__proto__;
  while (proto) {
    if (proto === B.prototype) return true;
    proto = proto.__proto__;
  }
  return false;
}

该函数从 A 的直接原型开始,逐级向上遍历,直到找到 B.prototype 或到达链尾。这种方式完全复现了原生 instanceof 的行为。

例如:

function Animal() {}
function Dog() {}
Dog.prototype = new Animal();

const dog = new Dog();
console.log(isInstanceOf(dog, Dog));     // true
console.log(isInstanceOf(dog, Animal));  // true

由于 dog 的原型链包含 Dog.prototypeAnimal.prototype,因此对两者都返回 true,体现了继承的传递性。


构造函数绑定继承:属性的复用

早期 JavaScript 中,一种常见的继承方式是通过 callapply 在子类构造函数中调用父类构造函数,从而复制实例属性:

function Animal() {
  this.species = '动物';
}
function Cat(name, color) {
  Animal.apply(this);
  this.name = name;
  this.color = color;
}
const cat = new Cat('小白', '白色');
console.log(cat.species); // "动物"

这种方式能正确继承实例属性,但无法继承原型上的方法。因此,cat instanceof Animal 会返回 false,因为 cat 的原型链并未包含 Animal.prototype


原型链继承:方法的共享

为了让子类也能访问父类原型上的方法,开发者通常采用“父类实例作为子类原型”的模式:

function Animal() {}
Animal.prototype.species = '动物';

function Cat(name, color) {
  this.name = name;
  this.color = color;
}
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

const cat = new Cat('小黑', '黑色');
console.log(cat.species); // "动物"

这里,Cat.prototype 被替换为 Animal 的一个实例,因此 cat 的原型链自然包含了 Animal.prototype。此时:

console.log(cat instanceof Cat);     // true
console.log(cat instanceof Animal);  // true

同时,修复 constructor 指向确保了类型标识的准确性,避免 cat.constructor 错误地指向 Animal


直接继承 prototype:简洁但有风险

另一种写法是直接让子类的 prototype 引用父类的 prototype

Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;

这种方式避免了创建多余的父类实例,节省内存。但由于是引用赋值,修改 Cat.prototype 会直接影响 Animal.prototype,破坏封装性。例如:

Cat.prototype.purr = function() { /* ... */ };
// 此时 Animal.prototype 也拥有了 purr 方法!

因此,这种模式虽简洁,但在多人协作或复杂系统中容易引发副作用,需谨慎使用。


手写 instanceof 的实际价值

在大型项目中,对象来源可能多样:可能是本地创建,也可能是远程 API 返回,或是第三方库生成。此时,仅靠 typeofObject.prototype.toString 难以准确判断其“身份”。而 instanceof(或其手写版本)能基于原型链提供可靠的类型验证:

if (obj instanceof User) {
  obj.login();
}

即使 User 类由不同模块定义,只要原型链一致,判断就有效。这在插件系统、组件通信、状态管理等场景中尤为重要。

此外,手写 instanceof 有助于深入理解 JavaScript 的对象模型。它揭示了“继承”并非语法糖,而是实实在在的指针链接。每一次 instanceof 判断,都是对这条链的一次遍历。


结语

instanceof 虽是一个简单的运算符,却承载着 JavaScript 面向对象设计的核心思想——基于原型的动态继承。通过手写其实现,我们不仅掌握了其工作原理,也更清晰地认识到不同继承方式对原型链结构的影响。

在现代开发中,尽管 ES6 的 class 语法让继承看起来更“传统”,但其底层依然依赖原型链。理解 instanceof 的本质,就是理解 JavaScript 如何在没有类的世界里,构建出灵活而强大的对象体系。这种理解,是写出健壮、可维护代码的重要基石。

HTTP一些问题的解答(接上篇)

作者 小熊哥722
2025年12月21日 17:03

一、在弱网环境下HTTP1会比HTTP2更快的原因是啥?

在弱网环境(高延迟、高丢包率)下,HTTP/1.x 有时比 HTTP/2 表现更好,核心原因是 HTTP/2 的多路复用机制与 TCP 协议的固有缺陷在弱网下产生了 “负协同效应” ,而 HTTP/1.x 的多连接策略反而规避了这种风险。具体可从以下几个角度拆解:

1. 多路复用放大了 TCP 队头阻塞的影响

HTTP/2 的核心优势是 “多路复用”—— 所有请求通过单个 TCP 连接传输,不同请求的帧(Frame)在该连接中交错发送。但这也意味着:单个 TCP 数据包的丢失会阻塞所有请求

  • 弱网下的问题:弱网环境丢包率高(比如 5% 以上),TCP 层一旦丢失一个数据包(可能包含某个请求的帧),会触发重传机制。由于 TCP 是 “按序交付” 的,重传期间,该连接上所有后续数据包(无论属于哪个请求)都会被暂存在接收端的 TCP 缓冲区,无法提交给 HTTP/2 应用层。例如:一个 TCP 连接上有 10 个并发请求的帧在传输,若第 3 个请求的某个帧丢失,TCP 重传期间,后面 7 个请求的帧即使已到达,也会被阻塞,导致所有 10 个请求都变慢。
  • HTTP/1.x 的规避方式:HTTP/1.x 依赖多个并行 TCP 连接(浏览器通常限制为 6-8 个),每个连接处理一个串行请求。若某个连接发生丢包,仅影响该连接上的请求,其他连接的请求仍可正常传输。例如:8 个连接中 1 个丢包,仅 1 个请求受影响,其余 7 个可继续,整体效率反而更高。

2. HTTP/2 的复杂机制在弱网下 “水土不服”

HTTP/2 为优化性能引入的机制(如头部压缩、流优先级),在弱网环境下可能变成负担:

  • HPACK 头部压缩的脆弱性:HTTP/2 用 HPACK 算法压缩请求头,依赖客户端和服务器维护 “共享压缩上下文”(记录已传输的头部字段)。若传输过程中某个头部帧丢失,可能导致双方压缩上下文不一致,需要重新同步,反而增加额外的传输开销和延迟。而 HTTP/1.x 的头部虽未压缩(冗余字节多),但结构简单,单个请求的头部丢失仅影响该请求,无需复杂同步。
  • 流优先级调度的失效:HTTP/2 允许标记请求的优先级(如 CSS/JS 优先于图片),但弱网下,TCP 层的丢包和重传会打乱优先级 —— 高优先级请求的帧可能因低优先级帧的丢包而被阻塞,导致优先级机制失效。而 HTTP/1.x 虽无优先级调度,但多连接天然隔离了不同请求,重要资源可通过独立连接传输,受其他请求影响更小。

3. TCP 拥塞控制对单连接的 “惩罚” 更重

TCP 有拥塞控制机制(如慢启动、拥塞避免),当检测到丢包时,会剧烈降低发送窗口(减少数据发送速率),直到网络恢复。

  • HTTP/2 的单连接劣势:单个 TCP 连接发生丢包后,拥塞窗口会被大幅缩减,导致所有请求的传输速率同时下降。弱网下丢包频繁,单连接可能长期处于低速率状态,整体吞吐量骤降。
  • HTTP/1.x 的多连接优势:多个 TCP 连接独立进行拥塞控制,某个连接因丢包被降速时,其他连接可保持正常速率。即使部分连接受影响,整体吞吐量仍能维持在较高水平。

总结:弱网下 “简单” 比 “高效” 更可靠

HTTP/2 的设计是为了在良好网络环境下通过多路复用提升效率(减少连接开销、优化资源调度),但依赖 TCP 作为底层传输协议。而弱网环境的高丢包、高延迟会放大 TCP 队头阻塞的影响,让多路复用从 “优势” 变成 “劣势”。

HTTP/1.x 虽然存在连接开销大、串行请求的问题,但多连接的 “分散风险” 特性在弱网下更稳健 —— 丢包的影响被限制在单个连接内,整体表现反而更优。这也是为什么在 3G 网络、高丢包的偏远地区,部分场景下 HTTP/1.x 反而比 HTTP/2 更快的核心原因。

二、HTTP2只能有一个连接吗?

HTTP/2 不是 “只能有一个连接” ,而是推荐使用单个 TCP 连接实现多路复用,但协议本身并不禁止建立多个连接。是否使用单连接,更多是基于性能优化的 “最佳实践”,而非强制限制。

1. 为什么 HTTP/2 通常用 “单个连接”?

HTTP/2 的核心设计目标之一是解决 HTTP/1.x “多连接开销大” 的问题,因此推荐通过单个 TCP 连接承载所有请求,利用 “多路复用”(多个 Stream 在同一连接中交错传输)提升效率:

  • 减少连接建立成本:TCP 三次握手、TLS 握手都有延迟(尤其是首次连接),单个连接可避免多次握手的开销;
  • 优化拥塞控制:单个连接的 TCP 拥塞窗口(发送速率)可集中利用带宽,多个连接可能因各自的拥塞控制相互竞争带宽,反而降低整体效率;
  • 简化多路复用逻辑:单个连接中,所有 Stream 的帧(Frame)通过Stream ID区分,接收端更容易管理和调度,多个连接会增加状态同步的复杂度。

2. 什么情况下 HTTP/2 会用 “多个连接”?

虽然不推荐,但 HTTP/2 协议允许同一客户端与服务器建立多个 TCP 连接,常见场景包括:

  • 兼容性兜底:部分老旧服务器或中间代理(如 CDN 节点)对 HTTP/2 的多路复用支持不完善(比如不识别 Stream ID),客户端可能 fallback 到多个连接以确保通信正常;
  • 域名分片残留:HTTP/1.x 常用 “域名分片”(将资源分散到多个子域名,突破浏览器单域名连接数限制),迁移到 HTTP/2 后,若未完全改造,可能仍保留多个子域名的连接(每个子域名一个 HTTP/2 连接);
  • 故障隔离:若单个连接因网络问题(如长时间卡顿、丢包)不可用,客户端可新建一个 HTTP/2 连接继续传输,避免整体中断;
  • 带宽限制突破:某些场景下(如超大文件传输),单个 TCP 连接的拥塞控制可能无法充分利用带宽,通过多个连接 “并行” 传输(类似 HTTP/1.x)可提升吞吐量(但这是对 HTTP/2 设计的 “反用”,较少见)。

3. 浏览器的实际行为:单连接为主,多连接为辅

现代浏览器(如 Chrome、Firefox)对 HTTP/2 的实现遵循 “单连接优先” 原则:

  • 同一域名,默认只建立1 个 HTTP/2 连接,所有请求通过该连接的多路复用传输;
  • 若该连接出现异常(如 TCP 断连),浏览器会自动新建一个 HTTP/2 连接替代;
  • 不同域名,仍会建立独立的 HTTP/2 连接(与 HTTP/1.x 一致,因跨域连接无法共享)。

总结

HTTP/2 的 “单个连接” 是推荐的最佳实践(为了最大化多路复用的优势),但协议本身不限制连接数量。实际应用中,绝大多数场景会用单连接,仅在兼容性、故障恢复等特殊情况下使用多个连接。这种设计既保留了灵活性,又通过单连接默认策略解决了 HTTP/1.x 的核心性能问题。

三、HTTP2中,多路复用的原理是什么?

HTTP/2 的多路复用(Multiplexing)是其核心特性之一,本质是在单一 TCP 连接上同时处理多个请求 - 响应事务,解决了 HTTP/1.x 中 “队头阻塞”(Head-of-Line Blocking)和连接效率低下的问题。其实现原理可拆解为三个关键机制:

1. 帧(Frame):数据传输的最小单位

HTTP/2 将所有传输的数据(请求头、响应体等)拆分为二进制帧,每个帧大小固定(默认最大 16KB),并包含以下关键信息:

  • 流标识符(Stream ID) :标记该帧属于哪个 “流”(对应一个请求 - 响应);
  • 类型(Type) :区分帧的用途(如HEADERS帧承载请求头,DATA帧承载正文,SETTINGS帧配置参数等);
  • 长度(Length) :帧的实际数据大小;
  • 标志位(Flags) :附加控制信息(如END_STREAM标记流结束)。

二进制帧的设计相比 HTTP/1.x 的文本格式,不仅解析效率更高,更重要的是为 “交错传输” 奠定了基础。

2. 流(Stream):请求 - 响应的逻辑通道

每个请求 - 响应事务对应一个,流是 TCP 连接内的 “虚拟通道”,具有以下特性:

  • 双向性:一个流中可同时传输客户端到服务器(请求)和服务器到客户端(响应)的帧;
  • 唯一标识:每个流有唯一的Stream ID(客户端发起的流 ID 为奇数,服务器发起的为偶数);
  • 优先级:可通过PRIORITY帧指定流的优先级(如 CSS/JS 资源优先于图片),服务器据此调整帧的发送顺序;
  • 可中断与复用:流可被暂停、恢复或终止,释放的 ID 可被新流复用。

通过流的隔离,多个请求 - 响应的帧可以在同一 TCP 连接上 “交错传输”(如请求 A 的DATA帧和请求 B 的HEADERS帧交替发送),接收方再根据Stream ID将帧重新组装成完整的请求 / 响应。

3. 单一 TCP 连接的复用

HTTP/2 通过上述 “帧 + 流” 机制,实现了单一 TCP 连接上的多路复用

  • 所有请求 / 响应共享一个 TCP 连接,无需为每个请求建立新连接(减少三次握手 / 慢启动的开销);
  • 多个流的帧可并行传输,避免了 HTTP/1.x 中 “一个请求阻塞导致后续请求排队” 的队头阻塞问题;
  • 即使某个流因网络问题阻塞,其他流的帧仍可正常传输(仅影响单个流,不阻塞整个连接)。

对比 HTTP/1.x 的核心优势

HTTP/1.x HTTP/2 多路复用
多个请求需建立多个 TCP 连接(或串行复用同一连接) 所有请求共享单一 TCP 连接
文本格式传输,解析效率低 二进制帧传输,解析更快
一个请求阻塞会导致后续请求排队(队头阻塞) 流隔离,单个流阻塞不影响其他流

简言之,HTTP/2 的多路复用通过 “帧拆分 + 流标识 + 单连接复用”,彻底解决了 HTTP/1.x 的连接效率问题,大幅提升了高并发场景下的性能(如网页加载大量资源时)。

四、HTTP/1为啥会一个请求阻塞会导致后续请求排队(队头阻塞)?

HTTP/1.x 出现 “队头阻塞”(Head-of-Line Blocking)的核心原理,是由TCP 协议的 “按序交付” 特性HTTP/1.x 协议的 “串行请求 - 响应” 设计共同决定的,两者叠加导致了 “前一个请求阻塞后续所有请求” 的现象。

原理拆解:两层机制的叠加限制

1. 底层 TCP 协议的 “按序交付” 特性(根本原因)

TCP 是面向连接的 “可靠字节流协议”,其核心特性之一是 “按序交付”

  • 发送方会给每个数据包分配一个唯一的 “序号”,接收方必须按序号从小到大的顺序接收并组装数据;
  • 若中间某个数据包丢失(如网络波动),接收方会触发 “超时重传” 机制,等待发送方重新发送丢失的数据包;
  • 在丢失的数据包被重传并接收前,后续所有已到达的数据包即使完整,也会被暂存队列中,无法提交给应用层处理(因为顺序被打乱,无法保证数据完整性)。

这就是 “TCP 层的队头阻塞”—— 单个数据包的问题会阻塞后续所有数据的处理。

2. HTTP/1.x 协议的 “串行请求 - 响应” 设计(放大问题)

HTTP/1.x 运行在 TCP 之上,但其协议设计进一步放大了 TCP 的队头阻塞问题:

  • 无 “流标识” 机制:HTTP/1.x 没有像 HTTP/2 那样的 “流 ID” 来区分不同请求的数据包。接收方(如浏览器)只能通过 “请求发送顺序” 来匹配对应的响应。
  • 严格串行处理:在同一个 TCP 连接上,HTTP/1.x 要求:
    • 必须等前一个请求的完整响应被接收后,才能发送下一个请求;
    • 响应也必须按请求发送的顺序返回(否则接收方无法判断哪个响应对应哪个请求)。

最终导致队头阻塞的过程

假设在一个 TCP 连接上,浏览器按顺序发送 3 个请求:请求A → 请求B → 请求C,过程如下:

  1. 服务器正常返回响应A的部分数据,但中途某个数据包丢失;
  2. 由于 TCP 按序交付特性,接收方会等待丢失的数据包重传,此时响应A的后续数据和已到达的响应B响应C的完整数据,都会被暂存在 TCP 缓冲区中,无法提交给浏览器处理;
  3. 同时,由于 HTTP/1.x 的串行规则,浏览器必须等响应A完全接收后,才能处理响应B响应C—— 即使响应B响应C的数据早已到达,也只能排队等待。

最终,单个请求(A)的阻塞会像 “多米诺骨牌” 一样,导致后续所有请求(B、C)被卡住,这就是 HTTP/1.x 队头阻塞的完整原理。

总结

TCP 的 “按序交付” 导致单个数据包问题阻塞后续数据,而 HTTP/1.x 缺乏 “流标识” 和 “并行处理” 能力,只能通过 “串行请求 - 响应” 来保证数据匹配,两者叠加使得一个请求的延迟会阻塞同一连接上所有后续请求,这就是队头阻塞的本质。

也是吃上细糠了。用上了Claude Code的无限中转API

作者 极客密码
2025年12月21日 16:58

前言

Cursor 切换到 Claude Code 一段时间了

搭配 Claude Code for Vs Code 插件使用 非常丝滑

插件

插件主界面

除了没有 Tab Completions 之外,其他体验相较于 Cursor 几乎没有差别

用 Claude Code 做了什么

期间用它 VibeCoding 了一个应用 echarts.me

Echarts.Me

这是一个用自然语言描述生成图表的应用,后续会写一篇文章单独介绍它

也写了一个 Claude Code 的 API 商家快速切换的 Cli 工具:

www.npmjs.com/package/@cl…

当然,正常工作中也解决了不少的问题

地址呢?上链接!

这就贴上链接哈,我用的是这家中转 API

因为他们家有付费用户,所以稳定性非常有保障,这么多天用下来的体验就是:模型很强,API很稳,响应很快

https://api.code-relay.com/register?aff=seMc

【用上面的链接注册赠送 30 刀额度,日常使用两个礼拜应该是够的】

同时,他们家与官方的 API 价格相比,也非常便宜,贴个图感受一下

价格

再说回标题白嫖的事儿,其实通过邀请链接注册除了给被邀请的新用户赠送的 30 刀,还会给邀请人赠送 25 刀额度

所以,理论上来说,就是无限白嫖!

嗯,怎么能不算呢???

让用户愿意等待的秘密:实时图片预览

作者 烟袅破辰
2025年12月21日 16:58

你有没有经历过这样的场景?点击“上传头像”,选了一张照片,页面却毫无反应——没有提示,没有图像,只有一个静默的按钮。你开始怀疑:是没选上?网速慢?还是系统出错了?于是你犹豫要不要再点一次,甚至直接关掉页面。
而如果在你选择文件的瞬间,一张清晰的缩略图立刻出现在眼前,哪怕后端还在处理,你也会安心地等待下去。
不是用户没耐心,而是他们需要一点“确定性”来支撑等待的理由。
图片预览,正是那个微小却关键的信号:你的操作已被接收,一切正在按预期进行。

得到程序正在运行的信号之后用户才会有等待的欲望。

今天,我们就来亲手实现一个图片预览功能。 先思考:要让一张用户选中的本地图片显示在网页上,我们到底需要做些什么?


第一步:我们要显示图片,那肯定得有个 <img> 标签吧?

没错。想在页面上看到图片,最直接的方式就是用 <img :src="xxx" />。但问题来了:用户刚从电脑里选了一张照片,这张照片还在他本地硬盘上,还没传到服务器,也没有公开 URL。那 src 该填什么?

这时候你可能会想:“能不能把这张本地文件直接塞进 src?”
答案是:不能直接塞 File 对象,但——我们可以把它“变成”一个 URL。


第二步:用户选了图,我们怎么拿到它?

通常我们会用 <input type="file" accept="image/*"> 让用户选择图片。在 Vue 中,为了能“拿到”这个 input 元素本身(而不仅仅是它的值),我们会用到 ref

<input 
  type="file" 
  ref="uploadImage" 
  accept="image/*"
  @change="updateImageData"
/>

这里,ref="uploadImage" 就像给这个 input 贴了个标签。之后在 script 里,我们就能通过 uploadImage.value 拿到它的真实 DOM 引用。

于是,在 updateImageData 函数里,我们可以这样取到用户选中的文件:

const input = uploadImage.value;
const file = input.files[0]; // 用户选的第一张图

注意:不是 input.file,而是 input.files —— 这是一个常见的笔误,也是很多初学者卡住的地方。


第三步:有了 File 对象,怎么变成 <img> 能识别的 src

现在我们手里有一个 File 对象,但它不能直接赋给 img.src。我们需要把它转成一种浏览器能直接渲染的格式。

这时候,FileReader 就登场了。

const reader = new FileReader();
reader.readAsDataURL(file);

readAsDataURL 会把文件内容读取为一个 Data URL,格式类似:

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...

这串字符串可以直接作为 <img>src!是不是很巧妙?

那什么时候能拿到这个结果呢?FileReader 是异步的,所以我们监听它的 onloadend 事件:

reader.onloadend = (e) => {
  imgPreview.value = e.target.result; // 这就是 Data URL
}

而我们的模板中早已准备好了一个 <img>

<img :src="imgPreview" alt="" v-if="imgPreview" />

imgPreview 有值时,图片就自动显示出来了!


完整逻辑串起来

把这些碎片拼在一起,整个流程就清晰了:

  1. 用户点击 input 选择图片;
  2. @change 触发 updateImageData
  3. 通过 ref 拿到 input,取出 files[0]
  4. FileReader 读取为 Data URL;
  5. 把结果存到响应式变量 imgPreview
  6. Vue 自动更新 <img :src="imgPreview">,图片就出来了。

这整个过程完全在前端完成,不需要上传到服务器,也不依赖任何第三方库——只用了浏览器原生 API 和 Vue 的响应式系统。


最后:完整实例

在view中实现图片预览的完整代码及效果

在PySide6/PyQt6的项目中封装一些基础类库,包括文件对话框、字体对话框、颜色对话框、消息对话框等内容

作者 伍华聪
2025年12月21日 16:41
在我们实际开发项目的时候,为了使用方便,会针对一些常用到的内容进行一定的封装处理,以降低使用的难度和减少相关代码,本篇随笔介绍在PySide6/PyQt6的项目中封装一些基础类库,包括文件对话框等内容

MVC、MVP、MVVM:用户界面与业务逻辑的解耦

2025年12月21日 16:32

MVC、MVP、MVVM是前端开发中经典的软件架构模式,通过关注点分离实现用户界面与业务逻辑解耦,提升代码可维护性与可扩展性。

一、MVC:分层解耦,实现初步解耦

image.png

核心思想

将代码划分为Model(数据 / 逻辑)、View(界面 / 交互)、Controller(协调 / 控制)三大组件,每个组件有特定的职责

组件责职

组件 职责说明 关键特点
Model 数据管理与业务逻辑。包括数据的访问、处理和存储。 不关心数据展示;当数据发生变化时,通知View更新
View 界面展示与用户交互。从Model获取数据进行展示,同时将用户输入传递给Controller。 一个模型可以对应多个视图,支持不同的展示方式
Controller 协调View和Model。接收用户输入,调用 Model 处理数据,指定View进行渲染 扮演“中间协调者”角色

说明:在Web早期,用户交互主要是用户访问超链接或表单提交,服务器返回新的HTML页面。随着Ajax技术的出现,前后端分离,前端异步请求数据,后端返回JSON数据,由前端进行局部渲染更新页面。

交互流程

  • 用户与View交互,View将用户输入传递给Controller。
  • Controller调用Model处理数据。
  • Model执行完业务逻辑并返回数据。
  • Controller选择View返回给用户,View从Model获取数据生成HTML或JSON

典型框架

  1. Spring MVC

Spring MVC是一个经典的Java Web框架,一个请求处理流程如下:

image.png

  1. Backbone.js

早期的MVC框架主要应用在服务器端,随着前后端分离,前端也有自己的MVC框架。Backbone.js就是其中之一。

在前后端分离的架构下,前端主要做两件事情:视图展示和前后端数据同步,对应Backbone. js中两大组件:Model 和 View。

Model负责数据管理与业务逻辑,并通过RESTful API与后端同步数据。当数据变化时,Model会触发事件通知视图更新。

View监听Model变化,从Model获取数据进行展示;监听DOM事件,接收用户输入并调用Model处理数据。

Backbone.js的View比较厚,承担了MVC中的View和Controller的职责。

// Model定义
var User = Backbone.Model.extend({    
    urlRoot: '/api/users'
});
// View定义
var UserView = Backbone.View.extend({    
    initialize: function() {        
        // 手动监听模型变化        
        this.listenTo(this.model, 'change', this.render);    
    },
    render: function() {       
        // 手动构建HTML或使用模板引擎        
        this.$el.html(`<h1>${this.model.get('name')}</h1>            
                       <p>Email: ${this.model.get('email')}</p>            
                       <button class="edit-btn">编辑</button>`);        
        return this;    
     },
    events: {        
        'click .edit-btn': 'editUser'    
    },
    editUser: function() {  
        // 手动创建编辑视图       
        var editView = new UserEditView({model: this.model});       
        $('#modal-container').html(editView.render().el);    
    }
});
// Controller控制流程
var user = new User({id: 123});
user.fetch();  // 从服务器获取数据
var userView = new UserView({model: user, el: '#user-container'});
userView.render();  // 初始渲染

补充:Backbone.js是早期的SPA开发框架,后来被Vue、React逐步代替。

MVC的优缺点

优点 缺点
修改View/Model互不影响 View依赖Model(需监听Model变化并获取数据)
View和Model支持并行开发 Controller职责过重,需要协调View/Model
一个Model可以对应多个View,支持不同的展示方式。 需要手动同步View和Model
可独立测试 View/Model

二、MVP:Presenter代理,实现彻底解耦

image.png

核心思想

用 Presenter 代替 Controller,彻底切断 View 与 Model 的直接通信。通过Presenter作为双向代理,实现两者的间接通信,解耦更彻底。

组件职责

组件 关键变化
View 仅提供渲染接口给Presenter调用,不需要从Model获取数据进行展示
Model 数据变化通知 Presenter 而非 View
Presenter 接收用户输入 → 调用 Model接口处理数据 → 调用 View 接口更新界面

交互流程

  • 用户与View交互,View调用Presenter接口处理事件。
  • Presenter调用Model处理数据。
  • Model完成后回调Presenter。
  • Presenter调用View接口更新界面。

典型框架

MVP框架主要应用在安卓界面开发,示例代码如下:

class Todo {
    private content: string;
    constructor(content: string) {
        this.content = content;
    }
    getContent(): string {
        return this.content;
    }
}

interface ITodoModel {
    interface TodosCallback {
        onSuccess(): void;
    }
    saveTodo(todo: Todo, callback: GetTodosCallback): void;
}

interface ITodoView {
    showSuccess(): void;
}

interface ITodoPresenter {
    saveTodo(todo: Todo):void;
}

class TodoModel implement ITodoModel {
    saveTodo(todo: Todo, callback: GetTodosCallback): void {
        saveDB(todo);// 模拟保存数据库
        callback.onSuccess();
    }
}

class BasePresenter<T> {
    private mView: T;
    attachView(view: T):void {
        mView = view;
    }
    dettachView():void {
        mView = null;
    }
    getView():T {
        return mView;
    }
}

class TodoPresenter extends BasePresenter<ITodoView> implement ITodoModel.TodosCallbackI, TodoPresenter {
    todoModel: ITodoModel;
    constructor(){
        this.todoModel = new TodoModel();
    }
    saveTodo(todo: Todo):void {
        this.todoModel.saveTodo(todo, this);
    }
    onSuccess() {
        getView().showSuccess();
    }
}

TodoActivity extends AppCompatActivity implements ITodoView {
    private TextView mResultTv;
    private EditText mTodoContentEt;
    private TodoPresenter mTodoPresenter;
    onCreate(Bundle savedInstanceState):void {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_todo);
        initPresenter();
        initView();
    }
    onDestroy(): void {
        super.onDestroy();
        this.mTodoPresenter.dettachView();
     }
    initView():void {
        this.mResultTv = findViewById(R.id.tv_result);
        this.mTodoContentEt = findViewById(R.id.et_todo_content);

        findViewById(R.id.btn_add).setOnClickListener(new View.OnClickListener() {
            onClick(View view):void {
                const todo = getTodoInput();
                this.mTodoPresenter.saveTodo(todo);
            }
        });
     }
     initPresenter() {
         this.mTodoPresenter = new TodoPresenter();
         this.mTodoPresenter.attachView(this);
     }
     getTodoInput(): Todo {
         const content = mTodoContentEt.getText().toString();
         return new Todo(content);
     }
     showSuccess(): void {
         this.mResultTv.setText("success");
     }
}

解读:

  • ITodoModel的职责是数据管理,并通过回调通知ITodoPresenter。
  • ITodoView的职责是展示数据,调用ITodoPresenter接口处理用户输入。
  • ITodoPresenter负责接收用户输入,调用ITodoModel处理数据,然后调用ITodoView展示数据。

MVP的优缺点

优点 缺点
View 与 Model 完全解耦 Presenter职责过重,需要组织View/Model完成所有交互
基于接口通信,进一步解耦,可独立测试 需定义大量接口,增加代码复杂度
View 逻辑纯粹(仅负责渲染) 仍需要手动同步View和Model

三、MVVM:数据绑定,实现自动同步

image.png

核心思想

通过数据绑定实现 View 与 Model 的自动同步,减少手动DOM操作。

组件职责

组件 职责说明
ViewModel 响应式系统:监听 Model 变化并自动同步到 View;监听 DOM 事件并自动更新 Model。
View 声明式地绑定关联的Model,无需手动操作 DOM。
Model 数据管理与业务逻辑,不关系数据展示。

交互流程

  • 用户与View交互,ViewModel自动将用户输入同步给Model。
  • Model执行业务逻辑和操作数据,更新状态。
  • Model变化后,ViewModel自动将Model同步到View。

典型框架

2010年后,Vue.js和React等主流框架采用MVVM架构,减少了DOM操作。

<body>
    <div id="app">
        常见搜索关键字:<span @click="setKeyword('人工智能')">人工智能</span>
        <input
            v-model="keyword" 
            placeholder="请输入要搜索的关键字..." 
            @keyup.enter="search"
        >
        <button @click="search">
            搜索
        </button>
        <div v-if="loading">
            正在搜索中,请稍候...
        </div>
        <div v-for="result in searchResults" :key="result.id">
            <div>{{ result.content }}</div>
        </div>
    </div>
  </body>

  <script>
        const { createApp, ref, computed } = Vue;
        createApp({
            setup() {
                const keyword = ref('');
                const loading = ref(false);
                const searchResults = ref([]);
                // 模拟数据
                const allResults = ref([
                    {
                        id: 1,
                        content: 'Vue.js 入门教程',
                    },
                    {
                        id: 2,
                        content: 'JavaScript 高级编程',
                    },
                ]);
                const setKeyword = (example) => {
                    keyword.value = example;
                    search();
                };
                const search = () => {
                    const searchTerm = keyword.value.toLowerCase();
                    searchResults.value = allResults.value.filter(result => (
                            result.content.toLowerCase().includes(searchTerm));
                    );
                };
                
                return {
                    keyword,
                    setKeyword,
                    loading,
                    searchResults,
                    search,
                };
            }
        }).mount('#app');
    </script>

解读:

  • 在Vue.js中使用v-model指令声明式地实现双向绑定。当用户输入关键字时,数据会自动同步到keyword变量。反之,当用户点击“常见搜索关键字” 按钮,改变keyword变量的值,也会自动同步到输入框。
  • 在Vue.js模版中使用{{}}声明式地实现单向绑定。当执行完搜索逻辑并把结果赋值给searchResults变量,数据会被自动渲染到结果列表。

MVVM的优缺点

优点 缺点
自动同步View和Model,减少手动 DOM 操作 数据绑定可能带来性能开销
ViewModel 可独立测试 双向绑定,调试复杂度高

四、总结

架构 核心思想 优点 缺点 适用场景
MVC 分层解耦 职责分离,易于扩展 View依赖Model 简单应用、后端开发
MVP 代理隔离 彻底切断 View 与 Model 关联 需要手动同步View和Model 中大型应用、需严格测试
MVVM 数据绑定 实现View和Model自动同步 有一定的性能与调试成本 现代前端应用、SPA 开发
❌
❌