社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  NGINX

Single-Spa微前端落地(含nginx部署)

lizijiee • 5 年前 • 790 次点击  
阅读 214

Single-Spa微前端落地(含nginx部署)

0. 关于

最近一段时间,由于未来工作中涉及工业应用较多,并且考虑以后需要将工业应用在同一系统进行展示,希望有一个突破口可以解决这个问题,即在一个总项目中展示不同的工业应用,每个工业应用是一个单独的项目,由于工业应用可能由于不同团队进行开发,应用开发技术栈最好没有限制,单一前端框架可能不再能满足要求,由此了解到微前端并进行尝试研究,希望借助微前端可以解决工业应用汇总展示的问题。

当然微前端的实现方式不有很多种,包括iframe、single-spa等,本文采用的主要是single-spa。如果你使用过iframe就会知道,iframe和single-spa完全不是一个难度,如果把iframe比作是easy模式,那么single-spa便是地狱模式,如果你的项目着急上线使用微前端最快的方法就是iframe,,相信也有很多人在想搞一下微前端的同学们,猝死在了研究single-spa的路上,还有也是资料的缺少,因为现在网上虽然微前端文章很多,但是大多只是理论介绍和部分代码的展示,并没法帮助我们真正落地在项目中去实施。

single-spa使用的主要难点在于: 1.对于总项目,如何完美兼容各个子项目,做到技术栈无关,并且让用户感觉是一个整体项目。 2.对于各个子项目,如何减少代码侵入,并且使其具有独立开发、独立运行、独立部署的能力。

1. 微前端是什么

微前端是借鉴后端微服务的概念而来,single-spa官网解释到,A microfrontend is a microservice that exists within a browser即微前端是浏览器中存在的微服务,微前端也是UI的一部分,通常由数十个组件组成,而这些组件可以是React,Vue和Angular等不同框架实现的,每个微前端项目可以交由不同的团队进行管理,每个团队也可以选择自己的框架。尽管在迁移或实验时可能会添有其它框架,但是最好对所有微前端使用一个框架,这是最实用的。 每个微前端项目可以放在不同的git存储库中,有自己的package.json和构建工具配置。这样每一个微前端项目都有一个独立的构建打包过程和独立的部署,也就意味着我们可以快速完成我们的微前端项目的打包上线,而不用每次对一个巨无霸(Monolith)项目进行操作,后期有新的需求,也只需要修改对应的微前端项目。

2. 为什么要用微前端

目前随着前端的不断发展,企业工程项目体积越来越大,页面越来越多,项目变得十分臃肿,维护起来也十分困难,有时我们仅仅更改项目简单样式,都需要整个项目重新打包上线,给开发人员造成了不小的麻烦,也非常浪费时间。老项目为了融入到新项目也需要不断进行重构,造成的人力成本也非常的高。

在前端开发工作中,面临的困难:
  1. 企业工程项目越来越大,项目构建部署速度慢。
  2. 工程团队人员较多,技术栈难以统一,异地团队间沟通成本高,开发代码容易冲突,会影响整个项目。
  3. 旧项目重构,代码改动太大,消耗时间严重。
对比分析:
  1. 具有独立运行、独立部署功能,构建部署速度快。
  2. 技术栈无关,具有独立开发功能,避免开发冲突,减少协作成本。
  3. 旧项目可以作为微前端项目一部分,避免重构。

3. 微前端实现方式

微前端实现方式有两种:

1.iframe嵌入 (难度:★)

2.single-spa合并类单页应用 (难度:★★★★★)

4. iframe

iframe嵌入方式比较容易实现,不再赘述。

Why Not Iframe

为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中.
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

参考文章: Why Not Iframe

5. single-spa

single-spa实现原理:
首先对微前端路由进行注册,使用single-spa充当微前端加载器,并作为项目单一入口来接受所有页面URL的访问,根据页面URL与微前端的匹配关系,选择加载对应的微前端模块,再由该微前端模块进行路由响应URL,即微前端模块中路由找到相应的组件,渲染页面内容。
参考文章: single-spa官网

6. 微前端实现过程

❤️❤️❤️ 项目源码地址 ❤️❤️️❤️

6.1 基座项目(父项目改造)

