探索跨端开发的常用解决方案:条件编译的实现

简介: 探索跨端开发的常用解决方案:条件编译的实现


前言


跨端开发是指在不同的平台或设备上开发同一种软件应用,例如:一个应用程序可以同时运行在移动设备、桌面电脑和浏览器等不同的设备上,或是一个小程序能够在微信、支付宝、抖音等多个平台使用。跨端开发的优点在于可以节省研发和维护成本,让开发者者编写一套符合规范的代码,由编译器将其编译生成出可以发布在每个平台的产物,在更广泛地覆盖用户群体的同时,可以保持产品在不同渠道的一致性,减少用户的上手使用成本。


然而,由于不同平台存在一些无法抹平的特性差异,或是针对特定平台可能会有不同的产品需求,比较常见的做法有以下两种:

  1. 在代码中编写大量的ifelse  来处理不同平台或需求的差异
  2. 对编译后的产物进行二次开发,或维护两套差异性代码


以上方式虽然一定程度上可以满足跨端开发的需求,但是也带来了大量问题:

  1. 性能下降:产物中充斥着大量其他平台的代码,造成代码执行性能底下,增大产物体积,在小程序这种有产物体积大小限制的项目中并不适用。
  2. 难以维护:业务上仍然需要维护多套代码,让后续的迭代和升级变得混乱,降低研发效率
  3. 违背初心:跨端开发的目标是「一次编写,多处运行」,以上方案让跨端研发一定程度上失去了转换的优势。



因此,最好的方式就是能够根据不同目标平台,打包只与该平台相关的代码产物,无其他冗余代码,产物体积小,利于后续的维护,而这个描述就很容易让人想到条件编译,本文就探索一下条件编译的实现原理。


现状


Conditional compilation is a compilation technique which results in an executable program that is able to be altered by changing specified parameters. In C and some languages with a similar syntax, This technique is commonly used when these alterations to the program are needed to run it on different platforms, or with different versions of required libraries or hardware. —— From Wikipedia「Conditional compilation」

大意是:条件编译是一种编译技术,它可以通过更改指定参数来更改的可执行程序,在类似 C 语言中,出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。—— 维基百科《条件编译》


条件编译常用的写法基本是以#ifdef 标识符#ifndef 标识符作为开头,以#endif结尾,中间编写符合当前标识渠道的代码段。


#ifdef 标识符仅在某平台存在的代码段#endif#ifndef 标识符除某平台外,其他平台均存在的代码段#endif


不同框架/编译器对标识符的取值都有自己的一套规定,不同的值对应的生效条件不同,例如 Uni-app 的可选值有 19 项,覆盖了微信小程序、支付宝小程序、快应用、App、H5 等场景,Taro 的可选值共有 8 项,包含微信小程序、支付宝小程序、抖音小程序、H5 等场景,MorJS 相对独特一些,其标识符分为两类:

  1. 默认注入的变量:各编译目标平台(微信小程序、支付宝小程序、百度小程序、抖音小程序、Web 应用),编译配置名,是否是生产环境等
  2. 自定义条件编译变量:MorJS 支持在配置文件中自定义条件编译的变量值,并提供了如下的语法:


#if 标识符符合变量值判断条件的代码段#endif
  1. 文件维度的条件编译:除了使用代码中的特殊的注释作为标记,实现条件编译外,MorJS 提供基于特殊规则的文件后缀,实现文件维度的条件编译。

例如:在同一目录下的index.jsindex.wx.jsindex.tt.js文件,在编译微信小程序时会使用优先级最高的index.wx.js文件,在编译抖音小程序时则会使用index.tt.js文件。


开发者也可以在配置文件中,添加配置conditionalCompile.fileExt来自定义文件维度条件编译的后缀值,以配置{ fileExt: ['.my', '.share'] }为例,编译时将按优先级查找index.my.js>index.share.js=>index.js  文件用于编译构建。


实现


代码维度条件编译

代码参考:https://github.com/eleme/morjs/blob/main/packages/plugin-compiler/src/preprocessors/codePreprocessor.ts


