返回 blog
2022年10月24日
7 分钟阅读

vite-plugin-html原理

注入tags

原理

vph内部调用 transformIndexHtml返回 tags即可实现tags注入。这个功能是 vph最简单的功能

vph注入tag

import { defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'

export defineConfig({
	plugins: [
		createHtmlPlugin({
			inject: {
				tags: [
					{
						injectTo: 'body',
						tag: 'div',
						attrs: {
							id: 'tag'
						}
					}
				]
			}
		})
	]
})

vite注入tag

注入tags至模板中,是vite提供的功能,抛开 vph,单纯用 vite

import { defineConfig } from 'vite'

export defineConfig({
	plugins: [
		{
			name: 'vite:injectTags',
			transformIndexHtml(html) {
				return {
					html,
					tags: [
						{
							injectTo: 'body',
							tag: 'div',
							attrs: {
								id: 'tag'
							}
						}
					]
				}
			}
		}
	]
})

压缩html

这个功能是 html-webpack-plugin 有的,照抄了思路

原理

使用 [html-minifier-terser](https://github.com/terser/html-minifier-terser)压缩html

html-webpack-plugin 是如何压缩的?

// 从函数名可以推断出来此函数是在最后执行
function postProcessHtml (html, assets, assetTags) {
	if (typeof html !== 'string') {
		return Promise.reject(new Error(`Expected html to be a string but got ${  JSON.stringify(html)}`));
	}
	const htmlAfterInjection = options.inject
		? injectAssetsIntoHtml(html, assets, assetTags)
		: html;
	const htmlAfterMinification = minifyHtml(htmlAfterInjection);
	return Promise.resolve(htmlAfterMinification);
}

function minifyHtml (html) {
	if (typeof options.minify !== 'object') {
		return html;
	}
	try {
		// 核心代码
		// 调用压缩插件的api即可
		return require('html-minifier-terser').minify(html, options.minify);
	} catch (e) {
		const isParseError = String(e.message).indexOf('Parse Error') === 0;
		if (isParseError) {
			e.message = `html-webpack-plugin could not minify the generated output.\n` +
					`In production mode the html minifcation is enabled by default.\n` +
					`If you are not generating a valid html output please disable it manually.\n` +
					`You can do so by adding the following setting to your HtmlWebpackPlugin config:\n|\n|` +
					`    minify: false\n|\n` +
					`See https://github.com/jantimon/html-webpack-plugin#options for details.\n\n` +
					`For parser dedicated bugs please create an issue here:\n` +
					`https://danielruf.github.io/html-minifier-terser/` +
				`\n${  e.message}`;
		}
		throw e;
	}
}

vph 是如何压缩的?

import { minify as minifyFn } from 'html-minifier-terser'

export async function minifyHtml(
  html: string,
  minify: boolean | MinifyOptions,
) {
  if (typeof minify === 'boolean' && !minify) {
    return html
  }

  let minifyOptions: boolean | MinifyOptions = minify

  if (typeof minify === 'boolean' && minify) {
    minifyOptions = getOptions(minify)
  }

  return await minifyFn(html, minifyOptions as MinifyOptions)
}

export function createMinifyHtmlPlugin({
  minify = true,
}: UserOptions = {}): PluginOption {
  return {
    name: 'vite:minify-html',
		// vph源码这段代码注释了,因为这段代码是无效的。
		// generateBundle 本身就在build阶段执行
    // apply: 'build',
    enforce: 'post',
		// rollup hook
		// 打包的时候执行(生成打包文件,但还没输出打包)
    async generateBundle(_, outBundle) {
      if (minify) {
        for (const bundle of Object.values(outBundle)) {
          if (
            bundle.type === 'asset' &&
            htmlFilter(bundle.fileName) &&
            typeof bundle.source === 'string'
          ) {
						// 直接修改bundle的源码,修改为最小化后的代码
            bundle.source = await minifyHtml(bundle.source, minify)
          }
        }
      }
    },
  }
}

支持ejs

原理

在vite处理indexHtml之前,使用 [ejs](https://ejs.co/#install)render转化代码

html-webpack-plugin

没有集成ejs,而是用的 lodash 做内容替换

const _ = require('lodash');

const template = _.template(source, { interpolate: /<%=([\s\S]+?)%>/g, variable: 'data', ...options });

vite-plugin-html

import { render } from 'ejs'

{
	// 在transformIndexHtml hook 时使用ejs渲染html模板
	transformIndexHtml: {
		// 强制前置transformIndexHtml,在vite内置transformIndexHtml钩子之前执行,避免vite识别不了ejs的语法而报错
		enforce: 'pre',
		async transform(html, ctx) {
			const url = ctx.filename
			const base = viteConfig.base
			const excludeBaseUrl = url.replace(base, '/')
			const htmlName = path.relative(process.cwd(), excludeBaseUrl)
		
			const page = getPage(userOptions, htmlName, viteConfig)
			const { injectOptions = {} } = page
			// 核心代码
			const _html = await renderHtml(html, {
				injectOptions,
				viteConfig,
				env,
				entry: page.entry || entry,
				verbose,
			})
			const { tags = [] } = injectOptions
			return {
				html: _html,
				tags: tags,
			}
		},
	},
}

export async function renderHtml(
  html: string,
  config: {
    injectOptions: InjectOptions
    viteConfig: ResolvedConfig
    env: Record<string, any>
    entry?: string
    verbose?: boolean
  },
) {
  const { injectOptions, viteConfig, env, entry, verbose } = config
  const { data, ejsOptions } = injectOptions

  const ejsData: Record<string, any> = {
    ...(viteConfig?.env ?? {}),
    ...(viteConfig?.define ?? {}),
    ...(env || {}),
    ...data,
  }

	// 核心代码
  let result = await render(html, ejsData, ejsOptions)

  if (entry) {
    result = removeEntryScript(result, verbose)
    result = result.replace(
      bodyInjectRE,
      `<script type="module" src="${normalizePath(
        `${entry}`,
      )}"></script>\n</body>`,
    )
  }
  return result
}

多页

server时

vite只支持约定式的server多页面,所以需要在服务器中间件处理一下多页

import history from 'connect-history-api-fallback'

configureServer(server) {
	let _pages: { filename: string; template: string }[] = []
	const rewrites: { from: RegExp; to: any }[] = []
	if (!isMpa(viteConfig)) {
		const template = userOptions.template || DEFAULT_TEMPLATE
		const filename = DEFAULT_TEMPLATE
		_pages.push({
			filename,
			template,
		})
	} else {
		_pages = pages.map((page) => {
			return {
				filename: page.filename || DEFAULT_TEMPLATE,
				template: page.template || DEFAULT_TEMPLATE,
			}
		})
	}
	const proxy = viteConfig.server?.proxy ?? {}
	const baseUrl = viteConfig.base ?? '/'
	const keys = Object.keys(proxy)

	let indexPage: any = null
	for (const page of _pages) {
		if (page.filename !== 'index.html') {
			rewrites.push(createRewire(page.template, page, baseUrl, keys))
		} else {
			indexPage = page
		}
	}

	// ensure order
	if (indexPage) {
		rewrites.push(createRewire('', indexPage, baseUrl, keys))
	}
	
	// 核心代码。使用 connect-history-api-fallback 做了中间件代理,避免404
	server.middlewares.use(
		history({
			disableDotRule: undefined,
			htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
			rewrites: rewrites,
		}),
	)
},

connect-history-api-fallback

SPA项目只有一个html,只是通过js动态修改html(通常是index.html)的内容,无论我们如何修改url,请求都是指向的index.html。如果我们希望不止一个html,如other.html,那么如果我们请求other.html将会404。 这个插件就是为了解决这个404,在后端代理一些前端路由,比如 当我们请求 other.html,请求将不再指向 index.html(本来是所有请求都指向index.html的)

build时

添加rollup多入口

config(conf) {
	const input = createInput(userOptions, conf as unknown as ResolvedConfig)

	if (input) {
		return {
			build: {
				rollupOptions: {
					input,
				},
			},
		}
	}
},

处理打包后dist中文件

async closeBundle() {
	const outputDirs: string[] = []

	if (isMpa(viteConfig) || pages.length) {
		for (const page of pages) {
			const dir = path.dirname(page.template)
			if (!ignoreDirs.includes(dir)) {
				outputDirs.push(dir)
			}
		}
	} else {
		const dir = path.dirname(template)
		if (!ignoreDirs.includes(dir)) {
			outputDirs.push(dir)
		}
	}
	const cwd = path.resolve(viteConfig.root, viteConfig.build.outDir)
	const htmlFiles = await fg(
		outputDirs.map((dir) => `${dir}/*.html`),
		{ cwd: path.resolve(cwd), absolute: true },
	)

	await Promise.all(
		htmlFiles.map((file) =>
			fs.move(file, path.resolve(cwd, path.basename(file)), {
				overwrite: true,
			}),
		),
	)

	const htmlDirs = await fg(
		outputDirs.map((dir) => dir),
		{ cwd: path.resolve(cwd), onlyDirectories: true, absolute: true },
	)
	await Promise.all(
		htmlDirs.map(async (item) => {
			const isEmpty = await isDirEmpty(item)
			if (isEmpty) {
				return fs.remove(item)
			}
		}),
	)
},

自定义entry入口文件

在transformIndexHtml阶段,判断如果自定义entry,则把模板中默认的entry代码删除 removeEntryScript。然后添加自定义的entry到body script标签中

if (entry) {
	result = removeEntryScript(result, verbose)
	result = result.replace(
		bodyInjectRE,
		`<script type="module" src="${normalizePath(
			`${entry}`,
		)}"></script>\n</body>`,
	)
}

function removeEntryScript(html: string, verbose = false) {
	if (!html) {
		return html
	}

	const root = parse(html)
	const scriptNodes = root.querySelectorAll('script[type=module]') || []
	const removedNode: string[] = []
	scriptNodes.forEach((item) => {
		removedNode.push(item.toString())
		item.parentNode.removeChild(item)
	})
	verbose &&
		removedNode.length &&
		consola.warn(`vite-plugin-html: Since you have already configured entry, ${dim(
			removedNode.toString(),
		)} is deleted. You may also delete it from the index.html.
        `)
	return root.toString()
}

自定义html template

vite默认的template是根目录下的index.html

server时

因为template影响了在server时的后端服务指向,所以需要使用 connect-history-api-fallback配置一下代理

build时

修改rollup的input构建选项,可以实现自定义模板。 例如:

input: {
	'index': 'index.html',
	'other': 'other.html'
}
config(conf) {
	// 修改rollup的input选项
	const input = createInput(userOptions, conf as unknown as ResolvedConfig)

	if (input) {
		return {
			build: {
				rollupOptions: {
					input,
				},
			},
		}
	}
}

export function createInput(
  { pages = [], template = DEFAULT_TEMPLATE }: UserOptions,
  viteConfig: ResolvedConfig,
) {
  const input: Record<string, string> = {}
  if (isMpa(viteConfig) || pages?.length) {
    const templates = pages.map((page) => page.template)
    templates.forEach((temp) => {
      let dirName = path.dirname(temp)
      const file = path.basename(temp)

      dirName = dirName.replace(/\s+/g, '').replace(/\//g, '-')

      const key =
        dirName === '.' || dirName === 'public' || !dirName
          ? file.replace(/\.html/, '')
          : dirName
      input[key] = path.resolve(viteConfig.root, temp)
    })

    return input
  } else {
    const dir = path.dirname(template)
    if (ignoreDirs.includes(dir)) {
      return undefined
    } else {
      const file = path.basename(template)
      const key = file.replace(/\.html/, '')
      return {
        [key]: path.resolve(viteConfig.root, template),
      }
    }
  }
}