扒一扒 小程序跨平台框架Taro/Chamelon/uniApp 多端差异性实现

2,951 阅读10分钟

预备

这里先看一下 这三个平台对于跨平台适配的描述

  • 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的源码

  1. 首先我们使用 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 + 注册插件 + 生命周期钩子函数 的实现方式,灵活的实现了各个不同的命令组合

  1. 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)
  }

image.png 这里可以看到 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 里是在哪里实现这些操作呢?

  1. 定义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']
  1. 判断编译平台,优先选择对应平台的 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' &amp;&amp; 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' &amp;&amp; next.text.trim() === '#endif') {
              break
            }
            const temp = next.next()
            next.remove()
            next = temp
          }
        }
      }
    })

总结

  • 优点 简单易懂,对于前端开发者比较亲切,都是比较传统的概念,易于理解

  • 缺点

  1. 在ts/js代码中会有大量 if/else 充斥其中,后期变得维护困难
  2. 遇到 条件使用 外部npm 包的时候需要是用到 require, 无法使用import, 对于tree-shaking会失效(tree-shaking的消除原理是依赖于ES6的模块特性)

Chameleon

Chameleon 提出多态协议的概念,通过多态接口/多态组件/多态模版/样式多态 四种类型来区分多平台侧的适配。

  1. 多台接口
<script cml-type="wx">
class Method implements UtilsInterface {
 getMsg(msg) {
   return 'wx:' + msg;
 }
}
export default new Method();
</script>
  1. 多态组件
<template c-else-if="{{ENV === 'wx'}}">
  // 假设wx-list 是微信小程序原生的组件
  <wx-list data="{{list}}"></wx-list>
</template>
  1. 多态模版
<template class="demo-com">
  <cml type="wx">
    <view>wx端以这段代码进行渲染</view>
    <demo-com title="我是标题wx"></demo-com>
  </cml>
  <cml type="base">
    <view
      >如果找不到对应端的代码,则以type='base'这段代码进行渲染,比如这段代码会在web端进行渲染</view
    >
    <demo-com title="我是标题base"></demo-com>
  </cml>
</template>
  1. 样式多态
<style>
@media cml-type (支持的平台) {

}
.common {
  /**/
}
<style>

源码核心库:

image.png

这里我们只需要关注 Chameleon 将 代码转化成 DSL协议进行多平台条件判断,利用 babel 转化为 ast 语法树,在对 ast 语法树解析的过程中,对于每个节点通过 tapable 控制该节点的处理方式,比如标签解析、样式语法解析、循环语句、条件语句、原生组件使用、动态组件解析等,达到适配不同端的需求,各端适配互相独立,互不影响,支持快速适配多端。CML的模板解析的整体架构如下图所示

image.png

// 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) &amp;&amp; (node.openingElement.name &amp;&amp; typeof node.openingElement.name.name === 'string' &amp;&amp; node.openingElement.name.name === 'template')) {
          path.stop();// 不要在进行子节点的遍历,因为这个只需要处理template
          let {hasCMLTag, hasOtherTag, jsxElements} = exports.checkTemplateChildren(path);
          if (hasCMLTag &amp;&amp; hasOtherTag) {
            throw new Error('多态模板里只允许在template标签下的一级标签是cml');
          }
          if (hasCMLTag &amp;&amp; !hasOtherTag) {// 符合多态模板的结构格式
            let currentPlatformCML = exports.getCurrentPlatformCML(jsxElements, type);
            if (currentPlatformCML) {
              currentPlatformCML.openingElement.name.name = 'view';
              // 这里要处理自闭和标签,没有closingElement,所以做个判断;
              currentPlatformCML.closingElement &amp;&amp; (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 &amp;&amp; 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 &amp;&amp; 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;

}

总结

  • 优点
  1. 代码隔离比较清晰,不会造成代码污染
  2. 编译后实现强隔离,不会有无用代码引入
  • 缺点
  1. 自研多态协议,前端新概念,上手有门槛
  2. 基于底层的 DSL 解析,以及AST 语法分析处理,底层架构依赖性 比较强

uniapp

uniapp 通过 API/组件/样式的 条件编译实现对于不同平台的适配工作

  1. API 条件编译
// #ifdef **  %PLATFORM%** 
平台特有的API实现
// #endif
  1. 组件条件编译
<!--  #ifdef **  %PLATFORM%**  -->
平台特有的组件
<!--  #endif -->
  1. 样式条件编译
/*  #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 等下一代打包工具中,体验到开发环境 秒级的开发体验

总结

  • 优点
  1. 操作简单,有过C语言开发经验的比较亲切,隔离作用强,不会造成代码污染
  2. 编译阶段处理后,能 对 import 方式也做 条件编译
  • 缺点
  1. 有一定的代码侵入

    感谢大家浪费宝贵的时间,阅读到这里,作为一个全面性的多端构建框架, 自然也要面临多平台的适配工作,尽管框架已经从底层帮助开发者解决了大部分的跨平台的兼容工作,然后 瓜无滚圆, 金无足赤, 遇到一些 平台特性的问题,仍需要开发者去按需适配。 以上是 差异性实现的全部内容,有不当之处,请批评指正。。。