引言
因为公司业务有好多个 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');
正则可视化网址:regexper.com
源码解析
在 loader 入口文件 index.js 中有
content = preprocessor.preprocess(content, context, {
type
})
- content 就是文件内容,是字符串
- context 是个对象,定义不同端的变量,打包时根据变量进行打包比如 APP H5
- type 需要处理字符串的语法类型,有 js、html 和 coffee
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
}
函数主要功能就是按顺序 include 、 extend 、foreach 、 exclude 、if 、 echo 、 exec 、 include-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 创建 start 和 end 对应的正则。 也就是 regexrules.js 中 js 对象中的 start 和 end
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 方法。
此方法返回的是根据 start 和 end 作为定界符,匹配到的结果集合。
我们抽离事例单独看一下它的执行结果:
<!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>
浏览器打印出的结果如图:
- left 属性是正则 start 的匹配结果
- right 属性是正则 end 的匹配结果
- match 是处在正则 start 和 end 中间的内容
- between 是正则 start 前的内容
这里也打印出 startMatches 和 endMatches 它是回调函数的参数。
我们在回头看一下 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 + '.')
}
})
我们上面已经确定 startMatches 和 endMatches的内容。
这样 变量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 值的值。
比如 test 是 H5,context 是对象 {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:h5 或 npm run dev:app
欢迎关注微信公众号:闹闹前端