多项目应用开发架构和多进程间开发构建流程优化分析

33次阅读

共计 11468 个字符,预计需要花费 29 分钟才能阅读完成。

多我的项目利用开发架构和多过程间开发构建流程优化剖析

随着业务复杂度的回升,前端我的项目不论是从代码量上,还是从依赖关系上都会爆炸式增长。对于单页面利用或者多利用我的项目来说,各个利用之间的关系也会更加简单,多个利用之间如何配合,如何保护互相关系?公共库版本如何治理?如何兼顾开发体验和上线构建效率?这些话题随着前端业务的倒退,逐步浮出水面。

这篇文章我就以一个成熟的大型项目为例,从其中一个优化点延长,谈一谈 前端现代化开发和架构设计形式的思考和教训。当然,每一种格调的我的项目组织形式都各有特点,如何在这些不同架构下,打造顺畅的开发构建流程,继续优化提效是一个十分值得深刻的话题。

多我的项目利用开发架构设计

对于一个大型简单的业务,如果咱们将所有业务逻辑开发放在同一个 Git 仓库下,那么短暂以往会导致我的项目臃肿不堪,难以保护。针对于此,历史上咱们习惯将这个“超级 Git 仓库”,扩散成多个小的 Git 仓库,一个我的项目拆分成多个利用。然而这样的做法并没有解决多个利用之间的耦合和公共逻辑的反复。甚至极其的场景下,如果利用之间具备强关联,这样的多仓库设计势必造成开发调试和上线的苦楚。

针对上述背景,目前更加现代化的前端治理格调和架构设计次要有两种(基于 Git submodule 能力的计划不再本文思考范畴之内):

  • 基于 Webpack 多入口的我的项目拆分和多利用打包构建
  • 基于 Monorepo 格调的我的项目设计

这两种解决方案都保留了这个惟一的“超级 Git 仓库”,然而它们的设计思维却有不同。咱们能够简略了解为:

  • 「基于 Webpack 多入口的我的项目拆分和多利用打包构建」更加适宜于业务利用我的项目,在多我的项目内聚的前提下,保障了开发和调试的便利性,同时能够拆分构建和打包流程,显著提高效率
  • 「基于 Monorepo 格调的我的项目组织」仿佛更加适宜库的编写,应用 Lerna 这种工具的 Monorepo 计划带有显明的发版能力和强烈的工程库格调。这种模式下,仍然具备上述提到的开发和调试便利性,构建和打包的原子性,堪称「你喜爱的样子我都有」

这两种解决方案的原理和实现这里我不再赘述,感兴趣的读者能够订阅频道,后续我会具体解析,而本篇将会持续从另一种角度来继续深刻。

Monorepo VS Multirepo

因为下文波及到的场景采纳了 Monorepo 格调的治理形式,且这是我集体十分推崇的计划,因而这里我略微介绍一下相干概念。对于相干概念曾经有过理解的读者,能够间接跳过这部分,进行下局部的浏览。

古代治理和组织代码的形式次要分为两种:

  • Multirepo
  • Monorepo

顾名思义,Multirepo 就是将利用依照模块别离治理在不同的仓库中;而 Monorepo 就是将利用中所有的模块全副一股脑放在同一个我的项目中,不再须要在独自发包、测试,且所有代码都在一个我的项目中治理,在开发阶段可能更早地复现 bugs,裸露问题,更不便进行调试。

这是我的项目代码在组织上的不同哲学:一种提倡分而治之,一种提倡集中管理。到底是把鸡蛋放在同一个篮子里,还是提倡多元化,这就要依据团队的格调以及面临的理论场景进行选型。

我试图从 Multirepo 和 Monorepo 两种解决形式的各自弊病说起,心愿给读者更多的参考和倡议。

对于 Multirepo,存在以下问题:

  • 开发调试以及版本更新效率低下
  • 团队技术选型扩散,有可能不同的库实现格调存在较大差别
  • Changelog 梳理艰难,issue 管理混乱(对于开源库来说)

而 Monorepo 毛病也非常明显:

  • 库体积超大,目录构造复杂度回升
  • 须要应用保护 Monorepo 的工具,这就意味着学习老本

