关于前端开发中“模块”和“组件”概念的思考

术语的重要性

首要是澄清术语。同事平时交流的时候,有比较多的上下文信息是双方已经预先知道的,所以容易推断对方要表达的意思,一定程度的术语混淆关系不大。但是和其他人交流的时候,如果不明确术语的内涵和外延,经常变成鸡同鸭讲的状况。

举个例子来说,上次看到有位同学老是骂别人的文章里哪里哪里不对,进而演变为完全否定他人。我后来发现他对某些术语的理解有诸多“与众不同”,即他自己从概念和定义上就否定了别人。而有这样问题的人常常不自知,或者被指出其术语运用存在的问题仍坚持认为是别人概念错误,跟这样的同学交流起来就特别令人痛苦和恼火。

所以让我们先明确定义。

模块(对应英文“module”)

通常所指“模块”是指编程语言所提供的代码组织机制,利用此机制可将程序拆解为独立且通用的代码单元。

对于JavaScript来说,在ES6之前,并没有语言内置的模块机制,但我们用一些方式自制了某种模块机制,像CommonJS / AMD甚至建立了普遍接受的社区标准。虽然它们都是模块机制,但会有一些重大或微妙的差异。故当我们提到JS模块时,如果没有足够的上下文,有时需要明确是CommonJS module或AMD module或ES6 module。

对于CSS来说,并没有普遍接受的“CSS模块”概念。一个CSS样式表里可以通过@import来引入其他样式表,但我们通常并不称之为“模块”。多份样式表以cascade机制结合,这和我们一般编程语言中模块互相调用的方式相当不同。且CSS的@import语义基本上就是最简单的include,也就是将@import语句替换为导入样式表的内容。而编程语言中的导入模块会在当前作用域导入命名空间、符号等,比简单的include要复杂许多。

有关“CSS模块”的问题,我们后面还会讨论。

注:在Web标准中,“CSS module”其实指CSS spec本身的模块化。这也是我们应该避免采用“CSS模块”来指代CSS代码的组织结构的重要原因。

其实我公司里对“模块”的用法也比较随便。比如我们有/static/js/modules/目录,其实下面就是一些脚本,并没有采用任何一种module规范。再如我们有/src/modules/目录,下面每个子目录是业务模块,里面包含了view、controller和相关的各种类。

这里一个是历史因素——目录结构不是我建立的,大家习惯如此,都知道我们讲的“module”是指业务模块,跟具体编程语言里的module没有直接的关系,只要沟通没有什么障碍,那也不必改了。不过当我们完成引入JS module loader和相关设施之后,很可能还是需要重新调整文档和目录命名,以避免可能的理解错位。

回到关于“模块”的定义讨论上,我建议运用此术语时尽量避免扩张性解释——即避免在脱离特定机制的general的“模块化”的意义上使用“模块”这个词。

比如,传统的JS代码组织方法之一,是挂在global上的层级命名空间。此严格上不好称之为“模块”。原因是namespace只提供逻辑划分,不解决代码本身的划分。如果没有其他机制,代码划分仍然是文件为单位,并由开发者自己指定script加载。同理,我们通常认为C++里没有模块(尽管有namespace和include),但是PHP我们认为有模块(因为它有autoloader可以根据namespace映射到目录去加载文件)。

当然,即便编程语言没有模块,我们仍然可以通过一些方式进行“模块化”编程,但这种模糊的用法有可能造成误解。在JS这边因为我们已经有很成熟的CommonJS / AMD / ES6 module了,更应避免模糊用法。

组件(对应英文“component”)

另一个概念是“组件”。大体上“组件”和“模块”的概念是类似的,只是“组件”通常指更high-level的东西。

我个人体会,“模块”指代码单元,其意义偏向静态的代码结构。而“组件”指功能单元,其意义偏向运行时的结构,并有更复杂的控制(如组件实例的生命周期管理)。

举例来说,在组件系统中,你应该可以比较容易的做到在运行时查找某种组件并替换为另一种组件(热插拔)。而这通常并不作为模块系统的需求——即使模块系统支持动态加载,通常也不支持注销旧模块;即使支持注销旧模块,通常也不支持替换所有旧模块的引用(意味着需要重新 实例化/初始化 模块依赖树上所有直接或间接引用此模块的模块)。

注:的确有某Node.js平台下的游戏框架设计以class作为模块单元,通过替换prototype来做到模块的热插拔。不过这其实要求非常多的编程方式约定,实际上可被视为使用的是JS的一个裁剪的特性子集,因而不具有普遍性。

组件与模块的关系

网页本身导入脚本、样式表、图片、组件等,继而组件导入其自己所需的脚本、样式表、图片、其他组件之类。这样的组件机制比较符合我们对于网页构成的一贯认知。Web Components相关规范中的HTML Imports大体就是这么个东西。

注意,(Web Components的)组件机制跟(JS的)模块机制是正交的。

