Vite2.X 揭秘,可否斩青龙?

912 阅读12分钟

大致运转流程

大致内容直接用一张PPT图来大致描绘。下文会附上各个阶段的代码解释及外链,所以最后在看吧

image.png

构建过程

依赖预构建

步骤

  1. 依赖搜寻替换:将裸模块(bare import)路径替换为相对路径
why: 浏览器无法识别ES裸模块导入,(本地可以是因为Node.js环境)
  1. Commonjs/UMD模块。转译成ES模块。基于esbuild
  2. 聚合常用ES多文件库,如lodash-es。否则会存在几百个文件一起请求
// before
import React from 'react' // 非相对路径,所以无法识别

// after
import __vite__cjsImport0_react from "/node_modules/.vite/react.js?v=432aac16"

依赖预构建 - 源码解读

预编译入口函数

Github链接: 构建入口

// vite1.0基于koa架构开发,但是在vite2.0改用httpserver。。充分的利用已有工具
if (!middlewareMode && httpServer) {
    const listen = httpServer.listen.bind(httpServer)
    //	重写listen,确保server启动之前执行。
    httpServer.listen = (async (port, ...args) => {
      try {
        // vite plugin集合体,统一触发buildStart生命周期调用
        await container.buildStart({})
        await runOptimize() //	预构建
      } catch (e) {}
      return listen(port, ...args)
    })   
} else {
  ....
}

预编译核心函数

Github链接:预编译入口

步骤:

  1. 缓存对比,决定是否重新构建
  2. 扫描入口,获取文件依赖Map。deps 形如: {"lodash-es": "node_modules/lodash-es"}
  3. 基于 es-module-lexer(ES文件词法解析)打平依赖Map。
  4. 调用 esbuild 编译,并将缓存写入_metadata.json
// 预编译主函数入口
function optimizeDeps( config, force=config.server.force,asCommand=false,newDeps?) {
  ...   
  
  ----------------------------------------------------------------------------
  /**   第一步: 比较上次预构建信息(_metadata.json)并决定是否重新构建    */
  ----------------------------------------------------------------------------
  // 前后比对hash,相同则直接返回
  if (prevData && prevData.hash === data.hash) {
    return prevData
  }
  ...
  
  ----------------------------------------------------------------------------
  /**   第二步: 扫描源码,或根据参数newDeps,获取依赖    */
  ----------------------------------------------------------------------------
   // newDeps参数是在服务启动后加入依赖时传入的依赖信息。
  let deps
  if (!newDeps) {
     //	借助esbuild扫描源码,获取依赖
    ;({ deps, missing } =  await scanImports(config))
  } else {
    deps = newDeps
    missing = {}
  }
 ...
  
  ----------------------------------------------------------------------------
  /**   第三步: 利用es-module-lexer扁平化嵌套的源码依赖    */
  ----------------------------------------------------------------------------  
  // 扁平化依赖
  await init
  for (const id in deps) {
    flatIdDeps[flatId] = deps[id]
    const entryContent = fs.readFileSync(deps[id], 'utf-8')
    const exportsData = parse(entryContent) as ExportsData 
    ... 
  }
  ...
    
  ----------------------------------------------------------------------------
  /**   第四步: 解析用户依赖优化配置,调用esbuild构建文件,并存入cacheDir    */
  ----------------------------------------------------------------------------  
  // 加入用户指定的esbuildOptions
  const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}

  //	调用esbuild.build打包文件
  const result = await build({
    ...
    entryPoints: Object.keys(flatIdDeps),// 入口
    format: 'esm',// 打包成esm模式
    external: config.optimizeDeps?.exclude,// 剔除exclude文件
    outdir: cacheDir,// 输出地址
  })
  
  //  重新写入_metadata.json
  for (const id in deps) {
    const entry = deps[id]
    data.optimized[id] = {
      file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
      src: entry,
    }
  }

  writeFile(dataPath, JSON.stringify(data, null, 2))

  return data
}

静态资源处理

浏览器虽然支持了ES Module,但是webpack告诉我们: Import anything u want,奈何浏览器看不懂import css、img

HTML模版解析

Github链接:入口

  1. 所有http请求,会经过createDevHtmlTransformFn函数统一处理
  2. vite内置插件vite:css-post 将css文件转译成
/**  createServer函数  */
server.transformIndexHtml = createDevHtmlTransformFn(server)

/**  createDevHtmlTransformFn函数  */
const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins) // 插件钩子函数前后处理

css

官方描述:导入 .css 文件将会把内容插入到 标签中,同时也带有 HMR 支持。也能够以字符串的形式检索处理后的、作为其模块默认导出的 CSS

步骤:

  1. server获取资源请求URL
  2. 判断是否为css文件,若是则通过Vite内置预处理插件:vite:css 修改返回样式文件如下
  3. 将修改后的文件,返回浏览器
