module federation -- 加载原理

968 阅读5分钟

项目A提供remote

项目B提使用remote

这里使用vite @originjs/vite-plugin-federation为例

源码

项目A

// index 提供的方法
const version = '1.0.0';

const name = 'project2'

const getVersion = () => version

const getName = () => name

export {
  version,
  name
}

export default {
  getVersion,
  getName
}


// vite配置
federation({
  name: 'module-name',
  filename: 'remoteEntry.js',
  exposes: {
    './project2': './src/packages/index.ts',
  },
})

项目B

// vite配置
federation({
  remotes: {
    'remotes': "http://localhost:5001/assets/remoteEntry.js",
  }
}),

// 使用
import project2, {version} from 'remotes/project2'
console.log(project2, project2.getVersion(), version) // 打印出A提供的变量和方法

构建的结果

项目A

// remoteEntry.js 入口问价
// 这里构建的结果任然是es6 module形式

// __vitePreload 加载资源的方法
import { _ as __vitePreload } from './preload-helper.1c052cf7.js';

const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);

// 入口
let moduleMap = {
  "./project2":() => {
      dynamicLoadingCss([]);
      // // 兼容处理,判断是直接返回module还是module.default 这里是具体的代码,会在remoteEntry中做加载
      return __federation_import('./__federation_expose_Project2_39d66529.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)
  }
};

// 缓存加载的文件
const seen = {};

// 加载css文件
const dynamicLoadingCss = (cssFilePaths) => {
      const metaUrl = import.meta.url; // 这里是remoteEntre.js的路径
      if (typeof metaUrl == 'undefined') { // 兼容问题,es必须>=2020版本
        console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".');
        return
      }
      // 通过截取获取base路径
      const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));

      // 循环创建link标签加载css
      cssFilePaths.forEach(cssFilePath => {
        const href = curUrl + cssFilePath;
        // 判断是否加载过
        if (href in seen) return
        seen[href] = true;
        const element = document.head.appendChild(document.createElement('link'));
        element.href = href;
        element.rel = 'stylesheet';
      });
};

async function __federation_import(name) {
    return __vitePreload(() => import(name),true?[]:void 0);
}

const get =(module) => {
    return moduleMap[module]();
};

// 加载外部依赖
const init =(shareScope) => {
  // 这里globalThis === window https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/globalThis
  // __federation_shared__是挂在在全局(window)上的一个变量用来记录已加载的shared资源
  globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
  Object.entries(shareScope).forEach(([key, value]) => {
    const versionKey = Object.keys(value)[0];
    const versionValue = Object.values(value)[0];
    const scope = versionValue.scope || 'default';
    globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
    const shared= globalThis.__federation_shared__[scope];
    (shared[key] = shared[key]||{})[versionKey] = versionValue;
  });
};

// 这里暴露了三个方法,会在B应用中使用	
export { dynamicLoadingCss, get, init };


// __federation_expose_Project2_39d66529.js
// 编写的代码的构建结果
const version = "1.0.0";
const name = "project2";
const getVersion = () => version;
const getName = () => name;
var index = {
  getVersion,
  getName
};

export { index as default, name, version };

项目B

const p = function polyfill() {
  const relList = document.createElement('link').relList;
  if (relList && relList.supports && relList.supports('modulepreload')) {
    return;
  }
  for (const link of document.querySelectorAll('link[rel="modulepreload"]')) {
    processPreload(link);
  }
  new MutationObserver((mutations) => {
      for (const mutation of mutations) {
          if (mutation.type !== 'childList') {
              continue;
          }
          for (const node of mutation.addedNodes) {
              if (node.tagName === 'LINK' && node.rel === 'modulepreload')
                  processPreload(node);
          }
      }
  }).observe(document, { childList: true, subtree: true });

  function getFetchOpts(script) {
      const fetchOpts = {};
      if (script.integrity)
          fetchOpts.integrity = script.integrity;
      if (script.referrerpolicy)
          fetchOpts.referrerPolicy = script.referrerpolicy;
      if (script.crossorigin === 'use-credentials')
          fetchOpts.credentials = 'include';
      else if (script.crossorigin === 'anonymous')
          fetchOpts.credentials = 'omit';
      else
          fetchOpts.credentials = 'same-origin';
      return fetchOpts;
  }
  function processPreload(link) {
      if (link.ep)
          // ep marker = processed
          return;
      link.ep = true;
      // prepopulate the load record
      const fetchOpts = getFetchOpts(link);
      fetch(link.href, fetchOpts); // 预加载
  }
};

