不定日拱卒-通过模板动态生成可用代码

1,075 阅读3分钟

不定日拱卒:分享日常开发过程中的一些小技巧,为更多人提供类似问题的解决方案

背景

  • 现代前端开发中,模块化已经成为主流

  • 一般情况下,我们引入其他模块是使用 es6 的 import 方法

import moduleA from './modules/moduleA.tsx';

moduleA.doSomething();
  • 在具体项目中,有时候我们的代码需要「通过变量拼接」动态引用其他的模块

  • import 语句不支持拼接路径

遇到的问题

  • 我们第一时间想到的就是通过 require 来实现需求,即以下方式
const moduleName = 'moduleA';
const aModule = require(`./modules/${moduleName}.tsx`).default;

aModule.doSomething();

测试后,页面效果是符合预期的

  • 然而,随着 ./modules 目录下文件的增加,我们发现打包速度越来越慢,而且包体积越来越大,这违背了我们初始的认知

  • 查阅资料,发现这是由于 webpack 编译原理导致的

如果你的 require 含有表达式(expressions),webpack 会创建一个上下文(context),因为在编译时(compile time)并不清楚具体是哪一个模块被导入

webpack 解析 require 语句,提取到的信息如下

Directory: ./modules
Regular expression: /^.*\.tsx$/

webpack 会根据信息此信息去遍历目录下所有符合的文件路径,并生成如下的 map

// matchResult.js
var matched = {
  'moduleA.tsx': require('./modules/moduleA.tsx'),
  'moduleB.tsx': require('./modules/moduleB.tsx'),
  'moduleC.tsx': require('./modules/moduleC.tsx'),
  ...
};
module.exports = function(key) {
  return matched[key];
}

然后,上面的 require 语句就等效于

require('matchResult.js')(moduleName + '.tsx').default;

这意味着 webpack 能够支持动态 require,但会导致所有可能用到的模块都包含在 bundle 中

解决方案

  • 既然无法通过语言特性解决问题,我们还是从代码本身入手,让 webpack 打包时就已经有了完整的代码

  • 而我们要引用的模块会根据 moduleName 这个参数动态变化,则我们可以通过编写预处理脚本,在打包前执行,根据参数生成我们想要的最终代码,再交给 webpack 去打包

  • 动态参数我们以环境变量的方式传入(此处有其他做法,不在此文讨论范围内)

  • 假设 moduleName 为 moduleA,那么我们的代码就转化为

// output.tsx
import moduleA from './modules/moduleA.tsx';
export default moduleA;

// main.tsx
import aModule from './output.tsx';
moduleA.doSomething();
...

而我们的关注点就转化为如何通过变量生成 output.tsx 文件了

  • 我们回顾一下 ejs、Vue template 等模板文件,发现它们是通过替换的方式来实现编译的,即将形如 {{moduleName}} 这样的字符串,通过变量替换为 moduleA,我们也可以采用这种方式,将模板文件编写如下
// output.tpl
import {{moduleName}} from './modules/{{moduleName}}.tsx';
export default {{moduleName}};

然后用 moduleName 的值替换掉它们

  • 以上方案可以解决比较简单的场景,但对于以下的场景,则显得灵活性不足(变量是 moduleA,moduleB,moduleC)
// output.tsx
import moduleA from './modules/moduleA.tsx';
import moduleB from './modules/moduleB.tsx';
import moduleC from './modules/moduleC.tsx';

const modules = {
  moduleA,
  moduleB,
  moduleC
};

export default modules;
  • 面对这种更具灵活性的需求,这里提供一种方法,就是 Slot 插槽的做法,即把每一个需要动态生成的部分,加上标记位
// output.tpl
//@importSlot

//@mergeSlot

export default modules;

这样的标记位,由于是注释,也不用在生成代码后专门去清除

  • 编写预处理文件
// preprocess.js

// 从环境变量中读取需要的变量
const modules = (process.env.MODULES || 'moduleA,moduleB,moduleC').split(',');

// 获取标记位末尾坐标值
const getSlotEndIndex = (origin, slot) => origin.indexOf(slot) + slot.length;

// 在指定坐标位置插入内容
const insertContent = (origin, index, content) =>
  origin.substring(0, index) + content + origin.substring(index + 1, origin.length);

const generateImportContent = () =>
  modules.reduce(
    (result, module) => (result += `\nimport ${module} from './modules/${module}.tsx';`),
    ''
  );

const generateMergeContent = () =>
  `const modules = {\n` + 
  modules.reduce(
    (result, module) => (result += `${module},`),
    ''
  ) +
  `\n};`

// 读取 output.tpl 模板文件
const tpl = fs.readFileSync('./output.tpl').toString();
let content = tpl;

// 插入 import 语句
content = insertContent(
  content,
  getSlotEndIndex(content, '//@importSlot'),
  generateImportContent()
);

// 插入 merge 语句
content = insertContent(
  content,
  getSlotEndIndex(content, '//@mergeSlot'),
  generateMergeContent()
);

// 生成最终文件
fs.writeFileSync('./output.tsx', content);
  • 最后,修改 package.json 编译命令(假设编译命令是 npm run build)
node preprocess.js && npm run build

有时候,我们可以适当转变一下思路,采用一些「偏方」来快速解决问题。不定日拱卒,慢慢进步。