/** 转译后的css文件,其实是js执行逻辑 */
import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/App.scss"); // HMR
import { updateStyle, removeStyle } from "/@vite/client"

const id = ".../src/App.scss"
const css = "XXXX"
updateStyle(id, css)

// 下面都是HMR相关,下面介绍,先不用关注
import.meta.hot.accept() 
export default css
import.meta.hot.prune(() => removeStyle(id))


/** 其中updateStyle函数,主要功能如下。通过js方式将css文件写入head */
updateStyle() {
	...
  const style = new CSSStyleSheet()
  style = document.createElement('style')
  style.setAttribute('type', 'text/css')
  style.innerHTML = content // content由esbuild处理得到
  document.head.appendChild(style)
  ...
}

scss、less、stylus等样式文件

快速跳转看源码:传送门

相较于css处理,多了一步基于sass、less等三方库的语法转译工作,其余步骤与css处理方式完全一致,甚至官网上写着

没有必要为它们安装特定的 Vite 插件

# .scss and .sass
npm install -D sass

JSX、TS、TSX

基于esbuild将Ts文件转译成js,是否有些好奇,为什么每次发起资源请求后,在执行编译却依旧能这么快?esbuild到底是什么魔鬼?

  1. Go语言编写,编译型语言机器可以直接执行。而JS属于解释型语言
  2. Go语言,并发无忧,且线程之间共享内存。JS只能序列化传递,反序列化读取

根据 esbuild 的作者的测试,垃圾回收机制似乎将 JavaScript 的工作线程的并行处理能力减少了一半,可能是因为你的一半 CPU 核心忙于为另一半收集垃圾

  1. 相比于TSC,esbuild只进行了3次AST遍历的次数,减少内存占用。

image.png

其它资源

如图片、json则直接通过URL导入

插件体系设计

Vite 可以使用插件进行扩展,这得益于 Rollup 优秀的插件接口设计和一部分 Vite 独有的额外选项。这意味着 Vite 用户可以利用 Rollup 插件的强大生态系统,同时根据需要也能够扩展开发服务器和 SSR 功能。

前提:其中蓝色标为Vite自定义的hook(5个)、红色标为Rollup内置Hook(7个)。且从左到右分别是执行的顺序

image.png

仅个人观点:Vite借助Rollup插件体系丰富自己的生态。官方解释Rollup更适合ES Module

  1. 生产环境构建使用ESModule并不合理,实际效果可能不如Bundle,所以需要借助目前主流的一些构建工具。如Rollup、webpack等。
  2. 配置简单、构建体积更小,加上目标就是替代webpack。所以选择Rollup并不奇怪

插件实现伪代码

  1. resolveConfig:插件解析,分类,初始化PluginContainer插件管理模块

  2. resolvePlugins:加入Vite内置的插件,如vite:css 等

  3. createPluginContainer:插件统一出口,执行对应hook,则将预分类后插件所有钩子函数按顺序执行

async function resolveConfig(inlineConfig, ...args) {
	let config = inlineConfig; // 配置参数对象

	...

	//  既然有flattern plugins数组,那就可以随意发挥[[pulginA,pulginB],pulginC]
	//  筛选应用apply设置应用场景(serve|build)的插件
	const rawUserPlugins = (config.plugins || []).flat().filter((p) => {
		return p && (!p.apply || p.apply === command);
	});

	//  sortUserPlugins函数根据enforce字段对插件进行排序,enforce可以填pre、 post
	const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawUserPlugins);

	//  执行plugin.config hook,可再次设置配置参数
	const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins];
	for (const p of userPlugins) {
		if (p.config) {
			const res = await p.config(config, configEnv);
			if (res) {
				config = mergeConfig(config, res);
			}
		}
	}

	//  利用createPluginContainer函数创建一个用于特殊场景的内部解析器,比如解析css @import ,预构建依赖等
	const createResolver = (options) => {
		return async (id, importer, aliasOnly) => {
			let container = await createPluginContainer(...args);
			return (await container.resolveId(id, importer))?.id;
		};
	};

	//  最终参数配置对象
	const resolved = {
		//  ...其他配置
		plugins: userPlugins,
		createResolver,
	};

	//  resolvePlugins函数添加vite内部插件,使完成各功能开箱即用。
	//  resolvePlugins函数往下会讲解,源码:https://github.com/vitejs/vite/blob/5745a2e8072cb92d647662dc387e7f12b2841cab/packages/vite/src/node/plugins/index.ts#L18
	resolved.plugins = await resolvePlugins(resolved, prePlugins, normalPlugins, postPlugins);

	//  执行plugin.configResolved hook
	await Promise.all(userPlugins.map((p) => p.configResolved?.(resolved)));

	// 省略以下
}

plugin container

