内置模块之 VM
vm 模块是 Nodejs 内部核心模块,Nodejs 底层的 require 实现用到了这个模块。
它的核心作用是可以创建一个独立运行代码的沙箱环境。
所谓“沙箱”可以理解为“独立”、“封闭”。
此处不讨论 vm 模块 API 的使用语法和细节,主要了解如何运用 vm 模块为实现模块的加载做准备。
也就是怎么通过 vm 把 A 模块中的内容,放在 B 模块中执行。
初始化示例
创建一个 test.txt
文件(为了演示运行的是模块内容,而不是 js 文件,这里使用的 .txt
):
var age = 18
创建 vm.js
文件实现运行 test.txt
模块的内容。
方法1 - eval
可以使用 eval
运行 JS 代码:
const fs = require('fs')
let content = fs.readFileSync('test.txt', 'utf-8')
console.log(content) // `var age = 18`
eval(content)
console.log(age) // 18
但是这种方式 content
和如果当前作用域下已经有了相同名称的 age
变量,这个脚本就会报错:SyntaxError: Identifier 'age' has already been declared
const fs = require('fs')
let content = fs.readFileSync('test.txt', 'utf-8')
let age = 20
eval(content)
console.log(age)
而 Nodejs 中加载的每个模块都拥有独立的作用域,所以 eval
显然不适合。
方法2 - new Function
let age = 20
// new Function
// 最后一个参数是函数体内容的字符串
// 前面的参数是函数接收的形参名称,可以分别传入,也可以用逗号拼接在一起
const fn = new Function('age', 'return age + 1')
console.log(fn(age)) // 21
new Function 虽然能创建独立作用域,不过需要手动指定作用域内需要的外部变量,当需要传入的变量增多,处理起来就相对麻烦了。
方法3 - vm 模块
示例1
vm.runInThisContext(code)
会在当前全局变量的上下文,为运行的代码创建一个与当前作用域隔离的沙箱环境,环境中只能访问全局变量,无法访问当前作用域中的变量。
// test.txt
var age = 18
const fs = require('fs')
const vm = require('vm')
let content = fs.readFileSync('test.txt', 'utf-8')
let age = 20
// vm
vm.runInThisContext(content)
console.log(age) // 20
示例2
沙箱环境在当前全局变量的上下文,可以访问全局变量 gloabl
,全局上下文中 var
声明的变量会添加到全局变量 global
中,所以:
// test.txt
var age = 18
const fs = require('fs')
const vm = require('vm')
let content = fs.readFileSync('test.txt', 'utf-8')
// let age = 20
// vm
vm.runInThisContext(content)
console.log(age) // 20
// 等同于
console.log(global.age) // 20
let
声明的变量就不会添加到全局对象:
// test.txt
// var age = 18
let age = 18
// vm.js
// ...
console.log(age) // undefined
Nodejs 中 module
、exports
、require
等都是使用 let
声明的。
示例3
vm.runInThisContext(code)
返回执行脚本中最后一条语句的执行结果:
const vm = require('vm')
const fn = vm.runInThisContext(`
var age = 18;
// 最后一行作为要返回的内容,所以要用'()'包裹函数
(function() {console.log(++age)})
`)
fn() // 19
fn() // 20
总结
vm.runInThisContext(code)
创建的沙箱环境保证代码不会与加载模块的外部作用域中的变量发生冲突。
既解决了可以执行外部读取到的其它模块的内容,同时还把模块中的作用域与外部作用域进行了隔离,避免同名变量冲突。
模块加载模拟实现
核心逻辑
本例以文件模块为例,模拟实现 Nodejs 的模块加载流程,以了解 Nodejs 的加载规则。主要完成的功能:
- 路径分析 - 确定目标模块的绝对路径
- 缓存优先 - 提高模块加载效率
- 文件定位 - 确定当前模块的文件类型
- 编译执行 - 把加载的模块内容编程在当前模块中可以使用的数据
代码实现
// m.js
module.exports = {
foo: 123
}
// imitate-require.js
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function Module(id) {
this.id = id
this.exports = {}
console.log('校验重复加载是否缓存优先')
}
// 获取模块的绝对定位
Module._resolveFilename = function (filename) {
// 利用 path 将 filename转化为绝对路径
const absolutePath = path.resolve(__dirname, filename)
// 判断路径对应的内容是否存在
if (fs.existsSync(absolutePath)) {
return absolutePath
} else {
// 如果不存在
// 文件定位(尝试补足不同的后缀重新判断)
const suffix = Object.keys(Module._extensions)
for (let i = 0; i < suffix.length; i++) {
const newPath = absolutePath + suffix[i]
if (fs.existsSync(newPath)) {
return newPath
}
}
}
throw new Error(`${filename} is not exists`)
}
// 不同类型文件的编译执行方法
Module._extensions = {
'.js'(module) {
// 读取
let content = fs.readFileSync(module.id, 'utf-8')
// 包装
content = Module.wrapper[0] + content + Module.wrapper[1]
// VM
const compileFn = vm.runInThisContext(content)
// 准备参数的值
const exports = module.exports
const dirname = path.dirname(module.id)
const filename = module.id
// 调用
compileFn.call(exports, exports, myRequire, module, filename, dirname)
},
'.json'(module) {
// 读取
const content = JSON.parse(fs.readFileSync(module.id, 'utf-8'))
module.exports = content
}
}
// 用于包装模块内容的函数收尾字符串
Module.wrapper = ['(function (exports, require, module, __filename, __dirname) {', '})']
// 模块缓存
Module._cache = {}
// 执行加载(编译执行)
Module.prototype.load = function () {
// 获取文件类型
const extname = path.extname(this.id)
Module._extensions[extname](this)
}
function myRequire(filename) {
// 1. 路径分析
const modulePath = Module._resolveFilename(filename)
// 2. 缓存优先
const cacheModule = Module._cache[modulePath]
if (cacheModule) {
return cacheModule.exports
}
// 3. 创建空对象加载目标模块
const module = new Module(modulePath)
// 4. 缓存已加载过的模块
Module._cache[modulePath] = module
// 5. 执行加载(编译执行)
module.load()
// 6. 返回数据
return module.exports
}
const obj = myRequire('./m')
console.log(obj)
// 校验重复加载是否缓存优先
myRequire('./m')