构建自定义小部件 - 邮箱小部件#

本教程展示了如何使用TypeScript小部件cookiecutter构建一个简单的电子邮件小部件:https://github.com/jupyter-widgets/widget-ts-cookiecutter

end-result

设置开发环境#

使用 miniconda 安装 conda#

我们推荐使用 miniconda 安装 conda

说明可在conda安装文档中找到。

创建新的conda环境并附带依赖项#

接下来创建一个包含以下内容的conda环境:

  1. JupyterLab的最新版本或经典笔记本

  2. cookiecutter: 你将用来引导自定义小部件的工具

  3. NodeJS: 你将要使用的JavaScript运行时,用于编译自定义部件的Web资源(例如:TypeScript、CSS)

要创建环境,请执行以下命令:

conda create -n ipyemail -c conda-forge jupyterlab cookiecutter nodejs yarn python

然后使用以下命令激活环境:

conda activate ipyemail

创建新项目#

从 cookiecutter 初始化项目#

通常建议使用cookiecutter来引导该widget。

当前提供以下两个Cookiecutter项目:

在本教程中,我们将使用TypeScript cookiecutter,因为许多现有的部件都是用TypeScript编写的。

要生成项目,请运行以下命令:

cookiecutter https://github.com/jupyter-widgets/widget-ts-cookiecutter

当提示时,按如下方式输入所需的值:

author_name []: Your Name
author_email []: your@name.net
github_project_name []: ipyemail
github_organization_name []: 
python_package_name [ipyemail]:
npm_package_name [ipyemail]: jupyter-email
npm_package_version [0.1.0]:
project_short_description [A Custom Jupyter Widget Library]: A Custom Email Widget

切换到由cookiecutter创建的目录并列出文件:

cd ipyemail
ls

您应该会看到如下所示的列表:

appveyor.yml  css   examples  ipyemail.json  MANIFEST.in   pytest.ini  readthedocs.yml  setup.cfg  src    tsconfig.json
codecov.yml   docs  ipyemail  LICENSE.txt    package.json  README.md   setupbase.py     setup.py   tests  webpack.config.js

构建并安装该微件用于开发#

生成的项目应该已经包含一个README.md文件,其中包含本地开发小部件的说明。

由于该小部件包含Python部分,需要以可编辑模式安装该包:

python -m pip install -e .

您还需要启用小部件前端扩展。

如果您正在使用 JupyterLab(版本3.x或更高):

# link your development version of the extension with JupyterLab
jupyter labextension develop . --overwrite

# rebuild extension TypeScript source after making changes
yarn build

也可以使用watch脚本在有新更改时自动重新构建小部件:

# watch the source directory in one terminal, automatically rebuilding when needed
yarn watch

如果您正在使用经典版的Notebook:

jupyter nbextension install --sys-prefix --symlink --overwrite --py ipyemail
jupyter nbextension enable --sys-prefix --py ipyemail

测试安装#

此时,你应该能够打开一个笔记本并创建一个新的ExampleWidget

要测试它,请在终端中执行以下命令:

# if you are using the classic notebook
jupyter notebook

# if you are using JupyterLab
jupyter lab

并打开 examples/introduction.ipynb

默认情况下,该组件显示字符串Hello World,并带有彩色背景:

hello-world

接下来的步骤将引导您如何修改现有代码,将小部件转换为电子邮件小部件。

实现该widget#

该组件框架构建于通信框架(简称Comm)之上。通信框架是一种允许内核与前端发送/接收JSON消息的框架(如下所示)。

Widget layer

要了解更多关于底层Widget协议的工作原理,请查看Low Level Widget文档。

要创建自定义小部件,您需要在浏览器和 Python 内核中定义该小部件。

Python 内核#

DOMWidget、ValueWidget与Widget#

要定义一个小部件,你必须继承自 DOMWidgetValueWidgetWidget 基类。如果你希望你的小部件能够显示,你将需要继承自 DOMWidget。如果你希望你的小部件用作 interact 的输入,你将需要继承自 ValueWidget。如果你的小部件具有单一明确输出(例如,一个 IntSlider 的输出显然是滑块的当前值),那么你的小部件应该继承自 ValueWidget

