Atom源码阅读系列(1)

1,626 阅读8分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

Atom是一个著名的开源编辑器,是由Chris Wanstrath在2008年作为其个人的编外项目发展而来。据说在今年(2022)年底,这款编辑器也将进入关停状态。而且目前大部分程序员都把VS Code作为其最主要的开发工作,但是Atom本身的设计和代码实现都是非常优秀的,通过阅读它的源码,我们还是可以学到很多相关的编程技巧。

主流程分析

atom的代码结构非常清晰,整个项目可以分为两个部分,一个是atom本身的代码,另一个是atom的插件。atom本身的代码又可以分为两部分,一个是atom的核心业务逻辑,另一个是atom的UI代码。核心业务逻辑主要是用来设置环境变量,调度窗口、调度系统资源等等。UI代码则主要负责处理atom的界面,比如菜单栏,工具栏,状态栏等等。

作为使用electron框架编写的应用程序,整体都是使用js来写的(早期是使用coffee来编写的),可以从其目录中看到,整个项目的目录结构如下:

|-src // 核心业务逻辑
|-|-main-process
|-|-|-atom-application.js
|-|-|-atom-environment.js
|-|-|-atom-window.js
|-static // UI代码
|-packages // 其它扩展包
...

众所周知,用Electron框架写成的应用,都可以分为主线程和渲染进程。对应到atom中,主线程的代码都是在src/main-process目录下,而渲染线程的代码则是直接src目录下。静态UI资源则在static目录下。

我们先从主线程的入口代码开始看起,代码位于 src/main-process/main.js路径下:

// 命令行工具入口,
const args = yargs(process.argv)
  // Don't handle --help or --version here; they will be handled later.
  .help(false)
  .version(false)
  .alias('d', 'dev')
  .alias('t', 'test')
  .alias('r', 'resource-path').argv;
  
// 下面省略大量代码,主要用于处理命令行参数,用来专门处理使用命令行打开atom的情况

// 真正的入口
const start = require(path.join(resourcePath, 'src', 'main-process', 'start'));
start(resourcePath, devResourcePath, startTime);

可以从上面代码看出,其实真正的处理入口还是在start函数中(src/main-process/start.js):

module.exports = function start(resourcePath, devResourcePath, startTime) {
  // 处理错误情况
  process.on('uncaughtException', function(error = {}) {
  });
  process.on('unhandledRejection', function(error = {}) {
  });

  // 初始化各种参数
  app.commandLine.appendSwitch('enable-experimental-web-platform-features');
  const args = parseCommandLine(process.argv.slice(1));
  const previousConsoleLog = console.log;
  console.log = nslog;

  args.resourcePath = normalizeDriveLetterName(resourcePath);
  args.devResourcePath = normalizeDriveLetterName(devResourcePath);

  atomPaths.setAtomHome(app.getPath('home'));
  atomPaths.setUserData(app);

  const config = getConfig();
  const colorProfile = config.get('core.colorProfile');
  if (colorProfile && colorProfile !== 'default') {
    app.commandLine.appendSwitch('force-color-profile', colorProfile);
  }

  if (handleStartupEventWithSquirrel()) {
    return;
  } else if (args.test && args.mainProcess) {
    // 处理测试情况
    app.setPath(
      'userData',
      temp.mkdirSync('atom-user-data-dir-for-main-process-tests')
    );
    console.log = previousConsoleLog;
    app.on('ready', function() {
      const testRunner = require(path.join(
        args.resourcePath,
        'spec/main-process/mocha-test-runner'
      ));
      testRunner(args.pathsToOpen);
    });
    return;
  }

  const releaseChannel = getReleaseChannel(app.getVersion());
  let appUserModelId = 'com.squirrel.atom.' + process.arch;
  if (releaseChannel !== 'stable') {
    appUserModelId += `-${releaseChannel}`;
  }

  // 这个方法可以防止win10在任务栏中显示重复的atom图标
  app.setAppUserModelId(appUserModelId);

  app.on('open-file', addPathToOpen);
  app.on('open-url', addUrlToOpen);
  // 当应用关闭的时候,需要上报一些数据
  app.on('will-finish-launching', () =>
    startCrashReporter({
      uploadToServer: config.get('core.telemetryConsent') === 'limited',
      releaseChannel
    })
  );

  if (args.userDataDir != null) {
    app.setPath('userData', args.userDataDir);
  } else if (args.test || args.benchmark || args.benchmarkTest) {
    app.setPath('userData', temp.mkdirSync('atom-test-data'));
  }

  app.on('ready', function() {
    app.removeListener('open-file', addPathToOpen);
    app.removeListener('open-url', addUrlToOpen);
    // 构造一个atomApplication对象
    const AtomApplication = require(path.join(
      args.resourcePath,
      'src',
      'main-process',
      'atom-application'
    ));
    // 并将之前的参数传入
    AtomApplication.open(args);
  });
};

