Notebook API

Notebook API 允许 Visual Studio Code 扩展将文件作为笔记本打开、执行笔记本代码单元,并以多种丰富且交互式的格式渲染笔记本输出。您可能熟悉 Jupyter Notebook 或 Google Colab 等流行的笔记本界面——Notebook API 允许在 Visual Studio Code 内部实现类似的体验。

笔记本的组成部分

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

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

直观示意图

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

序列化器 (Serializer)

NotebookSerializer API 参考

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

示例

  • JSON Notebook Serializer:简单的笔记本示例,它获取 JSON 输入并在自定义的 NotebookRenderer 中输出美化后的 JSON。
  • Markdown Serializer:以笔记本形式打开和编辑 Markdown 文件。

示例

在此示例中,我们构建了一个简化的笔记本提供程序扩展,用于查看使用 .notebook 扩展名(而非传统的 .ipynb 文件扩展名)保存的 Jupyter Notebook 格式 文件。

笔记本序列化器在 package.jsoncontributes.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 格式文件

Notebook showing contents of a Jupyter Notebook formatted file

您应该能够打开 Jupyter 格式的笔记本,并以纯文本和渲染后的 Markdown 两种方式查看单元,还可以编辑单元。但是,输出不会持久保存到磁盘;要保存输出,您还需要从 NotebookData 序列化和反序列化单元的输出。

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

控制器 (Controller)

NotebookController API 参考

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

示例

输出类型

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

简单的格式(如文本输出、错误输出或“简单”变体的富文本输出(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

富文本输出

富文本输出是显示单元输出的最先进形式。它们允许提供输出数据的多种不同表示形式,并按 MIME 类型进行键控。例如,如果单元输出代表一个 GitHub Issue,内核可能会产生一个在 data 字段中包含多个属性的富文本输出

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

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

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 可以渲染以下 MIME 类型

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

VS Code 将这些 MIME 类型作为代码在内置编辑器中渲染

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

此笔记本使用内置编辑器显示一些 Rust 代码: Notebook displaying Rust code in a built in Monaco editor

要渲染替代的 MIME 类型,必须为该 MIME 类型注册一个 NotebookRenderer

笔记本渲染器 (Notebook Renderer)

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

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

npm install -g yo generator-code

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

如果您不使用此模板,只需确保在扩展的 package.jsonkeywords 中添加 notebookRenderer,并在扩展名称或描述中的某处提及它的 MIME 类型,以便用户可以找到您的渲染器。

简单的非交互式渲染器

渲染器通过向扩展 package.jsoncontributes.notebookRenderer 属性贡献内容来声明一组 MIME 类型。此渲染器将处理 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.jsontypes 数组中,以便在代码中使用这些类型。

为了创建更丰富的内容,您可以手动创建 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);
    }
});

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

交互式笔记本(与控制器通信)

假设我们想在点击渲染输出中的按钮后增加查看 Issue 评论的功能。假设控制器可以在 ms-vscode.github-issue-notebook/github-issue-with-comments MIME 类型下提供带有评论的 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 的完整评论数据。此外,即使我们只是想显示更多一点数据,我们也需要控制器支持完全不同的 MIME 类型。

相反,控制器可以通过包含预加载脚本(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);
      }
    });
  }
};

然后您可以在渲染器中使用它。您需要确保检查控制器渲染脚本公开的全局变量是否可用,因为其他开发人员可能会在其他笔记本和未实现 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:<您的渲染器 ID> 添加到您的 activationEvents,并在扩展的 activate 函数中设置通信。
  • 不保证渲染器扩展发送给扩展主机的每条消息都能被送达。用户可能在渲染器的消息被送达之前关闭笔记本。

支持调试

对于某些控制器(例如那些实现编程语言的控制器),允许调试单元执行可能是有意义的。要增加调试支持,笔记本内核可以实现一个 调试适配器,既可以直接实现 调试适配器协议 (DAP),也可以通过委托并将协议转换为现有的笔记本调试器(如在 'vscode-simple-jupyter-notebook' 示例中所做的那样)。一种更简单的方法是使用现有的未修改的调试扩展,并即时转换笔记本所需的 DAP(如 'vscode-nodebook' 中所做的那样)。

示例

  • vscode-nodebook:具有调试支持的 Node.js 笔记本,由 VS Code 的内置 JavaScript 调试器和一些简单的协议转换提供支持
  • vscode-simple-jupyter-notebook:具有由现有 Xeus 调试器提供调试支持的 Jupyter 笔记本
© . This site is unofficial and not affiliated with Microsoft.