V8 引擎使得前端的工程化成为可能,前端的被细分到组件。这里讨论一点关于兼容性的问题,这里的兼容性问题特指组件在不同的环境下的显示效果问题,以求得在各种环境下达到一致且灵活的页面效果

传统前端

传统前端编程是直接编写 HTML、CSS、和 Javascript 代码,但从页面效果来说,HTML 和 CSS 的比重明显更大,使用 HTML 编写页面总是存在无穷无尽的细节,你可以看到每一句 HTML 对应的每一个页面效果(或者没有效果)。但是 HTML 将所有的细节展开也意味着丧失了抽象,就算页面上有多个完全相同的组件也必须重复地编写一样的 HTML 代码多次。

<!-- 这是一个盒子组件 -->
<div class="box .........." other-attr="value" ......>
	<span class="box-child .........." other-attr="value" ......>
		this is box one
	</span>
</div>
......
......
<!-- 这是也一个盒子组件,和上面的盒子完全相同 -->
<div class="box .........." other-attr="value" ......>
	<span class="box-child .........." other-attr="value" ......>
		this is box two, which as the same as box one, we still need to write the some code twice
	</span>
</div>

即使是完全一样的组件,出现在页面上多少次就必须写多少次相同的代码。这样的好处是随意修改了一处组件也不会影响到其他组件,这样能够随意地修改来适应页面的不同变化。比如

  • 在页面的四个角各有四个盒子组件,但是左上角的瘦一点,右下角的矮一点,那么就可以单单修改左上角和右下角的组件
  • 在移动端的盒子要高一点,那就可以编写一个盒子高一点的,在 PC 端隐藏起来,移动端就显示

但坏处也是修改了一处组件不会影响到其他组件。如果组件的样式需要统一,就不得不每处组件都进行相同的修改。比如当所有的组件都需要换一个主题颜色。

这种情况就是本文要讨论的兼容问题

由于这种情况,前端一直都需要组件化,但是传统 HTML 开发没有组件,所以需要借助一些手段来实现简单的组件化。

简单组件

最简单的方法是封装一些 CSS 到 .css 文件中,在页面上引入这个文件,将 CSS 属性引用到标签中,这样所有标签相同的都是同一个组件。

<link rel="stylesheet" href="xxxxx.css">
<div class="box">BOX</div>

很多 UI 库使用的这种方式:bootstrap 等。然而这样也只能应用与简单的组件,像按钮,框框等。如果标签有复杂的嵌套结构,那么其应用的难度也会升高,因为要确保编写的标签的嵌套结构正确符合 UI 库里指定的结构。比如当 UI 库里指定的类名为 box 的标签子元素类名必须为 box-child,开发者必须细心地查看文档。

<div class="box">
	<!-- 由于缺少了类名 box-child,这个盒子不会显示出正确的效果 -->
	<span>box</span>
</div>

另一种方式是将 HTML、CSS 编写到 JS 代码里,在 <script> 标签里执行这段 JS 代码将组件插入到想要的位置。这种方式看似挺好,抽象了组件,但是在传统 HTML 编程里但不过是将编写 HTML 的成本转嫁到编写成本更高的 JS 代码里,这样的方式完全舍弃了 HTML 声明式的直观性,单纯 review HTML 部分无法得知其大概效果,必须同时 review JS 代码,其维护成本是大大增加的。

<!-- 单纯 review HTML 部分无法得知这个 div 里面还会显示一个 box -->
<div id="box"></div>

<script>
insertBoxTo("id");
</script>

传统 HTML 编程里不会使用这种方式。

前端框架的出现使得组件抽象化成为了可能。

前端框架

以前端框架 React 为例子,React 支持通过组件化的方式将一系列 HTML、JS、CSS 代码封装成一个组件:

import css from 'style.css';

export default function Component1() {

	function handleClick() {
		//  clicked
	}
	return <div className="box">
		<button onClick={() => { handleClick() }}>CLICK ME</button>
	</div>;
}

在要使用这个组件地方使用类似 HTML 标签的方式插入组件:

import Component1 from './Component1';

export function App() {
	return <div>
		// 这里使用 HTML 标签的方式插入组件
		<Component1 />
	</div>;
}

这样一来在封装了 HTML 代码的同时又保留了 HTML 声明式语言的直观性。

传统前端到组件化的习惯

尽管封装了组件,不过在多个使用组件的时候也是会遇到需要应对不同的布局对组件进行微调的情况。就像四个角的组件要瘦一点或者高一点,PC 端和移动端的组件又有不同的情况。

