云隙随笔

技术 | TypeCell源码实现简要分析

发布于 # 学习笔记

TypeCell是一个类似Notion+Jupyter的TypeScript在线编辑文档,可以实时运行ts和react代码。本文主要分析渲染部分的源码实现。

核心技术栈

  • BlockNote:富文本编辑器
  • monaco-editor:类似vs code的代码编辑器和浏览器端的ts解析
  • mobx:状态管理
  • vscode-lib:复用了部分vscode的生命周期管理,相应代码待继续调研

项目架构

typecell
├── packages
│   ├── editor        - 主页面,WYSIWYG富文本编辑器
│   ├── engine        - 解析器和运行时,核心功能,后续会介绍
│   ├── frame         - 基于iframe的沙盒版本运行时,可以理解为codesandbox
│   ├── packager      - 打包器
│   ├── parsers       - 解析器,用于解析/转化Markdown和TypeCell格式
│   ├── server        - HocusPocus + Supabase后端

后续主要介绍engine

渲染引擎

ReactiveEngine.ts

自动运行注册模型的引擎,主要进行资源管理,例如注册和注销事件监听器和其他可释放资源。引擎会处理模型Model的代码,并为其提供一个上下文($),使得不同模型的代码可以交互。

  • 生命周期管理:vscode-lib
  • 代码执行:createCellEvaluator (见下个section)

CellEvaluator.ts

用于评估并运行在TypeCell环境中执行的代码,分为两步:

  • assignExecutionExports 将模块的导出经过处理后映射到TypeCell context
  • createCellEvaluator 创建了评估器对象,能够执行编译后的代码,并管理与TypeCell上下文相关的状态和回调。
    1. 通过getPatchedTypeCellCode将编译后的代码包在独立的函数之中
    2. 通过getModulesFromPatchedTypeCellCode 将上一步的代码与当前作用域结合,生成一个module
    3. 调用runModule传入module和回调,并执行代码
export function createCellEvaluator(
  typecellContext: TypeCellContext<any>, // TypeCell上下文
  resolveImport: (module: string) => Promise<any>, // 解析导入的函数
  setAndWatchOutput = true, // 是否设置和监控输出
  onOutputChanged: (output: any) => void, // 输出变化时的回调
  beforeExecuting: () => void // 执行前的回调
) {
  // ...
  const executionScope = createExecutionScope(typecellContext); // 创建执行作用域
  let moduleExecution: ModuleExecution | undefined; // 模块执行实例

  async function evaluate(compiledCode: string) {
    // 评估函数,用于执行编译后的代码
    if (moduleExecution) {
      moduleExecution.dispose(); // 如果已存在模块执行实例,先销毁它
    }

    try {
      // 将编译后的代码转换为可执行的模块
      const patchedCode = getPatchedTypeCellCode(compiledCode, executionScope);
      const modules = getModulesFromPatchedTypeCellCode(
        patchedCode,
        executionScope
      );

      if (modules.length !== 1) {
        throw new Error("expected exactly 1 module"); // 期望有且只有一个模块
      }

      // 执行模块并处理结果
      moduleExecution = await runModule(
        modules[0],
        typecellContext,
        resolveImport,
        beforeExecuting,
        onExecuted,
        onError,
        moduleExecution?.disposeVariables
      );
      await moduleExecution.initialRun;
    } catch (e) {
      console.error(e);
      onOutputChanged(e); // 发生错误时,通过回调通知
    }
  }

  return {
    evaluate, // 返回的评估器对象包含evaluate函数
    dispose: () => {
      // 评估器的销毁函数
      if (moduleExecution) {
        moduleExecution.dispose();
        moduleExecution.disposeVariables();
      }
    },
  };
}

补充:getPatchedTypeCellCode 实现

getPatchedTypeCellCode 将传入的编译后的代码(compiledCode)转换为一个可以在特定作用域(scope)下执行的模块化代码,通过添加额外的代码来确保编译后的代码可以作为模块运行。这个函数主要操作如下:

  1. 检查compiledCode是否已经包含了AMD(Asynchronous Module Definition)格式的模块定义代码。AMD格式的模块定义通常以define([], function() { /* module code */ });的形式出现。如果不包含,那么它会将整段代码包裹在一个define函数调用中,从而手动创建一个模块。
  2. 验证scope对象中的键是否都是有效的JavaScript变量名。如果发现无效的键名,则抛出错误。
  3. 构建一个字符串(variableImportCode),该字符串包含一系列的let声明,用于将scope对象中的每个键映射为局部变量。这样做的目的是让编译后的代码能够访问到这些作用域变量。
  4. define函数和variableImportCode添加到compiledCode前面,从而构建出完整的可执行代码(totalCode)。
  5. 使用正则表达式替换,将原有的同步define函数调用替换为异步的define函数调用。这样做可能是为了支持异步模块加载。
  6. 返回修改后的代码(totalCode),以便它可以作为一个模块在相应的执行上下文中运行。
export function getPatchedTypeCellCode(compiledCode: string, scope: any) {
  // Checks if define([], function) like code is already present
  if (!compiledCode.match(/(define\((".*", )?\[.*\], )function/gm)) {
    // file is not a module (no exports). Create module-like code manually
    compiledCode = `define([], function() { ${compiledCode}; });`;
  }

  if (Object.keys(scope).find((key) => !/^[a-zA-Z0-9_$]+$/.test(key))) {
    throw new Error("invalid key on scope!");
  }

  const variableImportCode = Object.keys(scope)
    .map((key) => `let ${key} = this.${key};`)
    .join("\n");

  let totalCode = `;
  let define = this.define;
  ${variableImportCode}
  ${compiledCode}
  `;

  totalCode = totalCode.replace(
    /^\s*(define\((".*", )?\[.*\], )function/gm,
    "$1async function"
  ); // TODO: remove await?

  return totalCode;
}

runModele()

在评估器内部的最终执行逻辑。

这个执行器runModule函数是一个异步函数,它的目的是在TypeCell环境中执行一个模块(mod)。它处理模块的依赖项,执行模块的工厂函数,并管理模块执行过程中的资源清理和错误处理。

核心运行步骤发生在:

try {
    executionPromise = mod.factoryFunction.apply(
        undefined,
        argsToCallFunctionWith,
    );
} finally {
    // ... 其他代码
}
await executionPromise;

在这里,mod.factoryFunction是编译后代码的一个函数,通常这个函数是由模块系统(比如Webpack或RequireJS)创建的。它封装了实际的代码,并且在执行时,可以通过.apply()方法被调用。.apply()方法允许你调用函数并显式设置函数内部的this值(在这个例子中设置为undefined),以及传入一个参数数组argsToCallFunctionWith,这些参数包括模块的依赖项,如exports对象和其他模块。

const f = new Function(code);

f.apply({ ...scope, define });

argsToCallFunctionWith数组是通过resolveDependencyArray函数解析得到的,它基于模块的依赖列表(mod.dependencyArray),并且通过resolveImport函数异步获取这些依赖。

在调用mod.factoryFunction.apply()之后,如果返回的是一个Promise,那么代码会等待此Promise完成。这意味着如果模块中的代码是异步的,它会被正确地等待和处理。

因此,具体的代码执行是在调用mod.factoryFunction.apply()时发生的,而不是通过eval()。这种方法遵循了现代JavaScript模块化实践,允许代码以安全、可控的方式在浏览器环境中运行。

module.ts

这段代码定义了一个处理JavaScript模块的机制。Module类型用于表示一个模块,其中包含模块名、依赖项数组和工厂函数。

getModulesFromWrappedPatchedTypeCellFunctiongetModulesFromPatchedTypeCellCode两个函数的目的是从不同的上下文中提取模块定义。前者接受一个函数caller,后者接受一个字符串code。两者都使用createDefine函数创建的define函数来注册模块。

createDefine函数返回一个typeCellDefine函数,该函数用于定义模块。当typeCellDefine被调用时,它会将模块信息(模块名、依赖项、工厂函数)推入到modules数组中,这个数组随后可以用于执行模块。

createExecutionScope函数创建一个包含TypeCell环境所需的作用域对象,通常包括一些MobX的函数(如autorununtrackedcomputedobservable),以及$$views对象,分别表示TypeCell上下文和视图上下文。

getPatchedTypeCellCode函数将编译后的代码转换成可以在特定作用域下执行的模块化代码。如果代码不是模块形式,它会手动包装代码以创建一个模块。同时,它会将作用域中的变量导入到执行环境中,并确保所有的define函数调用都转换为异步函数调用。

mod.factoryFunction.apply的含义

在上下文中,mod.factoryFunction是一个模块的工厂函数,它包含了模块的实际代码。apply方法是JavaScript中一个函数对象的方法,允许你调用这个函数,并指定函数执行时this的值以及传入的参数列表。

mod.factoryFunction.apply(undefined, argsToCallFunctionWith)被调用时,它执行以下操作:

  1. undefined作为this的值传入,这意味着在工厂函数内部,this将不指向任何对象。
  2. argsToCallFunctionWith是一个数组,包含了工厂函数需要的参数,如exports对象和其他模块依赖。

ts解析

使用了monaco-editor,即Visual Studio Code的编辑器组件,是在浏览器环境中运行并解析TypeScript代码。

注意:这个库也实现了通过请求 code.typescriptrepl.com 获取编译结果的方式。

getCompiledCodeInternal 函数通过调用monaco-editorTypeScriptWorker获取TypeScript的编译输出,包括.js.d.ts文件。它返回一个包含JavaScript代码和TypeScript声明文件内容的对象。 整个流程主要是在浏览器中通过monaco-editor的API和TypeScript的工作线程(Worker)来完成TypeScript代码到JavaScript代码的转换。

未来工作

一般主流的react浏览器端编译是靠 babel/standlone 实现的(例如obsidian-react插件),但是在项目源码中没有看到相关操作,待后续调研。