阅读视图

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

入坑node.js全面指南(一)

1. Node.js简介

什么是Node.js

Node.js是一个开源的JavaScript运行时环境,基于Chrome的V8引擎构建。它允许开发者在服务器端运行JavaScript,并且提供了丰富的库和模块,使得构建网络应用变得更加高效。

Node.js的特点

  • 非阻塞I/O:Node.js采用事件驱动、非阻塞I/O模型,提高了系统的并发处理能力。
  • 单线程:Node.js使用单线程模型,通过事件循环来处理多个连接,适用于高并发的场景。
  • 跨平台:Node.js支持多种操作系统,包括Windows、Linux和Mac OS。

2. 环境搭建

安装Node.js

访问Node.js官网下载适合你操作系统的版本,按照安装向导进行安装。

Node.js版本管理工具(nvm)

使用nvm可以方便地管理Node.js的多个版本。

安装特定版本的 Node.js:

nvm install <version>

安装最新的 LTS 版本:

nvm install lts

切换到已经安装的 Node.js 版本:

nvm use <version>

第一个Node.js程序

创建一个app.js文件,内容如下:

console.log("Hello, Node.js!");

在终端中运行:

node app.js

3. Node.js核心模块

node.js 内置了许多强大的模块,常用的包括:

  • http:创建 HTTP 服务器。
  • fs:文件系统操作。
  • path:文件路径操作。
  • url:URL 解析。
  • express:Web 框架(下一篇会详细介绍)。

事件模块

Node.js的事件模块提供了一种异步事件驱动的编程方式。

const EventEmitter = require('events');
const emitter = new EventEmitter();

emitter.on('event', () => {
  console.log('事件触发!');
});

emitter.emit('event');

文件系统模块

文件系统模块提供了对文件和目录的操作功能。

const fs = require('fs');

// 读取文件
fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

// 写入文件
fs.writeFile('example.txt', 'Hello, World!', (err) => {
  if (err) throw err;
  console.log('文件已保存!');
});

HTTP模块

HTTP模块可以创建服务器并处理请求。

const http = require('http');

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello, World!\n');
});

server.listen(3000, () => {
  console.log('服务器运行在 http://localhost:3000/');
});

路径模块

路径模块提供文件路径的处理方法。

const path = require('path');

const filePath = path.join(__dirname, 'example.txt');
console.log(filePath);

OS模块

OS模块提供有关操作系统的信息。

const os = require('os');

console.log('操作系统类型:', os.type());
console.log('系统内存:', os.totalmem());

4. NPM与模块管理

NPM简介

NPM是Node.js的包管理工具,提供了模块的安装、更新和卸载功能。

模块的安装与使用

安装第三方模块:

npm install express

使用模块:

const express = require('express');

创建自定义模块

创建一个模块文件myModule.js

module.exports.sayHello = function() {
  console.log('Hello from myModule!');
};

在主文件中使用:

const myModule = require('./myModule');
myModule.sayHello();

package.json详解

package.json是项目的配置文件,记录项目的基本信息及依赖。

{
  "name": "my-project",
  "version": "1.0.0",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^4.17.1"
  }
}

5. 异步编程

回调函数

Node.js使用回调函数处理异步操作。

fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

Promise

Promise是解决回调地狱的一种方式。

const fs = require('fs').promises;

fs.readFile('example.txt', 'utf8')
  .then(data => console.log(data))
  .catch(err => console.error(err));

async/await

async/await更进一步,简化了异步代码的写法。

async function readFileAsync() {
  try {
    const data = await fs.readFile('example.txt', 'utf8');
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

readFileAsync();

虚拟DOM:前端性能优化的双刃剑

在现代前端开发中,虚拟DOM(Virtual DOM)已成为React、Vue等主流框架的核心技术。它通过内存中的抽象层解决了一个困扰开发者多年的难题:高效更新用户界面

一、虚拟DOM的本质:内存中的数字蓝图

虚拟DOM本质上是一个轻量级的JavaScript对象,它是对真实DOM结构的抽象表示。这个对象包含了真实DOM的关键信息:节点类型、属性、子节点关系等,但不包含真实DOM上的复杂方法和浏览器渲染所需的额外重量。

// 虚拟DOM节点的JS对象表示
const virtualNode = {
  type: 'div',          // 节点类型
  props: {              // 属性
    id: 'main',
    className: 'container'
  },
  children: [           // 子节点
    { type: 'h1', props: { textContent: '标题' }, children: [] },
    { type: 'p', props: { textContent: '文本内容' }, children: [] }
  ]
};

当应用状态变化时,框架会生成新的虚拟DOM树,通过Diff算法对比新旧树的差异,计算出最小变更集,最终仅更新真实DOM中必要的部分。

这一机制与传统直接操作DOM形成鲜明对比。后者如同每次修改房屋结构都直接拆墙重建,而虚拟DOM如同建筑师先在蓝图上反复修改,确认无误后才施工。

二、为什么需要虚拟DOM?性能瓶颈的破局者

在虚拟DOM出现前,频繁的DOM操作是前端性能的主要瓶颈:

  1. 重排(Reflow)与重绘(Repaint)的代价
    每次DOM几何属性(如宽高、位置)变化会触发重排,外观变化(如颜色)触发重绘。两者均消耗CPU资源,频繁触发将导致页面卡顿。
  2. 传统更新的性能悬崖
    直接操作DOM时,连续1000次appendChild可能耗时超过120ms,造成明显延迟。例如向列表动态添加项时,每次插入都触发重排:
// 传统DOM操作:每次添加触发重排
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  list.appendChild(li); // 触发重排
}
  1. 开发维护的复杂性
    手动管理DOM状态在大型应用中极易出错,代码冗长且难维护。

虚拟DOM的核心价值在于通过批处理更新差异化渲染,将开发者的操作从底层渲染中解耦,既提升了开发效率,又规避了最严重的性能陷阱。

三、虚拟DOM的工作原理

步骤1:JS对象模拟DOM树

如前述代码所示,框架将UI描述为嵌套的JS对象,包含类型、属性和子节点。

步骤2:状态变更生成新虚拟树

数据变化时,重新生成一棵完整的虚拟DOM树(React通过setState,Vue通过响应式数据触发)。

步骤3:Diff算法对比差异

  • 同层比较(O(n)复杂度) :只对比同一层级节点,不跨层移动
  • Key优化列表更新:通过key标识节点身份,避免无意义重建
  • 差异类型标记:定义节点替换(REPLACE)、属性更新(PROPS)、文本修改(TEXT)、子节点重排序(REORDER)等变更类型

步骤4:补丁(Patch)应用至真实DOM

将Diff结果转化为DOM操作序列,如:

patches = [
  { type: 'PROPS', props: { class: 'new-class' } }, // 更新属性
  { type: 'TEXT', content: '更新文本' }            // 更新文本节点
]

关键优化点

  • 批处理更新:将多次变更合并为单次DOM提交(React的合成事件/Vue的nextTick
  • 时间切片(Time Slicing) :React Fiber将Diff计算拆分为可中断的小任务,避免阻塞主线程

四、虚拟DOM的优劣之争

核心优势:效率、开发体验与跨平台

  1. 性能安全网
    尽管Diff计算有开销,但避免了大量无效重排,为复杂应用提供了可预测的性能下限。在动态列表、频繁状态变更场景下优势显著。
  2. 开发效率革命
    开发者只需声明“UI应是什么状态”(声明式编程),无需手动操作DOM(命令式编程),大幅减少代码量并降低维护成本。

无法回避的缺陷

  1. 首次渲染成本
    构建虚拟树+Diff计算使首屏渲染可能比innerHTML慢10%~30%。
  2. 内存开销
    需在内存中维护完整虚拟树副本,对低端设备可能构成压力。
  3. 极致优化受限
    对性能要求极高的动画或游戏场景,虚拟DOM的Diff计算可能成为瓶颈,不如手动操作DOM高效。
  4. 学习曲线
    开发者需理解Diff机制、key的作用等概念,新手易误用导致性能反优化。

五、虚拟DOM vs 真实DOM

虚拟DOM不是真实DOM的替代品,而是针对特定场景的优化层

  • 真实DOM仍具价值
    简单静态页面中,直接操作DOM更轻量(如jQuery插件)。动画库若直接控制DOM属性(如transform),可绕过Diff避免帧率波动。
  • 虚拟DOM的适用场景
    适合多状态交互的中大型应用(如后台管理系统、动态表单),在可维护性与性能间取得平衡。

六、使用场景

推荐采用场景
✅ 复杂单页应用(如在线协作工具)
✅ 高频率数据更新界面(如实时仪表盘)
✅ 多平台共享代码的项目
✅ 大型团队协作开发

谨慎评估场景
⚠️ 静态内容展示页(如营销落地页)
⚠️ 对首屏速度要求严苛的应用
⚠️ 运行在低端设备的应用
⚠️ 需极致动画性能的场景

Nx带来极致的前端开发体验——项目搭建

首发于公众号 code进化论,欢迎关注。

项目搭建

创建项目

使用以下命令创建一个新的 React monorepo:

~ ❯ npx create-nx-workspace react-monorepo  --pm=pnpm --preset=react-monorepo

 NX   Let's create a new workspace [https://nx.dev/getting-started/intro]

✔ Application name · core
✔ Which bundler would you like to use? · rspack
✔ Which unit test runner would you like to use? · vitest
✔ Test runner to use for end to end (E2E) tests · none
✔ Default stylesheet format · scss
✔ Would you like to use ESLint? · No
✔ Would you like to use Prettier for code formatting? · No
✔ Which CI provider would you like to use? · skip
✔ Would you like remote caching to make your build faster? · skip

nx 提供了脚手架工具帮助开发者快速搭建项目,这里有几个点我们需要关注:

  • bunder

    nx 目前集成了 webpack、vite、rspack 三款流行的打包工具,基本都能满足项目的开发。

  • 测试框架

    nx 目前集成了 jest、vitest 两款当前最流行的测试框架,他们之间的对比可参考 vitest 官网的介绍

  • linter

    nx 目前只支持 eslint,暂不支持 xolint,需要如果开发者想接入 xolint 需要手动配置。

其余的像 prettier 代码格式化、远程缓存这些需要开发者结合自己的场景确定。上述命令生成以下结构:

└─ react-monorepo
   ├─ apps
   │  ├─ core
   │  │  ├─ public
   │  │  │  └─ ...
   │  │  ├─ src
   │  │  │  ├─ app
   │  │  │  │  ├─ app.module.css
   │  │  │  │  ├─ app.spec.tsx
   │  │  │  │  ├─ app.tsx
   │  │  │  │  └─ nx-welcome.tsx
   │  │  │  ├─ assets
   │  │  │  ├─ main.tsx
   │  │  │  └─ styles.css
   │  │  ├─ index.html
   │  │  ├─ package.json
   │  │  ├─ tsconfig.app.json
   │  │  ├─ tsconfig.json
   │  │  ├─ tsconfig.spec.json
   │  │  └─ vite.config.ts
   ├─ package.json
   ├─ nx.json
   ├─ tsconfig.base.json
   ├─ tsconfig.json
   └─ vitest.workspace.ts

主要的设置包括:

  • 一个新的 React 应用程序(apps/core

    在 Nx 中,推荐的一种方式是将应用程序放在 apps 文件夹下,将库应用(package)放在 libs 文件夹下,应用程序尽可能的保持轻量,通过组装 libs 文件夹下的库来实现具体的功能。

  • nx 基础配置文件(nx.json)

  • tsconfig 基础配置,之后创建的所有 package 的 ts 配置都会继承这个文件。

  • package.json 文件,默认在根目录安装所有依赖。

    Nx 支持两种依赖管理策略,一个是各个 package 都定义自己的 package.json 来管理自己的依赖,一个是在根目录的 package.json 安装所有依赖,甚至可以在项目中混用这两种策略,这两种策略的优缺点在官方文档中作了详细的讲解。

启动项目

npx nx serve core

启动服务之后可以在 http://localhost:4200/ 预览。

创建react library

在一个项目中通常会包含各个业务模块,比如一个商城系统,需要包含商品页、订单页、个人中心页等,这些功能模块都是相互独立的,可以拆解成一个个独立的 packge,最终在应用程序中进行组合形成一个商城系统。

为了创建这些独立的 package,Nx 提供了相应的代码生成器,以 react项目为例,nx 提供了 @nx/react 工具,下面通过命令创建一个新的 react library:

npx nx g @nx/react:lib libs/shop

NX  Generating @nx/react:library

✔ Which bundler would you like to use to build the library? Choose 'none' to skip build setup. · vite
✔ What unit test runner should be used? · vitest

目前 react-library 的 bunder 只支持 vite 和 rollup 这种轻量级的打包工具,对于一个 library 已经够用了,反观 webpack 就太重了,很难发挥它的优势。

由于我们的 monorepo 项目底层是基于 workspace 搭建的(这里我们用的 pnpm),所以 library 在被其他库或者应用引用之前需要先进行构建:

npx nx run @react-monorepo/shop:build

构建完之后会生成一个 dist 文件夹,用来存放构建产物:

同时也可以查看该 library 的 package.json,里面通过 exports 定义了外部能够引用当前库中的那些数据,这些数据是从哪些路径抛出去的。

{
  "name": "@react-monorepo/shop",
  "version": "0.0.1",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    "./package.json": "./package.json",
    ".": {
      "development": "./src/index.ts",
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "default": "./dist/index.js"
    }
  }
}

创建基础工具库

除了功能模块库,我们还需要创建一些基础工具库,比如常用的工具函数、hooks等。为了创建这些工具库,Nx 也提供了相应的代码生成器,以 typescript 工具函数为例,nx提供了@nx/js 工具,下面是一个示例:

npx nx g @nx/js:lib libs/utils

NX  Generating @nx/js:library

✔ Which bundler would you like to use to build the library? Choose 'none' to skip build setup. · tsc
✔ Which linter would you like to use? · none
✔ Which unit test runner would you like to use? · vitest

目前 Nx 已经支持 tsx、swc、esbuild、vite、rollup 等作为 ts 工具库的构建工具,默认使用 tsx,之后额直接进行构建:

npx nx run @react-monorepo/utils:build

整体流程和 react library 一致,构建完成之后就能被其他库正常引用。

Nx console

由于 Nx 提供的工具及其命令非常多,为了简化开发者的心智负担,Nx 官方推出了一个可视化的 UI 工具,开发者只需要在 UI 页面中选择相应的工具并输入相应配置就能完成库的创建或者任务的执行。

在 vscode 插件商店中搜索 Nx Console 插件并进行安装:

安装后在左上部分能看到当前工作区的所有 package 及其对应的命令:

在左下角可以看到 Nx 提供的所有命令,最需要关注的就是 Generate (UI)功能:

点击 Generate (UI)会弹出一个弹窗,里面展示了项目中已安装的所有 Nx 代码生成器工具:

以 react library 为例,点击之后会展示创建 react library 所有相关配置,每个配置都会有对应的描述:

填写完之后只需要点击右上角的生成按钮就能完成整个代码生成任务。

总结

这篇文章主要是借助 Nx 的代码生成器完成项目的搭建,帮助开发者学习如何构建不同种类的 package,后面将会带大家进入到代码开发阶段,如何借助 CDD 和 TDD 高效完成项目开发。

手把手教你实现一个旋转风车效果

前言

继上次实现一个大白兔的效果后,这次我就带大家来实现一个旋转风车的效果,纯CSS实现,十分简单,没有花里胡哨的技巧。话不多说,咱们直接进入主题。

效果预览

最终实现的相关效果如下。

HTML部分

首先看到HTML部分。相关代码如下。

   <figure>
        <span class="two part1"></span>
        <span class="two part2"></span>
        <span class="two part3"></span>
        <span class="two part4"></span>
        <span class="zero copy-1"></span>
        <span class="zero copy-2"></span>
        <span class="text happy">happy</span>
        <span class="text windmill">windmill</span>
        <span class="text pinwheel">pinwheel</span>
    </figure>

这里创建了一个 HTML 结构,用于创建一个包含多个 <span> 元素的 <figure> 容器。这些 <span> 元素构建一个风车图形。结合类名和结构,配合 happy 等文本,用于构建场景。part1-part4 是风车的四个叶片,zero 是中心轴,通过 CSS 动画实现旋转效果。最终实现了一个风车的基本模块。

CSS部分

接下来我们看到CSS部分,相关代码如下。首先是基础样式。

body {
    margin: 0;
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    background-color: darkred; /* 深红色背景 */
}

这里主要的作用就是让页面居中显示,背景为深红色。

然后是网格布局。

figure {
    font-size: 60px; /* 基准大小 */
    display: grid;
    grid-template-columns: repeat(8, 1em); /* 8列 */
    grid-template-rows: repeat(3, 1em);    /* 3行 */
    color: whitesmoke; /* 主色调 */
}

这里定义一个 8列 × 3行 的网格,每个单元格 1em(基于 font-size: 60px),主颜色为 whitesmoke(灰白色)。

接着是四个部分拼合。

.two {
    background-color: currentColor; /* 继承父级颜色 */
    filter: drop-shadow(4em 0 0 whitesmoke); /* 右侧投影(镜像效果) */
}
.two.part1 { grid-area: 1/1; border-radius: 100% 0 0 0; } /* 左上圆角 */
.two.part2 { grid-area: 2/2; border-radius: 0 0 100% 0; } /* 右下圆角 */
.two.part3 { grid-area: 3/1; border-radius: 100% 0 0 0; } /* 左上圆角 */
.two.part4 { grid-area: 3/2; border-radius: 0 0 0 100%; } /* 左下圆角 */

为 "2" 的每个部分添加右侧投影,形成镜像效果(类似 "2 2")。将四个部分定位到网格的特定位置,拼合成数字 "2"。最后通过圆角控制形状,模拟手写风格。

.zero {
    width: 0;
    height: 0;
    border: 1em solid;
    border-color: currentColor transparent; /* 上下有色,左右透明 */
    border-radius: 50%; /* 圆形 */
    transform: rotate(-45deg); /* 初始旋转 */
    animation: round 4s linear infinite; /* 旋转动画 */
}
.zero.copy-1 { grid-area: 2/3/4/5; } /* 第一个 "0" */
.zero.copy-2 { grid-area: 2/7/4/9; } /* 第二个 "0" */

通过 borderborder-radius 创建一个环形(只显示上下两段弧线)。

设计一个动画round让 "0" 持续旋转,相关代码如下。

@keyframes round {
    to { transform: rotate(-45deg + -1turn); } /* 逆时针旋转360° */
}

这里用于实现元素的连续逆时针旋转效果。元素从 -45deg 开始,逆时针完整旋转 360 度,最终回到 -45deg。由于动画是 infinite(无限循环),视觉效果为持续逆时针旋转

最后是装饰文本。

.text {
    font-size: 0.66em;
    text-transform: uppercase; /* 大写字母 */
    font-family: cursive; /* 手写字体 */
    font-weight: bold;
    mix-blend-mode: difference; /* 混合模式(反色效果) */
}
.text.happy { grid-area: 1/2; }   /* 位置:第1行第2列 */
.text.windmill { grid-area: 2/4; } /* 位置:第2行第4列 */
.text.pinwheel { grid-area: 1/6; } /* 位置:第1行第6列 */

mix-blend-mode: difference混合模式会计算文本颜色(whitesmoke)和背景色(darkred)的差值,产生动态反色效果,在深红背景上,whitesmoke 文字会显示为浅青色(#f5f5f5 与 #8b0000 的差值),增强视觉对比,营造霓虹灯或发光文字的感觉。

所以最终效果就是文字"happy"、"windmill"、"pinwheel" 作为装饰,动画方面风车持续逆时针旋转,营造动态感。配色方面,主色 whitesmoke 与深红背景形成对比,文字通过 mix-blend-mode 反色。

总结

以上就是整个效果的实现过程了,纯 CSS 实现,代码简单易懂。另外,感兴趣的小伙伴们还可以在现有基础上发散思维,如果需要调整,可以修改 font-size、动画速度或颜色来适配不同场景,比如增加点其他效果,或者更改颜色等等。关于该效果如果大家有更好的想法欢迎在评论区分享,互相学习。最后,完整代码在码上掘金里可以查看,如果有什么问题大家在评论区里讨论~

深入理解JavaScript中的Switch语句:从基础到高级实现的全景解析

引言

在JavaScript编程中,控制流语句是构建逻辑的重要工具。Switch语句作为一种多分支选择结构,能够让代码更加清晰和易于维护。本文将带你从基础知识入手,逐步深入,探索Switch语句的高级实现技巧和最佳实践。

1. 什么是Switch语句?

Switch语句是一种控制流语句,用于根据不同的条件执行不同的代码块。它的语法结构比多个if-else语句更加简洁,尤其在处理多个条件时,Switch语句的可读性更高。

基本语法

switch (expression) {
  case value1:
    // 执行代码块1
    break;
  case value2:
    // 执行代码块2
    break;
  default:
    // 执行默认代码块
}

2. Switch语句的基本使用

让我们通过一个简单的例子来理解Switch语句的基本用法:

const fruit = 'apple';

switch (fruit) {
  case 'banana':
    console.log('这是一个香蕉');
    break;
  case 'apple':
    console.log('这是一个苹果');
    break;
  default:
    console.log('未知水果');
}

在这个例子中,Switch语句根据变量fruit的值来决定输出的内容。

3. Switch语句的特点

  • 隐式类型转换:Switch语句在比较时会进行隐式类型转换,因此要注意数据类型的匹配。
  • fall-through特性:如果没有使用break语句,程序会继续执行下一个case的代码块,直到遇到break或结束。

示例:Fall-through特性

const grade = 'B';

switch (grade) {
  case 'A':
    console.log('优秀');
    break;
  case 'B':
  case 'C':
    console.log('良好');
    break;
  default:
    console.log('未通过');
}

在这个例子中,BC都将输出“良好”。

4. 高级用法:使用表达式

Switch语句不仅可以用于简单的值比较,还可以使用表达式来进行更复杂的条件判断。

示例:使用表达式

const score = 85;

switch (true) {
  case score >= 90:
    console.log('优秀');
    break;
  case score >= 80:
    console.log('良好');
    break;
  case score >= 70:
    console.log('及格');
    break;
  default:
    console.log('不及格');
}

在这个例子中,我们使用true作为Switch的表达式,通过条件判断来输出不同的结果。

5. Switch语句与对象映射的结合

在某些情况下,使用对象映射可以替代Switch语句,从而提高代码的可维护性和可读性。

示例:对象映射

const fruit = 'apple';
const fruitMessages = {
  banana: '这是一个香蕉',
  apple: '这是一个苹果',
  default: '未知水果',
};

console.log(fruitMessages[fruit] || fruitMessages.default);

在这个例子中,我们使用对象映射来替代Switch语句,使得代码更加简洁。

结语

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。

Flutter AI 工具包:集成 AI 聊天功能到 Flutter App

image.png

主要特点

  1. 多轮聊天:自动维护聊天历史,保持多轮交互的语义连贯性
  2. 流式响应渲染:实时逐字显示 AI 回复,提升交互体验
  3. 富文本显示:支持 Markdown 解析、代码块高亮、链接识别等
  4. 语音输入:使用语音输入prompt。
  5. 多媒体输入:支持发送图片、文件等附件,AI 可识别处理
  6. 自定义样式:提供自定义样式,以匹配App设计。
  7. 聊天序列化/反序列化:存储和检索App会话之间的对话。
  8. 自定义响应Widget:引入专门的UI组件来呈现LLM响应。
  9. 可插拔LLM支持:实现一个简单的界面来插入自定义LLM。
  10. 跨平台支持:兼容Android、iOS、Web和macOS平台。

demo效果

image.png

源代码可在GitHub上找到

安装

依赖项添加到pubspec.yaml文件中

dependencies:
  flutter_ai_toolkit: ^latest_version
  google_generative_ai: ^latest_version # 使用Gemini
  firebase_core: ^latest_version        # 使用Firebase Vertex AI

Gemini AI配置

要使用Google Gemini AI,请从Google Gemini AI Studio获取API密钥

还需要选择一个Gemini model。

import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text(App.title)),
        body: LlmChatView(
          provider: GeminiProvider( // Gemini 服务提供商
            model: GenerativeModel( // Gemini model
              model: 'gemini-2.0-flash',
              apiKey: 'GEMINI-API-KEY', // Gemini API Key
            ),
          ),
        ),
      );
}