从上面代码可以看出,前置处理也是各种参数的初始化,以及为了便于测试,做的一些定制处理。在应用初始化结束后,就会动态加载应用模块,构造 AtomApplication 实例。可以注意到,这里使用按需加载的目的是希望能够在需要的时候才会去加载对应的模块,这样可以减少内存的占用。

接着,我们来看一下atom-application.js的代码,这块代码量比较大,是整个atom的核心代码,我们先来看一下整体的结构:

// 是一个单列模式, 继承自`EventEmitter`模块,主要因为内部会大量应用事件处理机制来分发逻辑。
class AtomApplication extends EventEmitter {
    static open(options) {
        // 初始化一些参数
        // 创建一个atomApplication对象
        // 并将之前的参数传入
        return new AtomApplication(options);
    }
  
    exit(status) {
    	app.exit(status);
    }
  
    constructor(options){}
  
    async initialize(options) {}

}

程序启动的入口只有AtomApplication.open这一个方法,这个方法会创建一个AtomApplication对象,然后调用它的initialize方法,层层递进,再调用创建窗口、加载配置等方法,最终完成程序的启动。

其中,比较指的注意的是使用了一个叫做event-kit的模块。它是一个事件处理器模块,提供了一个事件处理器的抽象,可以让我们更容易地处理事件。最重要的作用是它实现了CompositeDisposable类,可以在需要的时候,释放资源。虽然javascript是一个有垃圾回收机制的语言,但是如果没有手动释放一些资源的话,会造成大量的内存占用。

在使用过程中,也十分简单

class AtomApplication extends EventEmitter {
  // 省略其它代码
  constructor(options) {
      // 省略其它代码
      this.disposable = new CompositeDisposable();
  }
  
  async destroy() {
    const windowsClosePromises = this.getAllWindows().map(window => {
      window.close();
      return window.closedPromise;
    });
    await Promise.all(windowsClosePromises);
    // 在销毁的时候统一释放
    this.disposable.dispose();
  }
  
  // 注册事件处理函数
  handleEvents() {
  	// 省略其它代码,
    // 在注册事件回调的时候,直接将对象添加到disposable的依赖中去
    this.disposable.add(
      ipcHelpers.on(app, 'before-quit', async event => {...})
    );
  }
}

扩展机制

atom作为一个编辑器,它的扩展机制是非常重要的。和其他的IDE类似,扩展机制也是使用的微内核模式(或者插件模式)来实现。微内核架构是一种十分常见的软件架构,它将应用系统分为两个部分:一个微内核和一组外部的插件。微内核负责管理插件,提供插件之间的通信机制,以及提供一些基础的服务。插件则负责提供具体的功能。这样的架构可以让我们更容易地扩展软件的功能,而不需要修改软件的核心代码。

在atom中,插件主要是通过package类来实现的。package是atom扩展的基本单元,它可以包含一些功能,比如语法高亮、代码提示、代码格式化等等,也提供了让第三方开发者扩展的能力。那么这些扩展是如何加载的呢?我们先来看一下package-manager.js的代码:

// 可以加载、激活、停用、卸载包
// 加载包读取并解析包的元数据和资源,例如快捷键、菜单、样式表等
// 激活包注册加载的资源并调用包的主模块的`activate()`方法
// 停用包取消注册包的资源并调用包的主模块的`deactivate()`方法
// 卸载包从包管理器中完全移除
// 可以通过`core.disabledPackages`配置项和调用`enablePackage()/disablePackage()`方法来启用/禁用包
class PackageManager {
  preloadPackage(packageName, pack) {
    ...
  }
  loadPackages() {
    ...
  }
  enablePackage(packageName) {
    ...
  }
  // 触发事件,用来注册回调
  onDidActivatePackage(callback) {
  }
}

