• 基于 PhosphorJS 能够实现 桌面端/Web 端对立实现类桌面的交互
  • 理解 PhosphorJS 的外围组件 Widget 的接口和实现
  • 理解 Theia 是如何基于 PhosphorJS 在 ApplicationShell 中进行页面组装的
  • 理解 Theia 前端界面是如何通过 FrontendApplication.start() 构建的
  • 理解 React 与 PhosphorJS 混写办法

Theia 框架前端 UI 布局和 Services 一样,具备灵便可拓展的特点。VSCode 是内置了一套根本的组件零碎,而 Theia 框架的 UI 布局基于 PhosphorJS 框架。 PhosphorJS 提供了蕴含 widgets、layouts、事件和数据结构的丰盛工具包。这使得开发人员可能构建可扩大的、高性能的、类桌面的 Web 应用程序,比方 JupyterLab。

PhosphorJS 作者退休,我的项目已归档,该我的项目当初被 Jupyter 团队重命名为 jupyterlab/lumino 持续保护。见 issue:https://github.com/jupyterlab...

成果

example-dockpanel

在 PhosphorJS 里运行 React 代码:ermalism/phosphorjs-react-jsx-example

Widget

PhosphorJS 布局的外围就在于 Widget。

这里的 Widget 和 Flutter 外面的 Widget 还不一样,Flutter 的 Widget 属于申明式 UI(declarative UI),而 PhosphorJS 的 Widget 更像是命令式 UI(imperative UI)。和 Chrome 开发者工具 ChromeDevTools/devtools-frontend 的 Widget 更相似。

对于申明式和命令式 UI 框架也能够浏览:聊聊我对古代前端框架的认知 作为补充。

Widget 的继承

官网提供了一系列 Widget 的继承实现:

Widget

- Panel  //  面板,wrapper around PanelLayout    - BoxPanel  // wrapper around a BoxLayout , 将子 widgets 依照行或列的形式排列    - SplitPanel  // wrapper around a SplitLayout , arranges its widgets into resizable sections.    - StackedPanel  // wrapper around a StackedLayout , visible widgets are stacked atop one another- CommandPalette // displays command items as a searchable palette- Menu // displays items as a canonical menu- TabBar // displays titles as a single row or column of tabs- DockPanel // 提供灵便的 docking area- MenuBar // canonical menu bar- ScrollBar // canonical scroll bar- TabPanel // combines a TabBar and a StackedPanel

并且都实现了 IDisposable 和 IMessageHandler 接口。

接口

Widget 蕴含以下状态:isDisposed、isAttached、isHidden、isVisible,以及一系列事件驱动的钩子:onCloseRequest、onResize、onUpdateRequest、onFitRequest、onActivateRequest、onBeforeShow、onBeforeHide、onBeforeAttach、onBeforeDetach、onChildAdded 等。

渲染的外围的办法在于 Widget.attach,实质上就是:host.insertBefore(widget.node, ref);

Widget 次要的字段及接口如下:

/** * The namespace for the `Widget` class statics. */export declare namespace Widget {    /**     * Construct a new widget.     *     * @param options - The options for initializing the widget.     */    constructor(options?: Widget.IOptions);    /**     * Get the DOM node owned by the widget.     */    readonly node: HTMLElement;    readonly title: Title<Widget>;    parent: Widget | null;    layout: Layout | null;    children(): IIterator<Widget>;    /**     * Post an `'update-request'` message to the widget.     *     * #### Notes     * This is a simple convenience method for posting the message.     */    update(): void;    /**     * Attach a widget to a host DOM node.     *     * @param widget - The widget of interest.     *     * @param host - The DOM node to use as the widget's host.     *     * @param ref - The child of `host` to use as the reference element.     *   If this is provided, the widget will be inserted before this     *   node in the host. The default is `null`, which will cause the     *   widget to be added as the last child of the host.     */    function attach(widget: Widget, host: HTMLElement, ref?: HTMLElement | null): void;}

Theia 布局构建

Theia 前端页面的启动非常简单:

function start() {    (window['theia'] = window['theia'] || {}).container = container;    const themeService = ThemeService.get();    themeService.loadUserTheme();    const application = container.get(FrontendApplication);    return application.start();}

能够看到外围就在于 FrontendApplication.start() 办法,那么这个办法里做了什么?

FrontendApplication

