searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

基于NPM的workspace和Git submodule结合的前端monorepo工程化实践

2023-07-28 04:01:53
225
0

背景:从主项目衍生出多个子项目,子项目高度复用主项目组件和工具函数,如果拆分独立项目增加维护难度,如UI升级、组件和工具函数修改要同步到各个子项目中;即使工具函数、组件以npm包发布到私仓也要同步更新,要花大量时间维护;但是如果共用主项目构建配置,每次打包发布都是全量升级,且构建时间长、构建包大,发布过程中影响访问,且存在版本问题,难以保证主项目、子模块版本发布没有冲突。为解决上述问题,尝试把子项目核心代码拆分到独立项目,但是利用主项目的构建配置,通过命令选择构建目标,达到增量构建、影响最小的目的,下面以vue+webpack项目为例讲述详细过程。

一、初始化项目

用vue-cli初始化一个main-repo项目,在git仓库创建3个子项目:suba-repo、subc-repo、subm-repo,然后执行下面命令拉取子项目

git submodule path/suba-repo.git  packages/suba
git submodule path/subc-repo.git  packages/subc
git submodule path/subm-repo.git  packages/subm

待子项目clone后,在src下新建src/views目录,用来存放.vue文件的目录,然后建3个子项目同名目录并在目录下建一个index.router.js文件。这里有个潜在约束,每个子项目src目录下固定一个routes.js文件,用来导出当前项目的路由,由主项目通过require.context正则查找添加到vue-router路由中,查找路由代码如下

  const regRouter = require.context('@/views', true, /\.router\.(js|ts)$/)
  const routes = []
  regRouter.keys().forEach((filename) => {
    let router = regRouter(filename).default
    router = Array.isArray(router) ? router : [router]
    routes.push(...router)
  });

packages是npm workspace目录,为使工作空间生效,要在package.json中申明加入下面代码并npm install建立模块软连接,npm提供了初始化工作空间的命令 npm  init -w packages/xxx,但是先这样初始化再git submodule add xxx时提示目录不可用,这里折中处理

  "workspaces": [
    "packages\\suba",
    "packages\\subc",
    "packages\\subm"
  ]

然后在views/suba/index.router.js中引入packages/suba中定义的路由,代码如下,这里先这样写后面要通过脚本生成router文件.

import routes from 'packages/suba/routes'

export default routes

至此项目雏形已完成,npm run serve可以正常加载子项目中的路由,整体目录如下:

二、工程化处理

上面只是完成了一个monorepo项目雏形,但是遗留一些问题:主项目和submodule全量打包,构建时间长、包体积大,只能通过路由分割来访问目标页面,发布过程中彼此影响,可以解决需求但是不够精致,尝试过以下几种方式:

1、多文件多入口,子项目独立部署,修改nginx配置文件index页引导,解决路由分割和发布过程中的彼此影响,但是构建包体积大,冗余大量文件,不是很完美,但是配置简单,在vue.config.js中增加如下配置

  pages: {
    index: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'index.html',
    },
    suba: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'suba.html',
    },
    subc: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'subc.html',
    },
    subm: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'subm.html',
    },
  }

为了防止用户输入URL路由打开某一页面,要按需添加路由,比如我们可以为部署的链接加个前缀: xxxx.com/、xxxx.com/suba/、xxxx.com/subc/、xxxx.com/subm/,然后在index.router.js中通过location.pathname来判断导出空数组还是路由,示例suba/index.router.js如下

import routes from 'packages/suba/routes'

let array = []
const regSuba = /\/suba(\.html)?/
if (regSuba.test(location.pathname)) {
  array.push(...routes)
}
export default array

2、单文件单入口按需打包,子项目独立部署,最理想的实现方式,实现思路:因为xx.router.js通过require.context在webpack构建时匹配,那在打包前删除目标外的xx.router.js,比如打包子项目suba,就删除主项目、subc、subm目录下index.router.js文件,这样webpack在遍历AST语法树时就不会额外打包,实现我们的构建目标。

我们可以通过node脚本来实现xx.router.js的创建、删除,并在构建前后加上自定义任务处理:pre-build build  after-build clean pre-dev

pre-build:构建前置处理,获取命令行中的参数target删除target外的xx.router.js文件

build:webpack构建配置

after-build:构建后需要把dist目录copy到target所在的目录

clean:清除dist目录

pre-dev:为主项目、子模块创建xx.router.js文件,本地dev开发全量加在路由

用npm-run-all库处理任务顺序执行,并传递构建参数,整体目录如下,下面逐个解释贴代码

target用户生成目标的xx.router.index,suba.js代码如下

const path = require('path')
const fs = require('fs')

const cmd = `import routes from 'packages/suba/routes''

export default routes
`

function writeFile() {
  const destDir = path.resolve(__dirname, '../../src/views/suba')
  try {
    const filePath = path.resolve(destDir, './index.router.js')
    if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, cmd)
    console.log('suba index.router.js write success')
  } catch (e) {
    console.error(e)
  }
}

writeFile()