基座项目创建:
yarn create react-app portal
yarn add antd
// 创建config-overrides.js支持antd按需加载
//    fixBabelImports('import', {
//       libraryName: 'antd',
//      libraryDirectory: 'es',
//      style: true,
//  }),
复制代码
本文采用微前端加载原理是:
首先在父项目创建dom节点,在项目注册过程输入待挂载的节点,即可完成子项目在父项目中运行。

6.2.1 创建路由及子项目挂载dom节点

代码如下:
  <div className="App" >
      <Layout>
        <Sider trigger={null} collapsible collapsed={collapsed}>
          <div className="logo" />
          <ul >
            <li key="react" >
              <Link to="/react">React</Link>
            </li>
            <li key="vue" >
              <Link to="/vue">Vue</Link>
            </li>
            <li key="angular" >
              <Link to="/angular">Angular</Link>
            </li>
          </ul>
        </Sider>
        <Layout className="site-layout">
          <Header className="site-layout-background" style={{ padding: 0 }}>
            {React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
              className: 'trigger',
              onClick: () => { setCollapse(!collapsed) },
            })}
          </Header>
          <Content
            className="site-layout-background"
            style={{
              margin: '24px 16px',
              padding: 24,
              minHeight: 280,
            }}
          <blockquote style=' padding: 10px 10px 10px 1rem; font-size: 0.9em; margin: 1em 0px; color: rgb(0, 0, 0); border-left: 5px solid #9370DB; background: rgb(239, 235, 233);'></blockquote>
            <div id="vue" />
            <div id="react-app" />
            <app-root></app-root>
          </Content>
        </Layout>
      </Layout>
    </div>
复制代码

6.2.2 微前端应用注册

src文件中创建singleSpa.js文件。
将文件引入项目入口文件index.js文件中。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter as Router } from 'react-router-dom'
import "./singleSpa.js"; // 引入微前端配置文件;

复制代码
// 项目目录结构
├── public
├── src
│   ├── index.js
│   ├── singleSpa.js
│   └── App.jsx
├── config-overrides.js
├── package.json
├── README.md
├── yarn.lock
复制代码
singleSpa.js部分代码:
import * as singleSpa from 'single-spa';

// 注册应用方式参考文章:
// Single-Spa + Vue Cli 微前端落地指南 (项目隔离远程加载,自动引入)(https://juejin.im/post/5dfd8a0c6fb9a0165f490004#heading-2)
/**
 * runScript 一个promise同步方法。可以代替创建一个script标签,然后加载服务
 * @param  {string}         url         请求文件地址
 */
const runScript = async (url) => {
    // 加载css同理
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = url;
        script.onload = resolve;
        script.onerror = reject;
        const firstScript = document.getElementsByTagName('script')[0];
        firstScript.parentNode.insertBefore(script, firstScript);
    });
};

// 注册微前端服务
/* 
    注册所用函数;
    return 一个模块对象(singleSpa),模块对象来自于要加载的js导出(子项目);
    如果这个函数不需要在线引入,只需要本地引入一块加载:
    () => import('xxx/main.js')
*/
singleSpa.registerApplication(
    'vue',
    async () => {
            await runScript('http://127.0.0.1:8080/js/chunk-vendors.js');
            await runScript('http://127.0.0.1:8080/js/app.js');
            return window.singleVue;
        },
        // 配置微前端模块前缀
        // 纯函数根据参数查看是否处于活动状态
        (location) => location.pathname.startsWith('/vue')
);

singleSpa.start(); // 启动注册,别忘记!
复制代码
registerApplication参数含义:

1、 appName: string 应用名称

2、applicationOrLoadingFn: () => <Function | Promise>返回promise加载函数或者已解析的应用。

// 应用作为参数,该参数由一个带有生命周期的对象组成。
const application = {
  bootstrap: () => Promise.resolve(), //bootstrap function
  mount: () => Promise.resolve(), //mount function
  unmount: () => Promise.resolve(), //unmount function
}
registerApplication('applicatonName', application, activityFunction)
复制代码

加载函数作为参数必须返回一个promise或者异步函数,第一次加载应用程序时,将不带任何参数地调用该函数,返回promise必须和应用一起解决。最常见的加载函数导入方式是:() => import('/path/to/application.js')

3、 activityFn: (location) => boolean

