• 微前端与项目实施方案研究


    一、前言

    微前端(micro-frontends)是近几年在前端领域出现的一个新概念,主要内容是将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的小块,而在用户看来仍然是内聚的单个产品。微前端的理念源于微服务,是将庞大的整体拆成可控的小块,并明确它们之间的依赖关系,而它的价值在于能将低耦合的代码与组件进行组合,基座+基础协议模式能接入大量应用,进行统一的管理和输出,许多公司与团队也都在不断尝试和优化相关解决技术与设计方案,为这一概念的落地和推广添砖加瓦。结合自身遇到的问题,适时引用微前端架构能起到明显的提效赋能作用。
    

    二、背景

    目前我司拥有大量的内部系统,这些系统采用相同的技术栈,在实际开发和使用过程中,逐渐暴露出如下几个问题:
    

    1.有大量可复用的部分,虽然有组件库,但是依赖版本难统一;
    2.静态资源体积过大,影响页面加载和渲染速度;
    3.应用切换目前是通过链接跳转的方式实现,会有白屏和等待时长的问题,对用户体验不够友好;
    针对上述几个问题,决定采用微前端架构对内部系统进行统一的管理,本文也是围绕微前端落地的技术预研方案。

    三、方案调研

    目前业界有多种解决方案,有各自的优缺点,具体如下:

    • 路由转发:路由转发严格意义上不属于微前端,多个子模块之间共享一个导航即可 简单,易实现 体验不好,切换应用整个页面刷新;

    • 嵌套 iframe:每个子应用一个 iframe 嵌套 应用之间自带沙箱隔离 重复加载脚本和样式;

    • 构建时组合:独立仓储,独立开发,构建时整体打包,合并应用 方便依赖管理,抽取公共模块 无法独立部署,技术栈,依赖版本必须统一;

    • 运行时组合:每个子应用独立构建,运行时由主应用负责应用管理,加载,启动,卸载,通信机制 良好的体验,真正的独立开发,独立部署 复杂,需要设计加载,通信机制,无法做到彻底隔离,需要解决依赖冲突,样式冲突问题;

      开源微前端框架也有多种,例如阿里出品的qiankun,icestark,还有针对angular提出的mooa等,都能快速接入项目,但结合公司内部系统的特点,直接采用会有有些限制,例如要实现定制界面,无刷新加载应用,且不能对现有项目的开发和部署造成影响,因此决定自研相关技术。

    四、架构设计

    undefined

    4.1 应用层

    应用层包括所有接入微服务工作台的内部系统,他们各自开发与部署,接入前后没有多大影响,只是需要针对微服务层单独输出打包一份静态资源;

    4.2 微服务层

    微服务层作为核心模块,拥有资源加载、路由管理、状态管理和用户认证管理几大功能,具体内容将在后面详细阐述,架构整体工作流程如下:

    undefined

    4.3 基础支撑层

    基础支撑层作为基座,提供微服务运行的环境和容器,同时接入其他后端服务,丰富实用场景和业务功能;

    五、技术重难点

    要实现自定义微前端架构,难点在于需要管理和整合多个应用,确保应用之间独立运行,彼此不受影响,需要解决如下几个问题:

    5.1 资源管理

    5.1.1资源加载

    undefined

    每个应用有一个应用资源管理和注册的文件(app.regiser.js),其中包含路由信息,应用配置信息(configs.js)和静态资源清单,当首次切换到某应用时,首先加载app.register.js文件,完成路由和应用信息的注册,然后根据当前浏览器路由地址加载对应的静态文件,完成页面渲染,从而将各应用的静态资源串联起来,其中注册入口文件通过webpack插件来实现,具体实现如下:
    
    FuluAppRegisterPlugin.prototype.apply = function(compiler) {
       appId = extraAppId();
       var entry = compiler.options.entry;
       if (isArray(entry)) {
                for (var i = 0; i < entry.length; i++) {
                    if (isIndexFile(entry[i])) { // 入口文件
                        indexFileEdit(entry[i]);
                        entry[i] = entry[i].replace(indexEntryRegx, indeEntryTemp); // 替换入口文件
                        i = entry.length;
                    }
                }
        } else {
                if (isIndexFile(entry)) { // 入口文件
                    indexFileEdit(entry); // 重新生成和编辑入口文件
                    compiler.options.entry = compiler.options.entry.replace(indexEntryRegx, indeEntryTemp); // 替换入口文件
                }
        }
        compiler.hooks.done.tap('fulu-app-register-done', function(compilation) {
                fs.unlinkSync(tempFilePath); // 删除临时文件
                return compilation;
        });
        compiler.hooks.emit.tap('fulu-app-register', function(compilation) {
            var contentStr = 'window.register("'+ appId + '", {
    router: [ 
     ' + extraRouters() + ' 
    ],
    entry: {
    '; // 全局注册方法
            var entryCssArr = [];
            var entryJsArr = [];
            for (var filename in compilation.assets) {
                if (filename.match(mainCssRegx)) { // 提取css文件
                    entryCssArr.push('"' + filename + '"');
                } else if (filename.match(mainJsRegx) || filename.match(manifestJsRegx) || filename.match(vendorsJsRegx)) { // 提取js文件
                    entryJsArr.push('"' + filename + '"');
                }
            }
            contentStr += ('css: ['+ entryCssArr.join(', ') +'],
    '); // css资源清单
            contentStr += ('js: ['+ entryJsArr.join(', ') +'],
     }
    });
    '); // js资源清单
            compilation.assets['resources/js/' + appId + '-app-register.js'] = { // 生成appid-app-register.js入口文件
                source: function() {
                    return contentStr;
                },
                size: function() {
                    return contentStr.length;
                }
            };
            return compilation;
        });
    };
    
    5.1.2资源文件名
    微服务输出打包模式下,静态资源统一打包形式以项目id开头,形如10000092-main.js, 文件名称的修改通过webpack的插件实现;
    

    undefined

    核心实现代码如下:

    FuluAppRegisterPlugin.prototype.apply = function(compiler) {
        ......
        compiler.options.output.filename = addIdToFileName(compiler.options.output.filename, appId);
        compiler.options.output.chunkFilename = addIdToFileName(compiler.options.output.chunkFilename, appId);
        compiler.options.plugins.forEach((c) => {
            if (c.options) {
                if (c.options.filename) {
                    c.options.filename = addIdToFileName(c.options.filename, appId);
                }
                if (c.options.chunkFilename) {
                    c.options.chunkFilename = addIdToFileName(c.options.chunkFilename, appId);
                }
            }
        });
       ......
    };
    

    5.2 路由管理

    路由分为应用级和菜单级两大类,应用类以应用id为前缀,将各应用区分开,避免路由地址重名的情况,菜单级的路由由各应用的路由系统自行管理,结构如下:
    

    undefined

    5.3 状态分隔

    前端项目通过状态管理库来进行数据的管理,为了保证各应用彼此间独立,因此需要修改状态库的映射关系,这一部分需要借助于webpack插件来进行统一的代码层面调整,包括model和view两部分代码,model定义了状态对象,view借助工具完成状态对象的映射,调整规则为【应用id+旧状态对象名称】,下面来讲解一下插件的实现;
    

    undefined

    插件的实现原理是借助AST的搜索语法匹配源代码中的状态编写和绑定的相关代码,然后加上应用编号前缀,变成符合预期的AST,最后输出成目标代码:
    
    module.exports = function(source) {
          var options = loaderUtils.getOptions(this);
    	stuff = 'app' + options.appId;
    	isView = !!~source.indexOf('React.createElement'); // 是否是视图层
    	allFunc = [];
    	var connectFn = "function connect(state) {return Object.keys(state).reduce(function (obj, k) { var nk = k.startsWith('"+stuff+"') ? k.replace('"+stuff+"', '') : k; obj[nk] = state[k]; return obj;}, {});}";
    	connctFnAst = parser.parse(connectFn);
    	const ast = parser.parse(source, { sourceType: "module", plugins: ['dynamicImport'] });
    	traverse(ast, {
    		CallExpression: function(path) {
    			if (path.node.callee && path.node.callee.name === 'connect') { // export default connext(...)
    				if (isArray(path.node.arguments)) {
    					var argNode = path.node.arguments[0];
    					if (argNode.type === 'FunctionExpression') { // connect(() => {...})
    						traverseMatchFunc(argNode);
    					} else if (argNode.type === 'Identifier' && argNode.name !== 'mapStateToProps') { // connect(zk)
    						var temp_node = allFunc.find((fnNode) => {
    							return fnNode.id.name === argNode.name;
    						});
    						if (temp_node) {
    							traverseMatchFunc(temp_node);
    						}
    					}
    				}
    			} else if (path.node.callee && path.node.callee.type === 'SequenceExpression') {
    				if (isArray(path.node.callee.expressions)) {
    					for (var i = 0; i < path.node.callee.expressions.length; i++) {
    						if (path.node.callee.expressions[i].type === 'MemberExpression'
    							&& path.node.callee.expressions[i].object.name === '_dva'
    							&& path.node.callee.expressions[i].property.name === 'connect') {
    								traverseMatchFunc(path.node.arguments[0]);
    								i = path.node.callee.expressions.length;
    						}
    					}
    				}
    			}
    		},
    		FunctionDeclaration: function(path) {
    			if (path.node.id.name === 'mapStateToProps' && path.node.body.type === 'BlockStatement') {
    				traverseMatchFunc(path.node);
    			}
    			allFunc.push(path.node);
    		},
    		ObjectExpression: function(path) {
    			if (isView) {
    				return;
    			}
    			if (isArray(path.node.properties)) {
    				var temp = path.node.properties;
    				for (var i = 0; i < temp.length; i++) {
    					if (temp[i].type === 'ObjectProperty' && temp[i].key.name === 'namespace') {
    						temp[i].value.value = stuff + temp[i].value.value;
    						i = temp.length;
    					}
    				}
    			}
    		}
    	});
    	return core.transformFromAstSync(ast).code;
    };
    

    5.4 框架容器渲染

    完成以上步骤的改造,就可以实现容器中的页面渲染,这一部分涉及到组件库框架层面的调整,大流程如下图:

    undefined

    六、构建流程

    6.1 使用插件

    构建过程中涉及到两款自开发的插件,分别是fulu-app-register-plugin和fulu-app-loader;

    6.1.1 安装
    npm i fulu-app-register-plugin fulu-app-loader -D;
    
    6.1.2 配置

    webpack配置修改:

    const FuluAppRegisterPlugin = require('fulu-app-register-plugin');
    module: {
       rules: [{
             test: /.jsx?$/,
             loader: 'fulu-app-loader',
          }
       ]
    }
    plugins: [
        new FuluAppRegisterPlugin(),
        ......
    ]
    

    6.2.编译

    编译过程与目前项目保持一致,相比以前,多输出了一份微前端项目编译代码,流程如下:

    undefined

    七、遗留问题

    7.1 js环境隔离

    由于各应用都加载到同一个运行环境,因此如果修改了公共的部分,则会对其他系统产生不可预知的影响,目前没有比较好的办法来解决,后续将持续关注这方面的内容,逐渐优化达到风险可制的效果。
    

    7.2.获取token

    目前应用切换使用重定向来完成token获取,要实现如上所述的微前端效果,需要放弃这种方式,改用接口调用异步获取,或者其他解决方案。
    

  • 相关阅读:
    关于CSS单位:rem vh vw vmin vmax
    关于遍历一个数组的几种方法的比较
    关于ng的过滤器的详细解释angular-filter
    angular的路由跳转,的监听$rootScope.$on
    js经典校验之注册与登录校验
    CSS制作的32种图形效果[梯形|三角|椭圆|平行四边形|菱形|四分之一圆|旗帜]
    js的with语句使用方法
    js的DOM节点操作:创建 ,插入,删除,复制,查找节点
    js中的如何定位固定层的位置
    CommonJS、requirejs、ES6的对比
  • 原文地址:https://www.cnblogs.com/fulu/p/13070633.html
Copyright © 2020-2023  润新知