阅读视图

发现新文章,点击刷新页面。

每日一题-统计公平数对的数目🟡

给你一个下标从 0 开始、长度为 n 的整数数组 nums ,和两个整数 lower 和 upper ,返回 公平数对的数目

如果 (i, j) 数对满足以下情况,则认为它是一个 公平数对 :

  • 0 <= i < j < n,且
  • lower <= nums[i] + nums[j] <= upper

 

示例 1:

输入:nums = [0,1,7,4,4,5], lower = 3, upper = 6
输出:6
解释:共计 6 个公平数对:(0,3)、(0,4)、(0,5)、(1,3)、(1,4) 和 (1,5) 。

示例 2:

输入:nums = [1,7,9,2,5], lower = 11, upper = 11
输出:1
解释:只有单个公平数对:(2,3) 。

 

提示:

  • 1 <= nums.length <= 105
  • nums.length == n
  • -109 <= nums[i] <= 109
  • -109 <= lower <= upper <= 109

CSS考点之权重计算🧑🏻‍💻

引言💭

在 CSS 中,权重决定了哪条规则会应用到 HTML 元素。当多个规则作用于同一元素时,浏览器会根据权重来判断哪一条规则优先执行。

这也是一个面试高频考题,赶紧码住。✍🏻

什么是 CSS 权重?🤔

CSS 权重是一个数值,用来表示某个选择器的优先级。浏览器根据该优先级来决定哪些规则被应用。CSS 权重的计算是根据选择器的类型(例如:元素、类、ID 选择器等)来确定的。每种选择器类型对应一个特定的权重值,而这些权重值会按照一定的规则叠加。

权重通常用四个数字表示,形式为 (a, b, c, d)。这四个数字分别代表不同类型选择器的权重:

  • a: 内联样式的权重,优先级最高。
  • b: ID 选择器的权重。
  • c: 类选择器、属性选择器和伪类选择器的权重。
  • d: 元素选择器和伪元素选择器的权重。

CSS 权重计算的详细规则📑

  1. 内联样式style="..."):

    • 权重:(1, 0, 0, 0),内联样式的优先级最高。
    • 示例:<div style="color: red;">内容</div>
  2. ID 选择器

    • 权重:(0, 1, 0, 0),每个 ID 选择器的权重为 1
    • 示例:#header { color: red; }#menu { color: blue; }
  3. 类选择器、属性选择器和伪类选择器

    • 权重:(0, 0, 1, 0),每个类选择器、属性选择器和伪类选择器的权重为 1
    • 示例:.menu { color: green; }[type="text"] { color: orange; }:hover { color: purple; }
  4. 元素选择器和伪元素选择器

    • 权重:(0, 0, 0, 1),每个元素选择器和伪元素选择器的权重为 1
    • 示例:div { color: blue; }p { color: red; }::before { content: ''; }

权重计算示例📌

假设有以下的 CSS 规则:

#header { color: red; }
.menu { color: green; }
div { color: blue; }

对应的 HTML 元素为:

<div id="header" class="menu">Hello</div>

根据权重计算,浏览器会按照以下方式决定哪个规则生效:

  1. #header { color: red; }

    • 权重:(0, 1, 0, 0),使用了 ID 选择器。
  2. .menu { color: green; }

    • 权重:(0, 0, 1, 0),使用了类选择器。
  3. div { color: blue; }

    • 权重:(0, 0, 0, 1),使用了元素选择器。

由于 ID 选择器的权重最高((0, 1, 0, 0)),最终文本的颜色会是 红色,即 #header 规则生效。

权重递增与选择器的组合🌈

多个选择器组合时,它们的权重会叠加。例如:

#header .menu div { color: purple; }

此时,#header .menu div 的权重是 (0, 1, 1, 1),它包含了一个 ID 选择器(#header)、一个类选择器(.menu)和一个元素选择器(div)。它的权重为:

  • ID 选择器:1
  • 类选择器:1
  • 元素选择器:1

因此,最终权重为 (0, 1, 1, 1)

内联样式的优先级🎢

内联样式具有最高的优先级,权重为 (1, 0, 0, 0)。即使在外部样式表中存在其他选择器,内联样式也会覆盖它们。

例如,以下 HTML 元素的内联样式将覆盖任何外部样式:

<div id="header" class="menu" style="color: yellow;">Hello</div>

即使外部 CSS 中有 #header { color: red; }.menu { color: green; },内联样式 style="color: yellow;" 仍然会应用,并使文本的颜色变为 黄色

选择器优先级的具体比较🚀

假设以下 CSS 规则:

#header { color: red; }
.menu { color: green; }
div { color: blue; }

以及 HTML 元素:

<div id="header" class="menu">Hello</div>

在此情况下,CSS 规则的权重:

  • #header { color: red; } 的权重为 (0, 1, 0, 0)
  • .menu { color: green; } 的权重为 (0, 0, 1, 0)
  • div { color: blue; } 的权重为 (0, 0, 0, 1)

因此,最终会应用 #header { color: red; },因为它的权重最大,红色会覆盖其他颜色。

结语✒️

在编写 CSS 时,尽量避免使用过多的高权重选择器,尤其是 ID 选择器,以保持样式的可维护性。如果你遇到样式覆盖问题,首先检查选择器的权重,确保它们按正确的顺序排列。🫰🏻

猫抓爱心 (2).gif

vue与react(自定义)中计算属性对比

一、核心概念对比

特性 Vue (Computed) React (useMemo/useCallback)
定义方式 声明式(基于依赖自动缓存) 命令式(手动声明依赖数组)
缓存机制 自动缓存(依赖不变则复用) 手动控制(依赖数组变化时重新计算)
响应式触发 依赖变更自动触发 需严格定义依赖数组
语法复杂度 低(模板中直接使用) 中(需配合Hooks使用)
适用场景 模板渲染优化、派生状态 性能优化、复杂计算避免重复执行

二、原理剖析

1. Vue Computed 原理

实现方式‌:基于响应式系统的依赖追踪

// Vue 3 源码简化版
function computed(getter) {
  let value;
  let dirty = true; // 标记是否需要重新计算

  const runner = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true; // 依赖变化时标记为脏数据
    }
  });

  return {
    get value() {
      if (dirty) {
        value = runner(); // 重新计算
        dirty = false;
      }
      return value;
    }
  };
}

关键点‌:

  • 依赖变更时通过scheduler标记数据为dirty
  • 下次访问时按需重新计算(惰性求值)

2. React useMemo 原理

实现方式‌:依赖数组的浅比较(Object.is)

// React 源码简化逻辑
function useMemo(factory, deps) {
  const hook = getCurrentHook();
  const nextDeps = deps || [];

  if (hook.memoizedState) {
    const [prevValue, prevDeps] = hook.memoizedState;
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevValue; // 依赖未变化时返回缓存值
    }
  }

  const newValue = factory();
  hook.memoizedState = [newValue, nextDeps];
  return newValue;
}

关键点‌:

  • 每次渲染时对比依赖数组
  • 依赖变化时重新执行工厂函数

三、代码实战对比

场景1:基础计算属性

Vue 实现

<template>
  <div>
    FullName: {{ fullName }} <!-- 自动缓存 -->
  </div>
</template>

<script>
export default {
  data() {
    return { firstName: '张', lastName: '三' };
  },
  computed: {
    fullName() {
      return this.firstName + ' ' + this.lastName; 
    }
  }
};
</script>

React 实现

function UserCard({ firstName, lastName }) {
  const fullName = useMemo(
    () => `${firstName} ${lastName}`,
    [firstName, lastName] // 需手动声明依赖
  );

  return <div>FullName: {fullName}</div>;
}

场景2:复杂数据过滤

Vue 实现

<template>
  <ul>
    <li v-for="user in activeUsers" :key="user.id">
      {{ user.name }}
    </li>
  </ul>
</template>

<script>
export default {
  data() {
    return {
      users: [
        { id: 1, name: 'Alice', isActive: true },
        { id: 2, name: 'Bob', isActive: false }
      ]
    };
  },
  computed: {
    activeUsers() {
      return this.users.filter(u => u.isActive); // 自动缓存结果
    }
  }
};
</script>

React 实现

function UserList({ users }) {
  const activeUsers = useMemo(
    () => users.filter(u => u.isActive),
    [users] // 注意:如果users引用不变但内容变化,需深度比较
  );

  return (
    <ul>
      {activeUsers.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

四、复杂场景处理

场景1:依赖动态变化

Vue 的自动依赖追踪

vueCopy Code
<script>
export default {
  data() {
    return { a: 1, b: 2, useA: true };
  },
  computed: {
    result() {
      return this.useA ? this.a : this.b; // 自动识别依赖切换
    }
  }
};
</script>

React 的显式依赖管理

function Calculator({ a, b, useA }) {
  const result = useMemo(
    () => (useA ? a : b),
    [useA, useA ? a : b] // 必须明确所有可能依赖
  );
  return <div>Result: {result}</div>;
}

场景2:计算属性链式调用

Vue 的链式计算

<script>
export default {
  data() {
    return { price: 100, taxRate: 0.1 };
  },
  computed: {
    tax() {
      return this.price * this.taxRate;
    },
    total() { // 可依赖其他计算属性
      return this.price + this.tax;
    }
  }
};
</script>

React 的逐层缓存

function Invoice({ price, taxRate }) {
  const tax = useMemo(() => price * taxRate, [price, taxRate]);
  const total = useMemo(() => price + tax, [price, tax]); // 需手动传递依赖

  return <div>Total: {total}</div>;
}

五、性能优化对比

Vue 的优势

  1. 自动缓存‌:无需手动维护依赖,适合模板中的派生数据
  2. 细粒度更新‌:依赖变更时精准触发相关组件更新

React 的优势

  1. 灵活控制‌:可结合useCallback缓存函数,避免子组件无效渲染
  2. 跨组件复用‌:通过自定义Hook共享计算逻辑
// React 自定义Hook复用
function useTotal(price, taxRate) {
  return useMemo(() => {
    const tax = price * taxRate;
    return price + tax;
  }, [price, taxRate]);
}

// 多个组件共享同一逻辑
function Cart() {
  const total = useTotal(100, 0.1);
  return <div>Total: {total}</div>;
}

六、使用场景推荐

框架 推荐场景
Vue 1. 模板中的复杂表达式优化 2. 需要自动追踪依赖的派生数据 3. 表单联动计算
React 1. 需要手动控制缓存时机 2. 跨组件共享计算逻辑 3. 结合Context的复杂状态派生

  • Vue Computed‌:适合声明式UI场景,‌ "省心" ‌但灵活性较低
  • React useMemo‌:适合精细控制,‌ "强大" ‌但需手动维护依赖
  • 终极选择‌:Vue适合快速开发,React适合大型应用状态管理

✈️ Colipot Agent + 🔥 MCP Tools = 让你的编程体验直接起飞🚀

前言

上篇文章主要介绍了使用 Mcp ts Sdk 搭建一个 MCP 应用,我们已经知道下面的概念和基本使用!

复习下 MCP 架构

我们并且在建立 Server 时添加了一个tool,根据关键字 公理 返回三体黑暗森林法则的两个公理!

命中了关键词

往期精彩推荐

正文

今天我们的主要目标是:将这个工具注册到 VsCode 中,并可以在 Agent 模式中使用它!

添加 Mcp Server

Ctrl+Shift+PCmd+Shift+P,输入并选择 MCP: Add Server

Ctrl+Shift+P

或者你也可以在 Colipot 中点击工具图标进入:

点击工具图标

添加 server

选择合适的类型

接着会让你选择服务类型,如果你是在本地,推荐使用 Stdio

选择合适的类型

  • Stdio 的优势Stdio 传输适合本地开发,避免网络复杂性,适合测试和调试。
  • 其他传输方式:虽然 VSCode 还支持 SSE(Server-Sent Events),但 Stdio 更适合本地场景

配置文件

根据自己的服务类型输入启动命令,比我上次我用的 js,这里会输入: node

node 或者 python

连续回车两次,第一次是自动生成的tool唯一标识,第二次会让你选择设置,建议选择workspace settings

workspace settings

VSCode 会在根目录下生成.vscode/mcp.json如下的配置文件:

配置文件

args 对应的是参数,比如执行文件目标地址!

使用

打开 Copilot Agent 视图,然后使用自然语言调用 MCP 服务器的工具。

识别到 tool

我们输入框里输入相关语句,VsCode 会自动查找对应的 tool

运行之后的结果

最后

通过 VsCode Colipot Agent 提供的能力,我们可以让我们的编辑期更加智能的为我们提供服务!

好了今天的分享就些了,十分感谢大家的支持,如果文章后中有错误的地方,欢迎指正!

往期精彩推荐

谷歌浏览器插件 录制菜单路由跳转行为 事件重复解决方案

背景

开发谷歌插件的过程中,监听用户浏览器操作行为,vue3 项目使用hash路由,发现在 background 脚本中通过chrome.tabs.onUpdated和chrome.webNavigation.onHistoryStateUpdated事件监听时,点击切换菜单路由时都会触发一个链接跳转事件,导致监听事件重复。

这里说明一下为什么在chrome.tabs.onUpdated和chrome.webNavigation.onHistoryStateUpdated事件都写了那个链接跳转事件,是因为发现在某些网站上有时候chrome.tabs.onUpdated会触发,有时候chrome.webNavigation.onHistoryStateUpdated会单独触发,为了兼容处理各种情况,所以就在这两个事件里都处理了链接跳转事件,结果引发了文章开头描述的问题

思考过程

引发上述问题可能的原因包括:

  1. 事件触发条件重叠:当Hash变化时,chrome.tabs.onUpdated可能因为URL变化而触发,而chrome.webNavigation.onHistoryStateUpdated则因为历史状态改变而触发,两者同时发生。
  2. 事件监听器的重复注册:可能在插件的不同地方多次注册了相同的监听器,导致重复触发。
  3. 事件传播或页面加载阶段的问题:比如在页面加载的不同阶段,两个事件可能被多次触发。

接下来,我需要考虑如何解决这个问题。可能的解决方案包括:

  • 合并事件监听:将两个事件的监听逻辑合并,避免重复处理。例如,将两个事件的回调函数统一到一个处理函数中,并添加防重机制。
  • 条件判断过滤:在事件回调中添加条件判断,比如检查URL变化是否是Hash变化,或者是否是用户主动触发的路由跳转,从而决定是否执行逻辑。
  • 使用防重或节流:在回调函数中使用防重(如setTimeout和clearTimeout)或节流来避免短时间内多次触发。
  • 选择更精确的事件监听:如果Hash路由主要通过hashchange事件触发,可能只需要监听chrome.webNavigation.onHistoryStateUpdated,而chrome.tabs.onUpdated可能包含更多的触发场景,可以考虑移除其中一个监听。(PASS,原因看开头背景说明)

解决方案

1. 合并事件监听逻辑,避免重复处理

// 合并后的处理函数
function handleUrlChange(details) {
  if (isHandling) return; // 防止重复触发
  isHandling = true;

  // 具体处理逻辑(如获取当前URL的Hash值,上报动作)
  
  // 具体处理逻辑

  // 延迟重置标志位,避免短时间内重复触发
  setTimeout(() => {
    isHandling = false;
  }, 200); // 根据需求调整延迟时间
}

// 监听 chrome.tabs.onUpdated
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.url && changeInfo.url.includes('#')) {
    handleUrlChange({ url: changeInfo.url });
  }
});

