single-spa.js

single-spa.js

什么是 single-spa.js

single-spa 是一个可以把多种 JavaScript 框架所开发的应用聚合在一个应用的前端框架.

它有如下特点:

  • 多个SPA的切换无需刷新
  • 每个应用独立部署
  • 兼容多元框架
  • 懒加载

这是一个 single-spa 应用的在线例子

从架构上来讲, single-spa 应用由两个部分组成:

  • applications
  • single-spa-config

Applications

single-spa apps 会包含众多的 SPA 应用, 并且每一个应用都是一个完整的应用, 都可以从 DOM 中装载和卸载自身. 与传统的 SPA 相比, single-spa apps 最大的不同是它可以与其他的应用共存, 共享同一个 html page.

如果你的网络还不错, 一定已经打开了在线例子. 在元素检视面板可以看到很多 div.

1.png

这些 div 就像插槽一样, 在启动特定的应用时, 在对应的插槽装载元素. 而当应用不在是激活状态时, 又会从 DOM 中卸载掉该应用的元素. 并且, 所有的应用都共享着同一个 html 页.

single-spa-config

single-spa-config 用于在 single-spa 中注册应用, 每个应用的注册需要如下 3 样东西:

  1. application name
  2. application load function
  3. application active status switch function

从零开始

本教程的目标是在结束时, 完成零开始到集成完毕. 它共需要 6 步:

  1. 初始化项目
  2. 新建 html 文件
  3. Registering
  4. Create the home application
  5. Create the navBar application
  6. Create the angularJs application

初始化项目

随意新建一个文件夹, 例如 single-spa 作为本此尝试所使用的目录:

0: 初始化目录

mkdir single-spa && cd single-spa
yarn init              # or npm init
yarn add single-spa    # or npm install --save single-spa
mkdir src

1. 安装配置 Babel

yarn add --dev @babel/core @babel/preset-env @babel/preset-react @babel/plugin-syntax-dynamic-import @babel/plugin-proposal-object-rest-spread

2. 安装配置 Webpack

single-spa 现阶段不得不使用 Webpack, 执行如下命令安装 Webpack, Webpack plugins, loaders.

# Webpack core
yarn add webpack webpack-dev-server webpack-cli --dev
# Webpack plugins
yarn add clean-webpack-plugin --dev
# Webpack loaders
yarn add style-loader css-loader html-loader babel-loader --dev

安装结束之后, 在根目录创建一个 webpack.config.js 文件, 贴入如下代码:

const path = require('path');
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
  mode: 'development',
  entry: {
    // Set the single-spa config as the project entry point
    'single-spa.config': './single-spa.config.js',
  },
  output: {
    publicPath: '/dist/',
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        // Webpack style loader added so we can use materialize
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }, {
        test: /\.js$/,
        exclude: [path.resolve(__dirname, 'node_modules')],
        loader: 'babel-loader',
      }, {
        // This plugin will allow us to use AngularJS HTML templates
        test: /\.html$/,
        exclude: /node_modules/,
        loader: 'html-loader',
      },
    ],
  },
  node: {
    fs: 'empty'
  },
  resolve: {
    modules: [path.resolve(__dirname, 'node_modules')],
  },
  plugins: [
    // A webpack plugin to remove/clean the output folder before building
    new CleanWebpackPlugin(),
  ],
  devtool: 'source-map',
  externals: [],
  devServer: {
    historyApiFallback: true
  }
};

3. 配置 npm run scripts

打开根目录的 package.json 文件, 添加如下脚本:

"scripts": {
  "start": "webpack-dev-server --open",
  "build": "webpack --config webpack.config.js -p"
},

新建 index.html 文件

在根目录新建一个 index.html 文件, 内容如下:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>single-spa</title>
</head>

<body>
  <div id="navBar"></div>
  <div id="home"></div>
  <div id="angularJS"></div>

  <script src="/dist/single-spa.config.js"></script>
</body>

</html>

如果有一些公共的样式什么的, 可以在此时一并引入到 index.html 中来.

由于 Webpack 随后会将 single-spa.config.js 输出到 /dist 目录, 所以single-spa 的配置文件的路径会是指向 /dist 的.

注册应用