DOMWidgetValueWidget 类均继承自 Widget 类。当 widget 无需直接在 notebook 中展示,而是作为另一种渲染环境的子元素时,Widget 类就非常有用。以下是一些示例:

  • 如果您想要创建一个three.js小部件(three.js是一个流行的WebGL库),您可以将渲染窗口实现为DOMWidget,并将任何在该窗口中渲染的3D对象或光源实现为Widget

  • 如果你想创建一个直接在笔记本中显示的小部件以用于interact(比如IntSlider),你应该同时从DOMWidgetValueWidget进行多重继承。

  • 如果你想创建一个提供给interact但不需显示的小部件,你应该仅继承ValueWidget

视图名称#

继承自DOMWidget并不会告诉小部件框架你的后端小部件要关联哪个前端小部件。

相反,您必须通过定义特殊命名的特性属性来自行告知它,_view_name_view_module_view_module_version(如下所示),以及可选的 _model_name_model_module

首先将ipyemail/example.py重命名为ipyemail/widget.py

ipyemail/widget.py中,将示例代码替换为以下内容:

from ipywidgets import DOMWidget, ValueWidget, register
from traitlets import Unicode, Bool, validate, TraitError

from ._frontend import module_name, module_version


@register
class Email(DOMWidget, ValueWidget):
    _model_name = Unicode('EmailModel').tag(sync=True)
    _model_module = Unicode(module_name).tag(sync=True)
    _model_module_version = Unicode(module_version).tag(sync=True)

    _view_name = Unicode('EmailView').tag(sync=True)
    _view_module = Unicode(module_name).tag(sync=True)
    _view_module_version = Unicode(module_version).tag(sync=True)

    value = Unicode('example@example.com').tag(sync=True)

ipyemail/__init__.py 中,将导入从:

from .example import ExampleWidget

致:

from .widget import Email

sync=True 特征属性#

Traitlets 是一个 IPython 库,用于在可配置对象上定义类型安全的属性。在本教程中,您无需担心 traitlets 机制中的可配置部分。sync=True 关键字参数告诉小部件框架处理将该值同步到浏览器的操作。如果没有 sync=True,小部件的属性将不会与前端的属性同步。

Syncing mutable types

请记住,可变类型在修改时不一定会同步。例如,向一个list添加元素不会导致更改同步。相反,必须创建一个新列表并将其分配给特性,以使更改同步。

另一种选择是使用诸如spectate这样的第三方库,它跟踪可变数据类型的更改。

其他 traitlet 类型#

Unicode, 用于_view_name, 并不是唯一的Traitlet类型, 还有更多一些类型在下面列出:

  • 任意

  • 布尔

  • 字节

  • CBool

  • CBytes

  • C复杂类型

  • 浮点数

  • 整数型转换

  • CLong

  • CRegExp

  • 统一码

  • 大小写无差别的字符串枚举

  • 复杂

  • 字典

  • 带点对象名称

  • 枚举

  • 浮点数

  • 函数类型

  • 实例

  • 实例类型

  • 整型

  • 列表

  • 长整型

  • 设置

  • TCP地址

  • 元组

  • 类型

  • 统一码

  • 并集

并非所有这些特质都可以通过网络同步,仅支持JSON格式的特质和小部件实例会被同步。

前端 (TypeScript)#

模型与视图#

IPython 小部件框架前端严重依赖 Backbone.js。Backbone.js 是一个 MVC(模型视图控制器)框架。在后端定义的小部件会自动与前端中的 Backbone.js Model 同步。每个前端 Model 处理小部件数据和状态,并可以有任意数量的关联 View。在小部件的上下文中,Views 是渲染对象以供用户交互的部分,而 Model 则处理与 Python 对象的通信。

在第一次从python推送状态时,同步的traitlets会自动添加。您之前定义的_view_name特征被widget框架用于创建相应的Backbone.js视图并将该视图链接到模型。

TypeScript cookiecutter 生成一个文件 src/widget.ts。打开该文件并将 ExampleModel 重命名为 EmailModel,将 ExampleView 重命名为 EmailView

export class EmailModel extends DOMWidgetModel {
  defaults() {
    return {...super.defaults(),
      _model_name: EmailModel.model_name,
      _model_module: EmailModel.model_module,
      _model_module_version: EmailModel.model_module_version,
      _view_name: EmailModel.view_name,
      _view_module: EmailModel.view_module,
      _view_module_version: EmailModel.view_module_version,
      value : 'Hello World'
    };
  }

