vite学习与简易实现

Vite 介绍

Vite 概念
Vite 是一个面向现代化浏览器的一个更轻、更快的 web 应用应用开发工具
它基于 ECMAScript 标准原生模块系统(ES Module)实现的
它的出现是为了解决 Webpack 在开发阶段,使用 webpack-dev-server 冷启动时间过长和 Webpack MHR 热更新反应慢的问题。

使用 Vite 创建的项目,默认就是一个普通的 Vue3应用,相比于 Vue CLI创建的项目,会少了很多文件和依赖。

Vite 的特性

  • 快速冷启动
  • 模块热更新
  • 按需编译
  • 开箱即用

Vite 项目依赖

Vite 创建的默认项目,开发依赖很少也很简单,只包含了:

  • Vite
  • @vue/compiler-sfc(用来编译.vue 结尾的单文件文件)

需要注意的是,Vite 目前创建的 Vue 项目只支持 3.0 的版本。在创建项目的时候,通过指定不同的模板,也可以创建其他框架的项目。

Vite 提供的命令

  • vite serve
    工作原理
    用于启动一个开发的 web 服务器,在启动服务器的时候不需要编译所有的模块启动速度非常的快。

我们来看看下面这张图:

工作原理.png

在运行vite serve 的时候,不需要打包,直接开启了一个 web 服务器。当浏览器请求服务器时,例如是一个 css,或者是一个单文件组件,这个时候在服务器会把这个浏览器请求的文件先编译,然后直接把编译后的结果返回给浏览器。

这里的编译是在服务器端,并且,模块的处理是在请求到服务器端处理的。

我们来回顾一下,Vue CLI 创建的应用


vue cli APP.png

Vue CLI 创建的项目启动 web 服务器用的是 vue-cli-service,当运行它的时候,它内部会使用 Webpack 去打包所有的模块(如果模块很多的情况下,编译的速度会很慢),打包完成后会将编译好的模块存储到内存中,然后启动一个 web 服务器,浏览器请求 web 服务器,最后才会从内存中把编译好的内容,返回到浏览器。

Webpack这样的工具,它的做法是将所有的模块提前都编译打包进内存里,不管模块是否被执行是否被调用,它会都打包编译,随着项目越来越大,打包后的内容也会越来越大,打包的速度也会越来越慢。

Vite 使用现代化浏览器原生支持的 ES Module 模块化的特性,省略了模块的打包环节。对于需要编译的文件,例如样式模块和单文件组件等,vite 采用了即时编译,也就是说当加载到这个文件的时候,才会去服务端编译好这个文件。

所以,这种即时编译的好处体现在按需编译,速度会更快。

HMR

  • Vite HMR
    立即编译当前所修改的文件
  • Webpack HMR
    会自动以这个文件为入口重新编译一次,所有的涉及到的依赖也会被加载一次

Vite 默认也支持 HMR 模块热更新,相对于Webpack中的 HMR 效果会更好,因为 Webpack 的 HMR 模块热跟新会从你修改的文件开始全部在编译一遍

vite build

  • Rollup
  • Dynamic import
    Polyfill
    Vite创建的项目使用 Vite build 进行生产模式的打包,这个命令内部使用过的是 Rollup 打包,最终也是把文件都打包编译在一起。对于代码切割的需求,Vite 内部采用的是原生的动态导入的方式实现的,所以打包的结果只能支持现代化的浏览器(不支持 ie)。不过相对应的 Polyfill 可以解决

是否还需要打包?
随着Vite 的出现,我们需要考虑一个问题,是否还必要打包应用。之前我们使用Webpack 进行打包,会把所有的模块都打包进bundle.js 中,主要有两个原因:

  • 浏览器环境对原生 ES Module 的支持
  • 零零散散的模块文件会产生大量的 HTTP 请求

但是,现在目前大部分的浏览器都已经支持了 ES Module。并且我们也可以使用 HTTP2 长链接去解决大量的 HTTP 请求。那是否还需要对应用进行打包,取决于你的团队和项目应用的运行环境。

个人觉得这以后会是一个趋势。

开箱即用

  • TypeScript - 内置支持
  • less/sass/stylus/postcss - 内置支持(需要单独安装)
  • JSX
  • Web Assemby