// 监听 chrome.webNavigation.onHistoryStateUpdated
chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
  handleUrlChange(details);
});

2. 过滤非必要的触发条件

在回调函数中添加条件判断,比如 仅在Hash值变化时执行逻辑,根据自己项目实际情况进行判断。

function handleUrlChange(details) {
  const currentUrl = details.url || chrome.tabs.getSelected().url;
  const newHash = new URL(currentUrl).hash;

  // 获取上一次记录的Hash值(需维护一个变量)
  if (lastHash === newHash) return; // 如果Hash未变化,直接返回
  lastHash = newHash;

  // 执行实际逻辑
  console.log('Hash changed:', newHash);
}

关键注意事项

  1. 防重机制

    • 使用标志位(如isHandling)或节流函数(如setTimeout)防止短时间内重复触发。
  2. 状态维护

    • 通过lastHash记录上一次的Hash值,避免重复处理相同Hash的事件。

🚀🚀🚀 MCP SDK 快速接入 DeepSeek 并添加工具!万万没想到MCP这么简单好用!

前言

上次发的文章因为遇到 bug 没有执行完,现在正常了,其实是我传错参数了,所以重新修正下!

这篇文章记录一下我用 MCP TypeScript SDK 实现一个自包含的 AI 聊天应用的过程:内部包含 MCP 服务器提供上下文,客户端拿上下文再去调 LLM 接口拿回答!

往期精彩推荐

正文

MCP 是什么?

简单说,MCP 是一个给 AI 应用提供上下文的标准协议。你可以把它理解成一个服务标准,它规定了“资源”和“工具”的接口规范,然后通过客户端连接这些接口,就可以组合出丰富的上下文数据。比如说资源可以是“当前时间”、“用户历史记录”,工具可以是“数据库搜索”、“调用外部 API”。

它采用的是客户端-服务器架构,Server 暴露上下文能力,Client 拉取这些上下文,再拿去调语言模型生成回答,而 Transport 负责 ServerClient 的通信部分!

MCP 架构

(AI 帮我画的图)

其中图片中的 Transport 层还分为:

  • StdioServerTransport:用于 CLI 工具对接 stdin/stdout
  • SSEServerTransport:用于HTTP通信
  • StdioClientTransport:客户端以子进程方式拉起服务端,这个不常用

另外,Server 层分为:

  • Server 基本类:原始的类,适合自己定制功能!
  • McpServer基于Server 封装好了可以快速使用的方法!

注意:基本类和封装类的接口有很大不同,具体请参看 README 文件!

安装依赖

用的是官方的 TypeScript SDK

仓库:github.com/modelcontex…

官网:modelcontextprotocol.io

npm install @modelcontextprotocol/sdk axios

DeepSeek 没有官方 SDK,用的是 HTTP API,所以需要 axios

记得把 API Key 放到 .env 或直接配置成环境变量,我用的 DEEPSEEK_API_KEY

实现一个 McpServer

我们先实现一个本地 McpServer,实现两个东西:

  • 当前时间(资源)
  • 本地“知识库”搜索(工具)

代码如下:

// src/server.js
import {
  McpServer,
  ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const facts = [
  "公理1: 生存是文明的第一需要.",
  "公理2: 文明不断增长和扩张,但宇宙中的物质总量保持不变.",
].map((f) => f.toLowerCase());
try {
  const server = new McpServer({
    name: "mcp-cli-server",
    version: "1.0.0",
  });

  // 使用 Zod 定义工具的输入模式
  server.tool(
    "search_local_database",
   {
      query: z.string(),
    },
    async ({ query }) => {
      console.log("Tool called with query:", query);
      const queryTerms = query.toLowerCase().split(/\s+/);
      const results = facts.filter((fact) =>
        queryTerms.some((term) => fact.includes(term))
      );
      return {
        content: [
          {
            type: "text",
            text: results.length === 0 ? "未找到相关公理" : results.join("\n"),
          },
        ],
      };
    }
  );


  // 定义资源
  server.resource(
    "current_time",
    new ResourceTemplate("time://current", { list: undefined }),
    async (uri) => ({
      contents: [{ uri: uri.href, text: new Date().toLocaleString() }],
    })
  );

  await server.connect(new StdioServerTransport());
  console.log("Server is running...");
} catch (err) {
  console.error("Server connection failed:", err);
}

这样一来,我们的服务端就能通过 MCP 协议对外暴露两个上下文能力了。

配置 MCP Client

MCP 的客户端用来连接服务器并获取资源或调用工具:

// src/client.js;
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

export async function createClient() {
  const client = new Client({
    name: "Demo",
    version: "1.0.0",
  });

  const transport = new StdioClientTransport({
    command: "node",
    args: ["src/server.js"],
  });

  try {
    await client.connect(transport);
    console.log("Client connected successfully");
  } catch (err) {
    console.error("Client connection failed:", err);
    throw err;
  }

  // 可选:添加客户端方法调用后的调试
  return client;
}

连上之后,我们就可以开始调用服务端的资源和工具了。

获取上下文

我们设定一个简单的逻辑:每次用户提问,客户端都会获取当前时间;如果问题里包含 公理,那就调用搜索工具查一下本地知识库:

async function getContext(client, question) {
  let currentTime = "";
  let additionalContext = "";

  try {
    const resources = await client.readResource(
      { uri: "time://current" },
      { timeout: 15000 }
    ); // 增加超时时间
    console.log("Resources response:", resources);
    currentTime = resources.contents[0]?.text ||
      new Date().toLocaleString(); // 注意:resources 直接包含 contents
  } catch (err) {
    console.error("Resource read error:", err);
    currentTime = new Date().toLocaleString();
  }

  if (question.toLowerCase().includes("公理")) {
    console.log("Searching for axioms...", question);
    try {
      const result = await client.getPrompt({
        name: "search_local_database",
        arguments: { query: question },
      });
      console.log("Tool result:", result);
      additionalContext = result?.[0]?.text || "No results found.";
    } catch (err) {
      console.error("Tool call error:", err);
      additionalContext = "Error searching database.";
    }
  }

  return { currentTime, additionalContext };
}

集成 DeepSeek,开始问答

DeepSeek 使用的是标准 OpenAI 接口风格,HTTP POST 请求即可。这里我们用 axios 调用:

import axios from "axios";

async function askLLM(prompt) {
  try {
    console.log("Calling LLM with prompt:", prompt);
    const res = await axios.post(
      "https://api.deepseek.com/chat/completions",
      {
        model: "deepseek-chat",
        messages: [{ role: "user", content: prompt }],
        max_tokens: 2048,
        stream: false,
        temperature: 0.7,
      },
      {
        headers: {
          Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,
          "Content-Type": "application/json",
        },
        timeout: 1000000,
      }
    );
    console.log("LLM response:", res.data);
    return res.data.choices[0].message.content;
  } catch (err) {
    console.error("LLM error:", err);
    return "Error calling LLM.";
  }
}

完整的代码,包含用命令行做一个简单的交互界面:

// src/index.js
import readline from "readline";
import axios from "axios";
import { createClient } from "./client.js";
import { DEEPSEEK_API_KEY } from "./config.js";

async function askLLM(prompt) {
  try {
    console.log("Calling LLM with prompt:", prompt);
    const res = await axios.post(
      "https://api.deepseek.com/chat/completions",
      {
        model: "deepseek-chat",
        messages: [{ role: "user", content: prompt }],
        max_tokens: 2048,
        stream: false,
        temperature: 0.7,
      },
      {
        headers: {
          Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
          "Content-Type": "application/json",
        },
        timeout: 1000000,
      }
    );
    return res.data.choices[0].message.content;
  } catch (err) {
    console.error("LLM error:", err);
    return "Error calling LLM.";
  }
}

async function getContext(client, question) {
  let currentTime = "";
  let additionalContext = "";

  try {
    const resources = await client.readResource(
      { uri: "time://current" },
      { timeout: 15000 }
    ); // 增加超时时间
    currentTime = resources.contents[0]?.text || new Date().toLocaleString(); // 注意:resources 直接包含 contents
  } catch (err) {
    console.error("Resource read error:", err);
    currentTime = new Date().toLocaleString();
  }

  if (question.toLowerCase().includes("公理")) {
    try {
      // const result = await client.getPrompt({
      //   name: "search_local_database",
      //   arguments: { query: question },
      // });
      
      const toolResult = await client.callTool({
        name: "search_local_database",
        arguments: { query: question },
      });
      console.log("Tool result:", toolResult);
      additionalContext = toolResult?.content?.[0]?.text || "No results found.";
    } catch (err) {
      console.error("Tool call error:", err);
      additionalContext = "Error searching database.";
    }
  }

  return { currentTime, additionalContext };
}

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const client = await createClient();

while (true) {
  const question = await new Promise((resolve) =>
    rl.question("You: ", resolve)
  );
  if (question.toLowerCase() === "exit") {
    console.log("Exiting...");
    rl.close();
    process.exit(0);
  }
  // 使用上下文
  const context = await getContext(client, question);
  // 不使用上下文
  // const context = {};
  const prompt = `Time: ${context.currentTime}\nContext: ${context.additionalContext}\nQ: ${question}\nA:`;
  console.log("Prompt:", prompt);
  const answer = await askLLM(prompt);
  console.log('Assistant:', answer);
}

接着在终端运行:

# 启动服务器
node src/server.js
# 启动客户端
node src/index.js

运行结果:

未命中关键词

命中了关键词

一些注意点

这个项目虽然小,但也踩了些坑,顺便分享几点:

  • MCP SDK 的 server 和 client 都是异步启动的,别忘了加上 await connect()
  • 工具的入参和 schema 必须严格匹配,否则会抛错。

下面是我的目录结构,做个参考吧!

mcp-mini/
├── package.json
├── src/
│   ├── client.js
│   ├── server.js
│   └── index.js

最后

总的来说,MCP TypeScript SDK 用起来还是挺顺的,适合做一些轻量、模块化、支持上下文的 AI 应用。这种服务 + 客户端 + LLM 的组合模式挺适合本地测试,也方便后续扩展别的服务。

今天的分享就到这了,如果文章中有啥错误,欢迎指正!

往期精彩推荐

谷歌浏览器插件 录制元素拖动事件

背景

最近在开发谷歌浏览器录制插件的过程中,发现无法监听 vue2 项目的元素拖动事件,拖拽组件使用的是 vue2 的vue-smooth-dnd 包。

正常未使用第三方拖拽库的元素已经可以监听到其拖拽事件,使用了上述第三方库的时候,元素拖拽事件无法监听。

解决思路

一番研究后发现vue-smooth-dnd 使用鼠标事件模拟拖拽,而非 HTML5 原生拖拽事件。因此,需监听鼠标事件或库的自定义事件。

vue-smooth-dnd 库会触发以下关键事件:

  • @drag-start: 拖拽开始时触发
  • @drag-end: 拖拽结束时触发
  • @drop: 元素放置时触发

在尝试监听 drag 等事件发现无效后,无法捕获自定义事件,采取监听 鼠标事件来处理拖拽行为。

// content-script.js
let isDragging = false;
let startX, startY;

document.addEventListener('mousedown', (e) => {
  if (e.target.closest('.smooth-dnd-container')) { // 检查目标元素
    isDragging = true;
    [startX, startY] = [e.clientX, e.clientY];
  }
});

document.addEventListener('mousemove', (e) => {
  if (isDragging) {
    const dx = e.clientX - startX;
    const dy = e.clientY - startY;
    // 如果移动超过阈值,判定为拖拽中
    if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
      console.log('拖拽中...', { dx, dy });
    }
  }
});

document.addEventListener('mouseup', () => {
  if (isDragging) {
    console.log('拖拽结束');
    isDragging = false;
  }
});

注意事项

  • 性能优化:避免在 mousemove 中执行高频率操作,可添加防抖。
  • 选择器适配:根据实际项目调整 .smooth-dnd-container 选择器。此处需要注意,不同拖拽库对应的 css 选择器可能不同,需要注意,处理对应兼容。

通过以上方法,插件应能正确录制 vue-smooth-dnd 的拖拽行为。

真实双百解法:排序O(nlogn)+双指针O(n) Python || C++

语言:Python3
提交时间:2023.02.15 20:27
时间:200 ms,击败97.7%
内存:26.3 MB,击败98.11%

解题思路

排序:不影响数目
原问题等价于<=upper的数对数目减去<=lower - 1的数对数目
left向右移动,统计每次nums[left] + nums[i] <= k的数目,总和即为res
right移至 nums[left] + nums[right] <= k 处,那么下标left + 1right的数 + nums[left]都是 <= k 的
易知left向右移后,满足条件的right需要左移或不动

以下代码易懂~

代码

class Solution:
    def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:
        n = len(nums)
        nums.sort()

        def my_count(k):  # 统计 <= k 的数对数目,原问题等价于 <=upper 的数对数目减去 <=lower - 1 的数对数目
            res = 0
            left, right = 0, n - 1
            while left < right:
                t = nums[left] + nums[right]
                if t <= k:  # 达到统计条件
                    res += right - left  
                  # 如果 nums[left] + nums[right] <= k, 那么下标从left + 1到right的数 + nums[left]都是 <= k 的
                    left += 1
                  # 继续统计 nums[left + 1] + nums[i] <= k 的数对
                else: # right指针左移,使t减小
                    right -= 1
            return res

        return my_count(upper) - my_count(lower - 1)
class Solution {
    // 统计 <= k 的数对数目,原问题等价于 <=upper 的数对数目减去 <=lower - 1 的数对数目
    long long my_count(vector<int>& nums, int k) {
        int left = 0, right = nums.size() - 1;
        long long res = 0;
        while (left < right) {
            int t = nums[left] + nums[right];
            if (t <= k) {  // 达到统计条件
                res += right - left;
            // 如果 nums[left] + nums[right] <= k, 那么下标从left + 1到right的数 + nums[left]都是 <= k 的
                left++;
            //  继续统计 nums[left + 1] + nums[i] <= k 的数对
            }
            else 
                right--;  // right指针左移,使t减小
        }
        return res;
    }
public:
    long long countFairPairs(vector<int>& nums, int lower, int upper) {
        sort(nums.begin(), nums.end());
        return my_count(nums, upper) - my_count(nums, lower - 1);
    }
};
class Solution:
    def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:
        n = len(nums)
        nums.sort()

        def my_count(k):
            res = 0
            left, right = 0, n - 1
            while left < right:
                t = nums[left] + nums[right]
                if t <= k:
                    res += right - left  
                    left += 1
                else:
                    right -= 1
            return res

        return my_count(upper) - my_count(lower - 1)
class Solution {
    long long my_count(vector<int>& nums, int k) {
        int left = 0, right = nums.size() - 1;
        long long res = 0;
        while (left < right) {
            int t = nums[left] + nums[right];
            if (t <= k) {
                res += right - left;
                left++;
            }
            else 
                right--;
        }
        return res;
    }
public:
    long long countFairPairs(vector<int>& nums, int lower, int upper) {
        sort(nums.begin(), nums.end());
        return my_count(nums, upper) - my_count(nums, lower - 1);
    }
};

题外话:为什么有人说用双指针超时了? 答:

“双指针”O(n)需要保证的是,left和right指针只往1个方向移动,两种情况:

1、同向双指针,假设方向向右,right往右移,left跟在right后面,相当于O(2n),“滑动窗口”那些题就是这个类型。
2、反向双指针,0和n-1移动至相遇。

如果某个指针,既会向左又会向右移,或者反复“移动后又移回原位”,那么最坏情况就是O(n²),本质上还是两层for循环的暴力,做的优化相当于“剪枝”。

本题因为需要同时兼顾>=lower<=upper,所以导致某个指针有时需要移动回去。这个时候我们把上下界问题等价于:<=upper的情况减去<=lower-1的情况 就可以了。

C++,排序,双指针

对于满足条件区间[i,j]的问题,我们一般转换成满足区间x<i-1和x<=j,然后将两个区间求得的结果相减。


我们先将nums排序,然后参考两数之和的双指针的操作,寻找在符合要求的范围内的两个边界。边界的长度-1就是当前边界中符合要求的对数,然后从左右向中间减小边界,重复上述操作,直到左右指针相碰。


例如:nums是[1,2,3,4,5,6],上界为8,那么开始时left指针指向1,right指向6,此时nums[left]+nums[right]<8,那么这个范围内所有的数都符合要求。接着left指针右移,直到nums[left]=3时,nums[left]+nums[right]>8,就移动right指针。

class Solution {
    long long count(vector<int> &nums, int upper)
    {
        int n = nums.size(), left = 0, right = n - 1;
        long long res = 0;
        while (left < right)//最后只剩一个数不能成对
        {
            long long sum = nums[left] + nums[right];
            if (sum <= upper)//判断当前双指针之和是否大于上界
            {   //不是的话,此范围内所有数都满足要求,范围内的对数为right-left
                res += right - left;
                left++;
            }
            else//如果大于上界,则right指针左移,sum会随之减小
                right--;
        }
        return res;
    }
public:
    long long countFairPairs(vector<int>& nums, int lower, int upper) {
        int n=nums.size(),left=0,right=n-1;
        sort(nums.begin(),nums.end());
        long long small=count(nums,lower-1);
        long long big=count(nums,upper);
        return big-small;
    }
};

两种方法:二分查找 / 相向三指针(Python/Java/C++/C/Go/JS/Rust)

方法一:排序 + 二分查找

由于排序不影响答案,可以先(从小到大)排序,这样可以二分查找。

$\textit{nums}$ 是 $[1,2,3]$ 还是 $[3,2,1]$,算出来的答案都是一样的,本质上就是从 $\textit{nums}$ 中选两个数。

排序后,枚举 $\textit{nums}[j]$,那么 $\textit{nums}[i]$ 需要满足 $0\le i < j$ 以及

$$
\textit{lower} - \textit{nums}[j] \le \textit{nums}[i] \le \textit{upper} - \textit{nums}[j]
$$

计算 $\le \textit{upper} - \textit{nums}[j]$ 的元素个数,减去 $< \textit{lower} - \textit{nums}[j]$ 的元素个数,即为满足上式的元素个数。(联想一下前缀和)

由于 $\textit{nums}$ 是有序的,我们可以在 $[0,j-1]$ 中二分查找,原理见【基础算法精讲 04】

  • 找到 $> \textit{upper} - \textit{nums}[j]$ 的第一个数,设其下标为 $r$,那么下标在 $[0,r-1]$ 中的数都是 $\le \textit{upper} - \textit{nums}[j]$ 的,这有 $r$ 个。如果 $[0,j-1]$ 中没有找到这样的数,那么二分结果为 $j$。这意味着 $[0,j-1]$ 中的数都是 $\le \textit{upper} - \textit{nums}[j]$ 的,这有 $j$ 个。
  • 找到 $\ge \textit{lower} - \textit{nums}[j]$ 的第一个数,设其下标为 $l$,那么下标在 $[0,l-1]$ 中的数都是 $< \textit{lower} - \textit{nums}[j]$ 的,这有 $l$ 个。如果 $[0,j-1]$ 中没有找到这样的数,那么二分结果为 $j$。这意味着 $[0,j-1]$ 中的数都是 $< \textit{lower} - \textit{nums}[j]$ 的,这有 $j$ 个。
  • 满足 $\textit{lower} - \textit{nums}[j] \le \textit{nums}[i] \le \textit{upper} - \textit{nums}[j]$ 的 $\textit{nums}[i]$ 的个数为 $r-l$,加入答案。
class Solution:
    def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:
        nums.sort()
        ans = 0
        for j, x in enumerate(nums):
            # 注意要在 [0, j-1] 中二分,因为题目要求两个下标 i < j
            r = bisect_right(nums, upper - x, 0, j)
            l = bisect_left(nums, lower - x, 0, j)
            ans += r - l  
        return ans
class Solution {
    public long countFairPairs(int[] nums, int lower, int upper) {
        Arrays.sort(nums);
        long ans = 0;
        for (int j = 0; j < nums.length; j++) {
            // 注意要在 [0, j-1] 中二分,因为题目要求两个下标 i < j
            int r = lowerBound(nums, j, upper - nums[j] + 1);
            int l = lowerBound(nums, j, lower - nums[j]);
            ans += r - l;
        }
        return ans;
    }

    // 原理请看 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, int right, int target) {
        int left = -1;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}
class Solution {
public:
    long long countFairPairs(vector<int>& nums, int lower, int upper) {
        ranges::sort(nums);
        long long ans = 0;
        for (int j = 0; j < nums.size(); j++) {
            // 注意要在 [0, j-1] 中二分,因为题目要求两个下标 i < j
            auto r = upper_bound(nums.begin(), nums.begin() + j, upper - nums[j]);
            auto l = lower_bound(nums.begin(), nums.begin() + j, lower - nums[j]);
            ans += r - l;
        }
        return ans;
    }
};
int cmp(const void* a, const void* b) {
    return *(int*)a - *(int*)b;
}

// 原理请看 https://www.bilibili.com/video/BV1AP41137w7/
int lowerBound(int* nums, int right, int target) {
    int left = -1;
    while (left + 1 < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] >= target) {
            right = mid;
        } else {
            left = mid;
        }
    }
    return right;
}

long long countFairPairs(int* nums, int numsSize, int lower, int upper) {
    qsort(nums, numsSize, sizeof(int), cmp);
    long long ans = 0;
    for (int j = 0; j < numsSize; j++) {
        // 注意要在 [0, j-1] 中二分,因为题目要求两个下标 i < j
        int r = lowerBound(nums, j, upper - nums[j] + 1);
        int l = lowerBound(nums, j, lower - nums[j]);
        ans += r - l;
    }
    return ans;
}
func countFairPairs(nums []int, lower, upper int) (ans int64) {
    slices.Sort(nums)
    for j, x := range nums {
        // 注意要在 [0, j-1] 中二分,因为题目要求两个下标 i < j
        r := sort.SearchInts(nums[:j], upper-x+1)
        l := sort.SearchInts(nums[:j], lower-x)
        ans += int64(r - l)
    }
    return
}
var countFairPairs = function(nums, lower, upper) {
    nums.sort((a, b) => a - b);
    let ans = 0;
    for (let j = 0; j < nums.length; j++) {
        // 注意要在 [0, j-1] 中二分,因为题目要求两个下标 i < j
        const r = lowerBound(nums, j, upper - nums[j] + 1);
        const l = lowerBound(nums, j, lower - nums[j]);
        ans += r - l;
    }
    return ans;
};