根据文件类型匹配不同正则


exportfunctionpreprocess(
sourceCode: string,
context: Record<string, any>,
ext: string,
filePath?: string): string {
lettype: stringif (JsLikeFileExts.includes(extasJsLikeFileExtType)) {
type='js'  } elseif (XmlLikeFileExts.includes(extasXmlLikeFileExtType)) {
type='xml'  }
if (!type) returnsourceCodereturnpreprocessor(
sourceCode,
context,
    { type: RegexRules[type], srcEol: getEolType(sourceCode) },
undefined,
filePath  )
}


preprocess  方法针对文件后缀(入参ext)进行区分以匹配后续注释的正则规则:1、JsLikeFileExts:命中 Style 文件,Config 文件,Script 文件,Sjs 文件等

  • Style 文件:.wxss.acss等,预处理器.less.scss.sass
  • Config 文件:.jsonc.json5.json文件无法编写注释)
  • Script 文件:.js.mjs.ts.mts
  • Sjs 文件等:.sjs.jsx.tsx

2、XmlLikeFileExts:命中各端小程序 XML 文件,如.wxml.axml条件编译的正则也同样分为两类,命中XmlLikeFileExts规则的使用 xml 正则,命中JsLikeFileExts规则的使用 js 正则


constRegexRules= {
xml: {
if: {
start:
'[ \t]*<!--[ \t]*#(ifndef|ifdef|if)[ \t]+(.*?)[ \t]*(?:-->|!>)(?:[ \t]*\n+)?',
end: '[ \t]*<!(?:--)?[ \t]*#endif[ \t]*(?:-->|!>)(?:[ \t]*\n)?'    }
  },
js: {
if: {
start:
'[ \t]*(?://|/\\*)[ \t]*#(ifndef|ifdef|if)[ \t]+([^\n*]*)(?:\\*(?:\\*|/))?(?:[ \t]*\n+)?',
end: '[ \t]*(?://|/\\*)[ \t]*#endif[ \t]*(?:\\*(?:\\*|/))?(?:[ \t]*\n)?'    }
  }
}


以下是XmlLikeFileExts  文件类型的  if start  正则的可视化图:




调用 XRegExp.matchRecursive 将代码块拆分


preprocessor创建了一个回调函数processor,并调用 replaceRecursive使用xregexp库的XRegExp.matchRecursive(),该方法接受需要搜索的字符串和左右分隔符的正则,返回左右分隔符之间匹配到的字符串数组matches,而matches.name有四种情况:

  1. between:正则start前的内容,直接作为字符串拼接
  2. left:正则start的匹配结果,执行exec方法并保存为matchGroup.left
  3. match:处于正则startend中间的内容,保存为matchGroup.match
  4. right:正则end的匹配结果,执行exec方法并保存为matchGroup.end,并调用回调函数processor

获得处理后的字符串,拼接到前面between的字符串后面:


functionreplaceRecursive(
rv: string,
rule: PreprocessRule,
processor: PreprocessProcessor): string {
if (!rule.start||!rule.end) {
thrownewError('Recursive rule must have start and end.')
  }
conststartRegex=newRegExp(rule.start, 'mi')
constendRegex=newRegExp(rule.end, 'mi')
functionmatchReplacePass(content: string): string {
constmatches=XRegExp.matchRecursive(
content,
rule.start,
rule.end,
'gmi',
      {
valueNames: ['between', 'left', 'match', 'right']      
      }
    )
// 如果未命中则直接返回内容if (matches.length===0) returncontentconstmatchGroup= {
left: null,
match: null,
right: null    } as {
left: null|RegExpExecArraymatch: null|stringright: null|RegExpExecArray    }
returnmatches.reduce(function (builder, match) {
switch (match.name) {
case'between':
builder+=match.valuebreakcase'left':
matchGroup.left=startRegex.exec(match.value)
breakcase'match':
matchGroup.match=match.valuebreakcase'right':
matchGroup.right=endRegex.exec(match.value)
builder+=processor(
matchGroup.left,
matchGroup.right,
matchGroup.match,
matchReplacePass          )
break      }
returnbuilder    }, '')
  }
