• 0

  • 461

  • 收藏

更新 Node 模块的正确姿势 - Jama Software

8个月前

译者:poppinlp

原文链接

你可以在完全无风险的情况下更新 Node 依赖

Node

在 left-pad 风波平息之后,我们应该回头想想如何更好的使用 node 模块,确切的说是如何在安全、可靠且可重构(至少 Javascript 能够允许的程度)的前提下更新 node 包。

问题

最近,我被要求更新我们过时的 react-router 到新版本。在我书写这篇文章的时候最新版本应该是 2.4.2,而我们使用的还是 0.13.3。

在我们的应用中,路由部分可能是前端使用的最难理解和麻烦的代码。它非常依赖一个家庭网历史模块,可容纳传统的路由并重定向到正确的路径位置,并采用一些“智能路由”以根据它的ID和上下文来决定对象的路线。我们也在许多贯穿应用的地方暴露出了路由器对象,以方便编程路由的目标(这些东西是在更新的 react-router 版本中不支持的)。

解决方法

我们最近请来了两个重构的专家,提供关于正确的重构以前代码的一些指导,并密切关注 Martin Fowler 的  pivotal book on the subject 一书。不幸的是,专家的指导没有像前端工程师们所喜欢的那样深入的挖掘重构前端代码,但从指导中我们仍旧收获了一些可以用于工作中的有价值的东西。我会利用我们从指导中了解的工具来更新这个模块。

首要的事情

幸好 react-router 有大量关于主版本升级的文档。我们通过研究文档了解到了我们需要做出的重大的修改。

除了涉及到迁移到新模块的过程外,我不会深入聊到升级的细节。

功能标志

Javascript 真的不太安全。几乎你在代码中做的任何事情都可能引入某种副作用,甚至只是很平常的引入一个模块(事实上,关于这一点,我发现在一个新模块导入后,再导入某个修改了全局状态的模块,将会导致新模块出问题)。

安全地修改代码的最好方法是引入的功能标志或者环境变量又或者一些其他的环境因子,通过这些来控制引入的新的代码。在我们的 Java 应用中,我们使用一个叫做 Togglz 的插件将值存储在数据库中,并将它们注入到前端。

我们也使用 Webpack。我们可以放弃 Java 的实现并且可以在 Webpack 配置信息中提供我们想要的配置。这些可以通过一个别名模块来实现:

// In the webpack config
...
resolve:  {
    alias:  {
        featureFlags:  {
            'react_013_to_1':  true
        }
    }
}
...
// In a file that needs the feature flag
var  featureFlags  =  require('featureFlags');
if  (featureFlags.react_013_to_1)  {
    // do something
}
复制代码

有很多方法可以实现这个,找到最适合你的环境和构建流程的方法即可。

同时存在两个不同版本的模块?

现在我们有办法可以开启和关闭之前说到的新功能了,下来就是寻找一个可以安全的可以同时导入一个模块的两个不同版本的方法。事实证明,如果我们打破一些规则,那么这是非常容易实现的。

我们现在使用着 0.13.3 版本的 react-router,第一步我们希望能升级到 1.0.3 或者 1.x.x 中可用的更高版本。原因在于我们希望能尽可能的接近 2.x.x 版本以便能够更轻松的进行升级过渡。

由于 react-router 是我们的前端入口的直接依赖,所以其实没有真正的方法可以绕过同时只能运行依赖模块某一个版本的事实 — 至少在 npm 中无法绕过。我们_能做_的只是从 github 上克隆 react-router 到本地并构建好,然后把它的库添加到我们的应用中使其进入我们的代码库。

这时,你可能会觉得有问题:在任何情况下我们都不应该把一个外部库放进我们自己的代码中,并且 node_modules 应该是你 .gitignore 文件中的一部分。但这个规则的目的是使得我们的构建流程可转移并且避免代码库附带有非常多的依赖库代码。但我们目前的情况,是需要做一些临时并且和构建无关的事情。

过程是这样的:

  1. 克隆 react-router 项目
  2. 进入克隆下来的项目,通过 git checkout 切换到 1.0.3 的版本
  3. 通过 npm install 来安装依赖
  4. 通过 npm run build 来构建 1.0.3 版本的结果
  5. 在前端代码中创建 module-upgrades 目录,并在里面创建 react-router-1 目录
  6. 复制 react-router 模块生成的库目录到 react-router-1 目录中

