前端技术分享

vite

  • vite 是神马?
image
  • Vite (法语意为 "快速的",发音 /vit/) 是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:

    • 一个开发服务器,它基于 原生 ES 模块 提供了 丰富的内建功能,如速度快到惊人的 模块热更新(HMR)。
  • 一套构建指令,它使用 Rollup 打包你的代码,并且它是预配置的,可以输出用于生产环境的优化过的静态资源。

 它有如下特点:

      光速启动

      按需编译

      热模块替换HMR

接下来我们使用vue3+vite 搭建第一个 Vite 项目;

开启 vscode

  • vite why?
  • ES module

    • Vite利用了浏览器native ES module imports特性,使用ES方式组织代码,浏览器自动请求需要的文件,并在服务端按需编译返回,完全跳过了打包过程

Vite vs webpack 参考链接

  • Webpack
    • webpack 是一个现代 JavaScript 应用程序的静态模块打包器。开发时启动本地开发服务器,实时预览。它通过解析应用程序中的每一个 import 和 require ,将整个应用程序构建成一个基于 JavaScript 的捆绑包,并在运行时转换文件,这都是在服务器端完成的,依赖的数量和改变后构建/重新构建的时间之间有一个大致的线性关系。需要对整个项目文件进行打包,开发服务器启动缓慢。
    • Webpack 的热更新会以当前修改的文件为入口重新 build 打包,所有涉及到的依赖也都会被重新加载一次,所以反应速度会慢一些。
image
  • Vite
    • Vite 不捆绑应用服务器端。相反,它利用浏览器中的原生 ES Moudle 模块,在具体去请求某个文件的时候,才会在服务端编译这个文件。
    • vite 只启动一台静态页面的服务器,对文件代码不打包,服务器会根据客户端的请求加载不同的模块处理,实现真正的按需加载。
    • 对于热更新问题, vite 采用立即编译当前修改文件的办法。同时 vite 还会使用缓存机制( http 缓存=> vite 内置缓存),加载更新后的文件内容。
image

vite优势

  • 不需要等待打包,所以冷启动的速度将会非常快。
  • 代码是按需编译的。只有你当前页面实际导入的模块才会被编译。你不需要等待整个应用程序被打包完才能够启动服务。这在巨型应用上体验差别更加巨大。(router demo)
  • 热替换HMR的性能将与模块的总数量无关。

vite 工作原理

vite 启动服务
1\. vite 在启动时,内部会启一个 http server,用于拦截页面的脚本文件。

处理.js/.vue文件,npm模块


// 精简了热更新相关代码,如果想看完整版建议去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/server.ts
import http, { Server } from 'http'
import serve from 'serve-handler'

import { vueMiddleware } from './vueCompiler'
import { resolveModule } from './moduleResolver'
import { rewrite } from './moduleRewriter'
import { sendJS } from './utils'

export async function createServer({
    port = 3000,
    cwd = process.cwd()
}: ServerConfig = {}): Promise {
const server = http.createServer(async (req, res) => {
    const pathname = url.parse(req.url!).pathname!
    if (pathname.startsWith('/__modules/')) {
        // 返回 import 的模块文件
        return resolveModule(pathname.replace('/__modules/', ''), cwd, res)
    } else if (pathname.endsWith('.vue')) {
        // 解析 vue 文件
        return vueMiddleware(cwd, req, res)
    } else if (pathname.endsWith('.js')) {
        // 读取 js 文本内容,然后使用 rewrite 处理
        const filename = path.join(cwd, pathname.slice(1))
        const content = await fs.readFile(filename, 'utf-8')
        return sendJS(res, rewrite(content))
    }

    serve(req, res, {
        public: cwd,
        // 默认返回 index.html
        rewrites: [{ source: '**', destination: '/index.html' }]
    })
})

return new Promise((resolve, reject) => {
    server.on('listening', () => {
    console.log(`Running at http://localhost:${port}`)
    resolve(server)
    })

    server.listen(port)
})
}

// 访问index.html

解析js文件

index.html 文件会请求 /src/main.js

image
if (pathname.endsWith('.js')) {
  // 读取 js 文本内容,然后使用 rewrite 处理
  const filename = path.join(cwd, pathname.slice(1))
  const content = await fs.readFile(filename, 'utf-8')
  return sendJS(res, rewrite(content))
}

// 精简了部分代码,如果想看完整版建议去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/moduleRewriter.ts
import { parse } from '@babel/parser'