通过配置 single-spa.config.js 注册应用, 可以告诉 single-spa 如何去引导、装载、卸载我们的应用.

在根目录创建一个 single-spa.config.js 文件, 内容如下:

import { registerApplication, start } from 'single-spa';

registerApplication(
  // 注册的应用的名称
  'home',
  // 加载函数
  () => { },
  // 活动函数
  location => location.pathname === '' ||
    location.pathname === '/' ||
    location.pathname.startsWith('/home')
)

start();

该配置注册了一个 home app, 并分别指明了其名称、加载函数、活动函数.

加载函数

loadingFunction 必须是一个 async 函数或者其他返回一个 已决议 Promise 的函数, 意思也是一样的.

当 loading 一个 app 时, 会首先调用本函数, 从这个角度来看, 定位有些像钩子函数.

活动函数

activityFunction 必须是一个返回 boolean 值或其他可判断真假的值的纯函数, 当返回结果为真时, 本应用认为是活动状态.

Home App

初始化 home app

src/ 目录下新建 home/ 文件夹, 并且在 Home/ 目录下新建两个 js 文件:

  • home.app.js
  • root.component.js

安装 react 依赖:

yarn add react react-dom single-spa-react react-router-dom react-transition-group

定义 home app 生命周期

注册应用以后, single-spa 就已经开始监听应用的引导与状态了, 届时对应的 app 会对此做出响应.

single-spa-react 提供了将 react 注册为 singleSpaReact 所需的通用生命周期钩子, 可以很方便的注册.

singleSpaReact 需要 4 个参数:

  1. React 实例
  2. ReactDOM 实例
  3. root 组件
  4. domElementGetter 函数

打开 src/home/home.app.js 文件, 内容如下:

import React from 'react';

import ReactDOM from 'react-dom';

import singleSpaReact from 'single-spa-react';

import Home from './root.component';

// 获取自己的槽
function domElementGetter () {
  return document.querySelector('#home');
}

// 对应三个钩子函数 bootstrap, mount, unmount
const reactLifecycles = singleSpaReact({
  React, ReactDOM, rootComponent: Home, domElementGetter
});

export const bootstrap = [reactLifecycles.bootstrap];

export const mount = [reactLifecycles.mount];

export const unmount = [reactLifecycles.unmount];

构建 React app

打开 src/home/root.component.js 文件, 内容如下:

import React from "react";
import { TransitionGroup, CSSTransition } from "react-transition-group";
import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link,
  Redirect
} from "react-router-dom";
/* you'll need this CSS somewhere
.fade-enter {
  opacity: 0;
  z-index: 1;
}
.fade-enter.fade-enter-active {
  opacity: 1;
  transition: opacity 250ms ease-in;
}
*/
const AnimationExample = () => (
  <Router basename="/home">
    <Route
      render={({ location }) => (
        <div style={{position: 'relative', height: '100%'}}>
          <Route
            exact
            path="/"
            render={() => <Redirect to="/hsl/10/90/50" />}
          />
          <ul style={styles.nav}>
            <NavLink to="/hsl/10/90/50">Red</NavLink>
            <NavLink to="/hsl/120/100/40">Green</NavLink>
            <NavLink to="/rgb/33/150/243">Blue</NavLink>
            <NavLink to="/rgb/240/98/146">Pink</NavLink>
          </ul>
          <div style={styles.content}>
            <TransitionGroup>
              {/* no different than other usage of
                CSSTransition, just make sure to pass
                `location` to `Switch` so it can match
                the old location as it animates out
              */}
              <CSSTransition key={location.key} classNames="fade" timeout={300}>
                <Switch location={location}>
                  <Route exact path="/hsl/:h/:s/:l" component={HSL} />
                  <Route exact path="/rgb/:r/:g/:b" component={RGB} />
                  {/* Without this `Route`, we would get errors during
                    the initial transition from `/` to `/hsl/10/90/50`
                  */}
                  <Route render={() => <div>Not Found</div>} />
                </Switch>
              </CSSTransition>
            </TransitionGroup>
          </div>
        </div>
      )}
    />
  </Router>
);
const NavLink = props => (
  <li style={styles.navItem}>
    <Link {...props} style={{ color: "inherit" }} />
  </li>
);
const HSL = ({ match: { params } }) => (
  <div
    style={{
      ...styles.fill,
      ...styles.hsl,
      background: `hsl(${params.h}, ${params.s}%, ${params.l}%)`
    }}
  >
    hsl({params.h}, {params.s}%, {params.l}%)
  </div>
);
const RGB = ({ match: { params } }) => (
  <div
    style={{
      ...styles.fill,
      ...styles.rgb,
      background: `rgb(${params.r}, ${params.g}, ${params.b})`
    }}
  >
    rgb({params.r}, {params.g}, {params.b})
  </div>
);
const styles = {};
styles.fill = {
  position: "absolute",
  left: 0,
  right: 0,
  top: 0,
  bottom: 0
};
styles.content = {
  ...styles.fill,
  top: "40px",
  textAlign: "center"
};
styles.nav = {
  padding: 0,
  margin: 0,
  position: "absolute",
  top: 0,
  height: "40px",
  width: "100%",
  display: "flex"
};
styles.navItem = {
  textAlign: "center",
  flex: 1,
  listStyleType: "none",
  padding: "10px"
};
styles.hsl = {
  ...styles.fill,
  color: "white",
  paddingTop: "20px",
  fontSize: "30px"
};
styles.rgb = {
  ...styles.fill,
  color: "white",
  paddingTop: "20px",
  fontSize: "30px"
};
export default AnimationExample;