所谓正交,就是两者并不互相依赖对方的机制——至少目前是这样的。HTML Imports导入的作为组件的HTML文件里,引入脚本(目前)仍然用的是script标签,并不需要ES6 module。

但是我们有两个问题。

第一,实践中的组件方案不止Web Components一种。(现在的情况实际是大多数人还没有用上Web Components。)

其他组件框架绝大多数基于JS,它们的代码本身需要被加载,那就有一个模块机制的问题——因为组件框架通常都足够复杂,不太可能用裸脚本方式。既然怎么样都需要某种模块加载器,那么组件框架很可能直接利用模块加载器来加载asset。这样模块机制就变成了组件机制的基础了。

另一方面,组件框架如何定义组件呢?无论过去还是现在,看下来大多数组件框架就以一个class来定义一个组件。最常见的代码组织惯例是,一个class对应一个模块,于是组件就变成了符合某种模式(如继承自某基类)的JS模块。

我们比较一下。原生的Web Components方案,开发者需要在document里加link rel="import",然后引用的组件HTML文件里写script/style/link标签,script里声明自定义标签和相关组件行为。比起直接document里加载JS模块,然后在JS模块里import / require其他的JS模块 / HTML template / CSS样式表的方式,好像后者反而更简单点?对此我们稍后再讨论。

前面讨论“模块”定义问题时,我们讲过要避免扩张性解释。“组件”可以被称为“模块”(通常会加限定词以区别于普通的JS模块,如“UI模块”)的原因只在于组件本身以JS来表达,因而可以对应到一个具体的JS模块。假如组件本身并不以JS来表达——像Web Components的组件的形式是一个特殊的HTML文件,则称之为“XX模块”就是对“模块”的扩张性解释。就算是前一种情况,为了概念清晰和保持一致性,我仍然会建议用“组件”一词。

第二,回到Web Components规范,尽管组件机制和模块机制可以是正交的,但是实际情况是资源的依赖、加载、执行(应用)等是两者共性的问题。当前相关的各项标准在这点上其实还未协调,故而标准社区有讨论是否需要统一以及如何统一的问题,而Firefox也因此暂未实现HTML Imports。怎么样才对,我现在也还没想清楚,社区也还没有一致的意见。

通过JS Module Loader加载CSS等资源

HTML Imports使用和传统网页较为一致的模型。与此相对的,从历史到现在一直有以JS为中心的方案。

之前我们讨论过JS的模块。语法上以import "a"require("a")来引入其他模块。但是到底这里的"a"表示什么,如何加载,如何执行,是由具体的loader(及其hook/plugin)处理的。这里就提供了从JS module loader加载其他资源的可能。比如RequireJS、Sea.js、SystemJS均(通过插件)支持加载CSS。

我们是否可以把被加载的CSS资源叫做“CSS模块”?我觉得是有问题的。现有loader的这些插件的实现实际上只是简单的创建link[rel=stylesheet]元素插入到document中。这种行为和通常引入JS模块非常不同。引入另一个JS模块是为了调用它所提供的接口,但引入一个CSS却并不“调用”CSS。所以引入CSS本身对于JS程序来说并不存在“模块化”意义,纯粹只是表达了一种资源依赖——即本JS模块所要完成的功能还需要某些asset。

loader其实可以加载任何东西。如果看loader的另一些插件,如允许import "a.png"的图片资源插件,它只是起到preload作用。字体插件亦然。所以没有人称其为图片模块和字体模块,而只是称之为资源。

CSS介于图片/字体和JS之间。CSS像JS的地方是在于其复杂性,现代Web应用的CSS的复杂度已经有点接近编程了。但是从loader的角度,它更像图片/字体。

我们进一步仔细分析可以发现,JS模块对其他JS模块的依赖是一种强依赖——在依赖项加载和执行完后才能执行自己,而其对加载的CSS、图片等的依赖是一种弱依赖——我们只是表达额外需要某种资源,但是加载顺序甚至是否加载成功且应用完毕都可能是不重要的。

所以我们或许应该认为存在一个更高阶的组件(即使它直接以这个JS模块本身表达),它同时需要这些JS代码逻辑和一些CSS资源。另一方面,现有的使用JS module loader来加载CSS、图片等的实践也许存在滥用和误用的状况。

BTW,hixie有一份草案是通过needs等属性表示资源的依赖关系和优先级,其中包含了延迟加载或空闲时才加载等特性(均可视为弱依赖关系)。抛开声明性和不依赖JS的优势不说,基于JS module loader的方案能否优雅的支持弱依赖关系,是有很大的疑问的。(当然needs提案也面临跟HTML Imports和ES6 module一样的问题,其底层的依赖处理机制需要协调统一。)

注意:loader可以支持import a from "a.png"然后a返回一个HTMLImageElement对象,import b from "b.css"然后b返回一个CSSStyleSheet对象。这样导出一些可以被JS操作的对象似乎使其更像JS模块一样具有强依赖的特征,这也许是一种合理的用法。不过这时我们可以注意到另一个行为上的差异——image插件其实并没有把HTMLImageElement插入到document中,而按照通常CSS插件的意图,却需要把CSSStyleSheet对象插入到document.styleSheets中。这反映了CSS不同寻常之处——它直接是全局生效的,与“模块化”的要求是正好抵触的。我们后面还会详细讨论这一点。