  static serializers: ISerializers = {
      ...DOMWidgetModel.serializers,
      // Add any extra serializers here
    }

  static model_name = 'EmailModel';
  static model_module = MODULE_NAME;
  static model_module_version = MODULE_VERSION;
  static view_name = 'EmailView';
  static view_module = MODULE_NAME;
  static view_module_version = MODULE_VERSION;
}


export class EmailView extends DOMWidgetView {
  render() {
    this.el.classList.add('custom-widget');

    this.value_changed();
    this.model.on('change:value', this.value_changed, this);
  }

  value_changed() {
    this.el.textContent = this.model.get('value');
  }
}

渲染方法#

现在,重写视图的基础render方法来自定义渲染逻辑。

可以通过this.el获取控件默认DOM元素的句柄。el属性是与视图关联的DOM元素。

src/widget.ts 中,定义 _emailInput 属性:

export class EmailView extends DOMWidgetView {
  private _emailInput: HTMLInputElement;
  
  render() {
     // .....
  }
  
  // .....
}

然后,为render方法添加以下逻辑:

render() { 
    this._emailInput = document.createElement('input');
    this._emailInput.type = 'email';
    this._emailInput.value = 'example@example.com';
    this._emailInput.disabled = true;
    this.el.appendChild(this._emailInput);
    
    this.el.classList.add('custom-widget');

    this.value_changed();
    this.model.on('change:value', this.value_changed, this);
},

测试#

首先,运行以下命令重新构建前端包:

npm run build

如果您使用 JupyterLab,您可能希望使用 jlpm 作为 npm 客户端。jlpm 在底层使用 yarn 作为包管理器。与 npm 的主要区别是,jlpm 会生成一个 yarn.lock 文件用于依赖管理,而不是 package-lock.json。使用 jlpm 时的命令是:

jlpm build

重新加载页面后,您现在应该能够像显示任何其他小部件一样显示您的小部件:

from ipyemail import Email

Email()

使小部件具备状态#

上述例子中,你能做到的与IPython显示框架能实现的几乎没有差别。为了改变这一点,你将使该widget具有状态。它将显示一个由后端设置的地址,而不是一个静态的“example@example.com”邮箱地址。首先,你需要在后端添加一个traitlet。使用value的名称,以便与widget框架的其他部分保持一致,并允许你的widget与interact一起使用。

我们希望能够避免用户输入无效的电子邮件地址,因此需要使用traitlets创建一个验证器。

from ipywidgets import DOMWidget, ValueWidget, register
from traitlets import Unicode, Bool, validate, TraitError

from ._frontend import module_name, module_version


@register
class Email(DOMWidget, ValueWidget):
    _model_name = Unicode('EmailModel').tag(sync=True)
    _model_module = Unicode(module_name).tag(sync=True)
    _model_module_version = Unicode(module_version).tag(sync=True)

    _view_name = Unicode('EmailView').tag(sync=True)
    _view_module = Unicode(module_name).tag(sync=True)
    _view_module_version = Unicode(module_version).tag(sync=True)

    value = Unicode('example@example.com').tag(sync=True)
    disabled = Bool(False, help="Enable or disable user changes.").tag(sync=True)

    # Basic validator for the email value
    @validate('value')
    def _valid_value(self, proposal):
        if proposal['value'].count("@") != 1:
            raise TraitError('Invalid email value: it must contain an "@" character')
        if proposal['value'].count(".") == 0:
            raise TraitError('Invalid email value: it must contain at least one "." character')
        return proposal['value']

从视图访问模型#

要访问与视图实例关联的模型,请使用视图的 model 属性。getset 方法用于与 Backbone 模型交互。get 很简单,但在使用 set 时需小心。调用模型的 set 后,需要调用视图的 touch 方法。这将 set 操作与特定视图关联,以便输出被路由到正确的单元格。模型还有一个 on 方法,允许您监听模型触发的事件(如值更改)。

渲染模型内容#

通过将字符串字面量替换为对model.get的调用,视图现在将在显示时展示后端的值。然而,当值发生变化时,它不会自动更新为新值。

export class EmailView extends DOMWidgetView {
  render() {
    this._emailInput = document.createElement('input');
    this._emailInput.type = 'email';
    this._emailInput.value = this.model.get('value');
    this._emailInput.disabled = this.model.get('disabled');
      
    this.el.appendChild(this._emailInput);
  }

