关于javascript:使用Skypack在浏览器上直接导入ES模块

10次阅读

共计 9082 个字符,预计需要花费 23 分钟才能阅读完成。

场景复现

笔者最近给本人的我的项目 CodeRun 减少了一个间接在浏览器上应用 ES 模块的性能,之前应用一个包前须要先找到它的在线 CDN 地址而后引进来,就像这样:

当初能够间接这样:

那么这是怎么实现的呢,很简略,应用 Skypack,上图中的导入语句实际上最终会变成这样:

import rough from 'https://cdn.skypack.dev/roughjs'

这个转换是通过 babel 实现的,咱们能够写个 babel 插件,当拜访到 import 语句时,判断如果是”裸“导入就拼接上 Skypack 的地址:

// 转换导入语句
const transformJsImport = (jsStr) => {
    return window.Babel.transform(jsStr, {
        plugins: [parseJsImportPlugin()
        ]
    }).code
}

// 批改 import from 语句
const parseJsImportPlugin = () => {return function (babel) {
        let t = babel.types
        return {
            visitor: {ImportDeclaration(path) {
            // 是裸导入则替换该节点
                    if (isBareImport(path.node.source.value)) {
                        path.replaceWith(t.importDeclaration(
                            path.node.specifiers,
                            t.stringLiteral(`https://cdn.skypack.dev/${path.node.source.value}`)
                        ))
                    }
                }
            }
        }
    }
}

// 查看是否是裸导入
// 非法的导入格局有:http、./、../、/
const isBareImport = (source) => {return !(/^https?:\/\//.test(source) || /^(\/|\.\/|\.\.\/)/.test(source));
}

此外,还须要给 script 标签增加一个 type="module" 的属性,因为浏览器默认不会把 script 当做 ES 模块,只有设置了这个属性能力应用模块语法。

Skypack

Skypack实质上是一个 CDN 服务,然而和传统 CDN 服务有点不一样,传统的 CDN 只是给你提供一个文件的固定拜访地址,你要应用哪个包,须要本人去这个包的公布文件中找到其中你要的那个文件。

晚期大部分包提供的都是 IIFE 或者 commonjs 标准的模块,咱们须要通过 linkscript标签引入,然而当初基本上所有的古代浏览器都原生反对 ES 模块,所以咱们能够间接在浏览器上应用模块语法。如果应用传统的 CDN 服务,那么首先就须要某个包它提供了 ES 模块的文件,而后咱们再从 CDN 里找到该 ES 版本的文件地址,再进行应用,如果某个包没有提供 ES 版本,那么咱们就无奈间接在浏览器上以模块的形式导入它,而 Skypack 是专门为古代浏览器设计的,它会主动帮咱们进行转换,咱们只有通知它咱们要导入的包名,即便这个包提供的是 commonjs 版本的文件,Skypack返回的也会是 ES 模块,所以咱们就能够间接在浏览器上以模块的形式导入了。

根本应用

它的应用形式很简略:

https://cdn.skypack.dev/PACKAGE_NAME

只有拼接上你须要导入的包名即可,比方咱们要导入moment

import moment from 'https://cdn.skypack.dev/moment';
console.log(moment().format());

如果要导入的包名有作用域,也只有把作用域带上就行,比方要导入@wanglin1994/markjs

import Markjs from "https://cdn.skypack.dev/@wanglin1994/markjs";
new Markjs();

指定版本

Skypack会依据咱们提供的包名去 npm 上进行实时的查问,并返回包的最新版本,就像咱们平时执行 npm install PACKAGE_NAME 一样,如果你须要导入指定的版本,那么也能够指定版本号,它遵循semverSemantic Version(语义化版本))标准,你能够像上面这样导入指定的版本:

https://cdn.skypack.dev/react@16.13.1   // 匹配 react v16.13.1
https://cdn.skypack.dev/react@16      // 匹配 react 16.x.x 最新版本
https://cdn.skypack.dev/react@16.13    // 匹配 react 16.13.x 最新版本
https://cdn.skypack.dev/react@~16.13.0  // 匹配 react v16.13.x 最新版本
https://cdn.skypack.dev/react@^16.13.0  // 匹配 react v16.x.x  最新版本

指定导出包或指定导出文件

默认状况下,Skypack会返回包主入口点指定的文件,也就是 package.jsonmain字段或 module 字段对应的文件,然而有时候这可能并不是咱们须要的,以 vue@2 为例:

能够看到页面输入是一片空白,这是为什么呢,让咱们关上 vue2.6.14 版本的 npm 包,首先能够看到 dist 目录里提供了很多文件:

依据 package.json 能够看到它的主入口为:

指向的文件都只蕴含运行时,也就是不蕴含编译器,所以它没有在浏览器编译模板的能力,所以它就把 {{message}} 内容给疏忽了,咱们要导入的应该是 vue.esm.browser.jsvue.esm.browser.min.js

Skypack也反对让咱们导入指定的文件:

import Vue from 'https://cdn.skypack.dev/vue@2.6.11/dist/vue.esm.browser.js'

在包名前面拼接上门路即可:

以这种形式尽管能够加载到咱们指定的文件,然而有一个很大的限度,就是如果要加载的文件不是 ES 模块,比方是 commonjs 模块,那么 Skypack 是不会主动对文件进行转换的,只有以按包名称 (主入口) 应用时才会进行解决。

css 文件

有些包不仅提供了 js 文件,还提供了 css 文件,常见于各种组件库,比方element-ui,示例如下:

<div id="app">
    <div>{{title}}</div>
    <el-button type="success"> 胜利按钮 </el-button>
    <el-button type="primary" icon="el-icon-edit" circle></el-button>
    <el-input v-model="input" placeholder="请输出内容"></el-input>
</div>
import Vue from 'vue@2.6.11/dist/vue.esm.browser.js'
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

Vue.use(ElementUI);

new Vue({
  el: '#app',
  data() {
    return {
      title: 'Element UI',
      input: ''
    }
  }
})

咱们间接在 js 外面导入 element-uicss文件,在咱们平时的开发中这是很失常的,不过在浏览器上的运行后果如下:

显然是无奈在 ES 模块里间接导入 css,所以咱们须要把css 通过传统款式的形式引入:

@import 'element-ui/lib/theme-chalk/index.css'

固定 url

以包名称进行导入尽管不便,但因为每次都是返回最新版本,所以很可能呈现不兼容的问题,在理论生产环境中是须要导入特定版本的,Skypack会主动生成固定的URL

生产环境咱们只有替换成图中划线的两个 URL 之一即可。

存在的问题

Skypack看起来很不错,然而现实是美妙的,事实是残暴的。

首先第一个问题就是国内的网络拜访 Skypack 的服务一言难尽,反正笔者应用时一会能申请到一会申请不到,十分不稳固。

第二个问题就是有些简单的包可能会失败,比方 dayjsvueelement-plus 等包的最新版本笔者尝试发现 Skypack 均编译失败了:

反正笔者目前应用下来发现失败概率还是很高的,你得不停的尝试不同的版本不同的文件,非常麻烦。

第三个问题笔者遇到的是 css 外面应用了在线字体,无奈失常加载:

鉴于以上问题,所以想用在理论生产环境中还是算了吧。

入手实现一个简略版

最初让咱们用 nodejs 来实现一个超级简略版本的Skypack

起个服务

创立一个新我的项目,在我的项目根目录新建一个 index.html 文件,用来测试 ES 模块,而后应用 Koa 搭建一个服务,装置:

npm i koa @koa/router koa-static
const Koa = require("koa");
const Router = require("@koa/router");
const serve = require('koa-static');

// 创立利用
const app = new Koa();

// 动态文件服务
app.use(serve('.'));

// 路由
const router = new Router();
app.use(router.routes()).use(router.allowedMethods())

router.get("/(.*)", (ctx, next) => {
  ctx.body = ctx.url;
  next();});

app.listen(3000);
console.log('服务启动胜利!');

当咱们拜访 /index.html 即可拜访 demo 页面:

拜访其余门路即可获取到拜访的url

下载 npm 包

先不思考带作用域的包,咱们暂且认为门路的第一段就是要下载的包名,而后咱们应用 npm install 命令下载包(有其余更好的形式欢送在评论区留言~):

const {execSync} = require('child_process');
const fs = require("fs");
const path = require("path");

router.get("/(.*)", async (ctx, next) => {let pkg = ctx.url.slice(1).split('/')[0];// 包名,比方 vue@2.6
  let [pkgName] = pkg.split('@');// 去除版本号,获取纯包名
  if (pkgName) {
    try {
      // 该包没有装置过
      if (!checkIsInstall(pkgName)) {
        // 安装包
        execSync('npm i' + pkg);
      }
    } catch (error) {ctx.throw(400, error.message);
    }
  }
  next();});

// 查看某个包是否已装置过,暂不思考版本问题
const checkIsInstall = (name) => {let dest = path.join("./node_modules/", name);
  try {fs.accessSync(dest, fs.constants.F_OK);
    return true;
  } catch (error) {return false;}
};

这样当咱们拜访 /moment 时如果没有装置这个包就会进行装置,曾经装置了则间接跳过。

解决 commonjs 模块

咱们能够读取下载的包的 package.json 文件,满足以下条件则代表是 commonjs 模块:

1.type字段不存在或者值为commonjs

2. 不存在 module 字段

const path = require("path");
const fs = require("fs");

router.get("/(.*)", async (ctx, next) => {let pkg = ctx.url.slice(1).split("/")[0];
  let [pkgName] = pkg.split("@");
  if (pkgName) {
    try {if (!checkIsInstall(pkgName)) {execSync("npm i" + pkg);
      }
      // 读取 package.json
      let modulePkg = readPkg(pkgName);
      // 判断是否是 commonjs 模块
      let res = isCommonJs(modulePkg);
      ctx.body = '是否是 commonjs 模块:' + res;
    } catch (error) {ctx.throw(400, error.message);
    }
  }
  next();});

// 读取指定模块的 package.json 文件
const readPkg = (name) => {return JSON.parse(fs.readFileSync(path.join('./node_modules/', name, 'package.json'), 'utf8'));
};

// 判断是否是 commonjs 模块
const isCommonJs = (pkg) => {return (!pkg.type || pkg.type === 'commonjs') && !pkg.module;
}

commonjs模块显然是无奈作为 ES 模块被加载的,所以须要先转换成 ES 模块,转换咱们能够应用 esbuild。

代码如下:

npm install esbuild
const {transformSync} = require("esbuild");
router.get("/(.*)", async (ctx, next) => {let pkg = ctx.url.slice(1).split("/")[0];
  let [pkgName] = pkg.split("@");
  if (pkgName) {
    try {if (!checkIsInstall(pkgName)) {execSync("npm i" + pkg);
      }
      let modulePkg = readPkg(pkgName);
      let res = isCommonJs(modulePkg);
      // 是 commonjs 模块
      if (res) {
        ctx.type = 'text/javascript';
        // 转换成 es 模块
        ctx.body = commonjsToEsm(pkgName, modulePkg);
      }
    } catch (error) {ctx.throw(400, error.message);
    }
  }
  next();});

// commonjs 模块转换为 esm
const commonjsToEsm = (name, pkg) => {let file = fs.readFileSync(path.join('./node_modules/', name, pkg.main), 'utf8');
  return transformSync(file, {format: 'esm'}).code;
}

moment未转换前的源码如下:

转换后如下:

咱们在 index.html 文件里测试一下,新增上面代码:

<div id="app"></div>
<script type="module">
    import moment from '/moment';
    document.getElementById('app').innerHTML = moment().format('YYYY-MM-DD');
</script>

解决 ES 模块

ES模块会比较复杂一些,因为可能一个模块中又导入了另一个模块,首先咱们来反对一下导入包中的指定文件,比方咱们要导入 dayjs/esm/index.js,当导入指定门路时咱们就不进行commonjs 检测了,间接默认为 ES 模块:

router.get("/(.*)", async (ctx, next) => {let urlArr = ctx.url.slice(1).split("/");// 切割门路
  let pkg = urlArr[0]; // 包名
  let pkgPathArr = urlArr.slice(1); // 包中的门路
  let [pkgName] = pkg.split("@"); // 指定了版本号
  if (pkgName) {
    try {if (!checkIsInstall(pkgName)) {execSync("npm i" + pkg);
      }
      if (pkgPathArr.length <= 0) {let modulePkg = readPkg(pkgName);
        let res = isCommonJs(modulePkg);
        if (res) {
          ctx.type = "text/javascript";
          ctx.body = commonjsToEsm(pkgName, modulePkg);
        } else {
          // es 模块
          ctx.type = "text/javascript";
          // 默认入口
          ctx.body = handleEsm(pkgName, [modulePkg.module || modulePkg.main]);
        }
      } else {
        // es 模块
        ctx.type = "text/javascript";
        // 指定入口
        ctx.body = handleEsm(pkgName, pkgPathArr);
      }
    } catch (error) {ctx.throw(400, error.message);
    }
  }
  next();});

咱们晓得当咱们导入 js 文件时是能够省略文件后缀的,比方 import xxx from 'xxx/xxx',所以咱们要查看是否省略了,省略了须要补上,handleEsm 函数如下:

// 解决 es 模块
const handleEsm = (name, paths) => {
  // 如果没有文件扩展名,则默认为 `.js` 后缀
  let last = paths[paths.length - 1];
  if (!/\.[^.]+$/.test(last)) {paths[paths.length - 1] = last + '.js';
  }
  let file = fs.readFileSync(path.join("./node_modules/", name, ...paths),
    "utf8"
  );
  return transformSync(file, {format: "esm",}).code;
};

dayjs/esm/index.js这个文件外面又引入了其余文件:

每个 import 语句浏览器会收回一个对应的申请,让咱们批改一下 index.html 进行测试:

<script type="module">
    import dayjs from '/dayjs/esm/index.js';
    document.getElementById('app').innerHTML = dayjs().format('YYYY-MM-DD HH:mm:ss');
</script>

能够看到的确每个 import 语句都收回了一个对应的申请,页面运行后果如下:

写到这里你可能会发现其实无需判断是否是 commonjs 模块,都交给 esbuild 解决就行了,让咱们精简一下代码:

router.get("/(.*)", async (ctx, next) => {let urlArr = ctx.url.slice(1).split("/");
  let pkg = urlArr[0];
  let pkgPathArr = urlArr.slice(1);
  let [pkgName] = pkg.split("@");
  if (pkgName) {
    try {if (!checkIsInstall(pkgName)) {execSync("npm i" + pkg);
      }
      let modulePkg = readPkg(pkgName);
      ctx.type = "text/javascript";
      ctx.body = handleEsm(pkgName, pkgPathArr.length <= 0 ? [modulePkg.module || modulePkg.main] : pkgPathArr);
    } catch (error) {ctx.throw(400, error.message);
    }
  }
  next();});

打包到一个文件里

axios 的入口文件为例:

应用 esbuildtransformSync办法编译后的后果为:

能够看到 require 办法还是存在,并没有把 require 的内容都打包进来,这样的 es 模块是无奈应用的,如果须要把依赖都打包到一个文件内咱们就不能应用 transformSync 办法了,须要应用buildSync,这个办法执行的是文件的编译,就是输入输出都是文件的模式。

const {buildSync} = require("esbuild");
// 解决 es 模块
const handleEsm = (name, paths) => {const outfile = path.join("./node_modules/", name, "esbuild_output.js");
  // 查看是否曾经编译过了
  if (checkIsExist(outfile)) {return fs.readFileSync(outfile, "utf8");
  }
  // 如果没有文件扩展名,则默认为 `.js` 后缀
  let last = paths[paths.length - 1];
  if (!/\.[^.]+$/.test(last)) {paths[paths.length - 1] = last + ".js";
  }
  // 编译文件
  buildSync({entryPoints: [path.join("./node_modules/", name, ...paths)],// 输出
    format: "esm",
    bundle: true,
    outfile,// 输入
  });
  return fs.readFileSync(outfile, "utf8");
};

// 查看某个文件是否存在
const checkIsExist = (file) => {
  try {fs.accessSync(file, fs.constants.F_OK);
    return true;
  } catch (error) {return false;}
};

再让咱们 axios 编译后的后果:

总结

本文介绍了一下 Skypack 的应用,以及写了一个简略版的 ES 模块 CDN 服务,如果你用过 vitejs,就会发现这就是它所做的事件之一,当然vite 的实现要简单的多。

demo的源代码地址 https://github.com/wanglin2/ES_Modules_CDN。

正文完
 0