vite no-bundle原理实现(一):vite的前身vue-dev-server

545 阅读4分钟

前言

vue-dev-server是尤大在19年写的一个demo项目(项目地址为:github.com/vuejs/vue-d…

主要实现了以下代码在浏览器端的正常打开

<div id="app"></div>
<script type="module">
import Vue from 'vue'
import App from './App.vue'

new Vue({
  render: h => h(App)
}).$mount('#app')
</script>

<template>
  <div>{{ msg }}</div>
</template>

<script>
export default {
  data() {
    return {
      msg: 'Hi from the Vue file!'
    }
  }
}
</script>

<style scoped>
div {
  color: red;
}
</style>

本文将简单实现一下这个过程

为什么上诉代码无法直接在浏览器中运行

  1. 对于vue这种node_modules中的依赖,浏览器无法自动添加node_modules的路径前缀

  2. 浏览器无法识别.vue结尾的文件

image.png

服务器准备

  1. 创建一个目录,初始化项目pnpm init
  2. 安装express依赖pnpm i express
  3. 按以下流程图写好简单的服务端代码:

image.png

// server/index.js

// express是一个比较快捷的服务器,可以方便地使用中间件去处理请求
const express = require('express')
const { vueMiddleware } = require('../middleware')

const app = express()
const root = process.cwd();

// vueMiddleware就是我们代码的主体
app.use(vueMiddleware())

// express.static是express内置的静态资源处理中间件,可以处理常规的静态资源
// 比如html文件、js文件、img文件等
app.use(express.static(root))

app.listen(3002, () => {
  console.log('server running at http://localhost:3002')
})
// vueMiddleware.js
const vueMiddleware = (options) => {
  return (req, res, next) => {
    next();
  }
}

module.exports.vueMiddleware = vueMiddleware;

完成后发现项目目录结构为:

image.png

终端执行node ./server/index.js

打开地址:http://localhost:3002

发现页面空白,且浏览器控制台报错:Uncaught TypeError: Failed to resolve module specifier "vue". Relative references must start with either "/", "./", or "../".

处理依赖

本节将使代码中的import vue from 'vue' 转换为 import vue from '/__modules/vue',用来解决上面操作的浏览器控制报错

原因分析

import vue from 'vue'这样的代码无法在浏览器中直接运行,浏览器只能识别http.//../开头的路径资源

拦截.html或者.js结尾的请求,并读取内容

// vueMiddleware.js
const vueMiddleware = (options) => {
  return (req, res, next) => {
    if (req.path.endsWith('.js')) {
      // 读取js相关代码(简化代码)
      const { source } = readSource(req.path);
      const newSrc = addPrefixBeforeImportInJs(source);
      // 返回处理后的js代码
      send(res, newSrc, 'application/javascript');
    } else if (req.path.endsWith('.html')) {
      // 读取html相关(简化代码)
      const { source } = readSource(req.path);
      // 处理html代码(后续会写)
      const newSrc = addPrefixBeforeImportInHtml(source)
      // 返回处理后的html代码
      send(res, newSrc, 'text/html')
    } else {
      next();
    }
  }
}

module.exports.vueMiddleware = vueMiddleware;

用parse5库将html代码解析出每一段script标签

安装依赖:pnpm i parse5

// source.js
const parse5 = require('parse5');
function findScriptTagsInHtml(node, scriptTags = []) {
  if (node.tagName === 'script') {
    scriptTags.push(node);
  } else if (node.childNodes) {
    for (const childNode of node.childNodes) {
      scriptTags.push(...findScriptTagsInHtml(childNode, scriptTags));
    }
  }
}

用babel内核库解析处理script标签中的内容

安装依赖:pnpm i @babel/parser @babel/traverse magic-string

  • @babel/parser 和 @babel/traverse是babel解析器,用来生成ast树和解析获取import所在节点,并且能返回节点对应源码的位置信息
  • magic-string是一个字符串处理的库,可以处理指定位置的字符串,搭配上面获得的位置信息,可以很方便的修改代码,避免直接改动ast树,这种操作受到众多知名框架的欢迎,比如vite。
// 判断依赖是否为本地依赖,只有本地依赖才需要添加前缀
// 例如import vue from 'vue';
function isDependency(str) {
  return !str.startsWith('.') && !str.startsWith('/') && !str.startsWith('http') && !str.startsWith('https');
}
// 解析js中的import代码
function addPrefixBeforeImportInJs(jsSrc) {
  try {
    // 解析 JavaScript 代码
    const ast = parse(jsSrc, {
      sourceType: 'module',
    });
    const code = new MagicString(jsSrc);

    // 遍历 AST,找到所有 import 语句
    traverse(ast, {
      enter(path) {
        if (path.isImportDeclaration()) {
          // 使用 MagicString 来替换 import 语句中的 from 关键字后面的内容
          const source = path.node.source;
          const importStart = source.start + 1;
          const importEnd = source.end - 1 ;
          console.log(source.value);
          if (importEnd > importStart && isDependency(source.value)) {
            code.overwrite(importStart, importEnd, `/__modules/${source.value}`);
          }
        }
      },
    });
    return code.toString();
  } catch (error) {
    console.error(`Error parsing script content: ${error.message}`);
  }
}
// 添加/__modules/到每一段
function addPrefixBeforeImportInHtml(htmlSrc) {
  const document = parse5.parse(htmlSrc);
  const scriptTags = [];
  // 获取每一段type=module的script
  findScriptTagsInHtml(document, scriptTags);
  scriptTags.forEach(scriptTag => {
    if (scriptTag.childNodes && scriptTag.childNodes.length > 0) {
      const scriptContent = scriptTag.childNodes[0].value;
      const newScriptContent = addPrefixBeforeImportInJs(scriptContent);
      scriptTag.childNodes[0].value = newScriptContent;
    }
  });
  // 返回新的内容
  return parse5.serialize(document);
}

效果

请求的html资源返回为:

<html><head></head><body><div id="app"></div>
<script type="module">
import Vue from '/__modules/vue'
import App from './App.vue'

new Vue({
  render: h => h(App)
}).$mount('#app')</script></body></html>

浏览器依旧显示为空页面,且出现下面两个问题

  • /__modules/vue 请求返回404
  • /App.vue 请求返回vue文件里面的内容,但是.vue文件浏览器没有对应的mime type,报错: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.

疑问

问题1:为啥上诉效果只处理了html代码,却对js后缀的请求也做了相同处理?

因为引入的js也有可能写入import vue from 'vue'这类引入依赖的语句

问题2:为啥要给依赖添加前缀?

因为需要二次拦截指定前缀开头的请求,接下来就是这个步骤

引入依赖

本节解决上诉步骤出现的/__modules/vue 请求返回404问题

原因分析

服务器的根目录下没有/__modules/vue 对应的资源,需要添加对应的内容返回,实际上我们想要返回node_modules/vue/dist/vue.runtime.esm-browser.js的内容

拦截/__modules/开头的请求

const vueMiddleware = (options) => {
  return async (req, res, next) => {
    if (req.path.endsWith('.js')) {
      // js相关
      ...
    } else if (req.path === '/' || req.path.endsWith('.html')) {
      // html相关
      ...
    } else if (req.path.startsWith('/__modules/')) {
      // 解析依赖名称
      const pkgName = req.path.replace('/__modules/', '');
      // 加载依赖的browser-esm版本的代码
      const { source } = loadkg(pkgName);
      send(source, 'application/javascript')
    } else {
      next();
    }
  }
}

加载依赖的browser-esm版本的代码

对于vue这类成熟框架而言,是有browser-esm版本的js提供的,可以直接读取/node_modues/vue/dist/

安装依赖:pnpm i vue@2.6.8

// 加载依赖的esm版本代码
async function loadPkg(pkgName) {
  console.log(pkgName, 'pkgName')
  if (pkgName === 'vue') {
    const res = await readSource('/../node_modules/vue/dist/vue.esm.browser.min.js');
    return res;
  } else {
  }
}

效果:打开http://localhost:3002/__modules/vue 正确返回js内容

解析vue文件

本节解决/App.vue请求返回浏览器无法识别内容的问题

拦截.vue结尾的请求

const vueMiddleware = (options) => {
  return async (req, res, next) => {
    if (req.path.endsWith('.js')) {
      // js相关
      ...
    } else if (req.path === '/' || req.path.endsWith('.html')) {
      // html相关
      ...
    } else if (req.path.startsWith('/__modules/')) {
      // 解析依赖名称
      ...
    } else if (req.path.endsWith('.vue'))) {
      // 解析.vue文件,转换为可执行的js
      const { source } = loadVue(req.path);
      // 处理js,同上为pkg依赖添加/__modules/前缀
      const vueJs = transformModuleImports(source)
      send(vueJs, 'application/javascript')
    } else {
      next();
    }
  }
}