社区上的经典选型案例:

  • Babel 和 React 都是典型的 Monorepo

他们的 issue 和 pull request 都集中到惟一的我的项目中,changelog 能够简略地从一份 commit 列表梳理进去。咱们参看 React 我的项目仓库,从其目录构造即可看出其强烈的 Monorepo 格调:

react-16.2.0/
  packages/
    react/
    react-art/
    react-.../

因而,react 和 react-dom 在 npm 上是两个不同的库,他们只不过在 react 我的项目中通过 Monorepo 的形式进行治理。

而驰名的 rollup 目前是 Multirepo 组织。

对于 Monorepo 和 Multirepo,抉择了 Monorepo 的 babel 奉献了文章:Why is Babel a Monorepo? 该文章思维,前文曾经有所指出,这里不再开展。

Monorepo 格调在构建中的挑战

上述对于 Monorepo 的优缺点剖析次要是针对于其治理格调自身来说的。作为工程师,咱们还是要在实践中总结和发现问题,比方我还要补充 Monorepo 理论落地之后,会面临的两个挑战:

  • Monorepo 我的项目过大,导致每次上线构建流程过长,存在不必要的工夫老本耗费
  • Monorepo 我的项目子利用和依赖在开发阶段存在相互「烦扰」的损耗

先说第一个挑战点,对于一个 Monorepo 我的项目来说,尽管能够在开发阶段独自构建打包,然而在整个我的项目上线时,却须要全量构建。举例来说,一个 Monorepo 我的项目蕴含利用:App1,App2,App3,Dependecies。当咱们对 App1 进行改变时,因为所有利用都在同一个 Git 仓库中,导致上线时 App2 和 App3 依然须要从新构建,这种构建显然是不必要的(App1,App2,App3 不同利用应该相互独立),这和 Multirepo 相比,这无疑减少了上线构建老本。这里须要留神的是:如果 Dependecies 改变,那么所有依赖 Dependecies 的我的项目比方 App1,App2,App3 的从新构建是必要且必须的。

这种“缺点”咱们往往应用「增量构建」的计划来优化。这个话题很有意思,比方波及到「如何能找出每次提交的改变点所对应的原子构建工作」,咱们这里暂不开展,仍然回到本文的主题上。

再说第二个挑战点,「Monorepo 我的项目子利用和依赖在开发阶段存在相互烦扰的损耗」并不好了解,但正是本篇文章一个十分外围的输入之一。接下来,咱们通过下一部分,从一个案例来说起,帮忙大家领会,并一起找到优化计划。

多过程间构建流程优化

前端构建流程的实质其实是一个个 NodeJS 工作,也因而是逃离不了过程或者线程的概念。Webpack,Babel,NPM Script 这些咱们耳熟能详的工具和脚本都是一个独立或互相关联的过程工作。这里须要大家明确一个「不间断过程」概念,我应用 continuous processes 来表白。其实很简略,比方 @babel/cli 提供了 watch mode 选项:

npx babel script.js --watch --out-file script-compiled.js

这个 watch 选项能够监听文件(夹)的实时变动,并在有变动时从新对指标文件(夹)进行编译。因而这个编译过程是挂起的,继续的,更多内容能够看笔者之前的文章 从构建过程间缓存设计 谈 Webpack5 优化和工作原理。相似的场景在 Webpack 当中也十分常见。

对于简单的前端构建过程,当这些工作过程交错在一起,产生流水关系时,就会变十分乏味,请持续浏览。

一个多我的项目利用开发架构下的瑕疵

咱们的中后盾我的项目「Monstro」采纳了经典的 Monorepo 构造,我的项目组织如下:

其中,package.json 中字端,

"workspaces": [
    "packages/*",
    "apps/*"
],

也暗示了我的项目中:apps 目录内是 Monorepo 下每一个独自的子利用,这些利用能够独自发版,独自构建,子利用之间绝对独立;packages 目录内是公共依赖,被 apps 目录内所有子利用援用。

