阅读 316

vite系列:手写一个简易版的vite-(依赖预构建版本)

之前我们已经实现了一个不带依赖预构建版本的vite, 在vite2.0中增加了一个代表性优化策略 依赖预构建今天我们也来手写一个。

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

  1. 依赖预构建功能

  2. 实现对SFC的解析

  3. 实现对vue3语法的解析

  4. 实现对html和js的解析

  5. 最后实现一个对数字的加减操作功能

代码分两部分

  1. 依赖预构建部分

  2. 本地服务部分

先开始编写依赖预构建部分

先把所需要的依赖引入

const http = require('http');
const path = require('path');
const url = require('url');
const querystring = require('querystring');
const glob = require('fast-glob'); // 在规定范围内查询指定的文件,并返回绝对路径
const { build } = require('esbuild'); // 打包编译esm模块
const fs = require('fs');
const os = require('os'); // 获取当前的系统信息
const { createHash } = require('crypto'); // 加密使用
const { init, parse } = require('es-module-lexer'); // 查询出代码中使用import部分信息
const MagicString = require('magic-string');// 替换代码中路径
const compilerSfc = require('@vue/compiler-sfc');// 将sfc转化为json数据
const compilerDom = require('@vue/compiler-dom');// 将template转化为render函数复制代码

编写依赖预构建主函数

async function optimizeDeps() {
  // 第一步:设置缓存存储的位置
  const cacheDir = 'node_modules/.vite';
  // _metadata.json中存储了所有预构建的依赖信息, 也存储到.vite文件夹中
  const dataPath = path.join(cacheDir, '_metadata.json');
  // getDepHash函数 将此项目的xxx.lock.json文件生成一个hash值(作用是如果我的依赖发生变化那我的lock文件也会发生更改,将来我的依赖预构建程序会在hash值发生变化的时候重新执行预构建程序)
  const mainHash = getDepHash();
  // 定义 _metadata.json中存储的数据格式
  const data = {
    hash: mainHash,
    browserHash: mainHash, // 浏览器存储的hash值 
    optimized: {}, // 依赖包的信息
  };
  // 首先判断_metadata.json是否存在 如果存在则对下之前存储的hash值是否跟现在的hash一样,如果一样则依赖没有发生变化 不用执行预构建动作
  if (fs.existsSync(dataPath)) {
    let prevData;
    try {
      // 解析.vite下的_metadata.json文件
      prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
    } catch (error) {
      console.log(error);
    }
    // 哈希是一致的,不需要重新绑定
    if (prevData && prevData.hash === data.hash) {
      return prevData;
    }
  }
  // 判断node_modules/.vite是否存在
  if (fs.existsSync(cacheDir)) {
    // 如果node_modules/.vite这个文件存在则清空.vite下的所有文件
    emptyDir(cacheDir);
  } else {
    fs.mkdirSync(cacheDir, { recursive: true });
  }

  // scanImports 收集所有依赖模块的绝对路径
  const { deps } = await scanImports();
  // {
  //   'vue': ''C:\\Users\\dftd\\desktop\\vite\\node-vue\\node_modules\\vue\\dist\\vue.runtime.esm-bundler.js''
  // }
  console.log('deps', deps);

  // 更新浏览器hash(目前浏览器部分我们不涉及可以不写这块)
  data.browserHash = createHash('sha256')
    .update(data.hash + JSON.stringify(deps))
    .digest('hex')
    .substr(0, 8);

  const qualifiedIds = Object.keys(deps);

  // 如果没有找到任何依赖,那么直接把data数据写入.vite/_metadata.json
  if (!qualifiedIds.length) {
    fs.writeFileSync(dataPath, data);
    return data;
  }

  // 第三步: 对收集的依赖进行处理
  // 比如deps的数据是 { 'plamat/byte': 'C:\\Users\\dftd\\node_modules\\vue\\dist\\byte.js'  }
  const flatIdDeps = {}; // 这个对象存储的是 { plamat_byte: 'path' } 的形式
  const idToExports = {}; // 这个对象存储的是{ plamat/byte: 'souce'} 的形式
  const flatIdToExports = {}; // 这个对象存储的是{ plamat/byte: 'souce'} 的形式

  await init;
  // 将 例如 node/example ==> node_example
  const flattenId = (id) => id.replace(/[\/\.]/g, '_');
  for (const id in deps) {
    const flatId = flattenId(id);
    flatIdDeps[flatId] = deps[id];
    const entryContent = fs.readFileSync(deps[id], 'utf-8');
    const exportsData = parse(entryContent);
    for (const { ss, se } of exportsData[0]) {
      const exp = entryContent.slice(ss, se);
      if (/export\s+\*\s+from/.test(exp)) {
        exportsData.hasReExports = true;
      }
    }
    idToExports[id] = exportsData;
    flatIdToExports[flatId] = exportsData;
  }

  const define = {
    'process.env.NODE_ENV': 'development',
  };
  console.log('flatIdDeps', flatIdDeps);
 // 使用esbuild对收集的依赖进行编译
  const result = await build({
    absWorkingDir: process.cwd(),
    entryPoints: Object.values(flatIdDeps),
    bundle: true,
    format: 'esm',
    outdir: cacheDir, // 配置打完包的文件存储的位置 cacheDir默认为
    treeShaking: true,
    metafile: true,
    define,
  });
  const metafile = result.metafile;

  // 将 _metadata.json 写入 .vite
  const cacheDirOutputPath = path.relative(process.cwd(), cacheDir);
  // _metadata.json中的依赖数据填充上
  for (const id in deps) {
    // p ==> C:\Users\dftd\desktop\vite\node-vue\node_modules\.vite\vue.js
    // normalizePath(p) ==> C:/Users/dftd/desktop/vite/node-vue/node_modules/.vite/vue.js
    const p = path.resolve(cacheDir, flattenId(id) + '.js');
    const entry = deps[id];
    data.optimized[id] = {
      file: normalizePath(p),
      src: normalizePath(entry),
      needsInterop: false,
    };
  }
  // 将数据写入_metadata.json
  fs.writeFileSync(dataPath, JSON.stringify(data, null, 2));
  return data;
}复制代码

