首页
/ Electron ClipboardBookmark 深度解析:`electron application/bookmark` 剪贴簿格式的读写与源码实现

Electron ClipboardBookmark 深度解析:`electron application/bookmark` 剪贴簿格式的读写与源码实现

2026-09-06 19:08:57作者:乔或婵

本文以 Electron 官方文档中的 ClipboardBookmark 结构定义(docs/api/structures/clipboard-bookmark.md)为核心,讲解这个用于 electron application/bookmark 自定义剪贴格式的结构体:它由 titleurl 两个字段组成,作为 ClipboardItem 的 MIME 键值写入 clipboard.write(),并在 clipboard.read() 后通过 getType('electron application/bookmark') 解析回 { title, url } 对象。读完本文,你将掌握书签数据在 Electron 剪贴板上的完整读写流程、其在原生层(ui::Clipboard::WriteURL / ReadURL)的底层落地方式、旧版 readBookmark/writeBookmark API 的迁移路径,以及跨平台测试所揭示的实际行为差异。

一、ClipboardBookmark:结构与 MIME 定位

ClipboardBookmark 的定义非常精简,完整定义见 clipboard-bookmark.md

字段 类型 说明
title string 书签的标题
url string 书签的 URL

它服务于 Electron 的自定义剪贴格式 electron application/bookmark。按照文档描述,这个结构的“生命周期”由两端构成:

  1. 写入端:作为 clipboard.write() 的参数,以 ClipboardItemdata 记录的一个 MIME 键值出现——键为 'electron application/bookmark',值就是这个 { title, url } 对象;
  2. 读取端:通过 clipboard.read() 拿到 ClipboardItem 数组后,getType('electron application/bookmark') 解析(resolve)出的结果类型,而不是像其他 MIME 类型那样解析为 Blob

这是整个 Electron 剪贴板 API 中唯一一个解析结果为结构化对象而非 Blob 的 MIME 类型。原因在于:书签天然是“标题 + 链接”的结构化数据,直接暴露 { title, url } 对象比让使用者手动反序列化一段字节载荷更友好。

在 JavaScript 侧的 JS 门面类中,这个 MIME 常量被定义为:

// lib/browser/api/clipboard-item.ts
const BOOKMARK_MIME = 'electron application/bookmark';

并且 getType() 对该 MIME 做了特殊分流——命中时直接返回 ClipboardBookmark 对象,其余 MIME 一律包装成带 MIME 标签的 Blob(见 clipboard-item.ts):

async getType(type: string): Promise<Blob | Electron.ClipboardBookmark> {
  if (this.#native) {
    const payload = await this.#native.getType(type);
    if (type === BOOKMARK_MIME) return payload as Electron.ClipboardBookmark;
    return new Blob([payload as Blob], { type });
  }
  // ...构造侧同样对 BOOKMARK_MIME 直接返回对象
}

测试代码中对应的类型也直接使用了 Electron.ClipboardBookmark(见 api-clipboard-spec.ts),说明该结构在 TypeScript 类型定义中是 clipboard 模块的一等公民。

二、写入书签:作为 ClipboardItem 的 MIME 键值

ClipboardItem 的构造器接受 Record<string, string | ClipboardBookmark | Blob | Promise<Blob | string>>:任意 MIME 都可以接受 stringBlobstring 会被 UTF-8 编码为载荷字节),而 electron application/bookmark 是唯一例外——它只接受 ClipboardBookmark 对象。完整的写入示例(源自 clipboard-item.md 的官方示例):

// 每个 ClipboardItem 描述一条带一个或多个 MIME 表示的剪贴板条目。
// bookmark 自定义格式接受结构化的 { title, url } 对象,而不是 Blob。
const { clipboard, ClipboardItem, nativeImage } = require('electron')

const png = nativeImage.createFromPath('/path/to/icon.png').toPNG()

clipboard.write([
  new ClipboardItem({
    'text/plain': 'hello',
    'text/html': '<b>hello</b>',
    'image/png': new Blob([png], { type: 'image/png' }),
    'electron application/bookmark': {
      title: 'Electron',
      url: 'https://electronjs.org'
    }
  })
])

几个值得注意的行为细节:

  • 原子提交:同一次 clipboard.write() 调用中的全部 MIME 条目(包括书签)作为一个原子事务提交到系统剪贴板,要么全部成功要么全部失败,不会出现“文本已写入但书签丢失”的中间态。
  • 异步载荷:非书签载荷也可以是 Promise(resolve 为 Blobstring),在 clipboard.write() 调用时才被 await;书签载荷本身是同步对象,不走 Promise 路径。
  • 类型校验发生在原生构造器:JS 门面类在 clipboard-item.ts[kToNative]() 中把 Blob 解析为 Buffer 后交给原生 NativeClipboardItem 构造器,真正的“载荷与 MIME 是否匹配”校验由 C++ 侧完成,不匹配的载荷会让 clipboard.write() 的 Promise 以 TypeError reject(对应测试见 api-clipboard-spec.ts 的“rejects an invalid payload at write() time”用例)。

