自帶異步渲染的前端框架: Crank

本文要點:

  • 主要的前端框架,如React,在不斷增加特性的同時也變得越來越複雜。與這些框架一起使用的其他工具、語法和生態系統的複雜性也在增加。
  • 複雜性增加的一部分原因是,大型框架由於用戶衆多,需要保持高度的向後兼容性和穩定性。因此,它們就有理由不去重新考慮關鍵的設計選擇。
  • Crank重新研究了類似React這樣的框架的關鍵架構部分,該部分規定渲染函數必須爲純函數。相反,Crank利用異步生成器執行異步渲染,沒有任何成本。異步生成器是JavaScript的一種標準語言特性,不用承擔實現該功能的庫的成本。
  • 藉助語言自帶的生成器和async/await語法,開發人員可以像處理同步任務一樣自然地處理異步任務(獲取遠程數據、暫停和恢復渲染)。實現前端應用程序時需要掌握的、與這門語言無關的概念減少了。

Brian Kim是開源庫Repeater.js的作者,他最近發佈了一個新的用於創建Web應用程序的JavaScript庫Crank。Crank的創新之處在於,它使用協程聲明式地描述了應用程序的行爲,這是用JavaScript及異步生成器實現的。

雖然Crank尚在測試階段,還需要進一步的研究,但它支持的異步渲染可能可以處理類似React提供的Suspense功能這樣的用例。

當生成器運行時,它可能會接收數據(初始的prop),返回一個迭代器,用於訪問生成器閉包中保存的私有狀態。迭代器在迭代方(Crank庫函數)請求時計算並生成視圖,而迭代方在其迭代請求中傳遞更新後的prop。Crank異步迭代器返回一個promise,使計算視圖得以渲染,從而提供異步渲染能力。這樣,Crank組件就自然地提供了局部state和effect支持,而不需要專門的語法。另外,Crank組件的生命週期就是生成器的生命週期:生成器在裝載匹配的DOM元素時啓動,在卸載匹配的DOM元素時停止。錯誤可以使用標準的JavaScript結構try... catch捕獲。

還有其他框架用JavaScript生成器來創建Web應用程序。Concur UI從Haskell移植到JavaScript,PureScript和Python使用異步生成器組合組件。Turbine將自己描述爲一個毫不妥協的純函數Web框架,它利用生成器實現了FRP範式。

InfoQ採訪了Brian Kim,內容涉及這個新的JavaScript框架的基本原理,以及他認爲利用JavaScript生成器可以獲得哪些好處。

InfoQ:您可以向我們的讀者介紹下自己嗎?

Brian Kim:我是一名獨立的前端工程師。我整個編程生涯幾乎都在使用React——你甚至可以在2013年的一篇React博文中看到我的名字。

我也是開源異步迭代器庫Repeater.js的創建者和維護者。該庫旨在成爲創建安全的異步迭代器所缺少的構造函數。[…]我創建了repeaters,這是一個看起來很像Promise構造函數的實用工具類,它讓你可以更輕鬆地將基於回調的API轉換爲異步迭代器。

InfoQ:您能快速地爲我們介紹下repeaters的設計目標嗎?

Kim:Repeaters採用了我多年來學到的許多好的異步迭代器設計實踐,比如延遲執行、使用有界隊列、處理反壓以及以可預測的方式傳播錯誤。本質上,它是一個精心設計的API,讓開發人員可以順利地使用異步迭代器,確保他們的事件處理程序總是得到清理,而瓶頸和死鎖可以被迅速發現。

InfoQ:您最近發佈了Crank.js。您將其描述爲一個新的創建Web應用程序的Web框架。爲什麼要創建一個新的JavaScript框架?

Kim:我知道,感覺像是每週都有一個新的JavaScript框架發佈,我在介紹Crank的博文中甚至爲又創建了一個框架而道歉。我創建Crank是因爲我對最新的React API(如Hooks和Suspense)感到失望,但我仍然希望使用靠着React流行起來的JSX和元素比較算法。我已經愉快地使用React超過五年了,所以我是用了很長時間後才說受夠了,並編寫了自己的框架。

InfoQ:是什麼讓您無法忍受了?

Kim:我想我的挫敗感是從鉤子開始的。我之前很興奮,React團隊致力於避免組件擁有狀態,使其函數語法更有用,但是我擔心“鉤子的規則”,它們似乎很容易規避,這對其他框架是不公平的,因爲它有權調用任何名字以use開頭的函數。然後,當我開始在實踐中學習更多關於鉤子的知識,並看到了letconst發明以前在JavaScript中從未見過的新的stale closure缺陷時,我開始懷疑鉤子是不是最好的方法。

