nuxt3拆包剖析——定义你的页面服务器

495 阅读7分钟

前言

我们使用脚手架开发的时候,比如vue-cli webpack-dev-server vite,都会执行dev命令,启动一个服务器,点击显示的链接,就能打开页面进行开发

不例外,nuxt也有自己的脚手架nuxi,并且执行dev命令也会创建一个服务器,点击链接进入开发环境的页面

image.png

接下来,我们探究一下nuxt脚手架的页面服务器究竟是如何搭建的

阅读本文,你能学到:

  • 原生node实现服务器
  • nuxt服务器的相关工具包
  • 手写一个类nuxt的开发服务器

原生node实现服务器

说实话我没做过node后端开发,以下内容可能稍微欠缺火候,路过熟悉的大哥可以出来指正

我当前的node版本为:v18.14.2

image.png

首先创建一个index.mjs文件,并且指定端口为9999,根路径返回一个html

import { createServer } from 'node:http'

const server = createServer((req, res) => {
  if (req.url === '/') {
    res.setHeader('Content-Type', 'text/html')
    res.end('<h1>hi, im http</h1>')
  }
})

server.listen(9999, () => {
  console.log('Server is running on: http://localhost:9999')
})

用node执行一下,打开链接就能看到效果

node index.mjs

image.png

打开http://localhost:9999,就能接收到Content-Type: text/html的内容

image.png

给服务器增加一个get接口请求,首先先改写页面内容,增加一个按钮,用于发起请求,并且给按钮绑定一个事件,点击的时候触发请求

const server = createServer((req, res) => {
  if (req.url === '/') {
    res.setHeader('Content-Type', 'text/html')
    res.end(`\
    <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>hi, im http</h1>
  <button id="button">get请求</button>

  <script>
    const button = document.getElementById('button')
    button.addEventListener('click', () => {
      const request = new XMLHttpRequest()
      request.open('GET', 'http://localhost:9999/user')
      request.send()
    })
  </script>
</body>
</html>
    `)
  }
  // ...
})

然后添加get请求的路径,然后设定返回内容

const server = createServer((req, res) => {
  // ...
  if (req.url === '/user') {
    res.setHeader('Content-Type', 'application/json')
    res.end(JSON.stringify({
      name: 'wu',
      age: 18,
    }))
  }
})

我们运行一下看看

image.png

可以看到,页面多了一个get请求按钮,点击按钮的时候会触发get请求,并且返回设置好的响应内容

server也可以把请求进行分离,不需要在初始化server实例的时候把每个请求都列出来,我们添加一个post例子来尝试添加新请求处理

先添加post按钮和请求方法

const server = createServer((req, res) => {
  if (req.url === '/') {
    res.setHeader('Content-Type', 'text/html')
    res.end(`\
    <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>hi, im http</h1>
  <button id="get">get请求</button>
  <button id="post">post请求</button>

  <script>
    const get = document.getElementById('get')
    get.addEventListener('click', () => {
      const request = new XMLHttpRequest()
      request.open('GET', 'http://localhost:9999/user')
      request.send()
    })

    const post = document.getElementById('post')
    post.addEventListener('click', () => {
      const request = new XMLHttpRequest()
      request.open('POST', 'http://localhost:9999/post')
      request.send(JSON.stringify({ // 这里带请求参数
        name: 'wu',
      }))
    })
  </script>
</body>
</html>
    `)
  }
  // ...
})

然后为server添加post请求/post

server.on('request', (req, res) => {
  if (req.url === '/post' && req.method === 'POST') {
    const body = []
    req.on('data', (chunk) => {
      body.push(chunk)
    }).on('end', () => {
      console.log(Buffer.concat(body).toString()) // {"name":"wu"} 这里展示请求参数
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({
        name: 'wu',
        age: 18,
      }))
    })
  }
})

image.png

可以看到,我们页面添加了一个post请求按钮,并且为server实例添加了一个/post的接口

以上应该就是实现一个基础的原生node服务器的过程

nuxt服务器的相关工具包

看过这个系列的都知道,我讲解的nuxt拆包剖析,都离不开一个小团队unjs,这次也不例外地nuxt的开发服务器相关的工具包也来自unjs

  • unjs/h3
  • unjs/listhen

unjs/h3

H3 is a minimal h(ttp) framework built for high performance and portability.

官方文档

从介绍可以看出,h3就是一个高性能的http框架,nuxt的开发服务器就是使用该工具包实现

基本使用过程,首先当然是安装,我就不赘述了,新建一个文件h3.mjs

import { createServer } from 'node:http'
import { createApp, eventHandler, toNodeListener } from 'h3'

const app = createApp()
app.use('/', eventHandler(() => '<h1>hi, im h3</h1>'))

createServer(toNodeListener(app)).listen(8888, () => {
  console.log('Server is running on: http://localhost:8888')
})

执行node h3.mjs后效果

image.png

image.png

个人认为相对于原生node写法,结合h3的写法还是优雅一点的

同样的,我们也添加get和post接口请求,我们会使用到h3createRoute方法,方便管理路由

const app = createApp()

const route = createRouter()
route.get('/', eventHandler(() => `\
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>hi, im http</h1>
  <button id="get">get请求</button>
  <button id="idget">带有id的get请求</button>
  <button id="post">post请求</button>

  <script>
    const get = document.getElementById('get')
    get.addEventListener('click', () => {
      const request = new XMLHttpRequest()
      request.open('GET', 'http://localhost:8888/user')
      request.send()
    })

    const idget = document.getElementById('idget')
    idget.addEventListener('click', () => {
      const request = new XMLHttpRequest()
      request.open('GET', 'http://localhost:8888/user/111')
      request.send()
    })

    const post = document.getElementById('post')
    post.addEventListener('click', () => {
      const request = new XMLHttpRequest()
      request.open('POST', 'http://localhost:8888/post')
      request.send(JSON.stringify({
        name: 'wu',
      }))
    })
  </script>
</body>
</html>
`)).get('/user', eventHandler(() => ({
  name: 'wu',
  age: 18,
}))).get('/user/:id', eventHandler(event => ({
  name: 'wu',
  age: 18,
  id: event.context.params.id,
}))).post('/post', eventHandler(async (event) => {
  const body = await readBody(event)
  console.log(body) // { name: 'wu' }
  return {
    age: 18,
    ...body,
  }
}))

