Skip to content

gxlmyacc/import-remote

Repository files navigation

import-remote

一个远程加载JS模块的库。支持webpack4/5,可以通过使你的项目支持微前端架构。

NPM version NPM downloads

说明

它的初衷是:

  • 希望像html-webpack-plugin插件生成HTML入口文件那样生成一个JS入口文件;

  • 插件的配置参数基本和html-webpack-plugin保持一致,以便只需将html-webpack-plugin插件替换为import-remote/plugin插件即可在htmlJS入口间切换;

  • 解决JS模块依赖传递的问题:不再是通过window变量来传递依赖;

  • 支持自动从公共包中加载依赖;

  • 利于webpack的懒加载功能实现公共包中的导出组件按需加载;

  • 提供global.xxxwindow.xxx这些全局变量私有化作用域的功能,避免污染window

  • JS/CSS动态添加publicPath,方便一份打包成果多处部署;

  • JS/CSS资源异步获取,避免通过scriptlink加载资源导致的同步加载;

  • 支持多入口文件,实现多模块代码/作用域共享;

html-webpack-plugin的区别

  • html-webpack-plugin生成的是HTML入口;import-remote/plugin生成的JS入口;

  • HTML入口通过浏览器或iframe标签加载;JS入口通过import-remote提供的加载方法(remote)加载;