GenerativeModel类来自google_generative_ai软件包。GeminiProvider将Gemini AI插入到LlmChatView,LlmChatView是顶级Widget,与您的用户提供基于LLM的聊天对话。

Vertex AI configuration

另外一个AI服务是Firebase的Vertex AI。不需要API密钥,并用更安全的Firebase取代它。要在项目中使用Vertex AI,请按照 Get started with the Gemini API using the Vertex AI in Firebase SDKs 中描述的步骤进行操作。

完成后,使用flutterfire CLI工具将新的Firebase项目集成到您的Flutter App中,如Add Firebase to your Flutter app文档中所述。

按照这些说明操作后,您就可以在Flutter App中使用Firebase Vertex AI了。

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_vertexai/firebase_vertexai.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

// ... other imports

import 'firebase_options.dart'; // from `flutterfire config`

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(const App());
}

在Flutter App中正确初始化Firebase后,可以创建Vertex provider的实例了:

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text(App.title)),
        // create the chat view, passing in the Vertex provider
        body: LlmChatView(
          provider: VertexProvider(
            chatModel: FirebaseVertexAI.instance.generativeModel(
              model: 'gemini-2.0-flash',
            ),
          ),
        ),
      );
}

FirebaseVertexAI类来自firebase_vertex ai软件包。构建VertexProvider类,将Vertex AI暴露给LlmChatView。不需要提供API密钥。这些都作为Firebase项目自动处理了。

LlmChatView

LlmChatView Widget 是AI Toolkit提供的互动聊天组件。支持如下功能

  • 多行文本输入:允许用户在输入prompt时粘贴长文本或插入新行。
  • 语音输入:允许用户使用语音输入prompt
  • 多媒体输入:允许用户拍照和发送图像和其他文件类型prompt。
  • 图像缩放:允许用户放大图像缩略图。
  • 复制到剪贴板:允许用户将消息或LLM响应的文本复制到剪贴板。
  • 消息编辑:允许用户编辑最新的消息以重新提交到LLM。
  • 支持Material 和 Cupertino两种设计样式

多行文本输入

语音输入

多媒体输入

图片缩放

点击能缩放图片

复制到剪贴板

文字编辑

长按文字, 弹出编辑菜单

支持Material and Cupertino两种设计样式

额外的功能

  • 欢迎信息:向用户显示初始问候。
  • prompt建议:向用户提供预定义的提建议prompt,以引导互动。
  • 系统指令:让 AI 系统明确 “做什么”“如何做” 以及 “在什么条件下执行”,类似于给 AI 系统下达的 “任务说明书” 或 “操作指南”。
  • 管理历史记录:每个LLM Provider都允许管理聊天记录,用于清除、动态更改和在会话之间存储聊天状态。
  • 聊天序列化/反序列化:存储和检索App会话之间的对话。
  • 自定义响应Widget:引入专门的UI组件来呈现LLM响应。
  • 自定义样式:定义独特的视觉样式,以将聊天外观与整个App相匹配。
  • 自定义LLM Provider:构建自定义LLM Provider,将聊天与您自己的模型后端集成。
  • 重新路由提示:调试、记录或重新路由消息,旨在让Provider动态跟踪问题或路由提示。

欢迎信息

自定义欢迎消息

class ChatPage extends StatelessWidget {
 const ChatPage({super.key});

 @override
 Widget build(BuildContext context) => Scaffold(
       appBar: AppBar(title: const Text(App.title)),
       body: LlmChatView(
         welcomeMessage: 'Hello and welcome to the Flutter AI Toolkit!', //初始化LlmChatView的欢迎消息:
         provider: GeminiProvider(
           model: GenerativeModel(
             model: 'gemini-2.0-flash',
             apiKey: geminiApiKey,
           ),
         ),
       ),
     );
}

prompt建议

没有聊天记录时,提供一组建议的prompt

class ChatPage extends StatelessWidget {
 const ChatPage({super.key});

 @override
 Widget build(BuildContext context) => Scaffold(
       appBar: AppBar(title: const Text(App.title)),
       body: LlmChatView(
         suggestions: [
           'I\'m a Star Wars fan. What should I wear for Halloween?',
           'I\'m allergic to peanuts. What candy should I avoid at Halloween?',
           'What\'s the difference between a pumpkin and a squash?',
         ], /// 建议列表
         provider: GeminiProvider(
           model: GenerativeModel(
             model: 'gemini-2.0-flash',
             apiKey: geminiApiKey,
           ),
         ),
       ),
     );
}

系统指令

让 AI 明确 “做什么”“如何做” 以及 “在什么条件下执行”,类似于给 AI 系统下达的 “任务说明书” 或 “操作指南”。

例如,食谱示例App使用systemInstructions参数来定制LLM,以专注于根据用户的说明提供食谱:

