前言
最近收到一个需求挺有意思的,那就是给可视化编辑器的动态请求配置面板添加pre-request请求脚本预处理。跟postman的pre-request功能一样,在发送请求之前先执行脚本给编辑器设置变量,然后在params|Body|Header面板通过{{variable}}使用变量。
接下来就来讲解一下此功能如何实现的,其本质就是在web端执行脚本注入变量因为在JavaScript执行第三方代码,因此实现一个JS沙箱来进行执行环境隔离是非常重要的,不能因为执行别人的脚本异常影响到编辑器的能力。这里不会涉及到CSS 隔离只是纯JS 隔离,针对CSS隔离计划在我的编辑器插件设计完成之后会进行分享。
需求分析
在正式开始功能实现之前,我们需要先充分理解需求。
postman如何使用环境变量和预处理脚本
- postman分为
Globals(全局变量)<Environments (环境变量)<Collections (集合变量)<variables(局部变量, 层越深优先级越高,遇到同名key内层覆盖外层 - 首先配置
环境变量的属性名和变量值在接口里配置
params|Headers|Body...。[Key]:{{varible}}的形式当postman在环境变量中能找到{{varible}}的声明则替换成对应的值,没有找到保持原有
- 此时你会发现针对于
AppKey和Signature加密key是通过CryptoJS脚本执行加密得来的,单纯的通过配置环境变量并不能满足我们的需求。此时pre-request就发挥了他的作用。
在script中的pre-script编写JavaScript脚本语法生成对应变量的值,然后通过pm.global.set(key,value)赋值全局变量、pm.environment.set(key,value)赋值环境变量、pm.variables.set(key,value)赋值局部变量。如下我赋值了局部变量
如下可以看到,在该请求就能获取到在
pre-script设置的对应变量了
功能实现
整体项目结果
创建一个PM顶级类管理旗下的Global类、Environment类...环境变量数据对象保存在当前类实例下面,这么设计的好处在于方便后续功能拓展。创建一个CodeSandbox类实现JS沙箱用于执行第三方脚本数据
这里模拟了三个组件(组件代表可视化里配置动态数据的组件), 因为是demo所以比较简陋对于 输入区域可以采用Monaco或者codemirror实现语法高亮和代码补全、格式化代码格式。 如果不想集成编辑器,那么可以采用highlight和prettier进行高亮和格式化
- 第一个组件除了设置变量还会手动抛出异常
- 第三个组件模拟使用
pm内置库CryptoJS进行数据加密
环境变量相关类
PM类声明和定义
- variables是
每个组件私有的因此使用variablesMap使用组件id缓存对应的variable实例从而建立了组件作用域变量。 - 通过
setVariableId聚焦当前选中的组件就可以通过设置get variables方法从而实现pm.variables获取当前聚焦组件的私有变量 - Request代表当前组件的
request对象这里不做缓存,因此聚焦下一个组件就把前一个组件的request对象直接移除掉缓存当前组件的request对象。每次都重新new一个新的对象的好处在于足够干净不会存在遗留未重置的属性 - CodeSandbox代表的沙箱管理类型
import Variables from './Variables.js'
import Global from './Global.js'
import Environment from './Environment.js'
import CodeSandbox from './CodeSandbox.js'
import Request from './Request.js'
import Response from './Response.js'
export default class PM {
// 全局变量
global = null
environment = null
// 局部变量
variablesMap = null
// 当前聚焦局部变量
variableId = null
// 沙箱缓存
codeSandboxMap = null
// request缓存
requestMap = null
// Response 缓存
responseMap = null
constructor() {
this.global = new Global()
this.environment = new Environment()
this.variablesMap = new Map()
this.codeSandboxMap = new Map()
this.requestMap = new Map()
this.responseMap = new Map()
}
sleep(time = 100) {
return new Promise(resolve => {
setTimeout(resolve, time)
})
}
async execScriptInSandbox(id, scriptText) {
// 获取沙箱实例且执行
const codeSandbox = this.codeSandboxMap.get(id)
codeSandbox.execScript(scriptText)
// !!目前只支持同步任务
// TODO: 目前没有去判断沙箱内部代码是否完成,所以这里暂时先sleep一下,等待沙箱执行完毕
// 后续追加代码分析,判断沙箱是否执行完毕
await this.sleep(100)
}
setRequest(id, req) {
if (!this.requestMap.has(id)) {
const request = new Request(req)
this.requestMap.set(id, request)
}
}
setResponse(id, res) {
if (!this.responseMap.has(id)) {
const response = new Response(res)
this.responseMap.set(id, response)
}
}
getResponseBody(id) {
return this.responseMap.get(id).json()
}
getVariables(id) {
return this.variablesMap.get(id).toObject()
}
setVariableId(id) {
// 每个组件都有自己的环境,这样子也不存在并发问题
// $ 套件无需特殊处理
if (!this.variablesMap.has(id)) {
// console.log('当前组件变量作用域已存在,无需重新创建');
const variables = new Variables(this)
this.variablesMap.set(id, variables)
}
if (!this.codeSandboxMap.has(id)) {
// 创建干净的沙箱实例(每一个组件都有独立的沙箱环境)
const codeSandbox = new CodeSandbox(id)
this.codeSandboxMap.set(id, codeSandbox)
}
}
// 解锁
unSetVariableId(id) {
// 销毁沙箱实例
const codeSandbox = this.codeSandboxMap.get(id)
codeSandbox?.destroy()
this.codeSandboxMap.delete(id)
this.variablesMap.delete(id)
this.requestMap.delete(id)
this.responseMap.delete(id)
}
}
Variables类声明和定义,Global类和Environment类类似
- 声明一个
data存储设置的变量数据
export default class Variables {
data = new Map()
pm = null
constructor(pm) {
this.pm = pm
}
has(key) {
return this.data.has(key)
}
get(key) {
return this.data.get(key)
}
set(key, value) {
return this.data.set(key, value)
}
unset(key) {
return this.data.delete(key)
}
clear() {
this.data.clear()
}
toObject() {
const global = this.pm.global.toObject()
const environment = this.pm.environment.toObject()
const variables = Object.fromEntries(this.data)
return { ...global, ...environment, ...variables }
}
}
CodeSandbox沙箱声明和定义
- 通过
iframe+proxy+with实现JS沙箱 - 首先创建一个
iframe,其次通过proxy拦截iframe的window对象(this.iframeWindow) - 针对于
pm和CryptoJS对象开放白名单,让沙箱内部可以访问父级的window对象 - 组件的每一次执行都会
创建一个新的沙箱环境,确保组件与组件之间或者单个组件多次执行不会相互影响都有自己的独立环境
import CryptoES from 'crypto-es'
class CodeSandbox {
// iframe 实例
iframe = null
// iframe 的 Window 实例
iframeWindow = null
// 环境变量id
variableId = null
constructor(id) {
// 当前沙箱所属的环境变量id
this.variableId = id
window.CryptoJS = CryptoES
this.iframe = this.createIframe()
this.iframeWindow = this.iframe.contentWindow
this.proxyIframe()
}
createIframe() {
const iframe = document.createElement('iframe')
iframe.setAttribute('src', 'about:blank')
iframe.setAttribute('style', 'display: block;width:0; height:0;border:none;')
document.body.appendChild(iframe)
return iframe
}
proxyIframe() {
this.iframeWindow.proxy = new Proxy(this.iframeWindow, {
get: (target, prop) => {
// 支持postman外置库
// https://postman.xiniushu.com/docs/writing-scripts/script-references/postman-sandbox-api-reference
if (prop === 'pm') {
/**
* 1. pm从全局变量获取
* 2. variables和request (不共用额外处理)
* 3. 其他pm属性,从pm下获取
*/
return new Proxy(window[prop] || {}, {
get: (subTarget, subProp) => {
if (subProp === 'variables') {
return subTarget.variablesMap.get(this.variableId)
} else if (subProp === 'request') {
return subTarget.requestMap.get(this.variableId)
} else if (subProp === 'response') {
return subTarget.responseMap.get(this.variableId)
}
return subTarget[subProp]
},
})
}
if (prop === 'CryptoJS') {
return window[prop]
}
if (prop === 'window' || prop === 'self') {
return this.iframeWindow.proxy
}
// TODO: 留给有缘人,一些错误边界问题,遇到再处理,it is no time fix it
return target[prop]
},
set: (target, prop, value) => {
target[prop] = value
return true
},
has: (target, prop) => true,
})
}
removeScript() {
const headDom = this.iframeWindow.document.head
while (headDom.firstChild) {
headDom.removeChild(headDom.lastChild)
}
}
execScript(scriptText) {
const scriptElement = this.iframeWindow.document.createElement('script')
scriptElement.textContent = `
(function(window) {
with(window) {
${scriptText}
}
}).bind(window.proxy)(window.proxy);
`
this.iframeWindow.document.head.appendChild(scriptElement)
}
destroy() {
if (this.iframe) {
this.iframe.parentNode?.removeChild(this.iframe)
}
this.iframe = null
this.iframeWindow = null
}
}
export default CodeSandbox
对一些全局属性进行白名单配置。例如postman外置库,在项目安装ESM或者UMD第三包,然后挂载到应用的window下。此沙箱还有瑕疵后续优化
预请求执行
// !!! 为什么不能使用yid作为执行脚本的唯一标识呢?
// !!! 原因: 因为请求是异步的,在轮训中可能同一个组件这次的请求还未完成,就会去执行下一次组件的请求。这次请求完成之后我会清空相关请求以及沙箱数据
// !!! 导致下一次请求时去获取 “相关请求以及沙箱数据” 会出现找不到的情况。(因为同一个组件yid是一致的)
// 也可以通过优化轮训方法来避免上一次请求没有完成,就进入到下一次请求的情况
import PM from './script/PM.js'
const pm = new PM();
window.pm = pm
const oneDom = document.getElementById('component_one_btn')
const oneInput = document.getElementById('component_one_input')
const oneOutput = document.getElementById('component_one_output')
oneDom.addEventListener('click', async () => {
const inputText = oneInput.value
const random = Math.random().toString(16).slice(2);
const yid = 'one';
const id = `${random}-${yid}`
pm.setVariableId(id)
await pm.execScriptInSandbox(id, inputText)
const data = pm.getVariables(id)
oneOutput.value = JSON.stringify(data)
pm.unSetVariableId(id)
})
const twoDom = document.getElementById('component_two_btn')
const twoInput = document.getElementById('component_two_input')
const twoOutput = document.getElementById('component_two_output')
twoDom.addEventListener('click', async () => {
const inputText = twoInput.value
const random = Math.random().toString(16).slice(2);
const yid = 'two';
const id = `${random}-${yid}`
pm.setVariableId(id)
await pm.execScriptInSandbox(id, inputText)
const data = pm.getVariables(id)
twoOutput.value = JSON.stringify(data)
pm.unSetVariableId(id)
})
const threeDom = document.getElementById('component_three_btn')
const threeInput = document.getElementById('component_three_input')
const threeOutput = document.getElementById('component_three_output')
threeDom.addEventListener('click', async () => {
const inputText = threeInput.value
const random = Math.random().toString(16).slice(2);
const yid = 'three';
const id = `${random}-${yid}`
pm.setVariableId(id)
await pm.execScriptInSandbox(id, inputText)
const data = pm.getVariables(id)
threeOutput.value = JSON.stringify(data)
pm.unSetVariableId(id)
})
根据"预请求执行(pre request)"流程执行原理也支持"响应后脚本(post response)"。我们可以通过pm.response.json()来获取接口返回的数据,然后将二次处理数据,在通过pm.response.setJson更新数据
针对在编辑器我写了hooks调用方式usePreScript有需要的同学可以参考一下。
execPreRequest请求预处理,在发起请求前需要调用execPostResponse响应后处理,在请求响应之后调用destroyScript执行完请求-响应全流程,销毁沙箱和相关缓存数据。 确保每一次都是最新最干净的沙箱环境
经过上面步骤,我们就实现了如下流程:
TODO 异步脚本
如何知道javascript执行栈任务已经完成?
查了一圈资料,没有找到相关有用的信息。说一下我之后实现的思路,对整个脚本进行babel解析,在脚本执行前初始化一个async为0,遇到then、settimeout等异步函数,async+1,每一个异步函数执行完async-1,当async为0时,postmessage通知上层已经执行完成
1. 声明一个全局变量 asyncAcount 记录移除执行的次数
2. 通过babel分析脚本代码,例如找到promise、settimeout... 那么 asyncAcount + 1
3. 在promise、settimeout..插入代码,当该方法执行完成就asyncAcount - 1
4. 当asyncAcount为0时,表示所有代码执行完成,通过window.postmessage通知上层已经执行完成了
5. 报错也被认定为完成
6. 对于PM类,需要创建队列排队执行
总结
通过上述步骤我们学会了,如何通过类思维让功能模块化,如何在web端安全的执行JavaScript脚本,这里的沙箱存在瑕疵后续会出专门一篇沙箱的文章。希望这篇文章对你有所帮助,到此就结束啦,此次涉及到的相关代码,点击这里
TIPS: 最新改动,每个组件都有独立的沙箱,不在共享单个沙箱。具体看代码逻辑,整体思路没有变化
如果还不会用postman的变量和预处理脚本,请阅读如下官方文章