export async function createPluginContainer({ plugins, rollupOptions }: ResolvedConfig) {
//省略部分代码
  ... 

  const container = {
    //	注意,options是一个立即调用函数,你的插件的options hook可以直接是配置对象,也可以是返回配置对象的函数
    options: await (async () => {
      for (const plugin of plugins) {
        if (!plugin.options) continue;
        options = (await plugin.options(...args)) || options;
      }
    })(),

    async buildStart() {
      await Promise.all(
        plugins.map((plugin) => {
          if (plugin.buildStart) {
            return plugin.buildStart.call(new Context(plugin), ...args);
          }
        }),
      );
    },

    // resolveId
    async resolveId(...args) {
      const ctx = new Context();
      for (const plugin of plugins) {
        if (!plugin.resolveId) continue;
        const result = await plugin.resolveId.call(ctx, ...args);
        if (!result) continue;
      }
    },

    // load
    async load(...args) {
      for (const plugin of plugins) {
        if (!plugin.load) continue;
        const result = await plugin.load.call(ctx, ...args);
      }
    },

    // transform
    async transform(...args) {
      const ctx = new TransformContext(...args);
      for (const plugin of plugins) {
        result = await plugin.transform.call(ctx, ...args);
      }
    },

    watchChange(...args) {
      const ctx = new Context();
      for (const plugin of plugins) {
        if (!plugin.watchChange) continue;
        plugin.watchChange.call(ctx, ...args);
      }
    },

    // buildEnd && closeBundle
    async close() {
      if (closed) return;
      const ctx = new Context();
      await Promise.all(plugins.map((p) => p.buildEnd && p.buildEnd.call(ctx)));
      await Promise.all(plugins.map((p) => p.closeBundle && p.closeBundle.call(ctx)));
      closed = true;
    },
  };

  return container;
}

HMR

释义

  1. hot module refresh:监听文件变化,重新编译文件并刷新整个页面
  2. hot module replacement: 模块热替换,只更新对应文件

hmr过程

HTML代码中能看到:

  • 服务端监听文件变化,发送websocket消息,告诉客户端变化类型,变化文件等。(chokidar)
  • 客户端接受到消息,根据消息内容决定重新刷新页面还是重新加载变化文件,并执行预设的hmr hook函数。( 如上文css处理中介绍到的updateStyle )
/** 入口文件 createServer */
if (!middlewareMode || middlewareMode === 'html') {
  // transform index.html
  middlewares.use(indexHtmlMiddleware(server))
  ...
}
  
/** 文件位置:src/node/server/middlewares/indexHtml.ts */
const devHtmlHook: IndexHtmlTransformHook = async (
  html,
  { path: htmlPath, server, originalUrl }
) => {
  // 处理一些缓存标识位等信息
  ...

  return {
    html,
    tags: [
      {
        tag: 'script',
        attrs: {
          type: 'module',
          // CLIENT_PUBLIC_PATH就是/@vite/client
          src: path.posix.join(base, CLIENT_PUBLIC_PATH) 
        },
        injectTo: 'head-prepend'
      }
    ]
  }
}

客户端响应逻辑

源码链接

  1. 根据type,判断刷新页面、还是更新文件
  2. 若css等资源文件,则直接修改link href
  3. 若JS等执行文件。则先执行hook。包含旧文件注销(disposer)。然后通过import加载文件
// @vite/client
socket.addEventListener('message', async ({ data }) => {
  handleMessage(JSON.parse(data))
})

// CSS等资源文件
if(css) {
	const el = (
    [].slice.call(
        document.querySelectorAll(`link`)
    )
  ).find((e) => e.href.includes(path))
  if (el) {
      const newPath = `${path}${
      path.includes('?') ? '&' : '?'
      }t=${timestamp}`
      el.href = new URL(newPath, el.href).href
  }
}

// JS等执行文件
if(js) {
 ...
 await Promise.all(
    Array.from(modulesToUpdate).map(async (dep) => {
      const disposer = disposeMap.get(dep)
      // 注销旧文件的影响
      if (disposer) await disposer(dataMap.get(dep))
      const [path, query] = dep.split(`?`)
      try {
        const newMod = await import(
          /* @vite-ignore */
          base +
            path.slice(1) +
            `?import&t=${timestamp}${query ? `&${query}` : ''}`
        )
        moduleMap.set(dep, newMod)
      } catch (e) {
        warnFailedFetch(e, dep)
      }
    })
  )
}

HMR对比Webpack

  1. 依赖关系的建立:Webpack 在 browser 运行时记录,vite 在服务侧编译时记录;这和构建策略有关,打包后的资源全量的加载,按需使用
  2. Module 更新:Webpack 直接替换本地缓存的模块(即删除掉)。而 vite 是直接请求新的模块内容并使用新的模块。
  3. Webpack 编译流程前置,vite 编译流程后置且按需编译;
  4. Webpack 使用 JSONP 请求新的编译生成的模块。vite 直接使用 ESM import 动态加载发生变更的模块内容。读取 export 导出的内容。