// packages/core/src/browser/frontend-application.ts@injectable()export class FrontendApplication {        /**     * Start the frontend application.     *     * Start up consists of the following steps:     * - start frontend contributions     * - attach the application shell to the host element     * - initialize the application shell layout     * - reveal the application shell if it was hidden by a startup indicator     */    async start(): Promise<void> {        await this.startContributions();        this.stateService.state = 'started_contributions';        const host = await this.getHost();        this.attachShell(host);        await animationFrame();        this.stateService.state = 'attached_shell';        await this.initializeLayout();        this.stateService.state = 'initialized_layout';        await this.fireOnDidInitializeLayout();        await this.revealShell(host);        this.registerEventListeners();        this.stateService.state = 'ready';    }    /**     * Attach the application shell to the host element. If a startup indicator is present, the shell is     * inserted before that indicator so it is not visible yet.     */    protected attachShell(host: HTMLElement): void {        const ref = this.getStartupIndicator(host);        Widget.attach(this.shell, host, ref);  // 实质是调用  host.insertBefore(widget.node, ref);    }}

ApplicationShell

次要分为:mainPanel:TheiaDockPanel、topPanel:Panel、bottomPanel:TheiaDockPanel、leftPanel、rightPanel

    /**     * General options for the application shell. These are passed on construction and can be modified     * through dependency injection (`ApplicationShellOptions` symbol).     */    export interface Options extends Widget.IOptions {        bottomPanel: BottomPanelOptions;        leftPanel: SidePanel.Options;        rightPanel: SidePanel.Options;    }    export interface BottomPanelOptions extends SidePanel.Options {    }    /**     * The default values for application shell options.     */    export const DEFAULT_OPTIONS = Object.freeze(<Options>{        bottomPanel: Object.freeze(<BottomPanelOptions>{            emptySize: 140,            expandThreshold: 160,            expandDuration: 0,            initialSizeRatio: 0.382        }),        leftPanel: Object.freeze(<SidePanel.Options>{            emptySize: 140,            expandThreshold: 140,            expandDuration: 0,            initialSizeRatio: 0.191        }),        rightPanel: Object.freeze(<SidePanel.Options>{            emptySize: 140,            expandThreshold: 140,            expandDuration: 0,            initialSizeRatio: 0.191        })    });

在 ApplicationShell 中初始化并拼装。

// packages/core/src/browser/shell/application-shell.ts/** * The application shell manages the top-level widgets of the application. Use this class to * add, remove, or activate a widget. */@injectable()export class ApplicationShell extends Widget {    /**     * Construct a new application shell.     */    constructor(        @inject(DockPanelRendererFactory) protected dockPanelRendererFactory: () => DockPanelRenderer,        @inject(StatusBarImpl) protected readonly statusBar: StatusBarImpl,        @inject(SidePanelHandlerFactory) sidePanelHandlerFactory: () => SidePanelHandler,        @inject(SplitPositionHandler) protected splitPositionHandler: SplitPositionHandler,        @inject(FrontendApplicationStateService) protected readonly applicationStateService: FrontendApplicationStateService,        @inject(ApplicationShellOptions) @optional() options: RecursivePartial<ApplicationShell.Options> = {}    ) {        super(options as Widget.IOptions);        this.addClass(APPLICATION_SHELL_CLASS);        this.id = 'theia-app-shell';        // Merge the user-defined application options with the default options        this.options = {            bottomPanel: {                ...ApplicationShell.DEFAULT_OPTIONS.bottomPanel,                ...options.bottomPanel || {}            },            leftPanel: {                ...ApplicationShell.DEFAULT_OPTIONS.leftPanel,                ...options.leftPanel || {}            },            rightPanel: {                ...ApplicationShell.DEFAULT_OPTIONS.rightPanel,                ...options.rightPanel || {}            }        };        this.mainPanel = this.createMainPanel();        this.topPanel = this.createTopPanel();        this.bottomPanel = this.createBottomPanel();        this.leftPanelHandler = sidePanelHandlerFactory();        this.leftPanelHandler.create('left', this.options.leftPanel);        this.leftPanelHandler.dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget));        this.leftPanelHandler.dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget));        this.rightPanelHandler = sidePanelHandlerFactory();        this.rightPanelHandler.create('right', this.options.rightPanel);        this.rightPanelHandler.dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget));        this.rightPanelHandler.dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget));        this.layout = this.createLayout();        this.tracker.currentChanged.connect(this.onCurrentChanged, this);        this.tracker.activeChanged.connect(this.onActiveChanged, this);    }    /**     * Assemble the application shell layout. Override this method in order to change the arrangement     * of the main area and the side panels.  Layout 创立     */    protected createLayout(): Layout {        const bottomSplitLayout = this.createSplitLayout(            [this.mainPanel, this.bottomPanel],            [1, 0],            { orientation: 'vertical', spacing: 0 }        );        const panelForBottomArea = new SplitPanel({ layout: bottomSplitLayout });        panelForBottomArea.id = 'theia-bottom-split-panel';        const leftRightSplitLayout = this.createSplitLayout(            [this.leftPanelHandler.container, panelForBottomArea, this.rightPanelHandler.container],            [0, 1, 0],            { orientation: 'horizontal', spacing: 0 }        );        const panelForSideAreas = new SplitPanel({ layout: leftRightSplitLayout });        panelForSideAreas.id = 'theia-left-right-split-panel';        return this.createBoxLayout(            [this.topPanel, panelForSideAreas, this.statusBar],            [0, 1, 0],            { direction: 'top-to-bottom', spacing: 0 }        );    }    /**     * Create the dock panel in the main shell area.  Panel 创立     */    protected createMainPanel(): TheiaDockPanel {        const renderer = this.dockPanelRendererFactory();        renderer.tabBarClasses.push(MAIN_BOTTOM_AREA_CLASS);        renderer.tabBarClasses.push(MAIN_AREA_CLASS);        const dockPanel = new TheiaDockPanel({            mode: 'multiple-document',            renderer,            spacing: 0        });        dockPanel.id = MAIN_AREA_ID;        dockPanel.widgetAdded.connect((_, widget) => this.fireDidAddWidget(widget));        dockPanel.widgetRemoved.connect((_, widget) => this.fireDidRemoveWidget(widget));        return dockPanel;    }}

