普通视图

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

Playwright使用体验

2025年12月29日 23:29

Playwright 安装

在项目中执行yarn create playwright即可。该命令会在package.json中添加依赖,并生成以下文件和目录。

playwright.config.ts  
tests/  
    example.spec.ts  
tests-examples/  
    demo-todo-app.spec.ts

Playwright 配置

playwright.config.ts是Playwrigth的配置文件,生成文件基本不用改动即可运行。

import { defineConfig, devices } from '@playwright/test';

/**
 * Read environment variables from file.
 * https://github.com/motdotla/dotenv
 */
// require('dotenv').config();

/**
 * See https://playwright.dev/docs/test-configuration.
 */
export default defineConfig({
  testDir: './tests',
  /* Run tests in files in parallel */
  fullyParallel: true,
  /* Fail the build on CI if you accidentally left test.only in the source code. */
  forbidOnly: !!process.env.CI,
  /* Retry on CI only */
  retries: process.env.CI ? 2 : 0,
  /* Opt out of parallel tests on CI. */
  workers: process.env.CI ? 1 : undefined,
  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
  reporter: 'html',
  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
  use: {
    /* Base URL to use in actions like `await page.goto('/')`. */
    // baseURL: 'http://local.test.com:3001',
    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
    trace: 'on-first-retry',
  },
  /* Configure projects for major browsers */
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
        viewport: { width: 1920, height: 1080 },
      },
      dependencies: ['setup'],
    },

    // {
    //   name: 'firefox',
    //   use: { ...devices['Desktop Firefox'] },
    // },

    // {
    //   name: 'webkit',
    //   use: { ...devices['Desktop Safari'] },
    // },

    /* Test against mobile viewports. */
    // {
    //   name: 'Mobile Chrome',
    //   use: { ...devices['Pixel 5'] },
    // },
    // {
    //   name: 'Mobile Safari',
    //   use: { ...devices['iPhone 12'] },
    // },

    /* Test against branded browsers. */
    // {
    //   name: 'Microsoft Edge',
    //   use: { ...devices['Desktop Edge'], channel: 'msedge' },
    // },
    // {
    //   name: 'Google Chrome',
    //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },
    // },
  ],
  timeout: 5 * 60 * 1000,
  /* Run your local dev server before starting the tests */
  webServer: {
    command: 'yarn dev',
    url: 'http://local.aaa.com:3001',
    reuseExistingServer: !process.env.CI,
  },
});

不过在实践中,有几项建议修改:

  1. baseURL,设置后,使用 page.goto不需要再指定完整路径

  2. timeout,不知道是否是网络原因,Playwright打开网页总是很慢,测试用例很容易跑超时,所以我将timeout改成了5 * 60 * 1000

  3. webServer 本地测试时强烈建议开启该功能

      webServer: {
        command: 'yarn dev',
        url: 'http://local.test.com:3001',
        reuseExistingServer: !process.env.CI,
      },
    

    运行测试时,Playwright将先运行yarn dev启动服务,并等待local.test.com:3001 可访问时再开始测试。这个配置非常好用,相对比cypress就需要额外安装start-server-and-test

  4. 用户登录相关,见下一节。

Playwright缓存用户登录信息

一般运行测试前需要先进行登录,Playwright可以缓存用户信息,在之后多次测试中复用,配置也很简单。

创建auth文件夹

按照官网建议,直接在项目中创建playwright.auth文件夹

编写登录程序

在tests目录中新增auth.setup.ts,内容可参考如下

import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  // Perform authentication steps. Replace these actions with your own.
  await page.goto('https://xxx.xxx.com/login/');
  await page.locator('#account').fill('xxx');
  await page.locator('#password').fill('xxx');
  await page.locator('button[type=submit]').click();
  // Wait until the page receives the cookies.
  //
  // Sometimes login flow sets cookies in the process of several redirects.
  // Wait for the final URL to ensure that the cookies are actually set.
  await page.waitForURL('https://xxx.xxx.com/');

  // Alternatively, you can wait until the page reaches a state where all cookies are set.
  await expect(page.locator('#title')).toBeVisible();

  // End of authentication steps.

  await page.context().storageState({ path: authFile });
});

这段代码先访问统一登录地址(测试网页域名可能和登录地址不一样),模拟输入用户名/密码,点击提交。登录成功后,page.context().storageState({ path: authFile })将cookie/storage信息存入authFile文件。

修改配置文件

修改配置文件中的projects配置

  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
        viewport: { width: 1920, height: 1080 },
      },
      dependencies: ['setup'],
    },
    ...
]

Playwright 运行

直接运行命令yarn playwright test --ui,会出现下面弹窗

image.png

点击example.spec.ts旁边的开始键就可以运行测试用例了,可以看到auth.setup.ts中的登录程序也被自动运行了。 点击每个用例可以显示更多运行信息。

image.png

Playwright 简单小结

Playwright上手非常简单,但是ui mode比较简陋,不适合边测试边debug。

🦾 可为与不可为:CDP 视角下的 Browser 控制边界

作者 卤代烃
2025年12月29日 22:54

如果我们把人类对电脑的所有操作记为全集,那么 Browser, CDP 和 puppeteer 可操作的集合范畴如下:

  • Computer:所有操作的全集
  • Browser:App 层权限,Browser 为了安全,还限制了许多能力,比如说直接操作本地的文件
  • CDP:专注于 Debug 能力,浏览器的非调试信息(比如说收藏网页)是没有权限访问的
  • puppeteer:基于 CDP 构建,但是有一部分 CDP API 并没有用到,所以能力属于 CDP 的子集

在 Browser-Use 场景,不同于 VNC 这种更为通用的投屏方案,CDP 是有能力边界的,所以说知道它擅长什么不擅长什么,对于架构的整体设计和未来演化方向有很大的意义。

下面主要是从浏览器角度出发,列出 CDP(puppeteer) 可以做到的事情,支持难度分类如下:

  • 直接支持:pptr 有封装好的现成的 API
  • 间接支持:需要组合多个 pptr API/CDP API 实现相关功能
  • 不能支持:CDP 完全做不到的事情

一、浏览器功能

这部分主要指浏览器级别的全局功能。包括 tabs 管理,页面导航等功能。

Tabs

Tabs(标签页)是非常重要的一个功能,CDP 的 tabs 相关 API 能力比较弱,puppeteer 也没有做相关的抽象和封装。这就导致想实现 tab 的「新建/更新/切换/关闭」这 4 个基础的功能需要组合多个 CDP API 间接实现。

tabs

还有个问题如果 Tabs 被人拖拽改变了显示顺序,这部分改变 CDP 是无法感知的。比如说上面图的 tabs 是拖拽过的,但是 CDP 直接拿到的顺序是:历史记录,百度一下,你就知道,今日头条,不过这种问题可以忽略。

Navigate

navigate Navigate 主要是 Tab 内部导航,包括 back/forward/refresh/goto 4 个最基础的功能。

这部分功能 puppeteer 基于 CDP 的 Page.getNavigationHistoryPage.navigateToHistoryEntry API 做了良好的封装,基本上可以直接拿来使用。

但值得注意的是输入框的联想记录是无法通过 CDP 获取:

search

扩展程序(浏览器插件)

可以初始化 browser 的时候就通过 pptr API 直接注入插件。插件能力还是比较重要的,比如说 ad-block,可以屏蔽一些广告的 DOM,让网页更干净,方便模型去定位。

内部设置页

  • 必要性:中
  • 支持度:直接支持
  • pptr APIPage.goto()

内部设置页我这里定义为 chrome:// 协议开头的页面,比如说 chrome://history/ 就是浏览器的历史记录页。这些设置页其实都是以网页为载体的,所以都能被 CDP 捕获到,这些页面有很多,比如还有 chrome://downloads/ chrome://extensions/ 等。

换句话说,我们只要知道设置页面的 URI 就可以直接 navigate 过去,这个看整体的诉求,可按需支持。

History Page History Page Screenshot
history-page history-page-screenshot

设置菜单

  • 必要性:低
  • 支持度:不支持

CDP 无法感知,但这种二级菜单三级菜单入口也比较深,主动改动的情况比较少见,而且其导航到的都是 chrome:// 协议开头的内置网页,拿到对应的 URI 后都可以直接 navigate 过去。

list

其它 App 级功能

  • 必要性:低
  • 支持度:不支持

除了上面常见的功能,还有一些较为冷门,或者说不会影响浏览网页主流程的 App 级功能。比如说:

  • 登录 Google 账号的弹窗
  • 标签页分组
  • 阅读模式/阅读清单
  • ......

这些都是有可以增强浏览体验,没有也没啥问题的功能。在 AI 场景上,永远是越简单的越健壮,所以这些甜品功能也没必要支持。

登录弹窗 标签页分组
login group
阅读模式 阅读清单
read-mode read-list

二、快捷键

快捷键是一个较为复杂的问题。

几大操作系统发展了几十年,虽然用的都是同一款键盘,但在快捷键上加入了一堆自己的小心思,所以各操作系统的快捷键本身就有很高的复杂度;CDP 场景下的快捷键,在 macOS 下也需要单独的适配。下面我就这两个方向展开说说。

跨平台角度

跨平台角度看,Windows 和 Linux 的常用快捷键还是比较统一的,但是 macOS 就比较特殊。

首先是对于大部份常用快捷键(全选/复制/黏贴 等),在 Mac 上使用的装饰键是 Command(即 Meta 键),但在 Windows/Linux 上为 Ctrl(即 Control 键)。

所以当 LLM 发出一个快捷键的 action 指令时,比如说「全选」快捷键,在工程这端需要做一下兜底,先判断当前运行环境的具体 OS,然后需要把 action 指令做修改以让指令正确的执行:

hotkey("ctrl+A") --> isMacOS? --- true ---> keyboard('Meta+KeyA')
                         └------- false --> keyboard('Control+KeyA')