pre-build前置处理,通过glob匹配所有xx.router.js文件并删除,读取命令行中target并执行target脚本生成xx.router.js,代码如下

const path = require('path')
const {globSync} = require('glob')
const {rimrafSync} = require('rimraf')
const {runTarget} = require('./util')

const target = process.argv[2]
function clean() {
  try {
    const files = globSync('src/views/**/*.router.js')
    for (let file of files) {
      rimrafSync(file)
    }
  } catch(e) {
    console.log(e)
  }
}

function generate() {
  clean()
  runTarget(target)
}

generate()

after-build后置处理,将打包后的dist目录copy到target所在的目录中,可用作发布

const fse = require('fs-extra')
const path = require('path')
const fs = require('fs')

const target = process.argv[2]

const srcDir = path.resolve(__dirname, '../dist')

function copyTo(target) {
  const destDir = path.resolve(__dirname, '../packages/', target)
  const distDir = path.resolve(destDir, 'dist')
  if (!fs.existsSync(destDir) || !fs.existsSync(srcDir)) return
  if (fs.existsSync(distDir)) {
    fse.emptyDirSync(distDir)
  } else {
    fse.mkdirSync(distDir)
  }
  fse.copy(srcDir, distDir).then(() => {
    console.log('copy successful')
  }).catch((e) => {
    console.log(e)
  })
}

copyTo(target)

clean任务直接删除dist目录,pre-dev任务执行target目录下所有的脚本任务,即runTarget每个文件,为每个目录生成xx.router.js文件。

util.js代码如下

const fs = require('fs')
const path = require('path')
const cmd = require('node-cmd')

exports.runTarget = function (target) {
  try {
    const cmdPath = path.resolve(__dirname, `./target/${target}.js`)
    if (fs.existsSync(cmdPath)) cmd.runSync('node ' + cmdPath)
  } catch(e) {
    console.log('RunTarget Error')
    console.log(e)
  }
}

接下来在package.json中的script中添加构建target的命令,如build:suba

  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "pre-init": "node ./scripts/pre-dev",
    "pre-build": "node ./scripts/pre-build",
    "after-build": "node ./scripts/after-build",
    "clean": "rimraf dist",
    "build:suba": "npm-run-all \"pre-build -- suba\" build \"after-build -- suba\" clean pre-init --sequential"
  },

至此,整个工程化完成了,看似简单,贵在实践和思考。

0条评论
0 / 1000
2****m
1文章数
0粉丝数
2****m
1 文章 | 0 粉丝
2****m
1文章数
0粉丝数
2****m
1 文章 | 0 粉丝
原创

基于NPM的workspace和Git submodule结合的前端monorepo工程化实践

2023-07-28 04:01:53
225
0

背景:从主项目衍生出多个子项目,子项目高度复用主项目组件和工具函数,如果拆分独立项目增加维护难度,如UI升级、组件和工具函数修改要同步到各个子项目中;即使工具函数、组件以npm包发布到私仓也要同步更新,要花大量时间维护;但是如果共用主项目构建配置,每次打包发布都是全量升级,且构建时间长、构建包大,发布过程中影响访问,且存在版本问题,难以保证主项目、子模块版本发布没有冲突。为解决上述问题,尝试把子项目核心代码拆分到独立项目,但是利用主项目的构建配置,通过命令选择构建目标,达到增量构建、影响最小的目的,下面以vue+webpack项目为例讲述详细过程。

一、初始化项目

用vue-cli初始化一个main-repo项目,在git仓库创建3个子项目:suba-repo、subc-repo、subm-repo,然后执行下面命令拉取子项目

git submodule path/suba-repo.git  packages/suba
git submodule path/subc-repo.git  packages/subc
git submodule path/subm-repo.git  packages/subm

待子项目clone后,在src下新建src/views目录,用来存放.vue文件的目录,然后建3个子项目同名目录并在目录下建一个index.router.js文件。这里有个潜在约束,每个子项目src目录下固定一个routes.js文件,用来导出当前项目的路由,由主项目通过require.context正则查找添加到vue-router路由中,查找路由代码如下

  const regRouter = require.context('@/views', true, /\.router\.(js|ts)$/)
  const routes = []
  regRouter.keys().forEach((filename) => {
    let router = regRouter(filename).default
    router = Array.isArray(router) ? router : [router]
    routes.push(...router)
  });

packages是npm workspace目录,为使工作空间生效,要在package.json中申明加入下面代码并npm install建立模块软连接,npm提供了初始化工作空间的命令 npm  init -w packages/xxx,但是先这样初始化再git submodule add xxx时提示目录不可用,这里折中处理

  "workspaces": [
    "packages\\suba",
    "packages\\subc",
    "packages\\subm"
  ]

然后在views/suba/index.router.js中引入packages/suba中定义的路由,代码如下,这里先这样写后面要通过脚本生成router文件.

import routes from 'packages/suba/routes'

export default routes

至此项目雏形已完成,npm run serve可以正常加载子项目中的路由,整体目录如下:

二、工程化处理

