Electron 废弃 API 改造的疑难杂症——protocol.register*Protocol

1,634 阅读6分钟

背景

根据 Electron 官方的更新公告:Electron 25.0.0 | Electron (electronjs.org)

以下几个 API 方法已被标记为弃用

主要原因是官方对 protocol 系列 API 进行了“简化”(所谓的 simplification),认为旧的 API 理解成本太高,因此推荐大家使用 protocol.handle 替代上述 API。

关于 protocol.handle 的讨论,具体可以看这个链接内的描述。当然链接里面的描述只是当时的一个畅想,在实现时又是另一幅样子了,但大差不差。

当前的具体接口定义可以查阅官方文档

显而易见,旧的 register 系列 API 和新出的 protocol.handle 很不一样,下面是一个例子:

// 在 Electron 25 中弃用
protocol.registerHttpProtocol('some-protocol', (_request, callback) => {
  callback({ url: 'https://electronjs.org' });
});

// 用以代替
protocol.handle('some-protocol', (_request) => {
  return net.fetch('https://electronjs.org');
});

乍一看似乎 API 的替换工作很好完成,但实际业务中不可能有这么简单而天真的代码。没有人知道有多少功能依赖于一些旧 API 上花里胡哨的用法,且这些功能是否能被新的 API 覆盖也是未知数。

下面是一些截止目前我在替换这些废弃 API 过程中遇到的问题以及解决方式记录。

API 替换问题

返回值问题

registerHttpProtocol 为例,其可以写出如下代码:

protocol.registerHttpProtocol('customProtocol', (request, callback) => {
    if (needHandle(request.url)) {
        // 一些业务逻辑
        doSomething(request);
    }
});

在这个例子中,要改造为使用新的 protocol.handle API 的话,需要注意——

旧实现中没有调用 callback 回调函数,为请求返回一个响应。

也就是说,这里的旧实现逻辑中,这个请求将触发一些功能代码,且永远不能收到响应。

我再们来看看,新的 API 的定义:

handle(
    scheme: string,
    handler: (request: Request) => Response | Promise<Response>
): void;

可以发现,这里的 handler 参数要求必须返回一个 Response 对象,或一个可以解决为 Response 对象的 Promise

而如果我们直接返回一个任意的 Response 对象,就会使得请求接收到响应,不符合旧实现的逻辑。

所以这里的解决方式应该是,在执行完对应的业务逻辑功能以后,返回一个永远不会解决的 Promise,使得对应的响应无法收到对应的请求:

protocol.handle('customProtocol', (request) => {
    if (needHandle(request.url)) {
        // 一些业务逻辑
        doSomething(request);
    }
    // 返回一个永远不会解决的 Promise 对象
    return new Promise(() => {});
});

error 的处理

registerHttpProtocol 为例,旧 API 可以写出如下代码:

protocol.registerHttpProtocol('customProtocol', (request, callback) => {
    if (/* 命中某些条件 */) {
        // 通过返回带 error 字段的对象,使请求失败
        // 并通过 error 的值指定失败的理由
        callback({error: -3});
    }
});

旧实现中可以通过 callback 返回一个带 error 字段的对象,使请求失败。并通过 error 的值指定失败的理由。在上例中,error 指定为 -3 时,请求将被终止,在 chrome 的 devtools 中检查对应的请求,其将会像这样显示:

image.png

官网文档对 error 字段的说明如下:

  • error Integer(可选的) - 如果赋值,request将会失败,并返回error错误码。 更多的错误号信息,您可以查阅网络错误列表.

但新版本 API 中(即 protocol.handle 中),返回的 Response 对象似乎并没有指定请求失败理由的功能。官方文档中也未提及如何替代旧 API 提供的这一行为。

于是去翻了 Electron 添加该功能的 PR

在这个 commit 中,protocol.handle 被添加进来,观察其对于错误的处理:

image.png

可以看出,只要返回的对象中存在 error 字段,应该可以做到和旧 API 一样的效果,再看这个 commit 中新增的测试用例也可以大致印证这个想法:

