你好,我是大圣。

上一讲学完了Vue的编译原理后,我们就把Vue的整体流程梳理完毕了,但是我们在使用Vue的时候,还会用到很多Vue生态的库。所以从今天开始,我会带你了解几个Vue生态中重要成员的原理和源码,今天我先带你剖析一下我们项目中用的工程化工具Vite的原理。

现在工程化的痛点

现在前端开发项目的时候,工程化工具已经成为了标准配置,webpack是现在使用率最高的工程化框架,它可以很好地帮助我们完成从代码调试到打包的全过程,但是随着项目规模的爆炸式增长,webpack也带来了一些痛点问题

最早webpack可以帮助我们在JavaScript文件中使用require导入其他JavaScript、CSS、image等文件,并且提供了dev-server启动测试服务器,极大地提高了我们开发项目的效率。

webpack的核心原理就是通过分析JavaScript中的require语句,分析出当前JavaScript文件所有的依赖文件,然后递归分析之后,就得到了整个项目的一个依赖图。对图中不同格式的文件执行不同的loader,比如会把CSS文件解析成加载CSS标签的JavaScript代码,最后基于这个依赖图获取所有的文件。

进行打包处理之后,放在内存中提供给浏览器使用,然后dev-server会启动一个测试服务器打开页面,并且在代码文件修改之后可以通过WebSocket通知前端自动更新页面,也就是我们熟悉的热更新功能

由于webpack在项目调试之前,要把所有文件的依赖关系收集完,打包处理后才能启动测试,很多大项目我们执行调试命令后需要等1分钟以上才能开始调试。这对于开发者来说,这段时间除了摸鱼什么都干不了,而且热更新也需要等几秒钟才能生效,极大地影响了我们开发的效率。所以针对webpack这种打包bundle的思路,社区就诞生了bundless的框架,Vite就是其中的佼佼者。

前端的项目之所以需要webpack打包,是因为浏览器里的JavaScript没有很好的方式去引入其他文件。webpack提供的打包功能可以帮助我们更好地组织开发代码,但是现在大部分浏览器都支持了ES6的module功能,我们在浏览器内使用type="module"标记一个script后,在src/main.js中就可以直接使用import语法去引入一个新的JavaScript文件。这样我们其实可以不依赖webpack的打包功能,利用浏览器的module功能就可以重新组织我们的代码。

    <script type="module" src="/src/main.js"></script>

Vite原理

了解了script的使用方式之后,我们来实现一个迷你的 Vite来讲解其大致的原理。

首先,浏览器的module功能有一些限制需要额外处理。浏览器识别出JavaScript中的import语句后,会发起一个新的网络请求去获取新的文件,所以只支持/、./和…/开头的路径。

而在下面的Vue项目启动代码中,首先浏览器并不知道Vue是从哪来,我们第一个要做的,就是分析文件中的import语句。如果路径不是一个相对路径或者绝对路径,那就说明这个模块是来自node_modules,我们需要去node_modules查找这个文件的入口文件后返回浏览器。然后 ./App.vue是相对路径,可以找到文件,但是浏览器不支持 .vue文件的解析,并且index.css也不是一个合法的JavaScript文件。

我们需要解决以上三个问题,才能让Vue项目很好地在浏览器里跑起来。

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

const app = createApp(App)
app.mount('#app')

怎么做呢?首先我们需要使用Koa搭建一个server,用来拦截浏览器发出的所有网络请求,才能实现上述功能。在下面代码中,我们使用Koa启动了一个服务器,并且访问首页内容读取index.html的内容。

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

app.use(async ctx=>{
  const {request:{url,query} } = ctx
if(url=='/'){
    ctx.type="text/html"
    let content = fs.readFileSync('./index.html','utf-8')
    
    ctx.body = content
  }
})
app.listen(24678, ()=>{
  console.log('快来快来数一数,端口24678')
})

下面就是首页index.html的内容,一个div作为Vue启动的容器,并且通过script引入src.main.js。我们访问首页之后,就会看到浏览器内显示的geektime文本,并且发起了一个main.js的HTTP请求,然后我们来解决页面中的报错问题

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite App</title>
</head>
<body>
  <h1>geek time</h1>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

