基于lerna和single-spa,sysyem.js搭建vue的微前端框架

為什么要用微前端

目前隨著前端的不斷發(fā)展谷徙,企業(yè)工程項目體積越來越大舀寓,頁面越來越多,項目變得十分臃腫市埋,維護起來也十分困難黎泣,有時我們僅僅更改項目簡單樣式,都需要整個項目重新打包上線缤谎,給開發(fā)人員造成了不小的麻煩抒倚,也非常浪費時間。老項目為了融入到新項目也需要不斷進行重構(gòu)坷澡,造成的人力成本也非常的高托呕。

微前端架構(gòu)具備以下幾個核心價值:

  • 技術(shù)棧無關(guān) 主框架不限制接入應(yīng)用的技術(shù)棧,子應(yīng)用具備完全自主權(quán)
  • 獨立開發(fā)频敛、獨立部署 子應(yīng)用倉庫獨立项郊,前后端可獨立開發(fā),部署完成后主框架自動完成同步更新
  • 獨立運行時 每個子應(yīng)用之間狀態(tài)隔離斟赚,運行時狀態(tài)不共享

single-spa實現(xiàn)原理

首先對微前端路由進行注冊呆抑,使用single-spa充當微前端加載器,并作為項目單一入口來接受所有頁面URL的訪問汁展,根據(jù)頁面URL與微前端的匹配關(guān)系鹊碍,選擇加載對應(yīng)的微前端模塊,再由該微前端模塊進行路由響應(yīng)URL食绿,即微前端模塊中路由找到相應(yīng)的組件侈咕,渲染頁面內(nèi)容。

sysyem.js的作用及好處

system.js的作用就是動態(tài)按需加載模塊器紧。假如我們子項目都使用了vue,vuex,vue-router耀销,每個項目都打包一次,就會很浪費铲汪。system.js可以配合webpackexternals屬性熊尉,將這些模塊配置成外鏈,然后實現(xiàn)按需加載,當然了掌腰,你也可以直接用script標簽將這些公共的js全部引入狰住,借助system.js這個插件,我們只需要將子項目的app.js暴露給它即可齿梁。

什么是Lerna

當前端項目變得越來越大的時候催植,我們通常會將公共代碼拆分出來肮蛹,成為一個個獨立的npm包進行維護。但是這樣一來创南,各種包之間的依賴管理就十分讓人頭疼伦忠。為了解決這種問題,我們可以將不同的npm包項目都放在同一個項目來管理稿辙。這樣的項目開發(fā)策略也稱作monorepo昆码。Lerna就是這樣一個你更好地進行這項工作的工具。Lerna是一個使用gitnpm來處理多包依賴管理的工具,利用它能夠自動幫助我們管理各種模塊包之間的版本依賴關(guān)系邻储。目前赋咽,已經(jīng)有很多公共庫都使用Lerna作為它們的模塊依賴管理工具了,如:babel, create-react-app, react-router, jest等芥备。

  1. 解決包之間的依賴關(guān)系冬耿。
  2. 通過git倉庫檢測改動舌菜,自動同步萌壳。
  3. 根據(jù)相關(guān)的git提交的commit,生成CHANGELOG日月。

你還需要全局安裝 Lerna:

npm install -g lerna

基于vue微前端項目搭建

1.項目初始化

