FrontendApplication.start()
構建的Theia 框架前端 UI 佈局和 Services 同樣,具有靈活可拓展的特色。VSCode 是內置了一套基本的組件系統,而 Theia 框架的 UI 佈局基於 PhosphorJS 框架。 PhosphorJS 提供了包含 widgets、layouts、事件和數據結構的豐富工具包。這使得開發人員可以構建可擴展的、高性能的、類桌面的 Web 應用程序,好比 JupyterLab。css
PhosphorJS 做者退休,項目已歸檔,該項目如今被 Jupyter 團隊重命名爲 jupyterlab/lumino 繼續維護。見 issue: https://github.com/jupyterlab...
在 PhosphorJS 裏運行 React 代碼:ermalism/phosphorjs-react-jsx-example前端
PhosphorJS 佈局的核心就在於 Widget。vue
這裏的 Widget 和 Flutter 裏面的 Widget 還不同,Flutter 的 Widget 屬於聲明式 UI(declarative UI),而 PhosphorJS 的 Widget 更像是命令式 UI(imperative UI)。和 Chrome 開發者工具 ChromeDevTools/devtools-frontend 的 Widget 更相似。node
關於聲明式和命令式 UI 框架也能夠閱讀:聊聊我對現代前端框架的認知 做爲補充。react
官方提供了一系列 Widget 的繼承實現:git
Widgetgithub
- 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 接口。docker
Widget 包含如下狀態:isDisposed、isAttached、isHidden、isVisible,以及一系列事件驅動的鉤子:onCloseRequest、onResize、onUpdateRequest、onFitRequest、onActivateRequest、onBeforeShow、onBeforeHide、onBeforeAttach、onBeforeDetach、onChildAdded 等。shell
渲染的核心的方法在於 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 前端頁面的啓動很是簡單:
function start() { (window['theia'] = window['theia'] || {}).container = container; const themeService = ThemeService.get(); themeService.loadUserTheme(); const application = container.get(FrontendApplication); return application.start(); }
能夠看到核心就在於 FrontendApplication.start()
方法,那麼這個方法裏作了什麼?
// 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); } }
主要分爲: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; } }
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();
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)
思路:
而後看成自定義的 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; }
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