class _HomePageState extends State<HomePage> {
  ...
  // create a new provider with the given history and the current settings
  LlmProvider _createProvider([List<ChatMessage>? history]) => GeminiProvider(
      history: history,
        ...,
        model: GenerativeModel(
          model: 'gemini-2.0-flash',
          apiKey: geminiApiKey,
          ...,
          systemInstruction: Content.system('''
You are a helpful assistant that generates recipes based on the ingredients and 
instructions provided as well as my food preferences, which are as follows:
${Settings.foodPreferences.isEmpty ? 'I don\'t have any food preferences' : Settings.foodPreferences}

You should keep things casual and friendly. You may generate multiple recipes in a single response, but only if asked. ...
''', /// 系统指令
          ),
        ),
      );
  ...
}

历史记录管理

访问history属性查看或设置历史记录:

void _clearHistory() => _provider.history = [];

使用旧的历史来创建新的Provider:

class _HomePageState extends State<HomePage> {
  ...
  void _onSettingsSave() => setState(() {
        // 迁移旧的历史记录到新的供应商
        final history = _provider.history.toList();
        _provider = _createProvider(history);
      });
}

_createProvider方法创建了一个具有上一个Provider历史记录和新用户首选项的新Provider。这对用户来说是无缝的;他们可以继续聊天,但现在LLM会考虑他们的新食物偏好,给他们回复

class _HomePageState extends State<HomePage> {
  ...
  // 根据给定的历史记录和当前设置创建一个新的提供者
  LlmProvider _createProvider([List<ChatMessage>? history]) =>
    GeminiProvider(
      history: history,
      ...
    );
  ...
}

Chat序列化/反序列化

要在App会话之间保存和恢复聊天记录,需要能够对每个用户prompt(包括附件)和每个 LLM 响应进行序列化和反序列化。 两种消息(用户prompt和LLM响应)都暴露在ChatMessage类中。 序列化可以通过使用每个ChatMessage实例的toJson方法来完成。

Future<void> _saveHistory() async {
  // 获取最新的历史
  final history = _provider.history.toList();

  // 保存历史消息
  for (var i = 0; i != history.length; ++i) {
    // 文件存在旧忽略
    final file = await _messageFile(i);
    if (file.existsSync()) continue;

    // 新消息保存到磁盘
    final map = history[i].toJson();
    final json = JsonEncoder.withIndent('  ').convert(map);
    await file.writeAsString(json);
  }
}

同样,要反序列化,使用ChatMessage fromJson方法:

Future<void> _loadHistory() async {
  // 从磁盘读取历史记录
  final history = <ChatMessage>[];
  for (var i = 0;; ++i) {
    final file = await _messageFile(i);
    if (!file.existsSync()) break;

    final map = jsonDecode(await file.readAsString());
    history.add(ChatMessage.fromJson(map));
  }

  /// 设置历史记录
  _provider.history = history;
}

自定义响应Widget

默认聊天视图显示的 LLM 响应格式为 Markdown。可以创建一个自定义Widget来显示您的App风格的样式:

设置LlmChatView的responseBuilder参数:

LlmChatView(
  provider: _provider,
  welcomeMessage: _welcomeMessage,
  responseBuilder: (context, response) => RecipeResponseView(
    response,
  ),
),
class RecipeResponseView extends StatelessWidget {
  const RecipeResponseView(this.response, {super.key});
  final String response;

  @override
  Widget build(BuildContext context) {
    final children = <Widget>[];
    String? finalText;

    // 收到LLM的回复后即时生成内容,因此目前无法得到完整的回复,添加一个按钮以便将食谱添加到列表中
    try {
      final map = jsonDecode(response);
      final recipesWithText = map['recipes'] as List<dynamic>;
      finalText = map['text'] as String?;

      for (final recipeWithText in recipesWithText) {
        // extract the text before the recipe
        final text = recipeWithText['text'] as String?;
        if (text != null && text.isNotEmpty) {
          children.add(MarkdownBody(data: text));
        }

        // 提取食谱
        final json = recipeWithText['recipe'] as Map<String, dynamic>;
        final recipe = Recipe.fromJson(json);
        children.add(const Gap(16));
        children.add(Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(recipe.title, style: Theme.of(context).textTheme.titleLarge),
            Text(recipe.description),
            RecipeContentView(recipe: recipe),
          ],
        ));

        // 添加按钮将食谱添加到列表中。
        children.add(const Gap(16));
        children.add(OutlinedButton(
          onPressed: () => RecipeRepository.addNewRecipe(recipe),
          child: const Text('Add Recipe'),
        ));
        children.add(const Gap(16));
      }
    } catch (e) {
      debugPrint('Error parsing response: $e');
    }

    ...

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: children,
    );
  }
}

自定义样式

使用LlmChatView构造函数的style参数来设置自己的样式,包括背景、文本字段、按钮、图标、建议等默认样式:

LlmChatView(
  provider: GeminiProvider(...),
  style: LlmChatViewStyle(...),
),

万圣节主题演示App

没有UI的聊天

不使用聊天视图也能访问Provider接口。

class _EditRecipePageState extends State<EditRecipePage> {
  ...
  final _provider = GeminiProvider(...);
  ...
  Future<void> _onMagic() async {
    final stream = _provider.sendMessageStream(
      'Generate a modified version of this recipe based on my food preferences: '
      '${_ingredientsController.text}\n\n${_instructionsController.text}',
    ); // 发送用户偏好食谱设置给llm provider
    var response = await stream.join(); // 获取llm推荐的响应
    final json = jsonDecode(response);

    try {
      final modifications = json['modifications'];
      final recipe = Recipe.fromJson(json['recipe']);

      if (!context.mounted) return;
      final accept = await showDialog<bool>( // 只使用了llm服务,没有使用聊天界面
        context: context,
        builder: (context) => AlertDialog(
          title: Text(recipe.title), // 推荐食谱标题
          content: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text('Modifications:'),
              const Gap(16),
              Text(_wrapText(modifications)), /// 修改的内容
            ],
          ),
          actions: [
            TextButton(
              onPressed: () => context.pop(true),
              child: const Text('Accept'),
            ),
            TextButton(
              onPressed: () => context.pop(false),
              child: const Text('Reject'),
            ),
          ],
        ),
      );
      ...
    } catch (ex) {
      ...
      }
    }
  }
}

重新路由Prompt

设置LlmChatView messageSender来调试、记录或操作聊天视图和底层Provider之间的连接

class ChatPage extends StatelessWidget {
  final _provider = GeminiProvider(...);

  @override
  Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(title: const Text(App.title)),
      body: LlmChatView(
        provider: _provider,
        messageSender: _logMessage,
      ),
    );

  Stream<String> _logMessage(
    String prompt, {
    required Iterable<Attachment> attachments,
  }) async* {
    // log the message and attachments
    debugPrint('# Sending Message');
    debugPrint('## Prompt\n$prompt');
    debugPrint('## Attachments\n${attachments.map((a) => a.toString())}');

    // 发送消息到provider
    final response = _provider.sendMessageStream(
      prompt,
      attachments: attachments,
    );

    // log response信息
    final text = await response.join();
    debugPrint('## Response\n$text');

    yield text;
  }
}

用于一些高级操作,如动态路由到Provider或检索增强生成(RAG)。

定制LLM Provider

abstract class LlmProvider implements Listenable {
  Stream<String> generateStream(String prompt, {Iterable<Attachment> attachments});
  Stream<String> sendMessageStream(String prompt, {Iterable<Attachment> attachments});
  Iterable<ChatMessage> get history;
  set history(Iterable<ChatMessage> history);
}

任何实现LlmProvider接口的都可以插入聊天视图, 可以是云或本地的

  1. 提供配置支持
  2. 处理历史
  3. 将消息和附件翻译成底层LLM
  4. 调用底层LLM

配置支持

class GeminiProvider extends LlmProvider ... {
  @immutable
  GeminiProvider({
    required GenerativeModel model,
    ...
  })  : _model = model,
        ...

  final GenerativeModel _model;
  ...
}

处理历史

历史记录是Provider的重要组成部分

Provider不仅需要允许直接操作历史记录,而且必须在更改时通知Listener。 为了支持序列化和更改Provider参数,必须支持保存历史记录作为构建过程的一部分。

class GeminiProvider extends LlmProvider with ChangeNotifier {
  @immutable
  GeminiProvider({
    required GenerativeModel model,
    Iterable<ChatMessage>? history,
    ...
  })  : _model = model,
        _history = history?.toList() ?? [],
        ... { ... }

  final GenerativeModel _model;
  final List<ChatMessage> _history;
  ...

  /// 设置对话历史记录并重新初始化聊天会话
  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]); /// 添加到历史记录

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  /// 获取当前的对话历史记录
  @override
  Iterable<ChatMessage> get history => _history;

  /// 设置对话历史记录并重新初始化聊天会话
  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }

  ...
}
import 'package:google_generative_ai/google_generative_ai.dart';
...

class GeminiProvider extends LlmProvider with ChangeNotifier {
  ...
  static Part _partFrom(Attachment attachment) => switch (attachment) {
        (final FileAttachment a) => DataPart(a.mimeType, a.bytes),
        (final LinkAttachment a) => FilePart(a.url),
      };

  static Content _contentFrom(ChatMessage message) => Content(
        message.origin.isUser ? 'user' : 'model',
        [
          TextPart(message.text ?? ''),
          ...message.attachments.map(_partFrom),
        ],
      );
}

调用LLM

class GeminiProvider extends LlmProvider with ChangeNotifier {
  ...

  @override
  Stream<String> generateStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) =>
      _generateStream(
        prompt: prompt,
        attachments: attachments,
        contentStreamGenerator: (c) => _model.generateContentStream([c]),
      );

  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]);

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  Stream<String> _generateStream({
    required String prompt,
    required Iterable<Attachment> attachments,
    required Stream<GenerateContentResponse> Function(Content)
        contentStreamGenerator,
  }) async* {
    final content = Content('user', [
      TextPart(prompt),
      ...attachments.map(_partFrom),
    ]);

    final response = contentStreamGenerator(content);
    yield* response
        .map((chunk) => chunk.text)
        .where((text) => text != null)
        .cast<String>();
  }

  @override
  Iterable<ChatMessage> get history => _history;

  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }
}

最终的AI 聊天效果

2025年前端最新面试题及答案

2025年前端最新面试题及答案

JavaScript 核心

1. 解释ES2025中的Record和Tuple提案

答案: Record和Tuple是ES2025中新增的两种不可变数据结构:

  • Record:不可变的对象,使用#{ }语法创建
    const record = #{ a: 1, b: 2 };
    
  • Tuple:不可变的数组,使用#[ ]语法创建
    const tuple = #[1, 2, 3];
    
    特点:
    1. 深度不可变(所有嵌套也是不可变的)
    2. 可以通过===进行值比较
    3. 不支持原型和自定义方法
    

2. WeakRef和FinalizationRegistry的使用场景

答案

  • WeakRef:允许持有对象的弱引用,不会阻止垃圾回收
    let obj = { data: 'important' };
    const weakRef = new WeakRef(obj);
    
    使用场景:缓存系统,当内存不足时可以自动释放
    
    
  • FinalizationRegistry:在对象被垃圾回收时执行清理操作
     const registry = new FinalizationRegistry((heldValue) => {
       console.log(`${heldValue}被回收了`);
     });
     
     registry.register(someObject, "someObject");
     
     使用场景:释放外部资源、监控内存泄漏
    

React相关

3. React Server Component在2025年的最新实践

答案: React Server Component (RSC)已成为现代React应用的标准配置:

  1. 优势

    • Zero-bundle-size组件(服务端组件不包含在客户端bundle中)
    • 自动代码分割和数据获取一体化
    • SEO友好
  2. 实践模式

// app/page.js (Next.js App Router)
async function Page() {
    const data = await fetchData(); //直接在组件中获取数据
    
    return (
        <div>
            <ClientComponent />
            <ServerComponent data={data} />
        </div>
    );
}
  1. 注意事项
    • Server Component不能使用状态和effects
    • Client边界需要显式声明'use client'

###4. React Compiler优化原理(原React Forget) 答案: React Compiler是2025年React官方推出的编译器,可以自动优化组件重新渲染:

  1. 工作原理

    • AST分析识别响应式值依赖关系 -自动生成等效的useMemo/useCallback代码
  2. 开发者影响

// Before (开发者手动优化)
function MyComponent({ a, b }) {
    const memoizedValue = useMemo(() => computeExpensive(a), [a]);
    
    return <div>{memoizedValue}</div>;
}

// After (编译器自动优化)
function MyComponent({ a, b }) {
    const value = computeExpensive(a); //会被自动memoize
    
    return <div>{value}</div>;
}

Web性能优化

###5. Partial Hydration在2025年的实现方式 答案 现代前端框架普遍采用部分水合策略:

1.实现方式

<!-- Island架构示例 -->
<div>
    <!-- Static内容 -->
    <h1>Welcome</h1>
    
    <!-- Island组件(需要交互的部分) -->
    <my-counter hydrate-on="visible"></my-counter>
</div>

2.关键技术

  • Intersection Observer API延迟加载可视区组件
  • requestIdleCallback分片执行水合
  • WASM预编译减少主线程负担

3.性能收益

  • TTI提升40%-60%
  • TBT降低30%-50%

TypeScript高级特性

###6. Template Literal Types在实际项目中的应用 答案

TS模板字面量类型可以实现强大类型约束:

// API路径类型安全 
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiPath<T extends string> = `/api/${T}`;

function fetchApi<T extends string>(
    method: HttpMethod,
    path: ApiPath<T>,
): Promise<Response> {
     // ...
}

// Valid调用  
fetchApi('GET', '/api/users'); ✅ 

// Invalid调用  
fetchApi('POST', '/users'); ❌ 

// Advanced用例: CSS单位验证  
type CssLength = `${number}px` | `${number}em` | `${number}rem`;
function setWidth(width: CssLength) {...}

WebAssembly前沿应用

###7.WASM与前端性能优化的结合点 答案

2025年WASM在前端的典型应用场景:

1.计算密集型任务

// Image处理示例  
const wasmModule = await WebAssembly.compileStreaming(fetch('image_proc.wasm'));
const { grayscale } = await wasmModule.exports;

canvas.addEventListener('input', () => {
     grayscale(canvasData); //比JS快3-5倍 
});

2.关键性能指标

Task JS实现 WASM实现
图像滤镜处理 120ms 25ms
物理引擎计算 85ms 15ms

3.最佳实践

  • SIMD指令并行计算(V8已默认启用)
  • WASM GC提案减少JS-WASM通信开销

