共计 14101 个字符,预计需要花费 36 分钟才能阅读完成。
- 基于 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 组件
思路:
- extends Widget
- 在 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 组件
- 创立 widget 组件
- 通过 React.createProtal() 将 this.props.children 渲染到 widget.node
- 将间接应用 React 组件
- 父组件通过 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