Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Theia 技术揭秘之布局系统 #84

Open
Pines-Cheng opened this issue Dec 7, 2020 · 2 comments
Open

Theia 技术揭秘之布局系统 #84

Pines-Cheng opened this issue Dec 7, 2020 · 2 comments
Labels

Comments

@Pines-Cheng
Copy link
Owner

Pines-Cheng commented Dec 7, 2020

  • 基于 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:jupyterlab/frontends-team-compass#28

效果

example-dockpanel

image

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

image

Widget

PhosphorJS 布局的核心就在于 Widget

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

关于声明式和命令式 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

参考

@Pines-Cheng Pines-Cheng added the IDE label Dec 7, 2020
@Pines-Cheng
Copy link
Owner Author

@Pines-Cheng
Copy link
Owner Author

Pines-Cheng commented Mar 7, 2023

split

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant