Garfish 微前端实现原理

avatar
前端工程师 @公众号:ELab团队

近期有落地一些微前端业务场景,也遇到一些问题,看了下他们的实现发现目前无论是garfish还是qiankun对于这一块的实现都在不断的完善中,但是qiankun我也看了一下他们的实现,在一些case的处理上较garfish存在一定不足。所以本次是针对garfish的实现分析。下面会从资源加载入口,资源解析,沙箱环境,代码执行四大块进行分析,了解微前端的主要实现逻辑。

下文中对比qiankun的版本为2.4.0,如有不正确的还请评论指正。

文中涉及大量的代码分析,希望能够从实现层面更加直接的看出实现的逻辑,而不是通过几张图来解释概念。

如何解析资源(html入口)

获取资源内容

根据提供的url作为入口文件加载资源。加载的实现很简单,通过fetch拿到资源内容,如果是html资源入口会进行标签的序列化和相关处理,这个后面会看到。如果是js文件则会直接实例化一个js资源类,目的是保存加载到资源的类型,大小,代码字符串等基本信息。并会尝试缓存加载的资源。

下面是加载各类资源的实现,比如获取html文件、js文件、css文件。在整个流程中,这个方法会被多次使用来加载资源。

  // 加载任意的资源,但是都是会转为 string

  load(url: string, config?: RequestInit) {

 // 移除了部分代码只保留说明性的部分

      config = { mode: 'cors', ...config, ...requestConfig };

      this.loadings[url] = fetch(url, config)

        .then((res) => {

          // 响应码大于 400 的当做错误

          if (res.status >= 400) {

            error(`load failed with status "${res.status}"`);

          }

          const type = res.headers.get('content-type');

          return res.text().then((code) => ({ code, type, res }));

        })

        .then(({ code, type, res }) => {

          let manager;

          const blob = new Blob([code]);

          const size = Number(blob.size);

          const ft = parseContentType(type);

          // 对加载的资源进行分类处理

          // 下方new 的几个实例的目的都是保存代码块字符串和资源类型等一些基本信息

          if (isJs(ft) || /.js/.test(res.url)) {

            manager = new JsResource({ url, code, size, attributes: [] });

          } else if (isHtml(ft) || /.html/.test(res.url)) {

            manager = new HtmlResource({ url, code, size });

          } else if (isCss(ft) || /.css/.test(res.url)) {

            manager = new CssResource({ url, code, size });

          } else {

            error(`Invalid resource type "${type}"`);

          }



        // 所有的请求会存在一个promise map来维护,加载完成后清空

          this.loadings[url] = null;

          currentSize += isNaN(size) ? 0 : size;

          if (!isOverCapacity(currentSize) || this.forceCaches.has(url)) {

            // 尝试缓存加载的资源

            this.caches[url] = manager;

          }

          return manager;

        })

        .catch((e) => {

          const message = e instanceof Error ? e.message : String(e);

          error(`${message}, url: "${url}"`);

        });

      return this.loadings[url];

    }

  }

在html入口被加载的时候,这个方法便帮助我们获取到了入口html文件内容,接下载需要解析这个html文件。

序列化DOM树

因为html入口比较特殊,下面单独对这部分进行分析。如何解析并处理html文件的呢。首先我们在上一步获得了资源的文件内容。下一步是对加载的html资源进行ast解析,结构化dom,以便提取不同类型的标签内容。这里使用到了 himalaya 这个辅助库。在线尝试地址jew.ski/himalaya/, 解析内容格式如下,将dom文本解析文json结构。

结构化后进行深度优先遍历把link,style,script标签提取出来

// 调用方式

this.queryVNodesByTagNames(['link', 'style', 'script']) 



// 具体实现

// 实现代码截取 其中this.ast就是上面演示的parse的结果

private queryVNodesByTagNames(tagNames: Array<string>) {

    const res: Record<string, Array<VNode>> = {};

    for (const tagName of tagNames) {

      res[tagName] = [];

    }

    const traverse = (vnode: VNode | VText) => {

      if (vnode.type === 'element') {

        const { tagName, children } = vnode;

        if (tagNames.indexOf(tagName) > -1) {

          res[tagName].push(vnode);

        }

        children.forEach((vnode) => traverse(vnode));

      }

    };

    this.ast.forEach((vnode) => traverse(vnode));

    return res;

  }

由于当前各个框架的实现基本都是有js生成dom并挂载到指定的元素上,因此这里只要把这三种加载资源的标签提取出来基本就完成了页面的加载。当然还需要配合微前端的加载方式改造下子系统入口,让挂载函数指向主应用提供的dom。至此我们完成了基本资源的提取。

构建运行环境

