uniApp条件编译原理探索

3,480 阅读8分钟

引言

因为公司业务有好多个 APP,还有 H5 页面,不同的端,模块展示可能不同,但是大部分功能又是相同的。而且不同的 APP 提供的接口也不一样。

如果通过简单的 if...else... 判断不同端,调用相应的 API 或 展示相应的模块,那随着时间和业务的发展,终有一天会造成代码混乱,不好维护。

最重要的一点是在某特定的端上,存在大量的其他端的冗余代码,会增加文件体积,影响加载速度以及性能。

最好的方式就是可以根据端,打包出的代码只有和这个端有关系的代码,无其他冗余代码,这样干净也便于以后的维护。

这样就想到了 条件编译uni-app 跨段框架就使用了 条件编译

方案也是从 uni-app 中来的。

本文就详细看看 uni-app 中的 条件编译 的实现原理。

webpack-preprocess-loader

uni-app 中封装了很多包,条件编译 的包是 webpack-preprocess-loader

它的位置在 github.com/dcloudio/un…

它是 webpack 的一个 loader

看一下它的入口文件 index.js

// 省略部分代码...

// 导入条件编译处理器
const preprocessor = require('./preprocess/lib/preprocess')

// 省略部分代码...

module.exports = function (content, map) {
  this.cacheable && this.cacheable()

  let types = utils.getOptions(this).type || 'js'

  const context = utils.getOptions(this).context || {}

  if (!Array.isArray(types)) {
    types = [types]
  }
  const resourcePath = this.resourcePath
  types.forEach(type => {
    try {
      // 对资源进行处理
      content = preprocessor.preprocess(content, context, {
        type
      })
    } catch (e) {
      // 省略部分代码...
    }
  })
  this.callback(null, content, map)
}

核心代码只有一行

content = preprocessor.preprocess(content, context, {
    type
})

preprocessor 就是 条件编译 处理器。

preprocess

uni-app 使用的 preprocessor 其实是修改第三方包 preprocess,它的地址是 **github.com/jsoverson/p…

preprocess条件编译 注释是以 @ 开头,uni-app 改成以 # 开头。

所有的正则都在 regexrules.js 文件中,以 uni-app 修改后的代码为例

module.exports = {
  simple: {
    // ... 省略代码
  },
  html: {
    
    // ... 省略代码
    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)?'
    },
    // ... 省略代码
  },
  coffee: {
    // ... 省略代码
  }
}

上面只列举出了 if 条件的正则,也就是类似下方写法

// #ifdef H5
import h5 from './h5';
// #endif
{/* #ifdef H5 */}
<div className='h5'>{h5()}</div>
{/* #endif */}

具体的正则就不详细分析了,这里给出 if start正则的可视化图

new RegExp('[ \t]*(?://|/\\*)[ \t]*#(ifndef|ifdef|if)[ \t]+([^\n*]*)(?:\\*(?:\\*|/))?(?:[ \t]*\n+)?', 'gmi');

image

正则可视化网址:regexper.com

源码解析

loader 入口文件 index.js 中有

content = preprocessor.preprocess(content, context, {
    type
})
  • content 就是文件内容,是字符串
  • context 是个对象,定义不同端的变量,打包时根据变量进行打包比如 APP H5
  • type 需要处理字符串的语法类型,有 jshtmlcoffee

preprocess 方法的参数可以参考 github.com/jsoverson/p… 的说明。

preprocess 方法

function preprocess (src, context, typeOrOptions) {
  // 强制待处理的内容是字符串
  src = src.toString()
  context = context || process.env

  // 默认的配置项
  var options = {
    fileNotFoundSilentFail: false,
    srcDir: process.cwd(),
    srcEol: getEolType(src),
    type: delim['html']
  }

  // needed for backward compatibility with 2.x.x series
  if (typeof typeOrOptions === 'string') {
    typeOrOptions = {
      type: typeOrOptions
    }
  }

  // needed for backward compatibility with 2.x.x series
  if (typeof context.srcDir === 'string') {
    typeOrOptions = typeOrOptions || {}
    typeOrOptions.srcDir = context.srcDir
  }
  // 处理 options 
  if (typeOrOptions && typeof typeOrOptions === 'object') {
    options.srcDir = typeOrOptions.srcDir || options.srcDir
    options.fileNotFoundSilentFail = typeOrOptions.fileNotFoundSilentFail || options.fileNotFoundSilentFail
    options.srcEol = typeOrOptions.srcEol || options.srcEol
    options.type = delim[typeOrOptions.type] || options.type
  }
  
  context = copy(context)
  // 调用处理器
  return preprocessor(src, context, options)
}

此函数功能很简单,首先开头调用 toString 方法,保证待处理的内容为字符串。

