Skip to content

简单实现 Vite

设计哲学

  • 开发环境:利用浏览器去解析 imports,在服务端按需编译返回,完全跳过打包这个概念(bundless)。
  • 生成环境:利用 rollup 打包
  • 灵感来自于 snowpack

特性

  • 开发环境下,基于浏览器原生 ES imports type="module" 属性
  • 真正意义上的按需编译
  • 快速的冷启动
  • 即时的模块热更新

启动命

npx my-vite

  • package.json
{
  "name": "learning-demo-17-my-vite",
  "main": "index.js",
  "bin": {
    "my-vite": "bin/vite.js"
  },
  "dependencies": {
    "es-module-lexer": "^0.3.24",
    "koa": "^2.13.0",
    "koa-static": "^5.0.0",
    "magic-string": "^0.25.7"
  }
}
  • bin/vite.js
#!/usr/bin/env node --inspect=9233
const createServer = require('../src/index.js');

createServer().listen(3001, () => {
  console.log('this is my vite listening on  3001');
});

运行 DEV SERVER

一个 Koa Server

const Koa = require('koa');
const serveStaticPlugin = require('./node/server/serverPluginServeStatic.js');
const moduleRewritePlugin = require('./node/server/serverPluginModuleRewrite.js');
const moduleResolvePlugin = require('./node/server/serverPluginModuleResolve.js');
const htmlRewritePlugin = require('./node/server/serverPluginHtml.js');
const vuePlugin = require('./node/server/serverPluginVue.js');

function createServer() {
  const app = new Koa();
  const context = {
    app,
    root: process.cwd() // 进程运行的目录
  };

  const resolvedPlugins = [
    // 2) 重写 html 一些环境变量
    htmlRewritePlugin,

    // 3) 解析 import,重写路径: "vue" => "/@modules/vue"
    moduleRewritePlugin,

    // 4) 解析 "/@modules" 开头的模块,匹配相应路径 
    moduleResolvePlugin,

    // 5) 利用 vue-compiler 编译 vue 后缀文件
    vuePlugin,    

    // 1) 静态服务插件
    serveStaticPlugin
  ];

  resolvedPlugins.forEach((m) => m && m(context));

  return app;
}

module.exports = createServer;

核心插件

1) serveStaticPlugin

  • 静态服务插件
const static = require('koa-static');
const path = require('path');

function serveStaticPlugin({ app, root }) {
  app.use(static(root));
  app.use(static(path.join(root, 'src')));
}

2) htmlRewritePlugin

  • 兼容 node 端的一些环境变量,注入浏览器页面
const injectScriptToHtml = require('../utils/injectScriptToHtml');
const readBody = require('../utils/readBody');

const injectScript = `
  <script>
    window.process = {
      env: {
        NODE_ENV: 'development'
      }
    };
  </script>
`;

function htmlRewritePlugin({ app, root }) {
  app.use(async (ctx, next) => {
    await next();

    if (ctx.body && ctx.response.is('html')) { // 只处理 js 文件
      let content = await readBody(ctx.body);
      const result = injectScriptToHtml(content, injectScript)
      ctx.body = result;
    }
  });
}
  • utils/injectScriptToHtml.js
const injectReplaceRE = [/<head>/, /<!doctype html>/i]

function injectScriptToHtml(html, script) {
  // inject after head or doctype
  for (const re of injectReplaceRE) {
    if (re.test(html)) {
      return html.replace(re, `$&${script}`)
    }
  }
  // if no <head> tag or doctype is present, just prepend
  return script + html
}

3) moduleRewritePlugin

  • 使用 es-module-lexer 解析 import
  • 使用 magic-string 重写路径: vue => /@modules/vue
const readBody = require('../utils/readBody');
const rewriteImports = require('../utils/rewriteImports');

function moduleRewritePlugin({ app, root }) {
  app.use(async (ctx, next) => {
    await next();

    if (ctx.body && ctx.response.is('js')) { // 只处理 js 文件
      let content = await readBody(ctx.body);
      const result = rewriteImports(content);
      ctx.body = result;
    }
  });
}
  • utils/readBody.js
  • 将请求的 ReadableStream 转换成 String
const Stream = require('stream');

async function readBody(stream) {
  if (stream instanceof Stream) {
    return new Promise((resolve, reject) => {
      let result = '';
      stream.on('data', (chunk) => {
        result += chunk;
      });
      stream.on('end', () => {
        resolve(result);
      });
    });
  } else {
    return stream.toString();
  }
}
  • utils/rewriteImports.js
  • 重写路径: vue => /@modules/vue
const { parse } = require('es-module-lexer'); // 解析资源 ast 拿到 import 的内容
const MagicString = require('magic-string'); // 重写 string

function rewriteImports(source) {
  let magicString = new MagicString(source);

  let imports = parse(source)[0]; // import 语句
  for(let i = 0; i < imports.length; i++) { // 拦截 import 语句,并重写
    let { s, e } = imports[i];
    let id = source.substring(s, e);

    if (!/^[\.\/]/.test(id)) { // 没有 . 或 / 开头的,如 vue
      id = `/@modules/${id}`;
      magicString.overwrite(s, e, id);
    }
  }

  return magicString.toString();
}