定义 loading function

打开根目录下的 single-spa.config.js 文件, 修改 loading function:

import { registerApplication, start } from 'single-spa';

registerApplication(
  // 注册的应用的名称
  'home',
  // 加载函数
  () => import('./src/home/home.app'), // <- here
  // 激活函数
  location => location.pathname === '' ||
    location.pathname === '/' ||
    location.pathname.startsWith('/home')
)

start();

这时候可以尝试启动一下.

Run yarn start, 如果一切正常的话, 将会成功启动, 并且看到如下页面:

2.png

NavBar App

创建和注册 NavBar app 的过程与 Home app 非常的相似. 不同点在于 NavBar 会导出一个带有生命周期的对象并且通过懒加载的方式获取各个应用的对象.

注册 navBar

single-spa.config.js 添加如下的代码:

registerApplication(
  'navBar',
  () => import('./src/navBar/navBar.app.js').then(module => module.navBar),
  () => true
)

navBar.app.js 目前还没有, 不过随后就会创建.

由于 navBar 是需要始终显示的, 因此, activityFunction 这里固定返回一个 true.

初始化 navBar app

在 src 目录下新建 navBar 目录, 并在其中分别创建 navBar.app.jsroot.component.js 文件.

可以通过在根路径执行以下命令创建:

mkdir src/navBar
touch src/navBar/navBar.app.js src/navBar/root.component.js

定义 navBar app 生命周期

打开 navBar.app.js 文件, 贴入下面的代码:

import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';
import NavBar from './root.component.js';
function domElementGetter() {
  return document.getElementById("navBar")
}
export const navBar = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: NavBar,
  domElementGetter,
})

编写 navBar app 页面

import React from 'react'
import {navigateToUrl} from 'single-spa'
const NavBar = () => (
  <nav>
    <div className="nav-wrapper">
      <a href="/" onClick={navigateToUrl} className="brand-logo">single-spa</a>
      <ul id="nav-mobile" className="right hide-on-med-and-down">
        <li><a href="/" onClick={navigateToUrl}>Home</a></li>
        <li><a href="/angularJS" onClick={navigateToUrl}>AngularJS</a></li>
      </ul>
    </div>
  </nav>
)
export default NavBar

AngularJs App

初始化 AngularJs app

执行如下命令:

mkdir src/angularJS
cd src/angularJS
touch angularJS.app.js root.component.js root.template.html routes.js app.module.js gifs.component.js gifs.template.html

为了演示子应用内部路由效果, 这里需要添加一些包:

yarn add angular angular-ui-router single-spa-angularjs

注册 AngularJs app

打开 single-spa.config.js 文件, 添加如下的代码:

function pathPrefix(prefix) {
    return function(location) {
        return location.pathname.startsWith(prefix);
    }
}

registerApplication(
  'angularJS',
  () => import ('./src/angularJS/angularJS.app.js'),
  pathPrefix('/angularJS')
)

