場景復現
筆者最近給自己的專案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
規範的模組,我們需要通過link
或script
標籤引入,但是現在基本上所有的現代瀏覽器都原生支援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
一樣,如果你需要匯入指定的版本,那麼也可以指定版本號,它遵循semver
(Semantic 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.json
的main
欄位或module
欄位對應的檔案,但是有時候這可能並不是我們需要的,以vue@2
為例:
可以看到頁面輸出是一片空白,這是為什麼呢,讓我們開啟vue2.6.14
版本的npm
包,首先可以看到dist
目錄裡提供了很多檔案:
根據package.json
可以看到它的主入口為:
指向的檔案都只包含執行時,也就是不包含編譯器,所以它沒有在瀏覽器編譯模板的能力,所以它就把{{message}}
內容給忽略了,我們要匯入的應該是vue.esm.browser.js
或vue.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-ui
的css
檔案,在我們平常的開發中這是很正常的,不過在瀏覽器上的執行結果如下:
顯然是無法在ES
模組裡直接匯入css
,所以我們需要把css
通過傳統樣式的方式引入:
@import 'element-ui/lib/theme-chalk/index.css'
固定url
以包名稱進行匯入雖然方便,但因為每次都是返回最新版本,所以很可能出現不相容的問題,在實際生產環境中是需要匯入特定版本的,Skypack
會自動生成固定的URL
:
生產環境我們只要替換成圖中劃線的兩個URL
之一即可。
存在的問題
Skypack
看起來很不錯,然而理想是美好的,現實是殘酷的。
首先第一個問題就是國內的網路訪問Skypack
的服務一言難盡,反正筆者使用時一會能請求到一會請求不到,非常不穩定。
第二個問題就是有些複雜的包可能會失敗,比如dayjs
、vue
、element-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
的入口檔案為例:
使用esbuild
的transformSync
方法編譯後的結果為:
可以看到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。