整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

听说过CSS in JS,那你听说过JS in CSS吗


SS in JS

CSS in JS是一种解决css问题想法的集合,而不是一个指定的库。从CSS in JS的字面意思可以看出,它是将css样式写在JavaScript文件中,而不需要独立出.css.less之类的文件。将css放在js中使我们更方便的使用js的变量模块化tree-shaking。还解决了css中的一些问题,譬如:更方便解决基于状态的样式更容易追溯依赖关系生成唯一的选择器来锁定作用域。尽管CSS in JS不是一个很新的技术,但国内的普及程度并不高。由于Vue和Angular都有属于他们自己的一套定义样式的方案,React本身也没有管用户怎样定义组件的样式[1],所以CSS in JS在React社区的热度比较高。

目前为止实现CSS in JS的第三方库有很多:(http://michelebertoli.github.io/css-in-js/)。像JSS[2]styled-components[3]等。在这里我们就不展开赘述了(相关链接已放在下方),这篇文章的重点是JS in CSS

JS in CSS又是什么

在上面我们提到CSS in JS就是把CSS写在JavaScript中,那么JS in CSS我们可以推断出就是可以在CSS中使用JavaScript脚本,如下所示。可以在CSS中编写Paint API的功能。还可以访问:ctx,geom。甚至我们还可以编写自己的css自定义属性等。这些功能的实现都基于CSS Houdini[4]

.el {
  --color: cyan;
  --multiplier: 0.24;
  --pad: 30;
  --slant: 20;
  --background-canvas: (ctx, geom) => {
    let multiplier = var(--multiplier);
    let c = `var(--color)`;
    let pad = var(--pad);
    let slant = var(--slant);

    ctx.moveTo(0, 0);
    ctx.lineTo(pad + (geom.width - slant - pad) * multiplier, 0);
    ctx.lineTo(pad + (geom.width - slant - pad) * multiplier + slant, geom.height);
    ctx.lineTo(0, geom.height);
    ctx.fillStyle = c;
    ctx.fill();
  };
  background: paint(background-canvas);
  transition: --multiplier .4s;
}
.el:hover {
  --multiplier: 1;
}

Houdini 解决了什么问题

CSS 与 JS的标准制定流程对比

在如今的Web开发中,JavaScript几乎占据了项目代码的大部分。我们可以在项目开发中使用ES 2020、ES2021、甚至提案中的新特性(如:Decorator[5]),即使浏览器尚未支持,也可以编写Polyfill或使用Babel之类的工具进行转译,让我们可以将最新的特性应用到生产环境中(如下图所示)。

JavaScript标准制定流程.png

而CSS就不同了,除了制定CSS标准规范所需的时间外,各家浏览器的版本、实战进度差异更是旷日持久(如下图所示),最多利用PostCSS、Sass等工具來帮我们转译出浏览器能接受的CSS。开发者们能操作的就是通过JS去控制DOMCSSOM来影响页面的变化,但是对于接下來的LayoutPaintComposite就几乎没有控制权了。为了解决上述问题,为了让CSS的魔力不在受到浏览器的限制,Houdini就此诞生。

CSS 标准制定流程.png

CSS Polyfill

我们上文中提到JavaScript中进入提案中的特性我们可以编写Polyfill,只需要很短的时间就可以讲新特性投入到生产环境中。这时,脑海中闪现出的第一个想法就是CSS Polyfill,只要CSS的Polyfill 足够强大,CSS或许也能有JavaScript一样的发展速度,令人可悲的是编写CSS Polyfill异常的困难,并且大多数情况下无法在不破坏性能的情况下进行。这是因为JavaScript是一门动态脚本语言[6]。它带来了极强的扩展性,正是因为这样,我们可以很轻松使用JavaScript做出JavaScript的Polyfill。但是CSS不是动态的,在某些场景下,我们可以在编译时将一种形式的CSS的转换成另一种(如PostCSS[7])。如果你的Polyfill依赖于DOM结构或者某一个元素的布局、定位等,那么我们的Polyfill就无法编译时执行,而需要在浏览器中运行了。不幸的是,在浏览器中实现这种方案非常不容易。

页面渲染流程.png

如上图所示,是从浏览器获取到HTML到渲染在屏幕上的全过程,我们可以看到只有带颜色(粉色、蓝色)的部分是JavaScript可以控制的环节。首先我们根本无法控制浏览器解析HTML与CSS并将其转化为DOMCSSOM的过程,以及Cascade,Layout,Paint,Composite我们也无能为力。整个过程中我们唯一完全可控制的就是DOM,另外CSSOM部分可控。

CSS Houdini草案中提到,这种程度的暴露是不确定的、兼容性不稳定的以及缺乏对关键特性的支持的。比如,在浏览器中的 CSSOM 是不会告诉我们它是如何处理跨域的样式表,而且对于浏览器无法解析的 CSS 语句它的处理方式就是不解析了,也就是说——如果我们要用 CSS polyfill让浏览器去支持它尚且不支持的属性,那就不能在 CSSOM 这个环节做,我们只能遍历一遍DOM,找到 <style><link rel="stylesheet"> 标签,获取其中的 CSS 样式、解析、重写,最后再加回 DOM 树中。令人尴尬的是,这样DOM树全部刷新了,会导致页面的重新渲染(如下如所示)。

即便如此,有的人可能会说:“除了这种方法,我们也别无选择,更何况对网站的性能也不会造成很大的影响”。那么对于部分网站是这样的。但如果我们的Polyfill是需要对可交互的页面呢?例如scrollresizemousemovekeyup等等,这些事件随时会被触发,那么意味着随时都会导致页面的重新渲染,交互不会像原本那样丝滑,甚至导致页面崩溃,对用户的体验也极其不好。

综上所述,如果我们想让浏览器解析它不认识的样式(低版本浏览器使用grid布局),然而渲染流程我们无法介入,我们也只能通过手动更新DOM的方式,这样会带来很多问题,Houdini的出现正是致力于解决他们。

Houdini API

Houdini是一组底层API,它公开了CSS引擎的各个部分,如下图所示展示了每个环节对应的新API(灰色部分各大浏览器还未实现),从而使开发人员能够通过加入浏览器渲染引擎的样式和布局过程来扩展CSS。Houdini是一群来自Mozilla,Apple,Opera,Microsoft,HP,Intel和Google的工程师组成的工作小组设计而成的。它们使开发者可以直接访问CSS对象模型(CSSOM),使开发人员可以编写浏览器可以解析为CSS的代码,从而创建新的CSS功能,而无需等待它们在浏览器中本地实现。

CSS Houdini-API

Properties & Values API

尽管当前已经有了CSS变量,可以让开发者控制属性值,但是无法约束类型或者更严格的定义,CSS Houdini新的API,我们可以扩展css的变量,我们可以定义CSS变量的类型,初始值,继承。它是css变量更强大灵活。

CSS变量现状:

.dom {
  --my-color: green;
  --my-color: url('not-a-color'); // 它并不知道当前的变量类型
  color: var(--my-color);
}

Houdini提供了两种自定义属性的注册方式,分别是在js和css中。

CSS.registerProperty({
  name: '--my-prop', // String 自定义属性名
  syntax: '<color>', // String 如何去解析当前的属性,即属性类型,默认 *
  inherits: false, // Boolean 如果是true,子节点将会继承
  initialValue: '#c0ffee', // String 属性点初始值
});

我们还可以在css中注册,也可以达到上面的效果

@property --my-prop {
  syntax: '<color>';
  inherits: false;
  initial-value: #c0ffee;
}

这个API中最令人振奋人心的功能是自定义属性上添加动画,像这样:transition: --multiplier 0.4s;,这个功能我们在前面介绍什么是js in css那个demo[8]用使用过。我们还可以使用+使syntax属性支持一个或多个类型,也可以使用|来分割。更多syntax属性值:

属性值描述<length>长度值<number>数字<percentage>百分比<length-percentage>长度或百分比,calc将长度和百分比组成的表达式<color>颜色<image>图像<url>网址<integer>整数<angle>角度<time>时间<resolution>分辨率<transform-list>转换函数<custom-ident>ident

Worklets

Worklets是渲染引擎的扩展,从概念上来讲它类似于Web Workers[9],但有几个重要的区别:

  1. 设计为并行,每个Worklets必须始终有两个或更多的实例,它们中的任何一个都可以在被调用时运行
  2. 作用域较小,限制不能访问全局作用域的API(Worklet的函数除外)
  3. 渲染引擎会在需要的时候调用他们,而不是我们手动调用

Worklet是一个JavaScript模块,通过调用worklet的addModule方法(它是个Promise)来添加。比如registerLayout,registerPaint, registerAnimator 我们都需要放在Worklet中

//加载单个
await demoWorklet.addModule('path/to/script.js');

// 一次性加载多个worklet
Promise.all([
  demoWorklet1.addModule('script1.js'),
  demoWorklet2.addModule('script2.js'),
]).then(results => {});

registerDemoWorklet('name', class {

  // 每个Worklet可以定义要使用的不同函数
  // 他们将由渲染引擎在需要时调用
  process(arg) {
    return !arg;
  }
});

Worklets的生命周期

Worklets lifecycle

  1. Worklet的生命周期从渲染引擎内开始
  2. 对于JavaScript,渲染引擎启动JavaScript主线程
  3. 然后他将启动多个worklet进程,并且可以运行。这些进程理想情况下是独立于主线程的线程,这样就不会阻塞主线程(但它们也不需要阻塞)
  4. 然后在主线程中加载我们浏览器的JavaScript
  5. 该JavaScript调用 worklet.addModule 并异步加载一个worklet
  6. 加载后,将worklet加载到两个或多个可用的worklet流程中
  7. 当需要时,渲染引擎将通过从加载的Worklet中调用适当的处理函数来执行Worklet。该调用可以针对任何并行的Worklet实例。

Typed OM

Typed OM是对现有的CSSOM的扩展,并实现 Parsing APIProperties & Values API相关的特性。它将css值转化为有意义类型的JavaScript的对象,而不是像现在的字符串。如果我们尝试将字符串类型的值转化为有意义的类型并返回可能会有很大的性能开销,因此这个API可以让我们更高效的使用CSS的值。

现在读取CSS值增加了新的基类CSSStyleValue,他有许多的子类可以更加精准的描述css值的类型:

子类描述CSSKeywordValueCSS关键字和其他标识符(如inherit或grid)CSSPositionValue位置信息 (x,y)CSSImageValue表示图像的值属性的对象CSSUnitValue表示为具有单个单位的单个值(例如50px),也可以表示为没有单位的单个值或百分比CSSMathValue比较复杂的数值,比如有calc,min和max。这包括子类 CSSMathSum, CSSMathProduct, CSSMathMin,CSSMathMax, CSSMathNegate 和 CSSMathInvertCSSTransformValue由CSS transforms组成的CSSTransformComponent列表,其中包括CSSTranslate, CSSRotate, CSSScale, CSSSkew, CSSSkewX, CSSSkewY, CSSPerspective 和 CSSMatrixComponent

使用Typed OM主要有两种方法:

  1. 通过attributeStyleMap设置和获取有类型的行间样式
  2. 通过computedStyleMap获取元素完整的Typed OM样式

使用attributeStyleMap设置并获取

myElement.attributeStyleMap.set('font-size', CSS.em(2));
myElement.attributeStyleMap.get('font-size'); // CSSUnitValue { value: 2, unit: 'em' }

myElement.attributeStyleMap.set('opacity', CSS.number(.5));
myElement.attributeStyleMap.get('opacity'); // CSSUnitValue { value: 0.5, unit: 'number' };

在线demo[10]

使用computedStyleMap

.foo {
  transform: translateX(1em) rotate(50deg) skewX(10deg);
  vertical-align: baseline;
  width: calc(100% - 3em);
}
const cs = document.querySelector('.foo').computedStyleMap();

cs.get('vertical-align');
// CSSKeywordValue {
//  value: 'baseline',
// }

cs.get('width');
// CSSMathSum {
//   operator: 'sum',
//   length: 2,
//   values: CSSNumericArray {
//     0: CSSUnitValue { value: -90, unit: 'px' },
//     1: CSSUnitValue { value: 100, unit: 'percent' },
//   },
// }

cs.get('transform');
// CSSTransformValue {
//   is2d: true,
//   length: 3,
//   0: CSSTranslate {
//     is2d: true,
//     x: CSSUnitValue { value: 20, unit: 'px' },
//     y: CSSUnitValue { value: 0, unit: 'px' },
//     z: CSSUnitValue { value: 0, unit: 'px' },
//   },
//   1: CSSRotate {...},
//   2: CSSSkewX {...},
// }

Layout API

开发者可以通过这个API实现自己的布局算法,我们可以像原生css一样使用我们自定义的布局(像display:flex, display:table)。在Masonry layout library[11] 上我们可以看到开发者们是有多想实现各种各样的复杂布局,其中一些布局光靠 CSS 是不行的。虽然这些布局会让人耳目一新印象深刻,但是它们的页面性能往往都很差,在一些低端设备上性能问题犹为明显。

CSS Layout API 暴露了一个registerLayout方法给开发者,接收一个布局名(layout name)作为后面在 CSS中使用的属性值,还有一个包含有这个布局逻辑的JavaScript类。

my-div {
  display: layout(my-layout);
}
// layout-worklet.js
registerLayout('my-layout', class {
  static get inputProperties() { return ['--foo']; }
  
  static get childrenInputProperties() { return ['--bar']; }
  
  async intrinsicSizes(children, edges, styleMap) {}

  async layout(children, edges, constraints, styleMap) {}
});
await CSS.layoutWorklet.addModule('layout-worklet.js');

目前浏览器大部分还不支持

Painting API

我们可以在CSS background-image中使用它,我们可以使用Canvas 2d上下文,根据元素的大小控制图像,还可以使用自定义属性。

await CSS.paintWorklet.addModule('paint-worklet.js');
registerPaint('sample-paint', class {
  static get inputProperties() { return ['--foo']; }

  static get inputArguments() { return ['<color>']; }

  static get contextOptions() { return {alpha: true}; }

  paint(ctx, size, props, args) { }
});

Animation API

这个API让我们可以控制基于用户输入的关键帧动画,并且以非阻塞的方式。还能更改一个 DOM 元素的属性,不过是不会引起渲染引擎重新计算布局或者样式的属性,比如 transform、opacity 或者滚动条位置(scroll offset)。Animation API的使用方式与 Paint APILayout API略有不同我们还需要通过new一个WorkletAnimation来注册worklet。

// animation-worklet.js
registerAnimator('sample-animator', class {
  constructor(options) {
  }
  animate(currentTime, effect) {
    effect.localTime = currentTime;
  }
});
await CSS.animationWorklet.addModule('animation-worklet.js');

// 需要添加动画的元素
const elem = document.querySelector('#my-elem');
const scrollSource = document.scrollingElement;
const timeRange = 1000;
const scrollTimeline = new ScrollTimeline({
  scrollSource,
  timeRange,
});

const effectKeyframes = new KeyframeEffect(
  elem,
  // 动画需要绑定的关键帧
  [
    {transform: 'scale(1)'},
    {transform: 'scale(.25)'},
    {transform: 'scale(1)'}
  ],
  {
    duration: timeRange,
  },
);
new WorkletAnimation(
  'sample-animator',
  effectKeyframes,
  scrollTimeline,
  {},
).play();

关于此API的更多内容:(https://github.com/w3c/css-houdini-drafts/tree/main/css-animation-worklet-1)

Parser API

允许开发者自由扩展 CSS 词法分析器。

解析规则:

const background = window.cssParse.rule("background: green");
console.log(background.styleMap.get("background").value) // "green"

const styles = window.cssParse.ruleSet(".foo { background: green; margin: 5px; }");
console.log(styles.length) // 5
console.log(styles[0].styleMap.get("margin-top").value) // 5
console.log(styles[0].styleMap.get("margin-top").type) // "px"

解析CSS:

const style = fetch("style.css")
        .then(response => CSS.parseStylesheet(response.body));
style.then(console.log);

Font Metrics API

它将提供一些方法来测量在屏幕上呈现的文本元素的尺寸,将允许开发者控制文本元素在屏幕上呈现的方式。使用当前功能很难或无法测量这些值,因此该API将使开发者可以更轻松地创建与文本和字体相关的CSS特性。例如:

  • flex布局: align-items baseline特性。需要知道每一个flex盒子中第一个元素的基线位置。
  • 首字母: 需要知道每个字母的基线高度和字母最大的高度,以及换行内容的基线长度。
  • 单个字形的前进和后退。
  • 换行: 需要访问字体数据,文本的所有样式输入以及布局信息(可用的段落长度等)。
  • 元素中的每一个line boxes都需要一个基线。(line boxes代表包含众多inline boxes的这行)

Houdini 目前进展

Is Houdini ready yet

(https://ishoudinireadyyet.com/)

Houdini 的蓝图

了解到这里,部分开发者可能会说:“我不需要这些花里胡哨的技术,并不能带收益。我只想简简单单的写几个页面,做做普通的Web App,并不想试图干预浏览器的渲染过程从而实现一些实验性或炫酷的功能。”如果这样想的话,我们不妨退一步再去思考。回忆下最近做过的项目,用于实现页面效果所使用到的技术,grid布局方式在考虑兼容老版本浏览器时也不得不放弃。我们想控制浏览器渲染页面的过程并不是仅仅为了炫技,更多的是为了帮助开发者们解决以下两个问题:

  1. 统一各大浏览器的行为
  2. JavaScript一样,在推出新的特性时,我们可以通过Polyfill的形式快速的投入生产环境中。

几年过后再回眸,当主流浏览器完全支持Houdini的时候。我们可以在浏览器上随心所欲的使用任何CSS属性,并且他们都能完美支持。像今天的grid布局在旧版本浏览器支持的并不友好的这类问题,那时我们只需要安装对应的Polyfill就能解决类似的问题。

、前言

虽然在 JavaScript 中对象无处不在,但这门语言并不使用经典的基于类的继承方式,而是依赖原型,至少在 ES6 之前是这样的。当时,假设我们要定义一个可以设置 id 与坐标的类,我们会这样写:

// Shape 类
function Shape(id, x, y) {
 this.id = id;
 this.setLocation(x, y);
}
// 设置坐标的原型方法
Shape.prototype.setLocation = function(x, y) {
 this.x = x;
 this.y = y;
};

上面是类定义,下面是用于设置坐标的原型方法。从 ECMAScript 2015 开始,语法糖 class被引入,开发者可以通过 class 关键字来定义类。我们可以直接定义类、在类中写静态方法或继承类等。上例便可改写为:

class Shape {
 constructor(id, x, y) { // 构造函数语法糖
 this.id = id;
 this.setLocation(x, y);
 }
 setLocation(x, y) { // 原型方法
 this.x = x;
 this.y = y;
 }
}

一个更符合“传统语言”的写法。语法糖写法的优势在于当类中充满各类静态方法与继承关系时,class 这种对象模版写法的简洁性会更加突出,且不易出错。但不可否认时至今日,我们还需要为某些用户兼容我们的 ES6+ 代码,class 就是 TodoList 上的一项:

作为当下最流行的 JavaScript 编译器,Babel 替我们转译 ECMAScript 语法,而我们不用再担心如何进行向后兼容。

本地安装 Babel 或者利用 Babel CLI 工具,看看我们的 Shape 类会有哪些变化。可惜的是,你会发现代码体积由现在的219字节激增到2.1KB,即便算上代码压缩(未混淆)代码也有1.1KB。转译后输出的代码长这样:

"use strict";var _createClass=function(){function a(a,b){for(var c,d=0;d<b.length;d++)c=b[d],c.enumerable=c.enumerable||!1,c.configurable=!0,"value"in c&&(c.writable=!0),Object.defineProperty(a,c.key,c)}return function(b,c,d){return c&&a(b.prototype,c),d&&a(b,d),b}}();function _classCallCheck(a,b){if(!(a instanceof b))throw new TypeError("Cannot call a class as a function")}var Shape=function(){function a(b,c,d){_classCallCheck(this,a),this.id=b,this.setLocation(c,d)}return _createClass(a,[{key:"setLocation",value:function c(a,b){this.x=a,this.y=b}}]),a}();

Babel 仅仅是把我们定义的 Shape 还原成一个 ES5 函数与对应的原型方法么?

一、揭秘

好像没那么简单,为了摸清实际转译流程,我们先将上述类定义代码简化为一个只有14字节的空类:

class Shape {}

首先,当访问器走到类声明阶段,需要补充严格模式:

"use strict";
class Shape {}

而进入变量声明与标识符阶段时则需补充 let 关键字并转为 var:

"use strict";
var Shape = class Shape {};

到这个时候代码的变化都不太大。接下来是进入函数表达式阶段,多出来几行函数:

"use strict";
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
var Shape = function Shape() {
 _classCallCheck(this, Shape);
};

该阶段不仅替换了 class,还在类中调用了叫做 _classCallCheck 的方法。这是什么呢?

这个函数的作用在于确保构造方法永远不会作为函数被调用,它会评估函数的上下文是否为 Shape 对象的实例,以此确定是否需要抛出异常。接下来,则轮到 babel-plugin-minify-simplify上场,这个插件做的事情在于通过简化语句为表达式、并使表达式尽可能统一来精简代码。运行后的输出是这样的:

"use strict";
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) throw new TypeError("Cannot call a class as a function"); }
var Shape = function Shape() {
 _classCallCheck(this, Shape);
};

可以看到 if 语句中由于只有一行代码,于是花括号被去掉。接下来上场的便是内置的 Block Hoist ,该插件通过遍历参数排序然后替换,Babel 输出结果为:

"use strict";
function _classCallCheck(a, b) { if (!(a instanceof b)) throw new TypeError("Cannot call a class as a function"); }
var Shape = function a() {
 _classCallCheck(this, a);
};

最后一步,minify 一下,代码体积由最初的14字节增为338字节:

"use strict";function _classCallCheck(a,b){if(!(a instanceof b))throw new TypeError("Cannot call a class as a function")}var Shape=function a(){_classCallCheck(this,a)};

二、再说一些

这是一个什么都没干的类声明,但现实中任何类都会有自己的方法,而此时 Babel 必定会引入更多的插件来帮助它完成代码的转译工作。直接在刚刚的空类中定义一个方法吧。

class Shape {
 render() {
 console.log("Hi");
 }
}

我们用 Babel 转译一下,会发现代码中包含如下这段:

var _createClass = function () { function a(a, b) { for (var c, d = 0; d < b.length; d++) c = b[d], c.enumerable = c.enumerable || !1, c.configurable = !0, "value" in c && (c.writable = !0), Object.defineProperty(a, c.key, c); } return function (b, c, d) { return c && a(b.prototype, c), d && a(b, d), b; }; }();

类似前面我们遇到的 _classCallCheck ,这里又多出一个 _createClass ,这是做什么的呢?我们稍微把代码状态往前挪一挪,来到 babel-plugin-minify-builtins 处理阶段(该插件的作用在于缩减内置对象代码体积,但我们主要关注点在于这个阶段的 _createClass 函数是基本可读的),此时 _classCallCheck 长成这样:

var _createClass = function() {
 function defineProperties(target, props) {
 for (var i = 0; i < props.length; i++) {
 var descriptor = props[i];
 descriptor.enumerable = descriptor.enumerable || false;
 descriptor.configurable = true;
 if ("value" in descriptor) descriptor.writable = true;
 Object.defineProperty(target, descriptor.key, descriptor);
 }
 }
 return function(Constructor, protoProps, staticProps) {
 if (protoProps) defineProperties(Constructor.prototype, protoProps);
 if (staticProps) defineProperties(Constructor, staticProps);
 return Constructor;
 };
} ();

可以看出 _createClass 用于处理创建对象属性,函数支持传入构造函数与需定义的键值对属性数组。函数判断传入的参数(普通方法/静态方法)是否为空对应到不同的处理流程上。而 defineProperties 方法做的事情便是遍历传入的属性数组,然后分别调用 Object.defineProperty 以更新构造函数。而在 Shape 中,由于我们定义的不是静态方法,我们便这样调用:

_createClass(Shape, [{
 key: "render",
 value: function render() {
 console.log("Hi");
 }
 }]);

T.J. Crowder 在 How does Babel.js create compile a class declaration into ES2015? 中谈到 Babel 是如何将 class 转化为 ES5 兼容代码时谈到了几点,大意为:

  • constructor 会成为构造方法数;
  • 所有非构造方法、非静态方法会成为原型方法;
  • 静态方法会被赋值到构造函数的属性上,其他属性保持不变;
  • 派生构造函数上的原型属性是通过 Object.create(Base.prototype) 构造的对象,而不是 new Base() ;
  • constructor 调用构造器基类是第一步操作;
  • ES5 中对应 super 方法的写法是 Base.prototype.baseMethod.call(this); ,这种操作不仅繁琐而且容易出错;

这些概述大致总结了类定义在两个 ES 版本中的一些差异,其他很多方面比如 extends ——继承关键字,它的使用则会使 Babel 在转译结果加上 _inherits 与 _possibleConstructorReturn两个函数。篇幅所限,此处不再展开详述。

三、最后

语法糖 class 给我们带来了很多写法上的便利,但可能会使我们在代码体积上的优化努力“付诸东流”。

另一方面,如果你是一名 React 应用开发者,你是否已经在想将代码中的所有 class 写法换为 function 呢?那样做的话,代码体积无疑会减少很多,但你一定也知道 PureComponent 相比 Component 的好处。所以虽然 function 给你的代码体积减负了,但他在哪里又给你无形增加负担了呢?

因此,真的不推荐开发者用 class 这种写法么,你觉得呢?

tsy 的 Web 平台团队在过去几年中花费了大量时间来更新我们的前端代码。仅在一年半以前,我们才将 JavaScript 构建系统现代化 ,以实现更高级的特性,比如 箭头函数 和 类 ,从 2015 年起,它们被添加到了这个语言中。尽管这个升级意味着我们对代码库的未来验证已经完成,并且可以编写出更加习惯化、更可扩展的 JavaScript,但是我们知道还有改进的空间。

Etsy 已经有十六年的历史了。自然地,我们的代码库变得相当庞大; Monorepo (单体仓库)拥有超过 17000 个 JavaScript 文件,并且跨越了网站的很多迭代。如果开发者使用我们的代码库,很难知道哪些部分仍被视为最佳实践,哪些部分遵循传统模式或者被视为技术债务。JavaScript 语言本身使得这个问题更加复杂:虽然在过去的几年里,为该语言增加了新的语法特性,但是 JavaScript 非常灵活,对如何使用也没有多少可强制性的限制。这样,在编写 JavaScript 时,如果没有事先研究依赖关系的实现细节,就很有挑战性。尽管文档在某种程度上有助于减轻这个问题,但是它只能在很大程度上防止 JavaScript 库的不当使用,从而最终导致不可靠的代码。

所有这些问题(还有更多!)都是我们认为 TypeScript 可能为我们解决的问题。TypeScript 自称是“JavaScript 的超集”。换言之,TypeScript 就是 JavaScript 中的一切,可以选择增加类型。类型从根本上来说,在编程中,类型是通过代码移动的数据的期望的方式:函数可以使用哪些类型的输入,变量可以保存哪些类型的值。(如果你不熟悉类型的概念,TypeScript 的手册有一个 很好的介绍 )。

TypeScript 被设计成可以很容易地在已有的 JavaScript 项目中逐步采用,尤其是在大型代码库中,而转换成一种新的语言是不可能的。它非常擅长从你已经编写好的代码中推断出类型,并且其类型语法细微到足以正确地描述 JavaScript 中普遍存在的“怪癖”。此外,它由微软开发,已被 Slack 和 Airbnb 等公司使用,根据 去年的“State of JavaScript”调查 ,它是迄今为止使用最多、最流行的 JavaScript。若要使用类型来为我们的代码库带来某种秩序,TypeScript 看起来是一个非常可靠的赌注。

因此,在迁移到 ES6 之后,我们开始研究采用 TypeScript 的路径。本文将讲述我们如何设计我们的方法,一些有趣的技术挑战,以及如何使一家 Etsy 级别的公司学习一种新的编程语言。

在高层次上采用 TypeScript

我并不想花太多时间向你安利 TypeScript,因为在这方面还有很多其他的 文章 和 讲座 ,都做得非常好。相反,我想谈谈 Etsy 在推出 TypeScript 支持方面所做的努力,这不仅仅是从 JavaScript 到 TypeScript 的技术实现。这也包括许多规划、教育和协调工作。但是如果把细节弄清楚,你会发现很多值得分享的学习经验。让我们先来讨论一下我们想要的采用是什么样的做法。

采用策略

TypeScript 在检查代码库中的类型时,可能多少有点“严格”。据 TypeScript 手册 所述,一个更严格的 TypeScript 配置 “能更好地保证程序的正确性”,你可以根据自己的设计,根据自己的需要逐步采用 TypeScript 的语法及其严格性。这个特性诗 TypeScript 添加到各种代码库中成为可能,但是它也使“将文件迁移到 TypeScript”成为一个定义松散的目标。很多文件需要用类型进行注释,这样 TypeScript 就可以完全理解它们。还有许多 JavaScript 文件可以转换成有效的 TypeScript,只需将其扩展名从 .js 改为 .ts 即可。但是,即使 TypeScript 对文件有很好的理解,该文件也可能会从更多的特定类型中获益,从而提高其实用性。

各种规模的公司都有无数的文章讨论如何迁移到 TypeScript,所有这些文章都对不同的迁移策略提出了令人信服的论点。例如,Airbnb 尽可能地 自动化 了他们的迁移。还有一些公司在他们的项目中启用了较不严格的 TypeScript,随着时间的推移在代码中添加类型。

确定 Etsy 的正确方法意味着要回答一些关于迁移的问题:

  • 我们希望 TypeScript 的味道有多严格?
  • 我们希望迁移多少代码库?
  • 我们希望编写的类型有多具体?

我们决定将严格性放在第一位;采用一种新的语言需要付出大量的努力,如果我们使用 TypeScript,我们可以充分利用其类型系统(此外,TypeScript 的检查器在更严格的类型上 执行得更好 )。我们还知道 Etsy 的代码库相当庞大;迁移每个文件可能并不能充分利用我们的时间,但是确保我们拥有类型用于我们网站的新的和经常更新的部分是很重要的。当然,我们也希望我们的类型尽可能有用,容易使用。

我们采用的是什么?

以下是我们的采用策略:

  1. 使 TypeScript 尽可能地严格,并逐个文件地移植代码库。
  2. 添加真正优秀的类型和真正优秀的支持文档,包括产品开发者常用的所有实用程序、组件和工具。
  3. 花时间教工程师们学习 TypeScript,并让他们逐个团队地启用 TypeScript 语法。

让我们再仔细看看这几点吧。

逐步迁移到严格的 TypeScript

严格的 TypeScript 能够避免许多常见的错误,所以我们认为最合理的做法是尽量严格的。这一决定的缺点是我们现有的大多数 JavaScript 都需要类型注释。它还需要以逐个文件的方式迁移我们的代码库。使用严格的 TypeScript,如果我们尝试一次转换所有的代码,我们最终将会有一个长期的积压问题需要解决。如前所述,我们在单体仓库中有超过 17000 个 JavaScript 文件,其中很多都不经常修改。我们选择把重点放在那些在网站上积极开发的区域,明确地区分哪些文件具有可靠的类型,以及哪些文件不使用 .js 和 .ts 文件扩展名。

一次完全迁移可能在逻辑上使改进已有的类型很难,尤其是在单体仓库模式中。当导入 TypeScript 文件时,出现被禁止的类型错误,你是否应该修复此错误?那是否意味着文件的类型必须有所不同才能适应这种依赖关系的潜在问题?哪些具有这种依赖关系,编辑它是否安全?就像我们的团队所知道的,每个可以被消除的模糊性,都可以让工程师自己作出改进。在增量迁移中,任何以 .ts 或 .tsx 结尾的文件都可以认为存在可靠的类型。

确保实用程序和工具有良好的 TypeScript 支持

当我们的工程师开始编写 TypeScript 之前,我们希望我们所有的工具都能支持 TypeScript,并且所有的核心库都有可用的、定义明确的类型。使用 TypeScript 文件中的非类型化依赖项会使代码难以使用,并可能会引入类型错误;尽管 TypeScript 会尽力推断非 TypeScript 文件中的类型,但是如果无法推断,则默认为“any”。换句话说,如果工程师花时间编写 TypeScript,他们应该能够相信,当他们编写代码的时候,语言能够捕捉到他们所犯的类型错误。另外,强制工程师在学习新语言和跟上团队路线图的同时为通用实用程序编写类型,这是一种让人们反感 TypeScript 的好方法。这项工作并非微不足道,但却带来了丰厚的回报。在下面的“技术细节”一节中,我将对此进行详细阐述。

逐个团队地进行工程师适职培训

我们已经花了很多时间在 TypeScript 的教育上,这是我们在迁移过程中所做的最好的决定。Etsy 有数百名工程师,在这次迁移之前,他们几乎没有 TypeScript 的经验(包括我)。我们知道,要想成功地迁移,人们首先必须学习如何使用 TypeScript。打开这个开关,告诉所有人都要这么做,这可能会使人们迷惑,使我们的团队被问题压垮,也会影响我们产品工程师的工作速度。通过逐步引入团队,我们能够努力完善工具和教学材料。它还意味着,没有任何工程师能在没有队友能够审查其代码的情况下编写 TypeScript。逐步适职使我们的工程师有时间学习 TypeScript,并把它融入到路线图中。

技术细节

在迁移过程中,有很多有趣的技术挑战。令人惊讶的是,采用 TypeScript 的最简单之处就是在构建过程中添加对它的支持。在这个问题上,我不会详细讨论,因为构建系统有许多不同的风格,但简单地说:

  • 用 Webpack 来构建我们的 JavaScript 。Webpack 使用 Babel 将我们的现代 JavaScript 移植到更古老、更兼容的 JavaScript。
  • Babel 有个可爱的插件 babel-preset-typescript ,可以快速地将 TypeScript 转换成 JavaScript,但希望你能自己进行类型检查。
  • 要检查我们的类型,我们运行 TypeScript 编译器作为我们测试套件的一部分,并配置它不 使用 noEmit 选项 来实际转译任何文件。

上面所做的工作花费了一到两个星期,其中大部分时间是用于验证我们发送到生产中的 TypeScript 是否会发生异常行为。在其他 TypeScript 工具上,我们花费了更多的时间,结果也更有趣。

使用 typescript-eslint 提高类型特异性

我们在 Etsy 中大量使用了自定义的 ESLint Lint 规则。它们为我们捕捉各种不良模式,帮助我们废除旧代码,并保持我们的 pull request(拉取请求)评论不跑题,没有吹毛求疵。如果它很重要,我们将尝试为其编写一个 Lint 规则。我们发现,有一个地方可以利用 Lint 规则的机会,那就是强化类型特异性,我一般用这个词来表示“类型与所描述的事物之间的精确匹配程度”。

举例来说,假设有一个函数接受 HTML 标签的名称并返回 HTML 元素。该函数可以将任何旧的字符串作为参数接受,但是如果它使用这个字符串来创建元素,那么最好能够确保该字符串实际上是一个真正的 HTML 元素的名称。

// This function type-checks, but I could pass in literally any string in as an argument.
function makeElement(tagName: string): HTMLElement {
   return document.createElement(tagName);
}
// This throws a DOMException at runtime
makeElement("literally anything at all");

假如我们努力使类型更加具体,那么其他开发者将更容易正确地使用我们的函数。

// This function makes sure that I pass in a valid HTML tag name as an argument.
// It makes sure that ‘tagName’ is one of the keys in 
// HTMLElementTagNameMap, a built-in type where the keys are tag names 
// and the values are the types of elements.
function makeElement(tagName: keyof HTMLElementTagNameMap): HTMLElement {
   return document.createElement(tagName);
}
// This is now a type error.
makeElement("literally anything at all");
// But this isn't. Excellent!
makeElement("canvas");

迁移到 TypeScript 意味着我们需要考虑和解决许多新实践。 typescript-eslint 项目给了我们一些 TypeScript 特有的规则,可供我们利用。例如, ban-types 规则允许我们警告不要使用泛型 Element 类型,而使用更具体的 HTMLElement 类型。

此外,我们也作出了一个(有一点争议)决定,在我们的代码库中 允许使用 非空断言 和 类型断言 。前者允许开发者告诉 TypeScript,当 TypeScript 认为某物可能是空的时候,它不是空的,而后者允许开发者将某物视为他们选择的任何类型。

// This is a constant that might be ‘null’.
const maybeHello = Math.random() > 0.5 ? "hello" : null;
// The `!` below is a non-null assertion. 
// This code type-checks, but fails at runtime.
const yellingHello = maybeHello!.toUpperCase()
// This is a type assertion.
const x = {} as { foo: number };
// This code type-checks, but fails at runtime.
x.foo;

这两种语法特性都允许开发者覆盖 TypeScript 对某物类型的理解。很多情况下,它们都意味着某种类型更深层次问题,需要加以修复。消除这些类型,我们强迫这些类型对于它们所描述得更具体。举例来说,你可以使用“ as ”将 Element 转换为 HTMLElement ,但是你可能首先要使用 HTMLElement。TypeScript 本身无法禁用这些语言特性,但是 Lint 使我们能够识别它们并防止它们被部署。

作为防止人们使用不良模式的工具,Lint 确实非常有用,但是这并不意味着这些模式是普遍不好的:每个规则都有例外。Lint 的好处在于,它提供了合理的逃生通道。在任何时候,如果确实需要使用“as”,我们可以随时添加一次性的 Lint 例外。

// NOTE: I promise there is a very good reason for us to use `as` here.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const x = {} as { foo: number };

将类型添加到 API

我们希望我们的开发者能够编写出有效的 TypeScript 代码,所以我们需要确保为尽可能多的开发环境提供类型。乍一看,这意味着将类型添加到可重用设计组件、辅助实用程序和其他共享代码中。但是理想情况下,开发者需要访问的任何数据都应该有自己的类型。几乎我们网站上所有的数据都是通过 Etsy API 实现的,所以如果我们能在那里提供类型,我们很快就可以涵盖大部分的代码库。

Etsy 的 API 使用 PHP 实现的,并且我们为每个端点生成 PHP 和 JavaScript 配置,从而帮助简化请求的过程。在 JavaScript 中,我们使用一个轻量级封装 EtsyFetch 来帮助处理这些请求。这一切看起来就是这样的:

// This function is generated automatically.
function getListingsForShop(shopId, optionalParams = {}) {
   return {
       url: `apiv3/Shop/${shopId}/getLitings`,
       optionalParams,
   };
}
// This is our fetch() wrapper, albeit very simplified.
function EtsyFetch(config) {
   const init = configToFetchInit(config);
   return fetch(config.url, init);
}
// Here's what a request might look like (ignoring any API error handling).
const shopId = 8675309;
EtsyFetch(getListingsForShop(shopId))
   .then((response) => response.json())
   .then((data) => {
       alert(data.listings.map(({ id }) => id));
   });

在我们的代码库中,这种模式是非常普遍的。如果我们没有为 API 响应生成类型,开发者就得手工写出它们,并且想让它们与实际的 API 同步。我们需要严格的类型,但是我们也不希望我们的开发者为了得到这些类型而折腾。

最后,我们在 开发者 API 上做了一些工作,将端点转换成 OpenAPI 规范 。OpenAPI 规范是以类似 JSON 等格式描述 API 端点的标准化方式。虽然我们的开发者 API 使用了这些规范来生成面向公共的文档,但是我们也可以利用它们生成用于 API 的响应的 TypeScript 类型。在编写和改进 OpenAPI 规范生成器之前,我们已经花费了大量的时间来编写和改进,它可以适用于我们所有的内部 API 端点,然后使用一个名为 openapi-typescript 的库,将这些规范转换成 TypeScript 类型。

在为所有端点生成 TypeScript 类型之后,仍然需要以一种可利用的方式将它们整合到代码库中。我们决定将生成的响应类型编入我们所生成的配置中,然后更新 EtsyFetch,以便在它返回的 Promise 中使用这些类型。把所有这些放在一起,看起来大致是这样的:

// These types are globally available:
interface EtsyConfig<JSONType> {
   url: string;
}
interface TypedResponse<JSONType> extends Response {
   json(): Promise<JSONType>;
}
// This is roughly what a generated API config file looks like:
import OASGeneratedTypes from "api/oasGeneratedTypes";
type JSONResponseType = OASGeneratedTypes["getListingsForShop"];
function getListingsForShop(shopId): EtsyConfig<JSONResponseType> {
   return {
       url: `apiv3/Shop/${shopId}/getListings`,
   };
}
// This is (looooosely) what EtsyFetch looks like:
function EtsyFetch<JSONType>(config: EtsyConfig<JSONType>) {
   const init = configToFetchInit(config);
   const response: Promise<TypedResponse<JSONType>> = fetch(config.url, init);
   return response;
}
// And this is what our product code looks like:
EtsyFetch(getListingsForShop(shopId))
   .then((response) => response.json())
   .then((data) => {
       data.listings; // "data" is fully typed using the types from our API
   });

这一模式的结果非常有用。目前,对 EtsyFetch 的现有调用具有开箱即用的强类型,不需要进行更改。而且,如果我们更新 API 的方式会引起客户端代码的破坏性改变,那么类型检查器就会失败,而这些代码将永远不会出现在生产中。

键入我们的 API 还为我们提供了机会,使我们可以在后端和浏览器之间使用 API 作为唯一的真相。举例来说,如果我们希望确保支持某个 API 的所有区域都有一个标志的表情符号,我们可以使用以下类型来强制执行:

type Locales  OASGeneratedTypes["updateCurrentLocale"]["locales"];
const localesToIcons : Record<Locales, string> = {
   "en-us": ":us:",
   "de": ":de:",
   "fr": ":fr:",
   "lbn": ":lb:",
   //... If a locale is missing here, it would cause a type error.
}

最重要的是,这些特性都不需要改变我们产品工程师的工作流程。他们可以免费使用这些类型,只要他们使用他们已经熟悉的模式。

通过分析我们的类型来改善开发体验

推出 TypeScript 的一个重要部分是密切关注来自我们工程师的投诉。在我们进行迁移的早期阶段,有人提到过在提供类型提示和代码完成时,他们的编辑器很迟钝。例如,一些人告诉我们,当鼠标悬停在一个变量上时,他们要等半分钟才能显示出类型信息。考虑到我们可以在一分钟内对所有的 TS 文件运行类型检查器,这个问题就更加复杂了;当然,单个变量的类型信息也不应该这么昂贵。

幸运的是,我们和一些 TypeScript 项目的维护者举行了一次会议。他们希望看到 TypeScript 能够在诸如 Etsy 这样独特的代码库上获得成功。对于我们在编辑器上的挑战,他们也很惊讶,而且更让他们吃惊的是,TypeScript 花了整整 10 分钟来检查我们的整个代码库,包括未迁移的文件和所有文件。

在反复讨论后,确定我们没有包含超出需求的文件后,他们向我们展示了当时他们刚刚推出的性能跟踪功能。跟踪结果表明,当对未迁移的 JavaScript 文件进行类型检查时,TypeScript 就会对我们的一个类型出现问题。以下是该文件的跟踪(这里的宽度代表时间)。

结果是,类型中存在一个循环依赖关系,它帮助我们创建不可变的对象的内部实用程序。到目前为止,这些类型对于我们处理的所有代码来说都是完美无缺的,但在代码库中尚未迁移的部分,它的一些使用却出现了问题,产生了一个无限的类型循环。如果有人打开了代码库的这些部分文件,或者在我们对所有代码运行类型检查器时,就会花很多时间来尝试理解该类型,然后放弃并记录类型错误。修复了这个类型之后,检查一个文件的时间从将近 46 秒减少到了不到 1 秒。

这种类型在其他地方也会产生问题。当进行修正之后,检查整个代码库的时间大约为此前的三分之一,并且减少了整整 1GB 的内存使用。

如果我们没有发现这个问题,那么它最终将导致我们的测试(以及生产部署)速度更慢。它还会使每个人在编写 TypeScript 的时候非常非常不愉快。

教育

采用 TypeScript 的最大障碍,无疑是让大家学习 TypeScript。类型越多的 TypeScript 就越好。假如工程师对编写 TypeScript 代码感到不适应,那么完全采用这种语言将是一场艰难的斗争。就像我在上面提到的,我们决定逐个团队推广是建立某种制度化的 TypeScript 的最佳方式。

基础

我们通过直接与少数团队合作来开始我们的推广工作。我们寻找那些即将开始新项目并且时间相对灵活的团队,并询问他们是否对用 TypeScript 编写项目感兴趣。在他们工作的时候,我们唯一的工作就是审查他们的拉取请求,为他们需要的模块实现类型,并在他们学习时与他们配对。

在此期间,我们可以完善自己的类型,并且为 Etsy 代码库中难以处理的部分开发专门的文档。由于只有少数工程师正在编写 TypeScript,所以很容易从他们那里得到直接的反馈,并迅速解决他们遇到的问题。这些早期的团队为我们提供了很多 Lint 规则,这可以确保我们的文档清晰、有用。它还为我们提供了足够的时间来完成迁移的技术部分,比如向 API 添加类型。

让团队接受教育

当我们感觉大多数问题已经解决后,我们决定让任何有兴趣和准备好的团队加入。为使团队能够编写 TypeScript,我们要求他们先完成一些培训。我们从 ExecuteProgram 找到了一门课程,我们认为这门课程以互动和有效的方式,很好地教授了 TypeScript 的基础知识。当我们认为团队的所有成员都需要完成这门课程(或具有一定的同等经验),我们才能认为他们准备好了。

我们努力使人们对 TypeScript 非常感兴趣,以吸引更多的人参加互联网上的课程。我们与 Dan Vanderkam 取得了联系,他是《 Effective TypeScript 》(暂无中译本)的作者,我们想知道他是否对做一次内部讲座感兴趣(他答应了,他的讲座和书都非常棒)。此外,我还设计了一些非常高质量的虚拟徽章,我们会在课程作业的期中和期末发给大家,以保持他们的积极性(并关注大家学习 TypeScript 的速度)。

然后我们鼓励新加入的团队腾出一些时间迁移他们团队负责的 JS 文件。我们发现,迁移你所熟悉的文件是学习如何使用 TypeScript 的一个好方法。这是一种直接的、亲手操作类型的方式,然后你可以马上在别处使用。实际上,我们决定不使用更复杂的自动迁移工具( 比如 Airbnb 写的那个 ),部分原因是它剥夺了一些学习机会。另外,一个有一点背景的工程师迁移文件的效率比脚本要高。

逐个团队推广的后勤保障

一次一个团队的适职意味着我们必须防止个别工程师在其团队其他成员准备就绪之前编写 TypeScript。这种情况比你想象的要多;TypeScript 是一种非常酷的语言,人们都渴望去尝试它,尤其是在看到代码库中使用它后。为了避免这种过早地采用,我们编写了一个简单的 git 提交钩子,禁止不属于安全列表的用户修改 TypeScript。当一个团队准备好时,我们只需将他们加入到安全列表即可。

此外,我们努力与每一个团队的工程师经理发展直接交流。将电子邮件发送到整个工程部门很容易,但是和每一个经理密切合作可以确保没有人对我们的推出感到惊讶。它还给了我们一个机会来解决团队所关心的问题,比如学习一门新语言。尤其在大公司中,强制要求变更可能是一种负担,虽然直接的沟通层很小,但会有很大的帮助(即使它需要一个相当大的电子表格来跟踪所有的团队)。

适职后支持团队

事实证明,审查 PR 是早期发现问题的一种很好的方法,并为以后 Lint 规则的制定提供了许多参考。为有助于迁移,我们决定对包含 TypeScript 的每个 PR 进行明确的审查,直到推广顺利。我们将审查的范围扩大到语法本身,并随着我们的发展,向那些已经成功适职的工程师寻求帮助。我们将这个小组称为 TypeScript 顾问,他们是新晋 TypeScript 工程师的重要支持来源。

在推广过程中最酷的一个方面就是很多学习过程是如何有机进行的。有些小组举行了大型的结对会议,他们共同解决问题,或者尝试迁移文件,我们并不知道。一些小组甚至建立了读书会来阅读 TypeScript 书籍。这类迁移确实需要付出大量的努力,但是我们很容易忘记,其中有多少工作是由热情的同事和队友完成的。

我们现在在哪里?

在今秋早些时候,我们已经开始要求使用 TypeScript 编写所有新文件。大概有 25% 的文件是类型,这个数字还不包括被丢弃的特性、内部工具和死代码。到撰写本文时,每一个团队都已成功地使用 TypeScript。

“完成向 TypeScript 的迁移”并不是一个明确的定义,特别是对于大型代码库而言。尽管我们可能还会有一段时间在我们的仓库中没有类型的 JavaScript 文件,但从现在开始,我们的每一个新特性都将进行类型化。撇开这些不谈,我们的工程师已经在有效地编写和使用 TypeScript,开发自己的工具,就类型展开深思熟虑的讨论,分享他们认为有用的文章和模式。虽然很难说,但是人们似乎很喜欢一种去年这个时候几乎没人用过的语言。对于我们来说,这是一次成功的迁移。