头图

scene reproduction

The author recently added a function to use the ES module directly on the browser to my project CodeRun . Before using a package, you need to find its online CDN address and then introduce it, like this:

【粘贴图片】

Now you can do this directly:

【粘贴图片】

So how is this achieved, very simple, using Skypack , the import statement in the picture above will actually end up like this:

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

This conversion is achieved through babel , we can write a babel plugin, when accessing the import statement, judge if it is a "naked" import and splicing it Skypack Address:

 // 转换导入语句
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" c1de63979a331cbbcae3f86fd29d7c1f---的属性,因为浏览script ES模块,只有Set this property to use module syntax.

【粘贴图片】

Skypack

Skypack CDN服务, CDN服务有点不一样,传统的CDN文件The fixed access address, which package you want to use, you need to go to the release file of this package to find the file you want.

Most of the early packages provided IIFE or commonjs canonical modules, we need to import through link or script tags Basically all modern browsers natively support the ES module, so we can use the module syntax directly on the browser. If you use the traditional CDN service, then you first need a package that provides the ES module file, and then we find it from CDN ES version of the file address, and then use, if a package does not provide ES version, then we can not directly import it in the browser as a module, and Skypack is specially designed for modern browsers, it will automatically do the conversion for us, we just need to tell it the name of the package we want to import, even if this package provides a file with version commonjs , Skypack will also return the ES module, so we can import it directly on the browser as a module.

basic use

Its usage is simple:

 https://cdn.skypack.dev/PACKAGE_NAME

Just splice the package name you need to import, for example, we want to import moment :

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

If the package name to be imported has a scope, just bring the scope, for example, to import @wanglin1994/markjs :

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

specified version

Skypack will go to npm for real-time query according to the package name we provide, and return the latest version of the package, just like we usually execute npm install PACKAGE_NAME , if You need to import the specified version, then you can also specify the version number, it follows the semver ( Semantic Version (semantic version)) specification, you can import the specified version like this:

 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  最新版本

Specify the export package or specify the export file

默认情况下, Skypack会返回包主入口点指定的文件,也就是package.jsonmain module的file, but sometimes this may not be what we need, take vue@2 as an example:

图片名称

You can see that the page output is blank, why is this, let us open the vue2.6.14 version of the npm package, you can first see the dist which is provided in the directory Lots of files:

【粘贴图片】

According to package.json it can be seen that its main entrance is:

【粘贴图片】

The files pointed to only contain the runtime, that is, do not contain the compiler, so it does not have the ability to compile templates in the browser, so it ignores the content of {{message}} , what we want to import should vue.esm.browser.js or vue.esm.browser.min.js :

【粘贴图片】

Skypack also supports Let's import the specified file:

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

Just splicing the path after the package name:

【粘贴图片】

Although it can be loaded into the file we specify in this way, there is a big limitation, that is, if the file to be loaded is not a ES module, such as a commonjs Skypack , then- Skypack The file will not be converted automatically, it will only be processed when it is used by package name (main entry).

css file

Some packages not only provide the js file, but also the css file, which is commonly found in various component libraries, such as element-ui , for example:

 <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: ''
    }
  }
})

We directly import the js element-ui css file in ---9de6dd90a6ed0533763def7d88aa6150------327d4999ac7c5408558c1322e802b92e---, which is normal in our usual development, but the result of running on the browser is as follows :

【粘贴图片】

Obviously, it is impossible to directly import css in the ES module, so we need to import --- css through the traditional style:

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

【粘贴图片】

fixed url

Although it is convenient to import by package name, because the latest version is returned every time, incompatibility problems are likely to occur. In the actual production environment, a specific version needs to be imported. Skypack will be automatically generated and fixed The URL :

【粘贴图片】

In the production environment, we only need to replace it with one of the two lines in the figure URL .

existing problems

Skypack looks very good, but the ideal is beautiful, the reality is cruel.

First of all, the first problem is that the service of domestic network access Skypack is difficult to describe. Anyway, when I use it, I can request it for a while, but it cannot be requested for a while, which is very unstable.

第二个问题就是有些复杂的包可能会失败,比如dayjsvueelement-plus Skypack Both compilation failed:

【粘贴图片】

【粘贴图片】

Anyway, the author has found that the probability of failure is still very high after using it. You have to keep trying different versions of different files, which is very troublesome.

The third problem I encountered is css which uses online fonts and cannot be loaded normally:

【粘贴图片】

In view of the above problems, it is better to use it in the actual production environment.

Hands-on implementation of a simple version

Finally let's use nodejs to implement a super simple version Skypack .

start a service

Create a new project, create a new index.html file in the project root directory to test the ES module, and then use Koa build a service, install:

 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('服务启动成功!');

When we visit /index.html we can visit demo page:

【粘贴图片】

Access other paths to get access url :

【粘贴图片】

Download npm package

Ignoring the scoped package first, we temporarily think that the first segment of the path is the package name to be downloaded, and then we use the npm install command to download the package (there are other better ways, welcome to leave a message in the comment area~ ):

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

In this way, when we visit /moment , if this package is not installed, it will be installed, and if it is already installed, it will be skipped directly.

Handling commonjs modules

We can read the package.json file of the downloaded package, if the following conditions are met, it means the commonjs module:

1. type field does not exist or has value commonjs

2. Does not exist module field

 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 Module obviously cannot be loaded as ES module, so it needs to be converted into ES module first, we can use esbuild for conversion.

code show as below:

 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 The source code before conversion is as follows:

【粘贴图片】

Converted as follows:

【粘贴图片】

【粘贴图片】

Let's test it in the index.html file and add the following code:

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

【粘贴图片】

Handling ES modules

ES module will be more complicated, because one module may import another module. First, let's support the specified file in the import package. For example, we want to import dayjs/esm/index.js , when When importing the specified path, we will not perform the commonjs detection, and the direct default is ES module:

 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();
});

We know that when we import the js file, the file suffix can be omitted, such as import xxx from 'xxx/xxx' , so we have to check whether it is omitted, and it needs to be added, handleEsm The function is as follows:

 // 处理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 This file introduces other files:

【粘贴图片】

Each import statement browser will issue a corresponding request, let's modify index.html to test:

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

【粘贴图片】

It can be seen that each import statement has issued a corresponding request, and the page running results are as follows:

【粘贴图片】

After writing this, you may find that there is no need to judge whether it is the commonjs module, and it is all handed over to esbuild for processing. Let's simplify the code:

 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();
});

package into a file

Take the entry file of axios as an example:

【粘贴图片】

The result after compiling with the esbuild transformSync method of ---40d1cca92887bc905d0ff8f3db569aa7--- is:

【粘贴图片】

It can be seen that the require method still exists, and the content of ---3405ca2a49a7287539a9bb7e1bb4846e require is not packaged, so the es module cannot be used. We can't use the transformSync method after packaging it into a file, we need to use buildSync This method executes the compilation of the file, that is, the input and output are in the form of files.

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

Let's axios compile the result:

【粘贴图片】

Summarize

本文介绍Skypack的使用,以及写了一个简单版的ES模块CDN服务,如果你用过vitejs , You'll find that this is one of the things it does, although of course vite the implementation is much more complicated.

The source code address of demo .


街角小林
883 声望771 粉丝