实现一个简易版的 vite

接下来,我们来实现一个简易版本的 vite,来深入理解 vite 的工作原理,分为以下五个步骤:

  • 静态 web 服务器
  • 修改第三方模块的路径
  • 加载第三方模块
  • 编译单文件组件
  • HMR(通过 WebSocket 实现,跳过)

静态 web 服务器

vite内部使用过的是koa 来开启静态服务器的,这里我们也使用 koa 来开启一个静态服务器,把当前运行的目录作为静态服务器的根目录

创建一个名为 my-vite 的空文件夹,进入该文件夹初始化 package.json,并且安装 koakoa-send

package.json 来配置 bin 字段:

"bin": "index.js"

新建 index.js 文件,并且在第一行配置 node 的运行环境(因为我们要开发的是一个基于 node的命令行工具,所以要指定运行node 的位置)

#!/usr/bin/env node

接下来,基于koa 启动一个 web 静态服务器:

#!/usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')

const app = new Koa()

// 1.开启静态文件服务器
app.use(async (ctx, next) => {
  await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
  next()
})

app.listen(3000)
console.log('Serve running @ http://localhost:3000')

接着,使用 npm link 到全局,然后打开一个使用 vue3 写的项目(可以用 vite 创建一个默认项目),进入命令行终端,输入 myvite。如果没有报错的话,会打印出"Serve running @ http://localhost:3000"这句话,我们打开浏览器,打开这个网址。

不过是一片空白的,接着我们打开 F12,会看到一个报错,报错的信息的意思是,解析 vue 模块的时候失败了,使用 import 导入模块的时候,模块的开头必须是"/", "./", or "../"这三种其中的一个。

我们来做一个对比,我们把使用vite 创建的项目启动后, vite-cli 创建的项目启动后的 main.js 在浏览器响应中的区别

myvite
vite-cli

通过上面两幅图的对比,你会发现,vite 它会处理这个模块引入的路径,它会加载一个不存在的路径@modules,并且请求这个路径的 js 文件也是可以请求成功的。

这是 vite 创建的项目启动后的 vue.js 的请求,观察响应头中的 Content-Type字段,他是 application/javascript;所以我们可以通过这个类型,在返回的时候去处理这个js 中的第三方路径问题。

修改第三方模块的路径

通过上面的观察和理解,我们得出一个思路,可以把不是"/", "./", or "../"开头的引用,全部替换成“/@modules/”

我们创建多一个中间件,用来做这件事情。