首先import {createApp} from Vue这一步由于浏览器无法识别Vue的路径,就会直接抛出错误,所以我们要在Koa中把Vue的路径重写。为了方便演示,我们可以直接使用replace语句,把Vue改成/@modules/vue,使用@module开头的地址来告诉Koa这是一个需要去node_modules查询的模块。

在下面的代码中,我们判断如果请求地址是js结尾,就去读取对应的文件内容,使用rewriteImport函数处理后再返回文件内容。在rewriteImport中我们实现了路径的替换,把Vue变成了 @modules/vue, 现在浏览器就会发起一个http://localhost:24678/@modules/vue 的请求,下一步我们要在Koa中拦截这个请求,并且返回Vue的代码内容。

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

function rewriteImport(content){
  return content.replace(/ from ['|"]([^'"]+)['|"]/g, function(s0,s1){
    // . ../ /开头的,都是相对路径
    if(s1[0]!=='.'&& s1[1]!=='/'){
      return ` from '/@modules/${s1}'`
    }else{
      return s0
    }
  })
}

app.use(async ctx=>{
  const {request:{url,query} } = ctx
  if(url=='/'){
      ctx.type="text/html"
      let content = fs.readFileSync('./index.html','utf-8')
      
      ctx.body = content
  }else if(url.endsWith('.js')){
    // js文件
    const p = path.resolve(__dirname,url.slice(1))
    ctx.type = 'application/javascript'
    const content = fs.readFileSync(p,'utf-8')
    ctx.body = rewriteImport(content)
  }
})
app.listen(24678, ()=>{
  console.log('快来快来说一书,端口24678')
})

图片

然后我们在Koa中判断请求地址,如果是@module的地址,就把后面的Vue解析出来,去node_modules中查询。然后拼接出目标路径 ./node_modules/vue/package.json去读取Vue项目中package.json的module字段,这个字段的地址就是 ES6 规范的入口文件。在我们读取到文件后,再使用rewriteImport处理后返回即可。

这里还要使用rewriteImport的原因是,Vue文件内部也会使用import的语法去加载其他模块。然后我们就可以看到浏览器网络请求列表中多了好几个Vue的请求。

else if(url.startsWith('/@modules/')){
    // 这是一个node_module里的东西
    const prefix = path.resolve(__dirname,'node_modules',url.replace('/@modules/',''))
    const module = require(prefix+'/package.json').module
    const p = path.resolve(prefix,module)
    const ret = fs.readFileSync(p,'utf-8')
    ctx.type = 'application/javascript'
    ctx.body = rewriteImport(ret)
}

图片

这样我们就实现了node_modules模块的解析,然后我们来处理浏览器无法识别 .vue文件的错误。

.vue文件是Vue中特有的文件格式,我们上一节课提过Vue内部通过@vue/compiler-sfc来解析单文件组件,把组件分成template、style、script三个部分,我们要做的就是在Node环境下,把template的内容解析成render函数,并且和script的内容组成组件对象,再返回即可。

其中,compiler-dom解析template的流程我们学习过,今天我们来看下如何使用。

在下面的代码中,我们判断 .vue的文件请求后,通过compilerSFC.parse方法解析Vue组件,通过返回的descriptor.script获取JavaScript代码,并且发起一个type=template的方法去获取render函数。在query.type是template的时候,调用compilerDom.compile解析template内容,直接返回render函数。

const compilerSfc = require('@vue/compiler-sfc') // .vue
const compilerDom = require('@vue/compiler-dom') // 模板





