在 VS Code 中试用

Notebook API

Notebook API 允许 Visual Studio Code 扩展将文件作为 Notebook 打开、执行 Notebook 代码单元格,并以各种丰富和交互式格式渲染 Notebook 输出。您可能知道流行的 Notebook 接口,例如 Jupyter Notebook 或 Google Colab – Notebook API 允许在 Visual Studio Code 中实现类似的体验。

Notebook 的组成部分

一个 Notebook 由一系列单元格及其输出组成。Notebook 的单元格可以是 Markdown 单元格代码单元格,并在 VS Code 核心内部渲染。输出可以是各种格式。一些输出格式,如纯文本、JSON、图像和 HTML,由 VS Code 核心渲染。其他的,如应用程序特定数据或交互式小程序,由扩展渲染。

Notebook 中的单元格由 NotebookSerializer 读取并写入文件系统,它负责从文件系统读取数据并将其转换为单元格的描述,并将对 Notebook 的修改持久化回文件系统。Notebook 的代码单元格可以由 NotebookController 执行,它获取单元格的内容并从中以多种格式生成零个或多个输出,格式范围从纯文本到格式化文档或交互式小程序。应用程序特定的输出格式和交互式小程序输出由 NotebookRenderer 渲染。

视觉上

Overview of 3 components of notebooks: NotebookSerializer, NotebookController, and NotebookRenderer, and how they interact. Described textually above and in following sections.

序列化程序

NotebookSerializer API 参考

NotebookSerializer 负责获取 Notebook 的序列化字节,并将这些字节反序列化为 NotebookData,其中包含 Markdown 和代码单元格的列表。它也负责相反的转换:获取 NotebookData 并将数据转换为序列化字节以进行保存。

示例

示例

在此示例中,我们构建了一个简化的 Notebook 提供程序扩展,用于查看具有 .notebook 扩展名(而不是其传统文件扩展名 .ipynb)的 Jupyter Notebook 格式文件。

Notebook 序列化程序在 package.json 中的 contributes.notebooks 部分声明如下

{
    ...
    "contributes": {
        ...
        "notebooks": [
            {
                "type": "my-notebook",
                "displayName": "My Notebook",
                "selector": [
                    {
                        "filenamePattern": "*.notebook"
                    }
                ]
            }
        ]
    }
}

然后 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 格式文件

Notebook showing contents of a Jupyter Notebook formatted file

您应该能够打开 Jupyter 格式的 Notebook,并将它们的单元格视为纯文本和渲染的 Markdown,还可以编辑单元格。然而,输出不会被持久化到磁盘;要保存输出,您还需要序列化和反序列化来自 NotebookData 的单元格输出。

要运行单元格,您需要实现一个 NotebookController

控制器

NotebookController API 参考

NotebookController 负责获取一个代码单元格并执行代码以生成一些或没有输出。

通过在创建控制器时设置 NotebookController#notebookType 属性,控制器可以直接与 Notebook 序列化程序和一种 Notebook 类型关联。然后,在扩展激活时将控制器推送到扩展订阅中,从而全局注册控制器。

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.jsonkeywords 中添加一个条目,例如 notebookKernel<ViewTypeUpperCamelCased>。例如,如果您发布了 github-issues Notebook 类型的备用内核,则应为您的扩展添加关键字 notebookKernelGithubIssues。这提高了在 Visual Studio Code 中打开 <ViewTypeUpperCamelCased> 类型的 Notebook 时扩展的可发现性。

示例

输出类型

输出必须是以下三种格式之一:文本输出、错误输出或富文本输出。内核可以为单个单元格的执行提供多个输出,在这种情况下,它们将显示为一个列表。

简单的格式,如文本输出、错误输出或富文本输出的“简单”变体(HTML、Markdown、JSON 等),由 VS Code 核心渲染,而应用程序特定的富文本输出类型由 NotebookRenderer 渲染。扩展可以选择自行渲染“简单”富文本输出,例如向 Markdown 输出添加 LaTeX 支持。

Diagram of the different output types described above

文本输出

文本输出是最简单的输出格式,其工作方式与您可能熟悉的许多 REPL 非常相似。它们仅包含一个 text 字段,该字段在单元格的输出元素中渲染为纯文本

vscode.NotebookCellOutputItem.text('This is the output...');

Cell with simple text output

错误输出

错误输出有助于以一致且易于理解的方式显示运行时错误。它们支持标准的 Error 对象。

try {
  /* Some code */
} catch (error) {
  vscode.NotebookCellOutputItem.error(error);
}

Cell with error output showing error name and message, as well as a stack trace with magenta text

富文本输出

富文本输出是显示单元格输出的最先进形式。它们允许提供输出数据的许多不同表示形式,以 mimetype 为键。例如,如果单元格输出表示一个 GitHub Issue,内核可能会在其 data 字段上产生一个具有多个属性的富文本输出

  • 一个包含 Issue 格式化视图的 text/html 字段。
  • 一个包含机器可读视图的 text/x-json 字段。
  • 一个 application/github-issue 字段,NotebookRenderer 可以使用它来创建 Issue 的完全交互式视图。