image.png

但是!!

实验过后发现,下面的代码并不能起到和旧 API 一样的效果

protocol.handle('customProtocol', (request) => {
    if (/* 命中某些条件 */) {
        return {error: -3};
    }
});

于是继续翻刚刚的 PR,又发现了其中的另一个 commit 中包含这样的代码:

image.png

这个 commit 中删除了对于 error 字段的支持,将 handler 的入参和出参规范化为 globalThis.RequestglobalThis.Response 对象。

在返回的 Response 对象中,若 type 字段为 'error' 时,等同于旧实现中 callback({error: ERR_FAILED}) 的效果。观察其余部分的代码可以发现,这个常量值是 -2

image.png

因此,可以得出结论:protocol.handle 中移除了对请求指定失败原因的支持

且下面的两段代码等同:

protocol.handle('customProtocol', (request) => {
    if (/* 命中某些条件 */) {
        return Response.error();
    }
});

protocol.registerHttpProtocol('customProtocol', (request, callback) => {
    if (/* 命中某些条件 */) {
        callback({error: -2});
    }
});

即当新 API 的 handler 返回 Response.error() 时,其效果与旧 API 中调用 callback({error: -2}) 相同。

读取本地 path

当拦截文件类型的请求时,旧版 API 可以这样做:

protocol.registerFileProtocol('some-protocol', (request, callback) => {  
    callback({ filePath: '/path/to/my/file' });  
});

在新版 API 中,需要注意添加上 file 协议的前缀:

protocol.handle('some-protocol', () => {  
    return net.fetch('file:///path/to/my/file');  
});

如果觉得处理 URL 很麻烦,也可以使用 url 这个 npm 包简化操作:

const {pathToFileURL} = require('url');

protocol.handle('some-protocol', () => {  
    return net.fetch(pathToFileURL('/path/to/my/file').toString());  
});

这是去调研了另一个开源项目 Rancher Desktop 的这个 PR,从其中的这个 commit 里借鉴来的。

有一点需要注意,如果拦截的本身就是 file:// 协议,又想返回一个本地文件作为响应的时候,比如:

protocol.handle('file', () => {
  	return net.fetch('file:///path/to/my/file'); // 这会造成死循环!
});

这个时候绝不能像前面一样返回 net.fetch('file:///path/to/my/file'),因为 net.fetch'file:///path/to/my/file' 调用的时候,这个 url 还是 file:// 协议的,所以这又会被拦截函数捕获,导致死循环。

此时应当使用 NodeJS 的功能手动读取文件,构造响应对象进行返回。一个可行的例子如下:

const fs = require('fs');
const mime = require('mime-types');

function readFileAsResponse(filePath) {
  	return new Promise(resolve => {
        fs.readFile(filePath, (err, data) => {
            if (err) {
                resolve(Response.error());
            } else {
                resolve(
                    new Response(data, {
                        headers: {
                            'Content-Type': mime.lookup(filePath) || 'text/plain'
                        }
                    })
                );
            }
        });
		});
}

protocol.handle('file', () => {
  	return readFileAsResponse('/path/to/my/file');
});

注意,手动构造 Response 对象的时候,记得要设置 Content-Type 响应头,否则返回的文件可能没法被正确按类型解析;

比如渲染进程如果请求了一个本地 CSS 文件的时候,如果不设置 Content-Type,此时会取得默认值 text/html。这会导致渲染进程无法解析 CSS 文件,进而导致样式错误

结语

Electron 官方推出了新的 protocol.handle 方法,旨在简化网络协议的处理,但这一变动带来了不小的挑战。新的 protocol.handle API 虽然在设计上更为简洁,但在实践中可能需要一些奇技淫巧才能保持对旧 API 的兼容。

唯一的感想:希望 Electron 团队能够多多完善一下文档,尤其是使用新的 API 替代旧 API 的时候,帮助大家顺利过渡到新的 API,另外保持对旧功能的兼容性,别说砍就砍了。