其次就是处理 options,如果某些配置项没传入就使用默认配置项。

最后调用 preprocessor 方法进入处理。

preprocessor

function preprocessor (src, context, opts, noRestoreEol) {
  src = normalizeEol(src)

  var rv = src

  // 处理对应语法类型中的 include 
  rv = replace(rv, opts.type.include, processIncludeDirective.bind(null, false, context, opts))
  // 处理对应语法类型中的 include 
  if (opts.type.extend) {
    rv = replaceRecursive(rv, opts.type.extend, function (startMatches, endMatches, include, recurse) {
      // 省略代码...
    })
  }
  // 处理对应语法类型中的 foreach 
  if (opts.type.foreach) {
    rv = replaceRecursive(rv, opts.type.foreach, function (startMatches, endMatches, include, recurse) {
      // 省略代码...
    })
  }
  // 处理对应语法类型中的 exclude 
  if (opts.type.exclude) {
    rv = replaceRecursive(rv, opts.type.exclude, function (startMatches, endMatches, include, recurse) {
      // 省略代码...
    })
  }
  // 处理对应语法类型中的 if 这里重点 
  if (opts.type.if) {
    rv = replaceRecursive(rv, opts.type.if, function (startMatches, endMatches, include, recurse) {
      var variant = startMatches[1]
      var test = (startMatches[2] || '').trim()
      switch (variant) {
        case 'if':
        case 'ifdef':
          return testPasses(test, context) ? (padContent(startMatches.input) + recurse(include) + padContent(endMatches.input)) : padContent(startMatches.input + include + endMatches.input)
        case 'ifndef':
          return !testPasses(test, context) ? (padContent(startMatches.input) + recurse(include) + padContent(endMatches.input)) : padContent(startMatches.input + include + endMatches.input)
        default:
          throw new Error('Unknown if variant ' + variant + '.')
      }
    })
  }
  // 处理对应语法类型中的 echo 
  rv = replace(rv, opts.type.echo, function (match, variable) {
    // 省略代码...
  })
  // 处理对应语法类型中的 exec 
  rv = replace(rv, opts.type.exec, function (match, name, value) {
    // 省略代码...
  })
  // 处理对应语法类型中的 include-static 
  rv = replace(rv, opts.type['include-static'], processIncludeDirective.bind(null, true, context, opts))

  if (!noRestoreEol) {
    rv = restoreEol(rv, opts.srcEol)
  }
  // 返回结果
  return rv
}

函数主要功能就是按顺序 includeextendforeachexcludeifechoexecinclude-static 进行处理,而这些都是定义在 regexrules.js 文件中的,它们都是正则。

这里重点看一下 if , 因为业务中用的最多的也是它。

replaceRecursive

if 调用了 replaceRecursive 方法。