但對我來說真正的轉折點是Suspense項目。[…]

InfoQ:您能詳細地說明下嗎?

Kim:這時,我開始試用Suspense,因爲我認爲它將允許人們使用我編寫的異步迭代器鉤子,就好像它們是同步的一樣。但是,我很快就發現,我實際上不可能使用Suspense,因爲它對緩存有嚴格的要求,而且我也不清楚如何緩存以及重用我的鉤子所依賴的異步迭代器。

Suspense以及React中異步數據獲取的實現需要緩存,這讓我有些震驚,因爲到目前爲止,我只是假設可以在React組件中獲得類似於async/await這樣的特性。[…]我非常擔心,我必須使用key並失效每一個爲了使用promises而進行的異步調用。

[我開始意識到]React在組件中使用componentDidWhat或鉤子所做的每件事都可以封裝成一個單一的異步生成器函數:

async function *MyComponent(props) 
  let state = componentWillMount(props);
  let ref = yield <MyElement />;
  state = componentDidMount(props, state, ref);
  try {
    for await (const nextProps of updates()) {
      if (shouldComponentUpdate(props, nextProps, state)) {
        state = componentWillUpdate(props, nextProps, state);
        ref = yield <MyElement />;
        state = componentDidUpdate(props, nextProps, state, ref);
      }
      props = nextProps;
    }
  } catch (err) {
    return componentDidCatch(err);
  } finally {
    componentWillUnmount(ref);
  }
}

[…] 通過生成JSX元素而不是返回它們,你可以在渲染之前和之後編寫代碼,類似於componentWillUpdatecomponentDidUpdate。State變成局部變量,而新的props可以通過框架提供的異步迭代器傳入,甚至可以使用JavaScript控制流如try/catch/finally從子組件捕獲錯誤並編寫清理邏輯,所有這些都在相同的作用域內。

InfoQ:所以您決定使用異步生成器作爲這個新框架的基礎?

Kim:[…] React團隊安排了大量的工程人才來構建一個“UI運行時”,我[意識到我]可以把最困難的部分如堆棧暫掛或調度委託給JavaScript運行時,它提供生成器、異步函數和一個微任務隊列來完成這些工作。我覺得React團隊所做的一切讓人印象深刻,作爲一名程序員,那是我所力不能及的,但是,他們把這些特性以原生JavaScript的形式提供了出來,我只需要弄清楚如何把這些拼圖拼在一起就可以了。

組件不是隻能用同步函數編寫,還可以用異步函數,以及同步和異步生成器函數。我走了彎路,在此之前,我一直埋頭於我的一個創業點子。在對這個想法進行了長達數月的調查研究之後,Crank誕生了。坦白地說,我希望回到編寫應用程序而不是框架的工作中來,但是,JavaScript社區突如其來的興趣讓Crank成了一個愉快的意外。

InfoQ:您提到,Crank.js利用了基於JSX的組件和異步生成器。JSX組件在使用渲染函數的框架(某種程度上類似於React這樣的框架或Vue)中非常常見。而生成器很少使用,異步生成器就更少用了。這些構造與開發Web應用程序有什麼關係?

Kim:我絕對不是第一個試驗生成器和異步生成器的人;我一直在GitHub上尋找新的想法,我看到在前端領域有很多人在用生成器做試驗。

然而,也許是由於JavaScript中的生成器早期與async/await和promises存在關聯,作爲指定組件異步依賴項的一種方式,這些庫中似乎有許多都使用生成器來生成promises而只返回JSX元素。我意識到,我們還可以簡單地生成JSX元素,並基於虛擬DOM比較算法爲異步組件找到一個單獨的語義。

最後,我認爲,JSX元素和生成器實際上是完美的搭配:你生成元素,框架渲染它們,渲染後的節點通過調用以某種響應模式傳遞迴生成器。我認爲,總的來說,很多人,特別是那些有函數編程背景的人,往往不急於採用迭代器和生成器,因爲它們是有狀態的數據結構,這些人認爲,生成器的有狀態性增加了推理難度。但實際上,我認爲,這是生成器的一個很好的特性,至少在JavaScript中,有狀態過程建模的最好方法是使用有狀態抽象。

