Skip to content

VScode 主题服务的实现 #3

@XDfield

Description

@XDfield

VSCode 作为当今大热的代码编辑器,其依赖前端技术与 electron 搭建成桌面软件,本质上为 web 应用。探索其源码,对开发 web 应用有较大帮助。其中,主题服务的实现,包含了自定义的事件机制监听改动,依赖注入获取全局服务对象的应用,动态修改组件样式与全局样式的方法,值得探讨。

VSCode 没有使用现成的前端框架,而是自己实现了各种基础类,甚至其各种 UI 元素。在页面布局中,像是标题栏,状态栏,侧边栏等各个部分(Part)被分在 src/vs/workbench/browser/parts 文件夹下。这些部分都带有主题样式动态修改的需求。

举个栗子

这里我们以 statusbar (状态栏) 为例:

export class StatusBarPart extends Part implements IStatusBarService, ISerializableView {
    // 其他私有属性
    // 负责插入 styleSheet 的元素
    private styleElement: HTMLStyleElement
    // 在构造函数中,利用依赖注入的方式将主题服务绑定到私有属性 themeService 上,并通过 super 传递给所继承的类(即 Part)
    constructor(
        id: string,
        @IThemeService themeService: IThemeService,
        @IStorageService storageService: IStorageService,
        // 其他参数
    ) {
        super(id, { hasTitle: false }, themeService, storageService)
        // 其他初始化方法
    }
    // 更新每个组件样式的函数
    protected updateStyles(): void {
        super.updateStyles();
        // 获取该组件的容器
        const container = this.getContainer();
        // 通过 style 属性更改元素样式颜色
        const backgroundColor = this.getColor('该部件的颜色id')
        container.style.backgroundColor = backgroundColor;
        // 省略其他同样通过 style 属性更改样式的操作
        if (!this.styleElement) {
            // 创建一个 styleSheet 元素
            this.styleElement = createStyleSheet(container)
        }
        // 直接改变 styleSheet 元素的 innerHTML 属性插入 css 样式。可见主要是为了改变伪类的样式
        this.styleElement.innerHTML = `.monaco-workbench .part.statusbar > .statusbar-item.has-beak:before { border-bottom-color: ${backgroundColor}; }`
    }
}

关于 Typescript 的依赖注入,能避免手动创建各个类所需的依赖项,具体细节以后会专门探讨。

这里我们说说常用的利用 js 来改变已有元素样式的方法:

// 一般先通过 document.querySelector 之类的方法拿到已有元素
let ele = document.querySelector('.app');
// 然后利用 style 属性来更改目标样式
ele.style.backgroundColor = '#e5e5e5'

但这种方法有个缺陷,不能改变伪类的样式。

什么是伪类?简单的说就是带冒号的。常见的伪类:

  • :hover 控制悬浮样式
  • :focus 控制获取焦点后的样式
  • :before / :after 前置与后置元素

伪类的样式无法通过 style 属性更改,一般只能利用 css 样式来变更。要动态的往页面中插入 css 样式表,来看下 updateStyles 方法中的 createStyleSheet 方法的实现:

位置 src/vs/base/browser/dom.ts

export function createStyleSheet(container: HTMLElement = document.getElementByTagName('head')[0]): HTMLStyleElement {
    let style = document.createElement('style');
    style.type = 'text/css';
    style.media = 'screen';
    container.appendChild('style');
    return style;
}

可见,该方法单纯向目标元素末尾,加入一个 <style /> 元素并返回。将返回的 style 元素作为组件的私有变量,所属组件通过改变其 innerHTML 属性达到动态变更 css 内容的目的。

深入一点

现在来分析一下主题变更的过程。沿着 StatusBarPart 的继承关系往上找,可以找到 Themable 类:

位置:src/vs/workbench/common/theme

export class Themable extends Disposable {
    protected theme: ITheme;
    constructor(protected themeService: IThemeService) {
        super();
        this.theme = themeSercice.getTheme()
        // 绑定当主题服务变更主题时,执行 onThemeChange 函数
        this._register(this.themeService.onThemeChange(theme => this.onThemeChange(theme)));
    }
    // 主题变更时
    protected onThemeChange(theme: ITheme): void {
        this.theme = theme;
        this.updateStyles();
    }
    protected updateStyles(): void { /* 由具体组件实现 */ }
    // 根据 id 返回具体颜色信息
    protected getColor(id: string, modify?: (color: Color, theme: ITheme) => Color): string | null {
        let color = this.theme.getColor(id);
        if (color && modify) {
            color = modify(color, this.theme);
        }
        return color ? color.toString() : null;
    }
}

Themable 类拥有一个 theme 对象,由 themeService.getTheme 获取,每次更换主题时,重新设置 theme 对象,并且各个组件执行 updateStyles 更新样式。

可见继承自 Themeable 的类都应该实现 updateStyles 方法,用于每次更新主题时调用,变更样式。

Themeable 类也提供了 getColor 方法,从属性 theme 中,按指定 id 获取颜色信息。