以上是2025年前端面试中的部分高频问题和深度解析。实际面试中还可能涉及Web Components标准化进展、边缘计算渲染、AI驱动UI开发等前沿话题。

JS基础篇-ES6+新增API

1. Map 比 Object 为什么更好?

    1. Key 类型更灵活
    1. 更直观的内置方法
    1. 处理大型数据性能更好
    1. 保留插入顺序
    1. 避免原型污染

Key 类型更灵活

Object: 对象键 只能是字符串或符号。其他类型(例如对象、函数和数字)会 自动转换为字符串:

const obj = {}; 
const key = { a1 }; 
obj[key] = '2'console.log(obj);    // { "[object Object]": "2" }

Map: Map 的键可以是 任何类型,包括对象、函数和 NaN:

const map = new Map(); 
const key = { a1 }; 
map.set(key, '2'); 
console.log(map.get(key)); // "2"

更直观的内置方法

Object:

obj[key] = value;      // 添加属性
obj[key];              // 获取属性的值
delete obj[key];       // 删除属性 
Object.keys(obj).length// 获取属性的数量

Object.keys(obj).forEach(key => { /* ... */ });  
Object.values(obj).forEach(value => { /* ... */ });  
Object.entries(obj).forEach(([key, value]) => { /* ... */ });

Map:

map.set(key, value);  // 添加属性  
map.get(key);         // 获取属性的值  
map.has(key);         // 属性是否存在  
map.delete(key);      // 删除属性  
map.clear();          // 删除所有属性   
map.size;             // 获取属性的数量

map.forEach((value, key) => { /* ... */ });  
for (const [key, value] of map) { /* ... */ }

处理大型数据性能更好

  • 频繁的增删改查操作:Map 针对频繁的键值插入和删除操作做了优化。
  • 处理大型数据集:Map 通常在内存使用和访问速度方面表现更好,尤其是动态生成的键。

保留插入顺序

Map 严格维护键值对的插入顺序,非常适合顺序很重要的场景:

const map = new Map();  
map.set('a'1);  
map.set('b'2);  
console.log([...map]); // [['a', 1], ['b', 2]]

避免原型污染

Object容易受到原型链污染:

Copyconst obj = {}; 
console.log(obj.constructor); 
obj.hasOwnProperty('key'); 

Map 独立于原型链:

const map = new Map(); 
console.log(map.constructor); 
map.set('hasOwnProperty''safe');

无障碍功能是必须的

为什么无障碍设施如此重要

无障碍设计是一项基本职责,而非次要考虑因素。每个人都应该能够使用您的应用,包括那些行动不便的人。包容性设计确保了用户能够平等地使用您的产品,无论他们是否有视力障碍、行动障碍或认知障碍。

在许多国家,无障碍设施也是一项法律要求(例如美国的 ADA、欧洲的 EN 301 549)。

a11y React 开发者最佳实践

1. 使用语义HTML

  • 偏好本土元素
  • 避免使用 div 进行交互式 UI — 屏幕阅读器会跳过它们

2. 确保键盘导航性

  • 每个交互元素都应该可以通过 Tab 和 Enter 访问和操作
  • 慎重使用 tabindex (避免 tabindex="0" 过载)

3. 需要时添加 ARIA 属性

  • 使用 aria-label、aria-hidden、aria-live 为屏幕阅读器提供上下文
  • 但是当语义 HTML 可以完成工作时,不要过度使用 ARIA

4. 为图片提供替代文本

  • 对重要图片使用有意义的 alt=""
  • 使用 alt="" 来隐藏装饰性的

5. 颜色对比度和焦点指示器

  • 确保文本具有高对比度(根据 WCAG AA/AAA 检查)
  • 不要删除焦点轮廓——如果需要,可以自定义它们

6. 表单错误处理

  • 使用 aria-scribeby 链接表单错误
  • 在模糊或提交时进行验证,而不仅仅是在更改时

确保年度合规性的工具

  • axe DevTools(Chrome 扩展程序)——实时分析 WCAG 违规行为
  • eslint-plugin-jsx-a11y — 查找缺失的角色、替代文本、标签陷阱
  • Lighthouse (Chrome/CI) — 审核中的 a11y 评分
  • 屏幕阅读器:NVDA(Windows)、VoiceOver(macOS)、ChromeVox