除了这类常见的快捷键,其实还有很多的情况需要去适配。比如说:

  • 查看浏览器历史记录:macOS 是 Command+Y,Linux 和 Windows 是 Ctrl+H
  • 退出浏览器:macOS 是 Command+Q,Linux 和 Windows 是 Alt+F,然后按 X
  • 导航到上个页面:macOS 是 Command+[,Linux 和 Windows 是 Alt+LeftArrow
  • ......

相关的快捷键可以参考官方给的规范:

平台 支持的快捷键
macOS support.apple.com/zh-cn/10265…
Windows support.microsoft.com/en-us/windo…
Linux GNOME help.gnome.org/users/gnome…
Chrome support.google.com/chrome/answ…

这些都很繁琐,除了交给 AI 来写,最好的方式还是只提供基础的适配,然后遇到啥修啥,要不然就是个无底洞。

CDP 角度

CDP 因为权限问题,通过它发送的键盘指令,并不是真正的系统键盘指令,可以简单理解为作用域限制在 Chrome 和 Page 内部。而且,我们的 macOS 又出幺蛾子了

首先说最基础的「全选」快捷键。在 macOS 上如果你直接发送 Meta+KeyA,你会发现根本不会执行全选操作。

await page.keyboard.down("Meta");
await page.keyboard.down("KeyA");
await page.keyboard.up("KeyA");
await page.keyboard.up("Meta"); // not working in macOS

具体原因比较复杂,可以参考 #776#1313,核心原因如下:

The first bug here is that we don't send nativeKeyCodes, so no real OSX events get made. When sending the nativeKeyCodes, "a" is keyCode 0 and protocol decides not to send a falsey keyCode. After these are fixed, OSX doesn't like to perform keyboard shortcuts unless the application has the foreground. And lastly, if Chromium has the foreground, we send the nativeKeyCode, and protocol processes it, the shortcut gets captured by the address bar instead of the page.

github.com/puppeteer/p…

可以看到回复在 2017 年,快 10 年了这个问题其实还存在。


不过好在对于「全选/复制/粘贴」这些常见的快捷键,CDP 有些别的曲线救国方案。CDP 发送键盘指令的 Input.dispatchKeyEvent,有个额外的 commands 指令,上面有一些编辑指令可以触发相关的操作,比如说我想执行「全选」操作,我就可以这样写:

await page.keyboard.down("KeyA", { commands: ["SelectAll"] });
await page.keyboard.up("KeyA"); // working in macOS

这样下面这些常见的编辑类快捷键就可以支持了:

操作 macOS Windows/Linux CDP commands
复制 Command + C Ctrl + C Copy
粘贴 Command + V Ctrl + V Paste
剪切 Command + X Ctrl + X Cut
撤销 Command + Z Ctrl + Z Undo
恢复 Shift + Command + Z Ctrl + Y Redo
全选 Command + A Ctrl + A SelectAll

对于一些其他的权限较高的快捷键,我们可以可以做好功能映射

  • 查看浏览器历史记录:Page.goto('chrome://history/')
  • 退出浏览器:Browser.close()
  • 导航到上个页面:Page.goBack()
  • ......

当然这些也最好按需添加,全适配意义不大。

三、网页功能

主要指对网页本身的做的操作,影响范围主要为当前网页,有截图,文件上传下载等功能。

截图

  • 必要性:高
  • 支持度:直接支持

CDP 的 Page.captureScreenshot API 可以直接对网页内容本身做截图。但需要注意的是,CDP 截图只能截到网页本身(也就是绿框内的内容),外部的 Chrome UI 是截图不到的。

所以这里需要格外注意,部分 VLM 模型在训练阶段是用的完整的 Chrome 截图(红框内的内容),如果泛化能力一般,直接把 CDP 截图传给 VLM,可能会有 action 坐标错位的问题。

screenshot

基础交互

这里的基础操作主要是指 click,drag,keyboard 等行为,这些 puppeteer 做好了原子方法,可以直接组合使用。而且 pptr 也提供了各种 DOM 回调去执行相关的 action 操作,还是非常灵活的。这里推荐直接看 pptr 的文档:pptr: Page Interactions,还是描述的比较清晰的。

Dialog 弹窗

  • 必要性:高
  • 支持度:间接支持
  • pptr API: Dialog class

CDP 可以直接感知到弹窗的相关事件(例如 Page.javascriptDialogOpening),所以下方的 4 类弹窗的触发都是可以感知到的:

Alert Confirm
alert confirm
Prompt Beforeunload
dialog beforeunload

因为弹窗是一个优先级很高的浏览器行为,它一旦唤起基本会中断网页的所有行为,JS 引擎也会挂起停止响应,所以必须得响应关闭弹窗才能执行后续流程,综合来看是一个非常高优的功能。

右键菜单

  • 必要性:低
  • 支持度:间接支持

CDP 无法感知到在页面内点击「鼠标右键」后触发的 系统菜单 本身:

Page Image Link Tab
page-right-button link-right-button 17-cdp-do-image-right-button tab-right-button

但系统弹窗内的功能基本可以通过其他方式实现,比如说「返回/前进/重新加载」等功能都可以用一些 Navigate 的方法做平替。但就目前的用户诉求看,这部分功能的必要性并不高。

对于网页内自定义的右键菜单,因为基本都是 DOM 绘制的,其实可以通过网页内截图感知到的,例如下图中 bilibili 播放器的右键 DOM 菜单就可以被 CDP 截图捕获:

custom-right-button

Input 选择器

  • 必要性:高
  • 支持度:间接支持

HTML 的大部分表单功能都是支持的,但是部分 Input 选择器使用了系统控件(例如 HTML 默认的 Select 选择器,日期选选择器),导致 CDP 截图无法感知到。

目前测试下来有这些选择器都是无法被 CDP Screenshot 所捕获的:

Select Date Time Color
select date time 23-cdp-do-link-color

但是也不是没有迂回的办法,我们可以尝试使用 JS 代码注入替换掉现有的系统控件为 DOM 控件,间接的实现被截图的诉求。例如 Select Option Picker,替换后的效果如下:

select-dom

文件上传/下载

pptr 上执行文件的逻辑较为完善,结合 uploadFile API 和 FileChooser 都可以做较好的文件上传支持。但是在文件下载上并没有提供非常好的 API,用户可以操作的就是通过 DownloadBehavior 去指定下载策略和下载路径。

打印

  • 必要性:中
  • 支持度:直接支持
  • pptr APIPage.pdf()

这个也是现成的 API,直接调用即可。

其它 Page 级功能

  • 必要性:低
  • 支持度:不支持

除了上述的各种高频功能,浏览器还有一些甜品级小功能,但从个人角度和用户诉求上看,这些功能在 Browser-Use 场景基本上用不到,而且这些功能 CDP 也基本不支持,但为了本文的内容完整性还是列出来:

  • 书签
  • 翻译
  • 搜索
  • 二维码
  • ......
书签 翻译
mark trans
搜索 二维码
search qrcode

总结

综上所述,我们可以看到 CDP 还是有明显的能力边界,但是已经足够支持 95% 的业务功能了。最重要的是把相关的功能打磨好,才能最大程度的放大 AI 的能力。

昨天 — 2025年12月29日技术

HarmonyOS应用开发:多重筛选

作者 鹿人戛
2025年12月29日 22:13

前言

本示例主要介绍多重筛选场景,利用数组方法过滤满足条件的数据,利用LazyForEach实现列表信息的渲染以及刷新。

效果图预览

使用说明

  1. 等待列表数据全部加载完成后,点击筛选类型,展开筛选数据。
  2. 选中想要筛选的数据,点击确认,列表刷新。
  3. 再次点开筛选类型,保留上次筛选的内容,点击重置筛选内容复原,列表数据恢复为未筛选前的数据。

实现思路

本例涉及的关键特性和实现方案如下:

  1. 使用Grid实现筛选条件布局。

    Grid() { ForEach(this.item.options, (options: string, idx: number) => { GridItem() { Text(options) .textAlign(TextAlign.Center) .fontSize(16) .height(40) .width('100%') } ... }) } .columnsTemplate('1fr 1fr 1fr') .rowsGap(16) .columnsGap(16) .margin({ left: 16, right: 6, top: 8, bottom: 8 }) .layoutDirection(GridDirection.Row) .constraintSize({ minHeight: '15%', maxHeight: '15%'// grid会撑满maxHeight,先限定死高度 })

  2. 使用数组方法对筛选数据进行过滤,得到筛选数据。

    GridItem() { Text(options) .textAlign(TextAlign.Center) .fontSize(16) .height(40) .width('100%') } .onClick(() => { if (this.item.selectItem.includes(idx)) { let index = this.item.selectItem.indexOf(idx); let listIdx = this.changData.indexOf(options); // 删除已存在的筛选数据的index值 this.item.selectItem.splice(index, 1); // 过滤出来没有重复数据的筛选值 this.changData = this.changData.filter(i => i !== options); this.selectArr = this.item.selectItem; // 删除已选择的数据的行数index数组 this.arrayListData.splice(listIdx, 1); } else { // 添加筛选数据的index值 this.item.selectItem.push(idx); // 添加选中的数据 this.changData.push(options); this.selectArr = this.item.selectItem; // 添加选择的数据的行数index数组 this.arrayListData.push(this.listIndex); } })

  3. 得到筛选的数据后根据点击的筛选数据行数,使用has进行if判断看是否满足多重筛选的条件。

    Button('确认') .height(40) .width(150) .backgroundColor(Color.White) .fontColor('#333') .onClick(() => { this.isShow = false; let arrayListData = new Set(this.arrayListData) if (arrayListData.has(0) && !arrayListData.has(1)) { // 仅选择停放时间 this.siteList.timeMultiFilter(this.changData); } else if (!arrayListData.has(0) && arrayListData.has(1)) { // 仅选择套餐类型 this.siteList.typeMultiFilter(this.changData); } else if (!arrayListData.has(0) && !arrayListData.has(1) && arrayListData.has(2)) { // 仅选择充电 this.siteList.getInitalList(); } else if (this.changData.length === 0) { // 未对数据进行选择 this.siteList.getInitalList(); } else { // 多重筛选 this.siteList.multiFilter(this.changData); } if (this.siteList.totalCount() === 0) { this.siteList.getInitalList(); promptAction.showToast({ message: "未找到相关数据" }); } })

  4. 使用filter过滤出来符合条件的数据,筛选出来的数组构建一个新的Set,使用Set中的has判断列表中相关数据是否存在。

    public multiFilter(changData: Array) { let siteListString: string | undefined = AppStorage.get('siteList') if (siteListString) { let siteListObject: SiteListDataSource | undefined = JSON.parse(siteListString) if (siteListObject === undefined) { return } this.initialSiteList = siteListObject.dataList this.dataList = [] this.dataList = this.initialSiteList // 筛选数据 let changDataSet = new Set(changData) let dataList: SiteItem[] = this.dataList.filter(item => { item.siteBale = item.siteBale.filter(item => { if ((item.time && item.type) && (changDataSet.has(item.time)) && (changDataSet.has(item.type))) { return item } return }) return item.siteBale }) dataList = dataList.filter(item => item.siteBale.length !== 0); this.dataList = []; this.dataList = dataList; this.notifyDataReload(); } }

  5. 使用深拷贝保留原数据。

    /**

    • 返回原数组 */ public getInitalList() { let siteListString: string | undefined = AppStorage.get('siteList'); if (siteListString) { let siteListObject: SiteListDataSource | undefined = JSON.parse(siteListString); if (siteListObject === undefined) { return; } this.initialSiteList = siteListObject.dataList; this.dataList = []; this.dataList = this.initialSiteList; this.notifyDataReload(); } }

如果您想系统深入地学习 HarmonyOS 开发或想考取HarmonyOS认证证书,欢迎加入华为开发者学堂:

请点击→: HarmonyOS官方认证培训

绿联云 NAS 安装 AudioDock 详细教程

2025年12月29日 21:54

前言

AudioDock(声仓)发布之后,好多感兴趣的小伙伴给了我反馈,感谢支持!

github.com/mmdctjj/Aud…

今天先来介绍下绿联云 NAS 的安装指南。我的NAS型号是:DH2600,新系统。

往期精彩推荐

正文

准备工作

首先确保自己的 NAS 可以下载 Docker 镜像。无法下载可以在后台私信我。

然后在 共享文件夹/docker 目录下新增一个文件目录:audiodock。

我新建过了,所以新建了 audiodock2 项目。

新建目录

打开这个文件目录,新建三个文件夹:music、audio、covers

music 是映射音乐的目录、audio 是映射声书的目录,covers 存放解析后封面的目录。

从 GitHub 下载的 nginx.conf 文件拖动到当前目录下。下载地址:github.com/mmdctjj/Aud…

然后打开 Docker 应用的项目栏目,新建一个项目:audiodock

新建项目

这时候系统会自动识别新建的 audiodock 目录。

将下面的内容复制到 compose 配置中。

version: "3.8"

services:
  # 1. API 后端服务 (Node.js)
  api:
    platform: linux/amd64
    image: mmdctjj/audiodock-api
    container_name: audiodock-api

    # 容器内部端口 (3000) 默认对内部网络开放,无需 ports 字段映射到宿主机
    # 如果要直接测试 API,可以加上 ports: - "3000:3000"
    ports:
      - "8858:3000"

    environment:
      - AUDIO_BOOK_DIR=/audio
      - MUSIC_BASE_DIR=/music
      - CACHE_DIR=/covers
      - DATABASE_URL=file:/data/dev.db

    # 挂载数据文件和缓存,使用 Docker 命名卷更安全
    volumes:
      - /volume1/迅雷下载/有声书:/audio
      - /volume1/迅雷下载/音乐:/music
      - ./covers:/covers
      - api-db:/data

    restart: unless-stopped
    networks:
      - audiodock-network

  # 2. Web 前端服务 (Nginx) - 用于托管静态文件和反向代理
  web:
    platform: linux/amd64
    image: mmdctjj/audiodock-web
    container_name: audiodock-web
    ports:
      - "9959:9958" # <--- 将 Web 服务的 80 端口映射到宿主机的 8080 端口
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - api # 确保 API 容器先启动
    networks:
      - audiodock-network

volumes:
  api-cache: # 命名卷用于缓存
  api-db: # 命名卷用于 SQLite 或其他数据文件

networks:
  audiodock-network:

重点替换替换下映射路径:

 # 挂载数据文件和缓存,使用 Docker 命名卷更安全
    volumes:
      - /volume1/迅雷下载/有声书:/audio
      - /volume1/迅雷下载/音乐:/music
      - ./covers:/covers
      - api-db:/data

映射路径的查看是选中文件夹右键属性,可以看到具体的地址,复制即可。

查看文件地址

最后保证服务端口映射没有重复,点击重新部署即可启动服务。

启动部署

部署成功

接下来稍等一会,等数据入库完成,后端服务占用资源减少

入库完成

打开页面地址,会看到页面是这样的

页面

输入后端服务器地址,鼠标点击页面空白区域,或者按 tab 键,会触发后端服务状态检查,绿代表链接成功,红色代表链接错误。

后端服务链接成功

输入用户名、密码登陆,或者点注册之后输入确认密码登陆并注册!

注册并登陆

页面会刷新首页,看到是这样的首页说明完全成功了(马赛克是防止版权问题平台不过审)!

登陆成功

以上就是部署服务端、web端的教程!桌面端的部署请看上篇文章。移动端预计本周末发版,敬请期待!

最后

本篇文章主要介绍了绿联云 nas 如何安装 AudioDock !

为了方便大家交流,我建了一个沟通群,欢迎大家入群交流。

如果无法下载镜像或者 nginx.conf 等文件,可以在后台回复 audiodock,我看到会发最新版的下载链接。

欢迎 Star:github.com/mmdctjj/Aud…

往期精彩推荐

GIS 数据转换:使用 GDAL 将 GeoJSON 转换为 Shp 数据

作者 GIS之路
2025年12月29日 21:21

前言

GeoJSON 作为一种通用的地理数据格式,可以很方便地用于共享交换。在 GIS 开发中,经常需要进行数据的转换处理,其中常见的便是将 GeoJSON 转换为 Shp 数据进行展示。

在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDALGeoJSON转换为Shp数据。

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 数据准备

GeoJSON是一种用于编码各种地理数据结构的格式,采用JSON方式表示。在WebGIS开发中,被广泛应用于数据传输和共享交换。

有关GeoJSON数据的详细介绍,请参考往期文章:GeoJSON 数据简介

如下是本文选取的部分国家边界范围的GeoJSON数据结构:

{"type":"FeatureCollection","features":[{"type":"Feature","id":"AFG","properties":{"name":"Afghanistan"},"geometry":{"type":"Polygon","coordinates":[[[61.210817,35.650072],[62.230651,35.270664],[62.984662,35.404041],[63.193538,35.857166],[63.982896,36.007957],[64.546479,36.312073],[64.746105,37.111818],[65.588948,37.305217],[65.745631,37.661164],[66.217385,37.39379],[66.518607,37.362784],[67.075782,37.356144],[67.83,37.144994],[68.135562,37.023115],[68.859446,37.344336],[69.196273,37.151144],[69.518785,37.608997],[70.116578,37.588223],[70.270574,37.735165],[70.376304,38.138396],[70.806821,38.486282],[71.348131,38.258905],[71.239404,37.953265],[71.541918,37.905774],[71.448693,37.065645],[71.844638,36.738171],[72.193041,36.948288],[72.63689,37.047558],[73.260056,37.495257],[73.948696,37.421566],[74.980002,37.41999],[75.158028,37.133031],[74.575893,37.020841],[74.067552,36.836176],[72.920025,36.720007],[71.846292,36.509942],[71.262348,36.074388],[71.498768,35.650563],[71.613076,35.153203],[71.115019,34.733126],[71.156773,34.348911],[70.881803,33.988856],[69.930543,34.02012],[70.323594,33.358533],[69.687147,33.105499],[69.262522,32.501944],[69.317764,31.901412],[68.926677,31.620189],[68.556932,31.71331],[67.792689,31.58293],[67.683394,31.303154],[66.938891,31.304911],[66.381458,30.738899],[66.346473,29.887943],[65.046862,29.472181],[64.350419,29.560031],[64.148002,29.340819],[63.550261,29.468331],[62.549857,29.318572],[60.874248,29.829239],[61.781222,30.73585],[61.699314,31.379506],[60.941945,31.548075],[60.863655,32.18292],[60.536078,32.981269],[60.9637,33.528832],[60.52843,33.676446],[60.803193,34.404102],[61.210817,35.650072]]]}},
{"type":"Feature","id":"AGO","properties":{"name":"Angola"},"geometry":{"type":"MultiPolygon","coordinates":[[[[16.326528,-5.87747],[16.57318,-6.622645],[16.860191,-7.222298],[17.089996,-7.545689],[17.47297,-8.068551],[18.134222,-7.987678],[18.464176,-7.847014],[19.016752,-7.988246],[19.166613,-7.738184],[19.417502,-7.155429],[20.037723,-7.116361],[20.091622,-6.94309],[20.601823,-6.939318],[20.514748,-7.299606],[21.728111,-7.290872],[21.746456,-7.920085],[21.949131,-8.305901],[21.801801,-8.908707],[21.875182,-9.523708],[22.208753,-9.894796],[22.155268,-11.084801],[22.402798,-10.993075],[22.837345,-11.017622],[23.456791,-10.867863],[23.912215,-10.926826],[24.017894,-11.237298],[23.904154,-11.722282],[24.079905,-12.191297],[23.930922,-12.565848],[24.016137,-12.911046],[21.933886,-12.898437],[21.887843,-16.08031],[22.562478,-16.898451],[23.215048,-17.523116],[21.377176,-17.930636],[18.956187,-17.789095],[18.263309,-17.309951],[14.209707,-17.353101],[14.058501,-17.423381],[13.462362,-16.971212],[12.814081,-16.941343],[12.215461,-17.111668],[11.734199,-17.301889],[11.640096,-16.673142],[11.778537,-15.793816],[12.123581,-14.878316],[12.175619,-14.449144],[12.500095,-13.5477],[12.738479,-13.137906],[13.312914,-12.48363],[13.633721,-12.038645],[13.738728,-11.297863],[13.686379,-10.731076],[13.387328,-10.373578],[13.120988,-9.766897],[12.87537,-9.166934],[12.929061,-8.959091],[13.236433,-8.562629],[12.93304,-7.596539],[12.728298,-6.927122],[12.227347,-6.294448],[12.322432,-6.100092],[12.735171,-5.965682],[13.024869,-5.984389],[13.375597,-5.864241],[16.326528,-5.87747]]],[[[12.436688,-5.684304],[12.182337,-5.789931],[11.914963,-5.037987],[12.318608,-4.60623],[12.62076,-4.438023],[12.995517,-4.781103],[12.631612,-4.991271],[12.468004,-5.248362],[12.436688,-5.684304]]]]}},
{"type":"Feature","id":"ALB","properties":{"name":"Albania"},"geometry":{"type":"Polygon","coordinates":[[[20.590247,41.855404],[20.463175,41.515089],[20.605182,41.086226],[21.02004,40.842727],[20.99999,40.580004],[20.674997,40.435],[20.615,40.110007],[20.150016,39.624998],[19.98,39.694993],[19.960002,39.915006],[19.406082,40.250773],[19.319059,40.72723],[19.40355,41.409566],[19.540027,41.719986],[19.371769,41.877548],[19.304486,42.195745],[19.738051,42.688247],[19.801613,42.500093],[20.0707,42.58863],[20.283755,42.32026],[20.52295,42.21787],[20.590247,41.855404]]]}},
{"type":"Feature","id":"ARE","properties":{"name":"United Arab Emirates"},"geometry":{"type":"Polygon","coordinates":[[[51.579519,24.245497],[51.757441,24.294073],[51.794389,24.019826],[52.577081,24.177439],[53.404007,24.151317],[54.008001,24.121758],[54.693024,24.797892],[55.439025,25.439145],[56.070821,26.055464],[56.261042,25.714606],[56.396847,24.924732],[55.886233,24.920831],[55.804119,24.269604],[55.981214,24.130543],[55.528632,23.933604],[55.525841,23.524869],[55.234489,23.110993],[55.208341,22.70833],[55.006803,22.496948],[52.000733,23.001154],[51.617708,24.014219],[51.579519,24.245497]]]}},
{"type":"Feature","id":"ARG","properties":{"name":"Argentina"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-65.5,-55.2],[-66.45,-55.25],[-66.95992,-54.89681],[-67.56244,-54.87001],[-68.63335,-54.8695],[-68.63401,-52.63637],[-68.25,-53.1],[-67.75,-53.85],[-66.45,-54.45],[-65.05,-54.7],[-65.5,-55.2]]],[[[-64.964892,-22.075862],[-64.377021,-22.798091],[-63.986838,-21.993644],[-62.846468,-22.034985],[-62.685057,-22.249029],[-60.846565,-23.880713],[-60.028966,-24.032796],[-58.807128,-24.771459],[-57.777217,-25.16234],[-57.63366,-25.603657],[-58.618174,-27.123719],[-57.60976,-27.395899],[-56.486702,-27.548499],[-55.695846,-27.387837],[-54.788795,-26.621786],[-54.625291,-25.739255],[-54.13005,-25.547639],[-53.628349,-26.124865],[-53.648735,-26.923473],[-54.490725,-27.474757],[-55.162286,-27.881915],[-56.2909,-28.852761],[-57.625133,-30.216295],[-57.874937,-31.016556],[-58.14244,-32.044504],[-58.132648,-33.040567],[-58.349611,-33.263189],[-58.427074,-33.909454],[-58.495442,-34.43149],[-57.22583,-35.288027],[-57.362359,-35.97739],[-56.737487,-36.413126],[-56.788285,-36.901572],[-57.749157,-38.183871],[-59.231857,-38.72022],[-61.237445,-38.928425],[-62.335957,-38.827707],[-62.125763,-39.424105],[-62.330531,-40.172586],[-62.145994,-40.676897],[-62.745803,-41.028761],[-63.770495,-41.166789],[-64.73209,-40.802677],[-65.118035,-41.064315],[-64.978561,-42.058001],[-64.303408,-42.359016],[-63.755948,-42.043687],[-63.458059,-42.563138],[-64.378804,-42.873558],[-65.181804,-43.495381],[-65.328823,-44.501366],[-65.565269,-45.036786],[-66.509966,-45.039628],[-67.293794,-45.551896],[-67.580546,-46.301773],[-66.597066,-47.033925],[-65.641027,-47.236135],[-65.985088,-48.133289],[-67.166179,-48.697337],[-67.816088,-49.869669],[-68.728745,-50.264218],[-69.138539,-50.73251],[-68.815561,-51.771104],[-68.149995,-52.349983],[-68.571545,-52.299444],[-69.498362,-52.142761],[-71.914804,-52.009022],[-72.329404,-51.425956],[-72.309974,-50.67701],[-72.975747,-50.74145],[-73.328051,-50.378785],[-73.415436,-49.318436],[-72.648247,-48.878618],[-72.331161,-48.244238],[-72.447355,-47.738533],[-71.917258,-46.884838],[-71.552009,-45.560733],[-71.659316,-44.973689],[-71.222779,-44.784243],[-71.329801,-44.407522],[-71.793623,-44.207172],[-71.464056,-43.787611],[-71.915424,-43.408565],[-72.148898,-42.254888],[-71.746804,-42.051386],[-71.915734,-40.832339],[-71.680761,-39.808164],[-71.413517,-38.916022],[-70.814664,-38.552995],[-71.118625,-37.576827],[-71.121881,-36.658124],[-70.364769,-36.005089],[-70.388049,-35.169688],[-69.817309,-34.193571],[-69.814777,-33.273886],[-70.074399,-33.09121],[-70.535069,-31.36501],[-69.919008,-30.336339],[-70.01355,-29.367923],[-69.65613,-28.459141],[-69.001235,-27.521214],[-68.295542,-26.89934],[-68.5948,-26.506909],[-68.386001,-26.185016],[-68.417653,-24.518555],[-67.328443,-24.025303],[-66.985234,-22.986349],[-67.106674,-22.735925],[-66.273339,-21.83231],[-64.964892,-22.075862]]]]}},
{"type":"Feature","id":"ARM","properties":{"name":"Armenia"},"geometry":{"type":"Polygon","coordinates":[[[43.582746,41.092143],[44.97248,41.248129],[45.179496,40.985354],[45.560351,40.81229],[45.359175,40.561504],[45.891907,40.218476],[45.610012,39.899994],[46.034534,39.628021],[46.483499,39.464155],[46.50572,38.770605],[46.143623,38.741201],[45.735379,39.319719],[45.739978,39.473999],[45.298145,39.471751],[45.001987,39.740004],[44.79399,39.713003],[44.400009,40.005],[43.656436,40.253564],[43.752658,40.740201],[43.582746,41.092143]]]}},
{"type":"Feature","id":"ATA","properties":{"name":"Antarctica"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-59.572095,-80.040179],[-59.865849,-80.549657],[-60.159656,-81.000327],[-62.255393,-80.863178],[-64.488125,-80.921934],[-65.741666,-80.588827],[-65.741666,-80.549657],[-66.290031,-80.255773],[-64.037688,-80.294944],[-61.883246,-80.39287],[-61.138976,-79.981371],[-60.610119,-79.628679],[-59.572095,-80.040179]]],[[[-159.208184,-79.497059],[-161.127601,-79.634209],[-162.439847,-79.281465],[-163.027408,-78.928774],[-163.066604,-78.869966],[-163.712896,-78.595667],[-163.712896,-78.595667],[-163.105801,-78.223338],[-161.245113,-78.380176],[-160.246208,-78.693645],[-159.482405,-79.046338],[-159.208184,-79.497059]]],[[[-45.154758,-78.04707],[-43.920828,-78.478103],[-43.48995,-79.08556],[-43.372438,-79.516645],[-43.333267,-80.026123],[-44.880537,-80.339644],[-46.506174,-80.594357],[-48.386421,-80.829485],[-50.482107,-81.025442],[-52.851988,-80.966685],[-54.164259,-80.633528],[-53.987991,-80.222028],[-51.853134,-79.94773],[-50.991326,-79.614623],[-50.364595,-79.183487],[-49.914131,-78.811209],[-49.306959,-78.458569],[-48.660616,-78.047018],[-48.660616,-78.047019],[-48.151396,-78.04707],[-46.662857,-77.831476],[-45.154758,-78.04707]]],[[[-121.211511,-73.50099],[-119.918851,-73.657725],[-118.724143,-73.481353],[-119.292119,-73.834097],[-120.232217,-74.08881],[-121.62283,-74.010468],[-122.621735,-73.657778],[-122.621735,-73.657777],[-122.406245,-73.324619],[-121.211511,-73.50099]]],[[[-125.559566,-73.481353],[-124.031882,-73.873268],[-124.619469,-73.834097],[-125.912181,-73.736118],[-127.28313,-73.461769],[-127.28313,-73.461768],[-126.558472,-73.246226],[-125.559566,-73.481353]]],[[[-98.98155,-71.933334],[-97.884743,-72.070535],[-96.787937,-71.952971],[-96.20035,-72.521205],[-96.983765,-72.442864],[-98.198083,-72.482035],[-99.432013,-72.442864],[-100.783455,-72.50162],[-101.801868,-72.305663],[-102.330725,-71.894164],[-102.330725,-71.894164],[-101.703967,-71.717792],[-100.430919,-71.854993],[-98.98155,-71.933334]]],[[[-68.451346,-70.955823],[-68.333834,-71.406493],[-68.510128,-71.798407],[-68.784297,-72.170736],[-69.959471,-72.307885],[-71.075889,-72.503842],[-72.388134,-72.484257],[-71.8985,-72.092343],[-73.073622,-72.229492],[-74.19004,-72.366693],[-74.953895,-72.072757],[-75.012625,-71.661258],[-73.915819,-71.269345],[-73.915819,-71.269344],[-73.230331,-71.15178],[-72.074717,-71.190951],[-71.780962,-70.681473],[-71.72218,-70.309196],[-71.741791,-69.505782],[-71.173815,-69.035475],[-70.253252,-68.87874],[-69.724447,-69.251017],[-69.489422,-69.623346],[-69.058518,-70.074016],[-68.725541,-70.505153],[-68.451346,-70.955823]]],[[[-58.614143,-64.152467],[-59.045073,-64.36801],[-59.789342,-64.211223],[-60.611928,-64.309202],[-61.297416,-64.54433],[-62.0221,-64.799094],[-62.51176,-65.09303],[-62.648858,-65.484942],[-62.590128,-65.857219],[-62.120079,-66.190326],[-62.805567,-66.425505],[-63.74569,-66.503847],[-64.294106,-66.837004],[-64.881693,-67.150474],[-65.508425,-67.58161],[-65.665082,-67.953887],[-65.312545,-68.365335],[-64.783715,-68.678908],[-63.961103,-68.913984],[-63.1973,-69.227556],[-62.785955,-69.619419],[-62.570516,-69.991747],[-62.276736,-70.383661],[-61.806661,-70.716768],[-61.512906,-71.089045],[-61.375809,-72.010074],[-61.081977,-72.382351],[-61.003661,-72.774265],[-60.690269,-73.166179],[-60.827367,-73.695242],[-61.375809,-74.106742],[-61.96337,-74.439848],[-63.295201,-74.576997],[-63.74569,-74.92974],[-64.352836,-75.262847],[-65.860987,-75.635124],[-67.192818,-75.79191],[-68.446282,-76.007452],[-69.797724,-76.222995],[-70.600724,-76.634494],[-72.206776,-76.673665],[-73.969536,-76.634494],[-75.555977,-76.712887],[-77.24037,-76.712887],[-76.926979,-77.104802],[-75.399294,-77.28107],[-74.282876,-77.55542],[-73.656119,-77.908112],[-74.772536,-78.221633],[-76.4961,-78.123654],[-77.925858,-78.378419],[-77.984666,-78.789918],[-78.023785,-79.181833],[-76.848637,-79.514939],[-76.633224,-79.887216],[-75.360097,-80.259545],[-73.244852,-80.416331],[-71.442946,-80.69063],[-70.013163,-81.004151],[-68.191646,-81.317672],[-65.704279,-81.474458],[-63.25603,-81.748757],[-61.552026,-82.042692],[-59.691416,-82.37585],[-58.712121,-82.846106],[-58.222487,-83.218434],[-57.008117,-82.865691],[-55.362894,-82.571755],[-53.619771,-82.258235],[-51.543644,-82.003521],[-49.76135,-81.729171],[-47.273931,-81.709586],[-44.825708,-81.846735],[-42.808363,-82.081915],[-42.16202,-81.65083],[-40.771433,-81.356894],[-38.244818,-81.337309],[-36.26667,-81.121715],[-34.386397,-80.906172],[-32.310296,-80.769023],[-30.097098,-80.592651],[-28.549802,-80.337938],[-29.254901,-79.985195],[-29.685805,-79.632503],[-29.685805,-79.260226],[-31.624808,-79.299397],[-33.681324,-79.456132],[-35.639912,-79.456132],[-35.914107,-79.083855],[-35.77701,-78.339248],[-35.326546,-78.123654],[-33.896763,-77.888526],[-32.212369,-77.65345],[-30.998051,-77.359515],[-29.783732,-77.065579],[-28.882779,-76.673665],[-27.511752,-76.497345],[-26.160336,-76.360144],[-25.474822,-76.281803],[-23.927552,-76.24258],[-22.458598,-76.105431],[-21.224694,-75.909474],[-20.010375,-75.674346],[-18.913543,-75.439218],[-17.522982,-75.125698],[-16.641589,-74.79254],[-15.701491,-74.498604],[-15.40771,-74.106742],[-16.46532,-73.871614],[-16.112784,-73.460114],[-15.446855,-73.146542],[-14.408805,-72.950585],[-13.311973,-72.715457],[-12.293508,-72.401936],[-11.510067,-72.010074],[-11.020433,-71.539767],[-10.295774,-71.265416],[-9.101015,-71.324224],[-8.611381,-71.65733],[-7.416622,-71.696501],[-7.377451,-71.324224],[-6.868232,-70.93231],[-5.790985,-71.030289],[-5.536375,-71.402617],[-4.341667,-71.461373],[-3.048981,-71.285053],[-1.795492,-71.167438],[-0.659489,-71.226246],[-0.228637,-71.637745],[0.868195,-71.304639],[1.886686,-71.128267],[3.022638,-70.991118],[4.139055,-70.853917],[5.157546,-70.618789],[6.273912,-70.462055],[7.13572,-70.246512],[7.742866,-69.893769],[8.48711,-70.148534],[9.525135,-70.011333],[10.249845,-70.48164],[10.817821,-70.834332],[11.953824,-70.638375],[12.404287,-70.246512],[13.422778,-69.972162],[14.734998,-70.030918],[15.126757,-70.403247],[15.949342,-70.030918],[17.026589,-69.913354],[18.201711,-69.874183],[19.259373,-69.893769],[20.375739,-70.011333],[21.452985,-70.07014],[21.923034,-70.403247],[22.569403,-70.697182],[23.666184,-70.520811],[24.841357,-70.48164],[25.977309,-70.48164],[27.093726,-70.462055],[28.09258,-70.324854],[29.150242,-70.20729],[30.031583,-69.93294],[30.971733,-69.75662],[31.990172,-69.658641],[32.754053,-69.384291],[33.302443,-68.835642],[33.870419,-68.502588],[34.908495,-68.659271],[35.300202,-69.012014],[36.16201,-69.247142],[37.200035,-69.168748],[37.905108,-69.52144],[38.649404,-69.776205],[39.667894,-69.541077],[40.020431,-69.109941],[40.921358,-68.933621],[41.959434,-68.600514],[42.938702,-68.463313],[44.113876,-68.267408],[44.897291,-68.051866],[45.719928,-67.816738],[46.503343,-67.601196],[47.44344,-67.718759],[48.344419,-67.366068],[48.990736,-67.091718],[49.930885,-67.111303],[50.753471,-66.876175],[50.949325,-66.523484],[51.791547,-66.249133],[52.614133,-66.053176],[53.613038,-65.89639],[54.53355,-65.818049],[55.414943,-65.876805],[56.355041,-65.974783],[57.158093,-66.249133],[57.255968,-66.680218],[58.137361,-67.013324],[58.744508,-67.287675],[59.939318,-67.405239],[60.605221,-67.679589],[61.427806,-67.953887],[62.387489,-68.012695],[63.19049,-67.816738],[64.052349,-67.405239],[64.992447,-67.620729],[65.971715,-67.738345],[66.911864,-67.855909],[67.891133,-67.934302],[68.890038,-67.934302],[69.712624,-68.972791],[69.673453,-69.227556],[69.555941,-69.678226],[68.596258,-69.93294],[67.81274,-70.305268],[67.949889,-70.697182],[69.066307,-70.677545],[68.929157,-71.069459],[68.419989,-71.441788],[67.949889,-71.853287],[68.71377,-72.166808],[69.869307,-72.264787],[71.024895,-72.088415],[71.573285,-71.696501],[71.906288,-71.324224],[72.454627,-71.010703],[73.08141,-70.716768],[73.33602,-70.364024],[73.864877,-69.874183],[74.491557,-69.776205],[75.62756,-69.737034],[76.626465,-69.619419],[77.644904,-69.462684],[78.134539,-69.07077],[78.428371,-68.698441],[79.113859,-68.326216],[80.093127,-68.071503],[80.93535,-67.875546],[81.483792,-67.542388],[82.051767,-67.366068],[82.776426,-67.209282],[83.775331,-67.30726],[84.676206,-67.209282],[85.655527,-67.091718],[86.752359,-67.150474],[87.477017,-66.876175],[87.986289,-66.209911],[88.358411,-66.484261],[88.828408,-66.954568],[89.67063,-67.150474],[90.630365,-67.228867],[91.5901,-67.111303],[92.608539,-67.189696],[93.548637,-67.209282],[94.17542,-67.111303],[95.017591,-67.170111],[95.781472,-67.385653],[96.682399,-67.248504],[97.759646,-67.248504],[98.68021,-67.111303],[99.718182,-67.248504],[100.384188,-66.915346],[100.893356,-66.58224],[101.578896,-66.30789],[102.832411,-65.563284],[103.478676,-65.700485],[104.242557,-65.974783],[104.90846,-66.327527],[106.181561,-66.934931],[107.160881,-66.954568],[108.081393,-66.954568],[109.15864,-66.837004],[110.235835,-66.699804],[111.058472,-66.425505],[111.74396,-66.13157],[112.860378,-66.092347],[113.604673,-65.876805],[114.388088,-66.072762],[114.897308,-66.386283],[115.602381,-66.699804],[116.699161,-66.660633],[117.384701,-66.915346],[118.57946,-67.170111],[119.832924,-67.268089],[120.871,-67.189696],[121.654415,-66.876175],[122.320369,-66.562654],[123.221296,-66.484261],[124.122274,-66.621462],[125.160247,-66.719389],[126.100396,-66.562654],[127.001427,-66.562654],[127.882768,-66.660633],[128.80328,-66.758611],[129.704259,-66.58224],[130.781454,-66.425505],[131.799945,-66.386283],[132.935896,-66.386283],[133.85646,-66.288304],[134.757387,-66.209963],[135.031582,-65.72007],[135.070753,-65.308571],[135.697485,-65.582869],[135.873805,-66.033591],[136.206705,-66.44509],[136.618049,-66.778197],[137.460271,-66.954568],[138.596223,-66.895761],[139.908442,-66.876175],[140.809421,-66.817367],[142.121692,-66.817367],[143.061842,-66.797782],[144.374061,-66.837004],[145.490427,-66.915346],[146.195552,-67.228867],[145.999699,-67.601196],[146.646067,-67.895131],[147.723263,-68.130259],[148.839629,-68.385024],[150.132314,-68.561292],[151.483705,-68.71813],[152.502247,-68.874813],[153.638199,-68.894502],[154.284567,-68.561292],[155.165857,-68.835642],[155.92979,-69.149215],[156.811132,-69.384291],[158.025528,-69.482269],[159.181013,-69.599833],[159.670699,-69.991747],[160.80665,-70.226875],[161.570479,-70.579618],[162.686897,-70.736353],[163.842434,-70.716768],[164.919681,-70.775524],[166.11444,-70.755938],[167.309095,-70.834332],[168.425616,-70.971481],[169.463589,-71.20666],[170.501665,-71.402617],[171.20679,-71.696501],[171.089227,-72.088415],[170.560422,-72.441159],[170.109958,-72.891829],[169.75737,-73.24452],[169.287321,-73.65602],[167.975101,-73.812806],[167.387489,-74.165498],[166.094803,-74.38104],[165.644391,-74.772954],[164.958851,-75.145283],[164.234193,-75.458804],[163.822797,-75.870303],[163.568239,-76.24258],[163.47026,-76.693302],[163.489897,-77.065579],[164.057873,-77.457442],[164.273363,-77.82977],[164.743464,-78.182514],[166.604126,-78.319611],[166.995781,-78.750748],[165.193876,-78.907483],[163.666217,-79.123025],[161.766385,-79.162248],[160.924162,-79.730482],[160.747894,-80.200737],[160.316964,-80.573066],[159.788211,-80.945395],[161.120016,-81.278501],[161.629287,-81.690001],[162.490992,-82.062278],[163.705336,-82.395435],[165.095949,-82.708956],[166.604126,-83.022477],[168.895665,-83.335998],[169.404782,-83.825891],[172.283934,-84.041433],[172.477049,-84.117914],[173.224083,-84.41371],[175.985672,-84.158997],[178.277212,-84.472518],[180,-84.71338],[-179.942499,-84.721443],[-179.058677,-84.139412],[-177.256772,-84.452933],[-177.140807,-84.417941],[-176.084673,-84.099259],[-175.947235,-84.110449],[-175.829882,-84.117914],[-174.382503,-84.534323],[-173.116559,-84.117914],[-172.889106,-84.061019],[-169.951223,-83.884647],[-168.999989,-84.117914],[-168.530199,-84.23739],[-167.022099,-84.570497],[-164.182144,-84.82521],[-161.929775,-85.138731],[-158.07138,-85.37391],[-155.192253,-85.09956],[-150.942099,-85.295517],[-148.533073,-85.609038],[-145.888918,-85.315102],[-143.107718,-85.040752],[-142.892279,-84.570497],[-146.829068,-84.531274],[-150.060732,-84.296146],[-150.902928,-83.904232],[-153.586201,-83.68869],[-153.409907,-83.23802],[-153.037759,-82.82652],[-152.665637,-82.454192],[-152.861517,-82.042692],[-154.526299,-81.768394],[-155.29018,-81.41565],[-156.83745,-81.102129],[-154.408787,-81.160937],[-152.097662,-81.004151],[-150.648293,-81.337309],[-148.865998,-81.043373],[-147.22075,-80.671045],[-146.417749,-80.337938],[-146.770286,-79.926439],[-148.062947,-79.652089],[-149.531901,-79.358205],[-151.588416,-79.299397],[-153.390322,-79.162248],[-155.329376,-79.064269],[-155.975668,-78.69194],[-157.268302,-78.378419],[-158.051768,-78.025676],[-158.365134,-76.889207],[-157.875474,-76.987238],[-156.974573,-77.300759],[-155.329376,-77.202728],[-153.742832,-77.065579],[-152.920247,-77.496664],[-151.33378,-77.398737],[-150.00195,-77.183143],[-148.748486,-76.908845],[-147.612483,-76.575738],[-146.104409,-76.47776],[-146.143528,-76.105431],[-146.496091,-75.733154],[-146.20231,-75.380411],[-144.909624,-75.204039],[-144.322037,-75.537197],[-142.794353,-75.34124],[-141.638764,-75.086475],[-140.209007,-75.06689],[-138.85759,-74.968911],[-137.5062,-74.733783],[-136.428901,-74.518241],[-135.214583,-74.302699],[-134.431194,-74.361455],[-133.745654,-74.439848],[-132.257168,-74.302699],[-130.925311,-74.479019],[-129.554284,-74.459433],[-128.242038,-74.322284],[-126.890622,-74.420263],[-125.402082,-74.518241],[-124.011496,-74.479019],[-122.562152,-74.498604],[-121.073613,-74.518241],[-119.70256,-74.479019],[-118.684145,-74.185083],[-117.469801,-74.028348],[-116.216312,-74.243891],[-115.021552,-74.067519],[-113.944331,-73.714828],[-113.297988,-74.028348],[-112.945452,-74.38104],[-112.299083,-74.714198],[-111.261059,-74.420263],[-110.066325,-74.79254],[-108.714909,-74.910103],[-107.559346,-75.184454],[-106.149148,-75.125698],[-104.876074,-74.949326],[-103.367949,-74.988497],[-102.016507,-75.125698],[-100.645531,-75.302018],[-100.1167,-74.870933],[-100.763043,-74.537826],[-101.252703,-74.185083],[-102.545337,-74.106742],[-103.113313,-73.734413],[-103.328752,-73.362084],[-103.681289,-72.61753],[-102.917485,-72.754679],[-101.60524,-72.813436],[-100.312528,-72.754679],[-99.13738,-72.911414],[-98.118889,-73.20535],[-97.688037,-73.558041],[-96.336595,-73.616849],[-95.043961,-73.4797],[-93.672907,-73.283743],[-92.439003,-73.166179],[-91.420564,-73.401307],[-90.088733,-73.322914],[-89.226951,-72.558722],[-88.423951,-73.009393],[-87.268337,-73.185764],[-86.014822,-73.087786],[-85.192236,-73.4797],[-83.879991,-73.518871],[-82.665646,-73.636434],[-81.470913,-73.851977],[-80.687447,-73.4797],[-80.295791,-73.126956],[-79.296886,-73.518871],[-77.925858,-73.420892],[-76.907367,-73.636434],[-76.221879,-73.969541],[-74.890049,-73.871614],[-73.852024,-73.65602],[-72.833533,-73.401307],[-71.619215,-73.264157],[-70.209042,-73.146542],[-68.935916,-73.009393],[-67.956622,-72.79385],[-67.369061,-72.480329],[-67.134036,-72.049244],[-67.251548,-71.637745],[-67.56494,-71.245831],[-67.917477,-70.853917],[-68.230843,-70.462055],[-68.485452,-70.109311],[-68.544209,-69.717397],[-68.446282,-69.325535],[-67.976233,-68.953206],[-67.5845,-68.541707],[-67.427843,-68.149844],[-67.62367,-67.718759],[-67.741183,-67.326845],[-67.251548,-66.876175],[-66.703184,-66.58224],[-66.056815,-66.209963],[-65.371327,-65.89639],[-64.568276,-65.602506],[-64.176542,-65.171423],[-63.628152,-64.897073],[-63.001394,-64.642308],[-62.041686,-64.583552],[-61.414928,-64.270031],[-60.709855,-64.074074],[-59.887269,-63.95651],[-59.162585,-63.701745],[-58.594557,-63.388224],[-57.811143,-63.27066],[-57.223582,-63.525425],[-57.59573,-63.858532],[-58.614143,-64.152467]]]]}},
{"type":"Feature","id":"ATF","properties":{"name":"French Southern and Antarctic Lands"},"geometry":{"type":"Polygon","coordinates":[[[68.935,-48.625],[69.58,-48.94],[70.525,-49.065],[70.56,-49.255],[70.28,-49.71],[68.745,-49.775],[68.72,-49.2425],[68.8675,-48.83],[68.935,-48.625]]]}},
{"type":"Feature","id":"AUS","properties":{"name":"Australia"},"geometry":{"type":"MultiPolygon","coordinates":[[[[145.397978,-40.792549],[146.364121,-41.137695],[146.908584,-41.000546],[147.689259,-40.808258],[148.289068,-40.875438],[148.359865,-42.062445],[148.017301,-42.407024],[147.914052,-43.211522],[147.564564,-42.937689],[146.870343,-43.634597],[146.663327,-43.580854],[146.048378,-43.549745],[145.43193,-42.693776],[145.29509,-42.03361],[144.718071,-41.162552],[144.743755,-40.703975],[145.397978,-40.792549]]],[[[143.561811,-13.763656],[143.922099,-14.548311],[144.563714,-14.171176],[144.894908,-14.594458],[145.374724,-14.984976],[145.271991,-15.428205],[145.48526,-16.285672],[145.637033,-16.784918],[145.888904,-16.906926],[146.160309,-17.761655],[146.063674,-18.280073],[146.387478,-18.958274],[147.471082,-19.480723],[148.177602,-19.955939],[148.848414,-20.39121],[148.717465,-20.633469],[149.28942,-21.260511],[149.678337,-22.342512],[150.077382,-22.122784],[150.482939,-22.556142],[150.727265,-22.402405],[150.899554,-23.462237],[151.609175,-24.076256],[152.07354,-24.457887],[152.855197,-25.267501],[153.136162,-26.071173],[153.161949,-26.641319],[153.092909,-27.2603],[153.569469,-28.110067],[153.512108,-28.995077],[153.339095,-29.458202],[153.069241,-30.35024],[153.089602,-30.923642],[152.891578,-31.640446],[152.450002,-32.550003],[151.709117,-33.041342],[151.343972,-33.816023],[151.010555,-34.31036],[150.714139,-35.17346],[150.32822,-35.671879],[150.075212,-36.420206],[149.946124,-37.109052],[149.997284,-37.425261],[149.423882,-37.772681],[148.304622,-37.809061],[147.381733,-38.219217],[146.922123,-38.606532],[146.317922,-39.035757],[145.489652,-38.593768],[144.876976,-38.417448],[145.032212,-37.896188],[144.485682,-38.085324],[143.609974,-38.809465],[142.745427,-38.538268],[142.17833,-38.380034],[141.606582,-38.308514],[140.638579,-38.019333],[139.992158,-37.402936],[139.806588,-36.643603],[139.574148,-36.138362],[139.082808,-35.732754],[138.120748,-35.612296],[138.449462,-35.127261],[138.207564,-34.384723],[137.71917,-35.076825],[136.829406,-35.260535],[137.352371,-34.707339],[137.503886,-34.130268],[137.890116,-33.640479],[137.810328,-32.900007],[136.996837,-33.752771],[136.372069,-34.094766],[135.989043,-34.890118],[135.208213,-34.47867],[135.239218,-33.947953],[134.613417,-33.222778],[134.085904,-32.848072],[134.273903,-32.617234],[132.990777,-32.011224],[132.288081,-31.982647],[131.326331,-31.495803],[129.535794,-31.590423],[128.240938,-31.948489],[127.102867,-32.282267],[126.148714,-32.215966],[125.088623,-32.728751],[124.221648,-32.959487],[124.028947,-33.483847],[123.659667,-33.890179],[122.811036,-33.914467],[122.183064,-34.003402],[121.299191,-33.821036],[120.580268,-33.930177],[119.893695,-33.976065],[119.298899,-34.509366],[119.007341,-34.464149],[118.505718,-34.746819],[118.024972,-35.064733],[117.295507,-35.025459],[116.625109,-35.025097],[115.564347,-34.386428],[115.026809,-34.196517],[115.048616,-33.623425],[115.545123,-33.487258],[115.714674,-33.259572],[115.679379,-32.900369],[115.801645,-32.205062],[115.689611,-31.612437],[115.160909,-30.601594],[114.997043,-30.030725],[115.040038,-29.461095],[114.641974,-28.810231],[114.616498,-28.516399],[114.173579,-28.118077],[114.048884,-27.334765],[113.477498,-26.543134],[113.338953,-26.116545],[113.778358,-26.549025],[113.440962,-25.621278],[113.936901,-25.911235],[114.232852,-26.298446],[114.216161,-25.786281],[113.721255,-24.998939],[113.625344,-24.683971],[113.393523,-24.384764],[113.502044,-23.80635],[113.706993,-23.560215],[113.843418,-23.059987],[113.736552,-22.475475],[114.149756,-21.755881],[114.225307,-22.517488],[114.647762,-21.82952],[115.460167,-21.495173],[115.947373,-21.068688],[116.711615,-20.701682],[117.166316,-20.623599],[117.441545,-20.746899],[118.229559,-20.374208],[118.836085,-20.263311],[118.987807,-20.044203],[119.252494,-19.952942],[119.805225,-19.976506],[120.85622,-19.683708],[121.399856,-19.239756],[121.655138,-18.705318],[122.241665,-18.197649],[122.286624,-17.798603],[122.312772,-17.254967],[123.012574,-16.4052],[123.433789,-17.268558],[123.859345,-17.069035],[123.503242,-16.596506],[123.817073,-16.111316],[124.258287,-16.327944],[124.379726,-15.56706],[124.926153,-15.0751],[125.167275,-14.680396],[125.670087,-14.51007],[125.685796,-14.230656],[126.125149,-14.347341],[126.142823,-14.095987],[126.582589,-13.952791],[127.065867,-13.817968],[127.804633,-14.276906],[128.35969,-14.86917],[128.985543,-14.875991],[129.621473,-14.969784],[129.4096,-14.42067],[129.888641,-13.618703],[130.339466,-13.357376],[130.183506,-13.10752],[130.617795,-12.536392],[131.223495,-12.183649],[131.735091,-12.302453],[132.575298,-12.114041],[132.557212,-11.603012],[131.824698,-11.273782],[132.357224,-11.128519],[133.019561,-11.376411],[133.550846,-11.786515],[134.393068,-12.042365],[134.678632,-11.941183],[135.298491,-12.248606],[135.882693,-11.962267],[136.258381,-12.049342],[136.492475,-11.857209],[136.95162,-12.351959],[136.685125,-12.887223],[136.305407,-13.29123],[135.961758,-13.324509],[136.077617,-13.724278],[135.783836,-14.223989],[135.428664,-14.715432],[135.500184,-14.997741],[136.295175,-15.550265],[137.06536,-15.870762],[137.580471,-16.215082],[138.303217,-16.807604],[138.585164,-16.806622],[139.108543,-17.062679],[139.260575,-17.371601],[140.215245,-17.710805],[140.875463,-17.369069],[141.07111,-16.832047],[141.274095,-16.38887],[141.398222,-15.840532],[141.702183,-15.044921],[141.56338,-14.561333],[141.63552,-14.270395],[141.519869,-13.698078],[141.65092,-12.944688],[141.842691,-12.741548],[141.68699,-12.407614],[141.928629,-11.877466],[142.118488,-11.328042],[142.143706,-11.042737],[142.51526,-10.668186],[142.79731,-11.157355],[142.866763,-11.784707],[143.115947,-11.90563],[143.158632,-12.325656],[143.522124,-12.834358],[143.597158,-13.400422],[143.561811,-13.763656]]]]}},
{"type":"Feature","id":"AUT","properties":{"name":"Austria"},"geometry":{"type":"Polygon","coordinates":[[[16.979667,48.123497],[16.903754,47.714866],[16.340584,47.712902],[16.534268,47.496171],[16.202298,46.852386],[16.011664,46.683611],[15.137092,46.658703],[14.632472,46.431817],[13.806475,46.509306],[12.376485,46.767559],[12.153088,47.115393],[11.164828,46.941579],[11.048556,46.751359],[10.442701,46.893546],[9.932448,46.920728],[9.47997,47.10281],[9.632932,47.347601],[9.594226,47.525058],[9.896068,47.580197],[10.402084,47.302488],[10.544504,47.566399],[11.426414,47.523766],[12.141357,47.703083],[12.62076,47.672388],[12.932627,47.467646],[13.025851,47.637584],[12.884103,48.289146],[13.243357,48.416115],[13.595946,48.877172],[14.338898,48.555305],[14.901447,48.964402],[15.253416,49.039074],[16.029647,48.733899],[16.499283,48.785808],[16.960288,48.596982],[16.879983,48.470013],[16.979667,48.123497]]]}},
{"type":"Feature","id":"AZE","properties":{"name":"Azerbaijan"},"geometry":{"type":"MultiPolygon","coordinates":[[[[45.001987,39.740004],[45.298145,39.471751],[45.739978,39.473999],[45.735379,39.319719],[46.143623,38.741201],[45.457722,38.874139],[44.952688,39.335765],[44.79399,39.713003],[45.001987,39.740004]]],[[[47.373315,41.219732],[47.815666,41.151416],[47.987283,41.405819],[48.584353,41.80887],[49.110264,41.282287],[49.618915,40.572924],[50.08483,40.526157],[50.392821,40.256561],[49.569202,40.176101],[49.395259,39.399482],[49.223228,39.049219],[48.856532,38.815486],[48.883249,38.320245],[48.634375,38.270378],[48.010744,38.794015],[48.355529,39.288765],[48.060095,39.582235],[47.685079,39.508364],[46.50572,38.770605],[46.483499,39.464155],[46.034534,39.628021],[45.610012,39.899994],[45.891907,40.218476],[45.359175,40.561504],[45.560351,40.81229],[45.179496,40.985354],[44.97248,41.248129],[45.217426,41.411452],[45.962601,41.123873],[46.501637,41.064445],[46.637908,41.181673],[46.145432,41.722802],[46.404951,41.860675],[46.686071,41.827137],[47.373315,41.219732]]]]}},
{"type":"Feature","id":"BDI","properties":{"name":"Burundi"},"geometry":{"type":"Polygon","coordinates":[[[29.339998,-4.499983],[29.276384,-3.293907],[29.024926,-2.839258],[29.632176,-2.917858],[29.938359,-2.348487],[30.469696,-2.413858],[30.527677,-2.807632],[30.743013,-3.034285],[30.752263,-3.35933],[30.50556,-3.568567],[30.116333,-4.090138],[29.753512,-4.452389],[29.339998,-4.499983]]]}},
{"type":"Feature","id":"BEL","properties":{"name":"Belgium"},"geometry":{"type":"Polygon","coordinates":[[[3.314971,51.345781],[4.047071,51.267259],[4.973991,51.475024],[5.606976,51.037298],[6.156658,50.803721],[6.043073,50.128052],[5.782417,50.090328],[5.674052,49.529484],[4.799222,49.985373],[4.286023,49.907497],[3.588184,50.378992],[3.123252,50.780363],[2.658422,50.796848],[2.513573,51.148506],[3.314971,51.345781]]]}},
{"type":"Feature","id":"BEN","properties":{"name":"Benin"},"geometry":{"type":"Polygon","coordinates":[[[2.691702,6.258817],[1.865241,6.142158],[1.618951,6.832038],[1.664478,9.12859],[1.463043,9.334624],[1.425061,9.825395],[1.077795,10.175607],[0.772336,10.470808],[0.899563,10.997339],[1.24347,11.110511],[1.447178,11.547719],[1.935986,11.64115],[2.154474,11.94015],[2.490164,12.233052],[2.848643,12.235636],[3.61118,11.660167],[3.572216,11.327939],[3.797112,10.734746],[3.60007,10.332186],[3.705438,10.06321],[3.220352,9.444153],[2.912308,9.137608],[2.723793,8.506845],[2.749063,7.870734],[2.691702,6.258817]]]}},
{"type":"Feature","id":"BFA","properties":{"name":"Burkina Faso"},"geometry":{"type":"Polygon","coordinates":[[[-2.827496,9.642461],[-3.511899,9.900326],[-3.980449,9.862344],[-4.330247,9.610835],[-4.779884,9.821985],[-4.954653,10.152714],[-5.404342,10.370737],[-5.470565,10.95127],[-5.197843,11.375146],[-5.220942,11.713859],[-4.427166,12.542646],[-4.280405,13.228444],[-4.006391,13.472485],[-3.522803,13.337662],[-3.103707,13.541267],[-2.967694,13.79815],[-2.191825,14.246418],[-2.001035,14.559008],[-1.066363,14.973815],[-0.515854,15.116158],[-0.266257,14.924309],[0.374892,14.928908],[0.295646,14.444235],[0.429928,13.988733],[0.993046,13.33575],[1.024103,12.851826],[2.177108,12.625018],[2.154474,11.94015],[1.935986,11.64115],[1.447178,11.547719],[1.24347,11.110511],[0.899563,10.997339],[0.023803,11.018682],[-0.438702,11.098341],[-0.761576,10.93693],[-1.203358,11.009819],[-2.940409,10.96269],[-2.963896,10.395335],[-2.827496,9.642461]]]}},
{"type":"Feature","id":"BGD","properties":{"name":"Bangladesh"},"geometry":{"type":"Polygon","coordinates":[[[92.672721,22.041239],[92.652257,21.324048],[92.303234,21.475485],[92.368554,20.670883],[92.082886,21.192195],[92.025215,21.70157],[91.834891,22.182936],[91.417087,22.765019],[90.496006,22.805017],[90.586957,22.392794],[90.272971,21.836368],[89.847467,22.039146],[89.70205,21.857116],[89.418863,21.966179],[89.031961,22.055708],[88.876312,22.879146],[88.52977,23.631142],[88.69994,24.233715],[88.084422,24.501657],[88.306373,24.866079],[88.931554,25.238692],[88.209789,25.768066],[88.563049,26.446526],[89.355094,26.014407],[89.832481,25.965082],[89.920693,25.26975],[90.872211,25.132601],[91.799596,25.147432],[92.376202,24.976693],[91.915093,24.130414],[91.46773,24.072639],[91.158963,23.503527],[91.706475,22.985264],[91.869928,23.624346],[92.146035,23.627499],[92.672721,22.041239]]]}},
{"type":"Feature","id":"BGR","properties":{"name":"Bulgaria"},"geometry":{"type":"Polygon","coordinates":[[[22.65715,44.234923],[22.944832,43.823785],[23.332302,43.897011],[24.100679,43.741051],[25.569272,43.688445],[26.065159,43.943494],[27.2424,44.175986],[27.970107,43.812468],[28.558081,43.707462],[28.039095,43.293172],[27.673898,42.577892],[27.99672,42.007359],[27.135739,42.141485],[26.117042,41.826905],[26.106138,41.328899],[25.197201,41.234486],[24.492645,41.583896],[23.692074,41.309081],[22.952377,41.337994],[22.881374,41.999297],[22.380526,42.32026],[22.545012,42.461362],[22.436595,42.580321],[22.604801,42.898519],[22.986019,43.211161],[22.500157,43.642814],[22.410446,44.008063],[22.65715,44.234923]]]}},
{"type":"Feature","id":"BHS","properties":{"name":"The Bahamas"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-77.53466,23.75975],[-77.78,23.71],[-78.03405,24.28615],[-78.40848,24.57564],[-78.19087,25.2103],[-77.89,25.17],[-77.54,24.34],[-77.53466,23.75975]]],[[[-77.82,26.58],[-78.91,26.42],[-78.98,26.79],[-78.51,26.87],[-77.85,26.84],[-77.82,26.58]]],[[[-77,26.59],[-77.17255,25.87918],[-77.35641,26.00735],[-77.34,26.53],[-77.78802,26.92516],[-77.79,27.04],[-77,26.59]]]]}},
{"type":"Feature","id":"BIH","properties":{"name":"Bosnia and Herzegovina"},"geometry":{"type":"Polygon","coordinates":[[[19.005486,44.860234],[19.36803,44.863],[19.11761,44.42307],[19.59976,44.03847],[19.454,43.5681],[19.21852,43.52384],[19.03165,43.43253],[18.70648,43.20011],[18.56,42.65],[17.674922,43.028563],[17.297373,43.446341],[16.916156,43.667722],[16.456443,44.04124],[16.23966,44.351143],[15.750026,44.818712],[15.959367,45.233777],[16.318157,45.004127],[16.534939,45.211608],[17.002146,45.233777],[17.861783,45.06774],[18.553214,45.08159],[19.005486,44.860234]]]}},
{"type":"Feature","id":"BLR","properties":{"name":"Belarus"},"geometry":{"type":"Polygon","coordinates":[[[23.484128,53.912498],[24.450684,53.905702],[25.536354,54.282423],[25.768433,54.846963],[26.588279,55.167176],[26.494331,55.615107],[27.10246,55.783314],[28.176709,56.16913],[29.229513,55.918344],[29.371572,55.670091],[29.896294,55.789463],[30.873909,55.550976],[30.971836,55.081548],[30.757534,54.811771],[31.384472,54.157056],[31.791424,53.974639],[31.731273,53.794029],[32.405599,53.618045],[32.693643,53.351421],[32.304519,53.132726],[31.497644,53.167427],[31.305201,53.073996],[31.540018,52.742052],[31.785998,52.101678],[30.927549,52.042353],[30.619454,51.822806],[30.555117,51.319503],[30.157364,51.416138],[29.254938,51.368234],[28.992835,51.602044],[28.617613,51.427714],[28.241615,51.572227],[27.454066,51.592303],[26.337959,51.832289],[25.327788,51.910656],[24.553106,51.888461],[24.005078,51.617444],[23.527071,51.578454],[23.508002,52.023647],[23.199494,52.486977],[23.799199,52.691099],[23.804935,53.089731],[23.527536,53.470122],[23.484128,53.912498]]]}},
{"type":"Feature","id":"BLZ","properties":{"name":"Belize"},"geometry":{"type":"Polygon","coordinates":[[[-89.14308,17.808319],[-89.150909,17.955468],[-89.029857,18.001511],[-88.848344,17.883198],[-88.490123,18.486831],[-88.300031,18.499982],[-88.296336,18.353273],[-88.106813,18.348674],[-88.123479,18.076675],[-88.285355,17.644143],[-88.197867,17.489475],[-88.302641,17.131694],[-88.239518,17.036066],[-88.355428,16.530774],[-88.551825,16.265467],[-88.732434,16.233635],[-88.930613,15.887273],[-89.229122,15.886938],[-89.150806,17.015577],[-89.14308,17.808319]]]}},
{"type":"Feature","id":"BMU","properties":{"name":"Bermuda"},"geometry":{"type":"Polygon","coordinates":[[[-64.7799734332998,32.3072000581802],[-64.7873319183061,32.3039237143428],[-64.7946942710173,32.3032682700388],[-64.8094297981283,32.3098175728414],[-64.8167896352437,32.3058845718466],[-64.8101968029642,32.3022833180511],[-64.7962291465484,32.2934409732427],[-64.7815086336978,32.2868973114514],[-64.7997025513437,32.2796896417328],[-64.8066707691087,32.2747767569465],[-64.8225587873683,32.2669111289395],[-64.8287548840306,32.2669075473817],[-64.8306732143498,32.2583944840235],[-64.8399924854972,32.254782282336],[-64.8566090462354,32.2547740387514],[-64.8682296789446,32.2616393614322],[-64.8628241459563,32.2724481933959],[-64.8748651338951,32.2757120264753],[-64.8717752856644,32.2819371582026],[-64.8671422127295,32.2930760547989],[-64.8559068764437,32.2960321186471],[-64.8597429072279,32.3015842021933],[-64.8439233486717,32.3140553852543],[-64.8350242329311,32.3242161760006],[-64.8338690593672,32.3294587561557],[-64.8520298651164,32.3110911879954],[-64.8635922932573,32.3048469433363],[-64.8686668994079,32.30910745083],[-64.8721354593415,32.3041908606301],[-64.8779667328485,32.3038632800462],[-64.8780046844321,32.2907757831692],[-64.8849776658292,32.2819261366004],[-64.8783230004629,32.2613001418681],[-64.863194968877,32.2465799485801],[-64.8519819555722,32.2485519134663],[-64.842311980074,32.2492123317296],[-64.8388242605209,32.2475773472534],[-64.8334002575532,32.2462714714698],[-64.8256389530584,32.2472637398594],[-64.8205697556026,32.2531698880328],[-64.8105087275579,32.2561208974156],[-64.7900177727338,32.2659446936992],[-64.7745415970416,32.2718413023427],[-64.7644742436426,32.2855931353214],[-64.7551803442276,32.2908326702531],[-64.7423982971436,32.2996734994024],[-64.7206991797682,32.3137542201258],[-64.7117851247134,32.3176823360806],[-64.6962778813133,32.3275029115532],[-64.6768921127452,32.3324095397555],[-64.6567136927777,32.3451776458469],[-64.6532168823499,32.3494356627941],[-64.6605720384429,32.3589423487763],[-64.65125819471,32.3615600906466],[-64.6462011670816,32.36975169749],[-64.6613227512832,32.3763135008721],[-64.6690666074397,32.388444543924],[-64.6834270548595,32.3854968316788],[-64.6954617672714,32.3763221285869],[-64.70438689565,32.3704254760469],[-64.7117569982798,32.368132600249],[-64.7061764744404,32.3600110593559],[-64.700531552697,32.3590601356818],[-64.6940348033967,32.3640708659835],[-64.6895164826082,32.3633598579866],[-64.6864150099255,32.3547797587266],[-64.6824635995504,32.3540628176846],[-64.6835876652835,32.3626447677968],[-64.6801998697415,32.3631199096979],[-64.6672170444687,32.3597751617473],[-64.6598811264978,32.3497625771755],[-64.6737331235384,32.3390281851635],[-64.6887090648183,32.3342439408053],[-64.706732854446,32.3429010723036],[-64.7149301576112,32.3552188753513],[-64.7185967666669,32.3552239212394],[-64.7214189847314,32.3518830231342],[-64.7270616067222,32.3466461715475],[-64.734962460882,32.3442819830499],[-64.7383521549094,32.3407216514918],[-64.7411729976333,32.3311790864627],[-64.7423019216485,32.323311561213],[-64.7462482354281,32.318538611581],[-64.7566773739613,32.3130509130175],[-64.768738200563,32.3088369816572],[-64.7799734332998,32.3072000581802]]]}},
{"type":"Feature","id":"BOL","properties":{"name":"Bolivia"},"geometry":{"type":"Polygon","coordinates":[[[-62.846468,-22.034985],[-63.986838,-21.993644],[-64.377021,-22.798091],[-64.964892,-22.075862],[-66.273339,-21.83231],[-67.106674,-22.735925],[-67.82818,-22.872919],[-68.219913,-21.494347],[-68.757167,-20.372658],[-68.442225,-19.405068],[-68.966818,-18.981683],[-69.100247,-18.260125],[-69.590424,-17.580012],[-68.959635,-16.500698],[-69.389764,-15.660129],[-69.160347,-15.323974],[-69.339535,-14.953195],[-68.948887,-14.453639],[-68.929224,-13.602684],[-68.88008,-12.899729],[-68.66508,-12.5613],[-69.529678,-10.951734],[-68.786158,-11.03638],[-68.271254,-11.014521],[-68.048192,-10.712059],[-67.173801,-10.306812],[-66.646908,-9.931331],[-65.338435,-9.761988],[-65.444837,-10.511451],[-65.321899,-10.895872],[-65.402281,-11.56627],[-64.316353,-12.461978],[-63.196499,-12.627033],[-62.80306,-13.000653],[-62.127081,-13.198781],[-61.713204,-13.489202],[-61.084121,-13.479384],[-60.503304,-13.775955],[-60.459198,-14.354007],[-60.264326,-14.645979],[-60.251149,-15.077219],[-60.542966,-15.09391],[-60.15839,-16.258284],[-58.24122,-16.299573],[-58.388058,-16.877109],[-58.280804,-17.27171],[-57.734558,-17.552468],[-57.498371,-18.174188],[-57.676009,-18.96184],[-57.949997,-19.400004],[-57.853802,-19.969995],[-58.166392,-20.176701],[-58.183471,-19.868399],[-59.115042,-19.356906],[-60.043565,-19.342747],[-61.786326,-19.633737],[-62.265961,-20.513735],[-62.291179,-21.051635],[-62.685057,-22.249029],[-62.846468,-22.034985]]]}},
{"type":"Feature","id":"BRA","properties":{"name":"Brazil"},"geometry":{"type":"Polygon","coordinates":[[[-57.625133,-30.216295],[-56.2909,-28.852761],[-55.162286,-27.881915],[-54.490725,-27.474757],[-53.648735,-26.923473],[-53.628349,-26.124865],[-54.13005,-25.547639],[-54.625291,-25.739255],[-54.428946,-25.162185],[-54.293476,-24.5708],[-54.29296,-24.021014],[-54.652834,-23.839578],[-55.027902,-24.001274],[-55.400747,-23.956935],[-55.517639,-23.571998],[-55.610683,-22.655619],[-55.797958,-22.35693],[-56.473317,-22.0863],[-56.88151,-22.282154],[-57.937156,-22.090176],[-57.870674,-20.732688],[-58.166392,-20.176701],[-57.853802,-19.969995],[-57.949997,-19.400004],[-57.676009,-18.96184],[-57.498371,-18.174188],[-57.734558,-17.552468],[-58.280804,-17.27171],[-58.388058,-16.877109],[-58.24122,-16.299573],[-60.15839,-16.258284],[-60.542966,-15.09391],[-60.251149,-15.077219],[-60.264326,-14.645979],[-60.459198,-14.354007],[-60.503304,-13.775955],[-61.084121,-13.479384],[-61.713204,-13.489202],[-62.127081,-13.198781],[-62.80306,-13.000653],[-63.196499,-12.627033],[-64.316353,-12.461978],[-65.402281,-11.56627],[-65.321899,-10.895872],[-65.444837,-10.511451],[-65.338435,-9.761988],[-66.646908,-9.931331],[-67.173801,-10.306812],[-68.048192,-10.712059],[-68.271254,-11.014521],[-68.786158,-11.03638],[-69.529678,-10.951734],[-70.093752,-11.123972],[-70.548686,-11.009147],[-70.481894,-9.490118],[-71.302412,-10.079436],[-72.184891,-10.053598],[-72.563033,-9.520194],[-73.226713,-9.462213],[-73.015383,-9.032833],[-73.571059,-8.424447],[-73.987235,-7.52383],[-73.723401,-7.340999],[-73.724487,-6.918595],[-73.120027,-6.629931],[-73.219711,-6.089189],[-72.964507,-5.741251],[-72.891928,-5.274561],[-71.748406,-4.593983],[-70.928843,-4.401591],[-70.794769,-4.251265],[-69.893635,-4.298187],[-69.444102,-1.556287],[-69.420486,-1.122619],[-69.577065,-0.549992],[-70.020656,-0.185156],[-70.015566,0.541414],[-69.452396,0.706159],[-69.252434,0.602651],[-69.218638,0.985677],[-69.804597,1.089081],[-69.816973,1.714805],[-67.868565,1.692455],[-67.53781,2.037163],[-67.259998,1.719999],[-67.065048,1.130112],[-66.876326,1.253361],[-66.325765,0.724452],[-65.548267,0.789254],[-65.354713,1.095282],[-64.611012,1.328731],[-64.199306,1.492855],[-64.083085,1.916369],[-63.368788,2.2009],[-63.422867,2.411068],[-64.269999,2.497006],[-64.408828,3.126786],[-64.368494,3.79721],[-64.816064,4.056445],[-64.628659,4.148481],[-63.888343,4.02053],[-63.093198,3.770571],[-62.804533,4.006965],[-62.08543,4.162124],[-60.966893,4.536468],[-60.601179,4.918098],[-60.733574,5.200277],[-60.213683,5.244486],[-59.980959,5.014061],[-60.111002,4.574967],[-59.767406,4.423503],[-59.53804,3.958803],[-59.815413,3.606499],[-59.974525,2.755233],[-59.718546,2.24963],[-59.646044,1.786894],[-59.030862,1.317698],[-58.540013,1.268088],[-58.429477,1.463942],[-58.11345,1.507195],[-57.660971,1.682585],[-57.335823,1.948538],[-56.782704,1.863711],[-56.539386,1.899523],[-55.995698,1.817667],[-55.9056,2.021996],[-56.073342,2.220795],[-55.973322,2.510364],[-55.569755,2.421506],[-55.097587,2.523748],[-54.524754,2.311849],[-54.088063,2.105557],[-53.778521,2.376703],[-53.554839,2.334897],[-53.418465,2.053389],[-52.939657,2.124858],[-52.556425,2.504705],[-52.249338,3.241094],[-51.657797,4.156232],[-51.317146,4.203491],[-51.069771,3.650398],[-50.508875,1.901564],[-49.974076,1.736483],[-49.947101,1.04619],[-50.699251,0.222984],[-50.388211,-0.078445],[-48.620567,-0.235489],[-48.584497,-1.237805],[-47.824956,-0.581618],[-46.566584,-0.941028],[-44.905703,-1.55174],[-44.417619,-2.13775],[-44.581589,-2.691308],[-43.418791,-2.38311],[-41.472657,-2.912018],[-39.978665,-2.873054],[-38.500383,-3.700652],[-37.223252,-4.820946],[-36.452937,-5.109404],[-35.597796,-5.149504],[-35.235389,-5.464937],[-34.89603,-6.738193],[-34.729993,-7.343221],[-35.128212,-8.996401],[-35.636967,-9.649282],[-37.046519,-11.040721],[-37.683612,-12.171195],[-38.423877,-13.038119],[-38.673887,-13.057652],[-38.953276,-13.79337],[-38.882298,-15.667054],[-39.161092,-17.208407],[-39.267339,-17.867746],[-39.583521,-18.262296],[-39.760823,-19.599113],[-40.774741,-20.904512],[-40.944756,-21.937317],[-41.754164,-22.370676],[-41.988284,-22.97007],[-43.074704,-22.967693],[-44.647812,-23.351959],[-45.352136,-23.796842],[-46.472093,-24.088969],[-47.648972,-24.885199],[-48.495458,-25.877025],[-48.641005,-26.623698],[-48.474736,-27.175912],[-48.66152,-28.186135],[-48.888457,-28.674115],[-49.587329,-29.224469],[-50.696874,-30.984465],[-51.576226,-31.777698],[-52.256081,-32.24537],[-52.7121,-33.196578],[-53.373662,-33.768378],[-53.650544,-33.202004],[-53.209589,-32.727666],[-53.787952,-32.047243],[-54.572452,-31.494511],[-55.60151,-30.853879],[-55.973245,-30.883076],[-56.976026,-30.109686],[-57.625133,-30.216295]]]}},
{"type":"Feature","id":"BRN","properties":{"name":"Brunei"},"geometry":{"type":"Polygon","coordinates":[[[114.204017,4.525874],[114.599961,4.900011],[115.45071,5.44773],[115.4057,4.955228],[115.347461,4.316636],[114.869557,4.348314],[114.659596,4.007637],[114.204017,4.525874]]]}},
{"type":"Feature","id":"BTN","properties":{"name":"Bhutan"},"geometry":{"type":"Polygon","coordinates":[[[91.696657,27.771742],[92.103712,27.452614],[92.033484,26.83831],[91.217513,26.808648],[90.373275,26.875724],[89.744528,26.719403],[88.835643,27.098966],[88.814248,27.299316],[89.47581,28.042759],[90.015829,28.296439],[90.730514,28.064954],[91.258854,28.040614],[91.696657,27.771742]]]}},
{"type":"Feature","id":"BWA","properties":{"name":"Botswana"},"geometry":{"type":"Polygon","coordinates":[[[25.649163,-18.536026],[25.850391,-18.714413],[26.164791,-19.293086],[27.296505,-20.39152],[27.724747,-20.499059],[27.727228,-20.851802],[28.02137,-21.485975],[28.794656,-21.639454],[29.432188,-22.091313],[28.017236,-22.827754],[27.11941,-23.574323],[26.786407,-24.240691],[26.485753,-24.616327],[25.941652,-24.696373],[25.765849,-25.174845],[25.664666,-25.486816],[25.025171,-25.71967],[24.211267,-25.670216],[23.73357,-25.390129],[23.312097,-25.26869],[22.824271,-25.500459],[22.579532,-25.979448],[22.105969,-26.280256],[21.605896,-26.726534],[20.889609,-26.828543],[20.66647,-26.477453],[20.758609,-25.868136],[20.165726,-24.917962],[19.895768,-24.76779],[19.895458,-21.849157],[20.881134,-21.814327],[20.910641,-18.252219],[21.65504,-18.219146],[23.196858,-17.869038],[23.579006,-18.281261],[24.217365,-17.889347],[24.520705,-17.887125],[25.084443,-17.661816],[25.264226,-17.73654],[25.649163,-18.536026]]]}},
{"type":"Feature","id":"CAF","properties":{"name":"Central African Republic"},"geometry":{"type":"Polygon","coordinates":[[[15.27946,7.421925],[16.106232,7.497088],[16.290562,7.754307],[16.456185,7.734774],[16.705988,7.508328],[17.96493,7.890914],[18.389555,8.281304],[18.911022,8.630895],[18.81201,8.982915],[19.094008,9.074847],[20.059685,9.012706],[21.000868,9.475985],[21.723822,10.567056],[22.231129,10.971889],[22.864165,11.142395],[22.977544,10.714463],[23.554304,10.089255],[23.55725,9.681218],[23.394779,9.265068],[23.459013,8.954286],[23.805813,8.666319],[24.567369,8.229188],[25.114932,7.825104],[25.124131,7.500085],[25.796648,6.979316],[26.213418,6.546603],[26.465909,5.946717],[27.213409,5.550953],[27.374226,5.233944],[27.044065,5.127853],[26.402761,5.150875],[25.650455,5.256088],[25.278798,5.170408],[25.128833,4.927245],[24.805029,4.897247],[24.410531,5.108784],[23.297214,4.609693],[22.84148,4.710126],[22.704124,4.633051],[22.405124,4.02916],[21.659123,4.224342],[20.927591,4.322786],[20.290679,4.691678],[19.467784,5.031528],[18.932312,4.709506],[18.542982,4.201785],[18.453065,3.504386],[17.8099,3.560196],[17.133042,3.728197],[16.537058,3.198255],[16.012852,2.26764],[15.907381,2.557389],[15.862732,3.013537],[15.405396,3.335301],[15.03622,3.851367],[14.950953,4.210389],[14.478372,4.732605],[14.558936,5.030598],[14.459407,5.451761],[14.53656,6.226959],[14.776545,6.408498],[15.27946,7.421925]]]}},
{"type":"Feature","id":"CAN","properties":{"name":"Canada"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-63.6645,46.55001],[-62.9393,46.41587],[-62.01208,46.44314],[-62.50391,46.03339],[-62.87433,45.96818],[-64.1428,46.39265],[-64.39261,46.72747],[-64.01486,47.03601],[-63.6645,46.55001]]],[[[-61.806305,49.10506],[-62.29318,49.08717],[-63.58926,49.40069],[-64.51912,49.87304],[-64.17322,49.95718],[-62.85829,49.70641],[-61.835585,49.28855],[-61.806305,49.10506]]],[[[-123.510002,48.510011],[-124.012891,48.370846],[-125.655013,48.825005],[-125.954994,49.179996],[-126.850004,49.53],[-127.029993,49.814996],[-128.059336,49.994959],[-128.444584,50.539138],[-128.358414,50.770648],[-127.308581,50.552574],[-126.695001,50.400903],[-125.755007,50.295018],[-125.415002,49.950001],[-124.920768,49.475275],[-123.922509,49.062484],[-123.510002,48.510011]]],[[[-56.134036,50.68701],[-56.795882,49.812309],[-56.143105,50.150117],[-55.471492,49.935815],[-55.822401,49.587129],[-54.935143,49.313011],[-54.473775,49.556691],[-53.476549,49.249139],[-53.786014,48.516781],[-53.086134,48.687804],[-52.958648,48.157164],[-52.648099,47.535548],[-53.069158,46.655499],[-53.521456,46.618292],[-54.178936,46.807066],[-53.961869,47.625207],[-54.240482,47.752279],[-55.400773,46.884994],[-55.997481,46.91972],[-55.291219,47.389562],[-56.250799,47.632545],[-57.325229,47.572807],[-59.266015,47.603348],[-59.419494,47.899454],[-58.796586,48.251525],[-59.231625,48.523188],[-58.391805,49.125581],[-57.35869,50.718274],[-56.73865,51.287438],[-55.870977,51.632094],[-55.406974,51.588273],[-55.600218,51.317075],[-56.134036,50.68701]]],[[[-132.710008,54.040009],[-132.710009,54.040009],[-132.710008,54.040009],[-132.710008,54.040009],[-131.74999,54.120004],[-132.04948,52.984621],[-131.179043,52.180433],[-131.57783,52.182371],[-132.180428,52.639707],[-132.549992,53.100015],[-133.054611,53.411469],[-133.239664,53.85108],[-133.180004,54.169975],[-132.710008,54.040009]]],[[[-79.26582,62.158675],[-79.65752,61.63308],[-80.09956,61.7181],[-80.36215,62.01649],[-80.315395,62.085565],[-79.92939,62.3856],[-79.52002,62.36371],[-79.26582,62.158675]]],[[[-81.89825,62.7108],[-83.06857,62.15922],[-83.77462,62.18231],[-83.99367,62.4528],[-83.25048,62.91409],[-81.87699,62.90458],[-81.89825,62.7108]]],[[[-85.161308,65.657285],[-84.975764,65.217518],[-84.464012,65.371772],[-83.882626,65.109618],[-82.787577,64.766693],[-81.642014,64.455136],[-81.55344,63.979609],[-80.817361,64.057486],[-80.103451,63.725981],[-80.99102,63.411246],[-82.547178,63.651722],[-83.108798,64.101876],[-84.100417,63.569712],[-85.523405,63.052379],[-85.866769,63.637253],[-87.221983,63.541238],[-86.35276,64.035833],[-86.224886,64.822917],[-85.883848,65.738778],[-85.161308,65.657285]]],[[[-75.86588,67.14886],[-76.98687,67.09873],[-77.2364,67.58809],[-76.81166,68.14856],[-75.89521,68.28721],[-75.1145,68.01036],[-75.10333,67.58202],[-75.21597,67.44425],[-75.86588,67.14886]]],[[[-95.647681,69.10769],[-96.269521,68.75704],[-97.617401,69.06003],[-98.431801,68.9507],[-99.797401,69.40003],[-98.917401,69.71003],[-98.218261,70.14354],[-97.157401,69.86003],[-96.557401,69.68003],[-96.257401,69.49003],[-95.647681,69.10769]]],[[[-90.5471,69.49766],[-90.55151,68.47499],[-89.21515,69.25873],[-88.01966,68.61508],[-88.31749,67.87338],[-87.35017,67.19872],[-86.30607,67.92146],[-85.57664,68.78456],[-85.52197,69.88211],[-84.10081,69.80539],[-82.62258,69.65826],[-81.28043,69.16202],[-81.2202,68.66567],[-81.96436,68.13253],[-81.25928,67.59716],[-81.38653,67.11078],[-83.34456,66.41154],[-84.73542,66.2573],[-85.76943,66.55833],[-86.0676,66.05625],[-87.03143,65.21297],[-87.32324,64.77563],[-88.48296,64.09897],[-89.91444,64.03273],[-90.70398,63.61017],[-90.77004,62.96021],[-91.93342,62.83508],[-93.15698,62.02469],[-94.24153,60.89865],[-94.62931,60.11021],[-94.6846,58.94882],[-93.21502,58.78212],[-92.76462,57.84571],[-92.29703,57.08709],[-90.89769,57.28468],[-89.03953,56.85172],[-88.03978,56.47162],[-87.32421,55.99914],[-86.07121,55.72383],[-85.01181,55.3026],[-83.36055,55.24489],[-82.27285,55.14832],[-82.4362,54.28227],[-82.12502,53.27703],[-81.40075,52.15788],[-79.91289,51.20842],[-79.14301,51.53393],[-78.60191,52.56208],[-79.12421,54.14145],[-79.82958,54.66772],[-78.22874,55.13645],[-77.0956,55.83741],[-76.54137,56.53423],[-76.62319,57.20263],[-77.30226,58.05209],[-78.51688,58.80458],[-77.33676,59.85261],[-77.77272,60.75788],[-78.10687,62.31964],[-77.41067,62.55053],[-75.69621,62.2784],[-74.6682,62.18111],[-73.83988,62.4438],[-72.90853,62.10507],[-71.67708,61.52535],[-71.37369,61.13717],[-69.59042,61.06141],[-69.62033,60.22125],[-69.2879,58.95736],[-68.37455,58.80106],[-67.64976,58.21206],[-66.20178,58.76731],[-65.24517,59.87071],[-64.58352,60.33558],[-63.80475,59.4426],[-62.50236,58.16708],[-61.39655,56.96745],[-61.79866,56.33945],[-60.46853,55.77548],[-59.56962,55.20407],[-57.97508,54.94549],[-57.3332,54.6265],[-56.93689,53.78032],[-56.15811,53.64749],[-55.75632,53.27036],[-55.68338,52.14664],[-56.40916,51.7707],[-57.12691,51.41972],[-58.77482,51.0643],[-60.03309,50.24277],[-61.72366,50.08046],[-63.86251,50.29099],[-65.36331,50.2982],[-66.39905,50.22897],[-67.23631,49.51156],[-68.51114,49.06836],[-69.95362,47.74488],[-71.10458,46.82171],[-70.25522,46.98606],[-68.65,48.3],[-66.55243,49.1331],[-65.05626,49.23278],[-64.17099,48.74248],[-65.11545,48.07085],[-64.79854,46.99297],[-64.47219,46.23849],[-63.17329,45.73902],[-61.52072,45.88377],[-60.51815,47.00793],[-60.4486,46.28264],[-59.80287,45.9204],[-61.03988,45.26525],[-63.25471,44.67014],[-64.24656,44.26553],[-65.36406,43.54523],[-66.1234,43.61867],[-66.16173,44.46512],[-64.42549,45.29204],[-66.02605,45.25931],[-67.13741,45.13753],[-67.79134,45.70281],[-67.79046,47.06636],[-68.23444,47.35486],[-68.905,47.185],[-69.237216,47.447781],[-69.99997,46.69307],[-70.305,45.915],[-70.66,45.46],[-71.08482,45.30524],[-71.405,45.255],[-71.50506,45.0082],[-73.34783,45.00738],[-74.867,45.00048],[-75.31821,44.81645],[-76.375,44.09631],[-76.5,44.018459],[-76.820034,43.628784],[-77.737885,43.629056],[-78.72028,43.625089],[-79.171674,43.466339],[-79.01,43.27],[-78.92,42.965],[-78.939362,42.863611],[-80.247448,42.3662],[-81.277747,42.209026],[-82.439278,41.675105],[-82.690089,41.675105],[-83.02981,41.832796],[-83.142,41.975681],[-83.12,42.08],[-82.9,42.43],[-82.43,42.98],[-82.137642,43.571088],[-82.337763,44.44],[-82.550925,45.347517],[-83.592851,45.816894],[-83.469551,45.994686],[-83.616131,46.116927],[-83.890765,46.116927],[-84.091851,46.275419],[-84.14212,46.512226],[-84.3367,46.40877],[-84.6049,46.4396],[-84.543749,46.538684],[-84.779238,46.637102],[-84.87608,46.900083],[-85.652363,47.220219],[-86.461991,47.553338],[-87.439793,47.94],[-88.378114,48.302918],[-89.272917,48.019808],[-89.6,48.01],[-90.83,48.27],[-91.64,48.14],[-92.61,48.45],[-93.63087,48.60926],[-94.32914,48.67074],[-94.64,48.84],[-94.81758,49.38905],[-95.15609,49.38425],[-95.15907,49],[-97.22872,49.0007],[-100.65,49],[-104.04826,48.99986],[-107.05,49],[-110.05,49],[-113,49],[-116.04818,49],[-117.03121,49],[-120,49],[-122.84,49],[-122.97421,49.002538],[-124.91024,49.98456],[-125.62461,50.41656],[-127.43561,50.83061],[-127.99276,51.71583],[-127.85032,52.32961],[-129.12979,52.75538],[-129.30523,53.56159],[-130.51497,54.28757],[-130.53611,54.80278],[-129.98,55.285],[-130.00778,55.91583],[-131.70781,56.55212],[-132.73042,57.69289],[-133.35556,58.41028],[-134.27111,58.86111],[-134.945,59.27056],[-135.47583,59.78778],[-136.47972,59.46389],[-137.4525,58.905],[-138.34089,59.56211],[-139.039,60],[-140.013,60.27682],[-140.99778,60.30639],[-140.9925,66.00003],[-140.986,69.712],[-139.12052,69.47102],[-137.54636,68.99002],[-136.50358,68.89804],[-135.62576,69.31512],[-134.41464,69.62743],[-132.92925,69.50534],[-131.43136,69.94451],[-129.79471,70.19369],[-129.10773,69.77927],[-128.36156,70.01286],[-128.13817,70.48384],[-127.44712,70.37721],[-125.75632,69.48058],[-124.42483,70.1584],[-124.28968,69.39969],[-123.06108,69.56372],[-122.6835,69.85553],[-121.47226,69.79778],[-119.94288,69.37786],[-117.60268,69.01128],[-116.22643,68.84151],[-115.2469,68.90591],[-113.89794,68.3989],[-115.30489,67.90261],[-113.49727,67.68815],[-110.798,67.80612],[-109.94619,67.98104],[-108.8802,67.38144],[-107.79239,67.88736],[-108.81299,68.31164],[-108.16721,68.65392],[-106.95,68.7],[-106.15,68.8],[-105.34282,68.56122],[-104.33791,68.018],[-103.22115,68.09775],[-101.45433,67.64689],[-99.90195,67.80566],[-98.4432,67.78165],[-98.5586,68.40394],[-97.66948,68.57864],[-96.11991,68.23939],[-96.12588,67.29338],[-95.48943,68.0907],[-94.685,68.06383],[-94.23282,69.06903],[-95.30408,69.68571],[-96.47131,70.08976],[-96.39115,71.19482],[-95.2088,71.92053],[-93.88997,71.76015],[-92.87818,71.31869],[-91.51964,70.19129],[-92.40692,69.69997],[-90.5471,69.49766]]],[[[-114.16717,73.12145],[-114.66634,72.65277],[-112.44102,72.9554],[-111.05039,72.4504],[-109.92035,72.96113],[-109.00654,72.63335],[-108.18835,71.65089],[-107.68599,72.06548],[-108.39639,73.08953],[-107.51645,73.23598],[-106.52259,73.07601],[-105.40246,72.67259],[-104.77484,71.6984],[-104.46476,70.99297],[-102.78537,70.49776],[-100.98078,70.02432],[-101.08929,69.58447],[-102.73116,69.50402],[-102.09329,69.11962],[-102.43024,68.75282],[-104.24,68.91],[-105.96,69.18],[-107.12254,69.11922],[-109,68.78],[-111.534149,68.630059],[-113.3132,68.53554],[-113.85496,69.00744],[-115.22,69.28],[-116.10794,69.16821],[-117.34,69.96],[-116.67473,70.06655],[-115.13112,70.2373],[-113.72141,70.19237],[-112.4161,70.36638],[-114.35,70.6],[-116.48684,70.52045],[-117.9048,70.54056],[-118.43238,70.9092],[-116.11311,71.30918],[-117.65568,71.2952],[-119.40199,71.55859],[-118.56267,72.30785],[-117.86642,72.70594],[-115.18909,73.31459],[-114.16717,73.12145]]],[[[-104.5,73.42],[-105.38,72.76],[-106.94,73.46],[-106.6,73.6],[-105.26,73.64],[-104.5,73.42]]],[[[-76.34,73.102685],[-76.251404,72.826385],[-77.314438,72.855545],[-78.39167,72.876656],[-79.486252,72.742203],[-79.775833,72.802902],[-80.876099,73.333183],[-80.833885,73.693184],[-80.353058,73.75972],[-78.064438,73.651932],[-76.34,73.102685]]],[[[-86.562179,73.157447],[-85.774371,72.534126],[-84.850112,73.340278],[-82.31559,73.750951],[-80.600088,72.716544],[-80.748942,72.061907],[-78.770639,72.352173],[-77.824624,72.749617],[-75.605845,72.243678],[-74.228616,71.767144],[-74.099141,71.33084],[-72.242226,71.556925],[-71.200015,70.920013],[-68.786054,70.525024],[-67.91497,70.121948],[-66.969033,69.186087],[-68.805123,68.720198],[-66.449866,68.067163],[-64.862314,67.847539],[-63.424934,66.928473],[-61.851981,66.862121],[-62.163177,66.160251],[-63.918444,64.998669],[-65.14886,65.426033],[-66.721219,66.388041],[-68.015016,66.262726],[-68.141287,65.689789],[-67.089646,65.108455],[-65.73208,64.648406],[-65.320168,64.382737],[-64.669406,63.392927],[-65.013804,62.674185],[-66.275045,62.945099],[-68.783186,63.74567],[-67.369681,62.883966],[-66.328297,62.280075],[-66.165568,61.930897],[-68.877367,62.330149],[-71.023437,62.910708],[-72.235379,63.397836],[-71.886278,63.679989],[-73.378306,64.193963],[-74.834419,64.679076],[-74.818503,64.389093],[-77.70998,64.229542],[-78.555949,64.572906],[-77.897281,65.309192],[-76.018274,65.326969],[-73.959795,65.454765],[-74.293883,65.811771],[-73.944912,66.310578],[-72.651167,67.284576],[-72.92606,67.726926],[-73.311618,68.069437],[-74.843307,68.554627],[-76.869101,68.894736],[-76.228649,69.147769],[-77.28737,69.76954],[-78.168634,69.826488],[-78.957242,70.16688],[-79.492455,69.871808],[-81.305471,69.743185],[-84.944706,69.966634],[-87.060003,70.260001],[-88.681713,70.410741],[-89.51342,70.762038],[-88.467721,71.218186],[-89.888151,71.222552],[-90.20516,72.235074],[-89.436577,73.129464],[-88.408242,73.537889],[-85.826151,73.803816],[-86.562179,73.157447]]],[[[-100.35642,73.84389],[-99.16387,73.63339],[-97.38,73.76],[-97.12,73.47],[-98.05359,72.99052],[-96.54,72.56],[-96.72,71.66],[-98.35966,71.27285],[-99.32286,71.35639],[-100.01482,71.73827],[-102.5,72.51],[-102.48,72.83],[-100.43836,72.70588],[-101.54,73.36],[-100.35642,73.84389]]],[[[-93.196296,72.771992],[-94.269047,72.024596],[-95.409856,72.061881],[-96.033745,72.940277],[-96.018268,73.43743],[-95.495793,73.862417],[-94.503658,74.134907],[-92.420012,74.100025],[-90.509793,73.856732],[-92.003965,72.966244],[-93.196296,72.771992]]],[[[-120.46,71.383602],[-123.09219,70.90164],[-123.62,71.34],[-125.928949,71.868688],[-125.5,72.292261],[-124.80729,73.02256],[-123.94,73.68],[-124.91775,74.29275],[-121.53788,74.44893],[-120.10978,74.24135],[-117.55564,74.18577],[-116.58442,73.89607],[-115.51081,73.47519],[-116.76794,73.22292],[-119.22,72.52],[-120.46,71.82],[-120.46,71.383602]]],[[[-93.612756,74.979997],[-94.156909,74.592347],[-95.608681,74.666864],[-96.820932,74.927623],[-96.288587,75.377828],[-94.85082,75.647218],[-93.977747,75.29649],[-93.612756,74.979997]]],[[[-98.5,76.72],[-97.735585,76.25656],[-97.704415,75.74344],[-98.16,75],[-99.80874,74.89744],[-100.88366,75.05736],[-100.86292,75.64075],[-102.50209,75.5638],[-102.56552,76.3366],[-101.48973,76.30537],[-99.98349,76.64634],[-98.57699,76.58859],[-98.5,76.72]]],[[[-108.21141,76.20168],[-107.81943,75.84552],[-106.92893,76.01282],[-105.881,75.9694],[-105.70498,75.47951],[-106.31347,75.00527],[-109.7,74.85],[-112.22307,74.41696],[-113.74381,74.39427],[-113.87135,74.72029],[-111.79421,75.1625],[-116.31221,75.04343],[-117.7104,75.2222],[-116.34602,76.19903],[-115.40487,76.47887],[-112.59056,76.14134],[-110.81422,75.54919],[-109.0671,75.47321],[-110.49726,76.42982],[-109.5811,76.79417],[-108.54859,76.67832],[-108.21141,76.20168]]],[[[-94.684086,77.097878],[-93.573921,76.776296],[-91.605023,76.778518],[-90.741846,76.449597],[-90.969661,76.074013],[-89.822238,75.847774],[-89.187083,75.610166],[-87.838276,75.566189],[-86.379192,75.482421],[-84.789625,75.699204],[-82.753445,75.784315],[-81.128531,75.713983],[-80.057511,75.336849],[-79.833933,74.923127],[-80.457771,74.657304],[-81.948843,74.442459],[-83.228894,74.564028],[-86.097452,74.410032],[-88.15035,74.392307],[-89.764722,74.515555],[-92.422441,74.837758],[-92.768285,75.38682],[-92.889906,75.882655],[-93.893824,76.319244],[-95.962457,76.441381],[-97.121379,76.751078],[-96.745123,77.161389],[-94.684086,77.097878]]],[[[-116.198587,77.645287],[-116.335813,76.876962],[-117.106051,76.530032],[-118.040412,76.481172],[-119.899318,76.053213],[-121.499995,75.900019],[-122.854924,76.116543],[-122.854925,76.116543],[-121.157535,76.864508],[-119.103939,77.51222],[-117.570131,77.498319],[-116.198587,77.645287]]],[[[-93.840003,77.519997],[-94.295608,77.491343],[-96.169654,77.555111],[-96.436304,77.834629],[-94.422577,77.820005],[-93.720656,77.634331],[-93.840003,77.519997]]],[[[-110.186938,77.697015],[-112.051191,77.409229],[-113.534279,77.732207],[-112.724587,78.05105],[-111.264443,78.152956],[-109.854452,77.996325],[-110.186938,77.697015]]],[[[-109.663146,78.601973],[-110.881314,78.40692],[-112.542091,78.407902],[-112.525891,78.550555],[-111.50001,78.849994],[-110.963661,78.804441],[-109.663146,78.601973]]],[[[-95.830295,78.056941],[-97.309843,77.850597],[-98.124289,78.082857],[-98.552868,78.458105],[-98.631984,78.87193],[-97.337231,78.831984],[-96.754399,78.765813],[-95.559278,78.418315],[-95.830295,78.056941]]],[[[-100.060192,78.324754],[-99.670939,77.907545],[-101.30394,78.018985],[-102.949809,78.343229],[-105.176133,78.380332],[-104.210429,78.67742],[-105.41958,78.918336],[-105.492289,79.301594],[-103.529282,79.165349],[-100.825158,78.800462],[-100.060192,78.324754]]],[[[-87.02,79.66],[-85.81435,79.3369],[-87.18756,79.0393],[-89.03535,78.28723],[-90.80436,78.21533],[-92.87669,78.34333],[-93.95116,78.75099],[-93.93574,79.11373],[-93.14524,79.3801],[-94.974,79.37248],[-96.07614,79.70502],[-96.70972,80.15777],[-96.01644,80.60233],[-95.32345,80.90729],[-94.29843,80.97727],[-94.73542,81.20646],[-92.40984,81.25739],[-91.13289,80.72345],[-89.45,80.509322],[-87.81,80.32],[-87.02,79.66]]],[[[-68.5,83.106322],[-65.82735,83.02801],[-63.68,82.9],[-61.85,82.6286],[-61.89388,82.36165],[-64.334,81.92775],[-66.75342,81.72527],[-67.65755,81.50141],[-65.48031,81.50657],[-67.84,80.9],[-69.4697,80.61683],[-71.18,79.8],[-73.2428,79.63415],[-73.88,79.430162],[-76.90773,79.32309],[-75.52924,79.19766],[-76.22046,79.01907],[-75.39345,78.52581],[-76.34354,78.18296],[-77.88851,77.89991],[-78.36269,77.50859],[-79.75951,77.20968],[-79.61965,76.98336],[-77.91089,77.022045],[-77.88911,76.777955],[-80.56125,76.17812],[-83.17439,76.45403],[-86.11184,76.29901],[-87.6,76.42],[-89.49068,76.47239],[-89.6161,76.95213],[-87.76739,77.17833],[-88.26,77.9],[-87.65,77.970222],[-84.97634,77.53873],[-86.34,78.18],[-87.96192,78.37181],[-87.15198,78.75867],[-85.37868,78.9969],[-85.09495,79.34543],[-86.50734,79.73624],[-86.93179,80.25145],[-84.19844,80.20836],[-83.408696,80.1],[-81.84823,80.46442],[-84.1,80.58],[-87.59895,80.51627],[-89.36663,80.85569],[-90.2,81.26],[-91.36786,81.5531],[-91.58702,81.89429],[-90.1,82.085],[-88.93227,82.11751],[-86.97024,82.27961],[-85.5,82.652273],[-84.260005,82.6],[-83.18,82.32],[-82.42,82.86],[-81.1,83.02],[-79.30664,83.13056],[-76.25,83.172059],[-75.71878,83.06404],[-72.83153,83.23324],[-70.665765,83.169781],[-68.5,83.106322]]]]}},
{"type":"Feature","id":"CHE","properties":{"name":"Switzerland"},"geometry":{"type":"Polygon","coordinates":[[[9.594226,47.525058],[9.632932,47.347601],[9.47997,47.10281],[9.932448,46.920728],[10.442701,46.893546],[10.363378,46.483571],[9.922837,46.314899],[9.182882,46.440215],[8.966306,46.036932],[8.489952,46.005151],[8.31663,46.163642],[7.755992,45.82449],[7.273851,45.776948],[6.843593,45.991147],[6.5001,46.429673],[6.022609,46.27299],[6.037389,46.725779],[6.768714,47.287708],[6.736571,47.541801],[7.192202,47.449766],[7.466759,47.620582],[8.317301,47.61358],[8.522612,47.830828],[9.594226,47.525058]]]}},
{"type":"Feature","id":"CHL","properties":{"name":"Chile"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-68.63401,-52.63637],[-68.63335,-54.8695],[-67.56244,-54.87001],[-66.95992,-54.89681],[-67.29103,-55.30124],[-68.14863,-55.61183],[-68.639991,-55.580018],[-69.2321,-55.49906],[-69.95809,-55.19843],[-71.00568,-55.05383],[-72.2639,-54.49514],[-73.2852,-53.95752],[-74.66253,-52.83749],[-73.8381,-53.04743],[-72.43418,-53.7154],[-71.10773,-54.07433],[-70.59178,-53.61583],[-70.26748,-52.93123],[-69.34565,-52.5183],[-68.63401,-52.63637]]],[[[-68.219913,-21.494347],[-67.82818,-22.872919],[-67.106674,-22.735925],[-66.985234,-22.986349],[-67.328443,-24.025303],[-68.417653,-24.518555],[-68.386001,-26.185016],[-68.5948,-26.506909],[-68.295542,-26.89934],[-69.001235,-27.521214],[-69.65613,-28.459141],[-70.01355,-29.367923],[-69.919008,-30.336339],[-70.535069,-31.36501],[-70.074399,-33.09121],[-69.814777,-33.273886],[-69.817309,-34.193571],[-70.388049,-35.169688],[-70.364769,-36.005089],[-71.121881,-36.658124],[-71.118625,-37.576827],[-70.814664,-38.552995],[-71.413517,-38.916022],[-71.680761,-39.808164],[-71.915734,-40.832339],[-71.746804,-42.051386],[-72.148898,-42.254888],[-71.915424,-43.408565],[-71.464056,-43.787611],[-71.793623,-44.207172],[-71.329801,-44.407522],[-71.222779,-44.784243],[-71.659316,-44.973689],[-71.552009,-45.560733],[-71.917258,-46.884838],[-72.447355,-47.738533],[-72.331161,-48.244238],[-72.648247,-48.878618],[-73.415436,-49.318436],[-73.328051,-50.378785],[-72.975747,-50.74145],[-72.309974,-50.67701],[-72.329404,-51.425956],[-71.914804,-52.009022],[-69.498362,-52.142761],[-68.571545,-52.299444],[-69.461284,-52.291951],[-69.94278,-52.537931],[-70.845102,-52.899201],[-71.006332,-53.833252],[-71.429795,-53.856455],[-72.557943,-53.53141],[-73.702757,-52.835069],[-73.702757,-52.83507],[-74.946763,-52.262754],[-75.260026,-51.629355],[-74.976632,-51.043396],[-75.479754,-50.378372],[-75.608015,-48.673773],[-75.18277,-47.711919],[-74.126581,-46.939253],[-75.644395,-46.647643],[-74.692154,-45.763976],[-74.351709,-44.103044],[-73.240356,-44.454961],[-72.717804,-42.383356],[-73.3889,-42.117532],[-73.701336,-43.365776],[-74.331943,-43.224958],[-74.017957,-41.794813],[-73.677099,-39.942213],[-73.217593,-39.258689],[-73.505559,-38.282883],[-73.588061,-37.156285],[-73.166717,-37.12378],[-72.553137,-35.50884],[-71.861732,-33.909093],[-71.43845,-32.418899],[-71.668721,-30.920645],[-71.370083,-30.095682],[-71.489894,-28.861442],[-70.905124,-27.64038],[-70.724954,-25.705924],[-70.403966,-23.628997],[-70.091246,-21.393319],[-70.16442,-19.756468],[-70.372572,-18.347975],[-69.858444,-18.092694],[-69.590424,-17.580012],[-69.100247,-18.260125],[-68.966818,-18.981683],[-68.442225,-19.405068],[-68.757167,-20.372658],[-68.219913,-21.494347]]]]}},
]}