app.use(route)
createServer(toNodeListener(app)).listen(8888, () => {
  console.log('Server is running on: http://localhost:8888')
})

我们关注几个部分

  • 使用createRoute创建路由,路由实例可以链式调用方法注册路由
  • 对比于原生node的返回响应数据,我们使用eventHandler能直接将响应数据return,从而精简代码
  • 我们可以通过:xxx的形式设置动态入参,对应的获取参数,则是event.context.params.xxx
  • 对于post请求,我们需要使用方法readBody获取body入参,响应数据也是直接return
  • 更多的h3方法可以查看h3 JSDocs

看下运行效果

image.png

点击三个按钮发出对应的请求,并且能按照我们的设定返回数据

unjs/listhen

An elegant HTTP listener.

上一节我们构造了一个服务器,并且通过nodecreateServer运行起来,但输出的内容有点简略

unjs团队也提供了一个工具包unjs/listhen用来运行服务,并且功能还更齐全

我们使用上面的例子,改写一下使用listhen运行

import { listen } from 'listhen'

// ...
app.use(route)
// createServer(toNodeListener(app)).listen(8888, () => {
//   console.log('Server is running on: http://localhost:8888')
// })

listen(toNodeListener(app), {
  port: 8888,
  showURL: true,
  open: false,
  clipboard: true,
})

image.png

这个图是node运行的样式

image.png

这个图是listhen运行的样式

对比一下可以看出,listhen运行的样式会更清晰,并且通过第二个参数可以进行一些控制,比如

  • port:控制端口
  • showURL: 控制是否展示链接
  • open: 控制运行后是否直接打开浏览器
  • clipboard: 控制运行后是否将url拷贝到剪切板

还有很多属性,可以在文档中查看

手写一个类nuxt的开发服务器

通过上文,其实我们已经知道如何做一个开发服务器,只需要做一些调整,就可以变成nuxt服务器

  • 第一步,创建一个文件nuxt-demo.mjs,我们的操作都在这里

  • 第二步,需要一个nuxt脚手架,因为脚手架不在本文的研究范围,我们先简单的处理一下,在package.json中添加

"scripts": {
    "dev": "node nuxt-demo.mjs",
}

这样执行pnpm dev的时候,就会运行nuxt-demo.mjs文件

  • 第三步,一个loading页面

我们在运行nuxt的时候,都会有一个loading页面,这个页面nuxt做成了一个集合包:nuxt/assets,其中loading页面的内容在这里

准备工作都好了,下面我先把完整代码贴出来

import { loading } from '@nuxt/ui-templates'
import { listen } from 'listhen'
import { createApp, eventHandler, toNodeListener } from 'h3'

// 第一部分
const app = createApp()
app.use('/', eventHandler(() => '<h1>hi, im nuxt</h1>'))

// 第二部分
const loadingListener = (req, res) => {
  res.setHeader('Content-Type', 'text/html; charset=UTF-8')
  res.statusCode = 503
  res.end(loading({ loading: 'Starting' }))
}
let currentListener
const serverHandler = (req, res) => {
  return currentListener ? currentListener(req, res) : loadingListener(req, res)
}

// 第三部分
const listhener = await listen(serverHandler, {
  port: 8888,
  showURL: false,
  open: false,
  clipboard: true,
})

// 第四部分
function initNuxt() {
  const nuxtInstance = {
    ready: () => {
      // ! nuxt的准备过程,先用setTimeout模拟
      setTimeout(() => {
        // 创建真正的开发服务器
        currentListener = toNodeListener(app)
      }, 5000)
    },
  }
  listhener.showURL() // 执行到这里,展示url
  return nuxtInstance
}

// 这里进行nuxt初始化
const currentNuxt = initNuxt()
currentNuxt.ready()

第一部分,创建h3实例,绑定一个根路径,返回页面内容<h1>hi, im nuxt</h1>

第二部分,准备监听方法currentListener,如果currentListener为空,则使用loadingListener,其中loadingListener则直接返回nuxt/assets中的loading模板,也就是我们在启动nuxt时看到的页面

第三部分,创建listhen实例,注意我这里showURL属性是false,也就是在执行的时候并不会马上显示url

第四部分,我们创建一个initNuxt方法,用户创建nuxt实例,其实nuxt实例就是一个对象,里面有一个ready属性,用于做一些nuxt的准备工作,比如生成.nuxt目录等

在创建完nuxt实例之后,才展示url,调用listhener.showURL()方法,到这步才会显示url,这样做的目的应该是减少用户看到loading页的时间

在最后,执行currentNuxt.ready(),准备nuxt环境,比如说给当前监听方法currentListener赋值,读取工程目录文件生成.nuxt目录等操作,我们就使用一个setTimeout来模拟等待的过程

我们来看下执行效果

nuxt过程.gif

总结

文章简单地实现了原生node服务器,和使用unjs工具包实现服务器,最后再根据nuxt的处理,模拟了一个开发服务器

本文所有代码在github仓库

这篇文章写的有点久,主要是不熟悉服务器的开发步骤,并且unjs的文档写的比较简约,最后还是通过看nuxtnitro源码才完成的,感觉还是一种进步吧