探索跨端开发的常用解决方案:条件编译的实现
前言
跨端开发是指在不同的平台或设备上开发同一种软件应用,例如:一个应用程序可以同时运行在移动设备、桌面电脑和浏览器等不同的设备上,或是一个小程序能够在微信、支付宝、抖音等多个平台使用。跨端开发的优点在于可以节省研发和维护成本,让开发者者编写一套符合规范的代码,由编译器将其编译生成出可以发布在每个平台的产物,在更广泛地覆盖用户群体的同时,可以保持产品在不同渠道的一致性,减少用户的上手使用成本。
在代码中编写大量的
if
、else
来处理不同平台或需求的差异对编译后的产物进行二次开发,或维护两套差异性代码
以上方式虽然一定程度上可以满足跨端开发的需求,但是也带来了大量问题:
性能下降:产物中充斥着大量其他平台的代码,造成代码执行性能底下,增大产物体积,在小程序这种有产物体积大小限制的项目中并不适用。
难以维护:业务上仍然需要维护多套代码,让后续的迭代和升级变得混乱,降低研发效率
违背初心:跨端开发的目标是「一次编写,多处运行」,以上方案让跨端研发一定程度上失去了转换的优势。
因此,最好的方式就是能够根据不同目标平台,打包只与该平台相关的代码产物,无其他冗余代码,产物体积小,利于后续的维护,而这个描述就很容易让人想到条件编译,本文就探索一下条件编译的实现原理。
现状
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
结尾,中间编写符合当前标识渠道的代码段。仅在某平台存在的代码段
除某平台外,其他平台均存在的代码段
默认注入的变量:各编译目标平台(微信小程序、支付宝小程序、百度小程序、抖音小程序、Web 应用),编译配置名,是否是生产环境等
自定义条件编译变量:MorJS 支持在配置文件中自定义条件编译的变量值,并提供了如下的语法:
符合变量值判断条件的代码段
文件维度的条件编译:除了使用代码中的特殊的注释作为标记,实现条件编译外,MorJS 提供基于特殊规则的文件后缀,实现文件维度的条件编译。
例如:在同一目录下的index.js
、index.wx.js
、index.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
根据文件类型匹配不同正则
export function preprocess(
sourceCode: string,
context: Record<string, any>,
ext: string,
filePath?: string
): string {
let type: string
if (JsLikeFileExts.includes(ext as JsLikeFileExtType)) {
type = 'js'
} else if (XmlLikeFileExts.includes(ext as XmlLikeFileExtType)) {
type = 'xml'
}
if (!type) return sourceCode
return preprocessor(
sourceCode,
context,
{ type: RegexRules[type], srcEol: getEolType(sourceCode) },
undefined,
filePath
)
}
preprocess
方法针对文件后缀(入参ext
)进行区分以匹配后续注释的正则规则:JsLikeFileExts
:命中 Style 文件,Config 文件,Script 文件,Sjs 文件等Style 文件: .wxss
、.acss
等,预处理器.less
、.scss
、.sass
Config 文件: .jsonc
、.json5
(.json
文件无法编写注释)Script 文件: .js
、.mjs
、.ts
、.mts
Sjs 文件等: .sjs
、.jsx
、.tsx
XmlLikeFileExts
:命中各端小程序 XML 文件,如.wxml
、.axml
等XmlLikeFileExts
规则的使用 xml 正则,命中JsLikeFileExts
规则的使用 js 正则const RegexRules = {
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
有四种情况:between
:正则start
前的内容,直接作为字符串拼接left
:正则start
的匹配结果,执行exec
方法并保存为matchGroup.left
match
:处于正则start
和end
中间的内容,保存为matchGroup.match
right
:正则end
的匹配结果,执行exec
方法并保存为matchGroup.end
,并调用回调函数processor
between
的字符串后面:function replaceRecursive(
rv: string,
rule: PreprocessRule,
processor: PreprocessProcessor
): string {
if (!rule.start || !rule.end) {
throw new Error('Recursive rule must have start and end.')
}
const startRegex = new RegExp(rule.start, 'mi')
const endRegex = new RegExp(rule.end, 'mi')
function matchReplacePass(content: string): string {
const matches = XRegExp.matchRecursive(
content,
rule.start,
rule.end,
'gmi',
{
valueNames: ['between', 'left', 'match', 'right']
}
)
// 如果未命中则直接返回内容
if (matches.length === 0) return content
const matchGroup = {
left: null,
match: null,
right: null
} as {
left: null | RegExpExecArray
match: null | string
right: null | RegExpExecArray
}
return matches.reduce(function (builder, match) {
switch (match.name) {
case 'between':
builder += match.value
break
case 'left':
matchGroup.left = startRegex.exec(match.value)
break
case 'match':
matchGroup.match = match.value
break
case 'right':
matchGroup.right = endRegex.exec(match.value)
builder += processor(
matchGroup.left,
matchGroup.right,
matchGroup.match,
matchReplacePass
)
break
}
return builder
}, '')
}
return matchReplacePass(rv)
}
根据标识符决定代码块的去留
processor
回调函数来决定保留或是删除,核心是调用getDeepPropFromObj
判断条件编译的项中,是否有符合标识符结果的项:命中条件编译:递归执行 replaceRecursive
中的matchReplacePass
方法,使用XRegExp.matchRecursive
二次检查是否仍包含条件编译的分隔符,未检测到则直接返回内容,完成保留代码块的过程;未命中条件编译:直接返回空,即删除代码块的过程;
const processor: PreprocessProcessor = (
startMatches,
endMatches,
include,
recurse
) => {
if (!startMatches || !endMatches || !include) return ''
const variant = startMatches[1]
const test = (startMatches[2] || '').trim()
switch (variant) {
case 'if': {
let testResult = testPasses(test, context) as any
// 当前传入的 context 没有该 key
if (testResult instanceof ReferenceError) {
logger.warn(
'当前条件编译中找不到变量,将按照条件执行结果为 false 处理\n' +
`条件判断语句: ${test}\n` +
`报错信息: ${testResult.message}` +
(filePath ? `\n文件路径: ${filePath}` : '')
)
}
if (typeof testResult !== 'boolean') testResult = false
return testResult ? recurse(include) : ''
}
case 'ifdef':
return typeof getDeepPropFromObj(context, test) !== 'undefined'
? recurse(include)
: ''
case 'ifndef':
return typeof getDeepPropFromObj(context, test) === 'undefined'
? recurse(include)
: ''
default:
throw new Error('Unknown if variant ' + variant + '.')
}
}
文件维度条件编译
代码参考:https://github.com/eleme/morjs/blob/main/packages/plugin-compiler/src/entries/index.ts
文件维度的条件编译,核心是在编译过程中,构建文件依赖树及分组关系时,基于后缀的优先级顺序,添加对应端命中的文件,也就是配置文件中的fileExt
的值,与需要查找的文件后缀进行拼接,返回查找到的第一个命中的文件地址。
async tryReachFileByExts(
fileName: string,
fileExts: string[],
contexts: string[],
parentPath?: string,
rootDirs?: string[]
): Promise<string> {
// 确保无后缀
const fileNameWithoutExt = pathWithoutExtname(fileName)
const roots = rootDirs?.length ? rootDirs : this.srcPaths
const contextDirs = this.expandContextsAccordingToRootDirs(contexts, roots)
// 需要判断文件是否为绝对路径
const isAbsolute = path.isAbsolute(fileName)
// 支持多 context 返回查找到的第一个文件
for await (const contextDir of contextDirs) {
let filePath = fileNameWithoutExt
if (isAbsolute) {
// 绝对路径需要限制在 contextDir 之内
filePath = filePath.startsWith(contextDir)
? filePath
: path.join(contextDir, filePath)
} else {
filePath = path.resolve(contextDir, filePath)
}
for await (const ext of fileExts) {
// 拼接后缀
const finalPath = filePath + ext
if (await this.fs.fileExists(finalPath)) {
return finalPath
}
}
}
}
值得一提的是,为了支持不同类型的文件编译,及各端默认的特殊后缀文件,MorJS 实现了一套文件优先级的计算方案,最终编译时如遇到同名文件,将使用优先级数值更高的文件进行编译:
配置了自定义入口文件 customEntries 的固定值为 1000,优先级最高;
条件编译文件基础值为 20,配置多个条件编译后缀时,位置越靠前的后缀优先级越高,步进为 5;
native 文件固定值为 15;
微信 DSL 文件固定值为 10,如 wxss 或 wxml 或 wxs 文件;
支付宝 DSL 文件固定值为 5,如 acss 或 axml 或 sjs 文件;
普通文件固定值为 0,如 js 或 ts 或 json 文件;
enum EntryPriority {
CustomEntry = 1000,
Conditional = 20,
Native = 15,
Wechat = 10,
Alipay = 5,
Normal = 0
}
function calculateEntryPriority(
extname: string,
isConditionalFile: boolean,
priorityAmplifier: number = 0,
entryType: EntryType
): EntryPriority {
if (entryType === EntryType.custom) {
return EntryPriority.CustomEntry
}
if (isConditionalFile) {
// 按照优先级自动放大
return EntryPriority.Conditional + 5 * priorityAmplifier
}
if (
this.targetFileTypes.template === extname ||
this.targetFileTypes.style === extname
) {
return EntryPriority.Native
}
if (
this.wechatFileTypes.template === extname ||
this.wechatFileTypes.style === extname ||
this.targetFileTypes.sjs === extname
) {
return EntryPriority.Wechat
}
if (
this.alipayFileTypes.template === extname ||
this.alipayFileTypes.style === extname ||
this.alipayFileTypes.sjs === extname
) {
return EntryPriority.Alipay
}
return EntryPriority.Normal
}
效果
代码维度
wxml/axml 文件类型
#ifdef
用于判断是否有该变量,以下代码仅在微信和支付宝端会显示对应的内容,在其他端则无显示<!-- #ifdef wechat -->
<view>只会在微信上显示</view>
<!-- #endif -->
<!-- #ifdef alipay -->
<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"
}
}
文件维度
└── components
└── demo
├── index.axml
├── index.acss
├── index.js
└── index.json
└── components
└── demo
├── index.axml
├── index.acss
├── index.js
├── index.json
├── index.wx.axml(微信版本)
├── index.wx.acss(微信版本)
└── index.wx.js(微信版本)
.wx
的版本来生成对应的微信版本源文件,而在引用该组件的页面的 json 中的 usingComponents 是不需要做任何修改的,依然保留原本的引用路径的。结语
微信扫码关注该文公众号作者