现在我们便可以通过切换功能标志开关来导入和使用两个不同版本的 react-router 了。当我们完全测试完新版后,便来到了这个方案的最后一步,也就是更新应用的 package.json 文件,使用 ^1.0.0 版本的 react-router,然后将对于我们代码中加入的 react-router (也就是 ""react-router-1"")的引用重构成对于真正 node_modules 中的模块的引用(定义在 package.json 中的模块),然后继续愉快的开发。

组件迁移

在这个路由的例子中,我们希望能每次更改一个路由并测试它。首先,我们有这么一个测试方法:

  1. 直接触发路由
  2. 通过前进按钮触发路由
  3. 通过后退按钮触发路由
  4. 通过以前的路由来触发路由(如果可以的话)
  5. 通过程序接口来触发路由

测试方法多种多样,你也可以实现自己的方法。由于我们这部分的单元测试参差不齐,所以我们决定每次更改路由后至少应该做一个冒烟测试。

接下来,我们创建了一个切换路由的地方 。我们所有的路由都在一个名为 Routes.jsx 的文件中,其中路由仅仅只是连接到一个数组中,然后将其导出以备后用。那个文件看起来像是这样:

var  Route  =  require('react-router').Route;
var  routes  =  [
,
,
...
];
module.export  =  routes;
复制代码

由于这个文件是给旧路由使用的,所以我们最好以 Routes.jsx 为副本,创建一个叫做 NewRoutes.jsx 的临时的新文件,用于逐步的迁移组件。当我们准备好移除功能标示时,我们只需要做一点哪怕是在 Javascript 中也非常容易的重构工作,即将旧的 Routes.jsx 删除并把 NewRoutes.jsx 重命名。

NewRoutes.jsx 类似于这样:

var  Route  =  require('react-router').Route,
NewRoute  =  require('module-upgrades/react-router');
var  routes  =  [
,  // migrated
,  // to be migrated
...
];
module.export  =  routes;
复制代码

在迁移各个路由的过程中,我们需要同时加载两个路由器。等到一旦迁移完了所有路由,我们便可以删除对于旧路由的引用,并且 NewRouter.jsx 将只会处理新路由。

抽象掉差别

现在,我们已经知道了如何安全的测试修改并且重构依赖关系,接下来我们就开始迁移我们的代码。

因为这部分的细节很大程度上取决于具体的迁移,所以探究我们基于 react-router 做的一些具体的改变意义不大 — 唯一有用的就是我们想 创建 一组能够跨代码库应用的的修改。

在我们的应用中,本质上我们有两种不同类型的路由逻辑:

  1. 我们通过名字找到目标(react-router 0.13 的遗留物)
  2. 我们通过名字找到目标,并提供数据

因为方案 2 只是方案 1 的扩展,所以我们可以通过扩展方案 1 的方式来让它与方案 2 一起工作。

这是我们的一个旧路由逻辑的例子:

...
var RouterContainer =  require('./../../routes/RouterContainer'),
...
RouterContainer.get().transitionTo(DocumentTypeToPathMap[documentType],  {  id:  this.props.id  },  {  projectId:  this.getProjectId()  });
...
复制代码

我们拿到了一个包含路由器单例(通过静态的 ""get"" 方法获取)的 ""RouterContainer"" 的引用。然后我们通过传递路由名字、参数(这个例子中只是一个 id)以及查询的值(projectId),来调用它的 ""transitionTo"" 方法。

由于 react-router 新版的历史处理由新的 history 这个依赖模块所代理,所以不再有能传递参数给路由的内置的方法,于是我们需要创建一个新的方法用于接收一个路由和一个 id,并把他们结合成一个预期结构的路由。然而,新的 history 依赖模块允许我们传递一个查询字符串。

现在我们有一个适用于所有路由逻辑的更改方式:

上面的例子如果想在新的场景下工作,必须被转换成这样:

...
var  NewJamaLocation  =  require('jama/routes/NewJamaLocation'),
...
NewJamaLocation.push({  pathname:  NewDocumentTypeToPathMap.documentTypeToPath(documentType,  this.props.id),  search:  '?projectId='+this.getProjectId()  });
复制代码