三、读取书签:getType 返回对象而非 Blob

读取侧的标准流程(示例来自 clipboard-item.mdclipboardItem.getType(bookmark) 一节):

const { clipboard } = require('electron')

async function dumpClipboard () {
  const bookmarkType = 'electron application/bookmark'
  const items = await clipboard.read()
  for (const item of items) {
    if (item.types.includes(bookmarkType)) {
      const bookmark = await item.getType(bookmarkType)
      console.log('Bookmark found: ', bookmark)
    } else {
      console.log('There is no bookmark present')
    }
  }
}

行为要点:

  • clipboard.read() 返回 Promise<ClipboardItem[]>,每个条目的 types 数组列出当前平台剪贴板可提供的 MIME 类型;书签是否存在于剪贴板,应先通过 item.types.includes('electron application/bookmark') 判断。
  • 当书签不存在时,getType(bookmarkType) 会 reject(错误信息为 The type '<mime>' was not found in the ClipboardItem),而非 resolve 空对象。
  • 与其他 MIME 的关键区别在于返回类型:getType('electron application/bookmark') 返回 Promise<ClipboardBookmark>,而 getType('text/html') 之类返回 Promise<Blob>。这一重载约定在 clipboard-item.mdclipboardItem.getType(type)clipboardItem.getType(bookmark) 两个小节中均有声明。

四、源码实现:从 JS 对象到平台剪贴簿 URL 格式

书签功能在原生层的实现集中在 electron_api_clipboard_item.ccelectron_api_clipboard_item.h,可以沿着“写入 → 提交”和“读取 → 解析”两条链路观察。

4.1 数据结构:BookmarkInfo

C++ 侧用如下结构承接 JS 的 { title, url } 对象(electron_api_clipboard_item.h):

// Structured payload for the `electron application/bookmark` MIME — a
// pre-parsed `{ title, url }` object the W3C clipboard write path
// commits via `ScopedClipboardWriter::WriteURL`.
struct BookmarkInfo {
  std::u16string title;
  std::string url;
};

它是 ClipboardItemPayload 这个 std::variant 的三个分支之一——原始字节(std::vector<uint8_t>)、UTF-16 文本(std::u16string)、以及书签(BookmarkInfo)。也就是说,书签在载荷层面就与“文本”“二进制”分道扬镳,拥有独立的类型路径。

4.2 写入链路:构造期校验 + WriteURL 提交

原生构造器遍历用户传入的 { [mime]: payload } 对象时,对书签 MIME 做了专门分支(electron_api_clipboard_item.cc):

if (mime == electron::api::clipboard_util::kBookmarkMime) {
  // Bookmark MIME takes a structured `{ title, url }` object.
  if (!value->IsObject()) {
    isolate->ThrowException(v8::Exception::TypeError(gin::StringToV8(
        isolate,
        "ClipboardItem payload for `electron application/bookmark` "
        "must be an object with `title` and `url` properties")));
    return;
  }
  gin_helper::Dictionary dict{isolate, value.As<v8::Object>()};
  BookmarkInfo info;
  dict.Get("title", &info.title);
  dict.Get("url", &info.url);
  payloads_.emplace(mime, std::move(info));
  types_.push_back(std::move(mime));
  continue;
}

这里有两点从源码结构看值得注意:其一,载荷不是对象时会同步抛出 TypeError,错误信息明确提示“必须是含 titleurl 属性的对象”;其二,title/url 通过 gin_helper::Dictionary::Get 提取,属于宽松读取——若字段缺失会得到空串而非报错。

真正提交到系统剪贴板发生在 WriteToelectron_api_clipboard_item.cc),书签走 Chromium 的 URL 剪贴格式写入:

if (std::holds_alternative<BookmarkInfo>(payload)) {
  const auto& bm = std::get<BookmarkInfo>(payload);
  writer.WriteURL(
      ui::ClipboardUrlInfo{.url = GURL(bm.url), .title = bm.title});
  continue;
}

ClipboardBookmark 最终落到 Chromium ui::ScopedClipboardWriter::WriteURL 所对应的平台“URL 剪贴格式”(Windows 的 CF_URL、macOS 的 public.url 等)。这意味着 Electron 写入的书签与其他应用(浏览器“复制链接”等)写入的 URL 在平台层面是同一格式,具备跨应用互通性。

4.3 读取链路:ReadURL → ResolveAsBookmark