动态函数(activity function),必须是一个纯函数,函数将window.location作为第一个参数提供,并在应用程序处于活动状态时返回一个判断结果。常见使用时,通过动态函数(activity function)第一个参数判断子应用是否处于激活状态。

6.2 微前端项目(子项目改造)

6.2.1 Vue子项目改造

环境准备:
npm install -g @vue/cli	//全局安装vue-cli
vue create vue-project	// 创建子项目

// 项目目录结构
├── public
├── src
│   ├── main.js
│   ├── assets
│   ├── components
│   └── App.vue
├── vue.config.js
├── package.json
├── README.md
└── yarn.lock
复制代码
修改main.js文件进行注册
import Vue from 'vue'
import App from './App.vue'
import singleSpaVue from "single-spa-vue";

Vue.config.productionTip = false
// el 为子项目待挂载到父项目的DOM节点!!!
const vueOptions = {
  el: "#vue",
  render: h => h(App)
};

// 主应用注册成功后会在window下挂载singleSpaNavigate方法
// 为了独立运行,避免子项目页面为空,
// 判断如果不在微前端环境下进行独立渲染html
if (!window.singleSpaNavigate) {
  new Vue({
    render: h => h(App),
  }).$mount('#app')
}

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: vueOptions,
});

export const bootstrap = vueLifecycles.bootstrap; // 启动时
export const mount = vueLifecycles.mount; // 挂载时
export const unmount = vueLifecycles.unmount; // 卸载时

export default vueLifecycles;
复制代码
根目录创建vue.config.js修改webpack配置
module.exports = {
    /* 
        重点:
        设置publicPath,避免父项目加载子项目时,部分资源文件路径为父项目地址,导致请求文件失败。
    */
    publicPath: "//localhost:8080/",
    configureWebpack: {
        devtool: 'none', // 不打包sourcemap
        output: {
            library: "singleVue", // 导出名称
            libraryTarget: "window", //挂载目标,可以在浏览器打印window.singleVue查看
        }
    },
    devServer: {
        contentBase: './',
        compress: true,
    }
};
复制代码
子项目改造我们整体可以分为两个步骤:
1. 子项目入口文件改造,注册微前端,确定子项目挂载节点;
2. 子项目webpack出口文件改造,打包后在window下创建singleVue方法。

6.2.1 React子项目改造

环境准备:
yarn create react-app react  // 创建子项目
yarn add single-spa-react // 安装single-spa-react

// 项目目录结构
├── public
├── src
│   ├── App.js
│   ├── index.js
│   └── serviceWorker.js
├── config
│   ├── jest
│   ├── webpack.config.js
│   └── webpackDevServer.config.js
├── scripts
│   ├── build.js
│   ├── start.js
│   └── test.js
├── package.json
├── README.md
└── yarn.lock
复制代码
修改index.js文件进行注册
import Vue from 'vue'
import App from './App.vue'
import singleSpaVue from "single-spa-vue";

Vue.config.productionTip = false
// el 为子项目待挂载到父项目的DOM节点!!!
const vueOptions = {
  el: "#vue",
  render: h => h(App)
};

// 主应用注册成功后会在window下挂载singleSpaNavigate方法
// 为了独立运行,避免子项目页面为空,
// 判断如果不在微前端环境下进行独立渲染html
if (!window.singleSpaNavigate) {
  new Vue({
    render: h => h(App),
  }).$mount('#app')
}

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: vueOptions,
});

export const bootstrap = vueLifecycles.bootstrap; // 启动时
export const mount = vueLifecycles.mount; // 挂载时
export const unmount = vueLifecycles.unmount; // 卸载时

export default vueLifecycles;
复制代码
修改项目启动端口号:
// scripts文件夹内start.js文件
- const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000
+ const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 5000;
复制代码
修改webpack配置,修改config文件夹中webpack.config.js文件
- publicPath:paths.publicUrlOrPath
+ publicPath: 'http://localhost:5000/',
+ library: "singleReact", // 导出名称
+ libraryTarget: "window", //挂载目标
复制代码
父项目首次加载子项目静态文件logo图片报错解决办法:
  1. 加载失败后,通过检查发现,父项目中加载子项目图片地址为:http://localhost:3000/static/media/logo.5d5d9eef.svg。
  2. 此时父项目地址为:http://localhost:3000/,子项目地址为:http://localhost:5000/
  3. 不难发现,logo图片请求地址应为子项目地址即http://localhost:5000/static/media/logo.5d5d9eef.svg
  4. 静态资源最终访问路径 = output.publicPath + 资源loader或插件等配置路径,默认publicPath路径为网站根目录的位置,而在父项目加载子项目时,当前网站根目录为http://localhost:3000/
  5. 可以通过将 output.publicPath设置为子项目跟目录http://localhost:5000/解决这个问题。

