众所周知,小程序中不能定义全局组件,只能在每个页面手动引入,随着项目越来越大,这种方式无疑是繁琐和低效的,还有可能会忘记,所以今天我们来研究下通过工程化的方式自动注入。
本次内容准备了两个demo
- 实现自定义的toast(采用api的方式调用)
- 实现全局的、有状态的遮罩
开始前我们现看下taro将页面编译成了什么样子 源文件
可以发现,编译完后的wxml全部长得一样,taro会根据data决定渲染模版
如下图所示,我们先采用原生的方式实现,如下图
template
模版注入到base.wxml
(减少页面大小)- 每个页面中引入下
<template is="toast" data="{{__toast__}}" />
- 样式文件注入到
app.css
中 - 封装Toast类(采用
getCurrentPages
获取当前页面实例,调用setData)
<template name="toast">
<view class="my-toast {{__toast__.visible ? '' : 'my-toast__hidden'}}">
<image class="my-toast__icon {{__toast__.icon !== 'none' ? '' : 'my-toast__hidden'}}" src="{{__toast__.icon}}"></image>
<view class="my-toast__text">{{__toast__.title}}</view>
</view>
</template>
这里我们采用自定义Taro插件的形式实现注入。每次改动Taro都会重新编译所有页面,所以我们需要在每次编译完成后注入。
export default (ctx) => {
ctx.onBuildFinish(() => {
// 获取需要注入的页面
const pages = getNeedInjectPages();
// 注入template到base.wxml, wxss到app.css
injectToBase();
// 在需要注入的页面中引入
injectToPages(pages);
})
}
完整代码请参考这里
打开config/index.js
,引入
const config = {
...,
plugins: [
[
path.resolve(__dirname, './taro-plugin-inject-template'),
{
path: [
path.resolve(__dirname, '../', 'src/components/toast'),
],
},
],
],
...,
}
import { getCurrentPages, PageInstance } from '@tarojs/taro';
interface ToastShowConfig {
title: string;
icon?: string;
duration?: number;
}
type IPage = PageInstance & { setData: (data: Record<string, any>) => void };
class Toast {
private static instance: Toast;
private constructor() {}
public static getInstance(): Toast {
if (!this.instance) {
this.instance = new Toast();
}
return this.instance;
}
private getCurrentPage = () => {
const pages = getCurrentPages();
const curPage = pages[pages.length - 1] || {};
return (curPage as any) as IPage;
};
public show(config: ToastShowConfig) {
const { title, icon = '', duration = 2000 } = config;
const currentPage = this.getCurrentPage();
if (!currentPage) {
return;
}
currentPage.setData({ __toast__: { title, visible: true, icon } });
setTimeout(() => this.hide(), duration);
}
public hide() {
const currentPage = this.getCurrentPage();
if (!currentPage) {
return;
}
currentPage.setData({ __toast__: { visible: false, title: '', icon: '' } });
}
}
这样就可以在任意地方调用toast.show({ ... })
方法了
接着我们照葫芦画瓢实现第二个demo 我们想要实现的效果是,当小程序被禁用了所有页面都应改弹出遮罩禁止用户操作,但是从上图可以看到页面切换时遮罩就消失了,显然不是我们想要的效果。
为了解决这个问题我们可以监听路由变化,然后重新打开遮罩,但是翻遍微信开发文档也没有找到相关接口,最后在微信开发社区找到了相关api
wx.onAppRoute(() => {
const isDisabled = getGlobalState('isDisabled');
if (isDisabled) {
disable.show();
} else {
disable.hide();
}
});
- 不能做复杂的交互,比如点击事件;
- 要考虑兼容问题,h5、其他小程序平台;
hoc
import { View } from '@tarojs/components';
import { ComponentType } from 'react';
export default function hocD<T extends Record<string, any>>(
Comp: ComponentType<T>
) {
return function(props) {
return (
<>
<Comp {...props} />
<View
style={{
background: 'black',
color: 'white',
textAlign: 'center',
fontWeight: 'bold',
}}
>
HocD
</View>
</>
);
};
}
Demo页面
import { View } from '@tarojs/components';
import { FC } from 'react';
const Demo: FC<{}> = () => <View>Identifier</View>;
export default Demo
想要实现的效果
export default hocD(Demo)
页面组件及导出方式五花八门:
-
默认导出唯一标识符:Identifier
export default Demo;
-
默认导出函数声明:FunctionDeclaration
// 可以是匿名 export default function Demo() { return <View>demo</View>; }
-
默认导出箭头函数:ArrowFunctionExpression
export default () => { return <View>demo</View>; };
-
默认导出类声明:ClassDeclaration
export default class Demo extends Component { render() { return <View>demo</View>; } }
-
默认导出调用表达式:CallExpression
export default isLogin(Demo);
babel开发手册 AST Explorer 可以让你对 AST 节点有一个更好的感性认识。
还是用上边的例子,对应语法树为:
遍历所有ImportDeclaration
,分析是否有没有引入过hocD, 如果没有引入就插入一条ImportDeclaration
语句
let hasImport = false;
traverse(ast, {
// 查找是否有导入
ImportDeclaration(path) {
if (path.node.source.value === '@/hoc/hocD') {
hasImport = true;
}
},
});
if (!hasImport) {
traverse(ast, {
ImportDeclaration(path) {
if (!hasImport) {
path.insertBefore(
types.importDeclaration(
[types.importDefaultSpecifier(types.identifier('hocD'))],
types.stringLiteral('@/hoc/hocD')
)
);
hasImport = true;
}
},
});
}
现在导出类型为Identifier
我们需要修改成CallExpression
traverse(ast, {
ExportDefaultDeclaration(path) {
let callExpression: types.CallExpression;
if (!isInjected) {
switch (path.node.declaration.type) {
case 'Identifier': {
callExpression = types.callExpression(types.identifier('hocD'), [
types.identifier(path.node.declaration.name),
]);
break;
}
}
if (callExpression) {
path.replaceWith(types.exportDefaultDeclaration(callExpression));
isInjected = true;
}
}
},
});
搞清楚ast语法树后,接下来我们来编写loader,难度升级一下,支持注入多个高阶组件
export default function injectHocLoader(source: string) {
// @ts-ignore
const loaderContext = this;
/** 当前文件路径, 兼容windows */
const filePath = loaderContext.resourcePath.replace(/\\/g, '/');
/** 获取loader options */
const loaderOptions = (loaderUtils.getOptions(
loaderContext
) as unknown) as LoaderOptions;
// 校验loader options
validate(schema as Schema, loaderOptions, { name: PACKAGE_NAME });
/** 根据loader options筛选出需要注入的高阶组件 */
const hocList = utils.getNeedInjectHocByOptions(loaderOptions, filePath);
/** 生成语法树 */
const ast = utils.parseAstTree(source);
/** 根据语法树筛选出实际需要注入的hoc */
const needInjectHocList = utils.getNeedInjectHocByAst(ast, hocList);
if (
!needInjectHocList ||
(needInjectHocList && needInjectHocList.length === 0)
) {
/** 如果没有需要注入的直接return */
return source;
}
/** 是否引入 */
let isImported = false;
/** 是否注入 */
let isInjected = false;
traverse(ast, {
ImportDeclaration(path) {
/** 文件顶部插入声明, 注意这里会被循环调用,所以要判断是否引入过了 */
if (!isImported) {
isImported = true;
path.insertBefore(
needInjectHocList.map(item =>
types.importDeclaration(
[types.importDefaultSpecifier(types.identifier(item.name))],
types.stringLiteral(item.path)
)
)
);
}
},
ExportDefaultDeclaration(path) {
let callExpression;
if (!isInjected) {
switch (path.node.declaration.type) {
case 'Identifier': {
/**
* 处理export default Demo情况
*/
callExpression = utils.generateCallExpression(needInjectHocList, [
types.identifier(path.node.declaration.name),
]);
break;
}
case 'CallExpression': {
/**
* 处理export default isInit(Demo) 情况
*/
callExpression = utils.generateCallExpression(needInjectHocList, [
types.callExpression(
types.identifier((path.node.declaration.callee as any).name),
path.node.declaration.arguments
),
]);
break;
}
case 'FunctionDeclaration': {
/**
* 处理export default function(): JSX 情况
*/
callExpression = utils.generateCallExpression(needInjectHocList, [
types.functionExpression(
path.node.declaration.id,
path.node.declaration.params,
path.node.declaration.body
),
]);
break;
}
case 'ClassDeclaration': {
/**
* 处理export default class extends Component {} 情况
*/
callExpression = utils.generateCallExpression(needInjectHocList, [
types.classExpression(
path.node.declaration.id,
path.node.declaration.superClass,
path.node.declaration.body
),
]);
break;
}
case 'ArrowFunctionExpression': {
/**
* 处理export default () => <View>demo</View> 情况
*/
callExpression = utils.generateCallExpression(needInjectHocList, [
types.arrowFunctionExpression(
path.node.declaration.params,
path.node.declaration.body
),
]);
break;
}
}
if (callExpression) {
isInjected = true;
path.replaceWith(types.exportDefaultDeclaration(callExpression));
} else {
loaderContext.emitWarning(
`[inject-hoc-loader]: ${filePath} 注入失败, 类型${path.node.declaration.type}`
);
}
}
},
});
return generate(ast).code;
}
function generateCallExpression(
needInjectHocList: HocBase[] = [],
params: any[]
): CallExpression {
if (needInjectHocList.length === 1) {
return types.callExpression(
types.identifier(needInjectHocList[0].name),
params
);
}
return types.callExpression(types.identifier(needInjectHocList.pop()!.name), [
generateCallExpression(needInjectHocList, params),
]);
}
webpackChain(chain) {
chain.merge({
module: {
rule: {
injectHocLoader: {
test: /\.tsx$/,
use: [
{
loader: 'taro-inject-hoc-loader',
options: {
hoc: [
{
name: 'hocA',
path: '@/hoc/hocA',
isInject: filePath => /pages\/[0-9A-Za-z_-]+\/index\.tsx$/.test(filePath),
},
{
name: 'hocB',
path: '@/hoc/hocB',
isInject: filePath => /pages\/[0-9A-Za-z_-]+\/index\.tsx$/.test(filePath),
},
{
name: 'hocC',
path: '@/hoc/hocC',
isInject: filePath => /pages\/[0-9A-Za-z_-]+\/index\.tsx$/.test(filePath),
},
],
},
},
],
},
},
},
});
},
},
注入顺序如下
export default hocC(hocB(hocA(Demo)))
- 尽量不要在高阶组件里拦截页面渲染,会导致某些情况下
useReady
、useDidShow
之类的hooks
无法触发情况(纯react项目没有这个问题)