读取侧的 MIME 分发函数 ReadMime 对书签有独立分支(electron_api_clipboard_item.cc):

if (mime == cu::kBookmarkMime) {
  // Unlike every other MIME type, `getType('electron application/bookmark')`
  // resolves to a plain `{ title, url }` object rather than a Buffer — a
  // bookmark is inherently structured data, so this is far easier to use
  // than a serialized byte payload.
  ui::Clipboard::GetForCurrentThread()->ReadURL(
      /* data_dst = */ std::nullopt,
      base::BindOnce(
          [](gin_helper::Promise<v8::Local<v8::Value>> promise,
             ui::ClipboardUrlInfo url_info) {
            ResolveAsBookmark(std::move(promise),
                              electron::api::BookmarkInfo{
                                  url_info.title, url_info.url.spec()});
          },
          std::move(promise)));
  return handle;
}

它调用 ui::Clipboard::ReadURL 异步读取平台剪贴板中的 URL 信息,然后由 ResolveAsBookmark 组装出 JS 对象(electron_api_clipboard_item.cc):

void ResolveAsBookmark(gin_helper::Promise<v8::Local<v8::Value>> promise,
                       const electron::api::BookmarkInfo& info) {
  ...
  auto dict = gin_helper::Dictionary::CreateEmpty(isolate);
  dict.Set("title", info.title);
  dict.Set("url", info.url);
  promise.Resolve(dict.GetHandle());
}

源码注释特别说明了 url.spec() 的使用:ReadURL 回调给出的是 GURL 对象,取其 spec() 字符串作为 url 字段。这也解释了测试中断言的是 'https://electronjs.org/'(带尾斜杠,GURL 规范化后的完整形式)。

从源码结构看,这条链路是惰性读取的:clipboard.read() 时刻只枚举 MIME 类型,并不真正读取载荷;只有调用 getType() 时才发起对平台剪贴板的 ReadURL 请求(读侧 ClipboardItem 只保存 ui::ClipboardBuffer 与类型列表,见 electron_api_clipboard_item.h 的类注释)。

五、API 演进:从 readBookmark/writeBookmark 到 MIME 化

clipboard 模块按 W3C Clipboard API 重构之前,书签读写有专用的窄化 API,现在已被移除并统一到 MIME 模型中。breaking-changes.md 给出了新旧 API 的对照(节选与书签相关的部分):

旧 API 新 API
clipboard.readBookmark() clipboard.read() + electron application/bookmark 自定义格式
clipboard.writeBookmark(title, url[, type]) clipboard.write() + electron application/bookmark 自定义格式

文档同时说明 clipboard.read() 的返回形态变化:返回 Promise<ClipboardItem[]>,每个条目提供 types 数组与 getType(type) → Promise<Blob>,而 getType('electron application/bookmark') 是唯一的例外——它解析为 { title, url } 对象。

官方迁移示例(breaking-changes.md)展示了如何用新 API 复刻旧 readBookmark/writeBookmark 的行为:

const BOOKMARK_MIME_TYPE = 'electron application/bookmark'

async function readBookmark (clipboardType) {
  const clipboardToUse = getClipboardToUse(clipboardType)
  const clipboardItems = await clipboardToUse.read()
  for (const clipboardItem of clipboardItems) {
    if (clipboardItem.types.includes(BOOKMARK_MIME_TYPE)) {
      // getType('electron application/bookmark') resolves to a
      // { title, url } object rather than a Blob.
      return clipboardItem.getType(BOOKMARK_MIME_TYPE)
    }
  }
}

async function writeBookmark (title, url, clipboardType) {
  const clipboardToUse = getClipboardToUse(clipboardType)
  await clipboardToUse.write([
    new ClipboardItem({
      [BOOKMARK_MIME_TYPE]: { title, url }
    })
  ])
}

这段“手写封装”实际上是书签读写的推荐通用模式:先遍历条目检查 types,命中再 getType

六、测试视角:平台差异与边界行为

Electron 的官方测试套件 spec/api-clipboard-spec.ts 对书签行为做了系统验证,几个关键用例揭示了实际的平台边界:

1. 基本往返(非 Linux 平台)

ifdescribe(process.platform !== 'linux')('reading bookmarks via clipboard.read()', () => {
  it('returns title and url via the electron application/bookmark MIME type', async () => {
    await clipboard.write([
      new ClipboardItem({
        [BOOKMARK_MIME]: {
          title: 'a title',
          url: 'https://electronjs.org/'
        }
      })
    ]);

    const bookmark = await readBookmark();
    expect(bookmark).to.be.an('object');
    if (process.platform !== 'win32') {
      expect(bookmark!.title).to.equal('a title');
    }
    expect(bookmark!.url).to.equal('https://electronjs.org/');
    ...
  });
});

