手撸Vite,揭开Vite神秘面纱

avatar
公众号「村长学前端」 @B站「前端杨村长」

村长其他Vite系列文章,欢迎小伙伴们拍砖:

备战2021:vite工程化实践,建议收藏

备战2021:Vite2项目最佳实践

精通Vite2之插件开发指南

前言

有不少小伙伴问我,为何vite启动、开发能那么快?

其实文档中对于这一块有相当明确的说明,文本将讨论这些知识,并且带领大家深入底层,手写实现一个自己的Vite,这些问题将迎刃而解!不仅如此我们还能全面了解vue3 SFC编译、解析的细节,可谓收获满满!

内容概要

  • Webpack的问题
  • Vite另辟蹊径
  • Vite工作原理
  • 手写实现自己的Vite
  • 源码下载
  • 视频教程
  • 后续创作计划

Webpack的问题

大家熟悉的webpack在开发时需要启动本地开发服务器实时预览。因为需要对整个项目文件进行打包,开发时启动速度会随着项目规模扩大越来越缓慢。对于开发时文件修改后的热更新也存在同样的问题。

img

看到没,不管需不需要,都要被强行喂饼!

image-20210413111741115

Vite另辟蹊径

Vite 则很好地解决了上面的两个问题。启动一台开发服务器,并不对文件代码打包,根据客户端的请求加载需要的模块处理,实现真正的按需加载。对于文件更新,Vite的HMR是在原生 ESM 上执行的。只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失效(大多数时候只需要模块本身),使HMR更新始终快速,无论应用的大小。真正的想吃什么就给什么!

img

Vite工作原理

神奇魔法如何实现?秘诀是Vite利用了浏览器native ES module imports特性,使用ES方式组织代码,浏览器自动请求需要的文件,并在服务端按需编译返回,完全跳过了打包过程。关键变化是index.html中的入口文件导入方式

image-20201102112021740

这样main.js中就可以使用ES6 Module方式组织代码:

vite需要根据请求资源类型做不同解析工作,比如App.vue,返回给用户的内容如下:

// 原先的script部分内容
import HelloWorld from '/src/components/HelloWorld.vue'

const __script = {
    name: 'App',
    components: {
        HelloWorld
    }
}

// 可见`template`部分转换为了一个模板请求,解析结果是一个渲染函数
import {render as __render} from "/src/App.vue?type=template"

__script.render = __render
__script.__hmrId = "/src/App.vue"
__script.__file = "/Users/yt/projects/vite-study/src/App.vue"
export default __script

下面是解析得到的渲染函数的内容:

手写实现自己的Vite

创建开发服务器

开发服务器能够将index.html返回给浏览器:

const Koa = require('koa')
const app = new Koa()

app.use(async (ctx) => {
  const url = ctx.request.url
  if (url === '/') {
    ctx.type = 'text/html'
    ctx.body = fs.readFileSync('./index.html', 'utf-8')  
  }
})

app.listen(3000, () => {
  console.log('kvite start');
})

浏览报错,需要处理main.js加载

image-20210318165748277

加载JS

服务器添加对js文件请求支持:

// 导入path
const path = require('path')

app.use(async (ctx) => {
  if (url === '/') {} 
  else if (url.endsWith('.js')) {
    // 获取js文件绝对路径,读取并返回
    const p = path.join(__dirname, url)
    ctx.type = 'text/javascript'
    ctx.body = fs.readFileSync(p, 'utf-8')
  }
})

加载第三方库

如果用户导入第三方依赖,例如vue

import {createApp, h} from 'vue'

createApp({
  render: () => h('div', 'hello, kvite!')
}).mount('#app')

main.js请求已经成功返回内容:

image-20210318174802304

但是我们发现浏览器只支持相对路径文件加载:

image-20210318174907653

这里的关键是替换裸模块路径相对路径,比如我们将from 'vue'替换为from '/@modules/vue'

function rewriteImport(content) {
  return content.replace(/ from ['|"]([^'"]+)['|"]/g, function(s0, s1){
    if (s1.startsWith('./') || s1.startsWith('/') || s1.startsWith('../')) {
      return s0
    } else {
      return ` from '/@modules/${s1}'`
    }
  })
}

app.use(async (ctx) => {
  const url = ctx.request.url
  if (url === '/') {
    // ...
  } else if (url.endsWith('.js')) {
    // ...
    const ret = fs.readFileSync(p, 'utf-8')
    // 重写裸模块导入部分
    ctx.body = rewriteImport(ret)
  }
})

查看转换结果

image-20210319100854747

可以看到浏览器在尝试加载/@modules/vue,说明替换已经成功

image-20210319101005916

最后处理依赖模块加载:目标文件在模块的package.json中有描述:

image-20210319101321723

获取此路径并读取目标文件,具体实现如下:

else if (url.startsWith('/@modules')) {
    const moduleName = url.replace("/@modules/", "");
    const prefix = path.join(__dirname, "../node_modules", moduleName);
    const module = require(prefix + "/package.json").module;
    const filePath = path.join(prefix, module);
    const ret = fs.readFileSync(filePath, "utf8");
    ctx.type = "text/javascript";
    ctx.body = rewriteImport(ret);
}

process模拟

一些库会访问process,因此会报process未定义的错误,给宿主页加一个mock规避即可

if (url === '/') {
    ctx.type = 'text/html'
    const content = fs.readFileSync('./index.html', 'utf-8').replace(
      '<script type="module" src="/src/main.js"></script>',
      `
        <script>
          window.process = {env:{NODE_ENV:'dev'}}
        </script>
        <script type="module" src="/src/main.js"></script>
      `,
    )
    ctx.body = content
}

成功渲染出内容!

image-20210325152502815

SFC请求处理

最后处理SFC解析,例如App.vue

<template>
  <div>{{ title }}</div>
</template>

<script>
import { ref } from "vue";
export default {
  setup() {
    const title = ref("hello, kvite!");
    return { title };
  },
};
</script>
import { createApp, h } from "vue";
import App from './App.vue'

createApp(App).mount("#app");

使用compiler-sfccompiler-dom编译SFC

const compilerSfc = require("@vue/compiler-sfc");
const compilerDom = require("@vue/compiler-dom");
else if (url.indexOf('.vue') > -1) {
   // SFC路径
		const p = path.join(__dirname, url.split("?")[0]);
    const ret = compilerSfc.parse(fs.readFileSync(p, 'utf-8'))
		// SFC文件请求
    if (!query.type) {
      const scriptContent = ret.descriptor.script.content
      const script = scriptContent.replace('export default ', 'const __script = ')
      // 返回App.vue解析结果
      ctx.type = 'text/javascript'
      ctx.body = `
        ${rewriteImport(script)}
        import { render as __render } from '${url}?type=template'
        __script.render = __render
        export default __script
      `
    } else if (query.type === 'template') {
      // 模板内容
      const template = ret.descriptor.template.content
      // 编译为render
      const render = compilerDom.compile(template, { mode: 'module' }).code
      ctx.type = 'text/javascript'
      ctx.body = rewriteImport(render)
    }
}

大功告成~

image-20210325161038448

视频教程

村长特地录制了配套视频,手把手带领大家撸出自己的Vite

Vite工作原理和手写实现「已完结」

欢迎各位小伙伴三连+关注,您的鼓励是我坚持下去的最大动力❤️

配套源码

欢迎关注公众号村长学前端自取