1
头图

众所周知vite已经是当前web新一代构建工具的佼佼者, 主要是让前端开发者在本地调试阶段修改代码后无需打包直接快速得到一个响应, 这让我们在开发阶段的效率得到一个提升, 接下来为了深入了解vite原理,我们由浅入深先自己实现一个不带预构建版本的vite。

首先我们要知道的是vite如今可以做到不用打包直接在浏览器上执行ESM代码,是因为目前主流的浏览器都已经支持了ESM模块加载即import可以直接被浏览器识别,识别的前提我们只需要在script标签上添加 type="module"即可。

我们要实现的功能如下图所示:

  1. 实现对vue3语法的解析
  2. 实现对SFC的解析
  3. 实现对html和js的解析
  4. 最后实现一个对数字的加减操作功能
    image.png

第一步我们先创建一个文件夹命名(node-vite)
第二步我们在文件夹内创建一个index.html文件和一个入口文件mian.js以及一个App.vue文件

<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>
    <div id="app"></div>
  </body>
  <script type="module" src="./main.js"></script>
  <script>
    // 提前声明这些值 是因为在解析第三方库的源码中有直接使用以下变量的,所以提前先声明出来
    const process = {
      env: {
        NODE_ENV: 'development',
      },
    };
    window.process = process;
    window.development = 'development';
  </script>
</html>
import * as Vue from 'vue';
import App from './App.vue';
Vue.createApp(App).mount('#app');
<template>
  <div>我现在是数字:{{ message }}</div>
  <button v-on:click="handleClick1">增加</button>
  <button v-on:click="handleClick2">减小</button>
</template>
<script>
export default {
  data() {
    return {
      message: 0,
    };
  },
  methods: {
    handleClick1() {
      this.message += 1;
    },
    handleClick2() {
      this.message -= 1;
    },
  },
};
</script>

这三个文件已经是我们要实现的加减数字操作功能的代码了,剩下的就是我们要实现一个vite把这三个文件可以正常运行起来并在浏览器上看到效果。

我们命令上进入到node-vite文件夹后执行 npm init, 接下来我们安装以下包:

  1. nodemon -------------- node进程管理
  2. vue -------------------我是安装的3.2.20
  3. @vue/compiler-dom------版本和vue的版本一致(将template编译成render函数)
  4. @vue/compiler-sfc------版本和vue的版本一致(将SFC文件编译成json数据)
  5. es-module-lexer -------获取文件中import语句的信息用来获取通过import加载的包
  6. magic-string ----------用来替换第三方包的路径

我们再node-vite目录中创建server.js用来实现简单版的vite功能
package.json代码如下

{
  "name": "node-vite",
  "version": "1.0.0",
  "description": "",
  "main": "",
  "scripts": {
    "dev": "nodemon ./server.js",
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@vue/compiler-dom": "3.2.20",
    "@vue/compiler-sfc": "3.2.20",
    "es-module-lexer": "^0.9.3",
    "magic-string": "^0.25.7",
    "vue": "3.2.20"
  },
  "devDependencies": {
    "nodemon": "^2.0.15"
  }
}

进入到server.js 我们先创建一个本地的http服务器

const http = require('http');
const server = http.createServer((req, res) => {
});
server.listen(9999);

我们的目的是当浏览器访问localhost:9999的时候让浏览器默认访问index.html, 从浏览器里面加载index.html后默认解析main.js,当浏览器再解析到 import * as Vue from 'vue'; 肯定解析不出vue的文件, 我们要做的是修改vue的路径然后从而主动找到vue的源码位置后取出vue的esm版本的js文件然后再主动返回给浏览器, 接下来我们开始处理

const http = require('http');
const url = require('url');
const path = require('path');
const server = http.createServer((req, res) => {
    // 解析出访问地址url的文件名
    let pathName = url.parse(req.url).pathname;
    // 当浏览器直接访问localhost:9999的时候 直接返回index.html文件
    if(pathName === '/'){
        pathName = '/index.html'
    }
    // 解析出文件的后缀类型
    let extName = path.extname(pathName);
    let extType = '';
    switch (extName) {
      case '.html':
        extType = 'text/html';
        break;
      case '.js':
        extType = 'application/javascript';
        break;
      case '.css':
        extType = 'text/css';
        break;
      case '.ico':
        extType = 'image/x-icon';
        break;
      case '.vue':
        extType = 'application/javascript';
        break;
      default:
        extType = 'text/html';
    }
    // 我们会把import * as Vue from 'vue' 这样的语句变成 import * as Vue from '/@modules/vue';
    // 这样我们会知道带`/@modules`标识的路径需要到node_modules中找源代码
    if (/\/@modules\//.test(pathName)) {
        // 如果解析到路径有`/@modules`则到node_modules中找对应库的源代然后返回给浏览器
        resolveNodeModules(pathName, res);
    } else {
        // 否则解析常规的js文件、.vue文件、html文件
        resolveModules(pathName, extName, extType, res, req);
    }
});
server.listen(9999);