webpack library打包的区别

  • webpack library的入口JS可以直接通过script标签加载,支持资源跨域;import-remote的入口JS需要专门提供的加载方法(remote)加载,该方法通过ajax请求加载资源,所以资源间的跨域问题需要使用者自行解决;

  • webpack library的模块依赖需要通过从window变量获取;import-remote的模块依赖只需在加载方法(remote)中传递进去即可;

  • webpack library包必须配置publicPathimport-remote无需配置publicPath或只需配置为/,加载方法(remote)在加载资源时会自动配置JS的`publicPath,并且查找CSS源代码中的相对路径,将其替换为绝对路径;

webpack Module Fedetation打包的区别

  • webpack Module Fedetation 需要应用和宿主都是webpack5import-remote没有这个要求,webpack4/5都可以,甚至宿主的打包环境使用的不是webpack(gulp、vite等)也没问题;

实现原理

构建期

参考html-webpack-plugin生成的js入口里,实际上是该入口依赖的mainfest信息,包含入口JS/CSS文件、external等信息;

加载期

import-remote的加载方法调用时:

  1. 加载入口JS文件;
  2. 根据入口JS里的信息,通过ajax请求下载入口JS/CSS资源;
  3. 通过内置的重写webpack运行时加载相关资源;
  4. 根据external信息,替换调加载模块中的这些外部模块;
  5. 调用入口模块,获取它的modlule.exports;
  6. 返回获取到的modlule.exports

说明

安装

  npm install --save import-remote

或者:

  yarn add import-remote

插件使用说明

通过import-remote/plugin插件可以为你的webpack包生成一个JS入口文件。下面是一个webpack配置示例:

webpack.config.js

const ImportRemotePlugin = require('import-remote/plugin')

const entryFiles = ['index', 'foo', 'bar'];

module.exports = {
  // 配置入口,可以配置多个入口
  entry: {
    ...(entryFiles.reduce((p, file) => {
      p[file] = `${file}.js`;
      return p;
    }, {}))
  }
  // 配置输出目录
  output: {
    path: __dirname + '/dist',
    filename: 'assets/[name]-[chunkhash:5].js',
    chunkFilename: 'assets/[name]-[chunkhash:5]-chunk.js',
  },
  // 配置外部依赖
  externals: [
    'react',
    'react-dom',
    'lodash'
  ],
  plugins: [
    // 装载插件
    ...entryFiles.map(file => new ImportRemotePlugin({
      template: `auto`,
      // 生成的入口文件名
      filename: `${file}.js`,
      // 包含的入口chunk
      chunks: [file],
    }))
    
  ]
}

入口文件示例:

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import _ from 'lodash',

const test = {
  dosomething() {
    console.log('dosomething', React, ReactDOM, _);
  }
}

// 导出你要导出的内容
module.exports = test;

宿主端使用示例

import remote from 'import-remote';

async function run() {
  // 通过await等待模块加载完毕
  const testIndex = await remote('http://localhost:3000/test/index.js', {
    // 配置模块需要依赖
    externals: { 
      react: require('react'), 
      'react-dom': require('react-dom'),
      'lodash': require('lodash'),
    }
  });
  // 拿到模块输出对象后,即可做你想做的任何事了
  testIndex.dosomething();

  // 也可以将依赖配置成全局依赖,这样importRemote时就不必再传依赖了
  Object.assign(remote.externals, { 
    react: require('react'), 
    'react-dom': require('react-dom'),
    'lodash': require('lodash'),
  });
  const testOther = await remote('http://localhost:3000/test/other.js');
  testOther.dosomething();
}

run();

import-remote还提供了远程模块(RemoteModule)类,方便管理同一组模块:

import { RemoteModule } from 'import-remote';

// 配置RemoteModule的host和依赖
const testModule = new RemoteModule('http://localhost:3000/test', {
  externals: { 
    react: require('react'), 
    'react-dom': require('react-dom'),
    'lodash': require('lodash'),
  }
});

// 具体的加载路径为RemoteModule的`${host}${requireName}.js`
const testIndex = await testModule.require('index');
const testOther = await testModule.require('other');

testIndex.dosomething();
testOther.dosomething();

remote/RemoteModule的options

  • scopeName: string - 作用域名,不配置时,会使用远程模块项目的项目名(package.json->name);

  • timeout:number - 资源加载的请求时间,为0表示不会超时;

  • externals: Record<string, any> - 传递给远程模块的依赖模块(webpack配置的externals);

  • getManifestCallback: (manifest: RemoteManifest) => any|Promise<any> - 当加载到manifest后的回调函数;

  • afterCreateRuntime: (webpack_require: any, ctx: RemoteModuleRuntime) => void - 模块的runtime创建好后的回调函数;

  • host: string - 远程模块的host,相同host的远程模块会认为来自同一个项目,它们会共享同一个runtime运行时。当不传时,host将从url中去取(去掉url中最后一个/前的部分会认为是host);

  • sync: boolean - 使用使用ajax的同步请求来加载资源;

  • cacheDB: boolean - 是否开启cacheDB,当开启后,每次加载成功的远程资源会保存在一个名叫import-remote-global-dbindexedDB数据库中,当遇到某个资源ajax请求失败,xhr.status0时,将会改为尝试从该数据库加载该资源。该数据每次启动时会清楚一个月之前的缓存,以避免缓存资源膨胀。

    :你也可以通过import-remote/cache导出的enableCacheDB方法来为所有模块开启cacheDB

import { enableCacheDB } from 'import-remote/cache';

enableCacheDB();
  • useEsModuleDefault: boolean - 是否在加载到远程模块后直接返回入口模块的default导出,而不是返回整个该入口模块;

import-remote/plugin插件基本配置选项

import-remote/plugin的基本配置选项基本和html-webpack-plugin一致,这样目的是方便两个插件间切换。

属性名 类型 默认值 说明
filename {String} 'index.js' 生成的入口文件名。默认为index.js. 你也可以在这里指的子目录 (比如: entrys/index.js)
hash {Boolean} false 如果值为true,就添加一个唯一的webpack compilation hash给所有JS和CSS文件。这对缓存清除(cache busting)十分有用
cache {Boolean} true 如果为true (默认),只要文件被更改了就生成文件
showErrors {Boolean} true 如果为true (默认),详细的错误信息将被写入到JS文件中
chunks {?} ? 允许你只添加某些chunks(e.g only the unit-test chunk)
chunksSortMode {String|Function} auto 在chunks被include到html文件中以前,允许你控制chunks 应当如何被排序。允许的值: none
excludeChunks {Array.<string>} `` 允许你跳过某些chunks (e.g don't add the unit-test chunk)
entriesManifest `{Boolean String}` ''
libraryFileName `{Boolean String}` ''

生成多输出入口文件

要生成多个输出入口文件,只需要在你的插件列表中配置多个ImportRemotePlugin插件:

{
  entry: 'index.js',
  output: {
    path: __dirname + '/dist',
    filename: 'index_bundle.js'
  },
  plugins: [
    new ImportRemotePlugin(), // 生成默认的index.js
    new ImportRemotePlugin({  // 同时生成一个test.js
      filename: 'test.js',
    })
  ]
}

过滤Chunks

要只打包特定的chunk包,你可以通过chunks属性来控制:

plugins: [
  new ImportRemotePlugin({
    chunks: ['app']
  })
]

你也可以配置要排除特定的包,通过excludeChunks选项来控制:

plugins: [
  new ImportRemotePlugin({
    excludeChunks: [ 'dev-helper' ]
  })
]

长期缓存

要支持长期缓存,你可以通过为filename添加contenthash/templatehash

plugins: [
  new ImportRemotePlugin({
    filename: 'index.[contenthash].js'
  })
]

contenthash/templatehash 是输出文件内容的hash。

另外, 你也可以这样配置hash: [<hashType>:contenthash:<digestType>:<length>]

  • hashType - sha1md5sha256sha512之一,或者任意一种node.js支持的hash类型
  • digestType - hexbase26base32base36base49base52base58base62base64之一
  • maxlength - 生成的hash字符的最大程度

默认为: [md5:contenthash:hex:9999]

import-remote/plugin插件额外配置选项

除了从html-webpack-plugin继承的配置选项,import-remote/plugin也提供了一些自己的配置选项:

属性名 类型 默认值 说明
commonModules [{ name?: string, url: string, scoped?: boolean }] [] 依赖的公共模块,如果配置了公共模块,则模块的依赖将尝试从这些公共模块中加载依赖。其中name是可选参数,为某个外部依赖模块名,如果配置了name,表示该公共包只是该名称的模块,则只有该模块依赖该名称的外部依赖模块时才会加载该模块包。该功能依赖于webpack的externals
shareModules [string or { name: string, var: string }] [] 共享模块列表,如果配置了共享模块,则模块的这些依赖将优先使用宿主应用externals参数中传递的依赖,如果未在宿主应用中找到,才使用自己的该模块。该功能不依赖webpack的externals。
globalToScopes string[] [] 需要局部化的全局变量,该数组中声明的名称,在加载资源时将会从源代码中将其替换为从某个私有变量中读取这些值

公共模块包

提供公共模块包的目的是方便公共模块的共享,同时利用webpack的懒加载功能实现组件的按需加载。

这是一个公共模块包的示例:


// index.js
import { createRequireFactory } from 'import-remote';

export default createRequireFactory({
  'import-remote': () => require('import-remote'),
  react: () => import(/* webpackChunkName: 'react' */ 'react'),
  'react-dom': () => import(/* webpackChunkName: 'react-dom' */ 'react-dom'),
  'prop-types': () => import(/* webpackChunkName: 'prop-types' */ 'prop-types'),
  mobx: () => import(/* webpackChunkName: 'mobx' */ 'mobx'),
  'mobx-react': () => import(/* webpackChunkName: 'mobx-react' */ 'mobx-react'),
  qs: () => import(/* webpackChunkName: 'qs' */ 'qs'),
});

webpack.config.js

const ImportRemotePlugin = require('import-remote/plugin')

module.exports = {
  entry: {
    index: 'index.js'
  }
  output: {
    filename: '[name]-[chunkhash:5].js'
  },
  externals: [
    {
      'import-remote': {
        root: 'importRemote',
        amd: 'import-remote',
        commonjs2: 'import-remote',
        commonjs: 'import-remote',
      },
    },
  ],
  plugins: [
    new ImportRemotePlugin()
  ]
}

这是一个公共lib模块包的示例:


// index.js
import axios from 'axios';

export default axios;

webpack.config.js

const ImportRemotePlugin = require('import-remote/plugin')

module.exports = {
  entry: {
    index: 'index.js'
  }
  output: {
    filename: '[name]-[chunkhash:5].js'
  },
  plugins: [
    new ImportRemotePlugin()
  ]
}

一个依赖公共包的webpack配置如下:

webpack.config.js

const ImportRemotePlugin = require('import-remote/plugin')

module.exports = {
  entry: {
    index: 'index.js'
  }
  output: {
    filename: '[name]-[chunkhash:5].js'
  },
  externals: {
    react: {
      root: 'React',
      amd: 'react',
      commonjs2: 'react',
      commonjs: 'react',
    },
    'react-dom': {
      root: 'ReactDOM',
      amd: 'react-dom',
      commonjs2: 'react-dom',
      commonjs: 'react-dom',
    },
    mobx: {
      root: 'mobx',
      commonjs: 'mobx',
      commonjs2: 'mobx',
    },
    'mobx-react': {
      root: 'mobxReact',
      commonjs: 'mobx-react',
      commonjs2: 'mobx-react',
    },
  },
  plugins: [
    new ImportRemotePlugin({
      commonModules: [
        // 当指定了name时,只有该name在externals中存在时,才会加载该公共包
        { name: 'axios', url: 'http://localhost:3000/lib/axios.min.js' },
        // 未指定name事,只要externals不为空,就会加载该公共包
        { url: 'http://localhost:3000/lib/commonLibs.js' },
      ]
    })
  ]
}

则这个模块的依赖:

  1. 首先从宿主使用加载方法remote中配置的externals中寻找依赖;

  2. 第一步没找到依赖,将会从模块中寻找依赖;

共享模块

通过共享模块,使多个远程应用间使用共同的依赖。

这是一个共享模块包的配置示例:

webpack.config.js

const ImportRemotePlugin = require('import-remote/plugin')

module.exports = {
  entry: {
    index: 'index.js'
  }
  output: {
    filename: '[name]-[chunkhash:5].js'
  },
  plugins: [
    new ImportRemotePlugin({
      shareModules: [
        { name: 'react', var: 'react' },
        { name: 'react-dom', var: 'ReactDOM' }
      ]
    })
  ]
}

宿主传递共享模块的方式:

import remote from 'import-remote';

// 通过await等待模块加载完毕
const testIndex = await remote('http://localhost:3000/test/index.js', {
  // 共享模块也可以通过externals来传递
  externals: { 
    react: require('react'), 
    'react-dom': require('react-dom'),
  }
});

全局变量私有化

通过globalToScopes选项可以将你的源代码中一些window.xxxglobal.xxx的变量替换为一个同组模块共享的私有变量中。这在相互集成时全局变量冲突时的一种解决办法;

如果你的webpack配置是这样的:

const ImportRemotePlugin = require('import-remote/plugin')

module.exports = {
  entry: {
    index: 'index.js'
  }
  output: {
    filename: '[name]-[chunkhash:5].js'
  },
  plugins: [
    new ImportRemotePlugin({
      globalToScopes: ['app', 'someVar']
    })
  ]
}

则宿主在加载你的模块时,将会把你源代码中的global.appwindow.appglobal.someVarwindow.someVar替换成_wp_.g.app_wp_.g.someVar

注:全局变量私有化可能有风险,需要您仔细斟酌是否使用。毕竟最好的方案就是没有全局变量。该选项只是用于解决您目前无法或没时间去掉全局变量时的一种托底解决办法。

运行期包使用说明

远程加载方法

函数签名:

/**
 * @param {string} url 远程模块的url地址
 * @param {object} options 加载选项
 * @param {object} options.externals 远程模块的依赖模块
 * @param {boolean} options.useEsModuleDefault 当远程模块导出的是ES模块时,是否只返回它的default导出部分
 * @returns {Promise<any>} 远程模块的导出内容
 **/
function remote(url: string, options: { externals: { [key]: any } }): Promise<any>; 

使用方法:

import remote from 'import-remote';

// 通过await等待模块加载完毕
const testIndex = await remote('http://localhost:3000/test/index.js', {
  // 配置模块需要依赖
  externals: { 
    react: require('react'), 
    'react-dom': require('react-dom'),
    'lodash': require('lodash'),
  }
});
// 拿到模块输出对象后,即可做你想做的任何事了
testIndex.dosomething();

远程模块

通过远程模块可以用于管理一个项目生成的所有模块:

import { RemoteModule } from 'import-remote';

// 配置RemoteModule的host和依赖
const testModule = new RemoteModule('http://localhost:3000/test', {
  externals: { 
    react: require('react'), 
    'react-dom': require('react-dom'),
    'lodash': require('lodash'),
  }
});

// 具体的加载路径为RemoteModule的`${host}${requireName}.js`
const testIndex = await testModule.require('index');
const testOther = await testModule.require('other');

testIndex.dosomething();
testOther.dosomething();

RemoteModule的的方法有:

{
  /**
   * @param {string} host 该项目生成的模块的基地址(host)
   * @param {object} options 加载选项
   * @param {object} options.externals 远程模块的依赖模块
   * @param {boolean} options.useEsModuleDefault 当远程模块导出的是ES模块时,是否只返回它的default导出部分
   * @returns {void}
   **/
  constructor(host: string, options: { externals: { [key]: any } }): void; 
  
  /**
   * 根据模块名生成该模块的入口地址
   * @param {string} moduleName 模块名
   * @returns {string} 该模块的入口地址
   **/
  resolveModuleUrl(moduleName: string = 'index'): string; 

  /**
   * 发送HEAD请求判断`moduleName`是否存在,当存在时返回请求的响应头信息
   * @param {string} moduleName 模块名
   * @param {object} options 加载选项
   * @returns {Promise<object|null>} HEAD请求头信息或为空
   **/
  exist(moduleName: string = 'index', options: {}): Promise<any>; 

  /**
   * 获取`moduleName`的meta信息
   * @param {string} entryName 入口模块名
   * @param {object} options 加载选项,和remote方法的option一致
   * @returns {Promise<any>} 所有入口的描述文件
   **/
  requireEntries(entryName: string = 'index', options: {}): Promise<any>; 

  /**
   * 获取`moduleName`的meta信息
   * @param {string} moduleName 模块名
   * @param {object} options 加载选项,和remote方法的option一致
   * @returns {Promise<any>} 模块的meta信息
   **/
  requireMeta(moduleName: string = 'index', options: {}): Promise<any>; 

  /**
   * 功能和requireMeta相同,只是将加载资源时的ajax设置为同步请求
   * @param {string} moduleName 模块名
   * @param {object} options 加载选项,和remote方法的option一致
   * @returns {any} 远程模块的导出内容
   **/
  requireMetaSync(moduleName: string = 'index', options: {}): any; 

  /**
   * 异步加载方法
   * @param {string} moduleName 模块名,调用
   * @param {object} options 加载选项,和remote方法的option一致
   * @returns {Promise<any>} 远程模块的导出内容
   **/
  require(moduleName: string = 'index', options: {}): Promise<any>; 

  /**
   * 功能和require相同,只是将加载资源时的ajax设置为同步请求
   * @param {string} moduleName 模块名,调用
   * @param {object} options 加载选项,和remote方法的option一致
   * @returns {any} 远程模块的导出内容
   **/
  requireSync(moduleName: string = 'index', options: {}): any; 

  /**
   * 异步加载方法,功能和require相同,只是在导出模块为es模块时,只导出它的default部分
   * @param {string} moduleName 模块名,调用
   * @param {object} options 加载选项,和remote方法的option一致
   * @returns {Promise<any>} 远程模块的导出内容
   **/
  import(moduleName: string = 'index', options: {}): Promise<any>; 

  /**
   * 功能和import相同,只是将加载资源时的ajax设置为同步请求
   * @param {string} moduleName 模块名,调用
   * @param {object} options 加载选项,和remote方法的option一致
   * @returns {any} 远程模块的导出内容
   **/
  importSync(moduleName: string = 'index', options: {}): any;  
}

RemoteView

RemoteView是一个React组件,模拟iframe,内部创建3个元素模拟htmlheadbody,通过src属性加载远程React组件, 通过该组件加载的资源会有自己独立的全局空间,移除该组件时,也将移除加载该远程模块时的所有资源。

示例:

import React from 'react';
import RemoteView from 'import-remote/view';

function Test(props) {
  return <div>
    <RemoteView 
      src="http://localhost:3000/test.js" 
      props={{
        aa: 1,
        bb: 2
      }}
      externals={{
        'react': require('react'),
        'react-dom': require('react-dom')
      }}
    />
  </div>;
}

或者使用RemoteModule来加载:

import React from 'react';
import { RemoteModule } from 'import-remote';
import RemoteView from 'import-remote/view';

const testModule = new RemoteModule('http://localhost:3000/test', {
  externals: { 
    react: require('react'), 
    'react-dom': require('react-dom'),
    'lodash': require('lodash'),
  }
});

function Test(props) {
  return <div>
    <RemoteView 
      module={testModule}
      moduleName="index"
      props={{
        aa: 1,
        bb: 2
      }}
    />
  </div>;
}

RemoteView除了支持远程模块导出的React组件,也支持以下格式的组件导出接口:

{
  namespace?: string,
  bootstrap: (props, children) => void,
  mounted: (el, props) => primise|void,
  update: (el, props, prevProps) => void,
  unmount: (el) => void,
}

{
  namespace?: string,
  init: (props, options) => primise|void,
  render: (el, props) => void,
  destroy: (el) => void,
}

注:如果namespace不为空,这将会将其添加到body模拟元素的className中。

如果想将这样的对象转换成React组件,可以通过下面的办法:

  1. 直接将一个导出对象转换成React组件:
import { createAppView } from 'import-remote/view';
import testObject from './test-object';

const Test = createAppView(testObject);
  1. 远程加载一个导出对象,并直接将其转换成React组件:
import { requireApp } from 'import-remote/view';

const Test = await requireApp('http://localhost:3000/test.js');

RemoteView的props

  • scopeStyle: boolean - 当为true,并且shadow为false时,将为引用的样式添加一个hash作用域,避免样式影响到外部。是一种在不支持shadow DOM的情况下解决样式冲突的一种解决办法

  • scopePrefix: string = 'v-' - 为引用样式创建的作用域名的前缀

  • classPrefix: string = 'import-remote-' - 创建的htmlheadbody模拟元素的类名前缀

  • tag: string = 'div' - 创建的htmlheadbody模拟元素的元素类型

  • src: string - 远程模块的入口文件地址

  • module: RemoteModule - 远程模块对象,和moduleName配合使用,与src属性互斥

  • moduleName: string - 远程模块名称,和module配合使用,与src属性互斥

  • props: object - 传递给远程模块导出组件的props

  • externals: object - 远程组件的外部依赖

  • shadow: boolean - 是否将htmlheadbody创建在shadow DOM

  • onViewLoading: (loading) => void - 远程模块加载中的回调事件,分别会在加载前后调用

  • onViewError: (error) => void - 远程模块加载失败的回调事件

  • hoc: Function - 对导出组件的高阶封装,参数格式:(Component: React.ComponentType | React.ForwardRefExoticComponent, error?: any) => React.ComponentType | React.ForwardRefExoticComponent

RemoteApp

RemoteApp是一个即插即用的React组件,当远程模块返回的结果本身就是React组件时,可以使用该组件加载对应的模块。它和RemoteView的区别是:RemoteView会局部化远程模块的作用域空间,而RemoteApp不会:

import React from 'react';
import { RemoteModule } from 'import-remote';
import { RemoteApp } from 'import-remote/view';
// or 
import { RemoteApp } from 'import-remote/view/app';
 
const testModule = new RemoteModule('http://localhost:3000/test', {
  externals: { 
    react: require('react'), 
    'react-dom': require('react-dom'),
    'lodash': require('lodash'),
  }
});

function Test(props) {
  return <div>
    <RemoteApp
      module={testModule}
      moduleName="index"
      aa="1"
      bb="2"
    />
  </div>;
}

RemoteApp的props

  • src: string - 远程模块的入口文件地址

  • module: RemoteModule - 远程模块对象,和moduleName配合使用,与src属性互斥

  • moduleName: string - 远程模块名称,和module配合使用,与src属性互斥

  • exportName: string - 当远程模块的结果为一个esModule时,取esModule的导出key名,默认为default

  • hoc: Function - 对导出组件的高阶封装,参数格式:(Component: React.ComponentType | React.ForwardRefExoticComponent, error?: any) => React.ComponentType | React.ForwardRefExoticComponent

  • clearWhenError: boolean - 当加载远程组件失败时是否清除当前显示的组件,即上次加载的组件。默认为true