mkdir lerna-project & cd lerna-project`

lerna init

執(zhí)行成功后袱瓮,目錄下將會生成這樣的目錄結(jié)構(gòu)。

├── README.md
├── lerna.json  # Lerna 配置文件
├── package.json
├── packages    # 應(yīng)用包目錄

2.Set up yarn的workspaces模式

默認是npm, 而且每個子package都有自己的node_modules爱咬,通過這樣設(shè)置后尺借,只有頂層有一個node_modules

{
  "packages": [
    "packages/*"
  ],
  "useWorkspaces": true,
  "npmClient": "yarn",
  "version": "0.0.0"
}

同時package.json 設(shè)置 private 為 true,防止根目錄被發(fā)布到 npm

{
 "private": true,
 "workspaces": [
    "packages/*"
 ]
}

配置根目錄下的 lerna.json 使用 yarn 客戶端并使用 workspaces

yarn config set workspaces-experimental true

3.注冊子應(yīng)用

第一步:使用vue-cli創(chuàng)建子應(yīng)用

# 進入packages目錄
cd packages

# 創(chuàng)建應(yīng)用
vue create app1

// 項目目錄結(jié)構(gòu)
├── public
├── src
│   ├── main.js
│   ├── assets
│   ├── components
│   └── App.vue
├── vue.config.js
├── package.json
├── README.md
└── yarn.lock

第二步:使用vue-cli-plugin-single-spa插件快速生成spa項目

# 會自動修改main.js加入singleSpaVue精拟,和生成set-public-path.js
vue add single-spa

生成的main.js文件

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: {
    // el: '#app', // 沒有掛載點默認掛載到body下
    render: (h) => h(App),
    router,
    store: window.rootStore,
  },
});

export const bootstrap = [
  vueLifecycles.bootstrap
];
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;

第三步:設(shè)置環(huán)境變量.env

# 應(yīng)用名稱
VUE_APP_NAME=app1
# 應(yīng)用根路徑燎斩,默認值為: '/',如果要發(fā)布到子目錄蜂绎,此值必須指定
VUE_APP_BASE_URL=/
# 端口栅表,子項目開發(fā)最好設(shè)置固定端口, 避免頻繁修改配置文件师枣,設(shè)置一個固定的特殊端口怪瓶,盡量避免端口沖突。
port=8081

第四步: 設(shè)置vue.config.js修改webpack配置

const isProduction = process.env.NODE_ENV === 'production'
const appName = process.env.VUE_APP_NAME
const port = process.env.port
const baseUrl = process.env.VUE_APP_BASE_URL
module.exports = {
  // 防止開發(fā)環(huán)境下的加載問題
  publicPath: isProduction ? `${baseUrl}${appName}/` : `http://localhost:${port}/`,

    // css在所有環(huán)境下践美,都不單獨打包為文件洗贰。這樣是為了保證最小引入(只引入js)
    css: {
        extract: false
    },

  productionSourceMap: false,

  outputDir: path.resolve(dirname, `../../dist/${appName}`), // 統(tǒng)一打包到根目錄下的dist下
  chainWebpack: config => {
    config.devServer.set('inline', false)
    config.devServer.set('hot', true)
    config.externals(['vue', 'vue-router'])

    // 保證打包出來的是一個js文件,供主應(yīng)用進行加載
    config.output.library(appName).libraryTarget('umd')

    config.externals(['vue', 'vue-router', 'vuex'])  // 一定要引否則說沒有注冊

    if (process.env.NODE_ENV !== 'production') {
      // 打包目標文件加上 hash 字符串陨倡,禁止瀏覽器緩存
      config.output.filename('js/index.[hash:8].js')
    }
  },
}

4.新建主項目

第一步:添加主項目package

# 進入packages目錄
cd packages
# 創(chuàng)建一個packge目錄, 進入root-html-file目錄
mkdir root-html-file && cd root-html-file
# 初始化一個package
npm init -y

第二步:新建主項目index.html

主應(yīng)用主要是扮演路由分發(fā)敛滋,資源加載的作用的角色

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>Vue-Microfrontends</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="importmap-type" content="systemjs-importmap">
    <!-- 配置文件注意寫成絕對路徑:/開頭,否則訪問子項目的時候重定向的index.html兴革,相對目錄會出錯 -->
   <script type="systemjs-importmap" src="importmap.json"></script>
    <link rel="preload"  as="script" crossorigin="anonymous" />
    <link rel="preload"  as="script" crossorigin="anonymous" />
    <!-- systemjs的包 -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/system.min.js"></script>
    <!-- 用于解析子包的解析 -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/amd.min.js"></script>
    <!-- 解析包的default -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/use-default.min.js"></script>
    <!-- systemjs的包 -->
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

第三步:編輯importMapjson文件,配置對應(yīng)子應(yīng)用的文件

