Garfish 微前端實現原理

近期有落地一些微前端業務場景,也遇到一些問題,看了下他們的實現發現目前無論是 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 這個輔助庫。在線嘗試地址 https://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 開源鏈接:https://github.com/modern-js-dev/garfish

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5t1RpPXVCGCPlxGBmxUGbg