3. 导入依赖

GeoJSON作为一种矢量数据格式,可以使用矢量库OGR进行处理,以实现GeoJSON数据从文本格式转换为Shp格式。其中还涉及坐标定义,所以还需要引入osr模块。

from osgeo import ogr,osr
import os

4. 数据读取与转换

定义一个方法GeoJSON2Shp(geoPath,shpPath)用于将GeoJSON数据转换为Shp数据。

"""
说明:将 GeoJSON 文件转换为 Shapfile 文件
参数:
    -geoPath:GeoJSON 文件路径
    -shpPath:Shp 文件路径
"""
def GeoJSON2Shp(geoPath,shpPath):

在进行GeoJSON数据格式转换之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(geoPath):
    print("GeoJSON 文件存在。")
else:
    print("GeoJSON 文件不存在,请重新选择文件!")
    return

打开GeoJSON数据。

# 打开JSON文件
jsonDataSource = ogr.Open(geoPath)
jsonLayer = jsonDataSource.GetLayer()

使用os.path.exists方法检查Shp文件是否已经创建,如果存在则将其删除。

if os.path.exists(shpPath):
    try:
        shpDriver.DeleteDataSource(shpPath)
        print("文件已删除!")
    except Exception as e:
        print(f"文件删除出错:{e}")
        return False