关注一下构造函数中的事件绑定:

this._register(this.themeService.onThemeChange(theme => this.onThemeChange(theme)));

这里分为两部分,第一部分是括号里的:

this.themeService.onThemeChange(theme => this.onThemeChange(theme))

themeService 对象的 onThemeChange 方法返回的其实是自定义的 Emitter 类的 event 属性,为一个自定义的 Event 实例。该 Event 实例接受一个回调函数 linstener: (e: T) => any ,返回一个带有 dispose 方法的对象。回调函数会被插入 Emitter 实例的回调函数链表中,当 Emitter 实例执行 fire 方法时,链表中的回调函数便会相继执行。

再看 this._register 方法,由于 Themable 类继承自 Disposable 类,_register 方法由 Disposable 类实现。

Disposable 是 VSCode 中定义的可手动执行清理的类,主要用于清理一些中间数据。内部维护一个 _toDispose 数组,_register 方法便是将带有 dispose 方法的对象加入该数组中。当 Disposable 类的实例执行 dispose 方法(也由 Disposable 类实现)时,_toDispose 数组中的各个项便会依次执行自己的 dispose 方法。

其主要是进行一些垃圾清理的工作。

关于 VSCode 的事件机制实现,会在以后专门探讨。

看下 ThemeService

ThemeServiceTheme 类的具体实现:

位置:src/vs/editor/standalone/browser/standaloneThemeServiceImpl.ts

// Theme 类
class StandaloneTheme implements IStandaloneTheme {
    public readonly id: string;
    public readonly themeName: string;  // 主题名
    
    private themeData: IStandaloneThemeData;  // 具体的颜色数据
    private colors: { [colorId: string]: Color } | null;  // 颜色映射
    private _tokenTheme: TokenTheme | null;  // 用于代码着色

    public getColor(colorId: ColorIdentifier, useDefault?: boolean): Color | undefined {}  // 获取指定 id 的颜色
    
    // other func
}

// ThemeService 类
class StandaloneThemeServiceImpl implements IStandaloneThemeService {
    private _knownThemes: Map<string, StandaloneTheme>;  // 已知主题
    private _theme: IStandaloneTheme;  // 当前主题
    private _styleElement: HTMLStyleElement;  // 主题的全局样式
    private _onThemeChange: Emitter<IStandaloneTheme>;  // 主题更改时的事件发射器
    // others
    constructor() {
        this._onThemeChange = new Emitter<IStandaloneTheme>();
        this._knownThemes = new Map<string, StandaloneTheme>();
        this._styleElement = dom.createStyleSheet();
        // others
    }
    // 获取主题改动时的事件
    public get onThemeChange(): Event<IStandaloneTheme> {
        return this._onThemeChange.event;  // 这里直接返回 Event 类型的实例
    }
    // 定义主题
    public defineTheme(themeName: string, themeData: IStandaloneThemeData): void {
        // 省略前面验证数据有效性的操作
        // 设置为已知主题或进行更新
        this._knowThemes.set(themeName, new StandaloneTheme(themeName, themeData));
        // 若定义的为基础主题,其他继承该基础主题的主题进行基础更新
        if (isBuiltinTheme(themeName)) {
            this._knowThemes.forEach(theme => {
                if (theme.base === themeName) {
                    theme.notifyBaseUpdated();
                }
            })
        }
        // 若定义的为当前使用的主题,则刷新一下主题
        if (this._theme && this._theme.themeName === themeName) {
            this.setTheme(themeName)
        }
    }
    // 获取当前主题
    public getTheme(): IStandaloneTheme {
        return this._theme;
    }
    // 设置主题
    public setTheme(themeName: string): string {
        let theme: StandaloneTheme;
        if (this._knownThemes.has(themeName)) {
            theme = this._knownThemes.get(themeName)!;
        } else {
            // 若设置的为非已知主题,则取默认主题
            theme = this._knownThemes.get(VS_THEME_NAME)!;
        }
        if (this._theme === theme) {
            return theme.id;  // 重复设置已用主题的话,仅返回主题id
        }
        this._theme = theme;
        // 以下是一些全局的样式设置
        let cssRules: string[] = [];  // 最终样式表
        let hasRule: { [rule: string]: boolean; } = {};  // 确保不添加重复样式
        let ruleCollector: ICssStyleCollector = {
            addRule: (rule: string) => {
                if (!hasRule[rule]) {
                    cssRules.push(rule);
                    hasRule[rule] = true;
                }
            }
        };
        // 辅助转换主题到css样式的方法
        themingRegistry.getThemingParticipants().forEach(p => p(theme, ruleCollector, this.environment));
        // 省略代码高亮相关样式的添加 ...
        // 插入样式到页面中
        this._styleElement.innerHTML = cssRule.join('\n');
        // 设置颜色映射
        TokenizationRegistry.setColorMap(colorMap);
        // 触发主题更改的事件,参数为新主题对象
        this._onThemeChange.fire(theme);
        return theme.id
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions