文章要点:
如何在 node 运行时编译打包JS代码字符串:
- 用
@babel/core
的transform
方法做语法转换,并正确配置 Polyfill - 用
Rollup
做代码的模块化打包工作;借助@rollup/plugin-virtual
插件使 Rollup 支持代码字符串作为 input
直接看代码👇
正文
开发前端应用,大多是构建时编译 - 写完代码后,运行 npm build
把新的 JS 语法转换成兼容性更好的语法(babel);把散落在各处的模块,有序拼接在一起(Webpack or Rollup.js)...
最近在开发一个动态脚本的功能,需要在一个前端应用中编写 JS 代码,提交到服务端保存,最后在某个前端应用中请求加载运行:
在写 JS 代码的时候,可能用到 ES6+ 的语法,最终的使用环境可能并不支持其中的某些语法。解决办法是:
在服务端把提交的 JS 代码,编译打包成生产环境兼容的代码后保存
整理流程是:前端写脚本 -> 调用保存接口 -> 服务端运行时编译,保存 -> 运行环境请求,加载,运行。
可以看出,编译的工作在服务端完成:服务端接收到一大串最新最潮的 JS 代码,把这串东西当场(runtime)编译成生产环境可以使用的 JS 代码,然后保存。
代码转换 - Babel
每个前端工程师都知道:转换编译代码用 babel
。不同的是,现在需要在运行时的时候编译。其实对 babel
来说,无论在构建时编译,还是运行时编译,都是调用 @babel/core 中的某个方法。所以我们可以写一个代码转换方法 - transform
:
// transformSync 方法可以同步编译代码,返回编译后的代码
import { transformSync } from '@babel/core';
function transform(scriptText: string) {
const transformed = transformSync(scriptText,{
presets: ['@babel/preset-env'],
});
return transformed.code;
}
PS:这里使用 transformsync 方法,同步方法可有效减轻焦虑,降低血压(笑)。
transformSync
的第二个参数接收 babel
的配置,就是我们平常写在 .babelrc
中的那些配置,用来告诉 babel
要怎么处理传入的代码。这里我们使用 @babel/preset-env
作为 preset,最常见的那些语法特性都可以兼容啦。
先试试效果,这样一串代码,用上了模版字符串,箭头函数:
function someScript() {
const str = `nice to meet you`
const fn = () => str
}
经过 transformSync
转换后👇:
"use strict";
function someScript() {
var str = "nice to meet you";
var fn = function fn() {
return str;
};
};
模版字符串被转换成了普通字符串,箭头函数被转换成了 function 函数申明。nice。
接着,再看看这段代码,用了数组扁平化的语法 - flat
:
function flatArr() {
const arr = [1,2,[4,5]]
return arr.flat()
}
转换后会是什么样子呢?
🥁🥁🥁🥁
转换后👇:
"use strict";
function flatArr() {
var arr = [1, 2, [4, 5]];
return arr.flat();
};
🤔🤔🤔🤔🤔
怎么回事?它还是曾经那个 flat,没有一丝丝改变。你可能注意到啦,flat
属于新API,需要 Polyfill 来兼容。
babel 在处理代码的时候,大致分成语法转换和 Polyfill。比如针对箭头函数,需要把箭头函数改写 function 申明,我们叫做语法转换;另外,诸如 Array 的很多方法 flat,map,filter 这些东西,是新增的语法 API,要想在低版本浏览器中使用,就需要在代码里实现这些新API,这就称之为 Polyfill。
那我们就加上 Polyfill 配置
import { transformSync } from '@babel/core';
function transform(scriptText: string) {
const transformed = transformSync(scriptText, {
presets: [
[
'@babel/preset-env',
{
modules: false, // 使用 ESModule
useBuiltIns: 'usage',
corejs: { version: '3.22.5', proposals: true },
targets: {
chrome: 49,
},
},
],
],
});
return transformed?.code ? transformed?.code : '';
}
我们给 preset 加上了一些配置:
首先,我们配置使用 core-js 作为 Polyfill 的包,记得先安装 core-js
。配置中指定版本和使用 proposals 状态的语法 - {version: '3.22.5', proposals: true }
接着,useBuiltIns: 'usage'
表示按需引入 Polyfill。比如代码中使用到了 flat
方法,仅取出 flat 的 Polyfill,其他的不要。
targets
指定代码最终运行的环境,我配置成了 chrome: 49
, 意思是:转换编译后的代码,能兼容 chrome 浏览器 49 版本(及以上)就可以了。合理配置 targets 可以加快编译效率,减轻包大小。比如 chrome: 49
本身就兼容很多新语法了(如箭头函数),就不需要编译转换了。
modules
选项设置为 false,意思是不做模块化代码转换,使用默认的 ES Module 语法。其他选项见Babel文档
配置好 Ployfill 后,我们再来看看这段代码的编译效果:
编译前:
function flatArr() {
const arr = [1,2,[4,5]]
return arr.flat()
}
编译后:
import "core-js/modules/es.array.flat.js";
import "core-js/modules/es.array.unscopables.flat.js";
function flatArr() {
const arr = [1, 2, [4, 5]];
return arr.flat();
};
可以看到,编译后的代码,从 core-js
中引入了 flat
方法的 Polyfill,即 flat
方法的替代实现。但是这段代码依然无法在浏览器中运行,因为浏览器不支持 import
语法。我们还需要把引用模块的代码全部「搂过来」,放到同一段代码里,保证最终在浏览器中运行的是没有模块化 JS 代码。而且,导入的模块本身可能也引用了其它模块,要通过递归全部「搂过来」合在一起。是的,这个过程就是我们说的「打包」(bundling)。也就是 Webpack,Rollup,Parcel 这些工具擅长的事情。
打包 - Rollup
选用 Rollup 打包代码,因为 Rollup
对 ES Module 有很好的支持,代码流分析也很优秀。
平常在使用 Rollup 时,也大多在构建时静态打包(npm build)。我们的情况依然是运行时打包:
传入一段代码字符串 -> 把里面引用的模块代码都抽取出来放在一起 -> 返回打包好后的字符串
封装一个 bundle
方法:
import { rollup, RollupOptions } from 'rollup';
import commonjs from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import virtual from '@rollup/plugin-virtual';
import { terser } from 'rollup-plugin-terser';
async function bundle(scriptText: string) {
const inputOptions: RollupOptions = {
input: 'script',
plugins: [
commonjs(),
nodeResolve(),
virtual({
script: scriptText,
}),
terser(),
],
};
const bundle = await rollup(inputOptions);
const result = await bundle.generate({
format: 'iife'
});
return result.output[0].code ? result.output[0].code : '';
}
首先,rollup 库有一个 rollup
方法,调用这个方法就可以打包了,rollup
方法运行后,返回的东西称之为 bundle,调用 bundle.generate
方法可以生成打包后的代码。
调用 rollup
方法时,可以传入配置选项,决定了如何解析传入的代码,这个配置我们叫做 inputOptions
。我们用 plugins
选项加载了一些插件:
commonjs
和nodeResolve
用来解析 commonjs 模块,引用的模块中可能有 commonjs 模块引用terser
用来压缩编译后的代码;- 重点是
@rollup/plugin-virtual
这个插件,它可以让rollup
打包支持传入代码字符串作为 input(默认只能接收文件路径path)。先在 plugins 配置中加载运行 virtual 插件,传入代码字符串,并给这段代码一个 key 名,这里叫做script
,传入的就是待编译的代码字符串。最后在配置中的 input 里指定这个 key 名就好。
调用 bundle.generate
生成代码时,需要传入输出选项 outputOptions
,指定代码打包的格式等等。这里把 outputOptions
中的 format 设置为 IIFE 立即调用函数表达式,简单,兼容性最好。
最后,返回打包后的代码字符串: result.output[0].code ? result.output[0].code : ''
。因为可能存在同时打包多个文件的情况,所以 result 是一个数组,需要解析一下,这里的场景只有一份代码,直接取数组第一个就好。另外我希望 bundle
方法永远有字符串的返回值,没有生成代码的话,就返回空字符串。
那么动态打包方法就写好了,我们再回到前面的这段代码:
import "core-js/modules/es.array.flat.js";
import "core-js/modules/es.array.unscopables.flat.js";
function flatArr() {
const arr = [1, 2, [4, 5]];
return arr.flat();
};
把 Babel 编译后的这段代码,传入 bundle 方法:
🥁🥁🥁🥁
看看输出什么(关闭 terser 压缩)👇
(function () {
'use strict';
//省略一堆polyfill代码...
var UNSCOPABLES = wellKnownSymbol('unscopables');
var ArrayPrototype = Array.prototype;
// Array.prototype[@@unscopables]
// https://tc39.es/ecma262/#sec-array.prototype-@@unscopables
if (ArrayPrototype[UNSCOPABLES] == undefined) {
definePropertyModule.f(ArrayPrototype, UNSCOPABLES, {
configurable: true,
value: create(null)
});
}
// add a key to Array.prototype[@@unscopables]
var addToUnscopables$1 = function (key) {
ArrayPrototype[UNSCOPABLES][key] = true;
};
// this method was added to unscopables after implementation
// in popular engines, so it's moved to a separate module
var addToUnscopables = addToUnscopables$1;
// https://tc39.es/ecma262/#sec-array.prototype-@@unscopables
addToUnscopables('flat');
function flatArr() {
const arr = [1, 2, [4, 5]];
return arr.flat();
};
})()
可以看出,打包后的代码是一个立即调用函数表达式(IIFE),最下方是我们的源码,源码上方是一堆 Polyfill 的代码,大致可以看出,Polyfill 在 Array 原型上扩展了 flat
方法。
大功告成!最后把这段代码字符串保存成 js 文件,以供应用请求,加载,运行就好啦。
封装 - transbundle
优化一下,封装一个方法,接受代码字符串,返回编译后的代码字符串。代码先经过 babel
编译(transform),再由 Rollup
打包(bundle),我们的方法就叫 transBundle
吧:
import { transformSync } from '@babel/core';
import { rollup, RollupOptions } from 'rollup';
import commonjs from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import virtual from '@rollup/plugin-virtual';
import { terser } from 'rollup-plugin-terser';
// 前一个函数的返回值是下一个函数的入参,用 compose 粘合
export const transbundle = compose(bundle, transform);
function transform(scriptText: string) {
const transformed = transformSync(scriptText, {
presets: [
[
'@babel/preset-env',
{
modules: false,
useBuiltIns: 'usage',
corejs: { version: '3.22.5', proposals: true },
targets: {
chrome: 49,
},
},
],
],
});
return transformed?.code ? transformed?.code : '';
}
async function bundle(scriptText: string) {
const inputOptions: RollupOptions = {
input: 'script',
plugins: [
commonjs(),
nodeResolve(),
virtual({
script: scriptText,
}),
terser(),
],
};
const bundle = await rollup(inputOptions);
const result = await bundle.generate({
format: 'iife',
});
return result.output[0].code ? result.output[0].code : '';
}
// compose 简易实现
function compose(...fns: ((...args: any[]) => any)[]) {
const last = fns.pop();
if (last == null || typeof last !== 'function') return () => {};
return function composed(...args: any[]) {
return fns.reduceRight(async (acc, fn) => {
return await fn(acc);
}, last.apply(null, args));
};
}