前一阵蹭了几节课,课里讲到dev-server
和按需加载
的基本原理,比较有趣。今天来实现一个。
前端的实现基础,是基于浏览器对es-module
的原生支持,也就省去了对代码降级和打包的过程,使整个架构相对简单。
搭建基础服务
基本文件结构:
- web
| - index.html
| ...
- server
| - dev-server.js
| ...
- node_modules
| ...
复制代码
静态文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1>DEV SERVER</h1>
</body>
</html>
复制代码
服务端配置
为了方便后续的迭代扩展,我们把配置文件剥离出来。
// server/config.js
const path = require('path')
exports.getDevServerConfig = () => ({
port: '3003',
web: path.resolve(__dirname, '../web'),
index: path.resolve(__dirname, '../web/index.html'),
moduels: path.resolve(__dirname, '../node_modules'),
})
复制代码
Koa服务
这次还是用Koa
来实现:
// server/dev-server.js
const fs = require('fs')
const Koa = require('koa')
const { getDevServerConfig } = require('./config')
const config = getDevServerConfig()
const app = new Koa()
app.use(ctx => {
const { url } = ctx.request
if(url === '/') {
const indexHtml = fs.readFileSync(config.index, 'utf-8')
ctx.body = indexHtml
ctx.type = 'text/html'
} else {
ctx.body = '<h1>404</h1>'
ctx.type = 'text/html'
ctx.status = 404
}
})
app.listen(config.port)
console.log(`DogServer started: http://localhost:${config.port}`)
复制代码
基本逻辑是:当访问服务的根路径/
时,返回web/index.html
的内容。
启动服务看一下:
% node dev-server.js
DogServer started: http://localhost:3003
复制代码
启动成功。
前端代码改造
引入入口js文件
最常规的做法,是嵌入一个<script>
标签。
// app.js
console.log('Hello there')
复制代码
然后更改html文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="./app.js"></script>
</head>
<body>
<h1>DEV SERVER</h1>
</body>
</html>
复制代码
由于我们在服务端逻辑中,只处理了根目录/
的情况,所以app.js
文件404了。
我们将这部分的服务端代码优化一下:
app.use(ctx => {
const { url } = ctx.request
if(url === '/') {
const indexHtml = fs.readFileSync(config.index, 'utf-8')
ctx.body = indexHtml
ctx.type = 'text/html'
} else {
// 拼接文件为web目录文件
const localFile = path.join(config.web, url)
// 如果文件存在
if(fs.existsSync(localFile)) {
ctx.body = fs.readFileSync(localFile)
} else {
ctx.type = 'text/html'
ctx.body = '<h1>404</h1>'
ctx.status = 404
}
}
})
复制代码
重启服务端,页面运行OK。
刚才这一波操作之后,浏览器url地址就与web
目录的文件一一映射了。
使用import导入文件
我们先改一下app.js
// app.js
import { add } from './add.js'
console.log('2 + 13 = ', add(2, 13))
console.log('Hello there')
复制代码
添加add.js
文件
// add.js
export const add = (a, b) => a + b
复制代码
刷新页面时提示:
Uncaught SyntaxError: Cannot use import statement outside a module
复制代码
那么html页面也需要调整一下,script
更改为type="module"
:
<script src="./app.js" type="module"></script>
复制代码
控制台又出现了另一个问题:
Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "application/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec.
复制代码
看来script的type属性变化,各种跳。做个分支任务,搞一下服务端响应头的问题。
ContentType映射
const getContentType = (file) => {
const [ext = ''] = file.match(/\.[^\.\/\\]+$/) || []
switch(ext) {
case '.js': return 'text/javascript'
case '.css': return 'text/css'
case '.html':
case '.htm':
return 'text/html'
}
return 'text/plain'
}
复制代码
响应头type配置
// 如果文件存在
if(fs.existsSync(localFile)) {
ctx.body = fs.readFileSync(localFile)
ctx.type = getContentType(localFile) // 这里
}
复制代码
重启服务器看下结果
# 更改前
Content-Type: application/octet-stream
#更改后
Content-Type: text/javascript; charset=utf-8
复制代码
OK,控制台响应符合预期:
2 + 13 = 15
Hello there
复制代码
提升开发舒适性
引用js模块时省略.js
后缀
这也就是webpack配置里的extensions
配置。我们写一个方法,对此类引用地址做预处理:
const fixFileName = (url) => {
if(/\.[^\.\/\\]+$/.test(url)) {
return url
}
const fin = ['.js', '.jsx', '.css', '.json'].find(ext => fs.existsSync(`${url}${ext}`)) || ''
return `${url}${fin}`
}
// ...
// 拼接文件为web目录文件
const localFile = fixFileName(path.join(config.web, url))
复制代码
然后更改app.js
import { add } from './add'
复制代码
重启服务,运行OK。事实上,这时候import
操作,是向http://127.0.0.1:3003/add
这个地址请求,服务端所做的更改,则是兼容了这个url。
引用node_modules模块
例如这种:
import Vue from 'vue'
复制代码
我们先看下浏览器对这种写法的反应:
Uncaught TypeError: Failed to resolve module specifier "vue". Relative references must start with either "/", "./", or "../".
复制代码
这种写法浏览器是不支持的,也就是这个请求并不会发送到服务端,那服务端也谈不上对此兼容。
所以我们需要换个思路:前端重编译。这个做法,与es6
降级es5
的套路是一致的。当服务端向前端返回js文件的时候,对内部的代码进行重写,修改类似的地方,让浏览器能够正确读取。
还记得配置文件config.js
当中的modules
吗?这时候该它出场了。
代码重构方法:
const rebuildCode = content => {
if(content instanceof Buffer) {
content = content.toString('utf-8')
}
// 搜索 `from "xxx"` 的部分
content = content.replace(/from\s+['"]([^'"\r\n]+)['"])/g, (m, g1) => {
// 判断 xxx 是否是标准的路径开头
if(/^[\.\/]/.test(g1)) {
return m
}
// 否则就更改为 node_modules 引用
const baseDir = path.join(config.moduels, g1)
const packageFile = path.join(baseDir, 'package.json')
const { module, main } = require(packageFile)
// module 是 es6 模块
return `from '${path.resolve(`/node_modules/${g1}`, module)}'`
})
}
// ...
// 如果文件存在
if(fs.existsSync(localFile)) {
ctx.body = rebuildCode(fs.readFileSync(localFile)) //
// ...
}
复制代码
看下效果,预期是服务端在返回app.js
文件时,import vue
的源地址应当发生变化:
import { add } from './add'
import Vue from '/node_modules/vue/dist/vue.runtime.esm.js'
console.log('2 + 13 = ', add(2, 13))
console.log('Hello there')
console.log(Vue)
复制代码
符合预期,现在来处理服务端对响应:
受限优化一下url转path的部分:
const resolveUrl = (url) => {
if(url.startsWith('/node_modules/')) {
return path.join(__dirname, '../', url)
} else {
return path.join(config.web, url)
}
}
// ...
// 拼接文件为web目录文件
const localFile = fixFileName(resolveUrl(url))
复制代码
重启服务看下,现在能看到,浏览器请求http://127.0.0.1:3003/node_modules/vue/dist/vue.runtime.esm.js
文件状态200,也能看到内容。但是控制台报错:
vue.runtime.esm.js:383 Uncaught ReferenceError: process is not defined
复制代码
具体报错
/**
* Show production mode tip message on boot?
*/
productionTip: process.env.NODE_ENV !== 'production'
复制代码
缺少一个全局process
对象。那我们继续使用这种重建代码
的方式,在html层面注入一个全局变量:
if(url === '/') {
const indexHtml = fs.readFileSync(config.index, 'utf-8')
const rebuildHtml = indexHtml.replace('<head>', '<head><script>window.process={env:{}};<\/script>')
ctx.body = rebuildHtml
ctx.type = 'text/html'
}
复制代码
直接硬性replace了一部分代码,比较粗暴,看服务端返回内容:
<!DOCTYPE html>
<html>
<head><script>window.process={env:{}};</script>
<meta charset="utf-8">
<script src="./app.js" type="module"></script>
</head>
<body>
<h1>DEV SERVER</h1>
</body>
</html>
复制代码
控制台也正常输出了vue包的内容
2 + 13 = 15
app.js:4 Hello there
app.js:5 ƒ Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword');
}
this._init(options)…
vue.runtime.esm.js:8484 You are running Vue in development mode.
Make sure to turn on production mode when deploying for production.
See more tips at https://vuejs.org/guide/deployment.html
复制代码
顺道一并,在
服务端处理type="module"
这个事看需求吧,我的目标是前端完全无感,不希望前端更改任何代码。
// 注入 process
let rebuildHtml = indexHtml.replace('<head>', '<head><script>window.process={env:{}};<\/script>')
// 添加入口脚本 type="module"
rebuildHtml = rebuildHtml.replace('<script ', '<script type="module" ')
复制代码
<!DOCTYPE html>
<html>
<head><script>window.process={env:{}};</script>
<meta charset="utf-8">
<script type="module" src="./app.js"></script>
</head>
<body>
<h1>DEV SERVER</h1>
</body>
</html>
复制代码
总结一下
今天边写边解决问题,大致走了以下几步:
- 构建DevServer,对各代码文件的路由进行正确响应:
getContentType
- DevServer对文件扩展名自动补全:
fixFileName
- DevServer对模块引用的支持:
rebuildCode
resolveUrl
- DevServer更改html入口,支持
process
- DevServer更改html入口,
type="module"
完整代码
server/config.js
const path = require('path')
exports.getDevServerConfig = () => ({
port: '3003',
web: path.resolve(__dirname, '../web'),
index: path.resolve(__dirname, '../web/index.html'),
moduels: path.resolve(__dirname, '../node_modules'),
})
复制代码
server/dev-server.js
const path = require('path')
const fs = require('fs')
const Koa = require('koa')
const { getDevServerConfig } = require('./config')
const config = getDevServerConfig()
const getContentType = (file) => {
const [ext = ''] = file.match(/\.[^\.\/\\]+$/) || []
switch(ext) {
case '.js': return 'text/javascript'
case '.css': return 'text/css'
case '.html':
case '.htm':
return 'text/html'
}
return 'text/plain'
}
const fixFileName = (url) => {
if(/\.[^\.\/\\]+$/.test(url)) {
return url
}
const fin = ['.js', '.jsx', '.css', '.json'].find(ext => fs.existsSync(`${url}${ext}`)) || ''
return `${url}${fin}`
}
const rebuildCode = content => {
if(content instanceof Buffer) {
content = content.toString('utf-8')
}
// 搜索 `from "xxx"` 的部分
content = content.replace(/from\s+['"]([^'"\r\n]+)['"]/g, (m, g1) => {
// 判断 xxx 是否是标准的路径开头
if(/^[\.\/]/.test(g1)) {
return m
}
// 否则就更改为 node_modules 引用
const baseDir = path.join(config.moduels, g1)
const packageFile = path.join(baseDir, 'package.json')
const { module, main } = require(packageFile)
// module 是 es6 模块
return `from '${path.resolve(`/node_modules/${g1}`, module)}'`
})
return content
}
const resolveUrl = (url) => {
if(url.startsWith('/node_modules/')) {
return path.join(__dirname, '../', url)
} else {
return path.join(config.web, url)
}
}
const app = new Koa()
app.use(ctx => {
const { url } = ctx.request
if(url === '/') {
const indexHtml = fs.readFileSync(config.index, 'utf-8')
// 注入 process
let rebuildHtml = indexHtml.replace('<head>', '<head><script>window.process={env:{}};<\/script>')
// 添加入口脚本 type="module"
rebuildHtml = rebuildHtml.replace('<script ', '<script type="module" ')
ctx.body = rebuildHtml
ctx.type = 'text/html'
} else {
// 拼接文件为web目录文件
const localFile = fixFileName(resolveUrl(url))
// 如果文件存在
if(fs.existsSync(localFile)) {
ctx.body = rebuildCode(fs.readFileSync(localFile))
ctx.type = getContentType(localFile)
} else {
ctx.type = 'text/html'
ctx.body = '<h1>404</h1>'
ctx.status = 404
}
}
})
app.listen(config.port)
console.log(`DogServer started: http://localhost:${config.port}`)
复制代码
web/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="./app.js"></script>
</head>
<body>
<h1>DEV SERVER</h1>
</body>
</html>
复制代码
web/app.js
import { add } from './add'
import Vue from 'vue'
console.log('2 + 13 = ', add(2, 13))
console.log('Hello there')
console.log(Vue)
复制代码
web/add.js
export const add = (a, b) => a + b
复制代码
以上