前端工程化初探:手写DevServer

1,093 阅读1分钟

前一阵蹭了几节课,课里讲到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>

总结一下

今天边写边解决问题,大致走了以下几步:

  1. 构建DevServer,对各代码文件的路由进行正确响应:getContentType
  2. DevServer对文件扩展名自动补全:fixFileName
  3. DevServer对模块引用的支持:rebuildCode resolveUrl
  4. DevServer更改html入口,支持process
  5. 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

以上