通过GetDriverByName获取Shp数据驱动,并创建Shp数据源。

# 创建Shp数据源
shpDriver = ogr.GetDriverByName("ESRI Shapefile")
shpDataSource = shpDriver.CreateDataSource(shpPath)

添加Shp图层属性。获取源数据坐标系、属性字段以及几何对象,并将其复制到目标图层中。属性结构显示如下:

# 获取坐标系
srs = jsonLayer.GetSpatialRef()

# 创建Shp数据图层
shpLayer = shpDataSource.CreateLayer(
    "layer",
    srs=srs,
    geom_type=jsonLayer.GetGeomType()
)

# 获取字段属性
layerDefn = jsonLayer.GetLayerDefn()
for i in range(layerDefn.GetFieldCount()):
    fieldDefn = layerDefn.GetFieldDefn(i)
    shpLayer.CreateField(fieldDefn)

# 获取几何属性
for feature in jsonLayer:
    shpLayer.CreateFeature(feature)

在数据读取完成之后关闭数据源。

# 关闭数据源
jsonDataSource = shpDataSource = None

数据转换完成之后在ArcMap中打开的显示效果:数据属性:

鸿蒙主题切换:一个开关搞定白天/黑夜模式

作者 90后晨仔
2025年12月29日 21:05

我强烈推荐使用鸿蒙原生资源限定词方案,配合一个简单的开关控制。这是最优雅、最高效的实现方式。让我用一个完整可运行的示例告诉你为什么。

一、完整实现:一个开关控制白天/黑夜模式

1.1 项目结构

project/
├── AppScope/resources/
│   ├── base/element/color.json      # 白天模式颜色
│   └── dark/element/color.json      # 夜间模式颜色
├── entry/src/main/ets/
│   ├── pages/
│   │   └── Index.ets               # 主页面
│   └── utils/
│       └── ThemeUtils.ets          # 主题管理工具
└── entry/src/main/resources/
    ├── base/media/                 # 白天模式图标
    └── dark/media/                 # 夜间模式图标

1.2 颜色资源定义

白天主题颜色 (base/element/color.json):

{
  "color": [
    {
      "name": "app_bg_primary",
      "value": "#F8F9FA"
    },
    {
      "name": "app_text_primary",
      "value": "#212529"
    },
    {
      "name": "app_card_bg",
      "value": "#FFFFFF"
    },
    {
      "name": "app_accent_primary",
      "value": "#0D6EFD"
    },
    {
      "name": "app_switch_track_on",
      "value": "#34C759"
    },
    {
      "name": "app_switch_track_off",
      "value": "#E9ECEF"
    }
  ]
}

夜间主题颜色 (dark/element/color.json):

{
  "color": [
    {
      "name": "app_bg_primary",
      "value": "#121212"
    },
    {
      "name": "app_text_primary",
      "value": "#E9ECEF"
    },
    {
      "name": "app_card_bg",
      "value": "#1E1E1E"
    },
    {
      "name": "app_accent_primary",
      "value": "#6EA8FE"
    },
    {
      "name": "app_switch_track_on",
      "value": "#30D158"
    },
    {
      "name": "app_switch_track_off",
      "value": "#3A3A3C"
    }
  ]
}

1.3 主题管理工具

// utils/ThemeUtils.ets
import common from '@ohos.app.ability.common';
import Configuration from '@ohos.app.ability.Configuration';
import Preferences from '@ohos.data.preferences';

export enum ThemeMode {
  LIGHT = 'light',
  DARK = 'dark',
  SYSTEM = 'system'
}

export class ThemeUtils {
  private static instance: ThemeUtils;
  private appContext: common.ApplicationContext | null = null;
  private preferences: Preferences | null = null;
  private readonly PREF_NAME = 'app_theme_prefs';
  private readonly THEME_KEY = 'current_theme';
  
  // 主题变化监听器
  private listeners: Array<(mode: ThemeMode) => void> = [];
  
  // 单例模式
  static getInstance(): ThemeUtils {
    if (!ThemeUtils.instance) {
      ThemeUtils.instance = new ThemeUtils();
    }
    return ThemeUtils.instance;
  }
  
  // 初始化
  async initialize(context: common.Context): Promise<void> {
    try {
      // 获取应用上下文
      const abilityContext = context as common.UIAbilityContext;
      this.appContext = abilityContext.configuration.appContext;
      
      // 初始化Preferences存储
      this.preferences = await Preferences.getPreferences(context, this.PREF_NAME);
      
      console.log('ThemeUtils 初始化完成');
    } catch (error) {
      console.error('ThemeUtils 初始化失败:', error);
    }
  }
  
  // 获取当前主题
  async getCurrentTheme(): Promise<ThemeMode> {
    if (!this.preferences) {
      return ThemeMode.SYSTEM;
    }
    
    try {
      const theme = await this.preferences.get(this.THEME_KEY, ThemeMode.SYSTEM) as string;
      return theme as ThemeMode;
    } catch (error) {
      console.error('获取主题失败:', error);
      return ThemeMode.SYSTEM;
    }
  }
  
  // 切换主题
  async toggleTheme(): Promise<void> {
    const current = await this.getCurrentTheme();
    let newTheme: ThemeMode;
    
    // 简单切换逻辑:白天 ↔ 夜间
    if (current === ThemeMode.LIGHT) {
      newTheme = ThemeMode.DARK;
    } else if (current === ThemeMode.DARK) {
      newTheme = ThemeMode.LIGHT;
    } else {
      // 如果是跟随系统,默认切换到夜间
      newTheme = ThemeMode.DARK;
    }
    
    await this.setTheme(newTheme);
  }
  
  // 设置主题
  async setTheme(mode: ThemeMode): Promise<void> {
    if (!this.appContext || !this.preferences) {
      console.error('ThemeUtils 未初始化');
      return;
    }
    
    try {
      // 保存到Preferences
      await this.preferences.put(this.THEME_KEY, mode);
      await this.preferences.flush();
      
      // 应用到系统
      let colorMode: Configuration.ColorMode;
      
      switch (mode) {
        case ThemeMode.LIGHT:
          colorMode = Configuration.ColorMode.COLOR_MODE_LIGHT;
          break;
        case ThemeMode.DARK:
          colorMode = Configuration.ColorMode.COLOR_MODE_DARK;
          break;
        case ThemeMode.SYSTEM:
          colorMode = Configuration.ColorMode.COLOR_MODE_SYSTEM;
          break;
        default:
          colorMode = Configuration.ColorMode.COLOR_MODE_LIGHT;
      }
      
      this.appContext.setColorMode(colorMode);
      
      console.log(`主题已切换为: ${mode}`);
      
      // 通知所有监听器
      this.notifyListeners(mode);
      
    } catch (error) {
      console.error('设置主题失败:', error);
    }
  }
  
  // 监听主题变化
  addListener(callback: (mode: ThemeMode) => void): void {
    this.listeners.push(callback);
  }
  
  // 移除监听器
  removeListener(callback: (mode: ThemeMode) => void): void {
    const index = this.listeners.indexOf(callback);
    if (index > -1) {
      this.listeners.splice(index, 1);
    }
  }
  
  // 通知所有监听器
  private notifyListeners(mode: ThemeMode): void {
    for (const listener of this.listeners) {
      try {
        listener(mode);
      } catch (error) {
        console.error('监听器执行失败:', error);
      }
    }
  }
}