6.2.1 Angular子项目改造

环境准备:
npm install -g @angular/cli //全局安装angular/cli,直接安装报错
// 报错信息 TypeError: Cannot read property 'flags' of undefined

npm install @angular/cli@9.0.0 -g // 指定版本安装
ng new angular-project // 创建项目

// 项目目录结构
├── e2e
├── src
│   ├── + `main.single-spa.ts`
│   ├── + `single-spa`	
│   │	├── + `single-spa-props.ts`
│   │	└── + `asset-url.ts`
│   ├── app
│   │	├── + `empty-route`
│   │	│	└── + `empty-route.component.ts`
│   │	├──  empty-route.component.ts
│   │	├──  app.component.ts
│   │	└──  `app.module.ts` //use src/app/empty-route/ EmptyRouteComponent
│   ├── assets
│   ├── environments
│   ├── index.html
│   ├── polyfills.ts
│   └── test.ts
├── node_modules
├── + `extra-webpack.config.js`
├── package.json
├── README.md
├── angular.json
└── yarn.lock
复制代码
[报错为:TypeError: Cannot read property 'flags' of undefined](https://stackoverflow.com/questions/49544854/typeerror-cannot-read-property-flags-of-undefined)
  • Install single-spa-angular.
  • Generate single-spa-props.ts in src/single-spa/
  • Generate asset-url.ts in src/single-spa/
  • Generate an EmptyRouteComponent in src/app/empty-route/, to be used in app-routing.module.ts.
注册应用
// src/app/empty-route/empty-route.component.ts 文件内代码

import { Component } from '@angular/core';

@Component({
    selector: 'app2-empty-route',
    template: '',
})
export class EmptyRouteComponent {
}
复制代码
// src/app/app.module.ts 文件内代码

+ import { EmptyRouteComponent } from './empty-route/empty-route.component';
@NgModule({
  declarations: [
    AppComponent,
 +  EmptyRouteComponent
  ],
复制代码
// src/single-spa/asset-url.ts 文件内代码

export function assetUrl(url: string): string {
    // @ts-ignore
    const publicPath = __webpack_public_path__;
    const publicPathSuffix = publicPath.endsWith('/') ? '' : '/';
    const urlPrefix = url.startsWith('/') ? '' : '/'
  
    return `${publicPath}${publicPathSuffix}assets${urlPrefix}${url}`;
  }
复制代码
// src/single-spa/single-spa-props.ts 文件内代码
import { ReplaySubject } from 'rxjs';
import { AppProps } from 'single-spa';

export const singleSpaPropsSubject = new ReplaySubject<SingleSpaProps>(1)
export type SingleSpaProps = AppProps & {
}
复制代码
// src/main.singleSpa.ts 文件内代码

import { enableProdMode, NgZone } from '@angular/core';

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { Router } from '@angular/router';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { singleSpaAngular } from 'single-spa-angular';
import { singleSpaPropsSubject } from './single-spa/single-spa-props';

if (environment.production) {
    enableProdMode();
}
// if (!window.singleSpaNavigate) {
platformBrowserDynamic().bootstrapModule(AppModule)
    .catch(err => console.error(err));
// }

const lifecycles = singleSpaAngular({
    bootstrapFunction: singleSpaProps => {
        singleSpaPropsSubject.next(singleSpaProps);
        return platformBrowserDynamic().bootstrapModule(AppModule);
    },
    template: '<app2-root />',
    Router,
    NgZone: NgZone,
});

export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount;
复制代码
修改angular.json为修改出口文件做准备工作
npm i -D @angular-builders/custom-webpack //用于修改webpack 配置
npm i -D @angular-builders/dev-server
 "build": {
        - "builder": "@angular-devkit/build-angular:browser",
        + "builder": "@angular-builders/custom-webpack:browser",
          "options": {
          + "customWebpackConfig": {
          +   "path": "./extra-webpack.config.js"


    
 // 读取文件,修改webpack配置
          + },
          + "deployUrl": "http://localhost:4000/", // 修改publicPath
            "outputPath": "dist/Delete",
            "index": "src/index.html",
————————————————
	"serve": {
      -	"builder": "@angular-devkit/build-angular:dev-server",
	  + "builder": "@angular-builders/custom-webpack:dev-server"
		"options": {
		"browserTarget": "Delete:build"
	 },    
————————————————        
复制代码
设置出口文件:
创建extra-webpack.config.js并进行配置
module.exports = {
  output: {
    library: "singleAngular", // 导出名称
    libraryTarget: "window", // 挂载目标
  },
}
复制代码
参考文章:
single-spa-angular
single-spa-angular示例代码地址
angular/cli版本 single-spa-angular版本
官方示例 8.1.0 3.0.1
本文示例 9.0.0 4

7. 线上部署Nginx配置

父项目加载过程中,全部请求:
线上部署完成后`首个请求vue/`发生`nginx报错404 Not Found`,通过排查发现nginx查找路径错误:

部署访问地址格式为xx.xx:0000/vue/#/app1

vue:微前端应用名称;

app1:子项目路由

Nginx报错分析:

首次发起请求时,由于这里写法类似browerRouter,根据请求会在nginx根目录文件内查找/vue文件夹,并检查是否有index.html文件进行返回。

xx.xx:0000/XX => nginx文件中XX文件夹 => 文件夹不存在返回404

因为/vue只是为了注册应用,并不需要真正去nginx中的/vue下查找index.html,因为按照这个路径查找并不能查找成功,我们仍希望在原来根地址进行查找。

ngix 遇到/XXX这样地址会被当作代理,寻找对应文件夹,如果文件夹没有就报错404,但是实际我是希望去进入到我的项目里面,我自己做判断的

解决方案:
在带有/vue进行访问时,首先进行判断,再使用nginx进行重定向,定向为原根目录。
注意事项:

此时进行重定向,但是我们并不希望url地址发生改变,所以我们需要使用rewrite "/xxx" /abc last;的这种跳转形式,但是这种重定向只能对站内url重写,如果rewrite第二个参数以http或者以https开头或者使用permanent都会导致url地址栏改变。(302,301等会修改地址栏的url)

location /vue/ {	
			root   /home/nginx/static/html/refining/;
	      		rewrite ^/vue(.*) /;
			index index.html index.htm;
 		}
// 注意重定向保持url不变,

复制代码

原本以为,这样就可以宣告成功了!!!但是现实总是残酷的。 我们刷新浏览器使父项目重新加载子项目,发现父项目中加载子项目文件依然报错,或者返回html导致类型报错。

通过查看请求可以发现,实际请求为: xx.xx:0000/vue/#/static/css/main.d6XXXXXXXXXX

下面我们继续对静态资源进行重定向:

location ~.*(gif|jpg|jpeg|bmp|png|ico|txt|js|css)$ {
	root   /home/nginx/static/html/refining/;
	rewrite ^/vue(.*) /$1 break;//$1 为匹配到的第一个参数,即去掉vue后的请求地址
	index  index.html index.htm;
}
复制代码
其它参考配置:
server {
    listen 8080;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;
    ssi on;

    # 将 / 重定向到 /browse
    rewrite ^/$ http://localhost:8080/browse redirect;

    # 根据路径访问 html 
    location /browse {
      set $PAGE 'browse';
    }
    location /order {
      set $PAGE 'order';
    }
    location /profile {
      set $PAGE 'profile'
    }

    # 所有其他路径都渲染 /index.html
    error_page 404 /index.html;
}
复制代码

8. 展望

8.1 改造优化

8.1.1 JS文件自动加载

stats-webpack-plugin生成manifest.json,实现自动加载。

8.1.2 CSS沙箱

CSS处理用到postcss-loader,postcss-loader用到postcss,我们添加postcss的处理插件,为每一个CSS选择器都添加名为`.namespace-kaoqin`的根选择器,最后打包出来的CSS,如下所示:

8.1.3 JS沙箱

8.1.4 加载HTML方式加载子应用

接入地址只需配置一次,省略使用manifest动态加载,因为html本身就是一个完整的manifest.

参考文章

参考代码

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/62862
 
790 次点击