returnmatchReplacePass(rv)
}


根据标识符决定代码块的去留


目前距离完成代码维度条件编译只差最后一步,将获取到的这段条件编译包裹的代码块,通过processor回调函数来决定保留或是删除,核心是调用getDeepPropFromObj判断条件编译的项中,是否有符合标识符结果的项:

  1. 命中条件编译:递归执行replaceRecursive中的matchReplacePass方法,使用XRegExp.matchRecursive二次检查是否仍包含条件编译的分隔符,未检测到则直接返回内容,完成保留代码块的过程;
  2. 未命中条件编译:直接返回空,即删除代码块的过程;


constprocessor: PreprocessProcessor= (
startMatches,
endMatches,
include,
recurse) => {
if (!startMatches||!endMatches||!include) return''constvariant=startMatches[1]
consttest= (startMatches[2] ||'').trim()
switch (variant) {
case'if': {
lettestResult=testPasses(test, context) asany// 当前传入的 context 没有该 keyif (testResultinstanceofReferenceError) {
logger.warn(
'当前条件编译中找不到变量,将按照条件执行结果为 false 处理\n'+`条件判断语句: ${test}\n`+`报错信息: ${testResult.message}`+            (filePath?`\n文件路径: ${filePath}` : '')
        )
      }
if (typeoftestResult!=='boolean') testResult=falsereturntestResult?recurse(include) : ''    }
case'ifdef':
returntypeofgetDeepPropFromObj(context, test) !=='undefined'?recurse(include)
        : ''case'ifndef':
returntypeofgetDeepPropFromObj(context, test) ==='undefined'?recurse(include)
        : ''default:
thrownewError('Unknown if variant '+variant+'.')
  }
}


文件维度条件编译


代码参考: https://github.com/eleme/morjs/blob/main/packages/plugin-compiler/src/entries/index.ts


文件维度的条件编译,核心是在编译过程中,构建文件依赖树及分组关系时,基于后缀的优先级顺序,添加对应端命中的文件,也就是配置文件中的fileExt的值,与需要查找的文件后缀进行拼接,返回查找到的第一个命中的文件地址。


asynctryReachFileByExts(
fileName: string,
fileExts: string[],
contexts: string[],
parentPath?: string,
rootDirs?: string[]
): Promise<string> {
// 确保无后缀constfileNameWithoutExt=pathWithoutExtname(fileName)
constroots=rootDirs?.length?rootDirs : this.srcPathsconstcontextDirs=this.expandContextsAccordingToRootDirs(contexts, roots)
// 需要判断文件是否为绝对路径constisAbsolute=path.isAbsolute(fileName)
// 支持多 context 返回查找到的第一个文件forawait (constcontextDirofcontextDirs) {
letfilePath=fileNameWithoutExtif (isAbsolute) {
// 绝对路径需要限制在 contextDir 之内filePath=filePath.startsWith(contextDir)
?filePath        : path.join(contextDir, filePath)
    } else {
filePath=path.resolve(contextDir, filePath)
    }
forawait (constextoffileExts) {
// 拼接后缀constfinalPath=filePath+extif (awaitthis.fs.fileExists(finalPath)) {
returnfinalPath      }
    }
  }
}


值得一提的是,为了支持不同类型的文件编译,及各端默认的特殊后缀文件,MorJS 实现了一套文件优先级的计算方案,最终编译时如遇到同名文件,将使用优先级数值更高的文件进行编译:

  1. 配置了自定义入口文件 customEntries 的固定值为 1000,优先级最高;
  2. 条件编译文件基础值为 20,配置多个条件编译后缀时,位置越靠前的后缀优先级越高,步进为 5;
  3. native 文件固定值为 15;
  4. 微信 DSL 文件固定值为 10,如 wxss 或 wxml 或 wxs 文件;
  5. 支付宝 DSL 文件固定值为 5,如 acss 或 axml 或 sjs 文件;
  6. 普通文件固定值为 0,如 js 或 ts 或 json 文件;
