背景:从主项目衍生出多个子项目,子项目高度复用主项目组件和工具函数,如果拆分独立项目增加维护难度,如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"
},
至此,整个工程化完成了,看似简单,贵在实践和思考。