vite系列:手写一个简易版的vite-(依赖预构建版本)
之前我们已经实现了一个不带依赖预构建版本的vite, 在vite2.0中增加了一个代表性优化策略 依赖预构建
今天我们也来手写一个。
我们要实现的功能如下图所示:
依赖预构建功能
实现对SFC的解析
实现对vue3语法的解析
实现对html和js的解析
最后实现一个对数字的加减操作功能
代码分两部分
依赖预构建部分
本地服务部分
先开始编写依赖预构建部分
先把所需要的依赖引入
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