找回密码
 会员注册
查看: 19|回复: 0

Lerna运行流程剖析

[复制链接]

3

主题

0

回帖

10

积分

新手上路

积分
10
发表于 2024-10-12 20:50:24 | 显示全部楼层 |阅读模式
Lerna 运行流程剖析 137 Lerna 运行流程剖析https://www.zoo.team/article/lerna-js前言随着前端组件、包库等工程体系发展,业务组件和工具库关系越来越复杂,非常容易遇到仓库多,库之间互相依赖。导致维护极其困难,发包过程非常繁琐,极大程度地限制了前端同学的开发效率。此刻,出现了一种新的项目管理方式—— Monorepo。一个仓库管理多个项目。MultiRepo 是目前常用的项目管理方式。但有些场景是不适用的,存在问题。多业务组件、互相依赖、无法复用发包流程复杂、版本管理痛苦此刻就有了 lerna.js简介“Lerna (lerna)  is a tool that optimizes the workflow around managing multi-package repositories with git and npm.Lerna 是一个优化基于 git + npm 的多 package 的项目管理工具。有哪些项目正在使用 Ta ?Vue Cli https://github.com/vuejs/vue-clicreate-react-app https://github.com/babel/babelmint-ui  https://github.com/ElemeFE/mint-ui......知识点通过阅读本文,你将会学会下图内容:使用与实践基本指令Lerna 的几个基本常用指令, 不是本文重点哦。文档在这里(https://lerna.js.org/)。下图是结构目录等。与工作区使用// package.json 添加"workspaces":[ "packages/*"]// lerna.json 添加 "useWorkspaces":true, "npmClient": "yarn", // 配置好后,所有依赖就会安装在最外层的 node_modules 中,且支持软链接方式 // npm 7.x 之后,同样支持工作区域学习的过程中少不了查看实现过程和运行流程。接下来我们分析一下 Lerna 中的一些代码,希望从中你能学到许多。原理剖析我们先 Github 克隆源码(https://github.com/lerna/lerna)观察一下目录指令的初始化流程脚手架入口文件位于 /core/lerna/cli.js“core/lerna/cli.js  入口#!/usr/bin/env node"use strict";/* eslint-disable import/no-dynamic-require, global-require */const importLocal = require("import-local");// 判断是否处于本地包文件,下文会介绍if (importLocal(__filename)) {  require("npmlog").info("cli", "using local version of lerna");} else {// 进入真实的入口执行代码  require(".")(process.argv.slice(2)); // [node, lerna, 指令]}如图一和代码入口的文件仅执行了一条判断语句 ,其目的是为了当项目的局部环境和全局环境都存在 Lerna 时优先使用局部环境下的 Lerna 代码import-local 一个判断是否本地包的方法库require(".")  是导入当前目录下的 index.js  并传入指令执行代码 ( process.argv ->  [node, lerna, 指令] )“core/lerna/index.js  初始化/** 省略相同代码 */// 导入 @lerna/cli 文件 const cli = require("@lerna/cli");// ..... 省略相同指令导入// 导入 publish 指令文件const publishCmd = require("@lerna/publish/command");const pkg = require("./package.json");module.exports = main;// 最终导出方法function main(argv) {  const context = {    lernaVersion: pkg.version,  };  return cli()  // ..... 省略     .command(publishCmd)    .parse(argv, context); // 解析注入指令 & 参数(版本号) }来到这个代码中,如图二和代码实际上做了这几件事初始化导入包 ("@lerna/cli")—— cli 实例导入所需要的指令文件通过 cli 实例的 command 方法注册指令parse(argv, context) 是执行解析注入指令和参数(版本号) 将 Cli | 指令 | 入参 进行模块划分,无论在业务中还是开源库中,都是一种优秀的划分方式“core/cli/index.js  全局指令初始化const dedent = require("dedent"); // 去除空行const log = require("npmlog");const yargs = require("yargs/yargs");const { globalOptions } = require("@lerna/global-options");module.exports = lernaCLI;function lernaCLI(argv, cwd) {  const cli = yargs(argv, cwd);  return globalOptions(cli)    .usage("Usage: $0  [options]")    .demandCommand(1, "A command is required. ass --help to see all available commands and options.") // 期望命令个数    .recommendCommands() // 推荐命令    .strict()  // 严格模式    .fail((msg, err) => {     // ... 省略    })    .alias("h", "help") // 别名    .alias("v", "version")    .wrap(cli.terminalWidth()) // 宽高    .epilogue(dedent`      When a command fails, all logs are written to lerna-debug.log in the current working directory.      For more information, find our manual at https://github.com/lerna/lerna    `);  // 结尾}查看图三全局指令初始化,我们会发现全局指令接受实例的传入,也支持指令的注册。显然这也导出了改 cli 实例(单一实例)指令的注册使用了 yargs 包进行管理(yargs 不是本文重点,不赘述)返回实例,全局指令注册 return 实例Config 是基本的配置分组等导出实例给 core/lerna/index.js 调用 我们回到  core/lerna/index.js 文件,使用了 command 方法注册指令传入了导入的指令文件。“commands/ 业务指令的注册可以看到图 4 中 commands 文件包中有着所有 lerna 指令的注册文件,每个文件夹带着 command.js 和 index.js在 core/lerna/index.js 导入的都是该目录中的 command.js (同入口逻辑在 handler 中执行了该目录下的 index.js )command.js 包括 yargs 的 command、aliases、describe、builder (执行前的参数操作)、handler (指令执行逻辑) 以 list 指令举例执行指令的逻辑的方法在 index.js继承 Command 做 指令的初始化父类中会在 constructor 执行 initialize 和 execute 方法const { Command } = require("@lerna/command");const listable = require("@lerna/listable");const { output } = require("@lerna/output");const { getFilteredPackages } = require("@lerna/filter-options");module.exports = factory;function factory(argv) {  return new ListCommand(argv);}class ListCommand extends Command {  get requiresGit() {    return false;  }  initialize() {    let chain = romise.resolve();    chain = chain.then(() => getFilteredPackages(this.packageGraph, this.execOpts, this.options));    chain = chain.then((filteredPackages) => {      this.result = listable.format(filteredPackages, this.options);    });    return chain;  }  execute() {    // piping to `wc -l` should not yield 1 when no packages matched    if (this.result.text.length) {      output(this.result.text);    }    this.logger.success(      "found",      "%d %s",      this.result.count,      this.result.count === 1 ? "package" : "packages"    );  }}module.exports.ListCommand = ListCommand;“core/command/index.js  所有指令的 Command Classconst { roject } = require("@lerna/project");// 省略大部分容错 和 logclass Command {  constructor(_argv) {    const argv = cloneDeep(_argv);    // "FooCommand" => "foo"    this.name = this.constructor.name.replace(/Command$/, "").toLowerCase();    // composed commands are called from other commands, like publish -> version    this.composed = typeof argv.composed === "string" & argv.composed !== this.name;    // launch the command    let runner = new romise((resolve, reject) => {      // run everything inside a romise chain      // 异步链      let chain = romise.resolve();      chain = chain.then(() => {        this.project = new roject(argv.cwd);      });      // 配置、环境初始化等      chain = chain.then(() => this.configureEnvironment());      chain = chain.then(() => this.configureOptions());      chain = chain.then(() => this.configureProperties());      chain = chain.then(() => this.configureLogging());      chain = chain.then(() => this.runValidations());      chain = chain.then(() => this.runPreparations());      // 最终执行逻辑      chain = chain.then(() => this.runCommand());      chain.then(        (result) => {          warnIfHanging();          resolve(result);        },        (err) => {          if (err.pkg) {            // Cleanly log specific package error details            logPackageError(err, this.options.stream);          } else if (err.name !== "ValidationError") {            // npmlog does some funny stuff to the stack by default,            // so pass it directly to avoid duplication.            log.error("", cleanStack(err, this.constructor.name));          }          // ValidationError does not trigger a log dump, nor do external package errors          if (err.name !== "ValidationError" & !err.pkg) {            writeLogFile(this.project.rootPath);          }          warnIfHanging();          // error code is handled by cli.fail()          reject(err);        }      );    }); // ...省略部分代码  }  runCommand() {    return romise.resolve()     // 命令初始化      .then(() => this.initialize())      .then((proceed) => {        if (proceed !== false) {          // 指令执行          return this.execute();        }        // early exits set their own exitCode (if non-zero)      });  } // 子类不存在 时 抛出错误  initialize() {    throw new ValidationError(this.name, "initialize() needs to be implemented.");  }  execute() {    throw new ValidationError(this.name, "execute() needs to be implemented.");  }}module.exports.Command = Command;在 Class 中最关心的就是 constructor 的逻辑 ,如图 5 和代码。上面写到,每个子指令类会执行 initialize 和 execute 方法。我们整理一下创建 Promise.resolve() 异步 Chain。对全局配置、参数、环境初始化执行 runCommand 方法runCommand 调用 initialize 和 execute(如果子类没有将会 执行 父类抛出异常) 采用了模板模式,对子指令通逻辑统一模板化。基本的执行流程就是这样。在这个 Class 中,很巧妙地将指令的初始化、指令的执行等逻辑均注册在 Promise 的异步任务中。指令的执行逻辑均晚于 Cli 的同步代码。(不影响 Cli 的代码执行)所有异常错误都可以统一捕获 通过上面的学习,我们几乎了解了 Lerna 的 一个指令 输入 -> 解析 -> 注册 -> 执行 -> 输出 的流程。转过头我们看下脚手架初始化的第一步的 import-local 到底做了什么?脚手架的初始化流程import-local  用于获取 npm 是否包存在本地(当前工作区域),用于判断全局安装的包如果本地有安装,优先用本地的,在 webpack-cli 中等绝大多数 cli 中都有运用。const path = require('path');const resolveCwd = require('resolve-cwd');const pkgDir = require('pkg-dir');module.exports = filename => {  // '/Users/nvm/versions/node/v14.17.3/lib/node_modules/lerna' 全局文件夹  const globalDir = pkgDir.sync(path.dirname(filename));  const relativePath = path.relative(globalDir, filename); // 'cli.js'  const pkg = require(path.join(globalDir, 'package.json'));  // '/Users/Desktop/person/lerna-demo/node_modules/lerna/cli.js' // 本地文件  const localFile = resolveCwd.silent(path.join(pkg.name, relativePath));   // '/Users/Desktop/person/lerna-demo/node_modules'  // 本地文件的 node_modules  const localNodeModules = path.join(process.cwd(), 'node_modules');   const filenameInLocalNodeModules = !path.relative(localNodeModules, filename).startsWith('..') &    // On Windows, if `localNodeModules` and `filename` are on different partitions, `path.relative()` returns the value of `filename`, resulting in `filenameInLocalNodeModules` incorrectly becoming `true`.    path.parse(localNodeModules).root === path.parse(filename).root;  // Use `path.relative()` to detect local package installation,  // because __filename's case is inconsistent on Windows  // Can use `===` when targeting Node.js 8  // See https://github.com/nodejs/node/issues/6624  // 导入使用本地包  return !filenameInLocalNodeModules & localFile & path.relative(localFile, filename) !== '' & require(localFile);};通过最后一行,可以分析出,最核心的是解析出指定的 npm 包存在全局和 npm 的文件夹、路径。进而判断是 require() 本地还是全局。问题 & 对比对比和查看问题之前,我们要关注一下 Monorepo 单仓库多项目管理的模式带来的优势。前端工作中你是否会遇到以下问题?问题 1:前端同学小明发现了在小红同学的项目中存在相同的业务逻辑A:  我选择复制一下代码B:  我选择封装成 npm 包多项目复用显然 A 方式就不是解决该问题的一种选项,完全不不符合应用程序的代码设计思想。大多数同学就会异口同声我选择 B那么如果这个 npm 包在后续迭代过程中发现,包依赖也要随之升级发布,怎么办?又或者业务中存在大多数这种场景,每个包没有统一管理,花绝大多数时间在包依赖之间升级发布。以及各自包的迭代。你可能只是删除了一行代码,你却要每个依赖这个包的 npm 包全部执行一遍流程。问题 2:在开发中,避免不了对 npm 包的更新,当你更新过程中少不了统一的打 tag 以及当前更新的包的影响面。是小的改动,还是大版本 api 无法兼容的升级。这些操作可能都会导致开发的项目中依赖未及时更新,tag 标记错误出现问题。优势 & 劣势就目前来看,Monorepo 解决的是,多仓库之间的依赖变更升级,批量包管理节省时间成本的事情。所以在开源社区中使用这种模式的一般存在于依赖拆分包,但是彼此之间独立的项目(npm 和脚手架等等)但是 Lerna 的多包管理也有不足之处依赖之间调试复杂changelog 信息不完整Lerna 本身不支持工作区概念,需要借助其他工具CI 定制成本大其他 MultiRepo 方案从图中我们可以看出pnpm 更注重包的管理(像下载,稳定准确性等),相比之下 Lerna 更注重包的发布流程规范指定。二者适用的场景略有不同。拓展import-local 解析如图六和下方代码,很显然 resolve-cwd 和 pkg-dir 是实现 import-local 的主要工具包resolve-cwd 解析类似 require.Resolve () 的模块的路径,但是要从当前工作目录中解析。pkg-dir 从根目录查找节点。js 项目或 npm 包“resolve-cwd 中使用 resolve-from 工具包解析路径来源const path = require('path');const Module = require('module');// 省略部分代码const fromFile = path.join(fromDirectory, 'noop.js'); // '/Users/Desktop/home/person/lerna-demo/noop.js'const resolveFileName = () => Module._resolveFilename(moduleId, {    id: fromFile,    filename: fromFile,    paths: Module._nodeModulePaths(fromDirectory)});使用原生的 module 的原生的两个 Api:Module._resolveFilename 和 Module._nodeModulePathsModule._nodeModulePaths 推断出可能存在该 node/js/json 等包文件的路径数组而在 Module._resolveFilename 这个方法中,首先会去检查,本地模块是否有这个模块,如果有,直接返回,如果没有,继续往下查找。模块对象的属性 包含module.idmodule.filenamemodule.loadedmodule.parentmodule.childrenmodule.paths Module 是实现 require() 和 热加载的核心方法之一。部分实现可以参考阮一峰老师的 require() 源码解读(https://www.ruanyifeng.com/blog/2015/05/require.html)“pkg-dir  中使用 find-up 工具包 向上找全局包文件夹const locatePath = require('locate-path');const stop = Symbol('findUp.stop');module.exports.sync = (name, options = {}) => {  let directory = path.resolve(options.cwd || '');  const {root} = path.parse(directory);  const paths = [].concat(name);  const runMatcher = locateOptions => {    if (typeof name !== 'function') {      return locatePath.sync(paths, locateOptions);    }    const foundPath = name(locateOptions.cwd);    if (typeof foundPath === 'string') {      return locatePath.sync([foundPath], locateOptions);    }    return foundPath;  };  // eslint-disable-next-line no-constant-condition  while (true) {    const foundPath = runMatcher({...options, cwd: directory});    if (foundPath === stop) {      return;    }    if (foundPath) {        return path.resolve(directory, foundPath);    }    if (directory === root) {      return;    }    directory = path.dirname(directory);  }};全局包文件夹全的在当前执行 cwd 向上查找存在 package.json 文件所以 locatePath.sync 接受一个查找的文件路径数组和执行的 cwd 路径通过 while 循环直至找到 return path.resolve(directory, foundPath);什么是软链接fs.symlink(target, path[, type], callback) Node/symlink (http://nodejs.cn/api/fs.html#fssymlinktarget-path-type-callback)target | | // 目标文件path | | // 创建软链对应的地址type 该 API 会创建路径为 path 的链接,该链接指向 target。type 参数仅在 Windows 上可用,在其他平台上则会被忽略。可以被设置为 dir、 file 或 function。如果未设置 type 参数,则 Node.js 将会自动检测 target 的类型并使用 file 或 dir。如果 target 不存在,则将会使用 'file'。Windows 上的连接点要求目标路径是绝对路径。当使用 'function' 时,target 参数将会自动地标准化为绝对路径。总结从 Lerna 的流程设计中,我们可以发现,每个可执行的 Node 程序,Lerna 都对其进行了拆分,再合。在自己的代码设计中,相信你也会遇到杂乱的代码。此刻你是无视,还是从“杂” -> “分” -> “合”来整理代码其次我们看到 Lerna 中,使用了单例来注册指令。在注册指令,又采用了面相对象和模板模式,来抽离公共的初始化逻辑。而在指令的执行过程中,全是微任务的任务执行,这都是可以学习的设计思路和设计模式。最后其他 MultiRepo 方案对比中可以看出,工具赋予的能力都有其优劣,没有好与不好,只有更适合。参考文献Lerna 文档(https://lerna.js.org)阮一峰老师的require() 源码解读(https://www.ruanyifeng.com/blog/2015/05/require.html)看「前端团队」,持续为你推送精选好文招贤纳士前端团队,一个年轻富有激情和创造力的前端团队,隶属于产品研发部,Base 在风景如画的杭州。团队现有 60 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-26 12:44 , Processed in 0.663486 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表