export function rewrite(source: string, asSFCScript = false) {
  // 通过 babel 解析,找到 import from、export default 相关代码
  const ast = parse(source, {
    sourceType: 'module',
    plugins: [
      'bigInt',
      'optionalChaining',
      'nullishCoalescingOperator'
    ]
  }).program.body

  let s = source
  ast.forEach((node) => {
    if (node.type === 'ImportDeclaration') {
      if (/^[^\.\/]/.test(node.source.value)) {
        // 在 import 模块名称前加上 /__modules/
        // import { foo } from 'vue' --> import { foo } from '/__modules/vue'
        s = s.slice(0, node.source.start) 
          + `"/__modules/${node.source.value}"`
            + s.slice(node.source.end) 
      }
    } else if (asSFCScript && node.type === 'ExportDefaultDeclaration') {
      // export default { xxx } -->
      // let __script; export default (__script = { xxx })
      s = s.slice(0, node.source.start)
        + `let __script; export default (__script = ${
            s.slice(node.source.start, node.declaration.start) 
            })`
        + s.slice(node.source.end) 
      s.overwrite(
        node.start!,
        node.declaration.start!,
        `let __script; export default (__script = `
      )
      s.appendRight(node.end!, `)`)
    }
  })

  return s.toString()
}

处理npm模块

请求的文件如果是 /__modules/ 开头的话,表明是一个 npm 模块

// 精简了部分代码,如果想看完整版建议去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/moduleResolver.ts
import path from 'path'
import resolve from 'resolve-from'
import { sendJSStream } from './utils'
import { ServerResponse } from 'http'

export function resolveModule(id: string, cwd: string, res: ServerResponse) {
  let modulePath: string
  modulePath = resolve(cwd, 'node_modules', `${id}/package.json`)
  if (id === 'vue') {
    // 如果是 vue 模块,返回 vue.runtime.esm-browser.js
    modulePath = path.join(
      path.dirname(modulePath),
      'dist/vue.runtime.esm-browser.js'
    )
  } else {
    // 通过 package.json 文件,找到需要返回的 js 文件
    const pkg = require(modulePath)
    modulePath = path.join(path.dirname(modulePath), pkg.module || pkg.main)
  }

  sendJSStream(res, modulePath)
}

处理 vue 文件
// 精简了部分代码,如果想看完整版建议去 github
// https://github.com/vitejs/vite/blob/a4f093a0c3/src/server/vueCompiler.ts

import url from 'url'
import path from 'path'
import { parse, SFCDescriptor } from '@vue/compiler-sfc'
import { rewrite } from './moduleRewriter'

export async function vueMiddleware(
  cwd: string, req, res
) {
  const { pathname, query } = url.parse(req.url, true)
  const filename = path.join(cwd, pathname.slice(1))
  const content = await fs.readFile(filename, 'utf-8')
  const { descriptor } = parse(content, { filename }) // vue 模板解析
  if (!query.type) {
    let code = ``
    if (descriptor.script) {
      code += rewrite(
        descriptor.script.content,
        true /* rewrite default export to `script` */
      )
    } else {
      code += `const __script = {}; export default __script`
    }
    if (descriptor.styles) {
      descriptor.styles.forEach((s, i) => {
        code += `\nimport ${JSON.stringify(
          pathname + `?type=style&index=${i}`
        )}`
      })
    }
    if (descriptor.template) {
      code += `\nimport { render as __render } from ${JSON.stringify(
        pathname + `?type=template`
      )}`
      code += `\n__script.render = __render`
    }
    sendJS(res, code)
    return
  }
  if (query.type === 'template') {
    // 返回模板
  }
  if (query.type === 'style') {
    // 返回样式
  }
}

经过解析,.vue 文件返回的时候会被拆分成三个部分:script、style、template。

// 解析前




// 解析后
import HelloWorld from "/src/components/HelloWorld.vue";

let __script;
export default (__script = {
    name: "App",
    components: {
        HelloWorld
    }
})

import {render as __render} from "/src/App.vue?type=template"
__script.render = __render

template 中的内容,会被 vue 解析成 render 方法。《Vue 模板编译原理》

import {
  parse,
  SFCDescriptor,
  compileTemplate
} from '@vue/compiler-sfc'

export async function vueMiddleware(
  cwd: string, req, res
) {
  // ...
  if (query.type === 'template') {
    // 返回模板
    const { code } = compileTemplate({
      filename,
      source: template.content,
    })
    sendJS(res, code)
    return
  }
  if (query.type === 'style') {
    // 返回样式
  }
}

[图片上传失败...(image-aaf414-1637751525318)]

而 template 的样式

import {
  parse,
  SFCDescriptor,
  compileStyle,
  compileTemplate
} from '@vue/compiler-sfc'