接下来就是实例化当前子应用了。我们需要子应用的运行时独立的环境不影响主应用的代码。因此子应用需要在指定的沙箱内运行,这也是微前端实现的核心部分。首先看下实例化子应用的代码

  // 每个子引用都会通过这个方法来实例化

  private createApp(

    appInfo: AppInfo,

    opts: LoadAppOptions,

    manager: HtmlResource,

    isHtmlMode: boolean,

  ) {

    const run = (resources: ResourceModules) => {

      // 这里是获取沙箱环境

      let AppCtor = opts.sandbox.snapshot ? SnapshotApp : App;

      if (!window.Proxy) {

        warn(

          'Since proxy is not supported, the sandbox is downgraded to snapshot sandbox',

        );

        AppCtor = SnapshotApp;

      }

      // 将app在沙箱内实例化以保证独立运行

      const app = new AppCtor(

        this.context,

        appInfo,

        opts,

        manager,

        resources, // 提供的html入口

        isHtmlMode,

      );

      this.context.emit(CREATE_APP, app);

      return app;

    };



    // 如果是 html, 就需要加载用到的资源

    const mjs = Promise.all(this.takeJsResources(manager as HtmlResource));

    const mlink = Promise.all(this.takeLinkResources(manager as HtmlResource));

    return Promise.all([mjs, mlink]).then(([js, link]) => run({ js, link }));

  }

这里只需要大致看一下一个子应用的大致创建和加载流程,基本就是一个上下文,一些资源信息。具体细节后续可以看看源码串下整体流程。接下来看下应用的运行上下文——沙箱的实现

代码的执行

在获取资源内容一节我们已经对script资源的获取进行了解析。但是这个部分代码具体是如何在沙箱环境执行的呢,在实例化app时会有一个方法execScript,实现如下,其中的code参数就是我们script获取的代码字符串。

  execScript(

    code: string,

    url?: string,

    options?: { async?: boolean; noEntry?: boolean },

  ) {

    try {

      (this.sandbox as Sandbox).execScript(code, url, options);

    } catch (e) {

      this.context.emit(ERROR_COMPILE_APP, this, e);

      throw e;

    }

  }

可以看到这部分的实现调用了沙箱中的execScript,这里先说下前置知识,基本所有的沙箱环境的代码执行都会使用with这个语法来处理代码的执行上下文,并且有着天然的优势。在vue中处理模板中访问变量this关键字的方式也采用了这个方式。

接下来看下具体的实现。

  execScript(code: string, url = '', options?: ExecScriptOptions) {

    // 省略一些次要代码,保留核心逻辑

    // 这里的context就是我们上面创建的代理window

    const context = this.context;

    const refs = { url, code, context };



    // 这一步是创建一个script标签如果url存在,src为给定的url,否则code放到标签体内

    // 返回值为清空这个script 元素的引用函数

    const revertCurrentScript = setDocCurrentScript(this, code, url, async);



    try {

      const sourceUrl = url ? `//# sourceURL=${url}\n` : '';

      let code = `${refs.code}\n${sourceUrl}`;



      if (this.options.openSandbox) {

        // 如果是非严格模式则需要with包裹保证内部代码执行的上下文为代理后的window

        code = !this.options.useStrict

          ? `with(window) {;${this.attachedCode + code}}`

          : code;

        // 这个函数构造了代码执行环境

        evalWithEnv(code, {

          window: refs.context,

          ...this.overrideContext.overrides,

          unstable_sandbox: this,

        });

      } 

    } 



    revertCurrentScript();



    if (noEntry) {

      refs.context.module = this.overrideContext.overrides.module;

      refs.context.exports = context.module.exports;

    }

  }

接下来看下evalWithEnv的实现逻辑,这个函数的执行逻辑也很简单,就是把我们的代码内容放到一个构造出来的上下文中执行,上下文中的window,document等对象都是我们重写和代理过的,因此保证了环境的隔离。

export function internFunc(internalizeString) {

  const temporaryOb = {};

  temporaryOb[internalizeString] = true;

  return Object.keys(temporaryOb)[0];

}



export function evalWithEnv(code: string, params: Record<string, any>) {

  const keys = Object.keys(params);

  // 不可使用随机值,否则无法作为常量字符串复用

  // 将我们代理过的全局变量挂到一个指定属性下

  const randomValKey = '__garfish__exec_temporary__';



  const vals = keys.map((k) => `window.${randomValKey}.${k}`);

  try {

    rawWindow[randomValKey] = params;

    // 数组首尾元素中间就是我们代码实际运行的位置

    // 可以看到首先绑定代理过的window作为上下文,然后参数指定了我们代理和重写的对象,

    // 这样代码内获取注入document对象时其实已经是代理过的了

    const evalInfo = [

      `;(function(${keys.join(',')}){`,

      `\n}).call(${vals[0]},${vals.join(',')});`,

    ];

    const internalizeString = internFunc(evalInfo[0] + code + evalInfo[1]);

    // (0, eval) 这个表达式会让 eval 在全局作用域下执行

    (0, eval)(internalizeString);

  } finally {

    delete rawWindow[randomValKey];

  }

}

到这里我们知道代码的执行环境使我们代理的window和重写的方法构造的,配合上面的with语句的特性则可以解决变量提升相关的问题。到这里我们完成了代码从加载到执行的路径分析。

结语

上面的分析大多为了讲解基本思路,阐述微前端的基本实现思想,在实际的执行过程中会有很多其他逻辑的判断以及加载优化,如果有兴趣的可以参考源码实现。目前garfish也在不断的完善过程中,因为很多场景需要用户验证,开发能考虑到的业务case毕竟有限,在写这篇文章的时候每天都会有近百个commit提交更新过来。可以看到优化场景还是挺多的。总的来说微前端确实很大程度上解决了项目迁移难,技术升级慢和难维护项目的问题。如果有上述痛点是可以尝试一下的。

Garfish 开源链接:github.com/modern-js-d…