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。
在上面我們提到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;
}
在如今的Web開發中,JavaScript幾乎占據了項目代碼的大部分。我們可以在項目開發中使用ES 2020、ES2021、甚至提案中的新特性(如:Decorator[5]),即使瀏覽器尚未支持,也可以編寫Polyfill或使用Babel之類的工具進行轉譯,讓我們可以將最新的特性應用到生產環境中(如下圖所示)。
JavaScript標準制定流程.png
而CSS就不同了,除了制定CSS標準規范所需的時間外,各家瀏覽器的版本、實戰進度差異更是曠日持久(如下圖所示),最多利用PostCSS、Sass等工具來幫我們轉譯出瀏覽器能接受的CSS。開發者們能操作的就是通過JS去控制DOM與CSSOM來影響頁面的變化,但是對于接下來的Layout、Paint與Composite就幾乎沒有控制權了。為了解決上述問題,為了讓CSS的魔力不在受到瀏覽器的限制,Houdini就此誕生。
CSS 標準制定流程.png
我們上文中提到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并將其轉化為DOM與CSSOM的過程,以及Cascade,Layout,Paint,Composite我們也無能為力。整個過程中我們唯一完全可控制的就是DOM,另外CSSOM部分可控。
CSS Houdini草案中提到,這種程度的暴露是不確定的、兼容性不穩定的以及缺乏對關鍵特性的支持的。比如,在瀏覽器中的 CSSOM 是不會告訴我們它是如何處理跨域的樣式表,而且對于瀏覽器無法解析的 CSS 語句它的處理方式就是不解析了,也就是說——如果我們要用 CSS polyfill讓瀏覽器去支持它尚且不支持的屬性,那就不能在 CSSOM 這個環節做,我們只能遍歷一遍DOM,找到 <style> 或 <link rel="stylesheet"> 標簽,獲取其中的 CSS 樣式、解析、重寫,最后再加回 DOM 樹中。令人尷尬的是,這樣DOM樹全部刷新了,會導致頁面的重新渲染(如下如所示)。
即便如此,有的人可能會說:“除了這種方法,我們也別無選擇,更何況對網站的性能也不會造成很大的影響”。那么對于部分網站是這樣的。但如果我們的Polyfill是需要對可交互的頁面呢?例如scroll,resize,mousemove,keyup等等,這些事件隨時會被觸發,那么意味著隨時都會導致頁面的重新渲染,交互不會像原本那樣絲滑,甚至導致頁面崩潰,對用戶的體驗也極其不好。
綜上所述,如果我們想讓瀏覽器解析它不認識的樣式(低版本瀏覽器使用grid布局),然而渲染流程我們無法介入,我們也只能通過手動更新DOM的方式,這樣會帶來很多問題,Houdini的出現正是致力于解決他們。
Houdini是一組底層API,它公開了CSS引擎的各個部分,如下圖所示展示了每個環節對應的新API(灰色部分各大瀏覽器還未實現),從而使開發人員能夠通過加入瀏覽器渲染引擎的樣式和布局過程來擴展CSS。Houdini是一群來自Mozilla,Apple,Opera,Microsoft,HP,Intel和Google的工程師組成的工作小組設計而成的。它們使開發者可以直接訪問CSS對象模型(CSSOM),使開發人員可以編寫瀏覽器可以解析為CSS的代碼,從而創建新的CSS功能,而無需等待它們在瀏覽器中本地實現。
CSS Houdini-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是渲染引擎的擴展,從概念上來講它類似于Web Workers[9],但有幾個重要的區別:
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
Typed OM是對現有的CSSOM的擴展,并實現 Parsing API 和 Properties & 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主要有兩種方法:
使用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 {...},
// }
開發者可以通過這個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');
目前瀏覽器大部分還不支持
我們可以在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) { }
});
這個API讓我們可以控制基于用戶輸入的關鍵幀動畫,并且以非阻塞的方式。還能更改一個 DOM 元素的屬性,不過是不會引起渲染引擎重新計算布局或者樣式的屬性,比如 transform、opacity 或者滾動條位置(scroll offset)。Animation API的使用方式與 Paint API 和Layout 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)
允許開發者自由擴展 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);
它將提供一些方法來測量在屏幕上呈現的文本元素的尺寸,將允許開發者控制文本元素在屏幕上呈現的方式。使用當前功能很難或無法測量這些值,因此該API將使開發者可以更輕松地創建與文本和字體相關的CSS特性。例如:
Is Houdini ready yet
(https://ishoudinireadyyet.com/)
了解到這里,部分開發者可能會說:“我不需要這些花里胡哨的技術,并不能帶收益。我只想簡簡單單的寫幾個頁面,做做普通的Web App,并不想試圖干預瀏覽器的渲染過程從而實現一些實驗性或炫酷的功能。”如果這樣想的話,我們不妨退一步再去思考。回憶下最近做過的項目,用于實現頁面效果所使用到的技術,grid布局方式在考慮兼容老版本瀏覽器時也不得不放棄。我們想控制瀏覽器渲染頁面的過程并不是僅僅為了炫技,更多的是為了幫助開發者們解決以下兩個問題:
幾年過后再回眸,當主流瀏覽器完全支持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 兼容代碼時談到了幾點,大意為:
這些概述大致總結了類定義在兩個 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,因為在這方面還有很多其他的 文章 和 講座 ,都做得非常好。相反,我想談談 Etsy 在推出 TypeScript 支持方面所做的努力,這不僅僅是從 JavaScript 到 TypeScript 的技術實現。這也包括許多規劃、教育和協調工作。但是如果把細節弄清楚,你會發現很多值得分享的學習經驗。讓我們先來討論一下我們想要的采用是什么樣的做法。
TypeScript 在檢查代碼庫中的類型時,可能多少有點“嚴格”。據 TypeScript 手冊 所述,一個更嚴格的 TypeScript 配置 “能更好地保證程序的正確性”,你可以根據自己的設計,根據自己的需要逐步采用 TypeScript 的語法及其嚴格性。這個特性詩 TypeScript 添加到各種代碼庫中成為可能,但是它也使“將文件遷移到 TypeScript”成為一個定義松散的目標。很多文件需要用類型進行注釋,這樣 TypeScript 就可以完全理解它們。還有許多 JavaScript 文件可以轉換成有效的 TypeScript,只需將其擴展名從 .js 改為 .ts 即可。但是,即使 TypeScript 對文件有很好的理解,該文件也可能會從更多的特定類型中獲益,從而提高其實用性。
各種規模的公司都有無數的文章討論如何遷移到 TypeScript,所有這些文章都對不同的遷移策略提出了令人信服的論點。例如,Airbnb 盡可能地 自動化 了他們的遷移。還有一些公司在他們的項目中啟用了較不嚴格的 TypeScript,隨著時間的推移在代碼中添加類型。
確定 Etsy 的正確方法意味著要回答一些關于遷移的問題:
我們決定將嚴格性放在第一位;采用一種新的語言需要付出大量的努力,如果我們使用 TypeScript,我們可以充分利用其類型系統(此外,TypeScript 的檢查器在更嚴格的類型上 執行得更好 )。我們還知道 Etsy 的代碼庫相當龐大;遷移每個文件可能并不能充分利用我們的時間,但是確保我們擁有類型用于我們網站的新的和經常更新的部分是很重要的。當然,我們也希望我們的類型盡可能有用,容易使用。
以下是我們的采用策略:
讓我們再仔細看看這幾點吧。
嚴格的 TypeScript 能夠避免許多常見的錯誤,所以我們認為最合理的做法是盡量嚴格的。這一決定的缺點是我們現有的大多數 JavaScript 都需要類型注釋。它還需要以逐個文件的方式遷移我們的代碼庫。使用嚴格的 TypeScript,如果我們嘗試一次轉換所有的代碼,我們最終將會有一個長期的積壓問題需要解決。如前所述,我們在單體倉庫中有超過 17000 個 JavaScript 文件,其中很多都不經常修改。我們選擇把重點放在那些在網站上積極開發的區域,明確地區分哪些文件具有可靠的類型,以及哪些文件不使用 .js 和 .ts 文件擴展名。
一次完全遷移可能在邏輯上使改進已有的類型很難,尤其是在單體倉庫模式中。當導入 TypeScript 文件時,出現被禁止的類型錯誤,你是否應該修復此錯誤?那是否意味著文件的類型必須有所不同才能適應這種依賴關系的潛在問題?哪些具有這種依賴關系,編輯它是否安全?就像我們的團隊所知道的,每個可以被消除的模糊性,都可以讓工程師自己作出改進。在增量遷移中,任何以 .ts 或 .tsx 結尾的文件都可以認為存在可靠的類型。
當我們的工程師開始編寫 TypeScript 之前,我們希望我們所有的工具都能支持 TypeScript,并且所有的核心庫都有可用的、定義明確的類型。使用 TypeScript 文件中的非類型化依賴項會使代碼難以使用,并可能會引入類型錯誤;盡管 TypeScript 會盡力推斷非 TypeScript 文件中的類型,但是如果無法推斷,則默認為“any”。換句話說,如果工程師花時間編寫 TypeScript,他們應該能夠相信,當他們編寫代碼的時候,語言能夠捕捉到他們所犯的類型錯誤。另外,強制工程師在學習新語言和跟上團隊路線圖的同時為通用實用程序編寫類型,這是一種讓人們反感 TypeScript 的好方法。這項工作并非微不足道,但卻帶來了豐厚的回報。在下面的“技術細節”一節中,我將對此進行詳細闡述。
我們已經花了很多時間在 TypeScript 的教育上,這是我們在遷移過程中所做的最好的決定。Etsy 有數百名工程師,在這次遷移之前,他們幾乎沒有 TypeScript 的經驗(包括我)。我們知道,要想成功地遷移,人們首先必須學習如何使用 TypeScript。打開這個開關,告訴所有人都要這么做,這可能會使人們迷惑,使我們的團隊被問題壓垮,也會影響我們產品工程師的工作速度。通過逐步引入團隊,我們能夠努力完善工具和教學材料。它還意味著,沒有任何工程師能在沒有隊友能夠審查其代碼的情況下編寫 TypeScript。逐步適職使我們的工程師有時間學習 TypeScript,并把它融入到路線圖中。
在遷移過程中,有很多有趣的技術挑戰。令人驚訝的是,采用 TypeScript 的最簡單之處就是在構建過程中添加對它的支持。在這個問題上,我不會詳細討論,因為構建系統有許多不同的風格,但簡單地說:
上面所做的工作花費了一到兩個星期,其中大部分時間是用于驗證我們發送到生產中的 TypeScript 是否會發生異常行為。在其他 TypeScript 工具上,我們花費了更多的時間,結果也更有趣。
我們在 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 };
我們希望我們的開發者能夠編寫出有效的 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,開發自己的工具,就類型展開深思熟慮的討論,分享他們認為有用的文章和模式。雖然很難說,但是人們似乎很喜歡一種去年這個時候幾乎沒人用過的語言。對于我們來說,這是一次成功的遷移。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。