从测试结构可以读出三条平台事实:

  • Linux 上不启用该组测试process.platform !== 'linux'),即 Linux 下书签 MIME 的可用性未被官方测试覆盖,使用时应自行验证;
  • Windows 上不校验 title——仅断言 url。可以推断 Windows 平台的 URL 剪贴格式在“标题”的存取上与其他平台存在差异,写应用时不应依赖 Windows 下 title 的精确回读;
  • 清空/覆盖为纯文本后,若平台仍报告该格式存在,则读回的是空结构 { title: '', url: '' }(见 api-clipboard-spec.ts)。

2. 混合 MIME 写入中的书签

clipboard.write() 的“mixed data”用例(api-clipboard-spec.ts)中,同一条 ClipboardItem 同时携带 text/plaintext/htmltext/rtfimage/png 与书签:

const bookmark = { title: 'a title', url: text };
await clipboard.write([
  new ClipboardItem({
    'text/plain': text,
    'text/html': '<b>Hi</b>',
    'text/rtf': rtf,
    'image/png': new Blob([i.toPNG()]),
    [BOOKMARK_MIME]: bookmark
  })
]);
...
const readBookmarkValue = await readBookmark();
if (process.platform !== 'win32') {
  expect(readBookmarkValue).to.deep.equal(bookmark);
} else {
  expect(readBookmarkValue!.url).to.equal(bookmark.url);
}

该用例验证了“一条剪贴板条目同时携带文本、HTML、RTF、PNG 与书签”的原子写入,且读回的 ClipboardBookmark 在 macOS 上与写入对象深度相等。测试文件顶部的 readBookmark 辅助函数(api-clipboard-spec.ts)则示范了生产代码中最稳妥的读取写法:

async function readBookmark(): Promise<Electron.ClipboardBookmark | undefined> {
  const items = await clipboard.read();
  for (const item of items) {
    if (item.types.includes(BOOKMARK_MIME)) {
      return (await item.getType(BOOKMARK_MIME)) as Electron.ClipboardBookmark;
    }
  }
  return undefined;
}

3. 与 MIME 探测的配合

clipboard.has(mimetype) 返回 Promise<boolean>,对自定义 MIME 同样适用。判断书签是否可用的完整模式可以组合为:先 await clipboard.has('electron application/bookmark') 做廉价探测,命中后再 clipboard.read() + getType 取结构化数据。

七、使用建议与安全边界

结合 clipboard-item.md 中的安全警告与上述源码实现,使用 ClipboardBookmark 时建议遵循以下实践:

  1. 只在主进程使用clipboardClipboardItem 是 Main 进程 API(见 clipboard-item.md 的 Process 标注)。渲染进程应使用 navigator.clipboard;确需高级能力时通过 preload 脚本与 contextBridge 暴露受控辅助函数,且避免向不可信内容暴露完整 clipboard API。
  2. 不要直接消费不可信数据构造 MIME 键值:文档明确指出 MIME 键是“能力面”——例如 text/uri-list 会在系统剪贴板放置真实文件引用。若载荷来自 IPC 渲染侧或网络,必须先校验并白名单化 MIME 类型与载荷形状,再构造 ClipboardItem。对书签而言,至少应校验 title/url 确为字符串且 url 为合法地址。
  3. 防御式读取:先用 types 判断 MIME 存在再 getType;对读回对象做空值容忍(平台可能给出 { title: '', url: '' });跨平台时注意 Linux 覆盖与 Windows title 回读的差异。
  4. 理解惰性读取clipboard.read() 不立即读取任何字节载荷,getType() 才触发平台读取(对图片 MIME 还要求 app ready,见 electron_api_clipboard_item.ccimage/* 的就绪检查)。书签读取同样异步,UI 上应基于 Promise 处理。

小结

ClipboardBookmark 虽然只有 titleurl 两个字段,但它背后串联了 Electron 剪贴板 W3C 化重构后的完整链路:JS 门面类(lib/browser/api/clipboard-item.ts)负责 Blob/Promise 的异步解析与 MIME 分流,原生 ClipboardItemshell/browser/api/electron_api_clipboard_item.cc)负责构造期类型校验,并最终通过 ScopedClipboardWriter::WriteURL / ui::Clipboard::ReadURL 与操作系统的 URL 剪贴格式交互。它也是旧 readBookmark/writeBookmark API 在新架构下的唯一归宿。掌握这套“MIME 键值 + 结构化解析”的模式,即可同时驾驭书签与其他自定义格式(如 web 前缀自定义 MIME、electron application/osclipboard)的读写。

登录后查看全文
热门项目推荐
相关项目推荐