if(url.indexOf('.vue')>-1){
    // vue单文件组件
    const p = path.resolve(__dirname, url.split('?')[0].slice(1))
    const {descriptor} = compilerSfc.parse(fs.readFileSync(p,'utf-8'))

    if(!query.type){
      ctx.type = 'application/javascript'
      // 借用vue自导的compile框架 解析单文件组件,其实相当于vue-loader做的事情
      ctx.body = `
  ${rewriteImport(descriptor.script.content.replace('export default ','const __script = '))}
  import { render as __render } from "${url}?type=template"
  __script.render = __render
  export default __script
      `
    }else if(query.type==='template'){
      // 模板内容
      const template = descriptor.template
      // 要在server端吧compiler做了
      const render = compilerDom.compile(template.content, {mode:"module"}).code
      ctx.type = 'application/javascript'

      ctx.body = rewriteImport(render)
    }
    

上面的代码实现之后,我们就可以在浏览器中看到App.vue组件解析的结果。App.vue会额外发起一个App.vue?type=template的请求,最终完成了整个App组件的解析。

图片图片

接下来我们再来实现对CSS文件的支持。下面的代码中,如果url是CSS结尾,我们就返回一段JavaScript代码。这段JavaScript代码会在浏览器里创建一个style标签,标签内部放入我们读取的CSS文件代码。这种对CSS文件的处理方式,让CSS以JavaScript的形式返回,这样我们就实现了在Node中对Vue组件的渲染。

if(url.endsWith('.css')){
    const p = path.resolve(__dirname,url.slice(1))
    const file = fs.readFileSync(p,'utf-8')
    const content = `
    const css = "${file.replace(/\n/g,'')}"
    let link = document.createElement('style')
    link.setAttribute('type', 'text/css')
    document.head.appendChild(link)
    link.innerHTML = css
    export default css
    `
    ctx.type = 'application/javascript'
    ctx.body = content
  }

图片

Vite的热更新

最后我们再来看一下热更新如何实现。热更新的目的就是在我们修改代码之后,浏览器能够自动渲染更新的内容,所以我们要在客户端注入一个额外的JavaScript文件,这个文件用来和后端实现WebSocket通信。然后后端启动WebSocket服务,通过chokidar库监听文件夹的变化后,再通过WebSocket去通知浏览器即可。

下面的代码中,我们通过chokidar.watch实现了文件夹变更的监听,并且通过handleHMRUpdate通知客户端文件更新的类型。


export function watch() {
  const watcher = chokidar.watch(appRoot, {
    ignored: ['**/node_modules/**', '**/.git/**'],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true,
  });
  watcher;

  return watcher;
}
export function handleHMRUpdate(opts: { file: string; ws: any }) {
  const { file, ws } = opts;
  const shortFile = getShortName(file, appRoot);
  const timestamp = Date.now();

  console.log(`[file change] ${chalk.dim(shortFile)}`);
  let updates;
  if (shortFile.endsWith('.css')) {
    updates = [
      {
        type: 'js-update',
        timestamp,
        path: `/${shortFile}`,
        acceptedPath: `/${shortFile}`,
      },
    ];
  }

  ws.send({
    type: 'update',
    updates,
  });
}

然后客户端注入一段额外的JavaScript代码,判断后端传递的类型是js-update还是css-update去执行不同的函数即可。

async function handleMessage(payload: any) {
  switch (payload.type) {
    case 'connected':
      console.log(`[vite] connected.`);

      setInterval(() => socket.send('ping'), 30000);
      break;

    case 'update':
      payload.updates.forEach((update: Update) => {
        if (update.type === 'js-update') {
          fetchUpdate(update);
        } 
      });
      break;
  }
}

总结

以上就是今天的主要内容,我们来总结一下吧!

首先,我们通过了解webpack的大致原理,知道了现在webpack在开发体验上的痛点。除了用户体验UX之外,开发者的体验DX也是项目质量的重要因素。

webpack启动服务器之前需要进行项目的打包,而Vite则是可以直接启动服务,通过浏览器运行时的请求拦截,实现首页文件的按需加载,这样开发服务器启动的时间就和整个项目的复杂度解耦。任何时候我们启动Vite的调试服务器,基本都可以在一秒以内响应,这极大地提升了开发者的体验,这也是Vite的使用率越来越高的原因。

并且我们可以看到,Vite的主要目的就是提供一个调试服务器。Vite也可以和Vue解耦,实现对任何框架的支持,如果使用Vite支持React,只需要解析React中的JSX就可以实现。这也是Vite项目的现状,我们只需要使用框架对应的Vite插件就可以支持任意框架。

Vite能够做到这么快的原因,还有一部分是因为使用了esbuild去解析JavaScript文件。esbuild是一个用Go语言实现的JavaScript打包器,支持JavaScript和TypeScript语法,现在前端工程化领域的工具也越来越多地使用Go和Rust等更高效的语言书写,这也是性能优化的一个方向。

思考题

最后留一个思考题吧。如果一个模块文件是分散的,导致Vite首页一下子要加载1000个JavaScript文件造成卡顿,我们该如何处理这种情况呢?

欢迎在评论区分享你的答案,我们下一讲再见!