定义 AngularJs app 生命周期

angularJs.app.js 文件中贴入如下代码:

import singleSpaAngularJS from 'single-spa-angularjs';

import angular from 'angular';

import './app.module.js';
import './routes.js';

const domElementGetter = () => document.querySelector('#angularJS');

const angularLifecycles = singleSpaAngularJS({
  angular,
  domElementGetter,
  mainAngularModule: 'angularJS-app',
  uiRouter: true,
  preserveGlobal: false,
});

export const bootstrap = [
  angularLifecycles.bootstrap,
];

export const mount = [
  angularLifecycles.mount,
];

export const unmount = [
  angularLifecycles.unmount,
];

配置 angular 应用

app.module.js

import angular from 'angular';
import 'angular-ui-router';
angular
.module('angularJS-app', ['ui.router']);

root.component.js

import angular from 'angular';

import template from './root.template.html';
angular
  .module('angularJS-app')
  .component('root', {
    template,
  });

root.template.html

<div ng-style='vm.styles'>
  <div class="container">
    <div class="row">
      <h4 class="light">
        Angular 1 example
      </h4>
      <p class="caption">
        This is a sample application written with Angular 1.5 and angular-ui-router.
      </p>
    </div>
    <div>
    <!-- These Routes will be set up in the routes.js file -->
      <a class="waves-effect waves-light btn-large" href="/angularJS/gifs" style="margin-right: 10px">
        Show me cat gifs
      </a>
      <a class="waves-effect waves-light btn-large" href="/angularJS" style="margin-right: 10px">
        Take me home
      </a>
    </div>
    <div class="row">
      <ui-view />
    </div>
  </div>
</div>

gifs.component.js

import angular from 'angular';

import template from './gifs.template.html';
angular
  .module('angularJS-app')
  .component('gifs', {
    template,
    controllerAs: 'vm',
    controller ($http) {
      const vm = this;
      $http
        .get('https://api.giphy.com/v1/gifs/search?q=cat&api_key=dc6zaTOxFJmzC')
        .then(response => {
          vm.gifs = response.data.data;
        })
        .catch(err => {
          setTimeout(() => {
            throw err;
          }, 0);
        });
    },
  });

gif.template.html

<div style="padding-top: 20px">
  <h4 class="light">
    Cat Gifs gifs
  </h4>
  <p>
  </p>
  <div ng-repeat="gif in vm.gifs" style="margin: 5px;">
    <img ng-src="{{gif.images.downsized_medium.url}}" class="col l3">
  </div>
</div>

设置 AngularJs app 内部路由

routes.js

import angular from 'angular';

import './gifs.component.js';
import './root.component.js';
angular
  .module('angularJS-app')
  .config(($stateProvider, $locationProvider) => {
    $locationProvider.html5Mode({
      enabled: true,
      requireBase: false,
    });
    $stateProvider
      .state('root', {
        url: '/angularJS',
        template: '<root />',
      })
      .state('root.gifs', {
        url: '/gifs',
        template: '<gifs />',
      });
  });

完成

虽然过程真的好繁琐, 但是不可否认以这种简单的例子来说确实成功了.

执行 yarn start 可以查看效果.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • single-spa 起因是看了一下掘金的这篇链接; [每日优鲜供应链前端团队微前端改造](https://jue...
    宝妞儿阅读 40,380评论 7 17
  • ng-model 指令ng-model 指令 绑定 HTML 元素 到应用程序数据。ng-model 指令也可以:...
    壬万er阅读 858评论 0 2
  • 写在开头 先说说为什么要写这篇文章, 最初的原因是组里的小朋友们看了webpack文档后, 表情都是这样的: (摘...
    Lefter阅读 5,269评论 4 31
  • 清晨的细雨 催生了炊烟升起 鸡鸣扰乱了思绪 谁家的额娘 早起 让我闻到了油锅里爆姜炒蒜的香气 是谁和我在故乡的情感...
    心者悟道阅读 140评论 0 3
  • 今天早上发生了一件不太美妙的事,沉寂已久的情绪还是压制不住,和别人吵架了。说实话,自从开始读书写字之后,发现我的情...
    夏末微光阅读 181评论 0 1