{
  "imports": {
    "navbar": "http://localhost:8888/js/app.js",
    "app1": "http://localhost:8081/js/app.js",
    "app2": "http://localhost:8082/js/app.js",
    "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js",
    "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js",
    "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js",
    "vuex": "https://cdn.jsdelivr.net/npm/vuex@3.1.2/dist/vuex.min.js"
  }
}

到時systemjs可以直接去import矛缨,具體作用參考systemjs

第四步:注冊app應(yīng)用

// 注冊子應(yīng)用
singleSpa.registerApplication(
  'app1', // systemjs-webpack-interop, 去匹配子應(yīng)用的名稱
  () => System.import('app1'), // 資源路徑
  location => location.hash.startsWith('/app1') // 資源激活的
)

singleSpa.registerApplication(
  'app2', // systemjs-webpack-interop, 去匹配子應(yīng)用的名稱
  () => System.import('app2'), // 資源路徑
  location => location.hash.startsWith('#/app2') // 資源激活的
)
singleSpa.registerApplication(
  'app2', // systemjs-webpack-interop, 去匹配子應(yīng)用的名稱
  () => System.import('app2'), // 資源路徑
  location => location.hash.startsWith('#/app2') // 資源激活的
)
// 開始singleSpa
singleSpa.start();

第五步:項目開發(fā)

項目的基本目錄結(jié)構(gòu)如下:

.
├── README.md
├── lerna.json  # Lerna 配置文件
├── node_modules
├── package.json
├── packages    # 應(yīng)用包目錄
│   ├── app1    # 應(yīng)用1
│   ├── app2    # 應(yīng)用2
│   ├── navbar   # 主應(yīng)用
│   └── root-html-file  # 入口
└── yarn.lock

如上圖所示,所有的應(yīng)用都存放在 packages 目錄中。其中 root-html-file 為入口項目箕昭,navbar 為常駐的主應(yīng)用灵妨,這兩者在開發(fā)過程中必須啟動相應(yīng)的服務(wù)。其他為待開發(fā)的子應(yīng)用落竹。

項目的優(yōu)化

抽取子應(yīng)用資源配置

在主應(yīng)用中抽取所有的子應(yīng)用到一個通用的app.config.json文件配置

{
  "apps": [
    {
      "name": "navbar", // 應(yīng)用名稱
      "main": "http://localhost:8888/js/app.js", // 應(yīng)用的入口
      "path": "/", // 是否為常駐應(yīng)用
      "base": true, // 是否使用history模式
      "hash": true // 是否使用hash模式
    },
    {
      "name": "app1",
      "main": "http://localhost:8081/js/app.js",
      "path": "/app1",
      "base": false,
      "hash": true
    },
    {
      "name": "app2",
      "main": "http://localhost:8082/js/app.js",
      "path": "/app2",
      "base": false,
      "hash": true
    }
  ]
}

主應(yīng)用的入口文件中注冊子應(yīng)用

try {
    // 讀取應(yīng)用配置并注冊應(yīng)用
    const config = await System.import(`/app.config.json`)
    const { apps } = config.default
    apps && apps.forEach((app: AppConfig) => {
      const { commonsChunks: chunks } = app
      registerApp(singleSpa, app)
    })
    singleSpa.start()
  } catch (e) {
    throw new Error('應(yīng)用配置加載失敗')
  }

/**
 * 注冊應(yīng)用
 * */
function registerApp (spa, app) {
  const activityFunc = app.hash ? hashPrefix(app) : pathPrefix(app)
  spa.registerApplication(
    app.name,
    () => System.import(app.main),
    app.base ? (() => true) : activityFunc,
    {
      store
    }
  )
}


/**
 * hash匹配模式
 * @param app 應(yīng)用配置
 */
 function hashPrefix (app) {
  return function (location) {
    if (!app.path) return true

    if (Array.isArray(app.path)) {
      if (app.path.some(path => location.hash.startsWith(`#${path}`))) {
        return true
      }
    } else if (location.hash.startsWith(`#${app.path}`)) {
      return true
    }

    return false
  }
}

/**
 * 普通路徑匹配模式
 * @param app 應(yīng)用配置
 */
