如何在运行时编译 JavaScript 代码 cover

如何在运行时编译 JavaScript 代码

运行时的 JS 编译

文章要点:

如何在 node 运行时编译打包JS代码字符串:

  1. @babel/coretransform 方法做语法转换,并正确配置 Polyfill
  2. Rollup 做代码的模块化打包工作;借助 @rollup/plugin-virtual 插件使 Rollup 支持代码字符串作为 input

直接看代码👇

Github-transbundle

正文

开发前端应用,大多是构建时编译 - 写完代码后,运行 npm build 把新的 JS 语法转换成兼容性更好的语法(babel);把散落在各处的模块,有序拼接在一起(Webpack or Rollup.js)...

最近在开发一个动态脚本的功能,需要在一个前端应用中编写 JS 代码,提交到服务端保存,最后在某个前端应用中请求加载运行

在写 JS 代码的时候,可能用到 ES6+ 的语法,最终的使用环境可能并不支持其中的某些语法。解决办法是:

服务端把提交的 JS 代码,编译打包成生产环境兼容的代码后保存

precedure.png

整理流程是:前端写脚本 -> 调用保存接口 -> 服务端运行时编译,保存 -> 运行环境请求,加载,运行。

可以看出,编译的工作在服务端完成:服务端接收到一大串最新最潮的 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 选项加载了一些插件:

  • commonjsnodeResolve 用来解析 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));
};
}