简易实现 html playground

探究了一下 webpack 的打包机制后发现,webpackeval 下会将模块打包成 IIFE + 类似Commonjs 的形式运行,又受到 vue playground 的启发,便想实现一个简易的html playground

webpack 编译代码分析

首先理解 webpack 打包后的 IIFE 的代码,🌰 如下:

js 复制代码
// index.js
function add(num1, num2) {
  return num1 + num2
}
console.log(add(1, 2))
js 复制代码
;(() => {
  'use strict'
  // __webpack_modules__ 打包的各个模块会亿 kv的形式集中在这个变量中
  var __webpack_modules__ = {
    './index.js': (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) => {
      eval(
        '__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _data__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./data */ "./data.js");\n\n\nconsole.log((0,_data__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2));\n\n\n//# sourceURL=webpack://webpack-build/./index.js?'
      )
    },
  }
  // 模块导入缓存变量
  var __webpack_module_cache__ = {}
  // require 加载模块的函数 在 ast 的过程中会将 import 替换为 这个
  function __webpack_require__(moduleId) {
    // 如果有缓存 取缓存的值
    var cachedModule = __webpack_module_cache__[moduleId]
    if (cachedModule !== undefined) {
      return cachedModule.exports
    }
    // 初始缓存变量
    var module = (__webpack_module_cache__[moduleId] = {
      exports: {},
    })
    // 没有缓存 则导入模块 __webpack_modules__ 会将模块和代码 以kv的形式存储 v是一个函数 内部会执行 eval
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__)

    return module.exports
  }

  // 剩下则向 __webpack_require__ 函数上初始化一些静态的函数
  ;(() => {
    __webpack_require__.d = (exports, definition) => {
      for (var key in definition) {
        if (
          __webpack_require__.o(definition, key) &&
          !__webpack_require__.o(exports, key)
        ) {
          Object.defineProperty(exports, key, {
            enumerable: true,
            get: definition[key],
          })
        }
      }
    }
  })()
  ;(() => {
    __webpack_require__.o = (obj, prop) =>
      Object.prototype.hasOwnProperty.call(obj, prop)
  })()
  ;(() => {
    __webpack_require__.r = (exports) => {
      if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, {
          value: 'Module',
        })
      }
      Object.defineProperty(exports, '__esModule', { value: true })
    }
  })()

  // 入口函数导入
  var __webpack_exports__ = __webpack_require__('./index.js')
})()

从上述的代码可以看出最后会将模块的路径编译成 key,而对应的 value 则是一个包含了模块逻辑的函数 下面我们将实现一个简易的 playground 并且可以使用 javascript module的功能

编译模块

参考 webpack 的编译逻辑,我们将会把每个 js 模块编译成一个函数,并且重写 import export 关键字

例如我们在 playground 中写下如下的 script 代码

js 复制代码
// utils.js
export function add(num1, num2) {
  return num1 + num2
}

// index.js
import { add } from './utils.js'
add(1, 2) // 3

经过编译之后的代码为:

js 复制代码
// utils.js
function add(num1, num2) {
  return num1 + num2
}
__exports('a.js', 'add', add)

// index.js
const { add } = await __require('a.js')
add(1, 2) // 3

可以看到 __exports 主要实现导出的功能,而 __require 则实现的是导入的功能

如何将 importexport 这些关键字转换成 __exports 这些变量名呢?

这里将使用 babel 对 源代码转换成 AST ,并在 traverse 时对代码进行一定程度的改造,基本的代码模版如下:

js 复制代码
function transform(code: string, fileName = '') {
  // 首先将源代码转换成 AST
  const ast = babel.parse(code, {
    sourceType: 'module',
  })
  // 之后在 traverse 阶段进行修改
  babel.traverse(ast, {
    // ...
  })

这里我们将主要对 ImportDeclarationExportNamedDeclarationExportDefaultDeclaration 这三个阶段进行操作

ImportDeclaration

首先是 ImportDeclaration阶段的操作

js 复制代码
{
   ImportDeclaration(path) {
       // 获取 import 的
        const importStringLiteral = path.node.source.value
        const defaultKey = Symbol('default')
        let isExistsNamespaceKey = false
        const namespaceKey = Symbol('namespace')
        // 对当前模块的 import 关键字导入进行一些处理
        const __import = path.node.specifiers.reduce<
          Record<string | symbol, string>
        >((acc, specifiers) => {
          const { type } = specifiers
          const name = specifiers.local.name
          if (type === 'ImportDefaultSpecifier') {
            // 记录默认导入的情况
            // import a from './utils.js'
            Reflect.set(acc, defaultKey, name)
          } else if (type === 'ImportSpecifier') {
            // 记录普通导入的情况
            // import { a } from './utils.js'
            Reflect.set(acc, name, name)
          } else {
            // 记录导入整个模块的情况
            // import * as U from './utils.js'
            Reflect.set(acc, namespaceKey, name)
          }
          return acc
        }, {})
        // importVariable 记录从模块导入的变量名称
        const importVariable: string[] = []
     // 对默认导入以及整个模块的导入进行特殊处理
     // 例如 默认导入 import a from './utils.js'
     // 后续将会处理成 const {default: a} = await __require('./utils.js')
        ;[defaultKey, namespaceKey].forEach((key) => {
          if (__import[key] !== void 0) {
            let prefix = ''
            if (key === defaultKey) {
              prefix = 'default : '
            } else {
              isExistsNamespaceKey = true
              prefix = ''
            }
            importVariable.push(`${prefix} ${Reflect.get(__import, key)}`)
          }
        })
     // Object.values 不会遍历 Symbol 属性 这里会将普通的导入加入到 `importVariable` 变量中
        Object.values(__import).reduce<string[]>((acc, value) => {
          acc.push(value as string)
          return acc
        }, importVariable)
     // 如果是整个模块导入 则不需要 {}
     // const Utils = __require('./utils')
        const __importVariable = isExistsNamespaceKey
          ? importVariable.join(',')
          : `{${importVariable.join(',')}}`
        // 生成 const { xxx } = require(path) 的结构
        const new_ast = babel.template.ast(
          `const ${__importVariable} = await __require("${importStringLiteral}");`,
        )
        // 替换原有的 import 导入
        if (new_ast) path.replaceWith(new_ast)
      }
}

在这一阶段,完成了对 导入默认值命名导入整体模块导入(命名空间导入) 进行了一个统一的处理,使其导入的结果为 __require 函数的返回值,完成 import的处理后,接下来便是对 export 进行处理

这里我们将 export 的处理分为两个不同的阶段,分别是 ExportDefaultDeclaration(对默认导出的处理)、ExportNamedDeclaration(对命名导出的处理)

ExportNamedDeclaration

举个 🌰,对于 export const a = 1 需要将它处理成 __exports(filename, 'a', 1),这里的 filename 自然就是当前变量所属的模块名称

js 复制代码
const newExportAst = (fileName: string, name: string, value: string) => {
  return babel.template.ast(
    `__exports("${fileName}", "${name}", ${value});`,
  )
}

{
   // export const a = 1 ====> __exports(filename, name, value)
    ExportNamedDeclaration(path) {
        // 获取 部分代码的值
       // 例如 export const a = 1
       // 获取的是 const a = 1 这一部分
       // 导出的部分通过 path.insertAfter 完成
        const declaration = path.node.declaration
        let name
        switch (path.node.declaration?.type) {
          case 'VariableDeclaration':
            name = path.node.declaration.declarations[0].id.name
            break
          case 'FunctionDeclaration':
            name = path.node.declaration.id?.name
            break
          default:
            // code ...
            break
        }
    if (name) {
          // 如果有名字 则是 命名导出
          const new_ast = newExportAst(fileName, name, name)
          path.replaceWith(declaration)
          path.insertAfter(new_ast)
        } else if (path.node.specifiers && path.node.specifiers.length > 0) {
          // e.g. export { a, b } 这种导出方式
          const exportName = path.node.specifiers.map(target => {
            return target?.exported?.name
          })
          exportName.forEach(name => {
            const new_ast = newExportAst(fileName, name, name)
            path.insertAfter(new_ast)
          })
          // 移除 export { a, b }
          path.remove()
        }
      },
}

ExportDefaultDeclaration

js 复制代码
{
      ExportDefaultDeclaration(path) {
        const declaration = path.node.declaration
        // 判断是否有 name 没有的话则是匿名导出
        // export function() {}
        const name = declaration?.id?.name
        const new_ast = newExportAst(fileName, 'default', name || generate(path.node.declaration)?.code)
        if (name) {
          path.replaceWith(declaration)
          path.insertAfter(new_ast)
        } else {
          path.replaceWith(new_ast)
        }
      },
}

代码生成

js 复制代码
// 最后通过 transformFromAstSync 将 AST 转换成 code
babel.transformFromAstSync(ast)?.code

至此,一个简易的 js module 编译工作已完成

API 实现

在上述中已经实现了 对 __require__exports 的编译,接下来,我们将对 __require__exports 函数的具体实现

首先是 __require API ,它接收一个 path 参数,用于加载对应的模块

先定义函数类型

js 复制代码
function __require(path) {
  // path的作用就是 从一大堆模块中找到对应的模块 然后加载
  // ...
  // 判断是否存在缓存 ,如果有缓存 直接读取缓存
  if (__exports._map[keys]) {
    return __exports._map[keys]
  }

  // 如果没有缓存,调用模块 将导出值存储在 `__exports._map` 中
  // 对于 __require.map 下面将会讲述这个变量的作用
  const func = __require._map[keys]
  if (func instanceof Function) {
    func(__require, __exports)
    return __exports._map[keys] || {}
  }
  // 兜底 返回 {}
  return {}
}

这里可以考虑之前的 webpack 的流程 他会将模块编译成对象的形式,之后通过 path 映射对应的 key 进行加载

因此我们会在定一个存储所有模块的变量,这里将这个存储模块的变量定义在了__require_map属性 中

js 复制代码
__require.map = {}

// keys 代表 模块名 code 代表了经过babel 编译后的源代码
// 具体的核心逻辑如下: 通过 new Function 实现一个函数,并且在函数内部可以使用 __require 和 __exports
__require.map[keys] = new Function(
  '__require',
  '__exports',
  '(async () => {' + code + '\\n' + '})()'
)

__exports 实现的逻辑则比较简单

js 复制代码
function __exports(fileName, type, value) {
  // 初始化导出模块
  if (!__exports._map[fileName]) {
    __exports._map[fileName] = {}
  }
  // 导出具体的值
  __exports._map[fileName][type] = value
}

实现了两个关键 API 后,接下来就是定义解析入口路径,之后进行加载模块

js 复制代码
// e.g. entry = index.js
const entry = './index.js'
__require._map[entry]?.(__require, __exports) // 从入口进行加载模块

至此一个简易的 js module 工作流程已完成

Import Map

Import Map 的可以添加第三方的导入映射功能,举个 🌰:

html 复制代码
<script type="importmap">
  {
    "imports": {
      "vue": "https://play.vuejs.org/vue.runtime.esm-browser.js"
    }
  }
</script>

<script type="module">
  // 通过 importmap 的定义,可以在浏览器中使用 esm 规范去引入第三方库
  import { ref } from 'vue'
  console.log(ref)
</script>

整体代码

html playgroundnuxt 进行搭建,整体的 Preview 代码如下所示:

tsx 复制代码
import { defineComponent, watch, ref } from 'vue'
export default defineComponent({
  name: 'Preview',
  props: {
    html: {
      type: String,
      default: '',
    },
    style: {
      type: String,
      default: '',
    },
    compileModule: {
      type: Object,
      default: () => ({}),
    },
    entry: {
      type: String,
      default: 'app.js',
    },
  },
  setup(props) {
    const srcDoc = ref('')

    const updateSrcDoc = () => {
      const code = JSON.stringify(props.compileModule)
      srcDoc.value = `
      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <link rel="icon" type="image/svg+xml" href="/vite.svg" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <title>test</title>
          <style>
            ${props.style}
          </style>
        </head>
        <body>
          <div id="app-iframe">
            ${props.html}
          </div>
          <script type="module">
          // 对importmap中的模块进行过滤
          const __import__map = ${importMapFields.value}
          async function __require(keys) {
            if (__exports._map[keys]) {
              return __exports._map[keys];
            }
            const func = __require._map[keys];
            if (func instanceof Function) {
              func(__require, __exports);
              return __exports._map[keys] || {};
            }
            // is exist import Map ?
            if (__import__map.includes(keys)) {
              return import(keys);
            }
            return {};
          }
          __require._map = {};

          function __exports(fileName, type, value) {
            if (!__exports._map[fileName]) {
              __exports._map[fileName] = {};
            }
            __exports._map[fileName][type] = value;
          }
          __exports._map = {};
          const code = ${code}
          const entry = "${props.entry}"
          Object.keys(code).forEach(keys => {
            __require._map[keys] = new Function("__require", "__exports", "(async () => {" + code[keys] + "\\n" + "})()")
          })
          __require._map[entry]?.(__require, __exports)
          </script>
        </body>
      </html>
    `
    }

    const updateSrcDebounce = updateSrcDoc
    watch(
      [() => props.html, () => props.style, () => props.compileModule],
      () => {
        updateSrcDebounce()
      }
    )
    return () => (
      <div class="w-full h-full box-border">
        <iframe
          sandbox="allow-scripts"
          class="w-full h-full"
          srcdoc={srcDoc.value}
        />
      </div>
    )
  },
})

结语

本文通过分析 webpack 的打包流程,实现了一个简易的 html playground中的 js module流程,在此之上添加了importmap 的特性,使 playground 能够支持引入第三方库。

本文完整的代码在 github 仓库,感兴趣可以给个 star 支持一下

参考文章

相关推荐
cs_dn_Jie14 分钟前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic1 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿1 小时前
webWorker基本用法
前端·javascript·vue.js
cy玩具1 小时前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
清灵xmf2 小时前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据2 小时前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_390161772 小时前
防抖函数--应用场景及示例
前端·javascript
334554323 小时前
element动态表头合并表格
开发语言·javascript·ecmascript
John.liu_Test3 小时前
js下载excel示例demo
前端·javascript·excel
Yaml43 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理