上面只是完成了一个monorepo项目雏形,但是遗留一些问题:主项目和submodule全量打包,构建时间长、包体积大,只能通过路由分割来访问目标页面,发布过程中彼此影响,可以解决需求但是不够精致,尝试过以下几种方式:

1、多文件多入口,子项目独立部署,修改nginx配置文件index页引导,解决路由分割和发布过程中的彼此影响,但是构建包体积大,冗余大量文件,不是很完美,但是配置简单,在vue.config.js中增加如下配置

  pages: {
    index: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'index.html',
    },
    suba: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'suba.html',
    },
    subc: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'subc.html',
    },
    subm: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'subm.html',
    },
  }

为了防止用户输入URL路由打开某一页面,要按需添加路由,比如我们可以为部署的链接加个前缀: xxxx.com/、xxxx.com/suba/、xxxx.com/subc/、xxxx.com/subm/,然后在index.router.js中通过location.pathname来判断导出空数组还是路由,示例suba/index.router.js如下

import routes from 'packages/suba/routes'

let array = []
const regSuba = /\/suba(\.html)?/
if (regSuba.test(location.pathname)) {
  array.push(...routes)
}
export default array

2、单文件单入口按需打包,子项目独立部署,最理想的实现方式,实现思路:因为xx.router.js通过require.context在webpack构建时匹配,那在打包前删除目标外的xx.router.js,比如打包子项目suba,就删除主项目、subc、subm目录下index.router.js文件,这样webpack在遍历AST语法树时就不会额外打包,实现我们的构建目标。

我们可以通过node脚本来实现xx.router.js的创建、删除,并在构建前后加上自定义任务处理:pre-build build  after-build clean pre-dev

pre-build:构建前置处理,获取命令行中的参数target删除target外的xx.router.js文件

build:webpack构建配置

after-build:构建后需要把dist目录copy到target所在的目录

clean:清除dist目录

pre-dev:为主项目、子模块创建xx.router.js文件,本地dev开发全量加在路由

用npm-run-all库处理任务顺序执行,并传递构建参数,整体目录如下,下面逐个解释贴代码

target用户生成目标的xx.router.index,suba.js代码如下

const path = require('path')
const fs = require('fs')

const cmd = `import routes from 'packages/suba/routes''

export default routes
`

function writeFile() {
  const destDir = path.resolve(__dirname, '../../src/views/suba')
  try {
    const filePath = path.resolve(destDir, './index.router.js')
    if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, cmd)
    console.log('suba index.router.js write success')
  } catch (e) {
    console.error(e)
  }
}

writeFile()

pre-build前置处理,通过glob匹配所有xx.router.js文件并删除,读取命令行中target并执行target脚本生成xx.router.js,代码如下

const path = require('path')
const {globSync} = require('glob')
const {rimrafSync} = require('rimraf')
const {runTarget} = require('./util')

const target = process.argv[2]
function clean() {
  try {
    const files = globSync('src/views/**/*.router.js')
    for (let file of files) {
      rimrafSync(file)
    }
  } catch(e) {
    console.log(e)
  }
}

function generate() {
  clean()
  runTarget(target)
}

generate()

after-build后置处理,将打包后的dist目录copy到target所在的目录中,可用作发布

const fse = require('fs-extra')
const path = require('path')
const fs = require('fs')

const target = process.argv[2]

const srcDir = path.resolve(__dirname, '../dist')

function copyTo(target) {
  const destDir = path.resolve(__dirname, '../packages/', target)
  const distDir = path.resolve(destDir, 'dist')
  if (!fs.existsSync(destDir) || !fs.existsSync(srcDir)) return
  if (fs.existsSync(distDir)) {
    fse.emptyDirSync(distDir)
  } else {
    fse.mkdirSync(distDir)
  }
  fse.copy(srcDir, distDir).then(() => {
    console.log('copy successful')
  }).catch((e) => {
    console.log(e)
  })
}

copyTo(target)

clean任务直接删除dist目录,pre-dev任务执行target目录下所有的脚本任务,即runTarget每个文件,为每个目录生成xx.router.js文件。

util.js代码如下

const fs = require('fs')
const path = require('path')
const cmd = require('node-cmd')

exports.runTarget = function (target) {
  try {
    const cmdPath = path.resolve(__dirname, `./target/${target}.js`)
    if (fs.existsSync(cmdPath)) cmd.runSync('node ' + cmdPath)
  } catch(e) {
    console.log('RunTarget Error')
    console.log(e)
  }
}

接下来在package.json中的script中添加构建target的命令,如build:suba

  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "pre-init": "node ./scripts/pre-dev",
    "pre-build": "node ./scripts/pre-build",
    "after-build": "node ./scripts/after-build",
    "clean": "rimraf dist",
    "build:suba": "npm-run-all \"pre-build -- suba\" build \"after-build -- suba\" clean pre-init --sequential"
  },

至此,整个工程化完成了,看似简单,贵在实践和思考。

文章来自个人专栏
文章 | 订阅
0条评论
0 / 1000
请输入你的评论
0
0