enumEntryPriority {
CustomEntry=1000,
Conditional=20,
Native=15,
Wechat=10,
Alipay=5,
Normal=0}
functioncalculateEntryPriority(
extname: string,
isConditionalFile: boolean,
priorityAmplifier: number=0,
entryType: EntryType): EntryPriority {
if (entryType===EntryType.custom) {
returnEntryPriority.CustomEntry  }
if (isConditionalFile) {
// 按照优先级自动放大returnEntryPriority.Conditional+5*priorityAmplifier  }
if (
this.targetFileTypes.template===extname||this.targetFileTypes.style===extname  ) {
returnEntryPriority.Native  }
if (
this.wechatFileTypes.template===extname||this.wechatFileTypes.style===extname||this.targetFileTypes.sjs===extname  ) {
returnEntryPriority.Wechat  }
if (
this.alipayFileTypes.template===extname||this.alipayFileTypes.style===extname||this.alipayFileTypes.sjs===extname  ) {
returnEntryPriority.Alipay  }
returnEntryPriority.Normal}


效果


代码维度

  • wxml/axml 文件类型

#ifdef用于判断是否有该变量,以下代码仅在微信和支付宝端会显示对应的内容,在其他端则无显示


<!--#ifdefwechat--><view>只会在微信上显示</view><!--#endif--><!--#ifdefalipay--><view>只会在支付宝上显示</view><!--#endif-->


  • acss/less 文件类型

#ifndef用于判断是否无该变量,以下代码的效果为:除微信外的其他端显示红色背景色:


.index-page {
/* #ifndef wechat */background: red;
/* #endif */}


  • js/ts 文件类型

#if用于判断变量值,以下代码仅在微信和支付宝端会显示打印对应的内容,在其他端则无打印


/* #if name == 'wechat' */console.log('这句话只会在微信上显示')
/* #endif *//* #if name == 'alipay' */console.log('这句话只会在支付宝上显示')
/* #endif */


  • jsonc/json5 文件类型

虽然 .json 文件无法编写注释,但 MorJS 友好的兼容了 .jsonc 和 .json5 文件,例如以下 json 文件仅在微信和支付宝端会加载对应的自定义组件。


{
"component": true,
"usingComponents": {
// #if name == 'wechat'"any-component": "./wechat-any-component",
// #endif// #if name == 'alipay'"any-component": "./alipay-any-component",
// #endif"other-component": "./other-component"  }
}


文件维度


以组件为例,默认情况下,组件都包含了 axml/acss/js/json 四个文件


└──components└──demo├──index.axml├──index.acss├──index.js└──index.json


若在微信小程序端,定制化需求或逻辑差异较大,可以直接用 .wx 来做区分


└──components└──demo├──index.axml├──index.acss├──index.js├──index.json├──index.wx.axml(微信版本)├──index.wx.acss(微信版本)└──index.wx.js(微信版本)


在编译输出时,针对微信端的编译构建,会优先用 .wx的版本来生成对应的微信版本源文件,而在引用该组件的页面的 json 中的 usingComponents 是不需要做任何修改的,依然保留原本的引用路径的。

结语


条件编译让一码多端框架的跨端转换能力变得更加完善,弥补了平台差异化和产品定制化的场景需求,在解决适配问题的同时,减少了不必要的冗余代码,提高代码的质量和可维护性。


最后,MorJS 作为一套基于小程序 DSL 的可扩展的多端研发框架,使用者只需书写一套小程序,就可以通过 MorJS 的转端编译能力,将源码分别编译出可以在不同端运行的产物,欢迎大家交流和使用。


GitHub:https://github.com/eleme/morjs


相关文章
|
2月前
|
JavaScript 数据管理 编译器
揭秘 ArkTS 与 TypeScript 的神秘差异:鸿蒙系统开发者的必备知识与实战技巧
【10月更文挑战第18天】ArkTS 是华为为鸿蒙系统(HarmonyOS)推出的开发语言,作为 TypeScript 的超集,它针对鸿蒙系统的分布式特性和需求进行了优化和扩展。ArkTS 强化了分布式数据管理、类型系统、编译与运行时性能,并支持声明式 UI 和专为鸿蒙设计的 API,使开发者能够更高效地开发跨设备协同工作的应用。
98 6
|
3月前
|
Linux C# 开发者
Uno Platform 驱动的跨平台应用开发:从零开始的全方位资源指南与定制化学习路径规划,助您轻松上手并精通 C# 与 XAML 编程技巧,打造高效多端一致用户体验的移动与桌面应用程序
【9月更文挑战第8天】Uno Platform 的社区资源与学习路径推荐旨在为初学者和开发者提供全面指南,涵盖官方文档、GitHub 仓库及社区支持,助您掌握使用 C# 和 XAML 创建跨平台原生 UI 的技能。从官网入门教程到进阶技巧,再到活跃社区如 Discord,本指南带领您逐步深入了解 Uno Platform,并提供实用示例代码,帮助您在 Windows、iOS、Android、macOS、Linux 和 WebAssembly 等平台上高效开发。建议先熟悉 C# 和 XAML 基础,然后实践官方教程,研究 GitHub 示例项目,并积极参与社区讨论,不断提升技能。
99 2
|
4月前
|
C# 开发者 前端开发
揭秘混合开发新趋势:Uno Platform携手Blazor,教你一步到位实现跨平台应用,代码复用不再是梦!
【8月更文挑战第31天】随着前端技术的发展,混合开发日益受到开发者青睐。本文详述了如何结合.NET生态下的两大框架——Uno Platform与Blazor,进行高效混合开发。Uno Platform基于WebAssembly和WebGL技术,支持跨平台应用构建;Blazor则让C#成为可能的前端开发语言,实现了客户端与服务器端逻辑共享。二者结合不仅提升了代码复用率与跨平台能力,还简化了项目维护并增强了Web应用性能。文中提供了从环境搭建到示例代码的具体步骤,并展示了如何创建一个简单的计数器应用,帮助读者快速上手混合开发。
90 0
|
7月前
|
JavaScript 前端开发 编译器
TypeScript的编译器、编辑器支持与工具链:构建高效开发环境的秘密武器
【4月更文挑战第23天】TypeScript的强大力量源于其编译器、编辑器支持和工具链,它们打造了高效的开发环境。编译器`tsc`进行类型检查、语法分析和代码转换;编辑器如VS Code提供智能提示、错误检查和格式化;工具链包括Webpack、Rollup等构建工具,Jest、Mocha等测试框架,以及代码质量和性能分析工具。这些组合使用能提升开发效率、保证代码质量和优化项目性能。
|
7月前
|
缓存 编译器 测试技术
简化 CMake 多平台兼容性处理:高效开发的秘诀
简化 CMake 多平台兼容性处理:高效开发的秘诀
235 0
|
7月前
|
开发工具 git
uniapp项目实践拓展章:代码统一风格
uniapp项目实践拓展章:代码统一风格
111 0
|
自然语言处理 Kubernetes 数据可视化
无代码开发和低代码开发的本质区别
无代码开发和低代码开发的本质区别
|
JavaScript 前端开发 Shell
Donut 多端框架:一款跨平台开发的利器
随着移动互联网的快速发展,越来越多的开发者开始关注跨平台开发技术。跨平台开发可以让我们在不同的设备和操作系统上运行相同的代码,大大提高了开发效率和应用的覆盖范围。本文将为大家介绍一款名为Donut 多端框架的跨平台开发工具,以及如何使用它来快速搭建一个跨平台的移动应用。
1307 0
|
JavaScript 前端开发 Java
谈一谈 OpenHarmony 的方舟编译体系
谈一谈 OpenHarmony 的方舟编译体系
|
Kubernetes Cloud Native Java
关于平台工程的开发者工具链,你还想加点啥?
一个新挑战往往诞生新构思,“内部研发自助平台”构想:“企业应该以平台化建设的方式,提供一系列的自助型工具,协助开发者在各个环节中解决遇到的各种技术问题”。文本会逐步的分析这个工具里面有点啥
444 10
关于平台工程的开发者工具链,你还想加点啥?