1.4 主页面实现(包含切换开关)

// pages/Index.ets
import { ThemeUtils, ThemeMode } from '../utils/ThemeUtils';

@Entry
@Component
struct Index {
  @State currentTheme: ThemeMode = ThemeMode.LIGHT;
  @State isDarkMode: boolean = false;
  
  // 在页面显示时初始化主题
  aboutToAppear(): void {
    this.initTheme();
  }
  
  // 初始化主题
  async initTheme(): Promise<void> {
    const themeUtils = ThemeUtils.getInstance();
    this.currentTheme = await themeUtils.getCurrentTheme();
    this.isDarkMode = this.currentTheme === ThemeMode.DARK;
    
    // 监听主题变化
    themeUtils.addListener((mode: ThemeMode) => {
      this.currentTheme = mode;
      this.isDarkMode = mode === ThemeMode.DARK;
    });
  }
  
  // 切换主题
  async toggleTheme(): Promise<void> {
    const themeUtils = ThemeUtils.getInstance();
    await themeUtils.toggleTheme();
  }
  
  // 获取开关状态文字
  getSwitchText(): string {
    return this.isDarkMode ? '夜间模式' : '白天模式';
  }
  
  // 获取模式图标
  getModeIcon(): Resource {
    return this.isDarkMode 
      ? $r('app.media.icon_moon')   // 月亮图标(夜间)
      : $r('app.media.icon_sun');   // 太阳图标(白天)
  }
  
  // 构建界面
  build() {
    Column() {
      // 顶部标题栏
      Row() {
        Text('主题设置')
          .fontSize(24)
          .fontColor($r('app.color.app_text_primary'))
          .fontWeight(FontWeight.Medium)
        
        Blank()
        
        Image(this.getModeIcon())
          .width(24)
          .height(24)
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 12, bottom: 12 })
      .backgroundColor($r('app.color.app_card_bg'))
      
      // 主要内容区域
      Scroll() {
        Column() {
          // 主题切换卡片
          Column() {
            Row() {
              Column() {
                Text('外观模式')
                  .fontSize(18)
                  .fontColor($r('app.color.app_text_primary'))
                  .fontWeight(FontWeight.Medium)
                
                Text(this.isDarkMode ? '深色主题,保护眼睛' : '浅色主题,清晰明亮')
                  .fontSize(14)
                  .fontColor($r('app.color.app_text_primary'))
                  .opacity(0.6)
                  .margin({ top: 4 })
              }
              .flexGrow(1)
              
              // 主题切换开关
              Toggle({ type: ToggleType.Switch, isOn: this.isDarkMode })
                .selectedColor($r('app.color.app_switch_track_on'))
                .switchPointColor($r('app.color.app_card_bg'))
                .onChange((isOn: boolean) => {
                  this.toggleTheme();
                })
            }
            .padding(20)
          }
          .backgroundColor($r('app.color.app_card_bg'))
          .borderRadius(12)
          .margin({ top: 20, left: 20, right: 20 })
          
          // 示例内容区域
          Column() {
            Text('示例内容')
              .fontSize(20)
              .fontColor($r('app.color.app_text_primary'))
              .fontWeight(FontWeight.Bold)
              .margin({ bottom: 16 })
            
            Text('这是一个文本示例,用于展示不同主题下的显示效果。在白天模式下,文字为深色;在夜间模式下,文字为浅色。')
              .fontSize(16)
              .fontColor($r('app.color.app_text_primary'))
              .opacity(0.8)
              .lineHeight(24)
            
            Divider()
              .strokeWidth(1)
              .color($r('app.color.app_text_primary'))
              .opacity(0.1)
              .margin({ top: 20, bottom: 20 })
            
            Row() {
              Button('主要按钮')
                .backgroundColor($r('app.color.app_accent_primary'))
                .fontColor('#FFFFFF')
                .borderRadius(8)
                .padding({ left: 20, right: 20 })
              
              Button('次要按钮')
                .backgroundColor($r('app.color.app_card_bg'))
                .fontColor($r('app.color.app_text_primary'))
                .border({
                  color: $r('app.color.app_text_primary'),
                  width: 1,
                  style: BorderStyle.Solid
                })
                .borderRadius(8)
                .margin({ left: 12 })
                .padding({ left: 20, right: 20 })
            }
            .margin({ top: 16 })
          }
          .padding(24)
          .backgroundColor($r('app.color.app_card_bg'))
          .borderRadius(12)
          .margin({ top: 20, left: 20, right: 20, bottom: 40 })
        }
        .width('100%')
      }
      .flexGrow(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.app_bg_primary'))
  }
}

1.5 在EntryAbility中初始化

// entry/src/main/ets/entryability/EntryAbility.ets
import { ThemeUtils } from '../utils/ThemeUtils';
import UIAbility from '@ohos.app.ability.UIAbility';

export default class EntryAbility extends UIAbility {
  onCreate(want, launchParam) {
    console.log('EntryAbility onCreate');
    
    // 初始化主题工具
    ThemeUtils.getInstance().initialize(this.context);
  }
}

二、为什么这是最佳方案?

2.1 简单直观,一行代码切换

// 核心切换逻辑,只需一行代码!
this.appContext.setColorMode(colorMode);

2.2 自动适配所有组件

一旦切换系统颜色模式,所有使用资源引用的组件都会自动更新:

  • 文本颜色
  • 背景颜色
  • 边框颜色
  • 图标资源
  • 甚至图片资源(如果有dark目录版本)

2.3 零耦合,高内聚

  • 业务组件:只关心$r('app.color.xxx'),不关心当前主题
  • 主题管理:集中在ThemeUtils,统一管理状态
  • 资源定义:设计师可以直接修改JSON文件

2.4 持久化存储

使用Preferences自动保存用户选择,下次启动自动恢复:

// 保存主题选择
await preferences.put('current_theme', mode);
await preferences.flush();

// 读取主题选择
const theme = await preferences.get('current_theme', 'light');

三、对比工具类方案的劣势

如果用工具类方案,你需要:

3.1 繁琐的状态管理

// 每个组件都需要监听主题变化
@Component
struct MyComponent {
  @State textColor: string = '#000000';
  
  aboutToAppear() {
    // 监听主题变化
    ThemeManager.addListener(() => {
      this.textColor = ThemeManager.getTextColor();
    });
  }
  
  build() {
    Text('示例')
      .fontColor(this.textColor)  // 需要状态变量
  }
}

3.2 手动更新所有组件

// 切换主题时需要手动更新所有组件
class ThemeManager {
  static toggleTheme() {
    this.isDark = !this.isDark;
    
    // 需要手动触发所有组件更新
    for (const component of this.registeredComponents) {
      component.updateTheme();
    }
  }
}

3.3 性能问题

  • 每次切换都要重新计算所有颜色
  • 组件需要频繁重绘
  • 无法利用系统的优化机制

四、高级功能扩展

4.1 添加跟随系统选项

// 在ThemeUtils中添加
async setFollowSystem(enable: boolean): Promise<void> {
  if (enable) {
    await this.setTheme(ThemeMode.SYSTEM);
    
    // 监听系统主题变化
    this.appContext.on('colorModeChange', (newMode: Configuration.ColorMode) => {
      console.log('系统主题已改变:', newMode);
      this.notifyListeners(this.mapSystemMode(newMode));
    });
  }
}

private mapSystemMode(mode: Configuration.ColorMode): ThemeMode {
  switch (mode) {
    case Configuration.ColorMode.COLOR_MODE_DARK:
      return ThemeMode.DARK;
    case Configuration.ColorMode.COLOR_MODE_LIGHT:
      return ThemeMode.LIGHT;
    default:
      return ThemeMode.LIGHT;
  }
}

4.2 添加动画效果

// 在切换主题时添加过渡动画
async toggleThemeWithAnimation(): Promise<void> {
  // 先设置半透明
  this.applyOpacityAnimation();
  
  // 延迟切换主题
  setTimeout(async () => {
    await this.toggleTheme();
    
    // 恢复不透明
    this.removeOpacityAnimation();
  }, 300);
}

4.3 多主题支持(节日主题等)

// 扩展支持更多主题
enum ExtendedThemeMode {
  LIGHT = 'light',
  DARK = 'dark',
  SYSTEM = 'system',
  FESTIVAL = 'festival',  // 节日主题
  HIGH_CONTRAST = 'high_contrast'  // 高对比度
}

// 创建对应的资源目录
// resources/festival/element/color.json
// resources/high_contrast/element/color.json

五、实践建议

5.1 新项目:直接使用资源限定词方案

  • 从第一天就建立base/dark/目录
  • 所有颜色使用$r('app.color.xxx')
  • 一个开关控制全部

5.2 老项目迁移:三步走

  1. 第一步:创建dark/color.json,复制所有颜色
  2. 第二步:逐步替换硬编码为资源引用
  3. 第三步:添加主题切换开关

5.3 设计规范

  1. 使用语义化颜色名称:primary_textsecondary_bg
  2. 建立颜色设计系统文档
  3. 定期同步设计和开发的颜色值

总结

对于"一个开关控制白天/黑夜模式"的需求,鸿蒙的资源限定词方案是最佳选择。

它提供了:

  • 一键切换:一个开关控制全局
  • 自动适配:所有组件自动更新
  • 零代码侵入:业务组件无需修改
  • 高性能:系统级优化
  • 易维护:颜色集中管理
  • 可扩展:支持多主题

而工具类方案需要:

  • ❌ 每个组件监听主题变化
  • ❌ 手动更新所有颜色
  • ❌ 性能损耗
  • ❌ 维护困难

选择资源限定词方案,让你的主题切换像呼吸一样自然!

Luckysheet 远程搜索下拉 控件开发 : 揭秘二开全流程

作者 朴shu
2025年12月29日 20:47

前言

远程搜索下拉控件是非常常见的控件,应用在表单填写场景下,也是非常合适的,本例将带领大家实现以下效果,并真实了解并熟悉 luckysheet 的二开流程。

在这里插入图片描述

拓展单元格属性

在官网上,有一个常见问题,既然已知批注是直接加到单元格属性上,那么,我们拓展属性,是不是可以参考批注的实现方案?

在这里插入图片描述 我们设定以下是远程搜索下拉的单元格拓展属性:

// 扩展后的单元格对象
const cell = {
    v: null, // 原始值
    m: null, // 显示值
    ct: { fa: "General", t: "s" },
    // ... other properties

    // 新增远程搜索下拉配置
    remoteSelect: {
        // 是否启用远程搜索,[必传]
        enable: boolean,

        // 用户输入时的回调函数,用于获取远程数据
        onInput(value): Promise<Array<string>> {
            // 这个函数需要返回一个Promise对象,resolve一个数组,数组的元素为下拉选项对象
        }

        // 用户选定某个选项后的回调函数
        onSelect(item): void {
            // item 为用户选定的选项对象,值是onInput函数返回的数组中的某个元素
        }

        // 是否需要设定当前输入单元格的值 [可选]
        setValue(item): string{
           // item 为用户选定的选项对象,需要返回一个字符串,用于设定单元格的值
        },

        // 自定义类名  [可选]
        popperClass: string,
    },
};

何时触发?

在这个场景中,我们需要在用户输入值时,进行远程数据获取,渲染列表,因此,需要在用户进行编辑时,进行处理:

  1. 一个方案,是在初始化 inputHTML 时,将相关事件处理: 在这里插入图片描述
 $("body").append(inputHTML)
 // 进行相关事件绑定: [伪代码]
 $input.on('input',function(){
 // 这里是未知具体单元格信息的,需要通过外部传递
 const remoteSelectOptions = Store.flowdata[r][c]
 // 执行后续操作
 })
  1. 另一个方案,是用户双击时,唤起输入框后,进行判断:
// handler.js
$("#luckysheet-cell-main, #luckysheetTableContent").dblclick(function(){
// 这个函数是内置的哈,因此,可以直接获取到 r c
// 判断当前单元格是否需要初始化远程搜索控件
RemoteSelect.checkCellNeedRemoteSelect(row_index, col_index)
})

两种方案我都试过了,实现略有不同,但都可以实现,还是借助原生的 dblclick 好处理些。

校验远程搜索下拉

既然每一个单元格双击,都会触发这个校验,那么何时才需要初始化这个下拉框呢?

// 判断当前单元格是否需要显示下拉框
checkCellNeedRemoteSelect: function(r, c) {
    // 通过 r c 获取单元格信息
    let cell = Store.flowdata[r][c];

    // 如果 cell 没有配置 remoteSelect 或者 remoteSelect.enable 为 false 则返回
    if (!cell || !cell.remoteSelect || !cell.remoteSelect.enable) {
        return;
    }
}

检验完成后,如果需要初始化校验,别忘了, 我们底层还要监听输入框事件哈:

// 不然,开始监听 input 输入事件
$("#luckysheet-input-box .luckysheet-cell-input").off("input").on("input", function() {
        let value = $(this).text().trim(); // 获取用户输入的值
      
        if (cell.remoteSelect && cell.remoteSelect.onInput && typeof cell.remoteSelect.onInput == "function") {
            // 调用外部接口
            cell.remoteSelect
                .onInput(value)
                .then(function(dataList) {
                    $this.loading = false;
                    $this.showRemoteSelect(dataList);
                })
                .catch((error) => {
                    $this.loading = false;
                    $this.hideRemoteSelect();
                    console.log("请求接口错误", error);
                    tooltip.info('<i class="fa fa-exclamation-triangle"></i>', "接口请求失败");
                });
        }
    });

初始化下拉框

远程搜索的核心,就是下拉选项,当远程接口初始化完成后,执行如下:

dataList.forEach((item) => {
const $item = $(`<div class="${this.selectItemClass}">`)
.text(item)
.click(() => {
// 触发选择回调
if (cell.remoteSelect && cell.remoteSelect.onSelect && typeof cell.remoteSelect.onSelect === "function") {
    cell.remoteSelect.onSelect(item);
}
})

这里就一个难点要处理,如何将下拉框定位到输入框的位置:

// 这里采用巧方案,直接取输入框的位置
const $input = $("#luckysheet-input-box");
const left = parseInt($input.css("left")) || 0;
const top = parseInt($input.css("top")) || 0;

// 获取当前单元格的高度 行高
const [_row_pre, rowHeight] = rowLocation(this.r);

// 设置下拉框位置和宽度
$selectBox.css({
    top: `${top + rowHeight + 4}px`, // 在单元格下方显示
    left: `${left}px`,
});

总结

虽然代码看起来不复杂,但是了解luckysheet 的源码、实现思路,以及单元格拓展实现方案是非常重要的。

通过本次实现,我们初步了解了luckysheet的二次开发流程,在不破坏原有功能的基础上添加新特性。远程搜索下拉控件的实现展示了如何在现有表格组件基础上,通过合理的架构设计和代码组织,添加复杂交互功能。

Midscene v1.0 发布 - 视觉驱动,UI 自动化体验跃迁

2025年12月29日 19:15

文章来源|ByteDance Web Infra 团队

Midscene 自 2024 年开源发布以来,已经在 Github 斩获 11k star 、Trending 榜第二名等成绩,并在互联网、金融、政企、汽车等大量应用场景下完成落地。

本月,我们正式宣布 Midscene v1.0 发布!本文将为你介绍:

  • 案例回顾:Midscene 在 PC、Android、iOS 等场景的任务能力;
  • 社区案例:社区开发者基于 Midscene 与任意界面集成的特性,扩展了机械臂 + 视觉模型 + 语音模型等模块,完成车机测试;
  • 1.0 版本的模型路线:拥抱纯视觉;
  • 1.0 版本的特性优化:报告优化、MCP 架构、跨端增强、API 变更等。

案例回顾

社区案例:视觉模型 + 机械臂

演示视频请在公众号查看:mp.weixin.qq.com/s/24rFtAfih…

有社区开发者成功基于 Midscene 与任意界面集成的特性,扩展了机械臂 + 视觉模型 + 语音模型等模块,运用于车机大屏测试场景中。

移动端案例:外卖下单

打开美团,帮我下单一杯 manner 超大杯冰美式咖啡,要加浓少冰喔,到结算页面让我确认。

演示视频请在公众号查看:mp.weixin.qq.com/s/24rFtAfih…

在我们的 Midscene 官网上,还有更多实战案例:

  1. iOS 自动化 - Twitter 自动点赞 @midscene_ai 首条推文;
  2. Android 自动化 - 懂车帝查看小米 SU7 参数;
  3. Android 自动化 - Booking 预订圣诞酒店;
  4. MCP 集成 - Midscene MCP 操作界面发布 prepatch 版本。

1.0 版本的模型路线

从 V1.0 开始,Midscene 全面转向视觉理解方案,提供更稳定可靠的 UI 自动化能力。

视觉模型有以下特点:

  • 效果稳定 :业界领先的视觉模型(如 Doubao Seed 1.6、Qwen3-VL 等)表现足够稳定,已经可以满足大多数业务需求;

  • UI 操作规划 :视觉模型通常具备较强的 UI 操作规划能力,能够完成不少复杂的任务流程;

  • 适用于任意系统 :自动化框架不再依赖 UI 渲染的技术栈,无论是 Android、iOS、桌面应用,还是浏览器中的 <canvas>,只要能获取截图,Midscene 即可完成交互操作;

  • 易于编写 :抛弃各类 selector 和 DOM 之后,开发者与模型的“磨合”会变得更简单,不熟悉渲染技术的新人也能很快上手;

  • token 量显著下降 :在去除 DOM 提取之后,视觉方案的 token 使用量可以减少 80%,成本更低,且本地运行速度也变得更快

  • 有开源模型解决方案 :开源模型表现渐佳,开发者开始有机会进行私有化部署模型,如 Qwen3-VL 提供的 8B、30B 等版本在不少项目中都有着不错的效果。

详情请阅读我们更新版的模型策略[1]。

🚀 多模型组合,为复杂任务带来更好效果

除了默认的交互场景,Midscene 还定义了 Planning(规划)和 Insight(洞察)两种意图,开发者可以按需为它们启用独立的模型。例如,用 GPT 模型做规划,同时使用默认的 Doubao 模型做元素定位。

多模型组合让开发者可以按需提升复杂需求的处理能力。

🚀 运行时架构优化

针对 Midscene 的运行时表现,我们进行了以下优化:

  • 减少对设备信息接口的调用,在确保安全的情况下复用部分上下文信息,提升运行时性能,让大多数的时间消耗集中在模型端;
  • 优化 Web 及移动端环境下的 Action Space 组合,向模型开放更合理、更清晰的工具集。

🚀 回放报告优化

回放报告是 Midscene 开发者非常依赖的一个特性,它能有效提升脚本的调试效率。

在 v1.0 中,我们更新了回放报告:

  • 参数视图:标记出交互参数的位置信息,合并截图信息,快速识别模型的规划结果;
  • 样式调整:支持以深色模式展示报告,更美观;
  • Token 消耗的展示:支持按模型汇总 Token 消耗量,分析不同场景的成本情况。

🚀 MCP 架构重构

我们重新定义了 Midscene MCP 服务的定位。Midscene MCP 的职责是围绕着视觉驱动的 UI 操作展开,将 iOS / Android / Web 设备 Action Space 中的每个 Action 操作暴露为 MCP 工具,也就是提供各类“原子操作”。

通过这种形式,开发者可以更专注于构建自己的高阶 Agent,而无需关心底层 UI 操作的实现细节,并且时刻获得满意的成功率。

详情请阅读 MCP 文档[2]。

🚀 移动端能力增强

iOS 改进

  • 新增 WebDriverAgent 5.x-7.x 全版本兼容;

  • 新增 WebDriver Clear API 支持,解决动态输入框问题;

  • 提升设备兼容性。

Android 改进

  • 新增截图轮询回退机制,提升远程设备稳定性;

  • 新增屏幕方向自动适配(displayId 截图);

  • 新增 YAML 脚本 runAdbShell 支持。

跨平台

  • 在 Agent 实例上暴露系统操作接口,包括 Home、Back、RecentApp 等。

🚧 API 变更

方法重命名(向后兼容):

  • 改名 aiAction() → aiAct()(旧方法保留,有弃用警告);

  • 改名 logScreenshot() → recordToReport()(旧方法保留,有弃用警告)。

环境变量重命名(向后兼容):

  • 改名 OPENAI_API_KEY → MODEL_API_KEY(新变量优先,旧变量作为备选);
  • 改名 OPENAI_BASE_URL → MODEL_BASE_URL(新变量优先,旧变量作为备选)。

⬆️ 升级到最新版

升级项目中的依赖,例如:

  • npm install @midscene/web@latest --save-dev

  • npm install @midscene/android@latest --save-dev

  • npm install @midscene/ios@latest --save-dev

如果使用全局安装的命令行版本:npm i -g @midscene/cli

了解更多

参考资料

[1] 模型策略: https://midscenejs.com/zh/model-strategy

[2] MCP 文档: https://midscenejs.com/zh/mcp

React 跨层级组件通信:从 Props Drilling 到 useContext 的实战剖析

2025年12月29日 18:28

在 React 开发中,组件通信是日常中最常见的任务之一。父子组件间通过 props 传递数据简单高效,但当数据需要传递到多层嵌套的子组件时,“Props Drilling”(属性穿透)问题就会显现:中间层组件明明不需要这些数据,却不得不被动接收并向下传递。这不仅让代码冗余,还降低了可维护性。

React 官方提供的 Context API 正是为此而生。它允许我们在组件树的最外层“提供”数据,任何深层组件都可以直接“消费”它,而无需层层传递。本文将通过一个用户信息的实际例子,对比两种方式,帮助你理解何时、何地该使用 useContext 解决跨层级通信问题。

Props Drilling:传统方式的痛点

假设我们有一个应用,需要在最顶层 App 组件持有用户信息(如登录后的用户数据),然后在深层嵌套的 UserInfo 组件中显示用户名。

传统方式是层层通过 props 传递:

jsx

// App.jsx
export default function App() {
  const user = { name: "Andrew" };

  return (
    <Page user={user} />
  );
}

// views/Page.jsx
function Page({ user }) {
  return <Header user={user} />;
}

// components/Header.jsx
function Header({ user }) {
  return <UserInfo user={user} />;
}

// components/UserInfo.jsx
function UserInfo({ user }) {
  return <div>{user.name}</div>;
}

这种方式在层级较浅时没问题,但想象一下如果组件树更深(比如 Page → Layout → Sidebar → Header → UserInfo),就需要在每一层都添加 user prop:

jsx

<Page user={user} />
<Layout user={user} />
<Sidebar user={user} />
<Header user={user} />
<UserInfo user={user} />

中间的 Layout、Sidebar 等组件根本不需要 user 数据,却被迫成为“快递员”。这就是典型的 Props Drilling:

  • 代码冗余,维护成本高(修改一次要改多处)。
  • 中间组件耦合度增加,重构困难。
  • 阅读性差,难以快速定位数据来源。

Context API:优雅解决跨层级通信

Context API 的核心思想是:数据在最外层提供,任何子组件主动消费。这样,数据持有和改变的逻辑依然在外层组件,但消费方可以直接获取,无需中间传递。

步骤一:创建 Context

通常在独立文件中创建(推荐实践,便于复用和维护),但简单示例可放在 App 中。

jsx

// App.jsx
import { createContext } from 'react';
import Page from './views/Page';

// 创建 Context,defaultValue 为 null(生产中可设为默认值)
export const UserContext = createContext(null);

export default function App() {
  const user = { name: "Andrew" };

  return (
    <UserContext.Provider value={user}>
      <Page />
    </UserContext.Provider>
  );
}
  • createContext 创建一个上下文对象。
  • Provider 组件包裹需要共享数据的组件树。
  • value 属性就是共享的数据(可以是对象、函数、状态等)。

步骤二:消费 Context

在任何子组件中使用 useContext Hook 直接读取:

jsx

// components/UserInfo.jsx
import { useContext } from 'react';
import { UserContext } from '../App';  // 根据实际路径调整

export default function UserInfo() {
  const user = useContext(UserContext);

  return <div>{user.name}</div>;
}

中间组件无需任何修改:

jsx

// views/Page.jsx
import Header from '../components/Header';

export default function Page() {
  return <Header />;
}

// components/Header.jsx
import UserInfo from './UserInfo';

export default function Header() {
  return <UserInfo />;
}

效果完全相同,但代码干净多了!UserInfo 组件主动“找”数据,而不是被动接收。

完整目录结构示例

text

src/
├── App.jsx
├── views/
│   └── Page.jsx
└── components/
    ├── Header.jsx
    └── UserInfo.jsx

为什么 useContext 更优?

  1. 避免 Props Drilling:中间层组件无需关心数据传递。
  2. 数据来源清晰:消费组件直接导入 Context,一目了然。
  3. 灵活性高:Provider 可以包裹任意子树,支持局部共享。
  4. 性能友好(注意事项见下文):React 会优化只重渲染实际消费的组件。

进阶:动态更新 Context 数据

单纯的对象共享已足够强大,但真实场景中用户数据往往需要更新(如登录/退出)。

推荐将状态和更新函数一起提供:

jsx

// App.jsx
import { useState, createContext } from 'react';

export const UserContext = createContext(null);

export default function App() {
  const [user, setUser] = useState({ name: "Andrew" });

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <Page />
    </UserContext.Provider>
  );
}

消费方:

jsx

// UserInfo.jsx
const { user, setUser } = useContext(UserContext);

// 示例:退出登录
<button onClick={() => setUser(null)}>退出</button>
{user ? <div>{user.name}</div> : <div>未登录</div>}

这样,任何组件都能读取并修改全局用户状态。

最佳实践与注意事项

  1. 单独文件管理 Context:大型项目中,将 createContext、Provider 封装成独立文件(如 UserContext.jsx),便于团队协作。

  2. 避免频繁更新大对象:Context 使用引用相等性判断重渲染。如果每次 Provider value 都是新对象(如 {...}),会导致所有消费者重渲染。解决办法:

    • 使用 useMemo 稳定 value:

      jsx

      const value = useMemo(() => ({ user, setUser }), [user]);
      <Provider value={value}>
      
    • 或拆分多个 Context(主题、用户、配置分开)。

  3. 不要滥用:Context 适合“全局性”低频变化数据(如用户、主题、语言)。高频变化或复杂状态推荐 Zustand、Jotai 或 Redux。

  4. 结合 React.memo 优化:如果消费者不依赖 Context,可用 React.memo 防止不必要重渲染。

  5. TypeScript 支持:createContext 时可指定类型,提升类型安全。

实际应用场景举例

  • 用户信息:登录状态、头像、权限。
  • 主题切换:dark/light mode。
  • 国际化:当前语言包。
  • 布局配置:侧边栏展开状态。

这些数据往往被多个深层组件使用,使用 Context 能极大简化代码。

结语

Props Drilling 是 React 新手最先接触的方式,但随着项目规模增长,它会成为维护的枷锁。Context API + useContext 提供了原生、轻量级的解决方案,让跨层级通信变得优雅而高效。

记住核心原则:数据在外层提供,消费方主动获取。这不仅解决了 Props Drilling,还为未来扩展(如结合 Reducer 实现小型状态管理)打下基础。

在 2025 年的 React 生态中,Context API 依然是中小型项目全局状态管理的首选。合理使用它,你的组件树将更清晰、可维护性更强。

赶紧在你的项目中试试吧——从一个简单的用户上下文开始,你会爱上这种“跳跃式”数据传递的自由!

滑动窗口详解:原理+分类+场景+模板+例题(视频贼清晰)

作者 颜酱
2025年12月29日 18:28

滑动窗口详解:原理+分类+场景+模板+例题

📺 推荐视频滑动窗口算法详解 - 视频解释非常清晰,建议先看视频再阅读本文!

在算法面试中,子串、子数组相关的问题频繁出现,暴力枚举往往因 O(n²) 时间复杂度超时。而滑动窗口算法,凭借其 O(n) 的高效性能,成为解决这类问题的"神兵利器"。本文将从原理本质出发,梳理滑动窗口的分类、适用场景,提炼通用模板,并结合经典例题实战拆解,帮你彻底掌握这一核心算法。

一、滑动窗口核心原理:用单调性压缩遍历维度

滑动窗口的本质,是利用区间的单调性,将原本需要嵌套遍历(O(n²))的连续区间问题,转化为单轮双指针遍历(O(n))。其核心逻辑基于对“窗口状态”的精准把控,通过两个指针(left 左边界、right 右边界)的协同移动,跳过无效区间(剪枝),实现高效枚举。

1.1 先搞懂:暴力枚举的痛点

以“无重复字符的最长子串”为例,暴力思路是枚举所有子串的起点 i 和终点 j(i≤j),检查子串 s[i..j] 是否无重复,最终记录最长长度。这种方式需要遍历所有 i、j 组合,时间复杂度 O(n²),且存在大量无效计算:比如当 s[0..3] 存在重复时,s[0..4]、s[0..5] 等包含该区间的子串必然也重复,无需再检查。

1.2 滑动窗口的核心洞察:区间单调性

滑动窗口能优化的关键,是抓住了「窗口状态的单调性」—— 窗口的状态(如是否含重复、和/积是否满足条件)会随窗口的扩展/缩小呈现单向变化,具体可总结为两条核心规律:

  • 规律1(坏状态的包含性):若窗口 [left, right] 处于“坏状态”(如含重复字符、和≥target、积≥K),则所有包含该窗口的更大窗口 [left, right+1]、[left, right+2]... 必然也是“坏状态”。此时无需继续扩展 right,应移动 left 缩小窗口,跳过无效区间。

  • 规律2(好状态的被包含性):若窗口 [left, right] 处于“好状态”(如无重复、和<target、积<K),则所有被该窗口包含的更小窗口 [left+1, right]、[left+2, right]... 必然也是“好状态”。此时无需缩小窗口,应继续扩展 right 寻找更优解。

1.3 一句话总结原理

滑动窗口通过 right 指针“扩窗口”探索新的区间,通过 left 指针“缩窗口”剔除无效区间,每个元素最多被加入窗口(right 移动)和移出窗口(left 移动)各一次,最终以 O(n) 时间完成所有有效区间的枚举。

二、滑动窗口的分类:按目标场景划分

滑动窗口的核心逻辑一致,但根据问题目标(求最长、求最短、求计数)的不同,缩窗口的条件和更新答案的时机会有差异。按目标可分为三大类,覆盖绝大多数经典场景:

分类 核心目标 缩窗口条件 更新答案时机 典型问题
类型1:求最长/最大区间 找到满足“好状态”的最长连续区间 窗口进入“坏状态”时,缩 left 至回到“好状态” 缩窗口完成后,每次扩展 right 后更新 无重复字符的最长子串、最长重复子数组
类型2:求最短/最小区间 找到满足“好状态”的最短连续区间 窗口进入“好状态”时,缩 left 至回到“坏状态”(尽可能缩小窗口) 缩窗口过程中,每次缩小 left 后更新 长度最小的子数组、最小覆盖子串
类型3:求计数/统计区间 统计所有满足“好状态”的连续区间个数 窗口进入“坏状态”时,缩 left 至回到“好状态” 缩窗口完成后,累加当前 right 对应的有效区间数(right-left+1) 乘积小于 K 的子数组、找到字符串中所有字母异位词

三、适用场景:3个核心判断标准

并非所有子串/子数组问题都能用滑动窗口,需满足以下 3 个核心条件,缺一不可:

  1. 问题对象是连续区间:滑动窗口仅适用于“连续子串”或“连续子数组”问题,非连续区间(如子序列)不适用。

  2. 窗口状态具有单调性:需满足前文提到的两条规律之一,即扩展/缩小窗口时,状态变化是单向的。反例:“找和为 target 的子数组(含负数值)”,窗口 [left, right] 和为 target 时,扩展 right 可能因负数导致和变小,打破单调性,无法用滑动窗口。

  3. 状态可快速更新:加入 right 元素或移出 left 元素时,窗口的状态(如和、积、字符频率)能在 O(1) 时间内更新,无需重新计算整个窗口状态。

四、通用模板:3类场景统一框架

基于上述分类,提炼出通用模板,只需根据目标调整「缩窗口条件」和「更新答案时机」即可。模板核心步骤:初始化变量 → 扩窗口 → 缩窗口 → 更新答案。

4.0 快速参考表

类型 初始 ans 缩窗口条件 更新答案时机 关键代码
类型1:求最长 0 进入坏状态 缩窗口后,每次扩展 right 后 ans = Math.max(ans, right - left + 1)
类型2:求最短 Infinity 进入好状态 缩窗口过程中 ans = Math.min(ans, right - left + 1)
类型3:求计数 0 进入坏状态 缩窗口后 ans += right - left + 1

4.1 通用模板(TypeScript/JavaScript)

function slidingWindowTemplate<T>(data: T[], targetParam: any): number {
  // 1. 初始化变量
  let left = 0; // 左窗口边界
  let ans = 初始值; // 答案变量(最长→0,最短→Infinity,计数→0)
  let status = 初始状态; // 如对象(字符频率)、sum=0、prod=1

  // 2. 扩窗口:right 遍历所有元素
  for (let right = 0; right < data.length; right++) {
    const rightVal = data[right];
    // 加入右元素,更新状态
    // status.update(rightVal); // 根据具体类型更新

    // 3. 缩窗口:根据目标和当前状态判断是否缩左
    while (缩窗口条件) {
      // 核心差异点:不同类型场景条件不同
      const leftVal = data[left];
      // 移出左元素,更新状态
      // status.remove(leftVal); // 根据具体类型更新
      left++; // 缩小窗口
    }

    // 4. 更新答案:根据类型调整时机
    // 答案更新逻辑
    // 核心差异点:不同类型场景时机不同
  }

  // 5. 处理边界情况(如无满足条件的窗口)
  return 处理后的 ans;
}

4.2 分类型模板细化

类型1:求最长/最大区间

function maxLengthTemplate<T>(data: T[], param: any): number {
  let left = 0;
  let ans = 0; // 最长初始为0
  const status: Record<string, number> = {}; // 对象:记录字符频率

  for (let right = 0; right < data.length; right++) {
    const rightVal = data[right];
    // 更新状态
    status[rightVal as string] = status[rightVal as string] ? status[rightVal as string] + 1 : 1;

    // 缩窗口条件:进入坏状态
    while (坏状态判断) {
      // 如 status[rightVal] > 1(重复字符)
      const leftVal = data[left];
      status[leftVal as string]--;
      left++;
    }

    // 更新答案:缩窗口后,当前窗口是有效最长窗口
    ans = Math.max(ans, right - left + 1);
  }

  return ans;
}

类型2:求最短/最小区间

function minLengthTemplate(data: number[], param: any): number {
  let left = 0;
  let ans = Infinity; // 最短初始为无穷大
  let status = 0; // 如 sumWindow = 0

  for (let right = 0; right < data.length; right++) {
    const rightVal = data[right];
    status += rightVal; // 更新状态

    // 缩窗口条件:进入好状态(尽可能缩小窗口)
    while (好状态判断) {
      // 如 status >= target(和≥目标)
      // 缩窗口时更新答案
      ans = Math.min(ans, right - left + 1);
      const leftVal = data[left];
      status -= leftVal;
      left++;
    }
  }

  // 处理边界:无满足条件的窗口返回0
  return ans !== Infinity ? ans : 0;
}

类型3:求计数/统计区间

function countTemplate(data: number[], param: any): number {
  let left = 0;
  let ans = 0; // 计数初始为0
  let status = 1; // 如 prod = 1

  for (let right = 0; right < data.length; right++) {
    const rightVal = data[right];
    status *= rightVal; // 更新状态

    // 缩窗口条件:进入坏状态
    while (坏状态判断) {
      // 如 status >= K(乘积≥K)
      const leftVal = data[left];
      status /= leftVal;
      left++;
    }

    // 更新答案:当前right对应的有效区间数 = right-left+1
    ans += right - left + 1;
  }

  return ans;
}

五、经典例题实战:逐行拆解

结合模板,拆解 3 类场景的经典例题,帮你理解如何将模板落地到具体问题。

例题1:无重复字符的最长子串(类型1:求最长)

题目描述

给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。

解题思路

  • 窗口状态(坏):窗口内存在重复字符;
  • 状态统计:用对象记录窗口内字符的出现次数;
  • 缩窗口条件:当前加入的字符出现次数>1(进入坏状态);
  • 更新答案:缩窗口完成后,计算当前窗口长度,更新最大值。

代码实现

function lengthOfLongestSubstring(s: string): number {
  let left = 0;
  let ans = 0; // 最长子串长度初始为0
  const window: Record<string, number> = {}; // 对象:记录窗口内字符出现次数

  for (let right = 0; right < s.length; right++) {
    const rightChar = s[right];
    // 加入右字符,更新状态
    window[rightChar] = window[rightChar] ? window[rightChar] + 1 : 1;

    // 缩窗口:当当前字符出现次数>1(坏状态),缩左直到无重复
    while (window[rightChar] > 1) {
      const leftChar = s[left];
      window[leftChar]--; // 移出左字符,更新状态
      left++;
    }

    // 更新答案:当前窗口是无重复的有效窗口,计算长度
    ans = Math.max(ans, right - left + 1);
  }

  return ans;
}

复杂度分析

时间复杂度 O(n):每个字符被 right 加入、left 移出各一次;空间复杂度 O(min(m, n))):m 是字符集大小,窗口内字符数不超过 min(m, n)。

例题2:长度最小的子数组(类型2:求最短)

题目描述

给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和 ≥ target 的长度最小的 连续子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。

解题思路