简要阐明一下这种组织架构的劣势:

  • 不同子利用之间构建环节独立,每个子利用存在本人的 package.json 文件,能够在我的项目根目录下通过 NPM Script:yarn start ${appName}yarn build ${appName} 联合 –scope 选项,进行独立开发调试和构建
  • 不同子利用之间能够独特依赖 packages 内的公共依赖、公共组件、公共脚本

咱们从开发流程来说起:当在根目录下进行 yarn start app1 时,会启动 appName 为 app1 的我的项目,浏览器代开 locahost:3000 端口进行开发调试。这一系列过程是如何串联起来的呢?yarn start app1 对应的脚本定义于 packages/script/* 目录当中,其内容简要为:

process.env.NODE_ENV = 'development'

const [app] = process.argv.slice(2)

const config = {
  stdio: 'inherit',
  env: {...process.env}
}

spawn.sync('monstro-scripts', ['clean'], config)
spawn('monstro-scripts', ['prebuild', '--watch'], config)
spawn(
  'npx',
  ['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
  config
)

代码很好了解,其实在 start 脚本中咱们做了三件事件:

  • 串行执行 clean 脚本,进行一次新的构建前解决
  • clean 执行完后,并行执行 prebuild 脚本,对 pacakges 目录中各个依赖项进行 babel 编译,并传递 watch 参数
  • clean 执行完后,并行执行 app1 scope 内的 npm run start,留神这个 npm run start 对应的 NPM Script 定义在 apps/app1/packages.json

其中代码中 monstro-scripts 命令行事后定义在 packages/scripts 的 package.json 文件中:

"bin": {"monstro-scripts": "bin/monstro-scripts.js"},

保障形如 spawn.sync('monstro-scripts', ['命令名称'], 参数) 的脚本可能失常执行。

让咱们来逐个剖析:

开发者敲入 yarn start app1 后,先执行 clean 脚本,clean 脚本执行构建后果清理工作:

import rimraf from 'rimraf'


rimraf.sync('node_modules/.cache')
rimraf.sync('packages/*/lib')
rimraf.sync('apps/*/build')
rimraf.sync('apps/*/node_modules/.cache')

同时执行 spawn('monstro-scripts', ['prebuild', '--watch'], config) 脚本,prebuild 过程实际上是应用 @babel/cli 对依赖目录 packages 内 src 目录内容进行编译,原地输入到 lib 目录中:

const args = process.argv.slice(2)

const packages = glob
  .sync(path.resolve(process.cwd(), 'packages/*'))
  .filter(name => readdirSync(name).includes('src'))

for (const pkg of packages) {
  spawn(
    'npx',
    [
      'babel',
      path.resolve(`${pkg}`, 'src'),
      '--out-dir',
      path.resolve(`${pkg}`, 'lib'),
      '--copy-files',
      '--config-file',
      path.resolve(__dirname, '../configs/babel.config.js'),
      '-x',
      ['.es6', '.js', '.es', '.jsx', '.mjs', '.ts', '.tsx'].join(','),
      ...args
    ],
    {
      stderr: 'inherit',
      env: {
        ...process.env,
        NODE_ENV: process.env.NODE_ENV || 'production'
      }
    }
  )
}

其中对于 Babel 的配置咱们采纳了 react-app 这个预设:

presets: [['react-app', { flow: false, typescript: true}]]

仍然是同时执行:

spawn(
  'npx',
  ['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
  config
)

这一步应用了 lerna exec 命令,该命令能够在每个包目录下(apps/*)执行任意命令,咱们到 apps/app1(@monstro/app-${app}) 下执行了 npm run start,对应 app1 的 start 脚本定义在 apps/app1/packages.json 中:

"scripts": {
    "build": "react-scripts build",
    "start": "react-scripts start"
},

由此可知,咱们最终是应用了 create-react-app 提供的 react-scripts 脚本实现了我的项目的开发构建,create-react-app 提供的 react-scripts 最终会关上浏览器,出现利用内容,启动继续化过程,监听利用依赖树上的任何变动,随时进行从新构建。

总结一下,一个 start 脚本构建流程如图:

整体来看,这套流程架构兼顾了各子利用的独立性,也充沛尊重了子利用之间和依赖的关联性,从而达到了较高的开发调试效率。在较长一段时间内,稳固为中后盾零碎赋能,反对一体化的开发、编译、上线流程。

直到有一天收到开发者 A 同学的反馈:有时候短时间内间断开启多个利用,会造成较高的内存占用,电脑继续发热并随同有较大风扇乐音。

这尽管是偶发的情况,然而依然失去了咱们的器重。还原场景如:我先开发第一个利用 app1:yarn start app1,接着开发第二个利用:yarn start app2,再开发第二个利用:yarn start app3

剖析问题实质 找到优化计划

多利用同时开发时的内存老本持续上升的起因是什么呢?

咱们将上述过程通过两个利用的启动来进行演示:

关键点在于 prebuild 这一步。回顾一下 start 脚本中对于 prebuild 工作的启动:

spawn('monstro-scripts', ['prebuild', '--watch'], config)

这里应用 Babel 编译 packages 下内容时,咱们应用了 @babel/cli 的 --watch 这一参数。用前文说法,watch 模式的开启将会创立一个可继续过程,监听 packages 下文件内容的变动,并即时将编译后果输入到原地 lib 目录中。

咱们晓得,对于每一个利用,咱们应用了 react-script 构建开发利用,create-react-app 中 react-script 会内置 Webpack 配置,参看其源码,能够找到内置 Webpack 配置的局部内容,配置有 webpack-dev-server 来帮忙开发者启动本地服务用于开发:

watchOptions: {ignored: ignoredFiles(paths.appSrc),
},

源码:webpackDevServer.config.js

简要对源码进行阐明:ignoredFiles(paths.appSrc) 是一个匹配我的项目 node_modules 的正则表达式,意味着 create-react-app 在持续性过程从新构建中会显式地疏忽 node_modules 目录的变动。

module.exports = function ignoredFiles(appSrc) {
  return new RegExp(
    `^(?!${escape(path.normalize(appSrc + '/').replace(/[\\]+/g, '/')
    )}).+/node_modules/`,
    'g'
  );
};

这么做的起因次要是思考到监听 node_modules 全量内容时的性能损耗的性价比。毕竟在 create-react-app 晚期在全量监听 node_modules 时,某些零碎(OS X)上会偶现 CPU 使用率过高的问题。具体 issues:

  • High CPU usage while running on OS X
  • Document src/node_modules as official solution for absolute imports

目前 create-react-app 对于监听 node_modules 这件事件所采纳的策略十分聪(鸡)明(贼):如果 node_modules 退出一个新的依赖包,依然会被监听到,从而触发 create-react-app 从新构建,这个是依赖 WatchMissingNodeModulesPlugin 插件实现的,在 create-react-app 源码文件 webpack.config.js 中:

isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),

总之,create-react-app 中 react-script 脚本应用了 webpack-dev-server,这样也同样开启了一个可继续过程,监听以后利用上依赖树关系的任何变动,以便随时从新进行构建。

间隔“破案”越来越近了。咱们想,当咱们在曾经启动 app1 并触发 webpack-dev-server watch 监听后,再次启动 app2,app2 的 start 流程不可避免地进行 prebuild 脚本,使得 packages 目录下产生了变动,这个变动反过来会影响 app1,被 app1 所对应的 webpack-dev-server 过程捕捉到变动,进而从新构建 app1(咱们这里默认所有的业务我的项目都依赖了 packages 目录内容,实际上这也是 99% 的场景)。这样循环上来,如果咱们同时开启 K 个利用,当再次开启 K + 1 个利用时(yarn start ${appK+1}),因为不可避免地触发了 packages 目录变动,后面 K 个利用都将会同时从新构建。这就意味着更大的内存耗费。

如下图,咱们以开启第四个利用我的项目为例:

我把这个问题称之为——「多利用多持续性过程间的构建耗费」问题。

互斥锁和锁竞争的解决之道

如何解决这个「多利用多持续性过程间的构建耗费」问题呢?首先,create-react-app 中 react-script 脚本的 webpack-dev-server 的 watch 配置肯定是咱们预期当中的:因为咱们心愿在利用我的项目中,有相干文件改变,即从新构建。其次 react-script 脚本由 create-react-app 封装,且不裸露配置 webpack-dev-server 的能力,同时 eject create-react-app 是咱们永远不想做的,因而改变 react-script 脚本的思路不可行

要害当然是在 prebuild 流程,咱们再次提及问题外围是:第一次启动 app1 之后,通过 prebuild,咱们曾经产出了编译后的 packages/*/lib 目录,因而后续启动的所有利用都不须要再次触发 prebuild。思路如此,对应图示为:

然而 start 脚本是对立的,咱们该如何革新呢?伪代码如下:

process.env.NODE_ENV = 'development'

const [app] = process.argv.slice(2)

const config = {
  stdio: 'inherit',
  env: {...process.env}
}

spawn.sync('monstro-scripts', ['clean'], config)

if (prebuild 过程曾经胜利执行 !== true) {spawn('monstro-scripts', ['prebuild', '--watch'], config)
}

spawn(
  'npx',
  ['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
  config
)

咱们给 spawn('monstro-scripts', ['prebuild', '--watch'], config) 加上了一个判断条件,在曾经胜利 prebuild 后,跳过后续所有 prebuild 流程。然而 prebuild 过程曾经胜利执行 这个变量应该如何设计呢?

每一个 start 脚本对应一个不同且独立的持续性过程工作,因而 prebuild 过程曾经胜利执行 这个变量应该可能被不同过程都拜访到,这是一个典型的多过程间通信问题。 历数 IPC 的几种形式,其实都并不齐全适宜咱们的场景。其实针对咱们的问题,仿佛用一个 文件锁 更好。以开源库 jsonfile 为例,咱们把 prebuild 后果标记在一个 json 文件中,仿佛是一个适合的抉择。伪代码:

const jsonfile = require('jsonfile')

process.env.NODE_ENV = 'development'

const [app] = process.argv.slice(2)

const config = {
  stdio: 'inherit',
  env: {...process.env}
}

spawn.sync('monstro-scripts', ['clean'], config)

if (jsonfile.readFileSync(file).status !== 'success') {spawn('monstro-scripts', ['prebuild', '--watch'], config)
    jsonfile.writeFileSync(file, {status: 'success'})
}

spawn(
  'npx',
  ['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
  config
)

此时 start 脚本流程如图:

这里插一个细节,初期设计咱们认为文件锁状态应该有 3 种:

{status: 'success'/'running'/'fail'}

如果 prebuild 流程正在构建或构建失败,依然要继续执行 spawn('monstro-scripts', ['prebuild', '--watch'], config)。事实上,这是齐全没有必要的,因为 Babel 的编译是一个持续性过程,开启 watch 选项,这样开发者能够始终在编译进行中和编译失败中失去信息,进行修复。文件锁内容齐全能够乐观更新,而后续乐观可行性保障由开发者负责。

但却还有另外一个重要问题须要思考:在初期架构设计中,每个利用的启动,都应用了 Babel 持续性编译过程,进行 watch 监听,这样当开发者手动杀死(Ctrl + C)一个终端过程后:比方不再须要 app1 的开发,杀死 app1 后,就没有任何利用可能监听 packages 的变动了。现实状态下,咱们须要启动另外一个利用过程,去监听着 packages 文件夹的变动,进而触发 packages 的继续编译。

如何了解呢?请参考上图,在咱们新改良的流程中:app1 的启动中执行了 spawn('monstro-scripts', ['prebuild', '--watch'], config),后续的 appN 不再有 prebuild 过程,也就不在监听 packages 文件夹的变动,此时,如果开发者手动杀死(Ctrl + C)第一个利用(即 app1),那么开发者再对 packages 内代码进行改变,就不会触发 Babel 编译,任何利用都不在有相应,此时状态如下:

如何解决这个问题?这就波及到了 竞争锁 的概念。

锁竞争常呈现在多线程编程中,相熟 Java 并发机制的读者可能对这个概念并不生疏。简略来说,同一个过程里线程是数共享的,当各个线程拜访数据资源时会呈现竞争状态,即数据简直同步会被多个线程占用,造成数据混论,即所谓的线程不平安。那怎么解决多线程问题,就是锁了。
切换到另一种语言,Python 提供的对线程管制的对象,其中包含有互斥锁、可重入锁、死锁等。互斥锁概念,是用来保障共享数据操作的完整性。这个标记用来保障在任一时刻,只能有一个线程拜访该对象。

依照这个思路,咱们进行扩大,通过互斥锁和锁竞争,实现这样的机制:第一个利用启动时,在 prebuild 阶段对该过程的终止进行监听,在监听到 Babel 持续性过程终止时,改写文件锁内容 status 为 available;同时之后的每一个利用启动时,都退出轮询脚本,轮询内容即为对文件锁 status 值的查问,一旦查问到 status === 'available',阐明相干监听 Babel 编译的过程完结,须要“我”来接管。具体操作是:将 status 值置为 ‘success’,同时开启 prebuild 流程(spawn('monstro-scripts', ['prebuild', '--watch'], config))。整个过程概括为:

  • 第一个利用负责 prebuild,负责 Babel 持续性过程来监听 packages 目录的改变。同时监听该进行的终止事件
  • 第一个利用一旦监听到过程终止,则改写文件锁 status 状态为 available,开释 prebuild 流程控制权
  • 其余利用通过启动时的轮询机制,竞争被开释的 prebuild 流程控制权
  • 其余利用谁先竞争到 prebuild 流程控制权,就通过改写文件锁 status 状态为 success,进行锁定

流程如下图:

工程从来不只是个技术问题

上述应用「锁」的计划尽管稍显简单,但仿佛可能从技术上给出较彻底齐备的解法了。可是在我的项目工程上,这真是我想要的么?
让咱们回到问题的最初始:「start 这个脚本开启两个子过程,其中对 packages 目录进行 watch 并增量编译的脚本会影响并触发 create-react-app 过程的从新构建。在多利用同时开发的状况下,这种影响是指数叠加的,从而导致了内存的反复耗费」。这是我的项目已用的设计,我不禁要想,「将 start 脚本中的 create-react-app 过程和 babel 增量编译过程解耦,仿佛是很自然而然的做法」。如下图:

这样的启动流程排除了 babel 过程和 create-react-app 过程之间的互相烦扰,从本源上解决了问题。然而它的「副作用」是:须要开发者在启动利用时,先执行 yarn prebuild 的 script,再执行 yarn start appX。相比于之前的「一键无脑启动」,多了一个终端 tab 和脚本执行过程,且要求开发者通晓这么做的目标以及意义:当批改 packages 目录下内容,并因为各种起因中断 prebuild babel 过程后,开发者要晓得须要重启 yarn prebuild 过程。这些「信息量」咱们能够通过 README 来进行阐明和领导,并在丢失 prebuild 过程继续执行时,进行中断敌对提醒。相比上述纯技术向的「锁」计划,这样的设计更「取巧」。我认为,工程从来不只是个技术问题,不钻牛角尖,多角度思考,往往有「四两拨千斤」的效用。

实际上,当初将 prebuild babel watch 过程作为 start 过程的子过程设计也是有肯定情理的,这里不再开展(这是一个设计取舍问题)。

总结

这篇文章探讨了两个外围问题:

  • 多我的项目利用开发架构设计
  • 多过程多利用间构建流程优化设计

对于大型简单利用的开发和构建设计——这一话题,咱们结合实际生产中的我的项目,剖析并给出了一个较为“完满”的计划。在这个计划的根底上,论证并解决了 Monorepo 化的我的项目在遇见多过程简单构建流程时的一个“小难堪”。整个过程中,为了发现问题,解决问题,咱们深刻分析了 create-react-app 和 Webpack 的源码及设计,同时探讨了继续化过程,最终通过互斥锁和锁竞争找到了灵感,实现了迭代和优化。

当然,这其中也波及到很多其余乏味的问题,大型简单利用的开发和构建关联到基建的方方面面,为此咱们会继续输入这方面的技术教训和心得,请大家订阅内容。

Happy coding!

正文完
 0