function pathPrefix (app) {
  return function (location) {
    if (!app.path) return true

    if (Array.isArray(app.path)) {
      if (app.path.some(path => location.pathname.startsWith(path))) {
        return true
      }
    } else if (location.pathname.startsWith(app.path)) {
      return true
    }

    return false
  }
}

所有子項目公用一個使用vuex

在主項目index.html注冊vuex的插件泌霍,通過window對象存儲,子項目加載啟動時候通過registerModule方式注入子應(yīng)用的模塊和自身的vue實例上

// 主應(yīng)用的js中
Vue.use(Vuex)
window.rootStore = new Vuex.Store() // 全局注冊唯一的vuex, 供子應(yīng)用的共享


// 子應(yīng)用的main.js
export const bootstrap = [
  () => {
    return new Promise(async (resolve, reject) => {
      // 注冊當前應(yīng)用的store
      window.rootStore.registerModule(VUE_APP_NAME, store)
      resolve()
    })
  },
  vueLifecycles.bootstrap
];
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;


樣式隔離

我們使用postcss的一個插件:postcss-selector-namespace述召。
他會把你項目里的所有css都會添加一個類名前綴朱转。這樣就可以實現(xiàn)命名空間隔離。
首先积暖,我們先安裝這個插件:npm install postcss-selector-namespace --save -d
項目目錄下新建 postcss.config.js藤为,使用插件:

// postcss.config.js

module.exports = {
  plugins: {
    // postcss-selector-namespace: 給所有css添加統(tǒng)一前綴,然后父項目添加命名空間
    'postcss-selector-namespace': {
      namespace(css) {
        // element-ui的樣式不需要添加命名空間
        if (css.includes('element-variables.scss')) return '';
        return '.app1' // 返回要添加的類名
      }
    },
  }
}

然后父項目添加命名空間

// 切換子系統(tǒng)的時候給body加上對應(yīng)子系統(tǒng)的 class namespace
window.addEventListener('single-spa:app-change', () => {
  const app = singleSpa.getMountedApps().pop();
  const isApp = /^app-\w+$/.test(app);
  if (app) document.body.className = app;
});

生產(chǎn)部署利用manifest自動加載生成子應(yīng)用的app.config.json路徑和importMapjson

stats-webpack-plugin可以在你每次打包結(jié)束后夺刑,都生成一個manifest.json 文件缅疟,里面存放著本次打包的 public_path bundle list chunk list 文件大小依賴等等信息”樵福可以根據(jù)這個信息來生成子應(yīng)用的app.config.json路徑和importMapjson.

npm install stats-webpack-plugin --save -d

vue.config.js中使用:

{
    configureWebpack: {
        plugins: [
            new StatsPlugin('manifest.json', {
                chunkModules: false,
                entrypoints: true,
                source: false,
                chunks: false,
                modules: false,
                assets: false,
                children: false,
                exclude: [/node_modules/]
            }),
        ]
    }
}

打包完成最后通過腳本generate-app.js生成對應(yīng),子應(yīng)用的json路徑和importMapjson

const path = require('path')
const fs = require('fs')
const root = process.cwd()
console.log(`當前工作目錄是: ${root}`);
const dir = readDir(root)
const jsons = readManifests(dir)
generateFile(jsons)

console.log('生成配置文件成功')


function readDir(root) {
  const manifests = []
  const files = fs.readdirSync(root)
  console.log(files)
  files.forEach(i => {
    const filePath = path.resolve(root, '.', i)
    const stat = fs.statSync(filePath);
    const is_direc = stat.isDirectory();

    if (is_direc) {
      manifests.push(filePath)
    }

  })
  return manifests
}


function readManifests(files) {
  const jsons = {}
  files.forEach(i => {
    const manifest = path.resolve(i, './manifest.json')
    if (fs.existsSync(manifest)) {
      const { publicPath, entrypoints: { app: { assets } } } = require(manifest)
      const name = publicPath.slice(1, -1)
      jsons[name] = `${publicPath}${assets}`
    }
  })

  return jsons

}