4) moduleResolvePlugin

  • 解析 vue 依赖的模块,并按需加载进页面。流程如下:
  • 在 koa 中间件里获取请求 path 对应的 body 内容
  • 通过 es-module-lexer 解析资源 AST,并拿到 import 的内容
  • 如果判断 import 是资源绝对路径,即可以认为该资源为项目应用资源,并返回处理后的资源路径。如 ./App.vue -> /src/App.vue
  • 判断是否以 /@modules,比如 vue -> /@modules/vue;如果是,取出包名,去 node_modules 寻找对应的 npm 库。
const fs = require('fs');
const resolveVue = require('../utils/resolveVue');
const moduleRE = /^\/@modules\//;

// plugin for resolving /@modules/:id requests.
function moduleResolvePlugin({ app, root }) {
  const vueResolved = resolveVue(root)

  app.use(async (ctx, next) => {
    if (!moduleRE.test(ctx.path)) {
      return next();
    }

    // path maybe contain encode chars
    const id = decodeURIComponent(ctx.path.replace(moduleRE, ''))
    ctx.type = 'js'

    const content = await fs.promises.readFile(vueResolved[id]);
    ctx.body = content;
  });
}
  • utils/resolveVue.js
  • 解析 vue 依赖的几个模块
const path = require('path');
const fs = require('fs');

const resolvePath = (name, from) => path.resolve(from, `@vue/${name}/dist/${name}.esm-bundler.js`);

function resolveVue(root) {
  const modulesBasePath = path.join(root, 'node_modules');
  // 其他核心模块
  const runtimeDomPath = resolvePath('runtime-dom', modulesBasePath);
  const runtimeCorePath = resolvePath('runtime-core', modulesBasePath);
  const reactivityPath = resolvePath('reactivity', modulesBasePath);
  const sharedPath = resolvePath('shared', modulesBasePath);
  // 编译模块
  const compilerPkgPath = path.join(modulesBasePath, '@vue/compiler-sfc/package.json');
  const compilerPkg = require(compilerPkgPath);
  const compilerPath = path.join(path.dirname(compilerPkgPath), compilerPkg['main']);

  return {
    vue: runtimeDomPath,
    '@vue/runtime-dom': runtimeDomPath,
    '@vue/runtime-core': runtimeCorePath,
    '@vue/reactivity': reactivityPath,
    '@vue/shared': sharedPath,
    compiler: compilerPath
  };
}

5) vuePlugin

  • 使用 @vue/compiler-sfc.vue 文件进行编译:通过 parseSFC 方法解决但文件组件,并通过 compileSFCMain 方法将单文件组件拆分。
  • 将 sfc 的 script 部分和 template 部分分开编译及加载。
const path = require('path');
const fs = require('fs');
const resolveVue = require('../utils/resolveVue');
const readBody = require('../utils/readBody');
const defaultExportRE = /((\n|;)\s*)export default/;

function vuePlugin({ app, root }) {
  app.use(async (ctx, next) => {
    if (!ctx.path.endsWith('.vue') && !ctx.vue) {
      return next();
    }

    const { parse, compileTemplate } = require(resolveVue(root).compiler);

    let content = await fs.promises.readFile(path.join(root, ctx.path));
    content = await readBody(content);    
    let { descriptor } = parse(content);

    if (!ctx.query.type) { // script 部分
      let code = ``;
      if (descriptor.script) {
        let content = descriptor.script.content;
        let replaced = content.replace(defaultExportRE, '$1const __script = ');
        code += replaced;
      }
      if (descriptor.template) {
        const templateRequest = ctx.path + '?type=template';
        code += `\nimport { render as __render } from '${templateRequest}'`;
        code += `\n__script.render = __render`;
      }
      code += `\nexport default __script`;
      ctx.type = 'js';
      ctx.body = code;
    } else { // template 部分
      let content = descriptor.template.content;
      const { code } = compileTemplate({ source: content });
      ctx.type = 'js';
      ctx.body = code;
    }
  });
}

总结

  • Vite 利用浏览区支持 ESM 这一特性,省略对模块的打包,不用生成 bundle,因此初次启动更快,HMR 特性友好
  • Vite 开发模式下,通过 koa 在服务端完成模块的改写、单文件解析编译和请求处理,实现真正按需编译
  • Vite 中间件拦截请求后:
    • 1)处理 ESM 语法
    • 2)对于 .ts.vue 实现即时编译
    • 3)对 Sass/Less 中需要预编的模块进行编译
    • 4)和浏览区建立 socket 连接,实现 HMR
  • Vite 的HMR
    • 通过 watcher 监听文件改动
    • server 端编译资源,并推送新模块内容给浏览器
    • 浏览器收到新模块内容,执行 rerender/reload