此外,loader不会多次加载和执行(应用)相同CSS——这是module loader的要点之一。而CSS自己的@import语义则正好相反,多次引入相同URL的样式表,都会在导入位置上应用。使用JS module loader的import的语义和CSS自己的@import语义不一致,这也许是个问题。

CSS的@import也支持media query和supports condition等特性,这是目前的JS module loader插件不支持的(至少我没见过支持的)。带有media query的CSS@import声明会在运行时根据media query是否匹配而动态应用,也就是除了依赖关系以外,还有其他因素共同决定是否加载,这和前面谈到的弱依赖是类似的。

要基于JS module loader实现@import "a.css" (min-width:500px)的效果,可能得这样写:

matchMedia('min-width:500px').addEventListener(mediaQueryList => if (mediaQueryList.matches) System.import('a.css').then(() => ...));

或者

import a from 'a.css'
assert(a instanceof CSSStyleSheet)
a.media.appendMedium('min-width:500px')

前者实在难看,且其依赖关系已经不是声明性的了(从而相当麻烦)。后者则可能在还未加上条件时已经开始下载了(从而不满足需求)。

总结一下。JS module loader虽然可以被利用来加载各种资源,但本质上就是一个dependencies tree和注册在其上的一些纯粹由依赖来驱动的callback / promise。对于JS模块来说,这样的设计恰如其分,但是对于其他种类的资源来说——它们可能具有比单纯依赖(即强依赖)更复杂的如优先级、动态条件、可fallback等需求,直接把JS module loader用作组件系统的基础可能并非合适方案。其实就算是加载JS,对于polyfill / shim,loader系统都可能是要开外挂而不在标准机制内。

回顾之前讨论过的“模块”概念,我们可以增加一个认识:“模块”术语暗示了强依赖——因为编程语言的模块都是强依赖的——即使许多人没有明确意识到这一点。

CSS局域化问题

我们对于CSS当然也有分而治之的需求。但是简单用“模块化”来表述可能是有问题的。

如前所述,传统上,CSS被插入文档中,其包含的样式规则是文档全局有效的,这和模块化本身是相抵牾的。

当然我们可以通过某种开发规则来达到效果的局部化。比如以特定id/class限定所有CSS rule的应用范围。

另一种似乎更常见的方式是:所有rule本身就只包含class选择器。从某种角度上说,可被视为这个样式表定义(导出)了一些可复用的样式,并以class来命名。是否能称这样一个样式表为一个“CSS模块”?

当我们讲“A模块依赖B模块”的时候,其实暗含A要使用B所导出的接口的意思。假如我们认为“CSS模块”暴露的是class钩子,可是一个CSS模块依赖其他CSS模块并不存在需要调用它的class钩子的情况;覆写和扩展class钩子或可类比为某种接口使用,但实际运行时并没有任何约束,我们也很难进行静态检查(比如我们无从判断A的代码中有一个B所不包含的class名字是有意扩展还仅仅是拼写错误)。JS依赖CSS的情况也是类似的。

另一方面,这导出的class及其样式声明,也未被限定于只能被声明依赖者使用,其效果仍然是全局性的。

所以不建议管这样的东西叫“CSS模块”,这在沟通中很容易造成误解。(虽然公司内部沟通的话可能问题不大。)

题外话:这种方式实际上滥用了class属性。因为CSS没有复用机制,所以只好拿class属性来充数,通过class来作为应用样式的钩子。这违背了HTML规范和CSS规范的要求。除了对规范的实质性违背之外,这种方式在工程上的一个后果是,将内容和样式的耦合点从样式表的selector转移到了HTML文档的元素属性上。这对于页面开发流程、分工协作方式和长期可维护性会有巨大的影响。此外和通常认知的不同,这样的开发方式其实对页面性能有负面作用。具体就不展开了,可另行讨论。

组件框架在CSS这块的需求我认为“局域化样式”(scoped style)是比“CSS模块”更准确的称呼。目前的具体实现方案除了class样式钩子外,更靠谱的方式是:

  1. shadow dom天生样式就是局域化的
  2. style元素的scoped属性
  3. 以特定id/class限定单个样式表中CSS rule的应用范围,并配合css3增加的all属性和unset值来确保不被其他样式表污染。

前两者目前都有浏览器支持的问题。但第三种方式配合CSS预处理器是完全可行的。

特别是如果讲CSS预处理器,因为它们是真的可以以mixin、函数等来进行抽象,因此讲“SASS模块”、“Stylus模块”、基于预处理器的“样式库/框架”,倒是可以接受的。

猜你喜欢

转载自blog.csdn.net/weixin_39939012/article/details/80828620
今日推荐