optimizeDeps函数中所涉及到工具函数

// 转化路径格式
function normalizePath(id) {
  const isWindows = os.platform() === 'win32';
  return path.posix.normalize(isWindows ? id.replace(/\\/g, '/') : id);
}
// 将此项目的xxx.lock.json文件内容生成一个hash
function getDepHash() {
  // 读取xxx.lock.json文件的内容
  const content = lookupFile() || '';
  const cryptographicStr = createHash('sha256')
    .update(content)
    .digest('hex')
    .substring(0, 8);
  return cryptographicStr;
}
// 读取xxx.lock.json文件的内容
function lookupFile() {
  const lockfileFormats = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
  let content = null;
  for (let index = 0; index < lockfileFormats.length; index++) {
    const lockPath = path.resolve(__dirname, lockfileFormats[index]);
    const isExist = fs.existsSync(lockPath, 'utf-8');
    if (isExist) {
      content = fs.readFileSync(lockPath);
      break;
    }
  }
  return content;
}
// 清空一个文件夹下的所有文件
function emptyDir(dir) {
  for (const file of fs.readdirSync(dir)) {
    const abs = path.resolve(dir, file);
    if (fs.lstatSync(abs).isDirectory()) {
      emptyDir(abs);
      fs.rmdirSync(abs);
    } else {
      fs.unlinkSync(abs);
    }
  }
}
// esbuild的plugin (作用是esbuild在打包过程中处理不同的文件,大致分为处理html、js、第三方包解析,目前是对main.js做单独的处理)
function esbuildScanPlugin(deps) {
  return {
    name: 'dep-scan',
    setup(build) {
      // 解析index.html
      build.onResolve({ filter: /\.(html|vue)$/ }, (args) => {
        // console.log(args);
        // const path1 = path.resolve(__dirname, args.path);
        return {
          path: args.path,
          namespace: 'html',
        };
      });
      // 加载当前index.html 文件 返回出 main.js
      build.onLoad(
        { filter: /\.(html|vue)$/, namespace: 'html' },
        async ({ path: ids }) => {
          const scriptModuleRE =
            /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims;
          const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im;
          const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im;
          const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)<\/script>/gims;
          const importsRE =
            /(?:^|;|\*\/)\s*import(?!\s+type)(?:[\w*{}\n\r\t, ]+from\s*)?\s*("[^"]+"|'[^']+')\s*(?:$|;|\/\/|\/\*)/gm;
          let raw = fs.readFileSync(ids, 'utf-8');
          raw = raw.replace(/<!--(.|[\r\n])*?-->/, '<!---->');
          const isHtml = ids.endsWith('.html');
          const regex = isHtml ? scriptModuleRE : scriptRE;
          regex.lastIndex = 0;
          let js = '';
          let loader = 'js';
          let match;

          while ((match = regex.exec(raw))) {
            const [, openTag, content] = match;
            const srcMatch = openTag.match(srcRE);
            const langMatch = openTag.match(langRE);
            const lang =
              langMatch && (langMatch[1] || langMatch[2] || langMatch[3]);
            if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
              loader = lang;
            }
            if (srcMatch) {
              const src = srcMatch[1] || srcMatch[2] || srcMatch[3];
              js += `import ${JSON.stringify(src)}\n`;
            } else if (content.trim()) {
              js += content + '\n';
            }
          }
          if (
            loader.startsWith('ts') &&
            (ids.endsWith('.svelte') ||
              (ids.endsWith('.vue') && /<script\s+setup/.test(raw)))
          ) {
            // when using TS + (Vue + <script setup>) or Svelte, imports may seem
            // unused to esbuild and dropped in the build output, which prevents
            // esbuild from crawling further.
            // the solution is to add `import 'x'` for every source to force
            // esbuild to keep crawling due to potential side effects.
            let m;
            const original = js;
            while ((m = importsRE.exec(original)) !== null) {
              // This is necessary to avoid infinite loops with zero-width matches
              if (m.index === importsRE.lastIndex) {
                importsRE.lastIndex++;
              }
              js += `\nimport ${m[1]}`;
            }
          }
          if (!js.includes(`export default`)) {
            js += `\nexport default {}`;
          }
          return {
            loader,
            contents: js,
          };
        },
      );
      // 解析第三方库的esm js模块文件 直接走打包
      build.onResolve(
        {
          // avoid matching windows volume
          filter: /\.js\?v=1$/,
        },
        ({ path: id, importer }) => {
          return {
            path: id,
            external: true,
          };
        },
      );
      // 解析.js 文件
      build.onResolve(
        {
          // avoid matching windows volume
          filter: /main\.js$/,
        },
        ({ path: id, importer }) => {
          return {
            path: id,
            namespace: 'mianJs',
          };
        },
      );
      // 加载.js文件的内容
      build.onLoad(
        { filter: /main\.js$/, namespace: 'mianJs' },
        ({ path: id }) => {
          const c = fs.readFileSync(id, 'utf-8');
          const magicString = new MagicString(c);
          let imports = parse(c)[0];
          imports.forEach((i) => {
            const { s, e, n } = i;
            let absolutePath = path.resolve(__dirname, 'node_modules', n);
            const isExist = fs.existsSync(absolutePath);
            if (isExist) {
              const modulePath = require(absolutePath + '/package.json').module;
              const esmPath = path.resolve(absolutePath, modulePath);
              magicString.overwrite(s, e, `${esmPath}?v=1`);
              deps[n] = esmPath;
            } else {
              // let aa = path.resolve(__dirname, n);
              // magicString.overwrite(s, e, aa);
            }
          });
          return {
            loader: 'js',
            contents: magicString.toString(),
          };
        },
      );
    },
  };
}
// 收集依赖模块路径 返回一个依赖合集对象
async function scanImports() {
  const deps = {};
  let entries;
  // 查询出当前目录下后缀为html的文件
  entries = await glob('**/*.html', {
    cwd: process.cwd(),
    ignore: ['**/node_modules/**'],
    absolute: true,
  });
  // entries => [ 'C:/Users/dftd/Desktop/vite/node-vue/index.html' ]
  // console.log('entries', entries);
  if (!entries.length) {
    return {
      deps: {},
    };
  }
  const scanPath = esbuildScanPlugin(deps);
  await build({
    absWorkingDir: process.cwd(), // 工作的目录
    entryPoints: entries, // 入口文件(目前我们特指处理index.html)
    write: false, // build API可以直接写入文件系统 默认情况下,JavaScript API会写入文件系统
    bundle: true, // 使用analyze功能生成一个关于bundle内容的易于阅读的报告
    format: 'esm', // 输出的类型 (iife, cjs, esm)
    plugins: [scanPath],
  });
  return {
    deps,
  };
}复制代码