function replaceRecursive (rv, rule, processor) {
  if (!rule.start || !rule.end) {
    throw new Error('Recursive rule must have start and end.')
  }

  var startRegex = new RegExp(rule.start, 'mi')
  var endRegex = new RegExp(rule.end, 'mi')

  function matchReplacePass (content) {
    var matches = XRegExp.matchRecursive(content, rule.start, rule.end, 'gmi', {
      valueNames: ['between', 'left', 'match', 'right']
    })

    var matchGroup = {
      left: null,
      match: null,
      right: null
    }

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

函数功能就分两部分: 第一部分,用 new RegExp 创建 startend 对应的正则。 也就是 regexrules.jsjs 对象中的 startend

js: {
    // ... 省略代码
    if: {
      start: '[ \t]*(?://|/\\*)[ \t]*#(ifndef|ifdef|if)[ \t]+([^\n*]*)(?:\\*(?:\\*|/))?(?:[ \t]*\n+)?',
      end: '[ \t]*(?://|/\\*)[ \t]*#endif[ \t]*(?:\\*(?:\\*|/))?(?:[ \t]*\n)?'
    },
    // ... 省略代码
  }

第二部分就是调用 matchReplacePass 函数并返回执行的结果。它是调用了 XRegExp 库,对正则进行匹配扩展。

XRegExp 库

此库提供增强的和可扩展的 JavaScript 正则表达式。地址是:github.com/slevithan/x…

matchReplacePass 使用的是 XRegExp.matchRecursive 方法。

此方法返回的是根据 startend 作为定界符,匹配到的结果集合。

我们抽离事例单独看一下它的执行结果:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script src="https://unpkg.com/xregexp/xregexp-all.js"></script>
  <script>
    let rule = {
      start: '[ \t]*(?://|/\\*)[ \t]*#(ifndef|ifdef|if)[ \t]+([^\n*]*)(?:\\*(?:\\*|/))?(?:[ \t]*\n+)?',
      end: '[ \t]*(?://|/\\*)[ \t]*#endif[ \t]*(?:\\*(?:\\*|/))?(?:[ \t]*\n)?'
    }
    let startRegex = new RegExp(rule.start, 'mi');
    let endRegex = new RegExp(rule.end, 'mi');
    let content = `
      // #ifdef H5
      import h5 from './h5';
      // #endif

      // #ifdef APP
      import app from './app';
      // #endif
    `;
    var matches = XRegExp.matchRecursive(content, rule.start, rule.end, 'gmi', {
      valueNames: ['between', 'left', 'match', 'right']
    });
    console.log(matches)

    var matchGroup = {
      left: null,
      match: null,
      right: null
    }

    matches.reduce(function (builder, match) {
      switch (match.name) {
        case 'between':
          builder += match.value
          break
        case 'left':
          matchGroup.left = startRegex.exec(match.value)
          console.log('startMatches', startRegex.exec(match.value))
          break
        case 'match':
          matchGroup.match = match.value
          break
        case 'right':
          matchGroup.right = endRegex.exec(match.value)
          console.log('endMatches', endRegex.exec(match.value))
          break
      }
      return builder
    }, '')
  </script>
</body>
</html>

浏览器打印出的结果如图: image

  • left 属性是正则 start 的匹配结果
  • right 属性是正则 end 的匹配结果
  • match 是处在正则 startend 中间的内容
  • between 是正则 start 前的内容

这里也打印出 startMatchesendMatches 它是回调函数的参数。

我们在回头看一下 matchReplacePass 方法功能,它就是根据数组中每个对象的 name 属性的值做了处理。

  • between 就字符串拼接起来
  • left 用 start 正则执行 exec
  • match 匹配中间的内容
  • right 用 end 正则执行 exec,并调用回调函数,回调函数的执行结果在和之前的字符串拼接起来。

好了,我们可以回到 if 调用 replaceRecursive 函数传递的回调函数体了,看它主要的功能。

条件编译具体实现

条件编译 决定代码块是保留还是删除就是在 replaceRecursive 函数传递的回调函数中实现的。

function (startMatches, endMatches, include, recurse) {
  var variant = startMatches[1]
  var test = (startMatches[2] || '').trim()
  switch (variant) {
    case 'if':
    case 'ifdef':
      return testPasses(test, context) ? (padContent(startMatches.input) + recurse(include) + padContent(endMatches.input)) : padContent(startMatches.input + include + endMatches.input)
    case 'ifndef':
      return !testPasses(test, context) ? (padContent(startMatches.input) + recurse(include) + padContent(endMatches.input)) : padContent(startMatches.input + include + endMatches.input)
    default:
      throw new Error('Unknown if variant ' + variant + '.')
  }
})

我们上面已经确定 startMatchesendMatches的内容。

这样 变量test 的值就是 H5 或者 APP

testPasses 函数源码如下

function getTestTemplate (test) {
  /* jshint evil:true */
  test = test || 'true'
  test = test.trim()

  // force single equals replacement
  test = test.replace(/([^=!])=([^=])/g, '$1==$2')
  //fixed by xxxxxx
  test = test.replace(/-/g,'_')
  /* eslint-disable no-new-func */
  return new Function('context', 'with (context||{}){ return ( ' + test + ' ); }')
}

function testPasses (test, context) {
  var testFn = getTestTemplate(test)
  try{
    return testFn(context, getDeepPropFromObj)
  }catch(e){}
  return false
}

它就是从 context 获取属性名为 test 值的值。

比如 testH5context 是对象 {H5: true, APP: false}。

最终的结果等价于 context.H5

如果 testPasses(test, context)true 则执行 padContent(startMatches.input) + recurse(include) + padContent(endMatches.input)

padContent 函数源码更简单了,但是它是决定是否保留代码的关键。

var splitRE = /\r?\n/g
function padContent (content) {
  let arr = content.split(splitRE);
  let result = Array(arr.length).join('\n');
  return result;
}

第一步:根据换行符把字符串拆分为数组

第二步:根据拆分后数组的长度创建新数组并按换行符拼接为字符串,这是重点

第三步:返回拼接后的字符串

关键在第二步,它只是根据拆分后的数组长度新创建了一个新数组,新数组的每个成员都是空,并无内容,然后在拼接。这样就把 content 删除了。

padContent(startMatches.input) 传递的参数是 // #ifdef H5\n 最终的结果只是两个 \n\n

padContent(endMatches.input) 传递的参数是 // #endif\n 最终的结果也是两个 \n\n

recurse(include)import h5 from './h5

padContent(startMatches.input) + recurse(include) + padContent(endMatches.input) 的最后结果就是 \n\nimport h5 from './h5\n\n

如果testPasses(test, context)false 则执行 padContent(startMatches.input + include + endMatches.input)

padContent 函数会把传递的参数丢弃转换为换行符。这样如果不是对应的环境就丢弃对应的代码,达到条件编译的功能。

结语

条件编译对于多端情况是一个很好的解决方案。

它能解决适配的问题,同时还没有冗余代码。

对于padContent实现,虽然简单但是很有创意,以后工作可以借鉴一下。

最后给出 webpack 的配置

// webpack.config.js
const path = require('path');
module.exports = {
  mode: 'development',
  entry: './src/index.jsx',
  output: {
    path: path.resolve(__dirname, 'dist', process.env.PLATFORM || 'h5'),
    filename: 'main.js'
  },
  devServer: {
    contentBase: path.join(__dirname, 'dist', process.env.PLATFORM || 'h5')
  },
  resolve: {
    extensions: ['.jsx', '.js', '.json', '.css']
  },
  resolveLoader: {// 配置查找loader的目录
    modules: [
      'node_modules',
      path.resolve(__dirname, 'loaders')
    ]
  },
  module: {
    rules: [

      {
        test: /\.js|jsx$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-react']
            }
          },
          {
            loader: 'preprocess-loader',
            options: {
              context: {
                H5: process.env.PLATFORM === 'h5',
                APP: process.env.PLATFORM === 'app'
              }
            }
          }
        ]
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader'
          },
          {
            // 在项目根目录里创建loaders目录,并有子目录preprocess-loader
            loader: 'preprocess-loader',
            options: {
              context: {
                H5: process.env.PLATFORM === 'h5',
                APP: process.env.PLATFORM === 'app'
              }
            }
          }
        ]
      }

    ]
  }
}
// package.json
{
  "name": "naonao",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "webpack serve",
    "build:h5": "cross-env PLATFORM=h5 webpack",
    "build:app": "cross-env PLATFORM=app webpack",
    "dev:h5": "cross-env PLATFORM=h5 webpack serve",
    "dev:app": "cross-env PLATFORM=app webpack serve"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "7.9.0",
    "@svgr/webpack": "4.3.3",
    "@typescript-eslint/eslint-plugin": "^2.10.0",
    "@typescript-eslint/parser": "^2.10.0",
    "babel-eslint": "10.1.0",
    "babel-jest": "^24.9.0",
    "babel-loader": "8.1.0",
    "babel-plugin-named-asset-import": "^0.3.6",
    "babel-preset-react-app": "^9.1.2",
    "camelcase": "^5.3.1",
    "case-sensitive-paths-webpack-plugin": "2.3.0",
    "cross-env": "^7.0.3",
    "css-loader": "3.4.2",
    "dotenv": "8.2.0",
    "dotenv-expand": "5.1.0",
    "eslint": "^6.6.0",
    "eslint-config-react-app": "^5.2.1",
    "eslint-loader": "3.0.3",
    "eslint-plugin-flowtype": "4.6.0",
    "eslint-plugin-import": "2.20.1",
    "eslint-plugin-jsx-a11y": "6.2.3",
    "eslint-plugin-react": "7.19.0",
    "file-loader": "4.3.0",
    "fs-extra": "^8.1.0",
    "fsevents": "2.1.2",
    "html-webpack-plugin": "4.0.0-beta.11",
    "identity-obj-proxy": "3.0.0",
    "jest": "24.9.0",
    "jest-environment-jsdom-fourteen": "1.0.1",
    "jest-resolve": "24.9.0",
    "jest-watch-typeahead": "0.4.2",
    "mini-css-extract-plugin": "0.9.0",
    "optimize-css-assets-webpack-plugin": "5.0.3",
    "pnp-webpack-plugin": "1.6.4",
    "postcss-flexbugs-fixes": "4.1.0",
    "postcss-loader": "3.0.0",
    "postcss-normalize": "8.0.1",
    "postcss-preset-env": "6.7.0",
    "postcss-safe-parser": "4.0.1",
    "react": "^17.0.1",
    "react-app-polyfill": "^1.0.6",
    "react-dev-utils": "^10.2.1",
    "react-dom": "^17.0.1",
    "resolve": "1.15.0",
    "resolve-url-loader": "3.1.2",
    "sass-loader": "8.0.2",
    "semver": "6.3.0",
    "style-loader": "0.23.1",
    "terser-webpack-plugin": "2.3.8",
    "ts-pnp": "1.1.6",
    "url-loader": "2.3.0",
    "webpack": "^5.24.3",
    "webpack-cli": "^4.5.0",
    "webpack-dev-server": "^3.11.2",
    "webpack-manifest-plugin": "2.2.0",
    "workbox-webpack-plugin": "4.3.1"
  }
}

在打包时运行对应平台的命令即可比如 npm run dev:h5npm run dev:app

欢迎关注微信公众号:闹闹前端