  private _emailInput: HTMLInputElement;
}

动态更新#

为了让视图动态更新自身,当模型的value属性改变时,注册一个函数来更新视图的值。这可以通过使用model.on方法完成。on方法接受三个参数:事件名称、回调句柄和回调上下文。每当模型发生改变时,名为change的Backbone事件将触发。通过在其后追加:value,你告诉Backbone只监听value属性的变化事件(如下所示)。

export class EmailView extends DOMWidgetView {
  render() {
    this._emailInput = document.createElement('input');
    this._emailInput.type = 'email';
    this._emailInput.value = this.model.get('value');
    this._emailInput.disabled = this.model.get('disabled');

    this.el.appendChild(this._emailInput);

    // Python -> JavaScript update
    this.model.on('change:value', this._onValueChanged, this);
    this.model.on('change:disabled', this._onDisabledChanged, this);
  }

  private _onValueChanged() {
    this._emailInput.value = this.model.get('value');
  }

  private _onDisabledChanged() {
    this._emailInput.disabled = this.model.get('disabled');
  }

  private _emailInput: HTMLInputElement;
}

这使我们能够从Python内核更新值到视图。现在,若要将从前端更新后的值传回Python内核(当输入未禁用时),我们使用model.set在前端模型上设置值,然后使用model.save_changes将前端模型与Python对象同步。

export class EmailView extends DOMWidgetView {
  render() {
    this._emailInput = document.createElement('input');
    this._emailInput.type = 'email';
    this._emailInput.value = this.model.get('value');
    this._emailInput.disabled = this.model.get('disabled');

    this.el.appendChild(this._emailInput);

    // Python -> JavaScript update
    this.model.on('change:value', this._onValueChanged, this);
    this.model.on('change:disabled', this._onDisabledChanged, this);

    // JavaScript -> Python update
    this._emailInput.onchange = this._onInputChanged.bind(this);
  }

  private _onValueChanged() {
    this._emailInput.value = this.model.get('value');
  }

  private _onDisabledChanged() {
    this._emailInput.disabled = this.model.get('disabled');
  }

  private _onInputChanged() {
    this.model.set('value', this._emailInput.value);
    this.model.save_changes();
  }

  private _emailInput: HTMLInputElement;
}

测试#

实例化一个新控件:

email = Email(value='john.doe@domain.com', disabled=False)
email

要获取该小部件的值:

email.value

设置小部件的值:

email.value = 'jane.doe@domain.com'

最终结果应如下所示:

end-result

传递URL#

在上面的示例中,我们已经看到了如何将简单的Unicode字符串传递到HTML输入元素。然而, 某些HTML元素,例如 <img/><iframe/><script/> 需要URL作为输入。考虑 一个嵌入 <iframe/> 的小部件。该小部件具有一个 src 属性,该属性连接到 <iframe/>src 属性。它是内置 IPython.display.IFrame(...) 的ipywidget版本。 与内置版本一样,我们希望支持两种形式:

from ipyiframe import IFrame

remote_url = IFrame(src='https://jupyter.org') # full HTTP URL
local_file = IFrame(src='./local_file.html')   # local file

注意,第二种形式是相对于笔记本文件的路径。使用此字符串作为src <iframe/>的属性将无法工作,因为浏览器会将其解释为相对URL,相对于浏览器的地址栏。要将相对路径转换为有效的文件URL,我们在我们的JavaScript视图类中使用 工具函数resolveUrl(...)

export class IFrameView extends DOMWidgetView {
  render() {
    this.$iframe = document.createElement('iframe');
    this.el.appendChild(this.$iframe);
    this.src_changed();
    this.model.on('change:src', this.src_changed, this);
  }

  src_changed() {
    const url = this.model.get('src'); 
    this.model.widget_manager.resolveUrl(url).then(resolvedUrl => { 
        this.$iframe.src = resolvedUrl;
    }); 
  }
}

调用this.model.widget_manager.resolveUrl(...)返回一个解析为正确URL的承诺。

了解更多#

正如我们在本教程中所见,从cookiecutter项目开始对于快速原型化自定义小部件非常有用。

当前提供以下两个Cookiecutter项目:

如果您想了解更多关于构建自定义组件的信息,也可以查看丰富的第三方组件生态系统: