使用Skypack在浏览器上直接导入ES模块

简介: 使用Skypack在浏览器上直接导入ES模块

image.png


场景复现


笔者最近给自己的项目CodeRun增加了一个直接在浏览器上使用ES模块的功能,之前使用一个包前需要先找到它的在线CDN地址然后引进来,就像这样:


image.png


现在可以直接这样:


image.png


那么这是怎么实现的呢,很简单,使用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模块,只有设置了这个属性才能使用模块语法。


image.png


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为例:


image.png


可以看到页面输出是一片空白,这是为什么呢,让我们打开vue2.6.14版本的npm包,首先可以看到dist目录里提供了很多文件:


image.png


根据package.json可以看到它的主入口为:


image.png


指向的文件都只包含运行时,也就是不包含编译器,所以它没有在浏览器编译模板的能力,所以它就把{{message}}内容给忽略了,我们要导入的应该是

vue.esm.browser.jsvue.esm.browser.min.js


image.png


Skypack也支持让我们导入指定的文件:


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


在包名后面拼接上路径即可:


image.png


以这种方式虽然可以加载到我们指定的文件,但是有一个很大的限制,就是如果要加载的文件不是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文件,在我们平常的开发中这是很正常的,不过在浏览器上的运行结果如下:


image.png


显然是无法在ES模块里直接导入css,所以我们需要把css通过传统样式的方式引入:


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


image.png


固定url


以包名称进行导入虽然方便,但因为每次都是返回最新版本,所以很可能出现不兼容的问题,在实际生产环境中是需要导入特定版本的,Skypack会自动生成固定的URL


image.png


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


存在的问题


Skypack看起来很不错,然而理想是美好的,现实是残酷的。


首先第一个问题就是国内的网络访问Skypack的服务一言难尽,反正笔者使用时一会能请求到一会请求不到,非常不稳定。


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


image.pngimage.png



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


第三个问题笔者遇到的是css里面使用了在线字体,无法正常加载:


image.png


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


动手实现一个简单版


最后让我们用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页面:


image.png



访问其他路径即可获取到访问的url


image.png

下载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;
}


image.png



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未转换前的源码如下:


image.png

转换后如下:image.pngimage.png

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


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


image.png


处理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这个文件里面又引入了其他文件:


image.png


每个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>

image.png


可以看到确实每个import语句都发出了一个对应的请求,页面运行结果如下:


image.png


写到这里你可能会发现其实无需判断是否是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的入口文件为例:


image.png


使用esbuildtransformSync方法编译后的结果为:


image.png


可以看到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编译后的结果:


image.png


总结


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


相关文章
|
Web App开发 数据安全/隐私保护
Chrome谷歌浏览器密码数据导出与导入管理(实现数据无缝同步)
Chrome谷歌浏览器密码数据导出与导入管理(实现数据无缝同步)
680 0
|
1月前
|
Web App开发 Linux iOS开发
Chrome浏览器如何导出所有书签并导入书签
【11月更文挑战第4天】本文介绍了如何在 Chrome 浏览器中导出和导入书签。导出时,打开书签管理器,点击“整理”按钮选择“导出书签”,保存为 HTML 文件。导入时,同样打开书签管理器,点击“整理”按钮选择“导入书签”,选择之前导出的 HTML 文件即可。其他主流浏览器也支持导入这种格式的书签文件。
346 2
|
编译器 Linux C语言
Qt浏览器模块的几点说明
Qt浏览器模块的几点说明
|
7月前
|
Web App开发 测试技术 API
Postman软件基本用法:浏览器复制请求信息并导入到软件从而测试、发送请求
Postman软件基本用法:浏览器复制请求信息并导入到软件从而测试、发送请求
179 1
|
Web App开发
浏览器导入和导出cookie
浏览器导入和导出cookie
|
Web App开发
chrome浏览器 导入burp证书 失败 无法抓包
chrome浏览器 导入burp证书 失败 无法抓包
496 0
|
XML 存储 JSON
浅析浏览器书签的导入和导出
浅析浏览器书签的导入和导出
168 0
|
安全 JavaScript 前端开发
浏览器内核之WebKit 架构与模块
此文章是我最近在看的【WebKit 技术内幕】一书的一些理解和做的笔记。 而【WebKit 技术内幕】是基于 WebKit 的 Chromium 项目的讲解。
610 0
浏览器内核之WebKit 架构与模块
|
Web App开发
跨浏览器书签导入实例演示,更好的管理书签!
因为种种原因,我们可能会用到多个浏览器,每个浏览器上都会有一部分我们珍藏的书签,那么,是时候整理一下了,来个书签大集合!
243 0
跨浏览器书签导入实例演示,更好的管理书签!
|
Web App开发 数据采集 JavaScript
16、web爬虫讲解2—PhantomJS虚拟浏览器+selenium模块操作PhantomJS
【http://bdy.lqkweb.com】 【http://www.swpan.cn】 【转载自:http://www.lqkweb.com】 PhantomJS虚拟浏览器 phantomjs 是一个基于js的webkit内核无头浏览器 也就是没有显示界面的浏览器,利用这个软件,可以获取到网址js加载的任何信息,也就是可以获取浏览器异步加载的信息 下载网址:http://phantomjs.
1335 0