当前位置: 首页 > 科技观察

从Vue中对mixin的批判,到模块间依赖关系的讨论

时间:2023-03-18 17:26:04 科技观察

编程框架日新月异,工具平台日新月异。但有趣的是,代码的恶臭并不会因为你使用工具的时尚而自行消散,团队成员的编程水平也不会随着工具的进化而上升。工具从来都不是你写出好代码的决定性因素。相反,它可能是最不起眼的条件之一,却恰恰给了大多数人一种救命稻草的错觉。它更像是一种催化剂,它可以帮助你的代码并加速它的消亡。Mixins就是一个很好的例子。Mixin语法回顾如果你对Vue中的mixin语法不是很了解,我可以用一句话和一个例子简单概括一下:mixin是一种组件间的代码共享机制,可以让你将代码封装成一个独立的模块,使用它用于跨多个组件共享。假设Toolbar和Card组件在实例化该组件时需要传递title和subTitle属性,那么可以考虑将这两个属性封装到一个名为ComminMixin的通用模块中,然后将该模块“插入”到需要的组件中。先定义CommonMixin模块代码:exportdefault{props:{title:string,subTitle:string,}}然后在Toolbar和Card组件中引用:importCommonMixinfrom'../mixins/commin-mixin.js'exportdefault{mixins:[CommonMixin]}这个方法和直接在Toolbar中定义title和subTitle属性没什么区别:importCommonMixinfrom'../mixins/commin-mixin.js'exportdefault{props:{title:string,subTitle:string}}什么样的mixin机制有什么问题?早在2016年,React官方就发布了一篇名为MixinsConsideredHarmful的文章,详细描述了mixin机制下会出现的几类问题,比如命名冲突,比如Causingsnowballingcomplexity等等。这里我只提一下我认为最有害的一点:隐式依赖。并以此引出我们下一节的主题。隐式依赖如上节代码所示,mixin模块中的代码和组件中的代码是等价的。如果你知道mixin中有一个名为hello的方法,你完全可以在组件中使用this.hello和()的形式乱调用。这种等价是双向的。虽然最初定义了mixin模块,但不知道以后哪些组件会引用它,但是如果你非常确定一个world方法注定存在于消费它的组件中,你可以调用this.world()在mixin模块中。这种关系也可以扩展到mixins。无论是并列关系中的mixin模块还是嵌套关系(一个mixin模块可以继续引用其他mixin模块),它们之间都可以相互访问变量和方法。你是不是已经嗅到了这里的危险?看起来特别方便!但是一旦代码需要维护,问题就会暴露出来,哪怕你只是想了解一个很小的代码片段。假设同事在组件中遇到了hello方法,想给它加一个参数,实现更多的功能——这个看似不起眼的东西,实际操作起来比天还难。因为他不知道这个方法是在哪个mixin模块里定义的,只知道这个方法是属于这个的,所以他要翻遍每个mixin的定义。退一步说,即使他在一个mixin中找到了方法的定义,他也会遇到另一个问题:他不敢修改这个函数的签名。虽然他可以知道有多少组件文件引用了这个mixin模块,但是不知道有多少地方直接或间接调用了这个方法。换句话说,这种修改的后果和影响很难评估。所有这些问题的根源都在components和mixins之间,mixins和mixins之间的依赖是隐式的。也就是说,当模块A依赖于函数B时,这种关系既不是通过显式声明(如导入语句或依赖注入)获得的,也不是通过公共约定(如windows上是否存在getComputedStyle方法)确定的目的)。这种关系也让IDE武功无用武之地。我发现最后解决这个问题的方法是Ctrl+C(复制)、Ctrl+V(粘贴)、Ctrl+Shift+F(全局搜索)、Ctrl+H(文本替换))。隐式依赖不仅会对脚本代码产生负面影响,还会对样式代码产生负面影响。Flex布局就是一个正面的例子:如果想控制子元素在父容器中的布局,只需要在父容器上修改flex相关的属性即可。您不依赖于子元素的DOM结构,更不用说子元素上的样式了;反模式的一个示例是text-overflow:ellipsis属性。单个样式属性不足以自动忽略容器中的文本。容器还需要满足1)宽度必须以px像素为单位2)元素必须有overflow:hidden和white-space:nowrap样式。text-overflow属性本身并没有告诉我们需要这些“配套设施”。由此产生的情况正好符合BobMartin大叔在他的OOD原则系列文章中谈到的BadDesign的几个特点,比如僵化(Ridigity):代码很难修改,因为变化会影响到太多地方Fragility:当你进行更改时,系统中意想不到的地方会被破坏Immobility:代码难以重用,因为它与当前系统中的功能耦合在一起,前面两个已经在上面解释过了,很容易理解。至于最后一个特性,mixin不仅看起来没有违和,而且表现也很好不是吗?这就涉及到Defactoring的内容,我们下一节会讲到。暂停一下,我们似乎进退两难:我们都承认mixin是极其强大和灵活的,它最大限度地提高了代码复用。但是现在来看,正是这种灵活性给我们的代码带来了灾难。我们应该如何理解这个矛盾呢?我们必须回答的第一个问题是:这种灵活性真的是我们想要的吗?ReginaldBraithwaite在2013年写了一篇有趣的技术文章Defactoring。请注意,它并不重要Constructed那个词Refctoring。什么是分解?简而言之,如果我们把将一个大的单体代码拆分成细粒度的碎片化代码的过程称为分解,那么分解就是将代码碎片组装起来的过程。为什么我们需要分解?因为灵活性并不总是带来好处,它会给我们带来认知上的困扰,你总是需要将不同的片段拼凑起来才能理解整个画面的本来面目;模棱两可的代码总是会让你搞不清它的意图;更不用说代码的复杂性了。你可能会问如果?有时候“灵活性”的背后是我们对未来的恐惧:我们可能需要支持功能A或者支持功能B,但实际上你不需要提前意识到这些可能性,让你的代码能够做到就足够了处理这些可能性。所以适当的分解是必要的。第二点我们需要考虑到人的因素。我很喜欢CodingHorror提出的FallIntoThePitofSuccess理论。引用原文是:一个设计良好的系统可以很容易地做正确的事情,而烦人(但并非不可能)做错误的事情。从项目和团队的角度考虑代码的可维护性时尤其如此。除此之外,代码应该易于修改,并且易于修改。例如,TypeScript与JavaScript相比,但显然mixins不是。从你写出一段代码的那一刻起,它的命运就不再掌握在你的手中。别人可能不理解你设计某个属性的用意,你精心设计的一段性能优化代码很容易被毁掉。所以我们需要一个适应度函数,我们需要一个测试。在实践中,mixins大多被滥用。您可以定义一个名为ComponentCommonMixin的模块,用于存储与所有组件关联的公共属性。但是后面的开发者并不知道你的初衷是什么,所以在规划接下来的公共属性的时候就想都没想就往这个模块上加,让它臃肿——“哦,因为它是公共的”。从表面上看,它分离了公共属性代码和组件特定代码,但实际上它恰好是mixin模块内部紧耦合反模式的最好体现。这种状态下的mixin根本就没有“单一责任(SingleResponsibility)”。一个模块可能同时包含与样式相关的属性和与权限相关的行为,涉及任何业务。需求的变化会导致模块“打开”重新修改,这也违反了开闭原则(Open-closed)。通用mixin模式的好处在于,这种mixin中的隐式依赖问题是Vue框架下的特例。说到代码复用,我们首先想到的就是继承,但是继承并不是万能的:继承打破了父类的封装;继承要求子类在重写方法时要与父类兼容;大多数语言不支持多重继承。总之,继承机制对类的抽象设计能力要求很高,低级抽象比不抽象更难维护。在这些约束下,组合模式似乎是一个不错的选择,mixin就是实现组合的一种方式。这里我们直接参考TypeScript2.2RC官方技术博客中的一个例子来说明mixin是如何实现的。简单来说,它分为以下四个步骤:采用构造函数声明一个类,该类扩展该构造函数向该新类添加成员并返回该类本身。这里我们尝试实现一个Timestampedmixin,它会添加一个需要扩展时间戳属性的类:typeConstructor=new(...args:any[])=>T;functionTimestamped(Base:TBase){returnclassextendsBase{timestamp=Date.now();};}首先Constructor是一种用来描述构造函数签名的类型。支持传入一个泛型类型T,T表示构造函数实例化后返回结果的类型。作用不大,主要是在下面的方法中继承基类。Timestamped方法接收一个基类作为参数。这个基类必须符合上面定义的构造函数签名,并且它必须能够“构造一些东西”。在函数的实现中,它使用匿名类继承基类,并为匿名类添加时间戳属性并返回。使用效果如何?下面以一个Point类为例,看看如何对其进行扩展y=y;}}constTimestampedPoint=Timestamped(Point);constp=newTimestampedPoint(10,10);p.x+p.y;p.timestamp.getMilliseconds();Point本身并没有定义timestamp属性,而是通过Timestamped方法对其进行扩展后,在仍然保留其自身行为的同时,增加了timestamp属性。这种模式可以无限嵌套。比如我们还可以添加draw、color等mixins,同时扩展Point类:constNewPoint=draw(color(Timestamped(Point)))这种模式是不是很眼熟?它是React中的高级组件。你一定用过withRouter或connect方法来封装组件。但是为什么隐式依赖中提到的问题在这种模式下似乎不存在呢?因为除了去掉模块中的“隐式”元素外,我们还间接调整了模块之间的依赖方向。下图Vue的mixin模式中,组件A和B看似单向引用mixin模块B,但实际上由于隐式依赖关系(图中灰色虚线所示)同上),模块与组件之间的依赖关系根本没有统一的方向,甚至可以循环依赖。在下图中TypeScript的mixin模式中,draw函数中的匿名类对传递给它的函数的类一无所知,它只是将自己的属性和行为添加到匿名类中,匿名类之间是相互独立的。这确保了模块之间的依赖关系是单向的。注意,虽然上面的箭头表达了一种“依赖”关系,但它在UML中并不是依赖关系。它既没有调用依赖模块的方法,也没有将依赖模块作为自己的成员变量。当然,如果你“够自信”,你仍然可以强行调用传入基类上的方法,但如果你真的打算这样做,你可能需要通过接口或类型来约束基类,并赋予方法的签名以确保它的存在。模块之间的依赖方向是另外一个我们需要关心但又容易被忽略的点,因为它会影响我们调整模块代码的难度。BobMartin叔叔在他的书《整洁架构》中提出了“稳定依赖原则”。他认为软件开发中的软件设计不可能是一成不变的,注定是需要调整的,而且不同的组成模块调整的频率也不尽相同。因此,一个注定要被改变的组件不应该依赖于难以改变的组件,否则它自己将变得难以改变。例如下图中的Y模块,它依赖于额外的三个模块。如此之多,以至于这三个模块中的任何一个发生变化都会影响它,使其极不稳定。隐式依赖的其他表现形式另一个极具争议的隐式依赖示例是服务定位器模式。大多数时候,服务定位模式被认为是一种反模式。它可以在前端领域实现,但很少使用。什么是服务定位模式?假设你需要调用一个类的某个方法中的依赖方法,你可以通过方法中的Locator“临时”找到这个依赖:);}}运行起来没有问题,但是我们还有另外一种实现方式,我们可以在创建实例的时候通过构造函数传入依赖,或者通过依赖注入的方式传入依赖:classMyClass{publicMyClass(IDepdep){}publicvoidMyMethod(){dep.DoSomething();}}在使用服务定位模式的前提下,你想创建一个实例并调用它的方法很可能会失败:varmyClass=newMyClass();myClass.MyMethod();因为问题与服务定位模式的区别在于它的依赖是隐藏的,你无法一眼看穿它对IDep的依赖,所以你可能不会在项目中引入相应的Locator和IDep。即使你完全收集了它的所有依赖,你仍然需要引入Locator模块,但它与你真正需要的业务功能无关。如果可以在构造函数中进行显式声明,就可以避免这些问题。结论当然我同意mixin是中立的,所有的意外本质上都是人为问题。但如果我们承认“人”是我们在软件活动中永远无法消除的不稳定因素,那么我们就不得不面对这样的风险,mixin会让我们的软件比其他机制更不稳定。这个时候,我们没有理由视而不见。