笔记本 API
笔记本 API 允许 Visual Studio Code 扩展将文件作为笔记本打开,执行笔记本代码单元格,并以各种丰富和交互式格式渲染笔记本输出。你可能了解流行的笔记本界面,如 Jupyter Notebook 或 Google Colab——笔记本 API 允许在 Visual Studio Code 内部提供类似的体验。
笔记本的组成部分
笔记本由一系列单元格及其输出组成。笔记本的单元格可以是 Markdown 单元格或代码单元格,并在 VS Code 核心中渲染。输出可以是各种格式。一些输出格式,例如纯文本、JSON、图像和 HTML,由 VS Code 核心渲染。其他格式,例如特定于应用程序的数据或交互式小程序,则由扩展渲染。
笔记本中的单元格由 NotebookSerializer
从文件系统读取和写入,它负责从文件系统读取数据并将其转换为单元格描述,以及将对笔记本的修改持久化回文件系统。笔记本的代码单元格可以由 NotebookController
执行,它获取单元格的内容并从中生成零个或多个各种格式的输出,范围从纯文本到格式化文档或交互式小程序。特定于应用程序的输出格式和交互式小程序输出由 NotebookRenderer
渲染。
可视化
序列化器
NotebookSerializer
负责获取笔记本的序列化字节,并将这些字节反序列化为 NotebookData
,其中包含 Markdown 和代码单元格的列表。它还负责相反的转换:获取 NotebookData
并将数据转换为要保存的序列化字节。
示例
- JSON 笔记本序列化器:一个简单的笔记本示例,它接受 JSON 输入并在自定义
NotebookRenderer
中输出美化后的 JSON。 - Markdown 序列化器:以笔记本形式打开和编辑 Markdown 文件。
示例
在此示例中,我们构建了一个简化的笔记本提供程序扩展,用于查看具有 .notebook
扩展名(而不是其传统文件扩展名 .ipynb
)的 Jupyter Notebook 格式文件。
笔记本序列化器在 package.json
的 contributes.notebooks
部分中声明,如下所示:
{
...
"contributes": {
...
"notebooks": [
{
"type": "my-notebook",
"displayName": "My Notebook",
"selector": [
{
"filenamePattern": "*.notebook"
}
]
}
]
}
}
然后笔记本序列化器在扩展的激活事件中注册
import { TextDecoder, TextEncoder } from 'util';
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.workspace.registerNotebookSerializer('my-notebook', new SampleSerializer())
);
}
interface RawNotebook {
cells: RawNotebookCell[];
}
interface RawNotebookCell {
source: string[];
cell_type: 'code' | 'markdown';
}
class SampleSerializer implements vscode.NotebookSerializer {
async deserializeNotebook(
content: Uint8Array,
_token: vscode.CancellationToken
): Promise<vscode.NotebookData> {
var contents = new TextDecoder().decode(content);
let raw: RawNotebookCell[];
try {
raw = (<RawNotebook>JSON.parse(contents)).cells;
} catch {
raw = [];
}
const cells = raw.map(
item =>
new vscode.NotebookCellData(
item.cell_type === 'code'
? vscode.NotebookCellKind.Code
: vscode.NotebookCellKind.Markup,
item.source.join('\n'),
item.cell_type === 'code' ? 'python' : 'markdown'
)
);
return new vscode.NotebookData(cells);
}
async serializeNotebook(
data: vscode.NotebookData,
_token: vscode.CancellationToken
): Promise<Uint8Array> {
let contents: RawNotebookCell[] = [];
for (const cell of data.cells) {
contents.push({
cell_type: cell.kind === vscode.NotebookCellKind.Code ? 'code' : 'markdown',
source: cell.value.split(/\r?\n/g)
});
}
return new TextEncoder().encode(JSON.stringify(contents));
}
}
现在尝试运行你的扩展,并打开一个保存为 .notebook
扩展名的 Jupyter Notebook 格式文件
你应该能够打开 Jupyter 格式的笔记本,并将它们的单元格作为纯文本和渲染的 Markdown 查看,以及编辑单元格。但是,输出不会持久化到磁盘;要保存输出,你还需要序列化和反序列化 NotebookData
中的单元格输出。
要运行单元格,你需要实现一个 NotebookController
。
控制器
NotebookController
负责获取一个代码单元格并执行代码以产生一些或不产生输出。
通过在创建控制器时设置 NotebookController#notebookType
属性,控制器直接与笔记本序列化器和笔记本类型关联。然后,通过在扩展激活时将控制器推送到扩展订阅中,全局注册控制器。
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(new Controller());
}
class Controller {
readonly controllerId = 'my-notebook-controller-id';
readonly notebookType = 'my-notebook';
readonly label = 'My Notebook';
readonly supportedLanguages = ['python'];
private readonly _controller: vscode.NotebookController;
private _executionOrder = 0;
constructor() {
this._controller = vscode.notebooks.createNotebookController(
this.controllerId,
this.notebookType,
this.label
);
this._controller.supportedLanguages = this.supportedLanguages;
this._controller.supportsExecutionOrder = true;
this._controller.executeHandler = this._execute.bind(this);
}
private _execute(
cells: vscode.NotebookCell[],
_notebook: vscode.NotebookDocument,
_controller: vscode.NotebookController
): void {
for (let cell of cells) {
this._doExecution(cell);
}
}
private async _doExecution(cell: vscode.NotebookCell): Promise<void> {
const execution = this._controller.createNotebookCellExecution(cell);
execution.executionOrder = ++this._executionOrder;
execution.start(Date.now()); // Keep track of elapsed time to execute cell.
/* Do some execution here; not implemented */
execution.replaceOutput([
new vscode.NotebookCellOutput([
vscode.NotebookCellOutputItem.text('Dummy output text!')
])
]);
execution.end(true, Date.now());
}
}
如果你将提供 NotebookController
的扩展与其序列化器分开发布,那么请在 package.json
的 keywords
中添加一个类似 notebookKernel<ViewTypeUpperCamelCased>
的条目。例如,如果你为 github-issues
笔记本类型发布了替代内核,则应将 notebookKernelGithubIssues
关键字添加到你的扩展中。这提高了在 Visual Studio Code 中打开 <ViewTypeUpperCamelCased>
类型的笔记本时扩展的可发现性。
示例
- GitHub Issues 笔记本:用于执行 GitHub Issues 查询的控制器
- REST Book:用于运行 REST 查询的控制器。
- Regexper 笔记本:用于可视化正则表达式的控制器。
输出类型
输出必须是以下三种格式之一:文本输出、错误输出或富文本输出。内核可以为单元格的单次执行提供多个输出,在这种情况下,它们将显示为列表。
简单的格式,如文本输出、错误输出或富文本输出的“简单”变体(HTML、Markdown、JSON 等)由 VS Code 核心渲染,而特定于应用程序的富文本输出类型则由 NotebookRenderer 渲染。扩展可以选择自行渲染“简单”富文本输出,例如为 Markdown 输出添加 LaTeX 支持。
文本输出
文本输出是最简单的输出格式,其工作方式与你可能熟悉的许多 REPL 类似。它们只包含一个 text
字段,该字段在单元格的输出元素中以纯文本形式渲染。
vscode.NotebookCellOutputItem.text('This is the output...');
错误输出
错误输出有助于以一致且易于理解的方式显示运行时错误。它们支持标准的 Error
对象。
try {
/* Some code */
} catch (error) {
vscode.NotebookCellOutputItem.error(error);
}
富文本输出
富文本输出是显示单元格输出的最先进形式。它们允许提供输出数据的许多不同表示形式,通过 mimetype 键控。例如,如果单元格输出要表示 GitHub Issue,则内核可能会在其 data
字段上生成具有多个属性的富文本输出:
- 包含问题格式化视图的
text/html
字段。 - 包含机器可读视图的
text/x-json
字段。 NotebookRenderer
可以使用application/github-issue
字段来创建问题的完全交互式视图。
在这种情况下,text/html
和 text/x-json
视图将由 VS Code 本地渲染,但如果未向该 mimetype 注册任何 NotebookRenderer
,则 application/github-issue
视图将显示错误。
execution.replaceOutput([new vscode.NotebookCellOutput([
vscode.NotebookCellOutputItem.text('<b>Hello</b> World', 'text/html'),
vscode.NotebookCellOutputItem.json({ hello: 'world' }),
vscode.NotebookCellOutputItem.json({ custom-data-for-custom-renderer: 'data' }, 'application/custom'),
])]);
默认情况下,VS Code 可以渲染以下 mimetype:
- application/javascript
- text/html
- image/svg+xml
- text/markdown
- image/png
- image/jpeg
- text/plain
VS Code 将在内置编辑器中将这些 mimetype 渲染为代码
- text/x-json
- text/x-javascript
- text/x-html
- text/x-rust
- ......对于任何其他内置或已安装的语言,使用 text/x-LANGUAGE_ID。
此笔记本正在使用内置编辑器显示一些 Rust 代码:
要渲染替代的 mimetype,必须为该 mimetype 注册一个 NotebookRenderer
。
笔记本渲染器
笔记本渲染器负责获取特定 mimetype 的输出数据,并提供该数据的渲染视图。输出单元格共享的渲染器可以在这些单元格之间维护全局状态。渲染视图的复杂性可以从简单的静态 HTML 到动态的完全交互式小程序。在本节中,我们将探讨渲染表示 GitHub Issue 的输出的各种技术。
你可以使用我们的 Yeoman 生成器的样板代码快速入门。为此,首先使用以下命令安装 Yeoman 和 VS Code 生成器:
npm install -g yo generator-code
然后,运行 yo code
并选择 New Notebook Renderer (TypeScript)
。
如果你不使用此模板,你只需要确保在扩展的 package.json
的 keywords
中添加 notebookRenderer
,并在扩展名称或描述中提及其 mimetype,以便用户可以找到你的渲染器。
一个简单的非交互式渲染器
渲染器通过贡献到扩展 package.json
的 contributes.notebookRenderer
属性来为一组 mimetype 声明。此渲染器将处理 ms-vscode.github-issue-notebook/github-issue
格式的输入,我们假设已安装的控制器能够提供此输入。
{
"activationEvents": ["...."],
"contributes": {
...
"notebookRenderer": [
{
"id": "github-issue-renderer",
"displayName": "GitHub Issue Renderer",
"entrypoint": "./out/renderer.js",
"mimeTypes": [
"ms-vscode.github-issue-notebook/github-issue"
]
}
]
}
}
输出渲染器始终在单个 iframe
中渲染,与 VS Code 的其余 UI 分开,以确保它们不会意外干扰或导致 VS Code 变慢。该贡献引用了一个“入口点”脚本,该脚本在任何输出需要渲染之前加载到笔记本的 iframe
中。你的入口点需要是一个单一文件,你可以自己编写,或者使用 Webpack、Rollup 或 Parcel 等打包工具创建。
加载后,你的入口点脚本应从 vscode-notebook-renderer
导出 ActivationFunction
,以便在 VS Code 准备好渲染你的渲染器时渲染你的 UI。例如,这将把你的所有 GitHub issue 数据作为 JSON 放入单元格输出中:
import type { ActivationFunction } from 'vscode-notebook-renderer';
export const activate: ActivationFunction = context => ({
renderOutputItem(data, element) {
element.innerText = JSON.stringify(data.json());
}
});
你可以在此处查阅完整的 API 定义。如果你正在使用 TypeScript,可以安装 @types/vscode-notebook-renderer
,然后将 vscode-notebook-renderer
添加到 tsconfig.json
中的 types
数组,使这些类型在你的代码中可用。
要创建更丰富的内容,你可以手动创建 DOM 元素,或者使用像 Preact 这样的框架并将其渲染到输出元素中,例如:
import type { ActivationFunction } from 'vscode-notebook-renderer';
import { h, render } from 'preact';
const Issue: FunctionComponent<{ issue: GithubIssue }> = ({ issue }) => (
<div key={issue.number}>
<h2>
{issue.title}
(<a href={`https://github.com/${issue.repo}/issues/${issue.number}`}>#{issue.number}</a>)
</h2>
<img src={issue.user.avatar_url} style={{ float: 'left', width: 32, borderRadius: '50%', marginRight: 20 }} />
<i>@{issue.user.login}</i> Opened: <div style="margin-top: 10px">{issue.body}</div>
</div>
);
const GithubIssues: FunctionComponent<{ issues: GithubIssue[]; }> = ({ issues }) => (
<div>{issues.map(issue => <Issue key={issue.number} issue={issue} />)}</div>
);
export const activate: ActivationFunction = (context) => ({
renderOutputItem(data, element) {
render(<GithubIssues issues={data.json()} />, element);
}
});
在具有 ms-vscode.github-issue-notebook/github-issue
数据字段的输出单元格上运行此渲染器,我们会得到以下静态 HTML 视图:
如果你的容器外部有元素或其他异步进程,你可以使用 disposeOutputItem
将它们清除。当输出被清除、单元格被删除以及在为现有单元格渲染新输出之前,此事件将触发。例如:
const intervals = new Map();
export const activate: ActivationFunction = (context) => ({
renderOutputItem(data, element) {
render(<GithubIssues issues={data.json()} />, element);
intervals.set(data.mime, setInterval(() => {
if(element.querySelector('h2')) {
element.querySelector('h2')!.style.color = `hsl(${Math.random() * 360}, 100%, 50%)`;
}
}, 1000));
},
disposeOutputItem(id) {
clearInterval(intervals.get(id));
intervals.delete(id);
}
});
需要记住的是,笔记本的所有输出都在同一个 iframe 中的不同元素中渲染。如果你使用像 document.querySelector
这样的函数,请确保将其作用域限定为你感兴趣的特定输出,以避免与其他输出冲突。在此示例中,我们使用 element.querySelector
来避免该问题。
交互式笔记本(与控制器通信)
假设我们想在渲染的输出中点击按钮后添加查看问题评论的功能。假设控制器可以在 ms-vscode.github-issue-notebook/github-issue-with-comments
mimetype 下提供带评论的问题数据,我们可能会尝试预先检索所有评论并按如下方式实现:
const Issue: FunctionComponent<{ issue: GithubIssueWithComments }> = ({ issue }) => {
const [showComments, setShowComments] = useState(false);
return (
<div key={issue.number}>
<h2>
{issue.title}
(<a href={`https://github.com/${issue.repo}/issues/${issue.number}`}>#{issue.number}</a>)
</h2>
<img src={issue.user.avatar_url} style={{ float: 'left', width: 32, borderRadius: '50%', marginRight: 20 }} />
<i>@{issue.user.login}</i> Opened: <div style="margin-top: 10px">{issue.body}</div>
<button onClick={() => setShowComments(true)}>Show Comments</button>
{showComments && issue.comments.map(comment => <div>{comment.text}</div>)}
</div>
);
};
这立即引发了一些问题。首先,我们正在为所有问题加载完整的评论数据,甚至在我们点击按钮之前。此外,即使我们只是想显示更多数据,我们也需要控制器支持完全不同的 mimetype。
相反,控制器可以通过包含一个预加载脚本来向渲染器提供附加功能,VS Code 也会在 iframe 中加载该脚本。该脚本可以访问全局函数 postKernelMessage
和 onDidReceiveKernelMessage
,可用于与控制器通信。
例如,你可能会修改你的控制器 rendererScripts
以引用一个新文件,你在其中创建与扩展主机的连接,并为渲染器公开一个全局通信脚本供其使用。
在你的控制器中
class Controller {
// ...
readonly rendererScriptId = 'my-renderer-script';
constructor() {
// ...
this._controller.rendererScripts.push(
new vscode.NotebookRendererScript(
vscode.Uri.file(/* path to script */),
rendererScriptId
)
);
}
}
在你的 package.json
中将你的脚本指定为渲染器的依赖项
{
"activationEvents": ["...."],
"contributes": {
...
"notebookRenderer": [
{
"id": "github-issue-renderer",
"displayName": "GitHub Issue Renderer",
"entrypoint": "./out/renderer.js",
"mimeTypes": [...],
"dependencies": [
"my-renderer-script"
]
}
]
}
}
在你的脚本文件中,你可以声明与控制器通信的函数
import 'vscode-notebook-renderer/preload';
globalThis.githubIssueCommentProvider = {
loadComments(issueId: string, callback: (comments: GithubComment[]) => void) {
postKernelMessage({ command: 'comments', issueId });
onDidReceiveKernelMessage(event => {
if (event.data.type === 'comments' && event.data.issueId === issueId) {
callback(event.data.comments);
}
});
}
};
然后你可以在渲染器中消费它。你需要确保检查控制器渲染脚本公开的全局是否可用,因为其他开发者可能在其他笔记本和未实现 githubIssueCommentProvider
的控制器中创建 github issue 输出。在这种情况下,只有当全局可用时,我们才会显示加载评论按钮。
const canLoadComments = globalThis.githubIssueCommentProvider !== undefined;
const Issue: FunctionComponent<{ issue: GithubIssue }> = ({ issue }) => {
const [comments, setComments] = useState([]);
const loadComments = () =>
globalThis.githubIssueCommentProvider.loadComments(issue.id, setComments);
return (
<div key={issue.number}>
<h2>
{issue.title}
(<a href={`https://github.com/${issue.repo}/issues/${issue.number}`}>#{issue.number}</a>)
</h2>
<img src={issue.user.avatar_url} style={{ float: 'left', width: 32, borderRadius: '50%', marginRight: 20 }} />
<i>@{issue.user.login}</i> Opened: <div style="margin-top: 10px">{issue.body}</div>
{canLoadComments && <button onClick={loadComments}>Load Comments</button>}
{comments.map(comment => <div>{comment.text}</div>)}
</div>
);
};
最后,我们想设置与控制器的通信。当渲染器使用全局 postKernelMessage
函数发布消息时,会调用 NotebookController.onDidReceiveMessage
方法。要实现此方法,请附加到 onDidReceiveMessage
以侦听消息。
class Controller {
// ...
constructor() {
// ...
this._controller.onDidReceiveMessage(event => {
if (event.message.command === 'comments') {
_getCommentsForIssue(event.message.issueId).then(
comments =>
this._controller.postMessage({
type: 'comments',
issueId: event.message.issueId,
comments
}),
event.editor
);
}
});
}
}
交互式笔记本(与扩展主机通信)
假设我们想添加在单独的编辑器中打开输出项的功能。为了实现这一点,渲染器需要能够向扩展主机发送消息,然后扩展主机将启动编辑器。
这在渲染器和控制器是两个独立扩展的场景中非常有用。
在渲染器扩展的 package.json
中,将 requiresMessaging
的值指定为 optional
,这允许你的渲染器在有或没有访问扩展主机的情况下都能工作。
{
"activationEvents": ["...."],
"contributes": {
...
"notebookRenderer": [
{
"id": "output-editor-renderer",
"displayName": "Output Editor Renderer",
"entrypoint": "./out/renderer.js",
"mimeTypes": [...],
"requiresMessaging": "optional"
}
]
}
}
requiresMessaging
的可能值包括:
always
:需要消息传递。渲染器只有当它是可以在扩展主机中运行的扩展的一部分时才会被使用。optional
:当扩展主机可用时,渲染器通过消息传递效果更好,但安装和运行渲染器不是必需的。never
:渲染器不需要消息传递。
后两个选项是首选,因为这确保了渲染器扩展在扩展主机不一定可用的其他上下文中的可移植性。
渲染器脚本文件可以按如下方式设置通信:
import { ActivationFunction } from 'vscode-notebook-renderer';
export const activate: ActivationFunction = (context) => ({
renderOutputItem(data, element) {
// Render the output using the output `data`
....
// The availability of messaging depends on the value in `requiresMessaging`
if (!context.postMessage){
return;
}
// Upon some user action in the output (such as clicking a button),
// send a message to the extension host requesting the launch of the editor.
document.querySelector('#openEditor').addEventListener('click', () => {
context.postMessage({
request: 'showEditor',
data: '<custom data>'
})
});
}
});
然后你可以在扩展主机中消费该消息,如下所示:
const messageChannel = notebooks.createRendererMessaging('output-editor-renderer');
messageChannel.onDidReceiveMessage(e => {
if (e.message.request === 'showEditor') {
// Launch the editor for the output identified by `e.message.data`
}
});
注意
- 为确保你的扩展在消息传递之前在扩展主机中运行,请将
onRenderer:<your renderer id>
添加到你的activationEvents
,并在扩展的activate
函数中设置通信。 - 渲染器扩展发送到扩展主机的消息并非全部保证送达。用户可能会在渲染器发送消息之前关闭笔记本。
支持调试
对于某些控制器,例如实现编程语言的控制器,可能需要允许调试单元格的执行。要添加调试支持,笔记本内核可以实现一个调试适配器,方法是直接实现调试适配器协议 (DAP),或者委托并转换协议到现有笔记本调试器(如“vscode-simple-jupyter-notebook”示例所示)。一种更简单的方法是使用现有未修改的调试扩展,并即时转换 DAP 以满足笔记本需求(如“vscode-nodebook”所示)。
示例
- vscode-nodebook:Node.js 笔记本,通过 VS Code 内置的 JavaScript 调试器和一些简单的协议转换提供调试支持。
- vscode-simple-jupyter-notebook:Jupyter 笔记本,通过现有的 Xeus 调试器提供调试支持。