p();

const scriptRel = 'modulepreload';
const seen = {}; // 缓存
const base = '/';
 
// 加载remote 通过link标签提前加载缓存下来
const __vitePreload = function preload(baseModule, deps) {
  debugger
    // @ts-ignore
    if (!true || !deps || deps.length === 0) {
        return baseModule();
    }
    return Promise.all(deps.map((dep) => {
      debugger
        // @ts-ignore
        dep = `${base}${dep}`;
        // @ts-ignore
        if (dep in seen)
            return;
        // @ts-ignore
        seen[dep] = true;
        const isCss = dep.endsWith('.css');
        const cssSelector = isCss ? '[rel="stylesheet"]' : '';
        // @ts-ignore check if the file is already preloaded by SSR markup
        if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) {
            return;
        }
        // @ts-ignore
        const link = document.createElement('link');
        // @ts-ignore
        link.rel = isCss ? 'stylesheet' : scriptRel;
        if (!isCss) {
            link.as = 'script';
            link.crossOrigin = '';
        }
        link.href = dep;
        // @ts-ignore
        document.head.appendChild(link);
        if (isCss) {
            return new Promise((res, rej) => {
                link.addEventListener('load', res);
                link.addEventListener('error', () => rej(new Error(`Unable to preload CSS for ${dep}`)));
            });
        }
    })).then(() => {
      debugger
      return baseModule()
    });
};

// 配置中的remote
const remotesMap = {
  'remotes':{url:'http://localhost:5001/assets/remoteEntry.js',format:'esm',from:'vite'}
};

// 加载js的方法
const loadJS = async (url, fn) => {
  const resolvedUrl = typeof url === 'function' ? await url() : url;
  const script = document.createElement('script');
  script.type = 'text/javascript';
  script.onload = fn;
  script.src = resolvedUrl;
  document.getElementsByTagName('head')[0].appendChild(script);
};

const scriptTypes = ['var'];
const importTypes = ['esm', 'systemjs'];

// 外部依赖的js
const wrapShareModule = remoteFrom => {
  return {

  }
};

async function __federation_method_ensure(remoteId) {
  const remote = remotesMap[remoteId];
  if (!remote.inited) {
    if (scriptTypes.includes(remote.format)) {
      // umd模式,挂载到window上模式
      // loading js with script tag
      return new Promise(resolve => {
        const callback = () => {
          if (!remote.inited) {
            remote.lib = window[remoteId];
            remote.lib.init(wrapShareModule(remote.from));
            remote.inited = true;
          }
          resolve(remote.lib);
        };
        return loadJS(remote.url, callback);
      });
    } else if (importTypes.includes(remote.format)) {
      // loading js with import(...)
      return new Promise(resolve => {
        const getUrl = typeof remote.url === 'function' ? remote.url : () => Promise.resolve(remote.url);
        getUrl().then(url => {
          __vitePreload(() => import(/* @vite-ignore */ url),true?[]:void 0).then(lib => {
            if (!remote.inited) {
              const shareScope = wrapShareModule(remote.from);
              // 调用A提供的init方法
              lib.init(shareScope);
              remote.lib = lib;
              remote.lib.init(shareScope);
              remote.inited = true;
            }
            resolve(remote.lib);
          });
        });
      })
    }
  } else {
    return remote.lib;
  }
}

// 兼容处理 module.default 和 module 这里加载export default导出的的模块
function __federation_method_unwrapDefault(module) {
  return (module?.__esModule || module?.[Symbol.toStringTag] === 'Module') ? module.default : module
}