当然最直观的解决方式还是从组件外部传递 CSS 进入到组件内部:

export default function Component1(props) {

	function handleClick() {
		//  clicked
	}
	return <div className={props.className}>
		<button onClick={() => { handleClick() }}>CLICK ME</button>
	</div>;
}

// 左上角的组件
export function TopleftComp() {
	return <div>
		// 这里使用 HTML 标签的方式插入组件,并传递类名 胖一点 的
		<Component1 className="box flat ..." />
	</div>;
}

// 右下角的组件
export function RightDownComp() {
	return <div>
		// 这里使用 HTML 标签的方式插入组件,并传递类名 瘦一点 的
		<Component1 className="box thin ..." />
	</div>;
}

我也使用过很久这种方式以应对不同的环境,后来发现这种方式是传染性的:组件的样式依赖于外部传递的样式,而不是外部的样式。一开始我居然没有意识到这个问题,都使用这种方式开发好久,这也是说明类名传递的方式能够解决问题,且并不容易暴露问题。

然而问题还是存在的,就是类名传递的传染性破坏了组件的封装,其后果是给组件增加了不确定性。

举一个最简单的例子,这里有一个组件,它采用 flex-row 布局,同时接收外部传递的 className 参数:

export function TopleftComp(props) {
  return <div className={`flex flex-row ${props.className}`}>
    <div>左边的区域</div>
    <div>右边的区域</div>
  </div>;
}

此时如果外部传递的是一个改变布局的类名,那么就会摧毁这个组件的结构:

export function App() {
  // 这里传递的 flex-col 将组件内的横向排列改变为了纵向排列,原本组件内的效果可能会变得乱七八糟
  return <TopleftComp className="flex-col" />;
}

这是最简单也是最常出现的一种情况,如果开发者有需要适配多端的需求,那么可能还会传递更多莫名奇妙的参数,甚至可能会编写专门适配这个组件的 CSS 嵌套结构。造成这种情况的原因之一是组件本身扩展性太差,在不适用的情况下让人不得不去魔改组件的样式。

但如果是组件的开发者则更可能是携带了在传统 HTML 开发中的开发习惯。在复杂的页面效果中传统 HTML、CSS 很难做到一套复杂样式的通用化,当我提取了一套 CSS 样式,准备通过类名应用在元素上的时候,经常会发现这个元素需要一点额外的样式来满足效果。造成这种情况的原因是多样的,可能是页面布局,可能是响应式,也可能是某个 CSS 没有写好。但不管如何,为了应对这种情况,传统 HTML 开发中最便捷的方法就是直接给元素加上一点额外的样式属性,可能是 CSS 类名,也可能是直接写一个 style 属性。就我的经验而言,直接写 style 属性要更加方便得多,尽管不得避免地增加了维护成本,但是更最好的之一。当然更好的方式是引入一个 bootstrap,但也是在对元素的样式做一点额外的修改。

<!-- 盒子组件 -->
<div class="box"></div>

<!-- 另一个盒子组件,但是需要窄一点,应用了 style="width: '100px'" -->
<div class="box" style="width: '100px'"></div>

<!-- 另一个盒子组件,但是需要高一点,应用了类名 h-full -->
<div class="box h-full"></div>

这种习惯带到组件化前端框架中开发组件的后果,就是不容易意识到就是这种方式给组件化带来的隐性成本。

传染性的坏习惯

后果之一就是非常容易破坏组件的既定效果,就像上面的例子,往组件里传递一个 flex-col 可能会完全改变组件的显示效果。为了防止这种情况,是不允许让开发者使用组件的时候随意传递类名到组件里面的。除非特地开放了指定的接口。 但是习惯了编写 HTML 的开发者就很容易习惯性地将 CSS 类名开放给调用者,只要有这个入口,就一定会有开发者这么用。如果允许开发者对组件随意的修改内容,那么使用组件的意义也就不大了。因为这样一来不过又是传统 HTML 写法的另一种形式。

export function TopleftComp(props) {
  // className 接受外部传入的 CSS
  return <div className={props.className}>
	...
  </div>;
}

组件本身编写的质量不高也是导致用户手动修改组件样式的原因之一。 任何前端开发都曾经不厌其烦地调整元素的 CSS 属性,以求嵌合页面的显示效果,比如:

<div style="height: 200px; width: 300px; background-color: rgb(212, 212, 212);">
  <Component1 />
</div>