现实世界的可访问性审计技巧

  • 仅使用键盘即可导航整个应用程序
  • 使用屏幕阅读器浏览常见流程
  • 使用 contrast-ratio.com 等工具测试颜色对比度
  • 避免可能引发运动障碍的动画(尊重prefers-reduced-motion

编写可测试的无障碍代码

  • 使用自动化测试框架(如 Jest + Testing Library)确保交互元素的可访问性
  • 示例:getByRole('button', { name: /提交/i }) 能验证按钮是否具备正确语义
  • 利用 axe-core 集成到测试流程中,防止无障碍回归

组件库与设计系统中的无障碍策略

  • 选择已通过 WCAG 检测的组件库(如 Reach UI、Radix UI)
  • 在设计阶段加入辅助功能审核,比如组件状态下的焦点样式、键盘操作流
  • 定义通用的 a11y 设计 token,例如:焦点边框、aria 属性规范、alt 文案策略

团队协作与文化建设

  • 将无障碍视为团队代码评审中的一部分,而不是事后补救
  • 对设计师、开发者、测试人员进行基础的无障碍培训
  • 分享无障碍提升成果(如 Lighthouse 分数、用户正反馈),增强团队使命感

最终要点:以人为本,确保代码面向未来

Web 应该属于所有人
作为开发者,我们的代码承载着一种责任——让技术成为助力,而非障碍。

从一个有意义的 alt 文案,到为按钮保留焦点样式,哪怕是一个小小的调整,也可能极大改善一位用户的体验。

别等需求单提出来才去优化无障碍。把 a11y 当作质量保障的一部分,你做出的产品会更稳定、更持久,也更被信任。

基于uniapp+nodejs实现小程序登录功能

本系列教程,以【中二少年工具箱】小程序为案例demo,具体效果请在微信中搜索该小程序查看。或在微信输入框输入 【#小程序://中二少年工具箱/6buitXgPnjHV21r】


一、概述

1.1 技术选型:

小程序端:uniapp

后端:nodejs+midwayjs+typeorm

数据库:mysql

1.2 登录功能实现方案:

1.小程序端调用接口uni.login获取随机code

2.将随机code传递给后端接口,后端接口中调用小程序官方api,获取用户信息

3.后端将用户信息保存到数据库中用户信息表,并将保存结果返回给前端

4.前端缓存用户信息,并显示

二、小程序端实现

代码实现:

 function getUserInfoByWx() {
        isLoad.value = true

        uni.login({
            provider: 'weixin', //使用微信登录
            success: function (loginRes) {
                const userData = {
                    code: loginRes.code
                }
                // console.log('userData',userData);
                getUserInfoByWxApi(userData).then(res => {
                    console.log(res)
                    if (res.success) {
                        userInfoStore.setUserInfo({
                            userName: res.data.userName,
                            openidWx: res.data.openidWx
                        })
                    } else {
                        openMessage({
                            text: '自动创建用户出错,请点击登录手动创建'
                        })
                    }
                }).catch(err => {
                    console.log('eeeeeeeeeeeeeee', err)
                    openMessage({
                        text: '登录失败,请联系开发者'
                    })
                })
                    .finally(() => {
                        // debugger
                        isLoad.value = false
uni.$emit('loginFinish');
                    })
            },
            fail() {
                isLoad.value = false
            }
        });
    }

代码解释:

1.isLoad:前端是否显示正在登录的动画。

2.uni.login:uniapp提供的登录api,可以生成各平台的临时code。

3.getUserInfoByWxApi:调用后端接口,将临时code作为参数传递给后端,后端再调用官方接口完成登录。

4.userInfoStore.setUserInfo登录成功后,在全局状态管理中保存用户信息

上面的代码,大部分都是和前端登录相关的业务代码,真正核心的是生成了临时code并传递给后端,因为调用官方接口只能在后端代码中运行。

三、后端实现

后端代码实现可分为两步,一是调用官方接口,获取小程序官方返回的用户信息;二是根据业务需求,将用户信息保存到我们的数据库中。

controller层代码实现;

  @Post('/getUserInfoByWx')
  async getUserInfoByWx(@Body() userData: { code: string }) {
    const openidRs = await this.loginService.getOpenidWx(userData)
    const openidKey = 'openidWx'
    const rs = await this.loginService.getUserInfoByPlat(openidRs, openidKey)
    return rs
  }

上面代码的code就是小程序端传入的临时code,主要用于getOpenidWx方法中,获取调用官方接口后的返回结果。

3.1 调用官方接口

上面代码中的getOpenidWx方法即是调用官方接口:

 const openidRs = await this.loginService.getOpenidWx(userData)

具体的service实现:

  /*根据临时code,获取wx返回的登录信息*/
  async getOpenidWx(userData:{code:string} | any): Promise<any> {
    const url = 'https://api.weixin.qq.com/sns/jscode2session';
    const data = {
      appid: 'wx9cxxxxxxxxxxxxx',
      secret: '66bxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      js_code: userData.code,
      grant_type: 'authorization_code'
    }
    const result = await this.httpService.get(url, {params: data})
    return result
  }

官方的文档如下:

在这里插入图片描述 结合文档和我写的接口示例,我为大家总结了关键点:

1.接口通过GET方式访问

2.参数包括appid、secret、js_code、grant_type都是通过url参数的方式传递。

3.appid+secret:开发者的身份认证,通过开发管理平台获取:

在这里插入图片描述

4.js_code:小程序端传递来的临时code

5.grant_type:照官网写,别问为什么。

返回结果中我们需要重点关注的参数是openid,这是每一个用户的唯一标识。

3.2 保存用户信息到数据库

上面controller层一共调用了两个方法,一个是上面的调用官方接口,另一个就是保存用户信息到数据库并返回用户信息:

  const rs = await this.loginService.getUserInfoByPlat(openidRs, openidKey)

上面是兼容各平台的写法,我们可以忽略openidKey参数,只以微信小程序为例,具体的service实现为:

/**
   * 根据各平台的openid获取用户信息,可用于首次登录时,自动注册*/
  async getUserInfoByPlat(openidRs,openidKey:string){
    // debugger
    // 通过三方平台的
    let rs:any={}
    let findWxUserRs = new WxUser()
    if (openidRs.status == 200 && (openidRs.data.openid || openidRs.data.data.openid)) {
      const userInfo={}
      userInfo[openidKey]=openidRs.data.openid || openidRs.data.data.openid
      findWxUserRs = await this.wxUserService.getWxUserByUserInfo(userInfo) || new WxUser()
    }

    if (findWxUserRs && findWxUserRs.id) {
      //用户已注册,获取用户信息
      if(!findWxUserRs.userExtraEntity){
        // 兼容旧数据,若没有extra信息,则创建
        rs = await this.wxUserService.saveWxUser(findWxUserRs)
      }else{
        rs=findWxUserRs
      }
    } else {
      //用户未注册,则保存并登录
      // /*TODO:tt和wxopenid的层级不同,需要改造*/
      findWxUserRs[openidKey] = openidRs.data.openid || openidRs.data.data.openid
      rs = await this.wxUserService.saveWxUser(findWxUserRs)
    }
    return rs
  }

代码解释:

1.openidRs是调用官方接口的方法返回的用户信息,如果它的status为200,并且openid有值,则说明调用官方接口成功。判断里之所以有两种层级,可能是因为某个平台的返回结果比较奇葩,代码过于久远,我也记不清了。

2.findWxUserRs是以openid作为筛选条件,筛选数据库的用户表,第一版代码不用像我写这么麻烦,我的openid可能分为openidWX,openidTT等等。第一次做这个功能,就在用户表里增加字段openid即可,然后根据这个字段筛选用户表。

3.如果以openid为筛选条件查到了用户信息,说明用户已注册,返回查询到的数据库中的用户信息。不用关心我的userExtraEntity对象判断,我的用户表结构发生过变化,为了兼容旧数据,这里做了判断。

4.如果以openid为筛选条件未查询到信息,则说明用户未注册,应该主动注册用户。注册用户的逻辑就因人而异了,我生成了随机的用户名称和用户id,再加上openid字段,保存了基本的用户信息。

5.保存成功后,返回用户信息。

我们要理清楚一个概念:用户信息。

通过官方接口获取的用户信息,是小程序官方提供返回结果,现阶段对我们最重要的是openid。

通过我们自己业务代码获取的用户信息,是返回数据库中的用户表信息。现阶段最重要的是用户名(userName)+id(表id,在我们业务中的唯一标识)+openid(用户在某小程序中的唯一标识)。

为什么不能用openid代替id成为我们业务表中的唯一标识,因为以后有可能还会集成其他小程序平台,openid在某小程序的各个场景中是唯一标识,但对于我们系统而言,它只是一个业务字段。

三、后端返回结果,前端显示

前端调用接口最终返回的结果,是保存在数据表中的用户信息:用户名+id+openid。

在上面前端代码中,成功调用接口后,主要做了两个操作:

      userInfoStore.setUserInfo({
                            userName: res.data.userName,
                            openidWx: res.data.openidWx
                        })

...省略代码
uni.$emit('loginFinish');

代码解释:

1.userInfoStore.setUserInfo:是维护全局状态管理中的用户信息,并且使用pinia做成响应式,只要改变,小程序端的页面就会显示对应用户名。

在这里插入图片描述

2.uni.$emit('loginFinish'):是一个事件通知机制。当登录模块所有操作完成,再触发loginFinish。其他组件中需要等待登录操作完成后才能执行的代码,需要严格控制执行顺序的代码,就在合适的位置插入uni.$on()监听。


总结

博主的大部分demo示例都会放到:中二少年学编程的示例项目。戳链接,查看示例效果。如果链接失效,请手动输入地址:lizetoolbox.top:8080/#/

本文知识点总结:

1.uni.login获取登录随机code,传递给后端接口。

2.后端代码中,调用官方接口获取平台返回的openid

3.将openid更新到数据库的用户信息表,如果没有该用户,则创建。

4.后端接口返回用户信息,在前端显示并执行后续操作

有任何前端项目、demo、教程需求,都可以联系博主,博主会视精力更新,免费的羊毛,不薅白不薅!~

手把手教你实现一个大白兔效果

前言

继上次实现一个树苗生长的效果后,这次我就带大家来实现一个活蹦乱跳的大白兔的效果,纯CSS实现,十分简单,没有花里胡哨的技巧。话不多说,咱们直接进入主题。

效果预览

最终实现的相关效果如下。

HTML、CSS部分

这里是类名为.rabbit的动画部分,相关代码如下。

.rabbit {
    width: 5em;
    height: 3em;
    color: whitesmoke;
    background: 
        radial-gradient(
            circle at 4.2em 1.4em,
            #333 0.15em,
            transparent 0.15em
        ), /* eye */
        currentColor;
    border-radius: 70% 90% 60% 50%;
    position: relative;
    box-shadow: -0.2em 1em 0 -0.75em #333;
    z-index: 1;
    animation: hop 1s linear infinite;
}

这里定义了一个名为.rabbit的类,用于创建一个卡通兔子图案的动画效果。设置元素的宽度为5em,高度为3em(em是相对单位,基于当前元素的字体大小)。设置文本颜色为whitesmoke(浅灰白色),这里也用作后续currentColor的参考值。

使用了两层背景:

  1. 眼睛:通过radial-gradient创建一个圆形径向渐变,模拟兔子的眼睛:
    • 圆心位于4.2em 1.4em(相对元素左上角)。
    • 从中心开始,#333(深灰色)覆盖半径为0.15em,之后透明。
  2. 身体颜色currentColor继承自color: whitesmoke,因此兔子身体为浅灰白色。

设置元素的四个角为不同的圆角百分比,形成不规则的椭圆形,模拟兔子的头部形状。简单来说,这里创建了一个简单的卡通兔子头部以及浅灰白色的不规则圆形身体。

随后定义了一个名为hop的动画,持续时间1s,速度曲线linear(匀速)并且无限循环。动画分为几个关键帧阶段,每个阶段设置了不同的 transform(变形)和 box-shadow(阴影)效果,使兔子看起来像是在跳跃并轻微旋转。相关代码如下。

@keyframes hop {
    20% {
        transform: rotate(-10deg) translate(1em, -2em);
        box-shadow: -0.2em 3em 0 -1em #333;
    }
    40% {
        transform: rotate(10deg) translate(3em, -4em);
        box-shadow: -0.2em 3.25em 0 -1.1em #333;
    }
    60%, 75% {
        transform: rotate(0deg) translate(4em, 0);
        box-shadow: -0.2em 1em 0 -0.75em #333;
    }
}

最终整体的动画效果如下。

  1. 0% → 20%:兔子向左倾斜,开始跳跃。
  2. 20% → 40%:兔子向右倾斜,达到跳跃最高点。
  3. 40% → 60%:兔子恢复水平,开始下落。
  4. 60% → 75%:兔子完成跳跃,回到地面(但水平位置向右移动 4em)。

这样兔子会在每次跳跃后回到起点,而不是一直向右移动。

最后就是兔子耳朵和眼睛的CSS部分,相关代码如下。

/* ears */
.rabbit::before {
    content: '';
    position: absolute;
    width: 0.75em;
    height: 2em;
    background-color: currentColor;
    border-radius: 50% 100% 0 0;
    transform: rotate(-30deg);
    top: -1em;
    right: 1em;
    border: 0.1em solid;
    border-color: gainsboro transparent transparent gainsboro;
    box-shadow: -0.5em 0 0 -0.1em;
}

/* tail and legs */
.rabbit::after {
    content: '';
    position: absolute;
    width: 1em;
    height: 1em;
    background-color: currentColor;
    border-radius: 50%;
    left: -0.3em;
    top: 0.5em;
    box-shadow: 
        0.5em 2em 0,
        4.2em 1.75em 0 -0.2em,
        4.4em 1.9em 0 -0.2em;
    animation: kick 1s 0.4s infinite linear;
}

这里为 .rabbit 元素添加了 耳朵(::before尾巴/腿(::after 的伪元素,进一步完善了兔子的外观和动画效果。

整体效果就是绘制了一只左耳(旋转 -30deg),并通过 box-shadow 生成右耳,耳朵内侧有浅灰色描边,增强立体感。带有一个圆形尾巴,生成两条前腿和一条后腿。最后应用 kick 动画,让腿在跳跃时摆动。

@keyframes kick {
    60% {
        box-shadow: 
        0.5em 1em 0,
        4em 1em 0 -0.2em,
        4em 1em 0 -0.2em;
    }
}

在兔子跳跃时的上升阶段(0%–40%),身体向上,腿部保持初始位置,下落阶段(60%),腿部短暂上收,模拟蹬腿动作,最后的落地阶段(75%–100%),腿部恢复原位。

这样,兔子会有更生动的跳跃效果! 🐰💨

总结

以上就是整个效果的实现过程了,纯 CSS 实现,代码简单易懂。另外,感兴趣的小伙伴们还可以在现有基础上发散思维,比如增加点其他效果,或者更改颜色等等。关于该效果如果大家有更好的想法欢迎在评论区分享,互相学习。最后,完整代码在码上掘金里可以查看,如果有什么问题大家在评论区里讨论~

初学langchain 从0开始执行一个llm任务

简单用langchain执行一个llm任务

哈喽哈喽我是你们的金樽清酒。是的,我已经上班一段时间了。感觉工作内容对我来说学习到的东西很有限。大部分都是增、删、改、查。当然也是我能力没有到位,无法完全胜任从事一些很高难度的任务再加上基本需求都是要赶工期的一部分领导的压力也会让你无法静下心来提升自己而是陷入焦虑。所以其实前一段时间我迷茫了,难道一辈子crud嘛,决定学习一点新的东西,打开买了一段时间的langchain小册开始langchain的第一课。

了解deno和Jupter NoteBook

啥是deno?啥是Jupter NoteBook?我相信很多人对这两个东西会很陌生,包括我第一次看到也是。那让我们一一来了解一下吧。

deno

deno和node一样是javascript的运行时环境。是node的原作者为了解决node的历史遗留问题而创建的。

  • 核心区别
🔐 安全性
Deno Node.js
权限控制 默认沙箱化,需显式授权(如 --allow-read 默认无限制,直接访问系统资源
示例 deno run --allow-net script.ts node script.js
📦 模块系统
Deno Node.js
导入方式 URL 或 import map(支持远程模块) require() 或 import
依赖管理 无 node_modules,直接缓存全局依赖 需要 npm install 和 package.json
示例 import { serve } from "https://deno.land/x/std/http/server.ts"; const fs = require('fs');

deno相对于node我觉得最主要的区别就是模块引入的方式。将commonJS规范换成了ESModule的形式。而且默认支持TypeScript。

Jupter NoteBook

Jupyter Notebook 是数据科学、机器学习和科学计算领域的重要工具,广泛应用于教育、研究和工业领域。算是机器学习领域的常客了。那他有什么优势呢? 交互式编程环境:可以分段执行代码,立即查看结果。 我们来看一下它的演示吧,这样可以更好的理解Jupter NoteBook的优势。

截屏2025-06-29 17.30.45.png 这就是Jupter NoteBook的使用界面了,然后右上角我使用的环境是deno。那为什么Jupter NoteBook具有优势呢。那是因为调用大模型型是耗时耗钱的。而Jupter NoteBook可以分段代码运行。由于Jupter Note没有代码提示,我们可以在vscode里面安装JupterNote插件,在vscode里面运行。

截屏2025-06-29 17.42.47.png

截屏2025-06-29 17.43.10.png 如上图所示是我运行的一个简单的llm任务。可以看到它是一个个的代码段,我可以先执行上面的代码然后返回调大模型所用的结果。然后在另一个段我可以打印结果或者对字符串进行处理。当上面的代码段执行完之后结果就不会变了,那我们就可以在下面反复执行,切割字符串调试。如果我们修改了上面的代码要重新运行,但是不会执行下面的代码。也就是说每个代码段的执行可以是独立的。这样碰到耗时或者耗钱的请求而我们又需要频繁调试的时候这是一个很好的选择。恰巧调大模型就是这样的耗时耗钱的任务。

如何安装deno和Jupter NoteBook

如何去学习和安装一个东西。那最好的方式就是看文档。

deno: docs.deno.com/runtime/get…

jupter NoteBook: jupyter.org/install

截屏2025-06-29 17.52.31.png 安装deno有很多种方式,这里我是选择npm install -g deno,在安装完之后检查deno的版本号,出现版本号而不是deno not found那就成功了。

截屏2025-06-29 17.54.59.png

对于Jupter NoteBook我们要用pip去安装。

截屏2025-06-29 18.02.40.png 如果电脑上没有python环境需要去安装python环境。注意python3安装的话使用pip3.

当然macos系统可以用HomeBrew去安装。同样检查一下版本号。 截屏2025-06-29 18.04.17.png

出现版本号之后就可以输入jupyter notebook启动。 当然我比较喜欢在vscode中用插件来运行。

截屏2025-06-29 18.13.04.png

在都安装完之后我们为使用 deno 为 Jupyter Notebook 配置 kernel

deno jupyter --unstable --install

截屏2025-06-29 18.17.17.png 当出现如图上面的提示说明成功了。这样我们运行Jupter Note的时候就是以deno为内核环境。

执行一个简单的llm任务

现在我们就要开始执行一个简单的llm任务啦。 我是在vscode里面用插件执行的,因为用插件会有代码提示。首先到插件市场找到Jupter插件安装。然后我们创建一个.ipynb后缀的文件。

截屏2025-06-29 18.24.52.png

如下图点击➕代码增加一个代码块 截屏2025-06-29 18.22.50.png

在代码块里面输入下面的代码

import { ChatOpenAI } from "@langchain/openai";
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
import { StringOutputParser } from "@langchain/core/output_parsers";
const chatModel = new ChatOpenAI({
    configuration: {
        baseURL: "https://api.deepseek.com",
        apiKey: "your-api-key-here",
    },
    modelName: "deepseek-chat",
});
const outputPrase = new StringOutputParser();
const simpleChain = chatModel.pipe(outputPrase)
const res1 = await chatModel.invoke([
    new SystemMessage("用中文输出。"),
    new HumanMessage("请给我讲一个程序员的笑话"),
]);

新建一个deno.json文件粘贴下面的依赖项配置

{   
    "nodeModulesDir": "auto",
    "tasks": {
    "dev": "deno run --watch main.ts"
    },
    "imports": {
    "@langchain/community/": "npm:/@langchain/community/",
    "@langchain/community": "npm:@langchain/community@^0.3.34",
    "@langchain/core": "npm:@langchain/core@^0.3.42",
    "@langchain/deepseek": "npm:@langchain/deepseek@^0.0.2",
    "@langchain/ollama": "npm:@langchain/ollama@^0.2.0",
    "@langchain/openai": "npm:@langchain/openai@^0.4.4",
    "@std/assert": "jsr:@std/assert@^1.0.11",
    "cheerio": "npm:cheerio@^1.0.0",
    "dotenv": "npm:dotenv@^16.4.7",
    "ignore": "npm:ignore@^7.0.3",
    "langchain": "npm:langchain@^0.3.19",
    "lodash": "npm:lodash@^4.17.21",
    "pdf-parse": "npm:pdf-parse@^1.1.1",
    "zod": "npm:zod@^3.24.2"
    }
}

截屏2025-06-29 18.30.38.png 然后点击左上角执行,执行成功后下面会有个执行时间。然后我们再添加一个代码块打印执行结果。

截屏2025-06-29 18.32.59.png 这样我们第一个langchain任务就完成啦。注意这个apiKey要替换成你们的真实的key哦,而且千万不要泄漏这个key。

如何获取key

这里我以deepseek的apiKey为例。我们来到deepseek的官网,点击右上角的API开放平台。

截屏2025-06-29 18.34.46.png

截屏2025-06-29 18.36.24.png 点击APIkeys创建APIkey。也可以看看它的文档。

总结

在运行第一个langchain任务之前我们需要准备安装deno和Jupter Notebook。deno跟node一样是js的运行时环境但是用ESmodule的形式引入文件依赖而且开箱支持ts。Jupter Note可以让代码分代码块运行。对于一些耗时且耗钱的任务可以很好的方便我们调试。对于API key的获取我们可以在deepseek官网上获取,但是要花钱的哦。

symbol作为对象属性

一、基础概念

1. 定义与特性

  • 唯一性:Symbol 是 ES6 新增的原始数据类型,通过 Symbol() 函数创建,每个 Symbol 值都是唯一的。

    const s1 = Symbol('key');
    const s2 = Symbol('key');
    s1 === s2; // false
    
  • 不可枚举性:默认不会出现在 for...inObject.keys() 中,常用于创建对象私有属性。

    const obj = { [Symbol('private')]: 'value' };
    Object.keys(obj); // []
    

2. 语法

// 创建 Symbol
const sym = Symbol('description'); // description 仅用于调试,不影响唯一性

// 作为对象属性
const obj = {
  [sym]: 'data',
};

二、核心应用场景

1. 作为对象属性键(避免命名冲突)

const NAME = Symbol('name');
class Person {
  constructor() {
    this[NAME] = '张三';
  }
  getName() {
    return this[NAME];
  }
}

2. 定义私有属性/方法

const PRIVATE_METHOD = Symbol();
class MyClass {
  [PRIVATE_METHOD]() { /* 私有逻辑 */ }
  publicMethod() {
    this[PRIVATE_METHOD](); // 内部可访问
  }
}

3. 实现自定义迭代器

const obj = {
  [Symbol.iterator]: function*() {
    yield 1;
    yield 2;
  }
};
[...obj]; // [1, 2]

4. 模拟枚举类型

const DIRECTION = {
  UP: Symbol('up'),
  DOWN: Symbol('down'),
};

三、内置 Symbol(元编程)

ES6 提供了一些内置的 Symbol,用于修改对象的默认行为:

内置 Symbol 作用 示例
Symbol.iterator 定义对象的迭代器方法 obj[Symbol.iterator] = function() { ... }
Symbol.toStringTag 自定义 Object.prototype.toString() 的返回值 obj[Symbol.toStringTag] = 'MyClass'
Symbol.toPrimitive 定义对象转换为原始值的逻辑 obj[Symbol.toPrimitive] = hint => ...
Symbol.species 指定创建衍生对象时使用的构造函数 class MyArray extends Array { static get [Symbol.species]() { ... } }
Symbol.hasInstance 自定义 instanceof 行为 class MyClass { static [Symbol.hasInstance](obj) { ... } }

四、问题

1. 问:Symbol 为什么适合作为对象属性?

 - **唯一性**:避免属性名冲突(如混入多个第三方库时)。  
 - **不可枚举性**:默认不会被遍历,适合隐藏实现细节。  

2. 问:如何获取对象的 Symbol 属性?

 ```javascript
 const sym = Symbol('key');
 const obj = { [sym]: 'value' };
 
 // 方法1:使用 Object.getOwnPropertySymbols()
 Object.getOwnPropertySymbols(obj); // [Symbol(key)]
 
 // 方法2:使用 Reflect.ownKeys()(获取所有键,包括 Symbol)
 Reflect.ownKeys(obj); // [Symbol(key)]
 ```  

3. 问:Symbol 与 WeakMap 结合实现私有属性的优势?

 ```javascript
 const privateData = new WeakMap();
 class Person {
   constructor() {
     privateData.set(this, { age: 18 });
   }
   getAge() {
     return privateData.get(this).age;
   }
 }
 ```  
 - **优势**:  
   - 真正私有(外部无法访问 `privateData`)。  
   - 垃圾回收友好(对象被销毁时,WeakMap 中的引用自动释放)。  

4. 问:Symbol 能被序列化吗?

 ```javascript
 const obj = { [Symbol('key')]: 'value' };
 JSON.stringify(obj); // {}(Symbol 属性会被忽略)
 ```  
 - **原因**:Symbol 是唯一且不可预测的,序列化后无法保证唯一性。  

疏通经脉: Bridge 联通逻辑层和渲染层

本节概述

经过前面两节的开发,我们已经完成了小程序逻辑线程和 UI 线程的启动引擎准备,这节开始,我们将完善 native bridge 层的搭建,构建起逻辑线程和UI线程之间的桥梁。

开始之前我们先来回顾一下逻辑引擎小节相关的流程图:

image.png

一次小程序的启动过程,我们在创建好小程序的 逻辑引擎worker 和 绘制引擎webview 之后,从启动到渲染依次会经过:

  1. 通知 webview 加载小程序资源,如果是首次启动,还需要通知逻辑线程加载资源(非首次启动则不用,一个小程序的逻辑 worker 层是公用的)
  2. 资源加载完毕后,开始通知逻辑线程创建应用实例
  3. 实例初始化完毕,请求 worker 线程获取小程序初始化渲染数据
  4. bridge 将worker层获取到的初始化数据发送给ui线程,ui线程启动渲染
  5. ui渲染完毕通过bridge 通知给逻辑worker,触发小程序的生命周期函数

在前面的双线程结构的小节中,我们已经完成了前置的: 创建worker创建webview 的准备。现在我们继续在其基础上连接起逻辑线程引擎ui线程引擎,打通经脉,启动小程序渲染

环境准备

在开始之前,我们先在之前小节的基础上调整下代码环境: 当时我们创建 webview 的时候,是模拟的小程序的配置信息。现在我们来模拟一个小程序的配置文件,然后通过网络请求读取配置信息后再注入

先创建一个小程序的编译后的配置文件,放在public目录下方便直接通过服务加载:

// config.json,这个配置文件的内容也是和我们上两节模拟的小程序逻辑代码和页面代码一致对应的
{
  "app": {
    "entryPagePath": "pages/home/index",
    "pages": [
      "pages/home/index"
    ],
    "window": {
      "navigationBarBackgroundColor": "#ffffff",
      "navigationBarTextStyle": "black",
      "navigationBarTitleText": "微信接口功能演示",
      "backgroundColor": "#eeeeee"
    },
    "tabBar": [],
    "networkTimeout": {},
    "debug": true
  },
  "modules": {
    "pages/home/index": {
      "navigationBarBackgroundColor": "#ffd200",
      "navigationBarTextStyle": "black",
      "navigationBarTitleText": "美团",
      "backgroundColor": "#fff",
      "usingComponents": {}
    }
  }
}

src/native/miniApp.ts 文件夹下的 init 方法中,我们进行下调整:

async init() {
  // 模拟读取小程序配置文件信息
+ const configPath = `/${this.app.appId}/config.json`;
+ const res = await fetch(configPath).then(res => res.text());
+ this.appConfig = JSON.parse(res);

  // 获取小程序入口文件配置: 传入的path 或者 配置文件中的 entryPagePath
+ const entryPagePath = this.app.path || this.appConfig!.app.entryPagePath;
  // 入口页面对应的页面配置信息
+ const pageConfig = this.appConfig!.modules?.[entryPagePath];

  const entryPageBridge = await this.createBridge({
    jscore: this.jscore,
    isRoot: true,
    appId: this.app.appId,
    pagePath: this.app.path,
    pages: this.appConfig!.app?.pages,
    query: this.app.query,
    scene: this.app.scene,
    configInfo: mergePageConfig(this.appConfig!.app, pageConfig), // 合并配置信息,主要是页面配置和全局window配置信息的合并
  });
}
export function mergePageConfig(appConfig: Record<string, any>, pageConfig: Record<string, any>) {
  const result: Record<string, any> = {};
  const appWindowConfig = appConfig.window || {}; // 全局window配置信息
  const pagePrivateConfig = pageConfig || {};     // 页面对应的配置信息

  result.navigationBarTitleText = pagePrivateConfig.navigationBarTitleText || appWindowConfig.navigationBarTitleText || '';
result.navigationBarBackgroundColor = pagePrivateConfig.navigationBarBackgroundColor || appWindowConfig.navigationBarBackgroundColor || '#000';
result.navigationBarTextStyle = pagePrivateConfig.navigationBarTextStyle || appWindowConfig.navigationBarTextStyle || 'white';
result.backgroundColor = pagePrivateConfig.backgroundColor || appWindowConfig.backgroundColor || '#fff';
result.navigationStyle = pagePrivateConfig.navigationStyle || appWindowConfig.navigationStyle || 'default';

  return result;
}

完善webview消息通信

在前面实现webview管理模块的时候,我们预留了消息通信相关的实现,经过上一小节 UI 引擎的实现我们可以知道,bridge 侧和ui线程的通信我们直接通过挂载ui全局window上的 JSBridge 对象来完成。bridge 侧需要添加 onReceiveUIMessage API给ui线程侧调用,来发送消息到bridge 侧

src/native/webview/index.ts 文件中我们来完善通信的逻辑;

async init(callback: () => void) {
  // 等待frame 加载完成
  await this.frameLoaded();
  const iframeWindow = window.frames[this.iframe.name];
  // 给webview内部的JSBridge对象添加 onReceiveUIMessage 方法
  iframeWindow.JSBridge.onReceiveUIMessage = (message: IMessage) => {
    this.event.emit('message', message);
  }
  callback && callback();
}

postMessage(message: IMessage) {
  const iframeWindow = (window.frames as any)[this.iframe.name];
  if (iframeWindow) {
    // 触发webview内部 JSBridge对象上的 onReceiveNativeMessage 方法完成通信
    iframeWindow.JSBridge.onReceiveNativeMessage(message);
  }
}

启动页面渲染

从上面分析的流程中我们可以发现,启动过程的触发点只需要通知两个线程加载资源即可,后续的过程将有两个线程的消息来持续推进。

现在我们来实现一个启动渲染的方法,开始让两个线程工作:

// src/native/bridge/index.ts
/**
 * bridge 通知逻辑线程和UI线程加载小程序资源
 */
start(loadLogicSource = true) {
  // 通知UI线程加载资源
  this.webview?.postMessage({
    type: 'loadResource',
    body: {
      appId: this.opts.appId,
      pagePath: this.opts.pagePath,
    }
  });

  // 初始化触发一次小程序逻辑资源加载
  if (loadLogicSource) {
    this.jscore.postMessage({
      type: 'loadResource',
      body: {
        appId: this.opts.appId,
        bridgeId: this.id,
        pages: this.opts.pages,
      }
    });
  } else {
    this.status++;
  }
}

这里有个参数是是否需要逻辑线程加载资源,经过前面小节的介绍其实我们可以快速的知道,因为一个小程序的逻辑线程worker是公用的,在初次启动后,后面就可以不用再继续加载了。

同时逻辑中还有一个 status 字段,这个状态字段是用于判断小程序进行到哪一步了,是否可以进行某一个等;

比如小程序要启动创建App实例,就需要两侧线程的资源都加载准备完毕,此时 status 的状态就需要变到 2 才能继续往下进行(ui线程资源加载完毕+1 和 逻辑线程资源加载完毕+1)

现在启动的契机开始之后,后续就是完成bridge监听两侧线程的消息,来推进逻辑的渲染:

逻辑线程消息监听

逻辑线程的启动事件通知包括:

  • logicResourceLoaded 逻辑线程资源加载完毕,如果此时 status 为 2,及ui侧也完毕时,启动App实例创建
  • appIsCreated 逻辑线程App创建完毕,后面要开始通知逻辑线程初始化渲染数据
  • initialDataReady 初始化渲染数据创建完毕返回,bridge 要通知 ui 线程挂载页面了
  • updateModule 逻辑线程侧调用了 setData api更新了数据,需要把新的数据发送个ui线程重新渲染
jscoreMessageHandler(message: IMessage) {
  console.log('接收到来自于逻辑线程的消息: ', message);
  const { type, body } = message;
  // 判断 bridgeId 是否对应
  if (body.bridgeId !== this.id) return;
  switch (type) {
    case 'logicResourceLoaded':
      this.status++;
      this.createApp(); // 逻辑线程和UI准备好之后就可以开始创建App了
      break;
    case 'appIsCreated':
      this.status++;
      this.notifyMakeInitialData(); // 通知逻辑线程初始化小程序渲染数据
      break;
    case 'initialDataReady':
      this.status++;
      this.setInitialData(body); // 把逻辑线程的初始化数据设置给UI线程,UI线程开始渲染页面
      break;
    case 'updateModule':
      this.updateModule(body); // 逻辑线程调用setData 更新数据,通知UI渲染
    }
}

// 通知逻辑线程创建小程序App实例
createApp() {
  // 只有logic和ui线程的loadResource 都完毕后,才能开始创建,此时status会变成2
  if (this.status !== 2) return;
  this.jscore.postMessage({
    type: 'createApp',
    body: {
      bridgeId: this.id,
      scene: this.opts.scene,
      pagePath: this.opts.pagePath,
      query: this.opts.query,
    }
  });
}
// 通知逻辑线程初始化渲染数据
notifyMakeInitialData() {
  this.jscore.postMessage({
    type: 'makePageInitialData',
    body: {
      bridgeId: this.id,
      pagePath: this.opts.pagePath,
    }
  });
}
// 将逻辑线程初始化好的渲染数据发送给ui线程渲染页面
setInitialData(data) {
  const { initialData } = data;
  this.webview?.postMessage({
    type: 'setInitialData',
    body: {
      initialData,
      bridgeId: this.id,
      pagePath: this.opts.pagePath,
    }
  });
}
// 逻辑线程数据更新,通知ui线程重新渲染
updateModule(payload) {
  const { id, data } = payload;
  this.webview?.postMessage({
    type: 'updateModule',
    body: {
      id,
      data,
    }
  })
}

UI 线程启动消息处理

ui 线程启动过程主要包括的事件节点有:

  • uiResourceLoaded ui线程资源加载完毕,如果 status 为2,及逻辑线程也加载完毕,可以启动创建 App 实例
  • moduleCreated ui线程模块创建完毕(在绘制过程了),此时需要通知逻辑线程创建页面实例 PageModule
  • moduleMounted ui线程页面已经挂载好了,此时通知逻辑线程触发 ready 事件
  • triggerEvent ui线程事件交互,通知逻辑线程触发相应的处理函数
uiMessageHandler(message: IMessage) {
  console.log('接收到来自UI线程的消息: ', message);
  const { type, body } = message;
  switch (type) {
    case 'uiResourceLoaded':
      this.status++;
      this.createApp();
      break;
    case 'moduleCreated':
      this.uiInstanceCreated(body);
      break;
    case 'moduleMounted':
      this.uiInstanceMounted(body);
      break;
    case 'triggerEvent':
      this.triggerEvent(body);
      break;
  }
}
// ui线程模块创建好,通知逻辑线程可以创建页面实例了
// 这里后面真实触发的时机回调整为 vue created 状态时执行
uiInstanceCreated(payload) {
  const { path, id } = payload;
  this.jscore.postMessage({
    type: 'createInstance',
    body: {
      id,
      path,
      bridgeId: this.id,
      query: this.opts.query,
    }
  });
}
// ui挂载完毕,通知逻辑线程触发 ready
uiInstanceMounted(payload) {
  const { id } = payload;
  this.jscore.postMessage({
    type: 'moduleMounted',
    body: { id }
  });
}
// 用户事件,通知逻辑线程触发处理函数
triggerEvent(payload) {
  const { id, methodName, paramsList } = payload;
  this.jscore.postMessage({
    type: 'triggerEvent',
    body: {
      id,
      methodName,
      paramsList
    }
  })
}

经过上面的步骤之后,我们的启动过程就连接好了,此时运行项目点击美团小程序可以看到如下效果:

52fjp-jhu9m.gif

本小节的代码已发布至github仓库,可前往查看完整代码: mini-wx-app

巧妙实现,用CSS实现的拖拽留言板卡片功能

前言

我们实现一个拖拽留言板卡片,通常会使用js的方式去实现拖拽。这一次我们用纯CSS方式去实现,会带给你另一个思路,让你眼前一亮。

1. 基础知识

在说实现思路之前,我们先认识下一个css属性resize,用于设置元素是否可调整尺寸,以及可调整的方向。需要与overflow属性搭配使用。

resize 不适用于下列元素:

  • 内联元素
  • overflow 属性设置为 visible 的块元素

具体使用代码如下:

<style>
    div {
        position: relative;
        width: 100px;
        height: 100px;
        /* border: 5px solid deeppink; */
        /* 必须配合使用才能变化大小 */
        resize: both;
        /* 不可以是visible */
        overflow: auto;
    }
</style>

<div></div>

效果如下:

image.png

通过上述代码可以点击右下角拖动,可以变化大小。我们接下来可以通过这个属性就可以一步一步实现纯CSS拖动。

2. 实现思路

说实现思路之前,我们可以通过一张图就可以看出来实现,看下图所示:

image.png

通过基础知识我们可以知道,元素设置了resize即可进行拖拽大小,在此基础上,我们将留言板定位到resize元素的右下角,这样resize元素变化大小会将留言板的定位改变,就巧妙实现了拖拽的效果。

整体实现流程是:

  1. 利用resize属性进行拖动
  • 通过resize: both使元素可调整大小
  • 配合overflow: hidden(不能是visible)使resize生效
  1. 定位技巧
  • 主容器设为position: absolute作为定位基准
  • 可调整元素(.resize)设为position: relative
  • 内容区域(.content)通过position: absolute和负值偏移定位到右下角
  1. 联动效果
  • 当拖动.resize元素改变大小时
  • 由于.content的位置是相对于主容器的负值定位
  • 自然实现了内容区域随拖动而移动的效果

3. 具体实现

通过上面思路的讲解,我们直接写一下具体的代码。具体步骤是:

3.1 创建基本的HTML

<div class="main">
        <div class="resize"></div>
        <div class="content" contenteditable="true">hello guys, drag me</div>
    </div>

基本结构是:

  • resize:可调整大小的蓝色方块
  • content:黄色内容区域,设置contenteditable="true"即可编辑

3.2 css实现逻辑

  1. 主容器设置为positionabsolute.
.main {
    position: absolute;
    display: inline-block;
}

  1. 可调整元素(resize)的样式

设置初始宽高为200px,启用调整大小功能(resize: both)配合(overflow: hidden)生效,相对定位并设置z-index。

.resize {
    content: '';
    width: 200px;
    height: 200px;
    border: 50%;
    resize: both;
    overflow: hidden;
    position: relative;
    background-color: lightblue;
    z-index: 1;
}

.resize::-webkit-resizer {
    background-color: transparent;
}

  1. 内容区域(content)的样式

设置固定宽高(400x500px),黄色背景,设置绝对定位,相对于主容器向右下方偏移,设置为right: -380px;bottom: -480px;,需要减去左上角拖动的大小。可编辑属性设置为(contenteditable="true"),则可以进行编辑。

.content {
    box-sizing: border-box;
    width: 400px;
    height: 500px;
    background-color: yellow;
    padding: 30px;
    font-size: 24px;
    position: absolute;
    right: -380px;
    bottom: -480px;
    &::before {
        content: "";
        width: 20px;
        height: 20px;
        border-radius: 50%;
        background-color: red;
        position: absolute;
        top: 0;
        left: 0;
    }
}

效果如下:

image.png

将背景色去除:

image.png

4. 完整代码

完整代码如下:

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

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
       .main {
        position: absolute;
        display: inline-block;
       }
       .resize {
        cursor: pointer;
        content: '';
        width: 200px;
        height: 200px;
        border: 50%;
        resize: both;
        overflow: hidden;
        position: relative;
        /* background-color: lightblue; */
        z-index: 1;
       }
       .resize::-webkit-resizer {
        background-color: transparent;
       }
       .content {
        box-sizing: border-box;
        width: 400px;
        height: 500px;
        background-color: yellow;
        padding: 30px;
        font-size: 24px;
        position: absolute;
        right: -380px;
        bottom: -480px;
        &::before {
            content: "";
            width: 20px;
            height: 20px;
            border-radius: 50%;
            background-color: red;
            position: absolute;
            top: 0;
            left: 0;
        }
       }
    </style>
</head>

<body>

    <div class="main">
        <div class="resize"></div>
        <div class="content" contenteditable="true">hello guys, drag me</div>
    </div>

</body>

</html>

5. 总结

最后总结一下:这种实现方式展示了CSS的另一面,通过CSS的resize属性巧妙地组合现有属性,可以实现通常需要JavaScript才能完成的功能。

仅仅提供一种思路,实际应用谨慎使用,如有错误,请指正O^O!

带你快速了解关于图片懒加载

一、什么是图片懒加载

让我们问问豆包,它是这么回答的:

微信截图_20250629161432.png

一言蔽之,图片懒加载是一种网页性能优化技术,核心目的是减少页面初始加载时的资源请求量,让图片在用户真正需要看到它时才进行加载,从而提升页面加载速度和用户体验。

二、为什么需要图片懒加载

所以为什么需要图片懒加载呢?让我们将继续追问豆包:

微信截图_20250629163331.png

相较于传统加载的一次性端上所有菜,懒加载则是吃一道上一道,这样的好处是你不用等所有图片都加载完,能更快看到内容,还不浪费流量(没看到的图片不提前加载)。

三、如何实现图片懒加载

首先我们需要解决图片提前加载的矛盾,因为如果直接在<img src>中写真实图片地址,浏览器会立即发起请求,违背懒加载 “按需加载” 的原则。

这该如何解决呢?我们可以用自定义属性(如 data-original)  存储真实图片 URL,src会先赋值为占位符(小尺寸透明图或低清图) ,仅在图片进入可视区域时才将data-original的值赋给src

    <img
      class="image-item"
      lazyload="true"
      src="预加载图片地址"
      data-original="真实图片地址"
    />

这样当页面打开时,会先显示占位图(不影响布局),当用户滚动到该商品时,JS触发加载真实商品图,提升浏览流畅度。

而懒加载的核心逻辑是:先不加载所有图片,等图片出现在浏览器可视区域时,再偷偷加载它

要实现图片懒加载需要判断<img>标签是否出现在可视区域,还需要考虑浏览器窗口的大小以及页面的滚动区域。

这里我们可以用getBoundingClientRect ()方法

这个方法是判断元素是否在可视区域的关键,它返回一个 DOMRect 对象,包含元素相对于浏览器视口的位置信息。

const rect = element.getBoundingClientRect();
// 返回的rect对象包含:
{
  top: 100,     // 元素顶部到视口顶部的距离(px)
  bottom: 300,  // 元素底部到视口顶部的距离(px)
  left: 50,     // 元素左侧到视口左侧的距离(px)
  right: 250,   // 元素右侧到视口左侧的距离(px)
  width: 200,   // 元素宽度(px)
  height: 200,  // 元素高度(px)
  x: 50,        // 等同于left
  y: 100        // 等同于top
}

设置核心判断条件:元素顶部低于视口顶部,且元素底部高于视口底部

// 获取视口高度
const viewportHeight = window.innerHeight;

// 判断元素是否在视口内
function isInViewport(element) {
  const rect = element.getBoundingClientRect();
  return (
    rect.top <= viewportHeight &&  // 元素顶部在视口内或视口上方
    rect.bottom >= 0              // 元素底部在视口内或视口下方
  );
}

整体代码实现如下

// 1. 获取视口高度
const viewportHeight = window.innerHeight;

// 2. 获取所有需要懒加载的图片(通过自定义属性标记)
const lazyImages = document.querySelectorAll('img[data-src]');

// 3. 定义可视区域判断函数
function isInViewport(element) {
  const rect = element.getBoundingClientRect();
  // 核心判断条件:元素顶部低于视口底部,且元素底部高于视口顶部
  return (
    rect.top <= viewportHeight &&  // 元素顶部在视口内或视口上方
    rect.bottom >= 0              // 元素底部在视口内或视口下方
  );
}

// 4. 懒加载执行函数
function loadLazyImages() {
  lazyImages.forEach(img => {
    // 若图片已加载或无真实地址,跳过
    if (img.src === img.dataset.src || !img.dataset.src) return;
    
    // 检查图片是否在可视区域
    if (isInViewport(img)) {
      // 创建临时Image对象预加载图片(避免页面闪烁)
      const tempImg = new Image();
      tempImg.src = img.dataset.src;
      
      // 图片加载完成后替换src,并清理属性
      tempImg.onload = () => {
        img.src = tempImg.src;
        img.removeAttribute('data-src'); // 移除自定义属性
      };
      
      // 处理加载失败
      tempImg.onerror = () => {
        img.src = 'https://via.placeholder.com/100x100?text=加载失败';
      };
    }
  });
}

// 5. 初始加载与滚动监听
loadLazyImages(); // 加载首屏图片
window.addEventListener('scroll', loadLazyImages); // 滚动时检查新图片

代码执行流程

  1. 初始状态

    • 图片src为占位图(小尺寸加载快),真实地址存于data-src
    • 浏览器解析 HTML 后,首屏图片通过loadLazyImages()检查并加载。
  2. 滚动场景

    • 用户滚动页面时,scroll事件触发loadLazyImages()
    • 函数遍历所有图片,用getBoundingClientRect()判断位置;
    • 符合条件的图片通过new Image()预加载,完成后替换src
  3. 优化效果

    • 仅加载可视区域图片,减少初始请求量;
    • 预加载机制确保图片显示时已缓存,避免闪烁。

四、性能优化

示例代码存在几个缺点:

滚动事件高频触发:未使用节流 / 防抖,每秒可触发 60 次计算,导致浏览器卡顿

重复遍历图片:已加载图片仍被反复判断,浪费 CPU 资源

我们可以添加节流处理(避免频繁滚动触发)

function throttle(func, delay) {
  let timer;
  return function() {
    if (timer) return;
    timer = setTimeout(() => {
      func.apply(this, arguments);
      timer = null;
    }, delay);
  };
}
window.addEventListener('scroll', throttle(loadLazyImages, 200)); // 200ms执行一次

核心作用:限制函数在指定时间内的执行频率,避免高频触发导致性能问题。

The End

以上内容就是关于图片懒加载的实现。

使用 ECharts 绘制仪表盘

数据可视化的仪表艺术

仪表盘是现代数据可视化中不可或缺的组件,它能直观展示关键指标的状态和进度。本文将全面介绍如何使用 ECharts 这一强大的开源可视化库创建各种仪表盘。从基础仪表到高级多指针仪表,通过代码示例让你快速掌握仪表盘的设计与应用。

环境准备与基础配置

1. 引入 ECharts

  • 使用 npm:npm install echarts
  • CDN 引入:
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>

2. 准备容器

<div id="dashboard" style="width: 600px; height: 400px;"></div>

3. 初始化图表

const chart = echarts.init(document.getElementById('dashboard'));

基础仪表盘实现

const option = {
  series: [{
    type: 'gauge', // 核心:仪表盘类型
    center: ['50%', '60%'], // 中心位置
    startAngle: 180, // 起始角度
    endAngle: 0,    // 结束角度
    min: 0,         // 最小值
    max: 100,       // 最大值
    splitNumber: 10, // 分割段数
    
    // 仪表盘指针
    pointer: {
      show: true,
      length: '60%',
      width: 6
    },
    
    // 仪表盘详情(通常显示当前值)
    detail: {
      valueAnimation: true,
      formatter: '{value}%',
      fontSize: 20,
      offsetCenter: [0, '20%']
    },
    
    // 数据配置
    data: [{ value: 65 }], // 初始值
    
    // 刻度配置
    axisLine: {
      lineStyle: {
        width: 20, // 仪表盘宽度
        color: [[0.3, '#67e0e3'], [0.7, '#37a2da'], [1, '#fd666d']]
      }
    },
    
    // 刻度标签
    axisLabel: {
      distance: -30,
      fontSize: 12
    },
    
    // 分割线
    splitLine: {
      length: 18
    }
  }]
};

chart.setOption(option);

个性化仪表盘设计技巧

1. 自定义色彩分段

axisLine: {
  lineStyle: {
    color: [
      [0.2, '#52c41a'],  // 0-20% 绿色
      [0.5, '#faad14'],  // 20-50% 黄色
      [0.8, '#fa541c'],  // 50-80% 橙色
      [1, '#f5222d']     // 80-100% 红色
    ],
    width: 18
  }
}

2. 修改指针样式

pointer: {
  icon: 'path://M12.8,0.7l12,40.1H0.7L12.8,0.7z', // 自定义形状
  length: '50%',
  width: 20,
  itemStyle: {
    color: 'auto'  // 自动匹配当前分段色
  }
}

3. 添加动画效果

detail: {
  valueAnimation: true,  // 数值动画
  formatter: function(value) {
    return '{value|' + value.toFixed(0) + '}{unit|%}'; // 富文本格式
  },
  rich: {
    value: {
      fontSize: 28,
      fontWeight: 'bold',
      color: '#4d4d4d'
    },
    unit: {
      fontSize: 15,
      color: '#999'
    }
  }
}

进阶应用:多指针仪表盘

const option = {
  series: [{
    type: 'gauge',
    // ...基础配置...
    
    pointer: {
      // 隐藏默认指针
      show: false
    },
    
    data: [
      { value: 35, name: '内存使用' },
      { value: 70, name: 'CPU使用' },
      { value: 85, name: '磁盘IO' }
    ],
    
    // 配置多个指针
    pointer: [
      {
        show: true,
        length: '60%',
        width: 6,
        itemStyle: { color: '#37A2DA' }
      },
      {
        show: true,
        length: '50%',
        width: 6,
        itemStyle: { color: '#FF9F7F' },
        offsetCenter: [0, '-10%']
      },
      {
        show: true,
        length: '40%',
        width: 6,
        itemStyle: { color: '#67E0E3' },
        offsetCenter: [0, '-20%']
      }
    ],
    
    // 详细标签配置
    detail: {
      show: false  // 禁用默认值显示
    }
  }],
  
  // 添加图例显示多个指标
  legend: {
    data: ['内存使用', 'CPU使用', '磁盘IO'],
    bottom: 10
  },
  
  // 添加标题
  title: {
    text: '服务器监控仪表盘',
    left: 'center'
  }
};

环形进度条仪表盘

const option = {
  series: [{
    type: 'gauge',
    radius: '90%',     // 半径大小
    startAngle: 225,   // 起点角度
    endAngle: -45,     // 终点角度
    
    // 进度条样式
    progress: {
      show: true,
      width: 20,
      roundCap: true   // 圆角端点
    },
    
    // 刻度配置
    axisLine: {
      roundCap: true,
      lineStyle: {
        width: 20,
        color: [[1, '#f0f0f0']] // 背景色
      }
    },
    
    // 自定义指针(圆点)
    pointer: {
      show: true,
      icon: 'circle',
      length: '0%',
      width: 12,
      itemStyle: {
        color: '#1890ff'
      }
    },
    
    // 添加标签
    axisLabel: {
      show: false
    },
    detail: {
      valueAnimation: true,
      fontSize: 28,
      formatter: '{value}%',
      color: 'auto',
      offsetCenter: [0, '10%']
    },
    data: [{
      value: 75
    }]
  }]
};

实时更新仪表盘数据

// 初始化图表
const chart = echarts.init(document.getElementById('dashboard'));
chart.setOption(option);

// 更新函数
function updateGauge(newValue) {
  chart.setOption({
    series: [{
      data: [{ value: newValue }]
    }]
  });
}

// 模拟实时数据(每秒更新)
setInterval(() => {
  const value = Math.round(Math.random() * 100);
  updateGauge(value);
}, 1000);

// 响应式调整
window.addEventListener('resize', () => {
  chart.resize();
});

业务场景应用示例:健康监测仪表盘

const healthOption = {
  series: [{
    type: 'gauge',
    min: 0,
    max: 100,
    splitNumber: 5,
    axisLabel: {
      formatter: function(value) {
        // 健康等级标签
        if (value === 0) return '危险';
        if (value === 25) return '较差';
        if (value === 50) return '一般';
        if (value === 75) return '良好';
        if (value === 100) return '优秀';
        return '';
      },
      color: '#666',
      distance: -20
    },
    anchor: {
      show: true,
      size: 12,
      itemStyle: {
        borderWidth: 3
      }
    },
    pointer: {
      icon: 'path://M2.9,0.7L2.9,0.7c1.4,0,2.6,1.2,2.6,2.6v35c0,1.4-1.2,2.6-2.6,2.6h0c-1.4,0-2.6-1.2-2.6-2.6V3.3C0.3,1.9,1.5,0.7,2.9,0.7z',
      length: '50%',
      width: 8
    },
    data: [{
      value: 88
    }]
  }]
};

性能优化与最佳实践

  1. 性能优化策略

    // 关闭不必要的动画
    animation: false,
    
    // 减少刷新频率
    setOption(newOption, {
      lazyUpdate: true, 
      notMerge: true
    });
    
    // 使用Canvas渲染(默认)
    init(dom, canvas);
    
  2. 响应式布局

    const resizeObserver = new ResizeObserver(() => {
      chart.resize();
    });
    resizeObserver.observe(document.getElementById('dashboard'));
    
  3. 无障碍访问

    aria: {
      show: true,
      description: '健康指数仪表盘,当前值为88%,处于优秀水平'
    }
    

常见问题解决方案

1. 仪表盘不显示

  • 检查容器尺寸是否正确设置
  • 确认 echarts.init() 参数正确
  • 验证数据格式是否符合要求

2. 自定义样式失效

  • 确保属性路径正确(如 axisLine.lineStyle.color)
  • 检查浏览器控制台错误信息
  • 优先使用官方文档支持的配置项

3. 动画卡顿

  • 减少数据刷新频率(使用节流)
  • 避免过度复杂的图表设计
  • 使用轻量级的 Canvas 渲染而非 SVG

打造专业级仪表盘

通过本文的学习,您已经掌握了:

  • ECharts 基础仪表盘的创建方法
  • 个性化仪表盘的设计技巧
  • 多指针和环形进度条的实现
  • 实时数据更新的最佳实践
  • 性能优化与问题排查方法

仪表盘设计不仅需要技术实现,还需要考虑用户体验和数据故事讲述原则:

graph LR
A[明确业务目标] --> B[选择合适指标]
B --> C[设计视觉层次]
C --> D[优化色彩对比]
D --> E[确保响应式布局]
E --> F[测试与迭代]

ECharts 提供了丰富的配置选项,让开发者能够创建出既美观又实用的仪表盘组件。

拓展资源

useReducer+useContext +扁平化

背景

作者之前是在百度实习,从事的是小程序和h5的开发,虽然H5用的hooks,但是对于某些hooks的用法不是很熟悉,对于大型Saas系统状态管理不是很熟悉,因此写下这篇来回顾总结上次遇到知识点不足的问题

useReducer

useReducer接收三个参数

  • reducer 函数:指定如何更新状态的还原函数,它必须是纯函数,以 state 和 dispatch 为参数,并返回下一个状态。
  • 初始状态:初始状态的计算值。
  • (可选的)初始化参数:用于返回初始状态。如果未指定,初始状态将设置为 initialArg;如果有指定,初始状态将被设置为调用init(initialArg)的结果。

useReducer返回两个参数

  • 当前的状态:当前状态。在第一次渲染时,它会被设置为init(initialArg)或 initialArg(如果没有 init 的情况下)。
  • dispatch:调度函数,用于调用 reducer 函数,以更新状态并触发重新渲染。

useContext

使用createContext创建上下文

export const ContextDemo = createContext(initDefault);

并且在外层使用Context.Provider来包裹子组件,使用的context 则是

    const {state, setSku} = useContext(ContextDemo);

扁平化

我们扁平化的目的就是在于,我们写代码更改某个值,希望使用的函数是类似于setXXX, 然而对于reducer来讲就是需要dispatch,但是这样在页面写dipspatch,有可能会充斥大量dispatch xxx类似这样的代码,影响阅读,同时调用多个相同的dispatch会增加代码行数,因此需要扁平化。使得我们context里面有state以及更改state中相应某个值。
这里扁平化逻辑实则对reducerdispatch进行二次封装。

//useContext.tsx
import React, {createContext, FC, ReactNode, useReducer} from 'react';
import {dispatcher, initState, reducer as useReducers} from './useReducer';
import {Action} from './interface';
const reducer = (State, action: Action) => {
 // 这里更改数据对数据进行二次封装和更改,应该可以算作策略设计模式吗?
 const fn = useReducers[action.type];
 return fn(State, action.value);
};
export const ContextDemo = createContext({});

export const Provider: FC<{children: ReactNode}> = ({children}) => {
 const [contextValue, dispatch] = useReducer(reducer, initState);
 // 这里传入dispatch,对dispatch进行二次封装
 const setter = dispatcher(contextValue, dispatch);

 return (
     <ContextDemo.Provider
         value={{
             state: contextValue,
             ...setter
         }}
     >
         {children}
     </ContextDemo.Provider>
 );
};

在上面代码中,我们关注到在useReducer的第一个参数中对数据进行二次封装抽象,这样避免了很多switch case等等 同时我们在Provider(14行)对dispatch进行二次封装 这两个封装是针对同一个useReducer的,因此我将他们写在useReducer.tsx文件里面

import {State, Action} from './interface';

export const initState: State = {
   sku: '',
   image: '',
   dockerUrl: ''
};

const setter = (s: any, props: string, value: any) => {
   return {
       ...s,
       [props]: value
   };
};
type action = 'setSku' | 'setImage' | 'setImageUrl';
type Reducer = (s: State, value?: any) => State;
export const reducer: {[key in action]: Reducer} = {
   setSku: (state: any, val: any) => setter(state, 'sku', val),
   setImage: (state: any, val: any) => setter(state, 'image', val),
   setImageUrl: (state: any, val: any) => setter(state, 'imageUrl', val)
};

// 外部传入 dispatch 和 state ,在这里二次封装
export const dispatcher = (state: State, dispatch: (val: {type: action; value?: string}) => void) => {
   return {
       setSku(value: string) {
           dispatch({
               type: 'setSku',
               value: value
           });
           // 这里可以return 做些其他逻辑
       },
       setImage(value: string) {
           dispatch({
               type: 'setImage',
               value: value
           });
       },
       setImageUrl(value: string) {
           dispatch({
               type: 'setImageUrl',
               value: value
           });
       }
   };
};

  1. 先说对dispatch进行二次封装,useContext.tsx里面 我们通过将dispatch传入来派发数据 同时返回一个对象,然后在Provider将对象解构,也就是useContext.tsx文件里面的15 、21行这样我们在全局就注入了setXXX函数
  2. 对于State的修改我们要记住,reducer必须返回新的state 不然不会更新数据,既然扁平化,我在useReducer里面 写了reducer对象(不是函数目的在于映射),代码useContext.tsx使用的reudcer是第行的reducer函数,在reducer函数里面接收到action和state(注意reducer as useReducers} from './useReducer';作者能力有限,命名不是很规范)通过action的type,映射到useReducer.tsx的reducer对象的更改某个值的函数,这样封装完成了对数据的修改和返回

总结

这样扁平化之后我们context上下文就是state以及各种setXXX的函数,useContext与useReducer的结合用法在掘金有很多文章,作者只不过进一步扁平化罢了, 实际场景中函数有副作用,比如获取数据,结合hooks的方案,常用的就是使用react-query这个库,还有很多状态管理库 比如zustand redux等,道阻且长。点个赞吧(作者25届自己菜,感觉不适合这一行,但又不知道干啥,就只能这样了)

别让“自动刷新”毁掉用户心血:聊聊数据丢失那些坑

📌 一个普遍但经常被忽略的场景:

无论你是构建一个:

  • 企业级中后台管理系统
  • 移动端 App 的购物车界面
  • 可视化建模/低代码平台
  • 数据驱动型游戏 UI
  • 多步操作表单 / 向导流程

只要你的系统涉及到:

✅ 用户在客户端编辑了某些数据
✅ 同时又会从服务端拉取这类数据的“最新版本”
✅ 并希望两者融合,用户输入不丢失

你就极有可能中招:

🔥 用户新增的数据或未提交的修改被“拉新操作”悄悄吞掉了!

一个常见场景是编辑富文本说明,如:

  • 后台运营填写商品详情、活动说明、用户协议等;
  • 用户在编辑区中写了一大段内容;
  • 此时他们点击了“查看另一个商品”或刷新按钮;
  • 系统重新拉了数据并替换表单值;
  • 用户输入的内容没有本地缓存,也没有弹窗提示。

最终结果就是:

❌ 富文本内容瞬间被“远程数据”覆盖,用户失去所有编辑进度。

这类问题最容易在以下情境中高频出现:

触发行为 描述
点击切换记录 切换当前编辑对象但无未保存提示
页面被 keep-alive 销毁又挂载 再次拉新数据时,误清空用户操作中内容
后台定时刷新数据 出于“保证时效”而忽略了用户的未提交状态
富文本控件未绑定本地缓存状态 没有绑定 v-model,或没做本地备份机制

❗️这个问题的本质不是“谁覆盖谁”,而是“我们到底以谁为主?”

大多数系统的错误合并逻辑是这样的:

const newData = apiData.map(item => {
  const cacheItem = cacheList.find(c => c.id === item.id);
  return cacheItem ? { ...item, ...cacheItem } : item;
});

这段代码的暗含逻辑是:

以接口返回的数据为“主表”,用户输入只是“补丁”。

但问题是:

  • 用户新增的数据根本没有 ID,所以压根不会出现在接口返回列表中。
  • 于是 map() 时直接被忽略 → 用户看着刚填的东西不见了。

✅ 站在抽象层面来看,这是「双数据源一致性同步」的问题

两个状态源:

  • 服务端下发的 “远程权威数据”
  • 客户端本地缓存的 “用户当前意图”

我们需要做到的是:

  • 字段更新 → 按字段级别 merge,优先保留用户值
  • 新增数据 → 完全保留用户本地结构
  • 顺序可控 → merge 后的列表仍满足展示顺序、状态约定

✅ 正确的思维模式是:“以用户状态为主、拉新为辅助”

工程上可以抽象为:

function mergeUserStateWithRemote<T>(
  userList: T[],
  serverList: T[],
  key: keyof T,
  fieldsToPreserve: (keyof T)[]
): T[] {
  const serverMap = new Map(serverList.map(item => [item[key], item]));

  return userList.map(userItem => {
    const serverItem = serverMap.get(userItem[key]);
    const base = serverItem ? { ...serverItem } : {};
    fieldsToPreserve.forEach(field => {
      if (userItem[field] !== undefined) {
        base[field] = userItem[field];
      }
    });
    return { ...base, ...userItem };
  });
}

🧠 通用应用场景

这不仅适用于中后台,而适用于几乎所有需要“本地编辑状态”的系统:

场景 举例
表单草稿 用户多步填写,后台随时可能刷新状态
图形/建模工具 本地组件拖拽中,后端状态变更
游戏设置界面 用户尚未点击“确认”,但服务器推送参数刷新
协作系统 当前编辑和团队协作修改并存
表格/列表 新增一行、拖拽排序中,接口更新触发刷新

🧩 建议封装成系统级别的“状态合并工具库”

命名建议如:

  • mergeStateWithRemote
  • safeMergeByKey
  • preserveUserInput

并明确三大目标:

  1. 新增项永不被吞
  2. 修改字段用户优先
  3. 可控顺序 + 可配置字段粒度

🛠 实践中的两个典型封装函数

在实际项目中,我们通常会封装成以下两种:

mergeCurrencyListWithAppend:保留用户输入 + 合并远程数据(新增项优先)

适合用户可新增项 + 字段需用户优先 + 接口拉全量覆盖的情况:

/**
 * 通用合并函数(保留新增 + 字段覆盖)
 * @param {Array} apiList 接口返回的最新数据
 * @param {Array} userList 页面中已有数据(用户输入)
 * @param {Array} fields 需要保留的字段列表
 * @param {String} key 唯一标识字段名
 * @returns {Array} 合并后的完整数据
 */
function mergeCurrencyListWithAppend(apiList, userList, fields = [], key = 'id') {
  const resultMap = new Map();

  // 1. 接口数据优先放入 map
  for (const apiItem of apiList) {
    resultMap.set(apiItem[key], { ...apiItem });
  }

  // 2. 用户数据合并字段或新增项
  for (const userItem of userList) {
    const id = userItem[key];
    if (resultMap.has(id)) {
      const merged = resultMap.get(id);
      for (const f of fields) {
        if (userItem[f] !== undefined) {
          merged[f] = userItem[f]; // 用户值优先
        }
      }
      resultMap.set(id, merged);
    } else {
      // 完全新增项
      resultMap.set(id, { ...userItem });
    }
  }

  return Array.from(resultMap.values());
}

mergeCachedFields:用于字段级“打补丁”逻辑,保留部分用户编辑

适合用户不能新增项,仅修改已存在项的某些字段场景:

/**
 * 通用缓存合并函数
 * @param {Array} newList 接口返回的新数据
 * @param {Array} cachedList 页面已存在的数据(缓存)
 * @param {Array} fields 需要保留的字段名
 * @param {String} key 唯一标识字段名,默认 'id'
 * @returns {Array} 合并结果(仅字段补丁)
 */
function mergeCachedFields(newList, cachedList, fields = [], key = 'id') {
  if (!Array.isArray(newList) || !Array.isArray(cachedList)) return newList;

  const cacheMap = new Map();
  cachedList.forEach(item => {
    if (item[key] !== undefined && item[key] !== null) {
      cacheMap.set(item[key], item);
    }
  });

  return newList.map(item => {
    const cached = cacheMap.get(item[key]);
    if (!cached) return item;

    const merged = { ...item };
    fields.forEach(f => {
      if (cached[f] !== undefined) {
        merged[f] = cached[f];
      }
    });
    return merged;
  });
}

这两个函数的定位如下:

函数名 用户新增项 字段保留 应用典型
mergeCurrencyListWithAppend ✅ 保留 ✅ 用户优先 表格新增行、行内编辑、配置面板
mergeCachedFields ❌ 忽略 ✅ 补丁逻辑 切换视图时保留局部编辑字段、纯接口更新

✍️ 结语:真正的控制权,在于如何处理“两个世界的数据”

在数据驱动的前端开发中:

  • 是接口说了算,还是用户说了算?
  • 是每次刷新都重置,还是用户操作优先?
  • 是字段级别合并,还是对象级覆盖?

我们常说“用户体验优先”,但真正的优先是:你是否允许用户的输入成为系统状态的主导。在面对远程数据刷新与用户本地修改时,合并策略就是你给出的答案。写好这类“状态同步逻辑”,不是技术炫技,而是对用户行为的尊重。

❌