Plugin API 里的 Widget 创立

Node/Browser API 的 Widget 创立:通过 WidgetFactory。

    bind(WidgetFactory).toDynamicValue(({ container }) => ({        id: PLUGIN_VIEW_DATA_FACTORY_ID,        createWidget: (identifier: TreeViewWidgetIdentifier) => {            const child = createTreeContainer(container, {                contextMenuPath: VIEW_ITEM_CONTEXT_MENU,                globalSelection: true            });            child.bind(TreeViewWidgetIdentifier).toConstantValue(identifier);            child.bind(PluginTree).toSelf();            child.rebind(TreeImpl).toService(PluginTree);            child.bind(PluginTreeModel).toSelf();            child.rebind(TreeModelImpl).toService(PluginTreeModel);            child.bind(TreeViewWidget).toSelf();            child.rebind(TreeWidget).toService(TreeViewWidget);            return child.get(TreeWidget);        }    })).inSingletonScope();    bind(WidgetFactory).toDynamicValue(({ container }) => ({        id: PLUGIN_VIEW_FACTORY_ID,        createWidget: (identifier: PluginViewWidgetIdentifier) => {            const child = container.createChild();            child.bind(PluginViewWidgetIdentifier).toConstantValue(identifier);            return child.get(PluginViewWidget);        }    })).inSingletonScope();    bind(WidgetFactory).toDynamicValue(({ container }) => ({        id: PLUGIN_VIEW_CONTAINER_FACTORY_ID,        createWidget: (identifier: ViewContainerIdentifier) =>            container.get<ViewContainer.Factory>(ViewContainer.Factory)(identifier)    })).inSingletonScope();

Packages

commands

Class CommandRegistry 治理命令汇合的对象。用于 CommandRegistry 类 statics 的命名空间。

命令注册表可用于填充各种 action-based widgets,如命令 palettes、menus 和 toolbars。

import { CommandRegistry } from '@phosphor/commands'const commands = new CommandRegistry()commands.addCommand('cut', {  label: 'Cut',  mnemonic: 1,  icon: 'fa fa-cut',  execute: () => {    console.log('Cut')  },})commands.addCommand('default-theme', {  label: 'Default theme',  mnemonic: 0,  icon: 'fa fa-paint-brush',  execute: () => {    console.log('Default theme')  },})let ctxt = new Menu({ commands })ctxt.addItem({ command: 'copy' })let toggle = new Toggle({ onLabel: 'Dark', offLabel: 'Light', command: 'dark-toggle', commands: commands })toggle.id = 'daylightToggle'bar.node.appendChild(toggle.node)

Widgets 与 React

将 React 组件封装成 Widget 组件

思路:

  1. extends Widget
  2. 在 onUpdateRequest 生命周期中ReactDOM.render JSX 到 widget node