上面代码中,外部的 DIV 是一个框,它的大小是 300px * 200px,里面的 Componentn1 是要显示的组件,这段代码的本意是让组件限制在 DIV 的大小,它显示的效果如下图:

file

看起来没有问题对吧,但是如果进入审查元素查看这个 DIV 的框,会发现组件的实际显示大小超过了 DIV 的 300px * 200px 大小:

file

在上图中选中的高光区域是 DIV 框,是正常的 300px * 200px,但是里面的组件(黑色部分)却是超过了框的显示。 为了让组件大小限制在预期的 300px * 200px,最简单的方式之一就是直接设置组件的 widthheight 了,但这是在组件开放了 CSS 传递的情况下,如果开放了 CSS 传递,那么调用者传递的样式可能会对组件既定样式造成破坏。如果没有开放,就只能指定 CSS 的写法了:

<div id="box" style="height: 200px; width: 300px; background-color: rgb(212, 212, 212);">
  <Component1 />
</div>

#box {
}
// 这里是 ID 为 box 的元素下的子元素,这里假设组件的最外层元素是 div
#box div {
	width: 300px;
	height: 200px;
}

像上面这种直接指定组件嵌套层级的 CSS 写法必须非常仔细,且如果组件更新,则极易失效!

组件并没有做好兼容性,使得用户必须手动传递样式

所以在进行组件开发的时候,要有和传统 HTML 不同的思维,需要仔细地思考组件的兼容性。

兼容性的组件

编写兼容性的组件不能使用传统 HTML 的写法的,HTML 因为其声明式写法,可以显示的要求组件这里的组件窄一点,那里的组件高一点,直接修改组件样式既可。但是组件化的写法,如果在不暴露 CSS 传递的写法下,是无法知晓外部的环境的,所以组件是不能根据环境来自我调整样式,也不应该根据环境来自我调整样式。

这里有几种提高兼容性的写法: 一种写法是暴露足够多的接口,由外部传递:

export function TopleftComp(props) {
  // className 接受外部传入的 CSS
  return <div className={props.className} 
	  // 暴露 width、height,由外部指定
	  width={props.width} height={props.height}>
	...
  </div>;
}

这种写法可取,但只能少量暴露,过多的暴露同样会使为何成本增加。对于固定大小的组件,这种方式是比较适合的,比如按钮,一般是固定的大小,但是如果用户需要一个特别大的按钮,那么就可以通过传递 widthheight 的方式设置大小。

对于无固定大小的组件,设置组件的大小为父级元素的大小会更合适。

由父元素限制组件的大小,具有更好的兼容性。比如一个盒子组件,它里面是用户需要显示的东西。这个盒子组件只需要关心自己受到父组件的限制,自己组件的稳定,就不需要考虑其他。

export function Comp() {
  // 这里是指定组件最外层 height: 100%; width: 100%;
  return <div className="w-full h-full">
	...
  </div>;
}

// 父元素的框多大组件就多大
<div class="left-top">
  <Comp />
</div>

// 父元素的框多大组件就多大
<div class="right-top">
  <Comp />
</div>

这样组件就不需要考虑太多兼容的问题,调用者也不需要考虑对组件的影响。但是同样的,编写组件的样式需要非常仔细,使其以正确的方式显示。 在上一节中我们介绍到的例子,组件的大小超过了父级元素的大小,是因为组件的 CSS 编写存在一点问题。这是该组件的最外层元素的样式:

.container {
  background-color: #232323;
  color: #fff;
  padding: 0.5rem;
  border-radius: 5px;
  height: 100%;
  width: 100%;
}

height: 100%; width: 100%;,按理说组件跟父级元素是一样高的,但是实际上却超出了一点点,这是由于 padding: 0.5rem; 将组件又撑开了一点点。如果说父级元素的宽度是 300px,设置了宽度为 width: 100%; 组件的宽度也应该是 300px 才对,但是实际由于 padding: 0.5rem;,组件的宽度是 300px + 0.5rem,这就超过了父级元素的宽度。尽管看起来没问题,但在变化莫测的页面中非常可能会造成莫名其妙的 CSS 混乱。

上面的情况解决方法也非常简单,设置 box-sizing: border-box;,这个是指定元素的 width 计算为 content + padding + border,这样一来不管 padding 如何改变,width 都是 300px。

不管是在传统 HTML 还是组件化,样式的编写都要非常仔细。

总结

UI 的编写似乎没有比声明式写法更好的方式了,但也更加繁琐。 使用暴露接口和受父级元素的限制这两种方式能够提高组件的兼容性,使得组件在各个环境中由稳定和灵活的表现。