// 原理请看 https://www.bilibili.com/video/BV1AP41137w7/
var lowerBound = function(nums, right, target) {
    let left = -1;
    while (left + 1 < right) {
        const mid = Math.floor((left + right) / 2);
        if (nums[mid] >= target) {
            right = mid;
        } else {
            left = mid;
        }
    }
    return right;
};
impl Solution {
    pub fn count_fair_pairs(mut nums: Vec<i32>, lower: i32, upper: i32) -> i64 {
        nums.sort_unstable();
        let mut ans = 0;
        for j in 0..nums.len() {
            // 注意要在 [0, j-1] 中二分,因为题目要求两个下标 i < j
            let l = nums[..j].partition_point(|&x| x < lower - nums[j]);
            let r = nums[..j].partition_point(|&x| x <= upper - nums[j]);
            ans += r - l;
        }
        ans as _
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 为 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。忽略排序的栈开销。

方法二:排序 + 相向三指针

由于随着 $\textit{nums}[j]$ 的变大,$\textit{upper}-\textit{nums}[j]$ 和 $\textit{lower} - \textit{nums}[j]$ 都在变小,有单调性,可以用相向三指针 $j,l,r$ 代替方法一中的二分查找:

  1. 初始化 $l=r=n$。
  2. 从左到右遍历(排序后的)$\textit{nums}$。
  3. 找 $> \textit{upper} - \textit{nums}[j]$ 的第一个数:如果 $\textit{nums}[r-1] > \textit{upper}-\textit{nums}[j]$,说明 $r$ 太大了,可以继续减小。循环结束后的 $r$,与 $j$ 取最小值后,就是方法一的二分查找计算出的 $r$。
  4. 找 $\ge \textit{lower} - \textit{nums}[j]$ 的第一个数:如果 $\textit{nums}[l-1] \ge \textit{lower}-\textit{nums}[j]$,说明 $l$ 太大了,可以继续减小。循环结束后的 $l$,与 $j$ 取最小值后,就是方法一的二分查找计算出的 $l$。
class Solution:
    def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:
        nums.sort()
        ans = 0
        l = r = len(nums)
        for j, x in enumerate(nums):
            while r and nums[r - 1] > upper - x:
                r -= 1
            while l and nums[l - 1] >= lower - x:
                l -= 1
            # 在方法一中,二分的结果必须 <= j,方法二同理
            ans += min(r, j) - min(l, j)
        return ans
class Solution {
    public long countFairPairs(int[] nums, int lower, int upper) {
        Arrays.sort(nums);
        long ans = 0;
        int l = nums.length;
        int r = nums.length;
        for (int j = 0; j < nums.length; j++) {
            while (r > 0 && nums[r - 1] > upper - nums[j]) {
                r--;
            }
            while (l > 0 && nums[l - 1] >= lower - nums[j]) {
                l--;
            }
            // 在方法一中,二分的结果必须 <= j,方法二同理
            ans += Math.min(r, j) - Math.min(l, j);
        }
        return ans;
    }
}
class Solution {
public:
    long long countFairPairs(vector<int>& nums, int lower, int upper) {
        ranges::sort(nums);
        long long ans = 0;
        int l = nums.size(), r = l;
        for (int j = 0; j < nums.size(); j++) {
            while (r && nums[r - 1] > upper - nums[j]) {
                r--;
            }
            while (l && nums[l - 1] >= lower - nums[j]) {
                l--;
            }
            // 在方法一中,二分的结果必须 <= j,方法二同理
            ans += min(r, j) - min(l, j);
        }
        return ans;
    }
};
#define MIN(a, b) ((b) < (a) ? (b) : (a))

int cmp(const void* a, const void* b) {
    return *(int*)a - *(int*)b;
}

long long countFairPairs(int* nums, int numsSize, int lower, int upper) {
    qsort(nums, numsSize, sizeof(int), cmp);
    long long ans = 0;
    int l = numsSize, r = numsSize;
    for (int j = 0; j < numsSize; j++) {
        while (r && nums[r - 1] > upper - nums[j]) {
            r--;
        }
        while (l && nums[l - 1] >= lower - nums[j]) {
            l--;
        }
        // 在方法一中,二分的结果必须 <= j,方法二同理
        ans += MIN(r, j) - MIN(l, j);
    }
    return ans;
}
func countFairPairs(nums []int, lower, upper int) (ans int64) {
    slices.Sort(nums)
    l, r := len(nums), len(nums)
    for j, x := range nums {
        for r > 0 && nums[r-1] > upper-x {
            r--
        }
        for l > 0 && nums[l-1] >= lower-x {
            l--
        }
        // 在方法一中,二分的结果必须 <= j,方法二同理
        ans += int64(min(r, j)-min(l, j))
    }
    return
}
var countFairPairs = function(nums, lower, upper) {
    nums.sort((a, b) => a - b);
    let ans = 0, l = nums.length, r = nums.length;
    for (let j = 0; j < nums.length; j++) {
        while (r && nums[r - 1] > upper - nums[j]) {
            r--;
        }
        while (l && nums[l - 1] >= lower - nums[j]) {
            l--;
        }
        // 在方法一中,二分的结果必须 <= j,方法二同理
        ans += Math.min(r, j) - Math.min(l, j);
    }
    return ans;
};
impl Solution {
    pub fn count_fair_pairs(mut nums: Vec<i32>, lower: i32, upper: i32) -> i64 {
        nums.sort_unstable();
        let mut ans = 0;
        let mut l = nums.len();
        let mut r = nums.len();
        for (j, &x) in nums.iter().enumerate() {
            while r > 0 && nums[r - 1] > upper - x {
                r -= 1;
            }
            while l > 0 && nums[l - 1] >= lower - x {
                l -= 1;
            }
            // 在方法一中,二分的结果必须 <= j,方法二同理
            ans += r.min(j) - l.min(j);
        }
        ans as _
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 为 $\textit{nums}$ 的长度。瓶颈在排序上。
  • 空间复杂度:$\mathcal{O}(1)$。忽略排序的栈开销。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/最短路/最小生成树/二分图/基环树/欧拉路径)
  7. 动态规划(入门/背包/状态机/划分/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

Spine动画教程:皮肤制作

一、前言

搁了很久的抖音直播小玩法开发,最近又让我想起来了。由于是初次尝试,所以我将开发费用的预算降到为零。不但不买服务器采用UnitySDK的指令直推,而且游戏的资产也用AI生成,主打省时又省钱。

但是图片有了,动画还是得自己做,于是我又从零开始学了一下Spine动画制作。整个过程也是有很多值得记录下来的,比如制作皮肤的教程,网上还是比较少,问DeepSeek也不全,做完后导入Unity3D报错等问题。所以,跟着我这篇文章动手走一遍,还是能快速制作动画皮肤的。

二、工具

Spine 3.8.75

spine-unity-3.8-2021-11-10

三、皮肤制作

在动手制作前,可以先对照一下自己的Spine版本,和Unity3D的Spine插件版本,后面我会说一个很有意思的导入问题,那下面就开始如何制作皮肤了。

1.简单的图片皮肤示例

1.1.画面创建

导入两张形状一致,但纹理不同的图片,然后两张图全部绑定在一个骨骼下,层级结构参考下图。

image-20250408165643830.png

1.2.添加皮肤

在层级树的皮肤中,点击创建两个皮肤,名字自定义。

image-20250408165839140.png

1.3.创建皮肤占位符

在层级树中选择一个皮肤,点击左边的 “眼睛(小圆点,表显示)”,然后选中某插槽下的一个附件(图片)。选择后再点击下面的 “新建” -> “皮肤占位符”。,名字自定。

image-20250408171257613.png

1.4.检查皮肤切换

通过点击皮肤左边的眼睛按钮,看看能不能展开皮肤占位符里的附件,因为给附件添加皮肤占位符后,就被折叠进占位符中了。

1.5.创建动画

进入动画模式后,点击两张图片上的骨骼,在摄影表中给该骨骼添加移动的关键帧,最后我们在播放时就可以切换皮肤了。

image-20250408171833203.png

2.简单的网格皮肤演示

装配和上面图片的皮肤示例一样,但是在绑定骨骼前,现对图片网格处理(蒙皮),然后绑骨骼,刷权重。

2.1.网格编辑

点击图片->勾选网格->编辑网格->新建,然后对图片的轮廓围起来(像PS里的钢笔一样操作)。

image-20250408172921833.png

2.2.添加骨骼

image-20250408171833203.png 选择自定义的根骨骼,按照预期的动画效果,添加几个骨骼。

image-20250408171257613.png

image-20250408165839140.png

2.3.给网格绑定骨骼

image-20250408172921833.png

选中网格,点击添加骨骼上方的 “权重” 按钮,弹出一个权重窗口,点击绑定,然后将下面添加的几个骨骼选中(按ctrl多选),最后再点一下绑定。

image-20250408171833203.png

2.4.添加皮肤

第二张图片重复前面的添加网格,但是不用再新建骨骼,和第一张共用,皮肤切换的作用就在这里了,多个皮肤共用一套骨骼动画。

就像我们玩游戏的时候,不管怎么换皮肤,英雄的招式是一样的,当然打击特效可能也拆分出了皮肤除外。

2.5.创建皮肤占位符

皮肤添加和占位符创建和前面的图片皮肤示例一样,只是这次是点击网格而不是附件了。

image-20250408175516573.png

四、导出与导入

导出是在Spine中导出能在Unity3D中使用的动画文件,这里要注意格式和添加方法,还有前面提到的导入游戏引擎中报错问题。

Spine导出

点击导出,选择 “JSON”,确定输出文件夹后,点击 “打包设置”,为了被Unity3D支持,图集扩展名要加上 “.txt”。

image-20250408191026555.png

导入Unity3D

从Spine导出后有三个文件,分别是atlas.txt的图集文件,json数据和png贴图。

放入一个文件夹后直接复制到Unity3D项目的Resources后,游戏引擎会再生成一个atlas和skeletonData的asset文件,以及一个mat文件。

进入编辑器中,只需要将SkeletonData.asset拖到场景中即可。

image-20250408192003300.png

导入运行报错

当我第一次导进去后就提示错误:Error reading skeleton JSON file for SkeletonData asset: skeleton_SkeletonData Unsupported skeleton data, please export with a newer version of Spine。

根据提示是导出的Spine文件版本太低,其实就是我安装的Spine-Unity插件和Spine3.8.75不匹配。一般的做法就是换新版Spine,或安装低版本Unity插件,但是我这次就对比了一下可以使用的spine动画json文件。

发现格式都差不多,唯一不同的只是skeleton下的spine版本号,原本的是3.8.75,当我改成3.8.99重新加载进去后竟然就可以了。这整得我也是很无语,就只是校验了一下版本号,我还以为是格式不对。

image-20250408192801332.png

五、写在后面

其实想要快速制作游戏动画,还有以一种更好的方法,那就是序列帧动画。首先将AI生成的图片,再输入指定动作的提示词,用图生视频。然后将视频拆成图片帧,最后只需要挑选一些抠图做成图片精灵就可以了。

虽然我一开始就是这样想的,但是了解到Spine优点后就放弃这种方式。第二个原因,通过学习Spine我可以可持续制作动画贴图,还能上传到平台赚取收益,以后有时间我也会不定时地更新Spine动画制作小案例。

浅玩一下 Mobile Use

上一篇文章简单调研了下利用 AI 进行 UI 自动化的技术,顺着这个思路,是不是可以给 AI 一个手机,让它帮我们做更多事情呢?搜索一下,发现已经有人开发了 mobile-use 这个项目,今天就来玩一下。

首先需要安装好 adb,并通过 USB 数据线连接安卓手机,打开开发者模式,并开启 USB 调试模式,在终端输入 adb devices,如果显示有设备,则说明安装成功。

然后,写个简单的 Demo 脚本:

const {mobileUse} = require('mobile-use')
const {createOpenAI} = require('@ai-sdk/openai')

const {readFile, access} = require('node:fs/promises')
const yargs = require('yargs')
const {hideBin} = require('yargs/helpers')

const openai = createOpenAI({
  baseURL: process.env.OPENAI_API_BASE_URL,
  apiKey: process.env.OPENAI_API_KEY,
})

const argv = yargs(hideBin(process.argv))
  .usage('Usage: $0 <command> [options]')
  .demandCommand(1, 'Please provide either a command string or a file path')
  .help().argv

async function run() {
  const input = argv._[0].toString()
  let task
  try {
    await access(input)
    task = (await readFile(input, 'utf-8')).trim()
  } catch {
    task = input
  }
  const response = await mobileUse({
    task,
    llm: openai('gpt-4o'),
  })
  return response.text
}

run()
  .catch((error) => console.error(error))
  .then((result) => console.log(result))

编写一个 task.txt 文件,即将手机笔记本中的内容复制到腾讯元宝中并提交:

1. open com.hihonor.notepad
2. Tap Taylor Swift
3. Get content of Taylor Swift as $content
4. open com.tencent.hunyuan.app.chat
5. Type $content
6. Click send

执行 node index.js task.txt

效果如下所示:

result.gif

我们还是通过请求日志来分析一下是怎么实现的。

第一次发送的请求如下所示,可以看到定义了 openApplistAppscomputer 三个 tool,这些 tool 最终都是通过 adb 来操作设备。

{
  "model": "gpt-4o",
  "temperature": 0,
  "messages": [
    {
      "role": "system",
      "content": "You are an experienced mobile automation engineer. \nYour job is to navigate an android device and perform actions to fullfil request of the user.\n\n<steps>\nIf the user asks to use a specific app in the request, open it before performing any other action.\nDo not take ui dump more than once per action. If you think you don't need to take ui dump, skip it. Use it sparingly.\n</steps>\n"
    },
    {
      "role": "user",
      "content": "1. open com.hihonor.notepad\n2. Tap Taylor Swift\n3. Get content of Taylor Swift as $content\n4. open com.tencent.hunyuan.app.chat\n5. Type $content\n6. Click send"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "openApp",
        "description": "Open an on on android device.",
        "parameters": {
          "type": "object",
          "properties": {
            "name": {
              "type": "string",
              "description": "package name of the app to open such as com.google.android.dialer"
            }
          },
          "required": ["name"],
          "additionalProperties": false
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "listApps",
        "description": "Use this to list packages.",
        "parameters": {
          "type": "object",
          "properties": {
            "name": {
              "type": "string",
              "description": "Name of the package to filter."
            }
          },
          "required": ["name"],
          "additionalProperties": false
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "computer",
        "description": "Mobile tool to perform actions on a mobile device.",
        "parameters": {
          "type": "object",
          "properties": {
            "action": {
              "type": "string",
              "enum": [
                "ui_dump",
                "tap",
                "swipe",
                "type",
                "press",
                "wait",
                "screenshot"
              ],
              "description": "ui_dump: Get UI elements you can interact with for the current screen.\ntap: Tap on the provided coordinate.\nswipe: Swipe from start_coordinate to end_coordinate.\ntype: Type in the box.\npress: Press mobile key or button.\nscreenshot: Take a screenshot of the current screen if UI dump is not helpful or where you need to see visuals. \n      "
            },
            "coordinate": {"type": "array", "items": {"type": "number"}},
            "start_coordinate": {"type": "array", "items": {"type": "number"}},
            "end_coordinate": {"type": "array", "items": {"type": "number"}},
            "text": {"type": "string"},
            "duration": {"type": "number"}
          },
          "required": ["action"],
          "additionalProperties": false
        }
      }
    }
  ],
  "tool_choice": "auto"
}

大模型返回 openApp 的 tool call,打开笔记本:

{
  "id": "chatcmpl-BN9A2Vtp0QnGxf9frNG8fMtk4tBHg",
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "logprobs": null,
      "message": {
        "content": null,
        "refusal": null,
        "role": "assistant",
        "annotations": [],
        "audio": null,
        "function_call": null,
        "tool_calls": [
          {
            "id": "call_ydC7cOmRppTsVEu0AneG3g6R",
            "function": {
              "arguments": "{\"name\":\"com.hihonor.notepad\"}",
              "name": "openApp"
            },
            "type": "function"
          }
        ]
      }
    }
  ],
  ...
}

第二次请求,将 openApp 以及执行结果追加到 messges 中给大模型:

{
  "model": "gpt-4o",
  "temperature": 0,
  "messages": [
    {
      "role": "system",
      "content": "You are an experienced mobile automation engineer. \nYour job is to navigate an android device and perform actions to fullfil request of the user.\n\n<steps>\nIf the user asks to use a specific app in the request, open it before performing any other action.\nDo not take ui dump more than once per action. If you think you don't need to take ui dump, skip it. Use it sparingly.\n</steps>\n"
    },
    {
      "role": "user",
      "content": "1. open com.hihonor.notepad\n2. Tap Taylor Swift\n3. Get content of Taylor Swift as $content\n4. open com.tencent.hunyuan.app.chat\n5. Type $content\n6. Click send"
    },
    {
      "role": "assistant",
      "content": "",
      "tool_calls": [
        {
          "id": "call_ydC7cOmRppTsVEu0AneG3g6R",
          "type": "function",
          "function": {
            "name": "openApp",
            "arguments": "{\"name\":\"com.hihonor.notepad\"}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_ydC7cOmRppTsVEu0AneG3g6R",
      "content": "\"Successfull opened com.hihonor.notepad\""
    }
  ],
  "tools": [
    ...
  ],
  "tool_choice": "auto"
}

大模型返回 computer 类型的 tool call,参数为 {"action": "ui_dump"},即获取页面的 UI 信息:

{
  "id": "chatcmpl-BN9A4Mrj0JZ8QeSZYsdlz7gOMiwhW",
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "logprobs": null,
      "message": {
        "content": null,
        "refusal": null,
        "role": "assistant",
        "annotations": [],
        "audio": null,
        "function_call": null,
        "tool_calls": [
          {
            "id": "call_2T37XmWjfQhItVmw4vQzkoom",
            "function": {
              "arguments": "{\"action\":\"ui_dump\"}",
              "name": "computer"
            },
            "type": "function"
          }
        ]
      }
    }
  ],
  ...
}

看一下 ui_dump 返回的结果,是一个 JSON 格式的数据结构,包含所有页面的元素的坐标、类型等信息。它是通过解析 uiautomator dump 的结果得到的。

{
  "model": "gpt-4o",
  "temperature": 0,
  "messages": [
    ...
    {
      "role": "assistant",
      "content": "",
      "tool_calls": [
        {
          "id": "call_2T37XmWjfQhItVmw4vQzkoom",
          "type": "function",
          "function": {
            "name": "computer",
            "arguments": "{\"action\":\"ui_dump\"}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_2T37XmWjfQhItVmw4vQzkoom",
      "content": "[{\"type\":\"text\",\"text\":\"{\\\"type\\\":\\\"view\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[0,0][1080,2316]\\\",\\\"children\\\":[{\\\"type\\\":\\\"view\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[0,84][1080,468]\\\",\\\"desc\\\":\\\"1 条笔记\\\",\\\"id\\\":\\\"collapsing_toolbar\\\",\\\"children\\\":[{\\\"type\\\":\\\"button\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[936,96][1044,240]\\\",\\\"desc\\\":\\\"更多选项\\\",\\\"id\\\":\\\"action_menu_more_button\\\"},{\\\"type\\\":\\\"view\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[72,245][564,366]\\\",\\\"children\\\":[{\\\"type\\\":\\\"text\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[72,245][432,366]\\\",\\\"text\\\":\\\"全部笔记\\\",\\\"id\\\":\\\"extend_appbar_title\\\"}]}]},{\\\"type\\\":\\\"list\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[0,468][1080,2316]\\\",\\\"id\\\":\\\"notes_listview\\\",\\\"children\\\":[{\\\"type\\\":\\\"view\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[0,636][1080,1012]\\\",\\\"id\\\":\\\"swipelayout\\\",\\\"children\\\":[{\\\"type\\\":\\\"text\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[72,666][996,731]\\\",\\\"text\\\":\\\"Taylor Swift\\\",\\\"id\\\":\\\"item_title\\\"},{\\\"type\\\":\\\"text\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[72,749][996,861]\\\",\\\"text\\\":\\\"本服务将笔记中输入的语音内容转换成文本,需联网,需读取设备唯一可识别信息,需收集、读取并传送笔记中输入的语音到服务器,其中语音内容会委托给第三方服务提供商,用于将语音转换成文本。点击“同意”,即可启用该服务。\\\",\\\"id\\\":\\\"item_content_hide\\\"},{\\\"type\\\":\\\"text\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[72,749][996,855]\\\",\\\"text\\\":\\\"We were both young when I first saw you.\\\",\\\"id\\\":\\\"item_content\\\"}]}]},{\\\"type\\\":\\\"view\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[984,468][1080,2316]\\\",\\\"id\\\":\\\"scroll_bar\\\"},{\\\"type\\\":\\\"view\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[0,468][1080,636]\\\",\\\"id\\\":\\\"real_search_view\\\",\\\"children\\\":[{\\\"type\\\":\\\"text\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[36,498][1044,606]\\\",\\\"text\\\":\\\"搜索笔记\\\",\\\"desc\\\":\\\"搜索查询\\\",\\\"id\\\":\\\"search_src_text\\\"}]},{\\\"type\\\":\\\"button\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[864,2016][1008,2160]\\\",\\\"desc\\\":\\\"新建\\\",\\\"id\\\":\\\"fab_add\\\"},{\\\"type\\\":\\\"text\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[0,0][0,0]\\\",\\\"text\\\":\\\"笔记\\\",\\\"id\\\":\\\"content\\\"},{\\\"type\\\":\\\"text\\\",\\\"clickable\\\":false,\\\"bounds\\\":\\\"[0,0][0,0]\\\",\\\"text\\\":\\\"待办\\\",\\\"id\\\":\\\"content\\\"}]}\"}]"
    }
  ],
  "tools": [
    ...
  ],
  "tool_choice": "auto"
}

然后 LLM 通过分析页面信息,得到 Taylor Swift 的点击坐标返回 computer 类型的 tool call,参数为 {"action": "tap", "coordinate":[72,666]}

{
  "id": "chatcmpl-BN9A8OmVB5R8o7cQ15spyO278XpJM",
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "logprobs": null,
      "message": {
        "content": null,
        "refusal": null,
        "role": "assistant",
        "annotations": [],
        "audio": null,
        "function_call": null,
        "tool_calls": [
          {
            "id": "call_KELEnVExZ9eBD7nmYxoVFrUk",
            "function": {
              "arguments": "{\"action\":\"tap\",\"coordinate\":[72,666]}",
              "name": "computer"
            },
            "type": "function"
          }
        ]
      }
    }
  ],
  ...
}

之后就是不停地重复类似的步骤了。

给大模型装配上手和脚以后,可以催生出很多应用场景,比如让大模型同时陪我的几个女朋友一起聊天,我就可以安心学习 AI 了,想想就开心。

Vue3.4之defineModel的用法

👀 defineModel是vue3.4中正式加入的API的,可以简化组件之间双向绑定的流程。

1.首先回顾一下基于v-modal实现的双向绑定

v-model是vue.js提供的一个语法糖,用于在表单元素和组件之间创建双向数据绑定,即当数据变属性化时,相应的表单元素也会自动更新。

1.1 v-model的写法

v-model的原理:v-bind绑定一个value属性,v-on指令给当前元素绑定事件

父组件:

<template>
  <Children v-model:count="countNumber"></Children>
</template>

<script setup lang="ts">
import Children from './children.vue'
import {ref} from 'vue'

const countNumber = ref(1) 
</script>

子组件:children

<template>
  <div>
    {{ props.count }}
    <button @click="updateCount">child</button>
  </div>
</template>

<script setup lang="ts">
const props = defineProps(["count"]);
const emits = defineEmits(['update:count'])

const updateCount = () =>{
  emits('update:count', props.count+1)
}
</script>
1.2 自定义事件写法

父组件:

<template>
  <Children :count="countNumber" @change="updateVal"></Children>
</template>

<script setup lang="ts">
import Children from './components/HelloWorld.vue'
import {ref} from 'vue'

const countNumber = ref(1) 
const updateVal = (item:number) => {
countNumber.value  = item
}
</script>

子组件:children

<template>
  <div>
    {{ count }}
    <button @click="updateCount">child</button>
  </div>
</template>

<script setup lang="ts">
const props = defineProps<{
  count: number 
}>()

const emits = defineEmits(['change'])

const updateCount = () =>{
  emits('change', props.count+1)
}
</script>
从vue2开始,vue就是单向数据流,在子组件中是不能直接修改props中的值,由子组件抛出一个事件,由父组件去监听这个事件,再去修改父组件中传递给props的变量。自定义事件的写法更显式,适合复杂逻辑的场景。- v-model写法更简洁,减少了样板代码。

2.defineModel实现数据双向绑定

这个宏可以用来声明一个双向绑定 prop,通过父组件的 v-model 来使用。在底层,这个宏声明了一个 model prop 和一个相应的值更新事件。如果第一个参数是一个字符串字面量,它将被用作 prop 名称;否则,prop 名称将默认为 "modelValue"。在这两种情况下,你都可以再传递一个额外的对象,它可以包含 prop 的选项和 model ref 的值转换选项。

父组件:

<template>
  <Children v-model:count="countNumber"></Children>
</template>

<script setup lang="ts">
import Children from './children.vue'
import {ref} from 'vue'

const countNumber = ref(1) 
</script>

子组件:

<template>
  <div>
    {{ count }}
    <button @click="updateCount">child</button>
  </div>
</template>

<script setup lang="ts">
const count = defineModel("count", {
  type: Number,
  default: 0
}) 

const updateCount = () =>{
  count.value++
}
</script>

2.1修饰符和转换器

为了获取 v-model 指令使用的修饰符,我们可以像这样解构 defineModel() 的返回值:

父组件:

<Children v-model:message.capitalize="word"/>

<script setup lang="ts">
import Children from './children.vue'
import {ref} from 'vue'

const word = ref('test')
</script>

子组件:

<div>Message: {{ message }}</div>
<input :value="message" @input="handleInput">
<script setup lang="ts">
const [message, modifiers] = defineModel('message', {
    type: String,
    default: 'test',
    set(value){
    if(modifiers.capitalize){
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
    }
})
const handleInput = (e) => {
  message.value = e.target.value
}

Foundation Agent:深度赋能AI4DATA

2025年5月17日,第76期DataFunSummit:AI Agent技术与应用峰会将在DataFun线上社区举办。Manus的爆火并非偶然,随着基础模型效果不断的提升,Agent作为大模型的超级应用备受全世界的关注。为了推动其技术和应用,本次峰会计划邀请40+位深耕AI Agent领域的专家学者同台分享交流,共同探讨技术进展和应用形式。

在5月17日下午的DataAgent论坛上,DeepWisdom 市场VP徐宗泽将出席此次会议,并带来主题分享:《Foundation Agent赋能:打造灵活对齐的AI4DATA实践与价值创造》

专家介绍:

DeepWisdom 合伙人之一,市场VP。佩斯大学硕士,河海大学(在读)博士;在DeepWisdom 负责包括电力、航空、制造等多个领域的 AI Agent 的应用落地;拥有数千万级AI Agent项目实践落地经验,MetaGPT团队核心成员。

演讲提纲

数据科学领域的行业痛点与挑战

Foundation Agent技术创新

灵活对齐的实现路径与方法论

AI4DATA行业落地案例分析

听众收益:

掌握Foundation Agent在数据科学领域的应用方法论,了解如何通过灵活对齐实现AI4DATA的价值最大化

获取跨行业AI4DATA应用的最佳实践与经验,包括电力、航空、金融等领域的实际落地案例

了解如何构建基于"原子化角色+经验池+领域知识"的多智能体团队,为企业数据科学能力建设提供可复制的实施路径

落地挑战和方案重点

领域知识融合难题:传统AI难以有效整合行业专业知识,导致数据分析应用场景受限。我们通过构建经验池系统,将行业专家经验与AI能力有机结合,实现了知识的高效复用与迭代优化,使AI4DATA能够真正理解并应用领域专业知识。

数据科学任务的动态适应挑战:数据分析任务通常涉及多个互相关联的步骤,需要动态调整中间数据和适应不断变化的任务依赖关系。我们采用动态层次规划方法,构建任务行动图以适应数据变化,使Foundation Agent能够自动解决各种复杂的数据科学问题,将平均调试次数从1.48降低到0.32,执行成本降低70%。

报名方式

大会议程

HarmonyOS - 组件内部状态管理装饰器:Local

前言

在鸿蒙平台,采用的是声明式 UI 来描述页面内容。所以就需要状态管理装饰器来修饰变量,这样 UI 组件才能在数据变化的时候自动去更新内容。

在状态管理装饰器的 V2 版本中,组件内部的数据状态管理是通过 Local 来实现的。

Local

如果在我们的开发场景中,有一个数据仅在组件内部使用,无需与其他组件交互,那么我们就可以用 @Local 去修饰该变量。

假设我们需要开发一个直播页面中的,页面中有一个可以点赞的按钮,有一个可以显示点赞数的文本。由于点赞数量这个数据仅在当前页面使用,无需跟其他页面进行交互,那么我们就可以使用 @Local 来实现。

具体代码实现如下:

@Entry
@ComponentV2
struct Index {
  @Local likeNum: number = 0;
  build() {
    Column() {
      Text(`点赞数:${this.likeNum}`)
      Button("点赞").onClick(() => {
        this.likeNum += 1
      })
    }
  }
}

效果图如下:

录屏2025-04-18 17.45.39.gif

通过上述代码可以看出,它的使用方式还是比较简单的。但在使用的过程中,我们有一些事项还是需要注意一下的。

注意事项

第一点就是 @Local 修饰的变量,只能进行本地初始化,不能从外部传递。这是比较好理解的,因为它的作用就是进行组件内部的状态管理。如果允许进行外部传递,那就破坏了组件内部的这个条件,违反了该装饰器的设计原则。

它修饰的变量必须进行本地化,否则会编译报错。比如下面的代码:

// 编译报错:Property 'likeNum' has no initializer and is not definitely assigned in the constructor. <ArkTSCheck>
@Local likeNum: number;

第二点需要注意的就是,如果它修饰的是 class 类型的变量,那么只能监测到对象赋值的变化,对于对象属性的变化是监测不到的。比如下面的代码:

class Live {
  likeNum: number = 0

  constructor(likeNum: number) {
    this.likeNum = likeNum;
  }
}

@Entry
@ComponentV2
struct Index {
  @Local like: Live = new Live(0);
  build() {
    Column() {
      Text(this.like.likeNum.toString())
      Button("点赞").onClick(() => {
        this.like.likeNum += 1
      })
    }
  }
}

首先,我们声明了一个 Live 的类将点赞数当其属性。然后定义了一个 Live 类型的变量 like,用 @Local 修饰。最后将 Text 与 like的 likeNum 属性进行绑定。

当我们点击点赞按钮的时候,就会发现虽然我们在按钮的点击事件中将 like 的 likeNum 进行了 +1 操作,但 Text 的内容并没有进行更新。

这种情况的原因就是 @Local 如果修饰的是类的实例对象,那么只能监测到赋值变化,不能监测到属性变化。赋值变化如下面的示例代码:

Button("点赞").onClick(() => {
  this.like = new Live(10)
})

这次,我们点击按钮可以发现,Text 组件的内容会变成 10。

与 @State 的对比

之前熟悉 HarmonyOS 的朋友应该知道 V1 版本的 @State,它是最基础的状态管理装饰器,起初的设计也是用于组件内的状态管理。但因为它修饰的变量可以从外部进行传入,破坏了它组件内的设计初衷。所以 V2 版本又推出了 @Local 装饰器,用来保证了组件内的设计原则。

总结

  • 使用场景:组件内部的状态管理,不能与外部组件交互;
  • 使用方式:必须本地初始化,否则编译报错;
  • 限制:装饰的 class 类型的变量,只能检测到变量的赋值变化,变量的属性变化是无法检测的;

pc 和大屏如何适配

适配能力

一、适配方案

  1. 移动端适配
  2. 大屏/PC 适配

二、移动端适配

  1. 适配前提:设计搞尺寸是 750 的;
  2. 适配规则:1rem = 100px;
  3. 移动端适配使用的是 netease-adapt.js; 直接在 main.js 全局引入即可;
    import "./netease-adapt";
    

三、大屏/PC 适配

大屏/PC 适配一般可分为三种情况:
  • 1、固定尺寸
  • 2、固定尺寸按照实际屏幕尺寸进行压缩或者拉伸
  • 3、固定比例尺寸适配(rem)
  • 4、使用postcss-pxtorem对px进行自动转换rem
无论哪种情况,页面宽高等单位最好使用百分比,若以后项目情况改变可尽可能少的改动。
  1. 固定尺寸:一般在页面的最外层设置页面的宽高即可,例如项目尺寸是 1920*1080

    body {
      width: 1920px;
      height: 1080px;
    }
    
  2. 固定尺寸按照实际屏幕尺寸进行压缩或者拉伸:在 PC 端,虽然很多设备都是 16:9 的尺寸, 但是仍有 16:10、4:2.5 的设备,尽管都是 16:9 的屏幕,也会因为浏览器是否有显示地址栏、是否显示标签栏 而导致页面的可视窗口不一致的问题,所以有此适配方案,但此可能导致页面出现压缩或拉伸。 具体可按以下流程引入

    1. 最外层盒子的宽高使用百分比铺满页面,页面内元素宽高等尽可能使用百分比,字体、间距可按照设计图尺寸写 px
      body {
        width: 100%;
        height: 100%;
      }
      
    2. App.vue 添加如下代码
      methods: {
       resizeWindow() {
         //页面实际的尺寸
         let pageWidth = 1920, pageHeight = 1080;
         // 因为设计图是带1920*1080的,但是浏览器本身带顶部工具栏,所以缩放到时候稍微更小一点,这样不会有滚动条,这个值可以选择更大些,比如2300,这样左右两边会有空白
         const myWidth = document.documentElement.clientWidth;
         const myHeight = document.documentElement.clientHeight;
         console.log(myWidth, myHeight);
         console.log(myHeight / pageHeight, myWidth / pageWidth);
         document.getElementById("app").style.transform = `scaleX(${
           myWidth / pageWidth
         }) scaleY(${myHeight / pageHeight})`;
         document.getElementById("app").style.transformOrigin = "left top";
       },
       created() {
         this.resizeWindow();
         //页面尺寸变化时重新修改
         window.onresize = () => {
           this.resizeWindow();
         };
      
  3. 固定比例尺寸适配(rem):此方案类似移动端,利用屏幕宽度变化动态改变 html 的字体大小, 注意:rem 尽量用来设置字体大小,尽量不要用来设置高度,引入如下

    1. 最外层盒子的宽高使用百分比铺满页面,页面内元素宽高等尽可能使用百分比;
      body {
        width: 100%;
        height: 100%;
      }
      
      1. App.vue 添加如下代码
       methods: {
         resetSizeRem() {
           let designSize = 1920; // 设计图尺寸
           let html = document.documentElement;
           let wW = html.clientWidth; // 窗口宽度
           let rem = wW / designSize;
           document.documentElement.style.fontSize = rem + "px";
         },
       },
       created() {
         this.resetSizeRem();
         //页面尺寸变化时重新修改
         window.onresize = () => {
           this.resetSizeRem();
         };
      

4.postcss-pxtorem 配置流程

  • 安装依赖 npm install postcss postcss-pxtorem --save-dev
  • 加入 根目录加入 postcss.config.js
  • ui输出以750的设计稿,所以rootValue一般以75作为基准,但是注意一些ui框架是以375作为基准,可以参考示例js进行自定义修改
  • exclude为过滤转换的目录,一般只过滤node_modules,若项目有特殊需求,可自行修改。

记一次Web Worker的使用

需求背景

当渲染大量卡片时,需要从卡片中的图片提取主色调,并据此计算合适的文本颜色。这一过程涉及大量计算,容易造成主线程阻塞,影响页面流畅度,尤其在处理多张图片或高分辨率图片时,存在性能问题。

优化方案

为了提升计算效率,采用 Web Worker 将颜色计算逻辑移至独立线程,利用浏览器的多线程能力,实现主色调提取与文本颜色计算的异步处理。从而避免主线程阻塞,提升页面性能。

主要逻辑

  1. 使用web worker进行图片取色&文本色计算
  2. 共用一个worker
  3. 抽离第三方库的代码到worker中

首先讲一下为什么需要将第三方库的代码进行抽离,例如取图片主色调的第三方库 color-thief

这段代码不能在worker中执行,原因如下:

Worker 不能访问 DOM Web Worker 运行在独立线程中,无法访问 documentwindowcanvas 等 DOM API,因此 document.createElement('canvas') 无法在 Worker 中执行。

所以我们不能直接在worker中操作dom,如果要在Worker 中处理图片颜色数据,可以使用 OffscreenCanvas,它允许在 Web Worker 线程中创建和操作 Canvas。那么就有两种方案:

方案 方案 1:Worker 直接 fetch 加载图片 方案 2:主线程加载图片,传数据给 Worker
加载方式 Worker 内部 fetch(src) 下载图片 主线程 new Image().src = src
缓存机制 无法使用浏览器 Disk Cache,每次都可能重新下载 能利用浏览器缓存,避免重复下载
CORS 影响 可能遇到 CORS 限制,需要手动处理跨域 主线程加载 crossOrigin="anonymous" 解决 CORS 问题
图片解码 需要 fetch → Blob → createImageBitmap 额外转换 直接 Image 解码,更高效
Worker 计算负担 Worker 需 下载 + 解码 + 颜色计算,任务重 Worker 仅计算颜色,更轻量
主线程占用 低(Worker 负责所有操作) 需要主线程加载 Image 和 Canvas
性能 解码 & 计算全在 Worker,但下载可能慢 加载快、缓存友好,Worker 专注计算,整体更快
// 方案一worker.js
self.onmessage = async (e) => {
  const imageUrl = e.data;

  const res = await fetch(imageUrl); // Worker 自己下载
  const blob = await res.blob();
  const bitmap = await createImageBitmap(blob); // 解码为图像

  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0);

  const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
  // 👉 在这里做颜色计算

  self.postMessage({ message: '颜色计算完了' });
};

因为我们最好是能够利用浏览器的缓存,而且需要避免跨域,最好采用方案二去实现:

在主线程中,我们加载图片,获取图片数据,通过postMessage传递给worker

const img = new Image();
    img.src = src;
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      const canvas = new OffscreenCanvas(img.width, img.height);
      const ctx = canvas.getContext('2d')!;
      ctx.drawImage(img, 0, 0);
      const imageData = ctx.getImageData(0, 0, img.width, img.height);
      const buffer = imageData.data.buffer;
      this.instance.postMessage(
        {
          key: src,
          buffer,
          src,
          type: COLOR_TYPE.BOTH,
          width: imageData.width,
          height: imageData.height,
        },
        [buffer],
      );

这里为什么要通过第二个参数传递buffer数据呢?

postMessage(aMessage, transferList) 是主线程与 Web Worker 之间通信的方法:

  • aMessage 是要发送的数据,默认通过结构化拷贝(深拷贝) 的方式传递,发送的是副本,发送后双方可以独立修改;
  • transferList 是一个可选的“可转移对象”数组(如 ArrayBufferMessagePortImageBitmap 等),用于转移对象的所有权;被转移的对象不会被复制,而是直接从主线程“搬”到 Worker,发送后原上下文将无法再使用该对象,性能更高效;

在worker中拿到图片数据,对图片进行图片提取主色调,并据此计算合适的文本颜色。

我在worker中主要是把这段代码通过blob的形式实现。

在主线程只需要new Worker(workerScript)即可。

目前使用后存在一些问题,如果有人知道怎么解决,强烈欢迎来帮助我解决一下🙏🙏🙏

遇到的问题

  1. web worker不支持导入的时候使用变量,这样会导致报错

webpack和rspack 均不支持在worker中使用变量

2、在worker中通过import引入其他文件,在打包到测试环境上后,会报安全问题,目前不知道咋解决.

这样就很烦,解析图片主色调和计算对比度的算法就是从第三方库的代码里扒出来的,然后这样就导致一个文件里面巨多代码

本人采取过的尝试

  1. 参考了rspack的例子,在本地通过import 引入没有问题,但是打包测试环境就不行了

github.com/rspack-cont…

  1. 通过importScript导入,ts会直接报错==>在tsconfig.json引入一下可以解决报错。但是没用,importScript不能加载模块化的文件。导入的文件不能通过export导出

既然都把别人的第三方库代码扒下来了,取色和对比度算法也不是很复杂,接下来我们就来探究下这两个方法的原理吧,worker就先说到这。

参考文档:

www.freecodecamp.org/chinese/new…

github.com/puxiao/note…

rust 全栈应用框架dioxus

逛github时发现了一个号称全栈应用框架dioxus,适用于web / desktop / mobile。零配置、集成了热启动和基于信号的状态管理。是由rust编写的,所以也就不受平台限制。

既然说的这么好,那就来试试构建一下三种平台的应用,构建的应用编译成webdesktopmobile三个平台的应用。

dioxus.png

与其他跨平台框架对比

这里列出几个大家熟知的平台,这些差异可以dioxus官网看到。

dioxus的定位:

  • 使用组件、属性、钩子构建UI视图,状态管理更像Svelte
  • 页面编写保持和HTML、CSS一致
  • 可以任意切换适配目标平台渲染器进行渲染。
  • 有很多功能库持续建设,和dioxus保持同步更新。

对于使用dioxus进行开发,有以下几点有用的功能:

  • 热更新
  • 具有日志记录、项目模板、代码检查等交互式cli。
  • 集成构建部署不同平台,包括webmacosioswindows
  • 支持现代web开发方式,如SSR、混合开发、html流
  • 直接访问系统API,如JNI(android) 、CoreFoundation (Apple) 、web-sys(web)
  • 类型安全的应用程序路由和服务端功能。

VS Tauri

tauri 是基于web框架(如:react、vue、svelte等)的构建移动应用程序框架。

  • 限制了它只能使用js或webassembly;而dioxus可以让你使用线程、访问文件系统等操作,无需IPC桥接。
  • 限制了它只能作用于js;而dioxus可以提供其他能力:后台服务、打包工具、原生渲染器。
  • 它们也有共享的一些库,是由tauri维护的

VS Leptos

leptos 是一个rust框架,它使用webassembly来构建web应用程序。

  • 它使用信号系统驱动响应和渲染。而dioxus使用信号仅驱动响应。
  • 它专注web,提供了一些web平台的组件;而dioxus目标是全平台包括桌面、移动、web。
  • 它使用类似html的语法;而dioxus自定义了一套自己的DSL语法。

VS Yew

yew 是一个构建单应用web程序框架。它是dioxus的灵感来源,而dioxus为了支持更多的平台和功能。yew并不满足。

VS Electron

electron是使用js、html、css等web技术开发桌面应用程序的框架。

  • dioxus 使用原生的webview渲染,它们的构建产物体积相差巨大。dioxus还可以和主机共享系统资源。
  • 相比electron已经很成熟了,而dioxus still quite young。

VS egui

egui 是一个跨平台的GUI库。

  • 它被设计为在每一帧上重新绘制,适用于游戏或其他交互式应用。而dioxus只会绘制一次,然后在每一帧上修改,这也使得dioxus可以使用web原生技术。
  • 它提供了自己的样式和布局方案。而dioxus则可以让你使用适用于web的第三方样式库,比如tailwindcssmaterial-ui
  • 它的状态管理是基于全局单一对象的。而dioxus鼓励使用组件和props传递状态。

通过这些比较呢,可以初步看到dioxus具有的优势。因为我还没有使用过svelte,对于这种基于信号的状态管理还是比较陌生。

构建Hello world

通过以上对比,我们可以看到想要学习使用dioxus,必须学习rust。如果没有学习过rust,那可能需要先去学习rust.

还有一个最重要的区别就是dioxus自己设计了一套DSL语法,类似html标签和css的语法,但可能在使用上还是有些不习惯。但是毕竟不破不立,想要跨平台、想要速度快,如果局限于现有某个技术,那么你也会有这个技术的瓶颈限制。

环境配置

  • 本地安装rust rust-lang.org/
  • 选择编辑工具,我这里选择了vscode,并安装了语法提示插件rust-analyzer
  • 安装cargo-binstall,使用它直接安装dx而无需从源码编译。
  • 安装dioxus-cli套件,包括两个部分:dioxus核心库和dx命令行工具。

系统环境配置,因为我是mac,所以无需配置,其他系统请查看Platform-specific dependencies;

初始化项目

dioxus提供了dx命令行工具,用于创建、编译、运行、调试项目。

新创建项目可以使用dx new <project-name>,因为我已经创建好了文件夹,所以需要初始化dx init

dioxus-init.png

初始化项目时可以选择项目模板,我们选择最简单的模板第一个就行。后续的设置路由、样式都先不需要,最后完成初始化。

dioxus-structure.png

和rust项目比较,多了一个Dioxus.toml用于配置dioxus项目。默认安装了dioxus-webdioxus-desktopdioxus-mobile,且默认平台为web。

编写我们的hello world

清空模板里的代码,输出Hello world!在页面上.

在主入口文件main.rs:

use dioxus::prelude::*;

fn main() {
    dioxus::launch(App);
}

#[component]
fn App() -> Element {
    rsx! {
        h1 { "Hello, world!" }
    }
}

我们这行dx serve 等待编译完成,第一次编译会慢一点,后续就会快很多。

dioxus-start.png

我们访问web服务地址http://127.0.0.1:8080

可以看到页面输出了Hello world!,可以继续我们的开发了 ✊

dioxus-web.png

可以看到web服务已经可以访问成功了,我们编译为桌面应用,使用dx serve --platform desktop

dioxus提供了开发桌面、移动应用,可以直接在系统上构建GUI页面展示,我们执行命令后,默认直接打开窗口。

dioxus-desktop.png

构建移动应用ios的app,对于移动端的开发,需要本地安装XCode 开发工具,并且需要选择下载ios资源包。

xcode-ios.png

本地rust需要安装目标编译资源

rustup target add aarch64-apple-ios aarch64-apple-ios-sim

所有环境准备好之后,我们启动xcode启动一个ios的模拟器。然后在dioxus项目中执行dx serve --platform ios,它会自动探测到模拟器并安装应用。

dioxus-ios.png

dioxus 核心人员都是全职在维护,感觉应该还是比较靠谱的。

目前版本最新0.6.3 发展过程中可能存在较大更新,但是值得关注。

基础知识

开始编写应用程序的最重要的就是掌握界面设计编码,作为前端开发人员,对于html必不陌生,对于dioxus,为了方便跨平台构建而不受已有语言的限制,自定义了一套自己的DSL语法。新的语法会带来一些陌生、不适以及潜在的问题,但也伴随无限的可能性。

组件

界面设计实现是由一个个组件组成的,组件可复用,可组合。在之前的例子中 App就是一个组件,它是一个函数,返回一个Element。使用rsx!{ }宏来定义UI结构。

#[component]
fn App() -> Element {
    rsx! {
        h1 { "Hello, world!" }
    }
}

我们还使用了#[component]它可以方便我们创建组件,用来简化组件接收到的数据结构定义。如果不使用,我们需要为组件定义参数结构体,并指定参数传递。

根组件App不能接受参数。


#[derive(Props, Clone, PartialEq)]
struct AppProps {
    name: String,
}

fn AppHeader(props: AppProps) -> Element {
    rsx! {
        h1 { "Hello, {props.name}!" }
    }
}

我们定义的结构体AppProps必须实现Props trait,并且支持ClonePartialEq trait。为了简化这一行为,方便创建组件,提供了#[component]derive简化这一过程,无需再定义结构体;还可以帮助我们检查组件定义的正确性。

#[component]
fn AppHeader(name: String) -> Element {
    rsx! {
        h1 { "Hello, {name}!" }
    }
}

通过#[component] 可以方便定义组件接收的参数。定义要接收的参数,无需再手动解构。

dioxus 是声明式框架,通过定义组件状态、属性来决定组件的的行为。

rsx!{ }

通过使用rsx!{ } 宏来定义UI结构,和html一样,我们可以使用比如div h1~h6 img等熟知的标签来构建页面结构。

rsx!{ } 使用rust的结构体定义元素结构,在声明标签结构时,采用的严格模式语法解析。这也在开发模式下提前暴露问题,从而避免运行时错误。

let handle_click = move |event| {
    info!("Button clicked!");
};

rsx! {
    div {
        class:"flex flex-col gap-15px",
        h2 { "Welcome to {name}" }
        button {
            onclick: handle_click,
            "Click me",
        }
     }
}

使用变量时,直接使用{ }包裹,rsx会解析使用format!()处理字符串。元素的事件绑定和react是一致的,通过on+事件名绑定事件处理函数。事件处理函数是一个闭包函数,接收事件句柄对象

条件/循环渲染

条件渲染可以通过rust的数据类型bool类型变量,或者Option<>类型的值来判断。

let show_title = true;

rsx! {
    {show_title.then(|| rsx!{ h2 { "Welcome to {name}" }})}
}

也可以通过if 语句进行判断

rsx! {
    if show_title {
        h2 { "Welcome to {name}" }
    }
}

对于循环渲染,可以使用迭代器.map 或者for .. in 进行渲染

rsx! {
    ul {
        {(0..3).map(|i| rsx!{ li {"index {i}"}})}
    }
    ul {
        for i in 0..3 {
             li {"index {i}"}
        }
    }
}

条件渲染和循环渲染,通常在业务中变量是动态的,比如通过点击事件更新变量值,而我们直接使用rust声明的变量值在修改时会存在所有权转移的问题,在上文中,点击事件的回调是一个闭包,想要在里面修改,并试图让视图更新,变量的所有权问题导致变得复杂。

在后面的章节 状态管理dioxus提供和视图绑定的hooks帮助我们管理视图状态,比如我们通过use_signal来控制状态,然后更新变量,从而重新渲染视图。

let mut show_title = use_signal(|| false);
let handle_click = move |event| {
    show_title.set(!show_title());
};

 rsx! {
    div {
        if show_title() {
            h2 { "Welcome to {name}" }
        }

        button {
            onclick: handle_click,
            "Click me",
        }
    }
 }

加载静态资源

dioxus 提供了assets!()来加载静态资源,比如如图片、css、js、json等。

在目录assets下定义css样式文件style.css,并导入到main.rs中。

static STYLE_CSS: Asset = asset!("/assets/css/style.css");

样式导入后需要将它和组件绑定在一起,通过document::Stylesheet {}引入,和html的link标签作用是一样的。

rsx! {
    document::Stylesheet {href:STYLE_CSS}
    // ...
}

加载图片也是同样的使用asset!,让后绑定到元素的属性上。

rsx! {
    img { src:BG_IMAGE, width:300,height:150}
}

通过/访问项目的根目录,不能直接将资源路径赋值给标签属性,这样无法加载到资源。

通过asset!加载资源还做了一些优化措施,我们也可以通过第二个参数设置来优化资源,比如通过将png转换为avif.

static BG_IMAGE: Asset = asset!(
    "/assets/imgs/dioxus.png",
    ImageAssetOptions::new().with_avif()
);

可以看到本来500多kb的png图片转换为avif后只有不到100kb了,提升了的图片的加载速度。

状态管理

rust中有所有权的概念,我们创建一个变量,在一个地方使用后,再在另一个地方使用会报错所有权被转移的错误。为了在其他地方使用我们可以借用变量,只使用它的值;或者调用.clone()来创建一个变量的副本。

dioxus提供了一个hookuse_hook函数处理这种情况,在每一次使用时,都会返回.clone()的值。

let name = use_hook(|| "hboot");

use_hook接受一个闭包函数,用于初始化渲染时创建变量。它不是响应式的,只能用来共享变量值,如果需要响应式变量,则需要使用use_signal

use_signal的设计来源于Svelte,dioxus的组件和react的组件一样,响应式数据发生变更时,就会重新渲染组件。创建的响应式变量可以通过.set()方法来更新值,

#[component]
fn AppFooter() -> Element {
    let mut count = use_signal(|| 0);
    rsx! {
        button {
            onclick: move |_| count+=1,
            "Clicked {count} times"
        }
    }
}

对于响应式数据,我们经常会遇到的父子组件共享数据,我们可以将use_signal创建的变量传递给子组件,而对于需要共享更多的组件,跨更深的层级,一直传递不好维护。

dioxus提供两种方式共享数据:ContextGlobalSignal

Context 局限于有上下级关系的组件,它提供了一个共享的上下文数据。通过use_context_provider创建数据对象,在子孙组件中通过使用use_context来获取消费数据。

我们在App组件中,创建一个Context对象,并在后续组件中使用。

#[component]
fn App() -> Element {
    use_context_provider(|| "Dioxus-app");
    rsx! {
        AppFooter {}
    }
}

我们提供了一个类型&str的变量值,在AppFooter来获取消费。可以看到我们在获取变量时只定了类型&str,这里是必须的,它帮助在上文环境中找到对应的Context,如果定义了的类型找不到,则会报错。

#[component]
fn AppFooter() -> Element {
    let app_name: &str = use_context();
    rsx! {
        footer {
            class:"bg-red-100 flex flex-col gap-15px",
            h2 { "Footer {app_name}" }
        }
    }
}

可以配合use_signal创建响应式变量,当变量发生更新时,所有依赖的组件都会重新渲染。

let app_name = use_signal(|| "Dioxus-app");
use_context_provider(|| app_name);

在获取消费时,它的类型变成了Signal<&str>

GlobalSignal 是一个全局共享的信号,它允许多个组件共享一个变量,并且当变量发生变化时,所有依赖它的组件都会重新渲染。通过Signal::global创建一个全局响应式变量,可以通过.write()方法来更新变量的值。

static APP_NAME: GlobalSignal<&str> = Signal::global(|| "Dioxus-app");

在更新变量时,通过.write() 获取可变引用,然后*解引用指向值存储地址修改值。

*APP_NAME.write() = "Rust";

除了这些本地同步执行的变量初始化,还有异步请求获取的数据,因为是异步的,它会在一段时间后初始化完毕,需要我们记录状态,dioxus提供了use_resource函数来帮助我们管理异步数据状态,它提供了开始、暂停、停止等方法来控制。

use_resource(|| async move {
    // 异步请求
});

Hooks

dioxus 借鉴了react的hooks概念,提供了一些Hook,用于管理组件状态。在上一章节中使用了几个常用的hook,当然还有一些hook供我们使用

Hook使用规则

dioxusHook使用规则同react的hooks限制,只能在组件body中使用,不能在函数或其它语句块中使用。约定俗成使用use_自定义hook。

  • 不能在条件语句中使用
  • 不能循环语句中使用
  • 不能在闭包中使用

响应式变量不能直接在组件体内修改,可以在副作用hook中修改,比如use_effect,它会在组件初始渲染时执行一次;且会收集其中使用到的响应式变量,当变量发生变化时,组件重新渲染,也会再次执行它。

let mut count = use_signal(|| 0);

// 不要直接修改
count+=1;

// 在副作用hook中修改
use_effect(move || {
    count+=1;
})

use_hook

use_hook 不是一个响应式声明变量的hook,它提供创建一个值,以便我们可以在组件内部使用它。解决了rust变量的所有权限制。

let count = use_hook(|| 0);

use_signal

use_signal 是一个响应式声明变量的hook,组件会跟踪值的变化,当值变化时,组件会重新渲染。

let mut count = use_signal(||0);

use_effect(move || {
    count+=1;
})

当声明了一个Signal变量后,提供一些方法在不同的情况下来操作这个变量。

// 获取到变量的一个克隆值
let value:i32 = count();

// 获取变量值的引用
let value:&i32 = &count.read();

// 设置变量的值
count.set(10);

// 获取内部值的一个可变引用,然后解引用并修改值
let value:&mut i32 = &mut count.write();
*value = 10;

use_effect

use_effect 是一个副作用可执行的hook函数,在组件初始渲染时执行一次;并且会收集在其内部使用到的响应式变量,当它们发生变化时,组件重新渲染,也会再次执行它。

use_resource

use_resource 是一个执行异步任务的hook函数。

use_memo

use_memo 是一个存储缓存值的hook,由其它变量计算一个可被跟踪的计算值。依赖的响应式变量发生更改时,计算一个新值,判断是否和之前的值相同。

let dobule_count = use_memo(move || count*2);

更新值和旧值相同时,则不会更新组件。

use_reactive

use_reactive 可以将一个原始数据值转为响应式变量,并跟踪原始值的变化。

可用于父子组件传递数据时,父组件传递了一个响应式变量的值引用,子组件通过use_memo使用了未被跟踪的值,即使值发生变化,use_memo的值也不会更新。

#[component]
fn Child(count: i32) -> Element {
    let double_count = use_memo(move || count * 2);
    rsx! {
        div {
            "Count: {double_count}"
        }
    }
}

为了处理这种情况,通过use_reactive转换为可跟踪的响应式变量。

#[component]
fn Child(count: i32) -> Element {
    let double_count = use_memo(use_reactive(&count, |val| val * 2));
    rsx! {
        div {
            "Count: {double_count}"
        }
    }
}

提供了一个use_reactive!简化书写依赖,可从闭包函数中获取需要依赖跟踪的值。依赖多个变量时,可使用元组()传递。

#[component]
fn Child(count: i32) -> Element {
    // let double_count = use_memo(use_reactive(&count, |val| val * 2));
    let double_count = use_memo(use_reactive!(|count| count * 2));
    rsx! {
        div {
            "Count: {double_count}"
        }
    }
}

use_context_provider

use_context_provider是一个用于创建一个上下文数据的hook,它允许在组件树中传递数据。子孙组件可以通过use_context进行消费。

最关键的是变量的数据类型,它是根据类型推导出需要获取上下文的哪个变量数据。

use_drop

use_drop 是一个用于清理资源的hook,在组件销毁时执行清理操作。组件默认会清理hooks、副作用函数,也可以自定义清理操作。

use_drop 可以在服务端渲染时被调用。

还提供其它很多不同地hooks 但由于dioxus仍在设计中,未来不确定性会被移除。

引用

生成pdf并下载

html2pdf.js

众所周知,H5生成pdf并下载最常用的就是html2pdf.js,而我也是众中的一位,常用的写法就是

downPdf(context) {
    htmlToPdf('#whiteBc', `${xxx}的报告`)
    function htmlToPdf(el,name) {
        let ele = document.querySelector(el);

        let opt = {
            margin: 20,
            filename:  name + '.pdf',
            pagebreak: { mode: ['avoid-all', 'css', 'legacy'] },
            image: {
                type: 'png',
                quality: 1
            },
            html2canvas: {
                scale: 2
            },
            jsPDF: {
                orientation: 'p',
                unit: 'pt',
                format: 'a4',
            }
        };

        html2pdf().set(opt).from(ele).save()
            .then(() => {
                
            })
            .catch(err => {
                console.error(err);
            })
    }
}

问题1:网络图片无法生成下载

解决1:直接把网络图片放在本地,引用本地的图片

问题是,我项目中的所有资源都是网络的,那把这些放到本地我不甘心啊

解决2:等待所有图片加载完成后再下载

// 修改html2canvas的配置
html2canvas: {
    scale: 2,
    CORS: true,
    useCORS: true,  // 添加这个选项以支持跨域图片
    allowTaint: true,  // 允许加载跨域图片
}
const images = ele.querySelectorAll('img');
const imagePromises = Array.from(images).map(img => {
    return new Promise((resolve, reject) => {
        // 对于每一张图片,检查其 `complete` 属性是否为 `true`(即图片是否已经完全加载)
        if (img.complete) {
            resolve();
        } else {
            // 图片加载完成
            img.onload = resolve;
            // 如果图片加载失败也调用 `resolve()`,这意味着即使图片加载失败,整个Promise也不会被拒绝。
            img.onerror = resolve;
        }
    });
});
// 当所有图片加载完毕(成功或失败),再下载执行
Promise.all(imagePromises)
    .then(() => {
        return html2pdf().set(opt).from(ele).save();
    })
    .then(() => {
        
    })
    .catch(err => {
        console.error(err);
    })

这里有个问题:我发现到测试环境后不行了,图片跨域了,但是打开控制台就又行了,这也是我上测试环境的原因,因为开发时我的控制台一直被打开着。那到底为啥呢?没找到原因,恕我无能

解决3:把图片转成base64(稍微麻烦点,但是也算解决了)

async handleImg(url) {
    return await this.toBase64(url)
},
toBase64(imgUrl) {
    return new Promise((resolve, reject) => {
        try {
            let image = new window.Image()
            image.setAttribute('crossOrigin', 'anonymous')
            image.src = imgUrl
            image.onload = () => {
                let canvas = document.createElement('canvas')
                canvas.width = image.width
                canvas.height = image.height
                let context = canvas.getContext('2d')
                context.drawImage(image, 0, 0, image.width, image.height)
                let quality = 0.8
                let dataurl = canvas.toDataURL('image/png', quality)
                resolve(dataurl)
            }
            image.onerror = (e) => {
                reject(e)
            }
        } catch (e) {
            reject(e)
        }
    })
}
// 使用
await this.handleImg('https://rice-website.oss-cn-shanghai.aliyuncs.com/resource/a217e360-f520-4f13-9059-cdffa8f0af91.png')
// html2canvas可以把跨域的配置去掉了

问题2:当页面固定高度,内部滚动时,滚动到页面的下方再下载,下载的pdf上半部分会出现一片空白,并且下载的pdf页面也不完整

解决:下载的时候把页面滚到顶部(略显逊)

window.scrollTo(0, 0)

再见。

❌