到目前为止我们的server.js文件结构架子搭建好了, 接下来我们开始实现resolveModules()函数

const compilerSfc = require('@vue/compiler-sfc');
const compilerDom = require('@vue/compiler-dom');
const fs = require('fs');
function resolveModules(pathName, extName, extType, res, req){
    fs.readFile(`.${pathName}`, 'utf-8', (err, data) => {
        // 如果文件解析出错则直接抛处错误
        if(err){
            throw err;
        }
        // 设置Content-Type
        res.writeHead(200, {'Content-Type': `${extType}; charset=utf-8`})

       // 对请求的过来的不同类型的文件做出响应的处理
       if(extName === '.js'){
        // 对后缀为.js的文件处理
        // rewriteImports函数作用将替换import引入第三方包的路径, 一会我们实现这个函数
        const r = rewriteImports(data);
        res.write(r);
       } else if (extName === '.vue') {
          // 对后缀为.vue的文件处理(即SFC)
          // 解析出请求url的参数对象
          const query = querystring.parse(url.parse(req.url).query);
          // 通过@vue/compiler-sfc库把sfc解析成json数据
          const ret = compilerSfc.parse(data);
          const { descriptor } = ret;
          if (!query.type) {
            // 解析出sfc文件script部分
            const scriptBlock = descriptor.script.content;
            // 在sfc文件中我们也可能使用import引入文件所以需要rewriteImports函数把里面的路径进行替换
            const newScriptBlock = rewriteImports(
              scriptBlock.replace('export default', 'const __script = '),
            );
            // 将替换好的js部分和动态引入render函数(template编译而成)组合再一起然后返回到浏览器
            const newRet = `
            ${newScriptBlock}
            import { render as __render } from '.${pathName}?type=template'
            __script.render = __render
            export default __script
            `;
            res.write(newRet);
          } else {
            // 浏览器再次解析到 `import { render as __render } from './App.vue?type=template'`会加载render函数
            // 解析出vue文件通过@vue/compiler-dom库将template部分变为render函数
            const templateBlock = descriptor.template.content;
            const compilerTemplateBlockRender = rewriteImports(
              compilerDom.compile(templateBlock, {
                mode: 'module',
              }).code,
            );
            res.write(compilerTemplateBlockRender);
         }
       } else {
        // 对其他后缀比如.html、ico的文件处理
        // 不需要做任何处理直接返回
        res.write(data);
       }
       res.end();
    })
}

我们开始实现rewriteImports()函数

// es-module-lexer 参数解析
// n 表示模块的名称
// s 表示模块名称在导入语句中的开始位置
// e 表示模块名称在导入语句中的结束位置
// ss 表示导入语句在源代码中的开始位置
// se 表示导入语句在源代码中的结束位置
// d 表示导入语句是否为动态导入,如果是则为对应的开始位置,否则默认为 -1
const { init, parse } = require('es-module-lexer');
const MagicString = require('magic-string');
function rewriteImports(soure) {
    const imports = parse(soure)[0];
    const magicString = new MagicString(soure);
    if (imports.length) {
    for (let i = 0; i < imports.length; i++) {
      const { s, e } = imports[i];
      let id = soure.substring(s, e);
      if (/^[^\/\.]/.test(id)) {
        // id = `/@modules/${id}`;
        // 修改路径增加 /@modules 前缀
        // magicString.overwrite(s, e, id);
        magicString.overwrite(s, e, `/@modules/${id}`);
      }
    }
    return magicString.toString();
  }
}

rewriteImports函数中使用了es-module-lexer和magic-string两个黑魔法库来达到替换路径的功能 也是vite中所使用的
接下来我们实现最后一个函数resolveNodeModules 在node_modules中获取第三方包的资源并返回给浏览器

function resolveNodeModules(pathName, res){
    // 获取 `/@modules/vue` 中的vue   
    const id = pathName.replace(/\/@modules\//, '');
    // 获取第三方包的绝对地址
    let absolutePath = path.resolve(__dirname, 'node_modules', id);
    // 获取第三方包的package.json的module字段解析出esm的包地址
    const modulePath = require(absolutePath + '/package.json').module;
    const esmPath = path.resolve(absolutePath, modulePath);
    // 读取esm模块的js内容
    fs.readFile(esmPath, 'utf-8', (err, data) => {
      if (err) {
        throw err;
      }
      res.writeHead(200, {
        'Content-Type': `application/javascript; charset=utf-8`,
      })
      // 使用rewriteImports函数替换资源中引入的第三方包的路径
      const r = rewriteImports(data);
      res.write(r);
      res.end();
    );
}

以上就是实现了一个简易版的vite, 我们可以执行npm run dev 来启动下服务看下效果。
从vite2.0后尤大神对vite进行了一系列的性能优化其中最为有代表性的优化是依赖预构建,我会在下一期中手写一个带有预构建版本的简易vite。写文章也让我对vite的原理理解更加深刻,同时也希望能帮助到你们! 后面我也不断的更新其他源码解析文章!


一支前端
4 声望0 粉丝