处理vue文件,转换为可执行的js

"@vue/component-compiler" 是一个 Vue.js 组件编译器的 npm 包。它被设计为与 Vue.js 的构建工具一起使用,可以将 Vue.js 单文件组件 (.vue 文件) 编译为 JavaScript 模块,以便可以在浏览器或 Node.js 环境中使用,它依赖于vue-template-compiler,我们这里将用来处理本地的.vue文件

安装组件编译器依赖:pnpm i @vue/component-compiler

安装vue@2.6.8版本对应的模板编译器: pnpm i vue-template-compiler@2.6.8

// 定义一个异步函数,用于加载 Vue 单文件组件
async function loadVue(reqFilePath) {
  // 读取 Vue 文件内容,获取文件路径和文件源代码
  const { filepath, source } = await readSource(reqFilePath);

  // 调用编译器的 compileToDescriptor 方法,将源代码编译为一个描述对象
  const descriptorResult = compiler.compileToDescriptor(filepath, source);

  // 调用 vueCompiler.assemble 方法,将描述对象转换为一个 Vue 组件对象
  const assembledResult = vueCompiler.assemble(compiler, filepath, {
    ...descriptorResult,
  });

  // 返回一个包含 Vue 组件代码的对象
  return { source: assembledResult.code };
}

效果

浏览器打开http://localhost:8080 , 页面出现内容,如下图所示:

image.png

尝试修改样式

<style scoped>
div {
  color: blue;
}
</style>

image.png

至此已经实现了一个满足要求的vue-dev-server

总结

本文简单实现了vue-dev-server,可以看出,在此项目中,我们没有像webpack一样,把包括依赖在内的js压缩到同一个文件中,而是利用浏览器module的特性,实时去编译vue文件成浏览器可运行的esm代码,这个动作和vite开发服务器的no-bundle原理是相似的。

但是上诉demo还有很多缺陷,例如:

  1. 每一次都要去编译vue文件,需要引入缓存机制只去编译首次访问的文件和改变了的文件
  2. 每次引入第三方依赖,都要引入依赖对应的esm模块代码,而有些依赖并有esm版本的文件,比如lodash、react,并且每次都要添加代码去指向
  3. 产生了请求依赖,如果使用了lodash-es,将会产生几百条请求
  4. 没有hmr机制
  5. ...

这些缺陷将在后续章节中一一解决,通过这些缺陷的解决,可以进一步了解包括预构建在内的vite的no-bundle原理。

链接文档