export async function vueMiddleware(
  cwd: string, req, res
) {
  // ...
  if (query.type === 'style') {
    // 返回样式
    const index = Number(query.index)
    const style = descriptor.styles[index]
    const { code } = compileStyle({
      filename,
      source: style.content
    })
    sendJS(
      res,
      `
  const id = "vue-style-${index}"
  let style = document.getElementById(id)
  if (!style) {
    style = document.createElement('style')
    style.id = id
    document.head.appendChild(style)
  }
  style.textContent = ${JSON.stringify(code)}
    `.trim()
    )
  }
}
复制代码

style 的处理也不复杂,拿到 style 标签的内容,然后 js 通过创建一个 style 标签,将样式添加到 head 标签中。

小结

通过上文解析了 vite 是如何拦截请求,然后返回需要的文件的过程;我们大概了解了vite是如何提高本地开发速度;接下来说一下 Vite 热更新的实现

HMR 处理机制

实现热更新,那么就需要浏览器和服务器建立某种通信机制,这样浏览器才能收到通知进行热更新。Vite 的是通过 WebSocket 来实现的热更新通信。

客户端

客户端的代码在 src/client/client.ts,主要是创建 WebSocket 客户端,监听来自服务端的 HMR 消息推送。

Vite 的 WS 客户端目前监听这几种消息:

  • connected: WebSocket 连接成功

  • vue-reload: Vue 组件重新加载(当你修改了 script 里的内容时)

  • vue-rerender: Vue 组件重新渲染(当你修改了 template 里的内容时)

  • style-update: 样式更新

  • style-remove: 样式移除

  • js-update: js 文件更新

  • full-reload: fallback 机制,网页重刷新

image
服务端

核心是监听项目文件的变更,然后根据不同文件类型(目前只有 vuejs)来做不同的处理:

watcher.on('change', async (file) => {
  const timestamp = Date.now() // 更新时间戳
  if (file.endsWith('.vue')) {
    handleVueReload(file, timestamp)
  } else if (file.endsWith('.js')) {
    handleJSReload(file, timestamp)
  }
})

//  简单的源码分析如下:
//  以 vue 文件处理
async function handleVueReload(
    file: string,
    timestamp: number = Date.now(),
    content?: string
) {
  const publicPath = resolver.fileToRequest(file) // 获取文件的路径
  const cacheEntry = vueCache.get(file) // 获取缓存里的内容

  debugHmr(`busting Vue cache for ${file}`)
  vueCache.del(file) // 发生变动了因此之前的缓存可以删除

  const descriptor = await parseSFC(root, file, content) // 编译 Vue 文件

  const prevDescriptor = cacheEntry && cacheEntry.descriptor // 获取前一次的缓存

  if (!prevDescriptor) {
    // 这个文件之前从未被访问过(本次是第一次访问),也就没必要热更新
    return
  }

  // 设置两个标志位,用于判断是需要 reload 还是 rerender
  let needReload = false
  let needRerender = false

  // 如果 script 部分不同则需要 reload
  if (!isEqual(descriptor.script, prevDescriptor.script)) {
    needReload = true
  }

  // 如果 template 部分不同则需要 rerender
  if (!isEqual(descriptor.template, prevDescriptor.template)) {
    needRerender = true
  }

  const styleId = hash_sum(publicPath)
  // 获取之前的 style 以及下一次(或者说热更新)的 style
  const prevStyles = prevDescriptor.styles || []
  const nextStyles = descriptor.styles || []

  // 如果不需要 reload,则查看是否需要更新 style
  if (!needReload) {
    nextStyles.forEach((_, i) => {
      if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
        send({
          type: 'style-update',
          path: publicPath,
          index: i,
          id: `${styleId}-${i}`,
          timestamp
        })
      }
    })
  }

  // 如果 style 标签及内容删掉了,则需要发送 `style-remove` 的通知
  prevStyles.slice(nextStyles.length).forEach((_, i) => {
    send({
      type: 'style-remove',
      path: publicPath,
      id: `${styleId}-${i + nextStyles.length}`,
      timestamp
    })
  })

  // 如果需要 reload 发送 `vue-reload` 通知
  if (needReload) {
    send({
      type: 'vue-reload',
      path: publicPath,
      timestamp
    })
  } else if (needRerender) {
    // 否则发送 `vue-rerender` 通知
    send({
      type: 'vue-rerender',
      path: publicPath,
      timestamp
    })
  }
}

客户端逻辑注入

代码里并没有引入 HRMclient 代码,Vite 是如何把 client 代码注入的呢??

回到上面的一张图,Vite 重写 index.html 文件的内容并返回时:

入口注入client.js文件

image
image

app.vue引入css文件

image

vue文件 新增class变更后,热更新代码变更差异

image

热更新的具体怎么替换模块???

到此,热更新的整体流程已经解析完毕

后续

依据原理手写实现自己的Vite -- lvite

你可能感兴趣的:(前端技术分享)