而后当作自定义的 Widget 应用即可。

Theia 已提供形象组件 ReactWidgt 供参考:packages/core/src/browser/widgets/react-widget.tsx

/******************************************************************************** * Copyright (C) 2018 TypeFox and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at * http://www.eclipse.org/legal/epl-2.0. * * This Source Code may also be made available under the following Secondary * Licenses when the conditions for such availability set forth in the Eclipse * Public License v. 2.0 are satisfied: GNU General Public License, version 2 * with the GNU Classpath Exception which is available at * https://www.gnu.org/software/classpath/license.html. * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/import * as ReactDOM from 'react-dom';import * as React from 'react';import { injectable, unmanaged } from 'inversify';import { DisposableCollection, Disposable } from '../../common';import { BaseWidget, Message } from './widget';import { Widget } from '@phosphor/widgets';@injectable()export abstract class ReactWidget extends BaseWidget {    protected readonly onRender = new DisposableCollection();    constructor(@unmanaged() options?: Widget.IOptions) {        super(options);        this.scrollOptions = {            suppressScrollX: true,            minScrollbarLength: 35,        };        this.toDispose.push(Disposable.create(() => {            ReactDOM.unmountComponentAtNode(this.node);        }));    }    protected onUpdateRequest(msg: Message): void {        super.onUpdateRequest(msg);        ReactDOM.render(<React.Fragment>{this.render()}</React.Fragment>, this.node, () => this.onRender.dispose());    }    /**     * Render the React widget in the DOM.     * - If the widget has been previously rendered,     * any subsequent calls will perform an update and only     * change the DOM if absolutely necessary.     */    protected abstract render(): React.ReactNode;}

将 Widget 封装成 React 组件

  1. 创立 widget 组件
  2. 通过 React.createProtal() 将 this.props.children 渲染到 widget.node
  3. 将间接应用 React 组件
  4. 父组件通过 context 传递本身办法,在 componentDidMount 生命周期中,通过 parent.receiveChild(this.widget) 将以后组件 widget 渲染
import * as PropTypes from "prop-types";import * as React from "react";import {createPortal} from "react-dom";import {Widget} from "@phosphor/widgets/lib/widget";import {Title} from "@phosphor/widgets/lib/title";require("@phosphor/widgets/style/widget.css");import {WidgetParentContext, IWidgetParent} from "./Common";export interface IWidgetProps {  title?: Partial<Title.IOptions<Widget>>;}export default class ReactWidget extends React.PureComponent<IWidgetProps, {}> {  private widget: Widget;  // TODO: aah why isn't this working  // Some indication that this may be unstable (i.e. worked on 16.6.3 but not 16.6.1)  // https://stackoverflow.com/questions/53110121/react-new-context-api-not-working-with-class-contexttype-but-works-with-conte  static contextType = WidgetParentContext;  contextType = WidgetParentContext;  private storedContext: IWidgetParent;  constructor(props) {    super(props);    this.widget = new Widget();    ReactWidget.setTitleKeys(this.widget, {}, props);  }  componentDidMount() {    let parent = this.storedContext;    if (!parent) throw new Error("ReactWidget must be wrapped in a container component (BoxPanel, SplitPanel, etc.)");    parent.receiveChild(this.widget);  }  componentDidUpdate(prevProps: IWidgetProps) {    ReactWidget.setTitleKeys(this.widget, prevProps, this.props);  }  static setTitleKeys(widget: Widget, prevProps: IWidgetProps, props: IWidgetProps) {    let titleKeys: (keyof Title.IOptions<Widget>)[] = ["caption", "className", "closable", "dataset", "icon", "iconClass", "iconLabel", "label", "mnemonic"];    for (let k of titleKeys) {      if ((prevProps.title || {})[k as any] !== (props.title || {})[k as any]) {        widget.title[k as any] = props.title[k as any];      }    }  }  render() {    return createPortal(      <div>          <p>              <WidgetParentContext.Consumer>                  {(value) => { this.storedContext = value; return null; }}              </WidgetParentContext.Consumer>          </p>          {this.props.children}      </div>,      this.widget.node    );  }}

或者参考:Run a PhosphorJS DockerPanel with Widgets INSIDE a React component

参考

  • PhosphorJS Docs
  • PhosphorJS Examples
  • ermalism/phosphorjs-react-jsx-example
  • codedownio/react-phosphorjs
  • jupyterlab/lumino
  • Lumino API Documentation
  • Using Lumino in a Vue.js application