NewJamaLocation 表示新的 history 依赖模块的单例,它有一个类似于 transitionTo.push 的 push 方法接受一个包含 pathname 和 search 值的对象。我们已经把路径的公式抽象成了 NewDocumentTypetoPathMap 对象,这个对象包含一个静态方法可以接受 documentType 和 id 作为参数并创建一个可用的路径。

然而,我们希望能安全的实现这个方法,所以我们需要加一些条件判断逻辑。记住,哪怕是引入模块也是有副作用的,所以我们最后的代码类似这个样子:

var NewJamaLocation,
    RouterContainer,
    featureFlags  =  require('featureFlags');
...
if  (featureFlags.react_013_to_1)  {
    NewJamaLocation  =  require('jama/routes/NewJamaLocation');
}  else  {
    RouterContainer  =  require('./../../routes/RouterContainer');
}
...
if  (featureFlags.react_013_to_1)  {
    NewJamaLocation.push({  pathname:  NewDocumentTypeToPathMap.documentTypeToPath(documentType,  this.props.id),  search:  '?projectId='+this.getProjectId()  });
}  else  {
    RouterContainer.get().transitionTo(DocumentTypeToPathMap[documentType],  {  id:  this.props.id  },  {  projectId:  this.getProjectId()  });
}
...
复制代码

另一种实现方式是在 RouterContainer 中写这些条件开关,然后创建一个叫 transitionTo 的过渡方法,用于将旧的路由过适配到新的 history 对象。我没有这样做是因为我希望能避开库中的一些不在文档中的方法。因为许多库有很棒的文档和例子,所以我们如果能更贴合文档中的描述方式来实现我们的代码,那么新来的同事便能更轻松的了解我们的代码,并且在开发时也不需要写很多特殊的文档或者了解一些特殊领域的知识。

复制/粘贴

现在对于路由逻辑我们有了一个同意的转换方式,那么我们可以把它应用在所有的路由逻辑上。通过复制/粘贴来实现代码,可以避免一些错误(例如手误等)。

在上文提到的做条件判断的地方我也建议这么做。在迁移工作的过程中,我经常用到类似以下这段代码:

var NewJamaLocation,
    RouterContainer,
    featureFlags  =  require('featureFlags');
...
if  (featureFlags.react_013_to_1)  {
    NewJamaLocation  =  require('jama/routes/NewJamaLocation');
}  else  {
    RouterContainer  =  require('./../../routes/RouterContainer');
}
if  (featureFlags.react_013_to_1)  {
}  else  {
}
复制代码

再说一次,如果你直接输入的字符越少,那么犯错误的机会也就越少。

关于提交的注意事项

在以上过程中,你应该细化所有提交 — 也就是说,在每一次有意义的代码修改,特别是任何一次 “危险” 的更改后,你都应该做代码提交。我这里说的 “危险” 的意思是某次更改并不是那么显而易见的安全。

收尾工作

当我们的代码公开使用并被完全测试之后,我们便可以开始删除那些过时的旧代码。这时可以直接通过去掉功能标志的判断条件来直接使用新代码。

我推荐使用 IntelliJ 或者类似对于重构中文件和依赖的重命名支持比较友好的 IDE。因为它们了解各种模块的导入状态,所以相比 grep 的方式而言,可能会捕捉到一些遗漏的地方。

接下来你可以移除那些之前迁入了代码库中的模块,把模块的引用切换到 node_modules 中(在我们的例子中,是 react-router),并修改 package.json 从而使用合适的模块版本(在我们的例子中,是 ^1.0.3),然后通过 npm install 安装依赖。如果以上事情你都正确的做完了,那么现在使用新版本模块的应用看起来应该和之前包含功能标志的时候一模一样。

注意

我们尝试的另一个安全升级是将 react 从 0.14.x 升级到 15.x.x。然后,这个升级在我们的系统中是不可行的。因为我们的功能标志机制依赖于独立的后端生成的一个前端全局对象,我们没办法切换版本,并且 react 也不允许同时允许两个不同的版本。

如果你的应用使用 Node 后端那么是有方法可以安全的升级 react 的,但你的服务仍然需要重启以启用不同配置来获取不同的模块版本,所以这并不是一个更换 react 版本的 “热切换” 方案。

也就是说,由于 react 会公开的告知版本更新并发布迁移工具,所以一旦你在使用过去的某个版本,那么升级新版本通常不会太痛苦。

免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

物联网

461

相关文章推荐

未登录头像

暂无评论