一文吃透 React DSL 應(yīng)用并從零到一實現(xiàn)
一 前言
React 是一款非常受前端開發(fā)者青睞的 UI 框架, 它不僅可以應(yīng)用到傳統(tǒng) web 領(lǐng)域,還可以應(yīng)用客戶端應(yīng)用,小程序應(yīng)用,甚至是桌面應(yīng)用。
可以說前端領(lǐng)域半片天都能看到 React 的影子,React 如此受歡迎的原因有很多,比如靈活的 JSX 語法,函數(shù)式編程思想,以及應(yīng)用多種設(shè)計模式等。正是因為 React 的這些優(yōu)點,近些年也有了“殊的 React ”特應(yīng)用。這些應(yīng)用給 React 開發(fā)者更大的發(fā)揮空間。
這些應(yīng)用長的像 React ,但又不是真正的 React, 它們和 React 有相同的語法,可以跑在 web 端,也可以跑在 Native 端,甚至桌面端。基于編譯時和運行時,它們可以在多端中相互轉(zhuǎn)化,實現(xiàn)一碼多端,一定程度上節(jié)約了開發(fā)成本,提高了開發(fā)效率。
這個應(yīng)用我們叫它基于 React 語法的 DSL 應(yīng)用,也就是我們今天討論的主題,那么接下來我們就揭開 React DSL 的面紗。
二 React DSL 背景介紹
2.1 什么是 DSL
在正式介紹 React DSL 之前,先來看一下 DSL 的概念:英語:domain-specific language)簡稱 DSL,比如 SQL, JSON 等。
DSL 分為內(nèi)部 DSL 和外部 DSL 。
-
使用獨立的解釋器或編譯器實現(xiàn)的 DSL 被稱為外部 DSL。 外部 DSL 的優(yōu)點在于它是獨立于程序開發(fā)語言的。對某個特定領(lǐng)域進(jìn)行操作的程序不一定是使用同一種語言來寫的。SQL 就是一種 DSL,學(xué)會了 SQL 就可以在不同的語言中使用相同的 SQL 來操作數(shù)據(jù)庫。
-
內(nèi)部 DSL。(則是在一個宿主語言(host language)中實現(xiàn),它一般以庫的形式實現(xiàn),并傾向于使用宿主語言中的語法。內(nèi)部 DSL 的優(yōu)點和缺點與外部 DSL 相反,它借助了宿主語言的語法,程序員無需學(xué)習(xí)一種新的語言。但是它被限制在宿主語言能夠描述的范圍內(nèi),因此自由度較低。
React DSL 是內(nèi)部的 DSL,它運行在 JavaScript 引擎中,以 React JSX 和基礎(chǔ) api 為語法。JSX 能夠形象的表示出視圖層的結(jié)構(gòu),有這數(shù)據(jù)層 state,以及改變 state 的方法 setState 和 useState 等。它們和 React API 保持一致,也可能是閹割版或者是加強版。
明白了 DSL 之后,我們來看一下 React DSL 的本質(zhì)。首先看一下 React 框架的本質(zhì)。
2.2 React 框架本質(zhì)
對于 DSL 框架的理解,從運行時和編譯時角度分析會更加清晰。如下圖所示:
編譯時:我們描述一下整個流程,首先通過 React cli 可以是 react-create-app,來編譯解析 jsx ,scss/less 等文件,jsx 語法會變成 React.createElement 的形式。最終形成 html,css,js 等瀏覽器能夠識別的文件。
運行時:接下來當(dāng)瀏覽器打開應(yīng)用的時候,會加載這些文件,然后 js 會通過 React 運行時提供的 API 變成 fiber 樹結(jié)構(gòu),接下來就會形成 DOM 樹,然后瀏覽器用 html 作為載體,加入 css 樹和 DOM 樹,形成渲染樹,這樣視圖就呈現(xiàn)了。
2.3 React DSL 本質(zhì)
React DSL 本質(zhì)也非常好理解。本質(zhì)上也分為編譯時和運行時兩種。
基于編譯時的 DSL:
基于編譯時的 React DSL 框架,長的和 React ,但是本質(zhì)完全不相同,因為在編譯階段已經(jīng)轉(zhuǎn)化成其他產(chǎn)物了,比如小程序。
其原理就是通過 parse 將 JSX css 等文件轉(zhuǎn)成不同的 AST (抽象語法樹),然后就可以用不同的 transformer 生成不同的產(chǎn)物:
-
想轉(zhuǎn)化成小程序,那么用小程序的 transformer 轉(zhuǎn)化,產(chǎn)物是 wxml ,wxss, js 和 json。 -
想轉(zhuǎn)化成 web 應(yīng)用,那么可以通過 web 的 transformer 轉(zhuǎn)化,產(chǎn)物為 css,html,和 js 三件套。
舉個例子,比如在 React DSL 中這么寫到:
class Home extends MyReact.Component{
handleClick(){
Router.push('xxx')
}
render(){
return <View>
<View onClick={ this.handleClick } >點擊</View>
</View>
}
}
那么可以通過編譯的方式轉(zhuǎn)化成微信小程序,如下所示:
<view>
<view bind:tap="handleClick" >點擊</view>
</view>
Page({
handleClick(){
wx.navigateTo({ url:'xxx' })
},
})
接下來我們看一下基于運行時 React DSL 本質(zhì):
在編譯時,可以通過 React DSL 腳手架工具,將 JSX 轉(zhuǎn)化成 createElement 形式。最終的產(chǎn)物可以理解成一個 JS 文件,可以稱之為 JSBundle 。
重點來了,在運行時,我們分別從 web應(yīng)用 和 Native應(yīng)用 兩個角度來解析流程:
-
如果是 React DSL web 應(yīng)用,那么可以通過瀏覽器加載 JSBundle ,然后通過運行時的 api 將頁面結(jié)構(gòu),轉(zhuǎn)化成虛擬 DOM , 虛擬 DOM 再轉(zhuǎn)化成真實 DOM, 然后瀏覽器可以渲染真實 DOM 。
-
如果是 React DSL Native 應(yīng)用,那么 Native 會通過一個 JS 引擎來運行 JSBundle ,然后同樣通過運行時的 API 轉(zhuǎn)化成虛擬 DOM, 接下來因為 Native 應(yīng)用,所以不能直接轉(zhuǎn)化的 DOM, 這個時候可以生成一些繪制指令,可以通過橋的方式,把指令傳遞給 Native 端,Native 端接收到指令之后,就可以繪制頁面了。這樣的好處就可以動態(tài)上傳 bundle ,來實現(xiàn)動態(tài)化更新。
接下來,我們來從零到一實現(xiàn)一個類似 Native 端的 React DSL 方案。因為純前端實現(xiàn),所以這里的 Native 端也用前端的 html 文件來模擬了。
三 實現(xiàn)一個跨端 React DSL 運行時應(yīng)用
下面用一個非常簡單案例,來用前端的方式模擬 React DSL Native 渲染流程。
如上:
-
index.html 為視圖層, 這里用視圖層模擬代替了 Native 應(yīng)用。 -
bridge 為 JS 層和 Native 層的代碼。 -
service.js 為我們寫在 js 業(yè)務(wù)層的代碼。
核心流程如下:
-
本質(zhì)上 service.js 運行在 Native 的 JS 引擎中,形成虛擬 DOM ,和繪制指令。 -
繪制指令可以通過 bridge 傳遞給 Native 端 (案例中的 html 和 js ),然后渲染視圖。 -
當(dāng)觸發(fā)更新時候,Native 端響應(yīng)事件,然后把事件通過橋方式傳遞給 service.js, 接下來 service.js 處理邏輯,發(fā)生 diff 更新,產(chǎn)生新的繪制指令,通知給 Native 渲染視圖。
因為這個案例是用 web 應(yīng)用模擬的 Native ,所以實現(xiàn)細(xì)節(jié)和真實場景有所不同,盡請諒解,本案例主要讓讀者更清晰了解渲染流程。
選今天的主角 React 語法作為 DSL ,簡單描述一下完整的流程。比如我們在 React DSL 應(yīng)用中,寫如下代碼:
class Home extends Component{
state={
show:true
}
handleClick(){
this.setState({ show:false })
}
render(){
const { show } = this.state
return <view style="height:400px;width:300px;border:1px solid #ccc;" >
<view style="height:100px;width:300px;background:blue" >小冊名:大前端跨端開發(fā)指南</view>
{ show && <view style="height:100px;width:300px;background:pink" >作者:我不是外星人</view> }
<button onClick={handleClick} >刪除作者</button>
</view>
}
}
這段代碼描述了一個視圖結(jié)構(gòu),有一個點擊方法,當(dāng)觸發(fā)點擊方法的時候,改變 show 狀態(tài),這個時候可以把 <view>作者:我不是外星人</view> 刪除。
這段代碼首先會被編譯,jsx 語法變成 createNode 形式。如下:
class Home extends Component{
state={
show:true
}
handleClick(){
this.setState({ show:false })
}
render(){
const { show } = this.state
return createNode('view',{ style:'height:400px;width:300px;border:1px solid #ccc;' }, [
createNode('view',{ style:'height:100px;width:300px;background:blue' }, '小冊名:大前端跨端開發(fā)指南'),
show && createNode('view',{ style:'height:100px;width:300px;background:pink' }, '作者:我不是外星人'),
createNode('button',{ onClick: this.handleClick }, '刪除作者')
] )
}
}
在 React 中,用 createElment 來描述視圖結(jié)構(gòu),但是本次實現(xiàn)的是以 React 語法做 DSL 應(yīng)用,本質(zhì)上并不是 React,(語法一樣,但是實現(xiàn)完全不同)所以這里我們直接用 createNode 來代替。createNode 實現(xiàn)非常簡單如下:
function createNode(tag,props,children){
const node = {
tag,
props,
children,
}
return node
}
createNode 會創(chuàng)建一個虛擬 DOM 節(jié)點,其中包括 tag,props 和 children 三個屬性,這樣視圖結(jié)構(gòu)就變成了如下的結(jié)構(gòu):
{
tag: 'view',
props: { style },
children: [
{
tag: 'view',
props: { style },
children: '小冊名:大前端跨端開發(fā)指南',
},
{
tag: 'view',
props:{ style },
children: '作者:我不是外星人',
},
{
tag: 'button',
props: { onClick },
children: '刪除作者',
}
],
}
初始化流程:
在初始化的時候,Native 開始加載 JS bundle,加載完 JS bundle ,同時通過橋向 JS 通信,開始運行加載 service.js 。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root" ></div>
</body>
<script src="./bridge.js" ></script>
<script>
/* 指令解析器 */
function handleDirect(directList){
//...
}
/* 監(jiān)聽 JS (service.js)向 Native (index.html,index.js) 通信 */
window.port2.onmessage = function(event) {
//...
}
/* 模擬加載 bundle 流程 */
const script = document.createElement('script')
script.src = './service.js'
script.onload = function(){
/* 初始化邏輯層 */
NativeToJs({ type:'init' })
}
document.body.appendChild(script)
</script>
</html>
因為涉及到橋通信,我們這里用 postMessage 來模擬了Native<->JS 雙向通信流程。代碼如下:
const { port1, port2 } = new MessageChannel();
window.port1 = port1
window.port2 = port2
/* JS 向 Native 通信 */
function JsToNative(payload){
console.warn('JS -> Native ')
port1.postMessage(payload)
}
/* Native 向 JS 通信 */
function NativeToJs(payload){
console.warn('Native -> JS ')
port2.postMessage(payload)
}
MessageChannel 會產(chǎn)生兩個端口,port1 和 port2,可以通過兩個端口的 postMessage 和 onmessage 方法來實現(xiàn)雙向的通信。注意:真實場景下,是通過 JSbridge 來實現(xiàn)的,在 React Native 通信章節(jié),會介紹相關(guān)通信機(jī)制和背后運行的原理,這里只是用 MessageChannel 來模擬效果。
如上在 bundle 加載之后,會調(diào)用 NativeToJs 方法來完成 JS 層的初始化流程。在 service.js 中,我們模擬一下對 Native
let workInProgress
/* 監(jiān)聽 Native (index.html) 向 JS (service.js) 通信 */
window.port1.onmessage = function(event){
const { type,nodeId } = event.data
/* 初始化邏輯層 */
if(type === 'init'){
workInProgress = renderInstance()
/* 發(fā)生點擊事件 */
}else if(type === 'click') {
console.log(nodeId)
const event = eventMap.get(nodeId)
event && event.call(workInProgress)
}
}
這里主要監(jiān)聽兩種事件:
-
第一種就是 Native 通信 JS 層完成初始化,初始化完成,渲染視圖。 -
第二種就是 Native 發(fā)生事件,觸發(fā) JS 對應(yīng)的回調(diào)函數(shù)。
剛剛在模擬 bundle 初始化的過程中,最終調(diào)用的是 NativeToJs({ type:'init' }) 方法。那么就會走到 renderInstance 邏輯中。
/* 應(yīng)用初始化 */
function renderInstance(){
/* 初始化-渲染形成元素節(jié)點 */
const instance = new Home()
const newVode = instance.render()
instance.vnode = diff(newVode,null,'root')
/* 發(fā)送繪制指令 */
JsToNative({ type:'render' ,data: JSON.stringify(directList) })
return instance
}
這個邏輯非常重要,主要可以分成三部分:
-
第一部分:就是實例化上面寫的 Home 組件,然后調(diào)用 render 函數(shù)生成虛擬 DOM 結(jié)構(gòu)。 -
第二部分: 調(diào)用 diff 來對比新老節(jié)點,如果初始化的時候,是沒有老元素的,所以 diff 的第二個參數(shù)為 null。在 diff 期間,會收集各種渲染指令,有了這些渲染指令,既可以在 Native 端渲染,也可以在 web 渲染,這樣就可以輕松的實現(xiàn)跨端。這里為了做新來元素的 diff ,通過 vnode 屬性來保存了最新構(gòu)建的虛擬 DOM 樹。 -
第三部分:就是通過橋的方式,來把指令信息傳遞過去,在傳遞過程中,因為只能傳遞字符串,所以這里用 stringify 來序列化生成的指令。
來看一下核心 diff 的實現(xiàn):
let directList = []
const CREATE = 'CREATE' /* 創(chuàng)建 */
const UPDATE = 'UPDATE' /* 更新 */
const DELETE = 'DELETE' /* 刪除 */
let nodeId = -1
const eventMap = new Map()
function diffChild(newVNode,oldVNode,parentId){
const newChildren = newVNode?.children
const oldChildren = oldVNode?.children
if(Array.isArray(newChildren)){
newChildren.forEach((newChildrenNode,index)=>{
const oldChildrenNode = oldChildren ? oldChildren[index] : null
diff(newChildrenNode,oldChildrenNode,parentId)
})
}
}
/* 對比獲取渲染指令 */
function diff(newVNode,oldVNode,parentId){
/* 新增元素 */
if(newVNode && !oldVNode){
newVNode.nodeId = ++nodeId
newVNode.parentId = parentId
let content = ''
/* 如果存在點擊事件,那么映射dui */
if(newVNode?.props?.onClick){
const onClick = newVNode.props.onClick
eventMap.set(nodeId,onClick)
newVNode.props.onClick = onClick.name // handleClick
}
if(Array.isArray(newVNode.children)){
diffChild(newVNode,null,nodeId)
}else {
content = newVNode.children
}
/* 創(chuàng)建渲染指令 */
const direct = {
type:CREATE,
tag:newVNode.tag,
parentId,
nodeId:newVNode.nodeId,
content,
props:newVNode.props
}
directList.push(direct)
/* 刪除元素 */
}else if(!newVNode && oldVNode) {
/* 創(chuàng)建刪除指令 */
const direct = {
type:DELETE,
tag:oldVNode.tag,
parentId,
nodeId:oldVNode.nodeId,
}
directList.push(direct)
}else {
/* 更新元素 */
newVNode.nodeId = oldVNode.nodeId
newVNode.parentId = oldVNode.parentId
/* 只有文本發(fā)生變化的時候,才算元素發(fā)生了更新 */
if(typeof newVNode.children === 'string' && newVNode.children !== oldVNode.children){
/* 創(chuàng)建更新指令 */
const direct = {
type:UPDATE,
parentId,
nodeId:oldVNode.nodeId,
content: newVNode.children,
props:newVNode.props
}
directList.push(direct)
}else{
diffChild(newVNode,oldVNode,newVNode.nodeId)
}
}
return newVNode
}
如上就是整個 diff 流程,在 diff 過程中,會判斷新老節(jié)點,來收集不同的指令,在 React 是通過 render 階段,來給 fiber 打不同的 flag 。
-
在 diff 過程中,會通過 nodeId 和 parentId 來記錄當(dāng)前元素節(jié)點的唯一性和當(dāng)前元素的父元素是哪個。 -
如果有新元素,沒有老元素,那么證明元素創(chuàng)建,會收集 create 指令,在這期間會特殊處理一下函數(shù),把函數(shù)通過 eventMap 來保存。 -
如果沒有新元素,只有老元素,證明元素是刪除,會收集 delete 指令,讓 Native 去刪除元素。 -
如果新老元素都存在,那么證明有可能發(fā)生了更新,這里做了偷懶,判定只有文本內(nèi)容更新的時候,才觸發(fā)更新. -
接下就通過 diffChild 來遞歸元素節(jié)點,完成整個 DOM 樹的遍歷。
如果 Home 組件經(jīng)過如上流程之后,會產(chǎn)生如下的繪制指令:
有了這些指令之后,接下來就會把消息傳遞給 Native 端(index.html),那么 Native 端同樣要監(jiān)聽來自 JS 端的消息。
/* 監(jiān)聽 JS (service.js)向 Native (index.html) 通信 */
window.port2.onmessage = function(event) {
const { type,data } = event.data
if(type === 'render'){
const directList = JSON.parse(data)
/* 處理繪制指令 */
handleDirect(directList)
}
}
如上當(dāng)接受到渲染指令 render 的時候,會調(diào)用 handleDirect 來完成頁面的繪制。
function handleDirect(directList){
console.log(directList)
directList.sort((a,b)=> a.nodeId - b.nodeId ).forEach(item=>{
const { content , nodeId, parentId, props, type, tag } = item
/* 插入節(jié)點 */
if(type ==='CREATE'){
let curtag = 'div'
switch(tag){
case 'view':
curtag = 'div'
break
default:
curtag = tag
break
}
const node = document.createElement(curtag)
node.id = 'node' + nodeId
if(content) node.innerText = content
/* 處理點擊事件 */
if(props.style) node.style = props.style
if(props.onClick) {
node.onclick = function(){
/* 向 js 層發(fā)送事件 */
NativeToJs({ type:'click', nodeId })
}
}
if(parentId === 'root'){
const root = document.getElementById('root')
root.appendChild(node)
}else{
const parentNode = document.getElementById('node'+ parentId)
parentNode && parentNode.appendChild(node)
}
}else if(type === 'DELETE'){
/* 刪除節(jié)點 */
const parentNode = document.getElementById('node'+ parentId)
const node = document.getElementById('node'+ nodeId)
parentNode.removeChild(node)
}
})
}
這里用前端的方式,來模擬了整個繪制流程,具體內(nèi)容包括:事件的處理,元素的處理,屬性的處理等等。
其中有一個細(xì)節(jié),就是如果發(fā)現(xiàn)指令中有綁定事件的時候,就會給元素綁定一個事件函數(shù),當(dāng)發(fā)生點擊的時候,觸發(fā)函數(shù),向 JS 層發(fā)送信息。
來看一下最終樣子。
可以看到 Native -> JS , JS -> Native 兩次通信,完成了初始化流程,視圖也渲染了。接下來就是發(fā)生點擊觸發(fā)更新的流程。
更新流程
觸發(fā)點擊事件,Native 首先響應(yīng),然后向 JS 層發(fā)送事件,通過傳遞 NodeId:
/* 向 js 層發(fā)送事件 */
NativeToJs({ type:'click', nodeId })
JS 層接受到事件,執(zhí)行對應(yīng)的事件:
else if(type === 'click') {
const event = eventMap.get(nodeId)
event && event.call(workInProgress)
}
JS 可以通過唯一標(biāo)志 nodeId 來找到對應(yīng)的函數(shù),然后執(zhí)行函數(shù),在函數(shù)中會觸發(fā) setState, 改變 show 的狀態(tài),然后讓 view 卸載:
handleClick(){
this.setState({ show:false })
}
因為我們寫的是 DSL ,并非真正的 React ,所以對于 Component 和 setStata 需要手動去實現(xiàn),原理如下:
/* Component 構(gòu)造函數(shù) */
function Component (){
this.setState = setState
}
/* 觸發(fā)更新 */
function setState (state){
/* 合并 state */
Object.assign(this.state,state)
directList = []
const newVode = this.render()
this.vnode = diff(newVode,this.vnode,'root')
/* 發(fā)送繪制指令 */
JsToNative({ type:'render' ,data: JSON.stringify(directList) })
}
Component 很簡單,就是給實例上綁定 this.setState 方法。handleClick 中會觸發(fā) setState 方法,其內(nèi)部會合并 state ,然后重制指令,接下來重新調(diào)用 render 形成新 node, 和老 node 進(jìn)行對比,對比哪些發(fā)生變化,會重新生成繪制指令。
當(dāng)觸發(fā) show = false 時候,會觸發(fā) delete 指令,銷毀元素。指令如下:
整體流程如下:
四 總結(jié)
本文介紹了 React DSL 的本質(zhì)和原理,并且從零到一寫了一個跨端的 React DSL 應(yīng)用,覺得有幫助的朋友可以點贊+收藏一波,鼓勵我繼續(xù)創(chuàng)作前端硬文。
可以關(guān)注一下筆者的公眾號:前端Sharing, 持續(xù)分享前端好文。
跨端小冊
想要學(xué)習(xí)更多跨端知識,這里推薦一本跨端小冊:
適合的人群如下:
-
想要系統(tǒng)學(xué)習(xí)移動端跨端開發(fā)的同學(xué); -
想要深入了解跨端實現(xiàn)原理的同學(xué); -
不甘心于現(xiàn)狀,想要進(jìn)階大前端的同學(xué); -
想要跳槽,攻克跨端技術(shù)面試知識點的同學(xué)。
為了感謝大家對我的信任,弄了幾個五折碼 l527XH2x 奉上,先到先得。