// 加载remotes资源 并返回 export {} 导出的对象集合
function __federation_method_getRemote(remoteName,  componentName){
  /** @description 调用remote的get方法 获取export {} 的方法 */
  return __federation_method_ensure(remoteName).then((remote) => {
      // 这里是A提供的get方法
      return remote.get(componentName).then((factory) => {
        return factory()
      })
    }
  );
}

/**
 * 这是写的源码转换的结果
 * import project2, {version} from 'remotes/project2'
 * console.log(project2, project2.getVersion(), version)
 */
const __federation_var_remotesproject2 = await __federation_method_getRemote("remotes" , "./project2");
let project2 = __federation_method_unwrapDefault(__federation_var_remotesproject2);
let {version} = __federation_var_remotesproject2;
console.log(project2, project2.getVersion(), version);

总结

所有的moderation的实现方式都类似:

  1. 加载远程模块
  2. 解析出远程模块的方法
  3. 调用远程方法

这里@originjs/vite-plugin-federation借用了es module的特性,对浏览器版本要求要求也比较高

加载远程模块的方式一般都是借助script标签,动态创建script标签插入到页面中,然后监听script的onload事件

实现一个load

// js加载器
// 这里简化了很多东西,只是为了描述下下实现逻辑。
const seen = {}; // 缓存
function loadJs(jsPath, async = false) {
  const promise = new Promise((resolve, reject) =>  {
    if(seen[jsPath]){
      resolve();
    }else {
      const script = document.createElement('script');
      script.src = jsPath;
      script.async = async;
      script.onload = resolve;
      script.onerror = reject;
      document.getElementsByTagName('head')[0].appendChild(script); 
    }
  })

  return promise
}

window.__federation__ = {
  loadJs
}

// 提供方法的远程模块
const version = "1.0.0";
const name = "project2";
const getVersion = () => version;
const getName = () => name;

// 这里借助了widnow对象
window.__federation__project2 = { 
  name, 
  version,
  getName,
  getVersion
};


// 使用
window__federation__.loadJs('xxx.js').then(() => {
  console.log(winddow__federation__project2.name, winddow__federation__project2.getName)
})

上面实现方式依赖了window上的变量,好处是使用了umd模式,浏览器兼容好,不好的地方时是污染window对象。如果想要像开始的@originjs/vite-plugin-federation那样,就需要借助es的特性,实现解析模块的代码。

这里还有个地方需要注意,script的async标签,这里采用的是异步模式,异步加载不影响页面加载和其他js的执行。有些情况是需要使用同步模式的,比如模块都依赖一个基础环境包(react、vue),那么这个基础包就要采用同步模式(script async设置为true),不然会出现子模块加载完,开始解析的时候没有找基础报的依赖的情况。同步加载会带来一个性能问题,就是http2的并行请求就用不了了。

实现一个异步load

window.__federation__project2 = { 
  // 不直接暴露方法,而是通过一个promise获取对应的方法,然后调用
  // 应用只需要加载一个很小的“入口”文件(方法集合)
  // 这样不仅实现了异步加载,还实现了按需加载调用
  getGetNameFn: async() => {
    await load('jsPath');
    return winddow__federation__project2.getName
  },
};

// 使用
window.__federation__project2.getGetNameFn().then((getName) => {
  console.log(getName())
})

当然还有其他很多方式能实现异步效果,都比上面的方案更好。比如:通过观察者模式,通注册事件,在加载完成之后,发送消息,这个比较经典的工具是rxjs。甚至在进一步通过proxy重写对象的get,实现自动加载,就节省了上面手动导出getGetNameFn过程了。

解决了什么问题

相对npm包的形式,他提供了一种可配置化的模式,npm在每次发包的时候都需要经过构建和发布才能生效,federation实现了动态更新,没有特殊要求,模块发布更新了,应用里也就是更新了。当然如果有需要也可以通过配置方式实现版本控制、AB测试的能力