使用Skypack在瀏覽器上直接匯入ES模組

街角小林發表於2022-05-06

場景復現

筆者最近給自己的專案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

相關文章