將組件生命週期建模爲生成器,我們不僅能夠在一個函數中捕獲DOM的狀態並建模,我們還能以非常透明的方式完成這項工作,因爲每個組件實例只有一個生成器執行,其閉包會在兩次渲染之間保留。在Crank中,同步生成器組件的恢復次數等於父組件更新它的次數加上組件本身更新的次數。這種可以推理組件執行準確次數的能力,人們基本不再期待React會提供,在實踐中,這意味着我們可以藉助Crank把副作用直接放到“渲染方法”中,因爲這個框架不會在你不期望的時候不斷地重新渲染你的組件。

InfoQ:您從開發人員那裏收到了什麼反饋嗎?

Kim:我收到過這樣的反饋:“我希望我們在Rust中也能有這樣的東西。”我很高興看到人們參照Crank的思想,然後用其他語言實現它們,這些語言可能會有像Rust futures這樣更強大的抽象。

InfoQ:有哪些事情用Crank更簡單,用其他框架更難?您能舉個例子嗎?

Kim:因爲所有狀態都是局部變量,所以我們可以在生成器組件中自由地組合來自React的概念,比如props、state和refs,這是其他框架無法做到的。例如,這個組件示例比較了新舊props,並根據它們是否匹配來渲染一些不同的東西,在Crank開發早期,我對此感到很震驚:

function *Greeting({name}) {
  yield <div>Hello {name}</div>;
  for (const {name: newName} of this) {
    if (name !== newName) {
      yield (
        <div>Goodbye {name} and hello {newName}</div>
      );
    } else {
      yield <div>Hello again {newName}</div>;
    }
    name = newName;
  }
}
renderer.render(<Greeting name="Alice" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello Alice</div>"
renderer.render(<Greeting name="Alice" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello again Alice</div>"
renderer.render(<Greeting name="Bob" />, document.body);
console.log(document.body.innerHTML); // "<div>Goodbye Alice and hello Bob</div>"
renderer.render(<Greeting name="Bob" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello again Bob</div>"

我們不需要一個單獨的生命週期或鉤子來比較新舊props,我們只需要在同一個閉包中引用它們。簡而言之,比較新舊props就像比較數組中的相鄰元素一樣簡單。

此外,因爲Crank將局部狀態的概念與重新渲染解耦,我認爲它解鎖了許多在其他框架中不可能實現的高級渲染模式。例如,你可以想象這樣一種架構:子組件具有局部狀態,但不會重新渲染,而是由在requestAnimationFrame循環中渲染的單個父組件一次性渲染。有狀態的組件不必在每次更新時都重新渲染,在Crank中這很容易實現,因爲我們已經從渲染中解耦了狀態。

舉個例子,你可以看看我製作的這個快速演示,我在其中實現了一個3D立方體/球體演示,去年,React和Svelte的用戶在Twitter上討論過這個演示。我對Crank的性能上限感到興奮,因爲更新組件是通過生成器逐步完成的,當狀態只是局部變量,而有狀態性本身並沒有與反應式系統緊密耦合(迫使每個有狀態組件重新渲染,即使有一個祖先組件會重新渲染它)時,你還可以在用戶空間做很多有趣的優化。雖然在Crank最初的版本中,我更關注的是正確性和API設計,而不是性能,但我目前正努力提升Crank的速度,而且結果看起來很有希望,儘管我還沒有計劃對Crank的性能提什麼具體的要求。

InfoQ:反過來說,有什麼事情使用其他框架更簡單,而使用Crank更難?

Kim:我曾批評過Concurrent Mode和React的未來發展方向,但如果React團隊能夠完成,那麼看到組件可以根據主線程擁擠程度而自動調度渲染,還是讓人感覺很神奇的。關於如何在Crank中實現這類調度,我有一些想法,但我沒有任何具體的解決方案。我的希望是,既然你可以直接在組件中await,那麼我們就可以在用戶空間中以一種透明、可選的方式直接實現調度。

此外,儘管我不喜歡React的鉤子,但對於庫作者如何將他們的整個API封裝在一個或兩個鉤子中,我還是有一些話要說。有一件我應該料到但實際沒料到的事情是,早期的採用者大力呼籲使用類似鉤子這樣的特性來將他們的庫與Crank集成。我還不確定那會是什麼樣子,但我也有一些想法。

受訪者介紹:

Brian Kim是一名獨立的前端工程師。他是開源異步迭代器庫Repeater.js的創建者和維護者。該庫旨在成爲創建安全的異步迭代器所缺少的構造函數。

原文鏈接:

Crank, a New Front-End Framework with Baked-In Asynchronous Rendering - Q&A with Brian Kim

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章