在这种情况下,text/htmltext/x-json 视图将由 VS Code 原生渲染,但如果没有针对该 mimetype 注册 NotebookRendererapplication/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'),
                        ])]);

Cell with rich output showing switching between formatted HTML, a JSON editor, and an error message showing no renderer is available (application/hello-world)

默认情况下,VS Code 可以渲染以下 mimetypes

  • application/javascript
  • text/html
  • image/svg+xml
  • text/markdown
  • image/png
  • image/jpeg
  • text/plain

VS Code 会在内置编辑器中将这些 mimetypes 渲染为代码

  • text/x-json
  • text/x-javascript
  • text/x-html
  • text/x-rust
  • ... text/x-LANGUAGE_ID 用于任何其他内置或已安装的语言。

此 Notebook 使用内置编辑器显示一些 Rust 代码:在内置 Monaco 编辑器中显示 Rust 代码的 Notebook

要渲染备用 mimetype,必须为该 mimetype 注册一个 NotebookRenderer

Notebook 渲染器

Notebook 渲染器负责获取特定 mimetype 的输出数据并提供该数据的渲染视图。由输出单元格共享的渲染器可以在这些单元格之间维护全局状态。渲染视图的复杂性可以从简单的静态 HTML 到动态的完全交互式小程序。在本节中,我们将探讨渲染表示 GitHub Issue 的输出的各种技术。

您可以使用我们的 Yeoman 生成器的样板快速入门。为此,首先使用以下命令安装 Yeoman 和 VS Code 生成器

npm install -g yo generator-code

然后,运行 yo code 并选择 New Notebook Renderer (TypeScript)

如果您不使用此模板,只需确保将 notebookRenderer 添加到您的扩展的 package.json 中的 keywords 中,并在扩展名称或描述的某个位置提及其 mimetype,以便用户可以找到您的渲染器。

一个简单、非交互式的渲染器

通过向扩展的 package.jsoncontributes.notebookRenderer 属性贡献,可以为一组 mimetypes 声明渲染器。此渲染器将使用 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 减速。贡献指的是一个“入口点”脚本,它在需要渲染任何输出之前加载到 Notebook 的 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 视图

Cell output showing rendered HTML view of issue

如果容器外部有元素或其他异步进程,可以使用 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);
    }
});

重要的是要记住,Notebook 的所有输出都在同一个 iframe 中的不同元素中渲染。如果您使用 document.querySelector 等函数,请确保将其作用域限制为您感兴趣的特定输出,以避免与其他输出冲突。在此示例中,我们使用 element.querySelector 来避免该问题。

交互式 Notebook(与控制器通信)

想象一下,我们希望在渲染的输出中单击按钮后添加查看 Issue 评论的功能。假设控制器可以在 ms-vscode.github-issue-notebook/github-issue-with-comments mimetype 下提供带有评论的 Issue 数据,我们可能会尝试预先检索所有评论并按如下方式实现

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>
  );
};

这立即引发了一些问题。首先,我们正在加载所有 Issue 的完整评论数据,即使在我们单击按钮之前。此外,即使我们只想显示更多数据,我们也需要控制器支持一个完全不同的 mimetype。

相反,控制器可以通过包含一个预加载脚本来向渲染器提供附加功能,VS Code 也会在 iframe 中加载该脚本。此脚本可以访问全局函数 postKernelMessageonDidReceiveKernelMessage,它们可用于与控制器通信。

Diagram showing how controllers interact with renderers through the NotebookRendererScript

例如,您可以修改控制器 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);
      }
    });
  }
};

然后您可以在渲染器中使用它。您需要确保检查控制器渲染脚本公开的全局变量是否可用,因为其他开发者可能在其他 Notebook 和不实现 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
        );
      }
    });
  }
}

交互式 Notebook(与扩展主机通信)

想象一下,我们希望添加在单独编辑器中打开输出项的功能。为了实现这一点,渲染器需要能够向扩展主机发送消息,然后扩展主机将启动编辑器。

这在渲染器和控制器是两个单独扩展的情况下会很有用。

在渲染器扩展的 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:<您的渲染器 ID> 添加到您的 activationEvents 中,并在扩展的 activate 函数中设置通信。
  • 并非所有由渲染器扩展发送到扩展主机的消息都能保证送达。用户可能在渲染器发送消息之前关闭 Notebook。

支持调试

对于某些控制器,例如实现编程语言的控制器,可能希望允许调试单元格的执行。要添加调试支持,Notebook 内核可以实现一个调试适配器,方法是直接实现调试适配器协议 (DAP),或者将协议委托并转换为现有的 Notebook 调试器(如“vscode-simple-jupyter-notebook”示例中所做)。一种更简单的方法是使用现有的未修改的调试扩展,并即时转换 DAP 以满足 Notebook 的需求(如“vscode-nodebook”中所做)。

示例

  • vscode-nodebook:Node.js Notebook,由 VS Code 内置的 JavaScript 调试器和一些简单的协议转换提供调试支持
  • vscode-simple-jupyter-notebook:Jupyter Notebook,由现有的 Xeus 调试器提供调试支持