在本节中,仅实现加载 html、css、js 类型的资源。
在本节中, 使用
@module/
为前缀的资源地址,表示是第三方模块包,需要从node_modules
中解析加载。
在一个 前端的工程化项目中, 资源包括了 项目中的 源文件、 通过如 npm/yarn/pnpm
等包管理工具下载安装的
第三方模块包。
如,在一个项目中,有如下资源内容:
.
├── node_modules
│ └── js-cookie
├── index.html
├── index.css
└── main.js
其中 index.html
内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<script type="module" src="@module/js-cookie"></script>
<script type="module" src="/main.js"></script>
</body>
</html>
使用 开发服务 可以正确加载 index.css
和main.js
,但是 @module/js-cookie
时,却遇到了问题, @module/js-cookie
不是一个标准的资源路径,当前的 开发服务 不能正确读取到该资源,
对浏览器来说,也无法判断该资源的类型。
路径解析
import 语句
在一个 ES Module
的 脚本文件中,我们通常会使用 import
语句导入其他的模块,导入文件可能是 第三方模块包,也可能是一个相对路径或绝对路径:
import axios from 'axios'
import mod1 from './mod1.js'
import mod2 from '/mod2.js'
这些路径对于服务器,可能并不能直接正确响应到资源,因此在服务器端,我们需要对这些路径进行转换。
比如, import axios from 'axios'
应转换为 import axios from '@module/axios'
。
通过 @module
告知服务器响应该资源时,应该从一个 第三方模块 中解析资源。
因此,我们首先需要先读取到对应资源的内容,查找内容中的 import
语句,并对其进行处理:
const RE_IMPORTER = /(?:\s+from|import)\s+['"]([^'"]+?)['"]/g
app.use((req, res) => {
// ...
const content = await fsp.readFile(id, 'utf-8');
res.end(rewriteImporters(content, relativePath));
// ...
})
function rewriteImporters(content: string, relativePath = '') {
return content.replace(RE_IMPORTER, (match, filepath) => {
const starts = match.startsWith('import') ? 'import' : ' from'
/**
* 以 './' '../'开口,或 '.' 的相对路径,需要根据根据当前资源的路径转换
*/
if (filepath.startsWith('./') || filepath.startsWith('../') || filepath === '.') {
return `${starts} '${relativePath? path.join(relativePath, filepath) : filepath}'`
}
/**
* 非相对路径和绝对路径,表示这是一个第三方模块包
*/
if (filepath[0] !== '/') {
return `${starts} '/@module/${filepath}'`
}
return match
})
}
node_modules
@module/
表示一个位于 node_modules
中的包,js-cookie
则是包名,在js-cookie
包中,我们可以查看到 package.json
中的配置:
"name": "js-cookie",
"module": "dist/js.cookie.mjs",
"exports": {
".": {
"import": "./dist/js.cookie.mjs",
"require": "./dist/js.cookie.js"
},
"./package.json": "./package.json"
},
在 nodejs
的标准中, 在package.json
中通过 main
或 exports
定义包的入口地址。
查看官方文档 main-entry-point-export (opens in a new tab) :
所以我们可以通过 js-cookie/package.json
获取 js-cookie
的 入口文件路径:
async function resolveExports(id: string) {
const [,,packageName, ...other] = id.split('/');
const pathname = other.join('/') || '.';
let pkg = {} as any;
try {
pkg = JSON.parse(await fsp.readFile(path.join(process.cwd(), 'node_modules', packageName, 'package.json'),'utf-8'));
} catch { /* empty */ }
if (!pkg.exports && pkg.main) {
id = pathname === '.' ? pkg.main : pathname;
} else if (pkg.exports) {
if (typeof pkg.exports === 'string') {
id = pathname === '.' ? pkg.exports : pathname;
} else {
const subExports = pkg.exports[pathname];
if (subExports) {
id = typeof subExports === 'string' ? subExports : subExports['import'];
} else {
id = pathname
}
}
}
return { packageName, id }
}
本节不完全实现
exports
的协议,仅针对js-cookie
。在
vite
中使用 resolve.exports (opens in a new tab) 实现对exports
的解析。
成功获取到 第三方模块的真实路径后,则继续读取该资源内容,并响应给浏览器:
server.use(async (req, res, next) => {
let id = req.url === '/' ? '/index.html' : req.url;
const isModule = id.startsWith('/@module')
let relativePath = ''
if (isModule) {
const { packageName, id: _id } = await resolveExports(id);
id = path.join(process.cwd(), 'node_modules', packageName, _id);
const subDir = path.dirname(_id);
relativePath = path.join('/@module', packageName, subDir)
} else {
id = path.join(process.cwd(), 'template', id);
}
if (fs.existsSync(id)) {
const content = await fsp.readFile(id, 'utf-8');
res.end(rewriteImporters(content, relativePath));
} else {
next()
}
})
响应头
虽然已经成功解析了 html 文件中 @module
导入的资源路径, 可以通过该路径加载资源文件并返回。
但是浏览器会给出错误信息:
Failed to load module script:
Expected a JavaScript module script but the server responded with a MIME type of "".
Strict MIME type checking is enforced for module scripts per HTML spec.
这是由于浏览器无法识别 @module/js-cookie
资源的 mime-type
。浏览器会根据文件名后缀判断资源类型,比如 main.js
被识别为 application/javascript
。 但显然 @module/js-cookie
无法被识别,为了让浏览器能够正确识别,我们需要在响应头中,添加 content-type
:
if (/\.[mc]?[tj]s$/.test(id)) {
res.setHeader('Content-Type', 'application/javascript');
}