源码小结

  • 有限的人力,放在刀刃上。
    • 比如:Vite成功的桥接各种前端工具(sass、esbuild、es-module-lexer)
    • Vite2.0放弃使用koa,转而投入Http怀抱。让浏览器来判断资源是否缓存,如依赖通过设置max-age = 31536000, immutable
  • 诸如静态资源处理这块,与webpack的思路基本一致。充分借鉴

现在

性能

原先,由于TCP启动慢、浏览器不支持并发的原因,所以性能优化中重要的一步就是合并请求。所以出现了webpack等打包工具。目前由于http2、浏览器模块化的发展是否一劳永逸?

数据来源于:Performance recommendations

前提:300个模块。对比压缩前后,耗时

image.png

尽管原生 ESM 现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking、懒加载和 chunk 分割(以获得更好的缓存)。 - 引用自Vite官网

本地测试

入口文件导入50个独立ts文件(每个文件仅20行),相比于脚手架Demo项目启动,耗时增长91%(基于http1.1)

image.png

image.png

Http2.0是否能加速

本地基于Http1.1传输,是否因为浏览器6个Tcp连接限制导致效率不行呢? - 文章主题为Vite,所以移除Http发展历程介绍,仅保留结论

结论:本地开发中确实提速不少。但是由于笔者目前的项目属于多SPA的大型应用,首屏访问需要的文件都是千级别。所以尽管快,实际首屏资源请求耗时依旧接近1min,这对于开发者来说有些unbearable

那么实际生产上的效果如何呢?但是笔者并没有在生产环境使用Vite,只能通过一些文章以偏概全

  1. 目前浏览器实现上,对高度模块化的web app并没有做的很好。仅传输客户端未缓存的文件有点困难

web servers’ and browsers’ implementations are not currently optimized towards highly-modularized web app use cases. It’s hard to only push the resources that the user doesn’t already have cached - Javascript module-use http/2

  1. 因为TCP的拥塞控制(congestion control)。使得单链接的Http/2很难比Http/1.1快一大截。甚至在低速丢包率较高的情况下(无需担心,通常有线网络下丢包率在0.01%),表现还不如6个连接的Http/1.1。 而且事实上意外的发现。HTTP/2 目前部署在浏览器和服务器中,在大多数情况下通常与 HTTP/1.1 一样快或略快

HTTP/2 as it is currently deployed in browsers and servers is typically as fast or slightly faster than HTTP/1.1 in most conditions. This is in my opinion partly because websites got better at optimizing for HTTP/2 - HOL blocking in HTTP/2 over TCP

其他工具

snowPack

  • 思路与Vite基本一致,且Vite的预构建也是受到了snowpack v1的启发
  • 除去官网所说的差异点 ,个人使用上能够发现,Vite已经融合Rollup作为生产打包工具,所有的功能开箱即用。但是Snowpack需要开发者自己搭建引入,有些支离破碎的体验感(这块体感来源于2020年10月V2版本,可能有些久了)

prepack

  • A tool for making JavaScript code run faster.
  • 将运行时损耗的性能,提前到编译阶段将代码进行等价转换,其内置了一个完整的JS解释器,在一个独立环境(prepack js环境 而非 当前Node环境)中执行代码。
  • 基于上面的环境,延展宿主环境与prepack执行环境交换 related issues

@web/dev-server

  • 不过多介绍,感兴趣可以点击上方链接

写在最后

  1. Vite期望完全替换Webpack:

image.png

对这个问题,笔者按目前的基建速度并不太乐观。或许是中小型项目能够很快捷的借助Vite进行开发。但是对于大型应用来说,JS Module方式带来的首屏场景下,大量资源请求造成的网络往返耗时令开发者无法接受(实测:巨型项目基于Vite启动需要请求3500+文件,即使用上Http2.0首屏加载依旧耗时80秒)。但是对于大型应用来说抛去了开发环境的快捷。那Vite还具有什么优势呢?

  1. Vite在非巨石应用的开发环境下启动速度毋庸置疑,借助了ESBuild、ES module特性具有盖过webpack的风头。但是在生产环境构建上,基于Rollup打包。。。差距还好
  2. HTTP2.0甚至3.0的升级,协议层面在积极解决传输效率问题。硬件层面带宽的拓展也会不断加速。都会使得Es module会越来越主流,个人也希望新技术能够带来更多的可能性

参考文档

es-module-lexer

Vite2.0 正式发布,凭什么吊打 webpack ?

Vite官方中文文档

prepack

A Future Without Webpack

Performance recommendations

snowPack

prepack-gentel-intro-1

Head-of-Line Blocking in QUIC and HTTP/3: The Details

ESBuild快在哪里