  • 窗口状态(好):窗口和≥target;
  • 状态统计:用 sumWindow 记录窗口内元素和;
  • 缩窗口条件:sumWindow≥target(进入好状态),缩左以寻找更短窗口;
  • 更新答案:缩窗口过程中,每次缩小后计算窗口长度,更新最小值。

代码实现

function minSubArrayLen(target: number, nums: number[]): number {
  let left = 0;
  let ans = Infinity; // 最短长度初始为无穷大
  let sumWindow = 0; // 窗口内元素和

  for (let right = 0; right < nums.length; right++) {
    sumWindow += nums[right]; // 加入右元素,更新和

    // 缩窗口:和≥target时,尽可能缩小窗口
    while (sumWindow >= target) {
      // 缩窗口时更新答案:当前窗口是有效最短窗口候选
      ans = Math.min(ans, right - left + 1);
      sumWindow -= nums[left]; // 移出左元素,更新和
      left++;
    }
  }

  // 处理边界:无满足条件的窗口返回0
  return ans !== Infinity ? ans : 0;
}

复杂度分析

时间复杂度 O(n):每个元素最多被遍历两次;空间复杂度 O(1):仅用常数级变量。

例题3:乘积小于 K 的子数组(类型3:求计数)

题目描述

给你一个整数数组 nums 和一个整数 k,统计并返回该数组中乘积小于 k 的连续子数组的个数。

解题思路

  • 窗口状态(坏):窗口乘积≥k;
  • 状态统计:用 prod 记录窗口内元素乘积;
  • 缩窗口条件:prod≥k(进入坏状态),缩左直到乘积<k;
  • 更新答案:缩窗口完成后,当前 right 对应的有效子数组数为 right-left+1(即 [left,right]、[left+1,right]...[right,right])。

代码实现

function numSubarrayProductLessThanK(nums: number[], k: number): number {
  // 边界条件:k≤1时,所有正整数乘积≥1,无满足条件的子数组
  if (k <= 1) {
    return 0;
  }
  let left = 0;
  let ans = 0; // 计数初始为0
  let prod = 1; // 窗口内元素乘积

  for (let right = 0; right < nums.length; right++) {
    prod *= nums[right]; // 加入右元素,更新乘积

    // 缩窗口:乘积≥k时,缩左直到乘积<k
    while (prod >= k) {
      prod /= nums[left]; // 移出左元素,更新乘积
      left++;
    }

    // 累加当前right对应的有效子数组数
    // 当窗口 [left, right] 的乘积 < k 时,以 right 结尾的所有子数组都满足条件
    // 即 [left,right]、[left+1,right]...[right,right] 共 right-left+1 个
    ans += right - left + 1;
  }

  return ans;
}

复杂度分析

时间复杂度 O(n):每个元素最多被遍历两次;空间复杂度 O(1):仅用常数级变量。

六、新手避坑指南

  1. 窗口边界统一:建议全程使用「左闭右闭」或「左闭右开」边界定义,不要混用。本文所有例题均采用「左闭右闭」,窗口长度为 right-left+1。

  2. 状态更新顺序:缩窗口时,需先更新状态(如减 sum、除 prod),再移动 left 指针,避免漏算或多算。

  3. 边界条件处理

    • 求最短时,初始 ans 设为无穷大,最后需判断是否更新过(未更新则返回 0);

    • 乘积问题需注意 k≤1 的情况(正整数乘积最小为 1,直接返回 0);

    • 空字符串/空数组需提前返回 0。

  4. 单调性验证:遇到子串/子数组问题时,先手动模拟 2-3 个案例,确认状态是否满足单调性,再决定是否用滑动窗口。

七、总结

滑动窗口的核心是「用单调性压缩遍历维度」,剪枝只是优化手段。掌握它的关键在于:

  1. 判断问题是否满足「连续区间+状态单调性+状态可快速更新」;

  2. 根据目标(最长/最短/计数)确定「缩窗口条件」和「更新答案时机」;

