咱們都知道,React 最大的賣點之一,就是 Learn once, write anywhere 的通用性。但如何才能在瀏覽器以外,甚至在 Node.js 以外,用 React 渲染 UI 呢?本文將帶你用 React 直通嵌入式驅動層,讓現代前端技術與古老的硬件無縫結合。html
本次咱們的渲染目標,是一塊僅 0.96 寸大的點陣液晶屏,型號爲 SSD1306。它的分辨率僅 128x64,你可能在早期黑白 MP3 時代用它滾動播放過歌詞。這塊芯片到底有多小呢?我拍了張實物對比圖:前端
通常的 PC 顯然不會直接支持這種硬件,所以咱們須要嵌入式的開發環境——我選擇了最方便的樹莓派。node
雖然樹莓派已經具有了完善的 Python 和 Node.js 等現成的語言環境,但我但願挑戰極限,按照「可以將 React 運行在最低配置的硬件環境上」的方式來作技術選型。爲此我尋找的是面向嵌入式硬件的超輕量 JS 解釋器,來替代瀏覽器和 Node.js 上較爲沉重的 V8。最後我選擇了 QuickJS,一個年輕但系出名門的 JS 引擎。react
因此簡單說,咱們的目標是打通 React → QuickJS → 樹莓派 → SSD1306 芯片這四個體系。這個初看起來困難的目標,能夠拆分爲以下的幾個步驟:linux
上面的每一步雖然都不算難,但也都足夠寫篇獨立的技術博客了。爲保持可讀性,本文只能儘可能覆蓋核心概念與關鍵步驟。不過我能夠先向你保證,最後的整個項目不只代碼足夠簡單,仍是自由而開源的。git
讓咱們開始吧!github
其實,QuickJS 並非惟一的嵌入式 JS 引擎,以前社區已有 DukTape 和 XS 等很多面向 IoT 硬件的 JS 引擎,但一直不溫不火。相比之下 QuickJS 最吸引個人地方,有這麼幾點:算法
可是,QuickJS 畢竟還只是個剛發佈幾個月的新項目而已,勇於嚐鮮的人並很少。即使經過了各類單元測試,它真的能穩定運行起 React 這樣的工業級 JS 項目嗎?這是決定這條技術路線可行性的關鍵問題。npm
爲此,咱們固然須要先實際用上 QuickJS。它的源碼是跨平臺的,並不是只能在 Linux 或樹莓派上運行。在個人 macOS 上,拉下代碼一套素質三連便可:後端
cd quickjs
make
sudo make install
複製代碼
這樣,咱們就能夠在終端輸入 qjs
命令來進入 QuickJS 解釋器了。只要形如 qjs foo.js
的形式,便可用它執行你的腳本。再加上 -m
參數,它就能支持載入 ES Module (ESM) 形式的模塊,直接運行起整個模塊化的 JS 項目了。
注意,在 QuickJS 中使用 ESM 時,必須給路徑加上完整的
.js
後綴。這和瀏覽器中對直接加載 ESM 的要求是一致的。
不過,QuickJS 並不能直接運行「咱們平常寫的那種 React」,畢竟標籤式的 JSX 只是方言,不是業界標準。怎麼辦呢?做爲變通,我引入了輔助的 Node.js 環境,先用 Rollup 打包並轉譯 JSX 代碼爲 ESM 格式,再交給 QuickJS 執行。這個輔助環境的 node_modules 體積只有 10M 不到,具體配置再也不贅述。
很快關鍵的一步就來了,你以爲 qjs react.js
真的能用嗎?這時就體現出 React 的設計優越性了——早在兩年前 React 16.0 發佈時,React 就在架構上分離了上層的 react
和下層的默認 DOM 渲染器 react-dom
,它們經過 react-reconciler
封裝的 Fiber 中間層來鏈接。react
包沒有對 DOM 的依賴,是能夠獨立在純 JS 環境下運行的。這種工程設計雖然增大了總體的項目體積,但對於咱們這種要定製渲染後端的場合則很是有用,也是個 React 比 Vue 已經領先了兩年有餘的地方。如何驗證 React 可用呢?編寫個最簡單的無狀態組件試試就好了:
import './polyfill.js'
import React from 'react'
const App = props => {
console.log(props.hello)
return null
}
console.log(<App hello={'QuickJS'} />) 複製代碼
注意到 polyfill.js
了嗎?這是將 React 移植到 QuickJS 環境所需的兼容代碼。看起來這種兼容工做可能很困難,但其實很是簡單,就像這樣:
// QuickJS 約定的全局變量爲 globalThis
globalThis.process = { env: { NODE_EMV: 'development' } }
globalThis.console.warn = console.log
複製代碼
這麼點代碼由 Rollup 打包後,執行 qjs dist.js
便可得到這樣的結果:
$ qjs ./dist.js
QuickJS
null
複製代碼
這說明 React.createElement
能正確執行,Props 的傳遞也沒有問題。這個結果讓我很興奮,由於即便停在這一步,也已經說明了:
npm install react
的源碼,可以一行不改地運行在符合標準的 JS 引擎上。好了,QuickJS 牛逼!React 牛逼!接下來該幹嗎呢?
咱們已經讓 React 順利地在 QuickJS 引擎上執行了。但別忘了咱們的目標——將 React 直接渲染到液晶屏!該如何在液晶屏上渲染內容呢?最貼近硬件的 C 語言確定是最方便的。但在開始編碼以前,咱們須要搞明白這些概念:
/dev
目錄下。咱們首先須要將屏幕芯片鏈接到樹莓派上。方法以下(樹莓派引腳號能夠用 pinout
命令查看):
鏈接好以後,大概是這樣的:
而後,在樹莓派「開始菜單」的 System Configuration 中,啓用 Interface 中的 I2C 項(這步也能敲命令處理)並重啓,便可啓用 I2C 支持。
硬件和系統都配置好以後,咱們來安裝 I2C 的一些工具包:
sudo apt-get install i2c-tools libi2c-dev
複製代碼
如何驗證上面這套流程 OK 了呢?使用 i2cdetect
命令便可。若是看到下面這樣在 3c
位置有值的結果,說明屏幕已經正確掛載了:
$ i2cdetect -y 1
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- 3c -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --
複製代碼
環境配置完成後,咱們就能夠編寫用 open / write / ioctl 等系統調用來控制屏幕的 C 代碼了。這須要對 I2C 通訊協議有些瞭解,好在有很多現成的輪子能夠用。這裏用的是 oled96 庫,基於它的示例代碼大概這樣:
// demo.c
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "oled96.h"
int main(int argc, char *argv[]) {
// 初始化
int iChannel = 1, bFlip = 0, bInvert = 0;
int iOLEDAddr = 0x3c;
int iOLEDType = OLED_128x64;
oledInit(iChannel, iOLEDAddr, iOLEDType, bFlip, bInvert);
// 清屏後渲染文字和像素
oledFill(0);
oledWriteString(0, 0, "Hello OLED!", FONT_SMALL);
oledSetPixel(42, 42, 1);
// 在用戶輸入後關閉屏幕
printf("Press ENTER to quit!\n");
getchar();
oledShutdown();
}
複製代碼
這個示例只須要 gcc demo.c
命令就能運行。不出意外的話,運行編譯產生的 ./a.out
便可點亮屏幕。這一步編寫的代碼也很淺顯易懂,真正較複雜的地方在於 oled96 驅動層的通訊實現。有興趣的同窗能夠讀讀它的源碼噢。
如今,React 世界和硬件世界分別都能正常運轉了。但如何鏈接它們呢?咱們須要爲 QuickJS 引擎開發 C 語言模塊。
QuickJS 中默認內置了 os
和 std
兩個原生模塊,好比咱們司空見慣的這種代碼:
const hello = 'Hello'
console.log(`${hello} World!`)
複製代碼
其實在 QuickJS 中也能換成這樣寫:
import * as std from 'std'
const hello = 'Hello'
std.out.printf('%s World!', hello)
複製代碼
有沒有種 C 語言換殼的感受?這裏的 std
模塊其實就是做者爲 C 語言 stdlib.h
和 stdio.h
實現的 JS Binding。那我若是想本身實現其餘的 C 模塊,該怎麼辦呢?官方文檔大手一揮,告訴你「直接照個人源碼來寫就行」——敢把核心源碼看成面向小白的示例,可能這就是大神吧。
一番折騰後,我發現 QuickJS 在接入原生模塊時的設計,很是的「藝高人膽大」。首先咱們要知道的是,在 qjs
以外,QuickJS 還提供了個 qjsc
命令,能將一份寫了 Hello World 的 hello.js
直接編譯到二進制可執行文件,或者這樣的 C 代碼:
/* File generated automatically by the QuickJS compiler. */
#include "quickjs-libc.h"
const uint32_t qjsc_hello_size = 87;
const uint8_t qjsc_hello[87] = {
0x01, 0x04, 0x0e, 0x63, 0x6f, 0x6e, 0x73, 0x6f,
0x6c, 0x65, 0x06, 0x6c, 0x6f, 0x67, 0x16, 0x48,
0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72,
0x6c, 0x64, 0x22, 0x65, 0x78, 0x61, 0x6d, 0x70,
0x6c, 0x65, 0x73, 0x2f, 0x68, 0x65, 0x6c, 0x6c,
0x6f, 0x2e, 0x6a, 0x73, 0x0d, 0x00, 0x06, 0x00,
0x9e, 0x01, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00,
0x14, 0x01, 0xa0, 0x01, 0x00, 0x00, 0x00, 0x39,
0xd0, 0x00, 0x00, 0x00, 0x43, 0xd1, 0x00, 0x00,
0x00, 0x04, 0xd2, 0x00, 0x00, 0x00, 0x24, 0x01,
0x00, 0xcc, 0x28, 0xa6, 0x03, 0x01, 0x00,
};
int main(int argc, char **argv) {
JSRuntime *rt;
JSContext *ctx;
rt = JS_NewRuntime();
ctx = JS_NewContextRaw(rt);
JS_AddIntrinsicBaseObjects(ctx);
js_std_add_helpers(ctx, argc, argv);
js_std_eval_binary(ctx, qjsc_hello, qjsc_hello_size, 0);
js_std_loop(ctx);
JS_FreeContext(ctx);
JS_FreeRuntime(rt);
return 0;
}
複製代碼
你的 Hello World 去哪了?就在這個大數組的字節碼裏呢。這裏一些形如 JS_NewRuntime
的 C 方法,其實就是 QuickJS 對外 API 的一部分。你能夠參考這種方式,在原生項目裏接入 QuickJS——真正的大神,即使把本身的代碼編譯一遍,仍是示例級的教程代碼。
搞懂這個過程後不難發現,QuickJS 中最簡單的原生模塊使用方式,實際上是這樣的:
qjsc
將所有 JS 代碼,編譯成 C 語言的 main.c
入口gcc -c
命令編譯爲 .o
格式的目標文件main.c
並連接上這些 .o
文件,得到最終的 main
可執行文件看懂了嗎?這個操做的核心在於先把 JS 編譯成普通的 C,再在 C 的世界裏連接各類原生模塊。雖然有些奇幻,但好處是這樣不須要魔改 QuickJS 源碼就能實現。按這種方式,我基於 oled96 實現了個名爲 renderer.c
的 C 模塊,它會提供名爲 renderer
的 JS 原生模塊。其總體實現大體是這樣的:
// 用於初始化 OLED 的 C 函數
JSValue nativeInit(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
const int bInvert = JS_ToBool(ctx, argv[0]);
const int bFlip = JS_ToBool(ctx, argv[1]);
int iChannel = 1;
int iOLEDAddr = 0x3c;
int iOLEDType = OLED_128x64;
oledInit(iChannel, iOLEDAddr, iOLEDType, bFlip, bInvert);
oledFill(0);
return JS_NULL;
}
// 用於繪製像素的 C 函數
JSValue nativeDrawPixel(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
int x, y;
JS_ToInt32(ctx, &x, argv[0]);
JS_ToInt32(ctx, &y, argv[1]);
oledSetPixel(x, y, 1);
return JS_NULL;
}
// 定義 JS 側所需的函數名與參數長度信息
const JSCFunctionListEntry nativeFuncs[] = {
JS_CFUNC_DEF("init", 2, nativeInit),
JS_CFUNC_DEF("drawPixel", 2, nativeDrawPixel)};
// 其餘的一些膠水代碼
// ...
複製代碼
整個包含了 C 模塊的項目編譯步驟,若是手動執行則較爲複雜。所以咱們選擇引入 GNU Make 來表達整個構建流程。因爲是第一次寫 Makefile,這個過程對我有些困擾。不過搞懂原理後,它其實也沒那麼可怕。感興趣的同窗能夠本身查看後面開源倉庫地址中的實現噢。
只要上面的 C 模塊編譯成功,咱們就能用這種前端同窗們信手拈來的 JS 代碼,直接驅動這塊屏幕了:
// main.js
import { setTimeout } from 'os'
import { init, clear, drawText } from 'renderer'
const wait = timeout =>
new Promise(resolve => setTimeout(resolve, timeout))
;(async () => {
const invert = false
const flip = false
init(invert, flip)
clear()
drawText('Hello world!')
await wait(2000)
clear()
drawText('Again!')
await wait(2000)
clear()
})()
複製代碼
其實,不少樹莓派上著名的 Python 模塊,也都爲你作好了這一步。那爲何要用 JS 從新實現一遍呢?由於只有 JS 上纔有 Learn once, write anywhere 的 React 呀!讓咱們走出最後一步,將 React 與這塊液晶屏鏈接起來吧。
爲 React 實現渲染後端,聽起來是件很是高大上的事情。其實這玩意極可能並無你想象的那麼複雜,社區也有 Making a custom React renderer 這樣不錯的教程,來告訴你如何從零到一地實現本身的渲染器。不過對我來講,光有這份教程還有些不太夠。關鍵在於兩個地方:
這兩個問題裏,問題 2 已經在上面基本解決了:咱們手裏已經有了個用 JS 調一次就能畫些東西的原生模塊。那麼剩下的問題就是,該如何實現一個支持按需更新的 React 渲染後端呢?
我選擇的基本設計,是將整個應用分爲三個宏觀角色:
這些體系是如何協調工做的呢?簡單來講,當用戶事件觸發了 React 中的 setState 後,React 不只會更新自身的狀態樹,還會在原生狀態容器中作出修改和標記。這樣在 Main Loop 的下一幀到來時,咱們就能根據標記,按需地刷新屏幕狀態了。從事件流向的視角來看,總體架構就像這樣:
圖中的 Native State Container 能夠理解爲瀏覽器真實 DOM 這樣「不難直接寫 JS 操控,但不如交給 React 幫你管理」的狀態容器。只要配置正確,React 就會單向地去更新這個容器的狀態。而一旦容器狀態被更新,這個新狀態就會在下一幀被同步到屏幕上。這其實和經典的生產者 - 消費者模型頗爲相似。其中 React 是更新容器狀態的生產者,而屏幕則是定時檢查並消費容器狀態的消費者。聽起來應該不難吧?
實現原生狀態容器和 Main Loop,其實都是很容易的。最大的問題在於,咱們該如何配置好 React,讓它自動更新這個狀態容器呢?這就須要使用大名鼎鼎的 React Reconciler 了。要想實現一個 React 的 Renderer,其實只要在 Reconciler 的各個生命週期勾子裏,正確地更新原生狀態容器就好了。從層次結構的視角來看,總體架構則是這樣的:
能夠認爲,咱們想在 React 中拿來使用的 JS Renderer,更像是一層較薄的殼。它下面依次還有兩層重要的結構須要咱們實現:
React 所用的 Renderer 這層殼的實現,大體像這樣:
import Reconciler from 'react-reconciler'
import { NativeContainer } from './native-adapter.js'
const root = new NativeContainer()
const hostConfig = { /* ... */ }
const reconciler = Reconciler(hostConfig)
const container = reconciler.createContainer(root, false)
export const SSD1306Renderer = {
render (reactElement) {
return reconciler.updateContainer(reactElement, container)
}
}
複製代碼
其中咱們須要實現個 NativeContainer 容器。這個容器大概是這樣的:
// 導入 QuickJS 原生模塊
import { init, clear, drawText, drawPixel } from 'renderer'
// ...
export class NativeContainer {
constructor () {
this.elements = []
this.synced = true
// 清屏,並開始事件循環
init()
clear()
mainLoop(() => this.onFrameTick())
}
// 交給 React 調用的方法
appendElement (element) {
this.synced = false
this.elements.push(element)
}
// 交給 React 調用的方法
removeElement (element) {
this.synced = false
const i = this.elements.indexOf(element)
if (i !== -1) this.elements.splice(i, 1)
}
// 每幀執行,但僅當狀態更改時從新 render
onFrameTick () {
if (!this.synced) this.render()
this.synced = true
}
// 清屏後繪製各種元素
render () {
clear()
for (let i = 0; i < this.elements.length; i++) {
const element = this.elements[i]
if (element instanceof NativeTextElement) {
const { children, row, col } = element.props
drawText(children[0], row, col)
} else if (element instanceof NativePixelElement) {
drawPixel(element.props.x, element.props.y)
}
}
}
}
複製代碼
不難看出這個 NativeContainer 只要內部元素被更改,就會在下一幀調用 C 渲染模塊。那麼該如何讓 React 調用它的方法呢?這就須要上面的 hostConfig
配置了。這份配置中須要實現大量的 Reconciler API。對於咱們最簡單的初次渲染場景而言,包括這些:
appendInitialChild () {}
appendChildToContainer () {} // 關鍵
appendChild () {}
createInstance () {} // 關鍵
createTextInstance () {}
finalizeInitialChildren () {}
getPublicInstance () {}
now () {}
prepareForCommit () {}
prepareUpdate () {}
resetAfterCommit () {}
resetTextContent () {}
getRootHostContext () {} // 關鍵
getChildHostContext () {}
shouldSetTextContent () {}
useSyncScheduling: true
supportsMutation: true
複製代碼
這裏真正有意義的實現基本都在標記爲「關鍵」的項裏。例如,假設個人 NativeContainer 中具有 NativeText 和 NativePixel 兩種元素,那麼 createInstance
勾子裏就應該根據 React 組件的 type 來建立相應的元素實例,並在 appendChildToContainer
勾子裏將這些實例添加到 NativeContainer 中。具體實現至關簡單,能夠參考實際代碼。
建立以後,咱們還有更新和刪除元素的可能。這至少對應於這些 Reconciler API:
commitTextUpdate () {}
commitUpdate () {} // 關鍵
removeChildFromContainer () {} // 關鍵
複製代碼
它們的實現也是同理的。最後,咱們須要跟 Renderer 打包提供一些「內置組件」,就像這樣:
export const Text = 'TEXT'
export const Pixel = 'PIXEL'
// ...
export const SSD1306Renderer = {
render () { /* ... */ }
}
複製代碼
這樣咱們從 Reconciler 那裏拿到的組件 type 就能夠是這些常量,進而告知 NativeContainer 更新啦。
到此爲止,通過這所有的歷程後,咱們終於能用 React 直接控制屏幕了!這個 Renderer 實現後,基於它的代碼就至關簡單了:
import './polyfill.js'
import React from 'react'
import { SSD1306Renderer, Text, Pixel } from './renderer.js'
class App extends React.Component {
constructor () {
super()
this.state = { hello: 'Hello React!', p: 0 }
}
render () {
const { hello, p } = this.state
return (
<React.Fragment>
<Text row={0} col={0}>{hello}</Text>
<Text row={1} col={0}>Hello QuickJS!</Text>
<Pixel x={p} y={p} />
</React.Fragment>
)
}
componentDidMount () {
// XXX: 模擬事件驅動更新
setTimeout(() => this.setState({ hello: 'Hello Pi!', p: 42 }), 2000)
setTimeout(() => this.setState({ hello: '', p: -1 }), 4000)
}
}
SSD1306Renderer.render(<App />)
複製代碼
渲染結果是這樣的:
別看顯示效果彷佛貌不驚人,這幾行文字的出現,標準着 JSX、組件生命週期勾子和潛在的 Hooks / Redux 等現代的前端技術,終於都能直通嵌入式硬件啦——將 React、QuickJS、樹莓派和液晶屏鏈接起來的嘗試,到此也算是能告一段落了。拜 QuickJS 所賜,最終包括 JS 引擎和 React 全家桶在內的整個二進制可執行文件體積,只有 780K 左右。
上面涉及的整個項目代碼示例,都在公開的 react-ssd1306 倉庫中(若是你以爲有意思,來個 star 吧)。再附上些過程當中較有幫助的參考連接:
若是你堅持到了這裏,那真是辛苦你啦~這篇文章的篇幅至關長,涉及的關鍵點也可能比較分散——重點究竟是如何使用 QuickJS、如何編寫 C 擴展,仍是如何定製 React Reconciler 呢?彷佛都很重要啊(笑)。不過這個過程折騰下來,確實給了我不少收穫。許多之前只是據說過,或者以爲很是高大上的概念,本身動手作過以後才發現並無那麼高不可攀。其餘的一些感想大概還有:
其實從我平常切圖的時候起,我就喜歡弄些「對業務沒什麼直接價值」的東西,好比:
此次的 react-ssd1306 項目裏,驅使個人動力和造這些輪子時也是類似的。爲何很差好寫業務邏輯,非要搞這些「沒有意義」的事呢?
Because we can.
我主要是個前端開發者。若是你對 Web 結構化數據編輯、WebGL 渲染、Hybrid 應用開發,或者計算機愛好者的碎碎念感興趣,歡迎關注我,或者個人公衆號
color-album
噢 :)