这个包管理器类PackageManager,可以管理扩展包的整个生命周期,主要负责包的加载、卸载、更新等操作。而所有的包都绑定在主内核的atom.packages这个全局变量上,我们可以通过这个变量来访问应用上加载的所有扩展。

那么packageManager是如何负责管理包的安装和卸载呢?:

class PackageManager {
    constructor(packages) {
        this.packages = packages;
    }
    getPackages() {
        return this.packages;
    }
    getPackage(name) {
        return this.packages.find(pkg => pkg.name === name);
    }
    // 禁用包,从内存中将包去除,然后通知应用程序或者扩展来执行禁用操作
    async deactivatePackage(name, suppressSerialization) {
        const pack = this.getLoadedPackage(name);
        if (pack == null) {
          return;
        }

        if (!suppressSerialization && this.isPackageActive(pack.name)) {
          this.serializePackage(pack);
        }

        const deactivationResult = pack.deactivate();
        if (deactivationResult && typeof deactivationResult.then === 'function') {
          await deactivationResult;
        }

        delete this.activePackages[pack.name];
        delete this.activatingPackages[pack.name];
        this.emitter.emit('did-deactivate-package', pack);
      }
}

比如在扩展ui-watcher中,就可以在监听到did-deactivate-package事件后,执行一些清理操作:

watchForPackageChanges() {
    this.subscriptions.add(
      atom.packages.onDidDeactivatePackage(pack => {
        const watcher = this.watchedPackages.get(pack.name);
        if (watcher) watcher.destroy();
        this.watchedPackages.delete(pack.name);
      })
    );
}

Package类,则包含了包的基础信息,包括键位设置、配置、样式等,并且有完整的生命周期。


class Package {
    constructor(params) {
      this.config = params.config;
      this.packageManager = params.packageManager;
      this.styleManager = params.styleManager;
      this.commandRegistry = params.commandRegistry;
      this.keymapManager = params.keymapManager;
      this.notificationManager = params.notificationManager;
      this.grammarRegistry = params.grammarRegistry;
      this.themeManager = params.themeManager;
      this.menuManager = params.menuManager;
      this.contextMenuManager = params.contextMenuManager;
      this.deserializerManager = params.deserializerManager;
      this.viewRegistry = params.viewRegistry;
      this.emitter = new Emitter();
      // 此处省略大量的细节
    }
    preload() {
        // do something
    }
    load() {
        // do something
    }
    unload() {
        // do something
    }
    activate() {
        // do something
    }
    deactivate() {
        // do something
    }
    finishLoading() {
        // do something
    }
}

Package(扩展)实例本身要和主应用进行通信,atom是直接通过全局对象的方式进行调用的,这样做的好处是不用考虑通信的问题,但是也有一些弊端,比如不方便重构等。

在应用入口处,会将PackageManager实例挂载在应用实例上。后续我们可以通过atom.packages来访问包管理器实例,从而获取包的信息。

// atom-application.js
this.packages = new PackageManager({
    ... // 一堆的配置
});

this.packages.initialize(...);

而在渲染进程中,可以通过在window上挂载atom对象来访问包管理器实例,从而获取所有扩展包的信息,进行预加载操作。

// initialize-application-window.js

/ 初始化 AtomEnvironment
global.atom = new AtomEnvironment({
  clipboard,
  applicationDelegate: new ApplicationDelegate(),
  enablePersistence: true
});

TextEditor.setScheduler(global.atom.views);
// 初始化应用窗口
global.atom.preloadPackages();

// ... 省略大量代码

module.exports = function({ blobStore }) {
 // 省略大量代码

 // 在startEditorWindows内部,当窗口初始化完成后,会正式调用`loadPackages`方法来加载所有的扩展包
 return global.atom.startEditorWindow().then(function() {
    const windowFocused = function() {
      window.removeEventListener('focus', windowFocused);
      setTimeout(() => document.querySelector('atom-workspace').focus(), 0);
    };

    window.addEventListener('focus', windowFocused);

    ipcRenderer.on('environment', (event, env) => updateProcessEnv(env));
  });
}

总体而言,atom的扩展机制还是比较简单的,在各种扩展的生命周期中,都可以通过事件来进行通信,从而实现各种功能。这样一种实现,其实也可以在我们日常工作过程中加以借鉴。