  3. 套用通用模板,灵活调整状态统计工具(哈希表/和/积)。

只要抓住这三点,无论是简单的“无重复子串”,还是复杂的“最小覆盖子串”,都能按此逻辑拆解。建议多做几道经典例题,固化模板思维,面试时就能快速反应。

练习题推荐

按难度和类型分类,建议按顺序练习:

基础题(必做)

进阶题(推荐)

扩展题(挑战)

React自定义Hooks

2025年12月29日 18:25

自定义一些常见的hook,方便在工作中使用,以下内容都是本人工作中的使用经验,不足之处,欢迎指正

useBooleans

import { useState } from 'react'

/**
 * @description 切换true/false的公共hook
 * @param {Boolean} initValue 默认值
 */

export interface UseBooleansActions {
  toggle: () => void // 切换值
  set: (value: boolean) => void // 设置值
  setTrue: () => void // 设置为true
  setFalse: () => void // 设置值为false
  reset: () => void // 重置为初始值
}

function useBooleans(initValue = false): [boolean, UseBooleansActions] {
  const [value, setValue] = useState<boolean>(initValue)

  /** 切换值 */
  const toggle = () => {
    setValue((pre) => !pre)
  }

  /** 设置值 */
  const set = (value: boolean) => {
    setValue(value)
  }

  /** 设置为true */
  const setTrue = () => {
    setValue(true)
  }

  /** 设置为false */
  const setFalse = () => {
    setValue(false)
  }

  /** 重置为初始值 */
  const reset = () => {
    setValue(initValue)
  }

  return [value, { toggle, set, setTrue, setFalse, reset }]
}

export default useBooleans

// 使用
const [booleanValue, { toggle, setTrue, setFalse, reset, set }] = useBooleans(true)

useToggle

import { useMemo, useState } from 'react'

// 定义操作方法的接口
interface Actions<T, U> {
  toggle: () => void
  set: (value: T | U) => void
  setLeft: () => void // 设置为默认值
  setRight: () => void // 设置为取反值
}

function useToggle<T = boolean>(
  defaultValue?: T,
  reverseValue?: T,
): [T, Actions<T, T>]

function useToggle<T, U>(
  defaultValue: T,
  reverseValue: U,
): [T | U, Actions<T | U, T | U>]

function useToggle<D, R>(
  defaultValue: D = false as unknown as D,
  reverseValue?: R,
) {
  // 状态管理:支持默认值和取反值
  const [state, setState] = useState<D | R>(defaultValue)

  // 计算实际的取反值(若未提供则使用布尔取反)
  const reverseValueOrigin =
    reverseValue === undefined ? !defaultValue : reverseValue

  // 缓存操作方法避免重复创建
  const actions = useMemo(() => {
    // 核心切换逻辑:在默认值和取反值之间切换
    const toggle = () =>
      setState(
        (s) =>
          (s === defaultValue ? reverseValueOrigin : defaultValue) as D | R,
      )
    const set = (value: D | R) => setState(value)
    const setLeft = () => setState(defaultValue)
    const setRight = () => setState(reverseValueOrigin as D | R)

    return { toggle, set, setLeft, setRight }
  }, [defaultValue, reverseValueOrigin])

  return [state, actions]
}

export default useToggle

// 使用
const [toggleValue, { toggle: toggle1, set: set1, setLeft, setRight }] =
    useToggle<'left', 'right'>('left', 'right')

useExcel

基于xlsx的一些常见的导入导出功能封装

import { getTypeOf, isAvailableArr } from '@/utils'
import { message } from 'antd'
import type {
  BookType,
  ColInfo,
  Range,
  RowInfo,
  Sheet2JSONOpts,
  WorkBook,
} from 'xlsx'
import * as XLSX from 'xlsx'

// 导出文件配置
export type IExportConfig = {
  name?: string // 导出文件名称
  bookType?: BookType // 导出文件类型
  sheetName?: string // sheet名称
  errorMsg?: string // 错误提示
  headers?: Record<string, string> // 自定义表头,导出的文件里面只有在定义中的字段,并且如果数据为空的话,只生成一个表头。示例:{id: 'ID', name: '链接名称', site_type: '官网类型'}
  merges?: Range[] // 单元格合并
  colInfo?: ColInfo[] // 列属性
  rowInfo?: RowInfo[] // 行属性
}

// 多sheet导出
export type IExtraSheetConfig = {
  name?: string // 导出文件名称
  bookType?: BookType // 导出文件类型
  sheets: ({ json: any[] } & Omit<IExportConfig, 'name' | 'bookType'>)[]
}

/**
 * @desc 自定义导出文件hook
 */
const useExcel = () => {
  /**
   * @desc 一维数组导出Excel文件
   *
   * 此函数将一个一维数组转换为Excel文件并下载
   * 支持自定义表头、单元格合并、列属性和行属性
   *
   * @param {any[]} json - 要导出的数据数组
   * @param {IExportConfig} config - 导出配置项
   * @param {string} [config.name='导出'] - 导出文件名称
   * @param {BookType} [config.bookType='xlsx'] - 导出文件类型
   * @param {string} [config.sheetName='Sheet1'] - 工作表名称
   * @param {Record<string, string>} [config.headers] - 自定义表头映射
   * @param {Range[]} [config.merges] - 单元格合并范围
   * @param {ColInfo[]} [config.colInfo] - 列属性配置
   * @param {RowInfo[]} [config.rowInfo] - 行属性配置
   * @returns {void}
   */
  function exportJson2Excel<T = any>(json: T[], config?: IExportConfig) {
    const {
      name = '导出',
      sheetName = 'Sheet1',
      bookType = 'xlsx',
      headers,
      merges,
      colInfo,
      rowInfo,
    } = config || {}
    let lists = [...json]
    if (
      headers &&
      getTypeOf(headers) === 'Object' &&
      isAvailableArr(Object.keys(headers))
    ) {
      // 没有数据的时候用header去生成一个空表头
      if (!isAvailableArr(lists)) {
        const headersField = Object.values(headers)
        const headerObj: Record<string, any> = {}
        headersField.forEach((f) => {
          headerObj[f] = null
        })
        lists = [headerObj as T]
      } else {
        // 有数据的时候根据header去生成
        const headerFields = Object.entries(headers)
        lists = json.map((j) => {
          const obj: Record<string, any> = {}
          for (const [key, value] of headerFields) {
            obj[value] = j[key as keyof T] ?? null
          }
          return obj
        }) as T[]
      }
    }

    const wb = XLSX.utils.book_new()
    const ws = XLSX.utils.json_to_sheet(lists)
    if (merges) {
      ws['!merges'] = merges
    }
    if (colInfo) {
      ws['!cols'] = colInfo
    }
    if (rowInfo) {
      ws['!rows'] = rowInfo
    }
    XLSX.utils.book_append_sheet(wb, ws, sheetName)
    XLSX.writeFile(wb, `${name}.${bookType}`, { bookType })
  }

  /**
   * @desc 一维数组多sheet导出Excel文件
   *
   * 此函数将多个一维数组分别放在不同的工作表中导出为一个Excel文件
   * 支持自定义表头、单元格合并、列属性和行属性
   *
   * @param {IExtraSheetConfig} params - 多sheet导出配置项
   * @param {string} [params.name='导出'] - 导出文件名称
   * @param {BookType} [params.bookType='xlsx'] - 导出文件类型
   * @param {Array} params.sheets - 工作表配置数组
   * @param {any[]} params.sheets[].json - 要导出的数据数组
   * @param {string} [params.sheets[].sheetName] - 工作表名称
   * @param {Record<string, string>} [params.sheets[].headers] - 自定义表头映射
   * @param {Range[]} [params.sheets[].merges] - 单元格合并范围
   * @param {ColInfo[]} [params.sheets[].colInfo] - 列属性配置
   * @param {RowInfo[]} [params.sheets[].rowInfo] - 行属性配置
   * @returns {void}
   */
  function exportJson2ExcelSheets<T = any>(params: IExtraSheetConfig) {
    const { name = '导出', bookType = 'xlsx', sheets } = params || {}
    const wb = XLSX.utils.book_new()
    if (isAvailableArr(sheets)) {
      sheets?.forEach((s) => {
        const { json, headers, merges, colInfo, rowInfo, sheetName } = s || {}
        let lists = [...json]
        if (
          headers &&
          getTypeOf(headers) === 'Object' &&
          isAvailableArr(Object.keys(headers))
        ) {
          // 没有数据的时候用header去生成一个空表头
          if (!isAvailableArr(lists)) {
            const headersField = Object.values(headers)
            const headerObj: Record<string, any> = {}
            headersField.forEach((f) => {
              headerObj[f] = null
            })
            lists = [headerObj as T]
          } else {
            // 有数据的时候根据header去生成
            const headerFields = Object.entries(headers)
            lists = json.map((j) => {
              const obj: Record<string, any> = {}
              for (const [key, value] of headerFields) {
                obj[value] = j[key] ?? null
              }
              return obj
            }) as T[]
          }
        }

        const ws = XLSX.utils.json_to_sheet(lists)
        if (merges) {
          ws['!merges'] = merges
        }
        if (colInfo) {
          ws['!cols'] = colInfo
        }
        if (rowInfo) {
          ws['!rows'] = rowInfo
        }
        XLSX.utils.book_append_sheet(wb, ws, sheetName)
      })
    } else {
      const ws = XLSX.utils.json_to_sheet([])
      XLSX.utils.book_append_sheet(wb, ws)
    }

    XLSX.writeFile(wb, `${name}.${bookType}`, { bookType })
  }

  /**
   * @desc 二维数组导出Excel文件
   *
   * 此函数将一个二维数组转换为Excel文件并下载
   * 支持单元格合并、列属性和行属性
   *
   * @param {any[][]} aoas - 要导出的二维数组
   * @param {IExportConfig} config - 导出配置项
   * @param {string} [config.name='导出'] - 导出文件名称
   * @param {BookType} [config.bookType='xlsx'] - 导出文件类型
   * @param {string} [config.sheetName='Sheet1'] - 工作表名称
   * @param {Range[]} [config.merges] - 单元格合并范围
   * @param {ColInfo[]} [config.colInfo] - 列属性配置
   * @param {RowInfo[]} [config.rowInfo] - 行属性配置
   * @returns {void}
   */
  function exportAoa2Excel<T = any>(aoas: T[][], config?: IExportConfig) {
    const {
      name = '导出',
      sheetName = 'Sheet1',
      bookType = 'xlsx',
      merges,
      colInfo,
      rowInfo,
    } = config || {}

    const wb = XLSX.utils.book_new()
    const ws = XLSX.utils.aoa_to_sheet(aoas)
    if (merges) {
      ws['!merges'] = merges
    }
    if (colInfo) {
      ws['!cols'] = colInfo
    }
    if (rowInfo) {
      ws['!rows'] = rowInfo
    }

    XLSX.utils.book_append_sheet(wb, ws, sheetName)
    XLSX.writeFile(wb, `${name}.${bookType}`, { bookType })
  }

  /**
   * @desc 从本地文件读取Excel工作簿
   *
   * 此函数从File或Blob对象读取Excel文件并返回工作簿对象
   *
   * @param {File | Blob} file - 要读取的Excel文件
   * @returns {Promise<WorkBook | false>} 返回工作簿对象或false(读取失败时)
   */
  function readWorkbookFromLocalFile(
    file: File | Blob,
  ): Promise<WorkBook | false> {
    return new Promise((resolve) => {
      const reader = new FileReader()
      reader.readAsBinaryString(file)
      reader.onload = function (e) {
        const data = (e.target as any)?.result
        const workbook = XLSX.read(data, {
          type: 'binary',
          raw: true,
          cellNF: true,
        })
        resolve(workbook)
      }
      reader.onerror = function () {
        resolve(false)
      }
    })
  }

  /**
   * @desc 从本地Excel文件读取数据为JSON格式
   *
   * 此函数从File或Blob对象读取Excel文件并返回第一个工作表的数据为JSON数组
   *
   * @param {File | Blob} file - 要读取的Excel文件
   * @param {Sheet2JSONOpts} [options] - 工作表转JSON的选项
   * @returns {Promise<any[] | false>} 返回JSON数组或false(读取失败时)
   */
  function readFileToJson<T = any>(
    file: File | Blob,
    options?: Sheet2JSONOpts,
  ): Promise<T[] | false> {
    return new Promise((resolve) => {
      const reader = new FileReader()
      reader.readAsBinaryString(file)
      reader.onload = function (e) {
        const data = (e.target as any)?.result
        const workbook = XLSX.read(data, {
          type: 'binary',
          raw: true,
          cellNF: true,
        })
        const json = XLSX.utils.sheet_to_json<T>(
          workbook.Sheets[workbook.SheetNames[0]],
          options,
        )
        resolve(json)
      }
      reader.onerror = function () {
        resolve(false)
      }
    })
  }

  /**
   * @desc 从本地Excel文件读取多个工作表的数据为JSON格式
   *
   * 此函数从File或Blob对象读取Excel文件并返回所有工作表的数据为JSON对象
   *
   * @param {File | Blob} file - 要读取的Excel文件
   * @param {Sheet2JSONOpts} [options] - 工作表转JSON的选项
   * @returns {Promise<Record<string, any[]> | false>} 返回包含所有工作表数据的对象或false(读取失败时)
   */
  function readFileToJsons(
    file: File | Blob,
    options?: Sheet2JSONOpts,
  ): Promise<Record<string, any[]> | false> {
    return new Promise((resolve) => {
      const reader = new FileReader()
      reader.readAsBinaryString(file)
      reader.onload = function (e) {
        const data = e.target?.result
        const workbook = XLSX.read(data, {
          type: 'binary',
          raw: true,
          cellNF: true,
        })

        const sheetNames = workbook.SheetNames
        if (!sheetNames?.length) {
          resolve(false)
        }

        const jsons = sheetNames.reduce(
          (pre: Record<string, any[]>, cur: string) => {
            const json = XLSX.utils.sheet_to_json(workbook.Sheets[cur], options)
            pre[cur] = json
            return pre
          },
          {},
        )

        resolve(jsons)
      }
      reader.onerror = function () {
        resolve(false)
      }
    })
  }

  /**
   * @desc 文件流导出Excel文件
   *
   * 此函数从File或Blob对象读取Excel文件流并导出为Excel文件
   *
   * @param {File | Blob} file - 要导出的Excel文件流
   * @param {IExportConfig} config - 导出配置项
   * @param {string} [config.name='导出'] - 导出文件名称
   * @param {BookType} [config.bookType='xlsx'] - 导出文件类型
   * @param {string} [config.errorMsg='下载失败'] - 错误提示信息
   * @returns {Promise<void>}
   */
  async function exportBuffer2Excel(file: File | Blob, config?: IExportConfig) {
    const {
      name = '导出',
      bookType = 'xlsx',
      errorMsg = '下载失败',
    } = config || {}
    const wb: WorkBook | false = await readWorkbookFromLocalFile(file)
    if (wb) {
      XLSX.writeFile(wb, `${name}.${bookType}`, { bookType })
    } else {
      message.error(errorMsg)
    }
  }

  /**
   * @desc 从URL导出Excel文件
   *
   * 此函数从URL获取Excel文件并导出
   *
   * @param {string} url - Excel文件的URL地址
   * @param {IExportConfig} config - 导出配置项
   * @param {string} [config.name='导出'] - 导出文件名称
   * @param {BookType} [config.bookType='xlsx'] - 导出文件类型
   * @param {string} [config.errorMsg='下载失败'] - 错误提示信息
   * @returns {Promise<void>}
   */
  async function exportUrl2Excel(url: string, config?: IExportConfig) {
    fetch(url)
      .then((response) => response.blob())
      .then((blob) => {
        exportBuffer2Excel(new Blob([blob]), config)
      })
      .catch((error) => {
        throw error
      })
  }

  /**
   * @desc 从工作表中读取指定列的数据
   *
   * 此函数从Excel文件的指定工作表中读取指定列的数据
   *
   * @param {File | Blob} file - Excel文件
   * @param {number} [sheetIndex=0] - 工作表索引
   * @param {string[]} [columns=['A']] - 要读取的列数组(如['A', 'B'])
   * @param {Record<string, string>} [fieldsMap={}] - 列名到字段名的映射
   * @returns {Promise<any[] | false>} 返回包含指定列数据的数组或false(读取失败时)
   */
  function readColumnFromSheet(
    file: File | Blob,
    sheetIndex: number = 0,
    columns: string[] = ['A'],
    fieldsMap: Record<string, string> = {},
  ): Promise<any[] | false> {
    return new Promise((resolve) => {
      const reader = new FileReader()
      reader.readAsBinaryString(file)
      reader.onload = function (e) {
        const data = (e.target as any)?.result
        const workbook = XLSX.read(data, {
          type: 'binary',
          raw: true,
          cellNF: true,
        })

        const sheetName = workbook.SheetNames[sheetIndex]
        const worksheet = workbook.Sheets[sheetName]

        // 获取 A 列的所有数据,跳过第一行(标题行)
        const columnData = []
        const range = XLSX.utils.decode_range(worksheet['!ref'] as string)

        // 从第2行开始(索引为1,跳过标题行)
        for (let row = range.s.r + 1; row <= range.e.r; row++) {
          const res: Record<string, any> = {}
          columns.forEach((col: string) => {
            const columnIndex = XLSX.utils.decode_col(col) // 将列字母转换为索引
            const cellAddress = XLSX.utils.encode_cell({
              r: row,
              c: columnIndex,
            }) // A列是第0列
            const cell = worksheet[cellAddress]
            const field = fieldsMap[col] ?? col
            res[field] = cell ? cell.v : ''
          })
          columnData.push(res)
        }

        resolve(columnData)
      }
      reader.onerror = function () {
        resolve(false)
      }
    })
  }

  /**
   * @desc 从工作表中获取指定列的数据
   *
   * 此函数从Excel工作表中获取指定列的所有数据(跳过标题行)
   *
   * @param {XLSX.WorkSheet} worksheet - Excel工作表对象
   * @param {string} column - 要获取数据的列(如'A'、'B')
   * @returns {any[]} 返回指定列的数据数组
   */
  function getColumnData(worksheet: XLSX.WorkSheet, column: string) {
    const columnData = []
    const range = XLSX.utils.decode_range(worksheet['!ref'] as string)
    const columnIndex = XLSX.utils.decode_col(column) // 将列字母转换为索引

    for (let row = range.s.r + 1; row <= range.e.r; row++) {
      const cellAddress = XLSX.utils.encode_cell({ r: row, c: columnIndex })
      const cell = worksheet[cellAddress]
      columnData.push(cell ? cell.v : null)
    }

    return columnData
  }

  /**
   * @desc 从本地文件读取Excel工作簿
   *
   * 此函数从File或Blob对象读取Excel文件并返回工作簿对象
   *
   * @param {File | Blob} file - 要读取的Excel文件
   * @param {XLSX.ParsingOptions} [options] - 解析选项
   * @returns {Promise<WorkBook | false>} 返回工作簿对象或false(读取失败时)
   */
  function readWorkbookFromFile(
    file: File | Blob,
    options: XLSX.ParsingOptions = {
      type: 'binary',
      raw: true,
      cellNF: true,
    },
  ): Promise<WorkBook | false> {
    return new Promise((resolve) => {
      const reader = new FileReader()
      reader.readAsBinaryString(file)
      reader.onload = function (e) {
        const data = (e.target as any)?.result
        const workbook = XLSX.read(data, options)
        resolve(workbook)
      }
    })
  }

  /**
   * @desc 从URL读取Excel工作簿
   *
   * 此函数从URL读取Excel文件并返回工作簿对象
   *
   * @param {string} url - Excel文件的URL地址
   * @param {XLSX.ParsingOptions} [options] - 解析选项
   * @returns {WorkBook} 返回工作簿对象
   */
  function readWorkbookFromUrl(
    url: string,
    options: XLSX.ParsingOptions = {
      type: 'string',
      raw: true,
      cellNF: true,
    },
  ): WorkBook {
    const workbook = XLSX.readFile(url, options)
    return workbook
  }

  /**
   * @desc 从URL读取Excel工作簿
   *
   * 此函数从URL读取Excel文件并返回工作簿对象
   *
   * @param {string} url - Excel文件的URL地址
   * @param {XLSX.ParsingOptions} [options] - 解析选项
   * @returns {WorkBook} 返回工作簿对象
   */
  async function readWorkbookFromUrl1(url: string): Promise<WorkBook | false> {
    try {
      const res = await fetch(url)
      const blob = await res.blob()
      const wb = readWorkbookFromFile(new Blob([blob]))
      return Promise.resolve(wb)
    } catch  {
      return Promise.resolve(false)
    }
  }

  /**
   * @desc 保存工作簿为Excel文件
   *
   * 此函数将工作簿对象保存为Excel文件
   *
   * @param {WorkBook} workbook - 要保存的工作簿对象
   * @param {string} fileName - 文件名
   * @param {XLSX.WritingOptions} [options] - 写入选项
   * @returns {void}
   */
  function saveWbToExcel(
    workbook: WorkBook,
    fileName: string,
    options?: XLSX.WritingOptions,
  ) {
    XLSX.writeFile(workbook, fileName, options)
  }

  return {
    exportJson2Excel,
    exportAoa2Excel,
    exportBuffer2Excel,
    exportJson2ExcelSheets,
    exportUrl2Excel,
    readWorkbookFromLocalFile,
    readFileToJson,
    readFileToJsons,
    readColumnFromSheet,
    getColumnData,
    readWorkbookFromFile,
    readWorkbookFromUrl,
    readWorkbookFromUrl1,
    saveWbToExcel,
  }
}

export default useExcel

// 使用
const {
    exportJson2Excel,
    readFileToJson,
    readFileToJsons,
    readColumnFromSheet,
    readWorkbookFromUrl,
    readWorkbookFromUrl1,
    readWorkbookFromFile,
    saveWbToExcel,
  } = useExcel()

useSet

import { useMemo, useState } from 'react'

// 定义操作方法的接口
interface Actions<T> {
  add: (key: T) => void // 新增一条数据
  set: (map: Set<T>) => void // 设置全部数据
  remove: (key: T) => void // 删除某条数据
  clear: () => void // 清空数据
  reset: () => void // 重置为默认值
}

function useSet<T>(defaultValue: Set<T> = new Set()): [Set<T>, Actions<T>] {
  const [sets, setSets] = useState<Set<T>>(defaultValue)

  // 缓存操作方法避免重复创建
  const actions = useMemo(() => {
    // 新增数据
    const add = (value: T) => {
      const newSet = new Set(sets)
      newSet.add(value)
      setSets(newSet)
    }

    // 设置全部数据
    const set = (sets: Set<T>) => {
      setSets(sets)
    }

    // 删除数据
    const remove = (key: T) => {
      const newSet = new Set(sets)
      newSet.delete(key)
      setSets(newSet)
    }

    // 清空数据
    const clear = () => {
      const newSet = new Set<T>()
      setSets(newSet)
    }

    // 重置为默认值
    const reset = () => {
      setSets(defaultValue)
    }

    return { add, set, remove, clear, reset }
  }, [defaultValue, sets])

  return [sets, actions] as const
}

export default useSet

useMap

import { useMemo, useState } from 'react'

// 定义操作方法的接口
interface Actions<T, U> {
  add: (key: T, value: U) => void // 新增一条数据
  get: (key: T) => U | undefined // 获取一条数据
  set: (map: Map<T, U>) => void // 设置全部数据
  remove: (key: T) => void // 删除某条数据
  clear: () => void // 清空数据
  reset: () => void // 重置为默认值
}

function useMap<T, U>(
  defaultValue: Map<T, U> = new Map(),
): [Map<T, U>, Actions<T, U>] {
  const [map, setMap] = useState<Map<T, U>>(defaultValue)

  // 缓存操作方法避免重复创建
  const actions = useMemo(() => {
    // 新增数据
    const add = (key: T, value: U) => {
      const newMap = new Map(map)
      newMap.set(key, value)
      setMap(newMap)
    }

    // 获取数据
    const get = (key: T) => {
      return map.get(key)
    }

    // 设置全部数据
    const set = (map: Map<T, U>) => {
      setMap(map)
    }

    // 删除数据
    const remove = (key: T) => {
      const newMap = new Map(map)
      newMap.delete(key)
      setMap(newMap)
    }

    // 清空数据
    const clear = () => {
      const newMap = new Map(map)
      newMap.clear()
      setMap(newMap)
    }

    // 重置为默认值
    const reset = () => {
      setMap(defaultValue)
    }

    return { add, get, set, remove, clear, reset }
  }, [defaultValue, map])

  return [map, actions] as const
}

export default useMap

usePrevious

import { useRef } from 'react'

type ShouldUpdateFn<T> = (prev: T | undefined, next: T) => boolean
const shouldUpdate = <T>(prev: T | undefined, next: T) => !Object.is(prev, next)
export default function usePrevious<T>(
  state: T,
  shouldUp: ShouldUpdateFn<T> = shouldUpdate,
): T | undefined {
  const curRef = useRef<T>() // 当前值
  const preRef = useRef<T>() // 上一个值

  if (shouldUp(curRef.current, state)) {
    preRef.current = curRef.current
    curRef.current = state
  }

  return preRef.current
}

useLatest

import { RefObject, useRef } from 'react'

// 获取某个state的最新值的hook
export default function useLatest<S>(value: S): RefObject<S> {
  // useRef 保存能保证每次获取到的都是最新的值
  const curRef = useRef<S>(value)
  curRef.current = value

  return curRef
}

useSafeState

import { useUnmount } from 'ahooks'
import { Dispatch, SetStateAction, useRef, useState } from 'react'

// 主要是实现 setRafState 方法,在外部调用 setRafState 方法时,会取消上一次的 setState 回调函数,并执行 requestAnimationFrame 来控制 setState 的执行时机
export default function useSafeState<S>(
  initialState: S | (() => S),
): [S, Dispatch<SetStateAction<S>>] {
  const [state, setState] = useState(initialState)
  const reqId = useRef(0)
  const setSafeState = (state: SetStateAction<S>) => {
    cancelAnimationFrame(reqId.current)

    reqId.current = requestAnimationFrame(() => {
      // 在回调执行真正的 setState
      setState(state)
    })
  }

  useUnmount(() => {
    cancelAnimationFrame(reqId.current)
  })

  return [state, setSafeState] as const
}

useSetState

import { getTypeOf } from '@/utils'
import { SetStateAction, useCallback, useRef, useState } from 'react'

// 类似于以前的setState,有一个回调函数,回调函数的参数是最新的值
export default function useSetState<S>(
  initialState: S | (() => S),
): [S, (state: SetStateAction<S>, cb?: (state: S) => void) => void] {
  const [state, set] = useState(initialState)
  const curRef = useRef<S>(state)
  curRef.current = state

  const setState = useCallback(
    (state: SetStateAction<S>, cb?: (state: S) => void) => {
      set((pre) => {
        const newState =
          getTypeOf(state) === 'Function'
            ? (state as (preState: S) => S)(pre)
            : (state as S)
        curRef.current = newState
        return newState
      })
      cb?.(curRef.current)
    },
    [],
  )

  return [state, setState] as const
}

useMergeStates

import { getTypeOf } from '@/utils'
import { Dispatch, SetStateAction, useCallback, useState } from 'react'

// 合并state属性,使用的时候不用整体重新赋值
export default function useMergeStates<S extends Record<string, any>>(
  initialState: S | (() => S),
): [S, Dispatch<SetStateAction<Partial<S>>>] {
  const [state, set] = useState(initialState)

  const setState = useCallback((patch: SetStateAction<Partial<S>>) => {
    set((pre) => {
      const newState =
        getTypeOf(patch) === 'Function'
          ? (patch as (preState: Partial<S>) => S)(pre)
          : (patch as S)

      return newState ? { ...pre, ...newState } : pre
    })
  }, [])

  return [state, setState] as const
}

useGetState

import { Dispatch, SetStateAction, useCallback, useRef, useState } from 'react'

// 在useState的基础上增加获取方法
export default function useGetState<S>(
  initialState: S | (() => S),
): [S, Dispatch<SetStateAction<S>>, () => S] {
  const [state, setState] = useState(initialState)
  const curRef = useRef(state)
  curRef.current = state

  const getState = useCallback(() => curRef.current, [])

  return [state, setState, getState] as const
}

useReactive

import { useRef } from 'react'
import useToggle from './useToggle'

// 响应式对象
export default function useReactive<S extends object>(initialState: S): S {
  const [, { toggle }] = useToggle(false)
  const curRef = useRef(initialState)

  const proxy = new Proxy(curRef.current, {
    set(target, key, value) {
      const ret = Reflect.set(target, key, value)
      toggle() // 属性赋值时触发回调
      return ret
    },
    get(target, key) {
      const ret = Reflect.get(target, key)
      return ret
    },
    defineProperty(target, key) {
      const ret = Reflect.deleteProperty(target, key)
      toggle() // 属性删除时触发回调
      return ret
    },
  })

  return proxy
}

useOptions

import { DefaultOptionType } from 'antd/lib/select'
import { useEffect, useState } from 'react'

// 账号列表项
export type UseOptionsProps<T = DefaultOptionType, R = any> = {
  labelField?: keyof R // 要取的作为label的字段
  labelFormat?: (record: R) => any // label字段自定义格式化函数
  valueField?: keyof R // 要取的作为value的字段
  valueFormat?: (record: R) => any // value字段自定义格式化函数
  resField?: string[] // 要取的返回值字段(比如res、list、resutl等)
  dataFn: (...args: any) => Promise<any> // 要请求的接口
  fnParams?: any // 请求的接口参数
  definedFormat?: (data: R[]) => T[] // 自定义格式化数据函数
}

/**
 * @desc 自定义获取任意接口并格式化为下拉options的hook
 * @param  T 最终返回的数据的类型
 * @param  R 接口返回的数据的类型
 */
function useOptions<T = DefaultOptionType, R = any>(
  props: UseOptionsProps<T, R>,
): [T[], R[], () => void] {
  const {
    labelField,
    valueField,
    labelFormat,
    valueFormat,
    fnParams,
    dataFn,
    resField,
    definedFormat,
  } = props || {}
  const [options, setOptions] = useState<T[]>([]) // 下拉选项
  const [datas, setDatas] = useState<R[]>([]) // 原始数据

  /** 格式化为通用下拉选项结构 */
  const formatToCommonOptions = (list: R[]): T[] => {
    return list?.map((item: R) => {
      const label = labelFormat
        ? labelFormat?.(item)
        : labelField
        ? item?.[labelField]
        : item?.['name' as keyof R] || '' // label取值
      const value = valueFormat
        ? valueFormat?.(item)
        : valueField
        ? item?.[valueField]
        : item?.['id' as keyof R] || '' // value取值

      return { label, value } as T
    })
  }

  // 获取数据
  const getDatas = async () => {
    try {
      const res = fnParams ? await dataFn(fnParams) : await dataFn()

      const list: R[] =
        resField && resField?.length
          ? resField.reduce((pre, cur) => pre?.[cur], res)
          : res
      if (list && Array.isArray(list)) {
        setDatas(list) // 保存原始数据
        setOptions(
          definedFormat ? definedFormat(list) : formatToCommonOptions(list),
        ) // 保存格式化数据
      }
    } catch {
      //
    }
  }

  useEffect(() => {
    getDatas()
  }, [])

  return [options, datas, getDatas]
}

export default useOptions

useSse

import { useEffect, useRef, useState } from 'react'

// 定时器的类型
type TimerType = ReturnType<typeof setTimeout> | undefined

/**
 * @description SSE暴露的方法
 */
export interface SSEMethods {
  close: () => void
  reconnect: () => void
}

/**
 * SSE连接状态枚举
 */
export enum SSEReadyState {
  CONNECTING, // 连接中
  OPEN, // 已连接
  CLOSED, // 已关闭
}

/**
 * @description 初始化参数
 */
export type EventSourceInit = {
  withCredentials?: boolean
  headers?: Record<string, string>
  payload?: string
}

/**
 * @description SSE连接需要的参数
 * @param T 当前SSE连接的数据结构
 */
export interface SSEProps {
  events?: string[] // 事件名称
  retryInterval?: number // 重试间隔
  heartbeatTimeout?: number // 心跳超时时间
  init?: EventSourceInit // 初始化参数
  onMessage?: (event: MessageEvent) => void // 消息回调
  autoConnect?: boolean // 是否自动连接
  maxRetries?: number // 最大重试次数
}

/**
 * @description SSE连接返回结构
 * @param T 当前SSE连接的数据结构
 */
export interface SSEReturn<T> {
  data: T | null // 当前SSE连接的数据结构
  error: Error | null // 错误
  readyState: SSEReadyState // 连接状态
  methods: SSEMethods // 暴露的方法
  retryCount: number // 当前重试次数
}

/**
 * @description SSE连接状态枚举
 */
export const READY_STATE_MAP = {
  [SSEReadyState.CONNECTING]: '连接中...',
  [SSEReadyState.OPEN]: '已连接',
  [SSEReadyState.CLOSED]: '已关闭',
}

/**
 * @description SSE连接状态枚举
 */
export const READY_STATE_TYPE_MAP = {
  [SSEReadyState.CONNECTING]: 'warning',
  [SSEReadyState.OPEN]: 'success',
  [SSEReadyState.CLOSED]: 'error',
}

/**
 * @description 通用的SSE连接HOOK
 * @param {SSEProps} 连接参数
 */
// 连接地址
const useSse = <T,>(url: string, options: SSEProps): SSEReturn<T> => {
  const {
    autoConnect,
    maxRetries,
    retryInterval = 5 * 1000,
    heartbeatTimeout,
    init,
    events,
    onMessage,
  } = options || {}

  const [readyState, setReadyState] = useState<SSEReadyState>(
    SSEReadyState.CONNECTING,
  ) // 当前连接状态
  const [data, setData] = useState<T | null>(null) // 当前SSE数据
  const [error, setError] = useState<Error | null>(null) // 错误
  const sseRef = useRef<EventSource | null>(null) // SSE实例
  const retryTimer = useRef<TimerType | null>(null) // 重试的定时器
  const heartbeatTimer = useRef<TimerType | null>(null) // 心跳的定时器
  const retryCount = useRef<number>(0) // 当前重试次数

  // 初始化SSE
  const initSSE = () => {
    try {
      const sseInstance = new EventSource(url, init)
      sseRef.current = sseInstance

      // sse连接打开事件
      sseInstance.onopen = () => {
        console.log('SSE连接已打开')
        setReadyState(SSEReadyState.OPEN)
        retryCount.current = 0 // 重置重试次数
        startHeatbeat() // 开始心跳
        bindEvents()
      }

      sseInstance.onerror = (e) => {
        setError(new Error(`SSE Error: ${e.type}`))
        setReadyState(SSEReadyState.CLOSED)
        if (autoConnect !== false) reconnect()
      }

      sseInstance.onmessage = handleMessage
    } catch (error) {
      setError(error as Error)
      setReadyState(SSEReadyState.CLOSED)
      if (autoConnect !== false) reconnect()
    }
  }

  // 消息处理事件
  const handleMessage = (event: MessageEvent) => {
    const parsedData = JSON.parse(event.data) as T
    setData(parsedData)
    onMessage?.(event)
  }

  // 重置重试的定时器
  const resetRetryTimer = () => {
    if (retryTimer.current) {
      clearTimeout(retryTimer.current)
      retryTimer.current = null
    }
  }

  // 重置心跳的定时器
  const resetHeartbeatTimer = () => {
    if (heartbeatTimer.current) {
      clearTimeout(heartbeatTimer.current)
      heartbeatTimer.current = null
    }
  }

  // 重连SSE
  const reconnect = () => {
    // 超过最大重试次数就不重试了
    if (maxRetries && retryCount.current >= maxRetries) {
      setReadyState(SSEReadyState.CLOSED)
      return
    }

    resetRetryTimer()
    resetHeartbeatTimer()

    retryTimer.current = setTimeout(() => {
      setReadyState(SSEReadyState.CONNECTING)
      retryCount.current += 1 // 重试次数+1
      initSSE()
    }, retryInterval * (retryCount.current + 1))
  }

  // 开始心跳
  const startHeatbeat = () => {
    if (heartbeatTimeout) {
      resetHeartbeatTimer()
      heartbeatTimer.current = setTimeout(() => {
        setReadyState(SSEReadyState.CLOSED)
        disconnectSSE()
        initSSE()
      }, heartbeatTimeout)
    }
  }

  // 绑定自定义事件
  const bindEvents = () => {
    if (sseRef && events?.length) {
      events?.forEach((eventName) => {
        ;(sseRef as unknown as EventSource).addEventListener(
          eventName,
          handleMessage,
        )
      })
    }
  }

  // 解绑自定义事件
  const unbindEvents = () => {
    if (sseRef && events?.length) {
      events?.forEach((eventName) => {
        ;(sseRef as unknown as EventSource).removeEventListener(
          eventName,
          handleMessage,
        )
      })
    }
  }

  useEffect(() => {
    // 如果是自动连接
    if (autoConnect !== false) {
      initSSE()
    }
    return close
  }, [])

  // 断开SSE连接
  const disconnectSSE = () => {
    const sseInstance = sseRef.current
    if (sseInstance) {
      sseInstance.close()
      sseRef.current = null
    }
  }

  // 关闭
  const close = () => {
    disconnectSSE()
    setReadyState(SSEReadyState.CLOSED)
    resetRetryTimer()
    resetHeartbeatTimer()
    retryCount.current = 0
    setData(null)
    unbindEvents()
  }

  const methods: SSEMethods = {
    close,
    reconnect: () => {
      close()
      initSSE()
    },
  }

  return { data, readyState, retryCount: retryCount.current, error, methods }
}

export default useSse

useWebSocket

import { HeartBeatType } from '@/enums/common'
import { useCallback, useMemo, useRef, useState } from 'react'

// 心跳类型枚举
enum HeartBeatType {
  Ping = 'heartBeatPing', // 心跳发送的消息
  Pong = 'heartBeatPong', // 心跳接收的消息
}

type TimerType = ReturnType<typeof setTimeout> | undefined // 定时器的类型

// useWebSocket的参数类型
export interface IWebSocketProps {
  url?: string // socket连接地址
  heartBeatInterval?: number // 心跳检测间隔
  heartBeatData?: HeartBeatType // 心跳检测发送数据
  heartBeatSendData?: any // 心跳检测实际发送数据
  maxReconnectAttempts?: number // 最大重连次数
  reconnectInterval?: number // 重连间隔
}

// 发送数据的类型
export type SendData = string | ArrayBufferLike | Blob | ArrayBufferView

/**
 * @description WebSocket hook封装
 */
export default function useWebSocket(options: IWebSocketProps) {
  const {
    url,
    heartBeatInterval = 60 * 1000,
    heartBeatData = HeartBeatType.Ping,
    heartBeatSendData,
    maxReconnectAttempts = 10,
    reconnectInterval = 5 * 1000,
  } = options

  const socket = useRef<WebSocket>() // socket实例
  const [lastMessage, setLastMessage] = useState<MessageEvent>() // socket消息
  const reconnectAttempts = useRef<number>(0) // 已经尝试重连了的次数
  const error = useRef<Event>() // error
  const curUrlRef = useRef<string>() // curUrlRef

  let heartBeatTimer: TimerType = undefined // 心跳检测的定时器
  let reconnectTimer: TimerType = undefined // 重连的定时器

  // 是否已连接
  const isConnected = useMemo(
    () => socket.current?.readyState === WebSocket.OPEN,
    [socket],
  )

  // 连接
  const connect = useCallback(
    (curUrl: string) => {
      // 如果已存在 WebSocket 实例,则先关闭它
      if (socket) {
        socket.current?.close()
      }

      if (curUrl && !curUrlRef.current) {
        curUrlRef.current = curUrl
      }

      const socketInstance = new WebSocket(curUrl || url!)
      socket.current = socketInstance
      socketInstance.onopen = onopen
      socketInstance.onmessage = onmessage
      socketInstance.onclose = onclose
      socketInstance.onerror = onerror
    },
    [url],
  )

  // 接收消息
  const onmessage = (data: any) => {
    setLastMessage(data)
  }

  // 连接成功
  const onopen = () => {
    console.log('连接成功')
    // 重置重连次数
    reconnectAttempts.current = 0
    // 开始心跳检测
    checkHealthStart()
  }

  // 连接断开
  const onclose = () => {
    console.log('连接断开')
    // 结束心跳检测
    checkHealthEnd()
  }

  // 连接出错
  const onerror = (e: Event) => {
    console.log('连接出错')
    error.current = e
    // 结束心跳检测
    checkHealthEnd()
    // 重连
    reconnect()
  }

  // 断开连接
  const disconnect = () => {
    console.log('连接断开')
    socket.current?.close()
    // 结束心跳检测
    checkHealthEnd()
  }

  // 发送消息
  const sendMessage = (data: SendData) => {
    console.log('即将发送的数据', data)
    if (socket.current?.readyState === WebSocket.OPEN) {
      console.log('真实发送的数据', data)
      socket.current?.send(data)
    }
  }

  // 心跳检测开始
  const checkHealthStart = () => {
    console.log('心跳检测开始')
    checkHealthEnd()
    heartBeatTimer = setTimeout(() => {
      if (socket.current?.readyState === WebSocket.OPEN) {
        sendMessage(
          heartBeatSendData ||
            JSON.stringify({
              type: 'ping',
              data: heartBeatData,
            }),
        )
        checkHealthStart()
      }
    }, heartBeatInterval)
  }

  // 心跳检测结束
  const checkHealthEnd = () => {
    console.log('心跳检测结束')
    clearTimeout(heartBeatTimer)
    heartBeatTimer = undefined
  }

  // 重连
  const reconnect = () => {
    console.log('开始重连')

    // 没有连接并且没有达到重连次数
    if (
      socket.current?.readyState !== WebSocket.OPEN &&
      reconnectAttempts.current <= maxReconnectAttempts
    ) {
      // 使用递增的延迟来避免频繁重连
      reconnectTimer = setTimeout(() => {
        connect(curUrlRef.current || url!)
      }, reconnectInterval * reconnectAttempts.current + 1)
      // 增加重连尝试次数
      reconnectAttempts.current += 1
    } else {
      clearTimeout(reconnectTimer)
      reconnectTimer = undefined
    }
  }

  // 取消重连
  const cancelReconnect = () => {
    clearTimeout(reconnectTimer)
    reconnectTimer = undefined
  }

  return {
    socket,
    lastMessage,
    isConnected,
    error,
    connect,
    disconnect,
    reconnect,
    checkHealthStart,
    sendMessage,
    cancelReconnect,
  }
}

useLocalforage

import localforage from 'localforage'

/**
 * @desc localForage 是一个 JavaScript 库,通过简单类似 localStorage API 的异步存储来改进你的 Web 应用程序的离线体验。它能存储多种类型的数据,而不仅仅是字符串。
  localForage 有一个优雅降级策略,若浏览器不支持 IndexedDB 或 WebSQL,则使用 localStorage。在所有主流浏览器中都可用:Chrome,Firefox,IE 和 Safari(包括 Safari Mobile)。
  localForage 提供回调 API 同时也支持 ES6 Promises API,你可以自行选择。
  * @return getItem 获取某个key的值
  * @return setItem 设置某个key的值
  * @return removeItem 移除某个key的值
  * @return clearItems 移除所有key的值,此方法将会删除离线仓库中的所有值。谨慎使用此方法。
  * @return itemsLength 获取离线仓库中的 key 的数量(即数据仓库的“长度”)
  * @return key 根据 key 的索引获取其名
  * @return keys 获取数据仓库中所有的 key,包含所有 key 名的数组
 */
const useLocalforage = <T = any>(): [
  getItem: (key: string) => Promise<T | null>, // 获取某个key的值
  setItem: (key: string, value: T) => Promise<T>, // 设置某个key的值
  removeItem: (key: string) => Promise<boolean>, // 移除某个key的值
  clearItems: () => Promise<boolean>, // 移除所有key的值,此方法将会删除离线仓库中的所有值。谨慎使用此方法。
  itemsLength: () => Promise<number>, // 获取离线仓库中的 key 的数量(即数据仓库的“长度”)
  key: (index: number) => Promise<string | null>, // 根据 key 的索引获取其名
  keys: () => Promise<string[]>, // 获取数据仓库中所有的 key,包含所有 key 名的数组
] => {
  /**
   * @desc 获取某个key的值
   * @param {string} key 要获取的key
   * @return {Promise<T | null>} 返回一个Promise,值的类型是T或者是null
   */
  const getItem = (key: string): Promise<T | null> => {
    return localforage
      .getItem<T>(key)
      .then((value) => Promise.resolve(value))
      .catch((err) => Promise.reject(err))
  }

  /**
   * @desc 设置某个key的值
   * @param {string} key 要设置的key
   * @param {T} value 要设置的key的值
   * @return {Promise<T>} 返回一个Promise,值的类型是T
   */
  const setItem = (key: string, value: T): Promise<T> => {
    return localforage
      .setItem<T>(key, value)
      .then((value) => Promise.resolve(value))
      .catch((err) => Promise.reject(err))
  }

  /**
   * @desc 从离线仓库中删除 key 对应的值
   * @param {string} key 要删除的key
   * @return {Promise<Boolean>} 返回一个Promise,值的类型是Boolean,为了保证不阻塞代码执行,移除时报错会返回Promise<false>,各位大佬可以根据返回值来判断是否移除成功
   */
  const removeItem = (key: string): Promise<boolean> => {
    return localforage
      .removeItem(key)
      .then(() => Promise.resolve(true))
      .catch(() => Promise.resolve(false))
  }

  /**
   * @desc 从数据库中删除所有的 key,重置数据库.将会删除离线仓库中的所有值。谨慎使用此方法
   * @return {Promise<Boolean>} 返回一个Promise,值的类型是Boolean,为了保证不阻塞代码执行,移除时报错会返回Promise<false>,各位大佬可以根据返回值来判断是否移除成功
   */
  const clearItems = (): Promise<boolean> => {
    return localforage
      .clear()
      .then(() => Promise.resolve(true))
      .catch(() => Promise.resolve(false))
  }

  /**
   * @desc 获取离线仓库中的 key 的数量(即数据仓库的“长度”)
   * @return {Promise<number>} 返回一个Promise,值的类型是number
   */
  const itemsLength = (): Promise<number> => {
    return localforage
      .length()
      .then((numberOfKeys) => Promise.resolve(numberOfKeys))
      .catch((err) => Promise.reject(err))
  }

  /**
   * @desc 根据 key 的索引获取其名
   * @param {number} index 要删除的key
   * @return {Promise<string>} 返回一个Promise,key名,值的类型是string
   */
  const key = (index: number): Promise<string | null> => {
    return localforage
      .key(index)
      .then((keyName) => Promise.resolve(keyName))
      .catch((err) => Promise.reject(err))
  }

  /**
   * @desc 获取数据仓库中所有的key名数组
   * @return {Promise<string[]>} 返回一个Promise,key名数组,值的类型是string[]
   */
  const keys = (): Promise<string[]> => {
    return localforage
      .keys()
      .then((keys) => Promise.resolve(keys))
      .catch((err) => Promise.reject(err))
  }

  return [getItem, setItem, removeItem, clearItems, itemsLength, key, keys]
}

export default useLocalforage

LogicFlow 交互新体验:让锚点"活"起来,鼠标跟随动效实战!🧲

作者 橙某人
2025年12月29日 18:25

写在开头

Hey Juejin.cn community! 😀

今是2025年12月28日,距离上一次写文章,已经过去了近两个月的时间。这段时间公司业务实在繁忙,两个月十个周末里有四个都贡献给了加班,就连平日里的工作日也被紧凑的任务填满,忙得几乎脚不沾地。😵

好在一番埋头苦干后,总算能稍稍喘口气了。昨天,小编去爬了广州的南香山🌄,本以为是一座平平无奇的"小山"(低于500米海拔的山,小编基本能无压力速通,嘿嘿),想不到还有惊喜,上山的路是规整的盘山公路,沿着公路一路向上,大半个小时就登顶了;下山时,我们选了一条更野趣的原始小径,有密林、有陡坡,走起来比公路有意思多了,当然,这条路线是有前人走过的,我们跟着网友分享的轨迹,再对照着树上绑着的小红带指路,一路有惊无险地顺利下了山。💯 难受的是,我们得打车回山的另一边拿车😅,但整体来说,这次爬山的体验整体很愉快~

ad2235428be6eb20735aae76471b9532.jpgca17f1c56d240ea424322b74dadcd4b0.jpg

言归正传,最近基于 LogicFlow 开发流程图功能时,做了个自定义锚点的 "吸附" 效果:鼠标靠近节点时,锚点会自动弹出并灵动跟随鼠标移动,这个小效果挺有趣的,分享给大家参考,效果如下,请诸君按需食用哈。

122901.gif

需求背景

LogicFlow 中,锚点是静态的,固定在节点的上下左右四个位置上,这就导致了两个问题:

  1. 视觉干扰:如果一直显示锚点,画面会显得很乱。
  2. 交互困难:用户必须精确点击到锚点才能开始连线,容错率低。

其实...就是产品经理要求要炫酷一点😣,要我说静态的挺好,直观简单。

我们想要的效果是:

  • 平时隐藏锚点,保持界面整洁。
  • 鼠标移入节点区域时,显示锚点。
  • 重点来了🎯:当鼠标在节点附近移动时,锚点应该像有磁力一样,自动吸附到离鼠标最近的位置(或者跟随鼠标在一定范围内移动),让连线变得随手可得。

具体实现

要实现这个功能,我们需要深入 LogicFlow 的自定义机制

这次主要围绕到两个文件:

  • customNode.js: 自定义节点,用于集成我们写好的超级锚点。
  • customAnchor.js: 核心逻辑,实现锚点的渲染和鼠标跟随逻辑。

第1️⃣步:自定义锚点组件

首先,我们需要创建一个自定义的锚点渲染函数。这个函数会返回一个 SVG 元素(这里用 LogicFlow内置的 h 函数来创建),并且包含复杂的交互逻辑。

为什么要返回一个 SVG 元素?

这得说到 LogicFlow 的底层技术选型问题了,可以看看这篇文章:传送门

它的核心思想是:创建一个较大的透明容器(container),用来捕获鼠标事件。在这个容器内,我们放一个"小球"(ballGroup),这个小球就是我们看到的锚点。

// customAnchor.js
import { h } from "@logicflow/core";

// 定义一些常量,方便调整手感
const CONTAINER_WIDTH = 72;  // 感应区域宽度
const CONTAINER_HEIGHT = 80; // 感应区域高度
const BALL_SIZE = 20;        // 锚点小球大小

/**
 * @name 创建复杂动效锚点
 * @param {Object} params 参数对象
 * @returns {any} LogicFlow 可用的锚点渲染形状
 */
export function createCustomAnchor(params) {
  const { x, y, side, id, nodeModel, graphModel } = params || {};
  
  // 依据左右两侧计算容器左上角 (小编的业务中最多仅只有左右两个锚点)
  const halfW = CONTAINER_WIDTH / 2;
  // 如果是左侧锚点,容器应该往左偏;右侧同理(可根据自己需求调整,小编的业务是同一时间仅需展示一边的锚点即可)
  const offsetX = side === "left" ? -halfW : halfW;
  
  // 计算透明容器在画布上的绝对坐标
  const containerX = x + offsetX - CONTAINER_WIDTH / 2;
  const containerY = y - CONTAINER_HEIGHT / 2;

  // DOM 引用,用于后续直接操作 DOM 提升性能
  let containerRef = null;
  let ballGroupRef = null;

  // 核心逻辑:鼠标移动时,更新小球的位置
  function handleMouseMove(ev) {
    if (!containerRef || !ballGroupRef) return;
    
    // 获取容器相对于视口的位置
    const rect = containerRef.getBoundingClientRect();
    
    // 获取鼠标在画布上的位置(这里需要处理一下浏览器兼容性,简单起见用 clientX/Y)
    const clientX = ev.clientX;
    const clientY = ev.clientY;
    
    // 计算鼠标相对于容器左上角的偏移量
    let relX = clientX - rect.left; 
    let relY = clientY - rect.top;
    
    // 关键点:限制小球在容器内移动,防止跑出感应区
    relX = Math.max(0, Math.min(CONTAINER_WIDTH, relX));
    relY = Math.max(0, Math.min(CONTAINER_HEIGHT, relY));
    
    // 使用 setAttribute 直接更新 transform,性能最好
    ballGroupRef.setAttribute("transform", `translate(${containerX + relX}, ${containerY + relY})`);
  }
  
  // 鼠标移入:变色 + 激活动画
  function handleMouseEnter() {
    if (!ballGroupRef) return;
    ballGroupRef.style.transition = "transform 140ms ease";
    // 这里可以改变颜色,例如 ballGroupRef.style.color = 'red';
  }

  // 鼠标移出:复位
  function handleMouseLeave() {
    if (!ballGroupRef) return;
    // 鼠标离开时,平滑回到容器中心
    ballGroupRef.style.transition = "transform 160ms ease, opacity 320ms ease";
    
    // 计算中心位置
    const centerX = containerX + CONTAINER_WIDTH / 2;
    const centerY = containerY + CONTAINER_HEIGHT / 2;
    ballGroupRef.setAttribute("transform", `translate(${centerX}, ${centerY})`);
  }

  return h("g", {}, [
    // 1. 透明容器:用于扩大感应区域,这就是“吸附”的秘密
    h("rect", {
      x: containerX,
      y: containerY,
      width: CONTAINER_WIDTH,
      height: CONTAINER_HEIGHT,
      fill: "transparent", // 必须是透明但存在的
      cursor: "crosshair",
      onMouseEnter: handleMouseEnter,
      onMouseMove: handleMouseMove, // 绑定移动事件
      onMouseLeave: handleMouseLeave,
      // ... 绑定其他事件 ...
    }),
    
    // 2. 实际显示的锚点(小球)
    h("g", {
        // 初始位置居中
        transform: `translate(${containerX + CONTAINER_WIDTH / 2}, ${containerY + CONTAINER_HEIGHT / 2})`,
        "pointer-events": "none", // 让鼠标事件穿透到下方的 rect 上
        ref: (el) => { ballGroupRef = el; }
      },
      [ 
        // 这里画一个圆形和一个加号
        h("circle", { r: BALL_SIZE / 2, stroke: "currentColor", fill: "none" }),
        h("path", { d: "M-5 0 L5 0 M0 -5 L0 5", stroke: "currentColor" })
      ]
    ),
  ]);
}

这里有个小技巧⏰:我们并没有直接改变 SVG 的 cx/cy,而是通过 transform: translate(...) 来移动整个锚点组,这样性能更好,动画也更流畅。同时,pointer-events: none 确保了鼠标事件始终由底层的透明 rect 触发,避免闪烁。

第2️⃣步:在自定义节点中使用

写好了锚点逻辑,接下来要在节点中用起来,咱们需要在自定义节点类中重写 getAnchorShape 方法。

// customNode.js
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
import { createCustomAnchor } from "./customAnchor";

// 定义节点 View
class CustomNodeView extends HtmlNode {
  /**
   * @name 自定义节点锚点形状
   * @param {object} anchorData 锚点数据
   * @returns {object} 锚点形状对象
   */
  getAnchorShape(anchorData) {
    const { x, y, name, id } = anchorData;
    
    // 简单的业务逻辑:只显示左右两侧的锚点
    const side = name === "left" ? "left" : "right";
    
    // 调用我们刚才写的神器!传入必要的参数
    return createCustomAnchor({
      x,
      y,
      side,
      id,
      nodeModel: this.props.model,
      graphModel: this.props.graphModel,
    });
  }
}

// 定义节点 Model
class CustomNodeModel extends HtmlNodeModel {
  // 定义锚点位置
  getDefaultAnchor() {
    const { id, width, x, y } = this;
    return [
      { x: x - width / 2, y, name: "left", id: `${id}-L` },
      { x: x + width / 2, y, name: "right", id: `${id}-R` },
    ];
  }
}

export default {
  type: "custom-node",
  view: CustomNodeView,
  model: CustomNodeModel,
};

第3️⃣步:记录拖拽状态

在实现“手动连线”之前,我们面临一个关键问题:当我们在目标节点的锚点上松开鼠标时,我们怎么知道连线是从哪里发起的

LogicFlow 的默认行为中,customAnchor 并不知道当前的拖拽上下文。因此,我们需要借助全局状态管理(小编用的是Vue3,所以使用Pinia做的全局数据共享)和 LogicFlow 的事件系统来 "搭桥"。

1. 定义 Store

我们需要一个地方存放“当前正在拖拽的锚点信息”。

// stores/logicFlow.js
import { defineStore } from "pinia";

export const useLogicFlowStore = defineStore("logicFlow", {
  state: () => ({
    draggingInfo: null, // 存储拖拽中的连线信息
    isManualConnected: false, // 标记是否触发了手动连接
  }),
});

2. 监听 LogicFlow 事件

在 LogicFlow 初始化的地方,我们需要监听 anchor:dragstartanchor:dragend 事件,实时更新 Store。

import { useLogicFlowStore } from "@/stores/logicFlow";

export function initEvents(lf) {
  const store = useLogicFlowStore();

  // 锚点开始拖拽:记录源节点和源锚点信息
  lf.on("anchor:dragstart", (data) => {
    store.draggingInfo = data; 
    store.isManualConnected = false;
  });

  // 锚点拖拽结束:清空信息
  lf.on("anchor:dragend", () => {
    store.draggingInfo = null;
  });
}

有了这个铺垫,咱们的自定义锚点就能知道 "谁在连我" 了!😎

第4️⃣步:手动连线逻辑

你可能注意到了,锚点位置是“动”的,但 LogicFlow 的连线计算通常基于固定的锚点坐标。如果我们不做处理,可能会出现连线连不上的情况。(其实肯定是连不上的😂)

所以,我们需要在 handleMouseUp(鼠标抬起)时,手动帮 LogicFlow 建立连线。

// customAnchor.js
import { useLogicFlowStore } from "@/stores/logicFlow";