// 2.修改第三方模块的路径
app.use(async (ctx, next) => {
  // 判断浏览器请求的文件类型,如果是js文件,在这里进行解析。
  if (ctx.type === 'application/javascript') {
    //将流转化成字符串
    const contents = await streamToString(ctx.body)
    // 在js的import当中,只会出现以下的几种情况:
    // 1、import vue from 'vue'
    // 2、import App from '/App.vue'
    // 3、import App from './App.vue'
    // 4、import App from '../App.vue'
    // 2、3、4这三种情况,现代化浏览器都可以识别,只有第一种情况不能识别,这里只处理第一种情况
    // 思路是用正则匹配到 (from ') 或者 是 (from ") 开头,替换成"/@modules/"

    /**
     * 这里进行分组的全局匹配
     * 第一个分组匹配以下内容:
     *  from 匹配 from
     *  \s+ 匹配空格
     *  ['"]匹配单引号或者是双引号
     * 第二个分组匹配以下内容:
     *  ?! 不匹配这个分组的结果
     *  \.\/ 匹配 ./
     *  \.\.\/ 匹配 ../
     * $1表示第一个分组的结果
     */
    ctx.body = contents.replace(
      /(from\s+['"])(?![\.\/\.\.\\/])/g,
      '$1/@modules/'
    )
  }
})

// 将流转化成字符串,是一个异步线程,返回一个promise
const streamToString = (stream) =>
  new Promise((resolve, reject) => {
    // 用于存储读取到的buffer
    const chunks = []
    //监听读取到buffer,并存储到chunks数组中
    stream.on('data', (chunk) => chunks.push(chunk))
    //当数据读取完毕之后,把结果返回给resolve,这里需要把读取到的buffer合并并且转换为字符串
    stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
    //如果读取buffer失败,返回reject
    stream.on('error', reject)
  })

加载第三方模块

现在我们要做的是,将 /@modules/开头的引用,去 node_modules 中找到并且替换它的返回内容。我们需要在创建一个中间件,这个中间件需要在创建静态服务器之前被调用。

// 3.加载第三方模块
app.use(async (ctx, next) => {
  // ctx.path --> /@modules/vue
  // 判断第三方模块的路径是否有/@modules/开头
  if (ctx.path.startsWith('/@modules/')) {
    // 对字符串进行截取,获取到模块名称
    const moduleName = ctx.path.substr(10)
    // 找到该模块名称在node_moduls中的package.json路径
    const pkgPath = path.join(
      process.cwd(),
      'node_modules',
      moduleName,
      'package.json'
    )
    // 通过require加载当前package.json
    const pkg = require(pkgPath)
    // 将内容替换成node_modules中的内容
    ctx.path = path.join('/node_modules', moduleName, pkg.module)
  }
  // 返回执行下一个中间件
  await next()
})

编写完后,我们需要重新启动一下服务器,启动完成后,我们重新打开 network网络面板,看看 vue 这个模块是否被加载了进来。


我们看到,vue 这个模块已经被加载进来了。

但是,我们发现 /@modules/@vue/runtime-dom/@modules/@vue/shared 却没有被加载进来,并且控制台却报了两个错误:加载模块App.vueindex.css失败

编译单文件组件

我们先观察一下,原本的 vite 启动后,sfc 单文件夹组件的请求是如何处理的,


编译单文件.png

我们来看 app.vue 的响应内容,它引入了一些组件,然后把它编译成一个选项对象,然后它又去加载了app.vue 并且在后面加上了一个参数 type=template,并且解构出了一个 render函数,然后把 render 函数挂载到选项对象上,然后又设置了两个属性(这两个属性不模拟),最后导出这个选项对象。

从这段代码我们可以观察到,当请求到单文件组件的时候,服务器会来编译这个单文件组件,并把相对应的结果返回给浏览器。

我们在来编写一个中间件,在编写中间件的时候,我们需要安装一个模块 @vue/compiler-sfc并且导入,这个模块的作用主要是编译单文件组件的。

代码如下:

// 4. 处理单文件组件
app.use(async (ctx, next) => {
  // 当请求的文件是单文件组件的时候,就是.vue结尾的时候
  if (ctx.path.endsWith('.vue')) {
    // 获取文件内容,它的内容是一个流,需要转换为字符串
    const contents = await streamToString(ctx.body)
    // compilerSFC.parse用来编译单文件组件,它返回一个对象,它有两个成员 descriptor、errors
    const { descriptor } = compilerSFC.parse(contents)
    // 最终返回浏览器的内容
    let code
    // 第一次请求,没有参数的时候,就是没有带type的时候
    if (!ctx.query.type) {
      // 第一次请求,把单文件组件编译成一个对象
      code = descriptor.script.content
      code = code.replace(/export\s+default\s+/g, 'const __script = ')
      code += `
        import { render as __render } from "${ctx.path}?type=template"
        __script.render = __render
        export default __script
      `
    }
    // 第二次请求,参数中是否有type参数,并且是否是template
    else if (ctx.query.type === 'template') {
      // compilerSFC.compileTemplate 编译模板
      const templateRender = compilerSFC.compileTemplate({
        // 编译内容
        source: descriptor.template.content,
      })
      code = templateRender.code
    }
    // 设置文件类型
    ctx.type = 'application/javascript'
    // 转化成流
    ctx.body = stringToStream(code)
  }
  await next()
})

然后,重启一下服务,需要注意的是,需要把图片和其他和 js 或者 vue 无关的文件都注释掉,因为我们这里只处理了vue 文件。

源码

#!/usr/bin/env node
const path = require('path')
const { Readable } = require('stream')
const Koa = require('koa')
const send = require('koa-send')
const compilerSFC = require('@vue/compiler-sfc')

const app = new Koa()

// 3.加载第三方模块
app.use(async (ctx, next) => {
  // ctx.path --> /@modules/vue
  // 判断第三方模块的路径是否有/@modules/开头
  if (ctx.path.startsWith('/@modules/')) {
    // 对字符串进行截取,获取到模块名称
    const moduleName = ctx.path.substr(10)
    // 找到该模块名称在node_moduls中的package.json路径
    const pkgPath = path.join(
      process.cwd(),
      'node_modules',
      moduleName,
      'package.json'
    )
    // 通过require加载当前package.json
    const pkg = require(pkgPath)
    // 将内容替换成node_modules中的内容
    ctx.path = path.join('/node_modules', moduleName, pkg.module)
  }
  await next()
})

// 1.开启静态文件服务器
app.use(async (ctx, next) => {
  await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
  await next()
})

// 4. 处理单文件组件
app.use(async (ctx, next) => {
  // 当请求的文件是单文件组件的时候,就是.vue结尾的时候
  if (ctx.path.endsWith('.vue')) {
    // 获取文件内容,它的内容是一个流,需要转换为字符串
    const contents = await streamToString(ctx.body)
    // compilerSFC.parse用来编译单文件组件,它返回一个对象,它有两个成员 descriptor、errors
    const { descriptor } = compilerSFC.parse(contents)
    // 最终返回浏览器的内容
    let code
    // 第一次请求,没有参数的时候,就是没有带type的时候
    if (!ctx.query.type) {
      // 第一次请求,把单文件组件编译成一个对象
      code = descriptor.script.content
      code = code.replace(/export\s+default\s+/g, 'const __script = ')
      code += `
        import { render as __render } from "${ctx.path}?type=template"
        __script.render = __render
        export default __script
      `
    }
    // 第二次请求,参数中是否有type参数,并且是否是template
    else if (ctx.query.type === 'template') {
      // compilerSFC.compileTemplate 编译模板
      const templateRender = compilerSFC.compileTemplate({
        // 编译内容
        source: descriptor.template.content,
      })
      code = templateRender.code
    }
    // 设置文件类型
    ctx.type = 'application/javascript'
    // 转化成流
    ctx.body = stringToStream(code)
  }
  await next()
})

// 2.修改第三方模块的路径
app.use(async (ctx, next) => {
  // 判断浏览器请求的文件类型,如果是js文件,在这里进行解析。
  if (ctx.type === 'application/javascript') {
    //将流转化成字符串
    const contents = await streamToString(ctx.body)
    // 在js的import当中,只会出现以下的几种情况:
    // 1、import vue from 'vue'
    // 2、import App from '/App.vue'
    // 3、import App from './App.vue'
    // 4、import App from '../App.vue'
    // 2、3、4这三种情况,现代化浏览器都可以识别,只有第一种情况不能识别,这里只处理第一种情况
    // 思路是用正则匹配到 (from ') 或者 是 (from ") 开头,替换成"/@modules/"

    /**
     * 这里进行分组的全局匹配
     * 第一个分组匹配以下内容:
     *  from 匹配 from
     *  \s+ 匹配空格
     *  ['"]匹配单引号或者是双引号
     * 第二个分组匹配以下内容:
     *  ?! 不匹配这个分组的结果
     *  \.\/ 匹配 ./
     *  \.\.\/ 匹配 ../
     * $1表示第一个分组的结果
     */
    ctx.body = contents
      .replace(/(from\s+['"])(?![\.\/\.\.\\/])/g, '$1/@modules/')
      .replace(/process\.env\.NODE_ENV/g, '"development"') // 替换process对象
  }
})

// 将流转化成字符串,是一个异步线程,返回一个promise
const streamToString = (stream) =>
  new Promise((resolve, reject) => {
    // 用于存储读取到的buffer
    const chunks = []
    //监听读取到buffer,并存储到chunks数组中
    stream.on('data', (chunk) => chunks.push(chunk))
    //当数据读取完毕之后,把结果返回给resolve,这里需要把读取到的buffer合并并且转换为字符串
    stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
    //如果读取buffer失败,返回reject
    stream.on('error', reject)
  })

// 将字符串转化成流
const stringToStream = (text) => {
  const stream = new Readable()
  stream.push(text)
  stream.push(null)
  return stream
}

app.listen(3000)
console.log('Serve running @ http://localhost:3000')
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342