记一次项目问题优化 —— 修改代码后项目无法自动重新构建

1,164 阅读7分钟

背景介绍

老项目,总会有那么多历史遗留问题。

尤其是当你入职新公司 ...

今天这个问题,听同事介绍,背景大概是这样:

        由于之前 SPA 某个页面会导致内存泄漏,而且团队当时一直没有找到合适的解决方案,因此做了个骚操作 —— 编写了一个脚本,在项目启动的时候将有内存泄漏的这部分页面的代码拷贝到一个独立的目录中单独进行打包,并将结果放到原来项目的 public 中,然后通过 iframe 引用这部分页面,从此内存泄漏不复存在焉!

听完我直呼:奇天下之大迹,大佬厉害!

但现在有个问题,同事邪笑着说,那就是每次改完了这部分页面对应的代码,就要重启整个项目,你可能还不知道,以后这个项目就交给你了!

img

我去,这么 NB?听得我两眼一黑 ...

img

竟然还有副作用,竟然刚到新公司就送这么个大礼包!好吧,不过也没有很出乎意料。趁现在需求还不紧,赶紧优化优化!

优化

我看了一下之前的启动命令是如下这样的:

"dev": "node ./src/workbenchMain.js && cd ./workbench && vue-cli-service build && cd ../ && vue-cli-service serve"

也就是先执行了一段脚本把原来的代码拷贝到 ./workbench 中,然后切换到 ./workbench 目录去执行 vue 打包,然后再返回原来的项目启动本地开发,怪不得同事说需要重启,因为这里是对拷贝出去的项目执行的 build 操作,所以打包完成之后改了对应的文件就没办法再次打包了。

这难不倒我,build 改成 serve 不就好了嘛。

一开始我也是这么的天真,然后发现事情并没有那么简单:如果采用 serve 会导致端口占用,原来的项目就没办法启动成功,如果切换端口,那对代码的改动量就有点大,毕竟之前是通过 Iframe 来直接引用的 ./workbench 的打包结果,是属于静态文件引用,如果换个端口在本地启动,不仅要修改之前的业务代码,还要处理一些跨域的问题,最主要的是刚入职新公司,一行业务代码都还没开始写,而且项目年代久远,又属于部门核心重点项目,稍微一点改动都可能伤筋动骨,年仅9岁的我,实在是不敢轻举妄动 ...

但不优化,又太影响开发效率了,我脑海中已经浮现出以后没日没夜不停的在重启项目的画面,这是我实在无法忍受的 ...

改!

但要慎重!

于是本着最小化影响原项目的原则,我简单理了一下大致方案:

  • 原来的项目还是正常启动
  • 在脚本中进行文件监控,如果有改动就重新执行 build

优化 workbenchMain.js

workbenchMain.js 是执行资源拷贝的脚本代码,它将原来项目中对应的代码拷贝到 workbench 中,先来优化一下 workbenchMain.js 中的代码:

原来的项目中是这样:

const fs = require('fs-extra');
const path = require('path');
function deleteFolder(pathurl) {
  let files = [];
  if (fs.existsSync(pathurl)) {
    files = fs.readdirSync(pathurl);
    files.forEach(function(file, index) {
      let curPath = pathurl + '/' + file;
      if (fs.statSync(curPath).isDirectory()) {
        deleteFolder(curPath);
      } else {
        fs.unlinkSync(curPath);
      }
    });
    fs.rmdirSync(pathurl);
  }
}

由于本身就是使用 fs-extra,不知道这位老哥当初为什么要自己去递归删除目录,而且 nodejs 的 fs.rm 后续也支持了 recursive 递归删除目录的选项。这里我还是采用 fs-extra 提供的方法,对 deleteFolder 进行了替换:

const deleteFolder = (path) => fs.removeSync(path);

优化路径解析代码

原来的代码中很多重复的格式化路径的代码,如下:

let tplSrc = path.join(__dirname, '.', 'views', 'workbench');
let appPath = path.join(
  __dirname,
  '..',
  'workbench',
  'src',
  'views',
  'workbench',
);

let tplComponent = path.join(__dirname, '.', 'components');
let tplComponentPath = path.join(
  __dirname,
  '..',
  'workbench',
  'src',
  'components',
);

let tplApi = path.join(__dirname, '.', 'api');
let appApiPath = path.join(__dirname, '..', 'workbench', 'src', 'api');

let tplMixins = path.join(__dirname, '.', 'mixins');
let appMixinsPath = path.join(__dirname, '..', 'workbench', 'src', 'mixins');

我们完全可以将重复的 path.join 进行包装一层,使其自带 __dirname

const resolve = (...paths) => path.resolve(__dirname, ...paths)

目录解析的代码就采用 resolve 方法:

const workbenchRoot = resolve('..', 'workbench');
const workbenchSrcRoot = resolve(workbenchRoot, 'src')

const tplSrc = resolve('views', 'workbench');
const appPath = resolve(workbenchSrcRoot, 'views', 'workbench');

const tplComponent = resolve('components');
const tplComponentPath = resolve(workbenchSrcRoot, 'components');

const tplApi = resolve('api');
const appApiPath = resolve(workbenchSrcRoot, 'api');

const tplMixins = resolve('mixins');
const appMixinsPath = resolve(workbenchSrcRoot, 'mixins');

没了那么多 __dirname,感觉清爽了不少。