  // ... 在 createCustomAnchor 内部 ...

  function handleMouseUp() {
    const store = useLogicFlowStore();
    // 获取全局存储的拖拽信息
    const { draggingInfo } = store; 
    
    // 尝试手动建立连接
    if (draggingInfo && graphModel) {
      const sourceNode = draggingInfo.nodeModel;
      const sourceAnchor = draggingInfo.data;
      
      // 1. 基础校验:避免自连
      if (sourceAnchor.id === id) return;

      try {
        // 2. 构造边数据
        // 注意:这里我们把终点 (endPoint) 强制设为当前鼠标/锚点的视觉位置 {x, y}
        // 而不是节点原本定义的静态锚点位置
        const edgeData = {
            type: "bezier", // 贝塞尔曲线
            sourceNodeId: sourceNode.id,
            sourceAnchorId: sourceAnchor.id,
            targetNodeId: nodeModel.id,
            targetAnchorId: id,
            startPoint: { x: sourceAnchor.x, y: sourceAnchor.y },
            endPoint: { x, y }, // <--- ⏰关键!使用当前的动态坐标
        };

        // 3. 核心:手动调用 graphModel.addEdge 添加边
        graphModel.addEdge(edgeData);
      } catch (error) {
        console.error("手动连接失败", error);
      }
    }
  }

这样一来,当用户从一个节点拖拽连线到我们的动态锚点上松开鼠标时,就能精准地建立连接了!不管你的锚点 "跑" 到了哪里,连线都能准确追踪。🎯

总结

通过这次改造,咱们的流程图编辑体验得到了"质"的飞跃体验:

  1. 灵动:锚点不再是死板的钉子,而是会互动的精灵。👻
  2. 高效:增大了鼠标感应区域,用户连线更轻松,无需像素级瞄准。
  3. 美观:平时隐藏,用时显现,保持了画布的整洁。

希望这个方案能给正在使用 LogicFlow 的小伙伴们一些灵感吧!💡





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

Electron 瘦身记:我是如何把安装后 900MB 的"巨无霸"砍到 466MB 的?

作者 mCell
2025年12月29日 17:46

同步至个人站点:Electron 瘦身记:我是如何把安装后 900MB 的"巨无霸"砍到 466MB 的?

089.webp

最近在参与一个 Electron 桌面端项目开发。作为 Electron 萌新,我原本以为“桌面端 = 写写前端 + 套个壳”,结果真正折磨人的,反而是工程化那一坨:打包构建、签名、公证、发布更新、更新测试……坑点密度高到离谱。

先吐槽两句:

  • macOS 打包要走签名 + 公证(Notarization)流程,第一次搞的时候你会怀疑自己是不是在给苹果写论文。
  • 自动更新测试更难绷:为了测 Windows 的更新链路,你甚至得在 macOS 上开个 Windows 虚拟机……(是的,我真的干过)

但让我印象最深刻、也最有成就感的,是一次很“实在”的工作:构建产物体积优化

因为它直接影响三件事:

  1. 用户下载/安装体验(你不希望用户下载一个“3A 大作”)
  2. 启动速度(图标跳半天才开,真的很败好感)
  3. 发布流程成本(包越大,签名/公证/上传/分发越折磨)

这篇就记录一下:我怎么从 DMG 240MB、安装后 900MB+,做到 DMG 155MB、安装后 466MB

先看结果:体积变化

最初版本:

  • macOS dmg:240MB
  • 安装后:900MB+
  • 体验:启动慢,菜单栏图标疯狂弹跳,弹到我怀疑人生

优化后:

  • macOS-arm.dmg:155MB
  • 安装后:466MB
  • 体验:启动速度肉眼可见地正常了

第一性原则:别猜,先把包拆开看

我一开始犯的错就是“凭感觉优化”。后面发现这事必须回到最朴素的方法:

把构建产物展开/解压,看看到底是谁在占空间。

在 macOS 上你可以:

  • dmg 挂载后找到 .app
  • 右键 → 显示包内容 → Contents/Resources/
  • 常见结构里会有 app.asar / app.asar.unpacked

然后用最土但最有效的方式查体积:

du -sh "YourApp.app"
du -sh "YourApp.app/Contents/Resources"/*

当我第一次看到结果时,基本就破案了:

  • .map 有,但不是主犯
  • 真正离谱的是:node_modules(体积大得像是把整个开发环境一起打进去了)

第一刀:发布版本别带 SourceMap(别把源码线索塞给用户)

这是我第一次做 Electron 发布,发布第一个内部测试版时我居然没关 sourcemap。

同事一句话把我点醒:“你这包里怎么能看到源码痕迹?”

我去翻构建产物:好家伙,.map 真在里面。

虽然 sourcemap 通常不会占几百 MB,但它有两个问题:

  • 安全性 / 泄露风险
  • 它属于“你不该带”的东西(该清理就清理)

于是我先在构建侧关掉 sourcemap,并在打包规则里也顺手排除 .map(双保险)。

第二刀:依赖治理——“npm install xxx 一把梭”的历史债,要还

接下来就是 node_modules 瘦身。

我以前装依赖的习惯非常粗暴:npm install xxx,能跑就行;根本不在乎它应该在 dependencies 还是 devDependencies

这在 Web 项目里可能不致命,但在 Electron 打包里非常致命:你分错了依赖,构建产物就会帮你把一堆开发工具、类型、脚手架、lint、测试相关,全塞进最终 App。

我当时做了两件事:

  1. 清理 package.json 里压根没用的废弃包 (这种是纯收益,删就完事了)
  2. 重新整理 dependencies / devDependencies 我甚至请 Claude Code 帮我分了一遍(因为我当时真的没经验,靠自己很容易漏)

这一轮做完再构建:

  • 安装体积从 900MB → 600MB+

我当时很开心,但冷静想想:600MB 的桌面软件还是大得离谱。

所以继续拆包。

第三刀:node_modules 里全是“你根本不需要”的文件

当 node_modules 变小之后,我继续往里看,结果又发现一堆“脂肪”:

  • README.md / CHANGELOG.md / HISTORY.md / LICENSE
  • tests / __tests__ / examples / docs / coverage
  • .d.ts
  • .map
  • 甚至还有 *.ts / *.tsx 源码、配置文件(rollup/webpack/tsconfig)

这些对用户运行 App 来说几乎没价值。

形象点讲:这就像你买披萨,厨师把面粉袋子、烤箱说明书、甚至工作笔记也一起塞给你。

于是我遇到了关键问题:

怎么系统性剔除这些文件? 难道要打包前去 node_modules 手动删?(那也太原始了)

关键方案:electron-builder 的 files 规则,精准排除

我最终落在 electron-builderbuild.files 配置上:用 glob 模式明确告诉打包工具“哪些要、哪些不要”。

核心思路很简单:

  • 只打包你的 dist / dist-electron
  • 排除 .map
  • 排除所有 *.md / *.ts / *.tsx 等“开发者文件”
  • node_modules 里重点清理:文档、测试、类型、锁文件、工具脚本、隐藏文件等

这是我最终的配置:

"files": [
  "dist/**/*",
  "dist-electron/**/*",
  "!dist/**/*.map",
  "!dist-electron/**/*.map",

  "!**/*.{ts,tsx,md}",

  "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,HISTORY.md,CONTRIBUTING.md}",
  "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples,coverage,docs,doc}",
  "!**/node_modules/*.d.ts",
  "!**/node_modules/.bin",
  "!**/node_modules/*/*.md",
  "!**/node_modules/*/*.markdown",
  "!**/node_modules/*/LICENSE*",
  "!**/node_modules/*/{.github,.vscode,.idea}",
  "!**/node_modules/*/{rollup.config.js,webpack.config.js,tsconfig.json}",
  "!**/node_modules/*/.*",
  "!**/node_modules/**/*.map",
  "!**/node_modules/**/*.{spec,test}.{js,jsx,ts,tsx}",
  "!**/node_modules/*/{yarn.lock,package-lock.json,pnpm-lock.yaml}",

  "!**/node_modules/lucide-react/dist/*.{ts,tsx}",
  "!**/node_modules/@types"
]

配置完,我点击构建按钮。电脑开始发烫、风扇狂转,我开始祈祷不要出“运行时报错找不到某个文件”的地狱场景。

结果:

  • macos-arm.dmg: 155MB(原 240MB)
  • 安装后:466MB(原 900MB+)

体积几乎减半,而且功能没缺、启动也明显变快。

这类“删文件式瘦身”有风险吗?

有,但可控。

因为确实存在少数包会在运行时读取某些资源文件(甚至是你以为“文档/配置”的东西)。所以我的策略是:

  1. 先排除最通用的一批:md/tests/docs/types/map/lockfile
  2. 打完包之后做一轮完整回归(重点:启动、关键路径、更新链路)
  3. 如果某个依赖真的需要某类文件,再对它做“例外放行”(白名单)

这比“手动删 node_modules”靠谱太多了:可重复、可追踪、可回滚。

工程化不只是“能跑”,而是“交付可控”

回头看,我从“能打出来就行”的心态,变成了:

  • 我知道最终产物里有什么
  • 我知道哪些文件不该进去
  • 我知道体积怎么定位、怎么收敛、怎么验证

如果你也在做 Electron,建议你按顺序做这几件事(基本不会亏):

  1. 拆包看体积分布(别凭感觉)
  2. 关 sourcemap(安全 + 清爽)
  3. 清理无用依赖、分好 dev/prod(最稳最值)
  4. 用 files 精准剔除 node_modules 垃圾文件(体积大头就在这)
  5. 每次瘦身都配回归测试(尤其是更新链路)

附:我常用的排查命令

# 看体积分布
du -sh dist dist-electron node_modules
du -sh "YourApp.app/Contents/Resources"/*

# 查大体积 sourcemap
find . -name "*.map" -size +5M -print

(完)

依赖注入系统

作者 借个火er
2025年12月29日 17:34

NestJS 源码解析:依赖注入系统

深入 Injector 和 Container,揭秘 NestJS 的 IoC 实现。

依赖注入基础

NestJS 的依赖注入基于 TypeScript 装饰器和 reflect-metadata

@Injectable()
export class CatsService {
  constructor(private readonly logger: LoggerService) {}
}

编译后,TypeScript 会生成元数据,记录构造函数参数类型。

核心组件

1. InstanceWrapper

包装每个可注入的实例:

// packages/core/injector/instance-wrapper.ts
export class InstanceWrapper<T = any> {
  public readonly name: string;
  public readonly token: InjectionToken;
  public readonly metatype: Type<T>;
  public readonly scope?: Scope;

  private readonly values = new WeakMap<ContextId, InstancePerContext<T>>();

  // 获取实例
  get instance(): T {
    const instancePerContext = this.getInstanceByContextId(STATIC_CONTEXT);
    return instancePerContext.instance;
  }

  // 设置实例
  set instance(value: T) {
    const instancePerContext = this.getInstanceByContextId(STATIC_CONTEXT);
    instancePerContext.instance = value;
  }

  // 获取构造函数元数据
  public getCtorMetadata(): InstanceWrapper[] {
    return this.inject || [];
  }
}

2. Module

模块类,管理 providers、controllers、imports、exports:

// packages/core/injector/module.ts
export class Module {
  private readonly _providers = new Map<InjectionToken, InstanceWrapper<Injectable>>();
  private readonly _controllers = new Map<InjectionToken, InstanceWrapper<Controller>>();
  private readonly _imports = new Set<Module>();
  private readonly _exports = new Set<InjectionToken>();

  // 添加 Provider
  public addProvider(provider: Provider): string {
    if (this.isCustomProvider(provider)) {
      return this.addCustomProvider(provider);
    }

    const token = provider as Type<Injectable>;
    const wrapper = new InstanceWrapper({
      token,
      name: token.name,
      metatype: token,
      instance: null,
      isResolved: false,
      scope: getClassScope(token),
      host: this,
    });

    this._providers.set(token, wrapper);
    return token.name;
  }

  // 添加自定义 Provider
  private addCustomProvider(provider: Provider): string {
    if (this.isCustomClass(provider)) {
      return this.addCustomClass(provider as ClassProvider);
    }
    if (this.isCustomValue(provider)) {
      return this.addCustomValue(provider as ValueProvider);
    }
    if (this.isCustomFactory(provider)) {
      return this.addCustomFactory(provider as FactoryProvider);
    }
    if (this.isCustomUseExisting(provider)) {
      return this.addCustomUseExisting(provider as ExistingProvider);
    }
  }
}

3. Injector

依赖注入核心引擎:

// packages/core/injector/injector.ts
export class Injector {
  // 加载实例
  public async loadInstance<T>(
    wrapper: InstanceWrapper<T>,
    collection: Map<InjectionToken, InstanceWrapper>,
    moduleRef: Module,
    contextId = STATIC_CONTEXT,
    inquirer?: InstanceWrapper,
  ) {
    const { token } = wrapper;

    // 检查是否已解析
    const instanceHost = wrapper.getInstanceByContextId(contextId, inquirer?.id);
    if (instanceHost.isResolved) {
      return instanceHost.instance;
    }

    // 解析构造函数依赖
    const dependencies = await this.resolveConstructorParams<T>(
      wrapper,
      moduleRef,
      contextId,
      inquirer,
    );

    // 实例化
    const instance = await this.instantiateClass(
      dependencies,
      wrapper,
      wrapper.inject,
      contextId,
      inquirer,
    );

    // 注入属性依赖
    await this.loadPropertiesOnInstance(instance, wrapper, moduleRef, contextId);

    return instance;
  }

  // 解析构造函数参数
  public async resolveConstructorParams<T>(
    wrapper: InstanceWrapper<T>,
    moduleRef: Module,
    contextId: ContextId,
    inquirer?: InstanceWrapper,
  ): Promise<unknown[]> {
    // 获取依赖元数据
    const dependencies = this.getCtorDependencies(wrapper);

    // 并行解析所有依赖
    return Promise.all(
      dependencies.map(async (dependency, index) => {
        const { wrapper: instanceWrapper } = await this.lookupComponent(
          dependency,
          moduleRef,
          contextId,
          wrapper,
          index,
        );
        return instanceWrapper.instance;
      }),
    );
  }

  // 查找依赖
  public async lookupComponent(
    dependency: InjectorDependency,
    moduleRef: Module,
    contextId: ContextId,
    wrapper: InstanceWrapper,
    index: number,
  ) {
    // 1. 在当前模块查找
    const instanceWrapper = await this.lookupComponentInParentModules(
      dependency,
      moduleRef,
      contextId,
      wrapper,
      index,
    );

    if (instanceWrapper) {
      return { wrapper: instanceWrapper };
    }

    // 2. 在全局模块查找
    const globalWrapper = await this.lookupComponentInGlobalModules(
      dependency,
      contextId,
      wrapper,
    );

    if (globalWrapper) {
      return { wrapper: globalWrapper };
    }

    // 3. 未找到,抛出异常
    throw new UndefinedDependencyException(wrapper.name, dependency, index);
  }
}

依赖解析流程

@Injectable()
class CatsService {
  constructor(
    private logger: LoggerService,
    private config: ConfigService,
  ) {}
}

解析流程:
┌─────────────────────────────────────────────────────┐
│ 1. 获取构造函数参数类型                              │
│    Reflect.getMetadata('design:paramtypes', target) │
│    → [LoggerService, ConfigService]                 │
└─────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────┐
│ 2. 遍历每个依赖,调用 lookupComponent               │
│    → 在当前模块 providers 中查找                    │
│    → 在 imports 的模块 exports 中查找               │
│    → 在全局模块中查找                               │
└─────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────┐
│ 3. 递归解析依赖的依赖                               │
│    LoggerService 可能也有自己的依赖                 │
└─────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────┐
│ 4. 实例化                                           │
│    new CatsService(loggerInstance, configInstance)  │
└─────────────────────────────────────────────────────┘

Barrier 并行解析机制

源码中使用 Barrier 实现依赖的并行解析:

// packages/core/injector/injector.ts
const paramBarrier = new Barrier(dependencies.length);

const resolveParam = async (param: unknown, index: number) => {
  try {
    const paramWrapper = await this.resolveSingleParam<T>(/*...*/);
    
    // 等待所有依赖都解析完成
    await paramBarrier.signalAndWait();
    
    const paramWrapperWithInstance = await this.resolveComponentHost(/*...*/);
    return instanceHost?.instance;
  } catch (err) {
    paramBarrier.signal(); // 出错也要释放信号
    // ...
  }
};

const instances = await Promise.all(dependencies.map(resolveParam));

Barrier 确保所有依赖的 InstanceWrapper 都被解析后,才开始获取实例。这避免了依赖树静态性判断错误导致的 undefined 注入问题。

SettlementSignal 循环依赖检测

// packages/core/injector/injector.ts
if (instanceHost.isPending) {
  const settlementSignal = wrapper.settlementSignal;
  if (inquirer && settlementSignal?.isCycle(inquirer.id)) {
    throw new CircularDependencyException(`"${wrapper.name}"`);
  }
  return instanceHost.donePromise!.then((err?: unknown) => {
    if (err) throw err;
  });
}

SettlementSignal 追踪依赖解析链,当检测到循环时抛出 CircularDependencyException

作用域

NestJS 支持三种作用域:

export enum Scope {
  DEFAULT = 0,    // 单例
  TRANSIENT = 1,  // 每次注入创建新实例
  REQUEST = 2,    // 每个请求创建新实例
}

单例 (DEFAULT)

@Injectable()
export class CatsService {}

整个应用生命周期只有一个实例。

瞬态 (TRANSIENT)

@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService {}

每次注入都创建新实例:

// packages/core/injector/injector.ts
if (wrapper.scope === Scope.TRANSIENT) {
  return this.instantiateClass(
    dependencies,
    wrapper,
    wrapper.inject,
    contextId,
    inquirer,
  );
}

请求作用域 (REQUEST)

@Injectable({ scope: Scope.REQUEST })
export class RequestService {}

每个 HTTP 请求创建新实例:

// packages/core/router/router-execution-context.ts
if (wrapper.scope === Scope.REQUEST) {
  const contextId = createContextId();
  const instance = await this.injector.loadInstance(
    wrapper,
    collection,
    moduleRef,
    contextId,
  );
  return instance;
}

自定义 Provider

useClass

{
  provide: CatsService,
  useClass: MockCatsService,
}

useValue

{
  provide: 'CONFIG',
  useValue: { apiKey: 'xxx' },
}

useFactory

{
  provide: 'ASYNC_CONNECTION',
  useFactory: async (config: ConfigService) => {
    return await createConnection(config.get('database'));
  },
  inject: [ConfigService],
}

useExisting

{
  provide: 'AliasService',
  useExisting: CatsService,
}

循环依赖处理

NestJS 使用 forwardRef 处理循环依赖:

@Injectable()
export class CatsService {
  constructor(
    @Inject(forwardRef(() => DogsService))
    private dogsService: DogsService,
  ) {}
}

实现原理:

// packages/common/utils/forward-ref.util.ts
export const forwardRef = (fn: () => any): ForwardReference => ({
  forwardRef: fn,
});

// packages/core/injector/injector.ts
private resolveParamToken(param: any) {
  if (param && param.forwardRef) {
    return param.forwardRef();
  }
  return param;
}

属性注入

除了构造函数注入,还支持属性注入:

@Injectable()
export class CatsService {
  @Inject(LoggerService)
  private readonly logger: LoggerService;
}

实现:

// packages/core/injector/injector.ts
public async loadPropertiesOnInstance(
  instance: object,
  wrapper: InstanceWrapper,
  moduleRef: Module,
  contextId: ContextId,
) {
  const properties = wrapper.getPropertiesMetadata();

  for (const property of properties) {
    const { key, wrapper: propertyWrapper } = await this.lookupComponent(
      property.name,
      moduleRef,
      contextId,
      wrapper,
    );
    instance[key] = propertyWrapper.instance;
  }
}

总结

NestJS 依赖注入系统的核心:

  1. 元数据驱动:通过 reflect-metadata 获取类型信息
  2. InstanceWrapper:包装每个可注入实例
  3. Module:管理 providers、controllers、imports、exports
  4. Injector:递归解析依赖,实例化对象
  5. 作用域:支持单例、瞬态、请求三种作用域
  6. 循环依赖:通过 forwardRef 延迟解析

下一篇我们将分析模块系统的实现。


📦 源码位置:packages/core/injector/

下一篇:NestJS 模块系统

项目介绍与环境搭建

作者 借个火er
2025年12月29日 17:32

NestJS 源码解析:项目介绍与环境搭建

NestJS 是 Node.js 最流行的企业级框架,本系列将深入源码,揭秘其设计思想。

NestJS 是什么

NestJS 是一个用于构建高效、可扩展的 Node.js 服务端应用的框架。它使用 TypeScript 构建,融合了 OOP、FP、FRP 的编程范式。

核心特点:

  • 模块化架构:借鉴 Angular 的模块系统
  • 依赖注入:内置 IoC 容器
  • 装饰器驱动:大量使用 TypeScript 装饰器
  • 平台无关:支持 Express、Fastify 等底层框架

源码仓库

git clone https://github.com/nestjs/nest.git
cd nest
npm install
npm run build

当前版本:v11.1.10

项目结构

nest/
├── packages/
│   ├── common/           # 公共模块(装饰器、接口、工具)
│   ├── core/             # 核心模块(DI、路由、生命周期)
│   ├── microservices/    # 微服务支持
│   ├── platform-express/ # Express 适配器
│   ├── platform-fastify/ # Fastify 适配器
│   ├── testing/          # 测试工具
│   └── websockets/       # WebSocket 支持
│
├── integration/          # 集成测试
├── sample/               # 示例项目
└── tools/                # 构建工具

核心包说明

@nestjs/common

公共模块,包含:

  • 装饰器:@Module@Controller@Injectable@Get@Post
  • 接口:NestModuleCanActivatePipeTransform
  • 异常:HttpExceptionBadRequestException
  • 工具函数

@nestjs/core

核心运行时,包含:

  • NestFactory:应用工厂
  • NestContainer:IoC 容器
  • Injector:依赖注入器
  • DependenciesScanner:模块扫描器
  • RouterExplorer:路由探索器
  • 生命周期钩子

@nestjs/platform-express

Express 平台适配器,将 NestJS 的抽象映射到 Express。

核心概念

1. 模块 (Module)

模块是组织代码的基本单元:

@Module({
  imports: [DatabaseModule],
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}

2. 控制器 (Controller)

处理 HTTP 请求:

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Get()
  findAll(): Cat[] {
    return this.catsService.findAll();
  }
}

3. 提供者 (Provider)

可注入的服务:

@Injectable()
export class CatsService {
  private cats: Cat[] = [];

  findAll(): Cat[] {
    return this.cats;
  }
}

4. 中间件 (Middleware)

请求处理管道:

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log('Request...');
    next();
  }
}

5. 守卫 (Guard)

权限控制:

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

6. 管道 (Pipe)

数据转换和验证:

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

7. 拦截器 (Interceptor)

AOP 切面:

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...');
    return next.handle().pipe(tap(() => console.log('After...')));
  }
}

请求处理流程

Request
   ↓
Middleware
   ↓
Guards
   ↓
Interceptors (before)
   ↓
Pipes
   ↓
Route Handler
   ↓
Interceptors (after)
   ↓
Exception Filters
   ↓
Response

系列文章

本系列将深入分析 NestJS 源码:

  1. 项目介绍(本文)- 了解项目结构和核心概念
  2. 架构总览 - NestFactory 启动流程
  3. 依赖注入 - IoC 容器和 Injector 实现
  4. 模块系统 - Module 扫描和加载
  5. 装饰器原理 - 元数据存储机制
  6. 路由系统 - 请求分发和处理
  7. 中间件机制 - 中间件注册和执行
  8. AOP 实现 - Guard、Pipe、Interceptor

调试技巧

1. 使用示例项目

cd sample/01-cats-app
npm install
npm run start:dev

2. 断点调试

在 VS Code 中配置 launch.json:

{
  "type": "node",
  "request": "launch",
  "name": "Debug Nest",
  "runtimeArgs": ["-r", "ts-node/register"],
  "args": ["${workspaceFolder}/sample/01-cats-app/src/main.ts"]
}

3. 关键断点位置

场景 文件 函数
应用创建 core/nest-factory.ts create
模块扫描 core/scanner.ts scan
依赖注入 core/injector/injector.ts loadInstance
路由注册 core/router/router-explorer.ts explore

下一步

环境准备好了,下一篇我们将分析 NestJS 的启动流程,看看 NestFactory.create() 背后发生了什么。


📦 源码地址:github.com/nestjs/nest

下一篇:NestJS 架构总览

Uni-app 性能天坑:为什么 v-if 删不掉 DOM 节点

作者 heyCHEEMS
2025年12月29日 17:22

在开发自定义 Swiper 或长列表组件时,为了优化性能,我们通常会给每一项加上懒加载逻辑:

<view class="item">
  <template v-if="shouldRender">
    <slot :name="'slot-' + index" />
  </template>
</view>

神奇的事情发生了: 哪怕 shouldRenderfalse,打开小程序调试器一看,WXML 树里依然排满了 view slot="slot-0"view slot="slot-1"... 里面甚至还塞满了图片节点。

结论:你的 v-if 只是“隐藏”了视觉,内存和 DOM 压力一点没减。


二、 具名插槽是“物理坑位”

为什么 v-if 失效了?因为在微信小程序底层,具名插槽(Named Slots) 的实现逻辑是静态枚举

  1. 预编译挖坑:小程序原生不支持动态插槽名。Uni-app 编译器为了兼容 Vue,会根据你的循环逻辑,在 WXML 里预先写死所有的占位符(如 slot="d-0", slot="d-1")。
  2. 渲染权倒置:在具名插槽模式下,父组件拥有内容的实例化权。父组件会先把所有内容节点生成好,然后再“分发”给子组件。
  3. 隔离失败:子组件里的 v-if 只能决定子组件自己是否显示,但挡不住父组件已经产生的物理节点。这就好比虽然你把家门关了(v-if=false),但邻居已经把货卸在了你门口(DOM 占位)

三、 作用域插槽是“动态模版”

要实现真正的懒加载(随 v-if 销毁 DOM),必须重构为作用域插槽(Scoped Slots)

1. 子组件改造:变“多坑”为“单模板”

不要再给插槽起动态名字,统一使用带作用域的默认插槽或具名插槽。

<view v-for="(item, index) in list" :key="index">
  <template v-if="shouldRender(index)">
    <slot name="content" :item="item" />
  </template>
</view>

2. 父组件调用:禁止使用 v-for 填坑

这是最关键的——父组件不再负责循环产生节点,只提供渲染模板。

<Swiper :list="dataList">
  <template v-slot:content="{ item }">
    <image :src="item.url" />
  </template>
</Swiper>

四、 循环 slot 不会重名冲突吗?

很多同学看到这里会问: “子组件循环 20 次 slot name="content",在 WXML 里难道不会报 ID 冲突或渲染覆盖吗?”

真相是: 当你使用“作用域插槽”时,底层渲染逻辑发生了本质改变。

  • 具名插槽(旧) :编译器会生成 d-0, d-1... 等多个不同的物理坑位
  • 作用域插槽(新) :编译器会将插槽内容封装成一个微信小程序原生的 template。在 WXML 层面,子组件循环的是同一个“模板调用”,而不是多个“物理坑位”。

这就好比:具名插槽是盖好了 20 间空房子等分发;而作用域插槽是给了一张图纸,子组件循环 20 次,只有遇到 v-if="true" 时才照着图纸现盖一间房。既然是现盖,自然不存在重名抢坑的问题。


五、 为什么换成作用域插槽就生效了?

这是底层架构的质变:

  • 实例化时机改变:实例化内容的控制权移交给了子组件
  • 按需生成:只有子组件执行到 <slot /> 那一行代码时,父组件定义的“模板”才会动态转变为真正的 DOM。
  • 地基都没了:如果 v-if="false",父组件的内容在内存里连影子都不会出现。

六、 避坑小结

如果你在 Uni-app 开发者工具里发现 DOM 节点数不对劲,请检查以下两点:

  1. 是否在循环里用了动态具名插槽?:name="'xxx' + index"
  2. 父组件是否也写了 v-for 去填坑? 确保父组件只提供 <template v-slot:xxx> 声明。

❌
❌