预备
这里先看一下 这三个平台对于跨平台适配的描述
- Taro
Taro 的设计初衷就是为了统一跨平台的开发方式,并且已经尽力通过运行时框架、组件、API 去抹平多端差异,但是由于不同的平台之间还是存在一些无法消除的差异,所以为了更好的实现跨平台开发,Taro 中提供了如下的解决方案:
内置环境变量
...
为了方便大家书写样式跨端的样式代码,添加了样式条件编译的特性。
- Chameleon
CML 的是多端的上层应用语言,在这样的目标下,用户扩展功能时,保障业务代码和各端通信一致性变得特别重要。
...
以上,跨端很美好,最大风险是可维护性问题。多态协议是 CML 业务层代码和各端底层组件和接口的分界点,CML 会严格“管制”输入输出值的类型和结构,同时会严格检查业务层 JS 代码,避免直接使用某端特有的接口,不允许在公共代码处使用某个端特定的方法,即使这段代码不会执行,例如禁止使用 `window` 、 `wx` 、 `my` 、 `swan` 、 `weex` 等方法。
- uniApp
uni-app 已将常用的组件、JS API 封装到框架中,开发者按照 uni-app 规范开发即可保证多平台兼容,大部分业务均可直接满足。
但每个平台有自己的一些特性,因此会存在一些无法跨平台的情况。
- 大量写 if else,会造成代码执行性能低下和管理混乱。
- 编译到不同的工程后二次修改,会让后续升级变的很麻烦。
在 C 语言中,通过 #ifdef、#ifndef 的方式,为 windows、mac 等不同 os 编译不同的代码。 `uni-app` 参考这个思路,为 `uni-app` 提供了条件编译手段,在一个工程里优雅的完成了平台个性化实现。
以上可以看出每个开源跨端框架都不能100%保证用户使用该框架能完全不管兼容性问题,只是帮助开发解决了大部分兼容问题,针对一些平台特性问题难以兼容部分,仍然需要开发者自己来完成, 那他们是如何实现这部分兼容部分的处理的呢,我们就来 扒开外衣,看看本质,由您看看哪家实现最优雅...
开始
Taro
内置环境变量
process.env.TARO_ENV
用于判断当前编译类型,目前有 weapp
/ swan
/ alipay
/ h5
/ rn
/ tt
/ qq
/ quickapp
八个取值,可以通过这个变量来书写对应一些不同环境下的代码,在编译时会将不属于当前编译类型的代码去掉,只保留当前编译类型下的代码,例如想在微信小程序和 H5 端分别引用不同资源
if (process.env.TARO_ENV === 'weapp') {
require('path/to/weapp/name')
} else if (process.env.TARO_ENV === 'h5') {
require('path/to/h5/name')
}
这个实现方案,使用过webpack的开发者比较熟悉,实现原理是 使用webpack.DefinePlugin
插件 注入到webpack中,在webpack 编译过程中启用 Tree-Shaking
来过滤掉 兼容平台的使用不到的代码。那 Taro 是在哪里处理的呢,我们来看下Taro的源码
- 首先我们使用
taro-cli
提供的初始化项目之后,它在package.json
里提供多种平台的编译方式
"scripts": {
"build:swan": "taro build --type swan",
"build:weapp": "taro build --type weapp",
"build:alipay": "taro build --type alipay",
...
},
可以看到 在 scripts 运行的时候 使用 --type
传入的 TARO_ENV
的值
vscode 打开 Taro 源码中next 分支
// packages/taro-cli/src/cli.ts
customCommand('build', kernel, {
_: args._,
platform,
plugin,
isWatch: Boolean(args.watch),
...
})
taro-cli 将命令行传入的type 使用platform 变量传入给Kernel
处理,Kernel
位于taro-service
子包,作为基础服务提供实时的编译工作, Taro 的核心就是 利用 Kernel
+ 注册插件
+ 生命周期钩子函数
的实现方式,灵活的实现了各个不同的命令组合
- Kernel 调用 mini-runner 进行build 构建, 将platform 传给 buildAdapter 处理
//packages/taro-service/src/platform-plugin-base.ts
/**
* 准备 mini-runner 参数
* @param extraOptions 需要额外合入 Options 的配置项
*/
protected getOptions (extraOptions = {}) {
const { ctx, config, globalObject, fileType, template } = this
return {
...
buildAdapter: config.platform,
...
}
}
/**
* 调用 mini-runner 开始编译
* @param extraOptions 需要额外传入 @tarojs/mini-runner 的配置项
*/
private async build (extraOptions = {}) {
this.ctx.onBuildInit?.(this)
await this.buildTransaction.perform(this.buildImpl, this, extraOptions)
}
private async buildImpl (extraOptions) {
const runner = await this.getRunner()
const options = this.getOptions(Object.assign({
runtimePath: this.runtimePath,
taroComponentsPath: this.taroComponentsPath
}, extraOptions))
await runner(options)
}
这里可以看到 runner 中options 在编译 微信小程序的时候输出的变量
3. taro-mini-runner 中执行 build.config.ts中build方法, 在build里利用 export const getDefinePlugin = pipe(mergeOption, listify, partial(getPlugin, webpack.DefinePlugin))
引入webpack.DefinePlugin
export default (appPath: string, mode, config: Partial<IBuildConfig>): any => {
const chain = getBaseConf(appPath)
const {
buildAdapter = PLATFORMS.WEAPP,
...
} = config
...
env.TARO_ENV = JSON.stringify(buildAdapter)
const runtimeConstants = getRuntimeConstants(runtime)
const constantsReplaceList = mergeOption([processEnvOption(env), defineConstants, runtimeConstants])
const entryRes = getEntry({
sourceDir,
entry,
isBuildPlugin
})
...
plugin.definePlugin = getDefinePlugin([constantsReplaceList])
chain.merge({
mode,
devtool: getDevtool(enableSourceMap, sourceMapType),
entry: entryRes!.entry,
...
plugin,
optimization: {
...
}
})
...
return chain
}
将 buildAdapter 作为env.TARO_ENV 传入到 webpack.DefinePlugin 之后利用 webpack 进行打包,做差异性处理
样式的条件编译
以上 webpack.DefinePlugin
可以针对 ts/js 代码进行 Tree-shaking
。
- 样式处理上,对于RN的样式处理直接替换整个 css代码
当在 JS 文件中引用样式文件: `import './index.scss'` 时,RN 平台会找到并引入 `index.rn.scss` ,其他平台会引入: `index.scss` ,方便大家书写跨端样式,更好地兼容 RN。
这里也很好理解,对于RN的替换引入文件的方式,如果我们作为Taro开发者, 在webpack 的 loader 插件中判断方法 对应的scss 文件有无 以 .rn.scss 文件,直接改变引入即可, taro 里是在哪里实现这些操作呢?
- 定义css 后缀文件
// packages/taro-helper/src/constants.ts
export const CSS_EXT: string[] = ['.css', '.scss', '.sass', '.less', '.styl', '.stylus', '.wxss', '.acss']
export const JS_EXT: string[] = ['.js', '.jsx']
export const TS_EXT: string[] = ['.ts', '.tsx']
- 判断编译平台,优先选择对应平台的 style 文件
//packages/taro-rn-supporter/src/utils.ts
// lookup modulePath if the file path exist
// import './a.scss'; import './app'; import '/app'; import 'app'; import 'C:\\\\app';
function lookup (modulePath, platform, isDirectory = false) {
const extensions = ([] as string[]).concat(helper.JS_EXT, helper.TS_EXT, helper.CSS_EXT)
const omitExtensions = ([] as string[]).concat(helper.JS_EXT, helper.TS_EXT)
const ext = path.extname(modulePath).toLowerCase()
const extMatched = !!extensions.find(e => e === ext)
// when platformExt is empty string('') it means find modulePath itself
const platformExts = [`.${platform}`, '.rn', '']
// include ext
if (extMatched) {
for (const plat of platformExts) {
const platformModulePath = modulePath.replace(ext, `${plat}${ext}`)
// 判断是否有对应平台的后缀文件,如果有就直接返回对应平台的后缀文件,替换掉默认的那个
if (fs.existsSync(platformModulePath)) {
return platformModulePath
}
}
}
// handle some omit situations
for (const plat of platformExts) {
for (const omitExt of omitExtensions) {
const platformModulePath = `${modulePath}${plat}${omitExt}`
if (fs.existsSync(platformModulePath)) {
return platformModulePath
}
}
}
// it is lookup in directory and the file path not exists, then return origin module path
if (isDirectory) {
return path.dirname(modulePath) // modulePath.replace(/\/index$/, '')
}
// handle the directory index file
const moduleIndexPath = path.join(modulePath, 'index')
return lookup(moduleIndexPath, platform, true)
}
- 对于 单个样式文件里使用 不同平台的兼容性样式 无法处理, Taro 这里引入了 条件编译的方式进行 处理, 处理方式如下:
/* #ifdef %PLATFORM% */
样式代码
/* #endif */
/* #ifndef %PLATFORM% */
样式代码
/* #endif */
Taro 使用postcss的插件,通过css.walkComments遍历注释 方式 判断是否截取注释内部的有效代码
//packages/postcss-pxtransform/index.js
/* #ifdef %PLATFORM% */
// 平台特有样式
/* #endif */
css.walkComments(comment => {
const wordList = comment.text.split(' ')
// 指定平台保留
if (wordList.indexOf('#ifdef') > -1) {
// 非指定平台
if (wordList.indexOf(options.platform) === -1) {
let next = comment.next()
// 循环取到下一行内容 直接remove,直到遇到#endif 为止
while (next) {
if (next.type === 'comment' && next.text.trim() === '#endif') {
break
}
const temp = next.next()
next.remove()
next = temp
}
}
}
})
/* #ifndef %PLATFORM% */
// 平台特有样式
/* #endif */
css.walkComments(comment => {
const wordList = comment.text.split(' ')
// 指定平台剔除
if (wordList.indexOf('#ifndef') > -1) {
// 指定平台
if (wordList.indexOf(options.platform) > -1) {
let next = comment.next()
// 循环取到下一行内容 直接remove,直到遇到#endif 为止
while (next) {
if (next.type === 'comment' && next.text.trim() === '#endif') {
break
}
const temp = next.next()
next.remove()
next = temp
}
}
}
})
总结
- 优点
简单易懂,对于前端开发者比较亲切,都是比较传统的概念,易于理解
- 缺点
- 在ts/js代码中会有大量 if/else 充斥其中,后期变得维护困难
- 遇到 条件使用 外部npm 包的时候需要是用到 require, 无法使用import, 对于tree-shaking会失效(tree-shaking的消除原理是依赖于ES6的模块特性)
Chameleon
Chameleon
提出多态协议的概念,通过多态接口/多态组件/多态模版/样式多态
四种类型来区分多平台侧的适配。
- 多台接口
<script cml-type="wx">
class Method implements UtilsInterface {
getMsg(msg) {
return 'wx:' + msg;
}
}
export default new Method();
</script>
- 多态组件
<template c-else-if="{{ENV === 'wx'}}">
// 假设wx-list 是微信小程序原生的组件
<wx-list data="{{list}}"></wx-list>
</template>
- 多态模版
<template class="demo-com">
<cml type="wx">
<view>wx端以这段代码进行渲染</view>
<demo-com ></demo-com>
</cml>
<cml type="base">
<view
>如果找不到对应端的代码,则以type='base'这段代码进行渲染,比如这段代码会在web端进行渲染</view
>
<demo-com ></demo-com>
</cml>
</template>
- 样式多态
<style>
@media cml-type (支持的平台) {
}
.common {
/**/
}
<style>
源码核心库:
这里我们只需要关注 Chameleon 将 代码转化成 DSL协议进行多平台条件判断,利用 babel 转化为 ast 语法树,在对 ast 语法树解析的过程中,对于每个节点通过 tapable 控制该节点的处理方式,比如标签解析、样式语法解析、循环语句、条件语句、原生组件使用、动态组件解析等,达到适配不同端的需求,各端适配互相独立,互不影响,支持快速适配多端。CML的模板解析的整体架构如下图所示
// packages/chameleon-template-parse/src/common/process-template.js
/* 提供给 chameleon-loader 用于删除多态模板多其他端的不用的代码
@params:source 模板内容
@params:type 当前要编译的平台,用于截取多态模板
@params:options needTranJSX 需要转化为jsx可以解析的模板;needDelTemplate 需要删除template节点
*/
exports.preParseMultiTemplate = function(source, type, options = {}) {
try {
if (options.needTranJSX) { // 当调用这个方法之前没有事先转义jsx,那么就需要转义一下
let callbacks = ['preDisappearAnnotation', 'preParseGtLt', 'preParseBindAttr', 'preParseVueEvent', 'preParseMustache', 'postParseLtGt'];
source = exports.preParseTemplateToSatisfactoryJSX(source, callbacks);
}
let isEmptyTemplate = false;
const ast = babylon.parse(source, {
plugins: ['jsx']
})
traverse(ast, {
enter(path) {
let node = path.node;
if (t.isJSXElement(node) && (node.openingElement.name && typeof node.openingElement.name.name === 'string' && node.openingElement.name.name === 'template')) {
path.stop();// 不要在进行子节点的遍历,因为这个只需要处理template
let {hasCMLTag, hasOtherTag, jsxElements} = exports.checkTemplateChildren(path);
if (hasCMLTag && hasOtherTag) {
throw new Error('多态模板里只允许在template标签下的一级标签是cml');
}
if (hasCMLTag && !hasOtherTag) {// 符合多态模板的结构格式
let currentPlatformCML = exports.getCurrentPlatformCML(jsxElements, type);
if (currentPlatformCML) {
currentPlatformCML.openingElement.name.name = 'view';
// 这里要处理自闭和标签,没有closingElement,所以做个判断;
currentPlatformCML.closingElement && (currentPlatformCML.closingElement.name.name = 'view');
node.children = [currentPlatformCML];
if (options.needDelTemplate) { // 将template节点替换成找到的cml type 节点;
path.replaceWith(currentPlatformCML)
}
} else {
// 如果没有写对应平台的 cml type='xxx' 或者 cml type='base',那么报错
throw new Error('没有对应平台的模板或者基础模板')
}
} else { // 不是多态模板
// 注意要考虑空模板的情况
if (options.needDelTemplate && jsxElements.length === 1) { // 将template节点替换成找到的cml type 节点;
path.replaceWith((jsxElements[0]));
} else {
isEmptyTemplate = true;
}
}
}
}
});
// 这里注意,每次经过babel之后,中文都需要转义过来;
if (isEmptyTemplate) {
return '';
}
source = exports.postParseUnicode(generate(ast).code);
if (/;$/.test(source)) { // 这里有个坑,jsx解析语法的时候,默认解析的是js语法,所以会在最后多了一个 ; 字符串;但是在 html中 ; 是无法解析的;
source = source.slice(0, -1);
}
return source;
} catch (e) {
console.log('preParseMultiTemplate', e)
}
}
这里进行ast 代码分析,将其他非对应平台的代码进行删除处理,确保打包到对应平台的代码纯净性。 而 对 样式多态的处理,直接使用正则匹配判断是否需要进行样式删除,然后 循环迭代方式截取对用平台样式,删除不需要的样式!!#ff0000 (代码里有详细的算法说明)!!
// packages/chameleon-css-loader/parser/media.js
module.exports = function parse(source = '', targetType) {
let reg = /@media\s*cml-type\s*\(([\w\s,]*)\)\s*/g;
if (!reg.test(source)) {
return source;
}
reg.lastIndex = 0;
/**
* 假如:输入是 @media cml-type(wx) {
body {
}
}
*
*/
while (true) { // eslint-disable-line
// 找到样式里所有 @media cml-type(wx) 这种类型的样式,知道全部被替换掉为止
let result = reg.exec(source);
if (!result) {break;}
let cmlTypes = result[1] || '';
cmlTypes = cmlTypes.split(',').map(item => item.trim());
let isSave = ~cmlTypes.indexOf(targetType);
let startIndex = result.index; // @media的开始
let currentIndex = source.indexOf('{', startIndex); // 从第一个@media开始
let signStartIndex = currentIndex; // 第一个{的位置
if (currentIndex == -1) {
throw new Error("@media cml-type format err");
}
let signStack = []; // 存放 { 的个数
signStack.push(0);
/*
校验 @media cml-type(wx) {} 是否书写正确, 并找出@media {} 的位置,匹配到的最后一个},
第一轮循环: index1 和 index2 都不是 -1, index 取小的那个就是body 后面那个,然后currenIndex 和 sign 取到的是 {
signStack.push(1) 继续循环 signStack = [0, 1]
第二轮循环, currentIndex 从 body { 下一个字符开始, index1 为 -1, index2 匹配到 body { 后面的 }不是 -1,index 取大的那个就是 body 闭合的}
currenIndex 和 sign 取到的是 body 后第一个 }的位置,然后 signStack 就pop一个处理 与 } 匹配
此时 signStack 还有一个 0, 表示 还有一个 @media cml-type(wx) 后面的 { 没有被循环掉,继续循环 signStack = [0]
第三轮循环。 currentIndex 从 body { } 下一个字符开始, index1 为 -1, index2 匹配到 body {} 后面的 } 不是 -1,
index 取大的那个就是 body{} 后面那个 } 可以与@media后的 { 闭合
currenIndex 和 sign 取到的是 body {} 后第一个 }的位置,然后 signStack 就pop一个 ,处理 与 } 匹配
此时 signStack 为 [], 刚好匹配成功,停止循环
这里其实可以优化 signStack 可以使用 数字替代,初始化 signStack = 1, 匹配一个{ push 表示signStack + 1,pop 表示 signStack - 1
直到signStack = 0 为止便找到最后一个 } 没必要使用数组存储数据
*/
while (signStack.length > 0) {
let index1 = source.indexOf('{', currentIndex + 1); // { 下一个位置
let index2 = source.indexOf('}', currentIndex + 1); // } 下一个位置
let index;
// 都有的话 index为最前面的
if (index1 !== -1 && index2 !== -1) {
index = Math.min(index1, index2);
} else {
index = Math.max(index1, index2);
}
if (index === -1) {
throw new Error("@media cml-type format err");
}
let sign = source[index];
currentIndex = index; // 经过循环会取到最后一个 @media cml-type(wx) {} 的 } 的位置
if (sign === '{') {
signStack.push(signStack.length);
} else if (sign === '}') {
signStack.pop();
}
}
// 操作source
if (isSave) { // 保存的@media
var sourceArray = Array.from(source);
/**
* Array.splice( index, remove_count, item_list )
* startIndex @media的开始, currentIndex - startIndex + 1 表示 @media {...} 里全部的数量
* source.slice(signStartIndex + 1, currentIndex) 取到 {} 内的内容进行填充
*/
sourceArray.splice(startIndex, currentIndex - startIndex + 1, source.slice(signStartIndex + 1, currentIndex));
source = sourceArray.join('');
} else { // 删除的
/**
* source.slice(0, startIndex) 取到@media {...} 之前的内容
* source.slice(currentIndex + 1) 取到@media {...} 之后的内容
*/
source = source.slice(0, startIndex) + source.slice(currentIndex + 1);
}
reg.lastIndex = 0;
}
return source;
}
总结
- 优点
- 代码隔离比较清晰,不会造成代码污染
- 编译后实现强隔离,不会有无用代码引入
- 缺点
- 自研多态协议,前端新概念,上手有门槛
- 基于底层的 DSL 解析,以及AST 语法分析处理,底层架构依赖性 比较强
uniapp
uniapp
通过 API/组件/样式的 条件编译
实现对于不同平台的适配工作
- API 条件编译
// #ifdef ** %PLATFORM%**
平台特有的API实现
// #endif
- 组件条件编译
<!-- #ifdef ** %PLATFORM%** -->
平台特有的组件
<!-- #endif -->
- 样式条件编译
/* #ifdef ** %PLATFORM% ** */
平台特有样式
/* #endif */
uniapp 内部利用 XRegExp 正则库 正则匹配 文件字符串 循环取出对应平台的内容,删除其他平台的内容。 XRegExp 提供增强的和可扩展的 JavaScript 正则表达式。地址是:github.com/slevithan/x… 使用到的条件判断正则如下:
//packages/uni-cli-shared/lib/preprocess/lib/regexrules.js
js : {
if : {
start : "[ \t]*(?://|/\\*)[ \t]*#(ifndef|ifdef|if)[ \t]+([^\n*]*)(?:\\*(?:\\*|/))?(?:[ \t]*\n+)?",
end : "[ \t]*(?://|/\\*)[ \t]*#endif[ \t]*(?:\\*(?:\\*|/))?(?:[ \t]*\n)?"
},
...
}
}
具体实现可参考 uniApp条件编译原理探索
uni-app 中封装了很多包,在master 仓库中 条件编译 的包是 webpack-preprocess-loader,而next 版本使用 packages/uni-cli-shared/lib/preprocess/lib/preprocess.js
直接在 编译阶段处理,master 中使用webpack 插件的方式进行条件编译处理,而next 版本中提前处理后,可切换到vite 等下一代打包工具中,体验到开发环境 秒级的开发体验
总结
- 优点
- 操作简单,有过C语言开发经验的比较亲切,隔离作用强,不会造成代码污染
- 编译阶段处理后,能 对 import 方式也做 条件编译
- 缺点
-
有一定的代码侵入
感谢大家浪费宝贵的时间,阅读到这里,作为一个全面性的多端构建框架, 自然也要面临多平台的适配工作,尽管框架已经从底层帮助开发者解决了大部分的跨平台的兼容工作,然后 瓜无滚圆, 金无足赤, 遇到一些 平台特性的问题,仍需要开发者去按需适配。 以上是 差异性实现的全部内容,有不当之处,请批评指正。。。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!