一、項目背景
隨著小程序在用戶規模和商業化上取得的極大成功,各大平臺都推出了自己的小程序。然而這些平臺的小程序開發在語法上又不盡相同,不同平臺小程序代碼的維護需要投入很大的精力,在邏輯性上也很難達到統一的效果。雖然也有各種轉換工具可以基于某一個平臺轉換出其他平臺的代碼,但轉換的效果也是差強人意,往往還需要人工去修改。使用小程序跨端開發框架來實現一次開發、到處運行以提升效率,已經成為開發者強烈而迫切的需求。
目前,小程序跨端開發框架主要可以按照技術棧和實現原理兩個維度進行分類。從技術棧來說,主流的跨端框架基本遵循 React、Vue 這兩個前端開發最常使用的框架。由于所在團隊主要使用的是React,所以本文主要介紹采用React語法的框架。從實現原理上,開源社區的跨端框架可分為編譯時(compile time)和運行時(runtime)。
主流框架及其特點介紹如下表1-1所示:
表1-1 React語法小程序跨端框架舉例
框架 |
廠家 |
特征 |
Kbone |
騰訊 |
不限技術棧,微信小程序和 Web 端同構的運行時解決方案,模擬了一套dom和bom接口,用以兼容現有的前端體系,只能用于Web兼容微信小程序,無法滿足其他平臺小程序的開發 |
Taro1/2 |
京東 |
類React,靜態編譯型框架僅在開發時遵循React語法,編譯后運行時與React無關 |
Nanachi |
去哪兒 |
React,靜態編譯型框架 |
Rax |
阿里巴巴 |
以運行時方案為基礎,支持局部場景使用編譯時方案。運行時的支持基于Kbone,使用的是類React語法的Rax框架 |
Remax |
螞蟻金服 |
使用原生React來構建小程序,運行時框架,從Remax2.0開始支持Web應用的構建 |
Taro3 |
京東 |
不限技術棧,使用一套runtime層來兼容各種DSL,誕生于Remax之后 |
compile time編譯時的跨端框架,主要的工作量在編譯階段。框架會把用戶寫的業務代碼解析成 AST 樹,然后通過語法分析將用戶寫的原始代碼轉換成符合小程序規則的代碼。運行時模式的跨端框架通過適配層,實現自定義渲染器,是真正的在小程序的邏輯層中運行起React或Vue框架的方式,這種方式比靜態編譯有天然的優勢。
編譯時主要存在以下問題:靈活的JSX語法既可以寫出非常復雜靈活的組件,同時也增加了編譯階段框架去分析和優化的難度。這就導致適配工作量巨大,維護成本較高,即使這樣,也無法適配所有的寫法。例如京東的Taro 1/2 用窮舉的方式對 JSX 可能的寫法進行了一一適配,但依然需要開發者遵循大量的語法約束,避免很多動態寫法。否則代碼就不能正常編譯運行,開發效率難以保證。此外,由于 DOM 和 BOM API 的缺失,Web 上累積的各種前端生態,基本無法在編譯時小程序中復用。京東的 Taro 1/2 , 去哪兒的 Nanachi都屬于靜態編譯型的React或類React跨端框架。
與之相比,運行時方案的優勢就在于能直接復用現有的前端生態。拿Remax來說,它最大的優勢是可以幾乎沒有限制的使用React的語法完成代碼編寫,正如它的口號一樣——使用真正React來構建跨平臺小程序。此外,從Remax2.0開始,remax/one支持Web應用的構建。
我們團隊做選型的時候Taro3還是待發布狀態,所以沒有做太多的考慮,下面著重去比較一下Rax和Remax。Rax和Remax都是出自阿里系但兩個框架從設計思路上完全不同。
Remax從誕生之初就是為了支持小程序跨端框架Rax為了能盡量壓縮React體積對React進行了重寫,又引入了Driver機制來適配多端,這就意味著Rax有額外的學習成本,并且Rax不能隨著React的迭代而更新。雖然Rax看似比較完善,提供了一套開箱即用的跨端API和完整的跨端UI控件支持,但它過多的依賴阿里的構建體系,似乎不太適合做為開源框架的選擇。
綜合以上幾點考慮最終我們選擇了Remax。
二、效果展示
圖2-1到2-3分別為首頁,列表頁和詳情頁在Web和微信小程序上的運行效果,實現了一套代碼多端運行,同時能做到同步更新。
|
|
圖2-1 攜程船票首頁web和微信小程序運行結果
|
|
圖2-2 攜程船票列表頁web和微信小程序運行結果
|
|
圖2-3 攜程船票詳情頁web和微信小程序運行結果
下面介紹一下Remax的基本用法。
三、基本用法
3.1 創建/安裝依賴/運行
使用create-remax-App創建的小程序
npxcreate-remax-app my-app
? name my-app //填寫你的appName
? author test<test@test.c>
? descriptionRemax Project //填寫項目描述
? platform
? 跨平臺小程序
微信小程序
阿里(支付寶)小程序
頭條小程序
百度小程序
因為我們介紹的是跨端小程序,這里選擇跨平臺小程序。
運行項目
cd my-app&& npm install
npm run dev web//web端預覽
npm run devwechat //微信小程序端
小程序端運行后會在項目目錄里生成 dist/wechat、dist/ali各個小程序的產物,用小程序的IDE導入對應的目錄即可預覽。
項目的目錄結構大致如下:
其中public目錄在編譯時會被復制到dist目錄下,里面的原生頁面pages目錄也會和Remax的pages目錄合并,這一部分后面會詳細介紹。
3.2 Remax的跨平臺機制
以下詳細介紹Remax的跨平臺機制
以上就是一個頁面的基本文件目錄結構,但是我們翻一遍Remax的文檔就會發現它提供的跨平臺的接口是少之又少,只有9個組件以及5個路由相關的API。所以Remax又提供了一種用文件名后綴來區分不同平臺代碼的方法:
如上面的目錄所示,在頁面的目錄里增加相應后綴的文件在build相應平臺的時候就會優先使用對應后綴的文件。
Remax還提供了環境變量區分平臺的機制,在代碼中直接通過
process.env.REMAX_PLATFORM區分平臺。例如:
if (process.env.REMAX_PLATFORM==='wechat') {}
以上代碼在編譯后只會保留對應平臺的部分,所以無需擔心兼容多平臺帶來額外的代碼size增加。
除了上面說到的9個跨平臺的組件和5個跨平臺的API外,Remax還可以直接使用各個平臺的組件及API,無需使用useComponents聲明。
import React from'react';
import { View } from'remax/one';
import NativeCard from'./native-card';//native-card 是一個原生自定義組件
// 組件
exportdefault () => (
<View>
<NativeCard />
</View>
);
// API
if (process.env.REMAX_PLATFORM==='ali') {
systemInfo = my.getSysyemInfoSync();
} elseif (process.env.REMAX_PLATFORM==='wechat') {
systemInfo = my.getSysyemInfoSync();
}
根據以上機制我們就可以自定義出任何我們需要的跨平臺API及組件。
按照Remax的說法之所以不提供更多的跨平臺組件和API是因為他們在設計之初沒有標準去抹平各個平臺的差異。這當然給使用框架的開發者帶來了一些麻煩,許多的組件和API不能開箱即用,需要額外的去做一層封裝。但這也是Remax這個框架的優勢,只保留了核心的組件及API使得它本身不占太多的size,也使得要兼容一個新的平臺變得非常容易。
四、實踐干貨
4.1混合原生
之前我們說到了public目錄里的原生代碼會拷貝到dist目錄并且pages目錄會合并,我們可以利用這一機制最大程度的復用現有小程序的代碼,這也是我們團隊選擇Remax這個框架的原因之一。
讓我們來體驗一下,先創建一個原生小程序,以微信小程序為例:
小程序的文件目錄大致如下
現在把整個小程序的代碼放入public目錄
此時npm run dev wechat就會把pages以及utils拷貝到dist目錄下并且pages目錄合并了。
雖然合并了,但是我們觀察一下發現原生小程序的app.json的內容不見了,難道要在Remax里面把app.json都寫一遍么?帶著疑問我們仔細翻看Remax的文檔,發現remax.config.js里是可以配置動態生成app.json的配置的:
onAppConfig() {
...
// 獲取原始小程序配置originConfig和 remax app.config.js配置tmp
// 做合并處理
const appJSON =JSON.parse(JSON.stringify(originConfig));
tmp.pages.forEach(function (item) {
if (appJSON.pages.indexOf(item) ==-1) {
appJSON.pages.push(item);
}
});
tmp.subPackages.forEach(function (item) {
let needAdd =true;
for (let i =0, a = appJSON.subPackages; i < a.length; i++) {
if (a[i].root=== item.root) {
needAdd =false; a[i] = item;break;
}
}
if (needAdd) {
appJSON.subPackages.push(item);
}
});
...
return appJSON;
}
經過以上處理,原小程序的app.json內容就和remax.config.js內容合并了,以上代碼只處理了pages和subPackages,如果覺得還有什么需要合并的也可以在這里處理。此時,build生成的產物app.json,就保留了原小程序的內容并且合并了Remax小程序的內容。
產物里的app.js也沒有原生小程序里的代碼,那原有的邏輯怎么辦呢。重新在Remax寫一遍嗎?大可不必。
我們可以自定義一個運行時插件:
function createBothFun(remaxConfig, originConfig, key) {
const remaxFun = remaxConfig[key];
const originFun = originConfig[key];
return function () {
// 這里的this就是微信的app 里的this
remaxFun.apply(this,arguments);
originFun.apply(this,arguments);
};
}
const lifeCycles=['onLaunch','onShow','onHide','onPageNotFound','onError']
function tryMergeConfig(remaxConfig, originConfig) {
for (const key in originConfig) {
if (key ==='constructor') {
console.log('xxx');
} elseif (lifeCycles.indexOf(key) >=0) {
remaxConfig[key] =createBothFun(remaxConfig, originConfig, key);
} else {
remaxConfig[key] = originConfig[key];
}
}
}
const mergeConfig = (remaxConfig, originConfig) => {
tryMergeConfig(remaxConfig, originConfig);
return remaxConfig;
};
export default {
onAppConfig({ config }) {
let __app = App;
let originConfig;
App =function (origin) {
originConfig = origin;
};
__non_webpack_require__('./app-origin.js');
App = __app;
//merge config
config =mergeConfig(config, originConfig);
const onLaunch = config.onLaunch;
config.onLaunch= (...args) => {
if (onLaunch) {
onLaunch.apply(config, args);
}
};
return config;
},
};
把原來的app.js重命名為app-origin.js,然后在onAppConfig函數中使用__non_webpack_require__('./app-origin.js'); 請注意,這里的相對路徑是產物里的相對路徑,經過以上一番操作后,我們的原小程序就可以真正和Remax混合運行了。
但是這樣一來,我們的Remax似乎變得不能跨端了,因為它只能編譯成你public目錄里放置的原生小程序類型。
難道混合和跨端只能是魚和熊掌不可兼得?后面我們將介紹使用工程化的方法來實現魚和熊掌兼得。
4.2 Modal API化
你可能注意到了Remax的文檔里面列舉了10個控件,但我說它只有9個控件,官方的文檔上也說只有9個控件,為什么呢?因為Modal嚴格的說來不能算是一個控件。
Modal實際上是調用createPortal這個API來創建一個節點覆蓋在其它內容之上,在web端是使用的ReactDOM的createPortal,在小程序端是使用的@remax/runtime這個包里提供的同名方法。實際上portal在兩端掛載的位置也不太一樣,在Web端是直接的body上創建了一個新的div,而在小程序上則是掛在頁面實例一個叫modalContainer的節點。在實際使用中,使用Modal組件去顯示彈窗非常不方便,所以還是得把它變成API調用的方式。
以小程序端為例:
import...
import { createPortal } from'@remax/runtime';
import {ReactReconcilerInst } from'@remax/runtime/esm/render';
let createPortal__ = createPortal;
let ReactReconcilerInst__ = ReactReconcilerInst;
const styles = {
modalContainer: {
...
},
};
export default function withModal(TargetComponent) {
const WrappedModalComponent = (props) => {
const { mask,...other } = props;
const component =useRef();
const container =getCurrentPage().modalContainer;
return createPortal__(
<View style={{ ...styles.modalContainer,pointerEvents: mask ?'auto':'none' }}>
<TargetComponent {...other} mask={mask} show={show} close={close} ref={component} />
</View>,
container
)
};
WrappedModalComponent.hide= (conext) => {
const container =getCurrentPage().modalContainer;
if (container._rootContainer) {
ReactReconcilerInst__.updateContainer(null, container._rootContainer,null,function () {
});
}
container.applyUpdate();
};
WrappedModalComponent.show= (props) => {
const container =getCurrentPage().modalContainer;
if (!container._rootContainer) {
container._rootContainer=ReactReconcilerInst__.createContainer(container,false,false);
}
const element = React.createElement(WrappedModalComponent, props);
ReactReconcilerInst__.updateContainer(element, container._rootContainer,null,function () {
});
context.modalContainer.applyUpdate();
};
return WrappedModalComponent;
}
export { withModal };
使用示例:
//在需要彈窗的組件上使用withModal裝飾器
@withModal
export default MyComponent(props) {
...
return<View>{...}</View>
}
//在不支持裝飾器的模式下也可以直接調用
function MyComponent(props) {
...
return<View>{...}</View>
}
const ModaledComponent =withModal(MyComponent)
//在需要使用彈窗的地方使用
ModaledComponent.show(props);//展示彈窗
ModaledComponent.hide();
4.3工程化
考慮到我們的小程序是多部門多團隊共同合作的項目,不可能讓整個公司同時都使用Remax重寫原來的業務,那樣會有極大不可控風險。所以Remax只能是在部分業務試用,并且能漸進的切換原有的業務,這就要求我們必須有工程化的方案。
我們期望的小程序產物的結構如下:
而 Web 端的產物結構如下:
這表示小程序端是依賴原有小程序的,Web端是可以單個業務單獨發布的,于是我們在編譯過程中給小程序和Web生成兩套不同的殼工程。
編譯小程序的過程中拉取殼工程,殼工程的目錄結構大致如下:
其中remaxA和remaxB的頁面代碼是在拉取殼工程的時候動態生成的,我們在殼工程里放入一個配置文件bundle.js,用來描述該殼工程有哪些Remax業務代碼:
module.exports= {
remaxA: {
git:"git@remaxA.git"
},
remaxB: {
gitL "git@remaxB.git"
}
}
在拉取殼工程的同時clonebundle.js所配置的倉庫到臨時目錄packages,此時packages目錄如下:
然后根據Remax業務代碼里的app.config.js,重新在殼工程生成新的頁面和頁面配置,其核心邏輯如下:
const template = (projectName, path) => {
return`import ${projectName} from'~packages/${projectName}/src/pages${path ?`/${path}`:''}';
${projectName}.prototype.onShareAppMessage;
${projectName}.prototype.onPageScroll;
${projectName}.prototype.onShareTimeline;
exportdefault ${projectName};
`;
}
const pageHandler = (projectName) => {
const projectPath =`${rootDir}/packages/${projectName}`;
shell.cd(projectPath);
let conf =require(`${projectPath}/src/app.config.js`);
let platConf = conf[platform] || conf;
const projectAllPages = [];
...
// 遍歷 pages 和subPackages配置并替換路徑
pagePath.replace('pages/',`pages/${projectName}/`);
...
subPackage.root= subPackage.root.replace('pages/',`pages/${projectName}/`);
...
// 將pages subPackages里所有的頁面路徑合并到一起
let allPages = [...platConf.pages]
allPages.push(path.join(subPackage.root, page));
// 遍歷頁面配置生成新的頁面
allPages.forEach((mapPath) => {
const pagePath = path.resolve(rootDir,'src','pages', projectName,`${mapPath}.js`);
fse.ensureFileSync(pagePath);
const data =template(projectName, mapPath);
fs.writeFileSync(pagePath, data);
});
};
const complier = () => {
...
//獲取子項目git地址,下載至packages目錄下
const packagesPath = path.resolve(rootDir,'packages');
const subDirs = fs.readdirSync(packagesPath);
// 遍歷packages根據packages里的app.config.js重新生成合并路徑后的頁面
subDirs.forEach((name) => {
let file = fs.statSync(`${packagesPath}/${name}`);
if (file.isDirectory()) {
pageHandler(name);
}
});
...
};
module.exports= complier
生成的頁面代碼如下:
import remaxA from'~packages/remaxA/src/pages/index/index';
remaxA.prototype.onShareAppMessage;
remaxA.prototype.onPageScroll;
remaxA.prototype.onShareTimeline;
exportdefault remaxA;
這個可以修改上面代碼中template按需求修改, 這段代碼中之所以有類似
remaxA.prototype.onShareAppMessage; 這樣的無用代碼是因為Remax在編譯過程中會收集頁面代碼里的生命周期函數的關鍵字,非必需的生命周期在頁面代碼沒有出現的時候,編譯產物里也不會有。
生成的頁面路徑如下:
同樣的,在Web端也會有相似的操作。我們Web是使用node容器發布的,所以殼工程就弄成了node工程了。如果使用靜態發布那就不需要殼工程了,直接build發布產物就可以了。
此外,由于小程序的單包size限制的原因,在小程序webpack配置方面需要做一些額外的配置,避免多個Remax業務不共同依賴的代碼也打到主包去,導致主包的單包size超出限制,這里給一個例子,僅供參考:
configWebpack:function (options) {
let config = options.config;
let subpackageGroups = {};
Object.keys(projects).forEach((key) => {
let packagePages = projectsPages[key];
let allPages = packagePages.allPages.map((page) =>`pages/${key}/${page}`);
let pages = packagePages.pages;
subpackageGroups[`${key}Common`] = {
name:`package-${key}-common`,
test: (module) =>newRegExp(`[\/]packages[\/]${key}[\/]src[\/]`).test(module.userRequest),
chunks:'all',
minChunks:2,
minSize:0,
priority:91,
filename:`pages/${key}/package-${key}-common.js`,
};
});
config.optimization.merge({
splitChunks: {
maxAsyncRequests:100,
maxInitialRequests:100,
automaticNameDelimiter:'-',
enforceSizeThreshold:50000,
cacheGroups: {
...,
...subpackageGroups
},
},
});
},
五、經驗總結
- 經過實踐,我們發現Remax確實是可以做到一處編寫到處運行的,不足之處是Remax沒有提供一整套開箱即用的跨端API和控件,使用這個框架可能會有較多的前期基礎工作。但這也是它的優勢,核心的依賴少了,而且是完全開放的,不會說用它就會帶來一個全家桶的依賴。
- 由于Remax實現原理的限制,在小程序上較復雜的頁面性能上會有不足。當然這也是所有跨端框架存在的問題,好在可以通過使用自定原生組件的方式來解決。
- Remax目前還不支持DOM、BOM接口,也不支持直接使用webhostcomponent 來編寫跨端小程序,所以要實現跨端的話還是需要對現有的React應用做一定的改造。
- Remax還不支持RN平臺,說不支持并不是說不能支持,只是要兼容RN的話,可能還得在現有的React上做一些限制。比如RN對css的支持,以及要實現一整套的RN的控件來兼容web端及小程序端,這又使得Remax不那么純粹了。
六、寫在最后
本文旨在給大家提供一些新的思路,在選型方面應該從多方面去考量,各個方案可能沒有明顯的好壞之分,適合的才是最好的。就拿Taro來說,得益于有官方團隊的支持,Taro3的發展速度非常之快,各個方面都做的比較完善。而Remax社區似乎沒有那么活躍,所以發展速度相對來說比較慢。期待有更多的朋友來參與開源框架的貢獻。
【作者簡介】汽車票前端研發團隊,致力于提供更便捷更智慧的出行方式,關注前端技術方向的探索和實踐。