function generateFile(jsons) {
  const { apps } = require('./app.config.json')
  const { imports } = require('./importmap.json')
  Object.keys(jsons).forEach(key => {
    imports[key] = jsons[key]
  })
  apps.forEach(i => {
    const { name } = i

    if (jsons[name]) {
      i.main = jsons[name]
    }
  })

  fs.writeFileSync('./importmap.json', JSON.stringify(
    {
      imports
    }
  ))

  fs.writeFileSync('./app.config.json', JSON.stringify(
    {
      apps
    }
  ))

}


應(yīng)用打包

在根目錄執(zhí)行build命令, packages里面的所有build命令都會執(zhí)行,這會在根目錄生成 dist 目錄下,

lerna run build

最終生成的目錄結(jié)構(gòu)如下

.
├── dist
│   ├── app1/
│   ├── app2/
    ├── navbar/
│   ├── app.config.json
│   ├── importmap.json
│   ├── main.js
│   ├── generate-app.js
│   └── index.html

最后存淫,執(zhí)行以下命令生成 generate-app.js,重新生成帶hash資源路徑的importmap.jsonapp.config.json文件:

cd dist && node generate-app.js

文章中的完整demo文件地址,如果覺得有用的話麻煩給個star

參考文檔

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末沼填,一起剝皮案震驚了整個濱河市桅咆,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌坞笙,老刑警劉巖岩饼,帶你破解...
    沈念sama閱讀 207,248評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異薛夜,居然都是意外死亡籍茧,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評論 2 381
  • 文/潘曉璐 我一進店門却邓,熙熙樓的掌柜王于貴愁眉苦臉地迎上來硕糊,“玉大人,你說我怎么就攤上這事腊徙〖蚴” “怎么了?”我有些...
    開封第一講書人閱讀 153,443評論 0 344
  • 文/不壞的土叔 我叫張陵撬腾,是天一觀的道長螟蝙。 經(jīng)常有香客問我,道長民傻,這世上最難降的妖魔是什么胰默? 我笑而不...
    開封第一講書人閱讀 55,475評論 1 279
  • 正文 為了忘掉前任场斑,我火速辦了婚禮,結(jié)果婚禮上牵署,老公的妹妹穿的比我還像新娘漏隐。我一直安慰自己,他們只是感情好奴迅,可當我...
    茶點故事閱讀 64,458評論 5 374
  • 文/花漫 我一把揭開白布青责。 她就那樣靜靜地躺著,像睡著了一般取具。 火紅的嫁衣襯著肌膚如雪脖隶。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,185評論 1 284
  • 那天暇检,我揣著相機與錄音产阱,去河邊找鬼。 笑死块仆,一個胖子當著我的面吹牛构蹬,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播榨乎,決...
    沈念sama閱讀 38,451評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼怎燥,長吁一口氣:“原來是場噩夢啊……” “哼瘫筐!你這毒婦竟也來了蜜暑?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,112評論 0 261
  • 序言:老撾萬榮一對情侶失蹤策肝,失蹤者是張志新(化名)和其女友劉穎肛捍,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體之众,經(jīng)...
    沈念sama閱讀 43,609評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡拙毫,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,083評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了棺禾。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片缀蹄。...
    茶點故事閱讀 38,163評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖膘婶,靈堂內(nèi)的尸體忽然破棺而出缺前,到底是詐尸還是另有隱情,我是刑警寧澤悬襟,帶...
    沈念sama閱讀 33,803評論 4 323
  • 正文 年R本政府宣布衅码,位于F島的核電站,受9級特大地震影響脊岳,放射性物質(zhì)發(fā)生泄漏逝段。R本人自食惡果不足惜垛玻,卻給世界環(huán)境...
    茶點故事閱讀 39,357評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望奶躯。 院中可真熱鬧帚桩,春花似錦、人聲如沸嘹黔。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,357評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽参淹。三九已至醉锄,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間浙值,已是汗流浹背恳不。 一陣腳步聲響...
    開封第一講書人閱讀 31,590評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留开呐,地道東北人烟勋。 一個月前我還...
    沈念sama閱讀 45,636評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像筐付,于是被迫代替她去往敵國和親卵惦。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,925評論 2 344