优化文件资源拷贝

原来的拷贝文件代码全部是采用同步方式:

fs.copySync(tplSrc, appPath);
fs.copySync(tplApi, appApiPath);
fs.copySync(tplMixins, appMixinsPath);
fs.copySync(tplLogger, appLoggerPath);
fs.copySync(tplComponent, tplComponentPath);

这样串行拷贝的方式效率低下,因此我将它改成了并行方式,另外由于调整之后每次修改都需要重复调用,因此把它们提取到单独的方法中:

const copyAssets = () => Promise.all([
  fs.copy(tplSrc, appPath),
  fs.copy(tplApi, appApiPath),
  fs.copy(tplMixins, appMixinsPath),
  fs.copy(tplLogger, appLoggerPath),
  fs.copy(tplComponent, tplComponentPath)
])

按照原来的启动命令肯定是不行的,我们需要让命令同时运行执行多个任务,而且退出的时候统一退出,比较好的方案是采用 concurrently。我对命令进行了拆分:

{
    "scripts":{
        "dev": "cross-env NODE_ENV=development concurrently -k "npm:dev:*"", // 执行启动命令
        "dev:main": "vue-cli-service serve", // 启动原来的项目
        "dev:copy": "node ./src/workbenchMain.js", // 执行脚本拷贝并监控文件
        "buildWorkbench":"cd workbench && vue-cli-service build" // 文件发生改变后执行构建
    }
}

这样一来,执行 dev 就会自动并行启动 dev:maindev:copy

另外 -k 参数可以在关闭的时候同时退出所有并行的任务。

添加文件监控

文件监控我采用了 chokidar。它解决了很多 nodejs 内置文件监控带来的问题。

先对文件进行监控:

chokidar.watch([tplSrc, tplApi, tplMixins, tplLogger, tplComponent]).on('all', (evt, path) => {
    buildWorkbenchTask() // 用于构建单独的 workbench 项目,待实现
})

监控 all 事件等于同时监控了文件的增删改。

完善构建函数 buildWorkbenchTask

构建函数需要先执行资源的拷贝,将主项目对应的资源拷贝到 workbench 项目中,然后执行 workbench 项目的构建:

const buildWorkbenchTask = async () => {
    await copyAssets(); // 拷贝资源
    console.log('\n', sep, ' 正在构建 workbench ... ', sep);
    // 执行 workbench 的构建命令
    childProcess.exec('npm run buildWorkbench', (err, stdOut, stdErr) => {
        err && console.error('\nbuildProcess error:', err.message);
        stdErr && console.error('\nbuildProcess std error:', stdErr)
        stdOut && console.log('\nbuildProcess log:', stdOut)
        console.log('\n', sep, ' workbench 构建结束 ', sep, '\n')
    })
}

但一开始项目启动的时候 chokidar 会触发每个文件的 add 以及每个目录的 addDir 事件,因此加个 debounce 肯定是极好的,先写个 debounce 函数:

function debounce(fn, time) {
  let timerId;
  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), time);
  }
}

然后再用 debouncebuildWorkbenchTask 包装一下:

+  const buildWorkbenchTask = debounce(async () => {
        await copyAssets();
        console.log('\n', sep, ' 正在构建 workbench ... ', sep);
        childProcess.exec('npm run buildWorkbench', (err, stdOut, stdErr) => {
            err && console.error('\nbuildProcess error:', err.message);
            stdErr && console.error('\nbuildProcess std error:', stdErr)
            stdOut && console.log('\nbuildProcess log:', stdOut)
            console.log('\n', sep, ' workbench 构建结束 ', sep, '\n')
        })
+  }, 500)

现在每次对应目录的文件发生改变就能够触发 workbench 重新构建。但这并不完美,如果在构建中途文件发生改变又启动一次构建,相当于多个构建任务同时执行,严重影响性能。

停止不必要的构建任务

我们将上一次启动的 childProcess 保存起来。

+   let buildProcess;

    const buildWorkbenchTask = debounce(async () => {
        await copyAssets();
        console.log('\n', sep, ' 正在构建 workbench ... ', sep);
+       buildProcess = childProcess.exec('npm run buildWorkbench', (err, stdOut, stdErr) => {
            err && console.error('\nbuildProcess error:', err.message);
            stdErr && console.error('\nbuildProcess std error:', stdErr)
            stdOut && console.log('\nbuildProcess log:', stdOut)
            console.log('\n', sep, ' workbench 构建结束 ', sep, '\n')
        })
    }, 500)

然后在文件发生改变但执行构建之前 killbuildProcess

    chokidar.watch([tplSrc, tplApi, tplMixins, tplLogger, tplComponent]).on('all', (evt, path) => {
+       if (buildProcess) {
+           buildProcess.kill();
+       }

        buildWorkbenchTask(evt, path)
    });

其它问题

  • npx 不会向上搜索命令:在子项目中执行 npx vue-cli-service build 无法启动,因为子项目并没有安装 @vue/cli,因此采用在父目录中启动命令并通过 cd 切换到子目录执行 npx vue-cli-service build
  • 公司部分项目还必须使用 node@8,虽然我现在还不太了解其具体原因,不过脚本也需要兼容一下对应的 node 版本,因此无法使用一些类似于 可选链 这样的较新语法。

写在最后

OK,大功告成!以后我就可以不用再每次手动重启整个项目了,此时我的表情是:

耶