依赖预构建总结: 依赖预构建大致的原理是 先收集打包的依赖比如我在代码中引入了vue 那么匹配到vue后到node_modules中找到对应vue的esm模块js然后把js都存储到一个对象中(收集依赖的过程也用到了esbuild build方法,收集的过程是在一个esbuild插件中完成的) 得到一个所有依赖的对象后再次用esbuild对这些依赖进行编译,编译后的文件存储到.vite中 下次再使用依赖的时候 直接从.vite中获取,不需要再次编译

注意:依赖预构建操作是在起本地服务之前完成的

继续编写本地服务部分编写本地服务主函数

function createServer() {
  // 执行预构建函数
  optimizeDeps();
  const serve = {};
  let httpServe = http.createServer((req, res) => {
    let pathName = url.parse(req.url).pathname;
    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';
    }
    if (/.vite/.test(pathName)) {
      // 直接加载预构建好的包
      resolveViteModules(pathName, extType, res);
    } else if (/\/@modules\//.test(pathName)) {
      // 加载那些没有预构建好的包第三方包
      resolveNodeModules(pathName, res);
    } else {
      // 加载非第三方包文件
      resolveModules(pathName, extName, extType, res, req);
    }
  });
  serve.listen = () => {
    httpServe.listen(7777);
  };
  return serve;
}复制代码

实现resolveViteModules函数(直接加载预构建好的包)

function resolveViteModules(pathName, extType, res) {
  const id = pathName.replace(/\/node_modules\/.vite\//, '');
  // 根据id到node_modules/.vite/_metadata.json中查询当前这个包是否存在
  const _metadataPath = path.resolve(
    __dirname,
    'node_modules/.vite/_metadata.json',
  );
  const _metaContent = require(_metadataPath).optimized;
  const { src, file } = _metaContent[id];
  // 提取当前文件路径中的文件名称
  const absFileName = path.basename(src);
  const absFile = path.resolve(__dirname, 'node_modules/.vite', absFileName);
  const content = fs.readFileSync(absFile, 'utf-8');
  res.writeHead(200, {
    'Content-Type': `application/javascript; charset=utf-8`,
  });
  res.write(content);
  res.end();
}复制代码

实现rewriteImports函数(重写路径)

function rewriteImports(source) {
  let imports = parse(source)[0];
  const magicString = new MagicString(source);
  if (imports.length) {
    for (let index = 0; index < imports.length; index++) {
      const { s, e } = imports[index];
      // 得到当前引入的第三方库的name
      const id = source.substring(s, e);
      if (/^[^\.\/]/.test(id)) {
        // 根据id到node_modules/.vite/_metadata.json中查询当前这个包是否存在
        const _metadataPath = path.resolve(
          __dirname,
          'node_modules/.vite/_metadata.json',
        );
        const _metaContent = require(_metadataPath).optimized;
        console.log('_metaContent', _metaContent);
        if (_metaContent[id]) {
          magicString.overwrite(s, e, `/node_modules/.vite/${id}`);
        } else {
          magicString.overwrite(s, e, `/@modules/${id}`);
        }
      }
    }
  }
  return magicString.toString();
}复制代码

实现resolveModules函数(处理加载非第三方包文件)

function resolveModules(pathName, extName, extType, res, req) {
  fs.readFile(`.${pathName}`, 'utf-8', (err, data) => {
    if (err) {
      throw err;
    }
    console.log('extName', extName);
    console.log('extType', extType);
    res.writeHead(200, {
      'Content-Type': `${extType}; charset=utf-8`,
    });
    if (extName == '.vue') {
      const query = querystring.parse(url.parse(req.url).query);
      const ret = compilerSfc.parse(data);
      const { descriptor } = ret;
      if (!query.type) {
        // 解析出vue文件script部分
        const scriptBlock = descriptor.script.content;
        const newScriptBlock = rewriteImports(
          scriptBlock.replace('export default', 'const __script = '),
        );
        const newRet = `
        ${newScriptBlock}
        import { render as __render } from '.${pathName}?type=template'
        __script.render = __render
        export default __script
        `;
        res.write(newRet);
      } else {
        // 解析出vue文件template部分 生成render函数
        const templateBlock = descriptor.template.content;
        const compilerTemplateBlockRender = rewriteImports(
          compilerDom.compile(templateBlock, {
            mode: 'module',
          }).code,
        );
        res.write(compilerTemplateBlockRender);
      }
    } else if (extName == '.js') {
      const r = rewriteImports(data);
      res.write(r);
    } else {
      res.write(data);
    }
    res.end();
  });
}复制代码

实现resolveNodeModules函数(在node_modules 中读取没有预构建好的资源)

function resolveNodeModules(pathName, res) {
  const id = pathName.replace(/\/@modules\//, '');
  // 如果是加载的是第三方包
  // 获取第三方包的绝对地址
  let absolutePath = path.resolve(__dirname, 'node_modules', id);
  // console.log('absolutePath', absolutePath);
  // 获取第三方包的esm的包地址
  const modulePath = require(absolutePath + '/package.json').module;
  const esmPath = path.resolve(absolutePath, modulePath);
  // console.log('esmPath', esmPath);

  // const pkgPath = `./node_modules/${id}/${modulePath}`;
  fs.readFile(esmPath, 'utf-8', (err, data) => {
    if (err) {
      throw err;
    }
    res.writeHead(200, {
      'Content-Type': `application/javascript; charset=utf-8`,
    });
    // const b = cjsEs6(data);
    // console.log(b);
    const r = rewriteImports(data);
    // console.log(r);
    res.write(r);
    res.end();
  });
}复制代码
const serve = createServer();
serve.listen();复制代码

最后贴出package.json文件

{
  "name": "node-vite",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "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",
    "os": "^0.1.2",
    "vue": "3.2.20"
  },
  "devDependencies": {
    "nodemon": "^2.0.15"
  }
}复制代码

本地服务总结: 本地服务逻辑没有发生太多变化,只是在重写路径那块改成了/node_modules/.vite/xxx, 获取资源那块也是从.vite中获取

现在你可以执行下npm run dev看下效果是不是出来了,代码中引用了很多vite的源码只是做了简化,如果我之前写的不带预构建版本的vite还没有看建议先看那个,这样由浅入深可以更好的理解vite的本质原理


作者:一支前端
链接:https://juejin.cn/post/7042986223652044808

文章分类
代码人生
文章标签
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